From 4ca7cef9857573bc517621e34f8fb55d3b589cd0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 02:40:50 +0000 Subject: [PATCH 001/217] =?UTF-8?q?Docs:=20=E6=8B=85=E5=BD=93=201=20?= =?UTF-8?q?=E5=9B=9E=E3=81=AE=E8=B5=B7=E5=8B=95=E3=81=AE=E7=B5=90=E6=9C=AB?= =?UTF-8?q?=E3=82=92=E5=85=B1=E9=80=9A=E3=81=AE=E8=AA=9E=E5=BD=99=E3=81=A7?= =?UTF-8?q?=E8=AA=AD=E3=82=80=E8=A8=AD=E8=A8=88=EF=BC=88#729=20#619=20#584?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 親 #729 の設計文書と要求を新設し、結果なしの判断と起動し直しの可否を 共通層 monitor_outcome へ移す契約を決める。利用上限(usage_limit)と CLI の上限(cli_timeout)の語彙と検知の文言、cross-review の判定が可否を 読む形、cross-refactoring の read_result が従う契約(実装は #728)を持つ。 既存の設計(PR #666)の P3 は対応表で指し、要求と契約の文書に案内を足す。 Co-Authored-By: Claude Fable 5.1 --- ...62-598-537-619-584-583-design-contracts.md | 2 + ...ue-662-598-537-619-584-583-requirements.md | 2 + issues/issue-729-619-584-design.md | 500 ++++++++++++++++++ issues/issue-729-619-584-requirements.md | 232 ++++++++ 4 files changed, 736 insertions(+) create mode 100644 issues/issue-729-619-584-design.md create mode 100644 issues/issue-729-619-584-requirements.md diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index f322a801..88ff805f 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -122,6 +122,8 @@ classDiagram ### 理由の語彙 +**P3 の語彙と「P3 で足す文言」は [issue-729-619-584-design.md](issue-729-619-584-design.md) の「データ構造」「入出力の契約」へ移した。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 + **監視が書く理由**(`monitor_outcome.REASONS`): | 理由 | 状態 | 入る Pull Request | 何が起きたか | diff --git a/issues/issue-662-598-537-619-584-583-requirements.md b/issues/issue-662-598-537-619-584-583-requirements.md index a3d2bb26..067be26e 100644 --- a/issues/issue-662-598-537-619-584-583-requirements.md +++ b/issues/issue-662-598-537-619-584-583-requirements.md @@ -157,6 +157,8 @@ ## 受け入れ条件(P3: #619 + #584 + #583 結果が失われる) +**この節は置き換えられた。** #619 / #584(AC50〜AC62、AC68〜AC69)は [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) が、#583(AC63〜AC67)は #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 + 文言と見るファイルの一覧は、契約の文書の「P3 で足す文言」にある。 理由を区別する(#619 / #598 の CLI の上限): diff --git a/issues/issue-729-619-584-design.md b/issues/issue-729-619-584-design.md new file mode 100644 index 00000000..0c21eb59 --- /dev/null +++ b/issues/issue-729-619-584-design.md @@ -0,0 +1,500 @@ +# #729 / #619 / #584: 結末の語彙を共通層で読み、起動し直しの可否を共通層が返す + +要求と受け入れ条件は [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) にある。この文書は「どう作るか」だけを扱う。 + +**この文書は既存の設計 [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md) の P3 を置き換える。** 既存の決定のうち引き継ぐものと変えるものは「既存の設計との対応」にある。#583(決定 18・19)は #730 の設計へ移る。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 利用上限の文言を検知し、理由 `usage_limit` として残す | 監視。進行側と利用者が理由を読む | +| F2 | CLI 自身の上限で結果を書かずに終わった担当を、理由 `cli_timeout` として残す | 同上 | +| F3 | 結果ファイルの有無・読めるかと監視の結末を 1 つの値として読み、起動し直しの可否を添える | cross-review の取り込み(`read-result`)と cross-refactoring の取り込み(G4) | +| F4 | 起動し直しても解けない結末の担当を、同じラウンドで起動し直さずに止めて理由を報告する | cross-review の判定(`judge`) | +| F5 | 監視が止めた担当の子プロセスに、止めた後に結果を書かせない | 収束ループ | + +## 決定の記録 + +### 決定 1: 設計文書は親 #729 の名前で新設し、既存の設計文書の本体は書き換えない + +既存の設計は 6 課題・3 本の Pull Request を 1 つの文書で扱い、P1 と P2 は配布済みである。P3 の節をその場で書き換えると、配布済みの決定と新しい決定が 1 つの差分に混ざり、承認する人が「何が変わるのか」を読み分けられない。親 #729 は根本原因の場所(共通層)を定め直しており、既存の決定 14(判定が `usage_limit` を見る)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** + +既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。1 行でも触ると、既存の 20 件の決定がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求の文書と契約の文書にだけ足す。 + +### 決定 2: 結果なしの判断と起動し直しの可否を、共通層 `monitor_outcome` の 1 つの関数へ移す + +いまは cross-review の `_read_review_result_file` と cross-refactoring の `read_result` が、それぞれ結果ファイルの有無だけで結果なしを決めている。監視が書いた理由はどちらも読まない。理由を読む処理を各 Skill に書くと、同じ表(監視の理由 → 結果なしの理由)が 2 か所にでき、語彙を足すたびに片方が古くなる(親 #729 の `move_responsibility`)。**共通層に `read_launch_outcome` を 1 つ置き、両 Skill はその値(`payload` / `reason` / `relaunch_same_agent`)を受け取るだけにする。** 語彙・対応表・可否の表は `monitor_outcome.py` だけが持つ。 + +各 Skill が `read_outcome` を呼んで自分で表を引く形(既存の G4 の設計の `apply._monitor_reason`)は採らない。表が Skill の数だけ増える。 + +### 決定 3: 起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にし、`usage_limit` だけを偽にする + +進行側が結末を見て決めることは「同じ担当をもう 1 度起動してよいか」に尽きる。利用上限は起動し直しても解けず、起動のたびに待ちと相手の CLI の枠を使う(#619 の 2 回目の空振り、#647 の 3729 回)。それ以外の理由(監視の上限・無進捗・致命の文言・CLI の上限・結果なし・読めない)は、対象や負荷で変わりうるため 1 度は起動し直してよい。 + +**偽のときに何をするかは Skill が決める。** cross-review は止めて理由を報告する(担当を外して回す判断は #478)。cross-refactoring は次の輪番の担当へ替える(G4 の設計の決定 3)。共通層が持つのは可否だけで、進行の分岐は持たない。 + +理由ごとに「止める / 替える / 起動し直す」の 3 値を返す形は採らない。3 値のうち「替える」は担当の集合を知る Skill にしか決められず、共通層に置くと担当の割り当てを読み込むことになる。 + +### 決定 4: 利用上限は `EARLY_ERROR`(終了コード 4)のまま、理由だけを `usage_limit` にする + +監視の終了コードは骨組みと G4 の設計が分岐に使う(既存の設計の決定 16)。利用上限は「プロセスが続いても結果を生成できないと分かった」致命の一種で、状態としては `EARLY_ERROR` と同じである。区別が要るのは理由の側だけで、監視の結果ファイルと記録に `usage_limit` が入れば、読む側は状態を変えずに区別できる。 + +新しい状態(`USAGE_LIMIT`、終了コード 7)を足す形は採らない。終了コードの意味が変わり、終了コードで分岐する骨組みと文書(cross-refactoring だけで `monitor.py` の呼び出しが 8 か所)を見直すことになる。 + +### 決定 5: CLI の上限は `NO_RESULT`(終了コード 3)のまま、理由だけを `cli_timeout` にする + +agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」で正しい。文言は終了の後にだけ見る(生きている間に見ると、途中で出た警告を致命と読む)。結果ファイルがあれば理由は `ok` にする(上限に当たっても結果を書き終えていれば使える)。 + +### 決定 6: 利用上限の文言は err.log を全担当で見、stdout.log は claude だけ JSON 向けの照合で見る + +kiro の `Monthly request limit reached` は err.log に出る(#619 の実物)。claude の `"api_error_status":429` はどちらに出るか未確認のため両方を見る(前提 1)。err.log は既存の `_scan_patterns`(表・引用・バッククォート・grep 形式の除外を掛ける)で見る。実測では、`"api_error_status":429` を含む JSON の 1 行も err.log の側で一致し、引用の判定に飲み込まれなかった(「実測」)。stdout.log は既存の `_scan_claude_stdout_fatal` と同じ JSON 向けの照合(除外を掛けない)で見る。JSON は 1 行に引用符を多く含み、行単位の引用の判定が「引用の内側」を誤って真にするためである。 + +### 決定 7: `unparsable` を共通の語彙に入れ、`no_verdict` と `not_posted` は cross-review 固有のまま残す + +結果ファイルが JSON として読めない・オブジェクトでないことは、両 Skill の読み取りが同じ形で見ている。共通の関数が結果ファイルを読む以上、この理由も共通の語彙に要る。`no_verdict`(判定の値が無い)と `not_posted`(投稿が届いていない)は結果ファイルの中身とレビューの投稿の話で、監視も cross-refactoring も知りえない。**cross-review が共通の関数の後で自分の理由を上書きする形にする。** + +`REASONS` は 9 語になる。`reason_for(status)` が返すのは、監視の状態から決まる 6 語のままである。`usage_limit` / `cli_timeout` は監視が結末に理由を添えたときだけ現れる。`unparsable` は読む側だけが使う。 + +### 決定 8: `MonitorOutcome` に理由を持たせ、無ければ状態からの既定を使う + +いま `_record_outcome` は `reason_for(st.status)` で理由を決めている。利用上限と CLI の上限は状態からは決まらないため、結末を作る場所(`_early_error_outcome` / `_process_exit_outcome`)が理由を添える。`MonitorOutcome` に `reason: Optional[str] = None` を足す。`_record_outcome` は `outcome.reason or reason_for(status)` で書く。`create(status, detail)` の既存の呼び出し 8 か所は変えない。 + +### 決定 9: 同じラウンドの 2 度目の結果なしで上書きされる 1 回目の理由は、監視の記録が持つ + +状態ファイルの `rounds[-1].<担当>` はその担当のそのラウンドの最後の結果を持つ構造で、起動し直すと 1 回目の `no_result_reason` は 2 回目で上書きされる。これは既存の構造のままにする。**過去を失わないのは追記だけの `monitor-outcomes.jsonl` の側で**(既存の設計の決定 2)、1 回目の理由も `reason` と `ended_at` から読める。状態ファイルに履歴の配列を足す形は採らない。判定が読むのは最後の結果だけで、履歴は要約(`launches[]`)が既に持つ。 + +### 決定 10: CLI を独立したプロセスグループで起動し、グループの先頭のときだけグループへシグナルを送る + +既存の設計の決定 17 をそのまま引き継ぐ。`launch-cli.sh` が `set -m` を有効にしてから背景で起動すると、CLI の pid がそのままプロセスグループの番号になる。監視は pid がグループの先頭であり、かつ監視自身のグループと違うときだけ `os.killpg` を使い、それ以外は従来どおり pid だけへ送る。`setsid` は macOS に標準で入っていないため採らない。`read-result` の前に結果ファイルの出現を待つ形(#584 の候補 2)も採らない。書き出しそのものを止めれば待つ理由が無い。 + +### 決定 11: `gitfacts.read_result` は結果なしを `die` せず、共通の関数の値で返す契約に置き換える。実装は G4 + +#728 は `read_result` が `die(code=2)` で進行の終了コードを決める向きを直す。その向きを直した先が `read_launch_outcome` の値である。G3 が決めるのは「`read_result` に相当する読み取りは `read_launch_outcome` を呼び、`payload` / `reason` / `relaunch_same_agent` を返す。終了コードを決めず、標準エラーに書かない」までで、3 つの取り込みがその値をどう扱うか(群の状態・担当の交代・終了コード)は G4 が決める。薄い包みとして残すか呼び出し側が直接呼ぶかも G4 に任せる(要求の「未決」)。 + +### 決定 12: `read-result` の終了コードと `judge` の 0 / 2 / 7 / 8 は変えず、利用上限は既存の 1 の枝で終える + +骨組み(`SKILL.md`)は `read-result` の終了コードを `|| true` で受け、`judge` の 7 / 8 / 0 / 2 で分岐し、それ以外を `exit` する。利用上限で起動し直さずに止める結末は、既存の「2 度目も結果なし → `final=error` → 1」と同じ出口へ載せる。骨組みの行は 1 つも変わらない(AC24)。 + +新しい終了コードで「利用上限で止めた」を骨組みへ伝える形は採らない。骨組みの分岐が増え、cross-review の `SKILL.md` と `docs/` の 2 か所へ同じ値を書くことになる。理由は `NO_RESULT_REASONS` の行と `report` の表で読める。 + +## 実測 + +2026-09-19 に `develop`(9eaebe14、bash 5.3.9、Python 3.14.4)で、提案する文言を既存の `_scan_patterns` に通した。 + +```text +kiro 実物 usage=HIT cli_timeout=- 現行fatal=- +claude 429 JSON 1 行 usage=HIT cli_timeout=- 現行fatal=- +claude 429 空白あり usage=HIT cli_timeout=- 現行fatal=- +HTTP 429 行 usage=HIT cli_timeout=- 現行fatal=HIT +HTTP 401 行 usage=- cli_timeout=- 現行fatal=HIT +表の中 usage=- cli_timeout=- 現行fatal=- +バッククォート usage=- cli_timeout=- 現行fatal=- +引用行 usage=- cli_timeout=- 現行fatal=- +agy print timeout usage=- cli_timeout=HIT 現行fatal=- +grep 形式 usage=- cli_timeout=- 現行fatal=- +stdout JSON 向け照合: True +``` + +- 利用上限の 4 つの文言は、実物の 3 形式(kiro の 1 行・claude の JSON 1 行・HTTP 429 行)に一致し、表・バッククォート・引用・grep 形式では一致しない +- `HTTP/1.1 401` は利用上限に入らず、現行の致命(`early_error`)に残る(AC4) +- claude の JSON の 1 行は、err.log 向けの引用の判定を通しても一致した(決定 6 の前提) +- 現行の `_scan_early_fatal` は kiro の実物と claude の JSON に一致しない(#619 の再現) + +プロセスグループの実測は既存の契約の文書の「実測」(2026-09-15)にあり、変更していない。`set -m` で起動した CLI を `killpg` で止めると、3 秒後に子が書く結果ファイルは書かれなかった。 + +## 構成要素 + +| 要素 | 新設 / 変更 | 責務 | +| --- | --- | --- | +| 結末の語彙と読み取り(`lib/monitor_outcome.py`) | 変更 | 理由 9 語、起動し直しの可否の表、結末を 1 つの値として読む `read_launch_outcome`(決定 2・3・7) | +| 監視(`lib/monitor.py`) | 変更 | 利用上限と CLI の上限の文言の検知、理由を持つ結末(決定 4・5・6・8)、グループへの停止(決定 10) | +| 起動(`lib/launch-cli.sh`) | 変更 | `set -m` で CLI を独立したプロセスグループにする(決定 10) | +| cross-review の状態(`cross-review/scripts/state.py`) | 変更 | `read-result` が共通の値を読んで理由と `monitor_detail` を記録する。`judge` が理由を出し、可否が偽なら止める。`report` が理由を表に出す(決定 12) | +| cross-review の文書(`docs/01-state-and-review.md` / `docs/03-review-output.md` / `docs/04-contracts.md`) | 変更 | 理由の表、上限の見分け方、状態ファイルの鍵 | +| 共通層の一覧(`lib/README.md`) | 変更 | `monitor_outcome.py` の行に読み取りの責務を足す | +| cross-refactoring の取り込み(`refactor_lib/gitfacts.py` ほか) | **契約のみ** | `read_result` に相当する読み取りが `read_launch_outcome` を呼ぶ(決定 11)。実装は G4 | +| テスト(`scripts/tests/` / `cross-review/tests/`) | 変更 | 「テスト設計」 | + +```mermaid +graph TD + subgraph 起動と監視 + L[起動 launch-cli.sh
独立したプロセスグループ] + M[監視 monitor.py
文言の検知・理由を持つ結末・グループへの停止] + end + subgraph 共通層の結末 + MO[結末の語彙と読み取り monitor_outcome.py
理由 9 語・可否の表・read_launch_outcome] + end + subgraph 一時ディレクトリ + F1[結果ファイル stem-result.json] + F2[監視の結果ファイル stem-monitor.json] + F3[監視の記録 monitor-outcomes.jsonl] + S[状態ファイル] + end + subgraph cross-review + RR[read-result] + J[judge] + RP[report] + end + subgraph cross-refactoring(G4 が実装) + GF[取り込み merge-apply / merge-fix / merge-final-fix] + end + L -->|pgid = pid| M + M -->|reason| MO + MO --> F2 + MO --> F3 + RR -->|payload / reason / 可否| MO + GF -.->|payload / reason / 可否| MO + MO -->|読む| F1 + MO -->|読む| F2 + RR -->|no_result_reason / monitor_detail| S + J -->|理由を読む・可否で止める| S + RP -->|理由を表に出す| S +``` +**図に含めない要素**は次の 2 つである。 + +| 要素 | 図との関係 | +| --- | --- | +| cross-review の文書と共通層の一覧 | 手順と表の記述で、呼び出しの辺を持たない | +| テスト | 実行時の依存ではない | + +## 文脈と配置 + +```mermaid +graph LR + H[進行側のホスト CLI] --> SK[cross-review / cross-refactoring の骨組み] + SK --> CLI[担当の CLI: codex / agy / kiro / claude] + CLI --> API[各 CLI の API の提供元
利用上限はここが返す] + SK --> GH[GitHub] + SK --> TMP[作業ツリーの中の一時ディレクトリ] +``` + +利用上限を返すのは各 CLI の API の提供元で、こちらは変えられない。**この変更が変えるのは、その返答が err.log / stdout.log に現れたときの読み方だけである。** + +| 実行の単位 | どこで動くか | 境界 | +| --- | --- | --- | +| 担当の CLI | 背景のプロセス。**独立したプロセスグループ**(pgid = pid) | 一時ディレクトリへ結果ファイルとログを書く | +| 監視 | 進行側のシェルから起動する Python(cross-review は `bg-wait.sh` の背景) | 一時ディレクトリを読み、監視の結果ファイルと記録を書き、CLI のグループへシグナルを送る | +| 状態の操作 | 進行側が 1 コマンドずつ呼ぶ Python | 一時ディレクトリの結果ファイル・監視の結果ファイル・状態ファイルを読み書きする | + +配置で変わるのは担当の CLI のプロセスグループだけである。 + +### 置き場所 + +```text +plugins/ndf/scripts/lib/ + monitor_outcome.py 変更(語彙 9 語・可否の表・read_launch_outcome・LaunchOutcome) + monitor.py 変更(USAGE_LIMIT_FATAL / CLI_TIMEOUT_AFTER_EXIT / MonitorOutcome.reason / _kill_pid のグループ) + launch-cli.sh 変更(set -m) + README.md 変更(monitor_outcome.py の行) +plugins/ndf/scripts/tests/ + test_monitor_outcome_unit.py 変更(語彙と read_launch_outcome の単体) +plugins/ndf/skills/cross-review/ + scripts/state.py 変更(_read_review_result_file / _record_no_result / _handle_no_result_round / report) + docs/01-state-and-review.md 変更(理由の表) + docs/03-review-output.md 変更(上限の見分け方) + docs/04-contracts.md 変更(monitor_detail) + tests/ 変更(文言・グループ・judge・report) +plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py 契約のみ(G4 が変える) +# dev.kiro / dev.agy の配布物は bash scripts/build-runtime-plugins.sh で同期する +``` + +## 構造 + +変更が触る型だけを載せる。 + +```mermaid +classDiagram + class MonitorOutcome { + status: str + exit_code: int + icon: str + detail: str + +reason: Optional~str~ + create(status, detail, reason=None) MonitorOutcome + } + class LaunchOutcome { + payload: Optional~dict~ + reason: Optional~str~ + detail: str + monitor: Optional~dict~ + relaunch_same_agent: bool + } + class monitor_outcome { + REASONS: tuple + NO_RELAUNCH_REASONS: frozenset + reason_for(status) str + relaunch_same_agent(reason) bool + read_launch_outcome(tmp_dir, stem, result_path) LaunchOutcome + } + class AgentStatus + class state_py + class gitfacts_read_result + AgentStatus --> MonitorOutcome : outcome + monitor_outcome ..> LaunchOutcome : 作る + state_py ..> monitor_outcome : read_launch_outcome を呼ぶ + gitfacts_read_result ..> monitor_outcome : 契約(G4 が実装) +``` + +`+` の付いた欄が増える。`AgentStatus` と `state.py` の既存の欄・関数は変えない。 + +| 触る型 | 責務 | +| --- | --- | +| `MonitorOutcome.reason` | 状態からは決まらない理由(`usage_limit` / `cli_timeout`)を結末に添える。`None` なら `reason_for(status)` | +| `LaunchOutcome` | 起動 1 回の結末。`payload` があれば使える結果、無ければ `reason` が理由。`relaunch_same_agent` は `reason` から導く(`payload` があれば `True`) | +| `monitor_outcome.NO_RELAUNCH_REASONS` | 起動し直しても解けない理由の集合。値は `{"usage_limit"}` | + +## データ構造 + +永続データは JSON のファイルで、データベースは無い。ER 図は作らず、表で持つ。 + +### 監視の結果ファイル `-monitor.json` と監視の記録(P1 から。`reason` の値だけが増える) + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `reason` | 文字列 | 許さない | 監視が書く理由。`ok` / `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `missing` / `pidfile_bad` の 8 語(`unparsable` は監視が書かない) | + +他の 14 個のキーは変えない(既存の契約の文書の「監視の結果ファイル」)。 + +### 状態ファイル `rounds[-1].<担当>`(cross-review) + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `intent` | 文字列 | 許さない | `NO_RESULT` のとき下の 2 列が意味を持つ(既存) | +| `no_result_reason` | 文字列 | 許さない(`NO_RESULT` のとき) | `read_launch_outcome` の `reason`(`ok` を除く 8 語)、または cross-review が上書きする `no_verdict` / `not_posted` | +| `monitor_detail` | 文字列 | 許す | 監視の `detail`(最大 200 文字の err.log の抜粋)。**鍵が無い** = 監視の結果ファイルが無かった。空文字は書かない | + +`no_result_reason` の値の集合は 10 語になる。既存の 3 語(`missing` / `unparsable` / `no_verdict`)と `not_posted` の意味は変えない。 + +### 理由の語彙(`monitor_outcome.REASONS`、9 語) + +| 理由 | 監視の状態 | 誰が書くか | 起動し直しの可否 | 何が起きたか | +| --- | --- | --- | --- | --- | +| `ok` | `OK` | 監視 | — | 結果ファイルがあって終わった | +| `timeout` | `TIMEOUT` | 監視 | 可 | 監視の上限 | +| `stalled` | `STALLED` | 監視 | 可 | 無進捗の許容 | +| `early_error` | `EARLY_ERROR` | 監視 | 可 | 利用上限以外の致命の文言 | +| `usage_limit` | `EARLY_ERROR` | 監視(決定 8) | **否** | 利用上限の文言 | +| `cli_timeout` | `NO_RESULT` | 監視(決定 8) | 可 | 結果なしで終わり、err.log に CLI の上限の文言 | +| `missing` | `NO_RESULT` | 監視・読む側 | 可 | 結果なしで終わり、理由の文言が無い | +| `pidfile_bad` | `PIDFILE_BAD` | 監視 | 可 | pid ファイルが無い・別のプロセス | +| `unparsable` | — | 読む側だけ | 可 | 結果ファイルがあるが JSON オブジェクトとして読めない | + +### 機能とデータの対応 + +| 機能 | 監視の結果ファイル | 監視の記録 | 結果ファイル | 状態ファイル | +| --- | --- | --- | --- | --- | +| F1 / F2 検知して理由を残す | C | C | — | — | +| F3 結末を 1 つの値として読む | R | — | R | — | +| F4 止めて理由を報告する(cross-review) | — | — | — | R / U | +| F5 止めた後に書かせない | — | — | (書かせない) | — | + +時系列の扱いは決定 9 のとおり。状態ファイルは上書き(最後の結果だけ)、監視の記録は追記だけで過去を持つ。移行は無い(鍵の追加と値の追加だけで、既存の状態ファイルはそのまま読める)。 + +## 入出力の契約 + +### `monitor_outcome.read_launch_outcome`(新設。両 Skill の取り込みが呼ぶ) + +| 項目 | 内容 | +| --- | --- | +| 名前 | `read_launch_outcome(tmp_dir, stem, result_path=None) -> LaunchOutcome` | +| 入力 | `tmp_dir`: 一時ディレクトリ。`stem`: 監視と同じ stem(`-review-pr` / `-apply-r` など)。`result_path`: 結果ファイルのパス。省くと `/-result.json` | +| 出力(使える結果) | `payload` に JSON オブジェクト、`reason` は `None`、`relaunch_same_agent` は `True`、`monitor` に監視の結果ファイルの辞書(無ければ `None`)、`detail` に監視の `detail`(無ければ空文字) | +| 出力(結果なし) | `payload` は `None`、`reason` は下の表、`relaunch_same_agent` は `reason not in NO_RELAUNCH_REASONS`、`detail` は監視の `detail`(無ければ読めなかった理由の 1 文) | +| 失敗の形 | **失敗しない。** 例外を投げず、`SystemExit` も出さず、標準出力・標準エラーに書かない。監視の結果ファイルが壊れていれば無いものとして扱う | +| 互換性 | 新設。既存の `read_outcome` / `reason_for` / `REASONS` の呼び出し側は変わらない(`REASONS` は 9 語になるが、一覧を持つ読み手は無い) | + +結果なしの `reason` の決め方(要求の AC9): + +| 監視の結果ファイルの `reason` | 結果ファイル | `reason` | +| --- | --- | --- | +| `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` | 問わない | その値(**監視が止めたか、結果を書けない終わり方をしたことが分かっている**) | +| `ok` / `missing` / ファイルが無い・読めない | 無い、または空 | `missing` | +| `ok` / `missing` / ファイルが無い・読めない | あるが JSON オブジェクトでない | `unparsable` | + +**結果ファイルが読めれば `payload` が勝つ。** 監視が `usage_limit` で止めた後にも結果ファイルが残っていれば、それは止める前に書き終えていた結果で、使ってよい。 + +### `monitor_outcome.relaunch_same_agent(reason) -> bool`(新設) + +`reason not in NO_RELAUNCH_REASONS`。`NO_RELAUNCH_REASONS = frozenset({"usage_limit"})`。理由を足すときはこの集合だけを見直す。 + +### `monitor.py`(変更。終了コードと標準出力は変えない) + +| 引数・出力 | 約束 | +| --- | --- | +| 終了コード 0〜6 | 変えない。`usage_limit` は 4、`cli_timeout` は 3 | +| 標準出力の 13 個のキー | 変えない | +| 監視の結果ファイルと記録の `reason` | `usage_limit` / `cli_timeout` が増える | +| `--no-early-error` | 変えない。利用上限の検知も致命の検知と一緒に無効になる(`MONITOR_NO_EARLY_ERROR=1` の逃げ道はそのまま) | + +検知の文言(err.log は `_scan_patterns` の除外を掛ける。claude の stdout.log は JSON 向けの照合で除外を掛けない): + +| 表 | 理由 | 見るファイル | いつ見るか | 文言(正規表現) | +| --- | --- | --- | --- | --- | +| `USAGE_LIMIT_FATAL` | `usage_limit` | err.log(全担当) | 生きている間の巡回ごと | `Monthly request limit reached` | +| `USAGE_LIMIT_FATAL` | `usage_limit` | err.log(全担当) | 同上 | `"api_error_status"\s*:\s*429` | +| `USAGE_LIMIT_FATAL` | `usage_limit` | err.log(既存の一致を付け替え) | 同上 | `\b(?:quota exceeded\|rate limit exceeded)\b`(大文字小文字を問わない)/ `^HTTP/\d\S* 429 ` | +| `CLAUDE_STDOUT_USAGE_LIMIT` | `usage_limit` | claude の stdout.log | 同上 | `"api_error_status"\s*:\s*429` | +| `EARLY_ERROR_FATAL` | `early_error` | err.log(既存の一致を分ける) | 同上 | `^HTTP/\d\S* (?:401\|403) ` と、残りの既存の致命 | +| `CLI_TIMEOUT_AFTER_EXIT` | `cli_timeout` | err.log | **終了した後、結果ファイルが無いときだけ** | `print timeout after \S+ with turn in progress` | + +照合の順序は利用上限 → 致命 → 警告の見た目の致命。同じ err.log に利用上限と他の致命が両方あれば `usage_limit` になる(上限で落ちた後に別の文言が続く形が普通で、上限のほうが原因である)。 + +### `launch-cli.sh` と `monitor._kill_pid`(変更。既存の設計の決定 17) + +| 項目 | 約束 | +| --- | --- | +| 起動 | `set -m` を有効にして背景起動する。CLI の pid = pgid | +| 停止 | `os.getpgid(pid) == pid` かつ `!= os.getpgrp()` なら `os.killpg`(SIGTERM → 3 秒 → SIGKILL)。それ以外は従来どおり `os.kill` | +| 互換性 | 呼び出し側の引数は変わらない。pid ファイルの中身も変わらない | + +### `state.py`(変更) + +| コマンド | 変わる出力 | 変わらないもの | +| --- | --- | --- | +| `read-result` | `rounds[-1].<担当>.no_result_reason` に `read_launch_outcome` の `reason`(`no_verdict` / `not_posted` は従来どおり後で上書き)。監視の結果ファイルがあれば `monitor_detail` | 終了コード(`unparsable` は 3、それ以外は 1、使える結果は 0) | +| `judge` | 結果なしがあるとき標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'`。可否が偽の理由を含めば `final=error` と終了コード 1、標準エラーに担当・理由・`monitor_detail` | 終了コード 0 / 2 / 7 / 8 の意味と `RELAUNCH_AGENTS` の形、8 の `flush` の枝 | +| `report` | ラウンド表で `<担当>=NO_RESULT(<理由>)` | 他の行 | + +### `gitfacts.read_result`(契約のみ。G4 が実装する) + +| 項目 | 契約 | +| --- | --- | +| 読み取り | `read_launch_outcome(state["tmp_dir"], stem_for(...), result_path(...))` を呼ぶ。自前で結果ファイルを開かない | +| 返すもの | `LaunchOutcome`(`payload` / `reason` / `relaunch_same_agent` / `detail` / `monitor`) | +| 失敗の形 | **`die` しない。** 終了コードを決めるのは 3 つの取り込み(`merge-apply` / `merge-fix` / `merge-final-fix`) | +| 群の記録 | `failed_attempts[].reason` には `LaunchOutcome.reason` をそのまま写す(G4 の設計の `_monitor_reason` はこの値で置き換わる) | +| 担当の交代 | `relaunch_same_agent` が偽なら同じ担当で試行を重ねない。替えるか止めるかは G4 の決定 3 | + +## 処理の流れ + +### 監視の 1 担当(利用上限と CLI の上限の枝が入る) + +```mermaid +graph TD + A[pid ファイルを待つ] -->|無い| PB[PIDFILE_BAD / pidfile_bad] + A --> P[巡回] + P -->|経過 ≥ 監視の上限| T[TIMEOUT / timeout] + P -->|利用上限の文言
err.log 全担当・stdout claude| U[EARLY_ERROR / usage_limit] + P -->|その他の致命の文言| E[EARLY_ERROR / early_error] + P -->|終了して結果あり| OK[OK / ok] + P -->|終了して結果なし
err.log に CLI の上限の文言| CT[NO_RESULT / cli_timeout] + P -->|終了して結果なし| NR[NO_RESULT / missing] + P -->|無進捗 ≥ 許容| SL[STALLED / stalled] + T --> K[止める: pgid = pid ならグループへ] + U --> K + E --> K + SL --> K + K --> W[結果ファイルと記録を書く
reason = outcome.reason or reason_for] + OK --> W + CT --> W + NR --> W + PB --> W + W --> O[標準出力の JSON と終了コード(変えない)] +``` + +### 結末を 1 つの値として読む(`read_launch_outcome`) + +```mermaid +graph TD + S[結果ファイルを読む] -->|JSON オブジェクト| P[payload / reason None / 可 True] + S -->|無い・空・読めない| M[監視の結果ファイルを読む] + M -->|reason が timeout / stalled / early_error
usage_limit / cli_timeout / pidfile_bad| R1[reason = その値] + M -->|ok / missing / 無い・壊れている| R2{結果ファイルは} + R2 -->|無い・空| R3[reason = missing] + R2 -->|あるが読めない| R4[reason = unparsable] + R1 --> V[可否 = reason not in NO_RELAUNCH_REASONS] + R3 --> V + R4 --> V +``` + +### cross-review の 1 ラウンド(起動し直しの判断) + +```mermaid +sequenceDiagram + participant H as 進行側(骨組み、変えない) + participant M as monitor.py + participant MO as monitor_outcome + participant S as state.py + H->>M: 監視(担当ごと) + M->>MO: 結末(reason 付き)を結果ファイルと記録へ + H->>S: read-result(担当ごと) + S->>MO: read_launch_outcome(tmp_dir, stem) + MO-->>S: payload / reason / 可否 / detail + alt payload あり + S->>S: 取り込み(no_verdict / not_posted は従来どおりここで判定) + else 結果なし + S->>S: rounds[-1].<担当> に NO_RESULT / no_result_reason / monitor_detail + S-->>H: 終了コード 1(unparsable は 3) + end + H->>S: judge + S->>S: 結果なしの担当の理由を集める + S-->>H: NO_RESULT_REASONS='kiro=usage_limit' + alt 可否が偽の理由がある + S->>S: final=error + S-->>H: 終了コード 1(担当・理由・monitor_detail を標準エラーへ) + else すべて可(1 度目) + S-->>H: 終了コード 7 と RELAUNCH_AGENTS(変えない) + else すべて可(2 度目) + S-->>H: 終了コード 1(変えない) + end +``` + +## 非機能の実現方式 + +| 条件 | 実現方式 | +| --- | --- | +| 理由が 4 か所で同じ語彙で読める | 監視が結末に理由を添え、`_record_outcome` が結果ファイルと記録へ同じ値を書く。`read-result` は `read_launch_outcome` の `reason` をそのまま `no_result_reason` に写す。要約の `launches[]` は記録の行から作る(P1 のまま)。語彙を足すときは `REASONS` と `NO_RELAUNCH_REASONS` と検知の表だけを変える | +| `monitor_detail` が要約に入らない | `run_metrics.py` は状態ファイルの `rounds[-1].<担当>` の鍵を要約へ写さず、記録の行から `detail` を除いて `launches[]` を作る(P1 の決定 7)。この変更は要約の側を触らない | +| macOS でもグループで止める | 外部コマンドに頼らない `set -m` を使う。bash 3.2 は未確認(「未確認のまま残ること」)。成り立たなければ pid だけの停止に落ちる(`os.getpgid(pid) != pid` の枝) | + +## テスト設計 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| AC1 | `REASONS` の 9 語と、6 つの状態の `reason_for` の返り値を固定する(`scripts/tests/test_monitor_outcome_unit.py`) | +| AC2〜AC5 | 各文言を 1 行書いた err.log / stdout.log と、終わったプロセスの pid ファイルで `monitor_agent` を呼び、状態・終了コード・結果ファイルの `reason`(`cross-review/tests/test_monitor_usage_limit.py` 新設)。AC5 は結果ファイルあり・なしの 2 通り | +| AC6 | 「実測」の 10 行をそのまま入力にし、表・引用・バッククォート・grep 形式で一致しないこと。claude の stdout.log の JSON で一致すること(`test_monitor_early_error.py` の形に倣う) | +| AC7 | 既存の `test_monitor_outcome_file.py` の標準出力のキーの検査を変更せずに通す。記録の行の `reason` を読む | +| AC8〜AC11 | 一時ディレクトリに監視の結果ファイル(各理由・壊れた JSON・無し)と結果ファイル(オブジェクト・配列・壊れた JSON・空・無し)を組み合わせて置き、`read_launch_outcome` の 5 つの欄。`capsys` で標準出力・標準エラーが空(`test_monitor_outcome_unit.py`) | +| AC12 | `git grep -n 'usage_limit' -- plugins/ndf/skills` の一致行が、文書とテストとテンプレートの文言だけである(テスト化せず、レビューの手順) | +| AC13 | 結果ファイル無し + 監視の結果ファイル(各理由)/ 無しで `read-result` を呼び、状態ファイルの `no_result_reason` と `monitor_detail` と終了コード(`cross-review/tests/test_read_result_reason.py` 新設) | +| AC14〜AC17 | 状態ファイルを作って `judge` と `report` を呼ぶ。`usage_limit` を含む / 含まない / 2 度目の 3 通り(`test_judge_no_result_reason.py` 新設) | +| AC18 | 文書の `grep`(10 語と「見分け方」の節) | +| AC19〜AC21 | 3 秒後に子が書く偽の CLI を `launch-cli.sh` で起動し、`os.getpgid` と、監視の上限 2 秒で止めた後の結果ファイルの有無。先頭でない pid では `os.killpg` が呼ばれないことを `mock` で見る(`test_launch_cli_process_group.py` 新設) | +| AC22〜AC23 | 検証手段の表のコマンド | +| AC24 | `SKILL.md` の骨組みの該当行(`bg-wait.sh run` から `case $JUDGE_RC` まで)を変更前と `diff` して差が無い | + +## 未確認のまま残ること + +| # | 項目 | 内容 | 決める時点 | +| --- | --- | --- | --- | +| 1 | claude の 429 の出る先 | `"api_error_status":429` が err.log と stdout.log のどちらに出るか。実物のログが手元に無い。両方を見るためどちらでも拾える | 実装で偽の claude を両方の形で試す。実物は次に上限に当たったときの記録で確かめる | +| 2 | kiro の利用上限のときの終わり方 | プロセスが直ちに終わるのか、待ち続けるのか。#619 の実物では `err.log` に 1 行出て `NO_RESULT` になった(直ちに終わったと読める) | どちらでも監視は巡回で文言を拾い `usage_limit` にする。実装で確かめない | +| 3 | macOS の bash 3.2 の `set -m` | Linux の bash 5.3 だけで確かめた | 実装で確かめる。成り立たなければ pid だけの停止に落ちる(構造は同じ) | +| 4 | agy が子プロセスで結果を書くか | #584 の事例が止めた後の書き出しだったかは確かめられていない | 確かめないまま進める(グループで止めればどちらでも塞がる) | +| 5 | 利用上限と他の致命が同じ err.log に並ぶ順序 | 上限の後に別の致命が続く形を想定して利用上限を先に見る。逆の順で並ぶ実物は未確認 | 実装で順序を固定し、逆の実物が出たら見直す | +| 6 | `read_launch_outcome` に渡す stem の組み立て | cross-review は `-review-pr`、cross-refactoring は `stem_for` の値。監視の `--stem-template` と食い違うと監視の結果ファイルを引けない | 実装で、`launch-*.sh` と `monitor.py` の呼び出しの stem を突き合わせるテストを置く | + +## 申し送り(並行する設計との境界) + +| 相手 | 決めた契約 | どちらが何をするか | +| --- | --- | --- | +| G4(#728 #647 #592 #553) | `gitfacts.read_result` の契約(「入出力の契約」)。理由の語彙 9 語と `relaunch_same_agent` | G3 が共通層と cross-review を実装する。G4 は `read_result` に相当する読み取りを `read_launch_outcome` に置き換え、3 つの取り込みで `payload` 無しのときの群の状態・担当の交代・終了コードを決める。G4 の既存の設計の `apply._monitor_reason` と `gitfacts.load_result` は `read_launch_outcome` で置き換わる | +| G4 | 監視の終了コード 0〜6 と標準出力は変えない | G3 が守る。G4 は終了コードで分岐しない設計(G4 の決定 4)を続ける | +| G5(#730 #583) | 既存の設計の決定 18(`prior_review_url` と記録だけの起動)・決定 19(起動し直しを初回と同じ経路へ)・AC63〜AC67 | G5 が持つ。G3 は `read-result` の結果なしの記録に `monitor_detail` を足すだけで、`prior_review_url` の鍵と `launch-reviewer.sh` は触らない。G5 が `_record_no_result` に `prior_review_url` を足すときは、G3 の `monitor_detail` の書き方(鍵が無い = 無かった)に揃える | +| D-B(#478) | 判定が出す `NO_RESULT_REASONS` の行と、可否が偽のときに止めること | G3 が入れる。利用上限の担当を外して残りで回す判断は #478 | +| G1(#727 #478 ほか) | `state.py` の `init` / 再開の引数 | 触るファイルが同じ(`state.py`)だが節が違う(G3 は `_read_review_result_file` / `_record_no_result` / `_handle_no_result_round` / `report`)。競合は後からマージする側が解く | + +## 既存の設計との対応 + +| 既存の決定(PR #666) | この文書 | 変わったこと | +| --- | --- | --- | +| 決定 14(利用上限は致命として止め、同じラウンドで起動し直さない) | 決定 3・4 | 「判定が `usage_limit` を見る」から「共通層の可否を見る」へ。値は同じ | +| 決定 15(CLI の上限は結果なしのまま理由だけ) | 決定 5 | 結果ファイルがあれば `ok` を明記 | +| 決定 16(監視の終了コードと標準出力を変えない) | 決定 4・12 | 同じ | +| 決定 17(プロセスグループで止める) | 決定 10 | 同じ | +| 決定 18(投稿済みの担当は記録だけの起動) | — | #730(G5)へ | +| 決定 19(起動し直しを初回と同じ経路へ) | — | #730(G5)へ | +| 決定 20(利用上限の検知は err.log + claude の stdout.log) | 決定 6 | 実測を足した。値は同じ | +| — | 決定 1・2・7・8・9・11 | 新設 | diff --git a/issues/issue-729-619-584-requirements.md b/issues/issue-729-619-584-requirements.md new file mode 100644 index 00000000..a2bb4599 --- /dev/null +++ b/issues/issue-729-619-584-requirements.md @@ -0,0 +1,232 @@ +# #729 / #619 / #584: 担当 1 回の起動の結末を共通の語彙で読む + +設計は [issue-729-619-584-design.md](issue-729-619-584-design.md) にある。この文書は「何を満たすか」だけを扱う。 + +**この文書は、既存の設計 [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md) の P3(AC50〜AC62、AC68〜AC69)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P1(#662)と P2(#598 #537)は v10.13.0 で配布済みで、この文書は触らない。#583 は #730 の設計が持つ。 + +## 依頼(原文) + +### #729(根本原因の親) + +> **担当 1 回の起動の結末(結果ファイルの有無と、監視が打ち切った理由)を読み、起動し直してよいかを返す契約。** +> +> - 結末の語彙は `plugins/ndf/scripts/lib/monitor_outcome.py`、早期の致命の検知は `monitor.py` の `EARLY_ERROR_FATAL` にある +> - cross-review の `state.py`(`_read_review_result_file`)も cross-refactoring の `refactor_lib/gitfacts.py`(`read_result`)も語彙を読まず、結果ファイルの有無だけで判断する(Skill 側で `monitor_outcome` を読むコードは 0 件) +> - `EARLY_ERROR_FATAL` は `Monthly request limit reached` や `"api_error_status":429` の行に一致しない +> +> `read_result` が `die` で進行を決める向きは #728 が持つ。 +> +> ## 採る手 +> +> - 移動(`move_responsibility`): 結果なしの判断を、各 Skill の結果ファイルの読み取りから共通層の結末へ移す +> - 新設: 利用上限(`usage_limit`)の語彙と検知の文言 +> +> ## 完了条件 +> +> - 両 Skill が共通層の結末を読み、利用上限を理由として出し、同じラウンドでの起動し直しを止める +> - 利用上限の実際の出力(上の 2 形式)を検知することを検査が確かめる +> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる + +### #619 + +> **監視の結果(状態と `detail`)を担当ごとにファイルへ残し、`read-result` が `NO_RESULT` の理由に使う。** +> 理由は、監視の上限(timeout)・CLI 自身の上限(cli_timeout)・利用上限(usage_limit)・早期エラー(early_error)・未投稿(not_posted)・結果ファイル無し(missing)を区別する。 +> +> - 利用上限は起動し直しても解けないため、理由が `usage_limit` のときは起動し直さずに止めて報告する判断もここに置ける(cross-refactoring の #647 と共通) + +### #584 + +> 移動(`move_responsibility`)。停止の単位を pid からプロセスグループへ移す。`launch-cli.sh` は CLI を新しいプロセスグループとして起動し、`_kill_pid` はそのグループへ SIGTERM / SIGKILL を送る。 +> +> 止めた理由を読む側(結果なしの理由の語彙)は、担当 1 回の起動の結末を共通の語彙で読む #729 が持つ。 + +## 目的 + +- 担当の CLI が利用上限で落ちたとき、進行側と利用者に届く理由が `usage_limit` になり、同じラウンドで同じ担当を起動し直さない +- 結果ファイルが無いときに「監視が打ち切った」「CLI が自分の上限で終わった」「終わったが結果を書かなかった」を、結末の語彙 1 つで区別できる +- 結果なしの判断と起動し直しの可否を共通層の 1 か所が持ち、cross-review と cross-refactoring がその値を読む(両 Skill が同じ判断を別々に書かない) +- 監視が止めた担当の子プロセスが、止めた後に結果ファイルを書かない + +## 前提 + +| # | 前提 | +| --- | --- | +| 1 | claude の利用上限の文言の実物は `"api_error_status":429` を含む行である(#647 の本文)。err.log と stdout.log のどちらに出るかは未確認のため、両方を見る | +| 2 | kiro の利用上限の文言の実物は err.log の `Monthly request limit reached` である(#619 の本文、PR #601 の round 2) | +| 3 | P1(監視の結果ファイル `-monitor.json` と `monitor_outcome.read_outcome`)と P2(`limits.py`、`--phase`)は v10.13.0 で `develop` に入っている。この変更はその上に載せる | +| 4 | cross-refactoring の側の実装(`gitfacts.read_result` と 3 つの取り込み)は #728(G4)が行う。この変更が決めるのは `read_result` が従う契約だけである | +| 5 | 利用上限で止まった担当を外して残りの担当で回す判断は #478(D-B)が持つ。この変更は「同じ担当を起動し直さずに止めて理由を報告する」までである | +| 6 | 監視の終了コード 0〜6 と標準出力の 13 個のキーは変えない(既存の設計の決定 16。G4 の骨組みがこれを前提にする) | + +## 対象範囲 + +含む: + +| 場所 | 扱うもの | +| --- | --- | +| 共通層 `plugins/ndf/scripts/lib/monitor_outcome.py` | 理由の語彙 2 つの追加、結末を 1 つの値として読む関数、起動し直しの可否 | +| 共通層 `plugins/ndf/scripts/lib/monitor.py` | 利用上限と CLI の上限の文言の検知、理由を持つ結末、プロセスグループへの停止 | +| 共通層 `plugins/ndf/scripts/lib/launch-cli.sh` | CLI を独立したプロセスグループで起動する | +| cross-review `scripts/state.py` | `read-result` の結果なしの理由と `monitor_detail`、`judge` の理由の出力と利用上限での停止、`report` の表 | +| cross-review `docs/` | 理由の表、上限に当たった場合の見分け方 | +| テスト | 共通層と cross-review のテスト | + +含まない: + +| 扱わないもの | 理由 | +| --- | --- | +| cross-refactoring の `gitfacts.read_result` と 3 つの取り込み(`merge-apply` / `merge-fix` / `merge-final-fix`)の実装 | #728(G4)。この文書は契約だけを決める(設計文書の「入出力の契約」) | +| 投稿の後に打ち切られた担当の記録と重ねての投稿(`prior_review_url`、記録だけの起動) | #583 → #730(G5)。既存の設計の決定 18・AC63〜AC67 はそちらへ移る | +| 起動し直した担当を初回と同じ経路(証拠集約を含む)に通す骨組みの変更 | 既存の設計の決定 19・AC67。投稿の重なりと同じ骨組みの行を触るため #730(G5)へ渡す | +| 利用上限の担当を外して残りの担当で回す | #478(D-B) | +| 反証の取り直し(`critique-round.sh`)で理由を見ること | 既存の設計の「未確認のまま残ること」10。直さない | +| codex のモデルの 404 を理由として区別すること | #461(マイルストーン 06)。この語彙では `early_error` か `missing` に落ちる | +| 監視の終了コードと標準出力の変更 | 前提 6 | +| 型・クラスの新設のうち、画面・永続データベース・OpenAPI に当たるもの | この変更に画面と API は無い。永続データは状態ファイルと監視の結果ファイル(JSON)で、設計文書の「データ構造」が表で持つ | +| `CHANGELOG.md` と版数 | 配布の工程が書く | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 担当 | レビュー・反証・適用・修正を行う CLI(codex / agy / kiro / claude) | +| 起動 1 回 | `launch-cli.sh` が担当を 1 度起動し、`monitor.py` がそれを見終わるまで | +| 結末 | 起動 1 回の終わり方。監視の `status` と `reason`、結果ファイルの有無と読めるかを合わせたもの | +| 理由 | 結末の語彙の 1 語(`monitor_outcome.REASONS`) | +| 起動し直しの可否 | 同じ担当を同じ条件で起動し直せば解ける結末か(偽なら、起動し直しても同じ結末になる) | +| 使える結果 | 結果ファイルがあり、JSON オブジェクトとして読める | +| 監視の結果ファイル | `-monitor.json`。担当 1 者の最後の監視の結果(P1) | +| 監視の記録 | `monitor-outcomes.jsonl`。監視の結果を追記だけで積む(P1) | +| 利用上限 | 担当の CLI の月間・週間の利用枠に達し、起動し直しても解けない状態 | +| CLI の上限 | 担当の CLI 自身の実行時間の上限(agy の `--print-timeout`) | + +## 受け入れ条件 + +文言の一覧は設計文書の「入出力の契約」の「検知の文言」にある。 + +語彙と検知(監視、#619): + +- [ ] AC1: `monitor_outcome.REASONS` は次の 9 語である。`reason_for(status)` の 6 つの状態に対する返り値は変更前と同じである + + | 既存の 6 語 | 足す 3 語 | + | --- | --- | + | `ok` / `timeout` / `stalled` / `early_error` / `missing` / `pidfile_bad` | `usage_limit` / `cli_timeout` / `unparsable` | + +- [ ] AC2: err.log に `Monthly request limit reached` の行が出ると、監視は担当を止めて `EARLY_ERROR`(終了コード 4)を返す。監視の結果ファイルの `reason` は `usage_limit` である +- [ ] AC3: err.log(全担当)か stdout.log(claude だけ)に `"api_error_status"` と `429` を `:` で結んだ行が出ると、AC2 と同じく `usage_limit` になる。`:` の前後の空白の有無は問わない +- [ ] AC4: 既存の一致のうち `quota exceeded` / `rate limit exceeded` / `HTTP/<版> 429` の `reason` は `usage_limit` になる。`HTTP/<版> 401` / `HTTP/<版> 403` とそれ以外の致命の一致は `early_error` のままである +- [ ] AC5: 担当が結果ファイル無しで終わり、err.log に `print timeout after <時間> with turn in progress` があるとき、監視は `NO_RESULT`(終了コード 3)を返す。`reason` は `cli_timeout` である。同じ文言があっても結果ファイルがあれば `OK` / `ok` である +- [ ] AC6: AC2〜AC5 の文言が err.log で markdown の表の行・引用・バッククォート・grep 形式の引用の中にあるときは一致しない。claude の stdout.log は JSON 向けの照合で見るため、この除外を掛けない +- [ ] AC7: `usage_limit` / `cli_timeout` は監視の結果ファイルと監視の記録の `reason` に入る。監視の標準出力の 13 個のキーと値の型、終了コードは変更前と同じである + +結末を 1 つの値として読む(共通層、#729): + +- [ ] AC8: 結果ファイルが JSON オブジェクトとして読めるとき、`monitor_outcome.read_launch_outcome(tmp_dir, stem, result_path)` はその辞書を `payload` に持つ。`reason` は `None`、`relaunch_same_agent` は `True` である。監視の結果ファイルの `reason` が何であっても同じである +- [ ] AC9: 使える結果が無いとき、`reason` は次の表で決まる + + | 監視の結果ファイルの `reason` | 結果ファイル | `reason` | + | --- | --- | --- | + | `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` | 問わない | その値 | + | `ok` / `missing` / ファイルが無い・読めない | 無い、または空 | `missing` | + | `ok` / `missing` / ファイルが無い・読めない | あるが JSON として読めない、または JSON オブジェクトでない | `unparsable` | + +- [ ] AC10: `relaunch_same_agent` は `reason` が `usage_limit` のときだけ `False` で、それ以外の理由では `True` である。`monitor_outcome.relaunch_same_agent(reason)` も同じ値を返す +- [ ] AC11: `read_launch_outcome` は `SystemExit` を投げず、標準出力・標準エラーへ何も書かない。監視の結果ファイルがあれば `monitor` にその辞書、無ければ `None` を持ち、`detail` に監視の `detail`(無ければ読めなかった理由の 1 文)を持つ +- [ ] AC12: 理由の一覧と起動し直しの可否の表を持つのは `plugins/ndf/scripts/lib/monitor_outcome.py` だけである。`plugins/ndf/skills/` の下に `usage_limit` を含む条件分岐(`== "usage_limit"` / `in (...)` の形)は無い + +cross-review が値を読む(#619): + +- [ ] AC13: `read-result` は使える結果が無いとき、`no_result_reason` に AC9 の `reason` を記録する。監視の結果ファイルがあれば `rounds[-1].<担当>.monitor_detail` に監視の `detail` を残す。終了コードは変更前と同じ(`unparsable` は 3、それ以外は 1)である +- [ ] AC14: `judge` は結果なしの担当があると、標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'` の 1 行を出す +- [ ] AC15: 結果なしの担当の理由に `relaunch_same_agent` が偽のもの(`usage_limit`)があるとき、`judge` は起動し直さない。`final=error` として終了コード 1 で終わり、標準エラーに担当・理由・`monitor_detail` が出る +- [ ] AC16: 理由がすべて起動し直してよいものなら、`judge` は変更前と同じく終了コード 7 で `RELAUNCH_AGENTS` を返し、2 度目の結果なしで `final=error` になる。終了コード 0 / 2 / 8 の枝は変更前と同じである +- [ ] AC17: `state.py report` のラウンド表で、結果なしの担当は `kiro=NO_RESULT(usage_limit)` の形で出る +- [ ] AC18: `docs/01-state-and-review.md` の理由の表に 10 個の理由が載る。10 個は AC1 の 9 語から `ok` を除いた 8 語に、`no_verdict` / `not_posted` を足したものである。`docs/03-review-output.md` の「monitor.py が誤って kill する場合の手順」に、上限に当たった場合の見分け方(`reason` と `monitor-outcomes.jsonl` の読み方)が載る + +止めた後に書かせない(#584): + +- [ ] AC19: `launch-cli.sh` で起動した CLI のプロセスは、自分の pid をプロセスグループの番号に持つ +- [ ] AC20: 3 秒後に子プロセスが結果ファイルを書く CLI を監視の上限で止めると、4 秒待っても結果ファイルが無い +- [ ] AC21: 対象の pid がプロセスグループの先頭でないとき、監視はその pid だけを止め、監視自身のプロセスグループへシグナルを送らない + +退行しないこと: + +- [ ] AC22: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る。既存の `test_monitor_*.py` と `test_monitor_outcome_unit.py` は変更せずに通る +- [ ] AC23: 「検証手段」の配布物の同期と定義の検査の 3 つのコマンドが、終了コード 0 で終わる +- [ ] AC24: cross-review の `SKILL.md` の骨組みの行は変えない。骨組みとは、レビューの起動 → 待ち → `read-result` → `judge` → 7 で起動し直し → 8 で `flush` の並びである。判定の終了コードの分岐が増えない + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 結果なしの理由が、状態ファイル(`no_result_reason`)・監視の結果ファイル・監視の記録・実行の要約の `launches[]` の 4 か所で同じ語彙で読める。理由の語彙を足すときに変える場所は `monitor_outcome.py` の 1 か所である | +| セキュリティ | `monitor_detail` は err.log の抜粋(最大 200 文字)で、作業ツリーの中の状態ファイルにだけ残る。実行の要約には入れない(既存の設計の決定 7 のまま) | +| システム環境 | macOS の bash 3.2 でも AC19 が成り立つこと(未確認。設計文書の「未確認のまま残ること」) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| `monitor.py` の終了コードと標準出力 | 変わらない。監視の結果ファイルと記録の `reason` に 2 つの値が増える | +| `monitor_outcome.REASONS` | 6 語から 9 語になる。読む側(`run_metrics.py` の `--by reason`)は値を集計するだけで、語彙の一覧を持たないため変更なし | +| 状態ファイル | `rounds[-1].<担当>` に `monitor_detail` が増える(結果なしのときだけ)。`no_result_reason` の値が 3 種類から 10 種類になる | +| `state.py judge` | `NO_RESULT_REASONS` の行が増える。利用上限では起動し直さず 1 で終わる。0 / 2 / 7 / 8 の意味は変わらない | +| `state.py read-result` | 終了コードは変わらない。`no_result_reason` の値が増える | +| `gitfacts.read_result` | この変更では触らない。契約(結果なしを値で返す)を G4 が実装する | +| 担当の CLI のプロセス | 独立したプロセスグループで動く。監視の停止がグループへ届く | +| 待ち時間の最悪値 | 利用上限では起動し直さないため、1 ラウンドあたり監視の上限 1 回分(レビュー 1200 秒)短くなる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | +| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | +| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | +| 手動確認(#619 の再現) | err.log に `Monthly request limit reached` を書く偽の kiro を PATH に置いて cross-review を 1 ラウンド回し、`judge` が 7 を返さず `NO_RESULT_REASONS='kiro=usage_limit'` を出して 1 で終わる | +| 手動確認(#584 の再現) | #584 の本文の再現スクリプト(3 秒後に子が書く `bash -c`)を `launch-cli.sh` 経由で起動し、`_kill_pid` の後に `late-result.json` が無い | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 2 つ以上の Skill が使う部品は `plugins/ndf/scripts/lib/` に置く(`plugins/ndf/scripts/lib/README.md` の「置いてよいもの・いけないもの」)。結末を読む関数は両 Skill が使うため共通層に置く | +| コーディング規約 | 外部コマンドとシグナルの挙動、正規表現の一致範囲は書く前に実行して確かめる(`AGENTS.md` の DO)。状態ファイルの鍵は追加だけで、既存の鍵の意味を変えない | +| テスト戦略 | 監視と起動は、PATH へ置いた偽の CLI を実プロセスとして動かす既存の形(`cross-review/tests/test_monitor_*.py`)。結末の読み取りは一時ディレクトリに監視の結果ファイルと結果ファイルを置いて単体に試す。`judge` / `report` は状態ファイルを作って呼ぶ | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、配布物の同期の検査、新しい文言を実物のログの形で試すこと | +| 確認してから行う | 利用上限で起動し直さない判断(AC15)。理由の語彙の追加(G4 が読む) | +| 行わない | cross-refactoring の取り込みの実装(G4)、投稿の重なりと骨組みの経路の変更(G5)、担当を外して回す判断(D-B)、監視の終了コードの変更 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| `gitfacts.read_result` を共通の関数の薄い包みとして残すか、呼び出し側が共通の関数を直接呼ぶか | G4(#728)の設計 | G4 の設計 Pull Request | + +## 既存の受け入れ条件との対応 + +既存の設計(PR #666)の P3 の受け入れ条件を、この文書のどこが引き継ぐかを示す。 + +| 既存 | この文書 | 変わったこと | +| --- | --- | --- | +| AC50 | AC2 | 同じ | +| AC51 | AC3 | 同じ | +| AC52 | AC4 | 同じ | +| AC53 | AC5 | 結果ファイルがあれば `ok` になることを明記 | +| AC54 | AC6 | 同じ | +| AC55 | AC9 + AC13 | 理由の表を `state.py` の規則から共通層の関数の規則へ移した。`unparsable` を共通の語彙に入れた | +| AC56 | AC14 | 同じ | +| AC57 | AC15 | 判定の条件を「`usage_limit` を含む」から「起動し直しの可否が偽」へ変えた。値は同じ | +| AC58 | AC16 | 同じ | +| AC59 | AC17 | 同じ | +| AC60〜AC62 | AC19〜AC21 | 同じ | +| AC63〜AC67 | — | #730(G5)へ | +| AC68 | AC18 | 理由が 10 個になった(`unparsable` を足し、`no_verdict` / `not_posted` を残す) | +| AC69 | AC18 | 同じ | +| — | AC1、AC8、AC10〜AC12 | 新設(結末を 1 つの値として読む契約) | +| — | AC24 | 新設(骨組みを変えない) | From 1bf86d85b325c622c3c2d405e47369544de9461b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 02:58:04 +0000 Subject: [PATCH 002/217] =?UTF-8?q?Docs:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BC=E3=81=AE=E6=8C=87=E6=91=98=E3=82=92=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=EF=BC=88=E3=83=86=E3=82=B9=E3=83=88=E8=A8=AD=E8=A8=88=E3=81=AE?= =?UTF-8?q?=E3=83=91=E3=82=B9=E3=81=A8=E5=A5=91=E7=B4=84=E6=96=87=E6=9B=B8?= =?UTF-8?q?=E3=81=AE=E8=A6=8B=E3=82=8B=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - issue-729-619-584-design.md の「テスト設計」の表のテストファイルのパスを、 「置き場所」のツリーと実際の配置に合わせて plugins/ndf/... からの完全なパスへ揃える - issue-662-598-537-619-584-583-design-contracts.md の「P3 で足す文言」の 429 の行に、 新しい設計では全担当の err.log を見ることを添える Co-Authored-By: Claude Fable 5.1 --- ...e-662-598-537-619-584-583-design-contracts.md | 2 +- issues/issue-729-619-584-design.md | 16 ++++++++-------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index 88ff805f..c29a03be 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -146,7 +146,7 @@ classDiagram | 理由 | 見るファイル | 文言(正規表現) | | --- | --- | --- | | `usage_limit` | err.log | `Monthly request limit reached` | -| `usage_limit` | claude の err.log と stdout.log | `"api_error_status"\s*:\s*429` | +| `usage_limit` | claude の err.log と stdout.log(新しい設計では全担当の err.log) | `"api_error_status"\s*:\s*429` | | `usage_limit` | err.log(既存の一致を付け替え) | `quota exceeded` / `rate limit exceeded` / `^HTTP/\d\S* 429 ` | | `early_error` | err.log(既存の一致を分ける) | `^HTTP/\d\S* (?:401\|403) ` | | `cli_timeout` | err.log(終了後だけ) | `print timeout after \S+ with turn in progress` | diff --git a/issues/issue-729-619-584-design.md b/issues/issue-729-619-584-design.md index 0c21eb59..d09c39c0 100644 --- a/issues/issue-729-619-584-design.md +++ b/issues/issue-729-619-584-design.md @@ -452,16 +452,16 @@ sequenceDiagram | 受け入れ条件 | 何で確かめるか | | --- | --- | -| AC1 | `REASONS` の 9 語と、6 つの状態の `reason_for` の返り値を固定する(`scripts/tests/test_monitor_outcome_unit.py`) | -| AC2〜AC5 | 各文言を 1 行書いた err.log / stdout.log と、終わったプロセスの pid ファイルで `monitor_agent` を呼び、状態・終了コード・結果ファイルの `reason`(`cross-review/tests/test_monitor_usage_limit.py` 新設)。AC5 は結果ファイルあり・なしの 2 通り | -| AC6 | 「実測」の 10 行をそのまま入力にし、表・引用・バッククォート・grep 形式で一致しないこと。claude の stdout.log の JSON で一致すること(`test_monitor_early_error.py` の形に倣う) | -| AC7 | 既存の `test_monitor_outcome_file.py` の標準出力のキーの検査を変更せずに通す。記録の行の `reason` を読む | -| AC8〜AC11 | 一時ディレクトリに監視の結果ファイル(各理由・壊れた JSON・無し)と結果ファイル(オブジェクト・配列・壊れた JSON・空・無し)を組み合わせて置き、`read_launch_outcome` の 5 つの欄。`capsys` で標準出力・標準エラーが空(`test_monitor_outcome_unit.py`) | +| AC1 | `REASONS` の 9 語と、6 つの状態の `reason_for` の返り値を固定する(`plugins/ndf/scripts/tests/test_monitor_outcome_unit.py`) | +| AC2〜AC5 | 各文言を 1 行書いた err.log / stdout.log と、終わったプロセスの pid ファイルで `monitor_agent` を呼び、状態・終了コード・結果ファイルの `reason`(`plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` 新設)。AC5 は結果ファイルあり・なしの 2 通り | +| AC6 | 「実測」の 10 行をそのまま入力にし、表・引用・バッククォート・grep 形式で一致しないこと。claude の stdout.log の JSON で一致すること(`plugins/ndf/skills/cross-review/tests/test_monitor_early_error.py` の形に倣う) | +| AC7 | 既存の `plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py` の標準出力のキーの検査を変更せずに通す。記録の行の `reason` を読む | +| AC8〜AC11 | 一時ディレクトリに監視の結果ファイル(各理由・壊れた JSON・無し)と結果ファイル(オブジェクト・配列・壊れた JSON・空・無し)を組み合わせて置き、`read_launch_outcome` の 5 つの欄。`capsys` で標準出力・標準エラーが空(`plugins/ndf/scripts/tests/test_monitor_outcome_unit.py`) | | AC12 | `git grep -n 'usage_limit' -- plugins/ndf/skills` の一致行が、文書とテストとテンプレートの文言だけである(テスト化せず、レビューの手順) | -| AC13 | 結果ファイル無し + 監視の結果ファイル(各理由)/ 無しで `read-result` を呼び、状態ファイルの `no_result_reason` と `monitor_detail` と終了コード(`cross-review/tests/test_read_result_reason.py` 新設) | -| AC14〜AC17 | 状態ファイルを作って `judge` と `report` を呼ぶ。`usage_limit` を含む / 含まない / 2 度目の 3 通り(`test_judge_no_result_reason.py` 新設) | +| AC13 | 結果ファイル無し + 監視の結果ファイル(各理由)/ 無しで `read-result` を呼び、状態ファイルの `no_result_reason` と `monitor_detail` と終了コード(`plugins/ndf/skills/cross-review/tests/test_read_result_reason.py` 新設) | +| AC14〜AC17 | 状態ファイルを作って `judge` と `report` を呼ぶ。`usage_limit` を含む / 含まない / 2 度目の 3 通り(`plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py` 新設) | | AC18 | 文書の `grep`(10 語と「見分け方」の節) | -| AC19〜AC21 | 3 秒後に子が書く偽の CLI を `launch-cli.sh` で起動し、`os.getpgid` と、監視の上限 2 秒で止めた後の結果ファイルの有無。先頭でない pid では `os.killpg` が呼ばれないことを `mock` で見る(`test_launch_cli_process_group.py` 新設) | +| AC19〜AC21 | 3 秒後に子が書く偽の CLI を `launch-cli.sh` で起動し、`os.getpgid` と、監視の上限 2 秒で止めた後の結果ファイルの有無。先頭でない pid では `os.killpg` が呼ばれないことを `mock` で見る(`plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py` 新設) | | AC22〜AC23 | 検証手段の表のコマンド | | AC24 | `SKILL.md` の骨組みの該当行(`bg-wait.sh run` から `case $JUDGE_RC` まで)を変更前と `diff` して差が無い | From a564ab3d756040620543ff5949da99df0948feb2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 03:21:41 +0000 Subject: [PATCH 003/217] =?UTF-8?q?Docs:=20cross-review=20=E3=81=AE?= =?UTF-8?q?=E3=80=8C=E6=95=B0=E3=81=88=E3=81=AA=E3=81=84=E3=80=8D=E5=8C=BA?= =?UTF-8?q?=E5=88=86=E3=82=92=E6=A3=84=E5=8D=B4=E3=81=AB=E9=99=90=E3=82=8B?= =?UTF-8?q?=E8=A8=AD=E8=A8=88=EF=BC=88#732=20#624=20#706=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 親 #732 の名前で要求と設計を新設し、既存の設計(PR #667)の P4 を置き換える。 数えないのは棄却(refute / not_reproduced)と minor 以下だけにし、誤りを示されて いない major を新しい区分 unrefuted として数える。既存の要求と契約の文書には 置き換え先の案内を 1 段落ずつ足した。 Co-Authored-By: Claude Fable 5.1 --- issues/issue-624-478-648-contracts.md | 2 + issues/issue-624-478-648-requirements.md | 2 + issues/issue-732-624-706-design.md | 298 +++++++++++++++++++++++ issues/issue-732-624-706-requirements.md | 226 +++++++++++++++++ 4 files changed, 528 insertions(+) create mode 100644 issues/issue-732-624-706-design.md create mode 100644 issues/issue-732-624-706-requirements.md diff --git a/issues/issue-624-478-648-contracts.md b/issues/issue-624-478-648-contracts.md index ffb784ce..3dee4624 100644 --- a/issues/issue-624-478-648-contracts.md +++ b/issues/issue-624-478-648-contracts.md @@ -5,6 +5,8 @@ 印の外し方(決定 5)で、「`state.py` の内部関数」の表の末尾 2 行と「変わらない項目の意味の変化」の先頭 2 行に 当たる。それ以外の契約は P5 のものである。 +**P4 の契約(`_classify_finding` の順 4 の条件と、`classification` の値)は [issue-732-624-706-design.md](issue-732-624-706-design.md) の「データ構造」「入出力の契約」へ移した。** 新しい設計は区分 `unrefuted` と項目 `unrefuted_reason` を足し、順 4 から根拠の条件を外す。印を外す契約(決定 5)はそのまま引き継がれた。以下の P4 の行は 2026-09-15 時点の記録として残す。 + ## データ構造(状態ファイル) ### 増える項目 diff --git a/issues/issue-624-478-648-requirements.md b/issues/issue-624-478-648-requirements.md index 367ebdf8..f2ed511f 100644 --- a/issues/issue-624-478-648-requirements.md +++ b/issues/issue-624-478-648-requirements.md @@ -38,6 +38,8 @@ ## 受け入れ条件(P4: #624 と #583 の収束の部分) +**この節は置き換えられた。** P4(AC1〜AC9)は [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) が持つ(親 #732 の設計。AC3 と AC6 は数える側へ改めた)。#583 の投稿の重なりは #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 + 反証する担当がいない指摘を数える: - [ ] AC1: 担当が 1 者(`only: "codex"`)で、証拠集約の印が付いたラウンドがある。そのラウンドに根拠を持つ `major` の diff --git a/issues/issue-732-624-706-design.md b/issues/issue-732-624-706-design.md new file mode 100644 index 00000000..428e09aa --- /dev/null +++ b/issues/issue-732-624-706-design.md @@ -0,0 +1,298 @@ +# #732 / #624 / #706: 「数えない」区分を棄却に限り、未反証の `major` を数える + +要求と受け入れ条件は [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) にある。この文書は「どう作るか」だけを扱う。 + +**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P4(決定 2〜5)を置き換える。** 引き継ぐ決定と改める決定は「既存の設計との対応」にある。P5(決定 6〜19)は #727 の設計が持つ。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 誤りを示されていない `major` を、反証の有無・担当の数・根拠の項目の有無によらず新しい指摘として数える | 1 者で回す利用者(#624)、担当が起動し直されたループ(#583 の収束の部分)、実行検証も支持も無い指摘が出た Pull Request(#706) | +| F2 | 数えた指摘に「なぜ独立に確かめられていないか」の理由を残す | 修正の担当(何が確かめられていないかを読む)、収束の後に記録を読む人 | +| F3 | 反証が揃わなかったラウンドを、全件を数える扱いへ戻す | 収束ループ全般 | +| F4 | 効果の測定の方式 `proposed` が、数える 3 区分を採る | 収束の記録を測る人 | + +## 決定の記録 + +決定 1 は文書の形、決定 2〜6 は区分、決定 7 は測定、決定 8 は印、決定 9〜11 はプロンプト・判定の出口・確定仕様を扱う。 + +### 決定 1: 設計文書は親 #732 の名前で新設し、既存の設計文書の本体は書き換えない + +既存の設計は 3 課題・2 本の Pull Request を 1 つの文書で扱い、P5(決定 6〜19)は #727 の設計が並行して置き換える。P4 の節をその場で書き換えると、P5 の変更と 1 つのファイルで競合し、承認する人が「どの束が何を変えたか」を読み分けられない。親 #732 は根本原因を「1 つの区分が 2 つの意味を兼ねる」と定め直しており、既存の決定 2〜4(区分の名前を増やさず、順 4 に条件を足す)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** + +既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。1 行でも触ると、既存の 19 件の決定がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求の文書と契約の文書にだけ足す(#729 の設計と同じ形)。 + +### 決定 2: 数えない判断を棄却と `minor` 以下に限り、誤りを示されていない `major` を新しい区分 `unrefuted` として数える + +区分の順 5(`insufficient_evidence`)には、いま 7 つの形が落ちる(「実測」の A〜F・H)。そのうち `minor` 以下(C)を除く 6 つは、いずれも「誰も誤りを示していない `major`」である。反証する担当がいない(A・B・H)、相手が `insufficient_evidence` / `out_of_scope` を返した(D・E)、根拠の 2 項目を欠く(B・F・H)のどれも、指摘が誤りだという主張ではない。**数えないのは、指摘が誤りだと示された `rejected` と、`APPROVE` を妨げない `minor` 以下だけにする。** 誤りを示されていない `major` は `unrefuted` として数え、修正の工程へ渡す。修正の担当が直す・却下の理由を返す・範囲外として起票するのは `needs_human_judgment` と同じである。 + +数えることで増えるラウンドは、指摘 1 件につき最大 1 回である。修正の工程が却下の理由を `rejected_findings` へ残し、次のラウンドのレビュープロンプトへ渡すため、同じ論点は戻らない。戻っても新規性の一致(位置・近傍・本文)が前のラウンドの指摘と結び、新規に数えない。#156 が避けた「同じ論点で 5 ラウンド」(#69)は、却下の記録が無かった頃の形である。 + +採らない案と理由: + +- **順 4 の条件に「`critiques` が空」を足す(既存の設計の決定 3)。** A と H は数えられるが、D・E(反証の機会があって支持されなかった)と B・F(根拠を欠く)は落ちたままである。親 #732 の完了条件「数えない判断が棄却された指摘に限られ」に当たらない +- **反証が 0 件のラウンドは絞り込まない(#624 の候補 1)。** ラウンド単位では、2 者のうち 1 件だけが後から入った #583 の形と、2 者で相手が `insufficient_evidence` を返した #706 の形を塞げない +- **誤りを示されていない `major` を全件 `needs_human_judgment` に入れる。** 数え方は同じになるが、「独立に確かめた担当がいる」と「誰も確かめていない」が同じ名前になり、修正の担当と測定がその差を読めない。親 #732 は別の区分にすることを採る手としている + +### 決定 3: 順 4(`needs_human_judgment`)の条件から根拠の 2 項目を外す + +根拠の 2 項目(`evidence` / `falsification`)は、別の担当がその指摘を確かめるための入力である。別の担当が `support` を返した、または 2 者以上が同じ指摘へ独立に到達した時点で、確かめる目的は果たされている。その後で 2 項目の欠けを理由に落とすと、「2 人が見て同じことを言っている」情報が判定に効かない(#706 の PR #45 で `support` の付いた 2 件が落ちた形)。**順 4 は `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上**とする。 + +`has_evidence` の値そのものは変えずに残す。反証のプロンプトが読み、測定と記録がそのことを持つ。**区分が読まなくなるだけである。** 4 項目を持たない指摘を捨てないという既存の決定(`docs/06` の「4 項目を持たない指摘」)は、この変更で「捨てず、数える」まで進む。 + +採らない案: **根拠を欠く指摘は `unrefuted` に入れ、`needs_human_judgment` には入れない。** 順 4 と順 5 の差が「独立に確かめたか」でなく「2 項目を書いたか」で決まり、`support` の意味が薄れる。 + +### 決定 4: 反証の担当が `insufficient_evidence` / `out_of_scope` だけを返した `major` も `unrefuted` として数える + +反証の値 `insufficient_evidence` は「可能性はあるが立証できない」で、`out_of_scope` は「この Pull Request の範囲から外れる」である。どちらも指摘が誤りだという主張ではなく、規約も「`refute` の代わりに使わせない」と定めている。誤りを示せない指摘を数えずに収束させると、未解決の `major` が残ったまま `approved` になる(#706 の観測そのもの)。**担当 2 者で相手が支持しなかった `major` を数えないという #156 の判断を改める。** #624 は「#156 の設計どおりで対象外」と書いたが、親 #732 の採る手と完了条件はこの形も棄却ではない側に置く。 + +この変更で、反証の担当が指摘を数から落とす手段は `refute` だけになる。プロンプトにそのことを書く(決定 9)。 + +採らない案: **`insufficient_evidence` を返された `major` は数えないまま残す(#156 の判断を保つ)。** 立証できない指摘と反証する担当がいない指摘を、状態ファイルから区別することはできる(`critiques` の有無)。しかし区別して前者だけを落とすと、実行検証を持たない Pull Request(文書だけの変更)では、担当が確かめられなかった `major` がすべて落ちる。#706 の PR #45 は文書の Pull Request である。 + +### 決定 5: `unrefuted` の理由を `unrefuted_reason`(`no_critique` / `not_supported`)として残す + +`rejected` が `rejection_reason` を持つのと同じ形で、`unrefuted` が「なぜ独立に確かめられていないか」を持つ。値は 2 つで、`no_critique`(反証を返した担当が 0 者)と `not_supported`(反証はあるが `support` も `refute` も無い)である。修正の担当は、`no_critique` なら誰も見ていない指摘、`not_supported` なら相手が確かめられなかった指摘として読める。測定は、1 者のループと 2 者のループでどちらの理由が多いかを同じ記録から読める。 + +根拠の 2 項目の欠けは理由に入れない。`has_evidence` が既に持つ値で、理由と直交する(`no_critique` かつ根拠なし、のように組になる)。 + +採らない案: **理由を持たず、`critiques` の有無から読む。** 読めはするが、`rejection_reason` と対になる形が崩れ、状態ファイルを読む人が区分ごとに違う導き方を覚えることになる。 + +### 決定 6: `insufficient_evidence` の名前は残し、`minor` 以下の残余だけに当てる + +`major` の 4 つの形が `unrefuted` へ移った後、順 6(旧 5)に残るのは「再現も棄却もされていない `minor` 以下」だけである。名前を `not_blocking` などへ変えると、付け替える先が 3 つ同時に出る。旧い状態ファイルの `classification`、`measure.py` の読み方、`docs/04` / `docs/06` / 確定仕様の語彙である。**意味は「立証されておらず、修正必須でもない」に狭まるが、名前は変えない。** 区分の表の条件の列で意味を定める。 + +採らない案: **`insufficient_evidence` を `not_blocking` へ改名する。** 語彙が正確になるが、この変更の目的(数えない判断を棄却に限る)に要らない。改名は測定の比較(変更の前後の記録)を難しくする。 + +### 決定 7: `measure.py` の `proposed` は数える 3 区分に揃え、2 か所の集合の一致をテストで固定する + +方式 `proposed` は「この変更の方式が採る指摘」で、定義は `state.py` の `COUNTED_CLASSIFICATIONS` と同じ集合である(`measure.py` のコメントが明記する)。`state.py` だけに `unrefuted` を足すと、収束の判定が数えた指摘を測定が採らず、`proposed` の再現率が実際より低く出る。**両方の定数を `("verified_blocking", "needs_human_judgment", "unrefuted")` にし、`test_measure.py` に 2 つの値が等しいことを見るテストを足す。** + +`measure.py` が `state.py` を import する形は採らない。`measure.py` は状態ファイルを読むだけの独立したスクリプトで(確定仕様の決定「測定は独立したスクリプトにする」)、`state.py` を読み込むと `gh` を呼ぶ側の前提を持ち込む。 + +### 決定 8: 反証が揃わない取り込みでは、先に付いていた印を外す + +既存の設計の決定 5 をそのまま引き継ぐ。`collect-critiques` は揃わないとき印を付けないが、既に付いている印は外さない。取り直しの後も `evidence_rounds` にそのラウンドが残ると、「印を付けないため、このラウンドは全件を数えます」の出力と実際の数え方が食い違う。`_handle_incomplete_critiques` の先頭でそのラウンドの番号を `evidence_rounds` から除く。 + +決定 2 の後もこの決定は要る。印の無いラウンドは `rejected` と `minor` も数えるため、反証が揃っていない(`refute` が届いていないかもしれない)ラウンドでは、全件を数える側が安全である。 + +### 決定 9: 反証のプロンプトに「`insufficient_evidence` は指摘を数から落とさない」を書く + +決定 4 の後、反証の担当が指摘を数から落とす手段は `refute` だけになる。プロンプトがそのことを言わないと、担当は従来どおり「判断できないものは `insufficient_evidence`」と返す。誤りを示せる指摘まで `insufficient_evidence` に流れ、修正の工程へ渡る。`critique.sh` の「返す値」の表の下に 1 段落を足す。書くのは 2 つで、`insufficient_evidence` を返しても指摘は数から落ちず修正の工程へ渡ることと、誤りを示せるなら理由を添えて `refute` を返すことである。 + +反証の値の語彙(5 つ)は変えない。変えるのは説明だけである。 + +### 決定 10: `judge` の本体・終了コード・出力の変数は変えず、区分の内訳を新しい出力として足さない + +#729(G3)の設計が `judge` の終了コード 0 / 2 / 7 / 8 と出力の変数を境界として固定している。この変更が触るのは `_new_finding_count` から下(`_counted_finding_keys` / `_apply_classification` / `_classify_finding`)と `_handle_incomplete_critiques` で、`cmd_judge` の行は書き換えない。区分ごとの件数を `judge` の標準出力へ足す案は採らない。骨組み(`SKILL.md`)が読まない値を足しても読む側が無く、出力の契約(`docs/04`)を増やすだけである。件数は状態ファイルの `classification` から読める。 + +### 決定 11: 確定仕様の区分の表は実装 Pull Request の同じ差分で更新する + +確定仕様 `docs/specifications/cross-review-evidence-based.md` は区分の表・区分ごとの行き先・収束の判定の表を持つ。「数えるのは 2 区分だけ」を決定としても書いている。`plan-to-spec` の工程まで待つと、実装が配布されてから確定仕様が古いまま残る期間ができ、`check-doc-staleness.py` がそれを拾わない(確定仕様は検査の対象外である)。**区分の表・行き先の表・決定の表・テスト観点の行を、実装の差分と同じ Pull Request で直す。** 経緯の節(背景・関連リンク)は `plan-to-spec` が足す。 + +## 実測 + +`develop` 9eaebe14、Python 3.14.4。`_classify_finding` に最小の指摘を渡した結果である(実行検証は `not_run`、担当 1 者の指摘は `origin_runtimes` 1 者)。 + +| 記号 | 入力 | いまの区分 | 数える | 変更後の区分 | 数える | +| --- | --- | --- | --- | --- | --- | +| A | `major`、根拠あり、反証なし(1 者) | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | +| B | `major`、根拠なし、反証なし | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | +| C | `minor`、根拠あり、反証なし | `insufficient_evidence` | いいえ | `insufficient_evidence` | いいえ | +| D | `major`、根拠あり、相手が `insufficient_evidence` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | +| E | `major`、根拠あり、相手が `out_of_scope` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | +| F | `major`、根拠なし、相手が `support` | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | +| G | `major`、根拠あり、相手が `support` | `needs_human_judgment` | はい | `needs_human_judgment` | はい | +| H | `major`、根拠なし、2 者が独立に出した | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | +| I | `major`、相手が `refute` | `rejected` | いいえ | `rejected` | いいえ | +| J | `major`、`not_reproduced` | `rejected` | いいえ | `rejected` | いいえ | +| K | `major`、`reproduced` | `verified_blocking` | はい | `verified_blocking` | はい | + +11 件のうち、数え方が変わるのは A・B・D・E・F・H の 6 件で、いずれも `major` である。`minor`(C)と棄却(I・J)と再現(K)と支持つき根拠あり(G)の 5 件は変わらない。 + +## 構成要素 + +| 要素 | 責務 | 変える・新設 | +| --- | --- | --- | +| 区分の判定(`_classify_finding`) | 6 つの区分を上から順に当てる。順 4 から根拠の条件を外し、順 5 に `unrefuted`(`major` 以上の残余)を置く(決定 2・3・4) | 変える | +| 区分の書き込み(`_apply_classification`) | 区分を要素へ書く。`rejected` に `rejection_reason`、`unrefuted` に `unrefuted_reason` を残し、他の区分では両方を消す(決定 5) | 変える | +| 数える集合(`COUNTED_CLASSIFICATIONS`。`state.py` と `measure.py`) | `unrefuted` を足した 3 つにする(決定 2・7) | 変える | +| 反証の不足の扱い(`_handle_incomplete_critiques`) | 先頭でそのラウンドの印を外してから、取り直す担当を返す(決定 8) | 変える | +| 反証のプロンプト(`critique.sh`) | `insufficient_evidence` が指摘を数から落とさないことを書く(決定 9) | 変える | +| 規約と契約(`docs/04` / `docs/05` / `docs/06`) | 6 区分・数える 3 つ・1 者と起動し直しの数え方・印を外すことを書く | 変える | +| 確定仕様(`docs/specifications/cross-review-evidence-based.md`) | 区分の表・行き先の表・決定の表・テスト観点を揃える(決定 11) | 変える | +| テスト(`test_classify_findings.py` / `test_critiques.py` / `test_measure.py` / `test_skill_layout.py`) | 実測の A〜K と AC10〜AC13、集合の一致、文書の語を固定する | 変える | + +```mermaid +graph TD + J[judge] --> NF[_new_finding_count] + NF --> CK[_counted_finding_keys] + CK --> AP[_apply_classification] + AP --> CL[_classify_finding] + CK --> CC[COUNTED_CLASSIFICATIONS] + COL[collect-critiques] --> IC[_handle_incomplete_critiques] + M[measure.py _proposed] --> CC2[COUNTED_CLASSIFICATIONS measure.py] +``` + +図の辺は呼び出しと参照を表す。`judge` と `collect-critiques` と `measure.py` は、変える要素の呼び出し元として置いた既存の要素である。プロンプト・規約・確定仕様・テストは呼び出しを持たないため図に含めない。 + +**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` の 1 プロセスと、状態ファイルを読む `measure.py` の 1 プロセスである。外部との出入り(`gh`)は変わらない。 + +### 置き場所 + +```text +plugins/ndf/skills/cross-review/ +├── SKILL.md # 変えない(`--only` の説明は #727 が持つ) +├── docs/04-contracts.md # classification の項を 6 区分・数える 3 つへ +├── docs/05-pool-and-convergence.md # 終了基準に 1 者と起動し直しの数え方を足す +├── docs/06-evidence.md # 区分の表を 6 行へ、印を外すことを書く +├── scripts/ +│ ├── critique.sh # 返す値の表の下に 1 段落を足す +│ ├── measure.py # COUNTED_CLASSIFICATIONS に unrefuted を足す +│ └── state.py # _classify_finding / _apply_classification / COUNTED_CLASSIFICATIONS / _handle_incomplete_critiques +└── tests/ + ├── test_classify_findings.py # A〜K、AC10、期待値を変える 3 件 + ├── test_critiques.py # AC13(印を外す) + ├── test_measure.py # AC11(一致)・AC12(proposed) + └── test_skill_layout.py # AC18〜AC20 の grep +docs/specifications/cross-review-evidence-based.md # 区分・行き先・決定・テスト観点 +``` + +`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。 + +## データ構造 + +状態ファイル `cross-review-pr<番号>-state.json` の `review_findings[]` の要素で変わるのは 2 項目である。**新しい最上位の項目は無く、`version` の類は持たないため上げない。** + +| 項目 | 型 | 空を許すか | 変更 | +| --- | --- | --- | --- | +| `classification` | 文字列 | 許さない(区分の後) | 値の集合が 6 つになる。`verified_blocking` / `verified_non_blocking` / `rejected` / `needs_human_judgment` / **`unrefuted`** / `insufficient_evidence` | +| `unrefuted_reason` | 文字列 | 項目が無いことを許す | **新設。** `classification` が `unrefuted` のときだけ持つ。値は `no_critique` / `not_supported`。区分が変わると消える(`rejection_reason` と同じ扱い) | +| `rejection_reason` | 文字列 | 項目が無いことを許す | 変わらない。`rejected` のときだけ持つ | +| `has_evidence` | 真偽値 | 許さない | 変わらない。**区分の条件から外れるが、値は残る** | + +### 区分の条件(`_classify_finding`) + +| 順 | 区分 | 条件 | 数える | 理由の項目 | +| --- | --- | --- | --- | --- | +| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | +| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | +| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | +| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | はい | — | +| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason` | +| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | + +`unrefuted_reason` は `critiques` が空なら `no_critique`、1 件以上あれば `not_supported` である。`critiques` は提案者以外の値だけを持つため、空は「反証を返した担当が 0 者」を表す。 + +### 機能とデータの対応 + +| 機能 | 読む | 書く | +| --- | --- | --- | +| F1 数える | `severity` / `verification.result` / `critiques[].verdict` / `origin_runtimes` | `classification` | +| F2 理由を残す | `critiques` の有無 | `unrefuted_reason`(他の区分では消す) | +| F3 印を戻す | `evidence_rounds` / `rounds[].critique_relaunched` | `evidence_rounds`(番号を除く) | +| F4 測る | `classification` / `evidence_rounds` / `merged_into` | (測定の出力。状態ファイルは書かない) | + +### 移行 + +この変更より前の状態ファイルは、次に `judge` か `collect-critiques` を呼んだ時点で `classification` が付け直される。区分は保存された値を読まず、毎回 `_apply_classification` が計算する。`unrefuted_reason` はそのときに付く。`measure.py` は保存された `classification` を読むため、収束の終わった旧い記録は旧い値のまま測られ、`unrefuted` は出ない。 + +## 入出力の契約 + +**`state.py` の引数・終了コード・標準出力の変数は変わらない。** 変わるのは内部関数の契約だけである。 + +| 関数 | 変更前 | 変更後 | +| --- | --- | --- | +| `_classify_finding(finding) -> str` | 5 つの値を返す。順 4 に `has_evidence` を求める | 6 つの値を返す。順 4 から `has_evidence` を外し、順 5 に `unrefuted` を置く。**純粋な関数で、出力も終了コードも持たない**(変わらない) | +| `_apply_classification(finding) -> str` | `rejected` に `rejection_reason` を書き、他では消す | 加えて `unrefuted` に `unrefuted_reason` を書き、他では消す | +| `COUNTED_CLASSIFICATIONS`(`state.py` / `measure.py`) | `("verified_blocking", "needs_human_judgment")` | `("verified_blocking", "needs_human_judgment", "unrefuted")`。2 か所の値は等しい | +| `_handle_incomplete_critiques(pr, st, round_no, missing)` | 印を付けない。既にある印は残す。終了コード 7 と `CRITIQUE_RETRY_AGENTS` を返す(変わらない) | 先頭で `evidence_rounds` からそのラウンドの番号を除く。それ以外は変わらない | +| `_new_finding_count(st, pr) -> (int, bool)` | 印のあるラウンドを 2 区分へ絞る | 印のあるラウンドを 3 区分へ絞る。返る値の意味は変わらない | +| `cmd_judge` | 0 / 2 / 7 / 8 / 1 | 変わらない。本体の行を書き換えない | +| `critique.sh` のプロンプト | 「判断できないものは `insufficient_evidence` にする」 | 加えて「`insufficient_evidence` を返しても指摘は数から落ちず、修正の工程へ渡る。誤りを示せるなら理由を添えて `refute`」 | + +## 処理の流れ + +```mermaid +graph TD + J[judge] --> K{payload を読めたか} + K -->|いいえ| U["(0, False) 全員 pass に従う"] + K -->|はい| M{ラウンドに印があるか} + M -->|いいえ| ALL[payload の全件を数える] + M -->|はい| C[指摘ごとに区分を決める] + C --> R{再現した} + R -->|はい| V[順 1・2 verified_*] + R -->|いいえ| X{not_reproduced / refute あり} + X -->|はい| RJ[順 3 rejected 数えない] + X -->|いいえ| S{major 以上} + S -->|いいえ| IE[順 6 insufficient_evidence 数えない] + S -->|はい| T{support あり / 2 者が独立に} + T -->|はい| NH[順 4 needs_human_judgment 数える] + T -->|いいえ| UR[順 5 unrefuted 数える] +``` + +順 5 に入った指摘は `critiques` の有無で `unrefuted_reason` が決まる。反証の取り込みで揃わないとき、`_handle_incomplete_critiques` がそのラウンドの印を外す。次に `judge` が数えるとき「印があるか」が「いいえ」へ進み、取り直して揃えば `_mark_evidence_round` が印を戻す。 + +**1 者で回したラウンドの収束は、`unrefuted` の新規性と `round_passes` の 2 つで決まる。** 担当が `APPROVE` か重大な指摘の無い `COMMENT` を返せば `round_passes` で収束する。`REQUEST_CHANGES` なら `unrefuted` の `major` が新規に数えられ、修正の工程へ進む。修正の後のラウンドで同じ指摘が戻れば前のラウンドと一致して新規 0 件になり、戻らなければ `APPROVE` で収束する。 + +## 非機能の実現方式 + +| 大項目 | 実現方式 | +| --- | --- | +| 運用・保守性 | 数える集合の 2 か所は `test_measure.py` の一致のテストが固定する。区分の理由は `rejection_reason` / `unrefuted_reason` として状態ファイルに残る | +| 移行性 | 区分は毎回計算し直すため、旧い状態ファイルの移行の処理は書かない。`version` は上げない | + +## テスト設計 + +置き場所は `plugins/ndf/skills/cross-review/tests/` の下である。 + +| 受け入れ条件 | 何で確かめるか | 置き場所 | +| --- | --- | --- | +| AC1・AC3 | 担当 1 者・印付きの状態ファイルを組み、`_new_finding_count` と `cmd_judge` の終了コードを見る(実測の A・C) | `test_classify_findings.py` | +| AC2 | A の指摘の `has_evidence` を偽にして同じ組を見る(実測の B) | 同上 | +| AC4 | `agy` + `kiro` で、`kiro` の指摘に反証が付き `agy` の指摘に付かない状態ファイル | 同上 | +| AC5・AC6 | `_classify_finding` に反証 `insufficient_evidence` / `out_of_scope` 付きの `major` を渡す(実測の D・E) | 同上 | +| AC7・AC8 | `has_evidence` 偽で `support` 付き、`has_evidence` 偽で `origin_runtimes` 2 者(実測の F・H) | 同上 | +| AC9 | 実測の I・J・K を既存のテストで確かめる(期待値を変えない) | 同上(既存) | +| AC10 | `_apply_classification` の後の `unrefuted_reason` の値と、区分が変わったときに消えること | 同上 | +| AC11 | `state_mod.COUNTED_CLASSIFICATIONS == measure_mod.COUNTED_CLASSIFICATIONS` と、3 つの値 | `test_measure.py` | +| AC12 | 印のあるラウンドの `unrefuted` を `proposed` の `found` が数える | 同上(既存の `test_proposed_takes_only_the_two_counted_classifications` を 3 区分へ改める) | +| AC13 | 印の付いた状態で `cmd_collect_critiques` を 2 回呼び、`evidence_rounds` と数え方を見る | `test_critiques.py` | +| AC14・AC15 | 既存のテスト(`minor` の区分、印なしの数え方)を期待値を変えずに通す | 既存のまま | +| AC16 | 3 件のテストの期待値を新しい区分へ改め、他は変えない | `test_classify_findings.py` | +| AC17 | `cmd_judge` の既存のテストを期待値を変えずに通す。差分に `cmd_judge` の行が無いことをレビューで見る | 既存のまま・設計 Pull Request のレビュー | +| AC18〜AC20 | 文書の語を `grep` するテスト | `test_skill_layout.py` | +| AC21・AC22 | コマンドの終了コード | 継続的統合と手元 | + +## 未確認のまま残ること + +| 項目 | 内容 | いつ決まるか | +| --- | --- | --- | +| 収束までのラウンド数の増え方 | 実測の D・E(相手が `insufficient_evidence` を返した `major`)を数えることで、2 者のループのラウンド数がどれだけ増えるかは測っていない | 配布後の運用で `measure.py` の出力を見る | +| `unrefuted` が多いときの修正の担当の負荷 | 誰も確かめていない `major` が修正の工程へ渡る件数が増える。却下の理由を書く回数が増える | 同上 | +| 担当を 3 者以上へ広げたときの順 3 と順 4 | 確定仕様が「広げるときに決め直す」としている。この変更は 2 者のまま | #478 の後 | +| `insufficient_evidence` の改名 | 決定 6 で残す。意味が狭まった名前をいつ付け替えるかは未決 | 要求が出たとき | +| テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | +| プロンプトの文言 | 決定 9 の段落は、含める 2 つの内容だけを決めた | **実装で決める** | + +## 申し送り(並行する設計との境界) + +| 相手 | 決めた契約 | +| --- | --- | +| #730(G5、#583) | **#583 の収束の部分はこの設計で塞がる。** 起動し直した担当の指摘は、取り込みの経路が証拠集約(`verify-findings` / `critique-round.sh`)を通っても通らなくても、反証を持たない `major` として `unrefuted` に入り数えられる。G5 が投稿を進行側へ移す設計を採っても、この判定は変わらない。G5 に残るのは投稿の重なりと、`JUDGE_RC -eq 7` の分岐が証拠集約を通らない点だけである | +| #729(G3) | `judge` の終了コード 0 / 2 / 7 / 8 と出力の変数を変えない。`cmd_judge` の本体の行を書き換えない(決定 10)。この設計が触る関数は `_new_finding_count` から下と `_handle_incomplete_critiques` で、G3 の `_read_review_result_file` と重ならない | +| #727(G1) | `--only` の意味づけ(使える者を 1 者へ絞る)と担当の決め方は G1 が持つ。1 者のときに何を数えるかはこの設計が持つ(処理の流れの最後の段落)。G1 の実装が 1 者で回す分岐を入れても、この設計が先に入っていれば #624 の誤った収束を踏まない。**既存の要求の文書と契約の文書に足す案内の段落は G1 も同じファイルへ足す可能性がある。** 節が違うため衝突は起きにくいが、起きたら後からマージする側が解く | +| #728(G4) | 触るファイルが重ならない(`refactor_lib` は区分を持たない) | + +## 既存の設計との対応 + +既存の設計(PR #667、2026-09-15)の P4 の決定との対応である。P5 の決定 6〜19 は #727 の設計が持つ。 + +| 既存の決定 | この文書 | 扱い | +| --- | --- | --- | +| 決定 1(P4 / P5 に分け、P4 を先に出す) | — | 束の分け方は実行計画(G1 / G2)が引き継いだ。P4 が先という順序は保つ(G1 の 1 者で回す分岐が #624 を踏まないため) | +| 決定 2(数えない判断は「反証を受けた単独の指摘」だけに掛け、記録から導く) | 決定 2・4・5 | **改める。** 数えないのは棄却と `minor` 以下だけにし、反証を受けて支持されなかった `major` も数える。記録から導く(項目を足さない)方針は `unrefuted_reason` を足すことで改める | +| 決定 3(順 4 の `support` の条件だけを外し、根拠と重大度の条件は残す) | 決定 2・3 | **改める。** 根拠の条件を外し、支持も反証も無い `major` は別の区分へ入れる。重大度の条件(`minor` を数えない)は引き継ぐ | +| 決定 4(区分の名前を増やさない) | 決定 2・6・7 | **改める。** `unrefuted` を足す。増やさない理由だった 2 か所の集合と語彙の同期は、テストと同じ差分で受ける | +| 決定 5(反証が揃わない取り込みでは先に付いていた印を外す) | 決定 8 | 引き継ぐ | diff --git a/issues/issue-732-624-706-requirements.md b/issues/issue-732-624-706-requirements.md new file mode 100644 index 00000000..086bba36 --- /dev/null +++ b/issues/issue-732-624-706-requirements.md @@ -0,0 +1,226 @@ +# #732 / #624 / #706: cross-review の「数えない」区分を棄却に限る + +設計は [issue-732-624-706-design.md](issue-732-624-706-design.md) にある。この文書は「何を満たすか」だけを扱う。 + +**この文書は、既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の P4(AC1〜AC9)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P5(#478 #648)は #727 の設計が持ち、この文書は触らない。#583 は #730 の設計が持つ。 + +## 依頼(原文) + +### #732(根本原因の親) + +> **cross-review の収束で「数えない」とする区分。** 場所は `plugins/ndf/skills/cross-review/scripts/state.py` の `_classify_finding`(3443 行目)と `COUNTED_CLASSIFICATIONS`(3431 行目)、規約 `docs/06-evidence.md` の区分の表である。 +> +> - `insufficient_evidence` が 2 つの場合を兼ねている。反証の機会があって支持されなかった場合と、立証の手段が無かった場合(反証する担当がいない・実行検証が無い・根拠の項目が欠ける)である +> - どちらも新規性の数から落ちるため、立証の手段が無かっただけの未解決の `major` が残ったまま収束する +> +> #583 の収束の部分も、同じ区分を通る。 +> +> ## 採る手 +> +> 分離(`extract_strategy`)。「棄却された(`refute` / `not_reproduced`)」と「立証の機会が無かった」を別の区分にし、数えない判断を前者だけに掛ける。 +> +> ## 完了条件 +> +> - 数えない判断が棄却された指摘に限られ、反証の機会が無かった `major` が新規性に残ることを検査が確かめる +> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる(子 issue はその時点の棚卸が「閉じてよい」で閉じる) + +### #624 + +> `cross-review` を `--only codex` で 1 者だけにして回すと、codex が `REQUEST_CHANGES` で新しい指摘を投稿したラウンドでも、`judge` が収束と判定する(ndf 10.10.1、2026-09-13)。 +> +> `--only` で担当が 1 者だと、反証する他の担当がいないため数える区分に入る指摘が 0 件になり、`_evaluate_convergence`(`:2848`)の `findings_measurable and new_findings == 0` が真になる。 +> +> 状態ファイルを最小の形で組み、関数を直接呼んだ再現(`--only codex`、証拠付きの major 1 件、印の付いたラウンド 1): +> +> ```text +> new_finding_count (0, True) classification insufficient_evidence +> round_passes False converged True +> ``` + +### #706 + +> `cross-review` で、**未解決の `major` が残っているのに、指摘が数えない区分 `insufficient_evidence` へ落ち、新規性の母集合から外れて「新しい指摘 0 件」と判定される。** 結果は `approved` になる。 +> +> ideabase の PR #45 のラウンド 2 で、3 件の指摘がいずれも `suggested_check` を実行できず `insufficient_evidence` になり、judge が `NEW_FINDINGS=0` を返した。指摘のうち 2 件は別の担当が `support` を付けており、こちらでもコードを読んで再現を確かめられた(文書が実装と食い違っていた)。 +> +> 数えない区分へ落ちるのは、次のいずれかに当たる指摘である。 +> +> - 反証の担当が `support` を返さなかった(`insufficient_evidence` を返した、または反証が無い) +> - 根拠の 2 項目(`evidence` / `falsification`)のどちらかが欠けている +> - 重要度が `minor` 以下である +> +> 対処の候補: 実行検証ができない指摘は `insufficient_evidence` ではなく別の区分(未検証)として新規性に数える、または他の担当の `support` が付いた指摘は区分によらず数える。 + +## 目的 + +- 収束の判定が数えないのは、棄却された指摘(実行して再現しなかった・`refute` を受けた)と `minor` 以下の指摘だけになる。誰にも誤りを示されていない `major` は、担当の数・反証の有無・根拠の項目の有無によらず新しい指摘として残り、修正の工程へ渡る +- `insufficient_evidence` が「反証の機会があって支持されなかった」と「立証の機会が無かった」の 2 つを兼ねる状態を解き、区分を読めば未解決の理由が分かる +- 1 者で回したループ(#624)と、起動し直した担当の指摘(#583 の収束の部分)と、実行検証も支持も無い指摘(#706)が、同じ 1 つの直しで数えられる + +## 前提 + +- 前提 1: 修正の工程(`fix`)は Pull Request の未解決のスレッドを区分によらず全件読む。区分は収束の判定と測定だけが読む(`plugins/ndf/skills/fix/` と `docs/02-fix-and-rotation.md` に区分を読む箇所が無い。`grep -rn classification` が 0 件)。**この前提が崩れると、数えるだけで修正へ渡らない指摘が生まれ、同じ指摘が毎ラウンド新規に見える** +- 前提 2: 却下した指摘は `rejected_findings` に位置と理由つきで残り、次のラウンドのレビュープロンプトへ渡る(#156 の 1 本目)。数える区分が増えても、同じ論点が戻ることはこの記録が止める +- 前提 3: 新規性の一致(位置・近傍・本文)は変えない。前のラウンドと一致する指摘は、区分によらず新規に数えない + +## 対象範囲 + +含む: + +- `state.py` の区分の判定(`_classify_finding` / `_apply_classification`)と、数える区分の集合(`COUNTED_CLASSIFICATIONS`。`measure.py` の同名の定数も) +- `state.py` の反証が揃わないときの印の外し方(`_handle_incomplete_critiques`) +- 反証のプロンプト(`critique.sh`)の `insufficient_evidence` の説明 +- `cross-review` の `docs/04` / `docs/05` / `docs/06` と、確定仕様 `docs/specifications/cross-review-evidence-based.md` の区分の表 +- テスト(`tests/test_classify_findings.py` / `tests/test_critiques.py` / `tests/test_measure.py` / `tests/test_skill_layout.py`) + +含まない: + +| 扱わないもの | 理由 | +| --- | --- | +| `--only` の意味づけ・使える担当の決め方・再開の引数 | #727(G1)の設計が持つ。1 者のときに何を数えるかだけをこの文書が決める | +| 起動し直した担当の投稿の重なり、投稿を進行側へ移すこと | #730(G5)の設計が持つ。#583 のうちこの文書が扱うのは、起動し直した担当の指摘が数えられずに収束する部分だけである | +| `read-result` / `judge` の終了コードと出力の変数、結末の語彙 | #729(G3)の設計が持つ。`judge` の 0 / 2 / 7 / 8 とその出力の変数は変えない | +| `cross-refactoring` の収束 | 区分を持たない(`refactor_lib` は `classification` を読まない) | +| 新規性の一致の判定(位置・近傍・本文)と振動の閾値 | 変えるのは母集合だけである(#156 の設計のまま) | +| `minor` 以下の指摘を数えること | `minor` は `APPROVE` を妨げない(`docs/03` の判定の基準)。数えると、`minor` だけの `REQUEST_CHANGES` でラウンドが続く | +| 反証の値(5 つ)の追加・削除 | 反証の担当が返す語彙は変えない。変えるのは、返った値を区分がどう読むかである | +| クラス図・型の追加 | 型を追加・変更しない(関数と辞書で組んだ状態ファイルを扱う)。状態ファイルの形は設計文書の「データ構造」が持つ | +| システムの文脈・配置の図 | 動くのは `state.py` の 1 プロセスで、外部との出入り(`gh`)は変わらない | +| `CHANGELOG.md` と版数 | 配布の工程が書く | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 数える | 収束の判定(新規性の層)が、そのラウンドの新しい指摘として件数に入れること。数える区分は `COUNTED_CLASSIFICATIONS` が持つ | +| 棄却 | 指摘が誤りだと示されたこと。実行して再現しなかった(`not_reproduced`)か、別の担当が `refute` を返した。区分は `rejected` | +| 反証の機会 | 提案者以外の担当が、その指摘へ賛否を返したこと。`critiques` が 1 件以上ある | +| 立証の機会が無かった | 反証の機会が無い、または反証はあるが `support` も `refute` も無い。誤りは示されていないが、独立に確かめた担当もいない | +| 未反証(`unrefuted`) | 新しい区分。`major` 以上で、再現も棄却もされておらず、独立に確かめた担当もいない指摘 | +| 独立に確かめた | 提案者以外の担当が `support` を返した、または 2 者以上が同じ指摘を独立に出した(`origin_runtimes` が 2 者以上) | +| 根拠の 2 項目 | `evidence` と `falsification`。両方が空でないとき `has_evidence` が真になる | +| 印 | そのラウンドが統合・実行検証・反証を通ったこと(`evidence_rounds`)。印のあるラウンドだけが区分で絞られ、無いラウンドは全件を数える | +| 代表 | 統合した組で判定が読む 1 件。束ねられた側は `merged_into` を持つ | + +## 受け入れ条件 + +### 区分(#624 / #706 / #583 の収束の部分) + +- [ ] AC1: 前提: 担当が 1 者(`only: "codex"`)で、印の付いたラウンドが 1 つあり、そのラウンドに根拠の 2 項目を持つ `major` の指摘が 1 件、反証は 0 件、前のラウンドは無い + 操作: `_new_finding_count` と `judge` を呼ぶ + 結果: `_new_finding_count` が `(1, True)` を返し、`judge` が終了コード 2 で終わる。指摘の `classification` は `unrefuted`(変更前は `(0, True)` と終了コード 0。#624 の再現) +- [ ] AC2: AC1 の指摘が根拠の 2 項目のどちらかを欠く(`has_evidence` が偽)とき、`_new_finding_count` は `(1, True)` を返し、`classification` は `unrefuted`、`has_evidence` は偽のまま残る +- [ ] AC3: AC1 の指摘が `minor` のとき、`_new_finding_count` は `(0, True)` を返し、`classification` は `insufficient_evidence` である +- [ ] AC4: 前提: 担当が `agy` + `kiro` で印の付いたラウンドに、`kiro` の指摘は反証を持ち、`agy` の根拠を持つ `major` が反証 0 件のまま入っている(起動し直した担当の指摘が、反証を取り込んだ後に取り込まれた形) + 結果: `_new_finding_count` が `agy` の 1 件を数える。`classification` は `unrefuted`(#583 の収束の部分) +- [ ] AC5: 担当 2 者で、相手が根拠を持つ `major` へ `insufficient_evidence` を返し、`support` も `refute` も無いとき、`classification` は `unrefuted` で、数える(変更前は `insufficient_evidence` で数えない。#706 の「反証の担当が `support` を返さなかった」) +- [ ] AC6: AC5 で相手が `out_of_scope` を返したときも、`classification` は `unrefuted` で、数える +- [ ] AC7: `major` の指摘に `support` が 1 件以上あるとき、`has_evidence` の真偽によらず `classification` は `needs_human_judgment` である(変更前は `has_evidence` が偽なら `insufficient_evidence`。#706 の「`support` が付いていても根拠の 2 項目の欠落で落ちる」) +- [ ] AC8: `origin_runtimes` が 2 者以上の `major` は、`has_evidence` の真偽によらず `needs_human_judgment` である +- [ ] AC9: `refute` が 1 件以上、または実行検証が `not_reproduced` の指摘は `rejected` になり、`unrefuted` より先に当たる。`reproduced` の指摘は `verified_blocking` / `verified_non_blocking` になる(変更前と同じ) +- [ ] AC10: `unrefuted` の指摘は `unrefuted_reason` を持ち、値は `no_critique`(反証を返した担当が 0 者)か `not_supported`(反証はあるが `support` も `refute` も無い)のどちらかである。他の区分の指摘は `unrefuted_reason` を持たない(区分が変わったときは消える) +- [ ] AC11: `COUNTED_CLASSIFICATIONS` は `verified_blocking` / `needs_human_judgment` / `unrefuted` の 3 つで、`state.py` と `measure.py` の値が一致する +- [ ] AC12: `measure.py` の方式 `proposed` は、印のあるラウンドの `unrefuted` の指摘を `found` に数える + +### 反証の取り直しで揃わないときは印を外す + +- [ ] AC13: 前提: 印が付いたラウンドがある + 操作: `collect-critiques` を実行し、反証が揃わない(終了コード 7) + 結果: `evidence_rounds` からそのラウンドの番号が消え、`_new_finding_count` は payload の全件を数える。取り直した後も揃わないとき(2 度目)も印は付かない + +### 退行しない + +- [ ] AC14: `minor` 以下の指摘の区分は変わらない。`support` が付いた `minor` は `insufficient_evidence`、再現した `minor` は `verified_non_blocking` で、どちらも数えない +- [ ] AC15: 印を持たないラウンドの数え方(payload の全件)は変わらない +- [ ] AC16: `tests/test_classify_findings.py` の既存のテストのうち、期待値を変えるのは旧い区分を固定した 4 件だけである。それ以外は期待値を変えずに通る。4 件は次のとおり + `test_support_without_evidence_is_insufficient` / `test_nothing_matched_is_insufficient` / + `test_a_finding_without_verification_is_readable` / `test_only_two_classifications_are_counted` +- [ ] AC17: `judge` の終了コード(0 / 2 / 7 / 8 / 1)と標準出力の変数(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS` / `RELAUNCH_AGENTS`)は変わらない。`cmd_judge` の本体の行を書き換えない + +### 文書 + +- [ ] AC18: `docs/06-evidence.md` の区分の表が 6 行になり、`unrefuted` の行と「数えるのは 3 つ」を持つ。`docs/04-contracts.md` の `classification` の項が 6 区分と数える 3 つを書く。`docs/05-pool-and-convergence.md` の終了基準が、担当 1 者と起動し直した担当の指摘の数え方を書く。次の 4 つがそれぞれ 1 行以上を出す + + ```bash + grep -n "unrefuted" plugins/ndf/skills/cross-review/docs/06-evidence.md + grep -n "unrefuted" plugins/ndf/skills/cross-review/docs/04-contracts.md + grep -n "unrefuted" plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md + grep -n "印を外す" plugins/ndf/skills/cross-review/docs/06-evidence.md + ``` + +- [ ] AC19: `critique.sh` のプロンプトが「`insufficient_evidence` を返しても指摘は数から落ちない。誤りを示せるなら `refute` を返す」ことを書く。`grep -n "数から落ち" plugins/ndf/skills/cross-review/scripts/critique.sh` が 1 行以上を出す +- [ ] AC20: `docs/specifications/cross-review-evidence-based.md` の区分の表と区分ごとの行き先の表が、`docs/06-evidence.md` と同じ 6 区分を持つ。`grep -c "unrefuted" docs/specifications/cross-review-evidence-based.md` が 2 以上を出す + +### 全体 + +- [ ] AC21: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [ ] AC22: 次の 4 つが終了コード 0 で終わる + + ```bash + python3 scripts/check-skill-frontmatter.py + python3 scripts/check-doc-staleness.py + python3 scripts/check-markdown-links.py --root . + python3 scripts/check-doc-line-limit.py + ``` + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 数える区分の集合は `state.py` と `measure.py` の 2 か所にあり、一致をテストが固定する(AC11)。区分の理由(`rejection_reason` / `unrefuted_reason`)は状態ファイルに残り、収束の後に「なぜ数えたか・数えなかったか」を読める | +| 移行性 | 状態ファイルの `version` は上げない。この変更より前の状態ファイルは、次に `judge` を呼んだ時点で区分が付け直される(区分は毎回計算し直す)。旧い記録の `classification` を `measure.py` が読むときは、旧い値のまま読む | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | `state.py` の引数・終了コード・出力の変数は変わらない。状態ファイルの `classification` に値 `unrefuted` が増え、`unrefuted_reason` が増える(読む側は `measure.py` だけ) | +| データ | 状態ファイルの形は変わらない(項目が 1 つ増える。`version` は上げない) | +| 既存の振る舞い | 印のあるラウンドで、反証を受けていない・支持されていない・根拠の項目を欠く `major` が数えられるようになる。2 者で相手が `insufficient_evidence` を返した `major` も数える(#156 の判断を改める。理由は設計文書の決定 4)。収束までのラウンド数が増えることがある | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | +| 静的解析・文書の検査 | AC22 の 4 コマンド | +| 再現手順 | #624: 状態ファイルを最小の形で組み `_new_finding_count` と `cmd_judge` を呼ぶ(AC1)。#706: `_classify_finding` に反証 `insufficient_evidence` 付きの `major` と、`support` 付きで根拠を欠く `major` を渡す(AC5 / AC7) | +| 手動確認 | 無し(すべてテストで確かめる) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | Skill の実体は `plugins/ndf/skills/cross-review/`。テストは同じ Skill の `tests/`。`dev.kiro` / `dev.agy` は symlink で参照するため書き写す配布物は無い | +| コーディング規約 | `state.py` は stdlib だけの uv 自己完結スクリプト(`AGENTS.md` / `cross-review/SKILL.md`)。区分の判定は純粋な関数で、出力も終了コードも持たない | +| テスト戦略 | 区分は `_classify_finding` の単体で、数え方は状態ファイルを組んだ `_new_finding_count` と `cmd_judge` の終了コードで確かめる(既存の `test_classify_findings.py` の形) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 区分の判定と数える集合の変更、印の外し方、文書と確定仕様の更新、テスト | +| 確認してから行う | 無し | +| 行わない | `--only` の扱い(#727)、投稿の移動(#730)、終了コードと結末の語彙(#729)、`minor` を数えること | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| `unrefuted` を数えることで収束までのラウンド数がどれだけ増えるか | 実装の後の運用で `measure.py` の出力を見る | 配布後 | + +## 既存の受け入れ条件との対応 + +既存の設計(PR #667、2026-09-15)の P4 の受け入れ条件 AC1〜AC9 との対応である。 + +| 既存 | この文書 | 扱い | +| --- | --- | --- | +| AC1(1 者、根拠付き `major`、反証 0 件 → 数える) | AC1 | 引き継ぐ。区分の名前が `needs_human_judgment` から `unrefuted` へ変わる | +| AC2(1 者、`minor` → 数えない) | AC3 | 引き継ぐ | +| AC3(1 者、根拠なし → 数えない) | AC2 | **改める。** 根拠の項目の欠けは立証の機会が無かった側に入る(親 #732) | +| AC4(起動し直した担当の `major` → 数える) | AC4 | 引き継ぐ。区分は `unrefuted` | +| AC5(揃わないときに印を外す) | AC13 | 引き継ぐ | +| AC6(2 者で相手が `insufficient_evidence` / `out_of_scope` → 数えない) | AC5 / AC6 | **改める。** 数える(設計文書の決定 4) | +| AC7(`origin_runtimes` 2 者の区分は変わらない) | AC8 | 引き継ぐ。根拠の条件は外す | +| AC8(印なしのラウンドと実行検証の区分は変わらない) | AC9 / AC15 / AC16 | 引き継ぐ。期待値を変える既存のテスト 4 件を名指しする | +| AC9(文書の 3 つの grep) | AC18 | 引き継ぐ。語を `unrefuted` に変える | From fd5c9f11bf84a6e40c6bac817e5446631f1df905 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 03:20:30 +0000 Subject: [PATCH 004/217] =?UTF-8?q?Docs:=20=E4=BD=BF=E3=81=88=E3=82=8B?= =?UTF-8?q?=E8=80=85=E3=81=AE=E6=B1=BA=E5=AE=9A=E3=81=A8=E5=B8=AD=E3=81=AE?= =?UTF-8?q?=E5=9F=8B=E3=82=81=E6=96=B9=E3=82=92=E5=85=B1=E9=80=9A=E5=B1=A4?= =?UTF-8?q?=E3=81=B8=E7=A7=BB=E3=81=99=E8=A8=AD=E8=A8=88=EF=BC=88#727=20#6?= =?UTF-8?q?87=20#478=20#664=20#648=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 親 #727 の名前で要求・設計・契約の 3 文書を新設し、既存の設計(PR #667)の P5 を置き換える。 既存の要求と契約の文書には置き換え先の案内を 1 段落ずつ足した(設計文書の本体は触らない)。 - 使える者の決定を共通層 `resolve_participants` へ移し、`check_auth` を `probe_auth` に替える - cross-review は各ラウンドに 2 席を確保する(使える者 → ホスト → 同じランタイムの 2 つ目) - cross-refactoring の既定の参加者を codex / kiro / ホストにし、レビュー担当の記録を消す - 再開で明示的に渡した引数だけを反映する規則を共通層 `apply_resume_args` に置く Refs #727 #687 #478 #664 #648 Co-Authored-By: Claude Fable 5.1 --- issues/issue-624-478-648-contracts.md | 4 + issues/issue-624-478-648-requirements.md | 4 + issues/issue-727-687-478-664-648-contracts.md | 271 ++++++++++ issues/issue-727-687-478-664-648-design.md | 487 ++++++++++++++++++ .../issue-727-687-478-664-648-requirements.md | 380 ++++++++++++++ 5 files changed, 1146 insertions(+) create mode 100644 issues/issue-727-687-478-664-648-contracts.md create mode 100644 issues/issue-727-687-478-664-648-design.md create mode 100644 issues/issue-727-687-478-664-648-requirements.md diff --git a/issues/issue-624-478-648-contracts.md b/issues/issue-624-478-648-contracts.md index ffb784ce..7609ccf1 100644 --- a/issues/issue-624-478-648-contracts.md +++ b/issues/issue-624-478-648-contracts.md @@ -5,6 +5,10 @@ 印の外し方(決定 5)で、「`state.py` の内部関数」の表の末尾 2 行と「変わらない項目の意味の変化」の先頭 2 行に 当たる。それ以外の契約は P5 のものである。 +**P5 の契約は [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) が置き換える** +(親 #727 の設計、2026-09-19)。6 項目は 1 つの `participants` に畳まれ、`review_assign` は `review_seats` に、 +`check_auth` は `probe_auth` に替わる。この文書の P5 の節は記録として残し、書き換えない。 + ## データ構造(状態ファイル) ### 増える項目 diff --git a/issues/issue-624-478-648-requirements.md b/issues/issue-624-478-648-requirements.md index 367ebdf8..d85116ff 100644 --- a/issues/issue-624-478-648-requirements.md +++ b/issues/issue-624-478-648-requirements.md @@ -5,6 +5,10 @@ **3 つの課題は 2 本の Pull Request で直す**(設計文書の決定 1)。P4 は #624 と、#583 のうち収束の誤りの部分を 直す。P5 は #478 と #648 を直す。マージは P4 → P5 の順である。受け入れ条件も Pull Request ごとに分ける。 +**P5(#478 / #648、AC10〜AC30)は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) が +置き換える**(親 #727 の設計、2026-09-19)。対応は置き換え先の「既存の受け入れ条件との対応」にある。この文書の P5 の +節は記録として残し、書き換えない。 + ## 目的 - 反証する担当がいない指摘が、数える区分から黙って落ちなくなる。1 者で回しても、起動し直した担当の指摘でも、 diff --git a/issues/issue-727-687-478-664-648-contracts.md b/issues/issue-727-687-478-664-648-contracts.md new file mode 100644 index 00000000..e8a9eee3 --- /dev/null +++ b/issues/issue-727-687-478-664-648-contracts.md @@ -0,0 +1,271 @@ +# #727 / #687 / #478 / #664 / #648: 状態ファイル・引数・関数の契約 + +[issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) の続きである。決定の理由は設計文書の +「決定の記録」にあり、この文書は形だけを書く。 + +**この文書は既存の契約 [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) の P5 の部分を置き換える。** +既存の 6 項目(`available_reviewers` ほか)は 1 つのオブジェクト `participants` に畳み、cross-refactoring も同じ形を持つ。 + +## データ構造(状態ファイル) + +### 両 Skill の最上位に増える 2 項目 + +| 項目 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `participants` | オブジェクト(下の表) | 許さない(項目が無いことは許す) | 使える者の解決の結果。項目が無いのは「この変更の前に始めた実行」 | +| `resume_changes` | オブジェクトの配列 | 許す(空の配列) | 再開で変えた値の記録。追記だけを行う | + +`participants` の中身: + +| 項目 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `pool` | 文字列の配列 | 許さない | 母集合の既定(`ALL_RUNTIMES` の順)。cross-review は全ランタイム − ホスト、cross-refactoring は `refactor_pool(host)` | +| `included` | 文字列の配列 | 許す(空) | `--include` で足した者。空は「足していない」 | +| `excluded` | 文字列の配列 | 許す(空) | `--exclude` で外した者。空は「外していない」 | +| `available` | 文字列の配列 | 許す(空。cross-review だけ) | 使える者。`ALL_RUNTIMES` の順。`only` があれば `[only]`。cross-refactoring では 1 者以上 | +| `unavailable` | オブジェクト(名前 → 理由の文字列) | 許す(空) | 確認を通らなかった者と `probe_auth` の `detail`。空は「全員が通った」か「確認を飛ばした」 | +| `probe_skipped` | 真偽値 | 許さない | `NDF_SKIP_AUTH_CHECK` で確認を飛ばしたか。`unavailable` が空である理由を区別する | +| `require_all` | 真偽値 | 許さない | `--require-all` の値。新規の既定は `false` | +| `fallback` | 文字列の配列 | 許す(空) | **cross-review だけ。** 席の埋め合わせに使える者(ホストの確認が通れば `[host]`)。`available` が 2 者以上なら空 | + +`resume_changes[]` の要素は既存の契約と同じ(`at` / `field` / `from` / `to`)。`field` は状態ファイルの鍵で、 +`participants` を作り直したときは `participants` の 1 件として積む(中の項目ごとには積まない)。 + +### 変わらない項目の意味の変化 + +| Skill | 項目 | 変わること | +| --- | --- | --- | +| 両方 | `host` | 変わらない。再開の `--host` で書き換えない | +| cross-review | `only` | 再開の `--only` で変わる。`--only none` で `null` へ戻る | +| cross-review | `max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` | 再開で明示的に渡したときだけ変わる | +| cross-review | `rounds[].reviewers` | **席の名前**が入る(`claude-2` のような値を取りうる)。過去のラウンドは再開で書き換えない | +| cross-review | `rounds[].<席>` | 鍵が席の名前になる。既存の `rounds[].codex` などはそのまま | +| cross-refactoring | `runtimes` | 使える者(`participants.available`)と同じ値。提案の対象と適用の輪番の両方が読む。既存の読み手(提案の取り込み・`prepare-worktrees.sh`)のために残す | +| cross-refactoring | `max_outer_rounds` / `max_test_rounds` / `max_fix_rounds` / `max_items_per_round` | 再開で明示的に渡したときだけ変わる | +| cross-refactoring | `models` / `target_scope` | 変わらない。再開で違う値が渡されたら知らせる | + +### cross-refactoring の新規の状態から消える項目 + +| 項目 | 理由 | +| --- | --- | +| 最上位の `impl_capable` | 母集合が 1 つになり `runtimes` と同じ値になる(決定 5) | +| `rounds[].reviewers` / `rounds[].reviewer_models` | レビュー工程が #436 で消えており、記録と表示にしか使われない(決定 6) | + +### 実体の関係 + +```mermaid +erDiagram + 状態ファイル ||--|| 参加者 : participants + 状態ファイル ||--o{ 再開で変えた値 : resume_changes + 状態ファイル ||--o{ ラウンド : rounds + 参加者 { + array pool + array included + array excluded + array available + object unavailable + bool probe_skipped + bool require_all + array fallback + } + ラウンド { + int round + array reviewers + string impl + } +``` + +`ラウンド.reviewers` は cross-review、`ラウンド.impl` は cross-refactoring が持つ。 + +### 機能とデータの対応 + +| 機能 | `participants` | `runtimes`(cross-refactoring) | `only` / 上限の項目 | `resume_changes` | `rounds[].reviewers` / `impl` | +| --- | --- | --- | --- | --- | --- | +| F1 使える者の解決(新規の `init`) | C | C | C | C(空) | — | +| F2 席の埋め方(`start-round`) | R | — | R | — | C | +| F3 除外と追加 | C | C | — | — | — | +| F4 適用の輪番(`start-round` / 適用ラウンド) | — | R | — | — | C | +| F5 再開の反映 | U | U | U | U(追記) | R | +| F6 報告 | R | R | R | R | R | + +### 時系列の扱い + +`participants` と `runtimes` は上書きし、過去の値は `resume_changes` に事象として積む。ラウンドごとの担当は +`rounds[]` が持つため、上書きで失われるのは「どの時点でどの一覧だったか」だけで、それを `resume_changes` が補う。 + +### 移行 + +**既存の状態ファイルは書き換えない。** 項目が無いときの読み方を決める。 + +| Skill | 項目が無いとき | 読み方 | +| --- | --- | --- | +| cross-review | `participants` | `host` があれば `review_seats(r, review_pool(host), [])`(変更前の輪番と同じ値)。`host` も無ければ `codex` / `agy` | +| cross-refactoring | `participants` | `runtimes` から `impl_assign` で輪番を決める。`impl_capable` は読まない | +| 両方 | `resume_changes` | 空として読む | + +再開で担当に関わる引数を渡したときだけ、`participants` を作り直して書く。渡さない再開では書き足さない。 + +## 入出力の契約 + +### `state.py init`(cross-review)の引数 + +| 引数 | 型 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | +| --- | --- | --- | --- | --- | --- | +| `pr` | 整数 | 必須 | — | — | 変わらない | +| `--max-rounds N` | 整数 | `None` | 無ければ 12 | 渡せば反映 | 既定を `None` へ | +| `--rotate-after K` | 整数 | `None` | 無ければ 8 | 渡せば反映 | 既定を `None` へ | +| `--only RUNTIME` | 4 つの名前か `none` | `None` | `none` は無しと同じ | 渡せば反映。`none` で `null` | `none` を追加 | +| `--exclude NAMES` | カンマ区切りの名前。繰り返し可。`none` | `None` | 外す | 渡せば置き換え。`none` で空 | **新設** | +| `--include NAMES` | 同上 | `None` | 足す | 渡せば置き換え。`none` で空 | **新設** | +| `--require-all` / `--no-require-all` | 真偽値 | `None` | 無ければ `false` | 渡せば反映 | **新設** | +| `--host RUNTIME` | 4 つの名前 | `None` | 無ければ推定 | 反映しない。違えば 1 行 | 再開での知らせを追加 | +| `--verify-command CMD` | 文字列。繰り返し可 | `None` | 無ければ空 | 渡せば置き換え | 再開で反映 | +| `--verify-exit-code N` | 整数。繰り返し可 | `None` | 無ければ空 | 渡せば置き換え | 再開で反映 | +| `--worktree` / `--focus` / `--extra-instructions-file` | — | — | — | — | 変わらない | + +### `refactor.py init`(cross-refactoring)の引数 + +| 引数 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | +| --- | --- | --- | --- | --- | +| `--exclude NAMES` / `--include NAMES` / `--require-all` | `None` | cross-review と同じ | cross-review と同じ | **新設** | +| `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / `--test-timeout` | `None` | 無ければ現行の既定(3 / 2 / 3 / 5 / 900) | 渡せば反映(`replace`) | 既定を `None` へ | +| `--model RT=MODEL` / `--host` / `--scope` / `--baseline-test` / `--ci-check` / `--severity-threshold` / `--sync-command` / `--plan-file` / `--workflow-step` / `--worktree-root` | `None`(`--scope` と `--baseline-test` は必須のまま) | 無ければ現行の既定 | 反映しない。状態と違えば 1 行(`notify`) | 再開での知らせを追加 | + +**状態ファイルに載る引数は、`replace` か `notify` のどちらかに必ず載る**(設計文書の決定 13)。載らないのは状態に載らない +引数(cross-review の `--worktree` / `--focus` / `--extra-instructions-file`)だけである。 + +**名前の検査は 2 段に分かれる。** + +| 段 | 何を見るか | 誰が弾くか | 終了コード | +| --- | --- | --- | --- | +| 1 | 綴り(4 つの名前か `none`) | argparse の型 | 2 | +| 2 | 母集合との関係(`--exclude` にホスト / `--include` と `--exclude` の重なり / `--only` と `--exclude` の矛盾 / `none` と名前の混在) | 共通層の `resolve_participants`(`AssignmentError`)。`init` が Skill の終了コードへ写す | cross-review 1 / cross-refactoring 4 | + +### `init` の出力と終了コード + +標準出力の `KEY=VALUE` は、cross-refactoring から `IMPL_POOL` が消えるほかは変えない。**増えるのは標準エラーの +行だけである。** + +| 場面 | 標準エラーに出るもの | cross-review | cross-refactoring | 状態ファイル | +| --- | --- | --- | --- | --- | +| 新規で全員が使える | 母集合と使える者の 1 行 | 0 | 0 | 作る | +| 新規で確認を通らない者がいる | 通らなかった者と理由を 1 者 1 行 | 0 | 0 | 作る | +| 新規で使える者が 2 者に満たない(cross-review) | 埋め合わせの相手を 1 行(`席をホストで埋めます` / `同じランタイムの 2 つ目で埋めます`) | 0 | — | 作る | +| 新規で使える者が 0 者 | 使える者がいない理由 | ホストで埋められれば 0。埋められなければ 1 | 4 | 0 のときだけ作る | +| 確認を飛ばした | 飛ばしたことを 1 行(`auth.py` の既存の文言) | 0 | 0 | 作る(`probe_skipped: true`) | +| `--require-all` で欠けがある | 欠けた者と理由 | 1 | 4 | 作らない | +| 名前の矛盾 | 何が矛盾したか | 1 | 4 | 作らない | +| 再開で引数を反映した | 項目ごとに `<項目>: <旧> → <新>` の 1 行 | 0 | 0 | 書き換える | +| 再開で反映しない引数が状態と違う | 引数ごとに 1 行 | 0 | 0 | 変えない | +| 再開で作り直した使える者が 0 者 / 欠けあり | 新規と同じ | 1 | 4 | 書き換えない | + +### `start-round` の出力 + +| Skill | 変わること | +| --- | --- | +| cross-review | `REVIEWERS` / `REVIEWERS_CSV` に席の名前が並ぶ(`codex claude-2` のような値を取りうる)。形は変わらない | +| cross-refactoring | `REVIEWERS` / `REVIEWERS_CSV` を出さない。`RUNTIMES` / `RUNTIMES_CSV` / `IMPL` / `IMPL_MODEL` は変わらない | + +### `read-result` と起動スクリプトの席の受け口(cross-review) + +| 受け口 | 変更前 | 変更後 | +| --- | --- | --- | +| `state.py read-result ` | `choices=ALL_RUNTIMES` | 席の名前(`seat_runtime` で検査)。通らなければ argparse の終了コード 2 | +| `launch-reviewer.sh ` | `case` で 4 つの名前を検査 | 席の名前を受け、CLI は `${SEAT%%-*}`(`seat_runtime` と同じ規則)で選ぶ。stem は席の名前で組む | +| `critique.sh ` | 同上 | 同上 | +| `monitor.py --agents` | 担当名の CSV | 席の名前の CSV。stem を組むだけなので変更は無い | + +### `report` の出力(cross-review) + +現行の「PR 履歴」の後に、次の節を足す。 + +```text +## 参加した者 +- 母集合: codex / agy / kiro +- 使える者: codex / kiro +- --exclude で外した者: agy +- --include で足した者: なし +- 確認を通らなかった者: なし +- 席の埋め合わせ: なし +- 再開で変えた値: 2026-09-19T12:00:00 participants … → … +``` + +`participants` を持たない状態ファイルでは「使える者: 記録なし」と出す。`probe_skipped` が真のときは「確認を通らなかった者: +確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。cross-refactoring の `report` は現行の「提案・レビュー」と「適用の母集合」の +2 行を「参加者」の 1 行にし、`participants` を持てば同じ節を足す。 + +### 共通層の関数(`lib/assignment.py`) + +| 関数 | 入力 | 出力 | 失敗の形 | 変更 | +| --- | --- | --- | --- | --- | +| `review_pool(host)` | ホスト名 | 全ランタイム − ホスト | ホストでない名前で `AssignmentError` | 変わらない | +| `refactor_pool(host)` | ホスト名 | `ALL_RUNTIMES` の順で、`DEFAULT_EXCLUDED_FOR_REFACTORING`(`("agy",)`)に無い者とホスト | 同上 | **新設** | +| `resolve_participants(pool, *, include=(), exclude=(), only=None, probe, require_all=False)` | 母集合、足す・外す名前、`--only`、確認の関数(`probe_auth` の形)、全員を要するか | `Participants`(`participants` の 8 項目のうち `fallback` を除く 7 項目を持つデータクラス。`to_state()` で辞書にする) | 名前の矛盾・`require_all` で欠け → `AssignmentError`(欠けた者と理由を並べる) | **新設** | +| `review_seats(round_no, available, fallback)` | ラウンド番号、使える者、埋め合わせに使える者 | 席の名前 2 つ(`only` は呼び出し側が先に処理する) | `round_no < 1` / `available` と `fallback` が両方空 → `AssignmentError` | **新設**(`review_assign` を置き換える) | +| `impl_assign(round_no, participants)` | ラウンド番号(適用の通し番号)、使える者 | 実装担当 1 者(`participants[round_no % len]`) | `round_no < 1` / 空 → `AssignmentError` | **新設**(`assign` を置き換える) | +| `seat_runtime(seat)` | 席の名前 | ランタイム名 | 形に合わない → `AssignmentError` | **新設** | +| `SEAT_PATTERN` | — | `^(claude\|codex\|agy\|kiro)(-[2-9])?$` | — | **新設** | +| `impl_pool()` / `review_assign()` / `assign()` | — | — | — | **消す**(P7) | + +`review_seats` の規則: + +| 使える者の数 | 席 | +| ---: | --- | +| 3 以上 | `pool[round_no % n]` と `pool[(round_no + 1) % n]` を母集合の順に並べた 2 席 | +| 2 | その 2 者 | +| 1 | その 1 者と、`fallback` の先頭。`fallback` が空なら同じランタイムの 2 つ目(`<名前>-2`) | +| 0 | `fallback` の先頭と、その 2 つ目(`<名前>-2`)。`fallback` が空なら `AssignmentError` | + +### 共通層の関数(`lib/auth.py`) + +| 関数 | 入力 | 出力 | 失敗の形 | 変更 | +| --- | --- | --- | --- | --- | +| `probe_auth(runtimes, *, info, env=None)` | 確かめる名前の一覧 | `(結果, 飛ばしたか)`。結果は名前 → `{"command", "ok", "detail"}` | 例外を上げない。コマンドが無い・時間切れ・終了コード非 0・未認証の文言は `ok: false` と `detail` | **新設** | +| `check_auth(...)` | — | — | — | **消す**(P7。P6 では残る) | +| `AUTH_PROBES` / `UNAUTHENTICATED_MARKERS` / `AUTH_PROBE_TIMEOUT` / `SKIP_ENV` | — | — | — | 変わらない | + +### 共通層の関数(`lib/statefile.py`) + +| 関数 | 入力 | 出力 | 失敗の形 | 変更 | +| --- | --- | --- | --- | --- | +| `apply_resume_args(state, args, spec)` | 状態、argparse の名前空間、反映の表 | 標準エラーへ出す行の一覧。`state` を書き換え、`resume_changes` に積む。書き込みは呼び出し側が 1 回で行う | 上げない | **新設** | +| `ResumeField(arg, key, mode)` | 引数の属性名、状態ファイルの鍵、`replace` / `notify` | — | — | **新設** | + +`spec` は Skill ごとの表で、`mode` が `replace` の項目は `None` でない値を状態へ書き、`notify` の項目は状態と違うときだけ +「反映しない」の行を返す。値が同じ項目は行を返さず、`resume_changes` にも積まない。 + +### `state.py` の内部関数(cross-review) + +| 関数 | 契約 | 変更 | +| --- | --- | --- | +| `_resolve_reviewers(host, args)` | `resolve_participants(review_pool(host), …)` を呼び、`available` が 2 者に満たなければホストを `probe_auth` で確かめて `fallback` を決める。`AssignmentError` は `die(code=1)` へ写す | **新設**(`_auth_targets` / `_validate_only` を置き換える) | +| `_round_reviewers(st, round_no)` | 決定 10 の順で返す | 順を変える | +| `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | `apply_resume_args` を呼び、担当に関わる引数があれば `_resolve_reviewers` で作り直す。失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | +| `_guard_previous_round(st, prev)` | `prev["reviewers"]`(無ければ `_round_reviewers`)を渡す | 担当を渡す | + +### `refactor_lib` の関数(cross-refactoring) + +| 関数 | 契約 | 変更 | +| --- | --- | --- | +| `commands/setup.py cmd_init` | `resolve_participants(refactor_pool(host), …)` を呼び、`runtimes` と `participants` を書く。`AssignmentError` は `die`(終了コード 4) | 母集合と確認を置き換える | +| `commands/setup.py cmd_init`(再開) | `apply_resume_args` を呼び、担当に関わる引数があれば作り直す | 反映を足す | +| `commands/setup.py cmd_start_round` | `impl_assign(round_no, state["runtimes"])`。`reviewers` を書かない | 担当の決め方を替える | +| `commands/apply.py` / `commands/gate.py` の `assign(seq, host)` | `impl_assign(seq, state["runtimes"])` | 呼び方を替える(各 1 行) | + +### 手順書の骨組み(cross-review の `SKILL.md` / `docs/01`) + +```bash +INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ + ${MAX_ROUNDS:+--max-rounds "$MAX_ROUNDS"} ${ROTATE_AFTER:+--rotate-after "$ROTATE_AFTER"} \ + ${HOST:+--host "$HOST"} ${ONLY:+--only "$ONLY"} \ + ${EXCLUDE:+--exclude "$EXCLUDE"} ${INCLUDE:+--include "$INCLUDE"} \ + ...) || exit $? + +for r in $REVIEWERS; do "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND"; done +"$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$REVIEWERS_CSV" || true +for r in $REVIEWERS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true; done +"$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" $REVIEWERS +``` + +**`--max-rounds` と `--rotate-after` も値があるときだけ渡す。** 現行の骨組みは `"$MAX_ROUNDS"` を常に渡すため、再開のたびに +利用者が指定していない値で上書きする。 diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md new file mode 100644 index 00000000..6787b509 --- /dev/null +++ b/issues/issue-727-687-478-664-648-design.md @@ -0,0 +1,487 @@ +# #727 / #687 / #478 / #664 / #648: 使える者の決定と席の埋め方を共通層へ移す + +要求と受け入れ条件は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) にある。 +この文書は「どう作るか」だけを扱う。状態ファイル・引数・関数の形は +[issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) にある。 + +**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P5(決定 6〜19)を置き換える。** +引き継ぐ決定と変える決定は末尾の「既存の設計との対応」にある。P4(決定 2〜5)は #732 の設計が持つ。 + +**実装は 2 本の Pull Request に分ける**(決定 19)。 + +| Pull Request | 中身 | 受け入れ条件 | +| --- | --- | --- | +| P6 | 共通層の新設(`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` / `apply_resume_args`)と cross-review 側 | AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 | +| P7 | cross-refactoring 側、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)の削除、`CLAUDE.md` | AC7、AC31〜AC43、AC47、AC49〜AC50 | + +## 機能一覧 + +| # | 機能 | 誰が使うか | Pull Request | +| --- | --- | --- | --- | +| F1 | 確認を通らない者を外し、使える者で収束ループを始める。使えない者と理由を残す | CLI の一部が導入・認証されていない利用者 | P6 / P7 | +| F2 | cross-review の各ラウンドに 2 席を確保する(使える者 → ホスト → 同じランタイムの 2 つ目) | 使える者が 2 者に満たない利用者 | P6 | +| F3 | `--exclude` / `--include` で参加者を名指しで外す・足す | 打ち切り・利用上限が分かっている担当を避けたい利用者、agy を戻したい利用者 | P6 / P7 | +| F4 | cross-refactoring の既定の参加者を codex / kiro / ホストにし、適用の輪番をその中で回す | cross-refactoring の利用者 | P7 | +| F5 | 再開で明示的に渡した引数を反映し、反映しない引数を知らせる | 中断したループを進め方を変えて再開する利用者 | P6 / P7 | +| F6 | 完了報告に参加者・外した者・足した者・確認を通らなかった者・席の埋め合わせ・再開で変えた値を出す | 収束の結果を読む人 | P6 / P7 | + +## 決定の記録 + +| 決定 | 扱うこと | +| --- | --- | +| 1 | 文書の置き方 | +| 2〜4 | 使える者の決定 | +| 5〜8 | cross-refactoring の母集合 | +| 9〜12 | cross-review の席 | +| 13〜18 | 再開と骨組み | +| 19〜20 | 分け方と規則の置き場所 | + +### 決定 1: 設計文書は親 #727 の名前で新設し、既存の設計文書の本体は書き換えない + +既存の設計は P4 と P5 を 1 つの文書で扱い、P4 は #732 が別に進める。P5 の節をその場で書き換えると、#732 が読む P4 の +決定と、この変更で変わる P5 の決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** +既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの +`## 決定の記録` の見出しをすべて写すため、1 行でも触ると既存の 19 件がこの Pull Request の決定として並ぶ。案内は +`## 決定の記録` を持たない要求と契約の文書にだけ足す(#729 の設計と同じ扱い)。 + +### 決定 2: 使える者の決定を共通層 `resolve_participants` に移し、両 Skill の `init` は終了コードへ写すだけにする + +いまは cross-review の `_auth_targets` / `_validate_only` と cross-refactoring の `cmd_init` が、それぞれ母集合を作って +`check_auth` を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・ +確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 の `move_responsibility`)。**共通層に `resolve_participants` を +1 つ置き、母集合・`--include` / `--exclude` / `--only`・確認・`--require-all` から `Participants` を返す。** Skill が持つのは、 +その値を状態ファイルへ書くことと、`AssignmentError` を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写す +ことだけである。 + +既存の設計の決定 10(cross-refactoring は変えず、`check_auth` を残す)は採らない。cross-review だけを直すと、同じ層を +使う cross-refactoring に「1 者欠けると `init` ごと失敗する」形が残る(#664)。 + +### 決定 3: 確認は止めない `probe_auth` に 1 本化し、確認コマンドは変えない + +`check_auth` は確認と中断を 1 つの関数が持つため、「使える者で回す」経路から呼べない。`probe_auth` は結果だけを返し、 +止めるかどうかは `resolve_participants` の `require_all` が決める。**両 Skill が `probe_auth` へ移った時点で `check_auth` は +呼び手を失うため消す**(P7)。確認コマンド(`AUTH_PROBES`)と未認証の文言は変えない。モデルを引く最小の呼び出しへ +替える判断は所要の実測が要るため #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口と、通らなかった +理由を `unavailable` に残す形である。 + +### 決定 4: 参加者は「母集合の既定 + `--include` − `--exclude`」で決め、既定は Skill ごとの関数が持つ + +外したい理由は「この担当が落ちる」であり、名指しするのは外す側である(`--exclude`)。戻したい理由は「既定から外れて +いる者を入れたい」で、これも名指しである(`--include`)。使う側を並べる `--reviewers codex,kiro` の形は、ホストが変わると +一覧を書き直すことになるため採らない。**既定は Skill ごとの関数が持ち、`resolve_participants` はどちらを渡されても同じ +規則で解決する。** + +| Skill | 既定を返す関数 | 中身 | +| --- | --- | --- | +| cross-review | `review_pool(host)` | 全ランタイム − ホスト | +| cross-refactoring | `refactor_pool(host)` | `DEFAULT_EXCLUDED_FOR_REFACTORING`(`("agy",)`)に無い者とホスト | + +cross-review の `--include` はホストを母集合へ入れる用途になる(#687 の「ホストの参加を許す」の明示的な形)。 + +### 決定 5: cross-refactoring の母集合を 1 つにし、`impl_capable` / `IMPL_POOL` / `impl_pool()` を消す + +既定の参加者を codex / kiro / ホストにすると、提案の母集合と適用の母集合が同じ集合になる。`impl_pool()` を関数として +残した理由(「両者は一致しない」)が消えるため、関数・状態ファイルの項目・`init` の出力変数の 3 つを消す。`runtimes` は +提案の取り込みと `prepare-worktrees.sh` が読むため残し、適用の輪番も同じ値を読む。 + +agy を既定から外す理由は #664 の実測にある。CLI の起動 199 回のうち失敗は agy の 7 回(STALLED 4 / NO_RESULT 3)だけで、 +提案の所要の中央値も agy が最も長い(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で +決まっていた。戻す手段は `--include agy` である。 + +### 決定 6: cross-refactoring のレビュー担当を消し、`assign()` を `impl_assign()` に置き換える + +cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-review` が担う。`assign()` が返すレビュー担当が流れる先は +3 つだけである。 + +| 流れる先 | 読む側 | +| --- | --- | +| ラウンドの記録(`reviewers` / `reviewer_models`) | 改修計画と報告の表示 | +| `start-round` の `REVIEWERS` / `REVIEWERS_CSV` | 無い(骨組み・文書・プロンプトで `grep -rn -i reviewer` が 0 件) | +| `record_observed_model` のレビュー側の枝 | 無い(呼び出しは `role="impl"` の 1 か所だけ) | + +**#664 の「使える者が 2 者だとレビュー担当が 1 者になる」は、存在しない役の記録の話である。** 役を消せば、席を埋める +規則を cross-refactoring に持ち込む必要が無い。`impl_assign(round_no, participants)` は実装担当 1 者だけを返す。 + +レビュー担当を「記録のためだけに」残す案は採らない。読み手が「このラウンドはこの 2 者がレビューした」と読む。 + +### 決定 7: 適用の輪番は `participants[round_no % n]` の式と `ALL_RUNTIMES` の順を保つ + +いまの `assign()` は `pool[round_no % 4]` で、ラウンド 1 が codex から始まる。式を変えずに `n` を参加者の数にすれば、 +ホスト claude の既定(`["claude", "codex", "kiro"]`)でも codex → kiro → claude の順になる。ホストが最初に適用する形に +ならない(「実測」)。ラウンド 1 から順に並べる式(`(round_no - 1) % n`)は、ホストが先頭に来るため採らない。 + +### 決定 8: 提案者と適用者が同じランタイムになることを避けない + +ホストが提案に入るため、ある項目の提案者と適用者が同じランタイムになりうる。適用ラウンドは複数の提案者の項目を +1 つの群にまとめるため、群ごとに提案者を避けると群の分け方そのものを変えることになる。**避けない。** 適用の結果は +検証(テスト)と Step 7 の `cross-review` が見る。#687 の「コードを書いたランタイムが担当に入ってよい」と同じ判断である。 + +### 決定 9: cross-review の席は 2 つで、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める + +#687 の 4 つの規則のうち「各ラウンドで 2 者」を最優先に置き、残る 3 つを埋める順序として読む。**違うランタイムを +先に使う。** 同じモデルの 2 つの文脈より、違うモデルの 2 つの文脈のほうが観点が分かれる。ホストは既定の母集合に +無いため、使える者が 1 者のときだけ席に入る。同じランタイムの 2 つ目(`<名前>-2`)は、ホストも使えないときの最後の +手段である。 + +| 使える者の数 | 席 | +| ---: | --- | +| 3 以上 | 輪番で 2 席(3 者のときは変更前の値と一致する) | +| 2 | その 2 者 | +| 1 | その 1 者とホスト。ホストが使えなければ同じランタイムの 2 つ目 | +| 0 | ホストとその 2 つ目。ホストも使えなければ失敗 | + +`--only` は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。使える者が 2 者のとき輪番で +1 者を外す案は、毎ラウンド 1 席になるため採らない。 + +### 決定 10: 担当の単位を「席の名前」にし、形は `<ランタイム>` か `<ランタイム>-<2〜9>` とする + +同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。**1 つ目の席の名前は +ランタイム名そのままにする。** 埋め合わせが要らない実行では、いまと同じ名前しか現れない。接尾辞の区切りは `-` で、 +ランタイム名に `-` を含むものが無いため、シェルの `${SEAT%%-*}` と Python の `seat_runtime` が同じ規則になる。 +stem を逆に解析して担当名へ戻す箇所は無い(「実測」)ため、stem 側の変更は無い。 + +受け口は 10 か所で、いずれも「CLI を選ぶ分岐に `seat_runtime` を通す」か「`choices` を席の形の検査に替える」の +どちらかである(「構成要素」の表)。`--only` と `--host` はランタイム名のままで、席の名前を取らない。 + +### 決定 11: 担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む + +```text +ラウンドの reviewers → only → participants の席 → host の輪番(review_pool)→ codex / agy +``` + +既存の決定 12 と同じ理由である。再開で `only` を変えられるようにすると、`only` を先に見る現行の順では過去のラウンドの +担当まで変わる。`_guard_previous_round` も `prev["reviewers"]` を渡す。現行は担当を渡さず `codex` / `agy` で数えるため、 +担当が `agy` + `kiro` のラウンドで `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 + +### 決定 12: `--require-all` で従来の関門を選べるようにする + +全員が揃わないなら始めたくない運用のために残す(既存の決定 9)。付けると、確認を通らない者が 1 者でもいれば +従来の文言で失敗する。`--exclude` で外した者は揃っていなくてよい。両 Skill に同じ引数を置く。 + +### 決定 13: 再開の反映を共通層 `apply_resume_args` に置き、Skill ごとの表で「反映する」「知らせる」を決める + +#648 の修正レイヤーは `statefile.py` である。cross-review の `_resume_from_state` だけを直すと、cross-refactoring の +再開経路(`args` 由来の項目を 1 つも反映しない)が残る。**共通層は「`None` でない引数を状態へ書き、`resume_changes` に +積み、反映しない引数は状態と違うときだけ知らせる」だけを持ち、どの引数がどちらかは Skill ごとの表が持つ。** + +**状態ファイルに載る引数は、表のどちらかに必ず載る。** 載らない引数は状態に載らないもの(`--worktree` / `--focus` / +`--extra-instructions-file`)だけである。黙って捨てる引数を残さないためで、`--baseline-test` のように再開でも渡す +必須の引数も「違えば知らせる」に載る。そのため状態に載る引数の既定はすべて `None` にし、新規の経路が定数の既定へ +置き換える。既定値と同じ値なら渡していないとみなす案は、`--max-rounds 12` で 20 から 12 へ戻す指定を区別できないため +採らない。 + +「知らせる」に置く引数は 2 種類ある。 + +| 種類 | 引数 | +| --- | --- | +| 変えると過去のラウンドと突き合わせられなくなる | `--host` / `--scope` / `--model` | +| 初期化の時点で 1 度だけ効く | `--baseline-test` / `--worktree-root` / `--plan-file` など | + +### 決定 14: 担当に関わる引数を渡した再開でだけ、確認し直して参加者を作り直す + +`--only` / `--exclude` / `--include` / `--require-all` のいずれかを渡した再開では、決定 2 と同じ手順で参加者を作り直す。 +いずれも渡さない再開では確かめ直さない(途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる)。 +作り直した結果が失敗(0 者、`--require-all` で欠け)なら、状態ファイルを書き換えずに終了コードで終わる。反映は再開の後に +開くラウンドから効く(要求の前提 3)。 + +### 決定 15: 再開で指定を外す値は `none` にする + +`--only none` は `only` を `null` へ、`--exclude none` / `--include none` は一覧を空へ戻す。空文字列は骨組みの +`${ONLY:+--only "$ONLY"}` が渡さないため、外す手段にならない。新規の経路で `none` を渡すと、渡さないのと同じになる。 + +### 決定 16: 再開で変えた値は `resume_changes` に積み、参加者の作り直しは 1 件として積む + +上書きだけでは、どの時点で何を変えたかが失われ、完了報告で「途中から agy を外した」ことが読めない。`participants` の +中の項目ごとに積むと 1 回の再開で最大 7 件になり、報告で読みにくい。`participants` 全体の前後を 1 件に積む。 + +### 決定 17: 骨組みは `$ONLY` で絞らず、`start-round` が返す席を使う + +`start-round` の `REVIEWERS` は `only` と席の埋め合わせを反映済みである。シェル変数でもう一度絞ると、状態ファイルと +シェル変数がずれたときに起動も監視も誰にも当たらない(#648 の 3 段の経過)。`SKILL.md` と `docs/01` の Step 2 と Step 2.5 を +`$REVIEWERS` / `$REVIEWERS_CSV` に揃え、`$ONLY` は `init` へ渡す 1 行にだけ残す(既存の決定 18)。 + +### 決定 18: 起動した後に分かる使えなさで、担当を自動的に外す仕組みは作らない + +利用上限やモデルの 404 は起動した後に分かり、分類は #729(G3)が持つ。自動で外すと、一時的な打ち切りでも以後の +ラウンドから恒久的に外れる。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る(既存の決定 19)。 + +### 決定 19: P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削除)の順に 2 本で出す + +共通層の新しい関数は P6 で入れ、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)は P7 で消す。P6 の +時点で旧関数を消すと cross-refactoring が壊れる。1 本にまとめると `state.py` と `refactor_lib` と文書 3 種を 1 度に +レビューすることになり、G2 / G3 / G5(`state.py`)と G4(`refactor_lib`)との競合も 1 度に解くことになる。 +**「片方にだけ古い形が残らない」(親 #727 の完了条件)は P7 のマージで満たす。** + +### 決定 20: 規則の実装は `assignment.py`、手順は各 `SKILL.md`、理由はこの設計文書が持つ + +#687 が問う置き場所である。使える者の数で分岐する実装と席の規則の表は `assignment.py`(`review_seats` の docstring)に +1 つだけ置く。利用者が手順として読む「担当の決まり方と渡す引数」は各 `SKILL.md`(cross-review は `docs/05` が本体)に +置く。同じランタイムの 2 つ目とホストの参加を許した理由はこの文書が持ち、`plan-to-spec` が `docs/specifications/` へ +移す。`CLAUDE.md` には要約の 2 行(cross-refactoring の母集合と輪番、cross-review の席)だけを置く。 + +## 実測 + +`develop` `9eaebe14`、Python 3.14 で、`assignment.py` を読み込んで式を突き合わせた。 + +| 場面 | 結果 | +| --- | --- | +| 席の式 `{pool[r % n], pool[(r + 1) % n]}` を母集合の順に並べる(n = 3) | 4 ホスト × ラウンド 1〜12 の全組で、変更前の `review_assign(r, host)` と一致(不一致 0 件) | +| 同じ式で n = 4 | ラウンド 1〜4 の席は `codex agy` / `agy kiro` / `claude kiro` / `claude codex`。各者ちょうど 2 回 | +| n = 2(`codex` / `kiro`) | ラウンド 1〜4 すべて `codex kiro` | +| n = 1 / n = 0 | `codex claude`(ホストあり)/ `codex codex-2`(ホストなし)/ `claude claude-2`(0 者・ホストあり) | +| `impl_assign` を `participants[r % n]` で `["claude", "codex", "kiro"]` に当てる | ラウンド 1〜6 で `codex` / `kiro` / `claude` / `codex` / `kiro` / `claude`。変更前の `assign(r, "claude")` は `codex` / `agy` / `kiro` / `claude` / … | +| 席の形 `^(claude\|codex\|agy\|kiro)(-[2-9])?$` | `kiro` / `kiro-2` / `claude-9` が一致し、`kiro-1` / `kiro-10` / `gemini` / `kiro-2-3` は一致しない | +| stem の逆解析 | `split("-")` / `rsplit` / `.stem` / `re.match(.*agent` を cross-review のスクリプトと `monitor.py` で検索し、担当名へ戻す箇所は 0 件(当たった 1 件は PR のファイル種別の判定) | +| cross-refactoring の `$REVIEWERS` の読み手 | `SKILL.md` / `docs/` / `prompts/` で `reviewer` が 0 件。`setup.py` が出すだけで、読むのはテスト `test_start_round_emits_runtimes.py` の期待値だけ | +| cross-refactoring の再開で反映される引数 | `init` の 15 引数のうち 0 個(`_apply_post_event` の投稿の扱いだけを毎回入れ直す) | +| 既存のテストの数 | `uv run --with pytest pytest scripts/tests plugins/ndf -q --co` で 4413 件 | + +担当名を鍵・分岐・選択肢に使う箇所の一覧(10 か所の受け口を含む)は、調査の控えから「構成要素」の表へ写した。 +控え(`survey-727-agent-keys.md`)は設計 Pull Request のレビューの間だけ scratchpad に置く。 + +## 構成要素 + +| 要素 | 責務 | Pull Request | +| --- | --- | --- | +| 母集合の既定(`assignment.review_pool` / `refactor_pool`) | Skill ごとの出発点を返す。既定の除外は表 `DEFAULT_EXCLUDED_FOR_REFACTORING` が持つ | P6 | +| 使える者の解決(`assignment.resolve_participants`) | 母集合・足す・外す・`only`・確認・`require_all` から `Participants` を返す。名前の矛盾と欠けを `AssignmentError` で返す | P6 | +| 確認(`auth.probe_auth`) | 止めずに確かめ、担当ごとの結果を返す | P6 | +| 席の埋め方(`assignment.review_seats`) | 使える者と埋め合わせから 2 席を返す(決定 9) | P6 | +| 適用の輪番(`assignment.impl_assign`) | 参加者から実装担当 1 者を返す(決定 7) | P6(呼び手は P7) | +| 席の名前(`assignment.seat_runtime` / `SEAT_PATTERN`) | 席の名前からランタイムを引く。形の検査 | P6 | +| 再開の反映(`statefile.apply_resume_args` / `ResumeField`) | 表に従って状態へ書き、`resume_changes` に積み、知らせる行を返す | P6 | +| cross-review の初期化(`state.py` の `_resolve_reviewers` / `_init_new_state` / `_resume_from_state`) | 共通層を呼び、`participants` を書き、失敗を終了コード 1 へ写す。再開で反映の表を渡す | P6 | +| cross-review の担当の読み出し(`_round_reviewers` / `_guard_previous_round`) | 決定 11 の順で席を返す | P6 | +| cross-review の席の受け口(10 か所) | `launch-reviewer.sh:29,223` / `critique.sh:31` / `critique-round.sh:39` / `wait-review.sh` の CLI を選ぶ分岐、`monitor.py:953` の `target` の `choices`、`monitor.py` の `agent == "codex"` / `"claude"` の比較(629 / 632 / 658 / 808 ほか)、`measure.py:36,128` の `AGENT_NAMES`、`state.py:4389` の `read-result` の `choices`、`_guard_previous_round` の `LEGACY_AGENTS` への落ち方 | P6 | +| cross-review の完了報告(`cmd_report`) | 「参加した者」の節を出す | P6 | +| cross-review の骨組みと文書(`SKILL.md` / `docs/01` / `docs/04` / `docs/05`) | `$ONLY` で絞らない。引数・状態ファイル・席の規則を書く | P6 | +| cross-refactoring の初期化(`refactor_lib/commands/setup.py` の `cmd_init`) | `refactor_pool` と `resolve_participants` を呼び、`runtimes` と `participants` を書く。`impl_capable` / `IMPL_POOL` を出さない。再開で反映の表を渡す。失敗を終了コード 4 へ写す | P7 | +| cross-refactoring の担当(`cmd_start_round` / `apply.py:134` / `gate.py:128`) | `impl_assign(seq, state["runtimes"])`。`reviewers` を書かず `REVIEWERS` を出さない | P7 | +| cross-refactoring の表示(`report.py` / `plan.py`) | 母集合を 1 行にし、レビュー担当の列を消す。`participants` があれば参加者の節を出す | P7 | +| cross-refactoring の引数(`refactor.py`) | `--exclude` / `--include` / `--require-all` を足し、状態に載る引数の既定を `None` にする | P7 | +| cross-refactoring の文書(`SKILL.md` / `docs/01`)と `CLAUDE.md` | 母集合・担当の決め方・前提・引数を実装後に合わせる | P7 | +| 旧関数の削除(`check_auth` / `impl_pool` / `review_assign` / `assign`) | 呼び手が無くなった時点で消す | P7 | + +要素の関係(辺は呼び出し): + +```mermaid +graph TD + subgraph 共通層 + PL[母集合の既定] --> RP[使える者の解決] + RP --> PA[確認] + RS[席の埋め方] + IA[適用の輪番] + SR[席の名前] + RA[再開の反映] + end + subgraph cross-review + RI[初期化] --> RP + RI --> RA + RR[担当の読み出し] --> RS + RC[席の受け口] --> SR + end + subgraph cross-refactoring + FI[初期化] --> RP + FI --> RA + FA[担当] --> IA + end +``` + +完了報告・表示・引数・文書・旧関数の削除は、状態ファイルを読むだけか呼び出しを持たないため、図に含めない。 + +## 文脈と配置 + +**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` / `refactor.py` の 1 プロセスずつである。 +外部との出入りは `gh`(GitHub)と、各 CLI の確認コマンドと起動だけで、確認コマンドの呼び出し先は変えない。変わるのは +起動する CLI の集合(cross-refactoring から agy が既定で外れ、ホストが入る)と、cross-review で同じ CLI を 2 プロセス +起動しうることである。 + +### 置き場所 + +```text +plugins/ndf/ +├── scripts/lib/ +│ ├── assignment.py # P6: refactor_pool / resolve_participants / review_seats / impl_assign / seat_runtime を新設 +│ │ # P7: impl_pool / review_assign / assign を消す +│ ├── auth.py # P6: probe_auth を新設。P7: check_auth を消す +│ ├── statefile.py # P6: apply_resume_args / ResumeField を新設 +│ ├── monitor.py # P6: target の choices と agent の比較を seat_runtime へ +│ └── tests/ # P6: test_lib_assignment.py を impl_assign へ、test_lib_participants.py / test_lib_resume_args.py を新設 +├── skills/cross-review/ +│ ├── SKILL.md / docs/01 / docs/04 / docs/05 # P6 +│ ├── scripts/state.py / launch-reviewer.sh / critique.sh / critique-round.sh / wait-review.sh / measure.py # P6 +│ └── tests/ # P6: test_state_review_pool.py に追記、test_state_resume_args.py / test_seat_names.py を新設 +├── skills/cross-refactoring/ +│ ├── SKILL.md / docs/01-state-and-propose.md # P7 +│ ├── scripts/refactor.py / refactor_lib/commands/{setup,apply,gate,report}.py / refactor_lib/plan.py # P7 +│ └── tests/ # P6: test_assignment.py の review_assign を review_seats へ +│ # P7: test_assignment.py の assign を impl_assign へ、test_init.py / test_start_round_emits_runtimes.py を直す +└── CLAUDE.md(リポジトリの根) # P7 +``` + +`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。`bash scripts/build-runtime-plugins.sh --check` +で食い違いが無いことだけを確かめる。 + +## 構造 + +型を足すのは `Participants`(データクラス。契約文書の `participants` の 7 項目)と `ResumeField`(名前付きタプル)の +2 つで、互いに関係を持たず、既存の型とも関係を持たない。クラス図は作らない(要求の「対象範囲」)。**変わるのは処理の +順序である。** + +## 処理の流れ + +### 新規の `init`(両 Skill で同じ形) + +```mermaid +graph TD + H[ホストを確定] --> P[母集合の既定を引く] + P --> RP[使える者の解決] + RP -->|名前の矛盾 / require_all で欠け| F[状態を作らず終了コード] + RP -->|通った| CR{Skill} + CR -->|cross-review| N{使える者が 2 者以上} + N -->|はい| W[状態ファイルを書く] + N -->|いいえ| HP[ホストを確認して fallback を決める] + HP -->|席を埋められる| W + HP -->|埋められない| F + CR -->|cross-refactoring| Z{使える者が 1 者以上} + Z -->|はい| W + Z -->|いいえ| F +``` + +使える者の解決の順序: + +1. `--include` と `--exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。`--exclude` にホストがあれば弾く +2. 参加者 = 母集合の既定 ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) +3. `--only` があれば参加者に含まれ、`exclude` に無いことを確かめ、参加者を `[only]` にする +4. 参加者の確認を `probe_auth` で行う。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとし `probe_skipped` を真にする +5. `require_all` が真で通らない者がいれば `AssignmentError` +6. 通った者を `available`、通らなかった者と理由を `unavailable` として返す + +### `start-round`(cross-review) + +```mermaid +graph TD + S[start-round] --> G[前ラウンドの検査 prev.reviewers を渡す] + G --> R{ラウンドに reviewers がある} + R -->|はい| U[その値] + R -->|いいえ| O{only がある} + O -->|はい| U1["[only]"] + O -->|いいえ| PT{participants がある} + PT -->|はい| RS["review_seats(r, available, fallback)"] + PT -->|いいえ| HO{host がある} + HO -->|はい| LG["review_seats(r, review_pool(host), [])"] + HO -->|いいえ| L2[codex / agy] +``` + +### 再開の `init`(両 Skill で同じ形) + +```mermaid +graph TD + A[状態ファイルを読む] --> AR["apply_resume_args(state, args, 表)"] + AR --> N[知らせる行を出す] + N --> RA{担当に関わる引数を渡した} + RA -->|はい| RP[使える者の解決をやり直す] + RP -->|失敗| F[状態を書き換えず終了コード] + RP -->|通った| W[participants を書き resume_changes に 1 件積む] + RA -->|いいえ| W2[変えた項目だけ書く] + W --> E[start-round へ] + W2 --> E +``` + +### 席の名前が流れる経路(cross-review、埋め合わせがあるときだけ現れる) + +```text +start-round → REVIEWERS="codex claude-2" + → launch-reviewer.sh claude-2 … : CLI は seat_runtime → claude、stem は claude-2-review-pr + → monitor.py --agents codex,claude-2 : stem を組むだけ。CLI 固有のログの検査は seat_runtime で選ぶ + → state.py read-result claude-2 : rounds[-1]["claude-2"] へ書く + → critique-round.sh codex claude-2 → critique.sh claude-2 … +``` + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 可用性 | 母集合の 1 者が使えないことで、どちらの収束ループも開始できない状態にならない | 確認の失敗を `unavailable` に記録し、使える者で続ける(決定 2・3)。cross-review は席を埋め合わせる(決定 9) | AC14、AC35 のテスト | +| 性能・拡張性 | 確認の回数は「参加者の数 + 埋め合わせが要るときのホスト 1 回」を超えない。再開で確かめ直すのは担当に関わる引数を渡したときだけ | 除外した者は確かめない。ホストは `available` が 2 者に満たないときだけ確かめる。再開は決定 14 の条件でだけ確かめる | AC3、AC26、AC28 のテストで `probe_auth` の呼び出しを数える | +| 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、反映しなかった引数が出力だけで分かる | `report` が参加者の節を出す。`init` が知らせる行を出す(決定 13・16) | AC24、AC29、AC39 のテスト | +| 移行性 | この変更の前に始めた実行の状態ファイルを書き換えずに読める | 項目が無いときの読み方を契約文書の「移行」が決める | AC22、AC41 のテスト | + +## テスト設計 + +置き場所の列の読み方は次のとおりである。 + +| 先頭 | 実際の場所 | +| --- | --- | +| `cross-refactoring/` / `cross-review/` | `plugins/ndf/skills/` の下 | +| `lib/` | `plugins/ndf/scripts/tests/` の下 | + +| 受け入れ条件 | 何で確かめるか | 置き場所 | +| --- | --- | --- | +| AC1〜AC5 | `probe` を差し替えて `resolve_participants` を呼び、返り値と例外と呼び出し回数を見る | `lib/test_lib_participants.py`(新設) | +| AC6 | `subprocess.run` を差し替えて `probe_auth` を呼ぶ(既存の `test_auth_probe.py` を書き直す) | `lib/test_auth_probe.py` | +| AC7 | `git grep` の結果を検査するテスト | `lib/test_shared_lib_layout.py`(既存に追記) | +| AC8〜AC12 | `review_seats` を 4 ホスト × 12 ラウンドと 2 / 1 / 0 者で呼ぶ。AC8 は変更前の式を期待値として持つ | `cross-refactoring/tests/test_assignment.py`(`review_assign` のテストを置き換える) | +| AC13 | `seat_runtime` に 7 つの名前を渡す | 同上 | +| AC14〜AC20 | `probe_auth` を差し替えて `_init_new_state` を呼ぶ。状態ファイルの有無と終了コードと標準エラーを見る | `cross-review/tests/test_state_review_pool.py` | +| AC21 | `read-result` を席の名前で呼ぶ。`launch-reviewer.sh` を `launch-cli.sh` を差し替えて呼び、渡った CLI 名と stem を見る | `cross-review/tests/test_seat_names.py`(新設) | +| AC22 | `participants` を持たない状態 / `host` も持たない状態で `_round_reviewers` | `cross-review/tests/test_state_review_pool.py` | +| AC23 | `verdict` の無い前ラウンドで `cmd_start_round` の終了コード 5 | `cross-review/tests/test_state_round_guard.py` | +| AC24 | `participants` と `resume_changes` を持つ状態ファイルで `cmd_report` の出力を見る | `cross-review/tests/test_state_review_pool.py` | +| AC25〜AC29 | 状態ファイルを置いた作業ツリーを渡して `cmd_init` を呼び、状態ファイルと標準エラーを見る | `cross-review/tests/test_state_resume_args.py`(新設) | +| AC30 | 2 ファイルの `ONLY` を含む行を数える | `cross-review/tests/test_skill_layout.py` | +| AC31〜AC33、AC35〜AC36 | `probe_auth` を差し替えて `cmd_init` を呼ぶ(既存の `test_init.py` の `check_auth` のテストを置き換える) | `cross-refactoring/tests/test_init.py` | +| AC34 | `impl_assign` を 6 ラウンド呼ぶ。`cmd_start_round` の出力に `REVIEWERS` が無い(`test_start_round_emits_runtimes.py` の期待値を反転する) | `lib/test_lib_assignment.py` / `cross-refactoring/tests/test_start_round_emits_runtimes.py` | +| AC37 | `cmd_report` と改修計画の出力を見る | `cross-refactoring/tests/test_run_metrics_summary.py` / `test_plan_comment.py` | +| AC38〜AC40 | 状態ファイルを置いて `cmd_init` を呼ぶ | `cross-refactoring/tests/test_init.py` | +| AC41 | `impl_capable` を持つ状態で `cmd_start_round` / `cmd_report` | 同上 | +| AC42〜AC44 | 文書の語を `grep` するテスト | `scripts/tests/test_cross_skill_refs.py` / `cross-review/tests/test_skill_layout.py` / `cross-refactoring/tests/test_skill_terms.py` | +| AC45〜AC48 | 各 issue の再現手順を実行し、結果を issue のコメントへ残す | 手元 | +| AC49〜AC50 | コマンドの終了コード | 継続的統合と手元 | + +`apply_resume_args` の表の規則(`replace` / `notify`、値が同じなら積まない)は `lib/test_lib_resume_args.py`(新設)で +Skill に依らず確かめる。 + +## 未確認のまま残ること + +| 項目 | 内容 | いつ決まるか | +| --- | --- | --- | +| 同じランタイムの 2 席の観点 | `claude` / `claude-2` の 2 席が、別のランタイムの 2 席より指摘を見落とすかは測っていない | この変更の後の運用(`measure.py`) | +| ホストが席に入ったときの `is_own_pr` の扱い | ホストの CLI が自分の Pull Request をレビューするとき、投稿の event が `COMMENT` へ倒れる既存の規則で足りるかは確かめていない | P6 の実装で 1 度回して見る | +| cross-refactoring でホストが提案に入ることの所要 | 提案は最も遅い者を待つ。claude の提案の所要は測っていない(適用は中央値 2 分) | P7 の後の運用 | +| 確認を「モデルを引く最小の呼び出し」へ替えるか | #461。所要の実測が要る | マイルストーン 06 の着手時 | +| `monitor.py` の CLI 固有の検査を席の名前に通す形 | `seat_runtime` で選ぶと決めたが、`agent` の比較が 6 か所あり、共通の関数へ寄せるかは実装で決める | **実装で決める** | +| 出力の文言 | 反映した行・知らせる行・埋め合わせの行の文言は、項目名と値を含むことだけを決めた | **実装で決める** | +| テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | + +## 申し送り(並行する設計との境界) + +| 相手 | 決めた契約 | どちらが何をするか | +| --- | --- | --- | +| G2(#732 #624) | `state.py` は同じファイルだが節が違う(G1 は `init` / 再開 / `_round_reviewers` / `read-result` の受け口 / `report` の参加者の節。G2 は `_classify_finding` / `COUNTED_CLASSIFICATIONS`)。**要求と契約の既存文書の先頭へ案内を足す点だけが重なる** | G1 が P5 の案内、G2 が P4 の案内をそれぞれ 1 段落足す。後からマージする側が並べる | +| G3(#729 #619 #584) | `state.py` の `_read_review_result_file` / `_record_no_result` / `_handle_no_result_round` と `report` の結末の節は G3。G1 が `report` に足すのは「参加した者」の節だけ。`monitor.py` は G3 が結末の理由を、G1 が `agent` の比較と `target` の `choices` を触る | 競合は後からマージする側が解く。G1 の `seat_runtime` は `monitor.py` の比較を包むだけで、G3 の語彙に触らない | +| G4(#728 #647 #592 #553) | `apply.py:134` と `gate.py:128` の `impl, _ = assignment.assign(seq, state["host"])` を G1(P7)が `impl = assignment.impl_assign(seq, state["runtimes"])` へ変える(各 1 行)。担当の交代(利用上限で次の輪番へ替える)は G4 が持ち、替える相手は `state["runtimes"]` から選ぶ | G4 は `assign` を新しく呼ばない。G1 の P7 と G4 の実装が同じ行を触ったら、後からマージする側が `impl_assign` に揃える | +| G5(#730 #583) | 起動スクリプト(`launch-reviewer.sh`)は G5 が投稿の経路を、G1 が席の受け口(先頭の `case` と `launch-cli.sh` へ渡す CLI 名)を触る | 競合は後からマージする側が解く | +| #461 | 確認コマンドの差し替え口は `AUTH_PROBES` のまま。`probe_auth` の返り値の形(`ok` / `detail`)は変えずに、コマンドだけを替えられる | #461 が実測して替える | +| #736 | `CLAUDE.md` の「`--max-outer-rounds` の既定が 4」の行は、輪番が 3 ラウンドで 1 周する形に合わせて G1 が書き直す | #736 は書き直した行を見て閉じるかを棚卸で決める | + +## 既存の設計との対応 + +| 既存の決定(PR #667) | この文書 | 変わったこと | +| --- | --- | --- | +| 決定 1(P4 / P5 の分け方) | 決定 19 | P5 を P6 / P7 に分け直した。P4 は #732 | +| 決定 6(確認を把握へ。`available_reviewers` を持つ) | 決定 2・3 | 解決を共通層へ移し、項目を `participants` に畳んだ。`check_auth` を消す | +| 決定 7(`--exclude`) | 決定 4 | `--include` を足した | +| 決定 8(使える者の数で分け、`review_assign` が一覧を受け取る) | 決定 9 | 1 者と 0 者を「席を埋める」に変えた。3 者の値は同じ | +| 決定 9(`--require-all`) | 決定 12 | 両 Skill に置く | +| 決定 10(cross-refactoring を変えない) | 決定 2・5・6 | 採らない。cross-refactoring も同じ層を通り、レビュー担当を消す | +| 決定 11(`--only` は 1 者) | 決定 9 | 同じ(埋め合わせをしない) | +| 決定 12(記録を先に見る) | 決定 11 | 同じ | +| 決定 13(既定を `None` に) | 決定 13 | 共通層へ移し、状態に載る引数は必ず表に載せる | +| 決定 14(`--host` は知らせる) | 決定 13 | 表の `notify` の 1 行になった | +| 決定 15(担当の引数で確かめ直す) | 決定 14 | `--include` を足した | +| 決定 16(`none`) | 決定 15 | `--include none` を足した | +| 決定 17(`resume_changes`) | 決定 16 | `participants` は 1 件として積む | +| 決定 18(`$ONLY` で絞らない) | 決定 17 | 同じ | +| 決定 19(自動で外さない) | 決定 18 | 同じ | +| — | 決定 1・5・7・8・10・20 | 新設 | diff --git a/issues/issue-727-687-478-664-648-requirements.md b/issues/issue-727-687-478-664-648-requirements.md new file mode 100644 index 00000000..92436b45 --- /dev/null +++ b/issues/issue-727-687-478-664-648-requirements.md @@ -0,0 +1,380 @@ +# #727 / #687 / #478 / #664 / #648: 使える者を共通層が決め、担当が揃わなくても 2 席で回す + +設計は [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) にある。この文書は +「何を満たすか」だけを扱う。 + +**この文書は、既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の +P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P4(#624)は +#732 の設計が持つ。既存の設計文書の本体は触らない。 + +## 目的 + +- 参加する CLI のどれか 1 者が使えなくても、収束ループ(cross-review / cross-refactoring)を開始でき、 + 使える者だけで回る。使えない者と理由は出力と状態ファイルに残る +- cross-review は、使える者が 2 者に満たなくても、各ラウンドに 2 席を確保する。席の埋め方の規則は + 1 つで、両 Skill が共有する共通層が持つ +- cross-refactoring の既定の参加者は codex / kiro / ホストの 3 者になり、agy は既定から外れる。 + 外す・戻す手段は引数で持つ +- 中断した収束ループを、引数で進め方を変えて再開できる。反映しなかった引数は出力で分かる。 + この規則も共通層が 1 か所で持つ + +## 前提 + +| # | 前提 | +| --- | --- | +| 1 | cross-refactoring のレビュー工程は #436 で消えており(Step 7 の `cross-review` が担う)、`assign()` が返すレビュー担当は状態ファイルへの記録と表示にしか使われない | +| 2 | 使える者の確認は、認証の確認コマンド(`AUTH_PROBES`)のままである。モデルを引く最小の呼び出しへ替える判断は #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口である | +| 3 | 再開の `init` の後、骨組みは必ず `start-round` で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | +| 4 | G2(#732)が同じ `state.py` の `_classify_finding` を、G3(#729)が `_read_review_result_file` / `_record_no_result` / `report` を、G5(#730)が投稿の経路を触る。この変更が触る節は `init` / 再開 / `_round_reviewers` / `start-round` / `read-result` の担当名の受け口 / `report` の参加者の節である | +| 5 | 同じランタイムの 2 つの CLI プロセスは、別の作業文脈を持てば独立した意見として扱う(#687 の利用者の指示) | + +## 対象範囲 + +含む: + +- 共通層 `lib/assignment.py`: 既定の母集合(Skill ごと)、使える者の解決、席の埋め方、適用の輪番、席の名前 +- 共通層 `lib/auth.py`: 止めない確認(`probe_auth`)。1 件の失敗で `die` する `check_auth` を消す +- 共通層 `lib/statefile.py`: 再開で明示的に渡した引数だけを状態へ重ね、反映しない引数を知らせる +- cross-review の `init`(新規と再開)、`start-round` の担当、`read-result` と起動スクリプトの席の受け口、`report`、 + `SKILL.md` と `docs/`(01 / 04 / 05) +- cross-refactoring の `init`(新規と再開)、`start-round` と適用の輪番、`report` / 改修計画の表示、`SKILL.md` と `docs/01` +- `CLAUDE.md` の cross-refactoring と cross-review の節 +- テスト(共通層・両 Skill) + +含まない: + +| 扱わないもの | 理由 | +| --- | --- | +| 反証する担当がいない指摘の数え方(#624、既存の P4) | #732(G2)の設計が持つ | +| 確認を「モデルを引く最小の呼び出し」へ替えること | #461。確認コマンドの所要と形の実測が要る。この変更の共通層は確認の手段を差し替えられる形にする | +| 起動した後に分かる使えなさ(利用上限・モデルの 404)で担当を自動で外すこと | #729(G3)が理由の語彙を持つ。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る | +| 監視の上限・結末の語彙・投稿の重なり | G3 / G5 の範囲 | +| cross-refactoring の適用ラウンドの取り込みと担当の交代 | #728(G4)の範囲 | +| 提案者と適用者が同じランタイムになることを避ける割り当て | 採らないと決めた(設計文書の決定 8) | +| 再開で `--verify-command` を空へ戻す手段 | 置き換えはできる。空へ戻す要求は出ていない | +| クラス図 | 型を追加するのは `Participants` 1 つで、関係を持つ型が無い。形は契約文書のデータ構造が持つ | +| `CHANGELOG.md` と版数 | 配布の工程が書く | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| ランタイム | `claude` / `codex` / `agy` / `kiro` の 4 つ(`ALL_RUNTIMES`) | +| ホスト | 収束ループを起動しているランタイム(`detect_host`) | +| 母集合の既定 | Skill ごとに決まる参加者の出発点。cross-review は全ランタイム − ホスト、cross-refactoring は codex / kiro / ホスト | +| 参加者 | 母集合の既定に `--include` を足し、`--exclude` を除いた一覧。確認の対象 | +| 使える者 | 参加者のうち確認を通った者(`participants.available`)。`--only` があれば `[only]` | +| 席 | ラウンドで 1 つの CLI プロセスが占める場所。名前はランタイム名か `<ランタイム>-<2〜9>`(同じランタイムの 2 つ目以降) | +| 担当 | そのラウンドの席を占める者。cross-review は `rounds[].reviewers`、cross-refactoring は `rounds[].impl` | +| 埋め合わせ | 使える者が 2 席に足りないとき、ホスト、次に同じランタイムの 2 つ目で席を埋めること | +| 再開 | 状態ファイルが残り `final` が `null` のときの `init` | + +## 受け入れ条件 + +### 共通層: 使える者の解決(`lib/assignment.py` / `lib/auth.py`) + +- [ ] AC1: 母集合 3 者のうち 1 者の確認が失敗する `probe` を `resolve_participants` に渡す。返る値の `available` は + 残り 2 者(母集合の順)、`unavailable` はその 1 者と理由を持ち、例外は上がらない +- [ ] AC2: AC1 と同じ入力で `require_all=True` を渡すと `AssignmentError` が上がり、メッセージに欠けた者の名前と + 理由が含まれる +- [ ] AC3: `exclude` に含めた者に対して `probe` が呼ばれない。`include` で足した者は呼ばれる(呼び出しの回数と + 引数で確かめる) +- [ ] AC4: 次の 4 つはいずれも `AssignmentError` になる。`include` と `exclude` に同じ名前 / `ALL_RUNTIMES` に無い + 名前 / `only` が `exclude` に含まれる / `only` が参加者に無い +- [ ] AC5: `NDF_SKIP_AUTH_CHECK` が立つと、`available` は参加者の全員、`probe_skipped` は真で、確認コマンドは + 1 回も呼ばれない +- [ ] AC6: `probe_auth` は失敗で例外を上げず、`ok: false` と理由(`コマンドが見つかりません` / 時間切れ / 終了コード + 非 0 / 未認証の文言)を返す。成功は `ok: true` +- [ ] AC7: P7 の後、`check_auth` / `impl_pool` / `review_assign` / `assign` の 4 つを `git grep -n` で探す。 + `plugins/ndf/scripts/lib/` と両 Skill の `scripts/` で 0 件になる + +### 共通層: 席の埋め方(cross-review の規則) + +- [ ] AC8: 使える者が 3 者のとき、`review_seats(r, available, [])` は変更前の `review_assign(r, host)` と一致する。 + 4 つのホスト × ラウンド 1〜12 の全組で確かめる +- [ ] AC9: 使える者が 4 者(`--include` でホストを足した)のとき、毎ラウンド 2 席で、ラウンド 1〜4 で各者が + ちょうど 2 回担当になる +- [ ] AC10: 使える者が 2 者のとき、ラウンド 1〜4 の全部でその 2 者が返る +- [ ] AC11: 使える者が 1 者(`codex`)のとき、埋め合わせに `["claude"]` を渡すと `["codex", "claude"]`、空を渡すと + `["codex", "codex-2"]` が返る +- [ ] AC12: 使える者が 0 者のとき、埋め合わせに `["claude"]` を渡すと `["claude", "claude-2"]`、空を渡すと + `AssignmentError` になる +- [ ] AC13: `seat_runtime("kiro-2")` と `seat_runtime("kiro")` は `kiro` を返す。`gemini` / `kiro-1` / `kiro-10` / + `kiro-2-3` は `AssignmentError` になる + +### cross-review: 新規の `init` + +- [ ] AC14: ホスト `claude` で `kiro` の確認が失敗する。`init` は終了コード 0 で状態ファイルを作る。 + `participants.available` は `["codex", "agy"]` で、`participants.unavailable.kiro` に理由が入る。標準エラーに + `kiro` を外したことが 1 行出る +- [ ] AC15: AC14 と同じ状態で `--require-all` を付けると、`init` は終了コード 1 で終わり、状態ファイルを作らない +- [ ] AC16: `--exclude agy` を渡すと `agy` の確認を行わない。`participants.excluded` が `["agy"]`、`available` が + `["codex", "kiro"]` になる。`--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ状態ファイルを作る +- [ ] AC17: ホスト `claude` で `--include claude` を渡すと、`available` が 4 者になり、`start-round` が 2 席を返す +- [ ] AC18: 使える者が `codex` の 1 者で、ホストの確認が通る。`init` は終了コード 0 で終わり、観点が減ることを 1 行出す。 + `participants.fallback` は `["claude"]`、`start-round` は `codex claude` を返す +- [ ] AC19: 使える者が 0 者でホストの確認が通ると、`init` は終了コード 0 で終わり、`start-round` は `claude claude-2` + を返す。ホストの確認も通らないと `init` は終了コード 1 で終わり、状態ファイルを作らない +- [ ] AC20: 次の 3 つはいずれも終了コード 1 で終わり、状態ファイルを作らない。`--exclude claude`(ホスト)/ + `--only codex --exclude codex` / `--include agy --exclude agy` +- [ ] AC21: `read-result claude-2` が受け付けられ、`rounds[-1]["claude-2"]` に結果を書く。 + `launch-reviewer.sh claude-2 ` は `claude` の CLI を起動し、stem は `claude-2-review-pr` になる + (起動は差し替えて確かめる) +- [ ] AC22: `participants` を持たない状態ファイルで、`host` があれば `start-round` は変更前の輪番を返す。 + `host` も無ければ `codex` / `agy` を返す +- [ ] AC23: 前のラウンドが `verdict` を持たず、担当 `agy` + `kiro` の両者が `REQUEST_CHANGES` で修正の記録が無い。 + このとき `start-round` は終了コード 5 で止まる +- [ ] AC24: `report` が「参加した者」の節を出す。行は 6 つで、使える者 / `--exclude` で外した者 / `--include` で + 足した者 / 確認を通らなかった者(理由つき)/ 埋め合わせ / 再開で変えた値である。`participants` を持たない + 状態ファイルでは「記録なし」と出す + +### cross-review: 再開の `init` + +- [ ] AC25: `max_rounds: 12` の状態ファイルへ `--max-rounds 20` を渡す。`max_rounds` が 20 になり、`12 → 20` の + 形で 1 行出る。`resume_changes` に `{field: "max_rounds", from: 12, to: 20}` が 1 件積まれる。 + `--rotate-after` / `--verify-command` / `--verify-exit-code` も同じく反映され、後の 2 つは置き換える +- [ ] AC26: 引数を渡さない再開では、次の 6 項目が変わらず、確認コマンドは 1 回も呼ばれない。`max_rounds` / + `rotate_after` / `verify_commands` / `verify_exit_codes` / `only` / `participants` +- [ ] AC27: `only: null` の状態ファイルへ `--only codex` を渡すと `only` が `codex` になり、次の `start-round` が + `codex` だけを返す。記録を持つ過去のラウンドの `reviewers` は変わらない。`--only none` は `only` を `null` へ戻す +- [ ] AC28: `--exclude agy` を渡した再開では、参加者の確認をやり直し、`available` から `agy` が消え、次の + `start-round` が `agy` を返さない。`--exclude none` は除外を空へ戻す +- [ ] AC29: `host: "claude"` の状態ファイルへ `--host codex` を渡すと `host` は変わらず、反映しないことが 1 行出る。 + `--host claude` では何も出ない +- [ ] AC30: `SKILL.md` と `docs/01-state-and-review.md` で `grep -n 'ONLY'` が当たる行は、`init` へ引数を渡す行と + 引数の説明の行だけになる。起動・監視・取り込み・反証の担当は `$REVIEWERS` / `$REVIEWERS_CSV` を使う + +### cross-refactoring: 母集合と担当 + +- [ ] AC31: ホスト `claude` の新規の `init` で、状態ファイルの `runtimes` は `["claude", "codex", "kiro"]` になり、 + `agy` の確認は行われない。状態ファイルに `impl_capable` は無く、標準出力に `IMPL_POOL=` の行は無い +- [ ] AC32: ホスト `codex` では `runtimes` が `["codex", "kiro"]`、ホスト `agy` では `["codex", "agy", "kiro"]` になる +- [ ] AC33: `--include agy` で `runtimes` が 4 者に、`--exclude kiro` で 2 者になる +- [ ] AC34: `impl_assign(r, ["claude", "codex", "kiro"])` をラウンド 1〜6 で呼ぶ。返る値は `codex` / `kiro` / `claude` / + `codex` / `kiro` / `claude` である。`start-round` は `REVIEWERS` / `REVIEWERS_CSV` を出さない。ラウンドの記録に + `reviewers` / `reviewer_models` が無い +- [ ] AC35: `kiro` の確認が失敗しても `init` は終了コード 0 で終わる。`participants.unavailable.kiro` に理由が入り、 + `runtimes` は 2 者になる。`--require-all` を付けると終了コード 4 で終わり、状態ファイルを作らない +- [ ] AC36: 使える者が 0 者のとき `init` は終了コード 4 で終わり、状態ファイルを作らない +- [ ] AC37: `report` と改修計画の表示にレビュー担当の列が無く、母集合を 1 行で出す(「提案・レビュー」と「適用の母集合」の + 2 行に分けない) + +### cross-refactoring: 再開の `init` + +- [ ] AC38: `max_outer_rounds: 3` の状態ファイルへ `--max-outer-rounds 5` を渡す。5 になり、`3 → 5` の形で 1 行出て、 + `resume_changes` に 1 件積まれる。`--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` も同じ +- [ ] AC39: 再開で `--model codex=x` / `--host codex` / `--scope other` を渡すと、状態は変わらず、反映しないことが + 引数ごとに 1 行出る。状態に載る他の引数(`--baseline-test` など。契約文書の表)も同じ扱いである。引数を渡さない + 再開では、上限 4 項目と `models` と `runtimes` が変わらない +- [ ] AC40: 再開で `--exclude kiro` を渡すと参加者の確認をやり直す。`runtimes` から `kiro` が消え、次の `start-round` の + `RUNTIMES` に `kiro` が無い +- [ ] AC41: `impl_capable` を持ち `participants` を持たない状態ファイル(この変更の前に始めた実行)を、`start-round` / + `report` が読める。適用の輪番は `runtimes` から決まる + +### 文書 + +- [ ] AC42: `CLAUDE.md` の cross-refactoring の節が「codex / kiro / ホストの 3 者」と「適用担当は 3 ラウンドで 1 周」を + 書く。「ホストを除く 3 者」「参加する 4 者」を含まない。cross-review の節が「codex / agy の両方」を含まない。 + 次の 3 つがいずれも 0 行を出す + + ```bash + grep -n "ホストを除く 3 者" CLAUDE.md + grep -n "参加する 4 者" CLAUDE.md + grep -n "codex / agy の両方" CLAUDE.md + ``` + +- [ ] AC43: cross-refactoring の `SKILL.md` の「担当の決め方」が母集合を 1 つの表で書く。引数の表と `argument-hint` に + `--exclude` / `--include` / `--require-all` がある。「前提」から「すべてログイン済み」が消える。ホストごとに要る + CLI の表が `codex` / `kiro-cli`(ホストが codex / kiro ならもう 1 つ)になる。`docs/01-state-and-propose.md` の + `init` が返す変数の表に `IMPL_POOL` が無い +- [ ] AC44: cross-review の次の 4 ファイルが、それぞれの内容を書く + + | ファイル | 書く内容 | + | --- | --- | + | `SKILL.md` | 引数の表と `argument-hint` に `--exclude` / `--include` / `--require-all`。`--only` の説明から「デバッグ用」が消える。母集合の行が席の規則を指す | + | `docs/05-pool-and-convergence.md` | 使える者の解決と席の埋め方(3 者以上 / 2 者 / 1 者 / 0 者)、`--exclude` / `--include`、確認が把握になったこと | + | `docs/04-contracts.md` | 状態ファイルの `participants` と `resume_changes`、席の名前の形 | + | `docs/01-state-and-review.md` | 再開で反映する引数と、反映しない引数 | + +### 子 issue の再現手順 + +- [ ] AC45: #478 の再現(`kiro-cli` が無い環境で `init`)で、`init` が終了コード 0 で終わる。#687 の場面 3 + (`codex` が使えない)で、`--exclude codex` を付けずに `init` が開始できる +- [ ] AC46: #648 の再現(再開の `init` に `--only codex`)で、次の `start-round` が `codex` だけを返す +- [ ] AC47: #664 の再現(`git grep -n '"--exclude"' -- plugins/ndf/skills/cross-refactoring`)が 1 行以上を出す +- [ ] AC48: #461 の再現(モデルを引けない CLI が確認を通る)は、この変更の後も現象が残ることを確かめて記録する + (直す判断は #461 が持つ) + +### 全体 + +- [ ] AC49: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [ ] AC50: 次の 6 つが終了コード 0 で終わる + + ```bash + bash scripts/build-runtime-plugins.sh --check + claude plugin validate . + python3 scripts/check-skill-frontmatter.py + python3 scripts/check-doc-staleness.py --root . + python3 scripts/check-markdown-links.py --root . + python3 scripts/check-skill-shell-vars.py + ``` + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 可用性 | 母集合の 1 者が使えないことで、どちらの収束ループも開始できない状態にならない(AC14、AC35) | +| 性能・拡張性 | 確認の回数は、新規の `init` で「参加者の数 + 埋め合わせが要るときのホスト 1 回」を超えない。除外した者は確かめない。再開で確かめ直すのは担当に関わる引数を渡したときだけである | +| 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、再開で反映しなかった引数が、`report` と `init` の出力だけで分かる(AC24、AC29、AC39) | +| 移行性 | この変更の前に始めた実行の状態ファイルを、書き換えずに読める(AC22、AC41) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 認証に失敗する CLI がある利用者 | どちらの `init` も止まらず、使える者で回る。従来の関門は `--require-all` で選べる | +| cross-refactoring の既定の参加者 | agy が既定から外れ、ホストが提案と適用に入る。`--include agy` で戻せる。提案者と適用者が同じランタイムになりうる | +| cross-review で使える者が 2 者に満たない利用者 | ホスト、次に同じランタイムの 2 つ目が席を埋める。1 者で回るのは `--only` を渡したときだけになる | +| 状態ファイルの形 | 両 Skill の最上位に `participants` と `resume_changes` が増える。cross-refactoring の `impl_capable` とラウンドの `reviewers` / `reviewer_models` が新規の状態から消える。無い項目は従来の読み方で読む | +| `init` の引数 | 両 Skill に `--exclude` / `--include` / `--require-all` が増える。cross-review の `--only` が `none` を取る。既定値は変わらない | +| 共通層の関数 | `check_auth` / `impl_pool` / `review_assign` / `assign` が消え、`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` が入る。呼び出し側は両 Skill だけである | +| 担当名の形 | ランタイム名に `-2`〜`-9` の接尾辞を持つ席の名前が、結果ファイルの stem と状態ファイルの鍵に現れうる | +| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC23) | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | +| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | +| 定義と文書の検査 | AC50 の 6 つ | +| 手動確認 | P7 の後、ホスト claude で `--exclude codex` を付けた cross-review と、既定の cross-refactoring を 1 本ずつ回し、`report` で参加者と席を見る | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 担当の決め方は `plugins/ndf/scripts/lib/assignment.py`、確認は `lib/auth.py`、再開の反映は `lib/statefile.py` に置く。判定と状態の鍵は各 Skill の `state.py` / `refactor_lib` が持ち、骨組みは結果を使うだけにする | +| コーディング規約 | 状態ファイルを最小の形で組み、関数を直接呼んで確かめる(`AGENTS.md` の DO)。分岐は表(データ)で持つ(`refactoring` の「分岐をデータ化」) | +| テスト戦略 | 共通層は `plugins/ndf/scripts/tests/` の関数テスト、Skill は既存の形(`conftest.py` の `state_mod` / `crossref_helpers`)で GitHub と CLI の起動を差し替える | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、配布物の同期の検査、文書の検査 | +| 確認してから行う | `init` の既定を「確認の失敗で止める」から「使える者で回す」へ変えること。cross-refactoring の既定から agy を外すこと。どちらも設計 Pull Request の承認で確認する | +| 行わない | 監視・起動・投稿の経路の変更、`refactor_lib` の適用の取り込みの変更、確認コマンドの差し替え | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 確認を「モデルを引く最小の呼び出し」へ替えるか、替えるならランタイムごとのコマンドと所要 | #461 | マイルストーン 06 の着手時 | +| 同じランタイムの 2 席が、別のランタイムの 2 席より指摘を見落とすか | この変更の後の運用(`measure.py`) | 実測が 3 本たまった時点 | + +## 既存の受け入れ条件との対応 + +既存の設計(PR #667)の P5 の受け入れ条件を、この文書のどこが引き継ぐかを示す。 + +| 既存 | この文書 | 変わったこと | +| --- | --- | --- | +| AC10 | AC14 | 項目が `available_reviewers` から `participants.available` へ。値は同じ | +| AC11 | AC15 | 同じ | +| AC12 | AC5 | 共通層の条件として書き直した | +| AC13 | AC16 | 同じ | +| AC14 | AC20 | `--include` と `--exclude` の矛盾を足した | +| AC15 | AC8 | 関数が `review_assign` から `review_seats` へ。値は同じ | +| AC16 | AC10 | 同じ | +| AC17 | AC18 | 1 者で回すのではなく、ホストで 2 席目を埋める。1 者のまま回るのは `--only` だけ | +| AC18 | AC19 | 0 者で失敗するのではなく、ホストが通れば 2 席を埋める。ホストも通らないときだけ失敗する | +| AC19 | AC22 | 同じ | +| AC20 | AC7、AC34、AC35 | 「cross-refactoring は変わらない」から「cross-refactoring も同じ層を通る」へ。`check_auth` は消える | +| AC21 | AC24 | `--include` で足した者と埋め合わせの行を足した | +| AC22 | AC25 | 同じ | +| AC23 | AC26 | 項目が `participants` に畳まれた | +| AC24 | AC27 | 同じ | +| AC25 | AC27、AC28 | 同じ | +| AC26 | AC28 | 同じ | +| AC27 | AC29 | 同じ | +| AC28 | AC30 | 同じ | +| AC29 | AC23 | 同じ | +| AC30 | AC44 | `--include` と席の規則を足した | +| AC31 | AC49 | 同じ | +| AC32 | AC50 | 文書の検査 3 つを足した | +| — | AC1〜AC4、AC6、AC9、AC11〜AC13、AC17、AC21 | 新設(共通層の解決と席) | +| — | AC31〜AC43 | 新設(cross-refactoring と `CLAUDE.md`) | +| — | AC45〜AC48 | 新設(子 issue の再現手順) | + +## 依頼(原文) + +### #727(根本原因の親) + +> **参加する CLI が実行できるかを確かめ、使える者から担当を割り当てる共通層。** 場所は `plugins/ndf/scripts/lib/auth.py` の `check_auth`(36 行目)と `assignment.py` の `review_pool` / `review_assign` / `assign`(78 / 95 / 114 行目)である。 +> +> - `check_auth` は認証の成否だけを見て、1 件の失敗で `die` する。モデルを引けるか・更新トークンが生きているかは見ない +> - `assignment.py` は母集合を「全ランタイム − ホスト」の定数から作り、使える者を入力に取らない +> - cross-review(`state.py` の `init`)と cross-refactoring(`refactor_lib/commands/setup.py` の `cmd_init`)の両方が、この層を使う +> +> ## 採る手 +> +> 移動(`move_responsibility`)。使える者の決定を、各 Skill の初期化の関門から共通層の割り当てへ移す。 +> +> **cross-refactoring の既定の母集合は codex / kiro / ホストとし、agy を外す**(#664 / #687 の 2026-09-18 の決定。CLI の起動 199 回のうち失敗は agy の 7 回だけで、提案の所要も agy が最も長かった)。提案も適用もこの 3 者で回す。cross-review と共有する `assignment.py` で規則を 1 つにし、ホストが codex / kiro のとき(母集合が 2 者)の扱いは #687 の規則(各ラウンド 2 者を確保する。同一ランタイム 2 つ・ホストの参加を許す)に従う。 +> +> ## 完了条件 +> +> - 共通層が「最小の呼び出しが通るか」で使える者を決めて返し、割り当てがその一覧から担当を選ぶ +> - cross-review と cross-refactoring の両方がこの層だけを通り、片方にだけ古い形が残らない +> - `CLAUDE.md` の cross-refactoring の節(「ホストを除く 3 者」「参加する 4 者から輪番」)と `cross-refactoring/SKILL.md` の「担当の決め方」を、実装後の母集合に合わせる +> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる(子 issue はその時点の棚卸が「閉じてよい」で閉じる) + +### #687 + +> - **各ラウンドで 2 者がレビューできることを最優先にする。** 誰が担当かより、2 つの目で見ることを優先する +> - **文脈が分かれていれば、同じランタイムを 2 つ立ててよい。** 別プロセス・別文脈なら独立した意見になる +> - **コードを書いたランタイム(ホスト)が担当に入ってよい。** 輪番の形にはこだわらない +> - **他にランタイムが 1 つも無ければ、ホストを 2 つ走らせる形でよい** +> +> **置き場所も決める。** 規則が決まっても、置き場所が決まらなければ次に使う人へ届かない。 +> +> **cross-refactoring では、既定の担当から agy を外し、ホストのランタイムを輪番へ入れる**と決めた(利用者の指示)。提案も適用も codex / kiro / ホストの 3 者になる。 + +### #478 + +> **認証確認を「関門」から「導入状況の把握」へ変える。** 使える者でレビューし、使えない者は最初から数えない。 +> +> 1. `init` で母集合の各ランタイムの認証を確かめ、**通った者の一覧を `state.json` へ記録する**(`available_reviewers`) +> 2. **明示的に外す手段を用意する**(例: `--exclude agy`)。**認証は通るが実行で落ちる担当を外す用途でも使う。** +> 3. 使える者の数で分岐する(3 者: 現行どおり / 2 者: 毎ラウンドその 2 者 / 1 者: 警告して 1 者 / 0 者: 失敗) +> 4. `review_assign()` は「**使える者が 3 者以上のときだけ 1 者を外す**」に変える +> 5. 全員揃っていることを要求したい運用のために `--require-all` を用意する +> 6. 完了報告に「このループに参加したのは誰か」を出す + +### #664 + +> `cross-refactoring` には、担当から特定のランタイムを外す引数が無い。 +> +> **外す手段を足すだけでは足りない。** `assign()` は実装担当を先に決めてから、提案・レビューの母集合から実装担当を除いた者をレビュー担当にする。使える者が 2 者だと、実装担当が codex か kiro のラウンドではレビュー担当が 1 者になる。 +> +> | 母集合 | いま | 変えた後 | +> |---|---|---| +> | 提案(`review_pool(host)` ) | 全ランタイム − ホスト(codex / agy / kiro) | codex / kiro / ホスト | +> | 適用(`impl_pool()` ) | 4 者すべて | codex / kiro / ホスト | +> +> - 外す手段(`--exclude` )を足すだけでなく、**既定の母集合から agy を外す**。agy を戻す手段を残すかは設計で決める +> - 同じ項目の提案者と適用者が重ならないよう、割り当てで避けるかは設計で決める +> - ホストが codex / kiro のときは、母集合が 2 者になる。そのときの扱いは #687 の規則に従う +> - `CLAUDE.md` の cross-refactoring の節(「codex / agy / kiro / claude のうちホストを除く 3 者」「参加する 4 者から輪番」)もあわせて直す + +### #648 + +> `/ndf:cross-review` を中断・再開すると、`state.py init` に渡した `--only` / `--max-rounds` / `--rotate-after` / `--verify-command` / `--verify-exit-code` / `--host` が**黙って無視される**。 +> +> **`cross-refactoring` にも同じ形がある。** `refactor.py` の `init` が受ける `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / `--model` は再開時に反映されない。 +> +> ## 修正レイヤー +> +> `plugins/ndf/scripts/lib/statefile.py` に置く、再開時の引数の反映の契約。「明示的に渡された引数だけを状態へ重ね、反映しない引数は渡されたら知らせる」を 1 か所で持つ。 + +(各 issue の本文から抜粋。全文は `gh issue view 727` / `687` / `478` / `664` / `648`) From 982fb760d5cbbab0b860c6048cd1c52fdd3d823b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 03:32:01 +0000 Subject: [PATCH 005/217] =?UTF-8?q?Docs:=20=E6=9C=9F=E5=BE=85=E5=80=A4?= =?UTF-8?q?=E3=82=92=E5=A4=89=E3=81=88=E3=82=8B=E6=97=A2=E5=AD=98=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=AE=E4=BB=B6=E6=95=B0=E3=82=92=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E6=96=87=E6=9B=B8=E3=81=AE=204=20=E4=BB=B6=E3=81=AB?= =?UTF-8?q?=E6=8F=83=E3=81=88=E3=82=8B=EF=BC=88#732=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- issues/issue-732-624-706-design.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/issue-732-624-706-design.md b/issues/issue-732-624-706-design.md index 428e09aa..5fb78970 100644 --- a/issues/issue-732-624-706-design.md +++ b/issues/issue-732-624-706-design.md @@ -152,7 +152,7 @@ plugins/ndf/skills/cross-review/ │ ├── measure.py # COUNTED_CLASSIFICATIONS に unrefuted を足す │ └── state.py # _classify_finding / _apply_classification / COUNTED_CLASSIFICATIONS / _handle_incomplete_critiques └── tests/ - ├── test_classify_findings.py # A〜K、AC10、期待値を変える 3 件 + ├── test_classify_findings.py # A〜K、AC10、期待値を変える 4 件 ├── test_critiques.py # AC13(印を外す) ├── test_measure.py # AC11(一致)・AC12(proposed) └── test_skill_layout.py # AC18〜AC20 の grep @@ -260,7 +260,7 @@ graph TD | AC12 | 印のあるラウンドの `unrefuted` を `proposed` の `found` が数える | 同上(既存の `test_proposed_takes_only_the_two_counted_classifications` を 3 区分へ改める) | | AC13 | 印の付いた状態で `cmd_collect_critiques` を 2 回呼び、`evidence_rounds` と数え方を見る | `test_critiques.py` | | AC14・AC15 | 既存のテスト(`minor` の区分、印なしの数え方)を期待値を変えずに通す | 既存のまま | -| AC16 | 3 件のテストの期待値を新しい区分へ改め、他は変えない | `test_classify_findings.py` | +| AC16 | 4 件のテストの期待値を新しい区分へ改め、他は変えない | `test_classify_findings.py` | | AC17 | `cmd_judge` の既存のテストを期待値を変えずに通す。差分に `cmd_judge` の行が無いことをレビューで見る | 既存のまま・設計 Pull Request のレビュー | | AC18〜AC20 | 文書の語を `grep` するテスト | `test_skill_layout.py` | | AC21・AC22 | コマンドの終了コード | 継続的統合と手元 | From 365ec658bcb77de91ecb9137e242780b896acdc7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 03:37:42 +0000 Subject: [PATCH 006/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=20PR=20#782?= =?UTF-8?q?=20=E3=81=AE=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87?= =?UTF-8?q?=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=EF=BC=88refactor=5Fpool=20?= =?UTF-8?q?=E3=81=AE=E6=97=A2=E5=AE=9A=E3=83=BBhost=20=E5=BC=95=E6=95=B0?= =?UTF-8?q?=E3=83=BB=E5=B8=AD=E3=81=AE=E9=87=8D=E8=A4=87=E9=98=B2=E6=AD=A2?= =?UTF-8?q?=E3=83=BB=E6=B1=BA=E5=AE=9A=E7=95=AA=E5=8F=B7=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - refactor_pool(host) の定義を「ALL_RUNTIMES の順で DEFAULT_REFACTOR_RUNTIMES(codex, kiro)とホスト」に直し、 DEFAULT_EXCLUDED_FOR_REFACTORING と「既定の除外」の言い方を設計・契約から消す(AC31/AC32 と揃える) - resolve_participants にキーワード引数 host を足し、「--exclude にホストがあれば弾く」検査を共通層が持つと明記 - review_seats の埋め合わせは available に含まれない者だけを使い、無ければ <名前>-2 を充てる(同じ席名の重複防止) - _round_reviewers の契約の参照先を決定 10 から決定 11 に直す Co-Authored-By: Claude Fable 5.1 --- issues/issue-727-687-478-664-648-contracts.md | 14 +++++++++----- issues/issue-727-687-478-664-648-design.md | 18 +++++++++++------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/issues/issue-727-687-478-664-648-contracts.md b/issues/issue-727-687-478-664-648-contracts.md index e8a9eee3..4841abbb 100644 --- a/issues/issue-727-687-478-664-648-contracts.md +++ b/issues/issue-727-687-478-664-648-contracts.md @@ -199,8 +199,8 @@ erDiagram | 関数 | 入力 | 出力 | 失敗の形 | 変更 | | --- | --- | --- | --- | --- | | `review_pool(host)` | ホスト名 | 全ランタイム − ホスト | ホストでない名前で `AssignmentError` | 変わらない | -| `refactor_pool(host)` | ホスト名 | `ALL_RUNTIMES` の順で、`DEFAULT_EXCLUDED_FOR_REFACTORING`(`("agy",)`)に無い者とホスト | 同上 | **新設** | -| `resolve_participants(pool, *, include=(), exclude=(), only=None, probe, require_all=False)` | 母集合、足す・外す名前、`--only`、確認の関数(`probe_auth` の形)、全員を要するか | `Participants`(`participants` の 8 項目のうち `fallback` を除く 7 項目を持つデータクラス。`to_state()` で辞書にする) | 名前の矛盾・`require_all` で欠け → `AssignmentError`(欠けた者と理由を並べる) | **新設** | +| `refactor_pool(host)` | ホスト名 | `ALL_RUNTIMES` の順で、`DEFAULT_REFACTOR_RUNTIMES`(`("codex", "kiro")`)とホスト | 同上 | **新設** | +| `resolve_participants(pool, *, host, include=(), exclude=(), only=None, probe, require_all=False)` | 母集合、ホスト名、足す・外す名前、`--only`、確認の関数(`probe_auth` の形)、全員を要するか | `Participants`(`participants` の 8 項目のうち `fallback` を除く 7 項目を持つデータクラス。`to_state()` で辞書にする) | 名前の矛盾(`exclude` にホストを含む場合もここ)・`require_all` で欠け → `AssignmentError`(欠けた者と理由を並べる) | **新設** | | `review_seats(round_no, available, fallback)` | ラウンド番号、使える者、埋め合わせに使える者 | 席の名前 2 つ(`only` は呼び出し側が先に処理する) | `round_no < 1` / `available` と `fallback` が両方空 → `AssignmentError` | **新設**(`review_assign` を置き換える) | | `impl_assign(round_no, participants)` | ラウンド番号(適用の通し番号)、使える者 | 実装担当 1 者(`participants[round_no % len]`) | `round_no < 1` / 空 → `AssignmentError` | **新設**(`assign` を置き換える) | | `seat_runtime(seat)` | 席の名前 | ランタイム名 | 形に合わない → `AssignmentError` | **新設** | @@ -213,9 +213,13 @@ erDiagram | ---: | --- | | 3 以上 | `pool[round_no % n]` と `pool[(round_no + 1) % n]` を母集合の順に並べた 2 席 | | 2 | その 2 者 | -| 1 | その 1 者と、`fallback` の先頭。`fallback` が空なら同じランタイムの 2 つ目(`<名前>-2`) | +| 1 | その 1 者と、`fallback` のうち `available` に含まれない先頭の者。そのような者が無ければ同じランタイムの 2 つ目(`<名前>-2`) | | 0 | `fallback` の先頭と、その 2 つ目(`<名前>-2`)。`fallback` が空なら `AssignmentError` | +埋め合わせの候補は `available` に含まれない者だけを使い、含まれる者は飛ばす。同じ席の名前を 2 つ返さないための規則で、 +`available=["claude"], fallback=["claude"]` は `["claude", "claude-2"]` になる(`--include` でホストが使える者に入った +cross-review)。 + ### 共通層の関数(`lib/auth.py`) | 関数 | 入力 | 出力 | 失敗の形 | 変更 | @@ -238,8 +242,8 @@ erDiagram | 関数 | 契約 | 変更 | | --- | --- | --- | -| `_resolve_reviewers(host, args)` | `resolve_participants(review_pool(host), …)` を呼び、`available` が 2 者に満たなければホストを `probe_auth` で確かめて `fallback` を決める。`AssignmentError` は `die(code=1)` へ写す | **新設**(`_auth_targets` / `_validate_only` を置き換える) | -| `_round_reviewers(st, round_no)` | 決定 10 の順で返す | 順を変える | +| `_resolve_reviewers(host, args)` | `resolve_participants(review_pool(host), host=host, …)` を呼び、`available` が 2 者に満たなければホストを `probe_auth` で確かめて `fallback` を決める。`AssignmentError` は `die(code=1)` へ写す | **新設**(`_auth_targets` / `_validate_only` を置き換える) | +| `_round_reviewers(st, round_no)` | 決定 11 の順で返す | 順を変える | | `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | `apply_resume_args` を呼び、担当に関わる引数があれば `_resolve_reviewers` で作り直す。失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | | `_guard_previous_round(st, prev)` | `prev["reviewers"]`(無ければ `_round_reviewers`)を渡す | 担当を渡す | diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md index 6787b509..59d81108 100644 --- a/issues/issue-727-687-478-664-648-design.md +++ b/issues/issue-727-687-478-664-648-design.md @@ -49,7 +49,8 @@ いまは cross-review の `_auth_targets` / `_validate_only` と cross-refactoring の `cmd_init` が、それぞれ母集合を作って `check_auth` を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・ 確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 の `move_responsibility`)。**共通層に `resolve_participants` を -1 つ置き、母集合・`--include` / `--exclude` / `--only`・確認・`--require-all` から `Participants` を返す。** Skill が持つのは、 +1 つ置き、母集合・ホスト・`--include` / `--exclude` / `--only`・確認・`--require-all` から `Participants` を返す。** +`--exclude` にホストがあれば弾く検査も、ホストを受け取るこの関数が持つ。Skill が持つのは、 その値を状態ファイルへ書くことと、`AssignmentError` を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写す ことだけである。 @@ -74,13 +75,14 @@ | Skill | 既定を返す関数 | 中身 | | --- | --- | --- | | cross-review | `review_pool(host)` | 全ランタイム − ホスト | -| cross-refactoring | `refactor_pool(host)` | `DEFAULT_EXCLUDED_FOR_REFACTORING`(`("agy",)`)に無い者とホスト | +| cross-refactoring | `refactor_pool(host)` | `ALL_RUNTIMES` の順で、`DEFAULT_REFACTOR_RUNTIMES`(`("codex", "kiro")`)とホスト | cross-review の `--include` はホストを母集合へ入れる用途になる(#687 の「ホストの参加を許す」の明示的な形)。 ### 決定 5: cross-refactoring の母集合を 1 つにし、`impl_capable` / `IMPL_POOL` / `impl_pool()` を消す -既定の参加者を codex / kiro / ホストにすると、提案の母集合と適用の母集合が同じ集合になる。`impl_pool()` を関数として +既定の参加者を codex / kiro / ホスト(表 `DEFAULT_REFACTOR_RUNTIMES` とホスト)にすると、提案の母集合と適用の母集合が +同じ集合になる。`impl_pool()` を関数として 残した理由(「両者は一致しない」)が消えるため、関数・状態ファイルの項目・`init` の出力変数の 3 つを消す。`runtimes` は 提案の取り込みと `prepare-worktrees.sh` が読むため残し、適用の輪番も同じ値を読む。 @@ -121,13 +123,15 @@ cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-re #687 の 4 つの規則のうち「各ラウンドで 2 者」を最優先に置き、残る 3 つを埋める順序として読む。**違うランタイムを 先に使う。** 同じモデルの 2 つの文脈より、違うモデルの 2 つの文脈のほうが観点が分かれる。ホストは既定の母集合に 無いため、使える者が 1 者のときだけ席に入る。同じランタイムの 2 つ目(`<名前>-2`)は、ホストも使えないときの最後の -手段である。 +手段である。**埋め合わせの候補は使える者に含まれない者だけを使う。** `--include` でホストが使える者に入っているとき、 +ホストを埋め合わせにも使うと同じ席の名前が並ぶ(`["claude", "claude"]`)。含まれる者は飛ばし、それでも 2 席に +満たなければ同じランタイムの 2 つ目を充てる(`available=["claude"], fallback=["claude"]` → `["claude", "claude-2"]`)。 | 使える者の数 | 席 | | ---: | --- | | 3 以上 | 輪番で 2 席(3 者のときは変更前の値と一致する) | | 2 | その 2 者 | -| 1 | その 1 者とホスト。ホストが使えなければ同じランタイムの 2 つ目 | +| 1 | その 1 者とホスト。ホストが使えないか、その 1 者と同じなら同じランタイムの 2 つ目 | | 0 | ホストとその 2 つ目。ホストも使えなければ失敗 | `--only` は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。使える者が 2 者のとき輪番で @@ -243,8 +247,8 @@ stem を逆に解析して担当名へ戻す箇所は無い(「実測」)た | 要素 | 責務 | Pull Request | | --- | --- | --- | -| 母集合の既定(`assignment.review_pool` / `refactor_pool`) | Skill ごとの出発点を返す。既定の除外は表 `DEFAULT_EXCLUDED_FOR_REFACTORING` が持つ | P6 | -| 使える者の解決(`assignment.resolve_participants`) | 母集合・足す・外す・`only`・確認・`require_all` から `Participants` を返す。名前の矛盾と欠けを `AssignmentError` で返す | P6 | +| 母集合の既定(`assignment.review_pool` / `refactor_pool`) | Skill ごとの出発点を返す。既定の参加者は表 `DEFAULT_REFACTOR_RUNTIMES` が持つ | P6 | +| 使える者の解決(`assignment.resolve_participants`) | 母集合・ホスト・足す・外す・`only`・確認・`require_all` から `Participants` を返す。名前の矛盾(`--exclude` にホストを含む)と欠けを `AssignmentError` で返す | P6 | | 確認(`auth.probe_auth`) | 止めずに確かめ、担当ごとの結果を返す | P6 | | 席の埋め方(`assignment.review_seats`) | 使える者と埋め合わせから 2 席を返す(決定 9) | P6 | | 適用の輪番(`assignment.impl_assign`) | 参加者から実装担当 1 者を返す(決定 7) | P6(呼び手は P7) | From 42e33d8c4379f1e0e62d3cd9027c772536600835 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 03:48:32 +0000 Subject: [PATCH 007/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=20PR=20#782?= =?UTF-8?q?=20=E3=81=AE=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=202=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=EF=BC=88--exclude?= =?UTF-8?q?=20=E3=81=AE=E6=A4=9C=E6=9F=BB=E3=82=92=E6=AF=8D=E9=9B=86?= =?UTF-8?q?=E5=90=88=E3=81=A7=E6=B1=BA=E3=82=81=E3=82=8B=E3=83=BB--only=20?= =?UTF-8?q?=E3=81=AF=E3=83=9B=E3=82=B9=E3=83=88=E3=82=92=E7=A2=BA=E3=81=8B?= =?UTF-8?q?=E3=82=81=E3=81=AA=E3=81=84=E3=83=BB=E6=97=A2=E5=AE=9A=E3=81=AE?= =?UTF-8?q?=E5=8F=82=E5=8A=A0=E8=80=85=E3=81=AE=E6=95=B0=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - `--exclude` / `--only` の名前は母集合(既定 ∪ `--include`)に含まれなければ弾く規則に統一。 cross-review ではホストが母集合に無いため `--exclude <ホスト>` はここで弾かれ、 cross-refactoring ではホストが母集合にあるため外せる(design.md 決定 2・処理の流れ・構成要素の表、 contracts.md 名前の検査の表・`resolve_participants` の行、requirements.md AC33) - `_resolve_reviewers` は `only` があるときホストを確かめず `fallback` を空にする (contracts.md の契約と `participants.fallback` の意味、design.md 決定 9、requirements.md AC18) - cross-refactoring の既定の参加者を「codex / kiro とホスト(ホストが codex / kiro なら 2 者)」に統一し、 輪番の 1 周を「参加者の数のラウンド」にする(requirements.md 目的・AC42・#664 の記録、design.md #736 の行) Co-Authored-By: Claude Fable 5.1 --- issues/issue-727-687-478-664-648-contracts.md | 8 ++++---- issues/issue-727-687-478-664-648-design.md | 14 +++++++++----- issues/issue-727-687-478-664-648-requirements.md | 14 +++++++++----- 3 files changed, 22 insertions(+), 14 deletions(-) diff --git a/issues/issue-727-687-478-664-648-contracts.md b/issues/issue-727-687-478-664-648-contracts.md index 4841abbb..a96db676 100644 --- a/issues/issue-727-687-478-664-648-contracts.md +++ b/issues/issue-727-687-478-664-648-contracts.md @@ -26,7 +26,7 @@ | `unavailable` | オブジェクト(名前 → 理由の文字列) | 許す(空) | 確認を通らなかった者と `probe_auth` の `detail`。空は「全員が通った」か「確認を飛ばした」 | | `probe_skipped` | 真偽値 | 許さない | `NDF_SKIP_AUTH_CHECK` で確認を飛ばしたか。`unavailable` が空である理由を区別する | | `require_all` | 真偽値 | 許さない | `--require-all` の値。新規の既定は `false` | -| `fallback` | 文字列の配列 | 許す(空) | **cross-review だけ。** 席の埋め合わせに使える者(ホストの確認が通れば `[host]`)。`available` が 2 者以上なら空 | +| `fallback` | 文字列の配列 | 許す(空) | **cross-review だけ。** 席の埋め合わせに使える者(ホストの確認が通れば `[host]`)。`available` が 2 者以上か `only` があれば空 | `resume_changes[]` の要素は既存の契約と同じ(`at` / `field` / `from` / `to`)。`field` は状態ファイルの鍵で、 `participants` を作り直したときは `participants` の 1 件として積む(中の項目ごとには積まない)。 @@ -139,7 +139,7 @@ erDiagram | 段 | 何を見るか | 誰が弾くか | 終了コード | | --- | --- | --- | --- | | 1 | 綴り(4 つの名前か `none`) | argparse の型 | 2 | -| 2 | 母集合との関係(`--exclude` にホスト / `--include` と `--exclude` の重なり / `--only` と `--exclude` の矛盾 / `none` と名前の混在) | 共通層の `resolve_participants`(`AssignmentError`)。`init` が Skill の終了コードへ写す | cross-review 1 / cross-refactoring 4 | +| 2 | 母集合との関係(`--exclude` / `--only` に母集合(既定 ∪ `--include`)に無い名前(cross-review のホストはこれに当たる) / `--include` と `--exclude` の重なり / `--only` と `--exclude` の矛盾 / `none` と名前の混在) | 共通層の `resolve_participants`(`AssignmentError`)。`init` が Skill の終了コードへ写す | cross-review 1 / cross-refactoring 4 | ### `init` の出力と終了コード @@ -200,7 +200,7 @@ erDiagram | --- | --- | --- | --- | --- | | `review_pool(host)` | ホスト名 | 全ランタイム − ホスト | ホストでない名前で `AssignmentError` | 変わらない | | `refactor_pool(host)` | ホスト名 | `ALL_RUNTIMES` の順で、`DEFAULT_REFACTOR_RUNTIMES`(`("codex", "kiro")`)とホスト | 同上 | **新設** | -| `resolve_participants(pool, *, host, include=(), exclude=(), only=None, probe, require_all=False)` | 母集合、ホスト名、足す・外す名前、`--only`、確認の関数(`probe_auth` の形)、全員を要するか | `Participants`(`participants` の 8 項目のうち `fallback` を除く 7 項目を持つデータクラス。`to_state()` で辞書にする) | 名前の矛盾(`exclude` にホストを含む場合もここ)・`require_all` で欠け → `AssignmentError`(欠けた者と理由を並べる) | **新設** | +| `resolve_participants(pool, *, host, include=(), exclude=(), only=None, probe, require_all=False)` | 母集合、ホスト名、足す・外す名前、`--only`、確認の関数(`probe_auth` の形)、全員を要するか | `Participants`(`participants` の 8 項目のうち `fallback` を除く 7 項目を持つデータクラス。`to_state()` で辞書にする) | 名前の矛盾(`exclude` / `only` の名前が母集合(既定 ∪ `include`)に無い場合もここ。cross-review のホストはこれに当たる)・`require_all` で欠け → `AssignmentError`(欠けた者と理由を並べる) | **新設** | | `review_seats(round_no, available, fallback)` | ラウンド番号、使える者、埋め合わせに使える者 | 席の名前 2 つ(`only` は呼び出し側が先に処理する) | `round_no < 1` / `available` と `fallback` が両方空 → `AssignmentError` | **新設**(`review_assign` を置き換える) | | `impl_assign(round_no, participants)` | ラウンド番号(適用の通し番号)、使える者 | 実装担当 1 者(`participants[round_no % len]`) | `round_no < 1` / 空 → `AssignmentError` | **新設**(`assign` を置き換える) | | `seat_runtime(seat)` | 席の名前 | ランタイム名 | 形に合わない → `AssignmentError` | **新設** | @@ -242,7 +242,7 @@ cross-review)。 | 関数 | 契約 | 変更 | | --- | --- | --- | -| `_resolve_reviewers(host, args)` | `resolve_participants(review_pool(host), host=host, …)` を呼び、`available` が 2 者に満たなければホストを `probe_auth` で確かめて `fallback` を決める。`AssignmentError` は `die(code=1)` へ写す | **新設**(`_auth_targets` / `_validate_only` を置き換える) | +| `_resolve_reviewers(host, args)` | `resolve_participants(review_pool(host), host=host, …)` を呼び、`available` が 2 者に満たなければホストを `probe_auth` で確かめて `fallback` を決める。`only` があるときはホストを確かめず `fallback` は空にする(決定 9: `--only` は埋め合わせをしない)。`AssignmentError` は `die(code=1)` へ写す | **新設**(`_auth_targets` / `_validate_only` を置き換える) | | `_round_reviewers(st, round_no)` | 決定 11 の順で返す | 順を変える | | `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | `apply_resume_args` を呼び、担当に関わる引数があれば `_resolve_reviewers` で作り直す。失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | | `_guard_previous_round(st, prev)` | `prev["reviewers"]`(無ければ `_round_reviewers`)を渡す | 担当を渡す | diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md index 59d81108..da07d72e 100644 --- a/issues/issue-727-687-478-664-648-design.md +++ b/issues/issue-727-687-478-664-648-design.md @@ -50,7 +50,9 @@ `check_auth` を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・ 確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 の `move_responsibility`)。**共通層に `resolve_participants` を 1 つ置き、母集合・ホスト・`--include` / `--exclude` / `--only`・確認・`--require-all` から `Participants` を返す。** -`--exclude` にホストがあれば弾く検査も、ホストを受け取るこの関数が持つ。Skill が持つのは、 +`--exclude` と `--only` の名前が母集合に含まれるかの検査も、ホストを受け取るこの関数が持つ(cross-review では +ホストが母集合に無いため、`--exclude <ホスト>` はここで弾かれる。cross-refactoring ではホストが母集合にあるため +外せる)。Skill が持つのは、 その値を状態ファイルへ書くことと、`AssignmentError` を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写す ことだけである。 @@ -134,7 +136,8 @@ cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-re | 1 | その 1 者とホスト。ホストが使えないか、その 1 者と同じなら同じランタイムの 2 つ目 | | 0 | ホストとその 2 つ目。ホストも使えなければ失敗 | -`--only` は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。使える者が 2 者のとき輪番で +`--only` は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。ホストの確認も行わない。 +使える者が 2 者のとき輪番で 1 者を外す案は、毎ラウンド 1 席になるため採らない。 ### 決定 10: 担当の単位を「席の名前」にし、形は `<ランタイム>` か `<ランタイム>-<2〜9>` とする @@ -248,7 +251,7 @@ stem を逆に解析して担当名へ戻す箇所は無い(「実測」)た | 要素 | 責務 | Pull Request | | --- | --- | --- | | 母集合の既定(`assignment.review_pool` / `refactor_pool`) | Skill ごとの出発点を返す。既定の参加者は表 `DEFAULT_REFACTOR_RUNTIMES` が持つ | P6 | -| 使える者の解決(`assignment.resolve_participants`) | 母集合・ホスト・足す・外す・`only`・確認・`require_all` から `Participants` を返す。名前の矛盾(`--exclude` にホストを含む)と欠けを `AssignmentError` で返す | P6 | +| 使える者の解決(`assignment.resolve_participants`) | 母集合・ホスト・足す・外す・`only`・確認・`require_all` から `Participants` を返す。名前の矛盾(`--exclude` / `--only` に母集合に無い名前。cross-review のホストはこれに当たる)と欠けを `AssignmentError` で返す | P6 | | 確認(`auth.probe_auth`) | 止めずに確かめ、担当ごとの結果を返す | P6 | | 席の埋め方(`assignment.review_seats`) | 使える者と埋め合わせから 2 席を返す(決定 9) | P6 | | 適用の輪番(`assignment.impl_assign`) | 参加者から実装担当 1 者を返す(決定 7) | P6(呼び手は P7) | @@ -354,7 +357,8 @@ graph TD 使える者の解決の順序: -1. `--include` と `--exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。`--exclude` にホストがあれば弾く +1. `--include` と `--exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。`--exclude` の各名前が母集合の既定 ∪ `include` に含まれなければ弾く + (cross-review のホストはこれに当たる) 2. 参加者 = 母集合の既定 ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) 3. `--only` があれば参加者に含まれ、`exclude` に無いことを確かめ、参加者を `[only]` にする 4. 参加者の確認を `probe_auth` で行う。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとし `probe_skipped` を真にする @@ -467,7 +471,7 @@ Skill に依らず確かめる。 | G4(#728 #647 #592 #553) | `apply.py:134` と `gate.py:128` の `impl, _ = assignment.assign(seq, state["host"])` を G1(P7)が `impl = assignment.impl_assign(seq, state["runtimes"])` へ変える(各 1 行)。担当の交代(利用上限で次の輪番へ替える)は G4 が持ち、替える相手は `state["runtimes"]` から選ぶ | G4 は `assign` を新しく呼ばない。G1 の P7 と G4 の実装が同じ行を触ったら、後からマージする側が `impl_assign` に揃える | | G5(#730 #583) | 起動スクリプト(`launch-reviewer.sh`)は G5 が投稿の経路を、G1 が席の受け口(先頭の `case` と `launch-cli.sh` へ渡す CLI 名)を触る | 競合は後からマージする側が解く | | #461 | 確認コマンドの差し替え口は `AUTH_PROBES` のまま。`probe_auth` の返り値の形(`ok` / `detail`)は変えずに、コマンドだけを替えられる | #461 が実測して替える | -| #736 | `CLAUDE.md` の「`--max-outer-rounds` の既定が 4」の行は、輪番が 3 ラウンドで 1 周する形に合わせて G1 が書き直す | #736 は書き直した行を見て閉じるかを棚卸で決める | +| #736 | `CLAUDE.md` の「`--max-outer-rounds` の既定が 4」の行は、輪番が参加者の数のラウンドで 1 周する形に合わせて G1 が書き直す | #736 は書き直した行を見て閉じるかを棚卸で決める | ## 既存の設計との対応 diff --git a/issues/issue-727-687-478-664-648-requirements.md b/issues/issue-727-687-478-664-648-requirements.md index 92436b45..d32a7f8d 100644 --- a/issues/issue-727-687-478-664-648-requirements.md +++ b/issues/issue-727-687-478-664-648-requirements.md @@ -13,7 +13,8 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け 使える者だけで回る。使えない者と理由は出力と状態ファイルに残る - cross-review は、使える者が 2 者に満たなくても、各ラウンドに 2 席を確保する。席の埋め方の規則は 1 つで、両 Skill が共有する共通層が持つ -- cross-refactoring の既定の参加者は codex / kiro / ホストの 3 者になり、agy は既定から外れる。 +- cross-refactoring の既定の参加者は codex / kiro とホスト(ホストが codex / kiro なら 2 者、それ以外なら 3 者) + になり、agy は既定から外れる。 外す・戻す手段は引数で持つ - 中断した収束ループを、引数で進め方を変えて再開できる。反映しなかった引数は出力で分かる。 この規則も共通層が 1 か所で持つ @@ -112,7 +113,8 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け `["codex", "kiro"]` になる。`--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ状態ファイルを作る - [ ] AC17: ホスト `claude` で `--include claude` を渡すと、`available` が 4 者になり、`start-round` が 2 席を返す - [ ] AC18: 使える者が `codex` の 1 者で、ホストの確認が通る。`init` は終了コード 0 で終わり、観点が減ることを 1 行出す。 - `participants.fallback` は `["claude"]`、`start-round` は `codex claude` を返す + `participants.fallback` は `["claude"]`、`start-round` は `codex claude` を返す。`--only codex` のときはホストを + 確かめず、`participants.fallback` は空で、`start-round` は `codex` だけを返す(確認コマンドの呼び出しは `codex` の 1 回) - [ ] AC19: 使える者が 0 者でホストの確認が通ると、`init` は終了コード 0 で終わり、`start-round` は `claude claude-2` を返す。ホストの確認も通らないと `init` は終了コード 1 で終わり、状態ファイルを作らない - [ ] AC20: 次の 3 つはいずれも終了コード 1 で終わり、状態ファイルを作らない。`--exclude claude`(ホスト)/ @@ -149,7 +151,8 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け - [ ] AC31: ホスト `claude` の新規の `init` で、状態ファイルの `runtimes` は `["claude", "codex", "kiro"]` になり、 `agy` の確認は行われない。状態ファイルに `impl_capable` は無く、標準出力に `IMPL_POOL=` の行は無い - [ ] AC32: ホスト `codex` では `runtimes` が `["codex", "kiro"]`、ホスト `agy` では `["codex", "agy", "kiro"]` になる -- [ ] AC33: `--include agy` で `runtimes` が 4 者に、`--exclude kiro` で 2 者になる +- [ ] AC33: `--include agy` で `runtimes` が 4 者に、`--exclude kiro` で 2 者になる。ホスト `claude` で `--exclude claude` を + 渡すと `runtimes` が `["codex", "kiro"]` になり、`init` は終了コード 0 で終わる(ホストは母集合に含まれるため外せる) - [ ] AC34: `impl_assign(r, ["claude", "codex", "kiro"])` をラウンド 1〜6 で呼ぶ。返る値は `codex` / `kiro` / `claude` / `codex` / `kiro` / `claude` である。`start-round` は `REVIEWERS` / `REVIEWERS_CSV` を出さない。ラウンドの記録に `reviewers` / `reviewer_models` が無い @@ -173,7 +176,8 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け ### 文書 -- [ ] AC42: `CLAUDE.md` の cross-refactoring の節が「codex / kiro / ホストの 3 者」と「適用担当は 3 ラウンドで 1 周」を +- [ ] AC42: `CLAUDE.md` の cross-refactoring の節が「codex / kiro とホスト(ホストが codex / kiro なら 2 者)」と + 「適用担当は参加者の数のラウンドで 1 周する」を 書く。「ホストを除く 3 者」「参加する 4 者」を含まない。cross-review の節が「codex / agy の両方」を含まない。 次の 3 つがいずれも 0 行を出す @@ -338,7 +342,7 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け > > **置き場所も決める。** 規則が決まっても、置き場所が決まらなければ次に使う人へ届かない。 > -> **cross-refactoring では、既定の担当から agy を外し、ホストのランタイムを輪番へ入れる**と決めた(利用者の指示)。提案も適用も codex / kiro / ホストの 3 者になる。 +> **cross-refactoring では、既定の担当から agy を外し、ホストのランタイムを輪番へ入れる**と決めた(利用者の指示)。提案も適用も codex / kiro とホスト(ホストが codex / kiro なら 2 者)になる。 ### #478 From 0f7f3c1d1797c56533e63fb960d4464fe1d67687 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 03:58:44 +0000 Subject: [PATCH 008/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=20PR=20#782?= =?UTF-8?q?=20=E3=81=AE=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=203=20?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AB=E5=AF=BE=E5=BF=9C=EF=BC=88=E5=86=8D?= =?UTF-8?q?=E9=96=8B=E3=81=A7=E6=B8=A1=E3=81=95=E3=81=AA=E3=81=8B=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E5=BC=95=E6=95=B0=E3=81=AF=E7=8A=B6=E6=85=8B=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=AE=E5=80=A4=E3=81=A7=E8=A3=9C?= =?UTF-8?q?=E3=81=86=E3=83=BB#687=20=E5=BC=95=E7=94=A8=E3=82=92=E5=8E=9F?= =?UTF-8?q?=E6=96=87=E3=81=B8=E6=88=BB=E3=81=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - design.md 決定 14: 一部の引数だけを渡した再開では、渡さなかった引数を状態ファイルの値で補って作り直す規則を足す - contracts.md: 「移行」の節と `_resume_from_state` / `cmd_init`(再開)の契約に同じ規則を足す - requirements.md AC28 / AC40: `--include` 済みの状態へ `--exclude` だけを渡す組み合わせを足す - design.md テスト設計: AC25〜AC29 の確かめ方に「一部の引数だけを渡す組み合わせを含む」を足す - requirements.md「依頼(原文)」#687 の引用を issue 本文どおりに戻す(設計の担い手の指示) Co-Authored-By: Claude Fable 5.1 --- issues/issue-727-687-478-664-648-contracts.md | 6 ++++-- issues/issue-727-687-478-664-648-design.md | 5 ++++- issues/issue-727-687-478-664-648-requirements.md | 8 +++++--- 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/issues/issue-727-687-478-664-648-contracts.md b/issues/issue-727-687-478-664-648-contracts.md index a96db676..928d8c03 100644 --- a/issues/issue-727-687-478-664-648-contracts.md +++ b/issues/issue-727-687-478-664-648-contracts.md @@ -104,6 +104,8 @@ erDiagram | 両方 | `resume_changes` | 空として読む | 再開で担当に関わる引数を渡したときだけ、`participants` を作り直して書く。渡さない再開では書き足さない。 +作り直すときの入力は、渡した引数と、渡さなかった引数の状態ファイルの値(`participants.included` / `excluded` / `require_all`、 +最上位の `only`)である。 ## 入出力の契約 @@ -244,7 +246,7 @@ cross-review)。 | --- | --- | --- | | `_resolve_reviewers(host, args)` | `resolve_participants(review_pool(host), host=host, …)` を呼び、`available` が 2 者に満たなければホストを `probe_auth` で確かめて `fallback` を決める。`only` があるときはホストを確かめず `fallback` は空にする(決定 9: `--only` は埋め合わせをしない)。`AssignmentError` は `die(code=1)` へ写す | **新設**(`_auth_targets` / `_validate_only` を置き換える) | | `_round_reviewers(st, round_no)` | 決定 11 の順で返す | 順を変える | -| `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | `apply_resume_args` を呼び、担当に関わる引数があれば `_resolve_reviewers` で作り直す。失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | +| `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | `apply_resume_args` を呼び、担当に関わる引数があれば、渡さなかった引数を状態ファイルの値で補って `_resolve_reviewers` で作り直す。失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | | `_guard_previous_round(st, prev)` | `prev["reviewers"]`(無ければ `_round_reviewers`)を渡す | 担当を渡す | ### `refactor_lib` の関数(cross-refactoring) @@ -252,7 +254,7 @@ cross-review)。 | 関数 | 契約 | 変更 | | --- | --- | --- | | `commands/setup.py cmd_init` | `resolve_participants(refactor_pool(host), …)` を呼び、`runtimes` と `participants` を書く。`AssignmentError` は `die`(終了コード 4) | 母集合と確認を置き換える | -| `commands/setup.py cmd_init`(再開) | `apply_resume_args` を呼び、担当に関わる引数があれば作り直す | 反映を足す | +| `commands/setup.py cmd_init`(再開) | `apply_resume_args` を呼び、担当に関わる引数があれば、渡さなかった引数を状態ファイルの値で補って作り直す | 反映を足す | | `commands/setup.py cmd_start_round` | `impl_assign(round_no, state["runtimes"])`。`reviewers` を書かない | 担当の決め方を替える | | `commands/apply.py` / `commands/gate.py` の `assign(seq, host)` | `impl_assign(seq, state["runtimes"])` | 呼び方を替える(各 1 行) | diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md index da07d72e..7c025fe4 100644 --- a/issues/issue-727-687-478-664-648-design.md +++ b/issues/issue-727-687-478-664-648-design.md @@ -187,6 +187,9 @@ stem を逆に解析して担当名へ戻す箇所は無い(「実測」)た ### 決定 14: 担当に関わる引数を渡した再開でだけ、確認し直して参加者を作り直す `--only` / `--exclude` / `--include` / `--require-all` のいずれかを渡した再開では、決定 2 と同じ手順で参加者を作り直す。 +**渡さなかった引数は状態ファイルの値で補う。** `--include claude` で始めた実行へ `--exclude agy` だけを渡した再開では、 +`included` は `["claude"]` のまま残り、`excluded` だけが `["agy"]` になる。渡さなかった引数を初期値へ戻すと、「明示した引数だけを +反映する」(決定 13)が破れる。 いずれも渡さない再開では確かめ直さない(途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる)。 作り直した結果が失敗(0 者、`--require-all` で欠け)なら、状態ファイルを書き換えずに終了コードで終わる。反映は再開の後に 開くラウンドから効く(要求の前提 3)。 @@ -436,7 +439,7 @@ start-round → REVIEWERS="codex claude-2" | AC22 | `participants` を持たない状態 / `host` も持たない状態で `_round_reviewers` | `cross-review/tests/test_state_review_pool.py` | | AC23 | `verdict` の無い前ラウンドで `cmd_start_round` の終了コード 5 | `cross-review/tests/test_state_round_guard.py` | | AC24 | `participants` と `resume_changes` を持つ状態ファイルで `cmd_report` の出力を見る | `cross-review/tests/test_state_review_pool.py` | -| AC25〜AC29 | 状態ファイルを置いた作業ツリーを渡して `cmd_init` を呼び、状態ファイルと標準エラーを見る | `cross-review/tests/test_state_resume_args.py`(新設) | +| AC25〜AC29 | 状態ファイルを置いた作業ツリーを渡して `cmd_init` を呼び、状態ファイルと標準エラーを見る。一部の引数だけを渡す組み合わせを含む | `cross-review/tests/test_state_resume_args.py`(新設) | | AC30 | 2 ファイルの `ONLY` を含む行を数える | `cross-review/tests/test_skill_layout.py` | | AC31〜AC33、AC35〜AC36 | `probe_auth` を差し替えて `cmd_init` を呼ぶ(既存の `test_init.py` の `check_auth` のテストを置き換える) | `cross-refactoring/tests/test_init.py` | | AC34 | `impl_assign` を 6 ラウンド呼ぶ。`cmd_start_round` の出力に `REVIEWERS` が無い(`test_start_round_emits_runtimes.py` の期待値を反転する) | `lib/test_lib_assignment.py` / `cross-refactoring/tests/test_start_round_emits_runtimes.py` | diff --git a/issues/issue-727-687-478-664-648-requirements.md b/issues/issue-727-687-478-664-648-requirements.md index d32a7f8d..bd2ebb9b 100644 --- a/issues/issue-727-687-478-664-648-requirements.md +++ b/issues/issue-727-687-478-664-648-requirements.md @@ -140,7 +140,9 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け - [ ] AC27: `only: null` の状態ファイルへ `--only codex` を渡すと `only` が `codex` になり、次の `start-round` が `codex` だけを返す。記録を持つ過去のラウンドの `reviewers` は変わらない。`--only none` は `only` を `null` へ戻す - [ ] AC28: `--exclude agy` を渡した再開では、参加者の確認をやり直し、`available` から `agy` が消え、次の - `start-round` が `agy` を返さない。`--exclude none` は除外を空へ戻す + `start-round` が `agy` を返さない。`--exclude none` は除外を空へ戻す。`participants.included` が `["claude"]` の状態へ + `--exclude agy` だけを渡すと、`included` は `["claude"]` のまま残り、`excluded` が `["agy"]` になる(渡さなかった引数は + 状態ファイルの値で補う) - [ ] AC29: `host: "claude"` の状態ファイルへ `--host codex` を渡すと `host` は変わらず、反映しないことが 1 行出る。 `--host claude` では何も出ない - [ ] AC30: `SKILL.md` と `docs/01-state-and-review.md` で `grep -n 'ONLY'` が当たる行は、`init` へ引数を渡す行と @@ -170,7 +172,7 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け 引数ごとに 1 行出る。状態に載る他の引数(`--baseline-test` など。契約文書の表)も同じ扱いである。引数を渡さない 再開では、上限 4 項目と `models` と `runtimes` が変わらない - [ ] AC40: 再開で `--exclude kiro` を渡すと参加者の確認をやり直す。`runtimes` から `kiro` が消え、次の `start-round` の - `RUNTIMES` に `kiro` が無い + `RUNTIMES` に `kiro` が無い。`--include agy` で始めた状態へ `--exclude kiro` だけを渡すと、`included` の `agy` は残る - [ ] AC41: `impl_capable` を持ち `participants` を持たない状態ファイル(この変更の前に始めた実行)を、`start-round` / `report` が読める。適用の輪番は `runtimes` から決まる @@ -342,7 +344,7 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け > > **置き場所も決める。** 規則が決まっても、置き場所が決まらなければ次に使う人へ届かない。 > -> **cross-refactoring では、既定の担当から agy を外し、ホストのランタイムを輪番へ入れる**と決めた(利用者の指示)。提案も適用も codex / kiro とホスト(ホストが codex / kiro なら 2 者)になる。 +> **cross-refactoring では、既定の担当から agy を外し、ホストのランタイムを輪番へ入れる**と決めた(利用者の指示)。提案も適用も codex / kiro / ホストの 3 者になる。 ### #478 From 90b783094a93bf553fd6de27c836032d019dd57f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 04:01:35 +0000 Subject: [PATCH 009/217] =?UTF-8?q?Docs:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=8C=E7=B5=90=E6=9E=9C?= =?UTF-8?q?=E3=81=AA=E3=81=97=E3=82=92=E5=80=A4=E3=81=A7=E5=8F=97=E3=81=91?= =?UTF-8?q?=E3=82=8B=E8=A8=AD=E8=A8=88=EF=BC=88#728=20#647=20#592=20#553?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 親 #728 の名前で要求と設計を新設し、既存の要求(PR #665)に置き換えの案内を足す。 gitfacts.read_result は G3(#729)の read_launch_outcome を包んで値を返し、3 つの取り込み (merge-apply / merge-fix / merge-final-fix)は intake.py の 1 つの手順で範囲の確定・ 未検証コミットの取り消し・結末の記録を行う。開き直しの判定は rounds.group_reopening に置く。 #674(最終ゲートの修正)を範囲に含める。 Refs #728 #647 #592 #553 #674 Co-Authored-By: Claude Fable 5.1 --- issues/issue-647-592-553-requirements.md | 2 + issues/issue-728-647-592-553-design.md | 500 +++++++++++++++++++ issues/issue-728-647-592-553-requirements.md | 273 ++++++++++ 3 files changed, 775 insertions(+) create mode 100644 issues/issue-728-647-592-553-design.md create mode 100644 issues/issue-728-647-592-553-requirements.md diff --git a/issues/issue-647-592-553-requirements.md b/issues/issue-647-592-553-requirements.md index 54270cdc..42ca0c45 100644 --- a/issues/issue-647-592-553-requirements.md +++ b/issues/issue-647-592-553-requirements.md @@ -2,6 +2,8 @@ 設計は [issue-647-592-553-design.md](issue-647-592-553-design.md) にある。この文書は「何を満たすか」だけを扱う。 +**この文書は置き換えられた。** 親 #728(取り込みが結果なしを値で受け、取り消しと群の状態を 1 か所で決める)の要求 [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md) と設計 [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md) が受け入れ条件と決定を持つ。対応は新しい文書の末尾の表にある。以下は 2026-09-15 時点の記録として残す。 + **3 つの課題は 1 本の Pull Request(P6)で直す。** #647 と #592 は入口が違うが、同じ `next-apply-round` → `merge-apply` の繰り返しが止まらない。#553 は同じ適用の検証で群を落とす。 マイルストーン 21 の実装順では P3(監視の結果の分類)の後に入る。 diff --git a/issues/issue-728-647-592-553-design.md b/issues/issue-728-647-592-553-design.md new file mode 100644 index 00000000..c3fd583b --- /dev/null +++ b/issues/issue-728-647-592-553-design.md @@ -0,0 +1,500 @@ +# #728 / #647 / #592 / #553: 取り込みが結果なしを値で受け、取り消しと群の状態を 1 か所で決める + +要求と受け入れ条件は [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md) にある。この文書は「どう作るか」だけを扱う。 + +**この文書は既存の設計 [issue-647-592-553-design.md](issue-647-592-553-design.md)(PR #665)を置き換える。** 引き継ぐ決定と変える決定は末尾の「既存の設計との対応」にある。G3(#729、PR #781)が決めた `read_result` の契約に従い、その先(3 つの取り込みが値をどう扱うか)をこの文書が決める。 + +**実装は 1 本の Pull Request にまとめる**(決定 2)。G3 の実装 Pull Request が `develop` に入った後に始める。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 担当が結果を残さなくても、3 つの取り込みが範囲を確め、未検証のコミットを取り消し、結末を記録して終わる | cross-refactoring を回す進行側 | +| F2 | 結果を残さない担当の群を、担当を替えて 1 回だけ開き直し、2 回目も残さなければ取り消す | 同上 | +| F3 | 採用 0 件の提案ラウンドと項目の無い群で、適用担当を起動せずに次へ進む | 同上 | +| F4 | 修正の結果を群の担当から読み、結果が無ければ修正ラウンドを 1 つ進める | 同上 | +| F5 | 最終ゲートの修正で結果が無ければ、作られたコミットを取り消して次の判定へ戻す | 同上(`--workflow-step` の実行) | +| F6 | 起動し直しても解けない結末では、同じ担当を同じ工程で起動し直さない | 同上 | +| F7 | 帰属行の段落が後ろに付いたコミットから必須トレーラーを読む | 同上(claude が適用担当の群) | +| F8 | 適用・修正の監視が、テストの実行中の無出力で担当を打ち切らない | 同上 | + +## 決定の記録 + +| 決定 | 扱うこと | +| --- | --- | +| 1〜2 | 文書の置き方と Pull Request の分け方 | +| 3〜5 | 結末の読み取りと共通の手順 | +| 6〜9 | 群の試行・開き直し・担当の交代 | +| 10〜11 | 修正ラウンドと最終ゲート | +| 12 | 採用 0 件と項目の無い群 | +| 13〜14 | トレーラー | +| 15 | 無進捗の打ち切り | + +### 決定 1: 設計文書は親 #728 の名前で新設し、既存の設計文書の本体は書き換えない + +既存の設計は 3 課題を 1 つの文書で扱い、設計 Pull Request の関門を通過している。親 #728 は決定 6(取り消しの本体を切り出して共有し、最終ゲートを #674 に残す)を改め、結果の読み取りの向きを変える。その節を書き換えると、通過済みの決定と新しい決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は変更したファイルの `## 決定の記録` の見出しをすべて写すため、1 行でも触ると既存の 13 件がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない既存の要求の文書にだけ足す(#729 / #727 の設計と同じ扱い)。 + +### 決定 2: 4 課題と #674 を 1 本の Pull Request で直す + +`read_result` の契約を変えると、呼び出し元 3 か所(`merge-apply` / `merge-fix` / `merge-final-fix`)を同時に書き直すことになる。取り消しの本体を 1 つにすることも 3 か所を同時に触る。課題ごとに分けると同じ関数を 2 度変える。#553 は同じ `gitfacts.py` と `merge-apply` の検証に閉じ、分けても触るファイルが重なる。 + +### 決定 3: `gitfacts.read_result` は名前を残し、引数を状態と工程に変えて `LaunchOutcome` を返す + +G3 の契約(決定 11)は「`read_launch_outcome` を呼び、値を返し、`die` しない」までで、薄い包みとして残すかは G4 に任せている。**包みとして残す。** 監視の `--stem-template` と食い違う stem を渡すと監視の結果ファイルを引けない(G3 の未確認 6)。stem を作る場所を `read_result(state, runtime, phase, round_no=None)` の 1 つにすれば、3 つの取り込みが同じ組み立てを通り、突き合わせのテスト(AC3)も 1 か所で書ける。名前を残すのは、親 #728 と子 issue の本文がこの名前で根本原因を指しているためである。引数を変えるため、古い形の呼び出し(`read_result(path, runtime)`)は実行時に失敗し、契約の変更を素通りしない。 + +呼び出し側が `read_launch_outcome` を直接呼ぶ形は採らない。stem の組み立てが 3 か所に分かれる。 + +### 決定 4: 「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を `intake.py` の 1 つの手順にする + +3 つの取り込みは、結果を読めたときも読めなかったときも、起点から HEAD までの範囲を確め、通らなければ範囲を取り消し、起点を取り消し後の HEAD へ進める。いまは取り消しの本体が 2 つにある(`gitfacts.revert_unverified_range` を修正と最終ゲートが、`apply._revert_unverified_apply_round` を適用が使う)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)だけである。**`refactor_lib/intake.py` を新設し、範囲の確定(`confirm_range`)・取り消し(`discard_unverified`)・結果なしの一連(`close_without_result`)を置く。** 起点の鍵と記録先の違いは `IntakeScope` の値で渡す。取り込みが持つのは、その値をどう終了コードと群の状態へ写すかだけになる(親 #728 の `consolidate_duplication`)。 + +置き場所を `commands/` にしないのは、`apply.py` / `converge.py` / `gate.py` の 3 つが読む層だからである(`rounds.py` と同じ理由。`commands` どうしの取り込みを作らない)。`gitfacts.py` に足す形も採らない。1158 行あり、取り消しは git の事実の読み取りではなく進行の手順である。 + +### 決定 5: 結果なしの記録は 3 つの取り込みで同じ形 `failed_attempts[]` にする + +群の `failed_attempts`、修正の `fix_merged_keys` の `":missing"`、最終ゲートの独自の記録と 3 通りに分けると、実行の要約と改修計画が 3 通りの読み方を持つ。**記録の辞書(群、または `final_gate`)の `failed_attempts[]` に `{phase, attempt, impl, reason, detail, at, reverted}` を足す。** `phase` が `apply` / `fix` / `final-fix` を分け、`attempt` が叩き直しの判定(同じ `phase` と `attempt` の記録があれば読まずに 2 を返す)に使う。修正の `fix_merged_keys` は結果を読めたときの二重取り込みの判定に残し、結果なしの判定には使わない。 + +### 決定 6: 同じ群の試行の上限は 2 回の固定値にし、引数を足さない + +2 回目は別の担当が試すため(決定 8)、2 回とも結果を残さなければ担当ではなく群の側を疑える。3 回以上にしても壊れた担当に当たる確率が上がるだけである。値は `vocabulary.py` の `MAX_APPLY_ATTEMPTS` に置く。`--max-apply-attempts` を足す形は採らない。`SKILL.md` は上限を 2 つ置くとどちらで止まったかを読み解く必要が出るとしており、止まった理由は群の記録(`drop_reason` と `failed_attempts`)が持つ。 + +### 決定 7: 開き直しの判定は `rounds.group_reopening` の 1 つに置き、`next-apply-round` と `merge-apply` の両方がそれを読む + +いまの `next-apply-round` は `pending` / `applied` の群を無条件に開き直し、中断からの再開と失敗した試行のやり直しを区別しない。判定に要る値は群が持つ 2 つ、開いた回数(`attempt`)と結末の記録(`failed_attempts` の `phase: apply` の件数)である。**`group_reopening(group)` がこの 2 つから `open`(開いて `attempt` を進める)/ `resume`(開いたまま閉じていない試行を再開する。番号を進めない)/ `exhausted`(上限に達した。開かない)/ `empty`(項目が無い)を返す。** `next-apply-round` は開くかどうかを、`merge-apply` は結果なしを記録した後に担当を替えるか取り消すかを、同じ関数の値で決める。 + +開いた回数だけを数える形は採らない。進行側が落ちて再開しただけで試行が進む。監視の終了コードで骨組みが分岐する形も採らない。結果ファイルが後から書かれた場合や、監視は `OK` でも JSON が壊れている場合は結果ファイルの側で決めるしかなく、判定を 1 か所に置けば骨組みは `|| continue` のまま変わらない。 + +### 決定 8: 2 回目の試行は次の輪番の担当が行い、替える先が無いときだけ結末の可否で決める + +結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の STALLED 4 回、claude の 429 の 3729 回、PR #757 の kiro)。同じ担当で開き直しても直らない。替える先は、`apply_seq` を 1 ずつ進めて `rounds.impl_for_seq` を引き、その群で失敗した担当のどれとも違う担当が出た最初の番号の担当である。1 つ進めるだけにしないのは、輪番が 1 周すると同じ担当へ戻るためである(既存の設計の「実測」: `assign(1)` と `assign(5)` はどちらも codex)。 + +**替える先が無いとき**(参加者が 1 者。G1 の `--exclude` で残りが 1 者になる実行)は、`LaunchOutcome.relaunch_same_agent` を読む。真(`missing` / `stalled` など)なら同じ担当で 2 回目を開き、偽(`usage_limit`)なら 1 回目で群を取り消す。可否を読むのはこの分岐だけで、替える先があるときは常に替える。利用上限で進行全体を止める形は採らない。他の担当で進められる群まで止まる。 + +### 決定 9: 作業を任せる担当の決定は `rounds.impl_for_seq` の 1 つを通す + +`rounds.impl_for_seq(state, seq)` を新設し、輪番の通し番号から担当と要求モデルを引く呼び出しはその中だけにする。呼ぶのは 3 か所で、群の担当(`apply._assign_apply_rounds_to_state`)・交代先(`apply._close_failed_attempt`)・最終ゲートの修正担当(`gate._final_fix_impl`)を決める。G1(#727)は `assignment.assign` を `impl_assign(participants, seq)` に置き換える。**どちらが先に入っても、変える場所は `impl_for_seq` の中だけである。** `setup.cmd_start_round` の担当は CLI を起動しないため通さない。 + +### 決定 10: 修正の結果は群の担当から読み、結果なしは修正ラウンドを 1 つ進める。起動し直せない結末では上限へ進める + +`merge-fix` は提案ラウンドの担当(`entry["impl"]`)の結果を読むが、骨組みが起動するのは群の担当である。一致しない群では結果を一度も取り込めず、`fix_rounds` が進まないまま検証と修正を往復する(既存の設計の「実測」)。担当は `current_group(entry)["impl"]` から読む。結果なしは `close_without_result` を通し、`fix_rounds` を 1 進めて 2 で終わる。範囲を確定できないときの既存の扱い(`_resolve_fix_range`)と同じ形である。 + +**`relaunch_same_agent` が偽なら、`fix_rounds` を `--max-fix-rounds` の値にする。** 修正の担当は替えない(直しかけの文脈を持つ者が続ける、という最終ゲートの既存の理由と同じ)。替えないまま上限まで起動し直すと、利用上限の担当を最大 3 回起動して 3 回とも 15 秒で落ちる。上限へ進めれば `should-abandon` が次の呼び出しで見送りへ移し、骨組みの行は変わらない。 + +### 決定 11: 最終ゲートの修正も同じ手順を通し、結果なしは 2 で終えて `final-gate` に判定を戻す + +`merge-final-fix` が結果なしで `die` すると、担当が作ったコミットが範囲の検査も取り消しも受けずに残る。次の `final-gate` はそのコミットを含む HEAD でテストし、落ちれば起点を HEAD へ置き直す(#674)。**`close_without_result` を通せば、コミットは取り消され、起点は取り消し後の HEAD になり、次の `final-gate` は修正前の地点でテストする。** 終了コードは 2 で、骨組みは見ずに `final-gate` へ戻る(変えない)。`final-gate` が `fix_rounds` を進めるため、繰り返しは `--max-fix-rounds` で止まる。 + +`relaunch_same_agent` が偽なら `gate.fix_rounds` を `--max-fix-rounds` の値にする。次の `final-gate` はテストが落ちれば 1(取り消さず報告)で終わる。既存の「Step 7 は push 済みの地点。上限に達しても採用した改善項目は取り消さない」の規則を変えない。 + +### 決定 12: 採用 0 件の提案ラウンドでは群を作らず、項目の無い群は開かずに取り消す + +`rounds.apply_groups` は `if groups:` で分岐するため、鍵が無い(`None`)ときも空の配列(`[]`)のときも 1 つの群を作る。`merge-proposals` は採用 0 件で `apply_rounds = []` を書くため、ここで項目 0 件の群が生まれる。**鍵が無いときだけ古い版として群を作り、空の配列はそのまま返す。** 既に項目の無い群を持つ状態ファイル(rf587)は残る。`group_reopening` が `empty` を返した群は、`next-apply-round` が `dropped`(`drop_reason: empty`)にして次を探す。`merge-apply` の取り込み済みの判定で採用 0 件だった群も `dropped` に直す。 + +`merge-proposals` がテスト整備の採用 0 件で 2 を返す形は採らない。2 は構造改善の繰り返しを終える合図で、テスト整備では構造改善へ進む前に抜けてしまう。 + +### 決定 13: トレーラーは末尾から続くトレーラーの段落を git の判定で読み、進行側はコミットを書き換えない + +`commit_trailers` はメッセージを空行で段落に分け、末尾の段落から前へ向かって 1 段落ずつ `git interpret-trailers --parse` に掛け、git がトレーラーの段落と判定しなかった段落で止める。同じ鍵は末尾に近い段落の値を採る。**1 段落目(題名)は掛けない**(掛けると `Refactor: …` の題名をトレーラーとして読む。既存の設計の「実測」)。 + +#553 の本文は、帰属のトレーラーを付ける責務を進行側の取り込みへ移す(`git commit --amend`)ことを修正レイヤーとしている。**採らない。** 3 つの理由がある。SHA が変わり、結果ファイルの `commits[].sha` の申告と実体の対応が切れる。群に複数の項目があると先頭のコミットを書き換えた時点で以降のすべてが書き換わり、範囲の検査の前提(申告の SHA が範囲に実在する)が崩れる。`Impl-Model`(実際に使ったモデル名)は担当しか知らず、進行側が付けるには結果ファイルから受け取ることになる。段落ごとの読み取りは、トレーラーの形で書かれた署名なら誰が何行足しても同じに読む。トレーラーの形でない散文を末尾に足すランタイムが現れたときだけ効かず、それは「未確認のまま残ること」に置く。 + +全文から `^: ` を拾う形も採らない。散文の段落にある `Round: …` の形の行を拾う。 + +### 決定 14: 雛形のコミットの規約にも、必須トレーラーを最後の段落に置くことを書く + +進行側の検証は決定 13 で通る。一方、人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計すると最後の段落しか読まない。帰属行を同じ段落に続けて書けば、git の標準の読み方でも取れる。従わなくても決定 13 で検証は通る。 + +### 決定 15: 無進捗の許容を `--test-timeout` + 900 秒にし、雛形に進捗マーカーを足す + +既存の設計の決定 13 をそのまま引き継ぐ。適用・修正の担当はテストを 1 回実行し、その間は何も出力しない。`init` が `IMPL_STALL_TIMEOUT` を出し、骨組みの適用・修正・最終ゲートの修正の監視が `--stall-timeout` に渡す。`--timeout` は渡さない(上限は P2 の `--phase` と `lib/limits.py` が決める)。`monitor.py` は変えない。 + +## 実測 + +2026-09-19 に `develop`(9eaebe14)で読み取った現状である(コードを実行して確かめた値は既存の設計の「実測」にあり、変えていない)。 + +| 見たもの | 値 | +| --- | --- | +| `read_result` の呼び出し元 | 3 か所(`commands/apply.py:452` / `commands/converge.py:478` / `commands/gate.py:165`)。いずれも `die(code=2)` を内包する | +| 取り消しの本体 | 2 つ(`gitfacts.revert_unverified_range`(`converge.py:383` / `gate.py:203` が呼ぶ)と `apply._revert_unverified_apply_round`)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)と `entry["apply"]` の記録 | +| `merge-final-fix` の順序 | `read_result`(:165)→ `commits_in_range`(:167)→ `unassigned_fix_commits` / `verify_final_fix_commit`(:178-189)→ `revert_unverified_range`(:199-206)。結果なしでは 2 つ目以降へ進まない | +| `rounds.apply_groups` | `if groups:`(`rounds.py:103`)だけで分岐し、`None` と `[]` を区別しない | +| `stem_for` / `result_path` | `refactor_lib/paths.py:94` / `:85`。`result_path` は `tmp_dir / f"{stem}-result.json"` で、G3 の `read_launch_outcome` の既定と同じ | +| 既存の設計が名付けた関数 | `MAX_APPLY_ATTEMPTS` / `impl_for_seq` / `load_result` / `_close_failed_attempt` / `_monitor_reason` はいずれも未実装(PR #665 は文書だけ) | + +## 構成要素 + +| 要素 | 新設 / 変更 | 責務 | +| --- | --- | --- | +| 結末の読み取り(`gitfacts.read_result`) | 変更 | 状態と工程から stem を組み、`read_launch_outcome` を呼んで `LaunchOutcome` を返す(決定 3) | +| 取り込みの共通手順(`intake.py`) | 新設 | 範囲の確定・未検証のコミットの取り消し・結末の記録(決定 4・5) | +| 群の進行(`rounds.py`) | 変更 | `apply_groups` の空配列の扱い、`group_reopening`、`impl_for_seq`(決定 7・9・12) | +| 適用の取り込み(`commands/apply.py`) | 変更 | `next-apply-round` が `group_reopening` で開く。`merge-apply` が結果なしを `_close_failed_attempt` へ渡し、担当の交代か取り消しを行う(決定 6〜9) | +| 修正の取り込み(`commands/converge.py`) | 変更 | 群の担当の結果を読む。結果なしは共通の手順を通して修正ラウンドを進める(決定 10) | +| 最終ゲート(`commands/gate.py`) | 変更 | `merge-final-fix` が共通の手順を通す。`_final_fix_impl` が `impl_for_seq` を引く(決定 9・11) | +| 語彙(`vocabulary.py`) | 変更 | `MAX_APPLY_ATTEMPTS = 2`、`IMPL_STALL_MARGIN = 900` | +| 起動(`commands/setup.py`) | 変更 | `_emit_init` が `IMPL_STALL_TIMEOUT` を出す(決定 15) | +| トレーラーの読み取り(`gitfacts.commit_trailers`) | 変更 | 末尾から続くトレーラーの段落を読む(決定 13) | +| 雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`) | 変更 | 必須トレーラーを最後の段落に置く。進捗マーカー(決定 14・15) | +| 手順書(`SKILL.md` / `docs/02` / `docs/04`) | 変更 | 語の表、「別の上限を置かない」の削除、骨組みの監視の引数、結果なしのときの振る舞い | +| 共通層(`lib/monitor_outcome.py`) | 変えない | G3 が実装する `read_launch_outcome` を読む | + +```mermaid +graph TD + subgraph 取り込み + A[merge-apply] + F[merge-fix] + G[merge-final-fix] + end + subgraph 共通の手順 + R[gitfacts.read_result] + I[intake
confirm_range / discard_unverified
close_without_result] + end + subgraph 群の進行 + N[next-apply-round] + RO[rounds.group_reopening] + IS[rounds.impl_for_seq] + end + MO[lib/monitor_outcome
read_launch_outcome] + A --> R + F --> R + G --> R + R --> MO + A --> I + F --> I + G --> I + A --> RO + N --> RO + A --> IS + G --> IS +``` + +**図に含めない要素**は、語彙・起動の出力・トレーラーの読み取り・雛形・手順書である。呼び出しの辺を持たない値と文書か、`merge-apply` の検証が呼ぶ 1 本(`commit_trailers`)で、図の主題(結末の扱いの統合)ではない。 + +### 文脈と配置 + +```mermaid +graph LR + 利用者 --> ホスト[ホストの CLI セッション] + ホスト -->|骨組みの bash| RF[refactor.py] + ホスト -->|launch-cli.sh / monitor.py| 担当[担当の CLI と監視] + RF --> TMP[一時ディレクトリ
結果ファイル・監視の結果ファイル] + 担当 --> TMP + RF --> WORK[作業ツリーの git] +``` + +**配置は変えない。** すべて利用者の機械のホストのセッションから起動するプロセスで、常駐しない。この変更で増える辺は `refactor.py` が共通層 `monitor_outcome` を通して監視の結果ファイルを読む 1 本だけである。 + +### 置き場所 + +```text +plugins/ndf/skills/cross-refactoring/ +├── SKILL.md # 語の表・段落の削除・骨組み +├── docs/02-apply-and-review.md # Step 4 の結果なし・開き直し・トレーラー +├── docs/04-fix-and-report.md # Step 6 / Step 7 の結果なし・監視の引数 +├── prompts/{apply,fix,final-fix}.md # トレーラーの段落・進捗マーカー +├── scripts/refactor_lib/ +│ ├── intake.py # 新設: IntakeScope / confirm_range / discard_unverified / close_without_result +│ ├── rounds.py # apply_groups / group_reopening / impl_for_seq +│ ├── gitfacts.py # read_result(契約の置き換え)/ commit_trailers / revert_unverified_range の削除 +│ ├── vocabulary.py # MAX_APPLY_ATTEMPTS / IMPL_STALL_MARGIN +│ └── commands/{apply,converge,gate,setup}.py +└── tests/ # 新設 3 本(test_intake / test_apply_attempts / test_commit_trailers_git)と既存 6 本の変更。対応は「テスト設計」 +# dev.kiro / dev.agy の配布物は bash scripts/build-runtime-plugins.sh で同期する +``` + +## 構造 + +変更が触る型だけを載せる。`LaunchOutcome` は G3 が作る型で名前と欄だけを置く。`intake` の関数は「入出力の契約」にある。 + +```mermaid +classDiagram + class LaunchOutcome { + payload: Optional~dict~ + reason: Optional~str~ + detail: str + relaunch_same_agent: bool + } + class IntakeScope { + holder: dict + base_key: str + records: dict + phase: str + attempt: int + impl: str + label: str + mirror: Optional~dict~ + } + class ClosedAttempt { + reason: str + detail: str + reverted: int + relaunch_same_agent: bool + range_unknown: bool + } + class intake + intake ..> IntakeScope : 受け取る + intake ..> LaunchOutcome : 読む + intake ..> ClosedAttempt : 返す +``` + +| 触る型 | 責務 | +| --- | --- | +| `IntakeScope` | 取り込み 1 つ分の「どこを見て、どこへ書くか」。`holder` は `pending_push` と起点を持つ辞書(`rounds[]` の要素か `final_gate`)、`base_key` は起点の鍵(`apply_base_sha` / `fix_base_sha`)、`records` は `failed_attempts` を持つ辞書(群か `final_gate`)、`mirror` は起点を同じ値に揃える辞書(群の `base_sha`。修正と最終ゲートは `None`) | +| `ClosedAttempt` | 結果なしを閉じた結果。`range_unknown` が真なら取り消しも記録も行っていない(呼び出し側が中断の終了コードを決める) | + +## データ構造 + +状態ファイル(`cross-refactoring-rf<番号>-state.json`)に鍵を足す。**版は上げない。** 鍵が無い状態ファイルは、試行 0・失敗なしとして読む。 + +### `rounds[].apply_rounds[]`(群) + +| 項目 | 型 | 値 | 空のときの意味 | +| --- | --- | --- | --- | +| `attempt` | 整数 | いま開いている試行の番号。1 から | 鍵なし = 0(まだ開いていない)。`merge-apply` は 0 を 1 回目として記録し、値を 1 に書く(変更前の版で開いた群を再開したとき) | +| `failed_attempts` | 配列 | 結果を残さなかった起動。1 件 = 下の「結末の記録」 | 鍵なし = 失敗なし | +| `drop_reason` | 文字列 | `no_result`(試行の上限、または起動し直せない結末で交代先なし)/ `empty`(項目なし) | 鍵なし = 既存の経路で取り消した、または取り消していない | +| `impl` / `impl_model` | 既存 | 担当を替えたときに書き換える。**前の担当は `failed_attempts[].impl` に残る** | — | + +### `final_gate` + +| 項目 | 型 | 値 | 空のときの意味 | +| --- | --- | --- | --- | +| `failed_attempts` | 配列 | 結果を残さなかった最終ゲートの修正の起動 | 鍵なし = 失敗なし | + +### 結末の記録(`failed_attempts[]` の 1 件) + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `phase` | 文字列 | 許さない | `apply` / `fix` / `final-fix`。同じ群の適用と修正の記録を分ける | +| `attempt` | 整数 | 許さない | 群の `attempt`(適用)/ `fix_attempts`(修正)/ `fix_rounds`(最終ゲート)。叩き直しの判定の鍵 | +| `impl` | 文字列 | 許さない | 起動した担当 | +| `reason` | 文字列 | 許さない | `LaunchOutcome.reason`(`missing` / `unparsable` / `stalled` / `timeout` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad`) | +| `detail` | 文字列 | 許す(空文字) | 監視の `detail`。監視の結果ファイルが無ければ空文字 | +| `at` | 文字列 | 許さない | 記録した時刻 | +| `reverted` | 整数 | 許さない | その起動の範囲から取り消したコミットの数。0 = コミットなし | + +**記録は追記だけで、上書きしない。** 群の `impl` は替えると上書きされるが、どの担当がどの試行で失敗したかは記録から読める。見送りの理由(`deferred_items[].defer_reason`)は `実装担当が結果を残しませんでした(agy: stalled → codex: missing)` の形にし、改修計画の「見送った項目」の表にそのまま出る。 + +### 機能とデータの対応 + +| 機能 | 群の `attempt` / `impl` / `status` | 群の `failed_attempts` | `final_gate.failed_attempts` | `deferred_items` | +| --- | --- | --- | --- | --- | +| F2 適用の結果なし | U | C | — | C(上限) | +| F3 項目なし | U(`dropped`) | — | — | — | +| F4 修正の結果なし | — | C | — | — | +| F5 最終ゲートの結果なし | — | — | C | — | + +### 群の状態の遷移 + +```mermaid +stateDiagram-v2 + [*] --> pending: merge-proposals + pending --> dropped: next-apply-round(empty) + pending --> pending: merge-apply(結果なし・open)
担当を替える + pending --> dropped: merge-apply(結果なし・exhausted) + pending --> dropped: merge-apply(未割当・検証の失敗) + pending --> applied: merge-apply(取り込んだ) + applied --> dropped: merge-apply(取り込み済み・採用 0 件) + applied --> verified: verify-round(通った) + applied --> dropped: abandon-items / merge-test-judgements + verified --> [*] + dropped --> [*] +``` + +**`pending` のまま同じ担当で無条件に開き直す遷移は無い。** 同じ担当で開き直すのは、交代先が無く `relaunch_same_agent` が真のとき 1 回だけである(決定 8)。 + +## 入出力の契約 + +### `gitfacts.read_result`(変更) + +| 項目 | 内容 | +| --- | --- | +| 名前 | `read_result(state, runtime, phase, round_no=None) -> LaunchOutcome` | +| 入力 | `state`: 状態ファイルの辞書(`tmp_dir` と `id` を読む)。`runtime`: 担当。`phase`: `apply` / `fix` / `final-fix`。`round_no`: 提案ラウンド(`final-fix` は省く) | +| 出力 | `read_launch_outcome(state["tmp_dir"], stem_for(runtime, phase, state["id"], round_no))` の値をそのまま | +| 失敗の形 | **失敗しない。** `die` せず、標準出力・標準エラーに書かない | +| 互換性 | 引数が変わる。呼び出し元 3 か所と `test_git_facts.py` の 3 件を書き直す | + +### `intake`(新設) + +| 名前 | 入力 | 出力 | 失敗の形 | +| --- | --- | --- | --- | +| `confirm_range(state, scope)` | `scope.holder[scope.base_key]` と HEAD | 新しい順のコミットの列。確定できなければ `None` | 失敗しない | +| `discard_unverified(path, state, scope, ordered_range)` | 取り消す範囲 | 取り消した数。`holder["pending_push"]` を立てて保存 → 新しい順に取り消す → `holder[base_key]`(と `mirror["base_sha"]`)を HEAD にして保存 | 取り消しに失敗したら既存の `_revert_range` が着手前へ戻して 4 で中断する | +| `already_closed(scope)` | `scope.records["failed_attempts"]` | 同じ `phase` と `attempt` の記録があれば真 | 失敗しない | +| `close_without_result(path, state, scope, outcome)` | `LaunchOutcome` | `ClosedAttempt`。範囲が `None` なら `range_unknown=True` で即返す。範囲にコミットがあれば `discard_unverified`。`records["failed_attempts"]` に 1 件足して保存。取り消したときだけ `push_with_retry_marker(holder)` | 取り消しの失敗は上と同じ | + +### `rounds`(変更・新設) + +| 名前 | 契約 | +| --- | --- | +| `apply_groups(entry)` | `apply_rounds` の鍵が無いときだけ古い版として群を 1 つ作る。空の配列はそのまま返す | +| `current_group(entry)` | 群が 1 つも無いときは 4 で中断する | +| `group_reopening(group) -> str` | `items` が空なら `empty`。`phase: apply` の失敗の件数を n として、n ≥ `MAX_APPLY_ATTEMPTS` なら `exhausted`、`attempt` > n なら `resume`、それ以外(`attempt` == n)は `open` | +| `impl_for_seq(state, seq) -> tuple[str, Optional[str]]` | 輪番の通し番号から担当と要求モデル。中身は G1 の先後で `assignment.assign(seq, host)` か `impl_assign(participants, seq)` を包む | + +### コマンドの終了コード + +| コマンド | 0 | 1 | 2 | 4 | +| --- | --- | --- | --- | --- | +| `next-apply-round` | 群を開いた | 残りの群が無い(群が無いラウンド・すべて `dropped` を含む) | — | — | +| `merge-apply` | 取り込んだ | — | **この群を取り消した、または担当を替えて開き直す**(意味を広げる) | 着手前テストが `green` でない・範囲を確定できない(2 から変更)・群が無い(新) | +| `merge-fix` | 取り込んだ | — | 範囲を確定できない・**結果なし(新。修正ラウンドは進む)** | 群が無い(新) | +| `merge-final-fix` | 取り込んだ | — | 範囲を確定できない・**結果なし(新。取り消して起点を戻す)** | 修正担当が未記録(変更なし) | + +骨組みは `merge-apply` の 2 で `continue` し、`merge-fix` と `merge-final-fix` の終了コードを見ない。**どちらも変えない。** + +### `init` の出力と骨組み + +`IMPL_STALL_TIMEOUT=` を足す。骨組みの差分は、`--phase apply` / `fix` / `final-fix` の 3 つの `monitor.py` の呼び出しに `--stall-timeout "$IMPL_STALL_TIMEOUT"` を 1 行ずつ足すことだけである。`merge-apply` の注記は「終了コード 2 = この群を取り消した、または担当を替えて開き直す」に改める。 + +### 雛形に足す文 + +| 雛形 | 足す文 | +| --- | --- | +| `apply.md` / `fix.md` のコミットの規約 | 4 つのトレーラーは**メッセージの最後の段落**に置く。実行環境が帰属行を足すときは、空行を挟まず同じ段落に続ける | +| `apply.md` / `fix.md` / `final-fix.md` | 作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記する(`start` / `edit` / `test` / `commit` / `done` と対象だけ) | + +## 処理の流れ + +### `next-apply-round` + +```mermaid +graph TD + S[status が pending / applied の群を順に見る] --> K{status} + K -->|無い| E1[終了コード 1] + K -->|applied| O[開き直す
起点・attempt を動かさない] + K -->|pending| R{group_reopening} + R -->|empty| D[dropped / empty] --> S + R -->|exhausted| D + R -->|open| INC[attempt を 1 進め
起点を HEAD にする] + R -->|resume| OUT[APPLY_ROUND / IMPL を出す] + INC --> OUT + O --> OUT +``` + +### `merge-apply` + +```mermaid +graph TD + B[置き土産を捨てる / 取り消しと push の再開] --> G{取り込み済みか} + G -->|採用あり| R0[終了コード 0] + G -->|採用 0 件| DR[dropped / empty] --> R2[終了コード 2] + G -->|未取り込み| BL{着手前テストが green} + BL -->|いいえ| A4[終了コード 4] + BL -->|はい| AC{already_closed} + AC -->|はい| R2 + AC -->|いいえ| RD[read_result] + RD -->|payload あり| RG{confirm_range} + RG -->|None| A4 + RG -->|範囲| V[既存の検証と取り込み] + RD -->|結果なし| CF[_close_failed_attempt] + CF -->|range_unknown| A4 + CF --> R2 +``` + +`_close_failed_attempt` は次の順で行う。 + +1. `close_without_result` を呼ぶ(範囲の確定 → 取り消し → `failed_attempts` へ `{phase: apply, attempt, impl, reason, detail, at, reverted}`。`attempt` が 0 なら 1 として記録し、群の `attempt` を 1 にする)。`range_unknown` なら項目を `blocked` にして 4 で中断する +2. `group_reopening(group)` を読む。`open` なら `apply_seq` を 1 ずつ進めて `impl_for_seq` を引き、`failed_attempts[].impl` のどれとも違う担当が出たらその担当と要求モデルで `impl` / `impl_model` を書き換える。4 回進めても出なければ、`relaunch_same_agent` が真なら担当を替えずに終え、偽なら手順 3 と同じく取り消す +3. `exhausted` なら群の項目を `abandoned` にして `deferred_items` へ入れる。群は `dropped`(`drop_reason: no_result`)にし、`apply.merged_at` を立て、局面を `phase_after_group` にする +4. 保存して 2 で終わる(push は手順 1 が取り消したときに済ませている) + +### `merge-fix` と `merge-final-fix` + +```mermaid +graph TD + S[置き土産を捨てる / push の再開] --> AC{already_closed} + AC -->|はい| R2[終了コード 2] + AC -->|いいえ| RD[read_result] + RD -->|payload あり| EX[既存の範囲の確定・検証・取り込み] + RD -->|結果なし| CW[close_without_result] + CW -->|range_unknown| RU[既存の扱い
fix: fix_rounds を進めて 2 / final-fix: 2] + CW --> ADV{relaunch_same_agent} + ADV -->|真| P1[fix_rounds を 1 進める] + ADV -->|偽| PM[fix_rounds を max_fix_rounds にする] + P1 --> R2 + PM --> R2 +``` + +3 つの取り込みが渡す `IntakeScope` の値は次のとおりである。結果を読めたときの検証の失敗(`_revert_unverified_apply_round` / `_revert_invalid_fix_round` / 最終ゲートの `unassigned or problems`)も `discard_unverified` を呼ぶ。 + +| 取り込み | `holder` | `base_key` | `records` | `phase` | `attempt` | `impl` | `mirror` | +| --- | --- | --- | --- | --- | --- | --- | --- | +| `merge-apply` | `entry` | `apply_base_sha` | 群 | `apply` | 群の `attempt` | 群の `impl` | 群 | +| `merge-fix` | `entry` | `fix_base_sha` | 群 | `fix` | `entry.fix_attempts` | 群の `impl` | なし | +| `merge-final-fix` | `final_gate` | `fix_base_sha` | `final_gate` | `final-fix` | `final_gate.fix_rounds` | `final_gate.impl` | なし | + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 適用担当の起動が群の数 × 2 回、修正担当と最終ゲートの修正担当の起動が `--max-fix-rounds` 回を超えない | `group_reopening` の上限(決定 6・7)、結果なしでも `fix_rounds` を進めること(決定 10・11)、起動し直せない結末で上限へ進めること | AC11(開く回数)、AC24・AC26(`should-abandon` が 0)、AC30 | +| 運用・保守性 | 取り消した理由が改修計画から読め、3 つの取り込みの記録が同じ形 | `defer_reason` に担当と理由を並べる。記録は `failed_attempts[]` の 1 形式(決定 5) | AC5、AC10 | + +## テスト設計 + +実行は `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q`。状態の遷移は関数を直接呼ぶ既存の形(`no_git` / `patch_lib` / `git_facts`)、トレーラーは一時リポジトリで実際に git を実行する。`read_launch_outcome` は差し替えず、一時ディレクトリに結果ファイルと監視の結果ファイルを置いて本物を通す。 + +| 受け入れ条件 | 何で確かめるか | 置き場所 | +| --- | --- | --- | +| AC1、AC2 | 一時ディレクトリに結果ファイルなし / 監視の結果ファイル(`stalled` / `usage_limit`)を置き、`read_result` の 5 つの欄。`capsys` で出力が空 | `test_intake.py` | +| AC3 | `stem_for` の 3 工程の値と、`SKILL.md` の `--stem-template` を担当名で埋めた値の一致 | `test_skill_terms.py` | +| AC4〜AC7 | `IntakeScope` を 3 つの取り込みの形で作り、`commits_in_range` が 1 件 / 0 件を返す状態で `close_without_result`。`no_git` の記録に `git revert` / `git push` が出るか、記録の件数、2 度目の呼び出しで件数が変わらないこと | `test_intake.py` | +| AC8 | `commits_in_range` が `None` を返す状態で 3 つの取り込みを呼び、`SystemExit` の値 | `test_intake.py` | +| AC9〜AC13、AC15、AC17 | 群 2 つ / 4 つの状態で結果ファイルを置かずに(AC12 は壊れた JSON と配列で)`next-apply-round` → `merge-apply`。`status` / `impl` / `attempt` / `failed_attempts` / `apply_seq` / `deferred_items` | `test_apply_attempts.py` | +| AC14 | `impl_for_seq` を常に同じ担当を返す関数に差し替え、監視の結果ファイルを `usage_limit` / `missing` で置く | `test_apply_attempts.py` | +| AC16 | 着手前テスト `red` で `SystemExit(4)`、結果ファイルを読まない(読み取りを差し替えて呼ばれないこと) | `test_apply_attempts.py` | +| AC18 | `group_reopening` を差し替え、`next-apply-round` と `merge-apply` の分岐が変わる | `test_apply_attempts.py` | +| AC19〜AC22 | 採用 0 件から `merge-proposals` → `next-apply-round`。鍵なし・rf587 の形・`items: []` の `pending` | `test_apply_rounds.py` | +| AC23〜AC27 | 群の担当と提案ラウンドの担当を分けた状態で `merge-fix`。AC26 は監視の結果ファイルを `usage_limit` で置き `should-abandon` まで | `test_abandon_items.py` | +| AC28〜AC31 | `final_gate` に `fix_base_sha` と `impl` を置き、結果ファイルなしで `merge-final-fix` → `final-gate`。`fix_commits` / `fix_base_sha` / `fix_rounds` / 終了コード | `test_final_fix.py` | +| AC32〜AC38 | 一時リポジトリで各形のコミットを作り、`commit_trailers` と `verify_apply_round` | `test_commit_trailers_git.py` | +| AC39、AC42 | 雛形の文言を `grep` で探す | `test_skill_terms.py` | +| AC40 | `_emit_init` の出力に `IMPL_STALL_TIMEOUT=1800` | `test_init.py` | +| AC41、AC43、AC44 | `SKILL.md` と `docs/02` / `docs/04` の骨組みの `monitor.py` の引数、語の表の行、段落の有無、結果なしの記述 | `test_skill_terms.py` | +| AC45 | トレーラーの節の記載をレビューで見る | 手動 | +| AC46 | 既存の `test_a_verified_apply_round_marks_every_item_applied` に `failed_attempts` が無いことを足す | `test_merge_apply.py` | +| AC47、AC48 | 全体のテスト、`bash scripts/build-runtime-plugins.sh --check`、`claude plugin validate .`、`python3 scripts/check-skill-frontmatter.py` | 手動 | +| AC49 | `impl_for_seq` を差し替え、群の割り当て・交代先・最終ゲートの修正担当の 3 つ | `test_final_fix.py` / `test_apply_attempts.py` | +| AC50 | `git grep -n revert_unverified_range -- plugins/ndf/skills/cross-refactoring/scripts` が 0 件 | 手動(レビューの手順) | + +## 未確認のまま残ること + +| # | 項目 | 内容 | 決める時点 | +| --- | --- | --- | --- | +| 1 | G3 の実装の形 | `LaunchOutcome` の欄の名前は PR #781 の設計のとおりとしている。実装で変われば `read_result` の包みが吸収し、取り込みは変わらない | G3 の実装 Pull Request のマージ | +| 2 | G1 の先後 | `impl_for_seq` の中身が `assignment.assign` か `impl_assign(participants, seq)` かは、実装の着手時点の `develop` で決める | 実装の計画 | +| 3 | 担当を替えた後の担当も結果を残さない割合 | 2 回目で救える群の数は測っていない。実行の要約の `apply_attempts` で数える | 配布後 | +| 4 | トレーラーの形でない署名を末尾に足すランタイム | codex / agy / kiro のコミットで帰属行の段落を見ていない。散文の段落を足す者が現れれば決定 13 は効かない | 次の実行の `failed` の理由を読む | +| 5 | 帰属行を同じ段落に続ける指示に claude が従うか | 決定 14 は補助で、従わなくても決定 13 で検証は通る | 実装後の最初の実行 | +| 6 | 担当が雛形の進捗マーカーに従うか | 従わなくても決定 15 の許容で打ち切られないのはテスト 1 回分まで | 実装後の最初の実行 | +| 7 | 修正の担当が利用上限のとき、群の他の項目を救う手段 | 決定 10 は修正を見送りへ進める。担当を替えて修正を続ける形は、直しかけの文脈が要るため採らなかった。見送りが増えれば見直す | 配布後 | + +## 申し送り(並行する設計との境界) + +| 相手 | 決めた契約 | どちらが何をするか | +| --- | --- | --- | +| G3(#729) | `read_launch_outcome` / `LaunchOutcome` / `NO_RELAUNCH_REASONS`(PR #781 の「入出力の契約」)。監視の終了コードと標準出力は変えない | G3 が共通層を実装し先にマージする。G4 は `read_result` の包みで呼び、`failed_attempts[].reason` に `reason` を、交代の分岐に `relaunch_same_agent` を写す。G3 の既存の設計にあった `apply._monitor_reason` / `gitfacts.load_result` は作らない | +| G1(#727) | 輪番の母集合と `impl_assign(participants, seq)` | G1 が `assignment.py` を変える。G4 の呼び出しは `rounds.impl_for_seq` の 1 か所で、後からマージする側がその中身を合わせる。`gate._final_fix_impl` の `assignment.assign`(`gate.py:128`)は G4 が `impl_for_seq` へ寄せる | +| D-A(#662 の P1) | 実行の要約の `apply_attempts` は、鍵 `"r<ラウンド>-g<群>"` → `{"attempts", "failed", "dropped_reason"}` を状態ファイルの群から作る | 既存の設計の契約を引き継ぐ。`failed` は `phase: apply` の件数 | +| G6(#678) | `conftest.py` の監視の環境変数 | 触るファイルが重ならない | + +## 既存の設計との対応 + +| 既存の決定(PR #665) | この文書 | 変わったこと | +| --- | --- | --- | +| 決定 1(1 本の Pull Request) | 決定 2 | #674 を範囲に足した | +| 決定 2(上限 2 回・引数なし) | 決定 6 | 同じ | +| 決定 3(次の輪番へ替える) | 決定 8 | 替える先が無いときの分岐に `relaunch_same_agent` を使う | +| 決定 4(判定は `merge-apply`、骨組みは分岐しない) | 決定 7 | 判定を `rounds.group_reopening` へ移し、`next-apply-round` も同じ関数を読む | +| 決定 5(試行番号は `next-apply-round` が進める) | 決定 7 | `resume` / `open` の判定を同じ関数に含めた | +| 決定 6(取り消しの本体を切り出して共有し、最終ゲートは #674) | 決定 4・11 | `intake.py` に 3 つの取り込みの手順を置き、最終ゲートも通す | +| 決定 7(着手前テストと範囲の未確定は 4) | 「入出力の契約」 | 同じ。範囲の確定は `confirm_range` を通る | +| 決定 8(`rounds.impl_for_seq`) | 決定 9 | G1 との先後を明記 | +| 決定 9・10(採用 0 件と項目の無い群) | 決定 12 | `group_reopening` の `empty` へ寄せた | +| 決定 11・12(トレーラー) | 決定 13・14 | #553 の本文が改めた修正レイヤー(進行側の `--amend`)を採らない理由を足した | +| 決定 13(無進捗の許容) | 決定 15 | 同じ | +| 構成要素の `apply._monitor_reason` / `gitfacts.load_result` | — | G3 の `read_launch_outcome` に置き換わり、作らない | +| — | 決定 1・3・5・10 | 新設(文書の置き方、`read_result` の包み、記録の形、修正の結果なしと起動し直せない結末) | diff --git a/issues/issue-728-647-592-553-requirements.md b/issues/issue-728-647-592-553-requirements.md new file mode 100644 index 00000000..ec6b9ac5 --- /dev/null +++ b/issues/issue-728-647-592-553-requirements.md @@ -0,0 +1,273 @@ +# #728 / #647 / #592 / #553: 取り込みが結果なしを値で受け、取り消しと群の状態を 1 か所で決める + +設計は [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md) にある。この文書は「何を満たすか」だけを扱う。 + +**この文書は既存の要求 [issue-647-592-553-requirements.md](issue-647-592-553-requirements.md)(PR #665)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。親 #728 が根本原因の場所(結果の読み取りの向きと、3 つの取り込みの重複)を定め直したため、受け入れ条件を親の名前で改めて置く。#674(最終ゲートの修正)は #728 の子として範囲に入れる。閉じるのは棚卸に任せる。 + +## 依頼(原文) + +### #728(根本原因の親) + +> `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py` の `read_result` は、結果ファイルが無い・読めないと `die(code=2)` で進行の終了コードを決める(1089 / 1093 / 1096 行目) +> +> それを呼ぶ取り込みが 3 つある(`commands/apply.py` の `merge-apply`、`commands/converge.py` の `merge-fix`、`commands/gate.py` の `merge-final-fix`)。下位の読み取りがプロセスを終わらせるため、群の状態を書く機会と、未検証のコミットを取り消す機会が無い +> +> 群を開き直す `apply.py` の `next-apply-round` は、中断からの再開と失敗した試行のやり直しを同じ `pending` / `applied` で区別しない。`rounds.apply_groups` は項目 0 件の群も作る +> +> ## 採る手 +> +> - 向きの修正(`fix_dependency_direction`): 下位の `read_result` は結果なしを値として返し、進行の扱いは取り込みが決める +> - 統合(`consolidate_duplication`): 3 つの取り込みの「範囲の確定 → 未検証コミットの取り消し → 群の状態の記録」を 1 つの手順にする +> +> ## 完了条件 +> +> - `read_result` は結果なしを値で返し、3 つの取り込みが同じ手順で取り消しと群の状態の記録を行う +> - 群が開いた回数と前回の結末を持ち、開き直しの判定が 1 か所にある。空の群は作らない +> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる + +### #647 + +> `/ndf:cross-refactoring` で、同じ適用ラウンド(書き換えるファイルが重ならない改善項目の群)の適用が**上限なしに再試行される**。後ろの群へ順番が回らず、収束の判定と最終ゲートへ届かない。 +> +> **PR #757:** kiro が適用フェーズで 15 秒で終わり `kiro-apply-r1-result.json` を残さない → `merge-apply` が 2 を返し、駆動側の `continue` が `next-apply-round` へ戻る → 同じ群を再び開く。**29 回繰り返した時点で手で止めた。** + +### #592 + +> **テスト整備ラウンドの採用が 0 件のとき、項目の無い適用ラウンドが開き、上限なしに同じ群を繰り返す。** + +### #553 + +> `cross-refactoring` の適用ラウンドで、**claude が実装担当のときだけ**必須トレーラー(`Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model`)が読めず、群が丸ごと取り消される。 +> +> **担当に書かせて、git の最終段落の定義で読み返す構造**が、ランタイムが後ろへ段落を足すたびに壊れる。読み取りを段落単位にする直しは現れている場所の直しで、次に別の書式の署名を足すランタイムが現れれば同じ形が起きうる。進行側が知っている値を進行側が取り込みで書けば、担当のランタイムの帰属行の書式に左右されない。 + +### #674 + +> `merge-final-fix` は `read_result` が結果ファイルの欠落で `die(code=2)` し、範囲の検査(`unassigned_fix_commits` / `verify_final_fix_commit`)と取り消しへ進まない。`final-gate` は担当が作ったコミットを含む HEAD でテストし、落ちれば `fix_base_sha` を HEAD へ置き直す。そのコミットは以後どの範囲にも入らない。**検証を受けていないコミットが Pull Request に残りうる。** + +## 目的 + +- 担当が結果を残さなくても、3 つの取り込み(適用・修正・最終ゲートの修正)が同じ手順で範囲を確め、未検証のコミットを取り消し、結末を記録して終了コードを返す。プロセスを終わらせる場所は取り込みだけになる +- 適用ラウンドの繰り返しが有限回で終わる。群は開いた回数と前回の結末を持ち、開き直すか・担当を替えるか・取り消すかを 1 つの関数が決める +- 採用 0 件の提案ラウンドで群を作らず、項目の無い群を開かない +- 起動し直しても解けない結末(利用上限)では、同じ担当を同じ工程で起動し直さない +- Claude Code が帰属行を別の段落で足しても、必須トレーラーが読めて群が落ちない + +## 前提 + +| # | 前提 | +| --- | --- | +| 1 | G3(#729、PR #781)の契約に従う。`lib/monitor_outcome.py` に `read_launch_outcome(tmp_dir, stem, result_path=None) -> LaunchOutcome`(`payload` / `reason` / `detail` / `monitor` / `relaunch_same_agent`)と `NO_RELAUNCH_REASONS = {"usage_limit"}` が入り、理由の語彙は 9 語(`ok` / `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `missing` / `pidfile_bad` / `unparsable`)である。**G3 の実装 Pull Request が `develop` に入った後に、この変更の実装を始める** | +| 2 | 監視の終了コード 0〜6 と標準出力は変わらない(G3 の申し送り)。骨組みは終了コードで分岐しない | +| 3 | P1(監視の結果ファイル `-monitor.json`)と P2(`--phase` と `lib/limits.py`)は v10.13.0 で `develop` に入っている | +| 4 | 輪番の母集合は G1(#727、PR #782)が変える(既定を codex / kiro / ホストにし、`impl_assign(participants, seq)` を新設)。この変更が担当を引く呼び出しは `rounds.impl_for_seq` の 1 つで、G1 と先後どちらでも変える場所はその中だけである | +| 5 | Claude Code が足す帰属行は、メッセージの末尾に独立した段落として付く(#553 の実測 `26a0fff`)。他のランタイムが足す署名もトレーラーの形(`Key: value` の行だけの段落)である | +| 6 | 除外されない参加者が 2 者以上いる。1 者しかいない実行では、担当の交代先が無いため、結末の可否だけで 2 回目を開くか取り消すかを決める(設計文書の決定 8) | + +## 対象範囲 + +含む: + +- `gitfacts.read_result` の契約の置き換え(結果なしを値で返す。`die` しない) +- 3 つの取り込みの「範囲の確定 → 未検証コミットの取り消し → 結末の記録」の共通化(`refactor_lib/intake.py` の新設) +- 取り消しの本体の一本化(`gitfacts.revert_unverified_range` と `apply._revert_unverified_apply_round` の 2 つを 1 つに) +- 群の開き直しの判定の一本化(`rounds.group_reopening`)と、群が持つ試行の記録(`attempt` / `failed_attempts` / `drop_reason`) +- 同じ群の試行の上限(2 回)と、2 回目の担当の交代 +- 採用 0 件の提案ラウンドで群を作らないこと。項目の無い群を開かないこと(#592) +- `merge-fix` が読む結果の担当と、結果が無いときの修正ラウンドの数え方 +- `merge-final-fix` が結果なしで未検証のコミットを取り消すこと(#674) +- 起動し直せない結末(`relaunch_same_agent` が偽)のときの 3 つの取り込みの振る舞い +- `commit_trailers` の読み方と、適用・修正の雛形のコミットの規約(#553) +- 適用・修正・最終ゲートの修正の監視に渡す無進捗の許容と、雛形の進捗マーカー(#647 の STALLED 対策。既存の設計から引き継ぐ) +- `SKILL.md` の語の表・「別の上限を置かない」の段落・骨組みの監視の引数、`docs/02-apply-and-review.md` / `docs/04-fix-and-report.md` の対応箇所 + +含まない: + +| 扱わないもの | 理由 | +| --- | --- | +| `lib/monitor_outcome.py` / `lib/monitor.py` / `lib/launch-cli.sh` の変更 | G3(#729)が所有する。この変更は `read_launch_outcome` を呼ぶ側 | +| `lib/assignment.py` と参加者の決め方 | G1(#727)が所有する。この変更は `rounds.impl_for_seq` の中で呼ぶだけ | +| 利用上限で進行全体を止めること | 結末の可否を見て担当を替えるか、修正の上限へ進める(設計文書の決定 8・10) | +| 骨組みの bash を `scripts/` へ出すこと | #560 | +| 進行側が取り込みで `git commit --amend` によりトレーラーを足し直すこと | 設計文書の決定 13。SHA が変わり申告との対応が切れる。群に複数のコミットがあると先頭の書き換えが以降をすべて書き換える | +| `deferred_items` の 2 通りの形(`rounds.deferred_record` の固定鍵と、`apply.py:178` の提案の複製)を揃えること | この変更が足す見送りは `deferred_record` の形を使う。読む側(`plan.py`)は鍵が無くても落ちない | +| `cross-review` 側の取り込み | G3 が `state.py` の `read-result` を変える | +| `CHANGELOG.md` と版数 | 配布の工程が書く | +| クラス図 | 設計文書の「構造」に触る型(`LaunchOutcome` / `IntakeScope` / `ClosedAttempt`)だけを載せる | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 取り込み | 担当の CLI が作ったコミットを、進行側が検証して受け入れるコマンド。`merge-apply` / `merge-fix` / `merge-final-fix` の 3 つ | +| 群 | 適用ラウンド。書き換えるファイルが重ならない項目の集まりで、状態の `rounds[].apply_rounds[]` の 1 件 | +| 試行 | 1 つの群に対して適用担当を起動し、`merge-apply` で取り込もうとした 1 回。番号は `attempt` | +| 結末 | 担当 1 回の起動の終わり方。G3 の `LaunchOutcome`(使える結果か、結果なしの理由か) | +| 結果なし | `LaunchOutcome.payload` が `None`。理由は `reason`(`missing` / `unparsable` / `stalled` など) | +| 起動し直しの可否 | `LaunchOutcome.relaunch_same_agent`。偽は同じ担当を同じ条件で起動しても解けない(`usage_limit`) | +| 範囲 | 取り込みが検査するコミットの列。起点(`apply_base_sha` / `fix_base_sha`)から HEAD まで | +| 未検証のコミット | 範囲にあるが、結果なしで検証を受けられなかったコミット | +| 帰属行 | Claude Code がコミットメッセージへ足す `Co-Authored-By:` / `Claude-Session:` の行 | +| トレーラーの段落 | `git interpret-trailers --parse` がトレーラーとして読む段落 | + +## 受け入れ条件(結末の読み取り) + +- [ ] AC1: 結果ファイルが無い状態で `gitfacts.read_result` を呼ぶと、`SystemExit` を出さず、標準出力・標準エラーに書かず、`payload` が `None` で `reason` が `missing` の値を返す +- [ ] AC2: 監視の結果ファイル(`-apply-r-monitor.json`)に `reason: stalled` があるとき、`read_result` の `reason` は `stalled`、`relaunch_same_agent` は真である。`reason: usage_limit` のとき `relaunch_same_agent` は偽である +- [ ] AC3: `read_result` に渡す stem は、監視の `--stem-template`(`{agent}-apply-r$ROUND` / `{agent}-fix-r$ROUND` / `{agent}-final-fix`)を担当名で埋めた値と一致する。`paths.stem_for` の 3 つの工程の値を骨組みの雛形から作った値と突き合わせる + +## 受け入れ条件(共通の手順) + +- [ ] AC4: 前提: 結果なしで、起点から HEAD までにコミットが 1 件以上ある + 操作: 3 つの取り込みのいずれかを呼ぶ + 結果: そのコミットは取り消され、起点(`apply_base_sha` と群の `base_sha` / `fix_base_sha` / `final_gate.fix_base_sha`)は取り消し後の HEAD になる +- [ ] AC5: 結果なしのとき、3 つの取り込みのいずれでも、記録の辞書(群 / `final_gate`)の `failed_attempts` に `{phase, attempt, impl, reason, detail, at, reverted}` の 1 件が足される。`reason` は `read_result` の値、`reverted` は取り消したコミットの数である +- [ ] AC6: 結果なしで範囲にコミットが無いとき、`git revert` も `git push` も実行されない +- [ ] AC7: 結果なしの取り込みを、同じ試行番号でもう一度呼ぶと、結果ファイルを読まずに前回と同じ終了コード 2 を返し、`failed_attempts` の件数は増えない。その間に結果ファイルが現れても読まない +- [ ] AC8: 3 つの取り込みで、範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは変わらない(`merge-apply` は 4、`merge-fix` は修正ラウンドを 1 進めて 2、`merge-final-fix` は 2) + +## 受け入れ条件(#647: 適用ラウンド) + +- [ ] AC9: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに `next-apply-round` → `merge-apply` を呼ぶ。終了コードは 2。1 つ目の群は `status: pending` のまま担当が agy 以外に替わり、`attempt` は 1、`failed_attempts` は 1 件(`phase: apply`、`attempt: 1`、`impl: agy`)である +- [ ] AC10: AC9 の後、替わった担当の結果ファイルも置かずにもう一度 `next-apply-round` → `merge-apply` を呼ぶ。1 つ目の群は `status: dropped`・`drop_reason: no_result`、項目は `abandoned`、`deferred_items` に `実装担当が結果を残しませんでした(agy: missing → codex: missing)` の形の理由で入る +- [ ] AC11: 結果ファイルを 1 つも置かずに `next-apply-round` が 1 を返すまで繰り返す。`next-apply-round` の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、両方の群が `dropped` になる +- [ ] AC12: 結果ファイルが JSON として読めない場合と JSON の配列の場合も AC9 と同じ状態になり、`failed_attempts[].reason` は `unparsable` である +- [ ] AC13: 群が 4 つ(`apply_seq` 4)あり先頭の群(担当 codex)が結果を残さない。替えた後の担当は codex 以外で、`apply_seq` は進めた分だけ進み、他の群の担当は変わらない +- [ ] AC14: 監視の結果ファイルの `reason` が `usage_limit` で、`rounds.impl_for_seq` の差し替えにより交代先が無い状態では、1 回目の失敗で群が `dropped`(`drop_reason: no_result`)になる。`reason` が `missing` で交代先が無い状態では、同じ担当で 2 回目を開く +- [ ] AC15: `next-apply-round` を `merge-apply` を挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。群の `attempt` は 1 のまま進まない +- [ ] AC16: 着手前のテストの状態が `green` でない状態で `merge-apply` を呼ぶと、結果ファイルを読まずに終了コード 4 で終わる +- [ ] AC17: `merge-apply` が終了コード 2 で終わった後の群は、`dropped` か、`failed_attempts` を持つ `pending` のどちらかである。確かめる経路は 4 つ(結果なし / 未割当のコミット / 適用の検証の失敗 / 取り込み済みで採用 0 件) +- [ ] AC18: `rounds.group_reopening` を差し替えると、`next-apply-round` の開き方(開く・再開・開かない)と `merge-apply` の結果なしの後の扱い(担当の交代・取り消し)の両方が、差し替えた関数の返す値に従う + +## 受け入れ条件(#592: 採用 0 件と項目の無い群) + +- [ ] AC19: テスト整備ラウンドで提案が 0 件の状態で `merge-proposals` を呼んだ後、`next-apply-round` を呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの `apply_rounds` は空の配列のままである +- [ ] AC20: `apply_rounds` の鍵を持たない状態ファイル(群を導入する前の版)では、`next-apply-round` が従来どおりラウンド全体を 1 つの群として開く +- [ ] AC21: 前提: rf587 で残った形の群(`status: applied`・`items: []`・`apply.merged_at` あり・`applied: []`) + 操作: `merge-apply` を呼ぶ + 結果: 終了コード 2 で終わり、群が `dropped`(`drop_reason: empty`)になる。続く `next-apply-round` は 1 を返す +- [ ] AC22: `status: pending`・`items: []` の群を持つ状態で `next-apply-round` を呼ぶ。その群は開かれずに `dropped`(`drop_reason: empty`)になり、次の群があればそれを開き、無ければ終了コード 1 を返す + +## 受け入れ条件(修正ラウンド) + +- [ ] AC23: 群の担当が agy、提案ラウンドの担当が codex の状態で `agy-fix-r1-result.json` を置いて `merge-fix` を呼ぶ。agy の結果が取り込まれ、`fix_rounds` が 1 になる +- [ ] AC24: 修正の結果ファイルが無い状態で `merge-fix` を呼ぶと、終了コード 2 で終わり、`fix_rounds` が 1 進み、群の `failed_attempts` に `phase: fix` の 1 件が足される。`--max-fix-rounds` 回続けた後の `should-abandon` は終了コード 0 を返す +- [ ] AC25: AC24 の直後に `verify-round` を挟まず `merge-fix` をもう一度呼んでも `fix_rounds` は進まない(AC7 の修正ラウンドの形) +- [ ] AC26: 修正の結果なしで監視の `reason` が `usage_limit` のとき、`merge-fix` は `fix_rounds` を `--max-fix-rounds` の値にし、続く `should-abandon` は終了コード 0 を返す +- [ ] AC27: 修正の結果なしで起点から HEAD にコミットがあるとき、取り消され、`fix_base_sha` が取り消し後の HEAD になる(AC4 の修正ラウンドの形) + +## 受け入れ条件(#674: 最終ゲートの修正) + +- [ ] AC28: 前提: 最終ゲートの修正の結果ファイルが無く、`final_gate.fix_base_sha` から HEAD にコミットが 1 件ある + 操作: `merge-final-fix` を呼ぶ + 結果: 終了コード 2。そのコミットは取り消され、`final_gate.fix_base_sha` は取り消し後の HEAD、`final_gate.failed_attempts` は 1 件(`phase: final-fix`) +- [ ] AC29: AC28 の後に `final-gate` を呼ぶと、テストは取り消し後の HEAD で実行され、`fix_commits` に取り消したコミットは入らない +- [ ] AC30: 最終ゲートの修正の結果なしで監視の `reason` が `usage_limit` のとき、`final_gate.fix_rounds` は `--max-fix-rounds` の値になり、続く `final-gate` はテストが落ちれば終了コード 1(取り消さず報告)で終わる +- [ ] AC31: 結果ファイルがあり検証を通る最終ゲートの修正は、変更前と同じく取り込まれ、`final_gate.failed_attempts` を持たない + +## 受け入れ条件(#553: 帰属行の後ろのトレーラー) + +一時リポジトリで実際にコミットを作って確かめる: + +- [ ] AC32: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、`commit_trailers` が 4 つとも値を返す +- [ ] AC33: AC32 の段落の後に `Co-Authored-By:` と `Claude-Session:` の 2 行の段落が付いても、4 つとも返す +- [ ] AC34: 必須トレーラーの段落と末尾の段落の間に散文の段落があるコミットで、散文より前にある `Round: …` の形の行を読まない +- [ ] AC35: 末尾の段落に散文とトレーラーの形の行が混ざる(git がトレーラーの段落と判定しない)コミットで、その行を読まない +- [ ] AC36: 同じ鍵が 2 つの段落にあるとき、末尾に近い段落の値を返す +- [ ] AC37: AC32 の形のコミットを申告した適用ラウンドが、トレーラーの欠落で取り消されない +- [ ] AC38: 本文がトレーラーの段落 1 つだけで、題名が `Round: 本文の題名` の形のコミットで、題名を読まない +- [ ] AC39: `prompts/apply.md` と `prompts/fix.md` のコミットの規約が、必須トレーラーをメッセージの最後の段落に置くことを書く + +## 受け入れ条件(無進捗の打ち切り) + +- [ ] AC40: `init` の出力に `IMPL_STALL_TIMEOUT` が入り、値が `--test-timeout` の値 + 900 である(既定で 1800) +- [ ] AC41: `SKILL.md` の骨組みで、`--phase apply` / `fix` / `final-fix` の 3 つの `monitor.py` の呼び出しが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない +- [ ] AC42: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が、作業段階ごとに `$RF_STEM-progress.log` へ 1 行追記する指示を持つ + +## 受け入れ条件(文書) + +- [ ] AC43: `SKILL.md` の「この Skill で使う語」の適用ラウンドの行が、同じ群の試行の上限(2 回)を書く。`grep -n "別の上限を置かない\|別に置かない" SKILL.md` が何も出力しない +- [ ] AC44: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6・Step 7 が 2 つを書く。結果なしのときの取り込みの振る舞い(取り消し・記録・終了コード)と、`SKILL.md` と同じ `monitor.py` の引数である +- [ ] AC45: `docs/02-apply-and-review.md` のトレーラーの節が、`git log --format='%(trailers:…)'` が最後の段落しか読まないことと、進行側の読み方の 2 つを書く + +## 受け入れ条件(退行しない) + +- [ ] AC46: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、`failed_attempts` を持たない +- [ ] AC47: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [ ] AC48: 配布物の同期・定義・frontmatter の 3 つの検査が終了コード 0 で終わる(コマンドは「検証手段」の表) + +## 受け入れ条件(他の設計との契約) + +- [ ] AC49: `rounds.impl_for_seq` を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの修正担当の 3 つが、差し替えた関数の返す担当になる +- [ ] AC50: 取り消しの本体は `intake.discard_unverified` の 1 つになる。`gitfacts.revert_unverified_range` は無くなり、`apply._revert_unverified_apply_round` は `discard_unverified` を呼ぶ + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 中断と再開を挟まない実行で、1 つの提案ラウンドで適用担当を起動する回数が群の数 × 2 回を超えない。修正担当を起動する回数も群の数 × `--max-fix-rounds` 回を超えない。最終ゲートの修正担当の起動は `--max-fix-rounds` 回を超えない | +| 運用・保守性 | 群を取り消した理由(担当と結末の理由)が改修計画の「見送った項目」の表から読める。3 つの取り込みの結果なしの記録が同じ形(`failed_attempts[]`)で、実行の要約が同じ読み方で数えられる | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| `gitfacts.read_result` | 引数と戻り値が変わる(結果なしを値で返す)。呼び出し元 3 か所とテスト(`test_git_facts.py` の 3 件)を書き直す | +| `gitfacts.revert_unverified_range` | 無くなる。呼び出し元 2 か所(`converge.py:383` / `gate.py:203`)は `intake.discard_unverified` へ | +| `merge-apply` の終了コード | 着手前テストの未確認と範囲の未確定が 2 から 4(中断)へ変わる。`SKILL.md` の終了コードの表は既に 4 と書いている | +| `merge-fix` / `merge-final-fix` の終了コード | 結果なしで 2(新)。骨組みは終了コードを見ないため変わらない | +| 状態ファイル | 群に `attempt` / `failed_attempts` / `drop_reason`、`final_gate` に `failed_attempts` が増える。既存の状態ファイルは鍵が無いまま読める(試行 0 回・失敗なし) | +| 群の担当 | 結果を残さなかった群だけ、2 回目の試行で次の輪番の担当へ替わる | +| 無進捗の打ち切り | 適用・修正・最終ゲートの修正で、どの担当も既定の許容より長くなる(既定 1800 秒) | +| トレーラーの読み取り | 最後の段落に加え、その直前に続くトレーラーの段落も読む | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q`(cross-refactoring だけなら `plugins/ndf/skills/cross-refactoring/tests`) | +| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | +| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | +| 手動確認 | 次に cross-refactoring を回した実行で、`-apply-r*-progress.log` に作業段階が残るか。担当が結果を残さなかった群の `failed_attempts[].reason` が監視の結果ファイルの `reason` と一致するか | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 状態の判定は `refactor_lib/` に置き、骨組みの bash は判定を持たない(`SKILL.md` の「実行」)。`commands/` どうしの取り込みを作らず、複数のコマンドが読む処理は `refactor_lib/` の直下(`rounds.py` / `intake.py`)に置く | +| コーディング規約 | 外部コマンド(`git interpret-trailers` / `git revert`)の挙動は書く前に実行して確かめる(`AGENTS.md` の DO) | +| テスト戦略 | 状態の遷移は既存の形(`tests/conftest.py` の `no_git` / `patch_lib` と `test_merge_apply.py` の `git_facts`)で関数を直接呼ぶ。トレーラーの読み取りは一時リポジトリで実際に git を実行する。共通層 `read_launch_outcome` はテストで差し替えず、一時ディレクトリに監視の結果ファイルと結果ファイルを置いて本物を通す | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、配布物の同期の検査 | +| 確認してから行う | 同じ群の試行の上限を引数にすること(この変更では固定の 2 回) | +| 行わない | `monitor.py` / `monitor_outcome.py` / `assignment.py` の変更、骨組みを `scripts/` へ出すこと、進行側によるコミットの書き換え(`--amend`) | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| G1(#727)の `impl_assign` / `participants` と、この変更の `rounds.impl_for_seq` のどちらが先に `develop` へ入るか | 進行側(実装の持ち場の着手時点) | 実装の計画 | + +## 既存の受け入れ条件との対応 + +| 既存(PR #665) | この文書 | 変わったこと | +| --- | --- | --- | +| AC1〜AC3、AC5、AC7 | AC9〜AC11、AC13、AC15 | 記録の名前を `failed_attempts[]`(`phase` 付き)に揃えた | +| AC4 | AC12 | 理由 `unparsable` を明記 | +| AC6 | AC7 | 3 つの取り込みに広げた | +| AC8 | AC4、AC6 | 3 つの取り込みに広げた。コミットが無いときは push しない | +| AC9、AC10 | AC16、AC8 | 同じ | +| AC11 | AC17 | 同じ | +| AC12 | AC2、AC5 | `_monitor_reason` を `read_launch_outcome` の値に置き換えた | +| AC13〜AC16 | AC19〜AC22 | `drop_reason: empty` を明記 | +| AC17〜AC19 | AC23〜AC25 | 記録を `failed_attempts[]` に揃えた | +| AC20〜AC27 | AC32〜AC39 | 同じ | +| AC28〜AC30 | AC40〜AC42 | 同じ | +| AC31〜AC33 | AC43〜AC45 | AC44 に Step 7 と結果なしの記述を足した | +| AC34〜AC36 | AC46〜AC48 | 同じ | +| AC37 | AC49 | 同じ | +| — | AC1、AC3、AC14、AC18、AC26〜AC31、AC50 | 新設(結末の読み取り、起動し直しの可否、最終ゲート、開き直しの判定の一本化、取り消しの本体の一本化) | From d47439c5b509ee658f5c616fd85cdbef8d69a2cd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 04:14:17 +0000 Subject: [PATCH 010/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=E6=96=87?= =?UTF-8?q?=E6=9B=B8=E3=81=AE=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87?= =?UTF-8?q?=E6=91=98=204=20=E4=BB=B6=E3=82=92=E7=9B=B4=E3=81=99=EF=BC=88#7?= =?UTF-8?q?84=20=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=201=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 実測表の `if groups:` の行番号を `rounds.py:109` に直す - 交代先探索の打ち切りを「参加者の数だけ進める」に直し、決定 8 に根拠を添える - `merge-fix` / `merge-final-fix` の図で final-fix が `fix_rounds` を進めないことを示す - `IntakeScope` の表に `label` 列を足す Co-Authored-By: Claude Fable 5.1 --- issues/issue-728-647-592-553-design.md | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/issues/issue-728-647-592-553-design.md b/issues/issue-728-647-592-553-design.md index c3fd583b..c232ced5 100644 --- a/issues/issue-728-647-592-553-design.md +++ b/issues/issue-728-647-592-553-design.md @@ -67,7 +67,7 @@ G3 の契約(決定 11)は「`read_launch_outcome` を呼び、値を返し ### 決定 8: 2 回目の試行は次の輪番の担当が行い、替える先が無いときだけ結末の可否で決める -結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の STALLED 4 回、claude の 429 の 3729 回、PR #757 の kiro)。同じ担当で開き直しても直らない。替える先は、`apply_seq` を 1 ずつ進めて `rounds.impl_for_seq` を引き、その群で失敗した担当のどれとも違う担当が出た最初の番号の担当である。1 つ進めるだけにしないのは、輪番が 1 周すると同じ担当へ戻るためである(既存の設計の「実測」: `assign(1)` と `assign(5)` はどちらも codex)。 +結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の STALLED 4 回、claude の 429 の 3729 回、PR #757 の kiro)。同じ担当で開き直しても直らない。替える先は、`apply_seq` を 1 ずつ進めて `rounds.impl_for_seq` を引き、その群で失敗した担当のどれとも違う担当が出た最初の番号の担当である。1 つ進めるだけにしないのは、輪番が 1 周すると同じ担当へ戻るためである(既存の設計の「実測」: `assign(1)` と `assign(5)` はどちらも codex)。探索は参加者の数だけ進めれば全員を 1 度ずつ見るので、打ち切りの回数は固定値ではなく参加者の数から導く。 **替える先が無いとき**(参加者が 1 者。G1 の `--exclude` で残りが 1 者になる実行)は、`LaunchOutcome.relaunch_same_agent` を読む。真(`missing` / `stalled` など)なら同じ担当で 2 回目を開き、偽(`usage_limit`)なら 1 回目で群を取り消す。可否を読むのはこの分岐だけで、替える先があるときは常に替える。利用上限で進行全体を止める形は採らない。他の担当で進められる群まで止まる。 @@ -118,7 +118,7 @@ G3 の契約(決定 11)は「`read_launch_outcome` を呼び、値を返し | `read_result` の呼び出し元 | 3 か所(`commands/apply.py:452` / `commands/converge.py:478` / `commands/gate.py:165`)。いずれも `die(code=2)` を内包する | | 取り消しの本体 | 2 つ(`gitfacts.revert_unverified_range`(`converge.py:383` / `gate.py:203` が呼ぶ)と `apply._revert_unverified_apply_round`)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)と `entry["apply"]` の記録 | | `merge-final-fix` の順序 | `read_result`(:165)→ `commits_in_range`(:167)→ `unassigned_fix_commits` / `verify_final_fix_commit`(:178-189)→ `revert_unverified_range`(:199-206)。結果なしでは 2 つ目以降へ進まない | -| `rounds.apply_groups` | `if groups:`(`rounds.py:103`)だけで分岐し、`None` と `[]` を区別しない | +| `rounds.apply_groups` | `if groups:`(`rounds.py:109`)だけで分岐し、`None` と `[]` を区別しない | | `stem_for` / `result_path` | `refactor_lib/paths.py:94` / `:85`。`result_path` は `tmp_dir / f"{stem}-result.json"` で、G3 の `read_launch_outcome` の既定と同じ | | 既存の設計が名付けた関数 | `MAX_APPLY_ATTEMPTS` / `impl_for_seq` / `load_result` / `_close_failed_attempt` / `_monitor_reason` はいずれも未実装(PR #665 は文書だけ) | @@ -397,7 +397,7 @@ graph TD `_close_failed_attempt` は次の順で行う。 1. `close_without_result` を呼ぶ(範囲の確定 → 取り消し → `failed_attempts` へ `{phase: apply, attempt, impl, reason, detail, at, reverted}`。`attempt` が 0 なら 1 として記録し、群の `attempt` を 1 にする)。`range_unknown` なら項目を `blocked` にして 4 で中断する -2. `group_reopening(group)` を読む。`open` なら `apply_seq` を 1 ずつ進めて `impl_for_seq` を引き、`failed_attempts[].impl` のどれとも違う担当が出たらその担当と要求モデルで `impl` / `impl_model` を書き換える。4 回進めても出なければ、`relaunch_same_agent` が真なら担当を替えずに終え、偽なら手順 3 と同じく取り消す +2. `group_reopening(group)` を読む。`open` なら `apply_seq` を 1 ずつ進めて `impl_for_seq` を引き、`failed_attempts[].impl` のどれとも違う担当が出たらその担当と要求モデルで `impl` / `impl_model` を書き換える。参加者の数だけ進めても出なければ(輪番は参加者の数で 1 周する)、`relaunch_same_agent` が真なら担当を替えずに終え、偽なら手順 3 と同じく取り消す 3. `exhausted` なら群の項目を `abandoned` にして `deferred_items` へ入れる。群は `dropped`(`drop_reason: no_result`)にし、`apply.merged_at` を立て、局面を `phase_after_group` にする 4. 保存して 2 で終わる(push は手順 1 が取り消したときに済ませている) @@ -412,19 +412,19 @@ graph TD RD -->|結果なし| CW[close_without_result] CW -->|range_unknown| RU[既存の扱い
fix: fix_rounds を進めて 2 / final-fix: 2] CW --> ADV{relaunch_same_agent} - ADV -->|真| P1[fix_rounds を 1 進める] - ADV -->|偽| PM[fix_rounds を max_fix_rounds にする] + ADV -->|真| P1[fix: fix_rounds を 1 進める
final-fix: 進めない(final-gate が進める)] + ADV -->|偽| PM[fix / final-fix とも
fix_rounds を max_fix_rounds にする] P1 --> R2 PM --> R2 ``` 3 つの取り込みが渡す `IntakeScope` の値は次のとおりである。結果を読めたときの検証の失敗(`_revert_unverified_apply_round` / `_revert_invalid_fix_round` / 最終ゲートの `unassigned or problems`)も `discard_unverified` を呼ぶ。 -| 取り込み | `holder` | `base_key` | `records` | `phase` | `attempt` | `impl` | `mirror` | -| --- | --- | --- | --- | --- | --- | --- | --- | -| `merge-apply` | `entry` | `apply_base_sha` | 群 | `apply` | 群の `attempt` | 群の `impl` | 群 | -| `merge-fix` | `entry` | `fix_base_sha` | 群 | `fix` | `entry.fix_attempts` | 群の `impl` | なし | -| `merge-final-fix` | `final_gate` | `fix_base_sha` | `final_gate` | `final-fix` | `final_gate.fix_rounds` | `final_gate.impl` | なし | +| 取り込み | `holder` | `base_key` | `records` | `phase` | `attempt` | `impl` | `label` | `mirror` | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| `merge-apply` | `entry` | `apply_base_sha` | 群 | `apply` | 群の `attempt` | 群の `impl` | `R<提案ラウンド>-A<群の番号>` | 群 | +| `merge-fix` | `entry` | `fix_base_sha` | 群 | `fix` | `entry.fix_attempts` | 群の `impl` | `R<提案ラウンド>-fix` | なし | +| `merge-final-fix` | `final_gate` | `fix_base_sha` | `final_gate` | `final-fix` | `final_gate.fix_rounds` | `final_gate.impl` | `final-gate-fix` | なし | ## 非機能の実現方式 From 671acb24ad9f338f9bfca47774d3397c47e69bbd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 04:25:43 +0000 Subject: [PATCH 011/217] =?UTF-8?q?Docs:=20AC8=20=E3=82=92=E5=A4=89?= =?UTF-8?q?=E6=9B=B4=E5=BE=8C=E3=81=AE=E7=B5=82=E4=BA=86=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=81=AE=E6=9C=9F=E5=BE=85=E5=80=A4=E3=81=A7=E6=9B=B8?= =?UTF-8?q?=E3=81=8F=EF=BC=88#784=20=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89?= =?UTF-8?q?=202=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「変わらない」と書くと、同じ文書の影響の表(`merge-apply` の範囲未確定を 2 から 4 へ 変える)と食い違って読める。受け入れ条件は各取り込みが返す値をそのまま書く。 Co-Authored-By: Claude Fable 5.1 --- issues/issue-728-647-592-553-requirements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-728-647-592-553-requirements.md b/issues/issue-728-647-592-553-requirements.md index ec6b9ac5..6d3610be 100644 --- a/issues/issue-728-647-592-553-requirements.md +++ b/issues/issue-728-647-592-553-requirements.md @@ -124,7 +124,7 @@ - [ ] AC5: 結果なしのとき、3 つの取り込みのいずれでも、記録の辞書(群 / `final_gate`)の `failed_attempts` に `{phase, attempt, impl, reason, detail, at, reverted}` の 1 件が足される。`reason` は `read_result` の値、`reverted` は取り消したコミットの数である - [ ] AC6: 結果なしで範囲にコミットが無いとき、`git revert` も `git push` も実行されない - [ ] AC7: 結果なしの取り込みを、同じ試行番号でもう一度呼ぶと、結果ファイルを読まずに前回と同じ終了コード 2 を返し、`failed_attempts` の件数は増えない。その間に結果ファイルが現れても読まない -- [ ] AC8: 3 つの取り込みで、範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは変わらない(`merge-apply` は 4、`merge-fix` は修正ラウンドを 1 進めて 2、`merge-final-fix` は 2) +- [ ] AC8: 3 つの取り込みで範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは、`merge-apply` は 4、`merge-fix` は修正ラウンドを 1 進めて 2、`merge-final-fix` は 2 である ## 受け入れ条件(#647: 適用ラウンド) From 22ab68ed5f231da71252bb33f54eaba807608a7a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 04:29:41 +0000 Subject: [PATCH 012/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=E3=83=BB?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E3=81=AE=E3=82=BF=E3=82=A4=E3=83=88=E3=83=AB?= =?UTF-8?q?=E3=81=A8=E5=86=92=E9=A0=AD=E3=82=92=E3=80=8C=E7=8F=BE=E8=B1=A1?= =?UTF-8?q?=20=E2=86=92=20=E6=88=90=E3=82=8A=E7=AB=8B=E3=81=A4=E3=81=93?= =?UTF-8?q?=E3=81=A8=E3=80=8D=E3=81=AE=E5=BD=A2=E3=81=AB=E3=81=97=E3=80=81?= =?UTF-8?q?=E7=9B=AE=E7=9A=84=E3=81=AE=E7=AB=A0=E3=82=92=E5=85=88=E9=A0=AD?= =?UTF-8?q?=E3=81=AB=E7=BD=AE=E3=81=8F=EF=BC=88#784=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 利用者の規約に合わせ、H1 を「cross-refactoring: <現象> → <直した後に成り立つこと>」に、 最初の章を「目的」(壊れていること・困る人・成り立つこと)にし、管理上の注記を 「文書の位置づけ」へ寄せる。決定の見出しは「何のために何を決めた」と読める語にする。 決定の索引表は見出しが目的を持つため外す。 Co-Authored-By: Claude Fable 5.1 --- issues/issue-728-647-592-553-design.md | 50 ++++++++++---------- issues/issue-728-647-592-553-requirements.md | 18 +++---- 2 files changed, 33 insertions(+), 35 deletions(-) diff --git a/issues/issue-728-647-592-553-design.md b/issues/issue-728-647-592-553-design.md index c232ced5..7e588a4e 100644 --- a/issues/issue-728-647-592-553-design.md +++ b/issues/issue-728-647-592-553-design.md @@ -1,4 +1,12 @@ -# #728 / #647 / #592 / #553: 取り込みが結果なしを値で受け、取り消しと群の状態を 1 か所で決める +# cross-refactoring: 実装担当が結果を残さないと同じ群が上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、群が開いた回数と結末で開き直しを決める(設計 / #728 #647 #592 #553) + +## 目的 + +- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わると(無進捗の打ち切り・利用上限・15 秒で落ちる kiro)、取り込みが下位の読み取りの `die` で止まり、同じ群が上限なしに開き直される(PR #757 で 29 回、rf646 で 3729 回)。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続け(rf587 で 187 回)、claude が担当の群は帰属行のためトレーラーが読めずに落ちる +- **困る人**: cross-refactoring を回す進行側(手で止めるまで CLI の起動と利用料が続く)と、その Pull Request を読む人(未検証の差分が混じる) +- **直すと成り立つこと**: 結果なしは 3 つの取り込みが 1 つの手順で受け、未検証のコミットを取り消して結末を記録する。群は開いた回数と前回の結末を持ち、担当を替えて 1 回だけ開き直して終わる。項目の無い群は開かない。帰属行の後ろでもトレーラーが読める + +## 文書の位置づけ 要求と受け入れ条件は [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md) にある。この文書は「どう作るか」だけを扱う。 @@ -21,79 +29,69 @@ ## 決定の記録 -| 決定 | 扱うこと | -| --- | --- | -| 1〜2 | 文書の置き方と Pull Request の分け方 | -| 3〜5 | 結末の読み取りと共通の手順 | -| 6〜9 | 群の試行・開き直し・担当の交代 | -| 10〜11 | 修正ラウンドと最終ゲート | -| 12 | 採用 0 件と項目の無い群 | -| 13〜14 | トレーラー | -| 15 | 無進捗の打ち切り | - -### 決定 1: 設計文書は親 #728 の名前で新設し、既存の設計文書の本体は書き換えない +### 決定 1: 通過済みの決定と新しい決定を差分で読み分けるために、設計文書は親 #728 の名前で新設し、既存の本体は書き換えない 既存の設計は 3 課題を 1 つの文書で扱い、設計 Pull Request の関門を通過している。親 #728 は決定 6(取り消しの本体を切り出して共有し、最終ゲートを #674 に残す)を改め、結果の読み取りの向きを変える。その節を書き換えると、通過済みの決定と新しい決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は変更したファイルの `## 決定の記録` の見出しをすべて写すため、1 行でも触ると既存の 13 件がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない既存の要求の文書にだけ足す(#729 / #727 の設計と同じ扱い)。 -### 決定 2: 4 課題と #674 を 1 本の Pull Request で直す +### 決定 2: 同じ関数を 2 度変えないために、4 課題と #674 を 1 本の Pull Request で直す `read_result` の契約を変えると、呼び出し元 3 か所(`merge-apply` / `merge-fix` / `merge-final-fix`)を同時に書き直すことになる。取り消しの本体を 1 つにすることも 3 か所を同時に触る。課題ごとに分けると同じ関数を 2 度変える。#553 は同じ `gitfacts.py` と `merge-apply` の検証に閉じ、分けても触るファイルが重なる。 -### 決定 3: `gitfacts.read_result` は名前を残し、引数を状態と工程に変えて `LaunchOutcome` を返す +### 決定 3: 監視と同じ stem を 1 か所で組むために、`gitfacts.read_result` は名前を残して引数を状態と工程に変え、`LaunchOutcome` を返す G3 の契約(決定 11)は「`read_launch_outcome` を呼び、値を返し、`die` しない」までで、薄い包みとして残すかは G4 に任せている。**包みとして残す。** 監視の `--stem-template` と食い違う stem を渡すと監視の結果ファイルを引けない(G3 の未確認 6)。stem を作る場所を `read_result(state, runtime, phase, round_no=None)` の 1 つにすれば、3 つの取り込みが同じ組み立てを通り、突き合わせのテスト(AC3)も 1 か所で書ける。名前を残すのは、親 #728 と子 issue の本文がこの名前で根本原因を指しているためである。引数を変えるため、古い形の呼び出し(`read_result(path, runtime)`)は実行時に失敗し、契約の変更を素通りしない。 呼び出し側が `read_launch_outcome` を直接呼ぶ形は採らない。stem の組み立てが 3 か所に分かれる。 -### 決定 4: 「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を `intake.py` の 1 つの手順にする +### 決定 4: 取り消しの本体を 1 つにするために、「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を `intake.py` の 1 つの手順にする 3 つの取り込みは、結果を読めたときも読めなかったときも、起点から HEAD までの範囲を確め、通らなければ範囲を取り消し、起点を取り消し後の HEAD へ進める。いまは取り消しの本体が 2 つにある(`gitfacts.revert_unverified_range` を修正と最終ゲートが、`apply._revert_unverified_apply_round` を適用が使う)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)だけである。**`refactor_lib/intake.py` を新設し、範囲の確定(`confirm_range`)・取り消し(`discard_unverified`)・結果なしの一連(`close_without_result`)を置く。** 起点の鍵と記録先の違いは `IntakeScope` の値で渡す。取り込みが持つのは、その値をどう終了コードと群の状態へ写すかだけになる(親 #728 の `consolidate_duplication`)。 置き場所を `commands/` にしないのは、`apply.py` / `converge.py` / `gate.py` の 3 つが読む層だからである(`rounds.py` と同じ理由。`commands` どうしの取り込みを作らない)。`gitfacts.py` に足す形も採らない。1158 行あり、取り消しは git の事実の読み取りではなく進行の手順である。 -### 決定 5: 結果なしの記録は 3 つの取り込みで同じ形 `failed_attempts[]` にする +### 決定 5: 要約と改修計画が 1 つの読み方で済むように、結果なしの記録は 3 つの取り込みで同じ形 `failed_attempts[]` にする 群の `failed_attempts`、修正の `fix_merged_keys` の `":missing"`、最終ゲートの独自の記録と 3 通りに分けると、実行の要約と改修計画が 3 通りの読み方を持つ。**記録の辞書(群、または `final_gate`)の `failed_attempts[]` に `{phase, attempt, impl, reason, detail, at, reverted}` を足す。** `phase` が `apply` / `fix` / `final-fix` を分け、`attempt` が叩き直しの判定(同じ `phase` と `attempt` の記録があれば読まずに 2 を返す)に使う。修正の `fix_merged_keys` は結果を読めたときの二重取り込みの判定に残し、結果なしの判定には使わない。 -### 決定 6: 同じ群の試行の上限は 2 回の固定値にし、引数を足さない +### 決定 6: 壊れた担当に当たり続けないために、同じ群の試行の上限は 2 回の固定値にし、引数を足さない 2 回目は別の担当が試すため(決定 8)、2 回とも結果を残さなければ担当ではなく群の側を疑える。3 回以上にしても壊れた担当に当たる確率が上がるだけである。値は `vocabulary.py` の `MAX_APPLY_ATTEMPTS` に置く。`--max-apply-attempts` を足す形は採らない。`SKILL.md` は上限を 2 つ置くとどちらで止まったかを読み解く必要が出るとしており、止まった理由は群の記録(`drop_reason` と `failed_attempts`)が持つ。 -### 決定 7: 開き直しの判定は `rounds.group_reopening` の 1 つに置き、`next-apply-round` と `merge-apply` の両方がそれを読む +### 決定 7: 中断からの再開と失敗のやり直しを区別するために、開き直しの判定は `rounds.group_reopening` の 1 つに置き、`next-apply-round` と `merge-apply` の両方がそれを読む いまの `next-apply-round` は `pending` / `applied` の群を無条件に開き直し、中断からの再開と失敗した試行のやり直しを区別しない。判定に要る値は群が持つ 2 つ、開いた回数(`attempt`)と結末の記録(`failed_attempts` の `phase: apply` の件数)である。**`group_reopening(group)` がこの 2 つから `open`(開いて `attempt` を進める)/ `resume`(開いたまま閉じていない試行を再開する。番号を進めない)/ `exhausted`(上限に達した。開かない)/ `empty`(項目が無い)を返す。** `next-apply-round` は開くかどうかを、`merge-apply` は結果なしを記録した後に担当を替えるか取り消すかを、同じ関数の値で決める。 開いた回数だけを数える形は採らない。進行側が落ちて再開しただけで試行が進む。監視の終了コードで骨組みが分岐する形も採らない。結果ファイルが後から書かれた場合や、監視は `OK` でも JSON が壊れている場合は結果ファイルの側で決めるしかなく、判定を 1 か所に置けば骨組みは `|| continue` のまま変わらない。 -### 決定 8: 2 回目の試行は次の輪番の担当が行い、替える先が無いときだけ結末の可否で決める +### 決定 8: 壊れた CLI が 1 者でも他の担当で群を進めるために、2 回目の試行は次の輪番の担当が行い、替える先が無いときだけ結末の可否で決める 結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の STALLED 4 回、claude の 429 の 3729 回、PR #757 の kiro)。同じ担当で開き直しても直らない。替える先は、`apply_seq` を 1 ずつ進めて `rounds.impl_for_seq` を引き、その群で失敗した担当のどれとも違う担当が出た最初の番号の担当である。1 つ進めるだけにしないのは、輪番が 1 周すると同じ担当へ戻るためである(既存の設計の「実測」: `assign(1)` と `assign(5)` はどちらも codex)。探索は参加者の数だけ進めれば全員を 1 度ずつ見るので、打ち切りの回数は固定値ではなく参加者の数から導く。 **替える先が無いとき**(参加者が 1 者。G1 の `--exclude` で残りが 1 者になる実行)は、`LaunchOutcome.relaunch_same_agent` を読む。真(`missing` / `stalled` など)なら同じ担当で 2 回目を開き、偽(`usage_limit`)なら 1 回目で群を取り消す。可否を読むのはこの分岐だけで、替える先があるときは常に替える。利用上限で進行全体を止める形は採らない。他の担当で進められる群まで止まる。 -### 決定 9: 作業を任せる担当の決定は `rounds.impl_for_seq` の 1 つを通す +### 決定 9: 参加者の決め方の変更(G1)を 1 か所で受けるために、作業を任せる担当の決定は `rounds.impl_for_seq` の 1 つを通す `rounds.impl_for_seq(state, seq)` を新設し、輪番の通し番号から担当と要求モデルを引く呼び出しはその中だけにする。呼ぶのは 3 か所で、群の担当(`apply._assign_apply_rounds_to_state`)・交代先(`apply._close_failed_attempt`)・最終ゲートの修正担当(`gate._final_fix_impl`)を決める。G1(#727)は `assignment.assign` を `impl_assign(participants, seq)` に置き換える。**どちらが先に入っても、変える場所は `impl_for_seq` の中だけである。** `setup.cmd_start_round` の担当は CLI を起動しないため通さない。 -### 決定 10: 修正の結果は群の担当から読み、結果なしは修正ラウンドを 1 つ進める。起動し直せない結末では上限へ進める +### 決定 10: 修正が上限なしに往復しないように、修正の結果は群の担当から読み、結果なしは修正ラウンドを進める。起動し直せない結末では上限へ進める `merge-fix` は提案ラウンドの担当(`entry["impl"]`)の結果を読むが、骨組みが起動するのは群の担当である。一致しない群では結果を一度も取り込めず、`fix_rounds` が進まないまま検証と修正を往復する(既存の設計の「実測」)。担当は `current_group(entry)["impl"]` から読む。結果なしは `close_without_result` を通し、`fix_rounds` を 1 進めて 2 で終わる。範囲を確定できないときの既存の扱い(`_resolve_fix_range`)と同じ形である。 **`relaunch_same_agent` が偽なら、`fix_rounds` を `--max-fix-rounds` の値にする。** 修正の担当は替えない(直しかけの文脈を持つ者が続ける、という最終ゲートの既存の理由と同じ)。替えないまま上限まで起動し直すと、利用上限の担当を最大 3 回起動して 3 回とも 15 秒で落ちる。上限へ進めれば `should-abandon` が次の呼び出しで見送りへ移し、骨組みの行は変わらない。 -### 決定 11: 最終ゲートの修正も同じ手順を通し、結果なしは 2 で終えて `final-gate` に判定を戻す +### 決定 11: 未検証のコミットを Pull Request に残さないために、最終ゲートの修正も同じ手順を通し、結果なしは 2 で終えて `final-gate` に判定を戻す `merge-final-fix` が結果なしで `die` すると、担当が作ったコミットが範囲の検査も取り消しも受けずに残る。次の `final-gate` はそのコミットを含む HEAD でテストし、落ちれば起点を HEAD へ置き直す(#674)。**`close_without_result` を通せば、コミットは取り消され、起点は取り消し後の HEAD になり、次の `final-gate` は修正前の地点でテストする。** 終了コードは 2 で、骨組みは見ずに `final-gate` へ戻る(変えない)。`final-gate` が `fix_rounds` を進めるため、繰り返しは `--max-fix-rounds` で止まる。 `relaunch_same_agent` が偽なら `gate.fix_rounds` を `--max-fix-rounds` の値にする。次の `final-gate` はテストが落ちれば 1(取り消さず報告)で終わる。既存の「Step 7 は push 済みの地点。上限に達しても採用した改善項目は取り消さない」の規則を変えない。 -### 決定 12: 採用 0 件の提案ラウンドでは群を作らず、項目の無い群は開かずに取り消す +### 決定 12: 項目の無い群で担当を起動しないために、採用 0 件の提案ラウンドでは群を作らず、項目の無い群は開かずに取り消す `rounds.apply_groups` は `if groups:` で分岐するため、鍵が無い(`None`)ときも空の配列(`[]`)のときも 1 つの群を作る。`merge-proposals` は採用 0 件で `apply_rounds = []` を書くため、ここで項目 0 件の群が生まれる。**鍵が無いときだけ古い版として群を作り、空の配列はそのまま返す。** 既に項目の無い群を持つ状態ファイル(rf587)は残る。`group_reopening` が `empty` を返した群は、`next-apply-round` が `dropped`(`drop_reason: empty`)にして次を探す。`merge-apply` の取り込み済みの判定で採用 0 件だった群も `dropped` に直す。 `merge-proposals` がテスト整備の採用 0 件で 2 を返す形は採らない。2 は構造改善の繰り返しを終える合図で、テスト整備では構造改善へ進む前に抜けてしまう。 -### 決定 13: トレーラーは末尾から続くトレーラーの段落を git の判定で読み、進行側はコミットを書き換えない +### 決定 13: 帰属行の書式に左右されずに検証を通すために、トレーラーは末尾から続く段落を git の判定で読み、進行側はコミットを書き換えない `commit_trailers` はメッセージを空行で段落に分け、末尾の段落から前へ向かって 1 段落ずつ `git interpret-trailers --parse` に掛け、git がトレーラーの段落と判定しなかった段落で止める。同じ鍵は末尾に近い段落の値を採る。**1 段落目(題名)は掛けない**(掛けると `Refactor: …` の題名をトレーラーとして読む。既存の設計の「実測」)。 @@ -101,11 +99,11 @@ G3 の契約(決定 11)は「`read_launch_outcome` を呼び、値を返し 全文から `^: ` を拾う形も採らない。散文の段落にある `Round: …` の形の行を拾う。 -### 決定 14: 雛形のコミットの規約にも、必須トレーラーを最後の段落に置くことを書く +### 決定 14: 人が git の標準の読み方でも集計できるように、雛形のコミットの規約にも必須トレーラーを最後の段落に置くことを書く 進行側の検証は決定 13 で通る。一方、人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計すると最後の段落しか読まない。帰属行を同じ段落に続けて書けば、git の標準の読み方でも取れる。従わなくても決定 13 で検証は通る。 -### 決定 15: 無進捗の許容を `--test-timeout` + 900 秒にし、雛形に進捗マーカーを足す +### 決定 15: テストの実行中の無出力で担当を打ち切らないために、無進捗の許容を `--test-timeout` + 900 秒にし、雛形に進捗マーカーを足す 既存の設計の決定 13 をそのまま引き継ぐ。適用・修正の担当はテストを 1 回実行し、その間は何も出力しない。`init` が `IMPL_STALL_TIMEOUT` を出し、骨組みの適用・修正・最終ゲートの修正の監視が `--stall-timeout` に渡す。`--timeout` は渡さない(上限は P2 の `--phase` と `lib/limits.py` が決める)。`monitor.py` は変えない。 diff --git a/issues/issue-728-647-592-553-requirements.md b/issues/issue-728-647-592-553-requirements.md index 6d3610be..56eaeb5b 100644 --- a/issues/issue-728-647-592-553-requirements.md +++ b/issues/issue-728-647-592-553-requirements.md @@ -1,4 +1,12 @@ -# #728 / #647 / #592 / #553: 取り込みが結果なしを値で受け、取り消しと群の状態を 1 か所で決める +# cross-refactoring: 実装担当が結果を残さないと同じ群が上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、群が開いた回数と結末で開き直しを決める(要求と受け入れ条件 / #728 #647 #592 #553) + +## 目的 + +- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わると、取り込みが下位の読み取りの `die` で止まり、同じ群が上限なしに開き直される。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続け、claude が担当の群は帰属行のためトレーラーが読めずに落ちる +- **困る人**: cross-refactoring を回す進行側(手で止めるまで CLI の起動と利用料が続く)と、その Pull Request を読む人(未検証の差分が混じる) +- **直すと成り立つこと**: 3 つの取り込み(適用・修正・最終ゲートの修正)が結果なしを同じ手順で受け、未検証のコミットを取り消して結末を記録し、終了コードを返す。適用ラウンドの繰り返しは有限回で終わり、群は開いた回数と前回の結末で開き直すか・担当を替えるか・取り消すかが決まる。採用 0 件では群を作らない。起動し直しても解けない結末(利用上限)では同じ担当を同じ工程で起動し直さない。帰属行の後ろでもトレーラーが読める + +## 文書の位置づけ 設計は [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md) にある。この文書は「何を満たすか」だけを扱う。 @@ -45,14 +53,6 @@ > `merge-final-fix` は `read_result` が結果ファイルの欠落で `die(code=2)` し、範囲の検査(`unassigned_fix_commits` / `verify_final_fix_commit`)と取り消しへ進まない。`final-gate` は担当が作ったコミットを含む HEAD でテストし、落ちれば `fix_base_sha` を HEAD へ置き直す。そのコミットは以後どの範囲にも入らない。**検証を受けていないコミットが Pull Request に残りうる。** -## 目的 - -- 担当が結果を残さなくても、3 つの取り込み(適用・修正・最終ゲートの修正)が同じ手順で範囲を確め、未検証のコミットを取り消し、結末を記録して終了コードを返す。プロセスを終わらせる場所は取り込みだけになる -- 適用ラウンドの繰り返しが有限回で終わる。群は開いた回数と前回の結末を持ち、開き直すか・担当を替えるか・取り消すかを 1 つの関数が決める -- 採用 0 件の提案ラウンドで群を作らず、項目の無い群を開かない -- 起動し直しても解けない結末(利用上限)では、同じ担当を同じ工程で起動し直さない -- Claude Code が帰属行を別の段落で足しても、必須トレーラーが読めて群が落ちない - ## 前提 | # | 前提 | From cbea7404e0e141b821e0c8e1c69780ef1445fbee Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 04:32:22 +0000 Subject: [PATCH 013/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=20PR=20#781?= =?UTF-8?q?=20=E3=81=AE=E6=96=87=E6=9B=B8=E3=82=92=E7=9B=AE=E7=9A=84?= =?UTF-8?q?=E3=81=8C=E8=AA=AD=E3=82=81=E3=82=8B=E5=BD=A2=E3=81=B8=E7=B5=84?= =?UTF-8?q?=E3=81=BF=E7=9B=B4=E3=81=99=EF=BC=88#729=20#619=20#584=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タイトルを「今起きている問題 → 直した後に成り立つこと」の形にし、各文書の最初の章を 「目的」にする。決定の見出しは「何のために何を決めた」と読める語にし、管理上の注記は 末尾の「この文書の位置づけ」へ寄せる。決定と受け入れ条件の中身は変えない。 Co-Authored-By: Claude Fable 5.1 --- ...62-598-537-619-584-583-design-contracts.md | 2 +- ...ue-662-598-537-619-584-583-requirements.md | 2 +- issues/issue-729-619-584-design.md | 56 +++++--- issues/issue-729-619-584-requirements.md | 131 ++++++++++-------- 4 files changed, 114 insertions(+), 77 deletions(-) diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index c29a03be..a29bb108 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -122,7 +122,7 @@ classDiagram ### 理由の語彙 -**P3 の語彙と「P3 で足す文言」は [issue-729-619-584-design.md](issue-729-619-584-design.md) の「データ構造」「入出力の契約」へ移した。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 +**P3 の語彙と「P3 で足す文言」は、結果なしの理由を共通層の 1 か所で読む設計 [issue-729-619-584-design.md](issue-729-619-584-design.md) の「データ構造」「入出力の契約」へ移した。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 **監視が書く理由**(`monitor_outcome.REASONS`): diff --git a/issues/issue-662-598-537-619-584-583-requirements.md b/issues/issue-662-598-537-619-584-583-requirements.md index 067be26e..a65f8735 100644 --- a/issues/issue-662-598-537-619-584-583-requirements.md +++ b/issues/issue-662-598-537-619-584-583-requirements.md @@ -157,7 +157,7 @@ ## 受け入れ条件(P3: #619 + #584 + #583 結果が失われる) -**この節は置き換えられた。** #619 / #584(AC50〜AC62、AC68〜AC69)は [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) が、#583(AC63〜AC67)は #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 +**#619 / #584 の受け入れ条件(AC50〜AC62、AC68〜AC69)は、利用上限で止まった担当を起動し直さず理由を報告する要求 [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) へ移した。** #583(AC63〜AC67)は #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 文言と見るファイルの一覧は、契約の文書の「P3 で足す文言」にある。 diff --git a/issues/issue-729-619-584-design.md b/issues/issue-729-619-584-design.md index d09c39c0..70822e62 100644 --- a/issues/issue-729-619-584-design.md +++ b/issues/issue-729-619-584-design.md @@ -1,11 +1,17 @@ -# #729 / #619 / #584: 結末の語彙を共通層で読み、起動し直しの可否を共通層が返す +# cross-review: 利用上限で止まった担当が missing と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(設計 / #729 #619 #584) -要求と受け入れ条件は [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) にある。この文書は「どう作るか」だけを扱う。 +## 目的 -**この文書は既存の設計 [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md) の P3 を置き換える。** 既存の決定のうち引き継ぐものと変えるものは「既存の設計との対応」にある。#583(決定 18・19)は #730 の設計へ移る。 +**起きていること。** 担当の CLI が利用上限で落ちると、監視はその文言を読めず、結果なしの理由が `missing` に畳まれる。進行側は同じ担当を起動し直し、監視の上限 1 回分を待ってから `error` で終わる(#619)。監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 + +**根本原因。** 結果なしの判断を cross-review と cross-refactoring がそれぞれ結果ファイルの有無だけで行い、監視が書いた理由を誰も読まない(#729)。 + +**この設計で成り立つこと。** 結末の語彙と起動し直しの可否を共通層 `monitor_outcome` の 1 か所に置き、両 Skill がその値を読む。利用上限は `usage_limit` として報告され、同じラウンドでは起動し直さない。止めた担当の子プロセスは結果を書かない。 ## 機能一覧 +5 つの機能のうち F1〜F3 は共通層、F4 は cross-review、F5 は起動と監視が担う。 + | # | 機能 | 誰が使うか | | --- | --- | --- | | F1 | 利用上限の文言を検知し、理由 `usage_limit` として残す | 監視。進行側と利用者が理由を読む | @@ -16,19 +22,21 @@ ## 決定の記録 -### 決定 1: 設計文書は親 #729 の名前で新設し、既存の設計文書の本体は書き換えない +12 件。決定 1 は文書の置き方、2〜3 は共通層の契約、4〜9 は語彙と検知、10 はプロセスグループ、11〜12 は両 Skill の読み方である。 + +### 決定 1: 変わった決定だけを差分に載せるため、設計文書は親 #729 の名前で新設し、既存の設計文書の本体は書き換えない 既存の設計は 6 課題・3 本の Pull Request を 1 つの文書で扱い、P1 と P2 は配布済みである。P3 の節をその場で書き換えると、配布済みの決定と新しい決定が 1 つの差分に混ざり、承認する人が「何が変わるのか」を読み分けられない。親 #729 は根本原因の場所(共通層)を定め直しており、既存の決定 14(判定が `usage_limit` を見る)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。1 行でも触ると、既存の 20 件の決定がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求の文書と契約の文書にだけ足す。 -### 決定 2: 結果なしの判断と起動し直しの可否を、共通層 `monitor_outcome` の 1 つの関数へ移す +### 決定 2: 同じ判断を両 Skill に書かないため、結果なしの判断と起動し直しの可否を共通層 `monitor_outcome` の 1 つの関数へ移す -いまは cross-review の `_read_review_result_file` と cross-refactoring の `read_result` が、それぞれ結果ファイルの有無だけで結果なしを決めている。監視が書いた理由はどちらも読まない。理由を読む処理を各 Skill に書くと、同じ表(監視の理由 → 結果なしの理由)が 2 か所にでき、語彙を足すたびに片方が古くなる(親 #729 の `move_responsibility`)。**共通層に `read_launch_outcome` を 1 つ置き、両 Skill はその値(`payload` / `reason` / `relaunch_same_agent`)を受け取るだけにする。** 語彙・対応表・可否の表は `monitor_outcome.py` だけが持つ。 +いまは cross-review の `_read_review_result_file` と cross-refactoring の `read_result` が、それぞれ結果なしを決めている。判断の材料は結果ファイルの有無だけである。監視が書いた理由はどちらも読まない。理由を読む処理を各 Skill に書くと、同じ表(監視の理由 → 結果なしの理由)が 2 か所にでき、語彙を足すたびに片方が古くなる(親 #729 の `move_responsibility`)。**共通層に `read_launch_outcome` を 1 つ置き、両 Skill はその値(`payload` / `reason` / `relaunch_same_agent`)を受け取るだけにする。** 語彙・対応表・可否の表は `monitor_outcome.py` だけが持つ。 各 Skill が `read_outcome` を呼んで自分で表を引く形(既存の G4 の設計の `apply._monitor_reason`)は採らない。表が Skill の数だけ増える。 -### 決定 3: 起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にし、`usage_limit` だけを偽にする +### 決定 3: 進行側の問いに合わせ、起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にし、`usage_limit` だけを偽にする 進行側が結末を見て決めることは「同じ担当をもう 1 度起動してよいか」に尽きる。利用上限は起動し直しても解けず、起動のたびに待ちと相手の CLI の枠を使う(#619 の 2 回目の空振り、#647 の 3729 回)。それ以外の理由(監視の上限・無進捗・致命の文言・CLI の上限・結果なし・読めない)は、対象や負荷で変わりうるため 1 度は起動し直してよい。 @@ -36,43 +44,43 @@ 理由ごとに「止める / 替える / 起動し直す」の 3 値を返す形は採らない。3 値のうち「替える」は担当の集合を知る Skill にしか決められず、共通層に置くと担当の割り当てを読み込むことになる。 -### 決定 4: 利用上限は `EARLY_ERROR`(終了コード 4)のまま、理由だけを `usage_limit` にする +### 決定 4: 終了コードで分岐する骨組みを変えないため、利用上限は `EARLY_ERROR`(終了コード 4)のまま、理由だけを `usage_limit` にする 監視の終了コードは骨組みと G4 の設計が分岐に使う(既存の設計の決定 16)。利用上限は「プロセスが続いても結果を生成できないと分かった」致命の一種で、状態としては `EARLY_ERROR` と同じである。区別が要るのは理由の側だけで、監視の結果ファイルと記録に `usage_limit` が入れば、読む側は状態を変えずに区別できる。 新しい状態(`USAGE_LIMIT`、終了コード 7)を足す形は採らない。終了コードの意味が変わり、終了コードで分岐する骨組みと文書(cross-refactoring だけで `monitor.py` の呼び出しが 8 か所)を見直すことになる。 -### 決定 5: CLI の上限は `NO_RESULT`(終了コード 3)のまま、理由だけを `cli_timeout` にする +### 決定 5: 「終わったが結果が無い」の状態を保つため、CLI の上限は `NO_RESULT`(終了コード 3)のまま、理由だけを `cli_timeout` にする agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」で正しい。文言は終了の後にだけ見る(生きている間に見ると、途中で出た警告を致命と読む)。結果ファイルがあれば理由は `ok` にする(上限に当たっても結果を書き終えていれば使える)。 -### 決定 6: 利用上限の文言は err.log を全担当で見、stdout.log は claude だけ JSON 向けの照合で見る +### 決定 6: 実物の文言に一致し引用を誤検知しないため、利用上限の文言は err.log を全担当で見、stdout.log は claude だけ JSON 向けの照合で見る kiro の `Monthly request limit reached` は err.log に出る(#619 の実物)。claude の `"api_error_status":429` はどちらに出るか未確認のため両方を見る(前提 1)。err.log は既存の `_scan_patterns`(表・引用・バッククォート・grep 形式の除外を掛ける)で見る。実測では、`"api_error_status":429` を含む JSON の 1 行も err.log の側で一致し、引用の判定に飲み込まれなかった(「実測」)。stdout.log は既存の `_scan_claude_stdout_fatal` と同じ JSON 向けの照合(除外を掛けない)で見る。JSON は 1 行に引用符を多く含み、行単位の引用の判定が「引用の内側」を誤って真にするためである。 -### 決定 7: `unparsable` を共通の語彙に入れ、`no_verdict` と `not_posted` は cross-review 固有のまま残す +### 決定 7: 両 Skill が同じ形で見る理由だけを共通にするため、`unparsable` を共通の語彙に入れ、`no_verdict` / `not_posted` は cross-review 固有に残す 結果ファイルが JSON として読めない・オブジェクトでないことは、両 Skill の読み取りが同じ形で見ている。共通の関数が結果ファイルを読む以上、この理由も共通の語彙に要る。`no_verdict`(判定の値が無い)と `not_posted`(投稿が届いていない)は結果ファイルの中身とレビューの投稿の話で、監視も cross-refactoring も知りえない。**cross-review が共通の関数の後で自分の理由を上書きする形にする。** `REASONS` は 9 語になる。`reason_for(status)` が返すのは、監視の状態から決まる 6 語のままである。`usage_limit` / `cli_timeout` は監視が結末に理由を添えたときだけ現れる。`unparsable` は読む側だけが使う。 -### 決定 8: `MonitorOutcome` に理由を持たせ、無ければ状態からの既定を使う +### 決定 8: 状態からは決まらない理由を残すため、`MonitorOutcome` に理由を持たせ、無ければ状態からの既定を使う いま `_record_outcome` は `reason_for(st.status)` で理由を決めている。利用上限と CLI の上限は状態からは決まらないため、結末を作る場所(`_early_error_outcome` / `_process_exit_outcome`)が理由を添える。`MonitorOutcome` に `reason: Optional[str] = None` を足す。`_record_outcome` は `outcome.reason or reason_for(status)` で書く。`create(status, detail)` の既存の呼び出し 8 か所は変えない。 -### 決定 9: 同じラウンドの 2 度目の結果なしで上書きされる 1 回目の理由は、監視の記録が持つ +### 決定 9: 起動し直しで上書きされる 1 回目の理由を失わないため、その理由は追記だけの監視の記録が持つ 状態ファイルの `rounds[-1].<担当>` はその担当のそのラウンドの最後の結果を持つ構造で、起動し直すと 1 回目の `no_result_reason` は 2 回目で上書きされる。これは既存の構造のままにする。**過去を失わないのは追記だけの `monitor-outcomes.jsonl` の側で**(既存の設計の決定 2)、1 回目の理由も `reason` と `ended_at` から読める。状態ファイルに履歴の配列を足す形は採らない。判定が読むのは最後の結果だけで、履歴は要約(`launches[]`)が既に持つ。 -### 決定 10: CLI を独立したプロセスグループで起動し、グループの先頭のときだけグループへシグナルを送る +### 決定 10: 止めた後に子プロセスが結果を書かないよう、CLI を独立したプロセスグループで起動し、グループの先頭のときだけグループへシグナルを送る 既存の設計の決定 17 をそのまま引き継ぐ。`launch-cli.sh` が `set -m` を有効にしてから背景で起動すると、CLI の pid がそのままプロセスグループの番号になる。監視は pid がグループの先頭であり、かつ監視自身のグループと違うときだけ `os.killpg` を使い、それ以外は従来どおり pid だけへ送る。`setsid` は macOS に標準で入っていないため採らない。`read-result` の前に結果ファイルの出現を待つ形(#584 の候補 2)も採らない。書き出しそのものを止めれば待つ理由が無い。 -### 決定 11: `gitfacts.read_result` は結果なしを `die` せず、共通の関数の値で返す契約に置き換える。実装は G4 +### 決定 11: cross-refactoring も同じ値を読むよう、`gitfacts.read_result` は結果なしを `die` せず、共通の関数の値で返す契約に置き換える。実装は G4 #728 は `read_result` が `die(code=2)` で進行の終了コードを決める向きを直す。その向きを直した先が `read_launch_outcome` の値である。G3 が決めるのは「`read_result` に相当する読み取りは `read_launch_outcome` を呼び、`payload` / `reason` / `relaunch_same_agent` を返す。終了コードを決めず、標準エラーに書かない」までで、3 つの取り込みがその値をどう扱うか(群の状態・担当の交代・終了コード)は G4 が決める。薄い包みとして残すか呼び出し側が直接呼ぶかも G4 に任せる(要求の「未決」)。 -### 決定 12: `read-result` の終了コードと `judge` の 0 / 2 / 7 / 8 は変えず、利用上限は既存の 1 の枝で終える +### 決定 12: cross-review の骨組みの行を変えないため、`read-result` の終了コードと `judge` の 0 / 2 / 7 / 8 は変えず、利用上限は既存の 1 の枝で終える 骨組み(`SKILL.md`)は `read-result` の終了コードを `|| true` で受け、`judge` の 7 / 8 / 0 / 2 で分岐し、それ以外を `exit` する。利用上限で起動し直さずに止める結末は、既存の「2 度目も結果なし → `final=error` → 1」と同じ出口へ載せる。骨組みの行は 1 つも変わらない(AC24)。 @@ -105,6 +113,8 @@ stdout JSON 向け照合: True ## 構成要素 +変えるのは共通層の 3 ファイルと cross-review の 4 ファイルで、cross-refactoring は契約だけを受け取る。 + | 要素 | 新設 / 変更 | 責務 | | --- | --- | --- | | 結末の語彙と読み取り(`lib/monitor_outcome.py`) | 変更 | 理由 9 語、起動し直しの可否の表、結末を 1 つの値として読む `read_launch_outcome`(決定 2・3・7) | @@ -450,6 +460,8 @@ sequenceDiagram ## テスト設計 +受け入れ条件 24 件を、既存のテストの形(偽の CLI を実プロセスで動かす・一時ディレクトリに結果を置く・状態ファイルを作って呼ぶ)で確かめる。 + | 受け入れ条件 | 何で確かめるか | | --- | --- | | AC1 | `REASONS` の 9 語と、6 つの状態の `reason_for` の返り値を固定する(`plugins/ndf/scripts/tests/test_monitor_outcome_unit.py`) | @@ -467,6 +479,8 @@ sequenceDiagram ## 未確認のまま残ること +6 件。実装で決めるものが 4 件、確かめないまま進めるものが 2 件(どちらでも設計が塞ぐ)である。 + | # | 項目 | 内容 | 決める時点 | | --- | --- | --- | --- | | 1 | claude の 429 の出る先 | `"api_error_status":429` が err.log と stdout.log のどちらに出るか。実物のログが手元に無い。両方を見るためどちらでも拾える | 実装で偽の claude を両方の形で試す。実物は次に上限に当たったときの記録で確かめる | @@ -478,6 +492,8 @@ sequenceDiagram ## 申し送り(並行する設計との境界) +同時に進む G4 / G5 / D-B / G1 と、どこまでをこの設計が持つかを決めた。 + | 相手 | 決めた契約 | どちらが何をするか | | --- | --- | --- | | G4(#728 #647 #592 #553) | `gitfacts.read_result` の契約(「入出力の契約」)。理由の語彙 9 語と `relaunch_same_agent` | G3 が共通層と cross-review を実装する。G4 は `read_result` に相当する読み取りを `read_launch_outcome` に置き換え、3 つの取り込みで `payload` 無しのときの群の状態・担当の交代・終了コードを決める。G4 の既存の設計の `apply._monitor_reason` と `gitfacts.load_result` は `read_launch_outcome` で置き換わる | @@ -488,6 +504,8 @@ sequenceDiagram ## 既存の設計との対応 +既存の設計(PR #666)の P3 の決定を、この文書のどの決定が引き継ぐかを示す。 + | 既存の決定(PR #666) | この文書 | 変わったこと | | --- | --- | --- | | 決定 14(利用上限は致命として止め、同じラウンドで起動し直さない) | 決定 3・4 | 「判定が `usage_limit` を見る」から「共通層の可否を見る」へ。値は同じ | @@ -498,3 +516,9 @@ sequenceDiagram | 決定 19(起動し直しを初回と同じ経路へ) | — | #730(G5)へ | | 決定 20(利用上限の検知は err.log + claude の stdout.log) | 決定 6 | 実測を足した。値は同じ | | — | 決定 1・2・7・8・9・11 | 新設 | + +## この文書の位置づけ + +この文書は「どう作るか」だけを扱う。要求と受け入れ条件は [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) にある。 + +**既存の設計 [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md) の P3 を置き換える。** 既存の決定のうち引き継ぐものと変えるものは「既存の設計との対応」にある。#583(決定 18・19)は #730 の設計へ移る。 diff --git a/issues/issue-729-619-584-requirements.md b/issues/issue-729-619-584-requirements.md index a2bb4599..ebd37d3c 100644 --- a/issues/issue-729-619-584-requirements.md +++ b/issues/issue-729-619-584-requirements.md @@ -1,65 +1,17 @@ -# #729 / #619 / #584: 担当 1 回の起動の結末を共通の語彙で読む - -設計は [issue-729-619-584-design.md](issue-729-619-584-design.md) にある。この文書は「何を満たすか」だけを扱う。 - -**この文書は、既存の設計 [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md) の P3(AC50〜AC62、AC68〜AC69)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P1(#662)と P2(#598 #537)は v10.13.0 で配布済みで、この文書は触らない。#583 は #730 の設計が持つ。 - -## 依頼(原文) - -### #729(根本原因の親) - -> **担当 1 回の起動の結末(結果ファイルの有無と、監視が打ち切った理由)を読み、起動し直してよいかを返す契約。** -> -> - 結末の語彙は `plugins/ndf/scripts/lib/monitor_outcome.py`、早期の致命の検知は `monitor.py` の `EARLY_ERROR_FATAL` にある -> - cross-review の `state.py`(`_read_review_result_file`)も cross-refactoring の `refactor_lib/gitfacts.py`(`read_result`)も語彙を読まず、結果ファイルの有無だけで判断する(Skill 側で `monitor_outcome` を読むコードは 0 件) -> - `EARLY_ERROR_FATAL` は `Monthly request limit reached` や `"api_error_status":429` の行に一致しない -> -> `read_result` が `die` で進行を決める向きは #728 が持つ。 -> -> ## 採る手 -> -> - 移動(`move_responsibility`): 結果なしの判断を、各 Skill の結果ファイルの読み取りから共通層の結末へ移す -> - 新設: 利用上限(`usage_limit`)の語彙と検知の文言 -> -> ## 完了条件 -> -> - 両 Skill が共通層の結末を読み、利用上限を理由として出し、同じラウンドでの起動し直しを止める -> - 利用上限の実際の出力(上の 2 形式)を検知することを検査が確かめる -> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる - -### #619 - -> **監視の結果(状態と `detail`)を担当ごとにファイルへ残し、`read-result` が `NO_RESULT` の理由に使う。** -> 理由は、監視の上限(timeout)・CLI 自身の上限(cli_timeout)・利用上限(usage_limit)・早期エラー(early_error)・未投稿(not_posted)・結果ファイル無し(missing)を区別する。 -> -> - 利用上限は起動し直しても解けないため、理由が `usage_limit` のときは起動し直さずに止めて報告する判断もここに置ける(cross-refactoring の #647 と共通) - -### #584 - -> 移動(`move_responsibility`)。停止の単位を pid からプロセスグループへ移す。`launch-cli.sh` は CLI を新しいプロセスグループとして起動し、`_kill_pid` はそのグループへ SIGTERM / SIGKILL を送る。 -> -> 止めた理由を読む側(結果なしの理由の語彙)は、担当 1 回の起動の結末を共通の語彙で読む #729 が持つ。 +# cross-review: 利用上限で止まった担当が missing と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(要求と受け入れ条件 / #729 #619 #584) ## 目的 -- 担当の CLI が利用上限で落ちたとき、進行側と利用者に届く理由が `usage_limit` になり、同じラウンドで同じ担当を起動し直さない -- 結果ファイルが無いときに「監視が打ち切った」「CLI が自分の上限で終わった」「終わったが結果を書かなかった」を、結末の語彙 1 つで区別できる -- 結果なしの判断と起動し直しの可否を共通層の 1 か所が持ち、cross-review と cross-refactoring がその値を読む(両 Skill が同じ判断を別々に書かない) -- 監視が止めた担当の子プロセスが、止めた後に結果ファイルを書かない +**起きていること。** 担当の CLI(kiro / claude)が月間の利用上限に当たると、監視はその文言を読めず、結果なしの理由が `missing` に畳まれる。進行側は同じ担当を同じラウンドで起動し直し、監視の上限 1 回分(レビューで 1200 秒)を待ってから `error` で終わる(#619。#647 では 3729 回の空振り)。また、監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 -## 前提 +**困る人。** 収束ループを回す進行側と、結果を待つ利用者。届く理由が `missing` のため、上限に当たったのか、監視の上限で打ち切られたのかを判別できない。 -| # | 前提 | -| --- | --- | -| 1 | claude の利用上限の文言の実物は `"api_error_status":429` を含む行である(#647 の本文)。err.log と stdout.log のどちらに出るかは未確認のため、両方を見る | -| 2 | kiro の利用上限の文言の実物は err.log の `Monthly request limit reached` である(#619 の本文、PR #601 の round 2) | -| 3 | P1(監視の結果ファイル `-monitor.json` と `monitor_outcome.read_outcome`)と P2(`limits.py`、`--phase`)は v10.13.0 で `develop` に入っている。この変更はその上に載せる | -| 4 | cross-refactoring の側の実装(`gitfacts.read_result` と 3 つの取り込み)は #728(G4)が行う。この変更が決めるのは `read_result` が従う契約だけである | -| 5 | 利用上限で止まった担当を外して残りの担当で回す判断は #478(D-B)が持つ。この変更は「同じ担当を起動し直さずに止めて理由を報告する」までである | -| 6 | 監視の終了コード 0〜6 と標準出力の 13 個のキーは変えない(既存の設計の決定 16。G4 の骨組みがこれを前提にする) | +**直すと成り立つこと。** 理由が `usage_limit` として進行側と利用者に届き、同じラウンドで同じ担当を起動し直さない。結果ファイルが無いときの「監視が打ち切った」「CLI が自分の上限で終わった」「終わったが結果を書かなかった」を、結末の語彙 1 つで区別できる。結果なしの判断と起動し直しの可否は共通層の 1 か所が持ち、cross-review と cross-refactoring がその値を読む(両 Skill が同じ判断を別々に書かない)。監視が止めた担当の子プロセスは、止めた後に結果ファイルを書かない。 ## 対象範囲 +変えるのは共通層の 3 ファイルと cross-review で、cross-refactoring は契約だけを決める。 + 含む: | 場所 | 扱うもの | @@ -87,6 +39,8 @@ ## 用語 +受け入れ条件はこの表の語で書く。 + | 用語 | 意味 | | --- | --- | | 担当 | レビュー・反証・適用・修正を行う CLI(codex / agy / kiro / claude) | @@ -100,9 +54,22 @@ | 利用上限 | 担当の CLI の月間・週間の利用枠に達し、起動し直しても解けない状態 | | CLI の上限 | 担当の CLI 自身の実行時間の上限(agy の `--print-timeout`) | +## 前提 + +利用上限の文言の実物は 2 つで、配布済みの P1 / P2 の上に載せる。 + +| # | 前提 | +| --- | --- | +| 1 | claude の利用上限の文言の実物は `"api_error_status":429` を含む行である(#647 の本文)。err.log と stdout.log のどちらに出るかは未確認のため、両方を見る | +| 2 | kiro の利用上限の文言の実物は err.log の `Monthly request limit reached` である(#619 の本文、PR #601 の round 2) | +| 3 | P1(監視の結果ファイル `-monitor.json` と `monitor_outcome.read_outcome`)と P2(`limits.py`、`--phase`)は v10.13.0 で `develop` に入っている。この変更はその上に載せる | +| 4 | cross-refactoring の側の実装(`gitfacts.read_result` と 3 つの取り込み)は #728(G4)が行う。この変更が決めるのは `read_result` が従う契約だけである | +| 5 | 利用上限で止まった担当を外して残りの担当で回す判断は #478(D-B)が持つ。この変更は「同じ担当を起動し直さずに止めて理由を報告する」までである | +| 6 | 監視の終了コード 0〜6 と標準出力の 13 個のキーは変えない(既存の設計の決定 16。G4 の骨組みがこれを前提にする) | + ## 受け入れ条件 -文言の一覧は設計文書の「入出力の契約」の「検知の文言」にある。 +24 件を 5 つの群に分ける。文言の一覧は設計文書の「入出力の契約」の「検知の文言」にある。 語彙と検知(監視、#619): @@ -113,15 +80,15 @@ | `ok` / `timeout` / `stalled` / `early_error` / `missing` / `pidfile_bad` | `usage_limit` / `cli_timeout` / `unparsable` | - [ ] AC2: err.log に `Monthly request limit reached` の行が出ると、監視は担当を止めて `EARLY_ERROR`(終了コード 4)を返す。監視の結果ファイルの `reason` は `usage_limit` である -- [ ] AC3: err.log(全担当)か stdout.log(claude だけ)に `"api_error_status"` と `429` を `:` で結んだ行が出ると、AC2 と同じく `usage_limit` になる。`:` の前後の空白の有無は問わない +- [ ] AC3: err.log(全担当)か stdout.log(claude だけ)に、`"api_error_status"` と `429` を `:` で結んだ行が出たときも AC2 と同じである。`reason` は `usage_limit` になる。`:` の前後の空白の有無は問わない - [ ] AC4: 既存の一致のうち `quota exceeded` / `rate limit exceeded` / `HTTP/<版> 429` の `reason` は `usage_limit` になる。`HTTP/<版> 401` / `HTTP/<版> 403` とそれ以外の致命の一致は `early_error` のままである -- [ ] AC5: 担当が結果ファイル無しで終わり、err.log に `print timeout after <時間> with turn in progress` があるとき、監視は `NO_RESULT`(終了コード 3)を返す。`reason` は `cli_timeout` である。同じ文言があっても結果ファイルがあれば `OK` / `ok` である +- [ ] AC5: 担当が結果ファイル無しで終わり、err.log に `print timeout after <時間> with turn in progress` がある場合を扱う。監視は `NO_RESULT`(終了コード 3)を返し、`reason` は `cli_timeout` である。同じ文言があっても結果ファイルがあれば `OK` / `ok` である - [ ] AC6: AC2〜AC5 の文言が err.log で markdown の表の行・引用・バッククォート・grep 形式の引用の中にあるときは一致しない。claude の stdout.log は JSON 向けの照合で見るため、この除外を掛けない - [ ] AC7: `usage_limit` / `cli_timeout` は監視の結果ファイルと監視の記録の `reason` に入る。監視の標準出力の 13 個のキーと値の型、終了コードは変更前と同じである 結末を 1 つの値として読む(共通層、#729): -- [ ] AC8: 結果ファイルが JSON オブジェクトとして読めるとき、`monitor_outcome.read_launch_outcome(tmp_dir, stem, result_path)` はその辞書を `payload` に持つ。`reason` は `None`、`relaunch_same_agent` は `True` である。監視の結果ファイルの `reason` が何であっても同じである +- [ ] AC8: `monitor_outcome.read_launch_outcome(tmp_dir, stem, result_path)` を呼ぶ。結果ファイルが JSON オブジェクトとして読めるとき、返り値はその辞書を `payload` に持つ。`reason` は `None`、`relaunch_same_agent` は `True` である。監視の結果ファイルの `reason` が何であっても同じである - [ ] AC9: 使える結果が無いとき、`reason` は次の表で決まる | 監視の結果ファイルの `reason` | 結果ファイル | `reason` | @@ -141,7 +108,7 @@ cross-review が値を読む(#619): - [ ] AC15: 結果なしの担当の理由に `relaunch_same_agent` が偽のもの(`usage_limit`)があるとき、`judge` は起動し直さない。`final=error` として終了コード 1 で終わり、標準エラーに担当・理由・`monitor_detail` が出る - [ ] AC16: 理由がすべて起動し直してよいものなら、`judge` は変更前と同じく終了コード 7 で `RELAUNCH_AGENTS` を返し、2 度目の結果なしで `final=error` になる。終了コード 0 / 2 / 8 の枝は変更前と同じである - [ ] AC17: `state.py report` のラウンド表で、結果なしの担当は `kiro=NO_RESULT(usage_limit)` の形で出る -- [ ] AC18: `docs/01-state-and-review.md` の理由の表に 10 個の理由が載る。10 個は AC1 の 9 語から `ok` を除いた 8 語に、`no_verdict` / `not_posted` を足したものである。`docs/03-review-output.md` の「monitor.py が誤って kill する場合の手順」に、上限に当たった場合の見分け方(`reason` と `monitor-outcomes.jsonl` の読み方)が載る +- [ ] AC18: `docs/01-state-and-review.md` の理由の表に 10 個の理由が載る。10 個は AC1 の 9 語から `ok` を除いた 8 語に、`no_verdict` / `not_posted` を足したものである。`docs/03-review-output.md` の「monitor.py が誤って kill する場合の手順」に、上限に当たった場合の見分け方が載る。見分け方は `reason` と `monitor-outcomes.jsonl` の読み方である 止めた後に書かせない(#584): @@ -165,6 +132,8 @@ cross-review が値を読む(#619): ## 影響 +監視の終了コードと標準出力は変わらず、増えるのは理由の値と状態ファイルの鍵である。 + | 対象 | 影響 | | --- | --- | | `monitor.py` の終了コードと標準出力 | 変わらない。監視の結果ファイルと記録の `reason` に 2 つの値が増える | @@ -208,6 +177,44 @@ cross-review が値を読む(#619): | --- | --- | --- | | `gitfacts.read_result` を共通の関数の薄い包みとして残すか、呼び出し側が共通の関数を直接呼ぶか | G4(#728)の設計 | G4 の設計 Pull Request | +## 依頼(原文) + +3 つの issue の本文を、書かれたままの形で引く。 + +### #729(根本原因の親) + +> **担当 1 回の起動の結末(結果ファイルの有無と、監視が打ち切った理由)を読み、起動し直してよいかを返す契約。** +> +> - 結末の語彙は `plugins/ndf/scripts/lib/monitor_outcome.py`、早期の致命の検知は `monitor.py` の `EARLY_ERROR_FATAL` にある +> - cross-review の `state.py`(`_read_review_result_file`)も cross-refactoring の `refactor_lib/gitfacts.py`(`read_result`)も語彙を読まず、結果ファイルの有無だけで判断する(Skill 側で `monitor_outcome` を読むコードは 0 件) +> - `EARLY_ERROR_FATAL` は `Monthly request limit reached` や `"api_error_status":429` の行に一致しない +> +> `read_result` が `die` で進行を決める向きは #728 が持つ。 +> +> ## 採る手 +> +> - 移動(`move_responsibility`): 結果なしの判断を、各 Skill の結果ファイルの読み取りから共通層の結末へ移す +> - 新設: 利用上限(`usage_limit`)の語彙と検知の文言 +> +> ## 完了条件 +> +> - 両 Skill が共通層の結末を読み、利用上限を理由として出し、同じラウンドでの起動し直しを止める +> - 利用上限の実際の出力(上の 2 形式)を検知することを検査が確かめる +> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる + +### #619 + +> **監視の結果(状態と `detail`)を担当ごとにファイルへ残し、`read-result` が `NO_RESULT` の理由に使う。** +> 理由は、監視の上限(timeout)・CLI 自身の上限(cli_timeout)・利用上限(usage_limit)・早期エラー(early_error)・未投稿(not_posted)・結果ファイル無し(missing)を区別する。 +> +> - 利用上限は起動し直しても解けないため、理由が `usage_limit` のときは起動し直さずに止めて報告する判断もここに置ける(cross-refactoring の #647 と共通) + +### #584 + +> 移動(`move_responsibility`)。停止の単位を pid からプロセスグループへ移す。`launch-cli.sh` は CLI を新しいプロセスグループとして起動し、`_kill_pid` はそのグループへ SIGTERM / SIGKILL を送る。 +> +> 止めた理由を読む側(結果なしの理由の語彙)は、担当 1 回の起動の結末を共通の語彙で読む #729 が持つ。 + ## 既存の受け入れ条件との対応 既存の設計(PR #666)の P3 の受け入れ条件を、この文書のどこが引き継ぐかを示す。 @@ -230,3 +237,9 @@ cross-review が値を読む(#619): | AC69 | AC18 | 同じ | | — | AC1、AC8、AC10〜AC12 | 新設(結末を 1 つの値として読む契約) | | — | AC24 | 新設(骨組みを変えない) | + +## この文書の位置づけ + +この文書は「何を満たすか」だけを扱う。設計は [issue-729-619-584-design.md](issue-729-619-584-design.md) にある。 + +**既存の要求 [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md) の P3 を置き換える。** 置き換える受け入れ条件は AC50〜AC62 と AC68〜AC69 で、対応は「既存の受け入れ条件との対応」にある。P1(#662)と P2(#598 #537)は v10.13.0 で配布済みで、この文書は触らない。#583 は #730 の設計が持つ。 From 3b27b2934a68e85ef322048a14fe0c7b6aa3ab22 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 04:34:01 +0000 Subject: [PATCH 014/217] =?UTF-8?q?Docs:=20#732=20#624=20#706=20=E3=81=AE?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E3=83=BB=E8=A8=AD=E8=A8=88=E6=96=87=E6=9B=B8?= =?UTF-8?q?=E3=82=92=E7=9B=AE=E7=9A=84=E3=81=8B=E3=82=89=E8=AA=AD=E3=82=81?= =?UTF-8?q?=E3=82=8B=E5=BD=A2=E3=81=B8=E7=B5=84=E3=81=BF=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - タイトルを「今起きている現象 → 直した後に成り立つこと」の形にし、仕組みの語を本文へ下ろす - 最初の章を「目的」にし、置き換え先の案内は末尾の「関連する文書」へ寄せる - 決定の見出しを「何のために何を決めた」と読める語にする(結論・理由は変えない) - 前提の箇条書きと決定 2 の 3 つの形を表にし、100 字を超える文を分ける Co-Authored-By: Claude Fable 5.1 --- issues/issue-624-478-648-contracts.md | 2 +- issues/issue-624-478-648-requirements.md | 2 +- issues/issue-732-624-706-design.md | 73 ++++++++++++++------- issues/issue-732-624-706-requirements.md | 80 +++++++++++++++--------- 4 files changed, 101 insertions(+), 56 deletions(-) diff --git a/issues/issue-624-478-648-contracts.md b/issues/issue-624-478-648-contracts.md index 3dee4624..7418a84d 100644 --- a/issues/issue-624-478-648-contracts.md +++ b/issues/issue-624-478-648-contracts.md @@ -5,7 +5,7 @@ 印の外し方(決定 5)で、「`state.py` の内部関数」の表の末尾 2 行と「変わらない項目の意味の変化」の先頭 2 行に 当たる。それ以外の契約は P5 のものである。 -**P4 の契約(`_classify_finding` の順 4 の条件と、`classification` の値)は [issue-732-624-706-design.md](issue-732-624-706-design.md) の「データ構造」「入出力の契約」へ移した。** 新しい設計は区分 `unrefuted` と項目 `unrefuted_reason` を足し、順 4 から根拠の条件を外す。印を外す契約(決定 5)はそのまま引き継がれた。以下の P4 の行は 2026-09-15 時点の記録として残す。 +**誰にも誤りを示されていない `major` を数える側へ改めるため、P4 の契約(`_classify_finding` の順 4 の条件と、`classification` の値)は [issue-732-624-706-design.md](issue-732-624-706-design.md) の「データ構造」「入出力の契約」が持つ。** 新しい設計は区分 `unrefuted` と項目 `unrefuted_reason` を足し、順 4 から根拠の条件を外す。印を外す契約(決定 5)はそのまま引き継がれた。以下の P4 の行は 2026-09-15 時点の記録として残す。 ## データ構造(状態ファイル) diff --git a/issues/issue-624-478-648-requirements.md b/issues/issue-624-478-648-requirements.md index f2ed511f..67e358e5 100644 --- a/issues/issue-624-478-648-requirements.md +++ b/issues/issue-624-478-648-requirements.md @@ -38,7 +38,7 @@ ## 受け入れ条件(P4: #624 と #583 の収束の部分) -**この節は置き換えられた。** P4(AC1〜AC9)は [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) が持つ(親 #732 の設計。AC3 と AC6 は数える側へ改めた)。#583 の投稿の重なりは #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 +**誰にも誤りを示されていない `major` を数える側へ改めるため、この節の受け入れ条件(P4: AC1〜AC9)は [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) が持つ。** 根拠を欠く指摘(AC3)と相手が支持しなかった指摘(AC6)を数えない、としていた条件を数える側へ改めた(親 #732)。#583 の投稿の重なりは #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 反証する担当がいない指摘を数える: diff --git a/issues/issue-732-624-706-design.md b/issues/issue-732-624-706-design.md index 5fb78970..d028e033 100644 --- a/issues/issue-732-624-706-design.md +++ b/issues/issue-732-624-706-design.md @@ -1,8 +1,10 @@ -# #732 / #624 / #706: 「数えない」区分を棄却に限り、未反証の `major` を数える +# cross-review: 反証する担当がいない・実行検証が無い・支持が付かない major が数えられず、修正の要る指摘を残して approved になる → 誤りを示された指摘と minor だけを数えない(#732 #624 #706 の設計) -要求と受け入れ条件は [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) にある。この文書は「どう作るか」だけを扱う。 +## 目的 -**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P4(決定 2〜5)を置き換える。** 引き継ぐ決定と改める決定は「既存の設計との対応」にある。P5(決定 6〜19)は #727 の設計が持つ。 +cross-review の収束の判定が、誰にも誤りを示されていない `major` の指摘を区分 `insufficient_evidence` へ落として数えない。その結果、新しい指摘 0 件として `approved` で終わる。1 者で回したループ(#624)、実行検証も支持も無い指摘(#706)、起動し直した担当の指摘(#583 の収束の部分)でこの形になり、修正の要る指摘が修正の工程へ渡らない。原因は、1 つの区分が「反証の機会があって支持されなかった」と「立証の機会が無かった」の 2 つの意味を兼ねていることにある。 + +この設計の後、数えないのは棄却された指摘(`rejected`)と `minor` 以下だけになる。誤りを示されていない `major` は新しい区分 `unrefuted` として数えられ、修正の工程へ渡る。「なぜ独立に確かめられていないか」の理由は `unrefuted_reason` として状態ファイルに残る。 ## 機能一覧 @@ -15,17 +17,25 @@ ## 決定の記録 -決定 1 は文書の形、決定 2〜6 は区分、決定 7 は測定、決定 8 は印、決定 9〜11 はプロンプト・判定の出口・確定仕様を扱う。 +決定 1 は文書の形、決定 2〜6 は区分、決定 7 は測定、決定 8 は印、決定 9〜11 はプロンプト・判定の出口・確定仕様を扱う。区分の中核は決定 2 で、決定 3〜6 はその境界を定める。 -### 決定 1: 設計文書は親 #732 の名前で新設し、既存の設計文書の本体は書き換えない +### 決定 1: 並行する設計と 1 つのファイルで競合しないよう、設計文書は親 #732 の名前で新設し、既存の設計文書の本体は書き換えない 既存の設計は 3 課題・2 本の Pull Request を 1 つの文書で扱い、P5(決定 6〜19)は #727 の設計が並行して置き換える。P4 の節をその場で書き換えると、P5 の変更と 1 つのファイルで競合し、承認する人が「どの束が何を変えたか」を読み分けられない。親 #732 は根本原因を「1 つの区分が 2 つの意味を兼ねる」と定め直しており、既存の決定 2〜4(区分の名前を増やさず、順 4 に条件を足す)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。1 行でも触ると、既存の 19 件の決定がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求の文書と契約の文書にだけ足す(#729 の設計と同じ形)。 -### 決定 2: 数えない判断を棄却と `minor` 以下に限り、誤りを示されていない `major` を新しい区分 `unrefuted` として数える +### 決定 2: 未解決の `major` を残して収束しないよう、数えない判断を棄却と `minor` 以下に限り、誤りを示されていない `major` を新しい区分 `unrefuted` として数える + +区分の順 5(`insufficient_evidence`)には、いま 7 つの形が落ちる(「実測」の A〜F・H)。そのうち `minor` 以下(C)を除く 6 つは、いずれも「誰も誤りを示していない `major`」である。 + +| 落ちる形 | 実測の記号 | +| --- | --- | +| 反証する担当がいない | A・B・H | +| 相手が `insufficient_evidence` / `out_of_scope` を返した | D・E | +| 根拠の 2 項目を欠く | B・F・H | -区分の順 5(`insufficient_evidence`)には、いま 7 つの形が落ちる(「実測」の A〜F・H)。そのうち `minor` 以下(C)を除く 6 つは、いずれも「誰も誤りを示していない `major`」である。反証する担当がいない(A・B・H)、相手が `insufficient_evidence` / `out_of_scope` を返した(D・E)、根拠の 2 項目を欠く(B・F・H)のどれも、指摘が誤りだという主張ではない。**数えないのは、指摘が誤りだと示された `rejected` と、`APPROVE` を妨げない `minor` 以下だけにする。** 誤りを示されていない `major` は `unrefuted` として数え、修正の工程へ渡す。修正の担当が直す・却下の理由を返す・範囲外として起票するのは `needs_human_judgment` と同じである。 +どれも、指摘が誤りだという主張ではない。**数えないのは、指摘が誤りだと示された `rejected` と、`APPROVE` を妨げない `minor` 以下だけにする。** 誤りを示されていない `major` は `unrefuted` として数え、修正の工程へ渡す。修正の担当が直す・却下の理由を返す・範囲外として起票するのは `needs_human_judgment` と同じである。 数えることで増えるラウンドは、指摘 1 件につき最大 1 回である。修正の工程が却下の理由を `rejected_findings` へ残し、次のラウンドのレビュープロンプトへ渡すため、同じ論点は戻らない。戻っても新規性の一致(位置・近傍・本文)が前のラウンドの指摘と結び、新規に数えない。#156 が避けた「同じ論点で 5 ラウンド」(#69)は、却下の記録が無かった頃の形である。 @@ -35,7 +45,7 @@ - **反証が 0 件のラウンドは絞り込まない(#624 の候補 1)。** ラウンド単位では、2 者のうち 1 件だけが後から入った #583 の形と、2 者で相手が `insufficient_evidence` を返した #706 の形を塞げない - **誤りを示されていない `major` を全件 `needs_human_judgment` に入れる。** 数え方は同じになるが、「独立に確かめた担当がいる」と「誰も確かめていない」が同じ名前になり、修正の担当と測定がその差を読めない。親 #732 は別の区分にすることを採る手としている -### 決定 3: 順 4(`needs_human_judgment`)の条件から根拠の 2 項目を外す +### 決定 3: 別の担当が支持した指摘が根拠の項目の欠けで落ちないよう、順 4(`needs_human_judgment`)の条件から根拠の 2 項目を外す 根拠の 2 項目(`evidence` / `falsification`)は、別の担当がその指摘を確かめるための入力である。別の担当が `support` を返した、または 2 者以上が同じ指摘へ独立に到達した時点で、確かめる目的は果たされている。その後で 2 項目の欠けを理由に落とすと、「2 人が見て同じことを言っている」情報が判定に効かない(#706 の PR #45 で `support` の付いた 2 件が落ちた形)。**順 4 は `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上**とする。 @@ -43,7 +53,7 @@ 採らない案: **根拠を欠く指摘は `unrefuted` に入れ、`needs_human_judgment` には入れない。** 順 4 と順 5 の差が「独立に確かめたか」でなく「2 項目を書いたか」で決まり、`support` の意味が薄れる。 -### 決定 4: 反証の担当が `insufficient_evidence` / `out_of_scope` だけを返した `major` も `unrefuted` として数える +### 決定 4: 立証できないことを棄却と扱わないよう、反証の担当が `insufficient_evidence` / `out_of_scope` だけを返した `major` も `unrefuted` として数える 反証の値 `insufficient_evidence` は「可能性はあるが立証できない」で、`out_of_scope` は「この Pull Request の範囲から外れる」である。どちらも指摘が誤りだという主張ではなく、規約も「`refute` の代わりに使わせない」と定めている。誤りを示せない指摘を数えずに収束させると、未解決の `major` が残ったまま `approved` になる(#706 の観測そのもの)。**担当 2 者で相手が支持しなかった `major` を数えないという #156 の判断を改める。** #624 は「#156 の設計どおりで対象外」と書いたが、親 #732 の採る手と完了条件はこの形も棄却ではない側に置く。 @@ -51,7 +61,7 @@ 採らない案: **`insufficient_evidence` を返された `major` は数えないまま残す(#156 の判断を保つ)。** 立証できない指摘と反証する担当がいない指摘を、状態ファイルから区別することはできる(`critiques` の有無)。しかし区別して前者だけを落とすと、実行検証を持たない Pull Request(文書だけの変更)では、担当が確かめられなかった `major` がすべて落ちる。#706 の PR #45 は文書の Pull Request である。 -### 決定 5: `unrefuted` の理由を `unrefuted_reason`(`no_critique` / `not_supported`)として残す +### 決定 5: 修正の担当と測定が「なぜ確かめられていないか」を読めるよう、`unrefuted` の理由を `unrefuted_reason`(`no_critique` / `not_supported`)として残す `rejected` が `rejection_reason` を持つのと同じ形で、`unrefuted` が「なぜ独立に確かめられていないか」を持つ。値は 2 つで、`no_critique`(反証を返した担当が 0 者)と `not_supported`(反証はあるが `support` も `refute` も無い)である。修正の担当は、`no_critique` なら誰も見ていない指摘、`not_supported` なら相手が確かめられなかった指摘として読める。測定は、1 者のループと 2 者のループでどちらの理由が多いかを同じ記録から読める。 @@ -59,41 +69,43 @@ 採らない案: **理由を持たず、`critiques` の有無から読む。** 読めはするが、`rejection_reason` と対になる形が崩れ、状態ファイルを読む人が区分ごとに違う導き方を覚えることになる。 -### 決定 6: `insufficient_evidence` の名前は残し、`minor` 以下の残余だけに当てる +### 決定 6: 旧い記録と語彙の付け替えを避けるため、`insufficient_evidence` の名前は残し、`minor` 以下の残余だけに当てる `major` の 4 つの形が `unrefuted` へ移った後、順 6(旧 5)に残るのは「再現も棄却もされていない `minor` 以下」だけである。名前を `not_blocking` などへ変えると、付け替える先が 3 つ同時に出る。旧い状態ファイルの `classification`、`measure.py` の読み方、`docs/04` / `docs/06` / 確定仕様の語彙である。**意味は「立証されておらず、修正必須でもない」に狭まるが、名前は変えない。** 区分の表の条件の列で意味を定める。 採らない案: **`insufficient_evidence` を `not_blocking` へ改名する。** 語彙が正確になるが、この変更の目的(数えない判断を棄却に限る)に要らない。改名は測定の比較(変更の前後の記録)を難しくする。 -### 決定 7: `measure.py` の `proposed` は数える 3 区分に揃え、2 か所の集合の一致をテストで固定する +### 決定 7: 収束の判定と測定が同じ指摘を数えるよう、`measure.py` の `proposed` を数える 3 区分に揃え、2 か所の集合の一致をテストで固定する -方式 `proposed` は「この変更の方式が採る指摘」で、定義は `state.py` の `COUNTED_CLASSIFICATIONS` と同じ集合である(`measure.py` のコメントが明記する)。`state.py` だけに `unrefuted` を足すと、収束の判定が数えた指摘を測定が採らず、`proposed` の再現率が実際より低く出る。**両方の定数を `("verified_blocking", "needs_human_judgment", "unrefuted")` にし、`test_measure.py` に 2 つの値が等しいことを見るテストを足す。** +方式 `proposed` は「この変更の方式が採る指摘」で、定義は `state.py` の `COUNTED_CLASSIFICATIONS` と同じ集合である(`measure.py` のコメントが明記する)。`state.py` だけに `unrefuted` を足すと、収束の判定が数えた指摘を測定が採らず、`proposed` の再現率が実際より低く出る。**両方の定数を `("verified_blocking", "needs_human_judgment", "unrefuted")` にする。** `test_measure.py` に 2 つの値が等しいことを見るテストを足す。 `measure.py` が `state.py` を import する形は採らない。`measure.py` は状態ファイルを読むだけの独立したスクリプトで(確定仕様の決定「測定は独立したスクリプトにする」)、`state.py` を読み込むと `gh` を呼ぶ側の前提を持ち込む。 -### 決定 8: 反証が揃わない取り込みでは、先に付いていた印を外す +### 決定 8: 反証が届いていないラウンドを全件を数える側へ戻すため、反証が揃わない取り込みでは先に付いていた印を外す 既存の設計の決定 5 をそのまま引き継ぐ。`collect-critiques` は揃わないとき印を付けないが、既に付いている印は外さない。取り直しの後も `evidence_rounds` にそのラウンドが残ると、「印を付けないため、このラウンドは全件を数えます」の出力と実際の数え方が食い違う。`_handle_incomplete_critiques` の先頭でそのラウンドの番号を `evidence_rounds` から除く。 決定 2 の後もこの決定は要る。印の無いラウンドは `rejected` と `minor` も数えるため、反証が揃っていない(`refute` が届いていないかもしれない)ラウンドでは、全件を数える側が安全である。 -### 決定 9: 反証のプロンプトに「`insufficient_evidence` は指摘を数から落とさない」を書く +### 決定 9: 誤りを示せる指摘が `insufficient_evidence` へ流れないよう、反証のプロンプトに「`insufficient_evidence` は指摘を数から落とさない」を書く 決定 4 の後、反証の担当が指摘を数から落とす手段は `refute` だけになる。プロンプトがそのことを言わないと、担当は従来どおり「判断できないものは `insufficient_evidence`」と返す。誤りを示せる指摘まで `insufficient_evidence` に流れ、修正の工程へ渡る。`critique.sh` の「返す値」の表の下に 1 段落を足す。書くのは 2 つで、`insufficient_evidence` を返しても指摘は数から落ちず修正の工程へ渡ることと、誤りを示せるなら理由を添えて `refute` を返すことである。 反証の値の語彙(5 つ)は変えない。変えるのは説明だけである。 -### 決定 10: `judge` の本体・終了コード・出力の変数は変えず、区分の内訳を新しい出力として足さない +### 決定 10: #729 が固定した境界を守るため、`judge` の本体・終了コード・出力の変数は変えず、区分の内訳を新しい出力として足さない -#729(G3)の設計が `judge` の終了コード 0 / 2 / 7 / 8 と出力の変数を境界として固定している。この変更が触るのは `_new_finding_count` から下(`_counted_finding_keys` / `_apply_classification` / `_classify_finding`)と `_handle_incomplete_critiques` で、`cmd_judge` の行は書き換えない。区分ごとの件数を `judge` の標準出力へ足す案は採らない。骨組み(`SKILL.md`)が読まない値を足しても読む側が無く、出力の契約(`docs/04`)を増やすだけである。件数は状態ファイルの `classification` から読める。 +#729(G3)の設計が `judge` の終了コード 0 / 2 / 7 / 8 と出力の変数を境界として固定している。この変更が触るのは `_new_finding_count` から下と `_handle_incomplete_critiques` である。「下」は `_counted_finding_keys` / `_apply_classification` / `_classify_finding` を指す。`cmd_judge` の行は書き換えない。区分ごとの件数を `judge` の標準出力へ足す案は採らない。骨組み(`SKILL.md`)が読まない値を足しても読む側が無く、出力の契約(`docs/04`)を増やすだけである。件数は状態ファイルの `classification` から読める。 -### 決定 11: 確定仕様の区分の表は実装 Pull Request の同じ差分で更新する +### 決定 11: 確定仕様が古いまま配布される期間を作らないため、確定仕様の区分の表は実装 Pull Request の同じ差分で更新する 確定仕様 `docs/specifications/cross-review-evidence-based.md` は区分の表・区分ごとの行き先・収束の判定の表を持つ。「数えるのは 2 区分だけ」を決定としても書いている。`plan-to-spec` の工程まで待つと、実装が配布されてから確定仕様が古いまま残る期間ができ、`check-doc-staleness.py` がそれを拾わない(確定仕様は検査の対象外である)。**区分の表・行き先の表・決定の表・テスト観点の行を、実装の差分と同じ Pull Request で直す。** 経緯の節(背景・関連リンク)は `plan-to-spec` が足す。 ## 実測 -`develop` 9eaebe14、Python 3.14.4。`_classify_finding` に最小の指摘を渡した結果である(実行検証は `not_run`、担当 1 者の指摘は `origin_runtimes` 1 者)。 +11 通りの最小の指摘を `_classify_finding` に渡し、いまの区分と変更後の区分を並べた。数え方が変わるのは A・B・D・E・F・H の 6 件で、いずれも `major` である。`minor`(C)と棄却(I・J)と再現(K)と支持つき根拠あり(G)の 5 件は変わらない。 + +`develop` 9eaebe14、Python 3.14.4。実行検証は `not_run`、担当 1 者の指摘は `origin_runtimes` 1 者である。 | 記号 | 入力 | いまの区分 | 数える | 変更後の区分 | 数える | | --- | --- | --- | --- | --- | --- | @@ -109,10 +121,10 @@ | J | `major`、`not_reproduced` | `rejected` | いいえ | `rejected` | いいえ | | K | `major`、`reproduced` | `verified_blocking` | はい | `verified_blocking` | はい | -11 件のうち、数え方が変わるのは A・B・D・E・F・H の 6 件で、いずれも `major` である。`minor`(C)と棄却(I・J)と再現(K)と支持つき根拠あり(G)の 5 件は変わらない。 - ## 構成要素 +変えるのは `state.py` の 4 つの関数・定数と `measure.py` の定数、反証のプロンプト、規約 3 文書、確定仕様、テスト 4 ファイルである。新設する要素は無い。 + | 要素 | 責務 | 変える・新設 | | --- | --- | --- | | 区分の判定(`_classify_finding`) | 6 つの区分を上から順に当てる。順 4 から根拠の条件を外し、順 5 に `unrefuted`(`major` 以上の残余)を置く(決定 2・3・4) | 変える | @@ -214,6 +226,8 @@ docs/specifications/cross-review-evidence-based.md # 区分・行き先・決 ## 処理の流れ +`judge` は印のあるラウンドだけ指摘ごとに区分を決め、数える 3 区分へ絞る。印の無いラウンドは payload の全件を数える。 + ```mermaid graph TD J[judge] --> K{payload を読めたか} @@ -245,7 +259,7 @@ graph TD ## テスト設計 -置き場所は `plugins/ndf/skills/cross-review/tests/` の下である。 +置き場所は `plugins/ndf/skills/cross-review/tests/` の下である。区分は `_classify_finding` の単体で、数え方は状態ファイルを組んだ `_new_finding_count` と `cmd_judge` の終了コードで確かめる。 | 受け入れ条件 | 何で確かめるか | 置き場所 | | --- | --- | --- | @@ -267,6 +281,8 @@ graph TD ## 未確認のまま残ること +6 件である。実装で決めるもの 2 件(テストの置き場所、プロンプトの文言)と、配布後の運用か別の課題で決まるもの 4 件に分かれる。 + | 項目 | 内容 | いつ決まるか | | --- | --- | --- | | 収束までのラウンド数の増え方 | 実測の D・E(相手が `insufficient_evidence` を返した `major`)を数えることで、2 者のループのラウンド数がどれだけ増えるかは測っていない | 配布後の運用で `measure.py` の出力を見る | @@ -285,9 +301,9 @@ graph TD | #727(G1) | `--only` の意味づけ(使える者を 1 者へ絞る)と担当の決め方は G1 が持つ。1 者のときに何を数えるかはこの設計が持つ(処理の流れの最後の段落)。G1 の実装が 1 者で回す分岐を入れても、この設計が先に入っていれば #624 の誤った収束を踏まない。**既存の要求の文書と契約の文書に足す案内の段落は G1 も同じファイルへ足す可能性がある。** 節が違うため衝突は起きにくいが、起きたら後からマージする側が解く | | #728(G4) | 触るファイルが重ならない(`refactor_lib` は区分を持たない) | -## 既存の設計との対応 +## 置き換える既存の設計との対応 -既存の設計(PR #667、2026-09-15)の P4 の決定との対応である。P5 の決定 6〜19 は #727 の設計が持つ。 +**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P4(決定 2〜5。PR #667、2026-09-15)を置き換える。** P5(決定 6〜19)は #727 の設計が持つ。 | 既存の決定 | この文書 | 扱い | | --- | --- | --- | @@ -296,3 +312,12 @@ graph TD | 決定 3(順 4 の `support` の条件だけを外し、根拠と重大度の条件は残す) | 決定 2・3 | **改める。** 根拠の条件を外し、支持も反証も無い `major` は別の区分へ入れる。重大度の条件(`minor` を数えない)は引き継ぐ | | 決定 4(区分の名前を増やさない) | 決定 2・6・7 | **改める。** `unrefuted` を足す。増やさない理由だった 2 か所の集合と語彙の同期は、テストと同じ差分で受ける | | 決定 5(反証が揃わない取り込みでは先に付いていた印を外す) | 決定 8 | 引き継ぐ | + +## 関連する文書 + +この文書は「どう作るか」だけを扱う。 + +| 文書 | 何を持つか | +| --- | --- | +| [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC22) | +| [issue-624-478-648-design.md](issue-624-478-648-design.md) | 置き換える前の設計(P4)。P5 は #727 の設計が持つ | diff --git a/issues/issue-732-624-706-requirements.md b/issues/issue-732-624-706-requirements.md index 086bba36..cf3b09e4 100644 --- a/issues/issue-732-624-706-requirements.md +++ b/issues/issue-732-624-706-requirements.md @@ -1,11 +1,15 @@ -# #732 / #624 / #706: cross-review の「数えない」区分を棄却に限る +# cross-review: 反証する担当がいない・実行検証が無い・支持が付かない major が数えられず、修正の要る指摘を残して approved になる → 誤りを示された指摘と minor だけを数えない(#732 #624 #706 の要求) -設計は [issue-732-624-706-design.md](issue-732-624-706-design.md) にある。この文書は「何を満たすか」だけを扱う。 +## 目的 + +cross-review の収束の判定が、誰にも誤りを示されていない `major` の指摘を「数えない」区分へ落とし、新しい指摘 0 件として `approved` で終わる。反証する担当がいない 1 者のループ(#624)、実行検証も支持も無い指摘(#706)、起動し直した担当の指摘(#583 の収束の部分)でこの形になり、修正の要る指摘が修正の工程へ渡らない。 -**この文書は、既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の P4(AC1〜AC9)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P5(#478 #648)は #727 の設計が持ち、この文書は触らない。#583 は #730 の設計が持つ。 +この変更の後、数えないのは棄却された指摘(実行して再現しなかった・`refute` を受けた)と `minor` 以下の指摘だけになる。誰にも誤りを示されていない `major` は、担当の数・反証の有無・根拠の項目の有無によらず新しい指摘として残り、修正の工程へ渡る。上の 3 つの場面は同じ 1 つの直しで数えられ、`insufficient_evidence` が 2 つの意味を兼ねる状態が解けて、区分を読めば未解決の理由が分かる。 ## 依頼(原文) +3 件の依頼はいずれも、指摘が数えない区分へ落ちたまま収束する現象を報告している。#732 が根本原因と採る手を定め、#624 と #706 がそれぞれの再現を持つ。 + ### #732(根本原因の親) > **cross-review の収束で「数えない」とする区分。** 場所は `plugins/ndf/skills/cross-review/scripts/state.py` の `_classify_finding`(3443 行目)と `COUNTED_CLASSIFICATIONS`(3431 行目)、規約 `docs/06-evidence.md` の区分の表である。 @@ -51,27 +55,18 @@ > > 対処の候補: 実行検証ができない指摘は `insufficient_evidence` ではなく別の区分(未検証)として新規性に数える、または他の担当の `support` が付いた指摘は区分によらず数える。 -## 目的 - -- 収束の判定が数えないのは、棄却された指摘(実行して再現しなかった・`refute` を受けた)と `minor` 以下の指摘だけになる。誰にも誤りを示されていない `major` は、担当の数・反証の有無・根拠の項目の有無によらず新しい指摘として残り、修正の工程へ渡る -- `insufficient_evidence` が「反証の機会があって支持されなかった」と「立証の機会が無かった」の 2 つを兼ねる状態を解き、区分を読めば未解決の理由が分かる -- 1 者で回したループ(#624)と、起動し直した担当の指摘(#583 の収束の部分)と、実行検証も支持も無い指摘(#706)が、同じ 1 つの直しで数えられる - -## 前提 - -- 前提 1: 修正の工程(`fix`)は Pull Request の未解決のスレッドを区分によらず全件読む。区分は収束の判定と測定だけが読む(`plugins/ndf/skills/fix/` と `docs/02-fix-and-rotation.md` に区分を読む箇所が無い。`grep -rn classification` が 0 件)。**この前提が崩れると、数えるだけで修正へ渡らない指摘が生まれ、同じ指摘が毎ラウンド新規に見える** -- 前提 2: 却下した指摘は `rejected_findings` に位置と理由つきで残り、次のラウンドのレビュープロンプトへ渡る(#156 の 1 本目)。数える区分が増えても、同じ論点が戻ることはこの記録が止める -- 前提 3: 新規性の一致(位置・近傍・本文)は変えない。前のラウンドと一致する指摘は、区分によらず新規に数えない - ## 対象範囲 +変えるのは、区分の判定と数える集合、印の外し方、それらの説明と確定仕様、テストである。`--only` の扱い・投稿の経路・終了コードは並行する設計が持つ。 + 含む: - `state.py` の区分の判定(`_classify_finding` / `_apply_classification`)と、数える区分の集合(`COUNTED_CLASSIFICATIONS`。`measure.py` の同名の定数も) - `state.py` の反証が揃わないときの印の外し方(`_handle_incomplete_critiques`) - 反証のプロンプト(`critique.sh`)の `insufficient_evidence` の説明 -- `cross-review` の `docs/04` / `docs/05` / `docs/06` と、確定仕様 `docs/specifications/cross-review-evidence-based.md` の区分の表 -- テスト(`tests/test_classify_findings.py` / `tests/test_critiques.py` / `tests/test_measure.py` / `tests/test_skill_layout.py`) +- `cross-review` の `docs/04` / `docs/05` / `docs/06` の区分の表 +- 確定仕様 `docs/specifications/cross-review-evidence-based.md` の区分の表 +- テスト 4 ファイル(`tests/test_classify_findings.py` / `tests/test_critiques.py` / `tests/test_measure.py` / `tests/test_skill_layout.py`) 含まない: @@ -88,6 +83,16 @@ | システムの文脈・配置の図 | 動くのは `state.py` の 1 プロセスで、外部との出入り(`gh`)は変わらない | | `CHANGELOG.md` と版数 | 配布の工程が書く | +## 前提 + +数える区分を増やしても、修正の工程が全件を読み、却下の記録が同じ論点を止め、新規性の一致が前のラウンドの指摘を新規に数えない。この 3 つが成り立つことを前提にする。 + +| # | 前提 | 崩れたときに起きること | +| --- | --- | --- | +| 1 | 修正の工程(`fix`)は Pull Request の未解決のスレッドを区分によらず全件読む。区分は収束の判定と測定だけが読む(`plugins/ndf/skills/fix/` と `docs/02-fix-and-rotation.md` に区分を読む箇所が無い。`grep -rn classification` が 0 件) | 数えるだけで修正へ渡らない指摘が生まれ、同じ指摘が毎ラウンド新規に見える | +| 2 | 却下した指摘は `rejected_findings` に位置と理由つきで残り、次のラウンドのレビュープロンプトへ渡る(#156 の 1 本目) | 数える区分が増えたとき、同じ論点が戻る。この記録がそれを止める | +| 3 | 新規性の一致(位置・近傍・本文)は変えない。前のラウンドと一致する指摘は、区分によらず新規に数えない | — | + ## 用語 | 用語 | 意味 | @@ -104,22 +109,28 @@ ## 受け入れ条件 +22 件である。区分 12 件(AC1〜AC12)、印 1 件(AC13)、退行しない 4 件(AC14〜AC17)、文書 3 件(AC18〜AC20)、全体 2 件(AC21〜AC22)。#624 の再現は AC1、#706 の再現は AC5 と AC7、#583 の収束の部分は AC4 が確かめる。 + ### 区分(#624 / #706 / #583 の収束の部分) -- [ ] AC1: 前提: 担当が 1 者(`only: "codex"`)で、印の付いたラウンドが 1 つあり、そのラウンドに根拠の 2 項目を持つ `major` の指摘が 1 件、反証は 0 件、前のラウンドは無い +- [ ] AC1: 前提: 担当が 1 者(`only: "codex"`)で、印の付いたラウンドが 1 つある。そのラウンドに根拠の 2 項目を持つ `major` の指摘が 1 件、反証は 0 件、前のラウンドは無い 操作: `_new_finding_count` と `judge` を呼ぶ 結果: `_new_finding_count` が `(1, True)` を返し、`judge` が終了コード 2 で終わる。指摘の `classification` は `unrefuted`(変更前は `(0, True)` と終了コード 0。#624 の再現) -- [ ] AC2: AC1 の指摘が根拠の 2 項目のどちらかを欠く(`has_evidence` が偽)とき、`_new_finding_count` は `(1, True)` を返し、`classification` は `unrefuted`、`has_evidence` は偽のまま残る -- [ ] AC3: AC1 の指摘が `minor` のとき、`_new_finding_count` は `(0, True)` を返し、`classification` は `insufficient_evidence` である -- [ ] AC4: 前提: 担当が `agy` + `kiro` で印の付いたラウンドに、`kiro` の指摘は反証を持ち、`agy` の根拠を持つ `major` が反証 0 件のまま入っている(起動し直した担当の指摘が、反証を取り込んだ後に取り込まれた形) +- [ ] AC2: 前提: AC1 の指摘が根拠の 2 項目のどちらかを欠く(`has_evidence` が偽) + 結果: `_new_finding_count` は `(1, True)` を返す。`classification` は `unrefuted` で、`has_evidence` は偽のまま残る +- [ ] AC3: 前提: AC1 の指摘が `minor` である + 結果: `_new_finding_count` は `(0, True)` を返し、`classification` は `insufficient_evidence` である +- [ ] AC4: 前提: 担当が `agy` + `kiro` で、印の付いたラウンドがある。そのラウンドで `kiro` の指摘は反証を持ち、`agy` の根拠を持つ `major` は反証 0 件のまま入っている(起動し直した担当の指摘が、反証を取り込んだ後に取り込まれた形) 結果: `_new_finding_count` が `agy` の 1 件を数える。`classification` は `unrefuted`(#583 の収束の部分) -- [ ] AC5: 担当 2 者で、相手が根拠を持つ `major` へ `insufficient_evidence` を返し、`support` も `refute` も無いとき、`classification` は `unrefuted` で、数える(変更前は `insufficient_evidence` で数えない。#706 の「反証の担当が `support` を返さなかった」) +- [ ] AC5: 前提: 担当 2 者で、相手が根拠を持つ `major` へ `insufficient_evidence` を返し、`support` も `refute` も無い + 結果: `classification` は `unrefuted` で、数える(変更前は `insufficient_evidence` で数えない。#706 の「反証の担当が `support` を返さなかった」) - [ ] AC6: AC5 で相手が `out_of_scope` を返したときも、`classification` は `unrefuted` で、数える -- [ ] AC7: `major` の指摘に `support` が 1 件以上あるとき、`has_evidence` の真偽によらず `classification` は `needs_human_judgment` である(変更前は `has_evidence` が偽なら `insufficient_evidence`。#706 の「`support` が付いていても根拠の 2 項目の欠落で落ちる」) +- [ ] AC7: 前提: `major` の指摘に `support` が 1 件以上ある + 結果: `has_evidence` の真偽によらず `classification` は `needs_human_judgment` である。変更前は `has_evidence` が偽なら `insufficient_evidence` だった(#706 の「`support` が付いていても根拠の 2 項目の欠落で落ちる」) - [ ] AC8: `origin_runtimes` が 2 者以上の `major` は、`has_evidence` の真偽によらず `needs_human_judgment` である - [ ] AC9: `refute` が 1 件以上、または実行検証が `not_reproduced` の指摘は `rejected` になり、`unrefuted` より先に当たる。`reproduced` の指摘は `verified_blocking` / `verified_non_blocking` になる(変更前と同じ) -- [ ] AC10: `unrefuted` の指摘は `unrefuted_reason` を持ち、値は `no_critique`(反証を返した担当が 0 者)か `not_supported`(反証はあるが `support` も `refute` も無い)のどちらかである。他の区分の指摘は `unrefuted_reason` を持たない(区分が変わったときは消える) -- [ ] AC11: `COUNTED_CLASSIFICATIONS` は `verified_blocking` / `needs_human_judgment` / `unrefuted` の 3 つで、`state.py` と `measure.py` の値が一致する +- [ ] AC10: `unrefuted` の指摘は `unrefuted_reason` を持つ。値は `no_critique`(反証を返した担当が 0 者)か `not_supported`(反証はあるが `support` も `refute` も無い)のどちらかである。他の区分の指摘は `unrefuted_reason` を持たない(区分が変わったときは消える) +- [ ] AC11: `COUNTED_CLASSIFICATIONS` は `verified_blocking` / `needs_human_judgment` / `unrefuted` の 3 つである。`state.py` と `measure.py` の値が一致する - [ ] AC12: `measure.py` の方式 `proposed` は、印のあるラウンドの `unrefuted` の指摘を `found` に数える ### 反証の取り直しで揃わないときは印を外す @@ -135,11 +146,11 @@ - [ ] AC16: `tests/test_classify_findings.py` の既存のテストのうち、期待値を変えるのは旧い区分を固定した 4 件だけである。それ以外は期待値を変えずに通る。4 件は次のとおり `test_support_without_evidence_is_insufficient` / `test_nothing_matched_is_insufficient` / `test_a_finding_without_verification_is_readable` / `test_only_two_classifications_are_counted` -- [ ] AC17: `judge` の終了コード(0 / 2 / 7 / 8 / 1)と標準出力の変数(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS` / `RELAUNCH_AGENTS`)は変わらない。`cmd_judge` の本体の行を書き換えない +- [ ] AC17: `judge` の終了コード(0 / 2 / 7 / 8 / 1)は変わらない。標準出力の変数(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS` / `RELAUNCH_AGENTS`)も変わらない。`cmd_judge` の本体の行を書き換えない ### 文書 -- [ ] AC18: `docs/06-evidence.md` の区分の表が 6 行になり、`unrefuted` の行と「数えるのは 3 つ」を持つ。`docs/04-contracts.md` の `classification` の項が 6 区分と数える 3 つを書く。`docs/05-pool-and-convergence.md` の終了基準が、担当 1 者と起動し直した担当の指摘の数え方を書く。次の 4 つがそれぞれ 1 行以上を出す +- [ ] AC18: 規約の 3 文書が新しい区分を書く。`docs/06-evidence.md` の区分の表が 6 行になり、`unrefuted` の行と「数えるのは 3 つ」を持つ。`docs/04-contracts.md` の `classification` の項が 6 区分と数える 3 つを書く。`docs/05-pool-and-convergence.md` の終了基準が、担当 1 者と起動し直した担当の指摘の数え方を書く。次の 4 つがそれぞれ 1 行以上を出す ```bash grep -n "unrefuted" plugins/ndf/skills/cross-review/docs/06-evidence.md @@ -149,7 +160,7 @@ ``` - [ ] AC19: `critique.sh` のプロンプトが「`insufficient_evidence` を返しても指摘は数から落ちない。誤りを示せるなら `refute` を返す」ことを書く。`grep -n "数から落ち" plugins/ndf/skills/cross-review/scripts/critique.sh` が 1 行以上を出す -- [ ] AC20: `docs/specifications/cross-review-evidence-based.md` の区分の表と区分ごとの行き先の表が、`docs/06-evidence.md` と同じ 6 区分を持つ。`grep -c "unrefuted" docs/specifications/cross-review-evidence-based.md` が 2 以上を出す +- [ ] AC20: 確定仕様 `docs/specifications/cross-review-evidence-based.md` が `docs/06-evidence.md` と同じ 6 区分を持つ。持つのは区分の表と区分ごとの行き先の表である。`grep -c "unrefuted" docs/specifications/cross-review-evidence-based.md` が 2 以上を出す ### 全体 @@ -209,9 +220,9 @@ | --- | --- | --- | | `unrefuted` を数えることで収束までのラウンド数がどれだけ増えるか | 実装の後の運用で `measure.py` の出力を見る | 配布後 | -## 既存の受け入れ条件との対応 +## 置き換える既存の受け入れ条件との対応 -既存の設計(PR #667、2026-09-15)の P4 の受け入れ条件 AC1〜AC9 との対応である。 +**この文書は、既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の P4(AC1〜AC9。PR #667、2026-09-15)を置き換える。** P5(#478 #648)は #727 の設計が持ち、この文書は触らない。#583 の投稿の重なりは #730 の設計が持つ。 | 既存 | この文書 | 扱い | | --- | --- | --- | @@ -224,3 +235,12 @@ | AC7(`origin_runtimes` 2 者の区分は変わらない) | AC8 | 引き継ぐ。根拠の条件は外す | | AC8(印なしのラウンドと実行検証の区分は変わらない) | AC9 / AC15 / AC16 | 引き継ぐ。期待値を変える既存のテスト 4 件を名指しする | | AC9(文書の 3 つの grep) | AC18 | 引き継ぐ。語を `unrefuted` に変える | + +## 関連する文書 + +この文書は「何を満たすか」だけを扱う。 + +| 文書 | 何を持つか | +| --- | --- | +| [issue-732-624-706-design.md](issue-732-624-706-design.md) | どう作るか(決定の記録・実測・データ構造・契約・処理の流れ・テスト設計) | +| [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) | 置き換える前の受け入れ条件(P4)。P5 は #727 の設計が持つ | From a7bb32c8f3b68975fd17fa8ccae9d22a49d13a56 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 04:46:21 +0000 Subject: [PATCH 015/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=20PR=20#782?= =?UTF-8?q?=20=E3=81=AE=203=20=E6=96=87=E6=9B=B8=E3=82=92=E7=9B=AE?= =?UTF-8?q?=E7=9A=84=E3=81=8C=E8=AA=AD=E3=82=81=E3=82=8B=E5=BD=A2=E3=81=B8?= =?UTF-8?q?=E7=B5=84=E3=81=BF=E7=9B=B4=E3=81=99=EF=BC=88#785=20=E3=81=AE?= =?UTF-8?q?=E8=A6=8F=E7=B4=84=E3=80=82#727=20#687=20#478=20#664=20#648?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - タイトルと H1 を「Skill 名: 今起きている問題 → 直した後に成り立つこと」の形にする - 最初の章を「目的」(壊れていること・困る人・直すと成り立つこと)にし、管理上の注記は「文書の位置づけ」へ寄せる - 章を読み手の問う順(結論 → 根拠 → 中身 → 手順 → 受け入れ条件)へ並べ替え、各章の先頭に要点の 1 文を置く - 決定の見出し 20 件を「何のために何を決めた」の形にする(中身は変えない) - 100 字を超える文を分け、「含む」の列挙を表にする。設計文書は段落の折り返しを外して 500 行の上限に収める - 既存の要求・契約の文書に足した置き換え先の案内を、目的が読める形にする Co-Authored-By: Claude Fable 5.1 --- issues/issue-624-478-648-contracts.md | 4 +- issues/issue-624-478-648-requirements.md | 4 +- issues/issue-727-687-478-664-648-contracts.md | 50 ++-- issues/issue-727-687-478-664-648-design.md | 228 ++++++--------- .../issue-727-687-478-664-648-requirements.md | 275 ++++++++---------- 5 files changed, 232 insertions(+), 329 deletions(-) diff --git a/issues/issue-624-478-648-contracts.md b/issues/issue-624-478-648-contracts.md index 7609ccf1..cd605224 100644 --- a/issues/issue-624-478-648-contracts.md +++ b/issues/issue-624-478-648-contracts.md @@ -5,9 +5,7 @@ 印の外し方(決定 5)で、「`state.py` の内部関数」の表の末尾 2 行と「変わらない項目の意味の変化」の先頭 2 行に 当たる。それ以外の契約は P5 のものである。 -**P5 の契約は [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) が置き換える** -(親 #727 の設計、2026-09-19)。6 項目は 1 つの `participants` に畳まれ、`review_assign` は `review_seats` に、 -`check_auth` は `probe_auth` に替わる。この文書の P5 の節は記録として残し、書き換えない。 +**P5 の契約は [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) が置き換える**(親 #727 の設計、2026-09-19)。置き換え先は、使える者だけで収束ループを開始し、再開で渡した引数を反映するための状態ファイル・引数・関数の形である。6 項目は 1 つの `participants` に畳まれ、`review_assign` は `review_seats` に、`check_auth` は `probe_auth` に替わる。この文書の P5 の節は記録として残し、書き換えない。 ## データ構造(状態ファイル) diff --git a/issues/issue-624-478-648-requirements.md b/issues/issue-624-478-648-requirements.md index d85116ff..b98a5aff 100644 --- a/issues/issue-624-478-648-requirements.md +++ b/issues/issue-624-478-648-requirements.md @@ -5,9 +5,7 @@ **3 つの課題は 2 本の Pull Request で直す**(設計文書の決定 1)。P4 は #624 と、#583 のうち収束の誤りの部分を 直す。P5 は #478 と #648 を直す。マージは P4 → P5 の順である。受け入れ条件も Pull Request ごとに分ける。 -**P5(#478 / #648、AC10〜AC30)は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) が -置き換える**(親 #727 の設計、2026-09-19)。対応は置き換え先の「既存の受け入れ条件との対応」にある。この文書の P5 の -節は記録として残し、書き換えない。 +**P5(#478 / #648、AC10〜AC30)は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) が置き換える**(親 #727 の設計、2026-09-19)。置き換え先は、参加する CLI が 1 者でも使えないと収束ループを開始できない形と、再開で渡した引数が黙って無視される形を、両 Skill が共有する共通層で直す要求である。対応は置き換え先の「既存の受け入れ条件との対応」にある。この文書の P5 の節は記録として残し、書き換えない。 ## 目的 diff --git a/issues/issue-727-687-478-664-648-contracts.md b/issues/issue-727-687-478-664-648-contracts.md index 928d8c03..a554a0d0 100644 --- a/issues/issue-727-687-478-664-648-contracts.md +++ b/issues/issue-727-687-478-664-648-contracts.md @@ -1,13 +1,21 @@ -# #727 / #687 / #478 / #664 / #648: 状態ファイル・引数・関数の契約 +# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(契約 / #727 #687 #478 #664 #648) -[issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) の続きである。決定の理由は設計文書の -「決定の記録」にあり、この文書は形だけを書く。 +## 目的 -**この文書は既存の契約 [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) の P5 の部分を置き換える。** -既存の 6 項目(`available_reviewers` ほか)は 1 つのオブジェクト `participants` に畳み、cross-refactoring も同じ形を持つ。 +- **壊れていること**: 状態ファイルは使える者の記録を持たず、`init` は認証の確認を 1 件でも通らないと止まる。再開の `init` は渡された引数を状態へ重ねず、黙って捨てる。cross-refactoring は提案と適用で母集合を 2 つ持つ +- **困る人**: 両 Skill の `init` / `start-round` / `read-result` / 起動スクリプトを実装する人と、状態ファイルを読む `report` / 監視の側 +- **直すと成り立つこと**: 使える者の解決の結果を `participants` の 1 つのオブジェクトが持ち、席の名前・引数・関数の形が両 Skill で同じになる。状態に載る引数は再開で「反映する」か「知らせる」のどちらかに必ず載る。この変更の前に始めた実行の状態ファイルは書き換えずに読める + +## 文書の位置づけ + +[issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) の続きである。決定の理由は設計文書の「決定の記録」にあり、この文書は形だけを書く。 + +**この文書は既存の契約 [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) の P5 の部分を置き換える。** 既存の 6 項目(`available_reviewers` ほか)は 1 つのオブジェクト `participants` に畳み、cross-refactoring も同じ形を持つ。 ## データ構造(状態ファイル) +両 Skill の状態ファイルに `participants` と `resume_changes` の 2 項目が増える。cross-refactoring からは `impl_capable` とラウンドの `reviewers` が消える。既存の状態ファイルは書き換えない。 + ### 両 Skill の最上位に増える 2 項目 | 項目 | 型 | 空を許すか | 意味 | @@ -28,8 +36,7 @@ | `require_all` | 真偽値 | 許さない | `--require-all` の値。新規の既定は `false` | | `fallback` | 文字列の配列 | 許す(空) | **cross-review だけ。** 席の埋め合わせに使える者(ホストの確認が通れば `[host]`)。`available` が 2 者以上か `only` があれば空 | -`resume_changes[]` の要素は既存の契約と同じ(`at` / `field` / `from` / `to`)。`field` は状態ファイルの鍵で、 -`participants` を作り直したときは `participants` の 1 件として積む(中の項目ごとには積まない)。 +`resume_changes[]` の要素は既存の契約と同じ(`at` / `field` / `from` / `to`)。`field` は状態ファイルの鍵で、`participants` を作り直したときは `participants` の 1 件として積む(中の項目ごとには積まない)。 ### 変わらない項目の意味の変化 @@ -90,8 +97,7 @@ erDiagram ### 時系列の扱い -`participants` と `runtimes` は上書きし、過去の値は `resume_changes` に事象として積む。ラウンドごとの担当は -`rounds[]` が持つため、上書きで失われるのは「どの時点でどの一覧だったか」だけで、それを `resume_changes` が補う。 +`participants` と `runtimes` は上書きし、過去の値は `resume_changes` に事象として積む。ラウンドごとの担当は `rounds[]` が持つため、上書きで失われるのは「どの時点でどの一覧だったか」だけで、それを `resume_changes` が補う。 ### 移行 @@ -103,12 +109,12 @@ erDiagram | cross-refactoring | `participants` | `runtimes` から `impl_assign` で輪番を決める。`impl_capable` は読まない | | 両方 | `resume_changes` | 空として読む | -再開で担当に関わる引数を渡したときだけ、`participants` を作り直して書く。渡さない再開では書き足さない。 -作り直すときの入力は、渡した引数と、渡さなかった引数の状態ファイルの値(`participants.included` / `excluded` / `require_all`、 -最上位の `only`)である。 +再開で担当に関わる引数を渡したときだけ、`participants` を作り直して書く。渡さない再開では書き足さない。作り直すときの入力は、渡した引数と、渡さなかった引数の状態ファイルの値(`participants.included` / `excluded` / `require_all`、最上位の `only`)である。 ## 入出力の契約 +`init` の引数と出力、`start-round` と `read-result` の受け口、`report` の節、共通層と各 Skill の関数の形を書く。 + ### `state.py init`(cross-review)の引数 | 引数 | 型 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | @@ -133,8 +139,7 @@ erDiagram | `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / `--test-timeout` | `None` | 無ければ現行の既定(3 / 2 / 3 / 5 / 900) | 渡せば反映(`replace`) | 既定を `None` へ | | `--model RT=MODEL` / `--host` / `--scope` / `--baseline-test` / `--ci-check` / `--severity-threshold` / `--sync-command` / `--plan-file` / `--workflow-step` / `--worktree-root` | `None`(`--scope` と `--baseline-test` は必須のまま) | 無ければ現行の既定 | 反映しない。状態と違えば 1 行(`notify`) | 再開での知らせを追加 | -**状態ファイルに載る引数は、`replace` か `notify` のどちらかに必ず載る**(設計文書の決定 13)。載らないのは状態に載らない -引数(cross-review の `--worktree` / `--focus` / `--extra-instructions-file`)だけである。 +**状態ファイルに載る引数は、`replace` か `notify` のどちらかに必ず載る**(設計文書の決定 13)。載らないのは状態に載らない引数(cross-review の `--worktree` / `--focus` / `--extra-instructions-file`)だけである。 **名前の検査は 2 段に分かれる。** @@ -145,8 +150,7 @@ erDiagram ### `init` の出力と終了コード -標準出力の `KEY=VALUE` は、cross-refactoring から `IMPL_POOL` が消えるほかは変えない。**増えるのは標準エラーの -行だけである。** +標準出力の `KEY=VALUE` は、cross-refactoring から `IMPL_POOL` が消えるほかは変えない。**増えるのは標準エラーの行だけである。** | 場面 | 標準エラーに出るもの | cross-review | cross-refactoring | 状態ファイル | | --- | --- | --- | --- | --- | @@ -192,9 +196,7 @@ erDiagram - 再開で変えた値: 2026-09-19T12:00:00 participants … → … ``` -`participants` を持たない状態ファイルでは「使える者: 記録なし」と出す。`probe_skipped` が真のときは「確認を通らなかった者: -確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。cross-refactoring の `report` は現行の「提案・レビュー」と「適用の母集合」の -2 行を「参加者」の 1 行にし、`participants` を持てば同じ節を足す。 +`participants` を持たない状態ファイルでは「使える者: 記録なし」と出す。`probe_skipped` が真のときは「確認を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。cross-refactoring の `report` は現行の「提案・レビュー」と「適用の母集合」の 2 行を「参加者」の 1 行にし、`participants` を持てば同じ節を足す。 ### 共通層の関数(`lib/assignment.py`) @@ -218,9 +220,7 @@ erDiagram | 1 | その 1 者と、`fallback` のうち `available` に含まれない先頭の者。そのような者が無ければ同じランタイムの 2 つ目(`<名前>-2`) | | 0 | `fallback` の先頭と、その 2 つ目(`<名前>-2`)。`fallback` が空なら `AssignmentError` | -埋め合わせの候補は `available` に含まれない者だけを使い、含まれる者は飛ばす。同じ席の名前を 2 つ返さないための規則で、 -`available=["claude"], fallback=["claude"]` は `["claude", "claude-2"]` になる(`--include` でホストが使える者に入った -cross-review)。 +埋め合わせの候補は `available` に含まれない者だけを使い、含まれる者は飛ばす。同じ席の名前を 2 つ返さないための規則である。例は `--include` でホストが使える者に入った cross-review である。`available=["claude"], fallback=["claude"]` は `["claude", "claude-2"]` になる。 ### 共通層の関数(`lib/auth.py`) @@ -237,8 +237,7 @@ cross-review)。 | `apply_resume_args(state, args, spec)` | 状態、argparse の名前空間、反映の表 | 標準エラーへ出す行の一覧。`state` を書き換え、`resume_changes` に積む。書き込みは呼び出し側が 1 回で行う | 上げない | **新設** | | `ResumeField(arg, key, mode)` | 引数の属性名、状態ファイルの鍵、`replace` / `notify` | — | — | **新設** | -`spec` は Skill ごとの表で、`mode` が `replace` の項目は `None` でない値を状態へ書き、`notify` の項目は状態と違うときだけ -「反映しない」の行を返す。値が同じ項目は行を返さず、`resume_changes` にも積まない。 +`spec` は Skill ごとの表で、`mode` が `replace` の項目は `None` でない値を状態へ書き、`notify` の項目は状態と違うときだけ「反映しない」の行を返す。値が同じ項目は行を返さず、`resume_changes` にも積まない。 ### `state.py` の内部関数(cross-review) @@ -273,5 +272,4 @@ for r in $REVIEWERS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true "$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" $REVIEWERS ``` -**`--max-rounds` と `--rotate-after` も値があるときだけ渡す。** 現行の骨組みは `"$MAX_ROUNDS"` を常に渡すため、再開のたびに -利用者が指定していない値で上書きする。 +**`--max-rounds` と `--rotate-after` も値があるときだけ渡す。** 現行の骨組みは `"$MAX_ROUNDS"` を常に渡すため、再開のたびに利用者が指定していない値で上書きする。 diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md index 7c025fe4..b4640c3c 100644 --- a/issues/issue-727-687-478-664-648-design.md +++ b/issues/issue-727-687-478-664-648-design.md @@ -1,21 +1,23 @@ -# #727 / #687 / #478 / #664 / #648: 使える者の決定と席の埋め方を共通層へ移す +# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(設計 / #727 #687 #478 #664 #648) -要求と受け入れ条件は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) にある。 -この文書は「どう作るか」だけを扱う。状態ファイル・引数・関数の形は -[issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) にある。 +## 目的 -**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P5(決定 6〜19)を置き換える。** -引き継ぐ決定と変える決定は末尾の「既存の設計との対応」にある。P4(決定 2〜5)は #732 の設計が持つ。 +- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の `init` が止まり、収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを `--only` などの引数を変えて再開しても、引数が黙って無視される(#648) +- **困る人**: 4 つの CLI が揃っていない環境で収束ループを回す利用者と、中断したループを進め方を変えて再開する利用者 +- **直すと成り立つこと**: 使える者の決定・席の埋め方・再開の反映の 3 つの規則を、両 Skill が共有する共通層が 1 か所ずつ持つ。両 Skill の `init` は共通層の結果を状態ファイルと終了コードへ写すだけになり、1 者欠けても止まらない。cross-review は毎ラウンド 2 席を確保し、cross-refactoring の既定の参加者は codex / kiro とホストになる -**実装は 2 本の Pull Request に分ける**(決定 19)。 +## 文書の位置づけ -| Pull Request | 中身 | 受け入れ条件 | -| --- | --- | --- | -| P6 | 共通層の新設(`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` / `apply_resume_args`)と cross-review 側 | AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 | -| P7 | cross-refactoring 側、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)の削除、`CLAUDE.md` | AC7、AC31〜AC43、AC47、AC49〜AC50 | +要求と受け入れ条件は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) にある。この文書は「どう作るか」だけを扱う。状態ファイル・引数・関数の形は [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) にある。 + +**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P5(決定 6〜19)を置き換える。** 引き継ぐ決定と変える決定は末尾の「既存の設計との対応」にある。P4(決定 2〜5)は #732 の設計が持つ。 + +**実装は 2 本の Pull Request に分ける**(決定 19)。分け方は「実装の分け方」にある。 ## 機能一覧 +6 つの機能を、使う人と出す Pull Request と対で並べる。 + | # | 機能 | 誰が使うか | Pull Request | | --- | --- | --- | --- | | F1 | 確認を通らない者を外し、使える者で収束ループを始める。使えない者と理由を残す | CLI の一部が導入・認証されていない利用者 | P6 / P7 | @@ -27,6 +29,8 @@ ## 決定の記録 +20 件を 6 つの塊に分ける。 + | 決定 | 扱うこと | | --- | --- | | 1 | 文書の置き方 | @@ -36,43 +40,23 @@ | 13〜18 | 再開と骨組み | | 19〜20 | 分け方と規則の置き場所 | -### 決定 1: 設計文書は親 #727 の名前で新設し、既存の設計文書の本体は書き換えない +### 決定 1: 通過済みの決定と新しい決定を差分で読み分けるために、設計文書は親 #727 の名前で新設し、既存の本体は書き換えない -既存の設計は P4 と P5 を 1 つの文書で扱い、P4 は #732 が別に進める。P5 の節をその場で書き換えると、#732 が読む P4 の -決定と、この変更で変わる P5 の決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** -既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの -`## 決定の記録` の見出しをすべて写すため、1 行でも触ると既存の 19 件がこの Pull Request の決定として並ぶ。案内は -`## 決定の記録` を持たない要求と契約の文書にだけ足す(#729 の設計と同じ扱い)。 +既存の設計は P4 と P5 を 1 つの文書で扱い、P4 は #732 が別に進める。P5 の節をその場で書き換えると、#732 が読む P4 の決定と、この変更で変わる P5 の決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。そのため 1 行でも触ると既存の 19 件がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求と契約の文書にだけ足す(#729 の設計と同じ扱い)。 -### 決定 2: 使える者の決定を共通層 `resolve_participants` に移し、両 Skill の `init` は終了コードへ写すだけにする +### 決定 2: 使える者を決める規則を 2 か所に持たないために、決定を共通層 `resolve_participants` に移し、両 Skill の `init` は終了コードへ写すだけにする -いまは cross-review の `_auth_targets` / `_validate_only` と cross-refactoring の `cmd_init` が、それぞれ母集合を作って -`check_auth` を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・ -確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 の `move_responsibility`)。**共通層に `resolve_participants` を -1 つ置き、母集合・ホスト・`--include` / `--exclude` / `--only`・確認・`--require-all` から `Participants` を返す。** -`--exclude` と `--only` の名前が母集合に含まれるかの検査も、ホストを受け取るこの関数が持つ(cross-review では -ホストが母集合に無いため、`--exclude <ホスト>` はここで弾かれる。cross-refactoring ではホストが母集合にあるため -外せる)。Skill が持つのは、 -その値を状態ファイルへ書くことと、`AssignmentError` を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写す -ことだけである。 +いまは cross-review の `_auth_targets` / `_validate_only` と cross-refactoring の `cmd_init` が、それぞれ母集合を作る。どちらも `check_auth` を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 の `move_responsibility`)。**共通層に `resolve_participants` を 1 つ置く。** 入力は母集合・ホスト・`--include` / `--exclude` / `--only`・確認・`--require-all` で、`Participants` を返す。 `--exclude` と `--only` の名前が母集合に含まれるかの検査も、ホストを受け取るこの関数が持つ。cross-review ではホストが母集合に無いため、`--exclude <ホスト>` はここで弾かれる。cross-refactoring ではホストが母集合にあるため外せる。Skill が持つのは 2 つだけである。その値を状態ファイルへ書くことと、`AssignmentError` を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写すことである。 -既存の設計の決定 10(cross-refactoring は変えず、`check_auth` を残す)は採らない。cross-review だけを直すと、同じ層を -使う cross-refactoring に「1 者欠けると `init` ごと失敗する」形が残る(#664)。 +既存の設計の決定 10(cross-refactoring は変えず、`check_auth` を残す)は採らない。cross-review だけを直すと、同じ層を使う cross-refactoring に「1 者欠けると `init` ごと失敗する」形が残る(#664)。 -### 決定 3: 確認は止めない `probe_auth` に 1 本化し、確認コマンドは変えない +### 決定 3: 1 者の失敗で全体を止めずに使える者を残すために、確認は止めない `probe_auth` に 1 本化し、確認コマンドは変えない -`check_auth` は確認と中断を 1 つの関数が持つため、「使える者で回す」経路から呼べない。`probe_auth` は結果だけを返し、 -止めるかどうかは `resolve_participants` の `require_all` が決める。**両 Skill が `probe_auth` へ移った時点で `check_auth` は -呼び手を失うため消す**(P7)。確認コマンド(`AUTH_PROBES`)と未認証の文言は変えない。モデルを引く最小の呼び出しへ -替える判断は所要の実測が要るため #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口と、通らなかった -理由を `unavailable` に残す形である。 +`check_auth` は確認と中断を 1 つの関数が持つため、「使える者で回す」経路から呼べない。`probe_auth` は結果だけを返し、止めるかどうかは `resolve_participants` の `require_all` が決める。**両 Skill が `probe_auth` へ移った時点で `check_auth` は呼び手を失うため消す**(P7)。確認コマンド(`AUTH_PROBES`)と未認証の文言は変えない。モデルを引く最小の呼び出しへ替える判断は所要の実測が要るため #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口と、通らなかった理由を `unavailable` に残す形である。 -### 決定 4: 参加者は「母集合の既定 + `--include` − `--exclude`」で決め、既定は Skill ごとの関数が持つ +### 決定 4: ホストが変わっても一覧を書き直さずに済むように、参加者は「母集合の既定 + `--include` − `--exclude`」で決め、既定は Skill ごとの関数が持つ -外したい理由は「この担当が落ちる」であり、名指しするのは外す側である(`--exclude`)。戻したい理由は「既定から外れて -いる者を入れたい」で、これも名指しである(`--include`)。使う側を並べる `--reviewers codex,kiro` の形は、ホストが変わると -一覧を書き直すことになるため採らない。**既定は Skill ごとの関数が持ち、`resolve_participants` はどちらを渡されても同じ -規則で解決する。** +外したい理由は「この担当が落ちる」であり、名指しするのは外す側である(`--exclude`)。戻したい理由は「既定から外れている者を入れたい」で、これも名指しである(`--include`)。使う側を並べる `--reviewers codex,kiro` の形は、ホストが変わると一覧を書き直すことになるため採らない。**既定は Skill ごとの関数が持ち、`resolve_participants` はどちらを渡されても同じ規則で解決する。** | Skill | 既定を返す関数 | 中身 | | --- | --- | --- | @@ -81,21 +65,15 @@ cross-review の `--include` はホストを母集合へ入れる用途になる(#687 の「ホストの参加を許す」の明示的な形)。 -### 決定 5: cross-refactoring の母集合を 1 つにし、`impl_capable` / `IMPL_POOL` / `impl_pool()` を消す +### 決定 5: 提案と適用を同じ者で回すために、cross-refactoring の母集合を 1 つにし、`impl_capable` / `IMPL_POOL` / `impl_pool()` を消す -既定の参加者を codex / kiro / ホスト(表 `DEFAULT_REFACTOR_RUNTIMES` とホスト)にすると、提案の母集合と適用の母集合が -同じ集合になる。`impl_pool()` を関数として -残した理由(「両者は一致しない」)が消えるため、関数・状態ファイルの項目・`init` の出力変数の 3 つを消す。`runtimes` は -提案の取り込みと `prepare-worktrees.sh` が読むため残し、適用の輪番も同じ値を読む。 +既定の参加者を codex / kiro / ホスト(表 `DEFAULT_REFACTOR_RUNTIMES` とホスト)にすると、提案の母集合と適用の母集合が同じ集合になる。`impl_pool()` を関数として残した理由(「両者は一致しない」)が消えるため、関数・状態ファイルの項目・`init` の出力変数の 3 つを消す。`runtimes` は提案の取り込みと `prepare-worktrees.sh` が読むため残し、適用の輪番も同じ値を読む。 -agy を既定から外す理由は #664 の実測にある。CLI の起動 199 回のうち失敗は agy の 7 回(STALLED 4 / NO_RESULT 3)だけで、 -提案の所要の中央値も agy が最も長い(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で -決まっていた。戻す手段は `--include agy` である。 +agy を既定から外す理由は #664 の実測にある。CLI の起動 199 回のうち失敗は agy の 7 回(STALLED 4 / NO_RESULT 3)だけで、提案の所要の中央値も agy が最も長い(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で決まっていた。戻す手段は `--include agy` である。 -### 決定 6: cross-refactoring のレビュー担当を消し、`assign()` を `impl_assign()` に置き換える +### 決定 6: 存在しない役の記録を読み手に見せないために、cross-refactoring のレビュー担当を消し、`assign()` を `impl_assign()` に置き換える -cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-review` が担う。`assign()` が返すレビュー担当が流れる先は -3 つだけである。 +cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-review` が担う。`assign()` が返すレビュー担当が流れる先は 3 つだけである。 | 流れる先 | 読む側 | | --- | --- | @@ -103,31 +81,21 @@ cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-re | `start-round` の `REVIEWERS` / `REVIEWERS_CSV` | 無い(骨組み・文書・プロンプトで `grep -rn -i reviewer` が 0 件) | | `record_observed_model` のレビュー側の枝 | 無い(呼び出しは `role="impl"` の 1 か所だけ) | -**#664 の「使える者が 2 者だとレビュー担当が 1 者になる」は、存在しない役の記録の話である。** 役を消せば、席を埋める -規則を cross-refactoring に持ち込む必要が無い。`impl_assign(round_no, participants)` は実装担当 1 者だけを返す。 +**#664 の「使える者が 2 者だとレビュー担当が 1 者になる」は、存在しない役の記録の話である。** 役を消せば、席を埋める規則を cross-refactoring に持ち込む必要が無い。`impl_assign(round_no, participants)` は実装担当 1 者だけを返す。 レビュー担当を「記録のためだけに」残す案は採らない。読み手が「このラウンドはこの 2 者がレビューした」と読む。 -### 決定 7: 適用の輪番は `participants[round_no % n]` の式と `ALL_RUNTIMES` の順を保つ +### 決定 7: ホストが最初に適用する形にならないように、適用の輪番は `participants[round_no % n]` の式と `ALL_RUNTIMES` の順を保つ -いまの `assign()` は `pool[round_no % 4]` で、ラウンド 1 が codex から始まる。式を変えずに `n` を参加者の数にすれば、 -ホスト claude の既定(`["claude", "codex", "kiro"]`)でも codex → kiro → claude の順になる。ホストが最初に適用する形に -ならない(「実測」)。ラウンド 1 から順に並べる式(`(round_no - 1) % n`)は、ホストが先頭に来るため採らない。 +いまの `assign()` は `pool[round_no % 4]` で、ラウンド 1 が codex から始まる。式を変えずに `n` を参加者の数にすれば、ホスト claude の既定(`["claude", "codex", "kiro"]`)でも codex → kiro → claude の順になる。ホストが最初に適用する形にならない(「実測」)。ラウンド 1 から順に並べる式(`(round_no - 1) % n`)は、ホストが先頭に来るため採らない。 -### 決定 8: 提案者と適用者が同じランタイムになることを避けない +### 決定 8: 適用ラウンドの群の分け方を変えないために、提案者と適用者が同じランタイムになることを避けない -ホストが提案に入るため、ある項目の提案者と適用者が同じランタイムになりうる。適用ラウンドは複数の提案者の項目を -1 つの群にまとめるため、群ごとに提案者を避けると群の分け方そのものを変えることになる。**避けない。** 適用の結果は -検証(テスト)と Step 7 の `cross-review` が見る。#687 の「コードを書いたランタイムが担当に入ってよい」と同じ判断である。 +ホストが提案に入るため、ある項目の提案者と適用者が同じランタイムになりうる。適用ラウンドは複数の提案者の項目を 1 つの群にまとめるため、群ごとに提案者を避けると群の分け方そのものを変えることになる。**避けない。** 適用の結果は検証(テスト)と Step 7 の `cross-review` が見る。#687 の「コードを書いたランタイムが担当に入ってよい」と同じ判断である。 -### 決定 9: cross-review の席は 2 つで、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める +### 決定 9: 使える者が足りなくても毎ラウンド 2 つの目で見るために、cross-review の席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める -#687 の 4 つの規則のうち「各ラウンドで 2 者」を最優先に置き、残る 3 つを埋める順序として読む。**違うランタイムを -先に使う。** 同じモデルの 2 つの文脈より、違うモデルの 2 つの文脈のほうが観点が分かれる。ホストは既定の母集合に -無いため、使える者が 1 者のときだけ席に入る。同じランタイムの 2 つ目(`<名前>-2`)は、ホストも使えないときの最後の -手段である。**埋め合わせの候補は使える者に含まれない者だけを使う。** `--include` でホストが使える者に入っているとき、 -ホストを埋め合わせにも使うと同じ席の名前が並ぶ(`["claude", "claude"]`)。含まれる者は飛ばし、それでも 2 席に -満たなければ同じランタイムの 2 つ目を充てる(`available=["claude"], fallback=["claude"]` → `["claude", "claude-2"]`)。 +#687 の 4 つの規則のうち「各ラウンドで 2 者」を最優先に置き、残る 3 つを埋める順序として読む。**違うランタイムを先に使う。** 同じモデルの 2 つの文脈より、違うモデルの 2 つの文脈のほうが観点が分かれる。ホストは既定の母集合に無いため、使える者が 1 者のときだけ席に入る。同じランタイムの 2 つ目(`<名前>-2`)は、ホストも使えないときの最後の手段である。**埋め合わせの候補は使える者に含まれない者だけを使う。** `--include` でホストが使える者に入っているとき、ホストを埋め合わせにも使うと同じ席の名前が並ぶ(`["claude", "claude"]`)。含まれる者は飛ばす。それでも 2 席に満たなければ同じランタイムの 2 つ目を充てる。例は `available=["claude"], fallback=["claude"]` → `["claude", "claude-2"]` である。 | 使える者の数 | 席 | | ---: | --- | @@ -136,46 +104,31 @@ cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-re | 1 | その 1 者とホスト。ホストが使えないか、その 1 者と同じなら同じランタイムの 2 つ目 | | 0 | ホストとその 2 つ目。ホストも使えなければ失敗 | -`--only` は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。ホストの確認も行わない。 -使える者が 2 者のとき輪番で -1 者を外す案は、毎ラウンド 1 席になるため採らない。 +`--only` は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。ホストの確認も行わない。使える者が 2 者のとき輪番で 1 者を外す案は、毎ラウンド 1 席になるため採らない。 -### 決定 10: 担当の単位を「席の名前」にし、形は `<ランタイム>` か `<ランタイム>-<2〜9>` とする +### 決定 10: 同じランタイムの 2 つ目を結果ファイルと状態ファイルで区別するために、担当の単位を「席の名前」にし、形は `<ランタイム>` か `<ランタイム>-<2〜9>` とする -同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。**1 つ目の席の名前は -ランタイム名そのままにする。** 埋め合わせが要らない実行では、いまと同じ名前しか現れない。接尾辞の区切りは `-` で、 -ランタイム名に `-` を含むものが無いため、シェルの `${SEAT%%-*}` と Python の `seat_runtime` が同じ規則になる。 -stem を逆に解析して担当名へ戻す箇所は無い(「実測」)ため、stem 側の変更は無い。 +同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。**1 つ目の席の名前はランタイム名そのままにする。** 埋め合わせが要らない実行では、いまと同じ名前しか現れない。接尾辞の区切りは `-` で、ランタイム名に `-` を含むものが無いため、シェルの `${SEAT%%-*}` と Python の `seat_runtime` が同じ規則になる。stem を逆に解析して担当名へ戻す箇所は無い(「実測」)ため、stem 側の変更は無い。 -受け口は 10 か所で、いずれも「CLI を選ぶ分岐に `seat_runtime` を通す」か「`choices` を席の形の検査に替える」の -どちらかである(「構成要素」の表)。`--only` と `--host` はランタイム名のままで、席の名前を取らない。 +受け口は 10 か所で、いずれも「CLI を選ぶ分岐に `seat_runtime` を通す」か「`choices` を席の形の検査に替える」のどちらかである(「構成要素」の表)。`--only` と `--host` はランタイム名のままで、席の名前を取らない。 -### 決定 11: 担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む +### 決定 11: 再開で `only` を変えても過去のラウンドの担当が変わらないように、担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む ```text ラウンドの reviewers → only → participants の席 → host の輪番(review_pool)→ codex / agy ``` -既存の決定 12 と同じ理由である。再開で `only` を変えられるようにすると、`only` を先に見る現行の順では過去のラウンドの -担当まで変わる。`_guard_previous_round` も `prev["reviewers"]` を渡す。現行は担当を渡さず `codex` / `agy` で数えるため、 -担当が `agy` + `kiro` のラウンドで `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 +既存の決定 12 と同じ理由である。再開で `only` を変えられるようにすると、`only` を先に見る現行の順では過去のラウンドの担当まで変わる。`_guard_previous_round` も `prev["reviewers"]` を渡す。現行は担当を渡さず `codex` / `agy` で数えるため、担当が `agy` + `kiro` のラウンドで `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 -### 決定 12: `--require-all` で従来の関門を選べるようにする +### 決定 12: 全員が揃わないなら始めたくない運用のために、`--require-all` で従来の関門を選べるようにする -全員が揃わないなら始めたくない運用のために残す(既存の決定 9)。付けると、確認を通らない者が 1 者でもいれば -従来の文言で失敗する。`--exclude` で外した者は揃っていなくてよい。両 Skill に同じ引数を置く。 +全員が揃わないなら始めたくない運用のために残す(既存の決定 9)。付けると、確認を通らない者が 1 者でもいれば従来の文言で失敗する。`--exclude` で外した者は揃っていなくてよい。両 Skill に同じ引数を置く。 -### 決定 13: 再開の反映を共通層 `apply_resume_args` に置き、Skill ごとの表で「反映する」「知らせる」を決める +### 決定 13: 黙って捨てる引数を残さないために、再開の反映を共通層 `apply_resume_args` に置き、Skill ごとの表で「反映する」「知らせる」を決める -#648 の修正レイヤーは `statefile.py` である。cross-review の `_resume_from_state` だけを直すと、cross-refactoring の -再開経路(`args` 由来の項目を 1 つも反映しない)が残る。**共通層は「`None` でない引数を状態へ書き、`resume_changes` に -積み、反映しない引数は状態と違うときだけ知らせる」だけを持ち、どの引数がどちらかは Skill ごとの表が持つ。** +#648 の修正レイヤーは `statefile.py` である。cross-review の `_resume_from_state` だけを直すと、cross-refactoring の再開経路(`args` 由来の項目を 1 つも反映しない)が残る。**共通層は「`None` でない引数を状態へ書き、`resume_changes` に積み、反映しない引数は状態と違うときだけ知らせる」だけを持ち、どの引数がどちらかは Skill ごとの表が持つ。** -**状態ファイルに載る引数は、表のどちらかに必ず載る。** 載らない引数は状態に載らないもの(`--worktree` / `--focus` / -`--extra-instructions-file`)だけである。黙って捨てる引数を残さないためで、`--baseline-test` のように再開でも渡す -必須の引数も「違えば知らせる」に載る。そのため状態に載る引数の既定はすべて `None` にし、新規の経路が定数の既定へ -置き換える。既定値と同じ値なら渡していないとみなす案は、`--max-rounds 12` で 20 から 12 へ戻す指定を区別できないため -採らない。 +**状態ファイルに載る引数は、表のどちらかに必ず載る。** 載らない引数は状態に載らないもの(`--worktree` / `--focus` / `--extra-instructions-file`)だけである。黙って捨てる引数を残さないためで、`--baseline-test` のように再開でも渡す必須の引数も「違えば知らせる」に載る。そのため状態に載る引数の既定はすべて `None` にし、新規の経路が定数の既定へ置き換える。既定値と同じ値なら渡していないとみなす案は、`--max-rounds 12` で 20 から 12 へ戻す指定を区別できないため採らない。 「知らせる」に置く引数は 2 種類ある。 @@ -184,50 +137,33 @@ stem を逆に解析して担当名へ戻す箇所は無い(「実測」)た | 変えると過去のラウンドと突き合わせられなくなる | `--host` / `--scope` / `--model` | | 初期化の時点で 1 度だけ効く | `--baseline-test` / `--worktree-root` / `--plan-file` など | -### 決定 14: 担当に関わる引数を渡した再開でだけ、確認し直して参加者を作り直す +### 決定 14: 途中で担当が入れ替わって前のラウンドと突き合わせられなくならないように、担当に関わる引数を渡した再開でだけ、確認し直して参加者を作り直す -`--only` / `--exclude` / `--include` / `--require-all` のいずれかを渡した再開では、決定 2 と同じ手順で参加者を作り直す。 -**渡さなかった引数は状態ファイルの値で補う。** `--include claude` で始めた実行へ `--exclude agy` だけを渡した再開では、 -`included` は `["claude"]` のまま残り、`excluded` だけが `["agy"]` になる。渡さなかった引数を初期値へ戻すと、「明示した引数だけを -反映する」(決定 13)が破れる。 -いずれも渡さない再開では確かめ直さない(途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる)。 -作り直した結果が失敗(0 者、`--require-all` で欠け)なら、状態ファイルを書き換えずに終了コードで終わる。反映は再開の後に -開くラウンドから効く(要求の前提 3)。 +`--only` / `--exclude` / `--include` / `--require-all` のいずれかを渡した再開では、決定 2 と同じ手順で参加者を作り直す。**渡さなかった引数は状態ファイルの値で補う。** `--include claude` で始めた実行へ `--exclude agy` だけを渡した再開では、`included` は `["claude"]` のまま残る。`excluded` だけが `["agy"]` になる。渡さなかった引数を初期値へ戻すと、「明示した引数だけを反映する」(決定 13)が破れる。いずれも渡さない再開では確かめ直さない(途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる)。作り直した結果が失敗(0 者、`--require-all` で欠け)なら、状態ファイルを書き換えずに終了コードで終わる。反映は再開の後に開くラウンドから効く(要求の前提 3)。 -### 決定 15: 再開で指定を外す値は `none` にする +### 決定 15: 骨組みが空文字列を渡さない形でも指定を外せるように、再開で指定を外す値は `none` にする -`--only none` は `only` を `null` へ、`--exclude none` / `--include none` は一覧を空へ戻す。空文字列は骨組みの -`${ONLY:+--only "$ONLY"}` が渡さないため、外す手段にならない。新規の経路で `none` を渡すと、渡さないのと同じになる。 +`--only none` は `only` を `null` へ、`--exclude none` / `--include none` は一覧を空へ戻す。空文字列は骨組みの `${ONLY:+--only "$ONLY"}` が渡さないため、外す手段にならない。新規の経路で `none` を渡すと、渡さないのと同じになる。 -### 決定 16: 再開で変えた値は `resume_changes` に積み、参加者の作り直しは 1 件として積む +### 決定 16: 途中から誰を外したかを完了報告で読めるように、再開で変えた値は `resume_changes` に積み、参加者の作り直しは 1 件として積む -上書きだけでは、どの時点で何を変えたかが失われ、完了報告で「途中から agy を外した」ことが読めない。`participants` の -中の項目ごとに積むと 1 回の再開で最大 7 件になり、報告で読みにくい。`participants` 全体の前後を 1 件に積む。 +上書きだけでは、どの時点で何を変えたかが失われ、完了報告で「途中から agy を外した」ことが読めない。`participants` の中の項目ごとに積むと 1 回の再開で最大 7 件になり、報告で読みにくい。`participants` 全体の前後を 1 件に積む。 -### 決定 17: 骨組みは `$ONLY` で絞らず、`start-round` が返す席を使う +### 決定 17: 状態ファイルとシェル変数がずれても起動と監視が誰かに当たるように、骨組みは `$ONLY` で絞らず、`start-round` が返す席を使う -`start-round` の `REVIEWERS` は `only` と席の埋め合わせを反映済みである。シェル変数でもう一度絞ると、状態ファイルと -シェル変数がずれたときに起動も監視も誰にも当たらない(#648 の 3 段の経過)。`SKILL.md` と `docs/01` の Step 2 と Step 2.5 を -`$REVIEWERS` / `$REVIEWERS_CSV` に揃え、`$ONLY` は `init` へ渡す 1 行にだけ残す(既存の決定 18)。 +`start-round` の `REVIEWERS` は `only` と席の埋め合わせを反映済みである。シェル変数でもう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も誰にも当たらない(#648 の 3 段の経過)。`SKILL.md` と `docs/01` の Step 2 と Step 2.5 を `$REVIEWERS` / `$REVIEWERS_CSV` に揃える。`$ONLY` は `init` へ渡す 1 行にだけ残す(既存の決定 18)。 -### 決定 18: 起動した後に分かる使えなさで、担当を自動的に外す仕組みは作らない +### 決定 18: 一時的な打ち切りで担当が恒久的に外れないように、起動した後に分かる使えなさで担当を自動的に外す仕組みは作らない -利用上限やモデルの 404 は起動した後に分かり、分類は #729(G3)が持つ。自動で外すと、一時的な打ち切りでも以後の -ラウンドから恒久的に外れる。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る(既存の決定 19)。 +利用上限やモデルの 404 は起動した後に分かり、分類は #729(G3)が持つ。自動で外すと、一時的な打ち切りでも以後のラウンドから恒久的に外れる。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る(既存の決定 19)。 -### 決定 19: P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削除)の順に 2 本で出す +### 決定 19: P6 で cross-refactoring を壊さないために、P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削除)の順で出す -共通層の新しい関数は P6 で入れ、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)は P7 で消す。P6 の -時点で旧関数を消すと cross-refactoring が壊れる。1 本にまとめると `state.py` と `refactor_lib` と文書 3 種を 1 度に -レビューすることになり、G2 / G3 / G5(`state.py`)と G4(`refactor_lib`)との競合も 1 度に解くことになる。 -**「片方にだけ古い形が残らない」(親 #727 の完了条件)は P7 のマージで満たす。** +共通層の新しい関数は P6 で入れ、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)は P7 で消す。P6 の時点で旧関数を消すと cross-refactoring が壊れる。1 本にまとめると `state.py` と `refactor_lib` と文書 3 種を 1 度にレビューすることになる。G2 / G3 / G5(`state.py`)と G4(`refactor_lib`)との競合も 1 度に解くことになる。**「片方にだけ古い形が残らない」(親 #727 の完了条件)は P7 のマージで満たす。** -### 決定 20: 規則の実装は `assignment.py`、手順は各 `SKILL.md`、理由はこの設計文書が持つ +### 決定 20: 次に使う人へ規則が届くように、規則の実装は `assignment.py`、手順は各 `SKILL.md`、理由はこの設計文書が持つ -#687 が問う置き場所である。使える者の数で分岐する実装と席の規則の表は `assignment.py`(`review_seats` の docstring)に -1 つだけ置く。利用者が手順として読む「担当の決まり方と渡す引数」は各 `SKILL.md`(cross-review は `docs/05` が本体)に -置く。同じランタイムの 2 つ目とホストの参加を許した理由はこの文書が持ち、`plan-to-spec` が `docs/specifications/` へ -移す。`CLAUDE.md` には要約の 2 行(cross-refactoring の母集合と輪番、cross-review の席)だけを置く。 +#687 が問う置き場所である。使える者の数で分岐する実装と席の規則の表は `assignment.py`(`review_seats` の docstring)に 1 つだけ置く。利用者が手順として読む「担当の決まり方と渡す引数」は各 `SKILL.md`(cross-review は `docs/05` が本体)に置く。同じランタイムの 2 つ目とホストの参加を許した理由はこの文書が持ち、`plan-to-spec` が `docs/specifications/` へ移す。`CLAUDE.md` には要約の 2 行(cross-refactoring の母集合と輪番、cross-review の席)だけを置く。 ## 実測 @@ -246,11 +182,12 @@ stem を逆に解析して担当名へ戻す箇所は無い(「実測」)た | cross-refactoring の再開で反映される引数 | `init` の 15 引数のうち 0 個(`_apply_post_event` の投稿の扱いだけを毎回入れ直す) | | 既存のテストの数 | `uv run --with pytest pytest scripts/tests plugins/ndf -q --co` で 4413 件 | -担当名を鍵・分岐・選択肢に使う箇所の一覧(10 か所の受け口を含む)は、調査の控えから「構成要素」の表へ写した。 -控え(`survey-727-agent-keys.md`)は設計 Pull Request のレビューの間だけ scratchpad に置く。 +担当名を鍵・分岐・選択肢に使う箇所の一覧(10 か所の受け口を含む)は、調査の控えから「構成要素」の表へ写した。控え(`survey-727-agent-keys.md`)は設計 Pull Request のレビューの間だけ scratchpad に置く。 ## 構成要素 +変える要素を、責務と出す Pull Request と対で並べる。 + | 要素 | 責務 | Pull Request | | --- | --- | --- | | 母集合の既定(`assignment.review_pool` / `refactor_pool`) | Skill ごとの出発点を返す。既定の参加者は表 `DEFAULT_REFACTOR_RUNTIMES` が持つ | P6 | @@ -301,10 +238,7 @@ graph TD ## 文脈と配置 -**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` / `refactor.py` の 1 プロセスずつである。 -外部との出入りは `gh`(GitHub)と、各 CLI の確認コマンドと起動だけで、確認コマンドの呼び出し先は変えない。変わるのは -起動する CLI の集合(cross-refactoring から agy が既定で外れ、ホストが入る)と、cross-review で同じ CLI を 2 プロセス -起動しうることである。 +**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` / `refactor.py` の 1 プロセスずつである。外部との出入りは `gh`(GitHub)と、各 CLI の確認コマンドと起動だけで、確認コマンドの呼び出し先は変えない。変わるのは起動する CLI の集合(cross-refactoring から agy が既定で外れ、ホストが入る)と、cross-review で同じ CLI を 2 プロセス起動しうることである。 ### 置き場所 @@ -329,17 +263,16 @@ plugins/ndf/ └── CLAUDE.md(リポジトリの根) # P7 ``` -`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。`bash scripts/build-runtime-plugins.sh --check` -で食い違いが無いことだけを確かめる。 +`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。`bash scripts/build-runtime-plugins.sh --check` で食い違いが無いことだけを確かめる。 ## 構造 -型を足すのは `Participants`(データクラス。契約文書の `participants` の 7 項目)と `ResumeField`(名前付きタプル)の -2 つで、互いに関係を持たず、既存の型とも関係を持たない。クラス図は作らない(要求の「対象範囲」)。**変わるのは処理の -順序である。** +型を足すのは `Participants`(データクラス。契約文書の `participants` の 7 項目)と `ResumeField`(名前付きタプル)の 2 つで、互いに関係を持たず、既存の型とも関係を持たない。クラス図は作らない(要求の「対象範囲」)。**変わるのは処理の順序である。** ## 処理の流れ +変わるのは新規の `init`・`start-round`・再開の `init` の 3 つの流れと、席の名前が流れる経路である。 + ### 新規の `init`(両 Skill で同じ形) ```mermaid @@ -360,8 +293,7 @@ graph TD 使える者の解決の順序: -1. `--include` と `--exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。`--exclude` の各名前が母集合の既定 ∪ `include` に含まれなければ弾く - (cross-review のホストはこれに当たる) +1. `--include` と `--exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。`--exclude` の各名前が母集合の既定 ∪ `include` に含まれなければ弾く(cross-review のホストはこれに当たる) 2. 参加者 = 母集合の既定 ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) 3. `--only` があれば参加者に含まれ、`exclude` に無いことを確かめ、参加者を `[only]` にする 4. 参加者の確認を `probe_auth` で行う。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとし `probe_skipped` を真にする @@ -411,6 +343,8 @@ start-round → REVIEWERS="codex claude-2" ## 非機能の実現方式 +要求の非機能 4 項目に、実現方式と確かめ方を対応づける。 + | 大項目 | 要求の条件 | 実現方式 | 確かめ方 | | --- | --- | --- | --- | | 可用性 | 母集合の 1 者が使えないことで、どちらの収束ループも開始できない状態にならない | 確認の失敗を `unavailable` に記録し、使える者で続ける(決定 2・3)。cross-review は席を埋め合わせる(決定 9) | AC14、AC35 のテスト | @@ -418,6 +352,15 @@ start-round → REVIEWERS="codex claude-2" | 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、反映しなかった引数が出力だけで分かる | `report` が参加者の節を出す。`init` が知らせる行を出す(決定 13・16) | AC24、AC29、AC39 のテスト | | 移行性 | この変更の前に始めた実行の状態ファイルを書き換えずに読める | 項目が無いときの読み方を契約文書の「移行」が決める | AC22、AC41 のテスト | +## 実装の分け方 + +P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削除)の順に出す(決定 19)。受け入れ条件は要求の文書の番号である。 + +| Pull Request | 中身 | 受け入れ条件 | +| --- | --- | --- | +| P6 | 共通層の新設(`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` / `apply_resume_args`)と cross-review 側 | AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 | +| P7 | cross-refactoring 側、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)の削除、`CLAUDE.md` | AC7、AC31〜AC43、AC47、AC49〜AC50 | + ## テスト設計 置き場所の列の読み方は次のとおりである。 @@ -450,11 +393,12 @@ start-round → REVIEWERS="codex claude-2" | AC45〜AC48 | 各 issue の再現手順を実行し、結果を issue のコメントへ残す | 手元 | | AC49〜AC50 | コマンドの終了コード | 継続的統合と手元 | -`apply_resume_args` の表の規則(`replace` / `notify`、値が同じなら積まない)は `lib/test_lib_resume_args.py`(新設)で -Skill に依らず確かめる。 +`apply_resume_args` の表の規則(`replace` / `notify`、値が同じなら積まない)は `lib/test_lib_resume_args.py`(新設)で Skill に依らず確かめる。 ## 未確認のまま残ること +7 件が残る。3 件は実装で決め、4 件は運用と #461 が決める。 + | 項目 | 内容 | いつ決まるか | | --- | --- | --- | | 同じランタイムの 2 席の観点 | `claude` / `claude-2` の 2 席が、別のランタイムの 2 席より指摘を見落とすかは測っていない | この変更の後の運用(`measure.py`) | @@ -467,6 +411,8 @@ Skill に依らず確かめる。 ## 申し送り(並行する設計との境界) +並行する 4 つの設計と 2 つの issue との境界を、決めた契約と分担で書く。 + | 相手 | 決めた契約 | どちらが何をするか | | --- | --- | --- | | G2(#732 #624) | `state.py` は同じファイルだが節が違う(G1 は `init` / 再開 / `_round_reviewers` / `read-result` の受け口 / `report` の参加者の節。G2 は `_classify_finding` / `COUNTED_CLASSIFICATIONS`)。**要求と契約の既存文書の先頭へ案内を足す点だけが重なる** | G1 が P5 の案内、G2 が P4 の案内をそれぞれ 1 段落足す。後からマージする側が並べる | @@ -478,6 +424,8 @@ Skill に依らず確かめる。 ## 既存の設計との対応 +既存の設計(PR #667)の各決定を、この文書のどの決定が引き継ぐかを示す。 + | 既存の決定(PR #667) | この文書 | 変わったこと | | --- | --- | --- | | 決定 1(P4 / P5 の分け方) | 決定 19 | P5 を P6 / P7 に分け直した。P4 は #732 | diff --git a/issues/issue-727-687-478-664-648-requirements.md b/issues/issue-727-687-478-664-648-requirements.md index bd2ebb9b..e68b6e5e 100644 --- a/issues/issue-727-687-478-664-648-requirements.md +++ b/issues/issue-727-687-478-664-648-requirements.md @@ -1,46 +1,32 @@ -# #727 / #687 / #478 / #664 / #648: 使える者を共通層が決め、担当が揃わなくても 2 席で回す - -設計は [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) にある。この文書は -「何を満たすか」だけを扱う。 - -**この文書は、既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の -P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P4(#624)は -#732 の設計が持つ。既存の設計文書の本体は触らない。 +# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(要求 / #727 #687 #478 #664 #648) ## 目的 -- 参加する CLI のどれか 1 者が使えなくても、収束ループ(cross-review / cross-refactoring)を開始でき、 - 使える者だけで回る。使えない者と理由は出力と状態ファイルに残る -- cross-review は、使える者が 2 者に満たなくても、各ラウンドに 2 席を確保する。席の埋め方の規則は - 1 つで、両 Skill が共有する共通層が持つ -- cross-refactoring の既定の参加者は codex / kiro とホスト(ホストが codex / kiro なら 2 者、それ以外なら 3 者) - になり、agy は既定から外れる。 - 外す・戻す手段は引数で持つ -- 中断した収束ループを、引数で進め方を変えて再開できる。反映しなかった引数は出力で分かる。 - この規則も共通層が 1 か所で持つ +- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の `init` が止まり、収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを `--only` などの引数を変えて再開しても、引数が黙って無視される(#648) +- **困る人**: 4 つの CLI が揃っていない環境で収束ループを回す利用者と、中断したループを進め方を変えて再開する利用者 +- **直すと成り立つこと**: 使える者だけで開始でき、使えない者と理由が出力と状態ファイルに残る。cross-review は使える者が 2 者に満たなくても、ホスト、次に同じランタイムの 2 つ目で各ラウンドに 2 席を確保する。cross-refactoring の既定の参加者は codex / kiro とホストになる(ホストが codex / kiro なら 2 者、それ以外なら 3 者)。agy は `--include` で戻す。再開で渡した引数は反映されるか、反映しないと知らされる。これらの規則は両 Skill が共有する共通層が 1 か所で持つ -## 前提 +## 文書の位置づけ -| # | 前提 | -| --- | --- | -| 1 | cross-refactoring のレビュー工程は #436 で消えており(Step 7 の `cross-review` が担う)、`assign()` が返すレビュー担当は状態ファイルへの記録と表示にしか使われない | -| 2 | 使える者の確認は、認証の確認コマンド(`AUTH_PROBES`)のままである。モデルを引く最小の呼び出しへ替える判断は #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口である | -| 3 | 再開の `init` の後、骨組みは必ず `start-round` で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | -| 4 | G2(#732)が同じ `state.py` の `_classify_finding` を、G3(#729)が `_read_review_result_file` / `_record_no_result` / `report` を、G5(#730)が投稿の経路を触る。この変更が触る節は `init` / 再開 / `_round_reviewers` / `start-round` / `read-result` の担当名の受け口 / `report` の参加者の節である | -| 5 | 同じランタイムの 2 つの CLI プロセスは、別の作業文脈を持てば独立した意見として扱う(#687 の利用者の指示) | +設計は [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) にある。この文書は「何を満たすか」だけを扱う。 + +**既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の P5(AC10〜AC30)は、この文書が置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P4(#624)は #732 の設計が持つ。既存の設計文書の本体は触らない。 ## 対象範囲 +変えるのは共通層の 3 ファイルと、両 Skill の初期化・担当・報告・文書である。指摘の数え方・監視・適用の取り込みは他の設計が持つ。 + 含む: -- 共通層 `lib/assignment.py`: 既定の母集合(Skill ごと)、使える者の解決、席の埋め方、適用の輪番、席の名前 -- 共通層 `lib/auth.py`: 止めない確認(`probe_auth`)。1 件の失敗で `die` する `check_auth` を消す -- 共通層 `lib/statefile.py`: 再開で明示的に渡した引数だけを状態へ重ね、反映しない引数を知らせる -- cross-review の `init`(新規と再開)、`start-round` の担当、`read-result` と起動スクリプトの席の受け口、`report`、 - `SKILL.md` と `docs/`(01 / 04 / 05) -- cross-refactoring の `init`(新規と再開)、`start-round` と適用の輪番、`report` / 改修計画の表示、`SKILL.md` と `docs/01` -- `CLAUDE.md` の cross-refactoring と cross-review の節 -- テスト(共通層・両 Skill) +| 場所 | 中身 | +| --- | --- | +| 共通層 `lib/assignment.py` | 既定の母集合(Skill ごと)、使える者の解決、席の埋め方、適用の輪番、席の名前 | +| 共通層 `lib/auth.py` | 止めない確認(`probe_auth`)。1 件の失敗で `die` する `check_auth` を消す | +| 共通層 `lib/statefile.py` | 再開で明示的に渡した引数だけを状態へ重ね、反映しない引数を知らせる | +| cross-review | `init`(新規と再開)、`start-round` の担当、`read-result` と起動スクリプトの席の受け口、`report`、`SKILL.md` と `docs/`(01 / 04 / 05) | +| cross-refactoring | `init`(新規と再開)、`start-round` と適用の輪番、`report` / 改修計画の表示、`SKILL.md` と `docs/01` | +| リポジトリ | `CLAUDE.md` の cross-refactoring と cross-review の節 | +| テスト | 共通層と両 Skill | 含まない: @@ -56,8 +42,37 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け | クラス図 | 型を追加するのは `Participants` 1 つで、関係を持つ型が無い。形は契約文書のデータ構造が持つ | | `CHANGELOG.md` と版数 | 配布の工程が書く | +## 影響 + +変わるのは `init` の振る舞い、既定の参加者、状態ファイルの形、`init` の引数、共通層の関数、担当名の形である。 + +| 対象 | 影響 | +| --- | --- | +| 認証に失敗する CLI がある利用者 | どちらの `init` も止まらず、使える者で回る。従来の関門は `--require-all` で選べる | +| cross-refactoring の既定の参加者 | agy が既定から外れ、ホストが提案と適用に入る。`--include agy` で戻せる。提案者と適用者が同じランタイムになりうる | +| cross-review で使える者が 2 者に満たない利用者 | ホスト、次に同じランタイムの 2 つ目が席を埋める。1 者で回るのは `--only` を渡したときだけになる | +| 状態ファイルの形 | 両 Skill の最上位に `participants` と `resume_changes` が増える。cross-refactoring の `impl_capable` とラウンドの `reviewers` / `reviewer_models` が新規の状態から消える。無い項目は従来の読み方で読む | +| `init` の引数 | 両 Skill に `--exclude` / `--include` / `--require-all` が増える。cross-review の `--only` が `none` を取る。既定値は変わらない | +| 共通層の関数 | `check_auth` / `impl_pool` / `review_assign` / `assign` が消え、`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` が入る。呼び出し側は両 Skill だけである | +| 担当名の形 | ランタイム名に `-2`〜`-9` の接尾辞を持つ席の名前が、結果ファイルの stem と状態ファイルの鍵に現れうる | +| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC23) | + +## 前提 + +この要求は次の 5 つを前提に書いている。 + +| # | 前提 | +| --- | --- | +| 1 | cross-refactoring のレビュー工程は #436 で消えており(Step 7 の `cross-review` が担う)、`assign()` が返すレビュー担当は状態ファイルへの記録と表示にしか使われない | +| 2 | 使える者の確認は、認証の確認コマンド(`AUTH_PROBES`)のままである。モデルを引く最小の呼び出しへ替える判断は #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口である | +| 3 | 再開の `init` の後、骨組みは必ず `start-round` で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | +| 4 | G2(#732)が同じ `state.py` の `_classify_finding` を、G3(#729)が `_read_review_result_file` / `_record_no_result` / `report` を、G5(#730)が投稿の経路を触る。この変更が触る節は `init` / 再開 / `_round_reviewers` / `start-round` / `read-result` の担当名の受け口 / `report` の参加者の節である | +| 5 | 同じランタイムの 2 つの CLI プロセスは、別の作業文脈を持てば独立した意見として扱う(#687 の利用者の指示) | + ## 用語 +受け入れ条件で使う語の意味を先に決める。 + | 用語 | 意味 | | --- | --- | | ランタイム | `claude` / `codex` / `agy` / `kiro` の 4 つ(`ALL_RUNTIMES`) | @@ -70,118 +85,92 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け | 埋め合わせ | 使える者が 2 席に足りないとき、ホスト、次に同じランタイムの 2 つ目で席を埋めること | | 再開 | 状態ファイルが残り `final` が `null` のときの `init` | +## 前提とする取り決め + +実装が従う置き場所・書き方・テストの形である。 + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 担当の決め方は `plugins/ndf/scripts/lib/assignment.py`、確認は `lib/auth.py`、再開の反映は `lib/statefile.py` に置く。判定と状態の鍵は各 Skill の `state.py` / `refactor_lib` が持ち、骨組みは結果を使うだけにする | +| コーディング規約 | 状態ファイルを最小の形で組み、関数を直接呼んで確かめる(`AGENTS.md` の DO)。分岐は表(データ)で持つ(`refactoring` の「分岐をデータ化」) | +| テスト戦略 | 共通層は `plugins/ndf/scripts/tests/` の関数テスト、Skill は既存の形(`conftest.py` の `state_mod` / `crossref_helpers`)で GitHub と CLI の起動を差し替える | + +## 境界 + +承認なしに行うこと・確認してから行うこと・行わないことを分ける。 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、配布物の同期の検査、文書の検査 | +| 確認してから行う | `init` の既定を「確認の失敗で止める」から「使える者で回す」へ変えること。cross-refactoring の既定から agy を外すこと。どちらも設計 Pull Request の承認で確認する | +| 行わない | 監視・起動・投稿の経路の変更、`refactor_lib` の適用の取り込みの変更、確認コマンドの差し替え | + ## 受け入れ条件 +50 件を、共通層・cross-review・cross-refactoring・文書・子 issue の再現・全体の 9 つの塊に分ける。 + ### 共通層: 使える者の解決(`lib/assignment.py` / `lib/auth.py`) -- [ ] AC1: 母集合 3 者のうち 1 者の確認が失敗する `probe` を `resolve_participants` に渡す。返る値の `available` は - 残り 2 者(母集合の順)、`unavailable` はその 1 者と理由を持ち、例外は上がらない -- [ ] AC2: AC1 と同じ入力で `require_all=True` を渡すと `AssignmentError` が上がり、メッセージに欠けた者の名前と - 理由が含まれる -- [ ] AC3: `exclude` に含めた者に対して `probe` が呼ばれない。`include` で足した者は呼ばれる(呼び出しの回数と - 引数で確かめる) -- [ ] AC4: 次の 4 つはいずれも `AssignmentError` になる。`include` と `exclude` に同じ名前 / `ALL_RUNTIMES` に無い - 名前 / `only` が `exclude` に含まれる / `only` が参加者に無い -- [ ] AC5: `NDF_SKIP_AUTH_CHECK` が立つと、`available` は参加者の全員、`probe_skipped` は真で、確認コマンドは - 1 回も呼ばれない -- [ ] AC6: `probe_auth` は失敗で例外を上げず、`ok: false` と理由(`コマンドが見つかりません` / 時間切れ / 終了コード - 非 0 / 未認証の文言)を返す。成功は `ok: true` -- [ ] AC7: P7 の後、`check_auth` / `impl_pool` / `review_assign` / `assign` の 4 つを `git grep -n` で探す。 - `plugins/ndf/scripts/lib/` と両 Skill の `scripts/` で 0 件になる +- [ ] AC1: 母集合 3 者のうち 1 者の確認が失敗する `probe` を `resolve_participants` に渡す。返る値の `available` は残り 2 者(母集合の順)、`unavailable` はその 1 者と理由を持ち、例外は上がらない +- [ ] AC2: AC1 と同じ入力で `require_all=True` を渡すと `AssignmentError` が上がり、メッセージに欠けた者の名前と理由が含まれる +- [ ] AC3: `exclude` に含めた者に対して `probe` が呼ばれない。`include` で足した者は呼ばれる(呼び出しの回数と引数で確かめる) +- [ ] AC4: 次の 4 つはいずれも `AssignmentError` になる。`include` と `exclude` に同じ名前 / `ALL_RUNTIMES` に無い名前 / `only` が `exclude` に含まれる / `only` が参加者に無い +- [ ] AC5: `NDF_SKIP_AUTH_CHECK` が立つと、`available` は参加者の全員、`probe_skipped` は真で、確認コマンドは 1 回も呼ばれない +- [ ] AC6: `probe_auth` は失敗で例外を上げず、`ok: false` と理由(`コマンドが見つかりません` / 時間切れ / 終了コード非 0 / 未認証の文言)を返す。成功は `ok: true` +- [ ] AC7: P7 の後、`check_auth` / `impl_pool` / `review_assign` / `assign` の 4 つを `git grep -n` で探す。`plugins/ndf/scripts/lib/` と両 Skill の `scripts/` で 0 件になる ### 共通層: 席の埋め方(cross-review の規則) -- [ ] AC8: 使える者が 3 者のとき、`review_seats(r, available, [])` は変更前の `review_assign(r, host)` と一致する。 - 4 つのホスト × ラウンド 1〜12 の全組で確かめる -- [ ] AC9: 使える者が 4 者(`--include` でホストを足した)のとき、毎ラウンド 2 席で、ラウンド 1〜4 で各者が - ちょうど 2 回担当になる +- [ ] AC8: 使える者が 3 者のとき、`review_seats(r, available, [])` は変更前の `review_assign(r, host)` と一致する。4 つのホスト × ラウンド 1〜12 の全組で確かめる +- [ ] AC9: 使える者が 4 者(`--include` でホストを足した)のとき、毎ラウンド 2 席で、ラウンド 1〜4 で各者がちょうど 2 回担当になる - [ ] AC10: 使える者が 2 者のとき、ラウンド 1〜4 の全部でその 2 者が返る -- [ ] AC11: 使える者が 1 者(`codex`)のとき、埋め合わせに `["claude"]` を渡すと `["codex", "claude"]`、空を渡すと - `["codex", "codex-2"]` が返る -- [ ] AC12: 使える者が 0 者のとき、埋め合わせに `["claude"]` を渡すと `["claude", "claude-2"]`、空を渡すと - `AssignmentError` になる -- [ ] AC13: `seat_runtime("kiro-2")` と `seat_runtime("kiro")` は `kiro` を返す。`gemini` / `kiro-1` / `kiro-10` / - `kiro-2-3` は `AssignmentError` になる +- [ ] AC11: 使える者が 1 者(`codex`)のとき、埋め合わせに `["claude"]` を渡すと `["codex", "claude"]`、空を渡すと `["codex", "codex-2"]` が返る +- [ ] AC12: 使える者が 0 者のとき、埋め合わせに `["claude"]` を渡すと `["claude", "claude-2"]`、空を渡すと `AssignmentError` になる +- [ ] AC13: `seat_runtime("kiro-2")` と `seat_runtime("kiro")` は `kiro` を返す。`gemini` / `kiro-1` / `kiro-10` / `kiro-2-3` は `AssignmentError` になる ### cross-review: 新規の `init` -- [ ] AC14: ホスト `claude` で `kiro` の確認が失敗する。`init` は終了コード 0 で状態ファイルを作る。 - `participants.available` は `["codex", "agy"]` で、`participants.unavailable.kiro` に理由が入る。標準エラーに - `kiro` を外したことが 1 行出る +- [ ] AC14: ホスト `claude` で `kiro` の確認が失敗する。`init` は終了コード 0 で状態ファイルを作る。`participants.available` は `["codex", "agy"]` で、`participants.unavailable.kiro` に理由が入る。標準エラーに `kiro` を外したことが 1 行出る - [ ] AC15: AC14 と同じ状態で `--require-all` を付けると、`init` は終了コード 1 で終わり、状態ファイルを作らない -- [ ] AC16: `--exclude agy` を渡すと `agy` の確認を行わない。`participants.excluded` が `["agy"]`、`available` が - `["codex", "kiro"]` になる。`--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ状態ファイルを作る +- [ ] AC16: `--exclude agy` を渡すと `agy` の確認を行わない。`participants.excluded` が `["agy"]`、`available` が `["codex", "kiro"]` になる。`--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ状態ファイルを作る - [ ] AC17: ホスト `claude` で `--include claude` を渡すと、`available` が 4 者になり、`start-round` が 2 席を返す -- [ ] AC18: 使える者が `codex` の 1 者で、ホストの確認が通る。`init` は終了コード 0 で終わり、観点が減ることを 1 行出す。 - `participants.fallback` は `["claude"]`、`start-round` は `codex claude` を返す。`--only codex` のときはホストを - 確かめず、`participants.fallback` は空で、`start-round` は `codex` だけを返す(確認コマンドの呼び出しは `codex` の 1 回) -- [ ] AC19: 使える者が 0 者でホストの確認が通ると、`init` は終了コード 0 で終わり、`start-round` は `claude claude-2` - を返す。ホストの確認も通らないと `init` は終了コード 1 で終わり、状態ファイルを作らない -- [ ] AC20: 次の 3 つはいずれも終了コード 1 で終わり、状態ファイルを作らない。`--exclude claude`(ホスト)/ - `--only codex --exclude codex` / `--include agy --exclude agy` -- [ ] AC21: `read-result claude-2` が受け付けられ、`rounds[-1]["claude-2"]` に結果を書く。 - `launch-reviewer.sh claude-2 ` は `claude` の CLI を起動し、stem は `claude-2-review-pr` になる - (起動は差し替えて確かめる) -- [ ] AC22: `participants` を持たない状態ファイルで、`host` があれば `start-round` は変更前の輪番を返す。 - `host` も無ければ `codex` / `agy` を返す -- [ ] AC23: 前のラウンドが `verdict` を持たず、担当 `agy` + `kiro` の両者が `REQUEST_CHANGES` で修正の記録が無い。 - このとき `start-round` は終了コード 5 で止まる -- [ ] AC24: `report` が「参加した者」の節を出す。行は 6 つで、使える者 / `--exclude` で外した者 / `--include` で - 足した者 / 確認を通らなかった者(理由つき)/ 埋め合わせ / 再開で変えた値である。`participants` を持たない - 状態ファイルでは「記録なし」と出す +- [ ] AC18: 使える者が `codex` の 1 者で、ホストの確認が通る。`init` は終了コード 0 で終わり、観点が減ることを 1 行出す。`participants.fallback` は `["claude"]`、`start-round` は `codex claude` を返す。`--only codex` のときはホストを確かめない。`participants.fallback` は空で、`start-round` は `codex` だけを返す(確認コマンドの呼び出しは `codex` の 1 回) +- [ ] AC19: 使える者が 0 者でホストの確認が通ると、`init` は終了コード 0 で終わり、`start-round` は `claude claude-2` を返す。ホストの確認も通らないと `init` は終了コード 1 で終わり、状態ファイルを作らない +- [ ] AC20: 次の 3 つはいずれも終了コード 1 で終わり、状態ファイルを作らない。`--exclude claude`(ホスト)/ `--only codex --exclude codex` / `--include agy --exclude agy` +- [ ] AC21: `read-result claude-2` が受け付けられ、`rounds[-1]["claude-2"]` に結果を書く。`launch-reviewer.sh claude-2 ` は `claude` の CLI を起動する。stem は `claude-2-review-pr` になる(起動は差し替えて確かめる) +- [ ] AC22: `participants` を持たない状態ファイルで、`host` があれば `start-round` は変更前の輪番を返す。`host` も無ければ `codex` / `agy` を返す +- [ ] AC23: 前のラウンドが `verdict` を持たず、担当 `agy` + `kiro` の両者が `REQUEST_CHANGES` で修正の記録が無い。このとき `start-round` は終了コード 5 で止まる +- [ ] AC24: `report` が「参加した者」の節を出す。行は 6 つで、使える者 / `--exclude` で外した者 / `--include` で足した者 / 確認を通らなかった者(理由つき)/ 埋め合わせ / 再開で変えた値である。`participants` を持たない状態ファイルでは「記録なし」と出す ### cross-review: 再開の `init` -- [ ] AC25: `max_rounds: 12` の状態ファイルへ `--max-rounds 20` を渡す。`max_rounds` が 20 になり、`12 → 20` の - 形で 1 行出る。`resume_changes` に `{field: "max_rounds", from: 12, to: 20}` が 1 件積まれる。 - `--rotate-after` / `--verify-command` / `--verify-exit-code` も同じく反映され、後の 2 つは置き換える -- [ ] AC26: 引数を渡さない再開では、次の 6 項目が変わらず、確認コマンドは 1 回も呼ばれない。`max_rounds` / - `rotate_after` / `verify_commands` / `verify_exit_codes` / `only` / `participants` -- [ ] AC27: `only: null` の状態ファイルへ `--only codex` を渡すと `only` が `codex` になり、次の `start-round` が - `codex` だけを返す。記録を持つ過去のラウンドの `reviewers` は変わらない。`--only none` は `only` を `null` へ戻す -- [ ] AC28: `--exclude agy` を渡した再開では、参加者の確認をやり直し、`available` から `agy` が消え、次の - `start-round` が `agy` を返さない。`--exclude none` は除外を空へ戻す。`participants.included` が `["claude"]` の状態へ - `--exclude agy` だけを渡すと、`included` は `["claude"]` のまま残り、`excluded` が `["agy"]` になる(渡さなかった引数は - 状態ファイルの値で補う) -- [ ] AC29: `host: "claude"` の状態ファイルへ `--host codex` を渡すと `host` は変わらず、反映しないことが 1 行出る。 - `--host claude` では何も出ない -- [ ] AC30: `SKILL.md` と `docs/01-state-and-review.md` で `grep -n 'ONLY'` が当たる行は、`init` へ引数を渡す行と - 引数の説明の行だけになる。起動・監視・取り込み・反証の担当は `$REVIEWERS` / `$REVIEWERS_CSV` を使う +- [ ] AC25: `max_rounds: 12` の状態ファイルへ `--max-rounds 20` を渡す。`max_rounds` が 20 になり、`12 → 20` の形で 1 行出る。`resume_changes` に `{field: "max_rounds", from: 12, to: 20}` が 1 件積まれる。`--rotate-after` / `--verify-command` / `--verify-exit-code` も同じく反映され、後の 2 つは置き換える +- [ ] AC26: 引数を渡さない再開では、次の 6 項目が変わらず、確認コマンドは 1 回も呼ばれない。`max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` / `only` / `participants` +- [ ] AC27: `only: null` の状態ファイルへ `--only codex` を渡すと `only` が `codex` になり、次の `start-round` が `codex` だけを返す。記録を持つ過去のラウンドの `reviewers` は変わらない。`--only none` は `only` を `null` へ戻す +- [ ] AC28: `--exclude agy` を渡した再開では、参加者の確認をやり直し、`available` から `agy` が消え、次の `start-round` が `agy` を返さない。`--exclude none` は除外を空へ戻す。`participants.included` が `["claude"]` の状態へ `--exclude agy` だけを渡すと、`included` は `["claude"]` のまま残る。`excluded` が `["agy"]` になる(渡さなかった引数は状態ファイルの値で補う) +- [ ] AC29: `host: "claude"` の状態ファイルへ `--host codex` を渡すと `host` は変わらず、反映しないことが 1 行出る。`--host claude` では何も出ない +- [ ] AC30: `SKILL.md` と `docs/01-state-and-review.md` で `grep -n 'ONLY'` が当たる行は、`init` へ引数を渡す行と引数の説明の行だけになる。起動・監視・取り込み・反証の担当は `$REVIEWERS` / `$REVIEWERS_CSV` を使う ### cross-refactoring: 母集合と担当 -- [ ] AC31: ホスト `claude` の新規の `init` で、状態ファイルの `runtimes` は `["claude", "codex", "kiro"]` になり、 - `agy` の確認は行われない。状態ファイルに `impl_capable` は無く、標準出力に `IMPL_POOL=` の行は無い +- [ ] AC31: ホスト `claude` の新規の `init` で、状態ファイルの `runtimes` は `["claude", "codex", "kiro"]` になり、`agy` の確認は行われない。状態ファイルに `impl_capable` は無く、標準出力に `IMPL_POOL=` の行は無い - [ ] AC32: ホスト `codex` では `runtimes` が `["codex", "kiro"]`、ホスト `agy` では `["codex", "agy", "kiro"]` になる -- [ ] AC33: `--include agy` で `runtimes` が 4 者に、`--exclude kiro` で 2 者になる。ホスト `claude` で `--exclude claude` を - 渡すと `runtimes` が `["codex", "kiro"]` になり、`init` は終了コード 0 で終わる(ホストは母集合に含まれるため外せる) -- [ ] AC34: `impl_assign(r, ["claude", "codex", "kiro"])` をラウンド 1〜6 で呼ぶ。返る値は `codex` / `kiro` / `claude` / - `codex` / `kiro` / `claude` である。`start-round` は `REVIEWERS` / `REVIEWERS_CSV` を出さない。ラウンドの記録に - `reviewers` / `reviewer_models` が無い -- [ ] AC35: `kiro` の確認が失敗しても `init` は終了コード 0 で終わる。`participants.unavailable.kiro` に理由が入り、 - `runtimes` は 2 者になる。`--require-all` を付けると終了コード 4 で終わり、状態ファイルを作らない +- [ ] AC33: `--include agy` で `runtimes` が 4 者に、`--exclude kiro` で 2 者になる。ホスト `claude` で `--exclude claude` を渡すと `runtimes` が `["codex", "kiro"]` になる。`init` は終了コード 0 で終わる(ホストは母集合に含まれるため外せる) +- [ ] AC34: `impl_assign(r, ["claude", "codex", "kiro"])` をラウンド 1〜6 で呼ぶ。返る値は `codex` / `kiro` / `claude` / `codex` / `kiro` / `claude` である。`start-round` は `REVIEWERS` / `REVIEWERS_CSV` を出さない。ラウンドの記録に `reviewers` / `reviewer_models` が無い +- [ ] AC35: `kiro` の確認が失敗しても `init` は終了コード 0 で終わる。`participants.unavailable.kiro` に理由が入り、`runtimes` は 2 者になる。`--require-all` を付けると終了コード 4 で終わり、状態ファイルを作らない - [ ] AC36: 使える者が 0 者のとき `init` は終了コード 4 で終わり、状態ファイルを作らない -- [ ] AC37: `report` と改修計画の表示にレビュー担当の列が無く、母集合を 1 行で出す(「提案・レビュー」と「適用の母集合」の - 2 行に分けない) +- [ ] AC37: `report` と改修計画の表示にレビュー担当の列が無く、母集合を 1 行で出す(「提案・レビュー」と「適用の母集合」の 2 行に分けない) ### cross-refactoring: 再開の `init` -- [ ] AC38: `max_outer_rounds: 3` の状態ファイルへ `--max-outer-rounds 5` を渡す。5 になり、`3 → 5` の形で 1 行出て、 - `resume_changes` に 1 件積まれる。`--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` も同じ -- [ ] AC39: 再開で `--model codex=x` / `--host codex` / `--scope other` を渡すと、状態は変わらず、反映しないことが - 引数ごとに 1 行出る。状態に載る他の引数(`--baseline-test` など。契約文書の表)も同じ扱いである。引数を渡さない - 再開では、上限 4 項目と `models` と `runtimes` が変わらない -- [ ] AC40: 再開で `--exclude kiro` を渡すと参加者の確認をやり直す。`runtimes` から `kiro` が消え、次の `start-round` の - `RUNTIMES` に `kiro` が無い。`--include agy` で始めた状態へ `--exclude kiro` だけを渡すと、`included` の `agy` は残る -- [ ] AC41: `impl_capable` を持ち `participants` を持たない状態ファイル(この変更の前に始めた実行)を、`start-round` / - `report` が読める。適用の輪番は `runtimes` から決まる +- [ ] AC38: `max_outer_rounds: 3` の状態ファイルへ `--max-outer-rounds 5` を渡す。5 になり、`3 → 5` の形で 1 行出て、`resume_changes` に 1 件積まれる。`--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` も同じ +- [ ] AC39: 再開で `--model codex=x` / `--host codex` / `--scope other` を渡すと、状態は変わらず、反映しないことが引数ごとに 1 行出る。状態に載る他の引数(`--baseline-test` など。契約文書の表)も同じ扱いである。引数を渡さない再開では、上限 4 項目と `models` と `runtimes` が変わらない +- [ ] AC40: 再開で `--exclude kiro` を渡すと参加者の確認をやり直す。`runtimes` から `kiro` が消え、次の `start-round` の `RUNTIMES` に `kiro` が無い。`--include agy` で始めた状態へ `--exclude kiro` だけを渡すと、`included` の `agy` は残る +- [ ] AC41: `impl_capable` を持ち `participants` を持たない状態ファイル(この変更の前に始めた実行)を、`start-round` / `report` が読める。適用の輪番は `runtimes` から決まる ### 文書 -- [ ] AC42: `CLAUDE.md` の cross-refactoring の節が「codex / kiro とホスト(ホストが codex / kiro なら 2 者)」と - 「適用担当は参加者の数のラウンドで 1 周する」を - 書く。「ホストを除く 3 者」「参加する 4 者」を含まない。cross-review の節が「codex / agy の両方」を含まない。 - 次の 3 つがいずれも 0 行を出す +- [ ] AC42: `CLAUDE.md` の cross-refactoring の節が「codex / kiro とホスト(ホストが codex / kiro なら 2 者)」を書く。同じ節が「適用担当は参加者の数のラウンドで 1 周する」を書く。「ホストを除く 3 者」「参加する 4 者」を含まない。cross-review の節が「codex / agy の両方」を含まない。次の 3 つがいずれも 0 行を出す ```bash grep -n "ホストを除く 3 者" CLAUDE.md @@ -189,10 +178,7 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け grep -n "codex / agy の両方" CLAUDE.md ``` -- [ ] AC43: cross-refactoring の `SKILL.md` の「担当の決め方」が母集合を 1 つの表で書く。引数の表と `argument-hint` に - `--exclude` / `--include` / `--require-all` がある。「前提」から「すべてログイン済み」が消える。ホストごとに要る - CLI の表が `codex` / `kiro-cli`(ホストが codex / kiro ならもう 1 つ)になる。`docs/01-state-and-propose.md` の - `init` が返す変数の表に `IMPL_POOL` が無い +- [ ] AC43: cross-refactoring の `SKILL.md` の「担当の決め方」が母集合を 1 つの表で書く。引数の表と `argument-hint` に `--exclude` / `--include` / `--require-all` がある。「前提」から「すべてログイン済み」が消える。ホストごとに要る CLI の表が `codex` / `kiro-cli`(ホストが codex / kiro ならもう 1 つ)になる。`docs/01-state-and-propose.md` の `init` が返す変数の表に `IMPL_POOL` が無い - [ ] AC44: cross-review の次の 4 ファイルが、それぞれの内容を書く | ファイル | 書く内容 | @@ -204,12 +190,10 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け ### 子 issue の再現手順 -- [ ] AC45: #478 の再現(`kiro-cli` が無い環境で `init`)で、`init` が終了コード 0 で終わる。#687 の場面 3 - (`codex` が使えない)で、`--exclude codex` を付けずに `init` が開始できる +- [ ] AC45: #478 の再現(`kiro-cli` が無い環境で `init`)で、`init` が終了コード 0 で終わる。#687 の場面 3(`codex` が使えない)で、`--exclude codex` を付けずに `init` が開始できる - [ ] AC46: #648 の再現(再開の `init` に `--only codex`)で、次の `start-round` が `codex` だけを返す - [ ] AC47: #664 の再現(`git grep -n '"--exclude"' -- plugins/ndf/skills/cross-refactoring`)が 1 行以上を出す -- [ ] AC48: #461 の再現(モデルを引けない CLI が確認を通る)は、この変更の後も現象が残ることを確かめて記録する - (直す判断は #461 が持つ) +- [ ] AC48: #461 の再現(モデルを引けない CLI が確認を通る)は、この変更の後も現象が残ることを確かめて記録する(直す判断は #461 が持つ) ### 全体 @@ -227,6 +211,8 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け ## 非機能の条件 +受け入れ条件のうち可用性・性能・運用・移行に当たるものを、大項目で束ねる。 + | 大項目 | 条件 | | --- | --- | | 可用性 | 母集合の 1 者が使えないことで、どちらの収束ループも開始できない状態にならない(AC14、AC35) | @@ -234,21 +220,10 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け | 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、再開で反映しなかった引数が、`report` と `init` の出力だけで分かる(AC24、AC29、AC39) | | 移行性 | この変更の前に始めた実行の状態ファイルを、書き換えずに読める(AC22、AC41) | -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 認証に失敗する CLI がある利用者 | どちらの `init` も止まらず、使える者で回る。従来の関門は `--require-all` で選べる | -| cross-refactoring の既定の参加者 | agy が既定から外れ、ホストが提案と適用に入る。`--include agy` で戻せる。提案者と適用者が同じランタイムになりうる | -| cross-review で使える者が 2 者に満たない利用者 | ホスト、次に同じランタイムの 2 つ目が席を埋める。1 者で回るのは `--only` を渡したときだけになる | -| 状態ファイルの形 | 両 Skill の最上位に `participants` と `resume_changes` が増える。cross-refactoring の `impl_capable` とラウンドの `reviewers` / `reviewer_models` が新規の状態から消える。無い項目は従来の読み方で読む | -| `init` の引数 | 両 Skill に `--exclude` / `--include` / `--require-all` が増える。cross-review の `--only` が `none` を取る。既定値は変わらない | -| 共通層の関数 | `check_auth` / `impl_pool` / `review_assign` / `assign` が消え、`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` が入る。呼び出し側は両 Skill だけである | -| 担当名の形 | ランタイム名に `-2`〜`-9` の接尾辞を持つ席の名前が、結果ファイルの stem と状態ファイルの鍵に現れうる | -| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC23) | - ## 検証手段 +テスト・配布物の同期・文書の検査・手動確認の 4 つで確かめる。 + | 項目 | 手段 | | --- | --- | | テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | @@ -256,24 +231,10 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け | 定義と文書の検査 | AC50 の 6 つ | | 手動確認 | P7 の後、ホスト claude で `--exclude codex` を付けた cross-review と、既定の cross-refactoring を 1 本ずつ回し、`report` で参加者と席を見る | -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 担当の決め方は `plugins/ndf/scripts/lib/assignment.py`、確認は `lib/auth.py`、再開の反映は `lib/statefile.py` に置く。判定と状態の鍵は各 Skill の `state.py` / `refactor_lib` が持ち、骨組みは結果を使うだけにする | -| コーディング規約 | 状態ファイルを最小の形で組み、関数を直接呼んで確かめる(`AGENTS.md` の DO)。分岐は表(データ)で持つ(`refactoring` の「分岐をデータ化」) | -| テスト戦略 | 共通層は `plugins/ndf/scripts/tests/` の関数テスト、Skill は既存の形(`conftest.py` の `state_mod` / `crossref_helpers`)で GitHub と CLI の起動を差し替える | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査、文書の検査 | -| 確認してから行う | `init` の既定を「確認の失敗で止める」から「使える者で回す」へ変えること。cross-refactoring の既定から agy を外すこと。どちらも設計 Pull Request の承認で確認する | -| 行わない | 監視・起動・投稿の経路の変更、`refactor_lib` の適用の取り込みの変更、確認コマンドの差し替え | - ## 未決 +この変更の外で決まる 2 件を残す。 + | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | | 確認を「モデルを引く最小の呼び出し」へ替えるか、替えるならランタイムごとのコマンドと所要 | #461 | マイルストーン 06 の着手時 | @@ -314,6 +275,8 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け ## 依頼(原文) +各 issue の本文から抜粋した原文である。全文は `gh issue view 727` / `687` / `478` / `664` / `648` で読む。 + ### #727(根本原因の親) > **参加する CLI が実行できるかを確かめ、使える者から担当を割り当てる共通層。** 場所は `plugins/ndf/scripts/lib/auth.py` の `check_auth`(36 行目)と `assignment.py` の `review_pool` / `review_assign` / `assign`(78 / 95 / 114 行目)である。 @@ -382,5 +345,3 @@ P5(AC10〜AC30)を置き換える。** 対応は末尾の「既存の受け > ## 修正レイヤー > > `plugins/ndf/scripts/lib/statefile.py` に置く、再開時の引数の反映の契約。「明示的に渡された引数だけを状態へ重ね、反映しない引数は渡されたら知らせる」を 1 か所で持つ。 - -(各 issue の本文から抜粋。全文は `gh issue view 727` / `687` / `478` / `664` / `648`) From ac9cd4d87d4cfe61bc639e890cff1d49a8a17400 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:02:17 +0000 Subject: [PATCH 016/217] =?UTF-8?q?Docs:=20#732=20#624=20#706=20=E3=81=AE?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E3=83=BB=E8=A8=AD=E8=A8=88=E6=96=87=E6=9B=B8?= =?UTF-8?q?=E3=82=92=E6=A5=AD=E5=8B=99=E7=94=A8=E8=AA=9E=E3=81=A7=E7=B5=84?= =?UTF-8?q?=E3=81=BF=E7=9B=B4=E3=81=99=EF=BC=88markdown-writing=20?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=AB=201=E3=80=9C9=20=E3=81=A8=E5=86=8D?= =?UTF-8?q?=E6=A7=8B=E6=88=90=E3=81=AE=204=20=E6=AE=B5=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 見出しから識別子を外し、業務用語で「何のために何を決めた」を書く(識別子は本文の初出の括弧書きへ) - 目的の直後に用語の対応表(業務用語 ↔ 識別子)を置き、説明文は業務用語で通す - 章立てを読み手が問う順(結論 → なぜ → 実測 → 決定 → 中身 → テスト)に並べ、依頼の原文は末尾へ - 長い文を分け、採らない案と未反証の理由を表にする - 決定の中身・受け入れ条件・契約の形は変えない Refs #788 Co-Authored-By: Claude Fable 5.1 --- issues/issue-732-624-706-design.md | 300 +++++++++++++---------- issues/issue-732-624-706-requirements.md | 153 +++++++----- 2 files changed, 261 insertions(+), 192 deletions(-) diff --git a/issues/issue-732-624-706-design.md b/issues/issue-732-624-706-design.md index d028e033..ffd65d26 100644 --- a/issues/issue-732-624-706-design.md +++ b/issues/issue-732-624-706-design.md @@ -1,129 +1,230 @@ -# cross-review: 反証する担当がいない・実行検証が無い・支持が付かない major が数えられず、修正の要る指摘を残して approved になる → 誤りを示された指摘と minor だけを数えない(#732 #624 #706 の設計) +# cross-review: 誤りを示されていない重大な指摘が数えられずに承認で終わる → 数えない指摘を棄却と軽微な指摘に限る(#732 #624 #706 の設計) ## 目的 -cross-review の収束の判定が、誰にも誤りを示されていない `major` の指摘を区分 `insufficient_evidence` へ落として数えない。その結果、新しい指摘 0 件として `approved` で終わる。1 者で回したループ(#624)、実行検証も支持も無い指摘(#706)、起動し直した担当の指摘(#583 の収束の部分)でこの形になり、修正の要る指摘が修正の工程へ渡らない。原因は、1 つの区分が「反証の機会があって支持されなかった」と「立証の機会が無かった」の 2 つの意味を兼ねていることにある。 +**この設計の後、cross-review の収束の判定が数えないのは、棄却された指摘と軽微な指摘だけになる。** 誰にも誤りを示されていない重大な指摘は、新しい区分「未反証」として数えられ、修正の工程へ渡る。「なぜ独立に確かめられていないか」は、未反証の理由として状態ファイルに残る。 -この設計の後、数えないのは棄却された指摘(`rejected`)と `minor` 以下だけになる。誤りを示されていない `major` は新しい区分 `unrefuted` として数えられ、修正の工程へ渡る。「なぜ独立に確かめられていないか」の理由は `unrefuted_reason` として状態ファイルに残る。 +いまは、誰にも誤りを示されていない重大な指摘が立証不足の区分へ落ち、数えられない。その結果、新しい指摘 0 件として承認で終わり、修正の要る指摘が修正の工程へ渡らない。 + +## 用語の対応表 + +本文は左の業務用語で書く。右は状態ファイル・スクリプトでの識別子で、コードブロック・表・「データ構造」「入出力の契約」「置き場所」の節ではそのまま使う。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 重大な指摘 / 軽微な指摘 | `severity` の `major` / `minor`(本文の「重大な指摘」は `major` 以上、「軽微な指摘」は `minor` 以下) | 指摘の重大度。軽微な指摘は承認を妨げない | +| 区分 | `classification` | 印のあるラウンドで指摘ごとに付く値。6 つになる | +| 再現した重大な指摘 / 再現した軽微な指摘 | `verified_blocking` / `verified_non_blocking` | 実行検証で再現した指摘の区分(順 1・2) | +| 棄却 | `rejected`(理由は `rejection_reason`) | 誤りだと示された指摘の区分(順 3) | +| 人の判断待ち | `needs_human_judgment` | 独立に確かめた担当がいる重大な指摘の区分(順 4) | +| 未反証 | `unrefuted`(理由は `unrefuted_reason`) | **新設。** 誰も誤りを示しておらず、独立に確かめた担当もいない重大な指摘の区分(順 5) | +| 未反証の理由: 反証なし / 支持なし | `no_critique` / `not_supported` | 反証を返した担当が 0 者 / 反証はあるが支持も否定も無い | +| 立証不足 | `insufficient_evidence`(区分の値) | 上のいずれにも当たらない軽微な指摘の区分(順 6) | +| 反証 | `critiques`(要素の `verdict`) | 提案者以外の担当が指摘へ返した賛否 | +| 反証の値: 支持 / 否定 / 立証できない / 範囲外 | `support` / `refute` / `insufficient_evidence` / `out_of_scope` | 反証の担当が返す 5 つの値のうち、この文書が扱う 4 つ | +| 実行検証の結果: 再現した / 再現しなかった / 実行していない | `verification.result` の `reproduced` / `not_reproduced` / `not_run` | 指摘の手順を実行した結果 | +| 根拠の 2 項目 / 根拠の有無 | `evidence` と `falsification` / `has_evidence` | 別の担当が確かめるための入力と、両方が揃っているかの真偽値 | +| 出した担当 | `origin_runtimes` | 同じ指摘を独立に出した担当の一覧。2 者以上で「独立に確かめた」とみなす | +| 印 | `evidence_rounds` | 統合・実行検証・反証を通ったラウンドの番号の一覧 | +| 却下の記録 | `rejected_findings` | 修正の工程が却下した指摘と理由。次のラウンドのプロンプトへ渡る | +| 数える区分の集合 | `COUNTED_CLASSIFICATIONS`(状態の管理スクリプトと測定スクリプトの 2 か所) | 新規性の層が新しい指摘として数える区分 | +| 状態の管理スクリプト / 測定スクリプト / 反証のプロンプト | `state.py` / `measure.py` / `critique.sh` | いずれも `plugins/ndf/skills/cross-review/scripts/` の下 | +| 区分の判定 / 区分の書き込み / 数える指摘の抽出 / 新しい指摘の数え上げ / 反証の不足の扱い / 印を付ける処理 | `_classify_finding` / `_apply_classification` / `_counted_finding_keys` / `_new_finding_count` / `_handle_incomplete_critiques` / `_mark_evidence_round` | 状態の管理スクリプトの内部関数 | +| 収束の判定 / 反証の取り込み | `judge`(本体は `cmd_judge`)/ `collect-critiques` | 状態の管理スクリプトの副コマンド | +| 全員が通したラウンド | `round_passes` | 全担当が承認か、重大な指摘の無いコメントを返したこと | +| 測定の方式「この変更の方式」 | `proposed` | 測定スクリプトが持つ方式の 1 つ | +| 承認 / 修正要求 / コメント | `APPROVE` / `REQUEST_CHANGES` / `COMMENT` | 担当が返すレビューの意図 | +| 承認で終わる | `approved` | 収束ループの結末 | +| GitHub CLI | `gh` | 状態の管理スクリプトが外部と出入りする唯一の経路 | ## 機能一覧 | # | 機能 | 誰が使うか | | --- | --- | --- | -| F1 | 誤りを示されていない `major` を、反証の有無・担当の数・根拠の項目の有無によらず新しい指摘として数える | 1 者で回す利用者(#624)、担当が起動し直されたループ(#583 の収束の部分)、実行検証も支持も無い指摘が出た Pull Request(#706) | +| F1 | 誤りを示されていない重大な指摘を、反証の有無・担当の数・根拠の項目の有無によらず新しい指摘として数える | 1 者で回す利用者(#624)、担当が起動し直されたループ(#583 の収束の部分)、実行検証も支持も無い指摘が出た Pull Request(#706) | | F2 | 数えた指摘に「なぜ独立に確かめられていないか」の理由を残す | 修正の担当(何が確かめられていないかを読む)、収束の後に記録を読む人 | | F3 | 反証が揃わなかったラウンドを、全件を数える扱いへ戻す | 収束ループ全般 | -| F4 | 効果の測定の方式 `proposed` が、数える 3 区分を採る | 収束の記録を測る人 | +| F4 | 効果の測定の方式「この変更の方式」が、数える 3 区分を採る | 収束の記録を測る人 | + +## なぜ変えるか + +**原因は、1 つの区分が 2 つの意味を兼ねていることにある。** 立証不足の区分に「反証の機会があって支持されなかった」と「立証の機会が無かった」の両方が落ち、どちらも数えられない。誤りを示されていない重大な指摘が数えられない形は、3 つの場面で出る。 + +| 場面 | 課題 | +| --- | --- | +| 反証する担当がいない 1 者のループ | #624 | +| 実行検証も支持も無い指摘 | #706 | +| 起動し直した担当の指摘 | #583 の収束の部分 | + +## 実測 + +11 通りの最小の指摘を区分の判定に渡し、いまの区分と変更後の区分を並べた。数え方が変わるのは A・B・D・E・F・H の 6 件で、いずれも重大な指摘である。軽微な指摘(C)、棄却(I・J)、再現(K)、支持つき根拠あり(G)の 5 件は変わらない。 + +測った条件は `develop` 9eaebe14、Python 3.14.4 である。実行検証は「実行していない」、担当 1 者の指摘は出した担当が 1 者である。 + +| 記号 | 入力 | いまの区分 | 数える | 変更後の区分 | 数える | +| --- | --- | --- | --- | --- | --- | +| A | `major`、根拠あり、反証なし(1 者) | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | +| B | `major`、根拠なし、反証なし | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | +| C | `minor`、根拠あり、反証なし | `insufficient_evidence` | いいえ | `insufficient_evidence` | いいえ | +| D | `major`、根拠あり、相手が `insufficient_evidence` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | +| E | `major`、根拠あり、相手が `out_of_scope` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | +| F | `major`、根拠なし、相手が `support` | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | +| G | `major`、根拠あり、相手が `support` | `needs_human_judgment` | はい | `needs_human_judgment` | はい | +| H | `major`、根拠なし、2 者が独立に出した | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | +| I | `major`、相手が `refute` | `rejected` | いいえ | `rejected` | いいえ | +| J | `major`、`not_reproduced` | `rejected` | いいえ | `rejected` | いいえ | +| K | `major`、`reproduced` | `verified_blocking` | はい | `verified_blocking` | はい | ## 決定の記録 -決定 1 は文書の形、決定 2〜6 は区分、決定 7 は測定、決定 8 は印、決定 9〜11 はプロンプト・判定の出口・確定仕様を扱う。区分の中核は決定 2 で、決定 3〜6 はその境界を定める。 +決定 1 は文書の形を扱う。決定 2〜6 は区分、決定 7 は測定、決定 8 は印、決定 9〜11 はプロンプト・判定の出口・確定仕様を扱う。区分の中核は決定 2 で、決定 3〜6 はその境界を定める。 ### 決定 1: 並行する設計と 1 つのファイルで競合しないよう、設計文書は親 #732 の名前で新設し、既存の設計文書の本体は書き換えない -既存の設計は 3 課題・2 本の Pull Request を 1 つの文書で扱い、P5(決定 6〜19)は #727 の設計が並行して置き換える。P4 の節をその場で書き換えると、P5 の変更と 1 つのファイルで競合し、承認する人が「どの束が何を変えたか」を読み分けられない。親 #732 は根本原因を「1 つの区分が 2 つの意味を兼ねる」と定め直しており、既存の決定 2〜4(区分の名前を増やさず、順 4 に条件を足す)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** +既存の設計は 3 課題・2 本の Pull Request を 1 つの文書で扱う。その P5(決定 6〜19)は #727 の設計が並行して置き換える。P4 の節をその場で書き換えると、P5 の変更と 1 つのファイルで競合する。承認する人が「どの束が何を変えたか」を読み分けられない。親 #732 は根本原因を「1 つの区分が 2 つの意味を兼ねる」と定め直した。これは既存の決定 2〜4(区分の名前を増やさず、順 4 に条件を足す)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** -既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。1 行でも触ると、既存の 19 件の決定がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求の文書と契約の文書にだけ足す(#729 の設計と同じ形)。 +既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの決定の見出しをすべて写す。1 行でも触ると、既存の 19 件の決定がこの Pull Request の決定として並ぶ。案内は、決定の記録を持たない要求の文書と契約の文書にだけ足す(#729 の設計と同じ形)。 -### 決定 2: 未解決の `major` を残して収束しないよう、数えない判断を棄却と `minor` 以下に限り、誤りを示されていない `major` を新しい区分 `unrefuted` として数える +### 決定 2: 未解決の重大な指摘を残して収束しないよう、数えない判断を棄却と軽微な指摘に限り、誤りを示されていない重大な指摘を新しい区分「未反証」として数える -区分の順 5(`insufficient_evidence`)には、いま 7 つの形が落ちる(「実測」の A〜F・H)。そのうち `minor` 以下(C)を除く 6 つは、いずれも「誰も誤りを示していない `major`」である。 +区分の順 5(立証不足)には、いま 7 つの形が落ちる(「実測」の A〜F・H)。そのうち軽微な指摘(C)を除く 6 つは、いずれも「誰も誤りを示していない重大な指摘」である。 | 落ちる形 | 実測の記号 | | --- | --- | | 反証する担当がいない | A・B・H | -| 相手が `insufficient_evidence` / `out_of_scope` を返した | D・E | +| 相手が「立証できない」か「範囲外」を返した | D・E | | 根拠の 2 項目を欠く | B・F・H | -どれも、指摘が誤りだという主張ではない。**数えないのは、指摘が誤りだと示された `rejected` と、`APPROVE` を妨げない `minor` 以下だけにする。** 誤りを示されていない `major` は `unrefuted` として数え、修正の工程へ渡す。修正の担当が直す・却下の理由を返す・範囲外として起票するのは `needs_human_judgment` と同じである。 +どれも、指摘が誤りだという主張ではない。**数えないのは、誤りだと示された棄却と、承認を妨げない軽微な指摘だけにする。** 誤りを示されていない重大な指摘は未反証として数え、修正の工程へ渡す。修正の担当の扱い(直す・却下の理由を返す・範囲外として起票する)は、人の判断待ちと同じである。 -数えることで増えるラウンドは、指摘 1 件につき最大 1 回である。修正の工程が却下の理由を `rejected_findings` へ残し、次のラウンドのレビュープロンプトへ渡すため、同じ論点は戻らない。戻っても新規性の一致(位置・近傍・本文)が前のラウンドの指摘と結び、新規に数えない。#156 が避けた「同じ論点で 5 ラウンド」(#69)は、却下の記録が無かった頃の形である。 +数えることで増えるラウンドは、指摘 1 件につき最大 1 回である。修正の工程が却下の理由を却下の記録へ残し、次のラウンドのレビュープロンプトへ渡す。そのため同じ論点は戻らない。戻っても、新規性の一致(位置・近傍・本文)が前のラウンドの指摘と結び、新規に数えない。#156 が避けた「同じ論点で 5 ラウンド」(#69)は、却下の記録が無かった頃の形である。 -採らない案と理由: +| 採らない案 | 採らない理由 | +| --- | --- | +| 順 4 の条件に「反証が空」を足す(既存の設計の決定 3) | A と H は数えられる。D・E(反証の機会があって支持されなかった)と B・F(根拠を欠く)は落ちたままで、親 #732 の完了条件「数えない判断が棄却された指摘に限られ」に当たらない | +| 反証が 0 件のラウンドは絞り込まない(#624 の候補 1) | ラウンド単位の判断では、2 者のうち 1 件だけが後から入った #583 の形と、2 者で相手が「立証できない」を返した #706 の形を塞げない | +| 誤りを示されていない重大な指摘を全件、人の判断待ちに入れる | 数え方は同じになる。しかし「独立に確かめた担当がいる」と「誰も確かめていない」が同じ名前になり、修正の担当と測定がその差を読めない。親 #732 は別の区分にすることを採る手としている | + +### 決定 3: 別の担当が支持した指摘が根拠の項目の欠けで落ちないよう、人の判断待ちの区分の条件から根拠の 2 項目を外す -- **順 4 の条件に「`critiques` が空」を足す(既存の設計の決定 3)。** A と H は数えられるが、D・E(反証の機会があって支持されなかった)と B・F(根拠を欠く)は落ちたままである。親 #732 の完了条件「数えない判断が棄却された指摘に限られ」に当たらない -- **反証が 0 件のラウンドは絞り込まない(#624 の候補 1)。** ラウンド単位では、2 者のうち 1 件だけが後から入った #583 の形と、2 者で相手が `insufficient_evidence` を返した #706 の形を塞げない -- **誤りを示されていない `major` を全件 `needs_human_judgment` に入れる。** 数え方は同じになるが、「独立に確かめた担当がいる」と「誰も確かめていない」が同じ名前になり、修正の担当と測定がその差を読めない。親 #732 は別の区分にすることを採る手としている +根拠の 2 項目は、別の担当がその指摘を確かめるための入力である。別の担当が支持を返した、または 2 者以上が同じ指摘へ独立に到達した時点で、確かめる目的は果たされている。その後で 2 項目の欠けを理由に落とすと、「2 人が見て同じことを言っている」情報が判定に効かない。#706 の PR #45 では、支持の付いた 2 件がこの形で落ちた。**順 4 は「重大な指摘で、支持が 1 件以上または出した担当が 2 者以上」**とする。 -### 決定 3: 別の担当が支持した指摘が根拠の項目の欠けで落ちないよう、順 4(`needs_human_judgment`)の条件から根拠の 2 項目を外す +根拠の有無の値そのものは変えずに残す。反証のプロンプトが読み、測定と記録がそのことを持つ。**区分が読まなくなるだけである。** 4 項目を持たない指摘を捨てないという既存の決定(`docs/06` の「4 項目を持たない指摘」)は、この変更で「捨てず、数える」まで進む。 -根拠の 2 項目(`evidence` / `falsification`)は、別の担当がその指摘を確かめるための入力である。別の担当が `support` を返した、または 2 者以上が同じ指摘へ独立に到達した時点で、確かめる目的は果たされている。その後で 2 項目の欠けを理由に落とすと、「2 人が見て同じことを言っている」情報が判定に効かない(#706 の PR #45 で `support` の付いた 2 件が落ちた形)。**順 4 は `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上**とする。 +採らない案: **根拠を欠く指摘は未反証に入れ、人の判断待ちには入れない。** 順 4 と順 5 の差が「独立に確かめたか」でなく「2 項目を書いたか」で決まり、支持の意味が薄れる。 -`has_evidence` の値そのものは変えずに残す。反証のプロンプトが読み、測定と記録がそのことを持つ。**区分が読まなくなるだけである。** 4 項目を持たない指摘を捨てないという既存の決定(`docs/06` の「4 項目を持たない指摘」)は、この変更で「捨てず、数える」まで進む。 +### 決定 4: 立証できないことを棄却と扱わないよう、反証の担当が「立証できない」「範囲外」だけを返した重大な指摘も未反証として数える -採らない案: **根拠を欠く指摘は `unrefuted` に入れ、`needs_human_judgment` には入れない。** 順 4 と順 5 の差が「独立に確かめたか」でなく「2 項目を書いたか」で決まり、`support` の意味が薄れる。 +反証の値「立証できない」(`insufficient_evidence`)の意味は「可能性はあるが立証できない」である。「範囲外」(`out_of_scope`)の意味は「この Pull Request の範囲から外れる」である。どちらも指摘が誤りだという主張ではない。規約も「否定の代わりに使わせない」と定めている。誤りを示せない指摘を数えずに収束させると、未解決の重大な指摘が残ったまま承認で終わる(#706 の観測そのもの)。**担当 2 者で相手が支持しなかった重大な指摘を数えないという #156 の判断を改める。** #624 は「#156 の設計どおりで対象外」と書いた。しかし親 #732 の採る手と完了条件は、この形も棄却ではない側に置く。 -### 決定 4: 立証できないことを棄却と扱わないよう、反証の担当が `insufficient_evidence` / `out_of_scope` だけを返した `major` も `unrefuted` として数える +この変更で、反証の担当が指摘を数から落とす手段は否定だけになる。プロンプトにそのことを書く(決定 9)。 -反証の値 `insufficient_evidence` は「可能性はあるが立証できない」で、`out_of_scope` は「この Pull Request の範囲から外れる」である。どちらも指摘が誤りだという主張ではなく、規約も「`refute` の代わりに使わせない」と定めている。誤りを示せない指摘を数えずに収束させると、未解決の `major` が残ったまま `approved` になる(#706 の観測そのもの)。**担当 2 者で相手が支持しなかった `major` を数えないという #156 の判断を改める。** #624 は「#156 の設計どおりで対象外」と書いたが、親 #732 の採る手と完了条件はこの形も棄却ではない側に置く。 +採らない案: **「立証できない」を返された重大な指摘は数えないまま残す(#156 の判断を保つ)。** 立証できない指摘と反証する担当がいない指摘は、反証の有無で状態ファイルから区別できる。しかし区別して前者だけを落とすと、実行検証を持たない Pull Request(文書だけの変更)では、担当が確かめられなかった重大な指摘がすべて落ちる。#706 の PR #45 は文書の Pull Request である。 -この変更で、反証の担当が指摘を数から落とす手段は `refute` だけになる。プロンプトにそのことを書く(決定 9)。 +### 決定 5: 修正の担当と測定が「なぜ確かめられていないか」を読めるよう、未反証の理由を「反証なし」「支持なし」の 2 値で状態ファイルに残す -採らない案: **`insufficient_evidence` を返された `major` は数えないまま残す(#156 の判断を保つ)。** 立証できない指摘と反証する担当がいない指摘を、状態ファイルから区別することはできる(`critiques` の有無)。しかし区別して前者だけを落とすと、実行検証を持たない Pull Request(文書だけの変更)では、担当が確かめられなかった `major` がすべて落ちる。#706 の PR #45 は文書の Pull Request である。 +棄却が棄却の理由を持つのと同じ形で、未反証が「なぜ独立に確かめられていないか」の理由(`unrefuted_reason`)を持つ。値は 2 つである。 -### 決定 5: 修正の担当と測定が「なぜ確かめられていないか」を読めるよう、`unrefuted` の理由を `unrefuted_reason`(`no_critique` / `not_supported`)として残す +| 値 | 意味 | 修正の担当の読み方 | +| --- | --- | --- | +| 反証なし(`no_critique`) | 反証を返した担当が 0 者 | 誰も見ていない指摘 | +| 支持なし(`not_supported`) | 反証はあるが、支持も否定も無い | 相手が確かめられなかった指摘 | -`rejected` が `rejection_reason` を持つのと同じ形で、`unrefuted` が「なぜ独立に確かめられていないか」を持つ。値は 2 つで、`no_critique`(反証を返した担当が 0 者)と `not_supported`(反証はあるが `support` も `refute` も無い)である。修正の担当は、`no_critique` なら誰も見ていない指摘、`not_supported` なら相手が確かめられなかった指摘として読める。測定は、1 者のループと 2 者のループでどちらの理由が多いかを同じ記録から読める。 +測定は、1 者のループと 2 者のループでどちらの理由が多いかを、同じ記録から読める。 -根拠の 2 項目の欠けは理由に入れない。`has_evidence` が既に持つ値で、理由と直交する(`no_critique` かつ根拠なし、のように組になる)。 +根拠の 2 項目の欠けは理由に入れない。根拠の有無が既に持つ値で、理由と直交する(反証なし、かつ根拠なし、のように組になる)。 -採らない案: **理由を持たず、`critiques` の有無から読む。** 読めはするが、`rejection_reason` と対になる形が崩れ、状態ファイルを読む人が区分ごとに違う導き方を覚えることになる。 +採らない案: **理由を持たず、反証の有無から読む。** 読めはする。しかし棄却の理由と対になる形が崩れ、状態ファイルを読む人が区分ごとに違う導き方を覚えることになる。 -### 決定 6: 旧い記録と語彙の付け替えを避けるため、`insufficient_evidence` の名前は残し、`minor` 以下の残余だけに当てる +### 決定 6: 旧い記録と語彙の付け替えを避けるため、立証不足の区分の名前は残し、軽微な指摘の残余だけに当てる -`major` の 4 つの形が `unrefuted` へ移った後、順 6(旧 5)に残るのは「再現も棄却もされていない `minor` 以下」だけである。名前を `not_blocking` などへ変えると、付け替える先が 3 つ同時に出る。旧い状態ファイルの `classification`、`measure.py` の読み方、`docs/04` / `docs/06` / 確定仕様の語彙である。**意味は「立証されておらず、修正必須でもない」に狭まるが、名前は変えない。** 区分の表の条件の列で意味を定める。 +重大な指摘の 4 つの形が未反証へ移った後、順 6(旧 5)に残るのは「再現も棄却もされていない軽微な指摘」だけである。名前(`insufficient_evidence`)を別の語へ変えると、付け替える先が 3 つ同時に出る。旧い状態ファイルの区分の値、測定スクリプトの読み方、規約と確定仕様の語彙である。**意味は「立証されておらず、修正必須でもない」に狭まるが、名前は変えない。** 区分の表の条件の列で意味を定める。 -採らない案: **`insufficient_evidence` を `not_blocking` へ改名する。** 語彙が正確になるが、この変更の目的(数えない判断を棄却に限る)に要らない。改名は測定の比較(変更の前後の記録)を難しくする。 +採らない案: **立証不足の区分を `not_blocking` へ改名する。** 語彙は正確になる。しかしこの変更の目的(数えない判断を棄却に限る)に要らず、改名は測定の比較(変更の前後の記録)を難しくする。 -### 決定 7: 収束の判定と測定が同じ指摘を数えるよう、`measure.py` の `proposed` を数える 3 区分に揃え、2 か所の集合の一致をテストで固定する +### 決定 7: 収束の判定と測定が同じ指摘を数えるよう、測定スクリプトの「この変更の方式」を数える 3 区分に揃え、2 か所の集合の一致をテストで固定する -方式 `proposed` は「この変更の方式が採る指摘」で、定義は `state.py` の `COUNTED_CLASSIFICATIONS` と同じ集合である(`measure.py` のコメントが明記する)。`state.py` だけに `unrefuted` を足すと、収束の判定が数えた指摘を測定が採らず、`proposed` の再現率が実際より低く出る。**両方の定数を `("verified_blocking", "needs_human_judgment", "unrefuted")` にする。** `test_measure.py` に 2 つの値が等しいことを見るテストを足す。 +測定の方式「この変更の方式」(`proposed`)は、「この変更の方式が採る指摘」を数える。その定義は、状態の管理スクリプトの数える区分の集合と同じ集合である(測定スクリプトのコメントが明記する)。状態の管理スクリプトだけに未反証を足すと、収束の判定が数えた指摘を測定が採らない。この方式の再現率が実際より低く出る。**両方の定数を `("verified_blocking", "needs_human_judgment", "unrefuted")` にする。** 測定のテストに、2 つの値が等しいことを見るテストを足す。 -`measure.py` が `state.py` を import する形は採らない。`measure.py` は状態ファイルを読むだけの独立したスクリプトで(確定仕様の決定「測定は独立したスクリプトにする」)、`state.py` を読み込むと `gh` を呼ぶ側の前提を持ち込む。 +測定スクリプトが状態の管理スクリプトを読み込む形は採らない。測定スクリプトは状態ファイルを読むだけの独立したスクリプトである(確定仕様の決定「測定は独立したスクリプトにする」)。状態の管理スクリプトを読み込むと、GitHub CLI を呼ぶ側の前提を持ち込む。 ### 決定 8: 反証が届いていないラウンドを全件を数える側へ戻すため、反証が揃わない取り込みでは先に付いていた印を外す -既存の設計の決定 5 をそのまま引き継ぐ。`collect-critiques` は揃わないとき印を付けないが、既に付いている印は外さない。取り直しの後も `evidence_rounds` にそのラウンドが残ると、「印を付けないため、このラウンドは全件を数えます」の出力と実際の数え方が食い違う。`_handle_incomplete_critiques` の先頭でそのラウンドの番号を `evidence_rounds` から除く。 +既存の設計の決定 5 をそのまま引き継ぐ。反証の取り込みは、揃わないとき印を付けない。しかし既に付いている印は外さない。取り直しの後もそのラウンドに印が残ると、「印を付けないため、このラウンドは全件を数えます」の出力と実際の数え方が食い違う。反証の不足の扱いの先頭で、そのラウンドの番号を印の一覧から除く。 -決定 2 の後もこの決定は要る。印の無いラウンドは `rejected` と `minor` も数えるため、反証が揃っていない(`refute` が届いていないかもしれない)ラウンドでは、全件を数える側が安全である。 +決定 2 の後もこの決定は要る。印の無いラウンドは棄却と軽微な指摘も数える。反証が揃っていない(否定が届いていないかもしれない)ラウンドでは、全件を数える側が安全である。 -### 決定 9: 誤りを示せる指摘が `insufficient_evidence` へ流れないよう、反証のプロンプトに「`insufficient_evidence` は指摘を数から落とさない」を書く +### 決定 9: 誤りを示せる指摘が「立証できない」へ流れないよう、反証のプロンプトに「立証できないと返しても指摘は数から落ちない」を書く -決定 4 の後、反証の担当が指摘を数から落とす手段は `refute` だけになる。プロンプトがそのことを言わないと、担当は従来どおり「判断できないものは `insufficient_evidence`」と返す。誤りを示せる指摘まで `insufficient_evidence` に流れ、修正の工程へ渡る。`critique.sh` の「返す値」の表の下に 1 段落を足す。書くのは 2 つで、`insufficient_evidence` を返しても指摘は数から落ちず修正の工程へ渡ることと、誤りを示せるなら理由を添えて `refute` を返すことである。 +決定 4 の後、反証の担当が指摘を数から落とす手段は否定だけになる。プロンプトがそのことを言わないと、担当は従来どおり「判断できないものは立証できない」と返す。誤りを示せる指摘まで「立証できない」に流れ、修正の工程へ渡る。反証のプロンプトの「返す値」の表の下に 1 段落を足す。書くのは 2 つである。「立証できない」を返しても指摘は数から落ちず、修正の工程へ渡ること。誤りを示せるなら、理由を添えて否定を返すこと。 反証の値の語彙(5 つ)は変えない。変えるのは説明だけである。 -### 決定 10: #729 が固定した境界を守るため、`judge` の本体・終了コード・出力の変数は変えず、区分の内訳を新しい出力として足さない +### 決定 10: #729 が固定した境界を守るため、収束の判定の本体・終了コード・出力の変数は変えず、区分の内訳を新しい出力として足さない -#729(G3)の設計が `judge` の終了コード 0 / 2 / 7 / 8 と出力の変数を境界として固定している。この変更が触るのは `_new_finding_count` から下と `_handle_incomplete_critiques` である。「下」は `_counted_finding_keys` / `_apply_classification` / `_classify_finding` を指す。`cmd_judge` の行は書き換えない。区分ごとの件数を `judge` の標準出力へ足す案は採らない。骨組み(`SKILL.md`)が読まない値を足しても読む側が無く、出力の契約(`docs/04`)を増やすだけである。件数は状態ファイルの `classification` から読める。 +#729(G3)の設計が、収束の判定の終了コード 0 / 2 / 7 / 8 と出力の変数を境界として固定している。この変更が触るのは、新しい指摘の数え上げから下と、反証の不足の扱いである。「下」は数える指摘の抽出・区分の書き込み・区分の判定を指す。収束の判定の本体(`cmd_judge`)の行は書き換えない。区分ごとの件数を収束の判定の標準出力へ足す案は採らない。骨組み(`SKILL.md`)が読まない値を足しても読む側が無く、出力の契約(`docs/04`)を増やすだけである。件数は状態ファイルの区分から読める。 ### 決定 11: 確定仕様が古いまま配布される期間を作らないため、確定仕様の区分の表は実装 Pull Request の同じ差分で更新する -確定仕様 `docs/specifications/cross-review-evidence-based.md` は区分の表・区分ごとの行き先・収束の判定の表を持つ。「数えるのは 2 区分だけ」を決定としても書いている。`plan-to-spec` の工程まで待つと、実装が配布されてから確定仕様が古いまま残る期間ができ、`check-doc-staleness.py` がそれを拾わない(確定仕様は検査の対象外である)。**区分の表・行き先の表・決定の表・テスト観点の行を、実装の差分と同じ Pull Request で直す。** 経緯の節(背景・関連リンク)は `plan-to-spec` が足す。 +確定仕様 `docs/specifications/cross-review-evidence-based.md` は、区分の表・区分ごとの行き先・収束の判定の表を持つ。「数えるのは 2 区分だけ」を決定としても書いている。仕様化の工程(`plan-to-spec`)まで待つと、実装が配布されてから確定仕様が古いまま残る期間ができる。文書の鮮度の検査(`check-doc-staleness.py`)はそれを拾わない(確定仕様は検査の対象外である)。**区分の表・行き先の表・決定の表・テスト観点の行を、実装の差分と同じ Pull Request で直す。** 経緯の節(背景・関連リンク)は仕様化の工程が足す。 -## 実測 +## データ構造 -11 通りの最小の指摘を `_classify_finding` に渡し、いまの区分と変更後の区分を並べた。数え方が変わるのは A・B・D・E・F・H の 6 件で、いずれも `major` である。`minor`(C)と棄却(I・J)と再現(K)と支持つき根拠あり(G)の 5 件は変わらない。 +状態ファイル `cross-review-pr<番号>-state.json` の `review_findings[]` の要素で変わるのは 2 項目である。**新しい最上位の項目は無く、`version` の類は持たないため上げない。** -`develop` 9eaebe14、Python 3.14.4。実行検証は `not_run`、担当 1 者の指摘は `origin_runtimes` 1 者である。 +| 項目 | 型 | 空を許すか | 変更 | +| --- | --- | --- | --- | +| `classification` | 文字列 | 許さない(区分の後) | 値の集合が 6 つになる。`verified_blocking` / `verified_non_blocking` / `rejected` / `needs_human_judgment` / **`unrefuted`** / `insufficient_evidence` | +| `unrefuted_reason` | 文字列 | 項目が無いことを許す | **新設。** `classification` が `unrefuted` のときだけ持つ。値は `no_critique` / `not_supported`。区分が変わると消える(`rejection_reason` と同じ扱い) | +| `rejection_reason` | 文字列 | 項目が無いことを許す | 変わらない。`rejected` のときだけ持つ | +| `has_evidence` | 真偽値 | 許さない | 変わらない。**区分の条件から外れるが、値は残る** | -| 記号 | 入力 | いまの区分 | 数える | 変更後の区分 | 数える | -| --- | --- | --- | --- | --- | --- | -| A | `major`、根拠あり、反証なし(1 者) | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | -| B | `major`、根拠なし、反証なし | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | -| C | `minor`、根拠あり、反証なし | `insufficient_evidence` | いいえ | `insufficient_evidence` | いいえ | -| D | `major`、根拠あり、相手が `insufficient_evidence` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | -| E | `major`、根拠あり、相手が `out_of_scope` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | -| F | `major`、根拠なし、相手が `support` | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | -| G | `major`、根拠あり、相手が `support` | `needs_human_judgment` | はい | `needs_human_judgment` | はい | -| H | `major`、根拠なし、2 者が独立に出した | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | -| I | `major`、相手が `refute` | `rejected` | いいえ | `rejected` | いいえ | -| J | `major`、`not_reproduced` | `rejected` | いいえ | `rejected` | いいえ | -| K | `major`、`reproduced` | `verified_blocking` | はい | `verified_blocking` | はい | +### 区分の条件 + +区分の判定(`_classify_finding`)が、上から順に当てる。 + +| 順 | 区分 | 条件 | 数える | 理由の項目 | +| --- | --- | --- | --- | --- | +| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | +| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | +| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | +| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | はい | — | +| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason` | +| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | + +未反証の理由は、反証の記録が空なら反証なし、1 件以上あれば支持なしである。反証の記録は提案者以外の値だけを持つため、空は「反証を返した担当が 0 者」を表す。 + +### 機能とデータの対応 + +| 機能 | 読む | 書く | +| --- | --- | --- | +| F1 数える | `severity` / `verification.result` / `critiques[].verdict` / `origin_runtimes` | `classification` | +| F2 理由を残す | `critiques` の有無 | `unrefuted_reason`(他の区分では消す) | +| F3 印を戻す | `evidence_rounds` / `rounds[].critique_relaunched` | `evidence_rounds`(番号を除く) | +| F4 測る | `classification` / `evidence_rounds` / `merged_into` | (測定の出力。状態ファイルは書かない) | + +### 移行 + +この変更より前の状態ファイルは、次に収束の判定か反証の取り込みを呼んだ時点で区分が付け直される。区分は保存された値を読まず、毎回、区分の書き込みが計算する。未反証の理由はそのときに付く。測定スクリプトは保存された区分を読むため、収束の終わった旧い記録は旧い値のまま測られ、未反証は出ない。 + +## 入出力の契約 + +**状態の管理スクリプトの引数・終了コード・標準出力の変数は変わらない。** 変わるのは内部関数の契約だけである。 + +| 関数 | 変更前 | 変更後 | +| --- | --- | --- | +| `_classify_finding(finding) -> str` | 5 つの値を返す。順 4 に `has_evidence` を求める | 6 つの値を返す。順 4 から `has_evidence` を外し、順 5 に `unrefuted` を置く。**純粋な関数で、出力も終了コードも持たない**(変わらない) | +| `_apply_classification(finding) -> str` | `rejected` に `rejection_reason` を書き、他では消す | 加えて `unrefuted` に `unrefuted_reason` を書き、他では消す | +| `COUNTED_CLASSIFICATIONS`(`state.py` / `measure.py`) | `("verified_blocking", "needs_human_judgment")` | `("verified_blocking", "needs_human_judgment", "unrefuted")`。2 か所の値は等しい | +| `_handle_incomplete_critiques(pr, st, round_no, missing)` | 印を付けない。既にある印は残す。終了コード 7 と `CRITIQUE_RETRY_AGENTS` を返す(変わらない) | 先頭で `evidence_rounds` からそのラウンドの番号を除く。それ以外は変わらない | +| `_new_finding_count(st, pr) -> (int, bool)` | 印のあるラウンドを 2 区分へ絞る | 印のあるラウンドを 3 区分へ絞る。返る値の意味は変わらない | +| `cmd_judge` | 0 / 2 / 7 / 8 / 1 | 変わらない。本体の行を書き換えない | +| `critique.sh` のプロンプト | 「判断できないものは `insufficient_evidence` にする」 | 加えて「`insufficient_evidence` を返しても指摘は数から落ちず、修正の工程へ渡る。誤りを示せるなら理由を添えて `refute`」 | ## 構成要素 -変えるのは `state.py` の 4 つの関数・定数と `measure.py` の定数、反証のプロンプト、規約 3 文書、確定仕様、テスト 4 ファイルである。新設する要素は無い。 +変えるのは、状態の管理スクリプトの 4 つの関数・定数、測定スクリプトの定数、反証のプロンプト、規約 3 文書、確定仕様、テスト 4 ファイルである。新設する要素は無い。 | 要素 | 責務 | 変える・新設 | | --- | --- | --- | @@ -147,11 +248,11 @@ graph TD M[measure.py _proposed] --> CC2[COUNTED_CLASSIFICATIONS measure.py] ``` -図の辺は呼び出しと参照を表す。`judge` と `collect-critiques` と `measure.py` は、変える要素の呼び出し元として置いた既存の要素である。プロンプト・規約・確定仕様・テストは呼び出しを持たないため図に含めない。 +図の辺は呼び出しと参照を表す。収束の判定・反証の取り込み・測定スクリプトは、変える要素の呼び出し元として置いた既存の要素である。プロンプト・規約・確定仕様・テストは呼び出しを持たないため、図に含めない。 -**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` の 1 プロセスと、状態ファイルを読む `measure.py` の 1 プロセスである。外部との出入り(`gh`)は変わらない。 +**文脈と配置は変わらない。** 動くのは 2 つのプロセスである。ホストの CLI から起動される状態の管理スクリプトと、状態ファイルを読む測定スクリプトである。外部との出入り(GitHub CLI)は変わらない。 -### 置き場所 +## 置き場所 ```text plugins/ndf/skills/cross-review/ @@ -173,60 +274,9 @@ docs/specifications/cross-review-evidence-based.md # 区分・行き先・決 `dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。 -## データ構造 - -状態ファイル `cross-review-pr<番号>-state.json` の `review_findings[]` の要素で変わるのは 2 項目である。**新しい最上位の項目は無く、`version` の類は持たないため上げない。** - -| 項目 | 型 | 空を許すか | 変更 | -| --- | --- | --- | --- | -| `classification` | 文字列 | 許さない(区分の後) | 値の集合が 6 つになる。`verified_blocking` / `verified_non_blocking` / `rejected` / `needs_human_judgment` / **`unrefuted`** / `insufficient_evidence` | -| `unrefuted_reason` | 文字列 | 項目が無いことを許す | **新設。** `classification` が `unrefuted` のときだけ持つ。値は `no_critique` / `not_supported`。区分が変わると消える(`rejection_reason` と同じ扱い) | -| `rejection_reason` | 文字列 | 項目が無いことを許す | 変わらない。`rejected` のときだけ持つ | -| `has_evidence` | 真偽値 | 許さない | 変わらない。**区分の条件から外れるが、値は残る** | - -### 区分の条件(`_classify_finding`) - -| 順 | 区分 | 条件 | 数える | 理由の項目 | -| --- | --- | --- | --- | --- | -| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | -| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | -| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | -| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | はい | — | -| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason` | -| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | - -`unrefuted_reason` は `critiques` が空なら `no_critique`、1 件以上あれば `not_supported` である。`critiques` は提案者以外の値だけを持つため、空は「反証を返した担当が 0 者」を表す。 - -### 機能とデータの対応 - -| 機能 | 読む | 書く | -| --- | --- | --- | -| F1 数える | `severity` / `verification.result` / `critiques[].verdict` / `origin_runtimes` | `classification` | -| F2 理由を残す | `critiques` の有無 | `unrefuted_reason`(他の区分では消す) | -| F3 印を戻す | `evidence_rounds` / `rounds[].critique_relaunched` | `evidence_rounds`(番号を除く) | -| F4 測る | `classification` / `evidence_rounds` / `merged_into` | (測定の出力。状態ファイルは書かない) | - -### 移行 - -この変更より前の状態ファイルは、次に `judge` か `collect-critiques` を呼んだ時点で `classification` が付け直される。区分は保存された値を読まず、毎回 `_apply_classification` が計算する。`unrefuted_reason` はそのときに付く。`measure.py` は保存された `classification` を読むため、収束の終わった旧い記録は旧い値のまま測られ、`unrefuted` は出ない。 - -## 入出力の契約 - -**`state.py` の引数・終了コード・標準出力の変数は変わらない。** 変わるのは内部関数の契約だけである。 - -| 関数 | 変更前 | 変更後 | -| --- | --- | --- | -| `_classify_finding(finding) -> str` | 5 つの値を返す。順 4 に `has_evidence` を求める | 6 つの値を返す。順 4 から `has_evidence` を外し、順 5 に `unrefuted` を置く。**純粋な関数で、出力も終了コードも持たない**(変わらない) | -| `_apply_classification(finding) -> str` | `rejected` に `rejection_reason` を書き、他では消す | 加えて `unrefuted` に `unrefuted_reason` を書き、他では消す | -| `COUNTED_CLASSIFICATIONS`(`state.py` / `measure.py`) | `("verified_blocking", "needs_human_judgment")` | `("verified_blocking", "needs_human_judgment", "unrefuted")`。2 か所の値は等しい | -| `_handle_incomplete_critiques(pr, st, round_no, missing)` | 印を付けない。既にある印は残す。終了コード 7 と `CRITIQUE_RETRY_AGENTS` を返す(変わらない) | 先頭で `evidence_rounds` からそのラウンドの番号を除く。それ以外は変わらない | -| `_new_finding_count(st, pr) -> (int, bool)` | 印のあるラウンドを 2 区分へ絞る | 印のあるラウンドを 3 区分へ絞る。返る値の意味は変わらない | -| `cmd_judge` | 0 / 2 / 7 / 8 / 1 | 変わらない。本体の行を書き換えない | -| `critique.sh` のプロンプト | 「判断できないものは `insufficient_evidence` にする」 | 加えて「`insufficient_evidence` を返しても指摘は数から落ちず、修正の工程へ渡る。誤りを示せるなら理由を添えて `refute`」 | - ## 処理の流れ -`judge` は印のあるラウンドだけ指摘ごとに区分を決め、数える 3 区分へ絞る。印の無いラウンドは payload の全件を数える。 +収束の判定は、印のあるラウンドだけ指摘ごとに区分を決め、数える 3 区分へ絞る。印の無いラウンドは、レビュー結果の本体(payload)の全件を数える。 ```mermaid graph TD @@ -246,9 +296,9 @@ graph TD T -->|いいえ| UR[順 5 unrefuted 数える] ``` -順 5 に入った指摘は `critiques` の有無で `unrefuted_reason` が決まる。反証の取り込みで揃わないとき、`_handle_incomplete_critiques` がそのラウンドの印を外す。次に `judge` が数えるとき「印があるか」が「いいえ」へ進み、取り直して揃えば `_mark_evidence_round` が印を戻す。 +順 5 に入った指摘は、反証の有無で未反証の理由が決まる。反証の取り込みで揃わないとき、反証の不足の扱いがそのラウンドの印を外す。次に収束の判定が数えるとき「印があるか」が「いいえ」へ進む。取り直して揃えば、印を付ける処理が印を戻す。 -**1 者で回したラウンドの収束は、`unrefuted` の新規性と `round_passes` の 2 つで決まる。** 担当が `APPROVE` か重大な指摘の無い `COMMENT` を返せば `round_passes` で収束する。`REQUEST_CHANGES` なら `unrefuted` の `major` が新規に数えられ、修正の工程へ進む。修正の後のラウンドで同じ指摘が戻れば前のラウンドと一致して新規 0 件になり、戻らなければ `APPROVE` で収束する。 +**1 者で回したラウンドの収束は、未反証の新規性と、全員が通したかの 2 つで決まる。** 担当が承認か、重大な指摘の無いコメントを返せば、全員が通したとして収束する。修正要求なら、未反証の重大な指摘が新規に数えられ、修正の工程へ進む。修正の後のラウンドで同じ指摘が戻れば、前のラウンドと一致して新規 0 件になる。戻らなければ承認で収束する。 ## 非機能の実現方式 @@ -259,7 +309,7 @@ graph TD ## テスト設計 -置き場所は `plugins/ndf/skills/cross-review/tests/` の下である。区分は `_classify_finding` の単体で、数え方は状態ファイルを組んだ `_new_finding_count` と `cmd_judge` の終了コードで確かめる。 +置き場所は `plugins/ndf/skills/cross-review/tests/` の下である。区分は区分の判定の単体で確かめる。数え方は、状態ファイルを組んだ新しい指摘の数え上げと、収束の判定の終了コードで確かめる。 | 受け入れ条件 | 何で確かめるか | 置き場所 | | --- | --- | --- | @@ -285,10 +335,10 @@ graph TD | 項目 | 内容 | いつ決まるか | | --- | --- | --- | -| 収束までのラウンド数の増え方 | 実測の D・E(相手が `insufficient_evidence` を返した `major`)を数えることで、2 者のループのラウンド数がどれだけ増えるかは測っていない | 配布後の運用で `measure.py` の出力を見る | -| `unrefuted` が多いときの修正の担当の負荷 | 誰も確かめていない `major` が修正の工程へ渡る件数が増える。却下の理由を書く回数が増える | 同上 | +| 収束までのラウンド数の増え方 | 実測の D・E(相手が「立証できない」を返した重大な指摘)を数えることで、2 者のループのラウンド数がどれだけ増えるかは測っていない | 配布後の運用で測定スクリプトの出力を見る | +| 未反証が多いときの修正の担当の負荷 | 誰も確かめていない重大な指摘が修正の工程へ渡る件数が増える。却下の理由を書く回数が増える | 同上 | | 担当を 3 者以上へ広げたときの順 3 と順 4 | 確定仕様が「広げるときに決め直す」としている。この変更は 2 者のまま | #478 の後 | -| `insufficient_evidence` の改名 | 決定 6 で残す。意味が狭まった名前をいつ付け替えるかは未決 | 要求が出たとき | +| 立証不足の区分の改名 | 決定 6 で残す。意味が狭まった名前をいつ付け替えるかは未決 | 要求が出たとき | | テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | | プロンプトの文言 | 決定 9 の段落は、含める 2 つの内容だけを決めた | **実装で決める** | @@ -296,8 +346,8 @@ graph TD | 相手 | 決めた契約 | | --- | --- | -| #730(G5、#583) | **#583 の収束の部分はこの設計で塞がる。** 起動し直した担当の指摘は、取り込みの経路が証拠集約(`verify-findings` / `critique-round.sh`)を通っても通らなくても、反証を持たない `major` として `unrefuted` に入り数えられる。G5 が投稿を進行側へ移す設計を採っても、この判定は変わらない。G5 に残るのは投稿の重なりと、`JUDGE_RC -eq 7` の分岐が証拠集約を通らない点だけである | -| #729(G3) | `judge` の終了コード 0 / 2 / 7 / 8 と出力の変数を変えない。`cmd_judge` の本体の行を書き換えない(決定 10)。この設計が触る関数は `_new_finding_count` から下と `_handle_incomplete_critiques` で、G3 の `_read_review_result_file` と重ならない | +| #730(G5、#583) | **#583 の収束の部分はこの設計で塞がる。** 起動し直した担当の指摘は、取り込みの経路が証拠集約(`verify-findings` / `critique-round.sh`)を通っても通らなくても、反証を持たない重大な指摘として未反証に入り数えられる。G5 が投稿を進行側へ移す設計を採っても、この判定は変わらない。G5 に残るのは投稿の重なりと、`JUDGE_RC -eq 7` の分岐が証拠集約を通らない点だけである | +| #729(G3) | 収束の判定の終了コード 0 / 2 / 7 / 8 と出力の変数を変えない。本体(`cmd_judge`)の行を書き換えない(決定 10)。この設計が触る関数は `_new_finding_count` から下と `_handle_incomplete_critiques` で、G3 の `_read_review_result_file` と重ならない | | #727(G1) | `--only` の意味づけ(使える者を 1 者へ絞る)と担当の決め方は G1 が持つ。1 者のときに何を数えるかはこの設計が持つ(処理の流れの最後の段落)。G1 の実装が 1 者で回す分岐を入れても、この設計が先に入っていれば #624 の誤った収束を踏まない。**既存の要求の文書と契約の文書に足す案内の段落は G1 も同じファイルへ足す可能性がある。** 節が違うため衝突は起きにくいが、起きたら後からマージする側が解く | | #728(G4) | 触るファイルが重ならない(`refactor_lib` は区分を持たない) | @@ -308,9 +358,9 @@ graph TD | 既存の決定 | この文書 | 扱い | | --- | --- | --- | | 決定 1(P4 / P5 に分け、P4 を先に出す) | — | 束の分け方は実行計画(G1 / G2)が引き継いだ。P4 が先という順序は保つ(G1 の 1 者で回す分岐が #624 を踏まないため) | -| 決定 2(数えない判断は「反証を受けた単独の指摘」だけに掛け、記録から導く) | 決定 2・4・5 | **改める。** 数えないのは棄却と `minor` 以下だけにし、反証を受けて支持されなかった `major` も数える。記録から導く(項目を足さない)方針は `unrefuted_reason` を足すことで改める | -| 決定 3(順 4 の `support` の条件だけを外し、根拠と重大度の条件は残す) | 決定 2・3 | **改める。** 根拠の条件を外し、支持も反証も無い `major` は別の区分へ入れる。重大度の条件(`minor` を数えない)は引き継ぐ | -| 決定 4(区分の名前を増やさない) | 決定 2・6・7 | **改める。** `unrefuted` を足す。増やさない理由だった 2 か所の集合と語彙の同期は、テストと同じ差分で受ける | +| 決定 2(数えない判断は「反証を受けた単独の指摘」だけに掛け、記録から導く) | 決定 2・4・5 | **改める。** 数えないのは棄却と軽微な指摘だけにし、反証を受けて支持されなかった重大な指摘も数える。記録から導く(項目を足さない)方針は、未反証の理由を足すことで改める | +| 決定 3(順 4 の支持の条件だけを外し、根拠と重大度の条件は残す) | 決定 2・3 | **改める。** 根拠の条件を外し、支持も反証も無い重大な指摘は別の区分へ入れる。重大度の条件(軽微な指摘を数えない)は引き継ぐ | +| 決定 4(区分の名前を増やさない) | 決定 2・6・7 | **改める。** 未反証を足す。増やさない理由だった 2 か所の集合と語彙の同期は、テストと同じ差分で受ける | | 決定 5(反証が揃わない取り込みでは先に付いていた印を外す) | 決定 8 | 引き継ぐ | ## 関連する文書 diff --git a/issues/issue-732-624-706-requirements.md b/issues/issue-732-624-706-requirements.md index cf3b09e4..9cbeeb63 100644 --- a/issues/issue-732-624-706-requirements.md +++ b/issues/issue-732-624-706-requirements.md @@ -1,63 +1,47 @@ -# cross-review: 反証する担当がいない・実行検証が無い・支持が付かない major が数えられず、修正の要る指摘を残して approved になる → 誤りを示された指摘と minor だけを数えない(#732 #624 #706 の要求) +# cross-review: 誤りを示されていない重大な指摘が数えられずに承認で終わる → 数えない指摘を棄却と軽微な指摘に限る(#732 #624 #706 の要求) ## 目的 -cross-review の収束の判定が、誰にも誤りを示されていない `major` の指摘を「数えない」区分へ落とし、新しい指摘 0 件として `approved` で終わる。反証する担当がいない 1 者のループ(#624)、実行検証も支持も無い指摘(#706)、起動し直した担当の指摘(#583 の収束の部分)でこの形になり、修正の要る指摘が修正の工程へ渡らない。 +**この変更の後、cross-review の収束の判定が数えないのは、棄却された指摘(実行して再現しなかった・否定を受けた)と軽微な指摘だけになる。** 誰にも誤りを示されていない重大な指摘は、担当の数・反証の有無・根拠の項目の有無によらず新しい指摘として残り、修正の工程へ渡る。 -この変更の後、数えないのは棄却された指摘(実行して再現しなかった・`refute` を受けた)と `minor` 以下の指摘だけになる。誰にも誤りを示されていない `major` は、担当の数・反証の有無・根拠の項目の有無によらず新しい指摘として残り、修正の工程へ渡る。上の 3 つの場面は同じ 1 つの直しで数えられ、`insufficient_evidence` が 2 つの意味を兼ねる状態が解けて、区分を読めば未解決の理由が分かる。 +いまは、誰にも誤りを示されていない重大な指摘が「数えない」区分へ落ち、新しい指摘 0 件として承認で終わる。修正の要る指摘が修正の工程へ渡らない。この形は 3 つの場面で出る。3 つの場面は同じ 1 つの直しで数えられる。立証不足の区分が 2 つの意味を兼ねる状態が解け、区分を読めば未解決の理由が分かる。 -## 依頼(原文) +| 場面 | 課題 | +| --- | --- | +| 反証する担当がいない 1 者のループ | #624 | +| 実行検証も支持も無い指摘 | #706 | +| 起動し直した担当の指摘 | #583 の収束の部分 | -3 件の依頼はいずれも、指摘が数えない区分へ落ちたまま収束する現象を報告している。#732 が根本原因と採る手を定め、#624 と #706 がそれぞれの再現を持つ。 +## 用語と識別子の対応 -### #732(根本原因の親) +本文は左の業務用語で書く。識別子は、コードブロック・表・受け入れ条件(検査の入力と期待値をそのまま定める)でそのまま使う。 -> **cross-review の収束で「数えない」とする区分。** 場所は `plugins/ndf/skills/cross-review/scripts/state.py` の `_classify_finding`(3443 行目)と `COUNTED_CLASSIFICATIONS`(3431 行目)、規約 `docs/06-evidence.md` の区分の表である。 -> -> - `insufficient_evidence` が 2 つの場合を兼ねている。反証の機会があって支持されなかった場合と、立証の手段が無かった場合(反証する担当がいない・実行検証が無い・根拠の項目が欠ける)である -> - どちらも新規性の数から落ちるため、立証の手段が無かっただけの未解決の `major` が残ったまま収束する -> -> #583 の収束の部分も、同じ区分を通る。 -> -> ## 採る手 -> -> 分離(`extract_strategy`)。「棄却された(`refute` / `not_reproduced`)」と「立証の機会が無かった」を別の区分にし、数えない判断を前者だけに掛ける。 -> -> ## 完了条件 -> -> - 数えない判断が棄却された指摘に限られ、反証の機会が無かった `major` が新規性に残ることを検査が確かめる -> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる(子 issue はその時点の棚卸が「閉じてよい」で閉じる) - -### #624 - -> `cross-review` を `--only codex` で 1 者だけにして回すと、codex が `REQUEST_CHANGES` で新しい指摘を投稿したラウンドでも、`judge` が収束と判定する(ndf 10.10.1、2026-09-13)。 -> -> `--only` で担当が 1 者だと、反証する他の担当がいないため数える区分に入る指摘が 0 件になり、`_evaluate_convergence`(`:2848`)の `findings_measurable and new_findings == 0` が真になる。 -> -> 状態ファイルを最小の形で組み、関数を直接呼んだ再現(`--only codex`、証拠付きの major 1 件、印の付いたラウンド 1): -> -> ```text -> new_finding_count (0, True) classification insufficient_evidence -> round_passes False converged True -> ``` - -### #706 - -> `cross-review` で、**未解決の `major` が残っているのに、指摘が数えない区分 `insufficient_evidence` へ落ち、新規性の母集合から外れて「新しい指摘 0 件」と判定される。** 結果は `approved` になる。 -> -> ideabase の PR #45 のラウンド 2 で、3 件の指摘がいずれも `suggested_check` を実行できず `insufficient_evidence` になり、judge が `NEW_FINDINGS=0` を返した。指摘のうち 2 件は別の担当が `support` を付けており、こちらでもコードを読んで再現を確かめられた(文書が実装と食い違っていた)。 -> -> 数えない区分へ落ちるのは、次のいずれかに当たる指摘である。 -> -> - 反証の担当が `support` を返さなかった(`insufficient_evidence` を返した、または反証が無い) -> - 根拠の 2 項目(`evidence` / `falsification`)のどちらかが欠けている -> - 重要度が `minor` 以下である -> -> 対処の候補: 実行検証ができない指摘は `insufficient_evidence` ではなく別の区分(未検証)として新規性に数える、または他の担当の `support` が付いた指摘は区分によらず数える。 +| 用語 | 識別子 | 意味 | +| --- | --- | --- | +| 数える | `COUNTED_CLASSIFICATIONS`(数える区分の集合) | 収束の判定(新規性の層)が、そのラウンドの新しい指摘として件数に入れること | +| 重大な指摘 / 軽微な指摘 | `severity` の `major` / `minor`(`major` 以上 / `minor` 以下) | 指摘の重大度。軽微な指摘は承認(`APPROVE`)を妨げない | +| 区分 | `classification` | 印のあるラウンドで指摘ごとに付く値 | +| 棄却 | `rejected`(理由は `rejection_reason`) | 指摘が誤りだと示されたこと。実行して再現しなかった(`not_reproduced`)か、別の担当が否定(`refute`)を返した | +| 反証の機会 | `critiques` が 1 件以上 | 提案者以外の担当が、その指摘へ賛否を返したこと | +| 反証の値: 支持 / 否定 / 立証できない / 範囲外 | `support` / `refute` / `insufficient_evidence` / `out_of_scope` | 反証の担当が返す値のうち、この文書が扱う 4 つ | +| 立証の機会が無かった | — | 反証の機会が無い、または反証はあるが支持も否定も無い。誤りは示されていないが、独立に確かめた担当もいない | +| 未反証 | `unrefuted`(理由は `unrefuted_reason`。値は反証なし `no_critique` / 支持なし `not_supported`) | 新しい区分。重大な指摘で、再現も棄却もされておらず、独立に確かめた担当もいない | +| 人の判断待ち | `needs_human_judgment` | 独立に確かめた担当がいる重大な指摘の区分 | +| 立証不足 | `insufficient_evidence`(区分の値) | 変更後は、再現も棄却もされていない軽微な指摘だけの区分 | +| 独立に確かめた | `support` が 1 件以上、または `origin_runtimes` が 2 者以上 | 提案者以外の担当が支持を返した、または 2 者以上が同じ指摘を独立に出した | +| 根拠の 2 項目 | `evidence` と `falsification`(両方が空でないとき `has_evidence` が真) | 別の担当が確かめるための入力 | +| 印 | `evidence_rounds` | そのラウンドが統合・実行検証・反証を通ったこと。印のあるラウンドだけが区分で絞られ、無いラウンドは全件を数える | +| 代表 | `merged_into` を持たない側 | 統合した組で判定が読む 1 件。束ねられた側は `merged_into` を持つ | +| 却下の記録 | `rejected_findings` | 修正の工程が却下した指摘と理由。次のラウンドのレビュープロンプトへ渡る | +| 状態の管理スクリプト / 測定スクリプト / 反証のプロンプト | `state.py` / `measure.py` / `critique.sh` | いずれも `plugins/ndf/skills/cross-review/scripts/` の下 | +| 区分の判定 / 区分の書き込み / 新しい指摘の数え上げ / 反証の不足の扱い | `_classify_finding` / `_apply_classification` / `_new_finding_count` / `_handle_incomplete_critiques` | 状態の管理スクリプトの内部関数 | +| 収束の判定 / 反証の取り込み | `judge`(本体は `cmd_judge`)/ `collect-critiques` | 状態の管理スクリプトの副コマンド | +| 1 者に絞る引数 | `--only` | 使える担当を 1 者へ絞る起動の引数。意味づけは #727 が持つ | +| 承認で終わる | `approved` | 収束ループの結末 | ## 対象範囲 -変えるのは、区分の判定と数える集合、印の外し方、それらの説明と確定仕様、テストである。`--only` の扱い・投稿の経路・終了コードは並行する設計が持つ。 +変えるのは、区分の判定と数える集合、印の外し方、それらの説明と確定仕様、テストである。1 者に絞る引数の扱い・投稿の経路・終了コードは並行する設計が持つ。 含む: @@ -85,7 +69,7 @@ cross-review の収束の判定が、誰にも誤りを示されていない `ma ## 前提 -数える区分を増やしても、修正の工程が全件を読み、却下の記録が同じ論点を止め、新規性の一致が前のラウンドの指摘を新規に数えない。この 3 つが成り立つことを前提にする。 +数える区分を増やしても成り立つことを 3 つ前提にする。修正の工程が全件を読むこと、却下の記録が同じ論点を止めること、新規性の一致が前のラウンドの指摘を新規に数えないことである。 | # | 前提 | 崩れたときに起きること | | --- | --- | --- | @@ -93,23 +77,9 @@ cross-review の収束の判定が、誰にも誤りを示されていない `ma | 2 | 却下した指摘は `rejected_findings` に位置と理由つきで残り、次のラウンドのレビュープロンプトへ渡る(#156 の 1 本目) | 数える区分が増えたとき、同じ論点が戻る。この記録がそれを止める | | 3 | 新規性の一致(位置・近傍・本文)は変えない。前のラウンドと一致する指摘は、区分によらず新規に数えない | — | -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 数える | 収束の判定(新規性の層)が、そのラウンドの新しい指摘として件数に入れること。数える区分は `COUNTED_CLASSIFICATIONS` が持つ | -| 棄却 | 指摘が誤りだと示されたこと。実行して再現しなかった(`not_reproduced`)か、別の担当が `refute` を返した。区分は `rejected` | -| 反証の機会 | 提案者以外の担当が、その指摘へ賛否を返したこと。`critiques` が 1 件以上ある | -| 立証の機会が無かった | 反証の機会が無い、または反証はあるが `support` も `refute` も無い。誤りは示されていないが、独立に確かめた担当もいない | -| 未反証(`unrefuted`) | 新しい区分。`major` 以上で、再現も棄却もされておらず、独立に確かめた担当もいない指摘 | -| 独立に確かめた | 提案者以外の担当が `support` を返した、または 2 者以上が同じ指摘を独立に出した(`origin_runtimes` が 2 者以上) | -| 根拠の 2 項目 | `evidence` と `falsification`。両方が空でないとき `has_evidence` が真になる | -| 印 | そのラウンドが統合・実行検証・反証を通ったこと(`evidence_rounds`)。印のあるラウンドだけが区分で絞られ、無いラウンドは全件を数える | -| 代表 | 統合した組で判定が読む 1 件。束ねられた側は `merged_into` を持つ | - ## 受け入れ条件 -22 件である。区分 12 件(AC1〜AC12)、印 1 件(AC13)、退行しない 4 件(AC14〜AC17)、文書 3 件(AC18〜AC20)、全体 2 件(AC21〜AC22)。#624 の再現は AC1、#706 の再現は AC5 と AC7、#583 の収束の部分は AC4 が確かめる。 +22 件である。区分 12 件(AC1〜AC12)、印 1 件(AC13)、退行しない 4 件(AC14〜AC17)、文書 3 件(AC18〜AC20)、全体 2 件(AC21〜AC22)。#624 の再現は AC1、#706 の再現は AC5 と AC7、#583 の収束の部分は AC4 が確かめる。各条件は検査の入力と期待値をそのまま定めるため識別子で書く。業務用語との対応は「用語と識別子の対応」にある。 ### 区分(#624 / #706 / #583 の収束の部分) @@ -187,7 +157,7 @@ cross-review の収束の判定が、誰にも誤りを示されていない `ma | --- | --- | | 公開インタフェース | `state.py` の引数・終了コード・出力の変数は変わらない。状態ファイルの `classification` に値 `unrefuted` が増え、`unrefuted_reason` が増える(読む側は `measure.py` だけ) | | データ | 状態ファイルの形は変わらない(項目が 1 つ増える。`version` は上げない) | -| 既存の振る舞い | 印のあるラウンドで、反証を受けていない・支持されていない・根拠の項目を欠く `major` が数えられるようになる。2 者で相手が `insufficient_evidence` を返した `major` も数える(#156 の判断を改める。理由は設計文書の決定 4)。収束までのラウンド数が増えることがある | +| 既存の振る舞い | 印のあるラウンドで、反証を受けていない・支持されていない・根拠の項目を欠く重大な指摘が数えられるようになる。2 者で相手が「立証できない」を返した重大な指摘も数える(#156 の判断を改める。理由は設計文書の決定 4)。収束までのラウンド数が増えることがある | ## 検証手段 @@ -218,7 +188,7 @@ cross-review の収束の判定が、誰にも誤りを示されていない `ma | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | -| `unrefuted` を数えることで収束までのラウンド数がどれだけ増えるか | 実装の後の運用で `measure.py` の出力を見る | 配布後 | +| 未反証を数えることで収束までのラウンド数がどれだけ増えるか | 実装の後の運用で測定スクリプトの出力を見る | 配布後 | ## 置き換える既存の受け入れ条件との対応 @@ -236,6 +206,55 @@ cross-review の収束の判定が、誰にも誤りを示されていない `ma | AC8(印なしのラウンドと実行検証の区分は変わらない) | AC9 / AC15 / AC16 | 引き継ぐ。期待値を変える既存のテスト 4 件を名指しする | | AC9(文書の 3 つの grep) | AC18 | 引き継ぐ。語を `unrefuted` に変える | +## 依頼(原文) + +3 件の依頼はいずれも、指摘が数えない区分へ落ちたまま収束する現象を報告している。#732 が根本原因と採る手を定め、#624 と #706 がそれぞれの再現を持つ。引用のため、識別子と文の長さは元のまま残す。 + +### #732(根本原因の親) + +> **cross-review の収束で「数えない」とする区分。** 場所は `plugins/ndf/skills/cross-review/scripts/state.py` の `_classify_finding`(3443 行目)と `COUNTED_CLASSIFICATIONS`(3431 行目)、規約 `docs/06-evidence.md` の区分の表である。 +> +> - `insufficient_evidence` が 2 つの場合を兼ねている。反証の機会があって支持されなかった場合と、立証の手段が無かった場合(反証する担当がいない・実行検証が無い・根拠の項目が欠ける)である +> - どちらも新規性の数から落ちるため、立証の手段が無かっただけの未解決の `major` が残ったまま収束する +> +> #583 の収束の部分も、同じ区分を通る。 +> +> ## 採る手 +> +> 分離(`extract_strategy`)。「棄却された(`refute` / `not_reproduced`)」と「立証の機会が無かった」を別の区分にし、数えない判断を前者だけに掛ける。 +> +> ## 完了条件 +> +> - 数えない判断が棄却された指摘に限られ、反証の機会が無かった `major` が新規性に残ることを検査が確かめる +> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる(子 issue はその時点の棚卸が「閉じてよい」で閉じる) + +### #624 + +> `cross-review` を `--only codex` で 1 者だけにして回すと、codex が `REQUEST_CHANGES` で新しい指摘を投稿したラウンドでも、`judge` が収束と判定する(ndf 10.10.1、2026-09-13)。 +> +> `--only` で担当が 1 者だと、反証する他の担当がいないため数える区分に入る指摘が 0 件になり、`_evaluate_convergence`(`:2848`)の `findings_measurable and new_findings == 0` が真になる。 +> +> 状態ファイルを最小の形で組み、関数を直接呼んだ再現(`--only codex`、証拠付きの major 1 件、印の付いたラウンド 1): +> +> ```text +> new_finding_count (0, True) classification insufficient_evidence +> round_passes False converged True +> ``` + +### #706 + +> `cross-review` で、**未解決の `major` が残っているのに、指摘が数えない区分 `insufficient_evidence` へ落ち、新規性の母集合から外れて「新しい指摘 0 件」と判定される。** 結果は `approved` になる。 +> +> ideabase の PR #45 のラウンド 2 で、3 件の指摘がいずれも `suggested_check` を実行できず `insufficient_evidence` になり、judge が `NEW_FINDINGS=0` を返した。指摘のうち 2 件は別の担当が `support` を付けており、こちらでもコードを読んで再現を確かめられた(文書が実装と食い違っていた)。 +> +> 数えない区分へ落ちるのは、次のいずれかに当たる指摘である。 +> +> - 反証の担当が `support` を返さなかった(`insufficient_evidence` を返した、または反証が無い) +> - 根拠の 2 項目(`evidence` / `falsification`)のどちらかが欠けている +> - 重要度が `minor` 以下である +> +> 対処の候補: 実行検証ができない指摘は `insufficient_evidence` ではなく別の区分(未検証)として新規性に数える、または他の担当の `support` が付いた指摘は区分によらず数える。 + ## 関連する文書 この文書は「何を満たすか」だけを扱う。 From 7a0c94897f836b53b2e3fd8f804281e8340d950b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:02:54 +0000 Subject: [PATCH 017/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=20PR=20#781?= =?UTF-8?q?=20=E3=81=AE=E6=96=87=E6=9B=B8=E3=82=92=20markdown-writing=20?= =?UTF-8?q?=E3=81=AE=E3=83=AB=E3=83=BC=E3=83=AB=E3=81=A8=E8=AA=AD=E3=81=BF?= =?UTF-8?q?=E6=89=8B=E3=81=AE=E5=95=8F=E3=81=86=E9=A0=86=E3=81=AB=E7=B5=84?= =?UTF-8?q?=E3=81=BF=E7=9B=B4=E3=81=99=EF=BC=88#729=20#619=20#584=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 見出し 18 件から識別子を外し、業務用語で「何のために何を決めた」を書く。識別子は本文の初出の括弧書きへ - 設計文書の目的の直後に「用語の対応表」を置き、本文は業務用語で通す。要求の用語表に識別子の列を足す - 要求の章を読み手の問う順(目的 → 用語 → 範囲 → 前提 → 影響 → 非機能 → 取り決め → 境界 → 検証 → 受け入れ条件 → 付録)に並べ替える - 設計の 40 行を超える章のうち散文が原因のものを分ける(置き場所・理由の語彙・両 Skill が従う契約)。章の先頭に要点の 1 文を置く - 長い文を分ける(設計の平均文長 45.3 字 → 36.4 字)。決定の中身・受け入れ条件・契約の形は変えない - 既存の契約文書の章名の参照を「理由の語彙」を含む形に揃える Refs #788 Co-Authored-By: Claude Fable 5.1 --- ...62-598-537-619-584-583-design-contracts.md | 2 +- issues/issue-729-619-584-design.md | 191 +++++++++++------ issues/issue-729-619-584-requirements.md | 202 ++++++++++-------- 3 files changed, 233 insertions(+), 162 deletions(-) diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index a29bb108..837f128c 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -122,7 +122,7 @@ classDiagram ### 理由の語彙 -**P3 の語彙と「P3 で足す文言」は、結果なしの理由を共通層の 1 か所で読む設計 [issue-729-619-584-design.md](issue-729-619-584-design.md) の「データ構造」「入出力の契約」へ移した。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 +**P3 の語彙と「P3 で足す文言」は、結果なしの理由を共通層の 1 か所で読む設計 [issue-729-619-584-design.md](issue-729-619-584-design.md) の「理由の語彙」「データ構造」「入出力の契約」へ移した。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 **監視が書く理由**(`monitor_outcome.REASONS`): diff --git a/issues/issue-729-619-584-design.md b/issues/issue-729-619-584-design.md index 70822e62..ca6ca58a 100644 --- a/issues/issue-729-619-584-design.md +++ b/issues/issue-729-619-584-design.md @@ -1,12 +1,52 @@ -# cross-review: 利用上限で止まった担当が missing と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(設計 / #729 #619 #584) +# cross-review: 利用上限で止まった担当が「結果ファイル無し」と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(設計 / #729 #619 #584) ## 目的 -**起きていること。** 担当の CLI が利用上限で落ちると、監視はその文言を読めず、結果なしの理由が `missing` に畳まれる。進行側は同じ担当を起動し直し、監視の上限 1 回分を待ってから `error` で終わる(#619)。監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 +**起きていること。** 担当の CLI が利用上限で落ちると、監視はその文言を読めない。結果なしの理由は「結果ファイル無し」に畳まれる。進行側は同じ担当を起動し直し、監視の上限 1 回分を待ってから全体を誤りで終える(#619)。監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 **根本原因。** 結果なしの判断を cross-review と cross-refactoring がそれぞれ結果ファイルの有無だけで行い、監視が書いた理由を誰も読まない(#729)。 -**この設計で成り立つこと。** 結末の語彙と起動し直しの可否を共通層 `monitor_outcome` の 1 か所に置き、両 Skill がその値を読む。利用上限は `usage_limit` として報告され、同じラウンドでは起動し直さない。止めた担当の子プロセスは結果を書かない。 +**この設計で成り立つこと。** 結末の語彙と起動し直しの可否を結末の共通層の 1 か所に置き、両 Skill がその値を読む。利用上限は理由「利用上限」として報告され、同じラウンドでは起動し直さない。止めた担当の子プロセスは結果を書かない。 + +## 用語の対応表 + +本文は左の業務用語で書く。識別子はこの表と、コードブロック・表・契約の節にだけ置く。 + +| 業務用語 | 識別子 | +| --- | --- | +| 結末の共通層 | `plugins/ndf/scripts/lib/monitor_outcome.py`(読み込み名 `monitor_outcome`) | +| 監視 | `plugins/ndf/scripts/lib/monitor.py` | +| 起動の手順 | `plugins/ndf/scripts/lib/launch-cli.sh` | +| 結末を読む関数 | `monitor_outcome.read_launch_outcome(tmp_dir, stem, result_path)` | +| 起動 1 回の結末(値) | `LaunchOutcome`。欄は使える結果 `payload`・理由 `reason`・起動し直しの可否 `relaunch_same_agent`・監視の詳細 `detail`・監視の結果 `monitor` | +| 可否を返す関数 | `monitor_outcome.relaunch_same_agent(reason)` | +| 起動し直せない理由の集合 | `monitor_outcome.NO_RELAUNCH_REASONS` | +| 理由の語彙 | `monitor_outcome.REASONS` | +| 状態からの既定の理由 | `monitor_outcome.reason_for(status)` | +| 監視の結末(型) | `MonitorOutcome`。新しい欄は `reason` | +| 監視の状態 | 正常 `OK` / 監視の上限 `TIMEOUT` / 無進捗 `STALLED` / 結果なし `NO_RESULT`(終了コード 3)/ 早期の致命 `EARLY_ERROR`(終了コード 4)/ pid ファイル不正 `PIDFILE_BAD` | +| 理由(監視が書く) | 正常 `ok` / 監視の上限 `timeout` / 無進捗 `stalled` / 致命の文言 `early_error` / 利用上限 `usage_limit` / CLI の上限 `cli_timeout` / 結果ファイル無し `missing` / pid ファイル不正 `pidfile_bad` | +| 理由(読む側が書く) | 読めない結果 `unparsable`。cross-review 固有は、判定の値が無い `no_verdict` / 未投稿 `not_posted` | +| 結果ファイル | `-result.json` | +| 監視の結果ファイル | `-monitor.json` | +| 監視の記録 | `monitor-outcomes.jsonl` | +| 結果の取り込み(cross-review) | `state.py read-result`。関数は `_read_review_result_file`、結果なしの記録は `_record_no_result` | +| 判定(cross-review) | `state.py judge`。結果なしのラウンドの扱いは `_handle_no_result_round` | +| 報告の表(cross-review) | `state.py report` | +| 結果なしの理由の鍵 | 状態ファイルの `rounds[-1].<担当>.no_result_reason` | +| 監視の詳細の鍵 | 状態ファイルの `rounds[-1].<担当>.monitor_detail` | +| 判定が出す理由の行 | 標準出力の `NO_RESULT_REASONS='<担当>=<理由> ...'` | +| 起動し直しの指示 | 判定の終了コード 7 と `RELAUNCH_AGENTS` | +| 誤りの終わり | 状態ファイルの `final=error`。判定の終了コード 1 | +| cross-refactoring の結果の読み取り | `refactor_lib/gitfacts.py` の `read_result`。進行を止める終了は `die` | +| 文言の照合(err.log) | 既存の `_scan_patterns`。表は `USAGE_LIMIT_FATAL` / `EARLY_ERROR_FATAL` / `CLI_TIMEOUT_AFTER_EXIT` | +| 文言の照合(claude の stdout.log) | 既存の `_scan_claude_stdout_fatal`。表は `CLAUDE_STDOUT_USAGE_LIMIT` | +| 結末を書く処理 | `monitor._record_outcome`。結末を作る場所は `_early_error_outcome` / `_process_exit_outcome` | +| プロセスの停止 | `monitor._kill_pid`。グループへは `os.killpg` | +| ジョブ制御 | bash の `set -m`。背景で起動した CLI の pid がプロセスグループの番号になる | +| 検知する文言 | 利用上限は kiro: `Monthly request limit reached`、claude: `"api_error_status":429`。CLI の上限は agy: `print timeout after <時間> with turn in progress` | +| 責務の移動 | issue の分類 `move_responsibility` | +| 実行の要約 | `run_metrics.py` が作る。起動の一覧は `launches[]` | ## 機能一覧 @@ -26,69 +66,69 @@ ### 決定 1: 変わった決定だけを差分に載せるため、設計文書は親 #729 の名前で新設し、既存の設計文書の本体は書き換えない -既存の設計は 6 課題・3 本の Pull Request を 1 つの文書で扱い、P1 と P2 は配布済みである。P3 の節をその場で書き換えると、配布済みの決定と新しい決定が 1 つの差分に混ざり、承認する人が「何が変わるのか」を読み分けられない。親 #729 は根本原因の場所(共通層)を定め直しており、既存の決定 14(判定が `usage_limit` を見る)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** +既存の設計は 6 課題・3 本の Pull Request を 1 つの文書で扱い、P1 と P2 は配布済みである。P3 の節をその場で書き換えると、配布済みの決定と新しい決定が 1 つの差分に混ざる。承認する人が「何が変わるのか」を読み分けられない。親 #729 は根本原因の場所(共通層)を定め直し、既存の決定 14(判定が理由「利用上限」を見る)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** -既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。1 行でも触ると、既存の 20 件の決定がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求の文書と契約の文書にだけ足す。 +既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの「決定の記録」の見出しをすべて写す。1 行でも触ると、既存の 20 件の決定がこの Pull Request の決定として並ぶ。案内は「決定の記録」を持たない要求の文書と契約の文書にだけ足す。 -### 決定 2: 同じ判断を両 Skill に書かないため、結果なしの判断と起動し直しの可否を共通層 `monitor_outcome` の 1 つの関数へ移す +### 決定 2: 同じ判断を両 Skill に書かないため、結果なしの判断と起動し直しの可否を結末の共通層の 1 つの関数へ移す -いまは cross-review の `_read_review_result_file` と cross-refactoring の `read_result` が、それぞれ結果なしを決めている。判断の材料は結果ファイルの有無だけである。監視が書いた理由はどちらも読まない。理由を読む処理を各 Skill に書くと、同じ表(監視の理由 → 結果なしの理由)が 2 か所にでき、語彙を足すたびに片方が古くなる(親 #729 の `move_responsibility`)。**共通層に `read_launch_outcome` を 1 つ置き、両 Skill はその値(`payload` / `reason` / `relaunch_same_agent`)を受け取るだけにする。** 語彙・対応表・可否の表は `monitor_outcome.py` だけが持つ。 +いまは cross-review の結果の取り込みと cross-refactoring の結果の読み取りが、それぞれ結果なしを決めている。判断の材料は結果ファイルの有無だけである。監視が書いた理由はどちらも読まない。理由を読む処理を各 Skill に書くと、同じ表(監視の理由 → 結果なしの理由)が 2 か所にできる。語彙を足すたびに片方が古くなる(親 #729 の責務の移動)。**結末の共通層に結末を読む関数を 1 つ置き、両 Skill はその値(使える結果・理由・起動し直しの可否)を受け取るだけにする。** 語彙・対応表・可否の表は結末の共通層だけが持つ。 -各 Skill が `read_outcome` を呼んで自分で表を引く形(既存の G4 の設計の `apply._monitor_reason`)は採らない。表が Skill の数だけ増える。 +各 Skill が監視の結果ファイルを読む既存の関数(`read_outcome`)を呼び、自分で表を引く形は採らない。既存の G4 の設計の `apply._monitor_reason` がこの形である。表が Skill の数だけ増える。 -### 決定 3: 進行側の問いに合わせ、起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にし、`usage_limit` だけを偽にする +### 決定 3: 進行側の問いに合わせ、起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にし、利用上限だけを偽にする -進行側が結末を見て決めることは「同じ担当をもう 1 度起動してよいか」に尽きる。利用上限は起動し直しても解けず、起動のたびに待ちと相手の CLI の枠を使う(#619 の 2 回目の空振り、#647 の 3729 回)。それ以外の理由(監視の上限・無進捗・致命の文言・CLI の上限・結果なし・読めない)は、対象や負荷で変わりうるため 1 度は起動し直してよい。 +進行側が結末を見て決めることは「同じ担当をもう 1 度起動してよいか」に尽きる。利用上限は起動し直しても解けない。起動のたびに待ちと相手の CLI の枠を使う(#619 の 2 回目の空振り、#647 の 3729 回)。それ以外の理由(監視の上限・無進捗・致命の文言・CLI の上限・結果なし・読めない)は、対象や負荷で変わりうる。1 度は起動し直してよい。 **偽のときに何をするかは Skill が決める。** cross-review は止めて理由を報告する(担当を外して回す判断は #478)。cross-refactoring は次の輪番の担当へ替える(G4 の設計の決定 3)。共通層が持つのは可否だけで、進行の分岐は持たない。 -理由ごとに「止める / 替える / 起動し直す」の 3 値を返す形は採らない。3 値のうち「替える」は担当の集合を知る Skill にしか決められず、共通層に置くと担当の割り当てを読み込むことになる。 +理由ごとに「止める / 替える / 起動し直す」の 3 値を返す形は採らない。「替える」は担当の集合を知る Skill にしか決められない。共通層に置くと担当の割り当てを読み込むことになる。 -### 決定 4: 終了コードで分岐する骨組みを変えないため、利用上限は `EARLY_ERROR`(終了コード 4)のまま、理由だけを `usage_limit` にする +### 決定 4: 終了コードで分岐する骨組みを変えないため、利用上限は「早期の致命」の状態のまま、理由だけを「利用上限」にする -監視の終了コードは骨組みと G4 の設計が分岐に使う(既存の設計の決定 16)。利用上限は「プロセスが続いても結果を生成できないと分かった」致命の一種で、状態としては `EARLY_ERROR` と同じである。区別が要るのは理由の側だけで、監視の結果ファイルと記録に `usage_limit` が入れば、読む側は状態を変えずに区別できる。 +監視の終了コードは骨組みと G4 の設計が分岐に使う(既存の設計の決定 16)。利用上限は「プロセスが続いても結果を生成できないと分かった」致命の一種である。状態としては早期の致命(`EARLY_ERROR`、終了コード 4)と同じである。区別が要るのは理由の側だけである。監視の結果ファイルと記録に理由「利用上限」(`usage_limit`)が入れば、読む側は状態を変えずに区別できる。 -新しい状態(`USAGE_LIMIT`、終了コード 7)を足す形は採らない。終了コードの意味が変わり、終了コードで分岐する骨組みと文書(cross-refactoring だけで `monitor.py` の呼び出しが 8 か所)を見直すことになる。 +新しい状態(`USAGE_LIMIT`、終了コード 7)を足す形は採らない。終了コードの意味が変わる。終了コードで分岐する骨組みと文書(cross-refactoring だけで監視の呼び出しが 8 か所)を見直すことになる。 -### 決定 5: 「終わったが結果が無い」の状態を保つため、CLI の上限は `NO_RESULT`(終了コード 3)のまま、理由だけを `cli_timeout` にする +### 決定 5: 「終わったが結果が無い」の状態を保つため、CLI の上限は「結果なし」の状態のまま、理由だけを「CLI の上限」にする -agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」で正しい。文言は終了の後にだけ見る(生きている間に見ると、途中で出た警告を致命と読む)。結果ファイルがあれば理由は `ok` にする(上限に当たっても結果を書き終えていれば使える)。 +agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」(`NO_RESULT`、終了コード 3)で正しい。理由だけを「CLI の上限」(`cli_timeout`)にする。文言は終了の後にだけ見る。生きている間に見ると、途中で出た警告を致命と読む。結果ファイルがあれば理由は「正常」にする(上限に当たっても結果を書き終えていれば使える)。 ### 決定 6: 実物の文言に一致し引用を誤検知しないため、利用上限の文言は err.log を全担当で見、stdout.log は claude だけ JSON 向けの照合で見る -kiro の `Monthly request limit reached` は err.log に出る(#619 の実物)。claude の `"api_error_status":429` はどちらに出るか未確認のため両方を見る(前提 1)。err.log は既存の `_scan_patterns`(表・引用・バッククォート・grep 形式の除外を掛ける)で見る。実測では、`"api_error_status":429` を含む JSON の 1 行も err.log の側で一致し、引用の判定に飲み込まれなかった(「実測」)。stdout.log は既存の `_scan_claude_stdout_fatal` と同じ JSON 向けの照合(除外を掛けない)で見る。JSON は 1 行に引用符を多く含み、行単位の引用の判定が「引用の内側」を誤って真にするためである。 +kiro の利用上限の文言は err.log に出る(#619 の実物)。claude の文言はどちらに出るか未確認のため、両方を見る(前提 1)。err.log は既存の文言の照合で見る。この照合は表・引用・バッククォート・grep 形式を除外する。実測では、claude の文言を含む JSON の 1 行も err.log の側で一致し、引用の判定に飲み込まれなかった(「実測」)。stdout.log は既存の claude 向けの照合と同じ JSON 向けの形(除外を掛けない)で見る。JSON は 1 行に引用符を多く含む。行単位の引用の判定が「引用の内側」を真と判定するためである。 -### 決定 7: 両 Skill が同じ形で見る理由だけを共通にするため、`unparsable` を共通の語彙に入れ、`no_verdict` / `not_posted` は cross-review 固有に残す +### 決定 7: 両 Skill が同じ形で見る理由だけを共通にするため、「読めない結果」を共通の語彙に入れ、「判定の値が無い」「未投稿」は cross-review 固有に残す -結果ファイルが JSON として読めない・オブジェクトでないことは、両 Skill の読み取りが同じ形で見ている。共通の関数が結果ファイルを読む以上、この理由も共通の語彙に要る。`no_verdict`(判定の値が無い)と `not_posted`(投稿が届いていない)は結果ファイルの中身とレビューの投稿の話で、監視も cross-refactoring も知りえない。**cross-review が共通の関数の後で自分の理由を上書きする形にする。** +結果ファイルが JSON として読めない・オブジェクトでないことは、両 Skill の読み取りが同じ形で見ている。共通の関数が結果ファイルを読む以上、この理由(`unparsable`)も共通の語彙に要る。「判定の値が無い」(`no_verdict`)と「未投稿」(`not_posted`)は結果ファイルの中身とレビューの投稿の話である。監視も cross-refactoring も知りえない。**cross-review が共通の関数の後で自分の理由を上書きする形にする。** -`REASONS` は 9 語になる。`reason_for(status)` が返すのは、監視の状態から決まる 6 語のままである。`usage_limit` / `cli_timeout` は監視が結末に理由を添えたときだけ現れる。`unparsable` は読む側だけが使う。 +理由の語彙は 9 語になる。状態からの既定の理由が返すのは、監視の状態から決まる 6 語のままである。「利用上限」と「CLI の上限」は監視が結末に理由を添えたときだけ現れる。「読めない結果」は読む側だけが使う。 -### 決定 8: 状態からは決まらない理由を残すため、`MonitorOutcome` に理由を持たせ、無ければ状態からの既定を使う +### 決定 8: 状態からは決まらない理由を残すため、監視の結末に理由の欄を持たせ、無ければ状態からの既定を使う -いま `_record_outcome` は `reason_for(st.status)` で理由を決めている。利用上限と CLI の上限は状態からは決まらないため、結末を作る場所(`_early_error_outcome` / `_process_exit_outcome`)が理由を添える。`MonitorOutcome` に `reason: Optional[str] = None` を足す。`_record_outcome` は `outcome.reason or reason_for(status)` で書く。`create(status, detail)` の既存の呼び出し 8 か所は変えない。 +いま結末を書く処理は、状態からの既定の理由で理由を決めている。利用上限と CLI の上限は状態からは決まらない。結末を作る場所が理由を添える。監視の結末の型に理由の欄(`reason: Optional[str] = None`)を足す。結末を書く処理は、結末に理由があればそれを、無ければ状態からの既定を書く(`outcome.reason or reason_for(status)`)。結末を作る既存の呼び出し 8 か所(`create(status, detail)`)は変えない。 ### 決定 9: 起動し直しで上書きされる 1 回目の理由を失わないため、その理由は追記だけの監視の記録が持つ -状態ファイルの `rounds[-1].<担当>` はその担当のそのラウンドの最後の結果を持つ構造で、起動し直すと 1 回目の `no_result_reason` は 2 回目で上書きされる。これは既存の構造のままにする。**過去を失わないのは追記だけの `monitor-outcomes.jsonl` の側で**(既存の設計の決定 2)、1 回目の理由も `reason` と `ended_at` から読める。状態ファイルに履歴の配列を足す形は採らない。判定が読むのは最後の結果だけで、履歴は要約(`launches[]`)が既に持つ。 +状態ファイルの担当ごとの結果は、そのラウンドの最後の結果を持つ構造である。起動し直すと、1 回目の結果なしの理由は 2 回目で上書きされる。これは既存の構造のままにする。**過去を失わないのは追記だけの監視の記録の側で**(既存の設計の決定 2)、1 回目の理由も記録の理由と終了時刻(`reason` と `ended_at`)から読める。状態ファイルに履歴の配列を足す形は採らない。判定が読むのは最後の結果だけである。履歴は実行の要約の起動の一覧が既に持つ。 ### 決定 10: 止めた後に子プロセスが結果を書かないよう、CLI を独立したプロセスグループで起動し、グループの先頭のときだけグループへシグナルを送る -既存の設計の決定 17 をそのまま引き継ぐ。`launch-cli.sh` が `set -m` を有効にしてから背景で起動すると、CLI の pid がそのままプロセスグループの番号になる。監視は pid がグループの先頭であり、かつ監視自身のグループと違うときだけ `os.killpg` を使い、それ以外は従来どおり pid だけへ送る。`setsid` は macOS に標準で入っていないため採らない。`read-result` の前に結果ファイルの出現を待つ形(#584 の候補 2)も採らない。書き出しそのものを止めれば待つ理由が無い。 +既存の設計の決定 17 をそのまま引き継ぐ。起動の手順がジョブ制御を有効にしてから背景で起動すると、CLI の pid がそのままプロセスグループの番号になる。監視は pid がグループの先頭であり、かつ監視自身のグループと違うときだけグループへ送る。それ以外は従来どおり pid だけへ送る。`setsid` は macOS に標準で入っていないため採らない。結果の取り込みの前に結果ファイルの出現を待つ形(#584 の候補 2)も採らない。書き出しそのものを止めれば待つ理由が無い。 -### 決定 11: cross-refactoring も同じ値を読むよう、`gitfacts.read_result` は結果なしを `die` せず、共通の関数の値で返す契約に置き換える。実装は G4 +### 決定 11: cross-refactoring も同じ値を読むよう、結果の読み取りは結果なしで進行を止めず、共通の関数の値で返す契約に置き換える。実装は G4 -#728 は `read_result` が `die(code=2)` で進行の終了コードを決める向きを直す。その向きを直した先が `read_launch_outcome` の値である。G3 が決めるのは「`read_result` に相当する読み取りは `read_launch_outcome` を呼び、`payload` / `reason` / `relaunch_same_agent` を返す。終了コードを決めず、標準エラーに書かない」までで、3 つの取り込みがその値をどう扱うか(群の状態・担当の交代・終了コード)は G4 が決める。薄い包みとして残すか呼び出し側が直接呼ぶかも G4 に任せる(要求の「未決」)。 +#728 は、cross-refactoring の結果の読み取りが進行を止める終了(`die(code=2)`)で終了コードを決める向きを直す。その向きを直した先が、結末を読む関数の値である。G3 が決めるのは次の範囲までである。結果の読み取りに相当する処理は結末を読む関数を呼び、使える結果・理由・起動し直しの可否を返す。終了コードを決めず、標準エラーに書かない。3 つの取り込みがその値をどう扱うか(群の状態・担当の交代・終了コード)は G4 が決める。薄い包みとして残すか呼び出し側が直接呼ぶかも G4 に任せる(要求の「未決」)。 -### 決定 12: cross-review の骨組みの行を変えないため、`read-result` の終了コードと `judge` の 0 / 2 / 7 / 8 は変えず、利用上限は既存の 1 の枝で終える +### 決定 12: cross-review の骨組みの行を変えないため、結果の取り込みの終了コードと判定の 0 / 2 / 7 / 8 は変えず、利用上限は既存の 1 の枝で終える -骨組み(`SKILL.md`)は `read-result` の終了コードを `|| true` で受け、`judge` の 7 / 8 / 0 / 2 で分岐し、それ以外を `exit` する。利用上限で起動し直さずに止める結末は、既存の「2 度目も結果なし → `final=error` → 1」と同じ出口へ載せる。骨組みの行は 1 つも変わらない(AC24)。 +骨組み(`SKILL.md`)は結果の取り込みの終了コードを `|| true` で受け、判定の 7 / 8 / 0 / 2 で分岐し、それ以外を `exit` する。利用上限で起動し直さずに止める結末は、既存の「2 度目も結果なし → 誤りの終わり → 1」と同じ出口へ載せる。骨組みの行は 1 つも変わらない(AC24)。 -新しい終了コードで「利用上限で止めた」を骨組みへ伝える形は採らない。骨組みの分岐が増え、cross-review の `SKILL.md` と `docs/` の 2 か所へ同じ値を書くことになる。理由は `NO_RESULT_REASONS` の行と `report` の表で読める。 +新しい終了コードで「利用上限で止めた」を骨組みへ伝える形は採らない。骨組みの分岐が増える。cross-review の `SKILL.md` と `docs/` の 2 か所へ同じ値を書くことになる。理由は判定が出す理由の行と報告の表で読める。 ## 実測 -2026-09-19 に `develop`(9eaebe14、bash 5.3.9、Python 3.14.4)で、提案する文言を既存の `_scan_patterns` に通した。 +2026-09-19 に `develop`(9eaebe14、bash 5.3.9、Python 3.14.4)で、提案する文言を既存の文言の照合に通した。 ```text kiro 実物 usage=HIT cli_timeout=- 現行fatal=- @@ -104,12 +144,12 @@ grep 形式 usage=- cli_timeout=- 現行fatal=- stdout JSON 向け照合: True ``` -- 利用上限の 4 つの文言は、実物の 3 形式(kiro の 1 行・claude の JSON 1 行・HTTP 429 行)に一致し、表・バッククォート・引用・grep 形式では一致しない -- `HTTP/1.1 401` は利用上限に入らず、現行の致命(`early_error`)に残る(AC4) +- 利用上限の 4 つの文言は、実物の 3 形式(kiro の 1 行・claude の JSON 1 行・HTTP 429 行)に一致する。表・バッククォート・引用・grep 形式では一致しない +- HTTP の 401 の行は利用上限に入らず、現行の致命の文言(`early_error`)に残る(AC4) - claude の JSON の 1 行は、err.log 向けの引用の判定を通しても一致した(決定 6 の前提) -- 現行の `_scan_early_fatal` は kiro の実物と claude の JSON に一致しない(#619 の再現) +- 現行の致命の照合(`_scan_early_fatal`)は kiro の実物と claude の JSON に一致しない(#619 の再現) -プロセスグループの実測は既存の契約の文書の「実測」(2026-09-15)にあり、変更していない。`set -m` で起動した CLI を `killpg` で止めると、3 秒後に子が書く結果ファイルは書かれなかった。 +プロセスグループの実測は既存の契約の文書の「実測」(2026-09-15)にあり、変更していない。ジョブ制御を有効にして起動した CLI をグループへのシグナルで止めると、3 秒後に子が書く結果ファイルは書かれなかった。 ## 構成要素 @@ -161,6 +201,7 @@ graph TD J -->|理由を読む・可否で止める| S RP -->|理由を表に出す| S ``` + **図に含めない要素**は次の 2 つである。 | 要素 | 図との関係 | @@ -170,6 +211,8 @@ graph TD ## 文脈と配置 +利用上限を返すのは各 CLI の API の提供元で、こちらは変えられない。**この変更が変えるのは、その返答が err.log / stdout.log に現れたときの読み方だけである。** + ```mermaid graph LR H[進行側のホスト CLI] --> SK[cross-review / cross-refactoring の骨組み] @@ -179,8 +222,6 @@ graph LR SK --> TMP[作業ツリーの中の一時ディレクトリ] ``` -利用上限を返すのは各 CLI の API の提供元で、こちらは変えられない。**この変更が変えるのは、その返答が err.log / stdout.log に現れたときの読み方だけである。** - | 実行の単位 | どこで動くか | 境界 | | --- | --- | --- | | 担当の CLI | 背景のプロセス。**独立したプロセスグループ**(pgid = pid) | 一時ディレクトリへ結果ファイルとログを書く | @@ -189,7 +230,9 @@ graph LR 配置で変わるのは担当の CLI のプロセスグループだけである。 -### 置き場所 +## 置き場所 + +変えるファイルと、それぞれで変わるものを示す。 ```text plugins/ndf/scripts/lib/ @@ -246,7 +289,7 @@ classDiagram gitfacts_read_result ..> monitor_outcome : 契約(G4 が実装) ``` -`+` の付いた欄が増える。`AgentStatus` と `state.py` の既存の欄・関数は変えない。 +`+` の付いた欄が増える。担当の状態の型(`AgentStatus`)と cross-review の状態の操作の既存の欄・関数は変えない。 | 触る型 | 責務 | | --- | --- | @@ -254,11 +297,27 @@ classDiagram | `LaunchOutcome` | 起動 1 回の結末。`payload` があれば使える結果、無ければ `reason` が理由。`relaunch_same_agent` は `reason` から導く(`payload` があれば `True`) | | `monitor_outcome.NO_RELAUNCH_REASONS` | 起動し直しても解けない理由の集合。値は `{"usage_limit"}` | +## 理由の語彙 + +共通層の理由の語彙は 9 語である。監視が書く語と読む側だけが書く語を、起動し直しの可否と合わせて 1 つの表で持つ。 + +| 理由 | 監視の状態 | 誰が書くか | 起動し直しの可否 | 何が起きたか | +| --- | --- | --- | --- | --- | +| `ok` | `OK` | 監視 | — | 結果ファイルがあって終わった | +| `timeout` | `TIMEOUT` | 監視 | 可 | 監視の上限 | +| `stalled` | `STALLED` | 監視 | 可 | 無進捗の許容 | +| `early_error` | `EARLY_ERROR` | 監視 | 可 | 利用上限以外の致命の文言 | +| `usage_limit` | `EARLY_ERROR` | 監視(決定 8) | **否** | 利用上限の文言 | +| `cli_timeout` | `NO_RESULT` | 監視(決定 8) | 可 | 結果なしで終わり、err.log に CLI の上限の文言 | +| `missing` | `NO_RESULT` | 監視・読む側 | 可 | 結果なしで終わり、理由の文言が無い | +| `pidfile_bad` | `PIDFILE_BAD` | 監視 | 可 | pid ファイルが無い・別のプロセス | +| `unparsable` | — | 読む側だけ | 可 | 結果ファイルがあるが JSON オブジェクトとして読めない | + ## データ構造 永続データは JSON のファイルで、データベースは無い。ER 図は作らず、表で持つ。 -### 監視の結果ファイル `-monitor.json` と監視の記録(P1 から。`reason` の値だけが増える) +### 監視の結果ファイルと監視の記録(P1 から。理由の値だけが増える) | 列 | 型 | 空を許すか | 意味 | | --- | --- | --- | --- | @@ -266,7 +325,7 @@ classDiagram 他の 14 個のキーは変えない(既存の契約の文書の「監視の結果ファイル」)。 -### 状態ファイル `rounds[-1].<担当>`(cross-review) +### 状態ファイルの担当ごとの最後の結果(cross-review) | 列 | 型 | 空を許すか | 意味 | | --- | --- | --- | --- | @@ -274,21 +333,7 @@ classDiagram | `no_result_reason` | 文字列 | 許さない(`NO_RESULT` のとき) | `read_launch_outcome` の `reason`(`ok` を除く 8 語)、または cross-review が上書きする `no_verdict` / `not_posted` | | `monitor_detail` | 文字列 | 許す | 監視の `detail`(最大 200 文字の err.log の抜粋)。**鍵が無い** = 監視の結果ファイルが無かった。空文字は書かない | -`no_result_reason` の値の集合は 10 語になる。既存の 3 語(`missing` / `unparsable` / `no_verdict`)と `not_posted` の意味は変えない。 - -### 理由の語彙(`monitor_outcome.REASONS`、9 語) - -| 理由 | 監視の状態 | 誰が書くか | 起動し直しの可否 | 何が起きたか | -| --- | --- | --- | --- | --- | -| `ok` | `OK` | 監視 | — | 結果ファイルがあって終わった | -| `timeout` | `TIMEOUT` | 監視 | 可 | 監視の上限 | -| `stalled` | `STALLED` | 監視 | 可 | 無進捗の許容 | -| `early_error` | `EARLY_ERROR` | 監視 | 可 | 利用上限以外の致命の文言 | -| `usage_limit` | `EARLY_ERROR` | 監視(決定 8) | **否** | 利用上限の文言 | -| `cli_timeout` | `NO_RESULT` | 監視(決定 8) | 可 | 結果なしで終わり、err.log に CLI の上限の文言 | -| `missing` | `NO_RESULT` | 監視・読む側 | 可 | 結果なしで終わり、理由の文言が無い | -| `pidfile_bad` | `PIDFILE_BAD` | 監視 | 可 | pid ファイルが無い・別のプロセス | -| `unparsable` | — | 読む側だけ | 可 | 結果ファイルがあるが JSON オブジェクトとして読めない | +結果なしの理由の値の集合は 10 語になる。既存の 3 語(結果ファイル無し・読めない結果・判定の値が無い)と未投稿の意味は変えない。 ### 機能とデータの対応 @@ -303,7 +348,9 @@ classDiagram ## 入出力の契約 -### `monitor_outcome.read_launch_outcome`(新設。両 Skill の取り込みが呼ぶ) +共通層の 2 つの関数と、監視・起動の約束を表で持つ。この節の識別子は「用語の対応表」の右の列である。 + +### 結末を読む関数(新設。両 Skill の取り込みが呼ぶ) | 項目 | 内容 | | --- | --- | @@ -314,7 +361,7 @@ classDiagram | 失敗の形 | **失敗しない。** 例外を投げず、`SystemExit` も出さず、標準出力・標準エラーに書かない。監視の結果ファイルが壊れていれば無いものとして扱う | | 互換性 | 新設。既存の `read_outcome` / `reason_for` / `REASONS` の呼び出し側は変わらない(`REASONS` は 9 語になるが、一覧を持つ読み手は無い) | -結果なしの `reason` の決め方(要求の AC9): +結果なしの理由の決め方(要求の AC9): | 監視の結果ファイルの `reason` | 結果ファイル | `reason` | | --- | --- | --- | @@ -322,13 +369,13 @@ classDiagram | `ok` / `missing` / ファイルが無い・読めない | 無い、または空 | `missing` | | `ok` / `missing` / ファイルが無い・読めない | あるが JSON オブジェクトでない | `unparsable` | -**結果ファイルが読めれば `payload` が勝つ。** 監視が `usage_limit` で止めた後にも結果ファイルが残っていれば、それは止める前に書き終えていた結果で、使ってよい。 +**結果ファイルが読めれば使える結果が勝つ。** 監視が利用上限で止めた後にも結果ファイルが残っていれば、それは止める前に書き終えていた結果で、使ってよい。 -### `monitor_outcome.relaunch_same_agent(reason) -> bool`(新設) +### 可否を返す関数(新設) -`reason not in NO_RELAUNCH_REASONS`。`NO_RELAUNCH_REASONS = frozenset({"usage_limit"})`。理由を足すときはこの集合だけを見直す。 +`relaunch_same_agent(reason) -> bool` は `reason not in NO_RELAUNCH_REASONS` を返す。`NO_RELAUNCH_REASONS = frozenset({"usage_limit"})`。理由を足すときはこの集合だけを見直す。 -### `monitor.py`(変更。終了コードと標準出力は変えない) +### 監視(変更。終了コードと標準出力は変えない) | 引数・出力 | 約束 | | --- | --- | @@ -337,7 +384,7 @@ classDiagram | 監視の結果ファイルと記録の `reason` | `usage_limit` / `cli_timeout` が増える | | `--no-early-error` | 変えない。利用上限の検知も致命の検知と一緒に無効になる(`MONITOR_NO_EARLY_ERROR=1` の逃げ道はそのまま) | -検知の文言(err.log は `_scan_patterns` の除外を掛ける。claude の stdout.log は JSON 向けの照合で除外を掛けない): +検知の文言(err.log は既存の照合の除外を掛ける。claude の stdout.log は JSON 向けの照合で除外を掛けない): | 表 | 理由 | 見るファイル | いつ見るか | 文言(正規表現) | | --- | --- | --- | --- | --- | @@ -348,9 +395,9 @@ classDiagram | `EARLY_ERROR_FATAL` | `early_error` | err.log(既存の一致を分ける) | 同上 | `^HTTP/\d\S* (?:401\|403) ` と、残りの既存の致命 | | `CLI_TIMEOUT_AFTER_EXIT` | `cli_timeout` | err.log | **終了した後、結果ファイルが無いときだけ** | `print timeout after \S+ with turn in progress` | -照合の順序は利用上限 → 致命 → 警告の見た目の致命。同じ err.log に利用上限と他の致命が両方あれば `usage_limit` になる(上限で落ちた後に別の文言が続く形が普通で、上限のほうが原因である)。 +照合の順序は利用上限 → 致命 → 警告の見た目の致命。同じ err.log に利用上限と他の致命が両方あれば、理由は「利用上限」になる。上限で落ちた後に別の文言が続く形が普通で、上限のほうが原因である。 -### `launch-cli.sh` と `monitor._kill_pid`(変更。既存の設計の決定 17) +### 起動の手順と監視の停止(変更。既存の設計の決定 17) | 項目 | 約束 | | --- | --- | @@ -358,7 +405,11 @@ classDiagram | 停止 | `os.getpgid(pid) == pid` かつ `!= os.getpgrp()` なら `os.killpg`(SIGTERM → 3 秒 → SIGKILL)。それ以外は従来どおり `os.kill` | | 互換性 | 呼び出し側の引数は変わらない。pid ファイルの中身も変わらない | -### `state.py`(変更) +## 両 Skill が従う契約 + +cross-review は状態の操作を変更し、cross-refactoring は結果の読み取りの契約だけを受け取る。 + +### cross-review の状態の操作(変更) | コマンド | 変わる出力 | 変わらないもの | | --- | --- | --- | @@ -366,7 +417,7 @@ classDiagram | `judge` | 結果なしがあるとき標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'`。可否が偽の理由を含めば `final=error` と終了コード 1、標準エラーに担当・理由・`monitor_detail` | 終了コード 0 / 2 / 7 / 8 の意味と `RELAUNCH_AGENTS` の形、8 の `flush` の枝 | | `report` | ラウンド表で `<担当>=NO_RESULT(<理由>)` | 他の行 | -### `gitfacts.read_result`(契約のみ。G4 が実装する) +### cross-refactoring の結果の読み取り(契約のみ。G4 が実装する) | 項目 | 契約 | | --- | --- | @@ -378,6 +429,8 @@ classDiagram ## 処理の流れ +監視の 1 担当、結末の読み取り、cross-review の 1 ラウンドの 3 つを図にする。 + ### 監視の 1 担当(利用上限と CLI の上限の枝が入る) ```mermaid @@ -403,7 +456,7 @@ graph TD W --> O[標準出力の JSON と終了コード(変えない)] ``` -### 結末を 1 つの値として読む(`read_launch_outcome`) +### 結末を読む関数の流れ ```mermaid graph TD @@ -452,6 +505,8 @@ sequenceDiagram ## 非機能の実現方式 +要求の非機能の条件 3 件に、それぞれ実現方式を対応させる。 + | 条件 | 実現方式 | | --- | --- | | 理由が 4 か所で同じ語彙で読める | 監視が結末に理由を添え、`_record_outcome` が結果ファイルと記録へ同じ値を書く。`read-result` は `read_launch_outcome` の `reason` をそのまま `no_result_reason` に写す。要約の `launches[]` は記録の行から作る(P1 のまま)。語彙を足すときは `REASONS` と `NO_RELAUNCH_REASONS` と検知の表だけを変える | @@ -496,7 +551,7 @@ sequenceDiagram | 相手 | 決めた契約 | どちらが何をするか | | --- | --- | --- | -| G4(#728 #647 #592 #553) | `gitfacts.read_result` の契約(「入出力の契約」)。理由の語彙 9 語と `relaunch_same_agent` | G3 が共通層と cross-review を実装する。G4 は `read_result` に相当する読み取りを `read_launch_outcome` に置き換え、3 つの取り込みで `payload` 無しのときの群の状態・担当の交代・終了コードを決める。G4 の既存の設計の `apply._monitor_reason` と `gitfacts.load_result` は `read_launch_outcome` で置き換わる | +| G4(#728 #647 #592 #553) | `gitfacts.read_result` の契約(「両 Skill が従う契約」)。理由の語彙 9 語と `relaunch_same_agent` | G3 が共通層と cross-review を実装する。G4 は `read_result` に相当する読み取りを `read_launch_outcome` に置き換え、3 つの取り込みで `payload` 無しのときの群の状態・担当の交代・終了コードを決める。G4 の既存の設計の `apply._monitor_reason` と `gitfacts.load_result` は `read_launch_outcome` で置き換わる | | G4 | 監視の終了コード 0〜6 と標準出力は変えない | G3 が守る。G4 は終了コードで分岐しない設計(G4 の決定 4)を続ける | | G5(#730 #583) | 既存の設計の決定 18(`prior_review_url` と記録だけの起動)・決定 19(起動し直しを初回と同じ経路へ)・AC63〜AC67 | G5 が持つ。G3 は `read-result` の結果なしの記録に `monitor_detail` を足すだけで、`prior_review_url` の鍵と `launch-reviewer.sh` は触らない。G5 が `_record_no_result` に `prior_review_url` を足すときは、G3 の `monitor_detail` の書き方(鍵が無い = 無かった)に揃える | | D-B(#478) | 判定が出す `NO_RESULT_REASONS` の行と、可否が偽のときに止めること | G3 が入れる。利用上限の担当を外して残りで回す判断は #478 | diff --git a/issues/issue-729-619-584-requirements.md b/issues/issue-729-619-584-requirements.md index ebd37d3c..04b3a08f 100644 --- a/issues/issue-729-619-584-requirements.md +++ b/issues/issue-729-619-584-requirements.md @@ -1,12 +1,35 @@ -# cross-review: 利用上限で止まった担当が missing と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(要求と受け入れ条件 / #729 #619 #584) +# cross-review: 利用上限で止まった担当が「結果ファイル無し」と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(要求と受け入れ条件 / #729 #619 #584) ## 目的 -**起きていること。** 担当の CLI(kiro / claude)が月間の利用上限に当たると、監視はその文言を読めず、結果なしの理由が `missing` に畳まれる。進行側は同じ担当を同じラウンドで起動し直し、監視の上限 1 回分(レビューで 1200 秒)を待ってから `error` で終わる(#619。#647 では 3729 回の空振り)。また、監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 +**起きていること。** 担当の CLI(kiro / claude)が月間の利用上限に当たると、監視はその文言を読めない。結果なしの理由は「結果ファイル無し」に畳まれる。進行側は同じ担当を同じラウンドで起動し直し、監視の上限 1 回分(レビューで 1200 秒)を待ってから全体を誤りで終える(#619。#647 では 3729 回の空振り)。また、監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 -**困る人。** 収束ループを回す進行側と、結果を待つ利用者。届く理由が `missing` のため、上限に当たったのか、監視の上限で打ち切られたのかを判別できない。 +**困る人。** 収束ループを回す進行側と、結果を待つ利用者。届く理由が「結果ファイル無し」のため、上限に当たったのか、監視の上限で打ち切られたのかを判別できない。 -**直すと成り立つこと。** 理由が `usage_limit` として進行側と利用者に届き、同じラウンドで同じ担当を起動し直さない。結果ファイルが無いときの「監視が打ち切った」「CLI が自分の上限で終わった」「終わったが結果を書かなかった」を、結末の語彙 1 つで区別できる。結果なしの判断と起動し直しの可否は共通層の 1 か所が持ち、cross-review と cross-refactoring がその値を読む(両 Skill が同じ判断を別々に書かない)。監視が止めた担当の子プロセスは、止めた後に結果ファイルを書かない。 +**直すと成り立つこと。** 理由が「利用上限」として進行側と利用者に届き、同じラウンドで同じ担当を起動し直さない。結果ファイルが無いときの「監視が打ち切った」「CLI が自分の上限で終わった」「終わったが結果を書かなかった」を、結末の語彙 1 つで区別できる。結果なしの判断と起動し直しの可否は共通層の 1 か所が持つ。cross-review と cross-refactoring がその値を読む(両 Skill が同じ判断を別々に書かない)。監視が止めた担当の子プロセスは、止めた後に結果ファイルを書かない。 + +## 用語 + +本文はこの表の用語で書く。受け入れ条件は検査で突き合わせる値を持つため、識別子の列の語で書く。 + +| 用語 | 意味 | 識別子 | +| --- | --- | --- | +| 担当 | レビュー・反証・適用・修正を行う CLI(codex / agy / kiro / claude) | — | +| 起動 1 回 | 起動の手順が担当を 1 度起動し、監視がそれを見終わるまで | `launch-cli.sh` / `monitor.py` | +| 結末 | 起動 1 回の終わり方。監視の状態と理由、結果ファイルの有無と読めるかを合わせたもの | 監視の `status` と `reason` | +| 理由 | 結末の語彙の 1 語 | `monitor_outcome.REASONS` | +| 起動し直しの可否 | 同じ担当を同じ条件で起動し直せば解ける結末か(偽なら、起動し直しても同じ結末になる) | `relaunch_same_agent` | +| 使える結果 | 結果ファイルがあり、JSON オブジェクトとして読める | `payload` | +| 結果ファイル | 担当が一時ディレクトリへ書く JSON | `-result.json` | +| 監視の結果ファイル | 担当 1 者の最後の監視の結果(P1) | `-monitor.json` | +| 監視の記録 | 監視の結果を追記だけで積む(P1) | `monitor-outcomes.jsonl` | +| 利用上限 | 担当の CLI の月間・週間の利用枠に達し、起動し直しても解けない状態 | 理由 `usage_limit`。監視の状態は早期の致命 `EARLY_ERROR`(終了コード 4) | +| CLI の上限 | 担当の CLI 自身の実行時間の上限(agy の `--print-timeout`) | 理由 `cli_timeout`。監視の状態は結果なし `NO_RESULT`(終了コード 3) | +| 結果ファイル無し | 結果ファイルが無く、理由の文言も無い | 理由 `missing` | +| 読めない結果 | 結果ファイルがあるが JSON オブジェクトとして読めない | 理由 `unparsable` | +| 誤りの終わり | 収束ループ全体を誤りとして終える | 状態ファイルの `final=error`。判定の終了コード 1 | +| 結果の取り込み / 判定 / 報告の表 | cross-review の状態の操作の 3 コマンド | `state.py read-result` / `judge` / `report` | +| 結末の共通層 | 2 つ以上の Skill が使う部品の置き場所にある、結末の語彙と読み取り | `plugins/ndf/scripts/lib/monitor_outcome.py` | ## 対象範囲 @@ -27,7 +50,7 @@ | 扱わないもの | 理由 | | --- | --- | -| cross-refactoring の `gitfacts.read_result` と 3 つの取り込み(`merge-apply` / `merge-fix` / `merge-final-fix`)の実装 | #728(G4)。この文書は契約だけを決める(設計文書の「入出力の契約」) | +| cross-refactoring の `gitfacts.read_result` と 3 つの取り込み(`merge-apply` / `merge-fix` / `merge-final-fix`)の実装 | #728(G4)。この文書は契約だけを決める(設計文書の「両 Skill が従う契約」) | | 投稿の後に打ち切られた担当の記録と重ねての投稿(`prior_review_url`、記録だけの起動) | #583 → #730(G5)。既存の設計の決定 18・AC63〜AC67 はそちらへ移る | | 起動し直した担当を初回と同じ経路(証拠集約を含む)に通す骨組みの変更 | 既存の設計の決定 19・AC67。投稿の重なりと同じ骨組みの行を触るため #730(G5)へ渡す | | 利用上限の担当を外して残りの担当で回す | #478(D-B) | @@ -37,23 +60,6 @@ | 型・クラスの新設のうち、画面・永続データベース・OpenAPI に当たるもの | この変更に画面と API は無い。永続データは状態ファイルと監視の結果ファイル(JSON)で、設計文書の「データ構造」が表で持つ | | `CHANGELOG.md` と版数 | 配布の工程が書く | -## 用語 - -受け入れ条件はこの表の語で書く。 - -| 用語 | 意味 | -| --- | --- | -| 担当 | レビュー・反証・適用・修正を行う CLI(codex / agy / kiro / claude) | -| 起動 1 回 | `launch-cli.sh` が担当を 1 度起動し、`monitor.py` がそれを見終わるまで | -| 結末 | 起動 1 回の終わり方。監視の `status` と `reason`、結果ファイルの有無と読めるかを合わせたもの | -| 理由 | 結末の語彙の 1 語(`monitor_outcome.REASONS`) | -| 起動し直しの可否 | 同じ担当を同じ条件で起動し直せば解ける結末か(偽なら、起動し直しても同じ結末になる) | -| 使える結果 | 結果ファイルがあり、JSON オブジェクトとして読める | -| 監視の結果ファイル | `-monitor.json`。担当 1 者の最後の監視の結果(P1) | -| 監視の記録 | `monitor-outcomes.jsonl`。監視の結果を追記だけで積む(P1) | -| 利用上限 | 担当の CLI の月間・週間の利用枠に達し、起動し直しても解けない状態 | -| CLI の上限 | 担当の CLI 自身の実行時間の上限(agy の `--print-timeout`) | - ## 前提 利用上限の文言の実物は 2 つで、配布済みの P1 / P2 の上に載せる。 @@ -67,6 +73,63 @@ | 5 | 利用上限で止まった担当を外して残りの担当で回す判断は #478(D-B)が持つ。この変更は「同じ担当を起動し直さずに止めて理由を報告する」までである | | 6 | 監視の終了コード 0〜6 と標準出力の 13 個のキーは変えない(既存の設計の決定 16。G4 の骨組みがこれを前提にする) | +## 影響 + +監視の終了コードと標準出力は変わらず、増えるのは理由の値と状態ファイルの鍵である。 + +| 対象 | 影響 | +| --- | --- | +| `monitor.py` の終了コードと標準出力 | 変わらない。監視の結果ファイルと記録の `reason` に 2 つの値が増える | +| `monitor_outcome.REASONS` | 6 語から 9 語になる。読む側(`run_metrics.py` の `--by reason`)は値を集計するだけで、語彙の一覧を持たないため変更なし | +| 状態ファイル | `rounds[-1].<担当>` に `monitor_detail` が増える(結果なしのときだけ)。`no_result_reason` の値が 3 種類から 10 種類になる | +| `state.py judge` | `NO_RESULT_REASONS` の行が増える。利用上限では起動し直さず 1 で終わる。0 / 2 / 7 / 8 の意味は変わらない | +| `state.py read-result` | 終了コードは変わらない。`no_result_reason` の値が増える | +| `gitfacts.read_result` | この変更では触らない。契約(結果なしを値で返す)を G4 が実装する | +| 担当の CLI のプロセス | 独立したプロセスグループで動く。監視の停止がグループへ届く | +| 待ち時間の最悪値 | 利用上限では起動し直さないため、1 ラウンドあたり監視の上限 1 回分(レビュー 1200 秒)短くなる | + +## 非機能の条件 + +運用・保守性、セキュリティ、システム環境の 3 つを条件にする。 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 結果なしの理由が、状態ファイル(`no_result_reason`)・監視の結果ファイル・監視の記録・実行の要約の `launches[]` の 4 か所で同じ語彙で読める。理由の語彙を足すときに変える場所は `monitor_outcome.py` の 1 か所である | +| セキュリティ | `monitor_detail` は err.log の抜粋(最大 200 文字)で、作業ツリーの中の状態ファイルにだけ残る。実行の要約には入れない(既存の設計の決定 7 のまま) | +| システム環境 | macOS の bash 3.2 でも AC19 が成り立つこと(未確認。設計文書の「未確認のまま残ること」) | + +## 前提とする取り決め + +プロジェクトの規約のうち、この変更が従うものを 3 つ挙げる。 + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 2 つ以上の Skill が使う部品は `plugins/ndf/scripts/lib/` に置く(`plugins/ndf/scripts/lib/README.md` の「置いてよいもの・いけないもの」)。結末を読む関数は両 Skill が使うため共通層に置く | +| コーディング規約 | 外部コマンドとシグナルの挙動、正規表現の一致範囲は書く前に実行して確かめる(`AGENTS.md` の DO)。状態ファイルの鍵は追加だけで、既存の鍵の意味を変えない | +| テスト戦略 | 監視と起動は、PATH へ置いた偽の CLI を実プロセスとして動かす既存の形(`cross-review/tests/test_monitor_*.py`)。結末の読み取りは一時ディレクトリに監視の結果ファイルと結果ファイルを置いて単体に試す。`judge` / `report` は状態ファイルを作って呼ぶ | + +## 境界 + +常に行うこと、確認してから行うこと、行わないことを分ける。 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、配布物の同期の検査、新しい文言を実物のログの形で試すこと | +| 確認してから行う | 利用上限で起動し直さない判断(AC15)。理由の語彙の追加(G4 が読む) | +| 行わない | cross-refactoring の取り込みの実装(G4)、投稿の重なりと骨組みの経路の変更(G5)、担当を外して回す判断(D-B)、監視の終了コードの変更 | + +## 検証手段 + +テスト・配布物の同期・定義の検査と、2 つの issue の再現の手動確認で確かめる。 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | +| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | +| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | +| 手動確認(#619 の再現) | err.log に `Monthly request limit reached` を書く偽の kiro を PATH に置いて cross-review を 1 ラウンド回し、`judge` が 7 を返さず `NO_RESULT_REASONS='kiro=usage_limit'` を出して 1 で終わる | +| 手動確認(#584 の再現) | #584 の本文の再現スクリプト(3 秒後に子が書く `bash -c`)を `launch-cli.sh` 経由で起動し、`_kill_pid` の後に `late-result.json` が無い | + ## 受け入れ条件 24 件を 5 つの群に分ける。文言の一覧は設計文書の「入出力の契約」の「検知の文言」にある。 @@ -122,60 +185,36 @@ cross-review が値を読む(#619): - [ ] AC23: 「検証手段」の配布物の同期と定義の検査の 3 つのコマンドが、終了コード 0 で終わる - [ ] AC24: cross-review の `SKILL.md` の骨組みの行は変えない。骨組みとは、レビューの起動 → 待ち → `read-result` → `judge` → 7 で起動し直し → 8 で `flush` の並びである。判定の終了コードの分岐が増えない -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 運用・保守性 | 結果なしの理由が、状態ファイル(`no_result_reason`)・監視の結果ファイル・監視の記録・実行の要約の `launches[]` の 4 か所で同じ語彙で読める。理由の語彙を足すときに変える場所は `monitor_outcome.py` の 1 か所である | -| セキュリティ | `monitor_detail` は err.log の抜粋(最大 200 文字)で、作業ツリーの中の状態ファイルにだけ残る。実行の要約には入れない(既存の設計の決定 7 のまま) | -| システム環境 | macOS の bash 3.2 でも AC19 が成り立つこと(未確認。設計文書の「未確認のまま残ること」) | - -## 影響 - -監視の終了コードと標準出力は変わらず、増えるのは理由の値と状態ファイルの鍵である。 - -| 対象 | 影響 | -| --- | --- | -| `monitor.py` の終了コードと標準出力 | 変わらない。監視の結果ファイルと記録の `reason` に 2 つの値が増える | -| `monitor_outcome.REASONS` | 6 語から 9 語になる。読む側(`run_metrics.py` の `--by reason`)は値を集計するだけで、語彙の一覧を持たないため変更なし | -| 状態ファイル | `rounds[-1].<担当>` に `monitor_detail` が増える(結果なしのときだけ)。`no_result_reason` の値が 3 種類から 10 種類になる | -| `state.py judge` | `NO_RESULT_REASONS` の行が増える。利用上限では起動し直さず 1 で終わる。0 / 2 / 7 / 8 の意味は変わらない | -| `state.py read-result` | 終了コードは変わらない。`no_result_reason` の値が増える | -| `gitfacts.read_result` | この変更では触らない。契約(結果なしを値で返す)を G4 が実装する | -| 担当の CLI のプロセス | 独立したプロセスグループで動く。監視の停止がグループへ届く | -| 待ち時間の最悪値 | 利用上限では起動し直さないため、1 ラウンドあたり監視の上限 1 回分(レビュー 1200 秒)短くなる | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認(#619 の再現) | err.log に `Monthly request limit reached` を書く偽の kiro を PATH に置いて cross-review を 1 ラウンド回し、`judge` が 7 を返さず `NO_RESULT_REASONS='kiro=usage_limit'` を出して 1 で終わる | -| 手動確認(#584 の再現) | #584 の本文の再現スクリプト(3 秒後に子が書く `bash -c`)を `launch-cli.sh` 経由で起動し、`_kill_pid` の後に `late-result.json` が無い | +## 未決 -## 前提とする取り決め +1 件。決めるのは G4 の設計である。 -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 2 つ以上の Skill が使う部品は `plugins/ndf/scripts/lib/` に置く(`plugins/ndf/scripts/lib/README.md` の「置いてよいもの・いけないもの」)。結末を読む関数は両 Skill が使うため共通層に置く | -| コーディング規約 | 外部コマンドとシグナルの挙動、正規表現の一致範囲は書く前に実行して確かめる(`AGENTS.md` の DO)。状態ファイルの鍵は追加だけで、既存の鍵の意味を変えない | -| テスト戦略 | 監視と起動は、PATH へ置いた偽の CLI を実プロセスとして動かす既存の形(`cross-review/tests/test_monitor_*.py`)。結末の読み取りは一時ディレクトリに監視の結果ファイルと結果ファイルを置いて単体に試す。`judge` / `report` は状態ファイルを作って呼ぶ | - -## 境界 +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| `gitfacts.read_result` を共通の関数の薄い包みとして残すか、呼び出し側が共通の関数を直接呼ぶか | G4(#728)の設計 | G4 の設計 Pull Request | -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査、新しい文言を実物のログの形で試すこと | -| 確認してから行う | 利用上限で起動し直さない判断(AC15)。理由の語彙の追加(G4 が読む) | -| 行わない | cross-refactoring の取り込みの実装(G4)、投稿の重なりと骨組みの経路の変更(G5)、担当を外して回す判断(D-B)、監視の終了コードの変更 | +## 既存の受け入れ条件との対応 -## 未決 +既存の設計(PR #666)の P3 の受け入れ条件を、この文書のどこが引き継ぐかを示す。 -| 項目 | 誰が決めるか | 期限 | +| 既存 | この文書 | 変わったこと | | --- | --- | --- | -| `gitfacts.read_result` を共通の関数の薄い包みとして残すか、呼び出し側が共通の関数を直接呼ぶか | G4(#728)の設計 | G4 の設計 Pull Request | +| AC50 | AC2 | 同じ | +| AC51 | AC3 | 同じ | +| AC52 | AC4 | 同じ | +| AC53 | AC5 | 結果ファイルがあれば `ok` になることを明記 | +| AC54 | AC6 | 同じ | +| AC55 | AC9 + AC13 | 理由の表を `state.py` の規則から共通層の関数の規則へ移した。`unparsable` を共通の語彙に入れた | +| AC56 | AC14 | 同じ | +| AC57 | AC15 | 判定の条件を「`usage_limit` を含む」から「起動し直しの可否が偽」へ変えた。値は同じ | +| AC58 | AC16 | 同じ | +| AC59 | AC17 | 同じ | +| AC60〜AC62 | AC19〜AC21 | 同じ | +| AC63〜AC67 | — | #730(G5)へ | +| AC68 | AC18 | 理由が 10 個になった(`unparsable` を足し、`no_verdict` / `not_posted` を残す) | +| AC69 | AC18 | 同じ | +| — | AC1、AC8、AC10〜AC12 | 新設(結末を 1 つの値として読む契約) | +| — | AC24 | 新設(骨組みを変えない) | ## 依頼(原文) @@ -215,29 +254,6 @@ cross-review が値を読む(#619): > > 止めた理由を読む側(結果なしの理由の語彙)は、担当 1 回の起動の結末を共通の語彙で読む #729 が持つ。 -## 既存の受け入れ条件との対応 - -既存の設計(PR #666)の P3 の受け入れ条件を、この文書のどこが引き継ぐかを示す。 - -| 既存 | この文書 | 変わったこと | -| --- | --- | --- | -| AC50 | AC2 | 同じ | -| AC51 | AC3 | 同じ | -| AC52 | AC4 | 同じ | -| AC53 | AC5 | 結果ファイルがあれば `ok` になることを明記 | -| AC54 | AC6 | 同じ | -| AC55 | AC9 + AC13 | 理由の表を `state.py` の規則から共通層の関数の規則へ移した。`unparsable` を共通の語彙に入れた | -| AC56 | AC14 | 同じ | -| AC57 | AC15 | 判定の条件を「`usage_limit` を含む」から「起動し直しの可否が偽」へ変えた。値は同じ | -| AC58 | AC16 | 同じ | -| AC59 | AC17 | 同じ | -| AC60〜AC62 | AC19〜AC21 | 同じ | -| AC63〜AC67 | — | #730(G5)へ | -| AC68 | AC18 | 理由が 10 個になった(`unparsable` を足し、`no_verdict` / `not_posted` を残す) | -| AC69 | AC18 | 同じ | -| — | AC1、AC8、AC10〜AC12 | 新設(結末を 1 つの値として読む契約) | -| — | AC24 | 新設(骨組みを変えない) | - ## この文書の位置づけ この文書は「何を満たすか」だけを扱う。設計は [issue-729-619-584-design.md](issue-729-619-584-design.md) にある。 From e0484cfb2943e3cab5f06f51fd133eeb9558efa6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:05:59 +0000 Subject: [PATCH 018/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=E3=81=A8?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E3=81=AE=E6=96=87=E6=9B=B8=E3=82=92=20markdo?= =?UTF-8?q?wn-writing=20=E3=81=AE=E8=A6=8F=E7=B4=84=E3=81=A8=E8=AA=AD?= =?UTF-8?q?=E3=81=BF=E6=89=8B=E3=81=AE=E5=95=8F=E3=81=86=E9=A0=86=E3=81=A7?= =?UTF-8?q?=E7=B5=84=E3=81=BF=E7=9B=B4=E3=81=99=EF=BC=88#784=E3=80=81#788?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 見出しから識別子を外し、業務用語で「何のために何を決めた」を書く(識別子を含む見出し 17 件 → 0 件) - 説明文の識別子を業務用語へ置き換え、初出に括弧書きで添える。目的の直後に「用語の対応」を置く - 章立てを目的 → 用語 → 機能 → 実測 → 決定 → 構成要素 → 構造 → データ構造 → 契約 → 流れ → テスト設計の順にし、関連文書と前後関係を末尾へ移す - 100 字を超える文を分け、決定 7・9・13 の列挙を表にする(設計 平均文長 48.4 → 38.7 字) - 決定の中身・受け入れ条件の番号と内容・契約の形は変えていない Co-Authored-By: Claude Fable 5.1 --- issues/issue-728-647-592-553-design.md | 217 ++++++++++++------- issues/issue-728-647-592-553-requirements.md | 172 ++++++++------- 2 files changed, 241 insertions(+), 148 deletions(-) diff --git a/issues/issue-728-647-592-553-design.md b/issues/issue-728-647-592-553-design.md index 7e588a4e..b7e4edb7 100644 --- a/issues/issue-728-647-592-553-design.md +++ b/issues/issue-728-647-592-553-design.md @@ -2,17 +2,42 @@ ## 目的 -- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わると(無進捗の打ち切り・利用上限・15 秒で落ちる kiro)、取り込みが下位の読み取りの `die` で止まり、同じ群が上限なしに開き直される(PR #757 で 29 回、rf646 で 3729 回)。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続け(rf587 で 187 回)、claude が担当の群は帰属行のためトレーラーが読めずに落ちる +- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わることがある。原因は無進捗の打ち切り・利用上限・15 秒で落ちる kiro である。このとき取り込みは、下位の読み取りがプロセスを終わらせるため止まる。同じ群が上限なしに開き直される(PR #757 で 29 回、rf646 で 3729 回)。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続ける(rf587 で 187 回)。claude が担当の群は帰属行のためトレーラーが読めずに落ちる - **困る人**: cross-refactoring を回す進行側(手で止めるまで CLI の起動と利用料が続く)と、その Pull Request を読む人(未検証の差分が混じる) -- **直すと成り立つこと**: 結果なしは 3 つの取り込みが 1 つの手順で受け、未検証のコミットを取り消して結末を記録する。群は開いた回数と前回の結末を持ち、担当を替えて 1 回だけ開き直して終わる。項目の無い群は開かない。帰属行の後ろでもトレーラーが読める +- **直すと成り立つこと**: 結果なしは 3 つの取り込みが 1 つの手順で受ける。未検証のコミットを取り消し、結末を記録する。群は開いた回数と前回の結末を持ち、担当を替えて 1 回だけ開き直して終わる。項目の無い群は開かない。帰属行の後ろでもトレーラーが読める -## 文書の位置づけ +この文書は「どう作るか」だけを扱う。要求と受け入れ条件、置き換える既存の設計、並行する設計との前後関係は末尾の「関連文書と前後関係」にある。 -要求と受け入れ条件は [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md) にある。この文書は「どう作るか」だけを扱う。 +## 用語の対応 -**この文書は既存の設計 [issue-647-592-553-design.md](issue-647-592-553-design.md)(PR #665)を置き換える。** 引き継ぐ決定と変える決定は末尾の「既存の設計との対応」にある。G3(#729、PR #781)が決めた `read_result` の契約に従い、その先(3 つの取り込みが値をどう扱うか)をこの文書が決める。 +本文は左の業務用語で書く。右の識別子は、コードブロック・表・「置き場所」「データ構造」「入出力の契約」で使う。 -**実装は 1 本の Pull Request にまとめる**(決定 2)。G3 の実装 Pull Request が `develop` に入った後に始める。 +| 業務用語 | 識別子 | +| --- | --- | +| 取り込み | 担当の CLI が作ったコミットを進行側が検証して受け入れるコマンド。適用の取り込み `merge-apply`(`commands/apply.py`)/ 修正の取り込み `merge-fix`(`commands/converge.py`)/ 最終ゲートの修正の取り込み `merge-final-fix`(`commands/gate.py`)の 3 つ | +| 群を開く | `next-apply-round`(`commands/apply.py`) | +| 最終ゲート | `final-gate`(`commands/gate.py`) | +| 結末の読み取り | `gitfacts.read_result` | +| 共通層の読み取り | `lib/monitor_outcome.py` の `read_launch_outcome`。G3 が作る | +| 結末 | `LaunchOutcome`。担当 1 回の起動の終わり方。使える結果(`payload`)か、結果なしの理由(`reason`: `missing` / `unparsable` / `stalled` / `usage_limit` など)を持つ | +| 起動し直しの可否 | `LaunchOutcome.relaunch_same_agent`。偽は同じ担当を同じ条件で起動しても解けない(`usage_limit`) | +| 取り込みの共通手順 | 新設の `refactor_lib/intake.py`。範囲の値 `IntakeScope`、閉じた結果 `ClosedAttempt`、範囲の確定 `confirm_range`、取り消し `discard_unverified`、叩き直しの判定 `already_closed`、結果なしの一連 `close_without_result` | +| 群の進行 | `refactor_lib/rounds.py` | +| 開き直しの判定 | `rounds.group_reopening` | +| 輪番から担当を引く関数 | `rounds.impl_for_seq` | +| 群の一覧 | `rounds.apply_groups` | +| 群 | 状態ファイルの `rounds[].apply_rounds[]` の 1 件。書き換えるファイルが重ならない項目の集まり | +| 試行の番号 | 群の `attempt` | +| 結末の記録 | `failed_attempts[]`。群と最終ゲートの記録(`final_gate`)が持つ | +| 取り消しの理由 | 群の `drop_reason`(`no_result` / `empty`) | +| 起点 | 適用は `apply_base_sha` と群の `base_sha`、修正と最終ゲートは `fix_base_sha` | +| 修正ラウンドの数と上限 | `fix_rounds` と `--max-fix-rounds` | +| 輪番の通し番号 | `apply_seq` | +| 試行の上限 | `vocabulary.MAX_APPLY_ATTEMPTS`(2) | +| 無進捗の許容 | `init` が出す `IMPL_STALL_TIMEOUT`。余白は `vocabulary.IMPL_STALL_MARGIN`(900 秒)、テストの制限時間は `--test-timeout` | +| トレーラーの読み取り | `gitfacts.commit_trailers` | +| 結果ファイルの名前の幹 | stem。`paths.stem_for` が組み、監視は `--stem-template` で受ける | +| G1 / G3 / G4 | 実行計画の束の名前。G1 = 参加者の決め方(#727、PR #782)、G3 = 結末の読み取りの共通層(#729、PR #781)、G4 = この設計(#728) | ## 機能一覧 @@ -27,98 +52,123 @@ | F7 | 帰属行の段落が後ろに付いたコミットから必須トレーラーを読む | 同上(claude が適用担当の群) | | F8 | 適用・修正の監視が、テストの実行中の無出力で担当を打ち切らない | 同上 | +## 実測 + +2026-09-19 に `develop`(9eaebe14)で読み取った現状である。コードを実行して確かめた値は既存の設計の「実測」にあり、変えていない。 + +| 見たもの | 値 | +| --- | --- | +| `read_result` の呼び出し元 | 3 か所(`commands/apply.py:452` / `commands/converge.py:478` / `commands/gate.py:165`)。いずれも `die(code=2)` を内包する | +| 取り消しの本体 | 2 つ(`gitfacts.revert_unverified_range`(`converge.py:383` / `gate.py:203` が呼ぶ)と `apply._revert_unverified_apply_round`)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)と `entry["apply"]` の記録 | +| `merge-final-fix` の順序 | `read_result`(:165)→ `commits_in_range`(:167)→ `unassigned_fix_commits` / `verify_final_fix_commit`(:178-189)→ `revert_unverified_range`(:199-206)。結果なしでは 2 つ目以降へ進まない | +| `rounds.apply_groups` | `if groups:`(`rounds.py:109`)だけで分岐し、`None` と `[]` を区別しない | +| `stem_for` / `result_path` | `refactor_lib/paths.py:94` / `:85`。`result_path` は `tmp_dir / f"{stem}-result.json"` で、G3 の `read_launch_outcome` の既定と同じ | +| 既存の設計が名付けた関数 | `MAX_APPLY_ATTEMPTS` / `impl_for_seq` / `load_result` / `_close_failed_attempt` / `_monitor_reason` はいずれも未実装(PR #665 は文書だけ) | + ## 決定の記録 ### 決定 1: 通過済みの決定と新しい決定を差分で読み分けるために、設計文書は親 #728 の名前で新設し、既存の本体は書き換えない -既存の設計は 3 課題を 1 つの文書で扱い、設計 Pull Request の関門を通過している。親 #728 は決定 6(取り消しの本体を切り出して共有し、最終ゲートを #674 に残す)を改め、結果の読み取りの向きを変える。その節を書き換えると、通過済みの決定と新しい決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は変更したファイルの `## 決定の記録` の見出しをすべて写すため、1 行でも触ると既存の 13 件がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない既存の要求の文書にだけ足す(#729 / #727 の設計と同じ扱い)。 +既存の設計は 3 課題を 1 つの文書で扱い、設計 Pull Request の関門を通過している。親 #728 は既存の決定 6(取り消しの本体を切り出して共有し、最終ゲートを #674 に残す)を改め、結果の読み取りの向きを変える。その節を書き換えると、通過済みの決定と新しい決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの「決定の記録」の見出しをすべて写す。既存の本体に 1 行でも触ると、既存の 13 件がこの Pull Request の決定として並ぶ。案内は「決定の記録」を持たない既存の要求の文書にだけ足す(#729 / #727 の設計と同じ扱い)。 ### 決定 2: 同じ関数を 2 度変えないために、4 課題と #674 を 1 本の Pull Request で直す -`read_result` の契約を変えると、呼び出し元 3 か所(`merge-apply` / `merge-fix` / `merge-final-fix`)を同時に書き直すことになる。取り消しの本体を 1 つにすることも 3 か所を同時に触る。課題ごとに分けると同じ関数を 2 度変える。#553 は同じ `gitfacts.py` と `merge-apply` の検証に閉じ、分けても触るファイルが重なる。 +結末の読み取り(`gitfacts.read_result`)の契約を変えると、呼び出し元 3 か所を同時に書き直すことになる。3 か所は適用・修正・最終ゲートの修正の取り込みである。取り消しの本体を 1 つにすることも、同じ 3 か所を同時に触る。課題ごとに分けると同じ関数を 2 度変える。#553 は同じファイル(`gitfacts.py`)と適用の取り込みの検証に閉じ、分けても触るファイルが重なる。 -### 決定 3: 監視と同じ stem を 1 か所で組むために、`gitfacts.read_result` は名前を残して引数を状態と工程に変え、`LaunchOutcome` を返す +### 決定 3: 監視と同じ結果ファイルの名前を 1 か所で組むために、結末の読み取りは名前を残して引数を状態と工程に変え、結末を値で返す -G3 の契約(決定 11)は「`read_launch_outcome` を呼び、値を返し、`die` しない」までで、薄い包みとして残すかは G4 に任せている。**包みとして残す。** 監視の `--stem-template` と食い違う stem を渡すと監視の結果ファイルを引けない(G3 の未確認 6)。stem を作る場所を `read_result(state, runtime, phase, round_no=None)` の 1 つにすれば、3 つの取り込みが同じ組み立てを通り、突き合わせのテスト(AC3)も 1 か所で書ける。名前を残すのは、親 #728 と子 issue の本文がこの名前で根本原因を指しているためである。引数を変えるため、古い形の呼び出し(`read_result(path, runtime)`)は実行時に失敗し、契約の変更を素通りしない。 +G3 の契約(G3 の設計の決定 11)は「共通層の読み取り(`read_launch_outcome`)を呼び、値を返し、プロセスを終わらせない」までである。薄い包みとして残すかは G4 に任せている。**包みとして残す。** 監視に渡した結果ファイルの名前の幹(stem)と食い違う幹を渡すと、監視の結果ファイルを引けない(G3 の未確認 6)。幹を作る場所を結末の読み取り(`read_result(state, runtime, phase, round_no=None)`)の 1 つにすれば、3 つの取り込みが同じ組み立てを通る。突き合わせのテスト(AC3)も 1 か所で書ける。名前を残すのは、親 #728 と子 issue の本文がこの名前で根本原因を指しているためである。引数を変えるため、古い形の呼び出し(`read_result(path, runtime)`)は実行時に失敗し、契約の変更を素通りしない。 -呼び出し側が `read_launch_outcome` を直接呼ぶ形は採らない。stem の組み立てが 3 か所に分かれる。 +呼び出し側が共通層の読み取りを直接呼ぶ形は採らない。幹の組み立てが 3 か所に分かれる。 -### 決定 4: 取り消しの本体を 1 つにするために、「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を `intake.py` の 1 つの手順にする +### 決定 4: 取り消しの本体を 1 つにするために、「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を新設の取り込みの共通手順に置く -3 つの取り込みは、結果を読めたときも読めなかったときも、起点から HEAD までの範囲を確め、通らなければ範囲を取り消し、起点を取り消し後の HEAD へ進める。いまは取り消しの本体が 2 つにある(`gitfacts.revert_unverified_range` を修正と最終ゲートが、`apply._revert_unverified_apply_round` を適用が使う)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)だけである。**`refactor_lib/intake.py` を新設し、範囲の確定(`confirm_range`)・取り消し(`discard_unverified`)・結果なしの一連(`close_without_result`)を置く。** 起点の鍵と記録先の違いは `IntakeScope` の値で渡す。取り込みが持つのは、その値をどう終了コードと群の状態へ写すかだけになる(親 #728 の `consolidate_duplication`)。 +3 つの取り込みは、結果を読めたときも読めなかったときも同じことを行う。起点から HEAD までの範囲を確め、通らなければ範囲を取り消し、起点を取り消し後の HEAD へ進める。いまは取り消しの本体が 2 つある。修正と最終ゲートが使うもの(`gitfacts.revert_unverified_range`)と、適用が使うもの(`apply._revert_unverified_apply_round`)である。違いは起点の鍵だけである。**取り込みの共通手順(`refactor_lib/intake.py`)を新設する。そこに範囲の確定(`confirm_range`)・取り消し(`discard_unverified`)・結果なしの一連(`close_without_result`)を置く。** 起点の鍵と記録先の違いは、取り込みの範囲の値(`IntakeScope`)で渡す。取り込みが持つのは、その値をどう終了コードと群の状態へ写すかだけになる(親 #728 の統合の手)。 -置き場所を `commands/` にしないのは、`apply.py` / `converge.py` / `gate.py` の 3 つが読む層だからである(`rounds.py` と同じ理由。`commands` どうしの取り込みを作らない)。`gitfacts.py` に足す形も採らない。1158 行あり、取り消しは git の事実の読み取りではなく進行の手順である。 +置き場所をコマンドの層(`commands/`)にしないのは、適用・修正・最終ゲートの 3 つが読む層だからである。群の進行と同じ理由で、コマンドどうしの取り込みを作らない。git の事実の読み取り(`gitfacts.py`)に足す形も採らない。1158 行あり、取り消しは git の事実の読み取りではなく進行の手順である。 -### 決定 5: 要約と改修計画が 1 つの読み方で済むように、結果なしの記録は 3 つの取り込みで同じ形 `failed_attempts[]` にする +### 決定 5: 要約と改修計画が 1 つの読み方で済むように、結果なしの記録は 3 つの取り込みで同じ形の配列にする -群の `failed_attempts`、修正の `fix_merged_keys` の `":missing"`、最終ゲートの独自の記録と 3 通りに分けると、実行の要約と改修計画が 3 通りの読み方を持つ。**記録の辞書(群、または `final_gate`)の `failed_attempts[]` に `{phase, attempt, impl, reason, detail, at, reverted}` を足す。** `phase` が `apply` / `fix` / `final-fix` を分け、`attempt` が叩き直しの判定(同じ `phase` と `attempt` の記録があれば読まずに 2 を返す)に使う。修正の `fix_merged_keys` は結果を読めたときの二重取り込みの判定に残し、結果なしの判定には使わない。 +記録の形を 3 通りに分けると、実行の要約と改修計画が 3 通りの読み方を持つ。3 通りとは、群の結末の記録(`failed_attempts`)、修正の取り込み済みの鍵(`fix_merged_keys` の `":missing"`)、最終ゲートの独自の記録である。**記録の辞書は群、または最終ゲートの記録(`final_gate`)である。その結末の記録(`failed_attempts[]`)に `{phase, attempt, impl, reason, detail, at, reverted}` を足す。** 工程(`phase`)が適用・修正・最終ゲートの修正を分ける。試行の番号(`attempt`)は叩き直しの判定に使う。同じ工程と試行の記録があれば、読まずに終了コード 2 を返す。修正の取り込み済みの鍵は、結果を読めたときの二重取り込みの判定に残し、結果なしの判定には使わない。 ### 決定 6: 壊れた担当に当たり続けないために、同じ群の試行の上限は 2 回の固定値にし、引数を足さない -2 回目は別の担当が試すため(決定 8)、2 回とも結果を残さなければ担当ではなく群の側を疑える。3 回以上にしても壊れた担当に当たる確率が上がるだけである。値は `vocabulary.py` の `MAX_APPLY_ATTEMPTS` に置く。`--max-apply-attempts` を足す形は採らない。`SKILL.md` は上限を 2 つ置くとどちらで止まったかを読み解く必要が出るとしており、止まった理由は群の記録(`drop_reason` と `failed_attempts`)が持つ。 +2 回目は別の担当が試す(決定 8)。2 回とも結果を残さなければ、担当ではなく群の側を疑える。3 回以上にしても壊れた担当に当たる確率が上がるだけである。値は語彙(`vocabulary.py` の `MAX_APPLY_ATTEMPTS`)に置く。上限の引数(`--max-apply-attempts`)を足す形は採らない。手順書(`SKILL.md`)は、上限を 2 つ置くとどちらで止まったかを読み解く必要が出るとしている。止まった理由は群の記録(取り消しの理由と結末の記録)が持つ。 + +### 決定 7: 中断からの再開と失敗のやり直しを区別するために、開き直しの判定は群の進行の 1 つの関数に置き、群を開く側と適用の取り込みの両方がそれを読む -### 決定 7: 中断からの再開と失敗のやり直しを区別するために、開き直しの判定は `rounds.group_reopening` の 1 つに置き、`next-apply-round` と `merge-apply` の両方がそれを読む +いまの群を開く側(`next-apply-round`)は、未着手(`pending`)と適用済み(`applied`)の群を無条件に開き直す。中断からの再開と失敗した試行のやり直しを区別しない。判定に要る値は群が持つ 2 つである。開いた回数(`attempt`)と、結末の記録のうち工程が適用のものの件数である。**開き直しの判定(`group_reopening(group)`)がこの 2 つから次の 4 つのどれかを返す。** -いまの `next-apply-round` は `pending` / `applied` の群を無条件に開き直し、中断からの再開と失敗した試行のやり直しを区別しない。判定に要る値は群が持つ 2 つ、開いた回数(`attempt`)と結末の記録(`failed_attempts` の `phase: apply` の件数)である。**`group_reopening(group)` がこの 2 つから `open`(開いて `attempt` を進める)/ `resume`(開いたまま閉じていない試行を再開する。番号を進めない)/ `exhausted`(上限に達した。開かない)/ `empty`(項目が無い)を返す。** `next-apply-round` は開くかどうかを、`merge-apply` は結果なしを記録した後に担当を替えるか取り消すかを、同じ関数の値で決める。 +| 値 | 意味 | +| --- | --- | +| `open` | 開いて試行の番号を進める | +| `resume` | 開いたまま閉じていない試行を再開する。番号を進めない | +| `exhausted` | 上限に達した。開かない | +| `empty` | 項目が無い | + +群を開く側は開くかどうかを、この値で決める。適用の取り込み(`merge-apply`)は結果なしを記録した後に担当を替えるか取り消すかを、同じ関数の値で決める。 -開いた回数だけを数える形は採らない。進行側が落ちて再開しただけで試行が進む。監視の終了コードで骨組みが分岐する形も採らない。結果ファイルが後から書かれた場合や、監視は `OK` でも JSON が壊れている場合は結果ファイルの側で決めるしかなく、判定を 1 か所に置けば骨組みは `|| continue` のまま変わらない。 +開いた回数だけを数える形は採らない。進行側が落ちて再開しただけで試行が進む。監視の終了コードで骨組みが分岐する形も採らない。結果ファイルが後から書かれた場合や、監視は正常でも JSON が壊れている場合は、結果ファイルの側で決めるしかない。判定を 1 か所に置けば、骨組みは `|| continue` のまま変わらない。 ### 決定 8: 壊れた CLI が 1 者でも他の担当で群を進めるために、2 回目の試行は次の輪番の担当が行い、替える先が無いときだけ結末の可否で決める -結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の STALLED 4 回、claude の 429 の 3729 回、PR #757 の kiro)。同じ担当で開き直しても直らない。替える先は、`apply_seq` を 1 ずつ進めて `rounds.impl_for_seq` を引き、その群で失敗した担当のどれとも違う担当が出た最初の番号の担当である。1 つ進めるだけにしないのは、輪番が 1 周すると同じ担当へ戻るためである(既存の設計の「実測」: `assign(1)` と `assign(5)` はどちらも codex)。探索は参加者の数だけ進めれば全員を 1 度ずつ見るので、打ち切りの回数は固定値ではなく参加者の数から導く。 +結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の無進捗 4 回、claude の 429 の 3729 回、PR #757 の kiro)。同じ担当で開き直しても直らない。替える先は次の手順で決める。輪番の通し番号(`apply_seq`)を 1 ずつ進め、輪番から担当を引く関数(`rounds.impl_for_seq`)を引く。その群で失敗した担当のどれとも違う担当が出た、最初の番号の担当を替える先とする。1 つ進めるだけにしないのは、輪番が 1 周すると同じ担当へ戻るためである(既存の設計の「実測」: 通し番号 1 と 5 はどちらも codex)。探索は参加者の数だけ進めれば全員を 1 度ずつ見る。打ち切りの回数は固定値ではなく参加者の数から導く。 -**替える先が無いとき**(参加者が 1 者。G1 の `--exclude` で残りが 1 者になる実行)は、`LaunchOutcome.relaunch_same_agent` を読む。真(`missing` / `stalled` など)なら同じ担当で 2 回目を開き、偽(`usage_limit`)なら 1 回目で群を取り消す。可否を読むのはこの分岐だけで、替える先があるときは常に替える。利用上限で進行全体を止める形は採らない。他の担当で進められる群まで止まる。 +**替える先が無いとき**(参加者が 1 者。G1 の除外の引数 `--exclude` で残りが 1 者になる実行)は、起動し直しの可否(`relaunch_same_agent`)を読む。真(結果なしの理由が `missing` / `stalled` など)なら同じ担当で 2 回目を開く。偽(`usage_limit`)なら 1 回目で群を取り消す。可否を読むのはこの分岐だけで、替える先があるときは常に替える。利用上限で進行全体を止める形は採らない。他の担当で進められる群まで止まる。 -### 決定 9: 参加者の決め方の変更(G1)を 1 か所で受けるために、作業を任せる担当の決定は `rounds.impl_for_seq` の 1 つを通す +### 決定 9: 参加者の決め方の変更(G1)を 1 か所で受けるために、作業を任せる担当の決定は輪番から担当を引く 1 つの関数を通す -`rounds.impl_for_seq(state, seq)` を新設し、輪番の通し番号から担当と要求モデルを引く呼び出しはその中だけにする。呼ぶのは 3 か所で、群の担当(`apply._assign_apply_rounds_to_state`)・交代先(`apply._close_failed_attempt`)・最終ゲートの修正担当(`gate._final_fix_impl`)を決める。G1(#727)は `assignment.assign` を `impl_assign(participants, seq)` に置き換える。**どちらが先に入っても、変える場所は `impl_for_seq` の中だけである。** `setup.cmd_start_round` の担当は CLI を起動しないため通さない。 +輪番から担当を引く関数(`rounds.impl_for_seq(state, seq)`)を新設する。輪番の通し番号から担当と要求モデルを引く呼び出しは、その中だけにする。呼ぶのは 3 か所である。 + +| 呼ぶ場所 | 決めるもの | +| --- | --- | +| 群の割り当て(`apply._assign_apply_rounds_to_state`) | 群の担当 | +| 結果なしの試行を閉じる処理(`apply._close_failed_attempt`) | 交代先 | +| 最終ゲートの修正担当の決定(`gate._final_fix_impl`) | 最終ゲートの修正担当 | + +G1 は担当の割り当て(`assignment.assign`)を参加者からの割り当て(`impl_assign(participants, seq)`)に置き換える。**どちらが先に入っても、変える場所はこの関数の中だけである。** 提案ラウンドの開始(`setup.cmd_start_round`)の担当は CLI を起動しないため通さない。 ### 決定 10: 修正が上限なしに往復しないように、修正の結果は群の担当から読み、結果なしは修正ラウンドを進める。起動し直せない結末では上限へ進める -`merge-fix` は提案ラウンドの担当(`entry["impl"]`)の結果を読むが、骨組みが起動するのは群の担当である。一致しない群では結果を一度も取り込めず、`fix_rounds` が進まないまま検証と修正を往復する(既存の設計の「実測」)。担当は `current_group(entry)["impl"]` から読む。結果なしは `close_without_result` を通し、`fix_rounds` を 1 進めて 2 で終わる。範囲を確定できないときの既存の扱い(`_resolve_fix_range`)と同じ形である。 +修正の取り込み(`merge-fix`)は提案ラウンドの担当(`entry["impl"]`)の結果を読むが、骨組みが起動するのは群の担当である。一致しない群では結果を一度も取り込めない。修正ラウンドの数(`fix_rounds`)が進まないまま、検証と修正を往復する(既存の設計の「実測」)。担当は群の担当(`current_group(entry)["impl"]`)から読む。結果なしは結果なしの一連(`close_without_result`)を通し、修正ラウンドの数を 1 進めて終了コード 2 で終わる。範囲を確定できないときの既存の扱い(`_resolve_fix_range`)と同じ形である。 -**`relaunch_same_agent` が偽なら、`fix_rounds` を `--max-fix-rounds` の値にする。** 修正の担当は替えない(直しかけの文脈を持つ者が続ける、という最終ゲートの既存の理由と同じ)。替えないまま上限まで起動し直すと、利用上限の担当を最大 3 回起動して 3 回とも 15 秒で落ちる。上限へ進めれば `should-abandon` が次の呼び出しで見送りへ移し、骨組みの行は変わらない。 +**起動し直しの可否が偽なら、修正ラウンドの数を上限(`--max-fix-rounds`)の値にする。** 修正の担当は替えない。直しかけの文脈を持つ者が続ける、という最終ゲートの既存の理由と同じである。替えないまま上限まで起動し直すと、利用上限の担当を最大 3 回起動して 3 回とも 15 秒で落ちる。上限へ進めれば見送りの判定(`should-abandon`)が次の呼び出しで見送りへ移し、骨組みの行は変わらない。 -### 決定 11: 未検証のコミットを Pull Request に残さないために、最終ゲートの修正も同じ手順を通し、結果なしは 2 で終えて `final-gate` に判定を戻す +### 決定 11: 未検証のコミットを Pull Request に残さないために、最終ゲートの修正も同じ手順を通し、結果なしは終了コード 2 で終えて最終ゲートに判定を戻す -`merge-final-fix` が結果なしで `die` すると、担当が作ったコミットが範囲の検査も取り消しも受けずに残る。次の `final-gate` はそのコミットを含む HEAD でテストし、落ちれば起点を HEAD へ置き直す(#674)。**`close_without_result` を通せば、コミットは取り消され、起点は取り消し後の HEAD になり、次の `final-gate` は修正前の地点でテストする。** 終了コードは 2 で、骨組みは見ずに `final-gate` へ戻る(変えない)。`final-gate` が `fix_rounds` を進めるため、繰り返しは `--max-fix-rounds` で止まる。 +最終ゲートの修正の取り込み(`merge-final-fix`)が結果なしでプロセスを終わらせると、担当が作ったコミットが範囲の検査も取り消しも受けずに残る。次の最終ゲート(`final-gate`)はそのコミットを含む HEAD でテストし、落ちれば起点を HEAD へ置き直す(#674)。**結果なしの一連を通せば、コミットは取り消され、起点は取り消し後の HEAD になる。次の最終ゲートは修正前の地点でテストする。** 終了コードは 2 で、骨組みは見ずに最終ゲートへ戻る(変えない)。最終ゲートが修正ラウンドの数を進めるため、繰り返しは上限で止まる。 -`relaunch_same_agent` が偽なら `gate.fix_rounds` を `--max-fix-rounds` の値にする。次の `final-gate` はテストが落ちれば 1(取り消さず報告)で終わる。既存の「Step 7 は push 済みの地点。上限に達しても採用した改善項目は取り消さない」の規則を変えない。 +起動し直しの可否が偽なら、最終ゲートの修正ラウンドの数(`gate.fix_rounds`)を上限の値にする。次の最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる。既存の「Step 7 は push 済みの地点。上限に達しても採用した改善項目は取り消さない」の規則を変えない。 ### 決定 12: 項目の無い群で担当を起動しないために、採用 0 件の提案ラウンドでは群を作らず、項目の無い群は開かずに取り消す -`rounds.apply_groups` は `if groups:` で分岐するため、鍵が無い(`None`)ときも空の配列(`[]`)のときも 1 つの群を作る。`merge-proposals` は採用 0 件で `apply_rounds = []` を書くため、ここで項目 0 件の群が生まれる。**鍵が無いときだけ古い版として群を作り、空の配列はそのまま返す。** 既に項目の無い群を持つ状態ファイル(rf587)は残る。`group_reopening` が `empty` を返した群は、`next-apply-round` が `dropped`(`drop_reason: empty`)にして次を探す。`merge-apply` の取り込み済みの判定で採用 0 件だった群も `dropped` に直す。 +群の一覧(`rounds.apply_groups`)は `if groups:` で分岐する。鍵が無い(`None`)ときも空の配列(`[]`)のときも 1 つの群を作る。提案の取り込み(`merge-proposals`)は採用 0 件で空の配列(`apply_rounds = []`)を書くため、ここで項目 0 件の群が生まれる。**鍵が無いときだけ古い版として群を作り、空の配列はそのまま返す。** 既に項目の無い群を持つ状態ファイル(rf587)は残る。開き直しの判定が「項目が無い」(`empty`)を返した群は、群を開く側が取り消し済み(`dropped`、理由 `empty`)にして次を探す。適用の取り込みの取り込み済みの判定でも、採用 0 件だった群を取り消し済みに直す。 -`merge-proposals` がテスト整備の採用 0 件で 2 を返す形は採らない。2 は構造改善の繰り返しを終える合図で、テスト整備では構造改善へ進む前に抜けてしまう。 +提案の取り込みがテスト整備の採用 0 件で終了コード 2 を返す形は採らない。2 は構造改善の繰り返しを終える合図で、テスト整備では構造改善へ進む前に抜けてしまう。 ### 決定 13: 帰属行の書式に左右されずに検証を通すために、トレーラーは末尾から続く段落を git の判定で読み、進行側はコミットを書き換えない -`commit_trailers` はメッセージを空行で段落に分け、末尾の段落から前へ向かって 1 段落ずつ `git interpret-trailers --parse` に掛け、git がトレーラーの段落と判定しなかった段落で止める。同じ鍵は末尾に近い段落の値を採る。**1 段落目(題名)は掛けない**(掛けると `Refactor: …` の題名をトレーラーとして読む。既存の設計の「実測」)。 +トレーラーの読み取り(`commit_trailers`)はメッセージを空行で段落に分ける。末尾の段落から前へ向かって 1 段落ずつ git の判定(`git interpret-trailers --parse`)に掛け、git がトレーラーの段落と判定しなかった段落で止める。同じ鍵は末尾に近い段落の値を採る。**1 段落目(題名)は掛けない。** 掛けると `Refactor: …` の題名をトレーラーとして読む(既存の設計の「実測」)。 -#553 の本文は、帰属のトレーラーを付ける責務を進行側の取り込みへ移す(`git commit --amend`)ことを修正レイヤーとしている。**採らない。** 3 つの理由がある。SHA が変わり、結果ファイルの `commits[].sha` の申告と実体の対応が切れる。群に複数の項目があると先頭のコミットを書き換えた時点で以降のすべてが書き換わり、範囲の検査の前提(申告の SHA が範囲に実在する)が崩れる。`Impl-Model`(実際に使ったモデル名)は担当しか知らず、進行側が付けるには結果ファイルから受け取ることになる。段落ごとの読み取りは、トレーラーの形で書かれた署名なら誰が何行足しても同じに読む。トレーラーの形でない散文を末尾に足すランタイムが現れたときだけ効かず、それは「未確認のまま残ること」に置く。 +#553 の本文は、帰属のトレーラーを付ける責務を進行側の取り込みへ移す(`git commit --amend`)ことを修正レイヤーとしている。**採らない。** 理由は 3 つある。 -全文から `^: ` を拾う形も採らない。散文の段落にある `Round: …` の形の行を拾う。 - -### 決定 14: 人が git の標準の読み方でも集計できるように、雛形のコミットの規約にも必須トレーラーを最後の段落に置くことを書く +| 理由 | 何が起きるか | +| --- | --- | +| SHA が変わる | 結果ファイルの申告(`commits[].sha`)と実体の対応が切れる | +| 群に複数の項目がある | 先頭のコミットを書き換えた時点で以降のすべてが書き換わる。範囲の検査の前提(申告の SHA が範囲に実在する)が崩れる | +| 実際に使ったモデル名(`Impl-Model`)は担当しか知らない | 進行側が付けるには結果ファイルから受け取ることになる | -進行側の検証は決定 13 で通る。一方、人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計すると最後の段落しか読まない。帰属行を同じ段落に続けて書けば、git の標準の読み方でも取れる。従わなくても決定 13 で検証は通る。 +段落ごとの読み取りは、トレーラーの形で書かれた署名なら誰が何行足しても同じに読む。効かないのは、トレーラーの形でない散文を末尾に足すランタイムが現れたときだけである。それは「未確認のまま残ること」に置く。 -### 決定 15: テストの実行中の無出力で担当を打ち切らないために、無進捗の許容を `--test-timeout` + 900 秒にし、雛形に進捗マーカーを足す +全文から `^: ` を拾う形も採らない。散文の段落にある `Round: …` の形の行を拾う。 -既存の設計の決定 13 をそのまま引き継ぐ。適用・修正の担当はテストを 1 回実行し、その間は何も出力しない。`init` が `IMPL_STALL_TIMEOUT` を出し、骨組みの適用・修正・最終ゲートの修正の監視が `--stall-timeout` に渡す。`--timeout` は渡さない(上限は P2 の `--phase` と `lib/limits.py` が決める)。`monitor.py` は変えない。 +### 決定 14: 人が git の標準の読み方でも集計できるように、雛形のコミットの規約にも必須トレーラーを最後の段落に置くことを書く -## 実測 +進行側の検証は決定 13 で通る。一方、人が git の標準の読み方(`git log --format='%(trailers:key=Impl-Model,valueonly)'`)で集計すると、最後の段落しか読まない。帰属行を同じ段落に続けて書けば、git の標準の読み方でも取れる。従わなくても決定 13 で検証は通る。 -2026-09-19 に `develop`(9eaebe14)で読み取った現状である(コードを実行して確かめた値は既存の設計の「実測」にあり、変えていない)。 +### 決定 15: テストの実行中の無出力で担当を打ち切らないために、無進捗の許容をテストの制限時間 + 900 秒にし、雛形に進捗マーカーを足す -| 見たもの | 値 | -| --- | --- | -| `read_result` の呼び出し元 | 3 か所(`commands/apply.py:452` / `commands/converge.py:478` / `commands/gate.py:165`)。いずれも `die(code=2)` を内包する | -| 取り消しの本体 | 2 つ(`gitfacts.revert_unverified_range`(`converge.py:383` / `gate.py:203` が呼ぶ)と `apply._revert_unverified_apply_round`)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)と `entry["apply"]` の記録 | -| `merge-final-fix` の順序 | `read_result`(:165)→ `commits_in_range`(:167)→ `unassigned_fix_commits` / `verify_final_fix_commit`(:178-189)→ `revert_unverified_range`(:199-206)。結果なしでは 2 つ目以降へ進まない | -| `rounds.apply_groups` | `if groups:`(`rounds.py:109`)だけで分岐し、`None` と `[]` を区別しない | -| `stem_for` / `result_path` | `refactor_lib/paths.py:94` / `:85`。`result_path` は `tmp_dir / f"{stem}-result.json"` で、G3 の `read_launch_outcome` の既定と同じ | -| 既存の設計が名付けた関数 | `MAX_APPLY_ATTEMPTS` / `impl_for_seq` / `load_result` / `_close_failed_attempt` / `_monitor_reason` はいずれも未実装(PR #665 は文書だけ) | +既存の設計の決定 13 をそのまま引き継ぐ。適用・修正の担当はテストを 1 回実行し、その間は何も出力しない。起動(`init`)が無進捗の許容(`IMPL_STALL_TIMEOUT`)を出す。骨組みの適用・修正・最終ゲートの修正の監視が、それを無進捗の引数(`--stall-timeout`)に渡す。全体の制限時間(`--timeout`)は渡さない。上限は P2 の工程の引数(`--phase`)と `lib/limits.py` が決める。監視(`monitor.py`)は変えない。 ## 構成要素 @@ -167,7 +217,7 @@ graph TD G --> IS ``` -**図に含めない要素**は、語彙・起動の出力・トレーラーの読み取り・雛形・手順書である。呼び出しの辺を持たない値と文書か、`merge-apply` の検証が呼ぶ 1 本(`commit_trailers`)で、図の主題(結末の扱いの統合)ではない。 +**図に含めない要素**は、語彙・起動の出力・トレーラーの読み取り・雛形・手順書である。呼び出しの辺を持たない値と文書か、適用の取り込みの検証が呼ぶ 1 本(トレーラーの読み取り)であり、図の主題(結末の扱いの統合)ではない。 ### 文脈と配置 @@ -181,7 +231,7 @@ graph LR RF --> WORK[作業ツリーの git] ``` -**配置は変えない。** すべて利用者の機械のホストのセッションから起動するプロセスで、常駐しない。この変更で増える辺は `refactor.py` が共通層 `monitor_outcome` を通して監視の結果ファイルを読む 1 本だけである。 +**配置は変えない。** すべて利用者の機械のホストのセッションから起動するプロセスで、常駐しない。この変更で増える辺は 1 本だけである。進行の本体(`refactor.py`)が共通層を通して監視の結果ファイルを読む辺である。 ### 置き場所 @@ -203,7 +253,7 @@ plugins/ndf/skills/cross-refactoring/ ## 構造 -変更が触る型だけを載せる。`LaunchOutcome` は G3 が作る型で名前と欄だけを置く。`intake` の関数は「入出力の契約」にある。 +変更が触る型だけを載せる。結末(`LaunchOutcome`)は G3 が作る型で、名前と欄だけを置く。取り込みの共通手順の関数は「入出力の契約」にある。 ```mermaid classDiagram @@ -245,22 +295,28 @@ classDiagram 状態ファイル(`cross-refactoring-rf<番号>-state.json`)に鍵を足す。**版は上げない。** 鍵が無い状態ファイルは、試行 0・失敗なしとして読む。 -### `rounds[].apply_rounds[]`(群) +### 群の記録 + +群(`rounds[].apply_rounds[]` の 1 件)に足す鍵である。 | 項目 | 型 | 値 | 空のときの意味 | | --- | --- | --- | --- | | `attempt` | 整数 | いま開いている試行の番号。1 から | 鍵なし = 0(まだ開いていない)。`merge-apply` は 0 を 1 回目として記録し、値を 1 に書く(変更前の版で開いた群を再開したとき) | -| `failed_attempts` | 配列 | 結果を残さなかった起動。1 件 = 下の「結末の記録」 | 鍵なし = 失敗なし | +| `failed_attempts` | 配列 | 結果を残さなかった起動。1 件 = 下の「結末の記録の 1 件」 | 鍵なし = 失敗なし | | `drop_reason` | 文字列 | `no_result`(試行の上限、または起動し直せない結末で交代先なし)/ `empty`(項目なし) | 鍵なし = 既存の経路で取り消した、または取り消していない | | `impl` / `impl_model` | 既存 | 担当を替えたときに書き換える。**前の担当は `failed_attempts[].impl` に残る** | — | -### `final_gate` +### 最終ゲートの記録 + +最終ゲートの記録(`final_gate`)に足す鍵である。 | 項目 | 型 | 値 | 空のときの意味 | | --- | --- | --- | --- | | `failed_attempts` | 配列 | 結果を残さなかった最終ゲートの修正の起動 | 鍵なし = 失敗なし | -### 結末の記録(`failed_attempts[]` の 1 件) +### 結末の記録の 1 件 + +結末の記録(`failed_attempts[]`)の 1 要素の形である。 | 列 | 型 | 空を許すか | 意味 | | --- | --- | --- | --- | @@ -272,7 +328,7 @@ classDiagram | `at` | 文字列 | 許さない | 記録した時刻 | | `reverted` | 整数 | 許さない | その起動の範囲から取り消したコミットの数。0 = コミットなし | -**記録は追記だけで、上書きしない。** 群の `impl` は替えると上書きされるが、どの担当がどの試行で失敗したかは記録から読める。見送りの理由(`deferred_items[].defer_reason`)は `実装担当が結果を残しませんでした(agy: stalled → codex: missing)` の形にし、改修計画の「見送った項目」の表にそのまま出る。 +**記録は追記だけで、上書きしない。** 群の担当は替えると上書きされるが、どの担当がどの試行で失敗したかは記録から読める。見送りの理由(`deferred_items[].defer_reason`)は `実装担当が結果を残しませんでした(agy: stalled → codex: missing)` の形にする。改修計画の「見送った項目」の表にそのまま出る。 ### 機能とデータの対応 @@ -300,11 +356,11 @@ stateDiagram-v2 dropped --> [*] ``` -**`pending` のまま同じ担当で無条件に開き直す遷移は無い。** 同じ担当で開き直すのは、交代先が無く `relaunch_same_agent` が真のとき 1 回だけである(決定 8)。 +**未着手のまま同じ担当で無条件に開き直す遷移は無い。** 同じ担当で開き直すのは、交代先が無く起動し直しの可否が真のとき 1 回だけである(決定 8)。 ## 入出力の契約 -### `gitfacts.read_result`(変更) +### 結末の読み取り(変更) | 項目 | 内容 | | --- | --- | @@ -314,7 +370,7 @@ stateDiagram-v2 | 失敗の形 | **失敗しない。** `die` せず、標準出力・標準エラーに書かない | | 互換性 | 引数が変わる。呼び出し元 3 か所と `test_git_facts.py` の 3 件を書き直す | -### `intake`(新設) +### 取り込みの共通手順(新設) | 名前 | 入力 | 出力 | 失敗の形 | | --- | --- | --- | --- | @@ -323,7 +379,7 @@ stateDiagram-v2 | `already_closed(scope)` | `scope.records["failed_attempts"]` | 同じ `phase` と `attempt` の記録があれば真 | 失敗しない | | `close_without_result(path, state, scope, outcome)` | `LaunchOutcome` | `ClosedAttempt`。範囲が `None` なら `range_unknown=True` で即返す。範囲にコミットがあれば `discard_unverified`。`records["failed_attempts"]` に 1 件足して保存。取り消したときだけ `push_with_retry_marker(holder)` | 取り消しの失敗は上と同じ | -### `rounds`(変更・新設) +### 群の進行(変更・新設) | 名前 | 契約 | | --- | --- | @@ -341,11 +397,11 @@ stateDiagram-v2 | `merge-fix` | 取り込んだ | — | 範囲を確定できない・**結果なし(新。修正ラウンドは進む)** | 群が無い(新) | | `merge-final-fix` | 取り込んだ | — | 範囲を確定できない・**結果なし(新。取り消して起点を戻す)** | 修正担当が未記録(変更なし) | -骨組みは `merge-apply` の 2 で `continue` し、`merge-fix` と `merge-final-fix` の終了コードを見ない。**どちらも変えない。** +骨組みは適用の取り込みの 2 で `continue` し、修正と最終ゲートの修正の取り込みの終了コードを見ない。**どちらも変えない。** -### `init` の出力と骨組み +### 起動の出力と骨組み -`IMPL_STALL_TIMEOUT=` を足す。骨組みの差分は、`--phase apply` / `fix` / `final-fix` の 3 つの `monitor.py` の呼び出しに `--stall-timeout "$IMPL_STALL_TIMEOUT"` を 1 行ずつ足すことだけである。`merge-apply` の注記は「終了コード 2 = この群を取り消した、または担当を替えて開き直す」に改める。 +起動の出力に無進捗の許容(`IMPL_STALL_TIMEOUT=`)を足す。骨組みの差分は、適用・修正・最終ゲートの修正の 3 つの監視の呼び出しに、無進捗の引数(`--stall-timeout "$IMPL_STALL_TIMEOUT"`)を 1 行ずつ足すことだけである。適用の取り込みの注記は「終了コード 2 = この群を取り消した、または担当を替えて開き直す」に改める。 ### 雛形に足す文 @@ -356,7 +412,9 @@ stateDiagram-v2 ## 処理の流れ -### `next-apply-round` +### 群を開く + +群を開く側(`next-apply-round`)の流れである。 ```mermaid graph TD @@ -372,7 +430,9 @@ graph TD O --> OUT ``` -### `merge-apply` +### 適用の取り込み + +適用の取り込み(`merge-apply`)の流れである。 ```mermaid graph TD @@ -392,14 +452,16 @@ graph TD CF --> R2 ``` -`_close_failed_attempt` は次の順で行う。 +結果なしの試行を閉じる処理(`_close_failed_attempt`)は次の順で行う。 -1. `close_without_result` を呼ぶ(範囲の確定 → 取り消し → `failed_attempts` へ `{phase: apply, attempt, impl, reason, detail, at, reverted}`。`attempt` が 0 なら 1 として記録し、群の `attempt` を 1 にする)。`range_unknown` なら項目を `blocked` にして 4 で中断する -2. `group_reopening(group)` を読む。`open` なら `apply_seq` を 1 ずつ進めて `impl_for_seq` を引き、`failed_attempts[].impl` のどれとも違う担当が出たらその担当と要求モデルで `impl` / `impl_model` を書き換える。参加者の数だけ進めても出なければ(輪番は参加者の数で 1 周する)、`relaunch_same_agent` が真なら担当を替えずに終え、偽なら手順 3 と同じく取り消す -3. `exhausted` なら群の項目を `abandoned` にして `deferred_items` へ入れる。群は `dropped`(`drop_reason: no_result`)にし、`apply.merged_at` を立て、局面を `phase_after_group` にする +1. 結果なしの一連(`close_without_result`)を呼ぶ。範囲の確定 → 取り消し → 結末の記録へ 1 件(`{phase: apply, attempt, impl, reason, detail, at, reverted}`)の順である。試行の番号が 0 なら 1 として記録し、群の試行の番号を 1 にする。範囲が確定できない(`range_unknown`)なら項目を `blocked` にして 4 で中断する +2. 開き直しの判定を読む。「開く」なら輪番の通し番号を 1 ずつ進めて輪番から担当を引く。結末の記録にある担当のどれとも違う担当が出たら、その担当と要求モデルで群の担当(`impl` / `impl_model`)を書き換える。参加者の数だけ進めても出なければ(輪番は参加者の数で 1 周する)、起動し直しの可否が真なら担当を替えずに終え、偽なら手順 3 と同じく取り消す +3. 「上限に達した」なら群の項目を `abandoned` にして見送り(`deferred_items`)へ入れる。群は取り消し済み(`dropped`、理由 `no_result`)にし、取り込みの時刻(`apply.merged_at`)を立て、局面を `phase_after_group` にする 4. 保存して 2 で終わる(push は手順 1 が取り消したときに済ませている) -### `merge-fix` と `merge-final-fix` +### 修正の取り込みと最終ゲートの修正の取り込み + +修正の取り込み(`merge-fix`)と最終ゲートの修正の取り込み(`merge-final-fix`)は同じ流れを通る。 ```mermaid graph TD @@ -416,7 +478,7 @@ graph TD PM --> R2 ``` -3 つの取り込みが渡す `IntakeScope` の値は次のとおりである。結果を読めたときの検証の失敗(`_revert_unverified_apply_round` / `_revert_invalid_fix_round` / 最終ゲートの `unassigned or problems`)も `discard_unverified` を呼ぶ。 +3 つの取り込みが渡す取り込みの範囲の値(`IntakeScope`)は次のとおりである。結果を読めたときの検証の失敗も取り消し(`discard_unverified`)を呼ぶ。該当は 3 つで、`_revert_unverified_apply_round` / `_revert_invalid_fix_round` / 最終ゲートの `unassigned or problems` である。 | 取り込み | `holder` | `base_key` | `records` | `phase` | `attempt` | `impl` | `label` | `mirror` | | --- | --- | --- | --- | --- | --- | --- | --- | --- | @@ -433,7 +495,7 @@ graph TD ## テスト設計 -実行は `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q`。状態の遷移は関数を直接呼ぶ既存の形(`no_git` / `patch_lib` / `git_facts`)、トレーラーは一時リポジトリで実際に git を実行する。`read_launch_outcome` は差し替えず、一時ディレクトリに結果ファイルと監視の結果ファイルを置いて本物を通す。 +実行は `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q` である。状態の遷移は関数を直接呼ぶ既存の形(`no_git` / `patch_lib` / `git_facts`)で確かめる。トレーラーは一時リポジトリで実際に git を実行する。共通層の読み取りは差し替えず、一時ディレクトリに結果ファイルと監視の結果ファイルを置いて本物を通す。 | 受け入れ条件 | 何で確かめるか | 置き場所 | | --- | --- | --- | @@ -496,3 +558,12 @@ graph TD | 決定 13(無進捗の許容) | 決定 15 | 同じ | | 構成要素の `apply._monitor_reason` / `gitfacts.load_result` | — | G3 の `read_launch_outcome` に置き換わり、作らない | | — | 決定 1・3・5・10 | 新設(文書の置き方、`read_result` の包み、記録の形、修正の結果なしと起動し直せない結末) | + +## 関連文書と前後関係 + +| 項目 | 内容 | +| --- | --- | +| 要求と受け入れ条件 | [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md)。この文書は「どう作るか」だけを扱う | +| 置き換える既存の設計 | [issue-647-592-553-design.md](issue-647-592-553-design.md)(PR #665)。**この文書が置き換える。** 引き継ぐ決定と変える決定は「既存の設計との対応」にある | +| 従う契約 | G3(#729、PR #781)が決めた結末の読み取りの契約。その先(3 つの取り込みが値をどう扱うか)をこの文書が決める | +| 実装の単位と順序 | 1 本の Pull Request にまとめる(決定 2)。G3 の実装 Pull Request が `develop` に入った後に始める | diff --git a/issues/issue-728-647-592-553-requirements.md b/issues/issue-728-647-592-553-requirements.md index 56eaeb5b..19079b0a 100644 --- a/issues/issue-728-647-592-553-requirements.md +++ b/issues/issue-728-647-592-553-requirements.md @@ -2,15 +2,41 @@ ## 目的 -- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わると、取り込みが下位の読み取りの `die` で止まり、同じ群が上限なしに開き直される。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続け、claude が担当の群は帰属行のためトレーラーが読めずに落ちる +- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わることがある。このとき取り込みは、下位の読み取りがプロセスを終わらせるため止まる。同じ群が上限なしに開き直される。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続ける。claude が担当の群は帰属行のためトレーラーが読めずに落ちる - **困る人**: cross-refactoring を回す進行側(手で止めるまで CLI の起動と利用料が続く)と、その Pull Request を読む人(未検証の差分が混じる) -- **直すと成り立つこと**: 3 つの取り込み(適用・修正・最終ゲートの修正)が結果なしを同じ手順で受け、未検証のコミットを取り消して結末を記録し、終了コードを返す。適用ラウンドの繰り返しは有限回で終わり、群は開いた回数と前回の結末で開き直すか・担当を替えるか・取り消すかが決まる。採用 0 件では群を作らない。起動し直しても解けない結末(利用上限)では同じ担当を同じ工程で起動し直さない。帰属行の後ろでもトレーラーが読める +- **直すと成り立つこと**: 3 つの取り込み(適用・修正・最終ゲートの修正)が結果なしを同じ手順で受ける。未検証のコミットを取り消し、結末を記録し、終了コードを返す。適用ラウンドの繰り返しは有限回で終わる。群は開いた回数と前回の結末で、開き直すか・担当を替えるか・取り消すかが決まる。採用 0 件では群を作らない。起動し直しても解けない結末(利用上限)では、同じ担当を同じ工程で起動し直さない。帰属行の後ろでもトレーラーが読める -## 文書の位置づけ +この文書は「何を満たすか」だけを扱う。設計、置き換える既存の要求、範囲に入れる子 issue は末尾の「関連文書と前後関係」にある。 -設計は [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md) にある。この文書は「何を満たすか」だけを扱う。 +## 用語 -**この文書は既存の要求 [issue-647-592-553-requirements.md](issue-647-592-553-requirements.md)(PR #665)を置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。親 #728 が根本原因の場所(結果の読み取りの向きと、3 つの取り込みの重複)を定め直したため、受け入れ条件を親の名前で改めて置く。#674(最終ゲートの修正)は #728 の子として範囲に入れる。閉じるのは棚卸に任せる。 +本文は左の用語で書く。右の識別子は、引用・表・受け入れ条件の判定値で使う。 + +| 用語 | 意味 | +| --- | --- | +| 取り込み | 担当の CLI が作ったコミットを、進行側が検証して受け入れるコマンド。適用の取り込み `merge-apply` / 修正の取り込み `merge-fix` / 最終ゲートの修正の取り込み `merge-final-fix` の 3 つ | +| 群を開く | `next-apply-round`。次に適用する群を選んで担当を出す | +| 最終ゲート | `final-gate`。全体のテストを実行して判定する | +| 結末の読み取り | `gitfacts.read_result`。担当の結果ファイルを読む | +| 共通層の読み取り | `lib/monitor_outcome.py` の `read_launch_outcome`。G3 が作る | +| 群 | 適用ラウンド。書き換えるファイルが重ならない項目の集まりで、状態の `rounds[].apply_rounds[]` の 1 件 | +| 試行 | 1 つの群に対して適用担当を起動し、適用の取り込みで取り込もうとした 1 回。番号は `attempt` | +| 結末 | 担当 1 回の起動の終わり方。G3 の `LaunchOutcome`(使える結果か、結果なしの理由か) | +| 結果なし | `LaunchOutcome.payload` が `None`。理由は `reason`(`missing` / `unparsable` / `stalled` など) | +| 起動し直しの可否 | `LaunchOutcome.relaunch_same_agent`。偽は同じ担当を同じ条件で起動しても解けない(`usage_limit`) | +| 結末の記録 | `failed_attempts[]`。結果を残さなかった起動の記録で、群と最終ゲートの記録(`final_gate`)が持つ | +| 取り消しの理由 | 群の `drop_reason`(`no_result` / `empty`) | +| 開き直しの判定 | `rounds.group_reopening` | +| 輪番から担当を引く関数 | `rounds.impl_for_seq` | +| 取り込みの共通手順 | 新設の `refactor_lib/intake.py`。取り消しの本体は `intake.discard_unverified` | +| 範囲 | 取り込みが検査するコミットの列。起点(`apply_base_sha` / `fix_base_sha`)から HEAD まで | +| 未検証のコミット | 範囲にあるが、結果なしで検証を受けられなかったコミット | +| 修正ラウンドの数と上限 | `fix_rounds` と `--max-fix-rounds` | +| 無進捗の許容 | `init` が出す `IMPL_STALL_TIMEOUT`。テストの制限時間(`--test-timeout`)+ 900 秒 | +| 帰属行 | Claude Code がコミットメッセージへ足す `Co-Authored-By:` / `Claude-Session:` の行 | +| トレーラーの段落 | `git interpret-trailers --parse` がトレーラーとして読む段落 | +| トレーラーの読み取り | `gitfacts.commit_trailers` | +| G1 / G3 | 実行計画の束の名前。G1 = 参加者の決め方(#727、PR #782)、G3 = 結末の読み取りの共通層(#729、PR #781) | ## 依頼(原文) @@ -68,18 +94,20 @@ 含む: -- `gitfacts.read_result` の契約の置き換え(結果なしを値で返す。`die` しない) -- 3 つの取り込みの「範囲の確定 → 未検証コミットの取り消し → 結末の記録」の共通化(`refactor_lib/intake.py` の新設) -- 取り消しの本体の一本化(`gitfacts.revert_unverified_range` と `apply._revert_unverified_apply_round` の 2 つを 1 つに) -- 群の開き直しの判定の一本化(`rounds.group_reopening`)と、群が持つ試行の記録(`attempt` / `failed_attempts` / `drop_reason`) -- 同じ群の試行の上限(2 回)と、2 回目の担当の交代 -- 採用 0 件の提案ラウンドで群を作らないこと。項目の無い群を開かないこと(#592) -- `merge-fix` が読む結果の担当と、結果が無いときの修正ラウンドの数え方 -- `merge-final-fix` が結果なしで未検証のコミットを取り消すこと(#674) -- 起動し直せない結末(`relaunch_same_agent` が偽)のときの 3 つの取り込みの振る舞い -- `commit_trailers` の読み方と、適用・修正の雛形のコミットの規約(#553) -- 適用・修正・最終ゲートの修正の監視に渡す無進捗の許容と、雛形の進捗マーカー(#647 の STALLED 対策。既存の設計から引き継ぐ) -- `SKILL.md` の語の表・「別の上限を置かない」の段落・骨組みの監視の引数、`docs/02-apply-and-review.md` / `docs/04-fix-and-report.md` の対応箇所 +| 変えるもの | 内容 | +| --- | --- | +| 結末の読み取りの契約 | 結果なしを値で返す。プロセスを終わらせない(`die` しない) | +| 取り込みの共通手順の新設 | 3 つの取り込みの「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を共通化する(`refactor_lib/intake.py`) | +| 取り消しの本体の一本化 | `gitfacts.revert_unverified_range` と `apply._revert_unverified_apply_round` の 2 つを 1 つにする | +| 群の開き直しの判定の一本化 | 開き直しの判定(`rounds.group_reopening`)と、群が持つ試行の記録(`attempt` / `failed_attempts` / `drop_reason`) | +| 試行の上限と担当の交代 | 同じ群の試行の上限(2 回)と、2 回目の担当の交代 | +| 採用 0 件の扱い | 採用 0 件の提案ラウンドで群を作らない。項目の無い群を開かない(#592) | +| 修正の取り込みが読む担当 | 修正の取り込みが読む結果の担当と、結果が無いときの修正ラウンドの数え方 | +| 最終ゲートの修正の結果なし | 最終ゲートの修正の取り込みが、結果なしで未検証のコミットを取り消す(#674) | +| 起動し直せない結末 | 起動し直しの可否が偽のときの 3 つの取り込みの振る舞い | +| トレーラーの読み方 | トレーラーの読み取りの読み方と、適用・修正の雛形のコミットの規約(#553) | +| 無進捗の許容 | 適用・修正・最終ゲートの修正の監視に渡す無進捗の許容と、雛形の進捗マーカー(#647 の無進捗の対策。既存の設計から引き継ぐ) | +| 手順書 | `SKILL.md` の語の表・「別の上限を置かない」の段落・骨組みの監視の引数、`docs/02-apply-and-review.md` / `docs/04-fix-and-report.md` の対応箇所 | 含まない: @@ -95,111 +123,96 @@ | `CHANGELOG.md` と版数 | 配布の工程が書く | | クラス図 | 設計文書の「構造」に触る型(`LaunchOutcome` / `IntakeScope` / `ClosedAttempt`)だけを載せる | -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 取り込み | 担当の CLI が作ったコミットを、進行側が検証して受け入れるコマンド。`merge-apply` / `merge-fix` / `merge-final-fix` の 3 つ | -| 群 | 適用ラウンド。書き換えるファイルが重ならない項目の集まりで、状態の `rounds[].apply_rounds[]` の 1 件 | -| 試行 | 1 つの群に対して適用担当を起動し、`merge-apply` で取り込もうとした 1 回。番号は `attempt` | -| 結末 | 担当 1 回の起動の終わり方。G3 の `LaunchOutcome`(使える結果か、結果なしの理由か) | -| 結果なし | `LaunchOutcome.payload` が `None`。理由は `reason`(`missing` / `unparsable` / `stalled` など) | -| 起動し直しの可否 | `LaunchOutcome.relaunch_same_agent`。偽は同じ担当を同じ条件で起動しても解けない(`usage_limit`) | -| 範囲 | 取り込みが検査するコミットの列。起点(`apply_base_sha` / `fix_base_sha`)から HEAD まで | -| 未検証のコミット | 範囲にあるが、結果なしで検証を受けられなかったコミット | -| 帰属行 | Claude Code がコミットメッセージへ足す `Co-Authored-By:` / `Claude-Session:` の行 | -| トレーラーの段落 | `git interpret-trailers --parse` がトレーラーとして読む段落 | - ## 受け入れ条件(結末の読み取り) -- [ ] AC1: 結果ファイルが無い状態で `gitfacts.read_result` を呼ぶと、`SystemExit` を出さず、標準出力・標準エラーに書かず、`payload` が `None` で `reason` が `missing` の値を返す -- [ ] AC2: 監視の結果ファイル(`-apply-r-monitor.json`)に `reason: stalled` があるとき、`read_result` の `reason` は `stalled`、`relaunch_same_agent` は真である。`reason: usage_limit` のとき `relaunch_same_agent` は偽である -- [ ] AC3: `read_result` に渡す stem は、監視の `--stem-template`(`{agent}-apply-r$ROUND` / `{agent}-fix-r$ROUND` / `{agent}-final-fix`)を担当名で埋めた値と一致する。`paths.stem_for` の 3 つの工程の値を骨組みの雛形から作った値と突き合わせる +- [ ] AC1: 結果ファイルが無い状態で結末の読み取り(`gitfacts.read_result`)を呼ぶと、例外(`SystemExit`)を出さず、標準出力・標準エラーに書かない。結果なしの値(`payload` が `None`、`reason` が `missing`)を返す +- [ ] AC2: 監視の結果ファイル(`-apply-r-monitor.json`)に無進捗の理由(`reason: stalled`)があるとき、結末の読み取りの理由は `stalled`、起動し直しの可否は真である。利用上限の理由(`reason: usage_limit`)のとき、可否は偽である +- [ ] AC3: 結末の読み取りに渡す結果ファイルの名前の幹(stem)は、監視の名前の雛形(`--stem-template`: `{agent}-apply-r$ROUND` / `{agent}-fix-r$ROUND` / `{agent}-final-fix`)を担当名で埋めた値と一致する。幹を組む関数(`paths.stem_for`)の 3 つの工程の値を、骨組みの雛形から作った値と突き合わせる ## 受け入れ条件(共通の手順) - [ ] AC4: 前提: 結果なしで、起点から HEAD までにコミットが 1 件以上ある 操作: 3 つの取り込みのいずれかを呼ぶ 結果: そのコミットは取り消され、起点(`apply_base_sha` と群の `base_sha` / `fix_base_sha` / `final_gate.fix_base_sha`)は取り消し後の HEAD になる -- [ ] AC5: 結果なしのとき、3 つの取り込みのいずれでも、記録の辞書(群 / `final_gate`)の `failed_attempts` に `{phase, attempt, impl, reason, detail, at, reverted}` の 1 件が足される。`reason` は `read_result` の値、`reverted` は取り消したコミットの数である +- [ ] AC5: 結果なしのとき、3 つの取り込みのいずれでも、記録の辞書(群 / `final_gate`)の結末の記録(`failed_attempts`)に 1 件(`{phase, attempt, impl, reason, detail, at, reverted}`)が足される。理由(`reason`)は結末の読み取りの値、取り消した数(`reverted`)は取り消したコミットの数である - [ ] AC6: 結果なしで範囲にコミットが無いとき、`git revert` も `git push` も実行されない -- [ ] AC7: 結果なしの取り込みを、同じ試行番号でもう一度呼ぶと、結果ファイルを読まずに前回と同じ終了コード 2 を返し、`failed_attempts` の件数は増えない。その間に結果ファイルが現れても読まない -- [ ] AC8: 3 つの取り込みで範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは、`merge-apply` は 4、`merge-fix` は修正ラウンドを 1 進めて 2、`merge-final-fix` は 2 である +- [ ] AC7: 結果なしの取り込みを、同じ試行番号でもう一度呼ぶと、結果ファイルを読まずに前回と同じ終了コード 2 を返す。結末の記録の件数は増えない。その間に結果ファイルが現れても読まない +- [ ] AC8: 3 つの取り込みで範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは次のとおりである。適用の取り込みは 4、修正の取り込みは修正ラウンドを 1 進めて 2、最終ゲートの修正の取り込みは 2 ## 受け入れ条件(#647: 適用ラウンド) -- [ ] AC9: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに `next-apply-round` → `merge-apply` を呼ぶ。終了コードは 2。1 つ目の群は `status: pending` のまま担当が agy 以外に替わり、`attempt` は 1、`failed_attempts` は 1 件(`phase: apply`、`attempt: 1`、`impl: agy`)である -- [ ] AC10: AC9 の後、替わった担当の結果ファイルも置かずにもう一度 `next-apply-round` → `merge-apply` を呼ぶ。1 つ目の群は `status: dropped`・`drop_reason: no_result`、項目は `abandoned`、`deferred_items` に `実装担当が結果を残しませんでした(agy: missing → codex: missing)` の形の理由で入る -- [ ] AC11: 結果ファイルを 1 つも置かずに `next-apply-round` が 1 を返すまで繰り返す。`next-apply-round` の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、両方の群が `dropped` になる -- [ ] AC12: 結果ファイルが JSON として読めない場合と JSON の配列の場合も AC9 と同じ状態になり、`failed_attempts[].reason` は `unparsable` である -- [ ] AC13: 群が 4 つ(`apply_seq` 4)あり先頭の群(担当 codex)が結果を残さない。替えた後の担当は codex 以外で、`apply_seq` は進めた分だけ進み、他の群の担当は変わらない -- [ ] AC14: 監視の結果ファイルの `reason` が `usage_limit` で、`rounds.impl_for_seq` の差し替えにより交代先が無い状態では、1 回目の失敗で群が `dropped`(`drop_reason: no_result`)になる。`reason` が `missing` で交代先が無い状態では、同じ担当で 2 回目を開く -- [ ] AC15: `next-apply-round` を `merge-apply` を挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。群の `attempt` は 1 のまま進まない -- [ ] AC16: 着手前のテストの状態が `green` でない状態で `merge-apply` を呼ぶと、結果ファイルを読まずに終了コード 4 で終わる -- [ ] AC17: `merge-apply` が終了コード 2 で終わった後の群は、`dropped` か、`failed_attempts` を持つ `pending` のどちらかである。確かめる経路は 4 つ(結果なし / 未割当のコミット / 適用の検証の失敗 / 取り込み済みで採用 0 件) -- [ ] AC18: `rounds.group_reopening` を差し替えると、`next-apply-round` の開き方(開く・再開・開かない)と `merge-apply` の結果なしの後の扱い(担当の交代・取り消し)の両方が、差し替えた関数の返す値に従う +- [ ] AC9: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに群を開く → 適用の取り込みを呼ぶ。終了コードは 2。1 つ目の群は未着手(`status: pending`)のまま担当が agy 以外に替わる。試行の番号(`attempt`)は 1、結末の記録は 1 件(`phase: apply`、`attempt: 1`、`impl: agy`)である +- [ ] AC10: AC9 の後、替わった担当の結果ファイルも置かずにもう一度、群を開く → 適用の取り込みを呼ぶ。1 つ目の群は取り消し済み(`status: dropped`・`drop_reason: no_result`)、項目は `abandoned` になる。見送り(`deferred_items`)に `実装担当が結果を残しませんでした(agy: missing → codex: missing)` の形の理由で入る +- [ ] AC11: 結果ファイルを 1 つも置かずに、群を開く操作が 1 を返すまで繰り返す。群を開く操作の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、両方の群が取り消し済み(`dropped`)になる +- [ ] AC12: 結果ファイルが JSON として読めない場合と JSON の配列の場合も AC9 と同じ状態になり、結末の記録の理由(`failed_attempts[].reason`)は `unparsable` である +- [ ] AC13: 群が 4 つ(輪番の通し番号 `apply_seq` が 4)あり、先頭の群(担当 codex)が結果を残さない。替えた後の担当は codex 以外である。輪番の通し番号は進めた分だけ進み、他の群の担当は変わらない +- [ ] AC14: 監視の結果ファイルの理由が `usage_limit` で、輪番から担当を引く関数(`rounds.impl_for_seq`)の差し替えにより交代先が無い状態では、1 回目の失敗で群が取り消し済み(`dropped`、`drop_reason: no_result`)になる。理由が `missing` で交代先が無い状態では、同じ担当で 2 回目を開く +- [ ] AC15: 群を開く操作を、適用の取り込みを挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。群の試行の番号は 1 のまま進まない +- [ ] AC16: 着手前のテストの状態が `green` でない状態で適用の取り込みを呼ぶと、結果ファイルを読まずに終了コード 4 で終わる +- [ ] AC17: 適用の取り込みが終了コード 2 で終わった後の群は、取り消し済み(`dropped`)か、結末の記録を持つ未着手(`pending`)のどちらかである。確かめる経路は 4 つ(結果なし / 未割当のコミット / 適用の検証の失敗 / 取り込み済みで採用 0 件) +- [ ] AC18: 開き直しの判定(`rounds.group_reopening`)を差し替えると、群を開く側の開き方(開く・再開・開かない)と、適用の取り込みの結果なしの後の扱い(担当の交代・取り消し)の両方が、差し替えた関数の返す値に従う ## 受け入れ条件(#592: 採用 0 件と項目の無い群) -- [ ] AC19: テスト整備ラウンドで提案が 0 件の状態で `merge-proposals` を呼んだ後、`next-apply-round` を呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの `apply_rounds` は空の配列のままである -- [ ] AC20: `apply_rounds` の鍵を持たない状態ファイル(群を導入する前の版)では、`next-apply-round` が従来どおりラウンド全体を 1 つの群として開く +- [ ] AC19: テスト整備ラウンドで提案が 0 件の状態で提案の取り込み(`merge-proposals`)を呼んだ後、群を開く操作を呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの群の配列(`apply_rounds`)は空のままである +- [ ] AC20: 群の配列の鍵(`apply_rounds`)を持たない状態ファイル(群を導入する前の版)では、群を開く操作が従来どおりラウンド全体を 1 つの群として開く - [ ] AC21: 前提: rf587 で残った形の群(`status: applied`・`items: []`・`apply.merged_at` あり・`applied: []`) - 操作: `merge-apply` を呼ぶ - 結果: 終了コード 2 で終わり、群が `dropped`(`drop_reason: empty`)になる。続く `next-apply-round` は 1 を返す -- [ ] AC22: `status: pending`・`items: []` の群を持つ状態で `next-apply-round` を呼ぶ。その群は開かれずに `dropped`(`drop_reason: empty`)になり、次の群があればそれを開き、無ければ終了コード 1 を返す + 操作: 適用の取り込みを呼ぶ + 結果: 終了コード 2 で終わり、群が取り消し済み(`dropped`、`drop_reason: empty`)になる。続く群を開く操作は 1 を返す +- [ ] AC22: 未着手で項目が無い群(`status: pending`・`items: []`)を持つ状態で、群を開く操作を呼ぶ。その群は開かれずに取り消し済み(`dropped`、`drop_reason: empty`)になる。次の群があればそれを開き、無ければ終了コード 1 を返す ## 受け入れ条件(修正ラウンド) -- [ ] AC23: 群の担当が agy、提案ラウンドの担当が codex の状態で `agy-fix-r1-result.json` を置いて `merge-fix` を呼ぶ。agy の結果が取り込まれ、`fix_rounds` が 1 になる -- [ ] AC24: 修正の結果ファイルが無い状態で `merge-fix` を呼ぶと、終了コード 2 で終わり、`fix_rounds` が 1 進み、群の `failed_attempts` に `phase: fix` の 1 件が足される。`--max-fix-rounds` 回続けた後の `should-abandon` は終了コード 0 を返す -- [ ] AC25: AC24 の直後に `verify-round` を挟まず `merge-fix` をもう一度呼んでも `fix_rounds` は進まない(AC7 の修正ラウンドの形) -- [ ] AC26: 修正の結果なしで監視の `reason` が `usage_limit` のとき、`merge-fix` は `fix_rounds` を `--max-fix-rounds` の値にし、続く `should-abandon` は終了コード 0 を返す -- [ ] AC27: 修正の結果なしで起点から HEAD にコミットがあるとき、取り消され、`fix_base_sha` が取り消し後の HEAD になる(AC4 の修正ラウンドの形) +- [ ] AC23: 群の担当が agy、提案ラウンドの担当が codex の状態で、agy の結果ファイル(`agy-fix-r1-result.json`)を置いて修正の取り込みを呼ぶ。agy の結果が取り込まれ、修正ラウンドの数(`fix_rounds`)が 1 になる +- [ ] AC24: 修正の結果ファイルが無い状態で修正の取り込みを呼ぶと、終了コード 2 で終わり、修正ラウンドの数が 1 進む。群の結末の記録に `phase: fix` の 1 件が足される。上限(`--max-fix-rounds`)の回数だけ続けた後の見送りの判定(`should-abandon`)は終了コード 0 を返す +- [ ] AC25: AC24 の直後に検証(`verify-round`)を挟まず修正の取り込みをもう一度呼んでも、修正ラウンドの数は進まない(AC7 の修正ラウンドの形) +- [ ] AC26: 修正の結果なしで監視の理由が `usage_limit` のとき、修正の取り込みは修正ラウンドの数を上限の値にする。続く見送りの判定は終了コード 0 を返す +- [ ] AC27: 修正の結果なしで起点から HEAD にコミットがあるとき、取り消され、起点(`fix_base_sha`)が取り消し後の HEAD になる(AC4 の修正ラウンドの形) ## 受け入れ条件(#674: 最終ゲートの修正) -- [ ] AC28: 前提: 最終ゲートの修正の結果ファイルが無く、`final_gate.fix_base_sha` から HEAD にコミットが 1 件ある - 操作: `merge-final-fix` を呼ぶ - 結果: 終了コード 2。そのコミットは取り消され、`final_gate.fix_base_sha` は取り消し後の HEAD、`final_gate.failed_attempts` は 1 件(`phase: final-fix`) -- [ ] AC29: AC28 の後に `final-gate` を呼ぶと、テストは取り消し後の HEAD で実行され、`fix_commits` に取り消したコミットは入らない -- [ ] AC30: 最終ゲートの修正の結果なしで監視の `reason` が `usage_limit` のとき、`final_gate.fix_rounds` は `--max-fix-rounds` の値になり、続く `final-gate` はテストが落ちれば終了コード 1(取り消さず報告)で終わる -- [ ] AC31: 結果ファイルがあり検証を通る最終ゲートの修正は、変更前と同じく取り込まれ、`final_gate.failed_attempts` を持たない +- [ ] AC28: 前提: 最終ゲートの修正の結果ファイルが無く、最終ゲートの起点(`final_gate.fix_base_sha`)から HEAD にコミットが 1 件ある + 操作: 最終ゲートの修正の取り込みを呼ぶ + 結果: 終了コード 2。そのコミットは取り消され、最終ゲートの起点は取り消し後の HEAD になる。最終ゲートの結末の記録(`final_gate.failed_attempts`)は 1 件(`phase: final-fix`) +- [ ] AC29: AC28 の後に最終ゲートを呼ぶと、テストは取り消し後の HEAD で実行され、修正のコミットの一覧(`fix_commits`)に取り消したコミットは入らない +- [ ] AC30: 最終ゲートの修正の結果なしで監視の理由が `usage_limit` のとき、最終ゲートの修正ラウンドの数(`final_gate.fix_rounds`)は上限の値になる。続く最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる +- [ ] AC31: 結果ファイルがあり検証を通る最終ゲートの修正は、変更前と同じく取り込まれ、最終ゲートの結末の記録を持たない ## 受け入れ条件(#553: 帰属行の後ろのトレーラー) 一時リポジトリで実際にコミットを作って確かめる: -- [ ] AC32: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、`commit_trailers` が 4 つとも値を返す +- [ ] AC32: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、トレーラーの読み取りが 4 つとも値を返す - [ ] AC33: AC32 の段落の後に `Co-Authored-By:` と `Claude-Session:` の 2 行の段落が付いても、4 つとも返す - [ ] AC34: 必須トレーラーの段落と末尾の段落の間に散文の段落があるコミットで、散文より前にある `Round: …` の形の行を読まない - [ ] AC35: 末尾の段落に散文とトレーラーの形の行が混ざる(git がトレーラーの段落と判定しない)コミットで、その行を読まない - [ ] AC36: 同じ鍵が 2 つの段落にあるとき、末尾に近い段落の値を返す - [ ] AC37: AC32 の形のコミットを申告した適用ラウンドが、トレーラーの欠落で取り消されない - [ ] AC38: 本文がトレーラーの段落 1 つだけで、題名が `Round: 本文の題名` の形のコミットで、題名を読まない -- [ ] AC39: `prompts/apply.md` と `prompts/fix.md` のコミットの規約が、必須トレーラーをメッセージの最後の段落に置くことを書く +- [ ] AC39: 適用と修正の雛形(`prompts/apply.md` / `prompts/fix.md`)のコミットの規約が、必須トレーラーをメッセージの最後の段落に置くことを書く ## 受け入れ条件(無進捗の打ち切り) -- [ ] AC40: `init` の出力に `IMPL_STALL_TIMEOUT` が入り、値が `--test-timeout` の値 + 900 である(既定で 1800) -- [ ] AC41: `SKILL.md` の骨組みで、`--phase apply` / `fix` / `final-fix` の 3 つの `monitor.py` の呼び出しが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない -- [ ] AC42: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が、作業段階ごとに `$RF_STEM-progress.log` へ 1 行追記する指示を持つ +- [ ] AC40: 起動(`init`)の出力に無進捗の許容(`IMPL_STALL_TIMEOUT`)が入り、値がテストの制限時間(`--test-timeout`)の値 + 900 である(既定で 1800) +- [ ] AC41: `SKILL.md` の骨組みで、適用・修正・最終ゲートの修正(`--phase apply` / `fix` / `final-fix`)の 3 つの監視(`monitor.py`)の呼び出しが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない +- [ ] AC42: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が、作業段階ごとに進捗の記録(`$RF_STEM-progress.log`)へ 1 行追記する指示を持つ ## 受け入れ条件(文書) - [ ] AC43: `SKILL.md` の「この Skill で使う語」の適用ラウンドの行が、同じ群の試行の上限(2 回)を書く。`grep -n "別の上限を置かない\|別に置かない" SKILL.md` が何も出力しない -- [ ] AC44: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6・Step 7 が 2 つを書く。結果なしのときの取り込みの振る舞い(取り消し・記録・終了コード)と、`SKILL.md` と同じ `monitor.py` の引数である -- [ ] AC45: `docs/02-apply-and-review.md` のトレーラーの節が、`git log --format='%(trailers:…)'` が最後の段落しか読まないことと、進行側の読み方の 2 つを書く +- [ ] AC44: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6・Step 7 が 2 つを書く。結果なしのときの取り込みの振る舞い(取り消し・記録・終了コード)と、`SKILL.md` と同じ監視の引数である +- [ ] AC45: `docs/02-apply-and-review.md` のトレーラーの節が、git の標準の読み方(`git log --format='%(trailers:…)'`)が最後の段落しか読まないことと、進行側の読み方の 2 つを書く ## 受け入れ条件(退行しない) -- [ ] AC46: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、`failed_attempts` を持たない +- [ ] AC46: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、結末の記録を持たない - [ ] AC47: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る - [ ] AC48: 配布物の同期・定義・frontmatter の 3 つの検査が終了コード 0 で終わる(コマンドは「検証手段」の表) ## 受け入れ条件(他の設計との契約) -- [ ] AC49: `rounds.impl_for_seq` を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの修正担当の 3 つが、差し替えた関数の返す担当になる -- [ ] AC50: 取り消しの本体は `intake.discard_unverified` の 1 つになる。`gitfacts.revert_unverified_range` は無くなり、`apply._revert_unverified_apply_round` は `discard_unverified` を呼ぶ +- [ ] AC49: 輪番から担当を引く関数(`rounds.impl_for_seq`)を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの修正担当の 3 つが、差し替えた関数の返す担当になる +- [ ] AC50: 取り消しの本体は取り込みの共通手順の 1 つ(`intake.discard_unverified`)になる。`gitfacts.revert_unverified_range` は無くなり、`apply._revert_unverified_apply_round` は `discard_unverified` を呼ぶ ## 非機能の条件 @@ -228,7 +241,7 @@ | テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q`(cross-refactoring だけなら `plugins/ndf/skills/cross-refactoring/tests`) | | 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | | 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認 | 次に cross-refactoring を回した実行で、`-apply-r*-progress.log` に作業段階が残るか。担当が結果を残さなかった群の `failed_attempts[].reason` が監視の結果ファイルの `reason` と一致するか | +| 手動確認 | 次に cross-refactoring を回した実行で、進捗の記録(`-apply-r*-progress.log`)に作業段階が残るか。担当が結果を残さなかった群の結末の記録の理由が、監視の結果ファイルの理由と一致するか | ## 前提とする取り決め @@ -271,3 +284,12 @@ | AC34〜AC36 | AC46〜AC48 | 同じ | | AC37 | AC49 | 同じ | | — | AC1、AC3、AC14、AC18、AC26〜AC31、AC50 | 新設(結末の読み取り、起動し直しの可否、最終ゲート、開き直しの判定の一本化、取り消しの本体の一本化) | + +## 関連文書と前後関係 + +| 項目 | 内容 | +| --- | --- | +| 設計 | [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md)。この文書は「何を満たすか」だけを扱う | +| 置き換える既存の要求 | [issue-647-592-553-requirements.md](issue-647-592-553-requirements.md)(PR #665)。**この文書が置き換える。** 対応は「既存の受け入れ条件との対応」にある | +| 親の名前で置く理由 | 親 #728 が根本原因の場所(結果の読み取りの向きと、3 つの取り込みの重複)を定め直したため、受け入れ条件を親の名前で改めて置く | +| 範囲に入れる子 issue | #674(最終ゲートの修正)は #728 の子として範囲に入れる。閉じるのは棚卸に任せる | From 26dafcef5d8e25b0fe94b649348a1ab04d3ceb83 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 09:20:29 +0000 Subject: [PATCH 019/217] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=20PR=20#782?= =?UTF-8?q?=20=E3=81=AE=203=20=E6=96=87=E6=9B=B8=E3=82=92=20markdown-writi?= =?UTF-8?q?ng=20=E3=81=AE=E8=A6=8F=E7=B4=84=E3=81=A7=E6=9B=B8=E3=81=8D?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=80=81=E7=AB=A0=E7=AB=8B=E3=81=A6=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E6=89=8B=E3=81=8C=E5=95=8F=E3=81=86=E9=A0=86?= =?UTF-8?q?=E3=81=B8=E7=B5=84=E3=81=BF=E7=9B=B4=E3=81=99=EF=BC=88#788?= =?UTF-8?q?=E3=80=82#727=20#687=20#478=20#664=20#648=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 決定 20 件と受け入れ条件の節の見出しから識別子を外し、業務用語で「何のために何を決めた」を書く(識別子を含む見出し: 設計 17 → 0、要求 4 → 0、契約 12 → 0) - 設計文書の目的の直後に「用語の対応表」を置き、本文は業務用語で通す。要求の用語表に識別子の列を足す - 章立てを 目的 → 用語 → 機能 → 実測 → 決定 → 構成要素 → 配置 → 流れ → 非機能 → 分け方 → テスト → 未確認 → 申し送り → 対応表 → 関連文書 の順へ並べ替え、文書の位置づけは末尾の「関連文書」へ移す - 契約文書の「入出力の契約」(162 行)を引数・出力・関数の形・骨組みの 4 章に分け、置き場所は見出し直下の 1 行へ移す - 100 字超の文を分け、使える者の解決の手順の列挙を表にする。決定の中身・受け入れ条件の番号と内容・契約の形は変えない Co-Authored-By: Claude Fable 5.1 --- issues/issue-727-687-478-664-648-contracts.md | 106 ++++++--- issues/issue-727-687-478-664-648-design.md | 210 +++++++++++------- .../issue-727-687-478-664-648-requirements.md | 122 +++++----- 3 files changed, 264 insertions(+), 174 deletions(-) diff --git a/issues/issue-727-687-478-664-648-contracts.md b/issues/issue-727-687-478-664-648-contracts.md index a554a0d0..7091869e 100644 --- a/issues/issue-727-687-478-664-648-contracts.md +++ b/issues/issue-727-687-478-664-648-contracts.md @@ -2,19 +2,15 @@ ## 目的 -- **壊れていること**: 状態ファイルは使える者の記録を持たず、`init` は認証の確認を 1 件でも通らないと止まる。再開の `init` は渡された引数を状態へ重ねず、黙って捨てる。cross-refactoring は提案と適用で母集合を 2 つ持つ -- **困る人**: 両 Skill の `init` / `start-round` / `read-result` / 起動スクリプトを実装する人と、状態ファイルを読む `report` / 監視の側 -- **直すと成り立つこと**: 使える者の解決の結果を `participants` の 1 つのオブジェクトが持ち、席の名前・引数・関数の形が両 Skill で同じになる。状態に載る引数は再開で「反映する」か「知らせる」のどちらかに必ず載る。この変更の前に始めた実行の状態ファイルは書き換えずに読める +- **壊れていること**: 状態ファイルは使える者の記録を持たず、初期化は認証の確認を 1 件でも通らないと止まる。再開の初期化は渡された引数を状態へ重ねず、黙って捨てる。cross-refactoring は提案と適用で母集合を 2 つ持つ +- **困る人**: 両 Skill の初期化・ラウンドの開始・結果の受け口・起動スクリプトを実装する人と、状態ファイルを読む完了報告と監視の側 +- **直すと成り立つこと**: 使える者の解決の結果を参加者の記録(`participants`)の 1 つのオブジェクトが持ち、席の名前・引数・関数の形が両 Skill で同じになる。状態に載る引数は再開で「反映する」か「知らせる」のどちらかに必ず載る。この変更の前に始めた実行の状態ファイルは書き換えずに読める -## 文書の位置づけ - -[issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) の続きである。決定の理由は設計文書の「決定の記録」にあり、この文書は形だけを書く。 - -**この文書は既存の契約 [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) の P5 の部分を置き換える。** 既存の 6 項目(`available_reviewers` ほか)は 1 つのオブジェクト `participants` に畳み、cross-refactoring も同じ形を持つ。 +この文書は形だけを書く。決定の理由と用語の対応は設計文書が持つ(末尾の「関連文書」)。形を書く節のため、表と各節の先頭の指し示しには識別子をそのまま置く。 ## データ構造(状態ファイル) -両 Skill の状態ファイルに `participants` と `resume_changes` の 2 項目が増える。cross-refactoring からは `impl_capable` とラウンドの `reviewers` が消える。既存の状態ファイルは書き換えない。 +両 Skill の状態ファイルに、参加者の記録と再開で変えた値の記録(`resume_changes`)の 2 項目が増える。cross-refactoring からは、適用専用の母集合の項目(`impl_capable`)とラウンドのレビュー担当(`reviewers`)が消える。既存の状態ファイルは書き換えない。 ### 両 Skill の最上位に増える 2 項目 @@ -23,7 +19,7 @@ | `participants` | オブジェクト(下の表) | 許さない(項目が無いことは許す) | 使える者の解決の結果。項目が無いのは「この変更の前に始めた実行」 | | `resume_changes` | オブジェクトの配列 | 許す(空の配列) | 再開で変えた値の記録。追記だけを行う | -`participants` の中身: +参加者の記録の中身: | 項目 | 型 | 空を許すか | 意味 | | --- | --- | --- | --- | @@ -36,7 +32,7 @@ | `require_all` | 真偽値 | 許さない | `--require-all` の値。新規の既定は `false` | | `fallback` | 文字列の配列 | 許す(空) | **cross-review だけ。** 席の埋め合わせに使える者(ホストの確認が通れば `[host]`)。`available` が 2 者以上か `only` があれば空 | -`resume_changes[]` の要素は既存の契約と同じ(`at` / `field` / `from` / `to`)。`field` は状態ファイルの鍵で、`participants` を作り直したときは `participants` の 1 件として積む(中の項目ごとには積まない)。 +再開で変えた値の記録の要素は既存の契約と同じ(`at` / `field` / `from` / `to`)。`field` は状態ファイルの鍵である。参加者の記録を作り直したときは、`participants` の 1 件として積む(中の項目ごとには積まない)。 ### 変わらない項目の意味の変化 @@ -95,13 +91,15 @@ erDiagram | F5 再開の反映 | U | U | U | U(追記) | R | | F6 報告 | R | R | R | R | R | -### 時系列の扱い +## 移行と時系列 -`participants` と `runtimes` は上書きし、過去の値は `resume_changes` に事象として積む。ラウンドごとの担当は `rounds[]` が持つため、上書きで失われるのは「どの時点でどの一覧だったか」だけで、それを `resume_changes` が補う。 +**既存の状態ファイルは書き換えない。** 項目が無いときの読み方を決め、過去の値は再開で変えた値の記録に残す。 -### 移行 +### 時系列の扱い + +参加者の記録と参加者の一覧(`runtimes`)は上書きし、過去の値は再開で変えた値の記録に事象として積む。ラウンドごとの担当はラウンドの記録(`rounds[]`)が持つ。上書きで失われるのは「どの時点でどの一覧だったか」だけで、それを再開で変えた値の記録が補う。 -**既存の状態ファイルは書き換えない。** 項目が無いときの読み方を決める。 +### 項目が無いときの読み方 | Skill | 項目が無いとき | 読み方 | | --- | --- | --- | @@ -109,13 +107,15 @@ erDiagram | cross-refactoring | `participants` | `runtimes` から `impl_assign` で輪番を決める。`impl_capable` は読まない | | 両方 | `resume_changes` | 空として読む | -再開で担当に関わる引数を渡したときだけ、`participants` を作り直して書く。渡さない再開では書き足さない。作り直すときの入力は、渡した引数と、渡さなかった引数の状態ファイルの値(`participants.included` / `excluded` / `require_all`、最上位の `only`)である。 +再開で担当に関わる引数を渡したときだけ、参加者の記録を作り直して書く。渡さない再開では書き足さない。作り直すときの入力は 2 つである。渡した引数と、渡さなかった引数の状態ファイルの値(`participants.included` / `excluded` / `require_all`、最上位の `only`)である。 + +## 初期化の引数 -## 入出力の契約 +両 Skill の初期化が受ける引数と、新規・再開それぞれの経路での扱いを書く。 -`init` の引数と出力、`start-round` と `read-result` の受け口、`report` の節、共通層と各 Skill の関数の形を書く。 +### cross-review の初期化の引数 -### `state.py init`(cross-review)の引数 +`state.py init` が受ける。 | 引数 | 型 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | | --- | --- | --- | --- | --- | --- | @@ -131,7 +131,9 @@ erDiagram | `--verify-exit-code N` | 整数。繰り返し可 | `None` | 無ければ空 | 渡せば置き換え | 再開で反映 | | `--worktree` / `--focus` / `--extra-instructions-file` | — | — | — | — | 変わらない | -### `refactor.py init`(cross-refactoring)の引数 +### cross-refactoring の初期化の引数 + +`refactor.py init` が受ける。 | 引数 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | | --- | --- | --- | --- | --- | @@ -139,7 +141,9 @@ erDiagram | `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / `--test-timeout` | `None` | 無ければ現行の既定(3 / 2 / 3 / 5 / 900) | 渡せば反映(`replace`) | 既定を `None` へ | | `--model RT=MODEL` / `--host` / `--scope` / `--baseline-test` / `--ci-check` / `--severity-threshold` / `--sync-command` / `--plan-file` / `--workflow-step` / `--worktree-root` | `None`(`--scope` と `--baseline-test` は必須のまま) | 無ければ現行の既定 | 反映しない。状態と違えば 1 行(`notify`) | 再開での知らせを追加 | -**状態ファイルに載る引数は、`replace` か `notify` のどちらかに必ず載る**(設計文書の決定 13)。載らないのは状態に載らない引数(cross-review の `--worktree` / `--focus` / `--extra-instructions-file`)だけである。 +**状態ファイルに載る引数は、「反映する」(`replace`)か「知らせる」(`notify`)のどちらかに必ず載る**(設計文書の決定 13)。載らないのは状態に載らない引数(cross-review の作業ツリー・観点・追加指示のファイルの 3 つ)だけである。 + +### 名前の検査 **名前の検査は 2 段に分かれる。** @@ -148,9 +152,13 @@ erDiagram | 1 | 綴り(4 つの名前か `none`) | argparse の型 | 2 | | 2 | 母集合との関係(`--exclude` / `--only` に母集合(既定 ∪ `--include`)に無い名前(cross-review のホストはこれに当たる) / `--include` と `--exclude` の重なり / `--only` と `--exclude` の矛盾 / `none` と名前の混在) | 共通層の `resolve_participants`(`AssignmentError`)。`init` が Skill の終了コードへ写す | cross-review 1 / cross-refactoring 4 | -### `init` の出力と終了コード +## 出力の契約 + +初期化・ラウンドの開始・結果の受け口・完了報告の出力を書く。標準出力の形は変えず、増えるのは標準エラーの行と席の名前である。 -標準出力の `KEY=VALUE` は、cross-refactoring から `IMPL_POOL` が消えるほかは変えない。**増えるのは標準エラーの行だけである。** +### 初期化の出力と終了コード + +標準出力の `KEY=VALUE` は、cross-refactoring から適用専用の母集合の変数(`IMPL_POOL`)が消えるほかは変えない。**増えるのは標準エラーの行だけである。** | 場面 | 標準エラーに出るもの | cross-review | cross-refactoring | 状態ファイル | | --- | --- | --- | --- | --- | @@ -165,14 +173,14 @@ erDiagram | 再開で反映しない引数が状態と違う | 引数ごとに 1 行 | 0 | 0 | 変えない | | 再開で作り直した使える者が 0 者 / 欠けあり | 新規と同じ | 1 | 4 | 書き換えない | -### `start-round` の出力 +### ラウンドの開始の出力 | Skill | 変わること | | --- | --- | | cross-review | `REVIEWERS` / `REVIEWERS_CSV` に席の名前が並ぶ(`codex claude-2` のような値を取りうる)。形は変わらない | | cross-refactoring | `REVIEWERS` / `REVIEWERS_CSV` を出さない。`RUNTIMES` / `RUNTIMES_CSV` / `IMPL` / `IMPL_MODEL` は変わらない | -### `read-result` と起動スクリプトの席の受け口(cross-review) +### 結果の受け口と起動スクリプトの席の受け口(cross-review) | 受け口 | 変更前 | 変更後 | | --- | --- | --- | @@ -181,7 +189,7 @@ erDiagram | `critique.sh ` | 同上 | 同上 | | `monitor.py --agents` | 担当名の CSV | 席の名前の CSV。stem を組むだけなので変更は無い | -### `report` の出力(cross-review) +### 完了報告の出力(cross-review) 現行の「PR 履歴」の後に、次の節を足す。 @@ -196,9 +204,15 @@ erDiagram - 再開で変えた値: 2026-09-19T12:00:00 participants … → … ``` -`participants` を持たない状態ファイルでは「使える者: 記録なし」と出す。`probe_skipped` が真のときは「確認を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。cross-refactoring の `report` は現行の「提案・レビュー」と「適用の母集合」の 2 行を「参加者」の 1 行にし、`participants` を持てば同じ節を足す。 +参加者の記録を持たない状態ファイルでは「使える者: 記録なし」と出す。確認を飛ばした印(`probe_skipped`)が真のときは「確認を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。cross-refactoring の完了報告は、現行の「提案・レビュー」と「適用の母集合」の 2 行を「参加者」の 1 行にし、参加者の記録を持てば同じ節を足す。 + +## 関数の形 -### 共通層の関数(`lib/assignment.py`) +共通層の 3 ファイルと、各 Skill の内部関数の入出力を書く。 + +### 共通層の関数: 割り当て + +置き場所は `lib/assignment.py` である。 | 関数 | 入力 | 出力 | 失敗の形 | 変更 | | --- | --- | --- | --- | --- | @@ -211,7 +225,7 @@ erDiagram | `SEAT_PATTERN` | — | `^(claude\|codex\|agy\|kiro)(-[2-9])?$` | — | **新設** | | `impl_pool()` / `review_assign()` / `assign()` | — | — | — | **消す**(P7) | -`review_seats` の規則: +席の埋め方(`review_seats`)の規則: | 使える者の数 | 席 | | ---: | --- | @@ -220,9 +234,11 @@ erDiagram | 1 | その 1 者と、`fallback` のうち `available` に含まれない先頭の者。そのような者が無ければ同じランタイムの 2 つ目(`<名前>-2`) | | 0 | `fallback` の先頭と、その 2 つ目(`<名前>-2`)。`fallback` が空なら `AssignmentError` | -埋め合わせの候補は `available` に含まれない者だけを使い、含まれる者は飛ばす。同じ席の名前を 2 つ返さないための規則である。例は `--include` でホストが使える者に入った cross-review である。`available=["claude"], fallback=["claude"]` は `["claude", "claude-2"]` になる。 +埋め合わせの候補は使える者に含まれない者だけを使い、含まれる者は飛ばす。同じ席の名前を 2 つ返さないための規則である。例は、足す者の指定でホストが使える者に入った cross-review である。使える者がホストだけで埋め合わせもホストなら、席はホストとその 2 つ目(`["claude", "claude-2"]`)になる。 + +### 共通層の関数: 確認 -### 共通層の関数(`lib/auth.py`) +置き場所は `lib/auth.py` である。 | 関数 | 入力 | 出力 | 失敗の形 | 変更 | | --- | --- | --- | --- | --- | @@ -230,16 +246,20 @@ erDiagram | `check_auth(...)` | — | — | — | **消す**(P7。P6 では残る) | | `AUTH_PROBES` / `UNAUTHENTICATED_MARKERS` / `AUTH_PROBE_TIMEOUT` / `SKIP_ENV` | — | — | — | 変わらない | -### 共通層の関数(`lib/statefile.py`) +### 共通層の関数: 再開の反映 + +置き場所は `lib/statefile.py` である。 | 関数 | 入力 | 出力 | 失敗の形 | 変更 | | --- | --- | --- | --- | --- | | `apply_resume_args(state, args, spec)` | 状態、argparse の名前空間、反映の表 | 標準エラーへ出す行の一覧。`state` を書き換え、`resume_changes` に積む。書き込みは呼び出し側が 1 回で行う | 上げない | **新設** | | `ResumeField(arg, key, mode)` | 引数の属性名、状態ファイルの鍵、`replace` / `notify` | — | — | **新設** | -`spec` は Skill ごとの表で、`mode` が `replace` の項目は `None` でない値を状態へ書き、`notify` の項目は状態と違うときだけ「反映しない」の行を返す。値が同じ項目は行を返さず、`resume_changes` にも積まない。 +反映の表(`spec`)は Skill ごとに持つ。「反映する」(`replace`)の項目は未指定でない値を状態へ書く。「知らせる」(`notify`)の項目は、状態と違うときだけ「反映しない」の行を返す。値が同じ項目は行を返さず、再開で変えた値の記録にも積まない。 + +### cross-review の内部関数 -### `state.py` の内部関数(cross-review) +置き場所は `state.py` である。 | 関数 | 契約 | 変更 | | --- | --- | --- | @@ -248,7 +268,9 @@ erDiagram | `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | `apply_resume_args` を呼び、担当に関わる引数があれば、渡さなかった引数を状態ファイルの値で補って `_resolve_reviewers` で作り直す。失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | | `_guard_previous_round(st, prev)` | `prev["reviewers"]`(無ければ `_round_reviewers`)を渡す | 担当を渡す | -### `refactor_lib` の関数(cross-refactoring) +### cross-refactoring の関数 + +置き場所は `refactor_lib` である。 | 関数 | 契約 | 変更 | | --- | --- | --- | @@ -257,7 +279,9 @@ erDiagram | `commands/setup.py cmd_start_round` | `impl_assign(round_no, state["runtimes"])`。`reviewers` を書かない | 担当の決め方を替える | | `commands/apply.py` / `commands/gate.py` の `assign(seq, host)` | `impl_assign(seq, state["runtimes"])` | 呼び方を替える(各 1 行) | -### 手順書の骨組み(cross-review の `SKILL.md` / `docs/01`) +## 手順書の骨組み(cross-review) + +対象は `SKILL.md` と `docs/01` である。初期化へは値のある引数だけを渡し、担当はラウンドの開始が返す一覧を使う。 ```bash INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ @@ -272,4 +296,12 @@ for r in $REVIEWERS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true "$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" $REVIEWERS ``` -**`--max-rounds` と `--rotate-after` も値があるときだけ渡す。** 現行の骨組みは `"$MAX_ROUNDS"` を常に渡すため、再開のたびに利用者が指定していない値で上書きする。 +**ラウンドの上限と交代の間隔(`--max-rounds` / `--rotate-after`)も値があるときだけ渡す。** 現行の骨組みは上限を常に渡すため、再開のたびに利用者が指定していない値で上書きする。 + +## 関連文書 + +| 文書 | 何を持つか | +| --- | --- | +| [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) | 決定の理由(「決定の記録」)と用語の対応表。この文書はその続きである | +| [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) | 受け入れ条件 AC1〜AC50 | +| [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) | 既存の契約(PR #667)。この文書はその P5 の部分を置き換える。既存の 6 項目(`available_reviewers` ほか)は参加者の記録の 1 つのオブジェクトに畳み、cross-refactoring も同じ形を持つ | diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md index b4640c3c..fcf39fe3 100644 --- a/issues/issue-727-687-478-664-648-design.md +++ b/issues/issue-727-687-478-664-648-design.md @@ -2,17 +2,47 @@ ## 目的 -- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の `init` が止まり、収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを `--only` などの引数を変えて再開しても、引数が黙って無視される(#648) +- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の初期化が止まる。収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを 1 者指定などの引数を変えて再開しても、引数が黙って無視される(#648) - **困る人**: 4 つの CLI が揃っていない環境で収束ループを回す利用者と、中断したループを進め方を変えて再開する利用者 -- **直すと成り立つこと**: 使える者の決定・席の埋め方・再開の反映の 3 つの規則を、両 Skill が共有する共通層が 1 か所ずつ持つ。両 Skill の `init` は共通層の結果を状態ファイルと終了コードへ写すだけになり、1 者欠けても止まらない。cross-review は毎ラウンド 2 席を確保し、cross-refactoring の既定の参加者は codex / kiro とホストになる +- **直すと成り立つこと**: 使える者の決定・席の埋め方・再開の反映の 3 つの規則を、両 Skill が共有する共通層が 1 か所ずつ持つ。両 Skill の初期化は共通層の結果を状態ファイルと終了コードへ写すだけになり、1 者欠けても止まらない。cross-review は毎ラウンド 2 席を確保し、cross-refactoring の既定の参加者は codex / kiro とホストになる -## 文書の位置づけ +この文書は「どう作るか」を扱う。実装は 2 本の Pull Request(P6 / P7)に分ける(決定 19)。要求・契約・既存の設計との関係は末尾の「関連文書」にある。 -要求と受け入れ条件は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) にある。この文書は「どう作るか」だけを扱う。状態ファイル・引数・関数の形は [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) にある。 +## 用語の対応表 -**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P5(決定 6〜19)を置き換える。** 引き継ぐ決定と変える決定は末尾の「既存の設計との対応」にある。P4(決定 2〜5)は #732 の設計が持つ。 +本文は左の業務用語で書く。識別子は表・コードブロック・「置き場所」と、業務用語の初出の括弧書きにだけ置く。 -**実装は 2 本の Pull Request に分ける**(決定 19)。分け方は「実装の分け方」にある。 +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 初期化 | `init`(cross-review は `state.py init`、cross-refactoring は `refactor.py init`) | 収束ループを新規に始める・再開する副コマンド | +| ラウンドの開始 | `start-round` | 次のラウンドを開き、担当を決めて返す副コマンド | +| 結果の受け口 | `read-result` | 担当の結果ファイルを状態ファイルへ写す副コマンド | +| 完了報告 | `report` | 収束の結果を出す副コマンド | +| 使える者の解決 | `resolve_participants`(`lib/assignment.py`) | 母集合・足す者・外す者・確認の結果から使える者を決める関数。結果は参加者の記録 | +| 止めない確認 | `probe_auth`(`lib/auth.py`) | 認証の確認コマンドを走らせ、止めずに結果だけを返す関数 | +| 従来の確認 | `check_auth` | 1 件の失敗で止める確認。P7 で消す | +| 母集合の既定 | `review_pool(host)` / `refactor_pool(host)` | Skill ごとの参加者の出発点を返す関数 | +| 既定の参加者の表 | `DEFAULT_REFACTOR_RUNTIMES` | cross-refactoring の既定(codex / kiro) | +| ランタイムの固定の順 | `ALL_RUNTIMES` | claude / codex / agy / kiro の順 | +| 席の埋め方 | `review_seats` | 使える者と埋め合わせから 2 席を返す関数。従来の `review_assign` を置き換える | +| 適用の輪番 | `impl_assign` | 参加者から実装担当 1 者を返す関数。従来の `assign` を置き換える | +| 適用専用の母集合 | `impl_pool()` / `IMPL_POOL` / `impl_capable` | 関数・初期化の出力変数・状態ファイルの項目。3 つとも消す | +| 席の名前の解釈 | `seat_runtime` / `SEAT_PATTERN` | 席の名前からランタイムを引く関数と、形の検査 | +| 再開の反映 | `apply_resume_args` / `ResumeField`(`lib/statefile.py`) | 再開で渡した引数を表に従って状態へ重ねる関数と、表の 1 行 | +| 足す者 / 外す者 | `--include` / `--exclude` | 参加者を名指しで足す・外す引数 | +| 1 者指定 | `--only`(シェル変数 `$ONLY`) | 担当を 1 者に固定する引数 | +| 全員を要する指定 | `--require-all` | 確認の失敗が 1 者でもあれば止める引数 | +| 指定を外す値 | `none` | 再開で 1 者指定・足す者・外す者を空へ戻す予約語 | +| 参加者の記録 | `participants`(`available` / `unavailable` / `fallback` ほか) | 状態ファイルの項目。使える者の解決の結果 | +| 埋め合わせ | `fallback` | 席が足りないときに使う者(ホスト) | +| 参加者の一覧 | `runtimes` | cross-refactoring の状態ファイルの項目。提案の対象と適用の輪番が読む | +| 再開で変えた値の記録 | `resume_changes` | 状態ファイルの項目。追記だけを行う | +| 担当の一覧の出力 | `REVIEWERS` / `REVIEWERS_CSV` | ラウンドの開始が返すシェル変数 | +| ラウンドの担当の記録 | `rounds[].reviewers` / `rounds[].impl` | ラウンドごとの席(cross-review)と実装担当(cross-refactoring) | +| 割り当ての失敗 | `AssignmentError` | 共通層が名前の矛盾と欠けを返す例外 | +| 担当の読み出し / 前ラウンドの検査 | `_round_reviewers` / `_guard_previous_round` | cross-review の内部関数 | +| P6 / P7 | — | 実装の Pull Request の 1 本目(共通層と cross-review)と 2 本目(cross-refactoring と旧関数の削除) | +| G2〜G5 | — | 並行して進む他の設計の束(#732 / #729 / #728 / #730) | ## 機能一覧 @@ -22,11 +52,30 @@ | --- | --- | --- | --- | | F1 | 確認を通らない者を外し、使える者で収束ループを始める。使えない者と理由を残す | CLI の一部が導入・認証されていない利用者 | P6 / P7 | | F2 | cross-review の各ラウンドに 2 席を確保する(使える者 → ホスト → 同じランタイムの 2 つ目) | 使える者が 2 者に満たない利用者 | P6 | -| F3 | `--exclude` / `--include` で参加者を名指しで外す・足す | 打ち切り・利用上限が分かっている担当を避けたい利用者、agy を戻したい利用者 | P6 / P7 | +| F3 | 外す者・足す者の指定で参加者を名指しで外す・足す | 打ち切り・利用上限が分かっている担当を避けたい利用者、agy を戻したい利用者 | P6 / P7 | | F4 | cross-refactoring の既定の参加者を codex / kiro / ホストにし、適用の輪番をその中で回す | cross-refactoring の利用者 | P7 | | F5 | 再開で明示的に渡した引数を反映し、反映しない引数を知らせる | 中断したループを進め方を変えて再開する利用者 | P6 / P7 | | F6 | 完了報告に参加者・外した者・足した者・確認を通らなかった者・席の埋め合わせ・再開で変えた値を出す | 収束の結果を読む人 | P6 / P7 | +## 実測 + +決定の根拠になる値である。`develop` のコミット 9eaebe14、Python 3.14 で、割り当ての部品(`assignment.py`)を読み込んで式を突き合わせた。 + +| 場面 | 結果 | +| --- | --- | +| 席の式 `{pool[r % n], pool[(r + 1) % n]}` を母集合の順に並べる(n = 3) | 4 ホスト × ラウンド 1〜12 の全組で、変更前の `review_assign(r, host)` と一致(不一致 0 件) | +| 同じ式で n = 4 | ラウンド 1〜4 の席は `codex agy` / `agy kiro` / `claude kiro` / `claude codex`。各者ちょうど 2 回 | +| n = 2(`codex` / `kiro`) | ラウンド 1〜4 すべて `codex kiro` | +| n = 1 / n = 0 | `codex claude`(ホストあり)/ `codex codex-2`(ホストなし)/ `claude claude-2`(0 者・ホストあり) | +| `impl_assign` を `participants[r % n]` で `["claude", "codex", "kiro"]` に当てる | ラウンド 1〜6 で `codex` / `kiro` / `claude` / `codex` / `kiro` / `claude`。変更前の `assign(r, "claude")` は `codex` / `agy` / `kiro` / `claude` / … | +| 席の形 `^(claude\|codex\|agy\|kiro)(-[2-9])?$` | `kiro` / `kiro-2` / `claude-9` が一致し、`kiro-1` / `kiro-10` / `gemini` / `kiro-2-3` は一致しない | +| stem の逆解析 | `split("-")` / `rsplit` / `.stem` / `re.match(.*agent` を cross-review のスクリプトと `monitor.py` で検索し、担当名へ戻す箇所は 0 件(当たった 1 件は PR のファイル種別の判定) | +| cross-refactoring の `$REVIEWERS` の読み手 | `SKILL.md` / `docs/` / `prompts/` で `reviewer` が 0 件。`setup.py` が出すだけで、読むのはテスト `test_start_round_emits_runtimes.py` の期待値だけ | +| cross-refactoring の再開で反映される引数 | `init` の 15 引数のうち 0 個(`_apply_post_event` の投稿の扱いだけを毎回入れ直す) | +| 既存のテストの数 | `uv run --with pytest pytest scripts/tests plugins/ndf -q --co` で 4413 件 | + +担当名を鍵・分岐・選択肢に使う箇所の一覧(10 か所の受け口を含む)は、調査の控えから「構成要素」の表へ写した。控え(`survey-727-agent-keys.md`)は設計 Pull Request のレビューの間だけ scratchpad に置く。 + ## 決定の記録 20 件を 6 つの塊に分ける。 @@ -42,60 +91,62 @@ ### 決定 1: 通過済みの決定と新しい決定を差分で読み分けるために、設計文書は親 #727 の名前で新設し、既存の本体は書き換えない -既存の設計は P4 と P5 を 1 つの文書で扱い、P4 は #732 が別に進める。P5 の節をその場で書き換えると、#732 が読む P4 の決定と、この変更で変わる P5 の決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの `## 決定の記録` の見出しをすべて写す。そのため 1 行でも触ると既存の 19 件がこの Pull Request の決定として並ぶ。案内は `## 決定の記録` を持たない要求と契約の文書にだけ足す(#729 の設計と同じ扱い)。 +既存の設計は P4 と P5 を 1 つの文書で扱い、P4 は #732 が別に進める。P5 の節をその場で書き換えると、#732 が読む P4 の決定と、この変更で変わる P5 の決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの「決定の記録」の見出しをすべて写す。そのため 1 行でも触ると既存の 19 件がこの Pull Request の決定として並ぶ。案内は「決定の記録」を持たない要求と契約の文書にだけ足す(#729 の設計と同じ扱い)。 -### 決定 2: 使える者を決める規則を 2 か所に持たないために、決定を共通層 `resolve_participants` に移し、両 Skill の `init` は終了コードへ写すだけにする +### 決定 2: 使える者を決める規則を 2 か所に持たないために、決定を共通層の 1 つの関数へ移し、両 Skill の初期化は結果を終了コードへ写すだけにする -いまは cross-review の `_auth_targets` / `_validate_only` と cross-refactoring の `cmd_init` が、それぞれ母集合を作る。どちらも `check_auth` を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 の `move_responsibility`)。**共通層に `resolve_participants` を 1 つ置く。** 入力は母集合・ホスト・`--include` / `--exclude` / `--only`・確認・`--require-all` で、`Participants` を返す。 `--exclude` と `--only` の名前が母集合に含まれるかの検査も、ホストを受け取るこの関数が持つ。cross-review ではホストが母集合に無いため、`--exclude <ホスト>` はここで弾かれる。cross-refactoring ではホストが母集合にあるため外せる。Skill が持つのは 2 つだけである。その値を状態ファイルへ書くことと、`AssignmentError` を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写すことである。 +いまは cross-review と cross-refactoring の初期化が、それぞれ母集合を作る。前者は `_auth_targets` / `_validate_only`、後者は `cmd_init` が持つ。どちらも従来の確認を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 が採る手「責務の移動」)。**共通層に使える者の解決(`resolve_participants`)を 1 つ置く。** 入力は母集合・ホスト・足す者・外す者・1 者指定・確認・全員を要するかで、参加者の記録を返す。外す者と 1 者指定の名前が母集合に含まれるかの検査も、ホストを受け取るこの関数が持つ。cross-review ではホストが母集合に無いため、ホストを外す指定はここで弾かれる。cross-refactoring ではホストが母集合にあるため外せる。Skill が持つのは 2 つだけである。返った値を状態ファイルへ書くことと、割り当ての失敗を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写すことである。 -既存の設計の決定 10(cross-refactoring は変えず、`check_auth` を残す)は採らない。cross-review だけを直すと、同じ層を使う cross-refactoring に「1 者欠けると `init` ごと失敗する」形が残る(#664)。 +既存の設計の決定 10(cross-refactoring は変えず、従来の確認を残す)は採らない。cross-review だけを直すと、同じ層を使う cross-refactoring に「1 者欠けると初期化ごと失敗する」形が残る(#664)。 -### 決定 3: 1 者の失敗で全体を止めずに使える者を残すために、確認は止めない `probe_auth` に 1 本化し、確認コマンドは変えない +### 決定 3: 1 者の失敗で全体を止めずに使える者を残すために、使えるかの確認は止めない 1 つの関数に寄せ、確認コマンドは変えない -`check_auth` は確認と中断を 1 つの関数が持つため、「使える者で回す」経路から呼べない。`probe_auth` は結果だけを返し、止めるかどうかは `resolve_participants` の `require_all` が決める。**両 Skill が `probe_auth` へ移った時点で `check_auth` は呼び手を失うため消す**(P7)。確認コマンド(`AUTH_PROBES`)と未認証の文言は変えない。モデルを引く最小の呼び出しへ替える判断は所要の実測が要るため #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口と、通らなかった理由を `unavailable` に残す形である。 +従来の確認(`check_auth`)は確認と中断を 1 つの関数が持つため、「使える者で回す」経路から呼べない。止めない確認(`probe_auth`)は結果だけを返し、止めるかどうかは使える者の解決の「全員を要するか」が決める。**両 Skill が止めない確認へ移った時点で従来の確認は呼び手を失うため消す**(P7)。確認コマンド(`AUTH_PROBES`)と未認証の文言は変えない。言語モデルを引く最小の呼び出しへ替える判断は所要の実測が要るため #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口と、通らなかった理由を参加者の記録に残す形である。 -### 決定 4: ホストが変わっても一覧を書き直さずに済むように、参加者は「母集合の既定 + `--include` − `--exclude`」で決め、既定は Skill ごとの関数が持つ +### 決定 4: ホストが変わっても一覧を書き直さずに済むように、参加者は「母集合の既定に足す者を加え、外す者を除く」で決め、既定は Skill ごとの関数が持つ -外したい理由は「この担当が落ちる」であり、名指しするのは外す側である(`--exclude`)。戻したい理由は「既定から外れている者を入れたい」で、これも名指しである(`--include`)。使う側を並べる `--reviewers codex,kiro` の形は、ホストが変わると一覧を書き直すことになるため採らない。**既定は Skill ごとの関数が持ち、`resolve_participants` はどちらを渡されても同じ規則で解決する。** +外したい理由は「この担当が落ちる」であり、名指しするのは外す側である(外す者 `--exclude`)。戻したい理由は「既定から外れている者を入れたい」で、これも名指しである(足す者 `--include`)。使う側を並べる形(`--reviewers codex,kiro`)は、ホストが変わると一覧を書き直すことになるため採らない。**既定は Skill ごとの関数が持ち、使える者の解決はどちらを渡されても同じ規則で解決する。** | Skill | 既定を返す関数 | 中身 | | --- | --- | --- | | cross-review | `review_pool(host)` | 全ランタイム − ホスト | -| cross-refactoring | `refactor_pool(host)` | `ALL_RUNTIMES` の順で、`DEFAULT_REFACTOR_RUNTIMES`(`("codex", "kiro")`)とホスト | +| cross-refactoring | `refactor_pool(host)` | ランタイムの固定の順で、既定の参加者の表(codex / kiro)とホスト | -cross-review の `--include` はホストを母集合へ入れる用途になる(#687 の「ホストの参加を許す」の明示的な形)。 +cross-review の足す者は、ホストを母集合へ入れる用途になる(#687 の「ホストの参加を許す」の明示的な形)。 -### 決定 5: 提案と適用を同じ者で回すために、cross-refactoring の母集合を 1 つにし、`impl_capable` / `IMPL_POOL` / `impl_pool()` を消す +### 決定 5: 提案と適用を同じ者で回すために、cross-refactoring の母集合を 1 つにし、適用専用の母集合を消す -既定の参加者を codex / kiro / ホスト(表 `DEFAULT_REFACTOR_RUNTIMES` とホスト)にすると、提案の母集合と適用の母集合が同じ集合になる。`impl_pool()` を関数として残した理由(「両者は一致しない」)が消えるため、関数・状態ファイルの項目・`init` の出力変数の 3 つを消す。`runtimes` は提案の取り込みと `prepare-worktrees.sh` が読むため残し、適用の輪番も同じ値を読む。 +既定の参加者を codex / kiro / ホスト(既定の参加者の表とホスト)にすると、提案の母集合と適用の母集合が同じ集合になる。適用専用の母集合を関数として残した理由(「両者は一致しない」)が消える。そのため関数・状態ファイルの項目・初期化の出力変数の 3 つ(`impl_pool()` / `impl_capable` / `IMPL_POOL`)を消す。参加者の一覧(`runtimes`)は提案の取り込みと作業ツリーの準備(`prepare-worktrees.sh`)が読むため残し、適用の輪番も同じ値を読む。 -agy を既定から外す理由は #664 の実測にある。CLI の起動 199 回のうち失敗は agy の 7 回(STALLED 4 / NO_RESULT 3)だけで、提案の所要の中央値も agy が最も長い(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で決まっていた。戻す手段は `--include agy` である。 +agy を既定から外す理由は #664 の実測にある。CLI の起動 199 回のうち失敗は agy の 7 回(停止 4 / 結果なし 3)だけで、提案の所要の中央値も agy が最も長い(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で決まっていた。戻す手段は足す者の指定(`--include agy`)である。 -### 決定 6: 存在しない役の記録を読み手に見せないために、cross-refactoring のレビュー担当を消し、`assign()` を `impl_assign()` に置き換える +### 決定 6: 存在しない役の記録を読み手に見せないために、cross-refactoring のレビュー担当を消し、担当の割り当ては実装担当 1 者だけを返す -cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-review` が担う。`assign()` が返すレビュー担当が流れる先は 3 つだけである。 +cross-refactoring のレビュー工程は #436 で消え、Step 7 の cross-review が担う。従来の割り当て(`assign()`)が返すレビュー担当が流れる先は 3 つだけである。 | 流れる先 | 読む側 | | --- | --- | | ラウンドの記録(`reviewers` / `reviewer_models`) | 改修計画と報告の表示 | -| `start-round` の `REVIEWERS` / `REVIEWERS_CSV` | 無い(骨組み・文書・プロンプトで `grep -rn -i reviewer` が 0 件) | +| ラウンドの開始の出力(`REVIEWERS` / `REVIEWERS_CSV`) | 無い(骨組み・文書・プロンプトで `grep -rn -i reviewer` が 0 件) | | `record_observed_model` のレビュー側の枝 | 無い(呼び出しは `role="impl"` の 1 か所だけ) | -**#664 の「使える者が 2 者だとレビュー担当が 1 者になる」は、存在しない役の記録の話である。** 役を消せば、席を埋める規則を cross-refactoring に持ち込む必要が無い。`impl_assign(round_no, participants)` は実装担当 1 者だけを返す。 +**#664 の「使える者が 2 者だとレビュー担当が 1 者になる」は、存在しない役の記録の話である。** 役を消せば、席を埋める規則を cross-refactoring に持ち込む必要が無い。適用の輪番(`impl_assign`)は実装担当 1 者だけを返す。 レビュー担当を「記録のためだけに」残す案は採らない。読み手が「このラウンドはこの 2 者がレビューした」と読む。 -### 決定 7: ホストが最初に適用する形にならないように、適用の輪番は `participants[round_no % n]` の式と `ALL_RUNTIMES` の順を保つ +### 決定 7: ホストが最初に適用する形にならないように、適用の輪番は「ラウンド番号を参加者の数で割った余り」の式とランタイムの固定の順を保つ -いまの `assign()` は `pool[round_no % 4]` で、ラウンド 1 が codex から始まる。式を変えずに `n` を参加者の数にすれば、ホスト claude の既定(`["claude", "codex", "kiro"]`)でも codex → kiro → claude の順になる。ホストが最初に適用する形にならない(「実測」)。ラウンド 1 から順に並べる式(`(round_no - 1) % n`)は、ホストが先頭に来るため採らない。 +いまの割り当ては、4 者の固定の順をラウンド番号で割った余りで引く(`pool[round_no % 4]`)。ラウンド 1 が codex から始まる。式を変えずに除数を参加者の数にすれば、ホスト claude の既定(claude / codex / kiro)でも codex → kiro → claude の順になる。ホストが最初に適用する形にならない(「実測」)。ラウンド 1 から順に並べる式(`(round_no - 1) % n`)は、ホストが先頭に来るため採らない。 ### 決定 8: 適用ラウンドの群の分け方を変えないために、提案者と適用者が同じランタイムになることを避けない -ホストが提案に入るため、ある項目の提案者と適用者が同じランタイムになりうる。適用ラウンドは複数の提案者の項目を 1 つの群にまとめるため、群ごとに提案者を避けると群の分け方そのものを変えることになる。**避けない。** 適用の結果は検証(テスト)と Step 7 の `cross-review` が見る。#687 の「コードを書いたランタイムが担当に入ってよい」と同じ判断である。 +ホストが提案に入るため、ある項目の提案者と適用者が同じランタイムになりうる。適用ラウンドは複数の提案者の項目を 1 つの群にまとめるため、群ごとに提案者を避けると群の分け方そのものを変えることになる。**避けない。** 適用の結果は検証(テスト)と Step 7 の cross-review が見る。#687 の「コードを書いたランタイムが担当に入ってよい」と同じ判断である。 ### 決定 9: 使える者が足りなくても毎ラウンド 2 つの目で見るために、cross-review の席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める -#687 の 4 つの規則のうち「各ラウンドで 2 者」を最優先に置き、残る 3 つを埋める順序として読む。**違うランタイムを先に使う。** 同じモデルの 2 つの文脈より、違うモデルの 2 つの文脈のほうが観点が分かれる。ホストは既定の母集合に無いため、使える者が 1 者のときだけ席に入る。同じランタイムの 2 つ目(`<名前>-2`)は、ホストも使えないときの最後の手段である。**埋め合わせの候補は使える者に含まれない者だけを使う。** `--include` でホストが使える者に入っているとき、ホストを埋め合わせにも使うと同じ席の名前が並ぶ(`["claude", "claude"]`)。含まれる者は飛ばす。それでも 2 席に満たなければ同じランタイムの 2 つ目を充てる。例は `available=["claude"], fallback=["claude"]` → `["claude", "claude-2"]` である。 +#687 の 4 つの規則のうち「各ラウンドで 2 者」を最優先に置き、残る 3 つを埋める順序として読む。**違うランタイムを先に使う。** 同じ言語モデルの 2 つの文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる。ホストは既定の母集合に無いため、使える者が 1 者のときだけ席に入る。同じランタイムの 2 つ目(`<名前>-2`)は、ホストも使えないときの最後の手段である。 + +**埋め合わせの候補は使える者に含まれない者だけを使う。** 足す者でホストが使える者に入っているとき、ホストを埋め合わせにも使うと同じ席の名前が並ぶ。含まれる者は飛ばす。それでも 2 席に満たなければ同じランタイムの 2 つ目を充てる。使える者がホストだけで埋め合わせもホストなら、席はホストとその 2 つ目(`claude` / `claude-2`)になる。 | 使える者の数 | 席 | | ---: | --- | @@ -104,31 +155,31 @@ cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-re | 1 | その 1 者とホスト。ホストが使えないか、その 1 者と同じなら同じランタイムの 2 つ目 | | 0 | ホストとその 2 つ目。ホストも使えなければ失敗 | -`--only` は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。ホストの確認も行わない。使える者が 2 者のとき輪番で 1 者を外す案は、毎ラウンド 1 席になるため採らない。 +1 者指定は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。ホストの確認も行わない。使える者が 2 者のとき輪番で 1 者を外す案は、毎ラウンド 1 席になるため採らない。 -### 決定 10: 同じランタイムの 2 つ目を結果ファイルと状態ファイルで区別するために、担当の単位を「席の名前」にし、形は `<ランタイム>` か `<ランタイム>-<2〜9>` とする +### 決定 10: 同じランタイムの 2 つ目を結果ファイルと状態ファイルで区別するために、担当の単位を「席の名前」にし、形はランタイム名か「ランタイム名に 2〜9 の接尾辞」とする -同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。**1 つ目の席の名前はランタイム名そのままにする。** 埋め合わせが要らない実行では、いまと同じ名前しか現れない。接尾辞の区切りは `-` で、ランタイム名に `-` を含むものが無いため、シェルの `${SEAT%%-*}` と Python の `seat_runtime` が同じ規則になる。stem を逆に解析して担当名へ戻す箇所は無い(「実測」)ため、stem 側の変更は無い。 +同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。**1 つ目の席の名前はランタイム名そのままにする。** 埋め合わせが要らない実行では、いまと同じ名前しか現れない。接尾辞の区切りはハイフンで、ランタイム名にハイフンを含むものが無いため、シェル側の切り出し(`${SEAT%%-*}`)と席の名前の解釈(`seat_runtime`)が同じ規則になる。stem を逆に解析して担当名へ戻す箇所は無い(「実測」)ため、stem 側の変更は無い。 -受け口は 10 か所で、いずれも「CLI を選ぶ分岐に `seat_runtime` を通す」か「`choices` を席の形の検査に替える」のどちらかである(「構成要素」の表)。`--only` と `--host` はランタイム名のままで、席の名前を取らない。 +受け口は 10 か所で、いずれも「CLI を選ぶ分岐に席の名前の解釈を通す」か「選択肢の検査を席の形の検査に替える」のどちらかである(「構成要素」の表)。1 者指定とホストの引数はランタイム名のままで、席の名前を取らない。 -### 決定 11: 再開で `only` を変えても過去のラウンドの担当が変わらないように、担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む +### 決定 11: 再開で 1 者指定を変えても過去のラウンドの担当が変わらないように、担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む ```text ラウンドの reviewers → only → participants の席 → host の輪番(review_pool)→ codex / agy ``` -既存の決定 12 と同じ理由である。再開で `only` を変えられるようにすると、`only` を先に見る現行の順では過去のラウンドの担当まで変わる。`_guard_previous_round` も `prev["reviewers"]` を渡す。現行は担当を渡さず `codex` / `agy` で数えるため、担当が `agy` + `kiro` のラウンドで `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 +既存の決定 12 と同じ理由である。再開で 1 者指定を変えられるようにすると、1 者指定を先に見る現行の順では過去のラウンドの担当まで変わる。前ラウンドの検査にも、そのラウンドの記録の担当を渡す。現行は担当を渡さず codex / agy で数えるため、担当が agy + kiro のラウンドで codex を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 -### 決定 12: 全員が揃わないなら始めたくない運用のために、`--require-all` で従来の関門を選べるようにする +### 決定 12: 全員が揃わないなら始めたくない運用のために、全員を要する指定で従来の関門を選べるようにする -全員が揃わないなら始めたくない運用のために残す(既存の決定 9)。付けると、確認を通らない者が 1 者でもいれば従来の文言で失敗する。`--exclude` で外した者は揃っていなくてよい。両 Skill に同じ引数を置く。 +全員を要する指定(`--require-all`)は、全員が揃わないなら始めたくない運用のために残す(既存の決定 9)。付けると、確認を通らない者が 1 者でもいれば従来の文言で失敗する。外す者で外した者は揃っていなくてよい。両 Skill に同じ引数を置く。 -### 決定 13: 黙って捨てる引数を残さないために、再開の反映を共通層 `apply_resume_args` に置き、Skill ごとの表で「反映する」「知らせる」を決める +### 決定 13: 黙って捨てる引数を残さないために、再開の反映を共通層の 1 つの関数に置き、Skill ごとの表で「反映する」「知らせる」を決める -#648 の修正レイヤーは `statefile.py` である。cross-review の `_resume_from_state` だけを直すと、cross-refactoring の再開経路(`args` 由来の項目を 1 つも反映しない)が残る。**共通層は「`None` でない引数を状態へ書き、`resume_changes` に積み、反映しない引数は状態と違うときだけ知らせる」だけを持ち、どの引数がどちらかは Skill ごとの表が持つ。** +#648 の修正レイヤーは状態ファイルの部品(`statefile.py`)である。cross-review の再開の経路(`_resume_from_state`)だけを直すと、cross-refactoring の再開の経路(引数由来の項目を 1 つも反映しない)が残る。**共通層の再開の反映(`apply_resume_args`)が持つのは 1 つの規則だけである。値のある引数を状態へ書き、再開で変えた値の記録に積み、反映しない引数は状態と違うときだけ知らせる。どの引数がどちらかは Skill ごとの表が持つ。** -**状態ファイルに載る引数は、表のどちらかに必ず載る。** 載らない引数は状態に載らないもの(`--worktree` / `--focus` / `--extra-instructions-file`)だけである。黙って捨てる引数を残さないためで、`--baseline-test` のように再開でも渡す必須の引数も「違えば知らせる」に載る。そのため状態に載る引数の既定はすべて `None` にし、新規の経路が定数の既定へ置き換える。既定値と同じ値なら渡していないとみなす案は、`--max-rounds 12` で 20 から 12 へ戻す指定を区別できないため採らない。 +**状態ファイルに載る引数は、表のどちらかに必ず載る。** 載らない引数は状態に載らないもの(作業ツリー・観点・追加指示のファイルの 3 つ)だけである。黙って捨てる引数を残さないためで、基準テストの引数のように再開でも渡す必須の引数も「違えば知らせる」に載る。そのため状態に載る引数の既定はすべて未指定(`None`)にし、新規の経路が定数の既定へ置き換える。既定値と同じ値なら渡していないとみなす案は、上限 12 の指定で 20 から 12 へ戻す操作を区別できないため採らない。 「知らせる」に置く引数は 2 種類ある。 @@ -139,50 +190,31 @@ cross-refactoring のレビュー工程は #436 で消え、Step 7 の `cross-re ### 決定 14: 途中で担当が入れ替わって前のラウンドと突き合わせられなくならないように、担当に関わる引数を渡した再開でだけ、確認し直して参加者を作り直す -`--only` / `--exclude` / `--include` / `--require-all` のいずれかを渡した再開では、決定 2 と同じ手順で参加者を作り直す。**渡さなかった引数は状態ファイルの値で補う。** `--include claude` で始めた実行へ `--exclude agy` だけを渡した再開では、`included` は `["claude"]` のまま残る。`excluded` だけが `["agy"]` になる。渡さなかった引数を初期値へ戻すと、「明示した引数だけを反映する」(決定 13)が破れる。いずれも渡さない再開では確かめ直さない(途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる)。作り直した結果が失敗(0 者、`--require-all` で欠け)なら、状態ファイルを書き換えずに終了コードで終わる。反映は再開の後に開くラウンドから効く(要求の前提 3)。 +1 者指定・外す者・足す者・全員を要する指定のいずれかを渡した再開では、決定 2 と同じ手順で参加者を作り直す。**渡さなかった引数は状態ファイルの値で補う。** ホストを足して始めた実行へ agy を外す指定だけを渡した再開では、足した者の記録はホストのまま残り、外した者だけが agy になる。渡さなかった引数を初期値へ戻すと、「明示した引数だけを反映する」(決定 13)が破れる。いずれも渡さない再開では確かめ直さない。途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなるためである。作り直した結果が失敗(0 者、全員を要する指定で欠け)なら、状態ファイルを書き換えずに終了コードで終わる。反映は再開の後に開くラウンドから効く(要求の前提 3)。 -### 決定 15: 骨組みが空文字列を渡さない形でも指定を外せるように、再開で指定を外す値は `none` にする +### 決定 15: 骨組みが空文字列を渡さない形でも指定を外せるように、再開で指定を外す値は「無し」を表す予約語にする -`--only none` は `only` を `null` へ、`--exclude none` / `--include none` は一覧を空へ戻す。空文字列は骨組みの `${ONLY:+--only "$ONLY"}` が渡さないため、外す手段にならない。新規の経路で `none` を渡すと、渡さないのと同じになる。 +予約語は `none` である。1 者指定に渡すと `null` へ、外す者・足す者に渡すと一覧を空へ戻す。空文字列は、骨組みが値のあるときだけ引数を渡す形(`${ONLY:+--only "$ONLY"}`)のため、外す手段にならない。新規の経路で予約語を渡すと、渡さないのと同じになる。 -### 決定 16: 途中から誰を外したかを完了報告で読めるように、再開で変えた値は `resume_changes` に積み、参加者の作り直しは 1 件として積む +### 決定 16: 途中から誰を外したかを完了報告で読めるように、再開で変えた値は変更の記録に積み、参加者の作り直しは 1 件として積む -上書きだけでは、どの時点で何を変えたかが失われ、完了報告で「途中から agy を外した」ことが読めない。`participants` の中の項目ごとに積むと 1 回の再開で最大 7 件になり、報告で読みにくい。`participants` 全体の前後を 1 件に積む。 +上書きだけでは、どの時点で何を変えたかが失われ、完了報告で「途中から agy を外した」ことが読めない。参加者の記録の中の項目ごとに積むと 1 回の再開で最大 7 件になり、報告で読みにくい。参加者の記録全体の前後を、再開で変えた値の記録(`resume_changes`)に 1 件として積む。 -### 決定 17: 状態ファイルとシェル変数がずれても起動と監視が誰かに当たるように、骨組みは `$ONLY` で絞らず、`start-round` が返す席を使う +### 決定 17: 状態ファイルとシェル変数がずれても起動と監視が誰かに当たるように、骨組みは 1 者指定のシェル変数で絞らず、ラウンドの開始が返す席を使う -`start-round` の `REVIEWERS` は `only` と席の埋め合わせを反映済みである。シェル変数でもう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も誰にも当たらない(#648 の 3 段の経過)。`SKILL.md` と `docs/01` の Step 2 と Step 2.5 を `$REVIEWERS` / `$REVIEWERS_CSV` に揃える。`$ONLY` は `init` へ渡す 1 行にだけ残す(既存の決定 18)。 +ラウンドの開始が返す担当の一覧(`REVIEWERS`)は、1 者指定と席の埋め合わせを反映済みである。シェル変数でもう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も誰にも当たらない(#648 の 3 段の経過)。手順書(`SKILL.md` / `docs/01`)の Step 2 と Step 2.5 を担当の一覧(`$REVIEWERS` / `$REVIEWERS_CSV`)に揃える。1 者指定のシェル変数(`$ONLY`)は初期化へ渡す 1 行にだけ残す(既存の決定 18)。 ### 決定 18: 一時的な打ち切りで担当が恒久的に外れないように、起動した後に分かる使えなさで担当を自動的に外す仕組みは作らない -利用上限やモデルの 404 は起動した後に分かり、分類は #729(G3)が持つ。自動で外すと、一時的な打ち切りでも以後のラウンドから恒久的に外れる。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る(既存の決定 19)。 +利用上限や言語モデルの 404 は起動した後に分かり、分類は #729(G3)が持つ。自動で外すと、一時的な打ち切りでも以後のラウンドから恒久的に外れる。この変更は利用者が外す者の指定で外し、再開で反映できる入口までを作る(既存の決定 19)。 ### 決定 19: P6 で cross-refactoring を壊さないために、P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削除)の順で出す -共通層の新しい関数は P6 で入れ、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)は P7 で消す。P6 の時点で旧関数を消すと cross-refactoring が壊れる。1 本にまとめると `state.py` と `refactor_lib` と文書 3 種を 1 度にレビューすることになる。G2 / G3 / G5(`state.py`)と G4(`refactor_lib`)との競合も 1 度に解くことになる。**「片方にだけ古い形が残らない」(親 #727 の完了条件)は P7 のマージで満たす。** - -### 決定 20: 次に使う人へ規則が届くように、規則の実装は `assignment.py`、手順は各 `SKILL.md`、理由はこの設計文書が持つ +共通層の新しい関数は P6 で入れ、旧関数(従来の確認・適用専用の母集合・従来の席と適用の割り当て)は P7 で消す。P6 の時点で旧関数を消すと cross-refactoring が壊れる。1 本にまとめると、cross-review の状態の部品(`state.py`)と cross-refactoring の部品(`refactor_lib`)と文書 3 種を 1 度にレビューすることになる。G2 / G3 / G5(cross-review 側)と G4(cross-refactoring 側)との競合も 1 度に解くことになる。**「片方にだけ古い形が残らない」(親 #727 の完了条件)は P7 のマージで満たす。** -#687 が問う置き場所である。使える者の数で分岐する実装と席の規則の表は `assignment.py`(`review_seats` の docstring)に 1 つだけ置く。利用者が手順として読む「担当の決まり方と渡す引数」は各 `SKILL.md`(cross-review は `docs/05` が本体)に置く。同じランタイムの 2 つ目とホストの参加を許した理由はこの文書が持ち、`plan-to-spec` が `docs/specifications/` へ移す。`CLAUDE.md` には要約の 2 行(cross-refactoring の母集合と輪番、cross-review の席)だけを置く。 +### 決定 20: 次に使う人へ規則が届くように、規則の実装は共通層の割り当ての部品、手順は各 Skill の手順書、理由はこの設計文書が持つ -## 実測 - -`develop` `9eaebe14`、Python 3.14 で、`assignment.py` を読み込んで式を突き合わせた。 - -| 場面 | 結果 | -| --- | --- | -| 席の式 `{pool[r % n], pool[(r + 1) % n]}` を母集合の順に並べる(n = 3) | 4 ホスト × ラウンド 1〜12 の全組で、変更前の `review_assign(r, host)` と一致(不一致 0 件) | -| 同じ式で n = 4 | ラウンド 1〜4 の席は `codex agy` / `agy kiro` / `claude kiro` / `claude codex`。各者ちょうど 2 回 | -| n = 2(`codex` / `kiro`) | ラウンド 1〜4 すべて `codex kiro` | -| n = 1 / n = 0 | `codex claude`(ホストあり)/ `codex codex-2`(ホストなし)/ `claude claude-2`(0 者・ホストあり) | -| `impl_assign` を `participants[r % n]` で `["claude", "codex", "kiro"]` に当てる | ラウンド 1〜6 で `codex` / `kiro` / `claude` / `codex` / `kiro` / `claude`。変更前の `assign(r, "claude")` は `codex` / `agy` / `kiro` / `claude` / … | -| 席の形 `^(claude\|codex\|agy\|kiro)(-[2-9])?$` | `kiro` / `kiro-2` / `claude-9` が一致し、`kiro-1` / `kiro-10` / `gemini` / `kiro-2-3` は一致しない | -| stem の逆解析 | `split("-")` / `rsplit` / `.stem` / `re.match(.*agent` を cross-review のスクリプトと `monitor.py` で検索し、担当名へ戻す箇所は 0 件(当たった 1 件は PR のファイル種別の判定) | -| cross-refactoring の `$REVIEWERS` の読み手 | `SKILL.md` / `docs/` / `prompts/` で `reviewer` が 0 件。`setup.py` が出すだけで、読むのはテスト `test_start_round_emits_runtimes.py` の期待値だけ | -| cross-refactoring の再開で反映される引数 | `init` の 15 引数のうち 0 個(`_apply_post_event` の投稿の扱いだけを毎回入れ直す) | -| 既存のテストの数 | `uv run --with pytest pytest scripts/tests plugins/ndf -q --co` で 4413 件 | - -担当名を鍵・分岐・選択肢に使う箇所の一覧(10 か所の受け口を含む)は、調査の控えから「構成要素」の表へ写した。控え(`survey-727-agent-keys.md`)は設計 Pull Request のレビューの間だけ scratchpad に置く。 +#687 が問う置き場所である。使える者の数で分岐する実装と席の規則の表は、割り当ての部品(`assignment.py` の `review_seats` の docstring)に 1 つだけ置く。利用者が手順として読む「担当の決まり方と渡す引数」は各 Skill の手順書(`SKILL.md`。cross-review は `docs/05` が本体)に置く。同じランタイムの 2 つ目とホストの参加を許した理由はこの文書が持ち、`plan-to-spec` が `docs/specifications/` へ移す。`CLAUDE.md` には要約の 2 行(cross-refactoring の母集合と輪番、cross-review の席)だけを置く。 ## 構成要素 @@ -238,7 +270,7 @@ graph TD ## 文脈と配置 -**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` / `refactor.py` の 1 プロセスずつである。外部との出入りは `gh`(GitHub)と、各 CLI の確認コマンドと起動だけで、確認コマンドの呼び出し先は変えない。変わるのは起動する CLI の集合(cross-refactoring から agy が既定で外れ、ホストが入る)と、cross-review で同じ CLI を 2 プロセス起動しうることである。 +**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される両 Skill の状態の部品(`state.py` / `refactor.py`)の 1 プロセスずつである。外部との出入りは GitHub の CLI(`gh`)と、各 CLI の確認コマンドと起動だけで、確認コマンドの呼び出し先は変えない。変わるのは 2 つである。起動する CLI の集合(cross-refactoring から agy が既定で外れ、ホストが入る)と、cross-review で同じ CLI を 2 プロセス起動しうることである。 ### 置き場所 @@ -263,17 +295,17 @@ plugins/ndf/ └── CLAUDE.md(リポジトリの根) # P7 ``` -`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。`bash scripts/build-runtime-plugins.sh --check` で食い違いが無いことだけを確かめる。 +Kiro と agy の配布ディレクトリ(`dev.kiro` / `dev.agy`)は Skill の実体を symlink で参照するため、書き写す配布物は無い。配布物の同期の検査(`bash scripts/build-runtime-plugins.sh --check`)で食い違いが無いことだけを確かめる。 ## 構造 -型を足すのは `Participants`(データクラス。契約文書の `participants` の 7 項目)と `ResumeField`(名前付きタプル)の 2 つで、互いに関係を持たず、既存の型とも関係を持たない。クラス図は作らない(要求の「対象範囲」)。**変わるのは処理の順序である。** +型を足すのは 2 つで、互いに関係を持たず、既存の型とも関係を持たない。参加者の記録(`Participants`、データクラス。契約文書の 7 項目)と、反映の表の 1 行(`ResumeField`、名前付きタプル)である。クラス図は作らない(要求の「対象範囲」)。**変わるのは処理の順序である。** ## 処理の流れ -変わるのは新規の `init`・`start-round`・再開の `init` の 3 つの流れと、席の名前が流れる経路である。 +変わるのは新規の初期化・ラウンドの開始・再開の初期化の 3 つの流れと、席の名前が流れる経路である。 -### 新規の `init`(両 Skill で同じ形) +### 新規の初期化(両 Skill で同じ形) ```mermaid graph TD @@ -293,14 +325,16 @@ graph TD 使える者の解決の順序: -1. `--include` と `--exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。`--exclude` の各名前が母集合の既定 ∪ `include` に含まれなければ弾く(cross-review のホストはこれに当たる) -2. 参加者 = 母集合の既定 ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) -3. `--only` があれば参加者に含まれ、`exclude` に無いことを確かめ、参加者を `[only]` にする -4. 参加者の確認を `probe_auth` で行う。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとし `probe_skipped` を真にする -5. `require_all` が真で通らない者がいれば `AssignmentError` -6. 通った者を `available`、通らなかった者と理由を `unavailable` として返す +| 順 | 何をするか | +| ---: | --- | +| 1 | 足す者・外す者の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。外す者の各名前が「母集合の既定 ∪ 足す者」に含まれなければ弾く(cross-review のホストはこれに当たる) | +| 2 | 参加者 = 母集合の既定 ∪ 足す者 − 外す者(`ALL_RUNTIMES` の順) | +| 3 | 1 者指定があれば参加者に含まれ、外す者に無いことを確かめ、参加者をその 1 者にする | +| 4 | 参加者の確認を止めない確認で行う。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとし `probe_skipped` を真にする | +| 5 | 全員を要する指定が真で通らない者がいれば `AssignmentError` | +| 6 | 通った者を `available`、通らなかった者と理由を `unavailable` として返す | -### `start-round`(cross-review) +### ラウンドの開始(cross-review) ```mermaid graph TD @@ -316,7 +350,7 @@ graph TD HO -->|いいえ| L2[codex / agy] ``` -### 再開の `init`(両 Skill で同じ形) +### 再開の初期化(両 Skill で同じ形) ```mermaid graph TD @@ -393,7 +427,7 @@ P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削 | AC45〜AC48 | 各 issue の再現手順を実行し、結果を issue のコメントへ残す | 手元 | | AC49〜AC50 | コマンドの終了コード | 継続的統合と手元 | -`apply_resume_args` の表の規則(`replace` / `notify`、値が同じなら積まない)は `lib/test_lib_resume_args.py`(新設)で Skill に依らず確かめる。 +再開の反映の表の規則(「反映する」「知らせる」、値が同じなら積まない)は、共通層のテスト(`lib/test_lib_resume_args.py`、新設)で Skill に依らず確かめる。 ## 未確認のまま残ること @@ -404,7 +438,7 @@ P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削 | 同じランタイムの 2 席の観点 | `claude` / `claude-2` の 2 席が、別のランタイムの 2 席より指摘を見落とすかは測っていない | この変更の後の運用(`measure.py`) | | ホストが席に入ったときの `is_own_pr` の扱い | ホストの CLI が自分の Pull Request をレビューするとき、投稿の event が `COMMENT` へ倒れる既存の規則で足りるかは確かめていない | P6 の実装で 1 度回して見る | | cross-refactoring でホストが提案に入ることの所要 | 提案は最も遅い者を待つ。claude の提案の所要は測っていない(適用は中央値 2 分) | P7 の後の運用 | -| 確認を「モデルを引く最小の呼び出し」へ替えるか | #461。所要の実測が要る | マイルストーン 06 の着手時 | +| 確認を「言語モデルを引く最小の呼び出し」へ替えるか | #461。所要の実測が要る | マイルストーン 06 の着手時 | | `monitor.py` の CLI 固有の検査を席の名前に通す形 | `seat_runtime` で選ぶと決めたが、`agent` の比較が 6 か所あり、共通の関数へ寄せるかは実装で決める | **実装で決める** | | 出力の文言 | 反映した行・知らせる行・埋め合わせの行の文言は、項目名と値を含むことだけを決めた | **実装で決める** | | テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | @@ -444,3 +478,11 @@ P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削 | 決定 18(`$ONLY` で絞らない) | 決定 17 | 同じ | | 決定 19(自動で外さない) | 決定 18 | 同じ | | — | 決定 1・5・7・8・10・20 | 新設 | + +## 関連文書 + +| 文書 | 何を持つか | +| --- | --- | +| [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC50) | +| [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) | 状態ファイル・引数・関数の形 | +| [issue-624-478-648-design.md](issue-624-478-648-design.md) | 既存の設計(PR #667)。この文書はその P5(決定 6〜19)を置き換える。引き継ぐ決定と変える決定は「既存の設計との対応」にある。P4(決定 2〜5)は #732 の設計が持つ | diff --git a/issues/issue-727-687-478-664-648-requirements.md b/issues/issue-727-687-478-664-648-requirements.md index e68b6e5e..e4c235fd 100644 --- a/issues/issue-727-687-478-664-648-requirements.md +++ b/issues/issue-727-687-478-664-648-requirements.md @@ -2,15 +2,39 @@ ## 目的 -- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の `init` が止まり、収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを `--only` などの引数を変えて再開しても、引数が黙って無視される(#648) +- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の初期化が止まる。収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを 1 者指定などの引数を変えて再開しても、引数が黙って無視される(#648) - **困る人**: 4 つの CLI が揃っていない環境で収束ループを回す利用者と、中断したループを進め方を変えて再開する利用者 -- **直すと成り立つこと**: 使える者だけで開始でき、使えない者と理由が出力と状態ファイルに残る。cross-review は使える者が 2 者に満たなくても、ホスト、次に同じランタイムの 2 つ目で各ラウンドに 2 席を確保する。cross-refactoring の既定の参加者は codex / kiro とホストになる(ホストが codex / kiro なら 2 者、それ以外なら 3 者)。agy は `--include` で戻す。再開で渡した引数は反映されるか、反映しないと知らされる。これらの規則は両 Skill が共有する共通層が 1 か所で持つ +- **直すと成り立つこと**: 使える者だけで開始でき、使えない者と理由が出力と状態ファイルに残る。cross-review は使える者が 2 者に満たなくても、ホスト、次に同じランタイムの 2 つ目で各ラウンドに 2 席を確保する。cross-refactoring の既定の参加者は codex / kiro とホストになる(ホストが codex / kiro なら 2 者、それ以外なら 3 者)。agy は足す者の指定で戻す。再開で渡した引数は反映されるか、反映しないと知らされる。これらの規則は両 Skill が共有する共通層が 1 か所で持つ -## 文書の位置づけ +この文書は「何を満たすか」を扱う。設計・既存の要求との関係は末尾の「関連文書」にある。 -設計は [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) にある。この文書は「何を満たすか」だけを扱う。 +## 用語 + +受け入れ条件で使う語の意味を先に決める。受け入れ条件の本文は検査の入力と期待値の形をそのまま定めるため、識別子を業務用語へ置き換えずに書く。対応はこの表で引く。 -**既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の P5(AC10〜AC30)は、この文書が置き換える。** 対応は末尾の「既存の受け入れ条件との対応」にある。P4(#624)は #732 の設計が持つ。既存の設計文書の本体は触らない。 +| 用語 | 識別子 | 意味 | +| --- | --- | --- | +| ランタイム | `ALL_RUNTIMES` | `claude` / `codex` / `agy` / `kiro` の 4 つ | +| ホスト | `host` / `detect_host` | 収束ループを起動しているランタイム | +| 初期化 | `init` | 収束ループを新規に始める・再開する副コマンド。cross-review は `state.py init`、cross-refactoring は `refactor.py init` | +| ラウンドの開始 | `start-round` | 次のラウンドを開き、担当を決めて返す副コマンド | +| 結果の受け口 | `read-result` | 担当の結果ファイルを状態ファイルへ写す副コマンド | +| 完了報告 | `report` | 収束の結果を出す副コマンド | +| 母集合の既定 | `review_pool(host)` / `refactor_pool(host)` | Skill ごとに決まる参加者の出発点。cross-review は全ランタイム − ホスト、cross-refactoring は codex / kiro / ホスト | +| 足す者 / 外す者 | `--include` / `--exclude` | 参加者を名指しで足す・外す引数 | +| 参加者 | `runtimes`(cross-refactoring) | 母集合の既定に足す者を加え、外す者を除いた一覧。確認の対象 | +| 使える者の解決 | `resolve_participants` | 母集合・足す者・外す者・確認の結果から使える者を決める共通層の関数 | +| 止めない確認 | `probe_auth` | 認証の確認コマンドを走らせ、止めずに結果だけを返す共通層の関数。従来の `check_auth` は 1 件の失敗で止める | +| 使える者 | `participants.available` | 参加者のうち確認を通った者。1 者指定があればその 1 者 | +| 1 者指定 | `--only` | 担当を 1 者に固定する引数。値 `none` で外す | +| 全員を要する指定 | `--require-all` | 確認の失敗が 1 者でもあれば止める引数 | +| 席 | `seat_runtime` / `SEAT_PATTERN` | ラウンドで 1 つの CLI プロセスが占める場所。名前はランタイム名か `<ランタイム>-<2〜9>`(同じランタイムの 2 つ目以降) | +| 席の埋め方 | `review_seats` | 使える者と埋め合わせから 2 席を返す共通層の関数 | +| 適用の輪番 | `impl_assign` | 参加者から実装担当 1 者を返す共通層の関数 | +| 担当 | `rounds[].reviewers` / `rounds[].impl` | そのラウンドの席を占める者。前者は cross-review、後者は cross-refactoring | +| 埋め合わせ | `participants.fallback` | 使える者が 2 席に足りないとき、ホスト、次に同じランタイムの 2 つ目で席を埋めること | +| 再開 | — | 状態ファイルが残り `final` が `null` のときの初期化 | +| 再開で変えた値の記録 | `resume_changes` | 再開で変えた値を積む状態ファイルの項目 | ## 対象範囲 @@ -33,58 +57,27 @@ | 扱わないもの | 理由 | | --- | --- | | 反証する担当がいない指摘の数え方(#624、既存の P4) | #732(G2)の設計が持つ | -| 確認を「モデルを引く最小の呼び出し」へ替えること | #461。確認コマンドの所要と形の実測が要る。この変更の共通層は確認の手段を差し替えられる形にする | -| 起動した後に分かる使えなさ(利用上限・モデルの 404)で担当を自動で外すこと | #729(G3)が理由の語彙を持つ。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る | +| 確認を「言語モデルを引く最小の呼び出し」へ替えること | #461。確認コマンドの所要と形の実測が要る。この変更の共通層は確認の手段を差し替えられる形にする | +| 起動した後に分かる使えなさ(利用上限・言語モデルの 404)で担当を自動で外すこと | #729(G3)が理由の語彙を持つ。この変更は利用者が外す者の指定で外し、再開で反映できる入口までを作る | | 監視の上限・結末の語彙・投稿の重なり | G3 / G5 の範囲 | | cross-refactoring の適用ラウンドの取り込みと担当の交代 | #728(G4)の範囲 | | 提案者と適用者が同じランタイムになることを避ける割り当て | 採らないと決めた(設計文書の決定 8) | -| 再開で `--verify-command` を空へ戻す手段 | 置き換えはできる。空へ戻す要求は出ていない | +| 再開で検証コマンド(`--verify-command`)を空へ戻す手段 | 置き換えはできる。空へ戻す要求は出ていない | | クラス図 | 型を追加するのは `Participants` 1 つで、関係を持つ型が無い。形は契約文書のデータ構造が持つ | | `CHANGELOG.md` と版数 | 配布の工程が書く | -## 影響 - -変わるのは `init` の振る舞い、既定の参加者、状態ファイルの形、`init` の引数、共通層の関数、担当名の形である。 - -| 対象 | 影響 | -| --- | --- | -| 認証に失敗する CLI がある利用者 | どちらの `init` も止まらず、使える者で回る。従来の関門は `--require-all` で選べる | -| cross-refactoring の既定の参加者 | agy が既定から外れ、ホストが提案と適用に入る。`--include agy` で戻せる。提案者と適用者が同じランタイムになりうる | -| cross-review で使える者が 2 者に満たない利用者 | ホスト、次に同じランタイムの 2 つ目が席を埋める。1 者で回るのは `--only` を渡したときだけになる | -| 状態ファイルの形 | 両 Skill の最上位に `participants` と `resume_changes` が増える。cross-refactoring の `impl_capable` とラウンドの `reviewers` / `reviewer_models` が新規の状態から消える。無い項目は従来の読み方で読む | -| `init` の引数 | 両 Skill に `--exclude` / `--include` / `--require-all` が増える。cross-review の `--only` が `none` を取る。既定値は変わらない | -| 共通層の関数 | `check_auth` / `impl_pool` / `review_assign` / `assign` が消え、`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` が入る。呼び出し側は両 Skill だけである | -| 担当名の形 | ランタイム名に `-2`〜`-9` の接尾辞を持つ席の名前が、結果ファイルの stem と状態ファイルの鍵に現れうる | -| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC23) | - ## 前提 この要求は次の 5 つを前提に書いている。 | # | 前提 | | --- | --- | -| 1 | cross-refactoring のレビュー工程は #436 で消えており(Step 7 の `cross-review` が担う)、`assign()` が返すレビュー担当は状態ファイルへの記録と表示にしか使われない | -| 2 | 使える者の確認は、認証の確認コマンド(`AUTH_PROBES`)のままである。モデルを引く最小の呼び出しへ替える判断は #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口である | -| 3 | 再開の `init` の後、骨組みは必ず `start-round` で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | +| 1 | cross-refactoring のレビュー工程は #436 で消えており(Step 7 の cross-review が担う)、従来の割り当て(`assign()`)が返すレビュー担当は状態ファイルへの記録と表示にしか使われない | +| 2 | 使える者の確認は、認証の確認コマンド(`AUTH_PROBES`)のままである。言語モデルを引く最小の呼び出しへ替える判断は #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口である | +| 3 | 再開の初期化の後、骨組みは必ずラウンドの開始で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | | 4 | G2(#732)が同じ `state.py` の `_classify_finding` を、G3(#729)が `_read_review_result_file` / `_record_no_result` / `report` を、G5(#730)が投稿の経路を触る。この変更が触る節は `init` / 再開 / `_round_reviewers` / `start-round` / `read-result` の担当名の受け口 / `report` の参加者の節である | | 5 | 同じランタイムの 2 つの CLI プロセスは、別の作業文脈を持てば独立した意見として扱う(#687 の利用者の指示) | -## 用語 - -受け入れ条件で使う語の意味を先に決める。 - -| 用語 | 意味 | -| --- | --- | -| ランタイム | `claude` / `codex` / `agy` / `kiro` の 4 つ(`ALL_RUNTIMES`) | -| ホスト | 収束ループを起動しているランタイム(`detect_host`) | -| 母集合の既定 | Skill ごとに決まる参加者の出発点。cross-review は全ランタイム − ホスト、cross-refactoring は codex / kiro / ホスト | -| 参加者 | 母集合の既定に `--include` を足し、`--exclude` を除いた一覧。確認の対象 | -| 使える者 | 参加者のうち確認を通った者(`participants.available`)。`--only` があれば `[only]` | -| 席 | ラウンドで 1 つの CLI プロセスが占める場所。名前はランタイム名か `<ランタイム>-<2〜9>`(同じランタイムの 2 つ目以降) | -| 担当 | そのラウンドの席を占める者。cross-review は `rounds[].reviewers`、cross-refactoring は `rounds[].impl` | -| 埋め合わせ | 使える者が 2 席に足りないとき、ホスト、次に同じランタイムの 2 つ目で席を埋めること | -| 再開 | 状態ファイルが残り `final` が `null` のときの `init` | - ## 前提とする取り決め 実装が従う置き場所・書き方・テストの形である。 @@ -102,14 +95,29 @@ | 区分 | 内容 | | --- | --- | | 常に行う | 既存テストの実行、配布物の同期の検査、文書の検査 | -| 確認してから行う | `init` の既定を「確認の失敗で止める」から「使える者で回す」へ変えること。cross-refactoring の既定から agy を外すこと。どちらも設計 Pull Request の承認で確認する | +| 確認してから行う | 初期化の既定を「確認の失敗で止める」から「使える者で回す」へ変えること。cross-refactoring の既定から agy を外すこと。どちらも設計 Pull Request の承認で確認する | | 行わない | 監視・起動・投稿の経路の変更、`refactor_lib` の適用の取り込みの変更、確認コマンドの差し替え | +## 影響 + +変わるのは初期化の振る舞い、既定の参加者、状態ファイルの形、初期化の引数、共通層の関数、担当名の形である。 + +| 対象 | 影響 | +| --- | --- | +| 認証に失敗する CLI がある利用者 | どちらの初期化も止まらず、使える者で回る。従来の関門は全員を要する指定(`--require-all`)で選べる | +| cross-refactoring の既定の参加者 | agy が既定から外れ、ホストが提案と適用に入る。`--include agy` で戻せる。提案者と適用者が同じランタイムになりうる | +| cross-review で使える者が 2 者に満たない利用者 | ホスト、次に同じランタイムの 2 つ目が席を埋める。1 者で回るのは 1 者指定を渡したときだけになる | +| 状態ファイルの形 | 両 Skill の最上位に `participants` と `resume_changes` が増える。cross-refactoring の `impl_capable` とラウンドの `reviewers` / `reviewer_models` が新規の状態から消える。無い項目は従来の読み方で読む | +| 初期化の引数 | 両 Skill に `--exclude` / `--include` / `--require-all` が増える。cross-review の `--only` が `none` を取る。既定値は変わらない | +| 共通層の関数 | `check_auth` / `impl_pool` / `review_assign` / `assign` が消え、`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` が入る。呼び出し側は両 Skill だけである | +| 担当名の形 | ランタイム名に `-2`〜`-9` の接尾辞を持つ席の名前が、結果ファイルの stem と状態ファイルの鍵に現れうる | +| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC23) | + ## 受け入れ条件 50 件を、共通層・cross-review・cross-refactoring・文書・子 issue の再現・全体の 9 つの塊に分ける。 -### 共通層: 使える者の解決(`lib/assignment.py` / `lib/auth.py`) +### 共通層: 使える者の解決と確認 - [ ] AC1: 母集合 3 者のうち 1 者の確認が失敗する `probe` を `resolve_participants` に渡す。返る値の `available` は残り 2 者(母集合の順)、`unavailable` はその 1 者と理由を持ち、例外は上がらない - [ ] AC2: AC1 と同じ入力で `require_all=True` を渡すと `AssignmentError` が上がり、メッセージに欠けた者の名前と理由が含まれる @@ -128,7 +136,7 @@ - [ ] AC12: 使える者が 0 者のとき、埋め合わせに `["claude"]` を渡すと `["claude", "claude-2"]`、空を渡すと `AssignmentError` になる - [ ] AC13: `seat_runtime("kiro-2")` と `seat_runtime("kiro")` は `kiro` を返す。`gemini` / `kiro-1` / `kiro-10` / `kiro-2-3` は `AssignmentError` になる -### cross-review: 新規の `init` +### cross-review: 新規の初期化 - [ ] AC14: ホスト `claude` で `kiro` の確認が失敗する。`init` は終了コード 0 で状態ファイルを作る。`participants.available` は `["codex", "agy"]` で、`participants.unavailable.kiro` に理由が入る。標準エラーに `kiro` を外したことが 1 行出る - [ ] AC15: AC14 と同じ状態で `--require-all` を付けると、`init` は終了コード 1 で終わり、状態ファイルを作らない @@ -142,7 +150,7 @@ - [ ] AC23: 前のラウンドが `verdict` を持たず、担当 `agy` + `kiro` の両者が `REQUEST_CHANGES` で修正の記録が無い。このとき `start-round` は終了コード 5 で止まる - [ ] AC24: `report` が「参加した者」の節を出す。行は 6 つで、使える者 / `--exclude` で外した者 / `--include` で足した者 / 確認を通らなかった者(理由つき)/ 埋め合わせ / 再開で変えた値である。`participants` を持たない状態ファイルでは「記録なし」と出す -### cross-review: 再開の `init` +### cross-review: 再開の初期化 - [ ] AC25: `max_rounds: 12` の状態ファイルへ `--max-rounds 20` を渡す。`max_rounds` が 20 になり、`12 → 20` の形で 1 行出る。`resume_changes` に `{field: "max_rounds", from: 12, to: 20}` が 1 件積まれる。`--rotate-after` / `--verify-command` / `--verify-exit-code` も同じく反映され、後の 2 つは置き換える - [ ] AC26: 引数を渡さない再開では、次の 6 項目が変わらず、確認コマンドは 1 回も呼ばれない。`max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` / `only` / `participants` @@ -161,7 +169,7 @@ - [ ] AC36: 使える者が 0 者のとき `init` は終了コード 4 で終わり、状態ファイルを作らない - [ ] AC37: `report` と改修計画の表示にレビュー担当の列が無く、母集合を 1 行で出す(「提案・レビュー」と「適用の母集合」の 2 行に分けない) -### cross-refactoring: 再開の `init` +### cross-refactoring: 再開の初期化 - [ ] AC38: `max_outer_rounds: 3` の状態ファイルへ `--max-outer-rounds 5` を渡す。5 になり、`3 → 5` の形で 1 行出て、`resume_changes` に 1 件積まれる。`--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` も同じ - [ ] AC39: 再開で `--model codex=x` / `--host codex` / `--scope other` を渡すと、状態は変わらず、反映しないことが引数ごとに 1 行出る。状態に載る他の引数(`--baseline-test` など。契約文書の表)も同じ扱いである。引数を渡さない再開では、上限 4 項目と `models` と `runtimes` が変わらない @@ -193,7 +201,7 @@ - [ ] AC45: #478 の再現(`kiro-cli` が無い環境で `init`)で、`init` が終了コード 0 で終わる。#687 の場面 3(`codex` が使えない)で、`--exclude codex` を付けずに `init` が開始できる - [ ] AC46: #648 の再現(再開の `init` に `--only codex`)で、次の `start-round` が `codex` だけを返す - [ ] AC47: #664 の再現(`git grep -n '"--exclude"' -- plugins/ndf/skills/cross-refactoring`)が 1 行以上を出す -- [ ] AC48: #461 の再現(モデルを引けない CLI が確認を通る)は、この変更の後も現象が残ることを確かめて記録する(直す判断は #461 が持つ) +- [ ] AC48: #461 の再現(言語モデルを引けない CLI が確認を通る)は、この変更の後も現象が残ることを確かめて記録する(直す判断は #461 が持つ) ### 全体 @@ -216,8 +224,8 @@ | 大項目 | 条件 | | --- | --- | | 可用性 | 母集合の 1 者が使えないことで、どちらの収束ループも開始できない状態にならない(AC14、AC35) | -| 性能・拡張性 | 確認の回数は、新規の `init` で「参加者の数 + 埋め合わせが要るときのホスト 1 回」を超えない。除外した者は確かめない。再開で確かめ直すのは担当に関わる引数を渡したときだけである | -| 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、再開で反映しなかった引数が、`report` と `init` の出力だけで分かる(AC24、AC29、AC39) | +| 性能・拡張性 | 確認の回数は、新規の初期化で「参加者の数 + 埋め合わせが要るときのホスト 1 回」を超えない。除外した者は確かめない。再開で確かめ直すのは担当に関わる引数を渡したときだけである | +| 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、再開で反映しなかった引数が、完了報告と初期化の出力だけで分かる(AC24、AC29、AC39) | | 移行性 | この変更の前に始めた実行の状態ファイルを、書き換えずに読める(AC22、AC41) | ## 検証手段 @@ -229,7 +237,7 @@ | テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | | 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | | 定義と文書の検査 | AC50 の 6 つ | -| 手動確認 | P7 の後、ホスト claude で `--exclude codex` を付けた cross-review と、既定の cross-refactoring を 1 本ずつ回し、`report` で参加者と席を見る | +| 手動確認 | P7 の後、ホスト claude で `--exclude codex` を付けた cross-review と、既定の cross-refactoring を 1 本ずつ回し、完了報告で参加者と席を見る | ## 未決 @@ -237,7 +245,7 @@ | 項目 | 誰が決めるか | 期限 | | --- | --- | --- | -| 確認を「モデルを引く最小の呼び出し」へ替えるか、替えるならランタイムごとのコマンドと所要 | #461 | マイルストーン 06 の着手時 | +| 確認を「言語モデルを引く最小の呼び出し」へ替えるか、替えるならランタイムごとのコマンドと所要 | #461 | マイルストーン 06 の着手時 | | 同じランタイムの 2 席が、別のランタイムの 2 席より指摘を見落とすか | この変更の後の運用(`measure.py`) | 実測が 3 本たまった時点 | ## 既存の受け入れ条件との対応 @@ -275,7 +283,7 @@ ## 依頼(原文) -各 issue の本文から抜粋した原文である。全文は `gh issue view 727` / `687` / `478` / `664` / `648` で読む。 +各 issue の本文から抜粋した原文である。原文は書き換えない。全文は `gh issue view 727` / `687` / `478` / `664` / `648` で読む。 ### #727(根本原因の親) @@ -345,3 +353,11 @@ > ## 修正レイヤー > > `plugins/ndf/scripts/lib/statefile.py` に置く、再開時の引数の反映の契約。「明示的に渡された引数だけを状態へ重ね、反映しない引数は渡されたら知らせる」を 1 か所で持つ。 + +## 関連文書 + +| 文書 | 何を持つか | +| --- | --- | +| [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) | どう作るか(決定の記録・実測・構成要素・処理の流れ・テスト設計) | +| [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) | 状態ファイル・引数・関数の形 | +| [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) | 既存の要求(PR #667)。この文書はその P5(AC10〜AC30)を置き換える(対応は「既存の受け入れ条件との対応」)。P4(#624)は #732 の設計が持つ。既存の文書の本体は触らない | From 98292ea94e5489023c34e53afe1604809aee7d88 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 10:41:14 +0000 Subject: [PATCH 020/217] =?UTF-8?q?Fix:=20=E6=A7=8B=E6=88=90=E8=A6=81?= =?UTF-8?q?=E7=B4=A0=E5=9B=B3=E3=81=AE=20subgraph=20=E3=81=AE=E9=A1=8C?= =?UTF-8?q?=E5=90=8D=E3=82=92=E5=BC=95=E7=94=A8=E7=AC=A6=E3=81=A7=E5=9B=B2?= =?UTF-8?q?=E3=81=BF=E3=80=81GitHub=20=E3=81=A7=E6=8F=8F=E7=94=BB=E3=81=A7?= =?UTF-8?q?=E3=81=8D=E3=82=8B=E5=BD=A2=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?#729=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- issues/issue-729-619-584-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-729-619-584-design.md b/issues/issue-729-619-584-design.md index ca6ca58a..1963eb64 100644 --- a/issues/issue-729-619-584-design.md +++ b/issues/issue-729-619-584-design.md @@ -186,7 +186,7 @@ graph TD J[judge] RP[report] end - subgraph cross-refactoring(G4 が実装) + subgraph CR["cross-refactoring(G4 が実装)"] GF[取り込み merge-apply / merge-fix / merge-final-fix] end L -->|pgid = pid| M From 9bb02033143f8550627da07c1670e0ac649ef10e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:01:11 +0000 Subject: [PATCH 021/217] =?UTF-8?q?Docs:=20=E7=B5=90=E6=9C=AB=E3=81=AE?= =?UTF-8?q?=E8=AA=9E=E5=BD=99=E3=81=A8=E8=B5=B7=E5=8B=95=E3=81=97=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=AE=E5=8F=AF=E5=90=A6=E3=82=92=E5=85=B1=E9=80=9A?= =?UTF-8?q?=E5=B1=A4=E3=81=B8=E7=A7=BB=E3=81=99=E5=AE=9F=E8=A3=85=E8=A8=88?= =?UTF-8?q?=E7=94=BB=EF=BC=88#729=20#619=20#584=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計文書(PR #781)の決定 12 件を 8 つのタスクへ分解する。共通層(語彙・監視・起動)→ cross-review の読む側 → 文書と退行の確認の順に進める。 Co-Authored-By: Claude Fable 5.1 --- .../issue-729-619-584-implementation-plan.md | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 issues/issue-729-619-584-implementation-plan.md diff --git a/issues/issue-729-619-584-implementation-plan.md b/issues/issue-729-619-584-implementation-plan.md new file mode 100644 index 00000000..d9e51081 --- /dev/null +++ b/issues/issue-729-619-584-implementation-plan.md @@ -0,0 +1,172 @@ +# cross-review: 利用上限で止まった担当が「結果ファイル無し」と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(実装計画 / #729 #619 #584) + +## 関連リンク + +- 親 issue #729、子 issue #619 #584 +- 要求と受け入れ条件: [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md)(AC1〜AC24) +- 設計: [issue-729-619-584-design.md](issue-729-619-584-design.md)(決定 12 件。識別子は冒頭の「用語の対応表」で引く) +- 設計 Pull Request: #781(`develop` へマージ済み) + +## モード + +`standard`。本番の振る舞い(結果なしの理由と起動し直しの判断)を変え、共通層と cross-review の複数モジュールにまたがる。 + +## 目的と非目的 + +達成したい状態: + +- 担当の CLI が利用上限で落ちたとき、進行側と利用者に理由「利用上限」が届き、同じラウンドで同じ担当を起動し直さない +- 結果なしの判断と起動し直しの可否を、結末の共通層の 1 つの関数が持つ。cross-review はその値を読むだけになる +- 監視が止めた担当の子プロセスが、止めた後に結果ファイルを書かない + +やらないこと: + +- cross-refactoring の結果の読み取りと 3 つの取り込みの実装(G4 #728 が、この計画が作る結末を読む関数を使って行う) +- 投稿の後に打ち切られた担当の記録と重ねての投稿(G5 #730) +- 利用上限の担当を外して残りの担当で回す判断(#478) +- 監視の終了コードと標準出力の変更、cross-review の骨組み(`SKILL.md`)の変更 + +## 前提 + +- 前提 1: 設計文書の決定 12 件を変えない。実装で決めるのは「未確認のまま残ること」の 4 件(claude の 429 の出る先・bash 3.2 の `set -m`・文言の並ぶ順序・stem の突き合わせ)で、決めた結果は設計文書の同じ節へ書く +- 前提 2: 監視の結果ファイルと監視の記録(P1)、上限の表と工程(P2)は `develop` にある。この変更はその上に載せる +- 前提 3: 手元の bash は 5.3.9 である。macOS の bash 3.2 は実機が無いため、`set -m` の互換は bash の変更履歴の確認までとし、成り立たない場合の逃げ道(グループの先頭でなければ pid だけを止める)を実装で保証する + +## 受け入れ条件 + +要求文書の AC1〜AC24 をそのまま使う。検証手段は設計文書の「テスト設計」の表にある。この計画では、各タスクが満たす番号を「タスク分解」で示す。 + +## ドメイン用語 + +設計文書の「用語の対応表」を使う。この計画で新たに使う語は次の 2 つである。 + +| 用語 | 意味 | +| --- | --- | +| 共通層のタスク | 結末の語彙・監視・起動の手順を変えるタスク(Task 1〜4)。cross-review を触らない | +| 読む側のタスク | cross-review の状態の操作を変えるタスク(Task 5〜6)。共通層のタスクの後に行う | + +## 不変条件 + +- 監視の終了コード 0〜6 と標準出力の 13 個のキーは変わらない +- 結果の取り込みの終了コード(使える結果 0 / 読めない結果 3 / それ以外 1)と、判定の 0 / 2 / 7 / 8 の意味は変わらない +- 結果ファイルが JSON オブジェクトとして読めるなら、監視の結末が何であっても使える結果として扱う +- 理由の語彙と起動し直しの可否の表を持つのは結末の共通層だけである + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| 監視の標準出力・終了コード | 変えない | 変えない | +| 監視の結果ファイル・監視の記録の `reason` | `usage_limit` / `cli_timeout` の 2 値が増える | 追加のみ。読む側は値を集計するだけで一覧を持たない | +| 状態ファイルの `rounds[-1].<担当>` | `monitor_detail` の鍵が増える(結果なしのときだけ)。`no_result_reason` の値が 10 種類になる | 追加のみ。既存の状態ファイルはそのまま読める | +| 結末の共通層の関数 | `read_launch_outcome` / `relaunch_same_agent` / `LaunchOutcome` / `NO_RELAUNCH_REASONS` を新設 | 追加のみ。既存の `read_outcome` / `reason_for` / `REASONS` の呼び出し側は変わらない | +| 起動の手順の引数・pid ファイル | 変えない | 変えない | + +## 修正対象 + +```text +plugins/ndf/scripts/lib/monitor_outcome.py +plugins/ndf/scripts/lib/monitor.py +plugins/ndf/scripts/lib/launch-cli.sh +plugins/ndf/scripts/lib/README.md +plugins/ndf/scripts/tests/test_monitor_outcome_unit.py +plugins/ndf/skills/cross-review/scripts/state.py +plugins/ndf/skills/cross-review/docs/01-state-and-review.md +plugins/ndf/skills/cross-review/docs/03-review-output.md +plugins/ndf/skills/cross-review/docs/04-contracts.md +plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py 新設 +plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py 新設 +plugins/ndf/skills/cross-review/tests/test_read_result_reason.py 新設 +plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py 新設 +issues/issue-729-619-584-design.md 「未確認のまま残ること」の更新 +plugins/ndf/dev.kiro/ / plugins/ndf/dev.agy/ 配布物の同期(生成物) +``` + +## タスク分解 + +共通層のタスク(1〜4)→ 読む側のタスク(5〜6)→ 文書と退行の確認(7〜8)の順に進める。各タスクは失敗するテスト → 通す最小実装 → 整理の順で行う。 + +### Task 1: 結末を 1 つの値として読む関数を共通層に置く + +- **対象ファイル:** `plugins/ndf/scripts/lib/monitor_outcome.py`、`plugins/ndf/scripts/tests/test_monitor_outcome_unit.py` +- **変更内容:** 理由の語彙を 9 語にする(`REASONS` に `usage_limit` / `cli_timeout` / `unparsable`)。起動し直せない理由の集合 `NO_RELAUNCH_REASONS = frozenset({"usage_limit"})` と `relaunch_same_agent(reason)` を置く。`LaunchOutcome`(`payload` / `reason` / `detail` / `monitor` / `relaunch_same_agent`)と `read_launch_outcome(tmp_dir, stem, result_path=None)` を新設する。理由の決め方は設計文書の「結末を読む関数」の表のとおり。例外・`SystemExit`・標準出力/標準エラーへの出力を出さない。モジュールの冒頭の説明に読み取りの責務を足す +- **満たす受け入れ条件:** AC1、AC8〜AC11 +- **進め方:** 監視の結果ファイル(各理由・壊れた JSON・無し)× 結果ファイル(オブジェクト・配列・壊れた JSON・空・無し)の組み合わせを一時ディレクトリに置く単体テストを先に書く。`capsys` で出力が空であることも見る + +### Task 2: 監視が利用上限の文言を検知し、理由「利用上限」を結末に添える + +- **対象ファイル:** `plugins/ndf/scripts/lib/monitor.py`、`plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py`(新設) +- **変更内容:** `MonitorOutcome` に `reason: Optional[str] = None` を足し、`create(status, detail, reason=None)` にする。`USAGE_LIMIT_FATAL`(kiro の文言・claude の 429 の JSON・既存の `quota exceeded` / `rate limit exceeded`・`HTTP/x 429`)と `CLAUDE_STDOUT_USAGE_LIMIT` を新設し、`EARLY_ERROR_FATAL` から 429 と quota / rate limit の一致を外す(401 / 403 は残す)。致命の照合の前に利用上限の照合を置く(err.log は全担当、stdout.log は claude だけ JSON 向けの照合)。`_early_error_outcome` は利用上限に一致したとき `reason="usage_limit"` を添える。`_record_outcome` は `outcome.reason or reason_for(status)` で理由を書く。結末を作る既存の呼び出し 8 か所は変えない +- **満たす受け入れ条件:** AC2〜AC4、AC6、AC7 +- **進め方:** 設計文書の「実測」の 10 行を入力にした照合の単体テストと、文言を 1 行書いた err.log / stdout.log と終わったプロセスの pid ファイルで `monitor_agent` を呼ぶテストを先に書く。既存の `test_monitor_outcome_file.py` と `test_monitor_early_error.py` は変えずに通す。**照合の順序(利用上限 → 致命 → 警告の見た目の致命)をテストで固定する**(設計文書の未確認 5) + +### Task 3: CLI 自身の上限で結果を書かずに終わった担当を、理由「CLI の上限」にする + +- **対象ファイル:** `plugins/ndf/scripts/lib/monitor.py`、`plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` +- **変更内容:** `CLI_TIMEOUT_AFTER_EXIT`(`print timeout after \S+ with turn in progress`)を新設する。`_process_exit_outcome` で、終了して結果ファイルが無いときだけ err.log を照合し、一致すれば `reason="cli_timeout"` を添える。結果ファイルがあれば従来どおり `OK` +- **満たす受け入れ条件:** AC5 +- **進め方:** 結果ファイルあり・なしの 2 通りのテストを先に書く + +### Task 4: CLI を独立したプロセスグループで起動し、止めるときはグループへ送る + +- **対象ファイル:** `plugins/ndf/scripts/lib/launch-cli.sh`、`plugins/ndf/scripts/lib/monitor.py`、`plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py`(新設) +- **変更内容:** 起動の手順の `launch_runtime` を呼ぶ前に `set -m` を有効にし、背景起動した CLI の pid がプロセスグループの番号になるようにする(起動の直後に `set +m` で戻す)。`_kill_pid` は `os.getpgid(pid) == pid` かつ `!= os.getpgrp()` のとき `os.killpg` で SIGTERM → 3 秒 → SIGKILL を送り、それ以外は従来どおり pid だけへ送る。生存の確認はグループの先頭の pid で見る +- **満たす受け入れ条件:** AC19〜AC21 +- **進め方:** 3 秒後に子プロセスが結果ファイルを書く偽の CLI(`bash -c`)を PATH に置いて起動の手順から起動し、`os.getpgid(pid) == pid` を確かめるテスト、監視の上限 2 秒で止めた後 4 秒待っても結果ファイルが無いテスト、先頭でない pid で `os.killpg` が呼ばれないことを `mock` で見るテストを先に書く。**bash 3.2 の互換は、`set -m` が bash 2 系から存在する組み込みであることを `man bash` / 変更履歴で確かめ、設計文書の未確認 3 へ書く**(実機での確認は未確認のまま残す) + +### Task 5: cross-review の結果の取り込みが共通の値を読み、理由と監視の詳細を残す + +- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/state.py`、`plugins/ndf/skills/cross-review/tests/test_read_result_reason.py`(新設) +- **変更内容:** 共通層の `monitor_outcome` を読み込む。`_read_review_result_file` は結果ファイルを自前で開かず `read_launch_outcome(tmp_dir, f"{agent}-review-pr{pr}", rfile)` を呼び、`payload` が無ければ `reason` を `no_result_reason` として記録して従来の終了コード(`unparsable` は 3、それ以外は 1)で止める。`_record_no_result` に `monitor_detail`(監視の結果ファイルがあるときだけ鍵を書く。空文字は書かない)を足す。`no_verdict` / `not_posted` の上書きは従来どおり +- **満たす受け入れ条件:** AC13、AC12(`plugins/ndf/skills/` に `usage_limit` の条件分岐を書かない) +- **進め方:** 結果ファイル無し + 監視の結果ファイル(各理由)/ 無しで `read-result` を呼び、状態ファイルの `no_result_reason` と `monitor_detail` と終了コードを見るテストを先に書く。既存の `test_state_read_result.py` / `test_state_no_result.py` は変えずに通す。**stem の突き合わせ**(設計文書の未確認 6)は、`launch-reviewer.sh` の `STEM` と監視の `DEFAULT_STEM_TEMPLATE` と取り込みの stem が同じ形であることをテストで固定する + +### Task 6: 判定が理由を出し、起動し直せない理由があれば止める。報告の表に理由を出す + +- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/state.py`、`plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py`(新設) +- **変更内容:** `_handle_no_result_round` は結果なしの担当の理由を集めて標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'` を出す。理由に `relaunch_same_agent` が偽のものがあれば、起動し直さずに `final=error` として終了コード 1 で止め、標準エラーに担当・理由・`monitor_detail` を出す。すべて起動し直してよい理由なら従来どおり 7 / 2 度目は 1。`_print_round_summary` は結果なしの担当を `<担当>=NO_RESULT(<理由>)` の形で出す +- **満たす受け入れ条件:** AC14〜AC17、AC24 +- **進め方:** 状態ファイルを作って `judge` と `report` を呼ぶ。`usage_limit` を含む / 含まない / 2 度目の 3 通りを先に書く。`SKILL.md` の骨組みの行(`bg-wait.sh run` から `case $JUDGE_RC` まで)は `git diff` で差が無いことを確かめる + +### Task 7: 文書を更新する + +- **対象ファイル:** `plugins/ndf/skills/cross-review/docs/01-state-and-review.md`、`docs/03-review-output.md`、`docs/04-contracts.md`、`plugins/ndf/scripts/lib/README.md`、`issues/issue-729-619-584-design.md` +- **変更内容:** 理由の表を 10 語にする(`missing` / `unparsable` / `no_verdict` / `not_posted` / `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad`)。「monitor.py が誤って kill する場合の手順」に、上限に当たった場合の見分け方(`reason` と `monitor-outcomes.jsonl` の読み方)を足す。契約の文書に `monitor_detail` の鍵と `reason` の 2 値を足す。共通層の一覧の `monitor_outcome.py` の行に読み取りの責務を足す。設計文書の「未確認のまま残ること」の 1・3・5・6 を、実装で決めた結果へ更新する +- **満たす受け入れ条件:** AC18 +- **進め方:** テスト駆動を適用しない(文書)。`grep` で 10 語と「見分け方」の節を確かめる。`python3 scripts/check-doc-line-limit.py` を通す + +### Task 8: 退行の確認と配布物の同期 + +- **対象ファイル:** `plugins/ndf/dev.kiro/` `plugins/ndf/dev.agy/` の生成物 +- **変更内容:** `bash scripts/build-runtime-plugins.sh` で生成物を揃える。全テスト・定義の検査を通す。#619 と #584 の再現を手動で確かめる(要求文書の「検証手段」) +- **満たす受け入れ条件:** AC22、AC23 +- **進め方:** テスト駆動を適用しない(検証)。コマンドの終了コードを記録し、Pull Request 本文へ載せる + +## 影響範囲 + +- 監視を使う 2 つの Skill(cross-review / cross-refactoring)。cross-refactoring は監視の結果ファイルの `reason` に 2 値が増えるだけで、終了コードの分岐は変わらない +- 実行の要約(`run_metrics.py`)は `reason` の値を集計するだけで、語彙の一覧を持たないため変更なし +- 担当の CLI のプロセスが独立したプロセスグループで動く。起動の手順を呼ぶ側(`launch-reviewer.sh`、cross-refactoring の `launch-*.sh`)の引数は変わらない + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| `state.py` は 4470 行の 1 ファイルで、G1(#727)も同じファイルの別の節を触る | タスクごとにテストを通す。触る関数を `_read_review_result_file` / `_record_no_result` / `_handle_no_result_round` / `_print_round_summary` の 4 つに限る。競合は後からマージする側が解く | +| `docs/01-state-and-review.md` は 497 行で、行数の上限(500)に近い。理由の表に 7 行足すと超える | 同じ文書の既存の記述を詰め、501 行以上にしない。詰められなければ理由の表を契約の文書(`04-contracts.md`)へ移し、元の場所からリンクする | +| 利用上限の文言の照合を致命の照合の前に置くため、既存の 429 / quota の一致の `reason` が変わる | 既存テストは `_scan_early_fatal` の返り値だけを見ており、利用上限の照合を先に置いても `_scan_early_fatal` 単体の挙動は変えない。理由の変化は新しいテストで固定する | +| `set -m` を有効にすると、bash がジョブの終了を標準エラーへ出す・起動元がジョブ制御の影響を受ける | `launch_runtime` の直前で有効にし、直後に `set +m` で戻す。既存の `test_lib_launch_cli_runtimes.py` / `test_launch_cli_guards.py` で標準エラーの形が変わらないことを見る | +| 監視の環境変数(`MONITOR_*`)を export したシェルでは既存テストが落ちる(#678、G6) | export していないシェルでテストを実行する | + +## 切り戻し手順 + +- すべてコードと文書の変更で、データ移行は無い。Pull Request の revert で戻せる +- 状態ファイルの `monitor_detail` は追加の鍵で、戻した後の読み手は無視する。監視の結果ファイルの `usage_limit` / `cli_timeout` は戻した後の `reason_for` の一覧に無いが、読む側は値を集計するだけで一覧を照合しない + +## 完了の定義 + +- [ ] AC1〜AC24 をすべて満たし、条件ごとに検証手段と結果が対応している(Pull Request 本文の表) +- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る(export していないシェル) +- [ ] `bash scripts/build-runtime-plugins.sh --check`、`python3 scripts/check-skill-frontmatter.py`、`claude plugin validate .` が終了コード 0 +- [ ] `git grep -n 'usage_limit' -- plugins/ndf/skills` の一致行が文書・テスト・テンプレートの文言だけである(AC12) +- [ ] 設計文書の「未確認のまま残ること」の 1・3・5・6 が、実装で決めた結果へ更新されている From b45a12b2c115d7534a2ee41f4fd13e4d65156f23 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:06:31 +0000 Subject: [PATCH 022/217] =?UTF-8?q?Add:=20=E7=B5=90=E6=9C=AB=E3=81=AE?= =?UTF-8?q?=E8=AA=9E=E5=BD=99=E3=82=92=209=20=E8=AA=9E=E3=81=AB=E3=81=97?= =?UTF-8?q?=E3=80=81=E8=B5=B7=E5=8B=95=201=20=E5=9B=9E=E3=81=AE=E7=B5=90?= =?UTF-8?q?=E6=9C=AB=E3=82=92=201=20=E3=81=A4=E3=81=AE=E5=80=A4=E3=81=A8?= =?UTF-8?q?=E3=81=97=E3=81=A6=E8=AA=AD=E3=82=80=E9=96=A2=E6=95=B0=E3=82=92?= =?UTF-8?q?=E5=85=B1=E9=80=9A=E5=B1=A4=E3=81=AB=E7=BD=AE=E3=81=8F=EF=BC=88?= =?UTF-8?q?#729=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 1。`REASONS` に `usage_limit` / `cli_timeout` / `unparsable` を足し、 `NO_RELAUNCH_REASONS` と `relaunch_same_agent` で起動し直しの可否を 1 か所に持つ。 `read_launch_outcome` は結果ファイルと監視の結果ファイルを突き合わせ、 `LaunchOutcome`(payload / reason / detail / monitor / relaunch_same_agent)を返す。 例外・SystemExit・標準出力/標準エラーへの出力を出さない。 `from __future__ import annotations` は外した。注釈が文字列になると `dataclass` が `sys.modules` を引き、`importlib` で登録せずに読む既存テストが落ちるため。 受け入れ条件: AC1、AC8〜AC11 Co-Authored-By: Claude Fable 5.1 --- plugins/ndf/scripts/lib/monitor_outcome.py | 100 ++++++++++++- .../tests/test_monitor_outcome_unit.py | 141 ++++++++++++++++++ 2 files changed, 236 insertions(+), 5 deletions(-) diff --git a/plugins/ndf/scripts/lib/monitor_outcome.py b/plugins/ndf/scripts/lib/monitor_outcome.py index 30ad5422..ccb356cc 100644 --- a/plugins/ndf/scripts/lib/monitor_outcome.py +++ b/plugins/ndf/scripts/lib/monitor_outcome.py @@ -13,14 +13,21 @@ キーと値の形は `issues/issue-662-598-537-619-584-583-design-contracts.md` の 「監視の結果ファイル」にある。 -""" -from __future__ import annotations +**起動 1 回の結末を 1 つの値として読むのもここである(#729)。** `read_launch_outcome` が +結果ファイルの有無・読めるかと監視の結果を突き合わせ、使える結果(`payload`)か理由 +(`reason`)と起動し直しの可否(`relaunch_same_agent`)を返す。結果なしの判断と可否の表を +cross-review / cross-refactoring がそれぞれ持つと、語彙を足すたびに片方が古くなる。 +""" +# `from __future__ import annotations` を置かない。注釈が文字列になると `dataclass` が +# `sys.modules[<モジュール名>]` を引くが、読む側の多くはこのファイルを `importlib` で +# `sys.modules` に登録せずに読み込むため落ちる。実行時に評価できる形(3.10 以上)で書く。 import datetime as _dt import json import os import pathlib import threading +from dataclasses import dataclass from typing import Any, Optional try: # Windows には無い。無ければスレッドの排他だけで書く。 @@ -28,9 +35,23 @@ except ImportError: # pragma: no cover - POSIX では通らない fcntl = None # type: ignore[assignment] -# 監視が書く理由。**状態(`status`)からの対応だけで決まる。** `usage_limit` と -# `cli_timeout` は P3 で足す(それまでは `early_error` と `missing` に落ちる)。 -REASONS = ("ok", "timeout", "stalled", "early_error", "missing", "pidfile_bad") +# 理由の語彙。先頭の 6 語は監視の状態(`status`)から決まる。`usage_limit` / `cli_timeout` は +# 監視が文言の照合で結末に添えたときだけ現れ、`unparsable` は読む側(`read_launch_outcome`) +# だけが書く(#729 の決定 7)。 +REASONS = ( + "ok", "timeout", "stalled", "early_error", "missing", "pidfile_bad", + "usage_limit", "cli_timeout", "unparsable", +) + +# 同じ担当を同じ条件で起動し直しても解けない理由。利用上限は起動のたびに待ちと相手の +# 枠を使うだけで直らない(#619)。それ以外は対象や負荷で変わりうるので 1 度は起動し直せる。 +# **理由を足すときはこの集合だけを見直す。** 偽のときに何をするかは Skill が決める。 +NO_RELAUNCH_REASONS = frozenset({"usage_limit"}) + +# 監視がこの理由を書いていれば、監視が止めたか、結果を書けない終わり方をしたと分かっている。 +# 結果ファイルの状態を見ずにその値を採る(`ok` / `missing` は結果ファイルの側で決め直す)。 +_MONITOR_DECIDED_REASONS = frozenset( + {"timeout", "stalled", "early_error", "usage_limit", "cli_timeout", "pidfile_bad"}) _STATUS_REASON = { "OK": "ok", @@ -75,10 +96,20 @@ def reason_for(status: str) -> str: raise ValueError(f"監視の状態として知らない値です: {status!r}") from None +def relaunch_same_agent(reason: Optional[str]) -> bool: + """同じ担当を同じ条件で起動し直せば解けるか。`NO_RELAUNCH_REASONS` に無ければ可。""" + return reason not in NO_RELAUNCH_REASONS + + def outcome_path(tmp_dir: os.PathLike[str] | str, stem: str) -> pathlib.Path: return pathlib.Path(tmp_dir) / f"{stem}-monitor.json" +def default_result_path(tmp_dir: os.PathLike[str] | str, stem: str) -> pathlib.Path: + """結果ファイルの既定の置き場所。`launch-cli.sh` の `-result.json` と同じ形。""" + return pathlib.Path(tmp_dir) / f"{stem}-result.json" + + def journal_path(tmp_dir: os.PathLike[str] | str) -> pathlib.Path: return pathlib.Path(tmp_dir) / JOURNAL_NAME @@ -105,6 +136,65 @@ def read_outcome(tmp_dir: os.PathLike[str] | str, stem: str) -> Optional[dict[st return data if isinstance(data, dict) else None +@dataclass(frozen=True) +class LaunchOutcome: + """起動 1 回の結末。`payload` があれば使える結果、無ければ `reason` が理由。""" + payload: Optional[dict[str, Any]] + reason: Optional[str] + detail: str + monitor: Optional[dict[str, Any]] + relaunch_same_agent: bool + + +def _read_result_file(path: pathlib.Path) -> tuple[Optional[dict[str, Any]], str]: + """結果ファイルを読む。使える辞書か、無ければ結果なしの理由(`missing` / `unparsable`)。""" + try: + text = path.read_text(encoding="utf-8") + except (OSError, ValueError): + return None, "missing" + if not text.strip(): + return None, "missing" + try: + data = json.loads(text) + except ValueError: + return None, "unparsable" + return (data, "") if isinstance(data, dict) else (None, "unparsable") + + +def read_launch_outcome(tmp_dir: os.PathLike[str] | str, stem: str, + result_path: Optional[os.PathLike[str] | str] = None) -> LaunchOutcome: + """起動 1 回の結末を 1 つの値として読む(#729 の決定 2)。 + + **結果ファイルが JSON オブジェクトとして読めれば使える結果が勝つ。** 監視が止めた後にも + 結果ファイルが残っていれば、止める前に書き終えていた結果である。無いときの理由は、監視が + 理由を知っていればその値、知らなければ結果ファイルの状態(無い・空 → `missing`、あるが + 読めない → `unparsable`)で決める。 + + **失敗しない。** 例外・`SystemExit`・標準出力/標準エラーへの出力を出さない。壊れた監視の + 結果ファイルは無いものとして扱う。読む側(両 Skill の取り込み)が終了コードを決める。 + """ + path = (pathlib.Path(result_path) if result_path is not None + else default_result_path(tmp_dir, stem)) + payload, result_reason = _read_result_file(path) + monitor = read_outcome(tmp_dir, stem) + monitor_detail = str(monitor.get("detail") or "") if monitor else "" + if payload is not None: + return LaunchOutcome(payload=payload, reason=None, detail=monitor_detail, + monitor=monitor, relaunch_same_agent=True) + monitor_reason = monitor.get("reason") if monitor else None + reason = monitor_reason if monitor_reason in _MONITOR_DECIDED_REASONS else result_reason + detail = monitor_detail or _unusable_detail(path, result_reason) + return LaunchOutcome(payload=None, reason=reason, detail=detail, monitor=monitor, + relaunch_same_agent=relaunch_same_agent(reason)) + + +def _unusable_detail(path: pathlib.Path, result_reason: str) -> str: + """監視の詳細が無いときに `detail` へ置く、結果を読めなかった理由の 1 文。""" + if result_reason == "missing": + return f"結果ファイルが無い、または空です: {path}" + return f"結果ファイルが JSON オブジェクトとして読めません: {path}" + + def append_journal(tmp_dir: os.PathLike[str] | str, outcome: dict[str, Any]) -> None: """記録へ 1 行を追記する。 diff --git a/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py b/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py index 333173c4..dded7078 100644 --- a/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py +++ b/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py @@ -29,3 +29,144 @@ def test_read_journal_ignores_malformed_and_non_object_rows(tmp_path): ) assert _load_monitor_outcome().read_journal(tmp_path) == [valid] + + +# ---------- 理由の語彙と起動し直しの可否(#729 の AC1 / AC10) ---------- + +import pytest # noqa: E402 + +MONITOR_REASONS = ("timeout", "stalled", "early_error", "usage_limit", "cli_timeout", "pidfile_bad") + + +def test_reasons_are_the_nine_words(): + assert _load_monitor_outcome().REASONS == ( + "ok", "timeout", "stalled", "early_error", "missing", "pidfile_bad", + "usage_limit", "cli_timeout", "unparsable", + ) + + +@pytest.mark.parametrize(("status", "reason"), [ + ("OK", "ok"), ("TIMEOUT", "timeout"), ("STALLED", "stalled"), + ("EARLY_ERROR", "early_error"), ("NO_RESULT", "missing"), ("PIDFILE_BAD", "pidfile_bad"), +]) +def test_reason_for_keeps_the_six_status_mappings(status, reason): + assert _load_monitor_outcome().reason_for(status) == reason + + +def test_only_usage_limit_forbids_relaunching_the_same_agent(): + mod = _load_monitor_outcome() + assert mod.NO_RELAUNCH_REASONS == frozenset({"usage_limit"}) + for reason in mod.REASONS: + assert mod.relaunch_same_agent(reason) is (reason != "usage_limit"), reason + + +# ---------- 結末を 1 つの値として読む(#729 の AC8 / AC9 / AC11) ---------- + +STEM = "kiro-review-pr7" + + +def _place(tmp_path, *, monitor=None, result=None): + """監視の結果ファイルと結果ファイルを置く。`monitor` / `result` は書く中身(None は置かない)。""" + if monitor is not None: + (tmp_path / f"{STEM}-monitor.json").write_text(monitor, encoding="utf-8") + if result is not None: + (tmp_path / f"{STEM}-result.json").write_text(result, encoding="utf-8") + + +def _monitor_json(reason, detail="early error (fatal) in err.log: Monthly request limit reached"): + return json.dumps({"agent": "kiro", "stem": STEM, "status": "EARLY_ERROR", + "reason": reason, "detail": detail}) + + +@pytest.mark.parametrize("monitor", [None, "{broken", *[_monitor_json(r) for r in ("ok", "usage_limit")]]) +def test_readable_result_object_wins_whatever_the_monitor_says(tmp_path, monitor, capsys): + mod = _load_monitor_outcome() + _place(tmp_path, monitor=monitor, result='{"event": "APPROVE"}') + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload == {"event": "APPROVE"} + assert got.reason is None + assert got.relaunch_same_agent is True + if monitor and monitor != "{broken": + assert got.monitor == json.loads(monitor) + assert got.detail == json.loads(monitor)["detail"] + else: + assert got.monitor is None + assert got.detail == "" + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize("reason", MONITOR_REASONS) +@pytest.mark.parametrize("result", [None, "", "[1, 2]", "{broken"]) +def test_monitor_reason_is_taken_when_the_monitor_knows_why(tmp_path, reason, result): + mod = _load_monitor_outcome() + _place(tmp_path, monitor=_monitor_json(reason), result=result) + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload is None + assert got.reason == reason + assert got.relaunch_same_agent is (reason != "usage_limit") + assert got.monitor["reason"] == reason + assert got.detail == "early error (fatal) in err.log: Monthly request limit reached" + + +@pytest.mark.parametrize("monitor", [None, "{broken", '"scalar"', _monitor_json("ok"), _monitor_json("missing")]) +@pytest.mark.parametrize(("result", "reason"), [ + (None, "missing"), ("", "missing"), (" \n", "missing"), + ("[1, 2]", "unparsable"), ("{broken", "unparsable"), ('"text"', "unparsable"), +]) +def test_missing_or_unparsable_is_decided_by_the_result_file(tmp_path, monitor, result, reason, capsys): + mod = _load_monitor_outcome() + _place(tmp_path, monitor=monitor, result=result) + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload is None + assert got.reason == reason + assert got.relaunch_same_agent is True + if monitor in (None, "{broken", '"scalar"'): + assert got.monitor is None + # 監視の詳細が無いときは、読めなかった理由を 1 文で持つ + assert got.detail and got.detail.strip() == got.detail + else: + assert got.monitor == json.loads(monitor) + assert got.detail == json.loads(monitor)["detail"] + assert capsys.readouterr() == ("", "") + + +def test_detail_without_monitor_explains_why_the_result_is_unusable(tmp_path): + mod = _load_monitor_outcome() + (tmp_path / f"{STEM}-result.json").write_text("[1]", encoding="utf-8") + assert mod.read_launch_outcome(tmp_path, STEM).detail != mod.read_launch_outcome( + tmp_path, "other-stem").detail + + +def test_result_path_overrides_the_default_location(tmp_path): + mod = _load_monitor_outcome() + other = tmp_path / "elsewhere.json" + other.write_text('{"verdict": "ok"}', encoding="utf-8") + _place(tmp_path, result="[1]") + + got = mod.read_launch_outcome(tmp_path, STEM, result_path=other) + + assert got.payload == {"verdict": "ok"} + + +def test_result_path_that_is_a_directory_does_not_raise(tmp_path, capsys): + mod = _load_monitor_outcome() + (tmp_path / f"{STEM}-result.json").mkdir() + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload is None + assert got.reason == "missing" + assert capsys.readouterr() == ("", "") + + +def test_launch_outcome_is_frozen(tmp_path): + mod = _load_monitor_outcome() + got = mod.read_launch_outcome(tmp_path, STEM) + with pytest.raises(Exception): + got.reason = "ok" From 9b89a8241d038dd1b60e6d5d42fc62cdf218972e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:10:23 +0000 Subject: [PATCH 023/217] =?UTF-8?q?Add:=20=E7=9B=A3=E8=A6=96=E3=81=8C?= =?UTF-8?q?=E5=88=A9=E7=94=A8=E4=B8=8A=E9=99=90=E3=81=AE=E6=96=87=E8=A8=80?= =?UTF-8?q?=E3=82=92=E6=A4=9C=E7=9F=A5=E3=81=97=E3=80=81=E7=90=86=E7=94=B1?= =?UTF-8?q?=20usage=5Flimit=20=E3=82=92=E7=B5=90=E6=9C=AB=E3=81=AB?= =?UTF-8?q?=E6=B7=BB=E3=81=88=E3=82=8B=EF=BC=88#729=20#619=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 2。`USAGE_LIMIT_FATAL`(kiro の `Monthly request limit reached`、claude の `"api_error_status":429`、既存の quota / rate limit、HTTP 429)と、claude の stdout.log 向けの `CLAUDE_STDOUT_USAGE_LIMIT` を新設。`EARLY_ERROR_FATAL` の HTTP 行は 401 / 403 に絞る。照合の順序は利用上限 → 致命 → 警告の見た目の致命で、 `MonitorOutcome.reason` に `usage_limit` を添え、`_record_outcome` は結末の理由を 優先して書く。`_scan_early_fatal` は「止めるべき文言があるか」の契約を保つ (既存の `test_monitor_early_error.py` を変えずに通す)。 受け入れ条件: AC2〜AC4、AC6、AC7 Co-Authored-By: Claude Fable 5.1 --- plugins/ndf/scripts/lib/monitor.py | 131 ++++++++-- .../tests/test_monitor_usage_limit.py | 247 ++++++++++++++++++ 2 files changed, 350 insertions(+), 28 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index 5e7df1d3..35821e2b 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -19,7 +19,11 @@ - 可能なら `/proc//cmdline` で codex/agy であることを再確認 (PID 再利用対策) 2. **sentinel** (codex のみ): err.log に `^tokens used$` 出現 3. **early-error pattern**: err.log に既知の致命的キーワードが出たら即中断 - - **FATAL** (auth/quota/sandbox 等の明確な致命): 検知時に kill + - **USAGE LIMIT** (利用上限。kiro の `Monthly request limit reached` / claude の + `"api_error_status":429` / HTTP 429 / quota・rate limit): 検知時に kill し、 + 状態は EARLY_ERROR のまま理由 `usage_limit` を結末に添える(#729 / #619)。 + claude だけは stdout.log の JSON も見る + - **FATAL** (auth/sandbox 等の明確な致命): 検知時に kill - **WARN** (生の `Error:` / `Traceback` 等の曖昧パターン): 警告ログのみ、kill せず通常判定を継続 - `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化可 4. **result.json**: プロセス終了後に `/.cross_review/-review-pr-result.json` が @@ -108,17 +112,29 @@ def _lib_dir() -> pathlib.Path: "1", "true", "yes", "on", } -# err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象)。 -# auth / quota / sandbox / HTTP 401-403-429 など、 -# プロセスが続行しても result を生成できないと判明しているケースだけを入れる。 +# **利用上限** の文言 (kill 対象。理由は `usage_limit`)。起動し直しても解けないため、 +# 他の致命と区別して結末に理由を添える(#729 の決定 4)。err.log は全担当で見る。 +# 照合は致命の表より **先** に行う。上限で落ちた後に別の致命が続く形が普通で、上限のほうが原因。 +USAGE_LIMIT_FATAL = [ + # kiro の実物(#619) + re.compile(r"Monthly request limit reached"), + # claude の `--output-format json` の結果行(#647)。`:` の前後の空白は問わない + re.compile(r'"api_error_status"\s*:\s*429'), + # quota / rate limit (`m.start()` をキーワード位置に合わせるため `^.*` を付けない。 + # `_match_is_quoted()` が backtick / 「」 引用を判定するために match 開始位置を使うため) + re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), + # HTTP 429 の状態行 + re.compile(r"^HTTP/\d\S* 429 ", re.MULTILINE), +] + +# err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象。理由は `early_error`)。 +# auth / sandbox / HTTP 401-403 など、プロセスが続行しても result を生成できないと +# 判明しているケースだけを入れる。利用上限は `USAGE_LIMIT_FATAL` の側。 EARLY_ERROR_FATAL = [ # HTTP エラーステータス行 (`HTTP/1.1 401 Unauthorized` 等) - re.compile(r"^HTTP/\d\S* (?:401|403|429) ", re.MULTILINE), + re.compile(r"^HTTP/\d\S* (?:401|403) ", re.MULTILINE), # 認証 / 権限系(行頭限定) re.compile(r"^(?:Authentication failed|Permission denied)", re.MULTILINE), - # quota / rate limit (`m.start()` をキーワード位置に合わせるため `^.*` を付けない。 - # `_match_is_quoted()` が backtick / 「」 引用を判定するために match 開始位置を使うため) - re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), # API key 系 re.compile(r"\bAPI key (?:not found|missing|invalid)\b", re.IGNORECASE), # codex 固有: sandbox エラー @@ -245,6 +261,12 @@ def _strip_ansi(text: str) -> str: re.compile(r'"is_error"\s*:\s*true'), ] +# claude の stdout.log に出る利用上限(理由は `usage_limit`)。err.log と stdout.log の +# どちらに出るか未確認のため両方を見る(#729 の決定 6)。JSON 向けの照合で除外を掛けない。 +CLAUDE_STDOUT_USAGE_LIMIT = [ + re.compile(r'"api_error_status"\s*:\s*429'), +] + # env を safe に int parse する。非数値時は warn を stderr に出して fallback 値を返す。 # 上限の表と同じ規則で読むため、表の側の実装を使う。 @@ -358,9 +380,12 @@ class MonitorOutcome: exit_code: int icon: str detail: str + # 状態からは決まらない理由(`usage_limit` / `cli_timeout`)。`None` なら結末を書くときに + # `monitor_outcome.reason_for(status)` へ落ちる(#729 の決定 8)。 + reason: Optional[str] = None @classmethod - def create(cls, status: str, detail: str) -> "MonitorOutcome": + def create(cls, status: str, detail: str, reason: Optional[str] = None) -> "MonitorOutcome": exit_code, icon = { "OK": (0, "✅"), "TIMEOUT": (2, "⏰"), @@ -369,7 +394,15 @@ def create(cls, status: str, detail: str) -> "MonitorOutcome": "STALLED": (5, "🛑"), "PIDFILE_BAD": (6, "❓"), }[status] - return cls(status, exit_code, icon, detail) + return cls(status, exit_code, icon, detail, reason) + + +@dataclass(frozen=True) +class EarlyFatal: + """早期の致命の一致。どのファイルで・何が・理由は何か(`None` なら `early_error`)。""" + source: str + message: str + reason: Optional[str] = None @dataclass @@ -538,7 +571,12 @@ def _scan_patterns( def _scan_early_fatal(path: pathlib.Path) -> Optional[str]: - hit = _scan_patterns(path, EARLY_ERROR_FATAL) + """err.log の致命の一致(kill 対象)。**利用上限も含む。** + + 理由(`usage_limit` か `early_error` か)の区別はここでは行わず、`_early_error` が + `USAGE_LIMIT_FATAL` を先に照合して決める。この関数は「止めるべき文言があるか」だけを返す。 + """ + hit = _scan_patterns(path, USAGE_LIMIT_FATAL) or _scan_patterns(path, EARLY_ERROR_FATAL) if hit: return hit return _scan_patterns( @@ -552,12 +590,8 @@ def _scan_early_warn(path: pathlib.Path) -> Optional[str]: return _scan_patterns(path, EARLY_ERROR_WARN) -def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: - """claude の JSON 出力から承認失敗・実行失敗を検出する。 - - `--output-format json` は完了時に 1 個の JSON を吐くため、 - `permission_denials` が非空、または `is_error` が真であれば失敗が確定する。 - err.log 側の行単位パターンでは拾えないので専用に見る。 +def _scan_claude_stdout(path: pathlib.Path, patterns: list[re.Pattern[str]]) -> Optional[str]: + """claude の JSON 出力を `patterns` で照合し、一致の前後 80 文字を返す。 `_scan_patterns()` は使わない。あちらは行単位の benign 判定と引用符パリティ判定を 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を @@ -567,13 +601,28 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: if data is None: return None data = _strip_ansi(data) - for pat in CLAUDE_STDOUT_FATAL: + for pat in patterns: m = pat.search(data) if m: return data[max(0, m.start() - 80):m.end() + 80].strip() return None +def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: + """claude の JSON 出力から承認失敗・実行失敗を検出する。 + + `--output-format json` は完了時に 1 個の JSON を吐くため、 + `permission_denials` が非空、または `is_error` が真であれば失敗が確定する。 + err.log 側の行単位パターンでは拾えないので専用に見る。 + """ + return _scan_claude_stdout(path, CLAUDE_STDOUT_FATAL) + + +def _scan_claude_stdout_usage_limit(path: pathlib.Path) -> Optional[str]: + """claude の JSON 出力から利用上限(`"api_error_status":429`)を検出する。""" + return _scan_claude_stdout(path, CLAUDE_STDOUT_USAGE_LIMIT) + + def _scan_codex_sentinel(path: pathlib.Path) -> bool: tail = _read_tail(path, 64 * 1024) if tail is None: @@ -646,19 +695,43 @@ def _lingering_completion( ) +def _scan_usage_limit(paths: AgentPaths, agent: str) -> EarlyFatal | None: + """利用上限の文言。err.log は全担当、stdout.log は claude だけ JSON 向けの照合で見る。""" + hit = _scan_patterns(paths.err_log, USAGE_LIMIT_FATAL) + if hit: + return EarlyFatal("err.log", hit, "usage_limit") + if agent == "claude": + hit = _scan_claude_stdout_usage_limit(paths.stdout_log) + if hit: + return EarlyFatal("stdout.log", hit, "usage_limit") + return None + + +def _scan_fatal(paths: AgentPaths, agent: str) -> EarlyFatal | None: + """利用上限以外の致命(理由は `early_error`)。致命 → 警告の見た目の致命の順。""" + hit = _scan_early_fatal(paths.err_log) + if hit: + return EarlyFatal("err.log", hit) + if agent == "claude": + hit = _scan_claude_stdout_fatal(paths.stdout_log) + if hit: + return EarlyFatal("stdout.log", hit) + return None + + def _early_error( paths: AgentPaths, agent: str, disabled: bool, -) -> tuple[tuple[str, str] | None, str | None]: +) -> tuple[EarlyFatal | None, str | None]: + """早期の致命と警告。**照合の順序は利用上限 → 致命 → 警告の見た目の致命。** + + 同じ err.log に利用上限と他の致命が並んでいれば理由は `usage_limit` になる(#729 の + 決定 6)。`disabled`(`--no-early-error`)は利用上限の検知も一緒に無効にする。 + """ if disabled: return None, None - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - fatal = (fatal_source, fatal_err) if fatal_err else None + fatal = _scan_usage_limit(paths, agent) or _scan_fatal(paths, agent) return fatal, _scan_early_warn(paths.err_log) @@ -729,9 +802,9 @@ def _early_error_outcome( return None, warning if alive: _kill_pid(status.pid) - source, message = fatal return MonitorOutcome.create( - "EARLY_ERROR", f"early error (fatal) in {source}: {message[:200]}" + "EARLY_ERROR", f"early error (fatal) in {fatal.source}: {fatal.message[:200]}", + reason=fatal.reason, ), warning @@ -905,7 +978,9 @@ def _record_outcome( stem = stem_template.format(agent=agent, id=pr) try: paths = AgentPaths.for_(agent, pr, stem_template) - st.reason = monitor_outcome.reason_for(st.status) + # 結末が理由を持てばそれを、無ければ状態からの既定を書く(#729 の決定 8) + st.reason = (st.outcome.reason if st.outcome and st.outcome.reason + else monitor_outcome.reason_for(st.status)) st.started_at = started_at st.ended_at = monitor_outcome.now_iso() try: diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py new file mode 100644 index 00000000..b2b3b7d7 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py @@ -0,0 +1,247 @@ +"""利用上限と CLI の上限の文言の検知(#729 の AC2〜AC7、#619)。 + +監視は PATH の偽物ではなく **実プロセス** を相手にする(`test_monitor_outcome_file.py` と +同じ形)。終わったプロセスの pid ファイルと、文言を 1 行書いた err.log / stdout.log を +置いて監視を呼び、状態・終了コード・監視の結果ファイルの `reason` を見る。 + +- AC2: `Monthly request limit reached`(kiro の実物)→ `EARLY_ERROR` / `usage_limit` +- AC3: `"api_error_status":429`(claude)は err.log(全担当)と stdout.log(claude だけ)で拾う +- AC4: 既存の `quota exceeded` / `rate limit exceeded` / `HTTP/x 429` は `usage_limit`、 + `HTTP/x 401` / `403` と他の致命は `early_error` のまま +- AC6: 表・引用・バッククォート・grep 形式の中の文言は一致しない。stdout.log の JSON は除外を掛けない +- AC7: `usage_limit` は監視の結果ファイルと記録の `reason` に入り、標準出力のキーは変わらない +- 照合の順序は利用上限 → 致命 → 警告の見た目の致命(設計文書の未確認 5 を固定する) +""" +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import sys + +import pytest + +_HERE = pathlib.Path(__file__).resolve().parent +_MONITOR_LIB = _HERE.parents[2] / "scripts" / "lib" / "monitor.py" + +STDOUT_KEYS = { + "agent", "status", "exit_code", "pid", "elapsed", "detail", "err_log_size", + "stdout_log_size", "progress_log_size", "progress_tail", "idle_seconds", + "result_exists", "sentinel_seen", +} + +# 設計文書「実測」の 10 行。見出しはそのまま、行は実物の形に合わせて組み立てた。 +KIRO_LIMIT = "Monthly request limit reached" +CLAUDE_429 = ('{"type":"result","subtype":"success","is_error":false,' + '"api_error_status":429,"result":"rate limited"}') +CLAUDE_429_SPACED = '{"type": "result", "api_error_status" : 429, "is_error": false}' +HTTP_429 = "HTTP/1.1 429 Too Many Requests" +HTTP_401 = "HTTP/1.1 401 Unauthorized" +IN_TABLE = "| usage_limit | Monthly request limit reached | 利用上限 |" +IN_BACKTICKS = "see `Monthly request limit reached` in err.log" +IN_QUOTE = "> Monthly request limit reached" +AGY_PRINT_TIMEOUT = "[agy] print timeout after 10m0s with turn in progress; returning partial output" +IN_GREP = ('plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py:12:' + ' KIRO_LIMIT = "Monthly request limit reached"') + +# (見出し, 行, 利用上限の表の一致, 利用上限を除いた致命の表の一致) +# 設計文書の「現行fatal」は変更前の表で測った値。HTTP 429 はこの変更で利用上限の表へ移る(AC4)。 +TEN_LINES = [ + ("kiro 実物", KIRO_LIMIT, True, False), + ("claude 429 JSON 1 行", CLAUDE_429, True, False), + ("claude 429 空白あり", CLAUDE_429_SPACED, True, False), + ("HTTP 429 行", HTTP_429, True, False), + ("HTTP 401 行", HTTP_401, False, True), + ("表の中", IN_TABLE, False, False), + ("バッククォート", IN_BACKTICKS, False, False), + ("引用行", IN_QUOTE, False, False), + ("agy print timeout", AGY_PRINT_TIMEOUT, False, False), + ("grep 形式", IN_GREP, False, False), +] + + +def _write(path: pathlib.Path, text: str) -> pathlib.Path: + path.write_text(text + "\n", encoding="utf-8") + return path + + +# ---------- 照合の単体(AC4 / AC6) ---------- + +@pytest.mark.parametrize(("label", "line", "usage_hit", "fatal_hit"), TEN_LINES, + ids=[t[0] for t in TEN_LINES]) +def test_ten_measured_lines_match_as_the_design_records(tmp_path, monitor_mod, label, line, + usage_hit, fatal_hit): + log = _write(tmp_path / "err.log", line) + assert (monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is not None) is usage_hit + assert (monitor_mod._scan_patterns(log, monitor_mod.EARLY_ERROR_FATAL) is not None) is fatal_hit + # 止めるべき文言があるかを返す `_scan_early_fatal` は、どちらの表の一致も拾う(既存テストの契約) + assert (monitor_mod._scan_early_fatal(log) is not None) is (usage_hit or fatal_hit) + + +def test_claude_stdout_json_is_matched_without_the_quote_exclusion(tmp_path, monitor_mod): + """stdout.log の JSON は 1 行に引用符が多く、行単位の除外を掛けると取りこぼす。""" + out = _write(tmp_path / "stdout.log", CLAUDE_429) + assert monitor_mod._scan_claude_stdout_usage_limit(out) is not None + assert monitor_mod._scan_claude_stdout_usage_limit(_write(tmp_path / "ok.log", '{"is_error":false}')) is None + + +@pytest.mark.parametrize("line", ["quota exceeded: please upgrade", "Rate limit exceeded for model", HTTP_429]) +def test_existing_limit_matches_moved_to_usage_limit(tmp_path, monitor_mod, line): + log = _write(tmp_path / "err.log", line) + assert monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is not None + assert monitor_mod._scan_patterns(log, monitor_mod.EARLY_ERROR_FATAL) is None + + +@pytest.mark.parametrize("line", [HTTP_401, "HTTP/2 403 Forbidden", "Authentication failed: token", + "Internal sandbox error: cannot start"]) +def test_other_fatal_lines_stay_early_error(tmp_path, monitor_mod, line): + log = _write(tmp_path / "err.log", line) + assert monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is None + assert monitor_mod._scan_early_fatal(log) is not None + + +# ---------- 照合の順序(利用上限 → 致命 → 警告の見た目の致命) ---------- + +def _paths(monitor_mod, tmp_path, agent="kiro"): + return monitor_mod.AgentPaths( + agent=agent, pr=7, + pidfile=tmp_path / "x.pid", err_log=tmp_path / "err.log", + stdout_log=tmp_path / "stdout.log", progress_log=tmp_path / "progress.log", + result=tmp_path / "result.json", + ) + + +@pytest.mark.parametrize("order", ["limit_first", "fatal_first"]) +def test_usage_limit_wins_over_other_fatal_lines_in_either_order(tmp_path, monitor_mod, order): + lines = [KIRO_LIMIT, "Authentication failed: retry"] + if order == "fatal_first": + lines.reverse() + _write(tmp_path / "err.log", "\n".join(lines)) + fatal, _warn = monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", False) + assert fatal is not None + assert fatal.reason == "usage_limit" + assert fatal.source == "err.log" + + +def test_usage_limit_wins_over_warning_shaped_fatal(tmp_path, monitor_mod): + _write(tmp_path / "err.log", + "WARNING: --trust-tools arg for custom tool foo\n" + KIRO_LIMIT) + fatal, _warn = monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", False) + assert fatal.reason == "usage_limit" + + +def test_fatal_without_usage_limit_has_no_reason(tmp_path, monitor_mod): + _write(tmp_path / "err.log", "Authentication failed: retry") + fatal, _warn = monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", False) + assert fatal.reason is None + assert fatal.message == "Authentication failed: retry" + + +def test_claude_stdout_usage_limit_is_seen_only_for_claude(tmp_path, monitor_mod): + _write(tmp_path / "stdout.log", CLAUDE_429) + fatal, _ = monitor_mod._early_error(_paths(monitor_mod, tmp_path, "claude"), "claude", False) + assert (fatal.source, fatal.reason) == ("stdout.log", "usage_limit") + fatal, _ = monitor_mod._early_error(_paths(monitor_mod, tmp_path, "kiro"), "kiro", False) + assert fatal is None + + +def test_disabled_early_error_disables_usage_limit_too(tmp_path, monitor_mod): + _write(tmp_path / "err.log", KIRO_LIMIT) + assert monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", True) == (None, None) + + +def test_monitor_outcome_create_keeps_the_two_argument_form(monitor_mod): + plain = monitor_mod.MonitorOutcome.create("EARLY_ERROR", "x") + assert (plain.reason, plain.exit_code) == (None, 4) + limited = monitor_mod.MonitorOutcome.create("EARLY_ERROR", "x", reason="usage_limit") + assert limited.reason == "usage_limit" + + +# ---------- 監視を実プロセスで呼ぶ(AC2 / AC3 / AC4 / AC7) ---------- + +def _dead_pid() -> int: + proc = subprocess.Popen(["true"]) + proc.wait() + return proc.pid + + +def _run_monitor(tmp_dir: pathlib.Path, agent: str, *extra: str) -> subprocess.CompletedProcess: + env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} + return subprocess.run( + [sys.executable, str(_MONITOR_LIB), "7", "--agents", agent, + "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], + capture_output=True, text=True, env=env, timeout=60, + ) + + +def _finished(tmp_dir: pathlib.Path, agent: str, *, err: str = "", stdout: str = "", + result: bool = False) -> str: + stem = f"{agent}-review-pr7" + (tmp_dir / f"{stem}.pid").write_text(str(_dead_pid())) + if err: + _write(tmp_dir / f"{stem}-err.log", err) + if stdout: + _write(tmp_dir / f"{stem}-stdout.log", stdout) + if result: + (tmp_dir / f"{stem}-result.json").write_text('{"event": "APPROVE"}') + return stem + + +def _outcome(tmp_dir: pathlib.Path, stem: str) -> dict: + return json.loads((tmp_dir / f"{stem}-monitor.json").read_text(encoding="utf-8")) + + +@pytest.mark.parametrize(("agent", "err", "stdout"), [ + ("kiro", KIRO_LIMIT, ""), # AC2 + ("codex", CLAUDE_429, ""), # AC3: err.log は全担当 + ("claude", "", CLAUDE_429), # AC3: stdout.log は claude だけ + ("claude", "", CLAUDE_429_SPACED), # AC3: 空白の有無を問わない + ("agy", "quota exceeded: upgrade", ""), # AC4 + ("agy", HTTP_429, ""), # AC4 +]) +def test_usage_limit_stops_the_agent_as_early_error_with_reason_usage_limit(tmp_path, agent, err, stdout): + stem = _finished(tmp_path, agent, err=err, stdout=stdout) + + proc = _run_monitor(tmp_path, agent) + + assert proc.returncode == 4, proc.stderr + outcome = _outcome(tmp_path, stem) + assert (outcome["status"], outcome["reason"]) == ("EARLY_ERROR", "usage_limit") + assert outcome["detail"].startswith("early error (fatal) in ") + rows = [json.loads(l) for l in (tmp_path / "monitor-outcomes.jsonl").read_text().splitlines()] + assert rows[-1]["reason"] == "usage_limit" + # AC7: 標準出力のキーは変わらない + out = [json.loads(l) for l in proc.stdout.splitlines() if l.strip()] + assert len(out) == 1 and set(out[0]) == STDOUT_KEYS and out[0]["exit_code"] == 4 + + +@pytest.mark.parametrize("err", [HTTP_401, "HTTP/1.1 403 Forbidden", "Permission denied"]) +def test_other_fatal_lines_keep_reason_early_error(tmp_path, err): + stem = _finished(tmp_path, "kiro", err=err) + + proc = _run_monitor(tmp_path, "kiro") + + assert proc.returncode == 4, proc.stderr + assert (_outcome(tmp_path, stem)["status"], _outcome(tmp_path, stem)["reason"]) == ( + "EARLY_ERROR", "early_error") + + +def test_claude_json_in_kiro_stdout_is_not_seen(tmp_path): + """stdout.log を見るのは claude だけ。他の担当の stdout は結果なしのまま。""" + stem = _finished(tmp_path, "kiro", stdout=CLAUDE_429) + + proc = _run_monitor(tmp_path, "kiro") + + assert proc.returncode == 3 + assert _outcome(tmp_path, stem)["reason"] == "missing" + + +def test_quoted_usage_limit_in_err_log_is_not_a_hit(tmp_path): + stem = _finished(tmp_path, "kiro", err="\n".join([IN_TABLE, IN_BACKTICKS, IN_QUOTE, IN_GREP]), + result=True) + + proc = _run_monitor(tmp_path, "kiro") + + assert proc.returncode == 0, proc.stderr + assert _outcome(tmp_path, stem)["reason"] == "ok" From a2420891ae97f9b751e4417a5a8ba5f6fb5a8688 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:11:28 +0000 Subject: [PATCH 024/217] =?UTF-8?q?Add:=20CLI=20=E8=87=AA=E8=BA=AB?= =?UTF-8?q?=E3=81=AE=E4=B8=8A=E9=99=90=E3=81=A7=E7=B5=90=E6=9E=9C=E3=82=92?= =?UTF-8?q?=E6=9B=B8=E3=81=8B=E3=81=9A=E3=81=AB=E7=B5=82=E3=82=8F=E3=81=A3?= =?UTF-8?q?=E3=81=9F=E6=8B=85=E5=BD=93=E3=82=92=E3=80=81=E7=90=86=E7=94=B1?= =?UTF-8?q?=20cli=5Ftimeout=20=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88#729?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 3。`CLI_TIMEOUT_AFTER_EXIT`(agy の `print timeout after <時間> with turn in progress`)を新設。`_process_exit_outcome` は終了して結果ファイルが無いときだけ err.log を照合し、一致すれば `NO_RESULT` に `reason="cli_timeout"` を添える。 結果ファイルがあれば従来どおり `OK` / `ok`。 受け入れ条件: AC5 Co-Authored-By: Claude Fable 5.1 --- plugins/ndf/scripts/lib/monitor.py | 20 +++++- .../tests/test_monitor_usage_limit.py | 62 +++++++++++++++---- 2 files changed, 68 insertions(+), 14 deletions(-) diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index 35821e2b..45632ef2 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -27,7 +27,8 @@ - **WARN** (生の `Error:` / `Traceback` 等の曖昧パターン): 警告ログのみ、kill せず通常判定を継続 - `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化可 4. **result.json**: プロセス終了後に `/.cross_review/-review-pr-result.json` が - 生成されていなければ失敗扱い + 生成されていなければ失敗扱い。err.log に CLI 自身の上限の文言(agy の + `print timeout after <時間> with turn in progress`)があれば理由 `cli_timeout`(#729) 5. **hard timeout**: 既定は `--phase` の工程で上限の表(`limits.py`)から引く (省略時は `review`)。`--timeout` → `MONITOR_TIMEOUT_` → `MONITOR_TIMEOUT` の順で上書き可 6. **stall timeout**: err.log + stdout.log の合計サイズが一定時間変化しなければ @@ -141,6 +142,14 @@ def _lib_dir() -> pathlib.Path: re.compile(r"\bsandbox error\b", re.IGNORECASE), ] +# **CLI 自身の上限** で結果を書かずに終わったことを示す文言(理由は `cli_timeout`)。 +# **終了した後、結果ファイルが無いときだけ** 照合する。生きている間に見ると途中の警告を +# 致命と読み、結果ファイルがあれば上限に当たっても書き終えているので使える(#729 の決定 5)。 +CLI_TIMEOUT_AFTER_EXIT = [ + # agy の `--print-timeout` の打ち切り(#598 / #537 の実物) + re.compile(r"print timeout after \S+ with turn in progress"), +] + # **警告の見た目で出る致命** パターン。`EARLY_ERROR_FATAL` と違い、行頭の # `warning:` を benign とする規則を適用しない(適用すると自分自身が消える)。 # 引用符・バックティック・markdown 引用による誤検知の除外だけを効かせる。 @@ -820,6 +829,15 @@ def _process_exit_outcome( f"process exited; sentinel={status.sentinel_seen}; " f"result_exists={status.result_exists}", ) + # 結果なしの理由を err.log から引く。CLI の上限の文言があれば `cli_timeout`、無ければ + # 状態からの既定(`missing`)に落ちる。 + cli_timeout = _scan_patterns(paths.err_log, CLI_TIMEOUT_AFTER_EXIT) + if cli_timeout: + return MonitorOutcome.create( + "NO_RESULT", + f"process exited but result.json missing (CLI timeout): {cli_timeout[:200]}", + reason="cli_timeout", + ) return MonitorOutcome.create( "NO_RESULT", f"process exited but result.json missing: {paths.result}" ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py index b2b3b7d7..490bda45 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py @@ -8,6 +8,8 @@ - AC3: `"api_error_status":429`(claude)は err.log(全担当)と stdout.log(claude だけ)で拾う - AC4: 既存の `quota exceeded` / `rate limit exceeded` / `HTTP/x 429` は `usage_limit`、 `HTTP/x 401` / `403` と他の致命は `early_error` のまま +- AC5: 結果ファイル無しで終わり err.log に `print timeout after <時間> with turn in progress` + → `NO_RESULT` / `cli_timeout`。結果ファイルがあれば `OK` / `ok` - AC6: 表・引用・バッククォート・grep 形式の中の文言は一致しない。stdout.log の JSON は除外を掛けない - AC7: `usage_limit` は監視の結果ファイルと記録の `reason` に入り、標準出力のキーは変わらない - 照合の順序は利用上限 → 致命 → 警告の見た目の致命(設計文書の未確認 5 を固定する) @@ -45,19 +47,19 @@ IN_GREP = ('plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py:12:' ' KIRO_LIMIT = "Monthly request limit reached"') -# (見出し, 行, 利用上限の表の一致, 利用上限を除いた致命の表の一致) +# (見出し, 行, 利用上限の表の一致, CLI の上限の表の一致, 利用上限を除いた致命の表の一致) # 設計文書の「現行fatal」は変更前の表で測った値。HTTP 429 はこの変更で利用上限の表へ移る(AC4)。 TEN_LINES = [ - ("kiro 実物", KIRO_LIMIT, True, False), - ("claude 429 JSON 1 行", CLAUDE_429, True, False), - ("claude 429 空白あり", CLAUDE_429_SPACED, True, False), - ("HTTP 429 行", HTTP_429, True, False), - ("HTTP 401 行", HTTP_401, False, True), - ("表の中", IN_TABLE, False, False), - ("バッククォート", IN_BACKTICKS, False, False), - ("引用行", IN_QUOTE, False, False), - ("agy print timeout", AGY_PRINT_TIMEOUT, False, False), - ("grep 形式", IN_GREP, False, False), + ("kiro 実物", KIRO_LIMIT, True, False, False), + ("claude 429 JSON 1 行", CLAUDE_429, True, False, False), + ("claude 429 空白あり", CLAUDE_429_SPACED, True, False, False), + ("HTTP 429 行", HTTP_429, True, False, False), + ("HTTP 401 行", HTTP_401, False, False, True), + ("表の中", IN_TABLE, False, False, False), + ("バッククォート", IN_BACKTICKS, False, False, False), + ("引用行", IN_QUOTE, False, False, False), + ("agy print timeout", AGY_PRINT_TIMEOUT, False, True, False), + ("grep 形式", IN_GREP, False, False, False), ] @@ -68,12 +70,13 @@ def _write(path: pathlib.Path, text: str) -> pathlib.Path: # ---------- 照合の単体(AC4 / AC6) ---------- -@pytest.mark.parametrize(("label", "line", "usage_hit", "fatal_hit"), TEN_LINES, +@pytest.mark.parametrize(("label", "line", "usage_hit", "cli_timeout_hit", "fatal_hit"), TEN_LINES, ids=[t[0] for t in TEN_LINES]) def test_ten_measured_lines_match_as_the_design_records(tmp_path, monitor_mod, label, line, - usage_hit, fatal_hit): + usage_hit, cli_timeout_hit, fatal_hit): log = _write(tmp_path / "err.log", line) assert (monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is not None) is usage_hit + assert (monitor_mod._scan_patterns(log, monitor_mod.CLI_TIMEOUT_AFTER_EXIT) is not None) is cli_timeout_hit assert (monitor_mod._scan_patterns(log, monitor_mod.EARLY_ERROR_FATAL) is not None) is fatal_hit # 止めるべき文言があるかを返す `_scan_early_fatal` は、どちらの表の一致も拾う(既存テストの契約) assert (monitor_mod._scan_early_fatal(log) is not None) is (usage_hit or fatal_hit) @@ -245,3 +248,36 @@ def test_quoted_usage_limit_in_err_log_is_not_a_hit(tmp_path): assert proc.returncode == 0, proc.stderr assert _outcome(tmp_path, stem)["reason"] == "ok" + + +# ---------- CLI の上限(AC5) ---------- + +def test_cli_timeout_without_result_is_no_result_with_reason_cli_timeout(tmp_path): + stem = _finished(tmp_path, "agy", err=AGY_PRINT_TIMEOUT) + + proc = _run_monitor(tmp_path, "agy") + + assert proc.returncode == 3, proc.stderr + outcome = _outcome(tmp_path, stem) + assert (outcome["status"], outcome["reason"]) == ("NO_RESULT", "cli_timeout") + rows = [json.loads(l) for l in (tmp_path / "monitor-outcomes.jsonl").read_text().splitlines()] + assert rows[-1]["reason"] == "cli_timeout" + + +def test_cli_timeout_with_result_is_still_ok(tmp_path): + """上限に当たっても結果を書き終えていれば使える。""" + stem = _finished(tmp_path, "agy", err=AGY_PRINT_TIMEOUT, result=True) + + proc = _run_monitor(tmp_path, "agy") + + assert proc.returncode == 0, proc.stderr + assert (_outcome(tmp_path, stem)["status"], _outcome(tmp_path, stem)["reason"]) == ("OK", "ok") + + +def test_cli_timeout_in_a_table_row_is_not_a_hit(tmp_path): + stem = _finished(tmp_path, "agy", err=f"| cli_timeout | {AGY_PRINT_TIMEOUT} |") + + proc = _run_monitor(tmp_path, "agy") + + assert proc.returncode == 3 + assert _outcome(tmp_path, stem)["reason"] == "missing" From 11b3a83544da3dbc37b93fe4f2967df78f9856a7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:17:50 +0000 Subject: [PATCH 025/217] =?UTF-8?q?Add:=20CLI=20=E3=82=92=E7=8B=AC?= =?UTF-8?q?=E7=AB=8B=E3=81=97=E3=81=9F=E3=83=97=E3=83=AD=E3=82=BB=E3=82=B9?= =?UTF-8?q?=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E3=81=A7=E8=B5=B7=E5=8B=95?= =?UTF-8?q?=E3=81=97=E3=80=81=E6=AD=A2=E3=82=81=E3=82=8B=E3=81=A8=E3=81=8D?= =?UTF-8?q?=E3=81=AF=E3=82=B0=E3=83=AB=E3=83=BC=E3=83=97=E3=81=B8=E9=80=81?= =?UTF-8?q?=E3=82=8B=EF=BC=88#729=20#584=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 4。`launch-cli.sh` は `launch_runtime` の直前で `set -m`、直後で `set +m` にし、 背景起動した CLI の pid をプロセスグループの番号にする。`monitor._kill_pid` は pid がグループの先頭で、かつ監視自身のグループでないときだけ `os.killpg` (SIGTERM → 3 秒 → SIGKILL)を送り、それ以外は従来どおり `os.kill`。 ゾンビの扱いは変えない。 `set -m` は bash 3.2 の bash.1(GNU Bash-3.2、2006-09-28)に 「Monitor mode. Job control is enabled. Background processes run in a separate process group」と記載があり、macOS の bash 3.2 でも使える。 受け入れ条件: AC19〜AC21 Co-Authored-By: Claude Fable 5.1 --- plugins/ndf/scripts/lib/launch-cli.sh | 8 + plugins/ndf/scripts/lib/monitor.py | 27 ++- .../tests/test_launch_cli_process_group.py | 164 ++++++++++++++++++ 3 files changed, 196 insertions(+), 3 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py diff --git a/plugins/ndf/scripts/lib/launch-cli.sh b/plugins/ndf/scripts/lib/launch-cli.sh index 2c1416c2..d874ff2b 100755 --- a/plugins/ndf/scripts/lib/launch-cli.sh +++ b/plugins/ndf/scripts/lib/launch-cli.sh @@ -141,7 +141,15 @@ CLAUDE_ALLOWED_TOOLS=${NDF_CLAUDE_ALLOWED_TOOLS:-Bash,Read,Write,Edit,Glob,Grep} cd "$WORKDIR" +# **CLI を独立したプロセスグループで起動する(#584 / #729 の決定 10)。** ジョブ制御を +# 有効にして背景起動すると、CLI の pid がそのままプロセスグループの番号になる。監視は +# 止めるときにグループへシグナルを送るので、CLI の子プロセスが止めた後に結果ファイルを +# 書かない。`setsid` は macOS に標準で無いため使わない。起動の直後に戻し、この script の +# 残りはジョブ制御の影響を受けない。`set -m` は bash 3.2(macOS)にもある(bash.1 の +# 「Monitor mode. Job control is enabled.」)。 +set -m launch_runtime +set +m echo "$PID" > "$PID_FILE" disown 2>/dev/null || true diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index 45632ef2..8c20c701 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -40,7 +40,9 @@ 8. **result.json + age fallback**: sentinel を持たない agent (agy) 向け。 result.json の mtime が 30 秒以上前なら完了とみなし kill → OK 9. **失敗時 kill**: TIMEOUT / STALLED / EARLY_ERROR (FATAL のみ) / PIDFILE_BAD で - 返るとき、対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する + 返るとき、対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する。対象がプロセス + グループの先頭(`launch-cli.sh` は `set -m` で起動する)なら、グループへ送って + 子プロセスも止める(#584)。監視自身のグループへは送らない Usage: monitor.py target ∈ {codex, agy, both} @@ -483,6 +485,20 @@ def _is_zombie(pid: int) -> bool: return state is not None and "Z" in state +def _leads_own_group(pid: int) -> bool: + """pid がプロセスグループの先頭で、かつ監視自身のグループではないか。 + + `launch-cli.sh` は `set -m` で起動するため CLI の pid = pgid になる。そうでない pid + (古い起動の手順・別の経路)は先頭でないか、監視と同じグループに居る。**監視自身の + グループへ送ると、進行側のシェルまで止まる**(#584 の候補で退けた形)。 + """ + try: + pgid = os.getpgid(pid) + except OSError: + return False + return pgid == pid and pgid != os.getpgrp() + + def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: """対象プロセスに SIGTERM、`sigterm_grace` 秒後も生きていたら SIGKILL。 @@ -490,13 +506,18 @@ def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: だと後から `gh api` 投稿や result.json 書き込みを実行してメインフローと 競合する。失敗扱いで返るときは必ず停止させる。 ゾンビプロセスにはシグナルを送れないためスキップする。 + + 対象がプロセスグループの先頭なら **グループへ送る**(#584 / #729 の決定 10)。pid だけへ + 送ると、CLI の子プロセスが残って止めた後に結果ファイルを書く。生存の確認は先頭の pid で見る。 """ if pid <= 0: return if _is_zombie(pid): return + send = (lambda sig: os.killpg(pid, sig)) if _leads_own_group(pid) else ( + lambda sig: os.kill(pid, sig)) try: - os.kill(pid, signal.SIGTERM) + send(signal.SIGTERM) except OSError: return deadline = time.monotonic() + sigterm_grace @@ -505,7 +526,7 @@ def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: return time.sleep(0.5) try: - os.kill(pid, signal.SIGKILL) + send(signal.SIGKILL) except OSError: pass diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py b/plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py new file mode 100644 index 00000000..abf1e706 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py @@ -0,0 +1,164 @@ +"""起動の手順が CLI を独立したプロセスグループで起動し、監視がグループへ止める(#729 の AC19〜AC21、#584)。 + +偽の CLI(3 秒後に **子プロセス** が結果ファイルを書く bash)を PATH に `codex` の名前で置き、 +`launch-cli.sh` から実際に起動する。監視が pid だけを止めると子が残って結果を書く(#584 の +再現)。グループへ止めれば書かれない。 + +- AC19: 起動した CLI の pid がプロセスグループの番号(pgid = pid) +- AC20: `_kill_pid` で止めて 4 秒待っても結果ファイルが無い +- AC21: 先頭でない pid では `os.killpg` を呼ばず、pid だけへ送る +""" +from __future__ import annotations + +import os +import pathlib +import signal +import subprocess +import time +from unittest import mock + +import pytest + +_HERE = pathlib.Path(__file__).resolve().parent +_LAUNCH_CLI = _HERE.parents[2] / "scripts" / "lib" / "launch-cli.sh" + +# 標準入力(プロンプト)を読み切ってから、子プロセスが 3 秒後に結果ファイルを書く。 +# 親(この script)は `wait` で子を待つので、監視から見て生きている。子を起こした後に +# 印(`.ready`)を書き、テストは印を待ってから止める(子が居ない時点で止めると、 +# グループで止めなくても結果が書かれず、テストが判別しない)。 +FAKE_CLI = """#!/usr/bin/env bash +cat >/dev/null +( sleep 3; echo '{"event": "APPROVE"}' > "$NDF_TEST_RESULT_FILE" ) & +: > "$NDF_TEST_RESULT_FILE.ready" +wait +""" + + +@pytest.fixture() +def launched(tmp_path): + """偽の CLI を起動し、(pid, 結果ファイルのパス) を返す。終わりに残ったプロセスを片付ける。""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "codex" + fake.write_text(FAKE_CLI, encoding="utf-8") + fake.chmod(0o755) + work = tmp_path / "work" + work.mkdir() + tmp_dir = tmp_path / "tmp" + tmp_dir.mkdir() + prompt = tmp_path / "prompt.md" + prompt.write_text("レビューしてください\n", encoding="utf-8") + stem = tmp_dir / "codex-review-pr7" + result = tmp_dir / "codex-review-pr7-result.json" + env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", + NDF_TEST_RESULT_FILE=str(result)) + + proc = subprocess.run( + ["bash", str(_LAUNCH_CLI), "codex", str(work), str(prompt), str(stem)], + capture_output=True, text=True, env=env, timeout=30, + ) + assert proc.returncode == 0, proc.stderr + # 起動の案内の 1 行だけが出る(ジョブ制御の通知が混ざらない) + assert [l for l in proc.stderr.splitlines() if l.strip()] == [ + l for l in proc.stderr.splitlines() if "launched" in l], proc.stderr + pid = int((tmp_dir / "codex-review-pr7.pid").read_text().strip()) + for _ in range(500): + if (tmp_dir / "codex-review-pr7-result.json.ready").exists(): + break + time.sleep(0.01) + assert (tmp_dir / "codex-review-pr7-result.json.ready").exists(), "偽の CLI が子を起こしていない" + try: + yield pid, result + finally: + # **自分のグループへは送らない。** 実装前は CLI がこのテストと同じグループに居るため、 + # 無条件の killpg は pytest 自身を落とす(AC21 が防ぐ事故そのもの)。 + try: + if os.getpgid(pid) == pid and os.getpgid(pid) != os.getpgrp(): + os.killpg(pid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def test_launched_cli_leads_its_own_process_group(launched): + pid, _ = launched + assert _alive(pid) + assert os.getpgid(pid) == pid # AC19 + assert os.getpgid(pid) != os.getpgrp() # 起動元(このテスト)のグループではない + + +def test_killing_the_group_prevents_the_child_from_writing_the_result(launched, monitor_mod): + pid, result = launched + assert not result.exists() + + monitor_mod._kill_pid(pid) + time.sleep(4) + + assert not result.exists() # AC20 + assert not _alive(pid) or monitor_mod._is_zombie(pid) + + +def test_non_leader_pid_is_signalled_alone(monitor_mod): + """先頭でない pid(pgid ≠ pid)は pid だけへ送り、`os.killpg` を呼ばない(AC21)。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch("os.getpgid", return_value=4242), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4243) + killpg.assert_not_called() + kill.assert_called_once_with(4243, signal.SIGTERM) + + +def test_pid_in_the_monitors_own_group_is_signalled_alone(monitor_mod): + """pgid = pid でも監視自身のグループなら `os.killpg` を呼ばない(監視まで止まる)。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch("os.getpgid", return_value=4242), + mock.patch("os.getpgrp", return_value=4242), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4242) + killpg.assert_not_called() + kill.assert_called_once_with(4242, signal.SIGTERM) + + +def test_group_leader_gets_sigterm_then_sigkill_via_killpg(monitor_mod): + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch("os.getpgid", return_value=4242), + mock.patch("os.getpgrp", return_value=1), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4242, sigterm_grace=0.6) + assert killpg.call_args_list == [mock.call(4242, signal.SIGTERM), mock.call(4242, signal.SIGKILL)] + kill.assert_not_called() + + +def test_getpgid_failure_falls_back_to_the_pid(monitor_mod): + """`os.getpgid` が失敗する(もう居ない・権限が無い)ときは従来どおり pid だけへ送る。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch("os.getpgid", side_effect=ProcessLookupError), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4242) + killpg.assert_not_called() + kill.assert_called_once_with(4242, signal.SIGTERM) From 2b25e58a8f9d27ff55bd510766f4a83d5f9a4e63 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:26:08 +0000 Subject: [PATCH 026/217] =?UTF-8?q?Fix:=20=E8=AA=A4=E3=82=8A=E3=82=92?= =?UTF-8?q?=E7=A4=BA=E3=81=95=E3=82=8C=E3=81=A6=E3=81=84=E3=81=AA=E3=81=84?= =?UTF-8?q?=E9=87=8D=E5=A4=A7=E3=81=AA=E6=8C=87=E6=91=98=E3=82=92=E6=9C=AA?= =?UTF-8?q?=E5=8F=8D=E8=A8=BC=E3=81=A8=E3=81=97=E3=81=A6=E6=95=B0=E3=81=88?= =?UTF-8?q?=E3=80=81=E5=8F=8D=E8=A8=BC=E3=81=8C=E6=8F=83=E3=82=8F=E3=81=AA?= =?UTF-8?q?=E3=81=84=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=E3=81=AE=E5=8D=B0?= =?UTF-8?q?=E3=82=92=E5=A4=96=E3=81=99=EF=BC=88#732=20#624=20#706=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 収束の判定が数えないのは、誤りだと示された棄却と軽微な指摘だけにする。 人の判断待ちの条件から根拠の 2 項目を外し、重大な指摘の残余を新しい区分 「未反証」(unrefuted)として数え、理由(no_critique / not_supported)を 状態ファイルに残す。測定スクリプトの数える集合も同じ 3 区分に揃え、一致を テストで固定する。反証が揃わない取り込みでは先に付いていた印を外す。 収束の判定の本体・終了コード・出力の変数は変えない。 Co-Authored-By: Claude Fable 5.1 --- .../skills/cross-review/scripts/measure.py | 7 +- .../ndf/skills/cross-review/scripts/state.py | 66 ++++-- .../tests/test_classify_findings.py | 196 +++++++++++++++++- .../cross-review/tests/test_critiques.py | 45 ++++ .../skills/cross-review/tests/test_measure.py | 21 +- 5 files changed, 303 insertions(+), 32 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/measure.py b/plugins/ndf/skills/cross-review/scripts/measure.py index 9fa53bf8..ba521c43 100755 --- a/plugins/ndf/skills/cross-review/scripts/measure.py +++ b/plugins/ndf/skills/cross-review/scripts/measure.py @@ -416,9 +416,10 @@ def _majority(representatives: list[dict[str, Any]], return _method_output(finding_ids, oracle_ids) -# 3 本目の区分のうち、この変更の方式が採る 2 つ(`state.py` の -# `COUNTED_CLASSIFICATIONS` と同じ)。**残る 3 つは採らない。** -COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment") +# 3 本目の区分(#732 で 6 つ)のうち、この変更の方式が採る 3 つ(`state.py` の +# `COUNTED_CLASSIFICATIONS` と同じ。一致は `test_measure.py` が固定する)。 +# **残る 3 つは採らない。** +COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment", "unrefuted") def _evidence_rounds(st: dict[str, Any]) -> set[int]: diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index f0a02e60..9ee5c2ac 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -3384,11 +3384,16 @@ def _handle_incomplete_critiques( ) -> None: """有効な反証が揃わなかったラウンドの扱い(#549 レビュー対応)。 - **印は付けない。** 印の無いラウンドは従来どおり全件を数えるため、未検証の `major` - が区分の絞り込みで落ちて収束することがない。**取り直しは同じラウンドで 1 度だけ + **印は付けず、先に付いていた印は外す**(#732)。印の無いラウンドは従来どおり全件を + 数えるため、反証が届いていない `major` が区分の絞り込みで落ちて収束することがない。 + 取り直しの後もそのラウンドに印が残ると、「印を付けないため、このラウンドは全件を + 数えます」の出力と実際の数え方が食い違う。**取り直しは同じラウンドで 1 度だけ である**(`judge` の結果なしと同じ作法。2 度続けて揃わないのは対象ではなく実行 環境の側の事象であり、そのときも印を付けないまま工程を進める)。 """ + st["evidence_rounds"] = [ + r for r in st.get("evidence_rounds") or [] + if not _same_round_no(r, round_no)] entry = next( (r for r in st.get("rounds") or [] if r.get("round") == round_no), None) relaunched = list((entry or {}).get("critique_relaunched") or []) @@ -3408,6 +3413,15 @@ def _handle_incomplete_critiques( sys.exit(7) +def _same_round_no(value: Any, round_no: int) -> bool: + """印の番号がそのラウンドを指すか。**番号の読み方は `_evidence_completed` と同じ** + (`int` へ換算して比べ、旧い状態ファイルの文字列の番号も同じラウンドとして読む)。""" + try: + return int(value) == int(round_no) + except (TypeError, ValueError): + return False + + # 実行の結果の強さ。**組から選び直すときの順である。** _VERIFY_RANK = {"reproduced": 2, "not_reproduced": 1, "not_run": 0} @@ -3426,9 +3440,10 @@ def _declared_duplicate_targets(finding: dict[str, Any]) -> set: return targets -# 収束の判定が数える区分(#156)。**残る 3 つは数えない。** 棄却した指摘を数えると、 -# そのぶんラウンドが増える(#69 で同じ論点が 5 ラウンド続いた事象)。 -COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment") +# 収束の判定が数える区分(#156、#732)。**残る 3 つは数えない。** 数えないのは、誤りだと +# 示された棄却と、承認を妨げない軽微な指摘だけである。棄却した指摘を数えると、そのぶん +# ラウンドが増える(#69 で同じ論点が 5 ラウンド続いた事象)。 +COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment", "unrefuted") def _verdicts(finding: dict[str, Any], verdict: str) -> list[str]: @@ -3441,11 +3456,16 @@ def _verdicts(finding: dict[str, Any], verdict: str) -> list[str]: def _classify_finding(finding: dict[str, Any]) -> str: - """1 件の指摘を 5 つの区分のいずれかへ分ける(#156)。 + """1 件の指摘を 6 つの区分のいずれかへ分ける(#156、#732)。 **上から順に見て、最初に当たった区分を採る。** 実行で再現した指摘を先に採ることで、 「実行の結果を担当の支持より先に見る」を順序そのもので表す。順 3 を先に置くと、 機械が再現した事実を担当の再評価が覆す。 + + **数えない側へ落とすのは、棄却(順 3)と `minor` 以下(順 6)だけである。** 誰にも + 誤りを示されていない `major` は、反証の有無・担当の数・根拠の 2 項目の有無によらず + `unrefuted`(順 5)として数える。「立証できない」「範囲外」は誤りだという主張では + ない(#706)。担当 1 者で反証する相手がいない指摘も同じである(#624)。 """ result = _verify_result(finding) major = _SEVERITY_RANK.get(str(finding.get("severity")), -1) >= _SEVERITY_RANK["major"] @@ -3456,18 +3476,32 @@ def _classify_finding(finding: dict[str, Any]) -> str: if result == "not_reproduced" or _verdicts(finding, "refute"): return "rejected" # **`minor` 以下は数えない。** 支持が 1 件付いただけでラウンドが増えるのを避ける。 - if finding.get("has_evidence") and major and ( - _verdicts(finding, "support") - or len(finding.get("origin_runtimes") or []) >= 2 - ): + if not major: + return "insufficient_evidence" + # **根拠の 2 項目は見ない。** 別の担当が支持した、または 2 者が独立に出した時点で + # 「確かめる」目的は果たされている(#706 で支持つきの 2 件が根拠の欠けで落ちた)。 + if _verdicts(finding, "support") or len(finding.get("origin_runtimes") or []) >= 2: return "needs_human_judgment" - return "insufficient_evidence" + return "unrefuted" + + +def _unrefuted_reason(finding: dict[str, Any]) -> str: + """なぜ独立に確かめられていないか。**反証の記録は提案者以外の値だけを持つ。** + + 空は「反証を返した担当が 0 者」を表す(`no_critique`)。1 件以上あれば、反証は + あるが支持も否定も無い(`not_supported`)。 + """ + return "not_supported" if finding.get("critiques") else "no_critique" def _apply_classification(finding: dict[str, Any]) -> str: - """区分を決めて要素へ書く。**棄却したものには理由を残す。**""" + """区分を決めて要素へ書く。**棄却と未反証には理由を残し、他の区分では消す。**""" classification = _classify_finding(finding) finding["classification"] = classification + if classification == "unrefuted": + finding["unrefuted_reason"] = _unrefuted_reason(finding) + else: + finding.pop("unrefuted_reason", None) if classification != "rejected": finding.pop("rejection_reason", None) return classification @@ -3492,9 +3526,9 @@ def _apply_classification(finding: dict[str, Any]) -> str: def _counted_finding_ids(st: dict[str, Any], round_no: int) -> list[str]: """新規性が数える指摘の `finding_id`(#156)。 - **数えるのは `verified_blocking` と `needs_human_judgment` だけである。** - 棄却した指摘を数えると、そのぶんラウンドが増える(#69 で同じ論点が 5 ラウンド - 続いた事象)。どちらも `major` 以上で、修正の工程へ渡る。 + **数えるのは `verified_blocking` と `needs_human_judgment` と `unrefuted` の 3 つ + である。** 棄却した指摘を数えると、そのぶんラウンドが増える(#69 で同じ論点が + 5 ラウンド続いた事象)。いずれも `major` 以上で、修正の工程へ渡る。 """ ids: list[str] = [] for finding in st.get("review_findings") or []: @@ -3742,7 +3776,7 @@ def _new_finding_count(st: dict[str, Any], pr: int) -> tuple[int, bool]: # 「測れなかった」と扱うと、元の REQUEST_CHANGES のまま終わらない。 if not curr: return 0, False - # **証拠集約を通ったラウンドだけを、数える 2 つへ絞る**(#156)。通っていない + # **証拠集約を通ったラウンドだけを、数える 3 つへ絞る**(#156、#732)。通っていない # ラウンドは従来どおり全件を数える(旧い状態ファイルと、3 本目より前に開いた # ラウンドがこれに当たる)。**`review_findings` の有無では判定しない** # (旧版でも取り込みの時点で積まれるため、区分も検証結果も持たない旧いラウンドが diff --git a/plugins/ndf/skills/cross-review/tests/test_classify_findings.py b/plugins/ndf/skills/cross-review/tests/test_classify_findings.py index 394b1f08..8207c248 100644 --- a/plugins/ndf/skills/cross-review/tests/test_classify_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_classify_findings.py @@ -100,16 +100,42 @@ def test_a_supported_minor_is_not_judged(state_mod): )) == "insufficient_evidence" -def test_support_without_evidence_is_insufficient(state_mod): +def test_support_without_evidence_needs_judgment(state_mod): + """**支持が付いた `major` は、根拠の 2 項目を欠いても人の判断待ちである**(#706。実測 F)。 + + 別の担当が支持を返した時点で「確かめる」目的は果たされている。根拠の欠けで落とすと、 + 2 人が同じことを言っている情報が判定に効かない。 + """ assert classify(state_mod, _finding( has_evidence=False, critiques=[_critique("kiro", "support")], - )) == "insufficient_evidence" + )) == "needs_human_judgment" + + +def test_two_proposers_are_enough_without_evidence(state_mod): + """2 者が独立に出した `major` は、根拠の 2 項目を欠いても人の判断待ちである(実測 H)。""" + assert classify(state_mod, _finding( + has_evidence=False, origin_runtimes=["codex", "kiro"], + )) == "needs_human_judgment" + + +# ---------- 順 5: 未反証(#732 #624 #706) ---------- +def test_a_major_nothing_matched_is_unrefuted(state_mod): + """誰にも誤りを示されていない `major` は数える側へ入る(実測 B)。""" + assert classify(state_mod, _finding()) == "unrefuted" -# ---------- 順 5 ---------- -def test_nothing_matched_is_insufficient(state_mod): - assert classify(state_mod, _finding()) == "insufficient_evidence" +def test_a_lone_major_with_evidence_is_unrefuted(state_mod): + """担当 1 者で反証する相手がいない `major` は未反証である(#624。実測 A)。""" + assert classify(state_mod, _finding(has_evidence=True)) == "unrefuted" + + +@pytest.mark.parametrize("verdict", ["insufficient_evidence", "out_of_scope"]) +def test_a_major_the_other_could_not_verify_is_unrefuted(state_mod, verdict): + """**「立証できない」「範囲外」は誤りだという主張ではない**(#706。実測 D・E)。""" + assert classify(state_mod, _finding( + has_evidence=True, critiques=[_critique("kiro", verdict)], + )) == "unrefuted" def test_not_run_is_not_the_same_as_not_reproduced(state_mod): @@ -123,7 +149,56 @@ def test_not_run_is_not_the_same_as_not_reproduced(state_mod): def test_a_finding_without_verification_is_readable(state_mod): f = _finding() del f["verification"] - assert classify(state_mod, f) == "insufficient_evidence" + assert classify(state_mod, f) == "unrefuted" + + +# ---------- 順 6: 立証不足(軽微な指摘の残余) ---------- + +def test_a_lone_minor_with_evidence_is_insufficient(state_mod): + """**`minor` 以下は反証の有無によらず数えない**(実測 C)。""" + assert classify(state_mod, _finding( + severity="minor", has_evidence=True)) == "insufficient_evidence" + + +# ---------- 未反証の理由 ---------- + +def test_an_unrefuted_finding_without_critiques_says_no_critique(state_mod): + f = _finding(has_evidence=True) + state_mod._apply_classification(f) + assert f["classification"] == "unrefuted" + assert f["unrefuted_reason"] == "no_critique" + + +def test_an_unrefuted_finding_with_critiques_says_not_supported(state_mod): + f = _finding(has_evidence=True, critiques=[_critique("kiro", "insufficient_evidence")]) + state_mod._apply_classification(f) + assert f["classification"] == "unrefuted" + assert f["unrefuted_reason"] == "not_supported" + + +def test_the_unrefuted_reason_is_dropped_when_the_classification_changes(state_mod): + """**理由は区分が未反証のときだけ存在する**(棄却の理由と同じ扱い)。""" + f = _finding(has_evidence=True) + state_mod._apply_classification(f) + assert f["unrefuted_reason"] == "no_critique" + + f["critiques"] = [_critique("kiro", "refute")] + state_mod._apply_classification(f) + + assert f["classification"] == "rejected" + assert "unrefuted_reason" not in f + assert "rejection_reason" in f + + +def test_other_classifications_carry_no_unrefuted_reason(state_mod): + for f in ( + _finding(verification=_verified("reproduced")), + _finding(has_evidence=True, critiques=[_critique("kiro", "support")]), + _finding(severity="minor"), + ): + state_mod._apply_classification(f) + assert f["classification"] != "unrefuted" + assert "unrefuted_reason" not in f # ---------- 棄却の理由 ---------- @@ -164,8 +239,11 @@ def counted(state_mod, findings, round_no=1): return state_mod._counted_finding_ids(st, round_no) -def test_only_two_classifications_are_counted(state_mod): - """**`rejected` と `insufficient_evidence` は数えない。**""" +def test_only_three_classifications_are_counted(state_mod): + """**数えないのは棄却(`rejected`)と軽微な指摘だけである**(#732)。 + + 誰にも誤りを示されていない `major`(`d`。未反証)は数える。 + """ out = counted(state_mod, [ _finding(finding_id="a", verification=_verified("reproduced")), _finding(finding_id="b", has_evidence=True, @@ -174,8 +252,9 @@ def test_only_two_classifications_are_counted(state_mod): _finding(finding_id="d"), _finding(finding_id="e", severity="minor", verification=_verified("reproduced")), + _finding(finding_id="f", severity="minor"), ]) - assert sorted(out) == ["a", "b"] + assert sorted(out) == ["a", "b", "d"] def test_a_merged_side_is_not_counted(state_mod): @@ -321,3 +400,102 @@ def test_measurability_is_decided_before_narrowing(state_mod, tmp_path, monkeypa assert measurable is True # 読めている assert count == 0 # 数える区分が 0 件 + + +# ---------- 担当 1 者・起動し直した担当の指摘を数える(#732 #624 #706) ---------- + +def _judge_rc(state_mod, pr): + import argparse + with pytest.raises(SystemExit) as e: + state_mod.cmd_judge(argparse.Namespace(pr=pr)) + return e.value.code + + +def _single_reviewer_state(tmp_path, finding, intent="REQUEST_CHANGES"): + """担当 1 者(`only: "codex"`)で印の付いたラウンドが 1 つある状態ファイル。 + + 担当 1 者は `only` で表す(`_round_reviewers` が最初に読む値)。判定が読めるよう、 + 状態ファイルと担当の payload を `CROSS_REVIEW_TMP_DIR` へ書く。 + """ + import json + st = { + "current_pr": 1, "repo": "o/r", "max_rounds": 12, "rotate_after": 8, + "only": "codex", "host": "claude", + "rounds": [{"round": 1, "pr": 1, "started_at": "2026-09-19T00:00:00+00:00", + "codex": {"intent": intent, + "by_severity": {finding["severity"]: 1}}}], + "evidence_rounds": [1], + "review_findings": [finding], + "deferred_nits": [], "carried_over": None, "final": None, + } + (tmp_path / "cross-review-pr1-state.json").write_text(json.dumps(st)) + _payload(tmp_path, "codex", 1, 1, [ + {"path": finding["path"], "line": finding["line"], "body": finding["body"], + "severity": finding["severity"]}]) + return st + + +def test_a_single_reviewer_unrefuted_major_is_counted(state_mod, tmp_path, monkeypatch): + """AC1: 反証する相手のいない `major` を数え、収束させない(#624 の再現)。 + + 変更前は `(0, True)` で新規 0 件となり、`REQUEST_CHANGES` のまま終了コード 0 + (承認)で終わっていた。 + """ + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + st = _single_reviewer_state(tmp_path, _finding(has_evidence=True)) + + assert state_mod._new_finding_count(st, 1) == (1, True) + assert st["review_findings"][0]["classification"] == "unrefuted" + assert _judge_rc(state_mod, 1) == 2 + + +def test_a_single_reviewer_major_without_evidence_is_still_counted( + state_mod, tmp_path, monkeypatch): + """AC2: 根拠の 2 項目を欠いても数える。`has_evidence` の値は残る。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + st = _single_reviewer_state(tmp_path, _finding(has_evidence=False)) + + assert state_mod._new_finding_count(st, 1) == (1, True) + assert st["review_findings"][0]["classification"] == "unrefuted" + assert st["review_findings"][0]["has_evidence"] is False + assert _judge_rc(state_mod, 1) == 2 + + +def test_a_single_reviewer_minor_is_not_counted(state_mod, tmp_path, monkeypatch): + """AC3: `minor` は担当 1 者でも数えない。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + st = _single_reviewer_state( + tmp_path, _finding(severity="minor", has_evidence=True)) + + assert state_mod._new_finding_count(st, 1) == (0, True) + assert st["review_findings"][0]["classification"] == "insufficient_evidence" + + +def test_a_relaunched_reviewers_major_without_critiques_is_counted( + state_mod, tmp_path, monkeypatch): + """AC4: 反証を取り込んだ後に入った担当の `major` を数える(#583 の収束の部分)。 + + `agy` + `kiro` のラウンドで、`kiro` の指摘には反証(`refute`)が付いて棄却され、 + `agy` の根拠を持つ `major` は反証 0 件のまま入っている。 + """ + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + agy = _finding(finding_id="agy-r1-0", agent="agy", origin_runtimes=["agy"], + path="a.py", line=1, body="x", has_evidence=True) + kiro = _finding(finding_id="kiro-r1-0", agent="kiro", origin_runtimes=["kiro"], + path="b.py", line=2, body="y", has_evidence=True, + critiques=[_critique("agy", "refute")]) + st = { + "current_pr": 1, "repo": "o/r", + "rounds": [{"round": 1, "pr": 1, "reviewers": ["agy", "kiro"]}], + "evidence_rounds": [1], + "review_findings": [agy, kiro], + } + _payload(tmp_path, "agy", 1, 1, [ + {"path": "a.py", "line": 1, "body": "x", "severity": "major"}]) + _payload(tmp_path, "kiro", 1, 1, [ + {"path": "b.py", "line": 2, "body": "y", "severity": "major"}]) + + assert state_mod._new_finding_count(st, 1) == (1, True) + assert agy["classification"] == "unrefuted" + assert agy["unrefuted_reason"] == "no_critique" + assert kiro["classification"] == "rejected" diff --git a/plugins/ndf/skills/cross-review/tests/test_critiques.py b/plugins/ndf/skills/cross-review/tests/test_critiques.py index 80b198f2..56e0d006 100644 --- a/plugins/ndf/skills/cross-review/tests/test_critiques.py +++ b/plugins/ndf/skills/cross-review/tests/test_critiques.py @@ -351,6 +351,51 @@ def test_the_retry_happens_once_per_round(tmp_dir, state_mod): assert st.get("evidence_rounds", []) == [] +def test_an_incomplete_collection_removes_an_existing_marker(tmp_dir, state_mod): + """**反証が揃わない取り込みは、先に付いていた印を外す**(#732 の AC13)。 + + 印が残ると「印を付けないため、このラウンドは全件を数えます」の出力と実際の数え方が + 食い違い、反証が届いていない `major` が区分の絞り込みへ掛かる。 + """ + _write(tmp_dir, _state([_finding("agy-r1-0", "agy")], evidence_rounds=[1])) + (tmp_dir / f"agy-review-pr{PR}-round1-payload.json").write_text(json.dumps( + {"comments": [{"path": "a.py", "line": 1, "body": "x", + "severity": "major"}]})) + + collect(state_mod, expect_rc=7) + + st = _read(tmp_dir) + assert st["evidence_rounds"] == [] + assert state_mod._evidence_completed(st, 1) is False + count, measurable = state_mod._new_finding_count(st, PR) + assert (count, measurable) == (1, True) # payload の全件を数える + + +def test_a_marker_stays_off_after_the_second_incomplete_collection(tmp_dir, state_mod): + """取り直した後も揃わないとき(2 度目)も印は付かない。""" + _write(tmp_dir, _state([_finding("codex-r1-0", "codex")], evidence_rounds=[1])) + + collect(state_mod, expect_rc=7) + collect(state_mod, expect_rc=0) + + st = _read(tmp_dir) + assert st["evidence_rounds"] == [] + assert sorted(st["rounds"][0]["critique_relaunched"]) == ["agy", "kiro"] + + +def test_an_incomplete_collection_keeps_other_rounds_markers(tmp_dir, state_mod): + """外すのはそのラウンドの番号だけである。前のラウンドの印は残る。""" + _write(tmp_dir, _state( + [_finding("codex-r2-0", "codex", round=2)], + rounds=[{"round": 1, "pr": PR}, {"round": 2, "pr": PR}], + evidence_rounds=[1, 2], + )) + + collect(state_mod, expect_rc=7) + + assert _read(tmp_dir)["evidence_rounds"] == [1] + + def test_a_proposer_only_round_is_marked_without_any_file(tmp_dir, state_mod): """**反証の対象が無い担当は不足に数えない。** 全員が提案者なら印が付く。""" _write(tmp_dir, _state([ diff --git a/plugins/ndf/skills/cross-review/tests/test_measure.py b/plugins/ndf/skills/cross-review/tests/test_measure.py index 3d9b9737..28967b5b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_measure.py +++ b/plugins/ndf/skills/cross-review/tests/test_measure.py @@ -573,10 +573,10 @@ def test_proposed_reports_all_rounds_when_every_round_is_marked(measure_mod): "oracle_scope": "all_rounds", "oracle_base": 2} -def test_proposed_takes_only_the_two_counted_classifications(measure_mod): - """採るのは `verified_blocking` と `needs_human_judgment` の 2 つだけである。 +def test_proposed_takes_only_the_three_counted_classifications(measure_mod): + """採るのは `verified_blocking` / `needs_human_judgment` / `unrefuted` の 3 つである(#732)。 - 棄却した指摘と立証できなかった指摘は採らない。 + 棄却した指摘と軽微な指摘(立証不足)は採らない。 """ st = _state( evidence_rounds=[1], @@ -588,10 +588,23 @@ def test_proposed_takes_only_the_two_counted_classifications(measure_mod): classification="insufficient_evidence"), _finding("agy-r1-0", 1, "d.py", 40, agent="agy", classification="needs_human_judgment"), + _finding("agy-r1-1", 1, "e.py", 50, agent="agy", + classification="unrefuted", unrefuted_reason="no_critique"), ], ) - assert measure_mod.measure(st)["methods"]["proposed"]["found"] == 2 + assert measure_mod.measure(st)["methods"]["proposed"]["found"] == 3 + + +def test_the_counted_classifications_match_the_state_script(measure_mod, state_mod): + """**収束の判定と測定は同じ指摘を数える**(#732 の AC11)。 + + 片方だけに `unrefuted` を足すと、判定が数えた指摘を測定が採らず、この方式の再現率が + 実際より低く出る。 + """ + assert measure_mod.COUNTED_CLASSIFICATIONS == state_mod.COUNTED_CLASSIFICATIONS + assert set(measure_mod.COUNTED_CLASSIFICATIONS) == { + "verified_blocking", "needs_human_judgment", "unrefuted"} def test_proposed_ignores_findings_from_unmarked_rounds(measure_mod): From c70ef287beb48526d52f75b0a52cca40d4cbe0e7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:26:08 +0000 Subject: [PATCH 027/217] =?UTF-8?q?Docs:=20=E5=8F=8D=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=B3=E3=83=97=E3=83=88=E3=81=A8=E8=A6=8F?= =?UTF-8?q?=E7=B4=84=E3=83=BB=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=82=92?= =?UTF-8?q?=206=20=E5=8C=BA=E5=88=86=E3=81=AB=E6=8F=83=E3=81=88=E3=80=81?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E8=A8=88=E7=94=BB=E3=82=92=E7=BD=AE=E3=81=8F?= =?UTF-8?q?=EF=BC=88#732=20#624=20#706=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 反証のプロンプトに「立証できないと返しても指摘は数から落ちない。誤りを 示せるなら否定を返す」を書く。規約 3 文書と確定仕様の区分の表を 6 行にし、 数えるのは 3 つと書き、揃わないときに印を外すことを足す。実装計画を issues/ に置き、設計文書の「未確認のまま残ること」を実装で決めた結果で更新する。 Co-Authored-By: Claude Fable 5.1 --- .../cross-review-evidence-based.md | 53 +++--- issues/issue-732-624-706-design.md | 6 +- issues/issue-732-624-706-plan.md | 162 ++++++++++++++++++ .../skills/cross-review/docs/04-contracts.md | 26 ++- .../docs/05-pool-and-convergence.md | 8 + .../skills/cross-review/docs/06-evidence.md | 59 ++++--- .../skills/cross-review/scripts/critique.sh | 4 + .../cross-review/tests/test_skill_layout.py | 30 ++++ 8 files changed, 292 insertions(+), 56 deletions(-) create mode 100644 issues/issue-732-624-706-plan.md diff --git a/docs/specifications/cross-review-evidence-based.md b/docs/specifications/cross-review-evidence-based.md index 6cde1da2..0e2983d9 100644 --- a/docs/specifications/cross-review-evidence-based.md +++ b/docs/specifications/cross-review-evidence-based.md @@ -17,8 +17,9 @@ **進行側は、担当の再評価より先に検証手順を実行する。** 機械が再現した事実は、担当の支持 より確かである。 -**指摘は 5 つの区分へ分かれ、収束の判定は担当の判定(`event`)ではなく区分を見る。** -数えるのは `verified_blocking` と `needs_human_judgment` の 2 つだけである。 +**指摘は 6 つの区分へ分かれ、収束の判定は担当の判定(`event`)ではなく区分を見る。** +数えるのは `verified_blocking` と `needs_human_judgment` と `unrefuted` の 3 つである。数えない +のは、誤りだと示された棄却と、承認を妨げない `minor` 以下の指摘だけである。 **却下した指摘は、位置・重要度・理由とともにラウンドをまたいで残る。** 次のラウンドの レビュープロンプトへ渡り、同じ論点が戻ることを止める。 @@ -37,7 +38,7 @@ | 反証条件 | 何が成り立てば棄却できるか(`falsification`) | | 検証手順 | 実行できる形で書いた確かめ方(`suggested_check`) | | 反証 | 提案者以外の担当が、各指摘へ返す 1 つの値 | -| 区分 | 1 件の指摘を分ける 5 つの分類(`classification`) | +| 区分 | 1 件の指摘を分ける 6 つの分類(`classification`) | | 方式 | 効果の測定で指摘を採る規則。`single` / `majority` / `proposed` / `oracle` の 4 つ | | 代表 | 統合した組で、判定が読む 1 件。束ねられた側は `merged_into` を持つ | | 印 | そのラウンドが統合・実行検証・反証を通ったこと(`evidence_rounds`) | @@ -82,9 +83,9 @@ | 反証は新しいラウンドを足さず、同じラウンドの中で回す | ラウンド数が 2 倍になり、収束の上限(12)の意味が変わる | | 申告による統合(2 段目)は次のラウンドへ回さない | 回すと、同じ主張を 2 者が別の本文で出した組が、統合される前に `insufficient_evidence` へ落ちて収束する | | 反証の値は担当ごとに置き換え、積み増さない | 取り直したときに古い値が残る。`refute` を `support` へ訂正しても両方が並び、区分の順で `refute` が先に当たって指摘が `rejected` のままになる | -| 収束の判定が数えるのは 2 区分だけである | 棄却した指摘と `minor` の指摘を数えると、そのぶんラウンドが増える(#69 の 5 ラウンド) | +| 数えないのは棄却した指摘と `minor` の指摘だけである(誤りを示されていない `major` は `unrefuted` として数える) | 棄却した指摘と `minor` の指摘を数えると、そのぶんラウンドが増える(#69 の 5 ラウンド)。誤りを示されていない `major` を数えないと、未解決の `major` を残して承認で収束する(#624 #706) | | 測れたかどうかは、区分で絞る**前**に決める | 絞った後の集合へ「空なら測れない」を適用すると、全件を棄却したラウンドが「測れなかった」ことになり、元の判定のままループが終わらない | -| 印(`evidence_rounds`)で母集合を決め、`review_findings` の有無では判定しない | 取り込みはこの変更より前から要素を積む。存在で判定すると、区分も検証結果も持たない旧いラウンドが絞り込みに掛かり、修正必須の `major` が落ちて収束する | +| 印(`evidence_rounds`)で母集合を決め、`review_findings` の有無では判定しない | 印の役割は、取り込みだけを済ませた旧いラウンドと反証が届いていないラウンドを、棄却と `minor` も含めて全件を数える側に置くことである。存在で判定すると、区分も検証結果も持たない旧いラウンドが絞り込みに掛かる。反証が揃わないときは印を付けず、先に付いていた印も外す | | 印を付けるのは経路の最後で、対象ごとに有効な反証が揃ったときだけである | 途中で付けると反証を結ぶ前の値で数える。結果ファイルの欠落でも付けると、未検証のまま収束する | | `needs_human_judgment` を人へのエスカレーションにしない | 収束のループはこの工程の中で回っており、止めて人を待つと自動で進まなくなる。決めるのは修正の担当で、その判断は却下の記録へ残る | | 振動の検知(一致の判定・閾値 0.5)は変えない | 母集合は指摘の構造化で既に広がっている。判定式まで同時に変えると、ラウンド数が動いたときにどちらが原因かを切り分けられない | @@ -106,7 +107,7 @@ - **束ねられた側は消さない。** `merged_into` を書いて残す。消すと、反証の結果がその指摘を 指したときに結び先を失う。判定・区分・測定はいずれも代表だけを数える - **実行できなかったこと(`not_run`)と、再現しなかったこと(`not_reproduced`)を同じに - しない。** 前者は区分の順 3 の前半に当たらず、区分は反証と根拠で決まる + しない。** 前者は区分の順 3 の前半に当たらず、区分は重大度と反証で決まる - **`ran_at` は実行した記録にだけ入る。** 実行しなかった記録では `exit_code` とともに `null` である。時刻が残ると、実行済みと見分けられない - **1 つの(ラウンド, 指摘, 担当)が持つ反証は 1 つである** @@ -189,7 +190,7 @@ ### 実行検証 **実行してよいのは、起動した側が渡したコマンドだけである。** 渡されなければ実行検証を -行わず、区分は根拠と反証で決まる。**新しい実行系は導入しない。** +行わず、区分は重大度と反証で決まる。**新しい実行系は導入しない。** | 守ること | なぜ | | --- | --- | @@ -231,24 +232,29 @@ **上から順に見て、最初に当たった区分を採る。** -| 順 | 区分 | 条件 | -| --- | --- | --- | -| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | -| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | -| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | -| 4 | `needs_human_judgment` | 根拠を持ち、`major` 以上で、`support` が 1 件以上**または** `origin_runtimes` が 2 者以上 | -| 5 | `insufficient_evidence` | 上のいずれにも当たらない | +| 順 | 区分 | 条件 | 数える | 理由の項目 | +| --- | --- | --- | --- | --- | +| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | +| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | +| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | +| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上**または** `origin_runtimes` が 2 者以上 | はい | — | +| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason`(`no_critique` / `not_supported`) | +| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | **順 3 を順 1・2 より先に置かない。** 置くと、機械が再現した事実を担当の再評価が覆す。 **独立に到達した担当の数を、支持と並べて数える。** 担当は 2 者であるため、2 者が同じ指摘を 独立に出すと提案者以外が 1 人も残らず、支持は必ず 0 件になる。支持の数だけを見ると、最も -強い一致である全員一致が `insufficient_evidence` へ落ちて収束する。 +強い一致である全員一致が、誰も確かめていない指摘と同じ `unrefuted` になり、独立に到達した +事実が記録に残らない。 **順 4 の分岐は、ラウンドの担当が 2 者であることに支えられている。** 担当を 3 者以上へ 広げると、単一の `refute` が順 3 で全員一致を覆す。広げるときに順 3 と順 4 の順序を決め直す。 -**棄却には理由を残す**(実行の結果か、`refute` の理由)。 +**棄却と未反証には理由を残す。** 棄却は実行の結果か `refute` の理由、未反証はなぜ独立に +確かめられていないか(反証を返した担当が 0 者なら `no_critique`、反証はあるが支持も否定も +無ければ `not_supported`)である。「立証できない」「範囲外」は誤りだという主張ではないため、 +その反証を受けた `major` は数から落とさない。 ### 区分ごとの行き先 @@ -256,6 +262,7 @@ | --- | --- | --- | | `verified_blocking` | 渡す | 直す。機械が再現しているため判断の余地は無い | | `needs_human_judgment` | 渡す | **修正の担当が読んで決める。** 直す・却下の理由を返す・範囲外として起票する | +| `unrefuted` | 渡す | 同上。理由(`unrefuted_reason`)から、誰も見ていないのか相手が確かめられなかったのかを読める | | `verified_non_blocking` | 渡さない | 最終スイープ。再現した事実は記録に残る | | `insufficient_evidence` | 渡さない | 同上 | | `rejected` | 渡さない | 棄却の理由が残り、次のラウンドのレビュープロンプトへ渡る | @@ -270,8 +277,8 @@ | 状態 | 返る値 | | --- | --- | | 指摘の記録を読めない | `(0, 測れない)`。従来の判定(全員が pass か)へ落ちる | -| 記録はあるが、数える 2 区分が 0 件 | **`(0, 測れた)`。収束させる** | -| 記録があり、数える 2 区分に一致しない指摘がある | `(件数, 測れた)` | +| 記録はあるが、数える 3 区分が 0 件 | **`(0, 測れた)`。収束させる** | +| 記録があり、数える 3 区分に一致しない指摘がある | `(件数, 測れた)` | **記録が読めることと、数える対象があることは別である。** 全件を棄却したラウンドは「新しい 修正必須の指摘が 0 件」であって、測れなかったラウンドではない。 @@ -288,7 +295,7 @@ | --- | --- | | `single` | 1 者だけの結果。担当ごとに 1 通り出す | | `majority` | `origin_runtimes` が 2 者以上の指摘 | -| `proposed` | 区分が `verified_blocking` または `needs_human_judgment` の指摘 | +| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘。状態の管理スクリプトが数える集合と同じで、一致をテストで固定する | | `oracle` | いずれかの担当が出した指摘のうち、**修正された**もの | **`origin_runtimes` を持たない指摘は、取り込み時の担当 1 者として読む。** この値は統合の @@ -347,7 +354,7 @@ | `origin_runtimes` / `merged_from` / `merged_into` / `evidence_from` / `duplicate_candidates` | 統合 | | `verification` | 実行検証。`command` / `exit_code` / `result` / `finding_id` / `ran_at` | | `critiques` | 反証。要素は `agent` / `verdict` / `reason`(`duplicate` のときは `duplicate_of`) | -| `classification` / `rejection_reason` | 区分 | +| `classification` / `rejection_reason` / `unrefuted_reason` | 区分。理由の 2 つはその区分のときだけ持つ | **識別子は取り込みの時点で採番する。** 形は `<担当>-r<ラウンド>-<索引>` で、索引はその担当の `payload.json` の並びである。同じ組の取り込みは入れ替えであるため、再実行しても同じ指摘へ @@ -413,10 +420,10 @@ measure.py <状態ファイルのパス> [--output <パス>] | 提案者以外だけが賛否を返し、5 つの値が記録されること | 同 `tests/test_critiques.py` | | 結び先の無い反証が残ること | 同上 | | 反証が揃わないラウンドに印が付かず、1 度だけ取り直すこと | 同上 | -| 5 つの区分へ分かれ、棄却に理由が残ること | 同 `tests/test_classify_findings.py` | +| 6 つの区分へ分かれ、棄却と未反証に理由が残ること | 同 `tests/test_classify_findings.py` | | 再現した指摘が反証があっても棄却されず、再現しない指摘が支持が多くても棄却されること | 同上 | | 全員一致の指摘が収束しないこと | 同上 | -| 新規性が 2 区分だけを数え、全件を棄却したラウンドが収束すること | 同上 | +| 新規性が 3 区分を数え、全件を棄却したラウンドが収束すること | 同上 | | 走らせる順序が手順と実装で揃っていること | 同 `tests/test_findings_pipeline_wiring.py` | | 4 つの方式が同じ記録から出て、統合された指摘を 2 回数えないこと | 同 `tests/test_measure.py` | | `origin_runtimes` を持たない記録でも担当別の件数が残ること | 同上 | @@ -431,7 +438,7 @@ measure.py <状態ファイルのパス> [--output <パス>] ## 運用 **実行検証を使うかは、起動する側が決める。** 引数を渡さないリポジトリでは実行検証が走らず、 -区分は根拠と反証で決まる。既定の一覧は持たない(リポジトリによってテストの起動が違う)。 +区分は重大度と反証で決まる。既定の一覧は持たない(リポジトリによってテストの起動が違う)。 **印を持たない記録では `proposed` を計算できず、位置の記録を持たない記録では `oracle` と 再現率を計算できない。** この変更より前に回した Pull Request が該当する。変更の前後を diff --git a/issues/issue-732-624-706-design.md b/issues/issue-732-624-706-design.md index ffd65d26..f0718089 100644 --- a/issues/issue-732-624-706-design.md +++ b/issues/issue-732-624-706-design.md @@ -331,7 +331,7 @@ graph TD ## 未確認のまま残ること -6 件である。実装で決めるもの 2 件(テストの置き場所、プロンプトの文言)と、配布後の運用か別の課題で決まるもの 4 件に分かれる。 +6 件である。実装で決めたもの 2 件(テストの置き場所、プロンプトの文言)と、配布後の運用か別の課題で決まるもの 4 件に分かれる。 | 項目 | 内容 | いつ決まるか | | --- | --- | --- | @@ -339,8 +339,8 @@ graph TD | 未反証が多いときの修正の担当の負荷 | 誰も確かめていない重大な指摘が修正の工程へ渡る件数が増える。却下の理由を書く回数が増える | 同上 | | 担当を 3 者以上へ広げたときの順 3 と順 4 | 確定仕様が「広げるときに決め直す」としている。この変更は 2 者のまま | #478 の後 | | 立証不足の区分の改名 | 決定 6 で残す。意味が狭まった名前をいつ付け替えるかは未決 | 要求が出たとき | -| テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | -| プロンプトの文言 | 決定 9 の段落は、含める 2 つの内容だけを決めた | **実装で決める** | +| テストの置き場所 | テスト設計の表のとおりに置いた。区分の単体と AC1〜AC4・AC10 は `test_classify_findings.py`、AC11・AC12 は `test_measure.py`、AC13 は `test_critiques.py`、AC18〜AC20 は `test_skill_layout.py` | 実装で決めた(実装 Pull Request) | +| プロンプトの文言 | 「返す値」の表の下に 3 文を置いた。「立証できない」を返しても指摘は数から落ちず未反証として修正の工程へ渡ること、誤りを示せるなら何がそう言えるかを理由へ書いて否定を返すこと、指摘を数から落とす手段は否定だけであること | 実装で決めた(実装 Pull Request) | ## 申し送り(並行する設計との境界) diff --git a/issues/issue-732-624-706-plan.md b/issues/issue-732-624-706-plan.md new file mode 100644 index 00000000..393859fe --- /dev/null +++ b/issues/issue-732-624-706-plan.md @@ -0,0 +1,162 @@ +# cross-review: 誤りを示されていない重大な指摘が数えられずに承認で終わる → 数えない指摘を棄却と軽微な指摘に限る(#732 #624 #706 の実装計画) + +## 関連リンク + +| 文書 | 何を持つか | +| --- | --- | +| [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) | 何を満たすか(受け入れ条件 AC1〜AC22) | +| [issue-732-624-706-design.md](issue-732-624-706-design.md) | どう作るか(決定 1〜11、区分の条件、入出力の契約、テスト設計)。**業務用語と識別子の対応表はこの文書の冒頭にある** | +| 親 #732、子 #624 #706 | 課題。設計 Pull Request は #783 | + +## モード + +`standard`。収束の判定が数える指摘の範囲(本番の振る舞い)を変えるため。 + +## 目的と非目的 + +達成したい状態: + +- 収束の判定が数えないのは、誤りだと示された指摘(棄却)と、承認を妨げない軽微な指摘だけになる +- 誰にも誤りを示されていない重大な指摘は、新しい区分「未反証」として数えられ、修正の工程へ渡る。「なぜ独立に確かめられていないか」の理由が状態ファイルに残る +- 効果の測定の方式「この変更の方式」が、収束の判定と同じ 3 区分を数える +- 反証が揃わなかったラウンドは、先に付いていた印が外れ、全件を数える側へ戻る +- 規約 3 文書と確定仕様が、実装と同じ差分で新しい区分を書く + +やらないこと: + +- 収束の判定の本体・終了コード・標準出力の変数の変更(#729 の束が持つ境界) +- 担当を 1 者へ絞る指定の意味づけ(#727 の束) +- 投稿の重なりの扱い(#730 の束) +- 軽微な指摘を数えること +- 立証不足の区分の改名(設計文書の決定 6) +- 既存の設計文書(`issue-624-478-648-design.md`)の本体の書き換え(設計文書の決定 1) + +## 前提 + +- 前提 1: 反証の記録は提案者以外の担当の値だけを持つ。そのため、記録が空であることは「反証を返した担当が 0 者」と同じである(状態の管理スクリプトの取り込みが提案者自身の値を落とす。既存のテスト `test_a_critique_on_own_finding_is_dropped` が固定する) +- 前提 2: 区分は保存された値を読まず、判定のたびに計算し直す。そのため、この変更より前の状態ファイルに移行の処理は要らない +- 前提 3: 収束の判定の本体を触らずに数え方を変えられる。数える指摘の抽出が数える集合を参照しており、集合の値を変えれば判定の本体は変わらない(`develop` 2b140606 の `_new_finding_count` → `_counted_finding_keys` → `COUNTED_CLASSIFICATIONS` の呼び出しで確認) + +## 受け入れ条件 + +要求文書の AC1〜AC22 をそのまま使う。条件ごとの検証手段は設計文書の「テスト設計」の表にある。この計画では、タスクごとに満たす条件の番号を書く。 + +## ドメイン用語 + +設計文書の「用語の対応表」を正本とし、ここには写さない。本文は業務用語で書き、識別子はコードブロック・表・ファイルの指し示しにだけ使う。 + +## 不変条件 + +- 実行で再現した指摘は、担当の反証によらず「再現した」の区分に入る(順 1・2 が最初に当たる) +- 誤りだと示された指摘(再現しなかった、または否定が 1 件以上)は、重大度によらず棄却になる(順 3 が順 4・5 より先に当たる) +- 軽微な指摘は、反証の有無・支持の有無によらず数えない +- 未反証の理由は、区分が未反証のときだけ存在する。区分が変われば消える(棄却の理由と同じ扱い) +- 数える区分の集合は、状態の管理スクリプトと測定スクリプトで同じ値を持つ + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| 状態の管理スクリプトの引数・終了コード・標準出力の変数 | 変えない | 変えない | +| 状態ファイルの `review_findings[].classification` | 値の集合が 5 つから 6 つになる(`unrefuted` が増える) | 追加のみ。読む側は測定スクリプトだけで、知らない値は「採らない」に落ちる | +| 状態ファイルの `review_findings[].unrefuted_reason` | 新設 | 追加のみ。`version` は上げない。旧い状態ファイルは次の判定で区分が付け直される | +| 反証のプロンプトの返す値の語彙 | 変えない | 説明の段落を足すだけ | + +## 修正対象 + +```text +plugins/ndf/skills/cross-review/ +├── docs/04-contracts.md +├── docs/05-pool-and-convergence.md +├── docs/06-evidence.md +├── scripts/critique.sh +├── scripts/measure.py +├── scripts/state.py +└── tests/ + ├── test_classify_findings.py + ├── test_critiques.py + ├── test_measure.py + └── test_skill_layout.py +docs/specifications/cross-review-evidence-based.md +issues/issue-732-624-706-design.md(「未確認のまま残ること」の 2 行だけ) +``` + +配布物の生成(`bash scripts/build-runtime-plugins.sh`)で変わるファイルがあれば同じ Pull Request に含める。 + +## タスク分解 + +機能単位で分ける。各タスクは、失敗するテスト → 通す最小実装 → 整理の順で進める。 + +### Task 1: 誤りを示されていない重大な指摘を「未反証」として数え、理由を残す + +- **対象ファイル:** `scripts/state.py`(`_classify_finding` / `_apply_classification` / `COUNTED_CLASSIFICATIONS` とその注記)、`tests/test_classify_findings.py` +- **変更内容:** 区分の判定を 6 区分にする。順 4(人の判断待ち)の条件から根拠の有無を外し、順 5 に未反証(重大な指摘の残余)を置き、順 6 を立証不足(軽微な指摘の残余)にする。区分の書き込みは、未反証に理由(反証の記録が空なら `no_critique`、あれば `not_supported`)を書き、他の区分では理由を消す。数える集合に `unrefuted` を足す。関数の docstring と注記の「5 つ」「2 つだけ」を新しい数に合わせる +- **テスト:** 設計文書の実測 A〜K を単体で固定する(A・B・D・E・F・H が変わる 6 件、C・G・I・J・K が変わらない 5 件)。AC1〜AC4 は状態ファイルを組み、新しい指摘の数え上げと収束の判定の終了コードで確かめる(既存の `test_the_new_count_uses_the_classification` の形)。AC10 は理由の値と、区分が変わったときに消えることを見る。期待値を変える既存のテストは AC16 の 4 件だけで、名前を新しい振る舞いに合わせて変える +- **満たす受け入れ条件:** AC1〜AC10、AC14、AC16 +- **進め方:** 失敗するテスト → 最小実装 → 整理 + +### Task 2: 効果の測定の方式「この変更の方式」を、数える 3 区分に揃える + +- **対象ファイル:** `scripts/measure.py`(`COUNTED_CLASSIFICATIONS` とその注記)、`tests/test_measure.py` +- **変更内容:** 測定スクリプトの数える集合に `unrefuted` を足す。注記の「2 つ」「残る 3 つ」を「3 つ」「残る 3 つ」に直す +- **テスト:** 2 つのスクリプトの集合が等しく、3 つの値を持つこと(AC11)。印のあるラウンドの未反証を「この変更の方式」が数えること(AC12。既存の `test_proposed_takes_only_the_two_counted_classifications` を 3 区分へ改める) +- **満たす受け入れ条件:** AC11、AC12 +- **進め方:** 失敗するテスト → 最小実装 → 整理 + +### Task 3: 反証が揃わない取り込みでは、先に付いていた印を外す + +- **対象ファイル:** `scripts/state.py`(`_handle_incomplete_critiques`)、`tests/test_critiques.py` +- **変更内容:** 反証の不足の扱いの先頭で、そのラウンドの番号を印の一覧から除く。取り直す担当を返す動きと終了コード 7 は変えない +- **テスト:** 印の付いた状態で反証の取り込みを 2 回呼び(1 回目は取り直し、2 回目も揃わない)、印が消えていることと、新しい指摘の数え上げがレビュー結果の本体の全件を数えることを見る(AC13) +- **満たす受け入れ条件:** AC13、AC15 +- **進め方:** 失敗するテスト → 最小実装 → 整理 + +### Task 4: 反証のプロンプトに「立証できないと返しても指摘は数から落ちない」を書く + +- **対象ファイル:** `scripts/critique.sh`、`tests/test_skill_layout.py` +- **変更内容:** 「返す値」の表の下に 1 段落を足す。書くのは 2 つ。「立証できない」を返しても指摘は数から落ちず修正の工程へ渡ること、誤りを示せるなら理由を添えて否定を返すこと。返す値の語彙は変えない +- **テスト:** プロンプトの文字列に「数から落ち」が含まれること(AC19) +- **満たす受け入れ条件:** AC19 +- **進め方:** 失敗するテスト → 最小実装 + +### Task 5: 規約 3 文書と確定仕様を、6 区分・数える 3 つへ揃える + +- **対象ファイル:** `docs/04-contracts.md`、`docs/05-pool-and-convergence.md`、`docs/06-evidence.md`、`docs/specifications/cross-review-evidence-based.md`、`tests/test_skill_layout.py` +- **変更内容:** 規約 06 の区分の表を 6 行にし、数えるのは 3 つと書き、反証が揃わないときに印を外すことを書く。規約 04 の区分の項を 6 区分・数える 3 つにし、`unrefuted_reason` の項を足す。規約 05 の終了基準に、担当 1 者と起動し直した担当の指摘の数え方を足す。確定仕様の概要・決定の表・区分の表・行き先の表・収束の判定の表・測定の表・テスト観点の行を揃える(経緯の節は仕様化の工程が足す)。測定の方式の表(規約 06・確定仕様)も 3 区分にする +- **テスト:** AC18 の 4 つの grep と AC20 の grep を配置テストで固定する +- **満たす受け入れ条件:** AC18、AC20 +- **進め方:** 失敗するテスト → 文書の更新。文書は `markdown-writing` の規約で書く(説明文の主語・目的語に識別子を置かない) + +### Task 6: 検証と配布物の同期 + +- **対象ファイル:** 生成物(`bash scripts/build-runtime-plugins.sh` が変えるもの)、`issues/issue-732-624-706-design.md`(「未確認のまま残ること」の「テストの置き場所」「プロンプトの文言」の 2 行を決めた結果で更新) +- **変更内容:** 全体テスト、フロントマターの検査、文書の鮮度・リンク・行数の検査を通す。配布物を同期する +- **満たす受け入れ条件:** AC17(収束の判定の本体の行が差分に無いことを `git diff` で見る)、AC21、AC22 +- **進め方:** コマンドの実行と結果の記録(テスト駆動は当たらない。検証の工程である) + +## 影響範囲 + +- 印のあるラウンドで、反証を受けていない・支持されていない・根拠の項目を欠く重大な指摘が数えられる。2 者で相手が「立証できない」「範囲外」を返した重大な指摘も数える。収束までのラウンド数が増えることがある(指摘 1 件につき最大 1 回) +- 修正の担当が読む指摘に未反証が加わる。扱いは人の判断待ちと同じ(直す・却下の理由を返す・範囲外として起票する) +- 効果の測定の「この変更の方式」の再現率が、収束の判定と同じ母集合で出る + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 状態の管理スクリプトは 3700 行を超える 1 ファイルで、G3(#729)が同じファイルの別の関数を同時に触る | タスクごとにテストを通す。触る関数を区分の判定・書き込み・数える集合・反証の不足の扱いの 4 つに限り、収束の判定の本体の行を書き換えない。競合は後からマージする側が解く | +| 数える集合が 2 か所にあり、片方だけ変わる | Task 2 の一致のテストが固定する | +| 文書の「2 つだけ」「5 つの区分」の記述が残る | Task 5 で `grep -rn "2 つだけ\|2 区分\|5 つの区分" plugins/ndf/skills/cross-review docs/specifications/cross-review-evidence-based.md` を実行し、0 件を確かめる | +| 実装の後の構造改善で足りるか | 触る範囲が狭く(関数 4 つ・定数 2 つ)、区分のテストが 30 件以上ある。構造改善は後の工程(`cross-refactoring`)で足りる | + +## 切り戻し手順 + +- Pull Request の revert で戻せる。状態ファイルの `version` を上げないため、データの移行は無い。戻した後の状態ファイルに残る `unrefuted` / `unrefuted_reason` は、次の判定で区分が付け直されるときに上書き・削除される(区分は毎回計算し直す) + +## 完了の定義 + +- [ ] AC1〜AC22 をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [ ] AC22 の 4 つの検査が終了コード 0 で終わる +- [ ] 収束の判定の本体(`cmd_judge`)の行が差分に含まれない +- [ ] 配布物が同期され、pre-commit の検査が通る diff --git a/plugins/ndf/skills/cross-review/docs/04-contracts.md b/plugins/ndf/skills/cross-review/docs/04-contracts.md index b73c2bf3..d630f829 100644 --- a/plugins/ndf/skills/cross-review/docs/04-contracts.md +++ b/plugins/ndf/skills/cross-review/docs/04-contracts.md @@ -106,17 +106,27 @@ **1 つの `(ラウンド, finding_id, 担当)` が持つ値は 1 つである。** 取り直した反証は 古い値へ積まず置き換える(積むと、`refute` を `support` へ訂正しても両方が並び、 区分の順で `refute` が先に当たって指摘が `rejected` のままになる) -- `review_findings[].classification` — 5 つの区分(#156)。**収束の判定が数えるのは - `verified_blocking` と `needs_human_judgment` の 2 つだけである** +- `review_findings[].classification` — 6 つの区分(#156、#732)。値は `verified_blocking` / + `verified_non_blocking` / `rejected` / `needs_human_judgment` / `unrefuted` / + `insufficient_evidence`。**収束の判定が数えるのは `verified_blocking` と + `needs_human_judgment` と `unrefuted` の 3 つである。** 数えないのは、誤りだと示された + 棄却と、承認を妨げない `minor` 以下だけである +- `review_findings[].unrefuted_reason` — 未反証の理由(#732)。**`classification` が + `unrefuted` のときだけ持つ。** 値は `no_critique`(反証を返した担当が 0 者)/ + `not_supported`(反証はあるが支持も否定も無い)。区分が変わると消える(`rejection_reason` + と同じ扱い) - `unmatched_critiques` — 結び先の無い反証(#156)。**捨てない**(反証 0 件のラウンドと、 結び先を誤ったラウンドを区別するため) - `evidence_rounds` — 証拠集約(統合・実行検証・反証)を通ったラウンドの番号(#156)。 - **収束の判定はこの印で母集合を決める。** 印を持つラウンドだけを区分の 2 つへ絞り、 - 持たないラウンドは従来どおり全件を数える。**`review_findings` の有無では判定しない** - (取り込みはこの変更より前から要素を積むため、区分も `verification` も持たない旧い - ラウンドが絞り込みに掛かり、修正必須の `major` が `insufficient_evidence` へ落ちて - 新規 0 件で収束する)。印を書くのは経路の最後(`collect-critiques`)で、**対象ごとに - 有効な反証が揃ったときだけである** + **収束の判定はこの印で母集合を決める。** 印を持つラウンドだけを数える 3 区分へ絞り、 + 持たないラウンドは従来どおり全件を数える。印の役割は、取り込みだけを済ませた旧いラウンドと、 + 反証が届いていないラウンドを、棄却と `minor` 以下も含めて全件を数える側に置くことである + (`major` は誤りを示されていなければ `unrefuted` として数えられるが、否定が届いていない + かもしれないラウンドでは全件を数える側が安全である)。**`review_findings` の有無では判定 + しない**(取り込みは印より前から要素を積むため、区分も `verification` も持たない旧い + ラウンドが絞り込みに掛かる)。印を書くのは経路の最後(`collect-critiques`)で、**対象 + ごとに有効な反証が揃ったときだけである**。揃わないときは付けないだけでなく、**先に付いて + いたそのラウンドの印を外す**(取り直しの後も印が残ると、出力と実際の数え方が食い違う) - `rounds[].critique_relaunched` — 反証を取り直した担当(#549 レビュー対応)。 **同じラウンドで 1 度だけ取り直す**ための控えである - `rejected_findings` — 却下した指摘を **per-item** で蓄積する。`rounds[].fix.rejected` は diff --git a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md index 48ef20bd..0800e1ad 100644 --- a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md +++ b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md @@ -51,6 +51,14 @@ Step 1(ラウンドの開始)と Step 3(判定)が読む基準を持つ しまう。測れないときは従来の判定(全員が pass か)に従い、出力の `NEW_FINDINGS` は `-` に なる。 +**担当が 1 者のラウンドと、起動し直した担当の指摘は、未反証(`unrefuted`)として新規性の層が +数える。** 担当が 1 者なら反証を返す相手がいない。起動し直した担当の指摘は、反証を取り込んだ +後に取り込まれるため反証を持たない。いずれも誰も誤りを示していない重大な指摘であり、数えない +と未解決のまま承認で収束する。1 者で回したラウンドの収束は、この新規性と、全員が通したかの +2 つで決まる。担当が承認か、重大な指摘の無いコメントを返せば、全員が通したとして収束する。 +修正要求なら、未反証の重大な指摘が新規に数えられ、修正の工程へ進む。修正の後のラウンドで同じ +指摘が戻れば、前のラウンドと一致して新規 0 件になる。戻らなければ承認で収束する。 + **全員 `APPROVE` は、最も止まらない参加者に律速される。** #156 が観測した 6 ラウンドでは、 一方が round 4 以降 3 ラウンド連続で承認を返す間、もう一方が round 6 まで指摘を出し続け、 round 5 の指摘は修正済みの箇所を指していた。再提出された指摘は Pull Request に残り、 diff --git a/plugins/ndf/skills/cross-review/docs/06-evidence.md b/plugins/ndf/skills/cross-review/docs/06-evidence.md index b68bdf75..d828fd68 100644 --- a/plugins/ndf/skills/cross-review/docs/06-evidence.md +++ b/plugins/ndf/skills/cross-review/docs/06-evidence.md @@ -97,12 +97,15 @@ 母集合を決める。`review_findings` の有無では、取り込みだけを済ませた旧いラウンドと 区別できない([04-contracts.md](04-contracts.md))。 -**通り切ったかどうかは、対象ごとに有効な反証が揃ったかで見る。** 結果ファイルの欠落や -不正でも印を付けると、実行検証を持たない単独の `major` が `insufficient_evidence` へ -落ち、新規 0 件のまま**未検証で収束する**。`collect-critiques` は揃わないときに印を -付けず、終了コード 7 と `CRITIQUE_RETRY_AGENTS` を返して再取得へ戻す。**取り直しは -同じラウンドで 1 度だけである**(`judge` の結果なしと同じ作法)。2 度目も揃わなければ -印を付けないまま進み、そのラウンドは全件を数える。 +**通り切ったかどうかは、対象ごとに有効な反証が揃ったかで見る。** 印の役割は、取り込み +だけを済ませた旧いラウンドと、反証が届いていないラウンドを、全件を数える側に置くことである。 +印の無いラウンドは棄却と軽微な指摘も数えるため、否定が届いていないかもしれないラウンドでは +全件を数える側が安全である。`collect-critiques` は揃わないときに印を付けず、**先に付いて +いたそのラウンドの印を外す**。外さずに取り直しへ進むと、「印を付けないため、このラウンドは +全件を数えます」の出力と実際の数え方が食い違う。そのうえで終了コード 7 と +`CRITIQUE_RETRY_AGENTS` を返して再取得へ戻す。**取り直しは同じラウンドで 1 度だけである** +(`judge` の結果なしと同じ作法)。2 度目も揃わなければ印を付けないまま進み、そのラウンドは +全件を数える。揃えば印を付ける処理が印を戻す。 **監視へ渡すのは、実際に起動した担当だけである。** `critique.sh` は反証の対象が無い 担当(自分の指摘だけ、または指摘 0 件)で `launch-cli.sh` を呼ばずに終わるため @@ -112,8 +115,9 @@ 冒頭で捨てる(`` はラウンドを名前に持たないため、残骸を起動済みと読む)。 **申告による統合(2 段目)は次のラウンドへ回さない。** 回すと、同じ主張を 2 者が別の -本文で出した組が、どちらも `origin_runtimes` 1 者・`support` 0 件のまま -`insufficient_evidence` へ落ち、統合される前に収束する。 +本文で出した組が、どちらも `origin_runtimes` 1 者・`support` 0 件のまま別々の `unrefuted` +として 2 件に数えられ、「2 者が独立に到達した」という一致が `needs_human_judgment` として +記録に残らない。軽微な指摘なら `insufficient_evidence` へ落ち、統合される前に収束する。 ## 反証 @@ -163,8 +167,8 @@ ことは別である。 **束ねた組の全員の `suggested_check` を対象にする。** 代表の値だけを読むと、代表が手順を -書いていない組は、束ねられた側が実行できる手順を書いていても `not_run` のまま -`insufficient_evidence` へ落ちる。**どちらが先に取り込まれたかで採否が変わる。** 代表が +書いていない組は、束ねられた側が実行できる手順を書いていても `not_run` のままで、機械が +再現した事実が区分に効かない。**どちらが先に取り込まれたかで区分が変わる。** 代表が 持つのは組の集約で、`reproduced` > `not_reproduced` > `not_run` の順で最初に当たった 1 件を採り、出所を `verification.finding_id` へ残す。**同じコマンドは 1 度しか実行しない。** @@ -172,19 +176,30 @@ **上から順に見て、最初に当たった区分を採る。** -| 順 | 区分 | 条件 | -| --- | --- | --- | -| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | -| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | -| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | -| 4 | `needs_human_judgment` | 根拠を持ち、`major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | -| 5 | `insufficient_evidence` | 上のいずれにも当たらない | +| 順 | 区分 | 条件 | 数える | 理由の項目 | +| --- | --- | --- | --- | --- | +| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | +| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | +| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | +| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | はい | — | +| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason`(`no_critique` / `not_supported`) | +| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | **実行で再現した指摘を先に採るのは、順序そのもので「実行の結果を担当の支持より先に 見る」を表すためである。** 順 3 を先に置くと、機械が再現した事実を担当の再評価が覆す。 -**収束の判定が数えるのは、`verified_blocking` と `needs_human_judgment` の 2 つだけ** -である。棄却した指摘を数えると、そのぶんラウンドが増える。 +**収束の判定が数えるのは、`verified_blocking` と `needs_human_judgment` と `unrefuted` の +3 つ**である。数えないのは、誤りだと示された棄却と、承認を妨げない軽微な指摘だけである。 +棄却した指摘を数えると、そのぶんラウンドが増える。 + +**未反証(`unrefuted`)は、誰も誤りを示しておらず、独立に確かめた担当もいない重大な指摘で +ある。** 反証の「立証できない」「範囲外」は誤りだという主張ではないため、その指摘を数から +落とさない。数えないと、未解決の重大な指摘を残したまま承認で収束する。なぜ確かめられていない +かは理由(`unrefuted_reason`)として残す。反証を返した担当が 0 者なら `no_critique`、反証は +あるが支持も否定も無ければ `not_supported` である。修正の担当の扱いは `needs_human_judgment` +と同じで、直す・却下の理由を返す・範囲外として起票する、のいずれかを決める。根拠の 2 項目 +(`evidence` / `falsification`)の有無は区分を変えない。別の担当が支持した、または 2 者が +独立に出した時点で、確かめる目的は果たされている。 **`needs_human_judgment` を人へのエスカレーションにしない。** 決めるのは修正の担当で、 その判断は却下の記録(`rejected_findings`)へ残る。 @@ -221,7 +236,7 @@ $SCRIPTS/measure.py <状態ファイルのパス> [--output <パス>] | --- | --- | | `single` | 1 者だけの結果。**担当ごとに 1 通り出す**(誰を選ぶかで結果が変わるため) | | `majority` | `origin_runtimes` が 2 者以上の指摘 | -| `proposed` | 区分が `verified_blocking` または `needs_human_judgment` の指摘 | +| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘 | | `oracle` | いずれかの担当が出した指摘のうち、**修正された**もの | **母集合は代表だけである。** 4 つとも `merged_into` を持つ要素を数えない。統合された側を @@ -232,8 +247,8 @@ $SCRIPTS/measure.py <状態ファイルのパス> [--output <パス>] 過去の記録の `single` が全件 0 になる。補った値は 1 者であるため `majority` には入らない。 **`proposed` が読むのは印(`evidence_rounds`)のあるラウンドだけである。** 印の無いラウンドを -母集合へ入れると、区分の付かない指摘が `insufficient_evidence` として落ち、再現率が実際より -低く出る。**分母も印のあるラウンドに限る**(分子だけを絞ると、印の混ざった記録で過小に出る)。 +母集合へ入れると、区分の付かない指摘が数える 3 区分のいずれにも当たらずに落ち、再現率が +実際より低く出る。**分母も印のあるラウンドに限る**(分子だけを絞ると、印の混ざった記録で過小に出る)。 分母が他の 3 つと違うことは `oracle_scope` と `oracle_base` の 2 つのキーで常に出す。 ### `oracle` の結び方 diff --git a/plugins/ndf/skills/cross-review/scripts/critique.sh b/plugins/ndf/skills/cross-review/scripts/critique.sh index a46d62a2..4078b30e 100755 --- a/plugins/ndf/skills/cross-review/scripts/critique.sh +++ b/plugins/ndf/skills/cross-review/scripts/critique.sh @@ -96,6 +96,10 @@ cat > "$PROMPT" < None: def test_the_skill_points_at_the_contract_document() -> None: assert "docs/04-contracts.md" in SKILL.read_text(encoding="utf-8") + + +# ---- 区分の 6 つ目「未反証」(#732) ---- +# +# 誤りを示されていない `major` を `unrefuted` として数える。規約 3 文書・反証のプロンプト・ +# 確定仕様が同じ語で書いていることを固定する(AC18〜AC20)。 + +CRITIQUE_SH = HERE / "scripts/critique.sh" +EVIDENCE = DOCS / "06-evidence.md" +POOL = DOCS / "05-pool-and-convergence.md" +SPEC = HERE.parents[3] / "docs/specifications/cross-review-evidence-based.md" + + +def test_the_critique_prompt_says_insufficient_evidence_does_not_drop_the_finding() -> None: + """「立証できない」を返しても指摘は数から落ちないことを、プロンプトが担当へ言う(AC19)。""" + assert "数から落ち" in CRITIQUE_SH.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("doc", (EVIDENCE, CONTRACTS, POOL), ids=lambda p: p.name) +def test_the_review_docs_name_the_unrefuted_classification(doc: pathlib.Path) -> None: + assert "unrefuted" in doc.read_text(encoding="utf-8"), doc.name + + +def test_the_evidence_doc_says_the_mark_is_removed_when_critiques_are_incomplete() -> None: + assert "印を外す" in EVIDENCE.read_text(encoding="utf-8") + + +def test_the_specification_holds_the_same_six_classifications() -> None: + """区分の表と行き先の表の両方が `unrefuted` を持つ(AC20)。""" + assert SPEC.read_text(encoding="utf-8").count("unrefuted") >= 2 From 3954efeb2c65a1c1a65e306a4337846a03a3d0d4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:28:27 +0000 Subject: [PATCH 028/217] =?UTF-8?q?Add:=20cross-review=20=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AE=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=8C=E5=85=B1=E9=80=9A=E3=81=AE=E7=B5=90=E6=9C=AB=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E3=80=81=E7=90=86=E7=94=B1=E3=81=A8=E7=9B=A3?= =?UTF-8?q?=E8=A6=96=E3=81=AE=E8=A9=B3=E7=B4=B0=E3=82=92=E6=AE=8B=E3=81=99?= =?UTF-8?q?=EF=BC=88#729=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 5(AC13、AC12)。`_read_review_result_file` は結果ファイルを自前で開かず `monitor_outcome.read_launch_outcome` を呼び、`payload` が無ければ共通の `reason` を `no_result_reason` に残す。監視の結果ファイルがあれば `monitor_detail` に監視の `detail` を残す(空なら鍵を書かない)。終了コードは従来どおり `unparsable` が 3、 それ以外は 1。起動の手順・監視・取り込みの stem が同じ形であることをテストで固定する (設計文書の未確認 6)。 Co-Authored-By: Claude Fable 5.1 --- .../ndf/skills/cross-review/scripts/state.py | 58 +++-- .../tests/test_read_result_reason.py | 236 ++++++++++++++++++ 2 files changed, 268 insertions(+), 26 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_read_result_reason.py diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index f0a02e60..b35a9530 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -35,6 +35,7 @@ import auth # noqa: E402 import post_queue # noqa: E402 import run_metrics # noqa: E402 実行の要約(#662) +import monitor_outcome # noqa: E402 起動 1 回の結末(#729) # ---------------- helpers ---------------- @@ -2404,12 +2405,17 @@ def cmd_unresolved_threads(args: argparse.Namespace) -> None: info(f"- {t['id']} {t.get('path', '')}:{t.get('line', '')}") -def _record_no_result(pr: int, agent: str, reason: str) -> None: +def _record_no_result( + pr: int, agent: str, reason: str, monitor_detail: str | None = None, +) -> None: """使える結果が残らなかったことを、そのラウンドへ残す。 判定(`cmd_judge`)はこの記録を読んで、起動し直しか中断かを決める。記録が無い ラウンドも結果なしとして読むため、ここで書けなかった場合も収束はしない。 + `monitor_detail` は監視の `detail`(#729)。**鍵が無い = 監視の結果ファイルが + 無かった**を保つため、`None` と空文字では鍵を書かない。 + 状態ファイルを読めないときとラウンドがまだ無いときは、何も書かずに戻る。呼び出し 元はこの直後に die するため、ここで新たに止める理由が無い。 """ @@ -2422,7 +2428,7 @@ def _record_no_result(pr: int, agent: str, reason: str) -> None: return if not isinstance(st, dict) or not st.get("rounds"): return - st["rounds"][-1][agent] = { + entry: dict[str, Any] = { "intent": NO_RESULT, "no_result_reason": reason, "posted_as": None, @@ -2430,6 +2436,9 @@ def _record_no_result(pr: int, agent: str, reason: str) -> None: "review_url": None, "by_severity": {}, } + if monitor_detail: + entry["monitor_detail"] = monitor_detail + st["rounds"][-1][agent] = entry _save(pr, st) @@ -2440,33 +2449,30 @@ def _die_no_result(pr: int, agent: str, reason: str, msg: str, code: int = 1) -> def _read_review_result_file(pr: int, agent: str, rfile: pathlib.Path) -> dict[str, Any]: - if not rfile.exists() or rfile.stat().st_size == 0: - _die_no_result(pr, agent, "missing", f"{agent}: result 未生成 ({rfile})") + """結果ファイルを、結末の共通層の値として読む(#729 の決定 2)。 - try: - r = json.loads(rfile.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - _die_no_result( - pr, - agent, - "unparsable", - f"{agent}: result.json の parse に失敗 ({rfile}): {exc}", - code=3, - ) - - # gemini round 4 指摘: result.json は本来 dict だが、launcher の出力バグや - # 別実行の残骸で list / str が入り込むと `r.get(...)` で AttributeError になる。 - # 不正な review result はバグなので即時 die(code=3) で停止させる。 - if not isinstance(r, dict): - _die_no_result( - pr, - agent, - "unparsable", - f"{agent}: result.json が dict ではない " - f"({rfile}, type={type(r).__name__})。review launcher の出力形式不正。", + 結果ファイルを自前で開かない。監視の結果ファイル(`-monitor.json`)と突き合わせて + 使える結果か理由かを決めるのは `read_launch_outcome` で、ここが決めるのは終了コードだけ + である(読めない結果は 3、それ以外は 1。変更前と同じ)。理由の語彙も起動し直しの可否も + ここには置かない。 + """ + outcome = monitor_outcome.read_launch_outcome( + _resolve_tmp_dir(pr), f"{agent}-review-pr{pr}", rfile) + if outcome.payload is not None: + return outcome.payload + reason = outcome.reason or "missing" + # 監視の結果ファイルがあるときだけ、その `detail` を残す(無いときの `outcome.detail` は + # 読めなかった理由の 1 文で、監視の詳細ではない) + monitor_detail = str(outcome.monitor.get("detail") or "") if outcome.monitor else None + _record_no_result(pr, agent, reason, monitor_detail) + if reason == "unparsable": + # 結果ファイルはあるが JSON の dict として parse できない。launcher の出力形式不正 + die( + f"{agent}: result.json の parse に失敗、または dict ではない ({rfile}):" + f" {outcome.detail}", code=3, ) - return r + die(f"{agent}: 使える結果が無い (reason={reason}, {rfile}): {outcome.detail}") def _verify_review_arrival( diff --git a/plugins/ndf/skills/cross-review/tests/test_read_result_reason.py b/plugins/ndf/skills/cross-review/tests/test_read_result_reason.py new file mode 100644 index 00000000..2e1ee0b6 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_read_result_reason.py @@ -0,0 +1,236 @@ +"""結果の取り込みが共通の結末を読み、理由と監視の詳細を残すことのテスト(#729 AC13)。 + +`read-result` は結果ファイルを自前で開かず、結末の共通層(`monitor_outcome`)の +`read_launch_outcome` を呼ぶ。使える結果が無いとき、`no_result_reason` に共通の `reason` +を残し、監視の結果ファイルがあれば `monitor_detail` に監視の `detail` を残す。 +終了コードは変更前と同じ(`unparsable` は 3、それ以外は 1)である。 + +| 監視の結果ファイル | 結果ファイル | `no_result_reason` | `monitor_detail` | 終了コード | +| --- | --- | --- | --- | --- | +| 監視が決めた理由(6 語) | 無い | その値 | 監視の `detail` | 1 | +| 監視が決めた理由(6 語) | 読めない | その値 | 監視の `detail` | 1 | +| 無い | 無い | `missing` | 鍵なし | 1 | +| 無い | 読めない | `unparsable` | 鍵なし | 3 | +| `ok` / `missing` | 無い | `missing` | 監視の `detail`(空なら鍵なし) | 1 | +| 問わない | 読める | (使える結果として取り込む) | — | 0 | + +stem の突き合わせ(設計文書の未確認 6)もここで固定する。起動の手順・監視・取り込みの +3 つが同じ形の stem を組み立てなければ、監視の結果ファイルを引けない。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import re + +import pytest + +PR = 7729 +AGENT = "kiro" +STEM = f"{AGENT}-review-pr{PR}" +DETAIL = "Monthly request limit reached" + +_LAUNCHER = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "launch-reviewer.sh" + +# 監視が結末に書く理由のうち、結果ファイルの状態を見ずにその値を採るもの(設計文書の +# 「理由の語彙」の表で「監視」が書き、`ok` / `missing` でない 6 語)。 +MONITOR_DECIDED = ("timeout", "stalled", "early_error", "usage_limit", "cli_timeout", "pidfile_bad") + + +# ---------------- 足場 ---------------- + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向ける。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture(autouse=True) +def review_posted(monkeypatch, state_mod): + """投稿の実在確認は届いた前提にする。ここで見るのは結末の読み方である。""" + monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) + + +def _seed_state(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, + "repo": "o/r", + "rounds": [{"round": 1, "pr": PR, "started_at": "2026-09-19T00:00:00+00:00"}], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _entry(tmp_dir: pathlib.Path) -> dict: + st = json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + return st["rounds"][-1][AGENT] + + +def _write_monitor(tmp_dir: pathlib.Path, reason: str, detail: str = DETAIL) -> None: + """監視の結果ファイル `-monitor.json` を置く。キーは監視が書く形に倣う。""" + outcome = { + "agent": AGENT, + "stem": STEM, + "status": "EARLY_ERROR", + "exit_code": 4, + "reason": reason, + "detail": detail, + } + (tmp_dir / f"{STEM}-monitor.json").write_text(json.dumps(outcome, ensure_ascii=False)) + + +def _read_result(state_mod, rfile: pathlib.Path | None) -> int: + """`read-result` を呼び、終了コードを返す。`rfile` が None なら既定のパスを使う。""" + args = argparse.Namespace(pr=PR, agent=AGENT, file=str(rfile) if rfile else None) + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(args) + return int(e.value.code or 0) + + +# ---------------- AC13: 理由と監視の詳細を残す ---------------- + + +@pytest.mark.parametrize("reason", MONITOR_DECIDED) +def test_the_monitor_reason_is_recorded_when_no_result_file_exists( + tmp_dir, state_mod, reason, capsys +): + """監視が理由を決めていれば、結果ファイルが無いときその理由と詳細が残る。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, reason) + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["intent"] == "NO_RESULT" + assert entry["no_result_reason"] == reason + assert entry["monitor_detail"] == DETAIL + err = capsys.readouterr().err + assert reason in err + assert DETAIL in err + + +def test_the_monitor_reason_wins_over_an_unreadable_result_file(tmp_dir, state_mod): + """監視が止めたと分かっているなら、読めない結果ファイルがあってもその理由で 1 で止まる。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "usage_limit") + rfile = tmp_dir / "result.json" + rfile.write_text("{ not json") + + assert _read_result(state_mod, rfile) == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "usage_limit" + assert entry["monitor_detail"] == DETAIL + + +def test_without_a_monitor_file_the_reason_is_missing_and_no_detail_key_is_written( + tmp_dir, state_mod +): + """監視の結果ファイルが無ければ `missing` で、`monitor_detail` の鍵そのものが無い。""" + _seed_state(tmp_dir) + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "missing" + assert "monitor_detail" not in entry + + +def test_an_unparsable_result_without_a_monitor_file_keeps_exit_code_3(tmp_dir, state_mod): + """監視の結果ファイルが無く結果が読めなければ、`unparsable` で従来どおり 3 で止まる。""" + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps([{"event": "APPROVE"}])) + + assert _read_result(state_mod, rfile) == 3 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "unparsable" + assert "monitor_detail" not in entry + + +@pytest.mark.parametrize("reason", ("ok", "missing")) +def test_a_monitor_that_did_not_decide_falls_back_to_the_result_file( + tmp_dir, state_mod, reason +): + """監視が `ok` / `missing` なら理由は結果ファイルの側で決まり、詳細だけ残る。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, reason, detail="exit 0") + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "missing" + assert entry["monitor_detail"] == "exit 0" + + +def test_an_empty_monitor_detail_does_not_write_the_key(tmp_dir, state_mod): + """監視の結果ファイルがあっても `detail` が空なら、`monitor_detail` を書かない。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "timeout", detail="") + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "timeout" + assert "monitor_detail" not in entry + + +def test_a_readable_result_file_wins_over_the_monitor_reason(tmp_dir, state_mod): + """監視が利用上限で止めた後でも、結果ファイルが読めればそれは使える結果である。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "usage_limit") + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps({ + "event": "APPROVE", "posted_as": "APPROVE", "comments_count": 0, + "review_url": "https://example/pr/1#1", "by_severity": {}, + })) + + state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent=AGENT, file=str(rfile))) + + entry = _entry(tmp_dir) + assert entry["intent"] == "APPROVE" + assert "no_result_reason" not in entry + + +def test_no_verdict_still_overrides_after_the_common_read(tmp_dir, state_mod): + """判定の値が無い結果は、共通の読み取りの後で cross-review が `no_verdict` に上書きする。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "ok", detail="exit 0") + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps({"comments_count": 0})) + + assert _read_result(state_mod, rfile) == 1 + assert _entry(tmp_dir)["no_result_reason"] == "no_verdict" + + +# ---------------- 未確認 6: stem の突き合わせ ---------------- + + +def _launcher_stem_pattern() -> str: + """`launch-reviewer.sh` の `STEM=` の行から、ディレクトリを除いた stem の形を取る。""" + lines = [l for l in _LAUNCHER.read_text(encoding="utf-8").splitlines() + if re.match(r"^\s*STEM=", l)] + assert len(lines) == 1, lines + value = lines[0].split("=", 1)[1].strip() + assert value.startswith("$TMP_DIR/"), value + return value[len("$TMP_DIR/"):] + + +def test_the_three_stems_have_the_same_shape(tmp_dir, state_mod, monitor_mod): + """起動の手順・監視・取り込みの stem が同じ形で、監視の結果ファイルを引ける。""" + # 起動の手順(bash): 変数名を置き換えて形を比べる + launcher = (_launcher_stem_pattern() + .replace("$RUNTIME", AGENT).replace("$STATE_PR", str(PR))) + # 監視(Python): 既定の stem テンプレート + monitor = monitor_mod.DEFAULT_STEM_TEMPLATE.format(agent=AGENT, id=PR) + assert launcher == monitor == STEM + + # 取り込み: 既定のパス(`--file` 無し)で、監視の結果ファイルの理由を引ける + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "usage_limit") + assert _read_result(state_mod, None) == 1 + assert _entry(tmp_dir)["no_result_reason"] == "usage_limit" From b3f2258bc711b261d496a4a1c7dbd66f658f7e8e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:30:44 +0000 Subject: [PATCH 029/217] =?UTF-8?q?Add:=20cross-review=20=E3=81=AE?= =?UTF-8?q?=E5=88=A4=E5=AE=9A=E3=81=8C=E7=B5=90=E6=9E=9C=E3=81=AA=E3=81=97?= =?UTF-8?q?=E3=81=AE=E7=90=86=E7=94=B1=E3=82=92=E5=87=BA=E3=81=97=E3=80=81?= =?UTF-8?q?=E8=B5=B7=E5=8B=95=E3=81=97=E7=9B=B4=E3=81=9B=E3=81=AA=E3=81=84?= =?UTF-8?q?=E7=90=86=E7=94=B1=E3=81=8C=E3=81=82=E3=82=8C=E3=81=B0=E6=AD=A2?= =?UTF-8?q?=E3=82=81=E3=82=8B=EF=BC=88#729=20#619=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 6(AC14〜AC17、AC24)。`_handle_no_result_round` は結果なしの担当ごとの理由を 集めて先に `NO_RESULT_REASONS='<担当>=<理由> ...'` を標準出力へ出す。理由のどれかが 共通層の `relaunch_same_agent` で偽なら、起動し直さずに `final=error` として終了コード 1 で止め、標準エラーに担当・理由・`monitor_detail` を出す。すべて可なら従来どおり (1 度目は 7 と `RELAUNCH_AGENTS`、2 度目は 1)。報告の表は結果なしの担当を `<担当>=NO_RESULT(<理由>)` の形で出す。判定の終了コードの分岐は増えず、SKILL.md の 骨組みは変えない。 Co-Authored-By: Claude Fable 5.1 --- .../ndf/skills/cross-review/scripts/state.py | 32 +- .../tests/test_judge_no_result_reason.py | 278 ++++++++++++++++++ 2 files changed, 309 insertions(+), 1 deletion(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index b35a9530..937989d9 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -2770,7 +2770,34 @@ def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, An def _handle_no_result_round( pr: int, st: dict[str, Any], last: dict[str, Any], no_result: list[str] ) -> None: + """結果なしの担当があるラウンドの出口を決める。 + + 先に理由の行(`NO_RESULT_REASONS`)を出す。どの出口でも進行側が理由を読めるようにする + ためである(#729 の AC14)。**起動し直しの可否は結末の共通層だけが決める** + (`monitor_outcome.relaunch_same_agent`)。可否が偽の理由が 1 つでもあれば、誰も起動し直さず + 誤りの終わりへ進む。起動し直しても解けない理由で待つのは、相手の CLI の枠と時間を使うだけ + である(#619)。骨組みは既存の 1 の枝で受けるため、終了コードは増えない(決定 12)。 + """ last["verdict"] = "no_result" + reasons = { + a: (last.get(a) or {}).get("no_result_reason") or "missing" for a in no_result + } + print(f"NO_RESULT_REASONS='{' '.join(f'{a}={r}' for a, r in reasons.items())}'") + blocked = [a for a, r in reasons.items() + if not monitor_outcome.relaunch_same_agent(r)] + if blocked: + st["final"] = "error" + st["ended_at"] = _now() + _save(pr, st) + for a in blocked: + detail = (last.get(a) or {}).get("monitor_detail") + info(f" {a}: reason={reasons[a]}" + (f" detail={detail}" if detail else "")) + die( + f"起動し直しても解けない理由で結果が残りませんでした: {' '.join(blocked)}。" + " 同じラウンドで起動し直さずに中断します。最終スイープを通してから" + "完了報告へ進んでください", + code=1, + ) relaunched = last.get("relaunched") or [] pending = [a for a in no_result if a not in relaunched] if not pending: @@ -4250,7 +4277,10 @@ def _print_round_summary(rounds: list) -> None: parts = [] for name in reviewers: entry = r.get(name) or {} - if entry: + if entry.get("intent") == NO_RESULT: + # 結果なしは投稿の数を持たない。括弧には理由を出す(#729 の AC17) + parts.append(f"{name}=NO_RESULT({entry.get('no_result_reason', '-')})") + elif entry: parts.append(f"{name}={entry.get('intent', '-')} ({entry.get('comments', '-')})") else: parts.append(f"{name}=-") diff --git a/plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py b/plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py new file mode 100644 index 00000000..771a92e3 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py @@ -0,0 +1,278 @@ +"""判定が結果なしの理由を出し、起動し直せない理由があれば止めることのテスト(#729 AC14〜AC17)。 + +| ラウンドの結果なし | 出口 | 終了コード | +| --- | --- | --- | +| 理由がすべて起動し直してよいもの、まだ起動し直していない | `RELAUNCH_AGENTS` を出して起動し直す | 7 | +| 理由がすべて起動し直してよいもの、既に起動し直している | 中断(`final = error`) | 1 | +| 起動し直せない理由(利用上限)を 1 つでも含む | **起動し直さず**中断(`final = error`) | 1 | + +どの出口でも、判定は先に標準出力へ `NO_RESULT_REASONS='<担当>=<理由> ...'` の 1 行を出す。 +起動し直しの可否は結末の共通層(`monitor_outcome.relaunch_same_agent`)だけが決め、 +判定は `usage_limit` という値を知らない(AC12)。報告の表は結果なしの担当を +`<担当>=NO_RESULT(<理由>)` の形で出す(AC17)。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + +PR = 8729 +REPO = "o/r" +DETAIL = "Monthly request limit reached" + + +# ---------------- 足場 ---------------- + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向ける。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def _state(rounds: list[dict], **over) -> dict: + state = { + "current_pr": PR, + "repo": REPO, + "max_rounds": 12, + "rotate_after": 8, + "only": None, + "rounds": rounds, + "pr_history": [{"pr": PR, "rounds": len(rounds)}], + "deferred_nits": [], + "final": None, + } + state.update(over) + return state + + +def _round(no: int = 1, **over) -> dict: + entry: dict = {"round": no, "pr": PR, "started_at": "2026-09-19T00:00:00+00:00"} + entry.update(over) + return entry + + +def _approve() -> dict: + return { + "intent": "APPROVE", + "posted_as": "APPROVE", + "comments": 0, + "by_severity": {"critical": 0, "major": 0}, + } + + +def _no_result(reason: str, detail: str | None = None) -> dict: + """`read-result` が残す結果なしの形(`test_read_result_reason.py` が固定する)。""" + entry: dict = { + "intent": "NO_RESULT", + "no_result_reason": reason, + "posted_as": None, + "comments": None, + "review_url": None, + "by_severity": {}, + } + if detail: + entry["monitor_detail"] = detail + return entry + + +def _write(tmp_dir: pathlib.Path, state: dict) -> None: + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _read(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +def _judge(state_mod) -> int: + with pytest.raises(SystemExit) as e: + state_mod.cmd_judge(argparse.Namespace(pr=PR)) + return int(e.value.code or 0) + + +# ---------------- AC14: 理由の行 ---------------- + + +def test_judge_prints_the_reason_of_each_agent_without_a_result(tmp_dir, state_mod, capsys): + """結果なしの担当があると、標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'` が出る。""" + _write(tmp_dir, _state([_round(codex=_no_result("timeout"), agy=_no_result("stalled"))])) + + assert _judge(state_mod) == 7 + + out = capsys.readouterr().out + assert "NO_RESULT_REASONS='codex=timeout agy=stalled'" in out + + +def test_an_agent_without_an_entry_is_reported_as_missing(tmp_dir, state_mod, capsys): + """`read-result` すら記録を残していない担当は、理由 `missing` として出る。""" + _write(tmp_dir, _state([_round(codex=_approve())])) + + assert _judge(state_mod) == 7 + + assert "NO_RESULT_REASONS='agy=missing'" in capsys.readouterr().out + + +def test_the_reason_line_comes_before_the_relaunch_line(tmp_dir, state_mod, capsys): + """理由の行は起動し直しの指示より先に出る。骨組みが読む順序を固定する。""" + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("cli_timeout"))])) + + assert _judge(state_mod) == 7 + + out = capsys.readouterr().out + assert out.index("NO_RESULT_REASONS=") < out.index("RELAUNCH_AGENTS=") + + +# ---------------- AC15: 起動し直せない理由があれば止める ---------------- + + +def test_a_usage_limit_stops_the_review_without_a_relaunch(tmp_dir, state_mod, capsys): + """利用上限の担当があれば、起動し直さず `final = error` で 1 で止まる。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy=_no_result("usage_limit", DETAIL)), + ])) + + assert _judge(state_mod) == 1 + + st = _read(tmp_dir) + assert st["final"] == "error" + assert st["ended_at"] + assert st["rounds"][-1]["verdict"] == "no_result" + assert "relaunched" not in st["rounds"][-1] + captured = capsys.readouterr() + assert "NO_RESULT_REASONS='agy=usage_limit'" in captured.out + assert "RELAUNCH_AGENTS" not in captured.out + assert "agy" in captured.err + assert "usage_limit" in captured.err + assert DETAIL in captured.err + + +def test_a_usage_limit_stops_even_when_another_agent_could_be_relaunched( + tmp_dir, state_mod, capsys +): + """起動し直してよい担当が混ざっていても、利用上限が 1 つあれば誰も起動し直さない。""" + _write(tmp_dir, _state([ + _round(codex=_no_result("timeout"), agy=_no_result("usage_limit")), + ])) + + assert _judge(state_mod) == 1 + + st = _read(tmp_dir) + assert st["final"] == "error" + assert "relaunched" not in st["rounds"][-1] + captured = capsys.readouterr() + assert "NO_RESULT_REASONS='codex=timeout agy=usage_limit'" in captured.out + assert "RELAUNCH_AGENTS" not in captured.out + + +def test_a_usage_limit_without_monitor_detail_still_stops(tmp_dir, state_mod, capsys): + """`monitor_detail` の鍵が無くても、担当と理由が標準エラーに出て止まる。""" + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("usage_limit"))])) + + assert _judge(state_mod) == 1 + + err = capsys.readouterr().err + assert "agy" in err + assert "usage_limit" in err + + +def test_the_verdict_reads_relaunchability_from_the_common_layer( + tmp_dir, state_mod, monkeypatch, capsys +): + """可否を決めるのは共通層の集合であり、判定は理由の値を見ない(AC12)。 + + 共通層の「起動し直せない理由」に `timeout` を足すと、判定は `timeout` でも止まる。 + 判定が `usage_limit` を直に比べていれば、この変更は届かず 7 になる。 + """ + monkeypatch.setattr( + state_mod.monitor_outcome, "NO_RELAUNCH_REASONS", frozenset({"timeout"})) + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("timeout"))])) + + assert _judge(state_mod) == 1 + assert _read(tmp_dir)["final"] == "error" + + # 逆に `usage_limit` を外せば、判定は起動し直す + monkeypatch.setattr(state_mod.monitor_outcome, "NO_RELAUNCH_REASONS", frozenset()) + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("usage_limit"))])) + + assert _judge(state_mod) == 7 + assert "RELAUNCH_AGENTS='agy'" in capsys.readouterr().out + + +# ---------------- AC16: 起動し直してよい理由なら従来どおり ---------------- + + +def test_relaunchable_reasons_ask_for_one_relaunch(tmp_dir, state_mod, capsys): + """理由がすべて起動し直してよいものなら、1 度目は 7 で `RELAUNCH_AGENTS` を返す。""" + _write(tmp_dir, _state([ + _round(codex=_no_result("cli_timeout", "print timeout after 60m"), + agy=_no_result("early_error", "fatal: something")), + ])) + + assert _judge(state_mod) == 7 + + out = capsys.readouterr().out + assert "RELAUNCH_AGENTS='codex agy'" in out + assert "RELAUNCH_TARGET=both" in out + st = _read(tmp_dir) + assert st["rounds"][-1]["relaunched"] == ["codex", "agy"] + assert st["final"] is None + + +def test_a_second_no_result_with_relaunchable_reasons_stops(tmp_dir, state_mod, capsys): + """起動し直した後も結果が残らなければ、従来どおり `final = error` で 1 で止まる。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy=_no_result("timeout"), relaunched=["agy"]), + ])) + + assert _judge(state_mod) == 1 + + st = _read(tmp_dir) + assert st["final"] == "error" + assert st["rounds"][-1]["verdict"] == "no_result" + assert "NO_RESULT_REASONS='agy=timeout'" in capsys.readouterr().out + + +def test_rounds_without_a_no_result_do_not_print_the_reason_line(tmp_dir, state_mod, capsys): + """結果なしが無いラウンドでは理由の行が出ず、終了コード 0 の枝は変わらない。""" + _write(tmp_dir, _state([_round(codex=_approve(), agy=_approve())])) + + assert _judge(state_mod) == 0 + + assert "NO_RESULT_REASONS" not in capsys.readouterr().out + assert _read(tmp_dir)["final"] == "approved" + + +# ---------------- AC17: 報告の表 ---------------- + + +def test_the_round_summary_shows_the_reason_of_a_no_result(tmp_dir, state_mod, capsys): + """報告の表で、結果なしの担当は `<担当>=NO_RESULT(<理由>)` の形で出る。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy=_no_result("usage_limit", DETAIL), + verdict="no_result"), + ], final="error")) + + state_mod.cmd_report(argparse.Namespace(pr=PR)) + + out = capsys.readouterr().out + assert "agy=NO_RESULT(usage_limit)" in out + assert "NO_RESULT (" not in out + # 使える結果を残した担当の形は変わらない + assert "codex=APPROVE (0)" in out + + +def test_the_round_summary_shows_a_dash_when_the_reason_is_unknown( + tmp_dir, state_mod, capsys +): + """理由の鍵を持たない古い記録は `NO_RESULT(-)` として出る。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy={"intent": "NO_RESULT"}, verdict="no_result"), + ])) + + state_mod.cmd_report(argparse.Namespace(pr=PR)) + + assert "agy=NO_RESULT(-)" in capsys.readouterr().out From 68909590c897d93822773e661b8720f1ae144045 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 11:35:31 +0000 Subject: [PATCH 030/217] =?UTF-8?q?Docs:=20=E7=B5=90=E6=9E=9C=E3=81=AA?= =?UTF-8?q?=E3=81=97=E3=81=AE=E7=90=86=E7=94=B1=2010=20=E8=AA=9E=E3=83=BB?= =?UTF-8?q?=E4=B8=8A=E9=99=90=E3=81=AB=E5=BD=93=E3=81=9F=E3=81=A3=E3=81=9F?= =?UTF-8?q?=E5=A0=B4=E5=90=88=E3=81=AE=E8=A6=8B=E5=88=86=E3=81=91=E6=96=B9?= =?UTF-8?q?=E3=83=BBmonitor=5Fdetail=20=E3=81=AE=E5=A5=91=E7=B4=84?= =?UTF-8?q?=E3=82=92=20cross-review=20=E3=81=AE=E6=96=87=E6=9B=B8=E3=81=B8?= =?UTF-8?q?=E8=BC=89=E3=81=9B=E3=82=8B=EF=BC=88#729=20#619=20#584=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 7。理由の表を 10 語にし(起動し直しの可否の列を足す)、判定が出す NO_RESULT_REASONS の行と利用上限で止める規則を書く。「monitor.py が誤って kill する場合の手順」に、監視の結果ファイルと監視の記録から上限を見分ける 表を足す。契約の文書に read_launch_outcome の値と monitor_detail の鍵を書く。 共通層の一覧に read_launch_outcome の責務を足す。 設計文書の「未確認のまま残ること」の 1・3・5・6 を、実装で決めた結果 (偽の claude を両方の形で試した・bash 3.2 の一次資料・照合の順序・stem の 突き合わせのテスト)へ更新する。実装計画の「やらないこと」に #789 を足す。 01-state-and-review.md は 4 つの罠の引用ブロックを同じ内容の表へ組み直し、 500 行の上限内に収めた。 Co-Authored-By: Claude Fable 5.1 --- issues/issue-729-619-584-design.md | 10 +-- .../issue-729-619-584-implementation-plan.md | 1 + plugins/ndf/scripts/lib/README.md | 2 +- .../cross-review/docs/01-state-and-review.md | 87 ++++++++++--------- .../cross-review/docs/03-review-output.md | 18 +++- .../skills/cross-review/docs/04-contracts.md | 14 ++- 6 files changed, 81 insertions(+), 51 deletions(-) diff --git a/issues/issue-729-619-584-design.md b/issues/issue-729-619-584-design.md index 1963eb64..137efdb8 100644 --- a/issues/issue-729-619-584-design.md +++ b/issues/issue-729-619-584-design.md @@ -534,16 +534,16 @@ sequenceDiagram ## 未確認のまま残ること -6 件。実装で決めるものが 4 件、確かめないまま進めるものが 2 件(どちらでも設計が塞ぐ)である。 +6 件。実装で決めたものが 4 件(1・3・5・6。決めた結果を「決める時点」の列に残す)、確かめないまま進めるものが 2 件(2・4。どちらでも設計が塞ぐ)である。 | # | 項目 | 内容 | 決める時点 | | --- | --- | --- | --- | -| 1 | claude の 429 の出る先 | `"api_error_status":429` が err.log と stdout.log のどちらに出るか。実物のログが手元に無い。両方を見るためどちらでも拾える | 実装で偽の claude を両方の形で試す。実物は次に上限に当たったときの記録で確かめる | +| 1 | claude の 429 の出る先 | `"api_error_status":429` が err.log と stdout.log のどちらに出るか。実物のログが手元に無い。両方を見るためどちらでも拾える | 実装で偽の claude を err.log と stdout.log の両方の形で試し、どちらでも `EARLY_ERROR` / `usage_limit` になることをテストで固定した(`test_monitor_usage_limit.py` の `test_usage_limit_stops_the_agent_as_early_error_with_reason_usage_limit`)。実物の出る先は次に上限に当たったときの記録で確かめる | | 2 | kiro の利用上限のときの終わり方 | プロセスが直ちに終わるのか、待ち続けるのか。#619 の実物では `err.log` に 1 行出て `NO_RESULT` になった(直ちに終わったと読める) | どちらでも監視は巡回で文言を拾い `usage_limit` にする。実装で確かめない | -| 3 | macOS の bash 3.2 の `set -m` | Linux の bash 5.3 だけで確かめた | 実装で確かめる。成り立たなければ pid だけの停止に落ちる(構造は同じ) | +| 3 | macOS の bash 3.2 の `set -m` | Linux の bash 5.3 だけで確かめた | 一次資料で確かめた。GNU の配布物 bash-3.2 の `doc/bash.1`(2006-09-28)の `set` の `-m` の項に「Background processes run in a separate process group」とあり、`CHANGES` の bash-2.01 の節に `set -m` の修正の記載がある(2.01 の時点で存在する)。手元の bash 5.3.9 では非対話・tty 無しで pid = pgid になり、標準エラーへジョブ制御の通知は出ない。macOS の実機では未確認のまま。成り立たなければ `_leads_own_group` が偽になり pid だけの停止に落ちる(構造は同じ) | | 4 | agy が子プロセスで結果を書くか | #584 の事例が止めた後の書き出しだったかは確かめられていない | 確かめないまま進める(グループで止めればどちらでも塞がる) | -| 5 | 利用上限と他の致命が同じ err.log に並ぶ順序 | 上限の後に別の致命が続く形を想定して利用上限を先に見る。逆の順で並ぶ実物は未確認 | 実装で順序を固定し、逆の実物が出たら見直す | -| 6 | `read_launch_outcome` に渡す stem の組み立て | cross-review は `-review-pr`、cross-refactoring は `stem_for` の値。監視の `--stem-template` と食い違うと監視の結果ファイルを引けない | 実装で、`launch-*.sh` と `monitor.py` の呼び出しの stem を突き合わせるテストを置く | +| 5 | 利用上限と他の致命が同じ err.log に並ぶ順序 | 上限の後に別の致命が続く形を想定して利用上限を先に見る。逆の順で並ぶ実物は未確認 | 照合の順序を利用上限 → 致命 → 警告の見た目の致命に固定し、err.log の並びがどちらの順でも理由が `usage_limit` になることをテストで固定した(`test_usage_limit_wins_over_other_fatal_lines_in_either_order`)。逆の順で並ぶ実物が出ても結果は変わらない | +| 6 | `read_launch_outcome` に渡す stem の組み立て | cross-review は `-review-pr`、cross-refactoring は `stem_for` の値。監視の `--stem-template` と食い違うと監視の結果ファイルを引けない | cross-review の 3 か所(`launch-reviewer.sh` の `STEM=` の行・監視の `DEFAULT_STEM_TEMPLATE`・取り込みが渡す stem)が同じ形であることを 1 つのテストで固定した(`test_read_result_reason.py` の `test_the_three_stems_have_the_same_shape`)。cross-refactoring の `stem_for` との突き合わせは G4 が `read_result` を置き換えるときに同じ形のテストを置く | ## 申し送り(並行する設計との境界) diff --git a/issues/issue-729-619-584-implementation-plan.md b/issues/issue-729-619-584-implementation-plan.md index d9e51081..c1fbb7e3 100644 --- a/issues/issue-729-619-584-implementation-plan.md +++ b/issues/issue-729-619-584-implementation-plan.md @@ -25,6 +25,7 @@ - 投稿の後に打ち切られた担当の記録と重ねての投稿(G5 #730) - 利用上限の担当を外して残りの担当で回す判断(#478) - 監視の終了コードと標準出力の変更、cross-review の骨組み(`SKILL.md`)の変更 +- テストが共通層のモジュールを読み込む流儀を揃えること(実装中に見つけ、#789 として起票した) ## 前提 diff --git a/plugins/ndf/scripts/lib/README.md b/plugins/ndf/scripts/lib/README.md index 3226c021..21debcf2 100644 --- a/plugins/ndf/scripts/lib/README.md +++ b/plugins/ndf/scripts/lib/README.md @@ -20,7 +20,7 @@ | [lock-common.sh](lock-common.sh) | 排他の取得と解放(#293) | 上の 2 つと `development-workflow` | | [monitor.py](monitor.py) | 別プロセスの多軸監視。対象と命名規則を引数で受ける | 収束ループの 2 つ | | [limits.py](limits.py) | 監視の上限(工程ごと)・無進捗の許容(担当ごと)・CLI の上限(監視の上限 + 120 秒)の表。既定値はここだけが持つ(#598 / #537) | 同上 | -| [monitor_outcome.py](monitor_outcome.py) | 監視の結果の理由の語彙と、結果ファイル・監視の記録の読み書き(#662) | 同上 | +| [monitor_outcome.py](monitor_outcome.py) | 監視の結果の理由の語彙(9 語)と起動し直しの可否、結果ファイル・監視の記録の読み書き(#662)、起動 1 回の結末を 1 つの値として読む `read_launch_outcome`(#729) | 同上 | | [launch-cli.sh](launch-cli.sh) | claude / codex / agy / kiro をランタイム名で分岐して背景起動する | 同上 | | [_tmpdir.sh](_tmpdir.sh) | 一時ディレクトリの解決。環境変数名とディレクトリ名を引数で受ける | 同上 | | [statefile.py](statefile.py) | 状態ファイルの読み書きと KEY=VALUE 出力、保存の後の差し込み口 | 同上 | diff --git a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md index b8d74728..3b392c35 100644 --- a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md +++ b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md @@ -191,28 +191,22 @@ fi |---|---| | pidfile + `kill -0` | プロセス生存確認。alive 確認後に `/proc//cmdline` で agent 名一致も検証 (PID 再利用対策)。**プロセスが既に死んでいる場合は result.json の有無のみで OK 判定**する (死亡直後 cmdline 不一致で誤検知しないため) | | codex sentinel | err.log に `^tokens used$` 出現で正常完了マーク | -| early-error | **行頭限定** で `^Error:` / `^FATAL:` / `^panic:` / `^Traceback ` / `^HTTP/1.1 401\|403\|429` / `^Approval mode overridden to "default"` / `^Authentication failed` / 「quota exceeded」「rate limit exceeded」「API key not found/missing/invalid」「sandbox error」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しないよう anchor + benign フィルタ併用) | +| early-error | 致命の文言を検出して止める。**利用上限**(`Monthly request limit reached` / `"api_error_status":429` / 「quota exceeded」「rate limit exceeded」/ `^HTTP/1.1 429`)は理由 `usage_limit`、それ以外の致命(`^HTTP/1.1 401\|403` / `^Authentication failed` / 「API key not found/missing/invalid」「sandbox error」など)は理由 `early_error`。どちらも状態は `EARLY_ERROR`(終了コード 4)。err.log は全担当、stdout.log は claude だけ JSON 向けの照合で見る。diff / doc 引用文中の同語句は anchor + benign フィルタで除外する。`^Error:` / `^Traceback ` などは警告に留めて止めない | | stall timeout | err.log + stdout.log + progress.log の合計サイズが一定時間変化しなければ STALLED で中断。既定は **agent 別** (codex=**180s** / agy=**480s** / kiro=**480s** / claude=**900s**、上限の表)。agy は err.log がほぼ無音のため大きめに取る。上書き方法: CLI `--stall-timeout` (明示優先) > env `MONITOR_STALL_` (per-agent) > env `MONITOR_STALL` (両 agent 共通) > agent 別ビルトイン | | hard timeout | 既定は `--phase` の工程の値で、上限の表(`scripts/lib/limits.py`)が持つ(review / critique は **1200 秒**)。上書きは `--timeout` > env `MONITOR_TIMEOUT_` > env `MONITOR_TIMEOUT`。agy の CLI の上限(`--print-timeout`)は同じ解決の値 + 120 秒で、監視より先に打ち切らない。解決した stall が hard timeout 以上なら担当ごとに警告する | | progress.log heartbeat | launcher が任意で `-review-pr-progress.log` への短いフェーズマーカー出力を要求し、monitor が最終行を stderr の heartbeat に表示する。内部推論ではなく `scan` / `analyze` / `post` / `done` などの監視用ステータスだけを出す | -| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3) | +| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3)。err.log に CLI 自身の上限の文言(agy の `print timeout after … with turn in progress`)があれば理由は `cli_timeout`、無ければ `missing` | | **result.json + age fallback** | sentinel を持たない agent (agy) 向け。プロセスが alive のまま result.json の mtime が **30 秒以上前**なら完了とみなし kill → OK。結果を書いた後にプロセスが終わらないケースに対応 (codex は sentinel チェックが先に発火するため影響なし) | -| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象プロセスに SIGTERM → 3 秒後 SIGKILL。残存プロセスが後から `gh api` 投稿や result.json 書き込みを行うのを防ぐ | - -> ⚠ **罠**: `nohup ... &` でラッパーシェルは即終了し、ハーネスから -> 「タスク完了」通知が飛んでくる。これに惑わされず、`monitor.py` で -> 実プロセスの完了を pidfile / sentinel で確認すること。 -> -> ⚠ **`pgrep -fa ` で完了判定しない**: agy は long `-p` プロンプトを -> 引数に持つため、`grep` のキーワード選定で誤検知する。**pidfile 必須**。 -> -> ⚠ **sentinel 単独で完了判定しない**: codex がクラッシュすると `tokens used` が -> 永遠に出ない。`monitor.py` は sentinel と pidfile/result.json/err.log を併用する。 -> -> ⚠ **Docker 環境ではゾンビプロセスに注意**: `nohup ... & disown` で起動した -> プロセスは、終了後にゾンビ化する (PID 1 が proper init でない場合)。 -> `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。 -> **推奨: Docker 実行時に `--init` フラグを付ける** (tini が PID 1 になりゾンビを reap する)。 +| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象に SIGTERM → 3 秒後 SIGKILL。`launch-cli.sh` は CLI を独立したプロセスグループで起動する(`set -m`)ため、対象がグループの先頭なら**グループごと**止める。止めた後に子プロセスが `gh api` 投稿や result.json 書き込みを行うのを防ぐ(#584) | + +完了判定の罠を 4 つ挙げる。 + +| 罠 | 守ること | +|---|---| +| `nohup ... &` でラッパーシェルは即終了し、ハーネスから「タスク完了」通知が飛んでくる | 惑わされず、`monitor.py` で実プロセスの完了を pidfile / sentinel で確認する | +| agy は長い `-p` プロンプトを引数に持ち、`pgrep -fa ` はキーワード選定で誤検知する | `pgrep` で完了判定しない。**pidfile 必須** | +| codex がクラッシュすると sentinel `tokens used` が永遠に出ない | sentinel 単独で完了判定しない。`monitor.py` は pidfile / result.json / err.log を併用する | +| Docker では `nohup ... & disown` のプロセスが終了後にゾンビ化する(PID 1 が init でない場合) | `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。**Docker 実行時は `--init` を付ける**(tini がゾンビを回収する) | AI への入出力の契約(2.2)と、AI が書き出すファイルの契約(2.3)は [04-contracts.md](04-contracts.md) にある。 @@ -230,9 +224,9 @@ done #### 申告されたコメント数を GitHub 側と突き合わせる -投稿は **AI 自身が `gh api` で行う**ため、失敗しても結果ファイルの申告だけは残る。 -申告のまま進むと、修正担当が読むべき指摘が GitHub 上に存在しないまま収束判定まで走る。 -実測では、2 件の申告に対しスレッドが 1 つも作られていなかった。 +投稿は **AI 自身が `gh api` で行う**ため、失敗しても結果ファイルの申告だけは残る。申告のまま進むと、 +修正担当が読むべき指摘が GitHub 上に存在しないまま収束判定まで走る。実測では、2 件の申告に対し +スレッドが 1 つも作られていなかった。 `read-result` は申告が 1 件以上のとき、`review_url` の識別子から `repos//pulls//reviews//comments` を数えて突き合わせる。 @@ -244,8 +238,7 @@ done | n 件 | n 件未満 | **中断する。** 投稿が届いていない | | n 件 | 取得できない | 申告を採用し、確認できなかったことを出力へ残す | -**「取得できなかった」と「0 件」を区別する。** 取得の失敗で止めると、GitHub 側の -一時的な不調でループが進まなくなる。 +**「取得できなかった」と「0 件」を区別する。** 取得の失敗で止めると、GitHub 側の一時的な不調でループが進まなくなる。 **誰がレビューし、いつ止めるかは [05-pool-and-convergence.md](05-pool-and-convergence.md) にある。** 母集合・担当の輪番・認証の確認と、終了基準の 3 つの層をそこで定める。 @@ -289,35 +282,45 @@ eval "$JUDGE_VARS" | `SKIP` | `--only` の指定で起動しなかった | 判定へ届かない(短絡する) | | `NO_RESULT` | 起動したが、使える結果が残らなかった | 通らない | -`NO_RESULT` は `read-result` が書き込む。理由は `no_result_reason` に残る。 +`NO_RESULT` は `read-result` が書き込む。理由は `no_result_reason` に、監視が残した詳細(err.log の +抜粋、最大 200 文字)は `monitor_detail` に残る(監視の結果ファイルがあったときだけ)。理由の語彙と +起動し直しの可否を持つのは共通層の `monitor_outcome.py` だけで、`read-result` はその値を写す(#729)。 -| 理由 | 何が起きたか | `read-result` の終了コード | -| --- | --- | --- | -| `missing` | 結果ファイルが無い、または空 | 1 | -| `unparsable` | JSON として読めない、または dict ではない | 3 | -| `no_verdict` | `event` も `intent` も無い | 1 | - -**記録が無いラウンドも結果なしとして読む。** 骨組みが取り込みを呼び忘れても、判定は -収束しない。 - -終了コード 7 のとき、判定は次の 2 行を追加で出力し、対象を `rounds[-1].relaunched` へ -記録する。**2 度目の判定は記録を見て中断へ回る。** +| 理由 | 何が起きたか | 起動し直し | `read-result` の終了コード | +| --- | --- | --- | --- | +| `missing` | 結果ファイルが無い・空で、監視の理由の文言も無い | 可 | 1 | +| `unparsable` | 結果ファイルがあるが JSON オブジェクトとして読めない | 可 | 3 | +| `no_verdict` | `event` も `intent` も無い | 可 | 1 | +| `not_posted` | 投稿が Pull Request に届いていない | 可 | 1 | +| `timeout` | 監視の上限で打ち切った | 可 | 1 | +| `stalled` | 無進捗の許容を超えて打ち切った | 可 | 1 | +| `early_error` | 利用上限以外の致命の文言で止めた | 可 | 1 | +| `usage_limit` | 担当の CLI の利用上限(月間・週間の枠)に達した | **否** | 1 | +| `cli_timeout` | CLI 自身の上限(agy の `--print-timeout`)で結果を書かずに終わった | 可 | 1 | +| `pidfile_bad` | pid ファイルが無い・別のプロセスを指す | 可 | 1 | + +**記録が無いラウンドも結果なしとして読む。** 骨組みが取り込みを呼び忘れても、判定は収束しない。 + +結果なしの担当があると、判定は理由を `NO_RESULT_REASONS='<担当>=<理由> ...'` の 1 行で出す。 +**起動し直しても解けない理由(`usage_limit`)を含むときは起動し直さず**、`final=error` として終了 +コード 1 で終える(標準エラーに担当・理由・`monitor_detail`)。利用上限は同じ担当を何度起動しても +解けず、待ちと相手の枠を消費するだけである(#619)。それ以外の理由なら終了コード 7 で次の 2 行を +追加で出力し、対象を `rounds[-1].relaunched` へ記録する。**2 度目の判定は記録を見て中断へ回る。** ```text +NO_RESULT_REASONS='agy=missing' RELAUNCH_AGENTS='agy' RELAUNCH_TARGET=agy ``` -`RELAUNCH_TARGET` は `codex` / `agy` / `both` のいずれかで、`monitor.py` へそのまま -渡せる値である。起動し直しはラウンドの中で完結するため、**ラウンドの数え方と上限の -意味は変わらない。** 回数を 1 度に限るのは、待ち時間の上限をラウンドあたり 2 回分に -収めるためである。 +`RELAUNCH_TARGET` は `codex` / `agy` / `both` のいずれかで、`monitor.py` へそのまま渡せる値である。 +起動し直しはラウンドの中で完結するため、**ラウンドの数え方と上限の意味は変わらない。** 回数を +1 度に限るのは、待ち時間の上限をラウンドあたり 2 回分に収めるためである。 ### 判定へ入れる対象 -判定は 2 つの対象を別々に見る。**投稿数と未解決の指摘の数は一致しない。** -投稿数はそのラウンドで外部の AI が新しく投稿した件数で、未解決の指摘は前のラウンドの -分も含む Pull Request 上の総数である。 +判定は 2 つの対象を別々に見る。**投稿数と未解決の指摘の数は一致しない。** 投稿数はそのラウンドで +外部の AI が新しく投稿した件数で、未解決の指摘は前のラウンドの分も含む Pull Request 上の総数である。 | 対象 | 判定への入れ方 | 数え方 | |---|---|---| diff --git a/plugins/ndf/skills/cross-review/docs/03-review-output.md b/plugins/ndf/skills/cross-review/docs/03-review-output.md index a74f062e..b070b56b 100644 --- a/plugins/ndf/skills/cross-review/docs/03-review-output.md +++ b/plugins/ndf/skills/cross-review/docs/03-review-output.md @@ -120,7 +120,23 @@ pint / larastan / test / build などは **中断** を原則とする。 環境変数) で EARLY_ERROR 検知自体を無効化し、hard timeout / stall / sentinel / result.json のみで判定するモードに切り替える 3. **新しい致命パターンを観測した場合**: `EARLY_ERROR_FATAL` に追記する (PR で plugin に反映)。 - 曖昧パターンは `EARLY_ERROR_WARN` 側に置き、kill 対象にはしない + 曖昧パターンは `EARLY_ERROR_WARN` 側に置き、kill 対象にはしない。利用上限の文言は + `USAGE_LIMIT_FATAL`(claude の JSON は `CLAUDE_STDOUT_USAGE_LIMIT`)に置く + +### 上限に当たった場合の見分け方 + +止めたのが監視の上限か、担当の CLI の利用上限か、CLI 自身の上限かは、監視が書いた理由で +見分ける。`err.log` の目視より先にこれを読む(#619 #729)。 + +| 読む場所 | 何が分かるか | +| --- | --- | +| `$TMP_DIR/-review-pr-monitor.json` の `reason` | その担当の**最後の**起動の理由。`usage_limit` なら利用上限、`cli_timeout` なら CLI 自身の上限、`timeout` なら監視の上限、`missing` なら文言の無い結果なし | +| 同じファイルの `detail` | 一致した文言を含む err.log / stdout.log の抜粋(最大 200 文字) | +| `$TMP_DIR/monitor-outcomes.jsonl` | 起動ごとに 1 行が追記だけで積まれる。起動し直した担当の **1 回目の理由**はここに残る(`-monitor.json` は 2 回目で上書きされる)。`reason` と `ended_at` で並べて読む | +| 状態ファイルの `rounds[-1]..no_result_reason` / `monitor_detail` | `read-result` が写した値。`state.py report` のラウンド表には `=NO_RESULT(<理由>)` の形で出る | + +`usage_limit` は起動し直しても解けないため、判定は同じラウンドで起動し直さず終了コード 1 +で終える。枠が戻るまで待つか、その担当を外して回すかは進行側が決める。 - ❌ **fix サブエージェントが Resolve をスキップ** — reply だけでは未対応扱い。Resolve まで実行 - ❌ **review body に identifier prefix を付け忘れる** — GitHub UI 上で誰のレビューか不明になる diff --git a/plugins/ndf/skills/cross-review/docs/04-contracts.md b/plugins/ndf/skills/cross-review/docs/04-contracts.md index b73c2bf3..91b8e8b4 100644 --- a/plugins/ndf/skills/cross-review/docs/04-contracts.md +++ b/plugins/ndf/skills/cross-review/docs/04-contracts.md @@ -239,10 +239,20 @@ launcher が生成するプロンプトに以下を強制している: | `monitor-outcomes.jsonl` | `$TMP_DIR` | 監視の結果を 1 行 1 つで**追記だけ**で積む | 同上。消さない | | `cross-review-pr-<開始時刻の UTC>.json` | 要約の置き場所の `--/` | 実行の要約(所要・結末・ラウンド・起動と `measure`)。**本文・`detail` を含まない** | 状態を保存するたび(同じ実行は上書き) | -`reason` は `status` から決まる(`OK`→`ok` / `TIMEOUT`→`timeout` / `STALLED`→`stalled` / -`EARLY_ERROR`→`early_error` / `NO_RESULT`→`missing` / `PIDFILE_BAD`→`pidfile_bad`)。 +`reason` は既定では `status` から決まる(`OK`→`ok` / `TIMEOUT`→`timeout` / `STALLED`→`stalled` / +`EARLY_ERROR`→`early_error` / `NO_RESULT`→`missing` / `PIDFILE_BAD`→`pidfile_bad`)。監視が +文言で区別した 2 つだけが状態から決まらない: 利用上限は `EARLY_ERROR` のまま `usage_limit`、 +CLI 自身の上限で結果を書かずに終わったときは `NO_RESULT` のまま `cli_timeout`(#729)。 監視の標準出力と終了コードは変わらない。 +結果の取り込みは結果ファイルを自前で開かず、共通層の `monitor_outcome.read_launch_outcome(tmp_dir, +"-review-pr", result_path)` が返す値(使える結果 `payload` / 理由 `reason` / 監視の詳細 +`detail` / 起動し直しの可否 `relaunch_same_agent`)を読む。結果なしのときは +`rounds[-1].` に `intent: "NO_RESULT"`、`no_result_reason: `、監視の結果ファイルが +あれば `monitor_detail: ` を書く(**鍵が無い** = 監視の結果ファイルが無かった。空文字は +書かない)。理由の一覧は [01-state-and-review.md](01-state-and-review.md) の「結果を残さなかった +レビュアーの扱い」にある。 + **要約の置き場所は作業ツリーの外である。** `NDF_METRICS_DIR` → `$XDG_STATE_HOME/ndf/metrics` → `$HOME/.local/state/ndf/metrics` の順に決まり、`NDF_METRICS=0` のときは書かない。`state.py report` の最後の行が、書いた要約のパスか書かなかった理由を出す。束ねるのは共通層の `run_metrics.py aggregate` From 3a7e27e53384c70684bcfd36cc5af1a610641302 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:04:13 +0000 Subject: [PATCH 031/217] =?UTF-8?q?Test:=20=E8=AA=8D=E8=A8=BC=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=81=A8=E7=9B=A3=E8=A6=96=E8=A8=98=E9=8C=B2=E3=81=AE?= =?UTF-8?q?=E7=8F=BE=E7=8A=B6=E5=8B=95=E4=BD=9C=E3=82=92=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 終了コード0の未認証判定と、UTF-8を含む監視結果の追記順を現状固定テストで覆う。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_auth_probe.py | 20 +++++++++++++++++++ .../tests/test_monitor_outcome_unit.py | 13 ++++++++++++ 2 files changed, 33 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_auth_probe.py b/plugins/ndf/scripts/tests/test_auth_probe.py index 30eaa4d0..d2470df3 100644 --- a/plugins/ndf/scripts/tests/test_auth_probe.py +++ b/plugins/ndf/scripts/tests/test_auth_probe.py @@ -4,6 +4,7 @@ import pathlib import subprocess import sys +from types import SimpleNamespace LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" @@ -43,3 +44,22 @@ def time_out(*args, **kwargs): assert results["codex"]["ok"] is False assert str(auth.AUTH_PROBE_TIMEOUT) in results["codex"]["detail"] assert len(failures) == 1 + + +def test_unauthenticated_marker_fails_even_when_probe_exits_zero(monkeypatch): + auth = _load_auth() + messages: list[str] = [] + failures: list[str] = [] + + monkeypatch.setattr( + auth.subprocess, + "run", + lambda *args, **kwargs: SimpleNamespace( + returncode=0, stdout="Not logged in", stderr="" + ), + ) + + results = auth.check_auth(["codex"], info=messages.append, die=failures.append, env={}) + + assert results["codex"]["ok"] is False + assert len(failures) == 1 diff --git a/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py b/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py index dded7078..91af3943 100644 --- a/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py +++ b/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py @@ -31,6 +31,19 @@ def test_read_journal_ignores_malformed_and_non_object_rows(tmp_path): assert _load_monitor_outcome().read_journal(tmp_path) == [valid] +def test_append_journal_preserves_order_and_utf8_content(tmp_path): + mod = _load_monitor_outcome() + outcomes = [ + {"agent": "codex", "status": "OK", "detail": "完了"}, + {"agent": "kiro", "status": "EARLY_ERROR", "detail": "認証が必要"}, + ] + + for outcome in outcomes: + mod.append_journal(tmp_path, outcome) + + assert mod.read_journal(tmp_path) == outcomes + + # ---------- 理由の語彙と起動し直しの可否(#729 の AC1 / AC10) ---------- import pytest # noqa: E402 From a1882bf7037a98281789c660532fd46c74cde247 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:05:09 +0000 Subject: [PATCH 032/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 80 ++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 issues/refactoring-plan-rf791.md diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md new file mode 100644 index 00000000..e5074138 --- /dev/null +++ b/issues/refactoring-plan-rf791.md @@ -0,0 +1,80 @@ +# 改修計画 — devbasex/ai-plugins #791 + +`/ndf:cross-refactoring` が提案し、適用した改善項目の記録である。 +理由と手順は提案の時点でしか残らないため、公開の直前に書き出している。 + +- 対象範囲: plugins/ndf/scripts/lib, plugins/ndf/skills/cross-review/scripts, plugins/ndf/scripts/tests, plugins/ndf/skills/cross-review/tests +- 着手前のテスト: uv run --with pytest pytest scripts/tests plugins/ndf -q + +## ラウンド 1(実装 codex / レビュー agy / kiro) + +### R1-001 — `plugins/ndf/scripts/lib/auth.py#check_auth` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | unit | — | kiro | 検証中 | 1 | + +**なぜ**: 終了コード 0 でも UNAUTHENTICATED_MARKERS を含む出力を未認証と判定する分岐が固定されていない。これはこのモジュールの存在理由そのものだが未固定である + +**手順**: 1. subprocess.run を差し替え、returncode 0 かつ stdout に 'not logged in' を含む結果を返す +2. check_auth(['codex'], ...) を呼ぶ +3. results['codex']['ok'] が False であることを確認する +4. failed があるため die が一度呼ばれることを確認する + +### R1-002 — `plugins/ndf/scripts/lib/auth.py#check_auth` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | unit | — | kiro | 未着手 | 0 | + +**なぜ**: 確認コマンドが見つからないとき(FileNotFoundError)に ok=False とし detail を 'コマンドが見つかりません' にする分岐が固定されていない + +**手順**: 1. subprocess.run を差し替え、FileNotFoundError を送出させる +2. check_auth(['codex'], ...) を呼ぶ +3. results['codex']['ok'] が False であることを確認する +4. results['codex']['detail'] に見つからない旨が入り、die が一度呼ばれることを確認する + +### R1-003 — `plugins/ndf/scripts/lib/monitor_outcome.py#append_journal` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| normal | unit | — | agy | 検証中 | 1 | + +**なぜ**: monitor_outcome.py には read_journal の単体テストはあるが、ペアとなる append_journal の単体テストが存在しない。親ディレクトリの自動生成や非ASCII文字(UTF-8)の保持を含め、複数回の追記によって順序通りに記録・復元できる正常系が単体レベルで未固定である。 + +**手順**: 1. 一時ディレクトリと複数の outcome 辞書を準備する +2. append_journal を複数回呼び出して追記する +3. read_journal で読み出し、追記された順序と内容が期待通り一致することを検証する + +### R1-004 — `plugins/ndf/scripts/lib/monitor_outcome.py#default_result_path` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| normal | unit | — | agy | 未着手 | 0 | + +**なぜ**: read_launch_outcome の既定フォールバック先として使われる公開関数 default_result_path について、パス命名規則(/-result.json)を直接検証する単体テストが存在しない。 + +**手順**: 1. tmp_dir と stem を準備する +2. default_result_path(tmp_dir, stem) を呼び出す +3. 返された Path が期待される /-result.json と等しいことを検証する + +### R1-005 — `plugins/ndf/scripts/lib/monitor_outcome.py#relaunch_same_agent` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| boundary | unit | — | agy | 未着手 | 0 | + +**なぜ**: relaunch_same_agent は再試行可否の判定に使われる。既存テストは定義済み語彙(REASONSの9語)のみをテストしており、reason=None や空文字列 ""、未知の理由文字列が渡された場合に True を返す境界値の挙動が固定されていない。 + +**手順**: 1. None、空文字列 ""、未知の理由文字列を引数として準備する +2. relaunch_same_agent を呼び出す +3. いずれも戻り値が True であることを検証する + +## 見送った項目 + +| ラウンド | 対象 | 兆候・経路 | 理由 | +| --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/lib/statefile.py#save` | error | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/scripts/lib/statefile.py#save` | normal | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | boundary | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | error | 1 ラウンドの採用上限 5 件を超えた | From eb8a0d0c9f9c8fa212f78b03ce04919443fd92cf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:23:36 +0000 Subject: [PATCH 033/217] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=20=E2=80=94=20plugins/ndf/scripts/lib/refresh.py#comp?= =?UTF-8?q?are?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit compare の 4 分岐(取得失敗・前回記録なし・一致・不一致)の現状の戻り値を 単体テストとして固定する。既存の instructions-check のテストは refresh.fetch を スタブへ差し替えるため、compare 本体はどこでも通っていなかった。 対象のコードは変更しない。 Item-Id: R2-001 Round: 2 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Fable 5.1 --- plugins/ndf/scripts/tests/test_refresh.py | 54 +++++++++++++++++++++++ 1 file changed, 54 insertions(+) create mode 100644 plugins/ndf/scripts/tests/test_refresh.py diff --git a/plugins/ndf/scripts/tests/test_refresh.py b/plugins/ndf/scripts/tests/test_refresh.py new file mode 100644 index 00000000..e91a23da --- /dev/null +++ b/plugins/ndf/scripts/tests/test_refresh.py @@ -0,0 +1,54 @@ +"""`lib/refresh.py` の `compare` の現状固定テスト(cross-refactoring R2-001)。 + +`compare` は取得失敗・前回記録なし・一致・不一致の 4 分岐を持つ純関数だが、 +instructions-check のテストは `refresh.fetch` をスタブへ差し替えるため本体を通らない。 +ここでは **現状の戻り値をそのまま正解として記録する**。正しさの主張ではない。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys + + +LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" + + +def _load_refresh(): + # `from __future__ import annotations` 下の dataclass は `sys.modules` から + # 自モジュールを引くため、登録してから実行する。 + spec = importlib.util.spec_from_file_location("ndf_lib_refresh_unit", LIB / "refresh.py") + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +def test_compare_returns_undecidable_when_fetch_failed(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=False, error="HTTP 404") + assert refresh.compare(result, "sha256:aa") == "判定できない" + + +def test_compare_reports_no_previous_record_when_previous_is_none(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, None) == "前回の記録が無い" + + +def test_compare_reports_no_previous_record_when_previous_is_empty(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, "") == "前回の記録が無い" + + +def test_compare_reports_unchanged_when_fingerprint_matches(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, "sha256:aa") == "変わっていない" + + +def test_compare_reports_changed_when_fingerprint_differs(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, "sha256:bb") == "変わった" From aeeff0ad8fec3cf62fbfe500d66705efc53b5e3e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:24:44 +0000 Subject: [PATCH 034/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 43 ++++++++++++++++++++++++++++++-- 1 file changed, 41 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index e5074138..df9384cd 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -12,7 +12,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | kiro | 検証中 | 1 | +| branch | unit | — | kiro | 採用 | 1 | **なぜ**: 終了コード 0 でも UNAUTHENTICATED_MARKERS を含む出力を未認証と判定する分岐が固定されていない。これはこのモジュールの存在理由そのものだが未固定である @@ -38,7 +38,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| normal | unit | — | agy | 検証中 | 1 | +| normal | unit | — | agy | 採用 | 1 | **なぜ**: monitor_outcome.py には read_journal の単体テストはあるが、ペアとなる append_journal の単体テストが存在しない。親ディレクトリの自動生成や非ASCII文字(UTF-8)の保持を含め、複数回の追記によって順序通りに記録・復元できる正常系が単体レベルで未固定である。 @@ -70,6 +70,45 @@ 2. relaunch_same_agent を呼び出す 3. いずれも戻り値が True であることを検証する +## ラウンド 2(実装 agy / レビュー codex / kiro) + +### R2-001 — `plugins/ndf/scripts/lib/refresh.py#compare` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | unit | — | kiro | 検証中 | 1 | + +**なぜ**: compare は取得失敗・前回記録なし・一致・不一致の 4 分岐を返す純関数だが、対象範囲のどこでも固定されていない。instructions-check の既存テストは refresh.fetch をスタブへ差し替えるため、compare 本体は一度も通らない + +**手順**: 1. ok=False の FetchResult を作り compare(result, 'sha256:aa') を呼ぶ +2. ok=True で fingerprint を持つ FetchResult を作り、previous を None・空文字・同じ指紋・異なる指紋の 4 通りで呼ぶ +3. 実行して得た戻り値(取得失敗・前回記録なし・一致・不一致を表す文字列)を期待値として固定する + +### R2-002 — `plugins/ndf/scripts/lib/refresh.py#fetch` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | unit | — | kiro | 未着手 | 0 | + +**なぜ**: fetch は取得の失敗を例外にせず FetchResult(ok=False, error=...) へ畳む分岐を持ち、error の文言は _reason が例外の種類ごとに書き分ける。この経路は未固定で、instructions-check のテストは fetch 自体を差し替えるため通らない。opener は差し替え用に設計された引数である + +**手順**: 1. opener に OSError を送出する呼び出し可能を渡し fetch(url, timeout, opener) を呼ぶ +2. urllib.error.HTTPError と urllib.error.URLError を送出する opener でも同様に呼ぶ +3. 3 通りとも ok が False で、実行して得た error の文言(種類ごとに異なる)を期待値として固定する + +### R2-003 — `plugins/ndf/scripts/lib/refresh.py#refresh` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| normal | integration | — | kiro | 未着手 | 0 | + +**なぜ**: refresh は fetch・row・compare をつないで全件の 1 行と失敗件数を返す公開入口だが、この配線を通す固定が対象範囲に無い。取れなかった URL を黙って落とさず件数へ数える振る舞いが未固定である + +**手順**: 1. 成功と失敗を混ぜて返す opener を差し替えで用意する +2. name/checked_at/claim を持つ複数の source を渡し refresh(sources, timeout, opener) を呼ぶ +3. 返る行数が source 数と一致し、失敗件数が失敗した source 数と一致することを固定する +4. 各行に source の name と取得の成否が含まれることを、実行して得た値で固定する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | From 38afae07554d96b017f2006f35ad7b3c5fd9b876 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:32:57 +0000 Subject: [PATCH 035/217] =?UTF-8?q?Test:=20fetch=20=E3=81=AE=E5=8F=96?= =?UTF-8?q?=E5=BE=97=E5=A4=B1=E6=95=97=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OSError、HTTPError、URLError を opener から発生させ、FetchResult の失敗理由を固定する。 Item-Id: R2-002 Round: 2 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_refresh.py | 42 ++++++++++++++++++++++- 1 file changed, 41 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/tests/test_refresh.py b/plugins/ndf/scripts/tests/test_refresh.py index e91a23da..5d0b2602 100644 --- a/plugins/ndf/scripts/tests/test_refresh.py +++ b/plugins/ndf/scripts/tests/test_refresh.py @@ -1,7 +1,8 @@ -"""`lib/refresh.py` の `compare` の現状固定テスト(cross-refactoring R2-001)。 +"""`lib/refresh.py` の `compare` と `fetch` の現状固定テスト。 `compare` は取得失敗・前回記録なし・一致・不一致の 4 分岐を持つ純関数だが、 instructions-check のテストは `refresh.fetch` をスタブへ差し替えるため本体を通らない。 +同じく `fetch` の取得失敗経路も本体を通らない。 ここでは **現状の戻り値をそのまま正解として記録する**。正しさの主張ではない。 """ from __future__ import annotations @@ -9,6 +10,7 @@ import importlib.util import pathlib import sys +import urllib.error LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" @@ -52,3 +54,41 @@ def test_compare_reports_changed_when_fingerprint_differs(): refresh = _load_refresh() result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") assert refresh.compare(result, "sha256:bb") == "変わった" + + +def test_fetch_returns_os_error_as_failure(): + refresh = _load_refresh() + + def opener(_url, timeout): + raise OSError("disk unavailable") + + result = refresh.fetch("https://example.test/x", 1, opener) + + assert result.ok is False + assert result.error == "OSError: disk unavailable" + + +def test_fetch_returns_http_error_as_failure(): + refresh = _load_refresh() + + def opener(_url, timeout): + raise urllib.error.HTTPError( + "https://example.test/x", 503, "Service Unavailable", {}, None + ) + + result = refresh.fetch("https://example.test/x", 1, opener) + + assert result.ok is False + assert result.error == "HTTP 503" + + +def test_fetch_returns_url_error_as_failure(): + refresh = _load_refresh() + + def opener(_url, timeout): + raise urllib.error.URLError("name resolution failed") + + result = refresh.fetch("https://example.test/x", 1, opener) + + assert result.ok is False + assert result.error == "接続できない(name resolution failed)" From e5d05ebb086b5bbc5ebc350b17e11bd943759d5b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:33:36 +0000 Subject: [PATCH 036/217] =?UTF-8?q?Revert=20"Test:=20fetch=20=E3=81=AE?= =?UTF-8?q?=E5=8F=96=E5=BE=97=E5=A4=B1=E6=95=97=E3=82=92=E7=8F=BE=E7=8A=B6?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E3=81=99=E3=82=8B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 38afae07554d96b017f2006f35ad7b3c5fd9b876. --- plugins/ndf/scripts/tests/test_refresh.py | 42 +---------------------- 1 file changed, 1 insertion(+), 41 deletions(-) diff --git a/plugins/ndf/scripts/tests/test_refresh.py b/plugins/ndf/scripts/tests/test_refresh.py index 5d0b2602..e91a23da 100644 --- a/plugins/ndf/scripts/tests/test_refresh.py +++ b/plugins/ndf/scripts/tests/test_refresh.py @@ -1,8 +1,7 @@ -"""`lib/refresh.py` の `compare` と `fetch` の現状固定テスト。 +"""`lib/refresh.py` の `compare` の現状固定テスト(cross-refactoring R2-001)。 `compare` は取得失敗・前回記録なし・一致・不一致の 4 分岐を持つ純関数だが、 instructions-check のテストは `refresh.fetch` をスタブへ差し替えるため本体を通らない。 -同じく `fetch` の取得失敗経路も本体を通らない。 ここでは **現状の戻り値をそのまま正解として記録する**。正しさの主張ではない。 """ from __future__ import annotations @@ -10,7 +9,6 @@ import importlib.util import pathlib import sys -import urllib.error LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" @@ -54,41 +52,3 @@ def test_compare_reports_changed_when_fingerprint_differs(): refresh = _load_refresh() result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") assert refresh.compare(result, "sha256:bb") == "変わった" - - -def test_fetch_returns_os_error_as_failure(): - refresh = _load_refresh() - - def opener(_url, timeout): - raise OSError("disk unavailable") - - result = refresh.fetch("https://example.test/x", 1, opener) - - assert result.ok is False - assert result.error == "OSError: disk unavailable" - - -def test_fetch_returns_http_error_as_failure(): - refresh = _load_refresh() - - def opener(_url, timeout): - raise urllib.error.HTTPError( - "https://example.test/x", 503, "Service Unavailable", {}, None - ) - - result = refresh.fetch("https://example.test/x", 1, opener) - - assert result.ok is False - assert result.error == "HTTP 503" - - -def test_fetch_returns_url_error_as_failure(): - refresh = _load_refresh() - - def opener(_url, timeout): - raise urllib.error.URLError("name resolution failed") - - result = refresh.fetch("https://example.test/x", 1, opener) - - assert result.ok is False - assert result.error == "接続できない(name resolution failed)" From 68fe443b0df1264de7275af1f9931b98b21698bd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:33:36 +0000 Subject: [PATCH 037/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index df9384cd..e605028e 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -76,7 +76,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | kiro | 検証中 | 1 | +| branch | unit | — | kiro | 採用 | 1 | **なぜ**: compare は取得失敗・前回記録なし・一致・不一致の 4 分岐を返す純関数だが、対象範囲のどこでも固定されていない。instructions-check の既存テストは refresh.fetch をスタブへ差し替えるため、compare 本体は一度も通らない @@ -88,7 +88,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | unit | — | kiro | 未着手 | 0 | +| error | unit | — | kiro | 取り消し | 1 | **なぜ**: fetch は取得の失敗を例外にせず FetchResult(ok=False, error=...) へ畳む分岐を持ち、error の文言は _reason が例外の種類ごとに書き分ける。この経路は未固定で、instructions-check のテストは fetch 自体を差し替えるため通らない。opener は差し替え用に設計された引数である @@ -117,3 +117,4 @@ | 1 | `plugins/ndf/scripts/lib/statefile.py#save` | normal | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | boundary | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | error | 1 ラウンドの採用上限 5 件を超えた | +| 2 | `plugins/ndf/scripts/lib/refresh.py#fetch` | error | テストの期待する振る舞いが変わっています(plugins/ndf/scripts/tests/test_refresh.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | From bf5f7671bc28ea8d6d4453cc2ee82285cf33ad99 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:37:33 +0000 Subject: [PATCH 038/217] =?UTF-8?q?Test:=20refresh=20=E3=81=AE=E5=85=A8?= =?UTF-8?q?=E4=BB=B6=E9=85=8D=E7=B7=9A=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=81=99=E3=82=8B=20=E2=80=94=20plugins/ndf/scripts/l?= =?UTF-8?q?ib/refresh.py#refresh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 成功と失敗を混ぜて返す opener を差し替えで用意し、複数の source を渡して refresh(sources, timeout, opener) を呼ぶ。返る行数が source 数と一致し、 失敗件数が失敗した source 数と一致すること、各行に name と取得の成否が 含まれることを、実行して得た値で固定する。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_refresh.py | 67 +++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_refresh.py b/plugins/ndf/scripts/tests/test_refresh.py index e91a23da..72363c43 100644 --- a/plugins/ndf/scripts/tests/test_refresh.py +++ b/plugins/ndf/scripts/tests/test_refresh.py @@ -52,3 +52,70 @@ def test_compare_reports_changed_when_fingerprint_differs(): refresh = _load_refresh() result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") assert refresh.compare(result, "sha256:bb") == "変わった" + + +class _FakeResponse: + """`opener` が返す応答の疑似実装。1 度本文を返し、次は空で終わる。""" + + def __init__(self, body: bytes): + self._body = body + self._done = False + + def read(self, _size: int) -> bytes: + if self._done: + return b"" + self._done = True + return self._body + + def close(self) -> None: + pass + + +def _opener_success_and_failure(failing_urls: set[str]): + """URL ごとに成功/失敗を出し分ける opener を作る。 + + 失敗させたい URL では例外を投げ、それ以外は本文を返す。`fetch` は失敗を + 例外にせず理由へ畳むため、この差し替えで成功と失敗を混ぜられる。 + """ + + def opener(url, timeout=None): # noqa: ARG001 - timeout は使わない + if url in failing_urls: + raise OSError("接続できない") + return _FakeResponse(url.encode("utf-8")) + + return opener + + +def test_refresh_returns_one_row_per_source_and_counts_failures(): + refresh = _load_refresh() + sources = [ + {"name": "alpha", "url": "https://example.test/a", "checked_at": "2026-01-01", "claim": "A の主張"}, + {"name": "beta", "url": "https://example.test/b", "checked_at": "2026-01-02", "claim": "B の主張"}, + {"name": "gamma", "url": "https://example.test/c", "checked_at": "2026-01-03", "claim": "C の主張"}, + ] + opener = _opener_success_and_failure({"https://example.test/b"}) + + lines, failed = refresh.refresh(sources, timeout=1.0, opener=opener) + + # 返る行数は source 数と一致する(取れなかった URL も黙って落とさない)。 + assert len(lines) == len(sources) + # 失敗件数は失敗した source の数と一致する。 + assert failed == 1 + + +def test_refresh_row_carries_name_and_fetch_state(): + refresh = _load_refresh() + sources = [ + {"name": "alpha", "url": "https://example.test/a", "checked_at": "2026-01-01", "claim": "A の主張"}, + {"name": "beta", "url": "https://example.test/b", "checked_at": "2026-01-02", "claim": "B の主張"}, + ] + opener = _opener_success_and_failure({"https://example.test/b"}) + + lines, _failed = refresh.refresh(sources, timeout=1.0, opener=opener) + + # 各行に source の name が含まれる。 + assert "alpha" in lines[0] + assert "beta" in lines[1] + # 取得の成否が行へ現れる(成功/失敗をそのまま固定する)。 + assert "取得できた" in lines[0] + assert "取得できなかった" in lines[1] From b94f80499db7b9450b644b69f29f2002ec639923 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:37:57 +0000 Subject: [PATCH 039/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index e605028e..c76feab8 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -100,7 +100,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| normal | integration | — | kiro | 未着手 | 0 | +| normal | integration | — | kiro | 検証中 | 1 | **なぜ**: refresh は fetch・row・compare をつないで全件の 1 行と失敗件数を返す公開入口だが、この配線を通す固定が対象範囲に無い。取れなかった URL を黙って落とさず件数へ数える振る舞いが未固定である From 0927df5076919694528a91f1e1ca0b9288780b09 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:51:59 +0000 Subject: [PATCH 040/217] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration?= =?UTF-8?q?=20/=20extract=5Fmethod=20/=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20=E7=9B=A3=E8=A6=96=E3=81=AE=E7=B5=90=E6=9E=9C?= =?UTF-8?q?=E3=81=A8=20cross-review=20=E7=8A=B6=E6=85=8B=E3=81=AE=E6=A7=8B?= =?UTF-8?q?=E9=80=A0=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R3-001 (centralize_configuration): monitor_outcome.OUTCOME_KEYS に `phase` を 加えて契約文書の並びと揃え、monitor.py の _record_outcome が組み立てた辞書の キー集合を OUTCOME_KEYS と突き合わせる assert を置いた。 R3-002 (extract_method): state.py の _handle_no_result_round から、理由の集約と 出力・共通の異常終了処理・再起動対象の記録と互換出力を小さな関数へ抽出した。 R3-004 (consolidate_duplication): monitor.py の monitor_agent で、5 つの終了分岐が 繰り返していた _finish_monitor 呼び出しを局所的な finish 処理へ寄せた。 振る舞いは変えていない(既存テスト 4564 件が通る)。 Item-Id: R3-001 Round: 3 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/lib/monitor.py | 28 +++++--- plugins/ndf/scripts/lib/monitor_outcome.py | 4 +- .../ndf/skills/cross-review/scripts/state.py | 72 ++++++++++++------- 3 files changed, 67 insertions(+), 37 deletions(-) diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index 8c20c701..2427ba38 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -888,11 +888,15 @@ def monitor_agent( hard timeout / stall / sentinel / result.json のみで判定する。 """ paths, status, started, pid = _initialize_monitor(agent, pr, config.stem_template) + + def finish(outcome: MonitorOutcome) -> AgentStatus: + """status とログ文脈を閉じ込めて結末を確定する。終了時のログ文脈を変えるときは + ここ 1 か所を直せばよい(各終了分岐が同じ呼び出しを繰り返さない)。""" + return _finish_monitor(status, outcome, (config.log_prefix, agent)) + if pid is None: - return _finish_monitor( - status, + return finish( MonitorOutcome.create("PIDFILE_BAD", f"pidfile not found: {paths.pidfile}"), - (config.log_prefix, agent), ) status.pid = pid @@ -928,9 +932,7 @@ def monitor_agent( if alive and (status.sentinel_seen or cmdline_validated): completion_detail = _lingering_completion(paths, status, pid, started_wall) if completion_detail is not None: - return _finish_monitor( - status, MonitorOutcome.create("OK", completion_detail), (config.log_prefix, agent) - ) + return finish(MonitorOutcome.create("OK", completion_detail)) # result.json が書かれた後もプロセスがハングするケース (実測: # MCP サーバー切断待ち等で exit しない)。sentinel 機構を持たない agent 向け @@ -943,12 +945,12 @@ def monitor_agent( pid, agent, alive, cmdline_validated ) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # 2. hard timeout outcome = _timeout_outcome(elapsed, config.timeout, alive, pid) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # 3. early error # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は @@ -956,7 +958,7 @@ def monitor_agent( # echo するケースで誤 kill されるのを防ぐ。 outcome, warn_err = _early_error_outcome(paths, status, alive, config.no_early_error) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) if not warned_early_error and warn_err: print( f"{config.log_prefix}⚠️ {agent} early-error WARN " @@ -967,7 +969,7 @@ def monitor_agent( outcome = _process_exit_outcome(paths, status, alive, config.require_result) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # agy は stdout 側だけ進捗が出るケースがあり、progress.log には @@ -978,7 +980,7 @@ def monitor_agent( ) outcome = _stall_outcome(status, config.stall_timeout, pid, last_progress_size) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # poll 中の進捗ログ _emit_progress(config.log_prefix, agent, status) @@ -1045,6 +1047,10 @@ def _record_outcome( # `--phase` の値。省いたときは null(#598 / #537) "phase": phase, } + # 組み立てたキー集合を正本(`monitor_outcome.OUTCOME_KEYS`)と突き合わせる。 + # キーを片方だけへ足すと、ここで食い違いがその場で落ちる(#662)。 + assert set(outcome) == set(monitor_outcome.OUTCOME_KEYS), ( + set(outcome).symmetric_difference(monitor_outcome.OUTCOME_KEYS)) tmp_dir = paths.pidfile.parent monitor_outcome.write_outcome(tmp_dir, stem, outcome) monitor_outcome.append_journal(tmp_dir, outcome) diff --git a/plugins/ndf/scripts/lib/monitor_outcome.py b/plugins/ndf/scripts/lib/monitor_outcome.py index ccb356cc..f2018c70 100644 --- a/plugins/ndf/scripts/lib/monitor_outcome.py +++ b/plugins/ndf/scripts/lib/monitor_outcome.py @@ -63,10 +63,12 @@ } # 結果ファイルのキー。**並びも契約の文書の表と揃える**(読む人が突き合わせやすい)。 +# `phase` は P2(#598 / #537)で足した `--phase` の値。省いたときは null で、契約の文書の +# とおり末尾に置く。 OUTCOME_KEYS = ( "agent", "stem", "status", "exit_code", "reason", "detail", "launched_at", "started_at", "ended_at", "elapsed", "idle_seconds", - "progress_tail", "result_exists", "pid", + "progress_tail", "result_exists", "pid", "phase", ) JOURNAL_NAME = "monitor-outcomes.jsonl" diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 937989d9..d9959022 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -2767,6 +2767,47 @@ def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, An return {"verdict": "success", "sha": sha} +def _no_result_reasons(last: dict[str, Any], no_result: list[str]) -> dict[str, str]: + """結果なしの担当ごとの理由を集め、`NO_RESULT_REASONS` を出す。 + + どの出口でも進行側が理由を読めるように、先頭で 1 度だけ出す(#729 の AC14)。 + """ + reasons = { + a: (last.get(a) or {}).get("no_result_reason") or "missing" for a in no_result + } + print(f"NO_RESULT_REASONS='{' '.join(f'{a}={r}' for a, r in reasons.items())}'") + return reasons + + +def _abort_no_result_round(pr: int, st: dict[str, Any], msg: str) -> None: + """結果なしのラウンドを異常終了させる共通処理。 + + `final=error` にして保存し、終了コード 1 で止める。最終スイープを通してから + 完了報告へ進むよう促す文言は呼び出し側が渡す。 + """ + st["final"] = "error" + st["ended_at"] = _now() + _save(pr, st) + die(msg, code=1) + + +def _record_relaunch( + pr: int, st: dict[str, Any], last: dict[str, Any], pending: list[str] +) -> None: + """同じラウンドで起動し直す担当を記録し、シェル向けの出力を出す。""" + last["relaunched"] = (last.get("relaunched") or []) + pending + _save(pr, st) + print(f"RELAUNCH_AGENTS='{' '.join(pending)}'") + print(f"RELAUNCH_AGENTS_CSV={','.join(pending)}") + # 互換のために残す。**`both` は codex / agy の 2 者だけを指す語**であるため、 + # 担当がそれ以外を含むラウンドでは CSV の側を使う。 + print(f"RELAUNCH_TARGET={'both' if len(pending) == 2 else pending[0]}") + info( + f"→ 結果を残さなかったレビュアーがいる: {' '.join(pending)}。" + "同じラウンドで 1 度だけ起動し直す。" + ) + + def _handle_no_result_round( pr: int, st: dict[str, Any], last: dict[str, Any], no_result: list[str] ) -> None: @@ -2779,49 +2820,30 @@ def _handle_no_result_round( である(#619)。骨組みは既存の 1 の枝で受けるため、終了コードは増えない(決定 12)。 """ last["verdict"] = "no_result" - reasons = { - a: (last.get(a) or {}).get("no_result_reason") or "missing" for a in no_result - } - print(f"NO_RESULT_REASONS='{' '.join(f'{a}={r}' for a, r in reasons.items())}'") + reasons = _no_result_reasons(last, no_result) blocked = [a for a, r in reasons.items() if not monitor_outcome.relaunch_same_agent(r)] if blocked: - st["final"] = "error" - st["ended_at"] = _now() - _save(pr, st) for a in blocked: detail = (last.get(a) or {}).get("monitor_detail") info(f" {a}: reason={reasons[a]}" + (f" detail={detail}" if detail else "")) - die( + _abort_no_result_round( + pr, st, f"起動し直しても解けない理由で結果が残りませんでした: {' '.join(blocked)}。" " 同じラウンドで起動し直さずに中断します。最終スイープを通してから" "完了報告へ進んでください", - code=1, ) relaunched = last.get("relaunched") or [] pending = [a for a in no_result if a not in relaunched] if not pending: # 2 度続けて結果が残らないのは、対象や負荷ではなく実行環境の側の事象である。 - st["final"] = "error" - st["ended_at"] = _now() - _save(pr, st) - die( + _abort_no_result_round( + pr, st, f"起動し直した後も結果が残りませんでした: {' '.join(no_result)}。" " 実行環境の側の問題として中断します。最終スイープを通してから" "完了報告へ進んでください", - code=1, ) - last["relaunched"] = relaunched + pending - _save(pr, st) - print(f"RELAUNCH_AGENTS='{' '.join(pending)}'") - print(f"RELAUNCH_AGENTS_CSV={','.join(pending)}") - # 互換のために残す。**`both` は codex / agy の 2 者だけを指す語**であるため、 - # 担当がそれ以外を含むラウンドでは CSV の側を使う。 - print(f"RELAUNCH_TARGET={'both' if len(pending) == 2 else pending[0]}") - info( - f"→ 結果を残さなかったレビュアーがいる: {' '.join(pending)}。" - "同じラウンドで 1 度だけ起動し直す。" - ) + _record_relaunch(pr, st, last, pending) sys.exit(7) From f850aa0e8cc0f437003881e9f6c6b3c5420a98c1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 12:52:35 +0000 Subject: [PATCH 041/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 68 +++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index c76feab8..4929e84f 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -100,7 +100,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| normal | integration | — | kiro | 検証中 | 1 | +| normal | integration | — | kiro | 採用 | 1 | **なぜ**: refresh は fetch・row・compare をつないで全件の 1 行と失敗件数を返す公開入口だが、この配線を通す固定が対象範囲に無い。取れなかった URL を黙って落とさず件数へ数える振る舞いが未固定である @@ -109,6 +109,71 @@ 3. 返る行数が source 数と一致し、失敗件数が失敗した source 数と一致することを固定する 4. 各行に source の name と取得の成否が含まれることを、実行して得た値で固定する +## ラウンド 3(実装 kiro / レビュー codex / agy) + +### R3-001 — `plugins/ndf/scripts/lib/monitor_outcome.py#OUTCOME_KEYS` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| scattered_config | centralize_configuration | major | kiro | 検証中 | 1 | + +**なぜ**: 監視の結果ファイルのキーの一覧が 3 か所に散っている。monitor_outcome.OUTCOME_KEYS(14 個・runtime では未使用)と、実際に辞書を組み立てる monitor.py の _record_outcome(`phase` を含む 15 個)と、test_monitor_outcome_file.py の独自コピー(15 個)である。正本のはずの OUTCOME_KEYS が `phase` を欠いており既に食い違っている。キーを足すたびにどこかが古くなる。 + +**手順**: 1. OUTCOME_KEYS へ `phase` を加え、契約文書の並びと揃える +2. monitor.py の _record_outcome が組み立てた辞書のキー集合を OUTCOME_KEYS と突き合わせる assert を置き、食い違いをその場で落とす(値の生成は現状のまま) +3. read_journal / read_outcome の既存テストと test_monitor_outcome_file.py を実行し、キー集合の期待が変わっていないことを確かめる + +### R3-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_handle_no_result_round` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 検証中 | 1 | + +**なぜ**: 1 関数に理由の集約と出力、再起動不能時の終了処理、再起動済み時の終了処理、再起動対象の記録とシェル向け出力という別々の段階が同居し、状態保存と終了条件が複数箇所に散っている。 + +**手順**: 1. 担当別理由の収集と NO_RESULT_REASONS 出力を小さな関数へ抽出する +2. final・ended_at・保存・die を行う共通の異常終了処理を抽出する +3. 再起動対象の記録と互換出力を行う処理を抽出する +4. usage_limit、再起動済み、再起動要求の既存テストで終了コード・state・出力順を固定する + +### R3-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_print_init_result` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_parameter_list | introduce_parameter_object | major | codex | 未着手 | 0 | + +**なぜ**: 初期化結果という同じ概念を表す 11 引数を位置で受け取り、特に連続する 3 個の bool と末尾の件数・再開フラグは呼び出し側で順序を取り違えても検出しにくい。新規初期化と再開の 2 経路が同じ組を渡している。 + +**手順**: 1. 出力対象を表す _InitResult の値オブジェクトを定義する +2. _print_init_result の引数を _InitResult 1 個へ置き換える +3. 新規初期化経路と再開経路で名前付きフィールドから _InitResult を構築する +4. 両経路の既存テストで標準出力が不変であることを確認する + +### R3-004 — `plugins/ndf/scripts/lib/monitor.py#monitor_agent` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| duplication | consolidate_duplication | minor | codex | 検証中 | 1 | + +**なぜ**: 監視ループの 5 つの終了分岐が、同じ _finish_monitor(status, outcome, (config.log_prefix, agent)) 呼び出しを繰り返している。終了時に渡すログ文脈を変更すると各分岐を同時に直す必要がある。 + +**手順**: 1. status とログ文脈を閉じ込めて outcome を受け取る局所的な finish 処理を定義する +2. PID 不正・完了・timeout・early error・process exit・stall の各終了分岐を同じ入口へ寄せる +3. monitor_agent を通る既存テストで status、ログ、終了結果が不変であることを確認する + +### R3-005 — `plugins/ndf/skills/cross-review/scripts/state.py#_verify_findings` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | minor | kiro | 未着手 | 0 | + +**なぜ**: 1 つの関数が 2 つの独立した段を通しで行う。前段は各指摘の suggested_check を(重複を除いて)実行し verification を記録する反復、後段は束ねた組の代表へ最良の結果を選び直す反復である。段ごとに名前が付き、共有するのは targets と by_id だけである。 + +**手順**: 1. 前段を _run_finding_checks(targets, allowed, work, codes, run) として抽出し、実行済みコマンドの対応表を関数内へ閉じる +2. 後段を _propagate_best_verification(targets, by_id) として抽出する +3. _verify_findings は codes / run / targets / by_id を用意し、2 つを順に呼ぶだけにする +4. test_verify_findings.py を実行して verification の記録が変わらないことを確かめる + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -118,3 +183,4 @@ | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | boundary | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | error | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/scripts/lib/refresh.py#fetch` | error | テストの期待する振る舞いが変わっています(plugins/ndf/scripts/tests/test_refresh.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | +| 3 | `plugins/ndf/scripts/lib/monitor.py#_record_outcome` | long_method | 1 ラウンドの採用上限 5 件を超えた | From 31d2cbc211d861f26424693c91ab9743c3ae6725 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:03:58 +0000 Subject: [PATCH 042/217] =?UTF-8?q?Refactor:=20introduce=5Fparameter=5Fobj?= =?UTF-8?q?ect=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state?= =?UTF-8?q?.py#=5Fprint=5Finit=5Fresult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 初期化結果という同じ概念を表す 11 個の位置引数を、名前付きフィールドを持つ _InitResult の値オブジェクト 1 個へ置き換える。連続する 3 個の bool と末尾の 件数・再開フラグは呼び出し側で順序を取り違えても検出しにくかったため、 再開経路と新規初期化経路の両方でキーワード引数から _InitResult を構築する。 標準出力の機械可読ブロックの形式と順序は変えない。 Item-Id: R3-003 Round: 3 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Fable 5.1 --- .../ndf/skills/cross-review/scripts/state.py | 104 ++++++++++-------- 1 file changed, 58 insertions(+), 46 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index d9959022..19b04a92 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1507,35 +1507,43 @@ def cmd_flush(args: argparse.Namespace) -> None: info(f"✅ 待ち行列は空です(送った {len(result.sent)} 件)") -def _print_init_result( - pr: object, - worktree: object, - tmp_dir: object, - repo: object, - head_branch: object, - base_branch: object, - is_own: bool, - event_downgrade: bool, - has_extra: bool, - carried_count: int, - resumed: bool, -) -> None: +class _InitResult(NamedTuple): + """cmd_init が標準出力の機械可読ブロックへ書く初期化結果。 + + 再開経路と新規経路が同じ組を渡すため、位置引数の並びではなく名前付きの + フィールドで受け渡す。 + """ + + pr: object + worktree: object + tmp_dir: object + repo: object + head_branch: object + base_branch: object + is_own: bool + event_downgrade: bool + has_extra: bool + carried_count: int + resumed: bool + + +def _print_init_result(result: _InitResult) -> None: """cmd_init の 2 経路(再開・新規)が共有する末尾の出力ブロック。 出力形式は再開側・新規側で同一のため 1 箇所へ寄せる。PR 番号だけは 元の両分岐に合わせて quote しない(数値のため)。 """ - print(f"PR={pr}") - print(f'WORKTREE={shlex.quote(str(worktree))}') - print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') - print(f'REPO={shlex.quote(str(repo))}') - print(f'HEAD_BRANCH={shlex.quote(str(head_branch))}') - print(f'BASE_BRANCH={shlex.quote(str(base_branch))}') - print(f"IS_OWN_PR={'1' if is_own else '0'}") - print(f"EVENT_DOWNGRADE={'1' if event_downgrade else '0'}") - print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if has_extra else '0'}") - print(f"CARRIED_OVER_THREADS={carried_count}") - print(f"RESUMED={'1' if resumed else '0'}") + print(f"PR={result.pr}") + print(f'WORKTREE={shlex.quote(str(result.worktree))}') + print(f'TMP_DIR={shlex.quote(str(result.tmp_dir))}') + print(f'REPO={shlex.quote(str(result.repo))}') + print(f'HEAD_BRANCH={shlex.quote(str(result.head_branch))}') + print(f'BASE_BRANCH={shlex.quote(str(result.base_branch))}') + print(f"IS_OWN_PR={'1' if result.is_own else '0'}") + print(f"EVENT_DOWNGRADE={'1' if result.event_downgrade else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if result.has_extra else '0'}") + print(f"CARRIED_OVER_THREADS={result.carried_count}") + print(f"RESUMED={'1' if result.resumed else '0'}") def _resume_from_state( @@ -1607,17 +1615,19 @@ def _resume_from_state( _sync_worktree(str(wt), int(st.get("current_pr") or pr), resume_head) info(f"↻ 前回中断 state から再開(round={len(st.get('rounds', []))})") _print_init_result( - st["current_pr"], - wt, - tmp_dir, - st.get("repo") or "", - st.get("head_branch") or "", - st.get("base_branch") or "", - bool(st.get("is_own_pr")), - bool(st.get("event_downgrade")), - bool(st.get("review_instructions")), - (st.get("carried_over") or {}).get("count", 0), - True, + _InitResult( + pr=st["current_pr"], + worktree=wt, + tmp_dir=tmp_dir, + repo=st.get("repo") or "", + head_branch=st.get("head_branch") or "", + base_branch=st.get("base_branch") or "", + is_own=bool(st.get("is_own_pr")), + event_downgrade=bool(st.get("event_downgrade")), + has_extra=bool(st.get("review_instructions")), + carried_count=(st.get("carried_over") or {}).get("count", 0), + resumed=True, + ) ) return True @@ -1855,17 +1865,19 @@ def _finalize_initial_state( _write_state(ws_ctx.state_file, state) info(f"✅ state 初期化: {ws_ctx.state_file}") _print_init_result( - pr, - pr_ctx.worktree, - ws_ctx.tmp_dir, - pr_ctx.repo, - pr_ctx.meta.head_branch, - pr_ctx.meta.base_branch, - pr_ctx.is_own, - pr_ctx.event_downgrade, - bool(review_ctx.review_instructions), - 0, - False, + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, + ) ) pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) From f44044c4c21b7f46cf6b7fbf4e0b7f558011cafb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:04:46 +0000 Subject: [PATCH 043/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index 4929e84f..0d6bc781 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -115,7 +115,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| scattered_config | centralize_configuration | major | kiro | 検証中 | 1 | +| scattered_config | centralize_configuration | major | kiro | 採用 | 1 | **なぜ**: 監視の結果ファイルのキーの一覧が 3 か所に散っている。monitor_outcome.OUTCOME_KEYS(14 個・runtime では未使用)と、実際に辞書を組み立てる monitor.py の _record_outcome(`phase` を含む 15 個)と、test_monitor_outcome_file.py の独自コピー(15 個)である。正本のはずの OUTCOME_KEYS が `phase` を欠いており既に食い違っている。キーを足すたびにどこかが古くなる。 @@ -127,7 +127,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 検証中 | 1 | +| long_method | extract_method | major | codex | 採用 | 1 | **なぜ**: 1 関数に理由の集約と出力、再起動不能時の終了処理、再起動済み時の終了処理、再起動対象の記録とシェル向け出力という別々の段階が同居し、状態保存と終了条件が複数箇所に散っている。 @@ -140,7 +140,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_parameter_list | introduce_parameter_object | major | codex | 未着手 | 0 | +| long_parameter_list | introduce_parameter_object | major | codex | 検証中 | 1 | **なぜ**: 初期化結果という同じ概念を表す 11 引数を位置で受け取り、特に連続する 3 個の bool と末尾の件数・再開フラグは呼び出し側で順序を取り違えても検出しにくい。新規初期化と再開の 2 経路が同じ組を渡している。 @@ -153,7 +153,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | codex | 検証中 | 1 | +| duplication | consolidate_duplication | minor | codex | 採用 | 1 | **なぜ**: 監視ループの 5 つの終了分岐が、同じ _finish_monitor(status, outcome, (config.log_prefix, agent)) 呼び出しを繰り返している。終了時に渡すログ文脈を変更すると各分岐を同時に直す必要がある。 From eb29c2755ab92855a4a1f0336ed24cd2c0a80c3c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:12:33 +0000 Subject: [PATCH 044/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#?= =?UTF-8?q?=5Fverify=5Ffindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 検証コマンドの実行と、代表への最良結果の反映をそれぞれ名前付き関数へ抽出する。 Item-Id: R3-005 Round: 3 Impl-Runtime: codex Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 82 +++++++++++-------- 1 file changed, 49 insertions(+), 33 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 19b04a92..45d79b64 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -3132,41 +3132,14 @@ def _merged_root( return current -def _verify_findings( - st: dict[str, Any], - round_no: int, +def _run_finding_checks( + targets: list[dict[str, Any]], allowed: list[str], work: str, - reproduced_codes: Optional[list[int]] = None, - runner: Optional[Any] = None, + codes: set[int], + run: Any, ) -> None: - """`suggested_check` を実行し、結果を `verification` へ残す(#156)。 - - **担当の再評価より先に走らせる。** 機械が再現した事実は、担当の支持より確かである。 - - **束ねた組の全員を対象にする**(`docs/specifications/cross-review-evidence-based.md` - の「重複の統合」)。 - 代表の `suggested_check` だけを読むと、代表が手順を書いていない組は、束ねられた側が - 実行できる手順を書いていても実行回数 0・`not_run` のまま `insufficient_evidence` へ - 落ちる。**どちらが先に取り込まれたかで採否が変わる。** 1 段目の統合は実行検証より - **前**にあるため、束ねられた側を読み飛ばすと、その手順は一度も実行されない。 - - 代表が持つのは組の集約である。`reproduced` > `not_reproduced` > `not_run` の順で - 最初に当たった 1 件を採り、**出所を `verification.finding_id` へ残す**。 - - **同じコマンドは 1 度しか実行しない。** 組の全員が同じ `suggested_check` を書くのは - 普通に起こり(統合の条件は本文の一致である)、そのたびに走らせると実行が増える。 - - **`ran_at` は結果を受け取った記録にだけ入れる。** 初期化で入れると、実行していない - `not_run` の記録にも時刻が残り、実行済みに見える。記録の `result` / `exit_code` / - `ran_at` が同じことを指すようにする。 - """ - codes = set(reproduced_codes or VERIFY_REPRODUCED_CODES) - run = runner or _run_verify - targets = [ - f for f in st.get("review_findings") or [] if f.get("round") == round_no - ] - by_id = {f.get("finding_id"): f for f in targets} + """各指摘の検証コマンドを重複なく実行し、結果を記録する。""" ran: dict[tuple[str, ...], Optional[int]] = {} for finding in targets: @@ -3191,7 +3164,11 @@ def _verify_findings( record["result"] = "not_reproduced" finding["verification"] = record - # 代表は組から選び直す。**実行し直さない**(記録済みの結果を選ぶだけである)。 + +def _propagate_best_verification( + targets: list[dict[str, Any]], by_id: dict[Any, dict[str, Any]] +) -> None: + """束ねた組から最良の検証結果を代表へ反映する。""" for rep in targets: if rep.get("merged_into"): continue @@ -3208,6 +3185,45 @@ def _verify_findings( rep["verification"] = dict(best) +def _verify_findings( + st: dict[str, Any], + round_no: int, + allowed: list[str], + work: str, + reproduced_codes: Optional[list[int]] = None, + runner: Optional[Any] = None, +) -> None: + """`suggested_check` を実行し、結果を `verification` へ残す(#156)。 + + **担当の再評価より先に走らせる。** 機械が再現した事実は、担当の支持より確かである。 + + **束ねた組の全員を対象にする**(`docs/specifications/cross-review-evidence-based.md` + の「重複の統合」)。 + 代表の `suggested_check` だけを読むと、代表が手順を書いていない組は、束ねられた側が + 実行できる手順を書いていても実行回数 0・`not_run` のまま `insufficient_evidence` へ + 落ちる。**どちらが先に取り込まれたかで採否が変わる。** 1 段目の統合は実行検証より + **前**にあるため、束ねられた側を読み飛ばすと、その手順は一度も実行されない。 + + 代表が持つのは組の集約である。`reproduced` > `not_reproduced` > `not_run` の順で + 最初に当たった 1 件を採り、**出所を `verification.finding_id` へ残す**。 + + **同じコマンドは 1 度しか実行しない。** 組の全員が同じ `suggested_check` を書くのは + 普通に起こり(統合の条件は本文の一致である)、そのたびに走らせると実行が増える。 + + **`ran_at` は結果を受け取った記録にだけ入れる。** 初期化で入れると、実行していない + `not_run` の記録にも時刻が残り、実行済みに見える。記録の `result` / `exit_code` / + `ran_at` が同じことを指すようにする。 + """ + codes = set(reproduced_codes or VERIFY_REPRODUCED_CODES) + run = runner or _run_verify + targets = [ + f for f in st.get("review_findings") or [] if f.get("round") == round_no + ] + by_id = {f.get("finding_id"): f for f in targets} + _run_finding_checks(targets, allowed, work, codes, run) + _propagate_best_verification(targets, by_id) + + def cmd_verify_findings(args: argparse.Namespace) -> None: """Step 2.5 前段 — 重複を束ね(1 段目)、`suggested_check` を実行する(#156)。 From 6a13b7f8d30dd045e88886737c629161eb8e38ea Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:13:13 +0000 Subject: [PATCH 045/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index 0d6bc781..d1d4faa9 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -140,7 +140,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_parameter_list | introduce_parameter_object | major | codex | 検証中 | 1 | +| long_parameter_list | introduce_parameter_object | major | codex | 採用 | 1 | **なぜ**: 初期化結果という同じ概念を表す 11 引数を位置で受け取り、特に連続する 3 個の bool と末尾の件数・再開フラグは呼び出し側で順序を取り違えても検出しにくい。新規初期化と再開の 2 経路が同じ組を渡している。 @@ -165,7 +165,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | kiro | 未着手 | 0 | +| long_method | extract_method | minor | kiro | 検証中 | 1 | **なぜ**: 1 つの関数が 2 つの独立した段を通しで行う。前段は各指摘の suggested_check を(重複を除いて)実行し verification を記録する反復、後段は束ねた組の代表へ最良の結果を選び直す反復である。段ごとに名前が付き、共有するのは targets と by_id だけである。 From 6701294fa0537efe6afdd8ba0ae8b852cf6264e9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:28:20 +0000 Subject: [PATCH 046/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#?= =?UTF-8?q?=5Fresume=5Ffrom=5Fstate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _resume_from_state に直列で置かれていた段階を名前付き関数へ分離する。 - _find_resumable_state: state ファイルの探索と再開可否(final 済み)の判定 - _refresh_resume_state: 旧形式の補完・manual 指示の反映・review_instructions の 再計算・carried_over の記録(保存はせず、変更有無だけを返す) - _sync_resume_worktree: 保存後の auto_flush → tmp_dir 解決 → 登録済み worktree の 同期を、副作用の順序が見える形で 1 か所へ寄せる _resume_from_state は各段階の呼び出しと _print_init_result だけになる。 出力・保存順序・副作用の順は不変(全体テスト 4564 passed / exit=0)。 Item-Id: R4-001 Round: 4 Impl-Runtime: kiro Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 89 +++++++++++++------ 1 file changed, 61 insertions(+), 28 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 45d79b64..a17f8378 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1546,20 +1546,15 @@ def _print_init_result(result: _InitResult) -> None: print(f"RESUMED={'1' if result.resumed else '0'}") -def _resume_from_state( - pr: object, - repo: str, - worktree: str, - manual_extra_review: str, -) -> bool: - """既存 state からの再開経路。 - - 再開に該当し出力まで済ませたら True、該当する state が無ければ False を返す。 - False のとき cmd_init は新規 init へ進む。 +def _find_resumable_state( + pr: object, worktree: str, +) -> tuple[dict[str, Any], pathlib.Path] | None: + """既存 state を探し、再開できるものだけを (state, path) で返す。 + + 再開に該当しなければ `None`。**探索の入口は `_tmp_dir()` を使わない**(mkdir の + 副作用でパスが作られてしまう)。`CROSS_REVIEW_TMP_DIR` があればそれを、無ければ + `/.cross_review/` を直接組む。`final` が確定した state は再開しない。 """ - # 再開チェック: CROSS_REVIEW_TMP_DIR が設定されている場合はそちらを優先し、 - # 未設定なら /.cross_review/ を直接パスとして組む。 - # _tmp_dir() は mkdir 副作用があるため使用せず、パス解決のみ行う。 env_tmp = os.environ.get("CROSS_REVIEW_TMP_DIR") if env_tmp: resume_dir = pathlib.Path(env_tmp).resolve() @@ -1567,10 +1562,21 @@ def _resume_from_state( resume_dir = pathlib.Path(worktree) / ".cross_review" resume_state_file = resume_dir / f"cross-review-pr{pr}-state.json" if not resume_state_file.exists(): - return False + return None st = json.loads(resume_state_file.read_text(encoding="utf-8")) if st.get("final") is not None: - return False + return None + return st, resume_state_file + + +def _refresh_resume_state( + st: dict[str, Any], pr: object, repo: str, manual_extra_review: str, +) -> bool: + """再開する state を最新化し、書き換えたかどうかを返す。 + + 旧形式の補完・manual 指示の反映・`review_instructions` の再計算・引き継ぎの記録を + 行う。**保存はしない**(呼び出し側が変更有無を見て 1 度だけ書く)。 + """ state_changed = False if "auto_review_instructions" not in st: changed_files = _fetch_changed_files(pr, st.get("repo") or repo) @@ -1595,29 +1601,56 @@ def _resume_from_state( # 再開した時点で残っている未解決の指摘を引き継ぎとして記録する。 if _record_carried_over(st, st.get("repo") or repo, st.get("current_pr") or pr): state_changed = True - if state_changed: - _write_state(resume_state_file, st) - info("↻ 追加レビュー観点を state に反映して再開") - # 待ち行列を流すのは、手元の `st` を書き戻した**後**である。流した結果 - # (`queued` の解除と、届かなかった投稿の結果なし)は `_confirm_flushed` が - # 状態ファイルへ直接書く。先に流すと、この関数がその後に書き戻す古い `st` が - # それらを消す。再開の入口で流すこと自体は変えないため、回復した後の - # 1 本目のコマンドで届く。 - # 渡すのは状態ファイルの鍵(`args.pr`)で、`current_pr` ではない。待ち行列も - # 状態ファイルも鍵で引くため、巻き直しの後に `current_pr` を渡すと引けない。 + return state_changed + + +def _sync_resume_worktree(st: dict[str, Any], pr: object, worktree: str) -> pathlib.Path: + """保存後の副作用(待ち行列の flush → tmp_dir 解決 → 作業ツリー同期)を順に行う。 + + 順序に意味がある: + 1. **待ち行列を流すのは、手元の `st` を書き戻した後である。** 流した結果 + (`queued` の解除と、届かなかった投稿の結果なし)は `_confirm_flushed` が + 状態ファイルへ直接書く。先に流すと、この後の書き戻しが古い `st` でそれらを + 消す。渡すのは状態ファイルの鍵(`args.pr`)で、`current_pr` ではない。 + 2. `tmp_dir` を解決して返す(`_print_init_result` が使う)。 + 3. **再開でも同期する。** 中断から再開までの間に head が進んでいることがあり、 + そのまま次のラウンドを回すと古い差分をレビューさせる。 + """ _auto_flush(pr) tmp_dir = _tmp_dir(worktree) wt = st.get("worktree_path") or "" - # 再開でも同期する。中断から再開までの間に head が進んでいることがあり、 - # そのまま次のラウンドを回すと古い差分をレビューさせる。 resume_head = str(st.get("head_branch") or "") if wt and resume_head and _is_registered_worktree(str(wt)): _sync_worktree(str(wt), int(st.get("current_pr") or pr), resume_head) + return tmp_dir + + +def _resume_from_state( + pr: object, + repo: str, + worktree: str, + manual_extra_review: str, +) -> bool: + """既存 state からの再開経路。 + + 再開に該当し出力まで済ませたら True、該当する state が無ければ False を返す。 + False のとき cmd_init は新規 init へ進む。 + """ + found = _find_resumable_state(pr, worktree) + if found is None: + return False + st, resume_state_file = found + + if _refresh_resume_state(st, pr, repo, manual_extra_review): + _write_state(resume_state_file, st) + info("↻ 追加レビュー観点を state に反映して再開") + + tmp_dir = _sync_resume_worktree(st, pr, worktree) info(f"↻ 前回中断 state から再開(round={len(st.get('rounds', []))})") _print_init_result( _InitResult( pr=st["current_pr"], - worktree=wt, + worktree=st.get("worktree_path") or "", tmp_dir=tmp_dir, repo=st.get("repo") or "", head_branch=st.get("head_branch") or "", From 4dcab3365afb35a94871eeb45af89a3e5995a71d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:28:42 +0000 Subject: [PATCH 047/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 60 +++++++++++++++++++++++++++++++- 1 file changed, 59 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index d1d4faa9..f9513c6a 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -165,7 +165,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | kiro | 検証中 | 1 | +| long_method | extract_method | minor | kiro | 採用 | 1 | **なぜ**: 1 つの関数が 2 つの独立した段を通しで行う。前段は各指摘の suggested_check を(重複を除いて)実行し verification を記録する反復、後段は束ねた組の代表へ最良の結果を選び直す反復である。段ごとに名前が付き、共有するのは targets と by_id だけである。 @@ -174,6 +174,64 @@ 3. _verify_findings は codes / run / targets / by_id を用意し、2 つを順に呼ぶだけにする 4. test_verify_findings.py を実行して verification の記録が変わらないことを確かめる +## ラウンド 4(実装 claude / レビュー codex / kiro) + +### R4-001 — `plugins/ndf/skills/cross-review/scripts/state.py#_resume_from_state` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 検証中 | 1 | + +**なぜ**: 既存stateの探索、旧形式の補完、追加レビュー観点の再計算、引き継ぎ記録、保存、待ち行列flush、worktree同期、機械可読出力までが1関数に直列で置かれ、副作用の順序を長い本体とコメントから追う必要がある。各段階には独立した終了条件と入出力があり、名前を付けて分離できる。 + +**手順**: 1. cmd_initを通る既存の再開テストで、stateなし、final済み、旧形式補完、追加観点更新、引き継ぎ、flush後の同期と出力を現状固定する +2. stateファイルの探索と再開可否判定を、stateとpathを返す関数へ抽出する +3. 旧形式の補完、manual指示の反映、review_instructions再計算、carried_over記録を、変更有無も返す関数へ抽出する +4. 保存後のauto_flush、tmp_dir解決、登録済みworktree同期を副作用順序が見える関数へ抽出する +5. _resume_from_stateを各段階の呼び出しと_print_init_resultだけにし、再開関連テストと全体テストで出力と保存順序が不変であることを確認する + +### R4-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 未着手 | 0 | + +**なぜ**: 新規初期化の1関数に、PR所有権の解決、レビュー観点の構築、worktreeと既存コメントの準備、認証確認、state構築、永続化と出力が同居し、さらに5個のローカル関数が本体を約200行へ広げている。各段階は既に名前と入出力を持つため、モジュールレベルへ抽出すれば段階単位で読めて個別にテストできる。 + +**手順**: 1. 既存の入口テストで、新規worktree、既存worktree、変更ファイル取得fallback、認証失敗、初期state出力の経路を現状固定する +2. _resolve_pr_and_ownership と _prepare_review_instructions をモジュールレベル関数へ抽出し、既存のcontext型を入出力に使う +3. _prepare_worktree_and_comments をモジュールレベル関数へ抽出し、worktree作成より後に_tmp_dirを呼ぶ順序とコメント取得失敗時の停止を保つ +4. _prepare_initial_assignment、_build_initial_review_state、_finalize_initial_state をモジュールレベルへ抽出し、_init_new_stateを段階を順に呼ぶオーケストレーションだけにする +5. 初期化関連テストと全体テストを実行し、標準出力、stateの内容、副作用の順序が不変であることを確認する + +### R4-003 — `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| conditional_chain | replace_with_lookup_table | minor | kiro | 未着手 | 0 | + +**なぜ**: 現ラウンドの各指摘について _finding_match_kind の戻り値 ("exact"/"near"/"body") を if/elif で数えている。種別ごとの集計は種別を増やすたびに分岐を足すことになる。あわせて、collect_keys クロージャは _finding_keys(st, pr, round_no) をそのまま呼ぶだけの指標なしの間接参照で、読み手が本体を追う負荷を増やしている。test_state_check_oscillation.py と test_state_oscillation_matching.py が cmd_check_oscillation / _finding_match_kind を通す。 + +**手順**: 1. exact/near/same_body の 3 変数と if/elif/elif の加算を、collections.Counter に対する `Counter(_finding_match_kind(k, prev) for k in curr)` へ置き換える +2. overlap_count は None 以外の合計として counts の値の総和から出す +3. info の表示は counts.get("exact", 0) 等から読む +4. collect_keys クロージャを消し、呼び出し 2 箇所を _finding_keys(st, pr, prev_round_no) / (curr_round_no) の直接呼び出しに戻す +5. テストを実行して振る舞い不変を確認する + +### R4-004 — `plugins/ndf/skills/cross-review/scripts/state.py#_normalize_fix_result` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | minor | kiro | 未着手 | 0 | + +**なぜ**: 1 関数に (a) 別名 fallback(fix_commit/commit_sha、fixed_count/fixed)、(b) deferred の list/dict/int による件数の場合分けと dict 要素への正規化、(c) rejected の同じ正規化、(d) 記録用辞書の組み立て、が同居する。deferred と rejected はどちらも _normalize_dict_items + 件数決定という同型の処理で、片方だけ直すと食い違いうる。test_state_merge_fix.py と test_state_ci_classification.py が cmd_merge_fix 経由で通す。 + +**手順**: 1. 「_normalize_dict_items した項目」と「保存する件数」を組で返す小関数 _normalize_deferred_like(raw) を抽出する(list/dict は展開件数、劣化表現の int/str は _count の値、という現在の規則をそのまま移す) +2. deferred と rejected の両方をこの関数で得る(現状 rejected の件数は常に _count なので、raw の型で分岐する現在の deferred 規則へ揃える形にはせず、抽出関数は deferred の規則を表し、rejected は従来どおり _count を使うなら別に保つ。振る舞いを変えないため、まず deferred 経路だけを抽出する) +3. 別名 fallback(fix_commit/fixed_count)を _resolve_fix_aliases として抽出する +4. 末尾の辞書組み立てを、抽出した値を差し込む形へ整える +5. テストを実行して出力の辞書が不変であることを確認する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | From f19e31de1aeaa9a007976ff06dcd829537fe309d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:37:34 +0000 Subject: [PATCH 048/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#?= =?UTF-8?q?=5Finit=5Fnew=5Fstate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _init_new_state に同居していた 5 個のローカル関数 (_resolve_pr_and_ownership / _prepare_review_instructions / _prepare_worktree_and_comments / _prepare_initial_assignment / _build_initial_review_state)と _finalize_initial_state をモジュール レベルへ抽出し、_init_new_state を各段を順に呼ぶオーケストレーション だけに縮めた。段の入出力は既存の _Init* NamedTuple をそのまま使う。 構造の現状固定テスト(test_init_body_not_duplicated.py)は、 _init_new_state 単体ではなく新規 init 経路を構成する関数群を まとめて見るよう更新し、「同じ文が 2 回並ばない」「副作用のある 呼び出しは 1 回だけ」という元の意図を保った。 Item-Id: R4-002 Round: 4 Impl-Runtime: kiro Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 365 +++++++++--------- .../tests/test_init_body_not_duplicated.py | 52 ++- 2 files changed, 225 insertions(+), 192 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index a17f8378..4bfa32a9 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1727,192 +1727,201 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str -def _init_new_state( +def _resolve_pr_and_ownership( + pr: object, repo: str, worktree: str, args_worktree: str | None +) -> _InitPRContext | None: + # 新規 init: プリチェック。 + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + meta = _fetch_pr_metadata(pr, repo) + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) + + +def _prepare_review_instructions( + pr: object, repo: str, manual_extra_review: str +) -> _InitReviewContext: + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) + + +def _prepare_worktree_and_comments( + worktree: str, pr: object, head_branch: str, repo: str +) -> _InitWorkspaceContext: + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + return _InitWorkspaceContext( + tmp_dir=tmp_dir, + state_file=state_file, + ) + + +def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + reviewers = assignment.review_pool(host) + info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") + _validate_only(args.only, host) + # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 + # **確かめるのは実際に起動する担当だけである。** + auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) + return _InitialAssignment(host=host, host_source=host_source) + + +def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, +) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source = ctx.assignment + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + "max_rounds": args.max_rounds, + "rotate_after": args.rotate_after, + "only": args.only, + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + + +def _finalize_initial_state( args: argparse.Namespace, pr: object, - repo: str, - worktree: str, + pr_ctx: _InitPRContext, + review_ctx: _InitReviewContext, + ws_ctx: _InitWorkspaceContext, manual_extra_review: str, ) -> None: - """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" - - def _resolve_pr_and_ownership( - pr: object, repo: str, worktree: str, args_worktree: str | None - ) -> _InitPRContext | None: - # 新規 init: プリチェック。 - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - meta = _fetch_pr_metadata(pr, repo) - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - - def _prepare_review_instructions( - pr: object, repo: str, manual_extra_review: str - ) -> _InitReviewContext: - changed_files = _fetch_changed_files(pr, repo) - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) - - def _prepare_worktree_and_comments( - worktree: str, pr: object, head_branch: str, repo: str - ) -> _InitWorkspaceContext: - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) - tmp_dir = _tmp_dir(worktree) - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - - return _InitWorkspaceContext( - tmp_dir=tmp_dir, - state_file=state_file, + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + ) + state = _build_initial_review_state(args, context) + _write_state(ws_ctx.state_file, state) + info(f"✅ state 初期化: {ws_ctx.state_file}") + _print_init_result( + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, ) + ) - def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - reviewers = assignment.review_pool(host) - info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") - _validate_only(args.only, host) - # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 - # **確かめるのは実際に起動する担当だけである。** - auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) - return _InitialAssignment(host=host, host_source=host_source) - - def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, - ) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source = ctx.assignment - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - "max_rounds": args.max_rounds, - "rotate_after": args.rotate_after, - "only": args.only, - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } - def _finalize_initial_state( - args: argparse.Namespace, - pr: object, - pr_ctx: _InitPRContext, - review_ctx: _InitReviewContext, - ws_ctx: _InitWorkspaceContext, - manual_extra_review: str, - ) -> None: - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review - ) - state = _build_initial_review_state(args, context) - _write_state(ws_ctx.state_file, state) - info(f"✅ state 初期化: {ws_ctx.state_file}") - _print_init_result( - _InitResult( - pr=pr, - worktree=pr_ctx.worktree, - tmp_dir=ws_ctx.tmp_dir, - repo=pr_ctx.repo, - head_branch=pr_ctx.meta.head_branch, - base_branch=pr_ctx.meta.base_branch, - is_own=pr_ctx.is_own, - event_downgrade=pr_ctx.event_downgrade, - has_extra=bool(review_ctx.review_instructions), - carried_count=0, - resumed=False, - ) - ) +def _init_new_state( + args: argparse.Namespace, + pr: object, + repo: str, + worktree: str, + manual_extra_review: str, +) -> None: + """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。 + 各段は独立したモジュールレベル関数へ切り出してあり、ここはそれらを順に + 呼ぶオーケストレーションだけを持つ。 + """ pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) if pr_ctx is None: return diff --git a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py index b8f91814..1a2cfd58 100644 --- a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py +++ b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py @@ -1,4 +1,4 @@ -"""`_init_new_state` の本体が 2 回現れないことを機械で見る。 +"""新規 init 経路の本体が 2 回現れないことを機械で見る。 **構造改善(`extract_method`)で旧本体の削除が漏れると、抽出後の本体がそのまま 2 回並ぶ。** 実際に PR #549 の `cmd_init` の抽出でこれが起き、`_fetch_pr_metadata` / @@ -8,6 +8,9 @@ **経路そのものは `gh` を要するため実行では確かめない。** 関数の構造(同じ文が 2 回 現れない・出力が 1 回だけ)を構文木で見る。 + +各段はモジュールレベル関数へ切り出したため、`_init_new_state` 単体ではなく +新規 init 経路を構成する関数群(`INIT_PATH_FUNCTIONS`)をまとめて見る。 """ from __future__ import annotations @@ -19,6 +22,17 @@ STATE_PY = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "state.py" +# 新規 init 経路を構成する関数群。`_init_new_state` から順に呼ばれる各段。 +INIT_PATH_FUNCTIONS = ( + "_init_new_state", + "_resolve_pr_and_ownership", + "_prepare_review_instructions", + "_prepare_worktree_and_comments", + "_finalize_initial_state", + "_prepare_initial_assignment", + "_build_initial_review_state", +) + # 1 回しか呼んではいけないもの。**副作用を持つ**か、標準出力の機械可読ブロックを書く。 SINGLE_CALL = ( "_print_init_result", @@ -29,31 +43,41 @@ @pytest.fixture(scope="module") -def init_new_state() -> ast.FunctionDef: +def init_functions() -> dict[str, ast.FunctionDef]: tree = ast.parse(STATE_PY.read_text(encoding="utf-8")) + found: dict[str, ast.FunctionDef] = {} for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == "_init_new_state": - return node - raise AssertionError("_init_new_state が見つからない") + if isinstance(node, ast.FunctionDef) and node.name in INIT_PATH_FUNCTIONS: + found[node.name] = node + missing = [name for name in INIT_PATH_FUNCTIONS if name not in found] + assert not missing, f"新規 init 経路の関数が見つからない: {missing}" + return found -def test_no_statement_appears_twice(init_new_state: ast.FunctionDef) -> None: - """本体の直下に、まったく同じ文が 2 回並ばない。 +def test_no_statement_appears_twice( + init_functions: dict[str, ast.FunctionDef], +) -> None: + """各段の本体の直下に、まったく同じ文が 2 回並ばない。 丸ごとの複製はこの形でしか起こらない(`if meta is None:` も `_print_init_result(...)` も 2 回現れていた)。 """ - dumps = [ast.dump(stmt) for stmt in init_new_state.body] - repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] - assert not repeated, ( - f"_init_new_state の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" - ) + for name, func in init_functions.items(): + dumps = [ast.dump(stmt) for stmt in func.body] + repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] + assert not repeated, ( + f"{name} の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" + ) @pytest.mark.parametrize("name", SINGLE_CALL) -def test_the_call_appears_once(init_new_state: ast.FunctionDef, name: str) -> None: +def test_the_call_appears_once( + init_functions: dict[str, ast.FunctionDef], name: str +) -> None: calls = [ - node for node in ast.walk(init_new_state) + node + for func in init_functions.values() + for node in ast.walk(func) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == name From 8422505e4439be031b4925cd1aa68a11c366521f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:38:03 +0000 Subject: [PATCH 049/217] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py?= =?UTF-8?q?#=5Finit=5Fnew=5Fstate"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit f19e31de1aeaa9a007976ff06dcd829537fe309d. --- .../ndf/skills/cross-review/scripts/state.py | 365 +++++++++--------- .../tests/test_init_body_not_duplicated.py | 52 +-- 2 files changed, 192 insertions(+), 225 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 4bfa32a9..a17f8378 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1727,189 +1727,6 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str -def _resolve_pr_and_ownership( - pr: object, repo: str, worktree: str, args_worktree: str | None -) -> _InitPRContext | None: - # 新規 init: プリチェック。 - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - meta = _fetch_pr_metadata(pr, repo) - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - - -def _prepare_review_instructions( - pr: object, repo: str, manual_extra_review: str -) -> _InitReviewContext: - changed_files = _fetch_changed_files(pr, repo) - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) - - -def _prepare_worktree_and_comments( - worktree: str, pr: object, head_branch: str, repo: str -) -> _InitWorkspaceContext: - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) - tmp_dir = _tmp_dir(worktree) - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - - return _InitWorkspaceContext( - tmp_dir=tmp_dir, - state_file=state_file, - ) - - -def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - reviewers = assignment.review_pool(host) - info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") - _validate_only(args.only, host) - # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 - # **確かめるのは実際に起動する担当だけである。** - auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) - return _InitialAssignment(host=host, host_source=host_source) - - -def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, -) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source = ctx.assignment - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - "max_rounds": args.max_rounds, - "rotate_after": args.rotate_after, - "only": args.only, - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } - - -def _finalize_initial_state( - args: argparse.Namespace, - pr: object, - pr_ctx: _InitPRContext, - review_ctx: _InitReviewContext, - ws_ctx: _InitWorkspaceContext, - manual_extra_review: str, -) -> None: - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review - ) - state = _build_initial_review_state(args, context) - _write_state(ws_ctx.state_file, state) - info(f"✅ state 初期化: {ws_ctx.state_file}") - _print_init_result( - _InitResult( - pr=pr, - worktree=pr_ctx.worktree, - tmp_dir=ws_ctx.tmp_dir, - repo=pr_ctx.repo, - head_branch=pr_ctx.meta.head_branch, - base_branch=pr_ctx.meta.base_branch, - is_own=pr_ctx.is_own, - event_downgrade=pr_ctx.event_downgrade, - has_extra=bool(review_ctx.review_instructions), - carried_count=0, - resumed=False, - ) - ) - - def _init_new_state( args: argparse.Namespace, pr: object, @@ -1917,11 +1734,185 @@ def _init_new_state( worktree: str, manual_extra_review: str, ) -> None: - """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。 + """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" + + def _resolve_pr_and_ownership( + pr: object, repo: str, worktree: str, args_worktree: str | None + ) -> _InitPRContext | None: + # 新規 init: プリチェック。 + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + meta = _fetch_pr_metadata(pr, repo) + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) + + def _prepare_review_instructions( + pr: object, repo: str, manual_extra_review: str + ) -> _InitReviewContext: + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) + + def _prepare_worktree_and_comments( + worktree: str, pr: object, head_branch: str, repo: str + ) -> _InitWorkspaceContext: + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + return _InitWorkspaceContext( + tmp_dir=tmp_dir, + state_file=state_file, + ) + + def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + reviewers = assignment.review_pool(host) + info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") + _validate_only(args.only, host) + # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 + # **確かめるのは実際に起動する担当だけである。** + auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) + return _InitialAssignment(host=host, host_source=host_source) + + def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, + ) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source = ctx.assignment + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + "max_rounds": args.max_rounds, + "rotate_after": args.rotate_after, + "only": args.only, + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + + def _finalize_initial_state( + args: argparse.Namespace, + pr: object, + pr_ctx: _InitPRContext, + review_ctx: _InitReviewContext, + ws_ctx: _InitWorkspaceContext, + manual_extra_review: str, + ) -> None: + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + ) + state = _build_initial_review_state(args, context) + _write_state(ws_ctx.state_file, state) + info(f"✅ state 初期化: {ws_ctx.state_file}") + _print_init_result( + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, + ) + ) - 各段は独立したモジュールレベル関数へ切り出してあり、ここはそれらを順に - 呼ぶオーケストレーションだけを持つ。 - """ pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) if pr_ctx is None: return diff --git a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py index 1a2cfd58..b8f91814 100644 --- a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py +++ b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py @@ -1,4 +1,4 @@ -"""新規 init 経路の本体が 2 回現れないことを機械で見る。 +"""`_init_new_state` の本体が 2 回現れないことを機械で見る。 **構造改善(`extract_method`)で旧本体の削除が漏れると、抽出後の本体がそのまま 2 回並ぶ。** 実際に PR #549 の `cmd_init` の抽出でこれが起き、`_fetch_pr_metadata` / @@ -8,9 +8,6 @@ **経路そのものは `gh` を要するため実行では確かめない。** 関数の構造(同じ文が 2 回 現れない・出力が 1 回だけ)を構文木で見る。 - -各段はモジュールレベル関数へ切り出したため、`_init_new_state` 単体ではなく -新規 init 経路を構成する関数群(`INIT_PATH_FUNCTIONS`)をまとめて見る。 """ from __future__ import annotations @@ -22,17 +19,6 @@ STATE_PY = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "state.py" -# 新規 init 経路を構成する関数群。`_init_new_state` から順に呼ばれる各段。 -INIT_PATH_FUNCTIONS = ( - "_init_new_state", - "_resolve_pr_and_ownership", - "_prepare_review_instructions", - "_prepare_worktree_and_comments", - "_finalize_initial_state", - "_prepare_initial_assignment", - "_build_initial_review_state", -) - # 1 回しか呼んではいけないもの。**副作用を持つ**か、標準出力の機械可読ブロックを書く。 SINGLE_CALL = ( "_print_init_result", @@ -43,41 +29,31 @@ @pytest.fixture(scope="module") -def init_functions() -> dict[str, ast.FunctionDef]: +def init_new_state() -> ast.FunctionDef: tree = ast.parse(STATE_PY.read_text(encoding="utf-8")) - found: dict[str, ast.FunctionDef] = {} for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name in INIT_PATH_FUNCTIONS: - found[node.name] = node - missing = [name for name in INIT_PATH_FUNCTIONS if name not in found] - assert not missing, f"新規 init 経路の関数が見つからない: {missing}" - return found + if isinstance(node, ast.FunctionDef) and node.name == "_init_new_state": + return node + raise AssertionError("_init_new_state が見つからない") -def test_no_statement_appears_twice( - init_functions: dict[str, ast.FunctionDef], -) -> None: - """各段の本体の直下に、まったく同じ文が 2 回並ばない。 +def test_no_statement_appears_twice(init_new_state: ast.FunctionDef) -> None: + """本体の直下に、まったく同じ文が 2 回並ばない。 丸ごとの複製はこの形でしか起こらない(`if meta is None:` も `_print_init_result(...)` も 2 回現れていた)。 """ - for name, func in init_functions.items(): - dumps = [ast.dump(stmt) for stmt in func.body] - repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] - assert not repeated, ( - f"{name} の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" - ) + dumps = [ast.dump(stmt) for stmt in init_new_state.body] + repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] + assert not repeated, ( + f"_init_new_state の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" + ) @pytest.mark.parametrize("name", SINGLE_CALL) -def test_the_call_appears_once( - init_functions: dict[str, ast.FunctionDef], name: str -) -> None: +def test_the_call_appears_once(init_new_state: ast.FunctionDef, name: str) -> None: calls = [ - node - for func in init_functions.values() - for node in ast.walk(func) + node for node in ast.walk(init_new_state) if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) and node.func.id == name From 5ecbab54858c717c68e21149211b8fd8166b9408 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:38:03 +0000 Subject: [PATCH 050/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index f9513c6a..544dd1c0 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -180,7 +180,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 検証中 | 1 | +| long_method | extract_method | major | codex | 採用 | 1 | **なぜ**: 既存stateの探索、旧形式の補完、追加レビュー観点の再計算、引き継ぎ記録、保存、待ち行列flush、worktree同期、機械可読出力までが1関数に直列で置かれ、副作用の順序を長い本体とコメントから追う必要がある。各段階には独立した終了条件と入出力があり、名前を付けて分離できる。 @@ -194,7 +194,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 未着手 | 0 | +| long_method | extract_method | major | codex | 取り消し | 1 | **なぜ**: 新規初期化の1関数に、PR所有権の解決、レビュー観点の構築、worktreeと既存コメントの準備、認証確認、state構築、永続化と出力が同居し、さらに5個のローカル関数が本体を約200行へ広げている。各段階は既に名前と入出力を持つため、モジュールレベルへ抽出すれば段階単位で読めて個別にテストできる。 @@ -242,3 +242,4 @@ | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | error | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/scripts/lib/refresh.py#fetch` | error | テストの期待する振る舞いが変わっています(plugins/ndf/scripts/tests/test_refresh.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | | 3 | `plugins/ndf/scripts/lib/monitor.py#_record_outcome` | long_method | 1 ラウンドの採用上限 5 件を超えた | +| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | テストの期待する振る舞いが変わっています(plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | From 95387271f1b307d6c0a88f1cde8f6401fe3a6111 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:43:06 +0000 Subject: [PATCH 051/217] =?UTF-8?q?Refactor:=20replace=5Fwith=5Flookup=5Ft?= =?UTF-8?q?able=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/stat?= =?UTF-8?q?e.py#cmd=5Fcheck=5Foscillation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 一致種別 (exact / near / body) を if/elif で 3 変数に加算していた集計を collections.Counter への置き換えに直す。重なりの件数は None 以外の値の総和で 出し、表示は counts から読む。あわせて _finding_keys をそのまま呼ぶだけの collect_keys クロージャを消し、直接呼び出しに戻す。振る舞いは変えない。 Item-Id: R4-003 Round: 4 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Fable 5.1 --- .../ndf/skills/cross-review/scripts/state.py | 25 ++++++------------- 1 file changed, 8 insertions(+), 17 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index a17f8378..86fa82ee 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -24,6 +24,7 @@ import sys import tempfile import time +from collections import Counter from typing import Any, NamedTuple # 待ち行列は共通層に置く。指し方の契約は `plugins/ndf/scripts/lib/README.md` にある。 @@ -3904,30 +3905,20 @@ def cmd_check_oscillation(args: argparse.Namespace) -> None: prev_round_no = same_pr[-2]["round"] curr_round_no = same_pr[-1]["round"] - def collect_keys(round_no: int) -> list[tuple[str, int, str]]: - """そのラウンドの指摘を (ファイル, 行, 正規化した本文) の並びで返す。""" - return _finding_keys(st, pr, round_no) - - prev = collect_keys(prev_round_no) - curr = collect_keys(curr_round_no) + prev = _finding_keys(st, pr, prev_round_no) + curr = _finding_keys(st, pr, curr_round_no) if not curr: info("⏭ 現ラウンドの payload なし: 振動検知スキップ") sys.exit(2) - exact = near = same_body = 0 - for key in curr: - kind = _finding_match_kind(key, prev) - if kind == "exact": - exact += 1 - elif kind == "near": - near += 1 - elif kind == "body": - same_body += 1 - overlap_count = exact + near + same_body + # 一致種別ごとの件数。None は「一致なし」で、重なりには数えない + counts = Counter(_finding_match_kind(key, prev) for key in curr) + overlap_count = sum(n for kind, n in counts.items() if kind is not None) ratio = overlap_count / len(curr) info( f"振動検知: overlap={overlap_count}/{len(curr)} ({ratio:.0%})" - f" 位置={exact} 近傍={near} 本文={same_body}" + f" 位置={counts.get('exact', 0)} 近傍={counts.get('near', 0)}" + f" 本文={counts.get('body', 0)}" ) if ratio >= 0.5: From e248d50a4609b5da638296e3126a5584701c02f5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:44:09 +0000 Subject: [PATCH 052/217] =?UTF-8?q?Revert=20"Refactor:=20replace=5Fwith=5F?= =?UTF-8?q?lookup=5Ftable=20=E2=80=94=20plugins/ndf/skills/cross-review/sc?= =?UTF-8?q?ripts/state.py#cmd=5Fcheck=5Foscillation"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 95387271f1b307d6c0a88f1cde8f6401fe3a6111. --- .../ndf/skills/cross-review/scripts/state.py | 25 +++++++++++++------ 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 86fa82ee..a17f8378 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -24,7 +24,6 @@ import sys import tempfile import time -from collections import Counter from typing import Any, NamedTuple # 待ち行列は共通層に置く。指し方の契約は `plugins/ndf/scripts/lib/README.md` にある。 @@ -3905,20 +3904,30 @@ def cmd_check_oscillation(args: argparse.Namespace) -> None: prev_round_no = same_pr[-2]["round"] curr_round_no = same_pr[-1]["round"] - prev = _finding_keys(st, pr, prev_round_no) - curr = _finding_keys(st, pr, curr_round_no) + def collect_keys(round_no: int) -> list[tuple[str, int, str]]: + """そのラウンドの指摘を (ファイル, 行, 正規化した本文) の並びで返す。""" + return _finding_keys(st, pr, round_no) + + prev = collect_keys(prev_round_no) + curr = collect_keys(curr_round_no) if not curr: info("⏭ 現ラウンドの payload なし: 振動検知スキップ") sys.exit(2) - # 一致種別ごとの件数。None は「一致なし」で、重なりには数えない - counts = Counter(_finding_match_kind(key, prev) for key in curr) - overlap_count = sum(n for kind, n in counts.items() if kind is not None) + exact = near = same_body = 0 + for key in curr: + kind = _finding_match_kind(key, prev) + if kind == "exact": + exact += 1 + elif kind == "near": + near += 1 + elif kind == "body": + same_body += 1 + overlap_count = exact + near + same_body ratio = overlap_count / len(curr) info( f"振動検知: overlap={overlap_count}/{len(curr)} ({ratio:.0%})" - f" 位置={counts.get('exact', 0)} 近傍={counts.get('near', 0)}" - f" 本文={counts.get('body', 0)}" + f" 位置={exact} 近傍={near} 本文={same_body}" ) if ratio >= 0.5: From 5bef51a28b0c70da2b9a313e924c369d4c698c75 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:44:09 +0000 Subject: [PATCH 053/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index 544dd1c0..8777492a 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -208,7 +208,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| conditional_chain | replace_with_lookup_table | minor | kiro | 未着手 | 0 | +| conditional_chain | replace_with_lookup_table | minor | kiro | 取り消し | 1 | **なぜ**: 現ラウンドの各指摘について _finding_match_kind の戻り値 ("exact"/"near"/"body") を if/elif で数えている。種別ごとの集計は種別を増やすたびに分岐を足すことになる。あわせて、collect_keys クロージャは _finding_keys(st, pr, round_no) をそのまま呼ぶだけの指標なしの間接参照で、読み手が本体を追う負荷を増やしている。test_state_check_oscillation.py と test_state_oscillation_matching.py が cmd_check_oscillation / _finding_match_kind を通す。 @@ -243,3 +243,4 @@ | 2 | `plugins/ndf/scripts/lib/refresh.py#fetch` | error | テストの期待する振る舞いが変わっています(plugins/ndf/scripts/tests/test_refresh.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | | 3 | `plugins/ndf/scripts/lib/monitor.py#_record_outcome` | long_method | 1 ラウンドの採用上限 5 件を超えた | | 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | テストの期待する振る舞いが変わっています(plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | +| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` | conditional_chain | コミット 95387271f1b307d6c0a88f1cde8f6401fe3a6111 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | From 3523d2cac7e70018e8ccde04a940947efaba3ce6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:48:28 +0000 Subject: [PATCH 054/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#?= =?UTF-8?q?=5Fnormalize=5Ffix=5Fresult?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 別名の解決と deferred の正規化を小関数へ抽出し、記録用辞書の組み立てを明確にする。 Item-Id: R4-004 Round: 4 Impl-Runtime: codex Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 30 ++++++++++++------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index a17f8378..8b01b888 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -4067,31 +4067,39 @@ def _merge_fix_records(st: dict, fix: dict, pr: int) -> dict: return st["rounds"][-1]["fix"] -def _normalize_fix_result(fix: dict) -> dict[str, Any]: - """fix の戻り値から別名と劣化表現を吸収し、記録へ写す値にそろえる。""" - # key 名 fallback (サブエージェントが別名で書いた場合の救済)。 - # 正規は fix_commit / fixed_count、別名は commit_sha / fixed のみ受理する。 +def _resolve_fix_aliases(fix: dict) -> tuple[object, object]: + """fix の commit と fixed 件数を正規 key と別名から解決する。""" fix_commit = fix.get("fix_commit") or fix.get("commit_sha") fixed_count = fix.get("fixed_count") if fixed_count is None: fixed_count = fix.get("fixed", 0) + return fix_commit, fixed_count + + +def _normalize_deferred_like(raw: object) -> tuple[list[dict], int]: + """deferred の項目と、劣化表現を考慮した保存件数を返す。""" + items = _normalize_dict_items(raw) + if isinstance(raw, (list, dict)): + return items, len(items) + return items, _count(raw) + + +def _normalize_fix_result(fix: dict) -> dict[str, Any]: + """fix の戻り値から別名と劣化表現を吸収し、記録へ写す値にそろえる。""" + # key 名 fallback (サブエージェントが別名で書いた場合の救済)。 + # 正規は fix_commit / fixed_count、別名は commit_sha / fixed のみ受理する。 + fix_commit, fixed_count = _resolve_fix_aliases(fix) # deferred は list が正だが、LLM がスキーマを無視して文字列リスト # (例: ["nit: ..."]) や単一 dict、int(件数) を返すケースがある。後段の # deferred_nits 展開ループは dict 以外をスキップするため、まず dict 要素のみへ # 正規化する (単一 dict は 1 件として包む)。 - _deferred_raw = fix.get("deferred") - _deferred_nits = _normalize_dict_items(_deferred_raw) - # 保存件数の単一整合ルール: # - 構造化データ (list / dict) は per-item を保持できるので、展開件数 # (len(_deferred_nits)) を保存し deferred_nits の件数と一致させる。 # - int / 数値文字列は per-item データを失った「劣化表現」なので、件数を # 失わないよう _count() の値を保存する (展開はできないので nits は空)。 - if isinstance(_deferred_raw, (list, dict)): - _deferred_count = len(_deferred_nits) - else: - _deferred_count = _count(_deferred_raw) + _deferred_nits, _deferred_count = _normalize_deferred_like(fix.get("deferred")) # 却下も同じ正規化を通す。**件数だけが返る劣化表現(int)では per-item を作れない** # ため、そのときは記録を空にし、件数は `_count()` の値で残す。 From 5e59a9882f12d0b8842450c56577ac6bbaaae042 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:49:15 +0000 Subject: [PATCH 055/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index 8777492a..5fdb361c 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -222,7 +222,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | kiro | 未着手 | 0 | +| long_method | extract_method | minor | kiro | 検証中 | 1 | **なぜ**: 1 関数に (a) 別名 fallback(fix_commit/commit_sha、fixed_count/fixed)、(b) deferred の list/dict/int による件数の場合分けと dict 要素への正規化、(c) rejected の同じ正規化、(d) 記録用辞書の組み立て、が同居する。deferred と rejected はどちらも _normalize_dict_items + 件数決定という同型の処理で、片方だけ直すと食い違いうる。test_state_merge_fix.py と test_state_ci_classification.py が cmd_merge_fix 経由で通す。 From bb191deb3276cc2751c645f3609af49aaeaa90fc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 13:59:28 +0000 Subject: [PATCH 056/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/post=5Fqueue.py#Queue.flush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Queue.flush から読み取り済み項目の処理を _flush_item へ抽出、 state.py の _sync_worktree からターゲット解決と同期判定を抽出し、 metrics.py の format_report で unmeasured / assumed 節の生成を _emit_bullet_section へ統合。 Item-Id: R5-001 Round: 5 Impl-Runtime: agy Impl-Model: default --- plugins/ndf/scripts/lib/metrics.py | 21 +++-- plugins/ndf/scripts/lib/post_queue.py | 59 +++++++----- .../ndf/skills/cross-review/scripts/state.py | 91 +++++++++++++------ 3 files changed, 112 insertions(+), 59 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index cdafe8ea..a7bb2d3e 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -266,6 +266,18 @@ def _emit_table( lines += [*headers, *rows] +def _emit_bullet_section( + lines: list[str], + title: str, + items: list[str], +) -> None: + """箇条書きの節を出力する。項目がなければ出力しない。""" + if not items: + return + lines += ["", f"## {title}", ""] + lines += [f"- {w}" for w in dict.fromkeys(items)] + + def format_report(metrics: dict[str, Any]) -> str: """人が読む形へ整形する。比較の限界を必ず添える。""" lines: list[str] = [] @@ -307,13 +319,8 @@ def format_report(metrics: dict[str, Any]) -> str: reviewer_rows, ) - if metrics["unmeasured"]: - lines += ["", "## 集計から分離したラウンド", ""] - lines += [f"- {w}" for w in dict.fromkeys(metrics["unmeasured"])] - - if metrics.get("assumed"): - lines += ["", "## 指定値で代用したラウンド", ""] - lines += [f"- {w}" for w in dict.fromkeys(metrics["assumed"])] + _emit_bullet_section(lines, "集計から分離したラウンド", metrics["unmeasured"]) + _emit_bullet_section(lines, "指定値で代用したラウンド", metrics.get("assumed") or []) lines += ["", "## 比較として読むときの限界", ""] lines += [f"- {c}" for c in COMPARISON_CAVEATS] diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index e79511ab..ddbb9274 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -487,6 +487,35 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: json.dump(item, f, indent=2, ensure_ascii=False) return path + def _flush_item( + self, path: pathlib.Path, item: dict[str, Any] + ) -> tuple[str, bool]: + """読み取り済み項目を 1 件処理する(既投稿・送信成功・送信失敗)。 + + 戻り値は `(状態, rate_limited)`。状態は 'skipped', 'sent', 'failed'。 + """ + found, row = posted_match(item) + if found is True: + # **送った場合と同じ形で返す。** 呼び出し側は届いたことを応答から + # 確かめるため、既に届いていた項目にも見つけた投稿を積んで渡す。 + if row is not None: + item["response"] = row + path.unlink(missing_ok=True) + return "skipped", False + attempt = send(item) + if attempt.ok: + try: + item["response"] = json.loads(attempt.stdout or "null") + except json.JSONDecodeError: + item["response"] = None + path.unlink(missing_ok=True) + return "sent", False + item["attempts"] = int(item.get("attempts") or 0) + 1 + item["last_error"] = attempt.summary() + path.write_text(json.dumps(item, indent=2, ensure_ascii=False), + encoding="utf-8") + return "failed", is_rate_limited(attempt) + def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -509,31 +538,15 @@ def flush(self) -> FlushResult: "last_error": f"待ち行列の項目を読めない ({path.name})", } break - found, row = posted_match(item) - if found is True: - # **送った場合と同じ形で返す。** 呼び出し側は届いたことを応答から - # 確かめるため、既に届いていた項目にも見つけた投稿を積んで渡す。 - if row is not None: - item["response"] = row - path.unlink(missing_ok=True) + status, item_rate_limited = self._flush_item(path, item) + if status == "skipped": skipped.append(item) - continue - attempt = send(item) - if attempt.ok: - try: - item["response"] = json.loads(attempt.stdout or "null") - except json.JSONDecodeError: - item["response"] = None - path.unlink(missing_ok=True) + elif status == "sent": sent.append(item) - continue - item["attempts"] = int(item.get("attempts") or 0) + 1 - item["last_error"] = attempt.summary() - path.write_text(json.dumps(item, indent=2, ensure_ascii=False), - encoding="utf-8") - failed = item - rate_limited = is_rate_limited(attempt) - break + else: + failed = item + rate_limited = item_rate_limited + break return FlushResult(sent, skipped, failed, self.count(), rate_limited) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 8b01b888..76a0853f 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -756,6 +756,62 @@ def _clean_untracked_files(worktree: str, exclusions: list[str], code: int) -> N die(f"追跡対象外のファイルを消せない: {clean.stderr.strip()}", code=code) +def _resolve_sync_target( + worktree: str, + pr: int, + head: str | HeadRef, +) -> tuple[bool, str, str]: + """HeadRef と文字列 head から have_base・target・label を解決する。""" + if isinstance(head, HeadRef): + have_base = _fetch_head(worktree, pr, head) + target = head.oid + label = head.branch + else: + # 旧来の呼び出し(ブランチ名だけを渡す経路)。基準は `origin/` になる。 + fetch = subprocess.run( + ["git", "fetch", "origin", head], + capture_output=True, text=True, + ) + have_base = fetch.returncode == 0 + target = f"origin/{head}" + label = head + return have_base, target, label + + +def _can_skip_sync( + worktree: str, + pr: int, + head: str | HeadRef, + have_base: bool, + target: str, + label: str, + exclusions: list[str], + *, + strict: bool, + code: int, +) -> bool: + """strict 時の同期済み早期終了と基準取得失敗を判定する。 + + 同期済みで作業が不要な場合は `True` を返す。 + """ + if have_base: + if strict and isinstance(head, HeadRef) and _is_synced( + worktree, pr, head, exclusions, code): + return True + elif strict: + # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 + # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 + die( + f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" + " ネットワークか権限を確認してください", + code=code, + ) + else: + # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 + info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + return False + + def _sync_worktree( worktree: str, pr: int, @@ -789,35 +845,12 @@ def _sync_worktree( """ code = 8 if strict else 1 exclusions = _sync_exclusions(worktree) - if isinstance(head, HeadRef): - have_base = _fetch_head(worktree, pr, head) - target = head.oid - label = head.branch - else: - # 旧来の呼び出し(ブランチ名だけを渡す経路)。基準は `origin/` になる。 - fetch = subprocess.run( - ["git", "fetch", "origin", head], - capture_output=True, text=True, - ) - have_base = fetch.returncode == 0 - target = f"origin/{head}" - label = head - - if have_base: - if strict and isinstance(head, HeadRef) and _is_synced( - worktree, pr, head, exclusions, code): - return - elif strict: - # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 - # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 - die( - f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" - " ネットワークか権限を確認してください", - code=code, - ) - else: - # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 - info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + have_base, target, label = _resolve_sync_target(worktree, pr, head) + if _can_skip_sync( + worktree, pr, head, have_base, target, label, exclusions, + strict=strict, code=code, + ): + return _reset_worktree_head(worktree, pr, target if have_base else None, code) _clean_untracked_files(worktree, exclusions, code) rev = subprocess.run( From 311bf7f61edcdd504d5dd1587ce88036f5731cea Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:06:32 +0000 Subject: [PATCH 057/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/post=5Fqueue.py#Queue.flush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 待ち行列の項目処理結果と worktree 同期判定の入力を構造化し、抽出した処理の責務と受け渡しを明確にする。箇条書き節の共通化も維持する。 Item-Id: R5-001 Round: 5 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/lib/metrics.py | 2 +- plugins/ndf/scripts/lib/post_queue.py | 23 ++++--- .../ndf/skills/cross-review/scripts/state.py | 61 +++++++++++-------- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index a7bb2d3e..1e1509c1 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -275,7 +275,7 @@ def _emit_bullet_section( if not items: return lines += ["", f"## {title}", ""] - lines += [f"- {w}" for w in dict.fromkeys(items)] + lines += [f"- {item}" for item in dict.fromkeys(items)] def format_report(metrics: dict[str, Any]) -> str: diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index ddbb9274..40125dd5 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -434,6 +434,13 @@ class FlushResult(NamedTuple): rate_limited: bool +class _FlushItemResult(NamedTuple): + """読み取り済み項目 1 件の処理結果。""" + + status: str + rate_limited: bool + + class Queue: """1 つの Pull Request 分の待ち行列。""" @@ -489,7 +496,7 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: def _flush_item( self, path: pathlib.Path, item: dict[str, Any] - ) -> tuple[str, bool]: + ) -> _FlushItemResult: """読み取り済み項目を 1 件処理する(既投稿・送信成功・送信失敗)。 戻り値は `(状態, rate_limited)`。状態は 'skipped', 'sent', 'failed'。 @@ -501,7 +508,7 @@ def _flush_item( if row is not None: item["response"] = row path.unlink(missing_ok=True) - return "skipped", False + return _FlushItemResult("skipped", False) attempt = send(item) if attempt.ok: try: @@ -509,12 +516,12 @@ def _flush_item( except json.JSONDecodeError: item["response"] = None path.unlink(missing_ok=True) - return "sent", False + return _FlushItemResult("sent", False) item["attempts"] = int(item.get("attempts") or 0) + 1 item["last_error"] = attempt.summary() path.write_text(json.dumps(item, indent=2, ensure_ascii=False), encoding="utf-8") - return "failed", is_rate_limited(attempt) + return _FlushItemResult("failed", is_rate_limited(attempt)) def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -538,14 +545,14 @@ def flush(self) -> FlushResult: "last_error": f"待ち行列の項目を読めない ({path.name})", } break - status, item_rate_limited = self._flush_item(path, item) - if status == "skipped": + result = self._flush_item(path, item) + if result.status == "skipped": skipped.append(item) - elif status == "sent": + elif result.status == "sent": sent.append(item) else: failed = item - rate_limited = item_rate_limited + rate_limited = result.rate_limited break return FlushResult(sent, skipped, failed, self.count(), rate_limited) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 76a0853f..6310447b 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -756,11 +756,30 @@ def _clean_untracked_files(worktree: str, exclusions: list[str], code: int) -> N die(f"追跡対象外のファイルを消せない: {clean.stderr.strip()}", code=code) +class _SyncTarget(NamedTuple): + """同期先の取得結果。""" + + have_base: bool + ref: str + label: str + + +class _SyncContext(NamedTuple): + """同期済み判定に必要な入力。""" + + worktree: str + pr: int + head: str | HeadRef + exclusions: list[str] + strict: bool + error_code: int + + def _resolve_sync_target( worktree: str, pr: int, head: str | HeadRef, -) -> tuple[bool, str, str]: +) -> _SyncTarget: """HeadRef と文字列 head から have_base・target・label を解決する。""" if isinstance(head, HeadRef): have_base = _fetch_head(worktree, pr, head) @@ -775,40 +794,33 @@ def _resolve_sync_target( have_base = fetch.returncode == 0 target = f"origin/{head}" label = head - return have_base, target, label + return _SyncTarget(have_base, target, label) def _can_skip_sync( - worktree: str, - pr: int, - head: str | HeadRef, - have_base: bool, - target: str, - label: str, - exclusions: list[str], - *, - strict: bool, - code: int, + context: _SyncContext, + target: _SyncTarget, ) -> bool: """strict 時の同期済み早期終了と基準取得失敗を判定する。 同期済みで作業が不要な場合は `True` を返す。 """ - if have_base: - if strict and isinstance(head, HeadRef) and _is_synced( - worktree, pr, head, exclusions, code): + if target.have_base: + if context.strict and isinstance(context.head, HeadRef) and _is_synced( + context.worktree, context.pr, context.head, + context.exclusions, context.error_code): return True - elif strict: + elif context.strict: # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 die( - f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" + f"PR #{context.pr} の基準のコミット {target.ref[:7]} を取り込めない。" " ネットワークか権限を確認してください", - code=code, + code=context.error_code, ) else: # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 - info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + info(f"⚠ git fetch origin {target.label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") return False @@ -845,13 +857,12 @@ def _sync_worktree( """ code = 8 if strict else 1 exclusions = _sync_exclusions(worktree) - have_base, target, label = _resolve_sync_target(worktree, pr, head) - if _can_skip_sync( - worktree, pr, head, have_base, target, label, exclusions, - strict=strict, code=code, - ): + target = _resolve_sync_target(worktree, pr, head) + context = _SyncContext(worktree, pr, head, exclusions, strict, code) + if _can_skip_sync(context, target): return - _reset_worktree_head(worktree, pr, target if have_base else None, code) + _reset_worktree_head( + worktree, pr, target.ref if target.have_base else None, code) _clean_untracked_files(worktree, exclusions, code) rev = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], From 594c8786765615abb00dc7491df2e158ebcecbda Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:06:52 +0000 Subject: [PATCH 058/217] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/post=5Fqueue.py#Queue.fl?= =?UTF-8?q?ush"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 311bf7f61edcdd504d5dd1587ce88036f5731cea. --- plugins/ndf/scripts/lib/metrics.py | 2 +- plugins/ndf/scripts/lib/post_queue.py | 23 +++---- .../ndf/skills/cross-review/scripts/state.py | 61 ++++++++----------- 3 files changed, 34 insertions(+), 52 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index 1e1509c1..a7bb2d3e 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -275,7 +275,7 @@ def _emit_bullet_section( if not items: return lines += ["", f"## {title}", ""] - lines += [f"- {item}" for item in dict.fromkeys(items)] + lines += [f"- {w}" for w in dict.fromkeys(items)] def format_report(metrics: dict[str, Any]) -> str: diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index 40125dd5..ddbb9274 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -434,13 +434,6 @@ class FlushResult(NamedTuple): rate_limited: bool -class _FlushItemResult(NamedTuple): - """読み取り済み項目 1 件の処理結果。""" - - status: str - rate_limited: bool - - class Queue: """1 つの Pull Request 分の待ち行列。""" @@ -496,7 +489,7 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: def _flush_item( self, path: pathlib.Path, item: dict[str, Any] - ) -> _FlushItemResult: + ) -> tuple[str, bool]: """読み取り済み項目を 1 件処理する(既投稿・送信成功・送信失敗)。 戻り値は `(状態, rate_limited)`。状態は 'skipped', 'sent', 'failed'。 @@ -508,7 +501,7 @@ def _flush_item( if row is not None: item["response"] = row path.unlink(missing_ok=True) - return _FlushItemResult("skipped", False) + return "skipped", False attempt = send(item) if attempt.ok: try: @@ -516,12 +509,12 @@ def _flush_item( except json.JSONDecodeError: item["response"] = None path.unlink(missing_ok=True) - return _FlushItemResult("sent", False) + return "sent", False item["attempts"] = int(item.get("attempts") or 0) + 1 item["last_error"] = attempt.summary() path.write_text(json.dumps(item, indent=2, ensure_ascii=False), encoding="utf-8") - return _FlushItemResult("failed", is_rate_limited(attempt)) + return "failed", is_rate_limited(attempt) def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -545,14 +538,14 @@ def flush(self) -> FlushResult: "last_error": f"待ち行列の項目を読めない ({path.name})", } break - result = self._flush_item(path, item) - if result.status == "skipped": + status, item_rate_limited = self._flush_item(path, item) + if status == "skipped": skipped.append(item) - elif result.status == "sent": + elif status == "sent": sent.append(item) else: failed = item - rate_limited = result.rate_limited + rate_limited = item_rate_limited break return FlushResult(sent, skipped, failed, self.count(), rate_limited) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 6310447b..76a0853f 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -756,30 +756,11 @@ def _clean_untracked_files(worktree: str, exclusions: list[str], code: int) -> N die(f"追跡対象外のファイルを消せない: {clean.stderr.strip()}", code=code) -class _SyncTarget(NamedTuple): - """同期先の取得結果。""" - - have_base: bool - ref: str - label: str - - -class _SyncContext(NamedTuple): - """同期済み判定に必要な入力。""" - - worktree: str - pr: int - head: str | HeadRef - exclusions: list[str] - strict: bool - error_code: int - - def _resolve_sync_target( worktree: str, pr: int, head: str | HeadRef, -) -> _SyncTarget: +) -> tuple[bool, str, str]: """HeadRef と文字列 head から have_base・target・label を解決する。""" if isinstance(head, HeadRef): have_base = _fetch_head(worktree, pr, head) @@ -794,33 +775,40 @@ def _resolve_sync_target( have_base = fetch.returncode == 0 target = f"origin/{head}" label = head - return _SyncTarget(have_base, target, label) + return have_base, target, label def _can_skip_sync( - context: _SyncContext, - target: _SyncTarget, + worktree: str, + pr: int, + head: str | HeadRef, + have_base: bool, + target: str, + label: str, + exclusions: list[str], + *, + strict: bool, + code: int, ) -> bool: """strict 時の同期済み早期終了と基準取得失敗を判定する。 同期済みで作業が不要な場合は `True` を返す。 """ - if target.have_base: - if context.strict and isinstance(context.head, HeadRef) and _is_synced( - context.worktree, context.pr, context.head, - context.exclusions, context.error_code): + if have_base: + if strict and isinstance(head, HeadRef) and _is_synced( + worktree, pr, head, exclusions, code): return True - elif context.strict: + elif strict: # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 die( - f"PR #{context.pr} の基準のコミット {target.ref[:7]} を取り込めない。" + f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" " ネットワークか権限を確認してください", - code=context.error_code, + code=code, ) else: # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 - info(f"⚠ git fetch origin {target.label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") return False @@ -857,12 +845,13 @@ def _sync_worktree( """ code = 8 if strict else 1 exclusions = _sync_exclusions(worktree) - target = _resolve_sync_target(worktree, pr, head) - context = _SyncContext(worktree, pr, head, exclusions, strict, code) - if _can_skip_sync(context, target): + have_base, target, label = _resolve_sync_target(worktree, pr, head) + if _can_skip_sync( + worktree, pr, head, have_base, target, label, exclusions, + strict=strict, code=code, + ): return - _reset_worktree_head( - worktree, pr, target.ref if target.have_base else None, code) + _reset_worktree_head(worktree, pr, target if have_base else None, code) _clean_untracked_files(worktree, exclusions, code) rev = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], From fe5948d25338b6c7a63af5328d9822661fd80442 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:06:52 +0000 Subject: [PATCH 059/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 73 +++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index 5fdb361c..8c4267a0 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -222,7 +222,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | kiro | 検証中 | 1 | +| long_method | extract_method | minor | kiro | 採用 | 1 | **なぜ**: 1 関数に (a) 別名 fallback(fix_commit/commit_sha、fixed_count/fixed)、(b) deferred の list/dict/int による件数の場合分けと dict 要素への正規化、(c) rejected の同じ正規化、(d) 記録用辞書の組み立て、が同居する。deferred と rejected はどちらも _normalize_dict_items + 件数決定という同型の処理で、片方だけ直すと食い違いうる。test_state_merge_fix.py と test_state_ci_classification.py が cmd_merge_fix 経由で通す。 @@ -232,6 +232,74 @@ 4. 末尾の辞書組み立てを、抽出した値を差し込む形へ整える 5. テストを実行して出力の辞書が不変であることを確認する +## ラウンド 5(実装 codex / レビュー agy / kiro) + +### R5-001 — `plugins/ndf/scripts/lib/post_queue.py#Queue.flush` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 取り消し | 0 | + +**なぜ**: 1 件の処理の中に、壊れた JSON の停止判定、既投稿の照合と削除、送信成功時の応答保存と削除、送信失敗時の再試行情報保存と rate limit 判定が直列に並び、flush 自体が順序制御と各項目の状態遷移の両方を担っている。 + +**手順**: 1. 読み取り済み項目について既投稿・送信成功・送信失敗を処理する部分を Queue の補助メソッドへ抽出する +2. 補助メソッドの戻り値で継続または停止と rate_limited を表し、flush は連番走査と集計だけを担うようにする +3. 壊れた項目で停止する既存経路は flush 側に残し、項目順序と停止位置を変えない +4. test_post_queue.py と cross-review/tests/test_queue_idempotency.py で skipped・sent・failed・remaining とファイル削除順を確認する + +### R5-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 取り消し | 0 | + +**なぜ**: PR head の取得方法の決定、strict 時の同期済み判定と失敗処理、worktree の reset・未追跡ファイル掃除、同期結果の表示という独立した段階が 1 関数に同居している。HeadRef と旧来の文字列 head の分岐も取得段階に閉じず、後続の制御へ have_base・target・label の組で持ち越されている。 + +**手順**: 1. HeadRef と文字列 head から have_base・target・label を解決する取得段階を補助関数へ抽出する +2. strict 時の同期済み早期終了と基準取得失敗の判定を補助関数へ抽出する +3. _sync_worktree は取得、判定、reset、clean、結果表示の順序だけを示す構成にする +4. test_state_sync_worktree.py と test_state_offline_fetch.py で既存の strict/fallback/失敗時終了コードを確認する + +### R5-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_load_payload` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| duplication | consolidate_duplication | minor | kiro | 未着手 | 0 | + +**なぜ**: payload が dict でない場合と comments が list でない場合の 2 経路が、`info(f"⚠ {agent}: ...形式不正で、判定は中断します")` を出して `return None` する同じ形で並ぶ。返す条件(dict でない / list でない)と型名の埋め込みが繰り返され、警告文の末尾の定型句も重複する。片方の文言だけ直すと 2 経路のメッセージが食い違う。test_review_findings.py が不正 payload での 0 件記録を固定している。 + +**手順**: 1. `_reject_payload(agent: str, path: pathlib.Path, detail: str) -> None` を追加し、`info(f"⚠ {agent}: {detail}({path}...)。指摘の記録は 0 件です。review launcher の出力形式不正で、判定は中断します")` を出して None を返す +2. dict でない場合と comments が list でない場合の 2 経路を、type 名を含む detail 文字列を渡す呼び出しへ置き換える +3. 部分不正(items != raw)は継続する経路のため対象にせず、そのまま残す +4. `uv run --with pytest pytest plugins/ndf/skills/cross-review/tests/test_review_findings.py -q` で 0 件記録と警告が不変なことを確認する + +### R5-004 — `plugins/ndf/scripts/lib/metrics.py#format_report` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| duplication | consolidate_duplication | minor | kiro | 取り消し | 0 | + +**なぜ**: unmeasured と assumed の 2 節が同じ形(見出し + 空行を lines へ足し、dict.fromkeys で重複を除いた項目を `- {w}` で並べる)で並んでいる。片方だけ書式を変えると 2 節の見た目が食い違う。同じ業務ルール(分離・代用の一覧の出し方)に由来し、変わるときは一緒に変わる。既存テスト test_models_and_metrics.py が format_report の出力を固定している。 + +**手順**: 1. `_emit_bullet_section(lines: list[str], title: str, items: list[str]) -> None` を追加し、items が空でなければ `['', f'## {title}', '']` と `[f'- {w}' for w in dict.fromkeys(items)]` を lines へ足す +2. unmeasured の if ブロックを `_emit_bullet_section(lines, "集計から分離したラウンド", metrics["unmeasured"])` へ置き換える +3. assumed の if ブロックを `_emit_bullet_section(lines, "指定値で代用したラウンド", metrics.get("assumed") or [])` へ置き換える +4. 比較の限界(COMPARISON_CAVEATS)節は常に出るため対象外のまま残す +5. `uv run --with pytest pytest scripts/tests plugins/ndf -q` で出力が不変なことを確認する + +### R5-005 — `plugins/ndf/skills/cross-review/scripts/state.py#build_parser` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | minor | codex | 未着手 | 0 | + +**なぜ**: init の多数のオプション定義と、ラウンド進行・結果取込・検証・報告に属する 11 個の副コマンド登録が 1 関数に連続しており、個別コマンドの引数変更でも 125 行の構築処理全体を読む必要がある。副コマンドごとに独立した名前を付けられる段階になっている。 + +**手順**: 1. init のパーサ設定を専用の補助関数へ抽出する +2. 各副コマンドの parser 作成・引数追加・set_defaults を用途別の小さな登録関数へ抽出する +3. build_parser はトップレベル parser と subparsers を作り、登録関数を順に呼んで返すだけにする +4. test_state_subcommand_help.py、test_state_review_pool.py、test_findings_pipeline_wiring.py で選択肢・help・func の対応が不変であることを確認する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -244,3 +312,6 @@ | 3 | `plugins/ndf/scripts/lib/monitor.py#_record_outcome` | long_method | 1 ラウンドの採用上限 5 件を超えた | | 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | テストの期待する振る舞いが変わっています(plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | | 4 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` | conditional_chain | コミット 95387271f1b307d6c0a88f1cde8f6401fe3a6111 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | +| 5 | `plugins/ndf/scripts/lib/post_queue.py#Queue.flush` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 5 | `plugins/ndf/scripts/lib/metrics.py#format_report` | duplication | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | From 1e8216ff382a2ff64871d4c4ac52ba5892608e94 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:06:32 +0000 Subject: [PATCH 060/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/post=5Fqueue.py#Queue.flush?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 待ち行列の項目処理結果と worktree 同期判定の入力を構造化し、抽出した処理の責務と受け渡しを明確にする。箇条書き節の共通化も維持する。 Item-Id: R5-001 Round: 5 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/lib/metrics.py | 2 +- plugins/ndf/scripts/lib/post_queue.py | 23 ++++--- .../ndf/skills/cross-review/scripts/state.py | 61 +++++++++++-------- 3 files changed, 52 insertions(+), 34 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index a7bb2d3e..1e1509c1 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -275,7 +275,7 @@ def _emit_bullet_section( if not items: return lines += ["", f"## {title}", ""] - lines += [f"- {w}" for w in dict.fromkeys(items)] + lines += [f"- {item}" for item in dict.fromkeys(items)] def format_report(metrics: dict[str, Any]) -> str: diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index ddbb9274..40125dd5 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -434,6 +434,13 @@ class FlushResult(NamedTuple): rate_limited: bool +class _FlushItemResult(NamedTuple): + """読み取り済み項目 1 件の処理結果。""" + + status: str + rate_limited: bool + + class Queue: """1 つの Pull Request 分の待ち行列。""" @@ -489,7 +496,7 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: def _flush_item( self, path: pathlib.Path, item: dict[str, Any] - ) -> tuple[str, bool]: + ) -> _FlushItemResult: """読み取り済み項目を 1 件処理する(既投稿・送信成功・送信失敗)。 戻り値は `(状態, rate_limited)`。状態は 'skipped', 'sent', 'failed'。 @@ -501,7 +508,7 @@ def _flush_item( if row is not None: item["response"] = row path.unlink(missing_ok=True) - return "skipped", False + return _FlushItemResult("skipped", False) attempt = send(item) if attempt.ok: try: @@ -509,12 +516,12 @@ def _flush_item( except json.JSONDecodeError: item["response"] = None path.unlink(missing_ok=True) - return "sent", False + return _FlushItemResult("sent", False) item["attempts"] = int(item.get("attempts") or 0) + 1 item["last_error"] = attempt.summary() path.write_text(json.dumps(item, indent=2, ensure_ascii=False), encoding="utf-8") - return "failed", is_rate_limited(attempt) + return _FlushItemResult("failed", is_rate_limited(attempt)) def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -538,14 +545,14 @@ def flush(self) -> FlushResult: "last_error": f"待ち行列の項目を読めない ({path.name})", } break - status, item_rate_limited = self._flush_item(path, item) - if status == "skipped": + result = self._flush_item(path, item) + if result.status == "skipped": skipped.append(item) - elif status == "sent": + elif result.status == "sent": sent.append(item) else: failed = item - rate_limited = item_rate_limited + rate_limited = result.rate_limited break return FlushResult(sent, skipped, failed, self.count(), rate_limited) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 76a0853f..6310447b 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -756,11 +756,30 @@ def _clean_untracked_files(worktree: str, exclusions: list[str], code: int) -> N die(f"追跡対象外のファイルを消せない: {clean.stderr.strip()}", code=code) +class _SyncTarget(NamedTuple): + """同期先の取得結果。""" + + have_base: bool + ref: str + label: str + + +class _SyncContext(NamedTuple): + """同期済み判定に必要な入力。""" + + worktree: str + pr: int + head: str | HeadRef + exclusions: list[str] + strict: bool + error_code: int + + def _resolve_sync_target( worktree: str, pr: int, head: str | HeadRef, -) -> tuple[bool, str, str]: +) -> _SyncTarget: """HeadRef と文字列 head から have_base・target・label を解決する。""" if isinstance(head, HeadRef): have_base = _fetch_head(worktree, pr, head) @@ -775,40 +794,33 @@ def _resolve_sync_target( have_base = fetch.returncode == 0 target = f"origin/{head}" label = head - return have_base, target, label + return _SyncTarget(have_base, target, label) def _can_skip_sync( - worktree: str, - pr: int, - head: str | HeadRef, - have_base: bool, - target: str, - label: str, - exclusions: list[str], - *, - strict: bool, - code: int, + context: _SyncContext, + target: _SyncTarget, ) -> bool: """strict 時の同期済み早期終了と基準取得失敗を判定する。 同期済みで作業が不要な場合は `True` を返す。 """ - if have_base: - if strict and isinstance(head, HeadRef) and _is_synced( - worktree, pr, head, exclusions, code): + if target.have_base: + if context.strict and isinstance(context.head, HeadRef) and _is_synced( + context.worktree, context.pr, context.head, + context.exclusions, context.error_code): return True - elif strict: + elif context.strict: # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 die( - f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" + f"PR #{context.pr} の基準のコミット {target.ref[:7]} を取り込めない。" " ネットワークか権限を確認してください", - code=code, + code=context.error_code, ) else: # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 - info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + info(f"⚠ git fetch origin {target.label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") return False @@ -845,13 +857,12 @@ def _sync_worktree( """ code = 8 if strict else 1 exclusions = _sync_exclusions(worktree) - have_base, target, label = _resolve_sync_target(worktree, pr, head) - if _can_skip_sync( - worktree, pr, head, have_base, target, label, exclusions, - strict=strict, code=code, - ): + target = _resolve_sync_target(worktree, pr, head) + context = _SyncContext(worktree, pr, head, exclusions, strict, code) + if _can_skip_sync(context, target): return - _reset_worktree_head(worktree, pr, target if have_base else None, code) + _reset_worktree_head( + worktree, pr, target.ref if target.have_base else None, code) _clean_untracked_files(worktree, exclusions, code) rev = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], From 3df024d0bb9eb2c1c1b0afaf98fd451cd4c8728e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:11:19 +0000 Subject: [PATCH 061/217] =?UTF-8?q?Refactor:=20consolidate=5Fduplication?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py?= =?UTF-8?q?#=5Fload=5Fpayload?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit payload が dict でない経路と comments が list でない経路が、型名の違いだけで 同じ警告(0 件・判定は中断)を出していた。共通の _reject_payload() へ末尾の 定型句を集約し、片方の文言だけが動いて 2 経路が食い違うのを防ぐ。振る舞いは不変。 Item-Id: R5-003 Round: 5 Impl-Runtime: kiro Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 26 +++++++++++++------ 1 file changed, 18 insertions(+), 8 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 6310447b..c328e212 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -2636,6 +2636,18 @@ def _has_evidence(finding: dict[str, Any]) -> bool: ) +def _reject_payload(agent: str, path: pathlib.Path, detail: str) -> None: + """形式不正の payload を、共通の警告文とともに退ける。**常に None を返す。** + + dict でない payload と list でない `comments` の 2 経路が、型名を含む `detail` の + 違いだけで同じ警告を出していた。末尾の定型句(0 件・判定は中断)を 1 か所へ集約し、 + 片方の文言だけが動いて 2 経路が食い違うのを防ぐ。 + """ + info(f"⚠ {agent}: {detail}({path})。指摘の記録は 0 件です。" + " review launcher の出力形式不正で、判定は中断します") + return None + + def _load_payload(agent: str, path: pathlib.Path) -> list[dict[str, Any]] | None: """payload.json を読み、検証して dict のリストとして返す。読めない・不正なときは None を返す。""" try: @@ -2647,16 +2659,14 @@ def _load_payload(agent: str, path: pathlib.Path) -> list[dict[str, Any]] | None if not isinstance(payload, dict): # 実測: dict 以外(`[]` / `null` / 文字列 / 数値)を渡すと # `payload.get(...)` が AttributeError で落ち、取り込みが例外で終わっていた。 - info(f"⚠ {agent}: payload.json が dict ではありません" - f"({path}, type={type(payload).__name__})。指摘の記録は 0 件です。" - " review launcher の出力形式不正で、判定は中断します") - return None + return _reject_payload( + agent, path, + f"payload.json が dict ではありません, type={type(payload).__name__}") raw = payload.get("comments") if not isinstance(raw, list): - info(f"⚠ {agent}: payload.comments が list ではありません" - f"({path}, type={type(raw).__name__})。指摘の記録は 0 件です。" - " review launcher の出力形式不正で、判定は中断します") - return None + return _reject_payload( + agent, path, + f"payload.comments が list ではありません, type={type(raw).__name__}") items = [c for c in raw if isinstance(c, dict)] if len(items) != len(raw): info(f"⚠ {agent}: payload.comments に dict でないエントリが" From 86ae4459f943bc48cb23988ca0c8f599d9099ba5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:11:43 +0000 Subject: [PATCH 062/217] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdup?= =?UTF-8?q?lication=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/?= =?UTF-8?q?state.py#=5Fload=5Fpayload"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 3df024d0bb9eb2c1c1b0afaf98fd451cd4c8728e. --- .../ndf/skills/cross-review/scripts/state.py | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index c328e212..6310447b 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -2636,18 +2636,6 @@ def _has_evidence(finding: dict[str, Any]) -> bool: ) -def _reject_payload(agent: str, path: pathlib.Path, detail: str) -> None: - """形式不正の payload を、共通の警告文とともに退ける。**常に None を返す。** - - dict でない payload と list でない `comments` の 2 経路が、型名を含む `detail` の - 違いだけで同じ警告を出していた。末尾の定型句(0 件・判定は中断)を 1 か所へ集約し、 - 片方の文言だけが動いて 2 経路が食い違うのを防ぐ。 - """ - info(f"⚠ {agent}: {detail}({path})。指摘の記録は 0 件です。" - " review launcher の出力形式不正で、判定は中断します") - return None - - def _load_payload(agent: str, path: pathlib.Path) -> list[dict[str, Any]] | None: """payload.json を読み、検証して dict のリストとして返す。読めない・不正なときは None を返す。""" try: @@ -2659,14 +2647,16 @@ def _load_payload(agent: str, path: pathlib.Path) -> list[dict[str, Any]] | None if not isinstance(payload, dict): # 実測: dict 以外(`[]` / `null` / 文字列 / 数値)を渡すと # `payload.get(...)` が AttributeError で落ち、取り込みが例外で終わっていた。 - return _reject_payload( - agent, path, - f"payload.json が dict ではありません, type={type(payload).__name__}") + info(f"⚠ {agent}: payload.json が dict ではありません" + f"({path}, type={type(payload).__name__})。指摘の記録は 0 件です。" + " review launcher の出力形式不正で、判定は中断します") + return None raw = payload.get("comments") if not isinstance(raw, list): - return _reject_payload( - agent, path, - f"payload.comments が list ではありません, type={type(raw).__name__}") + info(f"⚠ {agent}: payload.comments が list ではありません" + f"({path}, type={type(raw).__name__})。指摘の記録は 0 件です。" + " review launcher の出力形式不正で、判定は中断します") + return None items = [c for c in raw if isinstance(c, dict)] if len(items) != len(raw): info(f"⚠ {agent}: payload.comments に dict でないエントリが" From 7ebf5e8c69bfb532d880cde2e9c3da1ee575fa13 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:11:43 +0000 Subject: [PATCH 063/217] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/post=5Fqueue.py#Queue.fl?= =?UTF-8?q?ush"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1e8216ff382a2ff64871d4c4ac52ba5892608e94. --- plugins/ndf/scripts/lib/metrics.py | 2 +- plugins/ndf/scripts/lib/post_queue.py | 23 +++---- .../ndf/skills/cross-review/scripts/state.py | 61 ++++++++----------- 3 files changed, 34 insertions(+), 52 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index 1e1509c1..a7bb2d3e 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -275,7 +275,7 @@ def _emit_bullet_section( if not items: return lines += ["", f"## {title}", ""] - lines += [f"- {item}" for item in dict.fromkeys(items)] + lines += [f"- {w}" for w in dict.fromkeys(items)] def format_report(metrics: dict[str, Any]) -> str: diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index 40125dd5..ddbb9274 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -434,13 +434,6 @@ class FlushResult(NamedTuple): rate_limited: bool -class _FlushItemResult(NamedTuple): - """読み取り済み項目 1 件の処理結果。""" - - status: str - rate_limited: bool - - class Queue: """1 つの Pull Request 分の待ち行列。""" @@ -496,7 +489,7 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: def _flush_item( self, path: pathlib.Path, item: dict[str, Any] - ) -> _FlushItemResult: + ) -> tuple[str, bool]: """読み取り済み項目を 1 件処理する(既投稿・送信成功・送信失敗)。 戻り値は `(状態, rate_limited)`。状態は 'skipped', 'sent', 'failed'。 @@ -508,7 +501,7 @@ def _flush_item( if row is not None: item["response"] = row path.unlink(missing_ok=True) - return _FlushItemResult("skipped", False) + return "skipped", False attempt = send(item) if attempt.ok: try: @@ -516,12 +509,12 @@ def _flush_item( except json.JSONDecodeError: item["response"] = None path.unlink(missing_ok=True) - return _FlushItemResult("sent", False) + return "sent", False item["attempts"] = int(item.get("attempts") or 0) + 1 item["last_error"] = attempt.summary() path.write_text(json.dumps(item, indent=2, ensure_ascii=False), encoding="utf-8") - return _FlushItemResult("failed", is_rate_limited(attempt)) + return "failed", is_rate_limited(attempt) def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -545,14 +538,14 @@ def flush(self) -> FlushResult: "last_error": f"待ち行列の項目を読めない ({path.name})", } break - result = self._flush_item(path, item) - if result.status == "skipped": + status, item_rate_limited = self._flush_item(path, item) + if status == "skipped": skipped.append(item) - elif result.status == "sent": + elif status == "sent": sent.append(item) else: failed = item - rate_limited = result.rate_limited + rate_limited = item_rate_limited break return FlushResult(sent, skipped, failed, self.count(), rate_limited) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 6310447b..76a0853f 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -756,30 +756,11 @@ def _clean_untracked_files(worktree: str, exclusions: list[str], code: int) -> N die(f"追跡対象外のファイルを消せない: {clean.stderr.strip()}", code=code) -class _SyncTarget(NamedTuple): - """同期先の取得結果。""" - - have_base: bool - ref: str - label: str - - -class _SyncContext(NamedTuple): - """同期済み判定に必要な入力。""" - - worktree: str - pr: int - head: str | HeadRef - exclusions: list[str] - strict: bool - error_code: int - - def _resolve_sync_target( worktree: str, pr: int, head: str | HeadRef, -) -> _SyncTarget: +) -> tuple[bool, str, str]: """HeadRef と文字列 head から have_base・target・label を解決する。""" if isinstance(head, HeadRef): have_base = _fetch_head(worktree, pr, head) @@ -794,33 +775,40 @@ def _resolve_sync_target( have_base = fetch.returncode == 0 target = f"origin/{head}" label = head - return _SyncTarget(have_base, target, label) + return have_base, target, label def _can_skip_sync( - context: _SyncContext, - target: _SyncTarget, + worktree: str, + pr: int, + head: str | HeadRef, + have_base: bool, + target: str, + label: str, + exclusions: list[str], + *, + strict: bool, + code: int, ) -> bool: """strict 時の同期済み早期終了と基準取得失敗を判定する。 同期済みで作業が不要な場合は `True` を返す。 """ - if target.have_base: - if context.strict and isinstance(context.head, HeadRef) and _is_synced( - context.worktree, context.pr, context.head, - context.exclusions, context.error_code): + if have_base: + if strict and isinstance(head, HeadRef) and _is_synced( + worktree, pr, head, exclusions, code): return True - elif context.strict: + elif strict: # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 die( - f"PR #{context.pr} の基準のコミット {target.ref[:7]} を取り込めない。" + f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" " ネットワークか権限を確認してください", - code=context.error_code, + code=code, ) else: # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 - info(f"⚠ git fetch origin {target.label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") + info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") return False @@ -857,12 +845,13 @@ def _sync_worktree( """ code = 8 if strict else 1 exclusions = _sync_exclusions(worktree) - target = _resolve_sync_target(worktree, pr, head) - context = _SyncContext(worktree, pr, head, exclusions, strict, code) - if _can_skip_sync(context, target): + have_base, target, label = _resolve_sync_target(worktree, pr, head) + if _can_skip_sync( + worktree, pr, head, have_base, target, label, exclusions, + strict=strict, code=code, + ): return - _reset_worktree_head( - worktree, pr, target.ref if target.have_base else None, code) + _reset_worktree_head(worktree, pr, target if have_base else None, code) _clean_untracked_files(worktree, exclusions, code) rev = subprocess.run( ["git", "rev-parse", "--short", "HEAD"], From dfff09c99426a1a4a9aad5ef7f3c0fae4da1bf7c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:11:43 +0000 Subject: [PATCH 064/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index 8c4267a0..fd2a8163 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -264,7 +264,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 未着手 | 0 | +| duplication | consolidate_duplication | minor | kiro | 取り消し | 0 | **なぜ**: payload が dict でない場合と comments が list でない場合の 2 経路が、`info(f"⚠ {agent}: ...形式不正で、判定は中断します")` を出して `return None` する同じ形で並ぶ。返す条件(dict でない / list でない)と型名の埋め込みが繰り返され、警告文の末尾の定型句も重複する。片方の文言だけ直すと 2 経路のメッセージが食い違う。test_review_findings.py が不正 payload での 0 件記録を固定している。 @@ -315,3 +315,4 @@ | 5 | `plugins/ndf/scripts/lib/post_queue.py#Queue.flush` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 5 | `plugins/ndf/scripts/lib/metrics.py#format_report` | duplication | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_load_payload` | duplication | どの改善項目にも割り当てられていないコミットが 1 件(1e8216f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | From c1bf5e7bb96a6846fc3d61aed5b8d26e7eea116c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:21:37 +0000 Subject: [PATCH 065/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#bu?= =?UTF-8?q?ild=5Fparser?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit build_parser に連続していた init のオプション定義と 13 個の副コマンド登録を、 副コマンドごとの登録関数 `_add__parser(sub)` へ抽出する。build_parser は トップレベル parser と subparsers を作り、`_SUBCOMMAND_REGISTRARS` を元の順で 呼んで返すだけにする。`--help` の一覧は登録順で出るため、順序は変えていない (トップと 14 副コマンドの `--help` 出力が変更前後で一致することを確認)。 Item-Id: R5-005 Round: 5 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Fable 5.1 --- .../ndf/skills/cross-review/scripts/state.py | 74 ++++++++++++++++--- 1 file changed, 63 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 76a0853f..401c025f 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -4494,19 +4494,10 @@ def cmd_report(args: argparse.Namespace) -> None: # ---------------- main ---------------- -def build_parser() -> argparse.ArgumentParser: - """副コマンドの引数を組み立てる。**テストが選択肢を検査できるように分ける。** +_Subparsers = argparse._SubParsersAction - 実機で `kiro` の結果が `invalid choice` で弾かれた。担当が 4 つの名前を取りうる - 以上、副コマンドの引数も同じ母集合を持たなければ、結果を残した担当が「結果なし」 - として扱われる。 - """ - # 副コマンドの説明はここ(`help`)だけが持つ。**モジュールの docstring へ写さない。** - # 2 か所へ書くと片方だけが実装から離れる。振動の検知の基準は実装が 3 つの一致へ - # 変わった後も、docstring 側が古い基準を出し続けていた(#329)。 - p = argparse.ArgumentParser(description=__doc__) - sub = p.add_subparsers(dest="cmd", required=True) +def _add_init_parser(sub: _Subparsers) -> None: sp = sub.add_parser("init", help="Step 0 — state 初期化 or 再開") sp.add_argument("pr", type=int) sp.add_argument("--max-rounds", type=int, default=12) @@ -4537,6 +4528,8 @@ def build_parser() -> argparse.ArgumentParser: ) sp.set_defaults(func=cmd_init) + +def _add_start_round_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "start-round", help="Step 1 — round 開始判定 (1=上限到達/5=後始末の未了/8=同期できない)", @@ -4544,12 +4537,16 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_start_round) + +def _add_read_result_parser(sub: _Subparsers) -> None: sp = sub.add_parser("read-result", help="Step 2.4 — review result を state にマージ") sp.add_argument("pr", type=int) sp.add_argument("agent", choices=list(assignment.ALL_RUNTIMES)) sp.add_argument("--file", default=None) sp.set_defaults(func=cmd_read_result) + +def _add_unresolved_threads_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "unresolved-threads", help="PR 上の未解決の指摘を数える (0=数えられた/1=取得できなかった)", @@ -4557,10 +4554,14 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_unresolved_threads) + +def _add_flush_parser(sub: _Subparsers) -> None: sp = sub.add_parser("flush", help="待ち行列に積んだ投稿を流す (常に 0)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_flush) + +def _add_judge_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "judge", help="Step 3 — intent ベース pass 判定 " @@ -4569,6 +4570,8 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_judge) + +def _add_check_oscillation_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "check-oscillation", help="Step 4 — 同じ箇所を指す指摘の割合を計算 (2=続行/4=振動で中断)", @@ -4576,27 +4579,37 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_check_oscillation) + +def _add_verify_findings_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "verify-findings", help="Step 2.5 前段 — 重複の統合(1 段目)と実行検証(#156)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_verify_findings) + +def _add_collect_critiques_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "collect-critiques", help="Step 2.5 後段 — 反証の結果を指摘へ結び、申告の重複を束ねる(#156)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_collect_critiques) + +def _add_merge_fix_parser(sub: _Subparsers) -> None: sp = sub.add_parser("merge-fix", help="Step 5 post — fix 戻り値マージ + CI 分類") sp.add_argument("pr", type=int) sp.add_argument("--file", default=None) sp.set_defaults(func=cmd_merge_fix) + +def _add_should_rotate_parser(sub: _Subparsers) -> None: sp = sub.add_parser("should-rotate", help="Step 6 — rotate 要否 (0=rotate/2=keep)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_should_rotate) + +def _add_set_current_pr_parser(sub: _Subparsers) -> None: sp = sub.add_parser("set-current-pr", help="rotation 後の current_pr 更新") sp.add_argument( "--head-branch", @@ -4607,6 +4620,8 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("new_pr", type=int) sp.set_defaults(func=cmd_set_current_pr) + +def _add_verify_sweep_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "verify-sweep", help="Step 7.5 後段 — 最終スイープ後の未解決の指摘を検証 (0=残なし/6=残あり)", @@ -4615,9 +4630,46 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--file", default=None) sp.set_defaults(func=cmd_verify_sweep) + +def _add_report_parser(sub: _Subparsers) -> None: sp = sub.add_parser("report", help="Step 8 — deferred nit + サマリ表示") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_report) + + +# 副コマンドの登録関数。**`--help` の一覧はこの順で出る**ため、並びを変えない。 +_SUBCOMMAND_REGISTRARS = ( + _add_init_parser, + _add_start_round_parser, + _add_read_result_parser, + _add_unresolved_threads_parser, + _add_flush_parser, + _add_judge_parser, + _add_check_oscillation_parser, + _add_verify_findings_parser, + _add_collect_critiques_parser, + _add_merge_fix_parser, + _add_should_rotate_parser, + _add_set_current_pr_parser, + _add_verify_sweep_parser, + _add_report_parser, +) + + +def build_parser() -> argparse.ArgumentParser: + """副コマンドの引数を組み立てる。**テストが選択肢を検査できるように分ける。** + + 実機で `kiro` の結果が `invalid choice` で弾かれた。担当が 4 つの名前を取りうる + 以上、副コマンドの引数も同じ母集合を持たなければ、結果を残した担当が「結果なし」 + として扱われる。 + """ + # 副コマンドの説明は各登録関数の `help` だけが持つ。**モジュールの docstring へ写さない。** + # 2 か所へ書くと片方だけが実装から離れる。振動の検知の基準は実装が 3 つの一致へ + # 変わった後も、docstring 側が古い基準を出し続けていた(#329)。 + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + for register in _SUBCOMMAND_REGISTRARS: + register(sub) return p From bfe56afc31046a13348683ecfcb3e5bec2303f02 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:22:34 +0000 Subject: [PATCH 066/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index fd2a8163..556e68dd 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -291,7 +291,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | codex | 未着手 | 0 | +| long_method | extract_method | minor | codex | 検証中 | 1 | **なぜ**: init の多数のオプション定義と、ラウンド進行・結果取込・検証・報告に属する 11 個の副コマンド登録が 1 関数に連続しており、個別コマンドの引数変更でも 125 行の構築処理全体を読む必要がある。副コマンドごとに独立した名前を付けられる段階になっている。 From 9f75389e62173b95fbce5ed062f2df6d0dfbd874 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:33:49 +0000 Subject: [PATCH 067/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20=E8=AA=8D=E8=A8=BC=E7=A2=BA=E8=AA=8D=E3=81=A8?= =?UTF-8?q?=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E5=8F=8D=E6=98=A0=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI ごとの認証プローブ処理と、待ち行列項目の解釈・書き戻し先探索を補助関数へ抽出する。 Item-Id: R6-001 Round: 6 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/lib/auth.py | 37 +++++++++------ .../ndf/skills/cross-review/scripts/state.py | 45 +++++++++++++------ 2 files changed, 54 insertions(+), 28 deletions(-) diff --git a/plugins/ndf/scripts/lib/auth.py b/plugins/ndf/scripts/lib/auth.py index 1fc7d63b..d68c0d93 100644 --- a/plugins/ndf/scripts/lib/auth.py +++ b/plugins/ndf/scripts/lib/auth.py @@ -33,6 +33,26 @@ SKIP_ENV = "NDF_SKIP_AUTH_CHECK" +def _probe_auth(runtime: str, probe: tuple[str, ...]) -> tuple[bool, str, str]: + """1 つの CLI を確認し、集約に必要な結果へ変換する。""" + command = " ".join(probe) + try: + result = subprocess.run( + list(probe), capture_output=True, text=True, + timeout=AUTH_PROBE_TIMEOUT, + ) + merged = f"{result.stdout}\n{result.stderr}".lower() + ok = result.returncode == 0 and not any( + marker in merged for marker in UNAUTHENTICATED_MARKERS + ) + detail = (result.stderr.strip() or result.stdout.strip())[:200] + except FileNotFoundError: + ok, detail = False, "コマンドが見つかりません" + except subprocess.TimeoutExpired: + ok, detail = False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + return ok, detail, command + + def check_auth( runtimes: Iterable[str], *, @@ -59,20 +79,9 @@ def check_auth( probe = AUTH_PROBES.get(runtime) if probe is None: continue - try: - r = subprocess.run(list(probe), capture_output=True, text=True, - timeout=AUTH_PROBE_TIMEOUT) - merged = f"{r.stdout}\n{r.stderr}".lower() - ok = r.returncode == 0 and not any( - m in merged for m in UNAUTHENTICATED_MARKERS - ) - detail = (r.stderr.strip() or r.stdout.strip())[:200] - except FileNotFoundError: - ok, detail = False, "コマンドが見つかりません" - except subprocess.TimeoutExpired: - ok, detail = False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" - results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} - info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") + ok, detail, command = _probe_auth(runtime, probe) + results[runtime] = {"command": command, "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {runtime}: {command}") if not ok: failed.append(f"{runtime}({detail})") diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 401c025f..015448c6 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1436,6 +1436,33 @@ def _pending_posts(pr: int) -> int: return _queue(pr).count() +def _flushed_review(item: dict[str, Any]) -> tuple[Any, Any, str] | None: + """待ち行列の項目から、確認するレビューの担当・ラウンド・URL を得る。""" + if item.get("kind") != "review-post": + return None + extra = item.get("extra") or {} + agent, round_no = extra.get("agent"), extra.get("round") + if not (agent and round_no): + return None + response = item.get("response") + response = response if isinstance(response, dict) else {} + url = str(response.get("html_url") or "") + if not url and response.get("id"): + url = f"#pullrequestreview-{response['id']}" + return agent, round_no, url + + +def _flushed_review_target( + state: dict[str, Any], agent: Any, round_no: Any) -> dict[str, Any] | None: + """レビューを積んだラウンドから、担当の書き戻し先を探す。""" + return next( + (entry for entry in state.get("rounds", []) + if entry.get("round") == round_no + and isinstance(entry.get(agent), dict)), + None, + ) + + def _confirm_flushed(pr: int, item: dict[str, Any]) -> None: """流した直後に、投稿が届いたことを 1 度だけ確かめる。 @@ -1449,24 +1476,14 @@ def _confirm_flushed(pr: int, item: dict[str, Any]) -> None: 確認を促す側にも働かない**(判定は `queued` を見て照会を飛ばす)。共通層が 見つけた投稿を `response` として渡すため、どちらも同じ経路で確かめられる。 """ - if item.get("kind") != "review-post": - return - extra = item.get("extra") or {} - agent, round_no = extra.get("agent"), extra.get("round") - if not (agent and round_no): + review = _flushed_review(item) + if review is None: return - resp = item.get("response") if isinstance(item.get("response"), dict) else {} - url = str(resp.get("html_url") or "") - if not url and resp.get("id"): - url = f"#pullrequestreview-{resp['id']}" + agent, round_no, url = review st = _load(pr) # **書き戻す先は、その項目が属するラウンドである。** 積んだラウンドと流した # ラウンドが同じとは限らないため、最後のラウンドへ書かない。 - target = next( - (e for e in st.get("rounds", []) - if e.get("round") == round_no and isinstance(e.get(agent), dict)), - None, - ) + target = _flushed_review_target(st, agent, round_no) if target is None: return exists = _review_exists(str(st.get("repo") or ""), From 4c7daf44cf2d37603e33d70d813e2607387697ef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 14:34:34 +0000 Subject: [PATCH 068/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf791.md | 54 +++++++++++++++++++++++++++++++- 1 file changed, 53 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index 556e68dd..e7bf8871 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -291,7 +291,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | codex | 検証中 | 1 | +| long_method | extract_method | minor | codex | 採用 | 1 | **なぜ**: init の多数のオプション定義と、ラウンド進行・結果取込・検証・報告に属する 11 個の副コマンド登録が 1 関数に連続しており、個別コマンドの引数変更でも 125 行の構築処理全体を読む必要がある。副コマンドごとに独立した名前を付けられる段階になっている。 @@ -300,6 +300,58 @@ 3. build_parser はトップレベル parser と subparsers を作り、登録関数を順に呼んで返すだけにする 4. test_state_subcommand_help.py、test_state_review_pool.py、test_findings_pipeline_wiring.py で選択肢・help・func の対応が不変であることを確認する +## ラウンド 6(実装 agy / レビュー codex / kiro) + +### R6-001 — `plugins/ndf/scripts/lib/auth.py#check_auth` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 検証中 | 1 | + +**なぜ**: スキップ判定、CLI ごとの subprocess 実行、例外の認証結果への変換、結果の集約、全体の失敗通知が 1 関数に同居しており、個別 CLI のプローブ規則と複数 CLI の制御を別々に読めない。既存の test_auth_probe.py が未知 runtime、成功、未認証マーカー、コマンド不在を公開入口から固定している。 + +**手順**: 1. 1 runtime の probe 実行と FileNotFoundError・TimeoutExpired の認証結果への変換を、runtime と probe を受け取る補助関数へ抽出する +2. ok・detail・command の結果を check_auth が受け取り、既存どおり info 出力、failed 集約、die 判定を行う形へ置き換える +3. test_auth_probe.py を実行し、戻り値、通知文、die 呼び出しが不変であることを確認する + +### R6-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_confirm_flushed` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 検証中 | 1 | + +**なぜ**: 待ち行列項目の適用可否判定、response からの URL 復元、対象ラウンド探索、GitHub 到達確認、結果なしまたは成功状態への更新、永続化が 1 関数に直列で同居している。投稿確認は収束可否に関わるため、対象特定と状態遷移を独立した名前で読める構造にする価値が高く、test_state_queue_judge.py が送信済み・冪等スキップ・未到達の経路を固定している。 + +**手順**: 1. item の kind・extra・response を解釈して agent、round、review URL を返す処理を補助関数へ抽出する +2. state の rounds から書き戻し対象を探す処理を補助関数へ抽出する +3. _confirm_flushed は早期 return、到達確認、既存と同じ target 更新、_save の順序だけを担うよう置き換える +4. test_state_queue_judge.py を実行し、queued、review_url、not_posted、保存回数を含む観測結果が不変であることを確認する + +### R6-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_guard_previous_round` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 未着手 | 0 | + +**なぜ**: 旧形式 state の verdict 復元、修正記録の必須判定、申告済み Resolve の GitHub 照会、未解決 ID の検出という独立した 2 つのガードが 1 関数に同居している。各ガードは異なる理由で変更され、test_state_round_guard.py が修正記録なし、照会不能、未解決残存、正常通過を固定している。 + +**手順**: 1. 保存済み verdict が無い場合の no_result・pass からの復元を補助関数へ抽出する +2. fix の resolved_thread_ids と対象 PR を受け、照会不能時の通知または未解決 ID を判定する補助関数へ抽出する +3. _guard_previous_round は修正記録ガードと Resolve 状態ガードを順に呼ぶ構成へ置き換える +4. test_state_round_guard.py を実行し、終了コード、GitHub 照会先、警告、正常通過が不変であることを確認する + +### R6-004 — `plugins/ndf/skills/cross-review/scripts/state.py#_is_generated_path` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| scattered_config | centralize_configuration | minor | kiro | 未着手 | 0 | + +**なぜ**: 兄弟の判定述語(_is_dependency_path は DEPENDENCY_FILENAMES、_is_infra_path は INFRA_FILENAMES、_is_generated_path 自身も GENERATED_MARKERS)はいずれもモジュール定数を引くのに、_is_generated_path だけ lockfile 名の集合(package-lock.json / go.sum / cargo.lock など 9 件)を関数本体へじか書きしている。うち package-lock.json 等は DEPENDENCY_FILENAMES にも重複して載っており、lockfile を足すとき 2 か所を直す必要があるうえ、どこに定義があるか読み手が探す。定義を 1 か所へ寄せて兄弟と同じ形にする。 + +**手順**: 1. モジュール定数群(DEPENDENCY_FILENAMES / GENERATED_MARKERS / INFRA_FILENAMES の並び)へ GENERATED_LOCKFILES を新設し、現在インラインにある 9 件の集合をそのまま移す +2. _is_generated_path の本体を `name in GENERATED_LOCKFILES` へ置き換え、インラインの集合リテラルを消す +3. test_gap を埋めるため、先に go.sum / cargo.lock(DEPENDENCY_FILENAMES に無く、インライン集合にだけある名前)で generated カテゴリが立つ現状固定テストを test_state_auto_review_templates.py に追加し、移動の前後で同じ結果になることを確認する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | From d03916cfca22b3b1e8aeab554bd2fa75b2558f4c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 19:41:36 +0000 Subject: [PATCH 069/217] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/post=5Fqueue.py#Queue.fl?= =?UTF-8?q?ush"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bb191deb3276cc2751c645f3609af49aaeaa90fc. ラウンド 5 の適用ラウンド 1(R5-001 / R5-002 / R5-004)の担当が積んだコミット。同じ群に 割り当てのないコミットが混ざったため群ごと取り消したが、取り消しの範囲がそのコミットに 届いておらず、検証を受けていない変更が残っていた。項目単位の取り消しとして戻す。 Co-Authored-By: Claude Fable 5.1 --- plugins/ndf/scripts/lib/metrics.py | 21 ++--- plugins/ndf/scripts/lib/post_queue.py | 59 +++++------- .../ndf/skills/cross-review/scripts/state.py | 91 ++++++------------- 3 files changed, 59 insertions(+), 112 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index a7bb2d3e..cdafe8ea 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -266,18 +266,6 @@ def _emit_table( lines += [*headers, *rows] -def _emit_bullet_section( - lines: list[str], - title: str, - items: list[str], -) -> None: - """箇条書きの節を出力する。項目がなければ出力しない。""" - if not items: - return - lines += ["", f"## {title}", ""] - lines += [f"- {w}" for w in dict.fromkeys(items)] - - def format_report(metrics: dict[str, Any]) -> str: """人が読む形へ整形する。比較の限界を必ず添える。""" lines: list[str] = [] @@ -319,8 +307,13 @@ def format_report(metrics: dict[str, Any]) -> str: reviewer_rows, ) - _emit_bullet_section(lines, "集計から分離したラウンド", metrics["unmeasured"]) - _emit_bullet_section(lines, "指定値で代用したラウンド", metrics.get("assumed") or []) + if metrics["unmeasured"]: + lines += ["", "## 集計から分離したラウンド", ""] + lines += [f"- {w}" for w in dict.fromkeys(metrics["unmeasured"])] + + if metrics.get("assumed"): + lines += ["", "## 指定値で代用したラウンド", ""] + lines += [f"- {w}" for w in dict.fromkeys(metrics["assumed"])] lines += ["", "## 比較として読むときの限界", ""] lines += [f"- {c}" for c in COMPARISON_CAVEATS] diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index ddbb9274..e79511ab 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -487,35 +487,6 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: json.dump(item, f, indent=2, ensure_ascii=False) return path - def _flush_item( - self, path: pathlib.Path, item: dict[str, Any] - ) -> tuple[str, bool]: - """読み取り済み項目を 1 件処理する(既投稿・送信成功・送信失敗)。 - - 戻り値は `(状態, rate_limited)`。状態は 'skipped', 'sent', 'failed'。 - """ - found, row = posted_match(item) - if found is True: - # **送った場合と同じ形で返す。** 呼び出し側は届いたことを応答から - # 確かめるため、既に届いていた項目にも見つけた投稿を積んで渡す。 - if row is not None: - item["response"] = row - path.unlink(missing_ok=True) - return "skipped", False - attempt = send(item) - if attempt.ok: - try: - item["response"] = json.loads(attempt.stdout or "null") - except json.JSONDecodeError: - item["response"] = None - path.unlink(missing_ok=True) - return "sent", False - item["attempts"] = int(item.get("attempts") or 0) + 1 - item["last_error"] = attempt.summary() - path.write_text(json.dumps(item, indent=2, ensure_ascii=False), - encoding="utf-8") - return "failed", is_rate_limited(attempt) - def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -538,15 +509,31 @@ def flush(self) -> FlushResult: "last_error": f"待ち行列の項目を読めない ({path.name})", } break - status, item_rate_limited = self._flush_item(path, item) - if status == "skipped": + found, row = posted_match(item) + if found is True: + # **送った場合と同じ形で返す。** 呼び出し側は届いたことを応答から + # 確かめるため、既に届いていた項目にも見つけた投稿を積んで渡す。 + if row is not None: + item["response"] = row + path.unlink(missing_ok=True) skipped.append(item) - elif status == "sent": + continue + attempt = send(item) + if attempt.ok: + try: + item["response"] = json.loads(attempt.stdout or "null") + except json.JSONDecodeError: + item["response"] = None + path.unlink(missing_ok=True) sent.append(item) - else: - failed = item - rate_limited = item_rate_limited - break + continue + item["attempts"] = int(item.get("attempts") or 0) + 1 + item["last_error"] = attempt.summary() + path.write_text(json.dumps(item, indent=2, ensure_ascii=False), + encoding="utf-8") + failed = item + rate_limited = is_rate_limited(attempt) + break return FlushResult(sent, skipped, failed, self.count(), rate_limited) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 015448c6..9f58ad0a 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -756,62 +756,6 @@ def _clean_untracked_files(worktree: str, exclusions: list[str], code: int) -> N die(f"追跡対象外のファイルを消せない: {clean.stderr.strip()}", code=code) -def _resolve_sync_target( - worktree: str, - pr: int, - head: str | HeadRef, -) -> tuple[bool, str, str]: - """HeadRef と文字列 head から have_base・target・label を解決する。""" - if isinstance(head, HeadRef): - have_base = _fetch_head(worktree, pr, head) - target = head.oid - label = head.branch - else: - # 旧来の呼び出し(ブランチ名だけを渡す経路)。基準は `origin/` になる。 - fetch = subprocess.run( - ["git", "fetch", "origin", head], - capture_output=True, text=True, - ) - have_base = fetch.returncode == 0 - target = f"origin/{head}" - label = head - return have_base, target, label - - -def _can_skip_sync( - worktree: str, - pr: int, - head: str | HeadRef, - have_base: bool, - target: str, - label: str, - exclusions: list[str], - *, - strict: bool, - code: int, -) -> bool: - """strict 時の同期済み早期終了と基準取得失敗を判定する。 - - 同期済みで作業が不要な場合は `True` を返す。 - """ - if have_base: - if strict and isinstance(head, HeadRef) and _is_synced( - worktree, pr, head, exclusions, code): - return True - elif strict: - # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 - # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 - die( - f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" - " ネットワークか権限を確認してください", - code=code, - ) - else: - # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 - info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") - return False - - def _sync_worktree( worktree: str, pr: int, @@ -845,12 +789,35 @@ def _sync_worktree( """ code = 8 if strict else 1 exclusions = _sync_exclusions(worktree) - have_base, target, label = _resolve_sync_target(worktree, pr, head) - if _can_skip_sync( - worktree, pr, head, have_base, target, label, exclusions, - strict=strict, code=code, - ): - return + if isinstance(head, HeadRef): + have_base = _fetch_head(worktree, pr, head) + target = head.oid + label = head.branch + else: + # 旧来の呼び出し(ブランチ名だけを渡す経路)。基準は `origin/` になる。 + fetch = subprocess.run( + ["git", "fetch", "origin", head], + capture_output=True, text=True, + ) + have_base = fetch.returncode == 0 + target = f"origin/{head}" + label = head + + if have_base: + if strict and isinstance(head, HeadRef) and _is_synced( + worktree, pr, head, exclusions, code): + return + elif strict: + # HEAD を動かす前に、何が失われるかを数える材料が無い(基準が手元に無いのだから、 + # 未 push のコミットを数えられない)。判定できない状態でフォールバックしない。 + die( + f"PR #{pr} の基準のコミット {target[:7]} を取り込めない。" + " ネットワークか権限を確認してください", + code=code, + ) + else: + # フォーク PR は origin に head branch が無い。作成時と同じ経路で合わせる。 + info(f"⚠ git fetch origin {label} 失敗 (フォーク PR の可能性) — gh pr checkout でフォールバック") _reset_worktree_head(worktree, pr, target if have_base else None, code) _clean_untracked_files(worktree, exclusions, code) rev = subprocess.run( From 6acd1b8b1053df47ee57222a932c1f1e4e6b8dab Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 19:47:50 +0000 Subject: [PATCH 070/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 再起動で消えた状態ファイルの代わりに、head の全体テストとコミットの突き合わせで確定した 状態を写す。R6-001 / R6-002 は検証を通ったので採用、R5-001 / R5-002 / R5-004 は 残っていた 1 コミットを戻したので取り消し 1 コミット。 Co-Authored-By: Claude Fable 5.1 --- issues/refactoring-plan-rf791.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md index e7bf8871..a5a60c35 100644 --- a/issues/refactoring-plan-rf791.md +++ b/issues/refactoring-plan-rf791.md @@ -238,7 +238,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 0 | +| long_method | extract_method | major | codex | 取り消し | 1 | **なぜ**: 1 件の処理の中に、壊れた JSON の停止判定、既投稿の照合と削除、送信成功時の応答保存と削除、送信失敗時の再試行情報保存と rate limit 判定が直列に並び、flush 自体が順序制御と各項目の状態遷移の両方を担っている。 @@ -251,7 +251,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 0 | +| long_method | extract_method | major | codex | 取り消し | 1 | **なぜ**: PR head の取得方法の決定、strict 時の同期済み判定と失敗処理、worktree の reset・未追跡ファイル掃除、同期結果の表示という独立した段階が 1 関数に同居している。HeadRef と旧来の文字列 head の分岐も取得段階に閉じず、後続の制御へ have_base・target・label の組で持ち越されている。 @@ -277,7 +277,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 取り消し | 0 | +| duplication | consolidate_duplication | minor | kiro | 取り消し | 1 | **なぜ**: unmeasured と assumed の 2 節が同じ形(見出し + 空行を lines へ足し、dict.fromkeys で重複を除いた項目を `- {w}` で並べる)で並んでいる。片方だけ書式を変えると 2 節の見た目が食い違う。同じ業務ルール(分離・代用の一覧の出し方)に由来し、変わるときは一緒に変わる。既存テスト test_models_and_metrics.py が format_report の出力を固定している。 @@ -306,7 +306,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 検証中 | 1 | +| long_method | extract_method | major | codex | 採用 | 1 | **なぜ**: スキップ判定、CLI ごとの subprocess 実行、例外の認証結果への変換、結果の集約、全体の失敗通知が 1 関数に同居しており、個別 CLI のプローブ規則と複数 CLI の制御を別々に読めない。既存の test_auth_probe.py が未知 runtime、成功、未認証マーカー、コマンド不在を公開入口から固定している。 @@ -318,7 +318,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 検証中 | 1 | +| long_method | extract_method | major | codex | 採用 | 1 | **なぜ**: 待ち行列項目の適用可否判定、response からの URL 復元、対象ラウンド探索、GitHub 到達確認、結果なしまたは成功状態への更新、永続化が 1 関数に直列で同居している。投稿確認は収束可否に関わるため、対象特定と状態遷移を独立した名前で読める構造にする価値が高く、test_state_queue_judge.py が送信済み・冪等スキップ・未到達の経路を固定している。 From 37dec905a79146bc866135240b6fbdd3a5aed88d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 19:56:01 +0000 Subject: [PATCH 071/217] =?UTF-8?q?Test:=20cross-review=20=E3=81=AE?= =?UTF-8?q?=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit measure の境界値、反証再取得ループ、PR prepare の公開入口を固定する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../cross-review/tests/test_critiques.py | 45 +++++++++++ .../skills/cross-review/tests/test_measure.py | 32 ++++++++ .../tests/test_rotate_pr_queue.py | 74 +++++++++++++++++++ 3 files changed, 151 insertions(+) diff --git a/plugins/ndf/skills/cross-review/tests/test_critiques.py b/plugins/ndf/skills/cross-review/tests/test_critiques.py index 56e0d006..6303258e 100644 --- a/plugins/ndf/skills/cross-review/tests/test_critiques.py +++ b/plugins/ndf/skills/cross-review/tests/test_critiques.py @@ -8,6 +8,7 @@ import argparse import json import pathlib +import shutil import pytest @@ -573,6 +574,50 @@ def test_a_stale_pidfile_is_not_read_as_a_launch(tmp_dir, tmp_path): assert elapsed < ROUND_TIME_LIMIT, f"{elapsed:.1f} 秒かかった(監視が待っている)" +def test_a_retry_launches_only_the_agents_requested_by_collect(tmp_path): + """現状固定: 終了コード 7 の再取得では不足した担当だけを起動し直す。""" + script_dir = tmp_path / "scripts" + script_dir.mkdir() + shutil.copy2(SCRIPTS / "critique-round.sh", script_dir / "critique-round.sh") + calls = tmp_path / "critique-calls.txt" + collects = tmp_path / "collect-count.txt" + + (script_dir / "_tmpdir.sh").write_text( + 'tmpdir() { printf "%s\\n" "$CROSS_REVIEW_TMP_DIR"; }\n', encoding="utf-8") + (script_dir / "critique.sh").write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$1" >> "$CRITIQUE_CALLS"\n' + 'touch "$CROSS_REVIEW_TMP_DIR/$1-critique-pr$2.pid"\n', encoding="utf-8") + (script_dir / "monitor.py").write_text( + "#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + (script_dir / "state.py").write_text( + "#!/usr/bin/env bash\n" + 'count=0; [ ! -f "$COLLECT_COUNT" ] || count=$(cat "$COLLECT_COUNT")\n' + 'count=$((count + 1)); printf "%s\\n" "$count" > "$COLLECT_COUNT"\n' + 'if [ "$count" -eq 1 ]; then\n' + " printf \"CRITIQUE_RETRY_AGENTS='kiro'\\n\"\n" + " exit 7\n" + "fi\n" + "exit 0\n", encoding="utf-8") + for name in ("critique.sh", "monitor.py", "state.py"): + (script_dir / name).chmod(0o755) + + env = dict( + os.environ, + CROSS_REVIEW_TMP_DIR=str(tmp_path), + CRITIQUE_CALLS=str(calls), + COLLECT_COUNT=str(collects), + ) + result = subprocess.run( + ["bash", str(script_dir / "critique-round.sh"), str(PR), "1", "agy", "kiro"], + capture_output=True, text=True, env=env, check=False, + ) + + assert result.returncode == 0, result.stderr + assert calls.read_text(encoding="utf-8").splitlines() == ["agy", "kiro", "kiro"] + assert collects.read_text(encoding="utf-8").strip() == "2" + + def test_the_stale_pidfile_is_removed_even_without_targets(tmp_dir, tmp_path): """捨てるのは、対象が無くて起動しない経路より前である。""" work = tmp_path / "work" diff --git a/plugins/ndf/skills/cross-review/tests/test_measure.py b/plugins/ndf/skills/cross-review/tests/test_measure.py index 28967b5b..b20aed8d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_measure.py +++ b/plugins/ndf/skills/cross-review/tests/test_measure.py @@ -142,6 +142,17 @@ def test_empty_state_does_not_crash(measure_mod): assert "methods" in result +@pytest.mark.parametrize("state", [None, [], "not-a-state"]) +def test_non_mapping_state_falls_back_to_an_empty_state(measure_mod, state): + """現状固定: 辞書以外の入力も空の状態として指標の全キーを返す。""" + result = measure_mod.measure(state) + + assert set(result) == {"pr", "prs", "rounds", "methods", "cost", "convergence"} + assert result["pr"] is None + assert result["prs"] == [] + assert result["rounds"] == 0 + + def test_wall_clock_is_null_while_the_run_has_not_ended(measure_mod): """終わっていない実行では実時間を出さない。**0 で埋めない。**""" st = _state(rounds=[_round(1)]) @@ -573,6 +584,27 @@ def test_proposed_reports_all_rounds_when_every_round_is_marked(measure_mod): "oracle_scope": "all_rounds", "oracle_base": 2} +def test_proposed_normalizes_duplicate_and_invalid_evidence_rounds(measure_mod): + """現状固定: 有効な番号は型をそろえて一つの印にし、不正値は無視する。""" + st = _state( + evidence_rounds=["1", 1, "invalid", None], + rounds=[ + _round(1, fix=_fix(_position("T1", "a.py", 10))), + _round(2, fix=_fix(_position("T2", "b.py", 20))), + ], + review_findings=[ + _finding("codex-r1-0", 1, "a.py", 10, + classification="verified_blocking"), + _finding("codex-r2-0", 2, "b.py", 20, + classification="verified_blocking"), + ], + ) + + assert measure_mod.measure(st)["methods"]["proposed"] == { + "found": 1, "matched": 1, "of_oracle": 1.0, + "oracle_scope": "evidence_rounds", "oracle_base": 1} + + def test_proposed_takes_only_the_three_counted_classifications(measure_mod): """採るのは `verified_blocking` / `needs_human_judgment` / `unrefuted` の 3 つである(#732)。 diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py index 238c3742..3430718c 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py @@ -297,3 +297,77 @@ def test_an_unknown_flag_is_rejected() -> None: assert out.returncode == 2 assert "unknown arg: --unknown-flag" in out.stderr + + +def test_prepare_connects_state_pr_metadata_and_git_summary(tmp_path) -> None: + """現状固定: 公開 CLI が prepare.json と eval 用の代入を組み立てる。""" + state_pr = 41 + current_pr = 43 + tmp_dir = tmp_path / "tmp" + worktree = tmp_path / "worktree" + bin_dir = tmp_path / "bin" + tmp_dir.mkdir() + worktree.mkdir() + bin_dir.mkdir() + (tmp_dir / f"cross-review-pr{state_pr}-state.json").write_text(json.dumps({ + "worktree_path": str(worktree), + "current_pr": current_pr, + "repo": "o/r", + "viewer_login": "tester", + "rounds": [ + {"round": 1, "pr": 40}, + {"round": 2, "pr": current_pr}, + {"round": 3, "pr": current_pr}, + ], + }), encoding="utf-8") + (bin_dir / "gh").write_text( + "#!/usr/bin/env bash\n" + "printf '%s\\n' '{\"number\":43,\"url\":\"https://github.com/o/r/pull/43\"," + "\"title\":\"Current title\",\"body\":\"Current body\"," + "\"headRefName\":\"feature/prepare\",\"baseRefName\":\"develop\"," + "\"isDraft\":true}'\n", encoding="utf-8") + (bin_dir / "git").write_text( + "#!/usr/bin/env bash\n" + "case \"$1\" in\n" + " fetch|rev-parse) exit 0 ;;\n" + " log) printf 'abc123 First commit\\ndef456 Second commit\\n' ;;\n" + " diff) printf ' a.py | 2 ++\\n 1 file changed, 2 insertions(+)\\n' ;;\n" + " *) exit 3 ;;\n" + "esac\n", encoding="utf-8") + for command in ("gh", "git"): + (bin_dir / command).chmod(0o755) + + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "CROSS_REVIEW_TMP_DIR": str(tmp_dir), + } + out = subprocess.run( + ["bash", str(ROTATE), "prepare", str(state_pr)], + capture_output=True, text=True, env=env, check=False, + ) + + assert out.returncode == 0, out.stderr + evaluated = subprocess.run( + ["bash", "-c", + 'eval "$1"; printf "%s\\n" "$PREPARE_JSON" "$OLD_PR" "$HEAD_BRANCH" ' + '"$BASE_BRANCH" "$IS_DRAFT"', "bash", out.stdout], + capture_output=True, text=True, check=True, + ).stdout.splitlines() + prepare_path = tmp_dir / f"rotate-pr{state_pr}-prepare.json" + assert evaluated == [ + str(prepare_path), str(current_pr), "feature/prepare", "develop", "true"] + assert json.loads(prepare_path.read_text(encoding="utf-8")) == { + "state_pr": state_pr, + "old_pr": current_pr, + "old_pr_url": "https://github.com/o/r/pull/43", + "worktree_path": str(worktree), + "head_branch": "feature/prepare", + "base_branch": "develop", + "is_draft": True, + "round_in_pr": 2, + "old_title": "Current title", + "old_body": "Current body", + "git_log": "abc123 First commit\ndef456 Second commit", + "git_diff_stat": " a.py | 2 ++\n 1 file changed, 2 insertions(+)", + } From e772af39cd852a95179eb47b849f0b3e3ab8055e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 19:56:44 +0000 Subject: [PATCH 072/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 84 ++++++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 issues/refactoring-plan-rf790.md diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md new file mode 100644 index 00000000..73f013c4 --- /dev/null +++ b/issues/refactoring-plan-rf790.md @@ -0,0 +1,84 @@ +# 改修計画 — devbasex/ai-plugins #790 + +`/ndf:cross-refactoring` が提案し、適用した改善項目の記録である。 +理由と手順は提案の時点でしか残らないため、公開の直前に書き出している。 + +- 対象範囲: plugins/ndf/skills/cross-review/scripts, plugins/ndf/skills/cross-review/tests +- 着手前のテスト: uv run --with pytest pytest scripts/tests plugins/ndf -q + +## ラウンド 1(実装 codex / レビュー agy / kiro) + +### R1-001 — `plugins/ndf/skills/cross-review/scripts/measure.py#measure` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| boundary | unit | — | codex / agy | 検証中 | 1 | + +**なぜ**: measure 関数に None や辞書以外の型が渡されたとき、空辞書にフォールバックして例外なく指標辞書(pr, prs, rounds, methods, cost, convergence)を返す境界値の振る舞いが固定されていない + +**手順**: 1. evidence_rounds に文字列の有効ラウンド番号、重複する整数、不正値を含み、印付き・印なし両方の findings を持つ state を作る +2. measure を実行する +3. 有効番号が一つの印として扱われ、不正値が無視され、proposed の found・oracle_scope・oracle_base が現在の値になることを比較する + +### R1-002 — `plugins/ndf/skills/cross-review/scripts/critique-round.sh#critique-round` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | integration | — | kiro | 検証中 | 1 | + +**なぜ**: run_round を通す既存テストは 2 本とも指摘 0 件で、collect-critiques が 1 回目に 0 を返して 1 回で抜ける経路しか固定していない。exit 7 と CRITIQUE_RETRY_AGENTS を受けて 2 回目の起動を回すループ本体(for _attempt in 1 2)はどのテストも通っていない。 + +**手順**: 1. critique-round.sh を temp ディレクトリへ複製し、隣に stub の critique.sh・monitor.py・state.py・_tmpdir.sh を置く(test_wait_review.py と同じ、兄弟スクリプトを差し替える方式) +2. stub の state.py collect-critiques を、呼び出し回数を記録したうえで 1 回目は stdout に CRITIQUE_RETRY_AGENTS='kiro' を出して終了コード 7、2 回目は終了コード 0 を返すようにする +3. stub の critique.sh は渡された担当名を追記で記録し、対応する pid ファイルを作る +4. critique-round.sh に PR・ROUND・agy kiro を渡して実行し、終了コード 0 を確かめる +5. critique.sh の記録が 2 回目は kiro だけへ絞られている(agy は再起動されない)ことと、collect-critiques が 2 回呼ばれたことを比較する + +### R1-003 — `plugins/ndf/skills/cross-review/scripts/critique-round.sh#critique-round` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | integration | — | kiro | 未着手 | 0 | + +**なぜ**: collect-critiques が 7 以外を返したときに exit "$COLLECT_RC" でその終了コードを素通しする分岐が固定されていない。既存テストは 0 で抜ける経路だけを見ており、失敗の終了コードがラウンドの外へ伝わるかを誰も確かめていない。 + +**手順**: 1. critique-round.sh を temp ディレクトリへ複製し、隣に stub の critique.sh・monitor.py・state.py・_tmpdir.sh を置く +2. stub の state.py collect-critiques を、呼び出し回数を記録して終了コード 5 で終わるようにする +3. critique-round.sh に PR・ROUND・担当を渡して実行する +4. 終了コードが 5(collect-critiques が返した値)と一致することを確かめる +5. collect-critiques が 1 回だけ呼ばれ、2 回目の起動へ進んでいないことを記録から確かめる + +### R1-004 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#cmd_prepare` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| normal | integration | — | codex | 検証中 | 1 | + +**なぜ**: 公開入口 prepare は state、GitHub の PR メタデータ、git log/diff をつないで prepare.json と eval 用の出力を作るが、既存テストは生成済み prepare.json を与えるだけで、この経路を実行していない + +**手順**: 1. 一時 worktree と state を作り、gh pr view・git fetch/log/diff を現状の出力を返す代替コマンドへ差し替える +2. rotate-pr.sh prepare を公開 CLI から実行する +3. 終了コード 0、stdout の shell 代入を評価して得る値、prepare.json の PR・branch・draft・round・git 要約を比較する + +### R1-005 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | integration | — | codex | 未着手 | 0 | + +**なぜ**: 新 PR 作成失敗時に旧 PR を reopen する経路は light モードだけ固定され、同じ ERR trap を使う squash モードでは未固定である + +**手順**: 1. squash の close までは成功し、gh pr create だけが失敗する代替 git・gh と一時 state を用意する +2. rotate-pr.sh execute --mode squash を公開 CLI から実行する +3. 非ゼロ終了、新 PR が存在しないこと、旧 PR の最終状態が open に戻ること、成功用の NEW_PR が出ないことを比較する + +## 見送った項目 + +| ラウンド | 対象 | 兆候・経路 | 理由 | +| --- | --- | --- | --- | +| 1 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` | normal | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` | branch | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` | normal | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_collect_critiques` | boundary | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_collect_critiques` | error | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_verify_findings` | error | 1 ラウンドの採用上限 5 件を超えた | From 47ff27466d3e6a522392b875a174c056f11e0f40 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 19:57:18 +0000 Subject: [PATCH 073/217] =?UTF-8?q?Docs:=20=E5=AE=9F=E8=A3=85=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88P6:=20=E5=85=B1?= =?UTF-8?q?=E9=80=9A=E5=B1=A4=E3=81=A8=20cross-review=E3=80=82#727=20#687?= =?UTF-8?q?=20#478=20#648=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- issues/issue-727-p6-participants-plan.md | 208 +++++++++++++++++++++++ 1 file changed, 208 insertions(+) create mode 100644 issues/issue-727-p6-participants-plan.md diff --git a/issues/issue-727-p6-participants-plan.md b/issues/issue-727-p6-participants-plan.md new file mode 100644 index 00000000..fe495e30 --- /dev/null +++ b/issues/issue-727-p6-participants-plan.md @@ -0,0 +1,208 @@ +# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(実装計画 P6: 共通層と cross-review / #727 #687 #478 #648) + +## 関連リンク + +- 親 issue #727、子 issue #687 #478 #648(#664 は 2 本目の Pull Request が扱う) +- 設計: [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md)(決定 20 件。用語の対応表はこの文書の識別子の引き先) +- 要求: [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md)(受け入れ条件 AC1〜AC50) +- 契約: [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md)(状態ファイル・引数・関数の形) +- 設計 Pull Request: #782(2026-09-19 マージ) + +## モード + +`standard`。収束ループの初期化の振る舞いを変え、複数モジュール(共通層と cross-review)にまたがる。 + +## 目的と非目的 + +達成したい状態: + +- 参加する CLI のどれか 1 者が導入・認証されていなくても、cross-review の初期化が使える者で始まり、使えない者と理由が状態ファイルに残る +- cross-review の各ラウンドに 2 席が確保される(使える者 → ホスト → 同じランタイムの 2 つ目) +- 中断した収束ループを引数を変えて再開したとき、渡した引数が反映されるか、反映しないことが知らされる +- 使える者の決定・席の埋め方・再開の反映の 3 つの規則が共通層に 1 か所ずつ入り、2 本目の Pull Request(cross-refactoring 側)がそのまま呼べる + +やらないこと(2 本目の Pull Request が行う): + +- cross-refactoring の初期化・担当・表示・引数・文書の変更 +- 従来の確認(`check_auth`)・適用専用の母集合(`impl_pool`)・従来の席と適用の割り当て(`review_assign` / `assign`)の削除 +- リポジトリの根の `CLAUDE.md` の書き換え +- 起動した後に分かる使えなさで担当を自動的に外す仕組み(設計の決定 18) + +## 前提 + +- 前提 1: 設計文書の決定 20 件は変えない。実装で決めると設計が残した 3 件(監視の比較の寄せ方・出力の文言・テストの置き場所)は、この計画の「実装で決めたこと」に書き、設計文書の「未確認のまま残ること」の表を同じ Pull Request で更新する +- 前提 2: 並行する束が同じ状態の部品(`state.py`)を触る。G3(PR #791)は副コマンドの登録関数の分割と再開の経路の分割、G2(PR #790)は指摘の分類を触る。この Pull Request は既存の行を書き換える量を最小にし、足す形で書く。競合は後からマージする側が解く +- 前提 3: ホストが席に入ったときの自分の Pull Request への投稿の扱い(`is_own_pr`)は、この Pull Request では実機で回さず、検査の持ち場か運用で確かめる。確かめていないことを Pull Request 本文の残リスクに書く + +## 受け入れ条件 + +要求文書の AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 をそのまま使う。検証手段は要求文書の「検証手段」と設計文書の「テスト設計」の表にある。AC45・AC46・AC48(子 issue の再現手順)は手元で実行して結果を issue のコメントに残す。 + +## ドメイン用語 + +設計文書の「用語の対応表」を使う。この文書で追加する語は無い。 + +## 不変条件 + +- 使える者の並びは、ランタイムの固定の順(`ALL_RUNTIMES`)を保つ +- 使える者が 3 者のときの席は、変更前の輪番と同じ値になる +- この変更の前に始めた実行の状態ファイルは書き換えずに読める +- 再開で渡さなかった引数は、状態ファイルの値のまま残る + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| `state.py init` の引数 | `--exclude` / `--include` / `--require-all` / `--no-require-all` を足す。`--only` が `none` を取る。`--max-rounds` / `--rotate-after` の既定を未指定へ | 追加のみ。骨組みは値があるときだけ渡す形へ変える | +| `state.py read-result` の担当の引数 | 4 つの名前の選択肢から、席の名前の形の検査へ | 従来の 4 つの名前はそのまま通る | +| 起動スクリプトの第 1 引数 | ランタイム名から席の名前へ | 従来の名前はそのまま通る | +| 監視の位置引数の選択肢 | 4 つの名前と `both` から、席の名前の形と `both` へ | 同上 | +| 状態ファイル | 最上位に `participants` と `resume_changes` が増える。`rounds[].reviewers` と `rounds[].<席>` の鍵に席の名前が入りうる | 項目が無いときの読み方を契約文書の「移行」が決める。既存のファイルは書き換えない | +| 共通層の関数 | 6 つを新設。旧関数は残す | 追加のみ | + +## 修正対象 + +共通層: + +- `plugins/ndf/scripts/lib/auth.py` +- `plugins/ndf/scripts/lib/assignment.py` +- `plugins/ndf/scripts/lib/statefile.py` +- `plugins/ndf/scripts/lib/monitor.py` +- `plugins/ndf/scripts/lib/README.md`(関数の一覧の行) +- `plugins/ndf/scripts/tests/test_auth_probe.py`(書き直し)、`test_lib_participants.py`(新設)、`test_lib_resume_args.py`(新設)、`test_lib_assignment.py`(追記) + +cross-review: + +- `plugins/ndf/skills/cross-review/scripts/state.py` +- `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` / `critique.sh` / `critique-round.sh` / `wait-review.sh` / `measure.py` +- `plugins/ndf/skills/cross-review/SKILL.md` / `docs/01-state-and-review.md` / `docs/04-contracts.md` / `docs/05-pool-and-convergence.md` +- `plugins/ndf/skills/cross-review/tests/test_state_review_pool.py`(追記)、`test_state_round_guard.py`(追記)、`test_state_resume_args.py`(新設)、`test_seat_names.py`(新設)、`test_skill_layout.py`(追記) + +cross-refactoring(テストだけ): + +- `plugins/ndf/skills/cross-refactoring/tests/test_assignment.py`(席の埋め方のテストを追記。従来の席の割り当てのテストは 2 本目の Pull Request が消す) + +文書: + +- `issues/issue-727-687-478-664-648-design.md`(「未確認のまま残ること」の 3 行) + +## タスク分解 + +受け入れ条件の番号は要求文書のものである。 + +### Task 1: 止めない確認を共通層に足す + +- **対象ファイル:** `lib/auth.py`、`scripts/tests/test_auth_probe.py` +- **変更内容:** 確認コマンドを走らせて結果だけを返す関数(`probe_auth(runtimes, *, info, env=None)` → `(結果, 飛ばしたか)`)を足す。確認コマンド・未認証の文言・時間切れの秒数・飛ばす環境変数は変えない。従来の確認は残す。既存テストは従来の確認を使っているため、止めない確認のテストへ書き直す(従来の確認のテストは 2 本目の Pull Request が消すまで残してよい) +- **満たす受け入れ条件:** AC5(確認コマンドを呼ばない部分)、AC6 +- **進め方:** 失敗するテスト(コマンドが見つからない / 時間切れ / 終了コード非 0 / 未認証の文言 / 成功 / 飛ばし)→ 最小実装 → 従来の確認と重なる走らせ方を 1 つの内部関数へ寄せる + +### Task 2: 使える者の解決・席の埋め方・適用の輪番・席の名前を共通層に足す + +- **対象ファイル:** `lib/assignment.py`、`scripts/tests/test_lib_participants.py`(新設)、`scripts/tests/test_lib_assignment.py`、`skills/cross-refactoring/tests/test_assignment.py` +- **変更内容:** 既定の参加者の表(`DEFAULT_REFACTOR_RUNTIMES = ("codex", "kiro")`)、母集合の既定(`refactor_pool(host)`)、参加者の記録(`Participants` データクラス。`pool` / `included` / `excluded` / `available` / `unavailable` / `probe_skipped` / `require_all` と `to_state()`)、使える者の解決(`resolve_participants`。順序は設計文書の表の 6 段)、席の形(`SEAT_PATTERN`)と席の名前の解釈(`seat_runtime`)、席の埋め方(`review_seats(round_no, available, fallback)`。規則の表は docstring に置く。設計の決定 20)、適用の輪番(`impl_assign(round_no, participants)`)を足す。モジュールの docstring の「役割ごとに母集合が違う」の表は、2 本目の Pull Request で母集合が 1 つになるまで残す +- **満たす受け入れ条件:** AC1〜AC5、AC8〜AC13、AC34 のうち適用の輪番の値 +- **進め方:** 失敗するテスト → 最小実装 → 整理。AC8 は変更前の席の割り当て(`review_assign`)を期待値に使う + +### Task 3: 再開の反映を共通層に足す + +- **対象ファイル:** `lib/statefile.py`、`scripts/tests/test_lib_resume_args.py`(新設) +- **変更内容:** 反映の表の 1 行(`ResumeField(arg, key, mode)`。`mode` は `replace` / `notify`)と、再開の反映(`apply_resume_args(state, args, spec)` → 標準エラーへ出す行の一覧)を足す。「反映する」は未指定でない値を状態へ書き `resume_changes` に `{at, field, from, to}` を積む。「知らせる」は状態と違うときだけ行を返す。値が同じなら行も記録も出さない。予約語 `none` の扱い(1 者指定は `null`、一覧は空)は呼び出し側が引数を正規化してから渡す形にし、この関数は値をそのまま比べる +- **満たす受け入れ条件:** AC25〜AC29 の共通層の部分 +- **進め方:** 失敗するテスト → 最小実装 → 整理 + +### Task 4: cross-review の新規の初期化を共通層へ載せ替える + +- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_review_pool.py` +- **変更内容:** + - 初期化の引数: `--only` の型を 4 つの名前か `none` へ、`--exclude` / `--include`(カンマ区切り・繰り返し可・`none`)、`--require-all` / `--no-require-all`(既定は未指定)を足す。`--max-rounds` / `--rotate-after` の既定を未指定へ変え、新規の経路で 12 / 8 を置く。既存の引数の行はそのまま残し、足す行だけを加える(G3 が副コマンドの登録関数を分けるため) + - 使える者の解決(`_resolve_reviewers(host, args)`): 母集合の既定と使える者の解決を呼び、使える者が 2 者に満たなければホストを止めない確認で確かめて埋め合わせ(`fallback`)を決める。1 者指定があればホストを確かめず埋め合わせは空。割り当ての失敗は終了コード 1 へ写す。従来の確認の相手を決める関数と 1 者指定の検査(`_auth_targets` / `_validate_only`)はこの関数で置き換える + - 初期状態: `participants`(埋め合わせを含む 8 項目)と `resume_changes: []` を書く。`max_rounds` / `rotate_after` は既定を埋めた値 + - 標準エラーの行: 母集合と使える者の 1 行、通らなかった者は 1 者 1 行、埋め合わせは 1 行(文言は「実装で決めたこと」) +- **満たす受け入れ条件:** AC14〜AC20 +- **進め方:** 失敗するテスト(止めない確認を差し替えて新規の初期化を呼び、状態ファイルの有無・終了コード・標準エラーを見る)→ 最小実装 → 整理。既存テスト `test_auth_check_covers_only_the_reviewers_that_run` と `test_init_rejects_an_only_outside_the_pool` は新しい形へ書き直す + +### Task 5: 担当の読み出しを記録から先に見る順へ変え、前ラウンドの検査に記録の担当を渡す + +- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_review_pool.py`、`cross-review/tests/test_state_round_guard.py` +- **変更内容:** 担当の読み出し(`_round_reviewers`)を「ラウンドの記録 → 1 者指定 → 参加者の記録から席の埋め方 → ホストの輪番(変更前と同じ値)→ `codex` / `agy`」の順にする。前ラウンドの検査(`_guard_previous_round`)は `prev["reviewers"]`(無ければ担当の読み出し)を結果なしの判定と通過の判定へ渡す +- **満たす受け入れ条件:** AC17(席が 2 つ返る部分)、AC18・AC19(`start-round` の返り値)、AC22、AC23 +- **進め方:** 失敗するテスト → 最小実装 → 整理 + +### Task 6: 席の名前を結果の受け口と起動・監視・計測に通す + +- **対象ファイル:** `cross-review/scripts/state.py`(結果の受け口の引数)、`launch-reviewer.sh` / `critique.sh` / `critique-round.sh` / `wait-review.sh`、`lib/monitor.py`、`cross-review/scripts/measure.py`、`cross-review/tests/test_seat_names.py`(新設)、既存の監視のテスト +- **変更内容:** + - 結果の受け口(`read-result`)の担当の引数を、席の名前の形の検査(`seat_runtime` を型に使う)にする。通らなければ argparse の終了コード 2 + - 起動スクリプト 2 本(`launch-reviewer.sh` / `critique.sh`)の先頭の検査を席の形(`^(claude|codex|agy|kiro)(-[2-9])?$`)にし、CLI は `${SEAT%%-*}` で選んで共通の起動スクリプト(`launch-cli.sh`)へ渡す。結果ファイルの stem は席の名前で組む(変更なし)。`critique-round.sh` は席の名前をそのまま `critique.sh` へ渡すだけで変更は無い(確かめて記録する)。`wait-review.sh` は使い方の説明の担当名を席の名前に直す + - 監視(`lib/monitor.py`): 席の名前からランタイムを引く内部関数を 1 つ置き、CLI 固有の分岐 3 か所(`codex` の sentinel 2 か所、`claude` の標準出力の検査 1 か所)をその関数で包む。位置引数の選択肢を席の形と `both` を受ける型へ替える + - 計測(`measure.py`): 担当の名前の一覧で数える箇所を、記録の鍵のうち席の形に一致するものを数える形にする +- **満たす受け入れ条件:** AC21 +- **進め方:** 失敗するテスト(結果の受け口を `claude-2` で呼ぶ / 起動スクリプトを共通の起動スクリプトを差し替えて呼び、渡った CLI 名と stem を見る / 監視の位置引数に `kiro-2` を渡す)→ 最小実装 → 整理 + +### Task 7: cross-review の再開で引数を反映し、担当に関わる引数で参加者を作り直す + +- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_resume_args.py`(新設) +- **変更内容:** 反映の表(`max_rounds` / `rotate_after` / `only` / `verify_commands` / `verify_exit_codes` は反映する。`host` は知らせる)を置き、再開の経路(`_resume_from_state`)に引数を渡して再開の反映を呼ぶ。1 者指定・外す者・足す者・全員を要する指定のどれかを渡した再開では、渡さなかった引数を状態ファイルの値(`participants.included` / `excluded` / `require_all`、`only`)で補って使える者の解決をやり直し、`participants` を書き換えて `resume_changes` に 1 件積む。失敗したら状態ファイルを書き換えずに終了コード 1。既存の関数は引数を 1 つ足し、本体の既存の行は動かさず、反映の呼び出しを 1 ブロック足す形にする(G3 の分割と競合する行を減らす) +- **満たす受け入れ条件:** AC25〜AC29 +- **進め方:** 失敗するテスト(状態ファイルを置いた作業ツリーで初期化を呼び、状態ファイルと標準エラーを見る。一部の引数だけを渡す組み合わせを含む)→ 最小実装 → 整理 + +### Task 8: 完了報告に「参加した者」の節を足す + +- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_review_pool.py` +- **変更内容:** 「PR 履歴」の後に「参加した者」の節を出す関数を 1 つ足し、完了報告(`cmd_report`)から 1 行で呼ぶ。行は 7 つ(母集合 / 使える者 / 外した者 / 足した者 / 確認を通らなかった者(理由つき)/ 席の埋め合わせ / 再開で変えた値)。参加者の記録が無ければ「使える者: 記録なし」、確認を飛ばした印が真なら「確認を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」。既存の行は書き換えない(G3 が完了報告の結末の節を触る) +- **満たす受け入れ条件:** AC24 +- **進め方:** 失敗するテスト → 最小実装 + +### Task 9: 骨組みと文書を席の規則と新しい引数に合わせる + +- **対象ファイル:** `cross-review/SKILL.md`、`docs/01-state-and-review.md`、`docs/04-contracts.md`、`docs/05-pool-and-convergence.md`、`cross-review/tests/test_skill_layout.py` +- **変更内容:** 骨組み(Step 0 / 2 / 2.5)を契約文書の「手順書の骨組み」の形にする。初期化へは値のある引数だけを渡し、起動・監視・取り込み・反証の担当は `$REVIEWERS` / `$REVIEWERS_CSV` を使う。引数の表と `argument-hint` に 3 つの引数を足し、`--only` から「デバッグ用」を消す。`docs/05` に使える者の解決・席の埋め方(3 者以上 / 2 者 / 1 者 / 0 者)・足す者と外す者・確認が把握になったことを書く。`docs/04` に `participants` と `resume_changes` と席の名前の形を書く。`docs/01` に再開で反映する引数と反映しない引数の表を書く。`test_skill_layout.py` に「`ONLY` を含む行は初期化へ渡す行と引数の説明の行だけ」の検査を足す +- **満たす受け入れ条件:** AC30、AC44 +- **進め方:** テスト(`grep` の行数)→ 文書の書き換え。文書は `markdown-writing` の規約で書く + +### Task 10: 設計文書の「未確認のまま残ること」を更新し、配布物と検査を通す + +- **対象ファイル:** `issues/issue-727-687-478-664-648-design.md`、`lib/README.md`、生成物 +- **変更内容:** 実装で決めた 3 件(監視の比較の寄せ方・出力の文言・テストの置き場所)を「未確認のまま残ること」の表から「決めた」へ書き換える。共通層の README の関数の行を足す。`bash scripts/build-runtime-plugins.sh` で生成物を揃え、AC49・AC50 のコマンドを通す。AC45・AC46・AC48 を手元で実行し、結果を issue のコメントに残す +- **満たす受け入れ条件:** AC45、AC46、AC48、AC49、AC50 +- **進め方:** コマンドの実行と結果の記録(テスト駆動の対象ではない) + +## 実装で決めたこと + +設計文書が実装に委ねた 3 件を決める。 + +| 項目 | 決めたこと | 理由 | +| --- | --- | --- | +| 監視の CLI 固有の検査を席の名前に通す形 | 監視(`lib/monitor.py`)に席の名前からランタイムを引く内部関数を 1 つ置き、比較 3 か所をその関数で包む。席の形に合わない名前はそのまま返す | 監視は cross-refactoring も使い、担当名を任意の骨格で受ける経路がある(`test_monitor_generic_stem.py`)。形に合わない名前で失敗させると、その経路が壊れる | +| 出力の文言 | 反映した行は `↻ <項目>: <旧> → <新>`、知らせる行は `ℹ --<引数> は再開では反映しません(状態: <値> / 指定: <値>)`、通らなかった者は `⚠ <名前> を担当から外しました(<理由>)`、埋め合わせは `⚠ 使える者が <数> 者のため、席を<相手>で埋めます(観点が減ります)` | 既存の初期化の出力が `↻` / `ℹ` / `⚠` の印で始まる形に揃える。項目名と値を含めることは設計が決めている | +| テストの置き場所 | 設計文書の「テスト設計」の表のとおり。席の埋め方のテストは cross-refactoring の割り当てのテスト(`test_assignment.py`)に置く | 変更前の席の割り当てのテストが同じファイルにあり、AC8 の期待値をその場で引ける | + +## 影響範囲 + +- cross-review の初期化の既定の振る舞い: 確認を通らない者が 1 者でもあれば止める形から、使える者で回す形へ(従来の形は `--require-all`) +- cross-review の席: 使える者が 2 者に満たないとき、ホスト → 同じランタイムの 2 つ目で埋める。従来は 1 者で回すか失敗していた +- 状態ファイル・結果ファイルの名前に、席の名前(`claude-2` など)が現れうる。読む側(監視・計測・完了報告)はこの Pull Request で追随する +- cross-refactoring: 共通層の関数が増えるだけで振る舞いは変わらない。旧関数を残すため既存のテストは通る + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 状態の部品(`state.py`、4470 行)を G2 / G3 と並行して触る | 既存の行の書き換えを最小にし、足す形で書く。着手の前に G3 の差分を読んだ(副コマンドの登録関数の分割・再開の経路の 3 分割・完了報告の結末の節)。触る関数を初期化・担当の読み出し・前ラウンドの検査・結果の受け口の引数・完了報告の呼び出し 1 行に限る | +| 監視の比較を包む変更が、任意の骨格で担当名を受ける経路を壊す | 席の形に合わない名前はそのまま返す。既存の監視のテスト(`test_monitor_*`)を毎タスクで通す | +| 席の名前が読み手に届かない箇所が残る | 設計文書の「構成要素」の受け口 10 か所を 1 つずつ検査に対応づけ、`grep -n 'claude|codex|agy|kiro' scripts/` で分岐と選択肢を洗い直す | +| 従来の確認のテストが止めない確認の導入で意味を失う | 2 本目の Pull Request で消すまで残す。この Pull Request では止めない確認のテストを足す | + +## 切り戻し手順 + +- Pull Request を revert すれば戻る。状態ファイルの新しい項目(`participants` / `resume_changes`)は、旧版の読み手が読まない鍵のため、途中の実行を旧版で再開しても壊れない +- 席の名前を持つ状態ファイル(`claude-2` の鍵)を旧版で読むと、その席の結果は数えられない。旧版へ戻すときは実行を新しく始める + +## 完了の定義 + +- [ ] AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 を満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る(監視の環境変数を export していないシェルで実行する) +- [ ] AC50 の 6 つのコマンドが終了コード 0 で終わる +- [ ] 設計文書の「未確認のまま残ること」が更新されている +- [ ] Draft の Pull Request を `develop` 宛に出した From 0bf990a67bf325ed94b4fcaac25a9adf970dc85e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 19:57:18 +0000 Subject: [PATCH 074/217] =?UTF-8?q?Add:=20=E5=85=B1=E9=80=9A=E5=B1=A4?= =?UTF-8?q?=E3=81=AB=E4=BD=BF=E3=81=88=E3=82=8B=E8=80=85=E3=81=AE=E8=A7=A3?= =?UTF-8?q?=E6=B1=BA=E3=83=BB=E5=B8=AD=E3=81=AE=E5=9F=8B=E3=82=81=E6=96=B9?= =?UTF-8?q?=E3=83=BB=E6=AD=A2=E3=82=81=E3=81=AA=E3=81=84=E7=A2=BA=E8=AA=8D?= =?UTF-8?q?=E3=83=BB=E5=86=8D=E9=96=8B=E3=81=AE=E5=8F=8D=E6=98=A0=E3=82=92?= =?UTF-8?q?=E8=B6=B3=E3=81=99=EF=BC=88#727=20#687=20#478=20#648=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - lib/auth.py: probe_auth(止めない確認)。check_auth と走らせる部分を共有する - lib/assignment.py: refactor_pool / Participants / resolve_participants / SEAT_PATTERN / seat_runtime / review_seats / impl_assign。旧関数は別の Pull Request で消す - lib/statefile.py: ResumeField / apply_resume_args(再開の反映) - テスト: test_lib_participants.py / test_lib_resume_args.py を新設、 test_auth_probe.py / test_lib_assignment.py / cross-refactoring の test_assignment.py に追記 Co-Authored-By: Claude Fable 5.1 --- plugins/ndf/scripts/lib/assignment.py | 223 +++++++++++++++++- plugins/ndf/scripts/lib/auth.py | 98 +++++--- plugins/ndf/scripts/lib/statefile.py | 50 +++- plugins/ndf/scripts/tests/test_auth_probe.py | 151 ++++++++++++ .../ndf/scripts/tests/test_lib_assignment.py | 22 ++ .../scripts/tests/test_lib_participants.py | 217 +++++++++++++++++ .../ndf/scripts/tests/test_lib_resume_args.py | 150 ++++++++++++ .../tests/test_assignment.py | 69 ++++++ 8 files changed, 951 insertions(+), 29 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_lib_participants.py create mode 100644 plugins/ndf/scripts/tests/test_lib_resume_args.py diff --git a/plugins/ndf/scripts/lib/assignment.py b/plugins/ndf/scripts/lib/assignment.py index d4909d44..a01928c4 100644 --- a/plugins/ndf/scripts/lib/assignment.py +++ b/plugins/ndf/scripts/lib/assignment.py @@ -14,11 +14,23 @@ 参加する 4 者はいずれも NDF の配布先であるため、**適用から外す者はいない**。 ホストは提案・レビューから外れるが適用には入るため、2 つの母集合は重なるが 一致しない。輪番の式はホストによらず同じ形になる。 + +## 使える者の解決と席の埋め方(#727) + +参加者は「母集合の既定 ∪ 足す者 − 外す者」で決め(`resolve_participants`)、確認を +通った者だけを使える者(`available`)として記録する。cross-refactoring の母集合の +既定は `refactor_pool(host)`(`DEFAULT_REFACTOR_RUNTIMES` とホスト)、cross-review は +`review_pool(host)` のまま。担当の単位は席の名前(`SEAT_PATTERN`。`claude-2` のように +同じランタイムの 2 つ目を表す)で、cross-review の 2 席は `review_seats` が、 +cross-refactoring の適用担当は `impl_assign` が決める。上の表と `impl_pool` / +`review_assign` / `assign` は、母集合が 1 つになる次の Pull Request(P7)まで残す。 """ from __future__ import annotations import os -from typing import Mapping, Optional +import re +from dataclasses import dataclass, field +from typing import Any, Callable, Iterable, Mapping, Optional # 固定順。輪番の再現性を保つため並べ替えない。 ALL_RUNTIMES: tuple[str, ...] = ("claude", "codex", "agy", "kiro") @@ -28,6 +40,15 @@ # 別の問いで、配布先でない CLI が参加 CLI に加わると 2 つは再び分かれる。 HOST_RUNTIMES: tuple[str, ...] = ALL_RUNTIMES +# cross-refactoring の既定の参加者の表(ホストを除いた部分。設計の決定 4)。ホストは +# `refactor_pool(host)` が足す。表に無い者(agy)は `--include` で足す(#727)。 +DEFAULT_REFACTOR_RUNTIMES: tuple[str, ...] = ("codex", "kiro") + +# 席の名前の形: `^(claude|codex|agy|kiro)(-[2-9])?$`。ランタイム名そのままが 1 つ目の席、 +# ハイフンと 2〜9 の接尾辞が同じランタイムの 2 つ目以降(設計の決定 10)。ランタイム名に +# ハイフンを含むものが無いため、シェル側の切り出し(`${SEAT%%-*}`)と同じ規則になる。 +SEAT_PATTERN = re.compile(rf"^({'|'.join(ALL_RUNTIMES)})(-[2-9])?$") + # ホスト推定に使う環境変数。値の中身は見ず、**存在するかどうか**だけで判定する。 HOST_ENV_HINTS: tuple[tuple[str, str], ...] = ( ("CLAUDE_PLUGIN_ROOT", "claude"), @@ -135,3 +156,203 @@ def assign(round_no: int, host: str) -> tuple[str, list[str]]: dropped = (round_no // len(pool)) % len(candidates) candidates = [r for i, r in enumerate(candidates) if i != dropped] return impl, candidates + + +def _in_fixed_order(names: Iterable[str]) -> list[str]: + """`ALL_RUNTIMES` の順に並べ直す(重複は 1 つにする)。""" + wanted = set(names) + return [r for r in ALL_RUNTIMES if r in wanted] + + +def refactor_pool(host: str) -> list[str]: + """cross-refactoring の母集合の既定。`DEFAULT_REFACTOR_RUNTIMES` とホストの和集合。 + + ホストが変わっても一覧を書き直さずに済むように、既定は「ホストを除いた部分」 + だけを持ち、ホストをここで足す(設計の決定 4)。並びは `ALL_RUNTIMES` の順。 + """ + if host not in HOST_RUNTIMES: + raise AssignmentError(f"ホストになれないランタイムです: {host}") + return _in_fixed_order((*DEFAULT_REFACTOR_RUNTIMES, host)) + + +@dataclass +class Participants: + """使える者の解決の結果。状態ファイルの `participants` のうち `fallback` を除く 7 項目。 + + `fallback`(席の埋め合わせに使える者)は cross-review だけが持つため、呼び出し側が + `to_state()` の辞書へ足す。 + """ + pool: list[str] + included: list[str] = field(default_factory=list) + excluded: list[str] = field(default_factory=list) + available: list[str] = field(default_factory=list) + unavailable: dict[str, str] = field(default_factory=dict) + probe_skipped: bool = False + require_all: bool = False + + def to_state(self) -> dict[str, Any]: + return { + "pool": list(self.pool), + "included": list(self.included), + "excluded": list(self.excluded), + "available": list(self.available), + "unavailable": dict(self.unavailable), + "probe_skipped": self.probe_skipped, + "require_all": self.require_all, + } + + +# 止めない確認の形。`auth.probe_auth` を `functools.partial(auth.probe_auth, info=info)` +# のように包んで渡す。返り値は `(名前 → {"command", "ok", "detail"}, 飛ばしたか)`。 +Probe = Callable[[list[str]], tuple[dict[str, dict[str, Any]], bool]] + + +def resolve_participants( + pool: Iterable[str], + *, + host: str, + include: Iterable[str] = (), + exclude: Iterable[str] = (), + only: Optional[str] = None, + probe: Probe, + require_all: bool = False, +) -> Participants: + """母集合の既定・足す者・外す者・1 者指定から使える者を決める(設計の決定 2〜4)。 + + 順序: + + 1. `include` / `exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。 + `exclude` の名前が「`pool` ∪ `include`」に無ければ弾く(cross-review でホストを + 外す指定はここに当たる) + 2. 参加者 = `pool` ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) + 3. `only` があれば、参加者に含まれ `exclude` に無いことを確かめ、参加者をその 1 者にする + 4. `probe(参加者)` で確かめる。飛ばされたら全員を通ったものとし `probe_skipped` を真にする + 5. `require_all` が真で通らない者がいれば `AssignmentError`(欠けた者と理由を並べる) + 6. 通った者を `available`、通らなかった者と理由を `unavailable` として返す + + 名前の綴りの検査(argparse の型)はこの前段で済んでいる前提だが、ここでも + `ALL_RUNTIMES` に無い名前は弾く。 + """ + pool = list(pool) + include = list(include) + exclude = list(exclude) + + for name in (*include, *exclude): + if name not in ALL_RUNTIMES: + raise AssignmentError( + f"参加できないランタイムです: {name}({'/'.join(ALL_RUNTIMES)} のいずれか)" + ) + overlap = set(include) & set(exclude) + if overlap: + raise AssignmentError( + f"足す者と外す者に同じ名前があります: {', '.join(_in_fixed_order(overlap))}" + ) + base = set(pool) | set(include) + outside = [n for n in exclude if n not in base] + if outside: + raise AssignmentError( + f"母集合に無い者は外せません: {', '.join(_in_fixed_order(outside))}" + f"(母集合: {', '.join(_in_fixed_order(base))})" + ) + + participants = _in_fixed_order(base - set(exclude)) + + if only is not None: + if only in exclude: + raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") + if only not in participants: + raise AssignmentError( + f"--only は参加者のいずれかを指定してください: {only}" + f"(参加者: {', '.join(participants)})" + ) + participants = [only] + + results, skipped = probe(list(participants)) + if skipped: + available, unavailable = list(participants), {} + else: + unavailable = { + n: str(results.get(n, {}).get("detail", "")) + for n in participants + if not results.get(n, {}).get("ok", False) + } + available = [n for n in participants if n not in unavailable] + + if require_all and unavailable: + failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) + raise AssignmentError( + "認証されていない CLI があります: " + failed + "。" + "参加者が欠けたまま進むと、その者のレビューが無いまま収束します。" + "各 CLI でログインしてから再実行してください" + ) + + return Participants( + pool=pool, + included=_in_fixed_order(include), + excluded=_in_fixed_order(exclude), + available=available, + unavailable=unavailable, + probe_skipped=skipped, + require_all=require_all, + ) + + +def seat_runtime(seat: str) -> str: + """席の名前からランタイム名を引く。形は `SEAT_PATTERN`(`kiro` / `kiro-2`)。 + + 形に合わなければ `AssignmentError`。結果の受け口・起動スクリプト・監視が、担当の + 引数の検査にこの関数を使う。 + """ + m = SEAT_PATTERN.match(seat) + if m is None: + raise AssignmentError( + f"席の名前の形が違います: {seat}" + f"({'/'.join(ALL_RUNTIMES)} か、その名前に -2〜-9 を付けた形)" + ) + return m.group(1) + + +def review_seats(round_no: int, available: list[str], fallback: list[str]) -> list[str]: + """cross-review のラウンドの 2 席を決める(設計の決定 9・20。規則の正本はこの表)。 + + | 使える者の数 n | 席 | + | ---: | --- | + | 3 以上 | `available[round_no % n]` と `available[(round_no + 1) % n]` を `available` の順に並べた 2 席 | + | 2 | その 2 者 | + | 1 | その 1 者と、`fallback` のうち `available` に含まれない先頭の者。無ければ `<その 1 者>-2` | + | 0 | `fallback[0]` と `-2`。`fallback` が空なら `AssignmentError` | + + `available` の並びは `ALL_RUNTIMES` の順(`resolve_participants` が保つ)。n = 3 の値は + 変更前の `review_assign` と一致する。埋め合わせの候補は使える者に含まれない者だけを + 使い、含まれる者は飛ばす(同じ席の名前を 2 つ返さないため)。`only` の処理は呼び出し側が + 先に行う(1 者指定は埋め合わせをしない)。 + """ + if round_no < 1: + raise AssignmentError(f"ラウンド番号は 1 以上です: {round_no}") + n = len(available) + if n >= 3: + picked = {available[round_no % n], available[(round_no + 1) % n]} + return [r for r in available if r in picked] + if n == 2: + return list(available) + if n == 1: + first = available[0] + extra = next((f for f in fallback if f not in available), None) + return [first, extra if extra is not None else f"{first}-2"] + if not fallback: + raise AssignmentError("使える者も席の埋め合わせに使える者もいません") + return [fallback[0], f"{fallback[0]}-2"] + + +def impl_assign(round_no: int, participants: list[str]) -> str: + """cross-refactoring の適用担当 1 者を決める: `participants[round_no % len]`。 + + 式は変更前の `assign()` と同じで、除数だけを参加者の数にする(設計の決定 7)。 + ラウンド 1 が `participants[1]` から始まるため、ホスト claude の既定 + (claude / codex / kiro)でもホストが最初に適用する形にならない。 + """ + if round_no < 1: + raise AssignmentError(f"ラウンド番号は 1 以上です: {round_no}") + if not participants: + raise AssignmentError("適用担当を選べる参加者がいません") + return participants[round_no % len(participants)] diff --git a/plugins/ndf/scripts/lib/auth.py b/plugins/ndf/scripts/lib/auth.py index 1fc7d63b..810f3db8 100644 --- a/plugins/ndf/scripts/lib/auth.py +++ b/plugins/ndf/scripts/lib/auth.py @@ -13,6 +13,8 @@ import subprocess from typing import Any, Callable, Iterable, Optional +ProbeResult = dict[str, dict[str, Any]] + # 認証状態の確認コマンド。CLI ごとに、認証を通ったときだけ成功する最も短い操作を選ぶ。 AUTH_PROBES: dict[str, tuple[str, ...]] = { "claude": ("claude", "auth", "status"), @@ -33,13 +35,53 @@ SKIP_ENV = "NDF_SKIP_AUTH_CHECK" +def _skipped(env: Optional[dict[str, str]], info: Callable[[str], None]) -> bool: + """`NDF_SKIP_AUTH_CHECK` が立っているか。立っていれば飛ばしたことを出力へ残す。""" + environ = os.environ if env is None else env + if not environ.get(SKIP_ENV): + return False + info(f"⚠ {SKIP_ENV} が設定されているため認証確認を飛ばしました") + return True + + +def _run_probe(probe: tuple[str, ...]) -> tuple[bool, str]: + """確認コマンドを 1 つ走らせ、`(通ったか, 理由)` を返す。例外は上げない。 + + 理由は stderr か stdout の先頭 200 文字。終了コード 0 でも未認証の文言を含めば + 通らなかったものとする(kiro は成否を終了コードで表さない)。 + """ + try: + r = subprocess.run(list(probe), capture_output=True, text=True, + timeout=AUTH_PROBE_TIMEOUT) + except FileNotFoundError: + return False, "コマンドが見つかりません" + except subprocess.TimeoutExpired: + return False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + merged = f"{r.stdout}\n{r.stderr}".lower() + ok = r.returncode == 0 and not any(m in merged for m in UNAUTHENTICATED_MARKERS) + return ok, (r.stderr.strip() or r.stdout.strip())[:200] + + +def _probe_all(runtimes: Iterable[str], info: Callable[[str], None]) -> ProbeResult: + """`AUTH_PROBES` にある名前だけを順に確かめ、名前 → 結果を返す。1 者 1 行を出力する。""" + results: ProbeResult = {} + for runtime in runtimes: + probe = AUTH_PROBES.get(runtime) + if probe is None: + continue + ok, detail = _run_probe(probe) + results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") + return results + + def check_auth( runtimes: Iterable[str], *, info: Callable[[str], None], die: Callable[[str], None], env: Optional[dict[str, str]] = None, -) -> dict[str, dict[str, Any]]: +) -> ProbeResult: """参加する CLI の認証状態を確かめる。1 つでも欠けたら呼び出し側を中断させる。 **出力と中断の手段は呼び出し側から受け取る。** 工程ごとに終了コードの意味が違う @@ -47,35 +89,15 @@ def check_auth( 確認コマンドは CLI の版で変わりうるので、`NDF_SKIP_AUTH_CHECK` で飛ばせるように しておく。飛ばしたことは必ず出力へ残す(黙って劣化させない)。 + + P7 で消す。止めない確認は `probe_auth`、止めるかの判断は + `assignment.resolve_participants` の `require_all` が持つ。 """ - environ = os.environ if env is None else env - if environ.get(SKIP_ENV): - info(f"⚠ {SKIP_ENV} が設定されているため認証確認を飛ばしました") + if _skipped(env, info): return {} - results: dict[str, dict[str, Any]] = {} - failed: list[str] = [] - for runtime in runtimes: - probe = AUTH_PROBES.get(runtime) - if probe is None: - continue - try: - r = subprocess.run(list(probe), capture_output=True, text=True, - timeout=AUTH_PROBE_TIMEOUT) - merged = f"{r.stdout}\n{r.stderr}".lower() - ok = r.returncode == 0 and not any( - m in merged for m in UNAUTHENTICATED_MARKERS - ) - detail = (r.stderr.strip() or r.stdout.strip())[:200] - except FileNotFoundError: - ok, detail = False, "コマンドが見つかりません" - except subprocess.TimeoutExpired: - ok, detail = False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" - results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} - info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") - if not ok: - failed.append(f"{runtime}({detail})") - + results = _probe_all(runtimes, info) + failed = [f"{name}({r['detail']})" for name, r in results.items() if not r["ok"]] if failed: die( "認証されていない CLI があります: " + " / ".join(failed) + "。" @@ -83,3 +105,25 @@ def check_auth( "各 CLI でログインしてから再実行してください" ) return results + + +def probe_auth( + runtimes: Iterable[str], + *, + info: Callable[[str], None], + env: Optional[dict[str, str]] = None, +) -> tuple[ProbeResult, bool]: + """参加する CLI の認証状態を確かめ、結果だけを返す(止めない確認。#727)。 + + 返り値は `(結果, 飛ばしたか)`。結果は名前 → `{"command", "ok", "detail"}`。 + **例外を上げず、呼び出し側も中断させない。** 通らなかった者を外して続けるか、 + 全員を要して止めるかは、使える者の解決(`assignment.resolve_participants`)が + 決める。確認コマンド・未認証の文言・時間切れの秒数・飛ばす環境変数は + `check_auth` と同じものを使う。 + + `NDF_SKIP_AUTH_CHECK` が立てば確認コマンドを 1 回も呼ばず `({}, True)` を返す。 + 飛ばしたことは出力へ残す(黙って劣化させない)。 + """ + if _skipped(env, info): + return {}, True + return _probe_all(runtimes, info), False diff --git a/plugins/ndf/scripts/lib/statefile.py b/plugins/ndf/scripts/lib/statefile.py index 49e5cb9d..623c79a2 100644 --- a/plugins/ndf/scripts/lib/statefile.py +++ b/plugins/ndf/scripts/lib/statefile.py @@ -12,7 +12,7 @@ import pathlib import shlex import sys -from typing import Any, Callable +from typing import Any, Callable, Iterable, NamedTuple # 保存の後に呼ぶ関数(#662)。**共通層は呼ぶだけで、何をするかは知らない。** # cross-refactoring の `refactor.py` が実行の要約の書き出しを登録する。 @@ -82,3 +82,51 @@ def die(msg: str, code: int = 1) -> None: def info(msg: str) -> None: print(msg, file=sys.stderr) + + +# ---------- 再開の反映(#727 / #648) ---------- + + +class ResumeField(NamedTuple): + """反映の表の 1 行。`arg` は argparse の属性名、`key` は状態ファイルの鍵。 + + `mode` は `"replace"`(値のある引数を状態へ書き、`resume_changes` に積む)か + `"notify"`(状態と違うときだけ「反映しない」と知らせる。状態は変えない)。 + """ + arg: str + key: str + mode: str + + +def apply_resume_args( + state: dict[str, Any], + args: Any, + spec: Iterable[ResumeField], +) -> list[str]: + """再開で渡された引数を表に従って状態へ反映し、標準エラーへ出す行の一覧を返す。 + + **この関数は出力も保存もしない。** 呼び出し側が行を `info` で出し、`save` を 1 回で + 行う。未指定(属性が無いか `None`)の項目は何もしない。値が同じ項目は行を返さず、 + 記録にも積まない。予約語 `none` の正規化(1 者指定は `None`、一覧は `[]`)は + 呼び出し側が済ませてから渡し、ここでは値をそのまま `!=` で比べる。 + + どの引数が `replace` / `notify` かは Skill ごとの表(`spec`)が持つ(設計の決定 13)。 + """ + lines: list[str] = [] + for field in spec: + new = getattr(args, field.arg, None) + if new is None: + continue + old = state.get(field.key) + if old == new: + continue + if field.mode == "replace": + state[field.key] = new + state.setdefault("resume_changes", []).append( + {"at": now(), "field": field.key, "from": old, "to": new} + ) + lines.append(f"↻ {field.key}: {old} → {new}") + else: + option = "--" + field.arg.replace("_", "-") + lines.append(f"ℹ {option} は再開では反映しません(状態: {old} / 指定: {new})") + return lines diff --git a/plugins/ndf/scripts/tests/test_auth_probe.py b/plugins/ndf/scripts/tests/test_auth_probe.py index 30eaa4d0..65667229 100644 --- a/plugins/ndf/scripts/tests/test_auth_probe.py +++ b/plugins/ndf/scripts/tests/test_auth_probe.py @@ -1,3 +1,9 @@ +"""認証状態の確認(`lib/auth.py`)のテスト。 + +主題は止めない確認 `probe_auth`(#727)である。失敗しても例外を上げず、`ok` と理由を +返し、`NDF_SKIP_AUTH_CHECK` が立てば確認コマンドを 1 回も呼ばない(AC5 / AC6)。 +従来の `check_auth` のテストは、その関数を消す Pull Request(P7)まで末尾に残す。 +""" from __future__ import annotations import importlib.util @@ -5,6 +11,8 @@ import subprocess import sys +import pytest + LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" @@ -18,6 +26,149 @@ def _load_auth(): return mod +@pytest.fixture +def auth(): + return _load_auth() + + +def _completed(cmd, returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess(cmd, returncode, stdout, stderr) + + +# ---------- probe_auth: 失敗の 4 つの形(AC6) ---------- + +def test_probe_reports_a_missing_command(auth, monkeypatch): + def missing(*args, **kwargs): + raise FileNotFoundError(args[0][0]) + + monkeypatch.setattr(auth.subprocess, "run", missing) + messages: list[str] = [] + + results, skipped = auth.probe_auth(["codex"], info=messages.append, env={}) + + assert skipped is False + assert results["codex"]["ok"] is False + assert results["codex"]["detail"] == "コマンドが見つかりません" + assert results["codex"]["command"] == "codex login status" + assert messages == ["❌ codex: codex login status"] + + +def test_probe_reports_a_timeout(auth, monkeypatch): + def time_out(*args, **kwargs): + raise subprocess.TimeoutExpired(args[0], kwargs["timeout"]) + + monkeypatch.setattr(auth.subprocess, "run", time_out) + + results, skipped = auth.probe_auth(["codex"], info=lambda _m: None, env={}) + + assert skipped is False + assert results["codex"]["ok"] is False + assert results["codex"]["detail"] == f"{auth.AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + + +def test_probe_reports_a_nonzero_exit(auth, monkeypatch): + monkeypatch.setattr( + auth.subprocess, "run", + lambda cmd, **kw: _completed(cmd, 1, stdout="", stderr="error: no session\n"), + ) + + results, _ = auth.probe_auth(["codex"], info=lambda _m: None, env={}) + + assert results["codex"]["ok"] is False + assert results["codex"]["detail"] == "error: no session" + + +def test_probe_reports_an_unauthenticated_marker_despite_exit_zero(auth, monkeypatch): + """kiro は成否を終了コードで表さない。終了コード 0 でも文言で未認証を拾う。""" + monkeypatch.setattr( + auth.subprocess, "run", + lambda cmd, **kw: _completed(cmd, 0, stdout="Not logged in\n"), + ) + + results, _ = auth.probe_auth(["kiro"], info=lambda _m: None, env={}) + + assert results["kiro"]["ok"] is False + assert results["kiro"]["detail"] == "Not logged in" + + +# ---------- probe_auth: 成功と飛ばし ---------- + +def test_probe_reports_success(auth, monkeypatch): + monkeypatch.setattr( + auth.subprocess, "run", + lambda cmd, **kw: _completed(cmd, 0, stdout="Logged in as x\n"), + ) + messages: list[str] = [] + + results, skipped = auth.probe_auth(["claude"], info=messages.append, env={}) + + assert skipped is False + assert results["claude"] == { + "command": "claude auth status", "ok": True, "detail": "Logged in as x", + } + assert messages == ["✅ claude: claude auth status"] + + +def test_probe_truncates_detail_to_200_chars(auth, monkeypatch): + monkeypatch.setattr( + auth.subprocess, "run", + lambda cmd, **kw: _completed(cmd, 1, stderr="x" * 300), + ) + + results, _ = auth.probe_auth(["codex"], info=lambda _m: None, env={}) + + assert len(results["codex"]["detail"]) == 200 + + +def test_probe_skips_without_running_any_command(auth, monkeypatch): + """`NDF_SKIP_AUTH_CHECK` が立つと確認コマンドは 1 回も呼ばれない(AC5)。""" + calls: list[list[str]] = [] + monkeypatch.setattr( + auth.subprocess, "run", + lambda cmd, **kw: calls.append(list(cmd)) or _completed(cmd, 0), + ) + messages: list[str] = [] + + results, skipped = auth.probe_auth( + ["codex", "agy"], info=messages.append, env={auth.SKIP_ENV: "1"}, + ) + + assert (results, skipped) == ({}, True) + assert calls == [] + assert messages == [f"⚠ {auth.SKIP_ENV} が設定されているため認証確認を飛ばしました"] + + +def test_probe_ignores_an_unknown_runtime(auth, monkeypatch): + calls: list[list[str]] = [] + monkeypatch.setattr( + auth.subprocess, "run", + lambda cmd, **kw: calls.append(list(cmd)) or _completed(cmd, 0), + ) + + results, skipped = auth.probe_auth(["unknown", "codex"], info=lambda _m: None, env={}) + + assert skipped is False + assert list(results) == ["codex"] + assert calls == [["codex", "login", "status"]] + + +def test_probe_never_raises_and_returns_every_runtime(auth, monkeypatch): + """1 者の失敗で残りの確認が止まらない。""" + def run(cmd, **kw): + if cmd[0] == "codex": + raise FileNotFoundError(cmd[0]) + return _completed(cmd, 0, stdout="ok") + + monkeypatch.setattr(auth.subprocess, "run", run) + + results, _ = auth.probe_auth(["codex", "agy"], info=lambda _m: None, env={}) + + assert results["codex"]["ok"] is False + assert results["agy"]["ok"] is True + + +# ---------- check_auth(従来の確認。P7 で消す) ---------- + def test_unknown_runtime_is_ignored(): auth = _load_auth() messages: list[str] = [] diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index a817073e..6682cbfe 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -2,6 +2,7 @@ from __future__ import annotations import importlib.util +import sys from pathlib import Path import pytest @@ -56,6 +57,8 @@ def assignment(): spec = importlib.util.spec_from_file_location("ndf_lib_assignment", ASSIGNMENT) mod = importlib.util.module_from_spec(spec) + # `@dataclass` は `sys.modules[cls.__module__]` を見るため、登録してから実行する + sys.modules[spec.name] = mod spec.loader.exec_module(mod) return mod @@ -69,3 +72,22 @@ def test_assign_keeps_the_eight_round_rotation(assignment, host): assert any(impl != host for impl, _ in actual) assert all(len(reviewers) == 2 for _, reviewers in actual) assert all(impl not in reviewers for impl, reviewers in actual) + + +# ---------- 適用の輪番(#727。cross-refactoring が使う) ---------- + +def test_impl_assign_rotates_over_the_participants_starting_after_the_host(assignment): + """AC34: `participants[round_no % len]`。ホスト claude の既定でも codex から始まる。""" + participants = ["claude", "codex", "kiro"] + actual = [assignment.impl_assign(r, participants) for r in range(1, 7)] + assert actual == ["codex", "kiro", "claude", "codex", "kiro", "claude"] + + +def test_impl_assign_rejects_a_bad_round(assignment): + with pytest.raises(assignment.AssignmentError): + assignment.impl_assign(0, ["claude", "codex"]) + + +def test_impl_assign_rejects_an_empty_list(assignment): + with pytest.raises(assignment.AssignmentError): + assignment.impl_assign(1, []) diff --git a/plugins/ndf/scripts/tests/test_lib_participants.py b/plugins/ndf/scripts/tests/test_lib_participants.py new file mode 100644 index 00000000..59398b2f --- /dev/null +++ b/plugins/ndf/scripts/tests/test_lib_participants.py @@ -0,0 +1,217 @@ +"""使える者の解決(`assignment.resolve_participants` / `refactor_pool`)のテスト(#727)。 + +確認(`probe`)はスタブで、呼び出しの引数を記録する。環境変数の読み取りは +`auth.probe_auth` の責務なので、飛ばしは `probe` が `(…, True)` を返す形で確かめる。 +""" +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +ASSIGNMENT = Path(__file__).resolve().parents[1] / "lib" / "assignment.py" + + +@pytest.fixture(scope="module") +def assignment(): + spec = importlib.util.spec_from_file_location("ndf_lib_assignment_participants", ASSIGNMENT) + mod = importlib.util.module_from_spec(spec) + # `@dataclass` は `sys.modules[cls.__module__]` を見るため、登録してから実行する + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +def _probe(failing: dict[str, str] | None = None, *, skipped: bool = False): + """確認のスタブ。`failing` の名前は理由つきで通らない。呼び出しを `calls` に記録する。""" + failing = failing or {} + calls: list[list[str]] = [] + + def probe(names): + calls.append(list(names)) + if skipped: + return {}, True + results = { + n: {"command": f"{n} probe", "ok": n not in failing, "detail": failing.get(n, "")} + for n in names + } + return results, False + + probe.calls = calls + return probe + + +# ---------- 母集合の既定 ---------- + +@pytest.mark.parametrize("host,expected", [ + ("claude", ["claude", "codex", "kiro"]), + ("codex", ["codex", "kiro"]), + ("agy", ["codex", "agy", "kiro"]), + ("kiro", ["codex", "kiro"]), +]) +def test_refactor_pool_is_defaults_plus_host_in_fixed_order(assignment, host, expected): + assert assignment.refactor_pool(host) == expected + + +def test_refactor_pool_rejects_a_non_host(assignment): + with pytest.raises(assignment.AssignmentError): + assignment.refactor_pool("gemini") + + +def test_default_refactor_runtimes(assignment): + assert assignment.DEFAULT_REFACTOR_RUNTIMES == ("codex", "kiro") + + +# ---------- AC1 / AC2: 通らない者を外す・全員を要する ---------- + +def test_one_failing_participant_is_moved_to_unavailable(assignment): + """AC1: 母集合 3 者のうち 1 者が通らないと、残り 2 者が母集合の順で使える者になる。""" + probe = _probe({"agy": "コマンドが見つかりません"}) + + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", probe=probe, + ) + + assert p.available == ["codex", "kiro"] + assert p.unavailable == {"agy": "コマンドが見つかりません"} + assert p.probe_skipped is False + assert p.require_all is False + assert p.pool == ["codex", "agy", "kiro"] + assert p.included == [] and p.excluded == [] + + +def test_require_all_fails_with_the_missing_name_and_reason(assignment): + """AC2: `require_all` で欠けがあれば `AssignmentError`。名前と理由を含む。""" + probe = _probe({"agy": "コマンドが見つかりません"}) + + with pytest.raises(assignment.AssignmentError) as exc: + assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", probe=probe, require_all=True, + ) + + assert "agy" in str(exc.value) + assert "コマンドが見つかりません" in str(exc.value) + assert "認証されていない CLI があります" in str(exc.value) + + +# ---------- AC3: 確認の相手は exclude を除き include を含む ---------- + +def test_probe_is_called_once_with_included_but_not_excluded(assignment): + probe = _probe() + + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", + include=["claude"], exclude=["agy"], probe=probe, + ) + + assert probe.calls == [["claude", "codex", "kiro"]] + assert p.available == ["claude", "codex", "kiro"] + assert p.included == ["claude"] + assert p.excluded == ["agy"] + + +def test_only_narrows_the_probe_to_that_one(assignment): + probe = _probe() + + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", only="kiro", probe=probe, + ) + + assert probe.calls == [["kiro"]] + assert p.available == ["kiro"] + assert p.pool == ["codex", "agy", "kiro"] + + +# ---------- AC4: 名前の矛盾 ---------- + +@pytest.mark.parametrize("kwargs", [ + dict(include=["agy"], exclude=["agy"]), + dict(include=["gemini"]), + dict(exclude=["gemini"]), + dict(only="agy", exclude=["agy"]), + dict(only="claude"), +]) +def test_conflicting_names_raise_before_probing(assignment, kwargs): + probe = _probe() + + with pytest.raises(assignment.AssignmentError): + assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", probe=probe, **kwargs, + ) + + assert probe.calls == [] + + +def test_excluding_a_name_outside_the_pool_raises(assignment): + """cross-review でホストを外す指定は、母集合(既定 ∪ include)に無いためここで弾く。""" + probe = _probe() + + with pytest.raises(assignment.AssignmentError): + assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", exclude=["claude"], probe=probe, + ) + + assert probe.calls == [] + + +def test_excluding_an_included_host_is_a_conflict_not_out_of_pool(assignment): + """include でホストを足したうえで exclude すると、重なりとして弾く(母集合には入る)。""" + with pytest.raises(assignment.AssignmentError): + assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", + include=["claude"], exclude=["claude"], probe=_probe(), + ) + + +# ---------- AC5: 飛ばし ---------- + +def test_skipped_probe_marks_everyone_available(assignment): + probe = _probe(skipped=True) + + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", probe=probe, + ) + + assert p.available == ["codex", "agy", "kiro"] + assert p.unavailable == {} + assert p.probe_skipped is True + assert probe.calls == [["codex", "agy", "kiro"]] + + +def test_skipped_probe_satisfies_require_all(assignment): + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", probe=_probe(skipped=True), require_all=True, + ) + assert p.available == ["codex", "agy", "kiro"] + assert p.require_all is True + + +# ---------- 記録の形 ---------- + +def test_to_state_has_the_seven_keys_without_fallback(assignment): + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", + include=["claude"], exclude=["agy"], + probe=_probe({"kiro": "1 秒で応答しませんでした"}), + ) + + assert p.to_state() == { + "pool": ["codex", "agy", "kiro"], + "included": ["claude"], + "excluded": ["agy"], + "available": ["claude", "codex"], + "unavailable": {"kiro": "1 秒で応答しませんでした"}, + "probe_skipped": False, + "require_all": False, + } + + +def test_included_and_excluded_are_kept_in_fixed_order(assignment): + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], host="claude", + include=["claude"], exclude=["kiro", "agy"], probe=_probe(), + ) + assert p.excluded == ["agy", "kiro"] + assert p.available == ["claude", "codex"] diff --git a/plugins/ndf/scripts/tests/test_lib_resume_args.py b/plugins/ndf/scripts/tests/test_lib_resume_args.py new file mode 100644 index 00000000..2693b655 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_lib_resume_args.py @@ -0,0 +1,150 @@ +"""再開の反映(`statefile.apply_resume_args`)のテスト(#727 / #648)。 + +この関数は出力せず、標準エラーへ出す行の一覧を返す。予約語 `none` の正規化は +呼び出し側が済ませてから渡すので、値はそのまま `!=` で比べる。 +""" +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path + +import pytest + +STATEFILE = Path(__file__).resolve().parents[1] / "lib" / "statefile.py" + + +@pytest.fixture(scope="module") +def statefile(): + spec = importlib.util.spec_from_file_location("ndf_lib_statefile_resume", STATEFILE) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def frozen_now(statefile, monkeypatch): + monkeypatch.setattr(statefile, "now", lambda: "2026-09-19T10:00:00") + return "2026-09-19T10:00:00" + + +def _args(**values): + return argparse.Namespace(**values) + + +def test_replace_writes_the_value_and_records_the_change(statefile, frozen_now): + state = {"max_rounds": 12, "resume_changes": []} + spec = [statefile.ResumeField("max_rounds", "max_rounds", "replace")] + + lines = statefile.apply_resume_args(state, _args(max_rounds=20), spec) + + assert lines == ["↻ max_rounds: 12 → 20"] + assert state["max_rounds"] == 20 + assert state["resume_changes"] == [ + {"at": frozen_now, "field": "max_rounds", "from": 12, "to": 20}, + ] + + +def test_notify_returns_a_line_and_leaves_the_state_alone(statefile): + state = {"host": "claude", "resume_changes": []} + spec = [statefile.ResumeField("host", "host", "notify")] + + lines = statefile.apply_resume_args(state, _args(host="codex"), spec) + + assert lines == ["ℹ --host は再開では反映しません(状態: claude / 指定: codex)"] + assert state["host"] == "claude" + assert state["resume_changes"] == [] + + +def test_notify_uses_the_dashed_argument_name(statefile): + state = {"baseline_test": "pytest -q", "resume_changes": []} + spec = [statefile.ResumeField("baseline_test", "baseline_test", "notify")] + + lines = statefile.apply_resume_args(state, _args(baseline_test="make test"), spec) + + assert lines == ["ℹ --baseline-test は再開では反映しません(状態: pytest -q / 指定: make test)"] + + +def test_same_value_yields_no_line_and_no_record(statefile): + state = {"max_rounds": 12, "host": "claude", "resume_changes": []} + spec = [ + statefile.ResumeField("max_rounds", "max_rounds", "replace"), + statefile.ResumeField("host", "host", "notify"), + ] + + lines = statefile.apply_resume_args(state, _args(max_rounds=12, host="claude"), spec) + + assert lines == [] + assert state == {"max_rounds": 12, "host": "claude", "resume_changes": []} + + +def test_unspecified_argument_does_nothing(statefile): + """未指定(`None`)と属性そのものが無い場合の両方で何もしない。""" + state = {"max_rounds": 12, "only": "codex", "resume_changes": []} + spec = [ + statefile.ResumeField("max_rounds", "max_rounds", "replace"), + statefile.ResumeField("only", "only", "replace"), + statefile.ResumeField("host", "host", "notify"), + ] + + lines = statefile.apply_resume_args(state, _args(max_rounds=None, only=None), spec) + + assert lines == [] + assert state == {"max_rounds": 12, "only": "codex", "resume_changes": []} + + +def test_replace_creates_resume_changes_when_missing(statefile, frozen_now): + """この変更の前に始めた実行の状態ファイルにも積める(`resume_changes` が無い)。""" + state = {"only": "codex"} + spec = [statefile.ResumeField("only", "only", "replace")] + + lines = statefile.apply_resume_args(state, _args(only="kiro"), spec) + + assert lines == ["↻ only: codex → kiro"] + assert state["resume_changes"] == [ + {"at": frozen_now, "field": "only", "from": "codex", "to": "kiro"}, + ] + + +def test_replace_compares_values_as_given(statefile, frozen_now): + """`none` の正規化は呼び出し側の責務。正規化済みの `[]` と `None` をそのまま比べる。""" + state = {"verify_commands": ["pytest -q"], "only": "codex", "resume_changes": []} + spec = [ + statefile.ResumeField("verify_commands", "verify_commands", "replace"), + statefile.ResumeField("only", "only", "replace"), + ] + + lines = statefile.apply_resume_args(state, _args(verify_commands=[], only="codex"), spec) + + assert lines == ["↻ verify_commands: ['pytest -q'] → []"] + assert state["verify_commands"] == [] + assert state["only"] == "codex" + + +def test_several_fields_are_handled_in_one_call_in_spec_order(statefile, frozen_now): + state = {"max_rounds": 12, "rotate_after": 8, "host": "claude", "resume_changes": []} + spec = [ + statefile.ResumeField("max_rounds", "max_rounds", "replace"), + statefile.ResumeField("rotate_after", "rotate_after", "replace"), + statefile.ResumeField("host", "host", "notify"), + ] + + lines = statefile.apply_resume_args( + state, _args(max_rounds=20, rotate_after=4, host="codex"), spec, + ) + + assert lines == [ + "↻ max_rounds: 12 → 20", + "↻ rotate_after: 8 → 4", + "ℹ --host は再開では反映しません(状態: claude / 指定: codex)", + ] + assert (state["max_rounds"], state["rotate_after"], state["host"]) == (20, 4, "claude") + assert [c["field"] for c in state["resume_changes"]] == ["max_rounds", "rotate_after"] + + +def test_resume_field_is_a_named_tuple(statefile): + f = statefile.ResumeField("max_rounds", "max_rounds", "replace") + assert (f.arg, f.key, f.mode) == ("max_rounds", "max_rounds", "replace") + assert tuple(f) == ("max_rounds", "max_rounds", "replace") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py b/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py index f68e0328..134de695 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py @@ -205,3 +205,72 @@ def test_review_assign_rejects_a_bad_round(assignment): def test_review_assign_rejects_an_unknown_host(assignment): with pytest.raises(assignment.AssignmentError): assignment.review_assign(1, "gemini") + + +# ---------- 席の埋め方と席の名前(#727。cross-review が使う) ---------- +# +# 変更前の席の割り当て(`review_assign`)を期待値に使えるよう、同じファイルに置く。 +# `review_assign` のテストは P7 で消す。 + +def test_review_seats_match_review_assign_for_three_available(assignment): + """AC8: 使える者が 3 者のとき、変更前の輪番と同じ値になる(4 ホスト × ラウンド 1〜12)。""" + for host in assignment.HOST_RUNTIMES: + pool = assignment.review_pool(host) + for round_no in range(1, 13): + assert assignment.review_seats(round_no, pool, []) == \ + assignment.review_assign(round_no, host), f"host={host} round={round_no}" + + +def test_review_seats_with_four_available_give_each_two_turns(assignment): + """AC9: 使える者が 4 者なら毎ラウンド 2 席で、ラウンド 1〜4 で各者がちょうど 2 回。""" + available = list(assignment.ALL_RUNTIMES) + seats = [assignment.review_seats(r, available, []) for r in range(1, 5)] + assert all(len(s) == 2 for s in seats) + counts = {name: sum(name in s for s in seats) for name in available} + assert counts == {name: 2 for name in available} + + +def test_review_seats_with_two_available_return_both_every_round(assignment): + """AC10""" + for round_no in range(1, 5): + assert assignment.review_seats(round_no, ["codex", "kiro"], []) == ["codex", "kiro"] + + +def test_review_seats_with_one_available_fill_from_fallback_or_second_seat(assignment): + """AC11""" + assert assignment.review_seats(1, ["codex"], ["claude"]) == ["codex", "claude"] + assert assignment.review_seats(1, ["codex"], []) == ["codex", "codex-2"] + + +def test_review_seats_skip_a_fallback_that_is_already_available(assignment): + """埋め合わせの候補が使える者に含まれるときは飛ばす(同じ席の名前を 2 つ返さない)。""" + assert assignment.review_seats(1, ["claude"], ["claude"]) == ["claude", "claude-2"] + + +def test_review_seats_with_none_available_use_fallback_twice(assignment): + """AC12""" + assert assignment.review_seats(1, [], ["claude"]) == ["claude", "claude-2"] + with pytest.raises(assignment.AssignmentError): + assignment.review_seats(1, [], []) + + +def test_review_seats_reject_a_bad_round(assignment): + with pytest.raises(assignment.AssignmentError): + assignment.review_seats(0, ["codex", "kiro"], []) + + +def test_seat_runtime_strips_the_suffix(assignment): + """AC13""" + assert assignment.seat_runtime("kiro-2") == "kiro" + assert assignment.seat_runtime("kiro") == "kiro" + + +@pytest.mark.parametrize("seat", ["gemini", "kiro-1", "kiro-10", "kiro-2-3"]) +def test_seat_runtime_rejects_a_malformed_seat(assignment, seat): + """AC13""" + with pytest.raises(assignment.AssignmentError): + assignment.seat_runtime(seat) + + +def test_seat_pattern_matches_the_documented_form(assignment): + assert assignment.SEAT_PATTERN.pattern == r"^(claude|codex|agy|kiro)(-[2-9])?$" From 47384be070f17f42dc7515eef0243a020c80aa44 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:06:09 +0000 Subject: [PATCH 075/217] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E8=BF=BD=E5=8A=A0=20?= =?UTF-8?q?=E2=80=94=20critique-round=20/=20rotate-pr=20(squash)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - critique-round.sh: collect-critiques 失敗時の終了コード素通し分岐を固定 (R1-003) - rotate-pr.sh: squash モードでの新 PR 作成失敗時の旧 PR 復旧経路を固定 (R1-005) Item-Id: R1-003 Round: 1 Impl-Runtime: agy Impl-Model: default --- .../cross-review/tests/test_critiques.py | 40 +++++++++++++++++++ .../tests/test_rotate_pr_queue.py | 34 +++++++++++++++- 2 files changed, 72 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/skills/cross-review/tests/test_critiques.py b/plugins/ndf/skills/cross-review/tests/test_critiques.py index 6303258e..7c262dd0 100644 --- a/plugins/ndf/skills/cross-review/tests/test_critiques.py +++ b/plugins/ndf/skills/cross-review/tests/test_critiques.py @@ -618,6 +618,46 @@ def test_a_retry_launches_only_the_agents_requested_by_collect(tmp_path): assert collects.read_text(encoding="utf-8").strip() == "2" +def test_collect_failure_propagates_exit_code_without_retry(tmp_path): + """現状固定: collect-critiques が 7 以外(例: 5)を返したとき、終了コードを素通しして直ちに終了する。""" + script_dir = tmp_path / "scripts" + script_dir.mkdir() + shutil.copy2(SCRIPTS / "critique-round.sh", script_dir / "critique-round.sh") + calls = tmp_path / "critique-calls.txt" + collects = tmp_path / "collect-count.txt" + + (script_dir / "_tmpdir.sh").write_text( + 'tmpdir() { printf "%s\\n" "$CROSS_REVIEW_TMP_DIR"; }\n', encoding="utf-8") + (script_dir / "critique.sh").write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$1" >> "$CRITIQUE_CALLS"\n' + 'touch "$CROSS_REVIEW_TMP_DIR/$1-critique-pr$2.pid"\n', encoding="utf-8") + (script_dir / "monitor.py").write_text( + "#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + (script_dir / "state.py").write_text( + "#!/usr/bin/env bash\n" + 'count=0; [ ! -f "$COLLECT_COUNT" ] || count=$(cat "$COLLECT_COUNT")\n' + 'count=$((count + 1)); printf "%s\\n" "$count" > "$COLLECT_COUNT"\n' + "exit 5\n", encoding="utf-8") + for name in ("critique.sh", "monitor.py", "state.py"): + (script_dir / name).chmod(0o755) + + env = dict( + os.environ, + CROSS_REVIEW_TMP_DIR=str(tmp_path), + CRITIQUE_CALLS=str(calls), + COLLECT_COUNT=str(collects), + ) + result = subprocess.run( + ["bash", str(script_dir / "critique-round.sh"), str(PR), "1", "agy", "kiro"], + capture_output=True, text=True, env=env, check=False, + ) + + assert result.returncode == 5 + assert collects.read_text(encoding="utf-8").strip() == "1" + assert calls.read_text(encoding="utf-8").splitlines() == ["agy", "kiro"] + + def test_the_stale_pidfile_is_removed_even_without_targets(tmp_dir, tmp_path): """捨てるのは、対象が無くて起動しない経路より前である。""" work = tmp_path / "work" diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py index 3430718c..206e6225 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py @@ -221,7 +221,7 @@ def __init__(self, tmp_path: pathlib.Path) -> None: encoding="utf-8", ) - def run(self, create_ok: bool) -> subprocess.CompletedProcess[str]: + def run(self, create_ok: bool, mode: str = "light") -> subprocess.CompletedProcess[str]: env = { **os.environ, "PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}", @@ -231,7 +231,7 @@ def run(self, create_ok: bool) -> subprocess.CompletedProcess[str]: "GH_CREATE": "ok" if create_ok else "fail", } return subprocess.run( - ["bash", str(ROTATE), "execute", str(_STATE_PR), "--mode", "light"], + ["bash", str(ROTATE), "execute", str(_STATE_PR), "--mode", mode], capture_output=True, text=True, timeout=180, env=env, ) @@ -273,6 +273,36 @@ def test_a_create_success_closes_the_old_pr_and_opens_the_new_pr(rotation: _Rota assert not any(c.startswith("pr reopen") for c in rotation.gh_calls()) +def test_a_create_failure_in_squash_mode_reopens_the_old_pr_and_emits_no_new_pr( + rotation: _Rotation, +) -> None: + """現状固定: squash モードでも新 PR 作成が失敗すると非ゼロ終了で旧 PR が open へ戻り、NEW_PR は出ない。""" + out = rotation.run(create_ok=False, mode="squash") + + assert out.returncode != 0, out.stderr + states = rotation.pr_states() + assert states[str(_OLD_PR)] == "open" # reopen で戻る + assert str(_NEW_PR) not in states # 新 PR は作られていない + assert "NEW_PR=" not in out.stdout # 成功結果を出力していない + joined = rotation.gh_calls() + assert any(c.startswith(f"pr close {_OLD_PR}") for c in joined) + assert any(c.startswith(f"pr reopen {_OLD_PR}") for c in joined) + + +def test_a_create_success_in_squash_mode_closes_the_old_pr_and_opens_the_new_pr( + rotation: _Rotation, +) -> None: + """比較用: squash モードで作成が成功すると旧 PR は closed、新 PR は open、NEW_PR が作成結果を指す。""" + out = rotation.run(create_ok=True, mode="squash") + + assert out.returncode == 0, out.stderr + states = rotation.pr_states() + assert states[str(_OLD_PR)] == "closed" + assert states[str(_NEW_PR)] == "open" + assert f"NEW_PR={_NEW_PR}" in out.stdout + assert not any(c.startswith("pr reopen") for c in rotation.gh_calls()) + + # ---- execute の引数検証(R2-004、現状固定) ---- # # `--mode` の値検証と未知フラグの検出は gh/git を一切呼ばない純粋な引数解析であり、 From c76ae1913eb15549c87240b76ad352cbc7f92ce6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:06:57 +0000 Subject: [PATCH 076/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md index 73f013c4..e72b66b3 100644 --- a/issues/refactoring-plan-rf790.md +++ b/issues/refactoring-plan-rf790.md @@ -12,7 +12,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | codex / agy | 検証中 | 1 | +| boundary | unit | — | codex / agy | 採用 | 1 | **なぜ**: measure 関数に None や辞書以外の型が渡されたとき、空辞書にフォールバックして例外なく指標辞書(pr, prs, rounds, methods, cost, convergence)を返す境界値の振る舞いが固定されていない @@ -24,7 +24,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | integration | — | kiro | 検証中 | 1 | +| branch | integration | — | kiro | 採用 | 1 | **なぜ**: run_round を通す既存テストは 2 本とも指摘 0 件で、collect-critiques が 1 回目に 0 を返して 1 回で抜ける経路しか固定していない。exit 7 と CRITIQUE_RETRY_AGENTS を受けて 2 回目の起動を回すループ本体(for _attempt in 1 2)はどのテストも通っていない。 @@ -38,7 +38,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | integration | — | kiro | 未着手 | 0 | +| error | integration | — | kiro | 検証中 | 1 | **なぜ**: collect-critiques が 7 以外を返したときに exit "$COLLECT_RC" でその終了コードを素通しする分岐が固定されていない。既存テストは 0 で抜ける経路だけを見ており、失敗の終了コードがラウンドの外へ伝わるかを誰も確かめていない。 @@ -52,7 +52,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| normal | integration | — | codex | 検証中 | 1 | +| normal | integration | — | codex | 採用 | 1 | **なぜ**: 公開入口 prepare は state、GitHub の PR メタデータ、git log/diff をつないで prepare.json と eval 用の出力を作るが、既存テストは生成済み prepare.json を与えるだけで、この経路を実行していない @@ -64,7 +64,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | integration | — | codex | 未着手 | 0 | +| error | integration | — | codex | 検証中 | 1 | **なぜ**: 新 PR 作成失敗時に旧 PR を reopen する経路は light モードだけ固定され、同じ ERR trap を使う squash モードでは未固定である From 841d3c5597a3c772f209cafdfab8997af9183184 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:14:55 +0000 Subject: [PATCH 077/217] =?UTF-8?q?Add:=20cross-review=20=E3=81=AE?= =?UTF-8?q?=E5=88=9D=E6=9C=9F=E5=8C=96=E3=83=BB=E6=8B=85=E5=BD=93=E3=83=BB?= =?UTF-8?q?=E5=86=8D=E9=96=8B=E3=83=BB=E5=AE=8C=E4=BA=86=E5=A0=B1=E5=91=8A?= =?UTF-8?q?=E3=82=92=E5=85=B1=E9=80=9A=E5=B1=A4=E3=81=B8=E8=BC=89=E3=81=9B?= =?UTF-8?q?=E6=9B=BF=E3=81=88=E3=82=8B=EF=BC=88#727=20#687=20#478=20#648?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新規の初期化: 使える者の解決と止めない確認を呼び、participants と resume_changes を書く。 使える者が 2 者に満たなければホスト、次に同じランタイムの 2 つ目で席を埋める - 担当の読み出し: ラウンドの記録 → 1 者指定 → 参加者 → ホストの輪番 → 従来の 2 者の順へ。 前ラウンドの検査もそのラウンドの担当を読む - 再開: 明示的に渡した引数を反映し、反映しない引数を知らせる。担当に関わる引数を渡した ときだけ確認し直して参加者を作り直す - 完了報告: 「参加した者」の節を足す - 結果の受け口: 担当の引数を席の名前の形の検査へ - テスト 48 件(test_state_resume_args.py / test_seat_names.py を新設) Co-Authored-By: Claude Opus 5 (1M context) --- .../ndf/skills/cross-review/scripts/state.py | 347 +++++++++++++++--- .../cross-review/tests/test_seat_names.py | 78 ++++ .../tests/test_state_resume_args.py | 264 +++++++++++++ .../tests/test_state_review_pool.py | 345 ++++++++++++++++- .../tests/test_state_round_guard.py | 26 ++ 5 files changed, 999 insertions(+), 61 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_seat_names.py create mode 100644 plugins/ndf/skills/cross-review/tests/test_state_resume_args.py diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index f0a02e60..71d373bd 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -15,6 +15,7 @@ import argparse import datetime as _dt +import functools import json import os import pathlib @@ -34,6 +35,7 @@ import assignment # noqa: E402 import auth # noqa: E402 import post_queue # noqa: E402 +import statefile # noqa: E402 再開の反映(#727 / #648) import run_metrics # noqa: E402 実行の要約(#662) @@ -1537,11 +1539,80 @@ def _print_init_result( print(f"RESUMED={'1' if resumed else '0'}") +# 再開で渡した引数の反映の表(#727 / #648 の決定 13)。**状態ファイルに載る引数は、 +# この表のどちらかに必ず載る。** 載らないのは状態に載らない 3 つ(作業ツリー・観点・ +# 追加指示のファイル)だけである。`replace` は状態へ書いて記録へ積み、`notify` は +# 状態と違うときだけ「反映しない」と知らせる。 +REVIEW_RESUME_FIELDS = ( + statefile.ResumeField("max_rounds", "max_rounds", "replace"), + statefile.ResumeField("rotate_after", "rotate_after", "replace"), + statefile.ResumeField("only", "only", "replace"), + statefile.ResumeField("verify_command", "verify_commands", "replace"), + statefile.ResumeField("verify_exit_code", "verify_exit_codes", "replace"), + statefile.ResumeField("host", "host", "notify"), +) + +# 参加者を作り直す引数(決定 14)。どれかを渡した再開だけが確認をやり直す。 +PARTICIPANT_ARGS = ("only", "include", "exclude", "require_all") + + +def _apply_resume_args_block(st: dict[str, Any], args: argparse.Namespace) -> bool: + """再開で渡した引数を状態へ反映し、何か変えたら True を返す(#727 / #648)。 + + 担当に関わる引数(`--only` / `--include` / `--exclude` / `--require-all`)を渡した + ときだけ、**渡さなかった引数を状態ファイルの値で補って**使える者の解決をやり直す + (決定 14)。作り直しの失敗は状態を書き換える前に起きる(`_resolve_reviewers` を + 先に呼び、通ってから `st` を書く)。 + """ + only, include, exclude = _normalize_participant_args(args) + before = len(st.get("resume_changes") or []) + + # **`--only none` はここで処理する。** 正規化した `None` を表へ渡すと「未指定」と + # 区別できず、指定を外す操作が黙って捨てられる(決定 15)。 + args_copy = argparse.Namespace(**vars(args)) + args_copy.only = only + if getattr(args, "only", None) == NONE_WORD: + args_copy.only = None + if st.get("only") is not None: + old = st.get("only") + st["only"] = None + st.setdefault("resume_changes", []).append( + {"at": statefile.now(), "field": "only", "from": old, "to": None}) + info(f"↻ only: {old} → None") + + for line in statefile.apply_resume_args(st, args_copy, REVIEW_RESUME_FIELDS): + info(line) + + if any(getattr(args, name, None) is not None for name in PARTICIPANT_ARGS): + old_participants = st.get("participants") + recorded = old_participants or {} + try: + host = st.get("host") or assignment.detect_host(getattr(args, "host", None))[0] + except assignment.AssignmentError as e: + die(str(e), code=1) + raise + rebuild = argparse.Namespace( + only=st.get("only"), + include=include if include is not None else list(recorded.get("included") or []), + exclude=exclude if exclude is not None else list(recorded.get("excluded") or []), + require_all=(args.require_all if getattr(args, "require_all", None) is not None + else bool(recorded.get("require_all"))), + ) + participants = _resolve_reviewers(host, rebuild) + st["participants"] = participants + st.setdefault("resume_changes", []).append( + {"at": statefile.now(), "field": "participants", + "from": old_participants, "to": participants}) + + return len(st.get("resume_changes") or []) > before + + def _resume_from_state( pr: object, repo: str, worktree: str, manual_extra_review: str, + args: argparse.Namespace, ) -> bool: """既存 state からの再開経路。 @@ -1563,6 +1634,10 @@ def _resume_from_state( if st.get("final") is not None: return False state_changed = False + # 再開で渡した引数の反映(#727 / #648)。状態ファイルを読んだ直後に行い、 + # 作り直しの失敗はここで終了コードへ出る(以降の書き込みへ進まない)。 + if _apply_resume_args_block(st, args): + state_changed = True if "auto_review_instructions" not in st: changed_files = _fetch_changed_files(pr, st.get("repo") or repo) categories = _classify_changed_files(changed_files) @@ -1641,7 +1716,7 @@ def cmd_init(args: argparse.Namespace) -> None: # worktree ディレクトリが副作用で作成され exists() が常に true になる。 # そのため _tmp_dir() 呼び出しは worktree 作成/確認の後に行う。 - if _resume_from_state(pr, repo, worktree, manual_extra_review): + if _resume_from_state(pr, repo, worktree, manual_extra_review, args): return _init_new_state(args, pr, repo, worktree, manual_extra_review) @@ -1672,6 +1747,7 @@ class _InitWorkspaceContext(NamedTuple): class _InitialAssignment(NamedTuple): host: str host_source: str + participants: dict[str, Any] class _InitialStateContext(NamedTuple): @@ -1789,27 +1865,30 @@ def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: except assignment.AssignmentError as e: die(str(e)) raise - reviewers = assignment.review_pool(host) - info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") - _validate_only(args.only, host) - # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 - # **確かめるのは実際に起動する担当だけである。** - auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) - return _InitialAssignment(host=host, host_source=host_source) + info(f"ホストの判定: {host}({host_source})") + # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに + # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 + participants = _resolve_reviewers(host, args) + return _InitialAssignment( + host=host, host_source=host_source, participants=participants) def _build_initial_review_state( args: argparse.Namespace, ctx: _InitialStateContext, ) -> dict[str, Any]: """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source = ctx.assignment + host, host_source, participants = ctx.assignment + only, _include, _exclude = _normalize_participant_args(args) return { "started_at": _now(), "host": host, "host_source": host_source, - "max_rounds": args.max_rounds, - "rotate_after": args.rotate_after, - "only": args.only, + # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) + "max_rounds": args.max_rounds if args.max_rounds is not None else 12, + "rotate_after": args.rotate_after if args.rotate_after is not None else 8, + "only": only, + "participants": participants, + "resume_changes": [], "current_pr": ctx.pr, "worktree_path": ctx.pr_ctx.worktree, "tmp_dir": str(ctx.ws_ctx.tmp_dir), @@ -1891,56 +1970,156 @@ def _finalize_initial_state( def _round_reviewers(st: dict[str, Any], round_no: int) -> list[str]: - """そのラウンドのレビュー担当を返す。 - - **先に当たったものを採る。** ラウンドに記録があればそれを、無ければホストからの - 輪番を、ホストも無ければこれまでの 2 者を返す。 - - | 状態 | 返る担当 | - | --- | --- | - | ラウンドに `reviewers` がある | その値 | - | 状態ファイルに `host` がある | `assignment.review_assign(round_no, host)` | - | どちらも無い(古い状態ファイル) | `LEGACY_AGENTS` | + """そのラウンドのレビュー担当(席の名前)を返す。 + + **先に当たったものを採る**(設計の決定 11)。ラウンドの記録を 1 者指定より先に + 見るのは、再開で 1 者指定を変えても過去のラウンドの担当が変わらないようにする + ためである。 + + | 順 | 状態 | 返る担当 | + | ---: | --- | --- | + | 1 | ラウンドに `reviewers` がある | その値 | + | 2 | `only` がある | `[only]` | + | 3 | `participants` がある | `assignment.review_seats(round_no, available, fallback)` | + | 4 | `host` がある | `assignment.review_seats(round_no, review_pool(host), [])`(変更前の輪番と同じ値) | + | 5 | どれも無い(古い状態ファイル) | `LEGACY_AGENTS` | """ + for entry in st.get("rounds") or []: + if entry.get("round") == round_no and entry.get("reviewers"): + return list(entry["reviewers"]) # **`--only` は担当そのものを絞る。** 輪番が返す 2 者を担当のまま残すと、指定した # 1 者が含まれないラウンドで誰も起動されない。そのとき全員が「指定によるスキップ」 # として扱われ、レビューが行われていないのに収束する。 only = st.get("only") if only: return [only] - for entry in st.get("rounds") or []: - if entry.get("round") == round_no and entry.get("reviewers"): - return list(entry["reviewers"]) + participants = st.get("participants") + if participants: + return assignment.review_seats( + max(round_no, 1), + list(participants.get("available") or []), + list(participants.get("fallback") or []), + ) host = st.get("host") if host: - return assignment.review_assign(max(round_no, 1), host) + return assignment.review_seats( + max(round_no, 1), assignment.review_pool(host), []) return list(LEGACY_AGENTS) -def _auth_targets(only: str | None, host: str) -> list[str]: - """認証を確かめる相手。**実際に起動する担当だけを返す。** +# ---------- 参加者の引数と使える者の解決(#727) ---------- +# +# 名前の検査は 2 段に分かれる。綴り(4 つの名前か `none`)は argparse の型が弾き +# (終了コード 2)、母集合との関係は共通層の `resolve_participants` が弾く(終了コード 1)。 + +NONE_WORD = "none" + + +def _runtime_or_none(value: str) -> str: + """`--only` の型。4 つの名前か `none`(決定 15: 再開で指定を外す予約語)。""" + if value == NONE_WORD or value in assignment.ALL_RUNTIMES: + return value + raise argparse.ArgumentTypeError( + f"{'/'.join(assignment.ALL_RUNTIMES)} か {NONE_WORD} を指定してください: {value}") + - `--only` で 1 者へ絞ったときに母集合の全員を確かめると、そのラウンドで起動しない - CLI の未認証で初期化が失敗する。 +def _runtime_list(value: str) -> list[str]: + """`--exclude` / `--include` の型。カンマ区切りの 4 つの名前、または `none`。 + + `none` は `["none"]` のまま返し、`_normalize_participant_args` が空の一覧へ直す。 """ - return [only] if only else assignment.review_pool(host) + names = [n.strip() for n in value.split(",") if n.strip()] + for n in names: + _runtime_or_none(n) + if not names: + raise argparse.ArgumentTypeError("名前を 1 つ以上指定してください") + return names + + +def _seat_arg(value: str) -> str: + """席の名前の型(`read-result` の担当)。形は `assignment.SEAT_PATTERN`。""" + try: + assignment.seat_runtime(value) + except assignment.AssignmentError as e: + raise argparse.ArgumentTypeError(str(e)) + return value -def _validate_only(only: str | None, host: str) -> str | None: - """`--only` が母集合に含まれることを確かめる。含まなければ起動する前に弾く。 +def _normalize_participant_args( + args: argparse.Namespace, +) -> tuple[str | None, list[str] | None, list[str] | None]: + """`--only` / `--include` / `--exclude` を読み手の形へ直し `(only, include, exclude)` を返す。 - ホスト自身や、参加しないランタイムを指定しても、そのラウンドは 1 者も起動しない。 - **起動してから気づくと、レビューの無いラウンドが記録に残る。** + `only` の `none` は `None`。`include` / `exclude` は `action="append"` の入れ子を + 平らにし(`--exclude agy --exclude kiro` と `--exclude agy,kiro` が同じになる)、 + `none` を含めば `[]`。未指定は `None` のまま返す(再開の経路が「渡さなかった」と + 読むため)。`none` と名前の混在は終了コード 1。 """ - if only is None: - return None - pool = assignment.review_pool(host) - if only not in pool: - die( - f"--only に指定できるのはレビュワーの母集合だけです: {' / '.join(pool)}" - f"(指定: {only}、ホスト: {host})" + only = getattr(args, "only", None) + if only == NONE_WORD: + only = None + + def _flatten(option: str) -> list[str] | None: + raw = getattr(args, option, None) + if raw is None: + return None + names: list[str] = [] + for group in raw: + names.extend(group if isinstance(group, list) else [group]) + if NONE_WORD in names: + if len(names) > 1: + die(f"--{option} に {NONE_WORD} と名前を同時に指定できません: {', '.join(names)}") + return [] + return names + + return only, _flatten("include"), _flatten("exclude") + + +def _resolve_reviewers(host: str, args: argparse.Namespace) -> dict[str, Any]: + """使える者を決め、状態ファイルの `participants`(`fallback` を含む 8 項目)を返す。 + + 母集合は `review_pool(host)`。確認は止めない確認(`auth.probe_auth`)で、通らない者は + 外して続ける。使える者が 2 者に満たなければホストを確かめ、通れば `fallback` に + 置く(決定 9)。1 者指定があればホストを確かめず `fallback` は空。名前の矛盾・ + `--require-all` で欠け・0 者で埋め合わせも無い、は終了コード 1(状態ファイルは + この関数の後に書かれるため作られない)。 + """ + only, include, exclude = _normalize_participant_args(args) + probe = functools.partial(auth.probe_auth, info=info) + try: + pool = assignment.review_pool(host) + resolved = assignment.resolve_participants( + pool, host=host, include=include or [], exclude=exclude or [], only=only, + probe=probe, require_all=bool(getattr(args, "require_all", None)), ) - return only + except assignment.AssignmentError as e: + die(str(e), code=1) + raise + available = resolved.available + info(f"ホスト: {host} / 母集合: {' / '.join(pool)}" + f" / 使える者: {' / '.join(available) or 'なし'}") + for name, reason in resolved.unavailable.items(): + info(f"⚠ {name} を担当から外しました({reason})") + + fallback: list[str] = [] + if only is None and len(available) < 2: + results, skipped = auth.probe_auth([host], info=info) + if skipped or results.get(host, {}).get("ok", False): + fallback = [host] + if not available and not fallback: + die(f"使える者がいません: 母集合 {' / '.join(pool)} の全員が確認を通らず、" + f"ホスト {host} も通りません({results.get(host, {}).get('detail', '')})", + code=1) + if fallback and host not in available: + info(f"⚠ 使える者が {len(available)} 者のため、席をホスト({host})で埋めます" + "(観点が減ります)") + else: + info(f"⚠ 使える者が {len(available)} 者のため、席を同じランタイムの 2 つ目で" + "埋めます(観点が減ります)") + + state = resolved.to_state() + state["fallback"] = fallback + return state def _is_pass(intent: str | None, severity: dict[str, int] | None) -> bool: @@ -2028,10 +2207,15 @@ def _guard_previous_round(st: dict[str, Any], prev: dict[str, Any]) -> None: if verdict is None: # 判定の結果を持たない古い状態ファイルは、保存された重要度から判定し直す。 # 項目が欠けたラウンドは結果なしであり、修正の記録を求める対象ではない。 - if _no_result_agents(prev, st.get("only")): + # **数える相手はそのラウンドの担当である**(決定 11)。`codex` / `agy` で数えると、 + # 担当が `agy` + `kiro` のラウンドで `codex` を結果なしと読み、修正の記録が + # 無いまま次のラウンドへ通す。 + reviewers = prev.get("reviewers") or _round_reviewers(st, prev.get("round") or 1) + if _no_result_agents(prev, st.get("only"), reviewers): verdict = "no_result" else: - verdict = "approved" if _round_passes(prev, st.get("only")) else "changes_requested" + verdict = ("approved" if _round_passes(prev, st.get("only"), reviewers) + else "changes_requested") fix = prev.get("fix") if verdict == "changes_requested" and not fix: die( @@ -4258,6 +4442,60 @@ def _print_round_summary(rounds: list) -> None: print() +def _print_participants(st: dict) -> None: + """cmd_report の「参加した者」の節を出す(#727 の AC24)。 + + 途中から誰を外したか・誰が確認を通らなかったかを、完了報告だけで読めるようにする。 + 参加者の記録を持たない状態ファイル(この変更の前に始めた実行)では「記録なし」と出す。 + """ + print("## 参加した者") + p = st.get("participants") + if not p: + print("- 使える者: 記録なし") + print() + return + + def _names(values) -> str: + return " / ".join(values) if values else "なし" + + unavailable = p.get("unavailable") or {} + if unavailable: + failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) + elif p.get("probe_skipped"): + failed = "確認を飛ばした(NDF_SKIP_AUTH_CHECK)" + else: + failed = "なし" + + print(f"- 母集合: {_names(p.get('pool'))}") + print(f"- 使える者: {_names(p.get('available'))}") + print(f"- --exclude で外した者: {_names(p.get('excluded'))}") + print(f"- --include で足した者: {_names(p.get('included'))}") + print(f"- 確認を通らなかった者: {failed}") + print(f"- 席の埋め合わせ: {_names(p.get('fallback'))}") + + changes = st.get("resume_changes") or [] + if not changes: + print("- 再開で変えた値: なし") + else: + print("- 再開で変えた値:") + for c in changes: + print(f" - {c.get('at')} {c.get('field')}: " + f"{_resume_value(c.get('from'))} → {_resume_value(c.get('to'))}") + print() + + +def _resume_value(value: object) -> str: + """再開で変えた値の 1 つを 1 行へ収める。参加者の記録は使える者だけを出す。""" + if isinstance(value, dict): + available = value.get("available") + if available is not None: + return f"使える者={'/'.join(available) or 'なし'}" + return "…" + if isinstance(value, list): + return ",".join(str(v) for v in value) or "なし" + return str(value) + + def _print_sweep(st: dict) -> None: """cmd_report の最終スイープの節を出す。""" sweep = st.get("sweep") @@ -4323,6 +4561,7 @@ def cmd_report(args: argparse.Namespace) -> None: state_str = "closed" if h.get("closed_at") else "open" print(f"- #{h['pr']} ({state_str}, {h.get('rounds', 0)} rounds)") print() + _print_participants(st) _print_round_summary(st["rounds"]) _print_sweep(st) _print_deferred_nits(st) @@ -4349,11 +4588,21 @@ def build_parser() -> argparse.ArgumentParser: sp = sub.add_parser("init", help="Step 0 — state 初期化 or 再開") sp.add_argument("pr", type=int) - sp.add_argument("--max-rounds", type=int, default=12) - sp.add_argument("--rotate-after", type=int, default=8) + sp.add_argument("--max-rounds", type=int, default=None) + sp.add_argument("--rotate-after", type=int, default=None) sp.add_argument( - "--only", choices=list(assignment.ALL_RUNTIMES), default=None, + "--only", type=_runtime_or_none, default=None, help="片方だけで回す(デバッグ用)") + sp.add_argument( + "--exclude", action="append", type=_runtime_list, default=None, + help="母集合から外す者。カンマ区切り・繰り返し可。再開で `none` を渡すと空へ戻す") + sp.add_argument( + "--include", action="append", type=_runtime_list, default=None, + help="母集合に足す者(ホストも足せる)。カンマ区切り・繰り返し可。`none` で空へ戻す") + sp.add_argument( + "--require-all", dest="require_all", + action=argparse.BooleanOptionalAction, default=None, + help="確認を通らない者が 1 者でもいれば失敗する(従来の関門)。既定は外して続ける") sp.add_argument( "--host", choices=list(assignment.HOST_RUNTIMES), default=None, help="この収束ループを起動している CLI。省略時は環境変数から推定する") @@ -4386,7 +4635,7 @@ def build_parser() -> argparse.ArgumentParser: sp = sub.add_parser("read-result", help="Step 2.4 — review result を state にマージ") sp.add_argument("pr", type=int) - sp.add_argument("agent", choices=list(assignment.ALL_RUNTIMES)) + sp.add_argument("agent", type=_seat_arg) sp.add_argument("--file", default=None) sp.set_defaults(func=cmd_read_result) diff --git a/plugins/ndf/skills/cross-review/tests/test_seat_names.py b/plugins/ndf/skills/cross-review/tests/test_seat_names.py new file mode 100644 index 00000000..3842fae3 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_seat_names.py @@ -0,0 +1,78 @@ +"""席の名前を受け口が通すか(#727 の AC21、結果の受け口の部分)。 + +担当の単位は「席の名前」になった(設計の決定 10)。形は `assignment.SEAT_PATTERN` +(ランタイム名か、その名前に `-2`〜`-9` を付けたもの)。使える者が 2 者に満たない +ラウンドでは、同じランタイムの 2 つ目(`claude-2`)が席に入る。**受け口がこの形を +弾くと、結果を残した担当が「結果なし」として扱われる。** + +綴りの検査は argparse の型が行い、通らなければ終了コード 2 になる。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + +PR = 4243 +SEAT = "claude-2" + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod) -> pathlib.Path: + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture(autouse=True) +def review_posted(monkeypatch, state_mod): + """投稿の実在確認は届いた前提にする。ここで見るのは席の名前である。""" + monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) + + +def _seed_state(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, + "rounds": [{"round": 1, "pr": PR, "started_at": "2026-09-19T00:00:00+00:00", + "reviewers": ["codex", SEAT]}], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +# ---------------- 引数の検査 ---------------- + +def test_the_parser_accepts_a_second_seat(state_mod): + args = state_mod.build_parser().parse_args(["read-result", "1", SEAT]) + assert args.agent == SEAT + + +def test_the_parser_still_accepts_every_runtime(state_mod): + parser = state_mod.build_parser() + for runtime in state_mod.assignment.ALL_RUNTIMES: + assert parser.parse_args(["read-result", "1", runtime]).agent == runtime + + +@pytest.mark.parametrize("seat", ["gemini", "claude-1", "claude-10", "claude_2", ""]) +def test_a_name_outside_the_seat_pattern_exits_with_two(state_mod, seat): + with pytest.raises(SystemExit) as e: + state_mod.build_parser().parse_args(["read-result", "1", seat]) + assert e.value.code == 2 + + +# ---------------- 記録の鍵 ---------------- + +def test_the_result_of_a_second_seat_is_recorded_under_its_seat_name(tmp_dir, state_mod): + """AC21: `read-result claude-2` の結果は `rounds[-1]["claude-2"]` に入る。""" + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps({ + "event": "APPROVE", "posted_as": "APPROVE", "comments_count": 0, + "review_url": "https://example/pr/1#1", "by_severity": {}, + })) + + state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent=SEAT, file=str(rfile))) + + st = json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + assert st["rounds"][-1][SEAT]["intent"] == "APPROVE" diff --git a/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py b/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py new file mode 100644 index 00000000..72baa943 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py @@ -0,0 +1,264 @@ +"""再開で渡した引数を状態ファイルへ反映する(#727 / #648 の AC25〜AC29)。 + +**黙って捨てる引数を残さない**(設計の決定 13)。状態ファイルに載る引数は、反映の表の +「反映する」か「知らせる」のどちらかに必ず載る。担当に関わる引数(`--only` / +`--include` / `--exclude` / `--require-all`)を渡した再開だけが、使える者の解決を +やり直して参加者を作り直す(決定 14)。渡さなかった引数は状態ファイルの値で補う。 +""" +from __future__ import annotations + +import json +import pathlib + +import pytest + +PR = 6100 +REPO = "acme/demo" + + +def _participants(**over) -> dict: + p = { + "pool": ["codex", "agy", "kiro"], + "included": [], "excluded": [], + "available": ["codex", "agy", "kiro"], + "unavailable": {}, "probe_skipped": False, "require_all": False, + "fallback": [], + } + p.update(over) + return p + + +def _state(tmp_dir: pathlib.Path, **over) -> pathlib.Path: + st = { + "started_at": "2026-09-19T00:00:00+09:00", + "host": "claude", + "host_source": "explicit", + "max_rounds": 12, + "rotate_after": 8, + "only": None, + "participants": _participants(), + "resume_changes": [], + "current_pr": PR, + "worktree_path": str(tmp_dir), + "tmp_dir": str(tmp_dir), + "repo": REPO, + "head_branch": "feat/x", + "base_branch": "develop", + "auto_review_instructions": "", + "review_instructions": "", + "verify_commands": [], + "verify_exit_codes": [], + "pr_history": [{"pr": PR, "opened_at": "x", "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "carried_over": None, + "final": None, + } + st.update(over) + path = tmp_dir / f"cross-review-pr{PR}-state.json" + path.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") + return path + + +@pytest.fixture() +def resume(state_mod, monkeypatch, tmp_path): + """再開の入口を、GitHub にも git にも触れずに通す。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO) + monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: REPO) + monkeypatch.setattr(state_mod, "_auto_flush", lambda pr: None) + monkeypatch.setattr(state_mod, "_record_carried_over", lambda *a, **k: False) + monkeypatch.setattr(state_mod, "_sync_worktree", lambda *a, **k: None) + monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda path: False) + monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) + monkeypatch.setattr(state_mod, "_sync_before_round", lambda st, pr: None) + calls: list[list[str]] = [] + + def probe(runtimes, *, info, env=None): + calls.append(list(runtimes)) + return ({r: {"command": r, "ok": True, "detail": ""} for r in runtimes}, False) + + monkeypatch.setattr(state_mod.auth, "probe_auth", probe) + + def run(*argv: str) -> dict: + args = state_mod.build_parser().parse_args( + ["init", str(PR), "--worktree", str(tmp_path), *argv]) + state_mod.cmd_init(args) + return json.loads((tmp_path / f"cross-review-pr{PR}-state.json").read_text()) + + run.calls = calls + run.tmp_path = tmp_path + return run + + +def _seats(state_mod, tmp_path) -> list[str]: + state_mod.cmd_start_round(type("A", (), {"pr": PR})()) + st = json.loads((tmp_path / f"cross-review-pr{PR}-state.json").read_text()) + return st["rounds"][-1]["reviewers"] + + +# ---------------- 反映する引数(AC25) ---------------- + +def test_max_rounds_is_replaced_and_recorded(resume, tmp_path, capsys): + """AC25: `--max-rounds 20` は状態へ反映され、1 行出て、記録へ 1 件積まれる。""" + _state(tmp_path) + st = resume("--max-rounds", "20") + assert st["max_rounds"] == 20 + assert "↻ max_rounds: 12 → 20" in capsys.readouterr().err + changes = [c for c in st["resume_changes"] if c["field"] == "max_rounds"] + assert len(changes) == 1 + assert changes[0]["from"] == 12 and changes[0]["to"] == 20 + assert changes[0]["at"] + + +def test_the_other_replaced_fields_are_applied_too(resume, tmp_path): + """AC25 後半: `--rotate-after` / `--verify-command` / `--verify-exit-code` も反映する。""" + _state(tmp_path, verify_commands=["pytest -q"], verify_exit_codes=[1]) + st = resume("--rotate-after", "4", "--verify-command", "ruff check", + "--verify-exit-code", "2") + assert st["rotate_after"] == 4 + # 置き換えであり、足し込みではない。 + assert st["verify_commands"] == ["ruff check"] + assert st["verify_exit_codes"] == [2] + + +def test_the_same_value_is_not_recorded(resume, tmp_path, capsys): + """同じ値を渡した再開は、行も記録も出さない。""" + _state(tmp_path) + st = resume("--max-rounds", "12") + assert st["resume_changes"] == [] + assert "max_rounds" not in capsys.readouterr().err + + +# ---------------- 渡さない再開(AC26) ---------------- + +def test_a_resume_without_arguments_changes_nothing(resume, tmp_path): + """AC26: 引数を渡さない再開では 6 項目が変わらず、確認コマンドは 1 回も呼ばれない。""" + _state(tmp_path, only="kiro", verify_commands=["pytest -q"], verify_exit_codes=[1], + participants=_participants(available=["codex", "kiro"])) + before = json.loads((tmp_path / f"cross-review-pr{PR}-state.json").read_text()) + st = resume() + for key in ("max_rounds", "rotate_after", "verify_commands", "verify_exit_codes", + "only", "participants"): + assert st[key] == before[key], key + assert resume.calls == [] + assert st["resume_changes"] == [] + + +# ---------------- 1 者指定(AC27) ---------------- + +def test_only_is_replaced_and_narrows_the_next_round(resume, state_mod, tmp_path): + """AC27: `--only codex` は `only` を書き換え、次のラウンドを 1 席にする。""" + _state(tmp_path, rounds=[{"round": 1, "pr": PR, "started_at": "x", + "reviewers": ["agy", "kiro"], "verdict": "approved", + "agy": {"intent": "APPROVE", "by_severity": {}}, + "kiro": {"intent": "APPROVE", "by_severity": {}}}]) + st = resume("--only", "codex") + assert st["only"] == "codex" + # 過去のラウンドの担当は変わらない(決定 11)。 + assert st["rounds"][0]["reviewers"] == ["agy", "kiro"] + assert _seats(state_mod, tmp_path) == ["codex"] + + +def test_only_none_clears_the_narrowing(resume, tmp_path, capsys): + """AC27 後半: `--only none` は `only` を `null` へ戻す(決定 15)。""" + _state(tmp_path, only="codex") + st = resume("--only", "none") + assert st["only"] is None + assert "↻ only: codex → None" in capsys.readouterr().err + assert [c["field"] for c in st["resume_changes"]].count("only") == 1 + + +# ---------------- 外す者・足す者(AC28) ---------------- + +def test_exclude_reruns_the_probe_and_drops_the_name(resume, state_mod, tmp_path): + """AC28: `--exclude agy` は確認をやり直し、使える者から agy を外す。""" + _state(tmp_path) + st = resume("--exclude", "agy") + assert resume.calls == [["codex", "kiro"]] + assert st["participants"]["excluded"] == ["agy"] + assert st["participants"]["available"] == ["codex", "kiro"] + assert "agy" not in _seats(state_mod, tmp_path) + + +def test_the_participants_are_recorded_as_one_change(resume, tmp_path): + """決定 16: 参加者の作り直しは、項目ごとではなく 1 件として積む。""" + _state(tmp_path) + st = resume("--exclude", "agy") + changes = [c for c in st["resume_changes"] if c["field"] == "participants"] + assert len(changes) == 1 + assert changes[0]["from"]["excluded"] == [] + assert changes[0]["to"]["excluded"] == ["agy"] + + +def test_exclude_none_clears_the_exclusions(resume, tmp_path): + """AC28: `--exclude none` は外す者を空へ戻す。""" + _state(tmp_path, participants=_participants(excluded=["agy"], available=["codex", "kiro"])) + st = resume("--exclude", "none") + assert st["participants"]["excluded"] == [] + assert st["participants"]["available"] == ["codex", "agy", "kiro"] + + +def test_unpassed_arguments_come_from_the_state_file(resume, tmp_path): + """AC28 後半: 渡さなかった引数は状態ファイルの値で補う(決定 14)。""" + _state(tmp_path, participants=_participants( + included=["claude"], available=["claude", "codex", "agy", "kiro"])) + st = resume("--exclude", "agy") + assert st["participants"]["included"] == ["claude"] + assert st["participants"]["excluded"] == ["agy"] + assert st["participants"]["available"] == ["claude", "codex", "kiro"] + + +def test_require_all_alone_rebuilds_the_participants(resume, tmp_path): + """`--require-all` だけでも作り直す(担当に関わる引数のため)。""" + _state(tmp_path) + st = resume("--require-all") + assert st["participants"]["require_all"] is True + assert resume.calls == [["codex", "agy", "kiro"]] + + +def test_a_failed_rebuild_leaves_the_state_untouched(resume, state_mod, tmp_path, monkeypatch): + """作り直しが失敗したら、状態ファイルを書き換えずに終了コード 1 で終わる。""" + path = _state(tmp_path) + before = path.read_text(encoding="utf-8") + + def probe(runtimes, *, info, env=None): + return ({r: {"command": r, "ok": False, "detail": "未認証"} for r in runtimes}, False) + + monkeypatch.setattr(state_mod.auth, "probe_auth", probe) + with pytest.raises(SystemExit) as e: + resume("--exclude", "agy", "--require-all") + assert e.value.code == 1 + assert path.read_text(encoding="utf-8") == before + + +def test_a_state_without_participants_can_be_rebuilt(resume, tmp_path): + """`participants` を持たない状態ファイル(`host` だけ)でも作り直せる。""" + path = _state(tmp_path) + st = json.loads(path.read_text(encoding="utf-8")) + del st["participants"] + path.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") + + saved = resume("--exclude", "agy") + assert saved["participants"]["available"] == ["codex", "kiro"] + changes = [c for c in saved["resume_changes"] if c["field"] == "participants"] + assert changes[0]["from"] is None + + +# ---------------- 知らせる引数(AC29) ---------------- + +def test_host_is_not_applied_but_reported(resume, tmp_path, capsys): + """AC29: `--host codex` は反映せず、1 行で知らせる。""" + _state(tmp_path) + st = resume("--host", "codex") + assert st["host"] == "claude" + err = capsys.readouterr().err + assert err.count("ℹ --host は再開では反映しません(状態: claude / 指定: codex)") == 1 + assert st["resume_changes"] == [] + + +def test_the_same_host_prints_nothing(resume, tmp_path, capsys): + """AC29 後半: 状態と同じ `--host claude` では何も出さない。""" + _state(tmp_path) + resume("--host", "claude") + assert "--host" not in capsys.readouterr().err diff --git a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py index 87204440..97d0937c 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py @@ -241,14 +241,21 @@ def test_only_narrows_the_round_reviewers(state_mod, tmp_path): def test_init_rejects_an_only_outside_the_pool(state_mod, tmp_path, monkeypatch): - """母集合の外を `--only` に指定したら、起動する前に弾く。 + """母集合の外を `--only` に指定したら、起動する前に弾く(終了コード 1)。 ホスト自身や、参加しないランタイムを指定しても、そのラウンドは 1 者も起動しない。 + 検査は共通層の `resolve_participants` が行い、`_resolve_reviewers` が終了コードへ写す。 """ - with pytest.raises(SystemExit): - state_mod._validate_only("claude", "claude") # ホスト自身 - assert state_mod._validate_only("codex", "claude") == "codex" - assert state_mod._validate_only(None, "claude") is None + calls: list[list[str]] = [] + monkeypatch.setattr(state_mod.auth, "probe_auth", _fake_probe({}, calls)) + with pytest.raises(SystemExit) as e: + state_mod._resolve_reviewers("claude", _init_args(tmp_path, only="claude")) + assert e.value.code == 1 + assert calls == [] + p = state_mod._resolve_reviewers("claude", _init_args(tmp_path, only="codex")) + assert p["available"] == ["codex"] + p = state_mod._resolve_reviewers("claude", _init_args(tmp_path)) + assert p["available"] == ["codex", "agy", "kiro"] def test_judge_returns_the_relaunch_targets_as_a_list(state_mod, tmp_path, capsys): @@ -263,17 +270,20 @@ def test_judge_returns_the_relaunch_targets_as_a_list(state_mod, tmp_path, capsy assert "RELAUNCH_AGENTS_CSV=kiro" in out -def test_auth_check_covers_only_the_reviewers_that_run(state_mod, monkeypatch): +def test_auth_check_covers_only_the_reviewers_that_run(state_mod, tmp_path, monkeypatch): """`--only` を指定したときは、実際に起動する 1 者だけを確かめる。 母集合の全員を確かめると、そのラウンドで起動しない CLI の未認証で `init` が - 失敗する。デバッグのために 1 者へ絞った意味が無くなる。 + 失敗する。デバッグのために 1 者へ絞った意味が無くなる。1 者指定は埋め合わせを + しないため、ホストも確かめない(AC18 後半)。 """ - checked: list[list[str]] = [] - monkeypatch.setattr(state_mod.auth, "check_auth", - lambda rs, **k: checked.append(list(rs)) or {}) - assert state_mod._auth_targets("kiro", "claude") == ["kiro"] - assert state_mod._auth_targets(None, "claude") == ["codex", "agy", "kiro"] + calls: list[list[str]] = [] + monkeypatch.setattr(state_mod.auth, "probe_auth", _fake_probe({}, calls)) + state_mod._resolve_reviewers("claude", _init_args(tmp_path, only="kiro")) + assert calls == [["kiro"]] + calls.clear() + state_mod._resolve_reviewers("claude", _init_args(tmp_path)) + assert calls == [["codex", "agy", "kiro"]] def test_init_fails_when_the_host_cannot_be_guessed(state_mod, monkeypatch): @@ -303,3 +313,314 @@ def test_report_shows_every_reviewer_that_took_part(state_mod, tmp_path, capsys) assert "claude=APPROVE" in out assert "kiro=REQUEST_CHANGES" in out assert "agy=APPROVE" in out + + +# ---------- 使える者の解決と新規の初期化(#727: AC14〜AC20) ---------- + +PR_INIT = 500 +REPO_INIT = "acme/demo" + + +def _fake_probe(failing: dict[str, str], calls: list[list[str]], skipped: bool = False): + """止めない確認の差し替え。`failing` の名前だけ通らず、理由を `detail` に入れる。""" + def probe(runtimes, *, info, env=None): + calls.append(list(runtimes)) + if skipped: + return {}, True + return ({r: {"command": r, "ok": r not in failing, "detail": failing.get(r, "")} + for r in runtimes}, False) + return probe + + +def _init_args(tmp_path, *argv: str, only=None): + """`init` の引数を、実際の入口(`build_parser`)と同じ形で組む。""" + words = ["init", str(PR_INIT), "--host", "claude", "--worktree", str(tmp_path / "wt")] + if only is not None: + words += ["--only", only] + words += list(argv) + return state_mod_parser().parse_args(words) + + +_PARSER = {} + + +def state_mod_parser(): + return _PARSER["p"] + + +@pytest.fixture(autouse=True) +def _parser(state_mod): + _PARSER["p"] = state_mod.build_parser() + + +@pytest.fixture() +def new_init(state_mod, monkeypatch, tmp_path): + """新規の初期化を GitHub と git に触れずに通す。""" + (tmp_path / "wt").mkdir(exist_ok=True) + monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO_INIT) + monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: + state_mod.PrMetadata(REPO_INIT, "author", "feat/x", "abc", + "develop", False, 4000, None)) + monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "viewer") + monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) + monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda path: True) + monkeypatch.setattr(state_mod, "_sync_worktree", lambda *a, **k: None) + monkeypatch.setattr(state_mod.subprocess, "run", lambda *a, **k: + __import__("subprocess").CompletedProcess(a[0], 0, stdout="", stderr="")) + monkeypatch.setattr(state_mod, "_sync_before_round", lambda st, pr: None) + calls: list[list[str]] = [] + + def run(*argv: str, failing=None, only=None): + monkeypatch.setattr(state_mod.auth, "probe_auth", _fake_probe(failing or {}, calls)) + state_mod.cmd_init(_init_args(tmp_path, *argv, only=only)) + return json.loads((tmp_path / f"cross-review-pr{PR_INIT}-state.json").read_text()) + + run.calls = calls + run.state_file = tmp_path / f"cross-review-pr{PR_INIT}-state.json" + return run + + +def _start_round(state_mod, tmp_path): + state_mod.cmd_start_round(type("A", (), {"pr": PR_INIT})()) + st = json.loads((tmp_path / f"cross-review-pr{PR_INIT}-state.json").read_text()) + return st["rounds"][-1]["reviewers"] + + +def test_a_failing_reviewer_is_dropped_and_init_still_succeeds(new_init, capsys): + """AC14: 確認を通らない者は外して続ける。状態ファイルは作られ、理由が残る。""" + st = new_init(failing={"kiro": "コマンドが見つかりません"}) + p = st["participants"] + assert p["available"] == ["codex", "agy"] + assert p["unavailable"] == {"kiro": "コマンドが見つかりません"} + assert p["pool"] == ["codex", "agy", "kiro"] + assert p["fallback"] == [] + assert p["probe_skipped"] is False + assert p["require_all"] is False + assert st["resume_changes"] == [] + assert st["max_rounds"] == 12 and st["rotate_after"] == 8 + err = capsys.readouterr().err + assert err.count("⚠ kiro を担当から外しました(コマンドが見つかりません)") == 1 + + +def test_require_all_keeps_the_old_gate(new_init, capsys): + """AC15: `--require-all` では 1 者でも欠ければ終了コード 1 で、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + new_init("--require-all", failing={"kiro": "コマンドが見つかりません"}) + assert e.value.code == 1 + assert not new_init.state_file.exists() + assert "kiro" in capsys.readouterr().err + + +def test_exclude_skips_the_probe_and_is_recorded(new_init): + """AC16: `--exclude agy` は agy を確かめず、`excluded` に残す。""" + st = new_init("--exclude", "agy") + assert new_init.calls == [["codex", "kiro"]] + assert st["participants"]["excluded"] == ["agy"] + assert st["participants"]["available"] == ["codex", "kiro"] + + +def test_repeated_and_comma_separated_exclude_are_the_same(new_init): + """AC16 後半: `--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ状態を作る。""" + a = new_init("--exclude", "agy", "--exclude", "kiro")["participants"] + new_init.state_file.unlink() + b = new_init("--exclude", "agy,kiro")["participants"] + assert a == b + assert a["excluded"] == ["agy", "kiro"] + assert a["available"] == ["codex"] + + +def test_include_adds_the_host_and_start_round_still_returns_two_seats(new_init, state_mod, tmp_path): + """AC17: `--include claude` で 4 者になり、席は 2 つのまま。""" + st = new_init("--include", "claude") + assert st["participants"]["available"] == ["claude", "codex", "agy", "kiro"] + assert st["participants"]["included"] == ["claude"] + assert len(_start_round(state_mod, tmp_path)) == 2 + + +def test_one_available_reviewer_is_backed_by_the_host(new_init, state_mod, tmp_path, capsys): + """AC18: 使える者が 1 者ならホストを確かめ、通れば席を埋める。""" + st = new_init(failing={"agy": "未認証", "kiro": "未認証"}) + assert st["participants"]["available"] == ["codex"] + assert st["participants"]["fallback"] == ["claude"] + assert new_init.calls == [["codex", "agy", "kiro"], ["claude"]] + assert "⚠ 使える者が 1 者のため、席をホスト(claude)で埋めます(観点が減ります)" in capsys.readouterr().err + assert _start_round(state_mod, tmp_path) == ["codex", "claude"] + + +def test_only_does_not_probe_the_host_and_keeps_one_seat(new_init, state_mod, tmp_path): + """AC18 後半: `--only codex` はホストを確かめず、席は 1 つ。""" + st = new_init(only="codex") + assert new_init.calls == [["codex"]] + assert st["only"] == "codex" + assert st["participants"]["fallback"] == [] + assert _start_round(state_mod, tmp_path) == ["codex"] + + +def test_no_available_reviewer_fills_both_seats_with_the_host(new_init, state_mod, tmp_path, capsys): + """AC19: 使える者が 0 者でもホストが通れば、席はホストとその 2 つ目。""" + st = new_init(failing={"codex": "x", "agy": "x", "kiro": "x"}) + assert st["participants"]["available"] == [] + assert st["participants"]["fallback"] == ["claude"] + assert _start_round(state_mod, tmp_path) == ["claude", "claude-2"] + + +def test_no_available_reviewer_and_no_host_fails(new_init, capsys): + """AC19 後半: ホストも通らなければ終了コード 1 で、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + new_init(failing={"codex": "x", "agy": "x", "kiro": "x", "claude": "x"}) + assert e.value.code == 1 + assert not new_init.state_file.exists() + assert "使える者がいません" in capsys.readouterr().err + + +def test_the_second_seat_falls_back_to_a_second_copy_when_the_host_is_unavailable(new_init, state_mod, tmp_path, capsys): + """使える者が 1 者でホストも通らなければ、同じランタイムの 2 つ目で埋める。""" + st = new_init(failing={"agy": "x", "kiro": "x", "claude": "x"}) + assert st["participants"]["fallback"] == [] + assert "席を同じランタイムの 2 つ目で埋めます" in capsys.readouterr().err + assert _start_round(state_mod, tmp_path) == ["codex", "codex-2"] + + +@pytest.mark.parametrize("argv", [ + ("--exclude", "claude"), + ("--only", "codex", "--exclude", "codex"), + ("--include", "agy", "--exclude", "agy"), +]) +def test_contradicting_names_fail_before_the_state_is_written(new_init, argv): + """AC20: 名前の矛盾は終了コード 1 で、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + new_init(*argv) + assert e.value.code == 1 + assert not new_init.state_file.exists() + assert new_init.calls == [] + + +def test_none_mixed_with_a_name_is_rejected(new_init): + with pytest.raises(SystemExit) as e: + new_init("--exclude", "none,agy") + assert e.value.code == 1 + assert not new_init.state_file.exists() + + +def test_none_in_the_new_path_means_unspecified(new_init): + """決定 15: 新規の経路で `none` を渡すと、渡さないのと同じになる。""" + st = new_init("--only", "none", "--exclude", "none", "--include", "none") + assert st["only"] is None + assert st["participants"]["excluded"] == [] + assert st["participants"]["included"] == [] + + +def test_a_misspelt_runtime_is_rejected_by_argparse(state_mod): + """名前の綴りは argparse の型が弾く(終了コード 2)。""" + for words in (["--only", "gemini"], ["--exclude", "gemini"], ["--include", "codex,gemini"]): + with pytest.raises(SystemExit) as e: + state_mod.build_parser().parse_args(["init", "1", *words]) + assert e.value.code == 2 + + +# ---------- 担当の読み出し(#727: AC22) ---------- + +def test_a_state_without_participants_keeps_the_old_rotation(state_mod, tmp_path): + """AC22: `participants` が無くても、`host` があれば変更前の輪番と同じ値を返す。""" + path = _state(tmp_path, host="codex") + st = json.loads(path.read_text(encoding="utf-8")) + for round_no in range(1, 7): + assert state_mod._round_reviewers(st, round_no) == \ + state_mod.assignment.review_assign(round_no, "codex") + del st["host"] + assert state_mod._round_reviewers(st, 1) == ["codex", "agy"] + + +def test_recorded_reviewers_win_over_only(state_mod, tmp_path): + """決定 11: 再開で 1 者指定を変えても、記録のあるラウンドの担当は変わらない。""" + path = _state(tmp_path, only="codex", rounds=[_round(1, ["agy", "kiro"], {})]) + st = json.loads(path.read_text(encoding="utf-8")) + assert state_mod._round_reviewers(st, 1) == ["agy", "kiro"] + assert state_mod._round_reviewers(st, 2) == ["codex"] + + +def test_participants_win_over_the_host_rotation(state_mod, tmp_path): + """記録された参加者があれば、席の埋め方はその一覧から決める。""" + path = _state(tmp_path, participants={ + "pool": ["codex", "agy", "kiro"], "included": [], "excluded": ["agy"], + "available": ["codex", "kiro"], "unavailable": {}, "probe_skipped": False, + "require_all": False, "fallback": [], + }) + st = json.loads(path.read_text(encoding="utf-8")) + assert state_mod._round_reviewers(st, 1) == ["codex", "kiro"] + + +# ---------- 完了報告の「参加した者」(#727: AC24) ---------- + +def _report(state_mod, tmp_path, capsys, **over) -> list[str]: + _state(tmp_path, final="approved", **over) + state_mod.cmd_report(type("A", (), {"pr": 500})()) + out = capsys.readouterr().out + body = out.split("## 参加した者\n", 1) + assert len(body) == 2, out + lines = [] + for line in body[1].splitlines(): + if line.startswith("## "): + break + if line.strip(): + lines.append(line) + return lines + + +def test_the_report_lists_who_took_part(state_mod, tmp_path, capsys): + """AC24: 完了報告に「参加した者」の節が出る。""" + lines = _report(state_mod, tmp_path, capsys, participants={ + "pool": ["codex", "agy", "kiro"], "included": [], "excluded": ["agy"], + "available": ["codex", "kiro"], "unavailable": {}, "probe_skipped": False, + "require_all": False, "fallback": [], + }) + assert lines == [ + "- 母集合: codex / agy / kiro", + "- 使える者: codex / kiro", + "- --exclude で外した者: agy", + "- --include で足した者: なし", + "- 確認を通らなかった者: なし", + "- 席の埋め合わせ: なし", + "- 再開で変えた値: なし", + ] + + +def test_the_report_shows_the_reason_a_reviewer_was_dropped(state_mod, tmp_path, capsys): + lines = _report(state_mod, tmp_path, capsys, participants={ + "pool": ["codex", "agy", "kiro"], "included": ["claude"], "excluded": [], + "available": ["claude", "codex"], "unavailable": {"kiro": "コマンドが見つかりません"}, + "probe_skipped": False, "require_all": False, "fallback": ["claude"], + }) + assert "- --include で足した者: claude" in lines + assert "- 確認を通らなかった者: kiro(コマンドが見つかりません)" in lines + assert "- 席の埋め合わせ: claude" in lines + + +def test_the_report_says_the_probe_was_skipped(state_mod, tmp_path, capsys): + """確認を飛ばしたときは、通らなかった者が「なし」である理由を書き分ける。""" + lines = _report(state_mod, tmp_path, capsys, participants={ + "pool": ["codex", "agy", "kiro"], "included": [], "excluded": [], + "available": ["codex", "agy", "kiro"], "unavailable": {}, + "probe_skipped": True, "require_all": False, "fallback": [], + }) + assert "- 確認を通らなかった者: 確認を飛ばした(NDF_SKIP_AUTH_CHECK)" in lines + + +def test_the_report_lists_the_resume_changes(state_mod, tmp_path, capsys): + """再開で変えた値は 1 件 1 行で出す。""" + lines = _report(state_mod, tmp_path, capsys, participants={ + "pool": ["codex", "agy", "kiro"], "included": [], "excluded": [], + "available": ["codex", "agy", "kiro"], "unavailable": {}, + "probe_skipped": False, "require_all": False, "fallback": [], + }, resume_changes=[ + {"at": "2026-09-19T12:00:00", "field": "max_rounds", "from": 12, "to": 20}, + {"at": "2026-09-19T12:00:00", "field": "only", "from": None, "to": "codex"}, + ]) + assert "- 再開で変えた値:" in lines + assert " - 2026-09-19T12:00:00 max_rounds: 12 → 20" in lines + assert " - 2026-09-19T12:00:00 only: None → codex" in lines + + +def test_a_state_without_participants_says_so(state_mod, tmp_path, capsys): + """AC24 後半: `participants` を持たない状態ファイルでは「記録なし」と出す。""" + assert _report(state_mod, tmp_path, capsys) == ["- 使える者: 記録なし"] diff --git a/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py b/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py index b1b975d7..dd894ee8 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py @@ -207,3 +207,29 @@ def test_unavailable_count_does_not_stop_the_round(tmp_dir, state_mod, unresolve assert len(_read(tmp_dir)["rounds"]) == 2 assert "確認できません" in capsys.readouterr().err + + +# ---------------- 前ラウンドの担当で数える(#727: AC23) ---------------- + + +def test_the_guard_counts_the_reviewers_recorded_on_the_round(tmp_dir, state_mod, unresolved, capsys): + """AC23: 判定の結果を持たない前ラウンドは、そのラウンドの担当で数え直す。 + + 担当を渡さず `codex` / `agy` で数えると、担当が `agy` + `kiro` のラウンドでは + `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 + """ + unresolved([]) + prev = { + "round": 1, "pr": PR, "started_at": "2026-08-31T00:00:00+00:00", + "reviewers": ["agy", "kiro"], + "agy": {"intent": "REQUEST_CHANGES", "by_severity": {"major": 1}}, + "kiro": {"intent": "REQUEST_CHANGES", "by_severity": {"major": 1}}, + } + _write(tmp_dir, _state([prev], host="claude")) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_start_round(argparse.Namespace(pr=PR)) + + assert e.value.code == 5 + assert "修正の記録" in capsys.readouterr().err + assert len(_read(tmp_dir)["rounds"]) == 1 From b02b30dc8a7a1d7f92f7e0d6b5c2d8924e56be8e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:25:05 +0000 Subject: [PATCH 078/217] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20=E2=80=94=20cross-review=20=E3=81=AE=E6=9C=AA=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E5=88=86=E5=B2=90=203=20=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-001: measure の oracle 算出で resolved_thread_positions に非辞書要素 (文字列・null)が混じっても unmatched に数え、例外を出さず測定を続ける経路。 R2-002: rotate-pr.sh execute の --mode 値なし境界(${2:?...} で落ちる)と、 引数 0 個の entrypoint usage(exit 2)。いずれも gh/git を呼ぶ前で止まる。 R2-005: state.py cmd_set_current_pr で pr_history に過去 PR と現在 PR が並ぶとき、 過去 PR を変えず直前の現在 PR だけ閉じて新 PR を末尾へ足す分岐。 対象コードは変更していない。現状固定テストのみを追加。 Item-Id: R2-001 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../skills/cross-review/tests/test_measure.py | 44 +++++++++++++++++ .../tests/test_rotate_pr_queue.py | 30 ++++++++++++ .../tests/test_state_rotation_head_branch.py | 49 +++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/plugins/ndf/skills/cross-review/tests/test_measure.py b/plugins/ndf/skills/cross-review/tests/test_measure.py index b20aed8d..a6844a56 100644 --- a/plugins/ndf/skills/cross-review/tests/test_measure.py +++ b/plugins/ndf/skills/cross-review/tests/test_measure.py @@ -324,6 +324,50 @@ def test_oracle_counts_a_thread_without_a_position_as_unmatched(measure_mod): "found": 0, "unmatched": 1, "ambiguous": 0} +def test_oracle_counts_a_non_dict_position_as_unmatched(measure_mod): + """現状固定(R2-001)。位置の一覧に非辞書要素(文字列・null)が混じっても、 + + 落とさずに `unmatched` へ数え、例外を出さずに測定結果を返す。 + `_resolved_position` は辞書でない要素へ `(None, None)` を返し、 + `_add_oracle_match` がそれを `unmatched + 1` として扱う経路を固定する。 + """ + fix = { + "commit": "abc1234", "fixed": 1, "resolved_threads": 1, + "resolved_thread_ids": ["T1"], + "resolved_thread_positions": ["not-a-dict"], + } + st = _state( + rounds=[_round(1, fix=fix)], + review_findings=[_finding("codex-r1-0", 1, "a.py", 10)], + ) + + result = measure_mod.measure(st) + + assert result["methods"]["oracle"] == { + "found": 0, "unmatched": 1, "ambiguous": 0} + + +def test_oracle_counts_a_null_position_as_unmatched(measure_mod): + """現状固定(R2-001)。位置の一覧に `null` が混じっても `unmatched` に数える。 + + 非辞書要素の代表として `None`(JSON の null)でも同じ経路を通ることを固定する。 + """ + fix = { + "commit": "abc1234", "fixed": 1, "resolved_threads": 1, + "resolved_thread_ids": ["T1"], + "resolved_thread_positions": [None], + } + st = _state( + rounds=[_round(1, fix=fix)], + review_findings=[_finding("codex-r1-0", 1, "a.py", 10)], + ) + + result = measure_mod.measure(st) + + assert result["methods"]["oracle"] == { + "found": 0, "unmatched": 1, "ambiguous": 0} + + def test_oracle_does_not_count_a_finding_without_an_id(measure_mod): """`finding_id` を持たない指摘は結ばない(#558 レビュー)。 diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py index 206e6225..9202705a 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py @@ -329,6 +329,36 @@ def test_an_unknown_flag_is_rejected() -> None: assert "unknown arg: --unknown-flag" in out.stderr +def test_mode_without_a_value_is_rejected() -> None: + """現状固定(R2-002)。`--mode` の直後に値が無いと `${2:?...}` で落ちる。 + + state.json も newtext.json も用意せず、gh/git を呼ぶ前の引数解析だけで止まる。 + `${2:?...}` は set -u と相まって execute のループに入る前に落ちるため、 + load_state(state.json 読み込み)にも到達しない。 + """ + out = subprocess.run( + ["bash", str(ROTATE), "execute", "123", "--mode"], + capture_output=True, text=True, timeout=60, + ) + + assert out.returncode != 0 + assert "--mode requires light|squash" in out.stderr + + +def test_no_arguments_prints_usage() -> None: + """現状固定(R2-002)。引数が 0 個のとき entrypoint は usage を出して exit 2。 + + state.json を用意せず、引数解析だけで止まることを確かめる。 + """ + out = subprocess.run( + ["bash", str(ROTATE)], + capture_output=True, text=True, timeout=60, + ) + + assert out.returncode == 2 + assert "Usage:" in out.stderr + + def test_prepare_connects_state_pr_metadata_and_git_summary(tmp_path) -> None: """現状固定: 公開 CLI が prepare.json と eval 用の代入を組み立てる。""" state_pr = 41 diff --git a/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py b/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py index b5310c75..f14bd341 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py @@ -105,6 +105,55 @@ def test_an_empty_lookup_keeps_the_previous_branch(tmp_dir, state_mod, monkeypat assert _state(tmp_dir)["head_branch"] == OLD_BRANCH +def test_only_the_current_pr_entry_is_closed_when_history_has_past_prs( + tmp_dir, state_mod, monkeypatch +): + """現状固定(R2-005)。過去に閉じた PR を含む履歴で、直前の現在 PR だけを閉じる。 + + `pr_history` に閉じた過去 PR(`closed_at` 設定済み)と現在の PR(`closed_at` + が None)を順に持たせて `cmd_set_current_pr` を実行する。過去 PR は変わらず、 + 直前の現在 PR に `closed_at` と `rounds` が入り、新 PR エントリが + `closed_at: None` / `rounds: 0` で末尾へ足される分岐を固定する。 + """ + past_pr = 4200 + state = { + "current_pr": PR, + "repo": "o/r", + "head_branch": OLD_BRANCH, + "rounds": [ + {"round": 1, "pr": past_pr}, + {"round": 2, "pr": PR}, + {"round": 3, "pr": PR}, + ], + "pr_history": [ + {"pr": past_pr, "opened_at": "t0", "closed_at": "t1", "rounds": 1}, + {"pr": PR, "opened_at": "t2", "closed_at": None, "rounds": 0}, + ], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + # 引数で枝名を渡し、GitHub を呼ばない経路で確かめる。 + monkeypatch.setattr( + state_mod, "_sh", lambda cmd, check=True: pytest.fail("GitHub を呼んでいる") + ) + + state_mod.cmd_set_current_pr(_args(head_branch=NEW_BRANCH)) + + history = _state(tmp_dir)["pr_history"] + # 過去 PR は変わらない。 + assert history[0] == { + "pr": past_pr, "opened_at": "t0", "closed_at": "t1", "rounds": 1} + # 直前の現在 PR に closed_at と rounds(その PR のラウンド数 2)が入る。 + assert history[1]["pr"] == PR + assert history[1]["closed_at"] is not None + assert history[1]["rounds"] == 2 + # 新 PR エントリが末尾に closed_at: None / rounds: 0 で足される。 + assert history[2]["pr"] == NEW_PR + assert history[2]["closed_at"] is None + assert history[2]["rounds"] == 0 + assert len(history) == 3 + + def test_the_skeleton_passes_the_new_branch(state_mod) -> None: """手順書と参照の骨組みが `--head-branch` を渡していることを固定する。""" here = pathlib.Path(__file__).resolve().parent.parent From 678f61f09d678108288aa58320a2830ba8d7ef38 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:25:38 +0000 Subject: [PATCH 079/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 74 +++++++++++++++++++++++++++++++- 1 file changed, 72 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md index e72b66b3..45b29e8a 100644 --- a/issues/refactoring-plan-rf790.md +++ b/issues/refactoring-plan-rf790.md @@ -38,7 +38,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | integration | — | kiro | 検証中 | 1 | +| error | integration | — | kiro | 採用 | 1 | **なぜ**: collect-critiques が 7 以外を返したときに exit "$COLLECT_RC" でその終了コードを素通しする分岐が固定されていない。既存テストは 0 で抜ける経路だけを見ており、失敗の終了コードがラウンドの外へ伝わるかを誰も確かめていない。 @@ -64,7 +64,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | integration | — | codex | 検証中 | 1 | +| error | integration | — | codex | 採用 | 1 | **なぜ**: 新 PR 作成失敗時に旧 PR を reopen する経路は light モードだけ固定され、同じ ERR trap を使う squash モードでは未固定である @@ -72,6 +72,75 @@ 2. rotate-pr.sh execute --mode squash を公開 CLI から実行する 3. 非ゼロ終了、新 PR が存在しないこと、旧 PR の最終状態が open に戻ること、成功用の NEW_PR が出ないことを比較する +## ラウンド 2(実装 agy / レビュー codex / kiro) + +### R2-001 — `plugins/ndf/skills/cross-review/scripts/measure.py#measure` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | unit | — | codex / agy | 検証中 | 1 | + +**なぜ**: measure の oracle 算出において、resolved_thread_positions の要素が辞書形式である経路は固定されているが、リスト内に非辞書要素(文字列や null など)が混在した場合にそれを unmatched として数えて測定を継続する分岐が未固定である + +**手順**: 1. resolved_thread_positions に文字列や null などの非辞書要素を含む状態ファイルを用意する +2. measure を実行する +3. oracle の found が 0、unmatched が 1、ambiguous が 0 と計算され、例外を出さずに全体の測定結果が返ることを確かめる + +### R2-002 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#cmd_execute` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| boundary | integration | — | agy / kiro | 検証中 | 1 | + +**なぜ**: cmd_execute の引数解析は不正な --mode 値と未知フラグ(exit 2)は固定済みだが、--mode の直後に値が無いとき ${2:?--mode requires light|squash} で落ちる境界と、そもそも引数が 0 個のときの entrypoint の usage(exit 2)は固定されていない。 + +**手順**: 1. rotate-pr.sh execute --mode を値なしで実行し、終了コードが 0 以外で stderr に --mode requires light|squash が出ることを確かめる +2. rotate-pr.sh を引数なしで実行し、終了コードが 2 で usage が stderr に出ることを確かめる +3. いずれも state.json を用意せず、gh/git を呼ぶ前の引数解析だけで止まることを確かめる + +### R2-003 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | integration | — | agy / kiro | 未着手 | 0 | + +**なぜ**: execute_light は prepare.json / newtext.json の有無と title/body の null を 4 本の分岐で弾くが、既存テストは prepare.json と newtext.json が両方揃った成功・失敗経路(_Rotation)しか通していない。前提ファイルが欠ける分岐と、newtext.json の title が空・body が null になる分岐はどのテストも到達していない。 + +**手順**: 1. test_rotate_pr_queue.py の _Rotation と同じ組み立て(state.json・bin の gh/git 代替)を使い、gh/git は呼ばれる前に止まることを見込む +2. prepare.json を書かずに execute --mode light を実行し、終了コードが 1 で stderr に prepare.json not found が出ることを確かめる +3. prepare.json は置き newtext.json を書かずに実行し、終了コード 1 と stderr の newtext.json not found を確かめる +4. newtext.json に {"title": "", "body": "x"} を書いて実行し、終了コード 1 を確かめる +5. newtext.json に {"title": "x", "body": null} を書いて実行し、終了コード 1 を確かめる +6. いずれの分岐でも gh の呼び出し記録(GH_CALLS)が空で、旧 PR を close していないことを確かめる + +### R2-004 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#load_state` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | integration | — | agy / kiro | 未着手 | 0 | + +**なぜ**: load_state は state.json が不在または空のときに終了コード 1 と state.json not found を返して中断するが、rotate-pr.sh の公開入口を経由してこのエラー経路を通すテストが無い。launch-reviewer.sh 等では固定されているが rotate-pr.sh では未固定である + +**手順**: 1. CROSS_REVIEW_TMP_DIR を空の temp ディレクトリに向け、state.json を置かない +2. rotate-pr.sh execute を実行する +3. 終了コードが 1 であることを確かめる +4. stderr に state.json not found が含まれることを確かめる +5. gh/git の代替を PATH に置き、呼び出し記録が空(load_state の手前で止まる)であることを確かめる + +### R2-005 — `plugins/ndf/skills/cross-review/scripts/state.py#cmd_set_current_pr` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | unit | — | codex / agy | 検証中 | 1 | + +**なぜ**: cmd_set_current_pr において、pr_history に既に複数の履歴(過去に閉じた PR と現在開いている PR)が存在する場合に、過去 PR のエントリを変更せず直前の現在 PR のみ closed_at と rounds を更新して新 PR エントリを追加する分岐が未固定である + +**手順**: 1. 閉じた過去 PR(closed_at 設定済み)と現在の PR(closed_at が None)を順に含む pr_history を持つ状態ファイルを用意する +2. cmd_set_current_pr(pr, new_pr, head_branch) を実行する +3. 過去 PR の closed_at や rounds が変更されず保持されることを確かめる +4. 直前の現在 PR に closed_at が記録され、rounds がその PR のラウンド数と一致することを確かめる +5. 新 PR エントリが closed_at: None、rounds: 0 で末尾に追加されることを確かめる + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -82,3 +151,4 @@ | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_collect_critiques` | boundary | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_collect_critiques` | error | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_verify_findings` | error | 1 ラウンドの採用上限 5 件を超えた | +| 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#launch_reviewer` | branch | 1 ラウンドの採用上限 5 件を超えた | From 4cd469bc388e45e7c6e77f0793dc45cbad66c08c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:32:54 +0000 Subject: [PATCH 080/217] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= =?UTF-8?q?=20=E2=80=94=20rotate-pr.sh#execute=5Flight=20=E3=81=AE?= =?UTF-8?q?=E5=89=8D=E6=8F=90=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E6=A4=9C?= =?UTF-8?q?=E8=A8=BC=204=20=E5=88=86=E5=B2=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit execute --mode light が prepare.json の欠落・newtext.json の欠落・title の 空文字列・body の null をそれぞれ終了コード 1 で弾き、gh を呼ばず旧 PR を open のまま残すことを固定する。対象のコードは変更しない。 Item-Id: R2-003 Round: 2 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Fable 5.1 --- .../tests/test_rotate_pr_queue.py | 67 +++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py index 9202705a..b5dfc9aa 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py @@ -303,6 +303,73 @@ def test_a_create_success_in_squash_mode_closes_the_old_pr_and_opens_the_new_pr( assert not any(c.startswith("pr reopen") for c in rotation.gh_calls()) +# ---- execute --mode light の前提ファイル検証(R2-003、現状固定) ---- +# +# execute_light は prepare.json / newtext.json の有無と title / body の欠落を 4 本の分岐で +# 弾く。いずれも `git push` と旧 PR の close より前で止まるため、gh は 1 度も呼ばれず +# 旧 PR は open のまま残る。既存の _Rotation は両ファイルを揃えて書くので、ここでは +# 欠けさせる・書き換える操作を足してから実行する。正しさを主張しない現状固定テスト。 + + +def _prepare_path(rotation: _Rotation) -> pathlib.Path: + return rotation.tmp / f"rotate-pr{_STATE_PR}-prepare.json" + + +def _newtext_path(rotation: _Rotation) -> pathlib.Path: + return rotation.tmp / f"rotate-pr{_STATE_PR}-newtext.json" + + +def _assert_stopped_before_touching_the_old_pr(rotation: _Rotation) -> None: + assert rotation.gh_calls() == [] + assert rotation.pr_states() == {str(_OLD_PR): "open"} + + +def test_light_mode_stops_when_prepare_json_is_missing(rotation: _Rotation) -> None: + """prepare.json が無いと終了コード 1 で止まり、gh は呼ばれない。""" + _prepare_path(rotation).unlink() + + out = rotation.run(create_ok=True) + + assert out.returncode == 1 + assert "prepare.json not found" in out.stderr + _assert_stopped_before_touching_the_old_pr(rotation) + + +def test_light_mode_stops_when_newtext_json_is_missing(rotation: _Rotation) -> None: + """prepare.json はあっても newtext.json が無いと終了コード 1 で止まり、gh は呼ばれない。""" + _newtext_path(rotation).unlink() + + out = rotation.run(create_ok=True) + + assert out.returncode == 1 + assert "newtext.json not found" in out.stderr + _assert_stopped_before_touching_the_old_pr(rotation) + + +def test_light_mode_stops_when_the_title_is_empty(rotation: _Rotation) -> None: + """newtext.json の title が空文字列だと終了コード 1 で止まり、gh は呼ばれない。""" + _newtext_path(rotation).write_text( + json.dumps({"title": "", "body": "x"}), encoding="utf-8") + + out = rotation.run(create_ok=True) + + assert out.returncode == 1 + assert ".title がない" in out.stderr + _assert_stopped_before_touching_the_old_pr(rotation) + + +def test_light_mode_stops_when_the_body_is_null(rotation: _Rotation) -> None: + """newtext.json の body が null だと終了コード 1 で止まり、gh は呼ばれない。""" + _newtext_path(rotation).write_text( + json.dumps({"title": "x", "body": None}), encoding="utf-8") + + out = rotation.run(create_ok=True) + + assert out.returncode == 1 + assert ".body がない" in out.stderr + _assert_stopped_before_touching_the_old_pr(rotation) + + # ---- execute の引数検証(R2-004、現状固定) ---- # # `--mode` の値検証と未知フラグの検出は gh/git を一切呼ばない純粋な引数解析であり、 From b9026de935a0aed8030de27550c3d00d313801c5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:33:19 +0000 Subject: [PATCH 081/217] =?UTF-8?q?Revert=20"Test:=20=E7=8F=BE=E7=8A=B6?= =?UTF-8?q?=E5=9B=BA=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0=20=E2=80=94=20rotate-pr.sh#execute=5Flight=20?= =?UTF-8?q?=E3=81=AE=E5=89=8D=E6=8F=90=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB?= =?UTF-8?q?=E6=A4=9C=E8=A8=BC=204=20=E5=88=86=E5=B2=90"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 4cd469bc388e45e7c6e77f0793dc45cbad66c08c. --- .../tests/test_rotate_pr_queue.py | 67 ------------------- 1 file changed, 67 deletions(-) diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py index b5dfc9aa..9202705a 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py @@ -303,73 +303,6 @@ def test_a_create_success_in_squash_mode_closes_the_old_pr_and_opens_the_new_pr( assert not any(c.startswith("pr reopen") for c in rotation.gh_calls()) -# ---- execute --mode light の前提ファイル検証(R2-003、現状固定) ---- -# -# execute_light は prepare.json / newtext.json の有無と title / body の欠落を 4 本の分岐で -# 弾く。いずれも `git push` と旧 PR の close より前で止まるため、gh は 1 度も呼ばれず -# 旧 PR は open のまま残る。既存の _Rotation は両ファイルを揃えて書くので、ここでは -# 欠けさせる・書き換える操作を足してから実行する。正しさを主張しない現状固定テスト。 - - -def _prepare_path(rotation: _Rotation) -> pathlib.Path: - return rotation.tmp / f"rotate-pr{_STATE_PR}-prepare.json" - - -def _newtext_path(rotation: _Rotation) -> pathlib.Path: - return rotation.tmp / f"rotate-pr{_STATE_PR}-newtext.json" - - -def _assert_stopped_before_touching_the_old_pr(rotation: _Rotation) -> None: - assert rotation.gh_calls() == [] - assert rotation.pr_states() == {str(_OLD_PR): "open"} - - -def test_light_mode_stops_when_prepare_json_is_missing(rotation: _Rotation) -> None: - """prepare.json が無いと終了コード 1 で止まり、gh は呼ばれない。""" - _prepare_path(rotation).unlink() - - out = rotation.run(create_ok=True) - - assert out.returncode == 1 - assert "prepare.json not found" in out.stderr - _assert_stopped_before_touching_the_old_pr(rotation) - - -def test_light_mode_stops_when_newtext_json_is_missing(rotation: _Rotation) -> None: - """prepare.json はあっても newtext.json が無いと終了コード 1 で止まり、gh は呼ばれない。""" - _newtext_path(rotation).unlink() - - out = rotation.run(create_ok=True) - - assert out.returncode == 1 - assert "newtext.json not found" in out.stderr - _assert_stopped_before_touching_the_old_pr(rotation) - - -def test_light_mode_stops_when_the_title_is_empty(rotation: _Rotation) -> None: - """newtext.json の title が空文字列だと終了コード 1 で止まり、gh は呼ばれない。""" - _newtext_path(rotation).write_text( - json.dumps({"title": "", "body": "x"}), encoding="utf-8") - - out = rotation.run(create_ok=True) - - assert out.returncode == 1 - assert ".title がない" in out.stderr - _assert_stopped_before_touching_the_old_pr(rotation) - - -def test_light_mode_stops_when_the_body_is_null(rotation: _Rotation) -> None: - """newtext.json の body が null だと終了コード 1 で止まり、gh は呼ばれない。""" - _newtext_path(rotation).write_text( - json.dumps({"title": "x", "body": None}), encoding="utf-8") - - out = rotation.run(create_ok=True) - - assert out.returncode == 1 - assert ".body がない" in out.stderr - _assert_stopped_before_touching_the_old_pr(rotation) - - # ---- execute の引数検証(R2-004、現状固定) ---- # # `--mode` の値検証と未知フラグの検出は gh/git を一切呼ばない純粋な引数解析であり、 From 532d9356cfddb69b9585ccf08589e36ae173de40 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:33:19 +0000 Subject: [PATCH 082/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md index 45b29e8a..0ceba6c8 100644 --- a/issues/refactoring-plan-rf790.md +++ b/issues/refactoring-plan-rf790.md @@ -78,7 +78,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | codex / agy | 検証中 | 1 | +| branch | unit | — | codex / agy | 採用 | 1 | **なぜ**: measure の oracle 算出において、resolved_thread_positions の要素が辞書形式である経路は固定されているが、リスト内に非辞書要素(文字列や null など)が混在した場合にそれを unmatched として数えて測定を継続する分岐が未固定である @@ -90,7 +90,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| boundary | integration | — | agy / kiro | 検証中 | 1 | +| boundary | integration | — | agy / kiro | 採用 | 1 | **なぜ**: cmd_execute の引数解析は不正な --mode 値と未知フラグ(exit 2)は固定済みだが、--mode の直後に値が無いとき ${2:?--mode requires light|squash} で落ちる境界と、そもそも引数が 0 個のときの entrypoint の usage(exit 2)は固定されていない。 @@ -102,7 +102,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | integration | — | agy / kiro | 未着手 | 0 | +| branch | integration | — | agy / kiro | 取り消し | 1 | **なぜ**: execute_light は prepare.json / newtext.json の有無と title/body の null を 4 本の分岐で弾くが、既存テストは prepare.json と newtext.json が両方揃った成功・失敗経路(_Rotation)しか通していない。前提ファイルが欠ける分岐と、newtext.json の title が空・body が null になる分岐はどのテストも到達していない。 @@ -131,7 +131,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | codex / agy | 検証中 | 1 | +| branch | unit | — | codex / agy | 採用 | 1 | **なぜ**: cmd_set_current_pr において、pr_history に既に複数の履歴(過去に閉じた PR と現在開いている PR)が存在する場合に、過去 PR のエントリを変更せず直前の現在 PR のみ closed_at と rounds を更新して新 PR エントリを追加する分岐が未固定である @@ -152,3 +152,4 @@ | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_collect_critiques` | error | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_verify_findings` | error | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#launch_reviewer` | branch | 1 ラウンドの採用上限 5 件を超えた | +| 2 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light` | branch | コミット 4cd469bc388e45e7c6e77f0793dc45cbad66c08c にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | From 39fb6e09154fc91d2c80aa844f9e655b5aa8cc92 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:37:05 +0000 Subject: [PATCH 083/217] =?UTF-8?q?Test:=20rotate-pr=20=E3=81=AE=20state?= =?UTF-8?q?=20=E4=B8=8D=E5=9C=A8=E7=B5=8C=E8=B7=AF=E3=82=92=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 公開入口 execute が state.json 不在時に終了コード 1 とエラーを返し、gh/git を呼ばない現状を固定する。 Item-Id: R2-004 Round: 2 Impl-Runtime: codex Impl-Model: default --- .../tests/test_rotate_pr_queue.py | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py index 9202705a..d1865dc2 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py @@ -345,6 +345,37 @@ def test_mode_without_a_value_is_rejected() -> None: assert "--mode requires light|squash" in out.stderr +def test_execute_stops_when_state_json_is_missing(tmp_path) -> None: + """現状固定(R2-004)。state 不在なら外部コマンドを呼ばずに終了する。""" + tmp_dir = tmp_path / "tmp" + bin_dir = tmp_path / "bin" + calls = tmp_path / "calls.log" + tmp_dir.mkdir() + bin_dir.mkdir() + calls.write_text("", encoding="utf-8") + + fake_command = "#!/usr/bin/env bash\nprintf '%s\\n' \"$0 $*\" >> \"$CALLS\"\n" + for command in ("gh", "git"): + executable = bin_dir / command + executable.write_text(fake_command, encoding="utf-8") + executable.chmod(0o755) + + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "CROSS_REVIEW_TMP_DIR": str(tmp_dir), + "CALLS": str(calls), + } + out = subprocess.run( + ["bash", str(ROTATE), "execute", str(_STATE_PR)], + capture_output=True, text=True, timeout=60, env=env, + ) + + assert out.returncode == 1 + assert "state.json not found" in out.stderr + assert calls.read_text(encoding="utf-8") == "" + + def test_no_arguments_prints_usage() -> None: """現状固定(R2-002)。引数が 0 個のとき entrypoint は usage を出して exit 2。 From bab284edbcab40710348b9924b4c78f70b69708c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:37:39 +0000 Subject: [PATCH 084/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md index 0ceba6c8..4972cdbc 100644 --- a/issues/refactoring-plan-rf790.md +++ b/issues/refactoring-plan-rf790.md @@ -117,7 +117,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | integration | — | agy / kiro | 未着手 | 0 | +| error | integration | — | agy / kiro | 検証中 | 1 | **なぜ**: load_state は state.json が不在または空のときに終了コード 1 と state.json not found を返して中断するが、rotate-pr.sh の公開入口を経由してこのエラー経路を通すテストが無い。launch-reviewer.sh 等では固定されているが rotate-pr.sh では未固定である From 78c99001b972f95a7f3e4ce6bb0167107c7389c8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:38:06 +0000 Subject: [PATCH 085/217] =?UTF-8?q?Add:=20=E5=B8=AD=E3=81=AE=E5=90=8D?= =?UTF-8?q?=E5=89=8D=E3=82=92=E8=B5=B7=E5=8B=95=E3=83=BB=E7=9B=A3=E8=A6=96?= =?UTF-8?q?=E3=83=BB=E8=A8=88=E6=B8=AC=E3=81=AB=E9=80=9A=E3=81=97=E3=80=81?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E6=9B=B8=E3=81=A8=E6=96=87=E6=9B=B8=E3=82=92?= =?UTF-8?q?=E6=96=B0=E3=81=97=E3=81=84=E5=BC=95=E6=95=B0=E3=81=B8=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=82=8B=EF=BC=88#727=20#687=20#478=20#648?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 起動スクリプト 2 本: 席の名前を受け、CLI はランタイム名で選ぶ。結果ファイルは席の名前 - 監視: 席の名前からランタイムを引いて CLI 固有の検査を選ぶ。位置引数を席の形の検査へ - 計測: ラウンドの記録の鍵のうち席の形に一致するものを数える - SKILL.md / docs 01・04・05: 引数 3 つ、席の規則、状態ファイルの 2 項目、 再開で渡した引数の扱い。骨組みは値のある引数だけを渡し、担当はラウンドの開始が返す席を使う - テスト 14 件(席の受け口と手順書の検査) Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/monitor.py | 49 +++++- plugins/ndf/skills/cross-review/SKILL.md | 54 +++--- .../cross-review/docs/01-state-and-review.md | 37 +++-- .../skills/cross-review/docs/04-contracts.md | 43 ++++- .../docs/05-pool-and-convergence.md | 64 +++++-- .../skills/cross-review/scripts/critique.sh | 28 ++-- .../cross-review/scripts/launch-reviewer.sh | 35 ++-- .../skills/cross-review/scripts/measure.py | 16 +- .../ndf/skills/cross-review/scripts/state.py | 2 +- .../cross-review/scripts/wait-review.sh | 9 +- .../tests/test_launch_reviewer_guards.py | 6 +- .../cross-review/tests/test_monitor_agy.py | 5 +- .../cross-review/tests/test_seat_names.py | 156 +++++++++++++++++- .../cross-review/tests/test_skill_layout.py | 27 +++ 14 files changed, 420 insertions(+), 111 deletions(-) diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index 5e7df1d3..f8710ff0 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -14,6 +14,9 @@ `--stem-template` で決まる(既定は cross-review の `{agent}-review-pr{id}`)。 cross-refactoring は `{agent}-propose-rf{id}` のような別の命名を渡す。 +**担当の名前は席の名前を取りうる**(`claude-2` のような同じランタイムの 2 つ目。#727)。 +一時ファイルの名前はその名前のまま組み、CLI ごとの検査だけ `_agent_runtime` で選ぶ。 + 監視軸: 1. **pidfile** + `kill -0` でプロセス生存確認 - 可能なら `/proc//cmdline` で codex/agy であることを再確認 (PID 再利用対策) @@ -84,10 +87,39 @@ def _lib_dir() -> pathlib.Path: if str(_lib_dir()) not in sys.path: sys.path.insert(0, str(_lib_dir())) +import assignment # noqa: E402 席の名前の規則(#727) import limits # noqa: E402 上限の表(#598 / #537) import monitor_outcome # noqa: E402 監視の結果の語彙と読み書き(#662) +def _agent_runtime(agent: str) -> str: + """担当の名前からランタイムを引く(CLI ごとの検査を選ぶために使う)。 + + 担当の単位は席の名前(`assignment.SEAT_PATTERN`。`claude-2` のように同じランタイムの + 2 つ目を表す)である。**席の形に合わない名前はそのまま返す。** cross-refactoring は + 任意の骨格(`--stem-template`)で担当名を渡せるため、形で弾くとその経路が壊れる。 + """ + try: + return assignment.seat_runtime(agent) + except assignment.AssignmentError: + return agent + + +def _seat_or_both(value: str) -> str: + """位置引数 `target` の型。席の名前か `both` だけを通す。 + + 通らなければ argparse が終了コード 2 で終わる。`both` はこれまでの 2 者 + (codex / agy)を指す省略形である。 + """ + if value == "both": + return value + try: + assignment.seat_runtime(value) + except assignment.AssignmentError as e: + raise argparse.ArgumentTypeError(f"{e}。または both") from e + return value + + # ---------- 設定 ---------- # **上限の既定値はこの監視に持たない。** 上限の表(`limits.py`)だけが持ち、ここの名前は @@ -626,7 +658,7 @@ def _lingering_completion( started_wall: float, ) -> str | None: has_result = paths.result.exists() and paths.result.stat().st_size > 0 - if status.agent == "codex" and status.sentinel_seen and has_result: + if _agent_runtime(status.agent) == "codex" and status.sentinel_seen and has_result: _kill_pid(pid) status.result_exists = True return f"codex sentinel + result.json detected; killed lingering pid {pid}" @@ -655,7 +687,7 @@ def _early_error( return None, None fatal_err = _scan_early_fatal(paths.err_log) fatal_source = "err.log" - if not fatal_err and agent == "claude": + if not fatal_err and _agent_runtime(agent) == "claude": fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) fatal_source = "stdout.log" fatal = (fatal_source, fatal_err) if fatal_err else None @@ -805,7 +837,7 @@ def monitor_agent( # 1. プロセス生存確認 → 死んでいたら最終判定へ (result.json 存在をチェック) alive = _pid_alive(pid) - if agent == "codex": + if _agent_runtime(agent) == "codex": status.sentinel_seen = _scan_codex_sentinel(paths.err_log) # codex は `tokens used` sentinel を出した後もプロセスが exit せず常駐し続ける @@ -945,12 +977,11 @@ def main() -> None: p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) p.add_argument("pr", type=int) # 後方互換: cross-review は位置引数 `target` で codex / agy / both を渡す。 - # 4 ランタイム任意の組み合わせは `--agents` で渡す(どちらか一方だけを使う)。 - # **担当は 4 つの名前を取りうる。** `both` はこれまでの 2 者を指す省略形として残す - # (既存の呼び出し側が使い続けられるようにする)。3 者以上を監視するときは - # `--agents` を使う。 - p.add_argument("target", nargs="?", - choices=["claude", "codex", "agy", "kiro", "both"]) + # 2 者より多い組み合わせは `--agents` で渡す(どちらか一方だけを使う)。 + # **担当は席の名前を取りうる**(`claude-2` のような同じランタイムの 2 つ目。#727)。 + # `both` はこれまでの 2 者を指す省略形として残す(既存の呼び出し側が使い続けられる + # ようにする)。3 者以上を監視するときは `--agents` を使う。 + p.add_argument("target", nargs="?", type=_seat_or_both) p.add_argument("--agents", default=None, help="監視対象をカンマ区切りで指定 (例: claude,kiro)。" "位置引数 target の代わりに使う") diff --git a/plugins/ndf/skills/cross-review/SKILL.md b/plugins/ndf/skills/cross-review/SKILL.md index 15506ea2..372336cb 100644 --- a/plugins/ndf/skills/cross-review/SKILL.md +++ b/plugins/ndf/skills/cross-review/SKILL.md @@ -1,7 +1,7 @@ --- name: cross-review description: "Review a PR with two CLIs picked from the runtimes other than the host, looping fixes until no new finding appears. Use when a converging multi-AI review is wanted(クロスレビュー・両AIレビュー・収束レビュー)." -argument-hint: "[PR番号] [--host claude|codex|agy|kiro] [--max-rounds N] [--rotate-after K] [--rotate-mode light|squash] [--only RUNTIME] [--focus TEXT] [--extra-instructions-file PATH] [--verify-command CMD] [--verify-exit-code N]" +argument-hint: "[PR番号] [--host claude|codex|agy|kiro] [--max-rounds N] [--rotate-after K] [--rotate-mode light|squash] [--only RUNTIME] [--exclude NAMES] [--include NAMES] [--require-all] [--focus TEXT] [--extra-instructions-file PATH] [--verify-command CMD] [--verify-exit-code N]" allowed-tools: - Bash - Read @@ -16,9 +16,10 @@ allowed-tools: PR を**ホストを除く 3 者から選んだ 2 者**にレビューさせ、**新しい指摘が出なくなるまで** `/ndf:pr-review` と `/ndf:fix` を自動で回す。 -母集合は「全ランタイム − ホスト」で、担当はラウンドごとの輪番で決まる(`cross-refactoring` -と同じ決め方で、実装は共通層の `lib/assignment.py` にある)。**ホストを名指しで固定しない** -のは、固定するとホストが `codex` か `agy` のときに自分自身をレビュワーへ含めるためである。 +母集合は「全ランタイム − ホスト」で、そのうち使える者から毎ラウンド 2 席を埋める +(実装は共通層の `lib/assignment.py`)。**1 者が使えなくても始まり**、席が足りなければ +ホストと同じランタイムの 2 つ目で埋める(`docs/05`)。**ホストを名指しで固定しない**のは、 +固定するとホストが `codex` か `agy` のときに自分自身をレビュワーへ含めるためである。 /goalの引数として呼ばれた場合は、新しい指摘が出なくなるまで/cross-reviewを繰り返す。 * 担当のいずれかが不具合などで実行できなくなった場合は異常終了とする @@ -59,7 +60,7 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 | 長尺PR対策 | **`--rotate-after` ラウンドで PR をローテーション**(default=light: 同ブランチで PR 巻き直し / squash: 新ブランチ + squash 統合) | | 振動検知 | 前のラウンドと**同じ箇所を指す指摘**が 50% 以上なら中断(測り方は `docs/01` の Step 4) | | 終了基準 | **新しい指摘が出なくなったら収束**。全員 `APPROVE` は最も止まらない参加者に律速される。3 つの層の順序は `docs/01` の「終了基準」 | -| レビュワーの母集合 | **全ランタイム − ホスト**の 3 者から、輪番で 2 者。認証は `init` が起動前に確かめる | +| レビュワーの母集合 | **全ランタイム − ホスト**の 3 者から、使える者を決めて毎ラウンド 2 席。使える者の解決と席の埋め方は `docs/05` | ## 引数 @@ -70,7 +71,10 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 | `--rotate-after K` | この round 数で未収束なら PR ローテーション | `8` | | `--rotate-mode light\|squash` | ローテーション方式。`light`: 同ブランチで旧 PR を close → 新 PR (title/body は現状の差分・実装から再生成)。`squash`: squash 統合 + 新ブランチ + `(rotated)` suffix | `light` | | `--host claude\|codex\|agy\|kiro` | この収束ループを起動している CLI。母集合から外れる | 環境変数から推定。**推定できなければ失敗する** | -| `--only RUNTIME` | 1 者だけで回す(デバッグ用)。**そのラウンドの担当を 1 者へ絞る。** 母集合の外を指定したら `init` が弾く | 担当 2 者 | +| `--only RUNTIME` | 1 者だけで回す。**そのラウンドの担当を 1 者へ絞り、席の埋め合わせを行わない。** 母集合の外を指定したら `init` が弾く | 担当 2 者 | +| `--exclude NAMES` | 母集合から外す者。カンマ区切りで複数、繰り返しも可。再開で `none` を渡すと空へ戻す | なし | +| `--include NAMES` | 母集合に足す者(ホストも足せる)。書き方は `--exclude` と同じ | なし | +| `--require-all` | 確認を通らない者が 1 者でもいれば `init` を失敗させる。全員が揃わないなら始めたくない運用向け | 使える者で始める | | `--focus TEXT` | 自動レビュー観点に上乗せして**そのラウンドのレビュー担当 2 者**に渡す追加観点。短い重点チェック向け | なし | | `--extra-instructions-file PATH` | 自動レビュー観点に上乗せして**そのラウンドのレビュー担当 2 者**に渡す追加観点を UTF-8 テキストファイルから読む。長いチェックリスト向け | なし | | `--verify-command CMD` | 実行検証(Step 2.5)で実行してよいコマンド。**渡さなければ実行検証を行わない** | なし | @@ -83,6 +87,7 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 /ndf:cross-review 123 --max-rounds 4 --rotate-after 2 /ndf:cross-review 123 --rotate-mode squash /ndf:cross-review 123 --only codex +/ndf:cross-review 123 --exclude agy --include claude --require-all /ndf:cross-review 123 --focus "ドキュメントとコードの整合性を重点的に確認" /ndf:cross-review 123 --extra-instructions-file /tmp/review-focus.md /ndf:cross-review 123 --verify-command "pytest" --verify-exit-code 1 @@ -121,7 +126,7 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 - `/ndf:pr-review` が **AI 直接投稿**(外部 AI 自身が `gh api` で投稿)に対応 - `/ndf:fix` が **サブエージェント起動 + 重要度ベース自動修正 + Resolve Conversation** に対応 -- 担当になる CLI が動作し、`gh` CLI が認証済み(`init` が起動前に確かめる。誤検知するときは `NDF_SKIP_AUTH_CHECK=1`) +- `gh` CLI が認証済み。担当になる CLI は `init` が起動前に確かめ、通らない者は外して続ける(誤検知するときは `NDF_SKIP_AUTH_CHECK=1`) - `Agent(subagent_type="general-purpose", ...)` でサブエージェントを起動可能 ## 事前確認(`state.py init` が自動実施) @@ -160,15 +165,12 @@ flowchart TD Start([事前確認 / loop 開始前に 1 回だけ]):::phase --> Init["worktree 作成 + state.json 初期化
・自分の PR 判定 → event downgrade 設定
・<worktree-base>/pr<PR> を用意
・既存コメントスナップショット保存"] Init --> Round["Round N start
current_pr = PR#"]:::phase - Round -.並列バックグラウンド.-> Codex["/ndf:pr-review <PR> codex
(AI が gh api で直接投稿)
body 先頭: cross-review / round N / codex / intent
→ result.json (intent + posted_as)"] - Round -.並列バックグラウンド.-> Agy["/ndf:pr-review <PR> agy
--add-dir で作業領域を宣言
body 先頭: cross-review / round N / agy / intent
→ result.json (intent + posted_as)"] - - Codex --> Decide{"判定 (intent ベース)"} - Agy --> Decide + Round -.並列バックグラウンド.-> Seats["レビュー担当 2 席(start-round が返す)
/ndf:pr-review <PR> <席> を席ごとに起動
body 先頭: cross-review / round N / 席 / intent
→ <席>-review-pr<PR>-result.json"] + Seats --> Decide{"判定 (intent ベース)"} Decide -->|"結果なし (2 度目は final = error)"| Relaunch["結果を残さなかった側だけ
同じラウンドで 1 度起動し直す"] Relaunch --> Decide - Decide -->|"両方 APPROVE / --only で外した側"| Approved([final = approved]):::ok + Decide -->|"両席 APPROVE / --only で外した席"| Approved([final = approved]):::ok Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + push
・reply + resolveReviewThread
・deferred/rejected は reply のみ
→ $TMP_DIR/fix-pr<#>-result.json"] Fix --> Check{収束チェック} @@ -206,11 +208,12 @@ STATE_PR=$INITIAL_PR ROTATE_MODE=${ROTATE_MODE:-light} # Step 0: state 初期化 / 再開 -# ⚠ eval はコマンド置換の終了コードを潰す。変数で受けてから eval する(docs/01 参照) +# ⚠ eval はコマンド置換の終了コードを潰す。変数で受けてから eval する(docs/01 参照)。**値のある引数だけを渡す**(常に渡すと、再開のたびに指定していない既定値で上書きする)。 INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ - --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ + ${MAX_ROUNDS:+--max-rounds "$MAX_ROUNDS"} ${ROTATE_AFTER:+--rotate-after "$ROTATE_AFTER"} \ ${HOST:+--host "$HOST"} \ ${ONLY:+--only "$ONLY"} \ + ${EXCLUDE:+--exclude "$EXCLUDE"} ${INCLUDE:+--include "$INCLUDE"} ${REQUIRE_ALL:+--require-all} \ ${FOCUS:+--focus "$FOCUS"} \ ${VERIFY_COMMAND:+--verify-command "$VERIFY_COMMAND"} ${VERIFY_EXIT_CODE:+--verify-exit-code "$VERIFY_EXIT_CODE"} \ ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"}) || exit $? @@ -225,28 +228,25 @@ while :; do eval "$ROUND_VARS" # Step 2: 並列レビュー(担当は start-round が REVIEWERS / REVIEWERS_CSV で返す) + # **シェル変数で絞り直さない。** 返る一覧は 1 者指定と席の埋め合わせを反映済みである。 for r in $REVIEWERS; do - [ -z "$ONLY" ] || [ "$ONLY" = "$r" ] || continue "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND" done - # 監視: 上限は上限の表(review 1200 秒 / stall は担当別 codex 180・agy 480・kiro 480・claude 900)。失敗時は kill して返す。 - # Bash の 1 回 600 秒に収まらないため背景で起動し、wait(1 回 540 秒以内)を 124 のあいだ **別の Bash の呼び出しで** 呼び直す。 - # **繰り返しを 1 回の呼び出しへ書かない**(2 回目の待ちに入った時点で合計が 600 秒を超え、ホストに打ち切られる。docs/01)。 - # 監視と取り込みの終了コードは読まない。結果なしは NO_RESULT として state に残り、Step 3 が受け取る。担当は `--agents` で渡す(`both` は 2 者だけ)。 - "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "${ONLY:-$REVIEWERS_CSV}" + # 監視: 上限は上限の表(review 1200 秒 / stall は席のランタイム別 codex 180・agy 480・kiro 480・claude 900)。失敗時は kill して返す。担当は `--agents` で渡す。 + # Bash の 1 回 600 秒に収まらないため背景で起動し、wait(1 回 540 秒以内)を 124 のあいだ **別の Bash の呼び出しで** 呼び直す。**繰り返しを 1 回の呼び出しへ書かない**(2 回目の待ちで合計が 600 秒を超え、ホストに打ち切られる。docs/01)。 + # 監視と取り込みの終了コードは読まない。結果なしは NO_RESULT として state に残り、Step 3 が受け取る。 + "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$REVIEWERS_CSV" "$SCRIPTS/bg-wait.sh" wait "$TMP_DIR/review.rc" # 124 = まだ。**この 1 行を別の Bash の呼び出しとして呼び直す** for r in $REVIEWERS; do - [ -z "$ONLY" ] || [ "$ONLY" = "$r" ] || continue "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true done # Step 2.5: 根拠の検証(#156)。順序と理由は docs/06-evidence.md の「走らせる順序」。 # 飛ばすと、判定が読む区分が統合も実行の結果も反映しないまま決まる。 # ⚠ 起動 → 監視 → 取り込みは critique-round.sh が持つ。**未起動の担当を監視へ渡さない** - # (渡すと 30 秒待って PIDFILE_BAD (exit 6) が返る)ことと、有効な反証が揃わない - # ときに同じラウンドで 1 度だけ取り直すことを、この 1 本が引き受ける。 + # (渡すと 30 秒待って PIDFILE_BAD (exit 6) が返る)ことと、有効な反証が揃わないときに同じラウンドで 1 度だけ取り直すことを、この 1 本が引き受ける。 "$SCRIPTS/state.py" verify-findings "$STATE_PR" - "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/critique.rc" -- "$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" ${ONLY:-$REVIEWERS} + "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/critique.rc" -- "$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" $REVIEWERS "$SCRIPTS/bg-wait.sh" wait "$TMP_DIR/critique.rc" # 同上。124 のあいだ、別の呼び出しとして呼び直す # Step 3: 判定 (0=収束 / 2=修正へ / 7=結果なし / 8=待ち行列に残あり / 1=中断)。引き継いだ指摘が残っていれば、 @@ -390,8 +390,8 @@ bash ループは Agent tool を呼べないため、light モードでは Step | 2 | #123 | codex=REQUEST_CHANGES (2) / kiro=APPROVE (0) | def456 (2 fixed) | ✅ | | 3 | #145 | codex=APPROVE (0) / agy=APPROVE (0) | — | — | - **担当はラウンドごとに変わる。** 4 つの名前を取りうるため、担当と判定を 1 つの列へ - まとめる。 + **担当はラウンドごとに変わる。** 席の名前(`claude-2` のような 2 つ目を含む)を取りうる + ため、担当と判定を 1 つの列へまとめる。 - **最終スイープ結果** (Step 7.5): `sweep-pr-result.json` の `resolved` / `fixed_in_sweep` / `remaining_open`。**`remaining_open` は 0 が正常**(残 open diff --git a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md index b8d74728..e311a736 100644 --- a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md +++ b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md @@ -55,13 +55,13 @@ done SCRIPTS="$SKILL_DIR/scripts" # state 初期化 / 再開(プリチェック・worktree 作成・既存コメントスナップショットを内部実行) -# ⚠ `eval "$(スクリプト)"` は、スクリプトが異常終了しても出力が空なら終了コード 0 に -# なる。コマンド置換の終了コードは eval 自身の終了コードにならないため、止まるべき -# 場面で止まらない。**必ず変数で受け、終了コードを見てから eval する。** +# ⚠ `eval "$(スクリプト)"` は、スクリプトが異常終了しても出力が空なら終了コード 0 になる。 +# コマンド置換の終了コードは eval 自身の終了コードにならないため、止まるべき場面で +# 止まらない。**必ず変数で受け、終了コードを見てから eval する。** +# **値のある引数だけを渡す。** 上限や交代の間隔を常に渡すと、再開のたびに利用者が指定していない既定値で上書きする(「再開で渡した引数の扱い」)。 INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ - --max-rounds "$MAX_ROUNDS" --rotate-after "$ROTATE_AFTER" \ - ${ONLY:+--only "$ONLY"} \ - ${FOCUS:+--focus "$FOCUS"} \ + ${MAX_ROUNDS:+--max-rounds "$MAX_ROUNDS"} ${ROTATE_AFTER:+--rotate-after "$ROTATE_AFTER"} ${ONLY:+--only "$ONLY"} \ + ${EXCLUDE:+--exclude "$EXCLUDE"} ${INCLUDE:+--include "$INCLUDE"} ${REQUIRE_ALL:+--require-all} ${FOCUS:+--focus "$FOCUS"} \ ${EXTRA_INSTRUCTIONS_FILE:+--extra-instructions-file "$EXTRA_INSTRUCTIONS_FILE"}) || exit $? eval "$INIT_VARS" @@ -99,6 +99,17 @@ cd "$WORKTREE" **重要**: 以降の全ステップで `cd $WORKTREE` を強制。 サブエージェント(fix)を起動するときも、prompt 内で worktree path を明示する。 +### 再開で渡した引数の扱い + +**黙って捨てる引数は無い。** 渡さなかった引数は状態ファイルの値のまま残り、`--worktree` / +`--focus` / `--extra-instructions-file` は状態に載らないため毎回の指定が使われる。 + +| 扱い | 引数 | 何が起きるか | +| --- | --- | --- | +| 反映する | `--max-rounds` / `--rotate-after` / `--only` / `--verify-command` / `--verify-exit-code` | 状態を書き換え、`resume_changes` へ 1 件積み、`↻ <項目>: <旧> → <新>` を出す | +| 反映し、参加者を作り直す | `--exclude` / `--include` / `--require-all` | 使える者を解決し直して `participants` を置き換える。失敗したら状態を書き換えずに終了コード 1 | +| 反映しない | `--host` | 状態と違うときだけ `ℹ --host は再開では反映しません` を出す | + ## Step 1: Round 開始判定 ```bash @@ -161,15 +172,14 @@ eval "$ROUND_VARS" ### 2.1 launcher 起動 + monitor ```bash -# 担当は `start-round` が $REVIEWERS / $REVIEWERS_CSV で返す。**名前で分岐しない。** +# 担当は `start-round` が $REVIEWERS / $REVIEWERS_CSV で返す。**名前で分岐せず、シェル変数で絞り直さない**(絞ると状態ファイルとずれたときに誰にも当たらない。05-pool-and-convergence.md)。 for r in $REVIEWERS; do - [ -z "$ONLY" ] || [ "$ONLY" = "$r" ] || continue "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND" done # monitor.py が多軸で完了判定。exit code で失敗種別を分岐。上限は `--phase review`(1200 秒)。 # ⚠ 位置引数の `both` は codex / agy の 2 者だけを指す。担当の一覧は `--agents` で渡す。 -"$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "${ONLY:-$REVIEWERS_CSV}" +"$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$REVIEWERS_CSV" # 待ちは 1 回 540 秒以内。**124 が返るあいだ、この 2 行を別の Bash の呼び出しとして呼び直す。** # 繰り返しを 1 回の呼び出しへ書くと、2 回目の待ちで合計が 600 秒を超えてホストに打ち切られる。 "$SCRIPTS/bg-wait.sh" wait "$TMP_DIR/review.rc"; RC=$? @@ -203,12 +213,6 @@ fi > 「タスク完了」通知が飛んでくる。これに惑わされず、`monitor.py` で > 実プロセスの完了を pidfile / sentinel で確認すること。 > -> ⚠ **`pgrep -fa ` で完了判定しない**: agy は long `-p` プロンプトを -> 引数に持つため、`grep` のキーワード選定で誤検知する。**pidfile 必須**。 -> -> ⚠ **sentinel 単独で完了判定しない**: codex がクラッシュすると `tokens used` が -> 永遠に出ない。`monitor.py` は sentinel と pidfile/result.json/err.log を併用する。 -> > ⚠ **Docker 環境ではゾンビプロセスに注意**: `nohup ... & disown` で起動した > プロセスは、終了後にゾンビ化する (PID 1 が proper init でない場合)。 > `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。 @@ -221,12 +225,11 @@ AI への入出力の契約(2.2)と、AI が書き出すファイルの契 ```bash for r in $REVIEWERS; do - [ -z "$ONLY" ] || [ "$ONLY" = "$r" ] || continue "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" done ``` -`state.rounds[-1].` に `intent / posted_as / comments / review_url / by_severity` を分離保存する。 +`state.rounds[-1].<席の名前>` に `intent / posted_as / comments / review_url / by_severity` を分離保存する(席の名前の形は `04-contracts.md`)。 #### 申告されたコメント数を GitHub 側と突き合わせる diff --git a/plugins/ndf/skills/cross-review/docs/04-contracts.md b/plugins/ndf/skills/cross-review/docs/04-contracts.md index b73c2bf3..b68a0391 100644 --- a/plugins/ndf/skills/cross-review/docs/04-contracts.md +++ b/plugins/ndf/skills/cross-review/docs/04-contracts.md @@ -22,6 +22,17 @@ "pr_author": "someone", "is_own_pr": false, "event_downgrade": false, + "participants": { + "pool": ["codex", "agy", "kiro"], + "included": [], "excluded": ["agy"], + "available": ["codex"], + "unavailable": {"kiro": "kiro-cli が見つかりません"}, + "probe_skipped": false, "require_all": false, + "fallback": ["claude"] + }, + "resume_changes": [ + {"at": "...", "field": "max_rounds", "from": 12, "to": 4} + ], "pr_history": [ {"pr": 123, "opened_at": "...", "closed_at": null, "rounds": 2} ], @@ -43,10 +54,11 @@ "pr": 123, "started_at": "...", "verdict": "changes_requested", + "reviewers": ["codex", "claude-2"], "codex": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", "comments": 5, "review_url": "...", "by_severity": {"critical": 0, "major": 3, "minor": 2, "nit": 0}}, - "agy": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", + "claude-2": {"intent": "REQUEST_CHANGES", "posted_as": "COMMENT", "comments": 3, "review_url": "...", "by_severity": {"critical": 0, "major": 2, "minor": 1, "nit": 0}}, "fix": {"commit": "abc1234", "fixed": 6, "deferred": 2, "rejected": 0, @@ -85,6 +97,24 @@ `final` 値: `approved` / `max_rounds` / `oscillation` / `error` +### 席の名前 + +**担当の単位は席の名前である。** 形は `<ランタイム名>` か `<ランタイム名>-<2〜9>` で、 +正規表現にすると `^(claude|codex|agy|kiro)(-[2-9])?$`(共通層の `assignment.SEAT_PATTERN`)。 +接尾辞の付いた名前は、使える者が足りないラウンドで立てる**同じランタイムの 2 つ目**を指す。 + +| 現れる場所 | 値の例 | +| --- | --- | +| `rounds[].reviewers` | `["codex", "claude-2"]` | +| `rounds[].<席の名前>` の鍵 | `claude-2` | +| `review_findings[].agent` と `finding_id` の接頭 | `claude-2` / `claude-2-r1-0` | +| 結果ファイルの stem | `<席の名前>-review-pr<番号>` | + +起動する CLI はハイフンの手前を取って選ぶ(シェルは `${SEAT%%-*}`、Python は +`assignment.seat_runtime`)。ランタイム名にハイフンを含むものが無いため、両者は同じ +規則になる。**1 つ目の席の名前はランタイム名そのままである**ため、埋め合わせが要らない +実行ではこの変更の前と同じ名前しか現れない。 + ### 重要なフィールド - `host` — 確定したホスト名(`claude` / `codex` / `agy` / `kiro`)。母集合から外れる @@ -124,7 +154,16 @@ (`fix` が int を返す経路)があるためである。** そのときは記録が空になり、件数だけが残る。 **項目が欠けた要素も落とさない**(落とすと却下そのものが記録から消える) - `host_source` — `explicit`(`--host`)または `env`(環境変数からの推定) -- `rounds[].reviewers` — そのラウンドのレビュー担当 2 者。**ラウンドを開くときに決めて残す** +- `participants` — 使える者の解決の結果(#727)。`pool`(母集合の既定)/ `included` / + `excluded` / `available`(使える者)/ `unavailable`(名前 → 確認が通らなかった理由)/ + `probe_skipped`(確認を飛ばしたか)/ `require_all` / `fallback`(席の埋め合わせに使える + 相手)の 8 項目。**この項目を持たない状態ファイルは、この変更の前に始めた実行である** + (読み方は `05-pool-and-convergence.md`)。`unavailable` が空である理由は 2 つあり、 + `probe_skipped` がそれを分ける(全員が通った / 確認を飛ばした) +- `resume_changes` — 再開で変えた値の記録(#727)。要素は `at` / `field` / `to` / `from` で、 + `field` は状態ファイルの鍵である。**追記だけを行う。** 参加者の記録を作り直したときは + `participants` の 1 件として積む(中の項目ごとには積まない) +- `rounds[].reviewers` — そのラウンドのレビュー担当 2 席。**ラウンドを開くときに決めて残す** - `worktree_path` — 並行セッションとの分離。サブエージェントへの cwd 指示にも使う - `is_own_pr` / `event_downgrade` — 自分の PR の場合 `REQUEST_CHANGES → COMMENT` 強制ダウングレード - `rounds[].<担当>.intent` — AI の本来判定。**ループ判定はこれを見る**。担当ごとのキーの diff --git a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md index 48ef20bd..2cab47c6 100644 --- a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md +++ b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md @@ -14,24 +14,62 @@ Step 1(ラウンドの開始)と Step 3(判定)が読む基準を持つ | --- | --- | | ホスト | `--host` の明示指定、または環境変数からの推定(`detect_host`)。**推定できなければ `init` が失敗する** | | 母集合 | `review_pool(host)` の 3 者 | -| そのラウンドの担当 | `review_assign(round_no, host)` の 2 者。外す 1 者をラウンドごとに回す | +| 使える者 | 母集合 + `--include` で足した者 − `--exclude` で外した者 を確認へ通し、通った者(`resolve_participants`) | +| そのラウンドの席 | 使える者と埋め合わせから 2 席(`review_seats`)。`init` が `participants` へ残す | + +### 使える者の決め方 + +**1 者が使えなくても始める。** 導入していない CLI や認証の切れた CLI が 1 者あるだけで +収束ループ全体を開始できないと、他の 2 者で回せる場面まで止まる。確認は「止める関門」では +なく「誰が使えるかを把握する手順」であり、通らなかった者は理由とともに状態ファイルの +`participants.unavailable` へ残して先へ進む。 + +| 引数 | 何をするか | +| --- | --- | +| `--exclude NAMES` | 母集合から外す。今は呼びたくない相手を、確認の前に落とす | +| `--include NAMES` | 母集合に足す(ホストも足せる)。母集合の外の相手を 1 度だけ呼ぶ | +| `--require-all` | 従来の関門に戻す。確認を通らない者が 1 者でもいれば `init` を失敗させる | + +名前の矛盾(母集合に無い名前、足す者と外す者の重なり、1 者指定と外す者の食い違い)は +`init` が状態ファイルを作る前に弾き、終了コード 1 で終わる。確認そのものを飛ばしたい +ときは `NDF_SKIP_AUTH_CHECK=1` を使う(飛ばしたことは出力と状態ファイルへ残る)。 + +### 席の埋め方 + +**毎ラウンド 2 席を確保する。** 1 席になると、指摘が 1 つの言語モデルの見方だけで決まり、 +反証(`docs/06`)も成り立たない。**違うランタイムを先に使う。** 同じ言語モデルの 2 つの +文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる。 + +| 使える者の数 | 席 | +| ---: | --- | +| 3 以上 | 輪番で 2 席。外す 1 者をラウンドごとに回す | +| 2 | その 2 者 | +| 1 | その 1 者とホスト。ホストが使えないか、その 1 者と同じならその席の 2 つ目 | +| 0 | ホストとその 2 つ目。ホストも使えなければ `init` が失敗する | + +同じランタイムの 2 つ目は、名前に `-2` を付けた**席の名前**(`claude-2`)で表す。結果 +ファイルの名前と状態ファイルの鍵がこの名前になり、起動する CLI はハイフンの手前から選ぶ +(形は `04-contracts.md`)。**埋め合わせは使える者に含まれない相手だけを使う。** 含まれる +相手を充てると、同じ席の名前が 2 つ並ぶ。 **担当はラウンドを開くときに決めて状態へ残す。** 後から輪番を引き直すと、記録と実際に -起動した担当がずれる。`start-round` が `REVIEWERS` / `REVIEWERS_CSV` で返す。 +起動した担当がずれる。`start-round` が `REVIEWERS` / `REVIEWERS_CSV` で返す。**シェル変数で +絞り直さない。** 返る一覧は 1 者指定と埋め合わせを反映済みで、絞ると状態ファイルとずれた +ときに起動も監視も誰にも当たらない。 + +### 1 者だけで回す指定 -**`host` を持たない状態ファイルは `codex` / `agy` の 2 者として読む。** 中断した実行を -新しい版で再開したときに、担当が入れ替わって前のラウンドの記録と突き合わせられなくなる -ことを避ける。 +**`--only` はそのラウンドの担当を 1 者へ絞り、席の埋め合わせを行わない。** 利用者が 1 席と +決めた指定であるため、2 席へ戻さない。輪番が返す 2 者を担当のまま残すと、指定した 1 者が +含まれないラウンドで誰も起動されない。そのとき全員が「指定によるスキップ」として扱われ、 +**レビューが行われていないのに収束する**。母集合の外を指定した場合は `init` が弾く。 -**`--only` はそのラウンドの担当を 1 者へ絞る。** 輪番が返す 2 者を担当のまま残すと、 -指定した 1 者が含まれないラウンドで誰も起動されない。そのとき全員が「指定によるスキップ」 -として扱われ、**レビューが行われていないのに収束する**。母集合の外を指定した場合は `init` が -起動する前に弾く。 +### 参加者の記録を持たない状態ファイル -**認証は `init` が起動前に確かめる。相手は実際に起動する担当だけである。** 未認証の CLI は -起動から短時間で終わり、結果を残さないまま担当から欠ける。`--only` で 1 者へ絞ったときに -母集合の全員を確かめると、そのラウンドで起動しない CLI の未認証で初期化が失敗する。確認コマンドは CLI の版で変わりうるため、 -`NDF_SKIP_AUTH_CHECK=1` で飛ばせる(飛ばしたことは出力へ残る)。 +この変更の前に始めた実行の状態ファイルには `participants` が無い。そのときは `host` から +変更前と同じ輪番(`review_seats(round, review_pool(host), [])`)で 2 席を決める。`host` も +無ければ `codex` / `agy` の 2 者として読む。中断した実行を新しい版で再開したときに、担当が +入れ替わって前のラウンドの記録と突き合わせられなくなることを避ける。 ## 終了基準 diff --git a/plugins/ndf/skills/cross-review/scripts/critique.sh b/plugins/ndf/skills/cross-review/scripts/critique.sh index a46d62a2..fdb6b034 100755 --- a/plugins/ndf/skills/cross-review/scripts/critique.sh +++ b/plugins/ndf/skills/cross-review/scripts/critique.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # cross-review 反証の起動(#156 の 3 本目)。 # -# Usage: critique.sh +# Usage: critique.sh # -# runtime claude | codex | agy | kiro +# seat claude | codex | agy | kiro(同じランタイムの 2 つ目は `-2`〜`-9` を付ける) # # **提案者以外の担当が、各指摘へ 1 つの値を返す。** 値は support / refute / # insufficient_evidence / duplicate / out_of_scope の 5 つで、`state.py @@ -15,7 +15,10 @@ # **同じラウンドの 2 段目として回す。** 新しいラウンドを足すと、収束の上限(12)の # 意味が変わる。 # -# 状態ファイル: $TMP_DIR/-critique-pr-round.json +# **席の名前で受ける。** 指摘に載る担当も席の名前であるため、提案者かどうかの判定には +# 席の名前をそのまま使う。起動する CLI だけを `${SEAT%%-*}` で選ぶ(設計の決定 10)。 +# +# 状態ファイル: $TMP_DIR/-critique-pr-round.json set -euo pipefail @@ -24,13 +27,14 @@ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) . "$SCRIPT_DIR/_tmpdir.sh" load_context() { - RUNTIME=${1:?runtime required} + SEAT=${1:?seat required} STATE_PR=${2:?STATE_PR required} ROUND=${3:?ROUND required} - case "$RUNTIME" in - claude|codex|agy|kiro) ;; - *) echo "未知のランタイムです: $RUNTIME" >&2; exit 1 ;; - esac + # 席の名前の形(`lib/assignment.py` の `SEAT_PATTERN` と同じ規則)。 + if [[ ! $SEAT =~ ^(claude|codex|agy|kiro)(-[2-9])?$ ]]; then + echo "受け付けられない席の名前です: $SEAT" >&2; exit 1 + fi + RUNTIME=${SEAT%%-*} TMP_DIR=$(tmpdir) STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json [ -s "$STATE" ] || { echo "state.json not found: $STATE" >&2; exit 1; } @@ -40,7 +44,7 @@ load_context() { load_context "$@" -STEM=$TMP_DIR/$RUNTIME-critique-pr$STATE_PR +STEM=$TMP_DIR/$SEAT-critique-pr$STATE_PR # **前のラウンドの pid ファイルを先に捨てる。** 監視は `.pid` の有無で起動を # 見るため、残骸があると起動していない担当を起動済みと読む(`` はラウンドを # 名前に持たない)。対象が無くて起動しない経路より前に捨てる。 @@ -53,7 +57,7 @@ rm -f "$STEM.pid" # コマンド・終了コード・再現の結果を読んだうえで賛否を決める(`docs/06-evidence.md` の # 「走らせる順序」)。射影から外すと、担当は結果を見ないまま賛否を返すことになる。 select_targets() { -TARGETS=$(jq -r --arg agent "$RUNTIME" --argjson round "$ROUND" ' +TARGETS=$(jq -r --arg agent "$SEAT" --argjson round "$ROUND" ' [ (.review_findings // [])[] | select(.round == $round) | select(has("merged_into") | not) @@ -62,14 +66,14 @@ TARGETS=$(jq -r --arg agent "$RUNTIME" --argjson round "$ROUND" ' suggested_check, verification} ]' "$STATE") if [ "$(printf '%s' "$TARGETS" | jq 'length')" = "0" ]; then - echo "⏭ $RUNTIME: 反証の対象がありません(すべて自分の指摘)" + echo "⏭ $SEAT: 反証の対象がありません(すべて自分の指摘)" exit 0 fi } select_targets -OUT=$TMP_DIR/$RUNTIME-critique-pr$STATE_PR-round$ROUND.json +OUT=$TMP_DIR/$SEAT-critique-pr$STATE_PR-round$ROUND.json rm -f "$OUT" render_critique_prompt() { diff --git a/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh b/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh index a1e0f910..036cf92b 100755 --- a/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh +++ b/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh @@ -1,9 +1,9 @@ #!/usr/bin/env bash # cross-review レビュワー起動の入口(4 ランタイム共通)。 # -# Usage: launch-reviewer.sh +# Usage: launch-reviewer.sh # -# runtime claude | codex | agy | kiro +# seat claude | codex | agy | kiro(同じランタイムの 2 つ目は `-2`〜`-9` を付ける) # # 引数 STATE_PR は state.json の key (= 最初に init した PR 番号)。 # レビュー対象の PR は state.json の `current_pr` を読む。 @@ -17,18 +17,23 @@ # 作業領域の外を読ませずに済む。 # - 完了判定は monitor.py が pidfile + result.json で多軸判定する。 # -# 状態ファイル: $TMP_DIR/-review-pr-{result,err,stdout,pid}.json +# **席の名前で受ける。** 使える者が 2 者に満たないラウンドでは、同じランタイムの 2 つ目が +# 席に入る(設計の決定 10)。起動する CLI は `${SEAT%%-*}` で選び、結果ファイルの名前は +# 席の名前で組む。両者を分けないと、2 つの席の結果が同じファイルを奪い合う。 +# +# 状態ファイル: $TMP_DIR/-review-pr-{result,err,stdout,pid}.json # (パスは STATE_PR ベースで固定 — monitor.py / state.py と一致させる。) set -euo pipefail -RUNTIME=${1:?runtime required} +SEAT=${1:?seat required} STATE_PR=${2:?STATE_PR required} ROUND=${3:?ROUND required} -case "$RUNTIME" in - claude|codex|agy|kiro) ;; - *) echo "未知のランタイムです: $RUNTIME" >&2; exit 1 ;; -esac +# 席の名前の形(`lib/assignment.py` の `SEAT_PATTERN` と同じ規則)。 +if [[ ! $SEAT =~ ^(claude|codex|agy|kiro)(-[2-9])?$ ]]; then + echo "受け付けられない席の名前です: $SEAT" >&2; exit 1 +fi +RUNTIME=${SEAT%%-*} SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # shellcheck source=_tmpdir.sh @@ -57,11 +62,11 @@ prepare_prompt_context() { # 前ラウンドの結果を残さない。投稿失敗などで今ラウンドの result.json が # 書かれなかったとき、state.py read-result が**前ラウンドの結果を読んで** # 同じ判定を繰り返す事故を防ぐ。 -rm -f "$TMP_DIR/$RUNTIME-review-pr$STATE_PR-result.json" \ - "$TMP_DIR/$RUNTIME-review-pr$STATE_PR-round$ROUND-payload.json" \ - "$TMP_DIR/$RUNTIME-review-pr$STATE_PR-round$ROUND-api-payload.json" +rm -f "$TMP_DIR/$SEAT-review-pr$STATE_PR-result.json" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-payload.json" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-api-payload.json" -STEM=$TMP_DIR/$RUNTIME-review-pr$STATE_PR +STEM=$TMP_DIR/$SEAT-review-pr$STATE_PR PROMPT=$STEM-prompt.md # 既存コメントは **プロンプトにインライン埋め込み** する。 # tmp dir は `/.cross_review/` を使うが、埋め込みなら読み取りの往復が @@ -90,9 +95,9 @@ fi render_review_prompt() { cat > "$PROMPT" < + ## 🤖 cross-review | round $ROUND | $SEAT | \`\`\` - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) diff --git a/plugins/ndf/skills/cross-review/scripts/measure.py b/plugins/ndf/skills/cross-review/scripts/measure.py index 9fa53bf8..148d5efe 100755 --- a/plugins/ndf/skills/cross-review/scripts/measure.py +++ b/plugins/ndf/skills/cross-review/scripts/measure.py @@ -26,14 +26,19 @@ import datetime as _dt import json import pathlib +import re import sys from typing import Any, NamedTuple -# **担当の名前は 4 つである。** `reviewers` を持たない古い記録で、結果を残した -# 担当を数えるために使う(`state.py` の `LEGACY_AGENTS` は 2 者で、母集合を -# 広げる前の既定値である。ここは記録にある値だけを数えるため一覧を広く取る)。 -AGENT_NAMES = ("codex", "agy", "claude", "kiro") +# 席の名前の形(`lib/assignment.py` の `SEAT_PATTERN` と同じ規則)。`reviewers` を持たない +# 古い記録で、結果を残した担当を数えるために使う。**名前の一覧では数えない。** 使える者が +# 2 者に満たないラウンドには同じランタイムの 2 つ目(`claude-2`)が入り、一覧では +# その結果が漏れる(#727)。 +# +# **共通層を読み込まない。** この測定は状態ファイル 1 つを読むだけの自己完結スクリプトで、 +# 収束ループの外から単体で呼べることを保つ。 +SEAT_PATTERN = re.compile(r"^(claude|codex|agy|kiro)(-[2-9])?$") def _as_int(value: Any) -> int | None: @@ -125,7 +130,8 @@ def _reviewer_count(round_rec: dict[str, Any]) -> int: reviewers = round_rec.get("reviewers") if isinstance(reviewers, list) and reviewers: return len(reviewers) - return sum(1 for name in AGENT_NAMES if isinstance(round_rec.get(name), dict)) + return sum(1 for key, value in round_rec.items() + if SEAT_PATTERN.match(key) and isinstance(value, dict)) def _cost(st: dict[str, Any]) -> dict[str, Any]: diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 71d373bd..c6705776 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -4592,7 +4592,7 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--rotate-after", type=int, default=None) sp.add_argument( "--only", type=_runtime_or_none, default=None, - help="片方だけで回す(デバッグ用)") + help="1 者だけで回す。席の埋め合わせを行わない。none で指定を外す") sp.add_argument( "--exclude", action="append", type=_runtime_list, default=None, help="母集合から外す者。カンマ区切り・繰り返し可。再開で `none` を渡すと空へ戻す") diff --git a/plugins/ndf/skills/cross-review/scripts/wait-review.sh b/plugins/ndf/skills/cross-review/scripts/wait-review.sh index 9221e7d2..7b04bece 100755 --- a/plugins/ndf/skills/cross-review/scripts/wait-review.sh +++ b/plugins/ndf/skills/cross-review/scripts/wait-review.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash -# Wait for codex / agy review processes — monitor.py の薄いラッパ。 +# レビューの席の完了待ち — monitor.py の薄いラッパ。 # -# Usage: wait-review.sh [codex|agy|both] [--timeout SEC] [--stall-timeout SEC] +# Usage: wait-review.sh [<席の名前>|both] [--timeout SEC] [--stall-timeout SEC] +# +# 席の名前 claude | codex | agy | kiro(同じランタイムの 2 つ目は `-2`〜`-9` を付ける) +# both これまでの 2 者(codex / agy)を指す省略形 # # 既定値(上限の表 `scripts/lib/limits.py` が持つ。#598 / #537): # timeout 1200s (= 20 min、工程 review) env MONITOR_TIMEOUT_ / MONITOR_TIMEOUT で上書き -# stall-timeout 担当別 codex 180s / agy 480s / kiro 480s / claude 900s +# stall-timeout 席のランタイム別 codex 180s / agy 480s / kiro 480s / claude 900s # env MONITOR_STALL_ / MONITOR_STALL で上書き # poll 15s env MONITOR_POLL で上書き # diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py index e4f50d10..d8c6c5cd 100644 --- a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py +++ b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py @@ -1,4 +1,4 @@ -"""未知ランタイムを副作用なしで拒否する入口の現状固定。""" +"""席の形に合わない名前を副作用なしで拒否する入口の現状固定。""" import os import pathlib import subprocess @@ -6,7 +6,7 @@ SCRIPT = pathlib.Path(__file__).resolve().parent.parent / "scripts/launch-reviewer.sh" -def test_unknown_runtime_exits_before_writing_files(tmp_path): +def test_a_name_outside_the_seat_pattern_exits_before_writing_files(tmp_path): result = subprocess.run( ["bash", str(SCRIPT), "bogus", "1", "1"], env={**os.environ, "CROSS_REVIEW_TMP_DIR": str(tmp_path)}, @@ -14,7 +14,7 @@ def test_unknown_runtime_exits_before_writing_files(tmp_path): ) assert result.returncode == 1 - assert "未知のランタイム" in result.stderr + assert "受け付けられない席の名前です" in result.stderr assert list(tmp_path.iterdir()) == [] diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py b/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py index 400c416b..0c3bc47e 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py @@ -25,14 +25,15 @@ def test_the_monitor_accepts_the_new_name(tmp_path) -> None: # 起動待ちの 30 秒を使い切らないよう、終了済みの pid を先に置く。 (tmp_path / "agy-review-pr1.pid").write_text("2147483646\n", encoding="utf-8") r = _run("1", "agy", "--tmp-dir", str(tmp_path), "--timeout", "1", "--poll", "1") - assert "invalid choice" not in r.stderr + assert "席の名前の形が違います" not in r.stderr assert r.returncode != 2 def test_the_monitor_rejects_the_old_name() -> None: + """綴りの検査は席の名前の形が行う(#727)。通らなければ終了コード 2。""" r = _run("1", "gemini") assert r.returncode == 2 - assert "invalid choice" in r.stderr + assert "席の名前の形が違います" in r.stderr # ---------- 受け入れ条件 19(無進捗の許容時間) ---------- diff --git a/plugins/ndf/skills/cross-review/tests/test_seat_names.py b/plugins/ndf/skills/cross-review/tests/test_seat_names.py index 3842fae3..2dc3d88c 100644 --- a/plugins/ndf/skills/cross-review/tests/test_seat_names.py +++ b/plugins/ndf/skills/cross-review/tests/test_seat_names.py @@ -1,17 +1,20 @@ -"""席の名前を受け口が通すか(#727 の AC21、結果の受け口の部分)。 +"""席の名前を受け口が通すか(#727 の AC21)。 担当の単位は「席の名前」になった(設計の決定 10)。形は `assignment.SEAT_PATTERN` (ランタイム名か、その名前に `-2`〜`-9` を付けたもの)。使える者が 2 者に満たない ラウンドでは、同じランタイムの 2 つ目(`claude-2`)が席に入る。**受け口がこの形を 弾くと、結果を残した担当が「結果なし」として扱われる。** -綴りの検査は argparse の型が行い、通らなければ終了コード 2 になる。 +見るのは結果の受け口・起動スクリプト・監視の位置引数・計測の 4 つである。綴りの検査は +argparse の型が行い、通らなければ終了コード 2 になる。シェル側は席の形に合わない名前を +終了コード 1 で弾く。 """ from __future__ import annotations import argparse import json import pathlib +import sys import pytest @@ -76,3 +79,152 @@ def test_the_result_of_a_second_seat_is_recorded_under_its_seat_name(tmp_dir, st st = json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) assert st["rounds"][-1][SEAT]["intent"] == "APPROVE" + + +# ---------------- 起動スクリプト ---------------- +# +# 起動スクリプトは席の名前を受け、CLI は `${SEAT%%-*}` で選ぶ(設計の決定 10)。 +# **渡した先を差し替えて確かめる。** 実物の共通の起動スクリプトを呼ぶと CLI を起動する。 +# 差し替えのために、起動スクリプトの隣に置いた符号のリンクから、相対で解決される +# 共通層の位置(`../../../scripts/lib`)へ控えを置く。 + +LAUNCH_SCRIPTS = pathlib.Path(__file__).resolve().parents[1] / "scripts" +LIB = pathlib.Path(__file__).resolve().parents[3] / "scripts" / "lib" + + +def _stub_tree(tmp_path: pathlib.Path) -> tuple[pathlib.Path, pathlib.Path]: + """起動スクリプトの符号のリンクと、差し替えた共通の起動スクリプトを置く。 + + 返すのは `(起動スクリプトのパス, 渡された引数を書き出す控えのパス)`。 + """ + fake_scripts = tmp_path / "plugin" / "skills" / "cross-review" / "scripts" + fake_scripts.mkdir(parents=True) + for name in ("launch-reviewer.sh", "_tmpdir.sh"): + (fake_scripts / name).symlink_to(LAUNCH_SCRIPTS / name) + fake_lib = tmp_path / "plugin" / "scripts" / "lib" + fake_lib.mkdir(parents=True) + (fake_lib / "_tmpdir.sh").symlink_to(LIB / "_tmpdir.sh") + record = tmp_path / "launch-args.txt" + stub = fake_lib / "launch-cli.sh" + stub.write_text(f'#!/usr/bin/env bash\nprintf "%s\\n" "$@" > "{record}"\n') + stub.chmod(0o755) + return fake_scripts / "launch-reviewer.sh", record + + +def _run_launch(script: pathlib.Path, seat: str, tmp_dir: pathlib.Path): + import os + import subprocess + + state = { + "current_pr": PR, "repo": "o/r", "worktree_path": str(tmp_dir), + "rounds": [{"round": 1, "head_sha": "a" * 40}], + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + return subprocess.run( + ["bash", str(script), seat, str(PR), "1"], + env={**os.environ, "CROSS_REVIEW_TMP_DIR": str(tmp_dir)}, + capture_output=True, text=True, timeout=30, + ) + + +def test_the_launcher_passes_the_runtime_of_the_seat_to_the_shared_launcher(tmp_path): + """AC21: `claude-2` で起動すると、共通の起動スクリプトへ渡るのは `claude` である。""" + script, record = _stub_tree(tmp_path) + tmp_dir = tmp_path / "work" + tmp_dir.mkdir() + + result = _run_launch(script, SEAT, tmp_dir) + + assert result.returncode == 0, result.stderr + assert record.read_text().splitlines()[0] == "claude" + + +def test_the_launcher_builds_the_stem_from_the_seat_name(tmp_path): + """AC21: 結果ファイルの stem は席の名前で組む(`claude-2-review-pr`)。""" + script, record = _stub_tree(tmp_path) + tmp_dir = tmp_path / "work" + tmp_dir.mkdir() + + result = _run_launch(script, SEAT, tmp_dir) + + assert result.returncode == 0, result.stderr + assert record.read_text().splitlines()[3] == str(tmp_dir / f"{SEAT}-review-pr{PR}") + assert (tmp_dir / f"{SEAT}-review-pr{PR}-prompt.md").is_file() + + +@pytest.mark.parametrize("script_name", ["launch-reviewer.sh", "critique.sh"]) +def test_the_launch_scripts_reject_a_name_outside_the_seat_pattern(tmp_path, script_name): + import os + import subprocess + + result = subprocess.run( + ["bash", str(LAUNCH_SCRIPTS / script_name), "bogus", "1", "1"], + env={**os.environ, "CROSS_REVIEW_TMP_DIR": str(tmp_path)}, + capture_output=True, text=True, timeout=30, + ) + + assert result.returncode == 1 + assert "受け付けられない席の名前です" in result.stderr + assert list(tmp_path.iterdir()) == [] + + +# ---------------- 監視の位置引数 ---------------- + +def test_the_monitor_accepts_a_second_seat_as_its_target(monitor_mod): + """AC21: 監視の位置引数は席の名前を受ける。""" + assert monitor_mod._seat_or_both("kiro-2") == "kiro-2" + assert monitor_mod._seat_or_both("both") == "both" + for runtime in ("claude", "codex", "agy", "kiro"): + assert monitor_mod._seat_or_both(runtime) == runtime + + +def _run_monitor(tmp_path: pathlib.Path, *argv: str): + import os + import subprocess + + monitor = pathlib.Path(__file__).resolve().parents[1] / "scripts" / "monitor.py" + return subprocess.run( + [sys.executable, str(monitor), *argv], + env={**os.environ, "CROSS_REVIEW_TMP_DIR": str(tmp_path)}, + capture_output=True, text=True, timeout=120, + ) + + +def test_the_monitor_takes_a_second_seat_as_its_positional_argument(tmp_path): + """AC21: 位置引数に `kiro-2` を渡しても argparse は弾かない。""" + # 起動待ちを使い切らないよう、終了済みの pid を先に置く。 + (tmp_path / "kiro-2-review-pr1.pid").write_text("2147483646\n", encoding="utf-8") + + result = _run_monitor(tmp_path, "1", "kiro-2", "--tmp-dir", str(tmp_path), + "--timeout", "1", "--poll", "1") + + assert result.returncode != 2, result.stderr + assert "席の名前の形が違います" not in result.stderr + + +def test_the_monitor_rejects_a_name_outside_the_seat_pattern(tmp_path): + result = _run_monitor(tmp_path, "1", "gemini") + + assert result.returncode == 2, result.stderr + assert "席の名前の形が違います" in result.stderr + + +def test_the_runtime_of_a_seat_is_used_for_the_cli_specific_checks(monitor_mod): + """席の形に合わない名前はそのまま返す(cross-refactoring の任意の骨格のため)。""" + assert monitor_mod._agent_runtime("codex-2") == "codex" + assert monitor_mod._agent_runtime("impl") == "impl" + + +# ---------------- 計測 ---------------- + +def test_the_measure_counts_a_second_seat(measure_mod): + """AC21: 席の名前で残った結果も、そのラウンドの担当の数に入る。""" + assert measure_mod._reviewer_count( + {"round": 1, "pr": 1, "codex": {"intent": "APPROVE"}, SEAT: {"intent": "APPROVE"}} + ) == 2 + + +def test_the_measure_ignores_keys_outside_the_seat_pattern(measure_mod): + assert measure_mod._reviewer_count( + {"round": 1, "pr": 1, "ci": {"state": "SUCCESS"}, "claude-1": {"intent": "APPROVE"}} + ) == 0 diff --git a/plugins/ndf/skills/cross-review/tests/test_skill_layout.py b/plugins/ndf/skills/cross-review/tests/test_skill_layout.py index 07e75981..f0035997 100644 --- a/plugins/ndf/skills/cross-review/tests/test_skill_layout.py +++ b/plugins/ndf/skills/cross-review/tests/test_skill_layout.py @@ -121,3 +121,30 @@ def test_the_procedure_points_at_the_contract_document() -> None: def test_the_skill_points_at_the_contract_document() -> None: assert "docs/04-contracts.md" in SKILL.read_text(encoding="utf-8") + + +# ---- 1 者指定のシェル変数で絞らない(#727 の AC30) ---- +# +# ラウンドの開始が返す担当の一覧は、1 者指定と席の埋め合わせを反映済みである。 +# シェル変数でもう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も +# 誰にも当たらない(設計の決定 17)。1 者指定のシェル変数は初期化へ渡す 1 行にだけ残す。 + +ONLY_DOCS = (SKILL, PROCEDURE) + + +@pytest.mark.parametrize("doc", ONLY_DOCS, ids=lambda p: p.name) +def test_the_only_variable_appears_only_where_it_is_passed_to_init(doc: pathlib.Path) -> None: + offenders = [ + f"{doc.name}:{no}: {line.strip()}" + for no, line in enumerate(doc.read_text(encoding="utf-8").splitlines(), 1) + if "ONLY" in line and "--only" not in line + ] + assert offenders == [], offenders + + +@pytest.mark.parametrize("doc", ONLY_DOCS, ids=lambda p: p.name) +def test_the_reviewers_returned_by_the_round_are_used(doc: pathlib.Path) -> None: + """起動・監視・取り込み・反証は、ラウンドの開始が返す一覧を使う。""" + body = doc.read_text(encoding="utf-8") + assert "$REVIEWERS_CSV" in body + assert "${ONLY:-" not in body From 0cbb5aaaa9cd6591ff650f1e3c96674886c99e2e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:42:29 +0000 Subject: [PATCH 086/217] =?UTF-8?q?Docs:=20=E5=AE=9F=E8=A3=85=E3=81=A7?= =?UTF-8?q?=E6=B1=BA=E3=82=81=E3=81=9F=203=20=E4=BB=B6=E3=82=92=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=81=B8=E6=9B=B8=E3=81=8D=E3=80=81?= =?UTF-8?q?=E5=85=B1=E9=80=9A=E5=B1=A4=E3=81=AE=E7=B4=A2=E5=BC=95=E3=81=AB?= =?UTF-8?q?=E6=96=B0=E3=81=97=E3=81=84=E5=BD=B9=E5=89=B2=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99=EF=BC=88#727=20#687=20#478=20#648=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- issues/issue-727-687-478-664-648-design.md | 15 ++++++++++----- plugins/ndf/scripts/lib/README.md | 5 +++-- 2 files changed, 13 insertions(+), 7 deletions(-) diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md index fcf39fe3..16521bb9 100644 --- a/issues/issue-727-687-478-664-648-design.md +++ b/issues/issue-727-687-478-664-648-design.md @@ -431,17 +431,22 @@ P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削 ## 未確認のまま残ること -7 件が残る。3 件は実装で決め、4 件は運用と #461 が決める。 +4 件が残る。実装で決める 3 件は P6 で決まった(下の表の後)。残る 4 件は運用と #461 が決める。 | 項目 | 内容 | いつ決まるか | | --- | --- | --- | | 同じランタイムの 2 席の観点 | `claude` / `claude-2` の 2 席が、別のランタイムの 2 席より指摘を見落とすかは測っていない | この変更の後の運用(`measure.py`) | -| ホストが席に入ったときの `is_own_pr` の扱い | ホストの CLI が自分の Pull Request をレビューするとき、投稿の event が `COMMENT` へ倒れる既存の規則で足りるかは確かめていない | P6 の実装で 1 度回して見る | +| ホストが席に入ったときの `is_own_pr` の扱い | ホストの CLI が自分の Pull Request をレビューするとき、投稿の event が `COMMENT` へ倒れる既存の規則で足りるかは確かめていない。P6 では実機で回していない | P6 の後の運用で 1 度回して見る | | cross-refactoring でホストが提案に入ることの所要 | 提案は最も遅い者を待つ。claude の提案の所要は測っていない(適用は中央値 2 分) | P7 の後の運用 | | 確認を「言語モデルを引く最小の呼び出し」へ替えるか | #461。所要の実測が要る | マイルストーン 06 の着手時 | -| `monitor.py` の CLI 固有の検査を席の名前に通す形 | `seat_runtime` で選ぶと決めたが、`agent` の比較が 6 か所あり、共通の関数へ寄せるかは実装で決める | **実装で決める** | -| 出力の文言 | 反映した行・知らせる行・埋め合わせの行の文言は、項目名と値を含むことだけを決めた | **実装で決める** | -| テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | + +### P6 の実装で決めた 3 件 + +| 項目 | 決めたこと | +| --- | --- | +| `monitor.py` の CLI 固有の検査を席の名前に通す形 | 席の名前からランタイムを引く内部関数を 1 つ置き、CLI 固有の比較 3 か所をその関数で包む。**席の形に合わない名前はそのまま返す**(担当名を任意の骨格で受ける cross-refactoring の経路を壊さないため)。位置引数の選択肢は席の形を受ける型の検査へ替えた | +| 出力の文言 | 反映した行は `↻ <項目>: <旧> → <新>`、知らせる行は `ℹ --<引数> は再開では反映しません(状態: <値> / 指定: <値>)`、通らなかった者は `⚠ <名前> を担当から外しました(<理由>)`、埋め合わせは `⚠ 使える者が <数> 者のため、席を<相手>で埋めます(観点が減ります)`。既存の初期化の出力の印(`↻` / `ℹ` / `⚠`)に揃える | +| テストの置き場所 | テスト設計の表のとおり。席の埋め方は cross-refactoring の割り当てのテスト(変更前の席の割り当ての期待値が同じファイルにある)、起動と監視と計測の席の名前は cross-review の `tests/test_seat_names.py`(新設) | ## 申し送り(並行する設計との境界) diff --git a/plugins/ndf/scripts/lib/README.md b/plugins/ndf/scripts/lib/README.md index 3226c021..01f5113c 100644 --- a/plugins/ndf/scripts/lib/README.md +++ b/plugins/ndf/scripts/lib/README.md @@ -23,9 +23,10 @@ | [monitor_outcome.py](monitor_outcome.py) | 監視の結果の理由の語彙と、結果ファイル・監視の記録の読み書き(#662) | 同上 | | [launch-cli.sh](launch-cli.sh) | claude / codex / agy / kiro をランタイム名で分岐して背景起動する | 同上 | | [_tmpdir.sh](_tmpdir.sh) | 一時ディレクトリの解決。環境変数名とディレクトリ名を引数で受ける | 同上 | -| [statefile.py](statefile.py) | 状態ファイルの読み書きと KEY=VALUE 出力、保存の後の差し込み口 | 同上 | +| [statefile.py](statefile.py) | 状態ファイルの読み書きと KEY=VALUE 出力、保存の後の差し込み口、再開で渡した引数の反映(#727) | 同上 | +| [auth.py](auth.py) | 参加する CLI の認証の確認。止めずに結果だけを返す形を持つ(#727) | 同上 | | [run_metrics.py](run_metrics.py) | 実行の要約を作業ツリーの外へ書き、束ねて出す(`aggregate`、#662) | 同上 | -| [assignment.py](assignment.py) | ホスト判定、役割ごとの母集合の確定、担当の輪番 | 同上 | +| [assignment.py](assignment.py) | ホスト判定、母集合の確定、使える者の解決、席の埋め方と席の名前、担当の輪番(#727) | 同上 | | [models.py](models.py) | `--model` の解析、フラグ生成、実測値の突き合わせ | 同上 | | [metrics.py](metrics.py) | 担当ごとの指標算出と報告の整形 | 同上 | | [post_queue.py](post_queue.py) | 上限のときに投稿を積む待ち行列と、上限の見分け(#291) | 同上 | From 5cfb48f41dfd0bf6f39e29cf4641ad212ddc7c43 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:01:30 +0000 Subject: [PATCH 087/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#?= =?UTF-8?q?=5Fverify=5Ffindings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _verify_findings から以下の helper を抽出: - 1 finding の verification record を生成しキャッシュと結果分類を行う _verify_one_finding - merged_into の関係をたどり代表へ最良の verification を選ぶ _select_best_verification _verify_findings を対象抽出、各 finding の検証、代表結果の集約の 3 段階に整理。 Item-Id: R3-001 Round: 3 Impl-Runtime: agy Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 82 ++++++++++++------- 1 file changed, 53 insertions(+), 29 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 9ee5c2ac..ad35f086 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -3065,6 +3065,55 @@ def _merged_root( return current +def _verify_one_finding( + finding: dict[str, Any], + allowed: list[str], + work: str, + codes: set[int], + run: Any, + ran: dict[tuple[str, ...], Optional[int]], +) -> dict[str, Any]: + """1 つの指摘の suggested_check を検証し、verification レコードを生成する。""" + check = str(finding.get("suggested_check") or "") + record: dict[str, Any] = { + "command": check, "finding_id": finding.get("finding_id"), + "exit_code": None, "result": "not_run", "ran_at": None, + } + argv = _verify_argv(check, allowed, work) if allowed else None + if argv is not None: + key = tuple(argv) + if key in ran: + code = ran[key] + else: + code = run(argv, work) + ran[key] = code + record["exit_code"] = code + record["ran_at"] = _now() + if code in codes: + record["result"] = "reproduced" + elif code == 0: + record["result"] = "not_reproduced" + return record + + +def _select_best_verification( + rep: dict[str, Any], + targets: list[dict[str, Any]], + by_id: dict[Any, dict[str, Any]], +) -> dict[str, Any]: + """merged_into の関係をたどり、代表へ最良の verification を選ぶ。""" + best = rep["verification"] + for member in targets: + if member is rep or not member.get("merged_into"): + continue + if _merged_root(member, by_id) is not rep: + continue + if _VERIFY_RANK.get(_verify_result(member), -1) > \ + _VERIFY_RANK.get(str(best.get("result") or "not_run"), -1): + best = member["verification"] + return best + + def _verify_findings( st: dict[str, Any], round_no: int, @@ -3103,40 +3152,15 @@ def _verify_findings( ran: dict[tuple[str, ...], Optional[int]] = {} for finding in targets: - check = str(finding.get("suggested_check") or "") - record: dict[str, Any] = { - "command": check, "finding_id": finding.get("finding_id"), - "exit_code": None, "result": "not_run", "ran_at": None, - } - argv = _verify_argv(check, allowed, work) if allowed else None - if argv is not None: - key = tuple(argv) - if key in ran: - code = ran[key] - else: - code = run(argv, work) - ran[key] = code - record["exit_code"] = code - record["ran_at"] = _now() - if code in codes: - record["result"] = "reproduced" - elif code == 0: - record["result"] = "not_reproduced" - finding["verification"] = record + finding["verification"] = _verify_one_finding( + finding, allowed, work, codes, run, ran, + ) # 代表は組から選び直す。**実行し直さない**(記録済みの結果を選ぶだけである)。 for rep in targets: if rep.get("merged_into"): continue - best = rep["verification"] - for member in targets: - if member is rep or not member.get("merged_into"): - continue - if _merged_root(member, by_id) is not rep: - continue - if _VERIFY_RANK.get(_verify_result(member), -1) > \ - _VERIFY_RANK.get(str(best.get("result") or "not_run"), -1): - best = member["verification"] + best = _select_best_verification(rep, targets, by_id) if best is not rep["verification"]: rep["verification"] = dict(best) From 9e73ace957a4a99f8cf6caa0c18722487127bfc3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:13:25 +0000 Subject: [PATCH 088/217] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py?= =?UTF-8?q?#COUNTED=5FCLASSIFICATIONS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cross-review の scripts / tests の構造改善(振る舞い不変)を 1 コミットにまとめる。 - R4-001: COUNTED_CLASSIFICATIONS を scripts/classifications.py へ集約し、 state.py と measure.py が共有定義を import する(両者の重複を解消)。 - R4-004: conftest.py の autouse fixture を分割。gh 実行ガードは state_mod に 依存させず、state.py の既定差し替えは state_mod を要求するテストだけへ適用する。 - R4-005: measure.py の _proposed から oracle のラウンド別分母絞りを _scoped_oracle_ids として抽出する。 Item-Id: R4-001 Round: 4 Impl-Runtime: kiro Impl-Model: default --- .../cross-review/scripts/classifications.py | 12 ++++ .../skills/cross-review/scripts/measure.py | 56 ++++++++++++------- .../ndf/skills/cross-review/scripts/state.py | 11 ++-- .../ndf/skills/cross-review/tests/conftest.py | 24 +++++++- 4 files changed, 74 insertions(+), 29 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/scripts/classifications.py diff --git a/plugins/ndf/skills/cross-review/scripts/classifications.py b/plugins/ndf/skills/cross-review/scripts/classifications.py new file mode 100644 index 00000000..d25983e0 --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/classifications.py @@ -0,0 +1,12 @@ +"""cross-review の区分に関する共有定義(#156、#732)。 + +収束の判定(`state.py`)と効果の測定(`measure.py`)が同じ区分を数えるための +唯一の定義を置く。片方だけに区分を足すと、判定が数えた指摘を測定が採らず、 +その方式の再現率が実際より低く出る(`test_measure.py` が両者の一致を固定する)。 +""" +from __future__ import annotations + +# 収束の判定が数える区分(#156、#732)。**残る 3 つは数えない。** 数えないのは、誤りだと +# 示された棄却と、承認を妨げない軽微な指摘だけである。棄却した指摘を数えると、そのぶん +# ラウンドが増える(#69 で同じ論点が 5 ラウンド続いた事象)。 +COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment", "unrefuted") diff --git a/plugins/ndf/skills/cross-review/scripts/measure.py b/plugins/ndf/skills/cross-review/scripts/measure.py index ba521c43..cf21ac73 100755 --- a/plugins/ndf/skills/cross-review/scripts/measure.py +++ b/plugins/ndf/skills/cross-review/scripts/measure.py @@ -29,6 +29,11 @@ import sys from typing import Any, NamedTuple +# 区分の定義は scripts 配下の共有モジュールに 1 か所だけ置く(#156、#732)。 +# `state.py` も同じ定義を読み、両者の一致は `test_measure.py` が固定する。 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from classifications import COUNTED_CLASSIFICATIONS # noqa: E402 + # **担当の名前は 4 つである。** `reviewers` を持たない古い記録で、結果を残した # 担当を数えるために使う(`state.py` の `LEGACY_AGENTS` は 2 者で、母集合を @@ -416,12 +421,6 @@ def _majority(representatives: list[dict[str, Any]], return _method_output(finding_ids, oracle_ids) -# 3 本目の区分(#732 で 6 つ)のうち、この変更の方式が採る 3 つ(`state.py` の -# `COUNTED_CLASSIFICATIONS` と同じ。一致は `test_measure.py` が固定する)。 -# **残る 3 つは採らない。** -COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment", "unrefuted") - - def _evidence_rounds(st: dict[str, Any]) -> set[int]: """証拠集約(統合・実行検証・反証)を通ったラウンドの印。 @@ -446,6 +445,31 @@ def _all_rounds_marked(st: dict[str, Any], marked: set[int]) -> bool: return all(_round_no(rounds, i) in marked for i in range(len(rounds))) +def _scoped_oracle_ids( + representatives: list[dict[str, Any]], + oracle_ids: set[str] | None, + marked: set[int], +) -> set[str] | None: + """上限の方式の集合を、印のあるラウンドの指摘だけへ絞る(#156)。 + + 分母を全ラウンドのままにすると、印の混ざった記録で再現率が過小に出る。分子と + 同じ母集合(印のあるラウンド)へ絞るため、`finding_id` から `round` を引いて + `marked` に含まれるものだけを残す。 + + Returns: + - `oracle_ids` が `None`(上限を計算できない)なら `None` を返す。 + - `marked` が全ラウンドを覆うなら、絞り込みの結果は `oracle_ids` と同じになる。 + - 一部のラウンドだけが印を持つなら、そのラウンドの指摘だけが残る。 + """ + if oracle_ids is None: + return None + rounds_by_id = { + str(finding.get("finding_id")): _as_int(finding.get("round")) + for finding in representatives + } + return {fid for fid in oracle_ids if rounds_by_id.get(fid) in marked} + + def _proposed(st: dict[str, Any], representatives: list[dict[str, Any]], oracle_ids: set[str] | None) -> dict[str, Any]: """この変更の方式。**読むのは証拠集約を通ったラウンドだけである。** @@ -453,10 +477,11 @@ def _proposed(st: dict[str, Any], representatives: list[dict[str, Any]], 印の無いラウンドを母集合へ入れると、区分の付かない指摘が `insufficient_evidence` として落ち、方式の再現率が実際より低く出る。 - **分母も印のあるラウンドに限る。** 分子だけを絞ると、印の混ざった記録で - 再現率が過小に出る。印の無い round 1 と印のある round 2 に修正された指摘が - 1 件ずつあるとき、採れるのは round 2 の 1 件だけであり、全ラウンドの上限 - (2 件)で割ると**拾えるものを全部拾っても 0.5 にしかならない**。 + **分母も印のあるラウンドに限る**(絞り込みは `_scoped_oracle_ids` が持つ)。 + 分子だけを絞ると、印の混ざった記録で再現率が過小に出る。印の無い round 1 と + 印のある round 2 に修正された指摘が 1 件ずつあるとき、採れるのは round 2 の + 1 件だけであり、全ラウンドの上限(2 件)で割ると**拾えるものを全部拾っても + 0.5 にしかならない**。 **分母が全ラウンドと違うことは出力へ出す。** 添えないと、読む側がこの方式の 再現率を他の 3 つと同じ分母の値として読む。 @@ -474,16 +499,7 @@ def _proposed(st: dict[str, Any], representatives: list[dict[str, Any]], if _as_int(finding.get("round")) in marked and finding.get("classification") in COUNTED_CLASSIFICATIONS } - if oracle_ids is None: - base_ids = None - else: - rounds_by_id = { - str(finding.get("finding_id")): _as_int(finding.get("round")) - for finding in representatives - } - base_ids = { - fid for fid in oracle_ids if rounds_by_id.get(fid) in marked - } + base_ids = _scoped_oracle_ids(representatives, oracle_ids, marked) result = _method_output(finding_ids, base_ids) result["oracle_scope"] = ( "all_rounds" if _all_rounds_marked(st, marked) else "evidence_rounds" diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index ad35f086..80dc773c 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -36,6 +36,11 @@ import post_queue # noqa: E402 import run_metrics # noqa: E402 実行の要約(#662) +# 区分の定義は scripts 配下の共有モジュールに 1 か所だけ置く(#156、#732)。 +# `measure.py` も同じ定義を読み、両者の一致は `test_measure.py` が固定する。 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from classifications import COUNTED_CLASSIFICATIONS # noqa: E402 + # ---------------- helpers ---------------- @@ -3464,12 +3469,6 @@ def _declared_duplicate_targets(finding: dict[str, Any]) -> set: return targets -# 収束の判定が数える区分(#156、#732)。**残る 3 つは数えない。** 数えないのは、誤りだと -# 示された棄却と、承認を妨げない軽微な指摘だけである。棄却した指摘を数えると、そのぶん -# ラウンドが増える(#69 で同じ論点が 5 ラウンド続いた事象)。 -COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment", "unrefuted") - - def _verdicts(finding: dict[str, Any], verdict: str) -> list[str]: """その値を返した担当の一覧。""" return [ diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf/skills/cross-review/tests/conftest.py index e9bcbc23..67cd6aec 100644 --- a/plugins/ndf/skills/cross-review/tests/conftest.py +++ b/plugins/ndf/skills/cross-review/tests/conftest.py @@ -89,7 +89,7 @@ def _default_host(monkeypatch) -> None: @pytest.fixture(autouse=True) -def _no_github(monkeypatch, state_mod) -> None: +def _no_github(monkeypatch) -> None: """テストから GitHub を呼ばない。 収束の判定は継続的統合を照会するようになった(#327)。差し替えを忘れると、 @@ -97,6 +97,10 @@ def _no_github(monkeypatch, state_mod) -> None: **差し替えていない `gh` の実行はその場で落とす。** `subprocess.run` そのものを差し替えるテストは、この見張りを上書きして先へ進む。 + + **state.py の内部関数の差し替えは持たない。** それは `state_mod` を利用する + テストだけが必要とする(`_no_github_state`)。ここに混ぜると、monitor.py や + measure.py だけを検査するテストまで state.py を読み込む。 """ real = subprocess.run @@ -109,8 +113,22 @@ def _guard(cmd, *args, **kwargs): return real(cmd, *args, **kwargs) monkeypatch.setattr(subprocess, "run", _guard) - # 照会は既定で「確かめられなかった」に倒す。判定は収束を止めない側へ倒すため、 - # 検査ジョブを見ない既存のテストは期待値を変えずに通る。 + + +@pytest.fixture(autouse=True) +def _no_github_state(request, monkeypatch) -> None: + """state.py の GitHub 照会を既定で「確かめられなかった」に倒す。 + + **`state_mod` を要求するテストだけへ適用する。** monitor.py や measure.py だけを + 検査するテストは `state_mod` を要求しないため、この差し替えを通らず state.py を + 読み込まない。要求するテストでは従来どおり実 GitHub 呼び出しを防ぐ。 + + 判定は収束を止めない側へ倒すため、検査ジョブを見ない既存のテストは期待値を + 変えずに通る。 + """ + if "state_mod" not in request.fixturenames: + return + state_mod = request.getfixturevalue("state_mod") monkeypatch.setattr(state_mod, "_fetch_check_runs", lambda repo, sha: None) monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: None) From 8f8d7d1706570ee4b4a0c8037d784d99aaa7fe2b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:14:04 +0000 Subject: [PATCH 089/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 132 ++++++++++++++++++++++++++++++- 1 file changed, 131 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md index 4972cdbc..51a02bee 100644 --- a/issues/refactoring-plan-rf790.md +++ b/issues/refactoring-plan-rf790.md @@ -117,7 +117,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | integration | — | agy / kiro | 検証中 | 1 | +| error | integration | — | agy / kiro | 採用 | 1 | **なぜ**: load_state は state.json が不在または空のときに終了コード 1 と state.json not found を返して中断するが、rotate-pr.sh の公開入口を経由してこのエラー経路を通すテストが無い。launch-reviewer.sh 等では固定されているが rotate-pr.sh では未固定である @@ -141,6 +141,133 @@ 4. 直前の現在 PR に closed_at が記録され、rounds がその PR のラウンド数と一致することを確かめる 5. 新 PR エントリが closed_at: None、rounds: 0 で末尾に追加されることを確かめる +## ラウンド 3(実装 kiro / レビュー codex / agy) + +### R3-001 — `plugins/ndf/skills/cross-review/scripts/state.py#_verify_findings` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 未着手 | 0 | + +**なぜ**: 検証コマンドの正規化・重複実行の抑止・実行結果の分類・各 finding への記録・統合グループ代表の最良結果選択という独立した段階が 1 関数に連続し、実行キャッシュと統合関係の走査を同時に追う必要がある。 + +**手順**: 1. 1 finding の verification record を生成し、コマンド実行キャッシュを利用して結果を分類する helper を抽出する +2. merged_into の関係をたどって代表へ最良の verification を選ぶ処理を別 helper へ抽出する +3. _verify_findings は対象抽出、各 finding の検証、代表結果の集約という 3 段階だけを並べる +4. test_verify_findings.py と findings pipeline の既存テストで、同一コマンドの実行回数、結果優先順位、finding_id、ran_at が不変であることを確認する + +### R3-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_resume_from_state` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 未着手 | 0 | + +**なぜ**: 再開 state の探索・互換フィールドの補完・未解決指摘の引き継ぎ・待ち行列の flush・worktree 同期・結果出力という複数段階が 1 関数に同居し、書き戻しと flush の順序制約まで同じ本体で管理している。 + +**手順**: 1. state の互換フィールド補完と review_instructions 再構成を、state と変更有無を返す helper へ抽出する +2. 書き戻し後の auto-flush と worktree 同期を、順序を保持した再開準備 helper へ抽出する +3. _resume_from_state は state の有無・完了判定、各 helper の呼び出し、既存の _print_init_result だけを順に行う構成へ縮める +4. 既存の再開・carried-over・worktree 同期・run metrics のテストで出力と副作用順が不変であることを確認する + +### R3-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_thread_ids` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| duplication | consolidate_duplication | minor | agy | 未着手 | 0 | + +**なぜ**: _thread_ids における入力データ(リスト、単一辞書、数値等)の辞書要素抽出・正規化ロジックが、同モジュール内の共通関数 _normalize_dict_items と同じ関心をインラインで再実装しており重複している。_thread_positions と同様に _normalize_dict_items を呼び出す形に統一することで、入力値の正規化処理を一元化し一貫性と保守性を高められる。 + +**手順**: 1. _thread_ids 内の辞書要素抽出処理を _normalize_dict_items(value) の呼び出しに置き換える +2. 既存の test_state_thread_ids.py を実行し、各種入力に対する戻り値が変わらないことを確認する + +### R3-004 — `plugins/ndf/skills/cross-review/scripts/state.py#_is_generated_path` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| magic_value | introduce_named_constant | minor | agy | 未着手 | 0 | + +**なぜ**: パス分類判定関数群(_is_dependency_path, _is_config_ci_path, _is_infra_path 等)がモジュール定数(DEPENDENCY_FILENAMES, CONFIG_CI_FILENAMES, INFRA_FILENAMES 等)を参照しているのに対し、_is_generated_path 内にのみロックファイル名の一覧 set リテラルがハードコードされている。名前付きモジュール定数 GENERATED_LOCK_FILENAMES を定義して参照させることで、定数管理の一貫性と保守性を向上できる。 + +**手順**: 1. モジュール定数 GENERATED_LOCK_FILENAMES を定義する +2. _is_generated_path 内の set リテラルを GENERATED_LOCK_FILENAMES の参照に置き換える +3. 既存テストでパス分類の判定動作が不変であることを確認する + +### R3-005 — `plugins/ndf/skills/cross-review/scripts/state.py#_absorb` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| duplication | consolidate_duplication | minor | kiro | 未着手 | 0 | + +**なぜ**: 「2 つの指摘のうち検証結果 (reproduced > not_reproduced > not_run) が高い方を採る」という同じ業務ルールが _absorb (3645-3646 行) と _verify_findings の代表選び直しループ (3137-3138 行) の 2 箇所に _VERIFY_RANK.get(...) > _VERIFY_RANK.get(...) の比較として書かれている。_VERIFY_RANK の順位定義を変えるときや、片方だけ result の取り出し方 (_verify_result vs best.get('result')) を直したときに、もう片方だけ取り残される。両者の docstring がどちらも同じ順位を根拠に挙げており、同じ理由で一緒に変わる重複である。 + +**手順**: 1. _VERIFY_RANK 定義の直後に、2 つの verification dict を受け取り順位の高い方を返すヘルパー _higher_ranked_verification(current, candidate) を追加する(rank は _verify_result で正規化して比較する) +2. _verify_findings の代表選び直しループ (3135-3140) を、best と member['verification'] をヘルパーへ渡して best を更新する形へ置き換える +3. _absorb の verification 継承部 (3644-3648) を、同じヘルパーで rep['verification'] を更新する形へ置き換える +4. test_verify_findings.py / test_merge_duplicates.py / test_state_merge_fix.py を実行し、reproduced/not_reproduced/not_run の組で代表が採る値が変わらないことを確認する + +## ラウンド 4(実装 claude / レビュー codex / kiro) + +### R4-001 — `plugins/ndf/skills/cross-review/scripts/state.py#COUNTED_CLASSIFICATIONS` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| scattered_config | centralize_configuration | major | codex | 検証中 | 1 | + +**なぜ**: 収束判定が数える区分の組が state.py と measure.py に重複し、両者の一致をテストで監視している。区分追加時に片方だけ変わると、実行時の収束判定と事後測定が異なる集合を数える。 + +**手順**: 1. scripts 配下の小さな共有モジュールへ COUNTED_CLASSIFICATIONS を移す +2. state.py と measure.py は共有定義を import して各判定に使う +3. 値そのものと両経路の既存出力を既存テストで固定する + +### R4-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_finding_keys` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | split_into_pipeline | major | codex | 未着手 | 0 | + +**なぜ**: レビュワーごとのファイル解決、JSON 読み込み、payload と comments の境界検証、path・line・本文の正規化が1つの二重ループに入り、入力境界の失敗とキー変換の責務が分離されていない。 + +**手順**: 1. payload ファイルの読み込みと dict 検証を第1段へ抽出する +2. comments 要素の検証と3要素キーへの変換を第2段へ抽出する +3. _finding_keys はレビュワー列挙から各段をつなぐ処理だけにする +4. 不正 payload・不正 comment・欠損位置・正常な振動照合の既存テストを各段階で実行する + +### R4-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 未着手 | 0 | + +**なぜ**: 新規初期化の1関数内に PR 所有権解決、レビュー条件作成、worktree と既存コメントの準備、担当認証、初期 state 構築、保存と表示がネスト関数として同居し、各段階を単独で参照・テストできない。 + +**手順**: 1. ネストされた各段階を同じ入出力のモジュールレベル関数へ順に移す +2. _init_new_state はコンテキストを段階間で受け渡すオーケストレーションだけにする +3. init の再開・新規作成・既存 worktree・コメント取得失敗の既存テストを各抽出後に実行する + +### R4-004 — `plugins/ndf/skills/cross-review/tests/conftest.py#_no_github` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| test_bypasses_module_boundary | move_responsibility | minor | codex | 検証中 | 1 | + +**なぜ**: autouse fixture が state_mod を引数に取るため、monitor.py や measure.py だけを検査するテストまで state.py を共通入口から読み込み、GitHub 照会の内部関数を一律に差し替えている。 + +**手順**: 1. subprocess の gh 実行ガードと state.py の既定差し替えを別 fixture に分ける +2. state.py の差し替えは state_mod を利用するテスト経路だけが要求する形へ移す +3. monitor・measure のテストが state.py を読み込まず、state 系テストでは従来どおり実 GitHub 呼び出しを防ぐことを確認する + +### R4-005 — `plugins/ndf/skills/cross-review/scripts/measure.py#_proposed` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | minor | codex | 検証中 | 1 | + +**なぜ**: 証拠ラウンドの検査、採用 finding 集合の作成、oracle のラウンド別分母への絞り込み、出力メタデータ付与を1関数が連続して担い、分母規則だけを独立に検証しにくい。 + +**手順**: 1. finding_id から round を引き分母を絞る処理を _scoped_oracle_ids として抽出する +2. oracle が未計算の場合と evidence_rounds が一部だけの場合の戻り値を明示する +3. _proposed は採用集合の作成と出力組み立てだけに残し、既存の measure テストを実行する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -153,3 +280,6 @@ | 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_verify_findings` | error | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#launch_reviewer` | branch | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light` | branch | コミット 4cd469bc388e45e7c6e77f0793dc45cbad66c08c にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | +| 3 | `plugins/ndf/skills/cross-review/scripts/state.py#_apply_classification` | long_method | 1 ラウンドの採用上限 5 件を超えた | +| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_finding_keys` | duplication | 1 ラウンドの採用上限 5 件を超えた | +| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_print_init_result` | long_parameter_list | 1 ラウンドの採用上限 5 件を超えた | From 0f6fd03a4c5fd476b92173bf91e3466b7f195b39 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:24:51 +0000 Subject: [PATCH 090/217] =?UTF-8?q?Refactor:=20split=5Finto=5Fpipeline=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#?= =?UTF-8?q?=5Ffinding=5Fkeys?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビュワーごとのファイル解決・JSON 読み込み・payload と comments の境界検証・ path / line / 本文の正規化が 1 つの二重ループに入っていた `_finding_keys` を 3 段の連鎖へ分ける。 - 第 1 段 `_read_finding_payload`: payload ファイルの読み込みと dict 検証 (無い・読めないときは None、dict でなければ die(code=3)) - 第 2 段 `_comment_keys`: comments 要素の dict 検証と 3 つ組への変換 - `_finding_keys`: レビュワー列挙から各段をつなぐだけにする 振る舞いは変えない。die のメッセージと終了コード、位置欠損・行の型不正の 読み飛ばしは元のまま。 Item-Id: R4-002 Round: 4 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Fable 5.1 --- .../ndf/skills/cross-review/scripts/state.py | 82 ++++++++++++------- 1 file changed, 54 insertions(+), 28 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 80dc773c..fde27994 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -3693,38 +3693,64 @@ def _finding_keys( keys: list[tuple[str, int, str]] = [] for agent in _round_reviewers(st, round_no): p = _payload_path(agent, pr, round_no) - if not p.exists(): + payload = _read_finding_payload(agent, p) + if payload is None: continue - try: - payload = json.loads(p.read_text(encoding="utf-8")) - except json.JSONDecodeError: - continue - # gemini round 4 指摘: payload は本来 dict (comments: [...]) だが、 - # launcher のバグで list / str が入り込むと `payload.get(...)` で - # AttributeError になる。不正な review payload はバグなので - # 即時 die(code=3) で停止させる。 - if not isinstance(payload, dict): + keys.extend(_comment_keys(agent, p, payload)) + return keys + + +def _read_finding_payload(agent: str, p: pathlib.Path) -> dict[str, Any] | None: + """判定の直前に読む payload.json を dict として返す(第 1 段: 入力境界)。 + + 無い・JSON として読めないときは None を返して読み飛ばす。dict でないときは + launcher のバグとして `die(code=3)` で止める(`_load_payload` と違い、ここで + 止めても失われる記録が無い)。 + """ + if not p.exists(): + return None + try: + payload = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + # gemini round 4 指摘: payload は本来 dict (comments: [...]) だが、 + # launcher のバグで list / str が入り込むと `payload.get(...)` で + # AttributeError になる。不正な review payload はバグなので + # 即時 die(code=3) で停止させる。 + if not isinstance(payload, dict): + die( + f"{agent}: payload.json が dict ではない " + f"({p}, type={type(payload).__name__})。" + " review launcher の出力形式不正。", + code=3, + ) + return payload + + +def _comment_keys( + agent: str, p: pathlib.Path, payload: dict[str, Any] +) -> list[tuple[str, int, str]]: + """`comments[]` を (ファイル, 行, 正規化した本文) の 3 つ組へ変換する(第 2 段)。 + + 要素が dict でなければ `die(code=3)`。位置(path / line)が欠ける要素と、行が + 整数に読めない要素は読み飛ばす。 + """ + keys: list[tuple[str, int, str]] = [] + for c in payload.get("comments", []): + if not isinstance(c, dict): + # comments エントリが dict でない場合も同様に致命扱い die( - f"{agent}: payload.json が dict ではない " - f"({p}, type={type(payload).__name__})。" - " review launcher の出力形式不正。", + f"{agent}: payload.comments のエントリが dict ではない " + f"({p}, type={type(c).__name__})。", code=3, ) - for c in payload.get("comments", []): - if not isinstance(c, dict): - # comments エントリが dict でない場合も同様に致命扱い - die( - f"{agent}: payload.comments のエントリが dict ではない " - f"({p}, type={type(c).__name__})。", - code=3, - ) - path = c.get("path") - line = c.get("line") or c.get("start_line") - if path and line is not None: - try: - keys.append((str(path), int(line), _normalized_body(c.get("body")))) - except (TypeError, ValueError): - continue + path = c.get("path") + line = c.get("line") or c.get("start_line") + if path and line is not None: + try: + keys.append((str(path), int(line), _normalized_body(c.get("body")))) + except (TypeError, ValueError): + continue return keys From 42141142c683688dfbc7ec21682f0ab56b8f295a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:25:45 +0000 Subject: [PATCH 091/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md index 51a02bee..d80c1bb5 100644 --- a/issues/refactoring-plan-rf790.md +++ b/issues/refactoring-plan-rf790.md @@ -211,7 +211,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| scattered_config | centralize_configuration | major | codex | 検証中 | 1 | +| scattered_config | centralize_configuration | major | codex | 採用 | 1 | **なぜ**: 収束判定が数える区分の組が state.py と measure.py に重複し、両者の一致をテストで監視している。区分追加時に片方だけ変わると、実行時の収束判定と事後測定が異なる集合を数える。 @@ -223,7 +223,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | split_into_pipeline | major | codex | 未着手 | 0 | +| long_method | split_into_pipeline | major | codex | 検証中 | 1 | **なぜ**: レビュワーごとのファイル解決、JSON 読み込み、payload と comments の境界検証、path・line・本文の正規化が1つの二重ループに入り、入力境界の失敗とキー変換の責務が分離されていない。 @@ -248,7 +248,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| test_bypasses_module_boundary | move_responsibility | minor | codex | 検証中 | 1 | +| test_bypasses_module_boundary | move_responsibility | minor | codex | 採用 | 1 | **なぜ**: autouse fixture が state_mod を引数に取るため、monitor.py や measure.py だけを検査するテストまで state.py を共通入口から読み込み、GitHub 照会の内部関数を一律に差し替えている。 @@ -260,7 +260,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | codex | 検証中 | 1 | +| long_method | extract_method | minor | codex | 採用 | 1 | **なぜ**: 証拠ラウンドの検査、採用 finding 集合の作成、oracle のラウンド別分母への絞り込み、出力メタデータ付与を1関数が連続して担い、分母規則だけを独立に検証しにくい。 From 77d9383c32e6d9b7a90e6af5467a6c5cbdbb1932 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:37:21 +0000 Subject: [PATCH 092/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#?= =?UTF-8?q?=5Finit=5Fnew=5Fstate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新規初期化の各段階をモジュールレベル関数へ抽出し、初期化関数をオーケストレーションだけに整理する。構造依存テストも抽出後の段階呼び出しを検査する形へ更新する。 Item-Id: R4-003 Round: 4 Impl-Runtime: codex Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 354 +++++++++--------- .../tests/test_init_body_not_duplicated.py | 16 +- 2 files changed, 185 insertions(+), 185 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index fde27994..543070e4 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1688,6 +1688,178 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str +def _resolve_pr_and_ownership( + pr: object, repo: str, worktree: str, args_worktree: str | None +) -> _InitPRContext | None: + """PR のメタデータとレビュー実行者との所有関係を解決する。""" + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + meta = _fetch_pr_metadata(pr, repo) + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) + + +def _prepare_review_instructions( + pr: object, repo: str, manual_extra_review: str +) -> _InitReviewContext: + """変更ファイルから自動・手動のレビュー条件を組み立てる。""" + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) + + +def _prepare_worktree_and_comments( + worktree: str, pr: object, head_branch: str, repo: str +) -> _InitWorkspaceContext: + """worktree と既存コメントのスナップショットを準備する。""" + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + return _InitWorkspaceContext(tmp_dir=tmp_dir, state_file=state_file) + + +def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + reviewers = assignment.review_pool(host) + info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") + _validate_only(args.only, host) + # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 + # **確かめるのは実際に起動する担当だけである。** + auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) + return _InitialAssignment(host=host, host_source=host_source) + + +def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, +) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source = ctx.assignment + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + "max_rounds": args.max_rounds, + "rotate_after": args.rotate_after, + "only": args.only, + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + + +def _save_and_print_initial_state( + args: argparse.Namespace, ctx: _InitialStateContext +) -> None: + """初期状態を保存し、init の結果を表示する。""" + state = _build_initial_review_state(args, ctx) + _write_state(ctx.ws_ctx.state_file, state) + info(f"✅ state 初期化: {ctx.ws_ctx.state_file}") + _print_init_result( + ctx.pr, + ctx.pr_ctx.worktree, + ctx.ws_ctx.tmp_dir, + ctx.pr_ctx.repo, + ctx.pr_ctx.meta.head_branch, + ctx.pr_ctx.meta.base_branch, + ctx.pr_ctx.is_own, + ctx.pr_ctx.event_downgrade, + bool(ctx.review_ctx.review_instructions), + 0, + False, + ) + + def _init_new_state( args: argparse.Namespace, pr: object, @@ -1696,182 +1868,6 @@ def _init_new_state( manual_extra_review: str, ) -> None: """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" - - def _resolve_pr_and_ownership( - pr: object, repo: str, worktree: str, args_worktree: str | None - ) -> _InitPRContext | None: - # 新規 init: プリチェック。 - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - meta = _fetch_pr_metadata(pr, repo) - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - - def _prepare_review_instructions( - pr: object, repo: str, manual_extra_review: str - ) -> _InitReviewContext: - changed_files = _fetch_changed_files(pr, repo) - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) - - def _prepare_worktree_and_comments( - worktree: str, pr: object, head_branch: str, repo: str - ) -> _InitWorkspaceContext: - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) - tmp_dir = _tmp_dir(worktree) - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - - return _InitWorkspaceContext( - tmp_dir=tmp_dir, - state_file=state_file, - ) - - def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - reviewers = assignment.review_pool(host) - info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") - _validate_only(args.only, host) - # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 - # **確かめるのは実際に起動する担当だけである。** - auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) - return _InitialAssignment(host=host, host_source=host_source) - - def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, - ) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source = ctx.assignment - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - "max_rounds": args.max_rounds, - "rotate_after": args.rotate_after, - "only": args.only, - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } - - def _finalize_initial_state( - args: argparse.Namespace, - pr: object, - pr_ctx: _InitPRContext, - review_ctx: _InitReviewContext, - ws_ctx: _InitWorkspaceContext, - manual_extra_review: str, - ) -> None: - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review - ) - state = _build_initial_review_state(args, context) - _write_state(ws_ctx.state_file, state) - info(f"✅ state 初期化: {ws_ctx.state_file}") - _print_init_result( - pr, - pr_ctx.worktree, - ws_ctx.tmp_dir, - pr_ctx.repo, - pr_ctx.meta.head_branch, - pr_ctx.meta.base_branch, - pr_ctx.is_own, - pr_ctx.event_downgrade, - bool(review_ctx.review_instructions), - 0, - False, - ) - pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) if pr_ctx is None: return @@ -1880,9 +1876,11 @@ def _finalize_initial_state( ws_ctx = _prepare_worktree_and_comments( pr_ctx.worktree, pr, pr_ctx.meta.head_branch, pr_ctx.repo ) - _finalize_initial_state( - args, pr, pr_ctx, review_ctx, ws_ctx, manual_extra_review + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review ) + _save_and_print_initial_state(args, context) # **母集合を広げる前からある 2 者。** `host` を持たない状態ファイル(このリポジトリの diff --git a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py index b8f91814..cef0efbf 100644 --- a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py +++ b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py @@ -6,8 +6,8 @@ `auth.check_auth` が二重に走り、機械可読ブロック(`PR=…RESUMED=0`)が標準出力へ 2 回出ていた。 -**経路そのものは `gh` を要するため実行では確かめない。** 関数の構造(同じ文が 2 回 -現れない・出力が 1 回だけ)を構文木で見る。 +**経路そのものは `gh` を要するため実行では確かめない。** オーケストレーションの構造 +(同じ文が 2 回現れない・抽出した各段階が 1 回だけ)を構文木で見る。 """ from __future__ import annotations @@ -19,12 +19,14 @@ STATE_PY = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "state.py" -# 1 回しか呼んではいけないもの。**副作用を持つ**か、標準出力の機械可読ブロックを書く。 +# `_init_new_state` が順に 1 回ずつ呼ぶ段階。各段階の内部にある副作用の重複は、 +# オーケストレーターから同じ段階を二重に呼ばないことで防ぐ。 SINGLE_CALL = ( - "_print_init_result", - "_fetch_pr_metadata", - "_fetch_changed_files", - "_tmp_dir", + "_resolve_pr_and_ownership", + "_prepare_review_instructions", + "_prepare_worktree_and_comments", + "_prepare_initial_assignment", + "_save_and_print_initial_state", ) From f682913d1312b1ca77e03701c5407b726725390e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:37:56 +0000 Subject: [PATCH 093/217] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py?= =?UTF-8?q?#=5Finit=5Fnew=5Fstate"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 77d9383c32e6d9b7a90e6af5467a6c5cbdbb1932. --- .../ndf/skills/cross-review/scripts/state.py | 354 +++++++++--------- .../tests/test_init_body_not_duplicated.py | 16 +- 2 files changed, 185 insertions(+), 185 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 543070e4..fde27994 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1688,178 +1688,6 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str -def _resolve_pr_and_ownership( - pr: object, repo: str, worktree: str, args_worktree: str | None -) -> _InitPRContext | None: - """PR のメタデータとレビュー実行者との所有関係を解決する。""" - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - meta = _fetch_pr_metadata(pr, repo) - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - - -def _prepare_review_instructions( - pr: object, repo: str, manual_extra_review: str -) -> _InitReviewContext: - """変更ファイルから自動・手動のレビュー条件を組み立てる。""" - changed_files = _fetch_changed_files(pr, repo) - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) - - -def _prepare_worktree_and_comments( - worktree: str, pr: object, head_branch: str, repo: str -) -> _InitWorkspaceContext: - """worktree と既存コメントのスナップショットを準備する。""" - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) - tmp_dir = _tmp_dir(worktree) - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - - return _InitWorkspaceContext(tmp_dir=tmp_dir, state_file=state_file) - - -def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - reviewers = assignment.review_pool(host) - info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") - _validate_only(args.only, host) - # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 - # **確かめるのは実際に起動する担当だけである。** - auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) - return _InitialAssignment(host=host, host_source=host_source) - - -def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, -) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source = ctx.assignment - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - "max_rounds": args.max_rounds, - "rotate_after": args.rotate_after, - "only": args.only, - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } - - -def _save_and_print_initial_state( - args: argparse.Namespace, ctx: _InitialStateContext -) -> None: - """初期状態を保存し、init の結果を表示する。""" - state = _build_initial_review_state(args, ctx) - _write_state(ctx.ws_ctx.state_file, state) - info(f"✅ state 初期化: {ctx.ws_ctx.state_file}") - _print_init_result( - ctx.pr, - ctx.pr_ctx.worktree, - ctx.ws_ctx.tmp_dir, - ctx.pr_ctx.repo, - ctx.pr_ctx.meta.head_branch, - ctx.pr_ctx.meta.base_branch, - ctx.pr_ctx.is_own, - ctx.pr_ctx.event_downgrade, - bool(ctx.review_ctx.review_instructions), - 0, - False, - ) - - def _init_new_state( args: argparse.Namespace, pr: object, @@ -1868,6 +1696,182 @@ def _init_new_state( manual_extra_review: str, ) -> None: """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" + + def _resolve_pr_and_ownership( + pr: object, repo: str, worktree: str, args_worktree: str | None + ) -> _InitPRContext | None: + # 新規 init: プリチェック。 + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + meta = _fetch_pr_metadata(pr, repo) + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) + + def _prepare_review_instructions( + pr: object, repo: str, manual_extra_review: str + ) -> _InitReviewContext: + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) + + def _prepare_worktree_and_comments( + worktree: str, pr: object, head_branch: str, repo: str + ) -> _InitWorkspaceContext: + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + return _InitWorkspaceContext( + tmp_dir=tmp_dir, + state_file=state_file, + ) + + def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + reviewers = assignment.review_pool(host) + info(f"ホスト: {host}({host_source}) / レビュワーの母集合: {' / '.join(reviewers)}") + _validate_only(args.only, host) + # 未認証の CLI は起動から短時間で終わり、結果を残さないまま担当から欠ける。 + # **確かめるのは実際に起動する担当だけである。** + auth.check_auth(_auth_targets(args.only, host), info=info, die=lambda m: die(m)) + return _InitialAssignment(host=host, host_source=host_source) + + def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, + ) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source = ctx.assignment + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + "max_rounds": args.max_rounds, + "rotate_after": args.rotate_after, + "only": args.only, + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + + def _finalize_initial_state( + args: argparse.Namespace, + pr: object, + pr_ctx: _InitPRContext, + review_ctx: _InitReviewContext, + ws_ctx: _InitWorkspaceContext, + manual_extra_review: str, + ) -> None: + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + ) + state = _build_initial_review_state(args, context) + _write_state(ws_ctx.state_file, state) + info(f"✅ state 初期化: {ws_ctx.state_file}") + _print_init_result( + pr, + pr_ctx.worktree, + ws_ctx.tmp_dir, + pr_ctx.repo, + pr_ctx.meta.head_branch, + pr_ctx.meta.base_branch, + pr_ctx.is_own, + pr_ctx.event_downgrade, + bool(review_ctx.review_instructions), + 0, + False, + ) + pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) if pr_ctx is None: return @@ -1876,11 +1880,9 @@ def _init_new_state( ws_ctx = _prepare_worktree_and_comments( pr_ctx.worktree, pr, pr_ctx.meta.head_branch, pr_ctx.repo ) - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + _finalize_initial_state( + args, pr, pr_ctx, review_ctx, ws_ctx, manual_extra_review ) - _save_and_print_initial_state(args, context) # **母集合を広げる前からある 2 者。** `host` を持たない状態ファイル(このリポジトリの diff --git a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py index cef0efbf..b8f91814 100644 --- a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py +++ b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py @@ -6,8 +6,8 @@ `auth.check_auth` が二重に走り、機械可読ブロック(`PR=…RESUMED=0`)が標準出力へ 2 回出ていた。 -**経路そのものは `gh` を要するため実行では確かめない。** オーケストレーションの構造 -(同じ文が 2 回現れない・抽出した各段階が 1 回だけ)を構文木で見る。 +**経路そのものは `gh` を要するため実行では確かめない。** 関数の構造(同じ文が 2 回 +現れない・出力が 1 回だけ)を構文木で見る。 """ from __future__ import annotations @@ -19,14 +19,12 @@ STATE_PY = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "state.py" -# `_init_new_state` が順に 1 回ずつ呼ぶ段階。各段階の内部にある副作用の重複は、 -# オーケストレーターから同じ段階を二重に呼ばないことで防ぐ。 +# 1 回しか呼んではいけないもの。**副作用を持つ**か、標準出力の機械可読ブロックを書く。 SINGLE_CALL = ( - "_resolve_pr_and_ownership", - "_prepare_review_instructions", - "_prepare_worktree_and_comments", - "_prepare_initial_assignment", - "_save_and_print_initial_state", + "_print_init_result", + "_fetch_pr_metadata", + "_fetch_changed_files", + "_tmp_dir", ) From cef8f3655713df49a90547bfc48793f0b54761d5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 21:37:56 +0000 Subject: [PATCH 094/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf790.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md index d80c1bb5..3ebd316d 100644 --- a/issues/refactoring-plan-rf790.md +++ b/issues/refactoring-plan-rf790.md @@ -223,7 +223,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | split_into_pipeline | major | codex | 検証中 | 1 | +| long_method | split_into_pipeline | major | codex | 採用 | 1 | **なぜ**: レビュワーごとのファイル解決、JSON 読み込み、payload と comments の境界検証、path・line・本文の正規化が1つの二重ループに入り、入力境界の失敗とキー変換の責務が分離されていない。 @@ -236,7 +236,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 未着手 | 0 | +| long_method | extract_method | major | codex | 取り消し | 1 | **なぜ**: 新規初期化の1関数内に PR 所有権解決、レビュー条件作成、worktree と既存コメントの準備、担当認証、初期 state 構築、保存と表示がネスト関数として同居し、各段階を単独で参照・テストできない。 @@ -283,3 +283,4 @@ | 3 | `plugins/ndf/skills/cross-review/scripts/state.py#_apply_classification` | long_method | 1 ラウンドの採用上限 5 件を超えた | | 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_finding_keys` | duplication | 1 ラウンドの採用上限 5 件を超えた | | 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_print_init_result` | long_parameter_list | 1 ラウンドの採用上限 5 件を超えた | +| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | テストの期待する振る舞いが変わっています(plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | From bf5862d10e777b8def6335f28e7185429f3d78f5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 20:56:49 +0000 Subject: [PATCH 095/217] =?UTF-8?q?Docs:=20cross-review=20=E3=81=AE?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=E3=82=92=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=82=92=E5=9B=9E=E3=81=99=E5=81=B4=E3=81=B8?= =?UTF-8?q?=E9=9B=86=E3=82=81=E3=82=8B=E8=A8=AD=E8=A8=88=EF=BC=88#730=20#5?= =?UTF-8?q?83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 要求と受け入れ条件(AC1〜AC31)と設計(決定 1〜13)を新設する - 既存の要求と契約に、置き換え先を指す段落を足す(本体は書き換えない) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- ...62-598-537-619-584-583-design-contracts.md | 2 + ...ue-662-598-537-619-584-583-requirements.md | 4 +- issues/issue-730-583-design.md | 367 ++++++++++++++++++ issues/issue-730-583-requirements.md | 232 +++++++++++ 4 files changed, 604 insertions(+), 1 deletion(-) create mode 100644 issues/issue-730-583-design.md create mode 100644 issues/issue-730-583-requirements.md diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index 837f128c..e4de3027 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -369,5 +369,7 @@ conftest.py P1(NDF_METRICS_DIR) | AC60〜AC62 | 3 秒後に子が書く偽の CLI を `launch-cli.sh` で起動し、pgid と、監視の上限 2 秒で止めた後の結果ファイルの有無。先頭でない pid では `os.killpg` が呼ばれない | | AC63 / AC65 / AC66 | 偽の `gh` のレビュー一覧(見出しの一致・ラウンド違い・担当違い)で `prior_review_url` と、`launch-reviewer.sh` のプロンプトの文言 | | AC67 | `SKILL.md` の骨組みで、起動の行から判定の行までの間に `verify-findings` と `critique-round.sh` があり、起動し直しの専用の分岐が無い。各判定の直後に終了コード 8 の `flush` の枝がある | + +**AC63〜AC67 の確かめ方は、[issue-730-583-design.md](issue-730-583-design.md) の「テスト設計」が引き継いだ。** 担当が投稿しなくなるため、投稿済みのレビューを探す鍵(`prior_review_url`)を作らず、AC63〜AC65 の行は対象を失う。上の 2 行は 2026-09-15 時点の記録として残す。 | AC68 / AC69 | 文書の `grep` | | AC70〜AC72 | 検証手段の表のコマンド。AC72 はテストの前後で `find` | diff --git a/issues/issue-662-598-537-619-584-583-requirements.md b/issues/issue-662-598-537-619-584-583-requirements.md index a65f8735..fce9d216 100644 --- a/issues/issue-662-598-537-619-584-583-requirements.md +++ b/issues/issue-662-598-537-619-584-583-requirements.md @@ -157,7 +157,9 @@ ## 受け入れ条件(P3: #619 + #584 + #583 結果が失われる) -**#619 / #584 の受け入れ条件(AC50〜AC62、AC68〜AC69)は、利用上限で止まった担当を起動し直さず理由を報告する要求 [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) へ移した。** #583(AC63〜AC67)は #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 +**#619 / #584 の受け入れ条件(AC50〜AC62、AC68〜AC69)は、利用上限で止まった担当を起動し直さず理由を報告する要求 [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) へ移した。** 以下は 2026-09-15 時点の記録として残す。 + +**#583 の受け入れ条件(AC63〜AC67)は、GitHub と git への書き込みをレビューを回す側だけにする要求 [issue-730-583-requirements.md](issue-730-583-requirements.md) が引き継いだ。** そのうち AC63〜AC65 は取り下げ、AC66 は送信の応答から取る形へ改め、AC67 は引き継いでいる。対応は引き継いだ側の「置き換える既存の受け入れ条件との対応」にある。 文言と見るファイルの一覧は、契約の文書の「P3 で足す文言」にある。 diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md new file mode 100644 index 00000000..198990a2 --- /dev/null +++ b/issues/issue-730-583-design.md @@ -0,0 +1,367 @@ +# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのをレビューを回す側だけにし、途中で止まっても二度書かない(#730 #583 の設計) + +## 目的 + +レビューを任された担当が、PR へ指摘を書き込んでから結果を残す前に止まると、PR には指摘が出ているのに記録には残らない。レビューを回す側は記録だけを読んで「結果なし」と判定し、同じ担当をもう一度起動する。利用者は 1 つの論点に 2 つのスレッドを見て、両方へ返信する。 + +原因は、書き込みと記録を別々の相手が行っていることである。GitHub と git へ書くのをレビューを回す側 1 か所に集め、担当は結果を残すだけにする。書き込みと記録が同じ手順の中で続けて起きるため、途中で止まっても同じものを二度書き込まない。 + +## 用語の対応表 + +| 業務用語 | 識別子・実体 | +| --- | --- | +| レビューを回す側 | `plugins/ndf/skills/cross-review/scripts/state.py` と、それを呼ぶ `SKILL.md` の骨組み | +| 担当 | 1 ラウンドで 1 者ぶんのレビューを行う CLI。`launch-reviewer.sh` が起動する | +| 席 | そのラウンドで担当が入る枠。`claude` / `codex` / `agy` / `kiro` と、同じランタイムの 2 つ目の `-2`〜`-9` | +| 修正の担当 | 指摘を直すサブエージェント、または単独で動く `fix` の実行 | +| 結果ファイル | `<席>-review-pr<番号>-result.json` | +| 指摘の控え | `<席>-review-pr<番号>-round-payload.json` | +| 修正の結果ファイル | `fix-pr<番号>-result.json` | +| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py` | +| 二度書かないための照合 | `post_queue.already_posted()` / `posted_match()` | +| 結果ファイルを投稿へ変える層 | 新設する `plugins/ndf/scripts/lib/result_posts.py` | +| 指摘の取り込み | `state.py read-result` | +| 修正の取り込み | `state.py merge-fix` | +| 最終スイープの照合 | `state.py verify-sweep` | +| 投稿の種別 | `pr-comment` / `review-post` / `review-reply` / `thread-resolve` | + +## 機能一覧 + +| # | 機能 | 受け入れ条件 | +| --- | --- | --- | +| 1 | 担当のプロンプトから投稿の手順を外し、結果だけを書かせる | AC1〜AC4 | +| 2 | 指摘の取り込みが、控えからレビューを組み立てて送る | AC5・AC6・AC16・AC17 | +| 3 | 修正の取り込みが、返信・決着・まとめを送り、修正を送信して照合する | AC7〜AC9 | +| 4 | 同じものを二度書かない照合を、4 種別すべてに掛ける | AC10〜AC14 | +| 5 | 申告と実数の突き合わせをやめ、送信の応答を記録にする | AC15 | +| 6 | 起動し直しを初回と同じ経路へ通す | AC18〜AC20 | +| 7 | `fix` の書き込みを 1 つの実装にまとめる | AC21・AC22 | +| 8 | 文書の決定を書き直す | AC25〜AC29 | + +## なぜ変えるか + +**書き込みと記録が別の相手にあるため、片方だけが残る状態を作れる。** 担当は GitHub へ投稿し、その後に結果ファイルを書く。この 2 つの間で担当が止まると、投稿は残り記録は残らない。レビューを回す側は記録しか見ないので、投稿があることを知らないまま同じ担当を起動する。 + +**待ち行列は用意されているのに、担当の投稿がそこを通らない。** 上限で拒まれた投稿を残して後から流す仕組みはレビューを回す側にあるが、担当が自分で送るため、上限に当たった投稿はその場で失われる。 + +**送信の報告を確かめる手段がない。** 修正の担当はブランチ名を指定して送信する。作業ツリーが切り離された頭で作られている場合、この指定では現在の頭が送られないまま終了コード 0 で終わる。取り込む側は報告をそのまま記録する。 + +書き込みを 1 か所へ集めると、この 3 つが同じ場所で解ける。投稿は待ち行列を通り、送信は現在の頭を指定して行い、送った結果がそのまま記録になる。 + +## 実測 + +2026-09-21、`origin/develop`(`c853691a`)で確かめた。 + +| 何を測ったか | 値 | 確かめ方 | +| --- | --- | --- | +| 投稿の待ち行列の種別 | 4 つ(`pr-comment` / `review-post` / `review-reply` / `thread-resolve`) | `post_queue.py` の `KINDS` | +| そのうち、積む側が実装されている種別 | 1 つ(`pr-comment`。`rotate-pr.sh:53` の 1 か所だけ) | `grep -rn -- "--kind" plugins/ndf` の結果から、手順・テスト・別スクリプトの行を除いた | +| レビューを回す側の `gh` の呼び出し | 12 か所。書き込みの引数(`--method` / `-X POST` / `mutation`)を持つ行は 0 | `grep -c '"gh"'` と `grep -n -- '--method\|-X POST\|mutation'` | +| 担当のプロンプトに書かれた投稿の指示 | 4 か所(`launch-reviewer.sh:95,158,204,206`) | `grep -n "gh api"` | +| 修正の担当が行う書き込み | 4 種類(返信・決着・まとめ・送信)。`fix/SKILL.md:294,311,328,363` ほか | `grep -n "gh api\|gh pr comment\|git push"` | +| 重なりが出た実行 | PR #578 の round 1。1 回目にインライン 4 件、起動し直した 2 回目に 1 件。記録に入ったのは 2 回目の 1 件だけ | #583 の本文 | + +**待ち行列の受け皿だけが先にあり、積む側が無い状態である。** レビューの投稿を積む呼び出しは、配布物のどこにも無い。送信の確認(`_confirm_flushed`)はレビューの投稿の種別を読む形で用意されている。 + +## 決定の記録 + +見出しは「何のために何を決めたか」を書く。判断の材料は各節の本文にある。 + +### 決定 1: 並行する設計と 1 つのファイルで競合しないよう、設計文書は親課題の名前で新設し、既存の設計文書の本体は書き換えない + +同じ時期に 4 つの設計が同じ既存文書を指している。本体を書き換えると、先にマージした側の記述が後から入る側の差分で戻る。**既存の要求と設計には、置き換え先を指す段落を 1 つ足すだけにする。** 前例は #727 / #728 / #729 / #732 の 4 つである。 + +### 決定 2: 片方だけが残る状態を作らないため、GitHub と git への書き込みをレビューを回す側だけが行う + +担当は指摘の控えと結果ファイルを書き、レビューを回す側がそこから投稿を組み立てて送る。修正の担当はコミットまでを行い、取り込む側が送信する。 + +採らない案と理由: + +- **担当の投稿の後に、レビューを回す側が実物を照合して記録を補う。** 照合は GitHub への問い合わせを増やし、問い合わせが上限で失敗すると同じ食い違いが残る。書き込みを増やさずに読み取りを増やしても、原因の場所は動かない +- **担当に待ち行列へ積ませ、レビューを回す側が流す。** 担当は自分の作業領域しか持たず、待ち行列はレビューを回す側の作業ツリーにある。置き場所を担当へ開くと、巻き直しで捨てる範囲が変わる + +### 決定 3: 投稿の本文をレビューを回す側の応答へ載せないため、投稿は結果ファイルを読んだプロセスの中で組み立てる + +取り込みの部分命令が、控えのファイルを読み、投稿の項目を組み立て、待ち行列へ積み、流すところまでを 1 つのプロセスで行う。**部分命令の引数に本文を置かず、標準出力にも本文を出さない。** 応答に出るのは件数・URL・状態だけである。 + +採らない案と理由: + +- **控えの本文を引数や標準入力で渡す。** 収束ループを駆動している側の応答に本文が載る。文脈の予算の決めに反する +- **待ち行列のコマンドに種別ごとの引数を足して、手順から呼ぶ。** 引数の数が種別ごとに増え、手順の行が長くなる。組み立てはプロセスの中に閉じるほうが、手順の行が 1 本で済む + +### 決定 4: 投稿と記録の間に新しい食い違いを作らないため、「読んで記録する」と「投稿する」を 1 つの部分命令に閉じる + +取り込みの部分命令の名前と、骨組みの行の並びは変えない。中の順序を「控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」にする。 + +**別の部分命令に分けない。** 分けると「投稿したが記録していない」に加えて「記録したが投稿していない」がもう 1 つ増える。1 つに閉じれば、途中で止まったときに残るのは待ち行列の項目だけで、次に流したときに決着する。 + +### 決定 5: 途中で止まった実行を流し直しても増やさないため、二度書かない照合を 4 種別すべてに掛ける + +投稿の待ち行列は、送る前に同じものが先にあるかを照合する仕組みを持つ。レビューの投稿・返信・決着・まとめの 4 種別すべてでこれを通す。**照合の鍵は本文の先頭行にあるラウンドと席とする。** + +| 種別 | 照合の鍵 | +| --- | --- | +| `review-post` | 投稿者と、本文の先頭 80 文字(`## 🤖 cross-review \| round \| <席> \|` を含む) | +| `review-reply` | 返信先の指摘の識別子と、本文の先頭 80 文字 | +| `thread-resolve` | スレッドの識別子と、すでに決着しているかどうか | +| `pr-comment` | 投稿者と、本文の先頭 80 文字(ラウンドを含む) | + +投稿者はどの席でも同じになる。ラウンドと席を先頭行に持たせることで、同じ投稿者の別の投稿と区別できる。 + +### 決定 6: 投稿済みで記録なしの状態が起きなくなるため、投稿済みのレビューを探して記録だけの起動をする決めを取り下げる + +担当が投稿しなくなるため、探す対象が無い。**GitHub からレビューを探す照会も、記録だけを行うプロンプトも作らない。** 止まった担当を起動し直すときは、初回と同じプロンプトをそのまま使う。 + +この決めは、担当が投稿する前提のうえで、投稿済みの担当をもう一度投稿させないために置かれていた。前提のほうを変えるため、対処のほうは要らなくなる。 + +### 決定 7: 起動し直した担当の指摘が根拠の検証と反証を通るよう、起動し直しを初回と同じ経路へ通す + +骨組みの起動し直しの枝を、繰り返しの先頭へ戻す形にする。繰り返しは「起動 → 待ち → 取り込み → 根拠の検証 → 反証 → 判定」である。**経路が 1 本なら、証拠の集約を飛ばす枝が構造として無くなる。** 待ち行列に残りがあるときの枝(判定の終了コード 8)は、2 度目を含む各判定の直後に置き、7 の判定より先に見る順序を保つ。 + +### 決定 8: 申告と実物の食い違いを無くすため、申告件数と実数の突き合わせをやめ、送信の応答を記録にする + +投稿するのがレビューを回す側になるため、申告を受け取る相手がいない。記録に入る投稿の URL と件数は、送信の応答から取る。**申告件数を GitHub の実数と比べて中断する処理は取り除く。** + +この突き合わせは、担当が投稿したことを確かめるために置かれていた。投稿する側と記録する側が同じになるため、確かめる対象が無くなる。 + +### 決定 9: 差分の外を指す指摘を落とさないため、拒まれた指摘は投稿する側が本文へ退避する + +インラインの投稿が差分の外を理由に拒まれたとき(HTTP 422)、投稿する側がその指摘を本文の末尾へ移し、レビューをもう一度送る。退避した指摘の `posted_to` は `body` として記録する。**退避した件数を取り込みの出力に出す。** + +採らない案と理由: + +- **投稿の前に、差分に含まれる行かどうかを判定して振り分ける。** 差分の範囲を別に取り寄せる必要があり、取り寄せが失敗したときの分岐が増える。拒まれてから退避すれば、判定の根拠は応答そのものになる + +### 決定 10: 送ったという報告と実物が食い違わないよう、送信は現在の頭を指定して行い、送った後に照合する + +修正の送信は取り込む側が `git push origin HEAD:<ブランチ名>` で行う。**ブランチ名だけを指定しない。** 作業ツリーが切り離された頭で作られている場合、ブランチ名だけの指定では現在の頭が送られないまま終了コード 0 で終わる。 + +送信の後、修正の結果ファイルが報告するコミットが、送り先のブランチの履歴に含まれることを確かめる。含まれなければ取り込みは失敗として止まる。 + +### 決定 11: 起動の経路で書き込みの担い手が変わらないよう、修正の書き込みを 1 つの共通層にまとめる + +返信・決着・まとめの投稿・送信の実装を、共通層の 1 つのまとまりに置く。置き場所は `plugins/ndf/scripts/lib/result_posts.py` である。cross-review から呼ぶときは修正の取り込みがこれを呼び、単独で使うときは同じまとまりを部分命令として直接呼ぶ。 + +```bash +python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <結果ファイル> --head <ブランチ名> +``` + +**新しい入口のスクリプトを足さない。** 待ち行列のまとまりが取り込み用の口と部分命令の口の両方を持つ形に前例がある。手順に書く行は 1 本で済み、実装は 1 つになる。 + +### 決定 12: 読み取り側が途中の内容を読まないよう、担当は結果を一時の名前で書いてから改名する + +担当は結果ファイルと指摘の控えを一時の名前で書き、書き終えてから正式の名前へ改名する。**読めた状態は書き終えた状態である**ことが成り立つため、取り込み側は内容の途中を読むことがない。担当が途中で止まれば正式の名前のファイルは現れず、結果なしとして扱われる。 + +### 決定 13: すでに 1 か所にある操作を動かさないため、PR の巻き直しの開閉は移さない + +PR の締めと作り直しと再開はすでにレビューを回す側が行っている。待ち行列に積まず、上限のときは待って同じ操作をやり直す形である。**この性質を変えない。** 巻き直しは順序が意味を持ち、待ち行列へ積むと後続の投稿と並び替わる。 + +## データ構造 + +### 結果ファイル(レビュー) + +| 項目 | 変更前 | 変更後 | +| --- | --- | --- | +| `event` | 担当が書く | 変わらない | +| `posted_as` | 担当が書く(`REVIEW` / `COMMENT`) | 投稿する側が書く | +| `comments_count` | 担当が申告する | 投稿する側が、送れたインラインの件数で埋める | +| `review_url` | 担当が投稿の応答から書く | **担当は書かない。** 投稿する側が送信の応答から書く | +| `by_severity` | 担当が書く | 変わらない | +| `post_error` | 担当が書く | **無くなる。** 投稿の失敗は待ち行列の側に残る | + +### 指摘の控え + +形は変えない。`posted_to` の決め方だけが変わる。 + +| 項目 | 変更前 | 変更後 | +| --- | --- | --- | +| `path` / `line` / `body` / `severity` | 担当が書く | 変わらない | +| `evidence` / `falsification` / `suggested_check` | 担当が書く | 変わらない | +| `posted_to` | 担当が、自分が投稿した先を書く | **投稿する側が、送れた先を書く**(`inline` / `body`) | + +### 記録(ラウンドごとの担当の欄) + +| 鍵 | 何が入るか | +| --- | --- | +| `review_url` | 送信の応答が返した URL。流し直しで先客が見つかったときは、その先客の URL | +| `queued` | 上限などで送れず待ち行列に残っているとき、真になる | +| `posted_inline` | インラインとして送れた件数 | +| `posted_body` | 差分の外を理由に本文へ退避した件数 | + +**`prior_review_url` の鍵は作らない**(決定 6)。 + +### 修正の結果ファイル + +形は変えない。取り込む側が読む項目と、それを何に使うかだけを決める。 + +| 項目 | 取り込む側が何に使うか | +| --- | --- | +| `fix_commit` | 送信の後に、送り先の履歴に含まれることを確かめる(決定 10) | +| `resolved_threads` | 決着の投稿を積む | +| `deferred` / `rejected` | 返信の投稿を積む | +| `summary_comment_url` | **担当は書かない。** 投稿する側が、まとめの投稿の応答から書く | + +## 入出力の契約 + +### 結果ファイルを投稿へ変える層 + +| 関数 | 入力 | 出力 | +| --- | --- | --- | +| `review_posts(payload_path, result_path, repo, pr, round_no, seat, head_sha)` | 控えと結果ファイルのパス | 待ち行列へ積む項目の列 | +| `fix_posts(result_path, repo, pr)` | 修正の結果ファイルのパス | 待ち行列へ積む項目の列 | +| `push_fix(worktree, head_branch, fix_commit)` | 作業ツリーと送り先とコミット | 送信の結果と照合の可否 | + +**本文は引数として渡さない。** どの関数もファイルのパスを受け取り、本文はまとまりの中だけで扱う。 + +### 取り込みの標準出力 + +```text +POSTED review_url=https://github.com/.../pull/793#pullrequestreview-... +INLINE=7 BODY=1 QUEUED=0 +FINDINGS=8 +``` + +本文は出さない。上限で送れなかったときは `QUEUED` が 1 以上になり、判定の終了コード 8 の枝が流し直す。 + +### 終了コード + +変えない。取り込み・判定・報告の終了コードと標準出力の変数は変更前と同じである。 + +## 構成要素 + +**動くもの**(「処理の流れ」の図と手順に現れる 5 つ): + +| 構成要素 | 変える内容 | +| --- | --- | +| `plugins/ndf/scripts/lib/result_posts.py` | **新設。** 結果ファイルから投稿の項目を組み立て、送信と照合を行う。部分命令の口も持つ | +| `plugins/ndf/scripts/lib/post_queue.py` | レビューの投稿と返信と決着の照合の鍵を、決定 5 の表に合わせる。拒まれたときの区別(HTTP 422)を返す | +| `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` | プロンプトから投稿の手順を外す。控えと結果ファイルを一時の名前で書いてから改名させる | +| `plugins/ndf/skills/cross-review/scripts/state.py` | 指摘の取り込みが投稿を行う。申告と実数の突き合わせを取り除く。修正の取り込みが返信・決着・まとめと送信を行う | +| `plugins/ndf/skills/cross-review/SKILL.md` | 設計方針の表の投稿の行。起動し直しの枝を繰り返しの先頭へ戻す | + +**書き直す文書**(振る舞いを持たないため、流れの図には現れない): + +| 文書 | 変える内容 | +| --- | --- | +| `plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md` | 修正の手順から担当の送信の行を外す | +| `plugins/ndf/skills/cross-review/docs/03-review-output.md` | 直接投稿の決定を待ち行列を通す形へ書き直す | +| `plugins/ndf/skills/cross-review/docs/04-contracts.md` | 投稿の種別ごとの契約を載せる | +| `plugins/ndf/skills/cross-review/references/context-budget.md` | 工夫の一覧の 4 番目を書き直す | +| `plugins/ndf/skills/fix/SKILL.md` | 返信・決着・まとめ・送信の手順を、共通層を呼ぶ 1 行へ置き換える | + +## 置き場所 + +**結果ファイルを投稿へ変える層は共通層に置く。** cross-review と `fix` の両方から呼ぶためである。cross-review の配下に置くと、単独で `fix` を使う経路が cross-review に依存する。 + +**待ち行列の保存先は変えない。** レビューを回す側の作業ツリーの中にあり、巻き直しのときは作業ツリーごと捨てられる。 + +## 処理の流れ + +### 1 ラウンドのレビュー + +```mermaid +sequenceDiagram + participant M as レビューを回す側 + participant A as 担当 + participant G as GitHub + M->>A: 起動(投稿の手順を持たないプロンプト) + A->>A: 指摘の控えと結果を書く + A-->>M: 終了 + M->>M: 控えを読み、投稿を待ち行列へ積む + M->>G: レビューを送る + G-->>M: URL と件数 + M->>M: 記録へ書き戻し、指摘を取り込む +``` + +担当が控えを書く前に止まれば、レビューを回す側は積むものを持たないため、GitHub には何も増えない。起動し直しても重ならない。 + +### 途中で止まった実行の流し直し + +```mermaid +graph TD + A[取り込みが投稿を送る] --> B{記録の前に止まったか} + B -->|止まっていない| C[記録へ書き戻して続ける] + B -->|止まった| D[待ち行列に項目が残る] + D --> E[判定が残りを見て流し直す] + E --> F{先客がいるか} + F -->|いる| G[送らずに取り除き
先客の URL を記録する] + F -->|いない| H[送って記録する] +``` + +### 修正の取り込み + +1. 修正の担当がコミットまでを行い、結果ファイルを書く +2. 取り込む側が現在の頭を指定して送信する +3. 報告されたコミットが送り先の履歴に含まれることを確かめる。含まれなければ止まる +4. 返信・決着・まとめの投稿を待ち行列へ積んで流す +5. 送信の応答を記録へ書き戻す + +## 非機能の実現方式 + +| 条件 | どう満たすか | +| --- | --- | +| 応答の量 | 取り込みの標準出力を件数・URL・状態だけにする。本文はまとまりの中だけを通る | +| 上限への耐性 | 送れなかった投稿は待ち行列に残り、判定の終了コード 8 の枝が流し直す。担当をもう一度起動しない | +| 所要 | 担当のプロンプトから投稿の手順が消えるぶん、担当の実行が短くなる。監視の上限は変えない | +| 権限 | 担当の CLI に GitHub への書き込みの権限が要らなくなる | + +## テスト設計 + +| 受け入れ条件 | 何で確かめるか | +| --- | --- | +| AC1・AC2 | 担当のプロンプトを取り出し、投稿の手順の語が 0 件であることを見る | +| AC3 | 一時の名前のファイルだけがある状態で取り込みを呼び、結果なしとして扱われることを見る | +| AC4・AC20 | 控えを書かずに終わる偽の担当で 1 ラウンドを回し、偽の `gh` が受けたレビューの投稿が 0 件であることを見る | +| AC5・AC6 | 取り込みの標準出力に、控えの本文の文字列が含まれないことを見る | +| AC7 | 修正の取り込みを呼び、返信・決着・まとめの 3 種別が待ち行列へ積まれることを見る | +| AC8・AC9 | 送信を偽装し、報告されたコミットが送り先に無いときに失敗することを見る | +| AC10・AC11 | 偽の `gh` が先客を返す状態で投稿を積んで流し、新しい投稿が 0 件で項目が取り除かれることを見る | +| AC12 | 積んだまま流していない状態から流し直し、記録に先客の URL が入ることを見る | +| AC13・AC14 | 返信と決着とまとめについて、同じ項目を 2 度積んでも増えないことを見る | +| AC15 | 記録に入る URL と件数が、偽の `gh` が返した応答の値と一致することを見る | +| AC16 | 偽の `gh` が HTTP 422 を返す状態で、本文へ退避して送り直すことを見る | +| AC17 | 控えに 1 件あり、インラインが 422 で全件退避された実行で、結果なしにならないことを見る | +| AC18・AC19 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | +| AC21・AC22 | 共通層の部分命令を直接呼び、cross-review から呼んだときと同じ項目が積まれることを見る | +| AC23・AC24 | 取り込み・判定・報告の終了コードと標準出力を見る既存のテストを変えずに通す | +| AC25〜AC29 | 変更した文書の行を読む検査 | +| AC30・AC31 | 検証手段の表のコマンド | + +**偽の `gh` を使う形は既存のテストにある**(待ち行列の照合と巻き直しの投稿)。同じ仕掛けを使う。 + +## 未確認のまま残ること + +- **差分の外を指す指摘が拒まれるときの応答の形。** HTTP 422 が返ることは GitHub の仕様として知られている。本文とインラインを同じ要求で送ったときにどちらが拒まれるかは未確認である。実装の最初の段で、偽ではない `gh` で 1 度確かめる +- **決着の投稿を、すでに決着したスレッドへもう一度送ったときの応答。** 失敗にならないことを前提に置いているが、実行して確かめていない +- **投稿者のアカウントが席ごとに違う環境があるかどうか。** いまの作業環境では 1 つだが、別の環境で担当ごとに別の認証を使う設定があると、照合の鍵の前提が変わる + +## 申し送り(並行する設計との境界) + +| 相手 | 決めた契約 | +| --- | --- | +| #727(G1、PR #793) | 席の名前と、結果ファイル・控えの名前が席で組まれること、レビューの本文の先頭行が席を持つことを**この設計が前提にする**。決め方は G1 が持つ。触るファイル(`state.py` / `launch-reviewer.sh` / `SKILL.md`)が重なるため、後からマージする側が競合を解く | +| #729(G3、マージ済み) | 結果なしの理由の語彙を変えない。**投稿済みのレビューを探す鍵(`prior_review_url`)は作らない**ため、G3 が残した「鍵が無い = 無かった」の書き方に足すものは無い | +| #732(G2、PR #790) | 指摘の中身と数え方に触らない。#583 の収束の部分は G2 が塞いだ | +| #728(G4) | 触るファイルが重ならない | +| 子課題 #548 #350 #585 #676 | この設計で根本が動くため、現象が出なくなる見込みがある。**受け入れ条件は持たず、閉じるのは棚卸に任せる**(要求の「影響」) | + +## 置き換える既存の設計との対応 + +**この文書は[置き換える前の設計](issue-662-598-537-619-584-583-design.md)の決定 18・19 を置き換える。** 既存文書の本体は書き換えず、置き換え先を指す段落を 1 つ足す(決定 1)。 + +| 既存の決定 | この文書 | 扱い | +| --- | --- | --- | +| 決定 18(投稿済みのレビューを持つ担当は、記録だけを行う形で起動し直す) | 決定 2・6 | **取り下げる。** 担当が投稿しなくなるため、対処の前提が無くなる | +| 決定 19(起動し直した担当を、初回と同じ経路に通す) | 決定 7 | 引き継ぐ | + +## 関連する文書 + +この文書は「どう作るか」だけを扱う。 + +| 文書 | 何を持つか | +| --- | --- | +| [issue-730-583-requirements.md](issue-730-583-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC31) | +| [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md) | 置き換える前の設計(決定 18・19) | +| [issue-729-619-584-design.md](issue-729-619-584-design.md) | 結末の語彙と、止めた担当に書かせないこと | +| [issue-732-624-706-design.md](issue-732-624-706-design.md) | 数えない指摘の区分と収束の判定 | +| [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) | 席の決め方と再開の引数 | diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md new file mode 100644 index 00000000..8ed2c580 --- /dev/null +++ b/issues/issue-730-583-requirements.md @@ -0,0 +1,232 @@ +# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのをレビューを回す側だけにし、途中で止まっても二度書かない(#730 #583 の要求) + +## 目的 + +レビューを 1 者ぶん任された担当が、PR へ指摘を書き込んでから、回す側が読む結果を書く前に止まることがある。PR には指摘が出ているのに記録には無いため、回す側は「結果なし」と読んで同じ担当をもう一度起動し、同じ論点がもう一度投稿される。利用者は 1 つの論点に 2 つのスレッドを見て、両方へ返信する。修正の側でも、送ったという報告どおりにブランチが進んでいないことがある。 + +GitHub と git へ書くのをレビューを回す側だけにし、担当は結果を残すだけにする。途中で止まっても同じものを二度書き込まない。 + +## 用語と識別子の対応 + +| 業務用語 | 識別子・実体 | +| --- | --- | +| レビューを回す側 | `plugins/ndf/skills/cross-review/scripts/state.py` と、それを呼ぶ `SKILL.md` の骨組み。3 層では supervisor | +| 担当 | 1 ラウンドで 1 者ぶんのレビューを行う CLI。`launch-reviewer.sh` が起動する | +| 席 | そのラウンドで担当が入る枠。`claude` / `codex` / `agy` / `kiro` と、同じランタイムの 2 つ目の `-2`〜`-9`(#727) | +| 修正の担当 | 指摘を直すサブエージェント、または単独で動く `fix` の実行 | +| 結果ファイル | `<席>-review-pr<番号>-result.json`。判定の要約(`event` / `posted_as` / `comments_count` / `by_severity`) | +| 指摘の控え | `<席>-review-pr<番号>-round-payload.json`。指摘 1 件ごとの `path` / `line` / `body` / `severity` ほか | +| 修正の結果ファイル | `fix-pr<番号>-result.json`。`fix_commit` / `resolved_threads` / `deferred` / `rejected` ほか | +| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py`。種別は `pr-comment` / `review-post` / `review-reply` / `thread-resolve` | +| 二度書かないための照合 | `post_queue.already_posted()` / `posted_match()` | +| 結果ファイルを投稿へ変える層 | 新設する `plugins/ndf/scripts/lib/result_posts.py` | +| 指摘の取り込み | `state.py read-result` | +| 修正の取り込み | `state.py merge-fix` | +| 最終スイープの照合 | `state.py verify-sweep` | + +## 対象範囲 + +**含む。** + +| 何を | どう変わるか | +| --- | --- | +| レビューの投稿(本文とインライン) | 担当が `gh api` で直接送る → 担当は指摘の控えを書くだけ。レビューを回す側が待ち行列を通して送る | +| 指摘への返信とスレッドの決着 | 修正の担当が送る → 修正の結果ファイルを取り込む側が待ち行列を通して送る | +| 修正のまとめの投稿 | 同上 | +| 修正の送信 | 修正の担当が `git push origin <ブランチ名>` → 取り込む側が `git push origin HEAD:<ブランチ名>` | +| 申告と実数の突き合わせ | 担当の申告件数を GitHub の実数と比べる → 送った結果をそのまま記録にする | +| 投稿済みのレビューを探して記録だけの起動をする決め | 取り下げる(担当が投稿しないため、投稿済みで記録なしの状態が起きない) | +| 起動し直しの経路 | 初回と同じ経路(根拠の検証と反証を含む)へ通す | +| `fix` を単独で使う経路 | 書き込みの実装を cross-review から呼ぶ経路と 1 つにする | + +**含まない。** + +| 何を | なぜ | +| --- | --- | +| PR の巻き直しの close / create / reopen | すでにレビューを回す側が行う(`rotate-pr.sh`)。待ち行列に積まない性質も変えない | +| 反証の投稿 | 反証はもともと投稿しない(ファイルへ書くだけ) | +| 収束の判定・区分・数え方 | #732(G2)が持つ | +| 利用上限で止まった担当の報告と起動し直しの可否 | #729(G3)が持つ | +| 席の決め方・参加する者の選び方・再開の引数 | #727(G1)が持つ | +| cross-refactoring の書き込み | この束の外。`cross-refactoring` はすでに「公開するのは進行側だけ」である | +| 子課題 #548 #350 #585 #676 の個別の受け入れ条件 | マイルストーン 06。根本の修正で直る見込みは「影響」に書き、閉じるのは棚卸に任せる | + +## 前提 + +- **前提 1: レビューの投稿者アカウントは、いまも実質 1 つである。** 担当の CLI は同じ作業環境の `gh` の認証を使うため、投稿の担い手を変えても PR 上の投稿者は変わらない。どの席の指摘かは本文の先頭行が表す +- **前提 2: 担当が結果を書けずに止まったとき、その担当は何も投稿していない。** 投稿の手順をプロンプトから外すため、この前提は変更の後に成り立つ +- **前提 3: 修正の担当が働く作業ツリーは、取り込む側から同じパスで見える。** cross-review は状態ファイルが持つ作業ツリーを使い、単独の `fix` は現在の作業ツリーを使う +- **前提 4: 待ち行列は作業ツリーの中に置かれ、巻き直しのときは作業ツリーごと捨てられる。** 現行のとおりで変えない + +## 受け入れ条件 + +### 担当は結果だけを残す + +- [ ] AC1: 担当へ渡すプロンプトに、レビューを投稿する手順が 1 つも無い。 + 対象は `gh api` の呼び出し・`event` の指定・インラインの組み立ての 3 つである +- [ ] AC2: 担当へ渡すプロンプトは、指摘の控えと結果ファイルの 2 つだけを書かせる。結果ファイルの `review_url` は担当が書かない項目になる +- [ ] AC3: 担当は結果ファイルと指摘の控えを、一時の名前で書いてから改名する。読み取り側が途中の内容を読むことがない +- [ ] AC4: 担当が指摘の控えを書かずに終わった実行では、その PR に新しいレビューが 1 件も増えない + +### 書き込みは 1 か所から行う + +- [ ] AC5: 指摘の取り込みは、指摘の控えからレビューの投稿を組み立て、待ち行列へ積み、流すところまでを 1 回の呼び出しで行う。投稿の本文は呼び出しの引数に現れない +- [ ] AC6: 取り込みの標準出力に、レビューの本文とインラインの本文が出ない。出るのは件数・URL・状態だけである +- [ ] AC7: 修正の取り込みは、返信・スレッドの決着・まとめの投稿を待ち行列へ積んで流す。修正の担当はこの 3 つを行わない +- [ ] AC8: 修正の送信は取り込む側が `git push origin HEAD:<ブランチ名>` で行う。修正の担当はコミットまでを行い、送らない +- [ ] AC9: 送信の後、報告されたコミットが起点のブランチに載っていることを確かめる。載っていなければ取り込みは失敗として止まる + +### 途中で止まっても二度書かない + +- [ ] AC10: レビューの本文の先頭行は `## 🤖 cross-review | round | <席> | ` である。先頭 80 文字にラウンドと席が入る +- [ ] AC11: 同じラウンド・同じ席のレビューが PR にすでにあるとき、同じ投稿を積んで流しても新しいレビューは増えない。待ち行列の項目は送信済みとして取り除かれる +- [ ] AC12: 投稿の後・記録の前に取り込みが止まった状態から流し直すと、レビューは増えず、記録には既存のレビューの URL が入る +- [ ] AC13: 同じ指摘への返信が 2 度積まれても、返信は 1 件しか増えない。すでに決着したスレッドをもう一度決着させても、結果は変わらず失敗にもならない +- [ ] AC14: 同じラウンドのまとめの投稿を 2 度積んでも、コメントは 1 件しか増えない + +### 申告をやめ、送った結果を記録にする + +- [ ] AC15: 記録に入る投稿の URL と件数は、送信の応答から取る。担当の申告件数と GitHub の実数を突き合わせる処理は無くなる +- [ ] AC16: 差分の外を指す指摘は、投稿する側が本文へ退避する。 + 対象はインラインが HTTP 422 で拒まれたものである。 + 退避した指摘の `posted_to` は `body` になり、退避した件数が取り込みの出力に出る +- [ ] AC17: 指摘の控えに指摘が 1 件以上あり、インラインとして送れたものが 0 件でも、その担当は結果なしにならない + +### 起動し直しの経路(#583) + +- [ ] AC18: 骨組みで、起動し直した担当は初回と同じ経路を通る。取り込みの後に根拠の検証と反証を通ってから 2 度目の判定へ進む +- [ ] AC19: 待ち行列に残りがあるときの枝(判定の終了コード 8)は、2 度目を含む各判定の直後に残る +- [ ] AC20: 担当が投稿の後に止まった実行を再現しても、PR のレビューは 1 件しか増えず、同じ論点のスレッドが 2 つに分かれない + +### `fix` を単独で使う経路 + +- [ ] AC21: 返信・スレッドの決着・まとめの投稿・修正の送信の実装は 1 つである。 + cross-review から呼ぶ経路と単独で呼ぶ経路が、同じものを使う +- [ ] AC22: 単独で `fix` を使うときの手順に、修正の結果ファイルを投稿へ変えるコマンドが 1 行で書かれている。利用者が `gh api` を手で組み立てる手順は残らない + +### 退行しない + +- [ ] AC23: 取り込み・判定・報告の終了コードと標準出力の変数は変更前と同じである +- [ ] AC24: 席の名前(#727)・結末の語彙(#729)・数えない指摘の区分(#732)の扱いを変えない + +### 文書 + +- [ ] AC25: `SKILL.md` の設計方針の表から「AI 自身が `gh api` で PR に直接投稿」が消える。 + 代わりに、投稿の担い手とその理由が入る +- [ ] AC26: `docs/03-review-output.md` の「AI 直接投稿」の決定が、待ち行列を通す形へ書き直される +- [ ] AC27: `docs/02-fix-and-rotation.md` の修正の手順から、担当が送信する行が消える。 + 代わりに、取り込む側が送信することが書かれる +- [ ] AC28: `references/context-budget.md` の工夫の一覧から「中間ペイロードがメインを通らない」が消える。 + 代わりに、本文がレビューを回す側のプロセスの中だけを通り、応答には載らないことが書かれる +- [ ] AC29: 投稿の待ち行列の種別のうち、この変更で積む側ができたものが `docs/04-contracts.md` の契約に載る + +### 全体 + +- [ ] AC30: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [ ] AC31: 次の 3 つが終了コード 0 で終わる。`bash scripts/build-runtime-plugins.sh --check`。`claude plugin validate .`。`python3 scripts/check-skill-frontmatter.py` + +## 非機能の条件 + +| 項目 | 条件 | +| --- | --- | +| 応答の量 | 取り込み 1 回の標準出力は、投稿の件数と URL と状態だけで 20 行以内。レビューの本文を載せない | +| 上限への耐性 | 投稿が上限で拒まれたら待ち行列へ残り、判定の枝(終了コード 8)で流し直される。担当をもう一度起動しない | +| 所要 | 担当の実行時間は、投稿の手順が無くなるぶん短くなる。監視の上限(#729)は変えない | +| 権限 | 担当の CLI に GitHub への書き込みの権限が要らなくなる | + +## 影響 + +| 課題 | この変更で何が変わるか | 誰が閉じるか | +| --- | --- | --- | +| #583 | 担当が投稿しないため、投稿済みで記録なしの状態が起きない。起動し直しても論点が重ならない | この束 | +| #548 | 本文だけに書く担当がいなくなり、申告と実数の突き合わせも無くなるため、インライン 0 件で結果なしにならない | 棚卸(再現手順の確認だけ行う) | +| #350 | 担当と `fix` の投稿が待ち行列を通るため、上限の間に失われない | 棚卸 | +| #585 | 送信を取り込む側が `HEAD:<ブランチ名>` で行い、コミットが載ったことを確かめるため、報告と実物が食い違わない | 棚卸 | +| #676 | まとめの投稿が待ち行列を通るため、残らない回が無くなる | 棚卸 | + +## 検証手段 + +| 何を確かめるか | どう確かめるか | +| --- | --- | +| プロンプトから投稿の手順が消えたこと | 担当の起動スクリプトが作るプロンプトを取り出す単体テスト | +| 二度書かないこと | 偽の `gh` で「先客がいる」応答を返し、投稿が増えないことを見る単体テスト | +| 投稿の本文が応答に出ないこと | 取り込みの標準出力に本文の文字列が含まれないことを見る単体テスト | +| 送信と照合 | 送信を偽装し、報告されたコミットが起点に無いときに失敗することを見る単体テスト | +| 骨組みの経路 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | +| 文書 | 変更した行を読む検査、または該当ファイルの文言の検査 | + +## 前提とする取り決め + +| 相手 | 取り決め | +| --- | --- | +| #727(G1、PR #793) | 席の名前(`claude-2` の形)と、結果ファイル・指摘の控えの名前が席で組まれること。レビューの本文の先頭行が席を持つこと。**この束は席の名前をそのまま使い、決め方を変えない** | +| #729(G3、PR #791、マージ済み) | 結末の語彙と、結果ファイルが無いときの理由の記録。**投稿の担い手が変わっても、結果なしの理由の語彙を変えない** | +| #732(G2、PR #790) | 数えない指摘の区分と収束の判定。**この束は指摘の中身に触らない** | +| #728(G4) | 触るファイルが重ならない | + +## 境界 + +```text +常に行う … 既存テストの実行、変更した文書の検査、待ち行列を通す形への統一 +確認してから行う … 部分命令の名前の変更、終了コードの変更、待ち行列の保存先の変更 +行わない … 収束の判定の書き換え、席の決め方の変更、PR の巻き直しの手順の変更 +``` + +## 未決 + +- 単独で `fix` を使うときの入口を、専用のスクリプトにするか既存の共通層の部分命令にするか(設計の決定 11 で決める) +- 差分の外を指す指摘の退避を、投稿の前に判定するか 422 を受けてから行うか(設計の決定 9 で決める) + +## 置き換える既存の受け入れ条件との対応 + +| 既存の受け入れ条件 | この文書 | 扱い | +| --- | --- | --- | +| AC63(結果が使えない担当の投稿済みレビューを探す) | — | **取り下げる。** 担当が投稿しないため、探す対象が無い | +| AC64(照会が失敗したら残さない) | — | 同上 | +| AC65(記録だけを行うプロンプトへ差し替える) | — | 同上。プロンプトからは投稿の手順そのものが消える(AC1) | +| AC66(申告された URL を投稿済みとして取り込む) | AC15 | **改める。** 申告ではなく、送信の応答から取る | +| AC67(起動し直しを初回と同じ経路へ) | AC18・AC19 | 引き継ぐ | + +既存の一覧の置き場所は、[置き換える前の要求](issue-662-598-537-619-584-583-requirements.md)の「受け入れ条件(P3)」である。**その文書は 2026-09-15 時点の記録として残し、本体は書き換えない。** + +## 依頼(原文) + +### #730(根本原因の親) + +> **cross-review が GitHub と git へ行う書き込み(レビューの投稿・修正の push・サマリの投稿)の置き場所。** +> +> - いまは担当が行う。レビューは担当の CLI が `gh api` で直接投稿し、修正は担当のサブエージェントが `git push origin {HEAD_BRANCH}` で送る +> - 進行側の `scripts/state.py` は結果ファイルを読むだけで、実物と照合しない +> - 投稿の待ち行列 `plugins/ndf/scripts/lib/post_queue.py` は進行側にあるが、担当の投稿は通らない +> - 書き込みと、進行側が読む記録を担当が別々に行うため、打ち切り・detach の作業ツリー・書き忘れのどれでも、記録と実物が食い違う +> +> 移動(`move_responsibility`)。cross-refactoring の「公開するのは進行側だけ」と同じ形にする。担当は結果ファイルだけを書く。進行側は結果ファイルから `post_queue.py` を通して投稿し、修正は `HEAD:` で push し、サマリも投稿する。 +> +> **進行側の投稿は、結果ファイルのパスを `post_queue.py` へ渡す形にする。** 進行側が投稿を持つとき、payload の本文を supervisor の応答へ載せず、スクリプトがファイルから読んで送る。 +> +> 完了条件: +> +> - 担当は結果ファイルだけを書き、レビューの投稿・修正の push・サマリの投稿は `state.py` が行う +> - 「投稿済みで記録なし」「push したと報告して実物なし」の状態が起きないことを検査が確かめる +> - `fix` を単独で使う経路(返信と Resolve)の扱いを決める +> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる + +### #583 + +> **担当が投稿を終えた後、結果ファイルを書く前に監視の上限に達すると、投稿済みの指摘が状態ファイルに記録されない。** 判定は「結果なし」として同じラウンドで担当を起動し直し、起動し直した担当が同じ論点を重ねて投稿する。 +> +> 1 回目の 3 件(minor)は判定の入力に一度も入らない。**同じ論点が 2 つのスレッドに分かれ、修正の担当が両方へ返信する。** +> +> #730(親) — 修正レイヤーはこちらが持つ。担当は結果ファイルだけを書き、レビューの投稿は進行側(`state.py`)が行う形にすれば「投稿済みで記録なし」が起きない + +**#583 の収束の部分は #732(G2)の設計で塞がった。** この束に残るのは投稿の重なりと、起動し直しの経路である。 + +## 関連する文書 + +| 文書 | 何を持つか | +| --- | --- | +| [issue-730-583-design.md](issue-730-583-design.md) | どう作るか(決定・データ構造・契約・処理の流れ) | +| [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md) | 置き換える前の受け入れ条件(AC63〜AC67) | +| [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) | 結末の語彙と、止めた担当に書かせないこと | +| [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) | 数えない指摘の区分と収束の判定 | +| [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) | 席の決め方と再開の引数 | From 2c0c3bb8f5b3ec9a508cf5ecbc07af5dce43c5ed Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:10:21 +0000 Subject: [PATCH 096/217] =?UTF-8?q?Docs:=20=E7=B5=90=E6=9C=AB=E3=81=AE?= =?UTF-8?q?=E8=AA=9E=E5=BD=99=E3=81=A8=E6=95=B0=E3=81=88=E3=81=AA=E3=81=84?= =?UTF-8?q?=E6=8C=87=E6=91=98=E3=81=AE=E5=8C=BA=E5=88=86=E3=82=92=E7=A2=BA?= =?UTF-8?q?=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=B8=E7=A7=BB=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 利用上限で止まった担当の扱い(#729 #619 #584)を新しい確定仕様へまとめ、数えない指摘を 棄却と軽微な指摘に限る仕様(#732 #624 #706)の残りを既存の確定仕様へ書き足した。 Co-Authored-By: Claude Fable 5.1 --- docs/specifications/README.md | 1 + .../cross-review-evidence-based.md | 17 +- .../cross-review-launch-outcome.md | 255 ++++++++++++++++++ 3 files changed, 272 insertions(+), 1 deletion(-) create mode 100644 docs/specifications/cross-review-launch-outcome.md diff --git a/docs/specifications/README.md b/docs/specifications/README.md index b1ff5983..f0ecb7c6 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -16,6 +16,7 @@ | [ndf-testenv-lock-and-registry.md](ndf-testenv-lock-and-registry.md) | テスト環境の排他の判定を陳腐化の規則へ揃えたこと、台帳へ書けなかったときの扱い。手順は `worktree` の `references/test-execution.md` が正 | | [ndf-issue-upkeep-root-cause.md](ndf-issue-upkeep-root-cause.md) | 溜まった課題を根本原因の場所で直す判定(ルートコーズ)と、構造の判断の担い手。親 issue とサブイシューの実測、マイルストーンを連番で読む理由。手順は `issue-upkeep` の SKILL.md と `references/` が正 | | [cross-review-evidence-based.md](cross-review-evidence-based.md) | 証拠ベースのレビューと効果の測定。状態ファイルの契約と決定の理由。手順は `cross-review` の `SKILL.md` が正 | +| [cross-review-launch-outcome.md](cross-review-launch-outcome.md) | 起動 1 回の結末の語彙(理由 9 語)と起動し直しの可否、利用上限と CLI の上限の検知、プロセスグループでの起動と停止。手順は `cross-review` の `SKILL.md` と `docs/` が正 | | [ndf-cleanup-and-bundle-closing.md](ndf-cleanup-and-bundle-closing.md) | 後片付けが止まる条件(git の拒否だけ)、実行前確認の要否を決める 3 つの問い、まとまりの課題を終わりの工程で閉じる条件と結果の 4 値、配布の記録の形と読み方。手順は `merged` / `progress-tracking` / `release` の SKILL.md が正 | | [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) | `/goal` の工程を conductor / supervisor / worker の 3 層で通す運転。持ち場 5 つ、報告の 2 段、続けさせる回数、上限(429)で中断した層の再開。手順は `development-workflow` の `references/agent-layers.md` が正 | | [ndf-context-window-metrics.md](ndf-context-window-metrics.md) | 会話の記録から context window を 3 層で測る部品(`transcript_agents.py`)の値の取り方と、`skill-stats --agents` の 4 つの表と印。値の取り方はこの文書が正 | diff --git a/docs/specifications/cross-review-evidence-based.md b/docs/specifications/cross-review-evidence-based.md index 0e2983d9..a9195658 100644 --- a/docs/specifications/cross-review-evidence-based.md +++ b/docs/specifications/cross-review-evidence-based.md @@ -62,6 +62,13 @@ 返ったインラインは総評へ移る。`payload.json` が投稿したインラインの写しであった頃は、 この 2 つの経路を通った指摘が記録へ届かなかった(PR #157 の round 4)。 +**誤りを示されていない重大な指摘が数えられず、承認で収束した。** 担当を 1 者に絞って回した +4 ラウンドでは、どのラウンドの指摘も修正を要する妥当なものだったが、いずれも収束と判定された +(#624)。反証を返す相手がいないため、数える区分に入る指摘が 0 件になる。別のリポジトリの +Pull Request では、検証手順を実行できなかった 3 件が立証不足へ落ち、そのうち 2 件は別の担当が +支持を付けていた(#706)。原因は、立証不足の区分が「反証の機会があって支持されなかった」と +「立証の機会が無かった」の 2 つの意味を兼ねていたことにある。 + ## 決定と理由 | 決定 | 理由 | @@ -94,6 +101,7 @@ | 1 回の実行が測るのは 1 つの状態ファイルである | 集計の単位は測る目的で変わる(変更の前後・担当ごと・リポジトリごと)。0 か 1 の値で出しておけば、どの単位でも測る側が足し合わせられる | | 測る単位はローテーション全体である | ローテーションは同じ変更に対するレビューの続きで、終わり方も上限の判定も状態ファイル全体で 1 つである。`current_pr` の分だけを測ると、それ以前のラウンドがまるごと落ちる | | 精度(誤った指摘の割合)は測らない | 修正されなかった指摘が誤りだったかは記録から決まらない。範囲外として却下したもの、判断が割れて `deferred` にしたもの、単に対応しなかったものが混ざる。**測れない値を数字にすると、比較の根拠として使われる** | +| 立証不足の区分の名前は変えず、軽微な指摘の残余だけに当てる | 改名すると、旧い状態ファイルの値・測定の読み方・規約と確定仕様の語彙の 3 つを同時に付け替えることになり、変更の前後を測定で比べられなくなる | | 1 者だけの方式は担当ごとに出す | 1 つの数字にまとめると、誰を選ぶかが結果に混ざる | | 状態ファイルを残す仕組みは作らない | 状態ファイルには Pull Request の中身が入り、置き場所と保持の期間を決める必要がある。測定のためだけに決めるには重い。測る側が測る前に控える | @@ -286,6 +294,11 @@ **新規性の層は却下の記録を読まない。** 一致の判定を持つのは 1 か所であり、区分はその判定が 数える母集合を絞るだけである。同じ抑止を 2 つの仕組みが持たない。 +**担当が 1 者のラウンドと、起動し直した担当の指摘は、未反証として数える。** 担当が 1 者なら +反証を返す相手がいない。起動し直した担当の指摘は、反証を取り込んだ後に取り込まれるため反証を +持たない。どちらも誰も誤りを示していない重大な指摘であり、数えないと未解決のまま承認で収束 +する。1 者で回したラウンドの収束は、この新規性と、全員が通したかの 2 つで決まる。 + ### 効果の測定 **同じ記録を、方式ごとに違う規則で読む。** 母集合は代表だけで、4 つとも `merged_into` を @@ -295,7 +308,7 @@ | --- | --- | | `single` | 1 者だけの結果。担当ごとに 1 通り出す | | `majority` | `origin_runtimes` が 2 者以上の指摘 | -| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘。状態の管理スクリプトが数える集合と同じで、一致をテストで固定する | +| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘。**数える区分の集合を持つのは共有モジュール 1 か所(`scripts/classifications.py`)で、収束の判定と効果の測定の両方がそれを読む** | | `oracle` | いずれかの担当が出した指摘のうち、**修正された**もの | **`origin_runtimes` を持たない指摘は、取り込み時の担当 1 者として読む。** この値は統合の @@ -465,6 +478,8 @@ measure.py <状態ファイルのパス> [--output <パス>] - [PR #549](https://github.com/devbasex/ai-plugins/pull/549) — 同・実装 - [PR #557](https://github.com/devbasex/ai-plugins/pull/557) — 効果の測定の設計 - [PR #558](https://github.com/devbasex/ai-plugins/pull/558) — 同・実装 +- [PR #783](https://github.com/devbasex/ai-plugins/pull/783) — 数えない指摘を棄却と軽微な指摘に限る設計 +- [PR #790](https://github.com/devbasex/ai-plugins/pull/790) — 同・実装 - [根拠と反証条件の規約](../../plugins/ndf/skills/cross-review/docs/06-evidence.md) - [状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) - [レビュワーの母集合と終了基準](../../plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md) diff --git a/docs/specifications/cross-review-launch-outcome.md b/docs/specifications/cross-review-launch-outcome.md new file mode 100644 index 00000000..be6de51a --- /dev/null +++ b/docs/specifications/cross-review-launch-outcome.md @@ -0,0 +1,255 @@ +# cross-review: 利用上限で止まった担当が結果なしとだけ報告され、空振りの起動し直しで待たされる → 上限を理由として報告し、同じラウンドで起動し直さない + +## 目的 + +**担当の CLI が利用上限で止まったことが、理由「利用上限」として進行側と利用者に届く。** +同じラウンドで同じ担当を起動し直さず、その場で誤りの終わりへ進む。 + +**結果ファイルが無いときの終わり方を、1 つの語彙で区別できる。** 監視が打ち切った・CLI が +自分の上限で終わった・終わったが結果を書かなかったの 3 つが、別々の理由として残る。 + +**結果なしの判断と起動し直しの可否を持つのは 1 か所だけである。** 収束ループを回す 2 つの +Skill(cross-review / cross-refactoring)はその値を読むだけで、同じ判断を別々に書かない。 + +**監視が止めた担当の子プロセスは、止めた後に結果ファイルを書かない。** + +**手順と表は +[`cross-review` の SKILL.md](../../plugins/ndf/skills/cross-review/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-review/docs/01-state-and-review.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、共通層の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表とコードブロックにだけ置く。 + +| 業務用語 | 識別子 | +| --- | --- | +| 担当 | レビュー・反証・適用・修正を行う CLI(codex / agy / kiro / claude) | +| 起動 1 回 | 起動の手順が担当を 1 度起動し、監視がそれを見終わるまで | +| 結末 | 起動 1 回の終わり方。監視の状態と理由、結果ファイルの有無と読めるかを合わせたもの(`LaunchOutcome`) | +| 理由 | 結末の語彙の 1 語(`REASONS`) | +| 起動し直しの可否 | 同じ担当を同じ条件で起動し直せば解ける結末か(`relaunch_same_agent`) | +| 使える結果 | 結果ファイルがあり、JSON オブジェクトとして読める(`payload`) | +| 結末の共通層 | `plugins/ndf/scripts/lib/monitor_outcome.py` | +| 監視 | `plugins/ndf/scripts/lib/monitor.py` | +| 起動の手順 | `plugins/ndf/scripts/lib/launch-cli.sh` | +| 結末を読む関数 | `read_launch_outcome(tmp_dir, stem, result_path)` | +| 起動し直せない理由の集合 | `NO_RELAUNCH_REASONS` | +| 状態からの既定の理由 | `reason_for(status)` | +| 結果ファイル | `-result.json` | +| 監視の結果ファイル | `-monitor.json` | +| 監視の記録 | `monitor-outcomes.jsonl`。追記だけで積む | +| 結果の取り込み / 判定 / 報告の表 | `state.py` の `read-result` / `judge` / `report` | +| 誤りの終わり | 状態ファイルの `final=error` と、判定の終了コード 1 | + +## 背景 + +**利用上限の文言が照合の表に無く、結果なしの理由が「結果ファイル無し」に畳まれていた。** +そのため進行側は同じ担当を同じラウンドで起動し直した。監視の上限 1 回分(レビューでは +1200 秒)を待ってから、全体を誤りで終えていた(#619)。別の記録では、空振りの起動し直しが +3729 回に達した(#647)。届く理由が 1 つしかないため、上限に当たったのか、監視の上限で +打ち切られたのかを、進行側も利用者も判別できなかった。 + +**監視が止めた担当の子プロセスが、止めた後に結果ファイルを書いていた。** 監視は担当の +pid だけへシグナルを送っており、CLI が起こした子プロセスは生き残っていた(#584)。 + +**根本原因は、結果なしの判断を 2 つの Skill がそれぞれ結果ファイルの有無だけで行い、監視が +書いた理由をどちらも読まないことにある(#729)。** 理由を読む処理を各 Skill へ書くと、 +監視の理由から結果なしの理由へ移す同じ表が 2 か所にできる。語彙を足すたびに片方が古くなる。 + +**文言の照合は、実物の 3 形式に一致することを確かめてある。** 2026-09-19 に `develop` +(9eaebe14、bash 5.3.9、Python 3.14.4)で測った結果は次のとおりである。 + +| 入力 | 利用上限 | CLI の上限 | 既存の致命 | +| --- | --- | --- | --- | +| kiro の実物の 1 行 | 一致 | — | — | +| claude の 429 の JSON の 1 行 | 一致 | — | — | +| HTTP 429 の状態行 | 一致 | — | 一致 | +| HTTP 401 の状態行 | — | — | 一致 | +| agy の打ち切りの行 | — | 一致 | — | +| 表・バッククォート・引用・grep 形式 | — | — | — | + +**既存の致命の照合は、kiro の実物と claude の JSON に一致しない。** #619 が再現した形である。 + +**プロセスグループの停止は 2026-09-15 に測った。** ジョブ制御を有効にして起動した CLI を +グループへのシグナルで止めると、3 秒後に子プロセスが書く結果ファイルは書かれなかった。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 結果なしの判断と起動し直しの可否を、結末の共通層の 1 つの関数へ移す | 理由を読む処理を各 Skill に書くと、同じ表が 2 か所にできて片方が古くなる | +| 起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にする | 進行側が結末を見て決めることはこの 1 つに尽きる | +| 偽にするのは利用上限だけにする | 利用上限は起動し直しても解けず、起動のたびに待ちと相手の CLI の枠を使う。他の理由は対象や負荷で変わりうるため、1 度は起動し直してよい | +| 偽のときに何をするかは Skill が決める | 担当を替えられるかどうかは、担当の集合を知る Skill にしか決められない | +| 利用上限は早期の致命の状態のまま、理由だけを分ける | 終了コードで分岐する骨組みと文書を変えずに、読む側が区別できる。cross-refactoring だけで監視の呼び出しが 8 か所ある | +| CLI 自身の上限は結果なしの状態のまま、理由だけを分ける | agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」で正しい | +| 利用上限の文言は、標準エラーの記録を全担当で見る | 既存の照合が引用・表・grep 形式を除外する。実測では claude の JSON もこの判定に飲み込まれなかった | +| 標準出力の記録は claude だけ、JSON 向けの照合で見る | JSON は 1 行に引用符を多く含む。行単位の引用の判定が、引用の内側と判定してしまう | +| 読めない結果を共通の語彙に入れる | 結果ファイルが JSON として読めないことは、2 つの Skill が同じ形で見ている | +| 判定の値が無い・未投稿は cross-review 固有に残す | 結果ファイルの中身とレビューの投稿の話で、監視も cross-refactoring も知りえない | +| 監視の結末に理由の欄を持たせ、無ければ状態からの既定を使う | 利用上限と CLI の上限は、監視の状態からは決まらない | +| 起動し直しで上書きされる 1 回目の理由は、追記だけの監視の記録が持つ | 状態ファイルの担当ごとの結果は、最後の結果だけを持つ構造である | +| CLI を独立したプロセスグループで起動し、先頭のときだけグループへシグナルを送る | 止めた後に子プロセスが結果を書くことを止める。`setsid` は macOS に標準で入っていない | +| cross-refactoring の取り込みは、結果なしで進行を止めず、共通の関数の値で返す | 終了コードを決めるのは 3 つの取り込みであり、読み取りではない | +| 結果の取り込みの終了コードと、判定の 0 / 2 / 7 / 8 は変えない | 骨組みの分岐が増えると、`SKILL.md` と `docs/` の 2 か所へ同じ値を書くことになる | + +## 仕様 + +### 結末の語彙 + +**理由の語彙は 9 語である。** 監視が書く語と読む側だけが書く語を、起動し直しの可否と +合わせて 1 つの表で持つ。 + +| 理由 | 監視の状態 | 誰が書くか | 起動し直しの可否 | 何が起きたか | +| --- | --- | --- | --- | --- | +| `ok` | `OK` | 監視 | — | 結果ファイルがあって終わった | +| `timeout` | `TIMEOUT` | 監視 | 可 | 監視の上限 | +| `stalled` | `STALLED` | 監視 | 可 | 無進捗の許容を超えた | +| `early_error` | `EARLY_ERROR` | 監視 | 可 | 利用上限以外の致命の文言 | +| `usage_limit` | `EARLY_ERROR` | 監視 | **否** | 利用上限の文言 | +| `cli_timeout` | `NO_RESULT` | 監視 | 可 | 結果なしで終わり、CLI の上限の文言がある | +| `missing` | `NO_RESULT` | 監視・読む側 | 可 | 結果なしで終わり、理由の文言が無い | +| `pidfile_bad` | `PIDFILE_BAD` | 監視 | 可 | pid ファイルが無い、または別のプロセス | +| `unparsable` | — | 読む側だけ | 可 | 結果ファイルがあるが、JSON オブジェクトとして読めない | + +**起動し直せない理由の集合は 1 語(`{"usage_limit"}`)である。** 可否を返す関数は、その +集合に無いことを返すだけである。理由を足すときに見直すのはこの集合だけになる。 + +**理由の一覧と可否の表を持つのは結末の共通層だけである。** Skill の側に利用上限の条件分岐を +書かない。 + +### 結末を読む + +結末を読む関数が、結果ファイルと監視の結果ファイルを突き合わせ、起動 1 回の結末を 1 つの +値として返す。 + +| 項目 | 内容 | +| --- | --- | +| 入力 | 一時ディレクトリ、監視と同じ stem、結果ファイルのパス(省くと `/-result.json`) | +| 出力(使える結果) | `payload` に JSON オブジェクト、`reason` は `None`、可否は真、`monitor` に監視の結果ファイルの辞書(無ければ `None`)、`detail` に監視の `detail` | +| 出力(結果なし) | `payload` は `None`、`reason` は下の表、可否は集合から導く、`detail` は監視の `detail`(無ければ読めなかった理由の 1 文) | +| 失敗の形 | **失敗しない。** 例外・`SystemExit`・標準出力と標準エラーへの出力を出さない。壊れた監視の結果ファイルは、無いものとして扱う | + +結果なしの理由は次のとおり決める。 + +| 監視の結果ファイルの理由 | 結果ファイル | 決まる理由 | +| --- | --- | --- | +| `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` | 問わない | その値 | +| `ok` / `missing` / ファイルが無い・読めない | 無い、または空 | `missing` | +| `ok` / `missing` / ファイルが無い・読めない | あるが JSON オブジェクトでない | `unparsable` | + +**監視が上の 6 語を書いていれば、監視が止めたか、結果を書けない終わり方をしたと分かる。** +その値を結果ファイルの状態より先に採る。 + +**結果ファイルが読めれば、使える結果が勝つ。** 監視が利用上限で止めた後にも結果ファイルが +残っていれば、それは止める前に書き終えていた結果で、使ってよい。 + +### 上限の検知 + +| 理由 | 見るファイル | いつ見るか | 文言(正規表現) | +| --- | --- | --- | --- | +| `usage_limit` | 標準エラーの記録(全担当) | 生きている間の巡回ごと | `Monthly request limit reached` | +| `usage_limit` | 同上 | 同上 | `"api_error_status"\s*:\s*429` | +| `usage_limit` | 同上 | 同上 | `quota exceeded` / `rate limit exceeded`(大文字小文字を問わない)、`^HTTP/\d\S* 429 ` | +| `usage_limit` | claude の標準出力の記録 | 同上 | `"api_error_status"\s*:\s*429` | +| `early_error` | 標準エラーの記録 | 同上 | `^HTTP/\d\S* (?:401\|403) ` と、残りの既存の致命 | +| `cli_timeout` | 標準エラーの記録 | **終了した後、結果ファイルが無いときだけ** | `print timeout after \S+ with turn in progress` | + +**照合の順序は、利用上限 → 致命 → 警告の見た目の致命である。** 同じ記録に利用上限と他の +致命が両方あれば、理由は利用上限になる。上限で落ちた後に別の文言が続く形が普通で、上限の +ほうが原因であるためである。 + +**CLI の上限の文言は、終了した後にだけ見る。** 生きている間に見ると、途中で出た警告を +致命と読む。結果ファイルがあれば理由は `ok` になる。上限に当たっても、結果を書き終えて +いれば使える。 + +### 監視が変えないもの + +| 項目 | 約束 | +| --- | --- | +| 終了コード 0〜6 | 変えない。`usage_limit` は 4、`cli_timeout` は 3 | +| 標準出力の 13 個のキー | 変えない | +| 監視の結果ファイルと記録の理由 | `usage_limit` / `cli_timeout` が増える | +| 早期の致命の検知を止める引数 | 変えない。利用上限の検知も一緒に無効になる | + +**監視の結末は理由の欄を持つ。** 結末を書くときは、理由があればそれを、無ければ状態からの +既定を書く。 + +### 起動と停止 + +| 項目 | 約束 | +| --- | --- | +| 起動 | ジョブ制御(`set -m`)を有効にして背景起動する。CLI の pid が、そのままプロセスグループの番号になる | +| 停止 | 対象がグループの先頭であり、かつ監視自身のグループと違うときだけ、グループへ送る(SIGTERM → 3 秒 → SIGKILL)。それ以外は pid だけへ送る | +| 互換性 | 呼び出し側の引数と pid ファイルの中身は変わらない | + +### 結果なしのラウンドの扱い + +| コマンド | 変わる出力 | 変わらないもの | +| --- | --- | --- | +| 結果の取り込み | 担当の記録に結果なしの理由と監視の詳細を残す | 終了コード(読めない結果は 3、それ以外は 1、使える結果は 0) | +| 判定 | 結果なしがあるとき、標準出力に `NO_RESULT_REASONS='<担当>=<理由> …'`。可否が偽の理由を含めば誤りの終わりと終了コード 1、標準エラーに担当・理由・監視の詳細 | 終了コード 0 / 2 / 7 / 8 の意味と、`RELAUNCH_AGENTS` の形 | +| 報告の表 | ラウンドの表で `<担当>=NO_RESULT(<理由>)` | 他の行 | + +**結果の取り込みは結果ファイルを自前で開かず、結末を読む関数を呼ぶ。** + +**判定は理由の行を、どの出口でも先頭で 1 度だけ出す。** 可否が偽の理由が 1 つでもあれば、 +誰も起動し直さずに誤りの終わりへ進む。既存の終了コード 1 の枝で終えるため、骨組みの分岐は +増えない。 + +### cross-refactoring の読み取りの契約 + +cross-refactoring の取り込みが従う契約を、ここで定める。**この契約の実装は #728 にあり、 +この時点では入っていない。** + +| 項目 | 契約 | +| --- | --- | +| 読み取り | 結末を読む関数を呼ぶ。自前で結果ファイルを開かない | +| 失敗の形 | 進行を止める終了をしない。終了コードを決めるのは 3 つの取り込みである | +| 群の記録 | 失敗した試行の理由へ、結末の理由をそのまま写す | +| 担当の交代 | 可否が偽なら、同じ担当で試行を重ねない | + +## データ・設定 + +### 監視の結果ファイルと監視の記録 + +**増えるのは理由の値だけである。** 他の 14 個のキーは変えない。監視が書く理由は、 +「結末の語彙」の表のうち読む側だけが書く 1 語(`unparsable`)を除いた 8 語である。 + +**起動し直しで上書きされる 1 回目の理由は、監視の記録が持つ。** 記録は追記だけで積むため、 +理由と終了時刻から 1 回目の結末を読める。 + +### 状態ファイルの鍵(cross-review) + +| 鍵 | 何を持つか | +| --- | --- | +| `no_result_reason` | 結末の理由 8 語(`ok` を除く)、または cross-review が上書きする `no_verdict` / `not_posted`。合わせて 10 語 | +| `monitor_detail` | 監視の詳細(標準エラーの記録の抜粋、最大 200 文字)。**鍵が無い = 監視の結果ファイルが無かった。** 空文字は書かない | + +**移行は無い。** 鍵の追加と値の追加だけであり、既存の状態ファイルはそのまま読める。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 理由の語彙と起動し直しの可否が 1 か所にあり、結末を読む関数が失敗しないこと | `plugins/ndf/scripts/tests/test_monitor_outcome_unit.py` | +| 利用上限の文言を検知し、引用・表・grep 形式で誤検知しないこと | `plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` | +| CLI の上限の文言を、終了して結果ファイルが無いときだけ理由にすること | 同 `tests/test_launch_print_timeout.py` | +| CLI が独立したプロセスグループで起動し、グループごと止まること | 同 `tests/test_launch_cli_process_group.py` | +| 結果の取り込みが理由と監視の詳細を残し、終了コードを変えないこと | 同 `tests/test_read_result_reason.py` | +| 判定が理由の行を出し、可否が偽の理由で起動し直さずに止めること | 同 `tests/test_judge_no_result_reason.py` | +| 結果なしの記録と、判定の起動し直しの出口が変わらないこと | 同 `tests/test_state_no_result.py` | +| 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py` | +| 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py` | + +## 関連リンク + +- [issue #729](https://github.com/devbasex/ai-plugins/issues/729) — 結果なしの判断を共通層へ移す +- [issue #619](https://github.com/devbasex/ai-plugins/issues/619) — 利用上限で止まった担当の空振りの起動し直し +- [issue #584](https://github.com/devbasex/ai-plugins/issues/584) — 止めた担当が後から結果ファイルを書く +- [issue #728](https://github.com/devbasex/ai-plugins/issues/728) — cross-refactoring 側の読み取りの実装 +- [PR #791](https://github.com/devbasex/ai-plugins/pull/791) — 実装 +- [`cross-review` の状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) +- [`cross-review` の状態とレビューの手順](../../plugins/ndf/skills/cross-review/docs/01-state-and-review.md) +- [`cross-review` の手順](../../plugins/ndf/skills/cross-review/SKILL.md) From 5fdf9a138f335ef80e6707693cc167dd9cd9d92f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:11:21 +0000 Subject: [PATCH 097/217] =?UTF-8?q?Fix:=20round=201=20=E3=81=AE=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=204=20=E4=BB=B6?= =?UTF-8?q?=E3=82=92=E5=8F=8D=E6=98=A0=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AC9 の照合先を「起点のブランチ」から「送り先のブランチ(Pull Request の head)」へ直す - design-contracts の注記段落を表の外(表の末尾)へ移し、テーブルの分断を解く - 結果ファイルの表の posted_as の変更前を APPROVE / REQUEST_CHANGES / COMMENT へ直す - 束の呼び名 G5 を design と requirements の両方で名乗る Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-662-598-537-619-584-583-design-contracts.md | 4 ++-- issues/issue-730-583-design.md | 4 +++- issues/issue-730-583-requirements.md | 4 +++- 3 files changed, 8 insertions(+), 4 deletions(-) diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index e4de3027..592dfda8 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -369,7 +369,7 @@ conftest.py P1(NDF_METRICS_DIR) | AC60〜AC62 | 3 秒後に子が書く偽の CLI を `launch-cli.sh` で起動し、pgid と、監視の上限 2 秒で止めた後の結果ファイルの有無。先頭でない pid では `os.killpg` が呼ばれない | | AC63 / AC65 / AC66 | 偽の `gh` のレビュー一覧(見出しの一致・ラウンド違い・担当違い)で `prior_review_url` と、`launch-reviewer.sh` のプロンプトの文言 | | AC67 | `SKILL.md` の骨組みで、起動の行から判定の行までの間に `verify-findings` と `critique-round.sh` があり、起動し直しの専用の分岐が無い。各判定の直後に終了コード 8 の `flush` の枝がある | - -**AC63〜AC67 の確かめ方は、[issue-730-583-design.md](issue-730-583-design.md) の「テスト設計」が引き継いだ。** 担当が投稿しなくなるため、投稿済みのレビューを探す鍵(`prior_review_url`)を作らず、AC63〜AC65 の行は対象を失う。上の 2 行は 2026-09-15 時点の記録として残す。 | AC68 / AC69 | 文書の `grep` | | AC70〜AC72 | 検証手段の表のコマンド。AC72 はテストの前後で `find` | + +**AC63〜AC67 の確かめ方は、[issue-730-583-design.md](issue-730-583-design.md) の「テスト設計」が引き継いだ。** 担当が投稿しなくなるため、投稿済みのレビューを探す鍵(`prior_review_url`)を作らず、AC63〜AC65 の行は対象を失う。上の 2 行は 2026-09-15 時点の記録として残す。 diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index 198990a2..1a9205e6 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -163,7 +163,7 @@ PR の締めと作り直しと再開はすでにレビューを回す側が行 | 項目 | 変更前 | 変更後 | | --- | --- | --- | | `event` | 担当が書く | 変わらない | -| `posted_as` | 担当が書く(`REVIEW` / `COMMENT`) | 投稿する側が書く | +| `posted_as` | 担当が書く(`APPROVE` / `REQUEST_CHANGES` / `COMMENT`) | 投稿する側が書く | | `comments_count` | 担当が申告する | 投稿する側が、送れたインラインの件数で埋める | | `review_url` | 担当が投稿の応答から書く | **担当は書かない。** 投稿する側が送信の応答から書く | | `by_severity` | 担当が書く | 変わらない | @@ -337,6 +337,8 @@ graph TD ## 申し送り(並行する設計との境界) +この束は並行する設計の中で **G5** と呼ぶ(#727 が G1、#732 が G2、#729 が G3、#728 が G4)。 + | 相手 | 決めた契約 | | --- | --- | | #727(G1、PR #793) | 席の名前と、結果ファイル・控えの名前が席で組まれること、レビューの本文の先頭行が席を持つことを**この設計が前提にする**。決め方は G1 が持つ。触るファイル(`state.py` / `launch-reviewer.sh` / `SKILL.md`)が重なるため、後からマージする側が競合を解く | diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index 8ed2c580..6930841c 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -74,7 +74,7 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は - [ ] AC6: 取り込みの標準出力に、レビューの本文とインラインの本文が出ない。出るのは件数・URL・状態だけである - [ ] AC7: 修正の取り込みは、返信・スレッドの決着・まとめの投稿を待ち行列へ積んで流す。修正の担当はこの 3 つを行わない - [ ] AC8: 修正の送信は取り込む側が `git push origin HEAD:<ブランチ名>` で行う。修正の担当はコミットまでを行い、送らない -- [ ] AC9: 送信の後、報告されたコミットが起点のブランチに載っていることを確かめる。載っていなければ取り込みは失敗として止まる +- [ ] AC9: 送信の後、報告されたコミットが送り先のブランチ(Pull Request の head)に載っていることを確かめる。載っていなければ取り込みは失敗として止まる ### 途中で止まっても二度書かない @@ -157,6 +157,8 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は ## 前提とする取り決め +この束は並行する設計の中で **G5** と呼ぶ(#727 が G1、#732 が G2、#729 が G3、#728 が G4)。 + | 相手 | 取り決め | | --- | --- | | #727(G1、PR #793) | 席の名前(`claude-2` の形)と、結果ファイル・指摘の控えの名前が席で組まれること。レビューの本文の先頭行が席を持つこと。**この束は席の名前をそのまま使い、決め方を変えない** | From c20f78059aee6de0e5fcb646a5e2bef442423dd8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:13:25 +0000 Subject: [PATCH 098/217] =?UTF-8?q?Docs:=20=E6=A4=9C=E8=A8=BC=E6=89=8B?= =?UTF-8?q?=E6=AE=B5=E3=81=AE=E8=A1=A8=E3=81=AE=E7=85=A7=E5=90=88=E5=85=88?= =?UTF-8?q?=E3=82=92=E9=80=81=E3=82=8A=E5=85=88=E3=81=AE=E3=83=96=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=83=81=E3=81=B8=E6=8F=83=E3=81=88=E3=82=8B=EF=BC=88?= =?UTF-8?q?#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AC9 を送り先のブランチへ直した際に、検証手段の表だけが起点のままだった。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-requirements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index 6930841c..d12265f5 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -151,7 +151,7 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は | プロンプトから投稿の手順が消えたこと | 担当の起動スクリプトが作るプロンプトを取り出す単体テスト | | 二度書かないこと | 偽の `gh` で「先客がいる」応答を返し、投稿が増えないことを見る単体テスト | | 投稿の本文が応答に出ないこと | 取り込みの標準出力に本文の文字列が含まれないことを見る単体テスト | -| 送信と照合 | 送信を偽装し、報告されたコミットが起点に無いときに失敗することを見る単体テスト | +| 送信と照合 | 送信を偽装し、報告されたコミットが送り先のブランチに無いときに失敗することを見る単体テスト | | 骨組みの経路 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | | 文書 | 変更した行を読む検査、または該当ファイルの文言の検査 | From 3c62014565a6c4cde5f2dce541c39360be81526f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:17:00 +0000 Subject: [PATCH 099/217] =?UTF-8?q?Fix:=20=E6=95=B0=E3=81=88=E3=82=8B?= =?UTF-8?q?=E5=8C=BA=E5=88=86=E3=81=AE=E9=9B=86=E5=90=88=E3=81=AE=E7=BD=AE?= =?UTF-8?q?=E3=81=8D=E5=A0=B4=E6=89=80=E3=82=92=E5=AE=9F=E4=BD=93=E3=81=AE?= =?UTF-8?q?=E3=83=91=E3=82=B9=E3=81=B8=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit リポジトリ根の scripts/ ではなく、cross-review の scripts/ にある。 Co-Authored-By: Claude Fable 5.1 --- docs/specifications/cross-review-evidence-based.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specifications/cross-review-evidence-based.md b/docs/specifications/cross-review-evidence-based.md index a9195658..80adeb34 100644 --- a/docs/specifications/cross-review-evidence-based.md +++ b/docs/specifications/cross-review-evidence-based.md @@ -308,7 +308,7 @@ Pull Request では、検証手順を実行できなかった 3 件が立証不 | --- | --- | | `single` | 1 者だけの結果。担当ごとに 1 通り出す | | `majority` | `origin_runtimes` が 2 者以上の指摘 | -| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘。**数える区分の集合を持つのは共有モジュール 1 か所(`scripts/classifications.py`)で、収束の判定と効果の測定の両方がそれを読む** | +| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘。**数える区分の集合を持つのは共有モジュール 1 か所(`plugins/ndf/skills/cross-review/scripts/classifications.py`)で、収束の判定と効果の測定の両方がそれを読む** | | `oracle` | いずれかの担当が出した指摘のうち、**修正された**もの | **`origin_runtimes` を持たない指摘は、取り込み時の担当 1 者として読む。** この値は統合の From 6506be84b31280daeee3aee47624b3da9602a75f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:17:38 +0000 Subject: [PATCH 100/217] =?UTF-8?q?Test:=20detect=5Fhost=20=E3=81=AE?= =?UTF-8?q?=E5=88=86=E5=B2=90=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 明示指定、環境変数の優先順、推定不能時の例外を共通層の単体テストで固定する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../ndf/scripts/tests/test_lib_assignment.py | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index 6682cbfe..b55f2b3d 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -63,6 +63,49 @@ def assignment(): return mod +@pytest.mark.parametrize("host", ("claude", "codex", "agy", "kiro")) +def test_detect_host_accepts_an_explicit_host(assignment, host): + assert assignment.detect_host(host, {}) == (host, "explicit") + + +def test_detect_host_rejects_an_unknown_explicit_host(assignment): + with pytest.raises( + assignment.AssignmentError, + match=r"^--host には claude/codex/agy/kiro .+ gemini$", + ): + assignment.detect_host("gemini", {}) + + +@pytest.mark.parametrize( + ("env", "expected_host"), + [ + ({"CLAUDE_PLUGIN_ROOT": "/plugins/claude"}, "claude"), + ({"CODEX_HOME": "/home/codex"}, "codex"), + ({"KIRO_AGENT": "ndf"}, "kiro"), + ], +) +def test_detect_host_uses_environment_hints(assignment, env, expected_host): + assert assignment.detect_host(None, env) == (expected_host, "env") + + +def test_detect_host_uses_the_first_environment_hint(assignment): + env = { + "KIRO_AGENT": "ndf", + "CODEX_HOME": "/home/codex", + "CLAUDE_PLUGIN_ROOT": "/plugins/claude", + } + + assert assignment.detect_host(None, env) == ("claude", "env") + + +def test_detect_host_rejects_an_environment_without_hints(assignment): + with pytest.raises( + assignment.AssignmentError, + match=r"^ホストを推定できませんでした。.*--host claude\|codex\|agy\|kiro.*$", + ): + assignment.detect_host(None, {}) + + @pytest.mark.parametrize("host", EXPECTED) def test_assign_keeps_the_eight_round_rotation(assignment, host): actual = [assignment.assign(round_no, host) for round_no in range(1, 9)] From 688966a2d0cc0b1cfb58bb6a3648e718f84b6f4d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:18:40 +0000 Subject: [PATCH 101/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 85 ++++++++++++++++++++++++++++++++ 1 file changed, 85 insertions(+) create mode 100644 issues/refactoring-plan-rf793.md diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md new file mode 100644 index 00000000..5933a6fd --- /dev/null +++ b/issues/refactoring-plan-rf793.md @@ -0,0 +1,85 @@ +# 改修計画 — devbasex/ai-plugins #793 + +`/ndf:cross-refactoring` が提案し、適用した改善項目の記録である。 +理由と手順は提案の時点でしか残らないため、公開の直前に書き出している。 + +- 対象範囲: plugins/ndf/scripts/lib, plugins/ndf/skills/cross-review/scripts, plugins/ndf/scripts/tests, plugins/ndf/skills/cross-review/tests +- 着手前のテスト: uv run --with pytest pytest scripts/tests plugins/ndf -q + +## ラウンド 1(実装 codex / レビュー agy / kiro) + +### R1-001 — `plugins/ndf/scripts/lib/assignment.py#detect_host` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | unit | — | agy | 検証中 | 1 | + +**なぜ**: detect_host は収束ループ共通層においてホストを確定する重要関数であり、誤判定すると母集合が狂う致命的な影響を持つ。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが全く存在しない。明示指定(explicit)、環境変数ヒント(HOST_ENV_HINTS)の順序による推定、および手掛かりがない場合の例外送出の各分岐を共通層単体テストとして固定する必要がある。 + +**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_detect_host_* を追加する。 +2. 明示指定分岐: HOST_RUNTIMES に含まれる名前を指定したときに (host, 'explicit') が返り、無効な名前を指定したときに AssignmentError が送出されることを検証する。 +3. 環境変数推定分岐: CLAUDE_PLUGIN_ROOT, CODEX_HOME, KIRO_AGENT 等の環境変数ヒントを含む辞書を渡し、正しいホスト名と 'env' が返ることを検証する。 +4. 推定不能分岐: 環境変数が空辞書(またはヒントなし)の場合に、既定値を勝手に置かず AssignmentError が送出されることを検証する。 + +### R1-002 — `plugins/ndf/scripts/lib/assignment.py#review_seats` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | unit | — | agy | 未着手 | 0 | + +**なぜ**: assignment.py で新設された review_seats は、cross-review において各ラウンドのレビュワー2席を割り当てるコア関数である。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが一切存在しない(別スキル cross-refactoring のテスト側に暫定配置されているのみ)。len(available) の人数(3者以上の輪番、2者の固定、1者時の fallback または副席 -2 補填、0者時の fallback 2席割当および fallback 空時の例外送出)の全分岐の振る舞いを共通層の単体テストとして固定する必要がある。 + +**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_review_seats_* を追加する。 +2. 3者以上: available=['codex', 'agy', 'kiro'] でラウンド1〜3を実行し、available の順序を保った2席が輪番で選ばれることを検証する。 +3. 2者: available=['codex', 'kiro'] で複数ラウンドを実行し、ラウンド番号によらず常にその2者が返ることを検証する。 +4. 1者: available=['codex'], fallback=['claude'] で ['codex', 'claude'] が返り、fallback が空または available と重複する場合は ['codex', 'codex-2'] が返ることを検証する。 +5. 0者: available=[], fallback=['claude'] で ['claude', 'claude-2'] が返り、fallback も空の場合は AssignmentError となることを検証する。 +6. round_no < 1 の場合に AssignmentError が送出されることを検証する。 + +### R1-003 — `plugins/ndf/scripts/lib/assignment.py#review_seats` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | unit | — | codex | 未着手 | 0 | + +**なぜ**: 0 人でも fallback がある経路は状態初期化から固定されているが、available と fallback がともに空の公開入口が AssignmentError になる経路は未固定である。 + +**手順**: 1. round_no=1、available=[]、fallback=[] で公開入口を呼ぶ +2. AssignmentError が送出されることを観測する +3. 例外の利用者向け理由から、使える者と埋め合わせ候補がともに無いことを示す要点だけを確認する + +### R1-004 — `plugins/ndf/scripts/lib/assignment.py#seat_runtime` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| boundary | unit | — | agy | 未着手 | 0 | + +**なぜ**: seat_runtime は正規表現 SEAT_PATTERN(^(claude|codex|agy|kiro)(-[2-9])?$)に従って席名を検証・抽出するが、共通層テストに境界値・異常値のテストが存在しない。接尾辞の数値境界(-1 は不可、-2〜-9 は可、-10 は不可)、区切り文字違い(_2)、未知のランタイム、空文字列等で AssignmentError が送出される境界値の振る舞いを単体レベルで固定する必要がある。 + +**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_seat_runtime_rejects_malformed_seat を追加する。 +2. 接尾辞の数値境界: 'kiro-1'(下限未満)、'kiro-10'(上限超過)で AssignmentError が発生することを検証する。 +3. 区切り形式・重複の境界: 'claude_2'(アンダースコア)、'kiro-2-3'(ハイフン重複)、空文字列 '' で AssignmentError が発生することを検証する。 +4. 未知のランタイム: 'gemini', 'gpt' 等の ALL_RUNTIMES 外の名称で AssignmentError が発生することを検証する。 + +### R1-005 — `plugins/ndf/scripts/lib/assignment.py#seat_runtime` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| normal | unit | — | agy | 未着手 | 0 | + +**なぜ**: assignment.py で新設された seat_runtime(seat: str) は、席名から基底ランタイム名を取り出す共通層関数であり、結果受け口・起動スクリプト・監視処理で広く使われる。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが存在しない。接尾辞なしのランタイム名(claude, codex, agy, kiro)および同一ランタイムの副席名(-2〜-9 接尾辞)から正確にランタイム名が抽出される正常系の振る舞いを共通層単体テストとして固定する必要がある。 + +**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_seat_runtime_extracts_runtime_name を追加する。 +2. ALL_RUNTIMES の全ランタイム名('claude', 'codex', 'agy', 'kiro')をそのまま渡した場合に、同一のランタイム名が返ることを検証する。 +3. ハイフン付き席名('kiro-2', 'claude-9', 'agy-3' 等)を渡した場合に、接尾辞を除去した基底ランタイム名が正しく返ることを検証する。 + +## 見送った項目 + +| ラウンド | 対象 | 兆候・経路 | 理由 | +| --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/lib/models.py#mismatch_warning` | branch | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/scripts/lib/models.py#observed_model` | branch | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/scripts/lib/models.py#separation_reason` | branch | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/scripts/lib/monitor.py#monitor_agent` | branch | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/scripts/lib/statefile.py#save` | error | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/skills/cross-review/scripts/critique.sh#select_targets` | branch | 1 ラウンドの採用上限 5 件を超えた | From 3bd6e020cc0eb54933f31052110d9a360769af08 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:22:09 +0000 Subject: [PATCH 102/217] =?UTF-8?q?Docs:=20round=202=20=E3=81=AE=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E3=82=92=E5=8F=8D?= =?UTF-8?q?=E6=98=A0=E3=81=99=E3=82=8B=EF=BC=88=E6=B1=BA=E5=AE=9A=204?= =?UTF-8?q?=E3=83=BB5=E3=80=81posted=5Fas=E3=80=81=E6=B1=BA=E5=AE=9A=2014?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 決定 4: 止まったときの立て直しは取り込みの再実行で行うことを明記し、待ち行列に 項目が残ることに頼らないと書く。採らない案(流し方の変更)も記録する - 決定 5: 照合の鍵を「ラウンドと席までの前方一致」とし、判定の語とレビューの状態を 含めないことを明記する。review-post の行も揃える - posted_as: 投稿する側が送信の時点で自分の Pull Request かどうかを見て決める - 決定 14 を追加し、AC32(自 PR での posted_as の格下げ)と対応するテスト設計を足す Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 21 ++++++++++++++++----- issues/issue-730-583-requirements.md | 6 ++++-- 2 files changed, 20 insertions(+), 7 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index 1a9205e6..55c91e8a 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -33,7 +33,7 @@ | 2 | 指摘の取り込みが、控えからレビューを組み立てて送る | AC5・AC6・AC16・AC17 | | 3 | 修正の取り込みが、返信・決着・まとめを送り、修正を送信して照合する | AC7〜AC9 | | 4 | 同じものを二度書かない照合を、4 種別すべてに掛ける | AC10〜AC14 | -| 5 | 申告と実数の突き合わせをやめ、送信の応答を記録にする | AC15 | +| 5 | 申告と実数の突き合わせをやめ、送信の応答を記録にする | AC15・AC32 | | 6 | 起動し直しを初回と同じ経路へ通す | AC18〜AC20 | | 7 | `fix` の書き込みを 1 つの実装にまとめる | AC21・AC22 | | 8 | 文書の決定を書き直す | AC25〜AC29 | @@ -95,13 +95,19 @@ **別の部分命令に分けない。** 分けると「投稿したが記録していない」に加えて「記録したが投稿していない」がもう 1 つ増える。1 つに閉じれば、途中で止まったときに残るのは待ち行列の項目だけで、次に流したときに決着する。 +**止まったときの立て直しは、取り込みをもう一度呼ぶことで行う。** 結果ファイルが残るため投稿を組み立て直せ、照合が先客を見つけるので増えない。**待ち行列に項目が残ることに頼らない。** 送信に成功した項目は、記録より先に取り除かれる。 + +採らない案と理由: + +- **送信の応答を控えてから項目を消す形へ、待ち行列の流し方を変える。** すでに動いている投稿の経路(巻き直しのコメント)の契約まで変わる。取り込みをもう一度呼べば同じ状態になるため、変えずに済む + ### 決定 5: 途中で止まった実行を流し直しても増やさないため、二度書かない照合を 4 種別すべてに掛ける -投稿の待ち行列は、送る前に同じものが先にあるかを照合する仕組みを持つ。レビューの投稿・返信・決着・まとめの 4 種別すべてでこれを通す。**照合の鍵は本文の先頭行にあるラウンドと席とする。** +投稿の待ち行列は、送る前に同じものが先にあるかを照合する仕組みを持つ。レビューの投稿・返信・決着・まとめの 4 種別すべてでこれを通す。**照合の鍵は、本文の先頭行のうちラウンドと席までの前方一致とする。** 判定の語(`APPROVE` / `REQUEST_CHANGES` / `COMMENT`)と、レビューの状態を鍵に含めない。含めると、起動し直して判定が変わったときに別の投稿と読まれ、二重に投稿する。 | 種別 | 照合の鍵 | | --- | --- | -| `review-post` | 投稿者と、本文の先頭 80 文字(`## 🤖 cross-review \| round \| <席> \|` を含む) | +| `review-post` | 投稿者と、本文の先頭行の `## 🤖 cross-review \| round \| <席> \|` までの前方一致(判定の語を含めない) | | `review-reply` | 返信先の指摘の識別子と、本文の先頭 80 文字 | | `thread-resolve` | スレッドの識別子と、すでに決着しているかどうか | | `pr-comment` | 投稿者と、本文の先頭 80 文字(ラウンドを含む) | @@ -156,6 +162,10 @@ python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <結果ファ PR の締めと作り直しと再開はすでにレビューを回す側が行っている。待ち行列に積まず、上限のときは待って同じ操作をやり直す形である。**この性質を変えない。** 巻き直しは順序が意味を持ち、待ち行列へ積むと後続の投稿と並び替わる。 +### 決定 14: 自分の Pull Request への投稿が拒まれないよう、判定の格下げを投稿する側が送信の時点で行う + +自分の Pull Request には変更を求めるレビューを送れない(HTTP 422)。担当が投稿していたときはその場で格下げしていたため、投稿する側が引き取る。**格下げるのは送った形だけで、本来の判定は落とさない。** 収束の判定は本来の判定を読むため、格下げがループの続き方を変えない。 + ## データ構造 ### 結果ファイル(レビュー) @@ -163,7 +173,7 @@ PR の締めと作り直しと再開はすでにレビューを回す側が行 | 項目 | 変更前 | 変更後 | | --- | --- | --- | | `event` | 担当が書く | 変わらない | -| `posted_as` | 担当が書く(`APPROVE` / `REQUEST_CHANGES` / `COMMENT`) | 投稿する側が書く | +| `posted_as` | 担当が書く(`APPROVE` / `REQUEST_CHANGES` / `COMMENT`) | 投稿する側が、送信の時点で自分の Pull Request かどうかを見て決める | | `comments_count` | 担当が申告する | 投稿する側が、送れたインラインの件数で埋める | | `review_url` | 担当が投稿の応答から書く | **担当は書かない。** 投稿する側が送信の応答から書く | | `by_severity` | 担当が書く | 変わらない | @@ -316,7 +326,7 @@ graph TD | AC7 | 修正の取り込みを呼び、返信・決着・まとめの 3 種別が待ち行列へ積まれることを見る | | AC8・AC9 | 送信を偽装し、報告されたコミットが送り先に無いときに失敗することを見る | | AC10・AC11 | 偽の `gh` が先客を返す状態で投稿を積んで流し、新しい投稿が 0 件で項目が取り除かれることを見る | -| AC12 | 積んだまま流していない状態から流し直し、記録に先客の URL が入ることを見る | +| AC12 | 投稿の後・記録の前で止めた状態から取り込みをもう一度呼び、レビューが増えず記録に先客の URL が入ることを見る | | AC13・AC14 | 返信と決着とまとめについて、同じ項目を 2 度積んでも増えないことを見る | | AC15 | 記録に入る URL と件数が、偽の `gh` が返した応答の値と一致することを見る | | AC16 | 偽の `gh` が HTTP 422 を返す状態で、本文へ退避して送り直すことを見る | @@ -325,6 +335,7 @@ graph TD | AC21・AC22 | 共通層の部分命令を直接呼び、cross-review から呼んだときと同じ項目が積まれることを見る | | AC23・AC24 | 取り込み・判定・報告の終了コードと標準出力を見る既存のテストを変えずに通す | | AC25〜AC29 | 変更した文書の行を読む検査 | +| AC32 | 自分の Pull Request の状態で投稿を組み立て、送った形が `COMMENT` で本来の判定が変わらないことを見る | | AC30・AC31 | 検証手段の表のコマンド | **偽の `gh` を使う形は既存のテストにある**(待ち行列の照合と巻き直しの投稿)。同じ仕掛けを使う。 diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index d12265f5..7e8da298 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -79,8 +79,9 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は ### 途中で止まっても二度書かない - [ ] AC10: レビューの本文の先頭行は `## 🤖 cross-review | round | <席> | ` である。先頭 80 文字にラウンドと席が入る -- [ ] AC11: 同じラウンド・同じ席のレビューが PR にすでにあるとき、同じ投稿を積んで流しても新しいレビューは増えない。待ち行列の項目は送信済みとして取り除かれる -- [ ] AC12: 投稿の後・記録の前に取り込みが止まった状態から流し直すと、レビューは増えず、記録には既存のレビューの URL が入る + 照合に使うのは席までの前方一致で、判定の語を含めない +- [ ] AC11: 同じラウンド・同じ席のレビューが PR にすでにあるとき、判定が変わっていても新しいレビューは増えない。待ち行列の項目は送信済みとして取り除かれる +- [ ] AC12: 投稿の後・記録の前に取り込みが止まった状態から取り込みをもう一度呼ぶと、レビューは増えず、記録には既存のレビューの URL が入る - [ ] AC13: 同じ指摘への返信が 2 度積まれても、返信は 1 件しか増えない。すでに決着したスレッドをもう一度決着させても、結果は変わらず失敗にもならない - [ ] AC14: 同じラウンドのまとめの投稿を 2 度積んでも、コメントは 1 件しか増えない @@ -91,6 +92,7 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は 対象はインラインが HTTP 422 で拒まれたものである。 退避した指摘の `posted_to` は `body` になり、退避した件数が取り込みの出力に出る - [ ] AC17: 指摘の控えに指摘が 1 件以上あり、インラインとして送れたものが 0 件でも、その担当は結果なしにならない +- [ ] AC32: 自分の Pull Request では、投稿する側が送信の時点で `posted_as` を `COMMENT` へ落とす。本来の判定(`intent`)は落とさない ### 起動し直しの経路(#583) From 8c60a7a9e06a2e02cbf3b0ee6bdee81a9a78720e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:28:59 +0000 Subject: [PATCH 103/217] =?UTF-8?q?Test:=20review=5Fseats=20=E3=81=AE?= =?UTF-8?q?=E5=88=86=E5=B2=90=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 3者以上の輪番、2者の固定、1者時のfallback/第二席補填、0者時のfallback/例外送出、不正なラウンド番号の拒絶を共通層の単体テストで固定する。 Item-Id: R1-002 Round: 1 Impl-Runtime: agy Impl-Model: default --- .../ndf/scripts/tests/test_lib_assignment.py | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index b55f2b3d..d3a32504 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -134,3 +134,52 @@ def test_impl_assign_rejects_a_bad_round(assignment): def test_impl_assign_rejects_an_empty_list(assignment): with pytest.raises(assignment.AssignmentError): assignment.impl_assign(1, []) + + +# ---------- レビュー席の割り当て(#727。cross-review が使う) ---------- + +def test_review_seats_with_three_or_more_available_rotates_in_available_order(assignment): + """3者以上: available の順序を保った2席が輪番で選ばれる。""" + available = ["codex", "agy", "kiro"] + assert assignment.review_seats(1, available, []) == ["agy", "kiro"] + assert assignment.review_seats(2, available, []) == ["codex", "kiro"] + assert assignment.review_seats(3, available, []) == ["codex", "agy"] + + +def test_review_seats_with_two_available_returns_both_every_round(assignment): + """2者: ラウンド番号によらず常にその2者が返る。""" + available = ["codex", "kiro"] + for round_no in range(1, 5): + assert assignment.review_seats(round_no, available, []) == ["codex", "kiro"] + + +def test_review_seats_with_one_available_fills_from_fallback_or_second_seat(assignment): + """1者: fallback から補填、または fallback が空・重複時は -2 補填。""" + assert assignment.review_seats(1, ["codex"], ["claude"]) == ["codex", "claude"] + assert assignment.review_seats(1, ["codex"], []) == ["codex", "codex-2"] + assert assignment.review_seats(1, ["codex"], ["codex"]) == ["codex", "codex-2"] + + +def test_review_seats_with_zero_available_fills_from_fallback_or_raises(assignment): + """0者: fallback から2席割当、fallback も空の場合は AssignmentError。""" + assert assignment.review_seats(1, [], ["claude"]) == ["claude", "claude-2"] + with pytest.raises( + assignment.AssignmentError, + match=r"^使える者も席の埋め合わせに使える者もいません$", + ): + assignment.review_seats(1, [], []) + + +def test_review_seats_rejects_a_bad_round(assignment): + """round_no < 1 の場合に AssignmentError が送出される。""" + with pytest.raises( + assignment.AssignmentError, + match=r"^ラウンド番号は 1 以上です: 0$", + ): + assignment.review_seats(0, ["codex", "kiro"], []) + with pytest.raises( + assignment.AssignmentError, + match=r"^ラウンド番号は 1 以上です: -1$", + ): + assignment.review_seats(-1, ["codex", "kiro"], []) + From f27808cc66c4354ceca996cdd7304e51ced79cc0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:30:03 +0000 Subject: [PATCH 104/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 5933a6fd..fe049c0e 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -12,7 +12,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | agy | 検証中 | 1 | +| branch | unit | — | agy | 採用 | 1 | **なぜ**: detect_host は収束ループ共通層においてホストを確定する重要関数であり、誤判定すると母集合が狂う致命的な影響を持つ。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが全く存在しない。明示指定(explicit)、環境変数ヒント(HOST_ENV_HINTS)の順序による推定、および手掛かりがない場合の例外送出の各分岐を共通層単体テストとして固定する必要がある。 @@ -25,7 +25,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | agy | 未着手 | 0 | +| branch | unit | — | agy | 検証中 | 1 | **なぜ**: assignment.py で新設された review_seats は、cross-review において各ラウンドのレビュワー2席を割り当てるコア関数である。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが一切存在しない(別スキル cross-refactoring のテスト側に暫定配置されているのみ)。len(available) の人数(3者以上の輪番、2者の固定、1者時の fallback または副席 -2 補填、0者時の fallback 2席割当および fallback 空時の例外送出)の全分岐の振る舞いを共通層の単体テストとして固定する必要がある。 From 61d9fdc4400b2ebee01e4aead9ad88a58c10d499 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:34:51 +0000 Subject: [PATCH 105/217] =?UTF-8?q?Docs:=20=E6=AD=A2=E3=81=BE=E3=82=8A?= =?UTF-8?q?=E6=96=B9=E3=81=AE=202=20=E5=88=86=E5=B2=90=E3=82=92=E8=A1=A8?= =?UTF-8?q?=E3=81=A8=E5=9B=B3=E3=81=A7=E6=9B=B8=E3=81=8D=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=80=81AC20=20=E3=82=92=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=AE=E7=B5=8C=E8=B7=AF=E3=81=B8=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 決定 4 の立て直しの説明を、送る前と送った後の 2 つの止まり方の表にする - 「途中で止まった実行の立て直し」の図を 2 分岐の形へ描き直す - AC20 を「取り込みが送信の後・記録の前に止まる」経路の再現へ書き換える - テスト設計の表で AC20 を AC4 から分離し、独立の行にする - 関連する文書の表の受け入れ条件の範囲を AC1〜AC32 へ直す Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 29 ++++++++++++++++++---------- issues/issue-730-583-requirements.md | 3 ++- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index 55c91e8a..f9eb6426 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -93,9 +93,14 @@ 取り込みの部分命令の名前と、骨組みの行の並びは変えない。中の順序を「控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」にする。 -**別の部分命令に分けない。** 分けると「投稿したが記録していない」に加えて「記録したが投稿していない」がもう 1 つ増える。1 つに閉じれば、途中で止まったときに残るのは待ち行列の項目だけで、次に流したときに決着する。 +**別の部分命令に分けない。** 分けると「投稿したが記録していない」に加えて「記録したが投稿していない」がもう 1 つ増える。1 つに閉じれば、途中で止まった状態は「送れていない」か「送れたが記録が無い」の 2 つになる。 -**止まったときの立て直しは、取り込みをもう一度呼ぶことで行う。** 結果ファイルが残るため投稿を組み立て直せ、照合が先客を見つけるので増えない。**待ち行列に項目が残ることに頼らない。** 送信に成功した項目は、記録より先に取り除かれる。 +**止まり方は 2 つで、立て直し方が違う。** 送信に成功した項目は記録より先に待ち行列から取り除かれるため、**待ち行列に項目が残ることに頼らない。** + +| 止まった場所 | 待ち行列の項目 | 立て直し | +| --- | --- | --- | +| 送る前(上限などで送れていない) | 残る | 判定の終了コード 8 の枝が流し直す | +| 送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。結果ファイルが残るため投稿を組み立て直せ、照合が先客を見つけるので増えない | 採らない案と理由: @@ -285,16 +290,19 @@ sequenceDiagram 担当が控えを書く前に止まれば、レビューを回す側は積むものを持たないため、GitHub には何も増えない。起動し直しても重ならない。 -### 途中で止まった実行の流し直し +### 途中で止まった実行の立て直し ```mermaid graph TD - A[取り込みが投稿を送る] --> B{記録の前に止まったか} - B -->|止まっていない| C[記録へ書き戻して続ける] - B -->|止まった| D[待ち行列に項目が残る] - D --> E[判定が残りを見て流し直す] + A[取り込みが投稿を送る] --> B{送れたか} + B -->|送れていない| D[待ち行列に項目が残る] + D --> E[判定の 8 の枝が流し直す] + B -->|送れた| C{記録まで済んだか} + C -->|済んだ| J[次の段へ進む] + C -->|止まった| K[取り込みをもう一度呼ぶ] E --> F{先客がいるか} - F -->|いる| G[送らずに取り除き
先客の URL を記録する] + K --> F + F -->|いる| G[送らずに
先客の URL を記録する] F -->|いない| H[送って記録する] ``` @@ -321,12 +329,13 @@ graph TD | --- | --- | | AC1・AC2 | 担当のプロンプトを取り出し、投稿の手順の語が 0 件であることを見る | | AC3 | 一時の名前のファイルだけがある状態で取り込みを呼び、結果なしとして扱われることを見る | -| AC4・AC20 | 控えを書かずに終わる偽の担当で 1 ラウンドを回し、偽の `gh` が受けたレビューの投稿が 0 件であることを見る | +| AC4 | 控えを書かずに終わる偽の担当で 1 ラウンドを回し、偽の `gh` が受けたレビューの投稿が 0 件であることを見る | | AC5・AC6 | 取り込みの標準出力に、控えの本文の文字列が含まれないことを見る | | AC7 | 修正の取り込みを呼び、返信・決着・まとめの 3 種別が待ち行列へ積まれることを見る | | AC8・AC9 | 送信を偽装し、報告されたコミットが送り先に無いときに失敗することを見る | | AC10・AC11 | 偽の `gh` が先客を返す状態で投稿を積んで流し、新しい投稿が 0 件で項目が取り除かれることを見る | | AC12 | 投稿の後・記録の前で止めた状態から取り込みをもう一度呼び、レビューが増えず記録に先客の URL が入ることを見る | +| AC20 | 送信の後・記録の前で取り込みを止め、同じ取り込みをもう一度呼ぶ結合の経路で、レビューが 1 件しか増えないことを見る | | AC13・AC14 | 返信と決着とまとめについて、同じ項目を 2 度積んでも増えないことを見る | | AC15 | 記録に入る URL と件数が、偽の `gh` が返した応答の値と一致することを見る | | AC16 | 偽の `gh` が HTTP 422 を返す状態で、本文へ退避して送り直すことを見る | @@ -373,7 +382,7 @@ graph TD | 文書 | 何を持つか | | --- | --- | -| [issue-730-583-requirements.md](issue-730-583-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC31) | +| [issue-730-583-requirements.md](issue-730-583-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC32) | | [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md) | 置き換える前の設計(決定 18・19) | | [issue-729-619-584-design.md](issue-729-619-584-design.md) | 結末の語彙と、止めた担当に書かせないこと | | [issue-732-624-706-design.md](issue-732-624-706-design.md) | 数えない指摘の区分と収束の判定 | diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index 7e8da298..fe461f89 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -98,7 +98,8 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は - [ ] AC18: 骨組みで、起動し直した担当は初回と同じ経路を通る。取り込みの後に根拠の検証と反証を通ってから 2 度目の判定へ進む - [ ] AC19: 待ち行列に残りがあるときの枝(判定の終了コード 8)は、2 度目を含む各判定の直後に残る -- [ ] AC20: 担当が投稿の後に止まった実行を再現しても、PR のレビューは 1 件しか増えず、同じ論点のスレッドが 2 つに分かれない +- [ ] AC20: 取り込みが送信の後・記録の前に止まった実行を再現し、取り込みをもう一度呼ぶ。 + PR のレビューは 1 件しか増えず、同じ論点のスレッドが 2 つに分かれない ### `fix` を単独で使う経路 From 0f12eb8399dacbafb9bcdc55bf36e3dcd8d69a42 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:36:54 +0000 Subject: [PATCH 106/217] =?UTF-8?q?Test:=20review=5Fseats=20=E3=81=AE?= =?UTF-8?q?=E4=BD=BF=E3=81=88=E3=82=8B=E8=80=85=E3=82=82=E5=9F=8B=E3=82=81?= =?UTF-8?q?=E5=90=88=E3=82=8F=E3=81=9B=E3=82=82=E7=A9=BA=E3=81=AE=E7=B5=8C?= =?UTF-8?q?=E8=B7=AF=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/assignment.py#review=5Fseat?= =?UTF-8?q?s?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit available=[] かつ fallback=[] で公開入口 review_seats を round_no=1 で呼ぶと AssignmentError が送出されることを固定する。例外の利用者向け理由から、使える者と 席の埋め合わせ候補がともに無いことを示す要点だけを確認する。対象コードは変更しない。 Item-Id: R1-003 Round: 1 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_lib_assignment.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index d3a32504..cd8e34f8 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -170,6 +170,19 @@ def test_review_seats_with_zero_available_fills_from_fallback_or_raises(assignme assignment.review_seats(1, [], []) +def test_review_seats_raises_when_both_available_and_fallback_are_empty(assignment): + """0者かつ fallback も空: 公開入口が AssignmentError を送出する(R1-003)。 + + 使える者と席の埋め合わせ候補がともに無いことを、利用者向けの理由が示す。 + """ + with pytest.raises(assignment.AssignmentError) as excinfo: + assignment.review_seats(1, [], []) + + message = str(excinfo.value) + assert "使える者" in message + assert "席の埋め合わせに使える者" in message + + def test_review_seats_rejects_a_bad_round(assignment): """round_no < 1 の場合に AssignmentError が送出される。""" with pytest.raises( From e07c95bdab8528f3f83f723cf6492dea1284ff21 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:37:22 +0000 Subject: [PATCH 107/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index fe049c0e..a663c489 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -25,7 +25,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | agy | 検証中 | 1 | +| branch | unit | — | agy | 採用 | 1 | **なぜ**: assignment.py で新設された review_seats は、cross-review において各ラウンドのレビュワー2席を割り当てるコア関数である。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが一切存在しない(別スキル cross-refactoring のテスト側に暫定配置されているのみ)。len(available) の人数(3者以上の輪番、2者の固定、1者時の fallback または副席 -2 補填、0者時の fallback 2席割当および fallback 空時の例外送出)の全分岐の振る舞いを共通層の単体テストとして固定する必要がある。 @@ -40,7 +40,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex | 未着手 | 0 | +| error | unit | — | codex | 検証中 | 1 | **なぜ**: 0 人でも fallback がある経路は状態初期化から固定されているが、available と fallback がともに空の公開入口が AssignmentError になる経路は未固定である。 From 36dd097d4dff427b0de545bcd0cdc0de0e7b74fb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:44:32 +0000 Subject: [PATCH 108/217] =?UTF-8?q?Test:=20seat=5Fruntime=20=E3=81=AE?= =?UTF-8?q?=E5=A2=83=E7=95=8C=E5=80=A4=E3=81=A8=E7=95=B0=E5=B8=B8=E5=80=A4?= =?UTF-8?q?=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/assignment.py#seat=5Fruntime?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SEAT_PATTERN の境界(接尾辞は -2〜-9 のみ、区切りはハイフン 1 つ)と、 ALL_RUNTIMES 外の名前が AssignmentError になることを単体で固定する。 対象のコードは変更していない。 Item-Id: R1-004 Round: 1 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- .../ndf/scripts/tests/test_lib_assignment.py | 29 +++++++++++++++++++ 1 file changed, 29 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index cd8e34f8..723e0f1f 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -196,3 +196,32 @@ def test_review_seats_rejects_a_bad_round(assignment): ): assignment.review_seats(-1, ["codex", "kiro"], []) + +# ---------- 席の名前の検証(#727。結果の受け口・起動・監視が使う) ---------- + +@pytest.mark.parametrize( + "seat", + [ + "kiro-1", # 接尾辞の下限未満 + "kiro-10", # 接尾辞の上限超過 + "claude_2", # 区切りがアンダースコア + "kiro-2-3", # 接尾辞の重複 + "", # 空文字列 + "gemini", # ALL_RUNTIMES 外 + "gpt", # ALL_RUNTIMES 外 + ], +) +def test_seat_runtime_rejects_malformed_seat(assignment, seat): + """形に合わない席の名前は AssignmentError になる(R1-004)。 + + 現状固定。`SEAT_PATTERN` の境界(接尾辞は -2〜-9 のみ、区切りはハイフン 1 つ)と、 + 未知のランタイム名が弾かれることを、例外の種類と理由の文言ごと固定する。 + """ + with pytest.raises(assignment.AssignmentError) as excinfo: + assignment.seat_runtime(seat) + + assert str(excinfo.value) == ( + f"席の名前の形が違います: {seat}" + "(claude/codex/agy/kiro か、その名前に -2〜-9 を付けた形)" + ) + From 781588249546e98759e21d931f62b5c2fb509a7b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:45:12 +0000 Subject: [PATCH 109/217] =?UTF-8?q?Revert=20"Test:=20seat=5Fruntime=20?= =?UTF-8?q?=E3=81=AE=E5=A2=83=E7=95=8C=E5=80=A4=E3=81=A8=E7=95=B0=E5=B8=B8?= =?UTF-8?q?=E5=80=A4=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/assignment.py#seat=5Fruntim?= =?UTF-8?q?e"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 36dd097d4dff427b0de545bcd0cdc0de0e7b74fb. --- .../ndf/scripts/tests/test_lib_assignment.py | 29 ------------------- 1 file changed, 29 deletions(-) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index 723e0f1f..cd8e34f8 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -196,32 +196,3 @@ def test_review_seats_rejects_a_bad_round(assignment): ): assignment.review_seats(-1, ["codex", "kiro"], []) - -# ---------- 席の名前の検証(#727。結果の受け口・起動・監視が使う) ---------- - -@pytest.mark.parametrize( - "seat", - [ - "kiro-1", # 接尾辞の下限未満 - "kiro-10", # 接尾辞の上限超過 - "claude_2", # 区切りがアンダースコア - "kiro-2-3", # 接尾辞の重複 - "", # 空文字列 - "gemini", # ALL_RUNTIMES 外 - "gpt", # ALL_RUNTIMES 外 - ], -) -def test_seat_runtime_rejects_malformed_seat(assignment, seat): - """形に合わない席の名前は AssignmentError になる(R1-004)。 - - 現状固定。`SEAT_PATTERN` の境界(接尾辞は -2〜-9 のみ、区切りはハイフン 1 つ)と、 - 未知のランタイム名が弾かれることを、例外の種類と理由の文言ごと固定する。 - """ - with pytest.raises(assignment.AssignmentError) as excinfo: - assignment.seat_runtime(seat) - - assert str(excinfo.value) == ( - f"席の名前の形が違います: {seat}" - "(claude/codex/agy/kiro か、その名前に -2〜-9 を付けた形)" - ) - From 15130d3e3b532a793bcd023f1c60ed58c2bb31fb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:45:12 +0000 Subject: [PATCH 110/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index a663c489..454346ca 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -40,7 +40,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex | 検証中 | 1 | +| error | unit | — | codex | 採用 | 1 | **なぜ**: 0 人でも fallback がある経路は状態初期化から固定されているが、available と fallback がともに空の公開入口が AssignmentError になる経路は未固定である。 @@ -52,7 +52,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | agy | 未着手 | 0 | +| boundary | unit | — | agy | 取り消し | 1 | **なぜ**: seat_runtime は正規表現 SEAT_PATTERN(^(claude|codex|agy|kiro)(-[2-9])?$)に従って席名を検証・抽出するが、共通層テストに境界値・異常値のテストが存在しない。接尾辞の数値境界(-1 は不可、-2〜-9 は可、-10 は不可)、区切り文字違い(_2)、未知のランタイム、空文字列等で AssignmentError が送出される境界値の振る舞いを単体レベルで固定する必要がある。 @@ -83,3 +83,4 @@ | 1 | `plugins/ndf/scripts/lib/monitor.py#monitor_agent` | branch | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/scripts/lib/statefile.py#save` | error | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/critique.sh#select_targets` | branch | 1 ラウンドの採用上限 5 件を超えた | +| 1 | `plugins/ndf/scripts/lib/assignment.py#seat_runtime` | boundary | コミット 36dd097d4dff427b0de545bcd0cdc0de0e7b74fb にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | From a740c1041d43bd8cdfc1db149e44d022a27fa50f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:47:43 +0000 Subject: [PATCH 111/217] =?UTF-8?q?Docs:=20round=204=20=E3=81=AE=E6=8C=87?= =?UTF-8?q?=E6=91=98=E3=82=92=E5=8F=8D=E6=98=A0=EF=BC=88=E8=B6=B3=E3=81=99?= =?UTF-8?q?=E5=85=88=E3=81=AE=E6=98=8E=E7=A4=BA=E3=83=BB=E6=8B=85=E5=BD=93?= =?UTF-8?q?=E3=81=8C=E6=9B=B8=E3=81=8F=E9=8D=B5=E3=81=AE=E9=99=90=E5=AE=9A?= =?UTF-8?q?=E3=83=BB=E8=A1=A8=E3=81=AE=E4=B8=A6=E3=81=B9=E7=9B=B4=E3=81=97?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 決定 1 の本文を「足すのは置き換え先を指す段落だけ」に改め、足す先が要求と契約の 2 本であることを明示 - 「置き換える既存の設計との対応」の冒頭で、設計文書そのものは書き換えないことと、段落を足す先の 2 本を明示 - AC2 に、担当が書くのは event と by_severity だけであること、posted_as / comments_count / review_url は投稿する側が埋め post_error は無くなることを追記 - 「記録(ラウンドごとの担当の欄)」の表が、足す鍵と書き方が変わる鍵だけを並べることを明記 - 「修正の取り込み」の手順 5 を「投稿の応答(まとめのコメントの URL など)」へ具体化 - テスト設計の表の AC20 の行を AC18・AC19 の直後へ移動 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 10 ++++++---- issues/issue-730-583-requirements.md | 4 +++- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index f9eb6426..ee2e8a8a 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -69,7 +69,7 @@ ### 決定 1: 並行する設計と 1 つのファイルで競合しないよう、設計文書は親課題の名前で新設し、既存の設計文書の本体は書き換えない -同じ時期に 4 つの設計が同じ既存文書を指している。本体を書き換えると、先にマージした側の記述が後から入る側の差分で戻る。**既存の要求と設計には、置き換え先を指す段落を 1 つ足すだけにする。** 前例は #727 / #728 / #729 / #732 の 4 つである。 +同じ時期に 4 つの設計が同じ既存文書を指している。本体を書き換えると、先にマージした側の記述が後から入る側の差分で戻る。**足すのは、置き換え先を指す段落だけにする。** 足す先は、置き換わる受け入れ条件を持つ要求の文書と、その確かめ方を持つ契約の文書の 2 本である。 前例は #727 / #728 / #729 / #732 の 4 つである。 ### 決定 2: 片方だけが残る状態を作らないため、GitHub と git への書き込みをレビューを回す側だけが行う @@ -196,6 +196,8 @@ PR の締めと作り直しと再開はすでにレビューを回す側が行 ### 記録(ラウンドごとの担当の欄) +**この表は足す鍵と書き方が変わる鍵だけを並べる。** 収束の判定が読む既存の鍵(`intent` / `posted_as` / `by_severity`)はそのまま残る。 + | 鍵 | 何が入るか | | --- | --- | | `review_url` | 送信の応答が返した URL。流し直しで先客が見つかったときは、その先客の URL | @@ -312,7 +314,7 @@ graph TD 2. 取り込む側が現在の頭を指定して送信する 3. 報告されたコミットが送り先の履歴に含まれることを確かめる。含まれなければ止まる 4. 返信・決着・まとめの投稿を待ち行列へ積んで流す -5. 送信の応答を記録へ書き戻す +5. 投稿の応答(まとめのコメントの URL など)を記録へ書き戻す ## 非機能の実現方式 @@ -335,12 +337,12 @@ graph TD | AC8・AC9 | 送信を偽装し、報告されたコミットが送り先に無いときに失敗することを見る | | AC10・AC11 | 偽の `gh` が先客を返す状態で投稿を積んで流し、新しい投稿が 0 件で項目が取り除かれることを見る | | AC12 | 投稿の後・記録の前で止めた状態から取り込みをもう一度呼び、レビューが増えず記録に先客の URL が入ることを見る | -| AC20 | 送信の後・記録の前で取り込みを止め、同じ取り込みをもう一度呼ぶ結合の経路で、レビューが 1 件しか増えないことを見る | | AC13・AC14 | 返信と決着とまとめについて、同じ項目を 2 度積んでも増えないことを見る | | AC15 | 記録に入る URL と件数が、偽の `gh` が返した応答の値と一致することを見る | | AC16 | 偽の `gh` が HTTP 422 を返す状態で、本文へ退避して送り直すことを見る | | AC17 | 控えに 1 件あり、インラインが 422 で全件退避された実行で、結果なしにならないことを見る | | AC18・AC19 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | +| AC20 | 送信の後・記録の前で取り込みを止め、同じ取り込みをもう一度呼ぶ結合の経路で、レビューが 1 件しか増えないことを見る | | AC21・AC22 | 共通層の部分命令を直接呼び、cross-review から呼んだときと同じ項目が積まれることを見る | | AC23・AC24 | 取り込み・判定・報告の終了コードと標準出力を見る既存のテストを変えずに通す | | AC25〜AC29 | 変更した文書の行を読む検査 | @@ -369,7 +371,7 @@ graph TD ## 置き換える既存の設計との対応 -**この文書は[置き換える前の設計](issue-662-598-537-619-584-583-design.md)の決定 18・19 を置き換える。** 既存文書の本体は書き換えず、置き換え先を指す段落を 1 つ足す(決定 1)。 +**この文書は[置き換える前の設計](issue-662-598-537-619-584-583-design.md)の決定 18・19 を置き換える。** 既存の設計文書そのものは書き換えない。置き換え先を指す段落を足すのは、[置き換える前の要求](issue-662-598-537-619-584-583-requirements.md)と[置き換える前の契約](issue-662-598-537-619-584-583-design-contracts.md)の 2 本である(決定 1)。 | 既存の決定 | この文書 | 扱い | | --- | --- | --- | diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index fe461f89..407b55a2 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -64,7 +64,9 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は - [ ] AC1: 担当へ渡すプロンプトに、レビューを投稿する手順が 1 つも無い。 対象は `gh api` の呼び出し・`event` の指定・インラインの組み立ての 3 つである -- [ ] AC2: 担当へ渡すプロンプトは、指摘の控えと結果ファイルの 2 つだけを書かせる。結果ファイルの `review_url` は担当が書かない項目になる +- [ ] AC2: 担当へ渡すプロンプトは、指摘の控えと結果ファイルの 2 つだけを書かせる。 + 結果ファイルのうち担当が書くのは `event` と `by_severity` だけである。 + `posted_as` と `comments_count` と `review_url` は投稿する側が埋め、`post_error` は無くなる - [ ] AC3: 担当は結果ファイルと指摘の控えを、一時の名前で書いてから改名する。読み取り側が途中の内容を読むことがない - [ ] AC4: 担当が指摘の控えを書かずに終わった実行では、その PR に新しいレビューが 1 件も増えない From 1baa87fca2cc53372b6e737afdef7c9dc69835b0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:49:58 +0000 Subject: [PATCH 112/217] =?UTF-8?q?Test:=20seat=5Fruntime=20=E3=81=AE?= =?UTF-8?q?=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 基底席と副席からランタイム名を抽出する正常系を固定する。 Item-Id: R1-005 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../ndf/scripts/tests/test_lib_assignment.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index cd8e34f8..1925f12a 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -136,6 +136,23 @@ def test_impl_assign_rejects_an_empty_list(assignment): assignment.impl_assign(1, []) +# ---------- 席名からランタイム名への変換(#727) ---------- + +def test_seat_runtime_extracts_runtime_name(assignment): + """現状固定: 基底席と副席から同じランタイム名を返す。""" + for runtime in assignment.ALL_RUNTIMES: + assert assignment.seat_runtime(runtime) == runtime + + seats = { + "kiro-2": "kiro", + "agy-3": "agy", + "codex-5": "codex", + "claude-9": "claude", + } + for seat, runtime in seats.items(): + assert assignment.seat_runtime(seat) == runtime + + # ---------- レビュー席の割り当て(#727。cross-review が使う) ---------- def test_review_seats_with_three_or_more_available_rotates_in_available_order(assignment): @@ -195,4 +212,3 @@ def test_review_seats_rejects_a_bad_round(assignment): match=r"^ラウンド番号は 1 以上です: -1$", ): assignment.review_seats(-1, ["codex", "kiro"], []) - From 4d090a69b1f5975f6ffdd1879220347f734b3699 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:51:02 +0000 Subject: [PATCH 113/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 454346ca..ea98b2c0 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -65,7 +65,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| normal | unit | — | agy | 未着手 | 0 | +| normal | unit | — | agy | 検証中 | 1 | **なぜ**: assignment.py で新設された seat_runtime(seat: str) は、席名から基底ランタイム名を取り出す共通層関数であり、結果受け口・起動スクリプト・監視処理で広く使われる。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが存在しない。接尾辞なしのランタイム名(claude, codex, agy, kiro)および同一ランタイムの副席名(-2〜-9 接尾辞)から正確にランタイム名が抽出される正常系の振る舞いを共通層単体テストとして固定する必要がある。 From 883ead375a6b027b388b7c6105b6df695265c0e0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:55:42 +0000 Subject: [PATCH 114/217] =?UTF-8?q?Refactor:=20=E8=B5=B7=E5=8B=95=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9C=AB=E3=82=92=E5=80=A4=E3=81=A7=E5=8F=97=E3=81=91?= =?UTF-8?q?=E3=80=81=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=AE=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E6=B6=88=E3=81=97=E3=82=92=201=20=E3=81=8B=E6=89=80?= =?UTF-8?q?=E3=81=B8=E5=AF=84=E3=81=9B=E3=82=8B=EF=BC=88#728=20#647=20#592?= =?UTF-8?q?=20#553=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 結末の読み取りを、状態と工程から結果ファイルの名前の幹を組んで共通層を呼ぶ包みへ 変える。中断も出力も行わないため、結果を残さなかった担当のコミットが取り消されない まま残ることがなくなる。 範囲の確定・未検証のコミットの取り消し・結末の記録を新しいモジュールへ集め、3 つの 取り込み(適用・修正・最終ゲートの修正)が同じ手順を通るようにする。事実の読み取りの 層にあった取り消しは無くなる。 群の進行に開き直しの判定と輪番から担当を引く関数を足し、群を開く側と適用の取り込みの 両方が同じ判定を読む。項目の無い群と採用 0 件の群は開かずに取り消す。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-728-647-592-553-plan.md | 205 +++++++++++++++ .../scripts/refactor_lib/commands/apply.py | 238 ++++++++++++++---- .../scripts/refactor_lib/commands/converge.py | 95 +++++-- .../scripts/refactor_lib/commands/gate.py | 84 ++++++- .../scripts/refactor_lib/gitfacts.py | 67 ++--- .../scripts/refactor_lib/intake.py | 175 +++++++++++++ .../scripts/refactor_lib/rounds.py | 56 ++++- .../scripts/refactor_lib/vocabulary.py | 10 + .../cross-refactoring/tests/test_git_facts.py | 63 +++-- .../tests/test_merge_apply.py | 32 ++- 10 files changed, 872 insertions(+), 153 deletions(-) create mode 100644 issues/issue-728-647-592-553-plan.md create mode 100644 plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/intake.py diff --git a/issues/issue-728-647-592-553-plan.md b/issues/issue-728-647-592-553-plan.md new file mode 100644 index 00000000..eb4ffa65 --- /dev/null +++ b/issues/issue-728-647-592-553-plan.md @@ -0,0 +1,205 @@ +# cross-refactoring: 実装担当が結果を残さないと同じ群が上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、群が開いた回数と結末で開き直しを決める(実装計画 / #728 #647 #592 #553) + +## 関連リンク + +| 文書 | 何を持つか | +| --- | --- | +| [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md) | 受け入れ条件 50 件(AC1〜AC50) | +| [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md) | 決定 15 件、入出力の契約、テスト設計 | +| [issue-647-592-553-design.md](issue-647-592-553-design.md) | 置き換えられる既存の設計 | +| 課題 | #728(親)/ #647 / #592 / #553 / #674(最終ゲートの修正。閉じるのは棚卸に任せる) | + +**用語は設計文書の「用語の対応」の表で引く。** この計画は業務用語で書き、識別子はコードブロックと表にだけ置く。 + +## モード + +`standard`。公開しているコマンドの終了コードの意味と、状態ファイルの構造が変わる。複数のモジュールにまたがる。 + +## 目的と非目的 + +達成したい状態: + +- 担当の作業結果が残っていなくても、3 つの取り込み(適用・修正・最終ゲートの修正)が同じ手順で受け、未検証のコミットを取り消し、結末を記録して終わる +- 同じ改善項目の集まりを開き直す回数が 2 回で止まり、2 回目は別の担当が試す +- 採用が 0 件だった提案ラウンドと、項目が 1 件も無い集まりでは担当を起動しない +- 実行環境が帰属行を足したコミットでも、必須の記名(トレーラー)が読める + +やらないこと: + +- 監視そのもの(`plugins/ndf/scripts/lib/monitor.py`)の変更。結末の読み取りの共通層は別の課題(#729)が入れ終えている +- 参加者の決め方の変更(#727)。輪番から担当を引く 1 つの関数に寄せるところまでを行う +- 記録の集計(実行の要約の新しい欄)。契約だけを合わせ、集計は別の課題が行う + +## 前提 + +- 前提 1: 結末の読み取りの共通層(`plugins/ndf/scripts/lib/monitor_outcome.py`)は開発版の起点に入っている。`read_launch_outcome(tmp_dir, stem, result_path=None)` が 5 つの欄を持つ値を返し、同じ担当を起動し直せない理由は利用上限(`usage_limit`)の 1 語だけである +- 前提 2: 参加者の決め方の変更(#727)は開発版の起点に入っていない。輪番から担当を引く関数の中身は、現行の割り当て(`assignment.assign(seq, host)`)を包む形で書く。後から入る側がその中だけを差し替える +- 前提 3: 同じ提案ラウンドの別の変更(#727 の後続)が、担当の割り当ての 2 行(`commands/apply.py:134` と `commands/gate.py:128`)を触る。後からマージする側が輪番から担当を引く関数の中身を揃える + +## 実測 + +この計画のために実行して確かめた値である。 + +| 見たもの | 値 | +| --- | --- | +| 変更前のテスト | `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q` が 704 件成功・終了コード 0(31.8 秒) | +| 記名の解析に題名が要るか | **要る。** 記名だけの段落を単独で `git interpret-trailers --parse` へ渡すと出力が空になる。題名の行と空行を前に付けると 4 つとも返る(git 2.53.0) | +| 散文と記名が混ざる段落 | 出力は空。題名を付けても同じ | +| 題名の位置に記名の形の行を置いた場合 | その行が記名として返る。**先頭の段落を解析に掛けないことで避ける** | +| 解析にリポジトリが要るか | 要らない。リポジトリの外の現在地でも終了コード 0 で動く | + +## 受け入れ条件 + +要求の文書の AC1〜AC50 をそのまま用いる。この計画では各タスクが満たす番号だけを示す。検証手段は要求の文書の「検証手段」と設計文書の「テスト設計」が持つ。 + +## 代替案と採否 + +| 案 | 内容 | 採否 | 理由 | +| --- | --- | --- | --- | +| 結末の読み取りを包みとして残す | 名前を残し、引数を状態と工程に変える | 採用 | 結果ファイルの名前の幹を組む場所が 1 つになる(設計の決定 3) | +| 取り込みが共通層を直に呼ぶ | 包みを置かない | 不採用 | 名前の幹の組み立てが 3 か所に分かれる | +| 記名を進行側が付け直す | 取り込みでコミットを書き換える | 不採用 | コミットの識別子が変わり、申告と実体の対応が切れる(設計の決定 13) | + +## ドメイン用語 + +設計文書の「用語の対応」の表を正本とする。この計画で追加する語は無い。 + +## 不変条件 + +- 検証を受けていないコミットを、公開したまま次の工程へ渡さない +- 取り消した後の作業ツリーの先端が、次の範囲の起点になる +- 同じ工程・同じ試行番号の結末は、記録に 1 件しか入らない + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| 適用の取り込みの終了コード | 着手前テストが成功と確認できていない場合が 2 から 4 へ。2 の意味に「担当を替えて開き直す」が加わる | 骨組みの分岐は変えない(2 で次の群へ進む、4 で止まる、のどちらも既存の扱い) | +| 修正・最終ゲートの修正の取り込みの終了コード | 結果なしで 2 を返す場合が増える | 骨組みは両者の終了コードを見ない | +| 結末の読み取りの引数 | ファイルのパスと担当 → 状態・担当・工程・提案ラウンド | 内部の関数。古い形の呼び出しは実行時に失敗する | +| 状態ファイル | 集まりの側に試行番号・結末の記録・取り消しの理由、最終ゲートの記録に結末の記録を足す | 版を上げない。鍵が無い状態ファイルは試行 0・失敗なしとして読む | +| 取り消しの本体 | 事実の読み取りの層にあったものを取り込みの共通手順へ移す | 内部の関数。呼び出し元 2 か所を同じ変更で書き換える | + +## 修正対象 + +```text +plugins/ndf/skills/cross-refactoring/ +├── SKILL.md +├── docs/02-apply-and-review.md +├── docs/04-fix-and-report.md +├── prompts/{apply,fix,final-fix}.md +├── scripts/refactor_lib/ +│ ├── intake.py # 新設 +│ ├── gitfacts.py +│ ├── rounds.py +│ ├── vocabulary.py +│ └── commands/{apply,converge,gate,setup}.py +└── tests/ + ├── test_intake.py # 新設 + ├── test_apply_attempts.py # 新設 + ├── test_commit_trailers_git.py # 新設 + └── test_{git_facts,merge_apply,apply_rounds,abandon_items,final_fix,skill_terms,init}.py +``` + +配布物(`plugins/ndf/dev.kiro/` と `plugins/ndf/dev.agy/`)は `bash scripts/build-runtime-plugins.sh` で揃える。 + +## タスク分解 + +### Task 1: 起動の結末を値で受け取る + +- **対象ファイル:** `scripts/refactor_lib/gitfacts.py`、`tests/test_intake.py`(新設)、`tests/test_git_facts.py`、`tests/test_skill_terms.py` +- **変更内容:** 結末の読み取り(`read_result`)を、状態・担当・工程・提案ラウンドから結果ファイルの名前の幹を組み、共通層の読み取りを呼んで値を返す包みにする。中断も画面への出力も行わない。名前の幹を組む関数(`paths.stem_for`)の値と、手順書が監視へ渡す名前の雛形(`--stem-template`)を担当名で埋めた値の一致を確かめる +- **満たす受け入れ条件:** AC1、AC2、AC3 +- **進め方:** 一時ディレクトリに結果ファイルを置かない状態・監視の結果ファイルだけを置いた状態でそれぞれ失敗するテストを書き、包みを差し替えて通す。既存の 3 件(中断を現状固定していたもの)は、値を返す形へ書き直す + +### Task 2: 取り込みの共通手順を新設し、取り消しを 1 つにする + +- **対象ファイル:** `scripts/refactor_lib/intake.py`(新設)、`gitfacts.py`、`commands/{apply,converge,gate}.py`、`tests/test_intake.py` +- **変更内容:** 範囲の確定・未検証のコミットの取り消し・叩き直しの判定・結果なしの一連を新しいモジュールへ置く。取り込み 1 つ分の「どこを見て、どこへ書くか」は範囲の値(`IntakeScope`)で渡す。事実の読み取りの層にあった取り消し(`gitfacts.revert_unverified_range`)を削除し、修正・最終ゲート・適用の 3 か所を新しい取り消しへ寄せる。試し打ちの対応(`--dry-run`)は取り消しの引数で受ける +- **満たす受け入れ条件:** AC4、AC5、AC6、AC7、AC8、AC50 +- **進め方:** 3 つの取り込みの形の範囲の値を作り、範囲が 1 件・0 件・確定できない場合で失敗するテストを書いてから実装する。取り消しと公開の順序は、既存のテストが見ている記録(外部コマンドの呼び出し列)で確かめる + +### Task 3: 同じ集まりの試行を 2 回で止め、2 回目は別の担当が試す + +- **対象ファイル:** `scripts/refactor_lib/rounds.py`、`vocabulary.py`、`commands/apply.py`、`tests/test_apply_attempts.py`(新設)、`tests/test_merge_apply.py` +- **変更内容:** 開き直しの判定(`rounds.group_reopening`)と、輪番から担当を引く関数(`rounds.impl_for_seq`)を新設する。集まりを開く側と適用の取り込みの両方が同じ判定を読む。結果なしのときは共通手順で取り消しと記録を行い、次の輪番の担当のうち、その集まりで失敗した担当のどれとも違う最初の担当へ替える。替える先が無いときだけ、起動し直しの可否で続けるか取り消すかを決める。着手前テストの確認を結果の読み取りより前へ移し、終了コードを 4 にする +- **満たす受け入れ条件:** AC9、AC10、AC11、AC12、AC13、AC14、AC15、AC16、AC17、AC18、AC49(3 つのうち 2 つ) +- **進め方:** 集まりが 2 つ・4 つの状態で、結果ファイルを置かずに開く → 取り込む、を繰り返す失敗するテストを先に書く。担当の交代は、輪番から担当を引く関数を差し替えて確かめる + +### Task 4: 採用が 0 件の提案ラウンドと、項目の無い集まりで担当を起動しない + +- **対象ファイル:** `scripts/refactor_lib/rounds.py`、`commands/apply.py`、`tests/test_apply_rounds.py` +- **変更内容:** 集まりの一覧を返す関数で、鍵が無いときだけ古い版として 1 つ作り、空の配列はそのまま返す。集まりが 1 つも無い状態で進行中の集まりを引くと中断する。開き直しの判定が「項目が無い」を返した集まりは、開かずに取り消し済みにして次を探す。取り込み済みで採用が 0 件だった集まりも取り消し済みに直す +- **満たす受け入れ条件:** AC19、AC20、AC21、AC22 +- **進め方:** 採用 0 件からの取り込み → 開く、古い形の状態ファイル、項目の無い集まりの 3 通りで失敗するテストを書いてから実装する + +### Task 5: 修正の結果が無いときに修正ラウンドを進める + +- **対象ファイル:** `scripts/refactor_lib/commands/converge.py`、`tests/test_abandon_items.py` +- **変更内容:** 修正の結果を、提案ラウンドの担当ではなく集まりの担当から読む。結果が無ければ共通手順で取り消しと記録を行い、修正ラウンドの数を 1 進めて終了コード 2 で終える。起動し直せない結末では、修正ラウンドの数を上限の値にして見送りの判定へ渡す +- **満たす受け入れ条件:** AC23、AC24、AC25、AC26、AC27 +- **進め方:** 集まりの担当と提案ラウンドの担当を分けた状態で失敗するテストを書く。上限まで続けた後に見送りの判定が 0 を返すところまで通す + +### Task 6: 最終ゲートの修正の結果が無いときにコミットを取り消す + +- **対象ファイル:** `scripts/refactor_lib/commands/gate.py`、`tests/test_final_fix.py` +- **変更内容:** 最終ゲートの修正の取り込みを共通手順へ通す。結果が無ければ取り消して起点を取り消し後の先端へ進め、終了コード 2 で最終ゲートへ判定を戻す。起動し直せない結末では最終ゲートの修正ラウンドの数を上限の値にする。最終ゲートの修正担当の決定を、輪番から担当を引く関数へ寄せる +- **満たす受け入れ条件:** AC28、AC29、AC30、AC31、AC49(残り 1 つ) +- **進め方:** 最終ゲートの記録に起点と担当を置き、結果ファイルを置かずに取り込む → 判定する、の順で失敗するテストを書いてから実装する + +### Task 7: 帰属行の後ろでも記名を読む + +- **対象ファイル:** `scripts/refactor_lib/gitfacts.py`、`prompts/{apply,fix}.md`、`tests/test_commit_trailers_git.py`(新設)、`tests/test_merge_apply.py` +- **変更内容:** コミットのメッセージを空行で段落に分け、末尾の段落から前へ 1 段落ずつ git の解析へ掛ける。解析が記名の段落と判定しなかったところで止める。**先頭の段落(題名)は掛けない。** 解析には題名の行を補って渡す(実測のとおり、補わないと何も返らない)。同じ鍵は末尾に近い段落の値を採る。雛形のコミットの規約に、必須の記名を最後の段落へ置くことと、実行環境が帰属行を足すときは空行を挟まず同じ段落に続けることを書く +- **満たす受け入れ条件:** AC32、AC33、AC34、AC35、AC36、AC37、AC38、AC39 +- **進め方:** 一時リポジトリに各形のコミットを作り、実際に git を実行して確かめる失敗するテストを先に書く + +### Task 8: テストの実行中の無出力で担当を打ち切らない + +- **対象ファイル:** `scripts/refactor_lib/vocabulary.py`、`commands/setup.py`、`SKILL.md`、`prompts/{apply,fix,final-fix}.md`、`tests/test_init.py`、`tests/test_skill_terms.py` +- **変更内容:** 起動の出力に無進捗の許容を足す。値はテストの制限時間に 900 秒を加えたものとする。手順書の骨組みで、適用・修正・最終ゲートの修正の 3 つの監視の呼び出しにこの値を渡す。3 つの雛形に、作業段階が進むたびに進捗の記録へ 1 行足す指示を書く +- **満たす受け入れ条件:** AC40、AC41、AC42 +- **進め方:** 起動の出力と手順書の骨組みを読む失敗するテストを先に書く + +### Task 9: 手順書と説明文書を実装に合わせる + +- **対象ファイル:** `SKILL.md`、`docs/02-apply-and-review.md`、`docs/04-fix-and-report.md`、`tests/test_skill_terms.py` +- **変更内容:** 使う語の表の適用ラウンドの行に、同じ集まりの試行の上限(2 回)を書く。上限を 1 つに保つとしていた段落を外す。適用の工程と修正・最終ゲートの工程の説明に、結果が無いときの取り消し・記録・終了コードを書く。記名の節に、git の標準の読み方が最後の段落しか読まないことと、進行側の読み方の 2 つを書く +- **満たす受け入れ条件:** AC43、AC44、AC45 +- **進め方:** 文言を読むテストを先に書く。記名の節の記載(AC45)は Pull Request のレビューで見る + +### Task 10: 退行の確認と配布物の同期 + +- **対象ファイル:** `tests/test_merge_apply.py`、配布物一式 +- **変更内容:** 結果があり検証を通る適用が、変更前と同じく 1 回目の試行で取り込まれ、結末の記録を持たないことを既存のテストへ足す。配布物を同期し、定義と frontmatter の検査を通す +- **満たす受け入れ条件:** AC46、AC47、AC48 +- **進め方:** 全体のテスト → 同期 → 3 つの検査の順で実行し、結果を Pull Request 本文へ載せる + +## 影響範囲 + +| 影響を受けるもの | 何が変わるか | +| --- | --- | +| 収束リファクタリングの骨組み | 監視の呼び出しに引数が 1 つ増える。分岐は変えない | +| 進行中の実行の状態ファイル | 新しい鍵は無くても読める。項目の無い集まりを持つ既存の状態は、開こうとした時点で取り消し済みになる | +| 実行の要約と改修計画 | 見送りの理由に、どの担当がどの理由で結果を残さなかったかが並ぶ | +| 収束レビューの共通層 | 読むだけで変えない | + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 適用の取り込み(893 行)が 1 つの関数で結果の読み取り・検証・取り消し・記録を抱えており、分岐を足すと読めなくなる | **先に構造を整える。** Task 2 で取り消しを共通手順へ出し、Task 3 の分岐はそこへ委ねる。新しい分岐を既存の関数へ直接足さない | +| 適用の検証のテスト(1735 行)が終了コードと状態を細かく固定しており、変更が広範囲の失敗として現れる | タスクごとにテストを通す。失敗したテストは、期待値が変わったものと退行とを 1 件ずつ切り分けて記録する | +| 記名の解析が git の版で変わる | 実測した版(2.53.0)を計画へ残し、一時リポジトリで実際に git を実行するテストで固定する | +| 担当の割り当ての 2 行を別の変更が触る | 輪番から担当を引く関数の中だけに割り当ての呼び出しを置く。後からマージする側がその中身を揃える | + +## 切り戻し手順 + +Pull Request 単位で戻せる。状態ファイルの版を上げないため、途中まで進んだ実行の状態は変更前のコードでもそのまま読める。足した鍵は読まれずに残るだけである。 + +## 完了の定義 + +- [ ] 受け入れ条件 AC1〜AC50 について、条件ごとに検証手段と結果が対応している +- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [ ] `bash scripts/build-runtime-plugins.sh --check` / `claude plugin validate .` / `python3 scripts/check-skill-frontmatter.py` が終了コード 0 で終わる +- [ ] 手順書と説明文書が、実装した振る舞いと同じことを書いている diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 0e8d8fef..de59bab6 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -10,9 +10,8 @@ import json import pathlib import sys -from typing import Any +from typing import Any, Optional -import assignment import statefile from .. import die, info @@ -27,20 +26,28 @@ read_result, record_observed_model, reported_shas, - revert_item_commits, round_of, safe_int, collect_commit_facts, commits_in_range, ) +from ..intake import ( + IntakeScope, + already_closed, + close_without_result, + discard_unverified, +) from ..paths import git_out, load_state, result_path, stem_for from ..proposals import assign_apply_rounds, merge_proposals, merge_test_proposals from ..rounds import ( TEST, apply_groups, + attempt_of, current_group, deferred_record, entry_kind, + group_reopening, + impl_for_seq, item_key, item_kind, item_label, @@ -131,18 +138,19 @@ def _assign_apply_rounds_to_state( seq = safe_int(state.get("apply_seq")) for n, group in enumerate(assign_apply_rounds(adopted), start=1): seq += 1 - impl, _ = assignment.assign(seq, state["host"]) + impl, requested = impl_for_seq(state, seq) for item in group: item["apply_round"] = n entry["apply_rounds"].append({ "apply_round": n, "impl": impl, - "impl_model": {"requested": state["models"].get(impl), "observed": None}, + "impl_model": {"requested": requested, "observed": None}, "items": [i["item_id"] for i in group], "status": "pending", "base_sha": None, "head_sha": None, "fix_rounds": 0, + "attempt": 0, }) state["apply_seq"] = seq @@ -293,19 +301,44 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: # **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 # 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは # 収束ループの前提である。 - opened = next( - (g for g in groups if g.get("status") in {"pending", "applied"}), None - ) + # + # **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を + # 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 + # ここで取り消し済みにして次を探す。 + opened: Optional[dict[str, Any]] = None + reopening = "" + for group in groups: + if group.get("status") not in {"pending", "applied"}: + continue + if group.get("status") == "applied": + opened = group + break + reopening = group_reopening(group) + if reopening in {"empty", "exhausted"}: + group["status"] = "dropped" + group.setdefault( + "drop_reason", "empty" if reopening == "empty" else "no_result" + ) + info( + f"適用ラウンド {group['apply_round']} は開きません" + f"({'項目なし' if reopening == 'empty' else '試行の上限'})" + ) + continue + opened = group + break + if opened is None: + statefile.save(path, state) info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") sys.exit(1) entry["apply_round"] = opened["apply_round"] - if opened.get("status") == "pending": + if opened.get("status") == "pending" and reopening == "open": # 起点は**オーケストレータ側で**確定させる。実装担当の申告に委ねると、 # 欠落・不正時に範囲検査が無効になり、過去の任意のコミットが実在扱いになる。 head = git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) opened["base_sha"] = head + opened["attempt"] = attempt_of(opened) + 1 entry["apply_base_sha"] = head entry["fix_rounds"] = 0 entry["apply"] = { @@ -313,6 +346,10 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: "applied": [], "failed": [], "base_sha": head, "head_sha": None, "merged_at": None, } + elif opened.get("status") == "pending": + # 開いたまま閉じていない試行の再開。**起点も試行の番号も動かさない。** + info(f"↻ 適用ラウンド {opened['apply_round']} の試行を再開します") + entry["apply_base_sha"] = opened.get("base_sha") else: # 取り込み済みの群を開き直した。**起点も修正の回数も動かさない。** info(f"↻ 適用ラウンド {opened['apply_round']} は取り込み済みです(検証から再開)") @@ -337,7 +374,9 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用ラウンド 1 つ分の適用結果を検証して取り込む。 - 終了コード: 0 = 取り込んだ / 2 = この群を取り消した(次の群へ進む)。 + 終了コード: 0 = 取り込んだ / 2 = この群を取り消した、または担当を替えて開き直す + (次の群へ進む) / 4 = 着手前のテストが成功と確認できていない・範囲を確定 + できない・群が無い。 **適用そのものが通らないときは修正ラウンドを回さない**(競合・対象が消えて いる・手順を外れた)。修正ラウンドはテストの失敗を直す工程であり、前提その @@ -366,10 +405,38 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: f"{len(record.get('failed') or [])} 件)" ) if not applied_before: + # **採用 0 件の群は取り消し済みに直す**(#592)。残したままだと、 + # 次に群を開く操作がこの群を選び直して担当を起動し続ける。 + group["status"] = "dropped" + group.setdefault("drop_reason", "empty") + state["phase"] = phase_after_group(entry) + if not args.dry_run: + statefile.save(path, state) sys.exit(2) return - payload, commit_range = _load_apply_context(ctx) + # **着手前のテストの確認は、結果を読むより先に行う。** 成功と確認できていない + # 状態で採ると、壊したのか元から壊れていたのかを判別する手段が無い。`red` だけ + # でなく `unknown`(確認していない)も拒否する。 + baseline = state.get("baseline_test") or {} + if baseline.get("status") != "green": + _block_group_items(ctx) + die( + f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" + "適用へ着手しません(全項目を blocked)", + code=4, + ) + + scope = _apply_scope(ctx) + if already_closed(scope): + info("↻ この試行は結果なしとして記録済みです") + sys.exit(2) + + outcome = read_result(state, scope.impl, "apply", args.round) + if outcome.payload is None: + _close_failed_attempt(ctx, scope, outcome) + + payload, commit_range = _load_apply_context(ctx, outcome.payload) reported, unknown_ids = _collect_apply_reports(payload, group) @@ -444,26 +511,120 @@ def _block_group_items(ctx: _ApplyExecutionContext) -> None: statefile.save(ctx.path, ctx.state) -def _load_apply_context( - ctx: _ApplyExecutionContext, -) -> tuple[dict[str, Any], _ApplyCommitRange]: - impl = ctx.group.get("impl") or ctx.entry["impl"] - result = result_path(ctx.state, impl, stem_for(impl, "apply", ctx.state["id"], ctx.args.round)) - payload = read_result(result, impl) +def _apply_scope(ctx: _ApplyExecutionContext) -> IntakeScope: + """適用の取り込み 1 回分の範囲の値。 - record_observed_model(ctx.entry, "impl", impl, ctx.state, "apply", ctx.args.round) + 起点は提案ラウンドの控えが持ち、群の起点も同じ値へ揃える。結末の記録は群が + 持つ。**試行の番号が 0 なら 1 として扱う**(この版より前に開いた群の再開)。 + """ + group = ctx.group + return IntakeScope( + holder=ctx.entry, + base_key="apply_base_sha", + records=group, + phase="apply", + attempt=attempt_of(group) or 1, + impl=group.get("impl") or ctx.entry["impl"], + label=f"R{ctx.entry['round']}-A{group['apply_round']}", + mirror=group, + ) - # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 - # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を - # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 - baseline = ctx.state.get("baseline_test") or {} - if baseline.get("status") != "green": + +def _switch_apply_impl(state: dict[str, Any], group: dict[str, Any]) -> bool: + """結果を残さなかった群の担当を、次の輪番の別の担当へ替える。替えたら真。 + + **1 つ進めるだけにしない。** 輪番は参加者の数で 1 周するため、1 つ先が同じ担当 + になることがある。その群で失敗した担当のどれとも違う担当が出た最初の番号を採る。 + 参加者の数だけ進めても出なければ、替える先が無い(#728 の決定 8)。 + """ + tried = { + record.get("impl") for record in (group.get("failed_attempts") or []) + if record.get("phase") == "apply" + } + tried.add(group.get("impl")) + seq = safe_int(state.get("apply_seq")) + for _ in range(len(state.get("impl_capable") or []) or 4): + seq += 1 + impl, requested = impl_for_seq(state, seq) + if impl in tried: + continue + state["apply_seq"] = seq + group["impl"] = impl + group["impl_model"] = {"requested": requested, "observed": None} + info(f"↻ 適用ラウンド {group['apply_round']} の担当を {impl} へ替えます") + return True + return False + + +def _no_result_reason(group: dict[str, Any]) -> str: + """見送りの理由。どの担当がどの理由で結果を残さなかったかを並べる。""" + trail = " → ".join( + f"{record.get('impl')}: {record.get('reason')}" + for record in (group.get("failed_attempts") or []) + if record.get("phase") == "apply" + ) + return f"実装担当が結果を残しませんでした({trail})" + + +def _drop_group_without_result(ctx: _ApplyExecutionContext) -> None: + """結果を残せないまま上限に達した群を取り消し、項目を見送りへ入れる。""" + reason = _no_result_reason(ctx.group) + info(f"❌ {reason}") + for item_id in ctx.group["items"]: + item = find_item(ctx.state, item_id, required=False) + if item is None: + continue + item["status"] = "abandoned" + item["failure_reason"] = reason + ctx.group["status"] = "dropped" + ctx.group["drop_reason"] = "no_result" + ctx.entry["apply"] = { + "apply_round": ctx.group["apply_round"], + "applied": [], "failed": list(ctx.group["items"]), + "base_sha": ctx.entry.get("apply_base_sha"), + "head_sha": None, + "merged_at": statefile.now(), + } + ctx.state["phase"] = phase_after_group(ctx.entry) + _defer_abandoned_items(ctx.state, ctx.group) + + +def _close_failed_attempt( + ctx: _ApplyExecutionContext, scope: IntakeScope, outcome: Any +) -> None: + """適用担当が結果を残さなかった試行を閉じる。**必ず終了する。** + + 取り消しと記録を共通の手順へ通したあと、開き直しの判定で担当を替えるか群ごと + 取り消すかを決める。替える先が無いときだけ、起動し直しの可否で決める。 + """ + if ctx.args.dry_run: + info("(dry-run)適用結果がありません。状態ファイルは更新していません") + sys.exit(2) + closed = close_without_result(ctx.path, ctx.state, scope, outcome) + if closed.range_unknown: _block_group_items(ctx) die( - f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" - "適用へ着手しません(全項目を blocked)", - code=2, + "適用の範囲を確定できませんでした" + f"(起点 {ctx.entry.get('apply_base_sha')})。検証できない適用は採りません", + code=4, ) + ctx.group["attempt"] = scope.attempt + drop = group_reopening(ctx.group) != "open" + if not drop and not _switch_apply_impl(ctx.state, ctx.group): + # 替える先が無い。起動し直しても解けない結末(利用上限)なら、同じ担当で + # もう一度起動しても待ちと相手の枠を使うだけなので、ここで取り消す。 + drop = not closed.relaunch_same_agent + if drop: + _drop_group_without_result(ctx) + statefile.save(ctx.path, ctx.state) + sys.exit(2) + + +def _load_apply_context( + ctx: _ApplyExecutionContext, payload: dict[str, Any], +) -> tuple[dict[str, Any], _ApplyCommitRange]: + impl = ctx.group.get("impl") or ctx.entry["impl"] + record_observed_model(ctx.entry, "impl", impl, ctx.state, "apply", ctx.args.round) # 検証の材料は git から取る。結果ファイルから使うのは # 「どのコミットがこの群のものか」という対応付けだけ。 @@ -479,7 +640,7 @@ def _load_apply_context( "適用の範囲を確定できませんでした" f"(起点 {ctx.entry.get('apply_base_sha')} / HEAD {head_sha})。" "検証できない適用は採りません", - code=2, + code=4, ) return payload, _ApplyCommitRange(work, head_sha, ordered_range, in_range) @@ -560,23 +721,12 @@ def _revert_unverified_apply_round( ) -> None: """検証を通らない適用ラウンドの範囲を取り消し、状態と公開を反映する。""" # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{ctx.entry['round']}-A{ctx.group['apply_round']}", - "commits": list(commit_range.ordered_range), - } - if not ctx.args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - ctx.entry["pending_push"] = True - statefile.save(ctx.path, ctx.state) - revert_item_commits(ctx.state, whole_round, ctx.args.dry_run) - if not ctx.args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - ctx.entry["apply_base_sha"] = git_out(commit_range.work, ["rev-parse", "HEAD"]) - ctx.group["base_sha"] = ctx.entry["apply_base_sha"] + # 起点まで戻すのが最も確実である。取り消しの本体は 3 つの取り込みで共有する + # (`intake.discard_unverified`)。印を立てる順序も起点の更新もそちらが持つ。 + discard_unverified( + ctx.path, ctx.state, _apply_scope(ctx), commit_range.ordered_range, + dry_run=ctx.args.dry_run, + ) ctx.entry["apply"] = { "apply_round": ctx.group["apply_round"], "applied": [], "failed": list(ctx.group["items"]), diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index b4de4423..b563dd3a 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -31,7 +31,12 @@ collect_commit_facts, commits_in_range, resolved_threads_on_github, - revert_unverified_range, +) +from ..intake import ( + IntakeScope, + already_closed, + close_without_result, + discard_unverified, ) from ..outbound import dropped_line, item_lines, plan_line from ..paths import git_out, load_state, result_path, stem_for @@ -369,21 +374,17 @@ def _record_accepted_fix_commits( def _revert_invalid_fix_round( path: pathlib.Path, state: dict[str, Any], - entry: dict[str, Any], + scope: IntakeScope, ordered_range: list[str], ) -> set[str]: """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 """ - # 取り消しの本体は最終ゲートと共有する(`revert_unverified_range`)。控えの - # 形が同じなので、適用ラウンドと最終ゲートで別々に持たない。 + # 取り消しの本体は 3 つの取り込みで共有する(`intake.discard_unverified`)。 # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - revert_unverified_range( - path, state, entry, ordered_range, - f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) + discard_unverified(path, state, scope, ordered_range) info("⚠ 修正を取り消したため、解決の申告は採用しません") return set() @@ -452,6 +453,7 @@ def _settle_fix_round( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + scope: IntakeScope, ordered_range: list[str], resolved: set[str], unassigned: list[str], @@ -460,25 +462,89 @@ def _settle_fix_round( ) -> None: """検証結果に応じて修正ラウンドを取り消すか受理し、解決の印を付ける。""" if unassigned or problems: - resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) + resolved = _revert_invalid_fix_round(path, state, scope, ordered_range) else: _record_accepted_fix_commits(state, accepted) _mark_resolved_fix_findings(entry, resolved) +def _fix_scope(entry: dict[str, Any], impl: str) -> IntakeScope: + """修正の取り込み 1 回分の範囲の値。 + + 起点と公開の保留の印は提案ラウンドの控えが持ち、結末の記録は**その群**が持つ。 + 記録を群に置くのは、担当が群ごとに決まるためである。 + """ + return IntakeScope( + holder=entry, + base_key="fix_base_sha", + records=current_group(entry), + phase="fix", + attempt=safe_int(entry.get("fix_attempts")), + impl=impl, + label=f"R{entry['round']}-fix{safe_int(entry.get('fix_rounds')) + 1}", + ) + + +def _close_failed_fix( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + scope: IntakeScope, + outcome: Any, +) -> None: + """修正の担当が結果を残さなかったときに、取り消して修正ラウンドを進める。 + + **必ず修正ラウンドを進める。** 進めないと見送りの判定が上限に達する条件を + 満たさず、検証と修正を往復し続ける(#647)。起動し直しても解けない結末 + (利用上限)では上限の値まで進め、次の判定で見送りへ移す(#728 の決定 10)。 + """ + closed = close_without_result(path, state, scope, outcome) + if closed.range_unknown: + entry["fix_rounds"] = safe_int(entry.get("fix_rounds")) + 1 + statefile.save(path, state) + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')})。検証できない修正は採りません", + code=2, + ) + limit = safe_int(state.get("max_fix_rounds"), 3) + if closed.relaunch_same_agent: + entry["fix_rounds"] = safe_int(entry.get("fix_rounds")) + 1 + else: + entry["fix_rounds"] = limit + statefile.save(path, state) + info(f"修正ラウンド {entry['fix_rounds']} / {limit}") + sys.exit(2) + + def cmd_merge_fix(args: argparse.Namespace) -> None: - """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" + """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。 + + 終了コード: 0 = 取り込んだ / 2 = 範囲を確定できない、または担当が結果を + 残さなかった(どちらも修正ラウンドは進む) / 4 = 群が無い。 + """ path, state = load_state(args.id) entry = round_of(state, args.round) discard_impl_leftovers(state, state["worktrees"]["work"]) flush_pending_push(path, state, entry) - impl = entry["impl"] - result = result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) - payload = read_result(result, impl) + # **担当は群から読む。** 骨組みが起動するのは群の担当であり、提案ラウンドの + # 担当とは限らない。食い違うと結果ファイルを一度も引けない(#728 の決定 10)。 + group = current_group(entry) + impl = group.get("impl") or entry["impl"] + scope = _fix_scope(entry, impl) + if already_closed(scope): + info("↻ この修正の試行は結果なしとして記録済みです") + sys.exit(2) + + outcome = read_result(state, impl, "fix", args.round) + if outcome.payload is None: + _close_failed_fix(path, state, entry, scope, outcome) + payload = outcome.payload work = state["worktrees"]["work"] head_now = git_out(work, ["rev-parse", "HEAD"]) or "" + result = result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) merge_key = _fix_merge_key(entry, result) if _already_merged_fix_result(entry, merge_key): return @@ -492,7 +558,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: state, work, payload, baseline, ordered_range ) _settle_fix_round( - path, state, entry, ordered_range, resolved, unassigned, problems, accepted + path, state, entry, scope, ordered_range, resolved, unassigned, problems, + accepted, ) merged_keys.append(merge_key) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index e42eb409..08596df4 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -15,10 +15,10 @@ from __future__ import annotations import argparse +import pathlib import sys from typing import Any, Optional -import assignment import statefile from .. import die, info @@ -33,9 +33,15 @@ check_run_result, collect_commit_facts, commits_in_range, - revert_unverified_range, ) -from ..paths import git_out, load_state, result_path, stem_for +from ..intake import ( + IntakeScope, + already_closed, + close_without_result, + discard_unverified, +) +from ..paths import git_out, load_state +from ..rounds import impl_for_seq from ..verify import verify_final_fix_commit from ..vocabulary import DEFAULT_TEST_TIMEOUT from ..verify import unassigned_fix_commits @@ -125,12 +131,57 @@ def _final_fix_impl(state: dict[str, Any], gate: dict[str, Any]) -> str: if impl: return impl seq = safe_int(state.get("apply_seq")) + 1 - impl, _ = assignment.assign(seq, state["host"]) + impl, _ = impl_for_seq(state, seq) state["apply_seq"] = seq gate["impl"] = impl return impl +def _final_fix_scope(gate: dict[str, Any], impl: str) -> IntakeScope: + """最終ゲートの修正の取り込み 1 回分の範囲の値。 + + 起点も結末の記録も最終ゲートの控えが持つ。改善項目にも提案ラウンドにも + 属さないため、群は関わらない。 + """ + rounds = safe_int(gate.get("fix_rounds")) + return IntakeScope( + holder=gate, + base_key="fix_base_sha", + records=gate, + phase="final-fix", + attempt=rounds, + impl=impl, + label=f"final-gate-fix{rounds}", + ) + + +def _close_failed_final_fix( + path: pathlib.Path, + state: dict[str, Any], + gate: dict[str, Any], + scope: IntakeScope, + outcome: Any, +) -> None: + """最終ゲートの修正担当が結果を残さなかったときに、取り消して判定へ戻す。 + + **修正ラウンドは進めない。** 進めるのは次の最終ゲートで、そこが上限を見る。 + 起動し直しても解けない結末(利用上限)だけは上限の値まで進め、次の最終ゲートを + 「取り消さず報告」で終わらせる(#728 の決定 11)。 + """ + closed = close_without_result(path, state, scope, outcome) + if closed.range_unknown: + statefile.save(path, state) + die( + "最終ゲートの修正の範囲を確定できませんでした" + f"(起点 {gate.get('fix_base_sha')})。検証できない修正は採りません", + code=2, + ) + if not closed.relaunch_same_agent: + gate["fix_rounds"] = safe_int(state.get("max_fix_rounds"), 3) + statefile.save(path, state) + sys.exit(2) + + def cmd_merge_final_fix(args: argparse.Namespace) -> None: """Step 7 — 最終ゲートの修正結果を取り込む。 @@ -144,8 +195,13 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: | 正常なコミットまで取り消される | 古い起点が残っていると、そこから HEAD までが範囲になる | | トレーラーが揃わず全件が不正になる | `Item-Id` を要求するが、最終ゲートの修正は項目に属さない | - 終了コード: 0 = 取り込んだ / 2 = 取り込めなかった(範囲を確定できない)。 - 合否そのものは判定せず、**次の `final-gate` が採った側で 1 度だけ見る**。 + 終了コード: 0 = 取り込んだ / 2 = 取り込めなかった(範囲を確定できない、または + 担当が結果を残さなかった)。合否そのものは判定せず、**次の `final-gate` が + 採った側で 1 度だけ見る**。 + + **結果を残さなかったときも、作られたコミットは取り消す。** 取り消さずに抜けると、 + 次の最終ゲートがそのコミットを含む先端でテストし、落ちれば起点をそこへ置き直す。 + 未検証の差分が Pull Request に残る(#674)。 """ path, state = load_state(args.id) gate = state.setdefault("final_gate", {"fix_rounds": 0, "checks": []}) @@ -161,8 +217,15 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: discard_impl_leftovers(state, work) flush_pending_push(path, state, gate) - result = result_path(state, impl, stem_for(impl, "final-fix", state["id"])) - payload = read_result(result, impl) + scope = _final_fix_scope(gate, impl) + if already_closed(scope): + info("↻ この最終ゲートの修正の試行は結果なしとして記録済みです") + sys.exit(2) + + outcome = read_result(state, impl, "final-fix") + if outcome.payload is None: + _close_failed_final_fix(path, state, gate, scope, outcome) + payload = outcome.payload head_now = git_out(work, ["rev-parse", "HEAD"]) or "" ordered_range = commits_in_range(work, gate.get("fix_base_sha"), head_now) if ordered_range is None: @@ -200,10 +263,7 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: # **ここは取り消す。** 「上限に達しても取り消さない」のは*採用した改善項目* # の話で、検証を受けていない修正コミットは別である。取り消せば HEAD は # 最終ゲートが見た地点へ戻り、公開済みの内容と食い違わない。 - revert_unverified_range( - path, state, gate, ordered_range, - f"final-gate-fix{safe_int(gate.get('fix_rounds'))}", - ) + discard_unverified(path, state, scope, ordered_range) else: gate["fix_base_sha"] = head_now gate.setdefault("fix_commits", []).extend(ordered_range) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index d2f79144..12989d38 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -15,6 +15,7 @@ import models as models_lib import statefile +from monitor_outcome import LaunchOutcome, read_launch_outcome from . import die, info from .paths import git_out, sh, stem_for @@ -450,40 +451,6 @@ def revert_item_commits( return len(shas) -def revert_unverified_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - label: str, -) -> None: - """検証を通らない範囲を取り消し、`entry` の起点を取り消し後の HEAD へ進める。 - - `entry` は**修正の控えを持つ辞書**である。適用ラウンドの控え(`rounds[]` の - 要素)と最終ゲートの控え(`final_gate`)の両方が同じ 3 つの鍵 - (`pending_push` / `fix_base_sha`)を持つため、どちらからも呼べる。 - `label` は取り消しの単位を人が読むための名前で、git の操作には効かない。 - """ - work = state["worktrees"]["work"] - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - revert_item_commits( - state, - {"item_id": label, "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - - def _reset_hard(work: str, sha: Optional[str]) -> None: """着手前の HEAD へ戻す。半端な履歴を Pull Request に残さないための後始末。""" if sha: @@ -1079,26 +1046,22 @@ def find_item( return None -def read_result(path: pathlib.Path, runtime: str) -> dict[str, Any]: - """結果ファイルを読む。**JSON オブジェクトでなければ失敗させる。** +def read_result( + state: dict[str, Any], runtime: str, phase: str, round_no: Optional[int] = None +) -> LaunchOutcome: + """起動 1 回の結末を読む。**失敗しない。** - 配列や数値が返ってきたまま呼び出し側へ渡すと、`payload.get(...)` で - `AttributeError` になって進行が止まる。読み込みの時点で弾く。 + 結果ファイルの名前の幹をここで 1 度だけ組み、共通層(`read_launch_outcome`)へ + 渡す。3 つの取り込みが同じ組み立てを通るため、監視へ渡した名前の雛形 + (`--stem-template`)と食い違う幹で読むことがない。 + + **中断も出力もしない。** 結果を読めなかったときに何をするかは、読んだ側 + (取り込み)が終了コードとして決める。ここで `die` すると、未検証のコミットが + 取り消されないまま残る(#728)。 """ - if not path.exists(): - die(f"{runtime} の結果ファイルがありません: {path}", code=2) - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - die(f"{runtime} の結果ファイルが JSON として読めません: {e}", code=2) - raise SystemExit(2) - if not isinstance(payload, dict): - die( - f"{runtime} の結果ファイルが JSON オブジェクトではありません" - f"({type(payload).__name__}): {path}", - code=2, - ) - return payload + return read_launch_outcome( + state["tmp_dir"], stem_for(runtime, phase, state["id"], round_no) + ) def record_observed_model( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/intake.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/intake.py new file mode 100644 index 00000000..cb39b639 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/intake.py @@ -0,0 +1,175 @@ +"""取り込み 1 回分の共通手順(#728 の決定 4)。 + +適用・修正・最終ゲートの修正の 3 つの取り込みは、結果を読めたときも読めなかった +ときも同じことを行う。起点から HEAD までの範囲を確め、通らなければ取り消し、 +起点を取り消し後の HEAD へ進める。**違うのは起点の鍵と記録先だけ**なので、その差を +`IntakeScope` で受け取り、手順そのものはここ 1 か所に置く。 + +**`commands` 層に置かない。** 3 つのコマンドが読む層だからである(群の進行と同じ +理由。`commands` どうしの取り込みを作らない)。git の事実の読み取り +(`gitfacts`)にも置かない。取り消しは事実の読み取りではなく進行の手順である。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +import pathlib +from typing import Any, Optional + +import statefile + +from . import info +from .gitfacts import ( + commits_in_range, + push_with_retry_marker, + revert_item_commits, +) +from .paths import git_out + + +@dataclass +class IntakeScope: + """取り込み 1 つ分の「どこを見て、どこへ書くか」。 + + `holder` は公開の保留の印と起点を持つ辞書(提案ラウンドの控えか最終ゲートの + 控え)、`records` は結末の記録を持つ辞書(群か最終ゲートの控え)である。 + `mirror` は起点を同じ値に揃える辞書で、適用の取り込みだけが群を渡す。 + """ + + holder: dict[str, Any] + base_key: str + records: dict[str, Any] + phase: str + attempt: int + impl: str + label: str + mirror: Optional[dict[str, Any]] = None + + +@dataclass +class ClosedAttempt: + """結果なしを閉じた結果。 + + `range_unknown` が真のときは取り消しも記録も行っていない。**何で終わるかは + 呼び出し側が決める。** 適用は中断、修正と最終ゲートは既存の扱いへ戻す。 + """ + + reason: str + detail: str + reverted: int = 0 + relaunch_same_agent: bool = True + range_unknown: bool = False + tried: list[str] = field(default_factory=list) + + +def confirm_range(state: dict[str, Any], scope: IntakeScope) -> Optional[list[str]]: + """起点から HEAD までのコミットを新しい順で返す。確定できなければ `None`。 + + **空の配列と `None` を区別する。** 空は「1 件もコミットされていない」、`None` は + 「範囲を確定できなかった」である。混同すると、確定できないときに検査が素通りする。 + """ + work = state["worktrees"]["work"] + head = git_out(work, ["rev-parse", "HEAD"]) or "" + return commits_in_range(work, scope.holder.get(scope.base_key), head) + + +def discard_unverified( + path: pathlib.Path, + state: dict[str, Any], + scope: IntakeScope, + ordered_range: list[str], + dry_run: bool = False, +) -> int: + """検証を受けていない範囲を取り消し、起点を取り消し後の HEAD へ進める。 + + 順序は「印を立てて保存 → 新しい順に取り消す → 起点を書いて保存」である。 + **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに公開できずに終わると、 + 未検証の変更が Pull Request に残ったままになる。**起点はその場で保存する。** + 保存せずに落ちると、次の実行が古い起点から範囲を取り直し、取り消しコミット自体を + 「未申告」と判定して取り消しを取り消してしまう。 + """ + if not ordered_range: + return 0 + info(f"検証を通らない変更を残さないため、{scope.label} の範囲を取り消します") + if not dry_run: + scope.holder["pending_push"] = True + statefile.save(path, state) + revert_item_commits( + state, + {"item_id": scope.label, "commits": list(ordered_range)}, + dry_run=dry_run, + ) + if not dry_run: + head = git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + scope.holder[scope.base_key] = head + if scope.mirror is not None: + scope.mirror["base_sha"] = head + statefile.save(path, state) + return len(ordered_range) + + +def already_closed(scope: IntakeScope) -> bool: + """この工程・この試行番号の結末を既に記録しているか。 + + **記録していれば結果ファイルを読まない。** 叩き直しのたびに読むと、後から + 現れた結果ファイルを、取り消し済みの範囲の申告として取り込んでしまう。 + """ + return any( + record.get("phase") == scope.phase and record.get("attempt") == scope.attempt + for record in (scope.records.get("failed_attempts") or []) + ) + + +def failed_impls(scope: IntakeScope) -> list[str]: + """この工程で結果を残さなかった担当を、記録の順に返す。""" + return [ + str(record.get("impl") or "") + for record in (scope.records.get("failed_attempts") or []) + if record.get("phase") == scope.phase + ] + + +def close_without_result( + path: pathlib.Path, + state: dict[str, Any], + scope: IntakeScope, + outcome: Any, +) -> ClosedAttempt: + """結果なしの起動を閉じる。範囲の確定 → 取り消し → 結末の記録の順で行う。 + + 記録は追記だけで、上書きしない。担当を替えると群の担当は書き換わるが、どの担当が + どの試行で失敗したかは記録から読める。 + """ + reason = str(outcome.reason or "missing") + detail = str(outcome.detail or "") + ordered_range = confirm_range(state, scope) + if ordered_range is None: + return ClosedAttempt( + reason=reason, + detail=detail, + relaunch_same_agent=bool(outcome.relaunch_same_agent), + range_unknown=True, + ) + reverted = discard_unverified(path, state, scope, ordered_range) + scope.records.setdefault("failed_attempts", []).append({ + "phase": scope.phase, + "attempt": scope.attempt, + "impl": scope.impl, + "reason": reason, + "detail": detail, + "at": statefile.now(), + "reverted": reverted, + }) + statefile.save(path, state) + if reverted: + push_with_retry_marker(path, state, scope.holder) + info( + f"⚠ {scope.impl} は結果を残しませんでした({reason})。" + f"取り消したコミットは {reverted} 件です" + ) + return ClosedAttempt( + reason=reason, + detail=detail, + reverted=reverted, + relaunch_same_agent=bool(outcome.relaunch_same_agent), + tried=failed_impls(scope), + ) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py index 857b0cfa..fa17d6ef 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py @@ -11,12 +11,14 @@ import pathlib -from typing import Any +from typing import Any, Optional +import assignment import statefile -from . import info +from . import die, info from .paths import git_out +from .vocabulary import MAX_APPLY_ATTEMPTS # ラウンドの種類。**宣言の無い状態ファイルは構造改善として読む**(この版より前で # 始めた実行を、再開の時点でテスト整備へ戻さないため)。 @@ -104,9 +106,13 @@ def apply_groups(entry: dict[str, Any]) -> list[dict[str, Any]]: 群を持たない状態ファイル(この版より前)は、**ラウンド全体を 1 つの群**として 読み、その場で記録する。中断から再開したときに、群の単位が実行のたびに 変わらないようにするためである。 + + **鍵が無いときだけ作る。空の配列はそのまま返す。** 採用が 0 件だった提案 + ラウンドは空の配列を書くため、ここで作ると項目が 1 件も無い群が生まれ、 + 担当を起動し続ける(#592)。 """ groups = entry.get("apply_rounds") - if groups: + if groups is not None: return groups entry["apply_rounds"] = [{ "apply_round": 1, @@ -124,12 +130,56 @@ def apply_groups(entry: dict[str, Any]) -> list[dict[str, Any]]: def current_group(entry: dict[str, Any]) -> dict[str, Any]: """進行中の適用ラウンド。まだ開いていなければ最初の群を返す。""" groups = apply_groups(entry) + if not groups: + die("このラウンドには適用ラウンド(群)がありません") current = entry.get("apply_round") or 1 for group in groups: if group.get("apply_round") == current: return group return groups[-1] + +def attempt_of(group: dict[str, Any]) -> int: + """群がいま開いている試行の番号。鍵が無ければ 0(まだ開いていない)。""" + value = group.get("attempt") + return value if isinstance(value, int) and not isinstance(value, bool) else 0 + + +def group_reopening(group: dict[str, Any]) -> str: + """この群をどう扱うか。**中断からの再開と失敗のやり直しを区別する**(#647)。 + + | 値 | 意味 | + | --- | --- | + | `open` | 開いて試行の番号を進める | + | `resume` | 開いたまま閉じていない試行を再開する。番号を進めない | + | `exhausted` | 上限に達した。開かない | + | `empty` | 項目が無い | + + 判定に使うのは群が持つ 2 つだけである。開いた回数(`attempt`)と、結末の記録の + うち工程が適用のものの件数である。**開いた回数だけを数えない。** 進行側が落ちて + 再開しただけで試行が進んでしまう。 + """ + if not (group.get("items") or []): + return "empty" + failed = len([ + record for record in (group.get("failed_attempts") or []) + if record.get("phase") == "apply" + ]) + if failed >= MAX_APPLY_ATTEMPTS: + return "exhausted" + return "resume" if attempt_of(group) > failed else "open" + + +def impl_for_seq(state: dict[str, Any], seq: int) -> tuple[str, Optional[str]]: + """輪番の通し番号から、作業を任せる担当と要求するモデルを引く。 + + **輪番を引く呼び出しはここだけにする**(#728 の決定 9)。参加者の決め方が + 変わったとき(#727)に、変える場所がこの中だけで済む。読むのは 3 か所 + (群の割り当て・結果なしの試行の交代先・最終ゲートの修正担当)である。 + """ + impl, _reviewers = assignment.assign(seq, state["host"]) + return impl, (state.get("models") or {}).get(impl) + def phase_after_group(entry: dict[str, Any]) -> str: """この群を終えた後のフェーズ。残りの群があれば適用を続ける。""" remaining = [ diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py index b17d6d63..50fa0c6f 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py @@ -123,6 +123,16 @@ def vocabulary() -> dict[str, Any]: } +# 1 つの群に対して適用担当を起動し直す上限。**引数を足さない**(#647)。 +# 2 回目は別の担当が試す。2 回とも結果を残さなければ、担当ではなく群の側を疑える。 +# 3 回以上にしても、壊れた CLI に当たる確率が上がるだけである。 +MAX_APPLY_ATTEMPTS = 2 + +# 無進捗と見なすまでの余白。テストの制限時間(`--test-timeout`)へ足した値を +# 起動が `IMPL_STALL_TIMEOUT` として出す。適用と修正の担当はテストを 1 回実行し、 +# その間は何も出力しないため、制限時間そのままでは打ち切られる(#553)。 +IMPL_STALL_MARGIN = 900 + # 適用と修正のコミットに必須のトレーラー。1 つでも欠けたら当該項目を失敗にする。 # 自由文で「codex が実装」と書かせると集計に使えないため、必ずトレーラー形式にする。 REQUIRED_TRAILERS = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py index 6d9d3275..d4d5b7b2 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py @@ -276,39 +276,58 @@ def test_cutting_off_kills_children_that_ignore_sigterm(gitfacts, work): assert not marker.exists(), "SIGTERM を無視する子が生き残っている" -def test_read_result_aborts_when_the_file_is_missing(gitfacts, tmp_path): - """現状固定: 結果ファイルが無ければ終了コード 2 で中断する。 +def test_read_result_returns_a_value_when_the_file_is_missing(gitfacts, tmp_path, capsys): + """結果ファイルが無くても中断せず、結果なしの値を返す。 - 起動した CLI が結果を残さなかった場合であり、進行は次のラウンドへ進む。 + 中断すると、担当が作ったコミットが取り消されないまま Pull Request に残る + (#728)。何で終わるかは読んだ側(取り込み)が決める。 """ - with pytest.raises(SystemExit) as e: - gitfacts.read_result(tmp_path / "missing.json", "claude") - assert e.value.code == 2 + state = {"id": 130, "tmp_dir": str(tmp_path)} + outcome = gitfacts.read_result(state, "claude", "apply", 1) + assert outcome.payload is None + assert outcome.reason == "missing" + assert outcome.relaunch_same_agent is True + captured = capsys.readouterr() + assert (captured.out, captured.err) == ("", "") -def test_read_result_aborts_on_broken_json(gitfacts, tmp_path): - """現状固定: JSON として読めなければ終了コード 2 で中断する。""" - path = tmp_path / "result.json" - path.write_text('{"items": [', encoding="utf-8") - with pytest.raises(SystemExit) as e: - gitfacts.read_result(path, "claude") - assert e.value.code == 2 +def test_read_result_returns_unparsable_for_broken_json(gitfacts, tmp_path): + """JSON として読めない結果ファイルは、理由 `unparsable` の結果なしになる。""" + (tmp_path / "claude-apply-r1-result.json").write_text( + '{"items": [', encoding="utf-8") + state = {"id": 130, "tmp_dir": str(tmp_path)} + + outcome = gitfacts.read_result(state, "claude", "apply", 1) + + assert (outcome.payload, outcome.reason) == (None, "unparsable") @pytest.mark.parametrize("body", ['[{"item_id": "R1-001"}]', "42"]) -def test_read_result_aborts_when_the_json_is_not_an_object(gitfacts, tmp_path, body): - """現状固定: 配列や数値も終了コード 2 で中断する。 +def test_read_result_returns_unparsable_when_the_json_is_not_an_object( + gitfacts, tmp_path, body +): + """配列や数値も結果なしとして返す。 - 呼び出し側は `payload.get(...)` を呼ぶため、読み込みの時点で弾かないと - `AttributeError` になって進行が止まる。 + 呼び出し側は `payload.get(...)` を呼ぶため、辞書でないものを渡すと + `AttributeError` になって進行が止まる。読み込みの時点で結果なしへ寄せる。 """ - path = tmp_path / "result.json" - path.write_text(body, encoding="utf-8") + (tmp_path / "claude-apply-r1-result.json").write_text(body, encoding="utf-8") + state = {"id": 130, "tmp_dir": str(tmp_path)} - with pytest.raises(SystemExit) as e: - gitfacts.read_result(path, "claude") - assert e.value.code == 2 + outcome = gitfacts.read_result(state, "claude", "apply", 1) + + assert (outcome.payload, outcome.reason) == (None, "unparsable") + + +def test_read_result_reads_the_stem_of_each_phase(gitfacts, tmp_path): + """名前の幹は工程ごとに変わる。最終ゲートの修正だけラウンド番号を持たない。""" + (tmp_path / "codex-fix-r2-result.json").write_text('{"ok": 1}', encoding="utf-8") + (tmp_path / "codex-final-fix-result.json").write_text('{"ok": 2}', encoding="utf-8") + state = {"id": 130, "tmp_dir": str(tmp_path)} + + assert gitfacts.read_result(state, "codex", "fix", 2).payload == {"ok": 1} + assert gitfacts.read_result(state, "codex", "final-fix").payload == {"ok": 2} def test_find_item_returns_none_for_a_missing_id_when_not_required(gitfacts): diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py index 9a4813b6..303d8a79 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py @@ -934,7 +934,7 @@ def test_unverified_baseline_blocks_every_item(refactor, tmp_path, env_tmp_dir): refactor.cmd_merge_apply( type("A", (), {"id": 130, "round": 1, "dry_run": False})() ) - assert e.value.code == 2 + assert e.value.code == 4 assert read_state(state_path)["items"][0]["status"] == "blocked" @@ -955,7 +955,7 @@ def test_unknown_baseline_also_blocks(refactor, tmp_path, env_tmp_dir): refactor.cmd_merge_apply( type("A", (), {"id": 130, "round": 1, "dry_run": False})() ) - assert e.value.code == 2 + assert e.value.code == 4 assert read_state(state_path)["items"][0]["status"] == "blocked" @@ -1005,7 +1005,7 @@ def test_range_that_cannot_be_determined_fails_closed(patch_lib, refactor, tmp_p refactor.cmd_merge_apply( type("A", (), {"id": 130, "round": 1, "dry_run": False})() ) - assert e.value.code == 2 + assert e.value.code == 4 assert read_state(state_path)["items"][0]["status"] == "blocked" @@ -1101,10 +1101,27 @@ def test_broken_apply_result_does_not_crash( assert read_state(state_path)["items"][0]["status"] == "abandoned" -def test_non_object_result_file_fails(refactor, tmp_path, env_tmp_dir, no_git): - """結果が JSON オブジェクトでなければ、読み込みの時点で弾く。""" +def test_non_object_result_file_is_a_missing_result( + refactor, tmp_path, env_tmp_dir, no_git +): + """結果が JSON オブジェクトでなければ、結果なしとして扱う。 + + 呼び出し側は `payload.get(...)` を呼ぶため、辞書でないものを渡すと進行が + 止まる。取り消しと記録を通し、理由 `unparsable` を残して次の試行へ渡す。 + """ items = [item(item_id="R1-001")] - state_path = _state_with_items(tmp_path, items) + state_path = _state_with_items( + tmp_path, items, + rounds_override=[{ + "apply_round": 1, "impl": "codex", + "impl_model": {"requested": "gpt-5.5", "observed": None}, + "items": ["R1-001"], "status": "pending", + "base_sha": "base0", "head_sha": None, "fix_rounds": 0, "attempt": 1, + }], + ) + state = read_state(state_path) + state["rounds"][0]["apply_base_sha"] = "base0" + state_path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") env_tmp_dir(state_path) write_result(state_path, "codex-apply-r1", ["配列で返ってきた"]) with pytest.raises(SystemExit) as e: @@ -1113,6 +1130,9 @@ def test_non_object_result_file_fails(refactor, tmp_path, env_tmp_dir, no_git): ) assert e.value.code == 2 + group = read_state(state_path)["rounds"][0]["apply_rounds"][0] + assert [r["reason"] for r in group["failed_attempts"]] == ["unparsable"] + def test_the_group_sharing_one_commit_is_accepted(paths, patch_lib, refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts): """群の全項目が同じコミットを申告するのが**正しい形**である(決定 2)。 From ad8115a06caf822fee95050f83562e2038513161 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:56:53 +0000 Subject: [PATCH 115/217] =?UTF-8?q?Fix:=202=20=E3=83=95=E3=82=A1=E3=82=A4?= =?UTF-8?q?=E3=83=AB=E3=81=AE=E5=85=AC=E9=96=8B=E3=81=AE=E9=A0=86=E5=BA=8F?= =?UTF-8?q?=E3=81=A8=E3=80=81=E9=80=80=E9=81=BF=E3=81=99=E3=82=8B=20422=20?= =?UTF-8?q?=E3=81=AE=E8=A6=8B=E5=88=86=E3=81=91=E3=82=92=E5=A5=91=E7=B4=84?= =?UTF-8?q?=E3=81=B8=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit round 5 の指摘 2 件に対応する。 - 決定 12 / AC3 / テスト設計: 改名の順序を「控えが先、結果ファイルが後」と定め、 控えだけが正式の名前で結果ファイルが無い状態を結果なしとして扱うことを明記する - 決定 9 / AC16 / テスト設計 / 未確認のまま残ること: HTTP 422 のうち行を解決できない ことを示すものだけを退避の契機とし、それ以外の 422 は失敗として残すことを明記する Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 10 ++++++---- issues/issue-730-583-requirements.md | 5 ++++- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index ee2e8a8a..4aaaa0cc 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -139,6 +139,8 @@ インラインの投稿が差分の外を理由に拒まれたとき(HTTP 422)、投稿する側がその指摘を本文の末尾へ移し、レビューをもう一度送る。退避した指摘の `posted_to` は `body` として記録する。**退避した件数を取り込みの出力に出す。** +**HTTP 422 のすべてを退避の契機にしない。** 応答の本文が行を解決できないことを示すときだけ退避する。それ以外の 422(判定の値の誤り、基準のコミットの誤りなど)は失敗として残す。区別しないと、別の不具合が退避として飲み込まれる。 + 採らない案と理由: - **投稿の前に、差分に含まれる行かどうかを判定して振り分ける。** 差分の範囲を別に取り寄せる必要があり、取り寄せが失敗したときの分岐が増える。拒まれてから退避すれば、判定の根拠は応答そのものになる @@ -161,7 +163,7 @@ python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <結果ファ ### 決定 12: 読み取り側が途中の内容を読まないよう、担当は結果を一時の名前で書いてから改名する -担当は結果ファイルと指摘の控えを一時の名前で書き、書き終えてから正式の名前へ改名する。**読めた状態は書き終えた状態である**ことが成り立つため、取り込み側は内容の途中を読むことがない。担当が途中で止まれば正式の名前のファイルは現れず、結果なしとして扱われる。 +担当は結果ファイルと指摘の控えを一時の名前で書き、書き終えてから正式の名前へ改名する。**改名の順序は、控えが先、結果ファイルが後である。** 結果ファイルが正式の名前で現れたことが、2 つとも揃った印になる。控えだけが正式の名前で結果ファイルが無い状態は、結果なしとして扱う。**読めた状態は書き終えた状態である**ことが成り立つため、取り込み側は内容の途中を読むことがない。担当が途中で止まれば正式の名前のファイルは現れず、結果なしとして扱われる。 ### 決定 13: すでに 1 か所にある操作を動かさないため、PR の巻き直しの開閉は移さない @@ -330,7 +332,7 @@ graph TD | 受け入れ条件 | 何で確かめるか | | --- | --- | | AC1・AC2 | 担当のプロンプトを取り出し、投稿の手順の語が 0 件であることを見る | -| AC3 | 一時の名前のファイルだけがある状態で取り込みを呼び、結果なしとして扱われることを見る | +| AC3 | 控えだけを正式の名前で置き、結果ファイルが無い状態で取り込みを呼び、結果なしとして扱われ、投稿が 0 件であることを見る | | AC4 | 控えを書かずに終わる偽の担当で 1 ラウンドを回し、偽の `gh` が受けたレビューの投稿が 0 件であることを見る | | AC5・AC6 | 取り込みの標準出力に、控えの本文の文字列が含まれないことを見る | | AC7 | 修正の取り込みを呼び、返信・決着・まとめの 3 種別が待ち行列へ積まれることを見る | @@ -339,7 +341,7 @@ graph TD | AC12 | 投稿の後・記録の前で止めた状態から取り込みをもう一度呼び、レビューが増えず記録に先客の URL が入ることを見る | | AC13・AC14 | 返信と決着とまとめについて、同じ項目を 2 度積んでも増えないことを見る | | AC15 | 記録に入る URL と件数が、偽の `gh` が返した応答の値と一致することを見る | -| AC16 | 偽の `gh` が HTTP 422 を返す状態で、本文へ退避して送り直すことを見る | +| AC16 | 偽の `gh` が HTTP 422 を返す状態で、応答の本文が行を解決できないことを示すときだけ本文へ退避して送り直し、示さないときは失敗として止まることを見る | | AC17 | 控えに 1 件あり、インラインが 422 で全件退避された実行で、結果なしにならないことを見る | | AC18・AC19 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | | AC20 | 送信の後・記録の前で取り込みを止め、同じ取り込みをもう一度呼ぶ結合の経路で、レビューが 1 件しか増えないことを見る | @@ -353,7 +355,7 @@ graph TD ## 未確認のまま残ること -- **差分の外を指す指摘が拒まれるときの応答の形。** HTTP 422 が返ることは GitHub の仕様として知られている。本文とインラインを同じ要求で送ったときにどちらが拒まれるかは未確認である。実装の最初の段で、偽ではない `gh` で 1 度確かめる +- **差分の外を指す指摘が拒まれるときの応答の形。** HTTP 422 が返ることは GitHub の仕様として知られている。応答の本文が行を解決できないことをどの語で示すかと、本文とインラインを同じ要求で送ったときにどちらが拒まれるかは未確認である。実装の最初の段で、偽ではない `gh` で 1 度確かめ、判定に使う語を契約へ書く - **決着の投稿を、すでに決着したスレッドへもう一度送ったときの応答。** 失敗にならないことを前提に置いているが、実行して確かめていない - **投稿者のアカウントが席ごとに違う環境があるかどうか。** いまの作業環境では 1 つだが、別の環境で担当ごとに別の認証を使う設定があると、照合の鍵の前提が変わる diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index 407b55a2..99e1dcb4 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -67,7 +67,9 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は - [ ] AC2: 担当へ渡すプロンプトは、指摘の控えと結果ファイルの 2 つだけを書かせる。 結果ファイルのうち担当が書くのは `event` と `by_severity` だけである。 `posted_as` と `comments_count` と `review_url` は投稿する側が埋め、`post_error` は無くなる -- [ ] AC3: 担当は結果ファイルと指摘の控えを、一時の名前で書いてから改名する。読み取り側が途中の内容を読むことがない +- [ ] AC3: 担当は結果ファイルと指摘の控えを、一時の名前で書いてから改名する。 + 改名の順序は控えが先、結果ファイルが後である。 + 控えだけが正式の名前で結果ファイルが無い状態では、取り込みは結果なしとして扱い、投稿を 0 件にする - [ ] AC4: 担当が指摘の控えを書かずに終わった実行では、その PR に新しいレビューが 1 件も増えない ### 書き込みは 1 か所から行う @@ -93,6 +95,7 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は - [ ] AC16: 差分の外を指す指摘は、投稿する側が本文へ退避する。 対象はインラインが HTTP 422 で拒まれたものである。 退避した指摘の `posted_to` は `body` になり、退避した件数が取り込みの出力に出る + 行を解決できないことを示さない HTTP 422 は退避せず、取り込みが失敗として止まる - [ ] AC17: 指摘の控えに指摘が 1 件以上あり、インラインとして送れたものが 0 件でも、その担当は結果なしにならない - [ ] AC32: 自分の Pull Request では、投稿する側が送信の時点で `posted_as` を `COMMENT` へ落とす。本来の判定(`intent`)は落とさない From 4a7bc4e9f67eaeba8d7487dc799cf4398b8e8f8c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:59:45 +0000 Subject: [PATCH 116/217] =?UTF-8?q?Test:=20=E7=B5=90=E6=9E=9C=E3=81=AA?= =?UTF-8?q?=E3=81=97=E3=81=AE=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=A8?= =?UTF-8?q?=E7=BE=A4=E3=81=AE=E8=A9=A6=E8=A1=8C=E3=81=AE=E5=8F=97=E3=81=91?= =?UTF-8?q?=E5=85=A5=E3=82=8C=E6=9D=A1=E4=BB=B6=E3=82=92=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E3=81=99=E3=82=8B=EF=BC=88#728=20#647=20#592=20#553=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取り込みの共通手順(範囲の確定・取り消し・記録)、適用の群の試行と担当の交代、 採用 0 件と項目の無い群、修正と最終ゲートの修正の結果なしを、それぞれ受け入れ条件の 単位で確かめる。 実装を壊して確かめた: 試行の上限を 99 にすると 2 件、担当の交代を止めると 3 件が 落ちる。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- .../cross-refactoring/tests/conftest.py | 2 +- .../tests/test_abandon_items.py | 144 +++++++++ .../tests/test_apply_attempts.py | 302 ++++++++++++++++++ .../tests/test_apply_rounds.py | 114 +++++++ .../cross-refactoring/tests/test_final_fix.py | 123 +++++++ .../cross-refactoring/tests/test_intake.py | 220 +++++++++++++ 6 files changed, 904 insertions(+), 1 deletion(-) create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_intake.py diff --git a/plugins/ndf/skills/cross-refactoring/tests/conftest.py b/plugins/ndf/skills/cross-refactoring/tests/conftest.py index 3e0b6fc9..38695c64 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/conftest.py +++ b/plugins/ndf/skills/cross-refactoring/tests/conftest.py @@ -46,7 +46,7 @@ def refactor() -> types.ModuleType: _MODULES = ( "commands.apply", "commands.converge", "commands.gate", "commands.report", "commands.setup", - "gitfacts", "outbound", "paths", "plan", "proposals", + "gitfacts", "intake", "outbound", "paths", "plan", "proposals", "rounds", "scope", "verify", "vocabulary", ) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py index 767986f6..4fe84471 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py @@ -987,3 +987,147 @@ def test_the_drop_is_reported_as_a_count_only( assert "取り消し 2 件" in out assert "内訳は改修計画にある" in out assert "R1-001 を見送りました" not in out + + +# ---------- 修正の担当が結果を残さないとき(#728 の決定 10) ---------- + +def _fix_state_with_group_impl(tmp_path, group_impl="agy", fix_rounds=0): + """群の担当と提案ラウンドの担当が違う状態。""" + state_path = _state(tmp_path, [_finding("R1-001")], groups=[{ + "apply_round": 1, "impl": group_impl, + "impl_model": {"requested": None, "observed": None}, + "items": ["R1-001", "R1-002"], "status": "applied", + "base_sha": "base0", "head_sha": None, "fix_rounds": fix_rounds, + "attempt": 1, + }]) + state = read_state(state_path) + state["rounds"][0]["fix_rounds"] = fix_rounds + state["rounds"][0]["fix_base_sha"] = "FIX_BASE" + state["rounds"][0]["fix_attempts"] = 1 + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + return state_path + + +def _fix_args(): + return type("A", (), {"id": 130, "round": 1})() + + +def test_merge_fix_reads_the_result_of_the_group_agent( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC23: 群の担当の結果を読む。提案ラウンドの担当の結果ではない。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + fact = _fix_commit() + patch_lib("git_out", lambda work, args, **k: ( + args[-1].replace("^{commit}", "") + if args[:2] == ["rev-parse", "--verify"] else "HEAD_NOW")) + patch_lib("commits_in_range", lambda work, base, head: [fact["sha"]]) + patch_lib("collect_commit_facts", + lambda work, shas, rng, cmd, branch, timeout=None: [fact]) + patch_lib("resolved_threads_on_github", lambda repo, pr: set()) + write_result(state_path, "agy-fix-r1", { + "resolved_thread_ids": [], "elapsed_seconds": 3, + "commits": [{"sha": fact["sha"]}], + }) + + cmd_converge.cmd_merge_fix(_fix_args()) + + assert read_state(state_path)["rounds"][0]["fix_rounds"] == 1 + + +def test_a_missing_fix_result_advances_the_fix_round( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC24: 修正の結果が無ければ、記録を残して修正ラウンドを 1 進める。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + + with pytest.raises(SystemExit) as e: + cmd_converge.cmd_merge_fix(_fix_args()) + + assert e.value.code == 2 + entry = read_state(state_path)["rounds"][0] + assert entry["fix_rounds"] == 1 + records = entry["apply_rounds"][0]["failed_attempts"] + assert [(r["phase"], r["impl"], r["reason"]) for r in records] == [ + ("fix", "agy", "missing")] + + +def test_the_abandon_check_passes_after_the_fix_rounds_run_out( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC24: 上限の回数だけ結果が無ければ、見送りの判定が 0 を返す。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + + for attempt in range(1, 4): + state = read_state(state_path) + state["rounds"][0]["fix_attempts"] = attempt + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + with pytest.raises(SystemExit): + cmd_converge.cmd_merge_fix(_fix_args()) + + assert read_state(state_path)["rounds"][0]["fix_rounds"] == 3 + cmd_converge.cmd_should_abandon(_fix_args()) + + +def test_the_same_fix_attempt_does_not_advance_the_round_twice( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC25: 検証を挟まず叩き直しても、修正ラウンドは進まない。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + + for _ in range(2): + with pytest.raises(SystemExit) as e: + cmd_converge.cmd_merge_fix(_fix_args()) + assert e.value.code == 2 + + entry = read_state(state_path)["rounds"][0] + assert entry["fix_rounds"] == 1 + assert len(entry["apply_rounds"][0]["failed_attempts"]) == 1 + + +def test_a_usage_limit_on_the_fix_jumps_to_the_cap( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC26: 起動し直しても解けない結末では、修正ラウンドを上限の値にする。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + (state_path.parent / "agy-fix-r1-monitor.json").write_text( + __import__("json").dumps({"reason": "usage_limit", "detail": "上限"}), + encoding="utf-8") + + with pytest.raises(SystemExit) as e: + cmd_converge.cmd_merge_fix(_fix_args()) + + assert e.value.code == 2 + assert read_state(state_path)["rounds"][0]["fix_rounds"] == 3 + cmd_converge.cmd_should_abandon(_fix_args()) + + +def test_a_missing_fix_result_reverts_the_commits_in_range( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC27: 結果が無くても、起点から先端までのコミットは取り消す。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "AFTER_REVERT") + patch_lib("commits_in_range", lambda work, base, head: ["fix2", "fix1"]) + + with pytest.raises(SystemExit): + cmd_converge.cmd_merge_fix(_fix_args()) + + assert [c[-1] for c in no_git if c[:2] == ["git", "revert"]] == ["fix2", "fix1"] + entry = read_state(state_path)["rounds"][0] + assert entry["fix_base_sha"] == "AFTER_REVERT" + assert entry["apply_rounds"][0]["failed_attempts"][0]["reverted"] == 2 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py new file mode 100644 index 00000000..5d6db597 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py @@ -0,0 +1,302 @@ +"""適用担当が結果を残さなかったときの試行と担当の交代(#647 / #728)。 + +**結果ファイルを置かずに「群を開く → 取り込む」を繰り返す。** 変更前は同じ群が +上限なしに開き直され、担当が作ったコミットは検証を受けずに残っていた。 +""" +from __future__ import annotations + +import json + +import pytest + +from crossref_helpers import make_state, read_state, write_result + +_ARGS_OPEN = {"id": 130, "round": 1} +_ARGS_MERGE = {"id": 130, "round": 1, "dry_run": False} + + +def _group(n, impl, items, **over): + base = { + "apply_round": n, "impl": impl, + "impl_model": {"requested": None, "observed": None}, + "items": list(items), "status": "pending", + "base_sha": None, "head_sha": None, "fix_rounds": 0, "attempt": 0, + } + base.update(over) + return base + + +def _entry(groups, items): + return { + "round": 1, "impl": "codex", "reviewers": ["agy", "kiro"], + "impl_model": {"requested": None, "observed": None}, + "reviewer_models": {}, "proposed": {}, "merged": len(items), + "adopted": len(items), "deferred": 0, + "items": list(items), + "apply_rounds": groups, "apply_round": 0, + "apply": {"applied": [], "failed": [], "base_sha": None, "head_sha": None}, + "fix_rounds": 0, "durations": {}, "reviews": [], + } + + +def _item(item_id, path="src/a.py", symbol="f"): + return { + "item_id": item_id, "round": 1, "path": path, "symbol": symbol, + "smell": "long_method", "technique": "extract_method", + "severity": "major", "status": "pending", "commits": [], + "estimated_diff_lines": 10, + } + + +@pytest.fixture +def two_groups(tmp_path): + """担当の違う 2 つの群を持つ状態ファイル。""" + groups = [_group(1, "agy", ["R1-001"]), _group(2, "codex", ["R1-002"])] + return make_state( + tmp_path, + items=[_item("R1-001"), _item("R1-002", path="src/b.py")], + rounds=[_entry(groups, ["R1-001", "R1-002"])], + phase="apply", outer_round=1, apply_seq=2, + ) + + +@pytest.fixture +def run(patch_lib, cmd_apply, env_tmp_dir, no_git): + """群を開く・取り込むを、git を呼ばずに実行する。""" + def _make(state_path, head="HEAD_NOW", in_range=None): + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: head) + patch_lib("commits_in_range", lambda work, base, head_: list(in_range or [])) + + def _open(): + return _exit_code(cmd_apply.cmd_next_apply_round, + type("A", (), dict(_ARGS_OPEN))()) + + def _merge(): + return _exit_code(cmd_apply.cmd_merge_apply, + type("A", (), dict(_ARGS_MERGE))()) + + return _open, _merge + return _make + + +def _exit_code(fn, args): + try: + fn(args) + except SystemExit as e: + return e.code + return 0 + + +# ---------- 結果を残さない担当(AC9 / AC10 / AC12) ---------- + +def test_the_first_failure_swaps_the_agent_and_keeps_the_group_pending( + two_groups, run +): + """AC9: 1 回目の結果なしでは、群は未着手のまま担当が替わる。""" + open_round, merge = run(two_groups) + + assert open_round() == 0 + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert group["status"] == "pending" + assert group["impl"] != "agy" + assert group["attempt"] == 1 + assert [(r["phase"], r["attempt"], r["impl"], r["reason"]) + for r in group["failed_attempts"]] == [("apply", 1, "agy", "missing")] + + +def test_the_second_failure_drops_the_group_and_defers_its_items(two_groups, run): + """AC10: 2 回目も残さなければ取り消し、見送りの理由に担当と理由を並べる。""" + open_round, merge = run(two_groups) + open_round(); merge() + second = read_state(two_groups)["rounds"][0]["apply_rounds"][0]["impl"] + + assert open_round() == 0 + assert merge() == 2 + + state = read_state(two_groups) + group = state["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "no_result") + assert group["attempt"] == 2 + assert state["items"][0]["status"] == "abandoned" + assert state["deferred_items"][0]["defer_reason"] == ( + f"実装担当が結果を残しませんでした(agy: missing → {second}: missing)" + ) + + +def test_a_broken_result_file_is_recorded_as_unparsable(two_groups, run): + """AC12: JSON として読めない結果と配列の結果も、結果なしとして閉じる。""" + open_round, merge = run(two_groups) + open_round() + (two_groups.parent / "agy-apply-r1-result.json").write_text( + '{"items": [', encoding="utf-8") + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert group["status"] == "pending" + assert [r["reason"] for r in group["failed_attempts"]] == ["unparsable"] + + +def test_a_json_array_result_is_also_unparsable(two_groups, run): + """AC12: 配列の結果ファイルも同じ扱いになる。""" + open_round, merge = run(two_groups) + open_round() + write_result(two_groups, "agy-apply-r1", [{"item_id": "R1-001"}]) + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert [r["reason"] for r in group["failed_attempts"]] == ["unparsable"] + + +# ---------- 繰り返しが有限回で終わる(AC11) ---------- + +def test_the_rounds_run_out_after_two_attempts_per_group(two_groups, run): + """AC11: 結果を 1 つも置かなければ、開く操作は 5 回目で尽きる。""" + open_round, merge = run(two_groups) + + opened = 0 + while open_round() == 0: + opened += 1 + merge() + assert opened <= 8, "群を開く操作が止まらない" + + assert opened == 4 + groups = read_state(two_groups)["rounds"][0]["apply_rounds"] + assert [g["status"] for g in groups] == ["dropped", "dropped"] + + +# ---------- 交代先の決め方(AC13 / AC14) ---------- + +def test_the_replacement_differs_from_the_agent_that_failed(tmp_path, run): + """AC13: 輪番が 1 周しても、失敗した担当とは違う担当が出る。""" + groups = [_group(n, "codex" if n == 1 else "agy", [f"R1-00{n}"]) + for n in range(1, 5)] + state_path = make_state( + tmp_path, + items=[_item(f"R1-00{n}", path=f"src/{n}.py") for n in range(1, 5)], + rounds=[_entry(groups, [f"R1-00{n}" for n in range(1, 5)])], + phase="apply", outer_round=1, apply_seq=4, + ) + open_round, merge = run(state_path) + + open_round(); merge() + + saved = read_state(state_path)["rounds"][0]["apply_rounds"] + assert saved[0]["impl"] != "codex" + assert [g["impl"] for g in saved[1:]] == ["agy", "agy", "agy"] + assert read_state(state_path)["apply_seq"] > 4 + + +def test_no_replacement_left_with_a_usage_limit_drops_the_group_at_once( + two_groups, run, patch_lib +): + """AC14: 交代先が無く利用上限なら、1 回目の失敗で群を取り消す。""" + patch_lib("impl_for_seq", lambda state, seq: ("agy", None)) + open_round, merge = run(two_groups) + open_round() + (two_groups.parent / "agy-apply-r1-monitor.json").write_text( + json.dumps({"reason": "usage_limit", "detail": "上限"}), encoding="utf-8") + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "no_result") + + +def test_no_replacement_left_without_a_usage_limit_keeps_the_same_agent( + two_groups, run, patch_lib +): + """AC14: 交代先が無く、起動し直せる結末なら同じ担当で 2 回目を開く。""" + patch_lib("impl_for_seq", lambda state, seq: ("agy", None)) + open_round, merge = run(two_groups) + open_round() + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["impl"]) == ("pending", "agy") + assert open_round() == 0 + assert read_state(two_groups)["rounds"][0]["apply_rounds"][0]["attempt"] == 2 + + +# ---------- 再開と試行の番号(AC15 / AC16 / AC17 / AC18) ---------- + +def test_opening_twice_without_a_merge_does_not_advance_the_attempt(two_groups, run): + """AC15: 取り込みを挟まずに 2 回開いても、試行の番号は進まない。""" + open_round, _ = run(two_groups) + + open_round() + open_round() + + assert read_state(two_groups)["rounds"][0]["apply_rounds"][0]["attempt"] == 1 + + +def test_an_unverified_baseline_stops_before_reading_the_result( + tmp_path, patch_lib, cmd_apply, env_tmp_dir, no_git +): + """AC16: 着手前のテストが成功と確認できていなければ、結果を読まずに止まる。""" + groups = [_group(1, "agy", ["R1-001"])] + state_path = make_state( + tmp_path, items=[_item("R1-001")], + rounds=[_entry(groups, ["R1-001"])], phase="apply", outer_round=1, + baseline_test={"command": "pytest -q", "status": "red", "checked_at": "x"}, + ) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + read_calls: list[str] = [] + patch_lib("read_result", + lambda *a, **k: read_calls.append("読んだ") or (_ for _ in ()).throw( + AssertionError("結果を読んではいけない"))) + + code = _exit_code(cmd_apply.cmd_merge_apply, type("A", (), dict(_ARGS_MERGE))()) + + assert code == 4 + assert read_calls == [] + assert read_state(state_path)["items"][0]["status"] == "blocked" + + +def test_every_exit_2_leaves_the_group_dropped_or_pending_with_a_record( + two_groups, run +): + """AC17: 終了コード 2 の後の群は、取り消し済みか、記録を持つ未着手である。""" + open_round, merge = run(two_groups) + + for _ in range(4): + if open_round() != 0: + break + assert merge() == 2 + for group in read_state(two_groups)["rounds"][0]["apply_rounds"]: + if group["status"] == "pending" and group.get("attempt"): + assert group.get("failed_attempts"), group + else: + assert group["status"] in {"pending", "dropped"}, group + + +def test_both_sides_follow_the_reopening_decision(two_groups, run, patch_lib): + """AC18: 開き直しの判定を差し替えると、開く側も取り込み側も従う。""" + patch_lib("group_reopening", lambda group: "exhausted") + open_round, _ = run(two_groups) + + assert open_round() == 1 + + groups = read_state(two_groups)["rounds"][0]["apply_rounds"] + assert [g["status"] for g in groups] == ["dropped", "dropped"] + assert [g["drop_reason"] for g in groups] == ["no_result", "no_result"] + + +# ---------- 輪番から担当を引く関数(AC49) ---------- + +def test_the_group_assignment_goes_through_the_single_rotation_function( + rounds, monkeypatch +): + """AC49: 輪番から担当を引く関数を差し替えると、群の担当がそれに従う。""" + state = {"host": "claude", "models": {"kiro": "auto"}} + + impl, requested = rounds.impl_for_seq(state, 3) + + assert impl in {"claude", "codex", "agy", "kiro"} + assert requested == state["models"].get(impl) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py index 9e779758..2daa0f85 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py @@ -323,3 +323,117 @@ def test_phase_after_group_returns_to_propose_when_there_is_no_group(rounds): entry = _round_with_groups([], items=()) assert rounds.phase_after_group(entry) == "propose" + + +# ---------- 採用 0 件と項目の無い群(#592) ---------- + +def _empty_group(n, items=(), **over): + base = { + "apply_round": n, "impl": "codex", + "impl_model": {"requested": None, "observed": None}, + "items": list(items), "status": "pending", + "base_sha": None, "head_sha": None, "fix_rounds": 0, "attempt": 0, + } + base.update(over) + return base + + +def test_no_adopted_proposal_leaves_the_group_list_empty( + patch_lib, refactor, cmd_apply, tmp_path, env_tmp_dir, monkeypatch +): + """AC19: 採用 0 件のテスト整備ラウンドでは群を作らず、開く操作が 1 回で尽きる。""" + state_path = make_state( + tmp_path, rounds=[{**round_of(), "kind": "test"}], + phase="propose", outer_round=1, round_kind="test", + ) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "base0") + for runtime in ("codex", "agy", "kiro"): + write_result(state_path, f"{runtime}-propose-rf130-r1", {"items": []}) + refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) + + assert read_state(state_path)["rounds"][0]["apply_rounds"] == [] + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + assert e.value.code == 1 + assert read_state(state_path)["rounds"][0]["apply_rounds"] == [] + + +def test_a_state_without_the_group_key_still_opens_the_whole_round( + patch_lib, refactor, tmp_path, env_tmp_dir, monkeypatch, capsys +): + """AC20: 群の鍵を持たない状態ファイルは、ラウンド全体を 1 群として開く。""" + entry = round_of(items=["R1-001"]) + entry["impl"] = "codex" + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + refactor.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + + assert "APPLY_ROUND=1" in capsys.readouterr().out + groups = read_state(state_path)["rounds"][0]["apply_rounds"] + assert [g["items"] for g in groups] == [["R1-001"]] + + +def test_a_merged_group_with_nothing_adopted_is_dropped_as_empty( + patch_lib, cmd_apply, tmp_path, env_tmp_dir, no_git +): + """AC21: 取り込み済みで採用 0 件の群(項目なし)を取り消し済みに直す。""" + entry = round_of() + entry["apply_rounds"] = [_empty_group( + 1, status="applied", base_sha="base0", attempt=1)] + entry["apply_round"] = 1 + entry["apply"] = {"apply_round": 1, "applied": [], "failed": [], + "base_sha": "base0", "head_sha": "h", "merged_at": "x"} + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + assert e.value.code == 2 + + group = read_state(state_path)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "empty") + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + assert e.value.code == 1 + + +def test_a_pending_group_without_items_is_dropped_before_it_opens( + patch_lib, cmd_apply, tmp_path, env_tmp_dir, capsys +): + """AC22: 項目の無い未着手の群は開かれず、次の群があればそちらを開く。""" + entry = round_of(items=["R1-002"]) + entry["apply_rounds"] = [_empty_group(1), _empty_group(2, items=["R1-002"])] + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + + assert "APPLY_ROUND=2" in capsys.readouterr().out + groups = read_state(state_path)["rounds"][0]["apply_rounds"] + assert (groups[0]["status"], groups[0]["drop_reason"]) == ("dropped", "empty") + + +def test_a_round_whose_only_group_has_no_item_runs_out( + patch_lib, cmd_apply, tmp_path, env_tmp_dir +): + """AC22: 項目の無い群だけなら、開く操作は 1 を返す。""" + entry = round_of() + entry["apply_rounds"] = [_empty_group(1)] + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + + assert e.value.code == 1 + group = read_state(state_path)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "empty") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py b/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py index ba0c22f9..d8b5adff 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py @@ -369,3 +369,126 @@ def test_the_propose_result_file_carries_the_round_number(paths): 始まった時点で 1 巡目の提案内容が失われる。 """ assert paths.stem_for("codex", "propose", 130, 2) == "codex-propose-rf130-r2" + + +# ---------- 最終ゲートの修正で結果が無いとき(#674 / #728 の決定 11) ---------- + +def test_a_missing_final_fix_result_reverts_and_moves_the_base( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """AC28: 結果が無ければ取り消し、起点を取り消し後の先端へ進める。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + assert e.value.code == 2 + gate = read_state(state_path)["final_gate"] + assert len(merge_spy["reverted"]) == 1 + assert gate["fix_base_sha"] == "HEADSHA" + assert [(r["phase"], r["impl"], r["reason"]) for r in gate["failed_attempts"]] == [ + ("final-fix", "codex", "missing")] + assert "fix_commits" not in gate + + +def test_the_next_gate_does_not_see_the_reverted_commits( + patch_lib, cmd_gate, tmp_path, env_tmp_dir, merge_spy, gate_spy +): + """AC29: 取り消した後の最終ゲートは、修正のコミットを数に入れない。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + with pytest.raises(SystemExit): + cmd_gate.cmd_merge_final_fix(_args()) + + gate_spy["test_code"] = 0 + cmd_gate.cmd_final_gate(_args()) + + gate = read_state(state_path)["final_gate"] + assert gate.get("fix_commits", []) == [] + assert gate["status"] == "passed" + + +def test_a_usage_limit_on_the_final_fix_jumps_to_the_cap( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """AC30: 起動し直しても解けない結末では、修正ラウンドを上限の値にする。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + (state_path.parent / "codex-final-fix-monitor.json").write_text( + __import__("json").dumps({"reason": "usage_limit", "detail": "上限"}), + encoding="utf-8") + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + assert e.value.code == 2 + assert read_state(state_path)["final_gate"]["fix_rounds"] == 3 + + +def test_the_gate_after_the_cap_reports_without_reverting( + cmd_gate, tmp_path, env_tmp_dir, merge_spy, gate_spy +): + """AC30: 上限に達した後の最終ゲートは、落ちても取り消さず報告で終わる。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + (state_path.parent / "codex-final-fix-monitor.json").write_text( + __import__("json").dumps({"reason": "usage_limit", "detail": "上限"}), + encoding="utf-8") + with pytest.raises(SystemExit): + cmd_gate.cmd_merge_final_fix(_args()) + + gate_spy["test_code"] = 1 + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_final_gate(_args()) + + assert e.value.code == 1 + assert read_state(state_path)["final_gate"]["status"] == "failed" + + +def test_a_verified_final_fix_keeps_no_failure_record( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """AC31: 検証を通る修正は、変更前と同じく取り込まれ、記録を持たない。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + write_result(state_path, "codex-final-fix", + {"elapsed_seconds": 7, "commits": [{"sha": "C1FULL"}]}) + + cmd_gate.cmd_merge_final_fix(_args()) + + gate = read_state(state_path)["final_gate"] + assert "failed_attempts" not in gate + assert gate["fix_commits"] == ["C1FULL"] + + +def test_the_same_final_fix_attempt_is_closed_only_once( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """同じ修正ラウンドで叩き直しても、結果ファイルを読まずに同じ終了コードを返す。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + with pytest.raises(SystemExit): + cmd_gate.cmd_merge_final_fix(_args()) + write_result(state_path, "codex-final-fix", {"commits": [{"sha": "C1FULL"}]}) + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + assert e.value.code == 2 + assert len(read_state(state_path)["final_gate"]["failed_attempts"]) == 1 + + +def test_the_final_fix_agent_comes_from_the_single_rotation_function( + patch_lib, cmd_gate, tmp_path, env_tmp_dir, gate_spy +): + """AC49: 輪番から担当を引く関数を差し替えると、最終ゲートの修正担当も従う。""" + state_path = _gate_state(tmp_path) + env_tmp_dir(state_path) + gate_spy["test_code"] = 1 + patch_lib("impl_for_seq", lambda state, seq: ("kiro", "auto")) + + with pytest.raises(SystemExit): + cmd_gate.cmd_final_gate(_args()) + + assert read_state(state_path)["final_gate"]["impl"] == "kiro" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_intake.py b/plugins/ndf/skills/cross-refactoring/tests/test_intake.py new file mode 100644 index 00000000..18fda83f --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_intake.py @@ -0,0 +1,220 @@ +"""取り込みの共通手順(#728)。 + +3 つの取り込み(適用・修正・最終ゲートの修正)が、結果を残さなかった起動を同じ +手順で閉じることを確かめる。**共通層の読み取りは差し替えない。** 一時ディレクトリに +結果ファイルと監視の結果ファイルを置いて本物を通す。 +""" +from __future__ import annotations + +import json +import types + +import pytest + +from crossref_helpers import make_state, read_state + + +def _outcome(reason=None, payload=None, detail="", relaunch=True): + """`LaunchOutcome` と同じ欄を持つ値。読む側は 5 つの欄しか見ない。""" + return types.SimpleNamespace( + payload=payload, reason=reason, detail=detail, monitor=None, + relaunch_same_agent=relaunch, + ) + + +def _write_monitor(state_path, stem, reason, detail="打ち切りました"): + out = state_path.parent / f"{stem}-monitor.json" + out.write_text( + json.dumps({"reason": reason, "detail": detail}, ensure_ascii=False), + encoding="utf-8", + ) + return out + + +# ---------- 結末の読み取り(AC1 / AC2 / AC3) ---------- + +def test_a_missing_result_file_is_read_as_a_value(gitfacts, tmp_path, capsys): + """AC1: 結果ファイルが無くても中断せず、何も出力しない。""" + state = {"id": 130, "tmp_dir": str(tmp_path)} + + outcome = gitfacts.read_result(state, "agy", "apply", 1) + + assert (outcome.payload, outcome.reason) == (None, "missing") + assert capsys.readouterr() == ("", "") + + +def test_the_monitor_reason_decides_whether_the_same_agent_can_be_relaunched( + gitfacts, tmp_path +): + """AC2: 無進捗は起動し直せる。利用上限は起動し直せない。""" + state = {"id": 130, "tmp_dir": str(tmp_path)} + state_path = tmp_path / "dummy" + + (tmp_path / "agy-apply-r1-monitor.json").write_text( + json.dumps({"reason": "stalled", "detail": "無進捗"}), encoding="utf-8") + stalled = gitfacts.read_result(state, "agy", "apply", 1) + + (tmp_path / "claude-apply-r1-monitor.json").write_text( + json.dumps({"reason": "usage_limit", "detail": "上限"}), encoding="utf-8") + limited = gitfacts.read_result(state, "claude", "apply", 1) + + assert (stalled.reason, stalled.relaunch_same_agent) == ("stalled", True) + assert (limited.reason, limited.relaunch_same_agent) == ("usage_limit", False) + assert state_path.exists() is False + + +def test_the_stem_matches_the_template_the_orchestrator_passes_to_the_monitor(paths): + """AC3: 名前の幹は、骨組みが監視へ渡す雛形を担当名で埋めた値と一致する。""" + skill = ( + paths.pathlib.Path(__file__).resolve().parents[1] / "SKILL.md" + ).read_text(encoding="utf-8") + + expected = { + "apply": "{agent}-apply-r$ROUND", + "fix": "{agent}-fix-r$ROUND", + "final-fix": "{agent}-final-fix", + } + for phase, template in expected.items(): + assert f'--stem-template "{template}"' in skill, phase + built = template.replace("{agent}", "codex").replace("$ROUND", "2") + assert paths.stem_for("codex", phase, 130, 2) == built + + +# ---------- 取り込みの共通手順(AC4〜AC8) ---------- + +def _scope(intake, holder, records, phase, base_key="fix_base_sha", mirror=None): + return intake.IntakeScope( + holder=holder, base_key=base_key, records=records, phase=phase, + attempt=1, impl="agy", label="R1-A1", mirror=mirror, + ) + + +@pytest.fixture +def one_round(tmp_path): + """1 提案ラウンド・1 群の状態ファイル。""" + return make_state( + tmp_path, + items=[{"item_id": "R1-001", "path": "src/a.py", "symbol": "f", + "smell": "long_method", "status": "pending", "round": 1, + "commits": []}], + rounds=[{ + "round": 1, "impl": "codex", "items": ["R1-001"], + "apply_base_sha": "base0", "fix_base_sha": "base0", + "fix_rounds": 0, "fix_attempts": 1, + "apply_rounds": [{ + "apply_round": 1, "impl": "agy", + "impl_model": {"requested": None, "observed": None}, + "items": ["R1-001"], "status": "pending", + "base_sha": "base0", "head_sha": None, "fix_rounds": 0, + "attempt": 1, + }], + "apply_round": 1, "apply": {"applied": [], "failed": []}, + "durations": {}, "reviews": [], + }], + final_gate={"fix_rounds": 1, "checks": [], "impl": "agy", + "fix_base_sha": "base0"}, + ) + + +@pytest.mark.parametrize( + "phase,base_key,records_from", + [("apply", "apply_base_sha", "group"), + ("fix", "fix_base_sha", "group"), + ("final-fix", "fix_base_sha", "gate")], +) +def test_a_commit_in_range_is_reverted_and_the_base_moves_to_the_new_head( + intake, patch_lib, one_round, no_git, phase, base_key, records_from +): + """AC4 / AC5: 範囲のコミットを取り消し、起点を取り消し後の先端へ進める。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + gate = state["final_gate"] + holder = gate if records_from == "gate" else entry + records = gate if records_from == "gate" else group + patch_lib("commits_in_range", lambda work, base, head: ["c2", "c1"]) + patch_lib("git_out", lambda work, args, **kw: "newhead") + scope = _scope(intake, holder, records, phase, base_key=base_key, + mirror=group if phase == "apply" else None) + + closed = intake.close_without_result( + one_round, state, scope, _outcome(reason="stalled", detail="無進捗")) + + assert (closed.reverted, closed.range_unknown) == (2, False) + assert holder[base_key] == "newhead" + if phase == "apply": + assert group["base_sha"] == "newhead" + assert records["failed_attempts"] == [{ + "phase": phase, "attempt": 1, "impl": "agy", "reason": "stalled", + "detail": "無進捗", "at": records["failed_attempts"][0]["at"], "reverted": 2, + }] + assert [c[-1] for c in no_git if c[:2] == ["git", "revert"]] == ["c2", "c1"] + + +def test_an_empty_range_runs_neither_revert_nor_push( + intake, patch_lib, one_round, no_git +): + """AC6: 範囲にコミットが無ければ、取り消しも公開もしない。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + patch_lib("commits_in_range", lambda work, base, head: []) + patch_lib("git_out", lambda work, args, **kw: "head0") + scope = _scope(intake, entry, group, "fix") + + closed = intake.close_without_result(one_round, state, scope, _outcome("missing")) + + assert closed.reverted == 0 + assert [c for c in no_git if c[:2] in (["git", "revert"], ["git", "push"])] == [] + assert len(group["failed_attempts"]) == 1 + + +def test_the_same_attempt_is_not_recorded_twice(intake, patch_lib, one_round): + """AC7: 同じ工程・同じ試行番号は 1 件しか記録しない。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + patch_lib("commits_in_range", lambda work, base, head: []) + patch_lib("git_out", lambda work, args, **kw: "head0") + scope = _scope(intake, entry, group, "fix") + + intake.close_without_result(one_round, state, scope, _outcome("missing")) + assert intake.already_closed(scope) is True + + intake.close_without_result(one_round, state, scope, _outcome("missing")) + assert len(group["failed_attempts"]) == 2, "呼べば足すのは共通手順の責務である" + + +def test_a_range_that_cannot_be_determined_records_nothing( + intake, patch_lib, one_round, no_git +): + """AC8: 範囲を確定できないときは、取り消しも記録もしない。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + patch_lib("commits_in_range", lambda work, base, head: None) + patch_lib("git_out", lambda work, args, **kw: "head0") + scope = _scope(intake, entry, group, "fix") + + closed = intake.close_without_result(one_round, state, scope, _outcome("missing")) + + assert closed.range_unknown is True + assert "failed_attempts" not in group + assert [c for c in no_git if c[:2] == ["git", "revert"]] == [] + + +def test_the_failed_agents_are_listed_in_the_order_they_were_recorded( + intake, patch_lib, one_round +): + """交代先を決めるために、失敗した担当を記録の順で読めること。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + group["failed_attempts"] = [ + {"phase": "apply", "attempt": 1, "impl": "agy", "reason": "stalled"}, + {"phase": "fix", "attempt": 1, "impl": "kiro", "reason": "missing"}, + {"phase": "apply", "attempt": 2, "impl": "codex", "reason": "missing"}, + ] + scope = _scope(intake, entry, group, "apply", base_key="apply_base_sha") + + assert intake.failed_impls(scope) == ["agy", "codex"] From 72bc01be772b22e1d3f7a22437d0fe4c04bc16ca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:03:11 +0000 Subject: [PATCH 117/217] =?UTF-8?q?Test:=20assign=20=E3=81=AE=E4=B8=8B?= =?UTF-8?q?=E9=99=90=E6=9C=AA=E6=BA=80=E3=82=A8=E3=83=A9=E3=83=BC=E3=81=A8?= =?UTF-8?q?=E5=85=A8=E9=99=A4=E5=A4=96=E6=99=82=E3=81=AE=20resolve=5Fparti?= =?UTF-8?q?cipants=20=E3=82=92=E3=83=86=E3=82=B9=E3=83=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R2-001: assign で round_no < 1 の場合に AssignmentError が送出されるエラー経路を固定 R2-002: resolve_participants で母集合の全メンバーを exclude に指定した下限境界の振る舞いを固定 Item-Id: R2-001 Round: 2 Impl-Runtime: agy Impl-Model: default --- .../ndf/scripts/tests/test_lib_assignment.py | 12 ++++++++++++ .../ndf/scripts/tests/test_lib_participants.py | 17 +++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index 1925f12a..2259a9ae 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -117,6 +117,18 @@ def test_assign_keeps_the_eight_round_rotation(assignment, host): assert all(impl not in reviewers for impl, reviewers in actual) +@pytest.mark.parametrize("host", ("claude", "codex", "agy", "kiro")) +def test_assign_rejects_a_bad_round(assignment, host): + """round_no < 1 の場合に AssignmentError が送出される(R2-001)。""" + for round_no in (0, -1): + with pytest.raises( + assignment.AssignmentError, + match=r"^ラウンド番号は 1 以上です:", + ) as excinfo: + assignment.assign(round_no, host) + assert "ラウンド番号は 1 以上です" in str(excinfo.value) + + # ---------- 適用の輪番(#727。cross-refactoring が使う) ---------- def test_impl_assign_rotates_over_the_participants_starting_after_the_host(assignment): diff --git a/plugins/ndf/scripts/tests/test_lib_participants.py b/plugins/ndf/scripts/tests/test_lib_participants.py index 59398b2f..0c479c19 100644 --- a/plugins/ndf/scripts/tests/test_lib_participants.py +++ b/plugins/ndf/scripts/tests/test_lib_participants.py @@ -215,3 +215,20 @@ def test_included_and_excluded_are_kept_in_fixed_order(assignment): ) assert p.excluded == ["agy", "kiro"] assert p.available == ["claude", "codex"] + + +def test_excluding_all_pool_members_leaves_empty_available(assignment): + """母集合の全員を exclude に指定した下限境界の振る舞い(R2-002)。""" + probe = _probe() + p = assignment.resolve_participants( + ["codex", "agy", "kiro"], + host="claude", + exclude=["codex", "agy", "kiro"], + probe=probe, + ) + + assert probe.calls == [[]] + assert p.available == [] + assert p.unavailable == {} + assert p.excluded == ["codex", "agy", "kiro"] + From 13e01c50e4bcc13fec5f38389327eeb820c51ce8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:04:10 +0000 Subject: [PATCH 118/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 66 +++++++++++++++++++++++++++++++- 1 file changed, 65 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index ea98b2c0..6afffc8f 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -65,7 +65,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| normal | unit | — | agy | 検証中 | 1 | +| normal | unit | — | agy | 採用 | 1 | **なぜ**: assignment.py で新設された seat_runtime(seat: str) は、席名から基底ランタイム名を取り出す共通層関数であり、結果受け口・起動スクリプト・監視処理で広く使われる。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが存在しない。接尾辞なしのランタイム名(claude, codex, agy, kiro)および同一ランタイムの副席名(-2〜-9 接尾辞)から正確にランタイム名が抽出される正常系の振る舞いを共通層単体テストとして固定する必要がある。 @@ -73,6 +73,68 @@ 2. ALL_RUNTIMES の全ランタイム名('claude', 'codex', 'agy', 'kiro')をそのまま渡した場合に、同一のランタイム名が返ることを検証する。 3. ハイフン付き席名('kiro-2', 'claude-9', 'agy-3' 等)を渡した場合に、接尾辞を除去した基底ランタイム名が正しく返ることを検証する。 +## ラウンド 2(実装 agy / レビュー codex / kiro) + +### R2-001 — `plugins/ndf/scripts/lib/assignment.py#assign` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | unit | — | codex / agy | 検証中 | 1 | + +**なぜ**: assign は 8 ラウンド周期の割り当てを行う公開関数であり正常系は固定されているが、round_no < 1(0 や負数)が渡された場合に AssignmentError を送出するエラー経路が scripts/tests 内で固定されていない。 + +**手順**: 1. 有効な各ホスト(claude, codex, agy, kiro)について assignment.assign(0, host) および assignment.assign(-1, host) を呼び出す +2. どちらも assignment.AssignmentError が送出されることを検証する +3. 送出された例外メッセージに「ラウンド番号は 1 以上です」が含まれることを検証する + +### R2-002 — `plugins/ndf/scripts/lib/assignment.py#resolve_participants` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| boundary | unit | — | codex / agy | 検証中 | 1 | + +**なぜ**: resolve_participants で母集合の全メンバーを exclude に指定し、参加可能なメンバーが 0 件になる下限境界の振る舞い(空一覧で認証確認が呼ばれ、available が空リスト、excluded が固定順で記録されること)が固定されていない。 + +**手順**: 1. 母集合の全員(例: ['codex', 'agy', 'kiro'])を exclude に指定し、記録用プローブを渡して resolve_participants を呼び出す +2. プローブが空の一覧 [] で 1 回だけ呼ばれることを検証する +3. 戻り値の Participants において available が []、unavailable が {}、excluded が固定順(['codex', 'agy', 'kiro'])で保持されることを検証する + +### R2-003 — `plugins/ndf/scripts/lib/assignment.py#review_assign` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| boundary | unit | — | agy / kiro | 未着手 | 0 | + +**なぜ**: review_assign の round_no < 1 の下限境界条件で AssignmentError を送出する振る舞いが scripts/tests 内で固定されていない。同モジュールの impl_assign や review_seats には round_no < 1 の境界テストがあるが、review_assign だけ抜けている。 + +**手順**: 1. test_lib_assignment.py で assignment.review_assign(0, "claude") および assignment.review_assign(-1, "claude") を呼び出す +2. どちらの呼び出しでも assignment.AssignmentError が送出されることを検証する +3. 例外メッセージに「ラウンド番号は 1 以上です」が含まれることを検証する + +### R2-004 — `plugins/ndf/scripts/lib/assignment.py#review_assign` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| branch | unit | — | agy / kiro | 未着手 | 0 | + +**なぜ**: review_assign は適用の役を持たない工程が使う公開入口だが、scripts/tests には直接の固定が無い。in-scope の test_lib_assignment.py は assign / impl_assign / review_seats を固定するだけで、この関数の輪番(母集合3者から dropped=(round_no-1)%3 を外す各分岐)は通っていない。out-of-scope の cross-review テストは _round_reviewers の照合オラクルとして呼ぶだけで、この関数自身の戻り値を固定していない。 + +**手順**: 1. test_lib_assignment.py の assignment フィクスチャで各ホスト(claude, codex, agy, kiro)について review_assign(round_no, host) を round 1..6 で呼び出す +2. 各ホストで返る担当ペアの一覧が 3 ラウンド周期で循環し、現状の決定結果(例: claude は [['agy', 'kiro'], ['codex', 'kiro'], ['codex', 'agy']] が 2 周する)と完全一致することを検証する +3. 返されるレビュー担当が常に 2 者であり、指定したホスト自身を含まないことを併せて検証する + +### R2-005 — `plugins/ndf/scripts/lib/assignment.py#review_assign` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| error | unit | — | codex / agy | 未着手 | 0 | + +**なぜ**: review_assign に HOST_RUNTIMES に含まれない無効なホスト名が渡された場合、内部の review_pool から AssignmentError(「ホストになれないランタイムです」)が送出されるエラー経路が固定されていない。 + +**手順**: 1. test_lib_assignment.py で assignment.review_assign(1, "gemini") や assignment.review_assign(1, "unknown") を呼び出す +2. assignment.AssignmentError が送出されることを検証する +3. 例外メッセージに「ホストになれないランタイムです」が含まれることを検証する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -84,3 +146,5 @@ | 1 | `plugins/ndf/scripts/lib/statefile.py#save` | error | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/skills/cross-review/scripts/critique.sh#select_targets` | branch | 1 ラウンドの採用上限 5 件を超えた | | 1 | `plugins/ndf/scripts/lib/assignment.py#seat_runtime` | boundary | コミット 36dd097d4dff427b0de545bcd0cdc0de0e7b74fb にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | +| 2 | `plugins/ndf/scripts/lib/assignment.py#review_seats` | boundary | 1 ラウンドの採用上限 5 件を超えた | +| 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#main` | normal | 1 ラウンドの採用上限 5 件を超えた | From 9cda45f0ad31e982e7f943ec66e8e8f7bcdacdaa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:10:37 +0000 Subject: [PATCH 119/217] =?UTF-8?q?Docs:=20=E4=BB=B6=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E5=87=BA=E6=89=80=E3=82=92=E3=82=A4=E3=83=B3=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=81=AE=E9=80=81=E4=BF=A1=E6=88=90=E5=8A=9F=E6=95=B0?= =?UTF-8?q?=E3=81=B8=E6=94=B9=E3=82=81=E3=80=81=E5=AF=BE=E5=BF=9C=E3=81=AE?= =?UTF-8?q?=E6=AE=B5=E8=90=BD=E3=82=92=E8=A1=A8=E3=81=B8=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビューの投稿の応答は件数の項目を持たないため、AC15・決定 8・テスト設計の 3 箇所で「URL と件数を応答から取る」としていた記述を、URL は応答から、 件数は送信に成功したインラインの数から取ると改めた。 あわせて「置き換える既存の設計との対応」の冒頭にあった 152 字の 1 文を、 足す先とその中身を並べた表へ分けた。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 11 ++++++++--- issues/issue-730-583-requirements.md | 4 +++- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index 4aaaa0cc..bc1dff32 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -131,7 +131,7 @@ ### 決定 8: 申告と実物の食い違いを無くすため、申告件数と実数の突き合わせをやめ、送信の応答を記録にする -投稿するのがレビューを回す側になるため、申告を受け取る相手がいない。記録に入る投稿の URL と件数は、送信の応答から取る。**申告件数を GitHub の実数と比べて中断する処理は取り除く。** +投稿するのがレビューを回す側になるため、申告を受け取る相手がいない。記録に入る投稿の URL は送信の応答から取り、件数は送信に成功したインラインの数から取る。**レビューの投稿の応答は件数の項目を持たない。** **申告件数を GitHub の実数と比べて中断する処理は取り除く。** この突き合わせは、担当が投稿したことを確かめるために置かれていた。投稿する側と記録する側が同じになるため、確かめる対象が無くなる。 @@ -340,7 +340,7 @@ graph TD | AC10・AC11 | 偽の `gh` が先客を返す状態で投稿を積んで流し、新しい投稿が 0 件で項目が取り除かれることを見る | | AC12 | 投稿の後・記録の前で止めた状態から取り込みをもう一度呼び、レビューが増えず記録に先客の URL が入ることを見る | | AC13・AC14 | 返信と決着とまとめについて、同じ項目を 2 度積んでも増えないことを見る | -| AC15 | 記録に入る URL と件数が、偽の `gh` が返した応答の値と一致することを見る | +| AC15 | 記録に入る URL が偽の `gh` の応答の値と一致し、件数が実際に送ったインラインの数と一致することを見る | | AC16 | 偽の `gh` が HTTP 422 を返す状態で、応答の本文が行を解決できないことを示すときだけ本文へ退避して送り直し、示さないときは失敗として止まることを見る | | AC17 | 控えに 1 件あり、インラインが 422 で全件退避された実行で、結果なしにならないことを見る | | AC18・AC19 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | @@ -373,7 +373,12 @@ graph TD ## 置き換える既存の設計との対応 -**この文書は[置き換える前の設計](issue-662-598-537-619-584-583-design.md)の決定 18・19 を置き換える。** 既存の設計文書そのものは書き換えない。置き換え先を指す段落を足すのは、[置き換える前の要求](issue-662-598-537-619-584-583-requirements.md)と[置き換える前の契約](issue-662-598-537-619-584-583-design-contracts.md)の 2 本である(決定 1)。 +**この文書は[置き換える前の設計](issue-662-598-537-619-584-583-design.md)の決定 18・19 を置き換える。** 既存の設計文書そのものは書き換えない。置き換え先を指す段落を足す先は、次の 2 本である(決定 1)。 + +| 足す先 | 何を持つ文書か | +| --- | --- | +| [置き換える前の要求](issue-662-598-537-619-584-583-requirements.md) | 置き換わる受け入れ条件(AC63〜AC67) | +| [置き換える前の契約](issue-662-598-537-619-584-583-design-contracts.md) | その確かめ方 | | 既存の決定 | この文書 | 扱い | | --- | --- | --- | diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index 99e1dcb4..845b088c 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -91,7 +91,9 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は ### 申告をやめ、送った結果を記録にする -- [ ] AC15: 記録に入る投稿の URL と件数は、送信の応答から取る。担当の申告件数と GitHub の実数を突き合わせる処理は無くなる +- [ ] AC15: 記録に入る投稿の URL は、送信の応答から取る。 + 件数は、送信に成功したインラインの数から取る(レビューの投稿の応答は件数の項目を持たない)。 + 担当の申告件数と GitHub の実数を突き合わせる処理は無くなる - [ ] AC16: 差分の外を指す指摘は、投稿する側が本文へ退避する。 対象はインラインが HTTP 422 で拒まれたものである。 退避した指摘の `posted_to` は `body` になり、退避した件数が取り込みの出力に出る From 9d8fe3a8fa43e1f22238f3f55c4f52f41a5d5961 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:10:57 +0000 Subject: [PATCH 120/217] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20?= =?UTF-8?q?plugins/ndf/scripts/lib/assignment.py#review=5Fassign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review_assign の round_no < 1 の下限境界で AssignmentError を送出する 振る舞いを現状固定テストで固定する。同モジュールの impl_assign や review_seats には下限境界テストがあるが review_assign だけ抜けていた。 対象コードは変更しない。 Item-Id: R2-003 Round: 2 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_lib_assignment.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index 2259a9ae..a0d97302 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -129,6 +129,21 @@ def test_assign_rejects_a_bad_round(assignment, host): assert "ラウンド番号は 1 以上です" in str(excinfo.value) +def test_review_assign_rejects_a_bad_round(assignment): + """round_no < 1 の下限境界で AssignmentError が送出される(R2-003)。 + + 同モジュールの `assign` / `review_seats` は下限境界を固定しているが、 + `review_assign` だけ抜けていたため現状の振る舞いを固定する。 + """ + for round_no in (0, -1): + with pytest.raises( + assignment.AssignmentError, + match=r"^ラウンド番号は 1 以上です:", + ) as excinfo: + assignment.review_assign(round_no, "claude") + assert "ラウンド番号は 1 以上です" in str(excinfo.value) + + # ---------- 適用の輪番(#727。cross-refactoring が使う) ---------- def test_impl_assign_rotates_over_the_participants_starting_after_the_host(assignment): From 0afd36f4d31e8b9f43a327c9eb0a5d1893f1a88a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:11:18 +0000 Subject: [PATCH 121/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 6afffc8f..0db70c64 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -79,7 +79,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex / agy | 検証中 | 1 | +| error | unit | — | codex / agy | 採用 | 1 | **なぜ**: assign は 8 ラウンド周期の割り当てを行う公開関数であり正常系は固定されているが、round_no < 1(0 や負数)が渡された場合に AssignmentError を送出するエラー経路が scripts/tests 内で固定されていない。 @@ -91,7 +91,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | codex / agy | 検証中 | 1 | +| boundary | unit | — | codex / agy | 採用 | 1 | **なぜ**: resolve_participants で母集合の全メンバーを exclude に指定し、参加可能なメンバーが 0 件になる下限境界の振る舞い(空一覧で認証確認が呼ばれ、available が空リスト、excluded が固定順で記録されること)が固定されていない。 @@ -103,7 +103,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | agy / kiro | 未着手 | 0 | +| boundary | unit | — | agy / kiro | 検証中 | 1 | **なぜ**: review_assign の round_no < 1 の下限境界条件で AssignmentError を送出する振る舞いが scripts/tests 内で固定されていない。同モジュールの impl_assign や review_seats には round_no < 1 の境界テストがあるが、review_assign だけ抜けている。 From 9d8256de55e54aba96f9e4360a1904a024a166a9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:14:57 +0000 Subject: [PATCH 122/217] =?UTF-8?q?Fix:=20=E5=B8=B0=E5=B1=9E=E8=A1=8C?= =?UTF-8?q?=E3=81=AE=E5=BE=8C=E3=82=8D=E3=81=AE=E8=A8=98=E5=90=8D=E3=82=92?= =?UTF-8?q?=E8=AA=AD=E3=81=BF=E3=80=81=E7=84=A1=E9=80=B2=E6=8D=97=E3=81=AE?= =?UTF-8?q?=E8=A8=B1=E5=AE=B9=E3=81=A8=E6=89=8B=E9=A0=86=E6=9B=B8=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E8=A3=85=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=82=8B?= =?UTF-8?q?=EF=BC=88#553=20#728=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit コミットのメッセージを段落に分け、末尾から前へ 1 段落ずつ git の解析へ掛けて記名を 読む。実行環境が帰属の段落を後ろへ足しても、必須の記名が読めるようになる。題名の 段落は解析に掛けない。 起動の出力に無進捗の許容(テストの制限時間 + 900 秒)を足し、骨組みの 3 つの監視へ 渡す。担当がテストを実行している間の無出力で打ち切られなくなる。3 つの雛形に進捗の 記録の指示と、必須の記名を最後の段落へ置く規約を書く。 手順書と説明文書を、結果なしのときの振る舞い・試行の上限・記名の 2 つの読み方に 合わせる。適用の説明が行数の上限に達したため、改修計画の節を報告の説明へ移した (改修計画は報告の成果物であり、適用の手順ではない)。 実測: git 2.53.0 の `git interpret-trailers --parse` は、題名の行を補わないと何も 返さない。散文と記名が混ざる段落も返さない。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- plugins/ndf/skills/cross-refactoring/SKILL.md | 19 +- .../docs/02-apply-and-review.md | 82 ++++----- .../docs/04-fix-and-report.md | 62 ++++++- .../skills/cross-refactoring/prompts/apply.md | 22 +++ .../cross-refactoring/prompts/final-fix.md | 18 ++ .../skills/cross-refactoring/prompts/fix.md | 23 +++ .../scripts/refactor_lib/commands/setup.py | 9 +- .../scripts/refactor_lib/gitfacts.py | 44 ++++- .../tests/test_commit_trailers_git.py | 167 ++++++++++++++++++ .../cross-refactoring/tests/test_init.py | 24 +++ .../tests/test_merge_apply.py | 12 +- .../tests/test_monitor_phase_calls.py | 11 +- .../tests/test_skill_terms.py | 54 ++++++ 13 files changed, 485 insertions(+), 62 deletions(-) create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 80c1869a..4a5acd0f 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -46,15 +46,16 @@ allowed-tools: | --- | --- | --- | | テスト整備ラウンド | **足すべきテストを集める。** 3 者が提案し、採否を決める | `--max-test-rounds`(既定 2) | | 提案ラウンド | **構造改善の提案を集める。** 3 者が提案し、採否を決める | `--max-outer-rounds`(既定 3) | -| 適用ラウンド | **同時に適用して検証する。** 書き換えるファイルが重ならない項目だけを含む。**上の 2 つのラウンドが共有する** | 別に置かない(`--max-items-per-round` が実質の上限) | +| 適用ラウンド | **同時に適用して検証する。** 書き換えるファイルが重ならない項目だけを含む。**上の 2 つのラウンドが共有する** | 同じ群を開き直すのは 2 回まで(引数を持たない固定値)。件数は `--max-items-per-round` が実質の上限 | | 修正ラウンド | **検証の失敗を直す。上の 2 つのラウンドが共有する** | `--max-fix-rounds`(既定 3) | | 改善項目 | 構造改善の提案の 1 件。`<ファイル>#<シンボル>` と兆候で識別する | — | | テスト項目 | テスト整備の提案の 1 件。固定する入口(`target`)と経路の種類(`case`)で識別する | — | **「バッチ」「パッチ」の語は使わない。** 読み手が別の意味で知っている語である。 -**適用ラウンドに別の上限を置かない。** 採用件数の上限が既に群の数を切っている。 -上限を 2 つ置くと、どちらで止まったのかを読み解く必要が出る。 +**同じ群を開き直すのは 2 回までである。** 2 回目は別の担当が試す。2 回とも結果を +残さなければ、担当ではなく群の側を疑える。止まった理由は群の記録(取り消しの理由と +結末の記録)が持つ。 ## 設計方針 @@ -302,6 +303,7 @@ while :; do "$SCRIPTS/launch-cli.sh" "$a" "$PROPOSE_PHASE" "$ID" "$ROUND" done # 監視の上限は `--phase` の工程で上限の表(`lib/limits.py`)が決める。秒数を書かない。 + # 無進捗の許容だけは `init` が出す(テスト 1 回分の無出力で打ち切らないため)。 # **結果ファイルの名前は種類で変えない**ので、監視の雛形と工程(`propose`)は # テスト整備ラウンドでもそのまま使える。 "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ @@ -314,8 +316,9 @@ while :; do rf_eval next-apply-round "$ID" "$ROUND" || break # 終了コード 1 = 群が尽きた "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-apply-r$ROUND" --phase apply - # 終了コード 2 = 適用が通らずこの群を取り消した。修正ラウンドは回さない + --stem-template "{agent}-apply-r$ROUND" --phase apply \ + --stall-timeout "$IMPL_STALL_TIMEOUT" + # 終了コード 2 = この群を取り消した、または担当を替えて開き直す。修正ラウンドは回さない rf merge-apply "$ID" "$ROUND" || continue while :; do # 検証と修正の繰り返し @@ -325,7 +328,8 @@ while :; do fi "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-fix-r$ROUND" --phase fix + --stem-template "{agent}-fix-r$ROUND" --phase fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" rf merge-fix "$ID" "$ROUND" done # 次の群と、次のラウンドの提案に備えて読み取り用を同期する @@ -346,7 +350,8 @@ while :; do 1) echo "⚠ 最終ゲートが通らないまま修正の上限に達しました" >&2; break ;; 2) "$SCRIPTS/launch-cli.sh" "$FINAL_FIX_IMPL" final-fix "$ID" "$LIB/monitor.py" "$ID" --agents "$FINAL_FIX_IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-final-fix" --phase final-fix + --stem-template "{agent}-final-fix" --phase final-fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" rf merge-final-fix "$ID" ;; *) exit $gate ;; esac diff --git a/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md b/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md index 928ab276..81185715 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md +++ b/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md @@ -9,17 +9,36 @@ eval "$("$SCRIPTS/refactor.py" next-apply-round "$ID" "$ROUND")" # 1 = 群が尽きた "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-apply-r$ROUND" --phase apply -"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = この群を取り消した / 4 = 中断 + --stem-template "{agent}-apply-r$ROUND" --phase apply \ + --stall-timeout "$IMPL_STALL_TIMEOUT" +"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = 取り消した / 開き直す / 4 = 中断 ``` 終了コード 2 と 4 を**必ず区別する**。同じ扱いにすると、取り消しに失敗した状態を 「この群の失敗」として次の群へ進み、検証を通っていない変更が Pull Request に 残ったまま先へ進む(実測)。 -**適用の単位は適用ラウンド(群)である。** 群の中の項目は書き換えるファイルが -重ならないので、**まとめて 1 コミット**にできる。群と群は同じファイルを直列に -書き換えるため順序に依存し、後続の群は先行の群を適用した後の作業ツリーを読む。 +**無進捗の許容は `init` が出す**(`IMPL_STALL_TIMEOUT` = テストの制限時間 + 900 秒)。 +担当はテストの実行中は何も出力しないため、制限時間そのままでは打ち切られる。 + +### 実装担当が結果を残さなかったとき + +結果ファイルが無い・読めないまま終わることがある(無進捗の打ち切り・利用上限・起動の +直後に落ちた)。取り込みは、未検証のコミットを新しい順に取り消して起点を取り消し後の先端へ +進め、群の `failed_attempts[]` へ結末を 1 件記録し、終了コード 2 で終える。 + +| 何回目か | 何が起きるか | +| --- | --- | +| 1 回目 | その群で失敗した担当のどれとも違う、次の輪番の担当へ替えて開き直す | +| 2 回目 | 群を取り消し(`drop_reason: no_result`)、項目を見送りへ入れる。理由にはどの担当がどの理由で残さなかったかが並ぶ | +| 替える先が無い | 起動し直しの可否で決める。利用上限は待ちと相手の枠を使うだけなので 1 回目で取り消す | + +**項目が 1 件も無い群は開かない。** 採用 0 件の提案ラウンドは群を作らず、残っている +項目なしの群は取り消し済み(`drop_reason: empty`)にして次へ進む。 + +**適用の単位は適用ラウンド(群)である。** 群の中の項目は書き換えるファイルが重ならない +ので、**まとめて 1 コミット**にできる。群と群は同じファイルを直列に書き換えるため順序に +依存し、後続の群は先行の群を適用した後の作業ツリーを読む。 実装担当を**1 つの群につき 1 回**起動し、その群の項目を優先度順に**直列適用**させる。 並列適用はしない(同一ブランチへの同時コミットは競合と取り消し単位の曖昧化を招く)。 @@ -208,38 +227,6 @@ fi 控えておき、最後に `git reset --soft` で 1 コミットへまとめる。控えた地点より前へ 戻すと他の群のコミットを巻き込むため、起点は群の着手前に固定する。 -#### 改修計画は Pull Request のコメントに残す - -**なぜ直すのか(理由)とどう直すのか(手順)は、提案の時点でしか残らない。** -状態ファイルには入っているが、そのディレクトリは差分から除外されるため、 -Pull Request を読む側からは見えない。 - -**改修計画は実行の記録であって、リポジトリの知識ではない**(#436 決定 6)。既定の -置き場所は**対象の Pull Request のコメント 1 件**で、ラウンドが進むたびに**同じ -コメントを編集する**。 - -| 置き場所 | URL の安定 | 差分に混ざるか | 更新の手数 | -| --- | --- | --- | --- | -| **Pull Request のコメント 1 件**(既定) | **永続** | 混ざらない | 編集 1 回 | -| ファイル(`--plan-file`) | `` に依存。ブランチが消えると切れる | **混ざる** | コミットと push | - -- 内容は**状態から決まる**。同じ状態からは同じ本文が出る -- **取り消した項目の内訳を持つのは改修計画だけである。** 他の文章は件数だけ述べる -- 本文の先頭に印(``)を置く。状態ファイルの - 控えが失われても、印で同じコメントを引き当てられる。**引き当てられないと、 - ラウンドのたびに新しいコメントが積まれる** -- **投稿に失敗しても進行は止めない。** 記録が残らないことと、変更が検証を通って - いないことは別である。失敗したことは出力に残る - -**`--plan-file` は残す。** 明示したときだけファイルにする。この経路では公開を -生成物の同期と**同じコミット**に乗せる(分けると進行側のコミットが公開のたびに -2 つずつ積まれる)。空文字を渡すと記録しない。 - -**絶対パスと親へ抜ける経路は受け取った時点で拒む**(終了コード 4)。進行側は利用者の -リポジトリを触るため、作業ディレクトリの外へ書き出す余地を残さない。あわせて -`./issues/plan.md` のような表記も正規化する。git が返すパスと形が違うと、公開の -コミットメッセージが取り違えられる。 - #### 範囲の指定は検証にも効かせる `--scope` を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を検証へ @@ -357,15 +344,15 @@ Pull Request に残る。**都合の悪い変更を申告しないだけで検 | コマンド | 二重処理を防ぐ鍵 | | --- | --- | | `merge-proposals` | `proposal_keys` が既にあるか | -| `merge-apply` | `apply.merged_at` が既にあるか | +| `merge-apply` | `apply.merged_at` が既にあるか。結果を残さなかった試行は、群の `failed_attempts[]` に同じ工程と試行番号があるか | | `merge-fix` | 試行番号(`verify-round` が進める)と結果ファイルの内容の組 | | `abandon-items` | `abandoned` が既にあるか | #### 結果ファイルの形が崩れていても落ちない -相手は LLM なので、`commits` が配列でない・要素が辞書でない・`sha` が文字列でない -といった崩れ方をする。結果ファイルを読む箇所は**型を確かめてから使い**、取り出せた -ものだけを扱う。落ちると進行が止まるだけで、何の検証にもならない。 +相手は LLM なので、`commits` が配列でない・要素が辞書でないといった崩れ方をする。 +結果ファイルを読む箇所は**型を確かめてから使い**、取り出せたものだけを扱う。**JSON +オブジェクトとして読めない結果ファイルは結果なしと同じ扱いにする**(理由は `unparsable`)。 **1 件の失敗でラウンドを止めない。** 失敗した項目だけを見送りにして、残りは採用する。 全件失敗のときだけ終了コード 2 を返し、次の提案ラウンドへ進む。 @@ -444,6 +431,19 @@ Impl-Model: gpt-5.5 自由文で「codex が実装」と書かせると集計に使えない。プロンプトに書くだけでは守られない ので、`merge-apply` が有無を検証する。 +**読み方は 2 つあり、見る範囲が違う。** + +| 読み手 | 読み方 | 見る範囲 | +| --- | --- | --- | +| 人(集計) | `git log --format='%(trailers:key=Impl-Model,valueonly)'` | **最後の段落だけ** | +| 進行側(検証) | 末尾の段落から前へ 1 段落ずつ `git interpret-trailers --parse` に掛け、記名の段落と判定しなかったところで止める | 末尾から続く記名の段落すべて | + +実行環境が `Co-Authored-By:` などの帰属行を別の段落として足しても、進行側はその段落を +飛ばして前まで読むため検証は通る。人の集計は最後の段落しか読まないので、**雛形では +帰属行を空行を挟まず同じ段落に続けるよう求めている**(従わなくても検証は通る)。 +**1 段落目(題名)は判定に掛けない。** 掛けると `Round: 本文の題名` の形の題名を記名 +として読む(実測)。散文と記名の形が混ざる段落は、git が記名の段落と判定しない。 + `Impl-Model` には**実際に使ったモデル名**を書かせる。既定モデルで走った場合は `default` として報告時に区別する。 diff --git a/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md b/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md index 82810416..c2711ab0 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md +++ b/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md @@ -10,8 +10,9 @@ if "$SCRIPTS/refactor.py" should-abandon "$ID" "$ROUND"; then else "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-fix-r$ROUND" --phase fix - "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" + --stem-template "{agent}-fix-r$ROUND" --phase fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" + "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" # 2 = 結果なし / 範囲が確定しない # 修正後の状態を次の提案へ届ける "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" fi @@ -28,6 +29,18 @@ fi **`--max-fix-rounds` は 1 つの適用ラウンドあたりの上限である。** 数え直しは `next-apply-round` が群を開くときに行う。 +**読むのは群の担当の結果である。** 骨組みが起動するのは群の担当であり、提案ラウンドの +担当とは限らない。食い違うと結果ファイルを一度も引けず、修正ラウンドが進まないまま +検証と修正を往復し続ける。 + +**修正の担当が結果を残さなかったときも、修正ラウンドは進める。** 未検証のコミットを +取り消し、群の `failed_attempts[]` へ 1 件(工程 `fix`)を記録し、終了コード 2 で +終える。進めないと見送りの判定が上限に達する条件を満たさない。**起動し直しても +解けない結末(利用上限)では、修正ラウンドの数を上限の値にする。** 次の見送りの判定が +そのまま見送りへ移すため、同じ担当を上限まで起動し直すことがなくなる。 + +**無進捗の許容は `init` が出す**(`IMPL_STALL_TIMEOUT`)。適用と同じ値である。 + ### 修正コミットも適用と同じ基準で見る `merge-fix` は修正コミットにも `Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model` と @@ -228,7 +241,10 @@ Pull Request の読み手が持つため、失敗として報告に書く。 ```bash "$SCRIPTS/launch-cli.sh" "$FINAL_FIX_IMPL" final-fix "$ID" # 担当は final-gate が返す -"$SCRIPTS/refactor.py" merge-final-fix "$ID" # 取り込みも専用 +"$LIB/monitor.py" "$ID" --agents "$FINAL_FIX_IMPL" --tmp-dir "$TMP_DIR" \ + --stem-template "{agent}-final-fix" --phase final-fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" +"$SCRIPTS/refactor.py" merge-final-fix "$ID" # 2 = 結果なし / 範囲が確定しない ``` **`fix` フェーズと `merge-fix` は使い回せない。** どちらも適用ラウンド(群)の控えを @@ -263,6 +279,14 @@ Pull Request の読み手が持つため、失敗として報告に書く。 「上限に達しても取り消さない」のは*採用した改善項目*の話であって、検証を受けていない 修正コミットは別である。取り消せば HEAD は最終ゲートが見た地点へ戻る。 +**修正の担当が結果を残さなかったときも同じ手順を通る。** 取り消して起点を取り消し後の +先端へ進め、最終ゲートの記録の `failed_attempts[]` へ 1 件(工程 `final-fix`)を残し、 +終了コード 2 で最終ゲートへ判定を戻す。取り消さずに抜けると、次の最終ゲートがその +コミットを含む先端でテストし、落ちれば起点をそこへ置き直すため、未検証の差分が +Pull Request に残る。**修正ラウンドはここでは進めない。** 進めるのは次の最終ゲート +である。ただし**起動し直しても解けない結末(利用上限)では上限の値にする**。次の +最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる。 + ### テストで見つからない誤りは誰が拾うか Step 5 をテストへ置き換え、Step 7 を条件付きで省くと、実行の流れからレビューが @@ -307,6 +331,38 @@ Step 5 のテストが済ませている。同じ観点を後段へ渡すと、 | 実装担当 | 担当ラウンド数 / 適用成功した項目数 / 見送った項目数 / 平均修正ラウンド数 / 差分予算の超過率 / テスト失敗の発生率 / 所要時間 | | 提案の担当 | 提案件数 / 採用された率 / 他者と合意した率 / 所要時間 | +### 改修計画は Pull Request のコメントに残す + +**なぜ直すのか(理由)とどう直すのか(手順)は、提案の時点でしか残らない。** +状態ファイルには入っているが、そのディレクトリは差分から除外されるため、 +Pull Request を読む側からは見えない。 + +**改修計画は実行の記録であって、リポジトリの知識ではない**(#436 決定 6)。既定の +置き場所は**対象の Pull Request のコメント 1 件**で、ラウンドが進むたびに**同じ +コメントを編集する**。 + +| 置き場所 | URL の安定 | 差分に混ざるか | 更新の手数 | +| --- | --- | --- | --- | +| **Pull Request のコメント 1 件**(既定) | **永続** | 混ざらない | 編集 1 回 | +| ファイル(`--plan-file`) | `` に依存。ブランチが消えると切れる | **混ざる** | コミットと push | + +- 内容は**状態から決まる**。同じ状態からは同じ本文が出る +- **取り消した項目の内訳を持つのは改修計画だけである。** 他の文章は件数だけ述べる +- 本文の先頭に印(``)を置く。状態ファイルの + 控えが失われても、印で同じコメントを引き当てられる。**引き当てられないと、 + ラウンドのたびに新しいコメントが積まれる** +- **投稿に失敗しても進行は止めない。** 記録が残らないことと、変更が検証を通って + いないことは別である。失敗したことは出力に残る + +**`--plan-file` は残す。** 明示したときだけファイルにする。この経路では公開を +生成物の同期と**同じコミット**に乗せる(分けると進行側のコミットが公開のたびに +2 つずつ積まれる)。空文字を渡すと記録しない。 + +**絶対パスと親へ抜ける経路は受け取った時点で拒む**(終了コード 4)。進行側は利用者の +リポジトリを触るため、作業ディレクトリの外へ書き出す余地を残さない。あわせて +`./issues/plan.md` のような表記も正規化する。git が返すパスと形が違うと、公開の +コミットメッセージが取り違えられる。 + ### 報告も外へ出す文章である | 規約 | 報告での書き方 | diff --git a/plugins/ndf/skills/cross-refactoring/prompts/apply.md b/plugins/ndf/skills/cross-refactoring/prompts/apply.md index 8c257241..c3091132 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/apply.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/apply.md @@ -63,6 +63,28 @@ Impl-Model: $RF_MODEL - **`Item-Id` にはこの適用ラウンドの先頭の項目 ID を書く。** どの項目の ID でも 検証は通りますが、揃えておくと履歴が読みやすくなります - `Impl-Model` には**実際に使ったモデル名**を書く。分からなければ `default` +- **4 つのトレーラーはメッセージの最後の段落に置く。** 実行環境が + `Co-Authored-By:` などの帰属行を足すときは、**空行を挟まず同じ段落に続けます**。 + 人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計するとき、 + git は最後の段落しか読みません + +## 作業の進み具合を残す + +**作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記してください。** +テストの実行中は何も出力されないため、追記が無いと監視が「進んでいない」と見て +打ち切ります。書くのは段階と対象だけで、内容は要りません。 + +```bash +echo "test src/foo.py" >> "$RF_STEM-progress.log" +``` + +| 段階 | いつ書くか | +| --- | --- | +| `start` | 着手した | +| `edit` | ファイルを書き換えた | +| `test` | テストを実行する直前 | +| `commit` | コミットした | +| `done` | 結果ファイルを書き終えた | ## 守ること diff --git a/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md b/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md index a2dc4eb2..b3f49ad7 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md @@ -51,6 +51,24 @@ Impl-Model: $RF_MODEL **申告から漏れたコミットがあると、この修正の範囲ごと取り消されます。** 作った コミットは 1 件残らず結果ファイルへ書いてください。 +## 作業の進み具合を残す + +**作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記してください。** +テストの実行中は何も出力されないため、追記が無いと監視が「進んでいない」と見て +打ち切ります。書くのは段階と対象だけで、内容は要りません。 + +```bash +echo "test src/foo.py" >> "$RF_STEM-progress.log" +``` + +| 段階 | いつ書くか | +| --- | --- | +| `start` | 着手した | +| `edit` | ファイルを書き換えた | +| `test` | テストを実行する直前 | +| `commit` | コミットした | +| `done` | 結果ファイルを書き終えた | + ## 守ること - **push しない。** 公開するのは進行側だけで、**検証を通した後**に行います diff --git a/plugins/ndf/skills/cross-refactoring/prompts/fix.md b/plugins/ndf/skills/cross-refactoring/prompts/fix.md index 5115b0fe..d0fdf562 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/fix.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/fix.md @@ -63,6 +63,29 @@ Impl-Runtime: $RF_RUNTIME Impl-Model: $RF_MODEL ``` +- **4 つのトレーラーはメッセージの最後の段落に置く。** 実行環境が + `Co-Authored-By:` などの帰属行を足すときは、**空行を挟まず同じ段落に続けます**。 + 人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計するとき、 + git は最後の段落しか読みません + +## 作業の進み具合を残す + +**作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記してください。** +テストの実行中は何も出力されないため、追記が無いと監視が「進んでいない」と見て +打ち切ります。書くのは段階と対象だけで、内容は要りません。 + +```bash +echo "test src/foo.py" >> "$RF_STEM-progress.log" +``` + +| 段階 | いつ書くか | +| --- | --- | +| `start` | 着手した | +| `edit` | ファイルを書き換えた | +| `test` | テストを実行する直前 | +| `commit` | コミットした | +| `done` | 結果ファイルを書き終えた | + ## 守ること - **push しない。** 公開するのは進行側だけで、**検証を通した後**に行います diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index 3b4a671d..011ec37a 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -21,7 +21,7 @@ import statefile from .. import ABORT, die, info -from ..gitfacts import run_with_timeout +from ..gitfacts import run_with_timeout, safe_int from ..paths import ( default_worktree_base, load_state, @@ -35,6 +35,7 @@ from ..scope import require_scope_covers_tests from ..vocabulary import ( DEFAULT_TEST_TIMEOUT, + IMPL_STALL_MARGIN, REQUIRED_SKILLS, test_vocabulary, vocabulary, @@ -355,6 +356,12 @@ def _emit_init(state: dict[str, Any]) -> None: HEAD_BRANCH=state["head_branch"], BASE_BRANCH=state["base_branch"], SCOPE=" ".join(state["target_scope"]), + # 適用・修正・最終ゲートの修正の担当はテストを 1 回実行し、その間は何も + # 出力しない。テストの制限時間そのままでは、実行中に打ち切られる(#553)。 + IMPL_STALL_TIMEOUT=( + safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + + IMPL_STALL_MARGIN + ), ) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index 12989d38..4ddb68fa 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -7,6 +7,7 @@ import json import os import pathlib +import re import shutil import signal import subprocess @@ -98,14 +99,49 @@ def commit_trailers(work: str, sha: str) -> dict[str, str]: **結果ファイルの `trailers` は使わない。** JSON 上は仕様どおりでも、実際の `git commit` でトレーラーを書き忘れていれば集計に使えない。 + + **末尾の段落から前へ 1 段落ずつ読む**(#553)。実行環境が帰属の段落を後ろへ + 足すと、git の標準の読み方は最後の段落しか見ないため必須の記名が読めなくなる。 + トレーラーの段落と判定しなかった段落で止めるので、散文の中にある記名の形の行は + 拾わない。同じ鍵が 2 つの段落にあれば、末尾に近い段落の値を採る。 + + **1 段落目(題名)は掛けない。** 掛けると `Round: 本文の題名` の形の題名を + トレーラーとして読む。 """ - out = git_out(work, ["log", "-1", "--format=%(trailers:only,unfold)", sha]) + body = git_out(work, ["log", "-1", "--format=%B", sha], strip=False) + paragraphs = re.split(r"\n[ \t]*\n", (body or "").strip("\n")) trailers: dict[str, str] = {} - for line in (out or "").splitlines(): + for paragraph in reversed(paragraphs[1:]): + parsed = _parse_trailer_paragraph(paragraph) + if not parsed: + break + for key, value in parsed.items(): + trailers.setdefault(key, value) + return trailers + + +def _parse_trailer_paragraph(paragraph: str) -> dict[str, str]: + """1 つの段落を git の判定に掛け、トレーラーの段落なら鍵と値を返す。 + + **題名の行を補って渡す。** git はメッセージの 1 行目を題名として読むため、 + 段落だけを渡すと何も返らない(git 2.53.0 で実測)。判定そのものは git に委ね、 + 「何行以上なら記名の段落か」といった規則をこちら側に持たない。 + """ + if not paragraph.strip(): + return {} + result = subprocess.run( + ["git", "interpret-trailers", "--parse"], + input=f"subject\n\n{paragraph}\n", + capture_output=True, text=True, + ) + if result.returncode != 0: + return {} + parsed: dict[str, str] = {} + for line in result.stdout.splitlines(): key, sep, value = line.partition(":") if sep: - trailers[key.strip()] = value.strip() - return trailers + parsed[key.strip()] = value.strip() + return parsed def commit_diff_lines(work: str, sha: str) -> int: diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py b/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py new file mode 100644 index 00000000..946d6f4f --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py @@ -0,0 +1,167 @@ +"""実行環境が帰属の段落を足したコミットから記名を読む(#553)。 + +**一時リポジトリで実際に git を実行する。** 段落の切り分けは git の判定 +(`git interpret-trailers --parse`)に委ねているため、差し替えた出力で確かめても +本番の読み方を確かめたことにならない。 +""" +from __future__ import annotations + +import subprocess + +import pytest + +REQUIRED = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") + +_SIGNED = """Refactor: extract_method — src/foo.py#Bar.handle + +変更の説明。 + +Item-Id: R1-001 +Round: 1 +Impl-Runtime: claude +Impl-Model: claude-opus-5 + +Co-Authored-By: Claude Opus 5 +""" + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, + check=True) + + +@pytest.fixture +def repo(tmp_path): + """コミットを積める一時リポジトリ。""" + path = tmp_path / "repo" + path.mkdir() + _git("init", "-q", "-b", "main", cwd=path) + _git("config", "user.email", "t@e.st", cwd=path) + _git("config", "user.name", "test", cwd=path) + (path / "src").mkdir() + (path / "src" / "foo.py").write_text("x = 1\n", encoding="utf-8") + _git("add", "-A", cwd=path) + _git("commit", "-qm", "init", cwd=path) + return path + + +@pytest.fixture +def commit(repo): + """メッセージを渡してコミットを作り、その完全な識別子を返す。""" + counter = {"n": 0} + + def _make(message: str) -> str: + counter["n"] += 1 + (repo / "src" / f"f{counter['n']}.py").write_text("y = 1\n", encoding="utf-8") + _git("add", "-A", cwd=repo) + _git("commit", "-q", "-m", message, cwd=repo) + return _git("rev-parse", "HEAD", cwd=repo).stdout.strip() + + return _make + + +def test_a_signature_paragraph_after_the_required_ones_is_skipped( + gitfacts, repo, commit +): + """AC32: 必須の記名の後ろに帰属の段落が付いても 4 つとも読める。""" + sha = commit(_SIGNED) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert [trailers.get(k) for k in REQUIRED] == [ + "R1-001", "1", "claude", "claude-opus-5"] + + +def test_two_attribution_lines_in_one_paragraph_are_also_skipped( + gitfacts, repo, commit +): + """AC33: 帰属の段落が 2 行でも 4 つとも読める。""" + sha = commit( + _SIGNED + "Claude-Session: https://example.test/session_1\n") + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert all(trailers.get(k) for k in REQUIRED) + assert trailers["Claude-Session"] == "https://example.test/session_1" + + +def test_a_prose_paragraph_stops_the_reading(gitfacts, repo, commit): + """AC34: 散文の段落より前にある記名の形の行は読まない。""" + sha = commit( + "Refactor: 題名\n\n" + "Item-Id: R1-001\nRound: 1\n" + "Impl-Runtime: claude\nImpl-Model: claude-opus-5\n\n" + "この段落は説明の散文です。\n\n" + "Co-Authored-By: Someone \n" + ) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers == {"Co-Authored-By": "Someone "} + + +def test_a_mixed_last_paragraph_is_not_read(gitfacts, repo, commit): + """AC35: 散文と記名の形が混ざる段落は、git が記名の段落と判定しない。""" + sha = commit( + "Refactor: 題名\n\n" + "ここは説明です。\nこちらも説明です。\nさらに説明です。\nRound: 3\n" + ) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers == {} + + +def test_the_paragraph_nearest_the_end_wins(gitfacts, repo, commit): + """AC36: 同じ鍵が 2 つの段落にあれば、末尾に近い方の値を採る。""" + sha = commit( + "Refactor: 題名\n\n" + "Item-Id: R1-001\nRound: 1\n" + "Impl-Runtime: claude\nImpl-Model: claude-opus-5\n\n" + "Impl-Model: claude-opus-5-later\n" + ) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers["Impl-Model"] == "claude-opus-5-later" + assert trailers["Item-Id"] == "R1-001" + + +def test_a_subject_shaped_like_a_trailer_is_not_read(gitfacts, repo, commit): + """AC38: 題名が記名の形でも、1 段落目は判定に掛けない。""" + sha = commit("Round: 本文の題名\n\nItem-Id: R1-002\n") + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers == {"Item-Id": "R1-002"} + + +def test_a_commit_with_an_attribution_paragraph_passes_the_apply_check( + gitfacts, verify, repo, commit +): + """AC37: 帰属の段落が付いたコミットは、記名の欠落で取り消されない。""" + sha = commit(_SIGNED) + facts = gitfacts.collect_commit_facts( + str(repo), [sha], {sha}, "", "main") + + problem = verify.verify_apply_round( + [{"item_id": "R1-001", "estimated_diff_lines": 100, + "technique": "extract_method", "test_gap": False}], + facts, ["src"], + ) + + assert problem is None + + +def test_the_commit_convention_asks_for_the_last_paragraph(prompts_dir): + """AC39: 適用と修正の雛形が、必須の記名を最後の段落へ置くことを書く。""" + for name in ("apply.md", "fix.md"): + text = (prompts_dir / name).read_text(encoding="utf-8") + assert "最後の段落" in text, name + assert "空行を挟まず" in text, name + + +@pytest.fixture +def prompts_dir(): + import pathlib + return pathlib.Path(__file__).resolve().parents[1] / "prompts" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index ced38334..adc7e054 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -326,6 +326,30 @@ def test_init_emits_shell_assignments(run_init, tmp_path, capsys): assert "TMP_DIR=" in out and "WORK=" in out +def test_init_emits_the_stall_timeout_for_the_implementer(run_init, tmp_path, capsys): + """AC40: 無進捗の許容は、テストの制限時間に 900 秒を足した値である。 + + 適用と修正の担当はテストを 1 回実行し、その間は何も出力しない。制限時間 + そのままでは実行中に打ち切られる。 + """ + args = _args(tmp_path) + args.test_timeout = 900 # `--test-timeout` の既定 + + run_init(args) + + assert "IMPL_STALL_TIMEOUT=1800" in capsys.readouterr().out + + +def test_the_stall_timeout_follows_the_test_timeout(run_init, tmp_path, capsys): + """AC40: テストの制限時間を変えると、無進捗の許容も一緒に動く。""" + args = _args(tmp_path) + args.test_timeout = 1200 + + run_init(args) + + assert "IMPL_STALL_TIMEOUT=2100" in capsys.readouterr().out + + def test_existing_worktree_is_synced_to_origin(run_init, tmp_path, origin_repo): """再開までに head が進んでいたら、追いついてから始めること。 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py index 303d8a79..9f165b39 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py @@ -236,9 +236,13 @@ def test_the_wider_factor_is_limited_to_the_vocabulary(vocabulary): # ---------- git から事実を取る ---------- def test_commit_trailers_are_read_from_git(patch_lib, gitfacts, monkeypatch): - """結果ファイルではなく実際のコミットメッセージから読む。""" + """結果ファイルではなく実際のコミットメッセージから読む。 + + 読むのは**題名の次の段落から後ろ**である。段落の切り分けそのものは実際の git で + 確かめる(`test_commit_trailers_git.py`)。 + """ patch_lib("git_out", - lambda work, args, **_kw: "Item-Id: R1-001\nRound: 1\n" + lambda work, args, **_kw: "Refactor: 題名\n\nItem-Id: R1-001\nRound: 1\n" "Impl-Runtime: codex\nImpl-Model: gpt-5.5", ) assert gitfacts.commit_trailers("/w", "abc") == { @@ -449,6 +453,10 @@ def test_a_verified_apply_round_marks_every_item_applied( assert state["rounds"][0]["apply"]["applied"] == ["R1-001", "R1-002"] assert all(i["status"] == "applied" for i in state["items"]) assert state["phase"] == "verify", "次はテストによる検証へ進む" + # AC46: 結果があり検証を通る適用は、1 回目の試行で取り込まれ記録を残さない + group = state["rounds"][0]["apply_rounds"][0] + assert "failed_attempts" not in group + assert "drop_reason" not in group def test_all_failed_exits_2(refactor, tmp_path, env_tmp_dir, no_git, git_facts): diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py b/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py index 83ad6a49..15d8599f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py @@ -21,7 +21,7 @@ "SKILL.md": 4, "docs/01-state-and-propose.md": 1, "docs/02-apply-and-review.md": 2, - "docs/04-fix-and-report.md": 1, + "docs/04-fix-and-report.md": 2, } # 監視の雛形(stem)から、渡すべき工程を決める。 @@ -74,11 +74,14 @@ def test_the_call_does_not_pass_a_timeout(rel: str, call: str) -> None: @pytest.mark.skipif(shutil.which("grep") is None, reason="grep が無い") -def test_the_acceptance_grep_returns_8() -> None: - """要求の文書の AC38 のコマンドをそのまま実行する。""" +def test_the_acceptance_grep_returns_9() -> None: + """要求の文書の AC38 のコマンドをそのまま実行する。 + + 最終ゲートの修正の監視を修正の説明へも書いたため、9 件になる(#728)。 + """ command = ( 'grep -rn -A2 "monitor.py" plugins/ndf/skills/cross-refactoring/SKILL.md ' 'plugins/ndf/skills/cross-refactoring/docs | grep -c -- "--phase"' ) r = subprocess.run(["bash", "-c", command], cwd=REPO, capture_output=True, text=True) - assert r.stdout.strip() == "8", r.stdout + r.stderr + assert r.stdout.strip() == "9", r.stdout + r.stderr diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py b/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py index 4ef02075..ee7c4afd 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py @@ -108,3 +108,57 @@ def test_the_command_sequence_does_not_launch_reviewers(skill): block = _run_block(skill) assert " review " not in block assert "verify-round" in block + + +# ---------- 結果なしと無進捗の許容(#728 / #647 / #553) ---------- + +DOCS = SKILL.parent / "docs" + + +def test_the_apply_round_row_states_the_attempt_cap(skill): + """AC43: 適用ラウンドの行が、同じ群を開き直す上限(2 回)を書く。""" + row = next(r for r in _terms_table(skill) if "適用ラウンド" in r) + + assert "2 回" in row + + +def test_the_single_cap_paragraph_is_gone(skill): + """AC43: 上限を 1 つに保つとしていた段落が残っていないこと。""" + assert "別の上限を置かない" not in skill + assert "別に置かない" not in skill + + +def test_every_implementer_phase_passes_the_stall_timeout(skill): + """AC41: 適用・修正・最終ゲートの修正の監視が、無進捗の許容だけを受け取る。""" + for phase in ("apply", "fix", "final-fix"): + block = skill.split(f"--phase {phase}", 1)[1].split("\n\n", 1)[0] + assert '--stall-timeout "$IMPL_STALL_TIMEOUT"' in block, phase + assert "--timeout " not in block, phase + + +def test_the_apply_document_describes_a_missing_result(skill): + """AC44: 適用の説明が、結果なしのときの取り消し・記録・終了コードを書く。""" + text = (DOCS / "02-apply-and-review.md").read_text(encoding="utf-8") + + assert "実装担当が結果を残さなかったとき" in text + assert "failed_attempts" in text + assert "終了コード" in text + assert '--stall-timeout "$IMPL_STALL_TIMEOUT"' in text + + +def test_the_fix_document_describes_a_missing_result(skill): + """AC44: 修正と最終ゲートの説明が、同じ 2 つを書く。""" + text = (DOCS / "04-fix-and-report.md").read_text(encoding="utf-8") + + assert text.count('--stall-timeout "$IMPL_STALL_TIMEOUT"') == 2 + assert "修正の担当が結果を残さなかったときも、修正ラウンドは進める" in text + assert "修正の担当が結果を残さなかったときも同じ手順を通る" in text + + +def test_the_trailer_section_states_both_ways_of_reading(skill): + """AC45: 記名の節が、人の集計と進行側の検証の 2 つの読み方を書く。""" + text = (DOCS / "02-apply-and-review.md").read_text(encoding="utf-8") + section = text.split("### コミットトレーラーの形式", 1)[1].split("\n### ", 1)[0] + + assert "最後の段落だけ" in section + assert "git interpret-trailers --parse" in section From f63f4e8793d43b3a37dd371bd6567c3a0e8681ba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:15:45 +0000 Subject: [PATCH 123/217] =?UTF-8?q?Docs:=20=E5=8F=97=E3=81=91=E5=85=A5?= =?UTF-8?q?=E3=82=8C=E6=9D=A1=E4=BB=B6=E3=81=AE=E7=A2=BA=E8=AA=8D=E7=B5=90?= =?UTF-8?q?=E6=9E=9C=E3=81=A8=E3=80=81=E5=AE=9F=E8=A3=85=E3=81=A7=E6=B1=BA?= =?UTF-8?q?=E3=81=BE=E3=81=A3=E3=81=9F=E3=81=93=E3=81=A8=E3=82=92=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88#728=20#647=20#592=20#553?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 受け入れ条件 50 件を確認済みにする。設計文書の未確認の節に、輪番から担当を引く関数の 中身を現行の割り当てを包む形で書いたこと(参加者の決め方の変更は開発版の起点に 入っていなかった)と、適用の説明が行数の上限に達したことを残す。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-728-647-592-553-design.md | 3 +- issues/issue-728-647-592-553-requirements.md | 100 +++++++++---------- 2 files changed, 52 insertions(+), 51 deletions(-) diff --git a/issues/issue-728-647-592-553-design.md b/issues/issue-728-647-592-553-design.md index b7e4edb7..709e5411 100644 --- a/issues/issue-728-647-592-553-design.md +++ b/issues/issue-728-647-592-553-design.md @@ -525,11 +525,12 @@ graph TD | # | 項目 | 内容 | 決める時点 | | --- | --- | --- | --- | | 1 | G3 の実装の形 | `LaunchOutcome` の欄の名前は PR #781 の設計のとおりとしている。実装で変われば `read_result` の包みが吸収し、取り込みは変わらない | G3 の実装 Pull Request のマージ | -| 2 | G1 の先後 | `impl_for_seq` の中身が `assignment.assign` か `impl_assign(participants, seq)` かは、実装の着手時点の `develop` で決める | 実装の計画 | +| 2 | G1 の先後 | **決まった。** 実装の着手時点の開発版の起点に参加者の決め方の変更は入っていなかった(`git grep impl_assign` は設計文書だけに当たる)。輪番から担当を引く関数は現行の割り当て(`assignment.assign(seq, host)`)を包む形で書いた。後から入る側がその中だけを差し替える | 決定済み | | 3 | 担当を替えた後の担当も結果を残さない割合 | 2 回目で救える群の数は測っていない。実行の要約の `apply_attempts` で数える | 配布後 | | 4 | トレーラーの形でない署名を末尾に足すランタイム | codex / agy / kiro のコミットで帰属行の段落を見ていない。散文の段落を足す者が現れれば決定 13 は効かない | 次の実行の `failed` の理由を読む | | 5 | 帰属行を同じ段落に続ける指示に claude が従うか | 決定 14 は補助で、従わなくても決定 13 で検証は通る | 実装後の最初の実行 | | 6 | 担当が雛形の進捗マーカーに従うか | 従わなくても決定 15 の許容で打ち切られないのはテスト 1 回分まで | 実装後の最初の実行 | +| 8 | 適用の説明の行数 | 結果なしの節を足したことで行数の上限(500 行)に達したため、改修計画の節を報告の説明へ移した。次に節を足すときは分割が要る | 次に適用の説明を書き足すとき | | 7 | 修正の担当が利用上限のとき、群の他の項目を救う手段 | 決定 10 は修正を見送りへ進める。担当を替えて修正を続ける形は、直しかけの文脈が要るため採らなかった。見送りが増えれば見直す | 配布後 | ## 申し送り(並行する設計との境界) diff --git a/issues/issue-728-647-592-553-requirements.md b/issues/issue-728-647-592-553-requirements.md index 19079b0a..b2fac640 100644 --- a/issues/issue-728-647-592-553-requirements.md +++ b/issues/issue-728-647-592-553-requirements.md @@ -125,94 +125,94 @@ ## 受け入れ条件(結末の読み取り) -- [ ] AC1: 結果ファイルが無い状態で結末の読み取り(`gitfacts.read_result`)を呼ぶと、例外(`SystemExit`)を出さず、標準出力・標準エラーに書かない。結果なしの値(`payload` が `None`、`reason` が `missing`)を返す -- [ ] AC2: 監視の結果ファイル(`-apply-r-monitor.json`)に無進捗の理由(`reason: stalled`)があるとき、結末の読み取りの理由は `stalled`、起動し直しの可否は真である。利用上限の理由(`reason: usage_limit`)のとき、可否は偽である -- [ ] AC3: 結末の読み取りに渡す結果ファイルの名前の幹(stem)は、監視の名前の雛形(`--stem-template`: `{agent}-apply-r$ROUND` / `{agent}-fix-r$ROUND` / `{agent}-final-fix`)を担当名で埋めた値と一致する。幹を組む関数(`paths.stem_for`)の 3 つの工程の値を、骨組みの雛形から作った値と突き合わせる +- [x] AC1: 結果ファイルが無い状態で結末の読み取り(`gitfacts.read_result`)を呼ぶと、例外(`SystemExit`)を出さず、標準出力・標準エラーに書かない。結果なしの値(`payload` が `None`、`reason` が `missing`)を返す +- [x] AC2: 監視の結果ファイル(`-apply-r-monitor.json`)に無進捗の理由(`reason: stalled`)があるとき、結末の読み取りの理由は `stalled`、起動し直しの可否は真である。利用上限の理由(`reason: usage_limit`)のとき、可否は偽である +- [x] AC3: 結末の読み取りに渡す結果ファイルの名前の幹(stem)は、監視の名前の雛形(`--stem-template`: `{agent}-apply-r$ROUND` / `{agent}-fix-r$ROUND` / `{agent}-final-fix`)を担当名で埋めた値と一致する。幹を組む関数(`paths.stem_for`)の 3 つの工程の値を、骨組みの雛形から作った値と突き合わせる ## 受け入れ条件(共通の手順) -- [ ] AC4: 前提: 結果なしで、起点から HEAD までにコミットが 1 件以上ある +- [x] AC4: 前提: 結果なしで、起点から HEAD までにコミットが 1 件以上ある 操作: 3 つの取り込みのいずれかを呼ぶ 結果: そのコミットは取り消され、起点(`apply_base_sha` と群の `base_sha` / `fix_base_sha` / `final_gate.fix_base_sha`)は取り消し後の HEAD になる -- [ ] AC5: 結果なしのとき、3 つの取り込みのいずれでも、記録の辞書(群 / `final_gate`)の結末の記録(`failed_attempts`)に 1 件(`{phase, attempt, impl, reason, detail, at, reverted}`)が足される。理由(`reason`)は結末の読み取りの値、取り消した数(`reverted`)は取り消したコミットの数である -- [ ] AC6: 結果なしで範囲にコミットが無いとき、`git revert` も `git push` も実行されない -- [ ] AC7: 結果なしの取り込みを、同じ試行番号でもう一度呼ぶと、結果ファイルを読まずに前回と同じ終了コード 2 を返す。結末の記録の件数は増えない。その間に結果ファイルが現れても読まない -- [ ] AC8: 3 つの取り込みで範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは次のとおりである。適用の取り込みは 4、修正の取り込みは修正ラウンドを 1 進めて 2、最終ゲートの修正の取り込みは 2 +- [x] AC5: 結果なしのとき、3 つの取り込みのいずれでも、記録の辞書(群 / `final_gate`)の結末の記録(`failed_attempts`)に 1 件(`{phase, attempt, impl, reason, detail, at, reverted}`)が足される。理由(`reason`)は結末の読み取りの値、取り消した数(`reverted`)は取り消したコミットの数である +- [x] AC6: 結果なしで範囲にコミットが無いとき、`git revert` も `git push` も実行されない +- [x] AC7: 結果なしの取り込みを、同じ試行番号でもう一度呼ぶと、結果ファイルを読まずに前回と同じ終了コード 2 を返す。結末の記録の件数は増えない。その間に結果ファイルが現れても読まない +- [x] AC8: 3 つの取り込みで範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは次のとおりである。適用の取り込みは 4、修正の取り込みは修正ラウンドを 1 進めて 2、最終ゲートの修正の取り込みは 2 ## 受け入れ条件(#647: 適用ラウンド) -- [ ] AC9: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに群を開く → 適用の取り込みを呼ぶ。終了コードは 2。1 つ目の群は未着手(`status: pending`)のまま担当が agy 以外に替わる。試行の番号(`attempt`)は 1、結末の記録は 1 件(`phase: apply`、`attempt: 1`、`impl: agy`)である -- [ ] AC10: AC9 の後、替わった担当の結果ファイルも置かずにもう一度、群を開く → 適用の取り込みを呼ぶ。1 つ目の群は取り消し済み(`status: dropped`・`drop_reason: no_result`)、項目は `abandoned` になる。見送り(`deferred_items`)に `実装担当が結果を残しませんでした(agy: missing → codex: missing)` の形の理由で入る -- [ ] AC11: 結果ファイルを 1 つも置かずに、群を開く操作が 1 を返すまで繰り返す。群を開く操作の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、両方の群が取り消し済み(`dropped`)になる -- [ ] AC12: 結果ファイルが JSON として読めない場合と JSON の配列の場合も AC9 と同じ状態になり、結末の記録の理由(`failed_attempts[].reason`)は `unparsable` である -- [ ] AC13: 群が 4 つ(輪番の通し番号 `apply_seq` が 4)あり、先頭の群(担当 codex)が結果を残さない。替えた後の担当は codex 以外である。輪番の通し番号は進めた分だけ進み、他の群の担当は変わらない -- [ ] AC14: 監視の結果ファイルの理由が `usage_limit` で、輪番から担当を引く関数(`rounds.impl_for_seq`)の差し替えにより交代先が無い状態では、1 回目の失敗で群が取り消し済み(`dropped`、`drop_reason: no_result`)になる。理由が `missing` で交代先が無い状態では、同じ担当で 2 回目を開く -- [ ] AC15: 群を開く操作を、適用の取り込みを挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。群の試行の番号は 1 のまま進まない -- [ ] AC16: 着手前のテストの状態が `green` でない状態で適用の取り込みを呼ぶと、結果ファイルを読まずに終了コード 4 で終わる -- [ ] AC17: 適用の取り込みが終了コード 2 で終わった後の群は、取り消し済み(`dropped`)か、結末の記録を持つ未着手(`pending`)のどちらかである。確かめる経路は 4 つ(結果なし / 未割当のコミット / 適用の検証の失敗 / 取り込み済みで採用 0 件) -- [ ] AC18: 開き直しの判定(`rounds.group_reopening`)を差し替えると、群を開く側の開き方(開く・再開・開かない)と、適用の取り込みの結果なしの後の扱い(担当の交代・取り消し)の両方が、差し替えた関数の返す値に従う +- [x] AC9: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに群を開く → 適用の取り込みを呼ぶ。終了コードは 2。1 つ目の群は未着手(`status: pending`)のまま担当が agy 以外に替わる。試行の番号(`attempt`)は 1、結末の記録は 1 件(`phase: apply`、`attempt: 1`、`impl: agy`)である +- [x] AC10: AC9 の後、替わった担当の結果ファイルも置かずにもう一度、群を開く → 適用の取り込みを呼ぶ。1 つ目の群は取り消し済み(`status: dropped`・`drop_reason: no_result`)、項目は `abandoned` になる。見送り(`deferred_items`)に `実装担当が結果を残しませんでした(agy: missing → codex: missing)` の形の理由で入る +- [x] AC11: 結果ファイルを 1 つも置かずに、群を開く操作が 1 を返すまで繰り返す。群を開く操作の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、両方の群が取り消し済み(`dropped`)になる +- [x] AC12: 結果ファイルが JSON として読めない場合と JSON の配列の場合も AC9 と同じ状態になり、結末の記録の理由(`failed_attempts[].reason`)は `unparsable` である +- [x] AC13: 群が 4 つ(輪番の通し番号 `apply_seq` が 4)あり、先頭の群(担当 codex)が結果を残さない。替えた後の担当は codex 以外である。輪番の通し番号は進めた分だけ進み、他の群の担当は変わらない +- [x] AC14: 監視の結果ファイルの理由が `usage_limit` で、輪番から担当を引く関数(`rounds.impl_for_seq`)の差し替えにより交代先が無い状態では、1 回目の失敗で群が取り消し済み(`dropped`、`drop_reason: no_result`)になる。理由が `missing` で交代先が無い状態では、同じ担当で 2 回目を開く +- [x] AC15: 群を開く操作を、適用の取り込みを挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。群の試行の番号は 1 のまま進まない +- [x] AC16: 着手前のテストの状態が `green` でない状態で適用の取り込みを呼ぶと、結果ファイルを読まずに終了コード 4 で終わる +- [x] AC17: 適用の取り込みが終了コード 2 で終わった後の群は、取り消し済み(`dropped`)か、結末の記録を持つ未着手(`pending`)のどちらかである。確かめる経路は 4 つ(結果なし / 未割当のコミット / 適用の検証の失敗 / 取り込み済みで採用 0 件) +- [x] AC18: 開き直しの判定(`rounds.group_reopening`)を差し替えると、群を開く側の開き方(開く・再開・開かない)と、適用の取り込みの結果なしの後の扱い(担当の交代・取り消し)の両方が、差し替えた関数の返す値に従う ## 受け入れ条件(#592: 採用 0 件と項目の無い群) -- [ ] AC19: テスト整備ラウンドで提案が 0 件の状態で提案の取り込み(`merge-proposals`)を呼んだ後、群を開く操作を呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの群の配列(`apply_rounds`)は空のままである -- [ ] AC20: 群の配列の鍵(`apply_rounds`)を持たない状態ファイル(群を導入する前の版)では、群を開く操作が従来どおりラウンド全体を 1 つの群として開く -- [ ] AC21: 前提: rf587 で残った形の群(`status: applied`・`items: []`・`apply.merged_at` あり・`applied: []`) +- [x] AC19: テスト整備ラウンドで提案が 0 件の状態で提案の取り込み(`merge-proposals`)を呼んだ後、群を開く操作を呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの群の配列(`apply_rounds`)は空のままである +- [x] AC20: 群の配列の鍵(`apply_rounds`)を持たない状態ファイル(群を導入する前の版)では、群を開く操作が従来どおりラウンド全体を 1 つの群として開く +- [x] AC21: 前提: rf587 で残った形の群(`status: applied`・`items: []`・`apply.merged_at` あり・`applied: []`) 操作: 適用の取り込みを呼ぶ 結果: 終了コード 2 で終わり、群が取り消し済み(`dropped`、`drop_reason: empty`)になる。続く群を開く操作は 1 を返す -- [ ] AC22: 未着手で項目が無い群(`status: pending`・`items: []`)を持つ状態で、群を開く操作を呼ぶ。その群は開かれずに取り消し済み(`dropped`、`drop_reason: empty`)になる。次の群があればそれを開き、無ければ終了コード 1 を返す +- [x] AC22: 未着手で項目が無い群(`status: pending`・`items: []`)を持つ状態で、群を開く操作を呼ぶ。その群は開かれずに取り消し済み(`dropped`、`drop_reason: empty`)になる。次の群があればそれを開き、無ければ終了コード 1 を返す ## 受け入れ条件(修正ラウンド) -- [ ] AC23: 群の担当が agy、提案ラウンドの担当が codex の状態で、agy の結果ファイル(`agy-fix-r1-result.json`)を置いて修正の取り込みを呼ぶ。agy の結果が取り込まれ、修正ラウンドの数(`fix_rounds`)が 1 になる -- [ ] AC24: 修正の結果ファイルが無い状態で修正の取り込みを呼ぶと、終了コード 2 で終わり、修正ラウンドの数が 1 進む。群の結末の記録に `phase: fix` の 1 件が足される。上限(`--max-fix-rounds`)の回数だけ続けた後の見送りの判定(`should-abandon`)は終了コード 0 を返す -- [ ] AC25: AC24 の直後に検証(`verify-round`)を挟まず修正の取り込みをもう一度呼んでも、修正ラウンドの数は進まない(AC7 の修正ラウンドの形) -- [ ] AC26: 修正の結果なしで監視の理由が `usage_limit` のとき、修正の取り込みは修正ラウンドの数を上限の値にする。続く見送りの判定は終了コード 0 を返す -- [ ] AC27: 修正の結果なしで起点から HEAD にコミットがあるとき、取り消され、起点(`fix_base_sha`)が取り消し後の HEAD になる(AC4 の修正ラウンドの形) +- [x] AC23: 群の担当が agy、提案ラウンドの担当が codex の状態で、agy の結果ファイル(`agy-fix-r1-result.json`)を置いて修正の取り込みを呼ぶ。agy の結果が取り込まれ、修正ラウンドの数(`fix_rounds`)が 1 になる +- [x] AC24: 修正の結果ファイルが無い状態で修正の取り込みを呼ぶと、終了コード 2 で終わり、修正ラウンドの数が 1 進む。群の結末の記録に `phase: fix` の 1 件が足される。上限(`--max-fix-rounds`)の回数だけ続けた後の見送りの判定(`should-abandon`)は終了コード 0 を返す +- [x] AC25: AC24 の直後に検証(`verify-round`)を挟まず修正の取り込みをもう一度呼んでも、修正ラウンドの数は進まない(AC7 の修正ラウンドの形) +- [x] AC26: 修正の結果なしで監視の理由が `usage_limit` のとき、修正の取り込みは修正ラウンドの数を上限の値にする。続く見送りの判定は終了コード 0 を返す +- [x] AC27: 修正の結果なしで起点から HEAD にコミットがあるとき、取り消され、起点(`fix_base_sha`)が取り消し後の HEAD になる(AC4 の修正ラウンドの形) ## 受け入れ条件(#674: 最終ゲートの修正) -- [ ] AC28: 前提: 最終ゲートの修正の結果ファイルが無く、最終ゲートの起点(`final_gate.fix_base_sha`)から HEAD にコミットが 1 件ある +- [x] AC28: 前提: 最終ゲートの修正の結果ファイルが無く、最終ゲートの起点(`final_gate.fix_base_sha`)から HEAD にコミットが 1 件ある 操作: 最終ゲートの修正の取り込みを呼ぶ 結果: 終了コード 2。そのコミットは取り消され、最終ゲートの起点は取り消し後の HEAD になる。最終ゲートの結末の記録(`final_gate.failed_attempts`)は 1 件(`phase: final-fix`) -- [ ] AC29: AC28 の後に最終ゲートを呼ぶと、テストは取り消し後の HEAD で実行され、修正のコミットの一覧(`fix_commits`)に取り消したコミットは入らない -- [ ] AC30: 最終ゲートの修正の結果なしで監視の理由が `usage_limit` のとき、最終ゲートの修正ラウンドの数(`final_gate.fix_rounds`)は上限の値になる。続く最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる -- [ ] AC31: 結果ファイルがあり検証を通る最終ゲートの修正は、変更前と同じく取り込まれ、最終ゲートの結末の記録を持たない +- [x] AC29: AC28 の後に最終ゲートを呼ぶと、テストは取り消し後の HEAD で実行され、修正のコミットの一覧(`fix_commits`)に取り消したコミットは入らない +- [x] AC30: 最終ゲートの修正の結果なしで監視の理由が `usage_limit` のとき、最終ゲートの修正ラウンドの数(`final_gate.fix_rounds`)は上限の値になる。続く最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる +- [x] AC31: 結果ファイルがあり検証を通る最終ゲートの修正は、変更前と同じく取り込まれ、最終ゲートの結末の記録を持たない ## 受け入れ条件(#553: 帰属行の後ろのトレーラー) 一時リポジトリで実際にコミットを作って確かめる: -- [ ] AC32: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、トレーラーの読み取りが 4 つとも値を返す -- [ ] AC33: AC32 の段落の後に `Co-Authored-By:` と `Claude-Session:` の 2 行の段落が付いても、4 つとも返す -- [ ] AC34: 必須トレーラーの段落と末尾の段落の間に散文の段落があるコミットで、散文より前にある `Round: …` の形の行を読まない -- [ ] AC35: 末尾の段落に散文とトレーラーの形の行が混ざる(git がトレーラーの段落と判定しない)コミットで、その行を読まない -- [ ] AC36: 同じ鍵が 2 つの段落にあるとき、末尾に近い段落の値を返す -- [ ] AC37: AC32 の形のコミットを申告した適用ラウンドが、トレーラーの欠落で取り消されない -- [ ] AC38: 本文がトレーラーの段落 1 つだけで、題名が `Round: 本文の題名` の形のコミットで、題名を読まない -- [ ] AC39: 適用と修正の雛形(`prompts/apply.md` / `prompts/fix.md`)のコミットの規約が、必須トレーラーをメッセージの最後の段落に置くことを書く +- [x] AC32: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、トレーラーの読み取りが 4 つとも値を返す +- [x] AC33: AC32 の段落の後に `Co-Authored-By:` と `Claude-Session:` の 2 行の段落が付いても、4 つとも返す +- [x] AC34: 必須トレーラーの段落と末尾の段落の間に散文の段落があるコミットで、散文より前にある `Round: …` の形の行を読まない +- [x] AC35: 末尾の段落に散文とトレーラーの形の行が混ざる(git がトレーラーの段落と判定しない)コミットで、その行を読まない +- [x] AC36: 同じ鍵が 2 つの段落にあるとき、末尾に近い段落の値を返す +- [x] AC37: AC32 の形のコミットを申告した適用ラウンドが、トレーラーの欠落で取り消されない +- [x] AC38: 本文がトレーラーの段落 1 つだけで、題名が `Round: 本文の題名` の形のコミットで、題名を読まない +- [x] AC39: 適用と修正の雛形(`prompts/apply.md` / `prompts/fix.md`)のコミットの規約が、必須トレーラーをメッセージの最後の段落に置くことを書く ## 受け入れ条件(無進捗の打ち切り) -- [ ] AC40: 起動(`init`)の出力に無進捗の許容(`IMPL_STALL_TIMEOUT`)が入り、値がテストの制限時間(`--test-timeout`)の値 + 900 である(既定で 1800) -- [ ] AC41: `SKILL.md` の骨組みで、適用・修正・最終ゲートの修正(`--phase apply` / `fix` / `final-fix`)の 3 つの監視(`monitor.py`)の呼び出しが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない -- [ ] AC42: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が、作業段階ごとに進捗の記録(`$RF_STEM-progress.log`)へ 1 行追記する指示を持つ +- [x] AC40: 起動(`init`)の出力に無進捗の許容(`IMPL_STALL_TIMEOUT`)が入り、値がテストの制限時間(`--test-timeout`)の値 + 900 である(既定で 1800) +- [x] AC41: `SKILL.md` の骨組みで、適用・修正・最終ゲートの修正(`--phase apply` / `fix` / `final-fix`)の 3 つの監視(`monitor.py`)の呼び出しが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない +- [x] AC42: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が、作業段階ごとに進捗の記録(`$RF_STEM-progress.log`)へ 1 行追記する指示を持つ ## 受け入れ条件(文書) -- [ ] AC43: `SKILL.md` の「この Skill で使う語」の適用ラウンドの行が、同じ群の試行の上限(2 回)を書く。`grep -n "別の上限を置かない\|別に置かない" SKILL.md` が何も出力しない -- [ ] AC44: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6・Step 7 が 2 つを書く。結果なしのときの取り込みの振る舞い(取り消し・記録・終了コード)と、`SKILL.md` と同じ監視の引数である -- [ ] AC45: `docs/02-apply-and-review.md` のトレーラーの節が、git の標準の読み方(`git log --format='%(trailers:…)'`)が最後の段落しか読まないことと、進行側の読み方の 2 つを書く +- [x] AC43: `SKILL.md` の「この Skill で使う語」の適用ラウンドの行が、同じ群の試行の上限(2 回)を書く。`grep -n "別の上限を置かない\|別に置かない" SKILL.md` が何も出力しない +- [x] AC44: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6・Step 7 が 2 つを書く。結果なしのときの取り込みの振る舞い(取り消し・記録・終了コード)と、`SKILL.md` と同じ監視の引数である +- [x] AC45: `docs/02-apply-and-review.md` のトレーラーの節が、git の標準の読み方(`git log --format='%(trailers:…)'`)が最後の段落しか読まないことと、進行側の読み方の 2 つを書く ## 受け入れ条件(退行しない) -- [ ] AC46: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、結末の記録を持たない -- [ ] AC47: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC48: 配布物の同期・定義・frontmatter の 3 つの検査が終了コード 0 で終わる(コマンドは「検証手段」の表) +- [x] AC46: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、結末の記録を持たない +- [x] AC47: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [x] AC48: 配布物の同期・定義・frontmatter の 3 つの検査が終了コード 0 で終わる(コマンドは「検証手段」の表) ## 受け入れ条件(他の設計との契約) -- [ ] AC49: 輪番から担当を引く関数(`rounds.impl_for_seq`)を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの修正担当の 3 つが、差し替えた関数の返す担当になる -- [ ] AC50: 取り消しの本体は取り込みの共通手順の 1 つ(`intake.discard_unverified`)になる。`gitfacts.revert_unverified_range` は無くなり、`apply._revert_unverified_apply_round` は `discard_unverified` を呼ぶ +- [x] AC49: 輪番から担当を引く関数(`rounds.impl_for_seq`)を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの修正担当の 3 つが、差し替えた関数の返す担当になる +- [x] AC50: 取り消しの本体は取り込みの共通手順の 1 つ(`intake.discard_unverified`)になる。`gitfacts.revert_unverified_range` は無くなり、`apply._revert_unverified_apply_round` は `discard_unverified` を呼ぶ ## 非機能の条件 From 0c3b60c7101ac9b439e0b13b677f8061b81eb851 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:18:19 +0000 Subject: [PATCH 124/217] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20?= =?UTF-8?q?plugins/ndf/scripts/lib/assignment.py#review=5Fassign?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit review_assign の輪番(母集合 3 者から (ラウンド番号 - 1) % 3 の 1 者を外す 各分岐)を現状固定テストで固定する。ホスト claude / codex / agy / kiro それぞれ について round 1..6 を通し、3 ラウンド周期で循環すること、返るレビュー担当が 常に 2 者でホスト自身を含まないことを併せて固定する。対象コードは変更しない。 Item-Id: R2-004 Round: 2 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- .../ndf/scripts/tests/test_lib_assignment.py | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index a0d97302..bac91ccf 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -52,6 +52,15 @@ ], } +# `review_assign` の 3 ラウンド周期(現状の出力を記録したもの)。母集合は +# 全ランタイム − ホストの 3 者で、外す 1 者が (ラウンド番号 - 1) % 3 で回る。 +EXPECTED_REVIEW = { + "claude": [["agy", "kiro"], ["codex", "kiro"], ["codex", "agy"]], + "codex": [["agy", "kiro"], ["claude", "kiro"], ["claude", "agy"]], + "agy": [["codex", "kiro"], ["claude", "kiro"], ["claude", "codex"]], + "kiro": [["codex", "agy"], ["claude", "agy"], ["claude", "codex"]], +} + @pytest.fixture(scope="module") def assignment(): @@ -144,6 +153,20 @@ def test_review_assign_rejects_a_bad_round(assignment): assert "ラウンド番号は 1 以上です" in str(excinfo.value) +@pytest.mark.parametrize("host", EXPECTED_REVIEW) +def test_review_assign_keeps_the_three_round_rotation(assignment, host): + """現状固定: ホストごとに 3 ラウンド周期でレビュー担当が循環する(R2-004)。 + + 期待値は現在の実装の出力を記録したもので、仕様を主張しない。母集合 3 者から + 外す 1 者を回す各分岐(dropped = 0 / 1 / 2)を、2 周分で通す。 + """ + actual = [assignment.review_assign(round_no, host) for round_no in range(1, 7)] + + assert actual == EXPECTED_REVIEW[host] * 2 + assert all(len(reviewers) == 2 for reviewers in actual) + assert all(host not in reviewers for reviewers in actual) + + # ---------- 適用の輪番(#727。cross-refactoring が使う) ---------- def test_impl_assign_rotates_over_the_participants_starting_after_the_host(assignment): From 01a221b0f0398bd0d8a35cea27fd47dd6e803098 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:18:54 +0000 Subject: [PATCH 125/217] =?UTF-8?q?Revert=20"Test:=20characterization=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/assignment.py#review=5Fassi?= =?UTF-8?q?gn"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 0c3b60c7101ac9b439e0b13b677f8061b81eb851. --- .../ndf/scripts/tests/test_lib_assignment.py | 23 ------------------- 1 file changed, 23 deletions(-) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index bac91ccf..a0d97302 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -52,15 +52,6 @@ ], } -# `review_assign` の 3 ラウンド周期(現状の出力を記録したもの)。母集合は -# 全ランタイム − ホストの 3 者で、外す 1 者が (ラウンド番号 - 1) % 3 で回る。 -EXPECTED_REVIEW = { - "claude": [["agy", "kiro"], ["codex", "kiro"], ["codex", "agy"]], - "codex": [["agy", "kiro"], ["claude", "kiro"], ["claude", "agy"]], - "agy": [["codex", "kiro"], ["claude", "kiro"], ["claude", "codex"]], - "kiro": [["codex", "agy"], ["claude", "agy"], ["claude", "codex"]], -} - @pytest.fixture(scope="module") def assignment(): @@ -153,20 +144,6 @@ def test_review_assign_rejects_a_bad_round(assignment): assert "ラウンド番号は 1 以上です" in str(excinfo.value) -@pytest.mark.parametrize("host", EXPECTED_REVIEW) -def test_review_assign_keeps_the_three_round_rotation(assignment, host): - """現状固定: ホストごとに 3 ラウンド周期でレビュー担当が循環する(R2-004)。 - - 期待値は現在の実装の出力を記録したもので、仕様を主張しない。母集合 3 者から - 外す 1 者を回す各分岐(dropped = 0 / 1 / 2)を、2 周分で通す。 - """ - actual = [assignment.review_assign(round_no, host) for round_no in range(1, 7)] - - assert actual == EXPECTED_REVIEW[host] * 2 - assert all(len(reviewers) == 2 for reviewers in actual) - assert all(host not in reviewers for reviewers in actual) - - # ---------- 適用の輪番(#727。cross-refactoring が使う) ---------- def test_impl_assign_rotates_over_the_participants_starting_after_the_host(assignment): From 970d20cd871c120bac4cfc68608e23275e984a23 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:18:55 +0000 Subject: [PATCH 126/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 0db70c64..fa687d89 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -103,7 +103,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | agy / kiro | 検証中 | 1 | +| boundary | unit | — | agy / kiro | 採用 | 1 | **なぜ**: review_assign の round_no < 1 の下限境界条件で AssignmentError を送出する振る舞いが scripts/tests 内で固定されていない。同モジュールの impl_assign や review_seats には round_no < 1 の境界テストがあるが、review_assign だけ抜けている。 @@ -115,7 +115,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| branch | unit | — | agy / kiro | 未着手 | 0 | +| branch | unit | — | agy / kiro | 取り消し | 1 | **なぜ**: review_assign は適用の役を持たない工程が使う公開入口だが、scripts/tests には直接の固定が無い。in-scope の test_lib_assignment.py は assign / impl_assign / review_seats を固定するだけで、この関数の輪番(母集合3者から dropped=(round_no-1)%3 を外す各分岐)は通っていない。out-of-scope の cross-review テストは _round_reviewers の照合オラクルとして呼ぶだけで、この関数自身の戻り値を固定していない。 @@ -148,3 +148,4 @@ | 1 | `plugins/ndf/scripts/lib/assignment.py#seat_runtime` | boundary | コミット 36dd097d4dff427b0de545bcd0cdc0de0e7b74fb にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | | 2 | `plugins/ndf/scripts/lib/assignment.py#review_seats` | boundary | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#main` | normal | 1 ラウンドの採用上限 5 件を超えた | +| 2 | `plugins/ndf/scripts/lib/assignment.py#review_assign` | branch | コミット 0c3b60c7101ac9b439e0b13b677f8061b81eb851 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | From da936fe1ff759aa9b03cf1fc8e68125e1bed7e6a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:23:07 +0000 Subject: [PATCH 127/217] =?UTF-8?q?Test:=20review=5Fassign=20=E3=81=AE?= =?UTF-8?q?=E7=84=A1=E5=8A=B9=E3=83=9B=E3=82=B9=E3=83=88=E3=82=92=E7=8F=BE?= =?UTF-8?q?=E7=8A=B6=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HOST_RUNTIMES に含まれないホストで AssignmentError と既存メッセージが返る経路を固定する。 Item-Id: R2-005 Round: 2 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_lib_assignment.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index a0d97302..96d7bc63 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -144,6 +144,15 @@ def test_review_assign_rejects_a_bad_round(assignment): assert "ラウンド番号は 1 以上です" in str(excinfo.value) +@pytest.mark.parametrize("host", ("gemini", "unknown")) +def test_review_assign_rejects_a_host_outside_host_runtimes(assignment, host): + """HOST_RUNTIMES に含まれないホストを拒否する現状を固定する(R2-005)。""" + with pytest.raises(assignment.AssignmentError) as excinfo: + assignment.review_assign(1, host) + + assert "ホストになれないランタイムです" in str(excinfo.value) + + # ---------- 適用の輪番(#727。cross-refactoring が使う) ---------- def test_impl_assign_rotates_over_the_participants_starting_after_the_host(assignment): From a8a691f53f51cd2b755ee84a42427f2b3bf28d7f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:24:00 +0000 Subject: [PATCH 128/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index fa687d89..de73a2c3 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -127,7 +127,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex / agy | 未着手 | 0 | +| error | unit | — | codex / agy | 検証中 | 1 | **なぜ**: review_assign に HOST_RUNTIMES に含まれない無効なホスト名が渡された場合、内部の review_pool から AssignmentError(「ホストになれないランタイムです」)が送出されるエラー経路が固定されていない。 From 604a0794c3728d39d986701413e523db56b244b7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:31:14 +0000 Subject: [PATCH 129/217] =?UTF-8?q?Docs:=20=E5=A4=89=E3=81=88=E3=82=8B?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E3=81=A8=E5=A4=89=E3=81=88=E3=81=AA=E3=81=84?= =?UTF-8?q?=E5=87=BA=E5=8A=9B=E3=82=92=E5=88=86=E3=81=91=E3=80=81=E5=A5=91?= =?UTF-8?q?=E7=B4=84=E3=81=A8=E6=9C=AA=E7=A2=BA=E8=AA=8D=E3=81=AE=E9=A0=85?= =?UTF-8?q?=E7=9B=AE=E3=82=92=E8=A3=9C=E3=81=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 設計の「終了コード」の節を「変える出力と変えない出力」へ置き換え、 終了コード・判定が読む変数・取り込みの標準出力を分けて書く - AC23 を同じ区分に合わせて書き直す - 入出力の契約の review_posts に is_own_pr を足し、判定の格下げを この層が決めることを明記する - 決定 11 のコマンド例に --repo / --worktree を足し、待ち行列の 置き場所を渡さないことを書く - 未確認のまま残ることへ、拒まれた応答からインラインを 1 件ずつ 特定できるかどうかを足す Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 18 ++++++++++++++---- issues/issue-730-583-requirements.md | 4 +++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index bc1dff32..3d4d31bc 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -156,9 +156,12 @@ 返信・決着・まとめの投稿・送信の実装を、共通層の 1 つのまとまりに置く。置き場所は `plugins/ndf/scripts/lib/result_posts.py` である。cross-review から呼ぶときは修正の取り込みがこれを呼び、単独で使うときは同じまとまりを部分命令として直接呼ぶ。 ```bash -python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <結果ファイル> --head <ブランチ名> +python3 "$SCRIPTS/lib/result_posts.py" fix --repo <所有者>/<リポジトリ> --pr <番号> \ + --result <結果ファイル> --head <ブランチ名> --worktree <作業ツリー> ``` +**待ち行列の置き場所は渡さない。** 作業ツリーの下の決まった名前から導く。リポジトリと作業ツリーを省いたときは、いまいるディレクトリから引く。 + **新しい入口のスクリプトを足さない。** 待ち行列のまとまりが取り込み用の口と部分命令の口の両方を持つ形に前例がある。手順に書く行は 1 本で済み、実装は 1 つになる。 ### 決定 12: 読み取り側が途中の内容を読まないよう、担当は結果を一時の名前で書いてから改名する @@ -226,12 +229,14 @@ PR の締めと作り直しと再開はすでにレビューを回す側が行 | 関数 | 入力 | 出力 | | --- | --- | --- | -| `review_posts(payload_path, result_path, repo, pr, round_no, seat, head_sha)` | 控えと結果ファイルのパス | 待ち行列へ積む項目の列 | +| `review_posts(payload_path, result_path, repo, pr, round_no, seat, head_sha, is_own_pr)` | 控えと結果ファイルのパス、および自分の Pull Request かどうか | 待ち行列へ積む項目の列 | | `fix_posts(result_path, repo, pr)` | 修正の結果ファイルのパス | 待ち行列へ積む項目の列 | | `push_fix(worktree, head_branch, fix_commit)` | 作業ツリーと送り先とコミット | 送信の結果と照合の可否 | **本文は引数として渡さない。** どの関数もファイルのパスを受け取り、本文はまとまりの中だけで扱う。 +**判定の格下げは、自分の Pull Request かどうかを受け取ったこの層が決める**(決定 14)。呼ぶ側は状態ファイルが持つ値をそのまま渡す。 + ### 取り込みの標準出力 ```text @@ -242,9 +247,13 @@ FINDINGS=8 本文は出さない。上限で送れなかったときは `QUEUED` が 1 以上になり、判定の終了コード 8 の枝が流し直す。 -### 終了コード +### 変える出力と変えない出力 + +**終了コードは変えない。** 取り込み・判定・報告のどれも、変更前と同じ値を返す。 + +**収束の判定と報告が読む変数も変えない**(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS`)。 -変えない。取り込み・判定・報告の終了コードと標準出力の変数は変更前と同じである。 +**変わるのは取り込みの標準出力だけである。** 投稿の結果を表す行(`POSTED` / `INLINE` / `BODY` / `QUEUED`)と、取り込んだ指摘の件数(`FINDINGS`)を足す。判定が出す `NEW_FINDINGS` はこれとは別の変数で、変えない。 ## 構成要素 @@ -356,6 +365,7 @@ graph TD ## 未確認のまま残ること - **差分の外を指す指摘が拒まれるときの応答の形。** HTTP 422 が返ることは GitHub の仕様として知られている。応答の本文が行を解決できないことをどの語で示すかと、本文とインラインを同じ要求で送ったときにどちらが拒まれるかは未確認である。実装の最初の段で、偽ではない `gh` で 1 度確かめ、判定に使う語を契約へ書く +- **拒まれた応答から、どのインラインが原因かを 1 件ずつ特定できるかどうか。** 特定できなければ、拒まれた要求のインラインをすべて本文へ退避する形へ落とす。落としたときに本文が長くなりすぎないかを、実装の段で 1 度測る - **決着の投稿を、すでに決着したスレッドへもう一度送ったときの応答。** 失敗にならないことを前提に置いているが、実行して確かめていない - **投稿者のアカウントが席ごとに違う環境があるかどうか。** いまの作業環境では 1 つだが、別の環境で担当ごとに別の認証を使う設定があると、照合の鍵の前提が変わる diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md index 845b088c..4a60879c 100644 --- a/issues/issue-730-583-requirements.md +++ b/issues/issue-730-583-requirements.md @@ -116,7 +116,9 @@ GitHub と git へ書くのをレビューを回す側だけにし、担当は ### 退行しない -- [ ] AC23: 取り込み・判定・報告の終了コードと標準出力の変数は変更前と同じである +- [ ] AC23: 取り込み・判定・報告の終了コードは変更前と同じである。 + 収束の判定と報告が読む変数(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS`)も変わらない。 + 取り込みの標準出力には、投稿の結果を表す行が足される(AC6) - [ ] AC24: 席の名前(#727)・結末の語彙(#729)・数えない指摘の区分(#732)の扱いを変えない ### 文書 From a577efab6a4756322023f36b481609a778419d69 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:38:00 +0000 Subject: [PATCH 130/217] =?UTF-8?q?Test:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E6=9C=AA=E5=9B=BA=E5=AE=9A=E7=B5=8C=E8=B7=AF=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用試行の再実行、最終修正の範囲不明、作業ツリー基点の解決順を現状固定テストで保護する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- .../tests/test_apply_attempts.py | 31 +++++++++++++++++++ .../cross-refactoring/tests/test_final_fix.py | 21 +++++++++++++ .../cross-refactoring/tests/test_paths.py | 16 ++++++++++ 3 files changed, 68 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py index 5d6db597..8deb34e6 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py @@ -259,6 +259,37 @@ def test_an_unverified_baseline_stops_before_reading_the_result( assert read_state(state_path)["items"][0]["status"] == "blocked" +def test_a_closed_apply_attempt_stops_before_reading_the_result( + tmp_path, patch_lib, cmd_apply, env_tmp_dir, no_git +): + """現状固定: 記録済みの試行を叩き直しても、結果を読み直さない。""" + group = _group( + 1, + "agy", + ["R1-001"], + attempt=1, + failed_attempts=[{ + "phase": "apply", "attempt": 1, "impl": "agy", + "reason": "missing", "detail": "", "at": "x", "reverted": 0, + }], + ) + state_path = make_state( + tmp_path, items=[_item("R1-001")], + rounds=[_entry([group], ["R1-001"])], phase="apply", outer_round=1, + ) + env_tmp_dir(state_path) + patch_lib("read_result", lambda *a, **k: (_ for _ in ()).throw( + AssertionError("結果を読んではいけない"))) + before = read_state(state_path) + + code = _exit_code(cmd_apply.cmd_merge_apply, type("A", (), dict(_ARGS_MERGE))()) + + after = read_state(state_path) + assert code == 2 + assert after == before + assert len(after["rounds"][0]["apply_rounds"][0]["failed_attempts"]) == 1 + + def test_every_exit_2_leaves_the_group_dropped_or_pending_with_a_record( two_groups, run ): diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py b/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py index d8b5adff..15dde663 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py @@ -392,6 +392,27 @@ def test_a_missing_final_fix_result_reverts_and_moves_the_base( assert "fix_commits" not in gate +def test_a_missing_final_fix_with_an_unknown_range_keeps_the_attempt_open( + patch_lib, cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """現状固定: 結果も範囲も無ければ、記録も取り消しも行わず止まる。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + patch_lib("commits_in_range", lambda work, base, head: None) + before = read_state(state_path)["final_gate"] + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + gate = read_state(state_path)["final_gate"] + assert e.value.code == 2 + assert gate == before + assert gate["fix_rounds"] == 1 + assert gate["fix_base_sha"] == "BASE" + assert "failed_attempts" not in gate + assert merge_spy["reverted"] == [] + + def test_the_next_gate_does_not_see_the_reverted_commits( patch_lib, cmd_gate, tmp_path, env_tmp_dir, merge_spy, gate_spy ): diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_paths.py b/plugins/ndf/skills/cross-refactoring/tests/test_paths.py index f113e556..3d2bc91a 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_paths.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_paths.py @@ -9,6 +9,7 @@ import json import pathlib +import tempfile STATE_ID = 130 @@ -82,3 +83,18 @@ def test_it_uses_the_cwd_when_the_env_var_is_unset(paths, tmp_path, monkeypatch) assert path == cwd_path assert state["phase"] == "from-cwd" + + +def test_the_explicit_worktree_base_is_resolved(paths, tmp_path, monkeypatch): + """現状固定: 明示した作業ディレクトリの親を絶対パスへ解決する。""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("NDF_WORKTREE_BASE", "relative-worktrees") + + assert paths.default_worktree_base() == (tmp_path / "relative-worktrees").resolve() + + +def test_the_worktree_base_falls_back_to_the_system_tmpdir(paths, monkeypatch): + """現状固定: 明示指定が無ければシステム tmpdir 配下を使う。""" + monkeypatch.delenv("NDF_WORKTREE_BASE", raising=False) + + assert paths.default_worktree_base() == pathlib.Path(tempfile.gettempdir()) / "ndf-worktrees" From 233f28ba531364fc6a068c1685afd0ede7248bad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:38:32 +0000 Subject: [PATCH 131/217] =?UTF-8?q?Docs:=20=E4=BB=B6=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E5=87=BA=E6=89=80=E3=82=92=E5=9B=B3=E3=81=A8=E6=A4=9C=E8=A8=BC?= =?UTF-8?q?=E6=89=8B=E6=AE=B5=E3=81=AE=E8=A1=A8=E3=81=B8=E6=8F=83=E3=81=88?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビューの応答は URL だけを返すため、図の応答を「レビューの URL」へ直し、 件数は送れたインラインを数えて組み立てることを図へ明記する。あわせて AC23・AC24 の検証手段を、標準出力ではなく収束の判定・報告が読む変数を 見る記述へ改める。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index 3d4d31bc..7de56e85 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -297,8 +297,8 @@ sequenceDiagram A-->>M: 終了 M->>M: 控えを読み、投稿を待ち行列へ積む M->>G: レビューを送る - G-->>M: URL と件数 - M->>M: 記録へ書き戻し、指摘を取り込む + G-->>M: レビューの URL + M->>M: 送れたインラインを数え、URL と件数を記録し、指摘を取り込む ``` 担当が控えを書く前に止まれば、レビューを回す側は積むものを持たないため、GitHub には何も増えない。起動し直しても重ならない。 @@ -355,7 +355,7 @@ graph TD | AC18・AC19 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | | AC20 | 送信の後・記録の前で取り込みを止め、同じ取り込みをもう一度呼ぶ結合の経路で、レビューが 1 件しか増えないことを見る | | AC21・AC22 | 共通層の部分命令を直接呼び、cross-review から呼んだときと同じ項目が積まれることを見る | -| AC23・AC24 | 取り込み・判定・報告の終了コードと標準出力を見る既存のテストを変えずに通す | +| AC23・AC24 | 終了コードと、収束の判定・報告が読む変数を見る既存のテストを変えずに通す | | AC25〜AC29 | 変更した文書の行を読む検査 | | AC32 | 自分の Pull Request の状態で投稿を組み立て、送った形が `COMMENT` で本来の判定が変わらないことを見る | | AC30・AC31 | 検証手段の表のコマンド | From 352bd71f360287a4669d85930e344f0600caa1ce Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:45:38 +0000 Subject: [PATCH 132/217] =?UTF-8?q?Test:=20=E7=B5=90=E6=9E=9C=E3=81=AA?= =?UTF-8?q?=E3=81=97=E3=83=BB=E7=AF=84=E5=9B=B2=E7=A2=BA=E5=AE=9A=E4=B8=8D?= =?UTF-8?q?=E8=83=BD=E6=99=82=E3=81=AE=E9=81=A9=E7=94=A8=E4=B8=AD=E6=96=AD?= =?UTF-8?q?=E3=81=A8=E9=A0=85=E7=9B=AE=E3=83=96=E3=83=AD=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=92=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 結果ファイルが無く、かつ起点から HEAD までの範囲を確定できない経路において、 cmd_merge_apply が項目を blocked にして終了コード 4 で中断し、失敗試行の記録や コミット取り消しを行わない振る舞いを現状固定テストで保護する。 Item-Id: R1-002 Round: 1 Impl-Runtime: agy Impl-Model: default --- .../tests/test_apply_attempts.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py index 8deb34e6..d36fe48e 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py @@ -259,6 +259,25 @@ def test_an_unverified_baseline_stops_before_reading_the_result( assert read_state(state_path)["items"][0]["status"] == "blocked" +def test_missing_result_with_undetermined_range_blocks_items_and_exits_4( + two_groups, run, patch_lib, no_git +): + """現状固定: 結果がなく範囲を確定できないときは項目を blocked にして終了コード 4 で中断する。""" + open_round, merge = run(two_groups) + assert open_round() == 0 + + patch_lib("commits_in_range", lambda work, base, head_: None) + + code = merge() + + assert code == 4 + state = read_state(two_groups) + assert state["items"][0]["status"] == "blocked" + group = state["rounds"][0]["apply_rounds"][0] + assert "failed_attempts" not in group + assert [c for c in no_git if c[:2] == ["git", "revert"]] == [] + + def test_a_closed_apply_attempt_stops_before_reading_the_result( tmp_path, patch_lib, cmd_apply, env_tmp_dir, no_git ): From 97510f854f85a0c1861098e4c3a246a94b966839 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:57:42 +0000 Subject: [PATCH 133/217] =?UTF-8?q?Test:=20cmd=5Fmerge=5Ffix=20=E3=81=AE?= =?UTF-8?q?=E7=AF=84=E5=9B=B2=E4=B8=8D=E6=98=8E=E7=B5=8C=E8=B7=AF=E3=82=92?= =?UTF-8?q?=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=20=E2=80=94=20plugins/ndf/?= =?UTF-8?q?skills/cross-refactoring/scripts/refactor=5Flib/commands/conver?= =?UTF-8?q?ge.py#cmd=5Fmerge=5Ffix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正結果が無く範囲も確定できないとき、公開コマンドが修正ラウンドを 1 つ進めて 終了コード 2 を返す振る舞いを固定する。共通部品単体の範囲不明テストでは検出 できない、呼び出し側固有の進行(fix_rounds を進め、failed_attempts を足さず、 取り消しも行わない)を観測する。 Item-Id: R1-003 Round: 1 Impl-Runtime: kiro Impl-Model: default --- .../tests/test_apply_attempts.py | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py index d36fe48e..0afd3505 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py @@ -350,3 +350,60 @@ def test_the_group_assignment_goes_through_the_single_rotation_function( assert impl in {"claude", "codex", "agy", "kiro"} assert requested == state["models"].get(impl) + + +# ---------- 修正結果が無く範囲も確定できないとき(converge.cmd_merge_fix / R1-003) ---------- +# +# **共通部品(intake.close_without_result)単体の範囲不明テストでは足りない。** +# `cmd_merge_fix` はその戻り値を受けて、この呼び出し側固有の進行——修正ラウンドを +# 1 つ進めて終了コード 2 を返す——を行う。ここではその呼び出し側の振る舞いを固定する。 + + +def _fix_entry(fix_base, fix_rounds=0, fix_attempts=1): + """修正フェーズに入った、担当と進行中の群を持つラウンド。""" + group = _group(1, "agy", ["R1-001"], status="applied", + base_sha="base0", head_sha="sha1") + return { + "round": 1, "impl": "codex", "reviewers": ["agy", "kiro"], + "impl_model": {"requested": None, "observed": None}, + "reviewer_models": {}, "proposed": {}, "merged": 1, + "adopted": 1, "deferred": 0, + "items": ["R1-001"], + "apply_rounds": [group], "apply_round": 1, + "apply": {"applied": ["R1-001"], "failed": [], + "base_sha": "base0", "head_sha": "sha1"}, + "fix_rounds": fix_rounds, "fix_attempts": fix_attempts, + "fix_base_sha": fix_base, "durations": {}, "reviews": [], + } + + +def _fix_state(tmp_path, fix_base="fixbase0", fix_rounds=0): + item = _item("R1-001") + item["status"] = "applied" + return make_state( + tmp_path, + items=[item], + rounds=[_fix_entry(fix_base, fix_rounds=fix_rounds)], + phase="fix", outer_round=1, + ) + + +def test_a_missing_fix_result_with_an_unknown_range_advances_the_round_and_exits_2( + tmp_path, patch_lib, cmd_converge, env_tmp_dir, no_git +): + """現状固定: 修正結果が無く範囲も確定できないと、修正ラウンドを 1 つ進めて + 終了コード 2 で中断する。`failed_attempts` は足さず、取り消しも行わない。""" + state_path = _fix_state(tmp_path, fix_rounds=0) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head_: None) + + code = _exit_code(cmd_converge.cmd_merge_fix, + type("A", (), {"id": 130, "round": 1})()) + + assert code == 2 + entry = read_state(state_path)["rounds"][0] + assert entry["fix_rounds"] == 1, "修正ラウンドを 1 つ進める" + group = entry["apply_rounds"][0] + assert "failed_attempts" not in group, "範囲不明では失敗の記録を残さない" + assert [c for c in no_git if c[:2] == ["git", "revert"]] == [], "取り消さない" From fa71026c266248b70d38d69e18fcbc57c1774b8a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:07:59 +0000 Subject: [PATCH 134/217] =?UTF-8?q?Fix:=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AE=E5=AE=9F=E8=A1=8C=E4=B8=AD=E3=81=AF=E7=9B=A3=E8=A6=96?= =?UTF-8?q?=E3=81=AE=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0=E3=82=92=E5=85=B1?= =?UTF-8?q?=E9=80=9A=E3=81=AE=E5=89=8D=E6=8F=90=E3=81=A7=E5=A4=96=E3=81=99?= =?UTF-8?q?=EF=BC=88#678=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 無進捗の許容と打ち切りの上限を環境変数で延ばしたシェルから全体のテストを起動すると、 表の既定値を前提にするテストが既定値ではなくその値を読み、変更の中身と関係なく落ちて いた。収束ループの初期化は着手前のテストの通過を条件にするため、そこで止まる。 リポジトリ直下の共通の前提へ、接頭辞 MONITOR_ を持つ環境変数を外す仕組みを足す。収集より 前に外し(pytest_configure)、実行が終わった時点で元の値へ戻す(pytest_unconfigure)。 テストの本体を読み込む時点で上限を決めてしまう実装があるため、セッションの前提では 間に合わない。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- conftest.py | 37 ++++++++++++ issues/issue-678-requirements.md | 89 +++++++++++++++++++++++++++++ scripts/tests/test_root_conftest.py | 61 +++++++++++++++++++- 3 files changed, 186 insertions(+), 1 deletion(-) create mode 100644 issues/issue-678-requirements.md diff --git a/conftest.py b/conftest.py index 0b80e99e..b9f4a8c8 100644 --- a/conftest.py +++ b/conftest.py @@ -10,6 +10,8 @@ テストは、実行した人の設定に関わらずその場で落ちる 4. テストの実行中だけ実行の要約の置き場所(`NDF_METRICS_DIR`)を一時ディレクトリへ向ける。 状態を保存するテストが、実行した人の状態ディレクトリへ要約を書かない(#662 の AC72) +5. テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)を外す。上限を延ばした + シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) `playwright-kit-ops` のディレクトリを起点にした実行では、このファイルは読まれない。 `pytester` はそのディレクトリの `pyproject.toml` の `addopts` が読み込む。 @@ -82,6 +84,41 @@ def _missing(bundles: set[str]) -> dict[str, list[str]]: return found +# 監視の上限を指す環境変数の接頭辞(#678)。担当ごとの指定・共通の指定のどちらもこの +# 接頭辞を持つため、接頭辞だけで一致させる。名前を並べると、上限の種類が増えるたびに +# ここへ足し忘れる。 +MONITOR_ENV_PREFIX = "MONITOR_" + +# `pytest_configure` で外した値の控え。実行が終わったときに戻す。 +_saved_monitor_env: dict[str, str] = {} + + +def _strip_monitor_env() -> dict[str, str]: + """接頭辞の環境変数を外し、外した値を返す。""" + return {k: os.environ.pop(k) for k in list(os.environ) if k.startswith(MONITOR_ENV_PREFIX)} + + +def pytest_configure(config) -> None: + """テストの実行中だけ、監視の上限を指す環境変数を外す(#678)。 + + 無進捗の許容と打ち切りの上限は環境変数で延ばせる。運用で延ばしたシェルから起動すると、 + 表の既定値を前提にするテストが既定値ではなくその値を読み、変更の中身と関係なく落ちる。 + 収束ループの初期化は着手前のテストの通過を条件にするため、そこで止まる。 + + **収集より前に外す。** テストの本体を読み込む時点で上限を決めてしまう実装があり、 + セッションの前提(fixture)では間に合わない。子プロセスは環境変数を受け継ぐため、 + テストが起動する別プロセスにも同じ切り離しが効く。**個別に設定するテストは打ち消さない。** + `monkeypatch` も、別プロセスへ渡す上書きも、この後に効く。 + """ + _saved_monitor_env.update(_strip_monitor_env()) + + +def pytest_unconfigure(config) -> None: + """実行が終わったら、外した環境変数を戻す。""" + os.environ.update(_saved_monitor_env) + _saved_monitor_env.clear() + + def pytest_collection_modifyitems(config, items) -> None: bundles = {b for item in items if (b := _bundle_of(Path(str(item.fspath)))) is not None} missing = _missing(bundles) diff --git a/issues/issue-678-requirements.md b/issues/issue-678-requirements.md new file mode 100644 index 00000000..b91ae13b --- /dev/null +++ b/issues/issue-678-requirements.md @@ -0,0 +1,89 @@ +# テストの前提: 監視の上限を環境変数で延ばしたシェルでは既定値を前提にするテストが落ち、収束ループの初期化が中断する → テストの実行中は監視の環境変数を共通の前提で外す(#678) + +## 目的 + +無進捗の許容と打ち切りの上限は、環境変数で延ばせる。運用でこれを延ばしたシェルから全体の +テストを起動すると、既定値を前提にするテストが落ちる。落ちた原因はテストの実行環境にあり、 +変更の中身にはない。 + +収束ループ(`cross-refactoring`)の初期化は着手前のテストの通過を条件にするため、上限を +延ばしたシェルでは初期化がそこで止まる。 + +**テストの実行中だけ、監視の環境変数を利用者の環境から切り離す。** 切り離しの置き場所は +リポジトリ直下の共通の前提(`conftest.py`)とし、テストごとに散った除去をそこへ寄せる。 + +## 対象範囲 + +**含む** + +- リポジトリ直下の共通の前提へ、監視の環境変数(接頭辞 `MONITOR_`)を外す仕組みを足す +- テストごとに散った同じ除去を取り除く(1 変数ずつ外す箇所と、接頭辞でまとめて外す箇所) +- 共通の前提が働いていることを確かめるテストを足す + +**含まない** + +- 上限の解決順(担当ごとの指定 → 共通の指定 → 表の既定)の変更 +- 上限の表の値の変更 +- 監視の本体・起動スクリプト・Skill 本文の変更 + +## 前提 + +- 本番の振る舞いも本番コードの構造も変えない。変えるのはテストの前提だけである +- 子プロセスは実行中の環境変数を受け継ぐため、共通の前提で外せば、テストが起動する + 別プロセスにも同じ切り離しが効く +- テストの中で監視の環境変数を設定する箇所(`monkeypatch.setenv`・別プロセスへ渡す + 上書き)は、共通の前提より後に効くため、そのまま働く + +## 着手前の実測(2026-09-21) + +同じコマンドを、監視の環境変数を設定したシェルと、していないシェルで実行した。 + +```console +$ MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q +FAILED plugins/ndf/skills/cross-review/tests/test_launch_agy.py::test_the_print_timeout_defaults_to_the_longest_phase +FAILED plugins/ndf/skills/cross-review/tests/test_monitor_agy.py::test_the_new_name_has_a_stall_default +FAILED plugins/ndf/skills/cross-review/tests/test_monitor_import_safety.py::test_import_succeeds_with_non_numeric_monitor_stall +3 failed, 4600 passed in 182.10s + +$ uv run --with pytest pytest scripts/tests plugins/ndf -q +4603 passed in 179.67s +``` + +| 観測 | 値 | +| --- | --- | +| 収集した件数 | 4603(どちらのシェルでも同じ) | +| 設定したシェルで落ちる件数 | 3 | +| 設定していないシェルで落ちる件数 | 0 | + +課題の本文が記録した 2026-09-15 の観測では落ちるのが 2 件、その後の追記で 3 件だった。 +件数は着手時点の実測で 3 件のまま変わらない。 + +## 受け入れ条件 + +- [x] 受け入れ条件 1: 監視の環境変数(`MONITOR_TIMEOUT_AGY` と `MONITOR_STALL_AGY`)を設定した + シェルで全体のテストを実行すると、失敗が 0 件になる +- [x] 受け入れ条件 2: 設定したシェルと設定していないシェルで、通過した件数と失敗した件数が + 一致する +- [x] 受け入れ条件 3: 共通の前提が接頭辞 `MONITOR_` の環境変数を外していることを、テストが + 直接確かめる(実行中に該当する環境変数が 1 つも残らない) +- [x] 受け入れ条件 4: テストの中で監視の環境変数を設定する箇所は、共通の前提を足した後も + 同じ値を観測できる(共通の前提が個別の設定を打ち消さない) +- [x] 受け入れ条件 5: 接頭辞でまとめて外していた箇所と、1 変数ずつ外していた箇所が、 + 共通の前提へ寄る(対象のファイルに同じ除去が残らない) +- [x] 受け入れ条件 6: 共通の前提は、テストの実行が終わった後に元の環境変数を戻す + +## 検証手段 + +| 条件 | 確かめ方 | +| --- | --- | +| 1 / 2 | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q` と、設定しない同じコマンドの 2 回を実行し、件数を突き合わせる | +| 3 / 4 / 6 | 共通の前提を確かめるテスト(`scripts/tests/test_root_conftest.py`)を実行する | +| 5 | `grep -rn "MONITOR_" --include="*.py" <テストのディレクトリ>` の結果に、接頭辞での除去と 1 変数ずつの除去が残らないことを確かめる | + +## 境界 + +```text +常に行う … 共通の前提の追加、散った除去の削除、両方のシェルでの全体テスト +確認してから行う … 上限の解決順・表の既定値に触れる変更(この変更では行わない) +行わない … 監視の本体・起動スクリプト・Skill 本文の変更、依頼範囲外の整形 +``` diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 9306fb20..ff2a226d 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -1,10 +1,11 @@ """リポジトリの根の設定が持つ前提を固定する(#232 / #233 / #235)。 -3 つのことを確かめる。 +4 つのことを確かめる。 1. 起点をリポジトリの根に置いても収集が中断しない(`pytest_plugins` の宣言の位置) 2. 前提の外部コマンドが無いとき、読み飛ばさずに 0 以外の終了コードで終わる 3. テストの実行中は git の全体設定と system の設定を読まない +4. テストの実行中は監視の上限を指す環境変数を読まない(#678) 前提の不足は、`PATH` を絞った子プロセスとして pytest を起動して確かめる。実行環境の `PATH` は書き換えない。 @@ -165,3 +166,61 @@ def test_metrics_dir_points_to_a_temporary_directory_during_tests() -> None: assert metrics, "NDF_METRICS_DIR が設定されていない" assert Path(metrics).resolve().is_relative_to(Path(tempfile.gettempdir()).resolve()) assert "NDF_METRICS" not in os.environ + + +# ---------- 監視の上限を指す環境変数の切り離し(#678) ---------- + + +def _root_conftest_module(): + """根の設定を別名で読み込む。控えの辞書を汚さずに、外す側と戻す側を直接呼ぶ。""" + import importlib.util + + spec = importlib.util.spec_from_file_location("ndf_root_conftest", ROOT_CONFTEST) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_no_monitor_variable_survives_into_a_test() -> None: + """実行中は、監視の上限を指す環境変数が 1 つも残らない。""" + remaining = [k for k in os.environ if k.startswith("MONITOR_")] + assert remaining == [], remaining + + +def test_a_test_can_still_set_its_own_monitor_variable(monkeypatch: pytest.MonkeyPatch) -> None: + """個別に設定した値は打ち消されない。切り離しは実行の前に 1 度だけ効く。""" + monkeypatch.setenv("MONITOR_STALL_AGY", "600") + assert os.environ["MONITOR_STALL_AGY"] == "600" + + +def test_the_child_process_does_not_inherit_a_monitor_variable() -> None: + """子プロセスにも同じ切り離しが効く(起動する側で外し直さなくてよい)。""" + out = subprocess.run( + [sys.executable, "-c", + "import os; print([k for k in os.environ if k.startswith('MONITOR_')])"], + capture_output=True, text=True, + ) + assert out.stdout.strip() == "[]", out.stdout + + +def test_the_values_are_put_back_after_the_run(monkeypatch: pytest.MonkeyPatch) -> None: + """外した値は、実行が終わったときに戻る。""" + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + saved = mod._strip_monitor_env() + + assert saved == {"MONITOR_STALL_AGY": "1800"} + assert "MONITOR_STALL_AGY" not in os.environ + + os.environ.update(saved) + + assert os.environ["MONITOR_STALL_AGY"] == "1800" + + +def test_the_prefix_is_declared_once() -> None: + """接頭辞は根の設定だけが持つ。テストの側へ書き戻すと、同じ除去がまた散る。""" + body = _read_root_conftest() + + assert 'MONITOR_ENV_PREFIX = "MONITOR_"' in body + assert "def pytest_unconfigure" in body From 777b5ae2cdeacea450e9cfb2ba2ff3ffd8086859 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:07:59 +0000 Subject: [PATCH 135/217] =?UTF-8?q?Refactor:=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AB=E6=95=A3=E3=81=A3=E3=81=9F=E7=9B=A3?= =?UTF-8?q?=E8=A6=96=E3=81=AE=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E9=99=A4=E5=8E=BB=E3=82=92=E5=85=B1=E9=80=9A=E3=81=AE=E5=89=8D?= =?UTF-8?q?=E6=8F=90=E3=81=B8=E5=AF=84=E3=81=9B=E3=82=8B=EF=BC=88#678?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 変数ずつ外す 20 箇所と、接頭辞でまとめて外す 7 箇所を削除する。接頭辞を持つ名前は 上限の種類が増えるたびに増えるため、名前を並べる形では足し忘れが同じ形で再発する。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- plugins/ndf/scripts/tests/test_limits.py | 15 +++-------- .../tests/test_launch_agy_phases.py | 3 +-- .../tests/test_launch_print_timeout.py | 3 +-- .../tests/test_monitor_generic_stem.py | 5 +--- .../tests/test_monitor_outcome_file.py | 3 +-- .../cross-review/tests/test_monitor_phase.py | 3 +-- .../tests/test_monitor_stall_default.py | 25 ++++--------------- .../tests/test_monitor_usage_limit.py | 4 +-- 8 files changed, 15 insertions(+), 46 deletions(-) diff --git a/plugins/ndf/scripts/tests/test_limits.py b/plugins/ndf/scripts/tests/test_limits.py index 6b89b410..ca3a76da 100644 --- a/plugins/ndf/scripts/tests/test_limits.py +++ b/plugins/ndf/scripts/tests/test_limits.py @@ -26,26 +26,19 @@ @pytest.fixture() -def limits(monkeypatch): - # **表の既定値を読むテストである。** 実行した人の環境の `MONITOR_*` を外す(#678)。 - for key in [k for k in os.environ if k.startswith("MONITOR_")]: - monkeypatch.delenv(key) +def limits(): + # 表の既定値を読むテストである。実行した人の環境の `MONITOR_*` は、根の + # `conftest.py` が実行中だけ外す(#678)。 spec = importlib.util.spec_from_file_location("ndf_lib_limits", LIMITS) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod -def _clean_env(**over: str) -> dict[str, str]: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} - env.update(over) - return env - - def _run(*args: str, **env: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(LIMITS), *args], - env=_clean_env(**env), capture_output=True, text=True, + env={**os.environ, **env}, capture_output=True, text=True, ) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py index 595f7c1a..6ca8462f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py @@ -59,8 +59,7 @@ def _launch(tmp_path: pathlib.Path, phase: str) -> tuple[list[str], pathlib.Path subprocess.run( [str(LAUNCH), RUNTIME, phase, "130", "1"], env={ - # 実行した人の `MONITOR_*` で上限が変わらないよう外す(#678)。 - **{k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")}, + **os.environ, "CROSS_REFACTORING_TMP_DIR": str(state_path.parent), "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "NDF_TEST_ARGS_FILE": str(args_file), diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py b/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py index 0bd49631..64bd0a7b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py +++ b/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py @@ -34,8 +34,7 @@ def _env(tmp_path: pathlib.Path, **over: str) -> dict[str, str]: stub = bin_dir / "agy" stub.write_text(STUB, encoding="utf-8") stub.chmod(0o755) - # 実行した人の `MONITOR_*` で値が変わらないよう外す(#678)。 - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} + env = dict(os.environ) env.pop("NDF_CRITIQUE_PRINT_TIMEOUT", None) env.update({ "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py b/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py index 625cebcd..45c030ee 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py @@ -181,11 +181,8 @@ def test_claude_stdout_scan_ignores_missing_file(monitor_mod, tmp_path): # ---------- 5. 追加ランタイムの stall 既定 ---------- -def test_stall_defaults_cover_claude_and_kiro(monitor_mod, monkeypatch): +def test_stall_defaults_cover_claude_and_kiro(monitor_mod): """`claude -p` は完了まで無出力なので、最も長い既定を持つこと。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CLAUDE", raising=False) - monkeypatch.delenv("MONITOR_STALL_KIRO", raising=False) assert monitor_mod._agent_stall_default("claude") == 900 assert monitor_mod._agent_stall_default("kiro") == 480 diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py b/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py index 56b0b6d9..ebf1b0ee 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py @@ -60,11 +60,10 @@ def _dead_pid() -> int: def _run_monitor(tmp_dir: pathlib.Path, *extra: str, script: pathlib.Path = _MONITOR_LIB, pr: int = 7, agents: str = "codex") -> subprocess.CompletedProcess: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(script), str(pr), "--agents", agents, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env=env, timeout=60, + capture_output=True, text=True, timeout=60, ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py b/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py index 12414c80..b329c0d2 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py @@ -32,11 +32,10 @@ def _run(tmp_dir: pathlib.Path, *extra: str, agents: str = "agy", for agent in agents.split(","): (tmp_dir / f"{agent}-review-pr7.pid").write_text(str(_dead_pid())) (tmp_dir / f"{agent}-review-pr7-result.json").write_text('{"event": "APPROVE"}') - base = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(_MONITOR_LIB), "7", "--agents", agents, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env={**base, **(env or {})}, timeout=60, + capture_output=True, text=True, env={**os.environ, **(env or {})}, timeout=60, ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py index 71f6f245..62d8c31d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py @@ -7,31 +7,26 @@ agy は err.log にほぼ進捗を出さないため、ビルトイン既定を 480s と大きめに 取って 1 度目の STALLED 誤検知を避ける。codex は従来通り 180s で変更なし。 + +実行した人の `MONITOR_*` は、根の `conftest.py` が実行中だけ外す(#678)。 """ from __future__ import annotations import pytest -def test_builtin_default_codex(monkeypatch, monitor_mod): +def test_builtin_default_codex(monitor_mod): """codex のビルトイン既定は 180s。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) assert monitor_mod._agent_stall_default("codex") == 180 -def test_builtin_default_agy(monkeypatch, monitor_mod): +def test_builtin_default_agy(monitor_mod): """agy のビルトイン既定は 480s (codex より大きい)。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) assert monitor_mod._agent_stall_default("agy") == 480 def test_per_agent_env_overrides_builtin(monkeypatch, monitor_mod): """env `MONITOR_STALL_AGY` 設定で agy 既定が上書きされる。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "600") assert monitor_mod._agent_stall_default("agy") == 600 # codex は影響を受けない @@ -46,8 +41,6 @@ def test_shared_env_applies_to_both(monkeypatch, monitor_mod): 本テストは monkeypatch で `MONITOR_STALL=240` に書き換え、両 agent が 240 を 返すことを確認する (= 共通 env が実際に反映されることの検証)。 """ - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) monkeypatch.setenv("MONITOR_STALL", "240") # 共通 env が両 agent に効く (per-agent 上書きなしの場合) assert monitor_mod._agent_stall_default("codex") == 240 @@ -58,16 +51,13 @@ def test_per_agent_env_takes_precedence_over_shared(monkeypatch, monitor_mod): """per-agent env > 共通 env の優先順位を確認する。""" monkeypatch.setenv("MONITOR_STALL", "240") monkeypatch.setenv("MONITOR_STALL_AGY", "777") - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) assert monitor_mod._agent_stall_default("agy") == 777 # codex 側は per-agent env が無いので 共通 env (= 240) にフォールバック assert monitor_mod._agent_stall_default("codex") == 240 -def test_unknown_agent_falls_back_to_default_stall(monkeypatch, monitor_mod): +def test_unknown_agent_falls_back_to_default_stall(monitor_mod): """ビルトインに無い agent 名は `DEFAULT_STALL` にフォールバックする。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_UNKNOWN", raising=False) assert monitor_mod._agent_stall_default("unknown") == monitor_mod.DEFAULT_STALL @@ -80,8 +70,6 @@ def test_shared_env_non_numeric_falls_back_to_builtin(monkeypatch, monitor_mod, gemini round 4 指摘: `int(os.environ[...])` は非数値で ValueError を出す。 監視プロセスを env 設定ミスでクラッシュさせないため、try/except で builtin に戻す。 """ - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) monkeypatch.setenv("MONITOR_STALL", "abc") # codex / agy とも builtin 既定 (180 / 480) に戻る assert monitor_mod._agent_stall_default("codex") == 180 @@ -96,9 +84,7 @@ def test_per_agent_env_non_numeric_falls_back_to_builtin( monkeypatch, monitor_mod, capsys ): """env `MONITOR_STALL_` が非数値なら builtin にフォールバック。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "not-a-number") - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) # agy は builtin (480) にフォールバック assert monitor_mod._agent_stall_default("agy") == 480 # codex は env 未設定なので builtin (180) @@ -112,7 +98,6 @@ def test_per_agent_env_non_numeric_does_not_affect_other_agent( monkeypatch, monitor_mod ): """non-numeric な per-agent env は対象 agent だけに影響する。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "xxx") monkeypatch.setenv("MONITOR_STALL_CODEX", "200") # codex 側は正常 assert monitor_mod._agent_stall_default("codex") == 200 diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py index 490bda45..4a04ff71 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py @@ -17,7 +17,6 @@ from __future__ import annotations import json -import os import pathlib import subprocess import sys @@ -170,11 +169,10 @@ def _dead_pid() -> int: def _run_monitor(tmp_dir: pathlib.Path, agent: str, *extra: str) -> subprocess.CompletedProcess: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(_MONITOR_LIB), "7", "--agents", agent, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env=env, timeout=60, + capture_output=True, text=True, timeout=60, ) From 1a81a1a7910986176d559f11f151f64ab8c40664 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:10:52 +0000 Subject: [PATCH 136/217] =?UTF-8?q?Refactor:=20=E5=BC=95=E6=95=B0=E3=82=AA?= =?UTF-8?q?=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E5=B0=8E=E5=85=A5?= =?UTF-8?q?=E3=81=A8=E3=83=91=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=83=BB=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E3=81=AB=E3=82=88=E3=82=8B=E6=A7=8B=E9=80=A0=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: R3-001 Round: 3 Impl-Runtime: agy Impl-Model: default --- plugins/ndf/scripts/lib/assignment.py | 123 +++--- plugins/ndf/scripts/lib/refresh.py | 33 +- .../skills/cross-review/scripts/measure.py | 37 +- .../ndf/skills/cross-review/scripts/state.py | 375 +++++++++--------- 4 files changed, 306 insertions(+), 262 deletions(-) diff --git a/plugins/ndf/scripts/lib/assignment.py b/plugins/ndf/scripts/lib/assignment.py index a01928c4..da646c97 100644 --- a/plugins/ndf/scripts/lib/assignment.py +++ b/plugins/ndf/scripts/lib/assignment.py @@ -207,36 +207,12 @@ def to_state(self) -> dict[str, Any]: Probe = Callable[[list[str]], tuple[dict[str, dict[str, Any]], bool]] -def resolve_participants( - pool: Iterable[str], - *, - host: str, - include: Iterable[str] = (), - exclude: Iterable[str] = (), - only: Optional[str] = None, - probe: Probe, - require_all: bool = False, -) -> Participants: - """母集合の既定・足す者・外す者・1 者指定から使える者を決める(設計の決定 2〜4)。 - - 順序: - - 1. `include` / `exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。 - `exclude` の名前が「`pool` ∪ `include`」に無ければ弾く(cross-review でホストを - 外す指定はここに当たる) - 2. 参加者 = `pool` ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) - 3. `only` があれば、参加者に含まれ `exclude` に無いことを確かめ、参加者をその 1 者にする - 4. `probe(参加者)` で確かめる。飛ばされたら全員を通ったものとし `probe_skipped` を真にする - 5. `require_all` が真で通らない者がいれば `AssignmentError`(欠けた者と理由を並べる) - 6. 通った者を `available`、通らなかった者と理由を `unavailable` として返す - - 名前の綴りの検査(argparse の型)はこの前段で済んでいる前提だが、ここでも - `ALL_RUNTIMES` に無い名前は弾く。 - """ - pool = list(pool) - include = list(include) - exclude = list(exclude) - +def _validate_and_filter_participants( + pool: list[str], + include: list[str], + exclude: list[str], +) -> list[str]: + """ランタイム名と集合制約を検証し、除外を適用した参加者を返す。""" for name in (*include, *exclude): if name not in ALL_RUNTIMES: raise AssignmentError( @@ -255,28 +231,43 @@ def resolve_participants( f"(母集合: {', '.join(_in_fixed_order(base))})" ) - participants = _in_fixed_order(base - set(exclude)) + return _in_fixed_order(base - set(exclude)) - if only is not None: - if only in exclude: - raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") - if only not in participants: - raise AssignmentError( - f"--only は参加者のいずれかを指定してください: {only}" - f"(参加者: {', '.join(participants)})" - ) - participants = [only] - results, skipped = probe(list(participants)) +def _apply_only( + participants: list[str], + exclude: list[str], + only: Optional[str], +) -> list[str]: + """--only が指定されていれば検証した上でその 1 者のみを返す。""" + if only is None: + return list(participants) + if only in exclude: + raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") + if only not in participants: + raise AssignmentError( + f"--only は参加者のいずれかを指定してください: {only}" + f"(参加者: {', '.join(participants)})" + ) + return [only] + + +def _evaluate_probe_results( + targets: list[str], + probe: Probe, + require_all: bool, +) -> tuple[list[str], dict[str, str], bool]: + """probe を実行し、available/unavailable の集計と require_all の判定を行う。""" + results, skipped = probe(list(targets)) if skipped: - available, unavailable = list(participants), {} + available, unavailable = list(targets), {} else: unavailable = { n: str(results.get(n, {}).get("detail", "")) - for n in participants + for n in targets if not results.get(n, {}).get("ok", False) } - available = [n for n in participants if n not in unavailable] + available = [n for n in targets if n not in unavailable] if require_all and unavailable: failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) @@ -285,11 +276,49 @@ def resolve_participants( "参加者が欠けたまま進むと、その者のレビューが無いまま収束します。" "各 CLI でログインしてから再実行してください" ) + return available, unavailable, skipped + + +def resolve_participants( + pool: Iterable[str], + *, + host: str, + include: Iterable[str] = (), + exclude: Iterable[str] = (), + only: Optional[str] = None, + probe: Probe, + require_all: bool = False, +) -> Participants: + """母集合の既定・足す者・外す者・1 者指定から使える者を決める(設計の決定 2〜4)。 + + 順序: + + 1. `include` / `exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。 + `exclude` の名前が「`pool` ∪ `include`」に無ければ弾く(cross-review でホストを + 外す指定はここに当たる) + 2. 参加者 = `pool` ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) + 3. `only` があれば、参加者に含まれ `exclude` に無いことを確かめ、参加者をその 1 者にする + 4. `probe(参加者)` で確かめる。飛ばされたら全員を通ったものとし `probe_skipped` を真にする + 5. `require_all` が真で通らない者がいれば `AssignmentError`(欠けた者と理由を並べる) + 6. 通った者を `available`、通らなかった者と理由を `unavailable` として返す + + 名前の綴りの検査(argparse の型)はこの前段で済んでいる前提だが、ここでも + `ALL_RUNTIMES` に無い名前は弾く。 + """ + pool_list = list(pool) + include_list = list(include) + exclude_list = list(exclude) + + participants = _validate_and_filter_participants( + pool_list, include_list, exclude_list) + targets = _apply_only(participants, exclude_list, only) + available, unavailable, skipped = _evaluate_probe_results( + targets, probe, require_all) return Participants( - pool=pool, - included=_in_fixed_order(include), - excluded=_in_fixed_order(exclude), + pool=pool_list, + included=_in_fixed_order(include_list), + excluded=_in_fixed_order(exclude_list), available=available, unavailable=unavailable, probe_skipped=skipped, diff --git a/plugins/ndf/scripts/lib/refresh.py b/plugins/ndf/scripts/lib/refresh.py index b1c1acd6..21dee0c4 100644 --- a/plugins/ndf/scripts/lib/refresh.py +++ b/plugins/ndf/scripts/lib/refresh.py @@ -71,6 +71,23 @@ def _set_socket_timeout(response, seconds: float) -> bool: return False +def _read_until_deadline(response, deadline: float, timeout: float) -> bytes: + chunks: list[bytes] = [] + bounded = True + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise FetchTimeout(_timeout_reason(timeout, bounded)) + # **読み取りの最中も期限を見張る。** 渡せなかったときは、その事実を + # 越えたときの理由へ残す。 + bounded = _set_socket_timeout(response, remaining) and bounded + chunk = response.read(CHUNK_BYTES) + if not chunk: + break + chunks.append(chunk) + return b"".join(chunks) + + def fetch(url: str, timeout: float, opener=None) -> FetchResult: """URL を取得する。**待ちは 1 件あたりの総経過時間**で数える。 @@ -85,20 +102,8 @@ def fetch(url: str, timeout: float, opener=None) -> FetchResult: except Exception as exc: # noqa: BLE001 - 取得の失敗は理由として残す return FetchResult(url=url, ok=False, error=_reason(exc)) - chunks: list[bytes] = [] - bounded = True try: - while True: - remaining = deadline - time.monotonic() - if remaining <= 0: - raise FetchTimeout(_timeout_reason(timeout, bounded)) - # **読み取りの最中も期限を見張る。** 渡せなかったときは、その事実を - # 越えたときの理由へ残す。 - bounded = _set_socket_timeout(response, remaining) and bounded - chunk = response.read(CHUNK_BYTES) - if not chunk: - break - chunks.append(chunk) + data = _read_until_deadline(response, deadline, timeout) except Exception as exc: # noqa: BLE001 return FetchResult(url=url, ok=False, error=_reason(exc)) finally: @@ -106,7 +111,7 @@ def fetch(url: str, timeout: float, opener=None) -> FetchResult: if callable(close): close() - return FetchResult(url=url, ok=True, fingerprint=fingerprint(b"".join(chunks))) + return FetchResult(url=url, ok=True, fingerprint=fingerprint(data)) def _timeout_reason(timeout: float, bounded: bool) -> str: diff --git a/plugins/ndf/skills/cross-review/scripts/measure.py b/plugins/ndf/skills/cross-review/scripts/measure.py index 21f9c439..eedf57e5 100755 --- a/plugins/ndf/skills/cross-review/scripts/measure.py +++ b/plugins/ndf/skills/cross-review/scripts/measure.py @@ -175,6 +175,13 @@ class Oracle(NamedTuple): ambiguous: int +class MatchKey(NamedTuple): + pr: int | None + round_no: int + path: str | None + line: int | None + + class ResolvedPositionSource(NamedTuple): round_no: int pr: int | None @@ -193,8 +200,7 @@ def _representatives(st: dict[str, Any]) -> list[dict[str, Any]]: return [f for f in findings if isinstance(f, dict) and not f.get("merged_into")] -def _matches(finding: dict[str, Any], pr: int | None, round_no: int, - path: str, line: int) -> bool: +def _matches(finding: dict[str, Any], key: MatchKey) -> bool: """その解決が指しうる指摘かどうか。 **同じ Pull Request の指摘に限る。** `review_findings[].round` は状態 @@ -205,11 +211,11 @@ def _matches(finding: dict[str, Any], pr: int | None, round_no: int, 指摘は、解決した時点でまだ存在しない。 """ finding_round = _as_int(finding.get("round")) - if finding_round is None or finding_round > round_no: + if finding_round is None or finding_round > key.round_no: return False - if _as_int(finding.get("pr")) != pr: + if _as_int(finding.get("pr")) != key.pr: return False - return finding.get("path") == path and _as_int(finding.get("line")) == line + return finding.get("path") == key.path and _as_int(finding.get("line")) == key.line def _has_recorded_positions(st: dict[str, Any]) -> bool: @@ -229,14 +235,11 @@ def _has_recorded_positions(st: dict[str, Any]) -> bool: def _find_best_match( representatives: list[dict[str, Any]], - pr: int | None, - round_no: int, - path: str, - line: int, + key: MatchKey, ) -> tuple[str | None, bool]: """解決位置に対応する指摘 ID と、曖昧だったかを返す。""" candidates = [ - f for f in representatives if _matches(f, pr, round_no, path, line) + f for f in representatives if _matches(f, key) ] if not candidates: return None, False @@ -285,17 +288,13 @@ def _resolved_position(position: Any) -> tuple[str | None, int | None]: def _add_oracle_match( oracle: Oracle, representatives: list[dict[str, Any]], - pr: int | None, - round_no: int, - path: str | None, - line: int | None, + key: MatchKey, ) -> Oracle: """1 件の resolved position を Oracle 集計へ反映する。""" - if path is None or line is None: + if key.path is None or key.line is None: # 位置の欠けた要素も落とさない(`_thread_positions` が残す)。 return Oracle(oracle.finding_ids, oracle.unmatched + 1, oracle.ambiguous) - finding_id, is_ambiguous = _find_best_match( - representatives, pr, round_no, path, line) + finding_id, is_ambiguous = _find_best_match(representatives, key) if is_ambiguous: return Oracle(oracle.finding_ids, oracle.unmatched, oracle.ambiguous + 1) if finding_id is None: @@ -323,8 +322,8 @@ def _oracle(st: dict[str, Any]) -> Oracle | None: for source in _resolved_position_sources(st): for position in source.positions: path, line = _resolved_position(position) - oracle = _add_oracle_match( - oracle, representatives, source.pr, source.round_no, path, line) + key = MatchKey(source.pr, source.round_no, path, line) + oracle = _add_oracle_match(oracle, representatives, key) return oracle diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index e19cbce2..d32bf538 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1826,206 +1826,217 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str -def _init_new_state( - args: argparse.Namespace, +def _resolve_pr_and_ownership( pr: object, repo: str, worktree: str, - manual_extra_review: str, -) -> None: - """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" + args_worktree: str | None, + meta: PrMetadata | None, +) -> _InitPRContext | None: + # 新規 init: プリチェック。 + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) - def _resolve_pr_and_ownership( - pr: object, repo: str, worktree: str, args_worktree: str | None - ) -> _InitPRContext | None: - # 新規 init: プリチェック。 - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - meta = _fetch_pr_metadata(pr, repo) - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - def _prepare_review_instructions( - pr: object, repo: str, manual_extra_review: str - ) -> _InitReviewContext: - changed_files = _fetch_changed_files(pr, repo) - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) +def _prepare_review_instructions( + changed_files: list[str], manual_extra_review: str +) -> _InitReviewContext: + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) - def _prepare_worktree_and_comments( - worktree: str, pr: object, head_branch: str, repo: str - ) -> _InitWorkspaceContext: - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) - tmp_dir = _tmp_dir(worktree) - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - return _InitWorkspaceContext( - tmp_dir=tmp_dir, - state_file=state_file, - ) +def _ensure_worktree(worktree: str, pr: object, head_branch: str) -> None: + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + +def _prepare_worktree_and_comments( + worktree: str, pr: object, repo: str, tmp_dir: pathlib.Path +) -> _InitWorkspaceContext: + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - info(f"ホストの判定: {host}({host_source})") - # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに - # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 - participants = _resolve_reviewers(host, args) - return _InitialAssignment( - host=host, host_source=host_source, participants=participants) - - def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, - ) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source, participants = ctx.assignment - only, _include, _exclude = _normalize_participant_args(args) - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) - "max_rounds": args.max_rounds if args.max_rounds is not None else 12, - "rotate_after": args.rotate_after if args.rotate_after is not None else 8, - "only": only, - "participants": participants, - "resume_changes": [], - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } + return _InitWorkspaceContext( + tmp_dir=tmp_dir, + state_file=state_file, + ) - def _finalize_initial_state( - args: argparse.Namespace, - pr: object, - pr_ctx: _InitPRContext, - review_ctx: _InitReviewContext, - ws_ctx: _InitWorkspaceContext, - manual_extra_review: str, - ) -> None: - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review - ) - state = _build_initial_review_state(args, context) - _write_state(ws_ctx.state_file, state) - info(f"✅ state 初期化: {ws_ctx.state_file}") - _print_init_result( - _InitResult( - pr=pr, - worktree=pr_ctx.worktree, - tmp_dir=ws_ctx.tmp_dir, - repo=pr_ctx.repo, - head_branch=pr_ctx.meta.head_branch, - base_branch=pr_ctx.meta.base_branch, - is_own=pr_ctx.is_own, - event_downgrade=pr_ctx.event_downgrade, - has_extra=bool(review_ctx.review_instructions), - carried_count=0, - resumed=False, - ) - ) - pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) +def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + info(f"ホストの判定: {host}({host_source})") + # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに + # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 + participants = _resolve_reviewers(host, args) + return _InitialAssignment( + host=host, host_source=host_source, participants=participants) + + +def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, +) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source, participants = ctx.assignment + only, _include, _exclude = _normalize_participant_args(args) + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) + "max_rounds": args.max_rounds if args.max_rounds is not None else 12, + "rotate_after": args.rotate_after if args.rotate_after is not None else 8, + "only": only, + "participants": participants, + "resume_changes": [], + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + + +def _finalize_initial_state( + args: argparse.Namespace, + pr: object, + pr_ctx: _InitPRContext, + review_ctx: _InitReviewContext, + ws_ctx: _InitWorkspaceContext, + manual_extra_review: str, +) -> None: + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + ) + state = _build_initial_review_state(args, context) + _write_state(ws_ctx.state_file, state) + info(f"✅ state 初期化: {ws_ctx.state_file}") + + +def _init_new_state( + args: argparse.Namespace, + pr: object, + repo: str, + worktree: str, + manual_extra_review: str, +) -> None: + """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" + meta = _fetch_pr_metadata(pr, repo) + pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree, meta) if pr_ctx is None: return - review_ctx = _prepare_review_instructions(pr, pr_ctx.repo, manual_extra_review) + changed_files = _fetch_changed_files(pr, pr_ctx.repo) + review_ctx = _prepare_review_instructions(changed_files, manual_extra_review) + _ensure_worktree(pr_ctx.worktree, pr, pr_ctx.meta.head_branch) + tmp_dir = _tmp_dir(pr_ctx.worktree) ws_ctx = _prepare_worktree_and_comments( - pr_ctx.worktree, pr, pr_ctx.meta.head_branch, pr_ctx.repo + pr_ctx.worktree, pr, pr_ctx.repo, tmp_dir ) _finalize_initial_state( args, pr, pr_ctx, review_ctx, ws_ctx, manual_extra_review ) + _print_init_result( + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, + ) + ) # **母集合を広げる前からある 2 者。** `host` を持たない状態ファイル(このリポジトリの From 3bb7c1466df95c682854f9d903a5248d35a91392 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:12:28 +0000 Subject: [PATCH 137/217] =?UTF-8?q?Revert=20"Refactor:=20=E5=BC=95?= =?UTF-8?q?=E6=95=B0=E3=82=AA=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88?= =?UTF-8?q?=E5=B0=8E=E5=85=A5=E3=81=A8=E3=83=91=E3=82=A4=E3=83=97=E3=83=A9?= =?UTF-8?q?=E3=82=A4=E3=83=B3=E3=83=BB=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC?= =?UTF-8?q?=E6=8A=BD=E5=87=BA=E3=81=AB=E3=82=88=E3=82=8B=E6=A7=8B=E9=80=A0?= =?UTF-8?q?=E6=94=B9=E5=96=84"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 1a81a1a7910986176d559f11f151f64ab8c40664. --- plugins/ndf/scripts/lib/assignment.py | 123 +++--- plugins/ndf/scripts/lib/refresh.py | 33 +- .../skills/cross-review/scripts/measure.py | 37 +- .../ndf/skills/cross-review/scripts/state.py | 375 +++++++++--------- 4 files changed, 262 insertions(+), 306 deletions(-) diff --git a/plugins/ndf/scripts/lib/assignment.py b/plugins/ndf/scripts/lib/assignment.py index da646c97..a01928c4 100644 --- a/plugins/ndf/scripts/lib/assignment.py +++ b/plugins/ndf/scripts/lib/assignment.py @@ -207,12 +207,36 @@ def to_state(self) -> dict[str, Any]: Probe = Callable[[list[str]], tuple[dict[str, dict[str, Any]], bool]] -def _validate_and_filter_participants( - pool: list[str], - include: list[str], - exclude: list[str], -) -> list[str]: - """ランタイム名と集合制約を検証し、除外を適用した参加者を返す。""" +def resolve_participants( + pool: Iterable[str], + *, + host: str, + include: Iterable[str] = (), + exclude: Iterable[str] = (), + only: Optional[str] = None, + probe: Probe, + require_all: bool = False, +) -> Participants: + """母集合の既定・足す者・外す者・1 者指定から使える者を決める(設計の決定 2〜4)。 + + 順序: + + 1. `include` / `exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。 + `exclude` の名前が「`pool` ∪ `include`」に無ければ弾く(cross-review でホストを + 外す指定はここに当たる) + 2. 参加者 = `pool` ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) + 3. `only` があれば、参加者に含まれ `exclude` に無いことを確かめ、参加者をその 1 者にする + 4. `probe(参加者)` で確かめる。飛ばされたら全員を通ったものとし `probe_skipped` を真にする + 5. `require_all` が真で通らない者がいれば `AssignmentError`(欠けた者と理由を並べる) + 6. 通った者を `available`、通らなかった者と理由を `unavailable` として返す + + 名前の綴りの検査(argparse の型)はこの前段で済んでいる前提だが、ここでも + `ALL_RUNTIMES` に無い名前は弾く。 + """ + pool = list(pool) + include = list(include) + exclude = list(exclude) + for name in (*include, *exclude): if name not in ALL_RUNTIMES: raise AssignmentError( @@ -231,43 +255,28 @@ def _validate_and_filter_participants( f"(母集合: {', '.join(_in_fixed_order(base))})" ) - return _in_fixed_order(base - set(exclude)) - - -def _apply_only( - participants: list[str], - exclude: list[str], - only: Optional[str], -) -> list[str]: - """--only が指定されていれば検証した上でその 1 者のみを返す。""" - if only is None: - return list(participants) - if only in exclude: - raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") - if only not in participants: - raise AssignmentError( - f"--only は参加者のいずれかを指定してください: {only}" - f"(参加者: {', '.join(participants)})" - ) - return [only] + participants = _in_fixed_order(base - set(exclude)) + if only is not None: + if only in exclude: + raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") + if only not in participants: + raise AssignmentError( + f"--only は参加者のいずれかを指定してください: {only}" + f"(参加者: {', '.join(participants)})" + ) + participants = [only] -def _evaluate_probe_results( - targets: list[str], - probe: Probe, - require_all: bool, -) -> tuple[list[str], dict[str, str], bool]: - """probe を実行し、available/unavailable の集計と require_all の判定を行う。""" - results, skipped = probe(list(targets)) + results, skipped = probe(list(participants)) if skipped: - available, unavailable = list(targets), {} + available, unavailable = list(participants), {} else: unavailable = { n: str(results.get(n, {}).get("detail", "")) - for n in targets + for n in participants if not results.get(n, {}).get("ok", False) } - available = [n for n in targets if n not in unavailable] + available = [n for n in participants if n not in unavailable] if require_all and unavailable: failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) @@ -276,49 +285,11 @@ def _evaluate_probe_results( "参加者が欠けたまま進むと、その者のレビューが無いまま収束します。" "各 CLI でログインしてから再実行してください" ) - return available, unavailable, skipped - - -def resolve_participants( - pool: Iterable[str], - *, - host: str, - include: Iterable[str] = (), - exclude: Iterable[str] = (), - only: Optional[str] = None, - probe: Probe, - require_all: bool = False, -) -> Participants: - """母集合の既定・足す者・外す者・1 者指定から使える者を決める(設計の決定 2〜4)。 - - 順序: - - 1. `include` / `exclude` の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。 - `exclude` の名前が「`pool` ∪ `include`」に無ければ弾く(cross-review でホストを - 外す指定はここに当たる) - 2. 参加者 = `pool` ∪ `include` − `exclude`(`ALL_RUNTIMES` の順) - 3. `only` があれば、参加者に含まれ `exclude` に無いことを確かめ、参加者をその 1 者にする - 4. `probe(参加者)` で確かめる。飛ばされたら全員を通ったものとし `probe_skipped` を真にする - 5. `require_all` が真で通らない者がいれば `AssignmentError`(欠けた者と理由を並べる) - 6. 通った者を `available`、通らなかった者と理由を `unavailable` として返す - - 名前の綴りの検査(argparse の型)はこの前段で済んでいる前提だが、ここでも - `ALL_RUNTIMES` に無い名前は弾く。 - """ - pool_list = list(pool) - include_list = list(include) - exclude_list = list(exclude) - - participants = _validate_and_filter_participants( - pool_list, include_list, exclude_list) - targets = _apply_only(participants, exclude_list, only) - available, unavailable, skipped = _evaluate_probe_results( - targets, probe, require_all) return Participants( - pool=pool_list, - included=_in_fixed_order(include_list), - excluded=_in_fixed_order(exclude_list), + pool=pool, + included=_in_fixed_order(include), + excluded=_in_fixed_order(exclude), available=available, unavailable=unavailable, probe_skipped=skipped, diff --git a/plugins/ndf/scripts/lib/refresh.py b/plugins/ndf/scripts/lib/refresh.py index 21dee0c4..b1c1acd6 100644 --- a/plugins/ndf/scripts/lib/refresh.py +++ b/plugins/ndf/scripts/lib/refresh.py @@ -71,23 +71,6 @@ def _set_socket_timeout(response, seconds: float) -> bool: return False -def _read_until_deadline(response, deadline: float, timeout: float) -> bytes: - chunks: list[bytes] = [] - bounded = True - while True: - remaining = deadline - time.monotonic() - if remaining <= 0: - raise FetchTimeout(_timeout_reason(timeout, bounded)) - # **読み取りの最中も期限を見張る。** 渡せなかったときは、その事実を - # 越えたときの理由へ残す。 - bounded = _set_socket_timeout(response, remaining) and bounded - chunk = response.read(CHUNK_BYTES) - if not chunk: - break - chunks.append(chunk) - return b"".join(chunks) - - def fetch(url: str, timeout: float, opener=None) -> FetchResult: """URL を取得する。**待ちは 1 件あたりの総経過時間**で数える。 @@ -102,8 +85,20 @@ def fetch(url: str, timeout: float, opener=None) -> FetchResult: except Exception as exc: # noqa: BLE001 - 取得の失敗は理由として残す return FetchResult(url=url, ok=False, error=_reason(exc)) + chunks: list[bytes] = [] + bounded = True try: - data = _read_until_deadline(response, deadline, timeout) + while True: + remaining = deadline - time.monotonic() + if remaining <= 0: + raise FetchTimeout(_timeout_reason(timeout, bounded)) + # **読み取りの最中も期限を見張る。** 渡せなかったときは、その事実を + # 越えたときの理由へ残す。 + bounded = _set_socket_timeout(response, remaining) and bounded + chunk = response.read(CHUNK_BYTES) + if not chunk: + break + chunks.append(chunk) except Exception as exc: # noqa: BLE001 return FetchResult(url=url, ok=False, error=_reason(exc)) finally: @@ -111,7 +106,7 @@ def fetch(url: str, timeout: float, opener=None) -> FetchResult: if callable(close): close() - return FetchResult(url=url, ok=True, fingerprint=fingerprint(data)) + return FetchResult(url=url, ok=True, fingerprint=fingerprint(b"".join(chunks))) def _timeout_reason(timeout: float, bounded: bool) -> str: diff --git a/plugins/ndf/skills/cross-review/scripts/measure.py b/plugins/ndf/skills/cross-review/scripts/measure.py index eedf57e5..21f9c439 100755 --- a/plugins/ndf/skills/cross-review/scripts/measure.py +++ b/plugins/ndf/skills/cross-review/scripts/measure.py @@ -175,13 +175,6 @@ class Oracle(NamedTuple): ambiguous: int -class MatchKey(NamedTuple): - pr: int | None - round_no: int - path: str | None - line: int | None - - class ResolvedPositionSource(NamedTuple): round_no: int pr: int | None @@ -200,7 +193,8 @@ def _representatives(st: dict[str, Any]) -> list[dict[str, Any]]: return [f for f in findings if isinstance(f, dict) and not f.get("merged_into")] -def _matches(finding: dict[str, Any], key: MatchKey) -> bool: +def _matches(finding: dict[str, Any], pr: int | None, round_no: int, + path: str, line: int) -> bool: """その解決が指しうる指摘かどうか。 **同じ Pull Request の指摘に限る。** `review_findings[].round` は状態 @@ -211,11 +205,11 @@ def _matches(finding: dict[str, Any], key: MatchKey) -> bool: 指摘は、解決した時点でまだ存在しない。 """ finding_round = _as_int(finding.get("round")) - if finding_round is None or finding_round > key.round_no: + if finding_round is None or finding_round > round_no: return False - if _as_int(finding.get("pr")) != key.pr: + if _as_int(finding.get("pr")) != pr: return False - return finding.get("path") == key.path and _as_int(finding.get("line")) == key.line + return finding.get("path") == path and _as_int(finding.get("line")) == line def _has_recorded_positions(st: dict[str, Any]) -> bool: @@ -235,11 +229,14 @@ def _has_recorded_positions(st: dict[str, Any]) -> bool: def _find_best_match( representatives: list[dict[str, Any]], - key: MatchKey, + pr: int | None, + round_no: int, + path: str, + line: int, ) -> tuple[str | None, bool]: """解決位置に対応する指摘 ID と、曖昧だったかを返す。""" candidates = [ - f for f in representatives if _matches(f, key) + f for f in representatives if _matches(f, pr, round_no, path, line) ] if not candidates: return None, False @@ -288,13 +285,17 @@ def _resolved_position(position: Any) -> tuple[str | None, int | None]: def _add_oracle_match( oracle: Oracle, representatives: list[dict[str, Any]], - key: MatchKey, + pr: int | None, + round_no: int, + path: str | None, + line: int | None, ) -> Oracle: """1 件の resolved position を Oracle 集計へ反映する。""" - if key.path is None or key.line is None: + if path is None or line is None: # 位置の欠けた要素も落とさない(`_thread_positions` が残す)。 return Oracle(oracle.finding_ids, oracle.unmatched + 1, oracle.ambiguous) - finding_id, is_ambiguous = _find_best_match(representatives, key) + finding_id, is_ambiguous = _find_best_match( + representatives, pr, round_no, path, line) if is_ambiguous: return Oracle(oracle.finding_ids, oracle.unmatched, oracle.ambiguous + 1) if finding_id is None: @@ -322,8 +323,8 @@ def _oracle(st: dict[str, Any]) -> Oracle | None: for source in _resolved_position_sources(st): for position in source.positions: path, line = _resolved_position(position) - key = MatchKey(source.pr, source.round_no, path, line) - oracle = _add_oracle_match(oracle, representatives, key) + oracle = _add_oracle_match( + oracle, representatives, source.pr, source.round_no, path, line) return oracle diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index d32bf538..e19cbce2 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1826,217 +1826,206 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str -def _resolve_pr_and_ownership( +def _init_new_state( + args: argparse.Namespace, pr: object, repo: str, worktree: str, - args_worktree: str | None, - meta: PrMetadata | None, -) -> _InitPRContext | None: - # 新規 init: プリチェック。 - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - - -def _prepare_review_instructions( - changed_files: list[str], manual_extra_review: str -) -> _InitReviewContext: - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) - - -def _ensure_worktree(worktree: str, pr: object, head_branch: str) -> None: - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - -def _prepare_worktree_and_comments( - worktree: str, pr: object, repo: str, tmp_dir: pathlib.Path -) -> _InitWorkspaceContext: - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - - return _InitWorkspaceContext( - tmp_dir=tmp_dir, - state_file=state_file, - ) - + manual_extra_review: str, +) -> None: + """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" -def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - info(f"ホストの判定: {host}({host_source})") - # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに - # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 - participants = _resolve_reviewers(host, args) - return _InitialAssignment( - host=host, host_source=host_source, participants=participants) + def _resolve_pr_and_ownership( + pr: object, repo: str, worktree: str, args_worktree: str | None + ) -> _InitPRContext | None: + # 新規 init: プリチェック。 + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + meta = _fetch_pr_metadata(pr, repo) + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) + def _prepare_review_instructions( + pr: object, repo: str, manual_extra_review: str + ) -> _InitReviewContext: + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) -def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, -) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source, participants = ctx.assignment - only, _include, _exclude = _normalize_participant_args(args) - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) - "max_rounds": args.max_rounds if args.max_rounds is not None else 12, - "rotate_after": args.rotate_after if args.rotate_after is not None else 8, - "only": only, - "participants": participants, - "resume_changes": [], - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } + def _prepare_worktree_and_comments( + worktree: str, pr: object, head_branch: str, repo: str + ) -> _InitWorkspaceContext: + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + return _InitWorkspaceContext( + tmp_dir=tmp_dir, + state_file=state_file, + ) -def _finalize_initial_state( - args: argparse.Namespace, - pr: object, - pr_ctx: _InitPRContext, - review_ctx: _InitReviewContext, - ws_ctx: _InitWorkspaceContext, - manual_extra_review: str, -) -> None: - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review - ) - state = _build_initial_review_state(args, context) - _write_state(ws_ctx.state_file, state) - info(f"✅ state 初期化: {ws_ctx.state_file}") + def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + info(f"ホストの判定: {host}({host_source})") + # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに + # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 + participants = _resolve_reviewers(host, args) + return _InitialAssignment( + host=host, host_source=host_source, participants=participants) + + def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, + ) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source, participants = ctx.assignment + only, _include, _exclude = _normalize_participant_args(args) + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) + "max_rounds": args.max_rounds if args.max_rounds is not None else 12, + "rotate_after": args.rotate_after if args.rotate_after is not None else 8, + "only": only, + "participants": participants, + "resume_changes": [], + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + def _finalize_initial_state( + args: argparse.Namespace, + pr: object, + pr_ctx: _InitPRContext, + review_ctx: _InitReviewContext, + ws_ctx: _InitWorkspaceContext, + manual_extra_review: str, + ) -> None: + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + ) + state = _build_initial_review_state(args, context) + _write_state(ws_ctx.state_file, state) + info(f"✅ state 初期化: {ws_ctx.state_file}") + _print_init_result( + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, + ) + ) -def _init_new_state( - args: argparse.Namespace, - pr: object, - repo: str, - worktree: str, - manual_extra_review: str, -) -> None: - """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" - meta = _fetch_pr_metadata(pr, repo) - pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree, meta) + pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) if pr_ctx is None: return - changed_files = _fetch_changed_files(pr, pr_ctx.repo) - review_ctx = _prepare_review_instructions(changed_files, manual_extra_review) - _ensure_worktree(pr_ctx.worktree, pr, pr_ctx.meta.head_branch) - tmp_dir = _tmp_dir(pr_ctx.worktree) + review_ctx = _prepare_review_instructions(pr, pr_ctx.repo, manual_extra_review) ws_ctx = _prepare_worktree_and_comments( - pr_ctx.worktree, pr, pr_ctx.repo, tmp_dir + pr_ctx.worktree, pr, pr_ctx.meta.head_branch, pr_ctx.repo ) _finalize_initial_state( args, pr, pr_ctx, review_ctx, ws_ctx, manual_extra_review ) - _print_init_result( - _InitResult( - pr=pr, - worktree=pr_ctx.worktree, - tmp_dir=ws_ctx.tmp_dir, - repo=pr_ctx.repo, - head_branch=pr_ctx.meta.head_branch, - base_branch=pr_ctx.meta.base_branch, - is_own=pr_ctx.is_own, - event_downgrade=pr_ctx.event_downgrade, - has_extra=bool(review_ctx.review_instructions), - carried_count=0, - resumed=False, - ) - ) # **母集合を広げる前からある 2 者。** `host` を持たない状態ファイル(このリポジトリの From ff3e6cf619126198da57d197f5e545b5b111ee4a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:12:28 +0000 Subject: [PATCH 138/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 75 +++++++++++++++++++++++++++++++- 1 file changed, 74 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index de73a2c3..78724751 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -127,7 +127,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex / agy | 検証中 | 1 | +| error | unit | — | codex / agy | 採用 | 1 | **なぜ**: review_assign に HOST_RUNTIMES に含まれない無効なホスト名が渡された場合、内部の review_pool から AssignmentError(「ホストになれないランタイムです」)が送出されるエラー経路が固定されていない。 @@ -135,6 +135,74 @@ 2. assignment.AssignmentError が送出されることを検証する 3. 例外メッセージに「ホストになれないランタイムです」が含まれることを検証する +## ラウンド 3(実装 kiro / レビュー codex / agy) + +### R3-001 — `plugins/ndf/skills/cross-review/scripts/measure.py#_matches` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_parameter_list | introduce_parameter_object | major | kiro | 取り消し | 0 | + +**なぜ**: 解決位置の突き合わせ鍵 (pr, round_no, path, line) の 4 引数が _matches・_find_best_match・_add_oracle_match の 3 関数を順に渡り回っている。呼び出し側で順序を取り違えても型で防げず、鍵の項目を増やすたびに 3 関数すべての引数を直すことになる。 + +**手順**: 1. NamedTuple `MatchKey(pr, round_no, path, line)` を定義する +2. _matches の引数を (finding, key: MatchKey) にし、本体を key.* へ書き換える +3. _find_best_match・_add_oracle_match も MatchKey を受け取る形に変え、呼び出し側(_oracle のループ)で MatchKey を 1 度組み立てて渡す +4. test_measure.py の oracle 系テストで退行を確認する + +### R3-002 — `plugins/ndf/scripts/lib/assignment.py#resolve_participants` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | split_into_pipeline | major | codex | 取り消し | 0 | + +**なぜ**: 入力の正規化、名前と集合制約の検証、only 適用、認証 probe、利用可否の集計、require_all 判定、結果生成が直列に並び、検証規則と外部 probe の境界を個別に読みにくい。 + +**手順**: 1. test_lib_participants.py の既存ケースを現状固定として実行する +2. pool/include/exclude の正規化と制約検証を独立した段へ抽出する +3. only を適用して probe 対象を返す段を抽出する +4. probe 結果を available と unavailable へ変換し require_all を判定する段を抽出する +5. resolve_participants は各段の出力を次段へ渡して Participants を返す処理だけにする +6. 対象テストと全体テストで例外文言、順序、probe 呼び出し、戻り値が不変であることを確認する + +### R3-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 取り消し | 0 | + +**なぜ**: 200 行の関数内に PR 所有者判定、レビュー指示生成、worktree と既存コメントの準備、担当決定、初期 state 構築、保存と表示が同居し、補助関数もすべてローカル定義のため各段階を単独で検証できない。 + +**手順**: 1. 既存の init 経路テストを現状固定として実行する +2. _resolve_pr_and_ownership と _prepare_review_instructions をモジュールレベルへ抽出する +3. _prepare_worktree_and_comments と _prepare_initial_assignment をモジュールレベルへ抽出する +4. _build_initial_review_state と _finalize_initial_state をモジュールレベルへ抽出し、_init_new_state は各段階を順に呼ぶ構成へ縮める +5. init 関連テストと全体テストで公開入口の出力と副作用が不変であることを確認する + +### R3-004 — `plugins/ndf/skills/cross-review/scripts/measure.py#_state_file_pr` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| duplication | consolidate_duplication | minor | kiro | 未着手 | 0 | + +**なぜ**: _state_file_pr と _prs が同じ pr_history 走査(dict 判定→_as_int(entry.get("pr"))→current_pr へのフォールバック)を別々に持つ。_state_file_pr は実質「_prs の先頭」で、片方だけ直すと状態ファイルの鍵の選び方が食い違う。同じ業務ルール(状態ファイルの鍵の決め方)に由来し、必ず一緒に変わる重複である。 + +**手順**: 1. _prs を先に評価し、走査ロジックの唯一の持ち主にする +2. _state_file_pr を `prs = _prs(st); return prs[0] if prs else None` へ置き換える +3. test_measure.py の pr/prs を検査するテスト(test_identity_keys_report_state_file_key_and_all_prs 他)で退行を確認する + +### R3-005 — `plugins/ndf/scripts/lib/refresh.py#fetch` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | minor | kiro | 取り消し | 0 | + +**なぜ**: fetch が opener 呼び出し・期限付き読み取りループ・socket への期限伝播・close の後始末を通しで行う。読み取りループ(deadline 判定・_set_socket_timeout の bounded 蓄積・chunk 蓄積)だけを名前付きの段へ分けると、読み取り部分と取得の骨格を別々に読める。 + +**手順**: 1. 読み取りループを `_read_until_deadline(response, deadline, timeout) -> bytes` として抽出し、bounded 判定と FetchTimeout の送出をその中へ移す +2. fetch は opener 呼び出しと finally の close を残し、本文取得を抽出関数の呼び出しに置き換える +3. test_refresh.py の refresh/fetch 経路のテストで退行を確認する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -149,3 +217,8 @@ | 2 | `plugins/ndf/scripts/lib/assignment.py#review_seats` | boundary | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#main` | normal | 1 ラウンドの採用上限 5 件を超えた | | 2 | `plugins/ndf/scripts/lib/assignment.py#review_assign` | branch | コミット 0c3b60c7101ac9b439e0b13b677f8061b81eb851 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | +| 3 | `plugins/ndf/scripts/lib/post_queue.py#Queue.flush` | long_method | 1 ラウンドの採用上限 5 件を超えた | +| 3 | `plugins/ndf/skills/cross-review/scripts/measure.py#_matches` | long_parameter_list | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 3 | `plugins/ndf/scripts/lib/assignment.py#resolve_participants` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 3 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 3 | `plugins/ndf/scripts/lib/refresh.py#fetch` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | From bb4fc7264938e4f9fa427e5c1b003bcb0fada334 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:16:14 +0000 Subject: [PATCH 139/217] =?UTF-8?q?Refactor:=20consolidate=5Fduplication?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-review/scripts/measure.?= =?UTF-8?q?py#=5Fstate=5Ffile=5Fpr?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit _state_file_pr と _prs が同じ pr_history 走査を別々に持っていた重複を解消する。 _prs を走査ロジックの唯一の持ち主とし、_state_file_pr は _prs の先頭を返す 実装へ置き換える。振る舞いは不変。 Item-Id: R3-004 Round: 3 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/skills/cross-review/scripts/measure.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/measure.py b/plugins/ndf/skills/cross-review/scripts/measure.py index 21f9c439..ee26e60e 100755 --- a/plugins/ndf/skills/cross-review/scripts/measure.py +++ b/plugins/ndf/skills/cross-review/scripts/measure.py @@ -73,12 +73,8 @@ def _state_file_pr(st: dict[str, Any]) -> int | None: **`current_pr` ではない。** ローテーションを経ると `current_pr` は進むが、 状態ファイルの名前も `rounds[]` の並びも最初の番号のままである。 """ - for entry in st.get("pr_history") or []: - if isinstance(entry, dict): - pr = _as_int(entry.get("pr")) - if pr is not None: - return pr - return _as_int(st.get("current_pr")) + prs = _prs(st) + return prs[0] if prs else None def _prs(st: dict[str, Any]) -> list[int]: From 578a9e5306ab41dce00f9aa21486c9e0dabe9329 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:16:33 +0000 Subject: [PATCH 140/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 78724751..6bb1b010 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -183,7 +183,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 未着手 | 0 | +| duplication | consolidate_duplication | minor | kiro | 検証中 | 1 | **なぜ**: _state_file_pr と _prs が同じ pr_history 走査(dict 判定→_as_int(entry.get("pr"))→current_pr へのフォールバック)を別々に持つ。_state_file_pr は実質「_prs の先頭」で、片方だけ直すと状態ファイルの鍵の選び方が食い違う。同じ業務ルール(状態ファイルの鍵の決め方)に由来し、必ず一緒に変わる重複である。 From d827efb105b690e72f97b130cc11d5909cc92703 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:16:35 +0000 Subject: [PATCH 141/217] =?UTF-8?q?Test:=20=E9=96=8B=E3=81=8D=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=AE=E5=88=A4=E5=AE=9A=E3=83=BB=E8=A7=A3=E6=B1=BA?= =?UTF-8?q?=E7=94=B3=E5=91=8A=E3=81=AE=E7=AA=81=E3=81=8D=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=9B=E3=83=BB=E5=85=A5=E5=8A=9B=E3=82=A8=E3=83=A9=E3=83=BC?= =?UTF-8?q?=E3=83=BB=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=97=E3=81=AE=E5=86=AA?= =?UTF-8?q?=E7=AD=89=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用ラウンド 1(提案ラウンド 2)のテスト整備 4 件。対象のコードは変更していない。 - R2-001 rounds.py#group_reopening — empty / exhausted / resume / open の 4 分岐の返り値そのものを固定する(これまでは差し替えるだけだった) - R2-002 commands/converge.py#_resolved_fix_thread_ids — 配列でない自己申告・ GitHub 側が取得できない(None)・積集合だけを採る経路を単体で固定する - R2-003 commands/setup.py#cmd_init — ホスト判定とモデル指定の誤りで、外部照会 にも作業ディレクトリ作成にも進まないまま中断コード 4 で止まることを固定する - R2-004 gitfacts.py#revert_item_commits — reverted が真の項目は 0 を返し、 HEAD も項目も動かさない冪等経路を固定する Item-Id: R2-001 Round: 2 Impl-Runtime: claude Impl-Model: claude-opus-5[1m] Co-Authored-By: Claude Opus 5 (1M context) --- .../tests/test_abandon_items.py | 44 +++++++++++++++++++ .../tests/test_apply_rounds.py | 41 +++++++++++++++++ .../cross-refactoring/tests/test_git_facts.py | 20 +++++++++ .../cross-refactoring/tests/test_init.py | 38 ++++++++++++++++ 4 files changed, 143 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py index 4fe84471..f034e9b0 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py @@ -1131,3 +1131,47 @@ def test_a_missing_fix_result_reverts_the_commits_in_range( entry = read_state(state_path)["rounds"][0] assert entry["fix_base_sha"] == "AFTER_REVERT" assert entry["apply_rounds"][0]["failed_attempts"][0]["reverted"] == 2 + + +# ---------- R2-002: 自己申告と GitHub の突き合わせ(_resolved_fix_thread_ids) ---------- +# +# 未解決の指摘を解決扱いにしないための防御分岐。取り込み経由でしか通っていな +# かったため、返り値そのものを単体で固定する。 + +def test_resolved_fix_thread_ids_drops_a_claim_that_is_not_a_list( + patch_lib, cmd_converge, capsys +): + """現状固定: 配列でない自己申告は警告を出し、申告が無かったものとして扱う。""" + patch_lib("resolved_threads_on_github", lambda repo, pr: {"PRRT_a"}) + + resolved = cmd_converge._resolved_fix_thread_ids( + {"resolved_thread_ids": "PRRT_a"}, "acme/demo", 130) + + assert resolved == set() + assert "resolved_thread_ids が配列ではありません(str)" in capsys.readouterr().err + + +def test_resolved_fix_thread_ids_returns_nothing_when_github_cannot_be_read( + patch_lib, cmd_converge, capsys +): + """現状固定: 解決状態を取得できない(None)なら、申告を採用せず空集合を返す。""" + patch_lib("resolved_threads_on_github", lambda repo, pr: None) + + resolved = cmd_converge._resolved_fix_thread_ids( + {"resolved_thread_ids": ["PRRT_a"]}, "acme/demo", 130) + + assert resolved == set() + assert "レビュースレッドの解決状態を取得できませんでした" in capsys.readouterr().err + + +def test_resolved_fix_thread_ids_keeps_only_what_both_sides_call_resolved( + patch_lib, cmd_converge, capsys +): + """現状固定: 自己申告と GitHub の積集合だけを返し、外れた ID は警告に出す。""" + patch_lib("resolved_threads_on_github", lambda repo, pr: {"PRRT_a", "PRRT_c"}) + + resolved = cmd_converge._resolved_fix_thread_ids( + {"resolved_thread_ids": ["PRRT_a", "PRRT_b"]}, "acme/demo", 130) + + assert resolved == {"PRRT_a"} + assert "PRRT_b は解決済みと申告されましたが、GitHub では未解決です" in capsys.readouterr().err diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py index 2daa0f85..a5a5ed52 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py @@ -437,3 +437,44 @@ def test_a_round_whose_only_group_has_no_item_runs_out( assert e.value.code == 1 group = read_state(state_path)["rounds"][0]["apply_rounds"][0] assert (group["status"], group["drop_reason"]) == ("dropped", "empty") + + +# ---------- R2-001: 群の開き直しの判定(group_reopening) ---------- +# +# 4 本の分岐を返す純粋な判定関数だが、これまでは差し替えるだけで返り値そのものを +# 固定した経路が無かった。**返り値の文字列だけ**を比較し、内部の数え方には触れない。 + +def _reopening_group(items=("R1-001",), attempt=0, failed=0): + """`group_reopening` が読む鍵だけを持つ群。 + + 判定に使うのは項目の有無・開いた回数(`attempt`)・結末の記録のうち工程が + 適用のものの件数の 3 つである。 + """ + return _empty_group( + 1, items=items, attempt=attempt, + failed_attempts=[{"phase": "apply"} for _ in range(failed)], + ) + + +def test_group_reopening_says_empty_when_the_group_has_no_item(rounds): + """現状固定: 項目が無い群は `empty`。""" + assert rounds.group_reopening(_reopening_group(items=())) == "empty" + + +def test_group_reopening_says_exhausted_at_the_apply_attempt_cap(rounds): + """現状固定: 工程が適用の結末が上限に達した群は `exhausted`。""" + group = _reopening_group(attempt=rounds.MAX_APPLY_ATTEMPTS, + failed=rounds.MAX_APPLY_ATTEMPTS) + + assert rounds.group_reopening(group) == "exhausted" + + +def test_group_reopening_says_resume_for_an_attempt_that_never_closed(rounds): + """現状固定: 開いた回数が結末の件数を上回る群は `resume`。""" + assert rounds.group_reopening(_reopening_group(attempt=1, failed=0)) == "resume" + + +def test_group_reopening_says_open_when_every_attempt_is_closed(rounds): + """現状固定: 開いた回数が結末の件数以下の群は `open`。""" + assert rounds.group_reopening(_reopening_group(attempt=0, failed=0)) == "open" + assert rounds.group_reopening(_reopening_group(attempt=1, failed=1)) == "open" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py index d4d5b7b2..7ac393df 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py @@ -360,6 +360,26 @@ def test_revert_item_commits_failure_message_includes_item_id(gitfacts, work, ca assert _git("rev-parse", "HEAD", cwd=work).stdout.strip() == second +def test_revert_item_commits_does_nothing_for_an_already_reverted_item( + gitfacts, work, capsys +): + """現状固定: `reverted` が真の項目は 0 を返し、git も項目も動かさない。 + + push の失敗などで叩き直したときに、既に戻したコミットへもう一度 + `git revert` を掛けると必ず失敗する。 + """ + sha = _commit(work, "one", {"src/a.py": "a = 1\n"}) + head = _git("rev-parse", "HEAD", cwd=work).stdout.strip() + state = {"worktrees": {"work": str(work)}} + item = {"item_id": "R1-001", "commits": [sha], "reverted": True} + + assert gitfacts.revert_item_commits(state, item) == 0 + + assert item == {"item_id": "R1-001", "commits": [sha], "reverted": True} + assert _git("rev-parse", "HEAD", cwd=work).stdout.strip() == head + assert "↩ R1-001 は取り消し済みです" in capsys.readouterr().err + + def test_revert_range_failure_message_has_no_item_id_prefix(gitfacts, work, capsys): """現状固定: _revert_range 失敗時は項目 ID 接頭辞のないエラー文を出して中断する。""" first = _commit(work, "one", {"src/a.py": "a = 1\n"}) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index adc7e054..63818621 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -195,6 +195,44 @@ def test_init_rejects_unknown_model_runtime(run_init, tmp_path): run_init(_args(tmp_path, model=["gpt=gpt-5.5"])) +# ---------- R2-003: 入力の誤りで止まる経路 ---------- +# +# 引数だけで判定できる 2 つの誤り(ホストの指定・モデルの指定)は、外部照会にも +# 作業ディレクトリ作成にも進まないまま中断する。 + +def _no_external(patch_lib): + """外部照会が走ったらテストを落とす。""" + patch_lib("sh", + lambda cmd, **k: pytest.fail(f"入力の誤りの後に外部を呼んでいる: {cmd}"), + ) + + +def test_init_stops_on_an_invalid_host_before_touching_anything( + patch_lib, cmd_setup, tmp_path +): + """現状固定: ホストになれない `--host` は中断コード 4 で止まる。""" + _no_external(patch_lib) + + with pytest.raises(SystemExit) as e: + cmd_setup.cmd_init(_args(tmp_path, host="gpt")) + + assert e.value.code == refactor_abort() + assert not (tmp_path / "rf130").exists(), "作業ディレクトリを作っている" + + +def test_init_stops_on_an_invalid_model_spec_before_touching_anything( + patch_lib, cmd_setup, tmp_path +): + """現状固定: 解析できない `--model` は中断コード 4 で止まる。""" + _no_external(patch_lib) + + with pytest.raises(SystemExit) as e: + cmd_setup.cmd_init(_args(tmp_path, model=["codex"])) + + assert e.value.code == refactor_abort() + assert not (tmp_path / "rf130").exists(), "作業ディレクトリを作っている" + + def test_init_accepts_agy_as_host(run_init, tmp_path): """agy も NDF の配布先であるため、ホストになれる。""" run_init(_args(tmp_path, host="agy")) From 8aba78bb6eafa74598b7898f932046ca56108e61 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:18:04 +0000 Subject: [PATCH 142/217] =?UTF-8?q?Revert=20"Test:=20=E9=96=8B=E3=81=8D?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=81=AE=E5=88=A4=E5=AE=9A=E3=83=BB=E8=A7=A3?= =?UTF-8?q?=E6=B1=BA=E7=94=B3=E5=91=8A=E3=81=AE=E7=AA=81=E3=81=8D=E5=90=88?= =?UTF-8?q?=E3=82=8F=E3=81=9B=E3=83=BB=E5=85=A5=E5=8A=9B=E3=82=A8=E3=83=A9?= =?UTF-8?q?=E3=83=BC=E3=83=BB=E5=8F=96=E3=82=8A=E6=B6=88=E3=81=97=E3=81=AE?= =?UTF-8?q?=E5=86=AA=E7=AD=89=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit d827efb105b690e72f97b130cc11d5909cc92703. --- .../tests/test_abandon_items.py | 44 ------------------- .../tests/test_apply_rounds.py | 41 ----------------- .../cross-refactoring/tests/test_git_facts.py | 20 --------- .../cross-refactoring/tests/test_init.py | 38 ---------------- 4 files changed, 143 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py index f034e9b0..4fe84471 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py @@ -1131,47 +1131,3 @@ def test_a_missing_fix_result_reverts_the_commits_in_range( entry = read_state(state_path)["rounds"][0] assert entry["fix_base_sha"] == "AFTER_REVERT" assert entry["apply_rounds"][0]["failed_attempts"][0]["reverted"] == 2 - - -# ---------- R2-002: 自己申告と GitHub の突き合わせ(_resolved_fix_thread_ids) ---------- -# -# 未解決の指摘を解決扱いにしないための防御分岐。取り込み経由でしか通っていな -# かったため、返り値そのものを単体で固定する。 - -def test_resolved_fix_thread_ids_drops_a_claim_that_is_not_a_list( - patch_lib, cmd_converge, capsys -): - """現状固定: 配列でない自己申告は警告を出し、申告が無かったものとして扱う。""" - patch_lib("resolved_threads_on_github", lambda repo, pr: {"PRRT_a"}) - - resolved = cmd_converge._resolved_fix_thread_ids( - {"resolved_thread_ids": "PRRT_a"}, "acme/demo", 130) - - assert resolved == set() - assert "resolved_thread_ids が配列ではありません(str)" in capsys.readouterr().err - - -def test_resolved_fix_thread_ids_returns_nothing_when_github_cannot_be_read( - patch_lib, cmd_converge, capsys -): - """現状固定: 解決状態を取得できない(None)なら、申告を採用せず空集合を返す。""" - patch_lib("resolved_threads_on_github", lambda repo, pr: None) - - resolved = cmd_converge._resolved_fix_thread_ids( - {"resolved_thread_ids": ["PRRT_a"]}, "acme/demo", 130) - - assert resolved == set() - assert "レビュースレッドの解決状態を取得できませんでした" in capsys.readouterr().err - - -def test_resolved_fix_thread_ids_keeps_only_what_both_sides_call_resolved( - patch_lib, cmd_converge, capsys -): - """現状固定: 自己申告と GitHub の積集合だけを返し、外れた ID は警告に出す。""" - patch_lib("resolved_threads_on_github", lambda repo, pr: {"PRRT_a", "PRRT_c"}) - - resolved = cmd_converge._resolved_fix_thread_ids( - {"resolved_thread_ids": ["PRRT_a", "PRRT_b"]}, "acme/demo", 130) - - assert resolved == {"PRRT_a"} - assert "PRRT_b は解決済みと申告されましたが、GitHub では未解決です" in capsys.readouterr().err diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py index a5a5ed52..2daa0f85 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py @@ -437,44 +437,3 @@ def test_a_round_whose_only_group_has_no_item_runs_out( assert e.value.code == 1 group = read_state(state_path)["rounds"][0]["apply_rounds"][0] assert (group["status"], group["drop_reason"]) == ("dropped", "empty") - - -# ---------- R2-001: 群の開き直しの判定(group_reopening) ---------- -# -# 4 本の分岐を返す純粋な判定関数だが、これまでは差し替えるだけで返り値そのものを -# 固定した経路が無かった。**返り値の文字列だけ**を比較し、内部の数え方には触れない。 - -def _reopening_group(items=("R1-001",), attempt=0, failed=0): - """`group_reopening` が読む鍵だけを持つ群。 - - 判定に使うのは項目の有無・開いた回数(`attempt`)・結末の記録のうち工程が - 適用のものの件数の 3 つである。 - """ - return _empty_group( - 1, items=items, attempt=attempt, - failed_attempts=[{"phase": "apply"} for _ in range(failed)], - ) - - -def test_group_reopening_says_empty_when_the_group_has_no_item(rounds): - """現状固定: 項目が無い群は `empty`。""" - assert rounds.group_reopening(_reopening_group(items=())) == "empty" - - -def test_group_reopening_says_exhausted_at_the_apply_attempt_cap(rounds): - """現状固定: 工程が適用の結末が上限に達した群は `exhausted`。""" - group = _reopening_group(attempt=rounds.MAX_APPLY_ATTEMPTS, - failed=rounds.MAX_APPLY_ATTEMPTS) - - assert rounds.group_reopening(group) == "exhausted" - - -def test_group_reopening_says_resume_for_an_attempt_that_never_closed(rounds): - """現状固定: 開いた回数が結末の件数を上回る群は `resume`。""" - assert rounds.group_reopening(_reopening_group(attempt=1, failed=0)) == "resume" - - -def test_group_reopening_says_open_when_every_attempt_is_closed(rounds): - """現状固定: 開いた回数が結末の件数以下の群は `open`。""" - assert rounds.group_reopening(_reopening_group(attempt=0, failed=0)) == "open" - assert rounds.group_reopening(_reopening_group(attempt=1, failed=1)) == "open" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py index 7ac393df..d4d5b7b2 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py @@ -360,26 +360,6 @@ def test_revert_item_commits_failure_message_includes_item_id(gitfacts, work, ca assert _git("rev-parse", "HEAD", cwd=work).stdout.strip() == second -def test_revert_item_commits_does_nothing_for_an_already_reverted_item( - gitfacts, work, capsys -): - """現状固定: `reverted` が真の項目は 0 を返し、git も項目も動かさない。 - - push の失敗などで叩き直したときに、既に戻したコミットへもう一度 - `git revert` を掛けると必ず失敗する。 - """ - sha = _commit(work, "one", {"src/a.py": "a = 1\n"}) - head = _git("rev-parse", "HEAD", cwd=work).stdout.strip() - state = {"worktrees": {"work": str(work)}} - item = {"item_id": "R1-001", "commits": [sha], "reverted": True} - - assert gitfacts.revert_item_commits(state, item) == 0 - - assert item == {"item_id": "R1-001", "commits": [sha], "reverted": True} - assert _git("rev-parse", "HEAD", cwd=work).stdout.strip() == head - assert "↩ R1-001 は取り消し済みです" in capsys.readouterr().err - - def test_revert_range_failure_message_has_no_item_id_prefix(gitfacts, work, capsys): """現状固定: _revert_range 失敗時は項目 ID 接頭辞のないエラー文を出して中断する。""" first = _commit(work, "one", {"src/a.py": "a = 1\n"}) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index 63818621..adc7e054 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -195,44 +195,6 @@ def test_init_rejects_unknown_model_runtime(run_init, tmp_path): run_init(_args(tmp_path, model=["gpt=gpt-5.5"])) -# ---------- R2-003: 入力の誤りで止まる経路 ---------- -# -# 引数だけで判定できる 2 つの誤り(ホストの指定・モデルの指定)は、外部照会にも -# 作業ディレクトリ作成にも進まないまま中断する。 - -def _no_external(patch_lib): - """外部照会が走ったらテストを落とす。""" - patch_lib("sh", - lambda cmd, **k: pytest.fail(f"入力の誤りの後に外部を呼んでいる: {cmd}"), - ) - - -def test_init_stops_on_an_invalid_host_before_touching_anything( - patch_lib, cmd_setup, tmp_path -): - """現状固定: ホストになれない `--host` は中断コード 4 で止まる。""" - _no_external(patch_lib) - - with pytest.raises(SystemExit) as e: - cmd_setup.cmd_init(_args(tmp_path, host="gpt")) - - assert e.value.code == refactor_abort() - assert not (tmp_path / "rf130").exists(), "作業ディレクトリを作っている" - - -def test_init_stops_on_an_invalid_model_spec_before_touching_anything( - patch_lib, cmd_setup, tmp_path -): - """現状固定: 解析できない `--model` は中断コード 4 で止まる。""" - _no_external(patch_lib) - - with pytest.raises(SystemExit) as e: - cmd_setup.cmd_init(_args(tmp_path, model=["codex"])) - - assert e.value.code == refactor_abort() - assert not (tmp_path / "rf130").exists(), "作業ディレクトリを作っている" - - def test_init_accepts_agy_as_host(run_init, tmp_path): """agy も NDF の配布先であるため、ホストになれる。""" run_init(_args(tmp_path, host="agy")) From 30f6196be9f33451ef1f791ec33b75fb048bba13 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:22:40 +0000 Subject: [PATCH 143/217] =?UTF-8?q?Test:=20scoped=5Fitem=5Fids=20=E3=81=AE?= =?UTF-8?q?=E3=83=95=E3=82=A9=E3=83=BC=E3=83=AB=E3=83=90=E3=83=83=E3=82=AF?= =?UTF-8?q?=E3=82=92=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 現在の適用ラウンドに一致する群がない場合、entry 全体の items を元の順序で返す既存動作を固定する。 Item-Id: R2-005 Round: 2 Impl-Runtime: codex Impl-Model: default --- .../cross-refactoring/tests/test_git_facts.py | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py index d4d5b7b2..a079eec3 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py @@ -341,6 +341,22 @@ def test_find_item_returns_none_for_a_missing_id_when_not_required(gitfacts): assert gitfacts.find_item(state, "R9-999", required=False) is None +def test_scoped_item_ids_falls_back_to_all_items_when_apply_round_is_missing( + gitfacts, +): + """現状固定: 現在の適用ラウンドの群がなければ entry 全体の項目を返す。""" + entry = { + "apply_round": 3, + "items": ["R2-003", "R2-001", "R2-002"], + "apply_rounds": [ + {"apply_round": 1, "items": ["R2-001"]}, + {"apply_round": 2, "items": ["R2-002"]}, + ], + } + + assert gitfacts.scoped_item_ids(entry) == ["R2-003", "R2-001", "R2-002"] + + def test_revert_item_commits_failure_message_includes_item_id(gitfacts, work, capsys): """現状固定: revert_item_commits 失敗時は項目 ID 接頭辞付きのエラー文を出して中断する。""" first = _commit(work, "one", {"src/a.py": "a = 1\n"}) @@ -374,4 +390,3 @@ def test_revert_range_failure_message_has_no_item_id_prefix(gitfacts, work, caps assert "を取り消せませんでした" in err assert f"(HEAD を {second} へ戻しました)" in err assert _git("rev-parse", "HEAD", cwd=work).stdout.strip() == second - From f6e9df3ec20ca290b2d8f90fea855c16dfc450ba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:38:28 +0000 Subject: [PATCH 144/217] =?UTF-8?q?Refactor:=20=E9=81=A9=E7=94=A8=E3=83=A9?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E3=83=89=201=EF=BC=88R4-001=E3=80=9CR4-004?= =?UTF-8?q?=EF=BC=89=20=E2=80=94=20=E9=95=B7=E3=81=84=E9=96=A2=E6=95=B0?= =?UTF-8?q?=E3=81=AE=E5=88=86=E5=89=B2=E3=81=A8=E9=87=8D=E8=A4=87=E3=81=AE?= =?UTF-8?q?=E7=B5=B1=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R4-001 `plugins/ndf/scripts/lib/metrics.py#_aggregate_reviewer_round`: 判定一致の二重ループを `_tally_verdict_agreement` へ抽出し、指摘の集計と 判定一致の集計を分ける(extract_method) - R4-002 `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light / execute_squash`: close → create → trap 管理 → 番号抽出 → 結果出力の同じ順序を `rotate_close_and_create` へ統合し、モード固有はコメント・body・NEW_BRANCH・ `gh pr create` の引数だけにする(consolidate_duplication) - R4-003 `plugins/ndf/scripts/lib/worktree-common.sh#wt_extract_write_target`: 走査を `_wt_extract_scan` へ移し、語ごとの処理を命令の位置・段 3(現在地と 複合構文の追跡)・段 4(書き込み先の抽出)へ分ける。入口は前処理・字句化・ 走査を順に接続するだけにした(split_into_pipeline)。公開入出力を固定する 現状固定テスト `test_lib_write_target_stages.py` を先に追加した - R4-004 `plugins/ndf/scripts/lib/run_metrics.py#_by_round_count`: 入れ子の三項式を `_round_count_bucket` へ抽出し、境界を平坦な分岐で書く (extract_method) 振る舞いは変えていない。`uv run --with pytest pytest scripts/tests plugins/ndf -q` は 4770 passed / exit=0。 Item-Id: R4-001 Round: 4 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/metrics.py | 28 ++- plugins/ndf/scripts/lib/run_metrics.py | 12 +- plugins/ndf/scripts/lib/worktree-common.sh | 191 +++++++++++------- .../tests/test_lib_write_target_stages.py | 105 ++++++++++ .../skills/cross-review/scripts/rotate-pr.sh | 103 +++++----- 5 files changed, 306 insertions(+), 133 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_lib_write_target_stages.py diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index cdafe8ea..d900fc88 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -164,15 +164,25 @@ def _aggregate_reviewer_round( ] rb["findings"] += len(findings) rb["findings_resolved"] += sum(1 for f in findings if f.get("resolved")) - others = [o for o in entry.get("reviewers", []) if o != name] - for other in others: - other_verdict = _verdict(review, other) - if other_verdict is None: - continue - rb["verdict_pairs"] += 1 - rb["verdict_agreements"] += ( - 1 if other_verdict == _verdict(review, name) else 0 - ) + _tally_verdict_agreement(rb, entry, review, name) + + +def _tally_verdict_agreement( + rb: dict[str, Any], + entry: dict[str, Any], + review: dict[str, Any], + name: str, +) -> None: + """同じレビューに判定を出した他の担当との一致を数える。""" + others = [o for o in entry.get("reviewers", []) if o != name] + for other in others: + other_verdict = _verdict(review, other) + if other_verdict is None: + continue + rb["verdict_pairs"] += 1 + rb["verdict_agreements"] += ( + 1 if other_verdict == _verdict(review, name) else 0 + ) def _append_model_measurement_warnings( diff --git a/plugins/ndf/scripts/lib/run_metrics.py b/plugins/ndf/scripts/lib/run_metrics.py index f650a077..e64aac2d 100755 --- a/plugins/ndf/scripts/lib/run_metrics.py +++ b/plugins/ndf/scripts/lib/run_metrics.py @@ -388,14 +388,22 @@ def _by_total(rows: list[dict]) -> str: _finished_rows(rows) + _unfinished_rows(rows)) +def _round_count_bucket(count: int) -> Optional[str]: + """ラウンド数の表示区分。1 / 2 / 3 以上のどれでもなければ `None`(対象外)。""" + if count >= 3: + return "3 以上" + if count in (1, 2): + return str(count) + return None + + def _by_round_count(rows: list[dict]) -> str: buckets: dict[str, list[float]] = {"1": [], "2": [], "3 以上": []} for row in rows: minutes = _minutes(row) if row.get("kind") != "cross-review" or minutes is None: continue - count = len(row.get("rounds") or []) - key = "3 以上" if count >= 3 else str(count) if count in (1, 2) else None + key = _round_count_bucket(len(row.get("rounds") or [])) if key is not None: buckets[key].append(minutes) table = [[k, str(len(v)), _fmt(_quantile(sorted(v), 0.5))] for k, v in buckets.items() if v] diff --git a/plugins/ndf/scripts/lib/worktree-common.sh b/plugins/ndf/scripts/lib/worktree-common.sh index 89fe42dc..547be0e5 100644 --- a/plugins/ndf/scripts/lib/worktree-common.sh +++ b/plugins/ndf/scripts/lib/worktree-common.sh @@ -1055,7 +1055,28 @@ wt_extract_write_target() { _wt_read_lines < <(_wt_extract_tokenize "$spaced") words=("${WT_LINES[@]+"${WT_LINES[@]}"}") - # 段 3: 現在地追跡と書き込み先抽出のメイン走査 + # 段 3・段 4: 語列を 1 度読み通し、現在地を追いながら書き込み先を出す。 + _wt_extract_scan "$base" ${words[@]+"${words[@]}"} +} + +# 語列を 1 度だけ読み通す走査。第 1 引数は相対パスの起点、残りが語である。 +# 書き込み先を 1 件でも出せば 0、出せなければ 1 を返す。 +# +# 語ごとの処理は 3 つに分かれる。 +# +# | 段 | 関数 | 何を決めるか | +# | --- | --- | --- | +# | 命令の位置 | `_wt_scan_command_position` | その語が命令名か、関数定義の途中か | +# | 段 3: 追跡 | `_wt_scan_track_word` | 現在地と複合構文の入れ子。当たれば次の語へ進む | +# | 段 4: 抽出 | `_wt_scan_emit_word` | 書き込み先の語を出す | +# +# 状態は走査の全体で共有するため、各段はこの関数の局所変数を直接読み書きする。 +_wt_extract_scan() { + local base="$1" + shift + local -a words=() + words=("$@") + # `cd` を追った現在地と、それが確かかどうか。起点を渡されない限り使わない。 local cwd="$base" cwd_known=1 # `cd` の効果が及ぶ範囲は、それが動くシェルの中に限られる。パイプの各区画と @@ -1496,8 +1517,9 @@ wt_extract_write_target() { _emit "$dest" } - for ((i = 0; i < n; i++)); do - w=${words[i]} + # 今の語が命令の位置にあるかを決め、関数定義の見分けを進める。結果は `at_cmd` と + # `cmd_prefix` / `cmd_wrapper` / `func_stage` / `prev` に置く。書き込み先は出さない。 + _wt_scan_command_position() { # コマンドの位置にある語だけを命令として扱う。`echo cd > f` の `cd` を # 移動として数えると、書き込み先の起点がずれる。 at_cmd=0 @@ -1606,13 +1628,19 @@ wt_extract_write_target() { case "$func_moving" in *"|$w|"*) cwd_known=0 ;; esac fi prev="$w" + } + + # 段 3: 現在地と複合構文の追跡。今の語が区切り・複合構文・`cd` のいずれかであれば + # 走査の状態を更新して 0 を返す(呼び出し側はその語で次へ進む)。当たらなければ 1 を + # 返し、段 4 が書き込み先を見る。 + _wt_scan_track_word() { case "$w" in "|"|"|&") # パイプの各区画は部分シェルで動く。入口の位置へ戻す。 cwd="$pipe_cwd"; cwd_known="$pipe_known" # 区画の中の `cd` は親の位置を変えない。`||` の判定に使う回数も戻す。 list_cds="$pipe_cds"; cd_is_last=0 - continue + return 0 ;; "&") # 背景実行は処理のまとまりごと部分シェルへ入る。入口の位置へ戻す。 @@ -1622,7 +1650,7 @@ wt_extract_write_target() { list_cwd="$cwd"; list_known="$cwd_known"; list_cds=0; list_or=0 list_and_uncertain=0; list_cond_cd=0 cd_is_last=0 - continue + return 0 ;; "&&") # 左辺が成功したときに走る。移動の効果は残る。パイプの入口だけ引き直す。 @@ -1636,7 +1664,7 @@ wt_extract_write_target() { # ことが確かである。 if [ "$list_or" = 1 ] && [ "$list_cds" != 0 ]; then cwd_known=0; fi pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds="$list_cds" - continue + return 0 ;; "||") # 右辺が**後続へ進まない命令**なら、そこを過ぎた時点で左辺の成功が確定 @@ -1667,7 +1695,7 @@ wt_extract_write_target() { list_cwd="$cwd"; list_known="$cwd_known"; list_cds=0 list_and_uncertain=0; list_cond_cd=0; cd_is_last=0 pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds=0 - continue + return 0 ;; "{") # `cd dir || { echo ...; exit 1; }` は `|| exit` より広く使われる。 @@ -1699,7 +1727,7 @@ wt_extract_write_target() { fi list_or=1 pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds="$list_cds" - continue + return 0 ;; __WT_SEP__|__WT_CASE_FALL__) # `;` と改行でも同じシェルが続く。両方の入口を引き直す。 @@ -1723,7 +1751,7 @@ wt_extract_write_target() { list_cwd="$cwd"; list_known="$cwd_known"; list_cds=0; list_or=0 list_and_uncertain=0; list_cond_cd=0 cd_is_last=0 - continue + return 0 ;; if|while|until|for|select|case) # 複合コマンドの入口。中で `cd` を追ったかを、閉じるときに比べるため控える。 @@ -1738,7 +1766,7 @@ wt_extract_write_target() { case_depth=$((case_depth + 1)) fi fi - continue + return 0 ;; "{"|"(") # 部分シェル (`(`) と、同じシェルで走るまとまり (`{`) の入口。どちらも @@ -1764,7 +1792,7 @@ wt_extract_write_target() { func_stage="" fi fi - continue + return 0 ;; "}") # `}` は予約語で、命令の位置にしか置けない。`echo }` の `}` は語である。 @@ -1781,7 +1809,7 @@ wt_extract_write_target() { pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds=0 fi fi - continue + return 0 ;; __WT_SUBSHELL_END__) # 部分シェルの終わり。字句解析が切り出した `(` に対応するものだけが @@ -1793,7 +1821,7 @@ wt_extract_write_target() { _close_function_body _pop_group _pop_subshell - continue + return 0 ;; else|elif) # 条件が偽のときに走る。条件の中の `cd` は効いていない。 @@ -1801,7 +1829,7 @@ wt_extract_write_target() { [ "$cds" -gt "${block_cds[block_depth - 1]}" ]; then cwd_known=0 fi - continue + return 0 ;; fi|done|esac) # 本体が走ったかどうかは実行時に決まる。中で移動していたなら、閉じた後の @@ -1814,65 +1842,77 @@ wt_extract_write_target() { case_depth=$((case_depth - 1)) fi fi - continue - ;; - cd) - [ "$at_cmd" = 1 ] && [ -n "$base" ] || continue - # 部分シェルの中でも移動は追う。中の相対パスはここで解決する。親の位置は - # `)` で `_pop_subshell` が戻すため、この移動は外へ漏れない。 - # 移動先の語と、この `cd` に付いたリダイレクトを 1 回の走査で拾う。 - # **リダイレクト先は移動する前の位置で開かれる。** シェルはリダイレクトを - # 開いてから命令を実行するためである。移動後の位置で解決すると、主 - # ディレクトリ側への書き込みを作業ツリー側と取り違えて案内を出さない - # (検知漏れになる)。まだ `cwd` を更新していないここで解決する。 - dest="" - cd_end_of_options=0 - for ((k = i + 1; k < n; k++)); do - case "${words[k]}" in - __WT_REDIR__|__WT_APPEND__) - _redir_target "$k" - _emit "$_WT_REDIR_DEST" - k=$_WT_REDIR_END - continue - ;; - esac - if _wt_is_separator "${words[k]}"; then break; fi - case "${words[k]}" in - # `--` 以降はオプションの解釈を止める。`cd -- -dir` の `-dir` は - # 移動先であって `cd -` ではない。止めないと読み飛ばして、後続の - # 相対パスを抑止する。 - --) [ "$cd_end_of_options" = 1 ] || { cd_end_of_options=1; continue; } ;; - # **`-` だけは `--` の後でも直前の位置を指す。** bash では `-` が - # オプションではなく被演算子の綴りとして扱われるためで、`-` という - # 名前のディレクトリがあっても `$OLDPWD` へ移る(実測で確認)。 - # 字面からは追えないため、移動先を決めない。 - -) continue ;; - # `cd -` と同じく、オプションは移動先ではない。 - -*) [ "$cd_end_of_options" = 1 ] || continue ;; - esac - # 移動先は最初の被演算子である。リダイレクトを拾い切るため、 - # 見つけても区切りまで走査を続ける。 - [ -n "$dest" ] || dest=${words[k]} - done - # 走査が届いた位置を控える。`__WT_REDIR__` の枝が同じ語を二度拾わない。 - resolved_redir_end=$k - # `||` の右辺で戻せるかどうかの判定に使う。 - list_cds=$((list_cds + 1)); cd_is_last=1 - # `&&` を跨いだ先の `cd` は、走ったかどうかが左辺の成否で決まる。 - [ "$list_and_uncertain" = 0 ] || list_cond_cd=1 - # 複合コマンドを閉じるときの比較に使う。 - cds=$((cds + 1)) - case "$dest" in - # 引数なし (ホーム)・`cd -`・展開前の変数・チルダ展開。いずれも - # コマンドの字面からは移動先を決められない。 - ""|*'$'*|"~"*) cwd_known=0 ;; - /*) cwd=$(wt_normalize_path "$dest" "/"); cwd_known=1 ;; - *) [ "$cwd_known" = 1 ] && cwd=$(wt_normalize_path "$dest" "$cwd") ;; - esac + return 0 ;; + cd) _wt_scan_cd; return 0 ;; + esac + return 1 + } + + # `cd` の走査。移動先を現在地へ反映し、同じ命令に付いたリダイレクトを移動する前の + # 位置で解決する。命令の位置に無い `cd` と、起点を渡されない呼び方では何もしない。 + _wt_scan_cd() { + [ "$at_cmd" = 1 ] && [ -n "$base" ] || return 0 + # 部分シェルの中でも移動は追う。中の相対パスはここで解決する。親の位置は + # `)` で `_pop_subshell` が戻すため、この移動は外へ漏れない。 + # 移動先の語と、この `cd` に付いたリダイレクトを 1 回の走査で拾う。 + # **リダイレクト先は移動する前の位置で開かれる。** シェルはリダイレクトを + # 開いてから命令を実行するためである。移動後の位置で解決すると、主 + # ディレクトリ側への書き込みを作業ツリー側と取り違えて案内を出さない + # (検知漏れになる)。まだ `cwd` を更新していないここで解決する。 + dest="" + cd_end_of_options=0 + for ((k = i + 1; k < n; k++)); do + case "${words[k]}" in + __WT_REDIR__|__WT_APPEND__) + _redir_target "$k" + _emit "$_WT_REDIR_DEST" + k=$_WT_REDIR_END + continue + ;; + esac + if _wt_is_separator "${words[k]}"; then break; fi + case "${words[k]}" in + # `--` 以降はオプションの解釈を止める。`cd -- -dir` の `-dir` は + # 移動先であって `cd -` ではない。止めないと読み飛ばして、後続の + # 相対パスを抑止する。 + --) [ "$cd_end_of_options" = 1 ] || { cd_end_of_options=1; continue; } ;; + # **`-` だけは `--` の後でも直前の位置を指す。** bash では `-` が + # オプションではなく被演算子の綴りとして扱われるためで、`-` という + # 名前のディレクトリがあっても `$OLDPWD` へ移る(実測で確認)。 + # 字面からは追えないため、移動先を決めない。 + -) continue ;; + # `cd -` と同じく、オプションは移動先ではない。 + -*) [ "$cd_end_of_options" = 1 ] || continue ;; + esac + # 移動先は最初の被演算子である。リダイレクトを拾い切るため、 + # 見つけても区切りまで走査を続ける。 + [ -n "$dest" ] || dest=${words[k]} + done + # 走査が届いた位置を控える。`__WT_REDIR__` の枝が同じ語を二度拾わない。 + resolved_redir_end=$k + # `||` の右辺で戻せるかどうかの判定に使う。 + list_cds=$((list_cds + 1)); cd_is_last=1 + # `&&` を跨いだ先の `cd` は、走ったかどうかが左辺の成否で決まる。 + [ "$list_and_uncertain" = 0 ] || list_cond_cd=1 + # 複合コマンドを閉じるときの比較に使う。 + cds=$((cds + 1)) + case "$dest" in + # 引数なし (ホーム)・`cd -`・展開前の変数・チルダ展開。いずれも + # コマンドの字面からは移動先を決められない。 + ""|*'$'*|"~"*) cwd_known=0 ;; + /*) cwd=$(wt_normalize_path "$dest" "/"); cwd_known=1 ;; + *) [ "$cwd_known" = 1 ] && cwd=$(wt_normalize_path "$dest" "$cwd") ;; + esac + } + + # 段 4: 書き込み先の抽出。段 3 が扱わなかった語だけが渡る。出力は `_emit` が行い、 + # 相対パスはそこで現在地から解決される。 + _wt_scan_emit_word() { + case "$w" in __WT_REDIR__|__WT_APPEND__) # 移動前の位置で解決済みのリダイレクトは、その枝が拾い終えている。 - if [ "$i" -lt "$resolved_redir_end" ]; then continue; fi + if [ "$i" -lt "$resolved_redir_end" ]; then return 0; fi _redir_target "$i" _emit "$_WT_REDIR_DEST" # 命令の位置で読んだなら、被演算子の次に命令名が続く。 @@ -1900,11 +1940,20 @@ wt_extract_write_target() { _wt_extract_cp_mv_target "$i" ;; esac + } + + # 入口は各段を語ごとに順へ接続するだけにする。 + for ((i = 0; i < n; i++)); do + w=${words[i]} + _wt_scan_command_position + if _wt_scan_track_word; then continue; fi + _wt_scan_emit_word done unset -f _emit _push_group _pop_group _push_subshell _pop_subshell _or_group_exits \ _or_exit_redirs _close_function_body _wt_extract_sed_targets _wt_extract_cp_mv_target \ - _redir_span _wt_take_redirect_operand + _redir_span _wt_take_redirect_operand \ + _wt_scan_command_position _wt_scan_track_word _wt_scan_cd _wt_scan_emit_word [ "$found" = 1 ] || return 1 } diff --git a/plugins/ndf/scripts/tests/test_lib_write_target_stages.py b/plugins/ndf/scripts/tests/test_lib_write_target_stages.py new file mode 100644 index 00000000..df89e5c1 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_lib_write_target_stages.py @@ -0,0 +1,105 @@ +"""`wt_extract_write_target` の公開入出力を段の分割の前に固定する(現状固定テスト)。 + +**正しさを主張しない。** 走査を段(前処理・字句化・追跡・抽出)へ分けるとき、公開 +入口の振る舞いが変わっていないことだけを検出するために置く。期待値は分割の前の +実装を実際に動かして採った値である。 + +固定するのは、走査が持つ状態のうち分割で跨ぐもの(現在地の追跡、複合構文の入れ子、 +リダイレクトの解決済みの位置)が結果へ現れる形と、書き込みの 4 形式である。 + +| 固定する入力 | 何を通すか | +| --- | --- | +| 各書き込み形式 | `sed -i` / `>` / `>>` / `tee` / `cp` / `mv` | +| `cd` | 相対パスの起点の移動と、決められない移動先 | +| パイプ・背景実行 | 区画ごとの現在地の巻き戻し | +| 部分シェル | 中の移動を外へ漏らさない隔離 | +| `case` | 枝ごとに入口の位置へ戻す | +| 関数定義 | 本体の移動を外へ漏らさず、呼び出しの後は決めない | +| 複合構文・リダイレクト | `if` の中の移動、命令名より前のリダイレクト、記述子の複製 | +""" +from __future__ import annotations + +import os +import pathlib +import subprocess + +import pytest + +LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" / "worktree-common.sh" + +# (名前, コマンド, 起点, 期待する書き込み先, 期待する終了コード) +# 起点が空文字のときは第 2 引数を渡さない呼び方(出力は字面のまま)。 +CASES = [ + # --- 書き込みの 4 形式 --- + ("sed_inplace", "sed -i 's/a/b/' docs/a.md", "", ["docs/a.md"], 0), + ("redirect", "echo hi > docs/a.md", "", ["docs/a.md"], 0), + ("append", "echo hi >> docs/a.md", "", ["docs/a.md"], 0), + ("tee", "echo hi | tee docs/a.md docs/b.md", "", ["docs/a.md", "docs/b.md"], 0), + ("cp", "cp src.md docs/a.md", "", ["docs/a.md"], 0), + ("mv", "mv -f src.md docs/a.md", "", ["docs/a.md"], 0), + ("cp_target_dir", "cp -t docs/ a.md b.md", "", ["docs/"], 0), + ("read_only", "cat docs/a.md", "", [], 1), + ("empty", "", "", [], 1), + ("fd_dup", "make build 2>&1", "", [], 1), + ("heredoc", "cat > report.md < y\nEOS", "", ["report.md"], 0), + ("sed_after_redirect", "sed -i 's/a/b/' x.md >log y.md", "", + ["x.md", "y.md", "log"], 0), + # --- 現在地の追跡 --- + ("cd_then_write", "cd .worktrees/x\nsed -i 's/a/b/' README.md", "/base", + ["/base/.worktrees/x/README.md"], 0), + ("unresolvable_cd", 'cd "$TARGET"\nsed -i \'s/a/b/\' README.md', "/base", [], 1), + ("redirect_before_command", ">/dev/null cd .worktrees/x\necho hi > README.md", + "/base", ["/base/.worktrees/x/README.md"], 0), + ("cd_or_exit", "cd .worktrees/x || exit 1\necho hi > README.md", "/base", + ["/base/.worktrees/x/README.md"], 0), + ("cd_or_group_exit", "cd .worktrees/x || { echo ng; exit 1; }\necho hi > README.md", + "/base", ["/base/.worktrees/x/README.md"], 0), + # --- 部分シェルになる区画(パイプ・背景実行・`( )`) --- + ("cd_in_pipe", "cd .worktrees/x | true\necho hi > README.md", "/base", + ["/base/README.md"], 0), + ("pipe_segment_cd", "cd .worktrees/x && echo hi | tee README.md", "/base", + ["/base/.worktrees/x/README.md"], 0), + ("background_job", "cd .worktrees/x & echo hi > README.md", "/base", + ["/base/README.md"], 0), + ("subshell_cd", "( cd .worktrees/x; echo hi > in.md )\necho hi > out.md", "/base", + ["/base/.worktrees/x/in.md", "/base/out.md"], 0), + # --- 複合構文 --- + ("case_branches", + "case $1 in\n a) cd .worktrees/x; echo hi > a.md ;;\n b) echo hi > b.md ;;\nesac", + "/base", ["/base/.worktrees/x/a.md", "/base/b.md"], 0), + ("if_block_cd", "if true; then cd .worktrees/x; fi\necho hi > README.md", + "/base", [], 1), + # --- 関数定義 --- + ("function_def", + "f() {\n cd .worktrees/x\n echo hi > inner.md\n}\necho hi > outer.md", + "/base", ["/base/.worktrees/x/inner.md", "/base/outer.md"], 0), + ("function_call_after_move", "f() { cd .worktrees/x; }\nf\necho hi > after.md", + "/base", [], 1), +] + + +def _extract(command: str, base: str) -> tuple[list[str], int]: + """公開入口だけを通す。改行を含むコマンドはヒアドキュメントで渡す。""" + call = 'wt_extract_write_target "$cmd"' if not base \ + else f'wt_extract_write_target "$cmd" "{base}"' + script = ( + f'set -uo pipefail\n. "{LIB}"\n' + "cmd=$(cat <<'WT_EOF'\n" + command + "\nWT_EOF\n)\n" + f"{call}; echo rc=$?\n" + ) + env = {**os.environ, "LC_ALL": "C"} + done = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + env=env, timeout=120) + lines = [line for line in done.stdout.splitlines() if line] + rc = int(lines.pop().removeprefix("rc=")) + return lines, rc + + +@pytest.mark.parametrize( + ("command", "base", "targets", "rc"), + [pytest.param(*case[1:], id=case[0]) for case in CASES], +) +def test_the_public_entry_point_keeps_its_output( + command: str, base: str, targets: list[str], rc: int, +) -> None: + assert _extract(command, base) == (targets, rc) diff --git a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh index 5c7a26e8..e1e0d1f3 100755 --- a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh +++ b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh @@ -150,6 +150,44 @@ cmd_prepare() { printf 'IS_DRAFT=%q\n' "$(jq -r '.isDraft' <<<"$pr_json")" } +# 旧 PR の close から新 PR 作成・結果出力までの共通手順 (light / squash)。 +# モード固有なのは「旧 PR へ残すコメント」「新 PR の body」「NEW_BRANCH として出す名前」 +# 「gh pr create の引数」の 4 つだけで、順序と ERR trap の扱いは両モードで同じ。 +# +# rotate_close_and_create <コメント> <新 PR の body> +rotate_close_and_create() { + local comment=$1 new_body=$2 new_branch=$3 + shift 3 + local create_args=("$@") + + # 1. 旧 PR を close (コメント残し) + post_pr_comment "$OLD_PR" "$comment" + gh_retry gh pr close "$OLD_PR" + + # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する + # (関数定義は file 冒頭で共通化, gemini round 6 指摘) + trap reopen_old_pr_on_failure ERR + + # 2. 新 PR 作成。body は --body-file - 経由で stdin から渡し、argv 長制限を回避する + # (gemini 指摘) + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh_retry gh pr create "${create_args[@]}") + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。ブランチ名 / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$new_branch" +} + # light モード本体: 同ブランチで旧 PR を close → 同 head/base で新 PR 作成。 execute_light() { local state_pr=$1 @@ -183,36 +221,18 @@ execute_light() { echo "🔼 git push origin HEAD:$head_branch (未 push commit が無ければ no-op)" >&2 git push origin HEAD:"$head_branch" - # 2. 旧 PR を close (コメント残し) - post_pr_comment "$OLD_PR" "ℹ️ レビューコメント履歴整理のため本 PR を一度 close し、同じブランチ \`$head_branch\` で新 PR を作り直します。ブランチの内容・base は変えません。" - gh_retry gh pr close "$OLD_PR" - - # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する - # (関数定義は file 冒頭で共通化, gemini round 6 指摘) - trap reopen_old_pr_on_failure ERR - - # 3. 新 PR を同 head/base で作成 (Draft 状態は元 PR から継承)。 - # body は --body-file - 経由で stdin から渡し、argv 長制限を回避する (gemini 指摘)。 + # 2. 新 PR を同 head/base で作成 (Draft 状態は元 PR から継承)。 local create_args=(--base "$base_branch" --head "$head_branch" --title "$new_title" --body-file -) if [ "$is_draft" = "true" ]; then create_args+=(--draft) fi - local new_pr_url - new_pr_url=$(printf '%s' "$new_body" | gh_retry gh pr create "${create_args[@]}") - - # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても - # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 - trap - ERR - - # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, - # gemini round 6 指摘)。URL 形式: https://github.com///pull/ - local new_pr=${new_pr_url##*/} - echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 - # eval される契約。head_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape - printf 'NEW_PR=%q\n' "$new_pr" - printf 'NEW_PR_URL=%q\n' "$new_pr_url" - printf 'NEW_BRANCH=%q\n' "$head_branch" + # 3. close → create → 結果出力は squash と共通。 + rotate_close_and_create \ + "ℹ️ レビューコメント履歴整理のため本 PR を一度 close し、同じブランチ \`$head_branch\` で新 PR を作り直します。ブランチの内容・base は変えません。" \ + "$new_body" \ + "$head_branch" \ + "${create_args[@]}" } # squash モード本体。 @@ -283,17 +303,7 @@ execute_squash() { -m "(cross-review rotation: PR #$OLD_PR を squash 統合)" git push -u origin "$new_branch" - # 2. 旧 PR を close (コメント残し) - post_pr_comment "$OLD_PR" "🔄 cross-review ループ進行中のため、本 PR を close し新規 PR に巻き直します。 round_in_pr=$ROUND_IN_PR で長尺化を回避。" - gh_retry gh pr close "$OLD_PR" - - # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する - # (関数定義は file 冒頭で共通化, gemini round 6 指摘) - trap reopen_old_pr_on_failure ERR - - # 3. 新 PR 作成 - # body は --body-file - 経由で stdin から渡し、argv 長制限を回避する - # (execute_light と統一, gemini round 5 指摘) + # 2. 新 PR の body local new_body new_body=$(cat < EOF ) - local new_pr_url - new_pr_url=$(printf '%s' "$new_body" | gh_retry gh pr create --base "$base" --title "$new_title" --body-file -) - - # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても - # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 - trap - ERR - # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, - # gemini round 6 指摘)。URL 形式: https://github.com///pull/ - local new_pr=${new_pr_url##*/} - - echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 - # eval される契約。new_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape - printf 'NEW_PR=%q\n' "$new_pr" - printf 'NEW_PR_URL=%q\n' "$new_pr_url" - printf 'NEW_BRANCH=%q\n' "$new_branch" + # 3. close → create → 結果出力は light と共通。 + rotate_close_and_create \ + "🔄 cross-review ループ進行中のため、本 PR を close し新規 PR に巻き直します。 round_in_pr=$ROUND_IN_PR で長尺化を回避。" \ + "$new_body" \ + "$new_branch" \ + --base "$base" --title "$new_title" --body-file - } cmd_execute() { From 6d0231b52d126bf1fa4ea4734466d2841e2aed91 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:39:26 +0000 Subject: [PATCH 145/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 72 +++++++++++++++++++++++++++++++- 1 file changed, 71 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 6bb1b010..e1be8410 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -183,7 +183,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 検証中 | 1 | +| duplication | consolidate_duplication | minor | kiro | 採用 | 1 | **なぜ**: _state_file_pr と _prs が同じ pr_history 走査(dict 判定→_as_int(entry.get("pr"))→current_pr へのフォールバック)を別々に持つ。_state_file_pr は実質「_prs の先頭」で、片方だけ直すと状態ファイルの鍵の選び方が食い違う。同じ業務ルール(状態ファイルの鍵の決め方)に由来し、必ず一緒に変わる重複である。 @@ -203,6 +203,75 @@ 2. fetch は opener 呼び出しと finally の close を残し、本文取得を抽出関数の呼び出しに置き換える 3. test_refresh.py の refresh/fetch 経路のテストで退行を確認する +## ラウンド 4(実装 claude / レビュー codex / kiro) + +### R4-001 — `plugins/ndf/scripts/lib/metrics.py#_aggregate_reviewer_round` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | kiro | 検証中 | 1 | + +**なぜ**: 1 つの関数が 2 つの独立した集計を通しで行う。前半は review ごとの指摘件数と解決件数の集計、後半は entry.get('reviewers') を回して判定一致(verdict_pairs / verdict_agreements)を数える二重ループである。指摘の集計と判定一致の集計は変更理由が別で、後半のネストしたループが読む負荷を上げている。 + +**手順**: 1. 後半の others ループ(verdict_pairs / verdict_agreements の加算)を _tally_verdict_agreement(rb, entry, review, name) として抽出する +2. 抽出した関数は entry.get('reviewers') から name 以外を取り出し、_verdict を使って一致数を rb へ加算する +3. _aggregate_reviewer_round のループ本体を、指摘集計+抽出した関数の呼び出しに置き換える +4. metrics.aggregate を通す既存テスト(cross-refactoring 側 test_models_and_metrics.py の resolution_rate / agreement_rate)で退行が無いことを確かめる + +### R4-002 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light / execute_squash` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| duplication | consolidate_duplication | major | codex | 検証中 | 1 | + +**なぜ**: 両モードが旧 PR へのコメント、close、ERR trap の設定、新 PR 作成、trap 解除、URL からの番号抽出、NEW_PR・NEW_PR_URL・NEW_BRANCH の出力を同じ順序で持つ。同じ障害対策のコメントが両方へ反映されており、変更理由も共通している。 + +**手順**: 1. 既存の rotate-pr テストで light と squash の close、作成失敗時の reopen、成功時の出力を固定する +2. モード固有処理から新 PR の head、base、title、body、draft を組み立てる部分だけを残す +3. close から create、trap 管理、番号抽出、結果出力までを共通関数へ抽出する +4. execute_light と execute_squash を共通関数呼び出しへ置き換える +5. 両モードの既存テストを実行してコマンド順と標準出力が不変であることを確認する + +### R4-003 — `plugins/ndf/scripts/lib/worktree-common.sh#wt_extract_write_target` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | split_into_pipeline | major | codex | 検証中 | 1 | + +**なぜ**: 書き込み先抽出の入口に、ヒアドキュメント除去、字句化、作業ディレクトリと複合構文の状態追跡、sed・tee・cp・mv・リダイレクトの対象抽出が連続して同居している。多数の局所状態と入れ子の補助関数を一度に追う必要があり、各段を独立して固定できない。 + +**手順**: 1. 対象テスト配下に wt_extract_write_target の公開入出力を通す現状固定テストを追加し、cd、パイプ、部分シェル、case、関数定義、各書き込み形式を固定する +2. 前処理と字句化を、改行区切りの語列を返す段として独立させる +3. 現在地と複合構文の追跡を、語列から走査状態を更新する段へ分ける +4. 書き込み先候補の抽出と相対パス解決を最終段へ分け、入口は各段を順に接続するだけにする +5. 各段の後と最後に現状固定テストおよび全体テストを実行する + +### R4-004 — `plugins/ndf/scripts/lib/run_metrics.py#_by_round_count` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| conditional_chain | extract_method | minor | kiro | 検証中 | 1 | + +**なぜ**: バケット鍵の決定が入れ子の三項式 key = "3 以上" if count >= 3 else str(count) if count in (1, 2) else None に埋まっている。ラウンド数から表示区分を導く判断がループ本体の 1 行に押し込まれ、境界(1 / 2 / 3 以上 / 対象外)が読み取りづらい。 + +**手順**: 1. count から区分文字列(または None)を返す _round_count_bucket(count) を抽出する +2. 分岐を if count >= 3 / elif count in (1, 2) / else None として平坦に書く +3. _by_round_count のループ本体で key = _round_count_bucket(count) を呼ぶ形へ置き換える +4. test_run_metrics.py::test_aggregate_by_round_count(1 / 2 / 3 以上 の 3 行)で退行が無いことを確かめる + +### R4-005 — `plugins/ndf/scripts/lib/run_metrics.py#_select` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| conditional_chain | extract_method | minor | kiro | 未着手 | 0 | + +**なぜ**: 行ごとの絞り込みが 5 本の連続した if ... continue と、until 判定に埋め込まれた入れ子の三項(started >= until if until_exclusive else started > until)で構成される。時刻の下限・上限・repo・kind・version という別々の観点が 1 つのループ本体に同居し、until_exclusive の分岐が特に読みづらい。 + +**手順**: 1. 時刻の下限・上限の判定を _within_time_bound(started, since, until, until_exclusive) として抽出し、入れ子の三項をその中に閉じ込める +2. _select は since/until を計算した後、_within_time_bound と残りの属性一致(repo / kind / version)で 1 行を通すか決める +3. 属性一致も見通しが悪ければ _matches_filters(row, args) へまとめる +4. test_run_metrics.py::test_aggregate_filters(since / until / repo / kind / version の 5 例)で退行が無いことを確かめる + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -222,3 +291,4 @@ | 3 | `plugins/ndf/scripts/lib/assignment.py#resolve_participants` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 3 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 3 | `plugins/ndf/scripts/lib/refresh.py#fetch` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` | long_method | 1 ラウンドの採用上限 5 件を超えた | From 96fe22fd5c54cf2a0cf812c7be3386edfa2dcf86 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:41:30 +0000 Subject: [PATCH 146/217] =?UTF-8?q?Refactor:=20gate/apply/setup/proposals?= =?UTF-8?q?=E3=81=AE=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E6=8A=BD=E5=87=BA?= =?UTF-8?q?=E3=81=A8=E9=87=8D=E8=A4=87=E9=96=A2=E6=95=B0=E3=81=AE=E7=B5=B1?= =?UTF-8?q?=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提案ラウンド3の適用ラウンド1として以下の構造改善を適用: - R3-001: gate.py の cmd_final_gate から終了処理・修正準備処理を抽出 - R3-002: apply.py の cmd_merge_test_judgements から判定読み込みと却下処理を抽出 - R3-003: setup.py と report.py に重複していた rounds_of_kind を rounds.py へ集約 - R3-004: proposals.py の提案集約処理を _accumulate_proposals へ統合 Item-Id: R3-001 Item-Id: R3-002 Item-Id: R3-003 Item-Id: R3-004 Round: 3 Impl-Runtime: agy Impl-Model: default --- .../scripts/refactor_lib/commands/apply.py | 90 +++++++++++-------- .../scripts/refactor_lib/commands/gate.py | 47 +++++++--- .../scripts/refactor_lib/commands/report.py | 21 +++-- .../scripts/refactor_lib/commands/setup.py | 11 +-- .../scripts/refactor_lib/proposals.py | 46 +++++----- .../scripts/refactor_lib/rounds.py | 5 ++ 6 files changed, 133 insertions(+), 87 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index de59bab6..38b0e3a3 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -973,6 +973,55 @@ def _resume_incomplete_apply( flush_pending_push(path, state, entry) +def _load_test_judgement_verdicts( + state: dict[str, Any], entry: dict[str, Any], group_no: int +) -> list[dict[str, Any]]: + """担当者の結果ファイルを読み込んで verdicts リストを抽出・正規化する。 + + **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 + 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 + """ + impl = (current_group(entry) or {}).get("impl") or entry.get("impl") + verdicts: list[dict[str, Any]] = [] + if impl: + result = result_path( + state, impl, + f"{impl}-judge-test-changes-r{entry['round']}-g{group_no}") + if result.exists(): + try: + payload = json.loads(result.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + payload = {} + found = payload.get("verdicts") + if isinstance(found, list): + verdicts = [v for v in found if isinstance(v, dict)] + return verdicts + + +def _reject_test_judgements( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + group: dict[str, Any], + problem: str, +) -> None: + failed = list(group.get("items") or []) + # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 + for item_id in failed: + item = find_item(state, item_id, required=False) + if item: + item["status"] = "abandoned" + item["failure_reason"] = problem + _apply_drop(path, state, entry, group, failed) + # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた + # 分まで捨てない。 + record_pending_judgements(entry, group.get("apply_round") or 1, []) + statefile.save(path, state) + info(f"❌ 適用ラウンド {group.get('apply_round')}: {problem}") + # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 + sys.exit(2) + + def cmd_merge_test_judgements(args: argparse.Namespace) -> None: """段 2(AI エージェント)の答えを取り込む(#443)。 @@ -986,52 +1035,21 @@ def cmd_merge_test_judgements(args: argparse.Namespace) -> None: entry = round_of(state, args.round) # **判定の対象はこの群の保留である。** 全ての群をまとめて解かない。 records = entry.get("pending_test_judgements") - group_of_round = (current_group(entry) or {}).get("apply_round") or 1 - pending = list((records or {}).get(str(group_of_round), [])) \ + group = current_group(entry) or {} + group_no = group.get("apply_round") or 1 + pending = list((records or {}).get(str(group_no), [])) \ if isinstance(records, dict) else [] if not pending: info("判定を待っているテストはありません") return - # **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 - # 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 - impl = (current_group(entry) or {}).get("impl") or entry.get("impl") - verdicts: list[dict[str, Any]] = [] - if impl: - result = result_path( - state, impl, - f"{impl}-judge-test-changes-r{args.round}-g{group_of_round}") - if result.exists(): - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - payload = {} - found = payload.get("verdicts") - if isinstance(found, list): - verdicts = [v for v in found if isinstance(v, dict)] - + verdicts = _load_test_judgement_verdicts(state, entry, group_no) outcome = merge_test_judgements(pending, verdicts) if outcome["problem"]: - group = current_group(entry) - failed = list(group.get("items") or []) - # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 - for item_id in failed: - item = find_item(state, item_id, required=False) - if item: - item["status"] = "abandoned" - item["failure_reason"] = outcome["problem"] - _apply_drop(path, state, entry, group, failed) - # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた - # 分まで捨てない。 - record_pending_judgements(entry, group.get("apply_round") or 1, []) - statefile.save(path, state) - info(f"❌ 適用ラウンド {group.get('apply_round')}: {outcome['problem']}") - # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 - sys.exit(2) + _reject_test_judgements(path, state, entry, group, outcome["problem"]) # **解くのは、判定が実際に見た群の保留だけである。** 段 2 へ渡すのはその群の # 差分であるため、別の群で同じファイルが残っていてもそちらは解かない。 - group_no = (current_group(entry) or {}).get("apply_round") or 1 remaining = apply_judgements_to_group(entry, group_no, verdicts) if remaining: info( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index 08596df4..c6ccae25 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -82,23 +82,46 @@ def cmd_final_gate(args: argparse.Namespace) -> None: }) if passed: - gate["status"] = "passed" - statefile.save(path, state) - info(f"✅ 最終ゲートを通過しました({detail})") - statefile.emit(FINAL_GATE="passed") + _finish_passed(gate, path, state, detail) return limit = safe_int(state.get("max_fix_rounds"), 3) if safe_int(gate.get("fix_rounds")) >= limit: - gate["status"] = "failed" - statefile.save(path, state) - info( - f"❌ 最終ゲートが通らないまま修正の上限 {limit} に達しました({detail})。" - "**既に push してあるため取り消しません。** 失敗として報告します" - ) - statefile.emit(FINAL_GATE="failed") - sys.exit(1) + _finish_failed(gate, path, state, limit, detail) + + _start_fix_round(state, gate, path, detail) + +def _finish_passed( + gate: dict[str, Any], path: pathlib.Path, state: dict[str, Any], detail: str +) -> None: + gate["status"] = "passed" + statefile.save(path, state) + info(f"✅ 最終ゲートを通過しました({detail})") + statefile.emit(FINAL_GATE="passed") + + +def _finish_failed( + gate: dict[str, Any], + path: pathlib.Path, + state: dict[str, Any], + limit: int, + detail: str, +) -> None: + gate["status"] = "failed" + statefile.save(path, state) + info( + f"❌ 最終ゲートが通らないまま修正の上限 {limit} に達しました({detail})。" + "**既に push してあるため取り消しません。** 失敗として報告します" + ) + statefile.emit(FINAL_GATE="failed") + sys.exit(1) + + +def _start_fix_round( + state: dict[str, Any], gate: dict[str, Any], path: pathlib.Path, detail: str +) -> None: + limit = safe_int(state.get("max_fix_rounds"), 3) gate["fix_rounds"] = safe_int(gate.get("fix_rounds")) + 1 gate["status"] = "failing" # **修正の起点と担当をここで記録する。** 記録しないと `merge-final-fix` が範囲を diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py index 06cc9626..25f63194 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py @@ -20,7 +20,15 @@ from ..outbound import plan_reference from ..paths import load_state from ..proposals import duplicate_rate -from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, item_kind, item_label +from ..rounds import ( + STRUCTURE, + TEST, + entry_kind, + finish_outer_rounds, + item_kind, + item_label, + rounds_of_kind, +) from ..vocabulary import DEFAULT_MAX_TEST_ROUNDS, DUPLICATE_RATE_THRESHOLD @@ -47,13 +55,13 @@ def cmd_advance(args: argparse.Namespace) -> None: if entry_kind(last) == TEST: _advance_test_rounds(path, state, last) return - if len(_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: + if len(rounds_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: finish_outer_rounds(path, state, "max_outer_rounds") sys.exit(1) if last.get("adopted") == 0: finish_outer_rounds(path, state, "no_more_proposals") sys.exit(1) - previous = _of_kind(rounds[:-1], STRUCTURE) + previous = rounds_of_kind(rounds[:-1], STRUCTURE) if previous: # **同じ種類どうしで測る。** 鍵の形が種類で違うため、テスト整備ラウンドを # 相手にすると重なりが常に 0 になり、収束の判定が働かない。 @@ -67,11 +75,6 @@ def cmd_advance(args: argparse.Namespace) -> None: sys.exit(1) -def _of_kind(rounds: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: - """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" - return [r for r in rounds if entry_kind(r) == kind] - - def _advance_test_rounds( path: pathlib.Path, state: dict[str, Any], last: dict[str, Any] ) -> None: @@ -81,7 +84,7 @@ def _advance_test_rounds( 残っていても移る。**どちらで移ったかを記録する**(収束して終わったのか、 歯止めで止まったのかを報告で読み分けるため)。 """ - done = len(_of_kind(state["rounds"], TEST)) + done = len(rounds_of_kind(state["rounds"], TEST)) limit = safe_int(state.get("max_test_rounds"), DEFAULT_MAX_TEST_ROUNDS) if last.get("adopted") == 0: reason = "no_more_test_proposals" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index 011ec37a..c4433eae 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -31,7 +31,7 @@ tmp_dir_for, ) from ..plan import PLAN_COMMENT, PLAN_FILE, PLAN_NONE, normalize_plan_file -from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, round_kind +from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, round_kind, rounds_of_kind from ..scope import require_scope_covers_tests from ..vocabulary import ( DEFAULT_TEST_TIMEOUT, @@ -460,11 +460,6 @@ def _run_baseline_test( return {"command": command, "status": status, "checked_at": statefile.now()} -def rounds_of_kind(state: dict[str, Any], kind: str) -> list[dict[str, Any]]: - """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" - return [r for r in state.get("rounds") or [] if entry_kind(r) == kind] - - def cmd_start_round(args: argparse.Namespace) -> None: """Step 2 — ラウンドを開き、実装担当とレビュー担当を返す。 @@ -484,7 +479,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: rounds = state["rounds"] kind = round_kind(state) - if kind == STRUCTURE and len(rounds_of_kind(state, STRUCTURE)) >= state["max_outer_rounds"]: + if kind == STRUCTURE and len(rounds_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: finish_outer_rounds(path, state, "max_outer_rounds") sys.exit(1) @@ -525,7 +520,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: else: label = "提案ラウンド" limit = state["max_outer_rounds"] - seq = len(rounds_of_kind(state, kind)) + seq = len(rounds_of_kind(rounds, kind)) info( f"=== {label} {seq} / {limit} " f"(実装 {existing['impl']} / レビュー {' + '.join(existing['reviewers'])})===" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index 21a71be1..c5797f76 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -105,6 +105,26 @@ def _merge_one(existing: dict[str, Any], incoming: dict[str, Any]) -> None: ) +def _accumulate_proposals( + proposals: dict[str, list[dict[str, Any]]], + normalize_fn: Callable[[dict[str, Any], str], Optional[dict[str, Any]]], + merge_fn: Callable[[dict[str, Any], dict[str, Any]], None], +) -> dict[tuple[str, ...], dict[str, Any]]: + """各ランタイムの提案を正規化・重複排除しながら集約する。""" + merged: dict[tuple[str, ...], dict[str, Any]] = {} + for source, items in proposals.items(): + for raw in items: + norm = normalize_fn(raw, source) + if norm is None: + continue + key = _dedupe_key(norm) + if key in merged: + merge_fn(merged[key], norm) + else: + merged[key] = norm + return merged + + def merge_proposals( proposals: dict[str, list[dict[str, Any]]], threshold: str = DEFAULT_SEVERITY_THRESHOLD, @@ -120,17 +140,7 @@ def merge_proposals( `excluded_keys` には過去に見送った項目の鍵を渡す。見送った項目を毎ラウンド 再提案されると収束しないため、対象外として落とす。 """ - merged: dict[tuple[str, ...], dict[str, Any]] = {} - for source, items in proposals.items(): - for raw in items: - norm = _normalize_proposal(raw, source) - if norm is None: - continue - key = _dedupe_key(norm) - if key in merged: - _merge_one(merged[key], norm) - else: - merged[key] = norm + merged = _accumulate_proposals(proposals, _normalize_proposal, _merge_one) min_severity = SEVERITY_ORDER.get( threshold, SEVERITY_ORDER[DEFAULT_SEVERITY_THRESHOLD]) @@ -263,17 +273,9 @@ def merge_test_proposals( 採否の詰めは `merge_proposals` と同じ `_select` が行う。 """ - merged: dict[tuple[str, ...], dict[str, Any]] = {} - for source, items in proposals.items(): - for raw in items: - norm = _normalize_test_proposal(raw, source) - if norm is None: - continue - key = _dedupe_key(norm) - if key in merged: - _merge_test_one(merged[key], norm) - else: - merged[key] = norm + merged = _accumulate_proposals( + proposals, _normalize_test_proposal, _merge_test_one + ) def reject(item: dict[str, Any]) -> Optional[str]: if item["case"] == "unknown" or item["level"] == "unknown": diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py index fa17d6ef..210e7036 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py @@ -36,6 +36,11 @@ def entry_kind(entry: dict[str, Any]) -> str: return TEST if entry.get("kind") == TEST else STRUCTURE +def rounds_of_kind(rounds: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: + """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" + return [r for r in rounds if entry_kind(r) == kind] + + def item_kind(item: dict[str, Any]) -> str: """項目 1 件の種類。改善項目とテスト項目は同じ一覧に並ぶ。""" return TEST if item.get("kind") == TEST else STRUCTURE From c2e6b838e6a98e7d946c6398fa9a3f5e7b23a03b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:42:21 +0000 Subject: [PATCH 147/217] =?UTF-8?q?Revert=20"Refactor:=20gate/apply/setup/?= =?UTF-8?q?proposals=E3=81=AE=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=E3=81=A8=E9=87=8D=E8=A4=87=E9=96=A2=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E7=B5=B1=E5=90=88"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 96fe22fd5c54cf2a0cf812c7be3386edfa2dcf86. --- .../scripts/refactor_lib/commands/apply.py | 90 ++++++++----------- .../scripts/refactor_lib/commands/gate.py | 47 +++------- .../scripts/refactor_lib/commands/report.py | 21 ++--- .../scripts/refactor_lib/commands/setup.py | 11 ++- .../scripts/refactor_lib/proposals.py | 46 +++++----- .../scripts/refactor_lib/rounds.py | 5 -- 6 files changed, 87 insertions(+), 133 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 38b0e3a3..de59bab6 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -973,55 +973,6 @@ def _resume_incomplete_apply( flush_pending_push(path, state, entry) -def _load_test_judgement_verdicts( - state: dict[str, Any], entry: dict[str, Any], group_no: int -) -> list[dict[str, Any]]: - """担当者の結果ファイルを読み込んで verdicts リストを抽出・正規化する。 - - **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 - 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 - """ - impl = (current_group(entry) or {}).get("impl") or entry.get("impl") - verdicts: list[dict[str, Any]] = [] - if impl: - result = result_path( - state, impl, - f"{impl}-judge-test-changes-r{entry['round']}-g{group_no}") - if result.exists(): - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - payload = {} - found = payload.get("verdicts") - if isinstance(found, list): - verdicts = [v for v in found if isinstance(v, dict)] - return verdicts - - -def _reject_test_judgements( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - group: dict[str, Any], - problem: str, -) -> None: - failed = list(group.get("items") or []) - # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 - for item_id in failed: - item = find_item(state, item_id, required=False) - if item: - item["status"] = "abandoned" - item["failure_reason"] = problem - _apply_drop(path, state, entry, group, failed) - # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた - # 分まで捨てない。 - record_pending_judgements(entry, group.get("apply_round") or 1, []) - statefile.save(path, state) - info(f"❌ 適用ラウンド {group.get('apply_round')}: {problem}") - # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 - sys.exit(2) - - def cmd_merge_test_judgements(args: argparse.Namespace) -> None: """段 2(AI エージェント)の答えを取り込む(#443)。 @@ -1035,21 +986,52 @@ def cmd_merge_test_judgements(args: argparse.Namespace) -> None: entry = round_of(state, args.round) # **判定の対象はこの群の保留である。** 全ての群をまとめて解かない。 records = entry.get("pending_test_judgements") - group = current_group(entry) or {} - group_no = group.get("apply_round") or 1 - pending = list((records or {}).get(str(group_no), [])) \ + group_of_round = (current_group(entry) or {}).get("apply_round") or 1 + pending = list((records or {}).get(str(group_of_round), [])) \ if isinstance(records, dict) else [] if not pending: info("判定を待っているテストはありません") return - verdicts = _load_test_judgement_verdicts(state, entry, group_no) + # **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 + # 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 + impl = (current_group(entry) or {}).get("impl") or entry.get("impl") + verdicts: list[dict[str, Any]] = [] + if impl: + result = result_path( + state, impl, + f"{impl}-judge-test-changes-r{args.round}-g{group_of_round}") + if result.exists(): + try: + payload = json.loads(result.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + payload = {} + found = payload.get("verdicts") + if isinstance(found, list): + verdicts = [v for v in found if isinstance(v, dict)] + outcome = merge_test_judgements(pending, verdicts) if outcome["problem"]: - _reject_test_judgements(path, state, entry, group, outcome["problem"]) + group = current_group(entry) + failed = list(group.get("items") or []) + # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 + for item_id in failed: + item = find_item(state, item_id, required=False) + if item: + item["status"] = "abandoned" + item["failure_reason"] = outcome["problem"] + _apply_drop(path, state, entry, group, failed) + # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた + # 分まで捨てない。 + record_pending_judgements(entry, group.get("apply_round") or 1, []) + statefile.save(path, state) + info(f"❌ 適用ラウンド {group.get('apply_round')}: {outcome['problem']}") + # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 + sys.exit(2) # **解くのは、判定が実際に見た群の保留だけである。** 段 2 へ渡すのはその群の # 差分であるため、別の群で同じファイルが残っていてもそちらは解かない。 + group_no = (current_group(entry) or {}).get("apply_round") or 1 remaining = apply_judgements_to_group(entry, group_no, verdicts) if remaining: info( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index c6ccae25..08596df4 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -82,46 +82,23 @@ def cmd_final_gate(args: argparse.Namespace) -> None: }) if passed: - _finish_passed(gate, path, state, detail) + gate["status"] = "passed" + statefile.save(path, state) + info(f"✅ 最終ゲートを通過しました({detail})") + statefile.emit(FINAL_GATE="passed") return limit = safe_int(state.get("max_fix_rounds"), 3) if safe_int(gate.get("fix_rounds")) >= limit: - _finish_failed(gate, path, state, limit, detail) - - _start_fix_round(state, gate, path, detail) - - -def _finish_passed( - gate: dict[str, Any], path: pathlib.Path, state: dict[str, Any], detail: str -) -> None: - gate["status"] = "passed" - statefile.save(path, state) - info(f"✅ 最終ゲートを通過しました({detail})") - statefile.emit(FINAL_GATE="passed") - - -def _finish_failed( - gate: dict[str, Any], - path: pathlib.Path, - state: dict[str, Any], - limit: int, - detail: str, -) -> None: - gate["status"] = "failed" - statefile.save(path, state) - info( - f"❌ 最終ゲートが通らないまま修正の上限 {limit} に達しました({detail})。" - "**既に push してあるため取り消しません。** 失敗として報告します" - ) - statefile.emit(FINAL_GATE="failed") - sys.exit(1) - + gate["status"] = "failed" + statefile.save(path, state) + info( + f"❌ 最終ゲートが通らないまま修正の上限 {limit} に達しました({detail})。" + "**既に push してあるため取り消しません。** 失敗として報告します" + ) + statefile.emit(FINAL_GATE="failed") + sys.exit(1) -def _start_fix_round( - state: dict[str, Any], gate: dict[str, Any], path: pathlib.Path, detail: str -) -> None: - limit = safe_int(state.get("max_fix_rounds"), 3) gate["fix_rounds"] = safe_int(gate.get("fix_rounds")) + 1 gate["status"] = "failing" # **修正の起点と担当をここで記録する。** 記録しないと `merge-final-fix` が範囲を diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py index 25f63194..06cc9626 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py @@ -20,15 +20,7 @@ from ..outbound import plan_reference from ..paths import load_state from ..proposals import duplicate_rate -from ..rounds import ( - STRUCTURE, - TEST, - entry_kind, - finish_outer_rounds, - item_kind, - item_label, - rounds_of_kind, -) +from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, item_kind, item_label from ..vocabulary import DEFAULT_MAX_TEST_ROUNDS, DUPLICATE_RATE_THRESHOLD @@ -55,13 +47,13 @@ def cmd_advance(args: argparse.Namespace) -> None: if entry_kind(last) == TEST: _advance_test_rounds(path, state, last) return - if len(rounds_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: + if len(_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: finish_outer_rounds(path, state, "max_outer_rounds") sys.exit(1) if last.get("adopted") == 0: finish_outer_rounds(path, state, "no_more_proposals") sys.exit(1) - previous = rounds_of_kind(rounds[:-1], STRUCTURE) + previous = _of_kind(rounds[:-1], STRUCTURE) if previous: # **同じ種類どうしで測る。** 鍵の形が種類で違うため、テスト整備ラウンドを # 相手にすると重なりが常に 0 になり、収束の判定が働かない。 @@ -75,6 +67,11 @@ def cmd_advance(args: argparse.Namespace) -> None: sys.exit(1) +def _of_kind(rounds: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: + """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" + return [r for r in rounds if entry_kind(r) == kind] + + def _advance_test_rounds( path: pathlib.Path, state: dict[str, Any], last: dict[str, Any] ) -> None: @@ -84,7 +81,7 @@ def _advance_test_rounds( 残っていても移る。**どちらで移ったかを記録する**(収束して終わったのか、 歯止めで止まったのかを報告で読み分けるため)。 """ - done = len(rounds_of_kind(state["rounds"], TEST)) + done = len(_of_kind(state["rounds"], TEST)) limit = safe_int(state.get("max_test_rounds"), DEFAULT_MAX_TEST_ROUNDS) if last.get("adopted") == 0: reason = "no_more_test_proposals" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index c4433eae..011ec37a 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -31,7 +31,7 @@ tmp_dir_for, ) from ..plan import PLAN_COMMENT, PLAN_FILE, PLAN_NONE, normalize_plan_file -from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, round_kind, rounds_of_kind +from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, round_kind from ..scope import require_scope_covers_tests from ..vocabulary import ( DEFAULT_TEST_TIMEOUT, @@ -460,6 +460,11 @@ def _run_baseline_test( return {"command": command, "status": status, "checked_at": statefile.now()} +def rounds_of_kind(state: dict[str, Any], kind: str) -> list[dict[str, Any]]: + """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" + return [r for r in state.get("rounds") or [] if entry_kind(r) == kind] + + def cmd_start_round(args: argparse.Namespace) -> None: """Step 2 — ラウンドを開き、実装担当とレビュー担当を返す。 @@ -479,7 +484,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: rounds = state["rounds"] kind = round_kind(state) - if kind == STRUCTURE and len(rounds_of_kind(rounds, STRUCTURE)) >= state["max_outer_rounds"]: + if kind == STRUCTURE and len(rounds_of_kind(state, STRUCTURE)) >= state["max_outer_rounds"]: finish_outer_rounds(path, state, "max_outer_rounds") sys.exit(1) @@ -520,7 +525,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: else: label = "提案ラウンド" limit = state["max_outer_rounds"] - seq = len(rounds_of_kind(rounds, kind)) + seq = len(rounds_of_kind(state, kind)) info( f"=== {label} {seq} / {limit} " f"(実装 {existing['impl']} / レビュー {' + '.join(existing['reviewers'])})===" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index c5797f76..21a71be1 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -105,26 +105,6 @@ def _merge_one(existing: dict[str, Any], incoming: dict[str, Any]) -> None: ) -def _accumulate_proposals( - proposals: dict[str, list[dict[str, Any]]], - normalize_fn: Callable[[dict[str, Any], str], Optional[dict[str, Any]]], - merge_fn: Callable[[dict[str, Any], dict[str, Any]], None], -) -> dict[tuple[str, ...], dict[str, Any]]: - """各ランタイムの提案を正規化・重複排除しながら集約する。""" - merged: dict[tuple[str, ...], dict[str, Any]] = {} - for source, items in proposals.items(): - for raw in items: - norm = normalize_fn(raw, source) - if norm is None: - continue - key = _dedupe_key(norm) - if key in merged: - merge_fn(merged[key], norm) - else: - merged[key] = norm - return merged - - def merge_proposals( proposals: dict[str, list[dict[str, Any]]], threshold: str = DEFAULT_SEVERITY_THRESHOLD, @@ -140,7 +120,17 @@ def merge_proposals( `excluded_keys` には過去に見送った項目の鍵を渡す。見送った項目を毎ラウンド 再提案されると収束しないため、対象外として落とす。 """ - merged = _accumulate_proposals(proposals, _normalize_proposal, _merge_one) + merged: dict[tuple[str, ...], dict[str, Any]] = {} + for source, items in proposals.items(): + for raw in items: + norm = _normalize_proposal(raw, source) + if norm is None: + continue + key = _dedupe_key(norm) + if key in merged: + _merge_one(merged[key], norm) + else: + merged[key] = norm min_severity = SEVERITY_ORDER.get( threshold, SEVERITY_ORDER[DEFAULT_SEVERITY_THRESHOLD]) @@ -273,9 +263,17 @@ def merge_test_proposals( 採否の詰めは `merge_proposals` と同じ `_select` が行う。 """ - merged = _accumulate_proposals( - proposals, _normalize_test_proposal, _merge_test_one - ) + merged: dict[tuple[str, ...], dict[str, Any]] = {} + for source, items in proposals.items(): + for raw in items: + norm = _normalize_test_proposal(raw, source) + if norm is None: + continue + key = _dedupe_key(norm) + if key in merged: + _merge_test_one(merged[key], norm) + else: + merged[key] = norm def reject(item: dict[str, Any]) -> Optional[str]: if item["case"] == "unknown" or item["level"] == "unknown": diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py index 210e7036..fa17d6ef 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py @@ -36,11 +36,6 @@ def entry_kind(entry: dict[str, Any]) -> str: return TEST if entry.get("kind") == TEST else STRUCTURE -def rounds_of_kind(rounds: list[dict[str, Any]], kind: str) -> list[dict[str, Any]]: - """その種類のラウンドだけを取り出す。上限はそれぞれ別に数える。""" - return [r for r in rounds if entry_kind(r) == kind] - - def item_kind(item: dict[str, Any]) -> str: """項目 1 件の種類。改善項目とテスト項目は同じ一覧に並ぶ。""" return TEST if item.get("kind") == TEST else STRUCTURE From 422ff59c2be9f8f9131a364e6c6d29e3409849a5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:46:22 +0000 Subject: [PATCH 148/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-refactoring/scripts/refact?= =?UTF-8?q?or=5Flib/proposals.py#=5Fnormalize=5Fproposal?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit smell / technique / severity で 3 回繰り返していた「語彙外なら警告して unknown へ降格し degraded を立てる」同一ブロックを `_degrade_if_unknown` へ抽出し、 降格ルールを 1 箇所へ集約した。振る舞いは不変。 Item-Id: R3-005 Round: 3 Impl-Runtime: kiro Impl-Model: default --- .../scripts/refactor_lib/proposals.py | 40 +++++++++++++------ 1 file changed, 27 insertions(+), 13 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index 21a71be1..358757a4 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -21,6 +21,26 @@ ) +def _degrade_if_unknown( + value: str, + allowed: Iterable[str], + source: str, + label: str, + path: str, + symbol: str, +) -> tuple[str, bool]: + """語彙集合に含まれない値を `unknown` へ降格する。 + + 降格したときは警告を出し `(unknown, True)` を返す。含まれていれば値をそのまま + `(value, False)` で返す。`smell` / `technique` / `severity` の同じ降格ルールを + 1 箇所に集め、警告文や降格処理の変更が 3 箇所へ散らばらないようにする。 + """ + if value not in allowed: + info(f"⚠ {source}: 語彙外の{label} `{value}` — unknown へ降格 ({path}#{symbol})") + return "unknown", True + return value, False + + def _normalize_proposal(raw: dict[str, Any], source: str) -> Optional[dict[str, Any]]: """1 件の提案を正規化する。必須項目を欠くものは捨てる。 @@ -37,19 +57,13 @@ def _normalize_proposal(raw: dict[str, Any], source: str) -> Optional[dict[str, smell = str(raw.get("smell") or "").strip() technique = str(raw.get("technique") or "").strip() severity = str(raw.get("severity") or "").strip().lower() - degraded = False - if smell not in SMELLS: - info(f"⚠ {source}: 語彙外の兆候 `{smell}` — unknown へ降格 ({path}#{symbol})") - smell = "unknown" - degraded = True - if technique not in TECHNIQUES: - info(f"⚠ {source}: 語彙外の手法 `{technique}` — unknown へ降格 ({path}#{symbol})") - technique = "unknown" - degraded = True - if severity not in SEVERITY_ORDER: - info(f"⚠ {source}: 語彙外の重要度 `{severity}` — unknown へ降格 ({path}#{symbol})") - severity = "unknown" - degraded = True + smell, smell_degraded = _degrade_if_unknown( + smell, SMELLS, source, "兆候", path, symbol) + technique, technique_degraded = _degrade_if_unknown( + technique, TECHNIQUES, source, "手法", path, symbol) + severity, severity_degraded = _degrade_if_unknown( + severity, SEVERITY_ORDER, source, "重要度", path, symbol) + degraded = smell_degraded or technique_degraded or severity_degraded if degraded: severity = "unknown" From 068c92e2cc9bf8ebd9f036589870f91f1a8daaba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:46:45 +0000 Subject: [PATCH 149/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/run=5Fmetrics.py#=5Fselect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 時刻の上下限判定を名前付きメソッドへ抽出し、_select の絞り込み手順を明確にする。 Item-Id: R4-005 Round: 4 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/lib/run_metrics.py | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/lib/run_metrics.py b/plugins/ndf/scripts/lib/run_metrics.py index e64aac2d..e746a8da 100755 --- a/plugins/ndf/scripts/lib/run_metrics.py +++ b/plugins/ndf/scripts/lib/run_metrics.py @@ -314,15 +314,24 @@ def _bound(value: Optional[str], *, upper: bool) -> Optional[_dt.datetime]: return parsed +def _within_time_bound(started: Optional[_dt.datetime], + since: Optional[_dt.datetime], + until: Optional[_dt.datetime], + until_exclusive: bool) -> bool: + if since and (started is None or started < since): + return False + if until and (started is None or (started >= until if until_exclusive else started > until)): + return False + return True + + def _select(rows: list[dict], args: argparse.Namespace) -> list[dict]: since, until = _bound(args.since, upper=False), _bound(args.until, upper=True) until_exclusive = bool(args.until and re.fullmatch(r"\d{4}-\d{2}-\d{2}", args.until)) out = [] for row in rows: started = _parse_time(row.get("started_at")) - if since and (started is None or started < since): - continue - if until and (started is None or (started >= until if until_exclusive else started > until)): + if not _within_time_bound(started, since, until, until_exclusive): continue if args.repo and row.get("repo") != args.repo: continue From a05b5528a23457c62ee0f4b4d09eba74d498f260 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:47:32 +0000 Subject: [PATCH 150/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index e1be8410..7cae3c0f 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -209,7 +209,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | kiro | 検証中 | 1 | +| long_method | extract_method | major | kiro | 採用 | 1 | **なぜ**: 1 つの関数が 2 つの独立した集計を通しで行う。前半は review ごとの指摘件数と解決件数の集計、後半は entry.get('reviewers') を回して判定一致(verdict_pairs / verdict_agreements)を数える二重ループである。指摘の集計と判定一致の集計は変更理由が別で、後半のネストしたループが読む負荷を上げている。 @@ -222,7 +222,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | major | codex | 検証中 | 1 | +| duplication | consolidate_duplication | major | codex | 採用 | 1 | **なぜ**: 両モードが旧 PR へのコメント、close、ERR trap の設定、新 PR 作成、trap 解除、URL からの番号抽出、NEW_PR・NEW_PR_URL・NEW_BRANCH の出力を同じ順序で持つ。同じ障害対策のコメントが両方へ反映されており、変更理由も共通している。 @@ -236,7 +236,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_method | split_into_pipeline | major | codex | 検証中 | 1 | +| long_method | split_into_pipeline | major | codex | 採用 | 1 | **なぜ**: 書き込み先抽出の入口に、ヒアドキュメント除去、字句化、作業ディレクトリと複合構文の状態追跡、sed・tee・cp・mv・リダイレクトの対象抽出が連続して同居している。多数の局所状態と入れ子の補助関数を一度に追う必要があり、各段を独立して固定できない。 @@ -250,7 +250,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| conditional_chain | extract_method | minor | kiro | 検証中 | 1 | +| conditional_chain | extract_method | minor | kiro | 採用 | 1 | **なぜ**: バケット鍵の決定が入れ子の三項式 key = "3 以上" if count >= 3 else str(count) if count in (1, 2) else None に埋まっている。ラウンド数から表示区分を導く判断がループ本体の 1 行に押し込まれ、境界(1 / 2 / 3 以上 / 対象外)が読み取りづらい。 @@ -263,7 +263,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| conditional_chain | extract_method | minor | kiro | 未着手 | 0 | +| conditional_chain | extract_method | minor | kiro | 検証中 | 1 | **なぜ**: 行ごとの絞り込みが 5 本の連続した if ... continue と、until 判定に埋め込まれた入れ子の三項(started >= until if until_exclusive else started > until)で構成される。時刻の下限・上限・repo・kind・version という別々の観点が 1 つのループ本体に同居し、until_exclusive の分岐が特に読みづらい。 From 89bb86f7efc9d855b4cce99367b24044724a83de Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 00:08:01 +0000 Subject: [PATCH 151/217] =?UTF-8?q?Refactor:=20replace=5Fwith=5Fbulk=5Fope?= =?UTF-8?q?ration=20/=20extract=5Fmethod=20=E2=80=94=20auth.py,=20state.py?= =?UTF-8?q?,=20rotate-pr.sh?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R5-001: auth.py の _probe_all を ThreadPoolExecutor による並行処理に置き換え - R5-002: state.py の _apply_resume_args_block から引数解除・引数組み立て・参加者更新処理を抽出 - R5-003: rotate-pr.sh の execute_squash からメタ情報解決・ブランチ復元・コミットpush・本文生成処理を抽出、現状固定テストを追加 Item-Id: R5-001 Item-Id: R5-002 Item-Id: R5-003 Round: 5 Impl-Runtime: agy Impl-Model: default --- plugins/ndf/scripts/lib/auth.py | 29 ++-- .../skills/cross-review/scripts/rotate-pr.sh | 122 +++++++++++------ .../ndf/skills/cross-review/scripts/state.py | 71 +++++++--- .../tests/test_rotate_pr_squash.py | 126 ++++++++++++++++++ 4 files changed, 278 insertions(+), 70 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py diff --git a/plugins/ndf/scripts/lib/auth.py b/plugins/ndf/scripts/lib/auth.py index 810f3db8..ae38814d 100644 --- a/plugins/ndf/scripts/lib/auth.py +++ b/plugins/ndf/scripts/lib/auth.py @@ -9,6 +9,7 @@ """ from __future__ import annotations +import concurrent.futures import os import subprocess from typing import Any, Callable, Iterable, Optional @@ -63,15 +64,25 @@ def _run_probe(probe: tuple[str, ...]) -> tuple[bool, str]: def _probe_all(runtimes: Iterable[str], info: Callable[[str], None]) -> ProbeResult: - """`AUTH_PROBES` にある名前だけを順に確かめ、名前 → 結果を返す。1 者 1 行を出力する。""" - results: ProbeResult = {} - for runtime in runtimes: - probe = AUTH_PROBES.get(runtime) - if probe is None: - continue - ok, detail = _run_probe(probe) - results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} - info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") + """`AUTH_PROBES` にある名前を有界な並行ワーカーで確かめ、名前 → 結果を返す。1 者 1 行を出力する。""" + unique_targets: list[tuple[str, tuple[str, ...]]] = [] + seen = set() + for rt in runtimes: + if rt in AUTH_PROBES and rt not in seen: + seen.add(rt) + unique_targets.append((rt, AUTH_PROBES[rt])) + + if not unique_targets: + return {} + + max_workers = min(len(unique_targets), 4) + with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + futures = {rt: executor.submit(_run_probe, probe) for rt, probe in unique_targets} + results: ProbeResult = {} + for rt, probe in unique_targets: + ok, detail = futures[rt].result() + results[rt] = {"command": " ".join(probe), "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {rt}: {' '.join(probe)}") return results diff --git a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh index e1e0d1f3..fc4ad1b6 100755 --- a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh +++ b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh @@ -235,64 +235,68 @@ execute_light() { "${create_args[@]}" } -# squash モード本体。 -execute_squash() { - local state_pr=$1 - load_state "$state_pr" - - cd "$WORKTREE" - - local branch base title new_branch pr_meta prep - prep=$TMP_DIR/rotate-pr$state_pr-prepare.json +# PR メタ情報 (base / title) は、まず prepare.json があればそこから読み出し、 +# 無い場合のみ gh pr view にフォールバックする (execute_light と同じ方針で +# 不要な API 呼び出しを排除, gemini round 8 指摘)。 +resolve_squash_pr_metadata() { + local prep=$1 old_pr=$2 + local base="" title="" pr_meta="" - # PR メタ情報 (base / title / head) は、まず prepare.json があればそこから読み出し、 - # 無い場合のみ gh pr view にフォールバックする (execute_light と同じ方針で - # 不要な API 呼び出しを排除, gemini round 8 指摘)。 if [ -s "$prep" ]; then base=$(jq -r '.base_branch // empty' "$prep") title=$(jq -r '.old_title // empty' "$prep") fi if [ -z "${base:-}" ] || [ -z "${title:-}" ]; then - pr_meta=$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title) + pr_meta=$(gh pr view "$old_pr" --json headRefName,baseRefName,title) [ -n "${base:-}" ] || base=$(printf '%s' "$pr_meta" | jq -r '.baseRefName') [ -n "${title:-}" ] || title=$(printf '%s' "$pr_meta" | jq -r '.title') fi + jq -nc --arg base "$base" --arg title "$title" --arg pr_meta "$pr_meta" \ + '{base: $base, title: $title, pr_meta: $pr_meta}' +} - # state.py init は worktree を `git worktree add --detach origin/` で作るため、 - # `git branch --show-current` は空文字を返す。空のまま new_branch を生成すると - # `-rHHMMSS` だけのブランチ名になってしまうので、フォールバック順を以下に固定する: - # 1. git branch --show-current (通常 worktree なら使える) - # 2. prepare.json の head_branch (prepare 済みなら最も信頼できる) - # 3. gh pr view --json headRefName (prepare 未実行でも復元可能) - # (codex round 4 指摘) +# state.py init は worktree を `git worktree add --detach origin/` で作るため、 +# `git branch --show-current` は空文字を返す。空のまま new_branch を生成すると +# `-rHHMMSS` だけのブランチ名になってしまうので、フォールバック順を以下に固定する: +# 1. git branch --show-current (通常 worktree なら使える) +# 2. prepare.json の head_branch (prepare 済みなら最も信頼できる) +# 3. gh pr view --json headRefName (prepare 未実行でも復元可能) +# (codex round 4 指摘) +resolve_squash_head_branch() { + local prep=$1 old_pr=$2 pr_meta=${3:-} + local branch branch=$(git branch --show-current) if [ -z "$branch" ] && [ -s "$prep" ]; then branch=$(jq -r '.head_branch // empty' "$prep") fi if [ -z "$branch" ]; then - pr_meta=${pr_meta:-$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title)} + [ -n "$pr_meta" ] || pr_meta=$(gh pr view "$old_pr" --json headRefName,baseRefName,title) branch=$(printf '%s' "$pr_meta" | jq -r '.headRefName') fi [ -n "$branch" ] || { echo "head branch を復元できませんでした (detached worktree かつ prepare.json / gh pr view から取得失敗)" >&2; exit 1; } - new_branch="${branch}-r$(date +%H%M%S)" - - # 既に title 末尾に "(rotated)" / "(rotated2)" 等が付いている場合は除去してから - # "(rotated)" を 1 つだけ付与し、ローテーションのたびに suffix が重複しないようにする - # (gemini round 8 指摘)。 - # 例: - # "Fix foo" → "Fix foo (rotated)" - # "Fix foo (rotated)" → "Fix foo (rotated)" - # "Fix foo (rotated2)" → "Fix foo (rotated)" - # "Fix foo (rotated)(rotated)" → "Fix foo (rotated)" + printf '%s' "$branch" +} + +# 既に title 末尾に "(rotated)" / "(rotated2)" 等が付いている場合は除去してから +# "(rotated)" を 1 つだけ付与し、ローテーションのたびに suffix が重複しないようにする +# (gemini round 8 指摘)。 +# 例: +# "Fix foo" → "Fix foo (rotated)" +# "Fix foo (rotated)" → "Fix foo (rotated)" +# "Fix foo (rotated2)" → "Fix foo (rotated)" +# "Fix foo (rotated)(rotated)" → "Fix foo (rotated)" +normalize_rotated_title() { + local title=$1 local title_stripped=$title while [[ $title_stripped =~ [[:space:]]*\(rotated[0-9]*\)$ ]]; do title_stripped=${title_stripped%"${BASH_REMATCH[0]}"} done - local new_title="$title_stripped (rotated)" - - echo "🔄 PR #$OLD_PR rotation (squash): $branch → $new_branch (base=$base)" >&2 + printf '%s (rotated)' "$title_stripped" +} - # 1. 既存ブランチを squash して新ブランチに +# 既存ブランチを squash して新ブランチに commit & push +create_and_push_squash_branch() { + local new_branch=$1 base=$2 title=$3 old_pr=$4 git checkout -b "$new_branch" git reset --soft "origin/$base" # commit message は -m を複数指定で分割して渡す。$(cat < EOF -) +} + +# squash モード本体。 +execute_squash() { + local state_pr=$1 + load_state "$state_pr" + + cd "$WORKTREE" + + local prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + + local meta_json + meta_json=$(resolve_squash_pr_metadata "$prep" "$OLD_PR") + local base title pr_meta + base=$(jq -r '.base' <<<"$meta_json") + title=$(jq -r '.title' <<<"$meta_json") + pr_meta=$(jq -r '.pr_meta' <<<"$meta_json") + + local branch + branch=$(resolve_squash_head_branch "$prep" "$OLD_PR" "$pr_meta") + local new_branch="${branch}-r$(date +%H%M%S)" + + local new_title + new_title=$(normalize_rotated_title "$title") + + echo "🔄 PR #$OLD_PR rotation (squash): $branch → $new_branch (base=$base)" >&2 + + # 1. 既存ブランチを squash して新ブランチに + create_and_push_squash_branch "$new_branch" "$base" "$title" "$OLD_PR" + + # 2. 新 PR の body + local new_body + new_body=$(build_squash_body "$OLD_PR" "$ROUND_IN_PR") # 3. close → create → 結果出力は light と共通。 rotate_close_and_create \ diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index e19cbce2..dcb51800 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1587,6 +1587,55 @@ def _print_init_result(result: _InitResult) -> None: PARTICIPANT_ARGS = ("only", "include", "exclude", "require_all") +def _handle_resume_only_none(st: dict[str, Any], args: argparse.Namespace) -> None: + """`--only none` による指定解除を反映し、履歴へ記録する(決定 15)。 + + 正規化した `None` を表へ渡すと「未指定」と区別できず、指定を外す操作が黙って + 捨てられるため、ここで明示的に処理する。 + """ + if getattr(args, "only", None) == NONE_WORD and st.get("only") is not None: + old = st.get("only") + st["only"] = None + st.setdefault("resume_changes", []).append( + {"at": statefile.now(), "field": "only", "from": old, "to": None} + ) + info(f"↻ only: {old} → None") + + +def _build_resume_rebuild_args( + st: dict[str, Any], + args: argparse.Namespace, + include: list[str] | None, + exclude: list[str] | None, +) -> argparse.Namespace: + """状態ファイルの既存設定と再開引数をマージした再解決用 Namespace を組み立てる(決定 14)。""" + recorded = st.get("participants") or {} + return argparse.Namespace( + only=st.get("only"), + include=include if include is not None else list(recorded.get("included") or []), + exclude=exclude if exclude is not None else list(recorded.get("excluded") or []), + require_all=( + args.require_all + if getattr(args, "require_all", None) is not None + else bool(recorded.get("require_all")) + ), + ) + + +def _update_resume_participants(st: dict[str, Any], participants: dict[str, Any]) -> None: + """解決成功後の参加者情報を状態へ書き込み、変更履歴に記録する。""" + old_participants = st.get("participants") + st["participants"] = participants + st.setdefault("resume_changes", []).append( + { + "at": statefile.now(), + "field": "participants", + "from": old_participants, + "to": participants, + } + ) + + def _apply_resume_args_block(st: dict[str, Any], args: argparse.Namespace) -> bool: """再開で渡した引数を状態へ反映し、何か変えたら True を返す(#727 / #648)。 @@ -1604,36 +1653,20 @@ def _apply_resume_args_block(st: dict[str, Any], args: argparse.Namespace) -> bo args_copy.only = only if getattr(args, "only", None) == NONE_WORD: args_copy.only = None - if st.get("only") is not None: - old = st.get("only") - st["only"] = None - st.setdefault("resume_changes", []).append( - {"at": statefile.now(), "field": "only", "from": old, "to": None}) - info(f"↻ only: {old} → None") + _handle_resume_only_none(st, args) for line in statefile.apply_resume_args(st, args_copy, REVIEW_RESUME_FIELDS): info(line) if any(getattr(args, name, None) is not None for name in PARTICIPANT_ARGS): - old_participants = st.get("participants") - recorded = old_participants or {} try: host = st.get("host") or assignment.detect_host(getattr(args, "host", None))[0] except assignment.AssignmentError as e: die(str(e), code=1) raise - rebuild = argparse.Namespace( - only=st.get("only"), - include=include if include is not None else list(recorded.get("included") or []), - exclude=exclude if exclude is not None else list(recorded.get("excluded") or []), - require_all=(args.require_all if getattr(args, "require_all", None) is not None - else bool(recorded.get("require_all"))), - ) + rebuild = _build_resume_rebuild_args(st, args, include, exclude) participants = _resolve_reviewers(host, rebuild) - st["participants"] = participants - st.setdefault("resume_changes", []).append( - {"at": statefile.now(), "field": "participants", - "from": old_participants, "to": participants}) + _update_resume_participants(st, participants) return len(st.get("resume_changes") or []) > before diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py new file mode 100644 index 00000000..f2d522a5 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py @@ -0,0 +1,126 @@ +"""rotate-pr.sh の squash モードに関する現状固定テスト(#727 / R5-003)。 + +squash モードの振る舞い((rotated) 接尾辞の正規化、prepare.json / gh pr view からの +メタ情報フォールバック解決、ブランチ復元、PR body 生成)を固定する。 +""" +from __future__ import annotations + +import json +import os +import pathlib +import subprocess +import pytest + +HERE = pathlib.Path(__file__).resolve().parent +ROTATE_SH = HERE.parent / "scripts" / "rotate-pr.sh" + + +def _run_bash(script: str, env: dict | None = None, cwd: str | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + ["bash", "-c", script], + capture_output=True, + text=True, + env=env or os.environ.copy(), + cwd=cwd, + ) + + +def test_squash_helpers_are_defined() -> None: + """R5-003 で抽出されたヘルパー関数が rotate-pr.sh 内に定義されていることを確認する。""" + body = ROTATE_SH.read_text(encoding="utf-8") + assert "resolve_squash_pr_metadata()" in body + assert "resolve_squash_head_branch()" in body + assert "normalize_rotated_title()" in body + assert "create_and_push_squash_branch()" in body + assert "build_squash_body()" in body + + +# ---------------- 1. (rotated) 接尾辞の正規化 ---------------- + +@pytest.mark.parametrize( + ("original_title", "expected_title"), + [ + ("Fix foo", "Fix foo (rotated)"), + ("Fix foo (rotated)", "Fix foo (rotated)"), + ("Fix foo (rotated2)", "Fix foo (rotated)"), + ("Fix foo (rotated)(rotated)", "Fix foo (rotated)"), + ("Fix foo (rotated123)", "Fix foo (rotated)"), + ("Fix foo (rotated)", "Fix foo (rotated)"), + ("feat: add (special) feature", "feat: add (special) feature (rotated)"), + ], +) +def test_title_rotated_suffix_normalization(original_title: str, expected_title: str) -> None: + """rotate-pr.sh の接尾辞正規化ロジックの現状固定テスト。""" + snippet = f""" + eval "$(sed -n '/^normalize_rotated_title() {{/,/^}}/p' {ROTATE_SH})" + normalize_rotated_title {json.dumps(original_title)} + """ + res = _run_bash(snippet) + assert res.returncode == 0 + assert res.stdout == expected_title + + +# ---------------- 2. PR メタ情報(base / title)の解決 ---------------- + +def test_pr_metadata_reads_prepare_json(tmp_path: pathlib.Path) -> None: + """prepare.json が存在する場合、base_branch と old_title を優先して読み込む。""" + prep_file = tmp_path / "prepare.json" + prep_file.write_text( + json.dumps({ + "base_branch": "develop", + "old_title": "Fix something important", + "head_branch": "feat/my-branch", + }), + encoding="utf-8", + ) + snippet = f""" + eval "$(sed -n '/^resolve_squash_pr_metadata() {{/,/^}}/p' {ROTATE_SH})" + resolve_squash_pr_metadata {json.dumps(str(prep_file))} "123" + """ + res = _run_bash(snippet) + assert res.returncode == 0 + meta = json.loads(res.stdout) + assert meta["base"] == "develop" + assert meta["title"] == "Fix something important" + + +# ---------------- 3. ブランチ名解決のフォールバック順 ---------------- + +def test_branch_resolution_from_prepare_json_when_detached(tmp_path: pathlib.Path) -> None: + """detached HEAD で git branch --show-current が空の場合、prepare.json の head_branch を使う。""" + prep_file = tmp_path / "prepare.json" + prep_file.write_text( + json.dumps({ + "head_branch": "feature/from-prepare", + }), + encoding="utf-8", + ) + # git branch --show-current を空文字で模す一時ラッパー + snippet = f""" + git() {{ + if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then + return 0 + fi + command git "$@" + }} + eval "$(sed -n '/^resolve_squash_head_branch() {{/,/^}}/p' {ROTATE_SH})" + resolve_squash_head_branch {json.dumps(str(prep_file))} "123" + """ + res = _run_bash(snippet) + assert res.returncode == 0 + assert res.stdout == "feature/from-prepare" + + +# ---------------- 4. 新 PR 本文の生成 ---------------- + +def test_squash_body_content() -> None: + """squash モードで生成される新 PR 本文の構造を固定する。""" + snippet = f""" + eval "$(sed -n '/^build_squash_body() {{/,/^}}/p' {ROTATE_SH})" + build_squash_body "123" "8" + """ + res = _run_bash(snippet) + assert res.returncode == 0 + assert "旧 PR #123 をベースに、cross-review クロスレビューループの継続。" in res.stdout + assert "round_in_pr=8" in res.stdout + assert "" in res.stdout From 2f3aa53be681fecd078ad4eefce2caf7561fd0b0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 00:08:55 +0000 Subject: [PATCH 152/217] =?UTF-8?q?Revert=20"Refactor:=20replace=5Fwith=5F?= =?UTF-8?q?bulk=5Foperation=20/=20extract=5Fmethod=20=E2=80=94=20auth.py,?= =?UTF-8?q?=20state.py,=20rotate-pr.sh"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 89bb86f7efc9d855b4cce99367b24044724a83de. --- plugins/ndf/scripts/lib/auth.py | 29 ++-- .../skills/cross-review/scripts/rotate-pr.sh | 122 ++++++----------- .../ndf/skills/cross-review/scripts/state.py | 71 +++------- .../tests/test_rotate_pr_squash.py | 126 ------------------ 4 files changed, 70 insertions(+), 278 deletions(-) delete mode 100644 plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py diff --git a/plugins/ndf/scripts/lib/auth.py b/plugins/ndf/scripts/lib/auth.py index ae38814d..810f3db8 100644 --- a/plugins/ndf/scripts/lib/auth.py +++ b/plugins/ndf/scripts/lib/auth.py @@ -9,7 +9,6 @@ """ from __future__ import annotations -import concurrent.futures import os import subprocess from typing import Any, Callable, Iterable, Optional @@ -64,25 +63,15 @@ def _run_probe(probe: tuple[str, ...]) -> tuple[bool, str]: def _probe_all(runtimes: Iterable[str], info: Callable[[str], None]) -> ProbeResult: - """`AUTH_PROBES` にある名前を有界な並行ワーカーで確かめ、名前 → 結果を返す。1 者 1 行を出力する。""" - unique_targets: list[tuple[str, tuple[str, ...]]] = [] - seen = set() - for rt in runtimes: - if rt in AUTH_PROBES and rt not in seen: - seen.add(rt) - unique_targets.append((rt, AUTH_PROBES[rt])) - - if not unique_targets: - return {} - - max_workers = min(len(unique_targets), 4) - with concurrent.futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - futures = {rt: executor.submit(_run_probe, probe) for rt, probe in unique_targets} - results: ProbeResult = {} - for rt, probe in unique_targets: - ok, detail = futures[rt].result() - results[rt] = {"command": " ".join(probe), "ok": ok, "detail": detail} - info(f"{'✅' if ok else '❌'} {rt}: {' '.join(probe)}") + """`AUTH_PROBES` にある名前だけを順に確かめ、名前 → 結果を返す。1 者 1 行を出力する。""" + results: ProbeResult = {} + for runtime in runtimes: + probe = AUTH_PROBES.get(runtime) + if probe is None: + continue + ok, detail = _run_probe(probe) + results[runtime] = {"command": " ".join(probe), "ok": ok, "detail": detail} + info(f"{'✅' if ok else '❌'} {runtime}: {' '.join(probe)}") return results diff --git a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh index fc4ad1b6..e1e0d1f3 100755 --- a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh +++ b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh @@ -235,68 +235,64 @@ execute_light() { "${create_args[@]}" } -# PR メタ情報 (base / title) は、まず prepare.json があればそこから読み出し、 -# 無い場合のみ gh pr view にフォールバックする (execute_light と同じ方針で -# 不要な API 呼び出しを排除, gemini round 8 指摘)。 -resolve_squash_pr_metadata() { - local prep=$1 old_pr=$2 - local base="" title="" pr_meta="" +# squash モード本体。 +execute_squash() { + local state_pr=$1 + load_state "$state_pr" + + cd "$WORKTREE" + + local branch base title new_branch pr_meta prep + prep=$TMP_DIR/rotate-pr$state_pr-prepare.json + # PR メタ情報 (base / title / head) は、まず prepare.json があればそこから読み出し、 + # 無い場合のみ gh pr view にフォールバックする (execute_light と同じ方針で + # 不要な API 呼び出しを排除, gemini round 8 指摘)。 if [ -s "$prep" ]; then base=$(jq -r '.base_branch // empty' "$prep") title=$(jq -r '.old_title // empty' "$prep") fi if [ -z "${base:-}" ] || [ -z "${title:-}" ]; then - pr_meta=$(gh pr view "$old_pr" --json headRefName,baseRefName,title) + pr_meta=$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title) [ -n "${base:-}" ] || base=$(printf '%s' "$pr_meta" | jq -r '.baseRefName') [ -n "${title:-}" ] || title=$(printf '%s' "$pr_meta" | jq -r '.title') fi - jq -nc --arg base "$base" --arg title "$title" --arg pr_meta "$pr_meta" \ - '{base: $base, title: $title, pr_meta: $pr_meta}' -} -# state.py init は worktree を `git worktree add --detach origin/` で作るため、 -# `git branch --show-current` は空文字を返す。空のまま new_branch を生成すると -# `-rHHMMSS` だけのブランチ名になってしまうので、フォールバック順を以下に固定する: -# 1. git branch --show-current (通常 worktree なら使える) -# 2. prepare.json の head_branch (prepare 済みなら最も信頼できる) -# 3. gh pr view --json headRefName (prepare 未実行でも復元可能) -# (codex round 4 指摘) -resolve_squash_head_branch() { - local prep=$1 old_pr=$2 pr_meta=${3:-} - local branch + # state.py init は worktree を `git worktree add --detach origin/` で作るため、 + # `git branch --show-current` は空文字を返す。空のまま new_branch を生成すると + # `-rHHMMSS` だけのブランチ名になってしまうので、フォールバック順を以下に固定する: + # 1. git branch --show-current (通常 worktree なら使える) + # 2. prepare.json の head_branch (prepare 済みなら最も信頼できる) + # 3. gh pr view --json headRefName (prepare 未実行でも復元可能) + # (codex round 4 指摘) branch=$(git branch --show-current) if [ -z "$branch" ] && [ -s "$prep" ]; then branch=$(jq -r '.head_branch // empty' "$prep") fi if [ -z "$branch" ]; then - [ -n "$pr_meta" ] || pr_meta=$(gh pr view "$old_pr" --json headRefName,baseRefName,title) + pr_meta=${pr_meta:-$(gh pr view "$OLD_PR" --json headRefName,baseRefName,title)} branch=$(printf '%s' "$pr_meta" | jq -r '.headRefName') fi [ -n "$branch" ] || { echo "head branch を復元できませんでした (detached worktree かつ prepare.json / gh pr view から取得失敗)" >&2; exit 1; } - printf '%s' "$branch" -} - -# 既に title 末尾に "(rotated)" / "(rotated2)" 等が付いている場合は除去してから -# "(rotated)" を 1 つだけ付与し、ローテーションのたびに suffix が重複しないようにする -# (gemini round 8 指摘)。 -# 例: -# "Fix foo" → "Fix foo (rotated)" -# "Fix foo (rotated)" → "Fix foo (rotated)" -# "Fix foo (rotated2)" → "Fix foo (rotated)" -# "Fix foo (rotated)(rotated)" → "Fix foo (rotated)" -normalize_rotated_title() { - local title=$1 + new_branch="${branch}-r$(date +%H%M%S)" + + # 既に title 末尾に "(rotated)" / "(rotated2)" 等が付いている場合は除去してから + # "(rotated)" を 1 つだけ付与し、ローテーションのたびに suffix が重複しないようにする + # (gemini round 8 指摘)。 + # 例: + # "Fix foo" → "Fix foo (rotated)" + # "Fix foo (rotated)" → "Fix foo (rotated)" + # "Fix foo (rotated2)" → "Fix foo (rotated)" + # "Fix foo (rotated)(rotated)" → "Fix foo (rotated)" local title_stripped=$title while [[ $title_stripped =~ [[:space:]]*\(rotated[0-9]*\)$ ]]; do title_stripped=${title_stripped%"${BASH_REMATCH[0]}"} done - printf '%s (rotated)' "$title_stripped" -} + local new_title="$title_stripped (rotated)" + + echo "🔄 PR #$OLD_PR rotation (squash): $branch → $new_branch (base=$base)" >&2 -# 既存ブランチを squash して新ブランチに commit & push -create_and_push_squash_branch() { - local new_branch=$1 base=$2 title=$3 old_pr=$4 + # 1. 既存ブランチを squash して新ブランチに git checkout -b "$new_branch" git reset --soft "origin/$base" # commit message は -m を複数指定で分割して渡す。$(cat < EOF -} - -# squash モード本体。 -execute_squash() { - local state_pr=$1 - load_state "$state_pr" - - cd "$WORKTREE" - - local prep=$TMP_DIR/rotate-pr$state_pr-prepare.json - - local meta_json - meta_json=$(resolve_squash_pr_metadata "$prep" "$OLD_PR") - local base title pr_meta - base=$(jq -r '.base' <<<"$meta_json") - title=$(jq -r '.title' <<<"$meta_json") - pr_meta=$(jq -r '.pr_meta' <<<"$meta_json") - - local branch - branch=$(resolve_squash_head_branch "$prep" "$OLD_PR" "$pr_meta") - local new_branch="${branch}-r$(date +%H%M%S)" - - local new_title - new_title=$(normalize_rotated_title "$title") - - echo "🔄 PR #$OLD_PR rotation (squash): $branch → $new_branch (base=$base)" >&2 - - # 1. 既存ブランチを squash して新ブランチに - create_and_push_squash_branch "$new_branch" "$base" "$title" "$OLD_PR" - - # 2. 新 PR の body - local new_body - new_body=$(build_squash_body "$OLD_PR" "$ROUND_IN_PR") +) # 3. close → create → 結果出力は light と共通。 rotate_close_and_create \ diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index dcb51800..e19cbce2 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -1587,55 +1587,6 @@ def _print_init_result(result: _InitResult) -> None: PARTICIPANT_ARGS = ("only", "include", "exclude", "require_all") -def _handle_resume_only_none(st: dict[str, Any], args: argparse.Namespace) -> None: - """`--only none` による指定解除を反映し、履歴へ記録する(決定 15)。 - - 正規化した `None` を表へ渡すと「未指定」と区別できず、指定を外す操作が黙って - 捨てられるため、ここで明示的に処理する。 - """ - if getattr(args, "only", None) == NONE_WORD and st.get("only") is not None: - old = st.get("only") - st["only"] = None - st.setdefault("resume_changes", []).append( - {"at": statefile.now(), "field": "only", "from": old, "to": None} - ) - info(f"↻ only: {old} → None") - - -def _build_resume_rebuild_args( - st: dict[str, Any], - args: argparse.Namespace, - include: list[str] | None, - exclude: list[str] | None, -) -> argparse.Namespace: - """状態ファイルの既存設定と再開引数をマージした再解決用 Namespace を組み立てる(決定 14)。""" - recorded = st.get("participants") or {} - return argparse.Namespace( - only=st.get("only"), - include=include if include is not None else list(recorded.get("included") or []), - exclude=exclude if exclude is not None else list(recorded.get("excluded") or []), - require_all=( - args.require_all - if getattr(args, "require_all", None) is not None - else bool(recorded.get("require_all")) - ), - ) - - -def _update_resume_participants(st: dict[str, Any], participants: dict[str, Any]) -> None: - """解決成功後の参加者情報を状態へ書き込み、変更履歴に記録する。""" - old_participants = st.get("participants") - st["participants"] = participants - st.setdefault("resume_changes", []).append( - { - "at": statefile.now(), - "field": "participants", - "from": old_participants, - "to": participants, - } - ) - - def _apply_resume_args_block(st: dict[str, Any], args: argparse.Namespace) -> bool: """再開で渡した引数を状態へ反映し、何か変えたら True を返す(#727 / #648)。 @@ -1653,20 +1604,36 @@ def _apply_resume_args_block(st: dict[str, Any], args: argparse.Namespace) -> bo args_copy.only = only if getattr(args, "only", None) == NONE_WORD: args_copy.only = None - _handle_resume_only_none(st, args) + if st.get("only") is not None: + old = st.get("only") + st["only"] = None + st.setdefault("resume_changes", []).append( + {"at": statefile.now(), "field": "only", "from": old, "to": None}) + info(f"↻ only: {old} → None") for line in statefile.apply_resume_args(st, args_copy, REVIEW_RESUME_FIELDS): info(line) if any(getattr(args, name, None) is not None for name in PARTICIPANT_ARGS): + old_participants = st.get("participants") + recorded = old_participants or {} try: host = st.get("host") or assignment.detect_host(getattr(args, "host", None))[0] except assignment.AssignmentError as e: die(str(e), code=1) raise - rebuild = _build_resume_rebuild_args(st, args, include, exclude) + rebuild = argparse.Namespace( + only=st.get("only"), + include=include if include is not None else list(recorded.get("included") or []), + exclude=exclude if exclude is not None else list(recorded.get("excluded") or []), + require_all=(args.require_all if getattr(args, "require_all", None) is not None + else bool(recorded.get("require_all"))), + ) participants = _resolve_reviewers(host, rebuild) - _update_resume_participants(st, participants) + st["participants"] = participants + st.setdefault("resume_changes", []).append( + {"at": statefile.now(), "field": "participants", + "from": old_participants, "to": participants}) return len(st.get("resume_changes") or []) > before diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py deleted file mode 100644 index f2d522a5..00000000 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_squash.py +++ /dev/null @@ -1,126 +0,0 @@ -"""rotate-pr.sh の squash モードに関する現状固定テスト(#727 / R5-003)。 - -squash モードの振る舞い((rotated) 接尾辞の正規化、prepare.json / gh pr view からの -メタ情報フォールバック解決、ブランチ復元、PR body 生成)を固定する。 -""" -from __future__ import annotations - -import json -import os -import pathlib -import subprocess -import pytest - -HERE = pathlib.Path(__file__).resolve().parent -ROTATE_SH = HERE.parent / "scripts" / "rotate-pr.sh" - - -def _run_bash(script: str, env: dict | None = None, cwd: str | None = None) -> subprocess.CompletedProcess[str]: - return subprocess.run( - ["bash", "-c", script], - capture_output=True, - text=True, - env=env or os.environ.copy(), - cwd=cwd, - ) - - -def test_squash_helpers_are_defined() -> None: - """R5-003 で抽出されたヘルパー関数が rotate-pr.sh 内に定義されていることを確認する。""" - body = ROTATE_SH.read_text(encoding="utf-8") - assert "resolve_squash_pr_metadata()" in body - assert "resolve_squash_head_branch()" in body - assert "normalize_rotated_title()" in body - assert "create_and_push_squash_branch()" in body - assert "build_squash_body()" in body - - -# ---------------- 1. (rotated) 接尾辞の正規化 ---------------- - -@pytest.mark.parametrize( - ("original_title", "expected_title"), - [ - ("Fix foo", "Fix foo (rotated)"), - ("Fix foo (rotated)", "Fix foo (rotated)"), - ("Fix foo (rotated2)", "Fix foo (rotated)"), - ("Fix foo (rotated)(rotated)", "Fix foo (rotated)"), - ("Fix foo (rotated123)", "Fix foo (rotated)"), - ("Fix foo (rotated)", "Fix foo (rotated)"), - ("feat: add (special) feature", "feat: add (special) feature (rotated)"), - ], -) -def test_title_rotated_suffix_normalization(original_title: str, expected_title: str) -> None: - """rotate-pr.sh の接尾辞正規化ロジックの現状固定テスト。""" - snippet = f""" - eval "$(sed -n '/^normalize_rotated_title() {{/,/^}}/p' {ROTATE_SH})" - normalize_rotated_title {json.dumps(original_title)} - """ - res = _run_bash(snippet) - assert res.returncode == 0 - assert res.stdout == expected_title - - -# ---------------- 2. PR メタ情報(base / title)の解決 ---------------- - -def test_pr_metadata_reads_prepare_json(tmp_path: pathlib.Path) -> None: - """prepare.json が存在する場合、base_branch と old_title を優先して読み込む。""" - prep_file = tmp_path / "prepare.json" - prep_file.write_text( - json.dumps({ - "base_branch": "develop", - "old_title": "Fix something important", - "head_branch": "feat/my-branch", - }), - encoding="utf-8", - ) - snippet = f""" - eval "$(sed -n '/^resolve_squash_pr_metadata() {{/,/^}}/p' {ROTATE_SH})" - resolve_squash_pr_metadata {json.dumps(str(prep_file))} "123" - """ - res = _run_bash(snippet) - assert res.returncode == 0 - meta = json.loads(res.stdout) - assert meta["base"] == "develop" - assert meta["title"] == "Fix something important" - - -# ---------------- 3. ブランチ名解決のフォールバック順 ---------------- - -def test_branch_resolution_from_prepare_json_when_detached(tmp_path: pathlib.Path) -> None: - """detached HEAD で git branch --show-current が空の場合、prepare.json の head_branch を使う。""" - prep_file = tmp_path / "prepare.json" - prep_file.write_text( - json.dumps({ - "head_branch": "feature/from-prepare", - }), - encoding="utf-8", - ) - # git branch --show-current を空文字で模す一時ラッパー - snippet = f""" - git() {{ - if [ "$1" = "branch" ] && [ "$2" = "--show-current" ]; then - return 0 - fi - command git "$@" - }} - eval "$(sed -n '/^resolve_squash_head_branch() {{/,/^}}/p' {ROTATE_SH})" - resolve_squash_head_branch {json.dumps(str(prep_file))} "123" - """ - res = _run_bash(snippet) - assert res.returncode == 0 - assert res.stdout == "feature/from-prepare" - - -# ---------------- 4. 新 PR 本文の生成 ---------------- - -def test_squash_body_content() -> None: - """squash モードで生成される新 PR 本文の構造を固定する。""" - snippet = f""" - eval "$(sed -n '/^build_squash_body() {{/,/^}}/p' {ROTATE_SH})" - build_squash_body "123" "8" - """ - res = _run_bash(snippet) - assert res.returncode == 0 - assert "旧 PR #123 をベースに、cross-review クロスレビューループの継続。" in res.stdout - assert "round_in_pr=8" in res.stdout - assert "" in res.stdout From 7a5567b97922ff75218094cabe9fa58fa033a859 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 00:08:56 +0000 Subject: [PATCH 153/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 48 +++++++++++++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 7cae3c0f..ef348ea8 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -263,7 +263,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| conditional_chain | extract_method | minor | kiro | 検証中 | 1 | +| conditional_chain | extract_method | minor | kiro | 採用 | 1 | **なぜ**: 行ごとの絞り込みが 5 本の連続した if ... continue と、until 判定に埋め込まれた入れ子の三項(started >= until if until_exclusive else started > until)で構成される。時刻の下限・上限・repo・kind・version という別々の観点が 1 つのループ本体に同居し、until_exclusive の分岐が特に読みづらい。 @@ -272,6 +272,49 @@ 3. 属性一致も見通しが悪ければ _matches_filters(row, args) へまとめる 4. test_run_metrics.py::test_aggregate_filters(since / until / repo / kind / version の 5 例)で退行が無いことを確かめる +## ラウンド 5(実装 codex / レビュー agy / kiro) + +### R5-001 — `plugins/ndf/scripts/lib/auth.py#_probe_all` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| one_by_one_iteration | replace_with_bulk_operation | major | codex / agy | 取り消し | 0 | + +**なぜ**: AUTH_PROBES に定義された各 CLI(最大4者)の認証確認コマンド(タイムアウト各120秒)を for ループ内で直列に実行しており、参加者数に比例して全体の待ち時間が累積する。入力順と出力順を維持したまま有界な並行実行(ThreadPoolExecutor 等)へ置き換えることで待ち時間を短縮できる。 + +**手順**: 1. test_auth_probe.py で複数 CLI の確認順序・出力順序・戻り値構造を検証する既存テストを確認する +2. _probe_all 内で AUTH_PROBES に存在する対象を抽出し、有界な並行ワーカー(concurrent.futures 等)で並行実行する +3. 各ランタイムの結果を入力順に results へ格納し、info 出力も入力順に発出する +4. pytest plugins/ndf/scripts/tests/test_auth_probe.py および全体テストで互換性と表示順を検証する + +### R5-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_apply_resume_args_block` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex / agy | 取り消し | 0 | + +**なぜ**: 1 つの関数内で、引数の正規化、--only none による特殊な状態解除と履歴追記、一般フィールドの反映、参加者再構築用の引数名前空間生成、_resolve_reviewers による再解決、成功時の状態・履歴更新という複数の段階が連続して書かれており、状態更新の原子性と各段階の責務が混在している。 + +**手順**: 1. test_state_resume_args.py で --only none、通常引数反映、参加者再構築失敗時の原子性(ロールバック/非更新)がテストされていることを確認する +2. --only none の状態解除と履歴追記を補助関数へ抽出する +3. 既存の参加者情報と再開引数をマージして再解決用 Namespace を組み立てる処理を補助関数へ抽出する +4. 参加者の解決成功後に状態と resume_changes を更新する処理を補助関数へ抽出する +5. _apply_resume_args_block を各ステップの明瞭なオーケストレーションに再構成し、対象テストと全体テストを実行する + +### R5-003 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex / agy | 取り消し | 0 | + +**なぜ**: 86行の関数内で、prepare.json や gh pr view からの PR メタ情報(base, title)解決、detached HEAD からの head ブランチ復元、タイトル末尾の (rotated) 接尾辞の正規化ループ、squash コミットの作成と push、新 PR 本文の組み立て、rotate_close_and_create 呼び出しが密結合しており、情報解決と Git/GitHub 副作用の分離が不明瞭になっている。 + +**手順**: 1. test_rotate_pr_queue.py などの現状固定テストで squash モードの振る舞い(接尾辞正規化、ブランチ名解決など)を確認する +2. PR メタ情報(base / title)のフォールバック取得処理を補助関数へ抽出する +3. ブランチ復元(git branch --show-current / prepare.json / gh pr view)と (rotated) 接尾辞の正規化処理を補助関数へ抽出する +4. squash コミット作成とリモート push の Git 操作を補助関数へ抽出する +5. execute_squash を各抽出関数のパイプライン呼び出しに整理し、テストを実行する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -292,3 +335,6 @@ | 3 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 3 | `plugins/ndf/scripts/lib/refresh.py#fetch` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` | long_method | 1 ラウンドの採用上限 5 件を超えた | +| 5 | `plugins/ndf/scripts/lib/auth.py#_probe_all` | one_by_one_iteration | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_apply_resume_args_block` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 5 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | From b42b42774e346f9175f04a5dc051a775fae5001d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 00:19:35 +0000 Subject: [PATCH 154/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib=20=E3=81=A7=E9=95=B7?= =?UTF-8?q?=E3=81=84=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E3=82=92=E6=AE=B5?= =?UTF-8?q?=E3=81=AB=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit R6-001: transcript_agents.py の _aggregate_token_metrics から、合成でない assistant 行の選別(_real_assistant_rows)、トークン指標の更新 (_update_token_metrics)、応答IDとモデル件数の収集(_collect_response_model) を抽出した。混在していた集計規則を段ごとに分け、振る舞いは変えていない。 R6-002: metrics.py の format_report から、実装担当の行生成 (_format_impl_rows)、レビュー担当の行生成(_format_reviewer_rows)、 計測注記と比較上の注意の追加(_append_measurement_notes)を抽出した。 出力文字列は従来どおりで、既存テストで一致を確認した。 Item-Id: R6-001 Round: 6 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/lib/metrics.py | 63 ++++++++++++-------- plugins/ndf/scripts/lib/transcript_agents.py | 48 ++++++++++----- 2 files changed, 72 insertions(+), 39 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index d900fc88..abf37b3d 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -276,18 +276,48 @@ def _emit_table( lines += [*headers, *rows] -def format_report(metrics: dict[str, Any]) -> str: - """人が読む形へ整形する。比較の限界を必ず添える。""" - lines: list[str] = [] - impl_rows = [ +def _format_impl_rows(impl: dict[str, Any]) -> list[str]: + """実装担当の表の行を作る。""" + return [ ( f"| {key} | {m['rounds']} | {m['applied']} | {m['abandoned']} | " f"{_fmt(m['first_review_approval_rate'])} | {_fmt(m['avg_fix_rounds'])} | " f"{_fmt(m['budget_exceeded_rate'])} | {_fmt(m['test_failure_rate'])} | " f"{m['seconds']:.0f} |" ) - for key, m in metrics["impl"].items() + for key, m in impl.items() + ] + + +def _format_reviewer_rows(reviewer: dict[str, Any]) -> list[str]: + """レビュー担当の表の行を作る。""" + return [ + ( + f"| {key} | {m['reviews']} | {m['findings']} | " + f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " + f"{m['seconds']:.0f} |" + ) + for key, m in reviewer.items() ] + + +def _append_measurement_notes(lines: list[str], metrics: dict[str, Any]) -> None: + """計測不能・指定値代用の注記と、比較上の注意を末尾へ足す。""" + if metrics["unmeasured"]: + lines += ["", "## 集計から分離したラウンド", ""] + lines += [f"- {w}" for w in dict.fromkeys(metrics["unmeasured"])] + + if metrics.get("assumed"): + lines += ["", "## 指定値で代用したラウンド", ""] + lines += [f"- {w}" for w in dict.fromkeys(metrics["assumed"])] + + lines += ["", "## 比較として読むときの限界", ""] + lines += [f"- {c}" for c in COMPARISON_CAVEATS] + + +def format_report(metrics: dict[str, Any]) -> str: + """人が読む形へ整形する。比較の限界を必ず添える。""" + lines: list[str] = [] _emit_table( lines, "実装担当", @@ -295,17 +325,9 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | 担当R | 適用 | 見送り | 初回承認率 | 平均修正R | 予算超過率 | テスト失敗率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ), - impl_rows, + _format_impl_rows(metrics["impl"]), ) - reviewer_rows = [ - ( - f"| {key} | {m['reviews']} | {m['findings']} | " - f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " - f"{m['seconds']:.0f} |" - ) - for key, m in metrics["reviewer"].items() - ] lines.append("") _emit_table( lines, @@ -314,17 +336,8 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | レビュー回数 | 指摘 | 修正に至った率 | 判定一致率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: |", ), - reviewer_rows, + _format_reviewer_rows(metrics["reviewer"]), ) - if metrics["unmeasured"]: - lines += ["", "## 集計から分離したラウンド", ""] - lines += [f"- {w}" for w in dict.fromkeys(metrics["unmeasured"])] - - if metrics.get("assumed"): - lines += ["", "## 指定値で代用したラウンド", ""] - lines += [f"- {w}" for w in dict.fromkeys(metrics["assumed"])] - - lines += ["", "## 比較として読むときの限界", ""] - lines += [f"- {c}" for c in COMPARISON_CAVEATS] + _append_measurement_notes(lines, metrics) return "\n".join(lines) diff --git a/plugins/ndf/scripts/lib/transcript_agents.py b/plugins/ndf/scripts/lib/transcript_agents.py index 73798f00..0df0e870 100644 --- a/plugins/ndf/scripts/lib/transcript_agents.py +++ b/plugins/ndf/scripts/lib/transcript_agents.py @@ -264,6 +264,37 @@ def _tool_use_ids(rows: list[dict]) -> list[str]: return ids +def _real_assistant_rows(rows: list[dict]) -> list[dict]: + """合成でない assistant 行だけを返す(トークンとモデルの集計の対象)。""" + return [ + row for row in rows + if row.get("type") == "assistant" and not _is_synthetic(row) + ] + + +def _update_token_metrics(row: dict, record: AgentRecord) -> None: + """1 件の応答から固定費と最大充填を更新する。""" + total = _input_total(row) + if total is None: + return + if record.fixed is None: + record.fixed = total + record.peak = total if record.peak is None else max(record.peak, total) + + +def _collect_response_model( + row: dict, seen: set[str], models: Counter, +) -> None: + """応答 ID を重複排除しつつ、初出のモデルを 1 件として数える。""" + message_id = _message(row).get("id") + if not (isinstance(message_id, str) and message_id and message_id not in seen): + return + seen.add(message_id) + model = _message(row).get("model") + if isinstance(model, str) and model: + models[model] += 1 + + def _aggregate_token_metrics(rows: list[dict], record: AgentRecord) -> None: """固定費・最大充填・応答数・モデルを合成でない応答だけで数える(AC24)。 @@ -271,20 +302,9 @@ def _aggregate_token_metrics(rows: list[dict], record: AgentRecord) -> None: """ seen: set[str] = set() models: Counter = Counter() - for row in rows: - if row.get("type") != "assistant" or _is_synthetic(row): - continue - total = _input_total(row) - if total is not None: - if record.fixed is None: - record.fixed = total - record.peak = total if record.peak is None else max(record.peak, total) - message_id = _message(row).get("id") - if isinstance(message_id, str) and message_id and message_id not in seen: - seen.add(message_id) - model = _message(row).get("model") - if isinstance(model, str) and model: - models[model] += 1 + for row in _real_assistant_rows(rows): + _update_token_metrics(row, record) + _collect_response_model(row, seen, models) record.responses = len(seen) if record.fixed is not None and record.peak is not None: record.work = record.peak - record.fixed From 63abc1f11f9a93940fd03ddd24a79425e8c42dca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 00:20:17 +0000 Subject: [PATCH 155/217] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib=20=E3=81=A7=E9=95=B7?= =?UTF-8?q?=E3=81=84=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E3=82=92=E6=AE=B5?= =?UTF-8?q?=E3=81=AB=E5=88=86=E3=81=91=E3=82=8B"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit b42b42774e346f9175f04a5dc051a775fae5001d. --- plugins/ndf/scripts/lib/metrics.py | 63 ++++++++------------ plugins/ndf/scripts/lib/transcript_agents.py | 48 +++++---------- 2 files changed, 39 insertions(+), 72 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index abf37b3d..d900fc88 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -276,48 +276,18 @@ def _emit_table( lines += [*headers, *rows] -def _format_impl_rows(impl: dict[str, Any]) -> list[str]: - """実装担当の表の行を作る。""" - return [ +def format_report(metrics: dict[str, Any]) -> str: + """人が読む形へ整形する。比較の限界を必ず添える。""" + lines: list[str] = [] + impl_rows = [ ( f"| {key} | {m['rounds']} | {m['applied']} | {m['abandoned']} | " f"{_fmt(m['first_review_approval_rate'])} | {_fmt(m['avg_fix_rounds'])} | " f"{_fmt(m['budget_exceeded_rate'])} | {_fmt(m['test_failure_rate'])} | " f"{m['seconds']:.0f} |" ) - for key, m in impl.items() - ] - - -def _format_reviewer_rows(reviewer: dict[str, Any]) -> list[str]: - """レビュー担当の表の行を作る。""" - return [ - ( - f"| {key} | {m['reviews']} | {m['findings']} | " - f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " - f"{m['seconds']:.0f} |" - ) - for key, m in reviewer.items() + for key, m in metrics["impl"].items() ] - - -def _append_measurement_notes(lines: list[str], metrics: dict[str, Any]) -> None: - """計測不能・指定値代用の注記と、比較上の注意を末尾へ足す。""" - if metrics["unmeasured"]: - lines += ["", "## 集計から分離したラウンド", ""] - lines += [f"- {w}" for w in dict.fromkeys(metrics["unmeasured"])] - - if metrics.get("assumed"): - lines += ["", "## 指定値で代用したラウンド", ""] - lines += [f"- {w}" for w in dict.fromkeys(metrics["assumed"])] - - lines += ["", "## 比較として読むときの限界", ""] - lines += [f"- {c}" for c in COMPARISON_CAVEATS] - - -def format_report(metrics: dict[str, Any]) -> str: - """人が読む形へ整形する。比較の限界を必ず添える。""" - lines: list[str] = [] _emit_table( lines, "実装担当", @@ -325,9 +295,17 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | 担当R | 適用 | 見送り | 初回承認率 | 平均修正R | 予算超過率 | テスト失敗率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ), - _format_impl_rows(metrics["impl"]), + impl_rows, ) + reviewer_rows = [ + ( + f"| {key} | {m['reviews']} | {m['findings']} | " + f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " + f"{m['seconds']:.0f} |" + ) + for key, m in metrics["reviewer"].items() + ] lines.append("") _emit_table( lines, @@ -336,8 +314,17 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | レビュー回数 | 指摘 | 修正に至った率 | 判定一致率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: |", ), - _format_reviewer_rows(metrics["reviewer"]), + reviewer_rows, ) - _append_measurement_notes(lines, metrics) + if metrics["unmeasured"]: + lines += ["", "## 集計から分離したラウンド", ""] + lines += [f"- {w}" for w in dict.fromkeys(metrics["unmeasured"])] + + if metrics.get("assumed"): + lines += ["", "## 指定値で代用したラウンド", ""] + lines += [f"- {w}" for w in dict.fromkeys(metrics["assumed"])] + + lines += ["", "## 比較として読むときの限界", ""] + lines += [f"- {c}" for c in COMPARISON_CAVEATS] return "\n".join(lines) diff --git a/plugins/ndf/scripts/lib/transcript_agents.py b/plugins/ndf/scripts/lib/transcript_agents.py index 0df0e870..73798f00 100644 --- a/plugins/ndf/scripts/lib/transcript_agents.py +++ b/plugins/ndf/scripts/lib/transcript_agents.py @@ -264,37 +264,6 @@ def _tool_use_ids(rows: list[dict]) -> list[str]: return ids -def _real_assistant_rows(rows: list[dict]) -> list[dict]: - """合成でない assistant 行だけを返す(トークンとモデルの集計の対象)。""" - return [ - row for row in rows - if row.get("type") == "assistant" and not _is_synthetic(row) - ] - - -def _update_token_metrics(row: dict, record: AgentRecord) -> None: - """1 件の応答から固定費と最大充填を更新する。""" - total = _input_total(row) - if total is None: - return - if record.fixed is None: - record.fixed = total - record.peak = total if record.peak is None else max(record.peak, total) - - -def _collect_response_model( - row: dict, seen: set[str], models: Counter, -) -> None: - """応答 ID を重複排除しつつ、初出のモデルを 1 件として数える。""" - message_id = _message(row).get("id") - if not (isinstance(message_id, str) and message_id and message_id not in seen): - return - seen.add(message_id) - model = _message(row).get("model") - if isinstance(model, str) and model: - models[model] += 1 - - def _aggregate_token_metrics(rows: list[dict], record: AgentRecord) -> None: """固定費・最大充填・応答数・モデルを合成でない応答だけで数える(AC24)。 @@ -302,9 +271,20 @@ def _aggregate_token_metrics(rows: list[dict], record: AgentRecord) -> None: """ seen: set[str] = set() models: Counter = Counter() - for row in _real_assistant_rows(rows): - _update_token_metrics(row, record) - _collect_response_model(row, seen, models) + for row in rows: + if row.get("type") != "assistant" or _is_synthetic(row): + continue + total = _input_total(row) + if total is not None: + if record.fixed is None: + record.fixed = total + record.peak = total if record.peak is None else max(record.peak, total) + message_id = _message(row).get("id") + if isinstance(message_id, str) and message_id and message_id not in seen: + seen.add(message_id) + model = _message(row).get("model") + if isinstance(model, str) and model: + models[model] += 1 record.responses = len(seen) if record.fixed is not None and record.peak is not None: record.work = record.peak - record.fixed From 51b3675af808c65eaeea814d688344f407ab6402 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 00:20:18 +0000 Subject: [PATCH 156/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 58 ++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index ef348ea8..3eb74d96 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -315,6 +315,61 @@ 4. squash コミット作成とリモート push の Git 操作を補助関数へ抽出する 5. execute_squash を各抽出関数のパイプライン呼び出しに整理し、テストを実行する +## ラウンド 6(実装 agy / レビュー codex / kiro) + +### R6-001 — `plugins/ndf/scripts/lib/transcript_agents.py#_aggregate_token_metrics` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 取り消し | 1 | + +**なぜ**: 1回の走査でトークンの固定費・最大値、応答IDの重複排除、モデル別件数を集め、その後に派生値と代表モデルまで確定している。異なる集計規則が同じ局所状態へ混在し、各規則を単独で追いにくい。 + +**手順**: 1. 合成でない assistant 行を選ぶ処理を名前付きの反復単位へ抽出する +2. トークン指標の更新を _update_token_metrics として抽出する +3. 応答IDとモデル件数の更新を _collect_response_model として抽出する +4. 呼び出し側は集計結果から responses・work・modelを従来どおり確定し、既存フィクスチャの契約値で退行確認する + +### R6-002 — `plugins/ndf/scripts/lib/metrics.py#format_report` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_method | extract_method | major | codex | 取り消し | 1 | + +**なぜ**: 実装担当表の行生成、レビュー担当表の行生成、計測不能・指定値代用の注記、比較上の注意の4段階を1関数が通しで組み立てており、表の列変更と注記構成の変更が同じ関数へ集中している。 + +**手順**: 1. 実装担当の行生成を _format_impl_rows として抽出する +2. レビュー担当の行生成を _format_reviewer_rows として抽出する +3. 計測注記の追加を _append_measurement_notes として抽出する +4. format_report は各段を順に呼び、既存の文字列出力が一致することを既存テストで確認する + +### R6-003 — `plugins/ndf/skills/cross-review/tests/conftest.py#_no_github_state` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| mock_targets_implementation_detail | fix_dependency_direction | major | codex | 取り消し | 1 | + +**なぜ**: autouse fixture が state.py の非公開関数 _fetch_check_runs と _fetch_pr_metadata を名前で直接差し替えるため、GitHub取得処理の抽出や改名だけで広範なテストが壊れる。実際の外部境界は _gh_rest と subprocess.run なのに、その内側の実装手順を全テストへ固定している。 + +**手順**: 1. state.py が使うGitHub取得境界を明示した依存としてまとめる +2. cmd系の入口からその境界を注入できる最小の既定値を置く +3. _no_github_state は非公開取得関数ではなく境界の偽実装を注入する +4. 実取得の契約テストは既存の fake gh と _gh_rest 差し替えを維持し、全テストで外部通信が発生しないことを確認する + +### R6-004 — `plugins/ndf/scripts/lib/metrics.py#_append_model_measurement_warnings` + +| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | +| --- | --- | --- | --- | --- | ---: | +| long_parameter_list | introduce_parameter_object | minor | kiro | 未着手 | 0 | + +**なぜ**: 引数が 7 個。うち unmeasured / assumed は出力の蓄積先、round_no / runtime / requested / observed / role_label は 1 ラウンド 1 担当の計測文脈で、常に組で渡り回る。2 つの呼び出し側(_aggregate(impl)と _aggregate_round_reviewers)で同じ 5 値をその順で並べており、順序を取り違えると requested と observed が入れ替わっても型が同じ str のため気付けない。 + +**手順**: 1. runtime / requested / observed / role_label(と round_no)をまとめる NamedTuple もしくは dataclass(例 MeasurementContext)を metrics.py に定義する +2. _append_model_measurement_warnings の署名を (unmeasured, assumed, ctx) へ変更し、本体の runtime 等の参照を ctx.runtime 等へ置き換える +3. aggregate 内の impl 経路(round_no・impl_runtime・requested・observed・"実装担当")で ctx を組み立てて渡す +4. _aggregate_round_reviewers 内のレビュー担当経路(round_no・name・requested・observed・"レビュー担当")でも ctx を組み立てて渡す +5. cross-refactoring/tests/test_models_and_metrics.py(既存)で aggregate の出力(unmeasured / assumed の文言)が不変であることを確認する + ## 見送った項目 | ラウンド | 対象 | 兆候・経路 | 理由 | @@ -338,3 +393,6 @@ | 5 | `plugins/ndf/scripts/lib/auth.py#_probe_all` | one_by_one_iteration | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_apply_resume_args_block` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | | 5 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | +| 6 | `plugins/ndf/scripts/lib/transcript_agents.py#_aggregate_token_metrics` | long_method | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | +| 6 | `plugins/ndf/scripts/lib/metrics.py#format_report` | long_method | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | +| 6 | `plugins/ndf/skills/cross-review/tests/conftest.py#_no_github_state` | mock_targets_implementation_detail | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | From 5f63640dff6dd7739738a8794edc5db43792f342 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 01:34:11 +0000 Subject: [PATCH 157/217] =?UTF-8?q?Refactor:=20introduce=5Fparameter=5Fobj?= =?UTF-8?q?ect=20=E2=80=94=20plugins/ndf/scripts/lib/metrics.py#=5Fappend?= =?UTF-8?q?=5Fmodel=5Fmeasurement=5Fwarnings?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 引数 7 個のうち、1 ラウンド 1 担当の計測文脈として常に組で渡り回る 5 値 (round_no / runtime / requested / observed / role_label)を NamedTuple `MeasurementContext` にまとめた。requested と observed はどちらも str で、 位置引数で並べると取り違えても型では気付けなかった。 呼び出し側は aggregate の実装担当経路と _aggregate_round_reviewers の レビュー担当経路の 2 箇所。どちらもキーワードで ctx を組み立てる。 振る舞いは不変。 Item-Id: R6-004 Round: 6 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/metrics.py | 54 ++++++++++++++++++++++-------- 1 file changed, 40 insertions(+), 14 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index d900fc88..36aa513a 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -7,7 +7,7 @@ """ from __future__ import annotations -from typing import Any, Optional +from typing import Any, NamedTuple, Optional import models as _models @@ -28,6 +28,20 @@ ] +class MeasurementContext(NamedTuple): + """1 ラウンド 1 担当の計測文脈。 + + **常に組で渡り回る 5 値をまとめる。** `requested` と `observed` はどちらも + モデル名の文字列で、並べて渡すと取り違えても型では気付けない。 + """ + + round_no: Any + runtime: str + requested: Optional[str] + observed: Optional[str] + role_label: str + + def _key(runtime: str, model: Optional[str]) -> str: return f"{runtime} / {_models.label(model)}" @@ -70,7 +84,15 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: observed = impl_model.get("observed") _append_model_measurement_warnings( - unmeasured, assumed, round_no, impl_runtime, requested, observed, "実装担当" + unmeasured, + assumed, + MeasurementContext( + round_no=round_no, + runtime=impl_runtime, + requested=requested, + observed=observed, + role_label="実装担当", + ), ) reviews = _round_reviews(entry) @@ -105,7 +127,15 @@ def _aggregate_round_reviewers( requested = spec.get("requested") observed = spec.get("observed") _append_model_measurement_warnings( - unmeasured, assumed, round_no, name, requested, observed, "レビュー担当" + unmeasured, + assumed, + MeasurementContext( + round_no=round_no, + runtime=name, + requested=requested, + observed=observed, + role_label="レビュー担当", + ), ) if _models.is_measurable(name, requested): _aggregate_reviewer_round(reviewer, entry, name, requested, reviews) @@ -188,24 +218,20 @@ def _tally_verdict_agreement( def _append_model_measurement_warnings( unmeasured: list[str], assumed: list[str], - round_no: Any, - runtime: str, - requested: Optional[str], - observed: Optional[str], - role_label: str, + ctx: MeasurementContext, ) -> None: - warning = _models.mismatch_warning(runtime, requested, observed) + warning = _models.mismatch_warning(ctx.runtime, ctx.requested, ctx.observed) if warning: - unmeasured.append(f"round {round_no}: {warning}") + unmeasured.append(f"round {ctx.round_no}: {warning}") # 分離するかと、その理由はランタイムごとに違う。判断も文言も models.py が持つ。 - reason = _models.separation_reason(runtime, requested) + reason = _models.separation_reason(ctx.runtime, ctx.requested) if reason: unmeasured.append( - f"round {round_no}: {reason}ため、{role_label}の集計から分離する" + f"round {ctx.round_no}: {reason}ため、{ctx.role_label}の集計から分離する" ) - note = _models.assumption_note(runtime, requested) + note = _models.assumption_note(ctx.runtime, ctx.requested) if note: - assumed.append(f"round {round_no}: {note}({role_label})") + assumed.append(f"round {ctx.round_no}: {note}({ctx.role_label})") def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: From 225e399f79c7252d16a37eb2871ed4d3df0ea9a8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 01:34:42 +0000 Subject: [PATCH 158/217] =?UTF-8?q?Revert=20"Refactor:=20introduce=5Fparam?= =?UTF-8?q?eter=5Fobject=20=E2=80=94=20plugins/ndf/scripts/lib/metrics.py#?= =?UTF-8?q?=5Fappend=5Fmodel=5Fmeasurement=5Fwarnings"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 5f63640dff6dd7739738a8794edc5db43792f342. --- plugins/ndf/scripts/lib/metrics.py | 54 ++++++++---------------------- 1 file changed, 14 insertions(+), 40 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index 36aa513a..d900fc88 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -7,7 +7,7 @@ """ from __future__ import annotations -from typing import Any, NamedTuple, Optional +from typing import Any, Optional import models as _models @@ -28,20 +28,6 @@ ] -class MeasurementContext(NamedTuple): - """1 ラウンド 1 担当の計測文脈。 - - **常に組で渡り回る 5 値をまとめる。** `requested` と `observed` はどちらも - モデル名の文字列で、並べて渡すと取り違えても型では気付けない。 - """ - - round_no: Any - runtime: str - requested: Optional[str] - observed: Optional[str] - role_label: str - - def _key(runtime: str, model: Optional[str]) -> str: return f"{runtime} / {_models.label(model)}" @@ -84,15 +70,7 @@ def aggregate(state: dict[str, Any]) -> dict[str, Any]: observed = impl_model.get("observed") _append_model_measurement_warnings( - unmeasured, - assumed, - MeasurementContext( - round_no=round_no, - runtime=impl_runtime, - requested=requested, - observed=observed, - role_label="実装担当", - ), + unmeasured, assumed, round_no, impl_runtime, requested, observed, "実装担当" ) reviews = _round_reviews(entry) @@ -127,15 +105,7 @@ def _aggregate_round_reviewers( requested = spec.get("requested") observed = spec.get("observed") _append_model_measurement_warnings( - unmeasured, - assumed, - MeasurementContext( - round_no=round_no, - runtime=name, - requested=requested, - observed=observed, - role_label="レビュー担当", - ), + unmeasured, assumed, round_no, name, requested, observed, "レビュー担当" ) if _models.is_measurable(name, requested): _aggregate_reviewer_round(reviewer, entry, name, requested, reviews) @@ -218,20 +188,24 @@ def _tally_verdict_agreement( def _append_model_measurement_warnings( unmeasured: list[str], assumed: list[str], - ctx: MeasurementContext, + round_no: Any, + runtime: str, + requested: Optional[str], + observed: Optional[str], + role_label: str, ) -> None: - warning = _models.mismatch_warning(ctx.runtime, ctx.requested, ctx.observed) + warning = _models.mismatch_warning(runtime, requested, observed) if warning: - unmeasured.append(f"round {ctx.round_no}: {warning}") + unmeasured.append(f"round {round_no}: {warning}") # 分離するかと、その理由はランタイムごとに違う。判断も文言も models.py が持つ。 - reason = _models.separation_reason(ctx.runtime, ctx.requested) + reason = _models.separation_reason(runtime, requested) if reason: unmeasured.append( - f"round {ctx.round_no}: {reason}ため、{ctx.role_label}の集計から分離する" + f"round {round_no}: {reason}ため、{role_label}の集計から分離する" ) - note = _models.assumption_note(ctx.runtime, ctx.requested) + note = _models.assumption_note(runtime, requested) if note: - assumed.append(f"round {ctx.round_no}: {note}({ctx.role_label})") + assumed.append(f"round {round_no}: {note}({role_label})") def _duration(entry: dict[str, Any], phases: tuple[str, ...]) -> float: From d3029e0b0afef7e0c76609a0a8b4b4e4b4464904 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 01:34:42 +0000 Subject: [PATCH 159/217] =?UTF-8?q?Docs:=20=E6=94=B9=E4=BF=AE=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E8=A8=98=E9=8C=B2=E3=81=99=E3=82=8B=EF=BC=88?= =?UTF-8?q?cross-refactoring=20=E9=80=B2=E8=A1=8C=E5=81=B4=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit なぜ直すのか(理由)とどう直すのか(手順)は提案の時点でしか残らない。 状態ファイルは差分から除外されるため、Pull Request から読める場所へ置く。 --- issues/refactoring-plan-rf793.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md index 3eb74d96..6e5dda05 100644 --- a/issues/refactoring-plan-rf793.md +++ b/issues/refactoring-plan-rf793.md @@ -360,7 +360,7 @@ | 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | | --- | --- | --- | --- | --- | ---: | -| long_parameter_list | introduce_parameter_object | minor | kiro | 未着手 | 0 | +| long_parameter_list | introduce_parameter_object | minor | kiro | 取り消し | 1 | **なぜ**: 引数が 7 個。うち unmeasured / assumed は出力の蓄積先、round_no / runtime / requested / observed / role_label は 1 ラウンド 1 担当の計測文脈で、常に組で渡り回る。2 つの呼び出し側(_aggregate(impl)と _aggregate_round_reviewers)で同じ 5 値をその順で並べており、順序を取り違えると requested と observed が入れ替わっても型が同じ str のため気付けない。 @@ -396,3 +396,4 @@ | 6 | `plugins/ndf/scripts/lib/transcript_agents.py#_aggregate_token_metrics` | long_method | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | | 6 | `plugins/ndf/scripts/lib/metrics.py#format_report` | long_method | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | | 6 | `plugins/ndf/skills/cross-review/tests/conftest.py#_no_github_state` | mock_targets_implementation_detail | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | +| 6 | `plugins/ndf/scripts/lib/metrics.py#_append_model_measurement_warnings` | long_parameter_list | コミット 5f63640dff6dd7739738a8794edc5db43792f342 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | From bcca202062c6da7ee7eb780d97e51f11aea6d93f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 01:37:30 +0000 Subject: [PATCH 160/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20verify/apply/refactor.py=20=E3=81=AE=E6=AE=B5?= =?UTF-8?q?=E3=81=AE=E6=8A=BD=E5=87=BA=E3=81=A8=20commit=5Ffiles=20?= =?UTF-8?q?=E3=81=AE=E7=B5=B1=E5=90=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用ラウンド 1 の 4 項目をまとめて適用した。振る舞いは変えていない。 - R4-001 verify.verify_apply_round: 5 段の検証を `_verify_all_commits` / `_verify_characterization_test` / `_verify_diff_budget` / `_verify_apply_commit_count` へ抽出し、本体は同じ順で呼んで最初の失敗理由を 返す流れだけにした - R4-003 commands/apply.cmd_next_apply_round: 群の走査と開き直しの判定を `_find_group_to_open` へ、開いた群の状態の初期化を `_begin_apply_group` へ抽出した - R4-004 refactor.main: サブコマンドの登録を `_add_init_command` / `_add_id_commands` / `_add_round_commands` / `_add_dry_run_commands` / `_add_report_command` へ抽出した。解析とディスパッチの現状固定テスト (tests/test_cli_subcommands.py)を先に足してある - R4-005 gitfacts.commit_test_changes: 触れたファイルの列挙を commit_files へ 寄せ、git show --name-only の重複を無くした Item-Id: R4-001 Round: 4 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- .../cross-refactoring/scripts/refactor.py | 44 ++++- .../scripts/refactor_lib/commands/apply.py | 84 ++++---- .../scripts/refactor_lib/gitfacts.py | 8 +- .../scripts/refactor_lib/verify.py | 108 ++++++---- .../tests/test_cli_subcommands.py | 184 ++++++++++++++++++ 5 files changed, 345 insertions(+), 83 deletions(-) create mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py index 79203a40..6b6dd5c9 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py @@ -84,14 +84,13 @@ def _write_run_summary(path: pathlib.Path, state: dict) -> None: ) -# ---------------- main ---------------- - -def main() -> None: - p = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter - ) - sub = p.add_subparsers(dest="cmd", required=True) +# ---------------- サブコマンドの登録 ---------------- +# +# **ディスパッチ先は登録の時点で引く。** 表を関数の外へ出すと、読み込みの時点で +# 関数オブジェクトが固定され、入口の名前空間を差し替えても効かなくなる。 +def _add_init_command(sub: argparse._SubParsersAction) -> None: + """`init` を登録する。**この 1 つだけ持つオプションが多い。**""" init = sub.add_parser( "init", help="Step 0 — ホスト確定 / 母集合の確定 / 作業ディレクトリ root / 状態初期化") @@ -154,6 +153,9 @@ def main() -> None: init.add_argument("--worktree-root", default=None) init.set_defaults(func=cmd_init) + +def _add_id_commands(sub: argparse._SubParsersAction) -> None: + """進行の番号だけを取るサブコマンド群を登録する。""" for name, func, help_ in ( ("start-round", cmd_start_round, "Step 2 — 提案ラウンドを開く。実装担当とレビュー担当を返す"), @@ -171,6 +173,9 @@ def main() -> None: sp.add_argument("id", type=int) sp.set_defaults(func=func) + +def _add_round_commands(sub: argparse._SubParsersAction) -> None: + """提案ラウンドの番号まで取るサブコマンド群を登録する。""" for name, func, help_ in ( ("next-apply-round", cmd_next_apply_round, "Step 4 — 次の適用ラウンド(群)を開く。実装担当と対象の項目を返す"), @@ -187,7 +192,12 @@ def main() -> None: sp.add_argument("round", type=int) sp.set_defaults(func=func) - # コミットを取り消しうる 2 つは、実行前に何が消えるかを確かめられるようにする。 + +def _add_dry_run_commands(sub: argparse._SubParsersAction) -> None: + """コミットを取り消しうるサブコマンド群を登録する。 + + 実行前に何が消えるかを確かめられるよう、この 2 つだけ `--dry-run` を持つ。 + """ for name, func, help_ in ( ("merge-apply", cmd_merge_apply, "Step 4 — 適用ラウンドの検証(差分予算 / トレーラー / 範囲 / 1 コミット)"), @@ -201,6 +211,9 @@ def main() -> None: help="取り消すコミットを表示するだけで実行しない") sp.set_defaults(func=func) + +def _add_report_command(sub: argparse._SubParsersAction) -> None: + """`report` を登録する。指標の集計だけ任意にする。""" rp = sub.add_parser( "report", help="Step 8 — ラウンド表・項目表・見送り・指標") rp.add_argument("id", type=int) @@ -208,6 +221,21 @@ def main() -> None: help="ランタイムとモデルの組で指標を集計する") rp.set_defaults(func=cmd_report) + +# ---------------- main ---------------- + +def main() -> None: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = p.add_subparsers(dest="cmd", required=True) + + _add_init_command(sub) + _add_id_commands(sub) + _add_round_commands(sub) + _add_dry_run_commands(sub) + _add_report_command(sub) + args = p.parse_args() args.func(args) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index de59bab6..33eddfae 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -283,36 +283,25 @@ def _item_summary(item: dict[str, Any]) -> str: -def cmd_next_apply_round(args: argparse.Namespace) -> None: - """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 - - 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 - - **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを - 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 - - **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 - ラウンドあたりの上限だからである。 +def _find_group_to_open( + groups: list[dict[str, Any]], +) -> tuple[Optional[dict[str, Any]], str]: + """次に開く群と、その開き直しの判定を返す。開ける群が無ければ `(None, "")`。 + + **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 + 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは + 収束ループの前提である。 + + **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を + 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 + ここで取り消し済みにして次を探す。 """ - path, state = load_state(args.id) - entry = round_of(state, args.round) - groups = apply_groups(entry) - - # **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 - # 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは - # 収束ループの前提である。 - # - # **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を - # 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 - # ここで取り消し済みにして次を探す。 - opened: Optional[dict[str, Any]] = None reopening = "" for group in groups: if group.get("status") not in {"pending", "applied"}: continue if group.get("status") == "applied": - opened = group - break + return group, reopening reopening = group_reopening(group) if reopening in {"empty", "exhausted"}: group["status"] = "dropped" @@ -324,14 +313,19 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: f"({'項目なし' if reopening == 'empty' else '試行の上限'})" ) continue - opened = group - break + return group, reopening + return None, reopening - if opened is None: - statefile.save(path, state) - info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") - sys.exit(1) +def _begin_apply_group( + state: dict[str, Any], entry: dict[str, Any], + opened: dict[str, Any], reopening: str, +) -> None: + """開いた群に合わせて起点・試行の番号・適用の控えを整える。 + + 初回の開始だけが起点と試行の番号を進める。未完の試行の再開と、取り込み済みの + 群の開き直しは**どちらも動かさない**。 + """ entry["apply_round"] = opened["apply_round"] if opened.get("status") == "pending" and reopening == "open": # 起点は**オーケストレータ側で**確定させる。実装担当の申告に委ねると、 @@ -346,14 +340,38 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: "applied": [], "failed": [], "base_sha": head, "head_sha": None, "merged_at": None, } - elif opened.get("status") == "pending": + return + if opened.get("status") == "pending": # 開いたまま閉じていない試行の再開。**起点も試行の番号も動かさない。** info(f"↻ 適用ラウンド {opened['apply_round']} の試行を再開します") - entry["apply_base_sha"] = opened.get("base_sha") else: # 取り込み済みの群を開き直した。**起点も修正の回数も動かさない。** info(f"↻ 適用ラウンド {opened['apply_round']} は取り込み済みです(検証から再開)") - entry["apply_base_sha"] = opened.get("base_sha") + entry["apply_base_sha"] = opened.get("base_sha") + + +def cmd_next_apply_round(args: argparse.Namespace) -> None: + """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 + + 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 + + **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを + 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 + + **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 + ラウンドあたりの上限だからである。 + """ + path, state = load_state(args.id) + entry = round_of(state, args.round) + groups = apply_groups(entry) + + opened, reopening = _find_group_to_open(groups) + if opened is None: + statefile.save(path, state) + info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") + sys.exit(1) + + _begin_apply_group(state, entry, opened, reopening) state["phase"] = "apply" statefile.save(path, state) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index 4ddb68fa..fed6abad 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -169,11 +169,13 @@ def commit_test_changes(work: str, sha: str) -> dict[str, tuple[list[str], list[ **検証がテストの期待値を見るために要る**(#443)。差分ではなく前後の行を返すのは、 判定が `assert` の行の集合を突き合わせる形だからである。 + + 触れたファイルの列挙は `commit_files` が持つ。git の引数と空行の除外を 2 か所に + 持つと、列挙の仕方を変えるときに片方だけが直される。 """ - out = git_out(work, ["show", "--name-only", "--format=", sha]) changes: dict[str, tuple[list[str], list[str]]] = {} - for path in (out or "").splitlines(): - if not path.strip() or not _is_test_path(path): + for path in commit_files(work, sha): + if not _is_test_path(path): continue before = git_out(work, ["show", f"{sha}^:{path}"]) or "" after = git_out(work, ["show", f"{sha}:{path}"]) or "" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py index 35bc2388..08c3973d 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py @@ -189,27 +189,10 @@ def diff_budget_factor(technique: Optional[str]) -> int: return DIFF_BUDGET_FACTOR -def verify_apply_round( - items: list[dict[str, Any]], facts: list[dict[str, Any]], - scope: Optional[Iterable[str]] = None, +def _verify_all_commits( + facts: list[dict[str, Any]], scope: Optional[Iterable[str]], ) -> Optional[str]: - """適用ラウンド 1 つ分の適用結果を検証する。問題があれば失敗理由を返す。 - - **判定の単位は適用ラウンドである**(決定 3)。群の中は 1 コミットであり、 - 分離しても取り消せないため、**失敗を項目までは特定しない**。1 件の失敗は - 群の全件を巻き込む(「群の中の道連れ」)。分離を細かくしたい利用者は - `--max-items-per-round` を下げる。 - - `facts` は `collect_commit_facts()` が git から作る。振る舞い不変そのものは - ここでは確かめない(テストは `verify-round` が実行する)が、**手順が守られたかは - 結果から確かめられる**。 - """ - if not facts: - return ( - "コミットが 1 件もありません" - "(適用ラウンド = 1 コミットの前提を満たしていません)" - ) - + """群の全コミットが手順を満たしているか。満たさないものがあれば理由を返す。""" for commit in facts: problem = _verify_commit_basics( commit, @@ -220,24 +203,31 @@ def verify_apply_round( ) if problem: return problem + return None - if any(i.get("test_gap") for i in items): - # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 - if not facts[0].get("touches_tests"): - return ( - "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" - f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" - ) - - # **テストの期待値が変わっていないか**(#443)。段 1(機械)で決まるものだけを - # ここで落とす。決まらないものは `pending_test_judgements` が集め、進行側が - # 段 2(AI エージェント)へ渡す。 - changes = collect_test_changes(facts) - problem = verify_test_changes(changes) - if problem: - return problem +def _verify_characterization_test( + items: list[dict[str, Any]], facts: list[dict[str, Any]], +) -> Optional[str]: + """テストが乏しい項目に、現状固定テストの追加が伴っているか。 + + テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 + """ + if not any(i.get("test_gap") for i in items): + return None + if facts[0].get("touches_tests"): + return None + return ( + "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" + f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" + ) + + +def _verify_diff_budget( + items: list[dict[str, Any]], facts: list[dict[str, Any]], +) -> Optional[str]: + """実差分が見積と手法の倍率から決まる予算に収まっているか。""" estimated = sum(safe_int(i.get("estimated_diff_lines")) for i in items) factor = max( (diff_budget_factor(i.get("technique")) for i in items), @@ -250,10 +240,15 @@ def verify_apply_round( f"実差分 {actual} 行が差分予算 {budget} 行" f"(見積 {estimated} 行 × {factor})を超えました(範囲の逸脱)" ) + return None + - # 粒度は最後に見る。トレーラーや範囲の問題を粒度の失敗で覆い隠さない。 - # 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が - # 申告するのは正しい形なので、重ねた申告では落とさない。 +def _verify_apply_commit_count(facts: list[dict[str, Any]]) -> Optional[str]: + """群が 1 コミットに収まっているか。 + + 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が + 申告するのは正しい形なので、重ねた申告では落とさない。 + """ count = len({c.get("sha") for c in facts}) if count > 1: return ( @@ -264,6 +259,41 @@ def verify_apply_round( return None +def verify_apply_round( + items: list[dict[str, Any]], facts: list[dict[str, Any]], + scope: Optional[Iterable[str]] = None, +) -> Optional[str]: + """適用ラウンド 1 つ分の適用結果を検証する。問題があれば失敗理由を返す。 + + **判定の単位は適用ラウンドである**(決定 3)。群の中は 1 コミットであり、 + 分離しても取り消せないため、**失敗を項目までは特定しない**。1 件の失敗は + 群の全件を巻き込む(「群の中の道連れ」)。分離を細かくしたい利用者は + `--max-items-per-round` を下げる。 + + `facts` は `collect_commit_facts()` が git から作る。振る舞い不変そのものは + ここでは確かめない(テストは `verify-round` が実行する)が、**手順が守られたかは + 結果から確かめられる**。 + + **検査の順序は変えない。** 粒度は最後に見る。トレーラーや範囲の問題を粒度の + 失敗で覆い隠さないためである。テストの期待値の検査(#443)は段 1(機械)で + 決まるものだけを落とし、決まらないものは `pending_test_judgements` が集めて + 進行側が段 2(AI エージェント)へ渡す。 + """ + if not facts: + return ( + "コミットが 1 件もありません" + "(適用ラウンド = 1 コミットの前提を満たしていません)" + ) + + return ( + _verify_all_commits(facts, scope) + or _verify_characterization_test(items, facts) + or verify_test_changes(collect_test_changes(facts)) + or _verify_diff_budget(items, facts) + or _verify_apply_commit_count(facts) + ) + + def commit_limit_for(item: dict[str, Any]) -> int: """その項目が履歴に残せるコミット数。""" if item.get("test_gap"): diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py b/plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py new file mode 100644 index 00000000..60684a33 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py @@ -0,0 +1,184 @@ +"""入口(`refactor.py` の `main`)の解析とディスパッチを現状固定する。 + +**正しさを主張しない。** 各サブコマンドが「どの引数を受け取り、どの関数へ渡されるか」 +という現在の振る舞いをそのまま記録する。登録の書き方を変えても、ここが通れば +利用者から見える CLI は変わっていない。 + +ディスパッチ先は入口の名前空間を差し替えて確かめる。`set_defaults(func=...)` は +`main` の実行時にその名前を引くため、登録の仕方が変わっても同じ手段で見える。 +""" +from __future__ import annotations + +import sys + +import pytest + + +def _dispatch(refactor, monkeypatch, argv: list[str]): + """`main()` を 1 度通し `(呼ばれた関数名, 渡された Namespace)` を返す。""" + called: list[tuple[str, object]] = [] + for name in [n for n in vars(refactor) if n.startswith("cmd_")]: + monkeypatch.setattr( + refactor, name, + lambda args, _name=name: called.append((_name, args)), + ) + monkeypatch.setattr(sys, "argv", ["refactor.py", *argv]) + refactor.main() + assert len(called) == 1, f"呼ばれた関数が 1 つではない: {called}" + return called[0] + + +# ---------- id だけを取るサブコマンド群 ---------- + +ID_ONLY = [ + ("start-round", "cmd_start_round"), + ("merge-proposals", "cmd_merge_proposals"), + ("advance", "cmd_advance"), + ("final-gate", "cmd_final_gate"), + ("merge-final-fix", "cmd_merge_final_fix"), + ("status", "cmd_status"), +] + + +@pytest.mark.parametrize("name,func", ID_ONLY) +def test_id_only_subcommands(refactor, monkeypatch, name, func): + called, args = _dispatch(refactor, monkeypatch, [name, "796"]) + assert called == func + assert args.cmd == name + assert args.id == 796 + assert not hasattr(args, "round") + + +@pytest.mark.parametrize("name,_func", ID_ONLY) +def test_id_only_subcommands_require_id(refactor, monkeypatch, name, _func): + with pytest.raises(SystemExit) as e: + _dispatch(refactor, monkeypatch, [name]) + assert e.value.code == 2 + + +# ---------- id と round を取るサブコマンド群 ---------- + +WITH_ROUND = [ + ("next-apply-round", "cmd_next_apply_round"), + ("verify-round", "cmd_verify_round"), + ("should-abandon", "cmd_should_abandon"), + ("merge-fix", "cmd_merge_fix"), + ("merge-test-judgements", "cmd_merge_test_judgements"), +] + + +@pytest.mark.parametrize("name,func", WITH_ROUND) +def test_round_subcommands(refactor, monkeypatch, name, func): + called, args = _dispatch(refactor, monkeypatch, [name, "796", "3"]) + assert called == func + assert (args.id, args.round) == (796, 3) + assert not hasattr(args, "dry_run") + + +@pytest.mark.parametrize("name,_func", WITH_ROUND) +def test_round_subcommands_require_round(refactor, monkeypatch, name, _func): + with pytest.raises(SystemExit) as e: + _dispatch(refactor, monkeypatch, [name, "796"]) + assert e.value.code == 2 + + +# ---------- 取り消しうるサブコマンド群(--dry-run を持つ) ---------- + +WITH_DRY_RUN = [ + ("merge-apply", "cmd_merge_apply"), + ("abandon-items", "cmd_abandon_items"), +] + + +@pytest.mark.parametrize("name,func", WITH_DRY_RUN) +def test_dry_run_subcommands(refactor, monkeypatch, name, func): + called, args = _dispatch(refactor, monkeypatch, [name, "796", "3"]) + assert called == func + assert (args.id, args.round, args.dry_run) == (796, 3, False) + + _, args = _dispatch(refactor, monkeypatch, [name, "796", "3", "--dry-run"]) + assert args.dry_run is True + + +# ---------- init と report ---------- + +def test_init_defaults(refactor, monkeypatch): + called, args = _dispatch(refactor, monkeypatch, [ + "init", "796", + "--scope", "src", "tests", + "--baseline-test", "pytest -q", + ]) + assert called == "cmd_init" + assert args.pr == 796 + assert args.scope == ["src", "tests"] + assert args.baseline_test == "pytest -q" + assert args.host is None + assert args.max_outer_rounds == 3 + assert args.max_test_rounds == refactor.DEFAULT_MAX_TEST_ROUNDS + assert args.max_fix_rounds == 3 + assert args.max_items_per_round == 5 + assert args.ci_check is None + assert args.severity_threshold == refactor.DEFAULT_SEVERITY_THRESHOLD + assert args.model is None + assert args.test_timeout == refactor.DEFAULT_TEST_TIMEOUT + assert args.sync_command is None + assert args.plan_file is None + assert args.workflow_step is False + assert args.worktree_root is None + + +def test_init_accepts_options(refactor, monkeypatch): + _, args = _dispatch(refactor, monkeypatch, [ + "init", "796", + "--scope", "src", + "--baseline-test", "pytest -q", + "--host", "claude", + "--max-outer-rounds", "4", + "--max-test-rounds", "2", + "--max-fix-rounds", "1", + "--max-items-per-round", "2", + "--ci-check", "tests", + "--severity-threshold", "major", + "--model", "codex=gpt-5.5", "--model", "claude=claude-opus-5", + "--test-timeout", "600", + "--sync-command", "bash scripts/build.sh", + "--plan-file", "issues/plan.md", + "--workflow-step", + "--worktree-root", "/tmp/wt", + ]) + assert args.host == "claude" + assert (args.max_outer_rounds, args.max_test_rounds) == (4, 2) + assert (args.max_fix_rounds, args.max_items_per_round) == (1, 2) + assert args.ci_check == "tests" + assert args.severity_threshold == "major" + assert args.model == ["codex=gpt-5.5", "claude=claude-opus-5"] + assert args.test_timeout == 600 + assert args.sync_command == "bash scripts/build.sh" + assert args.plan_file == "issues/plan.md" + assert args.workflow_step is True + assert args.worktree_root == "/tmp/wt" + + +@pytest.mark.parametrize("argv", [ + ["init", "796", "--baseline-test", "pytest -q"], # --scope が無い + ["init", "796", "--scope", "src"], # --baseline-test が無い +]) +def test_init_requires_scope_and_baseline(refactor, monkeypatch, argv): + with pytest.raises(SystemExit) as e: + _dispatch(refactor, monkeypatch, argv) + assert e.value.code == 2 + + +def test_report(refactor, monkeypatch): + called, args = _dispatch(refactor, monkeypatch, ["report", "796"]) + assert called == "cmd_report" + assert (args.id, args.metrics) == (796, False) + + _, args = _dispatch(refactor, monkeypatch, ["report", "796", "--metrics"]) + assert args.metrics is True + + +def test_subcommand_is_required(refactor, monkeypatch): + with pytest.raises(SystemExit) as e: + _dispatch(refactor, monkeypatch, []) + assert e.value.code == 2 From 2b3d2819dd514ff29c73be3412454cb4beb4dc6b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 01:38:25 +0000 Subject: [PATCH 161/217] =?UTF-8?q?Revert=20"Refactor:=20extract=5Fmethod?= =?UTF-8?q?=20=E2=80=94=20verify/apply/refactor.py=20=E3=81=AE=E6=AE=B5?= =?UTF-8?q?=E3=81=AE=E6=8A=BD=E5=87=BA=E3=81=A8=20commit=5Ffiles=20?= =?UTF-8?q?=E3=81=AE=E7=B5=B1=E5=90=88"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit bcca202062c6da7ee7eb780d97e51f11aea6d93f. --- .../cross-refactoring/scripts/refactor.py | 44 +---- .../scripts/refactor_lib/commands/apply.py | 84 ++++---- .../scripts/refactor_lib/gitfacts.py | 8 +- .../scripts/refactor_lib/verify.py | 108 ++++------ .../tests/test_cli_subcommands.py | 184 ------------------ 5 files changed, 83 insertions(+), 345 deletions(-) delete mode 100644 plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py index 6b6dd5c9..79203a40 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py @@ -84,13 +84,14 @@ def _write_run_summary(path: pathlib.Path, state: dict) -> None: ) -# ---------------- サブコマンドの登録 ---------------- -# -# **ディスパッチ先は登録の時点で引く。** 表を関数の外へ出すと、読み込みの時点で -# 関数オブジェクトが固定され、入口の名前空間を差し替えても効かなくなる。 +# ---------------- main ---------------- + +def main() -> None: + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter + ) + sub = p.add_subparsers(dest="cmd", required=True) -def _add_init_command(sub: argparse._SubParsersAction) -> None: - """`init` を登録する。**この 1 つだけ持つオプションが多い。**""" init = sub.add_parser( "init", help="Step 0 — ホスト確定 / 母集合の確定 / 作業ディレクトリ root / 状態初期化") @@ -153,9 +154,6 @@ def _add_init_command(sub: argparse._SubParsersAction) -> None: init.add_argument("--worktree-root", default=None) init.set_defaults(func=cmd_init) - -def _add_id_commands(sub: argparse._SubParsersAction) -> None: - """進行の番号だけを取るサブコマンド群を登録する。""" for name, func, help_ in ( ("start-round", cmd_start_round, "Step 2 — 提案ラウンドを開く。実装担当とレビュー担当を返す"), @@ -173,9 +171,6 @@ def _add_id_commands(sub: argparse._SubParsersAction) -> None: sp.add_argument("id", type=int) sp.set_defaults(func=func) - -def _add_round_commands(sub: argparse._SubParsersAction) -> None: - """提案ラウンドの番号まで取るサブコマンド群を登録する。""" for name, func, help_ in ( ("next-apply-round", cmd_next_apply_round, "Step 4 — 次の適用ラウンド(群)を開く。実装担当と対象の項目を返す"), @@ -192,12 +187,7 @@ def _add_round_commands(sub: argparse._SubParsersAction) -> None: sp.add_argument("round", type=int) sp.set_defaults(func=func) - -def _add_dry_run_commands(sub: argparse._SubParsersAction) -> None: - """コミットを取り消しうるサブコマンド群を登録する。 - - 実行前に何が消えるかを確かめられるよう、この 2 つだけ `--dry-run` を持つ。 - """ + # コミットを取り消しうる 2 つは、実行前に何が消えるかを確かめられるようにする。 for name, func, help_ in ( ("merge-apply", cmd_merge_apply, "Step 4 — 適用ラウンドの検証(差分予算 / トレーラー / 範囲 / 1 コミット)"), @@ -211,9 +201,6 @@ def _add_dry_run_commands(sub: argparse._SubParsersAction) -> None: help="取り消すコミットを表示するだけで実行しない") sp.set_defaults(func=func) - -def _add_report_command(sub: argparse._SubParsersAction) -> None: - """`report` を登録する。指標の集計だけ任意にする。""" rp = sub.add_parser( "report", help="Step 8 — ラウンド表・項目表・見送り・指標") rp.add_argument("id", type=int) @@ -221,21 +208,6 @@ def _add_report_command(sub: argparse._SubParsersAction) -> None: help="ランタイムとモデルの組で指標を集計する") rp.set_defaults(func=cmd_report) - -# ---------------- main ---------------- - -def main() -> None: - p = argparse.ArgumentParser( - description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter - ) - sub = p.add_subparsers(dest="cmd", required=True) - - _add_init_command(sub) - _add_id_commands(sub) - _add_round_commands(sub) - _add_dry_run_commands(sub) - _add_report_command(sub) - args = p.parse_args() args.func(args) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 33eddfae..de59bab6 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -283,25 +283,36 @@ def _item_summary(item: dict[str, Any]) -> str: -def _find_group_to_open( - groups: list[dict[str, Any]], -) -> tuple[Optional[dict[str, Any]], str]: - """次に開く群と、その開き直しの判定を返す。開ける群が無ければ `(None, "")`。 - - **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 - 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは - 収束ループの前提である。 - - **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を - 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 - ここで取り消し済みにして次を探す。 +def cmd_next_apply_round(args: argparse.Namespace) -> None: + """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 + + 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 + + **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを + 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 + + **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 + ラウンドあたりの上限だからである。 """ + path, state = load_state(args.id) + entry = round_of(state, args.round) + groups = apply_groups(entry) + + # **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 + # 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは + # 収束ループの前提である。 + # + # **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を + # 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 + # ここで取り消し済みにして次を探す。 + opened: Optional[dict[str, Any]] = None reopening = "" for group in groups: if group.get("status") not in {"pending", "applied"}: continue if group.get("status") == "applied": - return group, reopening + opened = group + break reopening = group_reopening(group) if reopening in {"empty", "exhausted"}: group["status"] = "dropped" @@ -313,19 +324,14 @@ def _find_group_to_open( f"({'項目なし' if reopening == 'empty' else '試行の上限'})" ) continue - return group, reopening - return None, reopening + opened = group + break + if opened is None: + statefile.save(path, state) + info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") + sys.exit(1) -def _begin_apply_group( - state: dict[str, Any], entry: dict[str, Any], - opened: dict[str, Any], reopening: str, -) -> None: - """開いた群に合わせて起点・試行の番号・適用の控えを整える。 - - 初回の開始だけが起点と試行の番号を進める。未完の試行の再開と、取り込み済みの - 群の開き直しは**どちらも動かさない**。 - """ entry["apply_round"] = opened["apply_round"] if opened.get("status") == "pending" and reopening == "open": # 起点は**オーケストレータ側で**確定させる。実装担当の申告に委ねると、 @@ -340,38 +346,14 @@ def _begin_apply_group( "applied": [], "failed": [], "base_sha": head, "head_sha": None, "merged_at": None, } - return - if opened.get("status") == "pending": + elif opened.get("status") == "pending": # 開いたまま閉じていない試行の再開。**起点も試行の番号も動かさない。** info(f"↻ 適用ラウンド {opened['apply_round']} の試行を再開します") + entry["apply_base_sha"] = opened.get("base_sha") else: # 取り込み済みの群を開き直した。**起点も修正の回数も動かさない。** info(f"↻ 適用ラウンド {opened['apply_round']} は取り込み済みです(検証から再開)") - entry["apply_base_sha"] = opened.get("base_sha") - - -def cmd_next_apply_round(args: argparse.Namespace) -> None: - """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 - - 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 - - **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを - 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 - - **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 - ラウンドあたりの上限だからである。 - """ - path, state = load_state(args.id) - entry = round_of(state, args.round) - groups = apply_groups(entry) - - opened, reopening = _find_group_to_open(groups) - if opened is None: - statefile.save(path, state) - info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") - sys.exit(1) - - _begin_apply_group(state, entry, opened, reopening) + entry["apply_base_sha"] = opened.get("base_sha") state["phase"] = "apply" statefile.save(path, state) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index fed6abad..4ddb68fa 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -169,13 +169,11 @@ def commit_test_changes(work: str, sha: str) -> dict[str, tuple[list[str], list[ **検証がテストの期待値を見るために要る**(#443)。差分ではなく前後の行を返すのは、 判定が `assert` の行の集合を突き合わせる形だからである。 - - 触れたファイルの列挙は `commit_files` が持つ。git の引数と空行の除外を 2 か所に - 持つと、列挙の仕方を変えるときに片方だけが直される。 """ + out = git_out(work, ["show", "--name-only", "--format=", sha]) changes: dict[str, tuple[list[str], list[str]]] = {} - for path in commit_files(work, sha): - if not _is_test_path(path): + for path in (out or "").splitlines(): + if not path.strip() or not _is_test_path(path): continue before = git_out(work, ["show", f"{sha}^:{path}"]) or "" after = git_out(work, ["show", f"{sha}:{path}"]) or "" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py index 08c3973d..35bc2388 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py @@ -189,10 +189,27 @@ def diff_budget_factor(technique: Optional[str]) -> int: return DIFF_BUDGET_FACTOR -def _verify_all_commits( - facts: list[dict[str, Any]], scope: Optional[Iterable[str]], +def verify_apply_round( + items: list[dict[str, Any]], facts: list[dict[str, Any]], + scope: Optional[Iterable[str]] = None, ) -> Optional[str]: - """群の全コミットが手順を満たしているか。満たさないものがあれば理由を返す。""" + """適用ラウンド 1 つ分の適用結果を検証する。問題があれば失敗理由を返す。 + + **判定の単位は適用ラウンドである**(決定 3)。群の中は 1 コミットであり、 + 分離しても取り消せないため、**失敗を項目までは特定しない**。1 件の失敗は + 群の全件を巻き込む(「群の中の道連れ」)。分離を細かくしたい利用者は + `--max-items-per-round` を下げる。 + + `facts` は `collect_commit_facts()` が git から作る。振る舞い不変そのものは + ここでは確かめない(テストは `verify-round` が実行する)が、**手順が守られたかは + 結果から確かめられる**。 + """ + if not facts: + return ( + "コミットが 1 件もありません" + "(適用ラウンド = 1 コミットの前提を満たしていません)" + ) + for commit in facts: problem = _verify_commit_basics( commit, @@ -203,31 +220,24 @@ def _verify_all_commits( ) if problem: return problem - return None - - -def _verify_characterization_test( - items: list[dict[str, Any]], facts: list[dict[str, Any]], -) -> Optional[str]: - """テストが乏しい項目に、現状固定テストの追加が伴っているか。 - - テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 - """ - if not any(i.get("test_gap") for i in items): - return None - if facts[0].get("touches_tests"): - return None - return ( - "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" - f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" - ) + if any(i.get("test_gap") for i in items): + # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 + if not facts[0].get("touches_tests"): + return ( + "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" + f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" + ) + + # **テストの期待値が変わっていないか**(#443)。段 1(機械)で決まるものだけを + # ここで落とす。決まらないものは `pending_test_judgements` が集め、進行側が + # 段 2(AI エージェント)へ渡す。 + changes = collect_test_changes(facts) + problem = verify_test_changes(changes) + if problem: + return problem -def _verify_diff_budget( - items: list[dict[str, Any]], facts: list[dict[str, Any]], -) -> Optional[str]: - """実差分が見積と手法の倍率から決まる予算に収まっているか。""" estimated = sum(safe_int(i.get("estimated_diff_lines")) for i in items) factor = max( (diff_budget_factor(i.get("technique")) for i in items), @@ -240,15 +250,10 @@ def _verify_diff_budget( f"実差分 {actual} 行が差分予算 {budget} 行" f"(見積 {estimated} 行 × {factor})を超えました(範囲の逸脱)" ) - return None - -def _verify_apply_commit_count(facts: list[dict[str, Any]]) -> Optional[str]: - """群が 1 コミットに収まっているか。 - - 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が - 申告するのは正しい形なので、重ねた申告では落とさない。 - """ + # 粒度は最後に見る。トレーラーや範囲の問題を粒度の失敗で覆い隠さない。 + # 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が + # 申告するのは正しい形なので、重ねた申告では落とさない。 count = len({c.get("sha") for c in facts}) if count > 1: return ( @@ -259,41 +264,6 @@ def _verify_apply_commit_count(facts: list[dict[str, Any]]) -> Optional[str]: return None -def verify_apply_round( - items: list[dict[str, Any]], facts: list[dict[str, Any]], - scope: Optional[Iterable[str]] = None, -) -> Optional[str]: - """適用ラウンド 1 つ分の適用結果を検証する。問題があれば失敗理由を返す。 - - **判定の単位は適用ラウンドである**(決定 3)。群の中は 1 コミットであり、 - 分離しても取り消せないため、**失敗を項目までは特定しない**。1 件の失敗は - 群の全件を巻き込む(「群の中の道連れ」)。分離を細かくしたい利用者は - `--max-items-per-round` を下げる。 - - `facts` は `collect_commit_facts()` が git から作る。振る舞い不変そのものは - ここでは確かめない(テストは `verify-round` が実行する)が、**手順が守られたかは - 結果から確かめられる**。 - - **検査の順序は変えない。** 粒度は最後に見る。トレーラーや範囲の問題を粒度の - 失敗で覆い隠さないためである。テストの期待値の検査(#443)は段 1(機械)で - 決まるものだけを落とし、決まらないものは `pending_test_judgements` が集めて - 進行側が段 2(AI エージェント)へ渡す。 - """ - if not facts: - return ( - "コミットが 1 件もありません" - "(適用ラウンド = 1 コミットの前提を満たしていません)" - ) - - return ( - _verify_all_commits(facts, scope) - or _verify_characterization_test(items, facts) - or verify_test_changes(collect_test_changes(facts)) - or _verify_diff_budget(items, facts) - or _verify_apply_commit_count(facts) - ) - - def commit_limit_for(item: dict[str, Any]) -> int: """その項目が履歴に残せるコミット数。""" if item.get("test_gap"): diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py b/plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py deleted file mode 100644 index 60684a33..00000000 --- a/plugins/ndf/skills/cross-refactoring/tests/test_cli_subcommands.py +++ /dev/null @@ -1,184 +0,0 @@ -"""入口(`refactor.py` の `main`)の解析とディスパッチを現状固定する。 - -**正しさを主張しない。** 各サブコマンドが「どの引数を受け取り、どの関数へ渡されるか」 -という現在の振る舞いをそのまま記録する。登録の書き方を変えても、ここが通れば -利用者から見える CLI は変わっていない。 - -ディスパッチ先は入口の名前空間を差し替えて確かめる。`set_defaults(func=...)` は -`main` の実行時にその名前を引くため、登録の仕方が変わっても同じ手段で見える。 -""" -from __future__ import annotations - -import sys - -import pytest - - -def _dispatch(refactor, monkeypatch, argv: list[str]): - """`main()` を 1 度通し `(呼ばれた関数名, 渡された Namespace)` を返す。""" - called: list[tuple[str, object]] = [] - for name in [n for n in vars(refactor) if n.startswith("cmd_")]: - monkeypatch.setattr( - refactor, name, - lambda args, _name=name: called.append((_name, args)), - ) - monkeypatch.setattr(sys, "argv", ["refactor.py", *argv]) - refactor.main() - assert len(called) == 1, f"呼ばれた関数が 1 つではない: {called}" - return called[0] - - -# ---------- id だけを取るサブコマンド群 ---------- - -ID_ONLY = [ - ("start-round", "cmd_start_round"), - ("merge-proposals", "cmd_merge_proposals"), - ("advance", "cmd_advance"), - ("final-gate", "cmd_final_gate"), - ("merge-final-fix", "cmd_merge_final_fix"), - ("status", "cmd_status"), -] - - -@pytest.mark.parametrize("name,func", ID_ONLY) -def test_id_only_subcommands(refactor, monkeypatch, name, func): - called, args = _dispatch(refactor, monkeypatch, [name, "796"]) - assert called == func - assert args.cmd == name - assert args.id == 796 - assert not hasattr(args, "round") - - -@pytest.mark.parametrize("name,_func", ID_ONLY) -def test_id_only_subcommands_require_id(refactor, monkeypatch, name, _func): - with pytest.raises(SystemExit) as e: - _dispatch(refactor, monkeypatch, [name]) - assert e.value.code == 2 - - -# ---------- id と round を取るサブコマンド群 ---------- - -WITH_ROUND = [ - ("next-apply-round", "cmd_next_apply_round"), - ("verify-round", "cmd_verify_round"), - ("should-abandon", "cmd_should_abandon"), - ("merge-fix", "cmd_merge_fix"), - ("merge-test-judgements", "cmd_merge_test_judgements"), -] - - -@pytest.mark.parametrize("name,func", WITH_ROUND) -def test_round_subcommands(refactor, monkeypatch, name, func): - called, args = _dispatch(refactor, monkeypatch, [name, "796", "3"]) - assert called == func - assert (args.id, args.round) == (796, 3) - assert not hasattr(args, "dry_run") - - -@pytest.mark.parametrize("name,_func", WITH_ROUND) -def test_round_subcommands_require_round(refactor, monkeypatch, name, _func): - with pytest.raises(SystemExit) as e: - _dispatch(refactor, monkeypatch, [name, "796"]) - assert e.value.code == 2 - - -# ---------- 取り消しうるサブコマンド群(--dry-run を持つ) ---------- - -WITH_DRY_RUN = [ - ("merge-apply", "cmd_merge_apply"), - ("abandon-items", "cmd_abandon_items"), -] - - -@pytest.mark.parametrize("name,func", WITH_DRY_RUN) -def test_dry_run_subcommands(refactor, monkeypatch, name, func): - called, args = _dispatch(refactor, monkeypatch, [name, "796", "3"]) - assert called == func - assert (args.id, args.round, args.dry_run) == (796, 3, False) - - _, args = _dispatch(refactor, monkeypatch, [name, "796", "3", "--dry-run"]) - assert args.dry_run is True - - -# ---------- init と report ---------- - -def test_init_defaults(refactor, monkeypatch): - called, args = _dispatch(refactor, monkeypatch, [ - "init", "796", - "--scope", "src", "tests", - "--baseline-test", "pytest -q", - ]) - assert called == "cmd_init" - assert args.pr == 796 - assert args.scope == ["src", "tests"] - assert args.baseline_test == "pytest -q" - assert args.host is None - assert args.max_outer_rounds == 3 - assert args.max_test_rounds == refactor.DEFAULT_MAX_TEST_ROUNDS - assert args.max_fix_rounds == 3 - assert args.max_items_per_round == 5 - assert args.ci_check is None - assert args.severity_threshold == refactor.DEFAULT_SEVERITY_THRESHOLD - assert args.model is None - assert args.test_timeout == refactor.DEFAULT_TEST_TIMEOUT - assert args.sync_command is None - assert args.plan_file is None - assert args.workflow_step is False - assert args.worktree_root is None - - -def test_init_accepts_options(refactor, monkeypatch): - _, args = _dispatch(refactor, monkeypatch, [ - "init", "796", - "--scope", "src", - "--baseline-test", "pytest -q", - "--host", "claude", - "--max-outer-rounds", "4", - "--max-test-rounds", "2", - "--max-fix-rounds", "1", - "--max-items-per-round", "2", - "--ci-check", "tests", - "--severity-threshold", "major", - "--model", "codex=gpt-5.5", "--model", "claude=claude-opus-5", - "--test-timeout", "600", - "--sync-command", "bash scripts/build.sh", - "--plan-file", "issues/plan.md", - "--workflow-step", - "--worktree-root", "/tmp/wt", - ]) - assert args.host == "claude" - assert (args.max_outer_rounds, args.max_test_rounds) == (4, 2) - assert (args.max_fix_rounds, args.max_items_per_round) == (1, 2) - assert args.ci_check == "tests" - assert args.severity_threshold == "major" - assert args.model == ["codex=gpt-5.5", "claude=claude-opus-5"] - assert args.test_timeout == 600 - assert args.sync_command == "bash scripts/build.sh" - assert args.plan_file == "issues/plan.md" - assert args.workflow_step is True - assert args.worktree_root == "/tmp/wt" - - -@pytest.mark.parametrize("argv", [ - ["init", "796", "--baseline-test", "pytest -q"], # --scope が無い - ["init", "796", "--scope", "src"], # --baseline-test が無い -]) -def test_init_requires_scope_and_baseline(refactor, monkeypatch, argv): - with pytest.raises(SystemExit) as e: - _dispatch(refactor, monkeypatch, argv) - assert e.value.code == 2 - - -def test_report(refactor, monkeypatch): - called, args = _dispatch(refactor, monkeypatch, ["report", "796"]) - assert called == "cmd_report" - assert (args.id, args.metrics) == (796, False) - - _, args = _dispatch(refactor, monkeypatch, ["report", "796", "--metrics"]) - assert args.metrics is True - - -def test_subcommand_is_required(refactor, monkeypatch): - with pytest.raises(SystemExit) as e: - _dispatch(refactor, monkeypatch, []) - assert e.value.code == 2 From f33ecfd6d385600009ecacdc25f0825923ab30b4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 01:43:06 +0000 Subject: [PATCH 162/217] =?UTF-8?q?Refactor:=20consolidate=5Fduplication?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-refactoring/scripts/ref?= =?UTF-8?q?actor=5Flib/verify.py#merge=5Ftest=5Fjudgements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 段2の判定結果をパス別の辞書へ変換する処理を共通化する。 Item-Id: R4-002 Round: 4 Impl-Runtime: codex Impl-Model: default --- .../scripts/refactor_lib/verify.py | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py index 35bc2388..c6bd02ff 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py @@ -415,6 +415,15 @@ def pending_test_judgements(facts: Iterable[dict[str, Any]]) -> list[str]: return undecidable_test_changes(changes) +def _answers_by_path(verdicts: Iterable[dict[str, Any]]) -> dict[str, str]: + """段 2 の答えを、対象ファイルから引ける形にする。""" + return { + str(verdict.get("path")): str(verdict.get("verdict")) + for verdict in verdicts + if isinstance(verdict, dict) and verdict.get("path") + } + + def merge_test_judgements( pending: Iterable[str], verdicts: Iterable[dict[str, Any]], ) -> dict[str, Any]: @@ -428,10 +437,7 @@ def merge_test_judgements( **答えが欠けたものを `unchanged` に倒さない。** 倒すと、判定を返さないことが 通過の手段になる。知らない答えも同じ扱いにする。 """ - answers = { - str(v.get("path")): str(v.get("verdict")) - for v in verdicts if isinstance(v, dict) and v.get("path") - } + answers = _answers_by_path(verdicts) changed = sorted(p for p in pending if answers.get(p) == "changed") if changed: return { @@ -487,14 +493,10 @@ def apply_judgements_to_group( records = entry.get("pending_test_judgements") if not isinstance(records, dict): return [] - answers = { - str(v.get("path")): str(v.get("verdict")) - for v in verdicts if isinstance(v, dict) and v.get("path") - } + answers = _answers_by_path(verdicts) remaining = sorted( path for path in records.get(str(group), []) if answers.get(path) != "unchanged" ) record_pending_judgements(entry, group, remaining) return remaining - From 6bcbd6b04ebb6af6d5da4575b258a0d162d9b831 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 02:05:05 +0000 Subject: [PATCH 163/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20converge.py=20/=20apply.py=20/=20proposals.py=20/?= =?UTF-8?q?=20gitfacts.py?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 提案ラウンド 5(適用ラウンド 1)の構造改善を適用: - R5-001: converge.py の cmd_abandon_items から _record_deferred_abandoned_items を抽出 - R5-002: apply.py の cmd_merge_apply から事前検査・処理済み制御・反映処理を抽出してオーケストレーター化 - R5-003: proposals.py の _normalize_test_proposal における語彙外降格処理を _degrade_test_value へ集約 - R5-004: gitfacts.py の check_run_result をパイプライン関数群に分割し、現状固定テストを追加 Item-Id: R5-001 Round: 5 Impl-Runtime: agy Impl-Model: default --- .../scripts/refactor_lib/commands/apply.py | 135 +++++++++++------- .../scripts/refactor_lib/commands/converge.py | 27 ++-- .../scripts/refactor_lib/gitfacts.py | 62 +++++--- .../scripts/refactor_lib/proposals.py | 36 +++-- .../cross-refactoring/tests/test_git_facts.py | 88 ++++++++++++ 5 files changed, 254 insertions(+), 94 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index de59bab6..b520f96b 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -371,6 +371,83 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: ) +def _check_already_merged_apply(ctx: _ApplyExecutionContext) -> bool: + """取り込み済み判定を行い、再実行を制御する。処理済みなら True を返す。 + + **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った + 取り消しコミットが「未割当」と判定され、群ごと取り消してしまう。 + """ + record = ctx.entry.get("apply") or {} + if record.get("merged_at") and record.get("apply_round", ctx.group["apply_round"]) \ + == ctx.group["apply_round"]: + applied_before = record.get("applied") or [] + info( + f"↻ 適用ラウンド {ctx.group['apply_round']} の適用は取り込み済みです" + f"(採用 {len(applied_before)} 件 / 失敗 " + f"{len(record.get('failed') or [])} 件)" + ) + if not applied_before: + # **採用 0 件の群は取り消し済みに直す**(#592)。残したままだと、 + # 次に群を開く操作がこの群を選び直して担当を起動し続ける。 + ctx.group["status"] = "dropped" + ctx.group.setdefault("drop_reason", "empty") + ctx.state["phase"] = phase_after_group(ctx.entry) + if not ctx.args.dry_run: + statefile.save(ctx.path, ctx.state) + sys.exit(2) + return True + return False + + +def _verify_baseline_test_gate(ctx: _ApplyExecutionContext) -> None: + """着手前テスト結果 (baseline) を検証し、成功でなければ適用をブロックする。 + + **着手前のテストの確認は、結果を読むより先に行う。** 成功と確認できていない + 状態で採ると、壊したのか元から壊れていたのかを判別する手段が無い。`red` だけ + でなく `unknown`(確認していない)も拒否する。 + """ + baseline = ctx.state.get("baseline_test") or {} + if baseline.get("status") != "green": + _block_group_items(ctx) + die( + f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" + "適用へ着手しません(全項目を blocked)", + code=4, + ) + + +def _finalize_apply_result( + ctx: _ApplyExecutionContext, + record: dict[str, Any], + failed: list[str], +) -> None: + """検証結果に応じて状態更新、取り消し、または公開プッシュを反映する。""" + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if ctx.args.dry_run: + if failed: + drop_items(ctx.state, ctx.entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(record["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(ctx.path, ctx.state, ctx.entry, ctx.group, failed) + else: + # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 + # ここで公開しないと Pull Request 上の差分が古いままになる。 + ctx.group["status"] = "applied" + # 次は `verify-round` がテストで検証する。ここではまだ群を閉じない。 + ctx.state["phase"] = "verify" + record["merged_at"] = statefile.now() + # 保留の印・保存・push・印の解除は 1 か所が持つ(`push_with_retry_marker`)。 + push_with_retry_marker(ctx.path, ctx.state, ctx.entry) + applied = list(record["applied"]) + + if not applied: + info("この適用ラウンドは取り消しました。検証は行いません") + sys.exit(2) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用ラウンド 1 つ分の適用結果を検証して取り込む。 @@ -393,39 +470,10 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: discard_impl_leftovers(state, state["worktrees"]["work"]) _resume_incomplete_apply(path, state, entry) - # **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った - # 取り消しコミットが「未割当」と判定され、群ごと取り消してしまう。 - record = entry.get("apply") or {} - if record.get("merged_at") and record.get("apply_round", group["apply_round"]) \ - == group["apply_round"]: - applied_before = record.get("applied") or [] - info( - f"↻ 適用ラウンド {group['apply_round']} の適用は取り込み済みです" - f"(採用 {len(applied_before)} 件 / 失敗 " - f"{len(record.get('failed') or [])} 件)" - ) - if not applied_before: - # **採用 0 件の群は取り消し済みに直す**(#592)。残したままだと、 - # 次に群を開く操作がこの群を選び直して担当を起動し続ける。 - group["status"] = "dropped" - group.setdefault("drop_reason", "empty") - state["phase"] = phase_after_group(entry) - if not args.dry_run: - statefile.save(path, state) - sys.exit(2) + if _check_already_merged_apply(ctx): return - # **着手前のテストの確認は、結果を読むより先に行う。** 成功と確認できていない - # 状態で採ると、壊したのか元から壊れていたのかを判別する手段が無い。`red` だけ - # でなく `unknown`(確認していない)も拒否する。 - baseline = state.get("baseline_test") or {} - if baseline.get("status") != "green": - _block_group_items(ctx) - die( - f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" - "適用へ着手しません(全項目を blocked)", - code=4, - ) + _verify_baseline_test_gate(ctx) scope = _apply_scope(ctx) if already_closed(scope): @@ -449,30 +497,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: ) record = _record_apply_result(entry, group, commit_range, applied, failed, payload) - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(record["applied"]) - elif failed: - # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 - applied = _apply_drop(path, state, entry, group, failed) - else: - # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 - # ここで公開しないと Pull Request 上の差分が古いままになる。 - group["status"] = "applied" - # 次は `verify-round` がテストで検証する。ここではまだ群を閉じない。 - state["phase"] = "verify" - record["merged_at"] = statefile.now() - # 保留の印・保存・push・印の解除は 1 か所が持つ(`push_with_retry_marker`)。 - push_with_retry_marker(path, state, entry) - - if not applied: - info("この適用ラウンドは取り消しました。検証は行いません") - sys.exit(2) + _finalize_apply_result(ctx, record, failed) def _record_apply_result( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index b563dd3a..1516d06b 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -183,6 +183,22 @@ def cmd_should_abandon(args: argparse.Namespace) -> None: sys.exit(2) +def _record_deferred_abandoned_items( + state: dict[str, Any], targets: list[str] +) -> None: + """取り消し対象項目の status を abandoned に更新し、未登録なら deferred_items に追記する。""" + already = {d.get("item_id") for d in state["deferred_items"]} + for item_id in targets: + item = find_item(state, item_id) + item["status"] = "abandoned" + item.setdefault( + "failure_reason", "修正ラウンドの上限に達してもテストが通らなかった") + if item_id in already: + continue + state["deferred_items"].append( + deferred_record(item, item_id, item["failure_reason"])) + + def cmd_abandon_items(args: argparse.Namespace) -> None: """Step 6 — テストが通らなかった適用ラウンドを取り消す。 @@ -224,16 +240,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: run_drop(path, state, entry, targets) - already = {d.get("item_id") for d in state["deferred_items"]} - for item_id in targets: - item = find_item(state, item_id) - item["status"] = "abandoned" - item.setdefault( - "failure_reason", "修正ラウンドの上限に達してもテストが通らなかった") - if item_id in already: - continue - state["deferred_items"].append( - deferred_record(item, item_id, item["failure_reason"])) + _record_deferred_abandoned_items(state, targets) # 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 # push が失敗しても記録とローカルの git が食い違わない。 diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index 4ddb68fa..83f8d9eb 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -409,6 +409,43 @@ def resolved_threads_on_github(repo: str, pr: int) -> Optional[set[str]]: CHECK_RUNS_PER_PAGE = 100 +def _parse_check_runs(raw_json: Optional[str]) -> Optional[list[dict[str, Any]]]: + """API 出力から check_runs のリストを検証して返す。""" + if not raw_json: + return None + try: + body = json.loads(raw_json) + except json.JSONDecodeError: + return None + runs = body.get("check_runs") if isinstance(body, dict) else None + if not isinstance(runs, list): + return None + return [r for r in runs if isinstance(r, dict)] + + +def _filter_check_runs_by_name( + runs: list[dict[str, Any]], name: str +) -> list[dict[str, Any]]: + """名前が一致する run を選別する。""" + return [ + r for r in runs + if str(r.get("name") or "") == name + ] + + +def _aggregate_check_run_results(matched: list[dict[str, Any]]) -> Optional[str]: + """matched runs を pending・失敗結論・success の順で集約する。""" + if not matched: + return None + if any(str(r.get("status") or "").lower() != "completed" for r in matched): + return "pending" + for run in matched: + conclusion = str(run.get("conclusion") or "").lower() + if conclusion != "success": + return conclusion or "unknown" + return "success" + + def check_run_result(repo: str, sha: str, name: str) -> Optional[str]: """名前が一致した検査ジョブの結果を 1 つの語で返す。 @@ -427,28 +464,11 @@ def check_run_result(repo: str, sha: str, name: str) -> Optional[str]: f"?per_page={CHECK_RUNS_PER_PAGE}"], check=False, ) - if not out: + runs = _parse_check_runs(out) + if runs is None: return None - try: - body = json.loads(out) - except json.JSONDecodeError: - return None - runs = body.get("check_runs") if isinstance(body, dict) else None - if not isinstance(runs, list): - return None - matched = [ - r for r in runs - if isinstance(r, dict) and str(r.get("name") or "") == name - ] - if not matched: - return None - if any(str(r.get("status") or "").lower() != "completed" for r in matched): - return "pending" - for run in matched: - conclusion = str(run.get("conclusion") or "").lower() - if conclusion != "success": - return conclusion or "unknown" - return "success" + matched = _filter_check_runs_by_name(runs, name) + return _aggregate_check_run_results(matched) def revert_item_commits( diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index 358757a4..8ec8855e 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -206,6 +206,20 @@ def _select( return adopted, deferred +def _degrade_test_value( + value: str, + allowed: Iterable[str], + source: str, + label: str, + target: str, +) -> str: + """語彙集合に含まれないテスト提案の値を `unknown` へ降格する。""" + if value not in allowed: + info(f"⚠ {source}: 語彙外の{label} `{value}` — unknown へ降格 ({target})") + return "unknown" + return value + + def _normalize_test_proposal( raw: dict[str, Any], source: str ) -> Optional[dict[str, Any]]: @@ -222,14 +236,20 @@ def _normalize_test_proposal( info(f"⚠ {source}: path / target の無いテスト項目を無視しました: {raw!r:.120}") return None - case = str(raw.get("case") or "").strip().lower() - level = str(raw.get("level") or "").strip().lower() - if case not in TEST_CASES: - info(f"⚠ {source}: 語彙外の経路 `{case}` — unknown へ降格 ({target})") - case = "unknown" - if level not in TEST_LEVELS: - info(f"⚠ {source}: 語彙外の階層 `{level}` — unknown へ降格 ({target})") - level = "unknown" + case = _degrade_test_value( + str(raw.get("case") or "").strip().lower(), + TEST_CASES, + source, + "経路", + target, + ) + level = _degrade_test_value( + str(raw.get("level") or "").strip().lower(), + TEST_LEVELS, + source, + "階層", + target, + ) return { "kind": TEST, diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py index a079eec3..6b5e65b1 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import json import subprocess import pytest @@ -390,3 +391,90 @@ def test_revert_range_failure_message_has_no_item_id_prefix(gitfacts, work, caps assert "を取り消せませんでした" in err assert f"(HEAD を {second} へ戻しました)" in err assert _git("rev-parse", "HEAD", cwd=work).stdout.strip() == second + + +def test_check_run_result_characterization(gitfacts, monkeypatch): + """check_run_result の公開契約を固定する現状固定テスト。""" + # 1. 引数が空なら None + assert gitfacts.check_run_result("", "sha", "ci") is None + assert gitfacts.check_run_result("repo", "", "ci") is None + assert gitfacts.check_run_result("repo", "sha", "") is None + + # 2. gh api の実行失敗(sh が None または空)なら None + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: None) + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: "") + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 3. 不正 JSON なら None + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: "not-json{") + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 4. check_runs 欠損(非 dict、または check_runs がリストでない)なら None + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: "[]") + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: json.dumps({"check_runs": "not-a-list"})) + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 5. 対象名なし(一致する name がない)なら None + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [{"name": "other", "status": "completed", "conclusion": "success"}] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 6. 未完了(status != completed)なら "pending" + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "in_progress", "conclusion": None}, + {"name": "ci", "status": "completed", "conclusion": "success"}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "pending" + + # 7. 失敗(completed だが conclusion != success)ならその結論(または unknown) + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "completed", "conclusion": "failure"}, + {"name": "ci", "status": "completed", "conclusion": "success"}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "failure" + + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "completed", "conclusion": None}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "unknown" + + # 8. 全成功なら "success" + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "completed", "conclusion": "success"}, + {"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "success" + From fd30c8a197da7ba5302ce566dd4dfce726dc94e3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 02:20:57 +0000 Subject: [PATCH 164/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-refactoring=20=E3=81=AE=E9=95=B7=E3=81=84?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E3=81=AE=E6=AE=B5=E3=81=AE=E6=8A=BD=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用ラウンド 6(提案ラウンド 6)の構造改善 5 件をまとめて適用する。振る舞いは変えない。 - R6-001 gitfacts.py#drop_items: 旧版フォールバックを _drop_legacy_by_item へ抽出 - R6-002 report.py#cmd_report: 見出し・見送り・指標・run_metrics を _print_* へ抽出 - R6-003 converge.py#cmd_merge_fix: 結果取得・範囲確定と検証・記録と公開を抽出 - R6-004 apply.py#_verify_apply_group: 事実収集・問題判定・記録の 3 段を抽出 - R6-005 proposals.py: _degrade_test_value を廃し _degrade_if_unknown を location 引数で一般化 Item-Id: R6-001 Round: 6 Impl-Runtime: kiro Impl-Model: default --- .../scripts/refactor_lib/commands/apply.py | 58 +++++++++--- .../scripts/refactor_lib/commands/converge.py | 89 +++++++++++++------ .../scripts/refactor_lib/commands/report.py | 54 +++++++---- .../scripts/refactor_lib/gitfacts.py | 24 +++-- .../scripts/refactor_lib/proposals.py | 35 +++----- 5 files changed, 171 insertions(+), 89 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index b520f96b..e89feec9 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -837,22 +837,17 @@ def _record_apply_progress( }) -def _verify_apply_group( +def _collect_apply_group_facts( ctx: _ApplyExecutionContext, commit_range: _ApplyCommitRange, reported: dict[str, dict[str, Any]], -) -> tuple[list[str], list[str]]: - """適用ラウンドをまとめて検証し `(採用, 失敗)` を返す。 +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """群の申告から `(欠落項目, 申告 SHA, コミット事実)` を組み立てる。 - **判定は全件同時である**(決定 3)。群の中は 1 コミットなので、失敗を項目まで - 特定しても取り消しは分離できない。 + **群の全項目が同じコミットを申告する。** 申告の無い項目は、適用されたことを + 確かめる手がかりが無い。群の中は 1 コミットなので、1 件の欠落が群の全件を + 巻き込む(「群の中の道連れ」)。 """ - scope = ctx.state.get("target_scope") or [] - items = [find_item(ctx.state, i) for i in ctx.group["items"]] - - # **群の全項目が同じコミットを申告する。** 申告の無い項目は、適用されたことを - # 確かめる手がかりが無い。群の中は 1 コミットなので、1 件の欠落が群の全件を - # 巻き込む(「群の中の道連れ」)。 missing = [ i for i in ctx.group["items"] if not reported_shas(reported.get(i) or {}) ] @@ -863,14 +858,33 @@ def _verify_apply_group( commit_range.work, shas, commit_range.in_range, "", ctx.state["head_branch"], safe_int(ctx.state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) + return missing, shas, facts + + +def _determine_apply_problem( + ctx: _ApplyExecutionContext, + items: list[dict[str, Any]], + missing: list[str], + facts: list[dict[str, Any]], +) -> str: + """欠落と `verify_apply_round` から、この適用ラウンドの問題点を決める。""" if missing: - problem = ( + return ( f"適用結果に項目がありません: {', '.join(missing)}" "(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します)" ) - else: - problem = verify_apply_round(items, facts, scope) + scope = ctx.state.get("target_scope") or [] + return verify_apply_round(items, facts, scope) + +def _record_apply_group_outcome( + ctx: _ApplyExecutionContext, + items: list[dict[str, Any]], + shas: list[str], + facts: list[dict[str, Any]], + problem: str, +) -> None: + """保留判断・項目状態・進捗を記録し、結果を出力して保存する。""" # **機械で決まらなかったテストの差分を記録する**(#443)。落とさないが、 # 通ったものとしても扱わない。進行側がこれを見て段 2(`judge-test-changes`)を # 起動する。**空でないまま収束させない。** @@ -893,6 +907,22 @@ def _verify_apply_group( ) if not ctx.args.dry_run: statefile.save(ctx.path, ctx.state) + + +def _verify_apply_group( + ctx: _ApplyExecutionContext, + commit_range: _ApplyCommitRange, + reported: dict[str, dict[str, Any]], +) -> tuple[list[str], list[str]]: + """適用ラウンドをまとめて検証し `(採用, 失敗)` を返す。 + + **判定は全件同時である**(決定 3)。群の中は 1 コミットなので、失敗を項目まで + 特定しても取り消しは分離できない。 + """ + items = [find_item(ctx.state, i) for i in ctx.group["items"]] + missing, shas, facts = _collect_apply_group_facts(ctx, commit_range, reported) + problem = _determine_apply_problem(ctx, items, missing, facts) + _record_apply_group_outcome(ctx, items, shas, facts, problem) if problem: return [], list(ctx.group["items"]) return list(ctx.group["items"]), [] diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index 1516d06b..e1002685 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -525,40 +525,36 @@ def _close_failed_fix( sys.exit(2) -def cmd_merge_fix(args: argparse.Namespace) -> None: - """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。 - - 終了コード: 0 = 取り込んだ / 2 = 範囲を確定できない、または担当が結果を - 残さなかった(どちらも修正ラウンドは進む) / 4 = 群が無い。 +def _fetch_fix_result( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + scope: IntakeScope, impl: str, round_no: int, +) -> tuple[Optional[dict[str, Any]], Optional[str]]: + """修正結果を取得し、`(payload, merge_key)` を返す。 + + 結果を残さなかった試行は `_close_failed_fix` が終了させる。取り込み済みの + 結果なら `(None, None)` を返し、呼び出し側が何もせず戻れるようにする。 """ - path, state = load_state(args.id) - entry = round_of(state, args.round) - discard_impl_leftovers(state, state["worktrees"]["work"]) - flush_pending_push(path, state, entry) - # **担当は群から読む。** 骨組みが起動するのは群の担当であり、提案ラウンドの - # 担当とは限らない。食い違うと結果ファイルを一度も引けない(#728 の決定 10)。 - group = current_group(entry) - impl = group.get("impl") or entry["impl"] - scope = _fix_scope(entry, impl) - if already_closed(scope): - info("↻ この修正の試行は結果なしとして記録済みです") - sys.exit(2) - - outcome = read_result(state, impl, "fix", args.round) + outcome = read_result(state, impl, "fix", round_no) if outcome.payload is None: _close_failed_fix(path, state, entry, scope, outcome) payload = outcome.payload - work = state["worktrees"]["work"] - head_now = git_out(work, ["rev-parse", "HEAD"]) or "" - result = result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) + result = result_path(state, impl, stem_for(impl, "fix", state["id"], round_no)) merge_key = _fix_merge_key(entry, result) if _already_merged_fix_result(entry, merge_key): - return - merged_keys = entry["fix_merged_keys"] + return None, None + return payload, merge_key - resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) +def _confirm_and_settle_fix( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + scope: IntakeScope, work: str, head_now: str, payload: dict[str, Any], +) -> set[str]: + """Git 範囲を確定し、修正コミットを検証して取り消すか受理する。 + + 採用した解決スレッドの集合を返す(取り込みの通知に使う)。 + """ + resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) baseline = state.get("baseline_test") or {} ordered_range = _resolve_fix_range(path, state, entry, work, head_now) unassigned, problems, accepted = _inspect_fix_commits( @@ -568,10 +564,16 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: path, state, entry, scope, ordered_range, resolved, unassigned, problems, accepted, ) + return resolved - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 +def _record_and_publish_fix( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + merge_key: str, payload: dict[str, Any], resolved: set[str], +) -> None: + """取り込み済みの鍵・修正回数・所要時間を記録し、保存して公開する。""" + entry["fix_merged_keys"].append(merge_key) + entry["fix_rounds"] += 1 entry.setdefault("durations", {})["fix"] = ( entry.get("durations", {}).get("fix", 0) + safe_int(payload.get("elapsed_seconds")) @@ -584,3 +586,36 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"修正を取り込みました(解決 {len(resolved)} スレッド / " f"修正ラウンド {entry['fix_rounds']})。{plan_line(state)}" ) + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。 + + 終了コード: 0 = 取り込んだ / 2 = 範囲を確定できない、または担当が結果を + 残さなかった(どちらも修正ラウンドは進む) / 4 = 群が無い。 + """ + path, state = load_state(args.id) + entry = round_of(state, args.round) + discard_impl_leftovers(state, state["worktrees"]["work"]) + flush_pending_push(path, state, entry) + # **担当は群から読む。** 骨組みが起動するのは群の担当であり、提案ラウンドの + # 担当とは限らない。食い違うと結果ファイルを一度も引けない(#728 の決定 10)。 + group = current_group(entry) + impl = group.get("impl") or entry["impl"] + scope = _fix_scope(entry, impl) + if already_closed(scope): + info("↻ この修正の試行は結果なしとして記録済みです") + sys.exit(2) + + payload, merge_key = _fetch_fix_result( + path, state, entry, scope, impl, args.round + ) + if payload is None: + return + + work = state["worktrees"]["work"] + head_now = git_out(work, ["rev-parse", "HEAD"]) or "" + resolved = _confirm_and_settle_fix( + path, state, entry, scope, work, head_now, payload + ) + _record_and_publish_fix(path, state, entry, merge_key, payload, resolved) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py index 06cc9626..955bfea4 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py @@ -116,6 +116,28 @@ def cmd_status(args: argparse.Namespace) -> None: def cmd_report(args: argparse.Namespace) -> None: """Step 8 — ラウンド表・項目表・見送り項目・指標を出す。""" path, state = load_state(args.id) + _print_header(state) + print() + print("## ラウンド") + print() + print(_round_table(state)) + print() + print("## 改善項目") + print() + print(_item_table(state)) + # **取り消した項目の内訳は書かない**(#436 決定 6-b)。件数だけ述べ、内訳は + # 改修計画へ譲る。同じ一覧を 2 か所に置くと、片方だけが古くなる。 + print() + _print_deferred(state) + if args.metrics: + _print_metrics(state) + # **最後の行に置く**(#662 の AC23)。作業ツリーを消した後に要約を探す手がかりになる。 + print() + _print_run_metrics(path, state) + + +def _print_header(state: dict[str, Any]) -> None: + """見出し行と実行メタ情報(対象範囲・終了理由・改修計画・着手前テスト等)を出す。""" print(f"# cross-refactoring 実行報告 — {state['repo']} #{state['current_pr']}") print() print(f"- ホスト: {state['host']}({state['host_detection']})") @@ -134,28 +156,26 @@ def cmd_report(args: argparse.Namespace) -> None: print(f"- 最終ゲート: {gate.get('mode') or '—'}" f"({gate.get('status') or '未実行'}" f" / 修正 {gate.get('fix_rounds', 0)} 回)") - print() - print("## ラウンド") - print() - print(_round_table(state)) - print() - print("## 改善項目") - print() - print(_item_table(state)) - # **取り消した項目の内訳は書かない**(#436 決定 6-b)。件数だけ述べ、内訳は - # 改修計画へ譲る。同じ一覧を 2 か所に置くと、片方だけが古くなる。 - print() + + +def _print_deferred(state: dict[str, Any]) -> None: + """見送り節(件数と改修計画への参照)を出す。""" print("## 見送った提案") print() print(f"- 件数: {len(state['deferred_items'])} 件") print(f"- 内訳: 改修計画にある — {plan_reference(state)}") - if args.metrics: - print() - print("# 指標") - print() - print(metrics_lib.format_report(metrics_lib.aggregate(state))) - # **最後の行に置く**(#662 の AC23)。作業ツリーを消した後に要約を探す手がかりになる。 + + +def _print_metrics(state: dict[str, Any]) -> None: + """指標節を出す(`args.metrics` が真のときだけ呼ぶ)。""" + print() + print("# 指標") print() + print(metrics_lib.format_report(metrics_lib.aggregate(state))) + + +def _print_run_metrics(path: pathlib.Path, state: dict[str, Any]) -> None: + """run_metrics の要約 1 行を出す。""" print(run_metrics.report_line(path, state, "cross-refactoring", summary_extra)) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index 83f8d9eb..740b49f0 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -695,6 +695,22 @@ def _record_drop_result( "reverted": len(ordered), "replayed": len(mapping)} +def _drop_legacy_by_item( + state: dict[str, Any], pending: list[str], dry_run: bool = False, +) -> dict[str, Any]: + """起点を記録していない状態ファイル(旧版)で、項目のコミットだけを戻す。 + + 積み直しの起点(`apply_base_sha`)が無いため範囲を確定できない。従来どおり + 項目のコミットを新しい順に取り消すだけで、残す項目の積み直しは行わない。 + """ + info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") + reverted = 0 + for item_id in pending: + reverted += revert_item_commits(state, find_item(state, item_id), dry_run) + return {"mode": "item", "dropped": pending, + "reverted": reverted, "replayed": 0} + + def drop_items( state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], dry_run: bool = False, @@ -727,13 +743,7 @@ def drop_items( ordered = commits_in_range(work, entry.get("apply_base_sha"), head or "HEAD") if ordered is None: # 起点を記録していない状態ファイル(旧版)では積み直せない。 - # 従来どおり項目のコミットだけを新しい順に戻す。 - info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") - reverted = 0 - for item_id in pending: - reverted += revert_item_commits(state, find_item(state, item_id), dry_run) - return {"mode": "item", "dropped": pending, - "reverted": reverted, "replayed": 0} + return _drop_legacy_by_item(state, pending, dry_run) owner, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index 8ec8855e..69e3c5e8 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -26,17 +26,18 @@ def _degrade_if_unknown( allowed: Iterable[str], source: str, label: str, - path: str, - symbol: str, + location: str, ) -> tuple[str, bool]: """語彙集合に含まれない値を `unknown` へ降格する。 降格したときは警告を出し `(unknown, True)` を返す。含まれていれば値をそのまま - `(value, False)` で返す。`smell` / `technique` / `severity` の同じ降格ルールを - 1 箇所に集め、警告文や降格処理の変更が 3 箇所へ散らばらないようにする。 + `(value, False)` で返す。`smell` / `technique` / `severity` の同じ降格ルールと、 + テスト提案の `case` / `level` の降格を 1 箇所に集め、警告文や降格処理の変更が + 散らばらないようにする。`location` は警告に添える位置表記で、構造改善側は + `path#symbol`、テスト側は `target` を渡す。 """ if value not in allowed: - info(f"⚠ {source}: 語彙外の{label} `{value}` — unknown へ降格 ({path}#{symbol})") + info(f"⚠ {source}: 語彙外の{label} `{value}` — unknown へ降格 ({location})") return "unknown", True return value, False @@ -58,11 +59,11 @@ def _normalize_proposal(raw: dict[str, Any], source: str) -> Optional[dict[str, technique = str(raw.get("technique") or "").strip() severity = str(raw.get("severity") or "").strip().lower() smell, smell_degraded = _degrade_if_unknown( - smell, SMELLS, source, "兆候", path, symbol) + smell, SMELLS, source, "兆候", f"{path}#{symbol}") technique, technique_degraded = _degrade_if_unknown( - technique, TECHNIQUES, source, "手法", path, symbol) + technique, TECHNIQUES, source, "手法", f"{path}#{symbol}") severity, severity_degraded = _degrade_if_unknown( - severity, SEVERITY_ORDER, source, "重要度", path, symbol) + severity, SEVERITY_ORDER, source, "重要度", f"{path}#{symbol}") degraded = smell_degraded or technique_degraded or severity_degraded if degraded: severity = "unknown" @@ -206,20 +207,6 @@ def _select( return adopted, deferred -def _degrade_test_value( - value: str, - allowed: Iterable[str], - source: str, - label: str, - target: str, -) -> str: - """語彙集合に含まれないテスト提案の値を `unknown` へ降格する。""" - if value not in allowed: - info(f"⚠ {source}: 語彙外の{label} `{value}` — unknown へ降格 ({target})") - return "unknown" - return value - - def _normalize_test_proposal( raw: dict[str, Any], source: str ) -> Optional[dict[str, Any]]: @@ -236,14 +223,14 @@ def _normalize_test_proposal( info(f"⚠ {source}: path / target の無いテスト項目を無視しました: {raw!r:.120}") return None - case = _degrade_test_value( + case, _ = _degrade_if_unknown( str(raw.get("case") or "").strip().lower(), TEST_CASES, source, "経路", target, ) - level = _degrade_test_value( + level, _ = _degrade_if_unknown( str(raw.get("level") or "").strip().lower(), TEST_LEVELS, source, From 51724669b1916142890ced9154b07fd0d47921cb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 02:35:42 +0000 Subject: [PATCH 165/217] =?UTF-8?q?Fix:=201=20=E8=80=85=E6=8C=87=E5=AE=9A?= =?UTF-8?q?=E3=81=A7=E3=82=82=E4=BD=BF=E3=81=88=E3=82=8B=E8=80=85=E3=81=8C?= =?UTF-8?q?=200=20=E8=80=85=E3=81=AA=E3=82=89=E5=88=9D=E6=9C=9F=E5=8C=96?= =?UTF-8?q?=E3=82=92=E6=AD=A2=E3=82=81=E3=82=8B=EF=BC=88=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E6=8C=87=E6=91=98=E5=AF=BE=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1 者指定(`--only`)はレビュー担当の席の埋め合わせを行わないため、指定した実行主体が 認証の確認を通らないと使える者が 0 者のまま席へ座り、結果が残らないラウンドが続く。 0 者の検査が 1 者指定を素通ししていたので、その場合も終了コード 1 で止める。 あわせて文書を 3 か所直した。 - 手順書の Step 0 に「再開で渡した引数の扱い」(契約の文書)への案内を足す - 契約の文書の再開の表で、1 者指定を「反映し、参加者を作り直す」行へ分ける - 母集合と収束の文書に、1 者指定が確認を通らないときの終了を書く テストは初期化と再開の両方の経路で 0 者を止めることを固定する(修正前は 2 件とも落ちる ことを確認済み)。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- plugins/ndf/skills/cross-review/SKILL.md | 4 +-- .../skills/cross-review/docs/04-contracts.md | 9 +++++-- .../docs/05-pool-and-convergence.md | 5 ++++ .../ndf/skills/cross-review/scripts/state.py | 12 +++++++-- .../tests/test_state_resume_args.py | 21 ++++++++++++++++ .../tests/test_state_review_pool.py | 25 +++++++++++++++++++ 6 files changed, 70 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/skills/cross-review/SKILL.md b/plugins/ndf/skills/cross-review/SKILL.md index 372336cb..dbfac2d4 100644 --- a/plugins/ndf/skills/cross-review/SKILL.md +++ b/plugins/ndf/skills/cross-review/SKILL.md @@ -71,7 +71,7 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 | `--rotate-after K` | この round 数で未収束なら PR ローテーション | `8` | | `--rotate-mode light\|squash` | ローテーション方式。`light`: 同ブランチで旧 PR を close → 新 PR (title/body は現状の差分・実装から再生成)。`squash`: squash 統合 + 新ブランチ + `(rotated)` suffix | `light` | | `--host claude\|codex\|agy\|kiro` | この収束ループを起動している CLI。母集合から外れる | 環境変数から推定。**推定できなければ失敗する** | -| `--only RUNTIME` | 1 者だけで回す。**そのラウンドの担当を 1 者へ絞り、席の埋め合わせを行わない。** 母集合の外を指定したら `init` が弾く | 担当 2 者 | +| `--only RUNTIME` | 1 者だけで回す。**そのラウンドの担当を 1 者へ絞り、席の埋め合わせを行わない。** 母集合の外を指定したときと、その 1 者が確認を通らないときは `init` が弾く | 担当 2 者 | | `--exclude NAMES` | 母集合から外す者。カンマ区切りで複数、繰り返しも可。再開で `none` を渡すと空へ戻す | なし | | `--include NAMES` | 母集合に足す者(ホストも足せる)。書き方は `--exclude` と同じ | なし | | `--require-all` | 確認を通らない者が 1 者でもいれば `init` を失敗させる。全員が揃わないなら始めたくない運用向け | 使える者で始める | @@ -208,7 +208,7 @@ STATE_PR=$INITIAL_PR ROTATE_MODE=${ROTATE_MODE:-light} # Step 0: state 初期化 / 再開 -# ⚠ eval はコマンド置換の終了コードを潰す。変数で受けてから eval する(docs/01 参照)。**値のある引数だけを渡す**(常に渡すと、再開のたびに指定していない既定値で上書きする)。 +# ⚠ eval はコマンド置換の終了コードを潰す。変数で受けてから eval する(docs/01 参照)。**値のある引数だけを渡す**(常に渡すと、再開のたびに指定していない既定値で上書きする)。再開で渡した引数がどう扱われるか(反映する / 参加者を作り直す / 反映しない)は `docs/04-contracts.md` の「再開で渡した引数の扱い」にある。 INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ ${MAX_ROUNDS:+--max-rounds "$MAX_ROUNDS"} ${ROTATE_AFTER:+--rotate-after "$ROTATE_AFTER"} \ ${HOST:+--host "$HOST"} \ diff --git a/plugins/ndf/skills/cross-review/docs/04-contracts.md b/plugins/ndf/skills/cross-review/docs/04-contracts.md index 97a19342..fae7ca2b 100644 --- a/plugins/ndf/skills/cross-review/docs/04-contracts.md +++ b/plugins/ndf/skills/cross-review/docs/04-contracts.md @@ -216,10 +216,15 @@ | 扱い | 引数 | 何が起きるか | | --- | --- | --- | -| 反映する | `--max-rounds` / `--rotate-after` / `--only` / `--verify-command` / `--verify-exit-code` | 状態を書き換え、`resume_changes` へ 1 件積み、`↻ <項目>: <旧> → <新>` を出す | -| 反映し、参加者を作り直す | `--exclude` / `--include` / `--require-all` | 使える者を解決し直して `participants` を置き換える。失敗したら状態を書き換えずに終了コード 1 | +| 反映する | `--max-rounds` / `--rotate-after` / `--verify-command` / `--verify-exit-code` | 状態を書き換え、`resume_changes` へ 1 件積み、`↻ <項目>: <旧> → <新>` を出す | +| 反映し、参加者を作り直す | `--only` | 状態を書き換えて記録へ積んだうえで、認証の確認をやり直して `participants` を置き換える。`none` を渡すと 1 者指定を外す | +| 参加者を作り直す | `--exclude` / `--include` / `--require-all` | 使える者を解決し直して `participants` を置き換える。失敗したら状態を書き換えずに終了コード 1 | | 反映しない | `--host` | 状態と違うときだけ `ℹ --host は再開では反映しません` を出す | +**1 者指定は 2 行にまたがる。** 1 者指定(`--only`)は状態ファイルに載る項目であると同時に、 +参加する実行主体を決め直す引数でもある(`PARTICIPANT_ARGS`)。渡した再開は、指定した 1 者の +認証の確認をやり直し、通らなければ状態を書き換えずに終了コード 1 で止まる。 + ## AI への入出力契約(両 launcher 共通) launcher が生成するプロンプトに以下を強制している: diff --git a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md index 8a0b8e35..8a17ea8e 100644 --- a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md +++ b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md @@ -64,6 +64,11 @@ Step 1(ラウンドの開始)と Step 3(判定)が読む基準を持つ 含まれないラウンドで誰も起動されない。そのとき全員が「指定によるスキップ」として扱われ、 **レビューが行われていないのに収束する**。母集合の外を指定した場合は `init` が弾く。 +**指定した 1 者が確認を通らなければ `init` が失敗する**(終了コード 1、状態ファイルを作らない)。 +埋め合わせを行わない以上、使える者が 0 者のまま席へ座るのはその 1 者だけであり、起動しても +結果が残らないラウンドが積み重なる。使える者が 0 者で `init` が失敗する点は、上の表の +「使える者の数 0」と同じ扱いである。 + ### 参加者の記録を持たない状態ファイル この変更の前に始めた実行の状態ファイルには `participants` が無い。そのときは `host` から diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index e19cbce2..579a2ddc 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -2150,8 +2150,8 @@ def _resolve_reviewers(host: str, args: argparse.Namespace) -> dict[str, Any]: 母集合は `review_pool(host)`。確認は止めない確認(`auth.probe_auth`)で、通らない者は 外して続ける。使える者が 2 者に満たなければホストを確かめ、通れば `fallback` に 置く(決定 9)。1 者指定があればホストを確かめず `fallback` は空。名前の矛盾・ - `--require-all` で欠け・0 者で埋め合わせも無い、は終了コード 1(状態ファイルは - この関数の後に書かれるため作られない)。 + `--require-all` で欠け・0 者で埋め合わせも無い・1 者指定が確認を通らない、は終了 + コード 1(状態ファイルはこの関数の後に書かれるため作られない)。 """ only, include, exclude = _normalize_participant_args(args) probe = functools.partial(auth.probe_auth, info=info) @@ -2171,6 +2171,14 @@ def _resolve_reviewers(host: str, args: argparse.Namespace) -> dict[str, Any]: info(f"⚠ {name} を担当から外しました({reason})") fallback: list[str] = [] + # **1 者指定でも 0 者は通さない。** 指定した 1 者が確認を通らないと使える者が空に + # なるが、席は 1 者指定をそのまま返す(`_round_reviewers` の順 2)。確認を通らない + # 担当が席に座ると、レビューが行われないまま収束する。埋め合わせは 1 者指定では + # 行わないため(決定 9)、ここで止めるほかにない。 + if only is not None and not available: + die(f"1 者指定の {only} が確認を通りません" + f"({resolved.unavailable.get(only, '')})。" + f"{only} で認証し直すか、1 者指定を外して再実行してください", code=1) if only is None and len(available) < 2: results, skipped = auth.probe_auth([host], info=info) if skipped or results.get(host, {}).get("ok", False): diff --git a/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py b/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py index 72baa943..040baafb 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py @@ -160,6 +160,27 @@ def test_only_is_replaced_and_narrows_the_next_round(resume, state_mod, tmp_path assert _seats(state_mod, tmp_path) == ["codex"] +def test_only_that_cannot_be_reached_stops_before_writing( + resume, state_mod, tmp_path, monkeypatch, capsys): + """再開で渡した 1 者指定が確認を通らなければ、状態を書き換えずに終了コード 1。 + + 1 者指定は状態へ反映する引数であると同時に、参加者を作り直す引数でもある。 + 作り直しが 0 者になったまま先へ進むと、確認を通らない 1 者が次のラウンドの席に座る。 + """ + path = _state(tmp_path) + before = path.read_text(encoding="utf-8") + + def probe(runtimes, *, info, env=None): + return ({r: {"command": r, "ok": False, "detail": "未認証"} for r in runtimes}, False) + + monkeypatch.setattr(state_mod.auth, "probe_auth", probe) + with pytest.raises(SystemExit) as e: + resume("--only", "kiro") + assert e.value.code == 1 + assert path.read_text(encoding="utf-8") == before + assert "1 者指定の kiro が確認を通りません" in capsys.readouterr().err + + def test_only_none_clears_the_narrowing(resume, tmp_path, capsys): """AC27 後半: `--only none` は `only` を `null` へ戻す(決定 15)。""" _state(tmp_path, only="codex") diff --git a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py index 97d0937c..df13827f 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py @@ -456,6 +456,31 @@ def test_only_does_not_probe_the_host_and_keeps_one_seat(new_init, state_mod, tm assert _start_round(state_mod, tmp_path) == ["codex"] +def test_only_fails_when_the_named_reviewer_does_not_pass_the_probe(new_init, capsys): + """1 者指定でも使える者が 0 者なら止める(終了コード 1、状態ファイルを作らない)。 + + 1 者指定は席の埋め合わせをしないため、確認を通らない 1 者がそのまま席に座る。 + 起動しても結果が残らず、**レビューが行われていないのに収束する**。 + """ + with pytest.raises(SystemExit) as e: + new_init(only="codex", failing={"codex": "未認証"}) + assert e.value.code == 1 + assert not new_init.state_file.exists() + assert new_init.calls == [["codex"]] + err = capsys.readouterr().err + assert "1 者指定の codex が確認を通りません" in err + assert "未認証" in err + + +def test_only_still_starts_when_the_probe_is_skipped(new_init, state_mod, tmp_path, monkeypatch): + """確認を飛ばした実行では、1 者指定はそのまま通る(通らなかった者がいない)。""" + calls: list[list[str]] = [] + monkeypatch.setattr(state_mod.auth, "probe_auth", _fake_probe({}, calls, skipped=True)) + p = state_mod._resolve_reviewers("claude", _init_args(tmp_path, only="codex")) + assert p["available"] == ["codex"] + assert p["probe_skipped"] is True + + def test_no_available_reviewer_fills_both_seats_with_the_host(new_init, state_mod, tmp_path, capsys): """AC19: 使える者が 0 者でもホストが通れば、席はホストとその 2 つ目。""" st = new_init(failing={"codex": "x", "agy": "x", "kiro": "x"}) From fe174694c8881cd8ab8e38c4e0cf5160023bf4ef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 02:42:44 +0000 Subject: [PATCH 166/217] =?UTF-8?q?Fix:=20converge.py=20=E3=81=AE=20Option?= =?UTF-8?q?al=20=E3=81=AE=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E6=BC=8F?= =?UTF-8?q?=E3=82=8C=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 構造改善で抽出した `_fetch_fix_result` の戻り値注釈が `Optional[...]` を 使うが、取り込みが `from typing import Any` だけで `Optional` を欠いていた。 `from __future__ import annotations` があるため読み込み時には出ないが、 `typing.get_type_hints()` を通すと `NameError: name 'Optional' is not defined` になる。 同じ形の漏れが他に無いことを、`scripts/` の 19 モジュールすべてを `typing.get_type_hints()` へ通して確かめた(修正後 failures=0)。 `Optional` は rounds.py / verify.py / paths.py と同じ書き方に揃えた。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- .../cross-refactoring/scripts/refactor_lib/commands/converge.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index e1002685..ecbeff79 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -11,7 +11,7 @@ import hashlib import pathlib import sys -from typing import Any +from typing import Any, Optional import statefile From b9888cefe820fc6cbc4982327d722d2a8f07bb12 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 02:48:54 +0000 Subject: [PATCH 167/217] =?UTF-8?q?Fix:=202=20=E5=B8=AD=E7=9B=AE=E3=81=AE?= =?UTF-8?q?=E7=9B=A3=E8=A6=96=E3=81=AE=E4=B8=8A=E9=99=90=E3=82=92=E5=B8=AD?= =?UTF-8?q?=E3=81=AE=E5=90=8D=E5=89=8D=E3=81=A7=E3=81=AA=E3=81=8F=E3=83=A9?= =?UTF-8?q?=E3=83=B3=E3=82=BF=E3=82=A4=E3=83=A0=E5=90=8D=E3=81=A7=E5=BC=95?= =?UTF-8?q?=E3=81=8F=EF=BC=88=E3=83=AC=E3=83=93=E3=83=A5=E3=83=BC=E6=8C=87?= =?UTF-8?q?=E6=91=98=E5=AF=BE=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上限の表(limits.py)も担当別の環境変数も名前をランタイム名で引くため、席の名前 (claude-2 / agy-2 / kiro-2)のまま渡すと表に無い担当として既定の 180 秒へ落ち、 1 席目より早く無進捗(STALLED)と判定されていた。 - 並列監視の入口(_run_all)で席の名前をランタイム名へ直してから、監視の上限と 無進捗の許容を引く - 既定の解決(_agent_stall_default)も同じ扱いにする - 2 席目が 1 席目と同じ許容・同じ担当別環境変数で監視されるテストを足す Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- plugins/ndf/scripts/lib/monitor.py | 16 ++++-- .../cross-review/tests/test_seat_names.py | 57 +++++++++++++++++++ 2 files changed, 69 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index d811ffb2..cac43b06 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -15,7 +15,8 @@ cross-refactoring は `{agent}-propose-rf{id}` のような別の命名を渡す。 **担当の名前は席の名前を取りうる**(`claude-2` のような同じランタイムの 2 つ目。#727)。 -一時ファイルの名前はその名前のまま組み、CLI ごとの検査だけ `_agent_runtime` で選ぶ。 +一時ファイルの名前はその名前のまま組み、CLI ごとの検査と**上限の表の参照**は +`_agent_runtime` でランタイム名へ直してから行う。 監視軸: 1. **pidfile** + `kill -0` でプロセス生存確認 @@ -326,8 +327,12 @@ def _agent_stall_default(agent: str) -> int: 4. `DEFAULT_STALL` (表に無い agent) env は **呼び出し時** に再評価し、非数値なら warn を出して表の値に戻す。 + + **席の名前はランタイム名へ直してから引く**(#727)。上限の表も担当別の環境変数も + ランタイム名で引くため、`claude-2` のまま渡すと表に無い担当として `DEFAULT_STALL` + へ落ち、1 席目より早く無進捗と判定される。 """ - return limits.stall_timeout(agent) + return limits.stall_timeout(_agent_runtime(agent)) # `--tmp-dir` で明示指定された一時ディレクトリ。CLI の解析時にだけ設定する。 @@ -1179,8 +1184,11 @@ def _run_all( results: dict[str, AgentStatus] = {} def run(agent: str) -> None: - timeout = limits.monitor_timeout(phase, agent, args.timeout) - stall = limits.stall_timeout(agent, args.stall_timeout) + # 上限の表と担当別の環境変数はランタイム名で引く。席の名前(`claude-2`)のまま + # 渡すと表に無い担当として既定へ落ち、1 席目より早く無進捗と判定される(#727)。 + runtime = _agent_runtime(agent) + timeout = limits.monitor_timeout(phase, runtime, args.timeout) + stall = limits.stall_timeout(runtime, args.stall_timeout) print(f"[{agent}] ▶ hard timeout {timeout}s / stall {stall}s (phase {phase})", file=sys.stderr, flush=True) if stall >= timeout: diff --git a/plugins/ndf/skills/cross-review/tests/test_seat_names.py b/plugins/ndf/skills/cross-review/tests/test_seat_names.py index 2dc3d88c..b09efd76 100644 --- a/plugins/ndf/skills/cross-review/tests/test_seat_names.py +++ b/plugins/ndf/skills/cross-review/tests/test_seat_names.py @@ -215,6 +215,63 @@ def test_the_runtime_of_a_seat_is_used_for_the_cli_specific_checks(monitor_mod): assert monitor_mod._agent_runtime("impl") == "impl" +# ---------------- 監視の上限と無進捗の許容 ---------------- + +@pytest.fixture() +def no_limit_env(monkeypatch): + """上限の表を上書きする環境変数を外す。手元の設定でこの節が揺れないようにする。""" + for name in ("MONITOR_TIMEOUT", "MONITOR_STALL"): + monkeypatch.delenv(name, raising=False) + for runtime in ("CLAUDE", "CODEX", "AGY", "KIRO"): + monkeypatch.delenv(f"{name}_{runtime}", raising=False) + + +@pytest.mark.parametrize("seat,expected", [ + ("claude-2", 900), ("agy-2", 480), ("kiro-2", 480), ("codex-2", 180), +]) +def test_a_second_seat_gets_the_allowance_of_its_runtime( + monitor_mod, no_limit_env, seat, expected +): + """2 席目の無進捗の許容は、そのランタイムの値になる。 + + 席の名前のまま上限の表を引くと表に無い担当として既定(180 秒)へ落ち、1 席目より + 早く無進捗と判定される。 + """ + assert monitor_mod._agent_stall_default(seat) == expected + + +def test_a_second_seat_reads_the_environment_variable_of_its_runtime( + monkeypatch, monitor_mod, no_limit_env +): + """担当別の環境変数もランタイム名で引く(`MONITOR_STALL_CLAUDE-2` は書けない)。""" + monkeypatch.setenv("MONITOR_STALL_CLAUDE", "777") + assert monitor_mod._agent_stall_default("claude-2") == 777 + + +def test_both_seats_of_a_runtime_are_monitored_with_the_same_limits( + monkeypatch, monitor_mod, no_limit_env +): + """並列監視の入口(`_run_all`)でも、2 席目が 1 席目と同じ上限で監視される。""" + seen: dict[str, object] = {} + + def fake_monitor_agent(agent, pr, config): + seen[agent] = config + return monitor_mod.AgentStatus(agent=agent) + + monkeypatch.setattr(monitor_mod, "monitor_agent", fake_monitor_agent) + monkeypatch.setattr(monitor_mod, "_record_outcome", lambda *a, **k: None) + + args = argparse.Namespace( + timeout=None, stall_timeout=None, poll=1, no_require_result=False, + no_early_error=False, stem_template=monitor_mod.DEFAULT_STEM_TEMPLATE, + pr=1, phase="review", + ) + monitor_mod._run_all(["claude", "claude-2"], args, "review") + + assert seen["claude-2"].stall_timeout == seen["claude"].stall_timeout == 900 + assert seen["claude-2"].timeout == seen["claude"].timeout + + # ---------------- 計測 ---------------- def test_the_measure_counts_a_second_seat(measure_mod): From 28dad58e0bdbbb4cfa5072bef6b0726803cbec37 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 03:18:41 +0000 Subject: [PATCH 168/217] =?UTF-8?q?Fix:=20=E8=B5=B7=E7=82=B9=E3=81=AE?= =?UTF-8?q?=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E3=81=AB?= =?UTF-8?q?=E4=BE=9D=E3=82=89=E3=81=9A=E5=85=B1=E9=80=9A=E3=81=AE=E5=89=8D?= =?UTF-8?q?=E6=8F=90=E3=82=92=E5=8A=B9=E3=81=8B=E3=81=9B=E3=82=8B=EF=BC=88?= =?UTF-8?q?#678=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit テストの基準のディレクトリ(rootdir)は、起点から上へ設定ファイルを探して最初に 見つかったところで止まる。リポジトリの根に設定ファイルが 1 つも無いため、テストの束の ディレクトリを起点にすると基準がそこで止まり、根の共通の前提が読み込まれなかった。 上限を延ばしたシェルから束を起点に起動すると、監視の上限を前提にするテストが落ちる。 根に設定ファイルを 1 つ置き、基準をリポジトリの根へ固定する。設定値は足さない。 外した値を戻す検査は、控えを手で操作せずに外す側と戻す側を実際に呼ぶ形へ直した。 起点に依らず切り離しが効くことを、束のディレクトリからの起動で確かめる検査を足した。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- conftest.py | 3 +++ pytest.ini | 8 +++++++ scripts/tests/test_root_conftest.py | 33 ++++++++++++++++++++++++++--- 3 files changed, 41 insertions(+), 3 deletions(-) create mode 100644 pytest.ini diff --git a/conftest.py b/conftest.py index b9f4a8c8..ccf49f53 100644 --- a/conftest.py +++ b/conftest.py @@ -13,6 +13,9 @@ 5. テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)を外す。上限を延ばした シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) +どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は +根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。 + `playwright-kit-ops` のディレクトリを起点にした実行では、このファイルは読まれない。 `pytester` はそのディレクトリの `pyproject.toml` の `addopts` が読み込む。 """ diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..47b2c6e3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# 起点のディレクトリに関わらず、テストの基準のディレクトリ(rootdir)をリポジトリの根へ +# 解決させるために置く。pytest は起点から上へ設定ファイルを探し、見つかったところで止まる。 +# 根に設定ファイルが 1 つも無いと、テストの束のディレクトリを起点にした実行では基準が +# そこで止まり、根の共通の前提(`conftest.py`)が読み込まれない。監視の上限を指す環境変数 +# (接頭辞 `MONITOR_`)の除去のように、どの起点でも効かなければならない前提がここに載る。 +# +# **設定値は足さない。** `testpaths` などを書くと、既存の実行が対象にする範囲が変わる。 +[pytest] diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index ff2a226d..4883c38a 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -208,14 +208,41 @@ def test_the_values_are_put_back_after_the_run(monkeypatch: pytest.MonkeyPatch) mod = _root_conftest_module() monkeypatch.setenv("MONITOR_STALL_AGY", "1800") - saved = mod._strip_monitor_env() + mod.pytest_configure(None) - assert saved == {"MONITOR_STALL_AGY": "1800"} assert "MONITOR_STALL_AGY" not in os.environ + assert mod._saved_monitor_env == {"MONITOR_STALL_AGY": "1800"} - os.environ.update(saved) + mod.pytest_unconfigure(None) assert os.environ["MONITOR_STALL_AGY"] == "1800" + assert mod._saved_monitor_env == {} + + +def test_the_isolation_holds_from_a_bundle_directory() -> None: + """束のディレクトリを起点にしても切り離しが効く。 + + テストの基準のディレクトリ(rootdir)が起点で止まると、根の設定が読み込まれず、 + 上限を延ばしたシェルから起動したときだけ落ちる。根の設定ファイル(`pytest.ini`)が + 基準をリポジトリの根へ固定していることを、実際の起動で確かめる。 + **監視の環境変数は明示的に足す。** 実行中は根の設定が外した後のため、渡す環境へ + 足さないと再現しない。 + """ + bundle = REPO_ROOT / "plugins/ndf/skills/cross-review/tests" + env = dict(os.environ) + env.pop("NDF_TESTS_ALLOW_MISSING_COMMANDS", None) + env.update({"MONITOR_STALL_AGY": "1800", "MONITOR_TIMEOUT": "1800"}) + + result = subprocess.run( + [sys.executable, "-m", "pytest", "test_monitor_stall_default.py", "-q", + "--no-header", "-p", "no:cacheprovider"], + cwd=str(bundle), + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stdout + result.stderr def test_the_prefix_is_declared_once() -> None: From 7af3b7872a5c9d7efcf0ebd5d89563dc7ee53b47 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 03:21:55 +0000 Subject: [PATCH 169/217] =?UTF-8?q?Docs:=20=E5=86=8D=E9=96=8B=E3=81=A7?= =?UTF-8?q?=E6=B8=A1=E3=81=97=E3=81=9F=E5=BC=95=E6=95=B0=E3=81=AE=E6=89=B1?= =?UTF-8?q?=E3=81=84=E3=81=AE=E7=BD=AE=E3=81=8D=E5=A0=B4=E6=89=80=E3=82=92?= =?UTF-8?q?=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1=E4=BB=B6=E3=81=B8?= =?UTF-8?q?=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B=EF=BC=88#727=20#687=20#478?= =?UTF-8?q?=20#648=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 再開で渡した引数の扱いは、手順書ではなく契約の文書に置き、手順書からは案内で辿る形にした。 手順書の行数が上限(500 行。この手順書は検査が 420 行で固定している)に張り付いていて表を 足せないことと、同じ表を 2 か所へ置くと片方が古くなることによる。受け入れ条件の側を、実際の 置き場所へ合わせた。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-727-687-478-664-648-requirements.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-727-687-478-664-648-requirements.md b/issues/issue-727-687-478-664-648-requirements.md index e4c235fd..78daaa9e 100644 --- a/issues/issue-727-687-478-664-648-requirements.md +++ b/issues/issue-727-687-478-664-648-requirements.md @@ -194,7 +194,7 @@ | `SKILL.md` | 引数の表と `argument-hint` に `--exclude` / `--include` / `--require-all`。`--only` の説明から「デバッグ用」が消える。母集合の行が席の規則を指す | | `docs/05-pool-and-convergence.md` | 使える者の解決と席の埋め方(3 者以上 / 2 者 / 1 者 / 0 者)、`--exclude` / `--include`、確認が把握になったこと | | `docs/04-contracts.md` | 状態ファイルの `participants` と `resume_changes`、席の名前の形 | - | `docs/01-state-and-review.md` | 再開で反映する引数と、反映しない引数 | + | `docs/01-state-and-review.md` | 再開で渡した引数の扱いが `docs/04-contracts.md` にあることへの案内 | ### 子 issue の再現手順 From 8057d86ebf6be83836907712e0814a296dfdacef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 03:35:02 +0000 Subject: [PATCH 170/217] =?UTF-8?q?Fix:=20=E7=9B=A3=E8=A6=96=E3=81=AE?= =?UTF-8?q?=E7=92=B0=E5=A2=83=E5=A4=89=E6=95=B0=E3=81=AE=E5=88=87=E3=82=8A?= =?UTF-8?q?=E9=9B=A2=E3=81=97=E3=82=92=E7=A2=BA=E3=81=8B=E3=82=81=E3=82=8B?= =?UTF-8?q?=202=20=E3=81=A4=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=82=92?= =?UTF-8?q?=E6=81=92=E7=9C=9F=E3=81=A7=E3=81=AA=E3=81=8F=E3=81=99=E3=82=8B?= =?UTF-8?q?=EF=BC=88#678=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実行中に監視の上限を指す環境変数が残らないことと、子プロセスへ引き継がれないことを 確かめる 2 つは、周りのシェルがその環境変数を持たないときに、外す仕組みを壊しても 素通りしていた。継続的統合と多くの手元の環境がこれに当たる。 確かめる前に自分で 1 つ差し込む形へ直した。根の設定は別名で読み込み、控えを汚さない。 外す側を呼んだ後に戻す側も呼び、元の状態へ戻す。 外す側の本体を一時的に何もしない形へ置き換えると、この 2 つが落ちることを確かめた。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- scripts/tests/test_root_conftest.py | 52 ++++++++++++++++++++++------- 1 file changed, 40 insertions(+), 12 deletions(-) diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 4883c38a..f575da6d 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -181,10 +181,23 @@ def _root_conftest_module(): return mod -def test_no_monitor_variable_survives_into_a_test() -> None: - """実行中は、監視の上限を指す環境変数が 1 つも残らない。""" - remaining = [k for k in os.environ if k.startswith("MONITOR_")] - assert remaining == [], remaining +def test_no_monitor_variable_survives_into_a_test(monkeypatch: pytest.MonkeyPatch) -> None: + """外す側を呼んだ後は、監視の上限を指す環境変数が 1 つも残らない。 + + **確かめる前に自分で 1 つ差し込む。** 周りのシェルが上限を持たないと、外す仕組みを + 壊しても素通りする。差し込んでおけば、起動したシェルが何を持っていても同じことを + 確かめられる。控えを汚さないよう、根の設定は別名で読み込む。 + """ + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + try: + mod.pytest_configure(None) + + remaining = [k for k in os.environ if k.startswith("MONITOR_")] + assert remaining == [], remaining + finally: + mod.pytest_unconfigure(None) def test_a_test_can_still_set_its_own_monitor_variable(monkeypatch: pytest.MonkeyPatch) -> None: @@ -193,14 +206,29 @@ def test_a_test_can_still_set_its_own_monitor_variable(monkeypatch: pytest.Monke assert os.environ["MONITOR_STALL_AGY"] == "600" -def test_the_child_process_does_not_inherit_a_monitor_variable() -> None: - """子プロセスにも同じ切り離しが効く(起動する側で外し直さなくてよい)。""" - out = subprocess.run( - [sys.executable, "-c", - "import os; print([k for k in os.environ if k.startswith('MONITOR_')])"], - capture_output=True, text=True, - ) - assert out.stdout.strip() == "[]", out.stdout +def test_the_child_process_does_not_inherit_a_monitor_variable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """外した後に起動した子プロセスは、監視の上限を指す環境変数を受け継がない。 + + 起動する側で外し直さなくてよいことを確かめる。**確かめる前に自分で 1 つ差し込む。** + 周りのシェルが上限を持たないと、外す仕組みを壊しても素通りする。控えを汚さないよう、 + 根の設定は別名で読み込む。 + """ + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + try: + mod.pytest_configure(None) + + out = subprocess.run( + [sys.executable, "-c", + "import os; print([k for k in os.environ if k.startswith('MONITOR_')])"], + capture_output=True, text=True, + ) + assert out.stdout.strip() == "[]", out.stdout + finally: + mod.pytest_unconfigure(None) def test_the_values_are_put_back_after_the_run(monkeypatch: pytest.MonkeyPatch) -> None: From e2a8f7cb842006e0f85e1d8cd64ac0cb87c585ea Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 04:01:06 +0000 Subject: [PATCH 171/217] =?UTF-8?q?Docs:=20=E5=AE=9F=E8=A3=85=E8=A8=88?= =?UTF-8?q?=E7=94=BB=E3=82=92=E4=BD=9C=E3=82=8A=E3=80=81=E6=9C=AA=E7=A2=BA?= =?UTF-8?q?=E8=AA=8D=E3=81=A0=E3=81=A3=E3=81=9F=204=20=E4=BB=B6=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E6=B8=AC=E3=81=AE=E7=B5=90=E6=9E=9C=E3=81=B8=E6=9B=B8?= =?UTF-8?q?=E3=81=8D=E7=9B=B4=E3=81=99=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計の時点で未確認だった 4 件を、実物の gh で 1 度ずつ動かして決めた。 拒まれた要求は何も作らないため、確かめた跡は残っていない。 - 差分の外を指す指摘が拒まれるときの応答の形(退避の契機に使う語) - 拒まれた応答から原因を 1 件ずつ特定できるか(特定できない) - すでに決着したスレッドをもう一度決着させたときの応答(冪等) - 投稿者のアカウントが席ごとに違う環境があるか(無い) Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- issues/issue-730-583-design.md | 46 +++++++- issues/issue-730-583-plan.md | 185 +++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 5 deletions(-) create mode 100644 issues/issue-730-583-plan.md diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index 7de56e85..7b4c0db6 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -362,12 +362,48 @@ graph TD **偽の `gh` を使う形は既存のテストにある**(待ち行列の照合と巻き直しの投稿)。同じ仕掛けを使う。 -## 未確認のまま残ること +## 実測で決めた 4 件 -- **差分の外を指す指摘が拒まれるときの応答の形。** HTTP 422 が返ることは GitHub の仕様として知られている。応答の本文が行を解決できないことをどの語で示すかと、本文とインラインを同じ要求で送ったときにどちらが拒まれるかは未確認である。実装の最初の段で、偽ではない `gh` で 1 度確かめ、判定に使う語を契約へ書く -- **拒まれた応答から、どのインラインが原因かを 1 件ずつ特定できるかどうか。** 特定できなければ、拒まれた要求のインラインをすべて本文へ退避する形へ落とす。落としたときに本文が長くなりすぎないかを、実装の段で 1 度測る -- **決着の投稿を、すでに決着したスレッドへもう一度送ったときの応答。** 失敗にならないことを前提に置いているが、実行して確かめていない -- **投稿者のアカウントが席ごとに違う環境があるかどうか。** いまの作業環境では 1 つだが、別の環境で担当ごとに別の認証を使う設定があると、照合の鍵の前提が変わる +設計の時点で未確認だった 4 件を、2026-09-22 に実物の `gh` で 1 度ずつ動かして決めた。対象は +この束の設計を載せた Pull Request #794(マージ済み、head `233f28ba`)である。拒まれた要求は +何も作らないため、確かめた跡は残っていない(レビューとレビューのコメントを引いて 0 件)。 + +### 差分の外を指す指摘が拒まれるときの応答の形 + +レビューの作成は**要求ごとに全件が拒まれる**。正しいインラインを混ぜても、一部だけが作られる +ことはない。終了コードは 1 である。 + +| 何を送ったか | 応答の `errors` | +| --- | --- | +| 差分に無いファイルのインライン 1 件 | `["Path could not be resolved"]` | +| 差分にあるファイルの、塊の外の行のインライン 1 件(正しいインライン 1 件と同時) | `["Line could not be resolved"]` | +| 塊の外・差分に無いファイル・塊の外の 3 件 | `["Line could not be resolved, Path could not be resolved, and Line could not be resolved"]` | +| 判定の値に知らない語 | `["Variable $event of type PullRequestReviewEvent was provided invalid value"]` | +| 基準のコミットに存在しない値 | `["The commitOID is not part of the pull request"]` | + +**退避の契機に使う語は `could not be resolved` である。** 判定の値の誤りと基準のコミットの誤りは +この語を含まないため、決定 9 のとおり失敗として残せる。 + +### 拒まれた応答から、どのインラインが原因かを 1 件ずつ特定できるか + +**特定できない。** 応答は語を `, ` と `and` でつないだ 1 つの文字列で、位置も識別子も持たない。 +拒まれた件数は語の数から読めるが、正しいインラインを混ぜると位置が対応しない。 + +**決定 9 の落とし先を採る。** 行やファイルを解決できないことを理由に拒まれた要求は、その要求の +インラインをすべて本文へ退避して送り直す。1 ラウンドの指摘は多くて 10 件前後、1 件の本文は +数百文字のため、すべてを退避しても数 KB に収まり、投稿の本文の上限(65536 文字)に対して余裕がある。 + +### すでに決着したスレッドをもう一度決着させたときの応答 + +**冪等である。** 決着の操作は成功し(終了コード 0)、決着済みであることを返す。失敗にならない。 +決定 5 の照合の鍵(スレッドの識別子と、すでに決着しているかどうか)が送信を止め損ねても、 +二重の決着が失敗にはならない。 + +### 投稿者のアカウントが席ごとに違う環境があるか + +**無い。** 作業環境の `gh` は 1 アカウントだけを持ち、配布物の中に席ごとの認証を切り替える口は +無い(`plugins/ndf/` を認証の環境変数で引くと 2 行あり、どちらも別の目的の注釈である)。 +要求の前提 1(投稿者アカウントは実質 1 つ)はそのまま成り立ち、照合の鍵に投稿者を含める形を変えない。 ## 申し送り(並行する設計との境界) diff --git a/issues/issue-730-583-plan.md b/issues/issue-730-583-plan.md new file mode 100644 index 00000000..d9ab65e5 --- /dev/null +++ b/issues/issue-730-583-plan.md @@ -0,0 +1,185 @@ +# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのをレビューを回す側だけにし、途中で止まっても二度書かない(#730 #583) + +## 関連リンク + +| 文書 | 何を持つか | +| --- | --- | +| [issue-730-583-requirements.md](issue-730-583-requirements.md) | 何を満たすか(受け入れ条件 AC1〜AC32) | +| [issue-730-583-design.md](issue-730-583-design.md) | どう作るか(決定 1〜14・データ構造・入出力の契約) | +| #730 | 根本原因の親課題 | +| #583 | 投稿の重なりと起動し直しの経路 | + +## モード + +`standard`。レビューを回す仕組みの振る舞いを変え、複数の実行単位にまたがる。 + +## 目的と非目的 + +達成したい状態: + +- レビューを任された担当が途中で止まっても、PR に出ている指摘と記録が食い違わない +- 起動し直しても同じ論点のスレッドが 2 つに分かれない +- 修正を送ったという報告と、送り先のブランチの実物が一致する + +やらないこと: + +- 収束の判定・指摘の数え方・区分を変える(別の束が持つ) +- 席の決め方と再開の引数を変える(別の束が持つ) +- PR の巻き直しの締め・作り直し・再開の手順を変える(設計の決定 13) +- 子課題 #548 #350 #585 #676 の個別の受け入れ条件を立てる(根本の修正で現象が出なくなる見込みを要求へ書き、閉じるのは棚卸に任せる) + +## 用語の対応 + +**説明は業務用語で通す。** 識別子は設計文書の「用語の対応表」で引く。この計画で使う語だけを再掲する。 + +| 業務用語 | 実体 | +| --- | --- | +| レビューを回す側 | `plugins/ndf/skills/cross-review/scripts/state.py` と、それを呼ぶ `SKILL.md` の骨組み | +| 担当 | 1 ラウンドで 1 者ぶんのレビューを行う CLI | +| 席 | そのラウンドで担当が入る枠 | +| 指摘の控え | 担当が書く、指摘 1 件ごとのファイル | +| 結果ファイル | 担当が書く、判定の要約のファイル | +| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py` | +| 結果ファイルを投稿へ変える層 | 新設する `plugins/ndf/scripts/lib/result_posts.py` | +| 指摘の取り込み | `state.py read-result` | +| 修正の取り込み | `state.py merge-fix` | + +## 前提 + +- 前提 1: レビューの投稿者アカウントは 1 つである。作業環境の `gh` の認証は 1 アカウントで、席ごとに切り替える口は配布物に無い(実測で確かめた。「実測で決めたこと」の 4 番目) +- 前提 2: 担当が結果を書けずに止まったとき、その担当は何も投稿していない。投稿の手順をプロンプトから外すため、変更の後に成り立つ +- 前提 3: 修正の担当が働く作業ツリーは、取り込む側から同じパスで見える +- 前提 4: 待ち行列は作業ツリーの中にあり、巻き直しのときは作業ツリーごと捨てられる + +## 実測で決めたこと + +設計文書が「未確認のまま残ること」として残した 4 件を、実物の `gh` で 1 度ずつ動かして決めた。 +実測の記録は設計文書の同じ節へ書き戻す(同じ Pull Request に含める)。 + +| 何を | 決めたこと | +| --- | --- | +| 差分の外を指す指摘が拒まれるときの応答の形 | 要求ごとに全件が拒まれ、一部だけが作られることはない。判定に使う語は `could not be resolved` である。判定の値の誤りと基準のコミットの誤りはこの語を含まないため、失敗として残せる | +| 拒まれた応答から原因を 1 件ずつ特定できるか | できない。応答は語をつないだ 1 つの文字列で、位置も識別子も持たない。設計の落とし先(拒まれた要求のインラインをすべて本文へ退避する)を採る | +| すでに決着したスレッドをもう一度決着させたときの応答 | 冪等である。成功し、決着済みを返す。失敗にならない | +| 投稿者のアカウントが席ごとに違う環境があるか | 無い。前提 1 のとおり | + +## 受け入れ条件 + +**一覧は要求の文書が持つ**(AC1〜AC32)。この計画では、タスクごとに満たす番号を示す。 + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| 取り込み・判定・報告の終了コード | 変えない | AC23 | +| 収束の判定と報告が読む変数 | 変えない | AC23 | +| 取り込みの標準出力 | 投稿の結果を表す行と、取り込んだ指摘の件数を足す | 追加のみ | +| 結果ファイルの項目 | 担当が書く項目を減らし、投稿する側が埋める項目を増やす | 担当の側の契約が変わる。プロンプトと同じ変更に含める | +| 待ち行列の保存先 | 変えない | 前提 4 | +| 席の名前・結末の語彙・数えない指摘の区分 | 変えない | AC24 | + +## 修正対象 + +| ファイル | 扱い | +| --- | --- | +| `plugins/ndf/scripts/lib/result_posts.py` | 新設 | +| `plugins/ndf/scripts/lib/post_queue.py` | 変更 | +| `plugins/ndf/skills/cross-review/scripts/state.py` | 変更 | +| `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` | 変更 | +| `plugins/ndf/skills/cross-review/SKILL.md` | 変更 | +| `plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md` | 変更 | +| `plugins/ndf/skills/cross-review/docs/03-review-output.md` | 変更 | +| `plugins/ndf/skills/cross-review/docs/04-contracts.md` | 変更 | +| `plugins/ndf/skills/cross-review/references/context-budget.md` | 変更 | +| `plugins/ndf/skills/fix/SKILL.md` | 変更 | +| `issues/issue-730-583-design.md` | 「未確認のまま残ること」を実測の結果へ書き直す | +| 各ランタイムの配布物 | 生成の実行で揃える | + +## タスク分解 + +機能単位で分ける。各タスクは、失敗するテストを先に書いてから通す。 + +### Task 1: 二度書かない照合を 4 種別すべてへ広げ、拒まれ方を区別して返す + +- **対象ファイル:** `plugins/ndf/scripts/lib/post_queue.py` +- **変更内容:** 先客がいるかを見る照合の鍵を、レビューの投稿・返信・決着・まとめの 4 種別それぞれに与える。レビューの投稿の鍵は本文の先頭行のうちラウンドと席までの前方一致とし、判定の語を含めない。送信が拒まれたとき、行やファイルを解決できないことを示す応答と、それ以外の拒まれ方を呼ぶ側が見分けられる形で返す +- **満たす受け入れ条件:** AC10・AC11・AC13・AC14(照合)、AC16(拒まれ方の区別) +- **進め方:** 偽の `gh` で先客を返す状態を作り、同じ項目を 2 度積んでも増えないことを見るテストを先に書く + +### Task 2: 指摘の控えと結果ファイルからレビューの投稿を組み立てて送る層を新設する + +- **対象ファイル:** `plugins/ndf/scripts/lib/result_posts.py`(新設) +- **変更内容:** 控えと結果ファイルのパスを受け取り、待ち行列へ積む項目の列を返す。本文は引数にも標準出力にも出さない。自分の Pull Request かどうかを受け取り、送った形だけを格下げする。インラインが行を解決できないことを理由に拒まれたら、その要求のインラインを本文の末尾へ移して送り直し、移した指摘の宛先を本文として記録する +- **満たす受け入れ条件:** AC5・AC15・AC16・AC17・AC32 +- **進め方:** 偽の `gh` が拒む応答を返す状態で、本文へ移した件数と宛先の値を見るテストを先に書く + +### Task 3: 指摘の取り込みが投稿を行い、申告と実数の突き合わせをやめる + +- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/state.py` +- **変更内容:** 控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む、の順で 1 回の呼び出しの中を進める。担当の申告件数を GitHub の実数と比べて中断する処理を取り除く。記録に入る投稿の URL は送信の応答から取り、件数は送れたインラインの数から取る。標準出力へ足すのは件数・URL・状態の行だけにする +- **満たす受け入れ条件:** AC5・AC6・AC12・AC15・AC17・AC23 +- **進め方:** 標準出力に控えの本文の文字列が含まれないことと、投稿の後・記録の前で止めた状態から呼び直してもレビューが増えないことを見るテストを先に書く + +### Task 4: 担当のプロンプトから投稿の手順を外し、書き終えてから改名で公開する + +- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` +- **変更内容:** プロンプトから、レビューを投稿する手順(投稿の呼び出し・判定の値の指定・インラインの組み立て)を外す。担当が書くのは指摘の控えと結果ファイルの 2 つだけにする。どちらも一時の名前で書かせ、控えを先、結果ファイルを後の順で正式の名前へ改名させる +- **満たす受け入れ条件:** AC1・AC2・AC3・AC4 +- **進め方:** 組み立てたプロンプトを取り出し、投稿の手順の語が 0 件であることを見るテストを先に書く + +### Task 5: 修正の返信・決着・まとめと送信を 1 つの層にまとめ、単独の口を与える + +- **対象ファイル:** `plugins/ndf/scripts/lib/result_posts.py`・`plugins/ndf/skills/cross-review/scripts/state.py`・`plugins/ndf/skills/fix/SKILL.md` +- **変更内容:** 修正の結果ファイルから返信・決着・まとめの投稿を組み立てる口と、現在の頭を指定して送信する口を同じ層へ置く。送信の後、報告されたコミットが送り先のブランチの履歴に含まれることを確かめ、含まれなければ失敗として止まる。修正の取り込みはこの層を呼び、単独で使うときは同じ層を部分命令として直接呼ぶ。単独で使う手順は 1 行のコマンドへ置き換える +- **満たす受け入れ条件:** AC7・AC8・AC9・AC21・AC22 +- **進め方:** 送信を偽装し、報告されたコミットが送り先に無いときに失敗することを見るテストを先に書く + +### Task 6: 起動し直しを初回と同じ経路へ通す + +- **対象ファイル:** `plugins/ndf/skills/cross-review/SKILL.md` +- **変更内容:** 骨組みの起動し直しの枝を、繰り返しの先頭へ戻す形にする。待ち行列に残りがあるときの枝は、2 度目を含む各判定の直後に残す。設計方針の表から、担当が直接投稿する記述を外し、投稿の担い手とその理由を入れる +- **満たす受け入れ条件:** AC18・AC19・AC25 +- **進め方:** 骨組みの行の並びを読む既存の検査へ条件を足す + +### Task 7: 文書の決定を書き直す + +- **対象ファイル:** `docs/02-fix-and-rotation.md`・`docs/03-review-output.md`・`docs/04-contracts.md`・`references/context-budget.md`(いずれも `plugins/ndf/skills/cross-review/` の配下)・`issues/issue-730-583-design.md` +- **変更内容:** 修正の手順から担当が送信する行を外し、取り込む側が送信することを書く。担当が直接投稿する決定を、待ち行列を通す形へ書き直す。投稿の種別ごとの契約を載せる。文脈の予算の工夫の一覧を、本文がレビューを回す側のプロセスの中だけを通る形へ書き直す。設計文書の「未確認のまま残ること」を実測の結果へ書き直す +- **満たす受け入れ条件:** AC26・AC27・AC28・AC29 +- **進め方:** 文言の検査。振る舞いを持たないためテストは書かない + +### Task 8: 生成物を揃え、全体の検査を通す + +- **対象ファイル:** 各ランタイムの配布物 +- **変更内容:** 生成の実行で配布物を揃え、全体のテストと検査を通す +- **満たす受け入れ条件:** AC23・AC24・AC30・AC31 +- **進め方:** テストと検査のコマンドを実行し、終了コードを証跡として残す + +## 影響範囲 + +| 何が | どう変わるか | +| --- | --- | +| 担当の CLI | GitHub への書き込みの権限が要らなくなる。実行時間が短くなる | +| 収束の判定 | 変わらない。読む変数と終了コードを保つ | +| 修正を単独で行う経路 | 書き込みの実装が、レビューを回す経路と 1 つになる | +| 並行する束 | 席の決め方(PR #793、マージ済み)と同じファイルを触る。マージ済みのため競合は着手の時点で解けている | + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 変更が 1 ファイル 4983 行の取り込みの実装に集中する | タスクごとにテストを通す。構造の整理はこの変更の後の構造改善へ回す | +| 投稿の担い手を移す間に、送る経路が 2 つ並ぶ | 担当のプロンプトから手順を外す変更(Task 4)と、投稿を行う変更(Task 3)を同じ Pull Request に入れる | +| 拒まれ方の区別が実物の応答に合わない | 実測で確かめた語を契約へ書き、偽の応答をその形で作る | +| 本文へ退避した指摘で本文が長くなる | 1 ラウンドの指摘は多くて 10 件前後で、投稿の本文の上限に対して十分小さい(実測の記録に根拠を置く) | + +## 切り戻し手順 + +Pull Request の単位で戻せる。外部の系の状態を変える移行は無い。待ち行列の保存先を変えないため、 +途中まで積まれた項目は作業ツリーごと捨てられる。 + +## 完了の定義 + +- [ ] 受け入れ条件 AC1〜AC32 をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る +- [ ] `bash scripts/build-runtime-plugins.sh --check`・`claude plugin validate .`・`python3 scripts/check-skill-frontmatter.py` が終了コード 0 で終わる From 57a803e28502d935b15b76da206090216fb67344 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 04:04:10 +0000 Subject: [PATCH 172/217] =?UTF-8?q?Docs:=20=E4=BD=BF=E3=81=88=E3=82=8B?= =?UTF-8?q?=E8=80=85=E3=81=A0=E3=81=91=E3=81=A7=E5=A7=8B=E3=82=81=E3=82=8B?= =?UTF-8?q?=E5=8F=8E=E6=9D=9F=E3=83=AB=E3=83=BC=E3=83=97=E3=81=A8=E3=80=81?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AA=E3=81=97=E3=81=AE=E5=8F=96=E3=82=8A?= =?UTF-8?q?=E8=BE=BC=E3=81=BF=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 実装が済んだ 2 つのまとまりの計画・設計・要求を、現行の実装と一致する確定仕様へ 書き直した。 - cross-review-participants-and-seats.md: 使える者の解決・止めない確認・毎ラウンド 2 席の埋め方・席の名前・再開で渡した引数の反映(#727 #687 #478 #648) - cross-refactoring-apply-intake.md: 結果なしを 3 つの取り込みが同じ手順で受けること、 適用ラウンドの開き直しと試行の上限、項目の無いラウンドを作らないこと、帰属の段落の 後ろから記名を読むこと(#728 #647 #592 #553 #674) - cross-review-launch-outcome.md: 読み取りの契約が「まだ実装に入っていない」と書いて いた箇所を、実装済みの仕様への参照へ直した - README.md: 索引に 2 行を足した Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- docs/specifications/README.md | 2 + .../cross-refactoring-apply-intake.md | 410 ++++++++++++++++++ .../cross-review-launch-outcome.md | 6 +- .../cross-review-participants-and-seats.md | 401 +++++++++++++++++ 4 files changed, 816 insertions(+), 3 deletions(-) create mode 100644 docs/specifications/cross-refactoring-apply-intake.md create mode 100644 docs/specifications/cross-review-participants-and-seats.md diff --git a/docs/specifications/README.md b/docs/specifications/README.md index f0ecb7c6..02cdfd8b 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -17,6 +17,8 @@ | [ndf-issue-upkeep-root-cause.md](ndf-issue-upkeep-root-cause.md) | 溜まった課題を根本原因の場所で直す判定(ルートコーズ)と、構造の判断の担い手。親 issue とサブイシューの実測、マイルストーンを連番で読む理由。手順は `issue-upkeep` の SKILL.md と `references/` が正 | | [cross-review-evidence-based.md](cross-review-evidence-based.md) | 証拠ベースのレビューと効果の測定。状態ファイルの契約と決定の理由。手順は `cross-review` の `SKILL.md` が正 | | [cross-review-launch-outcome.md](cross-review-launch-outcome.md) | 起動 1 回の結末の語彙(理由 9 語)と起動し直しの可否、利用上限と CLI の上限の検知、プロセスグループでの起動と停止。手順は `cross-review` の `SKILL.md` と `docs/` が正 | +| [cross-review-participants-and-seats.md](cross-review-participants-and-seats.md) | 使える者だけで収束ループを始める共通層(認証の確認を止めない形・参加の母集合と足す者/外す者・毎ラウンド 2 席の埋め方・席の名前)と、再開で渡した引数の反映。手順は `cross-review` の `SKILL.md` と `docs/` が正 | +| [cross-refactoring-apply-intake.md](cross-refactoring-apply-intake.md) | 担当が結果を残さない起動を 3 つの取り込みが同じ手順で受けること(範囲の確定・未検証のコミットの取り消し・結末の記録)、適用ラウンドの開き直しの判定と試行の上限 2 回、項目の無い適用ラウンドを作らないこと、帰属の段落の後ろから必須の記名を読むこと。手順は `cross-refactoring` の `SKILL.md` と `docs/` が正 | | [ndf-cleanup-and-bundle-closing.md](ndf-cleanup-and-bundle-closing.md) | 後片付けが止まる条件(git の拒否だけ)、実行前確認の要否を決める 3 つの問い、まとまりの課題を終わりの工程で閉じる条件と結果の 4 値、配布の記録の形と読み方。手順は `merged` / `progress-tracking` / `release` の SKILL.md が正 | | [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) | `/goal` の工程を conductor / supervisor / worker の 3 層で通す運転。持ち場 5 つ、報告の 2 段、続けさせる回数、上限(429)で中断した層の再開。手順は `development-workflow` の `references/agent-layers.md` が正 | | [ndf-context-window-metrics.md](ndf-context-window-metrics.md) | 会話の記録から context window を 3 層で測る部品(`transcript_agents.py`)の値の取り方と、`skill-stats --agents` の 4 つの表と印。値の取り方はこの文書が正 | diff --git a/docs/specifications/cross-refactoring-apply-intake.md b/docs/specifications/cross-refactoring-apply-intake.md new file mode 100644 index 00000000..2e611a2d --- /dev/null +++ b/docs/specifications/cross-refactoring-apply-intake.md @@ -0,0 +1,410 @@ +# cross-refactoring: 実装担当が結果を残さないと同じ適用ラウンドが上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、開いた回数と結末で開き直しを決める + +## 目的 + +**担当の CLI が作業結果を残さずに終わっても、3 つの取り込み(適用・修正・最終ゲートの修正)は +同じ手順で受けて終わる。** 検証を受けていないコミットは取り消され、結末は記録に残り、終了 +コードが返る。下位の読み取りが進行を打ち切ることはない。 + +**同じ適用ラウンドを開き直す回数は 2 回で止まる。** 2 回目は別の担当が試し、2 回とも結果が +残らなければその適用ラウンドを取り消して見送りへ入れる。 + +**採用が 0 件だった提案ラウンドと、項目が 1 件も無い適用ラウンドでは担当を起動しない。** + +**起動し直しても解けない結末(利用上限)では、同じ担当を同じ工程で起動し直さない。** + +**実行環境が帰属の段落を後ろへ足したコミットでも、必須の記名が読める。** 進行側はコミットを +書き換えない。 + +**手順と引数の表は +[`cross-refactoring` の SKILL.md](../../plugins/ndf/skills/cross-refactoring/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、進行の内部の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 提案ラウンド | `rounds[]` | 提案 → 採否 → 適用 → 検証 → 修正の 1 周 | +| 適用ラウンド | `rounds[].apply_rounds[]` | 書き換えるファイルが重ならない改善項目の集まり。1 つで 1 コミット | +| 修正ラウンド | `fix_rounds` / `--max-fix-rounds` | 検証が落ちた適用ラウンドを直す 1 周と、その上限 | +| 取り込み | `merge-apply` / `merge-fix` / `merge-final-fix` | 担当が作ったコミットを進行側が検証して受け入れるコマンド。適用・修正・最終ゲートの修正の 3 つ | +| 適用ラウンドを開く | `next-apply-round` | 次に適用する 1 つを選び、担当と項目を出す | +| 最終ゲート | `final-gate` | 全体のテストを実行して合否を判定する | +| 試行 | `attempt` | 1 つの適用ラウンドに担当を起動し、適用の取り込みで受けようとした 1 回 | +| 結末 | `LaunchOutcome` | 担当 1 回の起動の終わり方。使える結果か、結果なしの理由かを持つ | +| 結果なし | `payload` が `None` | 結果ファイルが無い、または JSON オブジェクトとして読めない | +| 起動し直しの可否 | `relaunch_same_agent` | 同じ担当を同じ条件で起動し直せば解ける結末か | +| 結末の読み取り | `gitfacts.read_result` | 状態と工程から結果ファイルの名前の幹を組み、結末を値で返す包み | +| 結末の共通層 | `lib/monitor_outcome.py` の `read_launch_outcome` | 理由の語彙と可否を持つ、2 つの Skill 共通の読み取り | +| 取り込みの共通手順 | `refactor_lib/intake.py` | 範囲の確定・取り消し・結末の記録を 3 つの取り込みで共有する層 | +| 取り込みの範囲の値 | `IntakeScope` | 取り込み 1 つ分の「どこを見て、どこへ書くか」 | +| 範囲 | — | 起点(`apply_base_sha` / `fix_base_sha`)から作業ツリーの先端まで | +| 起点 | `apply_base_sha` / `fix_base_sha` / 適用ラウンドの `base_sha` | 範囲の始まり | +| 結末の記録 | `failed_attempts[]` | 結果を残さなかった起動の記録。適用ラウンドと最終ゲートの控えが持つ | +| 取り消しの理由 | `drop_reason` | 適用ラウンドを取り消した理由(`no_result` / `empty`) | +| 開き直しの判定 | `rounds.group_reopening` | その適用ラウンドを開くか・再開するか・開かないかを返す | +| 輪番から担当を引く関数 | `rounds.impl_for_seq` | 輪番の通し番号から担当と要求モデルを引く 1 か所 | +| 試行の上限 | `vocabulary.MAX_APPLY_ATTEMPTS`(2) | 同じ適用ラウンドを開き直す回数の上限 | +| 無進捗の許容 | `IMPL_STALL_TIMEOUT` / `vocabulary.IMPL_STALL_MARGIN`(900 秒) | 担当が何も出力しないまま待てる秒数と、その余白 | +| 必須の記名 | `Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model` | コミットメッセージのトレーラーとして担当が書く 4 つ | +| 帰属の段落 | `Co-Authored-By:` / `Claude-Session:` | 実行環境がコミットメッセージの末尾へ足す段落 | + +## 対象範囲 + +この文書が扱うのは、cross-refactoring の 3 つの取り込みと、適用ラウンドの進行の側である。 + +| 扱う | 扱わない | +| --- | --- | +| 結末を値で受け取る読み取りの包み、取り込みの共通手順、開き直しの判定、試行の上限と担当の交代、項目の無い適用ラウンド、修正と最終ゲートの修正の結果なし、必須の記名の読み方、無進捗の許容 | 結末の共通層そのもの(理由の語彙と可否を持つ側) | +| 輪番から担当を引く 1 つの関数へ寄せること | 参加者の決め方(別の課題が持つ) | +| 結末の記録の形(3 つの取り込みで同じ配列) | 記録を集計した実行の要約の新しい欄 | +| 進行側がコミットを書き換えないこと | 利用上限で進行全体を止めること | + +**輪番から担当を引く関数は、従来の担当の割り当てを包む形で実装されている。** 呼ぶのは 3 か所 +(適用ラウンドの割り当て・結果なしの試行の交代先・最終ゲートの修正担当)で、参加者の決め方が +変わったときに書き換える場所はこの関数の中だけである。 + +## 背景 + +**結果ファイルの読み取りが、そのまま進行の終了コードを決めていた。** 結果ファイルが無い・ +読めないときは下位の読み取りがプロセスを終わらせるため、その先にある範囲の検査・未検証の +コミットの取り消し・適用ラウンドの状態の記録へ一度も進めなかった。読み取りを呼ぶ取り込みは +適用・修正・最終ゲートの修正の 3 つあり、いずれも同じ形で止まっていた。 + +**適用ラウンドを開く側が、中断からの再開と失敗した試行のやり直しを区別していなかった。** +未着手と取り込み済みの適用ラウンドを無条件に開き直すため、結果を残さない担当に当たり続けた。 +実測では、担当が 15 秒で終わって結果を残さない構成で同じ適用ラウンドを 29 回開き直した時点で手で +止めている(PR #757)。別の実行では空振りの起動し直しが 3729 回に達した(#647)。 + +**採用が 0 件でも適用ラウンドが 1 つ作られていた。** 適用ラウンドの一覧は、鍵が無い状態と +空の配列を区別せずに 1 つの適用ラウンドを作っていた。テスト整備の採用が 0 件の実行では、項目が +1 件も無い適用ラウンドに担当を 187 回起動している(#592)。 + +**未検証のコミットが Pull Request に残りうる状態だった。** 最終ゲートの修正の取り込みが結果 +なしで止まると、担当の作ったコミットは範囲の検査も取り消しも受けない。次の最終ゲートはその +コミットを含む先端でテストし、落ちれば起点を先端へ置き直すため、そのコミットは以後どの範囲 +にも入らなかった(#674)。 + +**必須の記名が、実行環境の足す帰属の段落のために読めなかった。** git の標準の読み方は +メッセージの最後の段落しか読まない。claude が担当の適用ラウンドでは帰属の段落が後ろに付くため、 +4 つの記名が欠落と判定され、適用ラウンドが丸ごと取り消されていた(#553)。 + +**担当がテストを実行している間の無出力が、無進捗として打ち切られていた。** 適用と修正の担当は +テストを 1 回実行し、その間は何も出力しない(#647)。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 結末の読み取りは名前を残した包みとし、結果ファイルの名前の幹をそこで 1 度だけ組んで結末を値で返す | 監視へ渡した名前の雛形と食い違う幹で読むと監視の結果ファイルを引けない。取り込みが共通層を直に呼ぶ形では、幹の組み立てが 3 か所に分かれる | +| 引数を、ファイルのパスと担当から、状態・担当・工程・提案ラウンドへ変える | 古い形の呼び出しが実行時に失敗し、契約の変更を素通りしない | +| 「範囲の確定 → 未検証のコミットの取り消し → 結末の記録」を新しい共通の層へ置き、3 つの取り込みの差は範囲の値で渡す | 取り消しの本体が 2 つあり、違いは起点の鍵と記録先だけだった。取り込みに残るのは、その結果を終了コードと適用ラウンドの状態へ写すことだけになる | +| 共通の層をコマンドの層にも、git の事実の読み取りにも置かない | 3 つのコマンドが読む層である。コマンドどうしの取り込みを作らない。取り消しは事実の読み取りではなく進行の手順である | +| 結果なしの記録は、3 つの取り込みで同じ形の配列にする | 形を 3 通りに分けると、実行の要約と改修計画が 3 通りの読み方を持つ | +| 同じ適用ラウンドの試行の上限は 2 回の固定値とし、引数を足さない | 2 回目は別の担当が試すため、2 回とも残らなければ担当ではなく適用ラウンドの側を疑える。上限を 2 つ置くと、どちらで止まったかを読み解く必要が出る | +| 開き直しの判定を 1 つの関数へ置き、適用ラウンドを開く側と適用の取り込みの両方がそれを読む | 判定が 2 か所にあると、開く側とやり直しの側で違う答えを出す。判定を 1 か所に置けば、骨組みの分岐は変わらない | +| 開いた回数だけを数えず、結末の記録の件数と合わせて判定する | 進行側が落ちて再開しただけで試行が進んでしまう | +| 2 回目の試行は次の輪番の担当が行い、その適用ラウンドで失敗した担当のどれとも違う担当が出た最初の番号を採る | 結果を残さない原因の多くは担当の CLI の側にある。輪番を 1 つ進めるだけでは、1 周して同じ担当へ戻ることがある | +| 替える先が無いときだけ、起動し直しの可否で 2 回目を開くか取り消すかを決める | 参加者が 1 者に絞られた実行では交代先が無い。利用上限で進行全体を止めると、他の担当で進められる適用ラウンドまで止まる | +| 輪番を引く呼び出しを 1 つの関数へ寄せる | 参加者の決め方が変わったときに、変える場所がその関数の中だけで済む | +| 修正の結果は、提案ラウンドの担当ではなく適用ラウンドの担当から読む | 骨組みが起動するのは適用ラウンドの担当である。一致しない適用ラウンドでは結果を一度も取り込めず、検証と修正を往復し続ける | +| 修正の結果なしは修正ラウンドを 1 つ進め、起動し直せない結末では上限の値まで進める | 進めないと見送りの判定が上限に達する条件を満たさない。替えないまま上限まで起動し直すと、利用上限の担当を上限の回数だけ空振りさせる | +| 修正の担当は替えない | 直しかけの文脈を持つ者が続けたほうが速い。最終ゲートの修正が担当を替えない理由と同じである | +| 最終ゲートの修正も同じ共通の手順を通し、結果なしは取り消して判定へ戻す | 取り消せば、次の最終ゲートは修正前の地点でテストする。修正ラウンドの数を進めるのは最終ゲートの側なので、繰り返しは上限で止まる | +| 適用ラウンドの一覧は、鍵が無いときだけ古い版として 1 つ作り、空の配列はそのまま返す | 採用 0 件の提案ラウンドは空の配列を書く。ここで作ると項目の無い適用ラウンドが生まれる | +| 採用 0 件で提案の取り込みが繰り返しを終える合図を返す形は採らない | その合図はテスト整備では構造改善へ進む前に抜けてしまう | +| 必須の記名は、末尾の段落から前へ 1 段落ずつ git の判定に掛け、判定しなかった段落で止める | トレーラーの形で書かれた署名なら、誰が何行足しても同じに読める。全文から記名の形の行を拾う形では、散文の中の行まで拾う | +| 1 段落目(題名)は判定に掛けない | 掛けると、記名の形をした題名を記名として読む | +| 進行側は取り込みでコミットを書き換えない | コミットの識別子が変わり、申告と実体の対応が切れる。適用ラウンドに複数のコミットがあると、先頭の書き換えが以降をすべて書き換える。実際に使ったモデル名は担当しか知らない | +| 雛形のコミットの規約にも、必須の記名を最後の段落に置くことを書く | 人が git の標準の読み方で集計できる。従わなくても進行側の検証は通る | +| 無進捗の許容をテストの制限時間 + 900 秒とし、雛形に進捗の記録の指示を足す | 担当はテストを 1 回実行し、その間は何も出力しない。全体の制限時間は工程ごとの上限が決めるため渡さない | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 検証を受けていないコミットを、公開したまま次の工程へ渡さない | 3 つの取り込みが結果なしで範囲を取り消し、取り消した後の先端を次の起点にする | +| 取り消しへ着手する前に、公開の保留の印を立てて保存する | 取り消しは済んだのに公開できずに終わると、未検証の変更が Pull Request に残る | +| 取り消した後の起点は、その場で保存する | 保存せずに落ちると、次の実行が古い起点から範囲を取り直し、取り消しのコミット自体を未申告と判定する | +| 同じ工程・同じ試行番号の結末は、記録に 1 件しか入らない | 記録済みなら結果ファイルを読まずに終了コード 2 を返す。後から現れた結果ファイルを、取り消し済みの範囲の申告として取り込まない | +| 範囲の「1 件もコミットされていない」と「確定できなかった」を区別する | 混同すると、確定できないときに検査が素通りする | +| 結末の記録は追記だけで、上書きしない | 担当を替えると適用ラウンドの担当は書き換わるが、どの担当がどの試行で失敗したかは記録から読める | +| 取り消すコミットが無ければ、取り消しも公開も実行しない | 範囲が空のときは記録だけを足す | +| この変更の前に始めた実行の状態ファイルを、書き換えずに読める | 増えた鍵が無い状態は、試行 0 回・失敗なしとして読む | + +### 構成要素と責務 + +| 要素 | 責務 | 置き場所 | +| --- | --- | --- | +| 結末の読み取り | 状態と工程から結果ファイルの名前の幹を組み、結末の共通層を呼んで値を返す。中断も画面への出力もしない | `refactor_lib/gitfacts.py` の `read_result` | +| 取り込みの共通手順 | 範囲の確定・未検証のコミットの取り消し・叩き直しの判定・結果なしの一連 | `refactor_lib/intake.py` | +| 適用ラウンドの進行 | 適用ラウンドの一覧・進行中の適用ラウンド・開き直しの判定・輪番から担当を引く | `refactor_lib/rounds.py` | +| 適用の取り込みと開く側 | 開き直しの判定で開く。結果なしでは担当の交代か取り消しを決める | `refactor_lib/commands/apply.py` | +| 修正の取り込み | 適用ラウンドの担当の結果を読む。結果なしは修正ラウンドを進める | `refactor_lib/commands/converge.py` | +| 最終ゲートと最終ゲートの修正の取り込み | 共通の手順を通す。修正担当を輪番から引く | `refactor_lib/commands/gate.py` | +| 判断の基準になる値 | 試行の上限と、無進捗の許容の余白 | `refactor_lib/vocabulary.py` | +| 起動の出力 | 無進捗の許容を出す | `refactor_lib/commands/setup.py` | +| 必須の記名の読み取り | 末尾から続くトレーラーの段落を読む | `refactor_lib/gitfacts.py` の `commit_trailers` | +| 結末の共通層 | 理由の語彙と起動し直しの可否を持つ。この変更では読むだけ | `plugins/ndf/scripts/lib/monitor_outcome.py` | + +要素の関係(辺は呼び出し): + +```mermaid +graph TD + subgraph 取り込み + A[適用の取り込み] + F[修正の取り込み] + G[最終ゲートの修正の取り込み] + end + subgraph kyotsu["共通の手順"] + R[結末の読み取り] + I["取り込みの共通手順
範囲の確定 / 取り消し / 結末の記録"] + end + subgraph shinko["適用ラウンドの進行"] + N[適用ラウンドを開く] + RO[開き直しの判定] + IS[輪番から担当を引く] + end + MO[結末の共通層] + A --> R + F --> R + G --> R + R --> MO + A --> I + F --> I + G --> I + A --> RO + N --> RO + A --> IS + G --> IS +``` + +### 起動 1 回の結末を読む + +**結果ファイルを自前で開かず、結末の共通層を呼ぶ。** 包みが行うのは、状態・担当・工程・提案 +ラウンドから結果ファイルの名前の幹を組むことと、共通層の値をそのまま返すことだけである。 + +| 項目 | 内容 | +| --- | --- | +| 入力 | 状態ファイルの辞書(一時ディレクトリと実行の識別子を読む)、担当、工程(`apply` / `fix` / `final-fix`)、提案ラウンド(最終ゲートの修正では省く) | +| 出力 | 結末。使える結果、または結果なしの理由と起動し直しの可否 | +| 失敗の形 | **失敗しない。** 中断せず、標準出力と標準エラーにも書かない | + +**理由の語彙 9 語と起動し直しの可否は、結末の共通層が 1 か所で持つ。** この文書では定義せず、 +[起動 1 回の結末の仕様](cross-review-launch-outcome.md)を参照する。取り込みは理由をそのまま +記録へ写し、可否を担当の交代の分岐に使うだけで、利用上限の条件分岐を自分では持たない。 + +### 取り込み 1 回の共通手順 + +3 つの取り込みは、結果を読めたときも読めなかったときも同じことを行う。起点から先端までの +範囲を確かめ、通らなければ取り消し、起点を取り消した後の先端へ進める。違うのは起点の鍵と +記録先だけなので、その差だけを取り込みの範囲の値で受け取る。 + +| 手順 | 契約 | +| --- | --- | +| 範囲の確定 | 起点から先端までのコミットを新しい順で返す。確定できなければ「確定できなかった」を返す | +| 取り消し | 公開の保留の印を立てて保存 → 新しい順に取り消す → 起点(と適用ラウンドの起点)を先端にして保存。取り消した件数を返す | +| 叩き直しの判定 | 同じ工程・同じ試行番号の記録があれば真 | +| 結果なしの一連 | 範囲の確定 → 取り消し → 結末の記録へ 1 件を足して保存。取り消したときだけ公開する。範囲を確定できなければ、取り消しも記録も行わずに「確定できなかった」を返す | + +**何で終わるかは取り込みの側が決める。** 範囲を確定できなかったときの扱いは 3 つで異なる。 + +### 適用ラウンドを開くかどうかの判定 + +判定に使うのは適用ラウンドが持つ 2 つだけである。開いた回数と、結末の記録のうち工程が適用の +ものの件数である。 + +| 値 | 条件 | 意味 | +| --- | --- | --- | +| `empty` | 項目が 1 件も無い | 開かない。取り消し済み(理由は項目なし)にして次を探す | +| `exhausted` | 適用の失敗の件数が試行の上限に達した | 開かない。取り消し済み(理由は結果なし)にする | +| `resume` | 開いた回数が失敗の件数より多い | 開いたまま閉じていない試行の再開。起点も試行の番号も動かさない | +| `open` | 上のいずれでもない | 開いて試行の番号を進め、起点をその時点の先端にする | + +適用ラウンドを開く側の流れである。取り込み済みの適用ラウンドは判定に掛けず、起点も修正ラウンドの +数も動かさずに開き直す。適用は取り込んだが検証まで進めずに落ちた実行を再開できるようにする +ためである。 + +```mermaid +graph TD + S[未着手・取り込み済みの適用ラウンドを順に見る] --> K{状態} + K -->|無い| E1[終了コード 1] + K -->|取り込み済み| O[開き直す
起点と試行の番号を動かさない] + K -->|未着手| R{開き直しの判定} + R -->|項目なし・上限| D[取り消し済みにする] --> S + R -->|開く| INC[試行の番号を進め
起点を先端にする] + R -->|再開| OUT[担当と項目を出す] + INC --> OUT + O --> OUT +``` + +### 結果を残さなかった適用の試行の閉じ方 + +適用の取り込みは、置き土産の後始末と取り込み済みの判定を済ませ、着手前のテストが成功と確認 +できていることを確かめてから、叩き直しの判定を見て結果を読む。**着手前のテストの確認は結果を +読むより先に行う。** 成功と確認できていない状態で採ると、壊したのか元から壊れていたのかを +判別する手段が無い。 + +結果なしのときは次の順で閉じる。 + +1. 結果なしの一連を通す。範囲を確定できなければ、適用ラウンドの項目を保留にして終了コード 4 で + 中断する。試行の番号が 0 なら 1 として記録し、適用ラウンドの試行の番号を 1 にする +2. 開き直しの判定を読む。「開く」なら担当を替える。輪番の通し番号を 1 ずつ進め、その適用ラウンドで + 失敗した担当と現在の担当のどれとも違う担当が出た最初の番号を採る。参加者の数だけ進めても + 出なければ、替える先が無い +3. 替える先が無いときは、起動し直しの可否で決める。可(結果ファイルが無い・無進捗など)なら + 担当を替えずに 2 回目を開く。否(利用上限)ならその場で取り消す +4. 取り消すときは、適用ラウンドの項目を見送りへ入れて取り消し済み(理由は結果なし)にし、取り込みの + 時刻を立てて、残りの適用ラウンドの有無で次の局面を決める +5. 保存して終了コード 2 で終わる。公開は手順 1 が取り消したときに済んでいる + +**見送りの理由には、どの担当がどの理由で結果を残さなかったかを並べる。** 改修計画の見送った +項目の表にそのまま出る。 + +### 修正と最終ゲートの修正の結果なし + +どちらも叩き直しの判定 → 結末の読み取り → 結果なしの一連という同じ流れを通り、終了コード 2 で +終わる。違うのは修正ラウンドの数の進め方である。 + +| 取り込み | 範囲を確定できないとき | 起動し直しが可のとき | 起動し直しが否のとき | +| --- | --- | --- | --- | +| 修正 | 修正ラウンドを 1 進めて中断 | 修正ラウンドを 1 進める | 修正ラウンドを上限の値にする | +| 最終ゲートの修正 | そのまま中断 | 修正ラウンドを進めない(次の最終ゲートが進める) | 修正ラウンドを上限の値にする | + +**修正の担当は適用ラウンドの担当から読む。** 結末の記録も、最終ゲートの修正を除いてその適用ラウンド +が持つ。担当が適用ラウンドごとに決まるためである。 + +**最終ゲートの修正を取り消すと、次の最終ゲートは修正前の地点でテストする。** 取り消した +コミットは修正のコミットの一覧に入らない。起動し直しが否で修正ラウンドが上限の値になった後の +最終ゲートは、テストが落ちれば取り消さずに報告して終わる。押し出し済みの地点で、上限に達しても +採用した改善項目は取り消さないという既存の規則を変えない。 + +### 項目の無い適用ラウンドを作らない + +3 か所で止める。 + +| 場所 | 扱い | +| --- | --- | +| 適用ラウンドの一覧 | 鍵が無いときだけ、古い版として提案ラウンド全体を 1 つの適用ラウンドとしてまとめる。空の配列はそのまま返す | +| 適用ラウンドを開く側 | 項目の無い未着手の適用ラウンドは開かずに取り消し済み(理由は項目なし)にし、次を探す。残りが無ければ終了コード 1 | +| 適用の取り込み | 取り込み済みで採用が 0 件だった適用ラウンドは、取り消し済み(理由は項目なし)に直して終了コード 2 | + +### 必須の記名の読み方 + +コミットメッセージを空行で段落に分け、末尾の段落から前へ 1 段落ずつ git の判定に掛ける。 +判定がトレーラーの段落と認めなかった段落で止める。同じ鍵が 2 つの段落にあれば、末尾に近い +段落の値を採る。**1 段落目(題名)は判定に掛けない。** + +| 読む側 | 読み方 | どこまで読むか | +| --- | --- | --- | +| 進行側の検証 | 末尾から続くトレーラーの段落 | 帰属の段落を越えて必須の記名まで届く | +| 人の集計 | git の標準の読み方 | 最後の段落だけ | + +**判定そのものは git に委ねる。** 「何行以上なら記名の段落か」といった規則を進行側に持たない。 +段落だけを渡すと git は何も返さないため、題名の行を補って渡す。 + +雛形のコミットの規約は、必須の記名をメッセージの最後の段落に置き、実行環境が帰属行を足すときは +空行を挟まず同じ段落に続けることを求める。**従わなくても進行側の検証は通る。** 人の集計を +成り立たせるための補助である。 + +### 無進捗の打ち切り + +起動が無進捗の許容を出し、骨組みの適用・修正・最終ゲートの修正の 3 つの監視の呼び出しが、それを +無進捗の引数へ渡す。全体の制限時間の引数は渡さない。工程ごとの上限は別の仕組みが決める。 + +雛形は、作業段階が進むたびに進捗の記録へ 1 行追記することを担当へ求める。 + +### 終了コード + +| コマンド | 0 | 1 | 2 | 4 | +| --- | --- | --- | --- | --- | +| 適用ラウンドを開く | 開いた | 残りの適用ラウンドが無い | — | — | +| 適用の取り込み | 取り込んだ | — | この適用ラウンドを取り消した、または担当を替えて開き直す | 着手前のテストが成功と確認できていない・範囲を確定できない・適用ラウンドが無い | +| 修正の取り込み | 取り込んだ | — | 範囲を確定できない・結果なし | 適用ラウンドが無い | +| 最終ゲートの修正の取り込み | 取り込んだ | — | 範囲を確定できない・結果なし | 修正担当が未記録 | + +**骨組みの分岐は変わらない。** 適用の取り込みの 2 で次の適用ラウンドへ進み、4 で止まる。修正と最終 +ゲートの修正の取り込みの終了コードは骨組みが見ない。 + +## データ・設定 + +### 状態ファイルに増える項目 + +**版は上げない。** 鍵が無い状態ファイルは、試行 0 回・失敗なしとして読む。 + +適用ラウンド 1 件(`rounds[].apply_rounds[]` の要素)に足す鍵である。 + +| 項目 | 型 | 値 | 鍵が無いときの意味 | +| --- | --- | --- | --- | +| `attempt` | 整数 | いま開いている試行の番号。1 から | 0(まだ開いていない)。適用の取り込みは 0 を 1 回目として記録する | +| `failed_attempts` | 配列 | 結果を残さなかった起動。1 件の形は下の表 | 失敗なし | +| `drop_reason` | 文字列 | `no_result`(試行の上限、または起動し直せない結末で交代先なし)/ `empty`(項目なし) | 既存の経路で取り消した、または取り消していない | + +最終ゲートの控え(`final_gate`)にも、同じ形の結末の記録(`failed_attempts`)を足す。 + +**担当(`impl` / `impl_model`)は交代のときに書き換える。** 前の担当は結末の記録に残る。 + +### 結末の記録の 1 件 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `phase` | 文字列 | 許さない | `apply` / `fix` / `final-fix`。同じ適用ラウンドの適用と修正の記録を分ける | +| `attempt` | 整数 | 許さない | 適用は適用ラウンドの試行の番号、修正は修正の試行の番号、最終ゲートの修正は修正ラウンドの数。叩き直しの判定の鍵 | +| `impl` | 文字列 | 許さない | 起動した担当 | +| `reason` | 文字列 | 許さない | 結末の理由。語彙は結末の共通層が持つ | +| `detail` | 文字列 | 許す(空文字) | 監視の詳細。監視の結果ファイルが無ければ空文字 | +| `at` | 文字列 | 許さない | 記録した時刻 | +| `reverted` | 整数 | 許さない | その起動の範囲から取り消したコミットの数。0 = コミットなし | + +### 取り込みごとの範囲の値 + +| 取り込み | 起点を持つ控え | 起点の鍵 | 記録先 | 工程 | 起点を揃える先 | +| --- | --- | --- | --- | --- | --- | +| 適用 | 提案ラウンドの控え | `apply_base_sha` | その適用ラウンド | `apply` | その適用ラウンドの `base_sha` | +| 修正 | 提案ラウンドの控え | `fix_base_sha` | その適用ラウンド | `fix` | なし | +| 最終ゲートの修正 | 最終ゲートの控え | `fix_base_sha` | 最終ゲートの控え | `final-fix` | なし | + +### 起動の出力と骨組みの引数 + +| 値 | 決め方 | 既定 | +| --- | --- | --- | +| 無進捗の許容(`IMPL_STALL_TIMEOUT`) | テストの制限時間(`--test-timeout`)+ 余白 900 秒 | 1800 秒 | +| 試行の上限(`MAX_APPLY_ATTEMPTS`) | 固定値。引数を持たない | 2 回 | + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 結末の読み取りが中断も出力もせず、結果なしと理由を値で返すこと | `plugins/ndf/skills/cross-refactoring/tests/test_intake.py` | +| 3 つの取り込みの形で、範囲が 1 件・0 件・確定できない場合の取り消しと記録が同じになること。叩き直しで記録が増えないこと | 同 `tests/test_intake.py` | +| 結果を残さない担当で、担当の交代 → 取り消し → 見送りまで進み、開き直しの回数が有限で終わること | 同 `tests/test_apply_attempts.py` | +| 交代先が無いとき、起動し直しの可否で 2 回目を開くか取り消すかが決まること | 同 `tests/test_apply_attempts.py` | +| 着手前のテストが成功と確認できていないとき、結果ファイルを読まずに終了コード 4 で終わること | 同 `tests/test_apply_attempts.py` | +| 開き直しの判定と輪番から担当を引く関数を差し替えると、開く側と取り込みの両方の分岐が従うこと | 同 `tests/test_apply_attempts.py` / `tests/test_final_fix.py` | +| 採用 0 件と項目の無い適用ラウンドで、適用ラウンドを作らず開かずに取り消すこと | 同 `tests/test_apply_rounds.py` | +| 修正の結果を適用ラウンドの担当から読み、結果なしで修正ラウンドが進んで見送りへ至ること | 同 `tests/test_abandon_items.py` | +| 最終ゲートの修正の結果なしで取り消し、次の最終ゲートが取り消し後の地点でテストすること | 同 `tests/test_final_fix.py` | +| 帰属の段落が後ろに付いたコミットから必須の記名を読み、散文の中の記名の形の行を読まないこと | 同 `tests/test_commit_trailers_git.py`(一時リポジトリで git を実行する) | +| 無進捗の許容が起動の出力に入り、骨組みの 3 つの監視の呼び出しへ渡ること | 同 `tests/test_init.py` / `tests/test_skill_terms.py` | +| 結果ファイルがあり検証を通る適用ラウンドが、変更の前と同じく 1 回目で取り込まれること | 同 `tests/test_merge_apply.py` | +| 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py` | +| 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py` | + +## 関連リンク + +- [issue #728](https://github.com/devbasex/ai-plugins/issues/728) — 結果なしを取り込みの 1 か所で受ける +- [issue #647](https://github.com/devbasex/ai-plugins/issues/647) — 同じ適用ラウンドが上限なしに再試行される +- [issue #592](https://github.com/devbasex/ai-plugins/issues/592) — 採用 0 件で項目の無い適用ラウンドが開く +- [issue #553](https://github.com/devbasex/ai-plugins/issues/553) — 帰属の段落で必須の記名が読めない +- [issue #674](https://github.com/devbasex/ai-plugins/issues/674) — 最終ゲートの修正で未検証のコミットが残る +- [PR #796](https://github.com/devbasex/ai-plugins/pull/796) — 実装 +- [起動 1 回の結末の語彙と起動し直しの可否](cross-review-launch-outcome.md) +- [参加する CLI と席の決め方](cross-review-participants-and-seats.md) +- [`cross-refactoring` の適用と検証の手順](../../plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md) +- [`cross-refactoring` の修正と報告の手順](../../plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md) +- [`cross-refactoring` の手順](../../plugins/ndf/skills/cross-refactoring/SKILL.md) diff --git a/docs/specifications/cross-review-launch-outcome.md b/docs/specifications/cross-review-launch-outcome.md index be6de51a..d6339487 100644 --- a/docs/specifications/cross-review-launch-outcome.md +++ b/docs/specifications/cross-review-launch-outcome.md @@ -200,8 +200,8 @@ pid だけへシグナルを送っており、CLI が起こした子プロセス ### cross-refactoring の読み取りの契約 -cross-refactoring の取り込みが従う契約を、ここで定める。**この契約の実装は #728 にあり、 -この時点では入っていない。** +cross-refactoring の取り込みが従う契約を、ここで定める。**契約を守る側の仕様は +[結果なしの取り込みと開き直し](cross-refactoring-apply-intake.md)にある。** | 項目 | 契約 | | --- | --- | @@ -248,7 +248,7 @@ cross-refactoring の取り込みが従う契約を、ここで定める。**こ - [issue #729](https://github.com/devbasex/ai-plugins/issues/729) — 結果なしの判断を共通層へ移す - [issue #619](https://github.com/devbasex/ai-plugins/issues/619) — 利用上限で止まった担当の空振りの起動し直し - [issue #584](https://github.com/devbasex/ai-plugins/issues/584) — 止めた担当が後から結果ファイルを書く -- [issue #728](https://github.com/devbasex/ai-plugins/issues/728) — cross-refactoring 側の読み取りの実装 +- [結果なしの取り込みと開き直し](cross-refactoring-apply-intake.md) — cross-refactoring 側の読み取りを使う仕様 - [PR #791](https://github.com/devbasex/ai-plugins/pull/791) — 実装 - [`cross-review` の状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) - [`cross-review` の状態とレビューの手順](../../plugins/ndf/skills/cross-review/docs/01-state-and-review.md) diff --git a/docs/specifications/cross-review-participants-and-seats.md b/docs/specifications/cross-review-participants-and-seats.md new file mode 100644 index 00000000..90c2f742 --- /dev/null +++ b/docs/specifications/cross-review-participants-and-seats.md @@ -0,0 +1,401 @@ +# cross-review: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる + +## 目的 + +**参加する CLI のどれか 1 者が導入・認証されていなくても、収束ループが始まる。** 使えない者と +その理由は、初期化の出力と状態ファイルに残る。 + +**各ラウンドに 2 つの席が確保される。** 使える者が 2 者に満たないときは、ホスト、次に同じ +ランタイムの 2 つ目が席を埋める。1 席で回るのは、利用者が 1 者指定を渡したときだけである。 + +**中断した収束ループを引数を変えて再開すると、その引数は反映されるか、反映しないと知らされる。** +黙って捨てられる引数は無い。 + +**使える者の決定・席の埋め方・再開の反映の 3 つの規則は、収束ループを回す 2 つの Skill が +共有する共通層が 1 か所ずつ持つ。** Skill の側は結果を状態ファイルと終了コードへ写すだけである。 + +**手順と引数の表は +[`cross-review` の SKILL.md](../../plugins/ndf/skills/cross-review/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、共通層の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| ランタイム | `ALL_RUNTIMES` | `claude` / `codex` / `agy` / `kiro` の 4 つ。並びは固定 | +| ホスト | `host` | 収束ループを起動している CLI | +| 参加の母集合 | `review_pool(host)` / `refactor_pool(host)` | Skill ごとに決まる参加者の出発点。cross-review は全ランタイム − ホスト | +| 参加者 | — | 参加の母集合に足す者を加え、外す者を除いた一覧。認証の確認の対象 | +| 使える者 | `participants.available` | 参加者のうち認証の確認を通った者 | +| 認証の確認 | `probe_auth` | 確認コマンドを走らせ、止めずに結果だけを返す共通層の関数 | +| 使える者の解決 | `resolve_participants` | 参加の母集合・足す者・外す者・1 者指定・確認の結果から使える者を決める共通層の関数 | +| 参加者の記録 | `participants` | 使える者の解決の結果を持つ状態ファイルの項目 | +| 席 | — | 1 ラウンドで 1 つの CLI プロセスが占める場所 | +| 席の名前 | `SEAT_PATTERN` / `seat_runtime` | ランタイム名か、その名前に `-2`〜`-9` を付けた形 | +| 席の埋め方 | `review_seats` | 使える者と埋め合わせから 2 席を返す共通層の関数 | +| 埋め合わせ | `participants.fallback` | 席が足りないときに使う者。ホストが入る | +| 適用の輪番 | `impl_assign` | 参加者から実装担当 1 者を返す共通層の関数 | +| 1 者指定 | `--only` | 担当を 1 者に固定する引数 | +| 全員を要する指定 | `--require-all` | 認証の確認の失敗が 1 者でもあれば初期化を止める引数 | +| 再開 | — | 状態ファイルが残り `final` が `null` のときの初期化 | +| 再開の反映 | `apply_resume_args` / `ResumeField` | 渡した引数を状態へ重ね、反映しない引数を知らせる共通層の関数と、表の 1 行 | +| 再開で変えた値の記録 | `resume_changes` | 再開で変えた値を積む状態ファイルの項目 | +| 初期化 / ラウンドの開始 / 結果の受け口 / 完了報告 | `init` / `start-round` / `read-result` / `report` | `state.py` の副コマンド | + +## 対象範囲 + +この文書が扱うのは、共通層と cross-review の側である。 + +| 扱う | 扱わない | +| --- | --- | +| 共通層の使える者の解決・認証の確認・席の埋め方・適用の輪番・席の名前・再開の反映 | cross-refactoring の参加の母集合と担当の差し替え(別の課題が持つ) | +| cross-review の新規と再開の初期化、担当の決まる順、席の名前が流れる経路、完了報告の参加者の節 | 起動した後に分かる使えなさで担当を自動的に外す仕組み | + +適用の輪番は共通層に入っているためここで扱う。呼び出す側は cross-refactoring だけであり、 +その置き換えはこの時点では入っていない。 + +## 背景 + +**認証の確認が関門だった。** 従来の確認は 1 件の失敗でその場で終了しており、4 つの CLI が +揃っていない環境では収束ループを開始できなかった。使える CLI が 2 者あっても、3 者目が +入っていなければ初期化ごと止まる。 + +**使える者が 2 者に満たないラウンドの扱いが無かった。** 担当を決める関数は参加の母集合を +「全ランタイム − ホスト」の定数から作り、使える者を入力に取らない。そのため一部が使えない +構成では、担当が 1 者になるか、担当そのものを決められなかった。 + +**再開で渡した引数が黙って無視されていた。** 中断した収束ループを 1 者指定やラウンドの上限を +変えて再開しても、状態ファイルの値がそのまま使われる。利用者には、渡した引数が効いたのか +どうかを知る手立てが無かった。 + +**前のラウンドの検査が、固定の 2 者で結果を数えていた。** 担当がその 2 者と違うラウンドでは、 +担当でない者を結果なしと読み、修正の記録が無いまま次のラウンドへ通していた。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 使える者を決める規則を共通層の 1 つの関数へ置き、Skill は結果を状態ファイルと終了コードへ写すだけにする | 規則を Skill ごとに書くと、参加の母集合の作り方・除外の検査・確認の扱いが 2 か所にでき、片方だけが古くなる | +| 認証の確認は止めずに結果だけを返す形にし、確認コマンド・未認証の文言・時間切れの秒数は変えない | 確認と中断が 1 つの関数にあると、使える者で回す経路から呼べない。止めるかどうかは全員を要する指定が決める | +| 参加者は「既定に足す者を加え、外す者を除く」で決め、既定は Skill ごとの関数が持つ | 使う側を並べる形は、ホストが変わるたびに一覧を書き直すことになる | +| 全員が揃わないなら始めたくない運用のために、従来の関門を指定で選べるようにする | 既定を変えるだけでは、揃っていることを要求する運用が選べなくなる | +| 席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める | 各ラウンドで 2 つの目で見ることを最優先にする。同じ言語モデルの 2 つの文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる | +| 埋め合わせの候補は、使える者に含まれない者だけを使う | 足す者の指定でホストが使える者に入っているとき、ホストを埋め合わせにも使うと同じ席の名前が 2 つ並ぶ | +| 1 者指定のときは埋め合わせをせず、ホストの確認も行わない | 利用者が 1 席と決めた指定である | +| 担当の単位を席の名前にし、1 つ目の席の名前はランタイム名そのままにする | 同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。埋め合わせが要らない実行では、従来と同じ名前しか現れない | +| 接尾辞の区切りをハイフンにする | ランタイム名にハイフンを含むものが無いため、シェル側の切り出しと共通層の解釈が同じ規則になる | +| 監視では、席の形に合わない名前をそのまま返す | 担当名を任意の骨格で受ける cross-refactoring の経路がある。形で弾くとその経路が壊れる | +| 担当はラウンドの記録から先に見る | 再開で 1 者指定を変えても、過去のラウンドの担当が変わらない | +| 前のラウンドの検査にも、そのラウンドの担当を渡す | 固定の 2 者で数えると、担当が違うラウンドで担当でない者を結果なしと読み、修正の記録が無いまま次へ通す | +| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の式を保つ | ラウンド 1 が 2 番目の者から始まるため、ホストが最初に適用する形にならない | +| 再開の反映を共通層に置き、どの引数を「反映する」「知らせる」にするかは Skill ごとの表が持つ | 片方の Skill の再開の経路だけを直すと、もう片方に引数を捨てる形が残る | +| 状態ファイルに載る引数は表のどちらかに必ず載せ、引数の既定を未指定にする | 黙って捨てる引数を残さない。既定値と同じ値なら渡していないとみなす形では、上限を既定値へ戻す操作を区別できない | +| 担当に関わる引数を渡した再開でだけ、認証の確認をやり直して参加者を作り直す | 途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる | +| 作り直しのとき、渡さなかった引数は状態ファイルの値で補う | 初期値へ戻すと「明示した引数だけを反映する」が破れる | +| 指定を外す値は予約語 `none` にする | 骨組みは値のあるときだけ引数を渡すため、空文字列では指定を外せない | +| 参加者の作り直しは、記録へ 1 件として積む | 項目ごとに積むと 1 回の再開で最大 7 件になり、完了報告で読みにくい | +| 骨組みは 1 者指定のシェル変数で担当を絞り直さず、ラウンドの開始が返す席を使う | 返る一覧は 1 者指定と埋め合わせを反映済みである。もう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も誰にも当たらない | +| 起動した後に分かる使えなさで担当を自動的に外す仕組みは作らない | 一時的な打ち切りでも、以後のラウンドから恒久的に外れる | +| 規則の実装は共通層、手順は各 Skill の手順書、理由はこの文書が持つ | 規則が決まっても、置き場所が決まらなければ次に使う人へ届かない | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 使える者の並びは、ランタイムの固定の順である | 解決の中で並べ直すため、入力の順序に依らない | +| 使える者が 3 者のときの席は、この変更の前の輪番と同じ値になる | 4 つのホスト × ラウンド 1〜12 の全組で一致することをテストが固定する | +| 1 ラウンドの席は 2 つで、同じ席の名前が 2 つ並ばない | 埋め合わせの候補から使える者を除いて選ぶ | +| 使える者も埋め合わせも無ければ、席を返さず失敗する | 割り当ての失敗(`AssignmentError`)を上げ、初期化は状態ファイルを作らずに終了コード 1 | +| 状態ファイルに載る引数は、再開の表の「反映する」か「知らせる」のどちらかに載る | 表に無い引数は、状態ファイルに載らない 3 つ(作業ツリー・観点・追加指示のファイル)だけである | +| 再開で渡さなかった引数は、状態ファイルの値のまま残る | 参加者を作り直すときも、渡さなかった引数は記録から補う | +| この変更の前に始めた実行の状態ファイルを、書き換えずに読める | 項目が無いときの読み方を「項目が無いときの読み方」が決める | + +### 構成要素と責務 + +| 要素 | 責務 | 置き場所 | +| --- | --- | --- | +| 参加の母集合の既定 | Skill ごとの出発点を返す | `lib/assignment.py` の `review_pool` / `refactor_pool` | +| 使える者の解決 | 参加の母集合・ホスト・足す者・外す者・1 者指定・確認・全員を要するかから、参加者の記録を返す。名前の矛盾と欠けを例外で返す | 同 `resolve_participants` | +| 認証の確認 | 確認コマンドを走らせ、止めずに担当ごとの結果を返す | `lib/auth.py` の `probe_auth` | +| 席の埋め方 | 使える者と埋め合わせから 2 席を返す。規則の表はこの関数の docstring が正 | `lib/assignment.py` の `review_seats` | +| 適用の輪番 | 参加者から実装担当 1 者を返す | 同 `impl_assign` | +| 席の名前 | 席の名前からランタイムを引く。形の検査を持つ | 同 `seat_runtime` / `SEAT_PATTERN` | +| 再開の反映 | 表に従って状態へ書き、記録へ積み、知らせる行を返す | `lib/statefile.py` の `apply_resume_args` / `ResumeField` | +| 使える者の決定(cross-review) | 共通層を呼び、埋め合わせを決め、参加者の記録を返す。失敗を終了コード 1 へ写す | `cross-review/scripts/state.py` の `_resolve_reviewers` | +| 担当の読み出し | 決まった順で席を返す。前のラウンドの検査へその担当を渡す | 同 `_round_reviewers` / `_guard_previous_round` | +| 席の受け口 | 席の名前を受け、起動する CLI を席の名前から引く | `read-result` の引数の型、`launch-reviewer.sh`、`critique.sh`、`lib/monitor.py`、`measure.py` | +| 完了報告の参加者の節 | 参加した者・外した者・確認を通らなかった者・埋め合わせ・再開で変えた値を出す | `cross-review/scripts/state.py` の `_print_participants` | + +要素の関係(辺は呼び出し): + +```mermaid +graph TD + PL[参加の母集合の既定] --> RP[使える者の解決] + RP --> PA[認証の確認] + RI[初期化] --> RP + RI --> RA[再開の反映] + RR[担当の読み出し] --> RS[席の埋め方] + RC[席の受け口] --> SR[席の名前] +``` + +### 使える者の決め方 + +参加者を決め、認証の確認を通った者を使える者として記録する。順序は次のとおりである。 + +| 順 | 何をするか | +| ---: | --- | +| 1 | 足す者・外す者の各名前がランタイムの一覧にあり、互いに重ならないことを確かめる。外す者が「既定 ∪ 足す者」に含まれなければ弾く | +| 2 | 参加者 = 既定 ∪ 足す者 − 外す者(ランタイムの固定の順) | +| 3 | 1 者指定があれば、参加者に含まれ外す者に無いことを確かめ、参加者をその 1 者にする | +| 4 | 参加者へ認証の確認を行う。確認を飛ばす環境変数(`NDF_SKIP_AUTH_CHECK`)が立っていれば全員を通ったものとし、飛ばした印を真にする | +| 5 | 全員を要する指定が真で通らない者がいれば、欠けた者と理由を並べて失敗する | +| 6 | 通った者を使える者、通らなかった者と理由を通らなかった者として返す | + +**外した者へは確認コマンドを呼ばない。** 確認の回数は「参加者の数 + 埋め合わせが要るときの +ホストの 1 回」を超えない。 + +cross-review の新規の初期化は、この結果に席の埋め合わせを足して状態ファイルへ書く。 + +```mermaid +graph TD + H[ホストを確定] --> P[参加の母集合の既定を引く] + P --> RP[使える者の解決] + RP -->|名前の矛盾 / 欠け| F[状態ファイルを作らず終了コード 1] + RP -->|通った| N{使える者が 2 者以上} + N -->|はい| W[状態ファイルを書く] + N -->|いいえ| HP[ホストを確認し埋め合わせを決める] + HP -->|席を埋められる| W + HP -->|埋められない| F +``` + +**1 者指定のときは、この分岐へ入らない。** 埋め合わせを行わず、指定した 1 者が確認を通らない +ときだけ終了コード 1 で終わる。確認を通らない担当が席に座ると、レビューが行われないまま +収束するためである。 + +### 席の埋め方 + +| 使える者の数 | 席 | +| ---: | --- | +| 3 以上 | ラウンド番号を使える者の数で割った余りの位置と、その次の位置の 2 者。並びは使える者の順 | +| 2 | その 2 者 | +| 1 | その 1 者と、埋め合わせのうち使える者に含まれない先頭の者。そのような者が無ければ、その 1 者の 2 つ目 | +| 0 | 埋め合わせの先頭と、その 2 つ目。埋め合わせが空なら失敗する | + +**3 者のときの値は、この変更の前の輪番と一致する。** 4 者のときはラウンド 1〜4 で各者が +ちょうど 2 回担当になる。 + +### 席の名前 + +**担当の単位は席の名前である。** 形はランタイム名か、その名前にハイフンと 2〜9 を付けたもの +(`^(claude|codex|agy|kiro)(-[2-9])?$`)で、後者が同じランタイムの 2 つ目以降を表す。 + +| 受け口 | 席の名前の扱い | +| --- | --- | +| 結果の受け口の担当の引数 | 席の形を型として検査する。通らなければ終了コード 2 | +| レビューの起動(`launch-reviewer.sh`)と反証の起動(`critique.sh`) | 席の形を先頭で検査し、起動する CLI を `${SEAT%%-*}` で選ぶ。結果ファイルの stem は席の名前で組む | +| 監視(`lib/monitor.py`) | 位置引数は席の形か `both` を通す。CLI 固有のログの検査は席の名前からランタイムを引いて選ぶ。席の形に合わない名前はそのまま返す | +| 計測(`measure.py`) | 担当の記録を数えるとき、名前の一覧ではなく席の形に一致する鍵を数える | + +埋め合わせがあるラウンドで席の名前が流れる経路は次のとおりである。 + +```text +start-round → REVIEWERS="codex claude-2" + → launch-reviewer.sh claude-2 … : 起動する CLI は claude、stem は claude-2-review-pr + → monitor.py --agents codex,claude-2 + → state.py read-result claude-2 : rounds[-1]["claude-2"] へ書く + → critique-round.sh codex claude-2 → critique.sh claude-2 … +``` + +### ラウンドの担当が決まる順 + +**先に当たったものを採る。** + +| 順 | 状態 | 返る担当 | +| ---: | --- | --- | +| 1 | そのラウンドの記録に担当がある | その値 | +| 2 | 1 者指定がある | その 1 者だけ | +| 3 | 参加者の記録がある | 使える者と埋め合わせから 2 席 | +| 4 | ホストがある | 参加の母集合から 2 席(この変更の前の輪番と同じ値) | +| 5 | どれも無い | `codex` / `agy` | + +**ラウンドの記録を 1 者指定より先に見る。** 再開で 1 者指定を変えても、過去のラウンドの担当が +変わらないようにするためである。 + +**前のラウンドの検査は、そのラウンドの記録の担当で結果を数える。** 記録が無いときだけ、この順で +引き直す。 + +### 適用の輪番 + +実装担当 1 者を「ラウンド番号を参加者の数で割った余り」の位置から選ぶ。式はこの変更の前と +同じで、除数だけが参加者の数になる。ラウンド 1 が 2 番目の者から始まるため、ホストが最初に +適用する形にならない。呼び出す側は cross-refactoring だけであり、その置き換えはこの時点では +入っていない。 + +### 再開で渡した引数の扱い + +| 扱い | 引数 | 何が起きるか | +| --- | --- | --- | +| 反映する | `--max-rounds` / `--rotate-after` / `--verify-command` / `--verify-exit-code` | 状態ファイルを書き換え、記録へ 1 件積み、変更の行を出す | +| 反映し、参加者を作り直す | `--only` | 上に加えて、認証の確認をやり直して参加者の記録を置き換える。`none` で 1 者指定を外す | +| 参加者を作り直す | `--exclude` / `--include` / `--require-all` | 使える者を解決し直して参加者の記録を置き換える。`none` で一覧を空へ戻す | +| 知らせる | `--host` | 状態ファイルは変えず、状態と違うときだけ 1 行を出す | + +**値が同じ引数は、行も記録も出さない。** + +```mermaid +graph TD + A[状態ファイルを読む] --> AR[再開の反映] + AR --> N[知らせる行を出す] + N --> RA{担当に関わる引数を渡した} + RA -->|はい| RP[使える者の解決をやり直す] + RP -->|失敗| F[状態ファイルを書き換えず終了コード 1] + RP -->|通った| W[参加者の記録を書き 1 件積む] + RA -->|いいえ| W2[変えた項目だけ書く] +``` + +**作り直しの入力は 2 つである。** 渡した引数と、渡さなかった引数の状態ファイルの値(足した者・ +外した者・全員を要するかと、1 者指定)である。ホストを足して始めた実行へ外す指定だけを渡した +再開では、足した者の記録はそのまま残る。 + +**作り直しの失敗は、状態ファイルを書き換える前に起きる。** 使える者の解決を先に呼び、通って +から状態ファイルへ書く。 + +### 完了報告の「参加した者」 + +完了報告の PR 履歴の後に、7 行の節を出す。 + +```text +## 参加した者 +- 母集合: codex / agy / kiro +- 使える者: codex / kiro +- --exclude で外した者: agy +- --include で足した者: なし +- 確認を通らなかった者: なし +- 席の埋め合わせ: なし +- 再開で変えた値: 2026-09-19T12:00:00 participants … → … +``` + +参加者の記録を持たない状態ファイルでは「使える者: 記録なし」の 1 行だけを出す。確認を飛ばした +印が真のときは、通らなかった者の行に「確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。 + +## データ・設定 + +### 状態ファイルに増える 2 項目 + +形式そのものは +[04-contracts.md](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)が正である。 + +| 項目 | 型 | 意味 | +| --- | --- | --- | +| `participants` | オブジェクト(下の表) | 使える者の解決の結果。項目が無いのは、この変更の前に始めた実行である | +| `resume_changes` | オブジェクトの配列 | 再開で変えた値の記録。追記だけを行う。要素は `at` / `field` / `from` / `to` | + +参加者の記録の中身は 8 項目である。共通層が 7 項目を返し、埋め合わせだけを cross-review が足す。 + +| 項目 | 型 | 意味 | +| --- | --- | --- | +| `pool` | 文字列の配列 | 参加の母集合の既定。ランタイムの固定の順 | +| `included` | 文字列の配列 | 足した者。空は「足していない」 | +| `excluded` | 文字列の配列 | 外した者。空は「外していない」 | +| `available` | 文字列の配列 | 使える者。1 者指定があればその 1 者だけ | +| `unavailable` | オブジェクト | 確認を通らなかった者と理由 | +| `probe_skipped` | 真偽値 | 確認を飛ばしたか。通らなかった者が空である理由を区別する | +| `require_all` | 真偽値 | 全員を要する指定の値。新規の既定は偽 | +| `fallback` | 文字列の配列 | 席の埋め合わせに使える者。使える者が 2 者以上か、1 者指定があれば空 | + +**ラウンドの記録の担当と鍵には席の名前が入りうる。** 過去のラウンドの担当は再開で書き換えない。 + +### 初期化の引数 + +| 引数 | 型 | 既定 | 新規の経路 | 再開の経路 | +| --- | --- | --- | --- | --- | +| `--max-rounds N` / `--rotate-after K` | 整数 | 未指定 | 無ければ 12 / 8 | 渡せば反映 | +| `--only RUNTIME` | 4 つの名前か `none` | 未指定 | `none` は無しと同じ | 渡せば反映。`none` で外す | +| `--exclude NAMES` / `--include NAMES` | カンマ区切りの名前。繰り返し可。`none` | 未指定 | 外す / 足す | 渡せば置き換え。`none` で空 | +| `--require-all` / `--no-require-all` | 真偽値 | 未指定 | 無ければ偽 | 渡せば反映 | +| `--host RUNTIME` | 4 つの名前 | 未指定 | 無ければ推定 | 反映せず、違えば 1 行 | +| `--verify-command CMD` / `--verify-exit-code N` | 繰り返し可 | 未指定 | 無ければ空 | 渡せば置き換え | + +**上限の既定を引数の側に置かない。** 新規の経路が定数を置く。引数の側に既定を置くと、再開の +たびに利用者が指定していない値で状態ファイルを上書きする。 + +**名前の検査は 2 段に分かれる。** 綴りは引数の型が弾き(終了コード 2)、参加の母集合との関係は +共通層が弾く(終了コード 1)。後者に当たるのは、外す者・1 者指定に参加の母集合に無い名前を +渡したとき、足す者と外す者が重なるとき、1 者指定と外す者が矛盾するとき、`none` と名前を +混ぜたときである。cross-review ではホストが参加の母集合に無いため、ホストを外す指定はここで +弾かれる。 + +### 出力と終了コード + +標準出力の形は変わらない。増えるのは標準エラーの行と、席の名前が取りうる値である。 + +| 場面 | 標準エラー | 終了コード | 状態ファイル | +| --- | --- | ---: | --- | +| 新規で全員が使える | 参加の母集合と使える者の 1 行 | 0 | 作る | +| 新規で確認を通らない者がいる | 通らなかった者と理由を 1 者 1 行 | 0 | 作る | +| 新規で使える者が 2 者に満たない | 埋め合わせの相手を 1 行 | 0 | 作る | +| 新規で使える者も埋め合わせも無い | 使える者がいない理由 | 1 | 作らない | +| 確認を飛ばした | 飛ばしたことを 1 行 | 0 | 作る | +| 全員を要する指定で欠けがある | 欠けた者と理由 | 1 | 作らない | +| 名前の矛盾 | 何が矛盾したか | 1 | 作らない | +| 再開で引数を反映した | 項目ごとに 1 行 | 0 | 書き換える | +| 再開で反映しない引数が状態と違う | 引数ごとに 1 行 | 0 | 変えない | +| 再開の作り直しが失敗した | 新規と同じ | 1 | 書き換えない | + +行の先頭の印は、既存の初期化の出力に揃える。 + +| 場面 | 行の形 | +| --- | --- | +| 反映した | `↻ <項目>: <旧> → <新>` | +| 反映しない | `ℹ --<引数> は再開では反映しません(状態: <値> / 指定: <値>)` | +| 確認を通らなかった | `⚠ <名前> を担当から外しました(<理由>)` | +| 席を埋めた | `⚠ 使える者が <数> 者のため、席を<相手>で埋めます(観点が減ります)` | + +### 項目が無いときの読み方 + +**この変更の前に始めた実行の状態ファイルは書き換えない。** + +| 項目 | 無いときの読み方 | +| --- | --- | +| `participants` | ホストがあれば、参加の母集合から席を決める(この変更の前の輪番と同じ値)。ホストも無ければ `codex` / `agy` | +| `resume_changes` | 空として読む | +| ラウンドの記録の担当 | 担当の決まる順で引き直す | + +**再開で担当に関わる引数を渡したときだけ、参加者の記録を書く。** 渡さない再開では書き足さない。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 使える者の解決が、通らない者を外して続け、外した者へ確認を呼ばず、名前の矛盾と欠けを例外にすること | `plugins/ndf/scripts/tests/test_lib_participants.py` | +| 認証の確認が失敗で例外を上げず、理由を返すこと | 同 `test_auth_probe.py` | +| 席の埋め方が 3 者で従来の値と一致し、2 / 1 / 0 者で規則どおりに埋めること。席の名前の形 | `plugins/ndf/skills/cross-refactoring/tests/test_assignment.py` | +| 適用の輪番が参加者の数で回ること | `plugins/ndf/scripts/tests/test_lib_assignment.py` | +| 再開の反映が、表のとおりに書き換え・記録・知らせを行い、値が同じなら何もしないこと | 同 `test_lib_resume_args.py` | +| 新規の初期化が、確認を通らない者がいても状態ファイルを作り、全員を要する指定では作らないこと | `plugins/ndf/skills/cross-review/tests/test_state_review_pool.py` | +| 完了報告が参加者の節を出し、記録が無ければ「記録なし」と出すこと | 同 | +| 再開が渡した引数だけを反映し、渡さなかった引数を状態ファイルの値で補うこと | 同 `test_state_resume_args.py` | +| 席の名前が結果の受け口・起動・監視・計測を通ること | 同 `test_seat_names.py` | +| 前のラウンドの検査が、そのラウンドの担当で結果を数えること | 同 `test_state_round_guard.py` | +| 手順書が 1 者指定のシェル変数で担当を絞り直さないこと | 同 `test_skill_layout.py` | +| 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py --root .` | +| 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py --root .` | + +## 関連リンク + +- [issue #727](https://github.com/devbasex/ai-plugins/issues/727) — 使える者から担当を割り当てる共通層 +- [issue #687](https://github.com/devbasex/ai-plugins/issues/687) — 各ラウンドで 2 者を確保する規則と置き場所 +- [issue #478](https://github.com/devbasex/ai-plugins/issues/478) — 認証の確認を関門から把握へ +- [issue #648](https://github.com/devbasex/ai-plugins/issues/648) — 再開で渡した引数が無視される +- [PR #793](https://github.com/devbasex/ai-plugins/pull/793) — 実装 +- [`cross-review` のレビュワーの母集合と終了基準](../../plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md) +- [`cross-review` の状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) +- [`cross-review` の状態とレビューの手順](../../plugins/ndf/skills/cross-review/docs/01-state-and-review.md) +- [`cross-review` の手順](../../plugins/ndf/skills/cross-review/SKILL.md) +- [証拠ベースのレビューと効果の測定](cross-review-evidence-based.md) — 指摘の構造化・実行検証・反証と、状態ファイルのそれ以外の鍵 +- [起動 1 回の結末と上限の検知](cross-review-launch-outcome.md) — 結果なしの理由の語彙と起動し直しの可否 From 8711dcb3e70931e19bfadfa6de92cbb3315c8971 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 04:10:40 +0000 Subject: [PATCH 173/217] =?UTF-8?q?Update:=20=E4=BA=8C=E5=BA=A6=E6=9B=B8?= =?UTF-8?q?=E3=81=8B=E3=81=AA=E3=81=84=E7=85=A7=E5=90=88=E3=82=92=204=20?= =?UTF-8?q?=E7=A8=AE=E5=88=A5=E3=81=B8=E5=BA=83=E3=81=92=E3=80=81=E6=8B=92?= =?UTF-8?q?=E3=81=BE=E3=82=8C=E6=96=B9=E3=82=92=E5=8C=BA=E5=88=A5=E3=81=99?= =?UTF-8?q?=E3=82=8B=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit レビューの照合の鍵から判定の語を外し、本文の先頭行のラウンドと席までの前方一致に する。起動し直して判定が変わっても、同じラウンド・同じ席のレビューは増えない。 返信とまとめの照合も本文の先頭 80 文字で見る。 指した位置を差分の中に見つけられない拒まれ方を、同じ状態で返る別の拒まれ方 (判定の値の誤り・基準のコミットの誤り)と見分ける口を足した。応答の errors が 文字列の列でも失敗の説明に語が残るようにした。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- plugins/ndf/scripts/lib/post_queue.py | 64 ++++++++++++++---- plugins/ndf/scripts/tests/test_post_queue.py | 66 +++++++++++++++++++ .../tests/test_queue_idempotency.py | 59 ++++++++++++++--- 3 files changed, 166 insertions(+), 23 deletions(-) diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index e79511ab..7a04333a 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -75,13 +75,6 @@ QUEUED = "queued" FAILED = "failed" -# レビューの判定と、GitHub 側に残る状態の対応。 -_REVIEW_STATE = { - "APPROVE": "APPROVED", - "REQUEST_CHANGES": "CHANGES_REQUESTED", - "COMMENT": "COMMENTED", -} - # 未解決のスレッドの識別子だけを読む問い合わせ。**解決の冪等はこの一覧だけで決まる** # (一覧に無ければ、既に解決されている)。 _UNRESOLVED_QUERY = """ @@ -115,6 +108,9 @@ _HTTP_RE = re.compile(r"\(HTTP (\d{3})\)") # 上限を指す語。一次・二次・GraphQL の 3 つの言い回しを拾う。 _RATE_WORDS = ("rate limit", "rate_limited", "abuse detection") +# 指した位置を差分の中に見つけられないことを表す語。実測した応答は +# `Line could not be resolved` と `Path could not be resolved` の 2 つ。 +_POSITION_WORD = "could not be resolved" class Attempt(NamedTuple): @@ -149,6 +145,10 @@ def message(self) -> str: for e in errors: if isinstance(e, dict): parts += [str(e.get("message") or ""), str(e.get("type") or "")] + elif isinstance(e, str): + # レビューの作成が拒まれたときは、語をつないだ文字列が並ぶ + # (実測: `["Line could not be resolved"]`)。 + parts.append(e) return " ".join(p for p in parts if p) def summary(self) -> str: @@ -210,6 +210,24 @@ def is_rate_limited(attempt: Attempt) -> bool: return False +def is_position_unresolved(attempt: Attempt) -> bool: + """この失敗が「指した位置を差分の中に見つけられない」ことによるものか。 + + **レビューの作成は要求ごとに全件が拒まれる。** 差分の外の行やファイルを指した + インラインが 1 件でもあると、正しいインラインも総評も作られない(実測、 + 2026-09-22)。応答は語をつないだ 1 つの文字列で、どの項目かは指さないため、 + 呼び出し側は要求のインラインをまとめて総評へ移して送り直す。 + + **同じ状態で返る別の拒まれ方と分ける。** 判定の値の誤り + (`Variable $event ... was provided invalid value`)と基準のコミットの誤り + (`The commitOID is not part of the pull request`)はこの語を持たない。 + 区別しないと、別の不具合が退避として飲み込まれる。 + """ + if attempt.ok or attempt.http != 422: + return False + return _POSITION_WORD in f"{attempt.message} {attempt.stderr}".lower() + + # ---------------- 送る内容の組み立て ---------------- @@ -334,24 +352,44 @@ def _by_actor(row: dict[str, Any], actor: str | None) -> bool: return str((row.get("user") or {}).get("login") or "") == actor +def _head(body: Any) -> str: + return str(body or "")[:BODY_MATCH_CHARS] + + +def review_match_key(body: Any) -> str: + """レビューの本文から、同じ投稿かどうかを決める鍵を作る。 + + 先頭行は `## 🤖 cross-review | round | <席> | <判定>` である。**鍵に取るのは + 席までで、判定の語を含めない。** 含めると、起動し直して判定が変わったときに別の + 投稿と読まれ、同じラウンド・同じ席のレビューが 2 件になる(#730 #583)。 + + 先頭行がこの形でないときは、本文の先頭 `BODY_MATCH_CHARS` 文字へ落とす。 + """ + first = str(body or "").splitlines()[0] if str(body or "") else "" + parts = first.split("|") + if len(parts) < 4: + return _head(body) + return "|".join(parts[:3]).strip() + "|" + + def _comment_match(match: dict[str, Any], actor: str | None): - return lambda row: _by_actor(row, actor) and row.get("body") == match.get("body") + head = _head(match.get("body")) + return lambda row: _by_actor(row, actor) and _head(row.get("body")) == head def _review_match(match: dict[str, Any], actor: str | None): - want = _REVIEW_STATE.get(str(match.get("event") or ""), "") - head = str(match.get("body") or "")[:BODY_MATCH_CHARS] + key = review_match_key(match.get("body")) return lambda row: ( _by_actor(row, actor) - and str(row.get("state") or "") == want - and str(row.get("body") or "")[:BODY_MATCH_CHARS] == head + and review_match_key(row.get("body")) == key ) def _reply_match(match: dict[str, Any], actor: str | None): + head = _head(match.get("body")) return lambda row: ( str(row.get("in_reply_to_id") or "") == str(match.get("in_reply_to")) - and row.get("body") == match.get("body") + and _head(row.get("body")) == head ) diff --git a/plugins/ndf/scripts/tests/test_post_queue.py b/plugins/ndf/scripts/tests/test_post_queue.py index cb928c2e..827bfa66 100644 --- a/plugins/ndf/scripts/tests/test_post_queue.py +++ b/plugins/ndf/scripts/tests/test_post_queue.py @@ -267,3 +267,69 @@ def fake_send(item): assert len(sent_items) == 1 assert sent_items[0]["seq"] == 1 + + +# ---------------- 拒まれ方の区別(#730) ---------------- + +# 実測した応答(2026-09-22、Pull Request #794)。要求ごとに全件が拒まれ、 +# `errors` は語をつないだ 1 つの文字列で、どの項目かは指さない。 +_UNRESOLVED_LINE = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["Line could not be resolved"], + "status": "422", +}) +_UNRESOLVED_MANY = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["Line could not be resolved, Path could not be resolved," + " and Line could not be resolved"], + "status": "422", +}) +_BAD_EVENT = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["Variable $event of type PullRequestReviewEvent" + " was provided invalid value"], + "status": "422", +}) +_BAD_COMMIT = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["The commitOID is not part of the pull request"], + "status": "422", +}) +_STDERR_422 = "gh: Unprocessable Entity (HTTP 422)\n" + + +def _attempt(stdout: str, stderr: str = _STDERR_422) -> Any: + return post_queue.Attempt(1, stdout, stderr) + + +@pytest.mark.parametrize("stdout", [_UNRESOLVED_LINE, _UNRESOLVED_MANY]) +def test_a_rejection_that_cannot_resolve_the_position_is_told_apart(stdout: str) -> None: + """行やファイルを解決できない拒まれ方だけを、退避の契機として見分ける。""" + assert post_queue.is_position_unresolved(_attempt(stdout)) is True + + +@pytest.mark.parametrize("stdout", [_BAD_EVENT, _BAD_COMMIT]) +def test_another_rejection_of_the_same_status_is_not_a_reason_to_move(stdout: str) -> None: + """判定の値の誤りと基準のコミットの誤りは、退避せず失敗として残す。""" + assert post_queue.is_position_unresolved(_attempt(stdout)) is False + + +def test_a_rejection_of_another_status_is_not_a_reason_to_move() -> None: + assert post_queue.is_position_unresolved( + post_queue.Attempt(1, '{"message":"Not Found"}', "gh: Not Found (HTTP 404)") + ) is False + + +def test_a_success_is_not_a_rejection() -> None: + assert post_queue.is_position_unresolved(post_queue.Attempt(0, "{}", "")) is False + + +@pytest.mark.parametrize("stdout", [_UNRESOLVED_LINE, _BAD_EVENT]) +def test_the_words_of_the_rejection_are_readable(stdout: str) -> None: + """応答の `errors` が文字列の列でも、失敗の説明に語が残る。""" + assert "could not be resolved" in _attempt(_UNRESOLVED_LINE).message + assert _attempt(stdout).message != "" + + +def test_a_rejection_that_cannot_resolve_the_position_is_not_a_rate_limit() -> None: + assert post_queue.is_rate_limited(_attempt(_UNRESOLVED_LINE)) is False diff --git a/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py b/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py index e2813e19..ba62b5d0 100644 --- a/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py +++ b/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py @@ -6,13 +6,16 @@ | 種別 | 照会 | 同じとみなす条件 | | --- | --- | --- | -| `pr-comment` | `repos/{リポジトリ}/issues/{番号}/comments` | 投稿者が自分で、本文が一致 | -| `review-post` | `repos/{リポジトリ}/pulls/{番号}/reviews` | 投稿者・判定・本文の先頭 80 文字が一致 | -| `review-reply` | `repos/{リポジトリ}/pulls/{番号}/comments` | `in_reply_to_id` と本文が一致 | +| `pr-comment` | `repos/{リポジトリ}/issues/{番号}/comments` | 投稿者が自分で、本文の先頭 80 文字が一致 | +| `review-post` | `repos/{リポジトリ}/pulls/{番号}/reviews` | 投稿者と、本文の先頭行のラウンドと席までの前方一致 | +| `review-reply` | `repos/{リポジトリ}/pulls/{番号}/comments` | 返信先の指摘の識別子と、本文の先頭 80 文字が一致 | | `thread-resolve` | 未解決のスレッドの一覧 | 識別子が一覧に無い | 本文の先頭 80 文字で比べるのは、振動の検知が指摘の同一性を測るときと同じ幅である。 **同じ判断に別々の値を持たない。** + +**レビューの照合の鍵に判定の語を含めない。** 含めると、起動し直して判定が変わったときに +別の投稿と読まれ、同じラウンド・同じ席のレビューが 2 件になる(#730 #583)。 """ from __future__ import annotations @@ -27,6 +30,12 @@ BODY = "同じ内容の本文。" * 12 # 80 文字より長い本文で、先頭の照合が効くことを見る +def _review_body(event: str, round_no: int = 3, seat: str = "codex") -> str: + """レビューの本文。先頭行がラウンドと席と判定を持つ(#730 AC10)。""" + return (f"## 🤖 cross-review | round {round_no} | {seat} | {event}\n\n" + + BODY) + + @pytest.fixture() def qdir(tmp_path) -> pathlib.Path: return tmp_path / "pending" @@ -80,12 +89,14 @@ def test_a_review_already_on_github_is_not_posted_again( q = queue_mod.Queue(qdir) queue_mod.enqueue( q, "review-post", REPO, PR, - {"body": BODY, "event": "REQUEST_CHANGES"}, actor=ACTOR) - # 末尾だけが違う本文でも、先頭 80 文字が同じなら同じ投稿とみなす。 + {"body": _review_body("REQUEST_CHANGES"), "event": "REQUEST_CHANGES"}, + actor=ACTOR) + # 末尾だけが違う本文でも、先頭行のラウンドと席までが同じなら同じ投稿とみなす。 fake_gh.set_rules([ {"match": f"pulls/{PR}/reviews", "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "CHANGES_REQUESTED", - "body": BODY + "(末尾の言い回しだけが違う)", + "body": _review_body("REQUEST_CHANGES") + + "(末尾の言い回しだけが違う)", "id": 4961230016, "html_url": "https://x/#pullrequestreview-4961230016"}])}, ]) @@ -100,16 +111,42 @@ def test_a_review_already_on_github_is_not_posted_again( "https://x/#pullrequestreview-4961230016" -def test_a_review_with_a_different_verdict_is_not_the_same( +def test_a_review_with_a_different_verdict_is_still_the_same( queue_mod, fake_gh, qdir) -> None: + """判定が変わっても、同じラウンド・同じ席なら 2 件目を作らない(#730 AC11)。""" + q = queue_mod.Queue(qdir) + queue_mod.enqueue( + q, "review-post", REPO, PR, + {"body": _review_body("REQUEST_CHANGES"), "event": "REQUEST_CHANGES"}, + actor=ACTOR) + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", + "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", + "body": _review_body("APPROVE")}])}, + {"match": "", "stdout": "{}"}, + ]) + + result = q.flush() + + assert len(result.skipped) == 1 + assert _posted(fake_gh.joined()) == [] + + +@pytest.mark.parametrize("other", [ + _review_body("APPROVE", round_no=4), + _review_body("APPROVE", seat="agy"), +]) +def test_a_review_of_another_round_or_seat_is_not_the_same( + queue_mod, fake_gh, qdir, other) -> None: q = queue_mod.Queue(qdir) queue_mod.enqueue( q, "review-post", REPO, PR, - {"body": BODY, "event": "REQUEST_CHANGES"}, actor=ACTOR) + {"body": _review_body("REQUEST_CHANGES"), "event": "REQUEST_CHANGES"}, + actor=ACTOR) fake_gh.set_rules([ {"match": f"pulls/{PR}/reviews?", "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", - "body": BODY}])}, + "body": other}])}, {"match": "", "stdout": "{}"}, ]) @@ -124,9 +161,11 @@ def test_a_review_reply_already_on_github_is_not_posted_again( queue_mod.enqueue( q, "review-reply", REPO, PR, {"body": BODY, "in_reply_to": 987654}, actor=ACTOR) + # 末尾だけが違う返信でも、先頭 80 文字が同じなら同じ返信とみなす。 fake_gh.set_rules([ {"match": f"pulls/{PR}/comments", - "stdout": json.dumps([{"user": {"login": ACTOR}, "body": BODY, + "stdout": json.dumps([{"user": {"login": ACTOR}, + "body": BODY + "(末尾だけが違う)", "in_reply_to_id": 987654}])}, ]) From 693c8683f0ce96c4e2dda6320b3865491aea8338 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 04:19:11 +0000 Subject: [PATCH 174/217] =?UTF-8?q?Add:=20=E7=B5=90=E6=9E=9C=E3=83=95?= =?UTF-8?q?=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92=E6=8A=95=E7=A8=BF=E3=81=B8?= =?UTF-8?q?=E5=A4=89=E3=81=88=E3=82=8B=E5=B1=A4=E3=82=92=E6=96=B0=E8=A8=AD?= =?UTF-8?q?=E3=81=99=E3=82=8B=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 担当が書いた指摘の控えと結果ファイルを読み、GitHub へ送る投稿を組み立てて待ち行列を 通す層を共通層へ置いた。本文は引数にも標準出力にも出さず、この層の中だけを通る。 - レビューの投稿: 先頭行にラウンドと席と本来の判定を置き、自分の Pull Request では 送った形だけを落とす。位置を解決できずに拒まれたら、その要求のインラインをすべて 総評へ移して送り直す。同じ状態の別の拒まれ方は退避せず失敗として残す - 修正の投稿: 返信・決着・まとめを組み立てる。まとめの先頭 80 文字にラウンドと コミットを入れ、2 度積んでも増えないようにする - 修正の送信: 現在の頭を指定して送り、報告されたコミットが送り先に載ったことを確かめる - 単独で使う口として部分命令 fix を持つ 待ち行列の側に、拒まれ方を項目から読む口と、送る内容を変えて積み直すための取り除きを 足した。 Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01MGCedPTy818Zw7VYdmE4GB --- plugins/ndf/scripts/lib/post_queue.py | 33 +- plugins/ndf/scripts/lib/result_posts.py | 475 ++++++++++++++++++ .../ndf/scripts/tests/test_result_posts.py | 418 +++++++++++++++ 3 files changed, 925 insertions(+), 1 deletion(-) create mode 100644 plugins/ndf/scripts/lib/result_posts.py create mode 100644 plugins/ndf/scripts/tests/test_result_posts.py diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index 7a04333a..30216535 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -449,7 +449,7 @@ def already_posted(item: dict[str, Any]) -> bool | None: _SEQ_RE = re.compile(r"^(\d{4})-") -def _read_item(path: pathlib.Path) -> dict[str, Any] | None: +def read_item(path: pathlib.Path) -> dict[str, Any] | None: """待ち行列の項目を 1 件読む。読めなければ `None`。 項目は作成先の JSON ファイルへ直接書かれるため、書き込みの途中で終了すると @@ -462,6 +462,20 @@ def _read_item(path: pathlib.Path) -> dict[str, Any] | None: return item if isinstance(item, dict) else None +_read_item = read_item + + +def rejected_by_position(item: dict[str, Any]) -> bool: + """待ち行列に残った項目が、指した位置を解決できずに拒まれたものか。 + + 流した後に呼ぶ。`is_position_unresolved` と同じ判定を、項目へ残した状態と説明から + 行う(流す側は `Attempt` を返さないため)。 + """ + if int(item.get("last_status") or 0) != 422: + return False + return _POSITION_WORD in str(item.get("last_error") or "").lower() + + class FlushResult(NamedTuple): """流した結果。""" @@ -500,6 +514,20 @@ def items(self) -> list[tuple[pathlib.Path, dict[str, Any]]]: out.append((p, item)) return out + def drop(self, seq: Any) -> bool: + """連番で指した項目を 1 件取り除く。 + + 送れなかった項目を、送る内容を変えて積み直すときに使う(差分の外を指す指摘の + 退避)。**そのまま積み足すと、同じ論点の要求が 2 件並ぶ。** + """ + if seq is None: + return False + for path, item in self.items(): + if item.get("seq") == seq: + path.unlink(missing_ok=True) + return True + return False + def _next_seq(self) -> int: seqs = [int(m.group(1)) for m in (_SEQ_RE.match(p.name) for p in self.paths()) if m] @@ -567,6 +595,9 @@ def flush(self) -> FlushResult: continue item["attempts"] = int(item.get("attempts") or 0) + 1 item["last_error"] = attempt.summary() + # **状態も残す。** 拒まれ方の区別(位置を解決できない / それ以外)は、 + # 流した後に項目だけを見て決める。 + item["last_status"] = attempt.http path.write_text(json.dumps(item, indent=2, ensure_ascii=False), encoding="utf-8") failed = item diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py new file mode 100644 index 00000000..54214121 --- /dev/null +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -0,0 +1,475 @@ +#!/usr/bin/env python3 +"""結果ファイルを投稿へ変える層(#730 #583)。 + +**GitHub と git へ書くのは、レビューを回す側だけである。** 担当は指摘の控えと結果 +ファイルを書いて終わり、修正の担当はコミットまでを行う。この層が、その 2 種類の +結果ファイルを読んで投稿を組み立て、待ち行列を通して送り、送信の応答を返す。 + +**本文は引数にも標準出力にも出さない。** どの入口もファイルのパスを受け取り、本文は +この層の中だけを通る。収束ループを駆動している側の応答に本文が載ると、文脈の予算の +決めに反する。 + +**2 つの口を持つ。** 収束ループから呼ぶときは取り込みがこの層を呼び、単独で修正を +行うときは同じまとまりを部分命令として直接呼ぶ。入口のスクリプトを別に作らない。 + +```bash +python3 "$SCRIPTS/lib/result_posts.py" fix --repo <所有者>/<リポジトリ> --pr <番号> \\ + --result <結果ファイル> --head <ブランチ名> --worktree <作業ツリー> +``` +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import sys +from typing import Any, NamedTuple + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import post_queue # noqa: E402 + +# レビューの本文の先頭行。**照合の鍵はラウンドと席までの前方一致である**ため、判定の +# 語はこの行の末尾に置く(`post_queue.review_match_key`)。 +REVIEW_HEAD = "## 🤖 cross-review | round {round_no} | {seat} | {event}" +# 差分の外を指すために総評へ移した指摘の見出し。 +EVACUATED_HEAD = "### 差分の外を指す指摘" +# 修正のまとめの先頭行。ラウンドとコミットを鍵の幅(先頭 80 文字)の中へ入れる。 +FIX_HEAD = "## 🔧 /ndf:fix サマリ | round {round_no} | commit {commit}" +FIX_HEAD_NO_ROUND = "## 🔧 /ndf:fix サマリ | commit {commit}" + +TMP_DIRNAME = ".cross_review" + + +# ---------------- 読み取り ---------------- + + +def _read_json(path: pathlib.Path | str | None) -> dict[str, Any]: + if path is None: + return {} + try: + data = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _findings(payload: dict[str, Any]) -> list[dict[str, Any]]: + items = payload.get("comments") + if not isinstance(items, list): + return [] + return [i for i in items if isinstance(i, dict)] + + +def _dict_items(raw: Any) -> list[dict[str, Any]]: + if isinstance(raw, dict): + return [raw] + if isinstance(raw, list): + return [i for i in raw if isinstance(i, dict)] + return [] + + +# ---------------- レビューの投稿 ---------------- + + +def _can_be_inline(finding: dict[str, Any]) -> bool: + """指す先を持つか。**差分に含まれるかどうかは見ない。** + + 含まれるかは送ってみた応答が決める(設計の決定 9)。ここで見るのは、そもそも + 指す位置があるかどうかだけである。 + """ + return bool(finding.get("path")) and finding.get("line") is not None + + +def _evacuated_line(finding: dict[str, Any]) -> str: + where = str(finding.get("path") or "") + line = finding.get("line") + head = f"`{where}:{line}`" if where and line is not None else ( + f"`{where}`" if where else "") + severity = str(finding.get("severity") or "") + mark = f" [{severity}]" if severity else "" + text = str(finding.get("body") or "").strip() + return f"- {head}{mark} {text}".strip() + + +def _review_body(payload: dict[str, Any], round_no: int, seat: str, intent: str, + evacuated: list[dict[str, Any]]) -> str: + parts = [REVIEW_HEAD.format(round_no=round_no, seat=seat, event=intent)] + summary = str(payload.get("summary") or "").strip() + if summary: + parts.append(summary) + if evacuated: + parts.append(EVACUATED_HEAD) + parts.append("\n".join(_evacuated_line(f) for f in evacuated)) + return "\n\n".join(parts) + "\n" + + +def review_posts(payload_path: pathlib.Path | str, result_path: pathlib.Path | str, + repo: str, pr: int, round_no: int, seat: str, + head_sha: str | None, is_own_pr: bool, + evacuate_all: bool = False) -> list[dict[str, Any]]: + """指摘の控えと結果ファイルから、待ち行列へ積む項目の列を組み立てる。 + + **判定の格下げはこの層が決める**(設計の決定 14)。自分の Pull Request へは変更を + 求めるレビューを送れないため、送る形だけを `COMMENT` へ落とす。本来の判定は + 先頭行と `extra` に残り、収束の判定はそちらを読む。 + + `evacuate_all` が真のとき、位置を持つ指摘もすべて総評へ移す。送った要求が位置を + 解決できずに拒まれた後の送り直しで使う。 + """ + payload = _read_json(payload_path) + result = _read_json(result_path) + findings = _findings(payload) + intent = str(result.get("event") or result.get("intent") or "COMMENT") + posted_as = "COMMENT" if is_own_pr else intent + + inline = [] if evacuate_all else [f for f in findings if _can_be_inline(f)] + evacuated = [f for f in findings if f not in inline] + body = _review_body(payload, round_no, seat, intent, evacuated) + + fields: dict[str, Any] = {"body": body, "event": posted_as} + if head_sha: + fields["commit_id"] = head_sha + if inline: + fields["comments"] = [ + {"path": str(f.get("path")), "line": int(f.get("line")), + "side": "RIGHT", "body": str(f.get("body") or "")} + for f in inline + ] + extra = {"ident": f"{seat}-r{round_no}", "seat": seat, "round": round_no, + "intent": intent, "posted_as": posted_as, + "inline": len(inline), "body": len(evacuated)} + return [{"kind": "review-post", "fields": fields, "extra": extra}] + + +class ReviewOutcome(NamedTuple): + """レビューを 1 件送った結果。**本文は持たない。**""" + + review_url: str | None + posted_inline: int + posted_body: int + queued: int + findings: int + failed: bool + posted_as: str + intent: str + detail: str + + +def _find(items: list[dict[str, Any]], seq: Any) -> dict[str, Any] | None: + return next((i for i in items if i.get("seq") == seq), None) + + +def _response_url(item: dict[str, Any] | None) -> str | None: + response = (item or {}).get("response") + if not isinstance(response, dict): + return None + url = response.get("html_url") + if url: + return str(url) + if response.get("id"): + return f"#pullrequestreview-{response['id']}" + return None + + +def _write_destinations(payload_path: pathlib.Path | str, inline_count: int) -> None: + """控えへ、送れた先を書き戻す。**決めるのは投稿する側である。**""" + path = pathlib.Path(payload_path) + payload = _read_json(path) + findings = _findings(payload) + if not findings: + return + inline_ids = {id(f) for f in findings if _can_be_inline(f)} if inline_count else set() + for f in findings: + f["posted_to"] = "inline" if id(f) in inline_ids else "body" + try: + path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), + encoding="utf-8") + except OSError: + return + + +def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, + result_path: pathlib.Path | str, repo: str, pr: int, round_no: int, + seat: str, head_sha: str | None, is_own_pr: bool, + actor: str | None = None) -> ReviewOutcome: + """レビューを 1 件、待ち行列を通して送る。 + + **位置を解決できずに拒まれたら、その要求のインラインをすべて総評へ移して送り直す。** + 応答はどの項目が原因かを指さないため、1 件ずつの特定はできない(実測)。 + 同じ状態で返る別の拒まれ方(判定の値の誤り・基準のコミットの誤り)は退避せず、 + 失敗として残す。 + """ + findings = len(_findings(_read_json(payload_path))) + item = review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr)[0] + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + flushed = queue.flush() + + if flushed.failed and post_queue.rejected_by_position(flushed.failed): + queue.drop(flushed.failed.get("seq")) + item = review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr, evacuate_all=True)[0] + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + flushed = queue.flush() + + done = _find(flushed.sent, seq) or _find(flushed.skipped, seq) + failed = flushed.failed is not None and flushed.failed.get("seq") == seq + queued = 0 if done else 1 + if not failed: + _write_destinations(payload_path, item["extra"]["inline"]) + return ReviewOutcome( + review_url=_response_url(done), + posted_inline=item["extra"]["inline"] if done else 0, + posted_body=item["extra"]["body"] if done else 0, + queued=queued if not failed else 1, + findings=findings, + failed=bool(failed and not flushed.rate_limited), + posted_as=item["extra"]["posted_as"], + intent=item["extra"]["intent"], + detail=str((flushed.failed or {}).get("last_error") or ""), + ) + + +# ---------------- 修正の投稿 ---------------- + + +def _reply(comment_id: Any, body: str) -> dict[str, Any] | None: + try: + target = int(comment_id) + except (TypeError, ValueError): + return None + return {"kind": "review-reply", "fields": {"in_reply_to": target, "body": body}, + "extra": {"ident": f"reply-{target}"}} + + +def _fix_summary_body(fix: dict[str, Any], round_no: int | None, + resolved: int, deferred: int, rejected: int) -> str: + commit = str(fix.get("fix_commit") or fix.get("commit_sha") or "(なし)") + head = (FIX_HEAD.format(round_no=round_no, commit=commit) if round_no is not None + else FIX_HEAD_NO_ROUND.format(commit=commit)) + by = fix.get("by_severity") or {} + counts = " / ".join(f"{k}={by.get(k, 0)}" for k in ("critical", "major", "minor")) + lines = [ + head, + "", + f"対応件数: {counts}(合計 {fix.get('fixed_count', fix.get('fixed', 0))} 件)", + f"決着: {resolved} 件 / 見送り: {deferred} 件 / 却下: {rejected} 件", + f"CI: {fix.get('ci_status') or 'NONE'}", + ] + note = str(fix.get("ci_note") or "").strip() + if note: + lines += ["", note] + return "\n".join(lines) + "\n" + + +def fix_posts(result_path: pathlib.Path | str, repo: str, pr: int, + round_no: int | None = None) -> list[dict[str, Any]]: + """修正の結果ファイルから、待ち行列へ積む項目の列を組み立てる。 + + 並びは「返信 → 決着 → まとめ」である。返信を先に置くのは、決着したスレッドが + 畳まれた後に返信が届くと、読み手がその返信を開かないためである。 + """ + fix = _read_json(result_path) + resolved = _dict_items(fix.get("resolved_threads")) + deferred = _dict_items(fix.get("deferred")) + rejected = _dict_items(fix.get("rejected")) + commit = str(fix.get("fix_commit") or fix.get("commit_sha") or "") + + items: list[dict[str, Any]] = [] + for entry in resolved: + body = "対応しました。" + (f"({commit})" if commit else "") + reply = _reply(entry.get("comment_id"), body) + if reply: + items.append(reply) + for entry in deferred: + reason = str(entry.get("reason_for_deferral") or entry.get("reason") or "") + reply = _reply(entry.get("comment_id"), + f"見送ります。{reason}".strip()) + if reply: + items.append(reply) + for entry in rejected: + reason = str(entry.get("reason_for_rejection") or entry.get("reason") or "") + reply = _reply(entry.get("comment_id"), + f"この指摘は採らない判断です。{reason}".strip()) + if reply: + items.append(reply) + for entry in resolved: + thread_id = entry.get("thread_id") + if thread_id: + items.append({"kind": "thread-resolve", + "fields": {"thread_id": str(thread_id)}, + "extra": {"ident": f"resolve-{thread_id}"}}) + items.append({ + "kind": "pr-comment", + "fields": {"body": _fix_summary_body(fix, round_no, len(resolved), + len(deferred), len(rejected))}, + "extra": {"ident": f"fix-summary-{round_no if round_no is not None else commit}"}, + }) + return items + + +class FixOutcome(NamedTuple): + """修正の投稿を送った結果。""" + + summary_url: str | None + replied: int + resolved: int + queued: int + failed: bool + detail: str + + +def post_fix(queue: post_queue.Queue, result_path: pathlib.Path | str, repo: str, + pr: int, round_no: int | None = None, + actor: str | None = None) -> FixOutcome: + """返信・決着・まとめを待ち行列へ積んで流す。""" + seqs: dict[int, str] = {} + for item in fix_posts(result_path, repo, pr, round_no): + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + if seq is not None: + seqs[int(seq)] = item["kind"] + flushed = queue.flush() + + done = {int(i["seq"]): i for i in (flushed.sent + flushed.skipped) + if i.get("seq") is not None} + summary_url = None + for seq, kind in seqs.items(): + if kind == "pr-comment" and seq in done: + response = done[seq].get("response") + if isinstance(response, dict): + summary_url = response.get("html_url") or response.get("url") + failed = flushed.failed is not None and not flushed.rate_limited + return FixOutcome( + summary_url=str(summary_url) if summary_url else None, + replied=sum(1 for s, k in seqs.items() if k == "review-reply" and s in done), + resolved=sum(1 for s, k in seqs.items() if k == "thread-resolve" and s in done), + queued=flushed.remaining, + failed=bool(failed), + detail=str((flushed.failed or {}).get("last_error") or ""), + ) + + +# ---------------- 修正の送信 ---------------- + + +class PushResult(NamedTuple): + """送信の結果と、報告されたコミットが送り先に載っているか。""" + + ok: bool + pushed: bool + contains: bool + detail: str + + +def _git(worktree: pathlib.Path | str, *args: str) -> subprocess.CompletedProcess: + # 認証は `gh` の持ち物を使う。helper を空で 1 度挟むのは、応答を返さない helper が + # 先に当たると止まるためである。 + cmd = ["git", "-C", str(worktree), + "-c", "credential.helper=", + "-c", "credential.helper=!gh auth git-credential", *args] + return subprocess.run(cmd, capture_output=True, text=True) + + +def push_fix(worktree: pathlib.Path | str, head_branch: str, + fix_commit: str | None) -> PushResult: + """現在の頭を送り先のブランチへ送り、報告されたコミットが載ったことを確かめる。 + + **ブランチ名だけを指定しない。** 作業ツリーが切り離された頭で作られている場合、 + ブランチ名だけの指定では現在の頭が送られないまま終了コード 0 で終わる。 + """ + if not fix_commit: + return PushResult(True, False, True, "コミットが無いため送らない") + pushed = _git(worktree, "push", "origin", f"HEAD:{head_branch}") + if pushed.returncode != 0: + return PushResult(False, False, False, + (pushed.stderr or pushed.stdout or "").strip()[:300]) + fetched = _git(worktree, "fetch", "origin", head_branch) + if fetched.returncode != 0: + return PushResult(False, True, False, + (fetched.stderr or "").strip()[:300]) + contains = _git(worktree, "merge-base", "--is-ancestor", fix_commit, "FETCH_HEAD") + if contains.returncode != 0: + return PushResult(False, True, False, + f"報告されたコミット {fix_commit} が origin/{head_branch} に載っていない") + return PushResult(True, True, True, "") + + +# ---------------- 部分命令 ---------------- + + +def _sh(*cmd: str) -> str: + r = subprocess.run(list(cmd), capture_output=True, text=True) + return r.stdout.strip() if r.returncode == 0 else "" + + +def queue_for(worktree: pathlib.Path) -> post_queue.Queue: + """待ち行列の置き場所。**引数では渡さない。** 作業ツリーの下の決まった名前から導く。""" + base = os.environ.get("CROSS_REVIEW_TMP_DIR") or str(worktree / TMP_DIRNAME) + return post_queue.Queue(pathlib.Path(base) / post_queue.QUEUE_DIRNAME) + + +def cmd_fix(args: argparse.Namespace) -> int: + worktree = pathlib.Path(args.worktree or os.getcwd()).resolve() + repo = args.repo or _sh("gh", "repo", "view", "--json", "nameWithOwner", + "-q", ".nameWithOwner") + if not repo: + print("リポジトリを決められない(--repo を渡す)", file=sys.stderr) + return 1 + head = args.head or _sh("gh", "pr", "view", str(args.pr), "--json", "headRefName", + "-q", ".headRefName") + result = pathlib.Path(args.result) if args.result else ( + pathlib.Path(os.environ.get("CROSS_REVIEW_TMP_DIR") + or str(worktree / TMP_DIRNAME)) / f"fix-pr{args.pr}-result.json") + fix = _read_json(result) + if not fix: + print(f"修正の結果ファイルを読めない: {result}", file=sys.stderr) + return 1 + + if head: + pushed = push_fix(worktree, head, fix.get("fix_commit") or fix.get("commit_sha")) + print(f"PUSHED={1 if pushed.pushed else 0} " + f"COMMIT_ON_HEAD={1 if pushed.contains else 0}") + if not pushed.ok: + print(pushed.detail, file=sys.stderr) + return 1 + + actor = args.actor or _sh("gh", "api", "user", "-q", ".login") or None + outcome = post_fix(queue_for(worktree), result, repo, int(args.pr), + round_no=args.round, actor=actor) + if outcome.summary_url: + print(f"POSTED summary_url={outcome.summary_url}") + print(f"REPLIED={outcome.replied} RESOLVED={outcome.resolved} " + f"QUEUED={outcome.queued}") + if outcome.failed: + print(outcome.detail, file=sys.stderr) + return 1 + return 0 + + +def main() -> None: + p = argparse.ArgumentParser(description="結果ファイルから投稿を組み立てて送る") + sub = p.add_subparsers(dest="cmd", required=True) + f = sub.add_parser("fix", help="修正の結果ファイルの返信・決着・まとめと送信") + f.add_argument("--repo") + f.add_argument("--pr", required=True) + f.add_argument("--result") + f.add_argument("--head") + f.add_argument("--worktree") + f.add_argument("--round", type=int) + f.add_argument("--actor") + f.set_defaults(func=cmd_fix) + args = p.parse_args() + sys.exit(args.func(args)) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py new file mode 100644 index 00000000..b1677a77 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -0,0 +1,418 @@ +"""結果ファイルを投稿へ変える層(#730 #583)。 + +担当が書いた指摘の控えと結果ファイルを読み、GitHub へ送る投稿を組み立てる。 +**本文は引数にも標準出力にも出さない。** 受け取るのはファイルのパスだけで、本文は +この層の中だけを通る。 + +| 何を確かめるか | 受け入れ条件 | +| --- | --- | +| 控えと結果からレビューの投稿が組み立つ | AC5 | +| 本文が引数に現れない | AC5 | +| 自分の Pull Request では送った形だけを落とす | AC32 | +| 位置を解決できない拒まれ方で、インラインを総評へ退避する | AC16 | +| 同じ状態の別の拒まれ方は退避せず失敗として残す | AC16 | +| インラインが 0 件でも結果なしにしない | AC17 | +| 返信・決着・まとめが積まれる | AC7 | +| 送信は現在の頭を指定し、載ったことを確かめる | AC8・AC9 | +""" +from __future__ import annotations + +import inspect +import json +import os +import pathlib +import subprocess +import sys + +import pytest + +LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" +if str(LIB) not in sys.path: + sys.path.insert(0, str(LIB)) + +import post_queue # noqa: E402 +import result_posts # noqa: E402 + +REPO = "o/r" +PR = 730 +ROUND = 3 +SEAT = "codex" +SHA = "1" * 40 +ACTOR = "takemi" + + +# ---------------- 偽の `gh` ---------------- + +_FAKE_GH = '''#!/usr/bin/env python3 +import json, os, sys + +argv = sys.argv[1:] +joined = " ".join(argv) +stdin = "" if sys.stdin.isatty() else sys.stdin.read() +log = os.environ["GH_FAKE_LOG"] +with open(log, "a", encoding="utf-8") as f: + f.write(json.dumps({"argv": argv, "stdin": stdin}, ensure_ascii=False) + "\\n") +rules_file = os.environ.get("GH_FAKE_RULES") +rules = json.load(open(rules_file, encoding="utf-8")) if rules_file else [] +prior = 0 +with open(log, encoding="utf-8") as f: + prior = sum(1 for line in f if line.strip()) - 1 +for rule in rules: + if "calls_lt" in rule and prior >= int(rule["calls_lt"]): + continue + if rule.get("match", "") in joined: + sys.stdout.write(rule.get("stdout", "")) + sys.stderr.write(rule.get("stderr", "")) + sys.exit(int(rule.get("exit", 0))) +sys.stdout.write("[]") +''' + + +class FakeGh: + def __init__(self, log: pathlib.Path, rules: pathlib.Path, monkeypatch) -> None: + self.log, self.rules, self._mp = log, rules, monkeypatch + + def set_rules(self, rules: list[dict]) -> None: + self.rules.write_text(json.dumps(rules), encoding="utf-8") + self._mp.setenv("GH_FAKE_RULES", str(self.rules)) + + def calls(self) -> list[dict]: + if not self.log.exists(): + return [] + return [json.loads(line) for line in + self.log.read_text(encoding="utf-8").splitlines() if line.strip()] + + def joined(self) -> list[str]: + return [" ".join(c["argv"]) for c in self.calls()] + + def sent(self) -> list[dict]: + """状態を変える呼び出しの本文だけを取り出す。""" + out = [] + for c in self.calls(): + if "--method POST" in " ".join(c["argv"]) and c["stdin"]: + out.append(json.loads(c["stdin"])) + return out + + +@pytest.fixture() +def fake_gh(monkeypatch, tmp_path) -> FakeGh: + bindir = tmp_path / "fake-bin" + bindir.mkdir(exist_ok=True) + script = bindir / "gh" + script.write_text(_FAKE_GH, encoding="utf-8") + script.chmod(0o755) + monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ.get('PATH', '')}") + monkeypatch.setenv("GH_FAKE_LOG", str(tmp_path / "gh-calls.log")) + monkeypatch.delenv("GH_FAKE_RULES", raising=False) + return FakeGh(tmp_path / "gh-calls.log", tmp_path / "gh-rules.json", monkeypatch) + + +# ---------------- 控えと結果ファイル ---------------- + +def _files(tmp_path: pathlib.Path, comments: list[dict] | None = None, + summary: str = "設計の筋は通っている。", event: str = "REQUEST_CHANGES", + ) -> tuple[pathlib.Path, pathlib.Path]: + payload = tmp_path / f"{SEAT}-review-pr{PR}-round{ROUND}-payload.json" + result = tmp_path / f"{SEAT}-review-pr{PR}-result.json" + if comments is None: + comments = [ + {"path": "a.py", "line": 12, "body": "[major / 正確性] 戻り値を確かめる", + "severity": "major"}, + {"path": "b.py", "line": 34, "body": "[minor / 可読性] 名前を揃える", + "severity": "minor"}, + ] + payload.write_text(json.dumps({"summary": summary, "comments": comments}, + ensure_ascii=False), encoding="utf-8") + result.write_text(json.dumps( + {"event": event, "by_severity": {"critical": 0, "major": 1, "minor": 1, "nit": 0}}, + ensure_ascii=False), encoding="utf-8") + return payload, result + + +def _review_items(tmp_path, **kw): + payload, result = _files(tmp_path, **kw) + return result_posts.review_posts( + payload, result, repo=REPO, pr=PR, round_no=ROUND, seat=SEAT, + head_sha=SHA, is_own_pr=False) + + +# ---------------- 組み立て ---------------- + +def test_a_review_is_built_from_the_note_and_the_result(tmp_path) -> None: + items = _review_items(tmp_path) + + assert [i["kind"] for i in items] == ["review-post"] + fields = items[0]["fields"] + assert fields["event"] == "REQUEST_CHANGES" + assert fields["commit_id"] == SHA + assert [c["path"] for c in fields["comments"]] == ["a.py", "b.py"] + + +def test_the_first_line_carries_the_round_and_the_seat(tmp_path) -> None: + body = _review_items(tmp_path)[0]["fields"]["body"] + + assert body.splitlines()[0] == \ + f"## 🤖 cross-review | round {ROUND} | {SEAT} | REQUEST_CHANGES" + assert post_queue.review_match_key(body) == \ + f"## 🤖 cross-review | round {ROUND} | {SEAT}|" + + +def test_the_body_is_never_an_argument() -> None: + """本文は引数として渡さない。どの関数もファイルのパスを受け取る。""" + for fn in (result_posts.review_posts, result_posts.fix_posts): + names = list(inspect.signature(fn).parameters) + assert "body" not in names and "payload" not in names + assert "payload_path" in inspect.signature(result_posts.review_posts).parameters + + +def test_only_what_is_sent_is_downgraded_on_ones_own_pull_request(tmp_path) -> None: + """自分の Pull Request では送った形だけを落とし、本来の判定は落とさない(AC32)。""" + payload, result = _files(tmp_path) + items = result_posts.review_posts( + payload, result, repo=REPO, pr=PR, round_no=ROUND, seat=SEAT, + head_sha=SHA, is_own_pr=True) + + fields = items[0]["fields"] + assert fields["event"] == "COMMENT" + assert fields["body"].splitlines()[0].endswith("| REQUEST_CHANGES") + assert items[0]["extra"]["intent"] == "REQUEST_CHANGES" + assert items[0]["extra"]["posted_as"] == "COMMENT" + + +def test_a_finding_without_a_position_goes_to_the_summary(tmp_path) -> None: + """位置を持たない指摘は、指す先が無いので総評へ入れる。""" + items = _review_items(tmp_path, comments=[ + {"body": "[major / 設計] 層の分け方を見直す", "severity": "major"}, + {"path": "a.py", "line": 12, "body": "[minor / 可読性] 名前を揃える", + "severity": "minor"}, + ]) + + fields = items[0]["fields"] + assert [c["path"] for c in fields["comments"]] == ["a.py"] + assert "層の分け方を見直す" in fields["body"] + assert items[0]["extra"]["inline"] == 1 + assert items[0]["extra"]["body"] == 1 + + +# ---------------- 送信と退避 ---------------- + +def _queue(tmp_path) -> post_queue.Queue: + return post_queue.Queue(tmp_path / "pending") + + +def _post_review(tmp_path, **kw): + payload, result = _files(tmp_path, **kw) + return result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR), payload + + +_REJECT_POSITION = { + "match": "pulls/730/reviews", "exit": 1, + "stdout": json.dumps({"message": "Unprocessable Entity", + "errors": ["Line could not be resolved"], "status": "422"}), + "stderr": "gh: Unprocessable Entity (HTTP 422)\n", +} +_REJECT_EVENT = { + "match": "pulls/730/reviews", "exit": 1, + "stdout": json.dumps({"message": "Unprocessable Entity", + "errors": ["Variable $event of type PullRequestReviewEvent" + " was provided invalid value"], "status": "422"}), + "stderr": "gh: Unprocessable Entity (HTTP 422)\n", +} +_ACCEPT = {"match": "pulls/730/reviews", "stdout": json.dumps( + {"id": 99, "html_url": "https://x/pull/730#pullrequestreview-99"})} + + +def test_the_inlines_move_to_the_summary_when_the_position_is_not_resolved( + tmp_path, fake_gh) -> None: + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + dict(_REJECT_POSITION, calls_lt=3), + _ACCEPT, + ]) + + outcome, payload = _post_review(tmp_path) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-99" + assert outcome.posted_inline == 0 + assert outcome.posted_body == 2 + assert outcome.queued == 0 + # 2 度目の要求はインラインを持たず、指摘は総評に入る。 + last = fake_gh.sent()[-1] + assert "comments" not in last + assert "戻り値を確かめる" in last["body"] + # 控えには送れた先が残る。 + note = json.loads(payload.read_text(encoding="utf-8")) + assert [c["posted_to"] for c in note["comments"]] == ["body", "body"] + + +def test_another_rejection_of_the_same_status_is_not_moved(tmp_path, fake_gh) -> None: + """判定の値の誤りは退避の契機にしない。失敗として残す。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _REJECT_EVENT, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.failed is True + assert outcome.review_url is None + + +def test_a_review_that_is_already_on_github_is_not_posted_again( + tmp_path, fake_gh) -> None: + """投稿の後・記録の前に止まった実行をやり直しても、レビューは増えない(AC12)。""" + head = f"## 🤖 cross-review | round {ROUND} | {SEAT} | APPROVE" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", + "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", + "body": head + "\n\n先客", "id": 42, + "html_url": "https://x/pull/730#pullrequestreview-42"}])}, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-42" + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + + +def test_a_note_with_findings_is_not_a_missing_result(tmp_path, fake_gh) -> None: + """インラインとして送れたものが 0 件でも、その担当は結果なしにならない(AC17)。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + dict(_REJECT_POSITION, calls_lt=3), + _ACCEPT, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.findings == 2 + assert outcome.failed is False + + +# ---------------- 修正の投稿 ---------------- + +def _fix_file(tmp_path, **over) -> pathlib.Path: + data = { + "pr": PR, + "fix_commit": "abc1234", + "ci_status": "SUCCESS", + "fixed_count": 2, + "by_severity": {"critical": 0, "major": 2, "minor": 0, "nit": 0}, + "resolved_threads": [ + {"thread_id": "PRRT_a", "comment_id": 111, "path": "a.py", "line": 12}, + {"thread_id": "PRRT_b", "comment_id": 222, "path": "b.py", "line": 34}, + ], + "deferred": [ + {"comment_id": 333, "thread_id": "PRRT_c", "severity": "nit", + "summary": "末尾の書き方", "reason_for_deferral": "好みの範囲"}, + ], + "rejected": [ + {"comment_id": 444, "path": "c.py", "line": 7, "severity": "minor", + "summary": "引用の形", "reason_for_rejection": "意図して展開している"}, + ], + } + data.update(over) + path = tmp_path / f"fix-pr{PR}-result.json" + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return path + + +def test_the_take_in_of_a_fix_builds_replies_resolves_and_a_summary(tmp_path) -> None: + items = result_posts.fix_posts(_fix_file(tmp_path), repo=REPO, pr=PR, + round_no=ROUND) + + kinds = [i["kind"] for i in items] + assert kinds.count("review-reply") == 4 # 決着 2 + 見送り 1 + 却下 1 + assert kinds.count("thread-resolve") == 2 + assert kinds.count("pr-comment") == 1 + assert kinds[-1] == "pr-comment" # まとめは最後 + + +def test_the_summary_carries_the_round_in_the_head_of_the_body(tmp_path) -> None: + """同じラウンドのまとめを 2 度積んでも増えないよう、鍵になる先頭へ入れる(AC14)。""" + items = result_posts.fix_posts(_fix_file(tmp_path), repo=REPO, pr=PR, + round_no=ROUND) + body = [i for i in items if i["kind"] == "pr-comment"][0]["fields"]["body"] + + assert f"round {ROUND}" in body[:post_queue.BODY_MATCH_CHARS] + assert "abc1234" in body[:post_queue.BODY_MATCH_CHARS] + + +def test_a_reply_points_at_the_comment_it_answers(tmp_path) -> None: + items = result_posts.fix_posts(_fix_file(tmp_path), repo=REPO, pr=PR, + round_no=ROUND) + targets = sorted(i["fields"]["in_reply_to"] for i in items + if i["kind"] == "review-reply") + + assert targets == [111, 222, 333, 444] + + +def test_a_fix_without_threads_still_posts_the_summary(tmp_path) -> None: + items = result_posts.fix_posts( + _fix_file(tmp_path, resolved_threads=[], deferred=[], rejected=[]), + repo=REPO, pr=PR, round_no=ROUND) + + assert [i["kind"] for i in items] == ["pr-comment"] + + +# ---------------- 送信 ---------------- + +def _repo_with_remote(tmp_path) -> tuple[pathlib.Path, pathlib.Path]: + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], + check=True, capture_output=True) + work = tmp_path / "work" + subprocess.run(["git", "clone", str(remote), str(work)], + check=True, capture_output=True) + for key, value in (("user.email", "t@example.com"), ("user.name", "t")): + subprocess.run(["git", "-C", str(work), "config", key, value], check=True) + (work / "a.txt").write_text("1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(work), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(work), "commit", "-m", "1"], + check=True, capture_output=True) + subprocess.run(["git", "-C", str(work), "push", "origin", "main"], + check=True, capture_output=True) + return work, remote + + +def _head(work: pathlib.Path) -> str: + return subprocess.run(["git", "-C", str(work), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True).stdout.strip() + + +def test_the_push_names_the_current_head(tmp_path) -> None: + """切り離された頭でも現在の頭が送られる(AC8)。""" + work, remote = _repo_with_remote(tmp_path) + subprocess.run(["git", "-C", str(work), "checkout", "--detach"], + check=True, capture_output=True) + (work / "a.txt").write_text("2\n", encoding="utf-8") + subprocess.run(["git", "-C", str(work), "commit", "-am", "2"], + check=True, capture_output=True) + commit = _head(work) + + outcome = result_posts.push_fix(work, "main", commit) + + assert outcome.ok is True and outcome.contains is True + remote_head = subprocess.run( + ["git", "-C", str(remote), "rev-parse", "refs/heads/main"], + check=True, capture_output=True, text=True).stdout.strip() + assert remote_head == commit + + +def test_the_push_fails_when_the_reported_commit_is_not_on_the_branch(tmp_path) -> None: + """報告されたコミットが送り先に載っていなければ止まる(AC9)。""" + work, _ = _repo_with_remote(tmp_path) + + outcome = result_posts.push_fix(work, "main", "0" * 40) + + assert outcome.ok is False and outcome.contains is False + + +def test_nothing_is_pushed_when_no_commit_was_made(tmp_path) -> None: + work, _ = _repo_with_remote(tmp_path) + + outcome = result_posts.push_fix(work, "main", None) + + assert outcome.ok is True and outcome.pushed is False From 4be3513c1b032687e3a8640cccac3e1aedd4800e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 09:33:43 +0000 Subject: [PATCH 175/217] =?UTF-8?q?Update:=20=E6=8C=87=E6=91=98=E3=81=AE?= =?UTF-8?q?=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=8C=E6=8A=95=E7=A8=BF?= =?UTF-8?q?=E3=82=92=E8=A1=8C=E3=81=84=E3=80=81=E7=94=B3=E5=91=8A=E3=81=A8?= =?UTF-8?q?=E5=AE=9F=E6=95=B0=E3=81=AE=E7=AA=81=E3=81=8D=E5=90=88=E3=82=8F?= =?UTF-8?q?=E3=81=9B=E3=82=92=E3=82=84=E3=82=81=E3=82=8B=EF=BC=88#730=20#5?= =?UTF-8?q?83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 取り込みの中を「控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」の順にした。記録に入る参照は送信の応答から、件数は送れた インラインの数から取る。担当の申告を GitHub の実数と比べて中断する処理と、 担当が申告した投稿の失敗・参照を読んで結果なしにする処理を取り除いた。 取り込みの標準出力へ、投稿の結果(参照・インラインの件数・総評へ移した件数・ 積んだ件数)と取り込んだ指摘の件数の行を足した。本文は出さない。 入口で前の取り込みが積んだ投稿を先に流す。積むのは取り込みだけで、積んだ時点で その担当の記録を書くため、書き戻し先が揃っている。 旧い契約(担当が投稿して申告する)を前提にしたテストは、新しい契約の確かめへ 置き換えるか取り除いた。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 3 +- .../ndf/skills/cross-review/scripts/state.py | 213 +++++------------ .../ndf/skills/cross-review/tests/conftest.py | 22 ++ .../tests/test_read_result_posts.py | 215 ++++++++++++++++++ .../tests/test_review_findings.py | 13 +- .../tests/test_state_not_posted.py | 86 +------ .../tests/test_state_posted_comments.py | 199 ---------------- .../tests/test_state_queue_judge.py | 89 +++----- .../tests/test_state_read_result.py | 8 +- 9 files changed, 363 insertions(+), 485 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_read_result_posts.py delete mode 100644 plugins/ndf/skills/cross-review/tests/test_state_posted_comments.py diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 54214121..3c837873 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -138,7 +138,8 @@ def review_posts(payload_path: pathlib.Path | str, result_path: pathlib.Path | s "side": "RIGHT", "body": str(f.get("body") or "")} for f in inline ] - extra = {"ident": f"{seat}-r{round_no}", "seat": seat, "round": round_no, + extra = {"ident": f"{seat}-r{round_no}", "agent": seat, "seat": seat, + "round": round_no, "intent": intent, "posted_as": posted_as, "inline": len(inline), "body": len(evacuated)} return [{"kind": "review-post", "fields": fields, "extra": extra}] diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 579a2ddc..f00d3bb4 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -38,6 +38,7 @@ import statefile # noqa: E402 再開の反映(#727 / #648) import run_metrics # noqa: E402 実行の要約(#662) import monitor_outcome # noqa: E402 起動 1 回の結末(#729) +import result_posts # noqa: E402 結果ファイルを投稿へ変える層(#730) # 区分の定義は scripts 配下の共有モジュールに 1 か所だけ置く(#156、#732)。 # `measure.py` も同じ定義を読み、両者の一致は `test_measure.py` が固定する。 @@ -1489,8 +1490,9 @@ def _auto_flush(pr: int) -> None: 実行していない場合に流れない。明示だけだと、進行側が忘れたときに待ち行列が残った まま収束の判定へ進む。流せなくても工程は止めない。 - **入口は、書き戻し先が揃っている場所だけである。** 取り込み(`read-result`)の - 入口では、そのラウンドの担当のエントリがまだ無い。詳細は `cmd_read_result` にある。 + **入口は、書き戻し先が揃っている場所だけである。** 積むのは取り込み + (`read-result`)だけで、積んだ時点でその担当の記録を書くため、流す時点では + 書き戻し先が揃っている。 """ q = _queue(pr) if not q.count(): @@ -2413,32 +2415,6 @@ def _as_count(value: object) -> int: return 0 -def _posted_comment_count(repo: str, pr: int, review_url: str | None) -> int | None: - """レビューに実際にぶら下がっているインラインコメントの数。 - - 取得できなければ `None` を返す。**「取得できなかった」と「0 件」を区別する。** - 取得の失敗で中断すると、GitHub 側の一時的な不調でループが止まる。 - - 投稿は AI 自身が `gh api` で行うため、失敗しても結果ファイルの申告だけは残る。 - 数え直す先は、申告された `review_url` の末尾にある識別子から決める。 - """ - if not repo or not review_url: - return None - m = re.search(r"pullrequestreview-(\d+)", str(review_url)) - if not m: - return None - try: - out = _sh( - ["gh", "api", f"repos/{repo}/pulls/{pr}/reviews/{m.group(1)}/comments", - "--paginate", "--jq", "length"], - check=False, - ) - except Exception: - return None - counts = [int(line) for line in str(out).split() if line.strip().isdigit()] - return sum(counts) if counts else None - - # Pull Request 上の未解決の指摘(Resolve されていない review thread)を数えるための問い合わせ。 # `--paginate` に載せるため、カーソルと `pageInfo` を持たせる。 _UNRESOLVED_THREADS_QUERY = """ @@ -2462,14 +2438,13 @@ def _posted_comment_count(repo: str, pr: int, review_url: str | None) -> int | N def _review_exists(repo: str, pr: int, review_url: str | None) -> bool | None: - """申告された `review_url` の指すレビューが GitHub 側にあるか。 + """`review_url` の指すレビューが GitHub 側にあるか。 取得できなければ `None` を返す。**「取得できなかった」と「無い」を区別する。** 取得の失敗で中断すると、GitHub 側の一時的な不調でループが止まる。 - 投稿は AI 自身が `gh api` で行うため、失敗しても結果ファイルには判定が残る。 - 判定だけを採ると、修正の担当が読むべき指摘が Pull Request に無いまま修正の工程が - 起動する(実測: `review_url` が空、重要度別の件数もすべて 0)。 + 上限で積んだ投稿を後から流したとき、その直後に 1 度だけ呼ぶ(`_confirm_flushed`)。 + 取り込みが自分で送った投稿は、送信の応答をそのまま記録にするため照会しない(#730)。 """ if not repo or not review_url: return False @@ -2735,54 +2710,6 @@ def _read_review_result_file(pr: int, agent: str, rfile: pathlib.Path) -> dict[s die(f"{agent}: 使える結果が無い (reason={reason}, {rfile}): {outcome.detail}") -def _verify_review_arrival( - pr: int, agent: str, repo: str, result: dict[str, Any] -) -> bool: - """投稿が Pull Request に届いたかを確かめ、待ち行列へ積んだかどうかを返す。 - - **投稿が届いたかを先に確かめる。** 判定だけが残り、指摘の中身が Pull Request に - 無いまま修正の工程へ進む経路を塞ぐ(#261)。届いていないときは結果なしとして - 記録し、判定の側の「同じラウンドで 1 度だけ起動し直す」経路へ乗せる。修正の担当 - から見ると、結果が残らなかった場合と、結果はあるが指摘が届いていない場合は同じ - 状態である(読むべき指摘が無い)。 - - **待ち行列へ積んだ投稿は、積んだ時点では届いていない。** ここで照会すると - 結果なしになり、起動し直しで同じ内容が二重に積まれる。届いたことは流した直後に - 1 度だけ確かめる(`_confirm_flushed`)。 - """ - queued = bool(result.get("queued")) - if queued: - info( - f"⚠ {agent}: 投稿を待ち行列へ積んでいます。" - "届いたことの確認は流した直後に行います" - ) - post_error = None if queued else result.get("post_error") - if post_error: - _die_no_result( - pr, - agent, - "not_posted", - f"{agent}: レビューの投稿に失敗しています (post_error={post_error})。" - " 指摘が Pull Request に届いていないため、結果なしとして扱います", - ) - exists = None if queued else _review_exists(repo, pr, result.get("review_url")) - if exists is False: - _die_no_result( - pr, - agent, - "not_posted", - f"{agent}: 投稿されたレビューを確認できません " - f"(review_url={result.get('review_url')!r})。" - " 指摘が Pull Request に届いていないため、結果なしとして扱います", - ) - if exists is None and not queued: - info( - f"⚠ {agent}: レビューの投稿を確認できませんでした。" - "申告をそのまま採用します" - ) - return queued - - # 指摘へ既定を与える項目。**持たない指摘も捨てない**(#156)。捨てると、4 項目へ # 対応していない担当の指摘が記録から消える。 _FINDING_DEFAULTS: dict[str, Any] = { @@ -2887,70 +2814,34 @@ def _collect_review_findings( return len(items) -def _resolve_result_aliases(r: dict[str, Any]) -> tuple[str | None, str | None, Any]: - """result.json の別名フィールドを正規のキーへ解決する。 - - `intent` / `comment_count` を使う変則 JSON を書き出す既知のケースに対応する。 - 仕様としては `event` / `comments_count` が正で、そちらを優先する。 - """ - intent = r.get("event") or r.get("intent") - posted_as = r.get("posted_as") or intent - comments = r.get("comments_count") - if comments is None: - comments = r.get("comment_count") - return intent, posted_as, comments - - -def _verify_declared_comments( - repo: str, - pr: int, - agent: str, - comments: Any, - review_url: str | None, - queued: bool, -) -> None: - """**申告を GitHub 側と突き合わせる。** - - 投稿は AI 自身が行うので、失敗しても結果ファイルには件数が残る。申告のまま進むと、 - 修正担当が読むべき指摘が GitHub 上に存在しないまま収束判定まで走る - (実測: 申告 2 件に対しスレッド 0)。 - """ - declared = _as_count(comments) - if declared > 0 and not queued: - actual = _posted_comment_count(repo, pr, review_url) - if actual is None: - info( - f"⚠ {agent}: 投稿されたコメント数を確認できませんでした。" - f"申告({declared} 件)をそのまま採用します" - ) - elif actual < declared: - die( - f"{agent}: インラインコメントの申告 {declared} 件に対し、" - f"GitHub 上には {actual} 件しかありません。投稿が届いていないため" - "中断します。レビューを投稿し直してから再実行してください" - ) - - def cmd_read_result(args: argparse.Namespace) -> None: - """Step 2.4 — codex/agy の result.json を state にマージ。 + """Step 2.4 — 担当の結果を読み、レビューを投稿して state にマージ。 使える結果が残らなかったときは、`NO_RESULT` と理由をラウンドへ残してから止める。 終了コードは現行のまま(無い・判定の値を持たないときは 1、JSON として読めない ときは 3)で、進む先を決めるのは次の判定である。 - **ここでは待ち行列を流さない。** 流すと `review-post` の書き戻し先(そのラウンドの - 担当のエントリ)がまだ無い時点で項目が消える。`_confirm_flushed` は書き戻せず、 - この後の取り込みが `queued: true` だけを保存するため、待ち行列が空で `queued` の - ままの状態ができる。判定はその状態で収束してしまい、投稿の存在も参照も確かめない。 - **両方の担当を取り込んだ後に流す**(`judge` の入口)。取り込みは判定の直前に - しかないため、流す時期が遅れるのは 1 コマンド分である。 + **「読んで記録する」と「投稿する」を 1 つに閉じる**(#730 の決定 4)。順序は + 「控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」 + である。分けると「投稿したが記録していない」に加えて「記録したが投稿していない」が + もう 1 つ増える。1 つに閉じれば、途中で止まった状態は「送れていない」か + 「送れたが記録が無い」の 2 つになる。 + + | 止まった場所 | 待ち行列の項目 | 立て直し | + | --- | --- | --- | + | 送る前(上限などで送れていない) | 残る | 判定の終了コード 8 の枝が流し直す | + | 送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。照合が先客を見つける | + + **担当の申告と GitHub の実数を突き合わせない。** 投稿する側と記録する側が同じに + なるため、確かめる対象が無い。記録に入る URL は送信の応答から取り、件数は送れた + インラインの数から取る。 """ agent = args.agent pr = args.pr rfile = pathlib.Path(args.file or _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-result.json") r = _read_review_result_file(pr, agent, rfile) - intent, posted_as, comments = _resolve_result_aliases(r) + intent = r.get("event") or r.get("intent") if intent is None: _die_no_result( @@ -2965,27 +2856,49 @@ def cmd_read_result(args: argparse.Namespace) -> None: if not st.get("rounds"): die(f"{agent}: state.rounds が空。`state.py start-round` を先に呼んでください") - repo = str(st.get("repo") or "") - - queued = _verify_review_arrival(pr, agent, repo, r) - - _verify_declared_comments(repo, pr, agent, comments, r.get("review_url"), queued) - - st["rounds"][-1][agent] = { - "intent": intent, - "posted_as": posted_as, - "comments": comments, - "review_url": r.get("review_url"), + # **先に残りを流す。** 残っているのは、前の取り込みで送れずに積んだ投稿だけで、 + # その担当の記録は積んだ時点で書いてある。流した結果をその記録へ書き戻してから、 + # この担当の投稿を後ろへ積む(Pull Request 上の順序を保つ)。 + _auto_flush(pr) + st = _load(pr) + last = st["rounds"][-1] + round_no = last.get("round") + posted = result_posts.post_review( + _queue(pr), + _payload_path(agent, pr, round_no), + rfile, + repo=str(st.get("repo") or ""), + pr=int(st.get("current_pr") or pr), + round_no=int(round_no or 1), + seat=agent, + head_sha=str(last.get("head_sha") or ""), + is_own_pr=bool(st.get("event_downgrade") or st.get("is_own_pr")), + actor=str(st.get("viewer_login") or "") or None, + ) + if posted.failed: + die(f"{agent}: レビューを投稿できませんでした ({posted.detail})") + + last[agent] = { + "intent": posted.intent, + "posted_as": posted.posted_as, + "comments": posted.posted_inline, + "review_url": posted.review_url, "by_severity": r.get("by_severity", {}), - "queued": queued, + "queued": bool(posted.queued), + "posted_inline": posted.posted_inline, + "posted_body": posted.posted_body, } - # **指摘そのものは別に積む**(#156)。`comments` は投稿したインラインの数で、 - # GitHub 側の実数との突き合わせに使う。総評だけへ書いた指摘はそこに現れない。 - collected = _collect_review_findings(st, agent, pr, st["rounds"][-1]["round"]) + # **指摘そのものは別に積む**(#156)。`comments` は送れたインラインの数で、 + # 総評へ移した指摘はそこに現れない。 + collected = _collect_review_findings(st, agent, pr, round_no) _save(pr, st) - info(f"✅ {agent}: intent={intent} posted_as={posted_as} comments={comments}") - if collected: - info(f" 指摘の記録: {collected} 件") + if posted.review_url: + print(f"POSTED review_url={posted.review_url}") + print(f"INLINE={posted.posted_inline} BODY={posted.posted_body}" + f" QUEUED={posted.queued}") + print(f"FINDINGS={collected}") + info(f"✅ {agent}: intent={posted.intent} posted_as={posted.posted_as}" + f" comments={posted.posted_inline}") def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, Any]: diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf/skills/cross-review/tests/conftest.py index 67cd6aec..3cea4631 100644 --- a/plugins/ndf/skills/cross-review/tests/conftest.py +++ b/plugins/ndf/skills/cross-review/tests/conftest.py @@ -131,6 +131,28 @@ def _no_github_state(request, monkeypatch) -> None: state_mod = request.getfixturevalue("state_mod") monkeypatch.setattr(state_mod, "_fetch_check_runs", lambda repo, sha: None) monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: None) + # **取り込みはレビューを投稿する**(#730)。投稿を見ないテストでは、組み立てまでを + # 本物で通し、送信だけを「届いた」に置き換える。偽の `gh` を要求するテストは + # 送信も含めて検査するため置き換えない。 + if "fake_gh" not in request.fixturenames: + monkeypatch.setattr(state_mod.result_posts, "post_review", + _post_review_offline(state_mod.result_posts)) + + +def _post_review_offline(rp): + """送信を行わず、組み立てた内容がそのまま届いたものとして結果を返す。""" + def _post(queue, payload_path, result_path, repo, pr, round_no, seat, head_sha, + is_own_pr, actor=None): + item = rp.review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr)[0] + extra = item["extra"] + findings = len(rp._findings(rp._read_json(payload_path))) + return rp.ReviewOutcome( + review_url=f"https://github.com/{repo}/pull/{pr}#pullrequestreview-1", + posted_inline=extra["inline"], posted_body=extra["body"], queued=0, + findings=findings, failed=False, posted_as=extra["posted_as"], + intent=extra["intent"], detail="") + return _post @pytest.fixture() diff --git a/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py b/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py new file mode 100644 index 00000000..baf8a0be --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py @@ -0,0 +1,215 @@ +"""指摘の取り込みが、控えからレビューを組み立てて送る(#730 #583)。 + +**書き込みはレビューを回す側だけが行う。** 担当は指摘の控えと結果ファイルを書いて +終わり、取り込みがそこから投稿を組み立て、待ち行列を通して送り、送信の応答を記録に +する。担当の申告と GitHub の実数を突き合わせる処理は無くなる。 + +| 何を確かめるか | 受け入れ条件 | +| --- | --- | +| 控えから投稿が組み立ち、1 回の呼び出しで送られる | AC5 | +| 応答に本文が出ない | AC6 | +| 投稿の後・記録の前で止めた実行をやり直しても増えない | AC12 | +| 記録の URL と件数が送信の応答から来る | AC15 | +| インラインが 0 件でも結果なしにならない | AC17 | +| 控えが無ければ投稿を 0 件にする | AC3 | +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + +PR = 730 +AGENT = "codex" +ROUND = 2 +REPO = "o/r" +ACTOR = "takemi" +SHA = "f" * 40 +INLINE_TEXT = "[major / 正確性] 戻り値を確かめる" +SUMMARY_TEXT = "層の分け方をそろえると読みやすい" + + +def _seed(tmp_dir: pathlib.Path, **over) -> None: + state = { + "current_pr": PR, + "repo": REPO, + "viewer_login": ACTOR, + "is_own_pr": False, + "event_downgrade": False, + "rounds": [{"round": ROUND, "pr": PR, "head_sha": SHA, + "started_at": "2026-09-22T00:00:00+00:00"}], + "final": None, + } + state.update(over) + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text( + json.dumps(state), encoding="utf-8") + + +def _note(tmp_dir: pathlib.Path, comments: list[dict] | None = None) -> pathlib.Path: + if comments is None: + comments = [{"path": "a.py", "line": 12, "body": INLINE_TEXT, + "severity": "major"}] + path = tmp_dir / f"{AGENT}-review-pr{PR}-round{ROUND}-payload.json" + path.write_text(json.dumps({"summary": SUMMARY_TEXT, "comments": comments}, + ensure_ascii=False), encoding="utf-8") + return path + + +def _result(tmp_dir: pathlib.Path, **over) -> pathlib.Path: + data = {"event": "REQUEST_CHANGES", "by_severity": {"major": 1}} + data.update(over) + path = tmp_dir / f"{AGENT}-review-pr{PR}-result.json" + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return path + + +def _args() -> argparse.Namespace: + return argparse.Namespace(pr=PR, agent=AGENT, file=None) + + +def _state(tmp_dir: pathlib.Path) -> dict: + return json.loads( + (tmp_dir / f"cross-review-pr{PR}-state.json").read_text(encoding="utf-8")) + + +def _entry(tmp_dir: pathlib.Path) -> dict: + return _state(tmp_dir)["rounds"][-1][AGENT] + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +_ACCEPT = {"match": f"pulls/{PR}/reviews", "stdout": json.dumps( + {"id": 555, "html_url": f"https://github.com/o/r/pull/{PR}#pullrequestreview-555"})} +_NO_PRIOR = {"match": f"pulls/{PR}/reviews?", "stdout": "[]"} +_URL = f"https://github.com/o/r/pull/{PR}#pullrequestreview-555" + + +def test_the_take_in_posts_the_review_and_records_the_response( + tmp_dir, state_mod, fake_gh, capsys) -> None: + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + entry = _entry(tmp_dir) + assert entry["review_url"] == _URL # 送信の応答から取る(AC15) + assert entry["comments"] == 1 # 送れたインラインの数 + assert entry["intent"] == "REQUEST_CHANGES" + assert entry["queued"] is False + out = capsys.readouterr().out + assert f"POSTED review_url={_URL}" in out + assert "INLINE=1 BODY=0 QUEUED=0" in out + assert "FINDINGS=1" in out + + +def test_no_body_reaches_the_answer(tmp_dir, state_mod, fake_gh, capsys) -> None: + """取り込みの出力に、レビューの本文もインラインの本文も出ない(AC6)。""" + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + captured = capsys.readouterr() + assert INLINE_TEXT not in captured.out and INLINE_TEXT not in captured.err + assert SUMMARY_TEXT not in captured.out and SUMMARY_TEXT not in captured.err + assert len(captured.out.splitlines()) <= 20 + + +def test_the_body_is_sent_from_the_note(tmp_dir, state_mod, fake_gh) -> None: + """本文は控えから組み立てて送る。先頭行がラウンドと席を持つ(AC5・AC10)。""" + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + sent = [c for c in fake_gh.calls() if "--method POST" in " ".join(c["argv"])] + body = json.loads(sent[-1]["stdin"]) + assert body["body"].splitlines()[0] == \ + f"## 🤖 cross-review | round {ROUND} | {AGENT} | REQUEST_CHANGES" + assert body["commit_id"] == SHA + assert body["comments"][0]["path"] == "a.py" + + +def test_a_second_take_in_does_not_add_a_second_review( + tmp_dir, state_mod, fake_gh) -> None: + """投稿の後・記録の前で止めた実行をやり直しても、レビューは増えない(AC12・AC20)。""" + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + state_mod.cmd_read_result(_args()) + posted_once = len([c for c in fake_gh.joined() if "--method POST" in c]) + + # 記録を消して、投稿だけが残った状態を作る。 + st = _state(tmp_dir) + st["rounds"][-1].pop(AGENT, None) + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text( + json.dumps(st), encoding="utf-8") + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", "stdout": json.dumps([{ + "user": {"login": ACTOR}, "state": "CHANGES_REQUESTED", "id": 555, + "body": f"## 🤖 cross-review | round {ROUND} | {AGENT} | REQUEST_CHANGES\n", + "html_url": _URL}])}, + ]) + + state_mod.cmd_read_result(_args()) + + assert len([c for c in fake_gh.joined() if "--method POST" in c]) == posted_once + assert _entry(tmp_dir)["review_url"] == _URL + + +def test_findings_without_an_inline_are_still_a_result( + tmp_dir, state_mod, fake_gh, capsys) -> None: + """指摘があってインラインが 0 件でも、その担当は結果なしにならない(AC17)。""" + _seed(tmp_dir) + _note(tmp_dir, comments=[{"body": SUMMARY_TEXT, "severity": "major"}]) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + entry = _entry(tmp_dir) + assert entry["intent"] == "REQUEST_CHANGES" + assert entry["comments"] == 0 + assert "INLINE=0 BODY=1" in capsys.readouterr().out + assert len(_state(tmp_dir)["review_findings"]) == 1 + + +def test_a_note_alone_is_treated_as_no_result(tmp_dir, state_mod, fake_gh) -> None: + """控えだけがあって結果ファイルが無ければ、投稿を 0 件にする(AC3・AC4)。""" + _seed(tmp_dir) + _note(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + with pytest.raises(SystemExit): + state_mod.cmd_read_result(_args()) + + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + + +def test_only_what_is_sent_is_downgraded_on_ones_own_pull_request( + tmp_dir, state_mod, fake_gh) -> None: + """自分の Pull Request では送った形だけを落とす(AC32)。""" + _seed(tmp_dir, is_own_pr=True, event_downgrade=True) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + sent = [c for c in fake_gh.calls() if "--method POST" in " ".join(c["argv"])] + assert json.loads(sent[-1]["stdin"])["event"] == "COMMENT" + entry = _entry(tmp_dir) + assert entry["intent"] == "REQUEST_CHANGES" and entry["posted_as"] == "COMMENT" diff --git a/plugins/ndf/skills/cross-review/tests/test_review_findings.py b/plugins/ndf/skills/cross-review/tests/test_review_findings.py index 19ca5a56..1b92ee2b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_review_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_review_findings.py @@ -74,8 +74,7 @@ def tmp_dir(monkeypatch, tmp_path, state_mod): @pytest.fixture(autouse=True) def _no_github_checks(monkeypatch, state_mod): - """GitHub 側の突き合わせを通す。取り込みの形だけを見る。""" - monkeypatch.setattr(state_mod, "_posted_comment_count", lambda *a, **k: None) + """流した後の実在確認を通す。取り込みの形だけを見る。""" monkeypatch.setattr(state_mod, "_review_exists", lambda *a, **k: True) @@ -203,14 +202,18 @@ def test_a_state_file_without_the_key_is_readable(tmp_dir, state_mod): assert len(_read(tmp_dir)["review_findings"]) == 1 -def test_the_comment_count_is_unchanged(tmp_dir, state_mod): - """`comments_count` は投稿したインラインの数のままにする。""" +def test_the_comment_count_is_the_number_of_inlines_sent(tmp_dir, state_mod): + """件数は担当の申告ではなく、送れたインラインの数である(#730 AC15)。 + + 送り先を決めるのは投稿する側で、控えの `posted_to` は読まない。位置を持つ指摘は + どちらもインラインとして送る。 + """ _write(tmp_dir, _state()); _result(tmp_dir, comments_count=3) _payload(tmp_dir, [FULL, {**FULL, "posted_to": "body"}]) _read_result(state_mod) - assert _read(tmp_dir)["rounds"][-1][AGENT]["comments"] == 3 + assert _read(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 def test_a_missing_payload_does_not_break_the_import(tmp_dir, state_mod): diff --git a/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py b/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py index 72c3ff55..58da6c4e 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py @@ -1,19 +1,18 @@ -"""投稿が届いていないレビューを結果なしとして扱う(#261)。 +"""投稿が届いたことを、送った後に確かめる(#261 #730)。 -レビュアーが投稿に失敗したとき、判定だけが残り、指摘の中身が Pull Request に無いまま -修正の工程へ進んでいた。修正の担当は Pull Request のコメントを読んで直すため、指摘が -無ければ直すものが無く、ラウンドだけが 1 つ増える。 +**投稿するのはレビューを回す側である**(#730)。担当は投稿せず、結果ファイルに +投稿の失敗や参照を申告しない。取り込みは送信の応答をそのまま記録にするため、 +申告を読んで結果なしにする経路は無い。 -| 結果ファイルの状態 | 扱い | +残るのは、上限で積んだ投稿を後から流したときの確かめである。流した直後に参照から +照会し、届いていなければ結果なしとして記録して、判定の「同じラウンドで 1 度だけ +起動し直す」経路へ乗せる(`_confirm_flushed`)。この文書はその照会の振る舞いを見る。 + +| 照会の結果 | 扱い | | --- | --- | -| `post_error` に値がある | 届いていない。結果なしとして記録する | -| `review_url` が空、または識別子を取り出せない | 届いていない。結果なしとして記録する | -| 識別子から照会してレビューが存在する | 届いた。これまでどおり取り込む | -| 識別子から照会できない | 申告を採用し、確認できなかったことを出力へ残す | - -結果なしとして記録すると、判定(`state.py judge`)の「同じラウンドで 1 度だけ起動し直す」 -経路へ乗る。修正の担当から見ると、結果が残らなかった場合と、結果はあるが指摘が届いて -いない場合は同じ状態である(読むべき指摘が無い)。 +| 識別子から照会してレビューが存在する | 届いた | +| 識別子を取り出せない | 届いていない | +| 照会できない・何も返らない | 分からない(届いていないとは読まない) | """ from __future__ import annotations @@ -66,54 +65,6 @@ def _round(tmp_dir: pathlib.Path) -> dict: return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text())["rounds"][-1] -def test_a_post_error_is_recorded_as_no_result(tmp_dir, state_mod, monkeypatch): - """実測の形。`post_error` があり、`review_url` も空で件数もすべて 0。""" - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result( - _args(_result(tmp_dir, review_url="", post_error="gh api failed")) - ) - - assert e.value.code == 1 - assert _round(tmp_dir)[AGENT]["intent"] == "NO_RESULT" - assert _round(tmp_dir)[AGENT]["no_result_reason"] == "not_posted" - - -def test_an_empty_review_url_is_recorded_as_no_result(tmp_dir, state_mod, monkeypatch): - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "") - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir, review_url=""))) - - assert e.value.code == 1 - assert _round(tmp_dir)[AGENT]["no_result_reason"] == "not_posted" - - -def test_a_review_missing_on_github_is_recorded_as_no_result(tmp_dir, state_mod, monkeypatch): - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: False) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert e.value.code == 1 - assert _round(tmp_dir)[AGENT]["no_result_reason"] == "not_posted" - - -def test_an_unavailable_lookup_keeps_the_declaration(tmp_dir, state_mod, monkeypatch, capsys): - """照会できないときは申告を採用する。通信の失敗でループを止めない。""" - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: None) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _round(tmp_dir)[AGENT]["intent"] == "REQUEST_CHANGES" - assert "確認できませんでした" in capsys.readouterr().err - - def test_a_posted_review_is_merged(tmp_dir, state_mod, monkeypatch): _seed_state(tmp_dir) monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) @@ -159,16 +110,3 @@ def test_the_lookup_is_none_when_the_api_returns_nothing(state_mod, monkeypatch) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "") assert state_mod._review_exists("o/r", PR, REVIEW_URL) is None - - -def test_a_no_result_record_sends_the_judge_to_a_relaunch(tmp_dir, state_mod, monkeypatch): - """結果なしの記録を受けて、判定が起動し直しへ進む(終了コード 7)。""" - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: False) - with pytest.raises(SystemExit): - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_judge(argparse.Namespace(pr=PR)) - - assert e.value.code == 7 diff --git a/plugins/ndf/skills/cross-review/tests/test_state_posted_comments.py b/plugins/ndf/skills/cross-review/tests/test_state_posted_comments.py deleted file mode 100644 index bf53ee2a..00000000 --- a/plugins/ndf/skills/cross-review/tests/test_state_posted_comments.py +++ /dev/null @@ -1,199 +0,0 @@ -"""申告されたインラインコメント数を、GitHub 側の実数と突き合わせる。 - -レビューの投稿は AI 自身が `gh api` で行うため、**投稿に失敗しても結果ファイルの -申告だけは残る**。申告を信じて先へ進むと、修正担当が読むべき指摘が GitHub 上に -存在しないまま収束判定まで走る。実測では 2 件の申告に対しスレッドが 1 つも -作られていなかった。 - -| 申告 | GitHub 側 | 扱い | -| --- | --- | --- | -| 0 件 | 見に行かない | 投稿が無いので突き合わせる相手がいない | -| 2 件 | 2 件 | そのまま採用する | -| 2 件 | 0 件 | 投稿が届いていないので中断する | -| 2 件 | 取得できない | 申告を採用し、確認できなかったことを残す | - -「取得できなかった」と「0 件」を混同しない。取得の失敗で止めると、GitHub 側の -一時的な不調でループが進まなくなる。 -""" -from __future__ import annotations - -import argparse -import json -import pathlib - -import pytest - -PR = 4242 -AGENT = "agy" -REVIEW_URL = f"https://github.com/o/r/pull/{PR}#pullrequestreview-4961230016" - - -def _seed_state(tmp_dir: pathlib.Path) -> None: - state = { - "current_pr": PR, - "repo": "o/r", - "rounds": [{"round": 1, "pr": PR, "started_at": "2026-08-18T00:00:00+00:00"}], - "final": None, - } - (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) - - -def _result(tmp_dir: pathlib.Path, **over) -> pathlib.Path: - payload = { - "event": "REQUEST_CHANGES", - "posted_as": "REQUEST_CHANGES", - "comments_count": 2, - "review_url": REVIEW_URL, - "by_severity": {"major": 2}, - } - payload.update(over) - rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps(payload)) - return rfile - - -def _args(rfile: pathlib.Path) -> argparse.Namespace: - return argparse.Namespace(pr=PR, agent=AGENT, file=str(rfile)) - - -def _read_state(tmp_dir: pathlib.Path) -> dict: - return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) - - -@pytest.fixture() -def tmp_dir(monkeypatch, tmp_path, state_mod): - monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) - return tmp_path - - -@pytest.fixture(autouse=True) -def review_posted(monkeypatch, state_mod): - """投稿の実在確認は届いた前提にする。件数の突き合わせだけを見るため。""" - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) - - -@pytest.fixture() -def posted(monkeypatch, state_mod): - """GitHub 側の件数を差し替える。`None` は取得できなかったことを表す。""" - def _set(count): - monkeypatch.setattr( - state_mod, "_posted_comment_count", - lambda repo, pr, review_url: count, - ) - return _set - - -def test_declared_count_matching_github_is_accepted(tmp_dir, state_mod, posted): - _seed_state(tmp_dir) - posted(2) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 - - -def test_declared_comments_missing_on_github_aborts(tmp_dir, state_mod, posted): - """申告があるのに GitHub 側へ届いていなければ中断する。 - - そのまま進むと、修正担当が読むべき指摘が存在しないまま収束判定まで走る。 - """ - _seed_state(tmp_dir) - posted(0) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert e.value.code == 1 - assert AGENT not in _read_state(tmp_dir)["rounds"][-1] - - -def test_partially_posted_comments_abort(tmp_dir, state_mod, posted): - """一部しか届いていない場合も中断する。取りこぼしは全件欠落と同じ扱いにする。""" - _seed_state(tmp_dir) - posted(1) - - with pytest.raises(SystemExit): - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - -def test_more_comments_on_github_is_accepted(tmp_dir, state_mod, posted): - """GitHub 側が多い分には通す。人の追記など、申告以外の経路で増えうる。""" - _seed_state(tmp_dir) - posted(3) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 - - -def test_zero_declared_skips_the_check(tmp_dir, state_mod, monkeypatch): - """申告 0 件なら GitHub を見に行かない。""" - _seed_state(tmp_dir) - called: list = [] - monkeypatch.setattr( - state_mod, "_posted_comment_count", - lambda *a, **k: called.append(a) or 0, - ) - - state_mod.cmd_read_result(_args(_result(tmp_dir, event="APPROVE", comments_count=0))) - - assert called == [] - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 0 - - -def test_unavailable_github_count_keeps_the_declaration(tmp_dir, state_mod, posted): - """GitHub 側を取得できなければ申告を採用する。取得失敗で止めない。""" - _seed_state(tmp_dir) - posted(None) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 - - -def test_missing_review_url_is_treated_as_not_posted(tmp_dir, state_mod, monkeypatch): - """投稿先の参照が無ければ、レビューが届いていないものとして扱う(#261)。 - - 件数の突き合わせより前に、投稿そのものが届いたかを見る。 - """ - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: False) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir, review_url=None))) - - assert e.value.code == 1 - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["intent"] == "NO_RESULT" - - -# ---------------- 件数の取得 ---------------- - -def test_posted_count_reads_the_review_id_from_the_url(state_mod, monkeypatch): - calls: list[list[str]] = [] - monkeypatch.setattr( - state_mod, "_sh", - lambda cmd, check=True: calls.append(list(cmd)) or "2", - ) - - count = state_mod._posted_comment_count("o/r", PR, REVIEW_URL) - - assert count == 2 - assert calls and "repos/o/r/pulls/4242/reviews/4961230016/comments" in calls[0] - - -def test_posted_count_is_none_when_the_url_has_no_review_id(state_mod, monkeypatch): - monkeypatch.setattr( - state_mod, "_sh", - lambda cmd, check=True: pytest.fail("識別子が無いのに GitHub を呼んでいる"), - ) - - assert state_mod._posted_comment_count("o/r", PR, "https://example.test/") is None - - -def test_posted_count_is_none_when_the_api_fails(state_mod, monkeypatch): - def boom(cmd, check=True): - raise RuntimeError("network") - - monkeypatch.setattr(state_mod, "_sh", boom) - - assert state_mod._posted_comment_count("o/r", PR, REVIEW_URL) is None diff --git a/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py b/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py index bae7a2a0..79252286 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py @@ -5,8 +5,8 @@ | 段階 | #261 の検査 | 待ち行列を入れた後 | | --- | --- | --- | -| 結果を取り込む | 投稿の失敗があれば結果なし | `queued` が真の結果はこの検査を通す | -| 結果を取り込む | 投稿先の参照の存在を照会 | `queued` が真のときは照会しない(識別子がまだ無い) | +| 結果を取り込む | 投稿の失敗があれば結果なし | 取り込みが自分で送る。上限で送れなければ積んで `queued` を真にする(#730) | +| 結果を取り込む | 投稿先の参照の存在を照会 | 照会しない。参照は送信の応答から取る(#730) | | 流した直後 | — | 参照を書き戻し、存在を 1 度だけ確かめる | | 判定 | 収束 | 待ち行列が空のときだけ | """ @@ -186,51 +186,28 @@ def test_a_review_that_did_not_arrive_is_recorded_as_no_result( # ---- 受け入れ条件 13 ---- -def test_a_queued_result_skips_the_arrival_check(state_mod, tmp_dir, - monkeypatch) -> None: - """積んだ時点では届いていない。照会すると結果なしになり、二重に積まれる。""" +def test_a_post_refused_by_the_limit_is_queued_and_recorded( + state_mod, queue_mod, fake_gh, tmp_dir) -> None: + """上限で送れなかった投稿は積まれ、記録は `queued` になる(#730)。""" _seed(tmp_dir, rounds=[{"round": 1, "pr": PR, "started_at": "2026-09-03T00:00:00+00:00"}]) - called: list = [] - monkeypatch.setattr(state_mod, "_review_exists", - lambda *a: called.append(a) or True) - monkeypatch.setattr(state_mod, "_posted_comment_count", - lambda *a: called.append(a) or 0) rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps({ - "event": "REQUEST_CHANGES", "posted_as": "COMMENT", "comments_count": 3, - "review_url": "", "queued": True, - "post_error": "API rate limit exceeded", - "by_severity": {"major": 3}, - }), encoding="utf-8") + rfile.write_text(json.dumps({"event": "REQUEST_CHANGES", + "by_severity": {"major": 3}}), encoding="utf-8") + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", "stdout": "[]"}, + {"match": f"pulls/{PR}/reviews", "exit": 1, + "stdout": '{"message":"API rate limit exceeded for user ID 1.","status":"403"}', + "stderr": "gh: API rate limit exceeded for user ID 1. (HTTP 403)\n"}, + ]) state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent="codex", file=str(rfile))) - assert called == [] entry = _state(tmp_dir)["rounds"][0]["codex"] assert entry["intent"] == "REQUEST_CHANGES" assert entry["queued"] is True - - -def test_a_normal_result_still_checks_the_arrival(state_mod, tmp_dir, - monkeypatch) -> None: - """`queued` を持たない結果ファイルの扱いは変えない(#261 のまま)。""" - _seed(tmp_dir, rounds=[{"round": 1, "pr": PR, - "started_at": "2026-09-03T00:00:00+00:00"}]) - called: list = [] - monkeypatch.setattr(state_mod, "_review_exists", - lambda *a: called.append(a) or True) - rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps({ - "event": "APPROVE", "comments_count": 0, "review_url": REVIEW_URL, - "by_severity": {}, - }), encoding="utf-8") - - state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent="codex", - file=str(rfile))) - - assert len(called) == 1 + assert queue_mod.Queue(tmp_dir / "pending").count() == 1 # ---- 流した結果を、再開の入口の書き戻しが消さない ---- @@ -275,34 +252,40 @@ def test_the_resume_keeps_what_the_flush_wrote_to_the_state( # 書き戻す側の変更も残る。どちらか一方だけが残る直し方にしない。 assert saved["manual_extra_review_instructions"] == "重点観点" assert queue_mod.Queue(tmp_dir / "pending").count() == 0 -# ---- 取り込みの前に流さない ---- +# ---- 取り込みの入口で残りを流す ---- -def test_the_read_result_does_not_flush_the_queue( - state_mod, queue_mod, fake_gh, tmp_dir) -> None: - """取り込みの入口では流さない。**書き戻し先がまだ無い。** +def test_the_take_in_flushes_what_was_left_before_posting( + state_mod, queue_mod, fake_gh, tmp_dir, monkeypatch) -> None: + """前の取り込みで積んだ投稿を先に流し、その担当の記録へ書き戻す(#730)。 - 流すと `_confirm_flushed` は書き戻せないまま項目が消え、この後の取り込みが - `queued: true` だけを保存する。待ち行列は空になるため判定は収束させ、投稿の - 存在も参照も一度も確かめられない。 + 積むのは取り込みだけで、積んだ時点でその担当の記録を書くため、書き戻し先がある。 """ _seed(tmp_dir, rounds=[{"round": 1, "pr": PR, - "started_at": "2026-09-03T00:00:00+00:00"}]) + "started_at": "2026-09-03T00:00:00+00:00", + "agy": {"intent": "APPROVE", "queued": True, + "by_severity": {}}}]) queue_mod.enqueue( queue_mod.Queue(tmp_dir / "pending"), "review-post", REPO, PR, - {"body": "指摘の本文", "event": "APPROVE"}, - actor="me", extra={"agent": "codex", "round": 1}) + {"body": "## 🤖 cross-review | round 1 | agy | APPROVE\n", "event": "APPROVE"}, + actor="me", extra={"agent": "agy", "round": 1}) rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps({ - "event": "APPROVE", "comments_count": 0, "review_url": "", - "queued": True, "by_severity": {}, - }), encoding="utf-8") + rfile.write_text(json.dumps({"event": "APPROVE", "by_severity": {}}), + encoding="utf-8") + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", "stdout": "[]"}, + {"match": "", "stdout": json.dumps( + {"id": 4961230016, "html_url": REVIEW_URL})}, + ]) + monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent="codex", file=str(rfile))) - assert queue_mod.Queue(tmp_dir / "pending").count() == 1 - assert fake_gh.joined() == [] + rnd = _state(tmp_dir)["rounds"][0] + assert rnd["agy"]["queued"] is False and rnd["agy"]["review_url"] == REVIEW_URL + assert rnd["codex"]["queued"] is False + assert queue_mod.Queue(tmp_dir / "pending").count() == 0 def test_the_queued_reviews_are_confirmed_once_both_results_are_taken_in( diff --git a/plugins/ndf/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf/skills/cross-review/tests/test_state_read_result.py index 323bff6e..8a270b69 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_read_result.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_read_result.py @@ -71,7 +71,8 @@ def test_canonical_schema(patched_tmp_dir, state_mod): assert merged["intent"] == "APPROVE" assert merged["posted_as"] == "APPROVE" assert merged["comments"] == 0 - assert merged["review_url"] == "https://example/pr/1#1" + # 参照は担当の申告ではなく送信の応答から取る(#730 AC15)。 + assert merged["review_url"].endswith("#pullrequestreview-1") assert merged["by_severity"]["critical"] == 0 @@ -93,9 +94,10 @@ def test_alias_schema_intent_and_comment_count(patched_tmp_dir, state_mod): st = _read_state(tmp_dir) merged = st["rounds"][-1][AGENT] assert merged["intent"] == "APPROVE" - # posted_as は別名 result.json には存在しないので intent と同値にフォールバック + # posted_as は投稿する側が決める。自分の Pull Request でなければ intent と同じ assert merged["posted_as"] == "APPROVE" - assert merged["comments"] == 3 + # 件数は担当の申告(comment_count)ではなく、送れたインラインの数(#730 AC15) + assert merged["comments"] == 0 def test_missing_event_and_intent_dies(patched_tmp_dir, state_mod): From 01feeec4a57a1d77f9cd43b73feee9b7725b3931 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 09:36:05 +0000 Subject: [PATCH 176/217] =?UTF-8?q?Update:=20=E6=8B=85=E5=BD=93=E3=81=AE?= =?UTF-8?q?=E3=83=97=E3=83=AD=E3=83=B3=E3=83=97=E3=83=88=E3=81=8B=E3=82=89?= =?UTF-8?q?=E6=8A=95=E7=A8=BF=E3=81=AE=E6=89=8B=E9=A0=86=E3=82=92=E5=A4=96?= =?UTF-8?q?=E3=81=97=E3=80=81=E6=9B=B8=E3=81=8D=E7=B5=82=E3=81=88=E3=81=A6?= =?UTF-8?q?=E3=81=8B=E3=82=89=E6=94=B9=E5=90=8D=E3=81=A7=E5=85=AC=E9=96=8B?= =?UTF-8?q?=E3=81=95=E3=81=9B=E3=82=8B=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 担当に書かせるのは指摘の控えと結果ファイルの 2 つだけにした。結果ファイルは判定と 重要度別の件数だけを持つ。投稿の呼び出し・判定の格下げ・インラインの組み立てと 差分の外への対処・投稿の失敗の申告をプロンプトから外した。総評は控えの summary へ 書かせ、送れた先(posted_to)は投稿する側が書く。 どちらのファイルも一時の名前で書かせ、控えを先・結果ファイルを後の順で改名させる。 起動の前に一時の名前の残りも消す。 Co-Authored-By: Claude Opus 5 (1M context) --- .../cross-review/scripts/launch-reviewer.sh | 147 ++++++++---------- .../test_launch_reviewer_prompt_context.py | 62 ++++++-- .../tests/test_read_result_posts.py | 15 ++ .../tests/test_review_findings.py | 10 +- 4 files changed, 137 insertions(+), 97 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh b/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh index 036cf92b..3055c62c 100755 --- a/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh +++ b/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh @@ -46,7 +46,6 @@ STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json load_context() { WORKTREE=$(jq -r '.worktree_path' "$STATE") REPO=$(jq -r '.repo' "$STATE") -EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") # PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 # tmp パス側は STATE_PR で固定 (monitor.py / state.py との読み書き整合のため)。 @@ -59,11 +58,15 @@ SHA=$(jq -r '(.rounds[-1].head_sha // "")' "$STATE") } prepare_prompt_context() { -# 前ラウンドの結果を残さない。投稿失敗などで今ラウンドの result.json が +# 前ラウンドの結果を残さない。担当が止まって今ラウンドの result.json が # 書かれなかったとき、state.py read-result が**前ラウンドの結果を読んで** # 同じ判定を繰り返す事故を防ぐ。 +# 一時の名前のファイルも消す。前の起動が書きかけで止まった残りを、改名の対象に +# しないため。 rm -f "$TMP_DIR/$SEAT-review-pr$STATE_PR-result.json" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-result.json.tmp" \ "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-payload.json" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-payload.json.tmp" \ "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-api-payload.json" STEM=$TMP_DIR/$SEAT-review-pr$STATE_PR @@ -97,16 +100,14 @@ render_review_prompt() { cat > "$PROMPT" <\` は本来の intent を書く。 ## 既存コメントスナップショット(重複指摘禁止) workspace 外を読まなくて済むよう、以下にインライン展開する: @@ -116,28 +117,24 @@ $EXISTING_INLINE \`\`\` $EXTRA_REVIEW_BLOCK -## 出力契約 -- review body の **先頭行** に必ず以下を入れる: - \`\`\` - ## 🤖 cross-review | round $ROUND | $SEAT | - \`\`\` - - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) - -### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) -- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない -- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 -- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) -- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** -- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする - -### インラインコメントの書式 +## 指摘に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」** — 総評にも書かない +- ❌ **対応アクションが無い指摘** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘** — 好みの問題は指摘にしない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無い指摘** +- ❌ **判定 \`COMMENT\` での雑感** — 直すべき点が無ければ \`APPROVE\` にする + +### 指摘の書式 - \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) -- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) -- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit は指摘にしない) +- 本文は **1 指摘 = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く +- 指す行が分かる指摘は \`path\` と \`line\` を埋める。**差分の外の行でもよい** + (差分の外を指す指摘は、投稿する側が総評へ移す) +- 設計レベル・PR 横断の指摘で行を指せないものは、\`path\` / \`line\` を省く -### body (総評) の書き方 +### 総評(\`summary\`)の書き方 - 設計レベル・PR 横断の **修正提案のみ** 書く -- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) +- 書くことが無ければ 1 行サマリだけで良い (褒め言葉や評価文は不要) ### 進捗マーカー(監視用) - 無言ハングと区別できるよう、作業フェーズが進むたびに @@ -146,69 +143,53 @@ $EXTRA_REVIEW_BLOCK - \`start: review PR #$PR round $ROUND\` - \`scan: diff and existing comments\` - \`analyze: candidate findings\` - - \`post: submit review\` + - \`write: payload and result\` - \`done: result.json written\` - -### インラインコメントを付けられる行(422 対策・必須) -- インラインコメントは **この PR の差分に含まれる行にしか付けられない**。差分外の行を - 指定すると GitHub が \`HTTP 422 Line could not be resolved\` を返し、**インラインだけで - なくレビュー本体も投稿されない**(指摘が丸ごと失われる) -- 差分に無い箇所を指摘したいときは、インラインにせず **body に「ファイル名:行 + 指摘」 - の形で書く** -- それでも 422 が返ったときは、**該当インラインを body へ移して再投稿する**。 - 投稿を諦めない - -### 投稿できなかった場合(必須) -- gh api がエラーを返したら、err.log に詳細を残したうえで **result.json を必ず書いて - から終了する**。\`event\` は本来の intent、\`comments_count\` は 0、 - \`"post_error"\` に失敗理由(HTTP status とメッセージ)を入れる: - \`\`\`json - {"event": "REQUEST_CHANGES", "posted_as": "COMMENT", "comments_count": 0, - "review_url": "", "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0}, - "post_error": "422 Line could not be resolved"} - \`\`\` -- result.json を書かずに終了すると、収束ループは**前ラウンドの結果を使うか、結果なしで - 停止する**。エラー時ほど result.json が要る - -- 投稿後、サマリを **$STEM-result.json** に - **必ず以下のキーで** 書く: - \`\`\`json - { - "event": "APPROVE", - "posted_as": "COMMENT", - "comments_count": 3, - "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", - "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0} - } - \`\`\` - - \`intent\` / \`comment_count\` 等の別名は使わないこと - - \`event\` の値は \`APPROVE\` / \`REQUEST_CHANGES\` / \`COMMENT\` のいずれか - - \`event_downgrade=true\` のとき \`posted_as\` は \`COMMENT\` にダウングレード可 -- payload は **$STEM-round$ROUND-payload.json** に保存 - (\`{ "comments": [{path, line, body, severity, evidence, falsification, - suggested_check, posted_to}, ...] }\` 形式) - - **\`comments[]\` に載せるのは、あなたが出した指摘の全件である。** 投稿したインラインの - 写しではない。**差分の外を指すために body へ書いた指摘も、422 で body へ移した指摘も - 載せる**(載せないと進行側から見えない) - - \`posted_to\` は \`inline\` / \`body\` のどちらへ投稿したか - - \`path\` / \`line\` は body へ書いたときも埋める(body でも「ファイル名:行 + 指摘」の - 形で書くため、値は手元にある) - - \`evidence\` は根拠(対象のコードと到達経路)、\`falsification\` は反証条件 - (これが成り立てば棄却できる)、\`suggested_check\` は実行できる検証手順 - - **根拠と反証条件は、別の担当がその指摘を確かめるためのものである。** 確かめられない - 書き方(「一般によくない」など)は根拠にならない +## 書くファイル(2 つ) + +**どちらも一時の名前で書き終えてから、正式の名前へ改名する。改名の順序は控えが先、 +結果ファイルが後である。** 結果ファイルが正式の名前で現れたことが、2 つとも書き終えた +印になる。途中で止まったときは正式の名前のファイルを残さない。 + +1. 指摘の控えを **$STEM-round$ROUND-payload.json.tmp** に書く: + \`\`\`json + { + "summary": "総評(1〜数行)", + "comments": [ + {"path": "src/foo.py", "line": 42, "body": "[major / 正確性] ...", + "severity": "major", "evidence": "...", "falsification": "...", + "suggested_check": "..."} + ] + } + \`\`\` + - **\`comments[]\` に載せるのは、あなたが出した指摘の全件である** + - \`evidence\` は根拠(対象のコードと到達経路)、\`falsification\` は反証条件 + (これが成り立てば棄却できる)、\`suggested_check\` は実行できる検証手順 + - **根拠と反証条件は、別の担当がその指摘を確かめるためのものである。** 確かめられない + 書き方(「一般によくない」など)は根拠にならない +2. 判定を **$STEM-result.json.tmp** に **必ず以下のキーだけで** 書く: + \`\`\`json + { + "event": "REQUEST_CHANGES", + "by_severity": {"critical": 0, "major": 1, "minor": 0, "nit": 0} + } + \`\`\` + - \`event\` は本来の判定で、\`APPROVE\` / \`REQUEST_CHANGES\` / \`COMMENT\` のいずれか。 + \`intent\` などの別名は使わない +3. 改名する(**この順で**): + \`\`\`bash + mv "$STEM-round$ROUND-payload.json.tmp" "$STEM-round$ROUND-payload.json" + mv "$STEM-result.json.tmp" "$STEM-result.json" + \`\`\` ## 守るべきこと - **発見を終えるまで、参照してよい既存コメントは起動時に渡されたスナップショットに - 限る。** 同じラウンドの他の担当が投稿した指摘・結果ファイル・進捗ログは参照しない - (指摘を出し終えて投稿するまでの間の話で、投稿の手順が既存コメントを引くことは妨げない) - - **担当は並列に起動する。** 先に投稿した担当の指摘を読むと、独立に見つけた指摘と - 区別できなくなる。同じ指摘が 2 者から出たことに意味があるのは、互いを見ていない場合 - だけである -- **リポジトリ編集禁止**。gh api での投稿のみ許可 -- worktree 外のパスは触らない -- gh api 失敗時は err.log にエラー詳細を残し、**result.json を書いてから**終了する + 限る。** 同じラウンドの他の担当の結果ファイル・進捗ログは参照しない + - **担当は並列に起動する。** 他の担当の指摘を読むと、独立に見つけた指摘と区別できなく + なる。同じ指摘が 2 者から出たことに意味があるのは、互いを見ていない場合だけである +- **リポジトリ編集禁止。PR・GitHub・git への書き込みもしない** +- worktree 外のパスは、上の 2 つのファイルと進捗マーカー以外に触らない EOF } diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py index b48a024a..f5e7ded0 100644 --- a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py +++ b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py @@ -44,32 +44,70 @@ def test_existing_comments_are_inlined_or_replaced_with_none(tmp_path, comments, assert snapshot == expected + "\n" -@pytest.mark.parametrize("event_downgrade, expected_line", [ - (True, "- event_downgrade: true"), - (False, "- event_downgrade: false"), -], ids=["downgrade-true", "downgrade-false"]) -def test_event_downgrade_is_reflected_in_prompt(tmp_path, event_downgrade, expected_line): - """現状固定: state.json の event_downgrade がプロンプトに反映される。""" +def _prompt(tmp_path, **state_over) -> str: state = { "current_pr": PR, "repo": "o/r", "worktree_path": str(tmp_path), - "event_downgrade": event_downgrade, + "event_downgrade": True, "rounds": [{"round": 1, "head_sha": "a" * 40}], } + state.update(state_over) (tmp_path / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) bin_dir = tmp_path / "bin" - bin_dir.mkdir() + bin_dir.mkdir(exist_ok=True) stub = bin_dir / "codex" stub.write_text("#!/bin/sh\nexit 0\n") stub.chmod(0o755) - result = subprocess.run( ["bash", str(SCRIPT), "codex", str(PR), "1"], capture_output=True, text=True, env={**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "CROSS_REVIEW_TMP_DIR": str(tmp_path)}, ) - assert result.returncode == 0, result.stderr - prompt = (tmp_path / f"codex-review-pr{PR}-prompt.md").read_text() - assert expected_line in prompt + return (tmp_path / f"codex-review-pr{PR}-prompt.md").read_text() + + +# ---------------- 担当は結果だけを残す(#730) ---------------- + + +@pytest.mark.parametrize("word", [ + "gh api", # 投稿の呼び出し + "event_downgrade", # 判定の値の指定(格下げは投稿する側が決める) + "posted_as", + "comments_count", + "review_url", + "post_error", + "line could not be resolved", # インラインの組み立て(422 への対処) + "post: submit review", +]) +def test_the_prompt_has_no_step_to_post(tmp_path, word) -> None: + """担当へ渡すプロンプトに、レビューを投稿する手順が 1 つも無い(AC1・AC2)。""" + assert word.lower() not in _prompt(tmp_path).lower() + + +def test_the_prompt_asks_only_for_the_note_and_the_result(tmp_path) -> None: + """書かせるのは指摘の控えと結果ファイルの 2 つだけ。結果は判定と重要度別の件数(AC2)。""" + prompt = _prompt(tmp_path) + assert f"codex-review-pr{PR}-round1-payload.json" in prompt + assert f"codex-review-pr{PR}-result.json" in prompt + assert '"event"' in prompt and '"by_severity"' in prompt + + +def test_the_prompt_asks_to_rename_the_note_before_the_result(tmp_path) -> None: + """一時の名前で書き、控えを先・結果ファイルを後に改名させる(AC3)。""" + prompt = _prompt(tmp_path) + note_tmp = f"codex-review-pr{PR}-round1-payload.json.tmp" + result_tmp = f"codex-review-pr{PR}-result.json.tmp" + assert note_tmp in prompt and result_tmp in prompt + first_mv = prompt.index(f"mv ") + assert prompt.index(note_tmp, first_mv) < prompt.index(result_tmp, first_mv) + +def test_leftover_temporary_files_are_removed_before_launch(tmp_path) -> None: + """前の起動が残した一時の名前のファイルを持ち越さない。""" + for name in (f"codex-review-pr{PR}-result.json.tmp", + f"codex-review-pr{PR}-round1-payload.json.tmp"): + (tmp_path / name).write_text("{}") + _prompt(tmp_path) + assert not (tmp_path / f"codex-review-pr{PR}-result.json.tmp").exists() + assert not (tmp_path / f"codex-review-pr{PR}-round1-payload.json.tmp").exists() diff --git a/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py b/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py index baf8a0be..977a280c 100644 --- a/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py +++ b/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py @@ -213,3 +213,18 @@ def test_only_what_is_sent_is_downgraded_on_ones_own_pull_request( assert json.loads(sent[-1]["stdin"])["event"] == "COMMENT" entry = _entry(tmp_dir) assert entry["intent"] == "REQUEST_CHANGES" and entry["posted_as"] == "COMMENT" + + +def test_a_reviewer_that_wrote_nothing_adds_no_review( + tmp_dir, state_mod, fake_gh) -> None: + """控えも結果も書かずに終わった担当では、レビューが 1 件も増えない(AC4)。""" + _seed(tmp_dir) + # 書きかけの一時の名前だけが残った状態も、正式の名前が無ければ結果なしである。 + (tmp_dir / f"{AGENT}-review-pr{PR}-round{ROUND}-payload.json.tmp").write_text("{") + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + with pytest.raises(SystemExit): + state_mod.cmd_read_result(_args()) + + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + assert _entry(tmp_dir)["intent"] == "NO_RESULT" diff --git a/plugins/ndf/skills/cross-review/tests/test_review_findings.py b/plugins/ndf/skills/cross-review/tests/test_review_findings.py index 1b92ee2b..2be1f07c 100644 --- a/plugins/ndf/skills/cross-review/tests/test_review_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_review_findings.py @@ -339,12 +339,18 @@ def test_a_failed_reimport_does_not_erase_what_was_taken(tmp_dir, state_mod): SKILL = pathlib.Path(__file__).resolve().parents[1] -def test_the_prompt_asks_for_the_four_items(): +def test_the_prompt_asks_for_the_three_items(): text = (SKILL / "scripts/launch-reviewer.sh").read_text(encoding="utf-8") - for key in ("evidence", "falsification", "suggested_check", "posted_to"): + for key in ("evidence", "falsification", "suggested_check"): assert key in text, key +def test_the_prompt_does_not_ask_where_it_was_posted(): + """送れた先(`posted_to`)は投稿する側が書く。担当には書かせない(#730)。""" + text = (SKILL / "scripts/launch-reviewer.sh").read_text(encoding="utf-8") + assert "posted_to" not in text + + def test_the_prompt_asks_for_body_only_findings(): """総評だけへ書いた指摘も payload へ載せることを求める。""" text = (SKILL / "scripts/launch-reviewer.sh").read_text(encoding="utf-8") From 8d27d061e5ba7dc094622e319af3ddeb572e9254 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 09:40:27 +0000 Subject: [PATCH 177/217] =?UTF-8?q?Update:=20=E4=BF=AE=E6=AD=A3=E3=81=AE?= =?UTF-8?q?=E9=80=81=E4=BF=A1=E3=81=A8=E8=BF=94=E4=BF=A1=E3=83=BB=E6=B1=BA?= =?UTF-8?q?=E7=9D=80=E3=83=BB=E3=81=BE=E3=81=A8=E3=82=81=E3=82=92=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E8=BE=BC=E3=82=80=E5=81=B4=E3=81=B8=E7=A7=BB=E3=81=97?= =?UTF-8?q?=E3=80=81=E5=8D=98=E7=8B=AC=E3=81=AE=E5=8F=A3=E3=82=92=201=20?= =?UTF-8?q?=E8=A1=8C=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88#730=20#585=20#676?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 修正の取り込みが、現在の頭を指定して送り、報告されたコミットが送り先に載ったことを 確かめてから、返信・決着・まとめを待ち行列を通して送る。載っていなければ記録も 投稿もせずに止まる。まとめの参照は投稿の応答から記録へ書く。 修正の担当はコミットと戻り値ファイルまでを行う。単独で使うときは、戻り値ファイルを 書いた後に共通層の部分命令を 1 行実行する。返信・決着・まとめ・送信の実装は cross-review から呼ぶ経路と同じものである。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 3 + .../ndf/scripts/tests/test_result_posts.py | 40 ++++++ .../ndf/skills/cross-review/scripts/state.py | 33 ++++- .../ndf/skills/cross-review/tests/conftest.py | 19 ++- .../tests/test_merge_fix_posts.py | 102 +++++++++++++++ plugins/ndf/skills/fix/SKILL.md | 120 ++++++++---------- 6 files changed, 244 insertions(+), 73 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 3c837873..bbe9012f 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -389,6 +389,9 @@ def push_fix(worktree: pathlib.Path | str, head_branch: str, """ if not fix_commit: return PushResult(True, False, True, "コミットが無いため送らない") + if not (str(worktree or "") and head_branch): + return PushResult(False, False, False, + "送る先(作業ツリーとブランチ)が分からない") pushed = _git(worktree, "push", "origin", f"HEAD:{head_branch}") if pushed.returncode != 0: return PushResult(False, False, False, diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index b1677a77..747ad6cc 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -416,3 +416,43 @@ def test_nothing_is_pushed_when_no_commit_was_made(tmp_path) -> None: outcome = result_posts.push_fix(work, "main", None) assert outcome.ok is True and outcome.pushed is False + + +def test_the_push_fails_without_a_destination() -> None: + outcome = result_posts.push_fix("", "", "abc1234") + + assert outcome.ok is False and outcome.pushed is False + + +# ---------------- 単独で使う口 ---------------- + +def test_the_standalone_command_pushes_and_posts_with_the_same_layer( + tmp_path, fake_gh, monkeypatch) -> None: + """単独の `fix` も同じ層を使い、1 行のコマンドで送信と投稿を終える(AC21・AC22)。""" + work, remote = _repo_with_remote(tmp_path) + (work / "a.txt").write_text("2\n", encoding="utf-8") + subprocess.run(["git", "-C", str(work), "commit", "-am", "2"], + check=True, capture_output=True) + fix = _fix_file(tmp_path, fix_commit=_head(work)) + fake_gh.set_rules([ + {"match": "issues/730/comments?", "stdout": "[]"}, + {"match": "pulls/730/comments?", "stdout": "[]"}, + {"match": "reviewThreads", "stdout": "PRRT_a\nPRRT_b\n"}, + {"match": "issues/730/comments", "stdout": json.dumps( + {"id": 7, "html_url": "https://x/pull/730#issuecomment-7"})}, + {"match": "", "stdout": "{}"}, + ]) + monkeypatch.delenv("CROSS_REVIEW_TMP_DIR", raising=False) + + r = subprocess.run( + [sys.executable, str(LIB / "result_posts.py"), "fix", "--repo", REPO, + "--pr", str(PR), "--result", str(fix), "--head", "main", + "--worktree", str(work), "--round", str(ROUND), "--actor", ACTOR], + capture_output=True, text=True, env=os.environ.copy()) + + assert r.returncode == 0, r.stderr + assert "PUSHED=1 COMMIT_ON_HEAD=1" in r.stdout + assert "POSTED summary_url=https://x/pull/730#issuecomment-7" in r.stdout + assert "REPLIED=4 RESOLVED=2 QUEUED=0" in r.stdout + # 本文は出さない。 + assert "好みの範囲" not in r.stdout diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index f00d3bb4..89be6d14 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -4145,6 +4145,12 @@ def _count(v: Any) -> int: return 0 +# 読んだ修正の結果ファイルの場所。投稿の組み立ては本文を引数に取らず、ファイルの +# パスを受け取る(#730 の決定 3)。記録へは写らない(`_normalize_fix_result` は +# 決まった鍵だけを読む)。 +FIX_SOURCE_KEY = "_source_path" + + def _read_fix_result( pr: int | str, explicit_file: str | pathlib.Path | None, @@ -4169,7 +4175,9 @@ def _read_fix_result( ] if explicit is not None: - return _read_explicit_fix_result(explicit) + fix = _read_explicit_fix_result(explicit) + fix.setdefault(FIX_SOURCE_KEY, str(explicit)) + return fix fix = _find_fallback_fix_result(fallback_candidates, pr, round_started_ts) @@ -4221,6 +4229,7 @@ def _find_fallback_fix_result( candidate, pr, round_started_ts, is_canonical=is_canonical ) if is_fresh: + parsed.setdefault(FIX_SOURCE_KEY, str(candidate)) return parsed return None @@ -4366,9 +4375,31 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: fix = _read_fix_result(pr, args.file, round_started_ts) + # **送信と投稿は取り込む側が行う**(#730)。修正の担当はコミットまでで止まる。 + # 送れない・報告されたコミットが送り先に載っていないときは、記録も投稿もせずに + # 止まる。同じ取り込みをやり直せば、同じ手順を最初から通る。 + commit = fix.get("fix_commit") or fix.get("commit_sha") + pushed = result_posts.push_fix(str(st.get("worktree_path") or ""), + str(st.get("head_branch") or ""), commit) + if not pushed.ok: + die(f"修正を送れないか、報告されたコミットが送り先に載っていません: {pushed.detail}") + print(f"PUSHED={1 if pushed.pushed else 0} COMMIT_ON_HEAD={1 if pushed.contains else 0}") + round_fix = _merge_fix_records(st, fix, pr) _save(pr, st) + posted = result_posts.post_fix( + _queue(pr), fix[FIX_SOURCE_KEY], str(st.get("repo") or ""), + int(st.get("current_pr") or pr), round_no=st["rounds"][-1].get("round"), + actor=str(st.get("viewer_login") or "") or None) + st["rounds"][-1]["fix"]["summary_comment_url"] = posted.summary_url + _save(pr, st) + if posted.summary_url: + print(f"POSTED summary_url={posted.summary_url}") + print(f"REPLIED={posted.replied} RESOLVED={posted.resolved} QUEUED={posted.queued}") + if posted.failed: + die(f"返信・決着・まとめを投稿できませんでした ({posted.detail})") + # CI 分類 if (fix.get("ci_status") or "").upper() != "FAILURE": info(f"✅ fix マージ完了 (commit={round_fix['commit']} fixed={round_fix['fixed']})") diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf/skills/cross-review/tests/conftest.py index 3cea4631..7e87c901 100644 --- a/plugins/ndf/skills/cross-review/tests/conftest.py +++ b/plugins/ndf/skills/cross-review/tests/conftest.py @@ -135,8 +135,12 @@ def _no_github_state(request, monkeypatch) -> None: # 本物で通し、送信だけを「届いた」に置き換える。偽の `gh` を要求するテストは # 送信も含めて検査するため置き換えない。 if "fake_gh" not in request.fixturenames: - monkeypatch.setattr(state_mod.result_posts, "post_review", - _post_review_offline(state_mod.result_posts)) + rp = state_mod.result_posts + monkeypatch.setattr(rp, "post_review", _post_review_offline(rp)) + monkeypatch.setattr(rp, "push_fix", + lambda worktree, head, commit: rp.PushResult( + True, bool(commit), True, "")) + monkeypatch.setattr(rp, "post_fix", _post_fix_offline(rp)) def _post_review_offline(rp): @@ -289,3 +293,14 @@ def fake_gh(monkeypatch, tmp_path) -> FakeGh: def queue_mod() -> types.ModuleType: """共通層の待ち行列モジュール(#291)。""" return _load_module("ndf_post_queue", _POST_QUEUE) + + +def _post_fix_offline(rp): + """送信を行わず、組み立てた返信・決着・まとめがすべて届いたものとして返す。""" + def _post(queue, result_path, repo, pr, round_no=None, actor=None): + kinds = [i["kind"] for i in rp.fix_posts(result_path, repo, pr, round_no)] + return rp.FixOutcome( + summary_url=f"https://github.com/{repo}/pull/{pr}#issuecomment-1", + replied=kinds.count("review-reply"), resolved=kinds.count("thread-resolve"), + queued=0, failed=False, detail="") + return _post diff --git a/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py new file mode 100644 index 00000000..7956614c --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py @@ -0,0 +1,102 @@ +"""修正の取り込みが、送信と返信・決着・まとめを行う(#730 #585 #676)。 + +**修正の担当はコミットまでを行い、送らない。** 取り込みが現在の頭を指定して送り、 +報告されたコミットが送り先に載ったことを確かめてから、返信・決着・まとめを +待ち行列へ積んで流す。まとめの参照は投稿の応答から記録へ書く。 + +| 何を確かめるか | 受け入れ条件 | +| --- | --- | +| 返信・決着・まとめが積まれて流れる | AC7 | +| 送信は取り込む側が行う | AC8 | +| 報告されたコミットが送り先に無ければ止まる | AC9 | +| 同じ共通層を使う | AC21 | +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + +PR = 5850 +REPO = "o/r" + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def _seed(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, "repo": REPO, "viewer_login": "takemi", + "worktree_path": str(tmp_dir), "head_branch": "feat/x", + "rounds": [{"round": 2, "pr": PR, "started_at": "2026-01-01T00:00:00+00:00"}], + "deferred_nits": [], "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _fix(tmp_dir: pathlib.Path) -> pathlib.Path: + path = tmp_dir / f"fix-pr{PR}-result.json" + path.write_text(json.dumps({ + "pr": PR, "fix_commit": "abc1234", "ci_status": "SUCCESS", "fixed_count": 1, + "by_severity": {"major": 1}, + "resolved_threads": [{"thread_id": "PRRT_a", "comment_id": 11}], + "deferred": [], "rejected": [], + }), encoding="utf-8") + return path + + +def _state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +@pytest.fixture() +def calls(monkeypatch, state_mod): + """送信と投稿の呼び出しを記録する。共通層の口をそのまま差し替える。""" + rp = state_mod.result_posts + seen: dict = {"push": [], "post": []} + + def push(worktree, head, commit): + seen["push"].append((str(worktree), head, commit)) + return rp.PushResult(seen.get("push_ok", True), True, + seen.get("push_ok", True), "") + + def post(queue, result_path, repo, pr, round_no=None, actor=None): + items = rp.fix_posts(result_path, repo, pr, round_no) + seen["post"].append([i["kind"] for i in items]) + return rp.FixOutcome("https://x/pull/5850#issuecomment-9", 1, 1, 0, False, "") + + monkeypatch.setattr(rp, "push_fix", push) + monkeypatch.setattr(rp, "post_fix", post) + return seen + + +def test_the_take_in_pushes_the_head_and_posts_the_replies(tmp_dir, state_mod, calls): + _seed(tmp_dir) + _fix(tmp_dir) + + state_mod.cmd_merge_fix(argparse.Namespace(pr=PR, file=None)) + + assert calls["push"] == [(str(tmp_dir), "feat/x", "abc1234")] + assert calls["post"] == [["review-reply", "thread-resolve", "pr-comment"]] + fix = _state(tmp_dir)["rounds"][-1]["fix"] + assert fix["summary_comment_url"] == "https://x/pull/5850#issuecomment-9" + + +def test_the_take_in_stops_when_the_commit_is_not_on_the_branch( + tmp_dir, state_mod, calls): + """報告されたコミットが送り先に載っていなければ、記録も投稿もせずに止まる(AC9)。""" + _seed(tmp_dir) + _fix(tmp_dir) + calls["push_ok"] = False + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(argparse.Namespace(pr=PR, file=None)) + + assert e.value.code != 0 + assert calls["post"] == [] + assert "fix" not in _state(tmp_dir)["rounds"][-1] diff --git a/plugins/ndf/skills/fix/SKILL.md b/plugins/ndf/skills/fix/SKILL.md index 7b1c51d0..d9f59fba 100644 --- a/plugins/ndf/skills/fix/SKILL.md +++ b/plugins/ndf/skills/fix/SKILL.md @@ -56,19 +56,29 @@ PR: ) ``` -サブエージェント側ではこの SKILL.md を読み込んで、自己完結で -**修正 → コミット → push → reply → Resolve Conversation** まで実行する。 -メインへの戻り値は最小限のサマリのみ。 +サブエージェント側ではこの SKILL.md を読み込んで、**修正 → コミット → 戻り値ファイル** +までを行う。メインへの戻り値は最小限のサマリのみ。 -**push が credential helper の不全で落ちたときは退避する**(#524)。`gh` が認証済みでも -`git` だけが `Authentication failed` を返す環境がある。 +**修正の担当は GitHub と git へ書かない。** 送信・返信・スレッドの決着・まとめの投稿は、 +戻り値ファイルを読んだ側が行う(#730)。担当が送ると、送ったという報告と実物が食い違う +状態(切り離された頭では、ブランチ名だけの送信が何も送らずに終了コード 0 で終わる)と、 +途中で止まったときに投稿だけが残る状態が作れる。 + +| 起動のされ方 | 書き込みを行う側 | +| --- | --- | +| `/ndf:cross-review` から | 修正の取り込み(`state.py merge-fix`)が行う | +| 単独で呼んだ | 戻り値ファイルを書いた後、次の 1 行を実行する | ```bash -git -c credential.helper= -c credential.helper='!gh auth git-credential' push +python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <戻り値ファイル> \ + [--repo <所有者>/<リポジトリ>] [--head <ブランチ名>] [--worktree <作業ツリー>] [--round ] ``` -**空の値を先に置く。** `credential.helper` は複数の値を持てる設定で、`git` は宣言された -順に問い合わせる。空の値だけが一覧を空へ戻す。 +`$SCRIPTS` の決め方は `development-workflow` の `references/scripts-lookup.md` にある。 +`--repo` / `--head` / `--worktree` を省いたときは、いまいる作業ツリーと Pull Request から引く。 +このコマンドが現在の頭を送り先へ送り(`git push origin HEAD:<ブランチ名>`)、報告した +コミットが送り先に載ったことを確かめてから、返信・決着・まとめを待ち行列を通して送る。 +出力は件数と参照だけで、本文を出さない。 ## コメントの取得(3 ソース) @@ -203,14 +213,11 @@ GitHub MCP を使う場合は `mcp__github__get_pull_request_comments` を利用 4. 問題点を修正。**コード行数が減る方向の修正は積極的に実施**(重複排除、不要分岐除去) 5. **コミット前の再確認** — 作業中に新しいコメントが追加されていないか再取得し、CI 状態も 現時点だけ確認する(完了待ちはしない)。新しい指摘・失敗があれば手順 3 に戻る -6. コミット・プッシュ -7. **PR レベルの Summary コメントを投稿**(対応件数 + deferred 件数を明記) -8. 対応したインラインコメントに個別に返信 -9. **deferred スレッドには `[deferred / nit]` ラベル付き返信** を投稿(Resolve はしない) -10. reviewer に再レビューを依頼 -11. 対応完了したスレッドを **Resolve Conversation** にする -12. **本文の決めたことの節を設計文書に揃える**(**コミットの有無によらず**実行する。後述) -13. **戻り値ファイルを書き出す**(後述) +6. コミットする。**送らない** +7. **本文の決めたことの節を設計文書に揃える**(**コミットの有無によらず**実行する。後述) +8. **戻り値ファイルを書き出す**(後述)。対応したスレッド・見送り・却下をそれぞれの配列へ + 入れる。返信・決着・まとめはこの配列から組み立てられる +9. 単独で呼んだときだけ、「起動モード」の 1 行を実行して送信と投稿を終える ### 本文の決めたことの節を揃える @@ -278,33 +285,30 @@ review 指摘と CI エラーは**同じ PR で一緒に修正**する。同じ ## 返信と Resolve -### 返信の書き分け +**返信・決着・まとめは戻り値ファイルから組み立てる。** 担当が投稿の呼び出しを書かない。 +組み立てと送信は共通層(`lib/result_posts.py`)が 1 か所で持ち、`/ndf:cross-review` から +呼んだときも単独で呼んだときも同じ実装を通る。 -| 状況 | 返信の型 | -|---|---| -| 修正した | `対応しました — <ファイル>:<行> で〇〇 (commit )` | -| 別 PR で対応 | `別 PR で対応予定です。PR 説明の「やらないこと」に記載のとおり、<理由>` | -| deferred | `[deferred / nit] 後続 PR で対応予定` | -| rejected | `bot 指摘は誤読です — 理由: ...` | -| 対応不要 | `確認しました。<対応不要と判断した理由>` | -| 範囲外 | `範囲外と判断し、#<番号> として残しました` — `/ndf:out-of-scope` で起票してから返信する。起票先のリポジトリもその Skill が決める(flaky テスト・CI の失敗は例外で、この PR で直す) | +| 戻り値ファイルの配列 | 送られるもの | +| --- | --- | +| `resolved_threads` | 指摘への「対応しました(<コミット>)」の返信と、スレッドの決着 | +| `deferred` | 見送りの理由(`reason_for_deferral`)の返信。決着しない | +| `rejected` | 採らない理由(`reason_for_rejection`)の返信。決着しない | +| (すべて) | 対応件数・決着・見送り・却下・CI を並べた Pull Request のまとめ | -```bash -# 特定のコメントに返信(in_reply_to にコメント ID を指定) -gh api repos/{owner}/{repo}/pulls/{pr_number}/comments \ - -f body="対応しました。" -F in_reply_to={comment_id} -``` +- 返信の宛先は各要素の `comment_id`、決着の宛先は `thread_id`(`PRRT_...`)である。 + `thread_id` はレビューコメントの `node_id`(`PRRC_...`)ではない。下の query の + `nodes[].id` から取る(次の節の query) +- 同じ返信・同じまとめを 2 度送っても増えない(本文の先頭 80 文字で先客を照合する)。 + すでに決着したスレッドをもう一度決着させても失敗にならない -### Resolve Conversation +### 対応の対象は未解決の指摘を数え直して決める(必須) -**修正済みのスレッドのみ** Resolve する。`deferred` / `rejected` は次ラウンドで再評価する -ため Resolve しない。 +**投稿数を対象の数として使わない。** レビュー結果の `comments_count` は +そのラウンドで新しく投稿された件数であり、PR 上に残っている未解決の指摘の数ではない。 +前のラウンドの分や、中断の前に投稿された分がこの数の外にある。 -`resolveReviewThread` が要求するのは **review thread** の ID(`PRRT_...`)であり、 -レビューコメントの `node_id`(`PRRT_` ではなく `PRRC_...`)ではない。 -`repos/{owner}/{repo}/pulls/comments/` から引ける `node_id` はコメント側の ID -なので **Resolve には使えない**。必ず下記 query の `nodes[].id` を使い、 -`comments.nodes[].databaseId`(返信に使ったコメント ID)または本文と突き合わせて特定する。 +スレッドの一覧は次の query で読む(読み取りだけで、書き込みはしない)。 ```bash # スレッド一覧を thread ID (PRRT_...) 付きで取得 @@ -323,21 +327,9 @@ gh api graphql -f query=' }' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {thread_id: .id, path, line, comment_id: .comments.nodes[0].databaseId}' - -# 上で得た thread_id(PRRT_...)を THREAD_ID に入れて Resolve -gh api graphql -f query=' - mutation($id: ID!) { - resolveReviewThread(input: {threadId: $id}) { thread { isResolved } } - }' -f id="$THREAD_ID" ``` -### 対応の対象は未解決の指摘を数え直して決める(必須) - -**投稿数を対象の数として使わない。** レビュー結果の `comments_count` は -そのラウンドで新しく投稿された件数であり、PR 上に残っている未解決の指摘の数ではない。 -前のラウンドの分や、中断の前に投稿された分がこの数の外にある。 - -上の query を `isResolved == false` で絞った結果が対象の全量である。 +この query を `isResolved == false` で絞った結果が対象の全量である。 `/ndf:cross-review` から呼ばれた場合は、次のコマンドでも同じ数を取れる(引数は state.json の キー、つまり最初に `init` した PR 番号を渡す。対象の PR は state.json 側で解決される)。 @@ -351,28 +343,15 @@ eval "$UNRESOLVED_VARS" `eval` 自身の終了コードが 0 になり、スクリプトの `exit 1` が消える。未解決の件数を 取得できていないのに 0 件と読んで、対象が無いものとして先へ進むことになる。 -返信と Resolve を終えたら、**同じ query をもう一度実行して残数を確認する**。 +送信と投稿を終えたら、**同じ query をもう一度実行して残数を確認する**。 deferred / rejected として意図的に残したもの以外が残っていれば、対応が漏れている。 ### PR レベル Summary コメント(必須) インラインへの返信と Resolve **だけでは不十分**。PR ページの Conversation タブに -まとめが出ないと、レビュアー視点で見落とされる。 - -```bash -gh pr comment --body "$(cat <<'EOMD' -## 🔧 /ndf:fix サマリ - -対応件数: critical=X / major=Y / minor=Z (合計 N 件) -deferred: D 件 / rejected: R 件 -commit: -CI: SUCCESS | FAILURE | NONE - -### 詳細 -- 各 thread の対応概要(行リンク付き) -EOMD -)" -``` +まとめが出ないと、レビュアー視点で見落とされる。**まとめは戻り値ファイルから組み立てて +送られる**(上の表の最後の行)。先頭行にラウンドとコミットが入るため、同じラウンドの +まとめは 1 件だけになる。 ## 戻り値フォーマット(必須) @@ -401,11 +380,12 @@ EOMD {"comment_id": 3222849090, "path": "scripts/state.py", "line": 120, "severity": "minor", "summary": "heredoc を <<'JSON' にせよ", "reason_for_rejection": "$SHA を意図的に展開する必要があり、クオート化すると逆に壊れる"} - ], - "summary_comment_url": "https://github.com/.../pull/67#issuecomment-..." + ] } ``` +- **まとめの参照(`summary_comment_url`)は書かない。** 投稿する側が、まとめの投稿の応答から + 記録へ書く - `resolved_threads` / `deferred` / `rejected` は **必ず配列**で返す(件数の int は誤り)。 該当が無ければ空配列 - **`rejected` の各要素は `path` / `line` / `severity` を持つ。** 却下した論点が次のラウンドで From bee79b43568dd2f871dc27dadcb77989d7348767 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 09:41:56 +0000 Subject: [PATCH 178/217] =?UTF-8?q?Update:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E5=8F=82=E5=8A=A0=E8=80=85=E3=82=92=E5=85=B1=E9=80=9A=E5=B1=A4?= =?UTF-8?q?=E3=81=A7=E6=B1=BA=E3=82=81=E3=80=81=E9=81=A9=E7=94=A8=E3=81=AE?= =?UTF-8?q?=E8=BC=AA=E7=95=AA=E3=82=92=E5=8F=82=E5=8A=A0=E8=80=85=E3=81=AE?= =?UTF-8?q?=E4=B8=AD=E3=81=A7=E5=9B=9E=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 初期化は母集合の既定(codex / kiro とホスト)と使える者の解決を止めない確認で呼び、 確認を通らない者を外して続ける。足す者・外す者・全員を要する指定の引数を足す - 適用専用の母集合(状態の項目と初期化の出力)とラウンドのレビュー担当を消し、 適用の輪番を参加者の一覧から決める - 再開で上限を反映し、状態に載る他の引数は違えば知らせる。担当に関わる引数を 渡した再開でだけ参加者を作り直す - 報告と改修計画の表示を 1 つの母集合に揃え、参加者の節を足す - 呼び手の無くなった旧関数 4 つを共通層から消し、残らないことをテストで固定する Refs #664 #727 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/assignment.py | 92 +--- plugins/ndf/scripts/lib/auth.py | 34 +- plugins/ndf/scripts/tests/test_auth_probe.py | 49 --- .../ndf/scripts/tests/test_lib_assignment.py | 89 +--- .../cross-refactoring/scripts/refactor.py | 50 ++- .../scripts/refactor_lib/commands/apply.py | 4 +- .../scripts/refactor_lib/commands/report.py | 75 +++- .../scripts/refactor_lib/commands/setup.py | 259 ++++++++--- .../scripts/refactor_lib/gitfacts.py | 13 +- .../scripts/refactor_lib/plan.py | 4 +- .../scripts/refactor_lib/rounds.py | 9 +- .../tests/crossref_helpers.py | 1 - .../tests/test_apply_attempts.py | 10 +- .../tests/test_assignment.py | 149 ++----- .../cross-refactoring/tests/test_init.py | 415 ++++++++++-------- .../tests/test_models_and_metrics.py | 3 +- .../tests/test_plan_comment.py | 8 + .../cross-refactoring/tests/test_rounds.py | 79 +++- .../tests/test_start_round_emits_runtimes.py | 32 +- .../tests/test_rejected_findings.py | 3 +- .../tests/test_state_review_pool.py | 5 +- scripts/tests/test_shared_lib_layout.py | 21 + 22 files changed, 742 insertions(+), 662 deletions(-) diff --git a/plugins/ndf/scripts/lib/assignment.py b/plugins/ndf/scripts/lib/assignment.py index a01928c4..42c7b29c 100644 --- a/plugins/ndf/scripts/lib/assignment.py +++ b/plugins/ndf/scripts/lib/assignment.py @@ -1,29 +1,17 @@ """ホスト判定と担当の決定(収束ループ共通層)。 -**役割ごとに母集合が違う**ことがこの層の要点である。 +**母集合の既定は Skill ごとに違う**ことがこの層の要点である。 -| 母集合 | 定義 | 中身 | +| Skill | 母集合の既定 | 中身 | | --- | --- | --- | -| 提案・レビュー | 全ランタイム − ホスト | 常に 3 者 | -| 適用 | 全ランタイム | 常に 4 者 | - -**担当の選び方は、適用の役があるかどうかで分かれる。** `assign()` は実装担当を先に決めて -から残りを絞り、`review_assign()` は母集合から直接 2 者を選ぶ。どちらも返すレビュー担当は -2 者である。 - -参加する 4 者はいずれも NDF の配布先であるため、**適用から外す者はいない**。 -ホストは提案・レビューから外れるが適用には入るため、2 つの母集合は重なるが -一致しない。輪番の式はホストによらず同じ形になる。 - -## 使える者の解決と席の埋め方(#727) +| cross-review | `review_pool(host)` | 全ランタイム − ホスト | +| cross-refactoring | `refactor_pool(host)` | `DEFAULT_REFACTOR_RUNTIMES`(codex / kiro)とホスト | 参加者は「母集合の既定 ∪ 足す者 − 外す者」で決め(`resolve_participants`)、確認を -通った者だけを使える者(`available`)として記録する。cross-refactoring の母集合の -既定は `refactor_pool(host)`(`DEFAULT_REFACTOR_RUNTIMES` とホスト)、cross-review は -`review_pool(host)` のまま。担当の単位は席の名前(`SEAT_PATTERN`。`claude-2` のように -同じランタイムの 2 つ目を表す)で、cross-review の 2 席は `review_seats` が、 -cross-refactoring の適用担当は `impl_assign` が決める。上の表と `impl_pool` / -`review_assign` / `assign` は、母集合が 1 つになる次の Pull Request(P7)まで残す。 +通った者だけを使える者(`available`)として記録する。担当の単位は席の名前 +(`SEAT_PATTERN`。`claude-2` のように同じランタイムの 2 つ目を表す)で、cross-review の +2 席は `review_seats` が、cross-refactoring の適用担当は `impl_assign` が決める(#727)。 +cross-refactoring は提案と適用を同じ参加者で回し、レビュー担当を持たない。 """ from __future__ import annotations @@ -73,7 +61,7 @@ def detect_host( """ホストを確定し、`(ホスト名, 判定根拠)` を返す。 判定根拠は `explicit`(`--host` の明示指定)か `env`(環境変数からの推定)。 - 誤検出すると**提案・レビューの母集合が狂う**(ホストが提案側に混ざる、 + 誤検出すると**母集合の既定が狂う**(ホストが cross-review の担当に混ざる、 参加すべき者が外れる)ため、呼び出し側は結果を必ず出力と状態ファイルへ残す。 推定できないときは例外を上げる。既定値を勝手に置くと、間違ったまま一周して @@ -97,67 +85,12 @@ def detect_host( def review_pool(host: str) -> list[str]: - """提案・レビューの母集合(全ランタイム − ホスト)。常に 3 者になる。""" + """cross-review の母集合の既定(全ランタイム − ホスト)。常に 3 者になる。""" if host not in HOST_RUNTIMES: raise AssignmentError(f"ホストになれないランタイムです: {host}") return [r for r in ALL_RUNTIMES if r != host] -def impl_pool() -> list[str]: - """適用の母集合(全ランタイム)。ホストによらず常に同じ。 - - **関数として残す。** 呼び出し側が提案・レビューの母集合と適用の母集合を - 別々に確定する構造を保つためである。両者は依然として一致しない - (適用はホストを含み、提案・レビューは含まない)。 - """ - return list(ALL_RUNTIMES) - - -def review_assign(round_no: int, host: str) -> list[str]: - """ラウンド番号から**レビュー担当 2 者**を決める。適用の役を持たない工程が使う。 - - 母集合は `review_pool(host)` の 3 者で、外す 1 者をラウンドごとに回す。 - - レビュー担当 = 母集合 − 母集合[(ラウンド番号 - 1) % 3] - - `assign()` と分けているのは、**適用の役があるかどうかで選び方が変わる**ためである。 - `assign()` は先に実装担当を決めてから残りを絞るが、この工程には適用が無く、母集合から - 直接 2 者を選ぶ。3 者すべてを毎ラウンド起動しないのは、起動回数が 1.5 倍になるためで、 - ラウンドを重ねれば 3 者とも差分を見る。 - """ - if round_no < 1: - raise AssignmentError(f"ラウンド番号は 1 以上です: {round_no}") - pool = review_pool(host) - dropped = (round_no - 1) % len(pool) - return [r for i, r in enumerate(pool) if i != dropped] - - -def assign(round_no: int, host: str) -> tuple[str, list[str]]: - """ラウンド番号から `(実装担当, レビュー担当 2 者)` を決める。 - - 輪番の単位は**ラウンド**である。1 ラウンドの適用を 1 者へ集約することで、 - レビュー担当を「実装担当以外」から機械的に決められる。 - - 実装担当 = 適用候補[ラウンド番号 % 4] - 候補 = 提案・レビュー − 実装担当 - レビュー担当 = 候補が 2 者ならそのまま - 3 者なら 候補[(ラウンド番号 // 4) % 3] を除いた 2 者 - - 実装担当がホストと同じランタイムのとき、その者は提案・レビューの母集合に - 含まれないため候補が 3 者残る。**レビュー担当は常に 2 者**とし(起動回数を - 抑える方針と揃える)、余る 1 者はラウンドを跨いで順に外して負荷を均す。 - """ - if round_no < 1: - raise AssignmentError(f"ラウンド番号は 1 以上です: {round_no}") - pool = impl_pool() - impl = pool[round_no % len(pool)] - candidates = [r for r in review_pool(host) if r != impl] - if len(candidates) > 2: - dropped = (round_no // len(pool)) % len(candidates) - candidates = [r for i, r in enumerate(candidates) if i != dropped] - return impl, candidates - - def _in_fixed_order(names: Iterable[str]) -> list[str]: """`ALL_RUNTIMES` の順に並べ直す(重複は 1 つにする)。""" wanted = set(names) @@ -323,7 +256,7 @@ def review_seats(round_no: int, available: list[str], fallback: list[str]) -> li | 0 | `fallback[0]` と `-2`。`fallback` が空なら `AssignmentError` | `available` の並びは `ALL_RUNTIMES` の順(`resolve_participants` が保つ)。n = 3 の値は - 変更前の `review_assign` と一致する。埋め合わせの候補は使える者に含まれない者だけを + この関数より前の輪番(外す 1 者を `(round_no - 1) % 3` で回す式)と一致する。埋め合わせの候補は使える者に含まれない者だけを 使い、含まれる者は飛ばす(同じ席の名前を 2 つ返さないため)。`only` の処理は呼び出し側が 先に行う(1 者指定は埋め合わせをしない)。 """ @@ -347,7 +280,8 @@ def review_seats(round_no: int, available: list[str], fallback: list[str]) -> li def impl_assign(round_no: int, participants: list[str]) -> str: """cross-refactoring の適用担当 1 者を決める: `participants[round_no % len]`。 - 式は変更前の `assign()` と同じで、除数だけを参加者の数にする(設計の決定 7)。 + 式はこの関数より前の輪番(4 者の固定の順を `round_no % 4` で引く式)と同じで、 + 除数だけを参加者の数にする(設計の決定 7)。 ラウンド 1 が `participants[1]` から始まるため、ホスト claude の既定 (claude / codex / kiro)でもホストが最初に適用する形にならない。 """ diff --git a/plugins/ndf/scripts/lib/auth.py b/plugins/ndf/scripts/lib/auth.py index 810f3db8..c981f282 100644 --- a/plugins/ndf/scripts/lib/auth.py +++ b/plugins/ndf/scripts/lib/auth.py @@ -75,38 +75,6 @@ def _probe_all(runtimes: Iterable[str], info: Callable[[str], None]) -> ProbeRes return results -def check_auth( - runtimes: Iterable[str], - *, - info: Callable[[str], None], - die: Callable[[str], None], - env: Optional[dict[str, str]] = None, -) -> ProbeResult: - """参加する CLI の認証状態を確かめる。1 つでも欠けたら呼び出し側を中断させる。 - - **出力と中断の手段は呼び出し側から受け取る。** 工程ごとに終了コードの意味が違う - (`cross-refactoring` の中断は 4、`cross-review` は 1)ため、この層で決めない。 - - 確認コマンドは CLI の版で変わりうるので、`NDF_SKIP_AUTH_CHECK` で飛ばせるように - しておく。飛ばしたことは必ず出力へ残す(黙って劣化させない)。 - - P7 で消す。止めない確認は `probe_auth`、止めるかの判断は - `assignment.resolve_participants` の `require_all` が持つ。 - """ - if _skipped(env, info): - return {} - - results = _probe_all(runtimes, info) - failed = [f"{name}({r['detail']})" for name, r in results.items() if not r["ok"]] - if failed: - die( - "認証されていない CLI があります: " + " / ".join(failed) + "。" - "参加者が欠けたまま進むと、その者のレビューが無いまま収束します。" - "各 CLI でログインしてから再実行してください" - ) - return results - - def probe_auth( runtimes: Iterable[str], *, @@ -119,7 +87,7 @@ def probe_auth( **例外を上げず、呼び出し側も中断させない。** 通らなかった者を外して続けるか、 全員を要して止めるかは、使える者の解決(`assignment.resolve_participants`)が 決める。確認コマンド・未認証の文言・時間切れの秒数・飛ばす環境変数は - `check_auth` と同じものを使う。 + この層の定数(`AUTH_PROBES` ほか)が持つ。 `NDF_SKIP_AUTH_CHECK` が立てば確認コマンドを 1 回も呼ばず `({}, True)` を返す。 飛ばしたことは出力へ残す(黙って劣化させない)。 diff --git a/plugins/ndf/scripts/tests/test_auth_probe.py b/plugins/ndf/scripts/tests/test_auth_probe.py index fd93932a..f8bca89a 100644 --- a/plugins/ndf/scripts/tests/test_auth_probe.py +++ b/plugins/ndf/scripts/tests/test_auth_probe.py @@ -2,7 +2,6 @@ 主題は止めない確認 `probe_auth`(#727)である。失敗しても例外を上げず、`ok` と理由を 返し、`NDF_SKIP_AUTH_CHECK` が立てば確認コマンドを 1 回も呼ばない(AC5 / AC6)。 -従来の `check_auth` のテストは、その関数を消す Pull Request(P7)まで末尾に残す。 """ from __future__ import annotations @@ -166,51 +165,3 @@ def run(cmd, **kw): assert results["codex"]["ok"] is False assert results["agy"]["ok"] is True - - -# ---------- check_auth(従来の確認。P7 で消す) ---------- - -def test_unknown_runtime_is_ignored(): - auth = _load_auth() - messages: list[str] = [] - failures: list[str] = [] - - results = auth.check_auth(["unknown"], info=messages.append, die=failures.append, env={}) - - assert "unknown" not in results - assert failures == [] - - -def test_probe_timeout_is_reported(monkeypatch): - auth = _load_auth() - messages: list[str] = [] - failures: list[str] = [] - - def time_out(*args, **kwargs): - raise subprocess.TimeoutExpired(args[0], kwargs["timeout"]) - - monkeypatch.setattr(auth.subprocess, "run", time_out) - results = auth.check_auth(["codex"], info=messages.append, die=failures.append, env={}) - - assert results["codex"]["ok"] is False - assert str(auth.AUTH_PROBE_TIMEOUT) in results["codex"]["detail"] - assert len(failures) == 1 - - -def test_unauthenticated_marker_fails_even_when_probe_exits_zero(monkeypatch): - auth = _load_auth() - messages: list[str] = [] - failures: list[str] = [] - - monkeypatch.setattr( - auth.subprocess, - "run", - lambda *args, **kwargs: SimpleNamespace( - returncode=0, stdout="Not logged in", stderr="" - ), - ) - - results = auth.check_auth(["codex"], info=messages.append, die=failures.append, env={}) - - assert results["codex"]["ok"] is False - assert len(failures) == 1 diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index 96d7bc63..73400c8c 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -9,50 +9,6 @@ ASSIGNMENT = Path(__file__).resolve().parents[1] / "lib" / "assignment.py" -EXPECTED = { - "claude": [ - ("codex", ["agy", "kiro"]), - ("agy", ["codex", "kiro"]), - ("kiro", ["codex", "agy"]), - ("claude", ["codex", "kiro"]), - ("codex", ["agy", "kiro"]), - ("agy", ["codex", "kiro"]), - ("kiro", ["codex", "agy"]), - ("claude", ["codex", "agy"]), - ], - "codex": [ - ("codex", ["agy", "kiro"]), - ("agy", ["claude", "kiro"]), - ("kiro", ["claude", "agy"]), - ("claude", ["agy", "kiro"]), - ("codex", ["claude", "kiro"]), - ("agy", ["claude", "kiro"]), - ("kiro", ["claude", "agy"]), - ("claude", ["agy", "kiro"]), - ], - "agy": [ - ("codex", ["claude", "kiro"]), - ("agy", ["codex", "kiro"]), - ("kiro", ["claude", "codex"]), - ("claude", ["codex", "kiro"]), - ("codex", ["claude", "kiro"]), - ("agy", ["claude", "kiro"]), - ("kiro", ["claude", "codex"]), - ("claude", ["codex", "kiro"]), - ], - "kiro": [ - ("codex", ["claude", "agy"]), - ("agy", ["claude", "codex"]), - ("kiro", ["codex", "agy"]), - ("claude", ["codex", "agy"]), - ("codex", ["claude", "agy"]), - ("agy", ["claude", "codex"]), - ("kiro", ["claude", "agy"]), - ("claude", ["codex", "agy"]), - ], -} - - @pytest.fixture(scope="module") def assignment(): spec = importlib.util.spec_from_file_location("ndf_lib_assignment", ASSIGNMENT) @@ -106,49 +62,12 @@ def test_detect_host_rejects_an_environment_without_hints(assignment): assignment.detect_host(None, {}) -@pytest.mark.parametrize("host", EXPECTED) -def test_assign_keeps_the_eight_round_rotation(assignment, host): - actual = [assignment.assign(round_no, host) for round_no in range(1, 9)] - - assert actual == EXPECTED[host] - assert any(impl == host for impl, _ in actual) - assert any(impl != host for impl, _ in actual) - assert all(len(reviewers) == 2 for _, reviewers in actual) - assert all(impl not in reviewers for impl, reviewers in actual) - - -@pytest.mark.parametrize("host", ("claude", "codex", "agy", "kiro")) -def test_assign_rejects_a_bad_round(assignment, host): - """round_no < 1 の場合に AssignmentError が送出される(R2-001)。""" - for round_no in (0, -1): - with pytest.raises( - assignment.AssignmentError, - match=r"^ラウンド番号は 1 以上です:", - ) as excinfo: - assignment.assign(round_no, host) - assert "ラウンド番号は 1 以上です" in str(excinfo.value) - - -def test_review_assign_rejects_a_bad_round(assignment): - """round_no < 1 の下限境界で AssignmentError が送出される(R2-003)。 - - 同モジュールの `assign` / `review_seats` は下限境界を固定しているが、 - `review_assign` だけ抜けていたため現状の振る舞いを固定する。 - """ - for round_no in (0, -1): - with pytest.raises( - assignment.AssignmentError, - match=r"^ラウンド番号は 1 以上です:", - ) as excinfo: - assignment.review_assign(round_no, "claude") - assert "ラウンド番号は 1 以上です" in str(excinfo.value) - - @pytest.mark.parametrize("host", ("gemini", "unknown")) -def test_review_assign_rejects_a_host_outside_host_runtimes(assignment, host): - """HOST_RUNTIMES に含まれないホストを拒否する現状を固定する(R2-005)。""" +@pytest.mark.parametrize("pool", ("review_pool", "refactor_pool")) +def test_the_default_pools_reject_a_host_outside_host_runtimes(assignment, pool, host): + """HOST_RUNTIMES に含まれないホストを、どちらの母集合の既定も拒否する。""" with pytest.raises(assignment.AssignmentError) as excinfo: - assignment.review_assign(1, host) + getattr(assignment, pool)(host) assert "ホストになれないランタイムです" in str(excinfo.value) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py index 79203a40..01df6cf5 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py @@ -61,7 +61,11 @@ cmd_report, cmd_status, ) -from refactor_lib.commands.setup import cmd_init, cmd_start_round # noqa: E402 +from refactor_lib.commands.setup import ( # noqa: E402 + cmd_init, + cmd_start_round, + runtime_list, +) from refactor_lib.measure import summary_extra # noqa: E402 import run_metrics # noqa: E402 @@ -83,6 +87,10 @@ def _write_run_summary(path: pathlib.Path, state: dict) -> None: SEVERITY_ORDER, ) +# **状態ファイルに載る引数の既定は `None` にする**(#727 の決定 13)。既定値を引数に +# 持たせると、再開で「渡さなかった」と「既定値を渡した」を区別できない。新規の +# 初期化が `commands/setup.py` の `NEW_RUN_DEFAULTS` で置き換える。 + # ---------------- main ---------------- @@ -94,39 +102,51 @@ def main() -> None: init = sub.add_parser( "init", - help="Step 0 — ホスト確定 / 母集合の確定 / 作業ディレクトリ root / 状態初期化") + help="Step 0 — ホスト確定 / 参加者の確定 / 作業ディレクトリ root / 状態初期化・再開") init.add_argument("pr", type=int) init.add_argument("--scope", nargs="+", required=True, help="対象範囲。提案が無制限に広がらないよう必須にしている") init.add_argument("--host", choices=list(assignment.HOST_RUNTIMES), default=None, help="ホストの明示指定。未指定時は環境変数から推定する") + # **参加者は既定に足し引きして決める**(#727 の決定 4)。既定は codex / kiro と + # ホストで、使う側を並べる形にしないのは、ホストが変わるたびに書き直さずに済むため。 + init.add_argument("--exclude", action="append", type=runtime_list, default=None, + help="参加者から外す者(ホストも外せる)。カンマ区切り・繰り返し可。" + "再開で none を渡すと空へ戻す") + init.add_argument("--include", action="append", type=runtime_list, default=None, + help="参加者に足す者(例: agy)。カンマ区切り・繰り返し可。" + "再開で none を渡すと空へ戻す") + init.add_argument("--require-all", dest="require_all", + action=argparse.BooleanOptionalAction, default=None, + help="確認を通らない者が 1 者でもいれば中断する。" + "既定は外して続ける") # **切るのは提案の回数であって、適用できる件数ではない**(#436 決定 8)。 # 適用ラウンドを分けたことで、1 回の提案で通せる件数は上限に縛られなくなった。 # 取り消した項目は除外されるため、同じ提案が積み上がって回数を食うこともない。 # **輪番の 1 周を根拠にしない。** 適用の担当は適用ラウンドごとに進むので、 # 1 つの提案ラウンドが複数の群を持てば輪番は 1 周しうる。 - init.add_argument("--max-outer-rounds", type=int, default=3, - help="構造改善の提案ラウンドの上限") + init.add_argument("--max-outer-rounds", type=int, default=None, + help="構造改善の提案ラウンドの上限 (default: 3)") # テスト整備は母集合が増えない(対象のコードを変えないため、テストが薄い経路の # 集合は最初から確定している)。2 回目に出るのは 1 回目の挙げ漏らしだけである。 - init.add_argument("--max-test-rounds", type=int, - default=DEFAULT_MAX_TEST_ROUNDS, + init.add_argument("--max-test-rounds", type=int, default=None, help="テスト整備ラウンドの上限。到達したら採用が残っていても " "構造改善の提案ラウンドへ進む " f"(default: {DEFAULT_MAX_TEST_ROUNDS})") - init.add_argument("--max-fix-rounds", type=int, default=3, - help="1 つの適用ラウンドあたりの修正ラウンドの上限") - init.add_argument("--max-items-per-round", type=int, default=5, - help="1 つの提案ラウンド/テスト整備ラウンドの採用上限") + init.add_argument("--max-fix-rounds", type=int, default=None, + help="1 つの適用ラウンドあたりの修正ラウンドの上限 (default: 3)") + init.add_argument("--max-items-per-round", type=int, default=None, + help="1 つの提案ラウンド/テスト整備ラウンドの採用上限 (default: 5)") init.add_argument("--ci-check", default=None, metavar="NAME", help="最終ゲートで手元のテストの代わりに見る検査の名前。" "**指定すると手元のテストは実行しない**(排他)。" "指定が無ければ手元のテストで判定する") - init.add_argument("--severity-threshold", default=DEFAULT_SEVERITY_THRESHOLD, - choices=[s for s in SEVERITY_ORDER if s != "unknown"]) + init.add_argument("--severity-threshold", default=None, + choices=[s for s in SEVERITY_ORDER if s != "unknown"], + help=f"この重要度未満は採用しない (default: {DEFAULT_SEVERITY_THRESHOLD})") init.add_argument("--model", action="append", metavar="RUNTIME=MODEL", help="ランタイムごとのモデル指定。繰り返し指定できる") - init.add_argument("--test-timeout", type=int, default=DEFAULT_TEST_TIMEOUT, + init.add_argument("--test-timeout", type=int, default=None, help="テスト 1 回あたりの上限秒数。超えたら失敗として扱う " f"(default: {DEFAULT_TEST_TIMEOUT})") init.add_argument("--sync-command", default=None, @@ -147,7 +167,7 @@ def main() -> None: "振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須") # **起動のされ方は引数で受け取る**(#436 決定 7)。環境変数や控えの読み取りは、 # 起動元が違っても同じ値になりうる。呼ぶ側が明示すれば判定が 1 か所で済む。 - init.add_argument("--workflow-step", action="store_true", + init.add_argument("--workflow-step", action="store_true", default=None, help="`development-workflow` の 1 工程として起動したことを" "伝える。Step 7 の `cross-review` を省き、" "全体のテストで判定する") @@ -156,7 +176,7 @@ def main() -> None: for name, func, help_ in ( ("start-round", cmd_start_round, - "Step 2 — 提案ラウンドを開く。実装担当とレビュー担当を返す"), + "Step 2 — 提案ラウンドを開く。実装担当を返す"), ("merge-proposals", cmd_merge_proposals, "Step 3 — 提案の語彙検証・重複排除・優先度付け・採否"), ("advance", cmd_advance, "ラウンドの収束判定と、ラウンドの種類の切り替え"), diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index e89feec9..c3bd6cea 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -568,7 +568,7 @@ def _switch_apply_impl(state: dict[str, Any], group: dict[str, Any]) -> bool: } tried.add(group.get("impl")) seq = safe_int(state.get("apply_seq")) - for _ in range(len(state.get("impl_capable") or []) or 4): + for _ in range(len(state.get("runtimes") or [])): seq += 1 impl, requested = impl_for_seq(state, seq) if impl in tried: @@ -649,7 +649,7 @@ def _load_apply_context( ctx: _ApplyExecutionContext, payload: dict[str, Any], ) -> tuple[dict[str, Any], _ApplyCommitRange]: impl = ctx.group.get("impl") or ctx.entry["impl"] - record_observed_model(ctx.entry, "impl", impl, ctx.state, "apply", ctx.args.round) + record_observed_model(ctx.entry, impl, ctx.state, "apply", ctx.args.round) # 検証の材料は git から取る。結果ファイルから使うのは # 「どのコミットがこの群のものか」という対応付けだけ。 diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py index 955bfea4..c6dd584c 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py @@ -104,8 +104,8 @@ def cmd_status(args: argparse.Namespace) -> None: _, state = load_state(args.id) print(f"# cross-refactoring rf{state['id']}({state['repo']} #{state['current_pr']})") print(f"ホスト: {state['host']}({state['host_detection']})") - print(f"提案・レビュー: {' / '.join(state['runtimes'])}") - print(f"適用の母集合: {' / '.join(state['impl_capable'])}") + # **母集合は 1 つである**(#727 の決定 5)。提案と適用は同じ参加者で回す。 + print(f"参加者(提案と適用): {' / '.join(state['runtimes'])}") print(f"局面: {state['phase']} / 提案ラウンド {state['outer_round']} " f"/ {state['max_outer_rounds']}") print(f"終了理由: {state.get('final') or '(未終了)'}") @@ -125,6 +125,8 @@ def cmd_report(args: argparse.Namespace) -> None: print("## 改善項目") print() print(_item_table(state)) + print() + _print_participants(state) # **取り消した項目の内訳は書かない**(#436 決定 6-b)。件数だけ述べ、内訳は # 改修計画へ譲る。同じ一覧を 2 か所に置くと、片方だけが古くなる。 print() @@ -158,6 +160,54 @@ def _print_header(state: dict[str, Any]) -> None: f" / 修正 {gate.get('fix_rounds', 0)} 回)") +def _print_participants(state: dict[str, Any]) -> None: + """「参加した者」の節を出す(#727 の F6)。 + + 途中から誰を外したか・誰が確認を通らなかったかを、完了報告だけで読めるように + する。参加者の記録を持たない状態ファイル(この変更の前に始めた実行)では + 「記録なし」と出す。 + """ + print("## 参加した者") + print() + p = state.get("participants") + if not p: + print("- 使える者: 記録なし") + print() + return + + def _names(values: Any) -> str: + return " / ".join(values) if values else "なし" + + unavailable = p.get("unavailable") or {} + if unavailable: + failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) + elif p.get("probe_skipped"): + failed = "確認を飛ばした(NDF_SKIP_AUTH_CHECK)" + else: + failed = "なし" + print(f"- 母集合: {_names(p.get('pool'))}") + print(f"- 使える者: {_names(p.get('available'))}") + print(f"- --exclude で外した者: {_names(p.get('excluded'))}") + print(f"- --include で足した者: {_names(p.get('included'))}") + print(f"- 確認を通らなかった者: {failed}") + changes = state.get("resume_changes") or [] + if not changes: + print("- 再開で変えた値: なし") + else: + print("- 再開で変えた値:") + for c in changes: + print(f" - {c.get('at')} {c.get('field')}: " + f"{_change_value(c.get('from'))} → {_change_value(c.get('to'))}") + print() + + +def _change_value(value: Any) -> str: + """再開で変えた値の 1 つを 1 行へ収める。参加者の記録は使える者だけを出す。""" + if isinstance(value, dict) and "available" in value: + return " / ".join(value.get("available") or []) or "なし" + return str(value) + + def _print_deferred(state: dict[str, Any]) -> None: """見送り節(件数と改修計画への参照)を出す。""" print("## 見送った提案") @@ -180,29 +230,22 @@ def _print_run_metrics(path: pathlib.Path, state: dict[str, Any]) -> None: def _round_table(state: dict[str, Any]) -> str: + """ラウンド表。**レビュー担当の列を持たない**(#727 の決定 6)。 + + レビュー工程は #436 で消えた。古い状態ファイルがレビュー担当を持っていても出さない。 + """ lines = [ - "| R | 種類 | 実装担当 | モデル | レビュー担当 | モデル | 採用 | 適用 | 見送り | 修正 | 初回承認 |", - "| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | --- |", + "| R | 種類 | 実装担当 | モデル | 採用 | 適用 | 見送り | 修正 |", + "| --- | --- | --- | --- | ---: | ---: | ---: | ---: |", ] for entry in state["rounds"]: - reviewers = entry.get("reviewers", []) - reviewer_models = entry.get("reviewer_models") or {} - reviews = entry.get("reviews") or [] - first_approved = "—" - if reviews: - first_approved = ( - "はい" if all(reviews[0].get(r) == "APPROVE" for r in reviewers) else "いいえ" - ) lines.append( f"| {entry['round']} | " f"{'テスト整備' if entry_kind(entry) == TEST else '構造改善'} | " f"{entry.get('impl', '—')} | " f"{models_lib.label((entry.get('impl_model') or {}).get('requested'))} | " - f"{' / '.join(reviewers) or '—'} | " - f"{' / '.join(models_lib.label((reviewer_models.get(r) or {}).get('requested')) for r in reviewers) or '—'} | " f"{entry.get('adopted', 0)} | {len(entry.get('apply', {}).get('applied', []))} | " - f"{len(entry.get('apply', {}).get('failed', []))} | {entry.get('fix_rounds', 0)} | " - f"{first_approved} |" + f"{len(entry.get('apply', {}).get('failed', []))} | {entry.get('fix_rounds', 0)} |" ) return "\n".join(lines) if state["rounds"] else "(ラウンドなし)" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index 011ec37a..6857f63f 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -1,7 +1,7 @@ """ラウンドの入口。`init` と `start-round` を持つ。 -対象の Pull Request の文脈・参加する CLI の認証・作業ツリーの用意・状態ファイルの -初期化と、提案ラウンドの開始を扱う。 +対象の Pull Request の文脈・参加者の決定・作業ツリーの用意・状態ファイルの +初期化と再開と、提案ラウンドの開始を扱う。 """ from __future__ import annotations @@ -31,9 +31,18 @@ tmp_dir_for, ) from ..plan import PLAN_COMMENT, PLAN_FILE, PLAN_NONE, normalize_plan_file -from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, round_kind +from ..rounds import ( + STRUCTURE, + TEST, + entry_kind, + finish_outer_rounds, + impl_for_seq, + round_kind, +) from ..scope import require_scope_covers_tests from ..vocabulary import ( + DEFAULT_MAX_TEST_ROUNDS, + DEFAULT_SEVERITY_THRESHOLD, DEFAULT_TEST_TIMEOUT, IMPL_STALL_MARGIN, REQUIRED_SKILLS, @@ -42,14 +51,103 @@ ) -def check_auth(runtimes: Iterable[str]) -> dict[str, dict[str, Any]]: - """参加する CLI の認証状態を確かめる。1 つでも欠けたら初期化を中断する。 +# 再開で指定を外す予約語(#727 の決定 15)。足す者・外す者に渡すと一覧を空へ戻す。 +NONE_WORD = "none" + +# 新規の初期化で、未指定の引数を置き換える現行の既定。**引数の既定は `None` にする。** +# 既定値を引数に持たせると、再開で「渡さなかった」と「既定値を渡した」を区別できない +# (#727 の決定 13)。 +NEW_RUN_DEFAULTS: dict[str, Any] = { + "max_outer_rounds": 3, + "max_test_rounds": DEFAULT_MAX_TEST_ROUNDS, + "max_fix_rounds": 3, + "max_items_per_round": 5, + "test_timeout": DEFAULT_TEST_TIMEOUT, + "severity_threshold": DEFAULT_SEVERITY_THRESHOLD, + "workflow_step": False, +} + +# 再開で渡した引数の反映の表(#727 の決定 13)。**状態ファイルに載る引数は、この 2 つの +# 表のどちらかに必ず載る。** `replace` は状態へ書いて記録へ積み、`notify` は状態と違う +# ときだけ「反映しない」と知らせる。 +RESUME_REPLACE_FIELDS = tuple( + statefile.ResumeField(key, key, "replace") + for key in ("max_outer_rounds", "max_test_rounds", "max_fix_rounds", + "max_items_per_round", "test_timeout") +) +RESUME_NOTIFY_FIELDS = ( + statefile.ResumeField("host", "host", "notify"), + statefile.ResumeField("scope", "target_scope", "notify"), + statefile.ResumeField("model", "models", "notify"), + statefile.ResumeField("baseline_test", "baseline_test", "notify"), + statefile.ResumeField("ci_check", "ci_check", "notify"), + statefile.ResumeField("severity_threshold", "severity_threshold", "notify"), + statefile.ResumeField("sync_command", "sync_command", "notify"), + statefile.ResumeField("plan_file", "plan_file", "notify"), + statefile.ResumeField("workflow_step", "workflow_step", "notify"), + statefile.ResumeField("worktree_root", "worktree_root", "notify"), +) + + +def runtime_list(value: str) -> list[str]: + """`--exclude` / `--include` の型。カンマ区切りの 4 つの名前、または `none`。""" + names = [n.strip() for n in value.split(",") if n.strip()] + if not names: + raise argparse.ArgumentTypeError("名前を 1 つ以上指定してください") + for name in names: + if name != NONE_WORD and name not in assignment.ALL_RUNTIMES: + raise argparse.ArgumentTypeError( + f"{'/'.join(assignment.ALL_RUNTIMES)} か {NONE_WORD} を指定してください: {name}") + return names - 実装は共通層(`lib/auth.py`)にある。**この工程の中断は終了コード 4 である**ため、 - 出力と中断の手段をここから渡す。 + +def _names_arg(args: argparse.Namespace, option: str) -> Optional[list[str]]: + """`--include` / `--exclude` を平らな一覧へ直す。未指定は `None`、`none` は空。 + + `action="append"` の入れ子を平らにし、`--exclude agy --exclude kiro` と + `--exclude agy,kiro` を同じにする。`none` と名前の混在は中断する。 """ - return auth.check_auth(runtimes, info=info, die=die) + raw = getattr(args, option, None) + if raw is None: + return None + names: list[str] = [] + for group in raw: + names.extend(group if isinstance(group, list) else [group]) + if NONE_WORD in names: + if len(names) > 1: + die(f"--{option} に {NONE_WORD} と名前を同時に指定できません: {', '.join(names)}") + return [] + return names + + +def resolve_participants( + host: str, include: list[str], exclude: list[str], require_all: bool, +) -> dict[str, Any]: + """参加者を決め、状態ファイルの `participants` を返す(#727 の決定 2〜5)。 + 母集合の既定は `refactor_pool(host)`(codex / kiro とホスト)。確認は止めない確認 + (`auth.probe_auth`)で、通らない者は外して続ける。名前の矛盾・全員を要する指定で + 欠け・使える者が 0 者は、この工程の中断(終了コード 4)へ写す。状態ファイルは + この関数の後に書かれるため、失敗したときは作られも書き換えられもしない。 + """ + try: + pool = assignment.refactor_pool(host) + resolved = assignment.resolve_participants( + pool, host=host, include=include, exclude=exclude, + probe=lambda names: auth.probe_auth(names, info=info), + require_all=require_all, + ) + except assignment.AssignmentError as e: + die(str(e)) + raise + info(f"ホスト: {host} / 母集合: {' / '.join(pool)}" + f" / 使える者: {' / '.join(resolved.available) or 'なし'}") + for name, reason in resolved.unavailable.items(): + info(f"⚠ {name} を担当から外しました({reason})") + if not resolved.available: + die(f"使える者がいません: 参加者の全員が確認を通りませんでした" + f"({' / '.join(f'{n}: {d}' for n, d in resolved.unavailable.items())})") + return resolved.to_state() def _apply_post_event(state: dict[str, Any], is_own_pr: bool) -> None: @@ -177,10 +275,8 @@ class InitialContext: tmp_dir: pathlib.Path host: str detection: str - runtimes: list[str] - impl_capable: list[str] + participants: dict[str, Any] model_spec: dict[str, Optional[str]] - auth: dict[str, dict[str, Any]] baseline: dict[str, Any] @@ -194,6 +290,7 @@ def _build_initial_state( (`cmd_init`)が済ませたうえで値として渡す。この関数が持つのは、状態ファイルに 何という鍵で何を残すかだけである。 """ + runtimes = list(ctx.participants["available"]) return { "id": args.pr, "started_at": statefile.now(), @@ -204,16 +301,17 @@ def _build_initial_state( "worktree_root": str(ctx.root), "worktrees": { "work": str(ctx.work), - **{r: str(ctx.root / r) for r in ctx.runtimes}, + **{r: str(ctx.root / r) for r in runtimes}, }, "tmp_dir": str(ctx.tmp_dir), "target_scope": list(args.scope), "host": ctx.host, "host_detection": ctx.detection, - "runtimes": ctx.runtimes, - "impl_capable": ctx.impl_capable, + # **提案の対象と適用の輪番が同じ一覧を読む**(#727 の決定 5)。使える者と同じ値。 + "runtimes": runtimes, + "participants": ctx.participants, + "resume_changes": [], "models": ctx.model_spec, - "auth": ctx.auth, # 提案プロンプトへ許容値をそのまま列挙するために持たせる。 # 定義は検証側(この CLI)にあり、状態ファイル経由で起動側へ渡す。 "vocabulary": vocabulary(), @@ -254,10 +352,11 @@ def _build_initial_state( def cmd_init(args: argparse.Namespace) -> None: - """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 + """Step 0 — ホストと参加者を確定し、作業ディレクトリ root と状態を用意する。 - **提案・レビューの母集合(全 − ホスト)と適用の母集合(全 − agy)を - 別々に確定する。** 両者は重なるが一致しない。 + **母集合は 1 つである**(#727 の決定 5)。提案と適用は同じ参加者で回す。参加者は + codex / kiro とホストを既定とし、足す者・外す者で変える。確認を通らない者は外して + 続ける。前回の状態が残っていれば再開し、渡した引数を反映の表に従って扱う。 """ try: host, detection = assignment.detect_host(args.host) @@ -269,16 +368,8 @@ def cmd_init(args: argparse.Namespace) -> None: except models_lib.ModelSpecError as e: die(str(e)) return - - runtimes = assignment.review_pool(host) - impl_capable = assignment.impl_pool() - if host in runtimes: - die(f"提案・レビューの母集合にホスト {host} が含まれています(判定の誤り)") - _warn_unmeasurable_models(model_spec, set(runtimes) | set(impl_capable)) - - # **認証は作業ディレクトリを作る前に確かめる。** 未認証のまま進むと、 - # 参加者が欠けた構成のまま最後まで走り切ってしまう。 - auth = check_auth(sorted(set(runtimes) | set(impl_capable))) + include = _names_arg(args, "include") + exclude = _names_arg(args, "exclude") # リポジトリ名は git の設定から求め、Pull Request の応答で確かめる(#271)。 repo, base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) @@ -306,12 +397,19 @@ def cmd_init(args: argparse.Namespace) -> None: if state_file.exists(): state = statefile.load(state_file) if state.get("final") is None: - info(f"↻ 前回中断した状態から再開します(提案ラウンド {state.get('outer_round', 0)})") - _apply_post_event(state, is_own_pr) - statefile.save(state_file, state) - _emit_init(state) + _resume(state_file, state, args, model_spec, include, exclude, is_own_pr) return + for key, value in NEW_RUN_DEFAULTS.items(): + if getattr(args, key, None) is None: + setattr(args, key, value) + + # **確認は着手前のテストより先に行う。** 使える者がいなければ、テストに時間を + # 使わずに止める。 + participants = resolve_participants( + host, include or [], exclude or [], bool(getattr(args, "require_all", None))) + _warn_unmeasurable_models(model_spec, participants["available"]) + baseline = _run_baseline_test(args.baseline_test, work, args.test_timeout) context = InitialContext( @@ -323,10 +421,8 @@ def cmd_init(args: argparse.Namespace) -> None: tmp_dir=tmp_dir, host=host, detection=detection, - runtimes=runtimes, - impl_capable=impl_capable, + participants=participants, model_spec=model_spec, - auth=auth, baseline=baseline, ) state = _build_initial_state(args, context) @@ -337,11 +433,81 @@ def cmd_init(args: argparse.Namespace) -> None: statefile.save(state_file, state) info(f"✅ 状態を初期化しました: {state_file}") info(f" ホスト: {host}({detection})") - info(f" 提案・レビュー: {' / '.join(runtimes)}") - info(f" 適用の母集合: {' / '.join(impl_capable)}") + info(f" 参加者(提案と適用): {' / '.join(state['runtimes'])}") + _emit_init(state) + + +def _resume( + state_file: pathlib.Path, + state: dict[str, Any], + args: argparse.Namespace, + model_spec: dict[str, Optional[str]], + include: Optional[list[str]], + exclude: Optional[list[str]], + is_own_pr: bool, +) -> None: + """前回中断した状態から再開する(#727 / #648 の決定 13〜16)。 + + 上限は渡せば反映し、状態に載る他の引数は状態と違えば知らせる。足す者・外す者・ + 全員を要する指定のどれかを渡したときだけ確かめ直し、**渡さなかった値は記録から + 補う**。作り直しは `resume_changes` に 1 件として積む。作り直しが失敗したときは + 書き込みの前に中断するため、状態ファイルは変わらない。 + """ + info(f"↻ 前回中断した状態から再開します(提案ラウンド {state.get('outer_round', 0)})") + for line in statefile.apply_resume_args(state, args, RESUME_REPLACE_FIELDS): + info(line) + view, given = _notify_view(state, args, model_spec) + for line in statefile.apply_resume_args(view, given, RESUME_NOTIFY_FIELDS): + info(line) + + require_all = getattr(args, "require_all", None) + if include is not None or exclude is not None or require_all is not None: + recorded = state.get("participants") or {} + participants = resolve_participants( + str(state["host"]), + include if include is not None else list(recorded.get("included") or []), + exclude if exclude is not None else list(recorded.get("excluded") or []), + bool(require_all) if require_all is not None else bool(recorded.get("require_all")), + ) + state.setdefault("resume_changes", []).append({ + "at": statefile.now(), "field": "participants", + "from": state.get("participants"), "to": participants, + }) + state["participants"] = participants + state["runtimes"] = list(participants["available"]) + worktrees = state.setdefault("worktrees", {}) + for runtime in state["runtimes"]: + worktrees.setdefault(runtime, str(pathlib.Path(state["worktree_root"]) / runtime)) + + _apply_post_event(state, is_own_pr) + statefile.save(state_file, state) _emit_init(state) +def _notify_view( + state: dict[str, Any], args: argparse.Namespace, + model_spec: dict[str, Optional[str]], +) -> tuple[dict[str, Any], argparse.Namespace]: + """「知らせる」の比較を、状態と引数の形を揃えて行うための写しを返す。 + + 状態は着手前のテストを `{command, status, checked_at}` で、モデルを全ランタイムの + 辞書で、作業ディレクトリ root を解決済みのパスで持つ。引数の形のまま比べると、 + 同じ値でも「違う」と知らせてしまう。 + """ + view = dict(state) + view["baseline_test"] = (state.get("baseline_test") or {}).get("command") + given = argparse.Namespace(**{f.arg: getattr(args, f.arg, None) for f in RESUME_NOTIFY_FIELDS}) + if given.model is not None: + given.model = model_spec + if given.worktree_root is not None: + given.worktree_root = str(pathlib.Path(given.worktree_root).resolve()) + if given.plan_file is not None: + given.plan_file = normalize_plan_file(given.plan_file) + if given.scope is not None: + given.scope = list(given.scope) + return view, given + + def _emit_init(state: dict[str, Any]) -> None: statefile.emit( ID=state["id"], @@ -349,7 +515,6 @@ def _emit_init(state: dict[str, Any]) -> None: HOST=state["host"], RUNTIMES=" ".join(state["runtimes"]), RUNTIMES_CSV=",".join(state["runtimes"]), - IMPL_POOL=" ".join(state["impl_capable"]), WORKTREE_ROOT=state["worktree_root"], WORK=state["worktrees"]["work"], TMP_DIR=state["tmp_dir"], @@ -466,7 +631,10 @@ def rounds_of_kind(state: dict[str, Any], kind: str) -> list[dict[str, Any]]: def cmd_start_round(args: argparse.Namespace) -> None: - """Step 2 — ラウンドを開き、実装担当とレビュー担当を返す。 + """Step 2 — ラウンドを開き、実装担当を返す。 + + **レビュー担当は返さない**(#727 の決定 6)。レビュー工程は #436 で消え、Step 7 の + cross-review が担う。 終了コード: 0 = ラウンドを開いた / 1 = 繰り返しが終了済み。 @@ -491,8 +659,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: round_no = len(rounds) + 1 existing = next((r for r in rounds if r["round"] == round_no), None) if existing is None: - impl, reviewers = assignment.assign(round_no, state["host"]) - models = state["models"] + impl, requested = impl_for_seq(state, round_no) existing = { "round": round_no, # **種類はラウンドごとに残す。** 上限を別々に数えるためと、提案の @@ -500,11 +667,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: "kind": kind, "started_at": statefile.now(), "impl": impl, - "impl_model": {"requested": models.get(impl), "observed": None}, - "reviewers": reviewers, - "reviewer_models": { - r: {"requested": models.get(r), "observed": None} for r in reviewers - }, + "impl_model": {"requested": requested, "observed": None}, "proposed": {}, "merged": 0, "adopted": 0, "deferred": 0, "items": [], @@ -528,7 +691,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: seq = len(rounds_of_kind(state, kind)) info( f"=== {label} {seq} / {limit} " - f"(実装 {existing['impl']} / レビュー {' + '.join(existing['reviewers'])})===" + f"(実装 {existing['impl']})===" ) statefile.emit( ROUND=round_no, @@ -543,7 +706,5 @@ def cmd_start_round(args: argparse.Namespace) -> None: PROPOSE_PHASE="propose-tests" if kind == TEST else "propose", IMPL=existing["impl"], IMPL_MODEL=existing["impl_model"]["requested"], - REVIEWERS=" ".join(existing["reviewers"]), - REVIEWERS_CSV=",".join(existing["reviewers"]), MAX_FIX_ROUNDS=state["max_fix_rounds"], ) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index 740b49f0..f4b19107 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -1131,10 +1131,10 @@ def read_result( def record_observed_model( - entry: dict[str, Any], role: str, runtime: str, + entry: dict[str, Any], runtime: str, state: dict[str, Any], phase: str, round_no: Optional[int], ) -> None: - """CLI の出力から実際に使われたモデル名を拾って記録する。 + """実装担当の CLI の出力から、実際に使われたモデル名を拾って記録する。 取れるのは claude だけである。取れないランタイムは `None` のままにし、 報告では既定モデルのラウンドとして集計から区別する。 @@ -1148,13 +1148,8 @@ def record_observed_model( ) if not observed: return - if role == "impl": - entry["impl_model"]["observed"] = observed - requested = entry["impl_model"]["requested"] - else: - entry["reviewer_models"].setdefault(runtime, {"requested": None, "observed": None}) - entry["reviewer_models"][runtime]["observed"] = observed - requested = entry["reviewer_models"][runtime]["requested"] + entry["impl_model"]["observed"] = observed + requested = entry["impl_model"]["requested"] warning = models_lib.mismatch_warning(runtime, requested, observed) if warning: info(warning) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py index 8b68979b..3fbaf69d 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py @@ -199,10 +199,8 @@ def _plan_deferred_section(state: dict[str, Any]) -> list[str]: def _plan_round_section(state: dict[str, Any], entry: dict[str, Any]) -> list[str]: """1 ラウンド分の見出しと、そのラウンドの改善項目を並べる。""" - reviewers = " / ".join(entry.get("reviewers") or []) or "—" lines = [ - f"## ラウンド {entry['round']}" - f"(実装 {entry.get('impl', '—')} / レビュー {reviewers})", + f"## ラウンド {entry['round']}(実装 {entry.get('impl', '—')})", "", ] items = [i for i in state.get("items") or [] if i.get("round") == entry["round"]] diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py index fa17d6ef..0b4c825c 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py @@ -173,11 +173,12 @@ def group_reopening(group: dict[str, Any]) -> str: def impl_for_seq(state: dict[str, Any], seq: int) -> tuple[str, Optional[str]]: """輪番の通し番号から、作業を任せる担当と要求するモデルを引く。 - **輪番を引く呼び出しはここだけにする**(#728 の決定 9)。参加者の決め方が - 変わったとき(#727)に、変える場所がこの中だけで済む。読むのは 3 か所 - (群の割り当て・結果なしの試行の交代先・最終ゲートの修正担当)である。 + **輪番を引く呼び出しはここだけにする**(#728 の決定 9)。読むのは 4 か所 + (ラウンドの開始・群の割り当て・結果なしの試行の交代先・最終ゲートの修正担当) + である。輪番は参加者の一覧(`runtimes`)の中で回す(#727 の決定 5・7)。この + 変更の前に始めた実行の状態ファイルも、適用専用の母集合を読まずに同じ一覧で決める。 """ - impl, _reviewers = assignment.assign(seq, state["host"]) + impl = assignment.impl_assign(seq, list(state["runtimes"])) return impl, (state.get("models") or {}).get(impl) def phase_after_group(entry: dict[str, Any]) -> str: diff --git a/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py b/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py index 9914d55a..b6d24994 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py +++ b/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py @@ -36,7 +36,6 @@ def make_state(tmp_path: pathlib.Path, **overrides: Any) -> pathlib.Path: "host": host, "host_detection": "explicit", "runtimes": runtimes, - "impl_capable": ["claude", "codex", "kiro"], "models": {"claude": None, "codex": None, "agy": None, "kiro": None}, "skills": {"required": ["refactoring", "tdd-cycle", "quality-gates"]}, "max_outer_rounds": 3, diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py index 0afd3505..c1da7699 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py @@ -344,12 +344,14 @@ def test_the_group_assignment_goes_through_the_single_rotation_function( rounds, monkeypatch ): """AC49: 輪番から担当を引く関数を差し替えると、群の担当がそれに従う。""" - state = {"host": "claude", "models": {"kiro": "auto"}} + state = {"host": "claude", "runtimes": ["claude", "codex", "kiro"], + "models": {"kiro": "auto"}} - impl, requested = rounds.impl_for_seq(state, 3) + impl, requested = rounds.impl_for_seq(state, 2) - assert impl in {"claude", "codex", "agy", "kiro"} - assert requested == state["models"].get(impl) + # 輪番は参加者の一覧の中で回る(#727 の決定 7: `runtimes[seq % n]`) + assert impl == "kiro" + assert requested == "auto" # ---------- 修正結果が無く範囲も確定できないとき(converge.cmd_merge_fix / R1-003) ---------- diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py b/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py index 134de695..12f6475f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py @@ -1,8 +1,7 @@ -"""担当の決定(ホスト判定 / 母集合 / 輪番)のテスト。 +"""担当の決定(ホスト判定 / 母集合の既定 / 輪番 / 席)のテスト。 -**`runtimes` と `impl_capable` を同一視しない**ことがここの主題である。 -前者はホストを除いた 3 者(提案・レビュー)、後者は参加する 4 者すべて(適用)で、 -重なるが一致しない。 +cross-refactoring は提案と適用を 1 つの参加者の一覧で回し(#727 の決定 5)、 +cross-review はホストを除く母集合から 2 席を決める。母集合の既定は Skill ごとに違う。 """ from __future__ import annotations @@ -61,10 +60,15 @@ def test_review_pool_is_all_minus_host(assignment, host): assert set(pool) == set(assignment.ALL_RUNTIMES) - {host} -@pytest.mark.parametrize("host", HOSTS) -def test_impl_pool_is_host_independent(assignment, host): - """適用の母集合はホストによらず参加する 4 者すべてになる。""" - assert assignment.impl_pool() == ["claude", "codex", "agy", "kiro"] +@pytest.mark.parametrize("host, expected", [ + ("claude", ["claude", "codex", "kiro"]), + ("codex", ["codex", "kiro"]), + ("agy", ["codex", "agy", "kiro"]), + ("kiro", ["codex", "kiro"]), +]) +def test_refactor_pool_is_codex_kiro_and_the_host(assignment, host, expected): + """AC31 / AC32 — cross-refactoring の既定はホストを含む。並びは固定の順。""" + assert assignment.refactor_pool(host) == expected def test_no_runtime_is_excluded_from_applying(assignment): @@ -75,88 +79,42 @@ def test_no_runtime_is_excluded_from_applying(assignment): assert not hasattr(assignment, "IMPL_EXCLUDED") -# ---------- 輪番 ---------- +# ---------- 適用の輪番(cross-refactoring) ---------- @pytest.mark.parametrize("host", HOSTS) -def test_impl_and_reviewers_never_overlap(assignment, host): - for round_no in range(1, 17): - impl, reviewers = assignment.assign(round_no, host) - assert impl not in reviewers, f"round {round_no} で実装担当がレビューにも入っている" - assert len(reviewers) == 2, f"round {round_no} のレビュー担当が 2 者でない" - assert host not in reviewers, f"round {round_no} でホストがレビューに入っている" +def test_every_participant_implements_within_one_cycle(assignment, host): + """参加者の数のラウンドで、参加者が 1 度ずつ適用担当になる(#727 の決定 7)。""" + participants = assignment.refactor_pool(host) + impls = [assignment.impl_assign(r, participants) for r in range(1, len(participants) + 1)] + assert sorted(impls) == sorted(participants) @pytest.mark.parametrize("host", HOSTS) -def test_every_participant_implements_within_four_rounds(assignment, host): - """4 ラウンドで 4 者が 1 度ずつ適用担当になる(`--max-outer-rounds` の既定と揃う)。""" - impls = [assignment.assign(r, host)[0] for r in range(1, 5)] - assert set(impls) == set(assignment.ALL_RUNTIMES) - assert len(set(impls)) == len(impls), f"同じ担当が 2 度入っている: {impls}" - - -@pytest.mark.parametrize("host", HOSTS) -def test_host_takes_impl_turn_at_least_once(assignment, host): - """ホストは適用にだけ参加する。4 ラウンド回れば必ず 1 度は担当する。""" - impls = {assignment.assign(r, host)[0] for r in range(1, 5)} - assert host in impls - - -def test_reviewers_narrow_to_two_when_impl_is_host(rounds, assignment): - """実装担当がホストと同じラウンドでも、レビュー担当は 3 者にならず 2 者になる。""" - host = "claude" - rounds = [r for r in range(1, 13) if assignment.assign(r, host)[0] == host] - assert rounds, "ホストが実装担当になるラウンドが無い" - for round_no in rounds: - _, reviewers = assignment.assign(round_no, host) - assert len(reviewers) == 2 - - -def test_excluded_reviewer_rotates_across_rounds(assignment): - """余る 1 者はラウンドを跨いで順に外れ、負荷が偏らないこと。""" - host = "claude" - pool = set(assignment.review_pool(host)) - excluded = [] - for round_no in range(1, 13): - impl, reviewers = assignment.assign(round_no, host) - if impl != host: - continue - excluded.append((pool - {impl} - set(reviewers)).pop()) - assert len(set(excluded)) > 1, f"常に同じ 1 者だけが外れている: {excluded}" +def test_the_host_does_not_implement_first(assignment, host): + """ラウンド 1 は参加者の 2 番目から始まる。ホストが最初に適用する形にならない。""" + participants = assignment.refactor_pool(host) + if participants[0] == host: + assert assignment.impl_assign(1, participants) != host @pytest.mark.parametrize("host", HOSTS) def test_assignment_is_deterministic(assignment, host): """再開しても担当が変わらないこと(同じ入力なら同じ結果)。""" + participants = assignment.refactor_pool(host) for round_no in range(1, 13): - assert assignment.assign(round_no, host) == assignment.assign(round_no, host) + assert assignment.impl_assign(round_no, participants) == \ + assignment.impl_assign(round_no, participants) def test_round_number_must_be_positive(assignment): with pytest.raises(assignment.AssignmentError): - assignment.assign(0, "claude") + assignment.impl_assign(0, ["claude", "codex", "kiro"]) -# ---------- 割り当てを直に固定する(#214 / #216) ---------- +# ---------- 固定の順(#214) ---------- # `gemini` があった位置へ `agy` を入れた(#214)。並べ替えると同じラウンド番号でも # 担当が変わり、これまでの記録と突き合わせられなくなる。 -# 適用の母集合を 4 者にしたため(#216)、ラウンド 2 以降の担当が 1 つずつずれる。 -# 適用担当は 4 ラウンドで 1 周し、レビュー担当は適用担当がホストと重なるラウンドで -# 1 者を落とすため 12 ラウンドで 1 周する。読み替えた結果を直に置く。 -EXPECTED_FOR_CLAUDE = { - 1: ("codex", ["agy", "kiro"]), - 2: ("agy", ["codex", "kiro"]), - 3: ("kiro", ["codex", "agy"]), - 4: ("claude", ["codex", "kiro"]), - 5: ("codex", ["agy", "kiro"]), - 6: ("agy", ["codex", "kiro"]), - 7: ("kiro", ["codex", "agy"]), - 8: ("claude", ["codex", "agy"]), - 9: ("codex", ["agy", "kiro"]), - 10: ("agy", ["codex", "kiro"]), - 11: ("kiro", ["codex", "agy"]), - 12: ("claude", ["agy", "kiro"]), -} def test_the_participant_list_keeps_the_replaced_position(assignment): @@ -168,57 +126,24 @@ def test_host_runtimes_covers_every_participant(assignment): assert assignment.HOST_RUNTIMES == assignment.ALL_RUNTIMES -@pytest.mark.parametrize("round_no", sorted(EXPECTED_FOR_CLAUDE)) -def test_the_rotation_matches_the_renamed_result(assignment, round_no): - assert assignment.assign(round_no, "claude") == EXPECTED_FOR_CLAUDE[round_no] - - -# ---------- レビューだけの輪番(cross-review が使う) ---------- - -def test_review_assign_excludes_the_host(assignment): - """母集合はホストを除く 3 者で、返るのは常に 2 者である。""" - for host in assignment.HOST_RUNTIMES: - for round_no in range(1, 10): - picked = assignment.review_assign(round_no, host) - assert len(picked) == 2 - assert host not in picked - assert set(picked) <= set(assignment.review_pool(host)) - - -def test_review_assign_rotates_the_excluded_one(assignment): - """外す 1 者はラウンドごとに回り、3 ラウンドで 1 周する。""" - host = "claude" - pool = assignment.review_pool(host) - dropped = [ - set(pool) - set(assignment.review_assign(r, host)) for r in (1, 2, 3) - ] - assert [next(iter(d)) for d in dropped] == pool - # 4 ラウンド目は 1 ラウンド目と同じ担当へ戻る - assert assignment.review_assign(4, host) == assignment.review_assign(1, host) - - -def test_review_assign_rejects_a_bad_round(assignment): - with pytest.raises(assignment.AssignmentError): - assignment.review_assign(0, "claude") - +# ---------- 席の埋め方と席の名前(#727。cross-review が使う) ---------- -def test_review_assign_rejects_an_unknown_host(assignment): - with pytest.raises(assignment.AssignmentError): - assignment.review_assign(1, "gemini") +def _previous_review_rotation(round_no: int, pool: list[str]) -> list[str]: + """席の埋め方より前の輪番。3 者の母集合から `(round_no - 1) % 3` の者を外した 2 者。 + 関数は消えたため、式を期待値として持つ(AC8 の主張を保つ)。 + """ + dropped = (round_no - 1) % len(pool) + return [r for i, r in enumerate(pool) if i != dropped] -# ---------- 席の埋め方と席の名前(#727。cross-review が使う) ---------- -# -# 変更前の席の割り当て(`review_assign`)を期待値に使えるよう、同じファイルに置く。 -# `review_assign` のテストは P7 で消す。 -def test_review_seats_match_review_assign_for_three_available(assignment): +def test_review_seats_match_the_previous_rotation_for_three_available(assignment): """AC8: 使える者が 3 者のとき、変更前の輪番と同じ値になる(4 ホスト × ラウンド 1〜12)。""" for host in assignment.HOST_RUNTIMES: pool = assignment.review_pool(host) for round_no in range(1, 13): assert assignment.review_seats(round_no, pool, []) == \ - assignment.review_assign(round_no, host), f"host={host} round={round_no}" + _previous_review_rotation(round_no, pool), f"host={host} round={round_no}" def test_review_seats_with_four_available_give_each_two_turns(assignment): diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index adc7e054..a63d844e 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -77,8 +77,10 @@ def run_init(refactor_lib, paths, patch_lib, refactor, origin_repo, monkeypatch) 常に `author` なので、両者を一致させると自分の Pull Request になる。 """ refactor_lib = sys.modules["refactor_lib"] + probed: list[list[str]] = [] - def _run(args, viewer="someone-else"): + def _run(args, viewer="someone-else", probe=None): + """`probe` を渡すと確認を差し替える。`{ランタイム: 理由}` の者だけが通らない。""" real_sh = paths.sh def fake_sh(cmd, cwd=None, check=True): @@ -108,10 +110,24 @@ def fake_sh(cmd, cwd=None, check=True): patch_lib("sh", fake_sh) monkeypatch.chdir(origin_repo) monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) - # 認証確認は実際の CLI を起動する。ここでは対象外なので飛ばす - # (確認そのものは `test_init_checks_cli_authentication` で見る)。 - monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + # 認証確認は実際の CLI を起動する。既定では飛ばし、`probe` を渡したときだけ + # 止めない確認(`probe_auth`)を差し替えて結果を決める。 + if probe is None: + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + else: + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + cmd_setup = sys.modules["refactor_lib.commands.setup"] + probed.clear() + + def fake_probe(runtimes, *, info, env=None): + names = list(runtimes) + probed.append(names) + return {n: {"command": n, "ok": n not in probe, "detail": probe.get(n, "")} + for n in names}, False + + monkeypatch.setattr(cmd_setup.auth, "probe_auth", fake_probe) refactor.cmd_init(args) + _run.probed = probed return _run @@ -120,9 +136,13 @@ def refactor_abort(): return 4 -def _state_of(tmp_path): - path = (tmp_path / "rf130" / "work" / ".cross_refactoring" +def _state_path(tmp_path): + return (tmp_path / "rf130" / "work" / ".cross_refactoring" / "cross-refactoring-rf130-state.json") + + +def _state_of(tmp_path): + path = _state_path(tmp_path) return path, json.loads(path.read_text(encoding="utf-8")) @@ -136,15 +156,82 @@ def test_init_creates_the_writable_worktree_from_origin(run_init, tmp_path): assert head.stdout.strip() == HEAD_BRANCH -def test_init_records_cohorts_separately(run_init, tmp_path): - """提案・レビューと適用の母集合は別物である。""" - run_init(_args(tmp_path)) +def test_init_uses_codex_kiro_and_the_host_as_the_participants(run_init, tmp_path, capsys): + """AC31 — 既定の参加者は codex / kiro とホスト。agy は確かめず、母集合は 1 つだけ。""" + run_init(_args(tmp_path), probe={}) _, state = _state_of(tmp_path) - assert state["runtimes"] == ["codex", "agy", "kiro"] - assert state["impl_capable"] == ["claude", "codex", "agy", "kiro"] + assert state["runtimes"] == ["claude", "codex", "kiro"] + assert run_init.probed == [["claude", "codex", "kiro"]], "agy を確かめている" + assert "impl_capable" not in state + assert "IMPL_POOL=" not in capsys.readouterr().out + assert state["participants"]["available"] == ["claude", "codex", "kiro"] + assert state["participants"]["pool"] == ["claude", "codex", "kiro"] + assert state["resume_changes"] == [] assert state["host"] == "claude" assert state["host_detection"] == "explicit" - assert state["host"] not in state["runtimes"] + + +@pytest.mark.parametrize("host, expected", [ + ("codex", ["codex", "kiro"]), + ("agy", ["codex", "agy", "kiro"]), + ("kiro", ["codex", "kiro"]), +]) +def test_the_participants_follow_the_host(run_init, tmp_path, host, expected): + """AC32 — ホストが既定の参加者の表にいれば 2 者、いなければ 3 者になる。""" + run_init(_args(tmp_path, host=host), probe={}) + assert _state_of(tmp_path)[1]["runtimes"] == expected + + +@pytest.mark.parametrize("over, expected", [ + ({"include": [["agy"]]}, ["claude", "codex", "agy", "kiro"]), + ({"exclude": [["kiro"]]}, ["claude", "codex"]), + ({"exclude": [["claude"]]}, ["codex", "kiro"]), +]) +def test_include_and_exclude_change_the_participants(run_init, tmp_path, over, expected): + """AC33 — 足す者・外す者で名指しで変えられる。ホストも母集合にいるので外せる。""" + run_init(_args(tmp_path, **over), probe={}) + _, state = _state_of(tmp_path) + assert state["runtimes"] == expected + assert state["participants"]["included"] == over.get("include", [[]])[0] + assert state["participants"]["excluded"] == over.get("exclude", [[]])[0] + + +def test_a_failed_probe_drops_the_runtime_and_keeps_going(run_init, tmp_path, capsys): + """AC35 — 1 者の確認が通らなくても止めず、理由を残して使える者で始める。""" + run_init(_args(tmp_path), probe={"kiro": "Not logged in"}) + _, state = _state_of(tmp_path) + assert state["runtimes"] == ["claude", "codex"] + assert state["participants"]["unavailable"] == {"kiro": "Not logged in"} + assert "kiro を担当から外しました(Not logged in)" in capsys.readouterr().err + + +def test_require_all_stops_without_writing_the_state(run_init, tmp_path): + """AC35 — 全員を要する指定では従来の関門で止め、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, require_all=True), probe={"kiro": "Not logged in"}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + + +def test_no_available_runtime_stops_without_writing_the_state(run_init, tmp_path, capsys): + """AC36 — 使える者が 0 者なら終了コード 4 で止め、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path), probe={"claude": "x", "codex": "y", "kiro": "z"}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + assert "使える者がいません" in capsys.readouterr().err + + +@pytest.mark.parametrize("over", [ + {"exclude": [["agy"]]}, # 母集合に無い者は外せない + {"include": [["agy"]], "exclude": [["agy"]]}, # 足す者と外す者の重なり +]) +def test_contradicting_names_stop_the_init(run_init, tmp_path, over): + """名前の矛盾は共通層が弾き、この工程の中断(終了コード 4)へ写す。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, **over), probe={}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() def test_init_records_models(run_init, tmp_path): @@ -175,18 +262,26 @@ def test_init_warns_when_kiro_is_given_auto_explicitly(run_init, tmp_path, capsy def test_init_warns_when_codex_or_agy_has_no_model(run_init, tmp_path, capsys): - """実測できないランタイムで指定が無いラウンドも、kiro の auto と同じく分離される。""" + """実測できないランタイムで指定が無いラウンドも、kiro の auto と同じく分離される。 + + 警告の対象は参加者だけである。既定で外れる agy は、足したときだけ警告する。 + """ run_init(_args(tmp_path, model=["kiro=claude-opus-5"])) warning = capsys.readouterr().err assert "codex のモデルが default です" in warning - assert "agy のモデルが default です" in warning + assert "agy のモデルが" not in warning + + +def test_init_warns_about_agy_when_it_is_included(run_init, tmp_path, capsys): + run_init(_args(tmp_path, model=["kiro=claude-opus-5"], include=[["agy"]])) + assert "agy のモデルが default です" in capsys.readouterr().err def test_init_does_not_warn_when_every_model_can_be_measured(run_init, tmp_path, capsys): """claude だけは指定が無くても実測できるため、警告の対象にならない。""" run_init(_args(tmp_path, model=[ "codex=gpt-5.5", "agy=gemini-3.8", "kiro=claude-opus-5", - ])) + ], include=[["agy"]])) assert "集計から分離されます" not in capsys.readouterr().err @@ -200,8 +295,7 @@ def test_init_accepts_agy_as_host(run_init, tmp_path): run_init(_args(tmp_path, host="agy")) _, state = _state_of(tmp_path) assert state["host"] == "agy" - assert state["runtimes"] == ["claude", "codex", "kiro"] - assert state["impl_capable"] == ["claude", "codex", "agy", "kiro"] + assert state["runtimes"] == ["codex", "agy", "kiro"] def test_init_runs_the_baseline_test(run_init, tmp_path): @@ -256,19 +350,44 @@ def _parsed_init_args(patch_lib, refactor, monkeypatch, *extra): return captured -def test_the_round_caps_have_their_own_defaults(patch_lib, refactor, monkeypatch): +def test_the_round_caps_are_unset_in_the_arguments(patch_lib, refactor, monkeypatch): + """引数の既定は未指定で、再開で「渡さなかった」と読める(#727 の決定 13)。""" + captured = _parsed_init_args(patch_lib, refactor, monkeypatch) + for key in ("max_test_rounds", "max_outer_rounds", "max_fix_rounds", + "max_items_per_round", "test_timeout", "severity_threshold", + "workflow_step", "include", "exclude", "require_all"): + assert captured[key] is None, key + + +def test_a_new_run_fills_the_round_caps_with_their_defaults(run_init, tmp_path): """E1 — 4 つの上限は別々の単位に掛かる(#436 決定 8)。 - `--max-outer-rounds` が 3 でよいのは、適用ラウンドを分けたことで**1 回の提案で - 通せる件数が上限に縛られなくなった**ためである。輪番の 1 周を根拠にしない - (適用の担当は適用ラウンドごとに進むので、1 つの提案ラウンドでも輪番は 1 周 - しうる)。 + 新規の初期化が現行の既定(提案 3 / テスト整備 2 / 修正 3 / 採用 5)へ置き換える。 """ - captured = _parsed_init_args(patch_lib, refactor, monkeypatch) - assert captured["max_test_rounds"] == 2 - assert captured["max_outer_rounds"] == 3 - assert captured["max_fix_rounds"] == 3 - assert captured["max_items_per_round"] == 5 + run_init(_args(tmp_path, max_outer_rounds=None, max_test_rounds=None, + max_fix_rounds=None, max_items_per_round=None, + test_timeout=None, severity_threshold=None, workflow_step=None)) + _, state = _state_of(tmp_path) + assert (state["max_outer_rounds"], state["max_test_rounds"], + state["max_fix_rounds"], state["max_items_per_round"]) == (3, 2, 3, 5) + assert state["test_timeout"] == 900 + assert state["severity_threshold"] == "minor" + assert state["workflow_step"] is False + + +def test_include_and_exclude_parse_names_and_none(patch_lib, refactor, monkeypatch): + """カンマ区切りと繰り返しの両方を受ける。綴りの誤りは argparse が弾く。""" + captured = _parsed_init_args(patch_lib, refactor, monkeypatch, + "--exclude", "kiro", "--include", "agy,claude", + "--require-all") + assert captured["exclude"] == [["kiro"]] + assert captured["include"] == [["agy", "claude"]] + assert captured["require_all"] is True + assert _parsed_init_args(patch_lib, refactor, monkeypatch, + "--exclude", "none")["exclude"] == [["none"]] + with pytest.raises(SystemExit) as e: + _parsed_init_args(patch_lib, refactor, monkeypatch, "--exclude", "gemini") + assert e.value.code == 2 def test_the_ci_check_is_not_set_by_default(patch_lib, refactor, monkeypatch): @@ -319,10 +438,10 @@ def test_init_is_idempotent(run_init, tmp_path, capsys): def test_init_emits_shell_assignments(run_init, tmp_path, capsys): run_init(_args(tmp_path)) out = capsys.readouterr().out - assert "RUNTIMES_CSV=codex,agy,kiro" in out + assert "RUNTIMES_CSV=claude,codex,kiro" in out # 空白を含む値は必ず引用する。引用しないと呼び出し側の eval で語が割れる。 - assert "RUNTIMES='codex agy kiro'" in out - assert "IMPL_POOL='claude codex agy kiro'" in out + assert "RUNTIMES='claude codex kiro'" in out + assert "IMPL_POOL=" not in out assert "TMP_DIR=" in out and "WORK=" in out @@ -421,161 +540,95 @@ def test_init_records_the_vocabulary_for_the_prompt(run_init, tmp_path, vocabula assert state["vocabulary"]["smells"] == vocabulary.SMELLS -def _probe_result(cmd_setup, refactor, monkeypatch, outcomes): - """認証確認コマンドの結果を差し替える。`{ランタイム: (rc, 出力)}`。""" - def fake_run(cmd, **kwargs): - for runtime, probe in cmd_setup.auth.AUTH_PROBES.items(): - if list(cmd) == list(probe): - rc, out = outcomes.get(runtime, (0, "ok")) - return subprocess.CompletedProcess(cmd, rc, out, "") - raise AssertionError(f"想定外の呼び出し: {cmd}") - monkeypatch.setattr(cmd_setup.auth.subprocess, "run", fake_run) - - -def test_check_auth_passes_when_every_cli_is_logged_in(refactor, cmd_setup, monkeypatch): - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {}) - results = cmd_setup.check_auth(["claude", "codex", "agy", "kiro"]) - assert all(r["ok"] for r in results.values()) - - -def test_check_auth_fails_on_a_non_zero_exit(refactor_lib, cmd_setup, refactor, monkeypatch): - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {"kiro": (1, "")}) - with pytest.raises(SystemExit) as e: - cmd_setup.check_auth(["claude", "codex", "agy", "kiro"]) - assert e.value.code == refactor_abort() - - -def test_check_auth_fails_when_the_output_says_not_logged_in(refactor, cmd_setup, monkeypatch): - """終了コード 0 でも未認証を示すことがある(kiro は成否を終了コードで表さない)。""" - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {"kiro": (0, "Not logged in")}) - with pytest.raises(SystemExit): - cmd_setup.check_auth(["claude", "codex", "agy", "kiro"]) - - -def test_check_auth_fails_when_the_cli_is_missing(refactor, cmd_setup, monkeypatch): - cmd_setup = sys.modules["refactor_lib.commands.setup"] - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - - def missing(cmd, **kwargs): - raise FileNotFoundError(cmd[0]) - - monkeypatch.setattr(cmd_setup.auth.subprocess, "run", missing) - with pytest.raises(SystemExit): - cmd_setup.check_auth(["codex"]) - - -def test_check_auth_can_be_skipped_explicitly(refactor, cmd_setup, monkeypatch): - """確認コマンドは CLI の版で変わる。飛ばせる逃げ道を残す。""" - monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") - - def never(cmd, **kwargs): - raise AssertionError("認証確認を実行してはいけない") - - monkeypatch.setattr(cmd_setup.auth.subprocess, "run", never) - assert cmd_setup.check_auth(["codex", "agy"]) == {} - - -def test_init_checks_cli_authentication(patch_lib, refactor, cmd_setup, origin_repo, monkeypatch, tmp_path): - """未認証の CLI があれば初期化ごと中断すること。 - - 参加者が 1 人欠けた構成のまま進むと、その者の提案とレビューが無いまま収束する。 - """ - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - monkeypatch.chdir(origin_repo) - monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {"agy": (1, "Authentication failed")}) - patch_lib("sh", - lambda cmd, **k: pytest.fail("認証確認より前に gh を呼んでいる"), - ) - with pytest.raises(SystemExit) as e: - cmd_setup.cmd_init(_args(tmp_path)) - assert e.value.code == refactor_abort() - - -def test_init_downgrades_the_posting_event_on_own_pull_request(run_init, tmp_path): - """自分の Pull Request では投稿の event を `COMMENT` へ倒すこと。 - - GitHub は自分の Pull Request への `APPROVE` と `REQUEST_CHANGES` を - `HTTP 422` で拒む。倒さないとレビュー担当が投稿に失敗する。 - """ - run_init(_args(tmp_path), viewer="me") - _, state = _state_of(tmp_path) - assert state["is_own_pr"] is True - assert state["event_downgrade"] is True - - -def test_init_keeps_the_posting_event_on_someone_elses_pull_request(run_init, tmp_path): - """他者の Pull Request では判定をそのまま投稿すること。""" - run_init(_args(tmp_path), viewer="someone-else") - _, state = _state_of(tmp_path) - assert state["is_own_pr"] is False - assert state["event_downgrade"] is False - - -def test_init_continues_when_the_viewer_cannot_be_read(run_init, tmp_path): - """ログイン名を読めない環境でも `init` を続けること。 - - bot トークン(Actions の `GITHUB_TOKEN` など)は `/user` を読めず - `HTTP 403` を返す。この値は自分の Pull Request かどうかの判定にしか - 使わないので、読めなければ他者の Pull Request として扱う。 - """ - run_init(_args(tmp_path), viewer=None) - _, state = _state_of(tmp_path) - assert state["is_own_pr"] is False - assert state["event_downgrade"] is False - - -def test_init_fills_the_posting_event_when_resuming_an_old_state(run_init, tmp_path): - """この指示が入る前の状態ファイルから再開しても投稿の event を倒すこと。 - - 再開の分岐は状態ファイルをそのまま使って戻る。項目が無い状態ファイルを - そのまま渡すと、起動側は空の指示を読み、自分の Pull Request で - `HTTP 422` を踏み続ける。 - """ - run_init(_args(tmp_path), viewer="me") - path, state = _state_of(tmp_path) - # 旧版が書いた状態ファイル(2 項目が無い)を再現する - for key in ("is_own_pr", "event_downgrade"): - state.pop(key) - state["outer_round"] = 2 - path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") - - run_init(_args(tmp_path), viewer="me") - - _, resumed = _state_of(tmp_path) - assert resumed["outer_round"] == 2, "再開であって初期化ではないこと" - assert resumed["is_own_pr"] is True - assert resumed["event_downgrade"] is True +# ---------- 再開(#727 / #648 の決定 13〜16) ---------- -# ---------- 改修計画の書き出し先 ---------- - -def test_init_defaults_the_plan_to_a_pull_request_comment(run_init, tmp_path): - """D4 — 既定は Pull Request のコメント 1 件(#436 決定 6)。 - - **改修計画は実行の記録であって、リポジトリの知識ではない。** ファイルに - すると差分に混ざり、URL がブランチの後片付けで切れる。 - """ +@pytest.mark.parametrize("arg, value", [ + ("max_outer_rounds", 5), ("max_test_rounds", 4), + ("max_fix_rounds", 6), ("max_items_per_round", 8), +]) +def test_resume_reflects_a_changed_cap(run_init, tmp_path, capsys, arg, value): + """AC38 — 上限は再開で渡せば反映し、`旧 → 新` を 1 行出し、記録に 1 件積む。""" run_init(_args(tmp_path)) + _, before = _state_of(tmp_path) + capsys.readouterr() + + run_init(_args(tmp_path, **{arg: value})) + _, after = _state_of(tmp_path) + assert after[arg] == value + assert f"{arg}: {before[arg]} → {value}" in capsys.readouterr().err + assert [c["field"] for c in after["resume_changes"]] == [arg] + + +@pytest.mark.parametrize("over, option", [ + ({"model": ["codex=x"]}, "--model"), + ({"host": "codex"}, "--host"), + ({"scope": ["other", "tests"]}, "--scope"), + ({"baseline_test": "pytest -q"}, "--baseline-test"), + ({"severity_threshold": "major"}, "--severity-threshold"), +]) +def test_resume_notifies_arguments_it_does_not_reflect( + run_init, tmp_path, capsys, origin_repo, over, option): + """AC39 — 反映しない引数は状態を変えず、引数ごとに 1 行知らせる。""" + (origin_repo / "other").mkdir(exist_ok=True) + run_init(_args(tmp_path)) + path, before = _state_of(tmp_path) + capsys.readouterr() + + run_init(_args(tmp_path, **over)) + _, after = _state_of(tmp_path) + err = capsys.readouterr().err + assert f"ℹ {option} は再開では反映しません" in err + for key in ("models", "host", "target_scope", "baseline_test", "severity_threshold"): + assert after[key] == before[key], key + assert after["resume_changes"] == [] + + +def test_resume_without_arguments_changes_nothing(run_init, tmp_path, capsys): + """AC39 — 何も渡さない再開では、上限・モデル・参加者が変わらず、確認もしない。""" + run_init(_args(tmp_path), probe={}) + _, before = _state_of(tmp_path) + + run_init(_args(tmp_path), probe={"kiro": "Not logged in"}) + _, after = _state_of(tmp_path) + assert run_init.probed == [], "担当に関わる引数を渡していないのに確かめ直している" + for key in ("max_outer_rounds", "max_test_rounds", "max_fix_rounds", + "max_items_per_round", "models", "runtimes", "participants"): + assert after[key] == before[key], key + assert "再開では反映しません" not in capsys.readouterr().err + + +def test_resume_with_exclude_rebuilds_the_participants(run_init, tmp_path): + """AC40 — 外す者を渡した再開では確かめ直し、渡さなかった足す者は記録から補う。""" + run_init(_args(tmp_path, include=[["agy"]]), probe={}) + run_init(_args(tmp_path, exclude=[["kiro"]]), probe={}) _, state = _state_of(tmp_path) - assert state["plan_mode"] == "comment" - assert state["plan_file"] == "" - - -def test_init_keeps_an_explicit_plan_file(run_init, tmp_path): - """**`--plan-file` は残す。** 明示したときだけファイルにする。""" - run_init(_args(tmp_path, plan_file="docs/plan.md")) + assert run_init.probed == [["claude", "codex", "agy"]] + assert state["runtimes"] == ["claude", "codex", "agy"] + assert state["participants"]["included"] == ["agy"] + assert state["participants"]["excluded"] == ["kiro"] + changes = state["resume_changes"] + assert [c["field"] for c in changes] == ["participants"], "作り直しは 1 件として積む" + assert changes[0]["from"]["available"] == ["claude", "codex", "agy", "kiro"] + + +def test_resume_with_none_clears_the_recorded_names(run_init, tmp_path): + """予約語 `none` は記録の一覧を空へ戻す(決定 15)。""" + run_init(_args(tmp_path, exclude=[["kiro"]]), probe={}) + run_init(_args(tmp_path, exclude=[["none"]]), probe={}) _, state = _state_of(tmp_path) - assert state["plan_mode"] == "file" - assert state["plan_file"] == "docs/plan.md" + assert state["participants"]["excluded"] == [] + assert state["runtimes"] == ["claude", "codex", "kiro"] -def test_init_accepts_an_empty_plan_file_as_off(run_init, tmp_path): - """計画を残したくないリポジトリのために、空文字で無効にできる。""" - run_init(_args(tmp_path, plan_file="")) - _, state = _state_of(tmp_path) - assert state["plan_mode"] == "none" - assert state["plan_file"] == "" +def test_a_failed_rebuild_leaves_the_state_untouched(run_init, tmp_path): + """作り直しが 0 者なら終了コード 4 で止め、状態ファイルを書き換えない。""" + run_init(_args(tmp_path), probe={}) + path, _ = _state_of(tmp_path) + before = path.read_text(encoding="utf-8") + + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, exclude=[["kiro"]], max_outer_rounds=9), + probe={"claude": "x", "codex": "y"}) + assert e.value.code == refactor_abort() + assert path.read_text(encoding="utf-8") == before diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py b/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py index 49cc39ba..4a20f613 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py @@ -343,8 +343,7 @@ def test_models_are_fixed_across_rounds(cmd_setup, tmp_path, env_tmp_dir): state = read_state(state_path) entry = state["rounds"][-1] assert entry["impl_model"]["requested"] == state["models"][entry["impl"]] - for r in entry["reviewers"]: - assert entry["reviewer_models"][r]["requested"] == state["models"][r] + assert "reviewer_models" not in entry # 次のラウンドを開けるように、いま開いたラウンドを閉じる entry["adopted"] = 1 state_path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py b/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py index a37e8522..077f3894 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py @@ -134,6 +134,14 @@ def test_the_body_carries_the_marker_and_the_plan(plan, tmp_path): assert "R1-001" in body and "src/foo.py" in body +def test_the_round_heading_has_no_reviewers(plan, tmp_path): + """AC37 — 改修計画のラウンドの見出しにレビュー担当を出さない。古い記録にあっても。""" + _, state = _state(tmp_path) + body = plan.plan_comment_body(state) + assert "## ラウンド 1(実装 codex)" in body + assert "レビュー" not in body.split("## ラウンド 1", 1)[1].splitlines()[0] + + def test_a_failed_post_does_not_stop_the_run(plan, tmp_path, gh): """記録が残らないことと、変更が検証を通っていないことは別である。""" _, state = _state(tmp_path) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py index f7221bb6..7017e233 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py @@ -28,16 +28,16 @@ def round_of(round_no, **over): # ---------- start-round ---------- def test_start_round_opens_and_records_assignment(refactor, tmp_path, env_tmp_dir): - state_path = make_state(tmp_path) + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"]) env_tmp_dir(state_path) refactor.cmd_start_round(_args()) state = read_state(state_path) assert len(state["rounds"]) == 1 entry = state["rounds"][0] + # ラウンド 1 は参加者の 2 番目から始まり、ホストが最初に適用しない(#727 の決定 7) assert entry["impl"] == "codex" - assert entry["reviewers"] == ["agy", "kiro"] - assert entry["impl"] not in entry["reviewers"] + assert "reviewers" not in entry assert state["phase"] == "propose" @@ -156,10 +156,75 @@ def test_report_renders_tables(cmd_report, tmp_path, env_tmp_dir, capsys): assert "R1-001" in out -def test_status_reports_cohorts(cmd_report, tmp_path, env_tmp_dir, capsys): - state_path = make_state(tmp_path) +def test_status_reports_one_cohort(cmd_report, tmp_path, env_tmp_dir, capsys): + """AC37 — 母集合は 1 行で出す。提案と適用で分けない(#727 の決定 5)。""" + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"]) + env_tmp_dir(state_path) + cmd_report.cmd_status(_args()) + out = capsys.readouterr().out + assert "参加者(提案と適用): claude / codex / kiro" in out + assert "提案・レビュー" not in out + assert "適用の母集合" not in out + + +def test_status_reads_an_older_state_with_the_implementation_cohort( + cmd_report, tmp_path, env_tmp_dir, capsys): + """AC41 — 適用専用の母集合を持つ古い状態ファイルも読める。表示は参加者の一覧だけ。""" + state_path = make_state(tmp_path, impl_capable=["claude", "codex", "agy", "kiro"]) env_tmp_dir(state_path) cmd_report.cmd_status(_args()) + assert "参加者(提案と適用): codex / agy / kiro" in capsys.readouterr().out + + +def _report_args(): + return type("A", (), {"id": 130, "metrics": False})() + + +def test_report_has_no_reviewer_column(cmd_report, tmp_path, env_tmp_dir, capsys): + """AC37 — ラウンド表にレビュー担当の列が無い。古い記録にあっても出さない。""" + state_path = make_state(tmp_path, rounds=[{ + "round": 1, "kind": "structure", "impl": "codex", + "impl_model": {"requested": None, "observed": None}, + "reviewers": ["agy", "kiro"], "reviewer_models": {}, "adopted": 1, + "apply": {"applied": [], "failed": []}, "fix_rounds": 0, "reviews": [], + }]) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + out = capsys.readouterr().out + header = next(line for line in out.splitlines() if line.startswith("| R |")) + assert "レビュー担当" not in header + assert "| 1 | 構造改善 | codex |" in out + assert "agy / kiro" not in out + + +def test_report_prints_the_participants(cmd_report, tmp_path, env_tmp_dir, capsys): + """F6 — 参加者・外した者・足した者・確認を通らなかった者・再開で変えた値を出す。""" + state_path = make_state( + tmp_path, runtimes=["claude", "codex"], + participants={ + "pool": ["claude", "codex", "kiro"], "included": ["agy"], + "excluded": ["kiro"], "available": ["claude", "codex"], + "unavailable": {"agy": "Not logged in"}, + "probe_skipped": False, "require_all": False, + }, + resume_changes=[{"at": "2026-09-22T00:00:00", "field": "max_outer_rounds", + "from": 3, "to": 5}], + ) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) out = capsys.readouterr().out - assert "提案・レビュー: codex / agy / kiro" in out - assert "適用の母集合: claude / codex / kiro" in out + assert "## 参加した者" in out + assert "- 母集合: claude / codex / kiro" in out + assert "- 使える者: claude / codex" in out + assert "- --exclude で外した者: kiro" in out + assert "- --include で足した者: agy" in out + assert "- 確認を通らなかった者: agy(Not logged in)" in out + assert "max_outer_rounds: 3 → 5" in out + + +def test_report_says_no_record_for_an_older_state(cmd_report, tmp_path, env_tmp_dir, capsys): + """AC41 — 参加者の記録を持たない古い状態ファイルでは「記録なし」と出す。""" + state_path = make_state(tmp_path, impl_capable=["claude", "codex", "agy", "kiro"]) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + assert "- 使える者: 記録なし" in capsys.readouterr().out diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py b/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py index e783be16..3ff6a3a7 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py @@ -1,10 +1,11 @@ -"""`start-round` が提案・レビューの母集合を返すこと(#518-1)。 +"""`start-round` が参加者の一覧と実装担当を返すこと(#518-1 / #727)。 **繰り返しの中で使う値は、繰り返しの中で得られる。** 母集合を `init` だけが返すと、 状態ファイルから再開する経路と、骨組みを抜粋して写す経路の両方で未定義になる。 """ from __future__ import annotations +import json import shlex import pytest @@ -56,18 +57,33 @@ def test_existing_values_are_untouched(refactor, tmp_path, env_tmp_dir, capsys): emitted = _emitted(capsys) for key in ( - "ROUND", "ROUND_KIND", "PROPOSE_PHASE", "IMPL", "IMPL_MODEL", - "REVIEWERS", "REVIEWERS_CSV", "MAX_FIX_ROUNDS", + "ROUND", "ROUND_KIND", "PROPOSE_PHASE", "IMPL", "IMPL_MODEL", "MAX_FIX_ROUNDS", ): assert key in emitted, f"{key} が出力から消えている" -def test_the_pool_is_wider_than_the_reviewers(refactor, tmp_path, env_tmp_dir, capsys): - """`REVIEWERS` で代用すると提案する者が 1 人減る。""" - state_path = make_state(tmp_path, runtimes=["codex", "agy", "kiro"]) +def test_start_round_has_no_reviewers(refactor, tmp_path, env_tmp_dir, capsys): + """AC34 — レビュー工程は #436 で消えた。存在しない役を出力にも記録にも残さない。""" + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"]) env_tmp_dir(state_path) refactor.cmd_start_round(_args()) emitted = _emitted(capsys) - assert len(emitted["RUNTIMES"].split()) == 3 - assert len(emitted["REVIEWERS"].split()) == 2 + assert "REVIEWERS" not in emitted + assert "REVIEWERS_CSV" not in emitted + entry = json.loads(state_path.read_text(encoding="utf-8"))["rounds"][0] + assert "reviewers" not in entry + assert "reviewer_models" not in entry + + +def test_the_implementer_rotates_within_the_participants( + refactor, tmp_path, env_tmp_dir, capsys): + """AC34 / AC41 — 実装担当は参加者の一覧から決まる。適用専用の母集合は読まない。""" + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"], + impl_capable=["claude", "codex", "agy", "kiro"]) + env_tmp_dir(state_path) + impls = [] + for _ in range(3): + refactor.cmd_start_round(_args()) + impls.append(_emitted(capsys)["IMPL"]) + assert impls == ["codex", "kiro", "claude"] diff --git a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py index 1041e192..822a8197 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py @@ -92,7 +92,8 @@ def test_init_stores_an_empty_rejected_findings_list(tmp_dir, state_mod, monkeyp monkeypatch.setattr(state_mod, "_sync_worktree", lambda *args: None) monkeypatch.setattr(state_mod.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 0, stdout="", stderr="")) - monkeypatch.setattr(state_mod.auth, "check_auth", lambda *args, **kwargs: None) + monkeypatch.setattr(state_mod.auth, "probe_auth", lambda runtimes, **kwargs: ( + {r: {"command": r, "ok": True, "detail": ""} for r in runtimes}, False)) state_mod.cmd_init(argparse.Namespace( pr=PR, max_rounds=12, rotate_after=8, only=None, worktree=str(worktree), diff --git a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py index df13827f..4fe3d06b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py @@ -549,9 +549,10 @@ def test_a_state_without_participants_keeps_the_old_rotation(state_mod, tmp_path """AC22: `participants` が無くても、`host` があれば変更前の輪番と同じ値を返す。""" path = _state(tmp_path, host="codex") st = json.loads(path.read_text(encoding="utf-8")) + # 変更前の輪番: 母集合 claude / agy / kiro から `(round_no - 1) % 3` の者を外した 2 者 + previous = [["agy", "kiro"], ["claude", "kiro"], ["claude", "agy"]] for round_no in range(1, 7): - assert state_mod._round_reviewers(st, round_no) == \ - state_mod.assignment.review_assign(round_no, "codex") + assert state_mod._round_reviewers(st, round_no) == previous[(round_no - 1) % 3] del st["host"] assert state_mod._round_reviewers(st, 1) == ["codex", "agy"] diff --git a/scripts/tests/test_shared_lib_layout.py b/scripts/tests/test_shared_lib_layout.py index 735fe606..569e0c51 100644 --- a/scripts/tests/test_shared_lib_layout.py +++ b/scripts/tests/test_shared_lib_layout.py @@ -322,3 +322,24 @@ def test_the_identifier_still_has_to_be_an_integer() -> None: out = _monitor("10.2.0", "--agents", "deploy") assert out.returncode != 0 assert "invalid int value" in out.stderr + + +# ---------- 呼び手の無くなった旧関数(#727 の AC7) ---------- + +# 使える者の解決と席・適用の割り当てが共通層の新しい関数へ移り、呼び手が無くなった 4 つ。 +# **片方の Skill にだけ古い形が残らない**(親 #727 の完了条件)ことを、名前が残らない +# ことで固定する。部品(`scripts/`)だけを見る。テストと文書は古い値を期待値や経緯として +# 持ちうる。 +RETIRED_FUNCTIONS = ("check_auth", "impl_pool", "review_assign", "assign") + + +def test_the_retired_assignment_functions_are_gone() -> None: + result = subprocess.run( + ["git", "grep", "-n", "-w", + *[arg for name in RETIRED_FUNCTIONS for arg in ("-e", name)], + "--", "plugins/ndf/scripts/lib/", + "plugins/ndf/skills/cross-review/scripts/", + "plugins/ndf/skills/cross-refactoring/scripts/"], + cwd=ROOT, capture_output=True, text=True, + ) + assert result.returncode == 1, f"旧関数の名前が残っている:\n{result.stdout}" From 0d45912d16509bc9c04a87735b789c9dafc8bd39 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 09:47:27 +0000 Subject: [PATCH 179/217] =?UTF-8?q?Update:=20=E8=B5=B7=E5=8B=95=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=82=92=E5=88=9D=E5=9B=9E=E3=81=A8=E5=90=8C?= =?UTF-8?q?=E3=81=98=E7=B5=8C=E8=B7=AF=E3=81=B8=E9=80=9A=E3=81=97=E3=80=81?= =?UTF-8?q?=E6=9C=80=E7=B5=82=E3=82=B9=E3=82=A4=E3=83=BC=E3=83=97=E3=81=AE?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=E3=82=82=E5=9B=9E=E3=81=99?= =?UTF-8?q?=E5=81=B4=E3=81=B8=E7=A7=BB=E3=81=99=EF=BC=88#730=20#583?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 骨組みの起動し直しの枝が自分の起動・監視・取り込みを持たず、繰り返しの先頭へ戻る 形にした。起動し直した担当の指摘も、根拠の検証と反証を通ってから判定へ進む。 待ち行列に残りがあるときの枝は各判定の直後、起動し直しより先に見る。 設計方針の表から、担当が直接投稿する行と申告を突き合わせる行を外し、投稿の担い手と その理由を入れた。修正の手順(docs/02)から担当の送信・返信・決着・まとめを外した。 最終スイープも /ndf:fix を使うため、担当はコミットと結果ファイルまでとし、骨組みが 共通層の 1 行で送信・返信・決着を行う。スイープは見送りのスレッドも決着させるため、 要素ごとに決着を求める印を読む。 担当のプロンプトの見出しを /ndf:pr-review から外した(そちらは担当が直接投稿する 手順を持つ)。送信の認証の退避は共通層の値を読み、失敗したときだけ 1 度やり直す。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 32 +++++-- .../ndf/scripts/tests/test_result_posts.py | 19 ++++ plugins/ndf/skills/cross-review/SKILL.md | 54 ++++++----- .../cross-review/docs/02-fix-and-rotation.md | 93 +++++++------------ .../cross-review/scripts/launch-reviewer.sh | 2 +- .../cross-review/tests/test_skill_bg_wait.py | 14 +-- .../cross-review/tests/test_skill_layout.py | 37 ++++++++ plugins/ndf/skills/fix/SKILL.md | 2 + scripts/tests/test_push_fallback_docs.py | 7 +- 9 files changed, 154 insertions(+), 106 deletions(-) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index bbe9012f..95a5fa3c 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -301,7 +301,10 @@ def fix_posts(result_path: pathlib.Path | str, repo: str, pr: int, f"この指摘は採らない判断です。{reason}".strip()) if reply: items.append(reply) - for entry in resolved: + # 見送り・却下は既定では決着させない(次のラウンドで見直す)。最終スイープは + # スレッドを残さないため、要素の `resolve` を真にして決着まで求める。 + closing = resolved + [e for e in deferred + rejected if e.get("resolve")] + for entry in closing: thread_id = entry.get("thread_id") if thread_id: items.append({"kind": "thread-resolve", @@ -371,13 +374,28 @@ class PushResult(NamedTuple): detail: str +# 認証の退避の値は共通層 1 か所が持つ(`git-credential.sh`)。ここへ写さない。 +_CREDENTIAL_LIB = pathlib.Path(__file__).resolve().parent / "git-credential.sh" + + +def _credential_fallback_args() -> list[str]: + r = subprocess.run( + ["bash", "-c", f'. "{_CREDENTIAL_LIB}"; ndf_git_credential_fallback_args'], + capture_output=True, text=True) + return [line for line in r.stdout.split("\n") if line] if r.returncode == 0 else [] + + def _git(worktree: pathlib.Path | str, *args: str) -> subprocess.CompletedProcess: - # 認証は `gh` の持ち物を使う。helper を空で 1 度挟むのは、応答を返さない helper が - # 先に当たると止まるためである。 - cmd = ["git", "-C", str(worktree), - "-c", "credential.helper=", - "-c", "credential.helper=!gh auth git-credential", *args] - return subprocess.run(cmd, capture_output=True, text=True) + """git を 1 度実行し、認証で落ちたときだけ helper を退避して 1 度だけやり直す(#524)。""" + cmd = ["git", "-C", str(worktree), *args] + first = subprocess.run(cmd, capture_output=True, text=True) + if first.returncode == 0 or args[0] not in ("push", "fetch"): + return first + fallback = _credential_fallback_args() + if not fallback: + return first + return subprocess.run(["git", "-C", str(worktree), *fallback, *args], + capture_output=True, text=True) def push_fix(worktree: pathlib.Path | str, head_branch: str, diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index 747ad6cc..056fd6ec 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -456,3 +456,22 @@ def test_the_standalone_command_pushes_and_posts_with_the_same_layer( assert "REPLIED=4 RESOLVED=2 QUEUED=0" in r.stdout # 本文は出さない。 assert "好みの範囲" not in r.stdout + + +def test_a_deferred_thread_marked_to_resolve_is_resolved(tmp_path) -> None: + """最終スイープは見送りも決着させる。要素の `resolve` が真なら決着を積む。""" + items = result_posts.fix_posts( + _fix_file(tmp_path, resolved_threads=[], rejected=[], deferred=[ + {"comment_id": 333, "thread_id": "PRRT_c", "resolve": True, + "reason_for_deferral": "好みの範囲"}]), + repo=REPO, pr=PR) + + assert [i["kind"] for i in items] == ["review-reply", "thread-resolve", "pr-comment"] + assert items[1]["fields"]["thread_id"] == "PRRT_c" + + +def test_a_deferred_thread_is_not_resolved_by_default(tmp_path) -> None: + items = result_posts.fix_posts( + _fix_file(tmp_path, resolved_threads=[], rejected=[]), repo=REPO, pr=PR) + + assert "thread-resolve" not in [i["kind"] for i in items] diff --git a/plugins/ndf/skills/cross-review/SKILL.md b/plugins/ndf/skills/cross-review/SKILL.md index dbfac2d4..67a9eb5e 100644 --- a/plugins/ndf/skills/cross-review/SKILL.md +++ b/plugins/ndf/skills/cross-review/SKILL.md @@ -50,8 +50,8 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 | 観点 | 方針 | |---|---| -| レビュー投稿 | **AI 自身が `gh api` で PR に直接投稿**。メインはペイロードを保持しない | -| 投稿の確認 | **申告されたコメント数を GitHub 側と突き合わせる**。投稿が届いていなければ中断する(取得できない場合は申告を採用) | +| 投稿の担い手 | **GitHub と git へ書くのはレビューを回す側だけ**(#730)。担当は指摘の控えと結果ファイルを書き、取り込み(`read-result`)が組み立てて待ち行列から送る。修正の担当はコミットまでで、送信・返信・決着・まとめは `merge-fix` が行う。書き込みと記録が同じ手順で続くため、担当が途中で止まっても投稿だけが残らない | +| 投稿の記録 | 参照は送信の応答から、件数は送れたインラインの数から取る。本文は取り込みのプロセスの中だけを通り、メインの応答に載らない | | 修正 | **必ずサブエージェント (`general-purpose`) で実行**。メイン context に diff は載せない | | ユーザ問い合わせ | 自動判断を最大化(`critical`/`major`/`minor` は自動修正、ループ中の `nit` は deferred) | | 取りこぼし防止 | **ループ終了時(approved / max_rounds / oscillation / error いずれも)に最終スイープを必須実行**。`/ndf:fix` を再実行し、残った open review thread(最終 APPROVE ラウンドの minor/nit インラインコメント含む)を **全て解消**。修正可能なものは修正 + push、判断保留 nit も reply + resolveReviewThread して **open thread 0 で終了**。件数は `state.py verify-sweep` が GitHub 側の実数で確認する | @@ -124,7 +124,6 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 ## 前提 -- `/ndf:pr-review` が **AI 直接投稿**(外部 AI 自身が `gh api` で投稿)に対応 - `/ndf:fix` が **サブエージェント起動 + 重要度ベース自動修正 + Resolve Conversation** に対応 - `gh` CLI が認証済み。担当になる CLI は `init` が起動前に確かめ、通らない者は外して続ける(誤検知するときは `NDF_SKIP_AUTH_CHECK=1`) - `Agent(subagent_type="general-purpose", ...)` でサブエージェントを起動可能 @@ -151,11 +150,12 @@ GitHub は **自分の PR には `REQUEST_CHANGES` でレビューを投稿で ```json "codex": { "intent": "REQUEST_CHANGES", // AI の本来判定。ループ収束判定に使う - "posted_as": "COMMENT", // 422 回避でダウングレードした結果 + "posted_as": "COMMENT", // 投稿する側が送信の時点で落とした形 "comments": 5, "review_url": "..." } ``` +格下げは投稿する側(取り込み)が送信の時点で行う。担当は本来の判定だけを書く。 `state.py judge` は `intent` を見るので、ダウングレード投稿してもループは続行する。 ## 全体フロー @@ -165,13 +165,13 @@ flowchart TD Start([事前確認 / loop 開始前に 1 回だけ]):::phase --> Init["worktree 作成 + state.json 初期化
・自分の PR 判定 → event downgrade 設定
・<worktree-base>/pr<PR> を用意
・既存コメントスナップショット保存"] Init --> Round["Round N start
current_pr = PR#"]:::phase - Round -.並列バックグラウンド.-> Seats["レビュー担当 2 席(start-round が返す)
/ndf:pr-review <PR> <席> を席ごとに起動
body 先頭: cross-review / round N / 席 / intent
→ <席>-review-pr<PR>-result.json"] + Round -.並列バックグラウンド.-> Seats["レビュー担当 2 席(start-round が返す)
launch-reviewer.sh <席> を席ごとに起動
→ 指摘の控え + <席>-review-pr<PR>-result.json
read-result が組み立てて投稿(先頭: round N / 席 / intent)"] Seats --> Decide{"判定 (intent ベース)"} Decide -->|"結果なし (2 度目は final = error)"| Relaunch["結果を残さなかった側だけ
同じラウンドで 1 度起動し直す"] Relaunch --> Decide Decide -->|"両席 APPROVE / --only で外した席"| Approved([final = approved]):::ok - Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + push
・reply + resolveReviewThread
・deferred/rejected は reply のみ
→ $TMP_DIR/fix-pr<#>-result.json"] + Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + コミット(送らない)
→ $TMP_DIR/fix-pr<#>-result.json
merge-fix が送信・返信・決着・まとめ"] Fix --> Check{収束チェック} Check -->|max-rounds 到達| MaxR([final = max_rounds]):::stop @@ -182,7 +182,7 @@ flowchart TD Check -->|それ以外| Round Rotate --> Round - Approved --> Sweep["最終スイープ (必須)
Agent (general-purpose)
/ndf:fix <PR> を再実行
・残 open review thread を全て確認
・修正可能な minor/nit は修正 + push
・判断保留 nit も reply + resolveReviewThread
→ open thread 0 で終了"] + Approved --> Sweep["最終スイープ (必須)
Agent (general-purpose)
/ndf:fix <PR> を再実行
・残 open review thread を全て確認
・修正可能な minor/nit は修正 + コミット
・判断保留 nit も決着を求める
→ 共通層が送信・返信・決着 → open thread 0"] MaxR --> Sweep Osc --> Sweep Err --> Sweep @@ -229,17 +229,17 @@ while :; do # Step 2: 並列レビュー(担当は start-round が REVIEWERS / REVIEWERS_CSV で返す) # **シェル変数で絞り直さない。** 返る一覧は 1 者指定と席の埋め合わせを反映済みである。 - for r in $REVIEWERS; do - "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND" - done + # **起動し直しも同じ経路を通す**(#583)。7 のときは名前の出た担当だけを入れて先頭へ戻る。 + AGENTS=$REVIEWERS; AGENTS_CSV=$REVIEWERS_CSV; RELAUNCHED= + while :; do + for r in $AGENTS; do "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND"; done # 監視: 上限は上限の表(review 1200 秒 / stall は席のランタイム別 codex 180・agy 480・kiro 480・claude 900)。失敗時は kill して返す。担当は `--agents` で渡す。 # Bash の 1 回 600 秒に収まらないため背景で起動し、wait(1 回 540 秒以内)を 124 のあいだ **別の Bash の呼び出しで** 呼び直す。**繰り返しを 1 回の呼び出しへ書かない**(2 回目の待ちで合計が 600 秒を超え、ホストに打ち切られる。docs/01)。 # 監視と取り込みの終了コードは読まない。結果なしは NO_RESULT として state に残り、Step 3 が受け取る。 - "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$REVIEWERS_CSV" + "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$AGENTS_CSV" "$SCRIPTS/bg-wait.sh" wait "$TMP_DIR/review.rc" # 124 = まだ。**この 1 行を別の Bash の呼び出しとして呼び直す** - for r in $REVIEWERS; do - "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true - done + # 取り込みがレビューを投稿する(担当は投稿しない、#730)。出力は件数と参照だけ。 + for r in $AGENTS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true; done # Step 2.5: 根拠の検証(#156)。順序と理由は docs/06-evidence.md の「走らせる順序」。 # 飛ばすと、判定が読む区分が統合も実行の結果も反映しないまま決まる。 @@ -252,17 +252,15 @@ while :; do # Step 3: 判定 (0=収束 / 2=修正へ / 7=結果なし / 8=待ち行列に残あり / 1=中断)。引き継いだ指摘が残っていれば、 # 両者が承認しても 2 を返して修正の工程へ回す。置換の終了コードは変数で受けてから読む。 JUDGE_VARS=$("$SCRIPTS/state.py" judge "$STATE_PR"); JUDGE_RC=$?; eval "$JUDGE_VARS" - if [ "$JUDGE_RC" -eq 7 ]; then # 名前の出た担当だけを、同じラウンドで 1 度起動し直す - for a in $RELAUNCH_AGENTS; do "$SCRIPTS/launch-reviewer.sh" "$a" "$STATE_PR" "$ROUND"; done - "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$RELAUNCH_AGENTS_CSV" - "$SCRIPTS/bg-wait.sh" wait "$TMP_DIR/review.rc" # 同上。124 のあいだ、別の呼び出しとして呼び直す - for a in $RELAUNCH_AGENTS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$a" || true; done - JUDGE_VARS=$("$SCRIPTS/state.py" judge "$STATE_PR"); JUDGE_RC=$?; eval "$JUDGE_VARS" - fi - if [ "$JUDGE_RC" -eq 8 ]; then # 上限で積んだ投稿が残っている。流してから判定し直す + if [ "$JUDGE_RC" -eq 8 ]; then # 上限で積んだ投稿が残っている。流してから判定し直す(7 より先に見る) "$SCRIPTS/state.py" flush "$STATE_PR" JUDGE_VARS=$("$SCRIPTS/state.py" judge "$STATE_PR"); JUDGE_RC=$?; eval "$JUDGE_VARS" fi + if [ "$JUDGE_RC" -eq 7 ] && [ -z "$RELAUNCHED" ]; then # 同じラウンドで 1 度だけ起動し直す + AGENTS=$RELAUNCH_AGENTS; AGENTS_CSV=$RELAUNCH_AGENTS_CSV; RELAUNCHED=1; continue + fi + break + done case $JUDGE_RC in 0) break ;; 2) : ;; *) exit "$JUDGE_RC" ;; esac # 1=結果なしのまま中断 # 8 のまま残るのは上限が続いているとき。state は残るので、回復後に同じ引数で再開する。 @@ -312,12 +310,12 @@ while :; do fi done -# Step 7.5: 最終スイープ (必須) — どの終了経路 (approved / max_rounds / oscillation / -# error) でも、ループを抜けた直後に **メインが Agent(general-purpose) を起動** して -# /ndf:fix $STATE_PR を再実行し、$TMP_DIR/sweep-pr$STATE_PR-result.json を書かせる -# (bash 単体では Agent ツールを呼べない。プロンプトは docs/02 の Step 7.5)。 -# 最終 APPROVE ラウンドの minor/nit はループ内 fix を経由しないため、ここで拾わないと -# PR 上に未解決スレッドが残る。sweep 結果はメインが Step 8 の報告へ折り込む。 +# Step 7.5: 最終スイープ (必須) — どの終了経路でも、ループを抜けた直後に **メインが +# Agent(general-purpose) を起動** して /ndf:fix を再実行し、$TMP_DIR/sweep-pr$STATE_PR-result.json +# を書かせる(プロンプトは docs/02 の Step 7.5)。担当はコミットまでで、送信・返信・決着は +# 次の 1 行が行う(#730)。最終 APPROVE ラウンドの minor/nit はここで拾う。 +python3 "$SCRIPTS/../../../scripts/lib/result_posts.py" fix --pr "${CURRENT_PR:-$PR}" \ + --result "$TMP_DIR/sweep-pr$STATE_PR-result.json" --worktree "$WORKTREE" || exit $? # Step 7.5 後段: 最終スイープの結果を GitHub 側の実数で検証する (必須) # exit 0 = 未解決の指摘なし / exit 6 = 残っている (件数と理由を完了報告へ含めて続行) diff --git a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md index 21a5995e..a0805eaa 100644 --- a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md +++ b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md @@ -20,21 +20,21 @@ **メインセッションでは修正コードを書かない。** `/ndf:fix` を `general-purpose` サブエージェントで起動する。 -**サブエージェントの責務(必須 6 点)**: +**サブエージェントの責務(必須 4 点)**: -1. critical / major / minor の修正コミット +1. critical / major / minor の修正コミット(**送らない**) 2. 修正テストの追加・実行 -3. 修正対象の thread に **reply 投稿** + **`resolveReviewThread` で Resolve** -4. nit / 判断が割れる minor は **修正せず deferred 記録**(reply は `[deferred / nit]` ラベル付き、Resolve しない) -5. **PR レベルの Summary コメントを `gh pr comment` で投稿**(対応件数 / 重要度別 / deferred 件数 / rejected 件数 / commit SHA を含む) -6. 戻り値ファイル `$TMP_DIR/fix-pr-result.json` を必ず書き出す +3. nit / 判断が割れる minor は **修正せず deferred 記録** +4. 戻り値ファイル `$TMP_DIR/fix-pr-result.json` を必ず書き出す (`$TMP_DIR` は env `CROSS_REVIEW_TMP_DIR` > `/.cross_review/` の順で解決。 詳細は `scripts/state.py _tmp_dir()` 参照。`/tmp/` 直書きでも `state.py merge-fix` は legacy fallback で拾う) -> ⚠ inline thread への reply + Resolve **だけでは不十分**。PR ページの -> conversation タブに表示される **PR レベルコメント** がレビュアーへの -> サマリ通知として必須(`/ndf:fix` SKILL.md の手順 7 で規定)。 -> サブエージェント起動プロンプトでも明示的に指示すること。 +**送信・返信・決着・まとめは取り込み(`state.py merge-fix`)が行う**(#730)。サブエージェントは +GitHub と git へ書かない。取り込みは現在の頭を指定して送り(`git push origin HEAD:<ブランチ名>`)、 +戻り値ファイルが報告したコミットが送り先に載ったことを確かめてから、`resolved_threads` / +`deferred` / `rejected` の配列から返信と決着を、件数からまとめを組み立てて待ち行列で送る。 +載っていなければ記録も投稿もせずに止まる。担当が送ると、切り離された頭ではブランチ名だけの +送信が何も送らずに終了コード 0 で終わり、送ったという報告と実物が食い違う。 ### サブエージェント起動例 @@ -101,47 +101,15 @@ worktree 外を触ると競合します。 `total_count: 0` を返す(実測)。保留として読むと、承認されたラウンドが収束しない。 4. critical/major + 該当 minor/nit の修正コミット(worktree 内のみ) 5. `./pint-changed.sh && ./larastan-changed.sh` 等の品質チェック -6. push: `git push origin {HEAD_BRANCH}` (--force / --no-verify 禁止) -7. **CI 再実行は待たない**(push 後の `--watch` 等は行わない、`ci_status` は push 時点での既知失敗のみ反映) -8. **各 thread に reply 投稿**: - - 修正済み: 「対応しました — <ファイル>:<行> で〇〇 (commit )」 - - deferred: 「[deferred / nit] 後続 PR で対応予定」 - - rejected: 「bot 指摘は誤読です — 理由: ...」 -9. **修正済み thread を `resolveReviewThread` で Resolve**: - ```bash - # thread_id は GraphQL で取得 - gh api graphql -f query=' - query {{ repository(owner:"...", name:"...") {{ - pullRequest(number: {PR}) {{ reviewThreads(first:100) {{ - nodes {{ id isResolved path line }} - }} }} - }} }}' - # 修正済みのみ resolve - gh api graphql -f query=' - mutation($id: ID!) {{ - resolveReviewThread(input: {{threadId: $id}}) {{ thread {{ isResolved }} }} - }}' -f id="$THREAD_ID" - ``` - - deferred / rejected の thread は **Resolve しない** -10. **PR レベル Summary コメントを投稿**(必須・inline reply とは別物): - ```bash - gh pr comment {PR} --body "$(cat <<'EOMD' - ## 🔧 /ndf:fix サマリ (round N) - - 対応件数: critical=X / major=Y / minor=Z (合計 N 件) - deferred: D 件 / rejected: R 件 - commit: - CI: SUCCESS | FAILURE | NONE - - ### 詳細 - - 各 thread の対応概要(行リンク付き) - EOMD - )" - ``` - - inline reply + Resolve だけでは「PR ページの Conversation タブ」に - まとめが出ず、レビュアー視点で見落とされる。**必ず投稿する** -11. 戻り値ファイル書き出し(下記フォーマット)。`summary_comment_url` には - 手順 10 の URL を入れる +6. コミットする。**送らない**(送信は取り込みが行う) +7. **CI 再実行は待たない**(`ci_status` はコミット時点での既知失敗のみ反映) +8. 各 thread の扱いを戻り値ファイルの配列へ入れる。**返信・決着・まとめは投稿しない** + (取り込みが配列から組み立てる): + - 修正済み: `resolved_threads`(`thread_id` と `comment_id`) + - deferred: `deferred`(`comment_id` と `reason_for_deferral`) + - rejected: `rejected`(`comment_id` と `reason_for_rejection`) +9. 戻り値ファイル書き出し(下記フォーマット)。`summary_comment_url` は書かない + (取り込みがまとめの投稿の応答から記録へ書く) ## 戻り値ファイル $TMP_DIR/fix-pr{PR}-result.json @@ -381,13 +349,15 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > > PR の **全 open review thread**(インライン / レビュー body / PR レベルコメント)を > `gh api` で洗い出し、cross-review の codex/agy が残したものを中心に **すべて解消**せよ: -> 1. 修正可能な `minor`/`nit` → コード修正 + push(同ブランチ、main へは push しない)し、 -> reply + GraphQL `resolveReviewThread` で Resolve。 -> 2. 修正しない(好み・判断保留)`nit` → 「[deferred / nit] 対応見送り: <理由>」を日本語で -> reply した上で **Resolve まで実行**(スレッドを open のまま残さない)。 -> 3. bot 誤指摘 → 却下理由を reply して Resolve。 +> 1. 修正可能な `minor`/`nit` → コード修正 + コミット(**送らない**)し、`resolved_threads` へ入れる。 +> 2. 修正しない(好み・判断保留)`nit` → 見送りの理由を添えて `deferred` へ入れ、 +> **`"resolve": true`** を付ける(スレッドを open のまま残さない)。 +> 3. bot 誤指摘 → 却下理由を添えて `rejected` へ入れ、`"resolve": true` を付ける。 +> +> **GitHub と git へ書かない。** 返信・決着・送信は、メインがこの結果ファイルを読んで +> 共通層の 1 行(`result_posts.py fix`)で行う。 > -> **修正で push した場合は、対象リポジトリの検証を 1 度通すこと。** 何を実行するかは +> **修正をコミットした場合は、対象リポジトリの検証を 1 度通すこと。** 何を実行するかは > 対象リポジトリを見て決める。**コマンドを推測して組み立てない。** > > 1. 実行手段を探す。`Makefile` の `test` / `lint` / `check` ターゲット、`package.json` の @@ -399,7 +369,7 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > 3. 1 つも見つからないときは実行しない > > **終了コードが 0 でない実行を残したまま完了としない。** その修正が原因なら直して -> push し直し、もう一度実行して 0 を確かめる。修正の前から落ちていたなら直さず、 +> コミットし直し、もう一度実行して 0 を確かめる。修正の前から落ちていたなら直さず、 > 何が落ちているかを最終メッセージへ書く(この工程の範囲外である)。どちらの場合も > `commands` には**最後に実行した結果**を残す。 > @@ -408,15 +378,16 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > メッセージへ書く。 > > 完了後、上の**結果ファイル**(`$TMP_DIR/sweep-pr-result.json`)に -> `{"resolved": N, "fixed_in_sweep": M, "commit": "", "remaining_open": K, +> `{"resolved": N, "fixed_in_sweep": M, "commit": "", "fix_commit": "", +> "resolved_threads": [...], "deferred": [...], "rejected": [...], "remaining_open": K, > "remaining_reason": "0 のときの理由|null>", "items": ["<1行要約>", ...], > "verification": {"commands": [{"command": "<実行したコマンド>", "exit": <終了コード>}], > "skipped_reason": "<実行しなかった理由|null>"}}` を > 書き出し、最終メッセージで内訳を日本語報告せよ。 > 検証を実行したときは `commands` に実行順で並べ、`skipped_reason` を `null` にする。 > 実行手段が見つからなかったときは `commands` を空にし、`skipped_reason` に**何を探して -> 見つからなかったか**を書く。push しなかったラウンドも `commands` を空にし、 -> `skipped_reason` に「push なし」と書く。 +> 見つからなかったか**を書く。コミットしなかったときも `commands` を空にし、 +> `skipped_reason` に「コミットなし」と書く。 > **`remaining_open` は 0 とする。** 0 にできない場合は `remaining_reason` に理由を書く。 > この値は申告であり、次の `verify-sweep` が GitHub 側の実数と突き合わせる。 diff --git a/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh b/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh index 3055c62c..12456e1c 100755 --- a/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh +++ b/plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh @@ -98,7 +98,7 @@ fi render_review_prompt() { cat > "$PROMPT" < list[tuple[int, str]]: if not line.lstrip().startswith("#") and any(r in line for r in LONG_RUNNERS)] -def test_the_skeleton_has_the_three_long_runners() -> None: - """レビューの監視・起動し直しの監視・反証の 1 ラウンド。""" +def test_the_skeleton_has_the_two_long_runners() -> None: + """レビューの監視と反証の 1 ラウンド。起動し直しは同じ監視の行を通る(#583)。""" found = _long_runner_lines() - assert len(found) == 3 - assert sum('"$SCRIPTS/monitor.py"' in line for _, line in found) == 2 + assert len(found) == 2 + assert sum('"$SCRIPTS/monitor.py"' in line for _, line in found) == 1 assert sum('"$SCRIPTS/critique-round.sh"' in line for _, line in found) == 1 -@pytest.mark.parametrize("index", range(3)) +@pytest.mark.parametrize("index", range(2)) def test_each_long_runner_is_started_by_bg_wait_run(index: int) -> None: _, line = _long_runner_lines()[index] runner = next(r for r in LONG_RUNNERS if r in line) @@ -54,7 +54,7 @@ def test_each_long_runner_is_started_by_bg_wait_run(index: int) -> None: assert re.search(r'"\$SCRIPTS/bg-wait\.sh" run "[^"]+" -- $', head), line -@pytest.mark.parametrize("index", range(3)) +@pytest.mark.parametrize("index", range(2)) def test_each_run_is_followed_by_one_wait_on_the_same_rc(index: int) -> None: """**待ちは 1 回の呼び出しに 1 つだけ書く。** 繰り返しを 1 回の Bash へ書くと、 2 回目の待ちに入った時点で合計が 600 秒を超え、ホストに打ち切られる(#683 round 2)。""" diff --git a/plugins/ndf/skills/cross-review/tests/test_skill_layout.py b/plugins/ndf/skills/cross-review/tests/test_skill_layout.py index e440a67a..e5ed590d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_skill_layout.py +++ b/plugins/ndf/skills/cross-review/tests/test_skill_layout.py @@ -178,3 +178,40 @@ def test_the_evidence_doc_says_the_mark_is_removed_when_critiques_are_incomplete def test_the_specification_holds_the_same_six_classifications() -> None: """区分の表と行き先の表の両方が `unrefuted` を持つ(AC20)。""" assert SPEC.read_text(encoding="utf-8").count("unrefuted") >= 2 + + +# ---------- 起動し直しは初回と同じ経路を通る(#583 #730) ---------- + + +def _loop() -> str: + """骨組みの繰り返し(`while :; do` から `done` まで)。""" + text = SKILL.read_text(encoding="utf-8") + block = text.split("## 実行ステップ概要(メインの bash 骨組み)", 1)[1] + block = block.split("```bash", 1)[1].split("\n```", 1)[0] + return block.split("while :; do", 1)[1] + + +@pytest.mark.parametrize("step", [ + '"$SCRIPTS/launch-reviewer.sh"', + '"$SCRIPTS/state.py" read-result', + '"$SCRIPTS/state.py" verify-findings', + '"$SCRIPTS/critique-round.sh"', +]) +def test_each_step_of_a_review_is_written_once(step: str) -> None: + """起動し直しの枝が自分の起動・取り込みを持たない。経路は 1 本である(AC18)。 + + 枝が別に持つと、根拠の検証と反証を飛ばして 2 度目の判定へ進む。 + """ + assert _loop().count(step) == 1, step + + +def test_the_relaunch_goes_back_to_the_head_of_the_review() -> None: + loop = _loop() + relaunch = loop.index('"$JUDGE_RC" -eq 7') + assert "continue" in loop[relaunch:loop.index("done", relaunch)] + + +def test_the_queue_branch_is_seen_before_the_relaunch() -> None: + """待ち行列に残りがあるときの枝は、各判定の直後、7 より先に見る(AC19)。""" + loop = _loop() + assert loop.index('"$JUDGE_RC" -eq 8') < loop.index('"$JUDGE_RC" -eq 7') diff --git a/plugins/ndf/skills/fix/SKILL.md b/plugins/ndf/skills/fix/SKILL.md index d9f59fba..abc61fc1 100644 --- a/plugins/ndf/skills/fix/SKILL.md +++ b/plugins/ndf/skills/fix/SKILL.md @@ -79,6 +79,8 @@ python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <戻り値フ このコマンドが現在の頭を送り先へ送り(`git push origin HEAD:<ブランチ名>`)、報告した コミットが送り先に載ったことを確かめてから、返信・決着・まとめを待ち行列を通して送る。 出力は件数と参照だけで、本文を出さない。 +送信が認証で落ちたときの退避(#524)もこのコマンドが行う。値は共通層の +`scripts/lib/git-credential.sh` 1 か所が持つ。 ## コメントの取得(3 ソース) diff --git a/scripts/tests/test_push_fallback_docs.py b/scripts/tests/test_push_fallback_docs.py index a6835002..b5dc1053 100644 --- a/scripts/tests/test_push_fallback_docs.py +++ b/scripts/tests/test_push_fallback_docs.py @@ -19,12 +19,15 @@ REPO_ROOT = Path(__file__).resolve().parents[2] LIB = REPO_ROOT / "plugins/ndf/scripts/lib/git-credential.sh" -# 退避を案内する手順書。`cross-refactoring` は実装が退避するため、案内の形が違う。 +# 退避を案内する手順書。`cross-refactoring` と `fix` は実装が退避するため、案内の形が +# 違う(`fix` の送信は共通層の `result_posts.py` が行う、#730)。 DOCS_WITH_COMMAND = ( "plugins/ndf/skills/pr/SKILL.md", +) +DOCS_WITH_REFERENCE = ( + "plugins/ndf/skills/cross-refactoring/SKILL.md", "plugins/ndf/skills/fix/SKILL.md", ) -DOCS_WITH_REFERENCE = ("plugins/ndf/skills/cross-refactoring/SKILL.md",) def fallback_args() -> list[str]: From 5bfb08e259f593fc21e0d6f85e8126fec0775e7d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 09:48:47 +0000 Subject: [PATCH 180/217] =?UTF-8?q?Docs:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E6=9B=B8=E3=81=A8=E6=8C=87=E7=A4=BA=E6=9B=B8?= =?UTF-8?q?=E3=82=92=201=20=E3=81=A4=E3=81=AE=E5=8F=82=E5=8A=A0=E8=80=85?= =?UTF-8?q?=E3=81=A8=E8=BC=AA=E7=95=AA=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 手順書の担当の決め方を 1 つの表にし、足す者・外す者・全員を要する指定を 引数の表と argument-hint に足す。前提の「すべてログイン済み」を外す - init が返す変数の表から適用専用の母集合を消す - CLAUDE.md の cross-refactoring の節を codex / kiro とホストの既定と、参加者の数で 1 周する輪番に直し、--max-outer-rounds の既定を 3 と書く(#736)。 cross-review の節を席の規則に直す - 実装計画を足し、設計文書の「未確認のまま残ること」に P7 で決めた 6 件を足す Refs #664 #736 #727 Co-Authored-By: Claude Opus 5 (1M context) --- CLAUDE.md | 13 +- ...issue-664-p7-refactor-participants-plan.md | 176 ++++++++++++++++++ issues/issue-727-687-478-664-648-design.md | 11 ++ plugins/ndf/skills/cross-refactoring/SKILL.md | 71 ++++--- .../docs/01-state-and-propose.md | 42 ++--- .../prompts/propose-tests.md | 4 +- .../cross-refactoring/prompts/propose.md | 2 +- .../tests/test_skill_terms.py | 64 ++++++- 8 files changed, 320 insertions(+), 63 deletions(-) create mode 100644 issues/issue-664-p7-refactor-participants-plan.md diff --git a/CLAUDE.md b/CLAUDE.md index e1cf21f0..b6cbb18f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,27 +59,28 @@ python3 plugins/ndf/scripts/instructions-check.py --root . ## cross-refactoring -`/ndf:cross-refactoring` は codex / agy / kiro / claude のうち **ホストを除く 3 者**に構造改善を提案させ、**参加する 4 者**から輪番で選んだ 1 者が適用し、残り 2 者がレビューする。新しい提案が出なくなるまで繰り返す。 +`/ndf:cross-refactoring` は参加者に構造改善を提案させ、同じ参加者から輪番で選んだ 1 者が適用する。新しい提案が出なくなるまで繰り返す。参加者の既定は **codex / kiro とホスト(ホストが codex / kiro なら 2 者)** で、`--exclude` / `--include` で名指しで変える(agy は `--include agy` で戻す)。レビューは最終ゲートの `cross-review` が担う。 ```bash /ndf:cross-refactoring 130 --scope src/services --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=claude-opus-5 +/ndf:cross-refactoring 130 --scope src --include agy --exclude kiro ``` - `--scope` は必須。提案が発散して PR が肥大するのを防ぐ。**検証にも効く**ので、現状固定テストの置き場所も含める - ホストと同じランタイムが適用担当になる場合も、サブエージェントではなく **CLI プロセス**として起動する -- モデルを比べるなら `--model <ランタイム>=` を 4 つとも指定する。実際に動いたモデルを取得できるのは claude だけで、残り 3 つは指定値で代用する。指定が無いラウンドは集計から分離される -- 適用担当は 4 ラウンドで 1 周する。`--max-outer-rounds` の既定が 4 なのは、上限 3 では 4 者目の順番へ届かないため +- モデルを比べるなら `--model <ランタイム>=` を参加者の全員に指定する。実際に動いたモデルを取得できるのは claude だけで、残りは指定値で代用する。指定が無いラウンドは集計から分離される +- 適用担当は参加者の数のラウンドで 1 周する。輪番は適用ラウンドごとに進むため、`--max-outer-rounds`(既定 3)が切る提案の回数とは対応しない - 収束しない改善項目は **項目単位で取り消す**。合意済みの項目は PR に残る。ただし同一ファイルの隣接行を触る項目どうしは git だけでは分離できないため、そのラウンドは全件取り消しへ退避する - 生成物・配布物の同期は **進行側の責務**。実装担当にはさせない(範囲外の変更になる)。同期の手順は `--sync-command "bash scripts/build-runtime-plugins.sh"` のように渡す - 公開するのは **進行側だけ**。実装担当は push しない。進行側が検証を通した後に push するので、未検証の変更が公開されない - 履歴に残るのは **1 改善項目 = 1 コミット**。現状固定テストが要る項目だけ 2 コミット。テストも項目の単位で 1 回だけ求める - 改修計画は `--plan-file`(既定 `issues/refactoring-plan-rf.md`)へ書き出され、生成物の同期と同じコミットで公開される -- `init` が参加 CLI の認証状態を確認する。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` +- `init` が参加者の認証状態を確認し、通らない者を外して続ける。全員が揃わないなら止めたいときは `--require-all`。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` ## cross-review -`/ndf:cross-review` は codex / agy の両方に PR レビューを委譲し、両者が `APPROVE` するまで修正ループを回す。agy の progress log を heartbeat に表示するため、無言に見える時間でも `scan` / `analyze` / `post` / `done` などの作業段階を確認できる。 +`/ndf:cross-review` はホストを除く 3 つのランタイムのうち使える者から毎ラウンド 2 席を選んで PR レビューを委譲し、両席が `APPROVE` するまで修正ループを回す。使える者が 2 者に満たなければ、ホスト、次に同じランタイムの 2 つ目が席を埋める。agy の progress log を heartbeat に表示するため、無言に見える時間でも `scan` / `analyze` / `post` / `done` などの作業段階を確認できる。 追加レビュー観点は以下のどちらかで渡す: @@ -88,4 +89,4 @@ python3 plugins/ndf/scripts/instructions-check.py --root . /ndf:cross-review 123 --extra-instructions-file /tmp/review-focus.md ``` -PR の変更ファイルから docs only / code / DB migration / test / dependency / CI設定 / API契約 / 認証認可 / frontend / performance / deletion / generated / i18n / infra を自動分類し、該当するレビュー観点テンプレートも codex / agy 両方に渡す。 +PR の変更ファイルから docs only / code / DB migration / test / dependency / CI設定 / API契約 / 認証認可 / frontend / performance / deletion / generated / i18n / infra を自動分類し、該当するレビュー観点テンプレートも両席に渡す。 diff --git a/issues/issue-664-p7-refactor-participants-plan.md b/issues/issue-664-p7-refactor-participants-plan.md new file mode 100644 index 00000000..02523452 --- /dev/null +++ b/issues/issue-664-p7-refactor-participants-plan.md @@ -0,0 +1,176 @@ +# cross-refactoring: 担当を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる → 使える者だけで始まり、参加者は codex / kiro とホストを既定に足し引きでき、適用の輪番はその参加者の中で回る(実装計画 P7: cross-refactoring と旧関数の削除 / #664 #736) + +## 関連リンク + +- 親 issue #727、子 issue #664。あわせて #736(リポジトリの根の `CLAUDE.md` の上限の既定の記述) +- 設計: [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md)(決定 20 件。識別子は冒頭の用語の対応表で引く) +- 要求: [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md)(受け入れ条件 AC1〜AC50) +- 契約: [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md)(状態ファイル・引数・関数の形) +- 確定仕様: [cross-review-participants-and-seats.md](../docs/specifications/cross-review-participants-and-seats.md)(共通層と cross-review 側) +- 1 本目の実装: [issue-727-p6-participants-plan.md](issue-727-p6-participants-plan.md)(PR #793、マージ済み) + +## モード + +`standard`。収束ループの初期化と担当の決め方を変え、共通層と cross-refactoring と文書にまたがる。 + +## 目的と非目的 + +達成したい状態: + +- 参加する CLI のどれか 1 者が導入・認証されていなくても、cross-refactoring の初期化が使える者で始まる。使えない者と理由が状態ファイルに残る +- 既定の参加者が codex / kiro とホストになり、足す者・外す者の指定で名指しで変えられる。agy は足す者の指定で戻せる +- 適用の輪番が参加者の中で回る。存在しない役(レビュー担当)の記録と出力が消える +- 中断したループを引数を変えて再開すると、上限は反映され、反映しない引数は知らされる +- 使える者の決め方が共通層の 1 か所だけになり、両 Skill に古い形が残らない(親 #727 の完了条件) + +やらないこと: + +- cross-review 側の変更(1 本目で済んだ)。ただし旧関数に依存する cross-review のテスト 3 件は、関数を消すのに合わせて期待値を書き直す +- 起動した後に分かる使えなさで担当を自動的に外す仕組み(設計の決定 18) +- 引数の型の検査(カンマ区切りの名前と予約語)の共通層への移動。cross-review の状態の部品は並行する束(G5)が触っており、この Pull Request では cross-refactoring の側に同じ規則の型を置く +- 1 者指定を cross-refactoring に足すこと(契約の引数の表に無い) +- 指示書の cross-refactoring の節のうち、参加者と輪番と上限以外の古い行(コミットの単位・改修計画の置き場所・取り消しの単位)。#799 として起票した + +## 前提 + +- 前提 1: 設計文書の決定 20 件は変えない。実装で決めたことは「実装で決めたこと」に書き、設計文書の「未確認のまま残ること」に P7 の節を同じ Pull Request で足す +- 前提 2: 並行する束(G5、#730)は cross-review の投稿の部分と共通層の投稿の待ち行列を触る。旧関数を使う箇所が G5 のブランチに無いことを、消す前に `git grep` で確かめる(着手時点で `origin` に G5 の実装ブランチは無く、`develop` の呼び手はこの Pull Request が置き換える箇所だけだった) +- 前提 3: 新しい参加者(ホストが提案に入ること)の所要は測らない。設計の「未確認のまま残ること」のまま運用へ渡す + +## 受け入れ条件 + +要求文書の AC7、AC31〜AC43、AC47、AC49〜AC50 をそのまま使う。検証手段は設計文書の「テスト設計」の表にある。AC47(#664 の再現手順)は手元で実行し、結果を Pull Request 本文に残す。 + +## ドメイン用語 + +識別子は設計文書の用語の対応表と同じ。この計画で新しく使う語は次の 1 つだけである。 + +| 用語 | 意味 | +| --- | --- | +| 反映の表(cross-refactoring) | 再開で渡した引数ごとに「反映する」か「知らせる」かを決める表。cross-review の表と同じ形で、cross-refactoring の部品が持つ | + +## 不変条件 + +- 状態ファイルの参加者の一覧(`runtimes`)は、参加者の記録の使える者と同じ値である +- 新規の状態ファイルに適用専用の母集合の項目とラウンドのレビュー担当の項目が無い +- この変更の前に始めた実行の状態ファイルを、書き換えずに読める(適用の輪番は参加者の一覧から決まる) +- 使える者が 0 者、または全員を要する指定で欠けがあるとき、状態ファイルを作らない・書き換えない + +## 互換性 + +| 対象 | 変更 | 互換性の扱い | +| --- | --- | --- | +| 初期化の引数 | 足す者・外す者・全員を要する指定を足す。上限と重要度の既定を未指定にする | 追加のみ。未指定のときの既定値は変えない | +| 初期化の出力 | 適用専用の母集合の変数を出さない | 読み手は手順書の表だけ(骨組みは読まない)。手順書から消す | +| ラウンドの開始の出力 | レビュー担当の 2 変数を出さない | 読み手はテストの期待値だけ(設計文書の「実測」) | +| 状態ファイル | 参加者の記録と再開で変えた値の記録を足し、適用専用の母集合とレビュー担当を書かない | 古い状態ファイルは項目が無いまま読む。報告は「記録なし」と出す | +| 共通層の関数 | 従来の確認・適用専用の母集合・従来の席と適用の割り当ての 4 つを消す | 呼び手が 0 件になったことを `git grep` のテストで固定する | + +## 修正対象 + +```text +plugins/ndf/scripts/lib/assignment.py 旧関数 3 つと説明を消す +plugins/ndf/scripts/lib/auth.py 従来の確認を消す +plugins/ndf/scripts/tests/test_lib_assignment.py 旧関数のテストを消す +plugins/ndf/scripts/tests/test_auth_probe.py 従来の確認のテストを消す +plugins/ndf/scripts/tests/test_shared_lib_layout.py 旧関数が残らないことの検査を足す(AC7) +plugins/ndf/skills/cross-refactoring/scripts/refactor.py 引数の追加と既定の変更 +plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py 初期化・再開・ラウンドの開始 +plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py 適用の輪番の包み +plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py 担当の交代の上限 +plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py 母集合の 1 行・参加者の節・列の削除 +plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py 改修計画の見出しからレビュー担当を消す +plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py 観測したモデルの記録からレビュー側の枝を消す +plugins/ndf/skills/cross-refactoring/tests/ test_init.py / test_start_round_emits_runtimes.py / test_assignment.py ほか +plugins/ndf/skills/cross-review/tests/ 旧関数に依存する 3 件の期待値 +plugins/ndf/skills/cross-refactoring/SKILL.md / docs/01-state-and-propose.md +CLAUDE.md cross-refactoring の節と cross-review の節 +issues/issue-727-687-478-664-648-design.md 「未確認のまま残ること」に P7 の節 +``` + +## タスク分解 + +各タスクは失敗するテストを先に書き、通す最小の実装を足し、整える。旧関数の削除(Task 6)は呼び手をすべて置き換えた後に行う。 + +### Task 1: 新規の初期化を共通層の使える者の解決へ載せ替える + +- **対象ファイル:** `refactor.py`、`commands/setup.py`、`tests/test_init.py` +- **変更内容:** 母集合の既定(`refactor_pool`)と使える者の解決(`resolve_participants`)を止めない確認(`probe_auth`)で呼ぶ。状態ファイルへ参加者の一覧と参加者の記録と空の再開の記録を書き、適用専用の母集合を書かない・出さない。割り当ての失敗と 0 者を終了コード 4 へ写す。足す者・外す者・全員を要する指定の引数を足す +- **満たす受け入れ条件:** AC31、AC32、AC33、AC35、AC36、AC47 +- **進め方:** 確認を差し替えた初期化のテストを先に書く + +### Task 2: 適用の輪番を参加者の中で回し、レビュー担当を消す + +- **対象ファイル:** `rounds.py`、`commands/setup.py`、`commands/apply.py`、`gitfacts.py`、`tests/test_start_round_emits_runtimes.py`、`tests/test_apply_attempts.py` +- **変更内容:** 輪番の包み(`impl_for_seq`)の中身を適用の輪番(`impl_assign(seq, state["runtimes"])`)へ替え、ラウンドの開始もこれを使う。ラウンドの記録にレビュー担当を書かず、2 変数を出さない。担当の交代を試す回数を参加者の数にする +- **満たす受け入れ条件:** AC34、AC41 +- **進め方:** 出力と記録にレビュー担当が無いテストを先に書く(既存の期待値を反転する) + +### Task 3: 再開で上限を反映し、他の引数を知らせ、担当に関わる引数で参加者を作り直す + +- **対象ファイル:** `refactor.py`、`commands/setup.py`、`tests/test_init.py` +- **変更内容:** 状態に載る引数の既定を未指定にし、新規の経路で現行の既定へ置き換える。反映の表を置き、再開で共通層の再開の反映(`apply_resume_args`)を呼ぶ。足す者・外す者・全員を要する指定のどれかを渡した再開では、渡さなかった値を記録から補って作り直し、1 件として積む。失敗したら書き換えずに終了コード 4 +- **満たす受け入れ条件:** AC38、AC39、AC40 +- **進め方:** 状態ファイルを置いた作業ツリーで再開するテストを先に書く + +### Task 4: 報告と改修計画の表示を 1 つの母集合に揃える + +- **対象ファイル:** `commands/report.py`、`plan.py`、関連するテスト +- **変更内容:** 状態の表示の母集合を 1 行にし、ラウンド表からレビュー担当と初回承認の列を消す。完了報告に参加者の節を足す(参加者の記録が無ければ「記録なし」)。改修計画の見出しからレビュー担当を消す +- **満たす受け入れ条件:** AC37、AC41 +- **進め方:** 報告と改修計画の出力のテストを先に書く + +### Task 5: 手順書とリポジトリの根の指示書を実装に合わせる + +- **対象ファイル:** `SKILL.md`、`docs/01-state-and-propose.md`、`CLAUDE.md` +- **変更内容:** 担当の決め方を 1 つの表にし、引数の表と `argument-hint` に 3 つの引数を足し、前提の「すべてログイン済み」とホストごとの CLI の表を直す。`init` の変数の表から適用専用の母集合を消す。指示書の cross-refactoring の節を新しい母集合と輪番と上限の既定 3 に直し(#736)、cross-review の節を席の規則に直す +- **満たす受け入れ条件:** AC42、AC43 +- **進め方:** 文書の語の検査(既存のテスト)に期待を足してから直す + +### Task 6: 呼び手の無くなった旧関数を消す + +- **対象ファイル:** `assignment.py`、`auth.py`、共通層のテスト、cross-review と cross-refactoring の旧関数のテスト +- **変更内容:** 従来の確認・適用専用の母集合・従来の席と適用の割り当てを消す。消した関数を期待値に使っていたテストは、変更前の値を定数で持つ形へ直す。4 つの名前が共通層と両 Skill の部品に残らないことをテストで固定する +- **満たす受け入れ条件:** AC7 +- **進め方:** 残っていないことの検査を先に足す(失敗する)→ 消す + +### Task 7: 設計文書を更新し、配布物と検査を通す + +- **対象ファイル:** 設計文書、配布物 +- **変更内容:** 「未確認のまま残ること」に P7 で決めたことを足す。配布物を同期し、全体のテストと 6 つの検査を通す +- **満たす受け入れ条件:** AC49、AC50 +- **進め方:** テスト駆動の対象外(検査の実行) + +## 実装で決めたこと + +| 項目 | 決めたこと | +| --- | --- | +| 状態ファイルの確認の結果の項目(`auth`) | 新規の状態に書かない。読み手が無く、確認を通らなかった者と理由は参加者の記録(`participants.unavailable`)が持つ | +| 確認を行う位置 | 新規の経路で、作業ディレクトリの用意と範囲の関門の後、着手前のテストの前。状態ファイルの有無(新規か再開か)を見てから確かめるためで、再開では担当に関わる引数を渡したときだけ確かめる | +| 反映の表の中身 | 「反映する」は上限 4 つとテストの制限時間。「知らせる」はホスト・範囲・モデル・着手前のテスト・継続的統合の検査の名前・重要度の閾値・同期のコマンド・改修計画のファイル・起動のされ方・作業ディレクトリ root の 10 個。着手前のテストはコマンドで、モデルは全ランタイムの辞書で、作業ディレクトリ root は解決したパスで比べる | +| 引数の型の置き場所 | `--exclude` / `--include` の型(カンマ区切りの名前と予約語 `none`)は cross-refactoring の初期化の部品に置く。共通層へ移すと cross-review の状態の部品も触ることになり、並行する束(G5)と重なる | +| 完了報告の参加者の節 | cross-review と同じ行の形にし、席の埋め合わせの行は持たない(cross-refactoring に席は無い)。ラウンド表からはレビュー担当とモデルの列に加え、レビュー担当の判定から作っていた初回承認の列も消す | +| モデルの警告の対象 | 参加者だけ。既定で外れる agy は、足したときだけ警告する | + +## 影響範囲 + +- cross-refactoring の起動する CLI の集合が変わる(既定で agy が外れ、ホストが提案に入る) +- 担当名の読み手(監視・起動)は参加者の一覧をそのまま使うため変わらない +- 指標の集計(共通層の `metrics.py`)は、古い状態ファイルのレビュー担当を読む経路を残す + +## リスクと対処 + +| リスク | 対処 | +| --- | --- | +| 初期化の関数(`cmd_init`)は新規と再開の 2 経路と確認を 1 関数に持ち、再開の反映を足すと長くなる | タスクごとにテストを通す。再開の経路は別の関数に出す | +| 旧関数を消す時点で、並行する束が新しい呼び手を足している | 消す直前に `develop` と並行する束のブランチを `git grep` で確かめる。消した後の検査テストが継続的統合で拾う | +| 期待値に旧関数を使うテストの意味が変わる | 変更前の値を定数として持ち、テストの主張(3 者のときの席は変更前と一致する)を保つ | + +## 切り戻し手順 + +- この Pull Request を revert すれば元へ戻る。データ移行は無い。新しい形で作った状態ファイルは、戻した後の版では適用専用の母集合が無いため、実行の途中で戻すなら状態ファイルを消して最初から始める + +## 完了の定義 + +- [ ] 上の受け入れ条件をすべて満たし、条件ごとに検証手段と結果が対応している +- [ ] 全体のテスト、配布物の同期の検査、6 つの検査が終了コード 0 で終わる diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md index 16521bb9..80cada95 100644 --- a/issues/issue-727-687-478-664-648-design.md +++ b/issues/issue-727-687-478-664-648-design.md @@ -448,6 +448,17 @@ P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削 | 出力の文言 | 反映した行は `↻ <項目>: <旧> → <新>`、知らせる行は `ℹ --<引数> は再開では反映しません(状態: <値> / 指定: <値>)`、通らなかった者は `⚠ <名前> を担当から外しました(<理由>)`、埋め合わせは `⚠ 使える者が <数> 者のため、席を<相手>で埋めます(観点が減ります)`。既存の初期化の出力の印(`↻` / `ℹ` / `⚠`)に揃える | | テストの置き場所 | テスト設計の表のとおり。席の埋め方は cross-refactoring の割り当てのテスト(変更前の席の割り当ての期待値が同じファイルにある)、起動と監視と計測の席の名前は cross-review の `tests/test_seat_names.py`(新設) | +### P7 の実装で決めた 6 件 + +| 項目 | 決めたこと | +| --- | --- | +| 状態ファイルの確認の結果の項目(`auth`) | 新規の状態に書かない。読み手が無く、確認を通らなかった者と理由は参加者の記録(`participants.unavailable`)が持つ | +| 確認を行う位置 | 新規の経路で、作業ディレクトリの用意と範囲の関門の後、着手前のテストの前。状態ファイルの有無(新規か再開か)を見てから確かめるためで、再開では担当に関わる引数を渡したときだけ確かめる | +| 反映の表の中身 | 「反映する」は上限 4 つとテストの制限時間。「知らせる」はホスト・範囲・モデル・着手前のテスト・継続的統合の検査の名前・重要度の閾値・同期のコマンド・改修計画のファイル・起動のされ方・作業ディレクトリ root の 10 個。着手前のテストはコマンドで、モデルは全ランタイムの辞書で、作業ディレクトリ root は解決したパスで比べる | +| 引数の型の置き場所 | `--exclude` / `--include` の型(カンマ区切りの名前と予約語 `none`)は cross-refactoring の初期化の部品に置く。共通層へ移すと cross-review の状態の部品も触ることになり、並行する束(G5)と重なる | +| 完了報告の参加者の節 | cross-review と同じ行の形にし、席の埋め合わせの行は持たない(cross-refactoring に席は無い)。ラウンド表からはレビュー担当とモデルの列に加え、レビュー担当の判定から作っていた初回承認の列も消す | +| モデルの警告の対象 | 参加者だけ。既定で外れる agy は、足したときだけ警告する | + ## 申し送り(並行する設計との境界) 並行する 4 つの設計と 2 つの issue との境界を、決めた契約と分担で書く。 diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 4a5acd0f..6bf574fc 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -1,7 +1,7 @@ --- name: cross-refactoring description: "Let several CLIs propose, apply, and review refactorings on a PR until no new proposal appears. Use when structural improvement should converge across runtimes(クロスリファクタリング・多AIリファクタリング・収束リファクタリング)." -argument-hint: "[PR番号] --scope PATH... [--host claude|codex|agy|kiro] [--model RT=MODEL] [--baseline-test CMD] [--max-test-rounds N] [--max-outer-rounds N] [--max-fix-rounds N] [--max-items-per-round N] [--ci-check NAME] [--workflow-step]" +argument-hint: "[PR番号] --scope PATH... [--host claude|codex|agy|kiro] [--exclude NAMES] [--include NAMES] [--require-all] [--model RT=MODEL] [--baseline-test CMD] [--max-test-rounds N] [--max-outer-rounds N] [--max-fix-rounds N] [--max-items-per-round N] [--ci-check NAME] [--workflow-step]" allowed-tools: - Bash - Read @@ -44,8 +44,8 @@ allowed-tools: | 語 | 何の単位か | 上限を決めるもの | | --- | --- | --- | -| テスト整備ラウンド | **足すべきテストを集める。** 3 者が提案し、採否を決める | `--max-test-rounds`(既定 2) | -| 提案ラウンド | **構造改善の提案を集める。** 3 者が提案し、採否を決める | `--max-outer-rounds`(既定 3) | +| テスト整備ラウンド | **足すべきテストを集める。** 参加者が提案し、採否を決める | `--max-test-rounds`(既定 2) | +| 提案ラウンド | **構造改善の提案を集める。** 参加者が提案し、採否を決める | `--max-outer-rounds`(既定 3) | | 適用ラウンド | **同時に適用して検証する。** 書き換えるファイルが重ならない項目だけを含む。**上の 2 つのラウンドが共有する** | 同じ群を開き直すのは 2 回まで(引数を持たない固定値)。件数は `--max-items-per-round` が実質の上限 | | 修正ラウンド | **検証の失敗を直す。上の 2 つのラウンドが共有する** | `--max-fix-rounds`(既定 3) | | 改善項目 | 構造改善の提案の 1 件。`<ファイル>#<シンボル>` と兆候で識別する | — | @@ -62,7 +62,7 @@ allowed-tools: | 観点 | 方針 | | --- | --- | | 参加者 | **全員 CLI プロセス。** ホストのサブエージェント機能は使わない。ホストと同じランタイムが実装担当のラウンドでも別プロセスで起動する | -| 役割の分離 | 提案は**ホストを除く 3 者**、適用は**参加する 4 者すべて**。両者は重なるが一致しない | +| 参加者 | **提案と適用を同じ参加者で回す。** 既定は codex / kiro とホストで、`--exclude` / `--include` で名指しで変える。確認を通らない者は外して続ける | | 検証の単位 | **適用ラウンド(群)に対して 1 回。** 判定は `--baseline-test` の合否で決まり、レビュー CLI は起動しない | | 収束しない項目 | **捨てる。** リファクタリングは任意の作業なので、揉める提案を Pull Request に残さない | | コミットの単位 | **1 適用ラウンド = 1 コミット。** テストも適用ラウンドの単位で 1 回だけ求める | @@ -87,6 +87,9 @@ allowed-tools: | `[PR番号]` | 対象の Pull Request | 必須 | | `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | | `--host claude\|codex\|agy\|kiro` | ホストの明示指定。未指定時は環境変数から推定(agy は推定できないため明示する) | 推定 | +| `--exclude NAMES` | 参加者から外す者(カンマ区切り・繰り返し可)。ホストも外せる。再開で `none` を渡すと空へ戻す | なし | +| `--include NAMES` | 参加者に足す者(例: `--include agy`)。再開で `none` を渡すと空へ戻す | なし | +| `--require-all` | 確認を通らない者が 1 者でもいれば中断する(終了コード 4)。付けなければ外して続ける | 外して続ける | | `--model RT=MODEL` | ランタイムごとのモデル。繰り返し指定できる | CLI の既定 | | `--baseline-test CMD` | 着手前と各コミットで実行するテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | | `--max-test-rounds N` | **テスト整備ラウンド**の上限。到達したら採用が残っていても提案ラウンドへ進む | `2` | @@ -105,40 +108,47 @@ allowed-tools: /ndf:cross-refactoring 130 --scope src --baseline-test "pytest -q" --sync-command "make generate" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=claude-opus-5 /ndf:cross-refactoring 130 --scope src --host codex --max-outer-rounds 1 +/ndf:cross-refactoring 130 --scope src tests --baseline-test "pytest -q" --include agy --exclude kiro ``` -**モデルを比べたいなら `--model <ランタイム>=` を 4 つとも指定する。** -実際に動いたモデルを取得できるのは claude だけで、残る 3 者は指定値で代用する。 +**モデルを比べたいなら `--model <ランタイム>=` を参加者の全員に指定する。** +実際に動いたモデルを取得できるのは claude だけで、残る者は指定値で代用する。 指定が無いラウンドは何が動いたか分からないため、集計から分離される (kiro の既定 `auto` も同じ扱いになる)。 ## 担当の決め方 -ホストセッションは**進行の制御に徹し、提案とレビューには参加しない**。 -ただし**適用だけはホストと同じランタイムも担当しうる**。その場合も CLI プロセスとして -起動するため、ホストセッションの作業文脈からは切り離されている。 +ホストセッションは**進行の制御に徹する**。提案と適用はどちらも CLI プロセスとして +起動するため、ホストと同じランタイムが担当するときもホストセッションの作業文脈からは +切り離されている。 -| 母集合 | 定義 | 中身 | +| 参加者(`runtimes`) | 決め方 | ホストごとの既定 | | --- | --- | --- | -| 提案(`runtimes`) | 全ランタイム − ホスト | 常に 3 者 | -| 適用(`impl_capable`) | 全ランタイム | 常に claude / codex / agy / kiro | - -- **適用から外す者はいない。** 4 者はいずれも NDF の配布先で、適用で読ませる - `refactoring` / `tdd-cycle` / `quality-gates` を配っている -- **ホストは適用にだけ参加する。** 提案から外れているので、 - 「実装した者と提案した者が同一モデルにならない」構造は保たれる -- **適用担当は適用ラウンドごとに輪番を進める。** 1 つの提案ラウンドが複数の群を - 持てば、その分だけ輪番も進む。**`--max-outer-rounds` が切るのは提案の回数だけ**で、 - 輪番の 1 周とは対応しない +| 提案と適用の両方 | 既定(codex / kiro とホスト)+ `--include` − `--exclude`。確認を通らない者は外す | claude: claude / codex / kiro、codex: codex / kiro、agy: codex / agy / kiro、kiro: codex / kiro | + +- **agy は既定に入らない。** 起動の失敗が参加者の中で最も多く、提案の所要も最も長かった(#664)。 + 戻すときは `--include agy` を渡す +- **確認を通らない者は外して続ける。** 外した者と理由は状態ファイルと完了報告に残る。 + 全員が揃わないなら始めたくないときは `--require-all` を付ける。使える者が 0 者なら + 中断する(終了コード 4) +- **適用担当は適用ラウンドごとに輪番を進め、参加者の数のラウンドで 1 周する。** + ラウンド 1 は参加者の 2 番目から始まるため、ホストが最初に適用する形にならない。 + 提案者と適用者が同じランタイムになることは避けない(適用の結果はテストと Step 7 が見る) +- **`--max-outer-rounds` が切るのは提案の回数だけ**で、輪番の 1 周とは対応しない。 + 1 つの提案ラウンドが複数の群を持てば、その分だけ輪番も進む +- **レビュー担当はいない。** レビューは Step 7 の `cross-review` が担う 割り当ては `refactor.py start-round` が返し、状態ファイルへ記録する。**再開しても変わらない。** +再開で `--exclude` / `--include` / `--require-all` を渡したときだけ確認をやり直し、 +次に開くラウンドから反映する。上限(`--max-*-rounds` と `--test-timeout`)は再開で渡せば +反映し、状態に載る他の引数は状態と違えば「反映しない」と知らせる。 ## 前提 - `gh` CLI が認証済みで、`jq` と `uv`(または Python 3.10 以上)が使える -- 参加する CLI が**すべてログイン済み**である。`init` が認証状態を確認し、1 つでも - 未認証なら中断する(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま - 担当から脱落するため、確認しないと参加者が欠けた構成のまま進行する) +- 参加者の CLI がログイン済みである。`init` が認証状態を確認し、通らない者を担当から + 外して続ける(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま担当から + 脱落するため、確認しないと参加者が欠けた構成のまま進行する) | ランタイム | 確認コマンド | | --- | --- | @@ -150,16 +160,17 @@ allowed-tools: 確認コマンドは CLI の版で変わりうる。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` で 飛ばせる(飛ばしたことは出力に残る) -- ホストごとに次の CLI が使える(不足していると初期化時に失敗する) +- ホストごとに次の CLI が使える(使えない者は担当から外れる) | ホスト | 必要な CLI | | --- | --- | - | Claude Code | `codex` / `agy` / `kiro-cli` | - | Codex | `claude` / `agy` / `kiro-cli` | - | agy | `claude` / `codex` / `kiro-cli` | - | Kiro CLI | `claude` / `codex` / `agy` | + | Claude Code | `codex` / `kiro-cli` | + | Codex | `kiro-cli` | + | agy | `codex` / `kiro-cli` | + | Kiro CLI | `codex` | - 適用にはホスト自身も参加するため、ホストのコマンドも起動できる必要がある。 + ホスト自身も参加者に入るため、ホストのコマンドも起動できる必要がある。 + `--include` で足した者の CLI も要る。 **agy がホストのときは `--host agy` を明示する**(環境変数からは推定しない) - 対象の Pull Request が Draft で開いている(未作成なら `/ndf:pr` で先に作る) @@ -283,6 +294,8 @@ rf_eval() { rf_eval init "$PR" --scope $SCOPE \ --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ + ${EXCLUDE:+--exclude "$EXCLUDE"} ${INCLUDE:+--include "$INCLUDE"} \ + ${REQUIRE_ALL:+--require-all} \ --max-test-rounds "$MAX_TEST" --max-outer-rounds "$MAX_OUTER" \ --max-fix-rounds "$MAX_FIX" --max-items-per-round "$MAX_ITEMS" \ ${CI_CHECK:+--ci-check "$CI_CHECK"} ${WORKFLOW_STEP:+--workflow-step} \ diff --git a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md index ed457e14..68434085 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md @@ -17,8 +17,7 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" | 変数 | 内容 | | --- | --- | | `ID` | 状態ファイルの鍵(最初に初期化した Pull Request 番号) | -| `RUNTIMES` / `RUNTIMES_CSV` | 提案の母集合(ホストを除く 3 者) | -| `IMPL_POOL` | 適用の母集合(参加する 4 者すべて) | +| `RUNTIMES` / `RUNTIMES_CSV` | 参加者(提案と適用の両方。既定は codex / kiro とホスト) | | `WORKTREE_ROOT` / `WORK` / `TMP_DIR` | 作業ディレクトリと一時ディレクトリ | | `REPO` / `HEAD_BRANCH` / `BASE_BRANCH` / `SCOPE` | 対象の情報 | @@ -26,12 +25,11 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 1. **ホストの確定** — `--host` の明示指定を第一とし、未指定時のみ環境変数 (`CLAUDE_PLUGIN_ROOT` / `CODEX_HOME` / `KIRO_AGENT` など)から推定する。 - 推定できなければ**既定値を置かずに失敗する**。誤検出すると提案の母集合が - 狂う(ホストが提案側に混ざる、参加すべき者が外れる)ため、確定結果は出力と状態 - ファイルの両方へ残す -2. **母集合の確定** — 提案(全 − ホスト)と適用(全)を**別々に**確定する。 - 提案の母集合にホストが含まれていたら初期化ごと失敗させる。 - 適用から外す者はいないが、**関数は分けたまま**にする(ホストを含むか否かが違う) + 推定できなければ**既定値を置かずに失敗する**。誤検出すると参加者が + 狂う(参加すべき者が外れる)ため、確定結果は出力と状態ファイルの両方へ残す +2. **参加者の確定** — 既定(codex / kiro とホスト)に `--include` を足し、`--exclude` を + 除く。**提案と適用は同じ参加者で回す。** 母集合に無い者を外す指定と、足す者と外す者の + 重なりは中断する(終了コード 4) 3. **モデルの確定** — `--model <ランタイム>=<モデル>` を繰り返し受け取る。指定値は **全ラウンドで固定**する。途中で変えると比較が成立しないため、再開時も変えない 4. **書き込み用の作業ディレクトリ作成** — `/work/` に head ブランチを checkout する。 @@ -40,12 +38,14 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 使うと古い HEAD に対して提案・適用してしまう。早送りできない(履歴が分かれた) ときは中断する。`git fetch` に失敗したときも中断する(古い `origin/` へ 早送りして「同期したつもり」になるのを防ぐ) -5. **認証状態の確認** — 参加する CLI を 1 つずつ確認し、未認証なら**初期化ごと中断する** - (終了コード 4)。存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、 - 結果ファイルを残さないまま担当から脱落するが、それでも初期化は成功として扱われるため、 - **参加者が 1 人欠けた構成のまま最後まで進んでしまう**(実測)。作業ディレクトリを - 作る前に確認する。確認コマンドは CLI の版で変わりうるので `NDF_SKIP_AUTH_CHECK=1` - で飛ばせるが、飛ばしたことは必ず出力へ残す +5. **認証状態の確認** — 参加者の CLI を 1 つずつ確認し、通らない者を**担当から外して + 続ける**。外した者と理由は状態ファイルの `participants` に残る。存在確認だけでは + 足りない。未認証の CLI は起動から 15 秒で終わり、結果ファイルを残さないまま担当から + 脱落するが、それでも初期化は成功として扱われるため、**確認しないと参加者が 1 人欠けた + 構成のまま最後まで進んでしまう**(実測)。着手前のテストより先に確認する。 + `--require-all` を付けると 1 者でも通らなければ中断し、使える者が 0 者のときも + 中断する(どちらも終了コード 4 で、状態ファイルを作らない)。確認コマンドは CLI の版で + 変わりうるので `NDF_SKIP_AUTH_CHECK=1` で飛ばせるが、飛ばしたことは必ず出力へ残す 6. **語彙の受け渡し** — 検証側が持つ兆候・手法・重要度の集合を状態ファイルの `vocabulary` へ書く。提案プロンプトはここから**許容値をそのまま列挙する**。 定義を 1 箇所に保ったまま、読ませ方の不確実性を減らすためである @@ -201,7 +201,7 @@ claude 17 本 / codex 1 メソッド / kiro 0 本と揃わなかった。最後 | --- | --- | | いつ | 初期化の後、**最初の提案ラウンドの前** | | 何回 | 新しいテストの提案が出なくなるまで。上限は `--max-test-rounds`(既定 2) | -| 誰が | 提案は 3 者(ホストを除く)。適用は輪番。**提案ラウンドと同じ母集合** | +| 誰が | 提案は参加者の全員。適用は輪番。**提案ラウンドと同じ参加者** | | 何を | 対象範囲のうちテストの薄い経路へ現状固定テストを足す。**構造は変えない** | | 何を足さないか | **このラウンドの後、構造改善の提案で `test_gap` を真にできない。** テストは既に足してある | | 収束の条件 | **採用 0 件**(提案ラウンドと同じ形) | @@ -242,7 +242,7 @@ done `smell`)と同じ役目を持つ。**`level` は鍵に入れない**(同じ経路を別の階層で 2 度 固定させないため)。同じ経路に複数の階層が挙がったときは**低い方**を採る。 -**経路を自由文だけで挙げさせない。** 同じ経路が 3 者から別の言い回しで出て、重複 +**経路を自由文だけで挙げさせない。** 同じ経路が複数の参加者から別の言い回しで出て、重複 排除が効かなくなる。語彙外の値を含む提案は、構造改善の提案と同じく降格して対象外へ 落とす。 @@ -257,9 +257,9 @@ done --stem-template "{agent}-propose-rf{id}-r$ROUND" --phase propose ``` -3 CLI を並列で起動し、同一のプロンプトで提案させる。**提案フェーズにホストは現れない** -(母集合にいないため)。ただし `launch-cli.sh` はホストと同じランタイムを起動しうる -(適用担当のとき)ので、「ホストなら起動しない」といった分岐を入れてはならない。 +参加者の CLI を並列で起動し、同一のプロンプトで提案させる。**ホストと同じランタイムも +参加者として起動する**(CLI プロセスなので、ホストセッションの作業文脈からは切り離されて +いる)。「ホストなら起動しない」といった分岐を入れてはならない。 提出形式は [prompts/propose.md](../prompts/propose.md) にある。 @@ -304,7 +304,7 @@ CLI の起動時に同名の結果ファイルを消すため、**提案の結 `init` が状態ファイルの `vocabulary` へ書き、`launch-cli.sh` が読んで差し込む。 **同じ一覧を 2 か所に書かない。** -**採用上限も同じ経路で渡す**(`--max-items-per-round`)。「3 者の合計で N 件までが +**採用上限も同じ経路で渡す**(`--max-items-per-round`)。「参加者の合計で N 件までが 採用される」と書き、**多く出すより採れる提案を出す**ことを求める。テスト整備ラウンドの プロンプトも同じ形で伝える。 @@ -346,7 +346,7 @@ CLI の起動時に同名の結果ファイルを消すため、**提案の結 鍵は種類で変わる。改善項目は `path` + `symbol` + `smell`、**テスト項目は `target` + `case`** である。 -**採用上限は提案の時点で伝える**(受け入れ条件 C2)。上限を知らせないと、3 者が +**採用上限は提案の時点で伝える**(受け入れ条件 C2)。上限を知らせないと、参加者が 上限を超える件数を出し、超えた分は見送りとして記録されて以後は「対象外」になる。 提案の労力がそのまま無駄になる。 diff --git a/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md b/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md index 1e3c30f5..1a264189 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md @@ -13,7 +13,7 @@ - 作業ディレクトリ: `$RF_WORKDIR`(**ここから外は読まない・書かない**) - 対象範囲: `$RF_SCOPE`(**この範囲の外は提案しない**) - 着手前のテスト: `$RF_BASELINE_TEST` -- 採用上限: 3 者の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます +- 採用上限: 参加者全員の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます ## 手順書 @@ -80,7 +80,7 @@ $RF_VOCAB_LEVELS - `path` は**テストを足す先**のファイル(リポジトリ相対) - `target` は**固定する入口**で、`<ファイル>#<シンボル>` の形で書く -- **`target` + `case` が同じ提案は 1 件へ統合されます。** 3 者が同じ経路を挙げる +- **`target` + `case` が同じ提案は 1 件へ統合されます。** 複数の参加者が同じ経路を挙げる ため、この 2 つが重複排除の鍵になります。**他のランタイムと合意した提案ほど 優先される**ので、独自性を狙わず素直に挙げてください - `level` は鍵に入りません。同じ経路に複数の階層が挙がったときは**低い方**が採られます diff --git a/plugins/ndf/skills/cross-refactoring/prompts/propose.md b/plugins/ndf/skills/cross-refactoring/prompts/propose.md index 621cd774..2deeff18 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/propose.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/propose.md @@ -9,7 +9,7 @@ - 作業ディレクトリ: `$RF_WORKDIR`(**ここから外は読まない・書かない**) - 対象範囲: `$RF_SCOPE`(**この範囲の外は提案しない**) - 着手前のテスト: `$RF_BASELINE_TEST` -- 採用上限: 3 者の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます +- 採用上限: 参加者全員の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます ## 手順書 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py b/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py index ee7c4afd..8277a2a3 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py @@ -58,16 +58,72 @@ def test_every_cap_appears_in_the_argument_table(skill): assert f"`{cap} N`" in skill, f"引数の表に {cap} が無い" -def test_the_defaults_match_the_implementation(refactor, vocabulary, skill): - """既定値は 1 か所(`refactor.py`)が持ち、表はそれを写す。""" +def test_the_defaults_match_the_implementation(cmd_setup, vocabulary, skill): + """既定値は 1 か所(新規の初期化が置き換える表)が持ち、手順書の表はそれを写す。""" assert "| `--max-test-rounds N` | " in skill - for cap, default in (("--max-test-rounds", 2), ("--max-outer-rounds", 3), - ("--max-fix-rounds", 3), ("--max-items-per-round", 5)): + for cap in CAPS: + default = cmd_setup.NEW_RUN_DEFAULTS[cap[2:].replace("-", "_")] row = next(l for l in skill.splitlines() if l.startswith(f"| `{cap} N`")) assert f"`{default}`" in row, f"{cap} の既定が表と実装で食い違う" assert vocabulary.DEFAULT_MAX_TEST_ROUNDS == 2 +# ---------- 参加者と担当の決め方(#727 の AC42 / AC43) ---------- + +PARTICIPANT_ARGS = ("--exclude", "--include", "--require-all") +DOC01 = SKILL.parent / "docs" / "01-state-and-propose.md" +CLAUDE_MD = SKILL.parents[4] / "CLAUDE.md" + + +def test_the_participant_arguments_are_documented(skill): + """AC43 — 引数の表と `argument-hint` に 3 つの引数がある。""" + hint = next(l for l in skill.splitlines() if l.startswith("argument-hint:")) + rows = [l for l in skill.splitlines() if l.startswith("| `--")] + for arg in PARTICIPANT_ARGS: + assert arg in hint, f"argument-hint に {arg} が無い" + assert any(r.startswith(f"| `{arg}") for r in rows), f"引数の表に {arg} が無い" + + +def test_the_assignment_section_has_one_cohort(skill): + """AC43 — 担当の決め方は母集合を 1 つの表で書き、適用専用の母集合を持たない。""" + lines = skill.splitlines() + start = lines.index("## 担当の決め方") + end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) + section = "\n".join(lines[start:end]) + assert "impl_capable" not in section + assert sum(1 for l in lines[start:end] if l.startswith("| ---")) == 1 + assert "codex / kiro" in section and "--include agy" in section + + +def test_the_prerequisites_no_longer_demand_every_cli(skill): + """AC43 — 前提から「すべてログイン済み」が消え、要る CLI は codex / kiro-cli になる。""" + lines = skill.splitlines() + start = lines.index("## 前提") + end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) + section = "\n".join(lines[start:end]) + assert "すべてログイン済み" not in section + assert "| Claude Code | `codex` / `kiro-cli` |" in section + + +def test_init_variables_do_not_list_the_implementation_cohort(): + """AC43 — `init` が返す変数の表に適用専用の母集合が無い。""" + assert "IMPL_POOL" not in DOC01.read_text(encoding="utf-8") + + +def test_claude_md_describes_the_participants_and_the_rotation(): + """AC42 — 指示書の cross-refactoring の節が新しい母集合と輪番を書く(#736 を含む)。""" + text = CLAUDE_MD.read_text(encoding="utf-8") + lines = text.splitlines() + start = lines.index("## cross-refactoring") + end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) + section = "\n".join(lines[start:end]) + assert "codex / kiro とホスト(ホストが codex / kiro なら 2 者)" in section + assert "適用担当は参加者の数のラウンドで 1 周する" in section + for stale in ("ホストを除く 3 者", "参加する 4 者", "codex / agy の両方", + "既定が 4"): + assert stale not in text, f"CLAUDE.md に {stale} が残っている" + + # ---------- 実行のコマンド列(A1 / B6) ---------- def _run_block(text: str) -> str: From bf6923187657ea7c1ac84c59a55340169c5175c5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 09:51:10 +0000 Subject: [PATCH 181/217] =?UTF-8?q?Docs:=20=E6=9B=B8=E3=81=8D=E8=BE=BC?= =?UTF-8?q?=E3=81=BF=E3=81=AE=E6=8B=85=E3=81=84=E6=89=8B=E3=82=92=E7=A7=BB?= =?UTF-8?q?=E3=81=97=E3=81=9F=E6=B1=BA=E5=AE=9A=E3=82=92=20cross-review=20?= =?UTF-8?q?=E3=81=AE=E6=96=87=E6=9B=B8=E3=81=B8=E5=8F=8D=E6=98=A0=E3=81=99?= =?UTF-8?q?=E3=82=8B=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - docs/01: 担当は投稿せず、取り込みが投稿して送信の応答を記録にする節へ書き直した。 申告と実数の突き合わせの節を取り除いた - docs/03: 差分の外を指す指摘は投稿する側が総評へ移すこと、担当に投稿させない理由 - docs/04: 担当が書く 2 ファイルの形(一時の名前と改名の順序)、記録に足した鍵、 投稿の種別ごとの積む側・組み立ての元・照合の鍵 - context-budget: 工夫の 4 番目を「本文はプロセスの中だけを通る」へ書き直した 文書の受け入れ条件(AC25〜AC29)を検査にした。 Co-Authored-By: Claude Opus 5 (1M context) --- .../cross-review/docs/01-state-and-review.md | 31 +++++----- .../cross-review/docs/03-review-output.md | 17 +++--- .../skills/cross-review/docs/04-contracts.md | 56 ++++++++++++++----- .../cross-review/references/context-budget.md | 6 +- .../tests/test_writes_by_conductor_docs.py | 52 +++++++++++++++++ 5 files changed, 122 insertions(+), 40 deletions(-) create mode 100644 plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py diff --git a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md index 7ef66606..2cd4ef6b 100644 --- a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md +++ b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md @@ -154,11 +154,12 @@ eval "$ROUND_VARS" `--head-branch` で受け取った値を書き戻す。ラウンドの開始時にも取り直すのは、作業ツリーの 外で行われた変更に追従するためである。 -## Step 2: レビュー担当 2 者の並列レビュー(AI 直接投稿) +## Step 2: レビュー担当 2 者の並列レビュー **要点**: メインは launcher を **並列バックグラウンド** で起動するだけ。 -各 AI が `gh api` で投稿し `$TMP_DIR/-review-pr-result.json` に -サマリを書く。**ペイロード本体はメイン context に載せない**。 +各担当は **投稿しない。** 指摘の控え(`<席>-review-pr-round-payload.json`)と +結果ファイル(`<席>-review-pr-result.json`)を一時の名前で書き、控え → 結果の順に +改名する。投稿は取り込み(`read-result`)が行う(#730)。**本文はメイン context に載せない**。 ### 2.1 launcher 起動 + monitor @@ -222,23 +223,21 @@ done `state.rounds[-1].<席の名前>` に `intent / posted_as / comments / review_url / by_severity` を分離保存する(席の名前の形は `04-contracts.md`)。 -#### 申告されたコメント数を GitHub 側と突き合わせる +#### 取り込みがレビューを投稿する -投稿は **AI 自身が `gh api` で行う**ため、失敗しても結果ファイルの申告だけは残る。申告のまま進むと、 -修正担当が読むべき指摘が GitHub 上に存在しないまま収束判定まで走る。実測では、2 件の申告に対し -スレッドが 1 つも作られていなかった。 +**書き込みと記録を 1 つの部分命令に閉じる**(#730)。`read-result` は「控えを読む → 投稿を +積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」の順に進む。記録の参照は送信の +応答から、件数(`comments`)は送れたインラインの数から取る。**担当の申告を GitHub の実数と +突き合わせる処理は無い**(投稿する側と記録する側が同じになったため)。標準出力に足すのは +件数・参照・状態の行(`POSTED review_url=` / `INLINE= BODY= QUEUED=` / `FINDINGS=`)だけである。 -`read-result` は申告が 1 件以上のとき、`review_url` の識別子から -`repos//pulls//reviews//comments` を数えて突き合わせる。 - -| 申告 | GitHub 側 | 扱い | +| 止まった場所 | 待ち行列の項目 | 立て直し | | --- | --- | --- | -| 0 件 | 見に行かない | 突き合わせる相手がいない | -| n 件 | n 件以上 | 採用する。人の追記など申告以外の経路で増えうる | -| n 件 | n 件未満 | **中断する。** 投稿が届いていない | -| n 件 | 取得できない | 申告を採用し、確認できなかったことを出力へ残す | +| 送る前(上限などで送れていない) | 残る | 判定の終了コード 8 の枝が流し直す | +| 送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。照合(ラウンドと席までの前方一致)が先客を見つけ、増えない | -**「取得できなかった」と「0 件」を区別する。** 取得の失敗で止めると、GitHub 側の一時的な不調でループが進まなくなる。 +本文の先頭行は `## 🤖 cross-review | round | <席> | <本来の判定>` である。自分の +Pull Request では送る形だけを `COMMENT` へ落とし、記録の `intent` は本来の判定のまま残す。 **誰がレビューし、いつ止めるかは [05-pool-and-convergence.md](05-pool-and-convergence.md) にある。** 母集合・担当の輪番・認証の確認と、終了基準の 3 つの層をそこで定める。 diff --git a/plugins/ndf/skills/cross-review/docs/03-review-output.md b/plugins/ndf/skills/cross-review/docs/03-review-output.md index b070b56b..2927ae40 100644 --- a/plugins/ndf/skills/cross-review/docs/03-review-output.md +++ b/plugins/ndf/skills/cross-review/docs/03-review-output.md @@ -41,10 +41,11 @@ body 先頭に必ず以下を入れる: コード引用ブロック(``` ... ```)や現状説明だけのコメントは作らない。 **インラインは PR の差分に含まれる行にしか付かない。** 差分外の行を指定すると GitHub が -`HTTP 422 Line could not be resolved` を返し、**インラインだけでなくレビュー本体も投稿 -されない**(指摘が丸ごと失われ、PR 上には何も残らない)。差分に無い箇所を指摘するときは -body に「ファイル名:行 + 指摘」の形で書く。422 が返ったら該当インラインを body へ移して -再投稿する。 +`HTTP 422` を返し、**インラインだけでなくレビュー本体も作られない**(要求ごとに全件が拒まれ、 +応答はどのインラインが原因かを指さない)。担当はこれを気にせず、指す行が分かる指摘には +`path` と `line` を書く。**投稿する側(取り込み)が、拒まれた要求のインラインをすべて総評へ +移して送り直す。** 移す契機は応答が行やファイルを解決できないこと(`could not be resolved`)を +示すときだけで、判定の値や基準のコミットの誤りによる 422 は失敗として止める(#730)。 ### 3. body(総評)に書かないこと @@ -83,9 +84,11 @@ pint / larastan / test / build などは **中断** を原則とする。 ## アンチパターン - ❌ **修正をメインセッション内で行う** — context が一気に膨れる。必ずサブエージェント -- ❌ **AI に Markdown だけ返させる** — メインがパース・投稿する設計は禁物。AI 直接投稿 -- ❌ **result.json の申告だけで判定を進める** — 投稿が失敗しても件数は残る。GitHub 側の - 実数と突き合わせないと、修正担当が読むべき指摘が存在しないまま収束する +- ❌ **担当に GitHub へ投稿させる** — 投稿と記録を別の相手が行うと、担当が途中で止まった + ときに投稿だけが残り、記録には無い。起動し直した担当が同じ論点をもう一度投稿する(#583)。 + 担当は指摘の控えと結果ファイルを書くだけにし、取り込みが待ち行列を通して送る(#730) +- ❌ **本文をメインの応答へ載せて投稿する** — 投稿は結果ファイルを読んだプロセス(取り込み)の + 中で組み立てる。メインが読むのは件数と参照だけ - ❌ **nit を都度ユーザに問う** — ループ中は deferred 記録のみ。最終スイープ (Step 7.5) で Resolve - ❌ **未解決スレッドを残したまま終了する** — approved/max_rounds 等いずれの終了経路でも Step 7.5 の最終スイープを必ず実行し、open review thread 0 で終える。特に **最終 APPROVE diff --git a/plugins/ndf/skills/cross-review/docs/04-contracts.md b/plugins/ndf/skills/cross-review/docs/04-contracts.md index fae7ca2b..05e7a3ea 100644 --- a/plugins/ndf/skills/cross-review/docs/04-contracts.md +++ b/plugins/ndf/skills/cross-review/docs/04-contracts.md @@ -203,8 +203,12 @@ あいだは、両者が承認しても収束させない([01-state-and-review.md](01-state-and-review.md) の Step 3 参照) - `viewer_login` — 自分のログイン名。一度取って持つ控えで、待ち行列の冪等の照合が 「投稿者が自分か」を見るために使う -- `rounds[].codex.queued` — その結果の投稿を待ち行列へ積んだかどうか。真のあいだは - 届いたことの照会を飛ばす([01-state-and-review.md](01-state-and-review.md) の待ち行列の節参照) +- `rounds[].codex.queued` — 取り込みが送ったレビューが上限で送れず、待ち行列に残っているか。 + 流した直後に参照を書き戻して偽にする([01-state-and-review.md](01-state-and-review.md) の待ち行列の節参照) +- `rounds[].codex.review_url` — 送信の応答が返した参照。流し直しで先客が見つかったときは先客の参照 +- `rounds[].codex.posted_inline` / `posted_body` — インラインとして送れた件数と、差分の外を + 理由に総評へ移した件数(#730)。`comments` は `posted_inline` と同じ値 +- `rounds[].fix.summary_comment_url` — 修正のまとめの投稿の応答が返した参照(#730) - `rounds[].verdict` の `queued` — 通ったが待ち行列に投稿が残っているラウンド。収束させない - `sweep` — 最終スイープ後の検証結果。`remaining_open` は GitHub 側で数え直した実数で、 `declared_remaining_open` は結果ファイルの申告値。両者が食い違う場合は実数を採る @@ -231,17 +235,19 @@ launcher が生成するプロンプトに以下を強制している: - **headRefOid (commit_id) を明示**: AI が自前で取得すると baseRefOid を誤って入れる事故が多発 - **作業 worktree の絶対パス**: 「ファイル読み取りは必ず worktree 配下の絶対パスを使う」(実 path は state.json の `worktree_path` を参照。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決) -- **event ダウングレード警告**: `event_downgrade=true` のときは payload の `event` を `COMMENT` に +- **投稿の手順を持たない**(#730): 担当は投稿しない。判定の格下げ(`event_downgrade`)も + 担当へ渡さず、投稿する側が送信の時点で行う - **既存コメント差分**: `$TMP_DIR/cross-review-pr-existing-comments.txt` を読んで重複指摘禁止 - **自動レビュー観点**: GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、`common` / `docs_only` / `code` / `db_migration` / `test` / `dependency` / `config_ci` / `api_contract` / `auth_security` / `frontend` / `performance` / `deletion_rename` / `generated` / `i18n` / `infra` の該当テンプレートを state.json の `auto_review_instructions` に保存する - **手動追加レビュー観点**: `--focus` / `--extra-instructions-file` が指定されていれば state.json の `manual_extra_review_instructions` に保存し、自動テンプレートの後ろに連結した `review_instructions` を codex / agy 両 launcher が同じ「追加レビュー観点」セクションとしてプロンプトに差し込む - **進捗マーカー**: agy には `$TMP_DIR/agy-review-pr-progress.log` へ短いフェーズ名を追記させ、monitor の heartbeat で表示する。内部推論や長文説明は書かせない -- **review body 先頭 prefix**: +- **review body 先頭 prefix**(投稿する側が組み立てる): ``` - ## 🤖 cross-review | round | | + ## 🤖 cross-review | round | <席> | ``` `` は **本来の intent**(`posted_as` ではない)。 例: 自分PR で REQUEST_CHANGES を COMMENT にダウングロードしても、prefix は `REQUEST_CHANGES` のまま。 + 二度書かない照合はこの行の**席まで**の前方一致を鍵にする(判定の語を含めない) - **出力禁止事項**(SKILL.md「レビュー出力の制約」と一致): - 「良い点」「Strengths」などの褒めセクションを body に書かない - 修正アクションを伴わないインラインコメントは作らない(nit はインライン化しない) @@ -250,24 +256,27 @@ launcher が生成するプロンプトに以下を強制している: ## AI が書き出すファイル契約 -各 launcher は AI に以下 2 ファイルの書き出しを指示する: +各 launcher は AI に以下 2 ファイルの書き出しを指示する。**どちらも一時の名前(末尾 +`.tmp`)で書き終えてから、控え → 結果ファイルの順に改名させる**(#730)。結果ファイルが +正式の名前で現れたことが、2 つとも書き終えた印になる。控えだけが正式の名前で結果ファイルが +無い状態は、結果なしとして扱い投稿を 0 件にする。 | ファイル | 内容 | |---|---| -| `$TMP_DIR/-review-pr-result.json` | `{event, posted_as, comments_count, review_url, by_severity}` のサマリ | -| `$TMP_DIR/-review-pr-round-payload.json` | `{comments: [{path, line, body, severity, evidence, falsification, suggested_check, posted_to}, ...]}` | +| `$TMP_DIR/<席>-review-pr-result.json` | `{event, by_severity}`。担当が書くのはこの 2 つだけ | +| `$TMP_DIR/<席>-review-pr-round-payload.json` | `{summary, comments: [{path, line, body, severity, evidence, falsification, suggested_check}, ...]}` | -**`comments[]` が持つのは、その担当が出した指摘の全件である**(#156)。投稿した -インラインの写しではない。**差分の外を指すために総評へ書いた指摘も、`HTTP 422` で総評へ -移した指摘も載る。** そのため `result.json` の `comments_count`(投稿したインラインの数) -とは一致しない。 +**`comments[]` が持つのは、その担当が出した指摘の全件である**(#156)。位置を持つ指摘は +インラインとして送られ、位置を持たない指摘と、差分の外を理由に拒まれた要求の指摘は総評へ +入る。送れた先(`posted_to`)は投稿する側が控えへ書き戻す。記録の `comments` は送れた +インラインの数で、指摘の件数とは一致しない。 | 項目 | 何を書くか | 無いときの扱い | | --- | --- | --- | | `evidence` | 根拠。対象のコードと到達経路 | 空。`has_evidence` が偽になる | | `falsification` | 反証条件。これが成り立てば棄却できる | 同上 | | `suggested_check` | 実行できる検証手順 | 空 | -| `posted_to` | `inline` / `body` のどちらへ投稿したか | `inline` として扱う | +| `posted_to` | `inline` / `body` のどちらへ送れたか。**投稿する側が書く** | `inline` として扱う | **4 項目を持たない指摘も捨てない。** 捨てると、対応していない担当の指摘が記録から消える。 @@ -291,8 +300,25 @@ launcher が生成するプロンプトに以下を強制している: **落とすのは、書き込む中身が確定した後である。** 読めなかった再実行が、一度取り込めて いた記録を消さないようにする。 -`/ndf:pr-review` の result.json 出力規約に `posted_as` フィールドを含むこと -(自分PR ダウングレード時に GitHub に実際送った event。デフォルトは `event` と同値)。 +## 投稿の種別ごとの契約 + +**GitHub へ書くのはレビューを回す側だけで、すべて待ち行列(`scripts/lib/post_queue.py`)を +通る**(#730)。組み立てと送信は共通層の `scripts/lib/result_posts.py` が持つ。送る前に同じ +ものが先にあるかを照合し、あれば送らずに先客を応答として返す。 + +| 種別 | 積む側 | 組み立ての元 | 二度書かない照合の鍵 | +| --- | --- | --- | --- | +| `review-post` | 指摘の取り込み(`read-result`) | 指摘の控えと結果ファイル | 投稿者と、本文の先頭行の `## 🤖 cross-review \| round \| <席> \|` までの前方一致(判定の語を含めない) | +| `review-reply` | 修正の取り込み(`merge-fix`)/ 単独の `fix` | 修正の結果ファイルの `resolved_threads` / `deferred` / `rejected` | 返信先の指摘の識別子と、本文の先頭 80 文字 | +| `thread-resolve` | 同上 | `resolved_threads`(と、`resolve` が真の見送り・却下) | スレッドの識別子と、すでに決着しているかどうか | +| `pr-comment` | 同上(修正のまとめ)/ 巻き直し(`rotate-pr.sh`) | 修正の結果ファイルの件数とコミット | 投稿者と、本文の先頭 80 文字(まとめはラウンドとコミットを含む) | + +**差分の外を指すインラインで拒まれたら、その要求のインラインをすべて総評へ移して送り直す。** +契機は応答の `errors` が `could not be resolved` を含むときだけで、ほかの 422 は失敗として +止める。すでに決着したスレッドの決着をもう一度送っても失敗にならない(実測)。 + +修正の送信は `git push origin HEAD:<ブランチ名>` で行い、戻り値ファイルの `fix_commit` が +送り先に載ったことを確かめる。載っていなければ取り込みは失敗として止まる。 ## 監視と計測が残すファイル diff --git a/plugins/ndf/skills/cross-review/references/context-budget.md b/plugins/ndf/skills/cross-review/references/context-budget.md index 8dd941d6..7c513dbb 100644 --- a/plugins/ndf/skills/cross-review/references/context-budget.md +++ b/plugins/ndf/skills/cross-review/references/context-budget.md @@ -20,8 +20,10 @@ state.json と result.json だけ読む 2. **サブエージェント分離**: 修正は別 context window で実行 3. **PR ローテーション**: 1 PR あたりの会話履歴を抑える -4. **AI 直接投稿**: 中間ペイロードがメインを通らない +4. **投稿はプロセスの中で組み立てる**: 投稿の本文は担当が書いたファイルから取り込み + (`state.py read-result` / `merge-fix`)のプロセスの中だけを通り、メインの応答には件数・ + 参照・状態だけが載る 5. **state.json で再開可能**: メインが落ちても次回起動時に続きから 手順を変えるときは、この 5 つのどれかを崩していないかを確かめる。特に 1 と 4 は、 -外部 AI の出力をメインが読んで整形する形へ戻すと簡単に崩れる。 +担当の出力をメインが読んで整形する形や、本文を部分命令の引数で渡す形へ戻すと簡単に崩れる。 diff --git a/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py b/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py new file mode 100644 index 00000000..b9006590 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py @@ -0,0 +1,52 @@ +"""GitHub と git へ書くのをレビューを回す側だけにした決定が、文書に反映されている(#730)。 + +| 受け入れ条件 | 文書 | 消える記述 | 入る記述 | +| --- | --- | --- | --- | +| AC25 | `SKILL.md` の設計方針の表 | 担当が `gh api` で直接投稿する | 投稿の担い手とその理由 | +| AC26 | `docs/03-review-output.md` | 担当の直接投稿の決定 | 待ち行列を通す形 | +| AC27 | `docs/02-fix-and-rotation.md` | 担当の送信の行 | 取り込む側の送信 | +| AC28 | `references/context-budget.md` | 中間ペイロードがメインを通らない | 本文がプロセスの中だけを通る | +| AC29 | `docs/04-contracts.md` | — | 投稿の種別ごとの契約 | +""" +from __future__ import annotations + +import pathlib + +import pytest + +HERE = pathlib.Path(__file__).resolve().parent.parent + + +def _read(rel: str) -> str: + return (HERE / rel).read_text(encoding="utf-8") + + +def test_the_policy_table_names_who_posts() -> None: + text = _read("SKILL.md") + assert "AI 自身が `gh api` で PR に直接投稿" not in text + assert "| 投稿の担い手 |" in text + + +def test_the_review_output_goes_through_the_queue() -> None: + text = _read("docs/03-review-output.md") + assert "AI 直接投稿" not in text + assert "待ち行列" in text + + +def test_the_fix_procedure_does_not_push() -> None: + text = _read("docs/02-fix-and-rotation.md") + assert "git push origin {HEAD_BRANCH}" not in text + assert "HEAD:<ブランチ名>" in text + + +def test_the_context_budget_keeps_bodies_in_the_process() -> None: + text = _read("references/context-budget.md") + assert "中間ペイロードがメインを通らない" not in text + assert "プロセスの中だけを通り" in text + + +@pytest.mark.parametrize("kind", ["review-post", "review-reply", "thread-resolve", "pr-comment"]) +def test_the_contract_lists_each_kind_of_post(kind: str) -> None: + text = _read("docs/04-contracts.md") + section = text.split("## 投稿の種別ごとの契約", 1)[1].split("\n## ", 1)[0] + assert f"| `{kind}` |" in section From 5900ef07f250ec10137c0400a25bde07359798bb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:06:01 +0000 Subject: [PATCH 182/217] =?UTF-8?q?Test:=20=E5=A2=83=E7=95=8C=E5=88=86?= =?UTF-8?q?=E5=B2=90=E3=81=AE=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 再開時の参加者追加、少人数での適用担当輪番、空ラウンドの進行判定を固定する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_lib_assignment.py | 10 ++++++++++ .../skills/cross-refactoring/tests/test_init.py | 14 ++++++++++++++ .../skills/cross-refactoring/tests/test_rounds.py | 10 ++++++++++ 3 files changed, 34 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index 73400c8c..23f2b67c 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -81,6 +81,16 @@ def test_impl_assign_rotates_over_the_participants_starting_after_the_host(assig assert actual == ["codex", "kiro", "claude", "codex", "kiro", "claude"] +def test_impl_assign_with_one_participant_always_returns_that_participant(assignment): + participants = ["codex"] + assert [assignment.impl_assign(r, participants) for r in (1, 2)] == ["codex", "codex"] + + +def test_impl_assign_with_two_participants_rotates_between_them(assignment): + participants = ["codex", "kiro"] + assert [assignment.impl_assign(r, participants) for r in (1, 2)] == ["kiro", "codex"] + + def test_impl_assign_rejects_a_bad_round(assignment): with pytest.raises(assignment.AssignmentError): assignment.impl_assign(0, ["claude", "codex"]) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index a63d844e..dedcfc1d 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -612,6 +612,20 @@ def test_resume_with_exclude_rebuilds_the_participants(run_init, tmp_path): assert changes[0]["from"]["available"] == ["claude", "codex", "agy", "kiro"] +def test_resume_with_include_adds_the_participant_worktree(run_init, tmp_path): + """足す者を渡した再開では参加者と作業ツリーの対応をともに補う。""" + run_init(_args(tmp_path), probe={}) + + run_init(_args(tmp_path, include=[["agy"]]), probe={}) + + _, state = _state_of(tmp_path) + assert state["runtimes"] == ["claude", "codex", "agy", "kiro"] + assert state["worktrees"]["agy"] == str(tmp_path / "rf130" / "agy") + changes = state["resume_changes"] + assert [change["field"] for change in changes] == ["participants"] + assert changes[0]["to"]["available"] == state["runtimes"] + + def test_resume_with_none_clears_the_recorded_names(run_init, tmp_path): """予約語 `none` は記録の一覧を空へ戻す(決定 15)。""" run_init(_args(tmp_path, exclude=[["kiro"]]), probe={}) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py index 7017e233..d6adf114 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py @@ -79,6 +79,16 @@ def test_start_round_stops_when_already_final(refactor, tmp_path, env_tmp_dir): # ---------- advance(収束判定) ---------- +def test_advance_with_no_rounds_leaves_the_state_unchanged(cmd_report, tmp_path, env_tmp_dir): + state_path = make_state(tmp_path, rounds=[], final=None) + env_tmp_dir(state_path) + before = read_state(state_path) + + cmd_report.cmd_advance(_args()) + + assert read_state(state_path) == before + + def test_advance_continues_when_progress_is_made(cmd_report, tmp_path, env_tmp_dir): state_path = make_state(tmp_path, rounds=[round_of(1)]) env_tmp_dir(state_path) From da54b7665818bcdd0db0f52421afa5859cbe89d4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:12:42 +0000 Subject: [PATCH 183/217] =?UTF-8?q?Test:=20=E5=BC=95=E6=95=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90=E3=81=A7=20none=20=E3=81=A8=E5=90=8D=E5=89=8D?= =?UTF-8?q?=E3=81=AE=E6=B7=B7=E5=9C=A8=E3=82=92=E6=8B=92=E3=82=80=E5=88=86?= =?UTF-8?q?=E5=B2=90=E3=81=AE=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit cmd_init において --exclude や --include に none と通常のランタイム名が同時に指定された場合に終了コード 4 で処理が中断され、状態ファイルが作成されない振る舞いを固定する。 Item-Id: R1-002 Round: 1 Impl-Runtime: agy Impl-Model: default --- .../ndf/skills/cross-refactoring/tests/test_init.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index dedcfc1d..f5cb0ba6 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -234,6 +234,18 @@ def test_contradicting_names_stop_the_init(run_init, tmp_path, over): assert not _state_path(tmp_path).exists() +@pytest.mark.parametrize("over", [ + {"exclude": [["none", "kiro"]]}, + {"include": [["none", "agy"]]}, +]) +def test_none_mixed_with_runtime_names_stops_the_init(run_init, tmp_path, over): + """none とランタイム名の混在は中断(終了コード 4)し、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, **over), probe={}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + + def test_init_records_models(run_init, tmp_path): run_init(_args(tmp_path, model=["codex=gpt-5.5", "kiro=claude-opus-5"])) _, state = _state_of(tmp_path) From a81839ab1b2f4d14ebc4c31d78d9018f6f5ed5f9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:13:13 +0000 Subject: [PATCH 184/217] =?UTF-8?q?Test:=20=E6=8A=95=E7=A8=BF=E5=BE=85?= =?UTF-8?q?=E3=81=A1=E8=A1=8C=E5=88=97=E3=81=AE=E5=88=86=E5=B2=90=E3=82=92?= =?UTF-8?q?=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit APIレート制限時のレビュー保留と、Queue.dropの一致・不一致の振る舞いを固定する。 Item-Id: R1-001 Round: 1 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_post_queue.py | 23 +++++++++++++++ .../ndf/scripts/tests/test_result_posts.py | 28 +++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_post_queue.py b/plugins/ndf/scripts/tests/test_post_queue.py index 827bfa66..227e5e90 100644 --- a/plugins/ndf/scripts/tests/test_post_queue.py +++ b/plugins/ndf/scripts/tests/test_post_queue.py @@ -128,6 +128,29 @@ def test_flush_stops_at_corrupt_json_and_keeps_following_items( ] +def test_drop_removes_only_the_item_with_the_requested_sequence( + tmp_path: pathlib.Path, +) -> None: + """現状固定。指定した連番の項目だけを取り除く。""" + paths = [_write_item(tmp_path, seq) for seq in range(1, 4)] + queue = post_queue.Queue(tmp_path) + + assert queue.drop(2) is True + assert [path.name for path in queue.paths()] == [paths[0].name, paths[2].name] + + +@pytest.mark.parametrize("seq", [None, 99]) +def test_drop_keeps_items_when_the_sequence_does_not_match( + tmp_path: pathlib.Path, seq: int | None +) -> None: + """現状固定。連番が無い場合は何も取り除かない。""" + paths = [_write_item(tmp_path, item_seq) for item_seq in range(1, 3)] + queue = post_queue.Queue(tmp_path) + + assert queue.drop(seq) is False + assert [path.name for path in queue.paths()] == [path.name for path in paths] + + def test_post_succeeds_directly_when_queue_is_empty( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index 056fd6ec..eddaf1d1 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -222,6 +222,11 @@ def _post_review(tmp_path, **kw): } _ACCEPT = {"match": "pulls/730/reviews", "stdout": json.dumps( {"id": 99, "html_url": "https://x/pull/730#pullrequestreview-99"})} +_RATE_LIMITED = { + "match": "pulls/730/reviews", "exit": 1, + "stdout": json.dumps({"message": "API rate limit exceeded"}), + "stderr": "gh: API rate limit exceeded (HTTP 429)\n", +} def test_the_inlines_move_to_the_summary_when_the_position_is_not_resolved( @@ -260,6 +265,29 @@ def test_another_rejection_of_the_same_status_is_not_moved(tmp_path, fake_gh) -> assert outcome.review_url is None +def test_a_rate_limited_review_remains_queued_without_marking_the_note( + tmp_path, fake_gh) -> None: + """現状固定。上限時は失敗にせず、未投稿の要求と控えをそのまま残す。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _RATE_LIMITED, + ]) + + outcome, payload = _post_review(tmp_path) + + assert outcome.queued == 1 + assert outcome.failed is False + assert outcome.review_url is None + assert outcome.posted_inline == 0 + assert outcome.posted_body == 0 + note = json.loads(payload.read_text(encoding="utf-8")) + assert all("posted_to" not in comment for comment in note["comments"]) + queued = _queue(tmp_path).items() + assert len(queued) == 1 + assert queued[0][1]["kind"] == "review-post" + assert queued[0][1]["attempts"] == 1 + + def test_a_review_that_is_already_on_github_is_not_posted_again( tmp_path, fake_gh) -> None: """投稿の後・記録の前に止まった実行をやり直しても、レビューは増えない(AC12)。""" From 1267db4a60c8ae6606dd8311a65664e5d7e9fd33 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:21:36 +0000 Subject: [PATCH 185/217] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=20=E2=80=94=20refactor.py=20init=20=E3=81=AE=20--incl?= =?UTF-8?q?ude/--exclude=20=E3=81=AE=E6=8B=92=E5=90=A6=E7=B5=8C=E8=B7=AF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 空の --include が argparse の型で終了コード 2 になり初期化へ進まないこと、 none と実行者名を混在させた --exclude が終了コード 4 で中断し状態ファイルを 作らないことを現状固定テストとして追加する。対象コードは変更しない。 Item-Id: R1-004 Round: 1 Impl-Runtime: kiro Impl-Model: default --- .../cross-refactoring/tests/test_init.py | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index f5cb0ba6..e5d8cdaa 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -402,6 +402,36 @@ def test_include_and_exclude_parse_names_and_none(patch_lib, refactor, monkeypat assert e.value.code == 2 +@pytest.mark.parametrize("empty", ["", " ", ","]) +def test_empty_include_is_rejected_before_init_runs( + patch_lib, refactor, monkeypatch, empty): + """R1-004 — 空の `--include` は argparse の型が弾き、初期化へ進まない。 + + `runtime_list` が空の値で `ArgumentTypeError` を上げ、argparse が終了コード 2 で + 止める。`cmd_init` は差し替えた入口を通らないため、捕えた引数は空のままになる。 + """ + captured = {} + monkeypatch.setattr(refactor, "cmd_init", + lambda args: captured.update(vars(args))) + monkeypatch.setattr( + refactor.sys, "argv", + ["refactor.py", "init", "130", "--scope", "src", "--host", "claude", + "--baseline-test", "true", "--include", empty], + ) + with pytest.raises(SystemExit) as e: + refactor.main() + assert e.value.code == 2 + assert captured == {}, "初期化処理へ進んでいる" + + +def test_none_mixed_with_a_runtime_name_in_exclude_stops_the_init(run_init, tmp_path): + """R1-004 — none と実行者名を混在させた `--exclude` は中断し、状態を作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, exclude=[["none", "kiro"]]), probe={}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + + def test_the_ci_check_is_not_set_by_default(patch_lib, refactor, monkeypatch): """指定が無ければ代替しない。**手元のテストで判定する**(決定 7 の排他)。""" assert _parsed_init_args(patch_lib, refactor, monkeypatch)["ci_check"] is None From 1845c1a0d6e0a6d6201e725a122651a81766aca8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:23:45 +0000 Subject: [PATCH 186/217] =?UTF-8?q?Test:=20=E9=80=81=E4=BF=A1=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=81=A8=E5=BE=85=E3=81=A1=E8=A1=8C=E5=88=97?= =?UTF-8?q?=E3=81=AE=E4=BD=8D=E7=BD=AE=E3=81=AE=E6=8B=92=E5=90=A6=E3=82=92?= =?UTF-8?q?=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=20=E2=80=94=20plugins/ndf/?= =?UTF-8?q?scripts/lib/result=5Fposts.py#push=5Ffix,=20plugins/ndf/scripts?= =?UTF-8?q?/lib/post=5Fqueue.py#rejected=5Fby=5Fposition?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit push_fix で git push そのものが失敗したとき、載ったかを確かめずに PushResult(False, False, False, 理由) を返す経路を固定する。 rejected_by_position が 422 と位置の語がそろうときだけ真を返す分岐 (422 の別理由・404・状態なしは偽)を固定する。 Item-Id: R1-002 Round: 1 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/tests/test_post_queue.py | 12 ++++++++++++ plugins/ndf/scripts/tests/test_result_posts.py | 12 ++++++++++++ 2 files changed, 24 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_post_queue.py b/plugins/ndf/scripts/tests/test_post_queue.py index 227e5e90..23bfc967 100644 --- a/plugins/ndf/scripts/tests/test_post_queue.py +++ b/plugins/ndf/scripts/tests/test_post_queue.py @@ -356,3 +356,15 @@ def test_the_words_of_the_rejection_are_readable(stdout: str) -> None: def test_a_rejection_that_cannot_resolve_the_position_is_not_a_rate_limit() -> None: assert post_queue.is_rate_limited(_attempt(_UNRESOLVED_LINE)) is False + + +@pytest.mark.parametrize("item, expected", [ + ({"last_status": 422, "last_error": "Line could not be resolved"}, True), + ({"last_status": 422, "last_error": "Invalid event"}, False), + ({"last_status": 404, "last_error": "Line could not be resolved"}, False), + ({"last_status": None, "last_error": "Line could not be resolved"}, False), +]) +def test_a_queued_item_is_told_apart_by_its_status_and_words( + item: dict[str, Any], expected: bool) -> None: + """流した後に残った項目も、422 と位置の語がそろうときだけ位置の拒否と見る。""" + assert post_queue.rejected_by_position(item) is expected diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index eddaf1d1..edbc4f1c 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -452,6 +452,18 @@ def test_the_push_fails_without_a_destination() -> None: assert outcome.ok is False and outcome.pushed is False +def test_a_rejected_push_stops_before_checking_the_branch(tmp_path) -> None: + """送信そのものが拒まれたら、載ったかを確かめずに理由を残して止まる。""" + work, _ = _repo_with_remote(tmp_path) + subprocess.run(["git", "-C", str(work), "remote", "set-url", "origin", + str(tmp_path / "missing.git")], check=True) + + outcome = result_posts.push_fix(work, "main", _head(work)) + + assert outcome[:3] == (False, False, False) + assert outcome.detail != "" + + # ---------------- 単独で使う口 ---------------- def test_the_standalone_command_pushes_and_posts_with_the_same_layer( From 9d037c91cdcbf24835b08130daf9bfe92f44ee0b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:31:41 +0000 Subject: [PATCH 187/217] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20?= =?UTF-8?q?plugins/ndf/scripts/lib/post=5Fqueue.py#retry?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retry の公開入口の 4 分岐(成功・通常失敗・上限後の成功・待機上限到達)を 現状固定テストで固定する。run を応答列を返す疑似実装に、sleep を時間を進めず 待機秒数を記録する疑似実装に差し替え、結果と待機時間列が上限内であることを固定する。 対象コードは変更しない。 Item-Id: R1-005 Round: 1 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/tests/test_post_queue.py | 105 +++++++++++++++++++ 1 file changed, 105 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_post_queue.py b/plugins/ndf/scripts/tests/test_post_queue.py index 23bfc967..07cf7c84 100644 --- a/plugins/ndf/scripts/tests/test_post_queue.py +++ b/plugins/ndf/scripts/tests/test_post_queue.py @@ -368,3 +368,108 @@ def test_a_queued_item_is_told_apart_by_its_status_and_words( item: dict[str, Any], expected: bool) -> None: """流した後に残った項目も、422 と位置の語がそろうときだけ位置の拒否と見る。""" assert post_queue.rejected_by_position(item) is expected + + +# ---------------- 上限のときに待って再実行する(R1-005) ---------------- + +_OK = post_queue.Attempt(0, '{"id": 1}', "") +_RATE = post_queue.Attempt(1, "", "API rate limit exceeded (HTTP 429)") +_NORMAL_FAIL = post_queue.Attempt(1, "", "permission denied (HTTP 403)") + + +def _run_returning(responses: list[Any], calls: list[list[str]]): + """`run` の代わりに、応答列を順に返す疑似実装。呼ばれた cmd を記録する。""" + queue = list(responses) + + def fake_run(cmd, stdin=None): + calls.append(cmd) + return queue.pop(0) + + return fake_run + + +def _recording_sleep(waits: list[float]): + """時間を進めず、待った秒数だけ記録する疑似 sleep。""" + + def sleep(seconds): + waits.append(seconds) + + return sleep + + +def test_retry_returns_immediately_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。最初の実行が成功したら、待たずにその結果を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + monkeypatch.setattr(post_queue, "run", _run_returning([_OK], calls)) + monkeypatch.setattr(post_queue, "quota_remaining", lambda: 0) + + result = post_queue.retry(["gh", "pr", "create"], sleep=_recording_sleep(waits)) + + assert result is _OK + assert calls == [["gh", "pr", "create"]] + assert waits == [] + + +def test_retry_returns_immediately_on_a_normal_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。上限でない失敗は、待たずにそのまま返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + monkeypatch.setattr(post_queue, "run", _run_returning([_NORMAL_FAIL], calls)) + monkeypatch.setattr(post_queue, "quota_remaining", lambda: 100) + + result = post_queue.retry(["gh", "pr", "create"], sleep=_recording_sleep(waits)) + + assert result is _NORMAL_FAIL + assert calls == [["gh", "pr", "create"]] + assert waits == [] + + +def test_retry_waits_and_re_runs_until_it_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。上限のあいだ待って再実行し、成功したらその結果を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + monkeypatch.setattr( + post_queue, "run", _run_returning([_RATE, _RATE, _OK], calls) + ) + + result = post_queue.retry( + ["gh", "pr", "create"], + max_wait=900.0, + interval=30.0, + sleep=_recording_sleep(waits), + ) + + assert result is _OK + assert len(calls) == 3 + assert waits == [30.0, 30.0] + assert sum(waits) <= 900.0 + + +def test_retry_returns_the_last_rate_limited_attempt_when_the_wait_cap_is_reached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。待機の上限に達したら、最後の上限応答を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + last_rate = post_queue.Attempt(1, "", "API rate limit exceeded (HTTP 429)") + responses = [_RATE, _RATE, _RATE, last_rate] + monkeypatch.setattr(post_queue, "run", _run_returning(responses, calls)) + + result = post_queue.retry( + ["gh", "pr", "create"], + max_wait=90.0, + interval=30.0, + sleep=_recording_sleep(waits), + ) + + assert result is last_rate + assert len(calls) == 4 + assert waits == [30.0, 30.0, 30.0] + assert sum(waits) <= 90.0 From 101761abe497b0720e65f60613bbf221db646644 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:35:58 +0000 Subject: [PATCH 188/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-refactoring/scripts/refact?= =?UTF-8?q?or=5Flib/commands/apply.py#cmd=5Fnext=5Fapply=5Fround?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 適用ラウンド 1 の 3 項目を、振る舞いを変えずに関数の抽出で分割する。 - R2-001: cmd_next_apply_round から、開く群の選択(_select_next_apply_group)と 開いた群の状態に応じた適用の状態の組み立て(_prepare_apply_entry)を抽出 - R2-003: verify_apply_round から、現状固定テストの有無(_verify_test_gap_present)・ 差分予算(_verify_diff_budget)・粒度(_verify_apply_commit_count)の検査を抽出 - R2-005: cmd_merge_final_fix から、結果と範囲の確定(_collect_final_fix_range)・ 申告コミットの検証(_verify_final_fix_commits)・採否の反映(_apply_final_fix_verdict)を抽出 Item-Id: R2-001 Round: 2 Impl-Runtime: claude Impl-Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) --- .../scripts/refactor_lib/commands/apply.py | 74 ++++++----- .../scripts/refactor_lib/commands/gate.py | 121 ++++++++++++------ .../scripts/refactor_lib/verify.py | 87 ++++++++----- 3 files changed, 184 insertions(+), 98 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index c3bd6cea..e949fa0e 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -283,36 +283,25 @@ def _item_summary(item: dict[str, Any]) -> str: -def cmd_next_apply_round(args: argparse.Namespace) -> None: - """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 - - 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 - - **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを - 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 - - **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 - ラウンドあたりの上限だからである。 +def _select_next_apply_group( + groups: list[dict[str, Any]], +) -> tuple[Optional[dict[str, Any]], str]: + """次に開く群と、その群の開き直しの判定を返す。無ければ群は None。 + + **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 + 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは + 収束ループの前提である。 + + **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を + 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 + ここで取り消し済みにして次を探す。 """ - path, state = load_state(args.id) - entry = round_of(state, args.round) - groups = apply_groups(entry) - - # **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 - # 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは - # 収束ループの前提である。 - # - # **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を - # 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 - # ここで取り消し済みにして次を探す。 - opened: Optional[dict[str, Any]] = None reopening = "" for group in groups: if group.get("status") not in {"pending", "applied"}: continue if group.get("status") == "applied": - opened = group - break + return group, reopening reopening = group_reopening(group) if reopening in {"empty", "exhausted"}: group["status"] = "dropped" @@ -324,14 +313,15 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: f"({'項目なし' if reopening == 'empty' else '試行の上限'})" ) continue - opened = group - break + return group, reopening + return None, reopening - if opened is None: - statefile.save(path, state) - info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") - sys.exit(1) +def _prepare_apply_entry( + state: dict[str, Any], entry: dict[str, Any], + opened: dict[str, Any], reopening: str, +) -> None: + """開いた群の状態に応じて、提案ラウンドの適用の状態を組み立てる。""" entry["apply_round"] = opened["apply_round"] if opened.get("status") == "pending" and reopening == "open": # 起点は**オーケストレータ側で**確定させる。実装担当の申告に委ねると、 @@ -354,6 +344,30 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: # 取り込み済みの群を開き直した。**起点も修正の回数も動かさない。** info(f"↻ 適用ラウンド {opened['apply_round']} は取り込み済みです(検証から再開)") entry["apply_base_sha"] = opened.get("base_sha") + + +def cmd_next_apply_round(args: argparse.Namespace) -> None: + """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 + + 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 + + **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを + 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 + + **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 + ラウンドあたりの上限だからである。 + """ + path, state = load_state(args.id) + entry = round_of(state, args.round) + groups = apply_groups(entry) + + opened, reopening = _select_next_apply_group(groups) + if opened is None: + statefile.save(path, state) + info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") + sys.exit(1) + + _prepare_apply_entry(state, entry, opened, reopening) state["phase"] = "apply" statefile.save(path, state) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index 08596df4..85f2dd20 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -182,46 +182,18 @@ def _close_failed_final_fix( sys.exit(2) -def cmd_merge_final_fix(args: argparse.Namespace) -> None: - """Step 7 — 最終ゲートの修正結果を取り込む。 - - **`merge-fix` では代用できない。** あちらは適用ラウンド(群)の控えを読み、 - 範囲の起点・担当・改善項目の 3 つをそこから取る。最終ゲートにはそのどれも無い。 - 実際に流用すると次の 3 つが起きる。 - - | 流用したときに起きること | なぜ | - | --- | --- | - | 「起点 None」で止まり修正を取り込めない | 最後の群が検証を通っていれば `fix_base_sha` が無い | - | 正常なコミットまで取り消される | 古い起点が残っていると、そこから HEAD までが範囲になる | - | トレーラーが揃わず全件が不正になる | `Item-Id` を要求するが、最終ゲートの修正は項目に属さない | - - 終了コード: 0 = 取り込んだ / 2 = 取り込めなかった(範囲を確定できない、または - 担当が結果を残さなかった)。合否そのものは判定せず、**次の `final-gate` が - 採った側で 1 度だけ見る**。 +def _collect_final_fix_range( + path: pathlib.Path, + state: dict[str, Any], + gate: dict[str, Any], + scope: IntakeScope, + impl: str, + work: str, +) -> tuple[dict[str, Any], str, list[str]]: + """修正担当の結果と、取り込む範囲(HEAD と起点からのコミット)を確定する。 - **結果を残さなかったときも、作られたコミットは取り消す。** 取り消さずに抜けると、 - 次の最終ゲートがそのコミットを含む先端でテストし、落ちれば起点をそこへ置き直す。 - 未検証の差分が Pull Request に残る(#674)。 + 結果が無いとき・範囲を確定できないときは、ここで終了する。 """ - path, state = load_state(args.id) - gate = state.setdefault("final_gate", {"fix_rounds": 0, "checks": []}) - impl = str(gate.get("impl") or "") - if not impl: - die( - "最終ゲートの修正担当が記録されていません。" - "先に `final-gate` を実行してください", - code=4, - ) - - work = str(state["worktrees"]["work"]) - discard_impl_leftovers(state, work) - flush_pending_push(path, state, gate) - - scope = _final_fix_scope(gate, impl) - if already_closed(scope): - info("↻ この最終ゲートの修正の試行は結果なしとして記録済みです") - sys.exit(2) - outcome = read_result(state, impl, "final-fix") if outcome.payload is None: _close_failed_final_fix(path, state, gate, scope, outcome) @@ -236,7 +208,16 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: "検証できない修正は採りません", code=2, ) + return payload, head_now, ordered_range + +def _verify_final_fix_commits( + state: dict[str, Any], + work: str, + payload: dict[str, Any], + ordered_range: list[str], +) -> tuple[list[str], list[str]]: + """申告されたコミットを検証し、未申告のコミットと問題の一覧を返す。""" claimed_shas = reported_shas(payload) unassigned = unassigned_fix_commits(work, claimed_shas, ordered_range) # **テストコマンドは渡さない。** 合否は `final-gate` が採った側で 1 度だけ見る @@ -250,7 +231,20 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: for c in facts ) if p ] + return unassigned, problems + +def _apply_final_fix_verdict( + path: pathlib.Path, + state: dict[str, Any], + gate: dict[str, Any], + scope: IntakeScope, + head_now: str, + ordered_range: list[str], + unassigned: list[str], + problems: list[str], +) -> None: + """検証の結果に応じて、修正を取り消すか最終ゲートの記録へ取り込む。""" if unassigned: info( f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" @@ -269,6 +263,57 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: gate.setdefault("fix_commits", []).extend(ordered_range) info(f"修正を取り込みました({len(ordered_range)} コミット)") + +def cmd_merge_final_fix(args: argparse.Namespace) -> None: + """Step 7 — 最終ゲートの修正結果を取り込む。 + + **`merge-fix` では代用できない。** あちらは適用ラウンド(群)の控えを読み、 + 範囲の起点・担当・改善項目の 3 つをそこから取る。最終ゲートにはそのどれも無い。 + 実際に流用すると次の 3 つが起きる。 + + | 流用したときに起きること | なぜ | + | --- | --- | + | 「起点 None」で止まり修正を取り込めない | 最後の群が検証を通っていれば `fix_base_sha` が無い | + | 正常なコミットまで取り消される | 古い起点が残っていると、そこから HEAD までが範囲になる | + | トレーラーが揃わず全件が不正になる | `Item-Id` を要求するが、最終ゲートの修正は項目に属さない | + + 終了コード: 0 = 取り込んだ / 2 = 取り込めなかった(範囲を確定できない、または + 担当が結果を残さなかった)。合否そのものは判定せず、**次の `final-gate` が + 採った側で 1 度だけ見る**。 + + **結果を残さなかったときも、作られたコミットは取り消す。** 取り消さずに抜けると、 + 次の最終ゲートがそのコミットを含む先端でテストし、落ちれば起点をそこへ置き直す。 + 未検証の差分が Pull Request に残る(#674)。 + """ + path, state = load_state(args.id) + gate = state.setdefault("final_gate", {"fix_rounds": 0, "checks": []}) + impl = str(gate.get("impl") or "") + if not impl: + die( + "最終ゲートの修正担当が記録されていません。" + "先に `final-gate` を実行してください", + code=4, + ) + + work = str(state["worktrees"]["work"]) + discard_impl_leftovers(state, work) + flush_pending_push(path, state, gate) + + scope = _final_fix_scope(gate, impl) + if already_closed(scope): + info("↻ この最終ゲートの修正の試行は結果なしとして記録済みです") + sys.exit(2) + + payload, head_now, ordered_range = _collect_final_fix_range( + path, state, gate, scope, impl, work, + ) + unassigned, problems = _verify_final_fix_commits( + state, work, payload, ordered_range, + ) + _apply_final_fix_verdict( + path, state, gate, scope, head_now, ordered_range, unassigned, problems, + ) + gate.setdefault("durations", {})["fix"] = ( gate.get("durations", {}).get("fix", 0) + safe_int(payload.get("elapsed_seconds")) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py index c6bd02ff..db5b9b10 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py @@ -189,6 +189,56 @@ def diff_budget_factor(technique: Optional[str]) -> int: return DIFF_BUDGET_FACTOR +def _verify_test_gap_present( + items: list[dict[str, Any]], facts: list[dict[str, Any]], +) -> Optional[str]: + """テストが乏しい項目を含む群で、現状固定テストの追加が先行しているか。""" + if any(i.get("test_gap") for i in items): + # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 + if not facts[0].get("touches_tests"): + return ( + "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" + f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" + ) + return None + + +def _verify_diff_budget( + items: list[dict[str, Any]], facts: list[dict[str, Any]], +) -> Optional[str]: + """実差分が、見積の行数から決まる差分予算に収まっているか。""" + estimated = sum(safe_int(i.get("estimated_diff_lines")) for i in items) + factor = max( + (diff_budget_factor(i.get("technique")) for i in items), + default=DIFF_BUDGET_FACTOR, + ) + budget = estimated * factor + actual = sum(int(c.get("diff_lines") or 0) for c in facts) + if budget and actual > budget: + return ( + f"実差分 {actual} 行が差分予算 {budget} 行" + f"(見積 {estimated} 行 × {factor})を超えました(範囲の逸脱)" + ) + return None + + +def _verify_apply_commit_count(facts: list[dict[str, Any]]) -> Optional[str]: + """適用ラウンドのコミットが 1 件に収まっているか。 + + 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が + 申告するのは正しい形なので、重ねた申告では落とさない。 + """ + count = len({c.get("sha") for c in facts}) + if count > 1: + return ( + f"適用ラウンドのコミットが {count} 件あります" + "(残すのは適用ラウンド = 1 コミット。" + "群の中の項目はまとめて 1 つのコミットにします)" + ) + return None + + def verify_apply_round( items: list[dict[str, Any]], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -221,14 +271,9 @@ def verify_apply_round( if problem: return problem - if any(i.get("test_gap") for i in items): - # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 - if not facts[0].get("touches_tests"): - return ( - "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" - f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" - ) + problem = _verify_test_gap_present(items, facts) + if problem: + return problem # **テストの期待値が変わっていないか**(#443)。段 1(機械)で決まるものだけを # ここで落とす。決まらないものは `pending_test_judgements` が集め、進行側が @@ -238,30 +283,12 @@ def verify_apply_round( if problem: return problem - estimated = sum(safe_int(i.get("estimated_diff_lines")) for i in items) - factor = max( - (diff_budget_factor(i.get("technique")) for i in items), - default=DIFF_BUDGET_FACTOR, - ) - budget = estimated * factor - actual = sum(int(c.get("diff_lines") or 0) for c in facts) - if budget and actual > budget: - return ( - f"実差分 {actual} 行が差分予算 {budget} 行" - f"(見積 {estimated} 行 × {factor})を超えました(範囲の逸脱)" - ) + problem = _verify_diff_budget(items, facts) + if problem: + return problem # 粒度は最後に見る。トレーラーや範囲の問題を粒度の失敗で覆い隠さない。 - # 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が - # 申告するのは正しい形なので、重ねた申告では落とさない。 - count = len({c.get("sha") for c in facts}) - if count > 1: - return ( - f"適用ラウンドのコミットが {count} 件あります" - "(残すのは適用ラウンド = 1 コミット。" - "群の中の項目はまとめて 1 つのコミットにします)" - ) - return None + return _verify_apply_commit_count(facts) def commit_limit_for(item: dict[str, Any]) -> int: From 5cb07ed4b343ccc35dc3b51885b8ed90ca950f62 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:46:07 +0000 Subject: [PATCH 189/217] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20apply.py#=5Fload=5Fruntime=5Fproposals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ランタイムごとの提案読み取りを抽出し、提案統合のループを早期 continue で平坦化する。 Item-Id: R2-002 Round: 2 Impl-Runtime: codex Impl-Model: default --- .../scripts/refactor_lib/commands/apply.py | 49 +++++++++++-------- 1 file changed, 28 insertions(+), 21 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index e949fa0e..39402654 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -80,6 +80,30 @@ class _ApplyCommitRange: in_range: set[str] +def _read_runtime_proposal( + result: pathlib.Path, +) -> Optional[list[dict[str, Any]]]: + runtime = result.name.split("-", 1)[0] + if not result.exists(): + info(f"⚠ {runtime} の提案結果がありません: {result}") + return None + try: + payload = json.loads(result.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") + return None + if not isinstance(payload, dict): + info( + f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" + f"({type(payload).__name__})。提案なしとして扱います" + ) + return [] + items = payload.get("items") + if not isinstance(items, list): + return [] + return [item for item in items if isinstance(item, dict)] + + def _load_runtime_proposals( state: dict[str, Any], entry: dict[str, Any] ) -> dict[str, list[dict[str, Any]]]: @@ -94,28 +118,11 @@ def _load_runtime_proposals( state, runtime, stem_for(runtime, "propose", state["id"], entry["round"]), ) - if not result.exists(): - info(f"⚠ {runtime} の提案結果がありません: {result}") - continue - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") - continue - if not isinstance(payload, dict): - # 配列や数値のまま `payload.get(...)` を呼ぶと落ちる。 - # 提案は無かったものとして続ける(1 者の不調で全体を止めない)。 - info( - f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" - f"({type(payload).__name__})。提案なしとして扱います" - ) - proposals[runtime] = [] - entry["proposed"][runtime] = 0 + runtime_proposals = _read_runtime_proposal(result) + if runtime_proposals is None: continue - items = payload.get("items") - proposals[runtime] = [i for i in items if isinstance(i, dict)] \ - if isinstance(items, list) else [] - entry["proposed"][runtime] = len(proposals[runtime]) + proposals[runtime] = runtime_proposals + entry["proposed"][runtime] = len(runtime_proposals) return proposals From 4eabfd8c91d0156dc1c9866a8b54aa9ee1c0f5a3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:48:43 +0000 Subject: [PATCH 190/217] =?UTF-8?q?Test:=20=E5=A2=83=E7=95=8C=E3=81=A8?= =?UTF-8?q?=E5=88=86=E5=B2=90=E3=81=AE=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit retry の待機間隔ゼロ、不正な comment_id、保存後フックの重複登録を固定する。 Item-Id: R2-001 Round: 2 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/tests/test_post_queue.py | 20 ++++++++++++++++++ .../ndf/scripts/tests/test_result_posts.py | 21 +++++++++++++++++++ .../ndf/scripts/tests/test_statefile_emit.py | 19 +++++++++++++++++ 3 files changed, 60 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_post_queue.py b/plugins/ndf/scripts/tests/test_post_queue.py index 07cf7c84..5c1e8b08 100644 --- a/plugins/ndf/scripts/tests/test_post_queue.py +++ b/plugins/ndf/scripts/tests/test_post_queue.py @@ -473,3 +473,23 @@ def test_retry_returns_the_last_rate_limited_attempt_when_the_wait_cap_is_reache assert len(calls) == 4 assert waits == [30.0, 30.0, 30.0] assert sum(waits) <= 90.0 + + +def test_retry_returns_the_first_rate_limited_attempt_when_interval_is_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。待機間隔が 0 なら、待機も再実行もせず最初の応答を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + first_rate = post_queue.Attempt(1, "", "API rate limit exceeded (HTTP 429)") + monkeypatch.setattr(post_queue, "run", _run_returning([first_rate], calls)) + + result = post_queue.retry( + ["gh", "pr", "create"], + interval=0, + sleep=_recording_sleep(waits), + ) + + assert result is first_rate + assert calls == [["gh", "pr", "create"]] + assert waits == [] diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index edbc4f1c..3c90e55b 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -385,6 +385,27 @@ def test_a_fix_without_threads_still_posts_the_summary(tmp_path) -> None: assert [i["kind"] for i in items] == ["pr-comment"] +def test_fix_posts_skips_replies_with_non_numeric_comment_ids(tmp_path) -> None: + """現状固定。不正な返信先を飛ばしても、決着とまとめは組み立てる。""" + items = result_posts.fix_posts( + _fix_file( + tmp_path, + resolved_threads=[{"comment_id": "invalid", "thread_id": "PRRT_a"}], + deferred=[{"comment_id": "invalid", "thread_id": "PRRT_b"}], + rejected=[{"comment_id": "invalid", "thread_id": "PRRT_c"}], + ), + repo=REPO, + pr=PR, + round_no=ROUND, + ) + + assert [item["kind"] for item in items] == ["thread-resolve", "pr-comment"] + assert items[0]["fields"] == {"thread_id": "PRRT_a"} + assert items[0]["extra"] == {"ident": "resolve-PRRT_a"} + assert items[1]["extra"] == {"ident": f"fix-summary-{ROUND}"} + assert "決着: 1 件 / 見送り: 1 件 / 却下: 1 件" in items[1]["fields"]["body"] + + # ---------------- 送信 ---------------- def _repo_with_remote(tmp_path) -> tuple[pathlib.Path, pathlib.Path]: diff --git a/plugins/ndf/scripts/tests/test_statefile_emit.py b/plugins/ndf/scripts/tests/test_statefile_emit.py index 32e610a4..2c238ccb 100644 --- a/plugins/ndf/scripts/tests/test_statefile_emit.py +++ b/plugins/ndf/scripts/tests/test_statefile_emit.py @@ -42,3 +42,22 @@ def test_emit_joins_a_list_into_one_space_separated_word(mod, capsys) -> None: mod.emit(VALUES=["a b", "c"]) assert shlex.split(capsys.readouterr().out) == ["VALUES=a b c"] + + +def test_register_after_save_calls_the_same_hook_only_once(mod, tmp_path) -> None: + """現状固定。同じ差し込み口を 2 度登録しても保存後に 1 度だけ呼ぶ。""" + calls = [] + + def hook(path, state): + calls.append((path, state)) + + mod.register_after_save(hook) + mod.register_after_save(hook) + try: + path = tmp_path / "state.json" + state = {"round": 2} + mod.save(path, state) + finally: + mod.unregister_after_save(hook) + + assert calls == [(path, state)] From 067a9f8263ca4380344e165cf5f2a0b359375e7a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:55:36 +0000 Subject: [PATCH 191/217] =?UTF-8?q?Test:=20=E3=83=AC=E3=83=93=E3=83=A5?= =?UTF-8?q?=E3=83=BCURL=E8=A3=9C=E5=AE=8C=E3=81=A8=E7=8A=B6=E6=85=8B?= =?UTF-8?q?=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E6=AD=A3=E5=B8=B8=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E3=81=AE=E7=8F=BE=E7=8A=B6=E5=9B=BA=E5=AE=9A=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit post_review で応答が id のみの場合のフラグメント URL 補完と、 statefile.save での親ディレクトリ自動作成および登録後フック呼出を固定する。 Item-Id: R2-003 Round: 2 Impl-Runtime: agy Impl-Model: default --- .../ndf/scripts/tests/test_result_posts.py | 17 +++++++++++++++ .../ndf/scripts/tests/test_statefile_emit.py | 21 +++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index 3c90e55b..23301bc5 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -319,6 +319,23 @@ def test_a_note_with_findings_is_not_a_missing_result(tmp_path, fake_gh) -> None assert outcome.failed is False +def test_post_review_falls_back_to_fragment_url_when_response_has_id_only( + tmp_path, fake_gh) -> None: + """現状固定。応答に html_url がなく id だけのとき、review_url をフラグメントで補う。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + {"match": "pulls/730/reviews", "stdout": json.dumps({"id": 99})}, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.review_url == "#pullrequestreview-99" + assert outcome.posted_inline == 2 + assert outcome.posted_body == 0 + assert outcome.queued == 0 + assert outcome.failed is False + + # ---------------- 修正の投稿 ---------------- def _fix_file(tmp_path, **over) -> pathlib.Path: diff --git a/plugins/ndf/scripts/tests/test_statefile_emit.py b/plugins/ndf/scripts/tests/test_statefile_emit.py index 2c238ccb..b260216a 100644 --- a/plugins/ndf/scripts/tests/test_statefile_emit.py +++ b/plugins/ndf/scripts/tests/test_statefile_emit.py @@ -61,3 +61,24 @@ def hook(path, state): mod.unregister_after_save(hook) assert calls == [(path, state)] + + +def test_save_writes_state_creates_parent_directory_and_calls_registered_hook(mod, tmp_path) -> None: + """現状固定。未作成の親ディレクトリを作って保存し、登録した差し込み口へ引数を渡す。""" + calls = [] + + def hook(path, state): + calls.append((path, state)) + + mod.register_after_save(hook) + try: + path = tmp_path / "nested" / "parent" / "state.json" + state = {"a": 1} + mod.save(path, state) + loaded = mod.load(path) + finally: + mod.unregister_after_save(hook) + + assert loaded == state + assert calls == [(path, state)] + From 2d510a9b8d90274d7c83cedb8aae1dc8ad7da819 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 10:57:23 +0000 Subject: [PATCH 192/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-refactoring/scripts/refact?= =?UTF-8?q?or=5Flib/commands/apply.py#cmd=5Fmerge=5Ftest=5Fjudgements?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 保留の特定・担当の結果読み込み・changed時の項目放棄と群の取り消し・正常時の 群への判定適用と通知を、それぞれ独立した関数へ抽出した。入力取得と破棄を伴う 状態遷移が混ざっていた本体を、境界の見える 4 段に分けた。振る舞いは不変。 Item-Id: R2-004 Round: 2 Impl-Runtime: kiro Impl-Model: default --- .../scripts/refactor_lib/commands/apply.py | 127 +++++++++++------- 1 file changed, 82 insertions(+), 45 deletions(-) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 39402654..f18a57c4 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -1049,6 +1049,82 @@ def _resume_incomplete_apply( flush_pending_push(path, state, entry) +def _pending_judgements_for_round( + entry: dict[str, Any], group_no: int +) -> list[str]: + """この群の保留を取り出す。**全ての群をまとめて解かない。**""" + records = entry.get("pending_test_judgements") + if isinstance(records, dict): + return list(records.get(str(group_no), [])) + return [] + + +def _read_group_judge_verdicts( + state: dict[str, Any], impl: Optional[str], round_no: int, group_no: int, +) -> list[dict[str, Any]]: + """この群を判定した担当の結果ファイルを読み、dict の verdict だけを返す。 + + **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 + 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 + """ + if not impl: + return [] + result = result_path( + state, impl, + f"{impl}-judge-test-changes-r{round_no}-g{group_no}") + if not result.exists(): + return [] + try: + payload = json.loads(result.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + payload = {} + found = payload.get("verdicts") + if isinstance(found, list): + return [v for v in found if isinstance(v, dict)] + return [] + + +def _drop_round_on_changed_judgement( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group: dict[str, Any], problem: str, +) -> None: + """`changed` があった群を取り消し、項目へ印を残す。**必ず終了する。**""" + failed = list(group.get("items") or []) + # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 + for item_id in failed: + item = find_item(state, item_id, required=False) + if item: + item["status"] = "abandoned" + item["failure_reason"] = problem + _apply_drop(path, state, entry, group, failed) + # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた + # 分まで捨てない。 + record_pending_judgements(entry, group.get("apply_round") or 1, []) + statefile.save(path, state) + info(f"❌ 適用ラウンド {group.get('apply_round')}: {problem}") + # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 + sys.exit(2) + + +def _apply_group_judgements( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group_no: int, verdicts: list[dict[str, Any]], +) -> None: + """当該群だけへ判定を適用し、残りをレビューへ引き継ぐと知らせる。 + + **解くのは、判定が実際に見た群の保留だけである。** 段 2 へ渡すのはその群の + 差分であるため、別の群で同じファイルが残っていてもそちらは解かない。 + """ + remaining = apply_judgements_to_group(entry, group_no, verdicts) + if remaining: + info( + f"{len(remaining)} 件はレビューへ引き継ぎます: " + ", ".join(remaining) + ) + else: + info("この適用群のテストの差分は、期待する振る舞いを変えていません") + statefile.save(path, state) + + def cmd_merge_test_judgements(args: argparse.Namespace) -> None: """段 2(AI エージェント)の答えを取り込む(#443)。 @@ -1061,59 +1137,20 @@ def cmd_merge_test_judgements(args: argparse.Namespace) -> None: path, state = load_state(args.id) entry = round_of(state, args.round) # **判定の対象はこの群の保留である。** 全ての群をまとめて解かない。 - records = entry.get("pending_test_judgements") group_of_round = (current_group(entry) or {}).get("apply_round") or 1 - pending = list((records or {}).get(str(group_of_round), [])) \ - if isinstance(records, dict) else [] + pending = _pending_judgements_for_round(entry, group_of_round) if not pending: info("判定を待っているテストはありません") return - # **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 - # 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 impl = (current_group(entry) or {}).get("impl") or entry.get("impl") - verdicts: list[dict[str, Any]] = [] - if impl: - result = result_path( - state, impl, - f"{impl}-judge-test-changes-r{args.round}-g{group_of_round}") - if result.exists(): - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - payload = {} - found = payload.get("verdicts") - if isinstance(found, list): - verdicts = [v for v in found if isinstance(v, dict)] + verdicts = _read_group_judge_verdicts( + state, impl, args.round, group_of_round) outcome = merge_test_judgements(pending, verdicts) if outcome["problem"]: - group = current_group(entry) - failed = list(group.get("items") or []) - # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 - for item_id in failed: - item = find_item(state, item_id, required=False) - if item: - item["status"] = "abandoned" - item["failure_reason"] = outcome["problem"] - _apply_drop(path, state, entry, group, failed) - # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた - # 分まで捨てない。 - record_pending_judgements(entry, group.get("apply_round") or 1, []) - statefile.save(path, state) - info(f"❌ 適用ラウンド {group.get('apply_round')}: {outcome['problem']}") - # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 - sys.exit(2) + _drop_round_on_changed_judgement( + path, state, entry, current_group(entry), outcome["problem"]) - # **解くのは、判定が実際に見た群の保留だけである。** 段 2 へ渡すのはその群の - # 差分であるため、別の群で同じファイルが残っていてもそちらは解かない。 - group_no = (current_group(entry) or {}).get("apply_round") or 1 - remaining = apply_judgements_to_group(entry, group_no, verdicts) - if remaining: - info( - f"{len(remaining)} 件はレビューへ引き継ぎます: " + ", ".join(remaining) - ) - else: - info("この適用群のテストの差分は、期待する振る舞いを変えていません") - statefile.save(path, state) + _apply_group_judgements(path, state, entry, group_of_round, verdicts) From 7f0fe7642758df77323fab4c6c0099a6ef0876a8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:10:21 +0000 Subject: [PATCH 193/217] =?UTF-8?q?Refactor:=20consolidate=5Fduplication?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/skills/cross-refactoring/scripts/ref?= =?UTF-8?q?actor=5Flib/proposals.py#merge=5Fproposals?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - R3-001: merge_proposals と merge_test_proposals が共有していた提案の正規化・重複排除・統合の二重ループを _build_merged へ集約した - R3-002: format_report の実装担当・レビュー担当の行組み立てを _impl_rows / _reviewer_rows へ抽出した Item-Id: R3-001 Round: 3 Impl-Runtime: claude Impl-Model: default Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/metrics.py | 36 ++++++++------ .../scripts/refactor_lib/proposals.py | 48 ++++++++++--------- 2 files changed, 48 insertions(+), 36 deletions(-) diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index d900fc88..af2d625c 100644 --- a/plugins/ndf/scripts/lib/metrics.py +++ b/plugins/ndf/scripts/lib/metrics.py @@ -276,10 +276,9 @@ def _emit_table( lines += [*headers, *rows] -def format_report(metrics: dict[str, Any]) -> str: - """人が読む形へ整形する。比較の限界を必ず添える。""" - lines: list[str] = [] - impl_rows = [ +def _impl_rows(metrics: dict[str, Any]) -> list[str]: + """実装担当の表の行を組む。""" + return [ ( f"| {key} | {m['rounds']} | {m['applied']} | {m['abandoned']} | " f"{_fmt(m['first_review_approval_rate'])} | {_fmt(m['avg_fix_rounds'])} | " @@ -288,6 +287,23 @@ def format_report(metrics: dict[str, Any]) -> str: ) for key, m in metrics["impl"].items() ] + + +def _reviewer_rows(metrics: dict[str, Any]) -> list[str]: + """レビュー担当の表の行を組む。""" + return [ + ( + f"| {key} | {m['reviews']} | {m['findings']} | " + f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " + f"{m['seconds']:.0f} |" + ) + for key, m in metrics["reviewer"].items() + ] + + +def format_report(metrics: dict[str, Any]) -> str: + """人が読む形へ整形する。比較の限界を必ず添える。""" + lines: list[str] = [] _emit_table( lines, "実装担当", @@ -295,17 +311,9 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | 担当R | 適用 | 見送り | 初回承認率 | 平均修正R | 予算超過率 | テスト失敗率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ), - impl_rows, + _impl_rows(metrics), ) - reviewer_rows = [ - ( - f"| {key} | {m['reviews']} | {m['findings']} | " - f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " - f"{m['seconds']:.0f} |" - ) - for key, m in metrics["reviewer"].items() - ] lines.append("") _emit_table( lines, @@ -314,7 +322,7 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | レビュー回数 | 指摘 | 修正に至った率 | 判定一致率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: |", ), - reviewer_rows, + _reviewer_rows(metrics), ) if metrics["unmeasured"]: diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index 69e3c5e8..6752482a 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -135,17 +135,7 @@ def merge_proposals( `excluded_keys` には過去に見送った項目の鍵を渡す。見送った項目を毎ラウンド 再提案されると収束しないため、対象外として落とす。 """ - merged: dict[tuple[str, ...], dict[str, Any]] = {} - for source, items in proposals.items(): - for raw in items: - norm = _normalize_proposal(raw, source) - if norm is None: - continue - key = _dedupe_key(norm) - if key in merged: - _merge_one(merged[key], norm) - else: - merged[key] = norm + merged = _build_merged(proposals, _normalize_proposal, _merge_one) min_severity = SEVERITY_ORDER.get( threshold, SEVERITY_ORDER[DEFAULT_SEVERITY_THRESHOLD]) @@ -170,6 +160,30 @@ def reject(item: dict[str, Any]) -> Optional[str]: ) +def _build_merged( + proposals: dict[str, list[dict[str, Any]]], + normalize: Callable[[dict[str, Any], str], Optional[dict[str, Any]]], + merge_one: Callable[[dict[str, Any], dict[str, Any]], None], +) -> dict[tuple[str, ...], dict[str, Any]]: + """提案を正規化し、重複排除の鍵ごとに統合した辞書を返す。**種類で分けない。** + + 正規化と統合の関数だけが種類ごとに違う。重複排除の基準(`_dedupe_key`)が + 片方だけ直されて食い違わないよう 1 箇所に置く。 + """ + merged: dict[tuple[str, ...], dict[str, Any]] = {} + for source, items in proposals.items(): + for raw in items: + norm = normalize(raw, source) + if norm is None: + continue + key = _dedupe_key(norm) + if key in merged: + merge_one(merged[key], norm) + else: + merged[key] = norm + return merged + + def _select( merged: dict[tuple[str, ...], dict[str, Any]], *, @@ -284,17 +298,7 @@ def merge_test_proposals( 採否の詰めは `merge_proposals` と同じ `_select` が行う。 """ - merged: dict[tuple[str, ...], dict[str, Any]] = {} - for source, items in proposals.items(): - for raw in items: - norm = _normalize_test_proposal(raw, source) - if norm is None: - continue - key = _dedupe_key(norm) - if key in merged: - _merge_test_one(merged[key], norm) - else: - merged[key] = norm + merged = _build_merged(proposals, _normalize_test_proposal, _merge_test_one) def reject(item: dict[str, Any]) -> Optional[str]: if item["case"] == "unknown" or item["level"] == "unknown": From 6695c13e0b1af54aa8e929bfe766f2c0d511362a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:21:05 +0000 Subject: [PATCH 194/217] =?UTF-8?q?Refactor:=20consolidate=5Fduplication?= =?UTF-8?q?=20/=20extract=5Fmethod=20/=20fix=5Fdependency=5Fdirection=20?= =?UTF-8?q?=E2=80=94=20cross-review=20=E5=8F=8E=E6=9D=9F=E3=83=AB=E3=83=BC?= =?UTF-8?q?=E3=83=97=E3=81=AE=E6=A7=8B=E9=80=A0=E6=94=B9=E5=96=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - result_posts.post_review: 初回送信と位置解決失敗時の送り直しの 4 手順の重複を _enqueue_review ヘルパへ束ねた(R3-001) - assignment.resolve_participants: 検証・only 適用・probe 分類を _validate_and_build_participants / _apply_only / _classify_probe へ抽出(R3-003) - state.py: GitHub 取得と投稿処理を GitHubGateway 境界へまとめ、副コマンドは GITHUB 越しに呼ぶ。テストは非公開関数名ではなく境界へオフライン実装を注入する 形へ変更(R3-004) - state._init_new_state: 6 段のローカル関数をトップレベルへ切り出し、本体には 呼び出し順だけを残した(R3-005) いずれも振る舞いは不変。全体テスト(uv run --with pytest pytest scripts/tests plugins/ndf -q)が 4913 passed / exit=0。 Item-Id: R3-001 Round: 3 Impl-Runtime: kiro Impl-Model: default --- plugins/ndf/scripts/lib/assignment.py | 108 +++-- plugins/ndf/scripts/lib/result_posts.py | 23 +- .../ndf/skills/cross-review/scripts/state.py | 416 ++++++++++-------- .../ndf/skills/cross-review/tests/conftest.py | 39 +- .../tests/test_init_body_not_duplicated.py | 75 +++- .../tests/test_merge_fix_posts.py | 4 +- .../tests/test_rejected_findings.py | 7 +- .../tests/test_state_ci_classification.py | 3 +- .../test_state_init_changed_files_fallback.py | 6 +- .../test_state_init_worktree_creation.py | 6 +- .../cross-review/tests/test_state_judge_ci.py | 6 +- .../tests/test_state_offline_fetch.py | 7 +- .../tests/test_state_review_pool.py | 7 +- .../tests/test_state_run_metrics.py | 6 +- 14 files changed, 416 insertions(+), 297 deletions(-) diff --git a/plugins/ndf/scripts/lib/assignment.py b/plugins/ndf/scripts/lib/assignment.py index a01928c4..4290a391 100644 --- a/plugins/ndf/scripts/lib/assignment.py +++ b/plugins/ndf/scripts/lib/assignment.py @@ -207,6 +207,71 @@ def to_state(self) -> dict[str, Any]: Probe = Callable[[list[str]], tuple[dict[str, dict[str, Any]], bool]] +def _validate_and_build_participants( + pool: list[str], include: list[str], exclude: list[str], +) -> list[str]: + """`include` / `exclude` の整合性を検査し、参加者列(`pool ∪ include − exclude`)を返す。 + + 順序 1〜2(`resolve_participants` の docstring)を担う。名前が `ALL_RUNTIMES` に + あること・足す者と外す者が重ならないこと・外す者が母集合に含まれることを確かめる。 + """ + for name in (*include, *exclude): + if name not in ALL_RUNTIMES: + raise AssignmentError( + f"参加できないランタイムです: {name}({'/'.join(ALL_RUNTIMES)} のいずれか)" + ) + overlap = set(include) & set(exclude) + if overlap: + raise AssignmentError( + f"足す者と外す者に同じ名前があります: {', '.join(_in_fixed_order(overlap))}" + ) + base = set(pool) | set(include) + outside = [n for n in exclude if n not in base] + if outside: + raise AssignmentError( + f"母集合に無い者は外せません: {', '.join(_in_fixed_order(outside))}" + f"(母集合: {', '.join(_in_fixed_order(base))})" + ) + return _in_fixed_order(base - set(exclude)) + + +def _apply_only(participants: list[str], only: str | None, exclude: list[str]) -> list[str]: + """`only` を検査して適用し、参加者列を返す(順序 3)。 + + `only` が `None` なら参加者列をそのまま返す。指定があれば `exclude` と矛盾せず、 + 参加者に含まれることを確かめ、参加者をその 1 者にする。 + """ + if only is None: + return participants + if only in exclude: + raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") + if only not in participants: + raise AssignmentError( + f"--only は参加者のいずれかを指定してください: {only}" + f"(参加者: {', '.join(participants)})" + ) + return [only] + + +def _classify_probe( + participants: list[str], probe: Probe, +) -> tuple[list[str], dict[str, str], bool]: + """`probe` の戻り値から `(available, unavailable, probe_skipped)` を作る(順序 4)。 + + 飛ばされたら全員を通ったものとし、そうでなければ通らなかった者と理由を集める。 + """ + results, skipped = probe(list(participants)) + if skipped: + return list(participants), {}, True + unavailable = { + n: str(results.get(n, {}).get("detail", "")) + for n in participants + if not results.get(n, {}).get("ok", False) + } + available = [n for n in participants if n not in unavailable] + return available, unavailable, skipped + + def resolve_participants( pool: Iterable[str], *, @@ -237,46 +302,9 @@ def resolve_participants( include = list(include) exclude = list(exclude) - for name in (*include, *exclude): - if name not in ALL_RUNTIMES: - raise AssignmentError( - f"参加できないランタイムです: {name}({'/'.join(ALL_RUNTIMES)} のいずれか)" - ) - overlap = set(include) & set(exclude) - if overlap: - raise AssignmentError( - f"足す者と外す者に同じ名前があります: {', '.join(_in_fixed_order(overlap))}" - ) - base = set(pool) | set(include) - outside = [n for n in exclude if n not in base] - if outside: - raise AssignmentError( - f"母集合に無い者は外せません: {', '.join(_in_fixed_order(outside))}" - f"(母集合: {', '.join(_in_fixed_order(base))})" - ) - - participants = _in_fixed_order(base - set(exclude)) - - if only is not None: - if only in exclude: - raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") - if only not in participants: - raise AssignmentError( - f"--only は参加者のいずれかを指定してください: {only}" - f"(参加者: {', '.join(participants)})" - ) - participants = [only] - - results, skipped = probe(list(participants)) - if skipped: - available, unavailable = list(participants), {} - else: - unavailable = { - n: str(results.get(n, {}).get("detail", "")) - for n in participants - if not results.get(n, {}).get("ok", False) - } - available = [n for n in participants if n not in unavailable] + participants = _validate_and_build_participants(pool, include, exclude) + participants = _apply_only(participants, only, exclude) + available, unavailable, skipped = _classify_probe(participants, probe) if require_all and unavailable: failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 95a5fa3c..756df88f 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -203,22 +203,21 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, 同じ状態で返る別の拒まれ方(判定の値の誤り・基準のコミットの誤り)は退避せず、 失敗として残す。 """ - findings = len(_findings(_read_json(payload_path))) - item = review_posts(payload_path, result_path, repo, pr, round_no, seat, - head_sha, is_own_pr)[0] - path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], - actor=actor, extra=item["extra"]) - seq = (post_queue.read_item(path) or {}).get("seq") - flushed = queue.flush() - - if flushed.failed and post_queue.rejected_by_position(flushed.failed): - queue.drop(flushed.failed.get("seq")) + def _enqueue_review(evacuate_all: bool) -> tuple[dict[str, Any], Any, Any]: + """投稿項目を組み立てて待ち行列へ積み、`(項目, seq, 流した結果)` を返す。""" item = review_posts(payload_path, result_path, repo, pr, round_no, seat, - head_sha, is_own_pr, evacuate_all=True)[0] + head_sha, is_own_pr, evacuate_all=evacuate_all)[0] path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], actor=actor, extra=item["extra"]) seq = (post_queue.read_item(path) or {}).get("seq") - flushed = queue.flush() + return item, seq, queue.flush() + + findings = len(_findings(_read_json(payload_path))) + item, seq, flushed = _enqueue_review(evacuate_all=False) + + if flushed.failed and post_queue.rejected_by_position(flushed.failed): + queue.drop(flushed.failed.get("seq")) + item, seq, flushed = _enqueue_review(evacuate_all=True) done = _find(flushed.sent, seq) or _find(flushed.skipped, seq) failed = flushed.failed is not None and flushed.failed.get("seq") == seq diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 89be6d14..3b5a5a9d 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -578,6 +578,41 @@ def _fetch_check_runs(repo: str, sha: str) -> list[dict[str, Any]] | None: return runs or None +class GitHubGateway(NamedTuple): + """GitHub 取得と投稿処理をまとめた入出力境界(#801 R3-004)。 + + **副コマンドはこの境界を通してのみ GitHub へ触れる。** 本番経路では現在の関数群を + 束ねた実体(`_real_github_gateway`)を渡し、テストはオフライン実装を束ねた別の + 実体を注入する。境界を挟むことで、内部関数(`_fetch_check_runs` / + `_fetch_pr_metadata`)や `result_posts` の関数を名前で差し替えなくてよくなる。 + + フィールドは呼ばれる側の 5 つ。`fetch_pr_metadata` / `fetch_check_runs` は + このモジュールの関数、`post_review` / `post_fix` / `push_fix` は `result_posts` + の関数と同じ呼び出し規約を持つ。 + """ + + fetch_pr_metadata: Any + fetch_check_runs: Any + post_review: Any + post_fix: Any + push_fix: Any + + +def _real_github_gateway() -> GitHubGateway: + """本番経路の境界。現在の関数群をそのまま束ねる。""" + return GitHubGateway( + fetch_pr_metadata=_fetch_pr_metadata, + fetch_check_runs=_fetch_check_runs, + post_review=result_posts.post_review, + post_fix=result_posts.post_fix, + push_fix=result_posts.push_fix, + ) + + +# 副コマンドが通す境界。テストは `state_mod.GITHUB` を差し替える(`conftest.py`)。 +GITHUB: GitHubGateway = _real_github_gateway() + + class HeadRef(NamedTuple): """レビュー対象の Pull Request の head。 @@ -602,7 +637,7 @@ def _resolve_head_ref(pr: int, code: int = 8, repo: str | None = None) -> HeadRe 照会は REST の 1 回で、ブランチ名・commit・フォークの別が同じ応答から取れる。 """ - meta = _fetch_pr_metadata(pr, repo) + meta = GITHUB.fetch_pr_metadata(pr, repo) if meta is None: die(f"PR #{pr} の head を取得できない", code=code) raise SystemExit(code) # die は戻らないが、型のために置く @@ -1828,195 +1863,200 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str -def _init_new_state( - args: argparse.Namespace, - pr: object, - repo: str, - worktree: str, - manual_extra_review: str, -) -> None: - """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" +def _resolve_pr_and_ownership( + pr: object, repo: str, worktree: str, args_worktree: str | None +) -> _InitPRContext | None: + # 新規 init: プリチェック。 + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + meta = GITHUB.fetch_pr_metadata(pr, repo) + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) - def _resolve_pr_and_ownership( - pr: object, repo: str, worktree: str, args_worktree: str | None - ) -> _InitPRContext | None: - # 新規 init: プリチェック。 - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - meta = _fetch_pr_metadata(pr, repo) - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - def _prepare_review_instructions( - pr: object, repo: str, manual_extra_review: str - ) -> _InitReviewContext: - changed_files = _fetch_changed_files(pr, repo) - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) +def _prepare_review_instructions( + pr: object, repo: str, manual_extra_review: str +) -> _InitReviewContext: + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) - def _prepare_worktree_and_comments( - worktree: str, pr: object, head_branch: str, repo: str - ) -> _InitWorkspaceContext: - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) - tmp_dir = _tmp_dir(worktree) - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - return _InitWorkspaceContext( - tmp_dir=tmp_dir, - state_file=state_file, - ) +def _prepare_worktree_and_comments( + worktree: str, pr: object, head_branch: str, repo: str +) -> _InitWorkspaceContext: + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - info(f"ホストの判定: {host}({host_source})") - # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに - # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 - participants = _resolve_reviewers(host, args) - return _InitialAssignment( - host=host, host_source=host_source, participants=participants) - - def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, - ) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source, participants = ctx.assignment - only, _include, _exclude = _normalize_participant_args(args) - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) - "max_rounds": args.max_rounds if args.max_rounds is not None else 12, - "rotate_after": args.rotate_after if args.rotate_after is not None else 8, - "only": only, - "participants": participants, - "resume_changes": [], - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - def _finalize_initial_state( - args: argparse.Namespace, - pr: object, - pr_ctx: _InitPRContext, - review_ctx: _InitReviewContext, - ws_ctx: _InitWorkspaceContext, - manual_extra_review: str, - ) -> None: - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review - ) - state = _build_initial_review_state(args, context) - _write_state(ws_ctx.state_file, state) - info(f"✅ state 初期化: {ws_ctx.state_file}") - _print_init_result( - _InitResult( - pr=pr, - worktree=pr_ctx.worktree, - tmp_dir=ws_ctx.tmp_dir, - repo=pr_ctx.repo, - head_branch=pr_ctx.meta.head_branch, - base_branch=pr_ctx.meta.base_branch, - is_own=pr_ctx.is_own, - event_downgrade=pr_ctx.event_downgrade, - has_extra=bool(review_ctx.review_instructions), - carried_count=0, - resumed=False, - ) + return _InitWorkspaceContext( + tmp_dir=tmp_dir, + state_file=state_file, + ) + + +def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + info(f"ホストの判定: {host}({host_source})") + # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに + # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 + participants = _resolve_reviewers(host, args) + return _InitialAssignment( + host=host, host_source=host_source, participants=participants) + + +def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, +) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source, participants = ctx.assignment + only, _include, _exclude = _normalize_participant_args(args) + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) + "max_rounds": args.max_rounds if args.max_rounds is not None else 12, + "rotate_after": args.rotate_after if args.rotate_after is not None else 8, + "only": only, + "participants": participants, + "resume_changes": [], + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + + +def _finalize_initial_state( + args: argparse.Namespace, + pr: object, + pr_ctx: _InitPRContext, + review_ctx: _InitReviewContext, + ws_ctx: _InitWorkspaceContext, + manual_extra_review: str, +) -> None: + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + ) + state = _build_initial_review_state(args, context) + _write_state(ws_ctx.state_file, state) + info(f"✅ state 初期化: {ws_ctx.state_file}") + _print_init_result( + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, ) + ) + +def _init_new_state( + args: argparse.Namespace, + pr: object, + repo: str, + worktree: str, + manual_extra_review: str, +) -> None: + """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) if pr_ctx is None: return @@ -2863,7 +2903,7 @@ def cmd_read_result(args: argparse.Namespace) -> None: st = _load(pr) last = st["rounds"][-1] round_no = last.get("round") - posted = result_posts.post_review( + posted = GITHUB.post_review( _queue(pr), _payload_path(agent, pr, round_no), rfile, @@ -2914,13 +2954,13 @@ def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, An repo = str(st.get("repo") or "") sha = str(last.get("head_sha") or "") if not sha: - meta = _fetch_pr_metadata(pr, repo or None) + meta = GITHUB.fetch_pr_metadata(pr, repo or None) if meta is not None: sha = meta.head_sha repo = repo or meta.repo if not repo or not sha: return {"verdict": "unverified", "reason": "head のコミットを特定できない"} - runs = _fetch_check_runs(repo, sha) + runs = GITHUB.fetch_check_runs(repo, sha) if runs is None: return { "verdict": "unverified", @@ -4379,8 +4419,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 送れない・報告されたコミットが送り先に載っていないときは、記録も投稿もせずに # 止まる。同じ取り込みをやり直せば、同じ手順を最初から通る。 commit = fix.get("fix_commit") or fix.get("commit_sha") - pushed = result_posts.push_fix(str(st.get("worktree_path") or ""), - str(st.get("head_branch") or ""), commit) + pushed = GITHUB.push_fix(str(st.get("worktree_path") or ""), + str(st.get("head_branch") or ""), commit) if not pushed.ok: die(f"修正を送れないか、報告されたコミットが送り先に載っていません: {pushed.detail}") print(f"PUSHED={1 if pushed.pushed else 0} COMMIT_ON_HEAD={1 if pushed.contains else 0}") @@ -4388,7 +4428,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: round_fix = _merge_fix_records(st, fix, pr) _save(pr, st) - posted = result_posts.post_fix( + posted = GITHUB.post_fix( _queue(pr), fix[FIX_SOURCE_KEY], str(st.get("repo") or ""), int(st.get("current_pr") or pr), round_no=st["rounds"][-1].get("round"), actor=str(st.get("viewer_login") or "") or None) diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf/skills/cross-review/tests/conftest.py index 7e87c901..e058e281 100644 --- a/plugins/ndf/skills/cross-review/tests/conftest.py +++ b/plugins/ndf/skills/cross-review/tests/conftest.py @@ -48,6 +48,9 @@ def _load_monitor_module() -> types.ModuleType: # 既定で差し替える、GitHub を読みに行く関数。実物は `_REAL` へ退避する。 +# **境界(`state_mod.GITHUB`)越しに差し替える**(#801 R3-004)。非公開関数名を直接 +# monkeypatch すると、内部関数の抽出や移動だけでテスト基盤が壊れるため、入出力境界を +# 通す。取得の実物を戻す `real_github` は `_REAL` から組み立て直す。 _GITHUB_LOOKUPS = ("_fetch_check_runs", "_fetch_pr_metadata") _REAL: dict[str, object] = {} @@ -129,18 +132,20 @@ def _no_github_state(request, monkeypatch) -> None: if "state_mod" not in request.fixturenames: return state_mod = request.getfixturevalue("state_mod") - monkeypatch.setattr(state_mod, "_fetch_check_runs", lambda repo, sha: None) - monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: None) - # **取り込みはレビューを投稿する**(#730)。投稿を見ないテストでは、組み立てまでを - # 本物で通し、送信だけを「届いた」に置き換える。偽の `gh` を要求するテストは - # 送信も含めて検査するため置き換えない。 - if "fake_gh" not in request.fixturenames: - rp = state_mod.result_posts - monkeypatch.setattr(rp, "post_review", _post_review_offline(rp)) - monkeypatch.setattr(rp, "push_fix", - lambda worktree, head, commit: rp.PushResult( - True, bool(commit), True, "")) - monkeypatch.setattr(rp, "post_fix", _post_fix_offline(rp)) + rp = state_mod.result_posts + # **境界(`GITHUB`)越しに差し替える**(#801 R3-004)。取得は「確かめられなかった」に + # 倒し、投稿は既定でオフライン実装を渡す。偽の `gh` を要求するテストは送信も含めて + # 検査するため、投稿だけは実物を残す。 + offline = "fake_gh" not in request.fixturenames + gateway = state_mod.GitHubGateway( + fetch_pr_metadata=lambda pr, repo=None: None, + fetch_check_runs=lambda repo, sha: None, + post_review=_post_review_offline(rp) if offline else rp.post_review, + post_fix=_post_fix_offline(rp) if offline else rp.post_fix, + push_fix=(lambda worktree, head, commit: rp.PushResult( + True, bool(commit), True, "")) if offline else rp.push_fix, + ) + monkeypatch.setattr(state_mod, "GITHUB", gateway) def _post_review_offline(rp): @@ -165,9 +170,15 @@ def real_github(monkeypatch, state_mod): 取得そのものの組み立てを見るテストが使う。GitHub へは `_gh_rest` か `subprocess.run` の差し替えで届かないようにする。 + + **境界(`GITHUB`)の取得だけを実物へ戻す**(#801 R3-004)。`_no_github_state` が先に + 置いたオフラインの投稿はそのまま残す(`_replace` で取得の 2 つだけ差し替える)。 """ - for name in _GITHUB_LOOKUPS: - monkeypatch.setattr(state_mod, name, _REAL[name]) + gateway = state_mod.GITHUB._replace( + fetch_pr_metadata=_REAL["_fetch_pr_metadata"], + fetch_check_runs=_REAL["_fetch_check_runs"], + ) + monkeypatch.setattr(state_mod, "GITHUB", gateway) # ---- 模した `gh` を PATH の先頭へ置く(#291) ---- diff --git a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py index b8f91814..bbb2ecad 100644 --- a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py +++ b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py @@ -1,4 +1,4 @@ -"""`_init_new_state` の本体が 2 回現れないことを機械で見る。 +"""`_init_new_state` の経路が 2 回現れないことを機械で見る。 **構造改善(`extract_method`)で旧本体の削除が漏れると、抽出後の本体がそのまま 2 回並ぶ。** 実際に PR #549 の `cmd_init` の抽出でこれが起き、`_fetch_pr_metadata` / @@ -7,7 +7,13 @@ 2 回出ていた。 **経路そのものは `gh` を要するため実行では確かめない。** 関数の構造(同じ文が 2 回 -現れない・出力が 1 回だけ)を構文木で見る。 +現れない・副作用の呼び出しが 1 回だけ)を構文木で見る。 + +#801 の構造改善で `_init_new_state` の各段はトップレベルの関数へ切り出された +(`_resolve_pr_and_ownership` ほか)。二重化はどの関数の中でも起こりうるため、 +検査の対象を初期化の関数群全体へ広げる。GitHub 取得は入出力境界 +(`GITHUB.fetch_pr_metadata` / `GITHUB.fetch_check_runs`)越しに呼ぶため、属性の +呼び出しとしても数える。 """ from __future__ import annotations @@ -19,43 +25,72 @@ STATE_PY = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "state.py" +# 初期化の経路を構成するトップレベル関数(#801 で切り出した段を含む)。 +INIT_FUNCTIONS = ( + "_init_new_state", + "_resolve_pr_and_ownership", + "_prepare_review_instructions", + "_prepare_worktree_and_comments", + "_prepare_initial_assignment", + "_build_initial_review_state", + "_finalize_initial_state", +) + # 1 回しか呼んではいけないもの。**副作用を持つ**か、標準出力の機械可読ブロックを書く。 +# 取得は境界越しになったため、属性名(`fetch_pr_metadata`)でも数える。 SINGLE_CALL = ( "_print_init_result", - "_fetch_pr_metadata", + "fetch_pr_metadata", "_fetch_changed_files", "_tmp_dir", ) @pytest.fixture(scope="module") -def init_new_state() -> ast.FunctionDef: +def init_functions() -> dict[str, ast.FunctionDef]: tree = ast.parse(STATE_PY.read_text(encoding="utf-8")) - for node in ast.walk(tree): - if isinstance(node, ast.FunctionDef) and node.name == "_init_new_state": - return node - raise AssertionError("_init_new_state が見つからない") + found = { + node.name: node + for node in ast.walk(tree) + if isinstance(node, ast.FunctionDef) and node.name in INIT_FUNCTIONS + } + missing = [name for name in INIT_FUNCTIONS if name not in found] + assert not missing, f"初期化の関数が見つからない: {missing}" + return found -def test_no_statement_appears_twice(init_new_state: ast.FunctionDef) -> None: - """本体の直下に、まったく同じ文が 2 回並ばない。 +def test_no_statement_appears_twice( + init_functions: dict[str, ast.FunctionDef] +) -> None: + """各初期化関数の本体の直下に、まったく同じ文が 2 回並ばない。 丸ごとの複製はこの形でしか起こらない(`if meta is None:` も `_print_init_result(...)` も 2 回現れていた)。 """ - dumps = [ast.dump(stmt) for stmt in init_new_state.body] - repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] - assert not repeated, ( - f"_init_new_state の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" - ) + for name, func in init_functions.items(): + dumps = [ast.dump(stmt) for stmt in func.body] + repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] + assert not repeated, ( + f"{name} の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" + ) + + +def _call_name(node: ast.Call) -> str | None: + if isinstance(node.func, ast.Name): + return node.func.id + if isinstance(node.func, ast.Attribute): + return node.func.attr + return None @pytest.mark.parametrize("name", SINGLE_CALL) -def test_the_call_appears_once(init_new_state: ast.FunctionDef, name: str) -> None: +def test_the_call_appears_once( + init_functions: dict[str, ast.FunctionDef], name: str +) -> None: calls = [ - node for node in ast.walk(init_new_state) - if isinstance(node, ast.Call) - and isinstance(node.func, ast.Name) - and node.func.id == name + node + for func in init_functions.values() + for node in ast.walk(func) + if isinstance(node, ast.Call) and _call_name(node) == name ] assert len(calls) == 1, f"{name} が {len(calls)} 回呼ばれている" diff --git a/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py index 7956614c..09c49af4 100644 --- a/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py +++ b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py @@ -70,8 +70,8 @@ def post(queue, result_path, repo, pr, round_no=None, actor=None): seen["post"].append([i["kind"] for i in items]) return rp.FixOutcome("https://x/pull/5850#issuecomment-9", 1, 1, 0, False, "") - monkeypatch.setattr(rp, "push_fix", push) - monkeypatch.setattr(rp, "post_fix", post) + monkeypatch.setattr(state_mod, "GITHUB", + state_mod.GITHUB._replace(push_fix=push, post_fix=post)) return seen diff --git a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py index 1041e192..2c4fb3ed 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py @@ -83,9 +83,10 @@ def test_init_stores_an_empty_rejected_findings_list(tmp_dir, state_mod, monkeyp worktree = tmp_dir / "worktree" worktree.mkdir() monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO) - monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo: - state_mod.PrMetadata(REPO, "author", "feature/test", "abc", - "develop", False, 4000, None)) + monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( + fetch_pr_metadata=lambda pr, repo: + state_mod.PrMetadata(REPO, "author", "feature/test", "abc", + "develop", False, 4000, None))) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "viewer") monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda path: True) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py b/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py index 3f9d945b..0e6ed4a1 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py @@ -111,7 +111,8 @@ def _spy(runs): return real(runs) monkeypatch.setattr(state_mod, "_classify_ci", _spy) - monkeypatch.setattr(state_mod, "_fetch_check_runs", lambda repo, sha: [_run("pytest")]) + monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( + fetch_check_runs=lambda repo, sha: [_run("pytest")])) # 修正の取り込み側: 申告された失敗の名前を読む approved = { diff --git a/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py b/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py index c678e2c8..8d28904e 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py @@ -45,9 +45,9 @@ def stub_init_scaffolding(monkeypatch, state_mod, tmp_path): worktree.mkdir() monkeypatch.setattr( - state_mod, "_fetch_pr_metadata", - lambda pr, repo=None: state_mod.PrMetadata( - REPO, "takemi", "feat/x", "abc123", "develop", False, 4000, None), + state_mod, "GITHUB", state_mod.GITHUB._replace( + fetch_pr_metadata=lambda pr, repo=None: state_mod.PrMetadata( + REPO, "takemi", "feat/x", "abc123", "develop", False, 4000, None)), ) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "takemi") monkeypatch.setattr(state_mod, "_create_worktree", lambda *a: None) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py b/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py index cda23b03..298a8c1a 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py @@ -77,9 +77,9 @@ def stub_init_scaffolding(monkeypatch, state_mod, tmp_path): worktree = tmp_path / "wt-not-created-yet" # 存在しないパス(新規作成扱い) monkeypatch.setattr( - state_mod, "_fetch_pr_metadata", - lambda pr, repo=None: state_mod.PrMetadata( - REPO, "takemi", HEAD_BRANCH, "abc123", "develop", True, 4000, None), + state_mod, "GITHUB", state_mod.GITHUB._replace( + fetch_pr_metadata=lambda pr, repo=None: state_mod.PrMetadata( + REPO, "takemi", HEAD_BRANCH, "abc123", "develop", True, 4000, None)), ) monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py b/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py index 04a5dae0..1e557519 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py @@ -45,7 +45,8 @@ def _set(runs): def _fetch(repo, sha): calls.append((repo, sha)) return runs - monkeypatch.setattr(state_mod, "_fetch_check_runs", _fetch) + monkeypatch.setattr(state_mod, "GITHUB", + state_mod.GITHUB._replace(fetch_check_runs=_fetch)) return calls _set.calls = calls # type: ignore[attr-defined] @@ -242,7 +243,8 @@ def _meta(pr, repo=None): base_branch="develop", is_fork=False, rate_remaining=None, rate_reset=None, ) - monkeypatch.setattr(state_mod, "_fetch_pr_metadata", _meta) + monkeypatch.setattr(state_mod, "GITHUB", + state_mod.GITHUB._replace(fetch_pr_metadata=_meta)) round_ = _approved_round() del round_["head_sha"] _write(tmp_dir, _state([round_])) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py b/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py index 99b3c505..4b10ac76 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py @@ -125,9 +125,10 @@ def test_the_viewer_login_is_kept_in_the_state(state_mod, tmp_dir, monkeypatch, """一度取ったログイン名を状態ファイルへ持つ。待ち行列の冪等の照合が使う。""" worktree = tmp_path / "wt" worktree.mkdir() - monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: - state_mod.PrMetadata(REPO, "takemi", "feat/x", "abc", - "develop", False, 4000, None)) + monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( + fetch_pr_metadata=lambda pr, repo=None: + state_mod.PrMetadata(REPO, "takemi", "feat/x", "abc", + "develop", False, 4000, None))) monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "takemi") monkeypatch.setattr(state_mod, "_create_worktree", lambda *a: None) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py index df13827f..ff564c09 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py @@ -358,9 +358,10 @@ def new_init(state_mod, monkeypatch, tmp_path): """新規の初期化を GitHub と git に触れずに通す。""" (tmp_path / "wt").mkdir(exist_ok=True) monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO_INIT) - monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: - state_mod.PrMetadata(REPO_INIT, "author", "feat/x", "abc", - "develop", False, 4000, None)) + monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( + fetch_pr_metadata=lambda pr, repo=None: + state_mod.PrMetadata(REPO_INIT, "author", "feat/x", "abc", + "develop", False, 4000, None))) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "viewer") monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda path: True) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py b/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py index 862e1ce1..4a2951e4 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py @@ -172,9 +172,9 @@ def test_new_init_writes_the_summary(state_mod, review_dirs, monkeypatch): worktree, tmp_dir, metrics = review_dirs monkeypatch.setattr(state_mod, "_repo_from_git", lambda: "devbasex/ai-plugins") monkeypatch.setattr( - state_mod, "_fetch_pr_metadata", - lambda pr, repo=None: state_mod.PrMetadata( - "devbasex/ai-plugins", "takemi", "feat/x", "abc123", "develop", True, 4000, None)) + state_mod, "GITHUB", state_mod.GITHUB._replace( + fetch_pr_metadata=lambda pr, repo=None: state_mod.PrMetadata( + "devbasex/ai-plugins", "takemi", "feat/x", "abc123", "develop", True, 4000, None))) monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda wt: True) monkeypatch.setattr(state_mod, "_sync_worktree", lambda *a, **k: None) From a824f7e508dbb3ba85cb478f7bd970eb1b690330 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:21:40 +0000 Subject: [PATCH 195/217] =?UTF-8?q?Revert=20"Refactor:=20consolidate=5Fdup?= =?UTF-8?q?lication=20/=20extract=5Fmethod=20/=20fix=5Fdependency=5Fdirect?= =?UTF-8?q?ion=20=E2=80=94=20cross-review=20=E5=8F=8E=E6=9D=9F=E3=83=AB?= =?UTF-8?q?=E3=83=BC=E3=83=97=E3=81=AE=E6=A7=8B=E9=80=A0=E6=94=B9=E5=96=84?= =?UTF-8?q?"?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This reverts commit 6695c13e0b1af54aa8e929bfe766f2c0d511362a. --- plugins/ndf/scripts/lib/assignment.py | 108 ++--- plugins/ndf/scripts/lib/result_posts.py | 23 +- .../ndf/skills/cross-review/scripts/state.py | 416 ++++++++---------- .../ndf/skills/cross-review/tests/conftest.py | 39 +- .../tests/test_init_body_not_duplicated.py | 75 +--- .../tests/test_merge_fix_posts.py | 4 +- .../tests/test_rejected_findings.py | 7 +- .../tests/test_state_ci_classification.py | 3 +- .../test_state_init_changed_files_fallback.py | 6 +- .../test_state_init_worktree_creation.py | 6 +- .../cross-review/tests/test_state_judge_ci.py | 6 +- .../tests/test_state_offline_fetch.py | 7 +- .../tests/test_state_review_pool.py | 7 +- .../tests/test_state_run_metrics.py | 6 +- 14 files changed, 297 insertions(+), 416 deletions(-) diff --git a/plugins/ndf/scripts/lib/assignment.py b/plugins/ndf/scripts/lib/assignment.py index 4290a391..a01928c4 100644 --- a/plugins/ndf/scripts/lib/assignment.py +++ b/plugins/ndf/scripts/lib/assignment.py @@ -207,71 +207,6 @@ def to_state(self) -> dict[str, Any]: Probe = Callable[[list[str]], tuple[dict[str, dict[str, Any]], bool]] -def _validate_and_build_participants( - pool: list[str], include: list[str], exclude: list[str], -) -> list[str]: - """`include` / `exclude` の整合性を検査し、参加者列(`pool ∪ include − exclude`)を返す。 - - 順序 1〜2(`resolve_participants` の docstring)を担う。名前が `ALL_RUNTIMES` に - あること・足す者と外す者が重ならないこと・外す者が母集合に含まれることを確かめる。 - """ - for name in (*include, *exclude): - if name not in ALL_RUNTIMES: - raise AssignmentError( - f"参加できないランタイムです: {name}({'/'.join(ALL_RUNTIMES)} のいずれか)" - ) - overlap = set(include) & set(exclude) - if overlap: - raise AssignmentError( - f"足す者と外す者に同じ名前があります: {', '.join(_in_fixed_order(overlap))}" - ) - base = set(pool) | set(include) - outside = [n for n in exclude if n not in base] - if outside: - raise AssignmentError( - f"母集合に無い者は外せません: {', '.join(_in_fixed_order(outside))}" - f"(母集合: {', '.join(_in_fixed_order(base))})" - ) - return _in_fixed_order(base - set(exclude)) - - -def _apply_only(participants: list[str], only: str | None, exclude: list[str]) -> list[str]: - """`only` を検査して適用し、参加者列を返す(順序 3)。 - - `only` が `None` なら参加者列をそのまま返す。指定があれば `exclude` と矛盾せず、 - 参加者に含まれることを確かめ、参加者をその 1 者にする。 - """ - if only is None: - return participants - if only in exclude: - raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") - if only not in participants: - raise AssignmentError( - f"--only は参加者のいずれかを指定してください: {only}" - f"(参加者: {', '.join(participants)})" - ) - return [only] - - -def _classify_probe( - participants: list[str], probe: Probe, -) -> tuple[list[str], dict[str, str], bool]: - """`probe` の戻り値から `(available, unavailable, probe_skipped)` を作る(順序 4)。 - - 飛ばされたら全員を通ったものとし、そうでなければ通らなかった者と理由を集める。 - """ - results, skipped = probe(list(participants)) - if skipped: - return list(participants), {}, True - unavailable = { - n: str(results.get(n, {}).get("detail", "")) - for n in participants - if not results.get(n, {}).get("ok", False) - } - available = [n for n in participants if n not in unavailable] - return available, unavailable, skipped - - def resolve_participants( pool: Iterable[str], *, @@ -302,9 +237,46 @@ def resolve_participants( include = list(include) exclude = list(exclude) - participants = _validate_and_build_participants(pool, include, exclude) - participants = _apply_only(participants, only, exclude) - available, unavailable, skipped = _classify_probe(participants, probe) + for name in (*include, *exclude): + if name not in ALL_RUNTIMES: + raise AssignmentError( + f"参加できないランタイムです: {name}({'/'.join(ALL_RUNTIMES)} のいずれか)" + ) + overlap = set(include) & set(exclude) + if overlap: + raise AssignmentError( + f"足す者と外す者に同じ名前があります: {', '.join(_in_fixed_order(overlap))}" + ) + base = set(pool) | set(include) + outside = [n for n in exclude if n not in base] + if outside: + raise AssignmentError( + f"母集合に無い者は外せません: {', '.join(_in_fixed_order(outside))}" + f"(母集合: {', '.join(_in_fixed_order(base))})" + ) + + participants = _in_fixed_order(base - set(exclude)) + + if only is not None: + if only in exclude: + raise AssignmentError(f"--only と --exclude が矛盾しています: {only}") + if only not in participants: + raise AssignmentError( + f"--only は参加者のいずれかを指定してください: {only}" + f"(参加者: {', '.join(participants)})" + ) + participants = [only] + + results, skipped = probe(list(participants)) + if skipped: + available, unavailable = list(participants), {} + else: + unavailable = { + n: str(results.get(n, {}).get("detail", "")) + for n in participants + if not results.get(n, {}).get("ok", False) + } + available = [n for n in participants if n not in unavailable] if require_all and unavailable: failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 756df88f..95a5fa3c 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -203,21 +203,22 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, 同じ状態で返る別の拒まれ方(判定の値の誤り・基準のコミットの誤り)は退避せず、 失敗として残す。 """ - def _enqueue_review(evacuate_all: bool) -> tuple[dict[str, Any], Any, Any]: - """投稿項目を組み立てて待ち行列へ積み、`(項目, seq, 流した結果)` を返す。""" - item = review_posts(payload_path, result_path, repo, pr, round_no, seat, - head_sha, is_own_pr, evacuate_all=evacuate_all)[0] - path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], - actor=actor, extra=item["extra"]) - seq = (post_queue.read_item(path) or {}).get("seq") - return item, seq, queue.flush() - findings = len(_findings(_read_json(payload_path))) - item, seq, flushed = _enqueue_review(evacuate_all=False) + item = review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr)[0] + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + flushed = queue.flush() if flushed.failed and post_queue.rejected_by_position(flushed.failed): queue.drop(flushed.failed.get("seq")) - item, seq, flushed = _enqueue_review(evacuate_all=True) + item = review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr, evacuate_all=True)[0] + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + flushed = queue.flush() done = _find(flushed.sent, seq) or _find(flushed.skipped, seq) failed = flushed.failed is not None and flushed.failed.get("seq") == seq diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 3b5a5a9d..89be6d14 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -578,41 +578,6 @@ def _fetch_check_runs(repo: str, sha: str) -> list[dict[str, Any]] | None: return runs or None -class GitHubGateway(NamedTuple): - """GitHub 取得と投稿処理をまとめた入出力境界(#801 R3-004)。 - - **副コマンドはこの境界を通してのみ GitHub へ触れる。** 本番経路では現在の関数群を - 束ねた実体(`_real_github_gateway`)を渡し、テストはオフライン実装を束ねた別の - 実体を注入する。境界を挟むことで、内部関数(`_fetch_check_runs` / - `_fetch_pr_metadata`)や `result_posts` の関数を名前で差し替えなくてよくなる。 - - フィールドは呼ばれる側の 5 つ。`fetch_pr_metadata` / `fetch_check_runs` は - このモジュールの関数、`post_review` / `post_fix` / `push_fix` は `result_posts` - の関数と同じ呼び出し規約を持つ。 - """ - - fetch_pr_metadata: Any - fetch_check_runs: Any - post_review: Any - post_fix: Any - push_fix: Any - - -def _real_github_gateway() -> GitHubGateway: - """本番経路の境界。現在の関数群をそのまま束ねる。""" - return GitHubGateway( - fetch_pr_metadata=_fetch_pr_metadata, - fetch_check_runs=_fetch_check_runs, - post_review=result_posts.post_review, - post_fix=result_posts.post_fix, - push_fix=result_posts.push_fix, - ) - - -# 副コマンドが通す境界。テストは `state_mod.GITHUB` を差し替える(`conftest.py`)。 -GITHUB: GitHubGateway = _real_github_gateway() - - class HeadRef(NamedTuple): """レビュー対象の Pull Request の head。 @@ -637,7 +602,7 @@ def _resolve_head_ref(pr: int, code: int = 8, repo: str | None = None) -> HeadRe 照会は REST の 1 回で、ブランチ名・commit・フォークの別が同じ応答から取れる。 """ - meta = GITHUB.fetch_pr_metadata(pr, repo) + meta = _fetch_pr_metadata(pr, repo) if meta is None: die(f"PR #{pr} の head を取得できない", code=code) raise SystemExit(code) # die は戻らないが、型のために置く @@ -1863,192 +1828,6 @@ class _InitialStateContext(NamedTuple): manual_extra_review: str -def _resolve_pr_and_ownership( - pr: object, repo: str, worktree: str, args_worktree: str | None -) -> _InitPRContext | None: - # 新規 init: プリチェック。 - # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を - # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 - meta = GITHUB.fetch_pr_metadata(pr, repo) - if meta is None: - die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") - return None - if meta.repo != repo: - repo = meta.repo - if not args_worktree: - worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") - if meta.rate_remaining is not None: - info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") - - me = _sh(["gh", "api", "user", "--jq", ".login"]) - author = meta.author - is_own = (me == author) - event_downgrade = is_own - if is_own: - info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") - - return _InitPRContext( - repo=repo, - worktree=worktree, - meta=meta, - me=me, - author=author, - is_own=is_own, - event_downgrade=event_downgrade, - ) - - -def _prepare_review_instructions( - pr: object, repo: str, manual_extra_review: str -) -> _InitReviewContext: - changed_files = _fetch_changed_files(pr, repo) - auto_review_categories = _classify_changed_files(changed_files) - auto_review = _auto_review_instructions(auto_review_categories) - review_instructions = _combined_review_instructions(auto_review, manual_extra_review) - return _InitReviewContext( - changed_files=changed_files, - auto_review_categories=auto_review_categories, - auto_review=auto_review, - review_instructions=review_instructions, - ) - - -def _prepare_worktree_and_comments( - worktree: str, pr: object, head_branch: str, repo: str -) -> _InitWorkspaceContext: - # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する - if not pathlib.Path(worktree).exists(): - _create_worktree(worktree, pr, head_branch) - elif _is_registered_worktree(worktree): - info(f"↻ 既存 worktree 流用: {worktree}") - _sync_worktree(worktree, pr, head_branch) - else: - # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 - # 流用すると git 操作が壊れるため退避して作り直す。 - stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" - pathlib.Path(worktree).rename(stale) - info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") - _create_worktree(worktree, pr, head_branch) - - # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) - tmp_dir = _tmp_dir(worktree) - state_file = tmp_dir / f"cross-review-pr{pr}-state.json" - - # 既存コメントスナップショット(重複指摘防止)。 - # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を - # fix skill の共有スクリプトで一括取得する。 - fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" - r = subprocess.run( - [str(fetch_script), repo, str(pr)], - capture_output=True, text=True, - ) - existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" - if r.returncode == 0: - existing_path.write_text(r.stdout, encoding="utf-8") - else: - die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") - - return _InitWorkspaceContext( - tmp_dir=tmp_dir, - state_file=state_file, - ) - - -def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: - """担当ホストを確定し、起動対象の認証を検査する。""" - # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 - # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 - try: - host, host_source = assignment.detect_host(getattr(args, "host", None)) - except assignment.AssignmentError as e: - die(str(e)) - raise - info(f"ホストの判定: {host}({host_source})") - # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに - # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 - participants = _resolve_reviewers(host, args) - return _InitialAssignment( - host=host, host_source=host_source, participants=participants) - - -def _build_initial_review_state( - args: argparse.Namespace, - ctx: _InitialStateContext, -) -> dict[str, Any]: - """確定済みの材料から、副作用なしに初期状態を組み立てる。""" - host, host_source, participants = ctx.assignment - only, _include, _exclude = _normalize_participant_args(args) - return { - "started_at": _now(), - "host": host, - "host_source": host_source, - # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) - "max_rounds": args.max_rounds if args.max_rounds is not None else 12, - "rotate_after": args.rotate_after if args.rotate_after is not None else 8, - "only": only, - "participants": participants, - "resume_changes": [], - "current_pr": ctx.pr, - "worktree_path": ctx.pr_ctx.worktree, - "tmp_dir": str(ctx.ws_ctx.tmp_dir), - "repo": ctx.pr_ctx.repo, - "head_branch": ctx.pr_ctx.meta.head_branch, - "base_branch": ctx.pr_ctx.meta.base_branch, - "pr_author": ctx.pr_ctx.author, - "viewer_login": ctx.pr_ctx.me, - "is_own_pr": ctx.pr_ctx.is_own, - "event_downgrade": ctx.pr_ctx.event_downgrade, - "changed_files": ctx.review_ctx.changed_files, - "auto_review_categories": ctx.review_ctx.auto_review_categories, - "auto_review_instructions": ctx.review_ctx.auto_review, - "manual_extra_review_instructions": ctx.manual_extra_review, - "extra_review_instructions": ctx.manual_extra_review, - "review_instructions": ctx.review_ctx.review_instructions, - "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], - "rounds": [], - "deferred_nits": [], - "rejected_findings": [], - "review_findings": [], - "evidence_rounds": [], - "verify_commands": list(getattr(args, "verify_command", None) or []), - "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), - "carried_over": None, - "final": None, - } - - -def _finalize_initial_state( - args: argparse.Namespace, - pr: object, - pr_ctx: _InitPRContext, - review_ctx: _InitReviewContext, - ws_ctx: _InitWorkspaceContext, - manual_extra_review: str, -) -> None: - initial_assignment = _prepare_initial_assignment(args) - context = _InitialStateContext( - pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review - ) - state = _build_initial_review_state(args, context) - _write_state(ws_ctx.state_file, state) - info(f"✅ state 初期化: {ws_ctx.state_file}") - _print_init_result( - _InitResult( - pr=pr, - worktree=pr_ctx.worktree, - tmp_dir=ws_ctx.tmp_dir, - repo=pr_ctx.repo, - head_branch=pr_ctx.meta.head_branch, - base_branch=pr_ctx.meta.base_branch, - is_own=pr_ctx.is_own, - event_downgrade=pr_ctx.event_downgrade, - has_extra=bool(review_ctx.review_instructions), - carried_count=0, - resumed=False, - ) - ) - - def _init_new_state( args: argparse.Namespace, pr: object, @@ -2057,6 +1836,187 @@ def _init_new_state( manual_extra_review: str, ) -> None: """新規 init 経路: プリチェック → worktree 作成 → state 構築 → 出力。""" + + def _resolve_pr_and_ownership( + pr: object, repo: str, worktree: str, args_worktree: str | None + ) -> _InitPRContext | None: + # 新規 init: プリチェック。 + # **作成者・head・base は REST の 1 回でまとめて取る。** 項目ごとに `gh pr view` を + # 投げていた分(GraphQL 3 点)と、リポジトリ名の解決(同 1 点)が 0 点になる。 + meta = _fetch_pr_metadata(pr, repo) + if meta is None: + die(f"PR #{pr} のメタデータを取得できません(リポジトリ名: {repo})") + return None + if meta.repo != repo: + repo = meta.repo + if not args_worktree: + worktree = str(_default_worktree_base() / _repo_slug(repo) / f"pr{pr}") + if meta.rate_remaining is not None: + info(f"ℹ GitHub REST の残量: {meta.rate_remaining}") + + me = _sh(["gh", "api", "user", "--jq", ".login"]) + author = meta.author + is_own = (me == author) + event_downgrade = is_own + if is_own: + info(f"⚠ 自分の PR (author={me}) — REQUEST_CHANGES → COMMENT 強制ダウングレード") + + return _InitPRContext( + repo=repo, + worktree=worktree, + meta=meta, + me=me, + author=author, + is_own=is_own, + event_downgrade=event_downgrade, + ) + + def _prepare_review_instructions( + pr: object, repo: str, manual_extra_review: str + ) -> _InitReviewContext: + changed_files = _fetch_changed_files(pr, repo) + auto_review_categories = _classify_changed_files(changed_files) + auto_review = _auto_review_instructions(auto_review_categories) + review_instructions = _combined_review_instructions(auto_review, manual_extra_review) + return _InitReviewContext( + changed_files=changed_files, + auto_review_categories=auto_review_categories, + auto_review=auto_review, + review_instructions=review_instructions, + ) + + def _prepare_worktree_and_comments( + worktree: str, pr: object, head_branch: str, repo: str + ) -> _InitWorkspaceContext: + # worktree 分離 — _tmp_dir() より先に worktree を作成/確認する + if not pathlib.Path(worktree).exists(): + _create_worktree(worktree, pr, head_branch) + elif _is_registered_worktree(worktree): + info(f"↻ 既存 worktree 流用: {worktree}") + _sync_worktree(worktree, pr, head_branch) + else: + # パスは存在するが現リポジトリの worktree ではない (別リポジトリの残骸等)。 + # 流用すると git 操作が壊れるため退避して作り直す。 + stale = f"{worktree}.stale-{time.strftime('%Y%m%d%H%M%S')}" + pathlib.Path(worktree).rename(stale) + info(f"⚠ 現リポジトリの worktree でないため退避: {stale}") + _create_worktree(worktree, pr, head_branch) + + # worktree 作成/確認後に _tmp_dir() を呼ぶ (ここで .cross_review/ が作られる) + tmp_dir = _tmp_dir(worktree) + state_file = tmp_dir / f"cross-review-pr{pr}-state.json" + + # 既存コメントスナップショット(重複指摘防止)。 + # 3 ソース (インラインコメント / レビュー body / PR レベルコメント) を + # fix skill の共有スクリプトで一括取得する。 + fetch_script = pathlib.Path(__file__).resolve().parent.parent.parent / "fix" / "scripts" / "fetch-pr-comments.sh" + r = subprocess.run( + [str(fetch_script), repo, str(pr)], + capture_output=True, text=True, + ) + existing_path = tmp_dir / f"cross-review-pr{pr}-existing-comments.txt" + if r.returncode == 0: + existing_path.write_text(r.stdout, encoding="utf-8") + else: + die(f"既存コメント取得失敗 (重複検出無効のため中断): {r.stderr.strip()[:200]}") + + return _InitWorkspaceContext( + tmp_dir=tmp_dir, + state_file=state_file, + ) + + def _prepare_initial_assignment(args: argparse.Namespace) -> _InitialAssignment: + """担当ホストを確定し、起動対象の認証を検査する。""" + # **ホストを先に確定する。** 誤ると母集合が狂い、ホストが自分自身をレビューする。 + # 推定できないときに既定を置かない(間違ったまま一周してしまう)。 + try: + host, host_source = assignment.detect_host(getattr(args, "host", None)) + except assignment.AssignmentError as e: + die(str(e)) + raise + info(f"ホストの判定: {host}({host_source})") + # 使える者の解決は共通層が持つ(#727)。通らない者は外して続け、席が 2 つに + # 満たなければホストで埋め合わせる。名前の矛盾と 0 者は終了コード 1。 + participants = _resolve_reviewers(host, args) + return _InitialAssignment( + host=host, host_source=host_source, participants=participants) + + def _build_initial_review_state( + args: argparse.Namespace, + ctx: _InitialStateContext, + ) -> dict[str, Any]: + """確定済みの材料から、副作用なしに初期状態を組み立てる。""" + host, host_source, participants = ctx.assignment + only, _include, _exclude = _normalize_participant_args(args) + return { + "started_at": _now(), + "host": host, + "host_source": host_source, + # 引数の既定は未指定(`None`)で、新規の経路がここで定数を置く(決定 13) + "max_rounds": args.max_rounds if args.max_rounds is not None else 12, + "rotate_after": args.rotate_after if args.rotate_after is not None else 8, + "only": only, + "participants": participants, + "resume_changes": [], + "current_pr": ctx.pr, + "worktree_path": ctx.pr_ctx.worktree, + "tmp_dir": str(ctx.ws_ctx.tmp_dir), + "repo": ctx.pr_ctx.repo, + "head_branch": ctx.pr_ctx.meta.head_branch, + "base_branch": ctx.pr_ctx.meta.base_branch, + "pr_author": ctx.pr_ctx.author, + "viewer_login": ctx.pr_ctx.me, + "is_own_pr": ctx.pr_ctx.is_own, + "event_downgrade": ctx.pr_ctx.event_downgrade, + "changed_files": ctx.review_ctx.changed_files, + "auto_review_categories": ctx.review_ctx.auto_review_categories, + "auto_review_instructions": ctx.review_ctx.auto_review, + "manual_extra_review_instructions": ctx.manual_extra_review, + "extra_review_instructions": ctx.manual_extra_review, + "review_instructions": ctx.review_ctx.review_instructions, + "pr_history": [{"pr": ctx.pr, "opened_at": _now(), "closed_at": None, "rounds": 0}], + "rounds": [], + "deferred_nits": [], + "rejected_findings": [], + "review_findings": [], + "evidence_rounds": [], + "verify_commands": list(getattr(args, "verify_command", None) or []), + "verify_exit_codes": list(getattr(args, "verify_exit_code", None) or []), + "carried_over": None, + "final": None, + } + + def _finalize_initial_state( + args: argparse.Namespace, + pr: object, + pr_ctx: _InitPRContext, + review_ctx: _InitReviewContext, + ws_ctx: _InitWorkspaceContext, + manual_extra_review: str, + ) -> None: + initial_assignment = _prepare_initial_assignment(args) + context = _InitialStateContext( + pr, pr_ctx, review_ctx, ws_ctx, initial_assignment, manual_extra_review + ) + state = _build_initial_review_state(args, context) + _write_state(ws_ctx.state_file, state) + info(f"✅ state 初期化: {ws_ctx.state_file}") + _print_init_result( + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, + ) + ) + pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) if pr_ctx is None: return @@ -2903,7 +2863,7 @@ def cmd_read_result(args: argparse.Namespace) -> None: st = _load(pr) last = st["rounds"][-1] round_no = last.get("round") - posted = GITHUB.post_review( + posted = result_posts.post_review( _queue(pr), _payload_path(agent, pr, round_no), rfile, @@ -2954,13 +2914,13 @@ def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, An repo = str(st.get("repo") or "") sha = str(last.get("head_sha") or "") if not sha: - meta = GITHUB.fetch_pr_metadata(pr, repo or None) + meta = _fetch_pr_metadata(pr, repo or None) if meta is not None: sha = meta.head_sha repo = repo or meta.repo if not repo or not sha: return {"verdict": "unverified", "reason": "head のコミットを特定できない"} - runs = GITHUB.fetch_check_runs(repo, sha) + runs = _fetch_check_runs(repo, sha) if runs is None: return { "verdict": "unverified", @@ -4419,8 +4379,8 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: # 送れない・報告されたコミットが送り先に載っていないときは、記録も投稿もせずに # 止まる。同じ取り込みをやり直せば、同じ手順を最初から通る。 commit = fix.get("fix_commit") or fix.get("commit_sha") - pushed = GITHUB.push_fix(str(st.get("worktree_path") or ""), - str(st.get("head_branch") or ""), commit) + pushed = result_posts.push_fix(str(st.get("worktree_path") or ""), + str(st.get("head_branch") or ""), commit) if not pushed.ok: die(f"修正を送れないか、報告されたコミットが送り先に載っていません: {pushed.detail}") print(f"PUSHED={1 if pushed.pushed else 0} COMMIT_ON_HEAD={1 if pushed.contains else 0}") @@ -4428,7 +4388,7 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: round_fix = _merge_fix_records(st, fix, pr) _save(pr, st) - posted = GITHUB.post_fix( + posted = result_posts.post_fix( _queue(pr), fix[FIX_SOURCE_KEY], str(st.get("repo") or ""), int(st.get("current_pr") or pr), round_no=st["rounds"][-1].get("round"), actor=str(st.get("viewer_login") or "") or None) diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf/skills/cross-review/tests/conftest.py index e058e281..7e87c901 100644 --- a/plugins/ndf/skills/cross-review/tests/conftest.py +++ b/plugins/ndf/skills/cross-review/tests/conftest.py @@ -48,9 +48,6 @@ def _load_monitor_module() -> types.ModuleType: # 既定で差し替える、GitHub を読みに行く関数。実物は `_REAL` へ退避する。 -# **境界(`state_mod.GITHUB`)越しに差し替える**(#801 R3-004)。非公開関数名を直接 -# monkeypatch すると、内部関数の抽出や移動だけでテスト基盤が壊れるため、入出力境界を -# 通す。取得の実物を戻す `real_github` は `_REAL` から組み立て直す。 _GITHUB_LOOKUPS = ("_fetch_check_runs", "_fetch_pr_metadata") _REAL: dict[str, object] = {} @@ -132,20 +129,18 @@ def _no_github_state(request, monkeypatch) -> None: if "state_mod" not in request.fixturenames: return state_mod = request.getfixturevalue("state_mod") - rp = state_mod.result_posts - # **境界(`GITHUB`)越しに差し替える**(#801 R3-004)。取得は「確かめられなかった」に - # 倒し、投稿は既定でオフライン実装を渡す。偽の `gh` を要求するテストは送信も含めて - # 検査するため、投稿だけは実物を残す。 - offline = "fake_gh" not in request.fixturenames - gateway = state_mod.GitHubGateway( - fetch_pr_metadata=lambda pr, repo=None: None, - fetch_check_runs=lambda repo, sha: None, - post_review=_post_review_offline(rp) if offline else rp.post_review, - post_fix=_post_fix_offline(rp) if offline else rp.post_fix, - push_fix=(lambda worktree, head, commit: rp.PushResult( - True, bool(commit), True, "")) if offline else rp.push_fix, - ) - monkeypatch.setattr(state_mod, "GITHUB", gateway) + monkeypatch.setattr(state_mod, "_fetch_check_runs", lambda repo, sha: None) + monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: None) + # **取り込みはレビューを投稿する**(#730)。投稿を見ないテストでは、組み立てまでを + # 本物で通し、送信だけを「届いた」に置き換える。偽の `gh` を要求するテストは + # 送信も含めて検査するため置き換えない。 + if "fake_gh" not in request.fixturenames: + rp = state_mod.result_posts + monkeypatch.setattr(rp, "post_review", _post_review_offline(rp)) + monkeypatch.setattr(rp, "push_fix", + lambda worktree, head, commit: rp.PushResult( + True, bool(commit), True, "")) + monkeypatch.setattr(rp, "post_fix", _post_fix_offline(rp)) def _post_review_offline(rp): @@ -170,15 +165,9 @@ def real_github(monkeypatch, state_mod): 取得そのものの組み立てを見るテストが使う。GitHub へは `_gh_rest` か `subprocess.run` の差し替えで届かないようにする。 - - **境界(`GITHUB`)の取得だけを実物へ戻す**(#801 R3-004)。`_no_github_state` が先に - 置いたオフラインの投稿はそのまま残す(`_replace` で取得の 2 つだけ差し替える)。 """ - gateway = state_mod.GITHUB._replace( - fetch_pr_metadata=_REAL["_fetch_pr_metadata"], - fetch_check_runs=_REAL["_fetch_check_runs"], - ) - monkeypatch.setattr(state_mod, "GITHUB", gateway) + for name in _GITHUB_LOOKUPS: + monkeypatch.setattr(state_mod, name, _REAL[name]) # ---- 模した `gh` を PATH の先頭へ置く(#291) ---- diff --git a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py index bbb2ecad..b8f91814 100644 --- a/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py +++ b/plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py @@ -1,4 +1,4 @@ -"""`_init_new_state` の経路が 2 回現れないことを機械で見る。 +"""`_init_new_state` の本体が 2 回現れないことを機械で見る。 **構造改善(`extract_method`)で旧本体の削除が漏れると、抽出後の本体がそのまま 2 回並ぶ。** 実際に PR #549 の `cmd_init` の抽出でこれが起き、`_fetch_pr_metadata` / @@ -7,13 +7,7 @@ 2 回出ていた。 **経路そのものは `gh` を要するため実行では確かめない。** 関数の構造(同じ文が 2 回 -現れない・副作用の呼び出しが 1 回だけ)を構文木で見る。 - -#801 の構造改善で `_init_new_state` の各段はトップレベルの関数へ切り出された -(`_resolve_pr_and_ownership` ほか)。二重化はどの関数の中でも起こりうるため、 -検査の対象を初期化の関数群全体へ広げる。GitHub 取得は入出力境界 -(`GITHUB.fetch_pr_metadata` / `GITHUB.fetch_check_runs`)越しに呼ぶため、属性の -呼び出しとしても数える。 +現れない・出力が 1 回だけ)を構文木で見る。 """ from __future__ import annotations @@ -25,72 +19,43 @@ STATE_PY = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "state.py" -# 初期化の経路を構成するトップレベル関数(#801 で切り出した段を含む)。 -INIT_FUNCTIONS = ( - "_init_new_state", - "_resolve_pr_and_ownership", - "_prepare_review_instructions", - "_prepare_worktree_and_comments", - "_prepare_initial_assignment", - "_build_initial_review_state", - "_finalize_initial_state", -) - # 1 回しか呼んではいけないもの。**副作用を持つ**か、標準出力の機械可読ブロックを書く。 -# 取得は境界越しになったため、属性名(`fetch_pr_metadata`)でも数える。 SINGLE_CALL = ( "_print_init_result", - "fetch_pr_metadata", + "_fetch_pr_metadata", "_fetch_changed_files", "_tmp_dir", ) @pytest.fixture(scope="module") -def init_functions() -> dict[str, ast.FunctionDef]: +def init_new_state() -> ast.FunctionDef: tree = ast.parse(STATE_PY.read_text(encoding="utf-8")) - found = { - node.name: node - for node in ast.walk(tree) - if isinstance(node, ast.FunctionDef) and node.name in INIT_FUNCTIONS - } - missing = [name for name in INIT_FUNCTIONS if name not in found] - assert not missing, f"初期化の関数が見つからない: {missing}" - return found + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == "_init_new_state": + return node + raise AssertionError("_init_new_state が見つからない") -def test_no_statement_appears_twice( - init_functions: dict[str, ast.FunctionDef] -) -> None: - """各初期化関数の本体の直下に、まったく同じ文が 2 回並ばない。 +def test_no_statement_appears_twice(init_new_state: ast.FunctionDef) -> None: + """本体の直下に、まったく同じ文が 2 回並ばない。 丸ごとの複製はこの形でしか起こらない(`if meta is None:` も `_print_init_result(...)` も 2 回現れていた)。 """ - for name, func in init_functions.items(): - dumps = [ast.dump(stmt) for stmt in func.body] - repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] - assert not repeated, ( - f"{name} の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" - ) - - -def _call_name(node: ast.Call) -> str | None: - if isinstance(node.func, ast.Name): - return node.func.id - if isinstance(node.func, ast.Attribute): - return node.func.attr - return None + dumps = [ast.dump(stmt) for stmt in init_new_state.body] + repeated = [d for d, n in collections.Counter(dumps).items() if n > 1] + assert not repeated, ( + f"_init_new_state の本体に同じ文が {len(repeated)} 種類、2 回以上現れる" + ) @pytest.mark.parametrize("name", SINGLE_CALL) -def test_the_call_appears_once( - init_functions: dict[str, ast.FunctionDef], name: str -) -> None: +def test_the_call_appears_once(init_new_state: ast.FunctionDef, name: str) -> None: calls = [ - node - for func in init_functions.values() - for node in ast.walk(func) - if isinstance(node, ast.Call) and _call_name(node) == name + node for node in ast.walk(init_new_state) + if isinstance(node, ast.Call) + and isinstance(node.func, ast.Name) + and node.func.id == name ] assert len(calls) == 1, f"{name} が {len(calls)} 回呼ばれている" diff --git a/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py index 09c49af4..7956614c 100644 --- a/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py +++ b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py @@ -70,8 +70,8 @@ def post(queue, result_path, repo, pr, round_no=None, actor=None): seen["post"].append([i["kind"] for i in items]) return rp.FixOutcome("https://x/pull/5850#issuecomment-9", 1, 1, 0, False, "") - monkeypatch.setattr(state_mod, "GITHUB", - state_mod.GITHUB._replace(push_fix=push, post_fix=post)) + monkeypatch.setattr(rp, "push_fix", push) + monkeypatch.setattr(rp, "post_fix", post) return seen diff --git a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py index 2c4fb3ed..1041e192 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py @@ -83,10 +83,9 @@ def test_init_stores_an_empty_rejected_findings_list(tmp_dir, state_mod, monkeyp worktree = tmp_dir / "worktree" worktree.mkdir() monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO) - monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( - fetch_pr_metadata=lambda pr, repo: - state_mod.PrMetadata(REPO, "author", "feature/test", "abc", - "develop", False, 4000, None))) + monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo: + state_mod.PrMetadata(REPO, "author", "feature/test", "abc", + "develop", False, 4000, None)) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "viewer") monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda path: True) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py b/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py index 0e6ed4a1..3f9d945b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_ci_classification.py @@ -111,8 +111,7 @@ def _spy(runs): return real(runs) monkeypatch.setattr(state_mod, "_classify_ci", _spy) - monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( - fetch_check_runs=lambda repo, sha: [_run("pytest")])) + monkeypatch.setattr(state_mod, "_fetch_check_runs", lambda repo, sha: [_run("pytest")]) # 修正の取り込み側: 申告された失敗の名前を読む approved = { diff --git a/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py b/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py index 8d28904e..c678e2c8 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_init_changed_files_fallback.py @@ -45,9 +45,9 @@ def stub_init_scaffolding(monkeypatch, state_mod, tmp_path): worktree.mkdir() monkeypatch.setattr( - state_mod, "GITHUB", state_mod.GITHUB._replace( - fetch_pr_metadata=lambda pr, repo=None: state_mod.PrMetadata( - REPO, "takemi", "feat/x", "abc123", "develop", False, 4000, None)), + state_mod, "_fetch_pr_metadata", + lambda pr, repo=None: state_mod.PrMetadata( + REPO, "takemi", "feat/x", "abc123", "develop", False, 4000, None), ) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "takemi") monkeypatch.setattr(state_mod, "_create_worktree", lambda *a: None) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py b/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py index 298a8c1a..cda23b03 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_init_worktree_creation.py @@ -77,9 +77,9 @@ def stub_init_scaffolding(monkeypatch, state_mod, tmp_path): worktree = tmp_path / "wt-not-created-yet" # 存在しないパス(新規作成扱い) monkeypatch.setattr( - state_mod, "GITHUB", state_mod.GITHUB._replace( - fetch_pr_metadata=lambda pr, repo=None: state_mod.PrMetadata( - REPO, "takemi", HEAD_BRANCH, "abc123", "develop", True, 4000, None)), + state_mod, "_fetch_pr_metadata", + lambda pr, repo=None: state_mod.PrMetadata( + REPO, "takemi", HEAD_BRANCH, "abc123", "develop", True, 4000, None), ) monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py b/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py index 1e557519..04a5dae0 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_judge_ci.py @@ -45,8 +45,7 @@ def _set(runs): def _fetch(repo, sha): calls.append((repo, sha)) return runs - monkeypatch.setattr(state_mod, "GITHUB", - state_mod.GITHUB._replace(fetch_check_runs=_fetch)) + monkeypatch.setattr(state_mod, "_fetch_check_runs", _fetch) return calls _set.calls = calls # type: ignore[attr-defined] @@ -243,8 +242,7 @@ def _meta(pr, repo=None): base_branch="develop", is_fork=False, rate_remaining=None, rate_reset=None, ) - monkeypatch.setattr(state_mod, "GITHUB", - state_mod.GITHUB._replace(fetch_pr_metadata=_meta)) + monkeypatch.setattr(state_mod, "_fetch_pr_metadata", _meta) round_ = _approved_round() del round_["head_sha"] _write(tmp_dir, _state([round_])) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py b/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py index 4b10ac76..99b3c505 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_offline_fetch.py @@ -125,10 +125,9 @@ def test_the_viewer_login_is_kept_in_the_state(state_mod, tmp_dir, monkeypatch, """一度取ったログイン名を状態ファイルへ持つ。待ち行列の冪等の照合が使う。""" worktree = tmp_path / "wt" worktree.mkdir() - monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( - fetch_pr_metadata=lambda pr, repo=None: - state_mod.PrMetadata(REPO, "takemi", "feat/x", "abc", - "develop", False, 4000, None))) + monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: + state_mod.PrMetadata(REPO, "takemi", "feat/x", "abc", + "develop", False, 4000, None)) monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "takemi") monkeypatch.setattr(state_mod, "_create_worktree", lambda *a: None) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py index ff564c09..df13827f 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py @@ -358,10 +358,9 @@ def new_init(state_mod, monkeypatch, tmp_path): """新規の初期化を GitHub と git に触れずに通す。""" (tmp_path / "wt").mkdir(exist_ok=True) monkeypatch.setattr(state_mod, "_repo_from_git", lambda: REPO_INIT) - monkeypatch.setattr(state_mod, "GITHUB", state_mod.GITHUB._replace( - fetch_pr_metadata=lambda pr, repo=None: - state_mod.PrMetadata(REPO_INIT, "author", "feat/x", "abc", - "develop", False, 4000, None))) + monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: + state_mod.PrMetadata(REPO_INIT, "author", "feat/x", "abc", + "develop", False, 4000, None)) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "viewer") monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda path: True) diff --git a/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py b/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py index 4a2951e4..862e1ce1 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_run_metrics.py @@ -172,9 +172,9 @@ def test_new_init_writes_the_summary(state_mod, review_dirs, monkeypatch): worktree, tmp_dir, metrics = review_dirs monkeypatch.setattr(state_mod, "_repo_from_git", lambda: "devbasex/ai-plugins") monkeypatch.setattr( - state_mod, "GITHUB", state_mod.GITHUB._replace( - fetch_pr_metadata=lambda pr, repo=None: state_mod.PrMetadata( - "devbasex/ai-plugins", "takemi", "feat/x", "abc123", "develop", True, 4000, None))) + state_mod, "_fetch_pr_metadata", + lambda pr, repo=None: state_mod.PrMetadata( + "devbasex/ai-plugins", "takemi", "feat/x", "abc123", "develop", True, 4000, None)) monkeypatch.setattr(state_mod, "_fetch_changed_files", lambda pr, repo: []) monkeypatch.setattr(state_mod, "_is_registered_worktree", lambda wt: True) monkeypatch.setattr(state_mod, "_sync_worktree", lambda *a, **k: None) From b2d7b30137ce4238fd4a684c14731a4b2bd87b7c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:25:26 +0000 Subject: [PATCH 196/217] =?UTF-8?q?Refactor:=20consolidate=5Fduplication?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/result=5Fposts.py#fix=5F?= =?UTF-8?q?posts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit resolved / deferred / rejected の返信を組み立てる同型の 3 つのループを、 (要素の列, 定型句, 理由キー) の対応表を回す 1 つのループへまとめた。 返信本文・並び順・件数は変わらない(決着・まとめの組み立ては据え置き)。 Item-Id: R3-002 Round: 3 Impl-Runtime: claude Impl-Model: claude-opus-5 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 32 ++++++++++++------------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 95a5fa3c..56b87d44 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -283,24 +283,22 @@ def fix_posts(result_path: pathlib.Path | str, repo: str, pr: int, rejected = _dict_items(fix.get("rejected")) commit = str(fix.get("fix_commit") or fix.get("commit_sha") or "") + # (要素の列, 返信の定型句, 理由を取り出すキー)。決着は理由の代わりにコミットを添える + reply_rules = ( + (resolved, "対応しました。", None), + (deferred, "見送ります。", "reason_for_deferral"), + (rejected, "この指摘は採らない判断です。", "reason_for_rejection"), + ) items: list[dict[str, Any]] = [] - for entry in resolved: - body = "対応しました。" + (f"({commit})" if commit else "") - reply = _reply(entry.get("comment_id"), body) - if reply: - items.append(reply) - for entry in deferred: - reason = str(entry.get("reason_for_deferral") or entry.get("reason") or "") - reply = _reply(entry.get("comment_id"), - f"見送ります。{reason}".strip()) - if reply: - items.append(reply) - for entry in rejected: - reason = str(entry.get("reason_for_rejection") or entry.get("reason") or "") - reply = _reply(entry.get("comment_id"), - f"この指摘は採らない判断です。{reason}".strip()) - if reply: - items.append(reply) + for entries, lead, reason_key in reply_rules: + for entry in entries: + if reason_key is None: + note = f"({commit})" if commit else "" + else: + note = str(entry.get(reason_key) or entry.get("reason") or "") + reply = _reply(entry.get("comment_id"), f"{lead}{note}".strip()) + if reply: + items.append(reply) # 見送り・却下は既定では決着させない(次のラウンドで見直す)。最終スイープは # スレッドを残さないため、要素の `resolve` を真にして決着まで求める。 closing = resolved + [e for e in deferred + rejected if e.get("resolve")] From 24a45731b9186319609161c4dd44eafba9853239 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:41:06 +0000 Subject: [PATCH 197/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20cross-review=20=E3=81=AE=E9=95=B7=E3=81=84=E5=87=A6?= =?UTF-8?q?=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前ラウンド検査、投稿キュー送信、fix 入力解決の各段階を補助メソッドへ抽出する。 Item-Id: R4-001 Round: 4 Impl-Runtime: codex Impl-Model: default --- plugins/ndf/scripts/lib/post_queue.py | 75 ++++++++------- plugins/ndf/scripts/lib/result_posts.py | 24 ++++- .../ndf/skills/cross-review/scripts/state.py | 92 +++++++++++-------- 3 files changed, 115 insertions(+), 76 deletions(-) diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index 30216535..15ea730e 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -553,6 +553,41 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: json.dump(item, f, indent=2, ensure_ascii=False) return path + def _item_to_send( + self, path: pathlib.Path + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None, + dict[str, Any] | None]: + """項目を読み、送る項目・既投稿・読込失敗のいずれかを返す。""" + item = _read_item(path) + if item is None: + return None, None, { + "path": str(path), + "last_error": f"待ち行列の項目を読めない ({path.name})", + } + found, row = posted_match(item) + if found is not True: + return item, None, None + if row is not None: + item["response"] = row + path.unlink(missing_ok=True) + return None, item, None + + def _send_item(self, path: pathlib.Path, item: dict[str, Any]) -> tuple[bool, Any]: + """1 項目を送り、成功時の応答または失敗情報を項目へ反映する。""" + attempt = send(item) + if attempt.ok: + try: + item["response"] = json.loads(attempt.stdout or "null") + except json.JSONDecodeError: + item["response"] = None + path.unlink(missing_ok=True) + return True, attempt + item["attempts"] = int(item.get("attempts") or 0) + 1 + item["last_error"] = attempt.summary() + item["last_status"] = attempt.http + path.write_text(json.dumps(item, indent=2, ensure_ascii=False), encoding="utf-8") + return False, attempt + def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -564,42 +599,18 @@ def flush(self) -> FlushResult: failed: dict[str, Any] | None = None rate_limited = False for path in self.paths(): - item = _read_item(path) - if item is None: - # **読めない項目を黙って飛ばさない。** `count()` はファイルを数え - # 続けるため、飛ばすと送りも失敗の報告もしないまま件数だけが残り、 - # 判定は終了コード 8 を返し続けて誰も直せない状態になる。ここで - # 止めて理由を返せば、その項目を捨てるか直すかを人が選べる。 - failed = { - "path": str(path), - "last_error": f"待ち行列の項目を読めない ({path.name})", - } + item, already_posted, read_failure = self._item_to_send(path) + if read_failure is not None: + failed = read_failure break - found, row = posted_match(item) - if found is True: - # **送った場合と同じ形で返す。** 呼び出し側は届いたことを応答から - # 確かめるため、既に届いていた項目にも見つけた投稿を積んで渡す。 - if row is not None: - item["response"] = row - path.unlink(missing_ok=True) - skipped.append(item) + if already_posted is not None: + skipped.append(already_posted) continue - attempt = send(item) - if attempt.ok: - try: - item["response"] = json.loads(attempt.stdout or "null") - except json.JSONDecodeError: - item["response"] = None - path.unlink(missing_ok=True) + assert item is not None + ok, attempt = self._send_item(path, item) + if ok: sent.append(item) continue - item["attempts"] = int(item.get("attempts") or 0) + 1 - item["last_error"] = attempt.summary() - # **状態も残す。** 拒まれ方の区別(位置を解決できない / それ以外)は、 - # 流した後に項目だけを見て決める。 - item["last_status"] = attempt.http - path.write_text(json.dumps(item, indent=2, ensure_ascii=False), - encoding="utf-8") failed = item rate_limited = is_rate_limited(attempt) break diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 56b87d44..1ef7658c 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -437,13 +437,21 @@ def queue_for(worktree: pathlib.Path) -> post_queue.Queue: return post_queue.Queue(pathlib.Path(base) / post_queue.QUEUE_DIRNAME) -def cmd_fix(args: argparse.Namespace) -> int: +class FixInputs(NamedTuple): + worktree: pathlib.Path + repo: str + head: str + result: pathlib.Path + fix: dict[str, Any] + + +def _resolve_fix_inputs(args: argparse.Namespace) -> tuple[FixInputs | None, str]: + """単独 fix 命令の入力を引数と既定値から解決する。""" worktree = pathlib.Path(args.worktree or os.getcwd()).resolve() repo = args.repo or _sh("gh", "repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner") if not repo: - print("リポジトリを決められない(--repo を渡す)", file=sys.stderr) - return 1 + return None, "リポジトリを決められない(--repo を渡す)" head = args.head or _sh("gh", "pr", "view", str(args.pr), "--json", "headRefName", "-q", ".headRefName") result = pathlib.Path(args.result) if args.result else ( @@ -451,8 +459,16 @@ def cmd_fix(args: argparse.Namespace) -> int: or str(worktree / TMP_DIRNAME)) / f"fix-pr{args.pr}-result.json") fix = _read_json(result) if not fix: - print(f"修正の結果ファイルを読めない: {result}", file=sys.stderr) + return None, f"修正の結果ファイルを読めない: {result}" + return FixInputs(worktree, repo, head, result, fix), "" + + +def cmd_fix(args: argparse.Namespace) -> int: + inputs, error = _resolve_fix_inputs(args) + if inputs is None: + print(error, file=sys.stderr) return 1 + worktree, repo, head, result, fix = inputs if head: pushed = push_fix(worktree, head, fix.get("fix_commit") or fix.get("commit_sha")) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 89be6d14..993783b2 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -2262,49 +2262,36 @@ def _round_passes( return True -def _guard_previous_round(st: dict[str, Any], prev: dict[str, Any]) -> None: - """前のラウンドの後始末が終わっているかを確かめる。 - - 進行側が手で修正して次のラウンドへ進めると、修正の工程(Step 5)が担う返信と - Resolve が飛ばされる。飛ばされたまま進むと、未解決の指摘が残ったまま承認へ到達する。 - - 止めるのは次の 2 つ。 +def _resolve_previous_verdict(st: dict[str, Any], prev: dict[str, Any]) -> str | None: + """保存されていない旧形式の判定を、ラウンドの結果から復元する。""" + verdict = prev.get("verdict") + if verdict is not None: + return verdict + reviewers = prev.get("reviewers") or _round_reviewers(st, prev.get("round") or 1) + if _no_result_agents(prev, st.get("only"), reviewers): + return "no_result" + return ("approved" if _round_passes(prev, st.get("only"), reviewers) + else "changes_requested") + + +def _require_fix_for_changes(round_no: Any, verdict: str | None, + fix: dict[str, Any] | None) -> None: + """修正必須の判定に修正記録が伴うことを確かめる。""" + if verdict != "changes_requested" or fix: + return + die( + f"round {round_no} は修正必須の判定でしたが、修正の記録がありません。" + " 返信と Resolve が飛ばされている可能性があります。" + " `/ndf:fix` を実行して戻り値ファイルを作り、`merge-fix` を通してから" + " 次のラウンドを開始してください", + code=5, + ) - 1. 前のラウンドが修正必須の判定なのに、修正の記録が無い - 2. 前のラウンドで Resolve したと申告されたスレッドが、GitHub 側で未解決のまま - 未解決の指摘を取得できないときは検査を行わず、確認できなかったことを残して進む。 - 取得の失敗で止めると、GitHub 側の一時的な不調でループが進まなくなる。 - - スレッドの状態は、申告が行われた Pull Request(`prev["pr"]`)へ問い合わせる。 - ローテーションを挟んだラウンドでは Step 6 の `set-current-pr` が先に走るため、 - `current_pr` は既に新しい Pull Request を指している。そちらへ問い合わせると、 - 旧 Pull Request のスレッドが未解決のままでも一覧に現れず検査が素通りする。 - """ +def _verify_resolved_threads(st: dict[str, Any], prev: dict[str, Any], + fix: dict[str, Any] | None) -> None: + """Resolve 済みとの申告を GitHub の未解決スレッドと突き合わせる。""" round_no = prev.get("round") - verdict = prev.get("verdict") - if verdict is None: - # 判定の結果を持たない古い状態ファイルは、保存された重要度から判定し直す。 - # 項目が欠けたラウンドは結果なしであり、修正の記録を求める対象ではない。 - # **数える相手はそのラウンドの担当である**(決定 11)。`codex` / `agy` で数えると、 - # 担当が `agy` + `kiro` のラウンドで `codex` を結果なしと読み、修正の記録が - # 無いまま次のラウンドへ通す。 - reviewers = prev.get("reviewers") or _round_reviewers(st, prev.get("round") or 1) - if _no_result_agents(prev, st.get("only"), reviewers): - verdict = "no_result" - else: - verdict = ("approved" if _round_passes(prev, st.get("only"), reviewers) - else "changes_requested") - fix = prev.get("fix") - if verdict == "changes_requested" and not fix: - die( - f"round {round_no} は修正必須の判定でしたが、修正の記録がありません。" - " 返信と Resolve が飛ばされている可能性があります。" - " `/ndf:fix` を実行して戻り値ファイルを作り、`merge-fix` を通してから" - " 次のラウンドを開始してください", - code=5, - ) - claimed = (fix or {}).get("resolved_thread_ids") or [] if not claimed: return @@ -2326,6 +2313,31 @@ def _guard_previous_round(st: dict[str, Any], prev: dict[str, Any]) -> None: ) +def _guard_previous_round(st: dict[str, Any], prev: dict[str, Any]) -> None: + """前のラウンドの後始末が終わっているかを確かめる。 + + 進行側が手で修正して次のラウンドへ進めると、修正の工程(Step 5)が担う返信と + Resolve が飛ばされる。飛ばされたまま進むと、未解決の指摘が残ったまま承認へ到達する。 + + 止めるのは次の 2 つ。 + + 1. 前のラウンドが修正必須の判定なのに、修正の記録が無い + 2. 前のラウンドで Resolve したと申告されたスレッドが、GitHub 側で未解決のまま + + 未解決の指摘を取得できないときは検査を行わず、確認できなかったことを残して進む。 + 取得の失敗で止めると、GitHub 側の一時的な不調でループが進まなくなる。 + + スレッドの状態は、申告が行われた Pull Request(`prev["pr"]`)へ問い合わせる。 + ローテーションを挟んだラウンドでは Step 6 の `set-current-pr` が先に走るため、 + `current_pr` は既に新しい Pull Request を指している。そちらへ問い合わせると、 + 旧 Pull Request のスレッドが未解決のままでも一覧に現れず検査が素通りする。 + """ + fix = prev.get("fix") + verdict = _resolve_previous_verdict(st, prev) + _require_fix_for_changes(prev.get("round"), verdict, fix) + _verify_resolved_threads(st, prev, fix) + + def _sync_before_round(st: dict[str, Any], pr: int) -> HeadRef | None: """ラウンドを開く前に、レビュー用の作業ツリーを Pull Request の head へ揃える。 From 4e19403636e33df7724013f2e8667c2ea0b288c6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:51:41 +0000 Subject: [PATCH 198/217] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/state.py#cm?= =?UTF-8?q?d=5Fcollect=5Fcritiques?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 現ラウンドの finding 索引の作成と、reviewers ごとの不足 finding_id の算出を それぞれ _round_finding_index / _missing_critique_targets へ抽出した。 cmd_collect_critiques は取込・重複統合・不足時の処理・完了記録の順序だけを 表す形になる。振る舞いは不変。 Item-Id: R4-004 Round: 4 Impl-Runtime: kiro Impl-Model: default --- .../ndf/skills/cross-review/scripts/state.py | 38 ++++++++++++++----- 1 file changed, 28 insertions(+), 10 deletions(-) diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 993783b2..34ffe9d4 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -3602,11 +3602,7 @@ def cmd_collect_critiques(args: argparse.Namespace) -> None: if not st.get("rounds"): die("state.rounds が空。`state.py start-round` を先に呼んでください") round_no = st["rounds"][-1]["round"] - findings = { - f.get("finding_id"): f - for f in st.get("review_findings") or [] - if f.get("round") == round_no - } + findings = _round_finding_index(st, round_no) reviewers = _round_reviewers(st, round_no) attached, covered = _attach_critiques(st, pr, round_no, findings, reviewers) @@ -3620,11 +3616,7 @@ def cmd_collect_critiques(args: argparse.Namespace) -> None: # **揃っていない対象は統合の後に数える。** 束ねられた側は対象から外れるため、 # 先に数えると、代表へ返された 1 件で足りる組を不足として扱う。 - missing: dict[str, list[str]] = {} - for agent in reviewers: - unmet = sorted(_critique_targets(st, round_no, agent) - covered[agent]) - if unmet: - missing[agent] = unmet + missing = _missing_critique_targets(st, round_no, reviewers, covered) if missing: _handle_incomplete_critiques(pr, st, round_no, missing) return @@ -3635,6 +3627,32 @@ def cmd_collect_critiques(args: argparse.Namespace) -> None: _save(pr, st) +def _round_finding_index( + st: dict[str, Any], round_no: int +) -> dict[Any, dict[str, Any]]: + """そのラウンドの指摘を `finding_id` で引ける索引にする。""" + return { + f.get("finding_id"): f + for f in st.get("review_findings") or [] + if f.get("round") == round_no + } + + +def _missing_critique_targets( + st: dict[str, Any], + round_no: int, + reviewers: list[str], + covered: dict[str, set[str]], +) -> dict[str, list[str]]: + """反証が揃っていない対象を、担当ごとに `finding_id` の並びで返す。""" + missing: dict[str, list[str]] = {} + for agent in reviewers: + unmet = sorted(_critique_targets(st, round_no, agent) - covered[agent]) + if unmet: + missing[agent] = unmet + return missing + + def _handle_incomplete_critiques( pr: int, st: dict[str, Any], round_no: int, missing: dict[str, list[str]] ) -> None: From f563f45c72f881212c7f9e4983d8ff92567b2e11 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:52:51 +0000 Subject: [PATCH 199/217] =?UTF-8?q?Docs:=20cross-refactoring=20=E3=81=AE?= =?UTF-8?q?=E5=8F=82=E5=8A=A0=E8=80=85=E3=81=A8=E9=81=A9=E7=94=A8=E3=81=AE?= =?UTF-8?q?=E8=BC=AA=E7=95=AA=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit P7(PR #800)で入った cross-refactoring 側の参加者の決め方・提案と適用を同じ参加者で回す 輪番・再開の反映・旧関数の削除を docs/specifications/cross-refactoring-participants.md に書く。 共通層の確定仕様の「置き換えはこの時点では入っていない」を新しい文書への参照へ直し、索引へ足す。 Co-Authored-By: Claude Opus 5 (1M context) --- docs/specifications/README.md | 1 + .../cross-refactoring-participants.md | 243 ++++++++++++++++++ .../cross-review-participants-and-seats.md | 9 +- 3 files changed, 248 insertions(+), 5 deletions(-) create mode 100644 docs/specifications/cross-refactoring-participants.md diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 02cdfd8b..cfceb419 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -19,6 +19,7 @@ | [cross-review-launch-outcome.md](cross-review-launch-outcome.md) | 起動 1 回の結末の語彙(理由 9 語)と起動し直しの可否、利用上限と CLI の上限の検知、プロセスグループでの起動と停止。手順は `cross-review` の `SKILL.md` と `docs/` が正 | | [cross-review-participants-and-seats.md](cross-review-participants-and-seats.md) | 使える者だけで収束ループを始める共通層(認証の確認を止めない形・参加の母集合と足す者/外す者・毎ラウンド 2 席の埋め方・席の名前)と、再開で渡した引数の反映。手順は `cross-review` の `SKILL.md` と `docs/` が正 | | [cross-refactoring-apply-intake.md](cross-refactoring-apply-intake.md) | 担当が結果を残さない起動を 3 つの取り込みが同じ手順で受けること(範囲の確定・未検証のコミットの取り消し・結末の記録)、適用ラウンドの開き直しの判定と試行の上限 2 回、項目の無い適用ラウンドを作らないこと、帰属の段落の後ろから必須の記名を読むこと。手順は `cross-refactoring` の `SKILL.md` と `docs/` が正 | +| [cross-refactoring-participants.md](cross-refactoring-participants.md) | cross-refactoring の参加者(codex / kiro とホストを既定に足す者/外す者で変える・確認を通らない者を外して続ける)、提案と適用を同じ参加者で回す輪番、再開で渡した引数の反映、呼び手の無くなった共通層の旧関数の削除。手順は `cross-refactoring` の `SKILL.md` と `docs/` が正 | | [ndf-cleanup-and-bundle-closing.md](ndf-cleanup-and-bundle-closing.md) | 後片付けが止まる条件(git の拒否だけ)、実行前確認の要否を決める 3 つの問い、まとまりの課題を終わりの工程で閉じる条件と結果の 4 値、配布の記録の形と読み方。手順は `merged` / `progress-tracking` / `release` の SKILL.md が正 | | [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) | `/goal` の工程を conductor / supervisor / worker の 3 層で通す運転。持ち場 5 つ、報告の 2 段、続けさせる回数、上限(429)で中断した層の再開。手順は `development-workflow` の `references/agent-layers.md` が正 | | [ndf-context-window-metrics.md](ndf-context-window-metrics.md) | 会話の記録から context window を 3 層で測る部品(`transcript_agents.py`)の値の取り方と、`skill-stats --agents` の 4 つの表と印。値の取り方はこの文書が正 | diff --git a/docs/specifications/cross-refactoring-participants.md b/docs/specifications/cross-refactoring-participants.md new file mode 100644 index 00000000..1ccbfc79 --- /dev/null +++ b/docs/specifications/cross-refactoring-participants.md @@ -0,0 +1,243 @@ +# cross-refactoring: 参加する CLI が 1 者でも使えないと初期化が止まり、担当を外す手段が無く、適用の輪番が固定の 4 者で回る → 使える者だけで始まり、参加者は codex / kiro とホストを既定に足し引きでき、提案と適用が同じ参加者の中で回る + +## 目的 + +**参加する CLI のどれか 1 者が導入・認証されていなくても、構造改善の収束ループが始まる。** +使えない者とその理由は、初期化の出力と状態ファイルに残る。 + +**参加者は codex / kiro とホストが既定で、名指しで足し引きできる。** agy は足す者の指定で戻せる。 +ホストも外せる。 + +**提案と適用は同じ参加者で回る。** 適用の輪番は参加者の数のラウンドで 1 周し、ラウンド 1 は +ホストから始まらない。担当の割り当てが返すのは適用担当 1 者だけで、レビュー担当は無い。 + +**中断した収束ループを引数を変えて再開すると、その引数は反映されるか、反映しないと知らされる。** + +**使える者の決定・適用の輪番・再開の反映の規則は、共通層が 1 か所ずつ持つ。** cross-review と +同じ関数を使い、cross-refactoring が持つのは参加の母集合の既定と、再開の表と、結果を状態 +ファイルと終了コードへ写すことだけである。共通層の契約は +[使える者だけで始める収束ループ](cross-review-participants-and-seats.md)が持つ。 + +**手順と引数の表は +[`cross-refactoring` の SKILL.md](../../plugins/ndf/skills/cross-refactoring/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、cross-refactoring 側の +契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 +共通層の語(使える者・認証の確認・使える者の解決・再開の反映など)は +[共通層の確定仕様の用語](cross-review-participants-and-seats.md#用語)と同じ意味で使う。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 既定の参加者の表 | `DEFAULT_REFACTOR_RUNTIMES` | ホストを除いた既定の参加者(codex / kiro) | +| 参加の母集合の既定 | `refactor_pool(host)` | 既定の参加者の表とホストの和。ランタイムの固定の順 | +| 参加者の一覧 | `runtimes` | 状態ファイルの項目。参加者の記録の使える者と同じ値。提案の対象・作業ツリーの準備・適用の輪番が読む | +| 参加者の記録 | `participants` | 使える者の解決の結果。cross-review と同じ形で、埋め合わせの項目を持たない | +| 適用の輪番 | `impl_assign` | 参加者の一覧から適用担当 1 者を返す共通層の関数 | +| 輪番の包み | `impl_for_seq` | 輪番の通し番号から担当と要求するモデルを引く、cross-refactoring の唯一の入口 | +| 反映の表 | `RESUME_REPLACE_FIELDS` / `RESUME_NOTIFY_FIELDS` | 再開で渡した引数ごとに「反映する」か「知らせる」かを決める表 | +| 足す者 / 外す者 / 全員を要する指定 | `--include` / `--exclude` / `--require-all` | 参加者を名指しで変える引数と、確認の失敗で止める引数 | +| 初期化 / ラウンドの開始 / 完了報告 | `init` / `start-round` / `report` | `refactor.py` の副コマンド | + +## 対象範囲 + +| 扱う | 扱わない | +| --- | --- | +| cross-refactoring の参加者の決め方、適用の輪番、再開の反映、完了報告の参加者の節 | 共通層の関数の契約([共通層の確定仕様](cross-review-participants-and-seats.md)が持つ) | +| 呼び手が無くなった共通層の旧関数 4 つを消したこと | 起動した後に分かる使えなさで担当を自動的に外す仕組み(作らない) | +| リポジトリの根の指示書の要約(参加者と輪番と上限の既定) | 指示書の cross-refactoring の節のうち、参加者と輪番と上限以外の行(#799) | + +## 背景 + +**認証の確認が関門だった。** 従来の確認は 1 件の失敗でその場で終了した。参加者の 1 者が +入っていないだけで、構造改善の収束ループを開始できなかった。 + +**担当から特定の CLI を外す引数が無かった。** 参加者は「全ランタイム − ホスト」の固定の 3 者で、 +起動の失敗が多い者や利用上限に近い者を避けられなかった。 + +**存在しない役の記録が残っていた。** 担当の割り当ては適用担当とレビュー担当の 2 つを返していた。 +構造改善のレビュー工程は既に無く、レビュー担当はラウンドの記録と出力に書かれるだけだった。 +使える者が 2 者のときに「レビュー担当が 1 者になる」と見えたのは、この記録である。 + +**適用の輪番は、提案と別の 4 者の固定の母集合で回っていた。** 提案する者と適用する者の集合が +一致しないため、適用専用の母集合を状態ファイルと初期化の出力が持っていた。 + +**再開で渡した引数を 1 つも反映していなかった。** 上限を変えて再開しても、状態ファイルの値が +そのまま使われた。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 参加の母集合の既定を codex / kiro とホストにする | 起動 199 回のうち失敗は agy の 7 回だけで、提案の所要の中央値も agy が最も長かった(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で決まっていた | +| 既定はホストを除いた部分だけを表に持ち、ホストを関数で足す | ホストが変わるたびに一覧を書き直さずに済む | +| ホストも外す者の指定で外せる | cross-refactoring ではホストが参加の母集合に入る。cross-review ではホストが母集合に無いため弾かれる | +| 提案と適用を同じ参加者で回し、適用専用の母集合を消す | 既定にホストを含めると、提案と適用の集合が同じになる。別に持つ理由が無くなる | +| レビュー担当を消し、割り当ては適用担当 1 者だけを返す | 記録だけを残すと、読み手が「このラウンドはこの 2 者がレビューした」と読む。役を消せば、席を埋める規則を持ち込む必要も無い | +| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の位置を採る | ラウンド 1 が 2 番目の者から始まる。ホスト claude の既定(claude / codex / kiro)でも codex → kiro → claude の順になり、ホストが最初に適用しない | +| 提案者と適用者が同じランタイムになることを避けない | 適用ラウンドは複数の提案者の項目を 1 つの群にまとめる。群ごとに提案者を避けると、群の分け方そのものが変わる。適用の結果は検証と、後の工程の収束レビューが見る | +| 確認は着手前のテストより先に行う | 使える者がいなければ、テストに時間を使わずに止める | +| 認証の確認の結果の項目を状態ファイルに書かない | 読み手が無い。通らなかった者と理由は参加者の記録が持つ | +| 引数の型(カンマ区切りの名前と予約語 `none`)は cross-refactoring の初期化の部品が持つ | 共通層へ移すと cross-review の状態の部品も触ることになる | +| 完了報告の参加者の節は cross-review と同じ行の形にし、埋め合わせの行を持たない | cross-refactoring に席は無い | +| モデルの指定の警告は参加者だけを対象にする | 既定で外れる agy へ警告を出すと、使わない者の指定まで読み手に見せる | +| 呼び手の無くなった共通層の旧関数 4 つを消し、残らないことをテストで固定する | 片方の Skill にだけ古い形が残ると、使える者を決める規則が 2 か所になる | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 参加者の一覧は、参加者の記録の使える者と同じ値である | 初期化と、参加者を作り直す再開が、同じ値を両方へ書く | +| 新規の状態ファイルに、適用専用の母集合とラウンドのレビュー担当が無い | 初期化とラウンドの開始が書かない。出力にも出さない | +| 使える者が 0 者、または全員を要する指定で欠けがあるとき、状態ファイルを作らない・書き換えない | 使える者の解決を書き込みの前に呼び、失敗は終了コード 4 で止める | +| この変更の前に始めた実行の状態ファイルを、書き換えずに読める | 適用の輪番は参加者の一覧から決まる。参加者の記録が無ければ完了報告は「記録なし」と出す | +| 共通層と両 Skill の部品に、旧関数 4 つの名前が残らない | `git grep -w` が 1 件でも当たればテストが落ちる | + +### 構成要素と責務 + +| 要素 | 責務 | 置き場所 | +| --- | --- | --- | +| 参加の母集合の既定 | 既定の参加者の表とホストを固定の順で返す | `plugins/ndf/scripts/lib/assignment.py` の `refactor_pool` | +| 適用の輪番 | 参加者の一覧から適用担当 1 者を返す | 同 `impl_assign` | +| 参加者の決定 | 共通層の使える者の解決を止めない確認で呼び、出力し、失敗を終了コード 4 へ写す | `cross-refactoring/scripts/refactor_lib/commands/setup.py` の `resolve_participants` | +| 初期化と再開 | 新規なら既定を置いて参加者を決める。再開なら反映の表を渡し、担当に関わる引数があれば参加者を作り直す | 同 `cmd_init` / `_resume` | +| 輪番の包み | 輪番を引く唯一の入口。ラウンドの開始・群の割り当て・結果なしの試行の交代先・最終ゲートの修正担当の 4 か所が読む | `refactor_lib/rounds.py` の `impl_for_seq` | +| 完了報告の参加者の節 | 母集合・使える者・外した者・足した者・確認を通らなかった者・再開で変えた値を出す | `refactor_lib/commands/report.py` の `_print_participants` | + +### 参加者の決め方 + +参加の母集合の既定に足す者を加え、外す者を除き、認証の確認を通った者を参加者の一覧にする。 +手順と例外は共通層の使える者の解決が持つ。 + +| ホスト | 既定の参加者 | +| --- | --- | +| claude | claude / codex / kiro | +| codex | codex / kiro | +| agy | codex / agy / kiro | +| kiro | codex / kiro | + +**外した者へは確認コマンドを呼ばない。** 既定で外れる agy は、足さない限り確認されない。 + +| 場面 | 標準エラー | 終了コード | 状態ファイル | +| --- | --- | ---: | --- | +| 全員が使える | ホスト・母集合・使える者の 1 行 | 0 | 作る | +| 確認を通らない者がいる | `⚠ <名前> を担当から外しました(<理由>)` を 1 者 1 行 | 0 | 作る | +| 使える者が 0 者 | 全員の理由を並べた 1 行 | 4 | 作らない | +| 全員を要する指定で欠けがある | 欠けた者と理由 | 4 | 作らない | +| 名前の矛盾(外す者が母集合に無い・足す者と外す者が重なる・`none` と名前の混在) | 何が矛盾したか | 4 | 作らない | + +綴りの誤りは引数の型が弾く(終了コード 2)。 + +### 適用の輪番 + +適用担当は `参加者の一覧[通し番号 % 参加者の数]` である。通し番号は適用ラウンドを開くたびに +進むため、1 つの提案ラウンドが複数の群を持てば、その分だけ輪番も進む。 + +| 通し番号 | 1 | 2 | 3 | 4 | 5 | 6 | +| --- | --- | --- | --- | --- | --- | --- | +| 参加者が claude / codex / kiro のときの担当 | codex | kiro | claude | codex | kiro | claude | + +**結果なしの試行の交代先も同じ輪番から選ぶ。** 通し番号を 1 つずつ進め、その群で失敗した担当の +どれとも違う者が出た最初の番号を採る。参加者の数だけ進めても出なければ、替える先は無い。 + +**提案ラウンドの上限は、輪番の 1 周と対応しない。** 上限が切るのは提案の回数だけである。 + +### 再開で渡した引数の扱い + +| 扱い | 引数 | 何が起きるか | +| --- | --- | --- | +| 反映する | `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / テストの制限時間 | 状態ファイルを書き換え、再開で変えた値の記録へ 1 件積み、`↻ <項目>: <旧> → <新>` を出す | +| 参加者を作り直す | `--exclude` / `--include` / `--require-all` | 認証の確認をやり直し、参加者の記録と参加者の一覧を置き換え、記録へ 1 件として積む。`none` で一覧を空へ戻す | +| 知らせる | ホスト・範囲・モデル・着手前のテスト・継続的統合の検査の名前・重要度の閾値・同期のコマンド・改修計画のファイル・起動のされ方・作業ディレクトリの根 | 状態ファイルは変えず、状態と違うときだけ「反映しない」を 1 行出す | + +**値が同じ引数は、行も記録も出さない。** 比べる前に形を揃える。着手前のテストはコマンドで、 +モデルは全ランタイムの辞書で、作業ディレクトリの根は解決したパスで比べる。 + +**作り直しの入力は、渡した引数と、渡さなかった引数の記録の値である。** 足す者の指定で agy を +足して始めた実行へ外す者の指定だけを渡すと、足した agy は残る。 + +**作り直しの失敗は、状態ファイルを書き換える前に起きる。** 終了コード 4 で止まる。 + +### 完了報告の「参加した者」 + +```text +## 参加した者 + +- 母集合: claude / codex / kiro +- 使える者: claude / codex +- --exclude で外した者: なし +- --include で足した者: なし +- 確認を通らなかった者: kiro(<理由>) +- 再開で変えた値: なし +``` + +参加者の記録を持たない状態ファイルでは「使える者: 記録なし」の 1 行だけを出す。確認を飛ばした +ときは、通らなかった者の行に「確認を飛ばした(NDF_SKIP_AUTH_CHECK)」と出す。 + +ラウンド表と改修計画の見出しには、レビュー担当・そのモデル・初回承認の列が無い。 + +## データ・設定 + +### 状態ファイル + +| 項目 | 新規の状態ファイル | この変更の前の状態ファイル | +| --- | --- | --- | +| `runtimes` | 使える者と同じ値 | そのまま読む。適用の輪番もこの値を使う | +| `participants` | 共通層が返す 7 項目(埋め合わせの項目は無い) | 無い。完了報告は「記録なし」 | +| `resume_changes` | 空の配列から始め、再開で変えた値を追記する | 無ければ空として読む | +| `impl_capable` | 書かない | 残っていても読まない | +| `rounds[].reviewers` / `rounds[].reviewer_models` | 書かない | 残っていても表示しない。指標の集計は読む経路を残す | + +### 初期化の引数と出力 + +| 引数 | 既定 | 再開の経路 | +| --- | --- | --- | +| `--exclude NAMES` / `--include NAMES` | 未指定(カンマ区切り・繰り返し可。`none` で空) | 渡せば参加者を作り直す | +| `--require-all` / `--no-require-all` | 未指定(新規は偽) | 渡せば参加者を作り直す | +| 上限 4 つとテストの制限時間 | 未指定(新規の経路が現行の既定を置く。提案ラウンドの上限は 3) | 渡せば反映 | + +**上限の既定を引数の側に置かない。** 引数の側に既定を置くと、再開で「渡さなかった」と「既定値を +渡した」を区別できない。 + +初期化の出力は参加者の一覧(`RUNTIMES` / `RUNTIMES_CSV`)を持ち、適用専用の母集合の変数を +持たない。ラウンドの開始の出力は、レビュー担当の変数を持たない。 + +### 消した共通層の関数 + +| 関数 | 置き換え先 | +| --- | --- | +| `check_auth`(1 件の失敗で止める確認) | `probe_auth` と使える者の解決の全員を要する指定 | +| `impl_pool`(適用専用の母集合) | 参加者の一覧 | +| `review_assign`(従来の席の割り当て) | `review_seats` | +| `assign`(従来の適用とレビューの割り当て) | `impl_assign` | + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| ホストごとの既定の参加者、足す者・外す者での増減、ホストを外せること | `plugins/ndf/skills/cross-refactoring/tests/test_init.py` | +| 確認を通らない者を外して続け、全員を要する指定と 0 者では状態ファイルを作らずに終了コード 4 | 同 | +| 再開が上限を反映し、他の引数を知らせ、担当に関わる引数でだけ参加者を作り直し、渡さなかった値を記録で補うこと | 同 | +| 適用の輪番が参加者の数で 1 周し、ラウンド 1 がホストから始まらないこと | `plugins/ndf/scripts/tests/test_lib_assignment.py` / `cross-refactoring/tests/test_assignment.py` | +| ラウンドの開始がレビュー担当を出さず、記録にも書かないこと | `cross-refactoring/tests/test_start_round_emits_runtimes.py` | +| 完了報告と改修計画にレビュー担当の列が無く、母集合を 1 行で出すこと | `cross-refactoring/tests/test_rounds.py` / `test_plan_comment.py` | +| この変更の前の状態ファイルをラウンドの開始と完了報告が読めること | `cross-refactoring/tests/test_rounds.py` / `test_start_round_emits_runtimes.py` | +| 結果なしの試行の交代先が参加者の数の範囲で選ばれること | `cross-refactoring/tests/test_apply_attempts.py` | +| 手順書と指示書の語が新しい参加者と輪番を書くこと | `cross-refactoring/tests/test_skill_terms.py` | +| 旧関数 4 つが共通層と両 Skill の部品に残らないこと | `scripts/tests/test_shared_lib_layout.py` | + +## 関連リンク + +- [issue #664](https://github.com/devbasex/ai-plugins/issues/664) — 担当を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる +- [issue #736](https://github.com/devbasex/ai-plugins/issues/736) — 指示書の提案ラウンドの上限の既定 +- [issue #727](https://github.com/devbasex/ai-plugins/issues/727) — 使える者から担当を割り当てる共通層 +- [PR #800](https://github.com/devbasex/ai-plugins/pull/800) — 実装 +- [使える者だけで始める収束ループ](cross-review-participants-and-seats.md) — 共通層の契約と cross-review 側 +- [結果なしの取り込み](cross-refactoring-apply-intake.md) — 適用ラウンドの開き直しと試行の上限 +- [`cross-refactoring` の手順](../../plugins/ndf/skills/cross-refactoring/SKILL.md) +- [`cross-refactoring` の状態と提案の手順](../../plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md) diff --git a/docs/specifications/cross-review-participants-and-seats.md b/docs/specifications/cross-review-participants-and-seats.md index 90c2f742..e42b997d 100644 --- a/docs/specifications/cross-review-participants-and-seats.md +++ b/docs/specifications/cross-review-participants-and-seats.md @@ -51,11 +51,10 @@ | 扱う | 扱わない | | --- | --- | -| 共通層の使える者の解決・認証の確認・席の埋め方・適用の輪番・席の名前・再開の反映 | cross-refactoring の参加の母集合と担当の差し替え(別の課題が持つ) | +| 共通層の使える者の解決・認証の確認・席の埋め方・適用の輪番・席の名前・再開の反映 | cross-refactoring の参加の母集合・適用の輪番の使い方・再開の表([cross-refactoring の参加者](cross-refactoring-participants.md)が持つ) | | cross-review の新規と再開の初期化、担当の決まる順、席の名前が流れる経路、完了報告の参加者の節 | 起動した後に分かる使えなさで担当を自動的に外す仕組み | -適用の輪番は共通層に入っているためここで扱う。呼び出す側は cross-refactoring だけであり、 -その置き換えはこの時点では入っていない。 +適用の輪番は共通層に入っているためここで扱う。呼び出す側は cross-refactoring だけである。 ## 背景 @@ -233,8 +232,8 @@ start-round → REVIEWERS="codex claude-2" 実装担当 1 者を「ラウンド番号を参加者の数で割った余り」の位置から選ぶ。式はこの変更の前と 同じで、除数だけが参加者の数になる。ラウンド 1 が 2 番目の者から始まるため、ホストが最初に -適用する形にならない。呼び出す側は cross-refactoring だけであり、その置き換えはこの時点では -入っていない。 +適用する形にならない。呼び出す側は cross-refactoring だけで、使い方は +[cross-refactoring の参加者](cross-refactoring-participants.md)が持つ。 ### 再開で渡した引数の扱い From 3956e5bbf44dbaaa57a1599635c727b46fdc598f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 11:58:46 +0000 Subject: [PATCH 200/217] =?UTF-8?q?Docs:=20=E3=83=9B=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=8C=20agy=20/=20kiro=20=E3=81=AE=E3=81=A8=E3=81=8D?= =?UTF-8?q?=E3=83=A9=E3=82=A6=E3=83=B3=E3=83=89=201=20=E3=81=AE=E9=81=A9?= =?UTF-8?q?=E7=94=A8=E6=8B=85=E5=BD=93=E3=81=8C=E3=83=9B=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AB=E3=81=AA=E3=82=8B=E3=81=93=E3=81=A8=E3=82=92=E7=A2=BA?= =?UTF-8?q?=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- .../cross-refactoring-participants.md | 18 +++++++++++++++--- .../cross-review-participants-and-seats.md | 7 ++++--- 2 files changed, 19 insertions(+), 6 deletions(-) diff --git a/docs/specifications/cross-refactoring-participants.md b/docs/specifications/cross-refactoring-participants.md index 1ccbfc79..bbe3ab26 100644 --- a/docs/specifications/cross-refactoring-participants.md +++ b/docs/specifications/cross-refactoring-participants.md @@ -9,7 +9,9 @@ ホストも外せる。 **提案と適用は同じ参加者で回る。** 適用の輪番は参加者の数のラウンドで 1 周し、ラウンド 1 は -ホストから始まらない。担当の割り当てが返すのは適用担当 1 者だけで、レビュー担当は無い。 +参加者の 2 番目から始まる。既定の参加者では、ホストが claude / codex ならホストは最初に適用 +せず、ホストが agy / kiro ならラウンド 1 の適用担当はホストになる。担当の割り当てが返すのは +適用担当 1 者だけで、レビュー担当は無い。 **中断した収束ループを引数を変えて再開すると、その引数は反映されるか、反映しないと知らされる。** @@ -77,7 +79,7 @@ | ホストも外す者の指定で外せる | cross-refactoring ではホストが参加の母集合に入る。cross-review ではホストが母集合に無いため弾かれる | | 提案と適用を同じ参加者で回し、適用専用の母集合を消す | 既定にホストを含めると、提案と適用の集合が同じになる。別に持つ理由が無くなる | | レビュー担当を消し、割り当ては適用担当 1 者だけを返す | 記録だけを残すと、読み手が「このラウンドはこの 2 者がレビューした」と読む。役を消せば、席を埋める規則を持ち込む必要も無い | -| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の位置を採る | ラウンド 1 が 2 番目の者から始まる。ホスト claude の既定(claude / codex / kiro)でも codex → kiro → claude の順になり、ホストが最初に適用しない | +| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の位置を採る | ラウンド 1 が 2 番目の者から始まる。ホスト claude の既定(claude / codex / kiro)でも codex → kiro → claude の順になり、ホストが最初に適用しない。ホストが agy / kiro のときは、参加者がランタイムの固定の順に並ぶためホストが 2 番目に来て、ラウンド 1 の適用担当はホストになる(手順書との食い違いは #804 で扱う) | | 提案者と適用者が同じランタイムになることを避けない | 適用ラウンドは複数の提案者の項目を 1 つの群にまとめる。群ごとに提案者を避けると、群の分け方そのものが変わる。適用の結果は検証と、後の工程の収束レビューが見る | | 確認は着手前のテストより先に行う | 使える者がいなければ、テストに時間を使わずに止める | | 認証の確認の結果の項目を状態ファイルに書かない | 読み手が無い。通らなかった者と理由は参加者の記録が持つ | @@ -142,6 +144,16 @@ | --- | --- | --- | --- | --- | --- | --- | | 参加者が claude / codex / kiro のときの担当 | codex | kiro | claude | codex | kiro | claude | +参加者はランタイムの固定の順(claude / codex / agy / kiro)に並ぶ。そのため、既定の参加者で +ラウンド 1 の適用担当がホストになるかどうかはホストで決まる。 + +| ホスト | 既定の参加者 | ラウンド 1 の適用担当 | +| --- | --- | --- | +| claude | claude / codex / kiro | codex | +| codex | codex / kiro | kiro | +| agy | codex / agy / kiro | agy(ホスト) | +| kiro | codex / kiro | kiro(ホスト) | + **結果なしの試行の交代先も同じ輪番から選ぶ。** 通し番号を 1 つずつ進め、その群で失敗した担当の どれとも違う者が出た最初の番号を採る。参加者の数だけ進めても出なければ、替える先は無い。 @@ -223,7 +235,7 @@ | ホストごとの既定の参加者、足す者・外す者での増減、ホストを外せること | `plugins/ndf/skills/cross-refactoring/tests/test_init.py` | | 確認を通らない者を外して続け、全員を要する指定と 0 者では状態ファイルを作らずに終了コード 4 | 同 | | 再開が上限を反映し、他の引数を知らせ、担当に関わる引数でだけ参加者を作り直し、渡さなかった値を記録で補うこと | 同 | -| 適用の輪番が参加者の数で 1 周し、ラウンド 1 がホストから始まらないこと | `plugins/ndf/scripts/tests/test_lib_assignment.py` / `cross-refactoring/tests/test_assignment.py` | +| 適用の輪番が参加者の数で 1 周し、ラウンド 1 が参加者の 2 番目から始まること | `plugins/ndf/scripts/tests/test_lib_assignment.py` / `cross-refactoring/tests/test_assignment.py` | | ラウンドの開始がレビュー担当を出さず、記録にも書かないこと | `cross-refactoring/tests/test_start_round_emits_runtimes.py` | | 完了報告と改修計画にレビュー担当の列が無く、母集合を 1 行で出すこと | `cross-refactoring/tests/test_rounds.py` / `test_plan_comment.py` | | この変更の前の状態ファイルをラウンドの開始と完了報告が読めること | `cross-refactoring/tests/test_rounds.py` / `test_start_round_emits_runtimes.py` | diff --git a/docs/specifications/cross-review-participants-and-seats.md b/docs/specifications/cross-review-participants-and-seats.md index e42b997d..fbe0175b 100644 --- a/docs/specifications/cross-review-participants-and-seats.md +++ b/docs/specifications/cross-review-participants-and-seats.md @@ -89,7 +89,7 @@ | 監視では、席の形に合わない名前をそのまま返す | 担当名を任意の骨格で受ける cross-refactoring の経路がある。形で弾くとその経路が壊れる | | 担当はラウンドの記録から先に見る | 再開で 1 者指定を変えても、過去のラウンドの担当が変わらない | | 前のラウンドの検査にも、そのラウンドの担当を渡す | 固定の 2 者で数えると、担当が違うラウンドで担当でない者を結果なしと読み、修正の記録が無いまま次へ通す | -| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の式を保つ | ラウンド 1 が 2 番目の者から始まるため、ホストが最初に適用する形にならない | +| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の式を保つ | ラウンド 1 が 2 番目の者から始まる。参加者はランタイムの固定の順に並ぶため、既定の参加者でホストが最初に適用しないのはホストが claude / codex のときだけで、agy / kiro のときはラウンド 1 の担当がホストになる | | 再開の反映を共通層に置き、どの引数を「反映する」「知らせる」にするかは Skill ごとの表が持つ | 片方の Skill の再開の経路だけを直すと、もう片方に引数を捨てる形が残る | | 状態ファイルに載る引数は表のどちらかに必ず載せ、引数の既定を未指定にする | 黙って捨てる引数を残さない。既定値と同じ値なら渡していないとみなす形では、上限を既定値へ戻す操作を区別できない | | 担当に関わる引数を渡した再開でだけ、認証の確認をやり直して参加者を作り直す | 途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる | @@ -231,8 +231,9 @@ start-round → REVIEWERS="codex claude-2" ### 適用の輪番 実装担当 1 者を「ラウンド番号を参加者の数で割った余り」の位置から選ぶ。式はこの変更の前と -同じで、除数だけが参加者の数になる。ラウンド 1 が 2 番目の者から始まるため、ホストが最初に -適用する形にならない。呼び出す側は cross-refactoring だけで、使い方は +同じで、除数だけが参加者の数になる。ラウンド 1 は 2 番目の者から始まる。参加者はランタイムの +固定の順に並ぶため、既定の参加者でホストが最初に適用しないのはホストが claude / codex のとき +だけで、agy / kiro のときはラウンド 1 の担当がホストになる。呼び出す側は cross-refactoring だけで、使い方は [cross-refactoring の参加者](cross-refactoring-participants.md)が持つ。 ### 再開で渡した引数の扱い From df1066ba2db5bdb54964b11edaaf3158f8c0a1d5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 12:11:08 +0000 Subject: [PATCH 201/217] =?UTF-8?q?Fix:=20=E8=A1=8C=E3=81=8C=E6=95=B4?= =?UTF-8?q?=E6=95=B0=E3=81=AB=E3=81=AA=E3=82=89=E3=81=AA=E3=81=84=E6=8C=87?= =?UTF-8?q?=E6=91=98=E3=82=92=E4=BE=8B=E5=A4=96=E3=81=A7=E8=90=BD=E3=81=A8?= =?UTF-8?q?=E3=81=95=E3=81=9A=E7=B7=8F=E8=A9=95=E3=81=B8=E5=9B=9E=E3=81=99?= =?UTF-8?q?=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 担当が書き出す指摘の行は外部入力である。`"L42"` や `"40-45"` を素の int() へ 渡すと ValueError でレビュー全体が失われていた。`_line_no` で正の整数として 読めるときだけ位置を持つものとし、読めない指摘は総評へ退避する。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 21 ++++++++++++++++--- .../ndf/scripts/tests/test_result_posts.py | 21 +++++++++++++++++++ 2 files changed, 39 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 1ef7658c..bb0ebb24 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -74,13 +74,28 @@ def _dict_items(raw: Any) -> list[dict[str, Any]]: # ---------------- レビューの投稿 ---------------- +def _line_no(value: Any) -> int | None: + """行番号として読めるときだけ正の int を返す。 + + **行は担当が書き出す外部入力である。** `"L42"` や `"40-45"` を `int()` へ渡すと + 例外でレビュー全体が失われるため、読めない値は位置を持たないものとして扱う。 + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value > 0 else None + if isinstance(value, str) and value.strip().isdecimal(): + return int(value.strip()) or None + return None + + def _can_be_inline(finding: dict[str, Any]) -> bool: """指す先を持つか。**差分に含まれるかどうかは見ない。** 含まれるかは送ってみた応答が決める(設計の決定 9)。ここで見るのは、そもそも - 指す位置があるかどうかだけである。 + 指す位置があるかどうかだけである。行が数として読めない指摘は総評へ回す。 """ - return bool(finding.get("path")) and finding.get("line") is not None + return bool(finding.get("path")) and _line_no(finding.get("line")) is not None def _evacuated_line(finding: dict[str, Any]) -> str: @@ -134,7 +149,7 @@ def review_posts(payload_path: pathlib.Path | str, result_path: pathlib.Path | s fields["commit_id"] = head_sha if inline: fields["comments"] = [ - {"path": str(f.get("path")), "line": int(f.get("line")), + {"path": str(f.get("path")), "line": _line_no(f.get("line")), "side": "RIGHT", "body": str(f.get("body") or "")} for f in inline ] diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index 23301bc5..ff9849cd 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -194,6 +194,26 @@ def test_a_finding_without_a_position_goes_to_the_summary(tmp_path) -> None: assert items[0]["extra"]["body"] == 1 +@pytest.mark.parametrize("line", ["L42", "40-45", "", " ", True, 1.5, [12]]) +def test_a_finding_whose_line_is_not_an_integer_goes_to_the_summary( + tmp_path, line) -> None: + """行が整数にならない指摘は、例外で落とさず総評へ入れる(外部入力のため)。""" + items = _review_items(tmp_path, comments=[ + {"path": "a.py", "line": line, "body": "[major / 正確性] 行が壊れている", + "severity": "major"}, + {"path": "b.py", "line": "34", "body": "[minor / 可読性] 名前を揃える", + "severity": "minor"}, + ]) + + fields = items[0]["fields"] + assert fields["comments"] == [ + {"path": "b.py", "line": 34, "side": "RIGHT", + "body": "[minor / 可読性] 名前を揃える"}] + assert "行が壊れている" in fields["body"] + assert items[0]["extra"]["inline"] == 1 + assert items[0]["extra"]["body"] == 1 + + # ---------------- 送信と退避 ---------------- def _queue(tmp_path) -> post_queue.Queue: @@ -553,3 +573,4 @@ def test_a_deferred_thread_is_not_resolved_by_default(tmp_path) -> None: _fix_file(tmp_path, resolved_threads=[], rejected=[]), repo=REPO, pr=PR) assert "thread-resolve" not in [i["kind"] for i in items] + From b53a68a4af0a63d13b5ae0924e683ec2cec2b2e7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 12:14:43 +0000 Subject: [PATCH 202/217] =?UTF-8?q?Fix:=20=E5=8D=98=E7=8B=AC=20fix=20?= =?UTF-8?q?=E3=81=AE=E3=83=AA=E3=83=9D=E3=82=B8=E3=83=88=E3=83=AA=E3=81=A8?= =?UTF-8?q?=E9=A0=AD=E3=81=AE=E8=A7=A3=E6=B1=BA=E3=82=92=E4=BD=9C=E6=A5=AD?= =?UTF-8?q?=E3=83=84=E3=83=AA=E3=83=BC=E3=81=AE=E4=B8=AD=E3=81=A7=E8=A1=8C?= =?UTF-8?q?=E3=81=86=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `--worktree` を解決していながら、`gh repo view` / `gh pr view` は呼び出し元の cwd で動いていた。作業ツリーの外から呼ぶとリポジトリを決められずに止まるか、 頭が空になって送信を飛ばしたまま返信へ進む。`_sh` に cwd を渡せるようにし、 `gh pr view` には解決済みのリポジトリを `-R` で渡す。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 17 +++++---- .../ndf/scripts/tests/test_result_posts.py | 36 +++++++++++++++++++ 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index bb0ebb24..a26c1304 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -441,8 +441,8 @@ def push_fix(worktree: pathlib.Path | str, head_branch: str, # ---------------- 部分命令 ---------------- -def _sh(*cmd: str) -> str: - r = subprocess.run(list(cmd), capture_output=True, text=True) +def _sh(*cmd: str, cwd: pathlib.Path | str | None = None) -> str: + r = subprocess.run(list(cmd), capture_output=True, text=True, cwd=cwd) return r.stdout.strip() if r.returncode == 0 else "" @@ -461,14 +461,19 @@ class FixInputs(NamedTuple): def _resolve_fix_inputs(args: argparse.Namespace) -> tuple[FixInputs | None, str]: - """単独 fix 命令の入力を引数と既定値から解決する。""" + """単独 fix 命令の入力を引数と既定値から解決する。 + + **リポジトリと頭は作業ツリーの中で解決する。** 呼び出し元の cwd が作業ツリーの + 外だと、`gh` が別のリポジトリを読むか解決に失敗し、頭が空のまま送信を飛ばす。 + """ worktree = pathlib.Path(args.worktree or os.getcwd()).resolve() repo = args.repo or _sh("gh", "repo", "view", "--json", "nameWithOwner", - "-q", ".nameWithOwner") + "-q", ".nameWithOwner", cwd=worktree) if not repo: return None, "リポジトリを決められない(--repo を渡す)" - head = args.head or _sh("gh", "pr", "view", str(args.pr), "--json", "headRefName", - "-q", ".headRefName") + head = args.head or _sh("gh", "pr", "view", str(args.pr), "-R", repo, + "--json", "headRefName", "-q", ".headRefName", + cwd=worktree) result = pathlib.Path(args.result) if args.result else ( pathlib.Path(os.environ.get("CROSS_REVIEW_TMP_DIR") or str(worktree / TMP_DIRNAME)) / f"fix-pr{args.pr}-result.json") diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index ff9849cd..fe4ee40c 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -574,3 +574,39 @@ def test_a_deferred_thread_is_not_resolved_by_default(tmp_path) -> None: assert "thread-resolve" not in [i["kind"] for i in items] + +def test_the_standalone_command_resolves_repo_and_head_in_the_worktree( + tmp_path, monkeypatch) -> None: + """`--worktree` を渡したら、リポジトリと頭の解決も作業ツリーの中で行う。 + + 呼び出し元の cwd が作業ツリーの外でも、`gh` が別のリポジトリを読まないため。 + """ + work = tmp_path / "work" + work.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.chdir(outside) + calls: list[tuple[list[str], str | None]] = [] + + def fake_run(cmd, **kw): + calls.append((list(cmd), kw.get("cwd"))) + inside = kw.get("cwd") is not None and \ + pathlib.Path(kw["cwd"]).resolve() == work.resolve() + out = "" + if inside and cmd[:3] == ["gh", "repo", "view"]: + out = REPO + elif inside and cmd[:3] == ["gh", "pr", "view"]: + out = "feat/x" + return subprocess.CompletedProcess(cmd, 0, stdout=out, stderr="") + + monkeypatch.setattr(result_posts.subprocess, "run", fake_run) + fix = _fix_file(tmp_path) + args = result_posts.argparse.Namespace( + repo=None, pr=str(PR), result=str(fix), head=None, worktree=str(work)) + + inputs, error = result_posts._resolve_fix_inputs(args) + + assert error == "" and inputs is not None + assert (inputs.repo, inputs.head) == (REPO, "feat/x") + pr_view = next(c for c, _ in calls if c[:3] == ["gh", "pr", "view"]) + assert pr_view[pr_view.index("-R") + 1] == REPO From 1f646aa76a31a7c1e85a6e4cfd3eb8317b4806c3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 12:18:41 +0000 Subject: [PATCH 203/217] =?UTF-8?q?Fix:=20=E9=80=81=E3=82=8A=E5=85=88?= =?UTF-8?q?=E3=82=92=E6=B1=BA=E3=82=81=E3=82=89=E3=82=8C=E3=81=AA=E3=81=84?= =?UTF-8?q?=E3=81=A8=E3=81=8D=E3=81=AF=E8=BF=94=E4=BF=A1=E3=81=B8=E9=80=B2?= =?UTF-8?q?=E3=81=BE=E3=81=9A=E6=AD=A2=E3=82=81=E3=82=8B=EF=BC=88#730=20#5?= =?UTF-8?q?83=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 16 ++++++----- .../ndf/scripts/tests/test_result_posts.py | 28 +++++++++++++++++++ plugins/ndf/skills/fix/SKILL.md | 2 ++ 3 files changed, 39 insertions(+), 7 deletions(-) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index a26c1304..2e84ca4d 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -474,6 +474,9 @@ def _resolve_fix_inputs(args: argparse.Namespace) -> tuple[FixInputs | None, str head = args.head or _sh("gh", "pr", "view", str(args.pr), "-R", repo, "--json", "headRefName", "-q", ".headRefName", cwd=worktree) + if not head: + # 送れない修正へ「対応しました」と返信しないため、返信へ進まず止める。 + return None, "送り先のブランチを決められない(--head を渡す)" result = pathlib.Path(args.result) if args.result else ( pathlib.Path(os.environ.get("CROSS_REVIEW_TMP_DIR") or str(worktree / TMP_DIRNAME)) / f"fix-pr{args.pr}-result.json") @@ -490,13 +493,12 @@ def cmd_fix(args: argparse.Namespace) -> int: return 1 worktree, repo, head, result, fix = inputs - if head: - pushed = push_fix(worktree, head, fix.get("fix_commit") or fix.get("commit_sha")) - print(f"PUSHED={1 if pushed.pushed else 0} " - f"COMMIT_ON_HEAD={1 if pushed.contains else 0}") - if not pushed.ok: - print(pushed.detail, file=sys.stderr) - return 1 + pushed = push_fix(worktree, head, fix.get("fix_commit") or fix.get("commit_sha")) + print(f"PUSHED={1 if pushed.pushed else 0} " + f"COMMIT_ON_HEAD={1 if pushed.contains else 0}") + if not pushed.ok: + print(pushed.detail, file=sys.stderr) + return 1 actor = args.actor or _sh("gh", "api", "user", "-q", ".login") or None outcome = post_fix(queue_for(worktree), result, repo, int(args.pr), diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index fe4ee40c..c4ba93c2 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -610,3 +610,31 @@ def fake_run(cmd, **kw): assert (inputs.repo, inputs.head) == (REPO, "feat/x") pr_view = next(c for c, _ in calls if c[:3] == ["gh", "pr", "view"]) assert pr_view[pr_view.index("-R") + 1] == REPO + + +def test_the_standalone_command_stops_when_the_branch_is_not_known( + tmp_path, monkeypatch, capsys) -> None: + """送り先のブランチを決められないときは、返信へ進まず止める。 + + 送っていない修正へ「対応しました」と返信しないため。 + """ + work = tmp_path / "work" + work.mkdir() + calls: list[list[str]] = [] + + def fake_run(cmd, **kw): + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="no pr") + + monkeypatch.setattr(result_posts.subprocess, "run", fake_run) + monkeypatch.delenv("CROSS_REVIEW_TMP_DIR", raising=False) + fix = _fix_file(tmp_path) + args = result_posts.argparse.Namespace( + repo=REPO, pr=str(PR), result=str(fix), head=None, worktree=str(work), + round=ROUND, actor=ACTOR) + + assert result_posts.cmd_fix(args) == 1 + assert "ブランチ" in capsys.readouterr().err + # 返信・決着・まとめは 1 件も呼ばず、待ち行列にも積まない。 + assert [c for c in calls if c[:2] == ["gh", "api"]] == [] + assert not (work / result_posts.TMP_DIRNAME).exists() diff --git a/plugins/ndf/skills/fix/SKILL.md b/plugins/ndf/skills/fix/SKILL.md index abc61fc1..db38d841 100644 --- a/plugins/ndf/skills/fix/SKILL.md +++ b/plugins/ndf/skills/fix/SKILL.md @@ -76,6 +76,8 @@ python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <戻り値フ `$SCRIPTS` の決め方は `development-workflow` の `references/scripts-lookup.md` にある。 `--repo` / `--head` / `--worktree` を省いたときは、いまいる作業ツリーと Pull Request から引く。 +**送り先のブランチを決められないときは、返信へ進まず終了コード 1 で止まる。** 送っていない +修正へ「対応しました」と返信しないためである。 このコマンドが現在の頭を送り先へ送り(`git push origin HEAD:<ブランチ名>`)、報告した コミットが送り先に載ったことを確かめてから、返信・決着・まとめを待ち行列を通して送る。 出力は件数と参照だけで、本文を出さない。 From 4e2bc22493551f8f97b72241ef0014c43cef3206 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 12:37:45 +0000 Subject: [PATCH 204/217] =?UTF-8?q?Fix:=20=E4=BD=8D=E7=BD=AE=E3=82=A8?= =?UTF-8?q?=E3=83=A9=E3=83=BC=E3=81=AE=E9=80=80=E9=81=BF=E3=82=92=E4=BB=8A?= =?UTF-8?q?=E5=9B=9E=E7=A9=8D=E3=82=93=E3=81=A0=E9=A0=85=E7=9B=AE=E3=81=8C?= =?UTF-8?q?=E6=8B=92=E3=81=BE=E3=82=8C=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=AB?= =?UTF-8?q?=E9=99=90=E3=82=8B=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 先客が位置エラーで残る再開では、先客を消して今回分を二重に積み、未投稿のまま 控えへ送れた先を書いて取り込みを成功扱いにしていた。退避は今回分の連番が 拒まれたときだけ行い、今回分が送れていない限り控えを書かず失敗として返す (上限による待ちは従来どおり失敗にしない)。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 17 ++++++---- .../ndf/scripts/tests/test_result_posts.py | 33 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 2e84ca4d..f12cff89 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -217,6 +217,10 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, 応答はどの項目が原因かを指さないため、1 件ずつの特定はできない(実測)。 同じ状態で返る別の拒まれ方(判定の値の誤り・基準のコミットの誤り)は退避せず、 失敗として残す。 + + **退避するのは、今回積んだ項目が拒まれたときだけである。** 先に積まれていた項目 + (先客)の拒まれ方を今回分のものと取り違えると、先客を消して今回分を二重に積む。 + 今回分が送れていない限り、控えへ送れた先を書かない。 """ findings = len(_findings(_read_json(payload_path))) item = review_posts(payload_path, result_path, repo, pr, round_no, seat, @@ -226,7 +230,8 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, seq = (post_queue.read_item(path) or {}).get("seq") flushed = queue.flush() - if flushed.failed and post_queue.rejected_by_position(flushed.failed): + ours_failed = flushed.failed is not None and flushed.failed.get("seq") == seq + if ours_failed and post_queue.rejected_by_position(flushed.failed): queue.drop(flushed.failed.get("seq")) item = review_posts(payload_path, result_path, repo, pr, round_no, seat, head_sha, is_own_pr, evacuate_all=True)[0] @@ -236,17 +241,17 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, flushed = queue.flush() done = _find(flushed.sent, seq) or _find(flushed.skipped, seq) - failed = flushed.failed is not None and flushed.failed.get("seq") == seq - queued = 0 if done else 1 - if not failed: + if done: _write_destinations(payload_path, item["extra"]["inline"]) return ReviewOutcome( review_url=_response_url(done), posted_inline=item["extra"]["inline"] if done else 0, posted_body=item["extra"]["body"] if done else 0, - queued=queued if not failed else 1, + queued=0 if done else 1, findings=findings, - failed=bool(failed and not flushed.rate_limited), + # 先客に止められた場合も、今回分は送れていない。上限だけは待てば流れる。 + failed=bool(not done and flushed.failed is not None + and not flushed.rate_limited), posted_as=item["extra"]["posted_as"], intent=item["extra"]["intent"], detail=str((flushed.failed or {}).get("last_error") or ""), diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index c4ba93c2..d84cfa84 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -285,6 +285,39 @@ def test_another_rejection_of_the_same_status_is_not_moved(tmp_path, fake_gh) -> assert outcome.review_url is None +def test_a_position_rejection_of_an_earlier_item_is_not_taken_as_ours( + tmp_path, fake_gh) -> None: + """先に積まれた項目の位置エラーで、今回の分を退避しない。 + + 先客を消して今回分を二重に積むと、未投稿のまま控えへ送れた先を書き、取り込みを + 成功扱いにしてしまう。今回分が送れていない限り、失敗として残す。 + """ + earlier = post_queue.enqueue( + _queue(tmp_path), "review-post", REPO, PR, + {"body": f"## 🤖 cross-review | round {ROUND} | agy | COMMENT\n", + "event": "COMMENT", + "comments": [{"path": "c.py", "line": 9, "side": "RIGHT", "body": "先客"}]}, + actor=ACTOR, extra={"ident": f"agy-r{ROUND}"}) + earlier_seq = post_queue.read_item(earlier)["seq"] + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _REJECT_POSITION, + ]) + + outcome, payload = _post_review(tmp_path) + + assert outcome.failed is True + assert outcome.queued == 1 + assert outcome.review_url is None + note = json.loads(payload.read_text(encoding="utf-8")) + assert all("posted_to" not in comment for comment in note["comments"]) + # 先客は残り、今回分は 1 件だけ(退避した写しを足さない)。 + queued = [item for _, item in _queue(tmp_path).items()] + assert [i["seq"] for i in queued][0] == earlier_seq + assert len(queued) == 2 + assert [i["extra"].get("agent") for i in queued] == [None, SEAT] + + def test_a_rate_limited_review_remains_queued_without_marking_the_note( tmp_path, fake_gh) -> None: """現状固定。上限時は失敗にせず、未投稿の要求と控えをそのまま残す。""" From a3babd38ff5e08616887b7a001c6afd73f6826d0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 13:17:41 +0000 Subject: [PATCH 205/217] =?UTF-8?q?Fix:=20=E6=8E=A7=E3=81=88=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E9=80=81=E3=82=8C=E3=81=9F=E5=85=88=E3=81=AE=E6=9B=B8?= =?UTF-8?q?=E3=81=8D=E6=88=BB=E3=81=97=E3=82=92=E5=8E=9F=E5=AD=90=E7=9A=84?= =?UTF-8?q?=E3=81=AB=E3=81=97=E3=80=81=E5=A4=B1=E6=95=97=E3=81=A7=E5=8F=96?= =?UTF-8?q?=E3=82=8A=E8=BE=BC=E3=81=BF=E3=82=92=E6=AD=A2=E3=82=81=E3=82=8B?= =?UTF-8?q?=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 書き戻しが途中で落ちると半端な控えが残り、再実行で空として読まれて記録済みの 指摘を 0 件で置き換えていた。状態ファイルの原子的な書き込みを共通層 (statefile.write_json_atomic)へ切り出して控えにも使い、書けなかったときは 失敗として返して取り込みを止める。再実行は既投稿として照合し直す。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/lib/result_posts.py | 30 ++++++++++------- plugins/ndf/scripts/lib/statefile.py | 20 +++++++++--- .../ndf/scripts/tests/test_result_posts.py | 32 +++++++++++++++++++ 3 files changed, 65 insertions(+), 17 deletions(-) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index f12cff89..0ec4c24a 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -30,6 +30,7 @@ sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) import post_queue # noqa: E402 +import statefile # noqa: E402 # レビューの本文の先頭行。**照合の鍵はラウンドと席までの前方一致である**ため、判定の # 語はこの行の末尾に置く(`post_queue.review_match_key`)。 @@ -190,21 +191,26 @@ def _response_url(item: dict[str, Any] | None) -> str | None: return None -def _write_destinations(payload_path: pathlib.Path | str, inline_count: int) -> None: - """控えへ、送れた先を書き戻す。**決めるのは投稿する側である。**""" +def _write_destinations(payload_path: pathlib.Path | str, inline_count: int) -> str: + """控えへ、送れた先を書き戻す。**決めるのは投稿する側である。** + + **原子的に書き、失敗は呼び出し元へ返す。** 半端な控えが残ると、再実行で読めずに + 空として扱われ、記録済みの指摘を 0 件で置き換える。戻り値は失敗の説明で、 + 書けたときは空文字である。 + """ path = pathlib.Path(payload_path) payload = _read_json(path) findings = _findings(payload) if not findings: - return + return "" inline_ids = {id(f) for f in findings if _can_be_inline(f)} if inline_count else set() for f in findings: f["posted_to"] = "inline" if id(f) in inline_ids else "body" try: - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), - encoding="utf-8") - except OSError: - return + statefile.write_json_atomic(path, payload) + except OSError as exc: + return f"控えへ送れた先を書けない ({path.name}: {exc})" + return "" def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, @@ -241,8 +247,8 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, flushed = queue.flush() done = _find(flushed.sent, seq) or _find(flushed.skipped, seq) - if done: - _write_destinations(payload_path, item["extra"]["inline"]) + # 送れた後に控えを書けなければ、取り込みを止める。再実行は既投稿として照合し直す。 + note_error = _write_destinations(payload_path, item["extra"]["inline"]) if done else "" return ReviewOutcome( review_url=_response_url(done), posted_inline=item["extra"]["inline"] if done else 0, @@ -250,11 +256,11 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, queued=0 if done else 1, findings=findings, # 先客に止められた場合も、今回分は送れていない。上限だけは待てば流れる。 - failed=bool(not done and flushed.failed is not None - and not flushed.rate_limited), + failed=bool(note_error) or bool(not done and flushed.failed is not None + and not flushed.rate_limited), posted_as=item["extra"]["posted_as"], intent=item["extra"]["intent"], - detail=str((flushed.failed or {}).get("last_error") or ""), + detail=note_error or str((flushed.failed or {}).get("last_error") or ""), ) diff --git a/plugins/ndf/scripts/lib/statefile.py b/plugins/ndf/scripts/lib/statefile.py index 623c79a2..6e15626b 100644 --- a/plugins/ndf/scripts/lib/statefile.py +++ b/plugins/ndf/scripts/lib/statefile.py @@ -41,16 +41,26 @@ def load(path: pathlib.Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) -def save(path: pathlib.Path, state: dict[str, Any]) -> None: - """状態ファイルを原子的に書く。 +def write_json_atomic(path: pathlib.Path, data: Any) -> None: + """JSON を原子的に書く。 同じディレクトリへ一時ファイルを書いてから `replace` する。途中で落ちても - 半端な JSON が残らないため、再開時に必ず読める。 + 半端な JSON が残らないため、再開時に必ず読める。失敗は例外で返し、一時 + ファイルは残さない。 """ path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") - tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") - tmp.replace(path) + try: + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def save(path: pathlib.Path, state: dict[str, Any]) -> None: + """状態ファイルを原子的に書く(`write_json_atomic`)。""" + write_json_atomic(path, state) # **差し込み口の失敗で保存の呼び出し側を止めない。** 状態は既に書けている。 for hook in list(_AFTER_SAVE): try: diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index d84cfa84..9b62a98d 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -318,6 +318,38 @@ def test_a_position_rejection_of_an_earlier_item_is_not_taken_as_ours( assert [i["extra"].get("agent") for i in queued] == [None, SEAT] +def test_a_failed_write_of_the_note_keeps_it_whole_and_stops_the_take_in( + tmp_path, fake_gh, monkeypatch) -> None: + """控えの書き戻しが途中で落ちても控えは元のまま読め、取り込みは失敗として止まる。 + + 半端な控えを残すと、再実行で読めずに空として扱われ、記録済みの指摘を 0 件で + 置き換える。 + """ + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _ACCEPT, + ]) + payload, result = _files(tmp_path) + original = payload.read_text(encoding="utf-8") + real_write = pathlib.Path.write_text + + def half_write(self, data, *a, **kw): + if "posted_to" in data: + real_write(self, data[: len(data) // 2], *a, **kw) + raise OSError("disk full") + return real_write(self, data, *a, **kw) + + monkeypatch.setattr(pathlib.Path, "write_text", half_write) + outcome = result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR) + + assert outcome.failed is True + assert "控え" in outcome.detail + assert payload.read_text(encoding="utf-8") == original + assert [p.name for p in payload.parent.iterdir() if p.name.endswith(".tmp")] == [] + + def test_a_rate_limited_review_remains_queued_without_marking_the_note( tmp_path, fake_gh) -> None: """現状固定。上限時は失敗にせず、未投稿の要求と控えをそのまま残す。""" From 9927f24a948cc556989603d2466318c9ab5cd7ee Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 13:26:29 +0000 Subject: [PATCH 206/217] =?UTF-8?q?Fix:=20=E5=9B=9E=E3=81=97=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=9F=E5=AE=9F=E8=A1=8C=E3=81=AE=E3=83=AC=E3=83=93?= =?UTF-8?q?=E3=83=A5=E3=83=BC=E3=82=92=E5=89=8D=E3=81=AE=E5=AE=9F=E8=A1=8C?= =?UTF-8?q?=E3=81=AE=E6=8A=95=E7=A8=BF=E3=81=A8=E5=8F=96=E3=82=8A=E9=81=95?= =?UTF-8?q?=E3=81=88=E3=81=AA=E3=81=84=EF=BC=88#730=20#583=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- issues/issue-730-583-design.md | 4 +- plugins/ndf/scripts/lib/post_queue.py | 29 +++++++++++- plugins/ndf/scripts/lib/result_posts.py | 14 ++++-- .../ndf/scripts/tests/test_result_posts.py | 44 +++++++++++++++++++ .../ndf/skills/cross-review/scripts/state.py | 1 + .../ndf/skills/cross-review/tests/conftest.py | 4 +- .../tests/test_state_read_result.py | 24 ++++++++++ 7 files changed, 112 insertions(+), 8 deletions(-) diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md index 7b4c0db6..aa3b8d8d 100644 --- a/issues/issue-730-583-design.md +++ b/issues/issue-730-583-design.md @@ -112,13 +112,15 @@ | 種別 | 照合の鍵 | | --- | --- | -| `review-post` | 投稿者と、本文の先頭行の `## 🤖 cross-review \| round \| <席> \|` までの前方一致(判定の語を含めない) | +| `review-post` | 投稿者と、本文の先頭行の `## 🤖 cross-review \| round \| <席> \|` までの前方一致(判定の語を含めない)。ラウンドの開始時刻を持つときは、それ以降に出たレビューに限る | | `review-reply` | 返信先の指摘の識別子と、本文の先頭 80 文字 | | `thread-resolve` | スレッドの識別子と、すでに決着しているかどうか | | `pr-comment` | 投稿者と、本文の先頭 80 文字(ラウンドを含む) | 投稿者はどの席でも同じになる。ラウンドと席を先頭行に持たせることで、同じ投稿者の別の投稿と区別できる。 +**レビューの照合は、そのラウンドが始まった時刻(状態ファイルの `rounds[-1].started_at`)以降に出たレビューに限る。** ラウンドの番号は実行ごとに 1 から数え直すため、収束した PR へ回し直すと、ラウンドと席だけの鍵が前の実行のレビューに一致し、新しい指摘を送らずに前の実行の投稿を送れた先として記録する。開始時刻で絞っても同じ実行の中の送り直しは従来どおり見つかり、開始時刻かレビューの時刻を読めないときはラウンドと席だけの照合へ落とす。 + ### 決定 6: 投稿済みで記録なしの状態が起きなくなるため、投稿済みのレビューを探して記録だけの起動をする決めを取り下げる 担当が投稿しなくなるため、探す対象が無い。**GitHub からレビューを探す照会も、記録だけを行うプロンプトも作らない。** 止まった担当を起動し直すときは、初回と同じプロンプトをそのまま使う。 diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index 15ea730e..39949f53 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -251,11 +251,16 @@ def _request_review_post(repo: str, pr: int, fields: dict[str, Any]) -> dict[str body["commit_id"] = fields["commit_id"] if fields.get("comments"): body["comments"] = fields["comments"] + match = {"event": fields["event"], "body": fields.get("body", "")} + # **照合をラウンドの開始より後へ絞る。** ラウンドの番号は実行ごとに 1 から数え + # 直すため、番号と席の鍵だけでは前の実行のレビューに一致する。 + if fields.get("since"): + match["since"] = fields["since"] return { "request": {"method": "POST", "path": f"repos/{repo}/pulls/{int(pr)}/reviews", "fields": body}, - "match": {"event": fields["event"], "body": fields.get("body", "")}, + "match": match, } @@ -377,11 +382,33 @@ def _comment_match(match: dict[str, Any], actor: str | None): return lambda row: _by_actor(row, actor) and _head(row.get("body")) == head +def _parse_time(value: Any) -> _dt.datetime | None: + try: + parsed = _dt.datetime.fromisoformat(str(value or "").replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else None + + def _review_match(match: dict[str, Any], actor: str | None): + """同じラウンド・同じ席のレビューか。 + + **開始時刻を持つときは、それより後に出たレビューだけを見る。** 同じ実行の中の + 送り直しは見つかり、回し直す前の実行のレビューは外れる。開始時刻かレビューの + 時刻のどちらかを読めないときは、番号と席だけの照合へ落とす(二重に送る側へ + 倒さない)。 + """ key = review_match_key(match.get("body")) + since = _parse_time(match.get("since")) + + def _in_this_run(row: dict[str, Any]) -> bool: + submitted = _parse_time(row.get("submitted_at")) + return since is None or submitted is None or submitted >= since + return lambda row: ( _by_actor(row, actor) and review_match_key(row.get("body")) == key + and _in_this_run(row) ) diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 0ec4c24a..010e778e 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -125,13 +125,17 @@ def _review_body(payload: dict[str, Any], round_no: int, seat: str, intent: str, def review_posts(payload_path: pathlib.Path | str, result_path: pathlib.Path | str, repo: str, pr: int, round_no: int, seat: str, head_sha: str | None, is_own_pr: bool, - evacuate_all: bool = False) -> list[dict[str, Any]]: + evacuate_all: bool = False, + since: str | None = None) -> list[dict[str, Any]]: """指摘の控えと結果ファイルから、待ち行列へ積む項目の列を組み立てる。 **判定の格下げはこの層が決める**(設計の決定 14)。自分の Pull Request へは変更を 求めるレビューを送れないため、送る形だけを `COMMENT` へ落とす。本来の判定は 先頭行と `extra` に残り、収束の判定はそちらを読む。 + `since` はそのラウンドが始まった時刻である。二度書かない照合をこの時刻より後に + 出たレビューへ絞るために、待ち行列の項目へ持たせる。 + `evacuate_all` が真のとき、位置を持つ指摘もすべて総評へ移す。送った要求が位置を 解決できずに拒まれた後の送り直しで使う。 """ @@ -148,6 +152,8 @@ def review_posts(payload_path: pathlib.Path | str, result_path: pathlib.Path | s fields: dict[str, Any] = {"body": body, "event": posted_as} if head_sha: fields["commit_id"] = head_sha + if since: + fields["since"] = since if inline: fields["comments"] = [ {"path": str(f.get("path")), "line": _line_no(f.get("line")), @@ -216,7 +222,7 @@ def _write_destinations(payload_path: pathlib.Path | str, inline_count: int) -> def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, result_path: pathlib.Path | str, repo: str, pr: int, round_no: int, seat: str, head_sha: str | None, is_own_pr: bool, - actor: str | None = None) -> ReviewOutcome: + actor: str | None = None, since: str | None = None) -> ReviewOutcome: """レビューを 1 件、待ち行列を通して送る。 **位置を解決できずに拒まれたら、その要求のインラインをすべて総評へ移して送り直す。** @@ -230,7 +236,7 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, """ findings = len(_findings(_read_json(payload_path))) item = review_posts(payload_path, result_path, repo, pr, round_no, seat, - head_sha, is_own_pr)[0] + head_sha, is_own_pr, since=since)[0] path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], actor=actor, extra=item["extra"]) seq = (post_queue.read_item(path) or {}).get("seq") @@ -240,7 +246,7 @@ def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, if ours_failed and post_queue.rejected_by_position(flushed.failed): queue.drop(flushed.failed.get("seq")) item = review_posts(payload_path, result_path, repo, pr, round_no, seat, - head_sha, is_own_pr, evacuate_all=True)[0] + head_sha, is_own_pr, evacuate_all=True, since=since)[0] path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], actor=actor, extra=item["extra"]) seq = (post_queue.read_item(path) or {}).get("seq") diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py index 9b62a98d..117e16f6 100644 --- a/plugins/ndf/scripts/tests/test_result_posts.py +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -390,6 +390,50 @@ def test_a_review_that_is_already_on_github_is_not_posted_again( assert [c for c in fake_gh.joined() if "--method POST" in c] == [] +_STARTED = "2026-09-22T12:00:00+09:00" + + +def _earlier_run_review(submitted_at: str) -> dict: + """同じラウンド番号・同じ席の、先に出ていたレビュー。""" + head = f"## 🤖 cross-review | round {ROUND} | {SEAT} | APPROVE" + return {"match": "pulls/730/reviews?", + "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", + "body": head + "\n\n前の実行", "id": 42, + "submitted_at": submitted_at, + "html_url": "https://x/pull/730#pullrequestreview-42"}])} + + +def test_a_review_of_an_earlier_run_is_not_taken_as_this_one(tmp_path, fake_gh) -> None: + """回し直した実行では、ラウンドの開始より前のレビューを同じ投稿と読まない。 + + ラウンドの番号は実行ごとに 1 から数え直すため、番号と席だけでは実行をまたいで + 一意にならない。 + """ + fake_gh.set_rules([_earlier_run_review("2026-09-22T02:59:59Z"), _ACCEPT]) + + payload, result = _files(tmp_path) + outcome = result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR, since=_STARTED) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-99" + assert len([c for c in fake_gh.joined() if "--method POST" in c]) == 1 + + +def test_a_review_sent_after_the_round_started_is_still_not_sent_again( + tmp_path, fake_gh) -> None: + """同じ実行の中で送った後に止まった分は、開始時刻で絞っても見つかる(AC12)。""" + fake_gh.set_rules([_earlier_run_review("2026-09-22T03:00:05Z")]) + + payload, result = _files(tmp_path) + outcome = result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR, since=_STARTED) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-42" + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + + def test_a_note_with_findings_is_not_a_missing_result(tmp_path, fake_gh) -> None: """インラインとして送れたものが 0 件でも、その担当は結果なしにならない(AC17)。""" fake_gh.set_rules([ diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index 34ffe9d4..9667dd61 100755 --- a/plugins/ndf/skills/cross-review/scripts/state.py +++ b/plugins/ndf/skills/cross-review/scripts/state.py @@ -2886,6 +2886,7 @@ def cmd_read_result(args: argparse.Namespace) -> None: head_sha=str(last.get("head_sha") or ""), is_own_pr=bool(st.get("event_downgrade") or st.get("is_own_pr")), actor=str(st.get("viewer_login") or "") or None, + since=str(last.get("started_at") or "") or None, ) if posted.failed: die(f"{agent}: レビューを投稿できませんでした ({posted.detail})") diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf/skills/cross-review/tests/conftest.py index 7e87c901..94bb32c3 100644 --- a/plugins/ndf/skills/cross-review/tests/conftest.py +++ b/plugins/ndf/skills/cross-review/tests/conftest.py @@ -146,9 +146,9 @@ def _no_github_state(request, monkeypatch) -> None: def _post_review_offline(rp): """送信を行わず、組み立てた内容がそのまま届いたものとして結果を返す。""" def _post(queue, payload_path, result_path, repo, pr, round_no, seat, head_sha, - is_own_pr, actor=None): + is_own_pr, actor=None, since=None): item = rp.review_posts(payload_path, result_path, repo, pr, round_no, seat, - head_sha, is_own_pr)[0] + head_sha, is_own_pr, since=since)[0] extra = item["extra"] findings = len(rp._findings(rp._read_json(payload_path))) return rp.ReviewOutcome( diff --git a/plugins/ndf/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf/skills/cross-review/tests/test_state_read_result.py index 8a270b69..6700da5a 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_read_result.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_read_result.py @@ -171,3 +171,27 @@ def test_invalid_json_result_file_dies(patched_tmp_dir, state_mod, capsys): assert e.value.code == 3 captured = capsys.readouterr() assert "parse" in captured.err.lower() or "parse" in captured.err + + +def test_the_take_in_passes_the_start_of_the_round( + patched_tmp_dir, state_mod, monkeypatch): + """取り込みは、二度書かない照合を絞るためにラウンドの開始時刻を渡す。 + + ラウンドの番号は回し直すと 1 から数え直すため、前の実行のレビューと取り違えない。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + seen = {} + offline = state_mod.result_posts.post_review + + def _spy(*a, **kw): + seen["since"] = kw.get("since") + return offline(*a, **kw) + + monkeypatch.setattr(state_mod.result_posts, "post_review", _spy) + + state_mod.cmd_read_result(_make_args(rfile)) + + assert seen["since"] == "2026-05-21T00:00:00+00:00" From 5dee79f115ea4332a69267ccd79aeb2f54c76c56 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 13:42:59 +0000 Subject: [PATCH 207/217] =?UTF-8?q?Update:=20statusline=20=E3=81=AB?= =?UTF-8?q?=E3=82=B5=E3=83=96=E3=82=A8=E3=83=BC=E3=82=B8=E3=82=A7=E3=83=B3?= =?UTF-8?q?=E3=83=88=E3=81=AE=E3=82=B3=E3=83=B3=E3=83=86=E3=82=AD=E3=82=B9?= =?UTF-8?q?=E3=83=88=E4=BD=BF=E7=94=A8=E9=87=8F=E3=82=92=E4=B8=A6=E3=81=B9?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 実行中のサブエージェントの使用量を `│` の後に使用量の多い順で 3 本まで並べ、残りは本数だけを出す - 終わったサブエージェントは記録の末尾から判定して外す - ラベルは description の先頭を端末の 8 桁で切る(全角は 2 桁) - メインの表示から上限と使用率を外し、モデル名を詰める(Opus5 61k) - コンテナ名・ホスト名の表示を外す Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 116 ++++++++++++++++--------- plugins/ndf/skills/statusline/SKILL.md | 16 ++-- 2 files changed, 86 insertions(+), 46 deletions(-) diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index 19d7c098..a71d64fd 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -1,60 +1,94 @@ #!/bin/bash # ndf-statusline: managed (do not edit; auto-updated by ndf:statusline) # NDF plugin 標準 statusline: -# <コンテナ名 or ホスト名> [<モデル名>: 使用トークン / 全体 (使用率%)] +# [<モデル名> 使用トークン │ <サブエージェントの説明> 使用トークン · ...] input=$(cat) -# コンテナ名を取得する。コンテナでなければホスト名にフォールバック -container_name="" - -# Docker/コンテナ環境かどうかを /.dockerenv で判定 -if [ -f /.dockerenv ]; then - # CONTAINER_NAME 環境変数が明示設定されていればそちらを優先 - container_name="${CONTAINER_NAME:-}" - - # Docker ソケットが使えれば docker inspect で compose 上のコンテナ名を取得 - # (/etc/hostname はコンテナIDなので、それをキーに引く) - if [ -z "$container_name" ] && [ -S /var/run/docker.sock ] && command -v docker >/dev/null 2>&1; then - container_id=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') - if [ -n "$container_id" ]; then - container_name=$(docker inspect --format '{{.Name}}' "$container_id" 2>/dev/null | sed 's|^/||') - fi - fi - - # 取れなければ /etc/hostname(コンテナID)をフォールバックとして使用 - if [ -z "$container_name" ] && [ -f /etc/hostname ]; then - container_name=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') - fi -fi - -# コンテナ名が取れなければ hostname コマンドにフォールバック -if [ -z "$container_name" ]; then - container_name=$(hostname -s 2>/dev/null || hostname) -fi - -dir="$container_name" - # claude root のパスを取得(project_dir を優先し、なければ current_dir を使用) # jq 不在や無効な JSON 入力時に stderr が statusLine 描画に漏れないよう 2>/dev/null で抑制 claude_root=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // empty' 2>/dev/null) total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty' 2>/dev/null) -ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty' 2>/dev/null) -used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) +transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) -# モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック -model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null) +# モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック。 +# 現行モデルの上限は 1M なので、"Opus 5 (1M context)" の括弧と空白は落として "Opus5" にする +model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null | sed 's/ *(.*)//; s/ //g') ctx_label="${model_name:-ctx}" ctx_info="" -if [ -n "$total_input" ] && [ -n "$ctx_size" ] && [ -n "$used_pct" ]; then - total_input_k=$(awk "BEGIN { printf \"%.1f\", $total_input / 1000 }") - ctx_size_k=$(awk "BEGIN { printf \"%.0f\", $ctx_size / 1000 }") - ctx_info=$(printf " \033[0;36m[%s: %sk / %sk tokens (%.0f%%)]" "$ctx_label" "$total_input_k" "$ctx_size_k" "$used_pct") +if [ -n "$total_input" ]; then + ctx_info=$(printf " \033[0;36m[%s %sk" "$ctx_label" "$((total_input / 1000))") + + # 実行中のサブエージェントのコンテキスト使用量を並べる。statusLine の JSON は + # メインセッションの値しか持たないため、サブエージェントの記録から読む。 + # 記録は /subagents/agent-.jsonl にある + sub_dir="${transcript%.jsonl}/subagents" + rows="" + if [ -n "$transcript" ] && [ -d "$sub_dir" ]; then + now=$(date +%s) + # 直近 2 分以内に更新された記録だけを候補にする + for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -2 2>/dev/null); do + # 末尾だけを読む。記録は長くなるため全体を走査しない。 + # 出力: モデル / 使用量 / 状態 (run | done | idle) + # 最後の user か assistant の行が tool_use を含まない assistant なら応答を書き終えている。 + # end_turn が付いていれば終了。付いていなければ tool_use の直前の text の途中かもしれない (idle) + st=$(tail -n 50 "$f" | jq -rs ' + (map(select(.type == "assistant" or .type == "user")) | last) as $l + | (map(select(.message.usage?)) | last) as $u + | if $u == null then empty else + [ ($u.message.model // ""), + ($u.message.usage | (.input_tokens // 0) + (.cache_creation_input_tokens // 0) + (.cache_read_input_tokens // 0)), + (if $l.type == "assistant" and ([$l.message.content[]?.type] | index("tool_use") | not) + then (if $l.message.stop_reason == "end_turn" then "done" else "idle" end) + else "run" end) ] + | @tsv end' 2>/dev/null) + [ -n "$st" ] || continue + IFS=$'\t' read -r model tokens state <<<"$st" + [ "$state" = done ] && continue + if [ "$state" = idle ]; then + # 30 秒以上書き足されていなければ終わったとみなす (GNU / BSD の stat の両方に対応) + mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null) + [ -n "$mtime" ] && [ $((now - mtime)) -ge 30 ] && continue + fi + # 説明の先頭を端末の 8 桁までに切ってラベルにする。種類名は general-purpose が + # ほとんどで見分けに使えない。全角は 2 桁を取るため、文字数ではなく桁数で切る + # (U+2E80 以降を全角とみなす近似) + label=$(jq -r ' + def w: if . >= 11904 then 2 else 1 end; + .description // empty | gsub("\\s"; "") | explode as $c + | if ($c | map(w) | add // 0) <= 8 then . + else (reduce $c[] as $x ({s: [], n: 0, full: false}; + if .full or .n + ($x | w) > 7 then .full = true + else .s += [$x] | .n += ($x | w) end) + | .s | implode) + "…" end' "${f%.jsonl}.meta.json" 2>/dev/null) + [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:7}; } + # 1M 未満のモデルは Haiku(200K)だけなので、150k を超えたら黄色で知らせる + warn=0 + case "$model" in *haiku*) [ "$tokens" -gt 150000 ] && warn=1 ;; esac + rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' + done + fi + # 使用量の多い順に 3 本まで並べ、残りは本数だけを出す。80 桁の端末に収めるため + if [ -n "$rows" ]; then + subs="" + n=0 + while IFS=$'\t' read -r tokens warn label; do + n=$((n + 1)) + [ "$n" -gt 3 ] && continue + entry="$label $((tokens / 1000))k" + [ "$warn" = 1 ] && entry=$(printf "\033[0;33m%s\033[0;36m" "$entry") + subs="${subs:+$subs · }$entry" + done < <(printf "%s" "$rows" | sort -t $'\t' -k1,1nr) + [ "$n" -gt 3 ] && subs="$subs +$((n - 3))" + ctx_info="$ctx_info │ $subs" + fi + ctx_info="$ctx_info]" fi +# コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる if [ -n "$claude_root" ]; then - printf "\033[01;34m%s\033[00m \033[0;33m%s\033[00m%s" "$dir" "$claude_root" "$ctx_info" + printf "\033[0;33m%s\033[00m%s" "$claude_root" "$ctx_info" else - printf "\033[01;34m%s\033[00m%s" "$dir" "$ctx_info" + printf "%s" "${ctx_info# }" fi diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 4e94cdab..913ebd6a 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -9,18 +9,24 @@ allowed-tools: # Statusline 切り替えコマンド -NDF 標準 statusline (コンテナ名/ホスト名 + project_dir + コンテキスト使用率) と +NDF 標準 statusline (project_dir + メインとサブエージェントのコンテキスト使用量) と 既存のカスタム statusline を切り替える。 ## 表示内容 ``` -<コンテナ名|ホスト名> [<モデル名>: 12.3k / 200k tokens (6%)] + [Opus5 61k │ 修正:PR8… 167k · 検証:#8… 42k] ``` -- コンテナ環境 (`/.dockerenv` あり) ではコンテナ名、それ以外ではホスト名を表示 -- `CONTAINER_NAME` 環境変数があればそちらを優先 -- 角括弧内のラベルは利用中モデルの表示名 (例: `Opus 4.8`)。取得できない場合は `ctx` にフォールバック +- コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる +- 角括弧の先頭は利用中モデルの表示名と、メインセッションのコンテキスト使用量。表示名の括弧と空白は落とす (`Opus 5 (1M context)` → `Opus5`)。取得できない場合は `ctx` にフォールバック +- **上限と使用率は出さない。** 現行モデルの上限は Haiku 4.5 (200K) を除いて 1M で、使用量だけで足りる +- `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`/subagents/agent-.jsonl` の最後の `usage` から読む + - 直近 2 分以内に記録が更新され、終わっていないものを実行中とみなす。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす + - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため + - ラベルは `agent-.meta.json` の `description` の先頭で、空白を除いて端末の 8 桁までに切る (全角は 2 桁)。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す + - Haiku で 150k を超えたものだけ黄色で表示する + - サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない ## 使用方法 From 6f8a5732fb5cdacd401d656fd10a18be19c361eb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 13:59:37 +0000 Subject: [PATCH 208/217] =?UTF-8?q?Update:=20statusline=20=E3=81=AE?= =?UTF-8?q?=E5=86=8D=E6=8F=8F=E7=94=BB=E3=81=AE=E9=96=93=E9=9A=94=E3=81=A8?= =?UTF-8?q?=20500k=20=E8=B6=85=E3=81=AE=E8=AD=A6=E5=91=8A=E3=82=92?= =?UTF-8?q?=E8=B6=B3=E3=81=97=E3=80=81=E3=83=A9=E3=83=99=E3=83=AB=E3=82=92?= =?UTF-8?q?=204=20=E6=96=87=E5=AD=97=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NDF 標準の statusLine に refreshInterval: 5 を持たせる。既存の設定には ensure / set が足し、利用者の値は変えない - メイン・サブエージェントとも 500k を超えた使用量を赤で表示する(Haiku は 150k) - サブエージェントのラベルを説明の先頭 4 文字にし、空白を挟んで使用量を続ける Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline-switch.sh | 19 ++++++- plugins/ndf/scripts/statusline.sh | 31 +++++------ plugins/ndf/skills/statusline/SKILL.md | 11 +++- .../tests/test_statusline_switch.py | 55 +++++++++++++++++++ 4 files changed, 94 insertions(+), 22 deletions(-) diff --git a/plugins/ndf/scripts/statusline-switch.sh b/plugins/ndf/scripts/statusline-switch.sh index bccb616a..35657541 100755 --- a/plugins/ndf/scripts/statusline-switch.sh +++ b/plugins/ndf/scripts/statusline-switch.sh @@ -103,9 +103,20 @@ update_settings() { fi } +# 再描画の間隔 (秒)。メインが待機中でもイベントが起きず描き直されないため、 +# サブエージェントの使用量を追うには時間で描き直す必要がある +NDF_REFRESH_INTERVAL=5 + set_ndf_statusline() { - update_settings --arg cmd "$NDF_COMMAND" \ - '.statusLine = {type: "command", command: $cmd}' + update_settings --arg cmd "$NDF_COMMAND" --argjson ri "$NDF_REFRESH_INTERVAL" \ + '.statusLine = {type: "command", command: $cmd, refreshInterval: $ri}' +} + +# 既に NDF 標準を使っている設定へ、refreshInterval が無ければ足す。利用者が決めた値は残す +ensure_refresh_interval() { + if [ "$(jq -r '.statusLine | has("refreshInterval")' "$SETTINGS" 2>/dev/null)" = false ]; then + update_settings --argjson ri "$NDF_REFRESH_INTERVAL" '.statusLine.refreshInterval = $ri' + fi } # NDF 由来の旧 statusline を検出した際に、既存設定をバックアップした上で @@ -124,8 +135,9 @@ cmd_ensure() { deploy_script # 既に statusLine が設定されている場合 if [ -n "$(jq -r '.statusLine // empty' "$SETTINGS" 2>/dev/null)" ]; then - # 正規パスを指していれば deploy_script で本体が追従済み (何もしない) + # 正規パスを指していれば deploy_script で本体が追従済み。再描画の間隔だけ補う if is_ndf_statusline; then + ensure_refresh_interval return 0 fi # NDF が過去に配置したコピー (マーカー付き or レガシー statusline-command.sh) を @@ -146,6 +158,7 @@ cmd_ensure() { cmd_set() { deploy_script if is_ndf_statusline; then + ensure_refresh_interval echo "[ndf:statusline] 既に NDF 標準 statusline が設定されています" return 0 fi diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index a71d64fd..baad2567 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -16,9 +16,15 @@ transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null | sed 's/ *(.*)//; s/ //g') ctx_label="${model_name:-ctx}" +# 使用量がこの値を超えたら赤で知らせる。上限は出さないため、色で危険な水準を示す +WARN_TOKENS=500000 +WARN_COLOR='\033[0;31m' + ctx_info="" if [ -n "$total_input" ]; then - ctx_info=$(printf " \033[0;36m[%s %sk" "$ctx_label" "$((total_input / 1000))") + main_used="$((total_input / 1000))k" + [ "$total_input" -gt "$WARN_TOKENS" ] && main_used=$(printf "$WARN_COLOR%s\033[0;36m" "$main_used") + ctx_info=$(printf " \033[0;36m[%s %s" "$ctx_label" "$main_used") # 実行中のサブエージェントのコンテキスト使用量を並べる。statusLine の JSON は # メインセッションの値しか持たないため、サブエージェントの記録から読む。 @@ -51,21 +57,14 @@ if [ -n "$total_input" ]; then mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null) [ -n "$mtime" ] && [ $((now - mtime)) -ge 30 ] && continue fi - # 説明の先頭を端末の 8 桁までに切ってラベルにする。種類名は general-purpose が - # ほとんどで見分けに使えない。全角は 2 桁を取るため、文字数ではなく桁数で切る - # (U+2E80 以降を全角とみなす近似) - label=$(jq -r ' - def w: if . >= 11904 then 2 else 1 end; - .description // empty | gsub("\\s"; "") | explode as $c - | if ($c | map(w) | add // 0) <= 8 then . - else (reduce $c[] as $x ({s: [], n: 0, full: false}; - if .full or .n + ($x | w) > 7 then .full = true - else .s += [$x] | .n += ($x | w) end) - | .s | implode) + "…" end' "${f%.jsonl}.meta.json" 2>/dev/null) - [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:7}; } - # 1M 未満のモデルは Haiku(200K)だけなので、150k を超えたら黄色で知らせる + # 説明の先頭 4 文字をラベルにする。種類名は general-purpose がほとんどで見分けに使えない + label=$(jq -r '.description // empty | gsub("\\s"; "") | .[0:4]' "${f%.jsonl}.meta.json" 2>/dev/null) + [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:4}; } + # 500k を超えたら赤で知らせる。1M 未満のモデルは Haiku(200K)だけなので、Haiku は 150k で知らせる + limit=$WARN_TOKENS + case "$model" in *haiku*) limit=150000 ;; esac warn=0 - case "$model" in *haiku*) [ "$tokens" -gt 150000 ] && warn=1 ;; esac + [ "$tokens" -gt "$limit" ] && warn=1 rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' done fi @@ -77,7 +76,7 @@ if [ -n "$total_input" ]; then n=$((n + 1)) [ "$n" -gt 3 ] && continue entry="$label $((tokens / 1000))k" - [ "$warn" = 1 ] && entry=$(printf "\033[0;33m%s\033[0;36m" "$entry") + [ "$warn" = 1 ] && entry=$(printf "$WARN_COLOR%s\033[0;36m" "$entry") subs="${subs:+$subs · }$entry" done < <(printf "%s" "$rows" | sort -t $'\t' -k1,1nr) [ "$n" -gt 3 ] && subs="$subs +$((n - 3))" diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 913ebd6a..729db1ec 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -15,7 +15,7 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ ## 表示内容 ``` - [Opus5 61k │ 修正:PR8… 167k · 検証:#8… 42k] + [Opus5 61k │ 修正:PR 167k · 検証:#8 42k] ``` - コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる @@ -24,8 +24,8 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ - `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`/subagents/agent-.jsonl` の最後の `usage` から読む - 直近 2 分以内に記録が更新され、終わっていないものを実行中とみなす。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため - - ラベルは `agent-.meta.json` の `description` の先頭で、空白を除いて端末の 8 桁までに切る (全角は 2 桁)。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す - - Haiku で 150k を超えたものだけ黄色で表示する + - ラベルは `agent-.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す +- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。Haiku 4.5 (200K) のサブエージェントは 150k で赤にする - サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない ## 使用方法 @@ -82,3 +82,8 @@ statusline の変更は次回セッション開始時 (または statusline 再 既に statusline が設定されている場合はそちらが優先され、何も変更しない。 NDF 標準 statusline の利用中は、プラグイン更新時にスクリプト (`~/.claude/ndf-statusline.sh`) の内容が自動で追従する。 + +NDF 標準 statusline は `refreshInterval: 5` (秒) を持つ。メインセッションがバックグラウンドの +サブエージェントを待つ間は再描画のイベントが起きず、サブエージェントの使用量が止まって見える +ためである。既に NDF 標準を使っている設定に `refreshInterval` が無ければ、`ensure` と `set` が +足す。利用者が書いた値は変えない。 diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf/skills/statusline/tests/test_statusline_switch.py index c875a371..afcac8b9 100644 --- a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py +++ b/plugins/ndf/skills/statusline/tests/test_statusline_switch.py @@ -148,3 +148,58 @@ def test_unset_statusline_gets_ndf_default(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + + +def test_unset_statusline_gets_refresh_interval(tmp_path: Path) -> None: + """新規設定では refreshInterval も書く。待機中もサブエージェントの表示を更新するため。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text("{}") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 5 + + +def test_official_ndf_path_gets_missing_refresh_interval(tmp_path: Path) -> None: + """既に NDF 標準を使っていて refreshInterval が無ければ足す。""" + _claude(tmp_path) + _write_settings(tmp_path, NDF_COMMAND) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 5 + + +def test_existing_refresh_interval_is_kept(tmp_path: Path) -> None: + """利用者が決めた refreshInterval は上書きしない。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": NDF_COMMAND, + "refreshInterval": 30, + } + } + ) + ) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 30 + + +def test_user_custom_gets_no_refresh_interval(tmp_path: Path) -> None: + """利用者独自の statusline には refreshInterval を足さない。""" + claude = _claude(tmp_path) + (claude / "mybar.sh").write_text(CUSTOM) + _write_settings(tmp_path, "bash ~/.claude/mybar.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert "refreshInterval" not in _settings(tmp_path)["statusLine"] From f0965c9d9826f8f6f422c6326646f39c7b0a9b4e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:03:36 +0000 Subject: [PATCH 209/217] =?UTF-8?q?Docs:=20cross-review=20=E3=81=AE?= =?UTF-8?q?=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=E3=82=92=E9=80=B2=E8=A1=8C?= =?UTF-8?q?=E5=81=B4=E3=81=B8=E7=A7=BB=E3=81=97=E3=81=9F=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=EF=BC=88#730=20#583=20#678=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GitHub と git への書き込みをレビューを回す側だけが行う仕様を docs/specifications/cross-review-writes-to-conductor.md に置いた - テストの実行中に監視の環境変数を外す共通の前提の仕様を docs/specifications/test-monitor-env-isolation.md に置いた - 証拠ベースのレビューの仕様のうち、件数の突き合わせを書いていた段落を現在のコードに合わせた - マイルストーン 13 の束 G1〜G6 の作業文書 26 本を削除し、#662 の文書に残るリンクを確定仕様へ差し替えた Co-Authored-By: Claude Opus 5 (1M context) --- docs/specifications/README.md | 2 + .../cross-review-evidence-based.md | 9 +- .../cross-review-writes-to-conductor.md | 413 +++++++++++++ .../test-monitor-env-isolation.md | 72 +++ issues/issue-624-478-648-contracts.md | 203 ------ issues/issue-624-478-648-design.md | 420 ------------- issues/issue-624-478-648-requirements.md | 289 --------- issues/issue-647-592-553-design.md | 499 --------------- issues/issue-647-592-553-requirements.md | 264 -------- ...62-598-537-619-584-583-design-contracts.md | 4 +- ...ue-662-598-537-619-584-583-requirements.md | 4 +- ...issue-664-p7-refactor-participants-plan.md | 176 ------ issues/issue-678-requirements.md | 89 --- issues/issue-727-687-478-664-648-contracts.md | 307 ---------- issues/issue-727-687-478-664-648-design.md | 504 --------------- .../issue-727-687-478-664-648-requirements.md | 363 ----------- issues/issue-727-p6-participants-plan.md | 208 ------- issues/issue-728-647-592-553-design.md | 570 ----------------- issues/issue-728-647-592-553-plan.md | 205 ------- issues/issue-728-647-592-553-requirements.md | 295 --------- issues/issue-729-619-584-design.md | 579 ------------------ .../issue-729-619-584-implementation-plan.md | 173 ------ issues/issue-729-619-584-requirements.md | 261 -------- issues/issue-730-583-design.md | 446 -------------- issues/issue-730-583-plan.md | 185 ------ issues/issue-730-583-requirements.md | 246 -------- issues/issue-732-624-706-design.md | 373 ----------- issues/issue-732-624-706-plan.md | 162 ----- issues/issue-732-624-706-requirements.md | 265 -------- issues/refactoring-plan-rf790.md | 286 --------- issues/refactoring-plan-rf791.md | 370 ----------- issues/refactoring-plan-rf793.md | 399 ------------ 32 files changed, 496 insertions(+), 8145 deletions(-) create mode 100644 docs/specifications/cross-review-writes-to-conductor.md create mode 100644 docs/specifications/test-monitor-env-isolation.md delete mode 100644 issues/issue-624-478-648-contracts.md delete mode 100644 issues/issue-624-478-648-design.md delete mode 100644 issues/issue-624-478-648-requirements.md delete mode 100644 issues/issue-647-592-553-design.md delete mode 100644 issues/issue-647-592-553-requirements.md delete mode 100644 issues/issue-664-p7-refactor-participants-plan.md delete mode 100644 issues/issue-678-requirements.md delete mode 100644 issues/issue-727-687-478-664-648-contracts.md delete mode 100644 issues/issue-727-687-478-664-648-design.md delete mode 100644 issues/issue-727-687-478-664-648-requirements.md delete mode 100644 issues/issue-727-p6-participants-plan.md delete mode 100644 issues/issue-728-647-592-553-design.md delete mode 100644 issues/issue-728-647-592-553-plan.md delete mode 100644 issues/issue-728-647-592-553-requirements.md delete mode 100644 issues/issue-729-619-584-design.md delete mode 100644 issues/issue-729-619-584-implementation-plan.md delete mode 100644 issues/issue-729-619-584-requirements.md delete mode 100644 issues/issue-730-583-design.md delete mode 100644 issues/issue-730-583-plan.md delete mode 100644 issues/issue-730-583-requirements.md delete mode 100644 issues/issue-732-624-706-design.md delete mode 100644 issues/issue-732-624-706-plan.md delete mode 100644 issues/issue-732-624-706-requirements.md delete mode 100644 issues/refactoring-plan-rf790.md delete mode 100644 issues/refactoring-plan-rf791.md delete mode 100644 issues/refactoring-plan-rf793.md diff --git a/docs/specifications/README.md b/docs/specifications/README.md index cfceb419..6844855a 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -18,6 +18,7 @@ | [cross-review-evidence-based.md](cross-review-evidence-based.md) | 証拠ベースのレビューと効果の測定。状態ファイルの契約と決定の理由。手順は `cross-review` の `SKILL.md` が正 | | [cross-review-launch-outcome.md](cross-review-launch-outcome.md) | 起動 1 回の結末の語彙(理由 9 語)と起動し直しの可否、利用上限と CLI の上限の検知、プロセスグループでの起動と停止。手順は `cross-review` の `SKILL.md` と `docs/` が正 | | [cross-review-participants-and-seats.md](cross-review-participants-and-seats.md) | 使える者だけで収束ループを始める共通層(認証の確認を止めない形・参加の母集合と足す者/外す者・毎ラウンド 2 席の埋め方・席の名前)と、再開で渡した引数の反映。手順は `cross-review` の `SKILL.md` と `docs/` が正 | +| [cross-review-writes-to-conductor.md](cross-review-writes-to-conductor.md) | GitHub と git への書き込み(レビューの投稿・返信・決着・修正のまとめ・修正の送信)をレビューを回す側だけが行うこと、担当が書く 2 つのファイルと改名の順序、二度書かない照合、差分の外を指す指摘の退避、途中で止まったときの立て直し、起動し直しを初回と同じ経路へ通すこと。手順は `cross-review` の `SKILL.md` と `docs/` が正 | | [cross-refactoring-apply-intake.md](cross-refactoring-apply-intake.md) | 担当が結果を残さない起動を 3 つの取り込みが同じ手順で受けること(範囲の確定・未検証のコミットの取り消し・結末の記録)、適用ラウンドの開き直しの判定と試行の上限 2 回、項目の無い適用ラウンドを作らないこと、帰属の段落の後ろから必須の記名を読むこと。手順は `cross-refactoring` の `SKILL.md` と `docs/` が正 | | [cross-refactoring-participants.md](cross-refactoring-participants.md) | cross-refactoring の参加者(codex / kiro とホストを既定に足す者/外す者で変える・確認を通らない者を外して続ける)、提案と適用を同じ参加者で回す輪番、再開で渡した引数の反映、呼び手の無くなった共通層の旧関数の削除。手順は `cross-refactoring` の `SKILL.md` と `docs/` が正 | | [ndf-cleanup-and-bundle-closing.md](ndf-cleanup-and-bundle-closing.md) | 後片付けが止まる条件(git の拒否だけ)、実行前確認の要否を決める 3 つの問い、まとまりの課題を終わりの工程で閉じる条件と結果の 4 値、配布の記録の形と読み方。手順は `merged` / `progress-tracking` / `release` の SKILL.md が正 | @@ -25,5 +26,6 @@ | [ndf-context-window-metrics.md](ndf-context-window-metrics.md) | 会話の記録から context window を 3 層で測る部品(`transcript_agents.py`)の値の取り方と、`skill-stats --agents` の 4 つの表と印。値の取り方はこの文書が正 | | [ndf-execution-plan-and-parallel-capacity.md](ndf-execution-plan-and-parallel-capacity.md) | 並列の実行計画(依存を工程の対で書く・重なりの 3 区分・開いている間はコミットしない)、マイルストーンの組、メモリで見る本数(`parallel-measure.py`)。手順は `issue-plan-strategy` と `development-workflow` の `references/` が正 | | [ndf-instruction-files-check.md](ndf-instruction-files-check.md) | エージェント向け指示書の検査(`instructions-check.py`)。宣言 `.ndf/instructions.json` で決まる判定の強さ、即時読み込みと出た版の段落の判定、扱いの印、観点の調べ直し。呼び方と宣言の書き方は `release` の `references/instruction-files.md` が正 | +| [test-monitor-env-isolation.md](test-monitor-env-isolation.md) | テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)をリポジトリの根の共通の前提で外すこと、外す時点と戻す時点、根の設定ファイルで基準のディレクトリを固定すること | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/cross-review-evidence-based.md b/docs/specifications/cross-review-evidence-based.md index 80adeb34..51c0c360 100644 --- a/docs/specifications/cross-review-evidence-based.md +++ b/docs/specifications/cross-review-evidence-based.md @@ -131,11 +131,12 @@ Pull Request では、検証手順を実行できなかった 3 件が立証不 ### 指摘の構造化と独立発見 担当が書き出す `payload.json` の `comments[]` は、**その担当が出した指摘の全件**である。 -投稿したインラインの写しではない。総評だけへ書いた指摘も載り、`path` と `line` はそのときも -埋める。投稿先は `posted_to`(`inline` / `body`)が持つ。 +投稿したインラインの写しではない。位置を持たない指摘も載る。送れた先(`posted_to` の +`inline` / `body`)は、投稿する側が控えへ書き戻す。 -**インラインの件数(`result.json` の `comments_count`)は変えない。** 進行側が GitHub 側の -実数と突き合わせる値であり、指摘の全件を入れると照合が常に食い違う。 +**記録のインラインの件数(`comments`)は、指摘の件数と一致しない。** 送れたインラインの数で +あり、総評へ入った指摘はそこに現れない。投稿の担い手と件数の取り方は +[書き込みを回す側へ集める仕様](cross-review-writes-to-conductor.md)にある。 **発見を終えるまで、参照してよい既存コメントは起動時のスナップショットに限る。** 同じ ラウンドの他の担当の投稿・結果ファイル・進捗ログは参照しない。スナップショットは前の diff --git a/docs/specifications/cross-review-writes-to-conductor.md b/docs/specifications/cross-review-writes-to-conductor.md new file mode 100644 index 00000000..5a0a89ad --- /dev/null +++ b/docs/specifications/cross-review-writes-to-conductor.md @@ -0,0 +1,413 @@ +# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのはレビューを回す側だけになり、途中で止まっても同じものを二度書かない + +## 目的 + +**GitHub と git へ書くのは、レビューを回す側だけである。** レビューの担当は指摘の控えと +結果ファイルを書いて終わる。修正の担当はコミットまでで止まる。投稿・返信・スレッドの決着・ +修正のまとめ・修正の送信は、結果ファイルを取り込む側が行う。 + +**書き込みと記録が同じ手順の中で続けて起きる。** 担当が途中で止まっても、PR には指摘が +出ているのに記録には無い状態ができない。取り込みが途中で止まっても、やり直せば同じものを +二度書かない。 + +**修正を送ったという記録は、送り先のブランチの実物と一致する。** + +**投稿の本文は、取り込みのプロセスの中だけを通る。** 収束ループを駆動している側の応答に +載るのは、件数・参照・状態だけである。 + +**手順と表は +[`cross-review` の SKILL.md](../../plugins/ndf/skills/cross-review/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)が正である。** +投稿の種別ごとの照合の鍵は +[状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)が持つ。 +この文書が扱うのは、そこに書かない決定の理由と、共通層の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 +担当・席・結末の語は +[起動 1 回の結末](cross-review-launch-outcome.md#用語)と +[使える者だけで始める収束ループ](cross-review-participants-and-seats.md#用語)と同じ意味で使う。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| レビューを回す側 | `state.py` と、それを呼ぶ `SKILL.md` の骨組み | 担当を起動し、結果を取り込み、判定する側 | +| 担当 | `launch-reviewer.sh` が起動する CLI | 1 ラウンドで 1 席ぶんのレビューを行う | +| 修正の担当 | `/ndf:fix` を実行するサブエージェント | 指摘を直してコミットする | +| 結果ファイル | `<席>-review-pr<番号>-result.json` | 担当の判定の要約 | +| 指摘の控え | `<席>-review-pr<番号>-round-payload.json` | 担当が出した指摘の全件と総評 | +| 修正の結果ファイル | `fix-pr<番号>-result.json` / `sweep-pr<番号>-result.json` | 修正の担当が書く戻り値 | +| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py` | 送る前に積み、上限で送れなければ残す仕組み | +| 投稿を組み立てる層 | `plugins/ndf/scripts/lib/result_posts.py` | 結果ファイルから投稿を組み立て、送る共通層 | +| 指摘の取り込み | `state.py read-result` | 控えを読んでレビューを投稿し、記録する | +| 修正の取り込み | `state.py merge-fix` | 修正を送り、返信・決着・まとめを投稿し、記録する | +| 単独の修正の口 | `result_posts.py fix` | 修正を単独で行うときと最終スイープで呼ぶ部分命令 | +| 先客 | — | 送ろうとした投稿と同じものとして、すでに PR にある投稿 | +| 総評 | レビューの本文(`body`) | インラインではなく、レビュー本体に書く文章 | + +## 対象範囲 + +| 扱う | 扱わない | +| --- | --- | +| レビューの投稿(総評とインライン) | PR の巻き直しの締め・作り直し・再開(もとから回す側が行い、待ち行列に積まない) | +| 指摘への返信・スレッドの決着・修正のまとめ | 反証(もとから投稿せず、ファイルへ書くだけ) | +| 修正の送信と、送り先に載ったことの確認 | 収束の判定・指摘の区分と数え方([証拠ベースのレビュー](cross-review-evidence-based.md)) | +| 起動し直した担当を初回と同じ経路へ通すこと | 結末の語彙と起動し直しの可否([起動 1 回の結末](cross-review-launch-outcome.md)) | +| 修正を単独で行うときの書き込み | 席の決め方([使える者だけで始める収束ループ](cross-review-participants-and-seats.md)) | +| — | 単発の `/ndf:pr-review` が担当に直接投稿させる流れ | + +## 背景 + +**書き込みと記録を別の相手が行っていた。** 担当は GitHub へレビューを投稿し、その後に結果 +ファイルを書いていた。この 2 つの間で担当が止まると、投稿は残り、記録は残らない。回す側は +記録しか読まないため「結果なし」と判定し、同じ担当を同じラウンドで起動し直した。起動し +直した担当は同じ論点をもう一度投稿した(#583)。 + +**実例は PR #578 の round 1 である。** 1 回目の起動がインライン 4 件を投稿し、起動し直した +2 回目が 1 件を投稿した。記録に入ったのは 2 回目の 1 件だけだった。利用者は 1 つの論点に +2 つのスレッドを見て、修正の担当は両方へ返信した。 + +**待ち行列は担当の投稿を通していなかった。** 上限で拒まれた投稿を残して後から流す仕組みは +回す側にあった。担当が自分で送るため、上限に当たった投稿はその場で失われた。 + +**送信の報告を確かめる手段が無かった。** 修正の担当はブランチ名だけを指定して送信していた。 +作業ツリーが切り離された頭で作られていると、この指定は現在の頭を送らないまま終了コード 0 で +終わる。取り込む側はその報告をそのまま記録した。 + +**根本原因は、書き込みの担い手が記録の担い手と別であることにある(#730)。** +書き込みを回す側へ集めると、この 3 つが同じ場所で解ける。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| GitHub と git への書き込みを回す側だけが行う | 書き込みと記録が同じ手順で続けば、片方だけが残る状態を作れない | +| 担当の投稿の後に回す側が実物を照合して記録を補う形を採らない | 照合は問い合わせを増やす。問い合わせが上限で失敗すると同じ食い違いが残る | +| 担当に待ち行列へ積ませる形を採らない | 待ち行列は回す側の作業ツリーにある。置き場所を担当へ開くと、巻き直しで捨てる範囲が変わる | +| 投稿は結果ファイルを読んだプロセスの中で組み立てる | 本文を引数や標準入力で渡すと、収束ループを駆動する側の応答に本文が載る | +| 「読んで記録する」と「投稿する」を 1 つの取り込みに閉じる | 分けると「記録したが投稿していない」状態がもう 1 つ増える | +| 送信に成功した項目は記録より先に待ち行列から消える形を変えない | 取り込みをやり直せば同じ状態になる。流し方を変えると、巻き直しのコメントの契約まで変わる | +| 二度書かない照合を投稿の 4 種別すべてに掛ける | 途中で止まった取り込みをやり直しても、どの種別も増えない | +| レビューの照合の鍵に判定の語を含めない | 起動し直して判定が変わると、判定の語を含む鍵は別の投稿と読んで二重に送る | +| レビューの照合をラウンドの開始時刻より後のレビューに限る | ラウンドの番号は実行ごとに 1 から数え直す。回し直した PR では前の実行のレビューに一致する | +| 開始時刻かレビューの時刻を読めないときは、番号と席だけで照合する | 二重に送る側へ倒さない | +| 担当の申告件数と GitHub の実数の突き合わせをやめる | 投稿する側と記録する側が同じになり、確かめる対象が無い | +| 差分の外を指す指摘は、拒まれてから総評へ移す | 投稿の前に判定すると差分の範囲を別に取り寄せる必要があり、失敗の分岐が増える | +| 拒まれた要求のインラインは、1 件ずつでなくすべて総評へ移す | 応答はどのインラインが原因かを指さない(実測) | +| 位置を解決できない拒まれ方だけを退避の契機にする | 判定の値や基準のコミットの誤りまで退避すると、別の不具合が飲み込まれる | +| 修正は現在の頭を指定して送り、送った後に照合する | ブランチ名だけの指定は、切り離された頭で何も送らずに成功する | +| 修正の書き込みを共通層の 1 か所にまとめる | cross-review から呼ぶ経路と単独で使う経路で、担い手と実装が分かれない | +| 単独の口は新しい入口のスクリプトにせず、共通層の部分命令にする | 手順に書く行が 1 本で済み、実装が 1 つになる | +| 担当は一時の名前で書いてから改名する | 読めた状態が書き終えた状態になり、取り込みが書きかけを読まない | +| 自分の PR での判定の格下げは、投稿する側が送信の時点で行う | 格下げるのは送る形だけで、収束の判定が読む本来の判定は落とさない | +| 起動し直しを繰り返しの先頭へ戻す形にする | 経路が 1 本なら、根拠の検証と反証を飛ばす枝が構造として無くなる | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 担当へ渡すプロンプトは、投稿の手順(投稿の呼び出し・判定の格下げ・インラインの組み立て)を持たない | プロンプトの検査が落ちる | +| 担当が控えを書かずに止まった実行では、PR のレビューは増えない | — | +| 同じラウンド・同じ席のレビューは、この実行の中で 1 件しか PR に出ない | 先客があれば送らず、先客の参照を記録する | +| 取り込みの標準出力に、総評とインラインの本文は出ない | — | +| 記録に入るレビューの参照は、送信の応答か先客から取る | — | +| 修正の記録は、報告されたコミットが送り先のブランチに載ってから書く | 載っていなければ記録も投稿もせずに止まる | + +### 構成要素と責務 + +| 構成要素 | 責務 | +| --- | --- | +| 担当の起動(`launch-reviewer.sh`) | 投稿の手順を持たないプロンプトを作る。前の起動の一時の名前のファイルも消す | +| 指摘の取り込み(`read-result`) | 残りを流し、この担当のレビューを組み立てて送り、応答を記録し、指摘を取り込む | +| 修正の取り込み(`merge-fix`) | 修正を送って照合し、記録し、返信・決着・まとめを送る | +| 投稿を組み立てる層(`result_posts.py`) | 控えと結果ファイルから投稿を組み立てる。退避・格下げ・送信・照合を行う | +| 投稿の待ち行列(`post_queue.py`) | 送る前に先客を照合する。拒まれ方が位置によるものかを見分けて返す | +| 骨組み(`SKILL.md`) | 起動し直しを繰り返しの先頭へ戻す。最終スイープの書き込みを単独の修正の口で行う | + +投稿を組み立てる層が外へ見せる関数は次の 5 つである。**どれも本文を引数に取らず、ファイルの +パスを受け取る。** + +| 関数 | 入力 | 出力 | +| --- | --- | --- | +| `review_posts` | 控えと結果ファイルのパス、リポジトリ、PR、ラウンド、席、頭のコミット、自分の PR か、開始時刻 | 待ち行列へ積む項目の列 | +| `post_review` | 待ち行列と `review_posts` の入力、投稿者 | 送った結果(参照・インライン数・総評へ移した数・残した数・指摘数・失敗・送った形・本来の判定) | +| `fix_posts` | 修正の結果ファイルのパス、リポジトリ、PR、ラウンド | 返信 → 決着 → まとめの順の項目の列 | +| `post_fix` | 待ち行列と `fix_posts` の入力、投稿者 | まとめの参照・返信数・決着数・残した数・失敗 | +| `push_fix` | 作業ツリー、送り先のブランチ、報告されたコミット | 成否・送ったか・載っているか・説明 | + +### 担当が書くもの + +担当は 2 つのファイルを書く。**どちらも一時の名前(末尾 `.tmp`)で書き終えてから、控え → +結果ファイルの順に正式の名前へ改名する。** 結果ファイルが正式の名前で現れたことが、2 つとも +書き終えた印になる。 + +| 状態 | 取り込みの扱い | +| --- | --- | +| 結果ファイルが正式の名前である | 控えを読んで投稿する | +| 控えだけが正式の名前で、結果ファイルが無い | 結果なし。投稿は 0 件 | +| どちらも正式の名前に無い | 結果なし。投稿は 0 件 | + +担当が書く項目と、投稿する側が埋める項目は次のとおりである。 + +| ファイル | 担当が書く | 投稿する側が埋める | +| --- | --- | --- | +| 結果ファイル | 本来の判定(`event`)、重要度ごとの件数(`by_severity`) | —(記録の側に書く) | +| 指摘の控え | 総評(`summary`)、指摘の全件(`comments[]`) | 各指摘の送れた先(`posted_to` の `inline` / `body`) | + +指摘の各項目が持つ値は +[状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)にある。 + +### 指摘の取り込み + +取り込み 1 回の中を、次の順に進める。 + +1. 待ち行列の残りを流す。残りは前の取り込みが上限で送れなかった投稿で、その担当の記録は + 積んだ時点で書いてある +2. 控えと結果ファイルから、この担当のレビューを 1 件組み立てて積む +3. 流す +4. 位置を解決できずに拒まれたら、インラインをすべて総評へ移して積み直し、もう 1 度流す +5. 送れたら、控えへ送れた先を書き戻す +6. 送信の応答を記録へ書き、指摘を取り込む + +```mermaid +sequenceDiagram + participant M as レビューを回す側 + participant A as 担当 + participant G as GitHub + M->>A: 起動(投稿の手順を持たないプロンプト) + A->>A: 控えと結果ファイルを書いて改名する + A-->>M: 終了 + M->>M: 控えを読み、レビューを待ち行列へ積む + M->>G: 先客を照合し、いなければ送る + G-->>M: レビューの参照 + M->>M: 参照と件数を記録し、指摘を取り込む +``` + +**組み立てるレビューの本文の先頭行は、ラウンド・席・本来の判定を持つ。** + +```text +## 🤖 cross-review | round | <席> | <本来の判定> +``` + +**位置を持つ指摘はインラインに、持たない指摘は総評に入る。** 位置を持つとは、ファイルが +あり、行が正の整数として読めることである。行が `"L42"` や `"40-45"` の形なら総評へ回す。 + +### 差分の外を指す指摘の退避 + +GitHub はレビューの作成を**要求ごとに全件拒む**。差分の外を指すインラインが 1 件あると、 +正しいインラインも総評も作られない。応答の誤りは語をつないだ 1 つの文字列で、どの +インラインが原因かを指さない。 + +| 送ったもの | 応答の誤り | 扱い | +| --- | --- | --- | +| 差分に無いファイルのインライン | `Path could not be resolved` | 総評へ移して送り直す | +| 差分にあるファイルの、塊の外の行のインライン | `Line could not be resolved` | 同上 | +| 判定の値に知らない語 | `Variable $event ... was provided invalid value` | 失敗として止まる | +| 基準のコミットに存在しない値 | `The commitOID is not part of the pull request` | 同上 | + +**退避の契機は、HTTP 422 で応答が `could not be resolved` を含むときだけである。** +**退避するのは、この取り込みが積んだ項目が拒まれたときだけである。** 先に積まれていた項目の +拒まれ方を取り違えると、先の項目を消して同じレビューを二重に積む。 + +総評へ移した指摘は、総評の末尾の見出し「差分の外を指す指摘」の下に、位置と重要度を添えて +並ぶ。1 ラウンドの指摘は多くて 10 件前後で、すべて移しても総評の上限(65536 文字)に収まる。 + +### 自分の PR での判定の格下げ + +GitHub は自分の PR への変更を求めるレビューを拒む。**投稿する側は、自分の PR のとき送る形 +だけを `COMMENT` へ落とす。** 本来の判定は本文の先頭行と記録の `intent` に残る。収束の判定は +`intent` を読むため、格下げがループの続き方を変えない。 + +### 二度書かない照合 + +待ち行列は送る前に、同じものが PR に先にあるかを照合する。**先客がいれば送らず、先客を +送信の応答の代わりに返す。** 呼び出し側は、送った場合と同じ経路で参照を記録する。 + +| 種別 | 照合の要点 | +| --- | --- | +| レビュー | 投稿者と、先頭行の席までの前方一致。判定の語を含めない。ラウンドの開始時刻より後のレビューに限る | +| 返信 | 返信先の指摘と、本文の先頭 80 文字 | +| スレッドの決着 | スレッドがすでに決着しているか | +| PR へのコメント(修正のまとめ) | 投稿者と、本文の先頭 80 文字。先頭行がラウンドとコミットを持つ | + +**照合を確かめられないときは送る側へ倒す。** 確かめられないのは GitHub へ届いていないとき +であり、送っても同じ失敗で積まれ直す。 + +**投稿者は、どの席でも同じ 1 つのアカウントである。** 作業環境の `gh` は 1 アカウントを +持ち、席ごとに認証を切り替える口は配布物に無い。そのため席の区別は先頭行が担う。 + +**すでに決着したスレッドをもう一度決着させても失敗しない(実測)。** 照合が送信を止め +損ねても、二重の決着は誤りにならない。 + +### 修正の取り込みと送信 + +修正の担当はコミットまでを行い、修正の結果ファイルを書く。取り込みは次の順に進む。 + +1. 現在の頭を送り先のブランチへ送る(`git push origin HEAD:<ブランチ名>`)。認証で落ちた + ときは、共通層の退避の値で 1 度だけやり直す +2. 送り先を取り寄せ、報告されたコミットがその履歴に含まれることを確かめる +3. 修正を記録する +4. 返信 → 決着 → まとめの順に積んで流す +5. まとめの参照を記録へ書き戻す + +**1 か 2 で失敗したら、記録も投稿もせずに止まる。** 同じ取り込みをやり直せば、同じ手順を +最初から通る。報告されたコミットが無いときは送らずに進む。 + +| 修正の結果ファイルの配列 | 送られるもの | +| --- | --- | +| 対応した指摘(`resolved_threads`) | 「対応しました(<コミット>)」の返信と、スレッドの決着 | +| 見送った指摘(`deferred`) | 見送りの理由の返信。項目が決着を求めるときだけ決着する | +| 採らない指摘(`rejected`) | 採らない理由の返信。同上 | +| — | 件数・決着数・CI の状態を載せた修正のまとめ | + +**返信を決着より先に送る。** 決着したスレッドは畳まれ、後から届いた返信を読み手が開かない。 + +### 単独の修正と最終スイープ + +修正を単独で行うときと、ループを抜けた後の最終スイープでは、単独の修正の口を 1 行で呼ぶ。 +**修正の取り込みと同じ関数を通る。** + +```bash +python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <修正の結果ファイル> \ + [--repo <所有者>/<リポジトリ>] [--head <ブランチ名>] [--worktree <作業ツリー>] [--round ] +``` + +| 引数を省いたとき | 決め方 | +| --- | --- | +| リポジトリと送り先のブランチ | 作業ツリーの中で `gh` に問い合わせる | +| 作業ツリー | いまいるディレクトリ | +| 修正の結果ファイル | 一時ディレクトリ(「状態ファイルの鍵」の末尾)の `fix-pr<番号>-result.json` | + +**送り先のブランチを決められないときは、返信へ進まず終了コード 1 で止まる。** 送っていない +修正へ「対応しました」と返信しないためである。出力は次の形で、本文を含まない。 + +```text +PUSHED=1 COMMIT_ON_HEAD=1 +POSTED summary_url=https://github.com/.../pull/<番号>#issuecomment-... +REPLIED=3 RESOLVED=2 QUEUED=0 +``` + +### 途中で止まったときの立て直し + +送信に成功した項目は、記録より先に待ち行列から消える。**立て直しは、待ち行列に項目が残る +ことに頼らない。** + +| 止まった場所 | 待ち行列の項目 | 立て直し | +| --- | --- | --- | +| 担当が控えか結果ファイルを書く前 | 無い | 結果なし。起動し直しても投稿は重ならない | +| 取り込みが送る前(上限で送れない) | 残る | 判定の終了コード 8 の枝が流し直し、参照を記録へ書き戻す | +| 取り込みが送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。照合が先客を見つけ、先客の参照を記録する | + +```mermaid +graph TD + A[取り込みが投稿を送る] --> B{送れたか} + B -->|送れていない| D[待ち行列に残る] + D --> E[判定の 8 の枝が流し直す] + B -->|送れた| C{記録まで済んだか} + C -->|済んだ| J[次の段へ進む] + C -->|止まった| K[取り込みをもう一度呼ぶ] + E --> F{先客がいるか} + K --> F + F -->|いる| G[送らずに
先客の参照を記録する] + F -->|いない| H[送って記録する] +``` + +**送れた後に控えへ送れた先を書けなければ、取り込みは止まる。** 控えは原子的に書くため、 +書きかけは残らない。やり直すと、照合が先客を見つけて同じ参照を記録する。 + +### 起動し直しの経路 + +骨組みのレビューの段は「起動 → 監視 → 取り込み → 根拠の検証 → 反証 → 判定」の繰り返しで +ある。**判定が結果なし(終了コード 7)を返したとき、名前の出た担当だけを入れて繰り返しの +先頭へ戻る。** 起動し直しは同じラウンドで 1 度だけである。 + +| 判定の終了コード | 扱い | +| --- | --- | +| 8(待ち行列に残りがある) | 流してから判定し直す。**7 より先に見る** | +| 7(結果なし)で、まだ起動し直していない | 名前の出た担当で繰り返しの先頭へ戻る | +| それ以外 | 繰り返しを抜ける | + +**経路が 1 本であるため、起動し直した担当の指摘も根拠の検証と反証を通る。** +起動し直せない結末(利用上限)の扱いは[起動 1 回の結末](cross-review-launch-outcome.md)が持つ。 + +## データ・設定 + +### 状態ファイルの鍵 + +ラウンドごとの担当の欄のうち、この仕様が決める鍵は次のとおりである。収束の判定が読む鍵 +(本来の判定・送った形・重要度ごとの件数)は形を変えない。 + +| 鍵 | 何が入るか | +| --- | --- | +| `review_url` | 送信の応答が返した参照。先客が見つかったときは先客の参照 | +| `queued` | 上限で送れず待ち行列に残っているとき真。流した直後に参照を書き戻して偽にする | +| `posted_inline` | インラインとして送れた件数 | +| `posted_body` | 総評へ入れた件数(位置を持たない指摘と、退避した指摘) | +| `comments` | `posted_inline` と同じ値 | +| `posted_as` | 送った形。自分の PR では `COMMENT` | + +修正の記録は、まとめの参照(`summary_comment_url`)を持つ。**投稿済みのレビューを探す鍵は +作らない。** 担当が投稿しないため、探す対象が無い。 + +レビューの照合は、ラウンドの記録が持つ開始時刻(`started_at`)を読む。 + +**待ち行列の置き場所は変えない。** 状態ファイルと同じ一時ディレクトリの `pending/` にあり、 +巻き直しのときは作業ツリーごと捨てられる。単独の修正の口は、一時ディレクトリを環境変数 +`CROSS_REVIEW_TMP_DIR` から、無ければ作業ツリーの `.cross_review/` から決める。 + +### 取り込みの出力と終了コード + +指摘の取り込みは、標準出力へ次の 3 行を出す。本文は出さない。 + +```text +POSTED review_url=https://github.com/.../pull/<番号>#pullrequestreview-... +INLINE=7 BODY=1 QUEUED=0 +FINDINGS=8 +``` + +| 行 | 意味 | +| --- | --- | +| `POSTED` | 送れたときだけ出る。記録した参照 | +| `INLINE` / `BODY` / `QUEUED` | 送れたインライン数・総評へ入れた数・待ち行列に残した数 | +| `FINDINGS` | 取り込んだ指摘の件数。判定が出す新しい指摘の数とは別の値 | + +修正の取り込みは、単独の修正の口と同じ形の行(送信・照合・まとめの参照・返信数・決着数・ +残した数)を出す。 + +**取り込み・判定・報告の終了コードは変えない。** 収束の判定と報告が読む変数 +(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS`)も変えない。 +送れない・控えを書けない・修正が送り先に載らないときは、終了コード 1 で止まる。上限で送れなかったときは +止まらず、待ち行列に残して判定の 8 の枝へ渡す。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 担当のプロンプトに投稿の手順が無く、2 つのファイルを一時の名前で書いて控え → 結果ファイルの順に改名させること | `plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py` | +| 取り込みがレビューを送って応答を記録し、標準出力に本文を出さないこと | 同 `tests/test_read_result_posts.py` | +| 控えだけの状態と、何も書かなかった担当で、投稿が 0 件であること | 同上 | +| 位置を持つ指摘が 0 件でも、指摘があれば結果なしにならないこと | 同上 | +| 取り込みを 2 度呼んでもレビューが増えないこと | 同上 | +| 自分の PR で、送る形だけが `COMMENT` へ落ち、本来の判定が残ること | 同上と `plugins/ndf/scripts/tests/test_result_posts.py` | +| 位置を解決できない拒まれ方だけで総評へ移し、他の 422 と先の項目の拒まれ方では移さないこと | `plugins/ndf/scripts/tests/test_result_posts.py` / `test_post_queue.py` | +| 回し直した実行で、前の実行の同じラウンド・同じ席のレビューを先客と取り違えないこと | `plugins/ndf/scripts/tests/test_result_posts.py` | +| 控えへ送れた先を書けないとき、控えを壊さず取り込みを止めること | 同上と `plugins/ndf/scripts/tests/test_statefile_emit.py` | +| 返信・決着・まとめが 4 種別の照合を通り、2 度積んでも増えないこと | `plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py` / `plugins/ndf/scripts/tests/test_result_posts.py` | +| 修正を現在の頭で送り、報告されたコミットが送り先に無ければ止まること | 同 `test_result_posts.py` と `plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py` | +| 単独の修正の口が同じ層を使い、送り先のブランチを決められないとき返信へ進まないこと | `plugins/ndf/scripts/tests/test_result_posts.py` | +| 骨組みの起動し直しが繰り返しの先頭へ戻り、8 の枝を 7 より先に見ること | `plugins/ndf/skills/cross-review/tests/test_skill_layout.py` | +| 文書が投稿の担い手と種別ごとの契約を書いていること | `plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py` | + +## 関連リンク + +- [issue #730](https://github.com/devbasex/ai-plugins/issues/730) — GitHub と git への書き込みを回す側へ移す +- [issue #583](https://github.com/devbasex/ai-plugins/issues/583) — 投稿済みで記録なしの状態と、同じ論点の重なり +- [PR #801](https://github.com/devbasex/ai-plugins/pull/801) — 実装 +- [起動 1 回の結末](cross-review-launch-outcome.md) — 結果なしの理由と起動し直しの可否 +- [証拠ベースのレビューと効果の測定](cross-review-evidence-based.md) — 指摘の構造化・区分・収束の判定 +- [使える者だけで始める収束ループ](cross-review-participants-and-seats.md) — 席の名前と決め方 +- [`cross-review` の状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) +- [`cross-review` の修正と巻き直し](../../plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md) +- [`fix` の手順](../../plugins/ndf/skills/fix/SKILL.md) diff --git a/docs/specifications/test-monitor-env-isolation.md b/docs/specifications/test-monitor-env-isolation.md new file mode 100644 index 00000000..25dce7fd --- /dev/null +++ b/docs/specifications/test-monitor-env-isolation.md @@ -0,0 +1,72 @@ +# テストの前提: 監視の上限を環境変数で延ばしたシェルでは既定値を前提にするテストが落ち、収束ループの初期化が止まる → テストの実行中は監視の環境変数が共通の前提で外れ、どのシェルから起動しても同じ結果になる + +## 目的 + +**テストの実行中だけ、監視の上限を指す環境変数を利用者の環境から切り離す。** 上限を延ばした +シェルから全体のテストを起動しても、延ばしていないシェルと同じ件数が通る。 + +**切り離しはリポジトリの根の共通の前提(`conftest.py`)の 1 か所が持つ。** テストごとに +同じ除去を書かない。 + +## 用語 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 監視の環境変数 | 接頭辞 `MONITOR_` を持つ環境変数(`MONITOR_STALL_AGY` / `MONITOR_TIMEOUT` など) | 無進捗の許容と打ち切りの上限を、担当ごと・共通に延ばす指定 | +| 共通の前提 | リポジトリの根の `conftest.py` | 全体のテストに共通する前提を 1 か所で用意するファイル | +| 根の設定ファイル | リポジトリの根の `pytest.ini` | テストの基準のディレクトリ(rootdir)を根へ固定するための空の設定 | + +## 背景 + +**上限は環境変数で延ばせる。** 運用で延ばしたシェルから全体のテストを起動すると、表の既定値を +前提にするテストが延ばした値を読んで落ちた。原因はテストの実行環境にあり、変更の中身には無い。 + +2026-09-21 の実測では、監視の環境変数を 2 つ設定したシェルで 3 件が落ち、設定していない +シェルでは 0 件だった(収集は 4603 件で同じ)。 + +**収束ループの初期化は、着手前のテストが通ることを条件にする。** そのため上限を延ばした +シェルでは、cross-refactoring の初期化がそこで止まった。 + +## 仕様 + +| 項目 | 振る舞い | +| --- | --- | +| 外す対象 | 名前が接頭辞 `MONITOR_` で始まる環境変数すべて | +| 外す時点 | テストの収集より前(`pytest_configure`) | +| 戻す時点 | テストの実行が終わったとき(`pytest_unconfigure`)。外した値をそのまま戻す | +| 子プロセス | 環境変数を受け継ぐため、テストが起動する別プロセスにも切り離しが効く | +| テストが自分で設定する値 | 打ち消さない。`monkeypatch.setenv` も、別プロセスへ渡す上書きも、切り離しの後に効く | +| 読まれる範囲 | どの束のディレクトリを起点にしても読まれる。根の設定ファイルが基準のディレクトリを根へ固定する | + +**名前を並べず、接頭辞だけで一致させる。** 上限の種類が増えるたびに一覧へ足し忘れる形を +作らないためである。 + +**収集より前に外す。** テストの本体を読み込む時点で上限を決める実装があり、セッションの前提 +(fixture)では間に合わない。 + +**根の設定ファイルは設定値を持たない。** pytest は起点から上へ設定ファイルを探し、見つかった +ところを基準にする。根に 1 つも無いと、束のディレクトリを起点にした実行では共通の前提が +読まれない。`testpaths` などを書くと、既存の実行が対象にする範囲が変わる。 + +**変えないもの。** 上限の解決順(担当ごとの指定 → 共通の指定 → 表の既定)、上限の表の値、 +監視の本体と起動スクリプトの振る舞いは変えない。変わるのはテストの前提だけである。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 監視の環境変数を設定したシェルでも、全体のテストの失敗が 0 件で、設定しないシェルと件数が一致すること | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q` と、設定しない同じコマンドを比べる | +| 実行中のテストに接頭辞 `MONITOR_` の環境変数が 1 つも残らないこと | `scripts/tests/test_root_conftest.py` | +| テストが自分で設定した値は観測できること | 同上 | +| 別プロセスへ受け継がれないこと | 同上 | +| 実行が終わった後に元の値が戻ること | 同上 | +| 束のディレクトリを起点にした実行でも切り離しが効くこと | 同上 | +| テストごとの同じ除去が残っていないこと | `grep -rn "MONITOR_" --include="*.py"` で、接頭辞での除去と 1 変数ずつの除去がテストに無いことを見る | + +## 関連リンク + +- [issue #678](https://github.com/devbasex/ai-plugins/issues/678) — 上限を延ばしたシェルでテストが落ちる +- [PR #797](https://github.com/devbasex/ai-plugins/pull/797) — 実装 +- [起動 1 回の結末](cross-review-launch-outcome.md) — 監視の上限と結末の語彙 +- [共通の前提](../../conftest.py) +- [根の設定ファイル](../../pytest.ini) diff --git a/issues/issue-624-478-648-contracts.md b/issues/issue-624-478-648-contracts.md deleted file mode 100644 index 819cb216..00000000 --- a/issues/issue-624-478-648-contracts.md +++ /dev/null @@ -1,203 +0,0 @@ -# #624 / #478 / #648: 状態ファイル・引数・関数の契約 - -[issue-624-478-648-design.md](issue-624-478-648-design.md) の続きである。決定の理由は設計文書の「決定の記録」にあり、 -この文書は形だけを書く。**P4 は状態ファイルの形も引数も変えない。** P4 が変えるのは区分の条件(設計文書の決定 3)と -印の外し方(決定 5)で、「`state.py` の内部関数」の表の末尾 2 行と「変わらない項目の意味の変化」の先頭 2 行に -当たる。それ以外の契約は P5 のものである。 - -**誰にも誤りを示されていない `major` を数える側へ改めるため、P4 の契約(`_classify_finding` の順 4 の条件と、`classification` の値)は [issue-732-624-706-design.md](issue-732-624-706-design.md) の「データ構造」「入出力の契約」が持つ。** 新しい設計は区分 `unrefuted` と項目 `unrefuted_reason` を足し、順 4 から根拠の条件を外す。印を外す契約(決定 5)はそのまま引き継がれた。以下の P4 の行は 2026-09-15 時点の記録として残す。 - -**P5 の契約は [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) が置き換える**(親 #727 の設計、2026-09-19)。置き換え先は、使える者だけで収束ループを開始し、再開で渡した引数を反映するための状態ファイル・引数・関数の形である。6 項目は 1 つの `participants` に畳まれ、`review_assign` は `review_seats` に、`check_auth` は `probe_auth` に替わる。この文書の P5 の節は記録として残し、書き換えない。 - -## データ構造(状態ファイル) - -### 増える項目 - -状態ファイル `cross-review-pr<番号>-state.json` の最上位に 6 項目が増える。**`version` の類は持たないため上げない。** -項目が無い状態ファイルは、この変更の前に始めた実行として読む(下の「移行」)。 - -| 項目 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `available_reviewers` | 文字列の配列 | 許さない(項目が無いことは許す) | 使える者。母集合(`review_pool(host)`)の順に並ぶ。`only` があれば `[only]`。項目が無いのは「この変更の前に始めた実行」で、「使える者が 0 者」ではない(0 者の状態ファイルは作らない) | -| `excluded_reviewers` | 文字列の配列 | 許す(空の配列) | `--exclude` で外した者。母集合の順。空は「外していない」 | -| `unavailable_reviewers` | オブジェクト(名前 → 理由の文字列) | 許す(空のオブジェクト) | 認証を通らなかった者と、`probe_auth` の `detail`。空は「全員が通った」か「確認を飛ばした」 | -| `require_all` | 真偽値 | 許さない | `--require-all` の値。新規の既定は `false` | -| `auth_skipped` | 真偽値 | 許さない | `NDF_SKIP_AUTH_CHECK` で確認を飛ばしたか。`unavailable_reviewers` が空である理由を区別する | -| `resume_changes` | オブジェクトの配列 | 許す(空の配列) | 再開で変えた値の記録。追記だけを行う | - -`resume_changes[]` の要素: - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `at` | 文字列(ISO 8601) | 再開した時刻(`_now()`) | -| `field` | 文字列 | 変えた項目の名前(`max_rounds` / `rotate_after` / `only` / `verify_commands` / `verify_exit_codes` / `excluded_reviewers` / `available_reviewers` / `unavailable_reviewers` / `require_all` / `auth_skipped`)。値が変わった項目だけを積む | -| `from` | 任意 | 変える前の値。項目が無かったときは `null` | -| `to` | 任意 | 変えた後の値 | - -### 変わらない項目の意味の変化 - -| 項目 | 変わること | -| --- | --- | -| `evidence_rounds` | P4 から、反証が揃わない取り込みで番号が**外れる**ことがある(決定 5)。**付く条件は変わらない** | -| `review_findings[].classification` | P4 から、`critiques` が空で単独の根拠付き `major` 以上が `needs_human_judgment` になる(決定 3) | -| `only` | P5 から、再開の `--only` で変わる。`--only none` で `null` へ戻る | -| `max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` | P5 から、再開で明示的に渡したときだけ変わる | -| `rounds[].reviewers` | 変わらない。**過去のラウンドの担当はこの記録が持ち、再開で書き換えない** | - -### 実体の関係 - -```mermaid -erDiagram - 状態ファイル ||--o{ ラウンド : rounds - 状態ファイル ||--o{ 再開で変えた値 : resume_changes - 状態ファイル ||--o{ 指摘 : review_findings - 指摘 ||--o{ 反証 : critiques - 状態ファイル { - string host - string only - array available_reviewers - array excluded_reviewers - object unavailable_reviewers - array evidence_rounds - } - ラウンド { - int round - array reviewers - } -``` - -### 機能とデータの対応 - -| 機能 | `available_reviewers` / `excluded_reviewers` / `unavailable_reviewers` | `only` / `max_rounds` など | `resume_changes` | `rounds[].reviewers` | `evidence_rounds` | `review_findings[].classification` | -| --- | --- | --- | --- | --- | --- | --- | -| F1 数える | — | — | — | R | R | U | -| F2 印を外す | — | — | — | R | U | — | -| F3〜F5 新規の `init` | C | C | C(空) | — | — | — | -| F5 `start-round` | R | R | — | C | — | — | -| F6 再開の `init` | U | U | U(追記) | R | — | — | -| F7 `report` | R | R | R | R | — | — | - -### 時系列の扱い - -`available_reviewers` などの値は上書きし、過去の値は `resume_changes` に事象として積む(決定 17)。ラウンドごとに -誰が担当したかは `rounds[].reviewers` が持つため、上書きで失われるのは「どの時点でどの一覧だったか」だけで、 -それを `resume_changes` が補う。 - -### 移行 - -**既存の状態ファイルは書き換えない。** 項目が無いときの読み方を決める。 - -| 項目が無いとき | 読み方 | -| --- | --- | -| `available_reviewers` | `host` があれば `review_pool(host)` の輪番(変更前と同じ)。`host` も無ければ `codex` / `agy` | -| `excluded_reviewers` / `unavailable_reviewers` / `resume_changes` | 空として読む | -| `require_all` / `auth_skipped` | `false` として読む | - -再開で担当に関わる引数を渡したときだけ、使える者を作り直して項目を書く(決定 15)。渡さない再開では書き足さない。 - -## 入出力の契約 - -### `state.py init` の引数 - -| 引数 | 型 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | -| --- | --- | --- | --- | --- | --- | -| `pr` | 整数 | 必須 | — | — | 変わらない | -| `--max-rounds N` | 整数 | `None` | 無ければ 12 | 渡せば反映 | 既定を `None` へ | -| `--rotate-after K` | 整数 | `None` | 無ければ 8 | 渡せば反映 | 既定を `None` へ | -| `--only RUNTIME` | `claude`/`codex`/`agy`/`kiro`/`none` | `None` | `none` は無しと同じ | 渡せば反映。`none` で `null` | `none` を追加 | -| `--exclude NAMES` | カンマ区切りの名前。繰り返し可 | `None` | 外す | 渡せば置き換え。`none` で空 | **新設** | -| `--require-all` / `--no-require-all` | 真偽値 | `None` | 無ければ `false` | 渡せば反映 | **新設** | -| `--host RUNTIME` | 4 つの名前 | `None` | 無ければ推定 | 反映しない。違えば 1 行 | 再開での知らせを追加 | -| `--verify-command CMD` | 文字列。繰り返し可 | `None` | 無ければ空 | 渡せば置き換え | 再開で反映 | -| `--verify-exit-code N` | 整数。繰り返し可 | `None` | 無ければ空(判定側の既定 1) | 渡せば置き換え | 再開で反映 | -| `--worktree` / `--focus` / `--extra-instructions-file` | — | — | — | — | 変わらない | - -**`--exclude` の値の検査は 2 段に分かれる。** 名前の綴り(4 つの名前か `none`)は argparse の型が終了コード 2 で -弾く。母集合に含まれるか(ホスト自身でないか)は `init` がホストを確定した後に確かめ、終了コード 1 で弾く。 -`none` と他の名前を同時に渡したら終了コード 1 で弾く。 - -**再開で担当に関わる引数の一部だけを渡したとき、渡さなかった側は状態ファイルの値を使う。** 例えば `excluded_reviewers` が -`["agy"]` の状態ファイルへ `--only agy` だけを渡すと、状態ファイルの除外と矛盾するため終了コード 1 で弾く。 - -### `state.py init` の出力と終了コード - -標準出力の `KEY=VALUE`(`_print_init_result`)は変えない。**増えるのは標準エラーの行だけである。** - -| 場面 | 標準エラーに出るもの | 終了コード | 状態ファイル | -| --- | --- | --- | --- | -| 新規で全員が使える | 母集合と使える者の 1 行(現行の「ホスト / レビュワーの母集合」の行に続ける) | 0 | 作る | -| 新規で認証を通らない者がいる | 認証を通らなかった者と理由を 1 者 1 行 | 0 | 作る | -| 新規で使える者が 1 者 | 観点が 1 つになる警告 1 行 | 0 | 作る | -| 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`) | 飛ばしたことを 1 行(`auth.py` の既存の文言) | 0 | 作る(`auth_skipped: true`) | -| 新規で使える者が 0 者 | 使える者がいない理由 | 1 | 作らない | -| `--require-all` で欠けがある | 変更前と同じ「認証されていない CLI があります」の文言 | 1 | 作らない | -| `--exclude` が母集合の外 / `--only` と矛盾 / `none` と名前の混在 | 何が矛盾したか | 1 | 作らない | -| 再開で引数を反映した | 反映した項目ごとに `<項目>: <旧> → <新>` の 1 行 | 0 | 書き換える | -| 再開で `--host` が状態と違う | 反映しないことを 1 行 | 0 | `host` は変えない | -| 再開で作り直した使える者が 0 者 / 欠けあり | 新規と同じ | 1 | 書き換えない | - -### `state.py report` の出力 - -現行の「PR 履歴」の後に、次の節を足す。 - -```text -## 参加した者 -- 使える者: codex / kiro -- --exclude で外した者: agy -- 認証を通らなかった者: なし -- 再開で変えた値: 2026-09-15T12:00:00 excluded_reviewers [] → ["agy"] -``` - -項目が無い状態ファイル(この変更の前に始めた実行)では「使える者: 記録なし」と出す。`auth_skipped` が真のときは、 -3 行目を「認証を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。 - -### 共通層の関数(`lib/assignment.py`) - -| 関数 | 入力 | 出力 | 失敗の形 | 変更 | -| --- | --- | --- | --- | --- | -| `review_pool(host)` | ホスト名 | 母集合 3 者 | ホストでない名前で `AssignmentError` | 変わらない | -| `review_candidates(host, excluded)` | ホスト名、外す名前の集合 | 母集合から外した一覧(母集合の順) | 母集合に無い名前を含むと `AssignmentError`(名前を並べる) | **新設** | -| `review_assign(round_no, available)` | ラウンド番号、使える者の一覧(`list` / `tuple`) | 担当(3 者以上は 1 者を外す、2 者以下はそのまま) | `round_no < 1`、空の一覧、文字列を渡したときに `AssignmentError` | **第 2 引数をホスト名から一覧へ変える** | -| `assign(round_no, host)` | — | — | — | 変わらない | - -**`review_assign` の呼び出し側は `state.py` の `_round_reviewers` だけである。** `git grep -n review_assign` で -他に当たるのは `cross-refactoring` のテスト、`docs/05` の説明、`issues/old/` の記録だけである。文字列を弾くのは、変更前の呼び方(`review_assign(1, "claude")`)が -黙って 1 文字ずつの一覧として通るのを防ぐためである。 - -### 共通層の関数(`lib/auth.py`) - -| 関数 | 入力 | 出力 | 失敗の形 | 変更 | -| --- | --- | --- | --- | --- | -| `probe_auth(runtimes, *, info, env=None)` | 確かめる名前の一覧 | `(結果, 飛ばしたか)`。結果は名前 → `{"command", "ok", "detail"}` | 例外を上げない。コマンドが無い・時間切れは `ok: false` と `detail` | **新設** | -| `check_auth(runtimes, *, info, die, env=None)` | 変わらない | 変わらない(飛ばしたときは `{}`) | 1 件でも失敗すれば `die` を呼ぶ | **振る舞いを変えない**(中で `probe_auth` を使ってよい) | - -### `state.py` の内部関数 - -| 関数 | 契約 | 変更 | -| --- | --- | --- | -| `_resolve_reviewers(host, only, excluded, require_all)` | 設計文書の「使える者の解決」の 5 手順を行い、`{"available_reviewers", "excluded_reviewers", "unavailable_reviewers", "auth_skipped"}` を返す。失敗は `die(code=1)` | **新設**(`_auth_targets` を置き換える) | -| `_validate_only(only, host, excluded)` | `only` が母集合に無い、または `excluded` に含まれるとき `die` | 引数を足す | -| `_round_reviewers(st, round_no)` | 設計文書の決定 12 の順で返す | 順を変える | -| `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | 決定 13〜17 の反映を行う。反映が失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | -| `_apply_resume_args(st, args)` | 渡された引数を `st` へ書き、`resume_changes` に積み、出す行の一覧を返す。書き込みは呼び出し側が 1 回で行う | **新設** | -| `_guard_previous_round(st, prev)` | `_no_result_agents` と `_round_passes` に `prev["reviewers"]`(無ければ `_round_reviewers(st, prev["round"])`)を渡す | 担当を渡す | -| `_classify_finding(finding)` | 順 4 に「`critiques` が空」を足す(P4) | 条件を足す | -| `_handle_incomplete_critiques(pr, st, round_no, missing)` | 先頭でそのラウンドの印を外す(P4) | 印を外す | - -### 手順書の骨組み(`SKILL.md` / `docs/01`) - -```bash -INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ - ${MAX_ROUNDS:+--max-rounds "$MAX_ROUNDS"} ${ROTATE_AFTER:+--rotate-after "$ROTATE_AFTER"} \ - ${HOST:+--host "$HOST"} \ - ${ONLY:+--only "$ONLY"} ${EXCLUDE:+--exclude "$EXCLUDE"} \ - ...) || exit $? - -for r in $REVIEWERS; do "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND"; done -"$SCRIPTS/monitor.py" "$STATE_PR" --agents "$REVIEWERS_CSV" || true -for r in $REVIEWERS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true; done -"$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" $REVIEWERS -``` - -**`--max-rounds` と `--rotate-after` も値があるときだけ渡す。** 現行の骨組みは `"$MAX_ROUNDS"` を常に渡すため、 -再開のたびに利用者が指定していない値で上書きする(決定 13 が防ぎたい形そのもの)。 diff --git a/issues/issue-624-478-648-design.md b/issues/issue-624-478-648-design.md deleted file mode 100644 index bf37c37b..00000000 --- a/issues/issue-624-478-648-design.md +++ /dev/null @@ -1,420 +0,0 @@ -# #624 / #478 / #648: 反証する担当がいない指摘を数え、使える担当だけで回し、再開で引数を反映する - -要求と受け入れ条件は [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) にある。この文書は -「どう作るか」だけを扱う。 - -**実装は 2 本の Pull Request に分ける**(決定 1)。 - -| Pull Request | 課題 | 中身 | 受け入れ条件 | -| --- | --- | --- | --- | -| P4 | #624、#583 の収束の部分 | 反証を受けていない単独の指摘を数える。反証が揃わない取り込みで印を外す | AC1〜AC9、AC31、AC32 | -| P5 | #478、#648 | 使える者の一覧と `--exclude`。再開で明示的に渡した引数を反映する | AC10〜AC32 | - -全体の実装順は P1 → P2 → P3(いずれも D-A)→ P4 → P5 である。P4 / P5 は P1〜P3 が入った `state.py` の上に載せる。 - -## 機能一覧 - -| # | 機能 | 誰が使うか | Pull Request | -| --- | --- | --- | --- | -| F1 | 反証する担当がいない根拠付きの `major` を、新しい指摘として数える | 1 者で回す利用者、担当が起動し直されたループ | P4 | -| F2 | 反証が揃わなかったラウンドを、全件を数える扱いへ戻す | 収束ループ全般 | P4 | -| F3 | 認証できない担当を外し、使える者でループを始める | CLI の一部が導入・認証されていない利用者 | P5 | -| F4 | `--exclude` で担当を名指しで外す | 打ち切り・利用上限が分かっている担当を避けたい利用者 | P5 | -| F5 | 使える者の数に応じて担当を決める(3 者は輪番、2 者は固定、1 者は警告) | 収束ループ全般 | P5 | -| F6 | 再開で `--max-rounds` などの明示した引数を反映し、反映しない引数を知らせる | 中断したループを進め方を変えて再開する利用者 | P5 | -| F7 | 完了報告に、参加した者・外した者・認証を通らなかった者(理由つき)・再開で変えた値を出す | 収束の結果を読む人 | P5 | - -## 決定の記録 - -決定 1 は分け方、決定 2〜5 は P4 の数え方、決定 6〜12 は P5 の担当の決め方、決定 13〜19 は P5 の再開と骨組みを扱う。 - -### 決定 1: P4(#624 と #583 の収束の部分)と P5(#478 と #648)に分け、P4 を先に出す - -#478 は使える者が 1 者のときに 1 者で回す分岐を入れる。#624 を直さないまま入れると、その分岐が必ず誤った収束を -踏む。P4 は担当の決め方に依存しないため、先に単独で出せる。#478 と #648 は同じ引数(`--exclude` / `--only`)の -再開での扱いを一緒に決める必要があり、1 本にまとめる。 - -3 本に分ける形は採らない。#648 だけを先に出すと、`--exclude` を足したときに再開の規則をもう一度書き換える。 - -### 決定 2: 数えない判断は「反証を受けた単独の指摘」だけに掛け、判定は指摘の記録から導く - -区分 4 の `support` の条件は「別の担当が独立に確かめたか」を問う。**問えるのは、別の担当が反証を返した指摘だけ -である。** 反証を 1 件も受けていない単独の指摘とは、`critiques` が空で `origin_runtimes` が 1 者の指摘である。担当 1 者のラウンドと、 -反証を取り込んだ後に取り込まれた指摘(#583)に現れる。**状態ファイルに項目を足さない。** 取り込み -(`_collect_review_findings`)は、同じ担当の指摘を新しい要素で置き換える。起動し直した担当の指摘は、必ず反証を -持たない状態で入る。`collect-critiques` は、全対象の反証が揃ったときだけ印を付ける。 -そのため印の付いた 2 者のラウンドでは、単独の指摘はすべて反証を持つ(再現 C)。 - -採らない案と理由: - -- **反証が 0 件のラウンドは絞り込まない(issue の候補 1)。** ラウンド単位では、2 者のうち 1 件だけが後から入った - #583 の形を塞げない -- **担当 1 者なら intent に従う(候補 2)。** 1 者の分岐だけを塞ぎ、2 者の起動し直しの経路が残る -- **指摘へ「反証の対象になった」印を書く(候補 3 の字面どおり)。** 書く経路(取り込み・起動し直し・統合)ごとに - 付け忘れが起き、旧い状態ファイルには印が無い。記録から導けば経路に依存しない - -### 決定 3: 反証を受けていない単独の指摘は、区分 4 の `support` の条件だけを外す - -外すのは担当の構造上満たせない条件だけにする。根拠(`has_evidence`)と重大度(`major` 以上)の条件は残す。 -**同じ指摘の扱いが担当の数で変わらない。** 2 者のラウンドで数えない `minor` は、1 者のラウンドでも数えない。 -区分の順 1〜3(実行検証の再現・棄却、`refute`)はそのまま先に当たる。 - -```text -順 4: has_evidence かつ major 以上 かつ(support が 1 件以上 / origin_runtimes が 2 者以上 / critiques が空) -``` - -`critiques` が空で `origin_runtimes` が 2 者以上の指摘は、元の条件(2 者以上)で既に当たる。そのため条件は -「`critiques` が空」だけを足せば足りる。**2 者の通常のラウンドの判定は変わらない**(決定 3・5 を当てた試作で既存の cross-review の -テスト 773 件が期待値を変えずに通った。落ちた 1 件は複製先が git の作業ツリーでないことによる)。 - -採らない案: **反証を受けていない指摘は全件(`minor` 以下も)数える。** 1 者で回すと `minor` だけの -`REQUEST_CHANGES` でもラウンドが続き、2 者のときと収束の基準が食い違う。1 者でも intent が pass -(`APPROVE` / 重大な指摘の無い `COMMENT`)なら `round_passes` で収束する。この案の利点は `minor` を -数えることだけになる。 - -### 決定 4: 区分の名前を増やさない - -決定 3 で数える指摘は `needs_human_judgment` に入れる。修正の担当が読む意味(根拠を持つ `major`、判断は修正の担当) -は同じである。名前を足すと、`COUNTED_CLASSIFICATIONS` を持つ `state.py` と `measure.py` の 2 か所と、 -`docs/04` / `docs/06` の語彙を揃えて変えることになる。反証を受けていないことは `critiques` が空であることで -後から読める。 - -### 決定 5: 反証が揃わない取り込みでは、先に付いていた印を外す - -`collect-critiques` は揃わないとき印を付けないが、**既に付いている印は外さない**。 -再現 B2 では、取り直しの後も `evidence_rounds` が `[1]` のままだった。そのとき「印を付けないため、このラウンドは全件を数えます」の出力と実際の -数え方が食い違う。`_handle_incomplete_critiques` の先頭でそのラウンドの番号を `evidence_rounds` から除く。 -**印は「直近の取り込みで揃ったか」を表す。** 起動し直しの後に反証を取り直す経路(D-A の P3)でも、揃えば印が戻り、 -揃わなければ全件を数える。 - -採らない案: 決定 3 だけで足りるとして印を残す。出力の文言と `docs/06` の契約(揃わなければ全件)が偽のまま残る。 - -### 決定 6: 認証の確認を関門から把握へ変え、使える者を状態ファイルに持つ - -`init` は母集合から外した者を除いた全員を確かめる。通った者は `available_reviewers` へ、通らなかった者は理由とともに -`unavailable_reviewers` へ書く。**再開のたびに確かめ直さない**(決定 15 の場合を除く)。途中で担当が -入れ替わると、前のラウンドの記録と突き合わせられなくなる。 - -共通層 `lib/auth.py` に止めない確認 `probe_auth` を足す。`check_auth` は `cross-refactoring` が使うため、 -振る舞い(1 件でも失敗すれば `die` を呼ぶ)を変えない(決定 10)。 - -採らない案: 起動の直前にラウンドごとに確かめる。ラウンドごとに最大 3 回の確認(1 回あたり上限 120 秒)が増える。 - -### 決定 7: 外す指定は `--exclude`(繰り返し・カンマ区切り)にする - -外したい理由は「この担当が落ちる」であり、名指しするのは外す側である。`--exclude agy` と `--exclude agy,kiro` と -`--exclude agy --exclude kiro` を同じ意味にする。骨組みのシェル変数 1 つ(`${EXCLUDE:+--exclude "$EXCLUDE"}`)で -複数を渡せる。母集合の外(ホスト自身)は `init` が弾く(`--only` と同じ扱い)。 - -採らない案: 使う側を並べる `--reviewers codex,kiro`。ホストが変わると一覧を書き直す必要があり、#478 の提案の -形とも違う。 - -### 決定 8: 使える者の数で分け、`review_assign` は使える者の一覧を受け取る - -```python -def review_assign(round_no: int, available: Sequence[str]) -> list[str]: - # 3 者以上: (round_no - 1) % len を外す / 1〜2 者: そのまま / 0 者: AssignmentError -``` - -**3 者の結果は変わらない。** 試作で 4 つのホストとラウンド 1〜12 の全組を比べた。`review_assign(r, review_pool(host))` は -変更前の `review_assign(r, host)` とすべて一致した。2 者は毎ラウンド同じ 2 者になり、#631 の「1 者も見ていない -担当がいる」状態が起きない。1 者は `init` が警告して回し、P4 の規則で数える。0 者は `init` が失敗し、状態ファイルを -作らない。 - -採らない案: 使える者が 2 者のときも輪番で 1 者を外す。毎ラウンド 1 者だけのレビューになり、観点が減る。 - -### 決定 9: `--require-all` で従来の関門を選べるようにする - -全員が揃わないなら始めたくない運用(リリース前の最終レビューなど)のために残す。付けると、認証を通らない者が -1 者でもいれば従来の文言で失敗する。`--exclude` で外した者は揃っていなくてよい(外すことを明示しているため)。 -再開で `--no-require-all` を渡せるよう `BooleanOptionalAction` にし、既定は `None` とする(決定 13)。 - -### 決定 10: `cross-refactoring` には除外の引数を足さず、`assign()` と `check_auth()` を変えない - -`cross-refactoring` の `assign()` は実装担当を先に決めてから残りを選ぶ。使える者が 2 者のとき、実装担当を外すと -レビュー担当が 1 者になる。#478 のコメントは「著者を含めて回し、著者の `APPROVE` を収束に数えない」形を提案している。 -この形は判定とプロンプトに及び、D-C の範囲(`refactor_lib` の適用の骨組み)と重なる。この変更では共通層の `assign()` と -`check_auth()` の振る舞いを保ち、`cross-refactoring` の除外は別の課題へ切り出す(#664)。 - -採らない案: `cross-refactoring` の `init`(`refactor_lib/commands/setup.py`)に除外だけを足す。提案者と実装の -母集合は減らせても、レビュー担当が 1 者になる形が残る。 - -### 決定 11: `--only` は使える者を 1 者に絞る指定として扱う - -`--only X` のとき使える者は `[X]` で、認証を確かめるのも `X` だけである(現行の `_auth_targets` と同じ)。 -`X` が `--exclude` に含まれるときは矛盾として `init` が弾く。状態ファイルの `only` は残す(`_agent_intent` の -`SKIP` と旧い状態ファイルが読むため)。 - -### 決定 12: 担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む - -```text -ラウンドの reviewers → only → available_reviewers の輪番 → host の輪番(review_pool)→ codex / agy -``` - -現行は `only` をラウンドの記録より先に見る。再開で `only` を変えられるようにすると(決定 13)、過去のラウンドの -担当まで変わる。`_finding_keys` は前のラウンドの指摘を別の担当の payload から読むことになる。`start-round` はラウンドを開く -ときに `_round_reviewers` の結果を記録する。新しい状態ファイルでは、順を変えても結果は同じである。 - -`_guard_previous_round` も同じ理由で `prev["reviewers"]` を渡す。現行は担当を渡さず、旧来の `codex` / `agy` で数える。 -担当が `agy` + `kiro` のラウンドでは `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す(再現 I)。 - -### 決定 13: 引数の既定値を `None` にし、明示的に渡した引数だけを再開で反映する - -`build_parser` の 8 つの引数の既定を `None` にする。対象は `--max-rounds` / `--rotate-after` / `--only` / `--host` / -`--verify-command` / `--verify-exit-code` / `--exclude` / `--require-all` である。新規の経路は `None` を定数の既定(`DEFAULT_MAX_ROUNDS = 12` / -`DEFAULT_ROTATE_AFTER = 8`)へ置き換える。再開の経路は `None` でない引数だけを状態ファイルへ書き、変えた項目ごとに -`<項目>: <旧> → <新>` を 1 行出す。`--verify-command` と `--verify-exit-code` は置き換える。 - -採らない案: 既定値と同じ値なら渡していないとみなす。`--max-rounds 12` で 20 から 12 へ戻したい指定を区別できない。 -再現 G では `init 1` と `init 1 --max-rounds 12` がどちらも 12 になった。 - -### 決定 14: `--host` は再開で反映せず、違う値のときだけ知らせる - -ホストが変わると母集合が変わり、過去のラウンドの輪番を再現できない。状態ファイルと違う値が渡されたら -「`--host` は再開では反映しない(状態: claude、指定: codex)」を 1 行出す。同じ値なら出さない。 -`--worktree` は状態ファイルを探す場所の指定であり、反映の対象ではない。 - -### 決定 15: 担当に関わる引数を渡した再開でだけ、認証を確かめ直して使える者を作り直す - -`--only` / `--exclude` / `--require-all` のいずれかを渡した再開では、決定 6 と同じ手順で使える者を作り直す。 -対象は母集合から外した者を除いた全員(`--only` があればその 1 者)である。**いずれも渡さない再開では確かめ直さない。** -作り直した結果が 0 者、または `--require-all` で欠けがあるときは、状態ファイルを書き換えずに終了コード 1 で終わる。 - -採らない案: 新しく加わる者だけを確かめる。前回に認証を通らなかった者がログインした後も戻れない。 - -反映は再開の後に開くラウンドから効く。骨組みは `init` の直後に必ず `start-round` を呼び、`start-round` は常に -新しい番号のラウンドを開く。そのため、開いたまま中断したラウンドの担当は書き換えない(要求の前提 3)。 - -### 決定 16: 再開で指定を外す値は `none` にする - -`--only none` は `only` を `null` へ、`--exclude none` は除外を空へ戻す。空文字列は骨組みの -`${ONLY:+--only "$ONLY"}` が渡さないため、外す手段にならない。新規の経路で `none` を渡すと、渡さないのと同じになる。 - -### 決定 17: 再開で変えた値は `resume_changes` に積む - -`available_reviewers` や `max_rounds` は再開で上書きされる。上書きだけでは、どの時点で何を変えたかが失われ、 -完了報告で「途中から agy を外した」ことが読めない。変えた項目を `{"at", "field", "from", "to"}` の形で -追記し、書き換えない(事象の記録)。ラウンドごとの担当は `rounds[].reviewers` が既に持つ。 - -### 決定 18: 骨組みは `$ONLY` で絞らず、`start-round` が返す担当を使う - -`start-round` の `REVIEWERS` は `only` を反映済みである。シェル変数の `$ONLY` でもう一度絞ると、状態ファイルと -シェル変数がずれたときに起動も監視も誰にも当たらない。そのうえ起動し直しの分岐だけが担当を起動する(#648 の 3 段の経過)。 -`SKILL.md` と `docs/01` の 2 か所で、担当を `$REVIEWERS` / `$REVIEWERS_CSV` に揃える。 -対象は Step 2 の起動・監視・取り込みと、Step 2.5 の反証である。`$ONLY` は `init` へ渡す 1 行にだけ残す。 - -### 決定 19: 起動した後に分かる使えなさで、担当を自動的に外す仕組みは作らない - -利用上限(kiro の `Monthly request limit reached`)やモデルの 404 は、起動した後に分かる。 -認証の確認は通る(#478 のコメント)。分類は D-A の #619 が `usage_limit` などとして作る。自動で外すと、一時的な打ち切りでも以後の -ラウンドから恒久的に外れる。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る。 - -## 実測 - -**再現は、状態ファイルを最小の形で組んで `state.py` の関数を直接呼んで確かめた**(develop `b4a9f69`)。 - -| 場面 | 状態 | 変更前 | 決定 3・5 の試作 | -| --- | --- | --- | --- | -| A(#624) | 担当 `codex` 1 者、印あり、根拠付き `major` 1 件、反証なし | `(0, True)`、収束 | `(1, True)`、収束しない | -| B(#583) | 担当 `agy` + `kiro`、印あり、`agy` の根拠付き `major` が反証なし | `(0, True)`、収束 | `(1, True)`、収束しない | -| B2 | B の後に `collect-critiques`、`kiro` の反証が `agy` の指摘を覆わない | 終了コード 7、印 `[1]` のまま | 終了コード 7、印 `[]`、全件を数える | -| C | 2 者、相手が `insufficient_evidence` | `(0, True)`、収束 | 変わらない | -| D | `origin_runtimes` 2 者の `minor`、反証なし | `(0, True)`、収束 | 変わらない | -| A の `minor` / 根拠なし | A の指摘を `minor` / `has_evidence: false` にする | `(0, True)` | 変わらない(決定 3) | - -P5 の前提: - -| 場面 | 結果 | -| --- | --- | -| F(担当の優先) | `only: "codex"` の状態で、記録が `agy` + `kiro` のラウンド 1 を `_round_reviewers` が `["codex"]` と返す | -| G(#648) | `init 1` と `init 1 --max-rounds 12` はどちらも `max_rounds == 12`。再開の `init` に `--only codex --max-rounds 20 --verify-command pytest --host codex` を渡すと、状態ファイルは `only: null` / `max_rounds: 12` / `host: claude` / `verify_commands: []` のまま、出力にも出ない | -| H(#478) | `check_auth` は 3 者のうち 1 者の失敗で `die` を呼ぶ。`review_assign(r, "claude")` はラウンド 1〜3 で 3 者から 1 者ずつ外す | -| I(前ラウンドの検査) | 担当 `agy` + `kiro`・両者 `REQUEST_CHANGES`・`verdict` なし・修正の記録なしのラウンドを、`_no_result_agents(prev, None)` が `["codex"]` と読み、検査を通す | -| 引数の形 | `action="append"` とカンマ区切りの型で、`--exclude agy --exclude kiro` と `--exclude agy,kiro` が同じ集合になる。知らない名前は argparse が終了コード 2 で弾く | - -## 構成要素 - -| 要素 | 責務 | Pull Request | -| --- | --- | --- | -| 区分の判定(`_classify_finding`) | 順 4 の条件に「反証を受けていない」を足す(決定 3) | P4 | -| 反証の不足の扱い(`_handle_incomplete_critiques`) | そのラウンドの印を外してから、取り直す担当を返す(決定 5) | P4 | -| 担当の決定(`lib/assignment.py`) | 母集合から外した者を除く一覧を返す。使える者の一覧から担当を選ぶ(決定 7・8) | P5 | -| 認証の把握(`lib/auth.py`) | 止めずに確かめ、担当ごとの結果を返す(決定 6) | P5 | -| 使える者の解決(`state.py` の新しい関数) | 母集合・`--only`・`--exclude`・認証・`--require-all` から、使える者と外した者を決める。新規と再開の両方が呼ぶ | P5 | -| 再開の反映(`_resume_from_state`) | 明示的に渡した引数を状態ファイルへ書き、`resume_changes` に積み、反映しない引数を知らせる(決定 13〜17) | P5 | -| 担当の読み出し(`_round_reviewers` / `_guard_previous_round`) | ラウンドの記録を先に見る(決定 12) | P5 | -| 完了報告(`cmd_report`) | 使える者・外した者・認証を通らなかった者・再開で変えた値を出す | P5 | -| 骨組みと文書(`SKILL.md` / `docs/01` / `docs/04` / `docs/05` / `docs/06`) | `$ONLY` で絞らない。引数・状態ファイル・区分の規則を書く | P4 / P5 | - -P4 の要素(判定): - -```mermaid -graph TD - CC[反証の取り込み] --> IC[反証の不足の扱い] - NF[新しい指摘の数] --> CL[区分の判定] -``` - -P5 の要素(初期化とラウンド): - -```mermaid -graph TD - IN[init の新規の経路] --> RV[使える者の解決] - RS[再開の反映] --> RV - RV --> AU[認証の把握] - RV --> AS[担当の決定] - SR[start-round] --> GD[前ラウンドの検査] - SR --> RR[担当の読み出し] - GD --> RR - RR --> AS -``` - -図の辺は呼び出しを表す。`init` の新規の経路・`start-round`・反証の取り込み・新しい指摘の数は、変える要素の呼び出し元として -置いた既存の要素である。完了報告は状態ファイルを読むだけで他の要素を呼ばないため、骨組みと文書とともに図に含めない。 - -**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` の 1 プロセスである。 -外部との出入りは `gh`(GitHub)と、各 CLI の認証の確認コマンドだけである。認証の確認の呼び出し先(`AUTH_PROBES`)は変えない。 - -## 置き場所 - -```text -plugins/ndf/ -├── scripts/lib/ -│ ├── assignment.py # P5: review_candidates を新設、review_assign の引数を変更 -│ └── auth.py # P5: probe_auth を新設(check_auth は変えない) -└── skills/ - ├── cross-review/ - │ ├── SKILL.md # P5 - │ ├── docs/01-state-and-review.md # P5 - │ ├── docs/04-contracts.md # P5 - │ ├── docs/05-pool-and-convergence.md # P4 / P5 - │ ├── docs/06-evidence.md # P4 - │ ├── scripts/state.py # P4 / P5 - │ └── tests/ # P4: test_classify_findings.py / test_critiques.py に追記 - │ # P5: test_state_review_pool.py に追記、test_state_resume_args.py を新設 - └── cross-refactoring/tests/test_assignment.py # P5: review_assign の呼び方を変え、AC15・AC16 のテストを足す -``` - -`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。 -`bash scripts/build-runtime-plugins.sh --check` で食い違いが無いことだけを確かめる。 - -状態ファイルの形・引数・関数の契約は [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) にある。 - -## 処理の流れ - -### 新しい指摘の数え方(P4) - -```mermaid -graph TD - J[judge] --> K{payload を読めたか} - K -->|いいえ| U["(0, False) 全員 pass に従う"] - K -->|はい| M{ラウンドに印があるか} - M -->|いいえ| ALL[payload の全件を数える] - M -->|はい| C[指摘ごとに区分を決める] - C --> R{実行検証で再現・棄却 / refute} - R -->|当たる| V[順 1〜3 の区分] - R -->|当たらない| E{根拠あり かつ major 以上} - E -->|いいえ| IE[insufficient_evidence 数えない] - E -->|はい| S{support / 提案者 2 者 / 反証なし} - S -->|いずれか| NH[needs_human_judgment 数える] - S -->|どれも無い| IE -``` - -反証の取り込みで揃わないとき、`_handle_incomplete_critiques` がそのラウンドの印を外す。次に `judge` が数えるとき -「印があるか」が「いいえ」へ進む。取り直して揃えば `_mark_evidence_round` が印を戻す。 - -### 初期化と再開(P5) - -新規の経路: - -```mermaid -graph TD - N[PR の情報・作業ツリー・既存コメント] --> H[ホストを確定] - H --> RV[使える者の解決] - RV -->|0 者 / --require-all で欠け| F[終了コード 1 状態を作らない] - RV -->|1 者| W[観点が 1 つと警告] - RV -->|2〜3 者| S[状態ファイルを書く] - W --> S -``` - -再開の経路(状態ファイルがあり `final` が `null`): - -```mermaid -graph TD - A[明示的に渡した引数を集める] --> HO{--host が状態と違う} - HO -->|はい| HM[反映しないことを 1 行出す] - HO -->|いいえ| RA{--only / --exclude / --require-all を渡した} - HM --> RA - RA -->|はい| RV[使える者の解決] - RA -->|いいえ| W[変えた項目を書き resume_changes に積む] - RV -->|1 者以上| W - RV -->|0 者 / 欠けあり| F[終了コード 1 状態は書かない] -``` - -使える者の解決は次の順で決める。 - -1. `--only` があれば母集合に含まれるかを確かめ、`--exclude` に含まれていれば弾く。対象は `[only]` -2. `--only` が無ければ、`--exclude` の各名前が母集合に含まれるかを確かめ、対象は母集合から除いた者 -3. 対象の認証を `probe_auth` で確かめる。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとする -4. `--require-all` が真で通らない者がいれば、従来の文言で終了コード 1 -5. 通った者が 0 者なら終了コード 1。1 者なら警告を 1 行出す - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 可用性 | 母集合の 1 者が使えないことで、収束ループを開始できない状態にならない | 認証の失敗を `unavailable_reviewers` へ記録し、使える者で続ける(決定 6) | AC10 のテスト | -| 性能・拡張性 | 認証の確認の回数は、新規の `init` で変更前を上回らない。再開で増えるのは担当に関わる引数を渡したときだけで、母集合の最大 3 者である | `--exclude` で外した者は確かめない。再開は決定 15 の条件でだけ確かめる | AC13・AC26 のテストで `probe_auth` の呼び出しを数える | -| 運用・保守性 | 担当が欠けたまま収束したことが `report` の出力だけで分かる。再開で反映しなかった引数が出力に出る | `report` が 3 つの一覧と `resume_changes` を出す。`--host` の不一致を 1 行出す | AC21・AC27 のテスト | - -## 申し送り(並行する設計との境界) - -| 相手 | 決めた契約 | -| --- | --- | -| D-A(P1〜P3) | **P4 の規則は、起動し直しの経路が証拠集約を通るかどうかに依存しない。** 通らなければ決定 3 が数え、通って反証が揃わなければ決定 5 が全件へ戻す。D-A が起動し直しの後に `critique-round.sh` を呼ぶときは、担当を `$REVIEWERS`(そのラウンドの担当)で渡し、`$ONLY` を使わない(決定 18) | -| D-A(P1〜P3) | `SKILL.md` の骨組みで D-B が触るのは Step 0 の `init` の引数、Step 2 の起動・監視・取り込みの 3 つのループ、Step 2.5 の `critique-round.sh` の引数である。Step 3 の `JUDGE_RC -eq 7` の分岐は D-A が持つ。P5 は P3 の後に載せるため、衝突は P5 の側で解く | -| D-A(P1〜P3) | `_no_result_agents` と `_handle_no_result_round` の中身(`NO_RESULT` の理由)は D-A が持つ。P5 は `_guard_previous_round` から呼ぶときに担当の一覧を渡すだけにする | -| D-A(#619) | 利用上限などの分類を、担当を外す判断へつなぐのは後続に回す(決定 19)。つなぐ場合の入口は再開の `--exclude` である | -| D-C | `refactor_lib` に触らない。共通層の `assign()` と `check_auth()` の振る舞いを変えない(決定 10)。`review_assign` のテストは共通層のテストの置き場所 `cross-refactoring/tests/test_assignment.py` にある。P5 はそこで既存の呼び出しを直し、AC15・AC16 のテストを足す。`refactor_lib` は `review_assign` を呼ばない | - -**D-A の決定に依存する点:** - -- P3 が起動し直しの後に `verify-findings` と `critique-round.sh` を通す形を採るか。どちらでも P4 は成り立つ。 - 変わるのは、#583 の起動し直しの指摘を `needs_human_judgment` として数えるか、全件として数えるかである -- P1 が `monitor.py` の結果をファイルに残す形と、`SKILL.md` の Step 2 の監視の行の書き方。P5 の `$ONLY` の削除と同じ行を触る -- P3 が `state.py` の `_resume_from_state` か `cmd_init` に手を入れるか。P5 は `_resume_from_state` の引数を増やす - -## テスト設計 - -置き場所は、`cross-refactoring/` で始まるもの以外は `plugins/ndf/skills/cross-review/tests/` の下である。 -`cross-refactoring/` で始まるものは `plugins/ndf/skills/` の下にある。 - -| 受け入れ条件 | 何で確かめるか | 置き場所 | -| --- | --- | --- | -| AC1〜AC4 | 状態ファイルを組み、`_new_finding_count` と `cmd_judge` の終了コードを見る(実測の A / B と、A の `minor` / 根拠なし) | `test_classify_findings.py` | -| AC5 | 印の付いた状態で `cmd_collect_critiques` を 2 回呼び、`evidence_rounds` と数え方を見る(実測の B2) | `test_critiques.py` | -| AC6・AC7 | `_classify_finding` に反証の値ごと・`origin_runtimes` 2 者の指摘を渡す | `test_classify_findings.py` | -| AC8 | 既存のテストを期待値を変えずに通す | 既存のまま | -| AC9 | `grep` の行を検査するテスト | `test_skill_layout.py` | -| AC10〜AC14 | `probe_auth` を差し替えて `_init_new_state` を呼ぶ(GitHub を呼ぶ関数は `conftest.py` の既存の差し替え)。状態ファイルの有無と終了コードを見る | `test_state_review_pool.py` | -| AC15・AC16 | `review_assign` を 4 ホスト × 12 ラウンドと 2 者の一覧で呼ぶ。AC15 は変更前の式を期待値として持つ | `cross-refactoring/tests/test_assignment.py` | -| AC17・AC18 | 使える者 1 者・0 者の `init` | `test_state_review_pool.py` | -| AC19 | `available_reviewers` を持たない状態 / `host` も持たない状態で `_round_reviewers` | `test_state_review_pool.py` | -| AC20 | 既存の `assign` の期待値と、`check_auth` の失敗で `die` が呼ばれるテスト | `cross-refactoring/tests/test_assignment.py` / `cross-refactoring/tests/test_init.py` | -| AC21 | 3 つの一覧と `resume_changes` を持つ状態ファイルで `cmd_report` の出力の 4 行を見る | `test_state_review_pool.py` | -| AC22〜AC27 | 状態ファイルを置いた作業ツリーを渡して `cmd_init` を呼び、状態ファイルと標準エラーを見る(実測の G) | `test_state_resume_args.py`(新設) | -| AC28 | 2 ファイルの `ONLY` を含む行を数えるテスト | `test_skill_layout.py` | -| AC29 | `verdict` の無い前ラウンドで `cmd_start_round` の終了コード 5 を見る(実測の I) | `test_state_round_guard.py` | -| AC30 | 文書の語を `grep` するテスト | `test_skill_layout.py` | -| AC31・AC32 | コマンドの終了コード | 継続的統合と手元 | - -## 未確認のまま残ること - -| 項目 | 内容 | いつ決まるか | -| --- | --- | --- | -| 1 者で回したときの収束までのラウンド数 | 決定 3 で `minor` と根拠の無い `major` を数えないため、1 者のループが早く収束しすぎないかは実測していない | P4 の後の運用で `measure.py` の出力を見る | -| 2 者固定(`codex` + `kiro`)の観点の偏り | `agy` を外して 2 者を固定したとき、3 者の輪番より指摘を見落とすかは測っていない | P5 の後の運用 | -| `cross-refactoring` の除外と、著者を含めて回す規則 | 決定 10 で切り出す。レビュー担当が 1 者になる形をどう扱うかは未決 | #664 | -| 起動した後に分かる使えなさで外す仕組み | 決定 19 で作らない。自動で外すか、利用者へ再開を促すかは未決 | D-A の #619 の後 | -| 再開で `--verify-command` を空へ戻す手段 | 持たない。要求が出たら `none` と同じ形で足せる | 要求が出たとき | -| 出力の文言 | 反映した行・反映しない行・警告の文言は、項目名と値を含むことだけを決めた | **実装で決める** | -| テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | diff --git a/issues/issue-624-478-648-requirements.md b/issues/issue-624-478-648-requirements.md deleted file mode 100644 index 02caf60f..00000000 --- a/issues/issue-624-478-648-requirements.md +++ /dev/null @@ -1,289 +0,0 @@ -# #624 / #478 / #648: 反証する担当がいない指摘を数え、使える担当だけで回し、再開で引数を反映する - -設計は [issue-624-478-648-design.md](issue-624-478-648-design.md) にある。この文書は「何を満たすか」だけを扱う。 - -**3 つの課題は 2 本の Pull Request で直す**(設計文書の決定 1)。P4 は #624 と、#583 のうち収束の誤りの部分を -直す。P5 は #478 と #648 を直す。マージは P4 → P5 の順である。受け入れ条件も Pull Request ごとに分ける。 - -**P5(#478 / #648、AC10〜AC30)は [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) が置き換える**(親 #727 の設計、2026-09-19)。置き換え先は、参加する CLI が 1 者でも使えないと収束ループを開始できない形と、再開で渡した引数が黙って無視される形を、両 Skill が共有する共通層で直す要求である。対応は置き換え先の「既存の受け入れ条件との対応」にある。この文書の P5 の節は記録として残し、書き換えない。 - -## 目的 - -- 反証する担当がいない指摘が、数える区分から黙って落ちなくなる。1 者で回しても、起動し直した担当の指摘でも、 - 修正を要する指摘が残ったまま収束しない -- 認証できない担当や、打ち切りが分かっている担当を外し、使える担当だけでレビューを回せる -- 中断した収束ループを、引数で進め方を変えて再開できる。反映しなかった引数は出力で分かる - -## 対象範囲 - -含む: - -- `state.py` の収束の数え方(区分の判定、証拠集約の印の外し方)(P4) -- `lib/assignment.py` の `review_assign` と、除外・使える者の一覧の算出(P5) -- `lib/auth.py` に止めない認証の確認を足す(P5) -- `state.py` の `init` の新規と再開の経路、`start-round` の担当の決め方、前ラウンドの検査、`report`(P5) -- `cross-review` の `SKILL.md` と `docs/`(01 / 04 / 05 / 06)、テスト(P4 / P5) - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| `cross-refactoring` の除外の引数 | 実装担当を外す規則と組むと、使える者が 2 者のときにレビュー担当が 1 者になる。著者を含めて回す規則(#478 のコメント)は判定とプロンプトに及び、D-C の範囲と重なる(設計文書の決定 10)。#664 へ切り出した | -| 起動した後に分かる使えなさ(利用上限・モデルの 404)で担当を自動で外すこと | 分類は D-A(#619)が作る。この変更は利用者が `--exclude` で外す入口だけを作る(決定 19) | -| 監視の上限・結果のファイル化・`NO_RESULT` の理由・`JUDGE_RC -eq 7` の分岐 | D-A(#662 #598 #537 #619 #584 #583)の範囲 | -| #583 の投稿の重なり | D-A の範囲。この変更が扱うのは #583 のうち収束の誤りの部分だけである | -| 担当 2 者で、相手が反証で `support` を返さない単独の指摘を数えないこと | #156 の設計どおりであり、#624 の対象外と issue に書かれている | -| 再開で `--verify-command` を空へ戻す手段 | 置き換えはできる。空へ戻す要求は出ていない | -| クラス図 | 型を追加・変更しない(関数と辞書で組んだ状態ファイルを扱う)。状態ファイルの形は契約文書のデータ構造の節が持つ | -| システムの文脈・配置の図 | 動くのは `state.py` の 1 プロセスで、外部との出入り(`gh` と認証の確認コマンド)は変わらない | -| `CHANGELOG.md` と版数 | 配布の工程が書く | - -## 受け入れ条件(P4: #624 と #583 の収束の部分) - -**誰にも誤りを示されていない `major` を数える側へ改めるため、この節の受け入れ条件(P4: AC1〜AC9)は [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) が持つ。** 根拠を欠く指摘(AC3)と相手が支持しなかった指摘(AC6)を数えない、としていた条件を数える側へ改めた(親 #732)。#583 の投稿の重なりは #730 の設計が持つ。以下は 2026-09-15 時点の記録として残す。 - -反証する担当がいない指摘を数える: - -- [ ] AC1: 担当が 1 者(`only: "codex"`)で、証拠集約の印が付いたラウンドがある。そのラウンドに根拠を持つ `major` の - 指摘が 1 件あり、反証は 0 件で、前のラウンドは無い。このとき `_new_finding_count` が `(1, True)` を返し、`judge` が終了コード 2 で - 終わる(変更前は `(0, True)` と終了コード 0。issue の再現) -- [ ] AC2: AC1 の指摘が `minor` のとき、`_new_finding_count` は `(0, True)` を返す。担当 2 者のときと同じく、 - `minor` 以下は数えない -- [ ] AC3: AC1 の指摘が根拠を持たないとき、`_new_finding_count` は `(0, True)` を返す。根拠を持たないとは、 - `evidence` か `falsification` が空であることをいう -- [ ] AC4: 担当が `agy` + `kiro` で印が付いたラウンドに、`agy` の根拠を持つ `major` が反証 0 件のまま入っている。 - 起動し直した担当の指摘が、反証を取り込んだ後に取り込まれた形である。このとき `_new_finding_count` が `(1, True)` を - 返す(#583 の収束の部分) - -反証の取り直しで揃わないときは印を外す: - -- [ ] AC5: 印が付いたラウンドで `collect-critiques` を実行し、反証が揃わない(終了コード 7)。このとき - `evidence_rounds` からそのラウンドの番号が消え、`_new_finding_count` は payload の全件を数える。取り直した後も - 揃わないとき(2 度目)も印は付かない - -退行しない: - -- [ ] AC6: 担当 2 者で、相手が単独の根拠を持つ `major` へ `insufficient_evidence` か `out_of_scope` を返した指摘は - 数えない。`support` なら数え、`refute` なら `rejected` になる -- [ ] AC7: `origin_runtimes` が 2 者の指摘の区分は変わらない(根拠を持つ `major` は数え、`minor` は数えない) -- [ ] AC8: 印を持たないラウンドの数え方(payload の全件)と、実行検証の区分(`reproduced` / `not_reproduced`)は - 変わらない。`tests/test_classify_findings.py` の既存のテストが、期待値を変えずに通る - -文書: - -- [ ] AC9: `docs/06-evidence.md` の区分の表の順 4 が、「反証を受けた単独の指摘だけが `support` を求められる」条件を - 書く。同じ文書の「走らせる順序」の節が、取り直しで揃わないときに印を外すことを書く。`docs/05-pool-and-convergence.md` の - 終了基準が、担当 1 者と起動し直した担当の指摘の数え方を書く。次の 2 つがそれぞれ 1 行以上を出す - - ```bash - grep -n "反証を受けた" plugins/ndf/skills/cross-review/docs/06-evidence.md - grep -n "印を外す" plugins/ndf/skills/cross-review/docs/06-evidence.md - ``` - -## 受け入れ条件(P5: #478 使える担当だけで回す) - -認証の確認を把握にする: - -- [ ] AC10: ホスト `claude` で `kiro` の認証の確認が失敗する。`init` は終了コード 0 で状態ファイルを作る。 - `available_reviewers` は `["codex", "agy"]` で、`unavailable_reviewers` は `kiro` と失敗の理由を持つ。 - 標準エラーに `kiro` を外したことが 1 行出る -- [ ] AC11: `--require-all` を付けると、AC10 と同じ状態で `init` が終了コード 1 で終わり、状態ファイルを作らない -- [ ] AC12: `NDF_SKIP_AUTH_CHECK=1` のとき、`available_reviewers` は母集合から `--exclude` で外した者を除いた全員に - なる。確認を飛ばしたことが出力に残る - -除外の引数: - -- [ ] AC13: ホスト `claude` で `--exclude agy` を渡す。`agy` の認証を確かめず、`excluded_reviewers` が `["agy"]`、 - `available_reviewers` が `["codex", "kiro"]` になる。`--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ - 状態ファイルを作る -- [ ] AC14: 母集合の外(ホスト自身)を `--exclude` に渡す。または `--only codex --exclude codex` を渡す。 - どちらも `init` が終了コード 1 で終わり、状態ファイルを作らない - -使える者の数で分ける: - -- [ ] AC15: 使える者が 3 者のとき、`start-round` が返す担当は変更前と同じ輪番になる。4 つのホストとラウンド 1〜12 - の全組で、`review_assign(r, review_pool(host))` が変更前の `review_assign(r, host)` と一致する -- [ ] AC16: 使える者が 2 者(`codex` / `kiro`)のとき、ラウンド 1〜4 の `start-round` がすべて `codex kiro` を返す -- [ ] AC17: 使える者が 1 者のとき、`init` は終了コード 0 で終わり、観点が 1 つになることを 1 行出す。 - `start-round` はその 1 者を返す -- [ ] AC18: 使える者が 0 者のとき、`init` が終了コード 1 で終わり、状態ファイルを作らない - -退行しない: - -- [ ] AC19: `available_reviewers` を持たない状態ファイルは、この変更の前に始めた実行である。このとき `start-round` が - 返す担当は変更前と同じになる。`host` も持たない状態ファイルは `codex` / `agy` のままである -- [ ] AC20: `cross-refactoring` の担当と認証の関門が変わらない。`cross-refactoring/tests/test_assignment.py` の `assign` の期待値 - (`EXPECTED_FOR_CLAUDE`)が変えずに通る。`check_auth` は失敗が 1 件でもあれば中断させる - -報告: - -- [ ] AC21: `state.py report` が、使える者・`--exclude` で外した者・認証を通らなかった者・再開で変えた値を、 - 1 行ずつ出す。認証を通らなかった者には理由を添える。4 つとも空でない状態ファイル(`resume_changes` が - 1 件以上)で 4 行とも出る - -## 受け入れ条件(P5: #648 再開で引数を反映する) - -明示的に渡した引数だけを反映する: - -- [ ] AC22: `max_rounds: 12` の状態ファイルで、再開の `init` に `--max-rounds 20` を渡す。`max_rounds` が 20 になり、 - 反映したことが `12 → 20` の形で 1 行出る。`resume_changes` には `field: "max_rounds"` / `from: 12` / `to: 20` の - 要素が 1 件積まれる。`--rotate-after` / `--verify-command` / `--verify-exit-code` も同じく反映される。 - `--verify-command` と `--verify-exit-code` は置き換え、継ぎ足さない -- [ ] AC23: 再開の `init` に `--max-rounds` などの引数を渡さない。このとき状態ファイルの次の 10 項目が変わらず、 - `max_rounds: 20` の状態ファイルが 12 へ戻らない - - | 区分 | 項目 | - | --- | --- | - | 進め方 | `max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` | - | 担当 | `only` / `excluded_reviewers` / `available_reviewers` / `unavailable_reviewers` / `require_all` / `auth_skipped` | -- [ ] AC24: `only: null` の状態ファイルで、再開の `init` に `--only codex` を渡す。`only` が `codex` になり、次の - `start-round` が `codex` だけを返す。記録を持つ過去のラウンドの `_round_reviewers` は記録のまま変わらない -- [ ] AC25: `only: "codex"` の状態ファイルで、再開の `init` に `--only none` を渡す。`only` が `null` になり、使える者が - 作り直される。`--exclude none` は `excluded_reviewers` を空にする -- [ ] AC26: 再開の `init` に `--exclude agy` を渡す。母集合から `agy` を除いた全員の認証を確かめ直し、 - `available_reviewers` から `agy` が消え、次の `start-round` が `agy` を返さない。担当に関わる引数(`--only` / - `--exclude` / `--require-all`)を渡さない再開では、認証を確かめ直さない -- [ ] AC27: `host: "claude"` の状態ファイルで、再開の `init` に `--host codex` を渡す。`host` は `claude` のまま変わらず、 - 反映しないことが 1 行出る。同じ値の `--host claude` では何も出ない - -手順書の骨組み: - -- [ ] AC28: `SKILL.md` と `docs/01-state-and-review.md` の骨組みが、`start-round` が返した担当をそのまま使う。 - 起動・監視・取り込み・反証の担当を `$ONLY` で絞らない。2 ファイルに対する `grep -n 'ONLY' ` の出力が、`init` へ - 引数を渡す行と引数の説明の行だけになる - -前のラウンドの検査: - -- [ ] AC29: 前のラウンドが `verdict` を持たず、担当 `agy` + `kiro` の両者が `REQUEST_CHANGES` で、修正の記録が無い。 - このとき `start-round` は終了コード 5 で止まる。変更前は旧来の 2 者 `codex` / `agy` で数え、`codex` を結果なしと - 読んで通していた - -文書: - -- [ ] AC30: 次の 4 ファイルが、それぞれの内容を書く - - | ファイル | 書く内容 | - | --- | --- | - | `SKILL.md` | 引数の表と `argument-hint` に `--exclude` と `--require-all` がある。`--only` の説明から「デバッグ用」が消える | - | `docs/05-pool-and-convergence.md` | 使える者の数による分岐と、除外 | - | `docs/04-contracts.md` | 状態ファイルの 6 項目(`available_reviewers` / `excluded_reviewers` / `unavailable_reviewers` / `require_all` / `auth_skipped` / `resume_changes`) | - | `docs/01-state-and-review.md` | 再開で反映する引数と、反映しない引数 | - -## 受け入れ条件(両方) - -- [ ] AC31: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC32: 次の 3 つが終了コード 0 で終わる - - ```bash - bash scripts/build-runtime-plugins.sh --check - claude plugin validate . - python3 scripts/check-skill-frontmatter.py - ``` - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 可用性 | 母集合の 1 者が使えないことで、収束ループを開始できない状態にならない(AC10) | -| 性能・拡張性 | 認証の確認の回数は、新規の `init` で変更前を上回らない(除外した者は確かめない)。再開で増えるのは担当に関わる引数を渡したときだけで、母集合の最大 3 者である | -| 運用・保守性 | 担当が欠けたまま収束したことが、`report` の出力だけで分かる(AC21)。再開で反映しなかった引数が出力に出る(AC27) | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 認証に失敗する CLI がある利用者 | `init` が止まらず、使える者で回る。従来の関門は `--require-all` で選べる | -| `--only` で回していた利用者 | 反証する担当がいない根拠付きの `major` が数えられ、最初のラウンドで収束しなくなる | -| 状態ファイルの形 | 最上位に 6 項目が増える。無い状態ファイルは従来の担当の決め方で読む | -| `init` の引数 | `--exclude` / `--require-all` が増え、`--only` が `none` を取る。既定値は変わらない(`--max-rounds 12` / `--rotate-after 8`) | -| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC29) | -| `cross-refactoring` | 変わらない | - -## 前提 - -| # | 前提 | -| --- | --- | -| 1 | P1〜P3(D-A)が先に `develop` へ入る。P4 / P5 の `state.py` の変更はその上に載せる。P4 の規則は、起動し直しの経路が証拠集約を通る形(P3)でも通らない形でも成り立つように決める | -| 2 | 担当は最大 2 者である(`review_assign` が 3 者以上から 1 者を外すため)。反証を返せる担当は、提案者を除いて最大 1 者になる | -| 3 | 再開の `init` の後、骨組みは必ず `start-round` で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | -| 4 | 認証の確認コマンド(`AUTH_PROBES`)とその判定は変えない | - -前提 3 の根拠: `SKILL.md` の骨組みは `init` の直後に `start-round` を呼ぶ。`start-round` は常に -`len(rounds) + 1` のラウンドを開く(`state.py` の `cmd_start_round`)。 - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認 | P5 の後、`--exclude agy` を付けた `cross-review` を 1 本の Pull Request で回し、`agy` が 1 度も起動しないことを `report` で見る | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 担当の決め方は `plugins/ndf/scripts/lib/assignment.py`、認証は `lib/auth.py` に置く(`cross-refactoring` と共有する共通層)。判定は `state.py` に置き、骨組みは結果を使うだけにする | -| コーディング規約 | 状態ファイルを最小の形で組み、関数を直接呼んで確かめる(`AGENTS.md` の DO) | -| テスト戦略 | 既存の形(`tests/conftest.py` の `state_mod` で `state.py` を読み込み、GitHub を呼ぶ関数を差し替える) | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査 | -| 確認してから行う | `init` の既定を「認証の失敗で止める」から「使える者で回す」へ変えること(利用者の判断を仰ぐ) | -| 行わない | `cross-refactoring` の担当の決め方と `refactor_lib` の変更、監視と起動の変更 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 母集合 | 全ランタイム − ホストの 3 者(`review_pool(host)`) | -| 使える者 | 母集合から、`--exclude` で外した者と認証を通らなかった者を除いた一覧(`available_reviewers`) | -| 担当 | そのラウンドにレビューする者。`start-round` がラウンドへ記録する | -| 証拠集約の印 | `evidence_rounds` に載るラウンド番号。統合・実行検証・反証を通り切ったラウンドに付く | -| 反証を受けた指摘 | 提案者でない担当の有効な反証(`CRITIQUE_VERDICTS` のいずれか)が 1 件以上結ばれた指摘 | -| 単独の指摘 | `origin_runtimes` が 1 者の指摘 | -| 再開 | 状態ファイルが残り `final` が `null` のときの `init`(`_resume_from_state`) | - -## 依頼(原文) - -### #624 - -> `cross-review` を `--only codex` で 1 者だけにして回すと、codex が `REQUEST_CHANGES` で新しい指摘を投稿したラウンドでも、 -> `judge` が収束と判定する(ndf 10.10.1、2026-09-13)。 -> -> | 候補 | 内容 | -> | --- | --- | -> | 反証の担当がいないラウンドは絞り込まない | 反証が 0 件のときは全件を数える(`_evidence_completed` を偽に扱う) | -> | `--only` では intent に従う | 担当が 1 者なら、全員 pass(`round_passes`)だけを収束の条件にする | -> | 印を指摘単位で持つ | 証拠集約の印をラウンド単位ではなく指摘単位(反証の対象になったか)で持ち、反証の対象にならなかった指摘は絞り込まない。#583 の起動し直しの経路もまとめて塞がる | - -### #583(収束の部分) - -> **起動し直した担当の指摘も、反証を受けないまま数えられない。** 証拠集約の印(`_mark_evidence_round`)は、 -> 1 回目の経路で反証を取り込んだ時点でラウンドに付く。起動し直した担当の指摘はその後に取り込まれるため、 -> 反証の対象にならないまま `_new_finding_count` の絞り込みにかかり、単独の major は `insufficient_evidence` へ落ちる。 - -### #478 - -> **認証確認を「関門」から「導入状況の把握」へ変える。** 使える者でレビューし、使えない者は最初から数えない。 -> -> 1. `init` で母集合の各ランタイムの認証を確かめ、**通った者の一覧を `state.json` へ記録する**(`available_reviewers`) -> 2. **明示的に外す手段を用意する**(例: `--exclude agy`)。**認証は通るが実行で落ちる担当を外す用途でも使う。** -> 3. 使える者の数で分岐する(3 者: 現行どおり / 2 者: 毎ラウンドその 2 者 / 1 者: 警告して 1 者 / 0 者: 失敗) -> 4. `review_assign()` は「**使える者が 3 者以上のときだけ 1 者を外す**」に変える -> 5. 全員揃っていることを要求したい運用のために `--require-all` を用意する -> 6. 完了報告に「このループに参加したのは誰か」を出す - -### #648 - -> `/ndf:cross-review` を中断・再開すると、`state.py init` に渡した `--only` / `--max-rounds` / `--rotate-after` / -> `--verify-command` / `--verify-exit-code` / `--host` が**黙って無視される**。 -> -> 再開経路でも、**明示的に渡された引数だけ**を状態ファイルへ反映する。反映できないと決めた引数(例えば `--host`)は、 -> **渡されたら 1 行知らせる**。黙って捨てない。 - -(各 issue の本文から抜粋。全文は `gh issue view 624` / `583` / `478` / `648`) diff --git a/issues/issue-647-592-553-design.md b/issues/issue-647-592-553-design.md deleted file mode 100644 index b6dc51c9..00000000 --- a/issues/issue-647-592-553-design.md +++ /dev/null @@ -1,499 +0,0 @@ -# #647 / #592 / #553: 適用ラウンドに試行の上限を置き、帰属行の後ろのトレーラーを読む - -要求と受け入れ条件は [issue-647-592-553-requirements.md](issue-647-592-553-requirements.md) にある。 -この文書は「どう作るか」だけを扱う。 - -**実装は 1 本の Pull Request(P6)にまとめる**(決定 1)。マイルストーン 21 の順序では P3 の後に載せ、 P4・P5 とは並行してよい。 - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| 1 | 結果を残さない担当の群を、担当を替えて 1 回だけ開き直し、2 回目も残さなければ取り消す | cross-refactoring を回す進行側 | -| 2 | 採用 0 件の提案ラウンドと項目の無い群で、適用担当を起動せずに次へ進む | 同上 | -| 3 | 修正の結果を群の担当から読み、結果が無ければ修正ラウンドを 1 つ進める | 同上 | -| 4 | 帰属行の段落が後ろに付いたコミットから必須トレーラーを読む | 同上(claude が適用担当の群) | -| 5 | 適用・修正の監視が、テストの実行中の無出力で担当を打ち切らない | 同上 | - -## 決定の記録 - -決定 1〜8 は #647 と修正ラウンド、決定 9〜10 は #592、決定 11〜12 は #553、決定 13 は無進捗の打ち切りを扱う。 - -### 決定 1: 3 課題と `merge-fix` の担当の食い違いを 1 本の Pull Request で直す - -#647 と #592 は同じ `next-apply-round` → `merge-apply` の繰り返しで止まらず、#553 は同じ適用の検証で群を落とす。 -`merge-fix` の食い違いは、#647 を直した後に同じ形(結果を取り込めない担当で修正と検証が往復する)で残る。いずれも `refactor_lib/commands/apply.py` -と `SKILL.md` の骨組みを触るため、分けると同じ箇所を 2 度変える。 - -`merge-fix` を別の issue に起票する形は採らない。起票しても直す場所と時期が P6 と同じになる。 - -### 決定 2: 同じ群の試行の上限を 2 回の固定値にし、引数を足さない - -2 回目は別の担当が試すため(決定 3)、2 回とも結果を残さなければ担当ではなく群の側を疑える。3 回以上にしても、輪番の 4 者のうち壊れた者に当たる確率が上がるだけである。 -値は `vocabulary.py` の `MAX_APPLY_ATTEMPTS` に置く。 - -`--max-apply-attempts` を足す形は採らない。`SKILL.md` は上限を 2 つ置くとどちらで止まったかを読み解く必要が出るとしており、 -利用者が変えたい理由も見当たらない。止まった理由は群の記録(`drop_reason`)が持つ。 - -### 決定 3: 2 回目の試行は次の輪番の担当が行い、利用上限でも進行全体を止めない - -結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の STALLED 4 回、claude の 429 の 3729 回)。 -同じ担当で開き直しても直らない。担当を替えれば、壊れた CLI が 1 者でも他の者が群を適用できる。替える先は、`apply_seq` を 1 ずつ進めて輪番の担当を引き、 -その群で失敗した担当のどれとも違う担当が出た最初の番号の担当である。引く式は群を割り当てたときと同じものを通す(決定 8)。 - -1 つ進めるだけにしない。`apply_seq` は提案ラウンドの全群を割り当てた後の番号で、群自身の番号ではない。輪番は 4 者で 1 周するため、 -群が 4 つあり先頭の群が失敗すると、次の番号の担当は失敗した担当と同じになる(`assign(1)` と `assign(5)` はどちらも codex。 -「実測」の節)。 - -利用上限(429)で進行全体を止める形は採らない。止めると他の 3 者で進められる群まで止まり、再開すると同じ者にまた当たる。429 は監視が早期の致命として 15 秒前後で打ち切る(D-A -の P3)ため、担当を替える 1 回の費用は小さい。利用上限だった事実は理由の名前(`usage_limit`)として群の記録と見送りの理由に残る。 - -### 決定 4: 開き直しの判定は `merge-apply` が持ち、骨組みは監視の終了コードで分岐しない - -`merge-apply` は結果ファイルを読めたかどうかを自分で知っている。監視の終了コードで分岐しても、結果ファイルが後から書かれた場合(#584)や、 -監視は `OK` でも JSON が壊れている場合は結果ファイルの側で決めるしかない。判定を 1 か所に置くと、骨組みは `|| continue` のまま変わらない。 - -骨組みで監視の終了コードを受けて `merge-apply` へ渡す形は採らない。渡しても振る舞いは変わらず、使い道は理由の記録だけである。 -理由は D-A が P1 で残す監視の結果ファイルから読む(「他の設計との契約」)。 - -### 決定 5: 試行番号は `next-apply-round` が進め、前の試行が失敗で閉じたときだけ進める - -`next-apply-round` が `pending` の群を開くとき、失敗した試行の記録の数が試行番号と等しければ試行番号を 1 進める。 -等しくなければ(取り込みの前に進行が止まった再開)そのまま開く。 - -`merge-apply` は同じ試行番号の失敗の記録が既にあれば、結果ファイルを読まずに、記録を足さず同じ終了コード 2 を返す。読んでから判定する形は採らない。 -結果ファイルの名前(`{agent}-apply-r<提案ラウンド>`)は群の番号を持たず、消すのは `launch-cli.sh` の起動時だけである。 -担当を替えた直後に叩き直すと、替えた先の担当が同じ提案ラウンドの先行の群で残した結果を読み、検証へ進んで群を落とす。進行はどこで止まっても叩き直せることが前提である(`docs/02-apply-and-review.md`「叩き直しても同じ判定を返す」)。 -叩き直しを 2 回目の試行と数えると、1 回の失敗で群を落とす。 - -開いた回数だけを数える形は採らない。進行側が落ちて再開しただけで試行が進む。 - -### 決定 6: 結果を残さない試行のコミットは、開き直す前に取り消す - -`next-apply-round` は `pending` の群を開くたびに起点を HEAD へ置き直す(`apply.py:304-309`)。 -結果を残さない担当がコミットだけ作って止まると、そのコミットは次の試行の起点より前に入り、検証を受けないまま次の群の push で Pull Request に出る。 -範囲にコミットがあれば、既存の `_revert_unverified_apply_round`(`apply.py:555-598`)から取り消しの本体(`revert_item_commits` -と起点の更新)だけを切り出して共有し、起点を取り消し後の HEAD にする。関数をそのまま呼ぶ形は採らない。群を `dropped` にして項目を見送るため、 -1 回目の失敗で群が閉じる。 - -### 決定 7: 着手前テストの未確認と範囲の未確定は中断(4)にする - -`init` は着手前テストが失敗していれば止まるため、`merge-apply` で `green` でないのは状態ファイルが壊れたときだけである。 -範囲を確定できないのも git の状態が壊れたときである。どちらも群を替えても直らず、 `SKILL.md` の終了コードの表は既に「範囲を確定できない」を 4 と書いている。 - -試行の上限へ含める形は採らない。全ての群が 2 回ずつ同じ理由で落ち、全件が見送りになってから気づく。 - -### 決定 8: 作業を任せる担当の決定は `rounds.impl_for_seq` の 1 つを通す - -`rounds.impl_for_seq(state, seq)` を新設し、輪番の通し番号から担当を引く呼び出しはその中だけにする。呼ぶのは次の 3 か所で、 -いずれも `apply_seq` を進めて CLI に作業を任せる担当を決める。 - -| 呼び出し元 | 決めるもの | -| --- | --- | -| `apply._assign_apply_rounds_to_state` | 群を割り当てたときの担当 | -| `apply._close_failed_attempt` | 結果を残さなかった群の交代先 | -| `gate._final_fix_impl`(`gate.py:128` を置き換える) | 最終ゲートの修正担当 | - -置き場所を `rounds.py` にするのは、`apply.py` と `gate.py` の両方が読む層だからである(`commands` どうしの取り込みを作らない)。 -D-B が除外(#478)を足すとき、変える呼び出しは 1 か所で済む。`assignment.py` は変えない。 - -`setup.cmd_start_round`(`setup.py:467`)の `assignment.assign` は通さない。引くのは提案ラウンドの記録上の担当で、 -骨組みは適用の前に `next-apply-round` が返す群の担当で `IMPL` を上書きするため、この担当は CLI を起動しない。 - -### 決定 9: 採用 0 件の提案ラウンドでは群を作らない - -`rounds.apply_groups` は、`apply_rounds` が空の配列のときも古い版の状態として扱う(`rounds.py:108-110` の `if groups:`)。 -そのため、ラウンド全体を 1 つの群にしている。`merge-proposals` は採用 0 件で `apply_rounds = []` を書くため、 -ここで項目 0 件の群が生まれる。鍵が無い(`None`)ときだけ古い版として扱い、空の配列はそのまま返す。 - -`merge-proposals` がテスト整備の採用 0 件で終了コード 2 を返す形は採らない。2 は構造改善の繰り返しを終える合図で、 -テスト整備では構造改善へ進む前に抜けてしまう。 - -### 決定 10: 項目の無い群は開かずに取り消し、取り込み済みで採用 0 件の群も取り消しへ直す - -決定 9 の後も、既に項目の無い群を持つ状態ファイル(rf587)は残る。`next-apply-round` は `items` が空の `pending` の群を `dropped`(`drop_reason: empty`)にして次の群を探す。 -`merge-apply` の取り込み済みの判定で採用 0 件だったときは、群が `dropped` でなければ `dropped` にしてから終了コード 2 を返す。 - -### 決定 11: トレーラーは、末尾から続くトレーラーの段落を git の判定で読む - -`commit_trailers` はメッセージを空行で段落に分け、末尾の段落から前へ向かって、1 段落ずつ `git interpret-trailers --parse` に掛ける。 -git がトレーラーの段落と判定しなかった段落で止め、それまでに読んだ段落の値を合わせる。同じ鍵は末尾に近い段落の値を採る。**1 段落目(題名)は掛けない。** -掛けると、本文がトレーラーだけのコミットで `Refactor: …` の形の題名をトレーラーとして読む(「実測」の節)。 - -| issue の案 | 採否 | 理由(「実測」の節) | -| --- | --- | --- | -| `git interpret-trailers --parse` へ替える | 採らない | `%(trailers:only,unfold)` と同じく最後の段落しか読まない | -| 全文から `^: ` を拾う | 採らない | 散文の段落にある `Round: …` の形の行を拾う | -| 雛形に最後の段落へ置くよう書く | 補助として採る(決定 12) | 実装担当が従わなければ落ちる | -| 進行側が `--amend` でトレーラーを足し直す | 採らない | SHA が変わり、結果ファイルの申告との対応が切れる | - -段落ごとの判定を git に任せるため、トレーラーの定義(区切り文字・折り返し・25% の規則)を自前で持たない。散文の段落で止まるため、本文中の `Key: value` の形の行は読まない。 - -`claude -p` に `--settings` で帰属行を消させる形は採らない。帰属行を書くのはモデルで、利用者の `CLAUDE.md` の指示でも足される。 -共通層の `launch-cli.sh` は cross-review も使う。 - -### 決定 12: 雛形のコミットの規約にも、必須トレーラーを最後の段落に置くことを書く - -進行側の検証は決定 11 で通る。一方、人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計すると、 -最後の段落しか読まない。`docs/02-apply-and-review.md` は、この集計を理由にトレーラー形式を選んでいる。帰属行を同じ段落に続けて書けば、 -git の標準の読み方でも取れる。 - -### 決定 13: 無進捗の許容を `--test-timeout` + 900 秒にし、雛形に進捗マーカーを足す - -適用・修正の担当はテストを 1 回実行し、その間は何も出力しない。テストの上限は `--test-timeout`(既定 900)である。 claude は `--output-format json` -のため、完了まで出力しない(監視の既定の許容 900 秒はこのため)。 2 つを足した値が、正常に動いていても出力が無い最長の時間になる。`init` がこの値を `IMPL_STALL_TIMEOUT` -として出し、骨組みの適用・修正・最終ゲートの修正の監視が `--stall-timeout` に渡す。`monitor.py` は変えない。 - -**監視の上限(`--timeout`)は渡さない。** 上限は D-A の P2 が `--phase` と `lib/limits.py` で工程ごとに決め(既定 3600 秒)、修正と -最終ゲートの修正が 420 秒で打ち切られる件も P2 が直す。P6 は P2 が入れた `--phase` の呼び出しに `--stall-timeout` だけを足す。 - -雛形の進捗マーカーは、次に STALLED が出たときに担当が動いていたかを読むためにも足す。rf646 の agy のログは作業ツリーとともに消えており、 -この設計の時点では確かめられなかった(未確認 1)。 - -**`--test-timeout` は、`apply` / `fix` / `final-fix` の監視の上限 − 900 未満を前提にする**(P2 の既定 3600 秒なら 2700 未満)。 -監視の上限は `MONITOR_TIMEOUT_<担当>` / `MONITOR_TIMEOUT` で上書きされうる。この値以上にすると許容が監視の上限以上になり、監視の上限で -打ち切られ、P2 の監視が担当名と 2 つの値を警告する(D-A の AC33)。D-A の AC31 が固定する「無進捗の許容 < 監視の上限」は担当ごとの -既定の許容の組で、この許容(既定 1800 < 3600)はその順序を崩さない。監視の上限を許容から導く形は採らない。上限の表は P2 の -`limits.py` が 1 つだけ持ち、適用の所要の実測も無い(未確認 6)。 - -進捗マーカーだけで足りるとする形は採らない。テストの実行中はマーカーを書けない。 `MONITOR_STALL_AGY` などの環境変数に委ねる形も採らない。 -担当ごとに値を覚えさせることになり、cross-review にも効く。 - -## 実測 - -### #647: 結果を残さない群が開き直され続ける - -一時のテストファイルで `cmd_next_apply_round` → `cmd_merge_apply` を直接 4 回呼んだ(コミットしない)。 -補助は `tests/conftest.py` の `no_git` / `patch_lib` と、`test_merge_apply.py` の `_state_with_items` -/ `git_facts` である。 - -| 状態 | 開いた群 | `merge-apply` の終了コード | 群の `status` | -| --- | --- | --- | --- | -| 群 2 つ(agy / codex)、結果ファイルなし | `[1, 1, 1, 1]` | `[2, 2, 2, 2]` | `pending`, `pending` | -| 同上で着手前テストが `red`(2 回) | `[1, 1]` | `[2, 2]` | `pending`, `pending`(項目は `blocked`) | - -輪番の担当は `plugins/ndf/scripts/lib/assignment.py` の `assign` を `seq` 1〜8(ホスト claude)で引くと `codex, agy, kiro, claude, codex, agy, kiro, claude` -だった。4 で 1 周するため、`apply_seq` を 1 進めるだけでは失敗した担当へ戻ることがある(決定 3)。 - -### 範囲へ入れたもの: `merge-fix` が提案ラウンドの担当を読む - -群の担当 agy、提案ラウンドの担当 codex で `agy-fix-r1-result.json` を置き、`cmd_merge_fix` を 3 回呼んだ。 -3 回とも終了コード 2 で `❌ codex の結果ファイルがありません`、`fix_rounds` は 0 のままだった。 `merge-fix` は `entry["impl"]`(`converge.py:463`)を読み、 -骨組みは `launch-cli.sh "$IMPL" fix` で群の担当を起動する。 - -### #592: 採用 0 件で項目の無い群が開く - -テスト整備ラウンドで提案 0 件の状態から `cmd_merge_proposals`(終了コード 0)→ 上と同じ 4 回を呼んだ。 - -| 結果ファイル | 開いた群 | 終了コード | 群 | -| --- | --- | --- | --- | -| なし | `[1, 1, 1, 1]` | `[2, 2, 2, 2]` | `items: []`、`status: pending` | -| `{"items": []}` | `[1, 1, 1, 1]` | `[2, 2, 2, 2]` | `items: []`、`status: applied`(rf587 と同じ形) | - -`apply_groups` を「鍵が `None` のときだけ群を作る」に差し替えて、同じ手順を試した。`next-apply-round` は 1 回目で終了コード 1 を返し、 -`apply_rounds` は `[]` のままだった。 - -### #553: 段落ごとの読み取り - -git 2.53.0 の一時リポジトリでコミットを作り、2 つの読み方を比べた。1 つは `git log -1 --format='%(trailers:only,unfold)'` である。 -もう 1 つは決定 11 の読み方の試作で、Python から段落ごとに `git interpret-trailers --parse` を呼ぶ。 - -| メッセージの末尾 | `%(trailers:only,unfold)` | 試作 | -| --- | --- | --- | -| 必須 4 つ / 空行 / `Co-Authored-By` | `Co-Authored-By` だけ | 5 つ | -| 必須 4 つ / 空行 / `Co-Authored-By` + `Claude-Session` | 帰属行 2 つだけ | 6 つ | -| 必須 4 つと `Co-Authored-By` が同じ段落 | 5 つ | 5 つ | -| `Round: 本文の説明行` / 散文 / 必須 4 つ / 帰属行 | 帰属行だけ | 帰属行と必須 4 つ(`Round` は `4`) | -| 散文 + `Item-Id: R9-999` の段落 / 帰属行 | 帰属行だけ | 帰属行だけ | - -`printf 'Refactor: 重複を除く\n\nRefactor: 重複を除く\n' | git interpret-trailers --parse` は `Refactor: 重複を除く` -を返した。題名を段落として git に掛けると、題名をトレーラーとして読む。 - -`git log -1 --format=%B | git interpret-trailers --parse` は 1 行目と同じく帰属行だけを返した。 -`interpret-trailers --parse` へ替えるだけでは効かない。 `git interpret-trailers --parse` は題名の無い入力(段落 1 つだけ)ではトレーラーを返さなかったため、 -試作は `<題名>\n\n<段落>` の形で渡している。 - -## 構成要素 - -| 要素 | 責務 | 課題 | -| --- | --- | --- | -| `rounds.apply_groups`(変更) | 鍵が無いときだけ古い版として群を 1 つ作る。空の配列はそのまま返す | #592 | -| `rounds.current_group`(変更) | 群が 1 つも無いときは中断(4)する | #592 | -| `apply.cmd_next_apply_round`(変更) | 項目の無い `pending` の群を取り消して飛ばす。開くときに試行番号を進める(決定 5) | #647 #592 | -| `apply.cmd_merge_apply`(変更) | 取り込み済みで採用 0 件の群を取り消しへ直す。着手前テストと範囲の検査を結果の読み取りより前に置き、4 で中断する。結果を読めなければ `_close_failed_attempt` へ渡す | #647 #592 | -| `apply._close_failed_attempt`(新設) | 範囲のコミットを取り消し、失敗した試行を記録する。上限未満なら担当を替え、上限なら群を取り消して項目を見送る | #647 | -| `rounds.impl_for_seq`(新設) | 輪番の通し番号から担当と要求モデルを返す。作業を任せる担当を決める呼び出しはすべてこれを通す(決定 8) | #647 | -| `gate._final_fix_impl`(変更) | 最終ゲートの修正担当を `rounds.impl_for_seq` で引く | #647 | -| `apply._monitor_reason`(新設) | `monitor_outcome.read_outcome(tmp_dir, "-apply-r")` の `reason` が `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` ならその値を返す。`ok` / `missing` / ファイルなしなら `load_result` の問題(`missing` / `unparsable`)を返す(D-A の AC55 と同じ規則) | #647 | -| `gitfacts.load_result`(新設) | 結果ファイルを読み、`(payload, problem)` を返す。問題は `missing`(無い)/ `unparsable`(JSON として読めない・オブジェクトでない)。中断しない | #647 | -| `gitfacts.read_result`(変更なし) | 最終ゲートの修正(`gate.py`)が引き続き使う。結果が無いときの扱いは #674 | — | -| `converge.cmd_merge_fix`(変更) | 群の担当の結果を読む。結果を読めなければ範囲を取り消し、修正ラウンドを 1 つ進めて 2 で終わる | 範囲へ入れたもの | -| `gitfacts.commit_trailers`(変更) | 末尾から続くトレーラーの段落を読む(決定 11) | #553 | -| `vocabulary.py`(変更) | `MAX_APPLY_ATTEMPTS = 2` と `IMPL_STALL_MARGIN = 900` | #647 | -| `setup._emit_init`(変更) | `IMPL_STALL_TIMEOUT` を出す | #647 | -| `prompts/apply.md` / `fix.md`(変更) | 必須トレーラーを最後の段落に置く。進捗マーカー | #553 #647 | -| `prompts/final-fix.md`(変更) | 進捗マーカー | #647 | -| `SKILL.md`(変更) | 語の表の適用ラウンドの上限、「別の上限を置かない」の段落の削除、骨組みの監視の引数と終了コード 2 の注記 | #647 | -| `docs/02-apply-and-review.md` / `docs/04-fix-and-report.md`(変更) | Step 4 と Step 6 の骨組みの監視の引数、開き直しの規則、トレーラーの読み方、修正の結果が無いとき | 全体 | - -```mermaid -graph TD - N[next-apply-round] --> G[rounds.apply_groups
current_group] - A[merge-apply] --> G - A --> LR[gitfacts.load_result] - F[merge-fix] --> LR - A --> C[_close_failed_attempt] - C --> S[rounds.impl_for_seq] - S --> AS[assignment.assign] -``` - -```mermaid -graph TD - I[init] -->|IMPL_STALL_TIMEOUT| M[monitor.py] - M --> MR[監視の結果ファイル
D-A の P1] - C[_close_failed_attempt] --> R[_monitor_reason] - R --> MR -``` - -上の図は群の判定、下の図は監視との関係を描く。雛形・文書と、`commit_trailers`(`merge-apply` の検証が呼ぶ 1 本だけ)は含めない。`assignment.assign` と監視の結果ファイルは変えない要素である。 - -### 文脈と配置 - -```mermaid -graph TD - 利用者 --> ホスト[ホストの CLI セッション] - ホスト -->|骨組みの bash| RF[refactor.py] - ホスト -->|launch-cli.sh| 担当[適用・修正の担当 CLI] - ホスト --> 監視[monitor.py] - RF --> WORK[work/ の git] - 担当 --> WORK - 担当 --> 結果[結果ファイル
progress.log] - 監視 --> 結果 - 監視 --> 記録[監視の結果ファイル] - RF --> 記録 -``` - -**配置は変えない。** すべて利用者の機械のホストのセッションから起動するプロセスで、常駐しない。図は状態ファイルと Pull Request への push を含めない。この変更で増える辺は、`refactor.py` が監視の結果ファイルを読む 1 本だけである。 - -### 変わるファイル - -```text -plugins/ndf/skills/cross-refactoring/ -├── SKILL.md # 語の表・段落の削除・骨組み -├── docs/02-apply-and-review.md # Step 4 の骨組み・開き直し・トレーラー -├── docs/04-fix-and-report.md # Step 6 の骨組みの監視の引数・修正の結果が無いとき -├── prompts/apply.md # トレーラーの段落・進捗マーカー -├── prompts/fix.md # 同上 -├── prompts/final-fix.md # 進捗マーカー -├── scripts/refactor_lib/ -│ ├── rounds.py # apply_groups / current_group / impl_for_seq -│ ├── gitfacts.py # load_result / commit_trailers -│ ├── vocabulary.py # MAX_APPLY_ATTEMPTS / IMPL_STALL_MARGIN -│ └── commands/ -│ ├── apply.py # next-apply-round / merge-apply / 補助 3 つ -│ ├── converge.py # merge-fix -│ ├── gate.py # _final_fix_impl -│ └── setup.py # _emit_init -└── tests/ - ├── test_apply_attempts.py # 新設: AC1〜AC12、AC16 - ├── test_apply_rounds.py # AC13〜AC15 - ├── test_abandon_items.py # AC17〜AC19 - ├── test_commit_trailers_git.py # 新設: AC20〜AC26(一時リポジトリ) - ├── test_init.py # AC28 - ├── test_merge_apply.py # AC34 - ├── test_final_fix.py # AC37 - └── test_skill_terms.py # AC27、AC29〜AC32 -# dev.kiro / dev.agy の配布物は bash scripts/build-runtime-plugins.sh で同期する -``` - -## データ構造(状態ファイルの群) - -状態ファイル(`cross-refactoring-rf<番号>-state.json`)の `rounds[].apply_rounds[]` に項目を足す。 -**版は上げない。** 足す項目が無い状態ファイルは、試行番号 0・失敗の記録なしとして読む。 - -| 項目 | 型 | 値 | 空のときの意味 | -| --- | --- | --- | --- | -| `attempt` | 整数 | いま開いている試行の番号。1 から | 鍵なし = 0(まだ開いていない)。`merge-apply` は 0 を 1 回目として記録し、値を 1 に書く(変更前の版で開いた群を再開したとき) | -| `failed_attempts` | 配列 | 結果を残さなかった試行。1 件 = `{attempt, impl, reason, at, reverted}` | 鍵なし = 失敗なし | -| `failed_attempts[].reason` | 文字列 | 監視の理由の名前(`stalled` など)。監視が `ok` / `missing` か結果ファイルが無ければ `load_result` の問題(`missing` / `unparsable`) | — | -| `failed_attempts[].reverted` | 整数 | その試行の範囲から取り消したコミットの数 | 0 = コミットなし | -| `drop_reason` | 文字列 | `no_result`(試行の上限)/ `empty`(項目なし) | 鍵なし = 既存の経路で取り消した、または取り消していない | -| `impl` / `impl_model` | 既存 | 担当を替えたときに書き換える。**前の担当は `failed_attempts[].impl` に残る** | — | - -`impl` を書き換えても、どの担当がどの試行で失敗したかは `failed_attempts` から読める。群を取り消したときの見送りの理由(`deferred_items[].defer_reason`)は、 -`実装担当が結果を残しませんでした(agy: stalled → codex: missing)` の形にし、改修計画の「見送った項目」の表にそのまま出る。 - -ラウンドの項目(`rounds[]`)は `fix_merged_keys` に `":missing"` の形の鍵を足す(AC19)。 - -### 群の状態の遷移 - -```mermaid -stateDiagram-v2 - [*] --> pending: merge-proposals - pending --> dropped: next-apply-round(items が空) - pending --> pending: merge-apply(結果を残さない・attempt < 2)
担当を替える - pending --> dropped: merge-apply(結果を残さない・attempt = 2) - pending --> dropped: merge-apply(未割当・検証の失敗) - pending --> applied: merge-apply(取り込んだ) - applied --> dropped: merge-apply(取り込み済み・採用 0 件) - applied --> verified: verify-round(通った) - applied --> dropped: abandon-items / merge-test-judgements - verified --> [*] - dropped --> [*] -``` - -**`pending` のまま同じ担当で開き直す遷移は無い。** この変更で足す遷移は 4 本で、`items` が空の取り消し、担当の交代、 -試行の上限での取り消し、取り込み済み・採用 0 件の取り消しである。 - -## 入出力の契約 - -### コマンドの終了コード - -| コマンド | 0 | 1 | 2 | 4 | -| --- | --- | --- | --- | --- | -| `next-apply-round` | 群を開いた(変更なし) | 残りの群が無い(変更なし。群が無いラウンドも含む) | — | — | -| `merge-apply` | 取り込んだ(変更なし) | — | **この群を取り消した、または担当を替えて開き直す**(意味を広げる) | 着手前テストが `green` でない・範囲を確定できない(2 から変更)・群が無い(新) | -| `merge-fix` | 取り込んだ(変更なし) | — | 範囲を確定できない(変更なし)・**結果を読めない(新。修正ラウンドは進む)** | 群が無い(新) | -| `verify-round` / `abandon-items` / `merge-test-judgements` | 変更なし | — | 変更なし | 群が無い(新) | - -「群が無い(新)」は、群が 1 つも無いラウンドで `current_group` を呼んだときの中断である。`next-apply-round` は `current_group` を呼ばない。 - -骨組みは `merge-apply` の 2 で `continue` し、`merge-fix` の終了コードを見ない。**どちらも変えない。** - -### `init` の出力 - -`IMPL_STALL_TIMEOUT=` を足す。`test_timeout` は状態ファイルの値で、 -再開時も同じ値を出す。 - -### 骨組み(`SKILL.md` の「実行」の差分) - -```bash - "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-apply-r$ROUND" --phase apply \ - --stall-timeout "$IMPL_STALL_TIMEOUT" - # 終了コード 2 = この群を取り消した、または担当を替えて開き直す。修正ラウンドは回さない - rf merge-apply "$ID" "$ROUND" || continue -``` - -差分は `--stall-timeout` の 1 行だけで(`--phase apply` は P2 の形)、修正と最終ゲートの修正の監視にも同じ行を足す。`--timeout` は渡さない。 - -### 雛形に足す文 - -| 雛形 | 足す文 | -| --- | --- | -| `apply.md` / `fix.md` のコミットの規約 | 4 つのトレーラーは**メッセージの最後の段落**に置く。実行環境が帰属行(`Co-Authored-By:` など)を足すときは、空行を挟まず同じ段落に続ける | -| `apply.md` / `fix.md` / `final-fix.md` | 作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記する(`start` / `edit` / `test` / `commit` / `done` と対象だけ。推論は書かない)。形は cross-review の `launch-reviewer.sh` の「進捗マーカー」に揃える | - -## 処理の流れ - -### `next-apply-round` - -```mermaid -graph TD - S[群を順に見る] --> P{status が pending か
applied の群がある} - P -->|無い| E1[終了コード 1] - P -->|ある| K{status} - K -->|applied| O[開き直す
起点・試行番号を動かさない] - K -->|pending| EM{items が空} - EM -->|はい| D[dropped
drop_reason: empty] --> S - EM -->|いいえ| Q{失敗の記録の数 ==
attempt} - Q -->|はい| INC[attempt を 1 進め
起点を HEAD にする] - Q -->|いいえ・再開| OUT[APPLY_ROUND / IMPL を出す] - INC --> OUT - O --> OUT -``` - -再開で試行番号を進めないときも、`apply_round` と `apply_base_sha` は群の値から書き直す。 - -### `merge-apply` - -```mermaid -graph TD - B[置き土産を捨てる / 取り消しと push の再開] --> G{取り込み済みか} - G -->|採用あり| R0[終了コード 0] - G -->|採用 0 件| DR[群が dropped でなければ dropped] --> R2[終了コード 2] - G -->|未取り込み| DUP{同じ attempt の失敗の記録がある} - DUP -->|はい| R2 - DUP -->|いいえ| BL{着手前テストが green} - BL -->|いいえ| A4[終了コード 4] - BL -->|はい| RG{範囲を確定できる} - RG -->|いいえ| A4 - RG -->|はい| LD{load_result が読めた} - LD -->|はい| V[既存の検証と取り込み] - LD -->|いいえ| CF[_close_failed_attempt] --> R2 -``` - -`_close_failed_attempt` は次の順で行う。 - -1. 範囲にコミットがあれば全範囲を新しい順に取り消し、群と記録の起点を取り消し後の HEAD にする(push の印を先に立てる) -2. `failed_attempts` へ `{attempt, impl, reason: _monitor_reason(...), at, reverted}` を足す(`attempt` が 0 なら 1 として記録する) -3. `attempt < MAX_APPLY_ATTEMPTS` なら、`apply_seq` を 1 ずつ進めて `rounds.impl_for_seq` を引き、`failed_attempts[].impl` のどれとも違う担当が出たらその担当と要求モデルで `impl` / `impl_model` を書き換える。4 回進めても出なければ(除外で候補が 1 者しかない)、手順 4 と同じく群を取り消す -4. `attempt >= MAX_APPLY_ATTEMPTS` なら群の項目を `abandoned` にして `deferred_items` へ入れる。群は `dropped`(`drop_reason: no_result`)にし、`apply.merged_at` を立て、局面を `phase_after_group` にする -5. 保存し、取り消しがあったときだけ push する(`push_with_retry_marker`) - -### `merge-fix` - -担当は `current_group(entry)["impl"]`(無ければ `entry["impl"]`)から読む。`fix_merged_keys` に `":missing"` が -あれば、結果ファイルを読まずに 2 を返す(`merge-apply` の決定 5 と同じ理由。欠落の後に遅れて書かれた結果を取り込まない)。 -無ければ `load_result` を呼び、読めなければ範囲(`fix_base_sha`..HEAD)にコミットがあるときだけ `revert_unverified_range` で -取り消し、鍵を足し、`fix_rounds` を 1 進めて保存し、2 を返す。範囲を確定できないときの既存の扱い(`_resolve_fix_range`)と同じ形である。 - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 中断と再開を挟まない実行で、適用担当の起動が群の数 × 2 回、修正担当の起動が群の数 × `--max-fix-rounds` 回を超えない | 試行番号の上限(決定 2・5)と、結果が無い修正でも `fix_rounds` を進めること | AC3(開く回数)と AC18(`should-abandon` が上限で 0) | -| 運用・保守性 | 群を取り消した理由が改修計画の「見送った項目」から読める | `defer_reason` に担当と理由の名前を並べる。`plan.py` の表は `defer_reason` をそのまま出す | AC2 で `defer_reason` に `agy` と理由の名前が入ることを見る | - -## 他の設計との契約 - -| 相手 | 契約 | D-C の扱い | -| --- | --- | --- | -| D-A(P1) | 監視が担当ごとの結果を `-monitor.json` へ残し、`monitor_outcome.read_outcome(tmp_dir, stem)` で読める(D-A の契約の文書) | `_monitor_reason` だけが `read_outcome(tmp_dir, "-apply-r")` の `reason` を読む。`ok` / `missing` / ファイルなしは `load_result` の問題へ置き換える(D-A の AC55 と同じ規則。壊れた JSON は監視から見ると `ok`)。振る舞いは変えない。追記だけの `monitor-outcomes.jsonl` は読まない | -| D-A(P3) | 理由の名前は #619 の語彙を基本とし、claude の `"api_error_status":429` を `usage_limit` として早期に打ち切る | 名前を解釈しない。記録へ写すだけ | -| D-A(P1 の計測) | 工程の所要は監視の記録と `refactor.py` の `statefile.save` の差し込み口から組み立て、骨組みと `refactor_lib` の取り込みには足さない(D-A の決定 9) | 計測の呼び出しを置かない。`refactor.py` の差し込み口と `commands/report.py` の要約の行は D-A が足すため、P6 はその上に載せる。**要約の `apply_attempts` は、鍵 `"r<ラウンド>-g<群>"` → `{"attempts": , "failed": , "dropped_reason": }` を状態ファイルの群から作る**(D-A の AC14 の「群ごとの試行回数」の出どころ。監視の記録からは作らない)ことを前提 1 に含める | -| D-A(P2) | 監視の上限は `--phase` と `lib/limits.py` が工程で決め、cross-refactoring の 8 か所は `--timeout` を渡さない(D-A の AC37)。修正と最終ゲートの修正の 420 秒の打ち切りも P2 が直す。8 か所の引数は P2 が変え、D-C は上限の値を書かない(D-A の申し送り) | `--timeout` を足さず、`--phase` が `apply` / `fix` / `final-fix` の呼び出しに `--stall-timeout` だけを足す。**これは申し送りと D-A の決定 10(無進捗の許容は担当の軸だけで決め、既定値は `limits.py` の表だけが持つ)に対する例外である。** 工程(テスト 1 回を含む適用・修正)に依存する許容を、D-C が骨組みの引数で渡す。D-A の申し送りの行と決定 10 へ同じ例外を書き足すことを前提 1 に含める。許容が監視の上限以上になる組は P2 の警告(D-A の AC33)に任せる | -| D-A(全体) | 監視の終了コードの意味(2 / 3 / 4 / 5 / 6)と `--stall-timeout` の引数は変えない | 骨組みは終了コードで分岐しない(決定 4) | -| D-B(P4 / P5) | 除外(#478)は `assignment.assign` か、その呼び出し側に入る | 適用・交代・最終ゲートの修正の担当を決める呼び出しは `rounds.impl_for_seq` の 1 か所(決定 8)。D-B が先に入れば P6 がそこへ寄せ、P6 が先なら D-B がそこを変える。`setup.py:467` の提案ラウンドの担当は CLI を起動しないため対象外 | - -## テスト設計 - -実行は `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q`。 -状態の遷移は関数を直接呼ぶ既存の形(`no_git` / `patch_lib` / `git_facts`)、トレーラーは一時リポジトリで実際に git を実行する。 - -| 受け入れ条件 | 何で確かめるか | 置き場所 | -| --- | --- | --- | -| AC1、AC2、AC4、AC5 | 群 2 つの状態で結果ファイルを置かずに(AC4 は壊れた JSON と配列で)呼び、`status` / `impl` / `failed_attempts` / `apply_seq` / `deferred_items` を見る。AC5 は群 4 つ(`apply_seq` 4)で先頭の群を失敗させる | `test_apply_attempts.py` | -| AC3 | `next-apply-round` が 1 を返すまで繰り返し、呼び出し回数 5 と両群の `dropped` | `test_apply_attempts.py` | -| AC6、AC7 | `merge-apply` の 2 度呼び、`next-apply-round` の 2 度呼びで、記録の件数と `attempt` が変わらない。AC6 は替えた先の担当の古い結果ファイルを置いた状態でも確かめる | `test_apply_attempts.py` | -| AC8 | `commits_in_range` が 1 件返す状態で、`no_git` の記録に `git revert` が出て、群の `base_sha` が変わる | `test_apply_attempts.py` | -| AC9、AC10 | 着手前テスト `red`、`commits_in_range` が `None` で、`SystemExit` の値が 4 | `test_apply_attempts.py` | -| AC11 | 4 つの経路でパラメータ化し、終了コード 2 の後の群が `dropped` か失敗の記録付き `pending` | `test_apply_attempts.py` | -| AC12 | `-apply-r-monitor.json` に `reason: stalled` を置いた場合、ファイルが無い場合、`reason: ok` で結果ファイルが壊れた JSON の場合 | `test_apply_attempts.py` | -| AC13 | テスト整備ラウンドの採用 0 件から `merge-proposals` → `next-apply-round` が 1、`apply_rounds == []` | `test_apply_rounds.py` | -| AC14 | `apply_rounds` の鍵を消した状態で群が 1 つ作られる(既存のテストを確かめ直す) | `test_apply_rounds.py` | -| AC15 | rf587 の形の状態で `merge-apply` → 2、群 `dropped`、`next-apply-round` → 1 | `test_apply_rounds.py` | -| AC16 | `items: []` の `pending` の群と、項目のある群を並べて `next-apply-round` | `test_apply_attempts.py` | -| AC17〜AC19 | 群の担当と提案ラウンドの担当を分けた状態で `merge-fix`。AC18 は `should-abandon` まで回す | `test_abandon_items.py` | -| AC20〜AC24 | 一時リポジトリで各形のコミットを作り、`commit_trailers` の戻り値を比べる | `test_commit_trailers_git.py` | -| AC25 | 同じ一時リポジトリで `collect_commit_facts` → `verify_apply_round` が `None` | `test_commit_trailers_git.py` | -| AC26 | 同じ一時リポジトリで、題名が `Round: …` の形で本文がトレーラーの段落だけのコミットを作り、`Round` が題名の値にならない | `test_commit_trailers_git.py` | -| AC27、AC30 | 雛形の文言を `grep` で探す | `test_skill_terms.py` | -| AC28 | `_emit_init` の出力に `IMPL_STALL_TIMEOUT=1800`(`test_timeout` 900 の状態) | `test_init.py` | -| AC29、AC31 | `SKILL.md` の骨組みから `monitor.py` の呼び出し 4 つを取り出し、`--phase` が `apply` / `fix` / `final-fix` の 3 つが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない。語の表の行と段落の有無 | `test_skill_terms.py` | -| AC32 | `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6 の `monitor.py` の引数(`--phase` と `--stall-timeout`、`--timeout` なし)が、`SKILL.md` の同じ呼び出しと一致する | `test_skill_terms.py` | -| AC33 | トレーラーの節の記載をレビューで見る | 手動 | -| AC34 | 既存の `test_a_verified_apply_round_marks_every_item_applied` に `failed_attempts` が無いことを足す | `test_merge_apply.py` | -| AC35、AC36 | 全体のテスト、`bash scripts/build-runtime-plugins.sh --check`、`claude plugin validate .`、`python3 scripts/check-skill-frontmatter.py` | 手動 | -| AC37 | `impl_for_seq` を差し替え、群の割り当て・担当の交代・最終ゲートの修正担当の 3 つが差し替えた担当になる | `test_final_fix.py` / `test_apply_attempts.py` | - -## 未確認のまま残ること - -| # | 項目 | 内容 | 決める時点 | -| --- | --- | --- | --- | -| 1 | rf646 で agy が打ち切りの時点で動いていたか | ログは作業ツリーとともに消えていた(`find / -name 'agy-apply-r*'` で該当なし)。決定 13 は動いていた場合も止まっていた場合も成り立つ | 次の実行で `progress.log` を読む | -| 2 | 担当が雛形の進捗マーカーに従うか | cross-review の agy は従っている(heartbeat に作業段階が出る)。claude / codex / kiro の適用で従うかは起動して確かめていない。従わなくても決定 13 の許容で打ち切られないのはテスト 1 回分まで | 実装後の最初の実行 | -| 3 | 担当を替えた後の担当も結果を残さない割合 | 2 回目で救える群の数は測っていない。実行の要約の `apply_attempts`(群ごとの `attempt` と `failed_attempts` の件数)で数える | 配布後 | -| 4 | Claude Code 以外の担当が帰属行を足すか | codex / agy / kiro のコミットで帰属行の段落を見ていない。決定 11 は誰が足しても同じに読む | 確かめなくてよい | -| 5 | 帰属行を同じ段落に続ける指示に claude が従うか | 決定 12 は補助で、従わなくても決定 11 で検証は通る | 実装後の最初の実行 | -| 6 | `--test-timeout` が監視の上限 − 900(既定 2700)以上の利用 | 適用・修正の所要を測っていない。#662 の計測で工程ごとの所要が取れたら、P2 の上限の表と合わせて見直す | 配布後 | diff --git a/issues/issue-647-592-553-requirements.md b/issues/issue-647-592-553-requirements.md deleted file mode 100644 index 42ca0c45..00000000 --- a/issues/issue-647-592-553-requirements.md +++ /dev/null @@ -1,264 +0,0 @@ -# #647 / #592 / #553: cross-refactoring の適用ラウンドを有限回で終わらせ、帰属行の後ろでもトレーラーを読む - -設計は [issue-647-592-553-design.md](issue-647-592-553-design.md) にある。この文書は「何を満たすか」だけを扱う。 - -**この文書は置き換えられた。** 親 #728(取り込みが結果なしを値で受け、取り消しと群の状態を 1 か所で決める)の要求 [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md) と設計 [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md) が受け入れ条件と決定を持つ。対応は新しい文書の末尾の表にある。以下は 2026-09-15 時点の記録として残す。 - -**3 つの課題は 1 本の Pull Request(P6)で直す。** #647 と #592 は入口が違うが、同じ -`next-apply-round` → `merge-apply` の繰り返しが止まらない。#553 は同じ適用の検証で群を落とす。 -マイルストーン 21 の実装順では P3(監視の結果の分類)の後に入る。 - -## 目的 - -- 実装担当が結果を残さなくても、適用ラウンドの繰り返しが有限回で終わり、後ろの群と収束の判定へ届く -- テスト整備ラウンドの採用が 0 件でも、項目の無い群を開かずに提案ラウンドへ進む -- 修正ラウンドも、結果を残さない担当で上限なしに繰り返さない -- Claude Code が帰属行を別の段落で足しても、必須トレーラーが読めて群が落ちない - -## 対象範囲 - -含む: - -- `merge-apply` が取り込みの前に抜ける 3 か所の扱い(#647) -- 同じ群の試行の上限と、2 回目の試行で担当を替えること(#647) -- 採用 0 件の提案ラウンドで群を作らないこと(#592) -- `merge-fix` が読む結果ファイルの担当と、結果が無いときの修正ラウンドの数え方(範囲内へ入れた。下の「範囲へ入れたもの」) -- `commit_trailers` の読み方と、適用・修正の雛形のコミットの規約(#553) -- 適用・修正・最終ゲートの修正の監視に渡す無進捗の許容と、雛形の進捗マーカー(#647 の STALLED 対策) -- `SKILL.md` の「この Skill で使う語」「適用ラウンドに別の上限を置かない」「実行」の骨組み -- `docs/02-apply-and-review.md` と `docs/04-fix-and-report.md` の対応箇所 - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| `plugins/ndf/scripts/lib/monitor.py` の変更(429 の検知・理由の語彙・結果の記録) | D-A(#662 #619 #584 #583)が所有する。D-C は消費する側 | -| `plugins/ndf/scripts/lib/assignment.py` と除外の引数 | D-B(#624 #478 #648)が所有する | -| 工程ごとの所要時間の計測 | D-A(P1)が監視の記録と `statefile.save` の差し込み口から組み立てる。骨組みと `refactor_lib` の取り込みには足さない | -| 利用上限(429)で進行全体を止めること | 担当を替えて 1 回だけ再試行する扱いに含める(設計文書の決定 3) | -| 取り込み済みの群を開き直したときに適用担当を起動し直す無駄 | 回数は 1 回で有限。繰り返しにはならない | -| 骨組みの bash を `scripts/` へ出すこと | #560 | -| 最終ゲートの修正で担当が結果を残さないときのコミットの扱い | 繰り返しは `--max-fix-rounds` で既に止まり、残るのは未検証のコミットの扱いで主題が違う(#674) | -| `cross-review` 側の監視と担当 | 別の設計(D-A / D-B) | -| `CHANGELOG.md` と版数 | 配布の工程が書く | -| クラス図 | 型を変えない。変えるのは関数と、状態ファイルの辞書の項目だけで、群の項目は設計文書の「データ構造」が持つ | - -### 範囲へ入れたもの - -**`merge-fix` の担当の食い違い**(起票せず範囲内へ入れた。`out-of-scope` の 3 択の「範囲内へ入れる」)。 -`cmd_merge_fix` は提案ラウンドの担当(`entry["impl"]`)の結果ファイルを読むが、起動するのは群の担当 -(`next-apply-round` が返す `IMPL`)である。群の担当は群ごとの輪番で決まるため、2 つが一致しない群では -修正の結果を一度も取り込めない。`merge-fix` は結果が無いと `fix_rounds` を進めずに終了コード 2 で抜ける。 -骨組みは終了コードを見ないため、検証 → 修正 → 検証が上限なしに回る。一時テストで再現した。 -群の担当 agy・提案ラウンドの担当 codex で `merge-fix` を 3 回呼ぶと、3 回とも -`codex の結果ファイルがありません` で、`fix_rounds` は 0 のままだった。**#647 と同じ「結果を取り込めない -担当で繰り返しが止まらない」形で、適用の側だけ直すと修正の側が残る。** - -## 受け入れ条件(#647: 結果を残さない担当) - -試行の上限と担当の交代: - -- [ ] AC1: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに - `next-apply-round` → `merge-apply` を呼ぶ。`merge-apply` は終了コード 2 で終わる。1 つ目の群は - `status: pending` のまま担当が agy 以外に替わり、失敗した試行の記録が 1 件(試行番号 1・担当 agy・理由)残る -- [ ] AC2: AC1 の後、替わった担当の結果ファイルも置かずにもう一度 `next-apply-round` → `merge-apply` を呼ぶ。 - 1 つ目の群は `status: dropped` になり、群の項目は `abandoned` になり、`deferred_items` に理由付きで入る -- [ ] AC3: 結果ファイルを 1 つも置かずに、`next-apply-round` が終了コード 1 を返すまで `next-apply-round` → - `merge-apply` を繰り返す。`next-apply-round` の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、 - 両方の群が `dropped` になる -- [ ] AC4: 結果ファイルが JSON として読めない場合と、JSON の配列の場合も、AC1 と同じ状態になる -- [ ] AC5: 群が 4 つ(`apply_seq` 4)あり、先頭の群(担当 codex)が結果を残さない。担当を替えた後の担当は codex - 以外で、`apply_seq` は進めた分だけ進み、他の群の担当は変わらない - -叩き直しと中断からの再開: - -- [ ] AC6: AC1 の直後に、`next-apply-round` を挟まず `merge-apply` をもう一度呼ぶ。替えた先の担当の結果ファイルが - 同じ提案ラウンドの先行の群のものとして残っていても、終了コード 2 で終わる。失敗した試行の記録は 1 件のまま、 - 担当と `apply_seq` も変わらない -- [ ] AC7: `next-apply-round` を `merge-apply` を挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。 - 群の試行番号は 1 のまま進まない - -結果を残さない試行のコミット: - -- [ ] AC8: 結果ファイルが無く、群の起点から HEAD までにコミットが 1 件以上ある。`merge-apply` の後、 - そのコミットは取り消され、群の起点は取り消し後の HEAD になる(次に開いたときの範囲へ入らない) - -取り込みの前の他の 2 か所: - -- [ ] AC9: 着手前のテストの状態が `green` でない状態で `merge-apply` を呼ぶと、終了コード 4 で終わる -- [ ] AC10: 適用の範囲を確定できない(起点が無い、または git が範囲を返さない)状態で `merge-apply` を - 呼ぶと、終了コード 4 で終わる - -繰り返しが有限であること: - -- [ ] AC11: `merge-apply` が終了コード 2 で終わった後の群は、`dropped` か、失敗した試行の記録を持つ - `pending` のどちらかである。確かめる経路は 4 つ(結果を残さない / 未割当のコミット / 適用の検証の失敗 / - 取り込み済みで採用 0 件) - -理由の記録: - -- [ ] AC12: 監視の結果ファイル(D-A が P1 で足す)がその担当・その段の理由を持つとき、失敗した試行の記録と - 見送りの理由にその理由の名前(例 `stalled`)が入る。ファイルが無いときは `missing` が入る。監視の理由が `ok` で - 結果ファイルが壊れた JSON のときは `unparsable` が入り、`ok` は入らない - -## 受け入れ条件(#592: 採用 0 件と項目の無い群) - -- [ ] AC13: テスト整備ラウンドで提案が 0 件の状態で `merge-proposals` を呼んだ後、`next-apply-round` を - 呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの `apply_rounds` は空の配列のままである -- [ ] AC14: `apply_rounds` の鍵を持たない状態ファイル(群を導入する前の版)では、`next-apply-round` が - 従来どおりラウンド全体を 1 つの群として開く -- [ ] AC15: rf587 で残った形の群で `merge-apply` を呼ぶと、終了コード 2 で終わり、群が `dropped` になる。 - 形は `status: applied`・`items: []`・`apply.merged_at` あり・`applied: []` である。続く `next-apply-round` は - 終了コード 1 を返す -- [ ] AC16: `status: pending`、`items: []` の群を持つ状態で `next-apply-round` を呼ぶ。その群は開かれずに - `dropped` になり、次の群があればそれを開き、無ければ終了コード 1 を返す - -## 受け入れ条件(範囲へ入れたもの: 修正ラウンド) - -- [ ] AC17: 群の担当が agy、提案ラウンドの担当が codex の状態で、`agy-fix-r1-result.json` を置いて - `merge-fix` を呼ぶ。agy の結果が取り込まれ、`fix_rounds` が 1 になる -- [ ] AC18: 修正の結果ファイルが無い状態で `merge-fix` を呼ぶと、終了コード 2 で終わり、`fix_rounds` が - 1 進む。`--max-fix-rounds` 回続けた後の `should-abandon` は終了コード 0(見送りへ移る)を返す -- [ ] AC19: AC18 の直後に、`verify-round` を挟まず `merge-fix` をもう一度呼んでも、`fix_rounds` は進まない。その間に修正の結果ファイルが現れても進まない - -## 受け入れ条件(#553: 帰属行の後ろのトレーラー) - -一時リポジトリで実際にコミットを作って確かめる: - -- [ ] AC20: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、 - `commit_trailers` が 4 つとも値を返す -- [ ] AC21: AC20 の段落の後に `Co-Authored-By:` と `Claude-Session:` の 2 行の段落が付いても、4 つとも返す -- [ ] AC22: 必須トレーラーの段落と末尾の段落の間に散文の段落があるコミットで、散文より前にある - `Round: …` の形の行を読まない -- [ ] AC23: 末尾の段落に散文とトレーラーの形の行が混ざる(git がトレーラーの段落と判定しない)コミットで、 - その行を読まない -- [ ] AC24: 同じ鍵が 2 つの段落にあるとき、末尾に近い段落の値を返す -- [ ] AC25: AC20 の形のコミットを申告した適用ラウンドが、トレーラーの欠落で取り消されない -- [ ] AC26: 本文がトレーラーの段落 1 つだけで、題名が `Round: 本文の題名` の形のコミットで、題名を読まない - -雛形: - -- [ ] AC27: `prompts/apply.md` と `prompts/fix.md` のコミットの規約が、必須トレーラーをメッセージの - 最後の段落に置くことを書く - -## 受け入れ条件(無進捗の打ち切り) - -- [ ] AC28: `init` の出力に `IMPL_STALL_TIMEOUT` が入り、値が `--test-timeout` の値 + 900 である - (既定で 1800)。`--test-timeout` は `apply` / `fix` / `final-fix` の監視の上限 − 900 未満(P2 の既定なら 2700 未満)を前提とする -- [ ] AC29: `SKILL.md` の骨組みで、`--phase apply` / `fix` / `final-fix` の 3 つの `monitor.py` の呼び出しが - `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない -- [ ] AC30: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が進捗マーカーを - 書く。作業段階ごとに `$RF_STEM-progress.log` へ 1 行追記する指示である - -## 受け入れ条件(文書) - -- [ ] AC31: `SKILL.md` の「この Skill で使う語」の適用ラウンドの行が、上限を決めるものとして同じ群の試行の - 上限(2 回)を書く。「適用ラウンドに別の上限を置かない」の段落は無くなる。 - `grep -n "別の上限を置かない\|別に置かない" SKILL.md` が何も出力しない -- [ ] AC32: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6 の骨組みが、`SKILL.md` の - 同じ呼び出しと同じ `monitor.py` の引数を持つ(`--phase` と `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない) -- [ ] AC33: `docs/02-apply-and-review.md` のトレーラーの節が 2 つを書く。`git log --format='%(trailers:…)'` が - 最後の段落しか読まないことと、進行側の読み方である - -## 受け入れ条件(退行しない) - -- [ ] AC34: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、 - 失敗した試行の記録を持たない -- [ ] AC35: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC36: 配布物の同期・定義・frontmatter の 3 つの検査が終了コード 0 で終わる(コマンドは「検証手段」の表) - -## 受け入れ条件(他の設計との契約) - -- [ ] AC37: `rounds.impl_for_seq` を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの - 修正担当の 3 つが、差し替えた関数の返す担当になる - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 中断と再開を挟まない実行で、1 つの提案ラウンドで適用担当を起動する回数が群の数 × 2 回を超えない。修正担当を起動する回数も群の数 × `--max-fix-rounds` 回を超えない。取り込み済みの群の開き直しと、`merge-apply` の前に止まった再開は、その回数だけ起動が増える | -| 運用・保守性 | 群を取り消した理由(担当 2 者と、監視の理由の名前)が、改修計画の「見送った項目」の表から読める | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| `merge-apply` の終了コード | 着手前テストの未確認と範囲の未確定が 2 から 4(中断)へ変わる。`SKILL.md` の終了コードの表は既に 4 と書いている | -| 状態ファイル | 群に試行の記録が増える。既存の状態ファイルは記録が無いまま読める(試行 0 回として扱う) | -| 群の担当 | 結果を残さなかった群だけ、2 回目の試行で次の輪番の担当へ替わる | -| 無進捗の打ち切り | 適用・修正・最終ゲートの修正で、どの担当も既定の許容より長くなる(codex 180 秒 / agy・kiro 480 秒 / claude 900 秒 → 1800 秒) | -| トレーラーの読み取り | 最後の段落に加え、その直前に続くトレーラーの段落も読む。最終ゲートの修正にも同じ読み方が効く | - -## 前提 - -| # | 前提 | -| --- | --- | -| 1 | D-A の P1(監視の結果ファイルと `monitor_outcome.read_outcome`)、P2(`--phase` と `lib/limits.py` による監視の上限)、P3(理由の語彙)が先に `develop` へ入る。D-A の申し送り(cross-refactoring の監視の引数は P2 が変え、D-C は上限の値を書かない)と決定 10(無進捗の許容は担当の軸だけで決める)に、適用・修正・最終ゲートの修正の `--stall-timeout "$IMPL_STALL_TIMEOUT"` を D-C が渡す例外が書き足される。D-A の実行の要約の `apply_attempts` が、鍵 `"r<ラウンド>-g<群>"` → `{"attempts": 整数, "failed": 整数, "dropped_reason": 文字列または null}` を状態ファイルの群から作る。理由の名前は #619 の提案(`timeout` / `cli_timeout` / `usage_limit` / `early_error` / `stalled` / `not_posted` / `missing`)を基本とする | -| 2 | 監視の終了コード(2 = TIMEOUT / 3 = NO_RESULT / 4 = EARLY_ERROR / 5 = STALLED / 6 = PIDFILE_BAD)の意味は変わらない | -| 3 | `assignment.assign(seq, host)` の実装担当は 4 者の輪番で、`seq` を 1 ずつ進めれば 3 回以内に失敗した担当と別のランタイムが出る。D-B が除外を足した後も、除外されない者が 2 者以上いる | -| 4 | Claude Code が足す帰属行は、メッセージの末尾に独立した段落として付く(#553 の実測 `26a0fff`) | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q`(cross-refactoring だけなら `plugins/ndf/skills/cross-refactoring/tests`) | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認 | 次に cross-refactoring を回した実行で、`agy-apply-r*-progress.log` に作業段階が残るか(未確認のまま残ること 1) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 状態の判定は `refactor_lib/` に置き、骨組みの bash は判定を持たない(`SKILL.md` の「実行」) | -| コーディング規約 | 外部コマンド(`git interpret-trailers`)の挙動は書く前に実行して確かめる(`AGENTS.md` の DO) | -| テスト戦略 | 状態の遷移は既存の形(`tests/conftest.py` の `no_git` / `patch_lib` と `test_merge_apply.py` の `git_facts`)で関数を直接呼ぶ。トレーラーの読み取りは一時リポジトリで実際に git を実行する | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査 | -| 確認してから行う | 同じ群の試行の上限を引数にすること(この変更では固定の 2 回) | -| 行わない | `monitor.py` / `assignment.py` の変更、骨組みを `scripts/` へ出すこと | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 群 | 適用ラウンド。書き換えるファイルが重ならない項目の集まりで、状態の `apply_rounds[]` の 1 件 | -| 試行 | 1 つの群に対して適用担当を起動し、`merge-apply` で取り込もうとした 1 回 | -| 結果を残さない | 結果ファイルが無い、JSON として読めない、JSON オブジェクトでない、のいずれか | -| 監視の結果ファイル | D-A が P1 で足す `-monitor.json`。監視が担当 1 者ごとの状態と理由を残し、`monitor_outcome.read_outcome` で読む | -| 帰属行 | Claude Code がコミットメッセージへ足す `Co-Authored-By:` / `Claude-Session:` の行 | -| トレーラーの段落 | `git interpret-trailers --parse` がトレーラーとして読む段落 | - -## 依頼(原文) - -### #647 - -> `/ndf:cross-refactoring` で、同じ適用ラウンド(書き換えるファイルが重ならない改善項目の群)の適用が**上限なしに再試行される**。後ろの群へ順番が回らず、収束の判定と最終ゲートへ届かない。 -> -> - **同じ群の適用の試行に上限を置き**、超えたら項目単位で見送る(`abandon-items` と同じ扱い)。あわせて群の状態を進め、次の群と次の担当へ移る -> - `SKILL.md:49` / `:56` の「適用ラウンドに別の上限を置かない」を、開き直しの上限を持つ形へ改める -> - 骨組みで `monitor.py` の終了コードを受け取り、STALLED / TIMEOUT / EARLY_ERROR を区別して `merge-apply` へ渡すか、進行を止めて報告する -> - `EARLY_ERROR` のうち利用上限(429)は**再試行しても直らない**ため、その場で進行を止めて報告する。`EARLY_ERROR_FATAL` に claude の `api_error_status":429` の形を足す -> - cross-refactoring の適用・修正の雛形にも進捗マーカー(`$STEM-progress.log`)の指示を足すか、適用の監視に `--stall-timeout` を渡す。先に rf646 の agy のログで、打ち切りの時点で agy が動いていたかを確かめる - -### #592 - -> **テスト整備ラウンドの採用が 0 件のとき、項目の無い適用ラウンドが開き、上限なしに同じ群を繰り返す。** 手順書の骨組み(`SKILL.md` の「実行」)をそのまま回すと、提案ラウンドへ進まない。 - -### #553 - -> `cross-refactoring` の適用ラウンドで、**claude が実装担当のときだけ**必須トレーラー(`Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model`)が読めず、群が丸ごと取り消される。 -> -> | 案 | 中身 | 気になる点 | -> | --- | --- | --- | -> | A | `commit_trailers()` を `git interpret-trailers --parse` へ変える | 同じく最終段落しか読まない。効かない | -> | B | メッセージ全文から `^: ` を正規表現で拾う | git のトレーラー定義から外れる。本文中の同名の行を拾いうる | -> | C | プロンプトへ「必須トレーラーを**最後の段落**に置く(帰属行より後ろ)」と書く | 実装担当の従い方に依存する | -> | D | 進行側が取り込みの直前に `git commit --amend` でトレーラーを足し直す | 実装担当のコミットを進行側が書き換える | - -(3 件の issue の本文から抜粋。全文は `gh issue view 647` / `gh issue view 592` / `gh issue view 553`) diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index 592dfda8..e8d6f928 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -122,7 +122,7 @@ classDiagram ### 理由の語彙 -**P3 の語彙と「P3 で足す文言」は、結果なしの理由を共通層の 1 か所で読む設計 [issue-729-619-584-design.md](issue-729-619-584-design.md) の「理由の語彙」「データ構造」「入出力の契約」へ移した。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 +**P3 の語彙と「P3 で足す文言」は、結果なしの理由を共通層の 1 か所で読む設計(#729)へ移した。確定仕様は [起動 1 回の結末](../docs/specifications/cross-review-launch-outcome.md) の「結末の語彙」「データ・設定」にある。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 **監視が書く理由**(`monitor_outcome.REASONS`): @@ -372,4 +372,4 @@ conftest.py P1(NDF_METRICS_DIR) | AC68 / AC69 | 文書の `grep` | | AC70〜AC72 | 検証手段の表のコマンド。AC72 はテストの前後で `find` | -**AC63〜AC67 の確かめ方は、[issue-730-583-design.md](issue-730-583-design.md) の「テスト設計」が引き継いだ。** 担当が投稿しなくなるため、投稿済みのレビューを探す鍵(`prior_review_url`)を作らず、AC63〜AC65 の行は対象を失う。上の 2 行は 2026-09-15 時点の記録として残す。 +**AC63〜AC67 の確かめ方は、#730 の設計が引き継いだ。確定仕様は [書き込みを回す側へ集める仕様](../docs/specifications/cross-review-writes-to-conductor.md) の「テスト観点」にある。** 担当が投稿しなくなるため、投稿済みのレビューを探す鍵(`prior_review_url`)を作らず、AC63〜AC65 の行は対象を失う。上の 2 行は 2026-09-15 時点の記録として残す。 diff --git a/issues/issue-662-598-537-619-584-583-requirements.md b/issues/issue-662-598-537-619-584-583-requirements.md index fce9d216..7d58b27c 100644 --- a/issues/issue-662-598-537-619-584-583-requirements.md +++ b/issues/issue-662-598-537-619-584-583-requirements.md @@ -157,9 +157,9 @@ ## 受け入れ条件(P3: #619 + #584 + #583 結果が失われる) -**#619 / #584 の受け入れ条件(AC50〜AC62、AC68〜AC69)は、利用上限で止まった担当を起動し直さず理由を報告する要求 [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) へ移した。** 以下は 2026-09-15 時点の記録として残す。 +**#619 / #584 の受け入れ条件(AC50〜AC62、AC68〜AC69)は、利用上限で止まった担当を起動し直さず理由を報告する要求(#729)へ移した。確定仕様は [起動 1 回の結末](../docs/specifications/cross-review-launch-outcome.md) にある。** 以下は 2026-09-15 時点の記録として残す。 -**#583 の受け入れ条件(AC63〜AC67)は、GitHub と git への書き込みをレビューを回す側だけにする要求 [issue-730-583-requirements.md](issue-730-583-requirements.md) が引き継いだ。** そのうち AC63〜AC65 は取り下げ、AC66 は送信の応答から取る形へ改め、AC67 は引き継いでいる。対応は引き継いだ側の「置き換える既存の受け入れ条件との対応」にある。 +**#583 の受け入れ条件(AC63〜AC67)は、GitHub と git への書き込みをレビューを回す側だけにする要求(#730)が引き継いだ。** そのうち AC63〜AC65 は取り下げ、AC66 は送信の応答から取る形へ改め、AC67 は引き継いでいる。確定仕様は [書き込みを回す側へ集める仕様](../docs/specifications/cross-review-writes-to-conductor.md) にある。 文言と見るファイルの一覧は、契約の文書の「P3 で足す文言」にある。 diff --git a/issues/issue-664-p7-refactor-participants-plan.md b/issues/issue-664-p7-refactor-participants-plan.md deleted file mode 100644 index 02523452..00000000 --- a/issues/issue-664-p7-refactor-participants-plan.md +++ /dev/null @@ -1,176 +0,0 @@ -# cross-refactoring: 担当を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる → 使える者だけで始まり、参加者は codex / kiro とホストを既定に足し引きでき、適用の輪番はその参加者の中で回る(実装計画 P7: cross-refactoring と旧関数の削除 / #664 #736) - -## 関連リンク - -- 親 issue #727、子 issue #664。あわせて #736(リポジトリの根の `CLAUDE.md` の上限の既定の記述) -- 設計: [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md)(決定 20 件。識別子は冒頭の用語の対応表で引く) -- 要求: [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md)(受け入れ条件 AC1〜AC50) -- 契約: [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md)(状態ファイル・引数・関数の形) -- 確定仕様: [cross-review-participants-and-seats.md](../docs/specifications/cross-review-participants-and-seats.md)(共通層と cross-review 側) -- 1 本目の実装: [issue-727-p6-participants-plan.md](issue-727-p6-participants-plan.md)(PR #793、マージ済み) - -## モード - -`standard`。収束ループの初期化と担当の決め方を変え、共通層と cross-refactoring と文書にまたがる。 - -## 目的と非目的 - -達成したい状態: - -- 参加する CLI のどれか 1 者が導入・認証されていなくても、cross-refactoring の初期化が使える者で始まる。使えない者と理由が状態ファイルに残る -- 既定の参加者が codex / kiro とホストになり、足す者・外す者の指定で名指しで変えられる。agy は足す者の指定で戻せる -- 適用の輪番が参加者の中で回る。存在しない役(レビュー担当)の記録と出力が消える -- 中断したループを引数を変えて再開すると、上限は反映され、反映しない引数は知らされる -- 使える者の決め方が共通層の 1 か所だけになり、両 Skill に古い形が残らない(親 #727 の完了条件) - -やらないこと: - -- cross-review 側の変更(1 本目で済んだ)。ただし旧関数に依存する cross-review のテスト 3 件は、関数を消すのに合わせて期待値を書き直す -- 起動した後に分かる使えなさで担当を自動的に外す仕組み(設計の決定 18) -- 引数の型の検査(カンマ区切りの名前と予約語)の共通層への移動。cross-review の状態の部品は並行する束(G5)が触っており、この Pull Request では cross-refactoring の側に同じ規則の型を置く -- 1 者指定を cross-refactoring に足すこと(契約の引数の表に無い) -- 指示書の cross-refactoring の節のうち、参加者と輪番と上限以外の古い行(コミットの単位・改修計画の置き場所・取り消しの単位)。#799 として起票した - -## 前提 - -- 前提 1: 設計文書の決定 20 件は変えない。実装で決めたことは「実装で決めたこと」に書き、設計文書の「未確認のまま残ること」に P7 の節を同じ Pull Request で足す -- 前提 2: 並行する束(G5、#730)は cross-review の投稿の部分と共通層の投稿の待ち行列を触る。旧関数を使う箇所が G5 のブランチに無いことを、消す前に `git grep` で確かめる(着手時点で `origin` に G5 の実装ブランチは無く、`develop` の呼び手はこの Pull Request が置き換える箇所だけだった) -- 前提 3: 新しい参加者(ホストが提案に入ること)の所要は測らない。設計の「未確認のまま残ること」のまま運用へ渡す - -## 受け入れ条件 - -要求文書の AC7、AC31〜AC43、AC47、AC49〜AC50 をそのまま使う。検証手段は設計文書の「テスト設計」の表にある。AC47(#664 の再現手順)は手元で実行し、結果を Pull Request 本文に残す。 - -## ドメイン用語 - -識別子は設計文書の用語の対応表と同じ。この計画で新しく使う語は次の 1 つだけである。 - -| 用語 | 意味 | -| --- | --- | -| 反映の表(cross-refactoring) | 再開で渡した引数ごとに「反映する」か「知らせる」かを決める表。cross-review の表と同じ形で、cross-refactoring の部品が持つ | - -## 不変条件 - -- 状態ファイルの参加者の一覧(`runtimes`)は、参加者の記録の使える者と同じ値である -- 新規の状態ファイルに適用専用の母集合の項目とラウンドのレビュー担当の項目が無い -- この変更の前に始めた実行の状態ファイルを、書き換えずに読める(適用の輪番は参加者の一覧から決まる) -- 使える者が 0 者、または全員を要する指定で欠けがあるとき、状態ファイルを作らない・書き換えない - -## 互換性 - -| 対象 | 変更 | 互換性の扱い | -| --- | --- | --- | -| 初期化の引数 | 足す者・外す者・全員を要する指定を足す。上限と重要度の既定を未指定にする | 追加のみ。未指定のときの既定値は変えない | -| 初期化の出力 | 適用専用の母集合の変数を出さない | 読み手は手順書の表だけ(骨組みは読まない)。手順書から消す | -| ラウンドの開始の出力 | レビュー担当の 2 変数を出さない | 読み手はテストの期待値だけ(設計文書の「実測」) | -| 状態ファイル | 参加者の記録と再開で変えた値の記録を足し、適用専用の母集合とレビュー担当を書かない | 古い状態ファイルは項目が無いまま読む。報告は「記録なし」と出す | -| 共通層の関数 | 従来の確認・適用専用の母集合・従来の席と適用の割り当ての 4 つを消す | 呼び手が 0 件になったことを `git grep` のテストで固定する | - -## 修正対象 - -```text -plugins/ndf/scripts/lib/assignment.py 旧関数 3 つと説明を消す -plugins/ndf/scripts/lib/auth.py 従来の確認を消す -plugins/ndf/scripts/tests/test_lib_assignment.py 旧関数のテストを消す -plugins/ndf/scripts/tests/test_auth_probe.py 従来の確認のテストを消す -plugins/ndf/scripts/tests/test_shared_lib_layout.py 旧関数が残らないことの検査を足す(AC7) -plugins/ndf/skills/cross-refactoring/scripts/refactor.py 引数の追加と既定の変更 -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py 初期化・再開・ラウンドの開始 -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py 適用の輪番の包み -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py 担当の交代の上限 -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py 母集合の 1 行・参加者の節・列の削除 -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py 改修計画の見出しからレビュー担当を消す -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py 観測したモデルの記録からレビュー側の枝を消す -plugins/ndf/skills/cross-refactoring/tests/ test_init.py / test_start_round_emits_runtimes.py / test_assignment.py ほか -plugins/ndf/skills/cross-review/tests/ 旧関数に依存する 3 件の期待値 -plugins/ndf/skills/cross-refactoring/SKILL.md / docs/01-state-and-propose.md -CLAUDE.md cross-refactoring の節と cross-review の節 -issues/issue-727-687-478-664-648-design.md 「未確認のまま残ること」に P7 の節 -``` - -## タスク分解 - -各タスクは失敗するテストを先に書き、通す最小の実装を足し、整える。旧関数の削除(Task 6)は呼び手をすべて置き換えた後に行う。 - -### Task 1: 新規の初期化を共通層の使える者の解決へ載せ替える - -- **対象ファイル:** `refactor.py`、`commands/setup.py`、`tests/test_init.py` -- **変更内容:** 母集合の既定(`refactor_pool`)と使える者の解決(`resolve_participants`)を止めない確認(`probe_auth`)で呼ぶ。状態ファイルへ参加者の一覧と参加者の記録と空の再開の記録を書き、適用専用の母集合を書かない・出さない。割り当ての失敗と 0 者を終了コード 4 へ写す。足す者・外す者・全員を要する指定の引数を足す -- **満たす受け入れ条件:** AC31、AC32、AC33、AC35、AC36、AC47 -- **進め方:** 確認を差し替えた初期化のテストを先に書く - -### Task 2: 適用の輪番を参加者の中で回し、レビュー担当を消す - -- **対象ファイル:** `rounds.py`、`commands/setup.py`、`commands/apply.py`、`gitfacts.py`、`tests/test_start_round_emits_runtimes.py`、`tests/test_apply_attempts.py` -- **変更内容:** 輪番の包み(`impl_for_seq`)の中身を適用の輪番(`impl_assign(seq, state["runtimes"])`)へ替え、ラウンドの開始もこれを使う。ラウンドの記録にレビュー担当を書かず、2 変数を出さない。担当の交代を試す回数を参加者の数にする -- **満たす受け入れ条件:** AC34、AC41 -- **進め方:** 出力と記録にレビュー担当が無いテストを先に書く(既存の期待値を反転する) - -### Task 3: 再開で上限を反映し、他の引数を知らせ、担当に関わる引数で参加者を作り直す - -- **対象ファイル:** `refactor.py`、`commands/setup.py`、`tests/test_init.py` -- **変更内容:** 状態に載る引数の既定を未指定にし、新規の経路で現行の既定へ置き換える。反映の表を置き、再開で共通層の再開の反映(`apply_resume_args`)を呼ぶ。足す者・外す者・全員を要する指定のどれかを渡した再開では、渡さなかった値を記録から補って作り直し、1 件として積む。失敗したら書き換えずに終了コード 4 -- **満たす受け入れ条件:** AC38、AC39、AC40 -- **進め方:** 状態ファイルを置いた作業ツリーで再開するテストを先に書く - -### Task 4: 報告と改修計画の表示を 1 つの母集合に揃える - -- **対象ファイル:** `commands/report.py`、`plan.py`、関連するテスト -- **変更内容:** 状態の表示の母集合を 1 行にし、ラウンド表からレビュー担当と初回承認の列を消す。完了報告に参加者の節を足す(参加者の記録が無ければ「記録なし」)。改修計画の見出しからレビュー担当を消す -- **満たす受け入れ条件:** AC37、AC41 -- **進め方:** 報告と改修計画の出力のテストを先に書く - -### Task 5: 手順書とリポジトリの根の指示書を実装に合わせる - -- **対象ファイル:** `SKILL.md`、`docs/01-state-and-propose.md`、`CLAUDE.md` -- **変更内容:** 担当の決め方を 1 つの表にし、引数の表と `argument-hint` に 3 つの引数を足し、前提の「すべてログイン済み」とホストごとの CLI の表を直す。`init` の変数の表から適用専用の母集合を消す。指示書の cross-refactoring の節を新しい母集合と輪番と上限の既定 3 に直し(#736)、cross-review の節を席の規則に直す -- **満たす受け入れ条件:** AC42、AC43 -- **進め方:** 文書の語の検査(既存のテスト)に期待を足してから直す - -### Task 6: 呼び手の無くなった旧関数を消す - -- **対象ファイル:** `assignment.py`、`auth.py`、共通層のテスト、cross-review と cross-refactoring の旧関数のテスト -- **変更内容:** 従来の確認・適用専用の母集合・従来の席と適用の割り当てを消す。消した関数を期待値に使っていたテストは、変更前の値を定数で持つ形へ直す。4 つの名前が共通層と両 Skill の部品に残らないことをテストで固定する -- **満たす受け入れ条件:** AC7 -- **進め方:** 残っていないことの検査を先に足す(失敗する)→ 消す - -### Task 7: 設計文書を更新し、配布物と検査を通す - -- **対象ファイル:** 設計文書、配布物 -- **変更内容:** 「未確認のまま残ること」に P7 で決めたことを足す。配布物を同期し、全体のテストと 6 つの検査を通す -- **満たす受け入れ条件:** AC49、AC50 -- **進め方:** テスト駆動の対象外(検査の実行) - -## 実装で決めたこと - -| 項目 | 決めたこと | -| --- | --- | -| 状態ファイルの確認の結果の項目(`auth`) | 新規の状態に書かない。読み手が無く、確認を通らなかった者と理由は参加者の記録(`participants.unavailable`)が持つ | -| 確認を行う位置 | 新規の経路で、作業ディレクトリの用意と範囲の関門の後、着手前のテストの前。状態ファイルの有無(新規か再開か)を見てから確かめるためで、再開では担当に関わる引数を渡したときだけ確かめる | -| 反映の表の中身 | 「反映する」は上限 4 つとテストの制限時間。「知らせる」はホスト・範囲・モデル・着手前のテスト・継続的統合の検査の名前・重要度の閾値・同期のコマンド・改修計画のファイル・起動のされ方・作業ディレクトリ root の 10 個。着手前のテストはコマンドで、モデルは全ランタイムの辞書で、作業ディレクトリ root は解決したパスで比べる | -| 引数の型の置き場所 | `--exclude` / `--include` の型(カンマ区切りの名前と予約語 `none`)は cross-refactoring の初期化の部品に置く。共通層へ移すと cross-review の状態の部品も触ることになり、並行する束(G5)と重なる | -| 完了報告の参加者の節 | cross-review と同じ行の形にし、席の埋め合わせの行は持たない(cross-refactoring に席は無い)。ラウンド表からはレビュー担当とモデルの列に加え、レビュー担当の判定から作っていた初回承認の列も消す | -| モデルの警告の対象 | 参加者だけ。既定で外れる agy は、足したときだけ警告する | - -## 影響範囲 - -- cross-refactoring の起動する CLI の集合が変わる(既定で agy が外れ、ホストが提案に入る) -- 担当名の読み手(監視・起動)は参加者の一覧をそのまま使うため変わらない -- 指標の集計(共通層の `metrics.py`)は、古い状態ファイルのレビュー担当を読む経路を残す - -## リスクと対処 - -| リスク | 対処 | -| --- | --- | -| 初期化の関数(`cmd_init`)は新規と再開の 2 経路と確認を 1 関数に持ち、再開の反映を足すと長くなる | タスクごとにテストを通す。再開の経路は別の関数に出す | -| 旧関数を消す時点で、並行する束が新しい呼び手を足している | 消す直前に `develop` と並行する束のブランチを `git grep` で確かめる。消した後の検査テストが継続的統合で拾う | -| 期待値に旧関数を使うテストの意味が変わる | 変更前の値を定数として持ち、テストの主張(3 者のときの席は変更前と一致する)を保つ | - -## 切り戻し手順 - -- この Pull Request を revert すれば元へ戻る。データ移行は無い。新しい形で作った状態ファイルは、戻した後の版では適用専用の母集合が無いため、実行の途中で戻すなら状態ファイルを消して最初から始める - -## 完了の定義 - -- [ ] 上の受け入れ条件をすべて満たし、条件ごとに検証手段と結果が対応している -- [ ] 全体のテスト、配布物の同期の検査、6 つの検査が終了コード 0 で終わる diff --git a/issues/issue-678-requirements.md b/issues/issue-678-requirements.md deleted file mode 100644 index b91ae13b..00000000 --- a/issues/issue-678-requirements.md +++ /dev/null @@ -1,89 +0,0 @@ -# テストの前提: 監視の上限を環境変数で延ばしたシェルでは既定値を前提にするテストが落ち、収束ループの初期化が中断する → テストの実行中は監視の環境変数を共通の前提で外す(#678) - -## 目的 - -無進捗の許容と打ち切りの上限は、環境変数で延ばせる。運用でこれを延ばしたシェルから全体の -テストを起動すると、既定値を前提にするテストが落ちる。落ちた原因はテストの実行環境にあり、 -変更の中身にはない。 - -収束ループ(`cross-refactoring`)の初期化は着手前のテストの通過を条件にするため、上限を -延ばしたシェルでは初期化がそこで止まる。 - -**テストの実行中だけ、監視の環境変数を利用者の環境から切り離す。** 切り離しの置き場所は -リポジトリ直下の共通の前提(`conftest.py`)とし、テストごとに散った除去をそこへ寄せる。 - -## 対象範囲 - -**含む** - -- リポジトリ直下の共通の前提へ、監視の環境変数(接頭辞 `MONITOR_`)を外す仕組みを足す -- テストごとに散った同じ除去を取り除く(1 変数ずつ外す箇所と、接頭辞でまとめて外す箇所) -- 共通の前提が働いていることを確かめるテストを足す - -**含まない** - -- 上限の解決順(担当ごとの指定 → 共通の指定 → 表の既定)の変更 -- 上限の表の値の変更 -- 監視の本体・起動スクリプト・Skill 本文の変更 - -## 前提 - -- 本番の振る舞いも本番コードの構造も変えない。変えるのはテストの前提だけである -- 子プロセスは実行中の環境変数を受け継ぐため、共通の前提で外せば、テストが起動する - 別プロセスにも同じ切り離しが効く -- テストの中で監視の環境変数を設定する箇所(`monkeypatch.setenv`・別プロセスへ渡す - 上書き)は、共通の前提より後に効くため、そのまま働く - -## 着手前の実測(2026-09-21) - -同じコマンドを、監視の環境変数を設定したシェルと、していないシェルで実行した。 - -```console -$ MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q -FAILED plugins/ndf/skills/cross-review/tests/test_launch_agy.py::test_the_print_timeout_defaults_to_the_longest_phase -FAILED plugins/ndf/skills/cross-review/tests/test_monitor_agy.py::test_the_new_name_has_a_stall_default -FAILED plugins/ndf/skills/cross-review/tests/test_monitor_import_safety.py::test_import_succeeds_with_non_numeric_monitor_stall -3 failed, 4600 passed in 182.10s - -$ uv run --with pytest pytest scripts/tests plugins/ndf -q -4603 passed in 179.67s -``` - -| 観測 | 値 | -| --- | --- | -| 収集した件数 | 4603(どちらのシェルでも同じ) | -| 設定したシェルで落ちる件数 | 3 | -| 設定していないシェルで落ちる件数 | 0 | - -課題の本文が記録した 2026-09-15 の観測では落ちるのが 2 件、その後の追記で 3 件だった。 -件数は着手時点の実測で 3 件のまま変わらない。 - -## 受け入れ条件 - -- [x] 受け入れ条件 1: 監視の環境変数(`MONITOR_TIMEOUT_AGY` と `MONITOR_STALL_AGY`)を設定した - シェルで全体のテストを実行すると、失敗が 0 件になる -- [x] 受け入れ条件 2: 設定したシェルと設定していないシェルで、通過した件数と失敗した件数が - 一致する -- [x] 受け入れ条件 3: 共通の前提が接頭辞 `MONITOR_` の環境変数を外していることを、テストが - 直接確かめる(実行中に該当する環境変数が 1 つも残らない) -- [x] 受け入れ条件 4: テストの中で監視の環境変数を設定する箇所は、共通の前提を足した後も - 同じ値を観測できる(共通の前提が個別の設定を打ち消さない) -- [x] 受け入れ条件 5: 接頭辞でまとめて外していた箇所と、1 変数ずつ外していた箇所が、 - 共通の前提へ寄る(対象のファイルに同じ除去が残らない) -- [x] 受け入れ条件 6: 共通の前提は、テストの実行が終わった後に元の環境変数を戻す - -## 検証手段 - -| 条件 | 確かめ方 | -| --- | --- | -| 1 / 2 | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q` と、設定しない同じコマンドの 2 回を実行し、件数を突き合わせる | -| 3 / 4 / 6 | 共通の前提を確かめるテスト(`scripts/tests/test_root_conftest.py`)を実行する | -| 5 | `grep -rn "MONITOR_" --include="*.py" <テストのディレクトリ>` の結果に、接頭辞での除去と 1 変数ずつの除去が残らないことを確かめる | - -## 境界 - -```text -常に行う … 共通の前提の追加、散った除去の削除、両方のシェルでの全体テスト -確認してから行う … 上限の解決順・表の既定値に触れる変更(この変更では行わない) -行わない … 監視の本体・起動スクリプト・Skill 本文の変更、依頼範囲外の整形 -``` diff --git a/issues/issue-727-687-478-664-648-contracts.md b/issues/issue-727-687-478-664-648-contracts.md deleted file mode 100644 index 7091869e..00000000 --- a/issues/issue-727-687-478-664-648-contracts.md +++ /dev/null @@ -1,307 +0,0 @@ -# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(契約 / #727 #687 #478 #664 #648) - -## 目的 - -- **壊れていること**: 状態ファイルは使える者の記録を持たず、初期化は認証の確認を 1 件でも通らないと止まる。再開の初期化は渡された引数を状態へ重ねず、黙って捨てる。cross-refactoring は提案と適用で母集合を 2 つ持つ -- **困る人**: 両 Skill の初期化・ラウンドの開始・結果の受け口・起動スクリプトを実装する人と、状態ファイルを読む完了報告と監視の側 -- **直すと成り立つこと**: 使える者の解決の結果を参加者の記録(`participants`)の 1 つのオブジェクトが持ち、席の名前・引数・関数の形が両 Skill で同じになる。状態に載る引数は再開で「反映する」か「知らせる」のどちらかに必ず載る。この変更の前に始めた実行の状態ファイルは書き換えずに読める - -この文書は形だけを書く。決定の理由と用語の対応は設計文書が持つ(末尾の「関連文書」)。形を書く節のため、表と各節の先頭の指し示しには識別子をそのまま置く。 - -## データ構造(状態ファイル) - -両 Skill の状態ファイルに、参加者の記録と再開で変えた値の記録(`resume_changes`)の 2 項目が増える。cross-refactoring からは、適用専用の母集合の項目(`impl_capable`)とラウンドのレビュー担当(`reviewers`)が消える。既存の状態ファイルは書き換えない。 - -### 両 Skill の最上位に増える 2 項目 - -| 項目 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `participants` | オブジェクト(下の表) | 許さない(項目が無いことは許す) | 使える者の解決の結果。項目が無いのは「この変更の前に始めた実行」 | -| `resume_changes` | オブジェクトの配列 | 許す(空の配列) | 再開で変えた値の記録。追記だけを行う | - -参加者の記録の中身: - -| 項目 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `pool` | 文字列の配列 | 許さない | 母集合の既定(`ALL_RUNTIMES` の順)。cross-review は全ランタイム − ホスト、cross-refactoring は `refactor_pool(host)` | -| `included` | 文字列の配列 | 許す(空) | `--include` で足した者。空は「足していない」 | -| `excluded` | 文字列の配列 | 許す(空) | `--exclude` で外した者。空は「外していない」 | -| `available` | 文字列の配列 | 許す(空。cross-review だけ) | 使える者。`ALL_RUNTIMES` の順。`only` があれば `[only]`。cross-refactoring では 1 者以上 | -| `unavailable` | オブジェクト(名前 → 理由の文字列) | 許す(空) | 確認を通らなかった者と `probe_auth` の `detail`。空は「全員が通った」か「確認を飛ばした」 | -| `probe_skipped` | 真偽値 | 許さない | `NDF_SKIP_AUTH_CHECK` で確認を飛ばしたか。`unavailable` が空である理由を区別する | -| `require_all` | 真偽値 | 許さない | `--require-all` の値。新規の既定は `false` | -| `fallback` | 文字列の配列 | 許す(空) | **cross-review だけ。** 席の埋め合わせに使える者(ホストの確認が通れば `[host]`)。`available` が 2 者以上か `only` があれば空 | - -再開で変えた値の記録の要素は既存の契約と同じ(`at` / `field` / `from` / `to`)。`field` は状態ファイルの鍵である。参加者の記録を作り直したときは、`participants` の 1 件として積む(中の項目ごとには積まない)。 - -### 変わらない項目の意味の変化 - -| Skill | 項目 | 変わること | -| --- | --- | --- | -| 両方 | `host` | 変わらない。再開の `--host` で書き換えない | -| cross-review | `only` | 再開の `--only` で変わる。`--only none` で `null` へ戻る | -| cross-review | `max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` | 再開で明示的に渡したときだけ変わる | -| cross-review | `rounds[].reviewers` | **席の名前**が入る(`claude-2` のような値を取りうる)。過去のラウンドは再開で書き換えない | -| cross-review | `rounds[].<席>` | 鍵が席の名前になる。既存の `rounds[].codex` などはそのまま | -| cross-refactoring | `runtimes` | 使える者(`participants.available`)と同じ値。提案の対象と適用の輪番の両方が読む。既存の読み手(提案の取り込み・`prepare-worktrees.sh`)のために残す | -| cross-refactoring | `max_outer_rounds` / `max_test_rounds` / `max_fix_rounds` / `max_items_per_round` | 再開で明示的に渡したときだけ変わる | -| cross-refactoring | `models` / `target_scope` | 変わらない。再開で違う値が渡されたら知らせる | - -### cross-refactoring の新規の状態から消える項目 - -| 項目 | 理由 | -| --- | --- | -| 最上位の `impl_capable` | 母集合が 1 つになり `runtimes` と同じ値になる(決定 5) | -| `rounds[].reviewers` / `rounds[].reviewer_models` | レビュー工程が #436 で消えており、記録と表示にしか使われない(決定 6) | - -### 実体の関係 - -```mermaid -erDiagram - 状態ファイル ||--|| 参加者 : participants - 状態ファイル ||--o{ 再開で変えた値 : resume_changes - 状態ファイル ||--o{ ラウンド : rounds - 参加者 { - array pool - array included - array excluded - array available - object unavailable - bool probe_skipped - bool require_all - array fallback - } - ラウンド { - int round - array reviewers - string impl - } -``` - -`ラウンド.reviewers` は cross-review、`ラウンド.impl` は cross-refactoring が持つ。 - -### 機能とデータの対応 - -| 機能 | `participants` | `runtimes`(cross-refactoring) | `only` / 上限の項目 | `resume_changes` | `rounds[].reviewers` / `impl` | -| --- | --- | --- | --- | --- | --- | -| F1 使える者の解決(新規の `init`) | C | C | C | C(空) | — | -| F2 席の埋め方(`start-round`) | R | — | R | — | C | -| F3 除外と追加 | C | C | — | — | — | -| F4 適用の輪番(`start-round` / 適用ラウンド) | — | R | — | — | C | -| F5 再開の反映 | U | U | U | U(追記) | R | -| F6 報告 | R | R | R | R | R | - -## 移行と時系列 - -**既存の状態ファイルは書き換えない。** 項目が無いときの読み方を決め、過去の値は再開で変えた値の記録に残す。 - -### 時系列の扱い - -参加者の記録と参加者の一覧(`runtimes`)は上書きし、過去の値は再開で変えた値の記録に事象として積む。ラウンドごとの担当はラウンドの記録(`rounds[]`)が持つ。上書きで失われるのは「どの時点でどの一覧だったか」だけで、それを再開で変えた値の記録が補う。 - -### 項目が無いときの読み方 - -| Skill | 項目が無いとき | 読み方 | -| --- | --- | --- | -| cross-review | `participants` | `host` があれば `review_seats(r, review_pool(host), [])`(変更前の輪番と同じ値)。`host` も無ければ `codex` / `agy` | -| cross-refactoring | `participants` | `runtimes` から `impl_assign` で輪番を決める。`impl_capable` は読まない | -| 両方 | `resume_changes` | 空として読む | - -再開で担当に関わる引数を渡したときだけ、参加者の記録を作り直して書く。渡さない再開では書き足さない。作り直すときの入力は 2 つである。渡した引数と、渡さなかった引数の状態ファイルの値(`participants.included` / `excluded` / `require_all`、最上位の `only`)である。 - -## 初期化の引数 - -両 Skill の初期化が受ける引数と、新規・再開それぞれの経路での扱いを書く。 - -### cross-review の初期化の引数 - -`state.py init` が受ける。 - -| 引数 | 型 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | -| --- | --- | --- | --- | --- | --- | -| `pr` | 整数 | 必須 | — | — | 変わらない | -| `--max-rounds N` | 整数 | `None` | 無ければ 12 | 渡せば反映 | 既定を `None` へ | -| `--rotate-after K` | 整数 | `None` | 無ければ 8 | 渡せば反映 | 既定を `None` へ | -| `--only RUNTIME` | 4 つの名前か `none` | `None` | `none` は無しと同じ | 渡せば反映。`none` で `null` | `none` を追加 | -| `--exclude NAMES` | カンマ区切りの名前。繰り返し可。`none` | `None` | 外す | 渡せば置き換え。`none` で空 | **新設** | -| `--include NAMES` | 同上 | `None` | 足す | 渡せば置き換え。`none` で空 | **新設** | -| `--require-all` / `--no-require-all` | 真偽値 | `None` | 無ければ `false` | 渡せば反映 | **新設** | -| `--host RUNTIME` | 4 つの名前 | `None` | 無ければ推定 | 反映しない。違えば 1 行 | 再開での知らせを追加 | -| `--verify-command CMD` | 文字列。繰り返し可 | `None` | 無ければ空 | 渡せば置き換え | 再開で反映 | -| `--verify-exit-code N` | 整数。繰り返し可 | `None` | 無ければ空 | 渡せば置き換え | 再開で反映 | -| `--worktree` / `--focus` / `--extra-instructions-file` | — | — | — | — | 変わらない | - -### cross-refactoring の初期化の引数 - -`refactor.py init` が受ける。 - -| 引数 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | -| --- | --- | --- | --- | --- | -| `--exclude NAMES` / `--include NAMES` / `--require-all` | `None` | cross-review と同じ | cross-review と同じ | **新設** | -| `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / `--test-timeout` | `None` | 無ければ現行の既定(3 / 2 / 3 / 5 / 900) | 渡せば反映(`replace`) | 既定を `None` へ | -| `--model RT=MODEL` / `--host` / `--scope` / `--baseline-test` / `--ci-check` / `--severity-threshold` / `--sync-command` / `--plan-file` / `--workflow-step` / `--worktree-root` | `None`(`--scope` と `--baseline-test` は必須のまま) | 無ければ現行の既定 | 反映しない。状態と違えば 1 行(`notify`) | 再開での知らせを追加 | - -**状態ファイルに載る引数は、「反映する」(`replace`)か「知らせる」(`notify`)のどちらかに必ず載る**(設計文書の決定 13)。載らないのは状態に載らない引数(cross-review の作業ツリー・観点・追加指示のファイルの 3 つ)だけである。 - -### 名前の検査 - -**名前の検査は 2 段に分かれる。** - -| 段 | 何を見るか | 誰が弾くか | 終了コード | -| --- | --- | --- | --- | -| 1 | 綴り(4 つの名前か `none`) | argparse の型 | 2 | -| 2 | 母集合との関係(`--exclude` / `--only` に母集合(既定 ∪ `--include`)に無い名前(cross-review のホストはこれに当たる) / `--include` と `--exclude` の重なり / `--only` と `--exclude` の矛盾 / `none` と名前の混在) | 共通層の `resolve_participants`(`AssignmentError`)。`init` が Skill の終了コードへ写す | cross-review 1 / cross-refactoring 4 | - -## 出力の契約 - -初期化・ラウンドの開始・結果の受け口・完了報告の出力を書く。標準出力の形は変えず、増えるのは標準エラーの行と席の名前である。 - -### 初期化の出力と終了コード - -標準出力の `KEY=VALUE` は、cross-refactoring から適用専用の母集合の変数(`IMPL_POOL`)が消えるほかは変えない。**増えるのは標準エラーの行だけである。** - -| 場面 | 標準エラーに出るもの | cross-review | cross-refactoring | 状態ファイル | -| --- | --- | --- | --- | --- | -| 新規で全員が使える | 母集合と使える者の 1 行 | 0 | 0 | 作る | -| 新規で確認を通らない者がいる | 通らなかった者と理由を 1 者 1 行 | 0 | 0 | 作る | -| 新規で使える者が 2 者に満たない(cross-review) | 埋め合わせの相手を 1 行(`席をホストで埋めます` / `同じランタイムの 2 つ目で埋めます`) | 0 | — | 作る | -| 新規で使える者が 0 者 | 使える者がいない理由 | ホストで埋められれば 0。埋められなければ 1 | 4 | 0 のときだけ作る | -| 確認を飛ばした | 飛ばしたことを 1 行(`auth.py` の既存の文言) | 0 | 0 | 作る(`probe_skipped: true`) | -| `--require-all` で欠けがある | 欠けた者と理由 | 1 | 4 | 作らない | -| 名前の矛盾 | 何が矛盾したか | 1 | 4 | 作らない | -| 再開で引数を反映した | 項目ごとに `<項目>: <旧> → <新>` の 1 行 | 0 | 0 | 書き換える | -| 再開で反映しない引数が状態と違う | 引数ごとに 1 行 | 0 | 0 | 変えない | -| 再開で作り直した使える者が 0 者 / 欠けあり | 新規と同じ | 1 | 4 | 書き換えない | - -### ラウンドの開始の出力 - -| Skill | 変わること | -| --- | --- | -| cross-review | `REVIEWERS` / `REVIEWERS_CSV` に席の名前が並ぶ(`codex claude-2` のような値を取りうる)。形は変わらない | -| cross-refactoring | `REVIEWERS` / `REVIEWERS_CSV` を出さない。`RUNTIMES` / `RUNTIMES_CSV` / `IMPL` / `IMPL_MODEL` は変わらない | - -### 結果の受け口と起動スクリプトの席の受け口(cross-review) - -| 受け口 | 変更前 | 変更後 | -| --- | --- | --- | -| `state.py read-result ` | `choices=ALL_RUNTIMES` | 席の名前(`seat_runtime` で検査)。通らなければ argparse の終了コード 2 | -| `launch-reviewer.sh ` | `case` で 4 つの名前を検査 | 席の名前を受け、CLI は `${SEAT%%-*}`(`seat_runtime` と同じ規則)で選ぶ。stem は席の名前で組む | -| `critique.sh ` | 同上 | 同上 | -| `monitor.py --agents` | 担当名の CSV | 席の名前の CSV。stem を組むだけなので変更は無い | - -### 完了報告の出力(cross-review) - -現行の「PR 履歴」の後に、次の節を足す。 - -```text -## 参加した者 -- 母集合: codex / agy / kiro -- 使える者: codex / kiro -- --exclude で外した者: agy -- --include で足した者: なし -- 確認を通らなかった者: なし -- 席の埋め合わせ: なし -- 再開で変えた値: 2026-09-19T12:00:00 participants … → … -``` - -参加者の記録を持たない状態ファイルでは「使える者: 記録なし」と出す。確認を飛ばした印(`probe_skipped`)が真のときは「確認を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。cross-refactoring の完了報告は、現行の「提案・レビュー」と「適用の母集合」の 2 行を「参加者」の 1 行にし、参加者の記録を持てば同じ節を足す。 - -## 関数の形 - -共通層の 3 ファイルと、各 Skill の内部関数の入出力を書く。 - -### 共通層の関数: 割り当て - -置き場所は `lib/assignment.py` である。 - -| 関数 | 入力 | 出力 | 失敗の形 | 変更 | -| --- | --- | --- | --- | --- | -| `review_pool(host)` | ホスト名 | 全ランタイム − ホスト | ホストでない名前で `AssignmentError` | 変わらない | -| `refactor_pool(host)` | ホスト名 | `ALL_RUNTIMES` の順で、`DEFAULT_REFACTOR_RUNTIMES`(`("codex", "kiro")`)とホスト | 同上 | **新設** | -| `resolve_participants(pool, *, host, include=(), exclude=(), only=None, probe, require_all=False)` | 母集合、ホスト名、足す・外す名前、`--only`、確認の関数(`probe_auth` の形)、全員を要するか | `Participants`(`participants` の 8 項目のうち `fallback` を除く 7 項目を持つデータクラス。`to_state()` で辞書にする) | 名前の矛盾(`exclude` / `only` の名前が母集合(既定 ∪ `include`)に無い場合もここ。cross-review のホストはこれに当たる)・`require_all` で欠け → `AssignmentError`(欠けた者と理由を並べる) | **新設** | -| `review_seats(round_no, available, fallback)` | ラウンド番号、使える者、埋め合わせに使える者 | 席の名前 2 つ(`only` は呼び出し側が先に処理する) | `round_no < 1` / `available` と `fallback` が両方空 → `AssignmentError` | **新設**(`review_assign` を置き換える) | -| `impl_assign(round_no, participants)` | ラウンド番号(適用の通し番号)、使える者 | 実装担当 1 者(`participants[round_no % len]`) | `round_no < 1` / 空 → `AssignmentError` | **新設**(`assign` を置き換える) | -| `seat_runtime(seat)` | 席の名前 | ランタイム名 | 形に合わない → `AssignmentError` | **新設** | -| `SEAT_PATTERN` | — | `^(claude\|codex\|agy\|kiro)(-[2-9])?$` | — | **新設** | -| `impl_pool()` / `review_assign()` / `assign()` | — | — | — | **消す**(P7) | - -席の埋め方(`review_seats`)の規則: - -| 使える者の数 | 席 | -| ---: | --- | -| 3 以上 | `pool[round_no % n]` と `pool[(round_no + 1) % n]` を母集合の順に並べた 2 席 | -| 2 | その 2 者 | -| 1 | その 1 者と、`fallback` のうち `available` に含まれない先頭の者。そのような者が無ければ同じランタイムの 2 つ目(`<名前>-2`) | -| 0 | `fallback` の先頭と、その 2 つ目(`<名前>-2`)。`fallback` が空なら `AssignmentError` | - -埋め合わせの候補は使える者に含まれない者だけを使い、含まれる者は飛ばす。同じ席の名前を 2 つ返さないための規則である。例は、足す者の指定でホストが使える者に入った cross-review である。使える者がホストだけで埋め合わせもホストなら、席はホストとその 2 つ目(`["claude", "claude-2"]`)になる。 - -### 共通層の関数: 確認 - -置き場所は `lib/auth.py` である。 - -| 関数 | 入力 | 出力 | 失敗の形 | 変更 | -| --- | --- | --- | --- | --- | -| `probe_auth(runtimes, *, info, env=None)` | 確かめる名前の一覧 | `(結果, 飛ばしたか)`。結果は名前 → `{"command", "ok", "detail"}` | 例外を上げない。コマンドが無い・時間切れ・終了コード非 0・未認証の文言は `ok: false` と `detail` | **新設** | -| `check_auth(...)` | — | — | — | **消す**(P7。P6 では残る) | -| `AUTH_PROBES` / `UNAUTHENTICATED_MARKERS` / `AUTH_PROBE_TIMEOUT` / `SKIP_ENV` | — | — | — | 変わらない | - -### 共通層の関数: 再開の反映 - -置き場所は `lib/statefile.py` である。 - -| 関数 | 入力 | 出力 | 失敗の形 | 変更 | -| --- | --- | --- | --- | --- | -| `apply_resume_args(state, args, spec)` | 状態、argparse の名前空間、反映の表 | 標準エラーへ出す行の一覧。`state` を書き換え、`resume_changes` に積む。書き込みは呼び出し側が 1 回で行う | 上げない | **新設** | -| `ResumeField(arg, key, mode)` | 引数の属性名、状態ファイルの鍵、`replace` / `notify` | — | — | **新設** | - -反映の表(`spec`)は Skill ごとに持つ。「反映する」(`replace`)の項目は未指定でない値を状態へ書く。「知らせる」(`notify`)の項目は、状態と違うときだけ「反映しない」の行を返す。値が同じ項目は行を返さず、再開で変えた値の記録にも積まない。 - -### cross-review の内部関数 - -置き場所は `state.py` である。 - -| 関数 | 契約 | 変更 | -| --- | --- | --- | -| `_resolve_reviewers(host, args)` | `resolve_participants(review_pool(host), host=host, …)` を呼び、`available` が 2 者に満たなければホストを `probe_auth` で確かめて `fallback` を決める。`only` があるときはホストを確かめず `fallback` は空にする(決定 9: `--only` は埋め合わせをしない)。`AssignmentError` は `die(code=1)` へ写す | **新設**(`_auth_targets` / `_validate_only` を置き換える) | -| `_round_reviewers(st, round_no)` | 決定 11 の順で返す | 順を変える | -| `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | `apply_resume_args` を呼び、担当に関わる引数があれば、渡さなかった引数を状態ファイルの値で補って `_resolve_reviewers` で作り直す。失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | -| `_guard_previous_round(st, prev)` | `prev["reviewers"]`(無ければ `_round_reviewers`)を渡す | 担当を渡す | - -### cross-refactoring の関数 - -置き場所は `refactor_lib` である。 - -| 関数 | 契約 | 変更 | -| --- | --- | --- | -| `commands/setup.py cmd_init` | `resolve_participants(refactor_pool(host), …)` を呼び、`runtimes` と `participants` を書く。`AssignmentError` は `die`(終了コード 4) | 母集合と確認を置き換える | -| `commands/setup.py cmd_init`(再開) | `apply_resume_args` を呼び、担当に関わる引数があれば、渡さなかった引数を状態ファイルの値で補って作り直す | 反映を足す | -| `commands/setup.py cmd_start_round` | `impl_assign(round_no, state["runtimes"])`。`reviewers` を書かない | 担当の決め方を替える | -| `commands/apply.py` / `commands/gate.py` の `assign(seq, host)` | `impl_assign(seq, state["runtimes"])` | 呼び方を替える(各 1 行) | - -## 手順書の骨組み(cross-review) - -対象は `SKILL.md` と `docs/01` である。初期化へは値のある引数だけを渡し、担当はラウンドの開始が返す一覧を使う。 - -```bash -INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ - ${MAX_ROUNDS:+--max-rounds "$MAX_ROUNDS"} ${ROTATE_AFTER:+--rotate-after "$ROTATE_AFTER"} \ - ${HOST:+--host "$HOST"} ${ONLY:+--only "$ONLY"} \ - ${EXCLUDE:+--exclude "$EXCLUDE"} ${INCLUDE:+--include "$INCLUDE"} \ - ...) || exit $? - -for r in $REVIEWERS; do "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND"; done -"$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$REVIEWERS_CSV" || true -for r in $REVIEWERS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true; done -"$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" $REVIEWERS -``` - -**ラウンドの上限と交代の間隔(`--max-rounds` / `--rotate-after`)も値があるときだけ渡す。** 現行の骨組みは上限を常に渡すため、再開のたびに利用者が指定していない値で上書きする。 - -## 関連文書 - -| 文書 | 何を持つか | -| --- | --- | -| [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) | 決定の理由(「決定の記録」)と用語の対応表。この文書はその続きである | -| [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) | 受け入れ条件 AC1〜AC50 | -| [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) | 既存の契約(PR #667)。この文書はその P5 の部分を置き換える。既存の 6 項目(`available_reviewers` ほか)は参加者の記録の 1 つのオブジェクトに畳み、cross-refactoring も同じ形を持つ | diff --git a/issues/issue-727-687-478-664-648-design.md b/issues/issue-727-687-478-664-648-design.md deleted file mode 100644 index 80cada95..00000000 --- a/issues/issue-727-687-478-664-648-design.md +++ /dev/null @@ -1,504 +0,0 @@ -# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(設計 / #727 #687 #478 #664 #648) - -## 目的 - -- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の初期化が止まる。収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを 1 者指定などの引数を変えて再開しても、引数が黙って無視される(#648) -- **困る人**: 4 つの CLI が揃っていない環境で収束ループを回す利用者と、中断したループを進め方を変えて再開する利用者 -- **直すと成り立つこと**: 使える者の決定・席の埋め方・再開の反映の 3 つの規則を、両 Skill が共有する共通層が 1 か所ずつ持つ。両 Skill の初期化は共通層の結果を状態ファイルと終了コードへ写すだけになり、1 者欠けても止まらない。cross-review は毎ラウンド 2 席を確保し、cross-refactoring の既定の参加者は codex / kiro とホストになる - -この文書は「どう作るか」を扱う。実装は 2 本の Pull Request(P6 / P7)に分ける(決定 19)。要求・契約・既存の設計との関係は末尾の「関連文書」にある。 - -## 用語の対応表 - -本文は左の業務用語で書く。識別子は表・コードブロック・「置き場所」と、業務用語の初出の括弧書きにだけ置く。 - -| 業務用語 | 識別子 | 何を指すか | -| --- | --- | --- | -| 初期化 | `init`(cross-review は `state.py init`、cross-refactoring は `refactor.py init`) | 収束ループを新規に始める・再開する副コマンド | -| ラウンドの開始 | `start-round` | 次のラウンドを開き、担当を決めて返す副コマンド | -| 結果の受け口 | `read-result` | 担当の結果ファイルを状態ファイルへ写す副コマンド | -| 完了報告 | `report` | 収束の結果を出す副コマンド | -| 使える者の解決 | `resolve_participants`(`lib/assignment.py`) | 母集合・足す者・外す者・確認の結果から使える者を決める関数。結果は参加者の記録 | -| 止めない確認 | `probe_auth`(`lib/auth.py`) | 認証の確認コマンドを走らせ、止めずに結果だけを返す関数 | -| 従来の確認 | `check_auth` | 1 件の失敗で止める確認。P7 で消す | -| 母集合の既定 | `review_pool(host)` / `refactor_pool(host)` | Skill ごとの参加者の出発点を返す関数 | -| 既定の参加者の表 | `DEFAULT_REFACTOR_RUNTIMES` | cross-refactoring の既定(codex / kiro) | -| ランタイムの固定の順 | `ALL_RUNTIMES` | claude / codex / agy / kiro の順 | -| 席の埋め方 | `review_seats` | 使える者と埋め合わせから 2 席を返す関数。従来の `review_assign` を置き換える | -| 適用の輪番 | `impl_assign` | 参加者から実装担当 1 者を返す関数。従来の `assign` を置き換える | -| 適用専用の母集合 | `impl_pool()` / `IMPL_POOL` / `impl_capable` | 関数・初期化の出力変数・状態ファイルの項目。3 つとも消す | -| 席の名前の解釈 | `seat_runtime` / `SEAT_PATTERN` | 席の名前からランタイムを引く関数と、形の検査 | -| 再開の反映 | `apply_resume_args` / `ResumeField`(`lib/statefile.py`) | 再開で渡した引数を表に従って状態へ重ねる関数と、表の 1 行 | -| 足す者 / 外す者 | `--include` / `--exclude` | 参加者を名指しで足す・外す引数 | -| 1 者指定 | `--only`(シェル変数 `$ONLY`) | 担当を 1 者に固定する引数 | -| 全員を要する指定 | `--require-all` | 確認の失敗が 1 者でもあれば止める引数 | -| 指定を外す値 | `none` | 再開で 1 者指定・足す者・外す者を空へ戻す予約語 | -| 参加者の記録 | `participants`(`available` / `unavailable` / `fallback` ほか) | 状態ファイルの項目。使える者の解決の結果 | -| 埋め合わせ | `fallback` | 席が足りないときに使う者(ホスト) | -| 参加者の一覧 | `runtimes` | cross-refactoring の状態ファイルの項目。提案の対象と適用の輪番が読む | -| 再開で変えた値の記録 | `resume_changes` | 状態ファイルの項目。追記だけを行う | -| 担当の一覧の出力 | `REVIEWERS` / `REVIEWERS_CSV` | ラウンドの開始が返すシェル変数 | -| ラウンドの担当の記録 | `rounds[].reviewers` / `rounds[].impl` | ラウンドごとの席(cross-review)と実装担当(cross-refactoring) | -| 割り当ての失敗 | `AssignmentError` | 共通層が名前の矛盾と欠けを返す例外 | -| 担当の読み出し / 前ラウンドの検査 | `_round_reviewers` / `_guard_previous_round` | cross-review の内部関数 | -| P6 / P7 | — | 実装の Pull Request の 1 本目(共通層と cross-review)と 2 本目(cross-refactoring と旧関数の削除) | -| G2〜G5 | — | 並行して進む他の設計の束(#732 / #729 / #728 / #730) | - -## 機能一覧 - -6 つの機能を、使う人と出す Pull Request と対で並べる。 - -| # | 機能 | 誰が使うか | Pull Request | -| --- | --- | --- | --- | -| F1 | 確認を通らない者を外し、使える者で収束ループを始める。使えない者と理由を残す | CLI の一部が導入・認証されていない利用者 | P6 / P7 | -| F2 | cross-review の各ラウンドに 2 席を確保する(使える者 → ホスト → 同じランタイムの 2 つ目) | 使える者が 2 者に満たない利用者 | P6 | -| F3 | 外す者・足す者の指定で参加者を名指しで外す・足す | 打ち切り・利用上限が分かっている担当を避けたい利用者、agy を戻したい利用者 | P6 / P7 | -| F4 | cross-refactoring の既定の参加者を codex / kiro / ホストにし、適用の輪番をその中で回す | cross-refactoring の利用者 | P7 | -| F5 | 再開で明示的に渡した引数を反映し、反映しない引数を知らせる | 中断したループを進め方を変えて再開する利用者 | P6 / P7 | -| F6 | 完了報告に参加者・外した者・足した者・確認を通らなかった者・席の埋め合わせ・再開で変えた値を出す | 収束の結果を読む人 | P6 / P7 | - -## 実測 - -決定の根拠になる値である。`develop` のコミット 9eaebe14、Python 3.14 で、割り当ての部品(`assignment.py`)を読み込んで式を突き合わせた。 - -| 場面 | 結果 | -| --- | --- | -| 席の式 `{pool[r % n], pool[(r + 1) % n]}` を母集合の順に並べる(n = 3) | 4 ホスト × ラウンド 1〜12 の全組で、変更前の `review_assign(r, host)` と一致(不一致 0 件) | -| 同じ式で n = 4 | ラウンド 1〜4 の席は `codex agy` / `agy kiro` / `claude kiro` / `claude codex`。各者ちょうど 2 回 | -| n = 2(`codex` / `kiro`) | ラウンド 1〜4 すべて `codex kiro` | -| n = 1 / n = 0 | `codex claude`(ホストあり)/ `codex codex-2`(ホストなし)/ `claude claude-2`(0 者・ホストあり) | -| `impl_assign` を `participants[r % n]` で `["claude", "codex", "kiro"]` に当てる | ラウンド 1〜6 で `codex` / `kiro` / `claude` / `codex` / `kiro` / `claude`。変更前の `assign(r, "claude")` は `codex` / `agy` / `kiro` / `claude` / … | -| 席の形 `^(claude\|codex\|agy\|kiro)(-[2-9])?$` | `kiro` / `kiro-2` / `claude-9` が一致し、`kiro-1` / `kiro-10` / `gemini` / `kiro-2-3` は一致しない | -| stem の逆解析 | `split("-")` / `rsplit` / `.stem` / `re.match(.*agent` を cross-review のスクリプトと `monitor.py` で検索し、担当名へ戻す箇所は 0 件(当たった 1 件は PR のファイル種別の判定) | -| cross-refactoring の `$REVIEWERS` の読み手 | `SKILL.md` / `docs/` / `prompts/` で `reviewer` が 0 件。`setup.py` が出すだけで、読むのはテスト `test_start_round_emits_runtimes.py` の期待値だけ | -| cross-refactoring の再開で反映される引数 | `init` の 15 引数のうち 0 個(`_apply_post_event` の投稿の扱いだけを毎回入れ直す) | -| 既存のテストの数 | `uv run --with pytest pytest scripts/tests plugins/ndf -q --co` で 4413 件 | - -担当名を鍵・分岐・選択肢に使う箇所の一覧(10 か所の受け口を含む)は、調査の控えから「構成要素」の表へ写した。控え(`survey-727-agent-keys.md`)は設計 Pull Request のレビューの間だけ scratchpad に置く。 - -## 決定の記録 - -20 件を 6 つの塊に分ける。 - -| 決定 | 扱うこと | -| --- | --- | -| 1 | 文書の置き方 | -| 2〜4 | 使える者の決定 | -| 5〜8 | cross-refactoring の母集合 | -| 9〜12 | cross-review の席 | -| 13〜18 | 再開と骨組み | -| 19〜20 | 分け方と規則の置き場所 | - -### 決定 1: 通過済みの決定と新しい決定を差分で読み分けるために、設計文書は親 #727 の名前で新設し、既存の本体は書き換えない - -既存の設計は P4 と P5 を 1 つの文書で扱い、P4 は #732 が別に進める。P5 の節をその場で書き換えると、#732 が読む P4 の決定と、この変更で変わる P5 の決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの「決定の記録」の見出しをすべて写す。そのため 1 行でも触ると既存の 19 件がこの Pull Request の決定として並ぶ。案内は「決定の記録」を持たない要求と契約の文書にだけ足す(#729 の設計と同じ扱い)。 - -### 決定 2: 使える者を決める規則を 2 か所に持たないために、決定を共通層の 1 つの関数へ移し、両 Skill の初期化は結果を終了コードへ写すだけにする - -いまは cross-review と cross-refactoring の初期化が、それぞれ母集合を作る。前者は `_auth_targets` / `_validate_only`、後者は `cmd_init` が持つ。どちらも従来の確認を呼び、1 件の失敗で止める。使える者を決める規則を Skill ごとに書くと、母集合の作り方・除外の検査・確認の扱いが 2 か所にでき、片方だけが古くなる(親 #727 が採る手「責務の移動」)。**共通層に使える者の解決(`resolve_participants`)を 1 つ置く。** 入力は母集合・ホスト・足す者・外す者・1 者指定・確認・全員を要するかで、参加者の記録を返す。外す者と 1 者指定の名前が母集合に含まれるかの検査も、ホストを受け取るこの関数が持つ。cross-review ではホストが母集合に無いため、ホストを外す指定はここで弾かれる。cross-refactoring ではホストが母集合にあるため外せる。Skill が持つのは 2 つだけである。返った値を状態ファイルへ書くことと、割り当ての失敗を自分の終了コード(cross-review 1 / cross-refactoring 4)へ写すことである。 - -既存の設計の決定 10(cross-refactoring は変えず、従来の確認を残す)は採らない。cross-review だけを直すと、同じ層を使う cross-refactoring に「1 者欠けると初期化ごと失敗する」形が残る(#664)。 - -### 決定 3: 1 者の失敗で全体を止めずに使える者を残すために、使えるかの確認は止めない 1 つの関数に寄せ、確認コマンドは変えない - -従来の確認(`check_auth`)は確認と中断を 1 つの関数が持つため、「使える者で回す」経路から呼べない。止めない確認(`probe_auth`)は結果だけを返し、止めるかどうかは使える者の解決の「全員を要するか」が決める。**両 Skill が止めない確認へ移った時点で従来の確認は呼び手を失うため消す**(P7)。確認コマンド(`AUTH_PROBES`)と未認証の文言は変えない。言語モデルを引く最小の呼び出しへ替える判断は所要の実測が要るため #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口と、通らなかった理由を参加者の記録に残す形である。 - -### 決定 4: ホストが変わっても一覧を書き直さずに済むように、参加者は「母集合の既定に足す者を加え、外す者を除く」で決め、既定は Skill ごとの関数が持つ - -外したい理由は「この担当が落ちる」であり、名指しするのは外す側である(外す者 `--exclude`)。戻したい理由は「既定から外れている者を入れたい」で、これも名指しである(足す者 `--include`)。使う側を並べる形(`--reviewers codex,kiro`)は、ホストが変わると一覧を書き直すことになるため採らない。**既定は Skill ごとの関数が持ち、使える者の解決はどちらを渡されても同じ規則で解決する。** - -| Skill | 既定を返す関数 | 中身 | -| --- | --- | --- | -| cross-review | `review_pool(host)` | 全ランタイム − ホスト | -| cross-refactoring | `refactor_pool(host)` | ランタイムの固定の順で、既定の参加者の表(codex / kiro)とホスト | - -cross-review の足す者は、ホストを母集合へ入れる用途になる(#687 の「ホストの参加を許す」の明示的な形)。 - -### 決定 5: 提案と適用を同じ者で回すために、cross-refactoring の母集合を 1 つにし、適用専用の母集合を消す - -既定の参加者を codex / kiro / ホスト(既定の参加者の表とホスト)にすると、提案の母集合と適用の母集合が同じ集合になる。適用専用の母集合を関数として残した理由(「両者は一致しない」)が消える。そのため関数・状態ファイルの項目・初期化の出力変数の 3 つ(`impl_pool()` / `impl_capable` / `IMPL_POOL`)を消す。参加者の一覧(`runtimes`)は提案の取り込みと作業ツリーの準備(`prepare-worktrees.sh`)が読むため残し、適用の輪番も同じ値を読む。 - -agy を既定から外す理由は #664 の実測にある。CLI の起動 199 回のうち失敗は agy の 7 回(停止 4 / 結果なし 3)だけで、提案の所要の中央値も agy が最も長い(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で決まっていた。戻す手段は足す者の指定(`--include agy`)である。 - -### 決定 6: 存在しない役の記録を読み手に見せないために、cross-refactoring のレビュー担当を消し、担当の割り当ては実装担当 1 者だけを返す - -cross-refactoring のレビュー工程は #436 で消え、Step 7 の cross-review が担う。従来の割り当て(`assign()`)が返すレビュー担当が流れる先は 3 つだけである。 - -| 流れる先 | 読む側 | -| --- | --- | -| ラウンドの記録(`reviewers` / `reviewer_models`) | 改修計画と報告の表示 | -| ラウンドの開始の出力(`REVIEWERS` / `REVIEWERS_CSV`) | 無い(骨組み・文書・プロンプトで `grep -rn -i reviewer` が 0 件) | -| `record_observed_model` のレビュー側の枝 | 無い(呼び出しは `role="impl"` の 1 か所だけ) | - -**#664 の「使える者が 2 者だとレビュー担当が 1 者になる」は、存在しない役の記録の話である。** 役を消せば、席を埋める規則を cross-refactoring に持ち込む必要が無い。適用の輪番(`impl_assign`)は実装担当 1 者だけを返す。 - -レビュー担当を「記録のためだけに」残す案は採らない。読み手が「このラウンドはこの 2 者がレビューした」と読む。 - -### 決定 7: ホストが最初に適用する形にならないように、適用の輪番は「ラウンド番号を参加者の数で割った余り」の式とランタイムの固定の順を保つ - -いまの割り当ては、4 者の固定の順をラウンド番号で割った余りで引く(`pool[round_no % 4]`)。ラウンド 1 が codex から始まる。式を変えずに除数を参加者の数にすれば、ホスト claude の既定(claude / codex / kiro)でも codex → kiro → claude の順になる。ホストが最初に適用する形にならない(「実測」)。ラウンド 1 から順に並べる式(`(round_no - 1) % n`)は、ホストが先頭に来るため採らない。 - -### 決定 8: 適用ラウンドの群の分け方を変えないために、提案者と適用者が同じランタイムになることを避けない - -ホストが提案に入るため、ある項目の提案者と適用者が同じランタイムになりうる。適用ラウンドは複数の提案者の項目を 1 つの群にまとめるため、群ごとに提案者を避けると群の分け方そのものを変えることになる。**避けない。** 適用の結果は検証(テスト)と Step 7 の cross-review が見る。#687 の「コードを書いたランタイムが担当に入ってよい」と同じ判断である。 - -### 決定 9: 使える者が足りなくても毎ラウンド 2 つの目で見るために、cross-review の席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める - -#687 の 4 つの規則のうち「各ラウンドで 2 者」を最優先に置き、残る 3 つを埋める順序として読む。**違うランタイムを先に使う。** 同じ言語モデルの 2 つの文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる。ホストは既定の母集合に無いため、使える者が 1 者のときだけ席に入る。同じランタイムの 2 つ目(`<名前>-2`)は、ホストも使えないときの最後の手段である。 - -**埋め合わせの候補は使える者に含まれない者だけを使う。** 足す者でホストが使える者に入っているとき、ホストを埋め合わせにも使うと同じ席の名前が並ぶ。含まれる者は飛ばす。それでも 2 席に満たなければ同じランタイムの 2 つ目を充てる。使える者がホストだけで埋め合わせもホストなら、席はホストとその 2 つ目(`claude` / `claude-2`)になる。 - -| 使える者の数 | 席 | -| ---: | --- | -| 3 以上 | 輪番で 2 席(3 者のときは変更前の値と一致する) | -| 2 | その 2 者 | -| 1 | その 1 者とホスト。ホストが使えないか、その 1 者と同じなら同じランタイムの 2 つ目 | -| 0 | ホストとその 2 つ目。ホストも使えなければ失敗 | - -1 者指定は利用者が 1 席と決めた指定であり、埋め合わせをしない(既存の決定 11 のまま)。ホストの確認も行わない。使える者が 2 者のとき輪番で 1 者を外す案は、毎ラウンド 1 席になるため採らない。 - -### 決定 10: 同じランタイムの 2 つ目を結果ファイルと状態ファイルで区別するために、担当の単位を「席の名前」にし、形はランタイム名か「ランタイム名に 2〜9 の接尾辞」とする - -同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。**1 つ目の席の名前はランタイム名そのままにする。** 埋め合わせが要らない実行では、いまと同じ名前しか現れない。接尾辞の区切りはハイフンで、ランタイム名にハイフンを含むものが無いため、シェル側の切り出し(`${SEAT%%-*}`)と席の名前の解釈(`seat_runtime`)が同じ規則になる。stem を逆に解析して担当名へ戻す箇所は無い(「実測」)ため、stem 側の変更は無い。 - -受け口は 10 か所で、いずれも「CLI を選ぶ分岐に席の名前の解釈を通す」か「選択肢の検査を席の形の検査に替える」のどちらかである(「構成要素」の表)。1 者指定とホストの引数はランタイム名のままで、席の名前を取らない。 - -### 決定 11: 再開で 1 者指定を変えても過去のラウンドの担当が変わらないように、担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む - -```text -ラウンドの reviewers → only → participants の席 → host の輪番(review_pool)→ codex / agy -``` - -既存の決定 12 と同じ理由である。再開で 1 者指定を変えられるようにすると、1 者指定を先に見る現行の順では過去のラウンドの担当まで変わる。前ラウンドの検査にも、そのラウンドの記録の担当を渡す。現行は担当を渡さず codex / agy で数えるため、担当が agy + kiro のラウンドで codex を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 - -### 決定 12: 全員が揃わないなら始めたくない運用のために、全員を要する指定で従来の関門を選べるようにする - -全員を要する指定(`--require-all`)は、全員が揃わないなら始めたくない運用のために残す(既存の決定 9)。付けると、確認を通らない者が 1 者でもいれば従来の文言で失敗する。外す者で外した者は揃っていなくてよい。両 Skill に同じ引数を置く。 - -### 決定 13: 黙って捨てる引数を残さないために、再開の反映を共通層の 1 つの関数に置き、Skill ごとの表で「反映する」「知らせる」を決める - -#648 の修正レイヤーは状態ファイルの部品(`statefile.py`)である。cross-review の再開の経路(`_resume_from_state`)だけを直すと、cross-refactoring の再開の経路(引数由来の項目を 1 つも反映しない)が残る。**共通層の再開の反映(`apply_resume_args`)が持つのは 1 つの規則だけである。値のある引数を状態へ書き、再開で変えた値の記録に積み、反映しない引数は状態と違うときだけ知らせる。どの引数がどちらかは Skill ごとの表が持つ。** - -**状態ファイルに載る引数は、表のどちらかに必ず載る。** 載らない引数は状態に載らないもの(作業ツリー・観点・追加指示のファイルの 3 つ)だけである。黙って捨てる引数を残さないためで、基準テストの引数のように再開でも渡す必須の引数も「違えば知らせる」に載る。そのため状態に載る引数の既定はすべて未指定(`None`)にし、新規の経路が定数の既定へ置き換える。既定値と同じ値なら渡していないとみなす案は、上限 12 の指定で 20 から 12 へ戻す操作を区別できないため採らない。 - -「知らせる」に置く引数は 2 種類ある。 - -| 種類 | 引数 | -| --- | --- | -| 変えると過去のラウンドと突き合わせられなくなる | `--host` / `--scope` / `--model` | -| 初期化の時点で 1 度だけ効く | `--baseline-test` / `--worktree-root` / `--plan-file` など | - -### 決定 14: 途中で担当が入れ替わって前のラウンドと突き合わせられなくならないように、担当に関わる引数を渡した再開でだけ、確認し直して参加者を作り直す - -1 者指定・外す者・足す者・全員を要する指定のいずれかを渡した再開では、決定 2 と同じ手順で参加者を作り直す。**渡さなかった引数は状態ファイルの値で補う。** ホストを足して始めた実行へ agy を外す指定だけを渡した再開では、足した者の記録はホストのまま残り、外した者だけが agy になる。渡さなかった引数を初期値へ戻すと、「明示した引数だけを反映する」(決定 13)が破れる。いずれも渡さない再開では確かめ直さない。途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなるためである。作り直した結果が失敗(0 者、全員を要する指定で欠け)なら、状態ファイルを書き換えずに終了コードで終わる。反映は再開の後に開くラウンドから効く(要求の前提 3)。 - -### 決定 15: 骨組みが空文字列を渡さない形でも指定を外せるように、再開で指定を外す値は「無し」を表す予約語にする - -予約語は `none` である。1 者指定に渡すと `null` へ、外す者・足す者に渡すと一覧を空へ戻す。空文字列は、骨組みが値のあるときだけ引数を渡す形(`${ONLY:+--only "$ONLY"}`)のため、外す手段にならない。新規の経路で予約語を渡すと、渡さないのと同じになる。 - -### 決定 16: 途中から誰を外したかを完了報告で読めるように、再開で変えた値は変更の記録に積み、参加者の作り直しは 1 件として積む - -上書きだけでは、どの時点で何を変えたかが失われ、完了報告で「途中から agy を外した」ことが読めない。参加者の記録の中の項目ごとに積むと 1 回の再開で最大 7 件になり、報告で読みにくい。参加者の記録全体の前後を、再開で変えた値の記録(`resume_changes`)に 1 件として積む。 - -### 決定 17: 状態ファイルとシェル変数がずれても起動と監視が誰かに当たるように、骨組みは 1 者指定のシェル変数で絞らず、ラウンドの開始が返す席を使う - -ラウンドの開始が返す担当の一覧(`REVIEWERS`)は、1 者指定と席の埋め合わせを反映済みである。シェル変数でもう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も誰にも当たらない(#648 の 3 段の経過)。手順書(`SKILL.md` / `docs/01`)の Step 2 と Step 2.5 を担当の一覧(`$REVIEWERS` / `$REVIEWERS_CSV`)に揃える。1 者指定のシェル変数(`$ONLY`)は初期化へ渡す 1 行にだけ残す(既存の決定 18)。 - -### 決定 18: 一時的な打ち切りで担当が恒久的に外れないように、起動した後に分かる使えなさで担当を自動的に外す仕組みは作らない - -利用上限や言語モデルの 404 は起動した後に分かり、分類は #729(G3)が持つ。自動で外すと、一時的な打ち切りでも以後のラウンドから恒久的に外れる。この変更は利用者が外す者の指定で外し、再開で反映できる入口までを作る(既存の決定 19)。 - -### 決定 19: P6 で cross-refactoring を壊さないために、P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削除)の順で出す - -共通層の新しい関数は P6 で入れ、旧関数(従来の確認・適用専用の母集合・従来の席と適用の割り当て)は P7 で消す。P6 の時点で旧関数を消すと cross-refactoring が壊れる。1 本にまとめると、cross-review の状態の部品(`state.py`)と cross-refactoring の部品(`refactor_lib`)と文書 3 種を 1 度にレビューすることになる。G2 / G3 / G5(cross-review 側)と G4(cross-refactoring 側)との競合も 1 度に解くことになる。**「片方にだけ古い形が残らない」(親 #727 の完了条件)は P7 のマージで満たす。** - -### 決定 20: 次に使う人へ規則が届くように、規則の実装は共通層の割り当ての部品、手順は各 Skill の手順書、理由はこの設計文書が持つ - -#687 が問う置き場所である。使える者の数で分岐する実装と席の規則の表は、割り当ての部品(`assignment.py` の `review_seats` の docstring)に 1 つだけ置く。利用者が手順として読む「担当の決まり方と渡す引数」は各 Skill の手順書(`SKILL.md`。cross-review は `docs/05` が本体)に置く。同じランタイムの 2 つ目とホストの参加を許した理由はこの文書が持ち、`plan-to-spec` が `docs/specifications/` へ移す。`CLAUDE.md` には要約の 2 行(cross-refactoring の母集合と輪番、cross-review の席)だけを置く。 - -## 構成要素 - -変える要素を、責務と出す Pull Request と対で並べる。 - -| 要素 | 責務 | Pull Request | -| --- | --- | --- | -| 母集合の既定(`assignment.review_pool` / `refactor_pool`) | Skill ごとの出発点を返す。既定の参加者は表 `DEFAULT_REFACTOR_RUNTIMES` が持つ | P6 | -| 使える者の解決(`assignment.resolve_participants`) | 母集合・ホスト・足す・外す・`only`・確認・`require_all` から `Participants` を返す。名前の矛盾(`--exclude` / `--only` に母集合に無い名前。cross-review のホストはこれに当たる)と欠けを `AssignmentError` で返す | P6 | -| 確認(`auth.probe_auth`) | 止めずに確かめ、担当ごとの結果を返す | P6 | -| 席の埋め方(`assignment.review_seats`) | 使える者と埋め合わせから 2 席を返す(決定 9) | P6 | -| 適用の輪番(`assignment.impl_assign`) | 参加者から実装担当 1 者を返す(決定 7) | P6(呼び手は P7) | -| 席の名前(`assignment.seat_runtime` / `SEAT_PATTERN`) | 席の名前からランタイムを引く。形の検査 | P6 | -| 再開の反映(`statefile.apply_resume_args` / `ResumeField`) | 表に従って状態へ書き、`resume_changes` に積み、知らせる行を返す | P6 | -| cross-review の初期化(`state.py` の `_resolve_reviewers` / `_init_new_state` / `_resume_from_state`) | 共通層を呼び、`participants` を書き、失敗を終了コード 1 へ写す。再開で反映の表を渡す | P6 | -| cross-review の担当の読み出し(`_round_reviewers` / `_guard_previous_round`) | 決定 11 の順で席を返す | P6 | -| cross-review の席の受け口(10 か所) | `launch-reviewer.sh:29,223` / `critique.sh:31` / `critique-round.sh:39` / `wait-review.sh` の CLI を選ぶ分岐、`monitor.py:953` の `target` の `choices`、`monitor.py` の `agent == "codex"` / `"claude"` の比較(629 / 632 / 658 / 808 ほか)、`measure.py:36,128` の `AGENT_NAMES`、`state.py:4389` の `read-result` の `choices`、`_guard_previous_round` の `LEGACY_AGENTS` への落ち方 | P6 | -| cross-review の完了報告(`cmd_report`) | 「参加した者」の節を出す | P6 | -| cross-review の骨組みと文書(`SKILL.md` / `docs/01` / `docs/04` / `docs/05`) | `$ONLY` で絞らない。引数・状態ファイル・席の規則を書く | P6 | -| cross-refactoring の初期化(`refactor_lib/commands/setup.py` の `cmd_init`) | `refactor_pool` と `resolve_participants` を呼び、`runtimes` と `participants` を書く。`impl_capable` / `IMPL_POOL` を出さない。再開で反映の表を渡す。失敗を終了コード 4 へ写す | P7 | -| cross-refactoring の担当(`cmd_start_round` / `apply.py:134` / `gate.py:128`) | `impl_assign(seq, state["runtimes"])`。`reviewers` を書かず `REVIEWERS` を出さない | P7 | -| cross-refactoring の表示(`report.py` / `plan.py`) | 母集合を 1 行にし、レビュー担当の列を消す。`participants` があれば参加者の節を出す | P7 | -| cross-refactoring の引数(`refactor.py`) | `--exclude` / `--include` / `--require-all` を足し、状態に載る引数の既定を `None` にする | P7 | -| cross-refactoring の文書(`SKILL.md` / `docs/01`)と `CLAUDE.md` | 母集合・担当の決め方・前提・引数を実装後に合わせる | P7 | -| 旧関数の削除(`check_auth` / `impl_pool` / `review_assign` / `assign`) | 呼び手が無くなった時点で消す | P7 | - -要素の関係(辺は呼び出し): - -```mermaid -graph TD - subgraph 共通層 - PL[母集合の既定] --> RP[使える者の解決] - RP --> PA[確認] - RS[席の埋め方] - IA[適用の輪番] - SR[席の名前] - RA[再開の反映] - end - subgraph cross-review - RI[初期化] --> RP - RI --> RA - RR[担当の読み出し] --> RS - RC[席の受け口] --> SR - end - subgraph cross-refactoring - FI[初期化] --> RP - FI --> RA - FA[担当] --> IA - end -``` - -完了報告・表示・引数・文書・旧関数の削除は、状態ファイルを読むだけか呼び出しを持たないため、図に含めない。 - -## 文脈と配置 - -**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される両 Skill の状態の部品(`state.py` / `refactor.py`)の 1 プロセスずつである。外部との出入りは GitHub の CLI(`gh`)と、各 CLI の確認コマンドと起動だけで、確認コマンドの呼び出し先は変えない。変わるのは 2 つである。起動する CLI の集合(cross-refactoring から agy が既定で外れ、ホストが入る)と、cross-review で同じ CLI を 2 プロセス起動しうることである。 - -### 置き場所 - -```text -plugins/ndf/ -├── scripts/lib/ -│ ├── assignment.py # P6: refactor_pool / resolve_participants / review_seats / impl_assign / seat_runtime を新設 -│ │ # P7: impl_pool / review_assign / assign を消す -│ ├── auth.py # P6: probe_auth を新設。P7: check_auth を消す -│ ├── statefile.py # P6: apply_resume_args / ResumeField を新設 -│ ├── monitor.py # P6: target の choices と agent の比較を seat_runtime へ -│ └── tests/ # P6: test_lib_assignment.py を impl_assign へ、test_lib_participants.py / test_lib_resume_args.py を新設 -├── skills/cross-review/ -│ ├── SKILL.md / docs/01 / docs/04 / docs/05 # P6 -│ ├── scripts/state.py / launch-reviewer.sh / critique.sh / critique-round.sh / wait-review.sh / measure.py # P6 -│ └── tests/ # P6: test_state_review_pool.py に追記、test_state_resume_args.py / test_seat_names.py を新設 -├── skills/cross-refactoring/ -│ ├── SKILL.md / docs/01-state-and-propose.md # P7 -│ ├── scripts/refactor.py / refactor_lib/commands/{setup,apply,gate,report}.py / refactor_lib/plan.py # P7 -│ └── tests/ # P6: test_assignment.py の review_assign を review_seats へ -│ # P7: test_assignment.py の assign を impl_assign へ、test_init.py / test_start_round_emits_runtimes.py を直す -└── CLAUDE.md(リポジトリの根) # P7 -``` - -Kiro と agy の配布ディレクトリ(`dev.kiro` / `dev.agy`)は Skill の実体を symlink で参照するため、書き写す配布物は無い。配布物の同期の検査(`bash scripts/build-runtime-plugins.sh --check`)で食い違いが無いことだけを確かめる。 - -## 構造 - -型を足すのは 2 つで、互いに関係を持たず、既存の型とも関係を持たない。参加者の記録(`Participants`、データクラス。契約文書の 7 項目)と、反映の表の 1 行(`ResumeField`、名前付きタプル)である。クラス図は作らない(要求の「対象範囲」)。**変わるのは処理の順序である。** - -## 処理の流れ - -変わるのは新規の初期化・ラウンドの開始・再開の初期化の 3 つの流れと、席の名前が流れる経路である。 - -### 新規の初期化(両 Skill で同じ形) - -```mermaid -graph TD - H[ホストを確定] --> P[母集合の既定を引く] - P --> RP[使える者の解決] - RP -->|名前の矛盾 / require_all で欠け| F[状態を作らず終了コード] - RP -->|通った| CR{Skill} - CR -->|cross-review| N{使える者が 2 者以上} - N -->|はい| W[状態ファイルを書く] - N -->|いいえ| HP[ホストを確認して fallback を決める] - HP -->|席を埋められる| W - HP -->|埋められない| F - CR -->|cross-refactoring| Z{使える者が 1 者以上} - Z -->|はい| W - Z -->|いいえ| F -``` - -使える者の解決の順序: - -| 順 | 何をするか | -| ---: | --- | -| 1 | 足す者・外す者の各名前が `ALL_RUNTIMES` にあり、重ならないことを確かめる。外す者の各名前が「母集合の既定 ∪ 足す者」に含まれなければ弾く(cross-review のホストはこれに当たる) | -| 2 | 参加者 = 母集合の既定 ∪ 足す者 − 外す者(`ALL_RUNTIMES` の順) | -| 3 | 1 者指定があれば参加者に含まれ、外す者に無いことを確かめ、参加者をその 1 者にする | -| 4 | 参加者の確認を止めない確認で行う。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとし `probe_skipped` を真にする | -| 5 | 全員を要する指定が真で通らない者がいれば `AssignmentError` | -| 6 | 通った者を `available`、通らなかった者と理由を `unavailable` として返す | - -### ラウンドの開始(cross-review) - -```mermaid -graph TD - S[start-round] --> G[前ラウンドの検査 prev.reviewers を渡す] - G --> R{ラウンドに reviewers がある} - R -->|はい| U[その値] - R -->|いいえ| O{only がある} - O -->|はい| U1["[only]"] - O -->|いいえ| PT{participants がある} - PT -->|はい| RS["review_seats(r, available, fallback)"] - PT -->|いいえ| HO{host がある} - HO -->|はい| LG["review_seats(r, review_pool(host), [])"] - HO -->|いいえ| L2[codex / agy] -``` - -### 再開の初期化(両 Skill で同じ形) - -```mermaid -graph TD - A[状態ファイルを読む] --> AR["apply_resume_args(state, args, 表)"] - AR --> N[知らせる行を出す] - N --> RA{担当に関わる引数を渡した} - RA -->|はい| RP[使える者の解決をやり直す] - RP -->|失敗| F[状態を書き換えず終了コード] - RP -->|通った| W[participants を書き resume_changes に 1 件積む] - RA -->|いいえ| W2[変えた項目だけ書く] - W --> E[start-round へ] - W2 --> E -``` - -### 席の名前が流れる経路(cross-review、埋め合わせがあるときだけ現れる) - -```text -start-round → REVIEWERS="codex claude-2" - → launch-reviewer.sh claude-2 … : CLI は seat_runtime → claude、stem は claude-2-review-pr - → monitor.py --agents codex,claude-2 : stem を組むだけ。CLI 固有のログの検査は seat_runtime で選ぶ - → state.py read-result claude-2 : rounds[-1]["claude-2"] へ書く - → critique-round.sh codex claude-2 → critique.sh claude-2 … -``` - -## 非機能の実現方式 - -要求の非機能 4 項目に、実現方式と確かめ方を対応づける。 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 可用性 | 母集合の 1 者が使えないことで、どちらの収束ループも開始できない状態にならない | 確認の失敗を `unavailable` に記録し、使える者で続ける(決定 2・3)。cross-review は席を埋め合わせる(決定 9) | AC14、AC35 のテスト | -| 性能・拡張性 | 確認の回数は「参加者の数 + 埋め合わせが要るときのホスト 1 回」を超えない。再開で確かめ直すのは担当に関わる引数を渡したときだけ | 除外した者は確かめない。ホストは `available` が 2 者に満たないときだけ確かめる。再開は決定 14 の条件でだけ確かめる | AC3、AC26、AC28 のテストで `probe_auth` の呼び出しを数える | -| 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、反映しなかった引数が出力だけで分かる | `report` が参加者の節を出す。`init` が知らせる行を出す(決定 13・16) | AC24、AC29、AC39 のテスト | -| 移行性 | この変更の前に始めた実行の状態ファイルを書き換えずに読める | 項目が無いときの読み方を契約文書の「移行」が決める | AC22、AC41 のテスト | - -## 実装の分け方 - -P6(共通層と cross-review)→ P7(cross-refactoring と旧関数の削除)の順に出す(決定 19)。受け入れ条件は要求の文書の番号である。 - -| Pull Request | 中身 | 受け入れ条件 | -| --- | --- | --- | -| P6 | 共通層の新設(`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` / `apply_resume_args`)と cross-review 側 | AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 | -| P7 | cross-refactoring 側、旧関数(`check_auth` / `impl_pool` / `review_assign` / `assign`)の削除、`CLAUDE.md` | AC7、AC31〜AC43、AC47、AC49〜AC50 | - -## テスト設計 - -置き場所の列の読み方は次のとおりである。 - -| 先頭 | 実際の場所 | -| --- | --- | -| `cross-refactoring/` / `cross-review/` | `plugins/ndf/skills/` の下 | -| `lib/` | `plugins/ndf/scripts/tests/` の下 | - -| 受け入れ条件 | 何で確かめるか | 置き場所 | -| --- | --- | --- | -| AC1〜AC5 | `probe` を差し替えて `resolve_participants` を呼び、返り値と例外と呼び出し回数を見る | `lib/test_lib_participants.py`(新設) | -| AC6 | `subprocess.run` を差し替えて `probe_auth` を呼ぶ(既存の `test_auth_probe.py` を書き直す) | `lib/test_auth_probe.py` | -| AC7 | `git grep` の結果を検査するテスト | `lib/test_shared_lib_layout.py`(既存に追記) | -| AC8〜AC12 | `review_seats` を 4 ホスト × 12 ラウンドと 2 / 1 / 0 者で呼ぶ。AC8 は変更前の式を期待値として持つ | `cross-refactoring/tests/test_assignment.py`(`review_assign` のテストを置き換える) | -| AC13 | `seat_runtime` に 7 つの名前を渡す | 同上 | -| AC14〜AC20 | `probe_auth` を差し替えて `_init_new_state` を呼ぶ。状態ファイルの有無と終了コードと標準エラーを見る | `cross-review/tests/test_state_review_pool.py` | -| AC21 | `read-result` を席の名前で呼ぶ。`launch-reviewer.sh` を `launch-cli.sh` を差し替えて呼び、渡った CLI 名と stem を見る | `cross-review/tests/test_seat_names.py`(新設) | -| AC22 | `participants` を持たない状態 / `host` も持たない状態で `_round_reviewers` | `cross-review/tests/test_state_review_pool.py` | -| AC23 | `verdict` の無い前ラウンドで `cmd_start_round` の終了コード 5 | `cross-review/tests/test_state_round_guard.py` | -| AC24 | `participants` と `resume_changes` を持つ状態ファイルで `cmd_report` の出力を見る | `cross-review/tests/test_state_review_pool.py` | -| AC25〜AC29 | 状態ファイルを置いた作業ツリーを渡して `cmd_init` を呼び、状態ファイルと標準エラーを見る。一部の引数だけを渡す組み合わせを含む | `cross-review/tests/test_state_resume_args.py`(新設) | -| AC30 | 2 ファイルの `ONLY` を含む行を数える | `cross-review/tests/test_skill_layout.py` | -| AC31〜AC33、AC35〜AC36 | `probe_auth` を差し替えて `cmd_init` を呼ぶ(既存の `test_init.py` の `check_auth` のテストを置き換える) | `cross-refactoring/tests/test_init.py` | -| AC34 | `impl_assign` を 6 ラウンド呼ぶ。`cmd_start_round` の出力に `REVIEWERS` が無い(`test_start_round_emits_runtimes.py` の期待値を反転する) | `lib/test_lib_assignment.py` / `cross-refactoring/tests/test_start_round_emits_runtimes.py` | -| AC37 | `cmd_report` と改修計画の出力を見る | `cross-refactoring/tests/test_run_metrics_summary.py` / `test_plan_comment.py` | -| AC38〜AC40 | 状態ファイルを置いて `cmd_init` を呼ぶ | `cross-refactoring/tests/test_init.py` | -| AC41 | `impl_capable` を持つ状態で `cmd_start_round` / `cmd_report` | 同上 | -| AC42〜AC44 | 文書の語を `grep` するテスト | `scripts/tests/test_cross_skill_refs.py` / `cross-review/tests/test_skill_layout.py` / `cross-refactoring/tests/test_skill_terms.py` | -| AC45〜AC48 | 各 issue の再現手順を実行し、結果を issue のコメントへ残す | 手元 | -| AC49〜AC50 | コマンドの終了コード | 継続的統合と手元 | - -再開の反映の表の規則(「反映する」「知らせる」、値が同じなら積まない)は、共通層のテスト(`lib/test_lib_resume_args.py`、新設)で Skill に依らず確かめる。 - -## 未確認のまま残ること - -4 件が残る。実装で決める 3 件は P6 で決まった(下の表の後)。残る 4 件は運用と #461 が決める。 - -| 項目 | 内容 | いつ決まるか | -| --- | --- | --- | -| 同じランタイムの 2 席の観点 | `claude` / `claude-2` の 2 席が、別のランタイムの 2 席より指摘を見落とすかは測っていない | この変更の後の運用(`measure.py`) | -| ホストが席に入ったときの `is_own_pr` の扱い | ホストの CLI が自分の Pull Request をレビューするとき、投稿の event が `COMMENT` へ倒れる既存の規則で足りるかは確かめていない。P6 では実機で回していない | P6 の後の運用で 1 度回して見る | -| cross-refactoring でホストが提案に入ることの所要 | 提案は最も遅い者を待つ。claude の提案の所要は測っていない(適用は中央値 2 分) | P7 の後の運用 | -| 確認を「言語モデルを引く最小の呼び出し」へ替えるか | #461。所要の実測が要る | マイルストーン 06 の着手時 | - -### P6 の実装で決めた 3 件 - -| 項目 | 決めたこと | -| --- | --- | -| `monitor.py` の CLI 固有の検査を席の名前に通す形 | 席の名前からランタイムを引く内部関数を 1 つ置き、CLI 固有の比較 3 か所をその関数で包む。**席の形に合わない名前はそのまま返す**(担当名を任意の骨格で受ける cross-refactoring の経路を壊さないため)。位置引数の選択肢は席の形を受ける型の検査へ替えた | -| 出力の文言 | 反映した行は `↻ <項目>: <旧> → <新>`、知らせる行は `ℹ --<引数> は再開では反映しません(状態: <値> / 指定: <値>)`、通らなかった者は `⚠ <名前> を担当から外しました(<理由>)`、埋め合わせは `⚠ 使える者が <数> 者のため、席を<相手>で埋めます(観点が減ります)`。既存の初期化の出力の印(`↻` / `ℹ` / `⚠`)に揃える | -| テストの置き場所 | テスト設計の表のとおり。席の埋め方は cross-refactoring の割り当てのテスト(変更前の席の割り当ての期待値が同じファイルにある)、起動と監視と計測の席の名前は cross-review の `tests/test_seat_names.py`(新設) | - -### P7 の実装で決めた 6 件 - -| 項目 | 決めたこと | -| --- | --- | -| 状態ファイルの確認の結果の項目(`auth`) | 新規の状態に書かない。読み手が無く、確認を通らなかった者と理由は参加者の記録(`participants.unavailable`)が持つ | -| 確認を行う位置 | 新規の経路で、作業ディレクトリの用意と範囲の関門の後、着手前のテストの前。状態ファイルの有無(新規か再開か)を見てから確かめるためで、再開では担当に関わる引数を渡したときだけ確かめる | -| 反映の表の中身 | 「反映する」は上限 4 つとテストの制限時間。「知らせる」はホスト・範囲・モデル・着手前のテスト・継続的統合の検査の名前・重要度の閾値・同期のコマンド・改修計画のファイル・起動のされ方・作業ディレクトリ root の 10 個。着手前のテストはコマンドで、モデルは全ランタイムの辞書で、作業ディレクトリ root は解決したパスで比べる | -| 引数の型の置き場所 | `--exclude` / `--include` の型(カンマ区切りの名前と予約語 `none`)は cross-refactoring の初期化の部品に置く。共通層へ移すと cross-review の状態の部品も触ることになり、並行する束(G5)と重なる | -| 完了報告の参加者の節 | cross-review と同じ行の形にし、席の埋め合わせの行は持たない(cross-refactoring に席は無い)。ラウンド表からはレビュー担当とモデルの列に加え、レビュー担当の判定から作っていた初回承認の列も消す | -| モデルの警告の対象 | 参加者だけ。既定で外れる agy は、足したときだけ警告する | - -## 申し送り(並行する設計との境界) - -並行する 4 つの設計と 2 つの issue との境界を、決めた契約と分担で書く。 - -| 相手 | 決めた契約 | どちらが何をするか | -| --- | --- | --- | -| G2(#732 #624) | `state.py` は同じファイルだが節が違う(G1 は `init` / 再開 / `_round_reviewers` / `read-result` の受け口 / `report` の参加者の節。G2 は `_classify_finding` / `COUNTED_CLASSIFICATIONS`)。**要求と契約の既存文書の先頭へ案内を足す点だけが重なる** | G1 が P5 の案内、G2 が P4 の案内をそれぞれ 1 段落足す。後からマージする側が並べる | -| G3(#729 #619 #584) | `state.py` の `_read_review_result_file` / `_record_no_result` / `_handle_no_result_round` と `report` の結末の節は G3。G1 が `report` に足すのは「参加した者」の節だけ。`monitor.py` は G3 が結末の理由を、G1 が `agent` の比較と `target` の `choices` を触る | 競合は後からマージする側が解く。G1 の `seat_runtime` は `monitor.py` の比較を包むだけで、G3 の語彙に触らない | -| G4(#728 #647 #592 #553) | `apply.py:134` と `gate.py:128` の `impl, _ = assignment.assign(seq, state["host"])` を G1(P7)が `impl = assignment.impl_assign(seq, state["runtimes"])` へ変える(各 1 行)。担当の交代(利用上限で次の輪番へ替える)は G4 が持ち、替える相手は `state["runtimes"]` から選ぶ | G4 は `assign` を新しく呼ばない。G1 の P7 と G4 の実装が同じ行を触ったら、後からマージする側が `impl_assign` に揃える | -| G5(#730 #583) | 起動スクリプト(`launch-reviewer.sh`)は G5 が投稿の経路を、G1 が席の受け口(先頭の `case` と `launch-cli.sh` へ渡す CLI 名)を触る | 競合は後からマージする側が解く | -| #461 | 確認コマンドの差し替え口は `AUTH_PROBES` のまま。`probe_auth` の返り値の形(`ok` / `detail`)は変えずに、コマンドだけを替えられる | #461 が実測して替える | -| #736 | `CLAUDE.md` の「`--max-outer-rounds` の既定が 4」の行は、輪番が参加者の数のラウンドで 1 周する形に合わせて G1 が書き直す | #736 は書き直した行を見て閉じるかを棚卸で決める | - -## 既存の設計との対応 - -既存の設計(PR #667)の各決定を、この文書のどの決定が引き継ぐかを示す。 - -| 既存の決定(PR #667) | この文書 | 変わったこと | -| --- | --- | --- | -| 決定 1(P4 / P5 の分け方) | 決定 19 | P5 を P6 / P7 に分け直した。P4 は #732 | -| 決定 6(確認を把握へ。`available_reviewers` を持つ) | 決定 2・3 | 解決を共通層へ移し、項目を `participants` に畳んだ。`check_auth` を消す | -| 決定 7(`--exclude`) | 決定 4 | `--include` を足した | -| 決定 8(使える者の数で分け、`review_assign` が一覧を受け取る) | 決定 9 | 1 者と 0 者を「席を埋める」に変えた。3 者の値は同じ | -| 決定 9(`--require-all`) | 決定 12 | 両 Skill に置く | -| 決定 10(cross-refactoring を変えない) | 決定 2・5・6 | 採らない。cross-refactoring も同じ層を通り、レビュー担当を消す | -| 決定 11(`--only` は 1 者) | 決定 9 | 同じ(埋め合わせをしない) | -| 決定 12(記録を先に見る) | 決定 11 | 同じ | -| 決定 13(既定を `None` に) | 決定 13 | 共通層へ移し、状態に載る引数は必ず表に載せる | -| 決定 14(`--host` は知らせる) | 決定 13 | 表の `notify` の 1 行になった | -| 決定 15(担当の引数で確かめ直す) | 決定 14 | `--include` を足した | -| 決定 16(`none`) | 決定 15 | `--include none` を足した | -| 決定 17(`resume_changes`) | 決定 16 | `participants` は 1 件として積む | -| 決定 18(`$ONLY` で絞らない) | 決定 17 | 同じ | -| 決定 19(自動で外さない) | 決定 18 | 同じ | -| — | 決定 1・5・7・8・10・20 | 新設 | - -## 関連文書 - -| 文書 | 何を持つか | -| --- | --- | -| [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC50) | -| [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) | 状態ファイル・引数・関数の形 | -| [issue-624-478-648-design.md](issue-624-478-648-design.md) | 既存の設計(PR #667)。この文書はその P5(決定 6〜19)を置き換える。引き継ぐ決定と変える決定は「既存の設計との対応」にある。P4(決定 2〜5)は #732 の設計が持つ | diff --git a/issues/issue-727-687-478-664-648-requirements.md b/issues/issue-727-687-478-664-648-requirements.md deleted file mode 100644 index 78daaa9e..00000000 --- a/issues/issue-727-687-478-664-648-requirements.md +++ /dev/null @@ -1,363 +0,0 @@ -# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(要求 / #727 #687 #478 #664 #648) - -## 目的 - -- **壊れていること**: 参加する CLI のどれか 1 者が導入・認証されていないと、cross-review / cross-refactoring の初期化が止まる。収束ループを開始できない(#478 / #687)。cross-refactoring には担当から agy を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる(#664)。中断した収束ループを 1 者指定などの引数を変えて再開しても、引数が黙って無視される(#648) -- **困る人**: 4 つの CLI が揃っていない環境で収束ループを回す利用者と、中断したループを進め方を変えて再開する利用者 -- **直すと成り立つこと**: 使える者だけで開始でき、使えない者と理由が出力と状態ファイルに残る。cross-review は使える者が 2 者に満たなくても、ホスト、次に同じランタイムの 2 つ目で各ラウンドに 2 席を確保する。cross-refactoring の既定の参加者は codex / kiro とホストになる(ホストが codex / kiro なら 2 者、それ以外なら 3 者)。agy は足す者の指定で戻す。再開で渡した引数は反映されるか、反映しないと知らされる。これらの規則は両 Skill が共有する共通層が 1 か所で持つ - -この文書は「何を満たすか」を扱う。設計・既存の要求との関係は末尾の「関連文書」にある。 - -## 用語 - -受け入れ条件で使う語の意味を先に決める。受け入れ条件の本文は検査の入力と期待値の形をそのまま定めるため、識別子を業務用語へ置き換えずに書く。対応はこの表で引く。 - -| 用語 | 識別子 | 意味 | -| --- | --- | --- | -| ランタイム | `ALL_RUNTIMES` | `claude` / `codex` / `agy` / `kiro` の 4 つ | -| ホスト | `host` / `detect_host` | 収束ループを起動しているランタイム | -| 初期化 | `init` | 収束ループを新規に始める・再開する副コマンド。cross-review は `state.py init`、cross-refactoring は `refactor.py init` | -| ラウンドの開始 | `start-round` | 次のラウンドを開き、担当を決めて返す副コマンド | -| 結果の受け口 | `read-result` | 担当の結果ファイルを状態ファイルへ写す副コマンド | -| 完了報告 | `report` | 収束の結果を出す副コマンド | -| 母集合の既定 | `review_pool(host)` / `refactor_pool(host)` | Skill ごとに決まる参加者の出発点。cross-review は全ランタイム − ホスト、cross-refactoring は codex / kiro / ホスト | -| 足す者 / 外す者 | `--include` / `--exclude` | 参加者を名指しで足す・外す引数 | -| 参加者 | `runtimes`(cross-refactoring) | 母集合の既定に足す者を加え、外す者を除いた一覧。確認の対象 | -| 使える者の解決 | `resolve_participants` | 母集合・足す者・外す者・確認の結果から使える者を決める共通層の関数 | -| 止めない確認 | `probe_auth` | 認証の確認コマンドを走らせ、止めずに結果だけを返す共通層の関数。従来の `check_auth` は 1 件の失敗で止める | -| 使える者 | `participants.available` | 参加者のうち確認を通った者。1 者指定があればその 1 者 | -| 1 者指定 | `--only` | 担当を 1 者に固定する引数。値 `none` で外す | -| 全員を要する指定 | `--require-all` | 確認の失敗が 1 者でもあれば止める引数 | -| 席 | `seat_runtime` / `SEAT_PATTERN` | ラウンドで 1 つの CLI プロセスが占める場所。名前はランタイム名か `<ランタイム>-<2〜9>`(同じランタイムの 2 つ目以降) | -| 席の埋め方 | `review_seats` | 使える者と埋め合わせから 2 席を返す共通層の関数 | -| 適用の輪番 | `impl_assign` | 参加者から実装担当 1 者を返す共通層の関数 | -| 担当 | `rounds[].reviewers` / `rounds[].impl` | そのラウンドの席を占める者。前者は cross-review、後者は cross-refactoring | -| 埋め合わせ | `participants.fallback` | 使える者が 2 席に足りないとき、ホスト、次に同じランタイムの 2 つ目で席を埋めること | -| 再開 | — | 状態ファイルが残り `final` が `null` のときの初期化 | -| 再開で変えた値の記録 | `resume_changes` | 再開で変えた値を積む状態ファイルの項目 | - -## 対象範囲 - -変えるのは共通層の 3 ファイルと、両 Skill の初期化・担当・報告・文書である。指摘の数え方・監視・適用の取り込みは他の設計が持つ。 - -含む: - -| 場所 | 中身 | -| --- | --- | -| 共通層 `lib/assignment.py` | 既定の母集合(Skill ごと)、使える者の解決、席の埋め方、適用の輪番、席の名前 | -| 共通層 `lib/auth.py` | 止めない確認(`probe_auth`)。1 件の失敗で `die` する `check_auth` を消す | -| 共通層 `lib/statefile.py` | 再開で明示的に渡した引数だけを状態へ重ね、反映しない引数を知らせる | -| cross-review | `init`(新規と再開)、`start-round` の担当、`read-result` と起動スクリプトの席の受け口、`report`、`SKILL.md` と `docs/`(01 / 04 / 05) | -| cross-refactoring | `init`(新規と再開)、`start-round` と適用の輪番、`report` / 改修計画の表示、`SKILL.md` と `docs/01` | -| リポジトリ | `CLAUDE.md` の cross-refactoring と cross-review の節 | -| テスト | 共通層と両 Skill | - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| 反証する担当がいない指摘の数え方(#624、既存の P4) | #732(G2)の設計が持つ | -| 確認を「言語モデルを引く最小の呼び出し」へ替えること | #461。確認コマンドの所要と形の実測が要る。この変更の共通層は確認の手段を差し替えられる形にする | -| 起動した後に分かる使えなさ(利用上限・言語モデルの 404)で担当を自動で外すこと | #729(G3)が理由の語彙を持つ。この変更は利用者が外す者の指定で外し、再開で反映できる入口までを作る | -| 監視の上限・結末の語彙・投稿の重なり | G3 / G5 の範囲 | -| cross-refactoring の適用ラウンドの取り込みと担当の交代 | #728(G4)の範囲 | -| 提案者と適用者が同じランタイムになることを避ける割り当て | 採らないと決めた(設計文書の決定 8) | -| 再開で検証コマンド(`--verify-command`)を空へ戻す手段 | 置き換えはできる。空へ戻す要求は出ていない | -| クラス図 | 型を追加するのは `Participants` 1 つで、関係を持つ型が無い。形は契約文書のデータ構造が持つ | -| `CHANGELOG.md` と版数 | 配布の工程が書く | - -## 前提 - -この要求は次の 5 つを前提に書いている。 - -| # | 前提 | -| --- | --- | -| 1 | cross-refactoring のレビュー工程は #436 で消えており(Step 7 の cross-review が担う)、従来の割り当て(`assign()`)が返すレビュー担当は状態ファイルへの記録と表示にしか使われない | -| 2 | 使える者の確認は、認証の確認コマンド(`AUTH_PROBES`)のままである。言語モデルを引く最小の呼び出しへ替える判断は #461 が持つ。この変更が作るのは、確認の結果で使える者を決める入口である | -| 3 | 再開の初期化の後、骨組みは必ずラウンドの開始で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | -| 4 | G2(#732)が同じ `state.py` の `_classify_finding` を、G3(#729)が `_read_review_result_file` / `_record_no_result` / `report` を、G5(#730)が投稿の経路を触る。この変更が触る節は `init` / 再開 / `_round_reviewers` / `start-round` / `read-result` の担当名の受け口 / `report` の参加者の節である | -| 5 | 同じランタイムの 2 つの CLI プロセスは、別の作業文脈を持てば独立した意見として扱う(#687 の利用者の指示) | - -## 前提とする取り決め - -実装が従う置き場所・書き方・テストの形である。 - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 担当の決め方は `plugins/ndf/scripts/lib/assignment.py`、確認は `lib/auth.py`、再開の反映は `lib/statefile.py` に置く。判定と状態の鍵は各 Skill の `state.py` / `refactor_lib` が持ち、骨組みは結果を使うだけにする | -| コーディング規約 | 状態ファイルを最小の形で組み、関数を直接呼んで確かめる(`AGENTS.md` の DO)。分岐は表(データ)で持つ(`refactoring` の「分岐をデータ化」) | -| テスト戦略 | 共通層は `plugins/ndf/scripts/tests/` の関数テスト、Skill は既存の形(`conftest.py` の `state_mod` / `crossref_helpers`)で GitHub と CLI の起動を差し替える | - -## 境界 - -承認なしに行うこと・確認してから行うこと・行わないことを分ける。 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査、文書の検査 | -| 確認してから行う | 初期化の既定を「確認の失敗で止める」から「使える者で回す」へ変えること。cross-refactoring の既定から agy を外すこと。どちらも設計 Pull Request の承認で確認する | -| 行わない | 監視・起動・投稿の経路の変更、`refactor_lib` の適用の取り込みの変更、確認コマンドの差し替え | - -## 影響 - -変わるのは初期化の振る舞い、既定の参加者、状態ファイルの形、初期化の引数、共通層の関数、担当名の形である。 - -| 対象 | 影響 | -| --- | --- | -| 認証に失敗する CLI がある利用者 | どちらの初期化も止まらず、使える者で回る。従来の関門は全員を要する指定(`--require-all`)で選べる | -| cross-refactoring の既定の参加者 | agy が既定から外れ、ホストが提案と適用に入る。`--include agy` で戻せる。提案者と適用者が同じランタイムになりうる | -| cross-review で使える者が 2 者に満たない利用者 | ホスト、次に同じランタイムの 2 つ目が席を埋める。1 者で回るのは 1 者指定を渡したときだけになる | -| 状態ファイルの形 | 両 Skill の最上位に `participants` と `resume_changes` が増える。cross-refactoring の `impl_capable` とラウンドの `reviewers` / `reviewer_models` が新規の状態から消える。無い項目は従来の読み方で読む | -| 初期化の引数 | 両 Skill に `--exclude` / `--include` / `--require-all` が増える。cross-review の `--only` が `none` を取る。既定値は変わらない | -| 共通層の関数 | `check_auth` / `impl_pool` / `review_assign` / `assign` が消え、`probe_auth` / `resolve_participants` / `review_seats` / `impl_assign` / `seat_runtime` / `refactor_pool` が入る。呼び出し側は両 Skill だけである | -| 担当名の形 | ランタイム名に `-2`〜`-9` の接尾辞を持つ席の名前が、結果ファイルの stem と状態ファイルの鍵に現れうる | -| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC23) | - -## 受け入れ条件 - -50 件を、共通層・cross-review・cross-refactoring・文書・子 issue の再現・全体の 9 つの塊に分ける。 - -### 共通層: 使える者の解決と確認 - -- [ ] AC1: 母集合 3 者のうち 1 者の確認が失敗する `probe` を `resolve_participants` に渡す。返る値の `available` は残り 2 者(母集合の順)、`unavailable` はその 1 者と理由を持ち、例外は上がらない -- [ ] AC2: AC1 と同じ入力で `require_all=True` を渡すと `AssignmentError` が上がり、メッセージに欠けた者の名前と理由が含まれる -- [ ] AC3: `exclude` に含めた者に対して `probe` が呼ばれない。`include` で足した者は呼ばれる(呼び出しの回数と引数で確かめる) -- [ ] AC4: 次の 4 つはいずれも `AssignmentError` になる。`include` と `exclude` に同じ名前 / `ALL_RUNTIMES` に無い名前 / `only` が `exclude` に含まれる / `only` が参加者に無い -- [ ] AC5: `NDF_SKIP_AUTH_CHECK` が立つと、`available` は参加者の全員、`probe_skipped` は真で、確認コマンドは 1 回も呼ばれない -- [ ] AC6: `probe_auth` は失敗で例外を上げず、`ok: false` と理由(`コマンドが見つかりません` / 時間切れ / 終了コード非 0 / 未認証の文言)を返す。成功は `ok: true` -- [ ] AC7: P7 の後、`check_auth` / `impl_pool` / `review_assign` / `assign` の 4 つを `git grep -n` で探す。`plugins/ndf/scripts/lib/` と両 Skill の `scripts/` で 0 件になる - -### 共通層: 席の埋め方(cross-review の規則) - -- [ ] AC8: 使える者が 3 者のとき、`review_seats(r, available, [])` は変更前の `review_assign(r, host)` と一致する。4 つのホスト × ラウンド 1〜12 の全組で確かめる -- [ ] AC9: 使える者が 4 者(`--include` でホストを足した)のとき、毎ラウンド 2 席で、ラウンド 1〜4 で各者がちょうど 2 回担当になる -- [ ] AC10: 使える者が 2 者のとき、ラウンド 1〜4 の全部でその 2 者が返る -- [ ] AC11: 使える者が 1 者(`codex`)のとき、埋め合わせに `["claude"]` を渡すと `["codex", "claude"]`、空を渡すと `["codex", "codex-2"]` が返る -- [ ] AC12: 使える者が 0 者のとき、埋め合わせに `["claude"]` を渡すと `["claude", "claude-2"]`、空を渡すと `AssignmentError` になる -- [ ] AC13: `seat_runtime("kiro-2")` と `seat_runtime("kiro")` は `kiro` を返す。`gemini` / `kiro-1` / `kiro-10` / `kiro-2-3` は `AssignmentError` になる - -### cross-review: 新規の初期化 - -- [ ] AC14: ホスト `claude` で `kiro` の確認が失敗する。`init` は終了コード 0 で状態ファイルを作る。`participants.available` は `["codex", "agy"]` で、`participants.unavailable.kiro` に理由が入る。標準エラーに `kiro` を外したことが 1 行出る -- [ ] AC15: AC14 と同じ状態で `--require-all` を付けると、`init` は終了コード 1 で終わり、状態ファイルを作らない -- [ ] AC16: `--exclude agy` を渡すと `agy` の確認を行わない。`participants.excluded` が `["agy"]`、`available` が `["codex", "kiro"]` になる。`--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ状態ファイルを作る -- [ ] AC17: ホスト `claude` で `--include claude` を渡すと、`available` が 4 者になり、`start-round` が 2 席を返す -- [ ] AC18: 使える者が `codex` の 1 者で、ホストの確認が通る。`init` は終了コード 0 で終わり、観点が減ることを 1 行出す。`participants.fallback` は `["claude"]`、`start-round` は `codex claude` を返す。`--only codex` のときはホストを確かめない。`participants.fallback` は空で、`start-round` は `codex` だけを返す(確認コマンドの呼び出しは `codex` の 1 回) -- [ ] AC19: 使える者が 0 者でホストの確認が通ると、`init` は終了コード 0 で終わり、`start-round` は `claude claude-2` を返す。ホストの確認も通らないと `init` は終了コード 1 で終わり、状態ファイルを作らない -- [ ] AC20: 次の 3 つはいずれも終了コード 1 で終わり、状態ファイルを作らない。`--exclude claude`(ホスト)/ `--only codex --exclude codex` / `--include agy --exclude agy` -- [ ] AC21: `read-result claude-2` が受け付けられ、`rounds[-1]["claude-2"]` に結果を書く。`launch-reviewer.sh claude-2 ` は `claude` の CLI を起動する。stem は `claude-2-review-pr` になる(起動は差し替えて確かめる) -- [ ] AC22: `participants` を持たない状態ファイルで、`host` があれば `start-round` は変更前の輪番を返す。`host` も無ければ `codex` / `agy` を返す -- [ ] AC23: 前のラウンドが `verdict` を持たず、担当 `agy` + `kiro` の両者が `REQUEST_CHANGES` で修正の記録が無い。このとき `start-round` は終了コード 5 で止まる -- [ ] AC24: `report` が「参加した者」の節を出す。行は 6 つで、使える者 / `--exclude` で外した者 / `--include` で足した者 / 確認を通らなかった者(理由つき)/ 埋め合わせ / 再開で変えた値である。`participants` を持たない状態ファイルでは「記録なし」と出す - -### cross-review: 再開の初期化 - -- [ ] AC25: `max_rounds: 12` の状態ファイルへ `--max-rounds 20` を渡す。`max_rounds` が 20 になり、`12 → 20` の形で 1 行出る。`resume_changes` に `{field: "max_rounds", from: 12, to: 20}` が 1 件積まれる。`--rotate-after` / `--verify-command` / `--verify-exit-code` も同じく反映され、後の 2 つは置き換える -- [ ] AC26: 引数を渡さない再開では、次の 6 項目が変わらず、確認コマンドは 1 回も呼ばれない。`max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` / `only` / `participants` -- [ ] AC27: `only: null` の状態ファイルへ `--only codex` を渡すと `only` が `codex` になり、次の `start-round` が `codex` だけを返す。記録を持つ過去のラウンドの `reviewers` は変わらない。`--only none` は `only` を `null` へ戻す -- [ ] AC28: `--exclude agy` を渡した再開では、参加者の確認をやり直し、`available` から `agy` が消え、次の `start-round` が `agy` を返さない。`--exclude none` は除外を空へ戻す。`participants.included` が `["claude"]` の状態へ `--exclude agy` だけを渡すと、`included` は `["claude"]` のまま残る。`excluded` が `["agy"]` になる(渡さなかった引数は状態ファイルの値で補う) -- [ ] AC29: `host: "claude"` の状態ファイルへ `--host codex` を渡すと `host` は変わらず、反映しないことが 1 行出る。`--host claude` では何も出ない -- [ ] AC30: `SKILL.md` と `docs/01-state-and-review.md` で `grep -n 'ONLY'` が当たる行は、`init` へ引数を渡す行と引数の説明の行だけになる。起動・監視・取り込み・反証の担当は `$REVIEWERS` / `$REVIEWERS_CSV` を使う - -### cross-refactoring: 母集合と担当 - -- [ ] AC31: ホスト `claude` の新規の `init` で、状態ファイルの `runtimes` は `["claude", "codex", "kiro"]` になり、`agy` の確認は行われない。状態ファイルに `impl_capable` は無く、標準出力に `IMPL_POOL=` の行は無い -- [ ] AC32: ホスト `codex` では `runtimes` が `["codex", "kiro"]`、ホスト `agy` では `["codex", "agy", "kiro"]` になる -- [ ] AC33: `--include agy` で `runtimes` が 4 者に、`--exclude kiro` で 2 者になる。ホスト `claude` で `--exclude claude` を渡すと `runtimes` が `["codex", "kiro"]` になる。`init` は終了コード 0 で終わる(ホストは母集合に含まれるため外せる) -- [ ] AC34: `impl_assign(r, ["claude", "codex", "kiro"])` をラウンド 1〜6 で呼ぶ。返る値は `codex` / `kiro` / `claude` / `codex` / `kiro` / `claude` である。`start-round` は `REVIEWERS` / `REVIEWERS_CSV` を出さない。ラウンドの記録に `reviewers` / `reviewer_models` が無い -- [ ] AC35: `kiro` の確認が失敗しても `init` は終了コード 0 で終わる。`participants.unavailable.kiro` に理由が入り、`runtimes` は 2 者になる。`--require-all` を付けると終了コード 4 で終わり、状態ファイルを作らない -- [ ] AC36: 使える者が 0 者のとき `init` は終了コード 4 で終わり、状態ファイルを作らない -- [ ] AC37: `report` と改修計画の表示にレビュー担当の列が無く、母集合を 1 行で出す(「提案・レビュー」と「適用の母集合」の 2 行に分けない) - -### cross-refactoring: 再開の初期化 - -- [ ] AC38: `max_outer_rounds: 3` の状態ファイルへ `--max-outer-rounds 5` を渡す。5 になり、`3 → 5` の形で 1 行出て、`resume_changes` に 1 件積まれる。`--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` も同じ -- [ ] AC39: 再開で `--model codex=x` / `--host codex` / `--scope other` を渡すと、状態は変わらず、反映しないことが引数ごとに 1 行出る。状態に載る他の引数(`--baseline-test` など。契約文書の表)も同じ扱いである。引数を渡さない再開では、上限 4 項目と `models` と `runtimes` が変わらない -- [ ] AC40: 再開で `--exclude kiro` を渡すと参加者の確認をやり直す。`runtimes` から `kiro` が消え、次の `start-round` の `RUNTIMES` に `kiro` が無い。`--include agy` で始めた状態へ `--exclude kiro` だけを渡すと、`included` の `agy` は残る -- [ ] AC41: `impl_capable` を持ち `participants` を持たない状態ファイル(この変更の前に始めた実行)を、`start-round` / `report` が読める。適用の輪番は `runtimes` から決まる - -### 文書 - -- [ ] AC42: `CLAUDE.md` の cross-refactoring の節が「codex / kiro とホスト(ホストが codex / kiro なら 2 者)」を書く。同じ節が「適用担当は参加者の数のラウンドで 1 周する」を書く。「ホストを除く 3 者」「参加する 4 者」を含まない。cross-review の節が「codex / agy の両方」を含まない。次の 3 つがいずれも 0 行を出す - - ```bash - grep -n "ホストを除く 3 者" CLAUDE.md - grep -n "参加する 4 者" CLAUDE.md - grep -n "codex / agy の両方" CLAUDE.md - ``` - -- [ ] AC43: cross-refactoring の `SKILL.md` の「担当の決め方」が母集合を 1 つの表で書く。引数の表と `argument-hint` に `--exclude` / `--include` / `--require-all` がある。「前提」から「すべてログイン済み」が消える。ホストごとに要る CLI の表が `codex` / `kiro-cli`(ホストが codex / kiro ならもう 1 つ)になる。`docs/01-state-and-propose.md` の `init` が返す変数の表に `IMPL_POOL` が無い -- [ ] AC44: cross-review の次の 4 ファイルが、それぞれの内容を書く - - | ファイル | 書く内容 | - | --- | --- | - | `SKILL.md` | 引数の表と `argument-hint` に `--exclude` / `--include` / `--require-all`。`--only` の説明から「デバッグ用」が消える。母集合の行が席の規則を指す | - | `docs/05-pool-and-convergence.md` | 使える者の解決と席の埋め方(3 者以上 / 2 者 / 1 者 / 0 者)、`--exclude` / `--include`、確認が把握になったこと | - | `docs/04-contracts.md` | 状態ファイルの `participants` と `resume_changes`、席の名前の形 | - | `docs/01-state-and-review.md` | 再開で渡した引数の扱いが `docs/04-contracts.md` にあることへの案内 | - -### 子 issue の再現手順 - -- [ ] AC45: #478 の再現(`kiro-cli` が無い環境で `init`)で、`init` が終了コード 0 で終わる。#687 の場面 3(`codex` が使えない)で、`--exclude codex` を付けずに `init` が開始できる -- [ ] AC46: #648 の再現(再開の `init` に `--only codex`)で、次の `start-round` が `codex` だけを返す -- [ ] AC47: #664 の再現(`git grep -n '"--exclude"' -- plugins/ndf/skills/cross-refactoring`)が 1 行以上を出す -- [ ] AC48: #461 の再現(言語モデルを引けない CLI が確認を通る)は、この変更の後も現象が残ることを確かめて記録する(直す判断は #461 が持つ) - -### 全体 - -- [ ] AC49: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC50: 次の 6 つが終了コード 0 で終わる - - ```bash - bash scripts/build-runtime-plugins.sh --check - claude plugin validate . - python3 scripts/check-skill-frontmatter.py - python3 scripts/check-doc-staleness.py --root . - python3 scripts/check-markdown-links.py --root . - python3 scripts/check-skill-shell-vars.py - ``` - -## 非機能の条件 - -受け入れ条件のうち可用性・性能・運用・移行に当たるものを、大項目で束ねる。 - -| 大項目 | 条件 | -| --- | --- | -| 可用性 | 母集合の 1 者が使えないことで、どちらの収束ループも開始できない状態にならない(AC14、AC35) | -| 性能・拡張性 | 確認の回数は、新規の初期化で「参加者の数 + 埋め合わせが要るときのホスト 1 回」を超えない。除外した者は確かめない。再開で確かめ直すのは担当に関わる引数を渡したときだけである | -| 運用・保守性 | 担当が欠けたまま収束したこと、席を埋め合わせたこと、再開で反映しなかった引数が、完了報告と初期化の出力だけで分かる(AC24、AC29、AC39) | -| 移行性 | この変更の前に始めた実行の状態ファイルを、書き換えずに読める(AC22、AC41) | - -## 検証手段 - -テスト・配布物の同期・文書の検査・手動確認の 4 つで確かめる。 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義と文書の検査 | AC50 の 6 つ | -| 手動確認 | P7 の後、ホスト claude で `--exclude codex` を付けた cross-review と、既定の cross-refactoring を 1 本ずつ回し、完了報告で参加者と席を見る | - -## 未決 - -この変更の外で決まる 2 件を残す。 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 確認を「言語モデルを引く最小の呼び出し」へ替えるか、替えるならランタイムごとのコマンドと所要 | #461 | マイルストーン 06 の着手時 | -| 同じランタイムの 2 席が、別のランタイムの 2 席より指摘を見落とすか | この変更の後の運用(`measure.py`) | 実測が 3 本たまった時点 | - -## 既存の受け入れ条件との対応 - -既存の設計(PR #667)の P5 の受け入れ条件を、この文書のどこが引き継ぐかを示す。 - -| 既存 | この文書 | 変わったこと | -| --- | --- | --- | -| AC10 | AC14 | 項目が `available_reviewers` から `participants.available` へ。値は同じ | -| AC11 | AC15 | 同じ | -| AC12 | AC5 | 共通層の条件として書き直した | -| AC13 | AC16 | 同じ | -| AC14 | AC20 | `--include` と `--exclude` の矛盾を足した | -| AC15 | AC8 | 関数が `review_assign` から `review_seats` へ。値は同じ | -| AC16 | AC10 | 同じ | -| AC17 | AC18 | 1 者で回すのではなく、ホストで 2 席目を埋める。1 者のまま回るのは `--only` だけ | -| AC18 | AC19 | 0 者で失敗するのではなく、ホストが通れば 2 席を埋める。ホストも通らないときだけ失敗する | -| AC19 | AC22 | 同じ | -| AC20 | AC7、AC34、AC35 | 「cross-refactoring は変わらない」から「cross-refactoring も同じ層を通る」へ。`check_auth` は消える | -| AC21 | AC24 | `--include` で足した者と埋め合わせの行を足した | -| AC22 | AC25 | 同じ | -| AC23 | AC26 | 項目が `participants` に畳まれた | -| AC24 | AC27 | 同じ | -| AC25 | AC27、AC28 | 同じ | -| AC26 | AC28 | 同じ | -| AC27 | AC29 | 同じ | -| AC28 | AC30 | 同じ | -| AC29 | AC23 | 同じ | -| AC30 | AC44 | `--include` と席の規則を足した | -| AC31 | AC49 | 同じ | -| AC32 | AC50 | 文書の検査 3 つを足した | -| — | AC1〜AC4、AC6、AC9、AC11〜AC13、AC17、AC21 | 新設(共通層の解決と席) | -| — | AC31〜AC43 | 新設(cross-refactoring と `CLAUDE.md`) | -| — | AC45〜AC48 | 新設(子 issue の再現手順) | - -## 依頼(原文) - -各 issue の本文から抜粋した原文である。原文は書き換えない。全文は `gh issue view 727` / `687` / `478` / `664` / `648` で読む。 - -### #727(根本原因の親) - -> **参加する CLI が実行できるかを確かめ、使える者から担当を割り当てる共通層。** 場所は `plugins/ndf/scripts/lib/auth.py` の `check_auth`(36 行目)と `assignment.py` の `review_pool` / `review_assign` / `assign`(78 / 95 / 114 行目)である。 -> -> - `check_auth` は認証の成否だけを見て、1 件の失敗で `die` する。モデルを引けるか・更新トークンが生きているかは見ない -> - `assignment.py` は母集合を「全ランタイム − ホスト」の定数から作り、使える者を入力に取らない -> - cross-review(`state.py` の `init`)と cross-refactoring(`refactor_lib/commands/setup.py` の `cmd_init`)の両方が、この層を使う -> -> ## 採る手 -> -> 移動(`move_responsibility`)。使える者の決定を、各 Skill の初期化の関門から共通層の割り当てへ移す。 -> -> **cross-refactoring の既定の母集合は codex / kiro / ホストとし、agy を外す**(#664 / #687 の 2026-09-18 の決定。CLI の起動 199 回のうち失敗は agy の 7 回だけで、提案の所要も agy が最も長かった)。提案も適用もこの 3 者で回す。cross-review と共有する `assignment.py` で規則を 1 つにし、ホストが codex / kiro のとき(母集合が 2 者)の扱いは #687 の規則(各ラウンド 2 者を確保する。同一ランタイム 2 つ・ホストの参加を許す)に従う。 -> -> ## 完了条件 -> -> - 共通層が「最小の呼び出しが通るか」で使える者を決めて返し、割り当てがその一覧から担当を選ぶ -> - cross-review と cross-refactoring の両方がこの層だけを通り、片方にだけ古い形が残らない -> - `CLAUDE.md` の cross-refactoring の節(「ホストを除く 3 者」「参加する 4 者から輪番」)と `cross-refactoring/SKILL.md` の「担当の決め方」を、実装後の母集合に合わせる -> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる(子 issue はその時点の棚卸が「閉じてよい」で閉じる) - -### #687 - -> - **各ラウンドで 2 者がレビューできることを最優先にする。** 誰が担当かより、2 つの目で見ることを優先する -> - **文脈が分かれていれば、同じランタイムを 2 つ立ててよい。** 別プロセス・別文脈なら独立した意見になる -> - **コードを書いたランタイム(ホスト)が担当に入ってよい。** 輪番の形にはこだわらない -> - **他にランタイムが 1 つも無ければ、ホストを 2 つ走らせる形でよい** -> -> **置き場所も決める。** 規則が決まっても、置き場所が決まらなければ次に使う人へ届かない。 -> -> **cross-refactoring では、既定の担当から agy を外し、ホストのランタイムを輪番へ入れる**と決めた(利用者の指示)。提案も適用も codex / kiro / ホストの 3 者になる。 - -### #478 - -> **認証確認を「関門」から「導入状況の把握」へ変える。** 使える者でレビューし、使えない者は最初から数えない。 -> -> 1. `init` で母集合の各ランタイムの認証を確かめ、**通った者の一覧を `state.json` へ記録する**(`available_reviewers`) -> 2. **明示的に外す手段を用意する**(例: `--exclude agy`)。**認証は通るが実行で落ちる担当を外す用途でも使う。** -> 3. 使える者の数で分岐する(3 者: 現行どおり / 2 者: 毎ラウンドその 2 者 / 1 者: 警告して 1 者 / 0 者: 失敗) -> 4. `review_assign()` は「**使える者が 3 者以上のときだけ 1 者を外す**」に変える -> 5. 全員揃っていることを要求したい運用のために `--require-all` を用意する -> 6. 完了報告に「このループに参加したのは誰か」を出す - -### #664 - -> `cross-refactoring` には、担当から特定のランタイムを外す引数が無い。 -> -> **外す手段を足すだけでは足りない。** `assign()` は実装担当を先に決めてから、提案・レビューの母集合から実装担当を除いた者をレビュー担当にする。使える者が 2 者だと、実装担当が codex か kiro のラウンドではレビュー担当が 1 者になる。 -> -> | 母集合 | いま | 変えた後 | -> |---|---|---| -> | 提案(`review_pool(host)` ) | 全ランタイム − ホスト(codex / agy / kiro) | codex / kiro / ホスト | -> | 適用(`impl_pool()` ) | 4 者すべて | codex / kiro / ホスト | -> -> - 外す手段(`--exclude` )を足すだけでなく、**既定の母集合から agy を外す**。agy を戻す手段を残すかは設計で決める -> - 同じ項目の提案者と適用者が重ならないよう、割り当てで避けるかは設計で決める -> - ホストが codex / kiro のときは、母集合が 2 者になる。そのときの扱いは #687 の規則に従う -> - `CLAUDE.md` の cross-refactoring の節(「codex / agy / kiro / claude のうちホストを除く 3 者」「参加する 4 者から輪番」)もあわせて直す - -### #648 - -> `/ndf:cross-review` を中断・再開すると、`state.py init` に渡した `--only` / `--max-rounds` / `--rotate-after` / `--verify-command` / `--verify-exit-code` / `--host` が**黙って無視される**。 -> -> **`cross-refactoring` にも同じ形がある。** `refactor.py` の `init` が受ける `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / `--model` は再開時に反映されない。 -> -> ## 修正レイヤー -> -> `plugins/ndf/scripts/lib/statefile.py` に置く、再開時の引数の反映の契約。「明示的に渡された引数だけを状態へ重ね、反映しない引数は渡されたら知らせる」を 1 か所で持つ。 - -## 関連文書 - -| 文書 | 何を持つか | -| --- | --- | -| [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) | どう作るか(決定の記録・実測・構成要素・処理の流れ・テスト設計) | -| [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md) | 状態ファイル・引数・関数の形 | -| [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) | 既存の要求(PR #667)。この文書はその P5(AC10〜AC30)を置き換える(対応は「既存の受け入れ条件との対応」)。P4(#624)は #732 の設計が持つ。既存の文書の本体は触らない | diff --git a/issues/issue-727-p6-participants-plan.md b/issues/issue-727-p6-participants-plan.md deleted file mode 100644 index fe495e30..00000000 --- a/issues/issue-727-p6-participants-plan.md +++ /dev/null @@ -1,208 +0,0 @@ -# cross-review / cross-refactoring: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、cross-review は毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる(実装計画 P6: 共通層と cross-review / #727 #687 #478 #648) - -## 関連リンク - -- 親 issue #727、子 issue #687 #478 #648(#664 は 2 本目の Pull Request が扱う) -- 設計: [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md)(決定 20 件。用語の対応表はこの文書の識別子の引き先) -- 要求: [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md)(受け入れ条件 AC1〜AC50) -- 契約: [issue-727-687-478-664-648-contracts.md](issue-727-687-478-664-648-contracts.md)(状態ファイル・引数・関数の形) -- 設計 Pull Request: #782(2026-09-19 マージ) - -## モード - -`standard`。収束ループの初期化の振る舞いを変え、複数モジュール(共通層と cross-review)にまたがる。 - -## 目的と非目的 - -達成したい状態: - -- 参加する CLI のどれか 1 者が導入・認証されていなくても、cross-review の初期化が使える者で始まり、使えない者と理由が状態ファイルに残る -- cross-review の各ラウンドに 2 席が確保される(使える者 → ホスト → 同じランタイムの 2 つ目) -- 中断した収束ループを引数を変えて再開したとき、渡した引数が反映されるか、反映しないことが知らされる -- 使える者の決定・席の埋め方・再開の反映の 3 つの規則が共通層に 1 か所ずつ入り、2 本目の Pull Request(cross-refactoring 側)がそのまま呼べる - -やらないこと(2 本目の Pull Request が行う): - -- cross-refactoring の初期化・担当・表示・引数・文書の変更 -- 従来の確認(`check_auth`)・適用専用の母集合(`impl_pool`)・従来の席と適用の割り当て(`review_assign` / `assign`)の削除 -- リポジトリの根の `CLAUDE.md` の書き換え -- 起動した後に分かる使えなさで担当を自動的に外す仕組み(設計の決定 18) - -## 前提 - -- 前提 1: 設計文書の決定 20 件は変えない。実装で決めると設計が残した 3 件(監視の比較の寄せ方・出力の文言・テストの置き場所)は、この計画の「実装で決めたこと」に書き、設計文書の「未確認のまま残ること」の表を同じ Pull Request で更新する -- 前提 2: 並行する束が同じ状態の部品(`state.py`)を触る。G3(PR #791)は副コマンドの登録関数の分割と再開の経路の分割、G2(PR #790)は指摘の分類を触る。この Pull Request は既存の行を書き換える量を最小にし、足す形で書く。競合は後からマージする側が解く -- 前提 3: ホストが席に入ったときの自分の Pull Request への投稿の扱い(`is_own_pr`)は、この Pull Request では実機で回さず、検査の持ち場か運用で確かめる。確かめていないことを Pull Request 本文の残リスクに書く - -## 受け入れ条件 - -要求文書の AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 をそのまま使う。検証手段は要求文書の「検証手段」と設計文書の「テスト設計」の表にある。AC45・AC46・AC48(子 issue の再現手順)は手元で実行して結果を issue のコメントに残す。 - -## ドメイン用語 - -設計文書の「用語の対応表」を使う。この文書で追加する語は無い。 - -## 不変条件 - -- 使える者の並びは、ランタイムの固定の順(`ALL_RUNTIMES`)を保つ -- 使える者が 3 者のときの席は、変更前の輪番と同じ値になる -- この変更の前に始めた実行の状態ファイルは書き換えずに読める -- 再開で渡さなかった引数は、状態ファイルの値のまま残る - -## 互換性 - -| 対象 | 変更 | 互換性の扱い | -| --- | --- | --- | -| `state.py init` の引数 | `--exclude` / `--include` / `--require-all` / `--no-require-all` を足す。`--only` が `none` を取る。`--max-rounds` / `--rotate-after` の既定を未指定へ | 追加のみ。骨組みは値があるときだけ渡す形へ変える | -| `state.py read-result` の担当の引数 | 4 つの名前の選択肢から、席の名前の形の検査へ | 従来の 4 つの名前はそのまま通る | -| 起動スクリプトの第 1 引数 | ランタイム名から席の名前へ | 従来の名前はそのまま通る | -| 監視の位置引数の選択肢 | 4 つの名前と `both` から、席の名前の形と `both` へ | 同上 | -| 状態ファイル | 最上位に `participants` と `resume_changes` が増える。`rounds[].reviewers` と `rounds[].<席>` の鍵に席の名前が入りうる | 項目が無いときの読み方を契約文書の「移行」が決める。既存のファイルは書き換えない | -| 共通層の関数 | 6 つを新設。旧関数は残す | 追加のみ | - -## 修正対象 - -共通層: - -- `plugins/ndf/scripts/lib/auth.py` -- `plugins/ndf/scripts/lib/assignment.py` -- `plugins/ndf/scripts/lib/statefile.py` -- `plugins/ndf/scripts/lib/monitor.py` -- `plugins/ndf/scripts/lib/README.md`(関数の一覧の行) -- `plugins/ndf/scripts/tests/test_auth_probe.py`(書き直し)、`test_lib_participants.py`(新設)、`test_lib_resume_args.py`(新設)、`test_lib_assignment.py`(追記) - -cross-review: - -- `plugins/ndf/skills/cross-review/scripts/state.py` -- `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` / `critique.sh` / `critique-round.sh` / `wait-review.sh` / `measure.py` -- `plugins/ndf/skills/cross-review/SKILL.md` / `docs/01-state-and-review.md` / `docs/04-contracts.md` / `docs/05-pool-and-convergence.md` -- `plugins/ndf/skills/cross-review/tests/test_state_review_pool.py`(追記)、`test_state_round_guard.py`(追記)、`test_state_resume_args.py`(新設)、`test_seat_names.py`(新設)、`test_skill_layout.py`(追記) - -cross-refactoring(テストだけ): - -- `plugins/ndf/skills/cross-refactoring/tests/test_assignment.py`(席の埋め方のテストを追記。従来の席の割り当てのテストは 2 本目の Pull Request が消す) - -文書: - -- `issues/issue-727-687-478-664-648-design.md`(「未確認のまま残ること」の 3 行) - -## タスク分解 - -受け入れ条件の番号は要求文書のものである。 - -### Task 1: 止めない確認を共通層に足す - -- **対象ファイル:** `lib/auth.py`、`scripts/tests/test_auth_probe.py` -- **変更内容:** 確認コマンドを走らせて結果だけを返す関数(`probe_auth(runtimes, *, info, env=None)` → `(結果, 飛ばしたか)`)を足す。確認コマンド・未認証の文言・時間切れの秒数・飛ばす環境変数は変えない。従来の確認は残す。既存テストは従来の確認を使っているため、止めない確認のテストへ書き直す(従来の確認のテストは 2 本目の Pull Request が消すまで残してよい) -- **満たす受け入れ条件:** AC5(確認コマンドを呼ばない部分)、AC6 -- **進め方:** 失敗するテスト(コマンドが見つからない / 時間切れ / 終了コード非 0 / 未認証の文言 / 成功 / 飛ばし)→ 最小実装 → 従来の確認と重なる走らせ方を 1 つの内部関数へ寄せる - -### Task 2: 使える者の解決・席の埋め方・適用の輪番・席の名前を共通層に足す - -- **対象ファイル:** `lib/assignment.py`、`scripts/tests/test_lib_participants.py`(新設)、`scripts/tests/test_lib_assignment.py`、`skills/cross-refactoring/tests/test_assignment.py` -- **変更内容:** 既定の参加者の表(`DEFAULT_REFACTOR_RUNTIMES = ("codex", "kiro")`)、母集合の既定(`refactor_pool(host)`)、参加者の記録(`Participants` データクラス。`pool` / `included` / `excluded` / `available` / `unavailable` / `probe_skipped` / `require_all` と `to_state()`)、使える者の解決(`resolve_participants`。順序は設計文書の表の 6 段)、席の形(`SEAT_PATTERN`)と席の名前の解釈(`seat_runtime`)、席の埋め方(`review_seats(round_no, available, fallback)`。規則の表は docstring に置く。設計の決定 20)、適用の輪番(`impl_assign(round_no, participants)`)を足す。モジュールの docstring の「役割ごとに母集合が違う」の表は、2 本目の Pull Request で母集合が 1 つになるまで残す -- **満たす受け入れ条件:** AC1〜AC5、AC8〜AC13、AC34 のうち適用の輪番の値 -- **進め方:** 失敗するテスト → 最小実装 → 整理。AC8 は変更前の席の割り当て(`review_assign`)を期待値に使う - -### Task 3: 再開の反映を共通層に足す - -- **対象ファイル:** `lib/statefile.py`、`scripts/tests/test_lib_resume_args.py`(新設) -- **変更内容:** 反映の表の 1 行(`ResumeField(arg, key, mode)`。`mode` は `replace` / `notify`)と、再開の反映(`apply_resume_args(state, args, spec)` → 標準エラーへ出す行の一覧)を足す。「反映する」は未指定でない値を状態へ書き `resume_changes` に `{at, field, from, to}` を積む。「知らせる」は状態と違うときだけ行を返す。値が同じなら行も記録も出さない。予約語 `none` の扱い(1 者指定は `null`、一覧は空)は呼び出し側が引数を正規化してから渡す形にし、この関数は値をそのまま比べる -- **満たす受け入れ条件:** AC25〜AC29 の共通層の部分 -- **進め方:** 失敗するテスト → 最小実装 → 整理 - -### Task 4: cross-review の新規の初期化を共通層へ載せ替える - -- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_review_pool.py` -- **変更内容:** - - 初期化の引数: `--only` の型を 4 つの名前か `none` へ、`--exclude` / `--include`(カンマ区切り・繰り返し可・`none`)、`--require-all` / `--no-require-all`(既定は未指定)を足す。`--max-rounds` / `--rotate-after` の既定を未指定へ変え、新規の経路で 12 / 8 を置く。既存の引数の行はそのまま残し、足す行だけを加える(G3 が副コマンドの登録関数を分けるため) - - 使える者の解決(`_resolve_reviewers(host, args)`): 母集合の既定と使える者の解決を呼び、使える者が 2 者に満たなければホストを止めない確認で確かめて埋め合わせ(`fallback`)を決める。1 者指定があればホストを確かめず埋め合わせは空。割り当ての失敗は終了コード 1 へ写す。従来の確認の相手を決める関数と 1 者指定の検査(`_auth_targets` / `_validate_only`)はこの関数で置き換える - - 初期状態: `participants`(埋め合わせを含む 8 項目)と `resume_changes: []` を書く。`max_rounds` / `rotate_after` は既定を埋めた値 - - 標準エラーの行: 母集合と使える者の 1 行、通らなかった者は 1 者 1 行、埋め合わせは 1 行(文言は「実装で決めたこと」) -- **満たす受け入れ条件:** AC14〜AC20 -- **進め方:** 失敗するテスト(止めない確認を差し替えて新規の初期化を呼び、状態ファイルの有無・終了コード・標準エラーを見る)→ 最小実装 → 整理。既存テスト `test_auth_check_covers_only_the_reviewers_that_run` と `test_init_rejects_an_only_outside_the_pool` は新しい形へ書き直す - -### Task 5: 担当の読み出しを記録から先に見る順へ変え、前ラウンドの検査に記録の担当を渡す - -- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_review_pool.py`、`cross-review/tests/test_state_round_guard.py` -- **変更内容:** 担当の読み出し(`_round_reviewers`)を「ラウンドの記録 → 1 者指定 → 参加者の記録から席の埋め方 → ホストの輪番(変更前と同じ値)→ `codex` / `agy`」の順にする。前ラウンドの検査(`_guard_previous_round`)は `prev["reviewers"]`(無ければ担当の読み出し)を結果なしの判定と通過の判定へ渡す -- **満たす受け入れ条件:** AC17(席が 2 つ返る部分)、AC18・AC19(`start-round` の返り値)、AC22、AC23 -- **進め方:** 失敗するテスト → 最小実装 → 整理 - -### Task 6: 席の名前を結果の受け口と起動・監視・計測に通す - -- **対象ファイル:** `cross-review/scripts/state.py`(結果の受け口の引数)、`launch-reviewer.sh` / `critique.sh` / `critique-round.sh` / `wait-review.sh`、`lib/monitor.py`、`cross-review/scripts/measure.py`、`cross-review/tests/test_seat_names.py`(新設)、既存の監視のテスト -- **変更内容:** - - 結果の受け口(`read-result`)の担当の引数を、席の名前の形の検査(`seat_runtime` を型に使う)にする。通らなければ argparse の終了コード 2 - - 起動スクリプト 2 本(`launch-reviewer.sh` / `critique.sh`)の先頭の検査を席の形(`^(claude|codex|agy|kiro)(-[2-9])?$`)にし、CLI は `${SEAT%%-*}` で選んで共通の起動スクリプト(`launch-cli.sh`)へ渡す。結果ファイルの stem は席の名前で組む(変更なし)。`critique-round.sh` は席の名前をそのまま `critique.sh` へ渡すだけで変更は無い(確かめて記録する)。`wait-review.sh` は使い方の説明の担当名を席の名前に直す - - 監視(`lib/monitor.py`): 席の名前からランタイムを引く内部関数を 1 つ置き、CLI 固有の分岐 3 か所(`codex` の sentinel 2 か所、`claude` の標準出力の検査 1 か所)をその関数で包む。位置引数の選択肢を席の形と `both` を受ける型へ替える - - 計測(`measure.py`): 担当の名前の一覧で数える箇所を、記録の鍵のうち席の形に一致するものを数える形にする -- **満たす受け入れ条件:** AC21 -- **進め方:** 失敗するテスト(結果の受け口を `claude-2` で呼ぶ / 起動スクリプトを共通の起動スクリプトを差し替えて呼び、渡った CLI 名と stem を見る / 監視の位置引数に `kiro-2` を渡す)→ 最小実装 → 整理 - -### Task 7: cross-review の再開で引数を反映し、担当に関わる引数で参加者を作り直す - -- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_resume_args.py`(新設) -- **変更内容:** 反映の表(`max_rounds` / `rotate_after` / `only` / `verify_commands` / `verify_exit_codes` は反映する。`host` は知らせる)を置き、再開の経路(`_resume_from_state`)に引数を渡して再開の反映を呼ぶ。1 者指定・外す者・足す者・全員を要する指定のどれかを渡した再開では、渡さなかった引数を状態ファイルの値(`participants.included` / `excluded` / `require_all`、`only`)で補って使える者の解決をやり直し、`participants` を書き換えて `resume_changes` に 1 件積む。失敗したら状態ファイルを書き換えずに終了コード 1。既存の関数は引数を 1 つ足し、本体の既存の行は動かさず、反映の呼び出しを 1 ブロック足す形にする(G3 の分割と競合する行を減らす) -- **満たす受け入れ条件:** AC25〜AC29 -- **進め方:** 失敗するテスト(状態ファイルを置いた作業ツリーで初期化を呼び、状態ファイルと標準エラーを見る。一部の引数だけを渡す組み合わせを含む)→ 最小実装 → 整理 - -### Task 8: 完了報告に「参加した者」の節を足す - -- **対象ファイル:** `cross-review/scripts/state.py`、`cross-review/tests/test_state_review_pool.py` -- **変更内容:** 「PR 履歴」の後に「参加した者」の節を出す関数を 1 つ足し、完了報告(`cmd_report`)から 1 行で呼ぶ。行は 7 つ(母集合 / 使える者 / 外した者 / 足した者 / 確認を通らなかった者(理由つき)/ 席の埋め合わせ / 再開で変えた値)。参加者の記録が無ければ「使える者: 記録なし」、確認を飛ばした印が真なら「確認を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」。既存の行は書き換えない(G3 が完了報告の結末の節を触る) -- **満たす受け入れ条件:** AC24 -- **進め方:** 失敗するテスト → 最小実装 - -### Task 9: 骨組みと文書を席の規則と新しい引数に合わせる - -- **対象ファイル:** `cross-review/SKILL.md`、`docs/01-state-and-review.md`、`docs/04-contracts.md`、`docs/05-pool-and-convergence.md`、`cross-review/tests/test_skill_layout.py` -- **変更内容:** 骨組み(Step 0 / 2 / 2.5)を契約文書の「手順書の骨組み」の形にする。初期化へは値のある引数だけを渡し、起動・監視・取り込み・反証の担当は `$REVIEWERS` / `$REVIEWERS_CSV` を使う。引数の表と `argument-hint` に 3 つの引数を足し、`--only` から「デバッグ用」を消す。`docs/05` に使える者の解決・席の埋め方(3 者以上 / 2 者 / 1 者 / 0 者)・足す者と外す者・確認が把握になったことを書く。`docs/04` に `participants` と `resume_changes` と席の名前の形を書く。`docs/01` に再開で反映する引数と反映しない引数の表を書く。`test_skill_layout.py` に「`ONLY` を含む行は初期化へ渡す行と引数の説明の行だけ」の検査を足す -- **満たす受け入れ条件:** AC30、AC44 -- **進め方:** テスト(`grep` の行数)→ 文書の書き換え。文書は `markdown-writing` の規約で書く - -### Task 10: 設計文書の「未確認のまま残ること」を更新し、配布物と検査を通す - -- **対象ファイル:** `issues/issue-727-687-478-664-648-design.md`、`lib/README.md`、生成物 -- **変更内容:** 実装で決めた 3 件(監視の比較の寄せ方・出力の文言・テストの置き場所)を「未確認のまま残ること」の表から「決めた」へ書き換える。共通層の README の関数の行を足す。`bash scripts/build-runtime-plugins.sh` で生成物を揃え、AC49・AC50 のコマンドを通す。AC45・AC46・AC48 を手元で実行し、結果を issue のコメントに残す -- **満たす受け入れ条件:** AC45、AC46、AC48、AC49、AC50 -- **進め方:** コマンドの実行と結果の記録(テスト駆動の対象ではない) - -## 実装で決めたこと - -設計文書が実装に委ねた 3 件を決める。 - -| 項目 | 決めたこと | 理由 | -| --- | --- | --- | -| 監視の CLI 固有の検査を席の名前に通す形 | 監視(`lib/monitor.py`)に席の名前からランタイムを引く内部関数を 1 つ置き、比較 3 か所をその関数で包む。席の形に合わない名前はそのまま返す | 監視は cross-refactoring も使い、担当名を任意の骨格で受ける経路がある(`test_monitor_generic_stem.py`)。形に合わない名前で失敗させると、その経路が壊れる | -| 出力の文言 | 反映した行は `↻ <項目>: <旧> → <新>`、知らせる行は `ℹ --<引数> は再開では反映しません(状態: <値> / 指定: <値>)`、通らなかった者は `⚠ <名前> を担当から外しました(<理由>)`、埋め合わせは `⚠ 使える者が <数> 者のため、席を<相手>で埋めます(観点が減ります)` | 既存の初期化の出力が `↻` / `ℹ` / `⚠` の印で始まる形に揃える。項目名と値を含めることは設計が決めている | -| テストの置き場所 | 設計文書の「テスト設計」の表のとおり。席の埋め方のテストは cross-refactoring の割り当てのテスト(`test_assignment.py`)に置く | 変更前の席の割り当てのテストが同じファイルにあり、AC8 の期待値をその場で引ける | - -## 影響範囲 - -- cross-review の初期化の既定の振る舞い: 確認を通らない者が 1 者でもあれば止める形から、使える者で回す形へ(従来の形は `--require-all`) -- cross-review の席: 使える者が 2 者に満たないとき、ホスト → 同じランタイムの 2 つ目で埋める。従来は 1 者で回すか失敗していた -- 状態ファイル・結果ファイルの名前に、席の名前(`claude-2` など)が現れうる。読む側(監視・計測・完了報告)はこの Pull Request で追随する -- cross-refactoring: 共通層の関数が増えるだけで振る舞いは変わらない。旧関数を残すため既存のテストは通る - -## リスクと対処 - -| リスク | 対処 | -| --- | --- | -| 状態の部品(`state.py`、4470 行)を G2 / G3 と並行して触る | 既存の行の書き換えを最小にし、足す形で書く。着手の前に G3 の差分を読んだ(副コマンドの登録関数の分割・再開の経路の 3 分割・完了報告の結末の節)。触る関数を初期化・担当の読み出し・前ラウンドの検査・結果の受け口の引数・完了報告の呼び出し 1 行に限る | -| 監視の比較を包む変更が、任意の骨格で担当名を受ける経路を壊す | 席の形に合わない名前はそのまま返す。既存の監視のテスト(`test_monitor_*`)を毎タスクで通す | -| 席の名前が読み手に届かない箇所が残る | 設計文書の「構成要素」の受け口 10 か所を 1 つずつ検査に対応づけ、`grep -n 'claude|codex|agy|kiro' scripts/` で分岐と選択肢を洗い直す | -| 従来の確認のテストが止めない確認の導入で意味を失う | 2 本目の Pull Request で消すまで残す。この Pull Request では止めない確認のテストを足す | - -## 切り戻し手順 - -- Pull Request を revert すれば戻る。状態ファイルの新しい項目(`participants` / `resume_changes`)は、旧版の読み手が読まない鍵のため、途中の実行を旧版で再開しても壊れない -- 席の名前を持つ状態ファイル(`claude-2` の鍵)を旧版で読むと、その席の結果は数えられない。旧版へ戻すときは実行を新しく始める - -## 完了の定義 - -- [ ] AC1〜AC6、AC8〜AC30、AC44〜AC46、AC48〜AC50 を満たし、条件ごとに検証手段と結果が対応している -- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る(監視の環境変数を export していないシェルで実行する) -- [ ] AC50 の 6 つのコマンドが終了コード 0 で終わる -- [ ] 設計文書の「未確認のまま残ること」が更新されている -- [ ] Draft の Pull Request を `develop` 宛に出した diff --git a/issues/issue-728-647-592-553-design.md b/issues/issue-728-647-592-553-design.md deleted file mode 100644 index 709e5411..00000000 --- a/issues/issue-728-647-592-553-design.md +++ /dev/null @@ -1,570 +0,0 @@ -# cross-refactoring: 実装担当が結果を残さないと同じ群が上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、群が開いた回数と結末で開き直しを決める(設計 / #728 #647 #592 #553) - -## 目的 - -- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わることがある。原因は無進捗の打ち切り・利用上限・15 秒で落ちる kiro である。このとき取り込みは、下位の読み取りがプロセスを終わらせるため止まる。同じ群が上限なしに開き直される(PR #757 で 29 回、rf646 で 3729 回)。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続ける(rf587 で 187 回)。claude が担当の群は帰属行のためトレーラーが読めずに落ちる -- **困る人**: cross-refactoring を回す進行側(手で止めるまで CLI の起動と利用料が続く)と、その Pull Request を読む人(未検証の差分が混じる) -- **直すと成り立つこと**: 結果なしは 3 つの取り込みが 1 つの手順で受ける。未検証のコミットを取り消し、結末を記録する。群は開いた回数と前回の結末を持ち、担当を替えて 1 回だけ開き直して終わる。項目の無い群は開かない。帰属行の後ろでもトレーラーが読める - -この文書は「どう作るか」だけを扱う。要求と受け入れ条件、置き換える既存の設計、並行する設計との前後関係は末尾の「関連文書と前後関係」にある。 - -## 用語の対応 - -本文は左の業務用語で書く。右の識別子は、コードブロック・表・「置き場所」「データ構造」「入出力の契約」で使う。 - -| 業務用語 | 識別子 | -| --- | --- | -| 取り込み | 担当の CLI が作ったコミットを進行側が検証して受け入れるコマンド。適用の取り込み `merge-apply`(`commands/apply.py`)/ 修正の取り込み `merge-fix`(`commands/converge.py`)/ 最終ゲートの修正の取り込み `merge-final-fix`(`commands/gate.py`)の 3 つ | -| 群を開く | `next-apply-round`(`commands/apply.py`) | -| 最終ゲート | `final-gate`(`commands/gate.py`) | -| 結末の読み取り | `gitfacts.read_result` | -| 共通層の読み取り | `lib/monitor_outcome.py` の `read_launch_outcome`。G3 が作る | -| 結末 | `LaunchOutcome`。担当 1 回の起動の終わり方。使える結果(`payload`)か、結果なしの理由(`reason`: `missing` / `unparsable` / `stalled` / `usage_limit` など)を持つ | -| 起動し直しの可否 | `LaunchOutcome.relaunch_same_agent`。偽は同じ担当を同じ条件で起動しても解けない(`usage_limit`) | -| 取り込みの共通手順 | 新設の `refactor_lib/intake.py`。範囲の値 `IntakeScope`、閉じた結果 `ClosedAttempt`、範囲の確定 `confirm_range`、取り消し `discard_unverified`、叩き直しの判定 `already_closed`、結果なしの一連 `close_without_result` | -| 群の進行 | `refactor_lib/rounds.py` | -| 開き直しの判定 | `rounds.group_reopening` | -| 輪番から担当を引く関数 | `rounds.impl_for_seq` | -| 群の一覧 | `rounds.apply_groups` | -| 群 | 状態ファイルの `rounds[].apply_rounds[]` の 1 件。書き換えるファイルが重ならない項目の集まり | -| 試行の番号 | 群の `attempt` | -| 結末の記録 | `failed_attempts[]`。群と最終ゲートの記録(`final_gate`)が持つ | -| 取り消しの理由 | 群の `drop_reason`(`no_result` / `empty`) | -| 起点 | 適用は `apply_base_sha` と群の `base_sha`、修正と最終ゲートは `fix_base_sha` | -| 修正ラウンドの数と上限 | `fix_rounds` と `--max-fix-rounds` | -| 輪番の通し番号 | `apply_seq` | -| 試行の上限 | `vocabulary.MAX_APPLY_ATTEMPTS`(2) | -| 無進捗の許容 | `init` が出す `IMPL_STALL_TIMEOUT`。余白は `vocabulary.IMPL_STALL_MARGIN`(900 秒)、テストの制限時間は `--test-timeout` | -| トレーラーの読み取り | `gitfacts.commit_trailers` | -| 結果ファイルの名前の幹 | stem。`paths.stem_for` が組み、監視は `--stem-template` で受ける | -| G1 / G3 / G4 | 実行計画の束の名前。G1 = 参加者の決め方(#727、PR #782)、G3 = 結末の読み取りの共通層(#729、PR #781)、G4 = この設計(#728) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 担当が結果を残さなくても、3 つの取り込みが範囲を確め、未検証のコミットを取り消し、結末を記録して終わる | cross-refactoring を回す進行側 | -| F2 | 結果を残さない担当の群を、担当を替えて 1 回だけ開き直し、2 回目も残さなければ取り消す | 同上 | -| F3 | 採用 0 件の提案ラウンドと項目の無い群で、適用担当を起動せずに次へ進む | 同上 | -| F4 | 修正の結果を群の担当から読み、結果が無ければ修正ラウンドを 1 つ進める | 同上 | -| F5 | 最終ゲートの修正で結果が無ければ、作られたコミットを取り消して次の判定へ戻す | 同上(`--workflow-step` の実行) | -| F6 | 起動し直しても解けない結末では、同じ担当を同じ工程で起動し直さない | 同上 | -| F7 | 帰属行の段落が後ろに付いたコミットから必須トレーラーを読む | 同上(claude が適用担当の群) | -| F8 | 適用・修正の監視が、テストの実行中の無出力で担当を打ち切らない | 同上 | - -## 実測 - -2026-09-19 に `develop`(9eaebe14)で読み取った現状である。コードを実行して確かめた値は既存の設計の「実測」にあり、変えていない。 - -| 見たもの | 値 | -| --- | --- | -| `read_result` の呼び出し元 | 3 か所(`commands/apply.py:452` / `commands/converge.py:478` / `commands/gate.py:165`)。いずれも `die(code=2)` を内包する | -| 取り消しの本体 | 2 つ(`gitfacts.revert_unverified_range`(`converge.py:383` / `gate.py:203` が呼ぶ)と `apply._revert_unverified_apply_round`)。違いは起点の鍵(`fix_base_sha` / `apply_base_sha` と群の `base_sha`)と `entry["apply"]` の記録 | -| `merge-final-fix` の順序 | `read_result`(:165)→ `commits_in_range`(:167)→ `unassigned_fix_commits` / `verify_final_fix_commit`(:178-189)→ `revert_unverified_range`(:199-206)。結果なしでは 2 つ目以降へ進まない | -| `rounds.apply_groups` | `if groups:`(`rounds.py:109`)だけで分岐し、`None` と `[]` を区別しない | -| `stem_for` / `result_path` | `refactor_lib/paths.py:94` / `:85`。`result_path` は `tmp_dir / f"{stem}-result.json"` で、G3 の `read_launch_outcome` の既定と同じ | -| 既存の設計が名付けた関数 | `MAX_APPLY_ATTEMPTS` / `impl_for_seq` / `load_result` / `_close_failed_attempt` / `_monitor_reason` はいずれも未実装(PR #665 は文書だけ) | - -## 決定の記録 - -### 決定 1: 通過済みの決定と新しい決定を差分で読み分けるために、設計文書は親 #728 の名前で新設し、既存の本体は書き換えない - -既存の設計は 3 課題を 1 つの文書で扱い、設計 Pull Request の関門を通過している。親 #728 は既存の決定 6(取り消しの本体を切り出して共有し、最終ゲートを #674 に残す)を改め、結果の読み取りの向きを変える。その節を書き換えると、通過済みの決定と新しい決定が 1 つの差分に混ざる。**新設して対応表で指せば、変わった決定だけが差分に載る。** 既存の設計文書の本体には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの「決定の記録」の見出しをすべて写す。既存の本体に 1 行でも触ると、既存の 13 件がこの Pull Request の決定として並ぶ。案内は「決定の記録」を持たない既存の要求の文書にだけ足す(#729 / #727 の設計と同じ扱い)。 - -### 決定 2: 同じ関数を 2 度変えないために、4 課題と #674 を 1 本の Pull Request で直す - -結末の読み取り(`gitfacts.read_result`)の契約を変えると、呼び出し元 3 か所を同時に書き直すことになる。3 か所は適用・修正・最終ゲートの修正の取り込みである。取り消しの本体を 1 つにすることも、同じ 3 か所を同時に触る。課題ごとに分けると同じ関数を 2 度変える。#553 は同じファイル(`gitfacts.py`)と適用の取り込みの検証に閉じ、分けても触るファイルが重なる。 - -### 決定 3: 監視と同じ結果ファイルの名前を 1 か所で組むために、結末の読み取りは名前を残して引数を状態と工程に変え、結末を値で返す - -G3 の契約(G3 の設計の決定 11)は「共通層の読み取り(`read_launch_outcome`)を呼び、値を返し、プロセスを終わらせない」までである。薄い包みとして残すかは G4 に任せている。**包みとして残す。** 監視に渡した結果ファイルの名前の幹(stem)と食い違う幹を渡すと、監視の結果ファイルを引けない(G3 の未確認 6)。幹を作る場所を結末の読み取り(`read_result(state, runtime, phase, round_no=None)`)の 1 つにすれば、3 つの取り込みが同じ組み立てを通る。突き合わせのテスト(AC3)も 1 か所で書ける。名前を残すのは、親 #728 と子 issue の本文がこの名前で根本原因を指しているためである。引数を変えるため、古い形の呼び出し(`read_result(path, runtime)`)は実行時に失敗し、契約の変更を素通りしない。 - -呼び出し側が共通層の読み取りを直接呼ぶ形は採らない。幹の組み立てが 3 か所に分かれる。 - -### 決定 4: 取り消しの本体を 1 つにするために、「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を新設の取り込みの共通手順に置く - -3 つの取り込みは、結果を読めたときも読めなかったときも同じことを行う。起点から HEAD までの範囲を確め、通らなければ範囲を取り消し、起点を取り消し後の HEAD へ進める。いまは取り消しの本体が 2 つある。修正と最終ゲートが使うもの(`gitfacts.revert_unverified_range`)と、適用が使うもの(`apply._revert_unverified_apply_round`)である。違いは起点の鍵だけである。**取り込みの共通手順(`refactor_lib/intake.py`)を新設する。そこに範囲の確定(`confirm_range`)・取り消し(`discard_unverified`)・結果なしの一連(`close_without_result`)を置く。** 起点の鍵と記録先の違いは、取り込みの範囲の値(`IntakeScope`)で渡す。取り込みが持つのは、その値をどう終了コードと群の状態へ写すかだけになる(親 #728 の統合の手)。 - -置き場所をコマンドの層(`commands/`)にしないのは、適用・修正・最終ゲートの 3 つが読む層だからである。群の進行と同じ理由で、コマンドどうしの取り込みを作らない。git の事実の読み取り(`gitfacts.py`)に足す形も採らない。1158 行あり、取り消しは git の事実の読み取りではなく進行の手順である。 - -### 決定 5: 要約と改修計画が 1 つの読み方で済むように、結果なしの記録は 3 つの取り込みで同じ形の配列にする - -記録の形を 3 通りに分けると、実行の要約と改修計画が 3 通りの読み方を持つ。3 通りとは、群の結末の記録(`failed_attempts`)、修正の取り込み済みの鍵(`fix_merged_keys` の `":missing"`)、最終ゲートの独自の記録である。**記録の辞書は群、または最終ゲートの記録(`final_gate`)である。その結末の記録(`failed_attempts[]`)に `{phase, attempt, impl, reason, detail, at, reverted}` を足す。** 工程(`phase`)が適用・修正・最終ゲートの修正を分ける。試行の番号(`attempt`)は叩き直しの判定に使う。同じ工程と試行の記録があれば、読まずに終了コード 2 を返す。修正の取り込み済みの鍵は、結果を読めたときの二重取り込みの判定に残し、結果なしの判定には使わない。 - -### 決定 6: 壊れた担当に当たり続けないために、同じ群の試行の上限は 2 回の固定値にし、引数を足さない - -2 回目は別の担当が試す(決定 8)。2 回とも結果を残さなければ、担当ではなく群の側を疑える。3 回以上にしても壊れた担当に当たる確率が上がるだけである。値は語彙(`vocabulary.py` の `MAX_APPLY_ATTEMPTS`)に置く。上限の引数(`--max-apply-attempts`)を足す形は採らない。手順書(`SKILL.md`)は、上限を 2 つ置くとどちらで止まったかを読み解く必要が出るとしている。止まった理由は群の記録(取り消しの理由と結末の記録)が持つ。 - -### 決定 7: 中断からの再開と失敗のやり直しを区別するために、開き直しの判定は群の進行の 1 つの関数に置き、群を開く側と適用の取り込みの両方がそれを読む - -いまの群を開く側(`next-apply-round`)は、未着手(`pending`)と適用済み(`applied`)の群を無条件に開き直す。中断からの再開と失敗した試行のやり直しを区別しない。判定に要る値は群が持つ 2 つである。開いた回数(`attempt`)と、結末の記録のうち工程が適用のものの件数である。**開き直しの判定(`group_reopening(group)`)がこの 2 つから次の 4 つのどれかを返す。** - -| 値 | 意味 | -| --- | --- | -| `open` | 開いて試行の番号を進める | -| `resume` | 開いたまま閉じていない試行を再開する。番号を進めない | -| `exhausted` | 上限に達した。開かない | -| `empty` | 項目が無い | - -群を開く側は開くかどうかを、この値で決める。適用の取り込み(`merge-apply`)は結果なしを記録した後に担当を替えるか取り消すかを、同じ関数の値で決める。 - -開いた回数だけを数える形は採らない。進行側が落ちて再開しただけで試行が進む。監視の終了コードで骨組みが分岐する形も採らない。結果ファイルが後から書かれた場合や、監視は正常でも JSON が壊れている場合は、結果ファイルの側で決めるしかない。判定を 1 か所に置けば、骨組みは `|| continue` のまま変わらない。 - -### 決定 8: 壊れた CLI が 1 者でも他の担当で群を進めるために、2 回目の試行は次の輪番の担当が行い、替える先が無いときだけ結末の可否で決める - -結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の無進捗 4 回、claude の 429 の 3729 回、PR #757 の kiro)。同じ担当で開き直しても直らない。替える先は次の手順で決める。輪番の通し番号(`apply_seq`)を 1 ずつ進め、輪番から担当を引く関数(`rounds.impl_for_seq`)を引く。その群で失敗した担当のどれとも違う担当が出た、最初の番号の担当を替える先とする。1 つ進めるだけにしないのは、輪番が 1 周すると同じ担当へ戻るためである(既存の設計の「実測」: 通し番号 1 と 5 はどちらも codex)。探索は参加者の数だけ進めれば全員を 1 度ずつ見る。打ち切りの回数は固定値ではなく参加者の数から導く。 - -**替える先が無いとき**(参加者が 1 者。G1 の除外の引数 `--exclude` で残りが 1 者になる実行)は、起動し直しの可否(`relaunch_same_agent`)を読む。真(結果なしの理由が `missing` / `stalled` など)なら同じ担当で 2 回目を開く。偽(`usage_limit`)なら 1 回目で群を取り消す。可否を読むのはこの分岐だけで、替える先があるときは常に替える。利用上限で進行全体を止める形は採らない。他の担当で進められる群まで止まる。 - -### 決定 9: 参加者の決め方の変更(G1)を 1 か所で受けるために、作業を任せる担当の決定は輪番から担当を引く 1 つの関数を通す - -輪番から担当を引く関数(`rounds.impl_for_seq(state, seq)`)を新設する。輪番の通し番号から担当と要求モデルを引く呼び出しは、その中だけにする。呼ぶのは 3 か所である。 - -| 呼ぶ場所 | 決めるもの | -| --- | --- | -| 群の割り当て(`apply._assign_apply_rounds_to_state`) | 群の担当 | -| 結果なしの試行を閉じる処理(`apply._close_failed_attempt`) | 交代先 | -| 最終ゲートの修正担当の決定(`gate._final_fix_impl`) | 最終ゲートの修正担当 | - -G1 は担当の割り当て(`assignment.assign`)を参加者からの割り当て(`impl_assign(participants, seq)`)に置き換える。**どちらが先に入っても、変える場所はこの関数の中だけである。** 提案ラウンドの開始(`setup.cmd_start_round`)の担当は CLI を起動しないため通さない。 - -### 決定 10: 修正が上限なしに往復しないように、修正の結果は群の担当から読み、結果なしは修正ラウンドを進める。起動し直せない結末では上限へ進める - -修正の取り込み(`merge-fix`)は提案ラウンドの担当(`entry["impl"]`)の結果を読むが、骨組みが起動するのは群の担当である。一致しない群では結果を一度も取り込めない。修正ラウンドの数(`fix_rounds`)が進まないまま、検証と修正を往復する(既存の設計の「実測」)。担当は群の担当(`current_group(entry)["impl"]`)から読む。結果なしは結果なしの一連(`close_without_result`)を通し、修正ラウンドの数を 1 進めて終了コード 2 で終わる。範囲を確定できないときの既存の扱い(`_resolve_fix_range`)と同じ形である。 - -**起動し直しの可否が偽なら、修正ラウンドの数を上限(`--max-fix-rounds`)の値にする。** 修正の担当は替えない。直しかけの文脈を持つ者が続ける、という最終ゲートの既存の理由と同じである。替えないまま上限まで起動し直すと、利用上限の担当を最大 3 回起動して 3 回とも 15 秒で落ちる。上限へ進めれば見送りの判定(`should-abandon`)が次の呼び出しで見送りへ移し、骨組みの行は変わらない。 - -### 決定 11: 未検証のコミットを Pull Request に残さないために、最終ゲートの修正も同じ手順を通し、結果なしは終了コード 2 で終えて最終ゲートに判定を戻す - -最終ゲートの修正の取り込み(`merge-final-fix`)が結果なしでプロセスを終わらせると、担当が作ったコミットが範囲の検査も取り消しも受けずに残る。次の最終ゲート(`final-gate`)はそのコミットを含む HEAD でテストし、落ちれば起点を HEAD へ置き直す(#674)。**結果なしの一連を通せば、コミットは取り消され、起点は取り消し後の HEAD になる。次の最終ゲートは修正前の地点でテストする。** 終了コードは 2 で、骨組みは見ずに最終ゲートへ戻る(変えない)。最終ゲートが修正ラウンドの数を進めるため、繰り返しは上限で止まる。 - -起動し直しの可否が偽なら、最終ゲートの修正ラウンドの数(`gate.fix_rounds`)を上限の値にする。次の最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる。既存の「Step 7 は push 済みの地点。上限に達しても採用した改善項目は取り消さない」の規則を変えない。 - -### 決定 12: 項目の無い群で担当を起動しないために、採用 0 件の提案ラウンドでは群を作らず、項目の無い群は開かずに取り消す - -群の一覧(`rounds.apply_groups`)は `if groups:` で分岐する。鍵が無い(`None`)ときも空の配列(`[]`)のときも 1 つの群を作る。提案の取り込み(`merge-proposals`)は採用 0 件で空の配列(`apply_rounds = []`)を書くため、ここで項目 0 件の群が生まれる。**鍵が無いときだけ古い版として群を作り、空の配列はそのまま返す。** 既に項目の無い群を持つ状態ファイル(rf587)は残る。開き直しの判定が「項目が無い」(`empty`)を返した群は、群を開く側が取り消し済み(`dropped`、理由 `empty`)にして次を探す。適用の取り込みの取り込み済みの判定でも、採用 0 件だった群を取り消し済みに直す。 - -提案の取り込みがテスト整備の採用 0 件で終了コード 2 を返す形は採らない。2 は構造改善の繰り返しを終える合図で、テスト整備では構造改善へ進む前に抜けてしまう。 - -### 決定 13: 帰属行の書式に左右されずに検証を通すために、トレーラーは末尾から続く段落を git の判定で読み、進行側はコミットを書き換えない - -トレーラーの読み取り(`commit_trailers`)はメッセージを空行で段落に分ける。末尾の段落から前へ向かって 1 段落ずつ git の判定(`git interpret-trailers --parse`)に掛け、git がトレーラーの段落と判定しなかった段落で止める。同じ鍵は末尾に近い段落の値を採る。**1 段落目(題名)は掛けない。** 掛けると `Refactor: …` の題名をトレーラーとして読む(既存の設計の「実測」)。 - -#553 の本文は、帰属のトレーラーを付ける責務を進行側の取り込みへ移す(`git commit --amend`)ことを修正レイヤーとしている。**採らない。** 理由は 3 つある。 - -| 理由 | 何が起きるか | -| --- | --- | -| SHA が変わる | 結果ファイルの申告(`commits[].sha`)と実体の対応が切れる | -| 群に複数の項目がある | 先頭のコミットを書き換えた時点で以降のすべてが書き換わる。範囲の検査の前提(申告の SHA が範囲に実在する)が崩れる | -| 実際に使ったモデル名(`Impl-Model`)は担当しか知らない | 進行側が付けるには結果ファイルから受け取ることになる | - -段落ごとの読み取りは、トレーラーの形で書かれた署名なら誰が何行足しても同じに読む。効かないのは、トレーラーの形でない散文を末尾に足すランタイムが現れたときだけである。それは「未確認のまま残ること」に置く。 - -全文から `^: ` を拾う形も採らない。散文の段落にある `Round: …` の形の行を拾う。 - -### 決定 14: 人が git の標準の読み方でも集計できるように、雛形のコミットの規約にも必須トレーラーを最後の段落に置くことを書く - -進行側の検証は決定 13 で通る。一方、人が git の標準の読み方(`git log --format='%(trailers:key=Impl-Model,valueonly)'`)で集計すると、最後の段落しか読まない。帰属行を同じ段落に続けて書けば、git の標準の読み方でも取れる。従わなくても決定 13 で検証は通る。 - -### 決定 15: テストの実行中の無出力で担当を打ち切らないために、無進捗の許容をテストの制限時間 + 900 秒にし、雛形に進捗マーカーを足す - -既存の設計の決定 13 をそのまま引き継ぐ。適用・修正の担当はテストを 1 回実行し、その間は何も出力しない。起動(`init`)が無進捗の許容(`IMPL_STALL_TIMEOUT`)を出す。骨組みの適用・修正・最終ゲートの修正の監視が、それを無進捗の引数(`--stall-timeout`)に渡す。全体の制限時間(`--timeout`)は渡さない。上限は P2 の工程の引数(`--phase`)と `lib/limits.py` が決める。監視(`monitor.py`)は変えない。 - -## 構成要素 - -| 要素 | 新設 / 変更 | 責務 | -| --- | --- | --- | -| 結末の読み取り(`gitfacts.read_result`) | 変更 | 状態と工程から stem を組み、`read_launch_outcome` を呼んで `LaunchOutcome` を返す(決定 3) | -| 取り込みの共通手順(`intake.py`) | 新設 | 範囲の確定・未検証のコミットの取り消し・結末の記録(決定 4・5) | -| 群の進行(`rounds.py`) | 変更 | `apply_groups` の空配列の扱い、`group_reopening`、`impl_for_seq`(決定 7・9・12) | -| 適用の取り込み(`commands/apply.py`) | 変更 | `next-apply-round` が `group_reopening` で開く。`merge-apply` が結果なしを `_close_failed_attempt` へ渡し、担当の交代か取り消しを行う(決定 6〜9) | -| 修正の取り込み(`commands/converge.py`) | 変更 | 群の担当の結果を読む。結果なしは共通の手順を通して修正ラウンドを進める(決定 10) | -| 最終ゲート(`commands/gate.py`) | 変更 | `merge-final-fix` が共通の手順を通す。`_final_fix_impl` が `impl_for_seq` を引く(決定 9・11) | -| 語彙(`vocabulary.py`) | 変更 | `MAX_APPLY_ATTEMPTS = 2`、`IMPL_STALL_MARGIN = 900` | -| 起動(`commands/setup.py`) | 変更 | `_emit_init` が `IMPL_STALL_TIMEOUT` を出す(決定 15) | -| トレーラーの読み取り(`gitfacts.commit_trailers`) | 変更 | 末尾から続くトレーラーの段落を読む(決定 13) | -| 雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`) | 変更 | 必須トレーラーを最後の段落に置く。進捗マーカー(決定 14・15) | -| 手順書(`SKILL.md` / `docs/02` / `docs/04`) | 変更 | 語の表、「別の上限を置かない」の削除、骨組みの監視の引数、結果なしのときの振る舞い | -| 共通層(`lib/monitor_outcome.py`) | 変えない | G3 が実装する `read_launch_outcome` を読む | - -```mermaid -graph TD - subgraph 取り込み - A[merge-apply] - F[merge-fix] - G[merge-final-fix] - end - subgraph 共通の手順 - R[gitfacts.read_result] - I[intake
confirm_range / discard_unverified
close_without_result] - end - subgraph 群の進行 - N[next-apply-round] - RO[rounds.group_reopening] - IS[rounds.impl_for_seq] - end - MO[lib/monitor_outcome
read_launch_outcome] - A --> R - F --> R - G --> R - R --> MO - A --> I - F --> I - G --> I - A --> RO - N --> RO - A --> IS - G --> IS -``` - -**図に含めない要素**は、語彙・起動の出力・トレーラーの読み取り・雛形・手順書である。呼び出しの辺を持たない値と文書か、適用の取り込みの検証が呼ぶ 1 本(トレーラーの読み取り)であり、図の主題(結末の扱いの統合)ではない。 - -### 文脈と配置 - -```mermaid -graph LR - 利用者 --> ホスト[ホストの CLI セッション] - ホスト -->|骨組みの bash| RF[refactor.py] - ホスト -->|launch-cli.sh / monitor.py| 担当[担当の CLI と監視] - RF --> TMP[一時ディレクトリ
結果ファイル・監視の結果ファイル] - 担当 --> TMP - RF --> WORK[作業ツリーの git] -``` - -**配置は変えない。** すべて利用者の機械のホストのセッションから起動するプロセスで、常駐しない。この変更で増える辺は 1 本だけである。進行の本体(`refactor.py`)が共通層を通して監視の結果ファイルを読む辺である。 - -### 置き場所 - -```text -plugins/ndf/skills/cross-refactoring/ -├── SKILL.md # 語の表・段落の削除・骨組み -├── docs/02-apply-and-review.md # Step 4 の結果なし・開き直し・トレーラー -├── docs/04-fix-and-report.md # Step 6 / Step 7 の結果なし・監視の引数 -├── prompts/{apply,fix,final-fix}.md # トレーラーの段落・進捗マーカー -├── scripts/refactor_lib/ -│ ├── intake.py # 新設: IntakeScope / confirm_range / discard_unverified / close_without_result -│ ├── rounds.py # apply_groups / group_reopening / impl_for_seq -│ ├── gitfacts.py # read_result(契約の置き換え)/ commit_trailers / revert_unverified_range の削除 -│ ├── vocabulary.py # MAX_APPLY_ATTEMPTS / IMPL_STALL_MARGIN -│ └── commands/{apply,converge,gate,setup}.py -└── tests/ # 新設 3 本(test_intake / test_apply_attempts / test_commit_trailers_git)と既存 6 本の変更。対応は「テスト設計」 -# dev.kiro / dev.agy の配布物は bash scripts/build-runtime-plugins.sh で同期する -``` - -## 構造 - -変更が触る型だけを載せる。結末(`LaunchOutcome`)は G3 が作る型で、名前と欄だけを置く。取り込みの共通手順の関数は「入出力の契約」にある。 - -```mermaid -classDiagram - class LaunchOutcome { - payload: Optional~dict~ - reason: Optional~str~ - detail: str - relaunch_same_agent: bool - } - class IntakeScope { - holder: dict - base_key: str - records: dict - phase: str - attempt: int - impl: str - label: str - mirror: Optional~dict~ - } - class ClosedAttempt { - reason: str - detail: str - reverted: int - relaunch_same_agent: bool - range_unknown: bool - } - class intake - intake ..> IntakeScope : 受け取る - intake ..> LaunchOutcome : 読む - intake ..> ClosedAttempt : 返す -``` - -| 触る型 | 責務 | -| --- | --- | -| `IntakeScope` | 取り込み 1 つ分の「どこを見て、どこへ書くか」。`holder` は `pending_push` と起点を持つ辞書(`rounds[]` の要素か `final_gate`)、`base_key` は起点の鍵(`apply_base_sha` / `fix_base_sha`)、`records` は `failed_attempts` を持つ辞書(群か `final_gate`)、`mirror` は起点を同じ値に揃える辞書(群の `base_sha`。修正と最終ゲートは `None`) | -| `ClosedAttempt` | 結果なしを閉じた結果。`range_unknown` が真なら取り消しも記録も行っていない(呼び出し側が中断の終了コードを決める) | - -## データ構造 - -状態ファイル(`cross-refactoring-rf<番号>-state.json`)に鍵を足す。**版は上げない。** 鍵が無い状態ファイルは、試行 0・失敗なしとして読む。 - -### 群の記録 - -群(`rounds[].apply_rounds[]` の 1 件)に足す鍵である。 - -| 項目 | 型 | 値 | 空のときの意味 | -| --- | --- | --- | --- | -| `attempt` | 整数 | いま開いている試行の番号。1 から | 鍵なし = 0(まだ開いていない)。`merge-apply` は 0 を 1 回目として記録し、値を 1 に書く(変更前の版で開いた群を再開したとき) | -| `failed_attempts` | 配列 | 結果を残さなかった起動。1 件 = 下の「結末の記録の 1 件」 | 鍵なし = 失敗なし | -| `drop_reason` | 文字列 | `no_result`(試行の上限、または起動し直せない結末で交代先なし)/ `empty`(項目なし) | 鍵なし = 既存の経路で取り消した、または取り消していない | -| `impl` / `impl_model` | 既存 | 担当を替えたときに書き換える。**前の担当は `failed_attempts[].impl` に残る** | — | - -### 最終ゲートの記録 - -最終ゲートの記録(`final_gate`)に足す鍵である。 - -| 項目 | 型 | 値 | 空のときの意味 | -| --- | --- | --- | --- | -| `failed_attempts` | 配列 | 結果を残さなかった最終ゲートの修正の起動 | 鍵なし = 失敗なし | - -### 結末の記録の 1 件 - -結末の記録(`failed_attempts[]`)の 1 要素の形である。 - -| 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `phase` | 文字列 | 許さない | `apply` / `fix` / `final-fix`。同じ群の適用と修正の記録を分ける | -| `attempt` | 整数 | 許さない | 群の `attempt`(適用)/ `fix_attempts`(修正)/ `fix_rounds`(最終ゲート)。叩き直しの判定の鍵 | -| `impl` | 文字列 | 許さない | 起動した担当 | -| `reason` | 文字列 | 許さない | `LaunchOutcome.reason`(`missing` / `unparsable` / `stalled` / `timeout` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad`) | -| `detail` | 文字列 | 許す(空文字) | 監視の `detail`。監視の結果ファイルが無ければ空文字 | -| `at` | 文字列 | 許さない | 記録した時刻 | -| `reverted` | 整数 | 許さない | その起動の範囲から取り消したコミットの数。0 = コミットなし | - -**記録は追記だけで、上書きしない。** 群の担当は替えると上書きされるが、どの担当がどの試行で失敗したかは記録から読める。見送りの理由(`deferred_items[].defer_reason`)は `実装担当が結果を残しませんでした(agy: stalled → codex: missing)` の形にする。改修計画の「見送った項目」の表にそのまま出る。 - -### 機能とデータの対応 - -| 機能 | 群の `attempt` / `impl` / `status` | 群の `failed_attempts` | `final_gate.failed_attempts` | `deferred_items` | -| --- | --- | --- | --- | --- | -| F2 適用の結果なし | U | C | — | C(上限) | -| F3 項目なし | U(`dropped`) | — | — | — | -| F4 修正の結果なし | — | C | — | — | -| F5 最終ゲートの結果なし | — | — | C | — | - -### 群の状態の遷移 - -```mermaid -stateDiagram-v2 - [*] --> pending: merge-proposals - pending --> dropped: next-apply-round(empty) - pending --> pending: merge-apply(結果なし・open)
担当を替える - pending --> dropped: merge-apply(結果なし・exhausted) - pending --> dropped: merge-apply(未割当・検証の失敗) - pending --> applied: merge-apply(取り込んだ) - applied --> dropped: merge-apply(取り込み済み・採用 0 件) - applied --> verified: verify-round(通った) - applied --> dropped: abandon-items / merge-test-judgements - verified --> [*] - dropped --> [*] -``` - -**未着手のまま同じ担当で無条件に開き直す遷移は無い。** 同じ担当で開き直すのは、交代先が無く起動し直しの可否が真のとき 1 回だけである(決定 8)。 - -## 入出力の契約 - -### 結末の読み取り(変更) - -| 項目 | 内容 | -| --- | --- | -| 名前 | `read_result(state, runtime, phase, round_no=None) -> LaunchOutcome` | -| 入力 | `state`: 状態ファイルの辞書(`tmp_dir` と `id` を読む)。`runtime`: 担当。`phase`: `apply` / `fix` / `final-fix`。`round_no`: 提案ラウンド(`final-fix` は省く) | -| 出力 | `read_launch_outcome(state["tmp_dir"], stem_for(runtime, phase, state["id"], round_no))` の値をそのまま | -| 失敗の形 | **失敗しない。** `die` せず、標準出力・標準エラーに書かない | -| 互換性 | 引数が変わる。呼び出し元 3 か所と `test_git_facts.py` の 3 件を書き直す | - -### 取り込みの共通手順(新設) - -| 名前 | 入力 | 出力 | 失敗の形 | -| --- | --- | --- | --- | -| `confirm_range(state, scope)` | `scope.holder[scope.base_key]` と HEAD | 新しい順のコミットの列。確定できなければ `None` | 失敗しない | -| `discard_unverified(path, state, scope, ordered_range)` | 取り消す範囲 | 取り消した数。`holder["pending_push"]` を立てて保存 → 新しい順に取り消す → `holder[base_key]`(と `mirror["base_sha"]`)を HEAD にして保存 | 取り消しに失敗したら既存の `_revert_range` が着手前へ戻して 4 で中断する | -| `already_closed(scope)` | `scope.records["failed_attempts"]` | 同じ `phase` と `attempt` の記録があれば真 | 失敗しない | -| `close_without_result(path, state, scope, outcome)` | `LaunchOutcome` | `ClosedAttempt`。範囲が `None` なら `range_unknown=True` で即返す。範囲にコミットがあれば `discard_unverified`。`records["failed_attempts"]` に 1 件足して保存。取り消したときだけ `push_with_retry_marker(holder)` | 取り消しの失敗は上と同じ | - -### 群の進行(変更・新設) - -| 名前 | 契約 | -| --- | --- | -| `apply_groups(entry)` | `apply_rounds` の鍵が無いときだけ古い版として群を 1 つ作る。空の配列はそのまま返す | -| `current_group(entry)` | 群が 1 つも無いときは 4 で中断する | -| `group_reopening(group) -> str` | `items` が空なら `empty`。`phase: apply` の失敗の件数を n として、n ≥ `MAX_APPLY_ATTEMPTS` なら `exhausted`、`attempt` > n なら `resume`、それ以外(`attempt` == n)は `open` | -| `impl_for_seq(state, seq) -> tuple[str, Optional[str]]` | 輪番の通し番号から担当と要求モデル。中身は G1 の先後で `assignment.assign(seq, host)` か `impl_assign(participants, seq)` を包む | - -### コマンドの終了コード - -| コマンド | 0 | 1 | 2 | 4 | -| --- | --- | --- | --- | --- | -| `next-apply-round` | 群を開いた | 残りの群が無い(群が無いラウンド・すべて `dropped` を含む) | — | — | -| `merge-apply` | 取り込んだ | — | **この群を取り消した、または担当を替えて開き直す**(意味を広げる) | 着手前テストが `green` でない・範囲を確定できない(2 から変更)・群が無い(新) | -| `merge-fix` | 取り込んだ | — | 範囲を確定できない・**結果なし(新。修正ラウンドは進む)** | 群が無い(新) | -| `merge-final-fix` | 取り込んだ | — | 範囲を確定できない・**結果なし(新。取り消して起点を戻す)** | 修正担当が未記録(変更なし) | - -骨組みは適用の取り込みの 2 で `continue` し、修正と最終ゲートの修正の取り込みの終了コードを見ない。**どちらも変えない。** - -### 起動の出力と骨組み - -起動の出力に無進捗の許容(`IMPL_STALL_TIMEOUT=`)を足す。骨組みの差分は、適用・修正・最終ゲートの修正の 3 つの監視の呼び出しに、無進捗の引数(`--stall-timeout "$IMPL_STALL_TIMEOUT"`)を 1 行ずつ足すことだけである。適用の取り込みの注記は「終了コード 2 = この群を取り消した、または担当を替えて開き直す」に改める。 - -### 雛形に足す文 - -| 雛形 | 足す文 | -| --- | --- | -| `apply.md` / `fix.md` のコミットの規約 | 4 つのトレーラーは**メッセージの最後の段落**に置く。実行環境が帰属行を足すときは、空行を挟まず同じ段落に続ける | -| `apply.md` / `fix.md` / `final-fix.md` | 作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記する(`start` / `edit` / `test` / `commit` / `done` と対象だけ) | - -## 処理の流れ - -### 群を開く - -群を開く側(`next-apply-round`)の流れである。 - -```mermaid -graph TD - S[status が pending / applied の群を順に見る] --> K{status} - K -->|無い| E1[終了コード 1] - K -->|applied| O[開き直す
起点・attempt を動かさない] - K -->|pending| R{group_reopening} - R -->|empty| D[dropped / empty] --> S - R -->|exhausted| D - R -->|open| INC[attempt を 1 進め
起点を HEAD にする] - R -->|resume| OUT[APPLY_ROUND / IMPL を出す] - INC --> OUT - O --> OUT -``` - -### 適用の取り込み - -適用の取り込み(`merge-apply`)の流れである。 - -```mermaid -graph TD - B[置き土産を捨てる / 取り消しと push の再開] --> G{取り込み済みか} - G -->|採用あり| R0[終了コード 0] - G -->|採用 0 件| DR[dropped / empty] --> R2[終了コード 2] - G -->|未取り込み| BL{着手前テストが green} - BL -->|いいえ| A4[終了コード 4] - BL -->|はい| AC{already_closed} - AC -->|はい| R2 - AC -->|いいえ| RD[read_result] - RD -->|payload あり| RG{confirm_range} - RG -->|None| A4 - RG -->|範囲| V[既存の検証と取り込み] - RD -->|結果なし| CF[_close_failed_attempt] - CF -->|range_unknown| A4 - CF --> R2 -``` - -結果なしの試行を閉じる処理(`_close_failed_attempt`)は次の順で行う。 - -1. 結果なしの一連(`close_without_result`)を呼ぶ。範囲の確定 → 取り消し → 結末の記録へ 1 件(`{phase: apply, attempt, impl, reason, detail, at, reverted}`)の順である。試行の番号が 0 なら 1 として記録し、群の試行の番号を 1 にする。範囲が確定できない(`range_unknown`)なら項目を `blocked` にして 4 で中断する -2. 開き直しの判定を読む。「開く」なら輪番の通し番号を 1 ずつ進めて輪番から担当を引く。結末の記録にある担当のどれとも違う担当が出たら、その担当と要求モデルで群の担当(`impl` / `impl_model`)を書き換える。参加者の数だけ進めても出なければ(輪番は参加者の数で 1 周する)、起動し直しの可否が真なら担当を替えずに終え、偽なら手順 3 と同じく取り消す -3. 「上限に達した」なら群の項目を `abandoned` にして見送り(`deferred_items`)へ入れる。群は取り消し済み(`dropped`、理由 `no_result`)にし、取り込みの時刻(`apply.merged_at`)を立て、局面を `phase_after_group` にする -4. 保存して 2 で終わる(push は手順 1 が取り消したときに済ませている) - -### 修正の取り込みと最終ゲートの修正の取り込み - -修正の取り込み(`merge-fix`)と最終ゲートの修正の取り込み(`merge-final-fix`)は同じ流れを通る。 - -```mermaid -graph TD - S[置き土産を捨てる / push の再開] --> AC{already_closed} - AC -->|はい| R2[終了コード 2] - AC -->|いいえ| RD[read_result] - RD -->|payload あり| EX[既存の範囲の確定・検証・取り込み] - RD -->|結果なし| CW[close_without_result] - CW -->|range_unknown| RU[既存の扱い
fix: fix_rounds を進めて 2 / final-fix: 2] - CW --> ADV{relaunch_same_agent} - ADV -->|真| P1[fix: fix_rounds を 1 進める
final-fix: 進めない(final-gate が進める)] - ADV -->|偽| PM[fix / final-fix とも
fix_rounds を max_fix_rounds にする] - P1 --> R2 - PM --> R2 -``` - -3 つの取り込みが渡す取り込みの範囲の値(`IntakeScope`)は次のとおりである。結果を読めたときの検証の失敗も取り消し(`discard_unverified`)を呼ぶ。該当は 3 つで、`_revert_unverified_apply_round` / `_revert_invalid_fix_round` / 最終ゲートの `unassigned or problems` である。 - -| 取り込み | `holder` | `base_key` | `records` | `phase` | `attempt` | `impl` | `label` | `mirror` | -| --- | --- | --- | --- | --- | --- | --- | --- | --- | -| `merge-apply` | `entry` | `apply_base_sha` | 群 | `apply` | 群の `attempt` | 群の `impl` | `R<提案ラウンド>-A<群の番号>` | 群 | -| `merge-fix` | `entry` | `fix_base_sha` | 群 | `fix` | `entry.fix_attempts` | 群の `impl` | `R<提案ラウンド>-fix` | なし | -| `merge-final-fix` | `final_gate` | `fix_base_sha` | `final_gate` | `final-fix` | `final_gate.fix_rounds` | `final_gate.impl` | `final-gate-fix` | なし | - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 適用担当の起動が群の数 × 2 回、修正担当と最終ゲートの修正担当の起動が `--max-fix-rounds` 回を超えない | `group_reopening` の上限(決定 6・7)、結果なしでも `fix_rounds` を進めること(決定 10・11)、起動し直せない結末で上限へ進めること | AC11(開く回数)、AC24・AC26(`should-abandon` が 0)、AC30 | -| 運用・保守性 | 取り消した理由が改修計画から読め、3 つの取り込みの記録が同じ形 | `defer_reason` に担当と理由を並べる。記録は `failed_attempts[]` の 1 形式(決定 5) | AC5、AC10 | - -## テスト設計 - -実行は `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q` である。状態の遷移は関数を直接呼ぶ既存の形(`no_git` / `patch_lib` / `git_facts`)で確かめる。トレーラーは一時リポジトリで実際に git を実行する。共通層の読み取りは差し替えず、一時ディレクトリに結果ファイルと監視の結果ファイルを置いて本物を通す。 - -| 受け入れ条件 | 何で確かめるか | 置き場所 | -| --- | --- | --- | -| AC1、AC2 | 一時ディレクトリに結果ファイルなし / 監視の結果ファイル(`stalled` / `usage_limit`)を置き、`read_result` の 5 つの欄。`capsys` で出力が空 | `test_intake.py` | -| AC3 | `stem_for` の 3 工程の値と、`SKILL.md` の `--stem-template` を担当名で埋めた値の一致 | `test_skill_terms.py` | -| AC4〜AC7 | `IntakeScope` を 3 つの取り込みの形で作り、`commits_in_range` が 1 件 / 0 件を返す状態で `close_without_result`。`no_git` の記録に `git revert` / `git push` が出るか、記録の件数、2 度目の呼び出しで件数が変わらないこと | `test_intake.py` | -| AC8 | `commits_in_range` が `None` を返す状態で 3 つの取り込みを呼び、`SystemExit` の値 | `test_intake.py` | -| AC9〜AC13、AC15、AC17 | 群 2 つ / 4 つの状態で結果ファイルを置かずに(AC12 は壊れた JSON と配列で)`next-apply-round` → `merge-apply`。`status` / `impl` / `attempt` / `failed_attempts` / `apply_seq` / `deferred_items` | `test_apply_attempts.py` | -| AC14 | `impl_for_seq` を常に同じ担当を返す関数に差し替え、監視の結果ファイルを `usage_limit` / `missing` で置く | `test_apply_attempts.py` | -| AC16 | 着手前テスト `red` で `SystemExit(4)`、結果ファイルを読まない(読み取りを差し替えて呼ばれないこと) | `test_apply_attempts.py` | -| AC18 | `group_reopening` を差し替え、`next-apply-round` と `merge-apply` の分岐が変わる | `test_apply_attempts.py` | -| AC19〜AC22 | 採用 0 件から `merge-proposals` → `next-apply-round`。鍵なし・rf587 の形・`items: []` の `pending` | `test_apply_rounds.py` | -| AC23〜AC27 | 群の担当と提案ラウンドの担当を分けた状態で `merge-fix`。AC26 は監視の結果ファイルを `usage_limit` で置き `should-abandon` まで | `test_abandon_items.py` | -| AC28〜AC31 | `final_gate` に `fix_base_sha` と `impl` を置き、結果ファイルなしで `merge-final-fix` → `final-gate`。`fix_commits` / `fix_base_sha` / `fix_rounds` / 終了コード | `test_final_fix.py` | -| AC32〜AC38 | 一時リポジトリで各形のコミットを作り、`commit_trailers` と `verify_apply_round` | `test_commit_trailers_git.py` | -| AC39、AC42 | 雛形の文言を `grep` で探す | `test_skill_terms.py` | -| AC40 | `_emit_init` の出力に `IMPL_STALL_TIMEOUT=1800` | `test_init.py` | -| AC41、AC43、AC44 | `SKILL.md` と `docs/02` / `docs/04` の骨組みの `monitor.py` の引数、語の表の行、段落の有無、結果なしの記述 | `test_skill_terms.py` | -| AC45 | トレーラーの節の記載をレビューで見る | 手動 | -| AC46 | 既存の `test_a_verified_apply_round_marks_every_item_applied` に `failed_attempts` が無いことを足す | `test_merge_apply.py` | -| AC47、AC48 | 全体のテスト、`bash scripts/build-runtime-plugins.sh --check`、`claude plugin validate .`、`python3 scripts/check-skill-frontmatter.py` | 手動 | -| AC49 | `impl_for_seq` を差し替え、群の割り当て・交代先・最終ゲートの修正担当の 3 つ | `test_final_fix.py` / `test_apply_attempts.py` | -| AC50 | `git grep -n revert_unverified_range -- plugins/ndf/skills/cross-refactoring/scripts` が 0 件 | 手動(レビューの手順) | - -## 未確認のまま残ること - -| # | 項目 | 内容 | 決める時点 | -| --- | --- | --- | --- | -| 1 | G3 の実装の形 | `LaunchOutcome` の欄の名前は PR #781 の設計のとおりとしている。実装で変われば `read_result` の包みが吸収し、取り込みは変わらない | G3 の実装 Pull Request のマージ | -| 2 | G1 の先後 | **決まった。** 実装の着手時点の開発版の起点に参加者の決め方の変更は入っていなかった(`git grep impl_assign` は設計文書だけに当たる)。輪番から担当を引く関数は現行の割り当て(`assignment.assign(seq, host)`)を包む形で書いた。後から入る側がその中だけを差し替える | 決定済み | -| 3 | 担当を替えた後の担当も結果を残さない割合 | 2 回目で救える群の数は測っていない。実行の要約の `apply_attempts` で数える | 配布後 | -| 4 | トレーラーの形でない署名を末尾に足すランタイム | codex / agy / kiro のコミットで帰属行の段落を見ていない。散文の段落を足す者が現れれば決定 13 は効かない | 次の実行の `failed` の理由を読む | -| 5 | 帰属行を同じ段落に続ける指示に claude が従うか | 決定 14 は補助で、従わなくても決定 13 で検証は通る | 実装後の最初の実行 | -| 6 | 担当が雛形の進捗マーカーに従うか | 従わなくても決定 15 の許容で打ち切られないのはテスト 1 回分まで | 実装後の最初の実行 | -| 8 | 適用の説明の行数 | 結果なしの節を足したことで行数の上限(500 行)に達したため、改修計画の節を報告の説明へ移した。次に節を足すときは分割が要る | 次に適用の説明を書き足すとき | -| 7 | 修正の担当が利用上限のとき、群の他の項目を救う手段 | 決定 10 は修正を見送りへ進める。担当を替えて修正を続ける形は、直しかけの文脈が要るため採らなかった。見送りが増えれば見直す | 配布後 | - -## 申し送り(並行する設計との境界) - -| 相手 | 決めた契約 | どちらが何をするか | -| --- | --- | --- | -| G3(#729) | `read_launch_outcome` / `LaunchOutcome` / `NO_RELAUNCH_REASONS`(PR #781 の「入出力の契約」)。監視の終了コードと標準出力は変えない | G3 が共通層を実装し先にマージする。G4 は `read_result` の包みで呼び、`failed_attempts[].reason` に `reason` を、交代の分岐に `relaunch_same_agent` を写す。G3 の既存の設計にあった `apply._monitor_reason` / `gitfacts.load_result` は作らない | -| G1(#727) | 輪番の母集合と `impl_assign(participants, seq)` | G1 が `assignment.py` を変える。G4 の呼び出しは `rounds.impl_for_seq` の 1 か所で、後からマージする側がその中身を合わせる。`gate._final_fix_impl` の `assignment.assign`(`gate.py:128`)は G4 が `impl_for_seq` へ寄せる | -| D-A(#662 の P1) | 実行の要約の `apply_attempts` は、鍵 `"r<ラウンド>-g<群>"` → `{"attempts", "failed", "dropped_reason"}` を状態ファイルの群から作る | 既存の設計の契約を引き継ぐ。`failed` は `phase: apply` の件数 | -| G6(#678) | `conftest.py` の監視の環境変数 | 触るファイルが重ならない | - -## 既存の設計との対応 - -| 既存の決定(PR #665) | この文書 | 変わったこと | -| --- | --- | --- | -| 決定 1(1 本の Pull Request) | 決定 2 | #674 を範囲に足した | -| 決定 2(上限 2 回・引数なし) | 決定 6 | 同じ | -| 決定 3(次の輪番へ替える) | 決定 8 | 替える先が無いときの分岐に `relaunch_same_agent` を使う | -| 決定 4(判定は `merge-apply`、骨組みは分岐しない) | 決定 7 | 判定を `rounds.group_reopening` へ移し、`next-apply-round` も同じ関数を読む | -| 決定 5(試行番号は `next-apply-round` が進める) | 決定 7 | `resume` / `open` の判定を同じ関数に含めた | -| 決定 6(取り消しの本体を切り出して共有し、最終ゲートは #674) | 決定 4・11 | `intake.py` に 3 つの取り込みの手順を置き、最終ゲートも通す | -| 決定 7(着手前テストと範囲の未確定は 4) | 「入出力の契約」 | 同じ。範囲の確定は `confirm_range` を通る | -| 決定 8(`rounds.impl_for_seq`) | 決定 9 | G1 との先後を明記 | -| 決定 9・10(採用 0 件と項目の無い群) | 決定 12 | `group_reopening` の `empty` へ寄せた | -| 決定 11・12(トレーラー) | 決定 13・14 | #553 の本文が改めた修正レイヤー(進行側の `--amend`)を採らない理由を足した | -| 決定 13(無進捗の許容) | 決定 15 | 同じ | -| 構成要素の `apply._monitor_reason` / `gitfacts.load_result` | — | G3 の `read_launch_outcome` に置き換わり、作らない | -| — | 決定 1・3・5・10 | 新設(文書の置き方、`read_result` の包み、記録の形、修正の結果なしと起動し直せない結末) | - -## 関連文書と前後関係 - -| 項目 | 内容 | -| --- | --- | -| 要求と受け入れ条件 | [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md)。この文書は「どう作るか」だけを扱う | -| 置き換える既存の設計 | [issue-647-592-553-design.md](issue-647-592-553-design.md)(PR #665)。**この文書が置き換える。** 引き継ぐ決定と変える決定は「既存の設計との対応」にある | -| 従う契約 | G3(#729、PR #781)が決めた結末の読み取りの契約。その先(3 つの取り込みが値をどう扱うか)をこの文書が決める | -| 実装の単位と順序 | 1 本の Pull Request にまとめる(決定 2)。G3 の実装 Pull Request が `develop` に入った後に始める | diff --git a/issues/issue-728-647-592-553-plan.md b/issues/issue-728-647-592-553-plan.md deleted file mode 100644 index eb4ffa65..00000000 --- a/issues/issue-728-647-592-553-plan.md +++ /dev/null @@ -1,205 +0,0 @@ -# cross-refactoring: 実装担当が結果を残さないと同じ群が上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、群が開いた回数と結末で開き直しを決める(実装計画 / #728 #647 #592 #553) - -## 関連リンク - -| 文書 | 何を持つか | -| --- | --- | -| [issue-728-647-592-553-requirements.md](issue-728-647-592-553-requirements.md) | 受け入れ条件 50 件(AC1〜AC50) | -| [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md) | 決定 15 件、入出力の契約、テスト設計 | -| [issue-647-592-553-design.md](issue-647-592-553-design.md) | 置き換えられる既存の設計 | -| 課題 | #728(親)/ #647 / #592 / #553 / #674(最終ゲートの修正。閉じるのは棚卸に任せる) | - -**用語は設計文書の「用語の対応」の表で引く。** この計画は業務用語で書き、識別子はコードブロックと表にだけ置く。 - -## モード - -`standard`。公開しているコマンドの終了コードの意味と、状態ファイルの構造が変わる。複数のモジュールにまたがる。 - -## 目的と非目的 - -達成したい状態: - -- 担当の作業結果が残っていなくても、3 つの取り込み(適用・修正・最終ゲートの修正)が同じ手順で受け、未検証のコミットを取り消し、結末を記録して終わる -- 同じ改善項目の集まりを開き直す回数が 2 回で止まり、2 回目は別の担当が試す -- 採用が 0 件だった提案ラウンドと、項目が 1 件も無い集まりでは担当を起動しない -- 実行環境が帰属行を足したコミットでも、必須の記名(トレーラー)が読める - -やらないこと: - -- 監視そのもの(`plugins/ndf/scripts/lib/monitor.py`)の変更。結末の読み取りの共通層は別の課題(#729)が入れ終えている -- 参加者の決め方の変更(#727)。輪番から担当を引く 1 つの関数に寄せるところまでを行う -- 記録の集計(実行の要約の新しい欄)。契約だけを合わせ、集計は別の課題が行う - -## 前提 - -- 前提 1: 結末の読み取りの共通層(`plugins/ndf/scripts/lib/monitor_outcome.py`)は開発版の起点に入っている。`read_launch_outcome(tmp_dir, stem, result_path=None)` が 5 つの欄を持つ値を返し、同じ担当を起動し直せない理由は利用上限(`usage_limit`)の 1 語だけである -- 前提 2: 参加者の決め方の変更(#727)は開発版の起点に入っていない。輪番から担当を引く関数の中身は、現行の割り当て(`assignment.assign(seq, host)`)を包む形で書く。後から入る側がその中だけを差し替える -- 前提 3: 同じ提案ラウンドの別の変更(#727 の後続)が、担当の割り当ての 2 行(`commands/apply.py:134` と `commands/gate.py:128`)を触る。後からマージする側が輪番から担当を引く関数の中身を揃える - -## 実測 - -この計画のために実行して確かめた値である。 - -| 見たもの | 値 | -| --- | --- | -| 変更前のテスト | `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q` が 704 件成功・終了コード 0(31.8 秒) | -| 記名の解析に題名が要るか | **要る。** 記名だけの段落を単独で `git interpret-trailers --parse` へ渡すと出力が空になる。題名の行と空行を前に付けると 4 つとも返る(git 2.53.0) | -| 散文と記名が混ざる段落 | 出力は空。題名を付けても同じ | -| 題名の位置に記名の形の行を置いた場合 | その行が記名として返る。**先頭の段落を解析に掛けないことで避ける** | -| 解析にリポジトリが要るか | 要らない。リポジトリの外の現在地でも終了コード 0 で動く | - -## 受け入れ条件 - -要求の文書の AC1〜AC50 をそのまま用いる。この計画では各タスクが満たす番号だけを示す。検証手段は要求の文書の「検証手段」と設計文書の「テスト設計」が持つ。 - -## 代替案と採否 - -| 案 | 内容 | 採否 | 理由 | -| --- | --- | --- | --- | -| 結末の読み取りを包みとして残す | 名前を残し、引数を状態と工程に変える | 採用 | 結果ファイルの名前の幹を組む場所が 1 つになる(設計の決定 3) | -| 取り込みが共通層を直に呼ぶ | 包みを置かない | 不採用 | 名前の幹の組み立てが 3 か所に分かれる | -| 記名を進行側が付け直す | 取り込みでコミットを書き換える | 不採用 | コミットの識別子が変わり、申告と実体の対応が切れる(設計の決定 13) | - -## ドメイン用語 - -設計文書の「用語の対応」の表を正本とする。この計画で追加する語は無い。 - -## 不変条件 - -- 検証を受けていないコミットを、公開したまま次の工程へ渡さない -- 取り消した後の作業ツリーの先端が、次の範囲の起点になる -- 同じ工程・同じ試行番号の結末は、記録に 1 件しか入らない - -## 互換性 - -| 対象 | 変更 | 互換性の扱い | -| --- | --- | --- | -| 適用の取り込みの終了コード | 着手前テストが成功と確認できていない場合が 2 から 4 へ。2 の意味に「担当を替えて開き直す」が加わる | 骨組みの分岐は変えない(2 で次の群へ進む、4 で止まる、のどちらも既存の扱い) | -| 修正・最終ゲートの修正の取り込みの終了コード | 結果なしで 2 を返す場合が増える | 骨組みは両者の終了コードを見ない | -| 結末の読み取りの引数 | ファイルのパスと担当 → 状態・担当・工程・提案ラウンド | 内部の関数。古い形の呼び出しは実行時に失敗する | -| 状態ファイル | 集まりの側に試行番号・結末の記録・取り消しの理由、最終ゲートの記録に結末の記録を足す | 版を上げない。鍵が無い状態ファイルは試行 0・失敗なしとして読む | -| 取り消しの本体 | 事実の読み取りの層にあったものを取り込みの共通手順へ移す | 内部の関数。呼び出し元 2 か所を同じ変更で書き換える | - -## 修正対象 - -```text -plugins/ndf/skills/cross-refactoring/ -├── SKILL.md -├── docs/02-apply-and-review.md -├── docs/04-fix-and-report.md -├── prompts/{apply,fix,final-fix}.md -├── scripts/refactor_lib/ -│ ├── intake.py # 新設 -│ ├── gitfacts.py -│ ├── rounds.py -│ ├── vocabulary.py -│ └── commands/{apply,converge,gate,setup}.py -└── tests/ - ├── test_intake.py # 新設 - ├── test_apply_attempts.py # 新設 - ├── test_commit_trailers_git.py # 新設 - └── test_{git_facts,merge_apply,apply_rounds,abandon_items,final_fix,skill_terms,init}.py -``` - -配布物(`plugins/ndf/dev.kiro/` と `plugins/ndf/dev.agy/`)は `bash scripts/build-runtime-plugins.sh` で揃える。 - -## タスク分解 - -### Task 1: 起動の結末を値で受け取る - -- **対象ファイル:** `scripts/refactor_lib/gitfacts.py`、`tests/test_intake.py`(新設)、`tests/test_git_facts.py`、`tests/test_skill_terms.py` -- **変更内容:** 結末の読み取り(`read_result`)を、状態・担当・工程・提案ラウンドから結果ファイルの名前の幹を組み、共通層の読み取りを呼んで値を返す包みにする。中断も画面への出力も行わない。名前の幹を組む関数(`paths.stem_for`)の値と、手順書が監視へ渡す名前の雛形(`--stem-template`)を担当名で埋めた値の一致を確かめる -- **満たす受け入れ条件:** AC1、AC2、AC3 -- **進め方:** 一時ディレクトリに結果ファイルを置かない状態・監視の結果ファイルだけを置いた状態でそれぞれ失敗するテストを書き、包みを差し替えて通す。既存の 3 件(中断を現状固定していたもの)は、値を返す形へ書き直す - -### Task 2: 取り込みの共通手順を新設し、取り消しを 1 つにする - -- **対象ファイル:** `scripts/refactor_lib/intake.py`(新設)、`gitfacts.py`、`commands/{apply,converge,gate}.py`、`tests/test_intake.py` -- **変更内容:** 範囲の確定・未検証のコミットの取り消し・叩き直しの判定・結果なしの一連を新しいモジュールへ置く。取り込み 1 つ分の「どこを見て、どこへ書くか」は範囲の値(`IntakeScope`)で渡す。事実の読み取りの層にあった取り消し(`gitfacts.revert_unverified_range`)を削除し、修正・最終ゲート・適用の 3 か所を新しい取り消しへ寄せる。試し打ちの対応(`--dry-run`)は取り消しの引数で受ける -- **満たす受け入れ条件:** AC4、AC5、AC6、AC7、AC8、AC50 -- **進め方:** 3 つの取り込みの形の範囲の値を作り、範囲が 1 件・0 件・確定できない場合で失敗するテストを書いてから実装する。取り消しと公開の順序は、既存のテストが見ている記録(外部コマンドの呼び出し列)で確かめる - -### Task 3: 同じ集まりの試行を 2 回で止め、2 回目は別の担当が試す - -- **対象ファイル:** `scripts/refactor_lib/rounds.py`、`vocabulary.py`、`commands/apply.py`、`tests/test_apply_attempts.py`(新設)、`tests/test_merge_apply.py` -- **変更内容:** 開き直しの判定(`rounds.group_reopening`)と、輪番から担当を引く関数(`rounds.impl_for_seq`)を新設する。集まりを開く側と適用の取り込みの両方が同じ判定を読む。結果なしのときは共通手順で取り消しと記録を行い、次の輪番の担当のうち、その集まりで失敗した担当のどれとも違う最初の担当へ替える。替える先が無いときだけ、起動し直しの可否で続けるか取り消すかを決める。着手前テストの確認を結果の読み取りより前へ移し、終了コードを 4 にする -- **満たす受け入れ条件:** AC9、AC10、AC11、AC12、AC13、AC14、AC15、AC16、AC17、AC18、AC49(3 つのうち 2 つ) -- **進め方:** 集まりが 2 つ・4 つの状態で、結果ファイルを置かずに開く → 取り込む、を繰り返す失敗するテストを先に書く。担当の交代は、輪番から担当を引く関数を差し替えて確かめる - -### Task 4: 採用が 0 件の提案ラウンドと、項目の無い集まりで担当を起動しない - -- **対象ファイル:** `scripts/refactor_lib/rounds.py`、`commands/apply.py`、`tests/test_apply_rounds.py` -- **変更内容:** 集まりの一覧を返す関数で、鍵が無いときだけ古い版として 1 つ作り、空の配列はそのまま返す。集まりが 1 つも無い状態で進行中の集まりを引くと中断する。開き直しの判定が「項目が無い」を返した集まりは、開かずに取り消し済みにして次を探す。取り込み済みで採用が 0 件だった集まりも取り消し済みに直す -- **満たす受け入れ条件:** AC19、AC20、AC21、AC22 -- **進め方:** 採用 0 件からの取り込み → 開く、古い形の状態ファイル、項目の無い集まりの 3 通りで失敗するテストを書いてから実装する - -### Task 5: 修正の結果が無いときに修正ラウンドを進める - -- **対象ファイル:** `scripts/refactor_lib/commands/converge.py`、`tests/test_abandon_items.py` -- **変更内容:** 修正の結果を、提案ラウンドの担当ではなく集まりの担当から読む。結果が無ければ共通手順で取り消しと記録を行い、修正ラウンドの数を 1 進めて終了コード 2 で終える。起動し直せない結末では、修正ラウンドの数を上限の値にして見送りの判定へ渡す -- **満たす受け入れ条件:** AC23、AC24、AC25、AC26、AC27 -- **進め方:** 集まりの担当と提案ラウンドの担当を分けた状態で失敗するテストを書く。上限まで続けた後に見送りの判定が 0 を返すところまで通す - -### Task 6: 最終ゲートの修正の結果が無いときにコミットを取り消す - -- **対象ファイル:** `scripts/refactor_lib/commands/gate.py`、`tests/test_final_fix.py` -- **変更内容:** 最終ゲートの修正の取り込みを共通手順へ通す。結果が無ければ取り消して起点を取り消し後の先端へ進め、終了コード 2 で最終ゲートへ判定を戻す。起動し直せない結末では最終ゲートの修正ラウンドの数を上限の値にする。最終ゲートの修正担当の決定を、輪番から担当を引く関数へ寄せる -- **満たす受け入れ条件:** AC28、AC29、AC30、AC31、AC49(残り 1 つ) -- **進め方:** 最終ゲートの記録に起点と担当を置き、結果ファイルを置かずに取り込む → 判定する、の順で失敗するテストを書いてから実装する - -### Task 7: 帰属行の後ろでも記名を読む - -- **対象ファイル:** `scripts/refactor_lib/gitfacts.py`、`prompts/{apply,fix}.md`、`tests/test_commit_trailers_git.py`(新設)、`tests/test_merge_apply.py` -- **変更内容:** コミットのメッセージを空行で段落に分け、末尾の段落から前へ 1 段落ずつ git の解析へ掛ける。解析が記名の段落と判定しなかったところで止める。**先頭の段落(題名)は掛けない。** 解析には題名の行を補って渡す(実測のとおり、補わないと何も返らない)。同じ鍵は末尾に近い段落の値を採る。雛形のコミットの規約に、必須の記名を最後の段落へ置くことと、実行環境が帰属行を足すときは空行を挟まず同じ段落に続けることを書く -- **満たす受け入れ条件:** AC32、AC33、AC34、AC35、AC36、AC37、AC38、AC39 -- **進め方:** 一時リポジトリに各形のコミットを作り、実際に git を実行して確かめる失敗するテストを先に書く - -### Task 8: テストの実行中の無出力で担当を打ち切らない - -- **対象ファイル:** `scripts/refactor_lib/vocabulary.py`、`commands/setup.py`、`SKILL.md`、`prompts/{apply,fix,final-fix}.md`、`tests/test_init.py`、`tests/test_skill_terms.py` -- **変更内容:** 起動の出力に無進捗の許容を足す。値はテストの制限時間に 900 秒を加えたものとする。手順書の骨組みで、適用・修正・最終ゲートの修正の 3 つの監視の呼び出しにこの値を渡す。3 つの雛形に、作業段階が進むたびに進捗の記録へ 1 行足す指示を書く -- **満たす受け入れ条件:** AC40、AC41、AC42 -- **進め方:** 起動の出力と手順書の骨組みを読む失敗するテストを先に書く - -### Task 9: 手順書と説明文書を実装に合わせる - -- **対象ファイル:** `SKILL.md`、`docs/02-apply-and-review.md`、`docs/04-fix-and-report.md`、`tests/test_skill_terms.py` -- **変更内容:** 使う語の表の適用ラウンドの行に、同じ集まりの試行の上限(2 回)を書く。上限を 1 つに保つとしていた段落を外す。適用の工程と修正・最終ゲートの工程の説明に、結果が無いときの取り消し・記録・終了コードを書く。記名の節に、git の標準の読み方が最後の段落しか読まないことと、進行側の読み方の 2 つを書く -- **満たす受け入れ条件:** AC43、AC44、AC45 -- **進め方:** 文言を読むテストを先に書く。記名の節の記載(AC45)は Pull Request のレビューで見る - -### Task 10: 退行の確認と配布物の同期 - -- **対象ファイル:** `tests/test_merge_apply.py`、配布物一式 -- **変更内容:** 結果があり検証を通る適用が、変更前と同じく 1 回目の試行で取り込まれ、結末の記録を持たないことを既存のテストへ足す。配布物を同期し、定義と frontmatter の検査を通す -- **満たす受け入れ条件:** AC46、AC47、AC48 -- **進め方:** 全体のテスト → 同期 → 3 つの検査の順で実行し、結果を Pull Request 本文へ載せる - -## 影響範囲 - -| 影響を受けるもの | 何が変わるか | -| --- | --- | -| 収束リファクタリングの骨組み | 監視の呼び出しに引数が 1 つ増える。分岐は変えない | -| 進行中の実行の状態ファイル | 新しい鍵は無くても読める。項目の無い集まりを持つ既存の状態は、開こうとした時点で取り消し済みになる | -| 実行の要約と改修計画 | 見送りの理由に、どの担当がどの理由で結果を残さなかったかが並ぶ | -| 収束レビューの共通層 | 読むだけで変えない | - -## リスクと対処 - -| リスク | 対処 | -| --- | --- | -| 適用の取り込み(893 行)が 1 つの関数で結果の読み取り・検証・取り消し・記録を抱えており、分岐を足すと読めなくなる | **先に構造を整える。** Task 2 で取り消しを共通手順へ出し、Task 3 の分岐はそこへ委ねる。新しい分岐を既存の関数へ直接足さない | -| 適用の検証のテスト(1735 行)が終了コードと状態を細かく固定しており、変更が広範囲の失敗として現れる | タスクごとにテストを通す。失敗したテストは、期待値が変わったものと退行とを 1 件ずつ切り分けて記録する | -| 記名の解析が git の版で変わる | 実測した版(2.53.0)を計画へ残し、一時リポジトリで実際に git を実行するテストで固定する | -| 担当の割り当ての 2 行を別の変更が触る | 輪番から担当を引く関数の中だけに割り当ての呼び出しを置く。後からマージする側がその中身を揃える | - -## 切り戻し手順 - -Pull Request 単位で戻せる。状態ファイルの版を上げないため、途中まで進んだ実行の状態は変更前のコードでもそのまま読める。足した鍵は読まれずに残るだけである。 - -## 完了の定義 - -- [ ] 受け入れ条件 AC1〜AC50 について、条件ごとに検証手段と結果が対応している -- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] `bash scripts/build-runtime-plugins.sh --check` / `claude plugin validate .` / `python3 scripts/check-skill-frontmatter.py` が終了コード 0 で終わる -- [ ] 手順書と説明文書が、実装した振る舞いと同じことを書いている diff --git a/issues/issue-728-647-592-553-requirements.md b/issues/issue-728-647-592-553-requirements.md deleted file mode 100644 index b2fac640..00000000 --- a/issues/issue-728-647-592-553-requirements.md +++ /dev/null @@ -1,295 +0,0 @@ -# cross-refactoring: 実装担当が結果を残さないと同じ群が上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、群が開いた回数と結末で開き直しを決める(要求と受け入れ条件 / #728 #647 #592 #553) - -## 目的 - -- **壊れていること**: cross-refactoring の実装担当が結果ファイルを残さずに終わることがある。このとき取り込みは、下位の読み取りがプロセスを終わらせるため止まる。同じ群が上限なしに開き直される。担当が作ったコミットは検証を受けずに残る。テスト整備の採用 0 件では項目の無い群を起動し続ける。claude が担当の群は帰属行のためトレーラーが読めずに落ちる -- **困る人**: cross-refactoring を回す進行側(手で止めるまで CLI の起動と利用料が続く)と、その Pull Request を読む人(未検証の差分が混じる) -- **直すと成り立つこと**: 3 つの取り込み(適用・修正・最終ゲートの修正)が結果なしを同じ手順で受ける。未検証のコミットを取り消し、結末を記録し、終了コードを返す。適用ラウンドの繰り返しは有限回で終わる。群は開いた回数と前回の結末で、開き直すか・担当を替えるか・取り消すかが決まる。採用 0 件では群を作らない。起動し直しても解けない結末(利用上限)では、同じ担当を同じ工程で起動し直さない。帰属行の後ろでもトレーラーが読める - -この文書は「何を満たすか」だけを扱う。設計、置き換える既存の要求、範囲に入れる子 issue は末尾の「関連文書と前後関係」にある。 - -## 用語 - -本文は左の用語で書く。右の識別子は、引用・表・受け入れ条件の判定値で使う。 - -| 用語 | 意味 | -| --- | --- | -| 取り込み | 担当の CLI が作ったコミットを、進行側が検証して受け入れるコマンド。適用の取り込み `merge-apply` / 修正の取り込み `merge-fix` / 最終ゲートの修正の取り込み `merge-final-fix` の 3 つ | -| 群を開く | `next-apply-round`。次に適用する群を選んで担当を出す | -| 最終ゲート | `final-gate`。全体のテストを実行して判定する | -| 結末の読み取り | `gitfacts.read_result`。担当の結果ファイルを読む | -| 共通層の読み取り | `lib/monitor_outcome.py` の `read_launch_outcome`。G3 が作る | -| 群 | 適用ラウンド。書き換えるファイルが重ならない項目の集まりで、状態の `rounds[].apply_rounds[]` の 1 件 | -| 試行 | 1 つの群に対して適用担当を起動し、適用の取り込みで取り込もうとした 1 回。番号は `attempt` | -| 結末 | 担当 1 回の起動の終わり方。G3 の `LaunchOutcome`(使える結果か、結果なしの理由か) | -| 結果なし | `LaunchOutcome.payload` が `None`。理由は `reason`(`missing` / `unparsable` / `stalled` など) | -| 起動し直しの可否 | `LaunchOutcome.relaunch_same_agent`。偽は同じ担当を同じ条件で起動しても解けない(`usage_limit`) | -| 結末の記録 | `failed_attempts[]`。結果を残さなかった起動の記録で、群と最終ゲートの記録(`final_gate`)が持つ | -| 取り消しの理由 | 群の `drop_reason`(`no_result` / `empty`) | -| 開き直しの判定 | `rounds.group_reopening` | -| 輪番から担当を引く関数 | `rounds.impl_for_seq` | -| 取り込みの共通手順 | 新設の `refactor_lib/intake.py`。取り消しの本体は `intake.discard_unverified` | -| 範囲 | 取り込みが検査するコミットの列。起点(`apply_base_sha` / `fix_base_sha`)から HEAD まで | -| 未検証のコミット | 範囲にあるが、結果なしで検証を受けられなかったコミット | -| 修正ラウンドの数と上限 | `fix_rounds` と `--max-fix-rounds` | -| 無進捗の許容 | `init` が出す `IMPL_STALL_TIMEOUT`。テストの制限時間(`--test-timeout`)+ 900 秒 | -| 帰属行 | Claude Code がコミットメッセージへ足す `Co-Authored-By:` / `Claude-Session:` の行 | -| トレーラーの段落 | `git interpret-trailers --parse` がトレーラーとして読む段落 | -| トレーラーの読み取り | `gitfacts.commit_trailers` | -| G1 / G3 | 実行計画の束の名前。G1 = 参加者の決め方(#727、PR #782)、G3 = 結末の読み取りの共通層(#729、PR #781) | - -## 依頼(原文) - -### #728(根本原因の親) - -> `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py` の `read_result` は、結果ファイルが無い・読めないと `die(code=2)` で進行の終了コードを決める(1089 / 1093 / 1096 行目) -> -> それを呼ぶ取り込みが 3 つある(`commands/apply.py` の `merge-apply`、`commands/converge.py` の `merge-fix`、`commands/gate.py` の `merge-final-fix`)。下位の読み取りがプロセスを終わらせるため、群の状態を書く機会と、未検証のコミットを取り消す機会が無い -> -> 群を開き直す `apply.py` の `next-apply-round` は、中断からの再開と失敗した試行のやり直しを同じ `pending` / `applied` で区別しない。`rounds.apply_groups` は項目 0 件の群も作る -> -> ## 採る手 -> -> - 向きの修正(`fix_dependency_direction`): 下位の `read_result` は結果なしを値として返し、進行の扱いは取り込みが決める -> - 統合(`consolidate_duplication`): 3 つの取り込みの「範囲の確定 → 未検証コミットの取り消し → 群の状態の記録」を 1 つの手順にする -> -> ## 完了条件 -> -> - `read_result` は結果なしを値で返し、3 つの取り込みが同じ手順で取り消しと群の状態の記録を行う -> - 群が開いた回数と前回の結末を持ち、開き直しの判定が 1 か所にある。空の群は作らない -> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる - -### #647 - -> `/ndf:cross-refactoring` で、同じ適用ラウンド(書き換えるファイルが重ならない改善項目の群)の適用が**上限なしに再試行される**。後ろの群へ順番が回らず、収束の判定と最終ゲートへ届かない。 -> -> **PR #757:** kiro が適用フェーズで 15 秒で終わり `kiro-apply-r1-result.json` を残さない → `merge-apply` が 2 を返し、駆動側の `continue` が `next-apply-round` へ戻る → 同じ群を再び開く。**29 回繰り返した時点で手で止めた。** - -### #592 - -> **テスト整備ラウンドの採用が 0 件のとき、項目の無い適用ラウンドが開き、上限なしに同じ群を繰り返す。** - -### #553 - -> `cross-refactoring` の適用ラウンドで、**claude が実装担当のときだけ**必須トレーラー(`Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model`)が読めず、群が丸ごと取り消される。 -> -> **担当に書かせて、git の最終段落の定義で読み返す構造**が、ランタイムが後ろへ段落を足すたびに壊れる。読み取りを段落単位にする直しは現れている場所の直しで、次に別の書式の署名を足すランタイムが現れれば同じ形が起きうる。進行側が知っている値を進行側が取り込みで書けば、担当のランタイムの帰属行の書式に左右されない。 - -### #674 - -> `merge-final-fix` は `read_result` が結果ファイルの欠落で `die(code=2)` し、範囲の検査(`unassigned_fix_commits` / `verify_final_fix_commit`)と取り消しへ進まない。`final-gate` は担当が作ったコミットを含む HEAD でテストし、落ちれば `fix_base_sha` を HEAD へ置き直す。そのコミットは以後どの範囲にも入らない。**検証を受けていないコミットが Pull Request に残りうる。** - -## 前提 - -| # | 前提 | -| --- | --- | -| 1 | G3(#729、PR #781)の契約に従う。`lib/monitor_outcome.py` に `read_launch_outcome(tmp_dir, stem, result_path=None) -> LaunchOutcome`(`payload` / `reason` / `detail` / `monitor` / `relaunch_same_agent`)と `NO_RELAUNCH_REASONS = {"usage_limit"}` が入り、理由の語彙は 9 語(`ok` / `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `missing` / `pidfile_bad` / `unparsable`)である。**G3 の実装 Pull Request が `develop` に入った後に、この変更の実装を始める** | -| 2 | 監視の終了コード 0〜6 と標準出力は変わらない(G3 の申し送り)。骨組みは終了コードで分岐しない | -| 3 | P1(監視の結果ファイル `-monitor.json`)と P2(`--phase` と `lib/limits.py`)は v10.13.0 で `develop` に入っている | -| 4 | 輪番の母集合は G1(#727、PR #782)が変える(既定を codex / kiro / ホストにし、`impl_assign(participants, seq)` を新設)。この変更が担当を引く呼び出しは `rounds.impl_for_seq` の 1 つで、G1 と先後どちらでも変える場所はその中だけである | -| 5 | Claude Code が足す帰属行は、メッセージの末尾に独立した段落として付く(#553 の実測 `26a0fff`)。他のランタイムが足す署名もトレーラーの形(`Key: value` の行だけの段落)である | -| 6 | 除外されない参加者が 2 者以上いる。1 者しかいない実行では、担当の交代先が無いため、結末の可否だけで 2 回目を開くか取り消すかを決める(設計文書の決定 8) | - -## 対象範囲 - -含む: - -| 変えるもの | 内容 | -| --- | --- | -| 結末の読み取りの契約 | 結果なしを値で返す。プロセスを終わらせない(`die` しない) | -| 取り込みの共通手順の新設 | 3 つの取り込みの「範囲の確定 → 未検証コミットの取り消し → 結末の記録」を共通化する(`refactor_lib/intake.py`) | -| 取り消しの本体の一本化 | `gitfacts.revert_unverified_range` と `apply._revert_unverified_apply_round` の 2 つを 1 つにする | -| 群の開き直しの判定の一本化 | 開き直しの判定(`rounds.group_reopening`)と、群が持つ試行の記録(`attempt` / `failed_attempts` / `drop_reason`) | -| 試行の上限と担当の交代 | 同じ群の試行の上限(2 回)と、2 回目の担当の交代 | -| 採用 0 件の扱い | 採用 0 件の提案ラウンドで群を作らない。項目の無い群を開かない(#592) | -| 修正の取り込みが読む担当 | 修正の取り込みが読む結果の担当と、結果が無いときの修正ラウンドの数え方 | -| 最終ゲートの修正の結果なし | 最終ゲートの修正の取り込みが、結果なしで未検証のコミットを取り消す(#674) | -| 起動し直せない結末 | 起動し直しの可否が偽のときの 3 つの取り込みの振る舞い | -| トレーラーの読み方 | トレーラーの読み取りの読み方と、適用・修正の雛形のコミットの規約(#553) | -| 無進捗の許容 | 適用・修正・最終ゲートの修正の監視に渡す無進捗の許容と、雛形の進捗マーカー(#647 の無進捗の対策。既存の設計から引き継ぐ) | -| 手順書 | `SKILL.md` の語の表・「別の上限を置かない」の段落・骨組みの監視の引数、`docs/02-apply-and-review.md` / `docs/04-fix-and-report.md` の対応箇所 | - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| `lib/monitor_outcome.py` / `lib/monitor.py` / `lib/launch-cli.sh` の変更 | G3(#729)が所有する。この変更は `read_launch_outcome` を呼ぶ側 | -| `lib/assignment.py` と参加者の決め方 | G1(#727)が所有する。この変更は `rounds.impl_for_seq` の中で呼ぶだけ | -| 利用上限で進行全体を止めること | 結末の可否を見て担当を替えるか、修正の上限へ進める(設計文書の決定 8・10) | -| 骨組みの bash を `scripts/` へ出すこと | #560 | -| 進行側が取り込みで `git commit --amend` によりトレーラーを足し直すこと | 設計文書の決定 13。SHA が変わり申告との対応が切れる。群に複数のコミットがあると先頭の書き換えが以降をすべて書き換える | -| `deferred_items` の 2 通りの形(`rounds.deferred_record` の固定鍵と、`apply.py:178` の提案の複製)を揃えること | この変更が足す見送りは `deferred_record` の形を使う。読む側(`plan.py`)は鍵が無くても落ちない | -| `cross-review` 側の取り込み | G3 が `state.py` の `read-result` を変える | -| `CHANGELOG.md` と版数 | 配布の工程が書く | -| クラス図 | 設計文書の「構造」に触る型(`LaunchOutcome` / `IntakeScope` / `ClosedAttempt`)だけを載せる | - -## 受け入れ条件(結末の読み取り) - -- [x] AC1: 結果ファイルが無い状態で結末の読み取り(`gitfacts.read_result`)を呼ぶと、例外(`SystemExit`)を出さず、標準出力・標準エラーに書かない。結果なしの値(`payload` が `None`、`reason` が `missing`)を返す -- [x] AC2: 監視の結果ファイル(`-apply-r-monitor.json`)に無進捗の理由(`reason: stalled`)があるとき、結末の読み取りの理由は `stalled`、起動し直しの可否は真である。利用上限の理由(`reason: usage_limit`)のとき、可否は偽である -- [x] AC3: 結末の読み取りに渡す結果ファイルの名前の幹(stem)は、監視の名前の雛形(`--stem-template`: `{agent}-apply-r$ROUND` / `{agent}-fix-r$ROUND` / `{agent}-final-fix`)を担当名で埋めた値と一致する。幹を組む関数(`paths.stem_for`)の 3 つの工程の値を、骨組みの雛形から作った値と突き合わせる - -## 受け入れ条件(共通の手順) - -- [x] AC4: 前提: 結果なしで、起点から HEAD までにコミットが 1 件以上ある - 操作: 3 つの取り込みのいずれかを呼ぶ - 結果: そのコミットは取り消され、起点(`apply_base_sha` と群の `base_sha` / `fix_base_sha` / `final_gate.fix_base_sha`)は取り消し後の HEAD になる -- [x] AC5: 結果なしのとき、3 つの取り込みのいずれでも、記録の辞書(群 / `final_gate`)の結末の記録(`failed_attempts`)に 1 件(`{phase, attempt, impl, reason, detail, at, reverted}`)が足される。理由(`reason`)は結末の読み取りの値、取り消した数(`reverted`)は取り消したコミットの数である -- [x] AC6: 結果なしで範囲にコミットが無いとき、`git revert` も `git push` も実行されない -- [x] AC7: 結果なしの取り込みを、同じ試行番号でもう一度呼ぶと、結果ファイルを読まずに前回と同じ終了コード 2 を返す。結末の記録の件数は増えない。その間に結果ファイルが現れても読まない -- [x] AC8: 3 つの取り込みで範囲を確定できないとき(起点が無い、または git が範囲を返さない)の終了コードは次のとおりである。適用の取り込みは 4、修正の取り込みは修正ラウンドを 1 進めて 2、最終ゲートの修正の取り込みは 2 - -## 受け入れ条件(#647: 適用ラウンド) - -- [x] AC9: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに群を開く → 適用の取り込みを呼ぶ。終了コードは 2。1 つ目の群は未着手(`status: pending`)のまま担当が agy 以外に替わる。試行の番号(`attempt`)は 1、結末の記録は 1 件(`phase: apply`、`attempt: 1`、`impl: agy`)である -- [x] AC10: AC9 の後、替わった担当の結果ファイルも置かずにもう一度、群を開く → 適用の取り込みを呼ぶ。1 つ目の群は取り消し済み(`status: dropped`・`drop_reason: no_result`)、項目は `abandoned` になる。見送り(`deferred_items`)に `実装担当が結果を残しませんでした(agy: missing → codex: missing)` の形の理由で入る -- [x] AC11: 結果ファイルを 1 つも置かずに、群を開く操作が 1 を返すまで繰り返す。群を開く操作の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、両方の群が取り消し済み(`dropped`)になる -- [x] AC12: 結果ファイルが JSON として読めない場合と JSON の配列の場合も AC9 と同じ状態になり、結末の記録の理由(`failed_attempts[].reason`)は `unparsable` である -- [x] AC13: 群が 4 つ(輪番の通し番号 `apply_seq` が 4)あり、先頭の群(担当 codex)が結果を残さない。替えた後の担当は codex 以外である。輪番の通し番号は進めた分だけ進み、他の群の担当は変わらない -- [x] AC14: 監視の結果ファイルの理由が `usage_limit` で、輪番から担当を引く関数(`rounds.impl_for_seq`)の差し替えにより交代先が無い状態では、1 回目の失敗で群が取り消し済み(`dropped`、`drop_reason: no_result`)になる。理由が `missing` で交代先が無い状態では、同じ担当で 2 回目を開く -- [x] AC15: 群を開く操作を、適用の取り込みを挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。群の試行の番号は 1 のまま進まない -- [x] AC16: 着手前のテストの状態が `green` でない状態で適用の取り込みを呼ぶと、結果ファイルを読まずに終了コード 4 で終わる -- [x] AC17: 適用の取り込みが終了コード 2 で終わった後の群は、取り消し済み(`dropped`)か、結末の記録を持つ未着手(`pending`)のどちらかである。確かめる経路は 4 つ(結果なし / 未割当のコミット / 適用の検証の失敗 / 取り込み済みで採用 0 件) -- [x] AC18: 開き直しの判定(`rounds.group_reopening`)を差し替えると、群を開く側の開き方(開く・再開・開かない)と、適用の取り込みの結果なしの後の扱い(担当の交代・取り消し)の両方が、差し替えた関数の返す値に従う - -## 受け入れ条件(#592: 採用 0 件と項目の無い群) - -- [x] AC19: テスト整備ラウンドで提案が 0 件の状態で提案の取り込み(`merge-proposals`)を呼んだ後、群を開く操作を呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの群の配列(`apply_rounds`)は空のままである -- [x] AC20: 群の配列の鍵(`apply_rounds`)を持たない状態ファイル(群を導入する前の版)では、群を開く操作が従来どおりラウンド全体を 1 つの群として開く -- [x] AC21: 前提: rf587 で残った形の群(`status: applied`・`items: []`・`apply.merged_at` あり・`applied: []`) - 操作: 適用の取り込みを呼ぶ - 結果: 終了コード 2 で終わり、群が取り消し済み(`dropped`、`drop_reason: empty`)になる。続く群を開く操作は 1 を返す -- [x] AC22: 未着手で項目が無い群(`status: pending`・`items: []`)を持つ状態で、群を開く操作を呼ぶ。その群は開かれずに取り消し済み(`dropped`、`drop_reason: empty`)になる。次の群があればそれを開き、無ければ終了コード 1 を返す - -## 受け入れ条件(修正ラウンド) - -- [x] AC23: 群の担当が agy、提案ラウンドの担当が codex の状態で、agy の結果ファイル(`agy-fix-r1-result.json`)を置いて修正の取り込みを呼ぶ。agy の結果が取り込まれ、修正ラウンドの数(`fix_rounds`)が 1 になる -- [x] AC24: 修正の結果ファイルが無い状態で修正の取り込みを呼ぶと、終了コード 2 で終わり、修正ラウンドの数が 1 進む。群の結末の記録に `phase: fix` の 1 件が足される。上限(`--max-fix-rounds`)の回数だけ続けた後の見送りの判定(`should-abandon`)は終了コード 0 を返す -- [x] AC25: AC24 の直後に検証(`verify-round`)を挟まず修正の取り込みをもう一度呼んでも、修正ラウンドの数は進まない(AC7 の修正ラウンドの形) -- [x] AC26: 修正の結果なしで監視の理由が `usage_limit` のとき、修正の取り込みは修正ラウンドの数を上限の値にする。続く見送りの判定は終了コード 0 を返す -- [x] AC27: 修正の結果なしで起点から HEAD にコミットがあるとき、取り消され、起点(`fix_base_sha`)が取り消し後の HEAD になる(AC4 の修正ラウンドの形) - -## 受け入れ条件(#674: 最終ゲートの修正) - -- [x] AC28: 前提: 最終ゲートの修正の結果ファイルが無く、最終ゲートの起点(`final_gate.fix_base_sha`)から HEAD にコミットが 1 件ある - 操作: 最終ゲートの修正の取り込みを呼ぶ - 結果: 終了コード 2。そのコミットは取り消され、最終ゲートの起点は取り消し後の HEAD になる。最終ゲートの結末の記録(`final_gate.failed_attempts`)は 1 件(`phase: final-fix`) -- [x] AC29: AC28 の後に最終ゲートを呼ぶと、テストは取り消し後の HEAD で実行され、修正のコミットの一覧(`fix_commits`)に取り消したコミットは入らない -- [x] AC30: 最終ゲートの修正の結果なしで監視の理由が `usage_limit` のとき、最終ゲートの修正ラウンドの数(`final_gate.fix_rounds`)は上限の値になる。続く最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる -- [x] AC31: 結果ファイルがあり検証を通る最終ゲートの修正は、変更前と同じく取り込まれ、最終ゲートの結末の記録を持たない - -## 受け入れ条件(#553: 帰属行の後ろのトレーラー) - -一時リポジトリで実際にコミットを作って確かめる: - -- [x] AC32: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、トレーラーの読み取りが 4 つとも値を返す -- [x] AC33: AC32 の段落の後に `Co-Authored-By:` と `Claude-Session:` の 2 行の段落が付いても、4 つとも返す -- [x] AC34: 必須トレーラーの段落と末尾の段落の間に散文の段落があるコミットで、散文より前にある `Round: …` の形の行を読まない -- [x] AC35: 末尾の段落に散文とトレーラーの形の行が混ざる(git がトレーラーの段落と判定しない)コミットで、その行を読まない -- [x] AC36: 同じ鍵が 2 つの段落にあるとき、末尾に近い段落の値を返す -- [x] AC37: AC32 の形のコミットを申告した適用ラウンドが、トレーラーの欠落で取り消されない -- [x] AC38: 本文がトレーラーの段落 1 つだけで、題名が `Round: 本文の題名` の形のコミットで、題名を読まない -- [x] AC39: 適用と修正の雛形(`prompts/apply.md` / `prompts/fix.md`)のコミットの規約が、必須トレーラーをメッセージの最後の段落に置くことを書く - -## 受け入れ条件(無進捗の打ち切り) - -- [x] AC40: 起動(`init`)の出力に無進捗の許容(`IMPL_STALL_TIMEOUT`)が入り、値がテストの制限時間(`--test-timeout`)の値 + 900 である(既定で 1800) -- [x] AC41: `SKILL.md` の骨組みで、適用・修正・最終ゲートの修正(`--phase apply` / `fix` / `final-fix`)の 3 つの監視(`monitor.py`)の呼び出しが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない -- [x] AC42: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が、作業段階ごとに進捗の記録(`$RF_STEM-progress.log`)へ 1 行追記する指示を持つ - -## 受け入れ条件(文書) - -- [x] AC43: `SKILL.md` の「この Skill で使う語」の適用ラウンドの行が、同じ群の試行の上限(2 回)を書く。`grep -n "別の上限を置かない\|別に置かない" SKILL.md` が何も出力しない -- [x] AC44: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6・Step 7 が 2 つを書く。結果なしのときの取り込みの振る舞い(取り消し・記録・終了コード)と、`SKILL.md` と同じ監視の引数である -- [x] AC45: `docs/02-apply-and-review.md` のトレーラーの節が、git の標準の読み方(`git log --format='%(trailers:…)'`)が最後の段落しか読まないことと、進行側の読み方の 2 つを書く - -## 受け入れ条件(退行しない) - -- [x] AC46: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、結末の記録を持たない -- [x] AC47: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [x] AC48: 配布物の同期・定義・frontmatter の 3 つの検査が終了コード 0 で終わる(コマンドは「検証手段」の表) - -## 受け入れ条件(他の設計との契約) - -- [x] AC49: 輪番から担当を引く関数(`rounds.impl_for_seq`)を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの修正担当の 3 つが、差し替えた関数の返す担当になる -- [x] AC50: 取り消しの本体は取り込みの共通手順の 1 つ(`intake.discard_unverified`)になる。`gitfacts.revert_unverified_range` は無くなり、`apply._revert_unverified_apply_round` は `discard_unverified` を呼ぶ - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 中断と再開を挟まない実行で、1 つの提案ラウンドで適用担当を起動する回数が群の数 × 2 回を超えない。修正担当を起動する回数も群の数 × `--max-fix-rounds` 回を超えない。最終ゲートの修正担当の起動は `--max-fix-rounds` 回を超えない | -| 運用・保守性 | 群を取り消した理由(担当と結末の理由)が改修計画の「見送った項目」の表から読める。3 つの取り込みの結果なしの記録が同じ形(`failed_attempts[]`)で、実行の要約が同じ読み方で数えられる | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| `gitfacts.read_result` | 引数と戻り値が変わる(結果なしを値で返す)。呼び出し元 3 か所とテスト(`test_git_facts.py` の 3 件)を書き直す | -| `gitfacts.revert_unverified_range` | 無くなる。呼び出し元 2 か所(`converge.py:383` / `gate.py:203`)は `intake.discard_unverified` へ | -| `merge-apply` の終了コード | 着手前テストの未確認と範囲の未確定が 2 から 4(中断)へ変わる。`SKILL.md` の終了コードの表は既に 4 と書いている | -| `merge-fix` / `merge-final-fix` の終了コード | 結果なしで 2(新)。骨組みは終了コードを見ないため変わらない | -| 状態ファイル | 群に `attempt` / `failed_attempts` / `drop_reason`、`final_gate` に `failed_attempts` が増える。既存の状態ファイルは鍵が無いまま読める(試行 0 回・失敗なし) | -| 群の担当 | 結果を残さなかった群だけ、2 回目の試行で次の輪番の担当へ替わる | -| 無進捗の打ち切り | 適用・修正・最終ゲートの修正で、どの担当も既定の許容より長くなる(既定 1800 秒) | -| トレーラーの読み取り | 最後の段落に加え、その直前に続くトレーラーの段落も読む | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q`(cross-refactoring だけなら `plugins/ndf/skills/cross-refactoring/tests`) | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認 | 次に cross-refactoring を回した実行で、進捗の記録(`-apply-r*-progress.log`)に作業段階が残るか。担当が結果を残さなかった群の結末の記録の理由が、監視の結果ファイルの理由と一致するか | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 状態の判定は `refactor_lib/` に置き、骨組みの bash は判定を持たない(`SKILL.md` の「実行」)。`commands/` どうしの取り込みを作らず、複数のコマンドが読む処理は `refactor_lib/` の直下(`rounds.py` / `intake.py`)に置く | -| コーディング規約 | 外部コマンド(`git interpret-trailers` / `git revert`)の挙動は書く前に実行して確かめる(`AGENTS.md` の DO) | -| テスト戦略 | 状態の遷移は既存の形(`tests/conftest.py` の `no_git` / `patch_lib` と `test_merge_apply.py` の `git_facts`)で関数を直接呼ぶ。トレーラーの読み取りは一時リポジトリで実際に git を実行する。共通層 `read_launch_outcome` はテストで差し替えず、一時ディレクトリに監視の結果ファイルと結果ファイルを置いて本物を通す | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査 | -| 確認してから行う | 同じ群の試行の上限を引数にすること(この変更では固定の 2 回) | -| 行わない | `monitor.py` / `monitor_outcome.py` / `assignment.py` の変更、骨組みを `scripts/` へ出すこと、進行側によるコミットの書き換え(`--amend`) | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| G1(#727)の `impl_assign` / `participants` と、この変更の `rounds.impl_for_seq` のどちらが先に `develop` へ入るか | 進行側(実装の持ち場の着手時点) | 実装の計画 | - -## 既存の受け入れ条件との対応 - -| 既存(PR #665) | この文書 | 変わったこと | -| --- | --- | --- | -| AC1〜AC3、AC5、AC7 | AC9〜AC11、AC13、AC15 | 記録の名前を `failed_attempts[]`(`phase` 付き)に揃えた | -| AC4 | AC12 | 理由 `unparsable` を明記 | -| AC6 | AC7 | 3 つの取り込みに広げた | -| AC8 | AC4、AC6 | 3 つの取り込みに広げた。コミットが無いときは push しない | -| AC9、AC10 | AC16、AC8 | 同じ | -| AC11 | AC17 | 同じ | -| AC12 | AC2、AC5 | `_monitor_reason` を `read_launch_outcome` の値に置き換えた | -| AC13〜AC16 | AC19〜AC22 | `drop_reason: empty` を明記 | -| AC17〜AC19 | AC23〜AC25 | 記録を `failed_attempts[]` に揃えた | -| AC20〜AC27 | AC32〜AC39 | 同じ | -| AC28〜AC30 | AC40〜AC42 | 同じ | -| AC31〜AC33 | AC43〜AC45 | AC44 に Step 7 と結果なしの記述を足した | -| AC34〜AC36 | AC46〜AC48 | 同じ | -| AC37 | AC49 | 同じ | -| — | AC1、AC3、AC14、AC18、AC26〜AC31、AC50 | 新設(結末の読み取り、起動し直しの可否、最終ゲート、開き直しの判定の一本化、取り消しの本体の一本化) | - -## 関連文書と前後関係 - -| 項目 | 内容 | -| --- | --- | -| 設計 | [issue-728-647-592-553-design.md](issue-728-647-592-553-design.md)。この文書は「何を満たすか」だけを扱う | -| 置き換える既存の要求 | [issue-647-592-553-requirements.md](issue-647-592-553-requirements.md)(PR #665)。**この文書が置き換える。** 対応は「既存の受け入れ条件との対応」にある | -| 親の名前で置く理由 | 親 #728 が根本原因の場所(結果の読み取りの向きと、3 つの取り込みの重複)を定め直したため、受け入れ条件を親の名前で改めて置く | -| 範囲に入れる子 issue | #674(最終ゲートの修正)は #728 の子として範囲に入れる。閉じるのは棚卸に任せる | diff --git a/issues/issue-729-619-584-design.md b/issues/issue-729-619-584-design.md deleted file mode 100644 index 137efdb8..00000000 --- a/issues/issue-729-619-584-design.md +++ /dev/null @@ -1,579 +0,0 @@ -# cross-review: 利用上限で止まった担当が「結果ファイル無し」と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(設計 / #729 #619 #584) - -## 目的 - -**起きていること。** 担当の CLI が利用上限で落ちると、監視はその文言を読めない。結果なしの理由は「結果ファイル無し」に畳まれる。進行側は同じ担当を起動し直し、監視の上限 1 回分を待ってから全体を誤りで終える(#619)。監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 - -**根本原因。** 結果なしの判断を cross-review と cross-refactoring がそれぞれ結果ファイルの有無だけで行い、監視が書いた理由を誰も読まない(#729)。 - -**この設計で成り立つこと。** 結末の語彙と起動し直しの可否を結末の共通層の 1 か所に置き、両 Skill がその値を読む。利用上限は理由「利用上限」として報告され、同じラウンドでは起動し直さない。止めた担当の子プロセスは結果を書かない。 - -## 用語の対応表 - -本文は左の業務用語で書く。識別子はこの表と、コードブロック・表・契約の節にだけ置く。 - -| 業務用語 | 識別子 | -| --- | --- | -| 結末の共通層 | `plugins/ndf/scripts/lib/monitor_outcome.py`(読み込み名 `monitor_outcome`) | -| 監視 | `plugins/ndf/scripts/lib/monitor.py` | -| 起動の手順 | `plugins/ndf/scripts/lib/launch-cli.sh` | -| 結末を読む関数 | `monitor_outcome.read_launch_outcome(tmp_dir, stem, result_path)` | -| 起動 1 回の結末(値) | `LaunchOutcome`。欄は使える結果 `payload`・理由 `reason`・起動し直しの可否 `relaunch_same_agent`・監視の詳細 `detail`・監視の結果 `monitor` | -| 可否を返す関数 | `monitor_outcome.relaunch_same_agent(reason)` | -| 起動し直せない理由の集合 | `monitor_outcome.NO_RELAUNCH_REASONS` | -| 理由の語彙 | `monitor_outcome.REASONS` | -| 状態からの既定の理由 | `monitor_outcome.reason_for(status)` | -| 監視の結末(型) | `MonitorOutcome`。新しい欄は `reason` | -| 監視の状態 | 正常 `OK` / 監視の上限 `TIMEOUT` / 無進捗 `STALLED` / 結果なし `NO_RESULT`(終了コード 3)/ 早期の致命 `EARLY_ERROR`(終了コード 4)/ pid ファイル不正 `PIDFILE_BAD` | -| 理由(監視が書く) | 正常 `ok` / 監視の上限 `timeout` / 無進捗 `stalled` / 致命の文言 `early_error` / 利用上限 `usage_limit` / CLI の上限 `cli_timeout` / 結果ファイル無し `missing` / pid ファイル不正 `pidfile_bad` | -| 理由(読む側が書く) | 読めない結果 `unparsable`。cross-review 固有は、判定の値が無い `no_verdict` / 未投稿 `not_posted` | -| 結果ファイル | `-result.json` | -| 監視の結果ファイル | `-monitor.json` | -| 監視の記録 | `monitor-outcomes.jsonl` | -| 結果の取り込み(cross-review) | `state.py read-result`。関数は `_read_review_result_file`、結果なしの記録は `_record_no_result` | -| 判定(cross-review) | `state.py judge`。結果なしのラウンドの扱いは `_handle_no_result_round` | -| 報告の表(cross-review) | `state.py report` | -| 結果なしの理由の鍵 | 状態ファイルの `rounds[-1].<担当>.no_result_reason` | -| 監視の詳細の鍵 | 状態ファイルの `rounds[-1].<担当>.monitor_detail` | -| 判定が出す理由の行 | 標準出力の `NO_RESULT_REASONS='<担当>=<理由> ...'` | -| 起動し直しの指示 | 判定の終了コード 7 と `RELAUNCH_AGENTS` | -| 誤りの終わり | 状態ファイルの `final=error`。判定の終了コード 1 | -| cross-refactoring の結果の読み取り | `refactor_lib/gitfacts.py` の `read_result`。進行を止める終了は `die` | -| 文言の照合(err.log) | 既存の `_scan_patterns`。表は `USAGE_LIMIT_FATAL` / `EARLY_ERROR_FATAL` / `CLI_TIMEOUT_AFTER_EXIT` | -| 文言の照合(claude の stdout.log) | 既存の `_scan_claude_stdout_fatal`。表は `CLAUDE_STDOUT_USAGE_LIMIT` | -| 結末を書く処理 | `monitor._record_outcome`。結末を作る場所は `_early_error_outcome` / `_process_exit_outcome` | -| プロセスの停止 | `monitor._kill_pid`。グループへは `os.killpg` | -| ジョブ制御 | bash の `set -m`。背景で起動した CLI の pid がプロセスグループの番号になる | -| 検知する文言 | 利用上限は kiro: `Monthly request limit reached`、claude: `"api_error_status":429`。CLI の上限は agy: `print timeout after <時間> with turn in progress` | -| 責務の移動 | issue の分類 `move_responsibility` | -| 実行の要約 | `run_metrics.py` が作る。起動の一覧は `launches[]` | - -## 機能一覧 - -5 つの機能のうち F1〜F3 は共通層、F4 は cross-review、F5 は起動と監視が担う。 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 利用上限の文言を検知し、理由 `usage_limit` として残す | 監視。進行側と利用者が理由を読む | -| F2 | CLI 自身の上限で結果を書かずに終わった担当を、理由 `cli_timeout` として残す | 同上 | -| F3 | 結果ファイルの有無・読めるかと監視の結末を 1 つの値として読み、起動し直しの可否を添える | cross-review の取り込み(`read-result`)と cross-refactoring の取り込み(G4) | -| F4 | 起動し直しても解けない結末の担当を、同じラウンドで起動し直さずに止めて理由を報告する | cross-review の判定(`judge`) | -| F5 | 監視が止めた担当の子プロセスに、止めた後に結果を書かせない | 収束ループ | - -## 決定の記録 - -12 件。決定 1 は文書の置き方、2〜3 は共通層の契約、4〜9 は語彙と検知、10 はプロセスグループ、11〜12 は両 Skill の読み方である。 - -### 決定 1: 変わった決定だけを差分に載せるため、設計文書は親 #729 の名前で新設し、既存の設計文書の本体は書き換えない - -既存の設計は 6 課題・3 本の Pull Request を 1 つの文書で扱い、P1 と P2 は配布済みである。P3 の節をその場で書き換えると、配布済みの決定と新しい決定が 1 つの差分に混ざる。承認する人が「何が変わるのか」を読み分けられない。親 #729 は根本原因の場所(共通層)を定め直し、既存の決定 14(判定が理由「利用上限」を見る)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** - -既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの「決定の記録」の見出しをすべて写す。1 行でも触ると、既存の 20 件の決定がこの Pull Request の決定として並ぶ。案内は「決定の記録」を持たない要求の文書と契約の文書にだけ足す。 - -### 決定 2: 同じ判断を両 Skill に書かないため、結果なしの判断と起動し直しの可否を結末の共通層の 1 つの関数へ移す - -いまは cross-review の結果の取り込みと cross-refactoring の結果の読み取りが、それぞれ結果なしを決めている。判断の材料は結果ファイルの有無だけである。監視が書いた理由はどちらも読まない。理由を読む処理を各 Skill に書くと、同じ表(監視の理由 → 結果なしの理由)が 2 か所にできる。語彙を足すたびに片方が古くなる(親 #729 の責務の移動)。**結末の共通層に結末を読む関数を 1 つ置き、両 Skill はその値(使える結果・理由・起動し直しの可否)を受け取るだけにする。** 語彙・対応表・可否の表は結末の共通層だけが持つ。 - -各 Skill が監視の結果ファイルを読む既存の関数(`read_outcome`)を呼び、自分で表を引く形は採らない。既存の G4 の設計の `apply._monitor_reason` がこの形である。表が Skill の数だけ増える。 - -### 決定 3: 進行側の問いに合わせ、起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にし、利用上限だけを偽にする - -進行側が結末を見て決めることは「同じ担当をもう 1 度起動してよいか」に尽きる。利用上限は起動し直しても解けない。起動のたびに待ちと相手の CLI の枠を使う(#619 の 2 回目の空振り、#647 の 3729 回)。それ以外の理由(監視の上限・無進捗・致命の文言・CLI の上限・結果なし・読めない)は、対象や負荷で変わりうる。1 度は起動し直してよい。 - -**偽のときに何をするかは Skill が決める。** cross-review は止めて理由を報告する(担当を外して回す判断は #478)。cross-refactoring は次の輪番の担当へ替える(G4 の設計の決定 3)。共通層が持つのは可否だけで、進行の分岐は持たない。 - -理由ごとに「止める / 替える / 起動し直す」の 3 値を返す形は採らない。「替える」は担当の集合を知る Skill にしか決められない。共通層に置くと担当の割り当てを読み込むことになる。 - -### 決定 4: 終了コードで分岐する骨組みを変えないため、利用上限は「早期の致命」の状態のまま、理由だけを「利用上限」にする - -監視の終了コードは骨組みと G4 の設計が分岐に使う(既存の設計の決定 16)。利用上限は「プロセスが続いても結果を生成できないと分かった」致命の一種である。状態としては早期の致命(`EARLY_ERROR`、終了コード 4)と同じである。区別が要るのは理由の側だけである。監視の結果ファイルと記録に理由「利用上限」(`usage_limit`)が入れば、読む側は状態を変えずに区別できる。 - -新しい状態(`USAGE_LIMIT`、終了コード 7)を足す形は採らない。終了コードの意味が変わる。終了コードで分岐する骨組みと文書(cross-refactoring だけで監視の呼び出しが 8 か所)を見直すことになる。 - -### 決定 5: 「終わったが結果が無い」の状態を保つため、CLI の上限は「結果なし」の状態のまま、理由だけを「CLI の上限」にする - -agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」(`NO_RESULT`、終了コード 3)で正しい。理由だけを「CLI の上限」(`cli_timeout`)にする。文言は終了の後にだけ見る。生きている間に見ると、途中で出た警告を致命と読む。結果ファイルがあれば理由は「正常」にする(上限に当たっても結果を書き終えていれば使える)。 - -### 決定 6: 実物の文言に一致し引用を誤検知しないため、利用上限の文言は err.log を全担当で見、stdout.log は claude だけ JSON 向けの照合で見る - -kiro の利用上限の文言は err.log に出る(#619 の実物)。claude の文言はどちらに出るか未確認のため、両方を見る(前提 1)。err.log は既存の文言の照合で見る。この照合は表・引用・バッククォート・grep 形式を除外する。実測では、claude の文言を含む JSON の 1 行も err.log の側で一致し、引用の判定に飲み込まれなかった(「実測」)。stdout.log は既存の claude 向けの照合と同じ JSON 向けの形(除外を掛けない)で見る。JSON は 1 行に引用符を多く含む。行単位の引用の判定が「引用の内側」を真と判定するためである。 - -### 決定 7: 両 Skill が同じ形で見る理由だけを共通にするため、「読めない結果」を共通の語彙に入れ、「判定の値が無い」「未投稿」は cross-review 固有に残す - -結果ファイルが JSON として読めない・オブジェクトでないことは、両 Skill の読み取りが同じ形で見ている。共通の関数が結果ファイルを読む以上、この理由(`unparsable`)も共通の語彙に要る。「判定の値が無い」(`no_verdict`)と「未投稿」(`not_posted`)は結果ファイルの中身とレビューの投稿の話である。監視も cross-refactoring も知りえない。**cross-review が共通の関数の後で自分の理由を上書きする形にする。** - -理由の語彙は 9 語になる。状態からの既定の理由が返すのは、監視の状態から決まる 6 語のままである。「利用上限」と「CLI の上限」は監視が結末に理由を添えたときだけ現れる。「読めない結果」は読む側だけが使う。 - -### 決定 8: 状態からは決まらない理由を残すため、監視の結末に理由の欄を持たせ、無ければ状態からの既定を使う - -いま結末を書く処理は、状態からの既定の理由で理由を決めている。利用上限と CLI の上限は状態からは決まらない。結末を作る場所が理由を添える。監視の結末の型に理由の欄(`reason: Optional[str] = None`)を足す。結末を書く処理は、結末に理由があればそれを、無ければ状態からの既定を書く(`outcome.reason or reason_for(status)`)。結末を作る既存の呼び出し 8 か所(`create(status, detail)`)は変えない。 - -### 決定 9: 起動し直しで上書きされる 1 回目の理由を失わないため、その理由は追記だけの監視の記録が持つ - -状態ファイルの担当ごとの結果は、そのラウンドの最後の結果を持つ構造である。起動し直すと、1 回目の結果なしの理由は 2 回目で上書きされる。これは既存の構造のままにする。**過去を失わないのは追記だけの監視の記録の側で**(既存の設計の決定 2)、1 回目の理由も記録の理由と終了時刻(`reason` と `ended_at`)から読める。状態ファイルに履歴の配列を足す形は採らない。判定が読むのは最後の結果だけである。履歴は実行の要約の起動の一覧が既に持つ。 - -### 決定 10: 止めた後に子プロセスが結果を書かないよう、CLI を独立したプロセスグループで起動し、グループの先頭のときだけグループへシグナルを送る - -既存の設計の決定 17 をそのまま引き継ぐ。起動の手順がジョブ制御を有効にしてから背景で起動すると、CLI の pid がそのままプロセスグループの番号になる。監視は pid がグループの先頭であり、かつ監視自身のグループと違うときだけグループへ送る。それ以外は従来どおり pid だけへ送る。`setsid` は macOS に標準で入っていないため採らない。結果の取り込みの前に結果ファイルの出現を待つ形(#584 の候補 2)も採らない。書き出しそのものを止めれば待つ理由が無い。 - -### 決定 11: cross-refactoring も同じ値を読むよう、結果の読み取りは結果なしで進行を止めず、共通の関数の値で返す契約に置き換える。実装は G4 - -#728 は、cross-refactoring の結果の読み取りが進行を止める終了(`die(code=2)`)で終了コードを決める向きを直す。その向きを直した先が、結末を読む関数の値である。G3 が決めるのは次の範囲までである。結果の読み取りに相当する処理は結末を読む関数を呼び、使える結果・理由・起動し直しの可否を返す。終了コードを決めず、標準エラーに書かない。3 つの取り込みがその値をどう扱うか(群の状態・担当の交代・終了コード)は G4 が決める。薄い包みとして残すか呼び出し側が直接呼ぶかも G4 に任せる(要求の「未決」)。 - -### 決定 12: cross-review の骨組みの行を変えないため、結果の取り込みの終了コードと判定の 0 / 2 / 7 / 8 は変えず、利用上限は既存の 1 の枝で終える - -骨組み(`SKILL.md`)は結果の取り込みの終了コードを `|| true` で受け、判定の 7 / 8 / 0 / 2 で分岐し、それ以外を `exit` する。利用上限で起動し直さずに止める結末は、既存の「2 度目も結果なし → 誤りの終わり → 1」と同じ出口へ載せる。骨組みの行は 1 つも変わらない(AC24)。 - -新しい終了コードで「利用上限で止めた」を骨組みへ伝える形は採らない。骨組みの分岐が増える。cross-review の `SKILL.md` と `docs/` の 2 か所へ同じ値を書くことになる。理由は判定が出す理由の行と報告の表で読める。 - -## 実測 - -2026-09-19 に `develop`(9eaebe14、bash 5.3.9、Python 3.14.4)で、提案する文言を既存の文言の照合に通した。 - -```text -kiro 実物 usage=HIT cli_timeout=- 現行fatal=- -claude 429 JSON 1 行 usage=HIT cli_timeout=- 現行fatal=- -claude 429 空白あり usage=HIT cli_timeout=- 現行fatal=- -HTTP 429 行 usage=HIT cli_timeout=- 現行fatal=HIT -HTTP 401 行 usage=- cli_timeout=- 現行fatal=HIT -表の中 usage=- cli_timeout=- 現行fatal=- -バッククォート usage=- cli_timeout=- 現行fatal=- -引用行 usage=- cli_timeout=- 現行fatal=- -agy print timeout usage=- cli_timeout=HIT 現行fatal=- -grep 形式 usage=- cli_timeout=- 現行fatal=- -stdout JSON 向け照合: True -``` - -- 利用上限の 4 つの文言は、実物の 3 形式(kiro の 1 行・claude の JSON 1 行・HTTP 429 行)に一致する。表・バッククォート・引用・grep 形式では一致しない -- HTTP の 401 の行は利用上限に入らず、現行の致命の文言(`early_error`)に残る(AC4) -- claude の JSON の 1 行は、err.log 向けの引用の判定を通しても一致した(決定 6 の前提) -- 現行の致命の照合(`_scan_early_fatal`)は kiro の実物と claude の JSON に一致しない(#619 の再現) - -プロセスグループの実測は既存の契約の文書の「実測」(2026-09-15)にあり、変更していない。ジョブ制御を有効にして起動した CLI をグループへのシグナルで止めると、3 秒後に子が書く結果ファイルは書かれなかった。 - -## 構成要素 - -変えるのは共通層の 3 ファイルと cross-review の 4 ファイルで、cross-refactoring は契約だけを受け取る。 - -| 要素 | 新設 / 変更 | 責務 | -| --- | --- | --- | -| 結末の語彙と読み取り(`lib/monitor_outcome.py`) | 変更 | 理由 9 語、起動し直しの可否の表、結末を 1 つの値として読む `read_launch_outcome`(決定 2・3・7) | -| 監視(`lib/monitor.py`) | 変更 | 利用上限と CLI の上限の文言の検知、理由を持つ結末(決定 4・5・6・8)、グループへの停止(決定 10) | -| 起動(`lib/launch-cli.sh`) | 変更 | `set -m` で CLI を独立したプロセスグループにする(決定 10) | -| cross-review の状態(`cross-review/scripts/state.py`) | 変更 | `read-result` が共通の値を読んで理由と `monitor_detail` を記録する。`judge` が理由を出し、可否が偽なら止める。`report` が理由を表に出す(決定 12) | -| cross-review の文書(`docs/01-state-and-review.md` / `docs/03-review-output.md` / `docs/04-contracts.md`) | 変更 | 理由の表、上限の見分け方、状態ファイルの鍵 | -| 共通層の一覧(`lib/README.md`) | 変更 | `monitor_outcome.py` の行に読み取りの責務を足す | -| cross-refactoring の取り込み(`refactor_lib/gitfacts.py` ほか) | **契約のみ** | `read_result` に相当する読み取りが `read_launch_outcome` を呼ぶ(決定 11)。実装は G4 | -| テスト(`scripts/tests/` / `cross-review/tests/`) | 変更 | 「テスト設計」 | - -```mermaid -graph TD - subgraph 起動と監視 - L[起動 launch-cli.sh
独立したプロセスグループ] - M[監視 monitor.py
文言の検知・理由を持つ結末・グループへの停止] - end - subgraph 共通層の結末 - MO[結末の語彙と読み取り monitor_outcome.py
理由 9 語・可否の表・read_launch_outcome] - end - subgraph 一時ディレクトリ - F1[結果ファイル stem-result.json] - F2[監視の結果ファイル stem-monitor.json] - F3[監視の記録 monitor-outcomes.jsonl] - S[状態ファイル] - end - subgraph cross-review - RR[read-result] - J[judge] - RP[report] - end - subgraph CR["cross-refactoring(G4 が実装)"] - GF[取り込み merge-apply / merge-fix / merge-final-fix] - end - L -->|pgid = pid| M - M -->|reason| MO - MO --> F2 - MO --> F3 - RR -->|payload / reason / 可否| MO - GF -.->|payload / reason / 可否| MO - MO -->|読む| F1 - MO -->|読む| F2 - RR -->|no_result_reason / monitor_detail| S - J -->|理由を読む・可否で止める| S - RP -->|理由を表に出す| S -``` - -**図に含めない要素**は次の 2 つである。 - -| 要素 | 図との関係 | -| --- | --- | -| cross-review の文書と共通層の一覧 | 手順と表の記述で、呼び出しの辺を持たない | -| テスト | 実行時の依存ではない | - -## 文脈と配置 - -利用上限を返すのは各 CLI の API の提供元で、こちらは変えられない。**この変更が変えるのは、その返答が err.log / stdout.log に現れたときの読み方だけである。** - -```mermaid -graph LR - H[進行側のホスト CLI] --> SK[cross-review / cross-refactoring の骨組み] - SK --> CLI[担当の CLI: codex / agy / kiro / claude] - CLI --> API[各 CLI の API の提供元
利用上限はここが返す] - SK --> GH[GitHub] - SK --> TMP[作業ツリーの中の一時ディレクトリ] -``` - -| 実行の単位 | どこで動くか | 境界 | -| --- | --- | --- | -| 担当の CLI | 背景のプロセス。**独立したプロセスグループ**(pgid = pid) | 一時ディレクトリへ結果ファイルとログを書く | -| 監視 | 進行側のシェルから起動する Python(cross-review は `bg-wait.sh` の背景) | 一時ディレクトリを読み、監視の結果ファイルと記録を書き、CLI のグループへシグナルを送る | -| 状態の操作 | 進行側が 1 コマンドずつ呼ぶ Python | 一時ディレクトリの結果ファイル・監視の結果ファイル・状態ファイルを読み書きする | - -配置で変わるのは担当の CLI のプロセスグループだけである。 - -## 置き場所 - -変えるファイルと、それぞれで変わるものを示す。 - -```text -plugins/ndf/scripts/lib/ - monitor_outcome.py 変更(語彙 9 語・可否の表・read_launch_outcome・LaunchOutcome) - monitor.py 変更(USAGE_LIMIT_FATAL / CLI_TIMEOUT_AFTER_EXIT / MonitorOutcome.reason / _kill_pid のグループ) - launch-cli.sh 変更(set -m) - README.md 変更(monitor_outcome.py の行) -plugins/ndf/scripts/tests/ - test_monitor_outcome_unit.py 変更(語彙と read_launch_outcome の単体) -plugins/ndf/skills/cross-review/ - scripts/state.py 変更(_read_review_result_file / _record_no_result / _handle_no_result_round / report) - docs/01-state-and-review.md 変更(理由の表) - docs/03-review-output.md 変更(上限の見分け方) - docs/04-contracts.md 変更(monitor_detail) - tests/ 変更(文言・グループ・judge・report) -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py 契約のみ(G4 が変える) -# dev.kiro / dev.agy の配布物は bash scripts/build-runtime-plugins.sh で同期する -``` - -## 構造 - -変更が触る型だけを載せる。 - -```mermaid -classDiagram - class MonitorOutcome { - status: str - exit_code: int - icon: str - detail: str - +reason: Optional~str~ - create(status, detail, reason=None) MonitorOutcome - } - class LaunchOutcome { - payload: Optional~dict~ - reason: Optional~str~ - detail: str - monitor: Optional~dict~ - relaunch_same_agent: bool - } - class monitor_outcome { - REASONS: tuple - NO_RELAUNCH_REASONS: frozenset - reason_for(status) str - relaunch_same_agent(reason) bool - read_launch_outcome(tmp_dir, stem, result_path) LaunchOutcome - } - class AgentStatus - class state_py - class gitfacts_read_result - AgentStatus --> MonitorOutcome : outcome - monitor_outcome ..> LaunchOutcome : 作る - state_py ..> monitor_outcome : read_launch_outcome を呼ぶ - gitfacts_read_result ..> monitor_outcome : 契約(G4 が実装) -``` - -`+` の付いた欄が増える。担当の状態の型(`AgentStatus`)と cross-review の状態の操作の既存の欄・関数は変えない。 - -| 触る型 | 責務 | -| --- | --- | -| `MonitorOutcome.reason` | 状態からは決まらない理由(`usage_limit` / `cli_timeout`)を結末に添える。`None` なら `reason_for(status)` | -| `LaunchOutcome` | 起動 1 回の結末。`payload` があれば使える結果、無ければ `reason` が理由。`relaunch_same_agent` は `reason` から導く(`payload` があれば `True`) | -| `monitor_outcome.NO_RELAUNCH_REASONS` | 起動し直しても解けない理由の集合。値は `{"usage_limit"}` | - -## 理由の語彙 - -共通層の理由の語彙は 9 語である。監視が書く語と読む側だけが書く語を、起動し直しの可否と合わせて 1 つの表で持つ。 - -| 理由 | 監視の状態 | 誰が書くか | 起動し直しの可否 | 何が起きたか | -| --- | --- | --- | --- | --- | -| `ok` | `OK` | 監視 | — | 結果ファイルがあって終わった | -| `timeout` | `TIMEOUT` | 監視 | 可 | 監視の上限 | -| `stalled` | `STALLED` | 監視 | 可 | 無進捗の許容 | -| `early_error` | `EARLY_ERROR` | 監視 | 可 | 利用上限以外の致命の文言 | -| `usage_limit` | `EARLY_ERROR` | 監視(決定 8) | **否** | 利用上限の文言 | -| `cli_timeout` | `NO_RESULT` | 監視(決定 8) | 可 | 結果なしで終わり、err.log に CLI の上限の文言 | -| `missing` | `NO_RESULT` | 監視・読む側 | 可 | 結果なしで終わり、理由の文言が無い | -| `pidfile_bad` | `PIDFILE_BAD` | 監視 | 可 | pid ファイルが無い・別のプロセス | -| `unparsable` | — | 読む側だけ | 可 | 結果ファイルがあるが JSON オブジェクトとして読めない | - -## データ構造 - -永続データは JSON のファイルで、データベースは無い。ER 図は作らず、表で持つ。 - -### 監視の結果ファイルと監視の記録(P1 から。理由の値だけが増える) - -| 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `reason` | 文字列 | 許さない | 監視が書く理由。`ok` / `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `missing` / `pidfile_bad` の 8 語(`unparsable` は監視が書かない) | - -他の 14 個のキーは変えない(既存の契約の文書の「監視の結果ファイル」)。 - -### 状態ファイルの担当ごとの最後の結果(cross-review) - -| 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `intent` | 文字列 | 許さない | `NO_RESULT` のとき下の 2 列が意味を持つ(既存) | -| `no_result_reason` | 文字列 | 許さない(`NO_RESULT` のとき) | `read_launch_outcome` の `reason`(`ok` を除く 8 語)、または cross-review が上書きする `no_verdict` / `not_posted` | -| `monitor_detail` | 文字列 | 許す | 監視の `detail`(最大 200 文字の err.log の抜粋)。**鍵が無い** = 監視の結果ファイルが無かった。空文字は書かない | - -結果なしの理由の値の集合は 10 語になる。既存の 3 語(結果ファイル無し・読めない結果・判定の値が無い)と未投稿の意味は変えない。 - -### 機能とデータの対応 - -| 機能 | 監視の結果ファイル | 監視の記録 | 結果ファイル | 状態ファイル | -| --- | --- | --- | --- | --- | -| F1 / F2 検知して理由を残す | C | C | — | — | -| F3 結末を 1 つの値として読む | R | — | R | — | -| F4 止めて理由を報告する(cross-review) | — | — | — | R / U | -| F5 止めた後に書かせない | — | — | (書かせない) | — | - -時系列の扱いは決定 9 のとおり。状態ファイルは上書き(最後の結果だけ)、監視の記録は追記だけで過去を持つ。移行は無い(鍵の追加と値の追加だけで、既存の状態ファイルはそのまま読める)。 - -## 入出力の契約 - -共通層の 2 つの関数と、監視・起動の約束を表で持つ。この節の識別子は「用語の対応表」の右の列である。 - -### 結末を読む関数(新設。両 Skill の取り込みが呼ぶ) - -| 項目 | 内容 | -| --- | --- | -| 名前 | `read_launch_outcome(tmp_dir, stem, result_path=None) -> LaunchOutcome` | -| 入力 | `tmp_dir`: 一時ディレクトリ。`stem`: 監視と同じ stem(`-review-pr` / `-apply-r` など)。`result_path`: 結果ファイルのパス。省くと `/-result.json` | -| 出力(使える結果) | `payload` に JSON オブジェクト、`reason` は `None`、`relaunch_same_agent` は `True`、`monitor` に監視の結果ファイルの辞書(無ければ `None`)、`detail` に監視の `detail`(無ければ空文字) | -| 出力(結果なし) | `payload` は `None`、`reason` は下の表、`relaunch_same_agent` は `reason not in NO_RELAUNCH_REASONS`、`detail` は監視の `detail`(無ければ読めなかった理由の 1 文) | -| 失敗の形 | **失敗しない。** 例外を投げず、`SystemExit` も出さず、標準出力・標準エラーに書かない。監視の結果ファイルが壊れていれば無いものとして扱う | -| 互換性 | 新設。既存の `read_outcome` / `reason_for` / `REASONS` の呼び出し側は変わらない(`REASONS` は 9 語になるが、一覧を持つ読み手は無い) | - -結果なしの理由の決め方(要求の AC9): - -| 監視の結果ファイルの `reason` | 結果ファイル | `reason` | -| --- | --- | --- | -| `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` | 問わない | その値(**監視が止めたか、結果を書けない終わり方をしたことが分かっている**) | -| `ok` / `missing` / ファイルが無い・読めない | 無い、または空 | `missing` | -| `ok` / `missing` / ファイルが無い・読めない | あるが JSON オブジェクトでない | `unparsable` | - -**結果ファイルが読めれば使える結果が勝つ。** 監視が利用上限で止めた後にも結果ファイルが残っていれば、それは止める前に書き終えていた結果で、使ってよい。 - -### 可否を返す関数(新設) - -`relaunch_same_agent(reason) -> bool` は `reason not in NO_RELAUNCH_REASONS` を返す。`NO_RELAUNCH_REASONS = frozenset({"usage_limit"})`。理由を足すときはこの集合だけを見直す。 - -### 監視(変更。終了コードと標準出力は変えない) - -| 引数・出力 | 約束 | -| --- | --- | -| 終了コード 0〜6 | 変えない。`usage_limit` は 4、`cli_timeout` は 3 | -| 標準出力の 13 個のキー | 変えない | -| 監視の結果ファイルと記録の `reason` | `usage_limit` / `cli_timeout` が増える | -| `--no-early-error` | 変えない。利用上限の検知も致命の検知と一緒に無効になる(`MONITOR_NO_EARLY_ERROR=1` の逃げ道はそのまま) | - -検知の文言(err.log は既存の照合の除外を掛ける。claude の stdout.log は JSON 向けの照合で除外を掛けない): - -| 表 | 理由 | 見るファイル | いつ見るか | 文言(正規表現) | -| --- | --- | --- | --- | --- | -| `USAGE_LIMIT_FATAL` | `usage_limit` | err.log(全担当) | 生きている間の巡回ごと | `Monthly request limit reached` | -| `USAGE_LIMIT_FATAL` | `usage_limit` | err.log(全担当) | 同上 | `"api_error_status"\s*:\s*429` | -| `USAGE_LIMIT_FATAL` | `usage_limit` | err.log(既存の一致を付け替え) | 同上 | `\b(?:quota exceeded\|rate limit exceeded)\b`(大文字小文字を問わない)/ `^HTTP/\d\S* 429 ` | -| `CLAUDE_STDOUT_USAGE_LIMIT` | `usage_limit` | claude の stdout.log | 同上 | `"api_error_status"\s*:\s*429` | -| `EARLY_ERROR_FATAL` | `early_error` | err.log(既存の一致を分ける) | 同上 | `^HTTP/\d\S* (?:401\|403) ` と、残りの既存の致命 | -| `CLI_TIMEOUT_AFTER_EXIT` | `cli_timeout` | err.log | **終了した後、結果ファイルが無いときだけ** | `print timeout after \S+ with turn in progress` | - -照合の順序は利用上限 → 致命 → 警告の見た目の致命。同じ err.log に利用上限と他の致命が両方あれば、理由は「利用上限」になる。上限で落ちた後に別の文言が続く形が普通で、上限のほうが原因である。 - -### 起動の手順と監視の停止(変更。既存の設計の決定 17) - -| 項目 | 約束 | -| --- | --- | -| 起動 | `set -m` を有効にして背景起動する。CLI の pid = pgid | -| 停止 | `os.getpgid(pid) == pid` かつ `!= os.getpgrp()` なら `os.killpg`(SIGTERM → 3 秒 → SIGKILL)。それ以外は従来どおり `os.kill` | -| 互換性 | 呼び出し側の引数は変わらない。pid ファイルの中身も変わらない | - -## 両 Skill が従う契約 - -cross-review は状態の操作を変更し、cross-refactoring は結果の読み取りの契約だけを受け取る。 - -### cross-review の状態の操作(変更) - -| コマンド | 変わる出力 | 変わらないもの | -| --- | --- | --- | -| `read-result` | `rounds[-1].<担当>.no_result_reason` に `read_launch_outcome` の `reason`(`no_verdict` / `not_posted` は従来どおり後で上書き)。監視の結果ファイルがあれば `monitor_detail` | 終了コード(`unparsable` は 3、それ以外は 1、使える結果は 0) | -| `judge` | 結果なしがあるとき標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'`。可否が偽の理由を含めば `final=error` と終了コード 1、標準エラーに担当・理由・`monitor_detail` | 終了コード 0 / 2 / 7 / 8 の意味と `RELAUNCH_AGENTS` の形、8 の `flush` の枝 | -| `report` | ラウンド表で `<担当>=NO_RESULT(<理由>)` | 他の行 | - -### cross-refactoring の結果の読み取り(契約のみ。G4 が実装する) - -| 項目 | 契約 | -| --- | --- | -| 読み取り | `read_launch_outcome(state["tmp_dir"], stem_for(...), result_path(...))` を呼ぶ。自前で結果ファイルを開かない | -| 返すもの | `LaunchOutcome`(`payload` / `reason` / `relaunch_same_agent` / `detail` / `monitor`) | -| 失敗の形 | **`die` しない。** 終了コードを決めるのは 3 つの取り込み(`merge-apply` / `merge-fix` / `merge-final-fix`) | -| 群の記録 | `failed_attempts[].reason` には `LaunchOutcome.reason` をそのまま写す(G4 の設計の `_monitor_reason` はこの値で置き換わる) | -| 担当の交代 | `relaunch_same_agent` が偽なら同じ担当で試行を重ねない。替えるか止めるかは G4 の決定 3 | - -## 処理の流れ - -監視の 1 担当、結末の読み取り、cross-review の 1 ラウンドの 3 つを図にする。 - -### 監視の 1 担当(利用上限と CLI の上限の枝が入る) - -```mermaid -graph TD - A[pid ファイルを待つ] -->|無い| PB[PIDFILE_BAD / pidfile_bad] - A --> P[巡回] - P -->|経過 ≥ 監視の上限| T[TIMEOUT / timeout] - P -->|利用上限の文言
err.log 全担当・stdout claude| U[EARLY_ERROR / usage_limit] - P -->|その他の致命の文言| E[EARLY_ERROR / early_error] - P -->|終了して結果あり| OK[OK / ok] - P -->|終了して結果なし
err.log に CLI の上限の文言| CT[NO_RESULT / cli_timeout] - P -->|終了して結果なし| NR[NO_RESULT / missing] - P -->|無進捗 ≥ 許容| SL[STALLED / stalled] - T --> K[止める: pgid = pid ならグループへ] - U --> K - E --> K - SL --> K - K --> W[結果ファイルと記録を書く
reason = outcome.reason or reason_for] - OK --> W - CT --> W - NR --> W - PB --> W - W --> O[標準出力の JSON と終了コード(変えない)] -``` - -### 結末を読む関数の流れ - -```mermaid -graph TD - S[結果ファイルを読む] -->|JSON オブジェクト| P[payload / reason None / 可 True] - S -->|無い・空・読めない| M[監視の結果ファイルを読む] - M -->|reason が timeout / stalled / early_error
usage_limit / cli_timeout / pidfile_bad| R1[reason = その値] - M -->|ok / missing / 無い・壊れている| R2{結果ファイルは} - R2 -->|無い・空| R3[reason = missing] - R2 -->|あるが読めない| R4[reason = unparsable] - R1 --> V[可否 = reason not in NO_RELAUNCH_REASONS] - R3 --> V - R4 --> V -``` - -### cross-review の 1 ラウンド(起動し直しの判断) - -```mermaid -sequenceDiagram - participant H as 進行側(骨組み、変えない) - participant M as monitor.py - participant MO as monitor_outcome - participant S as state.py - H->>M: 監視(担当ごと) - M->>MO: 結末(reason 付き)を結果ファイルと記録へ - H->>S: read-result(担当ごと) - S->>MO: read_launch_outcome(tmp_dir, stem) - MO-->>S: payload / reason / 可否 / detail - alt payload あり - S->>S: 取り込み(no_verdict / not_posted は従来どおりここで判定) - else 結果なし - S->>S: rounds[-1].<担当> に NO_RESULT / no_result_reason / monitor_detail - S-->>H: 終了コード 1(unparsable は 3) - end - H->>S: judge - S->>S: 結果なしの担当の理由を集める - S-->>H: NO_RESULT_REASONS='kiro=usage_limit' - alt 可否が偽の理由がある - S->>S: final=error - S-->>H: 終了コード 1(担当・理由・monitor_detail を標準エラーへ) - else すべて可(1 度目) - S-->>H: 終了コード 7 と RELAUNCH_AGENTS(変えない) - else すべて可(2 度目) - S-->>H: 終了コード 1(変えない) - end -``` - -## 非機能の実現方式 - -要求の非機能の条件 3 件に、それぞれ実現方式を対応させる。 - -| 条件 | 実現方式 | -| --- | --- | -| 理由が 4 か所で同じ語彙で読める | 監視が結末に理由を添え、`_record_outcome` が結果ファイルと記録へ同じ値を書く。`read-result` は `read_launch_outcome` の `reason` をそのまま `no_result_reason` に写す。要約の `launches[]` は記録の行から作る(P1 のまま)。語彙を足すときは `REASONS` と `NO_RELAUNCH_REASONS` と検知の表だけを変える | -| `monitor_detail` が要約に入らない | `run_metrics.py` は状態ファイルの `rounds[-1].<担当>` の鍵を要約へ写さず、記録の行から `detail` を除いて `launches[]` を作る(P1 の決定 7)。この変更は要約の側を触らない | -| macOS でもグループで止める | 外部コマンドに頼らない `set -m` を使う。bash 3.2 は未確認(「未確認のまま残ること」)。成り立たなければ pid だけの停止に落ちる(`os.getpgid(pid) != pid` の枝) | - -## テスト設計 - -受け入れ条件 24 件を、既存のテストの形(偽の CLI を実プロセスで動かす・一時ディレクトリに結果を置く・状態ファイルを作って呼ぶ)で確かめる。 - -| 受け入れ条件 | 何で確かめるか | -| --- | --- | -| AC1 | `REASONS` の 9 語と、6 つの状態の `reason_for` の返り値を固定する(`plugins/ndf/scripts/tests/test_monitor_outcome_unit.py`) | -| AC2〜AC5 | 各文言を 1 行書いた err.log / stdout.log と、終わったプロセスの pid ファイルで `monitor_agent` を呼び、状態・終了コード・結果ファイルの `reason`(`plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` 新設)。AC5 は結果ファイルあり・なしの 2 通り | -| AC6 | 「実測」の 10 行をそのまま入力にし、表・引用・バッククォート・grep 形式で一致しないこと。claude の stdout.log の JSON で一致すること(`plugins/ndf/skills/cross-review/tests/test_monitor_early_error.py` の形に倣う) | -| AC7 | 既存の `plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py` の標準出力のキーの検査を変更せずに通す。記録の行の `reason` を読む | -| AC8〜AC11 | 一時ディレクトリに監視の結果ファイル(各理由・壊れた JSON・無し)と結果ファイル(オブジェクト・配列・壊れた JSON・空・無し)を組み合わせて置き、`read_launch_outcome` の 5 つの欄。`capsys` で標準出力・標準エラーが空(`plugins/ndf/scripts/tests/test_monitor_outcome_unit.py`) | -| AC12 | `git grep -n 'usage_limit' -- plugins/ndf/skills` の一致行が、文書とテストとテンプレートの文言だけである(テスト化せず、レビューの手順) | -| AC13 | 結果ファイル無し + 監視の結果ファイル(各理由)/ 無しで `read-result` を呼び、状態ファイルの `no_result_reason` と `monitor_detail` と終了コード(`plugins/ndf/skills/cross-review/tests/test_read_result_reason.py` 新設) | -| AC14〜AC17 | 状態ファイルを作って `judge` と `report` を呼ぶ。`usage_limit` を含む / 含まない / 2 度目の 3 通り(`plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py` 新設) | -| AC18 | 文書の `grep`(10 語と「見分け方」の節) | -| AC19〜AC21 | 3 秒後に子が書く偽の CLI を `launch-cli.sh` で起動し、`os.getpgid` と、監視の上限 2 秒で止めた後の結果ファイルの有無。先頭でない pid では `os.killpg` が呼ばれないことを `mock` で見る(`plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py` 新設) | -| AC22〜AC23 | 検証手段の表のコマンド | -| AC24 | `SKILL.md` の骨組みの該当行(`bg-wait.sh run` から `case $JUDGE_RC` まで)を変更前と `diff` して差が無い | - -## 未確認のまま残ること - -6 件。実装で決めたものが 4 件(1・3・5・6。決めた結果を「決める時点」の列に残す)、確かめないまま進めるものが 2 件(2・4。どちらでも設計が塞ぐ)である。 - -| # | 項目 | 内容 | 決める時点 | -| --- | --- | --- | --- | -| 1 | claude の 429 の出る先 | `"api_error_status":429` が err.log と stdout.log のどちらに出るか。実物のログが手元に無い。両方を見るためどちらでも拾える | 実装で偽の claude を err.log と stdout.log の両方の形で試し、どちらでも `EARLY_ERROR` / `usage_limit` になることをテストで固定した(`test_monitor_usage_limit.py` の `test_usage_limit_stops_the_agent_as_early_error_with_reason_usage_limit`)。実物の出る先は次に上限に当たったときの記録で確かめる | -| 2 | kiro の利用上限のときの終わり方 | プロセスが直ちに終わるのか、待ち続けるのか。#619 の実物では `err.log` に 1 行出て `NO_RESULT` になった(直ちに終わったと読める) | どちらでも監視は巡回で文言を拾い `usage_limit` にする。実装で確かめない | -| 3 | macOS の bash 3.2 の `set -m` | Linux の bash 5.3 だけで確かめた | 一次資料で確かめた。GNU の配布物 bash-3.2 の `doc/bash.1`(2006-09-28)の `set` の `-m` の項に「Background processes run in a separate process group」とあり、`CHANGES` の bash-2.01 の節に `set -m` の修正の記載がある(2.01 の時点で存在する)。手元の bash 5.3.9 では非対話・tty 無しで pid = pgid になり、標準エラーへジョブ制御の通知は出ない。macOS の実機では未確認のまま。成り立たなければ `_leads_own_group` が偽になり pid だけの停止に落ちる(構造は同じ) | -| 4 | agy が子プロセスで結果を書くか | #584 の事例が止めた後の書き出しだったかは確かめられていない | 確かめないまま進める(グループで止めればどちらでも塞がる) | -| 5 | 利用上限と他の致命が同じ err.log に並ぶ順序 | 上限の後に別の致命が続く形を想定して利用上限を先に見る。逆の順で並ぶ実物は未確認 | 照合の順序を利用上限 → 致命 → 警告の見た目の致命に固定し、err.log の並びがどちらの順でも理由が `usage_limit` になることをテストで固定した(`test_usage_limit_wins_over_other_fatal_lines_in_either_order`)。逆の順で並ぶ実物が出ても結果は変わらない | -| 6 | `read_launch_outcome` に渡す stem の組み立て | cross-review は `-review-pr`、cross-refactoring は `stem_for` の値。監視の `--stem-template` と食い違うと監視の結果ファイルを引けない | cross-review の 3 か所(`launch-reviewer.sh` の `STEM=` の行・監視の `DEFAULT_STEM_TEMPLATE`・取り込みが渡す stem)が同じ形であることを 1 つのテストで固定した(`test_read_result_reason.py` の `test_the_three_stems_have_the_same_shape`)。cross-refactoring の `stem_for` との突き合わせは G4 が `read_result` を置き換えるときに同じ形のテストを置く | - -## 申し送り(並行する設計との境界) - -同時に進む G4 / G5 / D-B / G1 と、どこまでをこの設計が持つかを決めた。 - -| 相手 | 決めた契約 | どちらが何をするか | -| --- | --- | --- | -| G4(#728 #647 #592 #553) | `gitfacts.read_result` の契約(「両 Skill が従う契約」)。理由の語彙 9 語と `relaunch_same_agent` | G3 が共通層と cross-review を実装する。G4 は `read_result` に相当する読み取りを `read_launch_outcome` に置き換え、3 つの取り込みで `payload` 無しのときの群の状態・担当の交代・終了コードを決める。G4 の既存の設計の `apply._monitor_reason` と `gitfacts.load_result` は `read_launch_outcome` で置き換わる | -| G4 | 監視の終了コード 0〜6 と標準出力は変えない | G3 が守る。G4 は終了コードで分岐しない設計(G4 の決定 4)を続ける | -| G5(#730 #583) | 既存の設計の決定 18(`prior_review_url` と記録だけの起動)・決定 19(起動し直しを初回と同じ経路へ)・AC63〜AC67 | G5 が持つ。G3 は `read-result` の結果なしの記録に `monitor_detail` を足すだけで、`prior_review_url` の鍵と `launch-reviewer.sh` は触らない。G5 が `_record_no_result` に `prior_review_url` を足すときは、G3 の `monitor_detail` の書き方(鍵が無い = 無かった)に揃える | -| D-B(#478) | 判定が出す `NO_RESULT_REASONS` の行と、可否が偽のときに止めること | G3 が入れる。利用上限の担当を外して残りで回す判断は #478 | -| G1(#727 #478 ほか) | `state.py` の `init` / 再開の引数 | 触るファイルが同じ(`state.py`)だが節が違う(G3 は `_read_review_result_file` / `_record_no_result` / `_handle_no_result_round` / `report`)。競合は後からマージする側が解く | - -## 既存の設計との対応 - -既存の設計(PR #666)の P3 の決定を、この文書のどの決定が引き継ぐかを示す。 - -| 既存の決定(PR #666) | この文書 | 変わったこと | -| --- | --- | --- | -| 決定 14(利用上限は致命として止め、同じラウンドで起動し直さない) | 決定 3・4 | 「判定が `usage_limit` を見る」から「共通層の可否を見る」へ。値は同じ | -| 決定 15(CLI の上限は結果なしのまま理由だけ) | 決定 5 | 結果ファイルがあれば `ok` を明記 | -| 決定 16(監視の終了コードと標準出力を変えない) | 決定 4・12 | 同じ | -| 決定 17(プロセスグループで止める) | 決定 10 | 同じ | -| 決定 18(投稿済みの担当は記録だけの起動) | — | #730(G5)へ | -| 決定 19(起動し直しを初回と同じ経路へ) | — | #730(G5)へ | -| 決定 20(利用上限の検知は err.log + claude の stdout.log) | 決定 6 | 実測を足した。値は同じ | -| — | 決定 1・2・7・8・9・11 | 新設 | - -## この文書の位置づけ - -この文書は「どう作るか」だけを扱う。要求と受け入れ条件は [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) にある。 - -**既存の設計 [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md) の P3 を置き換える。** 既存の決定のうち引き継ぐものと変えるものは「既存の設計との対応」にある。#583(決定 18・19)は #730 の設計へ移る。 diff --git a/issues/issue-729-619-584-implementation-plan.md b/issues/issue-729-619-584-implementation-plan.md deleted file mode 100644 index c1fbb7e3..00000000 --- a/issues/issue-729-619-584-implementation-plan.md +++ /dev/null @@ -1,173 +0,0 @@ -# cross-review: 利用上限で止まった担当が「結果ファイル無し」と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(実装計画 / #729 #619 #584) - -## 関連リンク - -- 親 issue #729、子 issue #619 #584 -- 要求と受け入れ条件: [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md)(AC1〜AC24) -- 設計: [issue-729-619-584-design.md](issue-729-619-584-design.md)(決定 12 件。識別子は冒頭の「用語の対応表」で引く) -- 設計 Pull Request: #781(`develop` へマージ済み) - -## モード - -`standard`。本番の振る舞い(結果なしの理由と起動し直しの判断)を変え、共通層と cross-review の複数モジュールにまたがる。 - -## 目的と非目的 - -達成したい状態: - -- 担当の CLI が利用上限で落ちたとき、進行側と利用者に理由「利用上限」が届き、同じラウンドで同じ担当を起動し直さない -- 結果なしの判断と起動し直しの可否を、結末の共通層の 1 つの関数が持つ。cross-review はその値を読むだけになる -- 監視が止めた担当の子プロセスが、止めた後に結果ファイルを書かない - -やらないこと: - -- cross-refactoring の結果の読み取りと 3 つの取り込みの実装(G4 #728 が、この計画が作る結末を読む関数を使って行う) -- 投稿の後に打ち切られた担当の記録と重ねての投稿(G5 #730) -- 利用上限の担当を外して残りの担当で回す判断(#478) -- 監視の終了コードと標準出力の変更、cross-review の骨組み(`SKILL.md`)の変更 -- テストが共通層のモジュールを読み込む流儀を揃えること(実装中に見つけ、#789 として起票した) - -## 前提 - -- 前提 1: 設計文書の決定 12 件を変えない。実装で決めるのは「未確認のまま残ること」の 4 件(claude の 429 の出る先・bash 3.2 の `set -m`・文言の並ぶ順序・stem の突き合わせ)で、決めた結果は設計文書の同じ節へ書く -- 前提 2: 監視の結果ファイルと監視の記録(P1)、上限の表と工程(P2)は `develop` にある。この変更はその上に載せる -- 前提 3: 手元の bash は 5.3.9 である。macOS の bash 3.2 は実機が無いため、`set -m` の互換は bash の変更履歴の確認までとし、成り立たない場合の逃げ道(グループの先頭でなければ pid だけを止める)を実装で保証する - -## 受け入れ条件 - -要求文書の AC1〜AC24 をそのまま使う。検証手段は設計文書の「テスト設計」の表にある。この計画では、各タスクが満たす番号を「タスク分解」で示す。 - -## ドメイン用語 - -設計文書の「用語の対応表」を使う。この計画で新たに使う語は次の 2 つである。 - -| 用語 | 意味 | -| --- | --- | -| 共通層のタスク | 結末の語彙・監視・起動の手順を変えるタスク(Task 1〜4)。cross-review を触らない | -| 読む側のタスク | cross-review の状態の操作を変えるタスク(Task 5〜6)。共通層のタスクの後に行う | - -## 不変条件 - -- 監視の終了コード 0〜6 と標準出力の 13 個のキーは変わらない -- 結果の取り込みの終了コード(使える結果 0 / 読めない結果 3 / それ以外 1)と、判定の 0 / 2 / 7 / 8 の意味は変わらない -- 結果ファイルが JSON オブジェクトとして読めるなら、監視の結末が何であっても使える結果として扱う -- 理由の語彙と起動し直しの可否の表を持つのは結末の共通層だけである - -## 互換性 - -| 対象 | 変更 | 互換性の扱い | -| --- | --- | --- | -| 監視の標準出力・終了コード | 変えない | 変えない | -| 監視の結果ファイル・監視の記録の `reason` | `usage_limit` / `cli_timeout` の 2 値が増える | 追加のみ。読む側は値を集計するだけで一覧を持たない | -| 状態ファイルの `rounds[-1].<担当>` | `monitor_detail` の鍵が増える(結果なしのときだけ)。`no_result_reason` の値が 10 種類になる | 追加のみ。既存の状態ファイルはそのまま読める | -| 結末の共通層の関数 | `read_launch_outcome` / `relaunch_same_agent` / `LaunchOutcome` / `NO_RELAUNCH_REASONS` を新設 | 追加のみ。既存の `read_outcome` / `reason_for` / `REASONS` の呼び出し側は変わらない | -| 起動の手順の引数・pid ファイル | 変えない | 変えない | - -## 修正対象 - -```text -plugins/ndf/scripts/lib/monitor_outcome.py -plugins/ndf/scripts/lib/monitor.py -plugins/ndf/scripts/lib/launch-cli.sh -plugins/ndf/scripts/lib/README.md -plugins/ndf/scripts/tests/test_monitor_outcome_unit.py -plugins/ndf/skills/cross-review/scripts/state.py -plugins/ndf/skills/cross-review/docs/01-state-and-review.md -plugins/ndf/skills/cross-review/docs/03-review-output.md -plugins/ndf/skills/cross-review/docs/04-contracts.md -plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py 新設 -plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py 新設 -plugins/ndf/skills/cross-review/tests/test_read_result_reason.py 新設 -plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py 新設 -issues/issue-729-619-584-design.md 「未確認のまま残ること」の更新 -plugins/ndf/dev.kiro/ / plugins/ndf/dev.agy/ 配布物の同期(生成物) -``` - -## タスク分解 - -共通層のタスク(1〜4)→ 読む側のタスク(5〜6)→ 文書と退行の確認(7〜8)の順に進める。各タスクは失敗するテスト → 通す最小実装 → 整理の順で行う。 - -### Task 1: 結末を 1 つの値として読む関数を共通層に置く - -- **対象ファイル:** `plugins/ndf/scripts/lib/monitor_outcome.py`、`plugins/ndf/scripts/tests/test_monitor_outcome_unit.py` -- **変更内容:** 理由の語彙を 9 語にする(`REASONS` に `usage_limit` / `cli_timeout` / `unparsable`)。起動し直せない理由の集合 `NO_RELAUNCH_REASONS = frozenset({"usage_limit"})` と `relaunch_same_agent(reason)` を置く。`LaunchOutcome`(`payload` / `reason` / `detail` / `monitor` / `relaunch_same_agent`)と `read_launch_outcome(tmp_dir, stem, result_path=None)` を新設する。理由の決め方は設計文書の「結末を読む関数」の表のとおり。例外・`SystemExit`・標準出力/標準エラーへの出力を出さない。モジュールの冒頭の説明に読み取りの責務を足す -- **満たす受け入れ条件:** AC1、AC8〜AC11 -- **進め方:** 監視の結果ファイル(各理由・壊れた JSON・無し)× 結果ファイル(オブジェクト・配列・壊れた JSON・空・無し)の組み合わせを一時ディレクトリに置く単体テストを先に書く。`capsys` で出力が空であることも見る - -### Task 2: 監視が利用上限の文言を検知し、理由「利用上限」を結末に添える - -- **対象ファイル:** `plugins/ndf/scripts/lib/monitor.py`、`plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py`(新設) -- **変更内容:** `MonitorOutcome` に `reason: Optional[str] = None` を足し、`create(status, detail, reason=None)` にする。`USAGE_LIMIT_FATAL`(kiro の文言・claude の 429 の JSON・既存の `quota exceeded` / `rate limit exceeded`・`HTTP/x 429`)と `CLAUDE_STDOUT_USAGE_LIMIT` を新設し、`EARLY_ERROR_FATAL` から 429 と quota / rate limit の一致を外す(401 / 403 は残す)。致命の照合の前に利用上限の照合を置く(err.log は全担当、stdout.log は claude だけ JSON 向けの照合)。`_early_error_outcome` は利用上限に一致したとき `reason="usage_limit"` を添える。`_record_outcome` は `outcome.reason or reason_for(status)` で理由を書く。結末を作る既存の呼び出し 8 か所は変えない -- **満たす受け入れ条件:** AC2〜AC4、AC6、AC7 -- **進め方:** 設計文書の「実測」の 10 行を入力にした照合の単体テストと、文言を 1 行書いた err.log / stdout.log と終わったプロセスの pid ファイルで `monitor_agent` を呼ぶテストを先に書く。既存の `test_monitor_outcome_file.py` と `test_monitor_early_error.py` は変えずに通す。**照合の順序(利用上限 → 致命 → 警告の見た目の致命)をテストで固定する**(設計文書の未確認 5) - -### Task 3: CLI 自身の上限で結果を書かずに終わった担当を、理由「CLI の上限」にする - -- **対象ファイル:** `plugins/ndf/scripts/lib/monitor.py`、`plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` -- **変更内容:** `CLI_TIMEOUT_AFTER_EXIT`(`print timeout after \S+ with turn in progress`)を新設する。`_process_exit_outcome` で、終了して結果ファイルが無いときだけ err.log を照合し、一致すれば `reason="cli_timeout"` を添える。結果ファイルがあれば従来どおり `OK` -- **満たす受け入れ条件:** AC5 -- **進め方:** 結果ファイルあり・なしの 2 通りのテストを先に書く - -### Task 4: CLI を独立したプロセスグループで起動し、止めるときはグループへ送る - -- **対象ファイル:** `plugins/ndf/scripts/lib/launch-cli.sh`、`plugins/ndf/scripts/lib/monitor.py`、`plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py`(新設) -- **変更内容:** 起動の手順の `launch_runtime` を呼ぶ前に `set -m` を有効にし、背景起動した CLI の pid がプロセスグループの番号になるようにする(起動の直後に `set +m` で戻す)。`_kill_pid` は `os.getpgid(pid) == pid` かつ `!= os.getpgrp()` のとき `os.killpg` で SIGTERM → 3 秒 → SIGKILL を送り、それ以外は従来どおり pid だけへ送る。生存の確認はグループの先頭の pid で見る -- **満たす受け入れ条件:** AC19〜AC21 -- **進め方:** 3 秒後に子プロセスが結果ファイルを書く偽の CLI(`bash -c`)を PATH に置いて起動の手順から起動し、`os.getpgid(pid) == pid` を確かめるテスト、監視の上限 2 秒で止めた後 4 秒待っても結果ファイルが無いテスト、先頭でない pid で `os.killpg` が呼ばれないことを `mock` で見るテストを先に書く。**bash 3.2 の互換は、`set -m` が bash 2 系から存在する組み込みであることを `man bash` / 変更履歴で確かめ、設計文書の未確認 3 へ書く**(実機での確認は未確認のまま残す) - -### Task 5: cross-review の結果の取り込みが共通の値を読み、理由と監視の詳細を残す - -- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/state.py`、`plugins/ndf/skills/cross-review/tests/test_read_result_reason.py`(新設) -- **変更内容:** 共通層の `monitor_outcome` を読み込む。`_read_review_result_file` は結果ファイルを自前で開かず `read_launch_outcome(tmp_dir, f"{agent}-review-pr{pr}", rfile)` を呼び、`payload` が無ければ `reason` を `no_result_reason` として記録して従来の終了コード(`unparsable` は 3、それ以外は 1)で止める。`_record_no_result` に `monitor_detail`(監視の結果ファイルがあるときだけ鍵を書く。空文字は書かない)を足す。`no_verdict` / `not_posted` の上書きは従来どおり -- **満たす受け入れ条件:** AC13、AC12(`plugins/ndf/skills/` に `usage_limit` の条件分岐を書かない) -- **進め方:** 結果ファイル無し + 監視の結果ファイル(各理由)/ 無しで `read-result` を呼び、状態ファイルの `no_result_reason` と `monitor_detail` と終了コードを見るテストを先に書く。既存の `test_state_read_result.py` / `test_state_no_result.py` は変えずに通す。**stem の突き合わせ**(設計文書の未確認 6)は、`launch-reviewer.sh` の `STEM` と監視の `DEFAULT_STEM_TEMPLATE` と取り込みの stem が同じ形であることをテストで固定する - -### Task 6: 判定が理由を出し、起動し直せない理由があれば止める。報告の表に理由を出す - -- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/state.py`、`plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py`(新設) -- **変更内容:** `_handle_no_result_round` は結果なしの担当の理由を集めて標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'` を出す。理由に `relaunch_same_agent` が偽のものがあれば、起動し直さずに `final=error` として終了コード 1 で止め、標準エラーに担当・理由・`monitor_detail` を出す。すべて起動し直してよい理由なら従来どおり 7 / 2 度目は 1。`_print_round_summary` は結果なしの担当を `<担当>=NO_RESULT(<理由>)` の形で出す -- **満たす受け入れ条件:** AC14〜AC17、AC24 -- **進め方:** 状態ファイルを作って `judge` と `report` を呼ぶ。`usage_limit` を含む / 含まない / 2 度目の 3 通りを先に書く。`SKILL.md` の骨組みの行(`bg-wait.sh run` から `case $JUDGE_RC` まで)は `git diff` で差が無いことを確かめる - -### Task 7: 文書を更新する - -- **対象ファイル:** `plugins/ndf/skills/cross-review/docs/01-state-and-review.md`、`docs/03-review-output.md`、`docs/04-contracts.md`、`plugins/ndf/scripts/lib/README.md`、`issues/issue-729-619-584-design.md` -- **変更内容:** 理由の表を 10 語にする(`missing` / `unparsable` / `no_verdict` / `not_posted` / `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad`)。「monitor.py が誤って kill する場合の手順」に、上限に当たった場合の見分け方(`reason` と `monitor-outcomes.jsonl` の読み方)を足す。契約の文書に `monitor_detail` の鍵と `reason` の 2 値を足す。共通層の一覧の `monitor_outcome.py` の行に読み取りの責務を足す。設計文書の「未確認のまま残ること」の 1・3・5・6 を、実装で決めた結果へ更新する -- **満たす受け入れ条件:** AC18 -- **進め方:** テスト駆動を適用しない(文書)。`grep` で 10 語と「見分け方」の節を確かめる。`python3 scripts/check-doc-line-limit.py` を通す - -### Task 8: 退行の確認と配布物の同期 - -- **対象ファイル:** `plugins/ndf/dev.kiro/` `plugins/ndf/dev.agy/` の生成物 -- **変更内容:** `bash scripts/build-runtime-plugins.sh` で生成物を揃える。全テスト・定義の検査を通す。#619 と #584 の再現を手動で確かめる(要求文書の「検証手段」) -- **満たす受け入れ条件:** AC22、AC23 -- **進め方:** テスト駆動を適用しない(検証)。コマンドの終了コードを記録し、Pull Request 本文へ載せる - -## 影響範囲 - -- 監視を使う 2 つの Skill(cross-review / cross-refactoring)。cross-refactoring は監視の結果ファイルの `reason` に 2 値が増えるだけで、終了コードの分岐は変わらない -- 実行の要約(`run_metrics.py`)は `reason` の値を集計するだけで、語彙の一覧を持たないため変更なし -- 担当の CLI のプロセスが独立したプロセスグループで動く。起動の手順を呼ぶ側(`launch-reviewer.sh`、cross-refactoring の `launch-*.sh`)の引数は変わらない - -## リスクと対処 - -| リスク | 対処 | -| --- | --- | -| `state.py` は 4470 行の 1 ファイルで、G1(#727)も同じファイルの別の節を触る | タスクごとにテストを通す。触る関数を `_read_review_result_file` / `_record_no_result` / `_handle_no_result_round` / `_print_round_summary` の 4 つに限る。競合は後からマージする側が解く | -| `docs/01-state-and-review.md` は 497 行で、行数の上限(500)に近い。理由の表に 7 行足すと超える | 同じ文書の既存の記述を詰め、501 行以上にしない。詰められなければ理由の表を契約の文書(`04-contracts.md`)へ移し、元の場所からリンクする | -| 利用上限の文言の照合を致命の照合の前に置くため、既存の 429 / quota の一致の `reason` が変わる | 既存テストは `_scan_early_fatal` の返り値だけを見ており、利用上限の照合を先に置いても `_scan_early_fatal` 単体の挙動は変えない。理由の変化は新しいテストで固定する | -| `set -m` を有効にすると、bash がジョブの終了を標準エラーへ出す・起動元がジョブ制御の影響を受ける | `launch_runtime` の直前で有効にし、直後に `set +m` で戻す。既存の `test_lib_launch_cli_runtimes.py` / `test_launch_cli_guards.py` で標準エラーの形が変わらないことを見る | -| 監視の環境変数(`MONITOR_*`)を export したシェルでは既存テストが落ちる(#678、G6) | export していないシェルでテストを実行する | - -## 切り戻し手順 - -- すべてコードと文書の変更で、データ移行は無い。Pull Request の revert で戻せる -- 状態ファイルの `monitor_detail` は追加の鍵で、戻した後の読み手は無視する。監視の結果ファイルの `usage_limit` / `cli_timeout` は戻した後の `reason_for` の一覧に無いが、読む側は値を集計するだけで一覧を照合しない - -## 完了の定義 - -- [ ] AC1〜AC24 をすべて満たし、条件ごとに検証手段と結果が対応している(Pull Request 本文の表) -- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る(export していないシェル) -- [ ] `bash scripts/build-runtime-plugins.sh --check`、`python3 scripts/check-skill-frontmatter.py`、`claude plugin validate .` が終了コード 0 -- [ ] `git grep -n 'usage_limit' -- plugins/ndf/skills` の一致行が文書・テスト・テンプレートの文言だけである(AC12) -- [ ] 設計文書の「未確認のまま残ること」の 1・3・5・6 が、実装で決めた結果へ更新されている diff --git a/issues/issue-729-619-584-requirements.md b/issues/issue-729-619-584-requirements.md deleted file mode 100644 index 04b3a08f..00000000 --- a/issues/issue-729-619-584-requirements.md +++ /dev/null @@ -1,261 +0,0 @@ -# cross-review: 利用上限で止まった担当が「結果ファイル無し」と報告されて空振りの起動し直しで待たされ、止めた担当が後から結果を書く → 上限を理由に報告して同じラウンドで起動し直さず、止めた後は書かせない(要求と受け入れ条件 / #729 #619 #584) - -## 目的 - -**起きていること。** 担当の CLI(kiro / claude)が月間の利用上限に当たると、監視はその文言を読めない。結果なしの理由は「結果ファイル無し」に畳まれる。進行側は同じ担当を同じラウンドで起動し直し、監視の上限 1 回分(レビューで 1200 秒)を待ってから全体を誤りで終える(#619。#647 では 3729 回の空振り)。また、監視が止めた担当の子プロセスが、止めた後に結果ファイルを書く(#584)。 - -**困る人。** 収束ループを回す進行側と、結果を待つ利用者。届く理由が「結果ファイル無し」のため、上限に当たったのか、監視の上限で打ち切られたのかを判別できない。 - -**直すと成り立つこと。** 理由が「利用上限」として進行側と利用者に届き、同じラウンドで同じ担当を起動し直さない。結果ファイルが無いときの「監視が打ち切った」「CLI が自分の上限で終わった」「終わったが結果を書かなかった」を、結末の語彙 1 つで区別できる。結果なしの判断と起動し直しの可否は共通層の 1 か所が持つ。cross-review と cross-refactoring がその値を読む(両 Skill が同じ判断を別々に書かない)。監視が止めた担当の子プロセスは、止めた後に結果ファイルを書かない。 - -## 用語 - -本文はこの表の用語で書く。受け入れ条件は検査で突き合わせる値を持つため、識別子の列の語で書く。 - -| 用語 | 意味 | 識別子 | -| --- | --- | --- | -| 担当 | レビュー・反証・適用・修正を行う CLI(codex / agy / kiro / claude) | — | -| 起動 1 回 | 起動の手順が担当を 1 度起動し、監視がそれを見終わるまで | `launch-cli.sh` / `monitor.py` | -| 結末 | 起動 1 回の終わり方。監視の状態と理由、結果ファイルの有無と読めるかを合わせたもの | 監視の `status` と `reason` | -| 理由 | 結末の語彙の 1 語 | `monitor_outcome.REASONS` | -| 起動し直しの可否 | 同じ担当を同じ条件で起動し直せば解ける結末か(偽なら、起動し直しても同じ結末になる) | `relaunch_same_agent` | -| 使える結果 | 結果ファイルがあり、JSON オブジェクトとして読める | `payload` | -| 結果ファイル | 担当が一時ディレクトリへ書く JSON | `-result.json` | -| 監視の結果ファイル | 担当 1 者の最後の監視の結果(P1) | `-monitor.json` | -| 監視の記録 | 監視の結果を追記だけで積む(P1) | `monitor-outcomes.jsonl` | -| 利用上限 | 担当の CLI の月間・週間の利用枠に達し、起動し直しても解けない状態 | 理由 `usage_limit`。監視の状態は早期の致命 `EARLY_ERROR`(終了コード 4) | -| CLI の上限 | 担当の CLI 自身の実行時間の上限(agy の `--print-timeout`) | 理由 `cli_timeout`。監視の状態は結果なし `NO_RESULT`(終了コード 3) | -| 結果ファイル無し | 結果ファイルが無く、理由の文言も無い | 理由 `missing` | -| 読めない結果 | 結果ファイルがあるが JSON オブジェクトとして読めない | 理由 `unparsable` | -| 誤りの終わり | 収束ループ全体を誤りとして終える | 状態ファイルの `final=error`。判定の終了コード 1 | -| 結果の取り込み / 判定 / 報告の表 | cross-review の状態の操作の 3 コマンド | `state.py read-result` / `judge` / `report` | -| 結末の共通層 | 2 つ以上の Skill が使う部品の置き場所にある、結末の語彙と読み取り | `plugins/ndf/scripts/lib/monitor_outcome.py` | - -## 対象範囲 - -変えるのは共通層の 3 ファイルと cross-review で、cross-refactoring は契約だけを決める。 - -含む: - -| 場所 | 扱うもの | -| --- | --- | -| 共通層 `plugins/ndf/scripts/lib/monitor_outcome.py` | 理由の語彙 2 つの追加、結末を 1 つの値として読む関数、起動し直しの可否 | -| 共通層 `plugins/ndf/scripts/lib/monitor.py` | 利用上限と CLI の上限の文言の検知、理由を持つ結末、プロセスグループへの停止 | -| 共通層 `plugins/ndf/scripts/lib/launch-cli.sh` | CLI を独立したプロセスグループで起動する | -| cross-review `scripts/state.py` | `read-result` の結果なしの理由と `monitor_detail`、`judge` の理由の出力と利用上限での停止、`report` の表 | -| cross-review `docs/` | 理由の表、上限に当たった場合の見分け方 | -| テスト | 共通層と cross-review のテスト | - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| cross-refactoring の `gitfacts.read_result` と 3 つの取り込み(`merge-apply` / `merge-fix` / `merge-final-fix`)の実装 | #728(G4)。この文書は契約だけを決める(設計文書の「両 Skill が従う契約」) | -| 投稿の後に打ち切られた担当の記録と重ねての投稿(`prior_review_url`、記録だけの起動) | #583 → #730(G5)。既存の設計の決定 18・AC63〜AC67 はそちらへ移る | -| 起動し直した担当を初回と同じ経路(証拠集約を含む)に通す骨組みの変更 | 既存の設計の決定 19・AC67。投稿の重なりと同じ骨組みの行を触るため #730(G5)へ渡す | -| 利用上限の担当を外して残りの担当で回す | #478(D-B) | -| 反証の取り直し(`critique-round.sh`)で理由を見ること | 既存の設計の「未確認のまま残ること」10。直さない | -| codex のモデルの 404 を理由として区別すること | #461(マイルストーン 06)。この語彙では `early_error` か `missing` に落ちる | -| 監視の終了コードと標準出力の変更 | 前提 6 | -| 型・クラスの新設のうち、画面・永続データベース・OpenAPI に当たるもの | この変更に画面と API は無い。永続データは状態ファイルと監視の結果ファイル(JSON)で、設計文書の「データ構造」が表で持つ | -| `CHANGELOG.md` と版数 | 配布の工程が書く | - -## 前提 - -利用上限の文言の実物は 2 つで、配布済みの P1 / P2 の上に載せる。 - -| # | 前提 | -| --- | --- | -| 1 | claude の利用上限の文言の実物は `"api_error_status":429` を含む行である(#647 の本文)。err.log と stdout.log のどちらに出るかは未確認のため、両方を見る | -| 2 | kiro の利用上限の文言の実物は err.log の `Monthly request limit reached` である(#619 の本文、PR #601 の round 2) | -| 3 | P1(監視の結果ファイル `-monitor.json` と `monitor_outcome.read_outcome`)と P2(`limits.py`、`--phase`)は v10.13.0 で `develop` に入っている。この変更はその上に載せる | -| 4 | cross-refactoring の側の実装(`gitfacts.read_result` と 3 つの取り込み)は #728(G4)が行う。この変更が決めるのは `read_result` が従う契約だけである | -| 5 | 利用上限で止まった担当を外して残りの担当で回す判断は #478(D-B)が持つ。この変更は「同じ担当を起動し直さずに止めて理由を報告する」までである | -| 6 | 監視の終了コード 0〜6 と標準出力の 13 個のキーは変えない(既存の設計の決定 16。G4 の骨組みがこれを前提にする) | - -## 影響 - -監視の終了コードと標準出力は変わらず、増えるのは理由の値と状態ファイルの鍵である。 - -| 対象 | 影響 | -| --- | --- | -| `monitor.py` の終了コードと標準出力 | 変わらない。監視の結果ファイルと記録の `reason` に 2 つの値が増える | -| `monitor_outcome.REASONS` | 6 語から 9 語になる。読む側(`run_metrics.py` の `--by reason`)は値を集計するだけで、語彙の一覧を持たないため変更なし | -| 状態ファイル | `rounds[-1].<担当>` に `monitor_detail` が増える(結果なしのときだけ)。`no_result_reason` の値が 3 種類から 10 種類になる | -| `state.py judge` | `NO_RESULT_REASONS` の行が増える。利用上限では起動し直さず 1 で終わる。0 / 2 / 7 / 8 の意味は変わらない | -| `state.py read-result` | 終了コードは変わらない。`no_result_reason` の値が増える | -| `gitfacts.read_result` | この変更では触らない。契約(結果なしを値で返す)を G4 が実装する | -| 担当の CLI のプロセス | 独立したプロセスグループで動く。監視の停止がグループへ届く | -| 待ち時間の最悪値 | 利用上限では起動し直さないため、1 ラウンドあたり監視の上限 1 回分(レビュー 1200 秒)短くなる | - -## 非機能の条件 - -運用・保守性、セキュリティ、システム環境の 3 つを条件にする。 - -| 大項目 | 条件 | -| --- | --- | -| 運用・保守性 | 結果なしの理由が、状態ファイル(`no_result_reason`)・監視の結果ファイル・監視の記録・実行の要約の `launches[]` の 4 か所で同じ語彙で読める。理由の語彙を足すときに変える場所は `monitor_outcome.py` の 1 か所である | -| セキュリティ | `monitor_detail` は err.log の抜粋(最大 200 文字)で、作業ツリーの中の状態ファイルにだけ残る。実行の要約には入れない(既存の設計の決定 7 のまま) | -| システム環境 | macOS の bash 3.2 でも AC19 が成り立つこと(未確認。設計文書の「未確認のまま残ること」) | - -## 前提とする取り決め - -プロジェクトの規約のうち、この変更が従うものを 3 つ挙げる。 - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 2 つ以上の Skill が使う部品は `plugins/ndf/scripts/lib/` に置く(`plugins/ndf/scripts/lib/README.md` の「置いてよいもの・いけないもの」)。結末を読む関数は両 Skill が使うため共通層に置く | -| コーディング規約 | 外部コマンドとシグナルの挙動、正規表現の一致範囲は書く前に実行して確かめる(`AGENTS.md` の DO)。状態ファイルの鍵は追加だけで、既存の鍵の意味を変えない | -| テスト戦略 | 監視と起動は、PATH へ置いた偽の CLI を実プロセスとして動かす既存の形(`cross-review/tests/test_monitor_*.py`)。結末の読み取りは一時ディレクトリに監視の結果ファイルと結果ファイルを置いて単体に試す。`judge` / `report` は状態ファイルを作って呼ぶ | - -## 境界 - -常に行うこと、確認してから行うこと、行わないことを分ける。 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査、新しい文言を実物のログの形で試すこと | -| 確認してから行う | 利用上限で起動し直さない判断(AC15)。理由の語彙の追加(G4 が読む) | -| 行わない | cross-refactoring の取り込みの実装(G4)、投稿の重なりと骨組みの経路の変更(G5)、担当を外して回す判断(D-B)、監視の終了コードの変更 | - -## 検証手段 - -テスト・配布物の同期・定義の検査と、2 つの issue の再現の手動確認で確かめる。 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認(#619 の再現) | err.log に `Monthly request limit reached` を書く偽の kiro を PATH に置いて cross-review を 1 ラウンド回し、`judge` が 7 を返さず `NO_RESULT_REASONS='kiro=usage_limit'` を出して 1 で終わる | -| 手動確認(#584 の再現) | #584 の本文の再現スクリプト(3 秒後に子が書く `bash -c`)を `launch-cli.sh` 経由で起動し、`_kill_pid` の後に `late-result.json` が無い | - -## 受け入れ条件 - -24 件を 5 つの群に分ける。文言の一覧は設計文書の「入出力の契約」の「検知の文言」にある。 - -語彙と検知(監視、#619): - -- [ ] AC1: `monitor_outcome.REASONS` は次の 9 語である。`reason_for(status)` の 6 つの状態に対する返り値は変更前と同じである - - | 既存の 6 語 | 足す 3 語 | - | --- | --- | - | `ok` / `timeout` / `stalled` / `early_error` / `missing` / `pidfile_bad` | `usage_limit` / `cli_timeout` / `unparsable` | - -- [ ] AC2: err.log に `Monthly request limit reached` の行が出ると、監視は担当を止めて `EARLY_ERROR`(終了コード 4)を返す。監視の結果ファイルの `reason` は `usage_limit` である -- [ ] AC3: err.log(全担当)か stdout.log(claude だけ)に、`"api_error_status"` と `429` を `:` で結んだ行が出たときも AC2 と同じである。`reason` は `usage_limit` になる。`:` の前後の空白の有無は問わない -- [ ] AC4: 既存の一致のうち `quota exceeded` / `rate limit exceeded` / `HTTP/<版> 429` の `reason` は `usage_limit` になる。`HTTP/<版> 401` / `HTTP/<版> 403` とそれ以外の致命の一致は `early_error` のままである -- [ ] AC5: 担当が結果ファイル無しで終わり、err.log に `print timeout after <時間> with turn in progress` がある場合を扱う。監視は `NO_RESULT`(終了コード 3)を返し、`reason` は `cli_timeout` である。同じ文言があっても結果ファイルがあれば `OK` / `ok` である -- [ ] AC6: AC2〜AC5 の文言が err.log で markdown の表の行・引用・バッククォート・grep 形式の引用の中にあるときは一致しない。claude の stdout.log は JSON 向けの照合で見るため、この除外を掛けない -- [ ] AC7: `usage_limit` / `cli_timeout` は監視の結果ファイルと監視の記録の `reason` に入る。監視の標準出力の 13 個のキーと値の型、終了コードは変更前と同じである - -結末を 1 つの値として読む(共通層、#729): - -- [ ] AC8: `monitor_outcome.read_launch_outcome(tmp_dir, stem, result_path)` を呼ぶ。結果ファイルが JSON オブジェクトとして読めるとき、返り値はその辞書を `payload` に持つ。`reason` は `None`、`relaunch_same_agent` は `True` である。監視の結果ファイルの `reason` が何であっても同じである -- [ ] AC9: 使える結果が無いとき、`reason` は次の表で決まる - - | 監視の結果ファイルの `reason` | 結果ファイル | `reason` | - | --- | --- | --- | - | `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` | 問わない | その値 | - | `ok` / `missing` / ファイルが無い・読めない | 無い、または空 | `missing` | - | `ok` / `missing` / ファイルが無い・読めない | あるが JSON として読めない、または JSON オブジェクトでない | `unparsable` | - -- [ ] AC10: `relaunch_same_agent` は `reason` が `usage_limit` のときだけ `False` で、それ以外の理由では `True` である。`monitor_outcome.relaunch_same_agent(reason)` も同じ値を返す -- [ ] AC11: `read_launch_outcome` は `SystemExit` を投げず、標準出力・標準エラーへ何も書かない。監視の結果ファイルがあれば `monitor` にその辞書、無ければ `None` を持ち、`detail` に監視の `detail`(無ければ読めなかった理由の 1 文)を持つ -- [ ] AC12: 理由の一覧と起動し直しの可否の表を持つのは `plugins/ndf/scripts/lib/monitor_outcome.py` だけである。`plugins/ndf/skills/` の下に `usage_limit` を含む条件分岐(`== "usage_limit"` / `in (...)` の形)は無い - -cross-review が値を読む(#619): - -- [ ] AC13: `read-result` は使える結果が無いとき、`no_result_reason` に AC9 の `reason` を記録する。監視の結果ファイルがあれば `rounds[-1].<担当>.monitor_detail` に監視の `detail` を残す。終了コードは変更前と同じ(`unparsable` は 3、それ以外は 1)である -- [ ] AC14: `judge` は結果なしの担当があると、標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'` の 1 行を出す -- [ ] AC15: 結果なしの担当の理由に `relaunch_same_agent` が偽のもの(`usage_limit`)があるとき、`judge` は起動し直さない。`final=error` として終了コード 1 で終わり、標準エラーに担当・理由・`monitor_detail` が出る -- [ ] AC16: 理由がすべて起動し直してよいものなら、`judge` は変更前と同じく終了コード 7 で `RELAUNCH_AGENTS` を返し、2 度目の結果なしで `final=error` になる。終了コード 0 / 2 / 8 の枝は変更前と同じである -- [ ] AC17: `state.py report` のラウンド表で、結果なしの担当は `kiro=NO_RESULT(usage_limit)` の形で出る -- [ ] AC18: `docs/01-state-and-review.md` の理由の表に 10 個の理由が載る。10 個は AC1 の 9 語から `ok` を除いた 8 語に、`no_verdict` / `not_posted` を足したものである。`docs/03-review-output.md` の「monitor.py が誤って kill する場合の手順」に、上限に当たった場合の見分け方が載る。見分け方は `reason` と `monitor-outcomes.jsonl` の読み方である - -止めた後に書かせない(#584): - -- [ ] AC19: `launch-cli.sh` で起動した CLI のプロセスは、自分の pid をプロセスグループの番号に持つ -- [ ] AC20: 3 秒後に子プロセスが結果ファイルを書く CLI を監視の上限で止めると、4 秒待っても結果ファイルが無い -- [ ] AC21: 対象の pid がプロセスグループの先頭でないとき、監視はその pid だけを止め、監視自身のプロセスグループへシグナルを送らない - -退行しないこと: - -- [ ] AC22: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る。既存の `test_monitor_*.py` と `test_monitor_outcome_unit.py` は変更せずに通る -- [ ] AC23: 「検証手段」の配布物の同期と定義の検査の 3 つのコマンドが、終了コード 0 で終わる -- [ ] AC24: cross-review の `SKILL.md` の骨組みの行は変えない。骨組みとは、レビューの起動 → 待ち → `read-result` → `judge` → 7 で起動し直し → 8 で `flush` の並びである。判定の終了コードの分岐が増えない - -## 未決 - -1 件。決めるのは G4 の設計である。 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| `gitfacts.read_result` を共通の関数の薄い包みとして残すか、呼び出し側が共通の関数を直接呼ぶか | G4(#728)の設計 | G4 の設計 Pull Request | - -## 既存の受け入れ条件との対応 - -既存の設計(PR #666)の P3 の受け入れ条件を、この文書のどこが引き継ぐかを示す。 - -| 既存 | この文書 | 変わったこと | -| --- | --- | --- | -| AC50 | AC2 | 同じ | -| AC51 | AC3 | 同じ | -| AC52 | AC4 | 同じ | -| AC53 | AC5 | 結果ファイルがあれば `ok` になることを明記 | -| AC54 | AC6 | 同じ | -| AC55 | AC9 + AC13 | 理由の表を `state.py` の規則から共通層の関数の規則へ移した。`unparsable` を共通の語彙に入れた | -| AC56 | AC14 | 同じ | -| AC57 | AC15 | 判定の条件を「`usage_limit` を含む」から「起動し直しの可否が偽」へ変えた。値は同じ | -| AC58 | AC16 | 同じ | -| AC59 | AC17 | 同じ | -| AC60〜AC62 | AC19〜AC21 | 同じ | -| AC63〜AC67 | — | #730(G5)へ | -| AC68 | AC18 | 理由が 10 個になった(`unparsable` を足し、`no_verdict` / `not_posted` を残す) | -| AC69 | AC18 | 同じ | -| — | AC1、AC8、AC10〜AC12 | 新設(結末を 1 つの値として読む契約) | -| — | AC24 | 新設(骨組みを変えない) | - -## 依頼(原文) - -3 つの issue の本文を、書かれたままの形で引く。 - -### #729(根本原因の親) - -> **担当 1 回の起動の結末(結果ファイルの有無と、監視が打ち切った理由)を読み、起動し直してよいかを返す契約。** -> -> - 結末の語彙は `plugins/ndf/scripts/lib/monitor_outcome.py`、早期の致命の検知は `monitor.py` の `EARLY_ERROR_FATAL` にある -> - cross-review の `state.py`(`_read_review_result_file`)も cross-refactoring の `refactor_lib/gitfacts.py`(`read_result`)も語彙を読まず、結果ファイルの有無だけで判断する(Skill 側で `monitor_outcome` を読むコードは 0 件) -> - `EARLY_ERROR_FATAL` は `Monthly request limit reached` や `"api_error_status":429` の行に一致しない -> -> `read_result` が `die` で進行を決める向きは #728 が持つ。 -> -> ## 採る手 -> -> - 移動(`move_responsibility`): 結果なしの判断を、各 Skill の結果ファイルの読み取りから共通層の結末へ移す -> - 新設: 利用上限(`usage_limit`)の語彙と検知の文言 -> -> ## 完了条件 -> -> - 両 Skill が共通層の結末を読み、利用上限を理由として出し、同じラウンドでの起動し直しを止める -> - 利用上限の実際の出力(上の 2 形式)を検知することを検査が確かめる -> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる - -### #619 - -> **監視の結果(状態と `detail`)を担当ごとにファイルへ残し、`read-result` が `NO_RESULT` の理由に使う。** -> 理由は、監視の上限(timeout)・CLI 自身の上限(cli_timeout)・利用上限(usage_limit)・早期エラー(early_error)・未投稿(not_posted)・結果ファイル無し(missing)を区別する。 -> -> - 利用上限は起動し直しても解けないため、理由が `usage_limit` のときは起動し直さずに止めて報告する判断もここに置ける(cross-refactoring の #647 と共通) - -### #584 - -> 移動(`move_responsibility`)。停止の単位を pid からプロセスグループへ移す。`launch-cli.sh` は CLI を新しいプロセスグループとして起動し、`_kill_pid` はそのグループへ SIGTERM / SIGKILL を送る。 -> -> 止めた理由を読む側(結果なしの理由の語彙)は、担当 1 回の起動の結末を共通の語彙で読む #729 が持つ。 - -## この文書の位置づけ - -この文書は「何を満たすか」だけを扱う。設計は [issue-729-619-584-design.md](issue-729-619-584-design.md) にある。 - -**既存の要求 [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md) の P3 を置き換える。** 置き換える受け入れ条件は AC50〜AC62 と AC68〜AC69 で、対応は「既存の受け入れ条件との対応」にある。P1(#662)と P2(#598 #537)は v10.13.0 で配布済みで、この文書は触らない。#583 は #730 の設計が持つ。 diff --git a/issues/issue-730-583-design.md b/issues/issue-730-583-design.md deleted file mode 100644 index aa3b8d8d..00000000 --- a/issues/issue-730-583-design.md +++ /dev/null @@ -1,446 +0,0 @@ -# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのをレビューを回す側だけにし、途中で止まっても二度書かない(#730 #583 の設計) - -## 目的 - -レビューを任された担当が、PR へ指摘を書き込んでから結果を残す前に止まると、PR には指摘が出ているのに記録には残らない。レビューを回す側は記録だけを読んで「結果なし」と判定し、同じ担当をもう一度起動する。利用者は 1 つの論点に 2 つのスレッドを見て、両方へ返信する。 - -原因は、書き込みと記録を別々の相手が行っていることである。GitHub と git へ書くのをレビューを回す側 1 か所に集め、担当は結果を残すだけにする。書き込みと記録が同じ手順の中で続けて起きるため、途中で止まっても同じものを二度書き込まない。 - -## 用語の対応表 - -| 業務用語 | 識別子・実体 | -| --- | --- | -| レビューを回す側 | `plugins/ndf/skills/cross-review/scripts/state.py` と、それを呼ぶ `SKILL.md` の骨組み | -| 担当 | 1 ラウンドで 1 者ぶんのレビューを行う CLI。`launch-reviewer.sh` が起動する | -| 席 | そのラウンドで担当が入る枠。`claude` / `codex` / `agy` / `kiro` と、同じランタイムの 2 つ目の `-2`〜`-9` | -| 修正の担当 | 指摘を直すサブエージェント、または単独で動く `fix` の実行 | -| 結果ファイル | `<席>-review-pr<番号>-result.json` | -| 指摘の控え | `<席>-review-pr<番号>-round-payload.json` | -| 修正の結果ファイル | `fix-pr<番号>-result.json` | -| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py` | -| 二度書かないための照合 | `post_queue.already_posted()` / `posted_match()` | -| 結果ファイルを投稿へ変える層 | 新設する `plugins/ndf/scripts/lib/result_posts.py` | -| 指摘の取り込み | `state.py read-result` | -| 修正の取り込み | `state.py merge-fix` | -| 最終スイープの照合 | `state.py verify-sweep` | -| 投稿の種別 | `pr-comment` / `review-post` / `review-reply` / `thread-resolve` | - -## 機能一覧 - -| # | 機能 | 受け入れ条件 | -| --- | --- | --- | -| 1 | 担当のプロンプトから投稿の手順を外し、結果だけを書かせる | AC1〜AC4 | -| 2 | 指摘の取り込みが、控えからレビューを組み立てて送る | AC5・AC6・AC16・AC17 | -| 3 | 修正の取り込みが、返信・決着・まとめを送り、修正を送信して照合する | AC7〜AC9 | -| 4 | 同じものを二度書かない照合を、4 種別すべてに掛ける | AC10〜AC14 | -| 5 | 申告と実数の突き合わせをやめ、送信の応答を記録にする | AC15・AC32 | -| 6 | 起動し直しを初回と同じ経路へ通す | AC18〜AC20 | -| 7 | `fix` の書き込みを 1 つの実装にまとめる | AC21・AC22 | -| 8 | 文書の決定を書き直す | AC25〜AC29 | - -## なぜ変えるか - -**書き込みと記録が別の相手にあるため、片方だけが残る状態を作れる。** 担当は GitHub へ投稿し、その後に結果ファイルを書く。この 2 つの間で担当が止まると、投稿は残り記録は残らない。レビューを回す側は記録しか見ないので、投稿があることを知らないまま同じ担当を起動する。 - -**待ち行列は用意されているのに、担当の投稿がそこを通らない。** 上限で拒まれた投稿を残して後から流す仕組みはレビューを回す側にあるが、担当が自分で送るため、上限に当たった投稿はその場で失われる。 - -**送信の報告を確かめる手段がない。** 修正の担当はブランチ名を指定して送信する。作業ツリーが切り離された頭で作られている場合、この指定では現在の頭が送られないまま終了コード 0 で終わる。取り込む側は報告をそのまま記録する。 - -書き込みを 1 か所へ集めると、この 3 つが同じ場所で解ける。投稿は待ち行列を通り、送信は現在の頭を指定して行い、送った結果がそのまま記録になる。 - -## 実測 - -2026-09-21、`origin/develop`(`c853691a`)で確かめた。 - -| 何を測ったか | 値 | 確かめ方 | -| --- | --- | --- | -| 投稿の待ち行列の種別 | 4 つ(`pr-comment` / `review-post` / `review-reply` / `thread-resolve`) | `post_queue.py` の `KINDS` | -| そのうち、積む側が実装されている種別 | 1 つ(`pr-comment`。`rotate-pr.sh:53` の 1 か所だけ) | `grep -rn -- "--kind" plugins/ndf` の結果から、手順・テスト・別スクリプトの行を除いた | -| レビューを回す側の `gh` の呼び出し | 12 か所。書き込みの引数(`--method` / `-X POST` / `mutation`)を持つ行は 0 | `grep -c '"gh"'` と `grep -n -- '--method\|-X POST\|mutation'` | -| 担当のプロンプトに書かれた投稿の指示 | 4 か所(`launch-reviewer.sh:95,158,204,206`) | `grep -n "gh api"` | -| 修正の担当が行う書き込み | 4 種類(返信・決着・まとめ・送信)。`fix/SKILL.md:294,311,328,363` ほか | `grep -n "gh api\|gh pr comment\|git push"` | -| 重なりが出た実行 | PR #578 の round 1。1 回目にインライン 4 件、起動し直した 2 回目に 1 件。記録に入ったのは 2 回目の 1 件だけ | #583 の本文 | - -**待ち行列の受け皿だけが先にあり、積む側が無い状態である。** レビューの投稿を積む呼び出しは、配布物のどこにも無い。送信の確認(`_confirm_flushed`)はレビューの投稿の種別を読む形で用意されている。 - -## 決定の記録 - -見出しは「何のために何を決めたか」を書く。判断の材料は各節の本文にある。 - -### 決定 1: 並行する設計と 1 つのファイルで競合しないよう、設計文書は親課題の名前で新設し、既存の設計文書の本体は書き換えない - -同じ時期に 4 つの設計が同じ既存文書を指している。本体を書き換えると、先にマージした側の記述が後から入る側の差分で戻る。**足すのは、置き換え先を指す段落だけにする。** 足す先は、置き換わる受け入れ条件を持つ要求の文書と、その確かめ方を持つ契約の文書の 2 本である。 前例は #727 / #728 / #729 / #732 の 4 つである。 - -### 決定 2: 片方だけが残る状態を作らないため、GitHub と git への書き込みをレビューを回す側だけが行う - -担当は指摘の控えと結果ファイルを書き、レビューを回す側がそこから投稿を組み立てて送る。修正の担当はコミットまでを行い、取り込む側が送信する。 - -採らない案と理由: - -- **担当の投稿の後に、レビューを回す側が実物を照合して記録を補う。** 照合は GitHub への問い合わせを増やし、問い合わせが上限で失敗すると同じ食い違いが残る。書き込みを増やさずに読み取りを増やしても、原因の場所は動かない -- **担当に待ち行列へ積ませ、レビューを回す側が流す。** 担当は自分の作業領域しか持たず、待ち行列はレビューを回す側の作業ツリーにある。置き場所を担当へ開くと、巻き直しで捨てる範囲が変わる - -### 決定 3: 投稿の本文をレビューを回す側の応答へ載せないため、投稿は結果ファイルを読んだプロセスの中で組み立てる - -取り込みの部分命令が、控えのファイルを読み、投稿の項目を組み立て、待ち行列へ積み、流すところまでを 1 つのプロセスで行う。**部分命令の引数に本文を置かず、標準出力にも本文を出さない。** 応答に出るのは件数・URL・状態だけである。 - -採らない案と理由: - -- **控えの本文を引数や標準入力で渡す。** 収束ループを駆動している側の応答に本文が載る。文脈の予算の決めに反する -- **待ち行列のコマンドに種別ごとの引数を足して、手順から呼ぶ。** 引数の数が種別ごとに増え、手順の行が長くなる。組み立てはプロセスの中に閉じるほうが、手順の行が 1 本で済む - -### 決定 4: 投稿と記録の間に新しい食い違いを作らないため、「読んで記録する」と「投稿する」を 1 つの部分命令に閉じる - -取り込みの部分命令の名前と、骨組みの行の並びは変えない。中の順序を「控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」にする。 - -**別の部分命令に分けない。** 分けると「投稿したが記録していない」に加えて「記録したが投稿していない」がもう 1 つ増える。1 つに閉じれば、途中で止まった状態は「送れていない」か「送れたが記録が無い」の 2 つになる。 - -**止まり方は 2 つで、立て直し方が違う。** 送信に成功した項目は記録より先に待ち行列から取り除かれるため、**待ち行列に項目が残ることに頼らない。** - -| 止まった場所 | 待ち行列の項目 | 立て直し | -| --- | --- | --- | -| 送る前(上限などで送れていない) | 残る | 判定の終了コード 8 の枝が流し直す | -| 送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。結果ファイルが残るため投稿を組み立て直せ、照合が先客を見つけるので増えない | - -採らない案と理由: - -- **送信の応答を控えてから項目を消す形へ、待ち行列の流し方を変える。** すでに動いている投稿の経路(巻き直しのコメント)の契約まで変わる。取り込みをもう一度呼べば同じ状態になるため、変えずに済む - -### 決定 5: 途中で止まった実行を流し直しても増やさないため、二度書かない照合を 4 種別すべてに掛ける - -投稿の待ち行列は、送る前に同じものが先にあるかを照合する仕組みを持つ。レビューの投稿・返信・決着・まとめの 4 種別すべてでこれを通す。**照合の鍵は、本文の先頭行のうちラウンドと席までの前方一致とする。** 判定の語(`APPROVE` / `REQUEST_CHANGES` / `COMMENT`)と、レビューの状態を鍵に含めない。含めると、起動し直して判定が変わったときに別の投稿と読まれ、二重に投稿する。 - -| 種別 | 照合の鍵 | -| --- | --- | -| `review-post` | 投稿者と、本文の先頭行の `## 🤖 cross-review \| round \| <席> \|` までの前方一致(判定の語を含めない)。ラウンドの開始時刻を持つときは、それ以降に出たレビューに限る | -| `review-reply` | 返信先の指摘の識別子と、本文の先頭 80 文字 | -| `thread-resolve` | スレッドの識別子と、すでに決着しているかどうか | -| `pr-comment` | 投稿者と、本文の先頭 80 文字(ラウンドを含む) | - -投稿者はどの席でも同じになる。ラウンドと席を先頭行に持たせることで、同じ投稿者の別の投稿と区別できる。 - -**レビューの照合は、そのラウンドが始まった時刻(状態ファイルの `rounds[-1].started_at`)以降に出たレビューに限る。** ラウンドの番号は実行ごとに 1 から数え直すため、収束した PR へ回し直すと、ラウンドと席だけの鍵が前の実行のレビューに一致し、新しい指摘を送らずに前の実行の投稿を送れた先として記録する。開始時刻で絞っても同じ実行の中の送り直しは従来どおり見つかり、開始時刻かレビューの時刻を読めないときはラウンドと席だけの照合へ落とす。 - -### 決定 6: 投稿済みで記録なしの状態が起きなくなるため、投稿済みのレビューを探して記録だけの起動をする決めを取り下げる - -担当が投稿しなくなるため、探す対象が無い。**GitHub からレビューを探す照会も、記録だけを行うプロンプトも作らない。** 止まった担当を起動し直すときは、初回と同じプロンプトをそのまま使う。 - -この決めは、担当が投稿する前提のうえで、投稿済みの担当をもう一度投稿させないために置かれていた。前提のほうを変えるため、対処のほうは要らなくなる。 - -### 決定 7: 起動し直した担当の指摘が根拠の検証と反証を通るよう、起動し直しを初回と同じ経路へ通す - -骨組みの起動し直しの枝を、繰り返しの先頭へ戻す形にする。繰り返しは「起動 → 待ち → 取り込み → 根拠の検証 → 反証 → 判定」である。**経路が 1 本なら、証拠の集約を飛ばす枝が構造として無くなる。** 待ち行列に残りがあるときの枝(判定の終了コード 8)は、2 度目を含む各判定の直後に置き、7 の判定より先に見る順序を保つ。 - -### 決定 8: 申告と実物の食い違いを無くすため、申告件数と実数の突き合わせをやめ、送信の応答を記録にする - -投稿するのがレビューを回す側になるため、申告を受け取る相手がいない。記録に入る投稿の URL は送信の応答から取り、件数は送信に成功したインラインの数から取る。**レビューの投稿の応答は件数の項目を持たない。** **申告件数を GitHub の実数と比べて中断する処理は取り除く。** - -この突き合わせは、担当が投稿したことを確かめるために置かれていた。投稿する側と記録する側が同じになるため、確かめる対象が無くなる。 - -### 決定 9: 差分の外を指す指摘を落とさないため、拒まれた指摘は投稿する側が本文へ退避する - -インラインの投稿が差分の外を理由に拒まれたとき(HTTP 422)、投稿する側がその指摘を本文の末尾へ移し、レビューをもう一度送る。退避した指摘の `posted_to` は `body` として記録する。**退避した件数を取り込みの出力に出す。** - -**HTTP 422 のすべてを退避の契機にしない。** 応答の本文が行を解決できないことを示すときだけ退避する。それ以外の 422(判定の値の誤り、基準のコミットの誤りなど)は失敗として残す。区別しないと、別の不具合が退避として飲み込まれる。 - -採らない案と理由: - -- **投稿の前に、差分に含まれる行かどうかを判定して振り分ける。** 差分の範囲を別に取り寄せる必要があり、取り寄せが失敗したときの分岐が増える。拒まれてから退避すれば、判定の根拠は応答そのものになる - -### 決定 10: 送ったという報告と実物が食い違わないよう、送信は現在の頭を指定して行い、送った後に照合する - -修正の送信は取り込む側が `git push origin HEAD:<ブランチ名>` で行う。**ブランチ名だけを指定しない。** 作業ツリーが切り離された頭で作られている場合、ブランチ名だけの指定では現在の頭が送られないまま終了コード 0 で終わる。 - -送信の後、修正の結果ファイルが報告するコミットが、送り先のブランチの履歴に含まれることを確かめる。含まれなければ取り込みは失敗として止まる。 - -### 決定 11: 起動の経路で書き込みの担い手が変わらないよう、修正の書き込みを 1 つの共通層にまとめる - -返信・決着・まとめの投稿・送信の実装を、共通層の 1 つのまとまりに置く。置き場所は `plugins/ndf/scripts/lib/result_posts.py` である。cross-review から呼ぶときは修正の取り込みがこれを呼び、単独で使うときは同じまとまりを部分命令として直接呼ぶ。 - -```bash -python3 "$SCRIPTS/lib/result_posts.py" fix --repo <所有者>/<リポジトリ> --pr <番号> \ - --result <結果ファイル> --head <ブランチ名> --worktree <作業ツリー> -``` - -**待ち行列の置き場所は渡さない。** 作業ツリーの下の決まった名前から導く。リポジトリと作業ツリーを省いたときは、いまいるディレクトリから引く。 - -**新しい入口のスクリプトを足さない。** 待ち行列のまとまりが取り込み用の口と部分命令の口の両方を持つ形に前例がある。手順に書く行は 1 本で済み、実装は 1 つになる。 - -### 決定 12: 読み取り側が途中の内容を読まないよう、担当は結果を一時の名前で書いてから改名する - -担当は結果ファイルと指摘の控えを一時の名前で書き、書き終えてから正式の名前へ改名する。**改名の順序は、控えが先、結果ファイルが後である。** 結果ファイルが正式の名前で現れたことが、2 つとも揃った印になる。控えだけが正式の名前で結果ファイルが無い状態は、結果なしとして扱う。**読めた状態は書き終えた状態である**ことが成り立つため、取り込み側は内容の途中を読むことがない。担当が途中で止まれば正式の名前のファイルは現れず、結果なしとして扱われる。 - -### 決定 13: すでに 1 か所にある操作を動かさないため、PR の巻き直しの開閉は移さない - -PR の締めと作り直しと再開はすでにレビューを回す側が行っている。待ち行列に積まず、上限のときは待って同じ操作をやり直す形である。**この性質を変えない。** 巻き直しは順序が意味を持ち、待ち行列へ積むと後続の投稿と並び替わる。 - -### 決定 14: 自分の Pull Request への投稿が拒まれないよう、判定の格下げを投稿する側が送信の時点で行う - -自分の Pull Request には変更を求めるレビューを送れない(HTTP 422)。担当が投稿していたときはその場で格下げしていたため、投稿する側が引き取る。**格下げるのは送った形だけで、本来の判定は落とさない。** 収束の判定は本来の判定を読むため、格下げがループの続き方を変えない。 - -## データ構造 - -### 結果ファイル(レビュー) - -| 項目 | 変更前 | 変更後 | -| --- | --- | --- | -| `event` | 担当が書く | 変わらない | -| `posted_as` | 担当が書く(`APPROVE` / `REQUEST_CHANGES` / `COMMENT`) | 投稿する側が、送信の時点で自分の Pull Request かどうかを見て決める | -| `comments_count` | 担当が申告する | 投稿する側が、送れたインラインの件数で埋める | -| `review_url` | 担当が投稿の応答から書く | **担当は書かない。** 投稿する側が送信の応答から書く | -| `by_severity` | 担当が書く | 変わらない | -| `post_error` | 担当が書く | **無くなる。** 投稿の失敗は待ち行列の側に残る | - -### 指摘の控え - -形は変えない。`posted_to` の決め方だけが変わる。 - -| 項目 | 変更前 | 変更後 | -| --- | --- | --- | -| `path` / `line` / `body` / `severity` | 担当が書く | 変わらない | -| `evidence` / `falsification` / `suggested_check` | 担当が書く | 変わらない | -| `posted_to` | 担当が、自分が投稿した先を書く | **投稿する側が、送れた先を書く**(`inline` / `body`) | - -### 記録(ラウンドごとの担当の欄) - -**この表は足す鍵と書き方が変わる鍵だけを並べる。** 収束の判定が読む既存の鍵(`intent` / `posted_as` / `by_severity`)はそのまま残る。 - -| 鍵 | 何が入るか | -| --- | --- | -| `review_url` | 送信の応答が返した URL。流し直しで先客が見つかったときは、その先客の URL | -| `queued` | 上限などで送れず待ち行列に残っているとき、真になる | -| `posted_inline` | インラインとして送れた件数 | -| `posted_body` | 差分の外を理由に本文へ退避した件数 | - -**`prior_review_url` の鍵は作らない**(決定 6)。 - -### 修正の結果ファイル - -形は変えない。取り込む側が読む項目と、それを何に使うかだけを決める。 - -| 項目 | 取り込む側が何に使うか | -| --- | --- | -| `fix_commit` | 送信の後に、送り先の履歴に含まれることを確かめる(決定 10) | -| `resolved_threads` | 決着の投稿を積む | -| `deferred` / `rejected` | 返信の投稿を積む | -| `summary_comment_url` | **担当は書かない。** 投稿する側が、まとめの投稿の応答から書く | - -## 入出力の契約 - -### 結果ファイルを投稿へ変える層 - -| 関数 | 入力 | 出力 | -| --- | --- | --- | -| `review_posts(payload_path, result_path, repo, pr, round_no, seat, head_sha, is_own_pr)` | 控えと結果ファイルのパス、および自分の Pull Request かどうか | 待ち行列へ積む項目の列 | -| `fix_posts(result_path, repo, pr)` | 修正の結果ファイルのパス | 待ち行列へ積む項目の列 | -| `push_fix(worktree, head_branch, fix_commit)` | 作業ツリーと送り先とコミット | 送信の結果と照合の可否 | - -**本文は引数として渡さない。** どの関数もファイルのパスを受け取り、本文はまとまりの中だけで扱う。 - -**判定の格下げは、自分の Pull Request かどうかを受け取ったこの層が決める**(決定 14)。呼ぶ側は状態ファイルが持つ値をそのまま渡す。 - -### 取り込みの標準出力 - -```text -POSTED review_url=https://github.com/.../pull/793#pullrequestreview-... -INLINE=7 BODY=1 QUEUED=0 -FINDINGS=8 -``` - -本文は出さない。上限で送れなかったときは `QUEUED` が 1 以上になり、判定の終了コード 8 の枝が流し直す。 - -### 変える出力と変えない出力 - -**終了コードは変えない。** 取り込み・判定・報告のどれも、変更前と同じ値を返す。 - -**収束の判定と報告が読む変数も変えない**(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS`)。 - -**変わるのは取り込みの標準出力だけである。** 投稿の結果を表す行(`POSTED` / `INLINE` / `BODY` / `QUEUED`)と、取り込んだ指摘の件数(`FINDINGS`)を足す。判定が出す `NEW_FINDINGS` はこれとは別の変数で、変えない。 - -## 構成要素 - -**動くもの**(「処理の流れ」の図と手順に現れる 5 つ): - -| 構成要素 | 変える内容 | -| --- | --- | -| `plugins/ndf/scripts/lib/result_posts.py` | **新設。** 結果ファイルから投稿の項目を組み立て、送信と照合を行う。部分命令の口も持つ | -| `plugins/ndf/scripts/lib/post_queue.py` | レビューの投稿と返信と決着の照合の鍵を、決定 5 の表に合わせる。拒まれたときの区別(HTTP 422)を返す | -| `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` | プロンプトから投稿の手順を外す。控えと結果ファイルを一時の名前で書いてから改名させる | -| `plugins/ndf/skills/cross-review/scripts/state.py` | 指摘の取り込みが投稿を行う。申告と実数の突き合わせを取り除く。修正の取り込みが返信・決着・まとめと送信を行う | -| `plugins/ndf/skills/cross-review/SKILL.md` | 設計方針の表の投稿の行。起動し直しの枝を繰り返しの先頭へ戻す | - -**書き直す文書**(振る舞いを持たないため、流れの図には現れない): - -| 文書 | 変える内容 | -| --- | --- | -| `plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md` | 修正の手順から担当の送信の行を外す | -| `plugins/ndf/skills/cross-review/docs/03-review-output.md` | 直接投稿の決定を待ち行列を通す形へ書き直す | -| `plugins/ndf/skills/cross-review/docs/04-contracts.md` | 投稿の種別ごとの契約を載せる | -| `plugins/ndf/skills/cross-review/references/context-budget.md` | 工夫の一覧の 4 番目を書き直す | -| `plugins/ndf/skills/fix/SKILL.md` | 返信・決着・まとめ・送信の手順を、共通層を呼ぶ 1 行へ置き換える | - -## 置き場所 - -**結果ファイルを投稿へ変える層は共通層に置く。** cross-review と `fix` の両方から呼ぶためである。cross-review の配下に置くと、単独で `fix` を使う経路が cross-review に依存する。 - -**待ち行列の保存先は変えない。** レビューを回す側の作業ツリーの中にあり、巻き直しのときは作業ツリーごと捨てられる。 - -## 処理の流れ - -### 1 ラウンドのレビュー - -```mermaid -sequenceDiagram - participant M as レビューを回す側 - participant A as 担当 - participant G as GitHub - M->>A: 起動(投稿の手順を持たないプロンプト) - A->>A: 指摘の控えと結果を書く - A-->>M: 終了 - M->>M: 控えを読み、投稿を待ち行列へ積む - M->>G: レビューを送る - G-->>M: レビューの URL - M->>M: 送れたインラインを数え、URL と件数を記録し、指摘を取り込む -``` - -担当が控えを書く前に止まれば、レビューを回す側は積むものを持たないため、GitHub には何も増えない。起動し直しても重ならない。 - -### 途中で止まった実行の立て直し - -```mermaid -graph TD - A[取り込みが投稿を送る] --> B{送れたか} - B -->|送れていない| D[待ち行列に項目が残る] - D --> E[判定の 8 の枝が流し直す] - B -->|送れた| C{記録まで済んだか} - C -->|済んだ| J[次の段へ進む] - C -->|止まった| K[取り込みをもう一度呼ぶ] - E --> F{先客がいるか} - K --> F - F -->|いる| G[送らずに
先客の URL を記録する] - F -->|いない| H[送って記録する] -``` - -### 修正の取り込み - -1. 修正の担当がコミットまでを行い、結果ファイルを書く -2. 取り込む側が現在の頭を指定して送信する -3. 報告されたコミットが送り先の履歴に含まれることを確かめる。含まれなければ止まる -4. 返信・決着・まとめの投稿を待ち行列へ積んで流す -5. 投稿の応答(まとめのコメントの URL など)を記録へ書き戻す - -## 非機能の実現方式 - -| 条件 | どう満たすか | -| --- | --- | -| 応答の量 | 取り込みの標準出力を件数・URL・状態だけにする。本文はまとまりの中だけを通る | -| 上限への耐性 | 送れなかった投稿は待ち行列に残り、判定の終了コード 8 の枝が流し直す。担当をもう一度起動しない | -| 所要 | 担当のプロンプトから投稿の手順が消えるぶん、担当の実行が短くなる。監視の上限は変えない | -| 権限 | 担当の CLI に GitHub への書き込みの権限が要らなくなる | - -## テスト設計 - -| 受け入れ条件 | 何で確かめるか | -| --- | --- | -| AC1・AC2 | 担当のプロンプトを取り出し、投稿の手順の語が 0 件であることを見る | -| AC3 | 控えだけを正式の名前で置き、結果ファイルが無い状態で取り込みを呼び、結果なしとして扱われ、投稿が 0 件であることを見る | -| AC4 | 控えを書かずに終わる偽の担当で 1 ラウンドを回し、偽の `gh` が受けたレビューの投稿が 0 件であることを見る | -| AC5・AC6 | 取り込みの標準出力に、控えの本文の文字列が含まれないことを見る | -| AC7 | 修正の取り込みを呼び、返信・決着・まとめの 3 種別が待ち行列へ積まれることを見る | -| AC8・AC9 | 送信を偽装し、報告されたコミットが送り先に無いときに失敗することを見る | -| AC10・AC11 | 偽の `gh` が先客を返す状態で投稿を積んで流し、新しい投稿が 0 件で項目が取り除かれることを見る | -| AC12 | 投稿の後・記録の前で止めた状態から取り込みをもう一度呼び、レビューが増えず記録に先客の URL が入ることを見る | -| AC13・AC14 | 返信と決着とまとめについて、同じ項目を 2 度積んでも増えないことを見る | -| AC15 | 記録に入る URL が偽の `gh` の応答の値と一致し、件数が実際に送ったインラインの数と一致することを見る | -| AC16 | 偽の `gh` が HTTP 422 を返す状態で、応答の本文が行を解決できないことを示すときだけ本文へ退避して送り直し、示さないときは失敗として止まることを見る | -| AC17 | 控えに 1 件あり、インラインが 422 で全件退避された実行で、結果なしにならないことを見る | -| AC18・AC19 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | -| AC20 | 送信の後・記録の前で取り込みを止め、同じ取り込みをもう一度呼ぶ結合の経路で、レビューが 1 件しか増えないことを見る | -| AC21・AC22 | 共通層の部分命令を直接呼び、cross-review から呼んだときと同じ項目が積まれることを見る | -| AC23・AC24 | 終了コードと、収束の判定・報告が読む変数を見る既存のテストを変えずに通す | -| AC25〜AC29 | 変更した文書の行を読む検査 | -| AC32 | 自分の Pull Request の状態で投稿を組み立て、送った形が `COMMENT` で本来の判定が変わらないことを見る | -| AC30・AC31 | 検証手段の表のコマンド | - -**偽の `gh` を使う形は既存のテストにある**(待ち行列の照合と巻き直しの投稿)。同じ仕掛けを使う。 - -## 実測で決めた 4 件 - -設計の時点で未確認だった 4 件を、2026-09-22 に実物の `gh` で 1 度ずつ動かして決めた。対象は -この束の設計を載せた Pull Request #794(マージ済み、head `233f28ba`)である。拒まれた要求は -何も作らないため、確かめた跡は残っていない(レビューとレビューのコメントを引いて 0 件)。 - -### 差分の外を指す指摘が拒まれるときの応答の形 - -レビューの作成は**要求ごとに全件が拒まれる**。正しいインラインを混ぜても、一部だけが作られる -ことはない。終了コードは 1 である。 - -| 何を送ったか | 応答の `errors` | -| --- | --- | -| 差分に無いファイルのインライン 1 件 | `["Path could not be resolved"]` | -| 差分にあるファイルの、塊の外の行のインライン 1 件(正しいインライン 1 件と同時) | `["Line could not be resolved"]` | -| 塊の外・差分に無いファイル・塊の外の 3 件 | `["Line could not be resolved, Path could not be resolved, and Line could not be resolved"]` | -| 判定の値に知らない語 | `["Variable $event of type PullRequestReviewEvent was provided invalid value"]` | -| 基準のコミットに存在しない値 | `["The commitOID is not part of the pull request"]` | - -**退避の契機に使う語は `could not be resolved` である。** 判定の値の誤りと基準のコミットの誤りは -この語を含まないため、決定 9 のとおり失敗として残せる。 - -### 拒まれた応答から、どのインラインが原因かを 1 件ずつ特定できるか - -**特定できない。** 応答は語を `, ` と `and` でつないだ 1 つの文字列で、位置も識別子も持たない。 -拒まれた件数は語の数から読めるが、正しいインラインを混ぜると位置が対応しない。 - -**決定 9 の落とし先を採る。** 行やファイルを解決できないことを理由に拒まれた要求は、その要求の -インラインをすべて本文へ退避して送り直す。1 ラウンドの指摘は多くて 10 件前後、1 件の本文は -数百文字のため、すべてを退避しても数 KB に収まり、投稿の本文の上限(65536 文字)に対して余裕がある。 - -### すでに決着したスレッドをもう一度決着させたときの応答 - -**冪等である。** 決着の操作は成功し(終了コード 0)、決着済みであることを返す。失敗にならない。 -決定 5 の照合の鍵(スレッドの識別子と、すでに決着しているかどうか)が送信を止め損ねても、 -二重の決着が失敗にはならない。 - -### 投稿者のアカウントが席ごとに違う環境があるか - -**無い。** 作業環境の `gh` は 1 アカウントだけを持ち、配布物の中に席ごとの認証を切り替える口は -無い(`plugins/ndf/` を認証の環境変数で引くと 2 行あり、どちらも別の目的の注釈である)。 -要求の前提 1(投稿者アカウントは実質 1 つ)はそのまま成り立ち、照合の鍵に投稿者を含める形を変えない。 - -## 申し送り(並行する設計との境界) - -この束は並行する設計の中で **G5** と呼ぶ(#727 が G1、#732 が G2、#729 が G3、#728 が G4)。 - -| 相手 | 決めた契約 | -| --- | --- | -| #727(G1、PR #793) | 席の名前と、結果ファイル・控えの名前が席で組まれること、レビューの本文の先頭行が席を持つことを**この設計が前提にする**。決め方は G1 が持つ。触るファイル(`state.py` / `launch-reviewer.sh` / `SKILL.md`)が重なるため、後からマージする側が競合を解く | -| #729(G3、マージ済み) | 結果なしの理由の語彙を変えない。**投稿済みのレビューを探す鍵(`prior_review_url`)は作らない**ため、G3 が残した「鍵が無い = 無かった」の書き方に足すものは無い | -| #732(G2、PR #790) | 指摘の中身と数え方に触らない。#583 の収束の部分は G2 が塞いだ | -| #728(G4) | 触るファイルが重ならない | -| 子課題 #548 #350 #585 #676 | この設計で根本が動くため、現象が出なくなる見込みがある。**受け入れ条件は持たず、閉じるのは棚卸に任せる**(要求の「影響」) | - -## 置き換える既存の設計との対応 - -**この文書は[置き換える前の設計](issue-662-598-537-619-584-583-design.md)の決定 18・19 を置き換える。** 既存の設計文書そのものは書き換えない。置き換え先を指す段落を足す先は、次の 2 本である(決定 1)。 - -| 足す先 | 何を持つ文書か | -| --- | --- | -| [置き換える前の要求](issue-662-598-537-619-584-583-requirements.md) | 置き換わる受け入れ条件(AC63〜AC67) | -| [置き換える前の契約](issue-662-598-537-619-584-583-design-contracts.md) | その確かめ方 | - -| 既存の決定 | この文書 | 扱い | -| --- | --- | --- | -| 決定 18(投稿済みのレビューを持つ担当は、記録だけを行う形で起動し直す) | 決定 2・6 | **取り下げる。** 担当が投稿しなくなるため、対処の前提が無くなる | -| 決定 19(起動し直した担当を、初回と同じ経路に通す) | 決定 7 | 引き継ぐ | - -## 関連する文書 - -この文書は「どう作るか」だけを扱う。 - -| 文書 | 何を持つか | -| --- | --- | -| [issue-730-583-requirements.md](issue-730-583-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC32) | -| [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md) | 置き換える前の設計(決定 18・19) | -| [issue-729-619-584-design.md](issue-729-619-584-design.md) | 結末の語彙と、止めた担当に書かせないこと | -| [issue-732-624-706-design.md](issue-732-624-706-design.md) | 数えない指摘の区分と収束の判定 | -| [issue-727-687-478-664-648-design.md](issue-727-687-478-664-648-design.md) | 席の決め方と再開の引数 | diff --git a/issues/issue-730-583-plan.md b/issues/issue-730-583-plan.md deleted file mode 100644 index d9ab65e5..00000000 --- a/issues/issue-730-583-plan.md +++ /dev/null @@ -1,185 +0,0 @@ -# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのをレビューを回す側だけにし、途中で止まっても二度書かない(#730 #583) - -## 関連リンク - -| 文書 | 何を持つか | -| --- | --- | -| [issue-730-583-requirements.md](issue-730-583-requirements.md) | 何を満たすか(受け入れ条件 AC1〜AC32) | -| [issue-730-583-design.md](issue-730-583-design.md) | どう作るか(決定 1〜14・データ構造・入出力の契約) | -| #730 | 根本原因の親課題 | -| #583 | 投稿の重なりと起動し直しの経路 | - -## モード - -`standard`。レビューを回す仕組みの振る舞いを変え、複数の実行単位にまたがる。 - -## 目的と非目的 - -達成したい状態: - -- レビューを任された担当が途中で止まっても、PR に出ている指摘と記録が食い違わない -- 起動し直しても同じ論点のスレッドが 2 つに分かれない -- 修正を送ったという報告と、送り先のブランチの実物が一致する - -やらないこと: - -- 収束の判定・指摘の数え方・区分を変える(別の束が持つ) -- 席の決め方と再開の引数を変える(別の束が持つ) -- PR の巻き直しの締め・作り直し・再開の手順を変える(設計の決定 13) -- 子課題 #548 #350 #585 #676 の個別の受け入れ条件を立てる(根本の修正で現象が出なくなる見込みを要求へ書き、閉じるのは棚卸に任せる) - -## 用語の対応 - -**説明は業務用語で通す。** 識別子は設計文書の「用語の対応表」で引く。この計画で使う語だけを再掲する。 - -| 業務用語 | 実体 | -| --- | --- | -| レビューを回す側 | `plugins/ndf/skills/cross-review/scripts/state.py` と、それを呼ぶ `SKILL.md` の骨組み | -| 担当 | 1 ラウンドで 1 者ぶんのレビューを行う CLI | -| 席 | そのラウンドで担当が入る枠 | -| 指摘の控え | 担当が書く、指摘 1 件ごとのファイル | -| 結果ファイル | 担当が書く、判定の要約のファイル | -| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py` | -| 結果ファイルを投稿へ変える層 | 新設する `plugins/ndf/scripts/lib/result_posts.py` | -| 指摘の取り込み | `state.py read-result` | -| 修正の取り込み | `state.py merge-fix` | - -## 前提 - -- 前提 1: レビューの投稿者アカウントは 1 つである。作業環境の `gh` の認証は 1 アカウントで、席ごとに切り替える口は配布物に無い(実測で確かめた。「実測で決めたこと」の 4 番目) -- 前提 2: 担当が結果を書けずに止まったとき、その担当は何も投稿していない。投稿の手順をプロンプトから外すため、変更の後に成り立つ -- 前提 3: 修正の担当が働く作業ツリーは、取り込む側から同じパスで見える -- 前提 4: 待ち行列は作業ツリーの中にあり、巻き直しのときは作業ツリーごと捨てられる - -## 実測で決めたこと - -設計文書が「未確認のまま残ること」として残した 4 件を、実物の `gh` で 1 度ずつ動かして決めた。 -実測の記録は設計文書の同じ節へ書き戻す(同じ Pull Request に含める)。 - -| 何を | 決めたこと | -| --- | --- | -| 差分の外を指す指摘が拒まれるときの応答の形 | 要求ごとに全件が拒まれ、一部だけが作られることはない。判定に使う語は `could not be resolved` である。判定の値の誤りと基準のコミットの誤りはこの語を含まないため、失敗として残せる | -| 拒まれた応答から原因を 1 件ずつ特定できるか | できない。応答は語をつないだ 1 つの文字列で、位置も識別子も持たない。設計の落とし先(拒まれた要求のインラインをすべて本文へ退避する)を採る | -| すでに決着したスレッドをもう一度決着させたときの応答 | 冪等である。成功し、決着済みを返す。失敗にならない | -| 投稿者のアカウントが席ごとに違う環境があるか | 無い。前提 1 のとおり | - -## 受け入れ条件 - -**一覧は要求の文書が持つ**(AC1〜AC32)。この計画では、タスクごとに満たす番号を示す。 - -## 互換性 - -| 対象 | 変更 | 互換性の扱い | -| --- | --- | --- | -| 取り込み・判定・報告の終了コード | 変えない | AC23 | -| 収束の判定と報告が読む変数 | 変えない | AC23 | -| 取り込みの標準出力 | 投稿の結果を表す行と、取り込んだ指摘の件数を足す | 追加のみ | -| 結果ファイルの項目 | 担当が書く項目を減らし、投稿する側が埋める項目を増やす | 担当の側の契約が変わる。プロンプトと同じ変更に含める | -| 待ち行列の保存先 | 変えない | 前提 4 | -| 席の名前・結末の語彙・数えない指摘の区分 | 変えない | AC24 | - -## 修正対象 - -| ファイル | 扱い | -| --- | --- | -| `plugins/ndf/scripts/lib/result_posts.py` | 新設 | -| `plugins/ndf/scripts/lib/post_queue.py` | 変更 | -| `plugins/ndf/skills/cross-review/scripts/state.py` | 変更 | -| `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` | 変更 | -| `plugins/ndf/skills/cross-review/SKILL.md` | 変更 | -| `plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md` | 変更 | -| `plugins/ndf/skills/cross-review/docs/03-review-output.md` | 変更 | -| `plugins/ndf/skills/cross-review/docs/04-contracts.md` | 変更 | -| `plugins/ndf/skills/cross-review/references/context-budget.md` | 変更 | -| `plugins/ndf/skills/fix/SKILL.md` | 変更 | -| `issues/issue-730-583-design.md` | 「未確認のまま残ること」を実測の結果へ書き直す | -| 各ランタイムの配布物 | 生成の実行で揃える | - -## タスク分解 - -機能単位で分ける。各タスクは、失敗するテストを先に書いてから通す。 - -### Task 1: 二度書かない照合を 4 種別すべてへ広げ、拒まれ方を区別して返す - -- **対象ファイル:** `plugins/ndf/scripts/lib/post_queue.py` -- **変更内容:** 先客がいるかを見る照合の鍵を、レビューの投稿・返信・決着・まとめの 4 種別それぞれに与える。レビューの投稿の鍵は本文の先頭行のうちラウンドと席までの前方一致とし、判定の語を含めない。送信が拒まれたとき、行やファイルを解決できないことを示す応答と、それ以外の拒まれ方を呼ぶ側が見分けられる形で返す -- **満たす受け入れ条件:** AC10・AC11・AC13・AC14(照合)、AC16(拒まれ方の区別) -- **進め方:** 偽の `gh` で先客を返す状態を作り、同じ項目を 2 度積んでも増えないことを見るテストを先に書く - -### Task 2: 指摘の控えと結果ファイルからレビューの投稿を組み立てて送る層を新設する - -- **対象ファイル:** `plugins/ndf/scripts/lib/result_posts.py`(新設) -- **変更内容:** 控えと結果ファイルのパスを受け取り、待ち行列へ積む項目の列を返す。本文は引数にも標準出力にも出さない。自分の Pull Request かどうかを受け取り、送った形だけを格下げする。インラインが行を解決できないことを理由に拒まれたら、その要求のインラインを本文の末尾へ移して送り直し、移した指摘の宛先を本文として記録する -- **満たす受け入れ条件:** AC5・AC15・AC16・AC17・AC32 -- **進め方:** 偽の `gh` が拒む応答を返す状態で、本文へ移した件数と宛先の値を見るテストを先に書く - -### Task 3: 指摘の取り込みが投稿を行い、申告と実数の突き合わせをやめる - -- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/state.py` -- **変更内容:** 控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む、の順で 1 回の呼び出しの中を進める。担当の申告件数を GitHub の実数と比べて中断する処理を取り除く。記録に入る投稿の URL は送信の応答から取り、件数は送れたインラインの数から取る。標準出力へ足すのは件数・URL・状態の行だけにする -- **満たす受け入れ条件:** AC5・AC6・AC12・AC15・AC17・AC23 -- **進め方:** 標準出力に控えの本文の文字列が含まれないことと、投稿の後・記録の前で止めた状態から呼び直してもレビューが増えないことを見るテストを先に書く - -### Task 4: 担当のプロンプトから投稿の手順を外し、書き終えてから改名で公開する - -- **対象ファイル:** `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` -- **変更内容:** プロンプトから、レビューを投稿する手順(投稿の呼び出し・判定の値の指定・インラインの組み立て)を外す。担当が書くのは指摘の控えと結果ファイルの 2 つだけにする。どちらも一時の名前で書かせ、控えを先、結果ファイルを後の順で正式の名前へ改名させる -- **満たす受け入れ条件:** AC1・AC2・AC3・AC4 -- **進め方:** 組み立てたプロンプトを取り出し、投稿の手順の語が 0 件であることを見るテストを先に書く - -### Task 5: 修正の返信・決着・まとめと送信を 1 つの層にまとめ、単独の口を与える - -- **対象ファイル:** `plugins/ndf/scripts/lib/result_posts.py`・`plugins/ndf/skills/cross-review/scripts/state.py`・`plugins/ndf/skills/fix/SKILL.md` -- **変更内容:** 修正の結果ファイルから返信・決着・まとめの投稿を組み立てる口と、現在の頭を指定して送信する口を同じ層へ置く。送信の後、報告されたコミットが送り先のブランチの履歴に含まれることを確かめ、含まれなければ失敗として止まる。修正の取り込みはこの層を呼び、単独で使うときは同じ層を部分命令として直接呼ぶ。単独で使う手順は 1 行のコマンドへ置き換える -- **満たす受け入れ条件:** AC7・AC8・AC9・AC21・AC22 -- **進め方:** 送信を偽装し、報告されたコミットが送り先に無いときに失敗することを見るテストを先に書く - -### Task 6: 起動し直しを初回と同じ経路へ通す - -- **対象ファイル:** `plugins/ndf/skills/cross-review/SKILL.md` -- **変更内容:** 骨組みの起動し直しの枝を、繰り返しの先頭へ戻す形にする。待ち行列に残りがあるときの枝は、2 度目を含む各判定の直後に残す。設計方針の表から、担当が直接投稿する記述を外し、投稿の担い手とその理由を入れる -- **満たす受け入れ条件:** AC18・AC19・AC25 -- **進め方:** 骨組みの行の並びを読む既存の検査へ条件を足す - -### Task 7: 文書の決定を書き直す - -- **対象ファイル:** `docs/02-fix-and-rotation.md`・`docs/03-review-output.md`・`docs/04-contracts.md`・`references/context-budget.md`(いずれも `plugins/ndf/skills/cross-review/` の配下)・`issues/issue-730-583-design.md` -- **変更内容:** 修正の手順から担当が送信する行を外し、取り込む側が送信することを書く。担当が直接投稿する決定を、待ち行列を通す形へ書き直す。投稿の種別ごとの契約を載せる。文脈の予算の工夫の一覧を、本文がレビューを回す側のプロセスの中だけを通る形へ書き直す。設計文書の「未確認のまま残ること」を実測の結果へ書き直す -- **満たす受け入れ条件:** AC26・AC27・AC28・AC29 -- **進め方:** 文言の検査。振る舞いを持たないためテストは書かない - -### Task 8: 生成物を揃え、全体の検査を通す - -- **対象ファイル:** 各ランタイムの配布物 -- **変更内容:** 生成の実行で配布物を揃え、全体のテストと検査を通す -- **満たす受け入れ条件:** AC23・AC24・AC30・AC31 -- **進め方:** テストと検査のコマンドを実行し、終了コードを証跡として残す - -## 影響範囲 - -| 何が | どう変わるか | -| --- | --- | -| 担当の CLI | GitHub への書き込みの権限が要らなくなる。実行時間が短くなる | -| 収束の判定 | 変わらない。読む変数と終了コードを保つ | -| 修正を単独で行う経路 | 書き込みの実装が、レビューを回す経路と 1 つになる | -| 並行する束 | 席の決め方(PR #793、マージ済み)と同じファイルを触る。マージ済みのため競合は着手の時点で解けている | - -## リスクと対処 - -| リスク | 対処 | -| --- | --- | -| 変更が 1 ファイル 4983 行の取り込みの実装に集中する | タスクごとにテストを通す。構造の整理はこの変更の後の構造改善へ回す | -| 投稿の担い手を移す間に、送る経路が 2 つ並ぶ | 担当のプロンプトから手順を外す変更(Task 4)と、投稿を行う変更(Task 3)を同じ Pull Request に入れる | -| 拒まれ方の区別が実物の応答に合わない | 実測で確かめた語を契約へ書き、偽の応答をその形で作る | -| 本文へ退避した指摘で本文が長くなる | 1 ラウンドの指摘は多くて 10 件前後で、投稿の本文の上限に対して十分小さい(実測の記録に根拠を置く) | - -## 切り戻し手順 - -Pull Request の単位で戻せる。外部の系の状態を変える移行は無い。待ち行列の保存先を変えないため、 -途中まで積まれた項目は作業ツリーごと捨てられる。 - -## 完了の定義 - -- [ ] 受け入れ条件 AC1〜AC32 をすべて満たし、条件ごとに検証手段と結果が対応している -- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] `bash scripts/build-runtime-plugins.sh --check`・`claude plugin validate .`・`python3 scripts/check-skill-frontmatter.py` が終了コード 0 で終わる diff --git a/issues/issue-730-583-requirements.md b/issues/issue-730-583-requirements.md deleted file mode 100644 index 4a60879c..00000000 --- a/issues/issue-730-583-requirements.md +++ /dev/null @@ -1,246 +0,0 @@ -# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのをレビューを回す側だけにし、途中で止まっても二度書かない(#730 #583 の要求) - -## 目的 - -レビューを 1 者ぶん任された担当が、PR へ指摘を書き込んでから、回す側が読む結果を書く前に止まることがある。PR には指摘が出ているのに記録には無いため、回す側は「結果なし」と読んで同じ担当をもう一度起動し、同じ論点がもう一度投稿される。利用者は 1 つの論点に 2 つのスレッドを見て、両方へ返信する。修正の側でも、送ったという報告どおりにブランチが進んでいないことがある。 - -GitHub と git へ書くのをレビューを回す側だけにし、担当は結果を残すだけにする。途中で止まっても同じものを二度書き込まない。 - -## 用語と識別子の対応 - -| 業務用語 | 識別子・実体 | -| --- | --- | -| レビューを回す側 | `plugins/ndf/skills/cross-review/scripts/state.py` と、それを呼ぶ `SKILL.md` の骨組み。3 層では supervisor | -| 担当 | 1 ラウンドで 1 者ぶんのレビューを行う CLI。`launch-reviewer.sh` が起動する | -| 席 | そのラウンドで担当が入る枠。`claude` / `codex` / `agy` / `kiro` と、同じランタイムの 2 つ目の `-2`〜`-9`(#727) | -| 修正の担当 | 指摘を直すサブエージェント、または単独で動く `fix` の実行 | -| 結果ファイル | `<席>-review-pr<番号>-result.json`。判定の要約(`event` / `posted_as` / `comments_count` / `by_severity`) | -| 指摘の控え | `<席>-review-pr<番号>-round-payload.json`。指摘 1 件ごとの `path` / `line` / `body` / `severity` ほか | -| 修正の結果ファイル | `fix-pr<番号>-result.json`。`fix_commit` / `resolved_threads` / `deferred` / `rejected` ほか | -| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py`。種別は `pr-comment` / `review-post` / `review-reply` / `thread-resolve` | -| 二度書かないための照合 | `post_queue.already_posted()` / `posted_match()` | -| 結果ファイルを投稿へ変える層 | 新設する `plugins/ndf/scripts/lib/result_posts.py` | -| 指摘の取り込み | `state.py read-result` | -| 修正の取り込み | `state.py merge-fix` | -| 最終スイープの照合 | `state.py verify-sweep` | - -## 対象範囲 - -**含む。** - -| 何を | どう変わるか | -| --- | --- | -| レビューの投稿(本文とインライン) | 担当が `gh api` で直接送る → 担当は指摘の控えを書くだけ。レビューを回す側が待ち行列を通して送る | -| 指摘への返信とスレッドの決着 | 修正の担当が送る → 修正の結果ファイルを取り込む側が待ち行列を通して送る | -| 修正のまとめの投稿 | 同上 | -| 修正の送信 | 修正の担当が `git push origin <ブランチ名>` → 取り込む側が `git push origin HEAD:<ブランチ名>` | -| 申告と実数の突き合わせ | 担当の申告件数を GitHub の実数と比べる → 送った結果をそのまま記録にする | -| 投稿済みのレビューを探して記録だけの起動をする決め | 取り下げる(担当が投稿しないため、投稿済みで記録なしの状態が起きない) | -| 起動し直しの経路 | 初回と同じ経路(根拠の検証と反証を含む)へ通す | -| `fix` を単独で使う経路 | 書き込みの実装を cross-review から呼ぶ経路と 1 つにする | - -**含まない。** - -| 何を | なぜ | -| --- | --- | -| PR の巻き直しの close / create / reopen | すでにレビューを回す側が行う(`rotate-pr.sh`)。待ち行列に積まない性質も変えない | -| 反証の投稿 | 反証はもともと投稿しない(ファイルへ書くだけ) | -| 収束の判定・区分・数え方 | #732(G2)が持つ | -| 利用上限で止まった担当の報告と起動し直しの可否 | #729(G3)が持つ | -| 席の決め方・参加する者の選び方・再開の引数 | #727(G1)が持つ | -| cross-refactoring の書き込み | この束の外。`cross-refactoring` はすでに「公開するのは進行側だけ」である | -| 子課題 #548 #350 #585 #676 の個別の受け入れ条件 | マイルストーン 06。根本の修正で直る見込みは「影響」に書き、閉じるのは棚卸に任せる | - -## 前提 - -- **前提 1: レビューの投稿者アカウントは、いまも実質 1 つである。** 担当の CLI は同じ作業環境の `gh` の認証を使うため、投稿の担い手を変えても PR 上の投稿者は変わらない。どの席の指摘かは本文の先頭行が表す -- **前提 2: 担当が結果を書けずに止まったとき、その担当は何も投稿していない。** 投稿の手順をプロンプトから外すため、この前提は変更の後に成り立つ -- **前提 3: 修正の担当が働く作業ツリーは、取り込む側から同じパスで見える。** cross-review は状態ファイルが持つ作業ツリーを使い、単独の `fix` は現在の作業ツリーを使う -- **前提 4: 待ち行列は作業ツリーの中に置かれ、巻き直しのときは作業ツリーごと捨てられる。** 現行のとおりで変えない - -## 受け入れ条件 - -### 担当は結果だけを残す - -- [ ] AC1: 担当へ渡すプロンプトに、レビューを投稿する手順が 1 つも無い。 - 対象は `gh api` の呼び出し・`event` の指定・インラインの組み立ての 3 つである -- [ ] AC2: 担当へ渡すプロンプトは、指摘の控えと結果ファイルの 2 つだけを書かせる。 - 結果ファイルのうち担当が書くのは `event` と `by_severity` だけである。 - `posted_as` と `comments_count` と `review_url` は投稿する側が埋め、`post_error` は無くなる -- [ ] AC3: 担当は結果ファイルと指摘の控えを、一時の名前で書いてから改名する。 - 改名の順序は控えが先、結果ファイルが後である。 - 控えだけが正式の名前で結果ファイルが無い状態では、取り込みは結果なしとして扱い、投稿を 0 件にする -- [ ] AC4: 担当が指摘の控えを書かずに終わった実行では、その PR に新しいレビューが 1 件も増えない - -### 書き込みは 1 か所から行う - -- [ ] AC5: 指摘の取り込みは、指摘の控えからレビューの投稿を組み立て、待ち行列へ積み、流すところまでを 1 回の呼び出しで行う。投稿の本文は呼び出しの引数に現れない -- [ ] AC6: 取り込みの標準出力に、レビューの本文とインラインの本文が出ない。出るのは件数・URL・状態だけである -- [ ] AC7: 修正の取り込みは、返信・スレッドの決着・まとめの投稿を待ち行列へ積んで流す。修正の担当はこの 3 つを行わない -- [ ] AC8: 修正の送信は取り込む側が `git push origin HEAD:<ブランチ名>` で行う。修正の担当はコミットまでを行い、送らない -- [ ] AC9: 送信の後、報告されたコミットが送り先のブランチ(Pull Request の head)に載っていることを確かめる。載っていなければ取り込みは失敗として止まる - -### 途中で止まっても二度書かない - -- [ ] AC10: レビューの本文の先頭行は `## 🤖 cross-review | round | <席> | ` である。先頭 80 文字にラウンドと席が入る - 照合に使うのは席までの前方一致で、判定の語を含めない -- [ ] AC11: 同じラウンド・同じ席のレビューが PR にすでにあるとき、判定が変わっていても新しいレビューは増えない。待ち行列の項目は送信済みとして取り除かれる -- [ ] AC12: 投稿の後・記録の前に取り込みが止まった状態から取り込みをもう一度呼ぶと、レビューは増えず、記録には既存のレビューの URL が入る -- [ ] AC13: 同じ指摘への返信が 2 度積まれても、返信は 1 件しか増えない。すでに決着したスレッドをもう一度決着させても、結果は変わらず失敗にもならない -- [ ] AC14: 同じラウンドのまとめの投稿を 2 度積んでも、コメントは 1 件しか増えない - -### 申告をやめ、送った結果を記録にする - -- [ ] AC15: 記録に入る投稿の URL は、送信の応答から取る。 - 件数は、送信に成功したインラインの数から取る(レビューの投稿の応答は件数の項目を持たない)。 - 担当の申告件数と GitHub の実数を突き合わせる処理は無くなる -- [ ] AC16: 差分の外を指す指摘は、投稿する側が本文へ退避する。 - 対象はインラインが HTTP 422 で拒まれたものである。 - 退避した指摘の `posted_to` は `body` になり、退避した件数が取り込みの出力に出る - 行を解決できないことを示さない HTTP 422 は退避せず、取り込みが失敗として止まる -- [ ] AC17: 指摘の控えに指摘が 1 件以上あり、インラインとして送れたものが 0 件でも、その担当は結果なしにならない -- [ ] AC32: 自分の Pull Request では、投稿する側が送信の時点で `posted_as` を `COMMENT` へ落とす。本来の判定(`intent`)は落とさない - -### 起動し直しの経路(#583) - -- [ ] AC18: 骨組みで、起動し直した担当は初回と同じ経路を通る。取り込みの後に根拠の検証と反証を通ってから 2 度目の判定へ進む -- [ ] AC19: 待ち行列に残りがあるときの枝(判定の終了コード 8)は、2 度目を含む各判定の直後に残る -- [ ] AC20: 取り込みが送信の後・記録の前に止まった実行を再現し、取り込みをもう一度呼ぶ。 - PR のレビューは 1 件しか増えず、同じ論点のスレッドが 2 つに分かれない - -### `fix` を単独で使う経路 - -- [ ] AC21: 返信・スレッドの決着・まとめの投稿・修正の送信の実装は 1 つである。 - cross-review から呼ぶ経路と単独で呼ぶ経路が、同じものを使う -- [ ] AC22: 単独で `fix` を使うときの手順に、修正の結果ファイルを投稿へ変えるコマンドが 1 行で書かれている。利用者が `gh api` を手で組み立てる手順は残らない - -### 退行しない - -- [ ] AC23: 取り込み・判定・報告の終了コードは変更前と同じである。 - 収束の判定と報告が読む変数(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS`)も変わらない。 - 取り込みの標準出力には、投稿の結果を表す行が足される(AC6) -- [ ] AC24: 席の名前(#727)・結末の語彙(#729)・数えない指摘の区分(#732)の扱いを変えない - -### 文書 - -- [ ] AC25: `SKILL.md` の設計方針の表から「AI 自身が `gh api` で PR に直接投稿」が消える。 - 代わりに、投稿の担い手とその理由が入る -- [ ] AC26: `docs/03-review-output.md` の「AI 直接投稿」の決定が、待ち行列を通す形へ書き直される -- [ ] AC27: `docs/02-fix-and-rotation.md` の修正の手順から、担当が送信する行が消える。 - 代わりに、取り込む側が送信することが書かれる -- [ ] AC28: `references/context-budget.md` の工夫の一覧から「中間ペイロードがメインを通らない」が消える。 - 代わりに、本文がレビューを回す側のプロセスの中だけを通り、応答には載らないことが書かれる -- [ ] AC29: 投稿の待ち行列の種別のうち、この変更で積む側ができたものが `docs/04-contracts.md` の契約に載る - -### 全体 - -- [ ] AC30: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC31: 次の 3 つが終了コード 0 で終わる。`bash scripts/build-runtime-plugins.sh --check`。`claude plugin validate .`。`python3 scripts/check-skill-frontmatter.py` - -## 非機能の条件 - -| 項目 | 条件 | -| --- | --- | -| 応答の量 | 取り込み 1 回の標準出力は、投稿の件数と URL と状態だけで 20 行以内。レビューの本文を載せない | -| 上限への耐性 | 投稿が上限で拒まれたら待ち行列へ残り、判定の枝(終了コード 8)で流し直される。担当をもう一度起動しない | -| 所要 | 担当の実行時間は、投稿の手順が無くなるぶん短くなる。監視の上限(#729)は変えない | -| 権限 | 担当の CLI に GitHub への書き込みの権限が要らなくなる | - -## 影響 - -| 課題 | この変更で何が変わるか | 誰が閉じるか | -| --- | --- | --- | -| #583 | 担当が投稿しないため、投稿済みで記録なしの状態が起きない。起動し直しても論点が重ならない | この束 | -| #548 | 本文だけに書く担当がいなくなり、申告と実数の突き合わせも無くなるため、インライン 0 件で結果なしにならない | 棚卸(再現手順の確認だけ行う) | -| #350 | 担当と `fix` の投稿が待ち行列を通るため、上限の間に失われない | 棚卸 | -| #585 | 送信を取り込む側が `HEAD:<ブランチ名>` で行い、コミットが載ったことを確かめるため、報告と実物が食い違わない | 棚卸 | -| #676 | まとめの投稿が待ち行列を通るため、残らない回が無くなる | 棚卸 | - -## 検証手段 - -| 何を確かめるか | どう確かめるか | -| --- | --- | -| プロンプトから投稿の手順が消えたこと | 担当の起動スクリプトが作るプロンプトを取り出す単体テスト | -| 二度書かないこと | 偽の `gh` で「先客がいる」応答を返し、投稿が増えないことを見る単体テスト | -| 投稿の本文が応答に出ないこと | 取り込みの標準出力に本文の文字列が含まれないことを見る単体テスト | -| 送信と照合 | 送信を偽装し、報告されたコミットが送り先のブランチに無いときに失敗することを見る単体テスト | -| 骨組みの経路 | 骨組みの行の並びを読む既存のレイアウトの検査へ条件を足す | -| 文書 | 変更した行を読む検査、または該当ファイルの文言の検査 | - -## 前提とする取り決め - -この束は並行する設計の中で **G5** と呼ぶ(#727 が G1、#732 が G2、#729 が G3、#728 が G4)。 - -| 相手 | 取り決め | -| --- | --- | -| #727(G1、PR #793) | 席の名前(`claude-2` の形)と、結果ファイル・指摘の控えの名前が席で組まれること。レビューの本文の先頭行が席を持つこと。**この束は席の名前をそのまま使い、決め方を変えない** | -| #729(G3、PR #791、マージ済み) | 結末の語彙と、結果ファイルが無いときの理由の記録。**投稿の担い手が変わっても、結果なしの理由の語彙を変えない** | -| #732(G2、PR #790) | 数えない指摘の区分と収束の判定。**この束は指摘の中身に触らない** | -| #728(G4) | 触るファイルが重ならない | - -## 境界 - -```text -常に行う … 既存テストの実行、変更した文書の検査、待ち行列を通す形への統一 -確認してから行う … 部分命令の名前の変更、終了コードの変更、待ち行列の保存先の変更 -行わない … 収束の判定の書き換え、席の決め方の変更、PR の巻き直しの手順の変更 -``` - -## 未決 - -- 単独で `fix` を使うときの入口を、専用のスクリプトにするか既存の共通層の部分命令にするか(設計の決定 11 で決める) -- 差分の外を指す指摘の退避を、投稿の前に判定するか 422 を受けてから行うか(設計の決定 9 で決める) - -## 置き換える既存の受け入れ条件との対応 - -| 既存の受け入れ条件 | この文書 | 扱い | -| --- | --- | --- | -| AC63(結果が使えない担当の投稿済みレビューを探す) | — | **取り下げる。** 担当が投稿しないため、探す対象が無い | -| AC64(照会が失敗したら残さない) | — | 同上 | -| AC65(記録だけを行うプロンプトへ差し替える) | — | 同上。プロンプトからは投稿の手順そのものが消える(AC1) | -| AC66(申告された URL を投稿済みとして取り込む) | AC15 | **改める。** 申告ではなく、送信の応答から取る | -| AC67(起動し直しを初回と同じ経路へ) | AC18・AC19 | 引き継ぐ | - -既存の一覧の置き場所は、[置き換える前の要求](issue-662-598-537-619-584-583-requirements.md)の「受け入れ条件(P3)」である。**その文書は 2026-09-15 時点の記録として残し、本体は書き換えない。** - -## 依頼(原文) - -### #730(根本原因の親) - -> **cross-review が GitHub と git へ行う書き込み(レビューの投稿・修正の push・サマリの投稿)の置き場所。** -> -> - いまは担当が行う。レビューは担当の CLI が `gh api` で直接投稿し、修正は担当のサブエージェントが `git push origin {HEAD_BRANCH}` で送る -> - 進行側の `scripts/state.py` は結果ファイルを読むだけで、実物と照合しない -> - 投稿の待ち行列 `plugins/ndf/scripts/lib/post_queue.py` は進行側にあるが、担当の投稿は通らない -> - 書き込みと、進行側が読む記録を担当が別々に行うため、打ち切り・detach の作業ツリー・書き忘れのどれでも、記録と実物が食い違う -> -> 移動(`move_responsibility`)。cross-refactoring の「公開するのは進行側だけ」と同じ形にする。担当は結果ファイルだけを書く。進行側は結果ファイルから `post_queue.py` を通して投稿し、修正は `HEAD:` で push し、サマリも投稿する。 -> -> **進行側の投稿は、結果ファイルのパスを `post_queue.py` へ渡す形にする。** 進行側が投稿を持つとき、payload の本文を supervisor の応答へ載せず、スクリプトがファイルから読んで送る。 -> -> 完了条件: -> -> - 担当は結果ファイルだけを書き、レビューの投稿・修正の push・サマリの投稿は `state.py` が行う -> - 「投稿済みで記録なし」「push したと報告して実物なし」の状態が起きないことを検査が確かめる -> - `fix` を単独で使う経路(返信と Resolve)の扱いを決める -> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる - -### #583 - -> **担当が投稿を終えた後、結果ファイルを書く前に監視の上限に達すると、投稿済みの指摘が状態ファイルに記録されない。** 判定は「結果なし」として同じラウンドで担当を起動し直し、起動し直した担当が同じ論点を重ねて投稿する。 -> -> 1 回目の 3 件(minor)は判定の入力に一度も入らない。**同じ論点が 2 つのスレッドに分かれ、修正の担当が両方へ返信する。** -> -> #730(親) — 修正レイヤーはこちらが持つ。担当は結果ファイルだけを書き、レビューの投稿は進行側(`state.py`)が行う形にすれば「投稿済みで記録なし」が起きない - -**#583 の収束の部分は #732(G2)の設計で塞がった。** この束に残るのは投稿の重なりと、起動し直しの経路である。 - -## 関連する文書 - -| 文書 | 何を持つか | -| --- | --- | -| [issue-730-583-design.md](issue-730-583-design.md) | どう作るか(決定・データ構造・契約・処理の流れ) | -| [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md) | 置き換える前の受け入れ条件(AC63〜AC67) | -| [issue-729-619-584-requirements.md](issue-729-619-584-requirements.md) | 結末の語彙と、止めた担当に書かせないこと | -| [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) | 数えない指摘の区分と収束の判定 | -| [issue-727-687-478-664-648-requirements.md](issue-727-687-478-664-648-requirements.md) | 席の決め方と再開の引数 | diff --git a/issues/issue-732-624-706-design.md b/issues/issue-732-624-706-design.md deleted file mode 100644 index f0718089..00000000 --- a/issues/issue-732-624-706-design.md +++ /dev/null @@ -1,373 +0,0 @@ -# cross-review: 誤りを示されていない重大な指摘が数えられずに承認で終わる → 数えない指摘を棄却と軽微な指摘に限る(#732 #624 #706 の設計) - -## 目的 - -**この設計の後、cross-review の収束の判定が数えないのは、棄却された指摘と軽微な指摘だけになる。** 誰にも誤りを示されていない重大な指摘は、新しい区分「未反証」として数えられ、修正の工程へ渡る。「なぜ独立に確かめられていないか」は、未反証の理由として状態ファイルに残る。 - -いまは、誰にも誤りを示されていない重大な指摘が立証不足の区分へ落ち、数えられない。その結果、新しい指摘 0 件として承認で終わり、修正の要る指摘が修正の工程へ渡らない。 - -## 用語の対応表 - -本文は左の業務用語で書く。右は状態ファイル・スクリプトでの識別子で、コードブロック・表・「データ構造」「入出力の契約」「置き場所」の節ではそのまま使う。 - -| 業務用語 | 識別子 | 何を指すか | -| --- | --- | --- | -| 重大な指摘 / 軽微な指摘 | `severity` の `major` / `minor`(本文の「重大な指摘」は `major` 以上、「軽微な指摘」は `minor` 以下) | 指摘の重大度。軽微な指摘は承認を妨げない | -| 区分 | `classification` | 印のあるラウンドで指摘ごとに付く値。6 つになる | -| 再現した重大な指摘 / 再現した軽微な指摘 | `verified_blocking` / `verified_non_blocking` | 実行検証で再現した指摘の区分(順 1・2) | -| 棄却 | `rejected`(理由は `rejection_reason`) | 誤りだと示された指摘の区分(順 3) | -| 人の判断待ち | `needs_human_judgment` | 独立に確かめた担当がいる重大な指摘の区分(順 4) | -| 未反証 | `unrefuted`(理由は `unrefuted_reason`) | **新設。** 誰も誤りを示しておらず、独立に確かめた担当もいない重大な指摘の区分(順 5) | -| 未反証の理由: 反証なし / 支持なし | `no_critique` / `not_supported` | 反証を返した担当が 0 者 / 反証はあるが支持も否定も無い | -| 立証不足 | `insufficient_evidence`(区分の値) | 上のいずれにも当たらない軽微な指摘の区分(順 6) | -| 反証 | `critiques`(要素の `verdict`) | 提案者以外の担当が指摘へ返した賛否 | -| 反証の値: 支持 / 否定 / 立証できない / 範囲外 | `support` / `refute` / `insufficient_evidence` / `out_of_scope` | 反証の担当が返す 5 つの値のうち、この文書が扱う 4 つ | -| 実行検証の結果: 再現した / 再現しなかった / 実行していない | `verification.result` の `reproduced` / `not_reproduced` / `not_run` | 指摘の手順を実行した結果 | -| 根拠の 2 項目 / 根拠の有無 | `evidence` と `falsification` / `has_evidence` | 別の担当が確かめるための入力と、両方が揃っているかの真偽値 | -| 出した担当 | `origin_runtimes` | 同じ指摘を独立に出した担当の一覧。2 者以上で「独立に確かめた」とみなす | -| 印 | `evidence_rounds` | 統合・実行検証・反証を通ったラウンドの番号の一覧 | -| 却下の記録 | `rejected_findings` | 修正の工程が却下した指摘と理由。次のラウンドのプロンプトへ渡る | -| 数える区分の集合 | `COUNTED_CLASSIFICATIONS`(状態の管理スクリプトと測定スクリプトの 2 か所) | 新規性の層が新しい指摘として数える区分 | -| 状態の管理スクリプト / 測定スクリプト / 反証のプロンプト | `state.py` / `measure.py` / `critique.sh` | いずれも `plugins/ndf/skills/cross-review/scripts/` の下 | -| 区分の判定 / 区分の書き込み / 数える指摘の抽出 / 新しい指摘の数え上げ / 反証の不足の扱い / 印を付ける処理 | `_classify_finding` / `_apply_classification` / `_counted_finding_keys` / `_new_finding_count` / `_handle_incomplete_critiques` / `_mark_evidence_round` | 状態の管理スクリプトの内部関数 | -| 収束の判定 / 反証の取り込み | `judge`(本体は `cmd_judge`)/ `collect-critiques` | 状態の管理スクリプトの副コマンド | -| 全員が通したラウンド | `round_passes` | 全担当が承認か、重大な指摘の無いコメントを返したこと | -| 測定の方式「この変更の方式」 | `proposed` | 測定スクリプトが持つ方式の 1 つ | -| 承認 / 修正要求 / コメント | `APPROVE` / `REQUEST_CHANGES` / `COMMENT` | 担当が返すレビューの意図 | -| 承認で終わる | `approved` | 収束ループの結末 | -| GitHub CLI | `gh` | 状態の管理スクリプトが外部と出入りする唯一の経路 | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 誤りを示されていない重大な指摘を、反証の有無・担当の数・根拠の項目の有無によらず新しい指摘として数える | 1 者で回す利用者(#624)、担当が起動し直されたループ(#583 の収束の部分)、実行検証も支持も無い指摘が出た Pull Request(#706) | -| F2 | 数えた指摘に「なぜ独立に確かめられていないか」の理由を残す | 修正の担当(何が確かめられていないかを読む)、収束の後に記録を読む人 | -| F3 | 反証が揃わなかったラウンドを、全件を数える扱いへ戻す | 収束ループ全般 | -| F4 | 効果の測定の方式「この変更の方式」が、数える 3 区分を採る | 収束の記録を測る人 | - -## なぜ変えるか - -**原因は、1 つの区分が 2 つの意味を兼ねていることにある。** 立証不足の区分に「反証の機会があって支持されなかった」と「立証の機会が無かった」の両方が落ち、どちらも数えられない。誤りを示されていない重大な指摘が数えられない形は、3 つの場面で出る。 - -| 場面 | 課題 | -| --- | --- | -| 反証する担当がいない 1 者のループ | #624 | -| 実行検証も支持も無い指摘 | #706 | -| 起動し直した担当の指摘 | #583 の収束の部分 | - -## 実測 - -11 通りの最小の指摘を区分の判定に渡し、いまの区分と変更後の区分を並べた。数え方が変わるのは A・B・D・E・F・H の 6 件で、いずれも重大な指摘である。軽微な指摘(C)、棄却(I・J)、再現(K)、支持つき根拠あり(G)の 5 件は変わらない。 - -測った条件は `develop` 9eaebe14、Python 3.14.4 である。実行検証は「実行していない」、担当 1 者の指摘は出した担当が 1 者である。 - -| 記号 | 入力 | いまの区分 | 数える | 変更後の区分 | 数える | -| --- | --- | --- | --- | --- | --- | -| A | `major`、根拠あり、反証なし(1 者) | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | -| B | `major`、根拠なし、反証なし | `insufficient_evidence` | いいえ | `unrefuted`(`no_critique`) | **はい** | -| C | `minor`、根拠あり、反証なし | `insufficient_evidence` | いいえ | `insufficient_evidence` | いいえ | -| D | `major`、根拠あり、相手が `insufficient_evidence` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | -| E | `major`、根拠あり、相手が `out_of_scope` | `insufficient_evidence` | いいえ | `unrefuted`(`not_supported`) | **はい** | -| F | `major`、根拠なし、相手が `support` | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | -| G | `major`、根拠あり、相手が `support` | `needs_human_judgment` | はい | `needs_human_judgment` | はい | -| H | `major`、根拠なし、2 者が独立に出した | `insufficient_evidence` | いいえ | `needs_human_judgment` | **はい** | -| I | `major`、相手が `refute` | `rejected` | いいえ | `rejected` | いいえ | -| J | `major`、`not_reproduced` | `rejected` | いいえ | `rejected` | いいえ | -| K | `major`、`reproduced` | `verified_blocking` | はい | `verified_blocking` | はい | - -## 決定の記録 - -決定 1 は文書の形を扱う。決定 2〜6 は区分、決定 7 は測定、決定 8 は印、決定 9〜11 はプロンプト・判定の出口・確定仕様を扱う。区分の中核は決定 2 で、決定 3〜6 はその境界を定める。 - -### 決定 1: 並行する設計と 1 つのファイルで競合しないよう、設計文書は親 #732 の名前で新設し、既存の設計文書の本体は書き換えない - -既存の設計は 3 課題・2 本の Pull Request を 1 つの文書で扱う。その P5(決定 6〜19)は #727 の設計が並行して置き換える。P4 の節をその場で書き換えると、P5 の変更と 1 つのファイルで競合する。承認する人が「どの束が何を変えたか」を読み分けられない。親 #732 は根本原因を「1 つの区分が 2 つの意味を兼ねる」と定め直した。これは既存の決定 2〜4(区分の名前を増やさず、順 4 に条件を足す)の前提を変える。**新設して対応表で指せば、変わった決定だけが差分に載る。** - -既存の設計文書の本体(`-design.md`)には案内の 1 行も足さない。設計 Pull Request の本文の「決めたこと」は、変更したファイルの決定の見出しをすべて写す。1 行でも触ると、既存の 19 件の決定がこの Pull Request の決定として並ぶ。案内は、決定の記録を持たない要求の文書と契約の文書にだけ足す(#729 の設計と同じ形)。 - -### 決定 2: 未解決の重大な指摘を残して収束しないよう、数えない判断を棄却と軽微な指摘に限り、誤りを示されていない重大な指摘を新しい区分「未反証」として数える - -区分の順 5(立証不足)には、いま 7 つの形が落ちる(「実測」の A〜F・H)。そのうち軽微な指摘(C)を除く 6 つは、いずれも「誰も誤りを示していない重大な指摘」である。 - -| 落ちる形 | 実測の記号 | -| --- | --- | -| 反証する担当がいない | A・B・H | -| 相手が「立証できない」か「範囲外」を返した | D・E | -| 根拠の 2 項目を欠く | B・F・H | - -どれも、指摘が誤りだという主張ではない。**数えないのは、誤りだと示された棄却と、承認を妨げない軽微な指摘だけにする。** 誤りを示されていない重大な指摘は未反証として数え、修正の工程へ渡す。修正の担当の扱い(直す・却下の理由を返す・範囲外として起票する)は、人の判断待ちと同じである。 - -数えることで増えるラウンドは、指摘 1 件につき最大 1 回である。修正の工程が却下の理由を却下の記録へ残し、次のラウンドのレビュープロンプトへ渡す。そのため同じ論点は戻らない。戻っても、新規性の一致(位置・近傍・本文)が前のラウンドの指摘と結び、新規に数えない。#156 が避けた「同じ論点で 5 ラウンド」(#69)は、却下の記録が無かった頃の形である。 - -| 採らない案 | 採らない理由 | -| --- | --- | -| 順 4 の条件に「反証が空」を足す(既存の設計の決定 3) | A と H は数えられる。D・E(反証の機会があって支持されなかった)と B・F(根拠を欠く)は落ちたままで、親 #732 の完了条件「数えない判断が棄却された指摘に限られ」に当たらない | -| 反証が 0 件のラウンドは絞り込まない(#624 の候補 1) | ラウンド単位の判断では、2 者のうち 1 件だけが後から入った #583 の形と、2 者で相手が「立証できない」を返した #706 の形を塞げない | -| 誤りを示されていない重大な指摘を全件、人の判断待ちに入れる | 数え方は同じになる。しかし「独立に確かめた担当がいる」と「誰も確かめていない」が同じ名前になり、修正の担当と測定がその差を読めない。親 #732 は別の区分にすることを採る手としている | - -### 決定 3: 別の担当が支持した指摘が根拠の項目の欠けで落ちないよう、人の判断待ちの区分の条件から根拠の 2 項目を外す - -根拠の 2 項目は、別の担当がその指摘を確かめるための入力である。別の担当が支持を返した、または 2 者以上が同じ指摘へ独立に到達した時点で、確かめる目的は果たされている。その後で 2 項目の欠けを理由に落とすと、「2 人が見て同じことを言っている」情報が判定に効かない。#706 の PR #45 では、支持の付いた 2 件がこの形で落ちた。**順 4 は「重大な指摘で、支持が 1 件以上または出した担当が 2 者以上」**とする。 - -根拠の有無の値そのものは変えずに残す。反証のプロンプトが読み、測定と記録がそのことを持つ。**区分が読まなくなるだけである。** 4 項目を持たない指摘を捨てないという既存の決定(`docs/06` の「4 項目を持たない指摘」)は、この変更で「捨てず、数える」まで進む。 - -採らない案: **根拠を欠く指摘は未反証に入れ、人の判断待ちには入れない。** 順 4 と順 5 の差が「独立に確かめたか」でなく「2 項目を書いたか」で決まり、支持の意味が薄れる。 - -### 決定 4: 立証できないことを棄却と扱わないよう、反証の担当が「立証できない」「範囲外」だけを返した重大な指摘も未反証として数える - -反証の値「立証できない」(`insufficient_evidence`)の意味は「可能性はあるが立証できない」である。「範囲外」(`out_of_scope`)の意味は「この Pull Request の範囲から外れる」である。どちらも指摘が誤りだという主張ではない。規約も「否定の代わりに使わせない」と定めている。誤りを示せない指摘を数えずに収束させると、未解決の重大な指摘が残ったまま承認で終わる(#706 の観測そのもの)。**担当 2 者で相手が支持しなかった重大な指摘を数えないという #156 の判断を改める。** #624 は「#156 の設計どおりで対象外」と書いた。しかし親 #732 の採る手と完了条件は、この形も棄却ではない側に置く。 - -この変更で、反証の担当が指摘を数から落とす手段は否定だけになる。プロンプトにそのことを書く(決定 9)。 - -採らない案: **「立証できない」を返された重大な指摘は数えないまま残す(#156 の判断を保つ)。** 立証できない指摘と反証する担当がいない指摘は、反証の有無で状態ファイルから区別できる。しかし区別して前者だけを落とすと、実行検証を持たない Pull Request(文書だけの変更)では、担当が確かめられなかった重大な指摘がすべて落ちる。#706 の PR #45 は文書の Pull Request である。 - -### 決定 5: 修正の担当と測定が「なぜ確かめられていないか」を読めるよう、未反証の理由を「反証なし」「支持なし」の 2 値で状態ファイルに残す - -棄却が棄却の理由を持つのと同じ形で、未反証が「なぜ独立に確かめられていないか」の理由(`unrefuted_reason`)を持つ。値は 2 つである。 - -| 値 | 意味 | 修正の担当の読み方 | -| --- | --- | --- | -| 反証なし(`no_critique`) | 反証を返した担当が 0 者 | 誰も見ていない指摘 | -| 支持なし(`not_supported`) | 反証はあるが、支持も否定も無い | 相手が確かめられなかった指摘 | - -測定は、1 者のループと 2 者のループでどちらの理由が多いかを、同じ記録から読める。 - -根拠の 2 項目の欠けは理由に入れない。根拠の有無が既に持つ値で、理由と直交する(反証なし、かつ根拠なし、のように組になる)。 - -採らない案: **理由を持たず、反証の有無から読む。** 読めはする。しかし棄却の理由と対になる形が崩れ、状態ファイルを読む人が区分ごとに違う導き方を覚えることになる。 - -### 決定 6: 旧い記録と語彙の付け替えを避けるため、立証不足の区分の名前は残し、軽微な指摘の残余だけに当てる - -重大な指摘の 4 つの形が未反証へ移った後、順 6(旧 5)に残るのは「再現も棄却もされていない軽微な指摘」だけである。名前(`insufficient_evidence`)を別の語へ変えると、付け替える先が 3 つ同時に出る。旧い状態ファイルの区分の値、測定スクリプトの読み方、規約と確定仕様の語彙である。**意味は「立証されておらず、修正必須でもない」に狭まるが、名前は変えない。** 区分の表の条件の列で意味を定める。 - -採らない案: **立証不足の区分を `not_blocking` へ改名する。** 語彙は正確になる。しかしこの変更の目的(数えない判断を棄却に限る)に要らず、改名は測定の比較(変更の前後の記録)を難しくする。 - -### 決定 7: 収束の判定と測定が同じ指摘を数えるよう、測定スクリプトの「この変更の方式」を数える 3 区分に揃え、2 か所の集合の一致をテストで固定する - -測定の方式「この変更の方式」(`proposed`)は、「この変更の方式が採る指摘」を数える。その定義は、状態の管理スクリプトの数える区分の集合と同じ集合である(測定スクリプトのコメントが明記する)。状態の管理スクリプトだけに未反証を足すと、収束の判定が数えた指摘を測定が採らない。この方式の再現率が実際より低く出る。**両方の定数を `("verified_blocking", "needs_human_judgment", "unrefuted")` にする。** 測定のテストに、2 つの値が等しいことを見るテストを足す。 - -測定スクリプトが状態の管理スクリプトを読み込む形は採らない。測定スクリプトは状態ファイルを読むだけの独立したスクリプトである(確定仕様の決定「測定は独立したスクリプトにする」)。状態の管理スクリプトを読み込むと、GitHub CLI を呼ぶ側の前提を持ち込む。 - -### 決定 8: 反証が届いていないラウンドを全件を数える側へ戻すため、反証が揃わない取り込みでは先に付いていた印を外す - -既存の設計の決定 5 をそのまま引き継ぐ。反証の取り込みは、揃わないとき印を付けない。しかし既に付いている印は外さない。取り直しの後もそのラウンドに印が残ると、「印を付けないため、このラウンドは全件を数えます」の出力と実際の数え方が食い違う。反証の不足の扱いの先頭で、そのラウンドの番号を印の一覧から除く。 - -決定 2 の後もこの決定は要る。印の無いラウンドは棄却と軽微な指摘も数える。反証が揃っていない(否定が届いていないかもしれない)ラウンドでは、全件を数える側が安全である。 - -### 決定 9: 誤りを示せる指摘が「立証できない」へ流れないよう、反証のプロンプトに「立証できないと返しても指摘は数から落ちない」を書く - -決定 4 の後、反証の担当が指摘を数から落とす手段は否定だけになる。プロンプトがそのことを言わないと、担当は従来どおり「判断できないものは立証できない」と返す。誤りを示せる指摘まで「立証できない」に流れ、修正の工程へ渡る。反証のプロンプトの「返す値」の表の下に 1 段落を足す。書くのは 2 つである。「立証できない」を返しても指摘は数から落ちず、修正の工程へ渡ること。誤りを示せるなら、理由を添えて否定を返すこと。 - -反証の値の語彙(5 つ)は変えない。変えるのは説明だけである。 - -### 決定 10: #729 が固定した境界を守るため、収束の判定の本体・終了コード・出力の変数は変えず、区分の内訳を新しい出力として足さない - -#729(G3)の設計が、収束の判定の終了コード 0 / 2 / 7 / 8 と出力の変数を境界として固定している。この変更が触るのは、新しい指摘の数え上げから下と、反証の不足の扱いである。「下」は数える指摘の抽出・区分の書き込み・区分の判定を指す。収束の判定の本体(`cmd_judge`)の行は書き換えない。区分ごとの件数を収束の判定の標準出力へ足す案は採らない。骨組み(`SKILL.md`)が読まない値を足しても読む側が無く、出力の契約(`docs/04`)を増やすだけである。件数は状態ファイルの区分から読める。 - -### 決定 11: 確定仕様が古いまま配布される期間を作らないため、確定仕様の区分の表は実装 Pull Request の同じ差分で更新する - -確定仕様 `docs/specifications/cross-review-evidence-based.md` は、区分の表・区分ごとの行き先・収束の判定の表を持つ。「数えるのは 2 区分だけ」を決定としても書いている。仕様化の工程(`plan-to-spec`)まで待つと、実装が配布されてから確定仕様が古いまま残る期間ができる。文書の鮮度の検査(`check-doc-staleness.py`)はそれを拾わない(確定仕様は検査の対象外である)。**区分の表・行き先の表・決定の表・テスト観点の行を、実装の差分と同じ Pull Request で直す。** 経緯の節(背景・関連リンク)は仕様化の工程が足す。 - -## データ構造 - -状態ファイル `cross-review-pr<番号>-state.json` の `review_findings[]` の要素で変わるのは 2 項目である。**新しい最上位の項目は無く、`version` の類は持たないため上げない。** - -| 項目 | 型 | 空を許すか | 変更 | -| --- | --- | --- | --- | -| `classification` | 文字列 | 許さない(区分の後) | 値の集合が 6 つになる。`verified_blocking` / `verified_non_blocking` / `rejected` / `needs_human_judgment` / **`unrefuted`** / `insufficient_evidence` | -| `unrefuted_reason` | 文字列 | 項目が無いことを許す | **新設。** `classification` が `unrefuted` のときだけ持つ。値は `no_critique` / `not_supported`。区分が変わると消える(`rejection_reason` と同じ扱い) | -| `rejection_reason` | 文字列 | 項目が無いことを許す | 変わらない。`rejected` のときだけ持つ | -| `has_evidence` | 真偽値 | 許さない | 変わらない。**区分の条件から外れるが、値は残る** | - -### 区分の条件 - -区分の判定(`_classify_finding`)が、上から順に当てる。 - -| 順 | 区分 | 条件 | 数える | 理由の項目 | -| --- | --- | --- | --- | --- | -| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | -| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | -| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | -| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | はい | — | -| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason` | -| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | - -未反証の理由は、反証の記録が空なら反証なし、1 件以上あれば支持なしである。反証の記録は提案者以外の値だけを持つため、空は「反証を返した担当が 0 者」を表す。 - -### 機能とデータの対応 - -| 機能 | 読む | 書く | -| --- | --- | --- | -| F1 数える | `severity` / `verification.result` / `critiques[].verdict` / `origin_runtimes` | `classification` | -| F2 理由を残す | `critiques` の有無 | `unrefuted_reason`(他の区分では消す) | -| F3 印を戻す | `evidence_rounds` / `rounds[].critique_relaunched` | `evidence_rounds`(番号を除く) | -| F4 測る | `classification` / `evidence_rounds` / `merged_into` | (測定の出力。状態ファイルは書かない) | - -### 移行 - -この変更より前の状態ファイルは、次に収束の判定か反証の取り込みを呼んだ時点で区分が付け直される。区分は保存された値を読まず、毎回、区分の書き込みが計算する。未反証の理由はそのときに付く。測定スクリプトは保存された区分を読むため、収束の終わった旧い記録は旧い値のまま測られ、未反証は出ない。 - -## 入出力の契約 - -**状態の管理スクリプトの引数・終了コード・標準出力の変数は変わらない。** 変わるのは内部関数の契約だけである。 - -| 関数 | 変更前 | 変更後 | -| --- | --- | --- | -| `_classify_finding(finding) -> str` | 5 つの値を返す。順 4 に `has_evidence` を求める | 6 つの値を返す。順 4 から `has_evidence` を外し、順 5 に `unrefuted` を置く。**純粋な関数で、出力も終了コードも持たない**(変わらない) | -| `_apply_classification(finding) -> str` | `rejected` に `rejection_reason` を書き、他では消す | 加えて `unrefuted` に `unrefuted_reason` を書き、他では消す | -| `COUNTED_CLASSIFICATIONS`(`state.py` / `measure.py`) | `("verified_blocking", "needs_human_judgment")` | `("verified_blocking", "needs_human_judgment", "unrefuted")`。2 か所の値は等しい | -| `_handle_incomplete_critiques(pr, st, round_no, missing)` | 印を付けない。既にある印は残す。終了コード 7 と `CRITIQUE_RETRY_AGENTS` を返す(変わらない) | 先頭で `evidence_rounds` からそのラウンドの番号を除く。それ以外は変わらない | -| `_new_finding_count(st, pr) -> (int, bool)` | 印のあるラウンドを 2 区分へ絞る | 印のあるラウンドを 3 区分へ絞る。返る値の意味は変わらない | -| `cmd_judge` | 0 / 2 / 7 / 8 / 1 | 変わらない。本体の行を書き換えない | -| `critique.sh` のプロンプト | 「判断できないものは `insufficient_evidence` にする」 | 加えて「`insufficient_evidence` を返しても指摘は数から落ちず、修正の工程へ渡る。誤りを示せるなら理由を添えて `refute`」 | - -## 構成要素 - -変えるのは、状態の管理スクリプトの 4 つの関数・定数、測定スクリプトの定数、反証のプロンプト、規約 3 文書、確定仕様、テスト 4 ファイルである。新設する要素は無い。 - -| 要素 | 責務 | 変える・新設 | -| --- | --- | --- | -| 区分の判定(`_classify_finding`) | 6 つの区分を上から順に当てる。順 4 から根拠の条件を外し、順 5 に `unrefuted`(`major` 以上の残余)を置く(決定 2・3・4) | 変える | -| 区分の書き込み(`_apply_classification`) | 区分を要素へ書く。`rejected` に `rejection_reason`、`unrefuted` に `unrefuted_reason` を残し、他の区分では両方を消す(決定 5) | 変える | -| 数える集合(`COUNTED_CLASSIFICATIONS`。`state.py` と `measure.py`) | `unrefuted` を足した 3 つにする(決定 2・7) | 変える | -| 反証の不足の扱い(`_handle_incomplete_critiques`) | 先頭でそのラウンドの印を外してから、取り直す担当を返す(決定 8) | 変える | -| 反証のプロンプト(`critique.sh`) | `insufficient_evidence` が指摘を数から落とさないことを書く(決定 9) | 変える | -| 規約と契約(`docs/04` / `docs/05` / `docs/06`) | 6 区分・数える 3 つ・1 者と起動し直しの数え方・印を外すことを書く | 変える | -| 確定仕様(`docs/specifications/cross-review-evidence-based.md`) | 区分の表・行き先の表・決定の表・テスト観点を揃える(決定 11) | 変える | -| テスト(`test_classify_findings.py` / `test_critiques.py` / `test_measure.py` / `test_skill_layout.py`) | 実測の A〜K と AC10〜AC13、集合の一致、文書の語を固定する | 変える | - -```mermaid -graph TD - J[judge] --> NF[_new_finding_count] - NF --> CK[_counted_finding_keys] - CK --> AP[_apply_classification] - AP --> CL[_classify_finding] - CK --> CC[COUNTED_CLASSIFICATIONS] - COL[collect-critiques] --> IC[_handle_incomplete_critiques] - M[measure.py _proposed] --> CC2[COUNTED_CLASSIFICATIONS measure.py] -``` - -図の辺は呼び出しと参照を表す。収束の判定・反証の取り込み・測定スクリプトは、変える要素の呼び出し元として置いた既存の要素である。プロンプト・規約・確定仕様・テストは呼び出しを持たないため、図に含めない。 - -**文脈と配置は変わらない。** 動くのは 2 つのプロセスである。ホストの CLI から起動される状態の管理スクリプトと、状態ファイルを読む測定スクリプトである。外部との出入り(GitHub CLI)は変わらない。 - -## 置き場所 - -```text -plugins/ndf/skills/cross-review/ -├── SKILL.md # 変えない(`--only` の説明は #727 が持つ) -├── docs/04-contracts.md # classification の項を 6 区分・数える 3 つへ -├── docs/05-pool-and-convergence.md # 終了基準に 1 者と起動し直しの数え方を足す -├── docs/06-evidence.md # 区分の表を 6 行へ、印を外すことを書く -├── scripts/ -│ ├── critique.sh # 返す値の表の下に 1 段落を足す -│ ├── measure.py # COUNTED_CLASSIFICATIONS に unrefuted を足す -│ └── state.py # _classify_finding / _apply_classification / COUNTED_CLASSIFICATIONS / _handle_incomplete_critiques -└── tests/ - ├── test_classify_findings.py # A〜K、AC10、期待値を変える 4 件 - ├── test_critiques.py # AC13(印を外す) - ├── test_measure.py # AC11(一致)・AC12(proposed) - └── test_skill_layout.py # AC18〜AC20 の grep -docs/specifications/cross-review-evidence-based.md # 区分・行き先・決定・テスト観点 -``` - -`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。 - -## 処理の流れ - -収束の判定は、印のあるラウンドだけ指摘ごとに区分を決め、数える 3 区分へ絞る。印の無いラウンドは、レビュー結果の本体(payload)の全件を数える。 - -```mermaid -graph TD - J[judge] --> K{payload を読めたか} - K -->|いいえ| U["(0, False) 全員 pass に従う"] - K -->|はい| M{ラウンドに印があるか} - M -->|いいえ| ALL[payload の全件を数える] - M -->|はい| C[指摘ごとに区分を決める] - C --> R{再現した} - R -->|はい| V[順 1・2 verified_*] - R -->|いいえ| X{not_reproduced / refute あり} - X -->|はい| RJ[順 3 rejected 数えない] - X -->|いいえ| S{major 以上} - S -->|いいえ| IE[順 6 insufficient_evidence 数えない] - S -->|はい| T{support あり / 2 者が独立に} - T -->|はい| NH[順 4 needs_human_judgment 数える] - T -->|いいえ| UR[順 5 unrefuted 数える] -``` - -順 5 に入った指摘は、反証の有無で未反証の理由が決まる。反証の取り込みで揃わないとき、反証の不足の扱いがそのラウンドの印を外す。次に収束の判定が数えるとき「印があるか」が「いいえ」へ進む。取り直して揃えば、印を付ける処理が印を戻す。 - -**1 者で回したラウンドの収束は、未反証の新規性と、全員が通したかの 2 つで決まる。** 担当が承認か、重大な指摘の無いコメントを返せば、全員が通したとして収束する。修正要求なら、未反証の重大な指摘が新規に数えられ、修正の工程へ進む。修正の後のラウンドで同じ指摘が戻れば、前のラウンドと一致して新規 0 件になる。戻らなければ承認で収束する。 - -## 非機能の実現方式 - -| 大項目 | 実現方式 | -| --- | --- | -| 運用・保守性 | 数える集合の 2 か所は `test_measure.py` の一致のテストが固定する。区分の理由は `rejection_reason` / `unrefuted_reason` として状態ファイルに残る | -| 移行性 | 区分は毎回計算し直すため、旧い状態ファイルの移行の処理は書かない。`version` は上げない | - -## テスト設計 - -置き場所は `plugins/ndf/skills/cross-review/tests/` の下である。区分は区分の判定の単体で確かめる。数え方は、状態ファイルを組んだ新しい指摘の数え上げと、収束の判定の終了コードで確かめる。 - -| 受け入れ条件 | 何で確かめるか | 置き場所 | -| --- | --- | --- | -| AC1・AC3 | 担当 1 者・印付きの状態ファイルを組み、`_new_finding_count` と `cmd_judge` の終了コードを見る(実測の A・C) | `test_classify_findings.py` | -| AC2 | A の指摘の `has_evidence` を偽にして同じ組を見る(実測の B) | 同上 | -| AC4 | `agy` + `kiro` で、`kiro` の指摘に反証が付き `agy` の指摘に付かない状態ファイル | 同上 | -| AC5・AC6 | `_classify_finding` に反証 `insufficient_evidence` / `out_of_scope` 付きの `major` を渡す(実測の D・E) | 同上 | -| AC7・AC8 | `has_evidence` 偽で `support` 付き、`has_evidence` 偽で `origin_runtimes` 2 者(実測の F・H) | 同上 | -| AC9 | 実測の I・J・K を既存のテストで確かめる(期待値を変えない) | 同上(既存) | -| AC10 | `_apply_classification` の後の `unrefuted_reason` の値と、区分が変わったときに消えること | 同上 | -| AC11 | `state_mod.COUNTED_CLASSIFICATIONS == measure_mod.COUNTED_CLASSIFICATIONS` と、3 つの値 | `test_measure.py` | -| AC12 | 印のあるラウンドの `unrefuted` を `proposed` の `found` が数える | 同上(既存の `test_proposed_takes_only_the_two_counted_classifications` を 3 区分へ改める) | -| AC13 | 印の付いた状態で `cmd_collect_critiques` を 2 回呼び、`evidence_rounds` と数え方を見る | `test_critiques.py` | -| AC14・AC15 | 既存のテスト(`minor` の区分、印なしの数え方)を期待値を変えずに通す | 既存のまま | -| AC16 | 4 件のテストの期待値を新しい区分へ改め、他は変えない | `test_classify_findings.py` | -| AC17 | `cmd_judge` の既存のテストを期待値を変えずに通す。差分に `cmd_judge` の行が無いことをレビューで見る | 既存のまま・設計 Pull Request のレビュー | -| AC18〜AC20 | 文書の語を `grep` するテスト | `test_skill_layout.py` | -| AC21・AC22 | コマンドの終了コード | 継続的統合と手元 | - -## 未確認のまま残ること - -6 件である。実装で決めたもの 2 件(テストの置き場所、プロンプトの文言)と、配布後の運用か別の課題で決まるもの 4 件に分かれる。 - -| 項目 | 内容 | いつ決まるか | -| --- | --- | --- | -| 収束までのラウンド数の増え方 | 実測の D・E(相手が「立証できない」を返した重大な指摘)を数えることで、2 者のループのラウンド数がどれだけ増えるかは測っていない | 配布後の運用で測定スクリプトの出力を見る | -| 未反証が多いときの修正の担当の負荷 | 誰も確かめていない重大な指摘が修正の工程へ渡る件数が増える。却下の理由を書く回数が増える | 同上 | -| 担当を 3 者以上へ広げたときの順 3 と順 4 | 確定仕様が「広げるときに決め直す」としている。この変更は 2 者のまま | #478 の後 | -| 立証不足の区分の改名 | 決定 6 で残す。意味が狭まった名前をいつ付け替えるかは未決 | 要求が出たとき | -| テストの置き場所 | テスト設計の表のとおりに置いた。区分の単体と AC1〜AC4・AC10 は `test_classify_findings.py`、AC11・AC12 は `test_measure.py`、AC13 は `test_critiques.py`、AC18〜AC20 は `test_skill_layout.py` | 実装で決めた(実装 Pull Request) | -| プロンプトの文言 | 「返す値」の表の下に 3 文を置いた。「立証できない」を返しても指摘は数から落ちず未反証として修正の工程へ渡ること、誤りを示せるなら何がそう言えるかを理由へ書いて否定を返すこと、指摘を数から落とす手段は否定だけであること | 実装で決めた(実装 Pull Request) | - -## 申し送り(並行する設計との境界) - -| 相手 | 決めた契約 | -| --- | --- | -| #730(G5、#583) | **#583 の収束の部分はこの設計で塞がる。** 起動し直した担当の指摘は、取り込みの経路が証拠集約(`verify-findings` / `critique-round.sh`)を通っても通らなくても、反証を持たない重大な指摘として未反証に入り数えられる。G5 が投稿を進行側へ移す設計を採っても、この判定は変わらない。G5 に残るのは投稿の重なりと、`JUDGE_RC -eq 7` の分岐が証拠集約を通らない点だけである | -| #729(G3) | 収束の判定の終了コード 0 / 2 / 7 / 8 と出力の変数を変えない。本体(`cmd_judge`)の行を書き換えない(決定 10)。この設計が触る関数は `_new_finding_count` から下と `_handle_incomplete_critiques` で、G3 の `_read_review_result_file` と重ならない | -| #727(G1) | `--only` の意味づけ(使える者を 1 者へ絞る)と担当の決め方は G1 が持つ。1 者のときに何を数えるかはこの設計が持つ(処理の流れの最後の段落)。G1 の実装が 1 者で回す分岐を入れても、この設計が先に入っていれば #624 の誤った収束を踏まない。**既存の要求の文書と契約の文書に足す案内の段落は G1 も同じファイルへ足す可能性がある。** 節が違うため衝突は起きにくいが、起きたら後からマージする側が解く | -| #728(G4) | 触るファイルが重ならない(`refactor_lib` は区分を持たない) | - -## 置き換える既存の設計との対応 - -**この文書は既存の設計 [issue-624-478-648-design.md](issue-624-478-648-design.md) の P4(決定 2〜5。PR #667、2026-09-15)を置き換える。** P5(決定 6〜19)は #727 の設計が持つ。 - -| 既存の決定 | この文書 | 扱い | -| --- | --- | --- | -| 決定 1(P4 / P5 に分け、P4 を先に出す) | — | 束の分け方は実行計画(G1 / G2)が引き継いだ。P4 が先という順序は保つ(G1 の 1 者で回す分岐が #624 を踏まないため) | -| 決定 2(数えない判断は「反証を受けた単独の指摘」だけに掛け、記録から導く) | 決定 2・4・5 | **改める。** 数えないのは棄却と軽微な指摘だけにし、反証を受けて支持されなかった重大な指摘も数える。記録から導く(項目を足さない)方針は、未反証の理由を足すことで改める | -| 決定 3(順 4 の支持の条件だけを外し、根拠と重大度の条件は残す) | 決定 2・3 | **改める。** 根拠の条件を外し、支持も反証も無い重大な指摘は別の区分へ入れる。重大度の条件(軽微な指摘を数えない)は引き継ぐ | -| 決定 4(区分の名前を増やさない) | 決定 2・6・7 | **改める。** 未反証を足す。増やさない理由だった 2 か所の集合と語彙の同期は、テストと同じ差分で受ける | -| 決定 5(反証が揃わない取り込みでは先に付いていた印を外す) | 決定 8 | 引き継ぐ | - -## 関連する文書 - -この文書は「どう作るか」だけを扱う。 - -| 文書 | 何を持つか | -| --- | --- | -| [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) | 何を満たすか(目的・対象範囲・用語・受け入れ条件 AC1〜AC22) | -| [issue-624-478-648-design.md](issue-624-478-648-design.md) | 置き換える前の設計(P4)。P5 は #727 の設計が持つ | diff --git a/issues/issue-732-624-706-plan.md b/issues/issue-732-624-706-plan.md deleted file mode 100644 index 393859fe..00000000 --- a/issues/issue-732-624-706-plan.md +++ /dev/null @@ -1,162 +0,0 @@ -# cross-review: 誤りを示されていない重大な指摘が数えられずに承認で終わる → 数えない指摘を棄却と軽微な指摘に限る(#732 #624 #706 の実装計画) - -## 関連リンク - -| 文書 | 何を持つか | -| --- | --- | -| [issue-732-624-706-requirements.md](issue-732-624-706-requirements.md) | 何を満たすか(受け入れ条件 AC1〜AC22) | -| [issue-732-624-706-design.md](issue-732-624-706-design.md) | どう作るか(決定 1〜11、区分の条件、入出力の契約、テスト設計)。**業務用語と識別子の対応表はこの文書の冒頭にある** | -| 親 #732、子 #624 #706 | 課題。設計 Pull Request は #783 | - -## モード - -`standard`。収束の判定が数える指摘の範囲(本番の振る舞い)を変えるため。 - -## 目的と非目的 - -達成したい状態: - -- 収束の判定が数えないのは、誤りだと示された指摘(棄却)と、承認を妨げない軽微な指摘だけになる -- 誰にも誤りを示されていない重大な指摘は、新しい区分「未反証」として数えられ、修正の工程へ渡る。「なぜ独立に確かめられていないか」の理由が状態ファイルに残る -- 効果の測定の方式「この変更の方式」が、収束の判定と同じ 3 区分を数える -- 反証が揃わなかったラウンドは、先に付いていた印が外れ、全件を数える側へ戻る -- 規約 3 文書と確定仕様が、実装と同じ差分で新しい区分を書く - -やらないこと: - -- 収束の判定の本体・終了コード・標準出力の変数の変更(#729 の束が持つ境界) -- 担当を 1 者へ絞る指定の意味づけ(#727 の束) -- 投稿の重なりの扱い(#730 の束) -- 軽微な指摘を数えること -- 立証不足の区分の改名(設計文書の決定 6) -- 既存の設計文書(`issue-624-478-648-design.md`)の本体の書き換え(設計文書の決定 1) - -## 前提 - -- 前提 1: 反証の記録は提案者以外の担当の値だけを持つ。そのため、記録が空であることは「反証を返した担当が 0 者」と同じである(状態の管理スクリプトの取り込みが提案者自身の値を落とす。既存のテスト `test_a_critique_on_own_finding_is_dropped` が固定する) -- 前提 2: 区分は保存された値を読まず、判定のたびに計算し直す。そのため、この変更より前の状態ファイルに移行の処理は要らない -- 前提 3: 収束の判定の本体を触らずに数え方を変えられる。数える指摘の抽出が数える集合を参照しており、集合の値を変えれば判定の本体は変わらない(`develop` 2b140606 の `_new_finding_count` → `_counted_finding_keys` → `COUNTED_CLASSIFICATIONS` の呼び出しで確認) - -## 受け入れ条件 - -要求文書の AC1〜AC22 をそのまま使う。条件ごとの検証手段は設計文書の「テスト設計」の表にある。この計画では、タスクごとに満たす条件の番号を書く。 - -## ドメイン用語 - -設計文書の「用語の対応表」を正本とし、ここには写さない。本文は業務用語で書き、識別子はコードブロック・表・ファイルの指し示しにだけ使う。 - -## 不変条件 - -- 実行で再現した指摘は、担当の反証によらず「再現した」の区分に入る(順 1・2 が最初に当たる) -- 誤りだと示された指摘(再現しなかった、または否定が 1 件以上)は、重大度によらず棄却になる(順 3 が順 4・5 より先に当たる) -- 軽微な指摘は、反証の有無・支持の有無によらず数えない -- 未反証の理由は、区分が未反証のときだけ存在する。区分が変われば消える(棄却の理由と同じ扱い) -- 数える区分の集合は、状態の管理スクリプトと測定スクリプトで同じ値を持つ - -## 互換性 - -| 対象 | 変更 | 互換性の扱い | -| --- | --- | --- | -| 状態の管理スクリプトの引数・終了コード・標準出力の変数 | 変えない | 変えない | -| 状態ファイルの `review_findings[].classification` | 値の集合が 5 つから 6 つになる(`unrefuted` が増える) | 追加のみ。読む側は測定スクリプトだけで、知らない値は「採らない」に落ちる | -| 状態ファイルの `review_findings[].unrefuted_reason` | 新設 | 追加のみ。`version` は上げない。旧い状態ファイルは次の判定で区分が付け直される | -| 反証のプロンプトの返す値の語彙 | 変えない | 説明の段落を足すだけ | - -## 修正対象 - -```text -plugins/ndf/skills/cross-review/ -├── docs/04-contracts.md -├── docs/05-pool-and-convergence.md -├── docs/06-evidence.md -├── scripts/critique.sh -├── scripts/measure.py -├── scripts/state.py -└── tests/ - ├── test_classify_findings.py - ├── test_critiques.py - ├── test_measure.py - └── test_skill_layout.py -docs/specifications/cross-review-evidence-based.md -issues/issue-732-624-706-design.md(「未確認のまま残ること」の 2 行だけ) -``` - -配布物の生成(`bash scripts/build-runtime-plugins.sh`)で変わるファイルがあれば同じ Pull Request に含める。 - -## タスク分解 - -機能単位で分ける。各タスクは、失敗するテスト → 通す最小実装 → 整理の順で進める。 - -### Task 1: 誤りを示されていない重大な指摘を「未反証」として数え、理由を残す - -- **対象ファイル:** `scripts/state.py`(`_classify_finding` / `_apply_classification` / `COUNTED_CLASSIFICATIONS` とその注記)、`tests/test_classify_findings.py` -- **変更内容:** 区分の判定を 6 区分にする。順 4(人の判断待ち)の条件から根拠の有無を外し、順 5 に未反証(重大な指摘の残余)を置き、順 6 を立証不足(軽微な指摘の残余)にする。区分の書き込みは、未反証に理由(反証の記録が空なら `no_critique`、あれば `not_supported`)を書き、他の区分では理由を消す。数える集合に `unrefuted` を足す。関数の docstring と注記の「5 つ」「2 つだけ」を新しい数に合わせる -- **テスト:** 設計文書の実測 A〜K を単体で固定する(A・B・D・E・F・H が変わる 6 件、C・G・I・J・K が変わらない 5 件)。AC1〜AC4 は状態ファイルを組み、新しい指摘の数え上げと収束の判定の終了コードで確かめる(既存の `test_the_new_count_uses_the_classification` の形)。AC10 は理由の値と、区分が変わったときに消えることを見る。期待値を変える既存のテストは AC16 の 4 件だけで、名前を新しい振る舞いに合わせて変える -- **満たす受け入れ条件:** AC1〜AC10、AC14、AC16 -- **進め方:** 失敗するテスト → 最小実装 → 整理 - -### Task 2: 効果の測定の方式「この変更の方式」を、数える 3 区分に揃える - -- **対象ファイル:** `scripts/measure.py`(`COUNTED_CLASSIFICATIONS` とその注記)、`tests/test_measure.py` -- **変更内容:** 測定スクリプトの数える集合に `unrefuted` を足す。注記の「2 つ」「残る 3 つ」を「3 つ」「残る 3 つ」に直す -- **テスト:** 2 つのスクリプトの集合が等しく、3 つの値を持つこと(AC11)。印のあるラウンドの未反証を「この変更の方式」が数えること(AC12。既存の `test_proposed_takes_only_the_two_counted_classifications` を 3 区分へ改める) -- **満たす受け入れ条件:** AC11、AC12 -- **進め方:** 失敗するテスト → 最小実装 → 整理 - -### Task 3: 反証が揃わない取り込みでは、先に付いていた印を外す - -- **対象ファイル:** `scripts/state.py`(`_handle_incomplete_critiques`)、`tests/test_critiques.py` -- **変更内容:** 反証の不足の扱いの先頭で、そのラウンドの番号を印の一覧から除く。取り直す担当を返す動きと終了コード 7 は変えない -- **テスト:** 印の付いた状態で反証の取り込みを 2 回呼び(1 回目は取り直し、2 回目も揃わない)、印が消えていることと、新しい指摘の数え上げがレビュー結果の本体の全件を数えることを見る(AC13) -- **満たす受け入れ条件:** AC13、AC15 -- **進め方:** 失敗するテスト → 最小実装 → 整理 - -### Task 4: 反証のプロンプトに「立証できないと返しても指摘は数から落ちない」を書く - -- **対象ファイル:** `scripts/critique.sh`、`tests/test_skill_layout.py` -- **変更内容:** 「返す値」の表の下に 1 段落を足す。書くのは 2 つ。「立証できない」を返しても指摘は数から落ちず修正の工程へ渡ること、誤りを示せるなら理由を添えて否定を返すこと。返す値の語彙は変えない -- **テスト:** プロンプトの文字列に「数から落ち」が含まれること(AC19) -- **満たす受け入れ条件:** AC19 -- **進め方:** 失敗するテスト → 最小実装 - -### Task 5: 規約 3 文書と確定仕様を、6 区分・数える 3 つへ揃える - -- **対象ファイル:** `docs/04-contracts.md`、`docs/05-pool-and-convergence.md`、`docs/06-evidence.md`、`docs/specifications/cross-review-evidence-based.md`、`tests/test_skill_layout.py` -- **変更内容:** 規約 06 の区分の表を 6 行にし、数えるのは 3 つと書き、反証が揃わないときに印を外すことを書く。規約 04 の区分の項を 6 区分・数える 3 つにし、`unrefuted_reason` の項を足す。規約 05 の終了基準に、担当 1 者と起動し直した担当の指摘の数え方を足す。確定仕様の概要・決定の表・区分の表・行き先の表・収束の判定の表・測定の表・テスト観点の行を揃える(経緯の節は仕様化の工程が足す)。測定の方式の表(規約 06・確定仕様)も 3 区分にする -- **テスト:** AC18 の 4 つの grep と AC20 の grep を配置テストで固定する -- **満たす受け入れ条件:** AC18、AC20 -- **進め方:** 失敗するテスト → 文書の更新。文書は `markdown-writing` の規約で書く(説明文の主語・目的語に識別子を置かない) - -### Task 6: 検証と配布物の同期 - -- **対象ファイル:** 生成物(`bash scripts/build-runtime-plugins.sh` が変えるもの)、`issues/issue-732-624-706-design.md`(「未確認のまま残ること」の「テストの置き場所」「プロンプトの文言」の 2 行を決めた結果で更新) -- **変更内容:** 全体テスト、フロントマターの検査、文書の鮮度・リンク・行数の検査を通す。配布物を同期する -- **満たす受け入れ条件:** AC17(収束の判定の本体の行が差分に無いことを `git diff` で見る)、AC21、AC22 -- **進め方:** コマンドの実行と結果の記録(テスト駆動は当たらない。検証の工程である) - -## 影響範囲 - -- 印のあるラウンドで、反証を受けていない・支持されていない・根拠の項目を欠く重大な指摘が数えられる。2 者で相手が「立証できない」「範囲外」を返した重大な指摘も数える。収束までのラウンド数が増えることがある(指摘 1 件につき最大 1 回) -- 修正の担当が読む指摘に未反証が加わる。扱いは人の判断待ちと同じ(直す・却下の理由を返す・範囲外として起票する) -- 効果の測定の「この変更の方式」の再現率が、収束の判定と同じ母集合で出る - -## リスクと対処 - -| リスク | 対処 | -| --- | --- | -| 状態の管理スクリプトは 3700 行を超える 1 ファイルで、G3(#729)が同じファイルの別の関数を同時に触る | タスクごとにテストを通す。触る関数を区分の判定・書き込み・数える集合・反証の不足の扱いの 4 つに限り、収束の判定の本体の行を書き換えない。競合は後からマージする側が解く | -| 数える集合が 2 か所にあり、片方だけ変わる | Task 2 の一致のテストが固定する | -| 文書の「2 つだけ」「5 つの区分」の記述が残る | Task 5 で `grep -rn "2 つだけ\|2 区分\|5 つの区分" plugins/ndf/skills/cross-review docs/specifications/cross-review-evidence-based.md` を実行し、0 件を確かめる | -| 実装の後の構造改善で足りるか | 触る範囲が狭く(関数 4 つ・定数 2 つ)、区分のテストが 30 件以上ある。構造改善は後の工程(`cross-refactoring`)で足りる | - -## 切り戻し手順 - -- Pull Request の revert で戻せる。状態ファイルの `version` を上げないため、データの移行は無い。戻した後の状態ファイルに残る `unrefuted` / `unrefuted_reason` は、次の判定で区分が付け直されるときに上書き・削除される(区分は毎回計算し直す) - -## 完了の定義 - -- [ ] AC1〜AC22 をすべて満たし、条件ごとに検証手段と結果が対応している -- [ ] `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC22 の 4 つの検査が終了コード 0 で終わる -- [ ] 収束の判定の本体(`cmd_judge`)の行が差分に含まれない -- [ ] 配布物が同期され、pre-commit の検査が通る diff --git a/issues/issue-732-624-706-requirements.md b/issues/issue-732-624-706-requirements.md deleted file mode 100644 index 9cbeeb63..00000000 --- a/issues/issue-732-624-706-requirements.md +++ /dev/null @@ -1,265 +0,0 @@ -# cross-review: 誤りを示されていない重大な指摘が数えられずに承認で終わる → 数えない指摘を棄却と軽微な指摘に限る(#732 #624 #706 の要求) - -## 目的 - -**この変更の後、cross-review の収束の判定が数えないのは、棄却された指摘(実行して再現しなかった・否定を受けた)と軽微な指摘だけになる。** 誰にも誤りを示されていない重大な指摘は、担当の数・反証の有無・根拠の項目の有無によらず新しい指摘として残り、修正の工程へ渡る。 - -いまは、誰にも誤りを示されていない重大な指摘が「数えない」区分へ落ち、新しい指摘 0 件として承認で終わる。修正の要る指摘が修正の工程へ渡らない。この形は 3 つの場面で出る。3 つの場面は同じ 1 つの直しで数えられる。立証不足の区分が 2 つの意味を兼ねる状態が解け、区分を読めば未解決の理由が分かる。 - -| 場面 | 課題 | -| --- | --- | -| 反証する担当がいない 1 者のループ | #624 | -| 実行検証も支持も無い指摘 | #706 | -| 起動し直した担当の指摘 | #583 の収束の部分 | - -## 用語と識別子の対応 - -本文は左の業務用語で書く。識別子は、コードブロック・表・受け入れ条件(検査の入力と期待値をそのまま定める)でそのまま使う。 - -| 用語 | 識別子 | 意味 | -| --- | --- | --- | -| 数える | `COUNTED_CLASSIFICATIONS`(数える区分の集合) | 収束の判定(新規性の層)が、そのラウンドの新しい指摘として件数に入れること | -| 重大な指摘 / 軽微な指摘 | `severity` の `major` / `minor`(`major` 以上 / `minor` 以下) | 指摘の重大度。軽微な指摘は承認(`APPROVE`)を妨げない | -| 区分 | `classification` | 印のあるラウンドで指摘ごとに付く値 | -| 棄却 | `rejected`(理由は `rejection_reason`) | 指摘が誤りだと示されたこと。実行して再現しなかった(`not_reproduced`)か、別の担当が否定(`refute`)を返した | -| 反証の機会 | `critiques` が 1 件以上 | 提案者以外の担当が、その指摘へ賛否を返したこと | -| 反証の値: 支持 / 否定 / 立証できない / 範囲外 | `support` / `refute` / `insufficient_evidence` / `out_of_scope` | 反証の担当が返す値のうち、この文書が扱う 4 つ | -| 立証の機会が無かった | — | 反証の機会が無い、または反証はあるが支持も否定も無い。誤りは示されていないが、独立に確かめた担当もいない | -| 未反証 | `unrefuted`(理由は `unrefuted_reason`。値は反証なし `no_critique` / 支持なし `not_supported`) | 新しい区分。重大な指摘で、再現も棄却もされておらず、独立に確かめた担当もいない | -| 人の判断待ち | `needs_human_judgment` | 独立に確かめた担当がいる重大な指摘の区分 | -| 立証不足 | `insufficient_evidence`(区分の値) | 変更後は、再現も棄却もされていない軽微な指摘だけの区分 | -| 独立に確かめた | `support` が 1 件以上、または `origin_runtimes` が 2 者以上 | 提案者以外の担当が支持を返した、または 2 者以上が同じ指摘を独立に出した | -| 根拠の 2 項目 | `evidence` と `falsification`(両方が空でないとき `has_evidence` が真) | 別の担当が確かめるための入力 | -| 印 | `evidence_rounds` | そのラウンドが統合・実行検証・反証を通ったこと。印のあるラウンドだけが区分で絞られ、無いラウンドは全件を数える | -| 代表 | `merged_into` を持たない側 | 統合した組で判定が読む 1 件。束ねられた側は `merged_into` を持つ | -| 却下の記録 | `rejected_findings` | 修正の工程が却下した指摘と理由。次のラウンドのレビュープロンプトへ渡る | -| 状態の管理スクリプト / 測定スクリプト / 反証のプロンプト | `state.py` / `measure.py` / `critique.sh` | いずれも `plugins/ndf/skills/cross-review/scripts/` の下 | -| 区分の判定 / 区分の書き込み / 新しい指摘の数え上げ / 反証の不足の扱い | `_classify_finding` / `_apply_classification` / `_new_finding_count` / `_handle_incomplete_critiques` | 状態の管理スクリプトの内部関数 | -| 収束の判定 / 反証の取り込み | `judge`(本体は `cmd_judge`)/ `collect-critiques` | 状態の管理スクリプトの副コマンド | -| 1 者に絞る引数 | `--only` | 使える担当を 1 者へ絞る起動の引数。意味づけは #727 が持つ | -| 承認で終わる | `approved` | 収束ループの結末 | - -## 対象範囲 - -変えるのは、区分の判定と数える集合、印の外し方、それらの説明と確定仕様、テストである。1 者に絞る引数の扱い・投稿の経路・終了コードは並行する設計が持つ。 - -含む: - -- `state.py` の区分の判定(`_classify_finding` / `_apply_classification`)と、数える区分の集合(`COUNTED_CLASSIFICATIONS`。`measure.py` の同名の定数も) -- `state.py` の反証が揃わないときの印の外し方(`_handle_incomplete_critiques`) -- 反証のプロンプト(`critique.sh`)の `insufficient_evidence` の説明 -- `cross-review` の `docs/04` / `docs/05` / `docs/06` の区分の表 -- 確定仕様 `docs/specifications/cross-review-evidence-based.md` の区分の表 -- テスト 4 ファイル(`tests/test_classify_findings.py` / `tests/test_critiques.py` / `tests/test_measure.py` / `tests/test_skill_layout.py`) - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| `--only` の意味づけ・使える担当の決め方・再開の引数 | #727(G1)の設計が持つ。1 者のときに何を数えるかだけをこの文書が決める | -| 起動し直した担当の投稿の重なり、投稿を進行側へ移すこと | #730(G5)の設計が持つ。#583 のうちこの文書が扱うのは、起動し直した担当の指摘が数えられずに収束する部分だけである | -| `read-result` / `judge` の終了コードと出力の変数、結末の語彙 | #729(G3)の設計が持つ。`judge` の 0 / 2 / 7 / 8 とその出力の変数は変えない | -| `cross-refactoring` の収束 | 区分を持たない(`refactor_lib` は `classification` を読まない) | -| 新規性の一致の判定(位置・近傍・本文)と振動の閾値 | 変えるのは母集合だけである(#156 の設計のまま) | -| `minor` 以下の指摘を数えること | `minor` は `APPROVE` を妨げない(`docs/03` の判定の基準)。数えると、`minor` だけの `REQUEST_CHANGES` でラウンドが続く | -| 反証の値(5 つ)の追加・削除 | 反証の担当が返す語彙は変えない。変えるのは、返った値を区分がどう読むかである | -| クラス図・型の追加 | 型を追加・変更しない(関数と辞書で組んだ状態ファイルを扱う)。状態ファイルの形は設計文書の「データ構造」が持つ | -| システムの文脈・配置の図 | 動くのは `state.py` の 1 プロセスで、外部との出入り(`gh`)は変わらない | -| `CHANGELOG.md` と版数 | 配布の工程が書く | - -## 前提 - -数える区分を増やしても成り立つことを 3 つ前提にする。修正の工程が全件を読むこと、却下の記録が同じ論点を止めること、新規性の一致が前のラウンドの指摘を新規に数えないことである。 - -| # | 前提 | 崩れたときに起きること | -| --- | --- | --- | -| 1 | 修正の工程(`fix`)は Pull Request の未解決のスレッドを区分によらず全件読む。区分は収束の判定と測定だけが読む(`plugins/ndf/skills/fix/` と `docs/02-fix-and-rotation.md` に区分を読む箇所が無い。`grep -rn classification` が 0 件) | 数えるだけで修正へ渡らない指摘が生まれ、同じ指摘が毎ラウンド新規に見える | -| 2 | 却下した指摘は `rejected_findings` に位置と理由つきで残り、次のラウンドのレビュープロンプトへ渡る(#156 の 1 本目) | 数える区分が増えたとき、同じ論点が戻る。この記録がそれを止める | -| 3 | 新規性の一致(位置・近傍・本文)は変えない。前のラウンドと一致する指摘は、区分によらず新規に数えない | — | - -## 受け入れ条件 - -22 件である。区分 12 件(AC1〜AC12)、印 1 件(AC13)、退行しない 4 件(AC14〜AC17)、文書 3 件(AC18〜AC20)、全体 2 件(AC21〜AC22)。#624 の再現は AC1、#706 の再現は AC5 と AC7、#583 の収束の部分は AC4 が確かめる。各条件は検査の入力と期待値をそのまま定めるため識別子で書く。業務用語との対応は「用語と識別子の対応」にある。 - -### 区分(#624 / #706 / #583 の収束の部分) - -- [ ] AC1: 前提: 担当が 1 者(`only: "codex"`)で、印の付いたラウンドが 1 つある。そのラウンドに根拠の 2 項目を持つ `major` の指摘が 1 件、反証は 0 件、前のラウンドは無い - 操作: `_new_finding_count` と `judge` を呼ぶ - 結果: `_new_finding_count` が `(1, True)` を返し、`judge` が終了コード 2 で終わる。指摘の `classification` は `unrefuted`(変更前は `(0, True)` と終了コード 0。#624 の再現) -- [ ] AC2: 前提: AC1 の指摘が根拠の 2 項目のどちらかを欠く(`has_evidence` が偽) - 結果: `_new_finding_count` は `(1, True)` を返す。`classification` は `unrefuted` で、`has_evidence` は偽のまま残る -- [ ] AC3: 前提: AC1 の指摘が `minor` である - 結果: `_new_finding_count` は `(0, True)` を返し、`classification` は `insufficient_evidence` である -- [ ] AC4: 前提: 担当が `agy` + `kiro` で、印の付いたラウンドがある。そのラウンドで `kiro` の指摘は反証を持ち、`agy` の根拠を持つ `major` は反証 0 件のまま入っている(起動し直した担当の指摘が、反証を取り込んだ後に取り込まれた形) - 結果: `_new_finding_count` が `agy` の 1 件を数える。`classification` は `unrefuted`(#583 の収束の部分) -- [ ] AC5: 前提: 担当 2 者で、相手が根拠を持つ `major` へ `insufficient_evidence` を返し、`support` も `refute` も無い - 結果: `classification` は `unrefuted` で、数える(変更前は `insufficient_evidence` で数えない。#706 の「反証の担当が `support` を返さなかった」) -- [ ] AC6: AC5 で相手が `out_of_scope` を返したときも、`classification` は `unrefuted` で、数える -- [ ] AC7: 前提: `major` の指摘に `support` が 1 件以上ある - 結果: `has_evidence` の真偽によらず `classification` は `needs_human_judgment` である。変更前は `has_evidence` が偽なら `insufficient_evidence` だった(#706 の「`support` が付いていても根拠の 2 項目の欠落で落ちる」) -- [ ] AC8: `origin_runtimes` が 2 者以上の `major` は、`has_evidence` の真偽によらず `needs_human_judgment` である -- [ ] AC9: `refute` が 1 件以上、または実行検証が `not_reproduced` の指摘は `rejected` になり、`unrefuted` より先に当たる。`reproduced` の指摘は `verified_blocking` / `verified_non_blocking` になる(変更前と同じ) -- [ ] AC10: `unrefuted` の指摘は `unrefuted_reason` を持つ。値は `no_critique`(反証を返した担当が 0 者)か `not_supported`(反証はあるが `support` も `refute` も無い)のどちらかである。他の区分の指摘は `unrefuted_reason` を持たない(区分が変わったときは消える) -- [ ] AC11: `COUNTED_CLASSIFICATIONS` は `verified_blocking` / `needs_human_judgment` / `unrefuted` の 3 つである。`state.py` と `measure.py` の値が一致する -- [ ] AC12: `measure.py` の方式 `proposed` は、印のあるラウンドの `unrefuted` の指摘を `found` に数える - -### 反証の取り直しで揃わないときは印を外す - -- [ ] AC13: 前提: 印が付いたラウンドがある - 操作: `collect-critiques` を実行し、反証が揃わない(終了コード 7) - 結果: `evidence_rounds` からそのラウンドの番号が消え、`_new_finding_count` は payload の全件を数える。取り直した後も揃わないとき(2 度目)も印は付かない - -### 退行しない - -- [ ] AC14: `minor` 以下の指摘の区分は変わらない。`support` が付いた `minor` は `insufficient_evidence`、再現した `minor` は `verified_non_blocking` で、どちらも数えない -- [ ] AC15: 印を持たないラウンドの数え方(payload の全件)は変わらない -- [ ] AC16: `tests/test_classify_findings.py` の既存のテストのうち、期待値を変えるのは旧い区分を固定した 4 件だけである。それ以外は期待値を変えずに通る。4 件は次のとおり - `test_support_without_evidence_is_insufficient` / `test_nothing_matched_is_insufficient` / - `test_a_finding_without_verification_is_readable` / `test_only_two_classifications_are_counted` -- [ ] AC17: `judge` の終了コード(0 / 2 / 7 / 8 / 1)は変わらない。標準出力の変数(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS` / `RELAUNCH_AGENTS`)も変わらない。`cmd_judge` の本体の行を書き換えない - -### 文書 - -- [ ] AC18: 規約の 3 文書が新しい区分を書く。`docs/06-evidence.md` の区分の表が 6 行になり、`unrefuted` の行と「数えるのは 3 つ」を持つ。`docs/04-contracts.md` の `classification` の項が 6 区分と数える 3 つを書く。`docs/05-pool-and-convergence.md` の終了基準が、担当 1 者と起動し直した担当の指摘の数え方を書く。次の 4 つがそれぞれ 1 行以上を出す - - ```bash - grep -n "unrefuted" plugins/ndf/skills/cross-review/docs/06-evidence.md - grep -n "unrefuted" plugins/ndf/skills/cross-review/docs/04-contracts.md - grep -n "unrefuted" plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md - grep -n "印を外す" plugins/ndf/skills/cross-review/docs/06-evidence.md - ``` - -- [ ] AC19: `critique.sh` のプロンプトが「`insufficient_evidence` を返しても指摘は数から落ちない。誤りを示せるなら `refute` を返す」ことを書く。`grep -n "数から落ち" plugins/ndf/skills/cross-review/scripts/critique.sh` が 1 行以上を出す -- [ ] AC20: 確定仕様 `docs/specifications/cross-review-evidence-based.md` が `docs/06-evidence.md` と同じ 6 区分を持つ。持つのは区分の表と区分ごとの行き先の表である。`grep -c "unrefuted" docs/specifications/cross-review-evidence-based.md` が 2 以上を出す - -### 全体 - -- [ ] AC21: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC22: 次の 4 つが終了コード 0 で終わる - - ```bash - python3 scripts/check-skill-frontmatter.py - python3 scripts/check-doc-staleness.py - python3 scripts/check-markdown-links.py --root . - python3 scripts/check-doc-line-limit.py - ``` - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 運用・保守性 | 数える区分の集合は `state.py` と `measure.py` の 2 か所にあり、一致をテストが固定する(AC11)。区分の理由(`rejection_reason` / `unrefuted_reason`)は状態ファイルに残り、収束の後に「なぜ数えたか・数えなかったか」を読める | -| 移行性 | 状態ファイルの `version` は上げない。この変更より前の状態ファイルは、次に `judge` を呼んだ時点で区分が付け直される(区分は毎回計算し直す)。旧い記録の `classification` を `measure.py` が読むときは、旧い値のまま読む | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | `state.py` の引数・終了コード・出力の変数は変わらない。状態ファイルの `classification` に値 `unrefuted` が増え、`unrefuted_reason` が増える(読む側は `measure.py` だけ) | -| データ | 状態ファイルの形は変わらない(項目が 1 つ増える。`version` は上げない) | -| 既存の振る舞い | 印のあるラウンドで、反証を受けていない・支持されていない・根拠の項目を欠く重大な指摘が数えられるようになる。2 者で相手が「立証できない」を返した重大な指摘も数える(#156 の判断を改める。理由は設計文書の決定 4)。収束までのラウンド数が増えることがある | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | -| 静的解析・文書の検査 | AC22 の 4 コマンド | -| 再現手順 | #624: 状態ファイルを最小の形で組み `_new_finding_count` と `cmd_judge` を呼ぶ(AC1)。#706: `_classify_finding` に反証 `insufficient_evidence` 付きの `major` と、`support` 付きで根拠を欠く `major` を渡す(AC5 / AC7) | -| 手動確認 | 無し(すべてテストで確かめる) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | Skill の実体は `plugins/ndf/skills/cross-review/`。テストは同じ Skill の `tests/`。`dev.kiro` / `dev.agy` は symlink で参照するため書き写す配布物は無い | -| コーディング規約 | `state.py` は stdlib だけの uv 自己完結スクリプト(`AGENTS.md` / `cross-review/SKILL.md`)。区分の判定は純粋な関数で、出力も終了コードも持たない | -| テスト戦略 | 区分は `_classify_finding` の単体で、数え方は状態ファイルを組んだ `_new_finding_count` と `cmd_judge` の終了コードで確かめる(既存の `test_classify_findings.py` の形) | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 区分の判定と数える集合の変更、印の外し方、文書と確定仕様の更新、テスト | -| 確認してから行う | 無し | -| 行わない | `--only` の扱い(#727)、投稿の移動(#730)、終了コードと結末の語彙(#729)、`minor` を数えること | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 未反証を数えることで収束までのラウンド数がどれだけ増えるか | 実装の後の運用で測定スクリプトの出力を見る | 配布後 | - -## 置き換える既存の受け入れ条件との対応 - -**この文書は、既存の設計 [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) の P4(AC1〜AC9。PR #667、2026-09-15)を置き換える。** P5(#478 #648)は #727 の設計が持ち、この文書は触らない。#583 の投稿の重なりは #730 の設計が持つ。 - -| 既存 | この文書 | 扱い | -| --- | --- | --- | -| AC1(1 者、根拠付き `major`、反証 0 件 → 数える) | AC1 | 引き継ぐ。区分の名前が `needs_human_judgment` から `unrefuted` へ変わる | -| AC2(1 者、`minor` → 数えない) | AC3 | 引き継ぐ | -| AC3(1 者、根拠なし → 数えない) | AC2 | **改める。** 根拠の項目の欠けは立証の機会が無かった側に入る(親 #732) | -| AC4(起動し直した担当の `major` → 数える) | AC4 | 引き継ぐ。区分は `unrefuted` | -| AC5(揃わないときに印を外す) | AC13 | 引き継ぐ | -| AC6(2 者で相手が `insufficient_evidence` / `out_of_scope` → 数えない) | AC5 / AC6 | **改める。** 数える(設計文書の決定 4) | -| AC7(`origin_runtimes` 2 者の区分は変わらない) | AC8 | 引き継ぐ。根拠の条件は外す | -| AC8(印なしのラウンドと実行検証の区分は変わらない) | AC9 / AC15 / AC16 | 引き継ぐ。期待値を変える既存のテスト 4 件を名指しする | -| AC9(文書の 3 つの grep) | AC18 | 引き継ぐ。語を `unrefuted` に変える | - -## 依頼(原文) - -3 件の依頼はいずれも、指摘が数えない区分へ落ちたまま収束する現象を報告している。#732 が根本原因と採る手を定め、#624 と #706 がそれぞれの再現を持つ。引用のため、識別子と文の長さは元のまま残す。 - -### #732(根本原因の親) - -> **cross-review の収束で「数えない」とする区分。** 場所は `plugins/ndf/skills/cross-review/scripts/state.py` の `_classify_finding`(3443 行目)と `COUNTED_CLASSIFICATIONS`(3431 行目)、規約 `docs/06-evidence.md` の区分の表である。 -> -> - `insufficient_evidence` が 2 つの場合を兼ねている。反証の機会があって支持されなかった場合と、立証の手段が無かった場合(反証する担当がいない・実行検証が無い・根拠の項目が欠ける)である -> - どちらも新規性の数から落ちるため、立証の手段が無かっただけの未解決の `major` が残ったまま収束する -> -> #583 の収束の部分も、同じ区分を通る。 -> -> ## 採る手 -> -> 分離(`extract_strategy`)。「棄却された(`refute` / `not_reproduced`)」と「立証の機会が無かった」を別の区分にし、数えない判断を前者だけに掛ける。 -> -> ## 完了条件 -> -> - 数えない判断が棄却された指摘に限られ、反証の機会が無かった `major` が新規性に残ることを検査が確かめる -> - 各子 issue の再現手順を実行し、現象が出ないことを確かめる(子 issue はその時点の棚卸が「閉じてよい」で閉じる) - -### #624 - -> `cross-review` を `--only codex` で 1 者だけにして回すと、codex が `REQUEST_CHANGES` で新しい指摘を投稿したラウンドでも、`judge` が収束と判定する(ndf 10.10.1、2026-09-13)。 -> -> `--only` で担当が 1 者だと、反証する他の担当がいないため数える区分に入る指摘が 0 件になり、`_evaluate_convergence`(`:2848`)の `findings_measurable and new_findings == 0` が真になる。 -> -> 状態ファイルを最小の形で組み、関数を直接呼んだ再現(`--only codex`、証拠付きの major 1 件、印の付いたラウンド 1): -> -> ```text -> new_finding_count (0, True) classification insufficient_evidence -> round_passes False converged True -> ``` - -### #706 - -> `cross-review` で、**未解決の `major` が残っているのに、指摘が数えない区分 `insufficient_evidence` へ落ち、新規性の母集合から外れて「新しい指摘 0 件」と判定される。** 結果は `approved` になる。 -> -> ideabase の PR #45 のラウンド 2 で、3 件の指摘がいずれも `suggested_check` を実行できず `insufficient_evidence` になり、judge が `NEW_FINDINGS=0` を返した。指摘のうち 2 件は別の担当が `support` を付けており、こちらでもコードを読んで再現を確かめられた(文書が実装と食い違っていた)。 -> -> 数えない区分へ落ちるのは、次のいずれかに当たる指摘である。 -> -> - 反証の担当が `support` を返さなかった(`insufficient_evidence` を返した、または反証が無い) -> - 根拠の 2 項目(`evidence` / `falsification`)のどちらかが欠けている -> - 重要度が `minor` 以下である -> -> 対処の候補: 実行検証ができない指摘は `insufficient_evidence` ではなく別の区分(未検証)として新規性に数える、または他の担当の `support` が付いた指摘は区分によらず数える。 - -## 関連する文書 - -この文書は「何を満たすか」だけを扱う。 - -| 文書 | 何を持つか | -| --- | --- | -| [issue-732-624-706-design.md](issue-732-624-706-design.md) | どう作るか(決定の記録・実測・データ構造・契約・処理の流れ・テスト設計) | -| [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) | 置き換える前の受け入れ条件(P4)。P5 は #727 の設計が持つ | diff --git a/issues/refactoring-plan-rf790.md b/issues/refactoring-plan-rf790.md deleted file mode 100644 index 3ebd316d..00000000 --- a/issues/refactoring-plan-rf790.md +++ /dev/null @@ -1,286 +0,0 @@ -# 改修計画 — devbasex/ai-plugins #790 - -`/ndf:cross-refactoring` が提案し、適用した改善項目の記録である。 -理由と手順は提案の時点でしか残らないため、公開の直前に書き出している。 - -- 対象範囲: plugins/ndf/skills/cross-review/scripts, plugins/ndf/skills/cross-review/tests -- 着手前のテスト: uv run --with pytest pytest scripts/tests plugins/ndf -q - -## ラウンド 1(実装 codex / レビュー agy / kiro) - -### R1-001 — `plugins/ndf/skills/cross-review/scripts/measure.py#measure` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | codex / agy | 採用 | 1 | - -**なぜ**: measure 関数に None や辞書以外の型が渡されたとき、空辞書にフォールバックして例外なく指標辞書(pr, prs, rounds, methods, cost, convergence)を返す境界値の振る舞いが固定されていない - -**手順**: 1. evidence_rounds に文字列の有効ラウンド番号、重複する整数、不正値を含み、印付き・印なし両方の findings を持つ state を作る -2. measure を実行する -3. 有効番号が一つの印として扱われ、不正値が無視され、proposed の found・oracle_scope・oracle_base が現在の値になることを比較する - -### R1-002 — `plugins/ndf/skills/cross-review/scripts/critique-round.sh#critique-round` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | integration | — | kiro | 採用 | 1 | - -**なぜ**: run_round を通す既存テストは 2 本とも指摘 0 件で、collect-critiques が 1 回目に 0 を返して 1 回で抜ける経路しか固定していない。exit 7 と CRITIQUE_RETRY_AGENTS を受けて 2 回目の起動を回すループ本体(for _attempt in 1 2)はどのテストも通っていない。 - -**手順**: 1. critique-round.sh を temp ディレクトリへ複製し、隣に stub の critique.sh・monitor.py・state.py・_tmpdir.sh を置く(test_wait_review.py と同じ、兄弟スクリプトを差し替える方式) -2. stub の state.py collect-critiques を、呼び出し回数を記録したうえで 1 回目は stdout に CRITIQUE_RETRY_AGENTS='kiro' を出して終了コード 7、2 回目は終了コード 0 を返すようにする -3. stub の critique.sh は渡された担当名を追記で記録し、対応する pid ファイルを作る -4. critique-round.sh に PR・ROUND・agy kiro を渡して実行し、終了コード 0 を確かめる -5. critique.sh の記録が 2 回目は kiro だけへ絞られている(agy は再起動されない)ことと、collect-critiques が 2 回呼ばれたことを比較する - -### R1-003 — `plugins/ndf/skills/cross-review/scripts/critique-round.sh#critique-round` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | integration | — | kiro | 採用 | 1 | - -**なぜ**: collect-critiques が 7 以外を返したときに exit "$COLLECT_RC" でその終了コードを素通しする分岐が固定されていない。既存テストは 0 で抜ける経路だけを見ており、失敗の終了コードがラウンドの外へ伝わるかを誰も確かめていない。 - -**手順**: 1. critique-round.sh を temp ディレクトリへ複製し、隣に stub の critique.sh・monitor.py・state.py・_tmpdir.sh を置く -2. stub の state.py collect-critiques を、呼び出し回数を記録して終了コード 5 で終わるようにする -3. critique-round.sh に PR・ROUND・担当を渡して実行する -4. 終了コードが 5(collect-critiques が返した値)と一致することを確かめる -5. collect-critiques が 1 回だけ呼ばれ、2 回目の起動へ進んでいないことを記録から確かめる - -### R1-004 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#cmd_prepare` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| normal | integration | — | codex | 採用 | 1 | - -**なぜ**: 公開入口 prepare は state、GitHub の PR メタデータ、git log/diff をつないで prepare.json と eval 用の出力を作るが、既存テストは生成済み prepare.json を与えるだけで、この経路を実行していない - -**手順**: 1. 一時 worktree と state を作り、gh pr view・git fetch/log/diff を現状の出力を返す代替コマンドへ差し替える -2. rotate-pr.sh prepare を公開 CLI から実行する -3. 終了コード 0、stdout の shell 代入を評価して得る値、prepare.json の PR・branch・draft・round・git 要約を比較する - -### R1-005 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | integration | — | codex | 採用 | 1 | - -**なぜ**: 新 PR 作成失敗時に旧 PR を reopen する経路は light モードだけ固定され、同じ ERR trap を使う squash モードでは未固定である - -**手順**: 1. squash の close までは成功し、gh pr create だけが失敗する代替 git・gh と一時 state を用意する -2. rotate-pr.sh execute --mode squash を公開 CLI から実行する -3. 非ゼロ終了、新 PR が存在しないこと、旧 PR の最終状態が open に戻ること、成功用の NEW_PR が出ないことを比較する - -## ラウンド 2(実装 agy / レビュー codex / kiro) - -### R2-001 — `plugins/ndf/skills/cross-review/scripts/measure.py#measure` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | unit | — | codex / agy | 採用 | 1 | - -**なぜ**: measure の oracle 算出において、resolved_thread_positions の要素が辞書形式である経路は固定されているが、リスト内に非辞書要素(文字列や null など)が混在した場合にそれを unmatched として数えて測定を継続する分岐が未固定である - -**手順**: 1. resolved_thread_positions に文字列や null などの非辞書要素を含む状態ファイルを用意する -2. measure を実行する -3. oracle の found が 0、unmatched が 1、ambiguous が 0 と計算され、例外を出さずに全体の測定結果が返ることを確かめる - -### R2-002 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#cmd_execute` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| boundary | integration | — | agy / kiro | 採用 | 1 | - -**なぜ**: cmd_execute の引数解析は不正な --mode 値と未知フラグ(exit 2)は固定済みだが、--mode の直後に値が無いとき ${2:?--mode requires light|squash} で落ちる境界と、そもそも引数が 0 個のときの entrypoint の usage(exit 2)は固定されていない。 - -**手順**: 1. rotate-pr.sh execute --mode を値なしで実行し、終了コードが 0 以外で stderr に --mode requires light|squash が出ることを確かめる -2. rotate-pr.sh を引数なしで実行し、終了コードが 2 で usage が stderr に出ることを確かめる -3. いずれも state.json を用意せず、gh/git を呼ぶ前の引数解析だけで止まることを確かめる - -### R2-003 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | integration | — | agy / kiro | 取り消し | 1 | - -**なぜ**: execute_light は prepare.json / newtext.json の有無と title/body の null を 4 本の分岐で弾くが、既存テストは prepare.json と newtext.json が両方揃った成功・失敗経路(_Rotation)しか通していない。前提ファイルが欠ける分岐と、newtext.json の title が空・body が null になる分岐はどのテストも到達していない。 - -**手順**: 1. test_rotate_pr_queue.py の _Rotation と同じ組み立て(state.json・bin の gh/git 代替)を使い、gh/git は呼ばれる前に止まることを見込む -2. prepare.json を書かずに execute --mode light を実行し、終了コードが 1 で stderr に prepare.json not found が出ることを確かめる -3. prepare.json は置き newtext.json を書かずに実行し、終了コード 1 と stderr の newtext.json not found を確かめる -4. newtext.json に {"title": "", "body": "x"} を書いて実行し、終了コード 1 を確かめる -5. newtext.json に {"title": "x", "body": null} を書いて実行し、終了コード 1 を確かめる -6. いずれの分岐でも gh の呼び出し記録(GH_CALLS)が空で、旧 PR を close していないことを確かめる - -### R2-004 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#load_state` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | integration | — | agy / kiro | 採用 | 1 | - -**なぜ**: load_state は state.json が不在または空のときに終了コード 1 と state.json not found を返して中断するが、rotate-pr.sh の公開入口を経由してこのエラー経路を通すテストが無い。launch-reviewer.sh 等では固定されているが rotate-pr.sh では未固定である - -**手順**: 1. CROSS_REVIEW_TMP_DIR を空の temp ディレクトリに向け、state.json を置かない -2. rotate-pr.sh execute を実行する -3. 終了コードが 1 であることを確かめる -4. stderr に state.json not found が含まれることを確かめる -5. gh/git の代替を PATH に置き、呼び出し記録が空(load_state の手前で止まる)であることを確かめる - -### R2-005 — `plugins/ndf/skills/cross-review/scripts/state.py#cmd_set_current_pr` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | unit | — | codex / agy | 採用 | 1 | - -**なぜ**: cmd_set_current_pr において、pr_history に既に複数の履歴(過去に閉じた PR と現在開いている PR)が存在する場合に、過去 PR のエントリを変更せず直前の現在 PR のみ closed_at と rounds を更新して新 PR エントリを追加する分岐が未固定である - -**手順**: 1. 閉じた過去 PR(closed_at 設定済み)と現在の PR(closed_at が None)を順に含む pr_history を持つ状態ファイルを用意する -2. cmd_set_current_pr(pr, new_pr, head_branch) を実行する -3. 過去 PR の closed_at や rounds が変更されず保持されることを確かめる -4. 直前の現在 PR に closed_at が記録され、rounds がその PR のラウンド数と一致することを確かめる -5. 新 PR エントリが closed_at: None、rounds: 0 で末尾に追加されることを確かめる - -## ラウンド 3(実装 kiro / レビュー codex / agy) - -### R3-001 — `plugins/ndf/skills/cross-review/scripts/state.py#_verify_findings` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 未着手 | 0 | - -**なぜ**: 検証コマンドの正規化・重複実行の抑止・実行結果の分類・各 finding への記録・統合グループ代表の最良結果選択という独立した段階が 1 関数に連続し、実行キャッシュと統合関係の走査を同時に追う必要がある。 - -**手順**: 1. 1 finding の verification record を生成し、コマンド実行キャッシュを利用して結果を分類する helper を抽出する -2. merged_into の関係をたどって代表へ最良の verification を選ぶ処理を別 helper へ抽出する -3. _verify_findings は対象抽出、各 finding の検証、代表結果の集約という 3 段階だけを並べる -4. test_verify_findings.py と findings pipeline の既存テストで、同一コマンドの実行回数、結果優先順位、finding_id、ran_at が不変であることを確認する - -### R3-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_resume_from_state` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 未着手 | 0 | - -**なぜ**: 再開 state の探索・互換フィールドの補完・未解決指摘の引き継ぎ・待ち行列の flush・worktree 同期・結果出力という複数段階が 1 関数に同居し、書き戻しと flush の順序制約まで同じ本体で管理している。 - -**手順**: 1. state の互換フィールド補完と review_instructions 再構成を、state と変更有無を返す helper へ抽出する -2. 書き戻し後の auto-flush と worktree 同期を、順序を保持した再開準備 helper へ抽出する -3. _resume_from_state は state の有無・完了判定、各 helper の呼び出し、既存の _print_init_result だけを順に行う構成へ縮める -4. 既存の再開・carried-over・worktree 同期・run metrics のテストで出力と副作用順が不変であることを確認する - -### R3-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_thread_ids` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | agy | 未着手 | 0 | - -**なぜ**: _thread_ids における入力データ(リスト、単一辞書、数値等)の辞書要素抽出・正規化ロジックが、同モジュール内の共通関数 _normalize_dict_items と同じ関心をインラインで再実装しており重複している。_thread_positions と同様に _normalize_dict_items を呼び出す形に統一することで、入力値の正規化処理を一元化し一貫性と保守性を高められる。 - -**手順**: 1. _thread_ids 内の辞書要素抽出処理を _normalize_dict_items(value) の呼び出しに置き換える -2. 既存の test_state_thread_ids.py を実行し、各種入力に対する戻り値が変わらないことを確認する - -### R3-004 — `plugins/ndf/skills/cross-review/scripts/state.py#_is_generated_path` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| magic_value | introduce_named_constant | minor | agy | 未着手 | 0 | - -**なぜ**: パス分類判定関数群(_is_dependency_path, _is_config_ci_path, _is_infra_path 等)がモジュール定数(DEPENDENCY_FILENAMES, CONFIG_CI_FILENAMES, INFRA_FILENAMES 等)を参照しているのに対し、_is_generated_path 内にのみロックファイル名の一覧 set リテラルがハードコードされている。名前付きモジュール定数 GENERATED_LOCK_FILENAMES を定義して参照させることで、定数管理の一貫性と保守性を向上できる。 - -**手順**: 1. モジュール定数 GENERATED_LOCK_FILENAMES を定義する -2. _is_generated_path 内の set リテラルを GENERATED_LOCK_FILENAMES の参照に置き換える -3. 既存テストでパス分類の判定動作が不変であることを確認する - -### R3-005 — `plugins/ndf/skills/cross-review/scripts/state.py#_absorb` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 未着手 | 0 | - -**なぜ**: 「2 つの指摘のうち検証結果 (reproduced > not_reproduced > not_run) が高い方を採る」という同じ業務ルールが _absorb (3645-3646 行) と _verify_findings の代表選び直しループ (3137-3138 行) の 2 箇所に _VERIFY_RANK.get(...) > _VERIFY_RANK.get(...) の比較として書かれている。_VERIFY_RANK の順位定義を変えるときや、片方だけ result の取り出し方 (_verify_result vs best.get('result')) を直したときに、もう片方だけ取り残される。両者の docstring がどちらも同じ順位を根拠に挙げており、同じ理由で一緒に変わる重複である。 - -**手順**: 1. _VERIFY_RANK 定義の直後に、2 つの verification dict を受け取り順位の高い方を返すヘルパー _higher_ranked_verification(current, candidate) を追加する(rank は _verify_result で正規化して比較する) -2. _verify_findings の代表選び直しループ (3135-3140) を、best と member['verification'] をヘルパーへ渡して best を更新する形へ置き換える -3. _absorb の verification 継承部 (3644-3648) を、同じヘルパーで rep['verification'] を更新する形へ置き換える -4. test_verify_findings.py / test_merge_duplicates.py / test_state_merge_fix.py を実行し、reproduced/not_reproduced/not_run の組で代表が採る値が変わらないことを確認する - -## ラウンド 4(実装 claude / レビュー codex / kiro) - -### R4-001 — `plugins/ndf/skills/cross-review/scripts/state.py#COUNTED_CLASSIFICATIONS` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| scattered_config | centralize_configuration | major | codex | 採用 | 1 | - -**なぜ**: 収束判定が数える区分の組が state.py と measure.py に重複し、両者の一致をテストで監視している。区分追加時に片方だけ変わると、実行時の収束判定と事後測定が異なる集合を数える。 - -**手順**: 1. scripts 配下の小さな共有モジュールへ COUNTED_CLASSIFICATIONS を移す -2. state.py と measure.py は共有定義を import して各判定に使う -3. 値そのものと両経路の既存出力を既存テストで固定する - -### R4-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_finding_keys` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | split_into_pipeline | major | codex | 採用 | 1 | - -**なぜ**: レビュワーごとのファイル解決、JSON 読み込み、payload と comments の境界検証、path・line・本文の正規化が1つの二重ループに入り、入力境界の失敗とキー変換の責務が分離されていない。 - -**手順**: 1. payload ファイルの読み込みと dict 検証を第1段へ抽出する -2. comments 要素の検証と3要素キーへの変換を第2段へ抽出する -3. _finding_keys はレビュワー列挙から各段をつなぐ処理だけにする -4. 不正 payload・不正 comment・欠損位置・正常な振動照合の既存テストを各段階で実行する - -### R4-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 1 | - -**なぜ**: 新規初期化の1関数内に PR 所有権解決、レビュー条件作成、worktree と既存コメントの準備、担当認証、初期 state 構築、保存と表示がネスト関数として同居し、各段階を単独で参照・テストできない。 - -**手順**: 1. ネストされた各段階を同じ入出力のモジュールレベル関数へ順に移す -2. _init_new_state はコンテキストを段階間で受け渡すオーケストレーションだけにする -3. init の再開・新規作成・既存 worktree・コメント取得失敗の既存テストを各抽出後に実行する - -### R4-004 — `plugins/ndf/skills/cross-review/tests/conftest.py#_no_github` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| test_bypasses_module_boundary | move_responsibility | minor | codex | 採用 | 1 | - -**なぜ**: autouse fixture が state_mod を引数に取るため、monitor.py や measure.py だけを検査するテストまで state.py を共通入口から読み込み、GitHub 照会の内部関数を一律に差し替えている。 - -**手順**: 1. subprocess の gh 実行ガードと state.py の既定差し替えを別 fixture に分ける -2. state.py の差し替えは state_mod を利用するテスト経路だけが要求する形へ移す -3. monitor・measure のテストが state.py を読み込まず、state 系テストでは従来どおり実 GitHub 呼び出しを防ぐことを確認する - -### R4-005 — `plugins/ndf/skills/cross-review/scripts/measure.py#_proposed` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | codex | 採用 | 1 | - -**なぜ**: 証拠ラウンドの検査、採用 finding 集合の作成、oracle のラウンド別分母への絞り込み、出力メタデータ付与を1関数が連続して担い、分母規則だけを独立に検証しにくい。 - -**手順**: 1. finding_id から round を引き分母を絞る処理を _scoped_oracle_ids として抽出する -2. oracle が未計算の場合と evidence_rounds が一部だけの場合の戻り値を明示する -3. _proposed は採用集合の作成と出力組み立てだけに残し、既存の measure テストを実行する - -## 見送った項目 - -| ラウンド | 対象 | 兆候・経路 | 理由 | -| --- | --- | --- | --- | -| 1 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` | normal | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` | branch | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` | normal | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_collect_critiques` | boundary | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_collect_critiques` | error | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_verify_findings` | error | 1 ラウンドの採用上限 5 件を超えた | -| 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#launch_reviewer` | branch | 1 ラウンドの採用上限 5 件を超えた | -| 2 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light` | branch | コミット 4cd469bc388e45e7c6e77f0793dc45cbad66c08c にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | -| 3 | `plugins/ndf/skills/cross-review/scripts/state.py#_apply_classification` | long_method | 1 ラウンドの採用上限 5 件を超えた | -| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_finding_keys` | duplication | 1 ラウンドの採用上限 5 件を超えた | -| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_print_init_result` | long_parameter_list | 1 ラウンドの採用上限 5 件を超えた | -| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | テストの期待する振る舞いが変わっています(plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | diff --git a/issues/refactoring-plan-rf791.md b/issues/refactoring-plan-rf791.md deleted file mode 100644 index a5a60c35..00000000 --- a/issues/refactoring-plan-rf791.md +++ /dev/null @@ -1,370 +0,0 @@ -# 改修計画 — devbasex/ai-plugins #791 - -`/ndf:cross-refactoring` が提案し、適用した改善項目の記録である。 -理由と手順は提案の時点でしか残らないため、公開の直前に書き出している。 - -- 対象範囲: plugins/ndf/scripts/lib, plugins/ndf/skills/cross-review/scripts, plugins/ndf/scripts/tests, plugins/ndf/skills/cross-review/tests -- 着手前のテスト: uv run --with pytest pytest scripts/tests plugins/ndf -q - -## ラウンド 1(実装 codex / レビュー agy / kiro) - -### R1-001 — `plugins/ndf/scripts/lib/auth.py#check_auth` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | unit | — | kiro | 採用 | 1 | - -**なぜ**: 終了コード 0 でも UNAUTHENTICATED_MARKERS を含む出力を未認証と判定する分岐が固定されていない。これはこのモジュールの存在理由そのものだが未固定である - -**手順**: 1. subprocess.run を差し替え、returncode 0 かつ stdout に 'not logged in' を含む結果を返す -2. check_auth(['codex'], ...) を呼ぶ -3. results['codex']['ok'] が False であることを確認する -4. failed があるため die が一度呼ばれることを確認する - -### R1-002 — `plugins/ndf/scripts/lib/auth.py#check_auth` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | unit | — | kiro | 未着手 | 0 | - -**なぜ**: 確認コマンドが見つからないとき(FileNotFoundError)に ok=False とし detail を 'コマンドが見つかりません' にする分岐が固定されていない - -**手順**: 1. subprocess.run を差し替え、FileNotFoundError を送出させる -2. check_auth(['codex'], ...) を呼ぶ -3. results['codex']['ok'] が False であることを確認する -4. results['codex']['detail'] に見つからない旨が入り、die が一度呼ばれることを確認する - -### R1-003 — `plugins/ndf/scripts/lib/monitor_outcome.py#append_journal` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| normal | unit | — | agy | 採用 | 1 | - -**なぜ**: monitor_outcome.py には read_journal の単体テストはあるが、ペアとなる append_journal の単体テストが存在しない。親ディレクトリの自動生成や非ASCII文字(UTF-8)の保持を含め、複数回の追記によって順序通りに記録・復元できる正常系が単体レベルで未固定である。 - -**手順**: 1. 一時ディレクトリと複数の outcome 辞書を準備する -2. append_journal を複数回呼び出して追記する -3. read_journal で読み出し、追記された順序と内容が期待通り一致することを検証する - -### R1-004 — `plugins/ndf/scripts/lib/monitor_outcome.py#default_result_path` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| normal | unit | — | agy | 未着手 | 0 | - -**なぜ**: read_launch_outcome の既定フォールバック先として使われる公開関数 default_result_path について、パス命名規則(/-result.json)を直接検証する単体テストが存在しない。 - -**手順**: 1. tmp_dir と stem を準備する -2. default_result_path(tmp_dir, stem) を呼び出す -3. 返された Path が期待される /-result.json と等しいことを検証する - -### R1-005 — `plugins/ndf/scripts/lib/monitor_outcome.py#relaunch_same_agent` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | agy | 未着手 | 0 | - -**なぜ**: relaunch_same_agent は再試行可否の判定に使われる。既存テストは定義済み語彙(REASONSの9語)のみをテストしており、reason=None や空文字列 ""、未知の理由文字列が渡された場合に True を返す境界値の挙動が固定されていない。 - -**手順**: 1. None、空文字列 ""、未知の理由文字列を引数として準備する -2. relaunch_same_agent を呼び出す -3. いずれも戻り値が True であることを検証する - -## ラウンド 2(実装 agy / レビュー codex / kiro) - -### R2-001 — `plugins/ndf/scripts/lib/refresh.py#compare` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | unit | — | kiro | 採用 | 1 | - -**なぜ**: compare は取得失敗・前回記録なし・一致・不一致の 4 分岐を返す純関数だが、対象範囲のどこでも固定されていない。instructions-check の既存テストは refresh.fetch をスタブへ差し替えるため、compare 本体は一度も通らない - -**手順**: 1. ok=False の FetchResult を作り compare(result, 'sha256:aa') を呼ぶ -2. ok=True で fingerprint を持つ FetchResult を作り、previous を None・空文字・同じ指紋・異なる指紋の 4 通りで呼ぶ -3. 実行して得た戻り値(取得失敗・前回記録なし・一致・不一致を表す文字列)を期待値として固定する - -### R2-002 — `plugins/ndf/scripts/lib/refresh.py#fetch` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | unit | — | kiro | 取り消し | 1 | - -**なぜ**: fetch は取得の失敗を例外にせず FetchResult(ok=False, error=...) へ畳む分岐を持ち、error の文言は _reason が例外の種類ごとに書き分ける。この経路は未固定で、instructions-check のテストは fetch 自体を差し替えるため通らない。opener は差し替え用に設計された引数である - -**手順**: 1. opener に OSError を送出する呼び出し可能を渡し fetch(url, timeout, opener) を呼ぶ -2. urllib.error.HTTPError と urllib.error.URLError を送出する opener でも同様に呼ぶ -3. 3 通りとも ok が False で、実行して得た error の文言(種類ごとに異なる)を期待値として固定する - -### R2-003 — `plugins/ndf/scripts/lib/refresh.py#refresh` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| normal | integration | — | kiro | 採用 | 1 | - -**なぜ**: refresh は fetch・row・compare をつないで全件の 1 行と失敗件数を返す公開入口だが、この配線を通す固定が対象範囲に無い。取れなかった URL を黙って落とさず件数へ数える振る舞いが未固定である - -**手順**: 1. 成功と失敗を混ぜて返す opener を差し替えで用意する -2. name/checked_at/claim を持つ複数の source を渡し refresh(sources, timeout, opener) を呼ぶ -3. 返る行数が source 数と一致し、失敗件数が失敗した source 数と一致することを固定する -4. 各行に source の name と取得の成否が含まれることを、実行して得た値で固定する - -## ラウンド 3(実装 kiro / レビュー codex / agy) - -### R3-001 — `plugins/ndf/scripts/lib/monitor_outcome.py#OUTCOME_KEYS` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| scattered_config | centralize_configuration | major | kiro | 採用 | 1 | - -**なぜ**: 監視の結果ファイルのキーの一覧が 3 か所に散っている。monitor_outcome.OUTCOME_KEYS(14 個・runtime では未使用)と、実際に辞書を組み立てる monitor.py の _record_outcome(`phase` を含む 15 個)と、test_monitor_outcome_file.py の独自コピー(15 個)である。正本のはずの OUTCOME_KEYS が `phase` を欠いており既に食い違っている。キーを足すたびにどこかが古くなる。 - -**手順**: 1. OUTCOME_KEYS へ `phase` を加え、契約文書の並びと揃える -2. monitor.py の _record_outcome が組み立てた辞書のキー集合を OUTCOME_KEYS と突き合わせる assert を置き、食い違いをその場で落とす(値の生成は現状のまま) -3. read_journal / read_outcome の既存テストと test_monitor_outcome_file.py を実行し、キー集合の期待が変わっていないことを確かめる - -### R3-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_handle_no_result_round` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 採用 | 1 | - -**なぜ**: 1 関数に理由の集約と出力、再起動不能時の終了処理、再起動済み時の終了処理、再起動対象の記録とシェル向け出力という別々の段階が同居し、状態保存と終了条件が複数箇所に散っている。 - -**手順**: 1. 担当別理由の収集と NO_RESULT_REASONS 出力を小さな関数へ抽出する -2. final・ended_at・保存・die を行う共通の異常終了処理を抽出する -3. 再起動対象の記録と互換出力を行う処理を抽出する -4. usage_limit、再起動済み、再起動要求の既存テストで終了コード・state・出力順を固定する - -### R3-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_print_init_result` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_parameter_list | introduce_parameter_object | major | codex | 採用 | 1 | - -**なぜ**: 初期化結果という同じ概念を表す 11 引数を位置で受け取り、特に連続する 3 個の bool と末尾の件数・再開フラグは呼び出し側で順序を取り違えても検出しにくい。新規初期化と再開の 2 経路が同じ組を渡している。 - -**手順**: 1. 出力対象を表す _InitResult の値オブジェクトを定義する -2. _print_init_result の引数を _InitResult 1 個へ置き換える -3. 新規初期化経路と再開経路で名前付きフィールドから _InitResult を構築する -4. 両経路の既存テストで標準出力が不変であることを確認する - -### R3-004 — `plugins/ndf/scripts/lib/monitor.py#monitor_agent` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | codex | 採用 | 1 | - -**なぜ**: 監視ループの 5 つの終了分岐が、同じ _finish_monitor(status, outcome, (config.log_prefix, agent)) 呼び出しを繰り返している。終了時に渡すログ文脈を変更すると各分岐を同時に直す必要がある。 - -**手順**: 1. status とログ文脈を閉じ込めて outcome を受け取る局所的な finish 処理を定義する -2. PID 不正・完了・timeout・early error・process exit・stall の各終了分岐を同じ入口へ寄せる -3. monitor_agent を通る既存テストで status、ログ、終了結果が不変であることを確認する - -### R3-005 — `plugins/ndf/skills/cross-review/scripts/state.py#_verify_findings` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | kiro | 採用 | 1 | - -**なぜ**: 1 つの関数が 2 つの独立した段を通しで行う。前段は各指摘の suggested_check を(重複を除いて)実行し verification を記録する反復、後段は束ねた組の代表へ最良の結果を選び直す反復である。段ごとに名前が付き、共有するのは targets と by_id だけである。 - -**手順**: 1. 前段を _run_finding_checks(targets, allowed, work, codes, run) として抽出し、実行済みコマンドの対応表を関数内へ閉じる -2. 後段を _propagate_best_verification(targets, by_id) として抽出する -3. _verify_findings は codes / run / targets / by_id を用意し、2 つを順に呼ぶだけにする -4. test_verify_findings.py を実行して verification の記録が変わらないことを確かめる - -## ラウンド 4(実装 claude / レビュー codex / kiro) - -### R4-001 — `plugins/ndf/skills/cross-review/scripts/state.py#_resume_from_state` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 採用 | 1 | - -**なぜ**: 既存stateの探索、旧形式の補完、追加レビュー観点の再計算、引き継ぎ記録、保存、待ち行列flush、worktree同期、機械可読出力までが1関数に直列で置かれ、副作用の順序を長い本体とコメントから追う必要がある。各段階には独立した終了条件と入出力があり、名前を付けて分離できる。 - -**手順**: 1. cmd_initを通る既存の再開テストで、stateなし、final済み、旧形式補完、追加観点更新、引き継ぎ、flush後の同期と出力を現状固定する -2. stateファイルの探索と再開可否判定を、stateとpathを返す関数へ抽出する -3. 旧形式の補完、manual指示の反映、review_instructions再計算、carried_over記録を、変更有無も返す関数へ抽出する -4. 保存後のauto_flush、tmp_dir解決、登録済みworktree同期を副作用順序が見える関数へ抽出する -5. _resume_from_stateを各段階の呼び出しと_print_init_resultだけにし、再開関連テストと全体テストで出力と保存順序が不変であることを確認する - -### R4-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 1 | - -**なぜ**: 新規初期化の1関数に、PR所有権の解決、レビュー観点の構築、worktreeと既存コメントの準備、認証確認、state構築、永続化と出力が同居し、さらに5個のローカル関数が本体を約200行へ広げている。各段階は既に名前と入出力を持つため、モジュールレベルへ抽出すれば段階単位で読めて個別にテストできる。 - -**手順**: 1. 既存の入口テストで、新規worktree、既存worktree、変更ファイル取得fallback、認証失敗、初期state出力の経路を現状固定する -2. _resolve_pr_and_ownership と _prepare_review_instructions をモジュールレベル関数へ抽出し、既存のcontext型を入出力に使う -3. _prepare_worktree_and_comments をモジュールレベル関数へ抽出し、worktree作成より後に_tmp_dirを呼ぶ順序とコメント取得失敗時の停止を保つ -4. _prepare_initial_assignment、_build_initial_review_state、_finalize_initial_state をモジュールレベルへ抽出し、_init_new_stateを段階を順に呼ぶオーケストレーションだけにする -5. 初期化関連テストと全体テストを実行し、標準出力、stateの内容、副作用の順序が不変であることを確認する - -### R4-003 — `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| conditional_chain | replace_with_lookup_table | minor | kiro | 取り消し | 1 | - -**なぜ**: 現ラウンドの各指摘について _finding_match_kind の戻り値 ("exact"/"near"/"body") を if/elif で数えている。種別ごとの集計は種別を増やすたびに分岐を足すことになる。あわせて、collect_keys クロージャは _finding_keys(st, pr, round_no) をそのまま呼ぶだけの指標なしの間接参照で、読み手が本体を追う負荷を増やしている。test_state_check_oscillation.py と test_state_oscillation_matching.py が cmd_check_oscillation / _finding_match_kind を通す。 - -**手順**: 1. exact/near/same_body の 3 変数と if/elif/elif の加算を、collections.Counter に対する `Counter(_finding_match_kind(k, prev) for k in curr)` へ置き換える -2. overlap_count は None 以外の合計として counts の値の総和から出す -3. info の表示は counts.get("exact", 0) 等から読む -4. collect_keys クロージャを消し、呼び出し 2 箇所を _finding_keys(st, pr, prev_round_no) / (curr_round_no) の直接呼び出しに戻す -5. テストを実行して振る舞い不変を確認する - -### R4-004 — `plugins/ndf/skills/cross-review/scripts/state.py#_normalize_fix_result` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | kiro | 採用 | 1 | - -**なぜ**: 1 関数に (a) 別名 fallback(fix_commit/commit_sha、fixed_count/fixed)、(b) deferred の list/dict/int による件数の場合分けと dict 要素への正規化、(c) rejected の同じ正規化、(d) 記録用辞書の組み立て、が同居する。deferred と rejected はどちらも _normalize_dict_items + 件数決定という同型の処理で、片方だけ直すと食い違いうる。test_state_merge_fix.py と test_state_ci_classification.py が cmd_merge_fix 経由で通す。 - -**手順**: 1. 「_normalize_dict_items した項目」と「保存する件数」を組で返す小関数 _normalize_deferred_like(raw) を抽出する(list/dict は展開件数、劣化表現の int/str は _count の値、という現在の規則をそのまま移す) -2. deferred と rejected の両方をこの関数で得る(現状 rejected の件数は常に _count なので、raw の型で分岐する現在の deferred 規則へ揃える形にはせず、抽出関数は deferred の規則を表し、rejected は従来どおり _count を使うなら別に保つ。振る舞いを変えないため、まず deferred 経路だけを抽出する) -3. 別名 fallback(fix_commit/fixed_count)を _resolve_fix_aliases として抽出する -4. 末尾の辞書組み立てを、抽出した値を差し込む形へ整える -5. テストを実行して出力の辞書が不変であることを確認する - -## ラウンド 5(実装 codex / レビュー agy / kiro) - -### R5-001 — `plugins/ndf/scripts/lib/post_queue.py#Queue.flush` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 1 | - -**なぜ**: 1 件の処理の中に、壊れた JSON の停止判定、既投稿の照合と削除、送信成功時の応答保存と削除、送信失敗時の再試行情報保存と rate limit 判定が直列に並び、flush 自体が順序制御と各項目の状態遷移の両方を担っている。 - -**手順**: 1. 読み取り済み項目について既投稿・送信成功・送信失敗を処理する部分を Queue の補助メソッドへ抽出する -2. 補助メソッドの戻り値で継続または停止と rate_limited を表し、flush は連番走査と集計だけを担うようにする -3. 壊れた項目で停止する既存経路は flush 側に残し、項目順序と停止位置を変えない -4. test_post_queue.py と cross-review/tests/test_queue_idempotency.py で skipped・sent・failed・remaining とファイル削除順を確認する - -### R5-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 1 | - -**なぜ**: PR head の取得方法の決定、strict 時の同期済み判定と失敗処理、worktree の reset・未追跡ファイル掃除、同期結果の表示という独立した段階が 1 関数に同居している。HeadRef と旧来の文字列 head の分岐も取得段階に閉じず、後続の制御へ have_base・target・label の組で持ち越されている。 - -**手順**: 1. HeadRef と文字列 head から have_base・target・label を解決する取得段階を補助関数へ抽出する -2. strict 時の同期済み早期終了と基準取得失敗の判定を補助関数へ抽出する -3. _sync_worktree は取得、判定、reset、clean、結果表示の順序だけを示す構成にする -4. test_state_sync_worktree.py と test_state_offline_fetch.py で既存の strict/fallback/失敗時終了コードを確認する - -### R5-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_load_payload` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 取り消し | 0 | - -**なぜ**: payload が dict でない場合と comments が list でない場合の 2 経路が、`info(f"⚠ {agent}: ...形式不正で、判定は中断します")` を出して `return None` する同じ形で並ぶ。返す条件(dict でない / list でない)と型名の埋め込みが繰り返され、警告文の末尾の定型句も重複する。片方の文言だけ直すと 2 経路のメッセージが食い違う。test_review_findings.py が不正 payload での 0 件記録を固定している。 - -**手順**: 1. `_reject_payload(agent: str, path: pathlib.Path, detail: str) -> None` を追加し、`info(f"⚠ {agent}: {detail}({path}...)。指摘の記録は 0 件です。review launcher の出力形式不正で、判定は中断します")` を出して None を返す -2. dict でない場合と comments が list でない場合の 2 経路を、type 名を含む detail 文字列を渡す呼び出しへ置き換える -3. 部分不正(items != raw)は継続する経路のため対象にせず、そのまま残す -4. `uv run --with pytest pytest plugins/ndf/skills/cross-review/tests/test_review_findings.py -q` で 0 件記録と警告が不変なことを確認する - -### R5-004 — `plugins/ndf/scripts/lib/metrics.py#format_report` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 取り消し | 1 | - -**なぜ**: unmeasured と assumed の 2 節が同じ形(見出し + 空行を lines へ足し、dict.fromkeys で重複を除いた項目を `- {w}` で並べる)で並んでいる。片方だけ書式を変えると 2 節の見た目が食い違う。同じ業務ルール(分離・代用の一覧の出し方)に由来し、変わるときは一緒に変わる。既存テスト test_models_and_metrics.py が format_report の出力を固定している。 - -**手順**: 1. `_emit_bullet_section(lines: list[str], title: str, items: list[str]) -> None` を追加し、items が空でなければ `['', f'## {title}', '']` と `[f'- {w}' for w in dict.fromkeys(items)]` を lines へ足す -2. unmeasured の if ブロックを `_emit_bullet_section(lines, "集計から分離したラウンド", metrics["unmeasured"])` へ置き換える -3. assumed の if ブロックを `_emit_bullet_section(lines, "指定値で代用したラウンド", metrics.get("assumed") or [])` へ置き換える -4. 比較の限界(COMPARISON_CAVEATS)節は常に出るため対象外のまま残す -5. `uv run --with pytest pytest scripts/tests plugins/ndf -q` で出力が不変なことを確認する - -### R5-005 — `plugins/ndf/skills/cross-review/scripts/state.py#build_parser` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | codex | 採用 | 1 | - -**なぜ**: init の多数のオプション定義と、ラウンド進行・結果取込・検証・報告に属する 11 個の副コマンド登録が 1 関数に連続しており、個別コマンドの引数変更でも 125 行の構築処理全体を読む必要がある。副コマンドごとに独立した名前を付けられる段階になっている。 - -**手順**: 1. init のパーサ設定を専用の補助関数へ抽出する -2. 各副コマンドの parser 作成・引数追加・set_defaults を用途別の小さな登録関数へ抽出する -3. build_parser はトップレベル parser と subparsers を作り、登録関数を順に呼んで返すだけにする -4. test_state_subcommand_help.py、test_state_review_pool.py、test_findings_pipeline_wiring.py で選択肢・help・func の対応が不変であることを確認する - -## ラウンド 6(実装 agy / レビュー codex / kiro) - -### R6-001 — `plugins/ndf/scripts/lib/auth.py#check_auth` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 採用 | 1 | - -**なぜ**: スキップ判定、CLI ごとの subprocess 実行、例外の認証結果への変換、結果の集約、全体の失敗通知が 1 関数に同居しており、個別 CLI のプローブ規則と複数 CLI の制御を別々に読めない。既存の test_auth_probe.py が未知 runtime、成功、未認証マーカー、コマンド不在を公開入口から固定している。 - -**手順**: 1. 1 runtime の probe 実行と FileNotFoundError・TimeoutExpired の認証結果への変換を、runtime と probe を受け取る補助関数へ抽出する -2. ok・detail・command の結果を check_auth が受け取り、既存どおり info 出力、failed 集約、die 判定を行う形へ置き換える -3. test_auth_probe.py を実行し、戻り値、通知文、die 呼び出しが不変であることを確認する - -### R6-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_confirm_flushed` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 採用 | 1 | - -**なぜ**: 待ち行列項目の適用可否判定、response からの URL 復元、対象ラウンド探索、GitHub 到達確認、結果なしまたは成功状態への更新、永続化が 1 関数に直列で同居している。投稿確認は収束可否に関わるため、対象特定と状態遷移を独立した名前で読める構造にする価値が高く、test_state_queue_judge.py が送信済み・冪等スキップ・未到達の経路を固定している。 - -**手順**: 1. item の kind・extra・response を解釈して agent、round、review URL を返す処理を補助関数へ抽出する -2. state の rounds から書き戻し対象を探す処理を補助関数へ抽出する -3. _confirm_flushed は早期 return、到達確認、既存と同じ target 更新、_save の順序だけを担うよう置き換える -4. test_state_queue_judge.py を実行し、queued、review_url、not_posted、保存回数を含む観測結果が不変であることを確認する - -### R6-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_guard_previous_round` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 未着手 | 0 | - -**なぜ**: 旧形式 state の verdict 復元、修正記録の必須判定、申告済み Resolve の GitHub 照会、未解決 ID の検出という独立した 2 つのガードが 1 関数に同居している。各ガードは異なる理由で変更され、test_state_round_guard.py が修正記録なし、照会不能、未解決残存、正常通過を固定している。 - -**手順**: 1. 保存済み verdict が無い場合の no_result・pass からの復元を補助関数へ抽出する -2. fix の resolved_thread_ids と対象 PR を受け、照会不能時の通知または未解決 ID を判定する補助関数へ抽出する -3. _guard_previous_round は修正記録ガードと Resolve 状態ガードを順に呼ぶ構成へ置き換える -4. test_state_round_guard.py を実行し、終了コード、GitHub 照会先、警告、正常通過が不変であることを確認する - -### R6-004 — `plugins/ndf/skills/cross-review/scripts/state.py#_is_generated_path` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| scattered_config | centralize_configuration | minor | kiro | 未着手 | 0 | - -**なぜ**: 兄弟の判定述語(_is_dependency_path は DEPENDENCY_FILENAMES、_is_infra_path は INFRA_FILENAMES、_is_generated_path 自身も GENERATED_MARKERS)はいずれもモジュール定数を引くのに、_is_generated_path だけ lockfile 名の集合(package-lock.json / go.sum / cargo.lock など 9 件)を関数本体へじか書きしている。うち package-lock.json 等は DEPENDENCY_FILENAMES にも重複して載っており、lockfile を足すとき 2 か所を直す必要があるうえ、どこに定義があるか読み手が探す。定義を 1 か所へ寄せて兄弟と同じ形にする。 - -**手順**: 1. モジュール定数群(DEPENDENCY_FILENAMES / GENERATED_MARKERS / INFRA_FILENAMES の並び)へ GENERATED_LOCKFILES を新設し、現在インラインにある 9 件の集合をそのまま移す -2. _is_generated_path の本体を `name in GENERATED_LOCKFILES` へ置き換え、インラインの集合リテラルを消す -3. test_gap を埋めるため、先に go.sum / cargo.lock(DEPENDENCY_FILENAMES に無く、インライン集合にだけある名前)で generated カテゴリが立つ現状固定テストを test_state_auto_review_templates.py に追加し、移動の前後で同じ結果になることを確認する - -## 見送った項目 - -| ラウンド | 対象 | 兆候・経路 | 理由 | -| --- | --- | --- | --- | -| 1 | `plugins/ndf/scripts/lib/statefile.py#save` | error | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/scripts/lib/statefile.py#save` | normal | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | boundary | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_read_result` | error | 1 ラウンドの採用上限 5 件を超えた | -| 2 | `plugins/ndf/scripts/lib/refresh.py#fetch` | error | テストの期待する振る舞いが変わっています(plugins/ndf/scripts/tests/test_refresh.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | -| 3 | `plugins/ndf/scripts/lib/monitor.py#_record_outcome` | long_method | 1 ラウンドの採用上限 5 件を超えた | -| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | テストの期待する振る舞いが変わっています(plugins/ndf/skills/cross-review/tests/test_init_body_not_duplicated.py)。構造改善では期待出力を変えません。振る舞いの変更は別の変更に分けてください | -| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#cmd_check_oscillation` | conditional_chain | コミット 95387271f1b307d6c0a88f1cde8f6401fe3a6111 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | -| 5 | `plugins/ndf/scripts/lib/post_queue.py#Queue.flush` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 5 | `plugins/ndf/scripts/lib/metrics.py#format_report` | duplication | どの改善項目にも割り当てられていないコミットが 1 件(311bf7f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_load_payload` | duplication | どの改善項目にも割り当てられていないコミットが 1 件(1e8216f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | diff --git a/issues/refactoring-plan-rf793.md b/issues/refactoring-plan-rf793.md deleted file mode 100644 index 6e5dda05..00000000 --- a/issues/refactoring-plan-rf793.md +++ /dev/null @@ -1,399 +0,0 @@ -# 改修計画 — devbasex/ai-plugins #793 - -`/ndf:cross-refactoring` が提案し、適用した改善項目の記録である。 -理由と手順は提案の時点でしか残らないため、公開の直前に書き出している。 - -- 対象範囲: plugins/ndf/scripts/lib, plugins/ndf/skills/cross-review/scripts, plugins/ndf/scripts/tests, plugins/ndf/skills/cross-review/tests -- 着手前のテスト: uv run --with pytest pytest scripts/tests plugins/ndf -q - -## ラウンド 1(実装 codex / レビュー agy / kiro) - -### R1-001 — `plugins/ndf/scripts/lib/assignment.py#detect_host` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | unit | — | agy | 採用 | 1 | - -**なぜ**: detect_host は収束ループ共通層においてホストを確定する重要関数であり、誤判定すると母集合が狂う致命的な影響を持つ。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが全く存在しない。明示指定(explicit)、環境変数ヒント(HOST_ENV_HINTS)の順序による推定、および手掛かりがない場合の例外送出の各分岐を共通層単体テストとして固定する必要がある。 - -**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_detect_host_* を追加する。 -2. 明示指定分岐: HOST_RUNTIMES に含まれる名前を指定したときに (host, 'explicit') が返り、無効な名前を指定したときに AssignmentError が送出されることを検証する。 -3. 環境変数推定分岐: CLAUDE_PLUGIN_ROOT, CODEX_HOME, KIRO_AGENT 等の環境変数ヒントを含む辞書を渡し、正しいホスト名と 'env' が返ることを検証する。 -4. 推定不能分岐: 環境変数が空辞書(またはヒントなし)の場合に、既定値を勝手に置かず AssignmentError が送出されることを検証する。 - -### R1-002 — `plugins/ndf/scripts/lib/assignment.py#review_seats` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | unit | — | agy | 採用 | 1 | - -**なぜ**: assignment.py で新設された review_seats は、cross-review において各ラウンドのレビュワー2席を割り当てるコア関数である。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが一切存在しない(別スキル cross-refactoring のテスト側に暫定配置されているのみ)。len(available) の人数(3者以上の輪番、2者の固定、1者時の fallback または副席 -2 補填、0者時の fallback 2席割当および fallback 空時の例外送出)の全分岐の振る舞いを共通層の単体テストとして固定する必要がある。 - -**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_review_seats_* を追加する。 -2. 3者以上: available=['codex', 'agy', 'kiro'] でラウンド1〜3を実行し、available の順序を保った2席が輪番で選ばれることを検証する。 -3. 2者: available=['codex', 'kiro'] で複数ラウンドを実行し、ラウンド番号によらず常にその2者が返ることを検証する。 -4. 1者: available=['codex'], fallback=['claude'] で ['codex', 'claude'] が返り、fallback が空または available と重複する場合は ['codex', 'codex-2'] が返ることを検証する。 -5. 0者: available=[], fallback=['claude'] で ['claude', 'claude-2'] が返り、fallback も空の場合は AssignmentError となることを検証する。 -6. round_no < 1 の場合に AssignmentError が送出されることを検証する。 - -### R1-003 — `plugins/ndf/scripts/lib/assignment.py#review_seats` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex | 採用 | 1 | - -**なぜ**: 0 人でも fallback がある経路は状態初期化から固定されているが、available と fallback がともに空の公開入口が AssignmentError になる経路は未固定である。 - -**手順**: 1. round_no=1、available=[]、fallback=[] で公開入口を呼ぶ -2. AssignmentError が送出されることを観測する -3. 例外の利用者向け理由から、使える者と埋め合わせ候補がともに無いことを示す要点だけを確認する - -### R1-004 — `plugins/ndf/scripts/lib/assignment.py#seat_runtime` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | agy | 取り消し | 1 | - -**なぜ**: seat_runtime は正規表現 SEAT_PATTERN(^(claude|codex|agy|kiro)(-[2-9])?$)に従って席名を検証・抽出するが、共通層テストに境界値・異常値のテストが存在しない。接尾辞の数値境界(-1 は不可、-2〜-9 は可、-10 は不可)、区切り文字違い(_2)、未知のランタイム、空文字列等で AssignmentError が送出される境界値の振る舞いを単体レベルで固定する必要がある。 - -**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_seat_runtime_rejects_malformed_seat を追加する。 -2. 接尾辞の数値境界: 'kiro-1'(下限未満)、'kiro-10'(上限超過)で AssignmentError が発生することを検証する。 -3. 区切り形式・重複の境界: 'claude_2'(アンダースコア)、'kiro-2-3'(ハイフン重複)、空文字列 '' で AssignmentError が発生することを検証する。 -4. 未知のランタイム: 'gemini', 'gpt' 等の ALL_RUNTIMES 外の名称で AssignmentError が発生することを検証する。 - -### R1-005 — `plugins/ndf/scripts/lib/assignment.py#seat_runtime` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| normal | unit | — | agy | 採用 | 1 | - -**なぜ**: assignment.py で新設された seat_runtime(seat: str) は、席名から基底ランタイム名を取り出す共通層関数であり、結果受け口・起動スクリプト・監視処理で広く使われる。しかし共通層テスト(plugins/ndf/scripts/tests/)には単体テストが存在しない。接尾辞なしのランタイム名(claude, codex, agy, kiro)および同一ランタイムの副席名(-2〜-9 接尾辞)から正確にランタイム名が抽出される正常系の振る舞いを共通層単体テストとして固定する必要がある。 - -**手順**: 1. plugins/ndf/scripts/tests/test_lib_assignment.py に test_seat_runtime_extracts_runtime_name を追加する。 -2. ALL_RUNTIMES の全ランタイム名('claude', 'codex', 'agy', 'kiro')をそのまま渡した場合に、同一のランタイム名が返ることを検証する。 -3. ハイフン付き席名('kiro-2', 'claude-9', 'agy-3' 等)を渡した場合に、接尾辞を除去した基底ランタイム名が正しく返ることを検証する。 - -## ラウンド 2(実装 agy / レビュー codex / kiro) - -### R2-001 — `plugins/ndf/scripts/lib/assignment.py#assign` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex / agy | 採用 | 1 | - -**なぜ**: assign は 8 ラウンド周期の割り当てを行う公開関数であり正常系は固定されているが、round_no < 1(0 や負数)が渡された場合に AssignmentError を送出するエラー経路が scripts/tests 内で固定されていない。 - -**手順**: 1. 有効な各ホスト(claude, codex, agy, kiro)について assignment.assign(0, host) および assignment.assign(-1, host) を呼び出す -2. どちらも assignment.AssignmentError が送出されることを検証する -3. 送出された例外メッセージに「ラウンド番号は 1 以上です」が含まれることを検証する - -### R2-002 — `plugins/ndf/scripts/lib/assignment.py#resolve_participants` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | codex / agy | 採用 | 1 | - -**なぜ**: resolve_participants で母集合の全メンバーを exclude に指定し、参加可能なメンバーが 0 件になる下限境界の振る舞い(空一覧で認証確認が呼ばれ、available が空リスト、excluded が固定順で記録されること)が固定されていない。 - -**手順**: 1. 母集合の全員(例: ['codex', 'agy', 'kiro'])を exclude に指定し、記録用プローブを渡して resolve_participants を呼び出す -2. プローブが空の一覧 [] で 1 回だけ呼ばれることを検証する -3. 戻り値の Participants において available が []、unavailable が {}、excluded が固定順(['codex', 'agy', 'kiro'])で保持されることを検証する - -### R2-003 — `plugins/ndf/scripts/lib/assignment.py#review_assign` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| boundary | unit | — | agy / kiro | 採用 | 1 | - -**なぜ**: review_assign の round_no < 1 の下限境界条件で AssignmentError を送出する振る舞いが scripts/tests 内で固定されていない。同モジュールの impl_assign や review_seats には round_no < 1 の境界テストがあるが、review_assign だけ抜けている。 - -**手順**: 1. test_lib_assignment.py で assignment.review_assign(0, "claude") および assignment.review_assign(-1, "claude") を呼び出す -2. どちらの呼び出しでも assignment.AssignmentError が送出されることを検証する -3. 例外メッセージに「ラウンド番号は 1 以上です」が含まれることを検証する - -### R2-004 — `plugins/ndf/scripts/lib/assignment.py#review_assign` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| branch | unit | — | agy / kiro | 取り消し | 1 | - -**なぜ**: review_assign は適用の役を持たない工程が使う公開入口だが、scripts/tests には直接の固定が無い。in-scope の test_lib_assignment.py は assign / impl_assign / review_seats を固定するだけで、この関数の輪番(母集合3者から dropped=(round_no-1)%3 を外す各分岐)は通っていない。out-of-scope の cross-review テストは _round_reviewers の照合オラクルとして呼ぶだけで、この関数自身の戻り値を固定していない。 - -**手順**: 1. test_lib_assignment.py の assignment フィクスチャで各ホスト(claude, codex, agy, kiro)について review_assign(round_no, host) を round 1..6 で呼び出す -2. 各ホストで返る担当ペアの一覧が 3 ラウンド周期で循環し、現状の決定結果(例: claude は [['agy', 'kiro'], ['codex', 'kiro'], ['codex', 'agy']] が 2 周する)と完全一致することを検証する -3. 返されるレビュー担当が常に 2 者であり、指定したホスト自身を含まないことを併せて検証する - -### R2-005 — `plugins/ndf/scripts/lib/assignment.py#review_assign` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| error | unit | — | codex / agy | 採用 | 1 | - -**なぜ**: review_assign に HOST_RUNTIMES に含まれない無効なホスト名が渡された場合、内部の review_pool から AssignmentError(「ホストになれないランタイムです」)が送出されるエラー経路が固定されていない。 - -**手順**: 1. test_lib_assignment.py で assignment.review_assign(1, "gemini") や assignment.review_assign(1, "unknown") を呼び出す -2. assignment.AssignmentError が送出されることを検証する -3. 例外メッセージに「ホストになれないランタイムです」が含まれることを検証する - -## ラウンド 3(実装 kiro / レビュー codex / agy) - -### R3-001 — `plugins/ndf/skills/cross-review/scripts/measure.py#_matches` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_parameter_list | introduce_parameter_object | major | kiro | 取り消し | 0 | - -**なぜ**: 解決位置の突き合わせ鍵 (pr, round_no, path, line) の 4 引数が _matches・_find_best_match・_add_oracle_match の 3 関数を順に渡り回っている。呼び出し側で順序を取り違えても型で防げず、鍵の項目を増やすたびに 3 関数すべての引数を直すことになる。 - -**手順**: 1. NamedTuple `MatchKey(pr, round_no, path, line)` を定義する -2. _matches の引数を (finding, key: MatchKey) にし、本体を key.* へ書き換える -3. _find_best_match・_add_oracle_match も MatchKey を受け取る形に変え、呼び出し側(_oracle のループ)で MatchKey を 1 度組み立てて渡す -4. test_measure.py の oracle 系テストで退行を確認する - -### R3-002 — `plugins/ndf/scripts/lib/assignment.py#resolve_participants` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | split_into_pipeline | major | codex | 取り消し | 0 | - -**なぜ**: 入力の正規化、名前と集合制約の検証、only 適用、認証 probe、利用可否の集計、require_all 判定、結果生成が直列に並び、検証規則と外部 probe の境界を個別に読みにくい。 - -**手順**: 1. test_lib_participants.py の既存ケースを現状固定として実行する -2. pool/include/exclude の正規化と制約検証を独立した段へ抽出する -3. only を適用して probe 対象を返す段を抽出する -4. probe 結果を available と unavailable へ変換し require_all を判定する段を抽出する -5. resolve_participants は各段の出力を次段へ渡して Participants を返す処理だけにする -6. 対象テストと全体テストで例外文言、順序、probe 呼び出し、戻り値が不変であることを確認する - -### R3-003 — `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 0 | - -**なぜ**: 200 行の関数内に PR 所有者判定、レビュー指示生成、worktree と既存コメントの準備、担当決定、初期 state 構築、保存と表示が同居し、補助関数もすべてローカル定義のため各段階を単独で検証できない。 - -**手順**: 1. 既存の init 経路テストを現状固定として実行する -2. _resolve_pr_and_ownership と _prepare_review_instructions をモジュールレベルへ抽出する -3. _prepare_worktree_and_comments と _prepare_initial_assignment をモジュールレベルへ抽出する -4. _build_initial_review_state と _finalize_initial_state をモジュールレベルへ抽出し、_init_new_state は各段階を順に呼ぶ構成へ縮める -5. init 関連テストと全体テストで公開入口の出力と副作用が不変であることを確認する - -### R3-004 — `plugins/ndf/skills/cross-review/scripts/measure.py#_state_file_pr` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | minor | kiro | 採用 | 1 | - -**なぜ**: _state_file_pr と _prs が同じ pr_history 走査(dict 判定→_as_int(entry.get("pr"))→current_pr へのフォールバック)を別々に持つ。_state_file_pr は実質「_prs の先頭」で、片方だけ直すと状態ファイルの鍵の選び方が食い違う。同じ業務ルール(状態ファイルの鍵の決め方)に由来し、必ず一緒に変わる重複である。 - -**手順**: 1. _prs を先に評価し、走査ロジックの唯一の持ち主にする -2. _state_file_pr を `prs = _prs(st); return prs[0] if prs else None` へ置き換える -3. test_measure.py の pr/prs を検査するテスト(test_identity_keys_report_state_file_key_and_all_prs 他)で退行を確認する - -### R3-005 — `plugins/ndf/scripts/lib/refresh.py#fetch` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | minor | kiro | 取り消し | 0 | - -**なぜ**: fetch が opener 呼び出し・期限付き読み取りループ・socket への期限伝播・close の後始末を通しで行う。読み取りループ(deadline 判定・_set_socket_timeout の bounded 蓄積・chunk 蓄積)だけを名前付きの段へ分けると、読み取り部分と取得の骨格を別々に読める。 - -**手順**: 1. 読み取りループを `_read_until_deadline(response, deadline, timeout) -> bytes` として抽出し、bounded 判定と FetchTimeout の送出をその中へ移す -2. fetch は opener 呼び出しと finally の close を残し、本文取得を抽出関数の呼び出しに置き換える -3. test_refresh.py の refresh/fetch 経路のテストで退行を確認する - -## ラウンド 4(実装 claude / レビュー codex / kiro) - -### R4-001 — `plugins/ndf/scripts/lib/metrics.py#_aggregate_reviewer_round` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | kiro | 採用 | 1 | - -**なぜ**: 1 つの関数が 2 つの独立した集計を通しで行う。前半は review ごとの指摘件数と解決件数の集計、後半は entry.get('reviewers') を回して判定一致(verdict_pairs / verdict_agreements)を数える二重ループである。指摘の集計と判定一致の集計は変更理由が別で、後半のネストしたループが読む負荷を上げている。 - -**手順**: 1. 後半の others ループ(verdict_pairs / verdict_agreements の加算)を _tally_verdict_agreement(rb, entry, review, name) として抽出する -2. 抽出した関数は entry.get('reviewers') から name 以外を取り出し、_verdict を使って一致数を rb へ加算する -3. _aggregate_reviewer_round のループ本体を、指摘集計+抽出した関数の呼び出しに置き換える -4. metrics.aggregate を通す既存テスト(cross-refactoring 側 test_models_and_metrics.py の resolution_rate / agreement_rate)で退行が無いことを確かめる - -### R4-002 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_light / execute_squash` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| duplication | consolidate_duplication | major | codex | 採用 | 1 | - -**なぜ**: 両モードが旧 PR へのコメント、close、ERR trap の設定、新 PR 作成、trap 解除、URL からの番号抽出、NEW_PR・NEW_PR_URL・NEW_BRANCH の出力を同じ順序で持つ。同じ障害対策のコメントが両方へ反映されており、変更理由も共通している。 - -**手順**: 1. 既存の rotate-pr テストで light と squash の close、作成失敗時の reopen、成功時の出力を固定する -2. モード固有処理から新 PR の head、base、title、body、draft を組み立てる部分だけを残す -3. close から create、trap 管理、番号抽出、結果出力までを共通関数へ抽出する -4. execute_light と execute_squash を共通関数呼び出しへ置き換える -5. 両モードの既存テストを実行してコマンド順と標準出力が不変であることを確認する - -### R4-003 — `plugins/ndf/scripts/lib/worktree-common.sh#wt_extract_write_target` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | split_into_pipeline | major | codex | 採用 | 1 | - -**なぜ**: 書き込み先抽出の入口に、ヒアドキュメント除去、字句化、作業ディレクトリと複合構文の状態追跡、sed・tee・cp・mv・リダイレクトの対象抽出が連続して同居している。多数の局所状態と入れ子の補助関数を一度に追う必要があり、各段を独立して固定できない。 - -**手順**: 1. 対象テスト配下に wt_extract_write_target の公開入出力を通す現状固定テストを追加し、cd、パイプ、部分シェル、case、関数定義、各書き込み形式を固定する -2. 前処理と字句化を、改行区切りの語列を返す段として独立させる -3. 現在地と複合構文の追跡を、語列から走査状態を更新する段へ分ける -4. 書き込み先候補の抽出と相対パス解決を最終段へ分け、入口は各段を順に接続するだけにする -5. 各段の後と最後に現状固定テストおよび全体テストを実行する - -### R4-004 — `plugins/ndf/scripts/lib/run_metrics.py#_by_round_count` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| conditional_chain | extract_method | minor | kiro | 採用 | 1 | - -**なぜ**: バケット鍵の決定が入れ子の三項式 key = "3 以上" if count >= 3 else str(count) if count in (1, 2) else None に埋まっている。ラウンド数から表示区分を導く判断がループ本体の 1 行に押し込まれ、境界(1 / 2 / 3 以上 / 対象外)が読み取りづらい。 - -**手順**: 1. count から区分文字列(または None)を返す _round_count_bucket(count) を抽出する -2. 分岐を if count >= 3 / elif count in (1, 2) / else None として平坦に書く -3. _by_round_count のループ本体で key = _round_count_bucket(count) を呼ぶ形へ置き換える -4. test_run_metrics.py::test_aggregate_by_round_count(1 / 2 / 3 以上 の 3 行)で退行が無いことを確かめる - -### R4-005 — `plugins/ndf/scripts/lib/run_metrics.py#_select` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| conditional_chain | extract_method | minor | kiro | 採用 | 1 | - -**なぜ**: 行ごとの絞り込みが 5 本の連続した if ... continue と、until 判定に埋め込まれた入れ子の三項(started >= until if until_exclusive else started > until)で構成される。時刻の下限・上限・repo・kind・version という別々の観点が 1 つのループ本体に同居し、until_exclusive の分岐が特に読みづらい。 - -**手順**: 1. 時刻の下限・上限の判定を _within_time_bound(started, since, until, until_exclusive) として抽出し、入れ子の三項をその中に閉じ込める -2. _select は since/until を計算した後、_within_time_bound と残りの属性一致(repo / kind / version)で 1 行を通すか決める -3. 属性一致も見通しが悪ければ _matches_filters(row, args) へまとめる -4. test_run_metrics.py::test_aggregate_filters(since / until / repo / kind / version の 5 例)で退行が無いことを確かめる - -## ラウンド 5(実装 codex / レビュー agy / kiro) - -### R5-001 — `plugins/ndf/scripts/lib/auth.py#_probe_all` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| one_by_one_iteration | replace_with_bulk_operation | major | codex / agy | 取り消し | 0 | - -**なぜ**: AUTH_PROBES に定義された各 CLI(最大4者)の認証確認コマンド(タイムアウト各120秒)を for ループ内で直列に実行しており、参加者数に比例して全体の待ち時間が累積する。入力順と出力順を維持したまま有界な並行実行(ThreadPoolExecutor 等)へ置き換えることで待ち時間を短縮できる。 - -**手順**: 1. test_auth_probe.py で複数 CLI の確認順序・出力順序・戻り値構造を検証する既存テストを確認する -2. _probe_all 内で AUTH_PROBES に存在する対象を抽出し、有界な並行ワーカー(concurrent.futures 等)で並行実行する -3. 各ランタイムの結果を入力順に results へ格納し、info 出力も入力順に発出する -4. pytest plugins/ndf/scripts/tests/test_auth_probe.py および全体テストで互換性と表示順を検証する - -### R5-002 — `plugins/ndf/skills/cross-review/scripts/state.py#_apply_resume_args_block` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex / agy | 取り消し | 0 | - -**なぜ**: 1 つの関数内で、引数の正規化、--only none による特殊な状態解除と履歴追記、一般フィールドの反映、参加者再構築用の引数名前空間生成、_resolve_reviewers による再解決、成功時の状態・履歴更新という複数の段階が連続して書かれており、状態更新の原子性と各段階の責務が混在している。 - -**手順**: 1. test_state_resume_args.py で --only none、通常引数反映、参加者再構築失敗時の原子性(ロールバック/非更新)がテストされていることを確認する -2. --only none の状態解除と履歴追記を補助関数へ抽出する -3. 既存の参加者情報と再開引数をマージして再解決用 Namespace を組み立てる処理を補助関数へ抽出する -4. 参加者の解決成功後に状態と resume_changes を更新する処理を補助関数へ抽出する -5. _apply_resume_args_block を各ステップの明瞭なオーケストレーションに再構成し、対象テストと全体テストを実行する - -### R5-003 — `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex / agy | 取り消し | 0 | - -**なぜ**: 86行の関数内で、prepare.json や gh pr view からの PR メタ情報(base, title)解決、detached HEAD からの head ブランチ復元、タイトル末尾の (rotated) 接尾辞の正規化ループ、squash コミットの作成と push、新 PR 本文の組み立て、rotate_close_and_create 呼び出しが密結合しており、情報解決と Git/GitHub 副作用の分離が不明瞭になっている。 - -**手順**: 1. test_rotate_pr_queue.py などの現状固定テストで squash モードの振る舞い(接尾辞正規化、ブランチ名解決など)を確認する -2. PR メタ情報(base / title)のフォールバック取得処理を補助関数へ抽出する -3. ブランチ復元(git branch --show-current / prepare.json / gh pr view)と (rotated) 接尾辞の正規化処理を補助関数へ抽出する -4. squash コミット作成とリモート push の Git 操作を補助関数へ抽出する -5. execute_squash を各抽出関数のパイプライン呼び出しに整理し、テストを実行する - -## ラウンド 6(実装 agy / レビュー codex / kiro) - -### R6-001 — `plugins/ndf/scripts/lib/transcript_agents.py#_aggregate_token_metrics` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 1 | - -**なぜ**: 1回の走査でトークンの固定費・最大値、応答IDの重複排除、モデル別件数を集め、その後に派生値と代表モデルまで確定している。異なる集計規則が同じ局所状態へ混在し、各規則を単独で追いにくい。 - -**手順**: 1. 合成でない assistant 行を選ぶ処理を名前付きの反復単位へ抽出する -2. トークン指標の更新を _update_token_metrics として抽出する -3. 応答IDとモデル件数の更新を _collect_response_model として抽出する -4. 呼び出し側は集計結果から responses・work・modelを従来どおり確定し、既存フィクスチャの契約値で退行確認する - -### R6-002 — `plugins/ndf/scripts/lib/metrics.py#format_report` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_method | extract_method | major | codex | 取り消し | 1 | - -**なぜ**: 実装担当表の行生成、レビュー担当表の行生成、計測不能・指定値代用の注記、比較上の注意の4段階を1関数が通しで組み立てており、表の列変更と注記構成の変更が同じ関数へ集中している。 - -**手順**: 1. 実装担当の行生成を _format_impl_rows として抽出する -2. レビュー担当の行生成を _format_reviewer_rows として抽出する -3. 計測注記の追加を _append_measurement_notes として抽出する -4. format_report は各段を順に呼び、既存の文字列出力が一致することを既存テストで確認する - -### R6-003 — `plugins/ndf/skills/cross-review/tests/conftest.py#_no_github_state` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| mock_targets_implementation_detail | fix_dependency_direction | major | codex | 取り消し | 1 | - -**なぜ**: autouse fixture が state.py の非公開関数 _fetch_check_runs と _fetch_pr_metadata を名前で直接差し替えるため、GitHub取得処理の抽出や改名だけで広範なテストが壊れる。実際の外部境界は _gh_rest と subprocess.run なのに、その内側の実装手順を全テストへ固定している。 - -**手順**: 1. state.py が使うGitHub取得境界を明示した依存としてまとめる -2. cmd系の入口からその境界を注入できる最小の既定値を置く -3. _no_github_state は非公開取得関数ではなく境界の偽実装を注入する -4. 実取得の契約テストは既存の fake gh と _gh_rest 差し替えを維持し、全テストで外部通信が発生しないことを確認する - -### R6-004 — `plugins/ndf/scripts/lib/metrics.py#_append_model_measurement_warnings` - -| 兆候・経路 | 手法・階層 | 重要度 | 提案元 | 状態 | コミット | -| --- | --- | --- | --- | --- | ---: | -| long_parameter_list | introduce_parameter_object | minor | kiro | 取り消し | 1 | - -**なぜ**: 引数が 7 個。うち unmeasured / assumed は出力の蓄積先、round_no / runtime / requested / observed / role_label は 1 ラウンド 1 担当の計測文脈で、常に組で渡り回る。2 つの呼び出し側(_aggregate(impl)と _aggregate_round_reviewers)で同じ 5 値をその順で並べており、順序を取り違えると requested と observed が入れ替わっても型が同じ str のため気付けない。 - -**手順**: 1. runtime / requested / observed / role_label(と round_no)をまとめる NamedTuple もしくは dataclass(例 MeasurementContext)を metrics.py に定義する -2. _append_model_measurement_warnings の署名を (unmeasured, assumed, ctx) へ変更し、本体の runtime 等の参照を ctx.runtime 等へ置き換える -3. aggregate 内の impl 経路(round_no・impl_runtime・requested・observed・"実装担当")で ctx を組み立てて渡す -4. _aggregate_round_reviewers 内のレビュー担当経路(round_no・name・requested・observed・"レビュー担当")でも ctx を組み立てて渡す -5. cross-refactoring/tests/test_models_and_metrics.py(既存)で aggregate の出力(unmeasured / assumed の文言)が不変であることを確認する - -## 見送った項目 - -| ラウンド | 対象 | 兆候・経路 | 理由 | -| --- | --- | --- | --- | -| 1 | `plugins/ndf/scripts/lib/models.py#mismatch_warning` | branch | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/scripts/lib/models.py#observed_model` | branch | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/scripts/lib/models.py#separation_reason` | branch | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/scripts/lib/monitor.py#monitor_agent` | branch | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/scripts/lib/statefile.py#save` | error | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/skills/cross-review/scripts/critique.sh#select_targets` | branch | 1 ラウンドの採用上限 5 件を超えた | -| 1 | `plugins/ndf/scripts/lib/assignment.py#seat_runtime` | boundary | コミット 36dd097d4dff427b0de545bcd0cdc0de0e7b74fb にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | -| 2 | `plugins/ndf/scripts/lib/assignment.py#review_seats` | boundary | 1 ラウンドの採用上限 5 件を超えた | -| 2 | `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh#main` | normal | 1 ラウンドの採用上限 5 件を超えた | -| 2 | `plugins/ndf/scripts/lib/assignment.py#review_assign` | branch | コミット 0c3b60c7101ac9b439e0b13b677f8061b81eb851 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | -| 3 | `plugins/ndf/scripts/lib/post_queue.py#Queue.flush` | long_method | 1 ラウンドの採用上限 5 件を超えた | -| 3 | `plugins/ndf/skills/cross-review/scripts/measure.py#_matches` | long_parameter_list | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 3 | `plugins/ndf/scripts/lib/assignment.py#resolve_participants` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 3 | `plugins/ndf/skills/cross-review/scripts/state.py#_init_new_state` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 3 | `plugins/ndf/scripts/lib/refresh.py#fetch` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(1a81a1a)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 4 | `plugins/ndf/skills/cross-review/scripts/state.py#_sync_worktree` | long_method | 1 ラウンドの採用上限 5 件を超えた | -| 5 | `plugins/ndf/scripts/lib/auth.py#_probe_all` | one_by_one_iteration | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 5 | `plugins/ndf/skills/cross-review/scripts/state.py#_apply_resume_args_block` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 5 | `plugins/ndf/skills/cross-review/scripts/rotate-pr.sh#execute_squash` | long_method | どの改善項目にも割り当てられていないコミットが 1 件(89bb86f)。検証を回避した変更や、状態と実差分の食い違いを Pull Request に残さないため、この適用ラウンドを取り消します | -| 6 | `plugins/ndf/scripts/lib/transcript_agents.py#_aggregate_token_metrics` | long_method | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | -| 6 | `plugins/ndf/scripts/lib/metrics.py#format_report` | long_method | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | -| 6 | `plugins/ndf/skills/cross-review/tests/conftest.py#_no_github_state` | mock_targets_implementation_detail | 適用結果に項目がありません: R6-003(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します) | -| 6 | `plugins/ndf/scripts/lib/metrics.py#_append_model_measurement_warnings` | long_parameter_list | コミット 5f63640dff6dd7739738a8794edc5db43792f342 にトレーラーが欠けています: Item-Id, Round, Impl-Runtime, Impl-Model | From d8b66ea485417bce26bd33a426f51fd0c797b1b9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:03:43 +0000 Subject: [PATCH 210/217] =?UTF-8?q?Fix:=20=E5=AD=90=E3=82=92=E5=BE=85?= =?UTF-8?q?=E3=81=A4=20supervisor=20=E3=81=8C=E3=82=B5=E3=83=96=E3=82=A8?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E3=81=AE=E8=A1=A8?= =?UTF-8?q?=E7=A4=BA=E3=81=8B=E3=82=89=E6=B6=88=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 更新の時刻(直近 2 分)で実行中を決めていたため、子や長いコマンドを待って記録を書き足さない supervisor が外れていた。候補を直近 60 分に広げ、実行中かどうかは記録の末尾で決める。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 6 ++++-- plugins/ndf/skills/statusline/SKILL.md | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index baad2567..cb279664 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -33,8 +33,10 @@ if [ -n "$total_input" ]; then rows="" if [ -n "$transcript" ] && [ -d "$sub_dir" ]; then now=$(date +%s) - # 直近 2 分以内に更新された記録だけを候補にする - for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -2 2>/dev/null); do + # 直近 60 分以内に更新された記録を候補にし、実行中かどうかは記録の末尾で決める。 + # 更新の時刻では決めない。子を待つ supervisor や長いコマンドを待つ担当は、実行中でも + # 何分も書き足さない + for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 2>/dev/null); do # 末尾だけを読む。記録は長くなるため全体を走査しない。 # 出力: モデル / 使用量 / 状態 (run | done | idle) # 最後の user か assistant の行が tool_use を含まない assistant なら応答を書き終えている。 diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 729db1ec..85fc0881 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -22,7 +22,7 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ - 角括弧の先頭は利用中モデルの表示名と、メインセッションのコンテキスト使用量。表示名の括弧と空白は落とす (`Opus 5 (1M context)` → `Opus5`)。取得できない場合は `ctx` にフォールバック - **上限と使用率は出さない。** 現行モデルの上限は Haiku 4.5 (200K) を除いて 1M で、使用量だけで足りる - `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`/subagents/agent-.jsonl` の最後の `usage` から読む - - 直近 2 分以内に記録が更新され、終わっていないものを実行中とみなす。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす + - 直近 60 分以内に記録が更新され、終わっていないものを実行中とみなす。更新の時刻では決めない (子を待つ supervisor や長いコマンドを待つ担当は、実行中でも何分も書き足さない)。止められた担当は、記録が `tool_use` で終わったまま 60 分残ることがある。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため - ラベルは `agent-.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す - メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。Haiku 4.5 (200K) のサブエージェントは 150k で赤にする From c7304359457178d4dd2a75a3c7c654d1b0cc2441 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:04:00 +0000 Subject: [PATCH 211/217] =?UTF-8?q?Docs:=20=E6=B6=88=E3=81=97=E3=81=9F?= =?UTF-8?q?=E8=A8=AD=E8=A8=88=E3=82=92=E6=8C=87=E3=81=99=E3=83=AA=E3=83=B3?= =?UTF-8?q?=E3=82=AF=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=B8?= =?UTF-8?q?=E5=B7=AE=E3=81=97=E6=9B=BF=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- issues/issue-598-537-limits-plan.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-598-537-limits-plan.md b/issues/issue-598-537-limits-plan.md index 070ceb9f..18cb6840 100644 --- a/issues/issue-598-537-limits-plan.md +++ b/issues/issue-598-537-limits-plan.md @@ -5,7 +5,7 @@ - 要求と受け入れ条件: [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md)(P2 は AC30〜AC42 と AC70〜AC72) - 設計文書: [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md)(P2 は決定 10〜13。決定 10 の cross-refactoring の例外を含む) - 契約の文書: [issue-662-598-537-619-584-583-design-contracts.md](issue-662-598-537-619-584-583-design-contracts.md)(「上限の表(P2)」「`limits.py`(P2)」「`launch-cli.sh` の第 7 引数(P2)」「`bg-wait.sh`(P2)」) -- 境界: [issue-647-592-553-design.md](issue-647-592-553-design.md) の決定 13 と「他の設計との契約」の D-A(P2)の行 +- 境界: #647 #592 #553 の設計(決定 13 と「他の設計との契約」の D-A(P2)の行。確定仕様は [適用の取り込み](../docs/specifications/cross-refactoring-apply-intake.md)) - 前段: P1(#662、PR #677、develop の d11c473) - 課題: #598 / #537(マイルストーン 21) From 1163f4fdf273761f64ee654225b3b48630db4c00 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:10:18 +0000 Subject: [PATCH 212/217] =?UTF-8?q?Docs:=20=E4=B8=8A=E9=99=90=E3=81=AE?= =?UTF-8?q?=E8=A7=A3=E6=B1=BA=E9=A0=86=E3=81=AB=E6=98=8E=E7=A4=BA=E5=BC=95?= =?UTF-8?q?=E6=95=B0=E3=82=92=E8=B6=B3=E3=81=99=EF=BC=88#805=20=E3=83=AC?= =?UTF-8?q?=E3=83=93=E3=83=A5=E3=83=BC=E5=AF=BE=E5=BF=9C=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5 (1M context) --- docs/specifications/test-monitor-env-isolation.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/specifications/test-monitor-env-isolation.md b/docs/specifications/test-monitor-env-isolation.md index 25dce7fd..ade97f37 100644 --- a/docs/specifications/test-monitor-env-isolation.md +++ b/docs/specifications/test-monitor-env-isolation.md @@ -48,7 +48,8 @@ ところを基準にする。根に 1 つも無いと、束のディレクトリを起点にした実行では共通の前提が 読まれない。`testpaths` などを書くと、既存の実行が対象にする範囲が変わる。 -**変えないもの。** 上限の解決順(担当ごとの指定 → 共通の指定 → 表の既定)、上限の表の値、 +**変えないもの。** 上限の解決順(起動時の明示の指定(`--timeout` / `--stall-timeout`)→ 担当ごとの +環境変数 → 共通の環境変数 → 表の既定)、上限の表の値、 監視の本体と起動スクリプトの振る舞いは変えない。変わるのはテストの前提だけである。 ## テスト観点 From 93ce55004cfe489906200a19bd0ec29a88160c99 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:24:26 +0000 Subject: [PATCH 213/217] =?UTF-8?q?Fix:=20statusline=20=E3=81=AE=E3=83=91?= =?UTF-8?q?=E3=82=B9=E5=88=86=E5=89=B2=E3=81=A8=E3=83=A1=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=81=AE=20Haiku=20=E9=96=BE=E5=80=A4=E3=82=92=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=80=81=E8=A1=A8=E7=A4=BA=E3=81=AE=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - サブエージェントの記録を find -print0 と NUL 区切りで読み、空白を含むパスでも表示する - メインの赤表示の閾値を context_window.context_window_size が 200K 以下なら 150k にする - statusline.sh の表示を擬似の記録で確かめる test_statusline_render.py を追加する Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 11 +- plugins/ndf/skills/statusline/SKILL.md | 2 +- .../tests/test_statusline_render.py | 124 ++++++++++++++++++ 3 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 plugins/ndf/skills/statusline/tests/test_statusline_render.py diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index cb279664..06cf0a10 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -9,6 +9,7 @@ input=$(cat) claude_root=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // empty' 2>/dev/null) total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty' 2>/dev/null) +ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty' 2>/dev/null) transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) # モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック。 @@ -23,7 +24,10 @@ WARN_COLOR='\033[0;31m' ctx_info="" if [ -n "$total_input" ]; then main_used="$((total_input / 1000))k" - [ "$total_input" -gt "$WARN_TOKENS" ] && main_used=$(printf "$WARN_COLOR%s\033[0;36m" "$main_used") + # 上限が 200K 以下のモデル(Haiku 4.5)は 150k で知らせる。モデル名ではなく入力の上限で決める + main_limit=$WARN_TOKENS + [ -n "$ctx_size" ] && [ "$ctx_size" -le 200000 ] 2>/dev/null && main_limit=150000 + [ "$total_input" -gt "$main_limit" ] && main_used=$(printf "$WARN_COLOR%s\033[0;36m" "$main_used") ctx_info=$(printf " \033[0;36m[%s %s" "$ctx_label" "$main_used") # 実行中のサブエージェントのコンテキスト使用量を並べる。statusLine の JSON は @@ -36,7 +40,8 @@ if [ -n "$total_input" ]; then # 直近 60 分以内に更新された記録を候補にし、実行中かどうかは記録の末尾で決める。 # 更新の時刻では決めない。子を待つ supervisor や長いコマンドを待つ担当は、実行中でも # 何分も書き足さない - for f in $(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 2>/dev/null); do + # NUL 区切りで読む。空白を含むパスでも 1 ファイルとして扱う + while IFS= read -r -d '' f; do # 末尾だけを読む。記録は長くなるため全体を走査しない。 # 出力: モデル / 使用量 / 状態 (run | done | idle) # 最後の user か assistant の行が tool_use を含まない assistant なら応答を書き終えている。 @@ -68,7 +73,7 @@ if [ -n "$total_input" ]; then warn=0 [ "$tokens" -gt "$limit" ] && warn=1 rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' - done + done < <(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 -print0 2>/dev/null) fi # 使用量の多い順に 3 本まで並べ、残りは本数だけを出す。80 桁の端末に収めるため if [ -n "$rows" ]; then diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 85fc0881..33ad4329 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -25,7 +25,7 @@ NDF 標準 statusline (project_dir + メインとサブエージェントのコ - 直近 60 分以内に記録が更新され、終わっていないものを実行中とみなす。更新の時刻では決めない (子を待つ supervisor や長いコマンドを待つ担当は、実行中でも何分も書き足さない)。止められた担当は、記録が `tool_use` で終わったまま 60 分残ることがある。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため - ラベルは `agent-.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す -- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。Haiku 4.5 (200K) のサブエージェントは 150k で赤にする +- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。上限が 200K 以下のモデル (Haiku 4.5) は 150k で赤にする。メインは入力の `context_window.context_window_size`、サブエージェントは記録のモデル名で判定する - サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない ## 使用方法 diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_render.py b/plugins/ndf/skills/statusline/tests/test_statusline_render.py new file mode 100644 index 00000000..2651b2f5 --- /dev/null +++ b/plugins/ndf/skills/statusline/tests/test_statusline_render.py @@ -0,0 +1,124 @@ +"""statusline.sh の表示(メインとサブエージェントの使用量)を検証する。 + +擬似の transcript(/sess.jsonl)とサブエージェントの記録 +(/sess/subagents/agent-.jsonl / .meta.json)を作り、標準入力へ JSON を渡して +`bash statusline.sh` の出力を突き合わせる。 +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +for _cmd in ("bash", "jq"): + if shutil.which(_cmd) is None: + pytest.skip(f"{_cmd} not available", allow_module_level=True) + +STATUSLINE = Path(__file__).resolve().parents[3] / "scripts" / "statusline.sh" +RED = "\033[0;31m" +ANSI = re.compile(r"\033\[[0-9;]*m") + + +def _usage(tokens: int) -> dict: + return {"input_tokens": tokens, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0} + + +def write_agent(root: Path, agent_id: str, *, tokens: int = 10_000, end: str = "tool_use", + model: str = "claude-opus-5", description: str | None = None, + age: float = 0) -> Path: + """end: tool_use(実行中)/ end_turn(終了)/ text(stop_reason 無しの text で終わる)""" + sub = root / "sess" / "subagents" + sub.mkdir(parents=True, exist_ok=True) + content = [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}] if end == "tool_use" \ + else [{"type": "text", "text": "done"}] + stop = "end_turn" if end == "end_turn" else None + lines = [ + {"type": "user", "message": {"role": "user", "content": "go"}}, + {"type": "assistant", "message": {"model": model, "content": content, + "stop_reason": stop, "usage": _usage(tokens)}}, + ] + f = sub / f"agent-{agent_id}.jsonl" + f.write_text("".join(json.dumps(line) + "\n" for line in lines)) + if description is not None: + (sub / f"agent-{agent_id}.meta.json").write_text(json.dumps({"description": description})) + if age: + t = time.time() - age + os.utime(f, (t, t)) + return f + + +def render(root: Path, total: int = 100_000, size: int | None = None) -> str: + cw: dict = {"total_input_tokens": total} + if size is not None: + cw["context_window_size"] = size + payload = {"transcript_path": str(root / "sess.jsonl"), + "model": {"display_name": "Opus 5 (1M context)"}, "context_window": cw} + r = subprocess.run(["bash", str(STATUSLINE)], input=json.dumps(payload), + capture_output=True, text=True, check=True) + return r.stdout + + +def plain(out: str) -> str: + return ANSI.sub("", out) + + +def test_tool_use_is_shown_and_end_turn_is_not(tmp_path): + write_agent(tmp_path, "aaaa1111", tokens=42_000, description="実行中の担当") + write_agent(tmp_path, "bbbb2222", tokens=77_000, end="end_turn", description="終わった担当") + out = plain(render(tmp_path)) + assert "実行中の 42k" in out + assert "終わった" not in out + + +def test_text_older_than_30s_is_hidden(tmp_path): + write_agent(tmp_path, "aaaa1111", end="text", age=60, description="古いtext") + write_agent(tmp_path, "bbbb2222", end="text", description="新しいtext") + out = plain(render(tmp_path)) + assert "古いte" not in out + assert "新しいt" in out + + +def test_top3_and_rest_count(tmp_path): + for i, tok in enumerate([10_000, 20_000, 30_000, 40_000, 50_000]): + write_agent(tmp_path, f"id{i}xxxx", tokens=tok, description=f"担当{i}号機") + out = plain(render(tmp_path)) + assert "│ 担当4号 50k · 担当3号 40k · 担当2号 30k +2]" in out + + +def test_haiku_warns_at_150k_but_opus_does_not(tmp_path): + write_agent(tmp_path, "aaaa1111", tokens=160_000, model="claude-haiku-4-5", description="はいく") + out = render(tmp_path) + assert f"{RED}はいく 160k" in out + shutil.rmtree(tmp_path / "sess") + write_agent(tmp_path, "aaaa1111", tokens=160_000, model="claude-opus-5", description="おーぱす") + assert RED not in render(tmp_path) + + +def test_main_warns_over_500k(tmp_path): + assert RED + "553k" in render(tmp_path, total=553_000) + assert RED not in render(tmp_path, total=160_000) + + +def test_main_with_200k_window_warns_at_150k(tmp_path): + assert RED + "160k" in render(tmp_path, total=160_000, size=200_000) + assert RED not in render(tmp_path, total=160_000, size=1_000_000) + + +def test_label_from_description_or_id(tmp_path): + write_agent(tmp_path, "abcd9999", tokens=12_000, description="Fix PR comments") + write_agent(tmp_path, "wxyz8888", tokens=11_000) + out = plain(render(tmp_path)) + assert "FixP 12k" in out + assert "wxyz 11k" in out + + +def test_path_with_spaces(tmp_path): + root = tmp_path / "my project dir" + write_agent(root, "aaaa1111", tokens=33_000, description="空白下") + assert "空白下 33k" in plain(render(root)) From 8b9e3366699a86d409a65ca439fb8975709a99fb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:27:20 +0000 Subject: [PATCH 214/217] Release: ndf v10.16.0-dev.1 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit マイルストーン 13「agy の打ち切りと止まらない収束ループ」を develop のチャネルへ載せる開発版。 - 版数を持つ 15 箇所を 10.16.0-dev.1 へ上げ、版の付け方の章の例を 10.16.0 / 10.17.0-dev.1 / 10.17.0-rc.1 へ繰り上げた - plugins/ndf/README.md の更新案内をこの版の変更(使える者だけで始める・--exclude / --include / --require-all・ cross-refactoring の既定の参加者から agy を外す・書き込みを回す側へ移す など)と確かめ方 3 コマンドへ書き直した - CHANGELOG.md に ndf 10.16.0 の節、docs/ndf-version-decisions.md に v10.16.0 の判断を足した Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 2 +- AGENTS.md | 2 +- CHANGELOG.md | 50 +++++++++++++++++++++++ README.md | 4 +- docs/ndf-version-decisions.md | 55 +++++++++++++++++++++++++- docs/versioning-and-distribution.md | 12 +++--- plugins/ndf/.claude-plugin/plugin.json | 4 +- plugins/ndf/.codex-plugin/plugin.json | 4 +- plugins/ndf/README.md | 53 ++++++++++++++++--------- plugins/ndf/dev.agy/plugin.json | 4 +- 10 files changed, 154 insertions(+), 36 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3450ce9d..3d895d69 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.15.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.16.0-dev.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/AGENTS.md b/AGENTS.md index 767b7a62..0885699c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ ai-plugins/ ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.15.1)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.16.0-dev.1)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 - Skill の実体は `plugins/ndf/skills/` の1箇所。配布先は `plugins/ndf/manifests/*-skills.txt` が決める - Claude Code版は 8個の専門サブエージェント、公開Skills、PreToolUse/SessionStart/Stopフックを提供 - Codex版は Codex向け公開Skillsと任意Slack通知hookを提供 diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c76210..08a4b845 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,56 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.16.0] - 2026-09-22 + +### 追加 + +- **`cross-review` と `cross-refactoring` の `init` に `--exclude` / `--include` / `--require-all` を足した**(#664 #727)。 + 参加者を名指しで足し引きでき(カンマ区切り・繰り返し可・再開で `none` を渡すと空へ戻す)、`--require-all` を + 付けたときだけ確認を通らない者が 1 者でもいれば止まる +- **`scripts/lib/result_posts.py` を新設した**(#730 #583)。結果ファイルからレビュー・返信・スレッドの決着・ + まとめを組み立てて待ち行列から送る共通層で、部分命令 `fix` が `/ndf:fix` を単独で使うときの送信と投稿を行う +- **`cross-refactoring/scripts/refactor_lib/intake.py` を新設した**(#728)。適用・修正・最終ゲートの修正の + 3 つの取り込みが、範囲の確定・未検証のコミットの取り消し・結末の記録を共有する +- `cross-review/scripts/classifications.py` を新設し、指摘の区分の語彙を 1 か所に置いた(#732) +- 状態ファイルに参加者の記録(`participants`)と再開で変えた値の記録(`resume_changes`)を足し、完了報告に + 参加した者の節を足した(#727 #648 #664) + +### 変更 + +- **認証の確認を関門から外した**(#478 #727 #664)。確認を通らない CLI があっても `init` はその者を外して + 続け、外した者と理由を状態ファイルと完了報告へ残す。使える者が 0 者なら止まる +- **`cross-review` が毎ラウンド 2 席を確保する**(#687 #727)。使える者が足りなければホスト、次に同じ + ランタイムの 2 つ目(`codex-2` など)で埋める。`--only` のときは埋め合わせをしない +- **`cross-refactoring` の既定の参加者を codex / kiro とホストにした**(#664)。agy は `--include agy` で戻す。 + 提案と適用を同じ参加者で回し、レビュー担当の役を無くした +- **再開で渡した引数を反映するか、反映しないと知らせる**(#648)。上限の引数の既定を未指定にし、既定値は + 状態の側で補う +- **担当 1 回の起動の結末を共通の語彙で読む**(#729 #619 #584)。利用上限は理由「利用上限」として残り、 + 同じラウンドで同じ担当を起動し直さない。監視は CLI を独立したプロセスグループで起動し、止めた後に子 + プロセスが結果を書かない +- **収束の判定で数えない指摘を、棄却と `minor` 以下に限った**(#732 #624 #706)。誤りを示されていない + `major` 以上は `unrefuted` として数える +- **GitHub と git へ書くのをレビューを回す側だけにした**(#730 #583)。レビューの担当は指摘の控えと + 結果ファイルを書いて終わり、`/ndf:fix` の担当はコミットまでで止まる。投稿は `state.py read-result`、 + 送信・返信・決着・まとめは `state.py merge-fix` が行い、4 種別すべてで二度書かない照合を掛ける +- **同じ適用ラウンドを開き直すのを 2 回までにした**(#728 #647 #592)。2 回目は別の担当が試し、2 回とも + 結果が残らなければ取り消して見送る。採用 0 件の提案ラウンドと項目の無い適用ラウンドでは担当を起動しない +- `launch-reviewer.sh` / `critique.sh` の第 1 引数をランタイム名から席の名前にした(ランタイム名は + そのまま受け付ける)(#727) + +### 修正 + +- 帰属の段落がコミットの後ろに足されても、必須の記名を末尾の段落から読むようにした(#553) +- テストの実行中は `MONITOR_` で始まる環境変数を外し、監視の上限を延ばしたシェルでも同じ件数が通るように + した(#678) + +### 削除 + +- 共通層の `check_auth` / `impl_pool` / `review_assign` / `assign` を消した(#664)。置き換え先は + `probe_auth` / 参加者の一覧 / `review_seats` / `impl_assign` +- `cross-review` の担当の申告件数と GitHub の実数の突き合わせをやめた(#730) + ## [ndf 10.15.1] - 2026-09-19 ### 修正 diff --git a/README.md b/README.md index eff39d40..397fa59d 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Claude Code / Codex / Kiro CLI / agy 向けのスキル・MCP設定を共有す このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Codex / Kiro CLI / agy)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン v10.15.1** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI / agy へ配布されるプラグインです。配布物は `plugins/ndf/` の1ディレクトリにまとまっており、Skill の実体は `plugins/ndf/skills/` の1箇所だけです。どのランタイムへ配るかは `plugins/ndf/manifests/*-skills.txt` が決めます。 +**NDFプラグイン v10.16.0-dev.1** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI / agy へ配布されるプラグインです。配布物は `plugins/ndf/` の1ディレクトリにまとまっており、Skill の実体は `plugins/ndf/skills/` の1箇所だけです。どのランタイムへ配るかは `plugins/ndf/manifests/*-skills.txt` が決めます。 - **公開Skills**: Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個に分離。 - **元Skills(45個)**: @@ -110,7 +110,7 @@ hook を効かせる手順と、新しい版へ入れ替える手順は | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 10.15.1 | Claude Code / Codex / Kiro CLI / agy へ 1 ディレクトリから配布する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個)、4ランタイム共通の作業ツリー運用フック(PreToolUse / SessionStart / userPromptSubmit / agentSpawn / PreInvocation)、Claude Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | +| **ndf** | 10.16.0-dev.1 | Claude Code / Codex / Kiro CLI / agy へ 1 ディレクトリから配布する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個)、4ランタイム共通の作業ツリー運用フック(PreToolUse / SessionStart / userPromptSubmit / agentSpawn / PreInvocation)、Claude Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | | **playwright-kit** | 2.0.3 | Playwright による E2E テストの計画・実装・証跡管理を提供するプラグイン。ページ役割からのテスト計画、動画 / trace 付きスクリプト実装、レポート生成と Drive 保管、playwright_kit ランタイム(init、a11y / CWV スキャン)の 4 Skill。NDF v7.0.0 で分離。 | [README](./plugins/playwright-kit/README.md) | ### 変更履歴 diff --git a/docs/ndf-version-decisions.md b/docs/ndf-version-decisions.md index e2ea946d..06f23abd 100644 --- a/docs/ndf-version-decisions.md +++ b/docs/ndf-version-decisions.md @@ -1,4 +1,4 @@ -# NDF の版ごとの決定と理由(v10.12.0〜v10.15.0) +# NDF の版ごとの決定と理由(v10.12.0〜v10.16.0) `CLAUDE.md` から移した、出た版の記録である。**その版で何を決め、なぜそう決めたか**を残す。 変更点の列挙は `CHANGELOG.md` にあり、こちらは判断の理由を持つ。**`CLAUDE.md` へ書くのは @@ -185,3 +185,56 @@ Pull Request を通すと、計画の更新がマージを待って進み具合 Claude Code 2.1.274 の実測 8 通りのうち読み込まれた 2 通り(行頭・空白の直後、強調の中)に合わせた。 観点は根拠を出典として残して値そのものは持たず、調べ直しは自動で書き換えない(外部の記述の読み違いが そのまま検査の基準になる)。**この配布が、退避の検査を配布の工程で通した最初の版である。** + +v10.16.0 で収束ループを、担当が揃わない・上限で止まる・結果を残さないときにも止まらず終わる形にした +(マイルストーン 13「agy の打ち切りと止まらない収束ループ」、#478 #553 #583 #584 #592 #619 #624 #647 +#648 #664 #678 #687 #706 #727 #728 #729 #730 #732 #736)。**19 件の課題を、根本原因の場所 5 つ +(#727 #728 #729 #730 #732)へ寄せてから設計した。** 現れ方ごとに直すと、同じ判断が cross-review と +cross-refactoring の 2 か所に書かれ、片方だけが古くなる。設計の Pull Request(#781〜#784 #794)を +先に通し、実装(#790 #791 #793 #796 #797 #800 #801)は共通層を触る順に並べた。 + +**結果なしの判断と起動し直しの可否は、結末の共通層の 1 つの真偽値が持つ**(#729)。偽にするのは +利用上限だけにした ── 利用上限は起動し直しても解けず、起動のたびに待ちと相手の枠を使う。他の理由は +対象や負荷で変わるため 1 度は起動し直してよい。偽のときに何をするか(担当を替えるか、誤りで終えるか)は +担当の集合を知る Skill が決める。**利用上限は早期の致命の状態のまま理由だけを分けた** ── 終了コードで +分岐する骨組みと文書を変えずに、読む側が区別できる。 + +**認証の確認を関門から外し、使える者だけで始める**(#478 #727)。確認と中断が 1 つの関数にあると、 +使える者で回す経路から呼べない。止めるかどうかは `--require-all` が決め、全員が揃わないなら始めたくない +運用を残した。**席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める**(#687)── +同じ言語モデルの 2 つの文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる。1 つ目の席の +名前をランタイム名そのままにしたのは、埋め合わせの要らない実行で従来と同じ名前しか現れないためである。 +**起動した後に分かる使えなさで担当を自動で外す仕組みは作らない** ── 一時的な打ち切りでも、以後の +ラウンドから恒久的に外れる。 + +**cross-refactoring の既定の参加者を codex / kiro とホストにした**(#664)。起動 199 回のうち失敗は +agy の 7 回だけで、提案の所要の中央値も agy が最も長かった(5 分。codex 3 分、kiro 2 分)。提案は最も +遅い者を待つため、所要はほぼ agy で決まっていた。既定にホストを含めると提案と適用の集合が一致し、 +適用専用の母集合を持つ理由が無くなる。構造改善のレビュー工程は既に無く、記録だけに残っていた +レビュー担当の役は消した ── 残すと読み手が「この 2 者がレビューした」と読む。 + +**再開で渡した引数は、反映するか知らせるかのどちらかの表に必ず載せる**(#648)。引数の既定を未指定に +したのは、既定値と同じ値を「渡していない」とみなす形では、上限を既定値へ戻す操作を区別できないため +である。担当に関わる引数を渡した再開でだけ参加者を作り直す ── 途中で担当が入れ替わると、前の +ラウンドの記録と突き合わせられなくなる。 + +**収束の判定で数えないのは、棄却と `minor` 以下だけにした**(#732 #624 #706)。反証する相手がいない +構成(`--only` の 1 者)で、誤りを示されていない `major` が立証不足の区分へ落ち、未解決のまま承認で +終わっていた。立証不足の区分の名前は変えず軽微な指摘の残余だけに当てた ── 改名すると旧い状態 +ファイルの値・測定の読み方・語彙を同時に付け替えることになり、変更の前後を測定で比べられなくなる。 + +**GitHub と git へ書くのは、レビューを回す側だけにした**(#730 #583)。担当が投稿してから結果ファイルを +書く形では、その間で止まると投稿は残り記録は残らず、起動し直した担当が同じ論点を重ねて投稿した +(PR #578)。担当の投稿の後に実物と照合して記録を補う形は採らない ── 照合の問い合わせが上限で +失敗すると同じ食い違いが残る。修正は現在の頭を指定して送り、送った後に照合する ── ブランチ名だけの +送信は、切り離された頭で何も送らずに終了コード 0 で終わる。単独の `/ndf:fix` の口は新しい入口の +スクリプトにせず共通層の部分命令にした ── 手順に書く行が 1 本で済み、実装が 1 つになる。 + +**同じ適用ラウンドを開き直すのは 2 回までの固定値とし、引数を足さない**(#728 #647 #592)。2 回目は +別の担当が試すため、2 回とも残らなければ担当ではなく適用ラウンドの側を疑える。上限を引数にして 2 つ +置くと、どちらで止まったかを読み解く必要が出る。**進行側は取り込みでコミットを書き換えない**(#553) +── 識別子が変わり申告と実体の対応が切れるため、必須の記名は末尾の段落から前へ git の判定に掛けて読む。 + +**テストの実行中は `MONITOR_` で始まる環境変数を、根の `conftest.py` の 1 か所で外す**(#678)。 +名前を並べずに接頭辞で一致させるのは、上限の種類が増えるたびに一覧へ足し忘れる形を作らないためで、 +収集より前に外すのは、テストの本体を読み込む時点で上限を決める実装があるためである。 diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 8f1bc8df..00b9a752 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,15 +57,15 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.15.1` | 利用者が常用してよい | -| 開発版 | `10.16.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | -| 公開前の確認版 | `10.16.0-rc.1` | 正式版の候補。残るのは確認だけ | +| 正式版 | `10.16.0` | 利用者が常用してよい | +| 開発版 | `10.17.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | +| 公開前の確認版 | `10.17.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.15.1` の次を開発するなら `10.16.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.16.0` の次を開発するなら `10.17.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる -- **正式版を出すときは接尾辞を外す。** `10.16.0-dev.3` の次は `10.16.0` -- 順序は semver に従い `10.16.0-dev.1` < `10.16.0-rc.1` < `10.16.0` になる +- **正式版を出すときは接尾辞を外す。** `10.17.0-dev.3` の次は `10.17.0` +- 順序は semver に従い `10.17.0-dev.1` < `10.17.0-rc.1` < `10.17.0` になる ## ランタイムごとの取得と導入 diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 3061e987..cf68969b 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.15.1", - "description": "Claude Code plugin (v10.15.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "version": "10.16.0-dev.1", + "description": "Claude Code plugin (v10.16.0-dev.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index 225702ba..4fe32e06 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.15.1", - "description": "Codex plugin (v10.15.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.16.0-dev.1", + "description": "Codex plugin (v10.16.0-dev.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 07430b2d..75dfab66 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -89,7 +89,7 @@ bash plugins/ndf/dev.kiro/install.sh --dry-run ```bash python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v10.15.1) +# => NDF統合開発エージェント(Kiro CLI用 / v10.16.0-dev.1) ``` ### agy @@ -119,21 +119,34 @@ agy plugin list # => {"imports":[{"name":"ndf","source":"antigravity","components":["skills","agents","hooks"]}]} ``` -## v10.15.1 へ更新するとき +## v10.16.0-dev.1 へ更新するとき -**`agent-layers.md` の「並行の本数」の節で、実行計画の持ち主の記載を直しました**(#762)。 -「本数の測り方と実行計画は `parallel-work.md` が持つ」と書いていた 1 文を、「本数を抑える下限は -`parallel-work.md`、本数の測り方と実行計画は `issue-plan-strategy` の -`references/execution-plan.md` が持つ」へ改め、`parallel-work.md` の境界の表と揃えました。 -変わるのは参照文書の 1 文だけで、Skill の数・手順・スクリプトは v10.15.0 のままです。破壊的な -変更はなく、記録の移行も要りません。変更点の一覧は [CHANGELOG.md](../../CHANGELOG.md) にあります。 +**`cross-review` と `cross-refactoring` の収束ループが、担当が揃わない・上限で止まる・結果を +残さないときにも止まらず終わるようにしました**(マイルストーン 13「agy の打ち切りと止まらない +収束ループ」、#478 #553 #583 #584 #592 #619 #624 #647 #648 #664 #678 #687 #706 #727 #728 #729 +#730 #732 #736)。Skill の数は変わりません。引数・Skill・スクリプトの削除や改名は無く、記録の +移行も要りません(前の版で始めた状態ファイルはそのまま読めます)。変更点の一覧は +[CHANGELOG.md](../../CHANGELOG.md) にあります。 -**正式版です。** 開発版 `10.15.1-dev.1` と中身は同じで、`main` に載ります。 +**開発版です。** `develop` にだけ載ります。取得元へ `#develop` を足す手順は +[docs/versioning-and-distribution.md の「開発版を試す」](../../docs/versioning-and-distribution.md#開発版を試す)にあります。 + +**`cross-refactoring` の既定の参加者から agy が外れます。** 既定は codex / kiro とホストです。 +これまでどおり agy に提案と適用をさせるなら `--include agy` を渡します。 + +| 変わったこと | 中身 | +| --- | --- | +| **使える者だけで始まります**(#478 #727 #664 #687) | 認証の確認を通らない CLI があっても `init` は止まらず、その者を外して続けます。外した者と理由は状態ファイルと完了報告に残ります。全員が揃わないなら始めたくないときは `--require-all` を付けます。`cross-review` は毎ラウンド 2 席を確保し、足りなければホスト、次に同じランタイムの 2 つ目(`codex-2` など)で埋めます | +| **参加者を名指しで変えられます**(#664) | 両 Skill に `--exclude` / `--include` が増えました(カンマ区切り・繰り返し可)。`cross-refactoring` は提案と適用を同じ参加者で回し、レビュー担当の役を無くしました | +| **再開で渡した引数が効きます**(#648) | 中断した収束ループを引数を変えて再開すると、上限は反映され、反映しない引数は「反映しない」と表示されます。黙って捨てられる引数はありません。指定を外すときは `none` を渡します | +| **利用上限を理由として報告します**(#729 #619 #584) | 担当の CLI が利用上限で止まると「結果なし」ではなく理由「利用上限」として残り、同じラウンドで起動し直しません。監視が止めた担当の子プロセスは、止めた後に結果を書きません | +| **未解決の重大な指摘を残して承認で終わりません**(#732 #624 #706) | 収束の判定で数えないのは、棄却した指摘と `minor` 以下の指摘だけになりました。誤りを示されていない `major` 以上は残る指摘として数えます | +| **GitHub と git へ書くのはレビューを回す側だけです**(#730 #583) | レビューの担当は指摘の控えを書くだけで、投稿は取り込み(`state.py read-result`)が行います。修正の担当はコミットまでで、送信・返信・決着・まとめは `state.py merge-fix` が行います。同じ論点が 2 つのスレッドに分かれず、途中で止まってもやり直しで二度書きません。**`/ndf:fix` を単独で使うときは、最後に `lib/result_posts.py fix` の 1 行で送信と返信を行います**(手順は `fix` の SKILL.md にあります) | +| **適用ラウンドが上限なしに開き直されません**(#728 #647 #592 #553) | 実装担当が結果を残さないと未検証のコミットを取り消し、同じ適用ラウンドは別の担当で 2 回まで試します。採用 0 件のラウンドでは担当を起動しません。帰属の段落が後ろに付いたコミットでも必須の記名を読みます | +| テストが監視の環境変数に左右されません(#678) | `MONITOR_` で始まる環境変数を延ばしたシェルから全体のテストを起動しても、同じ件数が通ります | -正式版のチャネル(ref を指定せずに登録した取得元)なら、次で入れ替わります。**動いているセッションには -反映されない**ため、更新したあとは起動し直してください。開発版を試すために `develop` を登録した -場合は、[docs/versioning-and-distribution.md の「ランタイムごとの取得と導入」](../../docs/versioning-and-distribution.md#ランタイムごとの取得と導入) -の手順で ref を指定せずに登録し直してから導入します。 +開発版のチャネルを登録済みなら、次で入れ替わります。**動いているセッションには反映されない** +ため、更新したあとは起動し直してください。 ```bash claude plugin marketplace update ai-plugins @@ -145,13 +158,15 @@ codex plugin add ndf@ai-plugins ### 手元で確かめる -読むだけで、課題もファイルも書き換えません。`$SCRIPTS` はプラグインの `scripts/` の -絶対パスで、決め方は +どれも `--help` を読むだけで、課題もファイルも書き換えません。`$SCRIPTS` はプラグインの +`scripts/` の絶対パスで、決め方は [development-workflow/references/scripts-lookup.md](skills/development-workflow/references/scripts-lookup.md) にあります。 ```bash -grep -q "execution-plan.md" "$SCRIPTS/../skills/development-workflow/references/agent-layers.md"; echo "exit=$?" # 0 なら新しい版の参照文書が入っている +python3 "$SCRIPTS/lib/result_posts.py" fix --help >/dev/null; echo "exit=$?" # 0 なら修正の送信を行う共通層が入っている +python3 "$SCRIPTS/../skills/cross-review/scripts/state.py" init --help | grep -q -- '--require-all'; echo "exit=$?" # 0 なら cross-review が使える者だけで始まる +python3 "$SCRIPTS/../skills/cross-refactoring/scripts/refactor.py" init --help | grep -q -- '--include'; echo "exit=$?" # 0 なら cross-refactoring の参加者を名指しで変えられる ``` ## Playwright テストについて @@ -295,7 +310,7 @@ agy models # 認証の確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.15.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.16.0-dev.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -317,14 +332,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.15.1/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.16.0-dev.1/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.15.1 +# => ndf@ai-plugins installed, enabled 10.16.0-dev.1 ``` 抑止していない Skill(`markdown-writing` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/dev.agy/plugin.json b/plugins/ndf/dev.agy/plugin.json index 29b91a1c..247da9f8 100644 --- a/plugins/ndf/dev.agy/plugin.json +++ b/plugins/ndf/dev.agy/plugin.json @@ -1,5 +1,5 @@ { "name": "ndf", - "version": "10.15.1", - "description": "Antigravity CLI plugin (v10.15.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." + "version": "10.16.0-dev.1", + "description": "Antigravity CLI plugin (v10.16.0-dev.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." } From 76fa06ee97acc239bd02b21dd90cf7b09bef10e8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:32:17 +0000 Subject: [PATCH 215/217] =?UTF-8?q?Fix:=20=E3=82=B5=E3=83=96=E3=82=A8?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=82=A7=E3=83=B3=E3=83=88=E3=81=AE=E8=AA=AC?= =?UTF-8?q?=E6=98=8E=E3=81=AB=E5=90=AB=E3=81=BE=E3=82=8C=E3=82=8B=E5=88=B6?= =?UTF-8?q?=E5=BE=A1=E6=96=87=E5=AD=97=E3=82=92=20statusline=20=E3=81=AB?= =?UTF-8?q?=E5=87=BA=E3=81=95=E3=81=AA=E3=81=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ラベル化の jq で空白に加えて \p{Cc}(U+0000-001F / U+007F-009F)を除く。 説明に ESC を含む場合のテストを足す。 Co-Authored-By: Claude Opus 5 (1M context) --- plugins/ndf/scripts/statusline.sh | 5 +++-- .../ndf/skills/statusline/tests/test_statusline_render.py | 8 ++++++++ 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index 06cf0a10..290bb880 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -64,8 +64,9 @@ if [ -n "$total_input" ]; then mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null) [ -n "$mtime" ] && [ $((now - mtime)) -ge 30 ] && continue fi - # 説明の先頭 4 文字をラベルにする。種類名は general-purpose がほとんどで見分けに使えない - label=$(jq -r '.description // empty | gsub("\\s"; "") | .[0:4]' "${f%.jsonl}.meta.json" 2>/dev/null) + # 説明の先頭 4 文字をラベルにする。種類名は general-purpose がほとんどで見分けに使えない。 + # 空白と制御文字(\p{Cc} = U+0000-001F / U+007F-009F)を除く。ESC などを端末へ出さないため + label=$(jq -r '.description // empty | gsub("[\\p{Cc}\\s]"; "") | .[0:4]' "${f%.jsonl}.meta.json" 2>/dev/null) [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:4}; } # 500k を超えたら赤で知らせる。1M 未満のモデルは Haiku(200K)だけなので、Haiku は 150k で知らせる limit=$WARN_TOKENS diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_render.py b/plugins/ndf/skills/statusline/tests/test_statusline_render.py index 2651b2f5..7d3f0318 100644 --- a/plugins/ndf/skills/statusline/tests/test_statusline_render.py +++ b/plugins/ndf/skills/statusline/tests/test_statusline_render.py @@ -118,6 +118,14 @@ def test_label_from_description_or_id(tmp_path): assert "wxyz 11k" in out +def test_control_chars_in_description_are_dropped(tmp_path): + # 説明に ESC などの制御文字があっても端末へ出さない(画面消去などを実行させない) + write_agent(tmp_path, "aaaa1111", tokens=21_000, description="\u001b[2J\u009b画面消去") + out = render(tmp_path) + assert "\u001b[2J" not in out + assert "\u009b" not in out + assert "[2J画 21k" in plain(out) + def test_path_with_spaces(tmp_path): root = tmp_path / "my project dir" write_agent(root, "aaaa1111", tokens=33_000, description="空白下") From b1a5d8c70ec5d4ac7832c757047dc71cf979f44e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:43:59 +0000 Subject: [PATCH 216/217] =?UTF-8?q?Release:=20ndf=20v10.16.0=EF=BC=88?= =?UTF-8?q?=E9=96=8B=E7=99=BA=E7=89=88=2010.16.0-dev.1=20=E3=81=AE?= =?UTF-8?q?=E6=8E=A5=E5=B0=BE=E8=BE=9E=E3=82=92=E5=A4=96=E3=81=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版数を持つ 7 ファイルの 10.16.0-dev.1 を 10.16.0 にし、 plugins/ndf/README.md の更新案内を正式版チャネルの文へ書き直した。中身は開発版と同じ。 Co-Authored-By: Claude Opus 5 (1M context) --- .claude-plugin/marketplace.json | 2 +- AGENTS.md | 2 +- README.md | 4 ++-- plugins/ndf/.claude-plugin/plugin.json | 4 ++-- plugins/ndf/.codex-plugin/plugin.json | 4 ++-- plugins/ndf/README.md | 19 ++++++++++--------- plugins/ndf/dev.agy/plugin.json | 4 ++-- 7 files changed, 20 insertions(+), 19 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3d895d69..cb96c3f5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.16.0-dev.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.16.0): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/AGENTS.md b/AGENTS.md index 0885699c..2ed45aa9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ ai-plugins/ ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.16.0-dev.1)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.16.0)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 - Skill の実体は `plugins/ndf/skills/` の1箇所。配布先は `plugins/ndf/manifests/*-skills.txt` が決める - Claude Code版は 8個の専門サブエージェント、公開Skills、PreToolUse/SessionStart/Stopフックを提供 - Codex版は Codex向け公開Skillsと任意Slack通知hookを提供 diff --git a/README.md b/README.md index 397fa59d..f861f3bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Claude Code / Codex / Kiro CLI / agy 向けのスキル・MCP設定を共有す このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Codex / Kiro CLI / agy)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン v10.16.0-dev.1** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI / agy へ配布されるプラグインです。配布物は `plugins/ndf/` の1ディレクトリにまとまっており、Skill の実体は `plugins/ndf/skills/` の1箇所だけです。どのランタイムへ配るかは `plugins/ndf/manifests/*-skills.txt` が決めます。 +**NDFプラグイン v10.16.0** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI / agy へ配布されるプラグインです。配布物は `plugins/ndf/` の1ディレクトリにまとまっており、Skill の実体は `plugins/ndf/skills/` の1箇所だけです。どのランタイムへ配るかは `plugins/ndf/manifests/*-skills.txt` が決めます。 - **公開Skills**: Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個に分離。 - **元Skills(45個)**: @@ -110,7 +110,7 @@ hook を効かせる手順と、新しい版へ入れ替える手順は | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 10.16.0-dev.1 | Claude Code / Codex / Kiro CLI / agy へ 1 ディレクトリから配布する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個)、4ランタイム共通の作業ツリー運用フック(PreToolUse / SessionStart / userPromptSubmit / agentSpawn / PreInvocation)、Claude Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | +| **ndf** | 10.16.0 | Claude Code / Codex / Kiro CLI / agy へ 1 ディレクトリから配布する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個)、4ランタイム共通の作業ツリー運用フック(PreToolUse / SessionStart / userPromptSubmit / agentSpawn / PreInvocation)、Claude Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | | **playwright-kit** | 2.0.3 | Playwright による E2E テストの計画・実装・証跡管理を提供するプラグイン。ページ役割からのテスト計画、動画 / trace 付きスクリプト実装、レポート生成と Drive 保管、playwright_kit ランタイム(init、a11y / CWV スキャン)の 4 Skill。NDF v7.0.0 で分離。 | [README](./plugins/playwright-kit/README.md) | ### 変更履歴 diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index cf68969b..292dc610 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.16.0-dev.1", - "description": "Claude Code plugin (v10.16.0-dev.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "version": "10.16.0", + "description": "Claude Code plugin (v10.16.0): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index 4fe32e06..8882ae58 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.16.0-dev.1", - "description": "Codex plugin (v10.16.0-dev.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.16.0", + "description": "Codex plugin (v10.16.0): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 75dfab66..2f5c69e7 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -89,7 +89,7 @@ bash plugins/ndf/dev.kiro/install.sh --dry-run ```bash python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v10.16.0-dev.1) +# => NDF統合開発エージェント(Kiro CLI用 / v10.16.0) ``` ### agy @@ -119,7 +119,7 @@ agy plugin list # => {"imports":[{"name":"ndf","source":"antigravity","components":["skills","agents","hooks"]}]} ``` -## v10.16.0-dev.1 へ更新するとき +## v10.16.0 へ更新するとき **`cross-review` と `cross-refactoring` の収束ループが、担当が揃わない・上限で止まる・結果を 残さないときにも止まらず終わるようにしました**(マイルストーン 13「agy の打ち切りと止まらない @@ -128,8 +128,7 @@ agy plugin list 移行も要りません(前の版で始めた状態ファイルはそのまま読めます)。変更点の一覧は [CHANGELOG.md](../../CHANGELOG.md) にあります。 -**開発版です。** `develop` にだけ載ります。取得元へ `#develop` を足す手順は -[docs/versioning-and-distribution.md の「開発版を試す」](../../docs/versioning-and-distribution.md#開発版を試す)にあります。 +**正式版です。** 開発版 `10.16.0-dev.1` と中身は同じで、`main` に載ります。 **`cross-refactoring` の既定の参加者から agy が外れます。** 既定は codex / kiro とホストです。 これまでどおり agy に提案と適用をさせるなら `--include agy` を渡します。 @@ -145,8 +144,10 @@ agy plugin list | **適用ラウンドが上限なしに開き直されません**(#728 #647 #592 #553) | 実装担当が結果を残さないと未検証のコミットを取り消し、同じ適用ラウンドは別の担当で 2 回まで試します。採用 0 件のラウンドでは担当を起動しません。帰属の段落が後ろに付いたコミットでも必須の記名を読みます | | テストが監視の環境変数に左右されません(#678) | `MONITOR_` で始まる環境変数を延ばしたシェルから全体のテストを起動しても、同じ件数が通ります | -開発版のチャネルを登録済みなら、次で入れ替わります。**動いているセッションには反映されない** -ため、更新したあとは起動し直してください。 +正式版のチャネル(ref を指定せずに登録した取得元)なら、次で入れ替わります。**動いているセッションには +反映されない**ため、更新したあとは起動し直してください。開発版を試すために `develop` を登録した +場合は、[docs/versioning-and-distribution.md の「ランタイムごとの取得と導入」](../../docs/versioning-and-distribution.md#ランタイムごとの取得と導入) +の手順で ref を指定せずに登録し直してから導入します。 ```bash claude plugin marketplace update ai-plugins @@ -310,7 +311,7 @@ agy models # 認証の確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.16.0-dev.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.16.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -332,14 +333,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.16.0-dev.1/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.16.0/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.16.0-dev.1 +# => ndf@ai-plugins installed, enabled 10.16.0 ``` 抑止していない Skill(`markdown-writing` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/dev.agy/plugin.json b/plugins/ndf/dev.agy/plugin.json index 247da9f8..b371a454 100644 --- a/plugins/ndf/dev.agy/plugin.json +++ b/plugins/ndf/dev.agy/plugin.json @@ -1,5 +1,5 @@ { "name": "ndf", - "version": "10.16.0-dev.1", - "description": "Antigravity CLI plugin (v10.16.0-dev.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." + "version": "10.16.0", + "description": "Antigravity CLI plugin (v10.16.0): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." } From 32bb5b81eb0551e64bfcf5ebf3f158fad6fac72f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 14:53:19 +0000 Subject: [PATCH 217/217] =?UTF-8?q?Docs:=2010.16.0=20=E3=81=AE=E5=A4=89?= =?UTF-8?q?=E6=9B=B4=E5=B1=A5=E6=AD=B4=E3=81=A8=E6=9B=B4=E6=96=B0=E6=A1=88?= =?UTF-8?q?=E5=86=85=E3=81=AB=20statusline=20=E3=81=AE=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=EF=BC=88#806=EF=BC=89=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 本番への配布の承認の後に #806 が develop へ入ったため、正式版 10.16.0 の CHANGELOG と plugins/ndf/README.md の更新案内へ書き足し、 「開発版と中身は同じ」の文を直した。版数は 10.16.0 のまま。 Co-Authored-By: Claude Opus 5 (1M context) --- CHANGELOG.md | 5 +++++ plugins/ndf/README.md | 4 +++- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08a4b845..376f8c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,9 @@ - `cross-review/scripts/classifications.py` を新設し、指摘の区分の語彙を 1 か所に置いた(#732) - 状態ファイルに参加者の記録(`participants`)と再開で変えた値の記録(`resume_changes`)を足し、完了報告に 参加した者の節を足した(#727 #648 #664) +- **statusline に実行中のサブエージェントのコンテキスト使用量を並べる**(#806)。使用量の多い順に 3 本まで、 + 残りは本数だけを出す。500k(Haiku 4.5 は 150k)を超えたら赤で表示し、NDF 標準の statusLine に + `refreshInterval: 5` を持たせる(既存の設定には `ensure` / `set` が足し、利用者の値は変えない) ### 変更 @@ -46,6 +49,8 @@ 結果が残らなければ取り消して見送る。採用 0 件の提案ラウンドと項目の無い適用ラウンドでは担当を起動しない - `launch-reviewer.sh` / `critique.sh` の第 1 引数をランタイム名から席の名前にした(ランタイム名は そのまま受け付ける)(#727) +- statusline のメインの表示から上限・使用率・コンテナ名・ホスト名を外し、モデル名を詰めた + (`Opus 5 (1M context)` → `Opus5`)(#806) ### 修正 diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 2f5c69e7..1d209dda 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -128,7 +128,8 @@ agy plugin list 移行も要りません(前の版で始めた状態ファイルはそのまま読めます)。変更点の一覧は [CHANGELOG.md](../../CHANGELOG.md) にあります。 -**正式版です。** 開発版 `10.16.0-dev.1` と中身は同じで、`main` に載ります。 +**正式版です。** `main` に載ります。開発版 `10.16.0-dev.1` の中身に、statusline の変更(#806)を +加えました。statusline の変更は開発版を経ていません。 **`cross-refactoring` の既定の参加者から agy が外れます。** 既定は codex / kiro とホストです。 これまでどおり agy に提案と適用をさせるなら `--include agy` を渡します。 @@ -142,6 +143,7 @@ agy plugin list | **未解決の重大な指摘を残して承認で終わりません**(#732 #624 #706) | 収束の判定で数えないのは、棄却した指摘と `minor` 以下の指摘だけになりました。誤りを示されていない `major` 以上は残る指摘として数えます | | **GitHub と git へ書くのはレビューを回す側だけです**(#730 #583) | レビューの担当は指摘の控えを書くだけで、投稿は取り込み(`state.py read-result`)が行います。修正の担当はコミットまでで、送信・返信・決着・まとめは `state.py merge-fix` が行います。同じ論点が 2 つのスレッドに分かれず、途中で止まってもやり直しで二度書きません。**`/ndf:fix` を単独で使うときは、最後に `lib/result_posts.py fix` の 1 行で送信と返信を行います**(手順は `fix` の SKILL.md にあります) | | **適用ラウンドが上限なしに開き直されません**(#728 #647 #592 #553) | 実装担当が結果を残さないと未検証のコミットを取り消し、同じ適用ラウンドは別の担当で 2 回まで試します。採用 0 件のラウンドでは担当を起動しません。帰属の段落が後ろに付いたコミットでも必須の記名を読みます | +| **statusline にサブエージェントの使用量が並びます**(#806) | Claude Code の NDF 標準 statusline が、実行中のサブエージェントのコンテキスト使用量を多い順に 3 本まで並べます(残りは `+2` のように本数だけ)。500k(Haiku 4.5 は 150k)を超えると赤になります。メインの表示から上限・使用率・コンテナ名・ホスト名を外しました。NDF 標準の statusLine には `refreshInterval: 5` が足されます(利用者が書いた値は変えません) | | テストが監視の環境変数に左右されません(#678) | `MONITOR_` で始まる環境変数を延ばしたシェルから全体のテストを起動しても、同じ件数が通ります | 正式版のチャネル(ref を指定せずに登録した取得元)なら、次で入れ替わります。**動いているセッションには