From 47ff27466d3e6a522392b875a174c056f11e0f40 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 19:57:18 +0000 Subject: [PATCH 01/46] =?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 000000000..fe495e30b --- /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 02/46] =?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 d4909d446..a01928c44 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 1fc7d63b5..810f3db88 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 49e5cb9da..623c79a28 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 30eaa4d07..656672290 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 a817073e0..6682cbfee 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 000000000..59398b2ff --- /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 000000000..2693b6552 --- /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 f68e03281..134de695f 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 841d3c5597a3c772f209cafdfab8997af9183184 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:14:55 +0000 Subject: [PATCH 03/46] =?UTF-8?q?Add:=20cross-review=20=E3=81=AE=E5=88=9D?= =?UTF-8?q?=E6=9C=9F=E5=8C=96=E3=83=BB=E6=8B=85=E5=BD=93=E3=83=BB=E5=86=8D?= =?UTF-8?q?=E9=96=8B=E3=83=BB=E5=AE=8C=E4=BA=86=E5=A0=B1=E5=91=8A=E3=82=92?= =?UTF-8?q?=E5=85=B1=E9=80=9A=E5=B1=A4=E3=81=B8=E8=BC=89=E3=81=9B=E6=9B=BF?= =?UTF-8?q?=E3=81=88=E3=82=8B=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 - 新規の初期化: 使える者の解決と止めない確認を呼び、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 f0a02e60a..71d373bd9 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 000000000..3842fae3a --- /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 000000000..72baa9439 --- /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 87204440d..97d0937ce 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 b1b975d7a..dd894ee8f 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 78c99001b972f95a7f3e4ce6bb0167107c7389c8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sat, 19 Sep 2026 20:38:06 +0000 Subject: [PATCH 04/46] =?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 5e7df1d33..f8710ff0b 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 15506ea21..372336cbe 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 b8d747283..e311a7363 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 b73c2bf3e..b68a03915 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 48ef20bd6..2cab47c60 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 a46d62a2a..fdb6b034b 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 a1e0f9107..036cf92b3 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 9fa53bf87..148d5efe0 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 71d373bd9..c67057766 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 9221e7d2e..7b04bece4 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 e4f50d106..d8c6c5cd2 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 400c416b3..0c3bc47ee 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 3842fae3a..2dc3d88cd 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 07e759813..f0035997b 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 05/46] =?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 fcf39fe37..16521bb92 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 3226c021e..01f5113ca 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 6506be84b31280daeee3aee47624b3da9602a75f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:17:38 +0000 Subject: [PATCH 06/46] =?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 6682cbfee..b55f2b3da 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 07/46] =?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 000000000..5933a6fda --- /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 8c60a7a9e06a2e02cbf3b0ee6bdee81a9a78720e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:28:59 +0000 Subject: [PATCH 08/46] =?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 b55f2b3da..d3a325040 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 09/46] =?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 5933a6fda..fe049c0ec 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 0f12eb8399dacbafb9bcdc55bf36e3dcd8d69a42 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:36:54 +0000 Subject: [PATCH 10/46] =?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 d3a325040..cd8e34f8f 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 11/46] =?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 fe049c0ec..a663c4894 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 12/46] =?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 cd8e34f8f..723e0f1fd 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 13/46] =?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 723e0f1fd..cd8e34f8f 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 14/46] =?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 a663c4894..454346ca8 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 1baa87fca2cc53372b6e737afdef7c9dc69835b0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 21:49:58 +0000 Subject: [PATCH 15/46] =?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 cd8e34f8f..1925f12a8 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 16/46] =?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 454346ca8..ea98b2c0b 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 72bc01be772b22e1d3f7a22437d0fe4c04bc16ca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:03:11 +0000 Subject: [PATCH 17/46] =?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 1925f12a8..2259a9ae7 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 59398b2ff..0c479c191 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 18/46] =?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 ea98b2c0b..6afffc8ff 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 9d8fe3a8fa43e1f22238f3f55c4f52f41a5d5961 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:10:57 +0000 Subject: [PATCH 19/46] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20pl?= =?UTF-8?q?ugins/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 2259a9ae7..a0d973024 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 20/46] =?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 6afffc8ff..0db70c64c 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 0c3b60c7101ac9b439e0b13b677f8061b81eb851 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 22:18:19 +0000 Subject: [PATCH 21/46] =?UTF-8?q?Test:=20characterization=20=E2=80=94=20pl?= =?UTF-8?q?ugins/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 a0d973024..bac91ccff 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 22/46] =?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 bac91ccff..a0d973024 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 23/46] =?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 0db70c64c..fa687d894 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 24/46] =?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 a0d973024..96d7bc637 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 25/46] =?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 fa687d894..de73a2c34 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 1a81a1a7910986176d559f11f151f64ab8c40664 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:10:52 +0000 Subject: [PATCH 26/46] =?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 a01928c44..da646c97d 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 b1c1acd67..21dee0c4c 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 21f9c4399..eedf57e59 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 e19cbce27..d32bf5386 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 27/46] =?UTF-8?q?Revert=20"Refactor:=20=E5=BC=95=E6=95=B0?= =?UTF-8?q?=E3=82=AA=E3=83=96=E3=82=B8=E3=82=A7=E3=82=AF=E3=83=88=E5=B0=8E?= =?UTF-8?q?=E5=85=A5=E3=81=A8=E3=83=91=E3=82=A4=E3=83=97=E3=83=A9=E3=82=A4?= =?UTF-8?q?=E3=83=B3=E3=83=BB=E3=83=98=E3=83=AB=E3=83=91=E3=83=BC=E6=8A=BD?= =?UTF-8?q?=E5=87=BA=E3=81=AB=E3=82=88=E3=82=8B=E6=A7=8B=E9=80=A0=E6=94=B9?= =?UTF-8?q?=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 da646c97d..a01928c44 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 21dee0c4c..b1c1acd67 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 eedf57e59..21f9c4399 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 d32bf5386..e19cbce27 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 28/46] =?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 de73a2c34..787247510 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 29/46] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/skills/cross-review/scripts/measure.py#?= =?UTF-8?q?=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 21f9c4399..ee26e60ef 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 30/46] =?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 787247510..6bb1b010b 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 f6e9df3ec20ca290b2d8f90fea855c16dfc450ba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:38:28 +0000 Subject: [PATCH 31/46] =?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 cdafe8eab..d900fc888 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 f650a077d..e64aac2da 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 89fe42dce..547be0e57 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 000000000..df89e5c1d --- /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 5c7a26e8f..e1e0d1f38 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 32/46] =?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 6bb1b010b..e1be8410b 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 068c92e2cc9bf8ebd9f036589870f91f1a8daaba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 21 Sep 2026 23:46:45 +0000 Subject: [PATCH 33/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=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 e64aac2da..e746a8da6 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 34/46] =?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 e1be8410b..7cae3c0f9 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 35/46] =?UTF-8?q?Refactor:=20replace=5Fwith=5Fbulk=5Fopera?= =?UTF-8?q?tion=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 810f3db88..ae38814d9 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 e1e0d1f38..fc4ad1b62 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 e19cbce27..dcb518004 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 000000000..f2d522a52 --- /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 36/46] =?UTF-8?q?Revert=20"Refactor:=20replace=5Fwith=5Fbu?= =?UTF-8?q?lk=5Foperation=20/=20extract=5Fmethod=20=E2=80=94=20auth.py,=20?= =?UTF-8?q?state.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 ae38814d9..810f3db88 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 fc4ad1b62..e1e0d1f38 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 dcb518004..e19cbce27 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 f2d522a52..000000000 --- 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 37/46] =?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 7cae3c0f9..ef348ea84 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 38/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib=20=E3=81=A7=E9=95=B7=E3=81=84?= =?UTF-8?q?=E3=83=A1=E3=82=BD=E3=83=83=E3=83=89=E3=82=92=E6=AE=B5=E3=81=AB?= =?UTF-8?q?=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 d900fc888..abf37b3d2 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 73798f000..0df0e870e 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 39/46] =?UTF-8?q?Revert=20"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 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 abf37b3d2..d900fc888 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 0df0e870e..73798f000 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 40/46] =?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 ef348ea84..3eb74d96a 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 41/46] =?UTF-8?q?Refactor:=20introduce=5Fparameter=5Fobjec?= =?UTF-8?q?t=20=E2=80=94=20plugins/ndf/scripts/lib/metrics.py#=5Fappend=5F?= =?UTF-8?q?model=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 d900fc888..36aa513a0 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 42/46] =?UTF-8?q?Revert=20"Refactor:=20introduce=5Fparamet?= =?UTF-8?q?er=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 36aa513a0..d900fc888 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 43/46] =?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 3eb74d96a..6e5dda055 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 51724669b1916142890ced9154b07fd0d47921cb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 02:35:42 +0000 Subject: [PATCH 44/46] =?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 372336cbe..dbfac2d40 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 97a19342b..fae7ca2b3 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 8a0b8e353..8a17ea8e8 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 e19cbce27..579a2ddca 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 72baa9439..040baafbc 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 97d0937ce..df13827ff 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 b9888cefe820fc6cbc4982327d722d2a8f07bb12 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 02:48:54 +0000 Subject: [PATCH 45/46] =?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 d811ffb25..cac43b06f 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 2dc3d88cd..b09efd76a 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 7af3b7872a5c9d7efcf0ebd5d89563dc7ee53b47 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Tue, 22 Sep 2026 03:21:55 +0000 Subject: [PATCH 46/46] =?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 e4c235fd0..78daaa9e9 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 の再現手順