diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 3450ce9d..cb96c3f5 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.15.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.16.0): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/AGENTS.md b/AGENTS.md index 767b7a62..2ed45aa9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ ai-plugins/ ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.15.1)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.16.0)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 - Skill の実体は `plugins/ndf/skills/` の1箇所。配布先は `plugins/ndf/manifests/*-skills.txt` が決める - Claude Code版は 8個の専門サブエージェント、公開Skills、PreToolUse/SessionStart/Stopフックを提供 - Codex版は Codex向け公開Skillsと任意Slack通知hookを提供 diff --git a/CHANGELOG.md b/CHANGELOG.md index 42c76210..376f8c51 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,61 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.16.0] - 2026-09-22 + +### 追加 + +- **`cross-review` と `cross-refactoring` の `init` に `--exclude` / `--include` / `--require-all` を足した**(#664 #727)。 + 参加者を名指しで足し引きでき(カンマ区切り・繰り返し可・再開で `none` を渡すと空へ戻す)、`--require-all` を + 付けたときだけ確認を通らない者が 1 者でもいれば止まる +- **`scripts/lib/result_posts.py` を新設した**(#730 #583)。結果ファイルからレビュー・返信・スレッドの決着・ + まとめを組み立てて待ち行列から送る共通層で、部分命令 `fix` が `/ndf:fix` を単独で使うときの送信と投稿を行う +- **`cross-refactoring/scripts/refactor_lib/intake.py` を新設した**(#728)。適用・修正・最終ゲートの修正の + 3 つの取り込みが、範囲の確定・未検証のコミットの取り消し・結末の記録を共有する +- `cross-review/scripts/classifications.py` を新設し、指摘の区分の語彙を 1 か所に置いた(#732) +- 状態ファイルに参加者の記録(`participants`)と再開で変えた値の記録(`resume_changes`)を足し、完了報告に + 参加した者の節を足した(#727 #648 #664) +- **statusline に実行中のサブエージェントのコンテキスト使用量を並べる**(#806)。使用量の多い順に 3 本まで、 + 残りは本数だけを出す。500k(Haiku 4.5 は 150k)を超えたら赤で表示し、NDF 標準の statusLine に + `refreshInterval: 5` を持たせる(既存の設定には `ensure` / `set` が足し、利用者の値は変えない) + +### 変更 + +- **認証の確認を関門から外した**(#478 #727 #664)。確認を通らない CLI があっても `init` はその者を外して + 続け、外した者と理由を状態ファイルと完了報告へ残す。使える者が 0 者なら止まる +- **`cross-review` が毎ラウンド 2 席を確保する**(#687 #727)。使える者が足りなければホスト、次に同じ + ランタイムの 2 つ目(`codex-2` など)で埋める。`--only` のときは埋め合わせをしない +- **`cross-refactoring` の既定の参加者を codex / kiro とホストにした**(#664)。agy は `--include agy` で戻す。 + 提案と適用を同じ参加者で回し、レビュー担当の役を無くした +- **再開で渡した引数を反映するか、反映しないと知らせる**(#648)。上限の引数の既定を未指定にし、既定値は + 状態の側で補う +- **担当 1 回の起動の結末を共通の語彙で読む**(#729 #619 #584)。利用上限は理由「利用上限」として残り、 + 同じラウンドで同じ担当を起動し直さない。監視は CLI を独立したプロセスグループで起動し、止めた後に子 + プロセスが結果を書かない +- **収束の判定で数えない指摘を、棄却と `minor` 以下に限った**(#732 #624 #706)。誤りを示されていない + `major` 以上は `unrefuted` として数える +- **GitHub と git へ書くのをレビューを回す側だけにした**(#730 #583)。レビューの担当は指摘の控えと + 結果ファイルを書いて終わり、`/ndf:fix` の担当はコミットまでで止まる。投稿は `state.py read-result`、 + 送信・返信・決着・まとめは `state.py merge-fix` が行い、4 種別すべてで二度書かない照合を掛ける +- **同じ適用ラウンドを開き直すのを 2 回までにした**(#728 #647 #592)。2 回目は別の担当が試し、2 回とも + 結果が残らなければ取り消して見送る。採用 0 件の提案ラウンドと項目の無い適用ラウンドでは担当を起動しない +- `launch-reviewer.sh` / `critique.sh` の第 1 引数をランタイム名から席の名前にした(ランタイム名は + そのまま受け付ける)(#727) +- statusline のメインの表示から上限・使用率・コンテナ名・ホスト名を外し、モデル名を詰めた + (`Opus 5 (1M context)` → `Opus5`)(#806) + +### 修正 + +- 帰属の段落がコミットの後ろに足されても、必須の記名を末尾の段落から読むようにした(#553) +- テストの実行中は `MONITOR_` で始まる環境変数を外し、監視の上限を延ばしたシェルでも同じ件数が通るように + した(#678) + +### 削除 + +- 共通層の `check_auth` / `impl_pool` / `review_assign` / `assign` を消した(#664)。置き換え先は + `probe_auth` / 参加者の一覧 / `review_seats` / `impl_assign` +- `cross-review` の担当の申告件数と GitHub の実数の突き合わせをやめた(#730) + ## [ndf 10.15.1] - 2026-09-19 ### 修正 diff --git a/CLAUDE.md b/CLAUDE.md index e1cf21f0..b6cbb18f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -59,27 +59,28 @@ python3 plugins/ndf/scripts/instructions-check.py --root . ## cross-refactoring -`/ndf:cross-refactoring` は codex / agy / kiro / claude のうち **ホストを除く 3 者**に構造改善を提案させ、**参加する 4 者**から輪番で選んだ 1 者が適用し、残り 2 者がレビューする。新しい提案が出なくなるまで繰り返す。 +`/ndf:cross-refactoring` は参加者に構造改善を提案させ、同じ参加者から輪番で選んだ 1 者が適用する。新しい提案が出なくなるまで繰り返す。参加者の既定は **codex / kiro とホスト(ホストが codex / kiro なら 2 者)** で、`--exclude` / `--include` で名指しで変える(agy は `--include agy` で戻す)。レビューは最終ゲートの `cross-review` が担う。 ```bash /ndf:cross-refactoring 130 --scope src/services --baseline-test "pytest -q" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=claude-opus-5 +/ndf:cross-refactoring 130 --scope src --include agy --exclude kiro ``` - `--scope` は必須。提案が発散して PR が肥大するのを防ぐ。**検証にも効く**ので、現状固定テストの置き場所も含める - ホストと同じランタイムが適用担当になる場合も、サブエージェントではなく **CLI プロセス**として起動する -- モデルを比べるなら `--model <ランタイム>=` を 4 つとも指定する。実際に動いたモデルを取得できるのは claude だけで、残り 3 つは指定値で代用する。指定が無いラウンドは集計から分離される -- 適用担当は 4 ラウンドで 1 周する。`--max-outer-rounds` の既定が 4 なのは、上限 3 では 4 者目の順番へ届かないため +- モデルを比べるなら `--model <ランタイム>=` を参加者の全員に指定する。実際に動いたモデルを取得できるのは claude だけで、残りは指定値で代用する。指定が無いラウンドは集計から分離される +- 適用担当は参加者の数のラウンドで 1 周する。輪番は適用ラウンドごとに進むため、`--max-outer-rounds`(既定 3)が切る提案の回数とは対応しない - 収束しない改善項目は **項目単位で取り消す**。合意済みの項目は PR に残る。ただし同一ファイルの隣接行を触る項目どうしは git だけでは分離できないため、そのラウンドは全件取り消しへ退避する - 生成物・配布物の同期は **進行側の責務**。実装担当にはさせない(範囲外の変更になる)。同期の手順は `--sync-command "bash scripts/build-runtime-plugins.sh"` のように渡す - 公開するのは **進行側だけ**。実装担当は push しない。進行側が検証を通した後に push するので、未検証の変更が公開されない - 履歴に残るのは **1 改善項目 = 1 コミット**。現状固定テストが要る項目だけ 2 コミット。テストも項目の単位で 1 回だけ求める - 改修計画は `--plan-file`(既定 `issues/refactoring-plan-rf.md`)へ書き出され、生成物の同期と同じコミットで公開される -- `init` が参加 CLI の認証状態を確認する。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` +- `init` が参加者の認証状態を確認し、通らない者を外して続ける。全員が揃わないなら止めたいときは `--require-all`。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` ## cross-review -`/ndf:cross-review` は codex / agy の両方に PR レビューを委譲し、両者が `APPROVE` するまで修正ループを回す。agy の progress log を heartbeat に表示するため、無言に見える時間でも `scan` / `analyze` / `post` / `done` などの作業段階を確認できる。 +`/ndf:cross-review` はホストを除く 3 つのランタイムのうち使える者から毎ラウンド 2 席を選んで PR レビューを委譲し、両席が `APPROVE` するまで修正ループを回す。使える者が 2 者に満たなければ、ホスト、次に同じランタイムの 2 つ目が席を埋める。agy の progress log を heartbeat に表示するため、無言に見える時間でも `scan` / `analyze` / `post` / `done` などの作業段階を確認できる。 追加レビュー観点は以下のどちらかで渡す: @@ -88,4 +89,4 @@ python3 plugins/ndf/scripts/instructions-check.py --root . /ndf:cross-review 123 --extra-instructions-file /tmp/review-focus.md ``` -PR の変更ファイルから docs only / code / DB migration / test / dependency / CI設定 / API契約 / 認証認可 / frontend / performance / deletion / generated / i18n / infra を自動分類し、該当するレビュー観点テンプレートも codex / agy 両方に渡す。 +PR の変更ファイルから docs only / code / DB migration / test / dependency / CI設定 / API契約 / 認証認可 / frontend / performance / deletion / generated / i18n / infra を自動分類し、該当するレビュー観点テンプレートも両席に渡す。 diff --git a/README.md b/README.md index eff39d40..f861f3bd 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Claude Code / Codex / Kiro CLI / agy 向けのスキル・MCP設定を共有す このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Codex / Kiro CLI / agy)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン v10.15.1** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI / agy へ配布されるプラグインです。配布物は `plugins/ndf/` の1ディレクトリにまとまっており、Skill の実体は `plugins/ndf/skills/` の1箇所だけです。どのランタイムへ配るかは `plugins/ndf/manifests/*-skills.txt` が決めます。 +**NDFプラグイン v10.16.0** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI / agy へ配布されるプラグインです。配布物は `plugins/ndf/` の1ディレクトリにまとまっており、Skill の実体は `plugins/ndf/skills/` の1箇所だけです。どのランタイムへ配るかは `plugins/ndf/manifests/*-skills.txt` が決めます。 - **公開Skills**: Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個に分離。 - **元Skills(45個)**: @@ -110,7 +110,7 @@ hook を効かせる手順と、新しい版へ入れ替える手順は | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 10.15.1 | Claude Code / Codex / Kiro CLI / agy へ 1 ディレクトリから配布する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個)、4ランタイム共通の作業ツリー運用フック(PreToolUse / SessionStart / userPromptSubmit / agentSpawn / PreInvocation)、Claude Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | +| **ndf** | 10.16.0 | Claude Code / Codex / Kiro CLI / agy へ 1 ディレクトリから配布する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個)、4ランタイム共通の作業ツリー運用フック(PreToolUse / SessionStart / userPromptSubmit / agentSpawn / PreInvocation)、Claude Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | | **playwright-kit** | 2.0.3 | Playwright による E2E テストの計画・実装・証跡管理を提供するプラグイン。ページ役割からのテスト計画、動画 / trace 付きスクリプト実装、レポート生成と Drive 保管、playwright_kit ランタイム(init、a11y / CWV スキャン)の 4 Skill。NDF v7.0.0 で分離。 | [README](./plugins/playwright-kit/README.md) | ### 変更履歴 diff --git a/conftest.py b/conftest.py index 0b80e99e..ccf49f53 100644 --- a/conftest.py +++ b/conftest.py @@ -10,6 +10,11 @@ テストは、実行した人の設定に関わらずその場で落ちる 4. テストの実行中だけ実行の要約の置き場所(`NDF_METRICS_DIR`)を一時ディレクトリへ向ける。 状態を保存するテストが、実行した人の状態ディレクトリへ要約を書かない(#662 の AC72) +5. テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)を外す。上限を延ばした + シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) + +どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は +根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。 `playwright-kit-ops` のディレクトリを起点にした実行では、このファイルは読まれない。 `pytester` はそのディレクトリの `pyproject.toml` の `addopts` が読み込む。 @@ -82,6 +87,41 @@ def _missing(bundles: set[str]) -> dict[str, list[str]]: return found +# 監視の上限を指す環境変数の接頭辞(#678)。担当ごとの指定・共通の指定のどちらもこの +# 接頭辞を持つため、接頭辞だけで一致させる。名前を並べると、上限の種類が増えるたびに +# ここへ足し忘れる。 +MONITOR_ENV_PREFIX = "MONITOR_" + +# `pytest_configure` で外した値の控え。実行が終わったときに戻す。 +_saved_monitor_env: dict[str, str] = {} + + +def _strip_monitor_env() -> dict[str, str]: + """接頭辞の環境変数を外し、外した値を返す。""" + return {k: os.environ.pop(k) for k in list(os.environ) if k.startswith(MONITOR_ENV_PREFIX)} + + +def pytest_configure(config) -> None: + """テストの実行中だけ、監視の上限を指す環境変数を外す(#678)。 + + 無進捗の許容と打ち切りの上限は環境変数で延ばせる。運用で延ばしたシェルから起動すると、 + 表の既定値を前提にするテストが既定値ではなくその値を読み、変更の中身と関係なく落ちる。 + 収束ループの初期化は着手前のテストの通過を条件にするため、そこで止まる。 + + **収集より前に外す。** テストの本体を読み込む時点で上限を決めてしまう実装があり、 + セッションの前提(fixture)では間に合わない。子プロセスは環境変数を受け継ぐため、 + テストが起動する別プロセスにも同じ切り離しが効く。**個別に設定するテストは打ち消さない。** + `monkeypatch` も、別プロセスへ渡す上書きも、この後に効く。 + """ + _saved_monitor_env.update(_strip_monitor_env()) + + +def pytest_unconfigure(config) -> None: + """実行が終わったら、外した環境変数を戻す。""" + os.environ.update(_saved_monitor_env) + _saved_monitor_env.clear() + + def pytest_collection_modifyitems(config, items) -> None: bundles = {b for item in items if (b := _bundle_of(Path(str(item.fspath)))) is not None} missing = _missing(bundles) diff --git a/docs/ndf-version-decisions.md b/docs/ndf-version-decisions.md index e2ea946d..06f23abd 100644 --- a/docs/ndf-version-decisions.md +++ b/docs/ndf-version-decisions.md @@ -1,4 +1,4 @@ -# NDF の版ごとの決定と理由(v10.12.0〜v10.15.0) +# NDF の版ごとの決定と理由(v10.12.0〜v10.16.0) `CLAUDE.md` から移した、出た版の記録である。**その版で何を決め、なぜそう決めたか**を残す。 変更点の列挙は `CHANGELOG.md` にあり、こちらは判断の理由を持つ。**`CLAUDE.md` へ書くのは @@ -185,3 +185,56 @@ Pull Request を通すと、計画の更新がマージを待って進み具合 Claude Code 2.1.274 の実測 8 通りのうち読み込まれた 2 通り(行頭・空白の直後、強調の中)に合わせた。 観点は根拠を出典として残して値そのものは持たず、調べ直しは自動で書き換えない(外部の記述の読み違いが そのまま検査の基準になる)。**この配布が、退避の検査を配布の工程で通した最初の版である。** + +v10.16.0 で収束ループを、担当が揃わない・上限で止まる・結果を残さないときにも止まらず終わる形にした +(マイルストーン 13「agy の打ち切りと止まらない収束ループ」、#478 #553 #583 #584 #592 #619 #624 #647 +#648 #664 #678 #687 #706 #727 #728 #729 #730 #732 #736)。**19 件の課題を、根本原因の場所 5 つ +(#727 #728 #729 #730 #732)へ寄せてから設計した。** 現れ方ごとに直すと、同じ判断が cross-review と +cross-refactoring の 2 か所に書かれ、片方だけが古くなる。設計の Pull Request(#781〜#784 #794)を +先に通し、実装(#790 #791 #793 #796 #797 #800 #801)は共通層を触る順に並べた。 + +**結果なしの判断と起動し直しの可否は、結末の共通層の 1 つの真偽値が持つ**(#729)。偽にするのは +利用上限だけにした ── 利用上限は起動し直しても解けず、起動のたびに待ちと相手の枠を使う。他の理由は +対象や負荷で変わるため 1 度は起動し直してよい。偽のときに何をするか(担当を替えるか、誤りで終えるか)は +担当の集合を知る Skill が決める。**利用上限は早期の致命の状態のまま理由だけを分けた** ── 終了コードで +分岐する骨組みと文書を変えずに、読む側が区別できる。 + +**認証の確認を関門から外し、使える者だけで始める**(#478 #727)。確認と中断が 1 つの関数にあると、 +使える者で回す経路から呼べない。止めるかどうかは `--require-all` が決め、全員が揃わないなら始めたくない +運用を残した。**席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める**(#687)── +同じ言語モデルの 2 つの文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる。1 つ目の席の +名前をランタイム名そのままにしたのは、埋め合わせの要らない実行で従来と同じ名前しか現れないためである。 +**起動した後に分かる使えなさで担当を自動で外す仕組みは作らない** ── 一時的な打ち切りでも、以後の +ラウンドから恒久的に外れる。 + +**cross-refactoring の既定の参加者を codex / kiro とホストにした**(#664)。起動 199 回のうち失敗は +agy の 7 回だけで、提案の所要の中央値も agy が最も長かった(5 分。codex 3 分、kiro 2 分)。提案は最も +遅い者を待つため、所要はほぼ agy で決まっていた。既定にホストを含めると提案と適用の集合が一致し、 +適用専用の母集合を持つ理由が無くなる。構造改善のレビュー工程は既に無く、記録だけに残っていた +レビュー担当の役は消した ── 残すと読み手が「この 2 者がレビューした」と読む。 + +**再開で渡した引数は、反映するか知らせるかのどちらかの表に必ず載せる**(#648)。引数の既定を未指定に +したのは、既定値と同じ値を「渡していない」とみなす形では、上限を既定値へ戻す操作を区別できないため +である。担当に関わる引数を渡した再開でだけ参加者を作り直す ── 途中で担当が入れ替わると、前の +ラウンドの記録と突き合わせられなくなる。 + +**収束の判定で数えないのは、棄却と `minor` 以下だけにした**(#732 #624 #706)。反証する相手がいない +構成(`--only` の 1 者)で、誤りを示されていない `major` が立証不足の区分へ落ち、未解決のまま承認で +終わっていた。立証不足の区分の名前は変えず軽微な指摘の残余だけに当てた ── 改名すると旧い状態 +ファイルの値・測定の読み方・語彙を同時に付け替えることになり、変更の前後を測定で比べられなくなる。 + +**GitHub と git へ書くのは、レビューを回す側だけにした**(#730 #583)。担当が投稿してから結果ファイルを +書く形では、その間で止まると投稿は残り記録は残らず、起動し直した担当が同じ論点を重ねて投稿した +(PR #578)。担当の投稿の後に実物と照合して記録を補う形は採らない ── 照合の問い合わせが上限で +失敗すると同じ食い違いが残る。修正は現在の頭を指定して送り、送った後に照合する ── ブランチ名だけの +送信は、切り離された頭で何も送らずに終了コード 0 で終わる。単独の `/ndf:fix` の口は新しい入口の +スクリプトにせず共通層の部分命令にした ── 手順に書く行が 1 本で済み、実装が 1 つになる。 + +**同じ適用ラウンドを開き直すのは 2 回までの固定値とし、引数を足さない**(#728 #647 #592)。2 回目は +別の担当が試すため、2 回とも残らなければ担当ではなく適用ラウンドの側を疑える。上限を引数にして 2 つ +置くと、どちらで止まったかを読み解く必要が出る。**進行側は取り込みでコミットを書き換えない**(#553) +── 識別子が変わり申告と実体の対応が切れるため、必須の記名は末尾の段落から前へ git の判定に掛けて読む。 + +**テストの実行中は `MONITOR_` で始まる環境変数を、根の `conftest.py` の 1 か所で外す**(#678)。 +名前を並べずに接頭辞で一致させるのは、上限の種類が増えるたびに一覧へ足し忘れる形を作らないためで、 +収集より前に外すのは、テストの本体を読み込む時点で上限を決める実装があるためである。 diff --git a/docs/specifications/README.md b/docs/specifications/README.md index b1ff5983..6844855a 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -16,10 +16,16 @@ | [ndf-testenv-lock-and-registry.md](ndf-testenv-lock-and-registry.md) | テスト環境の排他の判定を陳腐化の規則へ揃えたこと、台帳へ書けなかったときの扱い。手順は `worktree` の `references/test-execution.md` が正 | | [ndf-issue-upkeep-root-cause.md](ndf-issue-upkeep-root-cause.md) | 溜まった課題を根本原因の場所で直す判定(ルートコーズ)と、構造の判断の担い手。親 issue とサブイシューの実測、マイルストーンを連番で読む理由。手順は `issue-upkeep` の SKILL.md と `references/` が正 | | [cross-review-evidence-based.md](cross-review-evidence-based.md) | 証拠ベースのレビューと効果の測定。状態ファイルの契約と決定の理由。手順は `cross-review` の `SKILL.md` が正 | +| [cross-review-launch-outcome.md](cross-review-launch-outcome.md) | 起動 1 回の結末の語彙(理由 9 語)と起動し直しの可否、利用上限と CLI の上限の検知、プロセスグループでの起動と停止。手順は `cross-review` の `SKILL.md` と `docs/` が正 | +| [cross-review-participants-and-seats.md](cross-review-participants-and-seats.md) | 使える者だけで収束ループを始める共通層(認証の確認を止めない形・参加の母集合と足す者/外す者・毎ラウンド 2 席の埋め方・席の名前)と、再開で渡した引数の反映。手順は `cross-review` の `SKILL.md` と `docs/` が正 | +| [cross-review-writes-to-conductor.md](cross-review-writes-to-conductor.md) | GitHub と git への書き込み(レビューの投稿・返信・決着・修正のまとめ・修正の送信)をレビューを回す側だけが行うこと、担当が書く 2 つのファイルと改名の順序、二度書かない照合、差分の外を指す指摘の退避、途中で止まったときの立て直し、起動し直しを初回と同じ経路へ通すこと。手順は `cross-review` の `SKILL.md` と `docs/` が正 | +| [cross-refactoring-apply-intake.md](cross-refactoring-apply-intake.md) | 担当が結果を残さない起動を 3 つの取り込みが同じ手順で受けること(範囲の確定・未検証のコミットの取り消し・結末の記録)、適用ラウンドの開き直しの判定と試行の上限 2 回、項目の無い適用ラウンドを作らないこと、帰属の段落の後ろから必須の記名を読むこと。手順は `cross-refactoring` の `SKILL.md` と `docs/` が正 | +| [cross-refactoring-participants.md](cross-refactoring-participants.md) | cross-refactoring の参加者(codex / kiro とホストを既定に足す者/外す者で変える・確認を通らない者を外して続ける)、提案と適用を同じ参加者で回す輪番、再開で渡した引数の反映、呼び手の無くなった共通層の旧関数の削除。手順は `cross-refactoring` の `SKILL.md` と `docs/` が正 | | [ndf-cleanup-and-bundle-closing.md](ndf-cleanup-and-bundle-closing.md) | 後片付けが止まる条件(git の拒否だけ)、実行前確認の要否を決める 3 つの問い、まとまりの課題を終わりの工程で閉じる条件と結果の 4 値、配布の記録の形と読み方。手順は `merged` / `progress-tracking` / `release` の SKILL.md が正 | | [ndf-agent-layers-unattended-run.md](ndf-agent-layers-unattended-run.md) | `/goal` の工程を conductor / supervisor / worker の 3 層で通す運転。持ち場 5 つ、報告の 2 段、続けさせる回数、上限(429)で中断した層の再開。手順は `development-workflow` の `references/agent-layers.md` が正 | | [ndf-context-window-metrics.md](ndf-context-window-metrics.md) | 会話の記録から context window を 3 層で測る部品(`transcript_agents.py`)の値の取り方と、`skill-stats --agents` の 4 つの表と印。値の取り方はこの文書が正 | | [ndf-execution-plan-and-parallel-capacity.md](ndf-execution-plan-and-parallel-capacity.md) | 並列の実行計画(依存を工程の対で書く・重なりの 3 区分・開いている間はコミットしない)、マイルストーンの組、メモリで見る本数(`parallel-measure.py`)。手順は `issue-plan-strategy` と `development-workflow` の `references/` が正 | | [ndf-instruction-files-check.md](ndf-instruction-files-check.md) | エージェント向け指示書の検査(`instructions-check.py`)。宣言 `.ndf/instructions.json` で決まる判定の強さ、即時読み込みと出た版の段落の判定、扱いの印、観点の調べ直し。呼び方と宣言の書き方は `release` の `references/instruction-files.md` が正 | +| [test-monitor-env-isolation.md](test-monitor-env-isolation.md) | テストの実行中だけ監視の上限を指す環境変数(接頭辞 `MONITOR_`)をリポジトリの根の共通の前提で外すこと、外す時点と戻す時点、根の設定ファイルで基準のディレクトリを固定すること | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/cross-refactoring-apply-intake.md b/docs/specifications/cross-refactoring-apply-intake.md new file mode 100644 index 00000000..2e611a2d --- /dev/null +++ b/docs/specifications/cross-refactoring-apply-intake.md @@ -0,0 +1,410 @@ +# cross-refactoring: 実装担当が結果を残さないと同じ適用ラウンドが上限なしに開き直され、未検証のコミットが残る → 結果なしを取り込みの 1 か所で受けて取り消し、開いた回数と結末で開き直しを決める + +## 目的 + +**担当の CLI が作業結果を残さずに終わっても、3 つの取り込み(適用・修正・最終ゲートの修正)は +同じ手順で受けて終わる。** 検証を受けていないコミットは取り消され、結末は記録に残り、終了 +コードが返る。下位の読み取りが進行を打ち切ることはない。 + +**同じ適用ラウンドを開き直す回数は 2 回で止まる。** 2 回目は別の担当が試し、2 回とも結果が +残らなければその適用ラウンドを取り消して見送りへ入れる。 + +**採用が 0 件だった提案ラウンドと、項目が 1 件も無い適用ラウンドでは担当を起動しない。** + +**起動し直しても解けない結末(利用上限)では、同じ担当を同じ工程で起動し直さない。** + +**実行環境が帰属の段落を後ろへ足したコミットでも、必須の記名が読める。** 進行側はコミットを +書き換えない。 + +**手順と引数の表は +[`cross-refactoring` の SKILL.md](../../plugins/ndf/skills/cross-refactoring/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、進行の内部の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 提案ラウンド | `rounds[]` | 提案 → 採否 → 適用 → 検証 → 修正の 1 周 | +| 適用ラウンド | `rounds[].apply_rounds[]` | 書き換えるファイルが重ならない改善項目の集まり。1 つで 1 コミット | +| 修正ラウンド | `fix_rounds` / `--max-fix-rounds` | 検証が落ちた適用ラウンドを直す 1 周と、その上限 | +| 取り込み | `merge-apply` / `merge-fix` / `merge-final-fix` | 担当が作ったコミットを進行側が検証して受け入れるコマンド。適用・修正・最終ゲートの修正の 3 つ | +| 適用ラウンドを開く | `next-apply-round` | 次に適用する 1 つを選び、担当と項目を出す | +| 最終ゲート | `final-gate` | 全体のテストを実行して合否を判定する | +| 試行 | `attempt` | 1 つの適用ラウンドに担当を起動し、適用の取り込みで受けようとした 1 回 | +| 結末 | `LaunchOutcome` | 担当 1 回の起動の終わり方。使える結果か、結果なしの理由かを持つ | +| 結果なし | `payload` が `None` | 結果ファイルが無い、または JSON オブジェクトとして読めない | +| 起動し直しの可否 | `relaunch_same_agent` | 同じ担当を同じ条件で起動し直せば解ける結末か | +| 結末の読み取り | `gitfacts.read_result` | 状態と工程から結果ファイルの名前の幹を組み、結末を値で返す包み | +| 結末の共通層 | `lib/monitor_outcome.py` の `read_launch_outcome` | 理由の語彙と可否を持つ、2 つの Skill 共通の読み取り | +| 取り込みの共通手順 | `refactor_lib/intake.py` | 範囲の確定・取り消し・結末の記録を 3 つの取り込みで共有する層 | +| 取り込みの範囲の値 | `IntakeScope` | 取り込み 1 つ分の「どこを見て、どこへ書くか」 | +| 範囲 | — | 起点(`apply_base_sha` / `fix_base_sha`)から作業ツリーの先端まで | +| 起点 | `apply_base_sha` / `fix_base_sha` / 適用ラウンドの `base_sha` | 範囲の始まり | +| 結末の記録 | `failed_attempts[]` | 結果を残さなかった起動の記録。適用ラウンドと最終ゲートの控えが持つ | +| 取り消しの理由 | `drop_reason` | 適用ラウンドを取り消した理由(`no_result` / `empty`) | +| 開き直しの判定 | `rounds.group_reopening` | その適用ラウンドを開くか・再開するか・開かないかを返す | +| 輪番から担当を引く関数 | `rounds.impl_for_seq` | 輪番の通し番号から担当と要求モデルを引く 1 か所 | +| 試行の上限 | `vocabulary.MAX_APPLY_ATTEMPTS`(2) | 同じ適用ラウンドを開き直す回数の上限 | +| 無進捗の許容 | `IMPL_STALL_TIMEOUT` / `vocabulary.IMPL_STALL_MARGIN`(900 秒) | 担当が何も出力しないまま待てる秒数と、その余白 | +| 必須の記名 | `Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model` | コミットメッセージのトレーラーとして担当が書く 4 つ | +| 帰属の段落 | `Co-Authored-By:` / `Claude-Session:` | 実行環境がコミットメッセージの末尾へ足す段落 | + +## 対象範囲 + +この文書が扱うのは、cross-refactoring の 3 つの取り込みと、適用ラウンドの進行の側である。 + +| 扱う | 扱わない | +| --- | --- | +| 結末を値で受け取る読み取りの包み、取り込みの共通手順、開き直しの判定、試行の上限と担当の交代、項目の無い適用ラウンド、修正と最終ゲートの修正の結果なし、必須の記名の読み方、無進捗の許容 | 結末の共通層そのもの(理由の語彙と可否を持つ側) | +| 輪番から担当を引く 1 つの関数へ寄せること | 参加者の決め方(別の課題が持つ) | +| 結末の記録の形(3 つの取り込みで同じ配列) | 記録を集計した実行の要約の新しい欄 | +| 進行側がコミットを書き換えないこと | 利用上限で進行全体を止めること | + +**輪番から担当を引く関数は、従来の担当の割り当てを包む形で実装されている。** 呼ぶのは 3 か所 +(適用ラウンドの割り当て・結果なしの試行の交代先・最終ゲートの修正担当)で、参加者の決め方が +変わったときに書き換える場所はこの関数の中だけである。 + +## 背景 + +**結果ファイルの読み取りが、そのまま進行の終了コードを決めていた。** 結果ファイルが無い・ +読めないときは下位の読み取りがプロセスを終わらせるため、その先にある範囲の検査・未検証の +コミットの取り消し・適用ラウンドの状態の記録へ一度も進めなかった。読み取りを呼ぶ取り込みは +適用・修正・最終ゲートの修正の 3 つあり、いずれも同じ形で止まっていた。 + +**適用ラウンドを開く側が、中断からの再開と失敗した試行のやり直しを区別していなかった。** +未着手と取り込み済みの適用ラウンドを無条件に開き直すため、結果を残さない担当に当たり続けた。 +実測では、担当が 15 秒で終わって結果を残さない構成で同じ適用ラウンドを 29 回開き直した時点で手で +止めている(PR #757)。別の実行では空振りの起動し直しが 3729 回に達した(#647)。 + +**採用が 0 件でも適用ラウンドが 1 つ作られていた。** 適用ラウンドの一覧は、鍵が無い状態と +空の配列を区別せずに 1 つの適用ラウンドを作っていた。テスト整備の採用が 0 件の実行では、項目が +1 件も無い適用ラウンドに担当を 187 回起動している(#592)。 + +**未検証のコミットが Pull Request に残りうる状態だった。** 最終ゲートの修正の取り込みが結果 +なしで止まると、担当の作ったコミットは範囲の検査も取り消しも受けない。次の最終ゲートはその +コミットを含む先端でテストし、落ちれば起点を先端へ置き直すため、そのコミットは以後どの範囲 +にも入らなかった(#674)。 + +**必須の記名が、実行環境の足す帰属の段落のために読めなかった。** git の標準の読み方は +メッセージの最後の段落しか読まない。claude が担当の適用ラウンドでは帰属の段落が後ろに付くため、 +4 つの記名が欠落と判定され、適用ラウンドが丸ごと取り消されていた(#553)。 + +**担当がテストを実行している間の無出力が、無進捗として打ち切られていた。** 適用と修正の担当は +テストを 1 回実行し、その間は何も出力しない(#647)。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 結末の読み取りは名前を残した包みとし、結果ファイルの名前の幹をそこで 1 度だけ組んで結末を値で返す | 監視へ渡した名前の雛形と食い違う幹で読むと監視の結果ファイルを引けない。取り込みが共通層を直に呼ぶ形では、幹の組み立てが 3 か所に分かれる | +| 引数を、ファイルのパスと担当から、状態・担当・工程・提案ラウンドへ変える | 古い形の呼び出しが実行時に失敗し、契約の変更を素通りしない | +| 「範囲の確定 → 未検証のコミットの取り消し → 結末の記録」を新しい共通の層へ置き、3 つの取り込みの差は範囲の値で渡す | 取り消しの本体が 2 つあり、違いは起点の鍵と記録先だけだった。取り込みに残るのは、その結果を終了コードと適用ラウンドの状態へ写すことだけになる | +| 共通の層をコマンドの層にも、git の事実の読み取りにも置かない | 3 つのコマンドが読む層である。コマンドどうしの取り込みを作らない。取り消しは事実の読み取りではなく進行の手順である | +| 結果なしの記録は、3 つの取り込みで同じ形の配列にする | 形を 3 通りに分けると、実行の要約と改修計画が 3 通りの読み方を持つ | +| 同じ適用ラウンドの試行の上限は 2 回の固定値とし、引数を足さない | 2 回目は別の担当が試すため、2 回とも残らなければ担当ではなく適用ラウンドの側を疑える。上限を 2 つ置くと、どちらで止まったかを読み解く必要が出る | +| 開き直しの判定を 1 つの関数へ置き、適用ラウンドを開く側と適用の取り込みの両方がそれを読む | 判定が 2 か所にあると、開く側とやり直しの側で違う答えを出す。判定を 1 か所に置けば、骨組みの分岐は変わらない | +| 開いた回数だけを数えず、結末の記録の件数と合わせて判定する | 進行側が落ちて再開しただけで試行が進んでしまう | +| 2 回目の試行は次の輪番の担当が行い、その適用ラウンドで失敗した担当のどれとも違う担当が出た最初の番号を採る | 結果を残さない原因の多くは担当の CLI の側にある。輪番を 1 つ進めるだけでは、1 周して同じ担当へ戻ることがある | +| 替える先が無いときだけ、起動し直しの可否で 2 回目を開くか取り消すかを決める | 参加者が 1 者に絞られた実行では交代先が無い。利用上限で進行全体を止めると、他の担当で進められる適用ラウンドまで止まる | +| 輪番を引く呼び出しを 1 つの関数へ寄せる | 参加者の決め方が変わったときに、変える場所がその関数の中だけで済む | +| 修正の結果は、提案ラウンドの担当ではなく適用ラウンドの担当から読む | 骨組みが起動するのは適用ラウンドの担当である。一致しない適用ラウンドでは結果を一度も取り込めず、検証と修正を往復し続ける | +| 修正の結果なしは修正ラウンドを 1 つ進め、起動し直せない結末では上限の値まで進める | 進めないと見送りの判定が上限に達する条件を満たさない。替えないまま上限まで起動し直すと、利用上限の担当を上限の回数だけ空振りさせる | +| 修正の担当は替えない | 直しかけの文脈を持つ者が続けたほうが速い。最終ゲートの修正が担当を替えない理由と同じである | +| 最終ゲートの修正も同じ共通の手順を通し、結果なしは取り消して判定へ戻す | 取り消せば、次の最終ゲートは修正前の地点でテストする。修正ラウンドの数を進めるのは最終ゲートの側なので、繰り返しは上限で止まる | +| 適用ラウンドの一覧は、鍵が無いときだけ古い版として 1 つ作り、空の配列はそのまま返す | 採用 0 件の提案ラウンドは空の配列を書く。ここで作ると項目の無い適用ラウンドが生まれる | +| 採用 0 件で提案の取り込みが繰り返しを終える合図を返す形は採らない | その合図はテスト整備では構造改善へ進む前に抜けてしまう | +| 必須の記名は、末尾の段落から前へ 1 段落ずつ git の判定に掛け、判定しなかった段落で止める | トレーラーの形で書かれた署名なら、誰が何行足しても同じに読める。全文から記名の形の行を拾う形では、散文の中の行まで拾う | +| 1 段落目(題名)は判定に掛けない | 掛けると、記名の形をした題名を記名として読む | +| 進行側は取り込みでコミットを書き換えない | コミットの識別子が変わり、申告と実体の対応が切れる。適用ラウンドに複数のコミットがあると、先頭の書き換えが以降をすべて書き換える。実際に使ったモデル名は担当しか知らない | +| 雛形のコミットの規約にも、必須の記名を最後の段落に置くことを書く | 人が git の標準の読み方で集計できる。従わなくても進行側の検証は通る | +| 無進捗の許容をテストの制限時間 + 900 秒とし、雛形に進捗の記録の指示を足す | 担当はテストを 1 回実行し、その間は何も出力しない。全体の制限時間は工程ごとの上限が決めるため渡さない | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 検証を受けていないコミットを、公開したまま次の工程へ渡さない | 3 つの取り込みが結果なしで範囲を取り消し、取り消した後の先端を次の起点にする | +| 取り消しへ着手する前に、公開の保留の印を立てて保存する | 取り消しは済んだのに公開できずに終わると、未検証の変更が Pull Request に残る | +| 取り消した後の起点は、その場で保存する | 保存せずに落ちると、次の実行が古い起点から範囲を取り直し、取り消しのコミット自体を未申告と判定する | +| 同じ工程・同じ試行番号の結末は、記録に 1 件しか入らない | 記録済みなら結果ファイルを読まずに終了コード 2 を返す。後から現れた結果ファイルを、取り消し済みの範囲の申告として取り込まない | +| 範囲の「1 件もコミットされていない」と「確定できなかった」を区別する | 混同すると、確定できないときに検査が素通りする | +| 結末の記録は追記だけで、上書きしない | 担当を替えると適用ラウンドの担当は書き換わるが、どの担当がどの試行で失敗したかは記録から読める | +| 取り消すコミットが無ければ、取り消しも公開も実行しない | 範囲が空のときは記録だけを足す | +| この変更の前に始めた実行の状態ファイルを、書き換えずに読める | 増えた鍵が無い状態は、試行 0 回・失敗なしとして読む | + +### 構成要素と責務 + +| 要素 | 責務 | 置き場所 | +| --- | --- | --- | +| 結末の読み取り | 状態と工程から結果ファイルの名前の幹を組み、結末の共通層を呼んで値を返す。中断も画面への出力もしない | `refactor_lib/gitfacts.py` の `read_result` | +| 取り込みの共通手順 | 範囲の確定・未検証のコミットの取り消し・叩き直しの判定・結果なしの一連 | `refactor_lib/intake.py` | +| 適用ラウンドの進行 | 適用ラウンドの一覧・進行中の適用ラウンド・開き直しの判定・輪番から担当を引く | `refactor_lib/rounds.py` | +| 適用の取り込みと開く側 | 開き直しの判定で開く。結果なしでは担当の交代か取り消しを決める | `refactor_lib/commands/apply.py` | +| 修正の取り込み | 適用ラウンドの担当の結果を読む。結果なしは修正ラウンドを進める | `refactor_lib/commands/converge.py` | +| 最終ゲートと最終ゲートの修正の取り込み | 共通の手順を通す。修正担当を輪番から引く | `refactor_lib/commands/gate.py` | +| 判断の基準になる値 | 試行の上限と、無進捗の許容の余白 | `refactor_lib/vocabulary.py` | +| 起動の出力 | 無進捗の許容を出す | `refactor_lib/commands/setup.py` | +| 必須の記名の読み取り | 末尾から続くトレーラーの段落を読む | `refactor_lib/gitfacts.py` の `commit_trailers` | +| 結末の共通層 | 理由の語彙と起動し直しの可否を持つ。この変更では読むだけ | `plugins/ndf/scripts/lib/monitor_outcome.py` | + +要素の関係(辺は呼び出し): + +```mermaid +graph TD + subgraph 取り込み + A[適用の取り込み] + F[修正の取り込み] + G[最終ゲートの修正の取り込み] + end + subgraph kyotsu["共通の手順"] + R[結末の読み取り] + I["取り込みの共通手順
範囲の確定 / 取り消し / 結末の記録"] + end + subgraph shinko["適用ラウンドの進行"] + N[適用ラウンドを開く] + RO[開き直しの判定] + IS[輪番から担当を引く] + end + MO[結末の共通層] + A --> R + F --> R + G --> R + R --> MO + A --> I + F --> I + G --> I + A --> RO + N --> RO + A --> IS + G --> IS +``` + +### 起動 1 回の結末を読む + +**結果ファイルを自前で開かず、結末の共通層を呼ぶ。** 包みが行うのは、状態・担当・工程・提案 +ラウンドから結果ファイルの名前の幹を組むことと、共通層の値をそのまま返すことだけである。 + +| 項目 | 内容 | +| --- | --- | +| 入力 | 状態ファイルの辞書(一時ディレクトリと実行の識別子を読む)、担当、工程(`apply` / `fix` / `final-fix`)、提案ラウンド(最終ゲートの修正では省く) | +| 出力 | 結末。使える結果、または結果なしの理由と起動し直しの可否 | +| 失敗の形 | **失敗しない。** 中断せず、標準出力と標準エラーにも書かない | + +**理由の語彙 9 語と起動し直しの可否は、結末の共通層が 1 か所で持つ。** この文書では定義せず、 +[起動 1 回の結末の仕様](cross-review-launch-outcome.md)を参照する。取り込みは理由をそのまま +記録へ写し、可否を担当の交代の分岐に使うだけで、利用上限の条件分岐を自分では持たない。 + +### 取り込み 1 回の共通手順 + +3 つの取り込みは、結果を読めたときも読めなかったときも同じことを行う。起点から先端までの +範囲を確かめ、通らなければ取り消し、起点を取り消した後の先端へ進める。違うのは起点の鍵と +記録先だけなので、その差だけを取り込みの範囲の値で受け取る。 + +| 手順 | 契約 | +| --- | --- | +| 範囲の確定 | 起点から先端までのコミットを新しい順で返す。確定できなければ「確定できなかった」を返す | +| 取り消し | 公開の保留の印を立てて保存 → 新しい順に取り消す → 起点(と適用ラウンドの起点)を先端にして保存。取り消した件数を返す | +| 叩き直しの判定 | 同じ工程・同じ試行番号の記録があれば真 | +| 結果なしの一連 | 範囲の確定 → 取り消し → 結末の記録へ 1 件を足して保存。取り消したときだけ公開する。範囲を確定できなければ、取り消しも記録も行わずに「確定できなかった」を返す | + +**何で終わるかは取り込みの側が決める。** 範囲を確定できなかったときの扱いは 3 つで異なる。 + +### 適用ラウンドを開くかどうかの判定 + +判定に使うのは適用ラウンドが持つ 2 つだけである。開いた回数と、結末の記録のうち工程が適用の +ものの件数である。 + +| 値 | 条件 | 意味 | +| --- | --- | --- | +| `empty` | 項目が 1 件も無い | 開かない。取り消し済み(理由は項目なし)にして次を探す | +| `exhausted` | 適用の失敗の件数が試行の上限に達した | 開かない。取り消し済み(理由は結果なし)にする | +| `resume` | 開いた回数が失敗の件数より多い | 開いたまま閉じていない試行の再開。起点も試行の番号も動かさない | +| `open` | 上のいずれでもない | 開いて試行の番号を進め、起点をその時点の先端にする | + +適用ラウンドを開く側の流れである。取り込み済みの適用ラウンドは判定に掛けず、起点も修正ラウンドの +数も動かさずに開き直す。適用は取り込んだが検証まで進めずに落ちた実行を再開できるようにする +ためである。 + +```mermaid +graph TD + S[未着手・取り込み済みの適用ラウンドを順に見る] --> K{状態} + K -->|無い| E1[終了コード 1] + K -->|取り込み済み| O[開き直す
起点と試行の番号を動かさない] + K -->|未着手| R{開き直しの判定} + R -->|項目なし・上限| D[取り消し済みにする] --> S + R -->|開く| INC[試行の番号を進め
起点を先端にする] + R -->|再開| OUT[担当と項目を出す] + INC --> OUT + O --> OUT +``` + +### 結果を残さなかった適用の試行の閉じ方 + +適用の取り込みは、置き土産の後始末と取り込み済みの判定を済ませ、着手前のテストが成功と確認 +できていることを確かめてから、叩き直しの判定を見て結果を読む。**着手前のテストの確認は結果を +読むより先に行う。** 成功と確認できていない状態で採ると、壊したのか元から壊れていたのかを +判別する手段が無い。 + +結果なしのときは次の順で閉じる。 + +1. 結果なしの一連を通す。範囲を確定できなければ、適用ラウンドの項目を保留にして終了コード 4 で + 中断する。試行の番号が 0 なら 1 として記録し、適用ラウンドの試行の番号を 1 にする +2. 開き直しの判定を読む。「開く」なら担当を替える。輪番の通し番号を 1 ずつ進め、その適用ラウンドで + 失敗した担当と現在の担当のどれとも違う担当が出た最初の番号を採る。参加者の数だけ進めても + 出なければ、替える先が無い +3. 替える先が無いときは、起動し直しの可否で決める。可(結果ファイルが無い・無進捗など)なら + 担当を替えずに 2 回目を開く。否(利用上限)ならその場で取り消す +4. 取り消すときは、適用ラウンドの項目を見送りへ入れて取り消し済み(理由は結果なし)にし、取り込みの + 時刻を立てて、残りの適用ラウンドの有無で次の局面を決める +5. 保存して終了コード 2 で終わる。公開は手順 1 が取り消したときに済んでいる + +**見送りの理由には、どの担当がどの理由で結果を残さなかったかを並べる。** 改修計画の見送った +項目の表にそのまま出る。 + +### 修正と最終ゲートの修正の結果なし + +どちらも叩き直しの判定 → 結末の読み取り → 結果なしの一連という同じ流れを通り、終了コード 2 で +終わる。違うのは修正ラウンドの数の進め方である。 + +| 取り込み | 範囲を確定できないとき | 起動し直しが可のとき | 起動し直しが否のとき | +| --- | --- | --- | --- | +| 修正 | 修正ラウンドを 1 進めて中断 | 修正ラウンドを 1 進める | 修正ラウンドを上限の値にする | +| 最終ゲートの修正 | そのまま中断 | 修正ラウンドを進めない(次の最終ゲートが進める) | 修正ラウンドを上限の値にする | + +**修正の担当は適用ラウンドの担当から読む。** 結末の記録も、最終ゲートの修正を除いてその適用ラウンド +が持つ。担当が適用ラウンドごとに決まるためである。 + +**最終ゲートの修正を取り消すと、次の最終ゲートは修正前の地点でテストする。** 取り消した +コミットは修正のコミットの一覧に入らない。起動し直しが否で修正ラウンドが上限の値になった後の +最終ゲートは、テストが落ちれば取り消さずに報告して終わる。押し出し済みの地点で、上限に達しても +採用した改善項目は取り消さないという既存の規則を変えない。 + +### 項目の無い適用ラウンドを作らない + +3 か所で止める。 + +| 場所 | 扱い | +| --- | --- | +| 適用ラウンドの一覧 | 鍵が無いときだけ、古い版として提案ラウンド全体を 1 つの適用ラウンドとしてまとめる。空の配列はそのまま返す | +| 適用ラウンドを開く側 | 項目の無い未着手の適用ラウンドは開かずに取り消し済み(理由は項目なし)にし、次を探す。残りが無ければ終了コード 1 | +| 適用の取り込み | 取り込み済みで採用が 0 件だった適用ラウンドは、取り消し済み(理由は項目なし)に直して終了コード 2 | + +### 必須の記名の読み方 + +コミットメッセージを空行で段落に分け、末尾の段落から前へ 1 段落ずつ git の判定に掛ける。 +判定がトレーラーの段落と認めなかった段落で止める。同じ鍵が 2 つの段落にあれば、末尾に近い +段落の値を採る。**1 段落目(題名)は判定に掛けない。** + +| 読む側 | 読み方 | どこまで読むか | +| --- | --- | --- | +| 進行側の検証 | 末尾から続くトレーラーの段落 | 帰属の段落を越えて必須の記名まで届く | +| 人の集計 | git の標準の読み方 | 最後の段落だけ | + +**判定そのものは git に委ねる。** 「何行以上なら記名の段落か」といった規則を進行側に持たない。 +段落だけを渡すと git は何も返さないため、題名の行を補って渡す。 + +雛形のコミットの規約は、必須の記名をメッセージの最後の段落に置き、実行環境が帰属行を足すときは +空行を挟まず同じ段落に続けることを求める。**従わなくても進行側の検証は通る。** 人の集計を +成り立たせるための補助である。 + +### 無進捗の打ち切り + +起動が無進捗の許容を出し、骨組みの適用・修正・最終ゲートの修正の 3 つの監視の呼び出しが、それを +無進捗の引数へ渡す。全体の制限時間の引数は渡さない。工程ごとの上限は別の仕組みが決める。 + +雛形は、作業段階が進むたびに進捗の記録へ 1 行追記することを担当へ求める。 + +### 終了コード + +| コマンド | 0 | 1 | 2 | 4 | +| --- | --- | --- | --- | --- | +| 適用ラウンドを開く | 開いた | 残りの適用ラウンドが無い | — | — | +| 適用の取り込み | 取り込んだ | — | この適用ラウンドを取り消した、または担当を替えて開き直す | 着手前のテストが成功と確認できていない・範囲を確定できない・適用ラウンドが無い | +| 修正の取り込み | 取り込んだ | — | 範囲を確定できない・結果なし | 適用ラウンドが無い | +| 最終ゲートの修正の取り込み | 取り込んだ | — | 範囲を確定できない・結果なし | 修正担当が未記録 | + +**骨組みの分岐は変わらない。** 適用の取り込みの 2 で次の適用ラウンドへ進み、4 で止まる。修正と最終 +ゲートの修正の取り込みの終了コードは骨組みが見ない。 + +## データ・設定 + +### 状態ファイルに増える項目 + +**版は上げない。** 鍵が無い状態ファイルは、試行 0 回・失敗なしとして読む。 + +適用ラウンド 1 件(`rounds[].apply_rounds[]` の要素)に足す鍵である。 + +| 項目 | 型 | 値 | 鍵が無いときの意味 | +| --- | --- | --- | --- | +| `attempt` | 整数 | いま開いている試行の番号。1 から | 0(まだ開いていない)。適用の取り込みは 0 を 1 回目として記録する | +| `failed_attempts` | 配列 | 結果を残さなかった起動。1 件の形は下の表 | 失敗なし | +| `drop_reason` | 文字列 | `no_result`(試行の上限、または起動し直せない結末で交代先なし)/ `empty`(項目なし) | 既存の経路で取り消した、または取り消していない | + +最終ゲートの控え(`final_gate`)にも、同じ形の結末の記録(`failed_attempts`)を足す。 + +**担当(`impl` / `impl_model`)は交代のときに書き換える。** 前の担当は結末の記録に残る。 + +### 結末の記録の 1 件 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `phase` | 文字列 | 許さない | `apply` / `fix` / `final-fix`。同じ適用ラウンドの適用と修正の記録を分ける | +| `attempt` | 整数 | 許さない | 適用は適用ラウンドの試行の番号、修正は修正の試行の番号、最終ゲートの修正は修正ラウンドの数。叩き直しの判定の鍵 | +| `impl` | 文字列 | 許さない | 起動した担当 | +| `reason` | 文字列 | 許さない | 結末の理由。語彙は結末の共通層が持つ | +| `detail` | 文字列 | 許す(空文字) | 監視の詳細。監視の結果ファイルが無ければ空文字 | +| `at` | 文字列 | 許さない | 記録した時刻 | +| `reverted` | 整数 | 許さない | その起動の範囲から取り消したコミットの数。0 = コミットなし | + +### 取り込みごとの範囲の値 + +| 取り込み | 起点を持つ控え | 起点の鍵 | 記録先 | 工程 | 起点を揃える先 | +| --- | --- | --- | --- | --- | --- | +| 適用 | 提案ラウンドの控え | `apply_base_sha` | その適用ラウンド | `apply` | その適用ラウンドの `base_sha` | +| 修正 | 提案ラウンドの控え | `fix_base_sha` | その適用ラウンド | `fix` | なし | +| 最終ゲートの修正 | 最終ゲートの控え | `fix_base_sha` | 最終ゲートの控え | `final-fix` | なし | + +### 起動の出力と骨組みの引数 + +| 値 | 決め方 | 既定 | +| --- | --- | --- | +| 無進捗の許容(`IMPL_STALL_TIMEOUT`) | テストの制限時間(`--test-timeout`)+ 余白 900 秒 | 1800 秒 | +| 試行の上限(`MAX_APPLY_ATTEMPTS`) | 固定値。引数を持たない | 2 回 | + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 結末の読み取りが中断も出力もせず、結果なしと理由を値で返すこと | `plugins/ndf/skills/cross-refactoring/tests/test_intake.py` | +| 3 つの取り込みの形で、範囲が 1 件・0 件・確定できない場合の取り消しと記録が同じになること。叩き直しで記録が増えないこと | 同 `tests/test_intake.py` | +| 結果を残さない担当で、担当の交代 → 取り消し → 見送りまで進み、開き直しの回数が有限で終わること | 同 `tests/test_apply_attempts.py` | +| 交代先が無いとき、起動し直しの可否で 2 回目を開くか取り消すかが決まること | 同 `tests/test_apply_attempts.py` | +| 着手前のテストが成功と確認できていないとき、結果ファイルを読まずに終了コード 4 で終わること | 同 `tests/test_apply_attempts.py` | +| 開き直しの判定と輪番から担当を引く関数を差し替えると、開く側と取り込みの両方の分岐が従うこと | 同 `tests/test_apply_attempts.py` / `tests/test_final_fix.py` | +| 採用 0 件と項目の無い適用ラウンドで、適用ラウンドを作らず開かずに取り消すこと | 同 `tests/test_apply_rounds.py` | +| 修正の結果を適用ラウンドの担当から読み、結果なしで修正ラウンドが進んで見送りへ至ること | 同 `tests/test_abandon_items.py` | +| 最終ゲートの修正の結果なしで取り消し、次の最終ゲートが取り消し後の地点でテストすること | 同 `tests/test_final_fix.py` | +| 帰属の段落が後ろに付いたコミットから必須の記名を読み、散文の中の記名の形の行を読まないこと | 同 `tests/test_commit_trailers_git.py`(一時リポジトリで git を実行する) | +| 無進捗の許容が起動の出力に入り、骨組みの 3 つの監視の呼び出しへ渡ること | 同 `tests/test_init.py` / `tests/test_skill_terms.py` | +| 結果ファイルがあり検証を通る適用ラウンドが、変更の前と同じく 1 回目で取り込まれること | 同 `tests/test_merge_apply.py` | +| 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py` | +| 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py` | + +## 関連リンク + +- [issue #728](https://github.com/devbasex/ai-plugins/issues/728) — 結果なしを取り込みの 1 か所で受ける +- [issue #647](https://github.com/devbasex/ai-plugins/issues/647) — 同じ適用ラウンドが上限なしに再試行される +- [issue #592](https://github.com/devbasex/ai-plugins/issues/592) — 採用 0 件で項目の無い適用ラウンドが開く +- [issue #553](https://github.com/devbasex/ai-plugins/issues/553) — 帰属の段落で必須の記名が読めない +- [issue #674](https://github.com/devbasex/ai-plugins/issues/674) — 最終ゲートの修正で未検証のコミットが残る +- [PR #796](https://github.com/devbasex/ai-plugins/pull/796) — 実装 +- [起動 1 回の結末の語彙と起動し直しの可否](cross-review-launch-outcome.md) +- [参加する CLI と席の決め方](cross-review-participants-and-seats.md) +- [`cross-refactoring` の適用と検証の手順](../../plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md) +- [`cross-refactoring` の修正と報告の手順](../../plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md) +- [`cross-refactoring` の手順](../../plugins/ndf/skills/cross-refactoring/SKILL.md) diff --git a/docs/specifications/cross-refactoring-participants.md b/docs/specifications/cross-refactoring-participants.md new file mode 100644 index 00000000..bbe3ab26 --- /dev/null +++ b/docs/specifications/cross-refactoring-participants.md @@ -0,0 +1,255 @@ +# cross-refactoring: 参加する CLI が 1 者でも使えないと初期化が止まり、担当を外す手段が無く、適用の輪番が固定の 4 者で回る → 使える者だけで始まり、参加者は codex / kiro とホストを既定に足し引きでき、提案と適用が同じ参加者の中で回る + +## 目的 + +**参加する CLI のどれか 1 者が導入・認証されていなくても、構造改善の収束ループが始まる。** +使えない者とその理由は、初期化の出力と状態ファイルに残る。 + +**参加者は codex / kiro とホストが既定で、名指しで足し引きできる。** agy は足す者の指定で戻せる。 +ホストも外せる。 + +**提案と適用は同じ参加者で回る。** 適用の輪番は参加者の数のラウンドで 1 周し、ラウンド 1 は +参加者の 2 番目から始まる。既定の参加者では、ホストが claude / codex ならホストは最初に適用 +せず、ホストが agy / kiro ならラウンド 1 の適用担当はホストになる。担当の割り当てが返すのは +適用担当 1 者だけで、レビュー担当は無い。 + +**中断した収束ループを引数を変えて再開すると、その引数は反映されるか、反映しないと知らされる。** + +**使える者の決定・適用の輪番・再開の反映の規則は、共通層が 1 か所ずつ持つ。** cross-review と +同じ関数を使い、cross-refactoring が持つのは参加の母集合の既定と、再開の表と、結果を状態 +ファイルと終了コードへ写すことだけである。共通層の契約は +[使える者だけで始める収束ループ](cross-review-participants-and-seats.md)が持つ。 + +**手順と引数の表は +[`cross-refactoring` の SKILL.md](../../plugins/ndf/skills/cross-refactoring/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、cross-refactoring 側の +契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 +共通層の語(使える者・認証の確認・使える者の解決・再開の反映など)は +[共通層の確定仕様の用語](cross-review-participants-and-seats.md#用語)と同じ意味で使う。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 既定の参加者の表 | `DEFAULT_REFACTOR_RUNTIMES` | ホストを除いた既定の参加者(codex / kiro) | +| 参加の母集合の既定 | `refactor_pool(host)` | 既定の参加者の表とホストの和。ランタイムの固定の順 | +| 参加者の一覧 | `runtimes` | 状態ファイルの項目。参加者の記録の使える者と同じ値。提案の対象・作業ツリーの準備・適用の輪番が読む | +| 参加者の記録 | `participants` | 使える者の解決の結果。cross-review と同じ形で、埋め合わせの項目を持たない | +| 適用の輪番 | `impl_assign` | 参加者の一覧から適用担当 1 者を返す共通層の関数 | +| 輪番の包み | `impl_for_seq` | 輪番の通し番号から担当と要求するモデルを引く、cross-refactoring の唯一の入口 | +| 反映の表 | `RESUME_REPLACE_FIELDS` / `RESUME_NOTIFY_FIELDS` | 再開で渡した引数ごとに「反映する」か「知らせる」かを決める表 | +| 足す者 / 外す者 / 全員を要する指定 | `--include` / `--exclude` / `--require-all` | 参加者を名指しで変える引数と、確認の失敗で止める引数 | +| 初期化 / ラウンドの開始 / 完了報告 | `init` / `start-round` / `report` | `refactor.py` の副コマンド | + +## 対象範囲 + +| 扱う | 扱わない | +| --- | --- | +| cross-refactoring の参加者の決め方、適用の輪番、再開の反映、完了報告の参加者の節 | 共通層の関数の契約([共通層の確定仕様](cross-review-participants-and-seats.md)が持つ) | +| 呼び手が無くなった共通層の旧関数 4 つを消したこと | 起動した後に分かる使えなさで担当を自動的に外す仕組み(作らない) | +| リポジトリの根の指示書の要約(参加者と輪番と上限の既定) | 指示書の cross-refactoring の節のうち、参加者と輪番と上限以外の行(#799) | + +## 背景 + +**認証の確認が関門だった。** 従来の確認は 1 件の失敗でその場で終了した。参加者の 1 者が +入っていないだけで、構造改善の収束ループを開始できなかった。 + +**担当から特定の CLI を外す引数が無かった。** 参加者は「全ランタイム − ホスト」の固定の 3 者で、 +起動の失敗が多い者や利用上限に近い者を避けられなかった。 + +**存在しない役の記録が残っていた。** 担当の割り当ては適用担当とレビュー担当の 2 つを返していた。 +構造改善のレビュー工程は既に無く、レビュー担当はラウンドの記録と出力に書かれるだけだった。 +使える者が 2 者のときに「レビュー担当が 1 者になる」と見えたのは、この記録である。 + +**適用の輪番は、提案と別の 4 者の固定の母集合で回っていた。** 提案する者と適用する者の集合が +一致しないため、適用専用の母集合を状態ファイルと初期化の出力が持っていた。 + +**再開で渡した引数を 1 つも反映していなかった。** 上限を変えて再開しても、状態ファイルの値が +そのまま使われた。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 参加の母集合の既定を codex / kiro とホストにする | 起動 199 回のうち失敗は agy の 7 回だけで、提案の所要の中央値も agy が最も長かった(5 分。codex 3 分、kiro 2 分)。提案は最も遅い者を待つため、所要はほぼ agy で決まっていた | +| 既定はホストを除いた部分だけを表に持ち、ホストを関数で足す | ホストが変わるたびに一覧を書き直さずに済む | +| ホストも外す者の指定で外せる | cross-refactoring ではホストが参加の母集合に入る。cross-review ではホストが母集合に無いため弾かれる | +| 提案と適用を同じ参加者で回し、適用専用の母集合を消す | 既定にホストを含めると、提案と適用の集合が同じになる。別に持つ理由が無くなる | +| レビュー担当を消し、割り当ては適用担当 1 者だけを返す | 記録だけを残すと、読み手が「このラウンドはこの 2 者がレビューした」と読む。役を消せば、席を埋める規則を持ち込む必要も無い | +| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の位置を採る | ラウンド 1 が 2 番目の者から始まる。ホスト claude の既定(claude / codex / kiro)でも codex → kiro → claude の順になり、ホストが最初に適用しない。ホストが agy / kiro のときは、参加者がランタイムの固定の順に並ぶためホストが 2 番目に来て、ラウンド 1 の適用担当はホストになる(手順書との食い違いは #804 で扱う) | +| 提案者と適用者が同じランタイムになることを避けない | 適用ラウンドは複数の提案者の項目を 1 つの群にまとめる。群ごとに提案者を避けると、群の分け方そのものが変わる。適用の結果は検証と、後の工程の収束レビューが見る | +| 確認は着手前のテストより先に行う | 使える者がいなければ、テストに時間を使わずに止める | +| 認証の確認の結果の項目を状態ファイルに書かない | 読み手が無い。通らなかった者と理由は参加者の記録が持つ | +| 引数の型(カンマ区切りの名前と予約語 `none`)は cross-refactoring の初期化の部品が持つ | 共通層へ移すと cross-review の状態の部品も触ることになる | +| 完了報告の参加者の節は cross-review と同じ行の形にし、埋め合わせの行を持たない | cross-refactoring に席は無い | +| モデルの指定の警告は参加者だけを対象にする | 既定で外れる agy へ警告を出すと、使わない者の指定まで読み手に見せる | +| 呼び手の無くなった共通層の旧関数 4 つを消し、残らないことをテストで固定する | 片方の Skill にだけ古い形が残ると、使える者を決める規則が 2 か所になる | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 参加者の一覧は、参加者の記録の使える者と同じ値である | 初期化と、参加者を作り直す再開が、同じ値を両方へ書く | +| 新規の状態ファイルに、適用専用の母集合とラウンドのレビュー担当が無い | 初期化とラウンドの開始が書かない。出力にも出さない | +| 使える者が 0 者、または全員を要する指定で欠けがあるとき、状態ファイルを作らない・書き換えない | 使える者の解決を書き込みの前に呼び、失敗は終了コード 4 で止める | +| この変更の前に始めた実行の状態ファイルを、書き換えずに読める | 適用の輪番は参加者の一覧から決まる。参加者の記録が無ければ完了報告は「記録なし」と出す | +| 共通層と両 Skill の部品に、旧関数 4 つの名前が残らない | `git grep -w` が 1 件でも当たればテストが落ちる | + +### 構成要素と責務 + +| 要素 | 責務 | 置き場所 | +| --- | --- | --- | +| 参加の母集合の既定 | 既定の参加者の表とホストを固定の順で返す | `plugins/ndf/scripts/lib/assignment.py` の `refactor_pool` | +| 適用の輪番 | 参加者の一覧から適用担当 1 者を返す | 同 `impl_assign` | +| 参加者の決定 | 共通層の使える者の解決を止めない確認で呼び、出力し、失敗を終了コード 4 へ写す | `cross-refactoring/scripts/refactor_lib/commands/setup.py` の `resolve_participants` | +| 初期化と再開 | 新規なら既定を置いて参加者を決める。再開なら反映の表を渡し、担当に関わる引数があれば参加者を作り直す | 同 `cmd_init` / `_resume` | +| 輪番の包み | 輪番を引く唯一の入口。ラウンドの開始・群の割り当て・結果なしの試行の交代先・最終ゲートの修正担当の 4 か所が読む | `refactor_lib/rounds.py` の `impl_for_seq` | +| 完了報告の参加者の節 | 母集合・使える者・外した者・足した者・確認を通らなかった者・再開で変えた値を出す | `refactor_lib/commands/report.py` の `_print_participants` | + +### 参加者の決め方 + +参加の母集合の既定に足す者を加え、外す者を除き、認証の確認を通った者を参加者の一覧にする。 +手順と例外は共通層の使える者の解決が持つ。 + +| ホスト | 既定の参加者 | +| --- | --- | +| claude | claude / codex / kiro | +| codex | codex / kiro | +| agy | codex / agy / kiro | +| kiro | codex / kiro | + +**外した者へは確認コマンドを呼ばない。** 既定で外れる agy は、足さない限り確認されない。 + +| 場面 | 標準エラー | 終了コード | 状態ファイル | +| --- | --- | ---: | --- | +| 全員が使える | ホスト・母集合・使える者の 1 行 | 0 | 作る | +| 確認を通らない者がいる | `⚠ <名前> を担当から外しました(<理由>)` を 1 者 1 行 | 0 | 作る | +| 使える者が 0 者 | 全員の理由を並べた 1 行 | 4 | 作らない | +| 全員を要する指定で欠けがある | 欠けた者と理由 | 4 | 作らない | +| 名前の矛盾(外す者が母集合に無い・足す者と外す者が重なる・`none` と名前の混在) | 何が矛盾したか | 4 | 作らない | + +綴りの誤りは引数の型が弾く(終了コード 2)。 + +### 適用の輪番 + +適用担当は `参加者の一覧[通し番号 % 参加者の数]` である。通し番号は適用ラウンドを開くたびに +進むため、1 つの提案ラウンドが複数の群を持てば、その分だけ輪番も進む。 + +| 通し番号 | 1 | 2 | 3 | 4 | 5 | 6 | +| --- | --- | --- | --- | --- | --- | --- | +| 参加者が claude / codex / kiro のときの担当 | codex | kiro | claude | codex | kiro | claude | + +参加者はランタイムの固定の順(claude / codex / agy / kiro)に並ぶ。そのため、既定の参加者で +ラウンド 1 の適用担当がホストになるかどうかはホストで決まる。 + +| ホスト | 既定の参加者 | ラウンド 1 の適用担当 | +| --- | --- | --- | +| claude | claude / codex / kiro | codex | +| codex | codex / kiro | kiro | +| agy | codex / agy / kiro | agy(ホスト) | +| kiro | codex / kiro | kiro(ホスト) | + +**結果なしの試行の交代先も同じ輪番から選ぶ。** 通し番号を 1 つずつ進め、その群で失敗した担当の +どれとも違う者が出た最初の番号を採る。参加者の数だけ進めても出なければ、替える先は無い。 + +**提案ラウンドの上限は、輪番の 1 周と対応しない。** 上限が切るのは提案の回数だけである。 + +### 再開で渡した引数の扱い + +| 扱い | 引数 | 何が起きるか | +| --- | --- | --- | +| 反映する | `--max-outer-rounds` / `--max-test-rounds` / `--max-fix-rounds` / `--max-items-per-round` / テストの制限時間 | 状態ファイルを書き換え、再開で変えた値の記録へ 1 件積み、`↻ <項目>: <旧> → <新>` を出す | +| 参加者を作り直す | `--exclude` / `--include` / `--require-all` | 認証の確認をやり直し、参加者の記録と参加者の一覧を置き換え、記録へ 1 件として積む。`none` で一覧を空へ戻す | +| 知らせる | ホスト・範囲・モデル・着手前のテスト・継続的統合の検査の名前・重要度の閾値・同期のコマンド・改修計画のファイル・起動のされ方・作業ディレクトリの根 | 状態ファイルは変えず、状態と違うときだけ「反映しない」を 1 行出す | + +**値が同じ引数は、行も記録も出さない。** 比べる前に形を揃える。着手前のテストはコマンドで、 +モデルは全ランタイムの辞書で、作業ディレクトリの根は解決したパスで比べる。 + +**作り直しの入力は、渡した引数と、渡さなかった引数の記録の値である。** 足す者の指定で agy を +足して始めた実行へ外す者の指定だけを渡すと、足した agy は残る。 + +**作り直しの失敗は、状態ファイルを書き換える前に起きる。** 終了コード 4 で止まる。 + +### 完了報告の「参加した者」 + +```text +## 参加した者 + +- 母集合: claude / codex / kiro +- 使える者: claude / codex +- --exclude で外した者: なし +- --include で足した者: なし +- 確認を通らなかった者: kiro(<理由>) +- 再開で変えた値: なし +``` + +参加者の記録を持たない状態ファイルでは「使える者: 記録なし」の 1 行だけを出す。確認を飛ばした +ときは、通らなかった者の行に「確認を飛ばした(NDF_SKIP_AUTH_CHECK)」と出す。 + +ラウンド表と改修計画の見出しには、レビュー担当・そのモデル・初回承認の列が無い。 + +## データ・設定 + +### 状態ファイル + +| 項目 | 新規の状態ファイル | この変更の前の状態ファイル | +| --- | --- | --- | +| `runtimes` | 使える者と同じ値 | そのまま読む。適用の輪番もこの値を使う | +| `participants` | 共通層が返す 7 項目(埋め合わせの項目は無い) | 無い。完了報告は「記録なし」 | +| `resume_changes` | 空の配列から始め、再開で変えた値を追記する | 無ければ空として読む | +| `impl_capable` | 書かない | 残っていても読まない | +| `rounds[].reviewers` / `rounds[].reviewer_models` | 書かない | 残っていても表示しない。指標の集計は読む経路を残す | + +### 初期化の引数と出力 + +| 引数 | 既定 | 再開の経路 | +| --- | --- | --- | +| `--exclude NAMES` / `--include NAMES` | 未指定(カンマ区切り・繰り返し可。`none` で空) | 渡せば参加者を作り直す | +| `--require-all` / `--no-require-all` | 未指定(新規は偽) | 渡せば参加者を作り直す | +| 上限 4 つとテストの制限時間 | 未指定(新規の経路が現行の既定を置く。提案ラウンドの上限は 3) | 渡せば反映 | + +**上限の既定を引数の側に置かない。** 引数の側に既定を置くと、再開で「渡さなかった」と「既定値を +渡した」を区別できない。 + +初期化の出力は参加者の一覧(`RUNTIMES` / `RUNTIMES_CSV`)を持ち、適用専用の母集合の変数を +持たない。ラウンドの開始の出力は、レビュー担当の変数を持たない。 + +### 消した共通層の関数 + +| 関数 | 置き換え先 | +| --- | --- | +| `check_auth`(1 件の失敗で止める確認) | `probe_auth` と使える者の解決の全員を要する指定 | +| `impl_pool`(適用専用の母集合) | 参加者の一覧 | +| `review_assign`(従来の席の割り当て) | `review_seats` | +| `assign`(従来の適用とレビューの割り当て) | `impl_assign` | + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| ホストごとの既定の参加者、足す者・外す者での増減、ホストを外せること | `plugins/ndf/skills/cross-refactoring/tests/test_init.py` | +| 確認を通らない者を外して続け、全員を要する指定と 0 者では状態ファイルを作らずに終了コード 4 | 同 | +| 再開が上限を反映し、他の引数を知らせ、担当に関わる引数でだけ参加者を作り直し、渡さなかった値を記録で補うこと | 同 | +| 適用の輪番が参加者の数で 1 周し、ラウンド 1 が参加者の 2 番目から始まること | `plugins/ndf/scripts/tests/test_lib_assignment.py` / `cross-refactoring/tests/test_assignment.py` | +| ラウンドの開始がレビュー担当を出さず、記録にも書かないこと | `cross-refactoring/tests/test_start_round_emits_runtimes.py` | +| 完了報告と改修計画にレビュー担当の列が無く、母集合を 1 行で出すこと | `cross-refactoring/tests/test_rounds.py` / `test_plan_comment.py` | +| この変更の前の状態ファイルをラウンドの開始と完了報告が読めること | `cross-refactoring/tests/test_rounds.py` / `test_start_round_emits_runtimes.py` | +| 結果なしの試行の交代先が参加者の数の範囲で選ばれること | `cross-refactoring/tests/test_apply_attempts.py` | +| 手順書と指示書の語が新しい参加者と輪番を書くこと | `cross-refactoring/tests/test_skill_terms.py` | +| 旧関数 4 つが共通層と両 Skill の部品に残らないこと | `scripts/tests/test_shared_lib_layout.py` | + +## 関連リンク + +- [issue #664](https://github.com/devbasex/ai-plugins/issues/664) — 担当を外す引数が無く、使える者が 2 者だとレビュー担当が 1 者になる +- [issue #736](https://github.com/devbasex/ai-plugins/issues/736) — 指示書の提案ラウンドの上限の既定 +- [issue #727](https://github.com/devbasex/ai-plugins/issues/727) — 使える者から担当を割り当てる共通層 +- [PR #800](https://github.com/devbasex/ai-plugins/pull/800) — 実装 +- [使える者だけで始める収束ループ](cross-review-participants-and-seats.md) — 共通層の契約と cross-review 側 +- [結果なしの取り込み](cross-refactoring-apply-intake.md) — 適用ラウンドの開き直しと試行の上限 +- [`cross-refactoring` の手順](../../plugins/ndf/skills/cross-refactoring/SKILL.md) +- [`cross-refactoring` の状態と提案の手順](../../plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md) diff --git a/docs/specifications/cross-review-evidence-based.md b/docs/specifications/cross-review-evidence-based.md index 6cde1da2..51c0c360 100644 --- a/docs/specifications/cross-review-evidence-based.md +++ b/docs/specifications/cross-review-evidence-based.md @@ -17,8 +17,9 @@ **進行側は、担当の再評価より先に検証手順を実行する。** 機械が再現した事実は、担当の支持 より確かである。 -**指摘は 5 つの区分へ分かれ、収束の判定は担当の判定(`event`)ではなく区分を見る。** -数えるのは `verified_blocking` と `needs_human_judgment` の 2 つだけである。 +**指摘は 6 つの区分へ分かれ、収束の判定は担当の判定(`event`)ではなく区分を見る。** +数えるのは `verified_blocking` と `needs_human_judgment` と `unrefuted` の 3 つである。数えない +のは、誤りだと示された棄却と、承認を妨げない `minor` 以下の指摘だけである。 **却下した指摘は、位置・重要度・理由とともにラウンドをまたいで残る。** 次のラウンドの レビュープロンプトへ渡り、同じ論点が戻ることを止める。 @@ -37,7 +38,7 @@ | 反証条件 | 何が成り立てば棄却できるか(`falsification`) | | 検証手順 | 実行できる形で書いた確かめ方(`suggested_check`) | | 反証 | 提案者以外の担当が、各指摘へ返す 1 つの値 | -| 区分 | 1 件の指摘を分ける 5 つの分類(`classification`) | +| 区分 | 1 件の指摘を分ける 6 つの分類(`classification`) | | 方式 | 効果の測定で指摘を採る規則。`single` / `majority` / `proposed` / `oracle` の 4 つ | | 代表 | 統合した組で、判定が読む 1 件。束ねられた側は `merged_into` を持つ | | 印 | そのラウンドが統合・実行検証・反証を通ったこと(`evidence_rounds`) | @@ -61,6 +62,13 @@ 返ったインラインは総評へ移る。`payload.json` が投稿したインラインの写しであった頃は、 この 2 つの経路を通った指摘が記録へ届かなかった(PR #157 の round 4)。 +**誤りを示されていない重大な指摘が数えられず、承認で収束した。** 担当を 1 者に絞って回した +4 ラウンドでは、どのラウンドの指摘も修正を要する妥当なものだったが、いずれも収束と判定された +(#624)。反証を返す相手がいないため、数える区分に入る指摘が 0 件になる。別のリポジトリの +Pull Request では、検証手順を実行できなかった 3 件が立証不足へ落ち、そのうち 2 件は別の担当が +支持を付けていた(#706)。原因は、立証不足の区分が「反証の機会があって支持されなかった」と +「立証の機会が無かった」の 2 つの意味を兼ねていたことにある。 + ## 決定と理由 | 決定 | 理由 | @@ -82,9 +90,9 @@ | 反証は新しいラウンドを足さず、同じラウンドの中で回す | ラウンド数が 2 倍になり、収束の上限(12)の意味が変わる | | 申告による統合(2 段目)は次のラウンドへ回さない | 回すと、同じ主張を 2 者が別の本文で出した組が、統合される前に `insufficient_evidence` へ落ちて収束する | | 反証の値は担当ごとに置き換え、積み増さない | 取り直したときに古い値が残る。`refute` を `support` へ訂正しても両方が並び、区分の順で `refute` が先に当たって指摘が `rejected` のままになる | -| 収束の判定が数えるのは 2 区分だけである | 棄却した指摘と `minor` の指摘を数えると、そのぶんラウンドが増える(#69 の 5 ラウンド) | +| 数えないのは棄却した指摘と `minor` の指摘だけである(誤りを示されていない `major` は `unrefuted` として数える) | 棄却した指摘と `minor` の指摘を数えると、そのぶんラウンドが増える(#69 の 5 ラウンド)。誤りを示されていない `major` を数えないと、未解決の `major` を残して承認で収束する(#624 #706) | | 測れたかどうかは、区分で絞る**前**に決める | 絞った後の集合へ「空なら測れない」を適用すると、全件を棄却したラウンドが「測れなかった」ことになり、元の判定のままループが終わらない | -| 印(`evidence_rounds`)で母集合を決め、`review_findings` の有無では判定しない | 取り込みはこの変更より前から要素を積む。存在で判定すると、区分も検証結果も持たない旧いラウンドが絞り込みに掛かり、修正必須の `major` が落ちて収束する | +| 印(`evidence_rounds`)で母集合を決め、`review_findings` の有無では判定しない | 印の役割は、取り込みだけを済ませた旧いラウンドと反証が届いていないラウンドを、棄却と `minor` も含めて全件を数える側に置くことである。存在で判定すると、区分も検証結果も持たない旧いラウンドが絞り込みに掛かる。反証が揃わないときは印を付けず、先に付いていた印も外す | | 印を付けるのは経路の最後で、対象ごとに有効な反証が揃ったときだけである | 途中で付けると反証を結ぶ前の値で数える。結果ファイルの欠落でも付けると、未検証のまま収束する | | `needs_human_judgment` を人へのエスカレーションにしない | 収束のループはこの工程の中で回っており、止めて人を待つと自動で進まなくなる。決めるのは修正の担当で、その判断は却下の記録へ残る | | 振動の検知(一致の判定・閾値 0.5)は変えない | 母集合は指摘の構造化で既に広がっている。判定式まで同時に変えると、ラウンド数が動いたときにどちらが原因かを切り分けられない | @@ -93,6 +101,7 @@ | 1 回の実行が測るのは 1 つの状態ファイルである | 集計の単位は測る目的で変わる(変更の前後・担当ごと・リポジトリごと)。0 か 1 の値で出しておけば、どの単位でも測る側が足し合わせられる | | 測る単位はローテーション全体である | ローテーションは同じ変更に対するレビューの続きで、終わり方も上限の判定も状態ファイル全体で 1 つである。`current_pr` の分だけを測ると、それ以前のラウンドがまるごと落ちる | | 精度(誤った指摘の割合)は測らない | 修正されなかった指摘が誤りだったかは記録から決まらない。範囲外として却下したもの、判断が割れて `deferred` にしたもの、単に対応しなかったものが混ざる。**測れない値を数字にすると、比較の根拠として使われる** | +| 立証不足の区分の名前は変えず、軽微な指摘の残余だけに当てる | 改名すると、旧い状態ファイルの値・測定の読み方・規約と確定仕様の語彙の 3 つを同時に付け替えることになり、変更の前後を測定で比べられなくなる | | 1 者だけの方式は担当ごとに出す | 1 つの数字にまとめると、誰を選ぶかが結果に混ざる | | 状態ファイルを残す仕組みは作らない | 状態ファイルには Pull Request の中身が入り、置き場所と保持の期間を決める必要がある。測定のためだけに決めるには重い。測る側が測る前に控える | @@ -106,7 +115,7 @@ - **束ねられた側は消さない。** `merged_into` を書いて残す。消すと、反証の結果がその指摘を 指したときに結び先を失う。判定・区分・測定はいずれも代表だけを数える - **実行できなかったこと(`not_run`)と、再現しなかったこと(`not_reproduced`)を同じに - しない。** 前者は区分の順 3 の前半に当たらず、区分は反証と根拠で決まる + しない。** 前者は区分の順 3 の前半に当たらず、区分は重大度と反証で決まる - **`ran_at` は実行した記録にだけ入る。** 実行しなかった記録では `exit_code` とともに `null` である。時刻が残ると、実行済みと見分けられない - **1 つの(ラウンド, 指摘, 担当)が持つ反証は 1 つである** @@ -122,11 +131,12 @@ ### 指摘の構造化と独立発見 担当が書き出す `payload.json` の `comments[]` は、**その担当が出した指摘の全件**である。 -投稿したインラインの写しではない。総評だけへ書いた指摘も載り、`path` と `line` はそのときも -埋める。投稿先は `posted_to`(`inline` / `body`)が持つ。 +投稿したインラインの写しではない。位置を持たない指摘も載る。送れた先(`posted_to` の +`inline` / `body`)は、投稿する側が控えへ書き戻す。 -**インラインの件数(`result.json` の `comments_count`)は変えない。** 進行側が GitHub 側の -実数と突き合わせる値であり、指摘の全件を入れると照合が常に食い違う。 +**記録のインラインの件数(`comments`)は、指摘の件数と一致しない。** 送れたインラインの数で +あり、総評へ入った指摘はそこに現れない。投稿の担い手と件数の取り方は +[書き込みを回す側へ集める仕様](cross-review-writes-to-conductor.md)にある。 **発見を終えるまで、参照してよい既存コメントは起動時のスナップショットに限る。** 同じ ラウンドの他の担当の投稿・結果ファイル・進捗ログは参照しない。スナップショットは前の @@ -189,7 +199,7 @@ ### 実行検証 **実行してよいのは、起動した側が渡したコマンドだけである。** 渡されなければ実行検証を -行わず、区分は根拠と反証で決まる。**新しい実行系は導入しない。** +行わず、区分は重大度と反証で決まる。**新しい実行系は導入しない。** | 守ること | なぜ | | --- | --- | @@ -231,24 +241,29 @@ **上から順に見て、最初に当たった区分を採る。** -| 順 | 区分 | 条件 | -| --- | --- | --- | -| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | -| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | -| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | -| 4 | `needs_human_judgment` | 根拠を持ち、`major` 以上で、`support` が 1 件以上**または** `origin_runtimes` が 2 者以上 | -| 5 | `insufficient_evidence` | 上のいずれにも当たらない | +| 順 | 区分 | 条件 | 数える | 理由の項目 | +| --- | --- | --- | --- | --- | +| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | +| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | +| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | +| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上**または** `origin_runtimes` が 2 者以上 | はい | — | +| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason`(`no_critique` / `not_supported`) | +| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | **順 3 を順 1・2 より先に置かない。** 置くと、機械が再現した事実を担当の再評価が覆す。 **独立に到達した担当の数を、支持と並べて数える。** 担当は 2 者であるため、2 者が同じ指摘を 独立に出すと提案者以外が 1 人も残らず、支持は必ず 0 件になる。支持の数だけを見ると、最も -強い一致である全員一致が `insufficient_evidence` へ落ちて収束する。 +強い一致である全員一致が、誰も確かめていない指摘と同じ `unrefuted` になり、独立に到達した +事実が記録に残らない。 **順 4 の分岐は、ラウンドの担当が 2 者であることに支えられている。** 担当を 3 者以上へ 広げると、単一の `refute` が順 3 で全員一致を覆す。広げるときに順 3 と順 4 の順序を決め直す。 -**棄却には理由を残す**(実行の結果か、`refute` の理由)。 +**棄却と未反証には理由を残す。** 棄却は実行の結果か `refute` の理由、未反証はなぜ独立に +確かめられていないか(反証を返した担当が 0 者なら `no_critique`、反証はあるが支持も否定も +無ければ `not_supported`)である。「立証できない」「範囲外」は誤りだという主張ではないため、 +その反証を受けた `major` は数から落とさない。 ### 区分ごとの行き先 @@ -256,6 +271,7 @@ | --- | --- | --- | | `verified_blocking` | 渡す | 直す。機械が再現しているため判断の余地は無い | | `needs_human_judgment` | 渡す | **修正の担当が読んで決める。** 直す・却下の理由を返す・範囲外として起票する | +| `unrefuted` | 渡す | 同上。理由(`unrefuted_reason`)から、誰も見ていないのか相手が確かめられなかったのかを読める | | `verified_non_blocking` | 渡さない | 最終スイープ。再現した事実は記録に残る | | `insufficient_evidence` | 渡さない | 同上 | | `rejected` | 渡さない | 棄却の理由が残り、次のラウンドのレビュープロンプトへ渡る | @@ -270,8 +286,8 @@ | 状態 | 返る値 | | --- | --- | | 指摘の記録を読めない | `(0, 測れない)`。従来の判定(全員が pass か)へ落ちる | -| 記録はあるが、数える 2 区分が 0 件 | **`(0, 測れた)`。収束させる** | -| 記録があり、数える 2 区分に一致しない指摘がある | `(件数, 測れた)` | +| 記録はあるが、数える 3 区分が 0 件 | **`(0, 測れた)`。収束させる** | +| 記録があり、数える 3 区分に一致しない指摘がある | `(件数, 測れた)` | **記録が読めることと、数える対象があることは別である。** 全件を棄却したラウンドは「新しい 修正必須の指摘が 0 件」であって、測れなかったラウンドではない。 @@ -279,6 +295,11 @@ **新規性の層は却下の記録を読まない。** 一致の判定を持つのは 1 か所であり、区分はその判定が 数える母集合を絞るだけである。同じ抑止を 2 つの仕組みが持たない。 +**担当が 1 者のラウンドと、起動し直した担当の指摘は、未反証として数える。** 担当が 1 者なら +反証を返す相手がいない。起動し直した担当の指摘は、反証を取り込んだ後に取り込まれるため反証を +持たない。どちらも誰も誤りを示していない重大な指摘であり、数えないと未解決のまま承認で収束 +する。1 者で回したラウンドの収束は、この新規性と、全員が通したかの 2 つで決まる。 + ### 効果の測定 **同じ記録を、方式ごとに違う規則で読む。** 母集合は代表だけで、4 つとも `merged_into` を @@ -288,7 +309,7 @@ | --- | --- | | `single` | 1 者だけの結果。担当ごとに 1 通り出す | | `majority` | `origin_runtimes` が 2 者以上の指摘 | -| `proposed` | 区分が `verified_blocking` または `needs_human_judgment` の指摘 | +| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘。**数える区分の集合を持つのは共有モジュール 1 か所(`plugins/ndf/skills/cross-review/scripts/classifications.py`)で、収束の判定と効果の測定の両方がそれを読む** | | `oracle` | いずれかの担当が出した指摘のうち、**修正された**もの | **`origin_runtimes` を持たない指摘は、取り込み時の担当 1 者として読む。** この値は統合の @@ -347,7 +368,7 @@ | `origin_runtimes` / `merged_from` / `merged_into` / `evidence_from` / `duplicate_candidates` | 統合 | | `verification` | 実行検証。`command` / `exit_code` / `result` / `finding_id` / `ran_at` | | `critiques` | 反証。要素は `agent` / `verdict` / `reason`(`duplicate` のときは `duplicate_of`) | -| `classification` / `rejection_reason` | 区分 | +| `classification` / `rejection_reason` / `unrefuted_reason` | 区分。理由の 2 つはその区分のときだけ持つ | **識別子は取り込みの時点で採番する。** 形は `<担当>-r<ラウンド>-<索引>` で、索引はその担当の `payload.json` の並びである。同じ組の取り込みは入れ替えであるため、再実行しても同じ指摘へ @@ -413,10 +434,10 @@ measure.py <状態ファイルのパス> [--output <パス>] | 提案者以外だけが賛否を返し、5 つの値が記録されること | 同 `tests/test_critiques.py` | | 結び先の無い反証が残ること | 同上 | | 反証が揃わないラウンドに印が付かず、1 度だけ取り直すこと | 同上 | -| 5 つの区分へ分かれ、棄却に理由が残ること | 同 `tests/test_classify_findings.py` | +| 6 つの区分へ分かれ、棄却と未反証に理由が残ること | 同 `tests/test_classify_findings.py` | | 再現した指摘が反証があっても棄却されず、再現しない指摘が支持が多くても棄却されること | 同上 | | 全員一致の指摘が収束しないこと | 同上 | -| 新規性が 2 区分だけを数え、全件を棄却したラウンドが収束すること | 同上 | +| 新規性が 3 区分を数え、全件を棄却したラウンドが収束すること | 同上 | | 走らせる順序が手順と実装で揃っていること | 同 `tests/test_findings_pipeline_wiring.py` | | 4 つの方式が同じ記録から出て、統合された指摘を 2 回数えないこと | 同 `tests/test_measure.py` | | `origin_runtimes` を持たない記録でも担当別の件数が残ること | 同上 | @@ -431,7 +452,7 @@ measure.py <状態ファイルのパス> [--output <パス>] ## 運用 **実行検証を使うかは、起動する側が決める。** 引数を渡さないリポジトリでは実行検証が走らず、 -区分は根拠と反証で決まる。既定の一覧は持たない(リポジトリによってテストの起動が違う)。 +区分は重大度と反証で決まる。既定の一覧は持たない(リポジトリによってテストの起動が違う)。 **印を持たない記録では `proposed` を計算できず、位置の記録を持たない記録では `oracle` と 再現率を計算できない。** この変更より前に回した Pull Request が該当する。変更の前後を @@ -458,6 +479,8 @@ measure.py <状態ファイルのパス> [--output <パス>] - [PR #549](https://github.com/devbasex/ai-plugins/pull/549) — 同・実装 - [PR #557](https://github.com/devbasex/ai-plugins/pull/557) — 効果の測定の設計 - [PR #558](https://github.com/devbasex/ai-plugins/pull/558) — 同・実装 +- [PR #783](https://github.com/devbasex/ai-plugins/pull/783) — 数えない指摘を棄却と軽微な指摘に限る設計 +- [PR #790](https://github.com/devbasex/ai-plugins/pull/790) — 同・実装 - [根拠と反証条件の規約](../../plugins/ndf/skills/cross-review/docs/06-evidence.md) - [状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) - [レビュワーの母集合と終了基準](../../plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md) diff --git a/docs/specifications/cross-review-launch-outcome.md b/docs/specifications/cross-review-launch-outcome.md new file mode 100644 index 00000000..d6339487 --- /dev/null +++ b/docs/specifications/cross-review-launch-outcome.md @@ -0,0 +1,255 @@ +# cross-review: 利用上限で止まった担当が結果なしとだけ報告され、空振りの起動し直しで待たされる → 上限を理由として報告し、同じラウンドで起動し直さない + +## 目的 + +**担当の CLI が利用上限で止まったことが、理由「利用上限」として進行側と利用者に届く。** +同じラウンドで同じ担当を起動し直さず、その場で誤りの終わりへ進む。 + +**結果ファイルが無いときの終わり方を、1 つの語彙で区別できる。** 監視が打ち切った・CLI が +自分の上限で終わった・終わったが結果を書かなかったの 3 つが、別々の理由として残る。 + +**結果なしの判断と起動し直しの可否を持つのは 1 か所だけである。** 収束ループを回す 2 つの +Skill(cross-review / cross-refactoring)はその値を読むだけで、同じ判断を別々に書かない。 + +**監視が止めた担当の子プロセスは、止めた後に結果ファイルを書かない。** + +**手順と表は +[`cross-review` の SKILL.md](../../plugins/ndf/skills/cross-review/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-review/docs/01-state-and-review.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、共通層の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表とコードブロックにだけ置く。 + +| 業務用語 | 識別子 | +| --- | --- | +| 担当 | レビュー・反証・適用・修正を行う CLI(codex / agy / kiro / claude) | +| 起動 1 回 | 起動の手順が担当を 1 度起動し、監視がそれを見終わるまで | +| 結末 | 起動 1 回の終わり方。監視の状態と理由、結果ファイルの有無と読めるかを合わせたもの(`LaunchOutcome`) | +| 理由 | 結末の語彙の 1 語(`REASONS`) | +| 起動し直しの可否 | 同じ担当を同じ条件で起動し直せば解ける結末か(`relaunch_same_agent`) | +| 使える結果 | 結果ファイルがあり、JSON オブジェクトとして読める(`payload`) | +| 結末の共通層 | `plugins/ndf/scripts/lib/monitor_outcome.py` | +| 監視 | `plugins/ndf/scripts/lib/monitor.py` | +| 起動の手順 | `plugins/ndf/scripts/lib/launch-cli.sh` | +| 結末を読む関数 | `read_launch_outcome(tmp_dir, stem, result_path)` | +| 起動し直せない理由の集合 | `NO_RELAUNCH_REASONS` | +| 状態からの既定の理由 | `reason_for(status)` | +| 結果ファイル | `-result.json` | +| 監視の結果ファイル | `-monitor.json` | +| 監視の記録 | `monitor-outcomes.jsonl`。追記だけで積む | +| 結果の取り込み / 判定 / 報告の表 | `state.py` の `read-result` / `judge` / `report` | +| 誤りの終わり | 状態ファイルの `final=error` と、判定の終了コード 1 | + +## 背景 + +**利用上限の文言が照合の表に無く、結果なしの理由が「結果ファイル無し」に畳まれていた。** +そのため進行側は同じ担当を同じラウンドで起動し直した。監視の上限 1 回分(レビューでは +1200 秒)を待ってから、全体を誤りで終えていた(#619)。別の記録では、空振りの起動し直しが +3729 回に達した(#647)。届く理由が 1 つしかないため、上限に当たったのか、監視の上限で +打ち切られたのかを、進行側も利用者も判別できなかった。 + +**監視が止めた担当の子プロセスが、止めた後に結果ファイルを書いていた。** 監視は担当の +pid だけへシグナルを送っており、CLI が起こした子プロセスは生き残っていた(#584)。 + +**根本原因は、結果なしの判断を 2 つの Skill がそれぞれ結果ファイルの有無だけで行い、監視が +書いた理由をどちらも読まないことにある(#729)。** 理由を読む処理を各 Skill へ書くと、 +監視の理由から結果なしの理由へ移す同じ表が 2 か所にできる。語彙を足すたびに片方が古くなる。 + +**文言の照合は、実物の 3 形式に一致することを確かめてある。** 2026-09-19 に `develop` +(9eaebe14、bash 5.3.9、Python 3.14.4)で測った結果は次のとおりである。 + +| 入力 | 利用上限 | CLI の上限 | 既存の致命 | +| --- | --- | --- | --- | +| kiro の実物の 1 行 | 一致 | — | — | +| claude の 429 の JSON の 1 行 | 一致 | — | — | +| HTTP 429 の状態行 | 一致 | — | 一致 | +| HTTP 401 の状態行 | — | — | 一致 | +| agy の打ち切りの行 | — | 一致 | — | +| 表・バッククォート・引用・grep 形式 | — | — | — | + +**既存の致命の照合は、kiro の実物と claude の JSON に一致しない。** #619 が再現した形である。 + +**プロセスグループの停止は 2026-09-15 に測った。** ジョブ制御を有効にして起動した CLI を +グループへのシグナルで止めると、3 秒後に子プロセスが書く結果ファイルは書かれなかった。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 結果なしの判断と起動し直しの可否を、結末の共通層の 1 つの関数へ移す | 理由を読む処理を各 Skill に書くと、同じ表が 2 か所にできて片方が古くなる | +| 起動し直しの可否は「同じ担当を同じ条件で起動し直せば解けるか」の 1 つの真偽値にする | 進行側が結末を見て決めることはこの 1 つに尽きる | +| 偽にするのは利用上限だけにする | 利用上限は起動し直しても解けず、起動のたびに待ちと相手の CLI の枠を使う。他の理由は対象や負荷で変わりうるため、1 度は起動し直してよい | +| 偽のときに何をするかは Skill が決める | 担当を替えられるかどうかは、担当の集合を知る Skill にしか決められない | +| 利用上限は早期の致命の状態のまま、理由だけを分ける | 終了コードで分岐する骨組みと文書を変えずに、読む側が区別できる。cross-refactoring だけで監視の呼び出しが 8 か所ある | +| CLI 自身の上限は結果なしの状態のまま、理由だけを分ける | agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」で正しい | +| 利用上限の文言は、標準エラーの記録を全担当で見る | 既存の照合が引用・表・grep 形式を除外する。実測では claude の JSON もこの判定に飲み込まれなかった | +| 標準出力の記録は claude だけ、JSON 向けの照合で見る | JSON は 1 行に引用符を多く含む。行単位の引用の判定が、引用の内側と判定してしまう | +| 読めない結果を共通の語彙に入れる | 結果ファイルが JSON として読めないことは、2 つの Skill が同じ形で見ている | +| 判定の値が無い・未投稿は cross-review 固有に残す | 結果ファイルの中身とレビューの投稿の話で、監視も cross-refactoring も知りえない | +| 監視の結末に理由の欄を持たせ、無ければ状態からの既定を使う | 利用上限と CLI の上限は、監視の状態からは決まらない | +| 起動し直しで上書きされる 1 回目の理由は、追記だけの監視の記録が持つ | 状態ファイルの担当ごとの結果は、最後の結果だけを持つ構造である | +| CLI を独立したプロセスグループで起動し、先頭のときだけグループへシグナルを送る | 止めた後に子プロセスが結果を書くことを止める。`setsid` は macOS に標準で入っていない | +| cross-refactoring の取り込みは、結果なしで進行を止めず、共通の関数の値で返す | 終了コードを決めるのは 3 つの取り込みであり、読み取りではない | +| 結果の取り込みの終了コードと、判定の 0 / 2 / 7 / 8 は変えない | 骨組みの分岐が増えると、`SKILL.md` と `docs/` の 2 か所へ同じ値を書くことになる | + +## 仕様 + +### 結末の語彙 + +**理由の語彙は 9 語である。** 監視が書く語と読む側だけが書く語を、起動し直しの可否と +合わせて 1 つの表で持つ。 + +| 理由 | 監視の状態 | 誰が書くか | 起動し直しの可否 | 何が起きたか | +| --- | --- | --- | --- | --- | +| `ok` | `OK` | 監視 | — | 結果ファイルがあって終わった | +| `timeout` | `TIMEOUT` | 監視 | 可 | 監視の上限 | +| `stalled` | `STALLED` | 監視 | 可 | 無進捗の許容を超えた | +| `early_error` | `EARLY_ERROR` | 監視 | 可 | 利用上限以外の致命の文言 | +| `usage_limit` | `EARLY_ERROR` | 監視 | **否** | 利用上限の文言 | +| `cli_timeout` | `NO_RESULT` | 監視 | 可 | 結果なしで終わり、CLI の上限の文言がある | +| `missing` | `NO_RESULT` | 監視・読む側 | 可 | 結果なしで終わり、理由の文言が無い | +| `pidfile_bad` | `PIDFILE_BAD` | 監視 | 可 | pid ファイルが無い、または別のプロセス | +| `unparsable` | — | 読む側だけ | 可 | 結果ファイルがあるが、JSON オブジェクトとして読めない | + +**起動し直せない理由の集合は 1 語(`{"usage_limit"}`)である。** 可否を返す関数は、その +集合に無いことを返すだけである。理由を足すときに見直すのはこの集合だけになる。 + +**理由の一覧と可否の表を持つのは結末の共通層だけである。** Skill の側に利用上限の条件分岐を +書かない。 + +### 結末を読む + +結末を読む関数が、結果ファイルと監視の結果ファイルを突き合わせ、起動 1 回の結末を 1 つの +値として返す。 + +| 項目 | 内容 | +| --- | --- | +| 入力 | 一時ディレクトリ、監視と同じ stem、結果ファイルのパス(省くと `/-result.json`) | +| 出力(使える結果) | `payload` に JSON オブジェクト、`reason` は `None`、可否は真、`monitor` に監視の結果ファイルの辞書(無ければ `None`)、`detail` に監視の `detail` | +| 出力(結果なし) | `payload` は `None`、`reason` は下の表、可否は集合から導く、`detail` は監視の `detail`(無ければ読めなかった理由の 1 文) | +| 失敗の形 | **失敗しない。** 例外・`SystemExit`・標準出力と標準エラーへの出力を出さない。壊れた監視の結果ファイルは、無いものとして扱う | + +結果なしの理由は次のとおり決める。 + +| 監視の結果ファイルの理由 | 結果ファイル | 決まる理由 | +| --- | --- | --- | +| `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` | 問わない | その値 | +| `ok` / `missing` / ファイルが無い・読めない | 無い、または空 | `missing` | +| `ok` / `missing` / ファイルが無い・読めない | あるが JSON オブジェクトでない | `unparsable` | + +**監視が上の 6 語を書いていれば、監視が止めたか、結果を書けない終わり方をしたと分かる。** +その値を結果ファイルの状態より先に採る。 + +**結果ファイルが読めれば、使える結果が勝つ。** 監視が利用上限で止めた後にも結果ファイルが +残っていれば、それは止める前に書き終えていた結果で、使ってよい。 + +### 上限の検知 + +| 理由 | 見るファイル | いつ見るか | 文言(正規表現) | +| --- | --- | --- | --- | +| `usage_limit` | 標準エラーの記録(全担当) | 生きている間の巡回ごと | `Monthly request limit reached` | +| `usage_limit` | 同上 | 同上 | `"api_error_status"\s*:\s*429` | +| `usage_limit` | 同上 | 同上 | `quota exceeded` / `rate limit exceeded`(大文字小文字を問わない)、`^HTTP/\d\S* 429 ` | +| `usage_limit` | claude の標準出力の記録 | 同上 | `"api_error_status"\s*:\s*429` | +| `early_error` | 標準エラーの記録 | 同上 | `^HTTP/\d\S* (?:401\|403) ` と、残りの既存の致命 | +| `cli_timeout` | 標準エラーの記録 | **終了した後、結果ファイルが無いときだけ** | `print timeout after \S+ with turn in progress` | + +**照合の順序は、利用上限 → 致命 → 警告の見た目の致命である。** 同じ記録に利用上限と他の +致命が両方あれば、理由は利用上限になる。上限で落ちた後に別の文言が続く形が普通で、上限の +ほうが原因であるためである。 + +**CLI の上限の文言は、終了した後にだけ見る。** 生きている間に見ると、途中で出た警告を +致命と読む。結果ファイルがあれば理由は `ok` になる。上限に当たっても、結果を書き終えて +いれば使える。 + +### 監視が変えないもの + +| 項目 | 約束 | +| --- | --- | +| 終了コード 0〜6 | 変えない。`usage_limit` は 4、`cli_timeout` は 3 | +| 標準出力の 13 個のキー | 変えない | +| 監視の結果ファイルと記録の理由 | `usage_limit` / `cli_timeout` が増える | +| 早期の致命の検知を止める引数 | 変えない。利用上限の検知も一緒に無効になる | + +**監視の結末は理由の欄を持つ。** 結末を書くときは、理由があればそれを、無ければ状態からの +既定を書く。 + +### 起動と停止 + +| 項目 | 約束 | +| --- | --- | +| 起動 | ジョブ制御(`set -m`)を有効にして背景起動する。CLI の pid が、そのままプロセスグループの番号になる | +| 停止 | 対象がグループの先頭であり、かつ監視自身のグループと違うときだけ、グループへ送る(SIGTERM → 3 秒 → SIGKILL)。それ以外は pid だけへ送る | +| 互換性 | 呼び出し側の引数と pid ファイルの中身は変わらない | + +### 結果なしのラウンドの扱い + +| コマンド | 変わる出力 | 変わらないもの | +| --- | --- | --- | +| 結果の取り込み | 担当の記録に結果なしの理由と監視の詳細を残す | 終了コード(読めない結果は 3、それ以外は 1、使える結果は 0) | +| 判定 | 結果なしがあるとき、標準出力に `NO_RESULT_REASONS='<担当>=<理由> …'`。可否が偽の理由を含めば誤りの終わりと終了コード 1、標準エラーに担当・理由・監視の詳細 | 終了コード 0 / 2 / 7 / 8 の意味と、`RELAUNCH_AGENTS` の形 | +| 報告の表 | ラウンドの表で `<担当>=NO_RESULT(<理由>)` | 他の行 | + +**結果の取り込みは結果ファイルを自前で開かず、結末を読む関数を呼ぶ。** + +**判定は理由の行を、どの出口でも先頭で 1 度だけ出す。** 可否が偽の理由が 1 つでもあれば、 +誰も起動し直さずに誤りの終わりへ進む。既存の終了コード 1 の枝で終えるため、骨組みの分岐は +増えない。 + +### cross-refactoring の読み取りの契約 + +cross-refactoring の取り込みが従う契約を、ここで定める。**契約を守る側の仕様は +[結果なしの取り込みと開き直し](cross-refactoring-apply-intake.md)にある。** + +| 項目 | 契約 | +| --- | --- | +| 読み取り | 結末を読む関数を呼ぶ。自前で結果ファイルを開かない | +| 失敗の形 | 進行を止める終了をしない。終了コードを決めるのは 3 つの取り込みである | +| 群の記録 | 失敗した試行の理由へ、結末の理由をそのまま写す | +| 担当の交代 | 可否が偽なら、同じ担当で試行を重ねない | + +## データ・設定 + +### 監視の結果ファイルと監視の記録 + +**増えるのは理由の値だけである。** 他の 14 個のキーは変えない。監視が書く理由は、 +「結末の語彙」の表のうち読む側だけが書く 1 語(`unparsable`)を除いた 8 語である。 + +**起動し直しで上書きされる 1 回目の理由は、監視の記録が持つ。** 記録は追記だけで積むため、 +理由と終了時刻から 1 回目の結末を読める。 + +### 状態ファイルの鍵(cross-review) + +| 鍵 | 何を持つか | +| --- | --- | +| `no_result_reason` | 結末の理由 8 語(`ok` を除く)、または cross-review が上書きする `no_verdict` / `not_posted`。合わせて 10 語 | +| `monitor_detail` | 監視の詳細(標準エラーの記録の抜粋、最大 200 文字)。**鍵が無い = 監視の結果ファイルが無かった。** 空文字は書かない | + +**移行は無い。** 鍵の追加と値の追加だけであり、既存の状態ファイルはそのまま読める。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 理由の語彙と起動し直しの可否が 1 か所にあり、結末を読む関数が失敗しないこと | `plugins/ndf/scripts/tests/test_monitor_outcome_unit.py` | +| 利用上限の文言を検知し、引用・表・grep 形式で誤検知しないこと | `plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` | +| CLI の上限の文言を、終了して結果ファイルが無いときだけ理由にすること | 同 `tests/test_launch_print_timeout.py` | +| CLI が独立したプロセスグループで起動し、グループごと止まること | 同 `tests/test_launch_cli_process_group.py` | +| 結果の取り込みが理由と監視の詳細を残し、終了コードを変えないこと | 同 `tests/test_read_result_reason.py` | +| 判定が理由の行を出し、可否が偽の理由で起動し直さずに止めること | 同 `tests/test_judge_no_result_reason.py` | +| 結果なしの記録と、判定の起動し直しの出口が変わらないこと | 同 `tests/test_state_no_result.py` | +| 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py` | +| 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py` | + +## 関連リンク + +- [issue #729](https://github.com/devbasex/ai-plugins/issues/729) — 結果なしの判断を共通層へ移す +- [issue #619](https://github.com/devbasex/ai-plugins/issues/619) — 利用上限で止まった担当の空振りの起動し直し +- [issue #584](https://github.com/devbasex/ai-plugins/issues/584) — 止めた担当が後から結果ファイルを書く +- [結果なしの取り込みと開き直し](cross-refactoring-apply-intake.md) — cross-refactoring 側の読み取りを使う仕様 +- [PR #791](https://github.com/devbasex/ai-plugins/pull/791) — 実装 +- [`cross-review` の状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) +- [`cross-review` の状態とレビューの手順](../../plugins/ndf/skills/cross-review/docs/01-state-and-review.md) +- [`cross-review` の手順](../../plugins/ndf/skills/cross-review/SKILL.md) diff --git a/docs/specifications/cross-review-participants-and-seats.md b/docs/specifications/cross-review-participants-and-seats.md new file mode 100644 index 00000000..fbe0175b --- /dev/null +++ b/docs/specifications/cross-review-participants-and-seats.md @@ -0,0 +1,401 @@ +# cross-review: 参加する CLI が 1 者でも使えないと収束ループを開始できず、再開で渡した引数が黙って無視される → 使える者だけで開始し、毎ラウンド 2 席を確保し、再開で渡した引数は反映されるか反映しないと知らされる + +## 目的 + +**参加する CLI のどれか 1 者が導入・認証されていなくても、収束ループが始まる。** 使えない者と +その理由は、初期化の出力と状態ファイルに残る。 + +**各ラウンドに 2 つの席が確保される。** 使える者が 2 者に満たないときは、ホスト、次に同じ +ランタイムの 2 つ目が席を埋める。1 席で回るのは、利用者が 1 者指定を渡したときだけである。 + +**中断した収束ループを引数を変えて再開すると、その引数は反映されるか、反映しないと知らされる。** +黙って捨てられる引数は無い。 + +**使える者の決定・席の埋め方・再開の反映の 3 つの規則は、収束ループを回す 2 つの Skill が +共有する共通層が 1 か所ずつ持つ。** Skill の側は結果を状態ファイルと終了コードへ写すだけである。 + +**手順と引数の表は +[`cross-review` の SKILL.md](../../plugins/ndf/skills/cross-review/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md)が正である。** +ここに書き写さない。この文書が扱うのは、そこに書かない決定の理由と、共通層の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| ランタイム | `ALL_RUNTIMES` | `claude` / `codex` / `agy` / `kiro` の 4 つ。並びは固定 | +| ホスト | `host` | 収束ループを起動している CLI | +| 参加の母集合 | `review_pool(host)` / `refactor_pool(host)` | Skill ごとに決まる参加者の出発点。cross-review は全ランタイム − ホスト | +| 参加者 | — | 参加の母集合に足す者を加え、外す者を除いた一覧。認証の確認の対象 | +| 使える者 | `participants.available` | 参加者のうち認証の確認を通った者 | +| 認証の確認 | `probe_auth` | 確認コマンドを走らせ、止めずに結果だけを返す共通層の関数 | +| 使える者の解決 | `resolve_participants` | 参加の母集合・足す者・外す者・1 者指定・確認の結果から使える者を決める共通層の関数 | +| 参加者の記録 | `participants` | 使える者の解決の結果を持つ状態ファイルの項目 | +| 席 | — | 1 ラウンドで 1 つの CLI プロセスが占める場所 | +| 席の名前 | `SEAT_PATTERN` / `seat_runtime` | ランタイム名か、その名前に `-2`〜`-9` を付けた形 | +| 席の埋め方 | `review_seats` | 使える者と埋め合わせから 2 席を返す共通層の関数 | +| 埋め合わせ | `participants.fallback` | 席が足りないときに使う者。ホストが入る | +| 適用の輪番 | `impl_assign` | 参加者から実装担当 1 者を返す共通層の関数 | +| 1 者指定 | `--only` | 担当を 1 者に固定する引数 | +| 全員を要する指定 | `--require-all` | 認証の確認の失敗が 1 者でもあれば初期化を止める引数 | +| 再開 | — | 状態ファイルが残り `final` が `null` のときの初期化 | +| 再開の反映 | `apply_resume_args` / `ResumeField` | 渡した引数を状態へ重ね、反映しない引数を知らせる共通層の関数と、表の 1 行 | +| 再開で変えた値の記録 | `resume_changes` | 再開で変えた値を積む状態ファイルの項目 | +| 初期化 / ラウンドの開始 / 結果の受け口 / 完了報告 | `init` / `start-round` / `read-result` / `report` | `state.py` の副コマンド | + +## 対象範囲 + +この文書が扱うのは、共通層と cross-review の側である。 + +| 扱う | 扱わない | +| --- | --- | +| 共通層の使える者の解決・認証の確認・席の埋め方・適用の輪番・席の名前・再開の反映 | cross-refactoring の参加の母集合・適用の輪番の使い方・再開の表([cross-refactoring の参加者](cross-refactoring-participants.md)が持つ) | +| cross-review の新規と再開の初期化、担当の決まる順、席の名前が流れる経路、完了報告の参加者の節 | 起動した後に分かる使えなさで担当を自動的に外す仕組み | + +適用の輪番は共通層に入っているためここで扱う。呼び出す側は cross-refactoring だけである。 + +## 背景 + +**認証の確認が関門だった。** 従来の確認は 1 件の失敗でその場で終了しており、4 つの CLI が +揃っていない環境では収束ループを開始できなかった。使える CLI が 2 者あっても、3 者目が +入っていなければ初期化ごと止まる。 + +**使える者が 2 者に満たないラウンドの扱いが無かった。** 担当を決める関数は参加の母集合を +「全ランタイム − ホスト」の定数から作り、使える者を入力に取らない。そのため一部が使えない +構成では、担当が 1 者になるか、担当そのものを決められなかった。 + +**再開で渡した引数が黙って無視されていた。** 中断した収束ループを 1 者指定やラウンドの上限を +変えて再開しても、状態ファイルの値がそのまま使われる。利用者には、渡した引数が効いたのか +どうかを知る手立てが無かった。 + +**前のラウンドの検査が、固定の 2 者で結果を数えていた。** 担当がその 2 者と違うラウンドでは、 +担当でない者を結果なしと読み、修正の記録が無いまま次のラウンドへ通していた。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| 使える者を決める規則を共通層の 1 つの関数へ置き、Skill は結果を状態ファイルと終了コードへ写すだけにする | 規則を Skill ごとに書くと、参加の母集合の作り方・除外の検査・確認の扱いが 2 か所にでき、片方だけが古くなる | +| 認証の確認は止めずに結果だけを返す形にし、確認コマンド・未認証の文言・時間切れの秒数は変えない | 確認と中断が 1 つの関数にあると、使える者で回す経路から呼べない。止めるかどうかは全員を要する指定が決める | +| 参加者は「既定に足す者を加え、外す者を除く」で決め、既定は Skill ごとの関数が持つ | 使う側を並べる形は、ホストが変わるたびに一覧を書き直すことになる | +| 全員が揃わないなら始めたくない運用のために、従来の関門を指定で選べるようにする | 既定を変えるだけでは、揃っていることを要求する運用が選べなくなる | +| 席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める | 各ラウンドで 2 つの目で見ることを最優先にする。同じ言語モデルの 2 つの文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる | +| 埋め合わせの候補は、使える者に含まれない者だけを使う | 足す者の指定でホストが使える者に入っているとき、ホストを埋め合わせにも使うと同じ席の名前が 2 つ並ぶ | +| 1 者指定のときは埋め合わせをせず、ホストの確認も行わない | 利用者が 1 席と決めた指定である | +| 担当の単位を席の名前にし、1 つ目の席の名前はランタイム名そのままにする | 同じランタイムの 2 つ目を立てるには、結果ファイルの stem と状態ファイルの鍵を分ける名前が要る。埋め合わせが要らない実行では、従来と同じ名前しか現れない | +| 接尾辞の区切りをハイフンにする | ランタイム名にハイフンを含むものが無いため、シェル側の切り出しと共通層の解釈が同じ規則になる | +| 監視では、席の形に合わない名前をそのまま返す | 担当名を任意の骨格で受ける cross-refactoring の経路がある。形で弾くとその経路が壊れる | +| 担当はラウンドの記録から先に見る | 再開で 1 者指定を変えても、過去のラウンドの担当が変わらない | +| 前のラウンドの検査にも、そのラウンドの担当を渡す | 固定の 2 者で数えると、担当が違うラウンドで担当でない者を結果なしと読み、修正の記録が無いまま次へ通す | +| 適用の輪番は「ラウンド番号を参加者の数で割った余り」の式を保つ | ラウンド 1 が 2 番目の者から始まる。参加者はランタイムの固定の順に並ぶため、既定の参加者でホストが最初に適用しないのはホストが claude / codex のときだけで、agy / kiro のときはラウンド 1 の担当がホストになる | +| 再開の反映を共通層に置き、どの引数を「反映する」「知らせる」にするかは Skill ごとの表が持つ | 片方の Skill の再開の経路だけを直すと、もう片方に引数を捨てる形が残る | +| 状態ファイルに載る引数は表のどちらかに必ず載せ、引数の既定を未指定にする | 黙って捨てる引数を残さない。既定値と同じ値なら渡していないとみなす形では、上限を既定値へ戻す操作を区別できない | +| 担当に関わる引数を渡した再開でだけ、認証の確認をやり直して参加者を作り直す | 途中で担当が入れ替わると、前のラウンドの記録と突き合わせられなくなる | +| 作り直しのとき、渡さなかった引数は状態ファイルの値で補う | 初期値へ戻すと「明示した引数だけを反映する」が破れる | +| 指定を外す値は予約語 `none` にする | 骨組みは値のあるときだけ引数を渡すため、空文字列では指定を外せない | +| 参加者の作り直しは、記録へ 1 件として積む | 項目ごとに積むと 1 回の再開で最大 7 件になり、完了報告で読みにくい | +| 骨組みは 1 者指定のシェル変数で担当を絞り直さず、ラウンドの開始が返す席を使う | 返る一覧は 1 者指定と埋め合わせを反映済みである。もう一度絞ると、状態ファイルとシェル変数がずれたときに起動も監視も誰にも当たらない | +| 起動した後に分かる使えなさで担当を自動的に外す仕組みは作らない | 一時的な打ち切りでも、以後のラウンドから恒久的に外れる | +| 規則の実装は共通層、手順は各 Skill の手順書、理由はこの文書が持つ | 規則が決まっても、置き場所が決まらなければ次に使う人へ届かない | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 使える者の並びは、ランタイムの固定の順である | 解決の中で並べ直すため、入力の順序に依らない | +| 使える者が 3 者のときの席は、この変更の前の輪番と同じ値になる | 4 つのホスト × ラウンド 1〜12 の全組で一致することをテストが固定する | +| 1 ラウンドの席は 2 つで、同じ席の名前が 2 つ並ばない | 埋め合わせの候補から使える者を除いて選ぶ | +| 使える者も埋め合わせも無ければ、席を返さず失敗する | 割り当ての失敗(`AssignmentError`)を上げ、初期化は状態ファイルを作らずに終了コード 1 | +| 状態ファイルに載る引数は、再開の表の「反映する」か「知らせる」のどちらかに載る | 表に無い引数は、状態ファイルに載らない 3 つ(作業ツリー・観点・追加指示のファイル)だけである | +| 再開で渡さなかった引数は、状態ファイルの値のまま残る | 参加者を作り直すときも、渡さなかった引数は記録から補う | +| この変更の前に始めた実行の状態ファイルを、書き換えずに読める | 項目が無いときの読み方を「項目が無いときの読み方」が決める | + +### 構成要素と責務 + +| 要素 | 責務 | 置き場所 | +| --- | --- | --- | +| 参加の母集合の既定 | Skill ごとの出発点を返す | `lib/assignment.py` の `review_pool` / `refactor_pool` | +| 使える者の解決 | 参加の母集合・ホスト・足す者・外す者・1 者指定・確認・全員を要するかから、参加者の記録を返す。名前の矛盾と欠けを例外で返す | 同 `resolve_participants` | +| 認証の確認 | 確認コマンドを走らせ、止めずに担当ごとの結果を返す | `lib/auth.py` の `probe_auth` | +| 席の埋め方 | 使える者と埋め合わせから 2 席を返す。規則の表はこの関数の docstring が正 | `lib/assignment.py` の `review_seats` | +| 適用の輪番 | 参加者から実装担当 1 者を返す | 同 `impl_assign` | +| 席の名前 | 席の名前からランタイムを引く。形の検査を持つ | 同 `seat_runtime` / `SEAT_PATTERN` | +| 再開の反映 | 表に従って状態へ書き、記録へ積み、知らせる行を返す | `lib/statefile.py` の `apply_resume_args` / `ResumeField` | +| 使える者の決定(cross-review) | 共通層を呼び、埋め合わせを決め、参加者の記録を返す。失敗を終了コード 1 へ写す | `cross-review/scripts/state.py` の `_resolve_reviewers` | +| 担当の読み出し | 決まった順で席を返す。前のラウンドの検査へその担当を渡す | 同 `_round_reviewers` / `_guard_previous_round` | +| 席の受け口 | 席の名前を受け、起動する CLI を席の名前から引く | `read-result` の引数の型、`launch-reviewer.sh`、`critique.sh`、`lib/monitor.py`、`measure.py` | +| 完了報告の参加者の節 | 参加した者・外した者・確認を通らなかった者・埋め合わせ・再開で変えた値を出す | `cross-review/scripts/state.py` の `_print_participants` | + +要素の関係(辺は呼び出し): + +```mermaid +graph TD + PL[参加の母集合の既定] --> RP[使える者の解決] + RP --> PA[認証の確認] + RI[初期化] --> RP + RI --> RA[再開の反映] + RR[担当の読み出し] --> RS[席の埋め方] + RC[席の受け口] --> SR[席の名前] +``` + +### 使える者の決め方 + +参加者を決め、認証の確認を通った者を使える者として記録する。順序は次のとおりである。 + +| 順 | 何をするか | +| ---: | --- | +| 1 | 足す者・外す者の各名前がランタイムの一覧にあり、互いに重ならないことを確かめる。外す者が「既定 ∪ 足す者」に含まれなければ弾く | +| 2 | 参加者 = 既定 ∪ 足す者 − 外す者(ランタイムの固定の順) | +| 3 | 1 者指定があれば、参加者に含まれ外す者に無いことを確かめ、参加者をその 1 者にする | +| 4 | 参加者へ認証の確認を行う。確認を飛ばす環境変数(`NDF_SKIP_AUTH_CHECK`)が立っていれば全員を通ったものとし、飛ばした印を真にする | +| 5 | 全員を要する指定が真で通らない者がいれば、欠けた者と理由を並べて失敗する | +| 6 | 通った者を使える者、通らなかった者と理由を通らなかった者として返す | + +**外した者へは確認コマンドを呼ばない。** 確認の回数は「参加者の数 + 埋め合わせが要るときの +ホストの 1 回」を超えない。 + +cross-review の新規の初期化は、この結果に席の埋め合わせを足して状態ファイルへ書く。 + +```mermaid +graph TD + H[ホストを確定] --> P[参加の母集合の既定を引く] + P --> RP[使える者の解決] + RP -->|名前の矛盾 / 欠け| F[状態ファイルを作らず終了コード 1] + RP -->|通った| N{使える者が 2 者以上} + N -->|はい| W[状態ファイルを書く] + N -->|いいえ| HP[ホストを確認し埋め合わせを決める] + HP -->|席を埋められる| W + HP -->|埋められない| F +``` + +**1 者指定のときは、この分岐へ入らない。** 埋め合わせを行わず、指定した 1 者が確認を通らない +ときだけ終了コード 1 で終わる。確認を通らない担当が席に座ると、レビューが行われないまま +収束するためである。 + +### 席の埋め方 + +| 使える者の数 | 席 | +| ---: | --- | +| 3 以上 | ラウンド番号を使える者の数で割った余りの位置と、その次の位置の 2 者。並びは使える者の順 | +| 2 | その 2 者 | +| 1 | その 1 者と、埋め合わせのうち使える者に含まれない先頭の者。そのような者が無ければ、その 1 者の 2 つ目 | +| 0 | 埋め合わせの先頭と、その 2 つ目。埋め合わせが空なら失敗する | + +**3 者のときの値は、この変更の前の輪番と一致する。** 4 者のときはラウンド 1〜4 で各者が +ちょうど 2 回担当になる。 + +### 席の名前 + +**担当の単位は席の名前である。** 形はランタイム名か、その名前にハイフンと 2〜9 を付けたもの +(`^(claude|codex|agy|kiro)(-[2-9])?$`)で、後者が同じランタイムの 2 つ目以降を表す。 + +| 受け口 | 席の名前の扱い | +| --- | --- | +| 結果の受け口の担当の引数 | 席の形を型として検査する。通らなければ終了コード 2 | +| レビューの起動(`launch-reviewer.sh`)と反証の起動(`critique.sh`) | 席の形を先頭で検査し、起動する CLI を `${SEAT%%-*}` で選ぶ。結果ファイルの stem は席の名前で組む | +| 監視(`lib/monitor.py`) | 位置引数は席の形か `both` を通す。CLI 固有のログの検査は席の名前からランタイムを引いて選ぶ。席の形に合わない名前はそのまま返す | +| 計測(`measure.py`) | 担当の記録を数えるとき、名前の一覧ではなく席の形に一致する鍵を数える | + +埋め合わせがあるラウンドで席の名前が流れる経路は次のとおりである。 + +```text +start-round → REVIEWERS="codex claude-2" + → launch-reviewer.sh claude-2 … : 起動する CLI は claude、stem は claude-2-review-pr + → monitor.py --agents codex,claude-2 + → state.py read-result claude-2 : rounds[-1]["claude-2"] へ書く + → critique-round.sh codex claude-2 → critique.sh claude-2 … +``` + +### ラウンドの担当が決まる順 + +**先に当たったものを採る。** + +| 順 | 状態 | 返る担当 | +| ---: | --- | --- | +| 1 | そのラウンドの記録に担当がある | その値 | +| 2 | 1 者指定がある | その 1 者だけ | +| 3 | 参加者の記録がある | 使える者と埋め合わせから 2 席 | +| 4 | ホストがある | 参加の母集合から 2 席(この変更の前の輪番と同じ値) | +| 5 | どれも無い | `codex` / `agy` | + +**ラウンドの記録を 1 者指定より先に見る。** 再開で 1 者指定を変えても、過去のラウンドの担当が +変わらないようにするためである。 + +**前のラウンドの検査は、そのラウンドの記録の担当で結果を数える。** 記録が無いときだけ、この順で +引き直す。 + +### 適用の輪番 + +実装担当 1 者を「ラウンド番号を参加者の数で割った余り」の位置から選ぶ。式はこの変更の前と +同じで、除数だけが参加者の数になる。ラウンド 1 は 2 番目の者から始まる。参加者はランタイムの +固定の順に並ぶため、既定の参加者でホストが最初に適用しないのはホストが claude / codex のとき +だけで、agy / kiro のときはラウンド 1 の担当がホストになる。呼び出す側は cross-refactoring だけで、使い方は +[cross-refactoring の参加者](cross-refactoring-participants.md)が持つ。 + +### 再開で渡した引数の扱い + +| 扱い | 引数 | 何が起きるか | +| --- | --- | --- | +| 反映する | `--max-rounds` / `--rotate-after` / `--verify-command` / `--verify-exit-code` | 状態ファイルを書き換え、記録へ 1 件積み、変更の行を出す | +| 反映し、参加者を作り直す | `--only` | 上に加えて、認証の確認をやり直して参加者の記録を置き換える。`none` で 1 者指定を外す | +| 参加者を作り直す | `--exclude` / `--include` / `--require-all` | 使える者を解決し直して参加者の記録を置き換える。`none` で一覧を空へ戻す | +| 知らせる | `--host` | 状態ファイルは変えず、状態と違うときだけ 1 行を出す | + +**値が同じ引数は、行も記録も出さない。** + +```mermaid +graph TD + A[状態ファイルを読む] --> AR[再開の反映] + AR --> N[知らせる行を出す] + N --> RA{担当に関わる引数を渡した} + RA -->|はい| RP[使える者の解決をやり直す] + RP -->|失敗| F[状態ファイルを書き換えず終了コード 1] + RP -->|通った| W[参加者の記録を書き 1 件積む] + RA -->|いいえ| W2[変えた項目だけ書く] +``` + +**作り直しの入力は 2 つである。** 渡した引数と、渡さなかった引数の状態ファイルの値(足した者・ +外した者・全員を要するかと、1 者指定)である。ホストを足して始めた実行へ外す指定だけを渡した +再開では、足した者の記録はそのまま残る。 + +**作り直しの失敗は、状態ファイルを書き換える前に起きる。** 使える者の解決を先に呼び、通って +から状態ファイルへ書く。 + +### 完了報告の「参加した者」 + +完了報告の PR 履歴の後に、7 行の節を出す。 + +```text +## 参加した者 +- 母集合: codex / agy / kiro +- 使える者: codex / kiro +- --exclude で外した者: agy +- --include で足した者: なし +- 確認を通らなかった者: なし +- 席の埋め合わせ: なし +- 再開で変えた値: 2026-09-19T12:00:00 participants … → … +``` + +参加者の記録を持たない状態ファイルでは「使える者: 記録なし」の 1 行だけを出す。確認を飛ばした +印が真のときは、通らなかった者の行に「確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。 + +## データ・設定 + +### 状態ファイルに増える 2 項目 + +形式そのものは +[04-contracts.md](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)が正である。 + +| 項目 | 型 | 意味 | +| --- | --- | --- | +| `participants` | オブジェクト(下の表) | 使える者の解決の結果。項目が無いのは、この変更の前に始めた実行である | +| `resume_changes` | オブジェクトの配列 | 再開で変えた値の記録。追記だけを行う。要素は `at` / `field` / `from` / `to` | + +参加者の記録の中身は 8 項目である。共通層が 7 項目を返し、埋め合わせだけを cross-review が足す。 + +| 項目 | 型 | 意味 | +| --- | --- | --- | +| `pool` | 文字列の配列 | 参加の母集合の既定。ランタイムの固定の順 | +| `included` | 文字列の配列 | 足した者。空は「足していない」 | +| `excluded` | 文字列の配列 | 外した者。空は「外していない」 | +| `available` | 文字列の配列 | 使える者。1 者指定があればその 1 者だけ | +| `unavailable` | オブジェクト | 確認を通らなかった者と理由 | +| `probe_skipped` | 真偽値 | 確認を飛ばしたか。通らなかった者が空である理由を区別する | +| `require_all` | 真偽値 | 全員を要する指定の値。新規の既定は偽 | +| `fallback` | 文字列の配列 | 席の埋め合わせに使える者。使える者が 2 者以上か、1 者指定があれば空 | + +**ラウンドの記録の担当と鍵には席の名前が入りうる。** 過去のラウンドの担当は再開で書き換えない。 + +### 初期化の引数 + +| 引数 | 型 | 既定 | 新規の経路 | 再開の経路 | +| --- | --- | --- | --- | --- | +| `--max-rounds N` / `--rotate-after K` | 整数 | 未指定 | 無ければ 12 / 8 | 渡せば反映 | +| `--only RUNTIME` | 4 つの名前か `none` | 未指定 | `none` は無しと同じ | 渡せば反映。`none` で外す | +| `--exclude NAMES` / `--include NAMES` | カンマ区切りの名前。繰り返し可。`none` | 未指定 | 外す / 足す | 渡せば置き換え。`none` で空 | +| `--require-all` / `--no-require-all` | 真偽値 | 未指定 | 無ければ偽 | 渡せば反映 | +| `--host RUNTIME` | 4 つの名前 | 未指定 | 無ければ推定 | 反映せず、違えば 1 行 | +| `--verify-command CMD` / `--verify-exit-code N` | 繰り返し可 | 未指定 | 無ければ空 | 渡せば置き換え | + +**上限の既定を引数の側に置かない。** 新規の経路が定数を置く。引数の側に既定を置くと、再開の +たびに利用者が指定していない値で状態ファイルを上書きする。 + +**名前の検査は 2 段に分かれる。** 綴りは引数の型が弾き(終了コード 2)、参加の母集合との関係は +共通層が弾く(終了コード 1)。後者に当たるのは、外す者・1 者指定に参加の母集合に無い名前を +渡したとき、足す者と外す者が重なるとき、1 者指定と外す者が矛盾するとき、`none` と名前を +混ぜたときである。cross-review ではホストが参加の母集合に無いため、ホストを外す指定はここで +弾かれる。 + +### 出力と終了コード + +標準出力の形は変わらない。増えるのは標準エラーの行と、席の名前が取りうる値である。 + +| 場面 | 標準エラー | 終了コード | 状態ファイル | +| --- | --- | ---: | --- | +| 新規で全員が使える | 参加の母集合と使える者の 1 行 | 0 | 作る | +| 新規で確認を通らない者がいる | 通らなかった者と理由を 1 者 1 行 | 0 | 作る | +| 新規で使える者が 2 者に満たない | 埋め合わせの相手を 1 行 | 0 | 作る | +| 新規で使える者も埋め合わせも無い | 使える者がいない理由 | 1 | 作らない | +| 確認を飛ばした | 飛ばしたことを 1 行 | 0 | 作る | +| 全員を要する指定で欠けがある | 欠けた者と理由 | 1 | 作らない | +| 名前の矛盾 | 何が矛盾したか | 1 | 作らない | +| 再開で引数を反映した | 項目ごとに 1 行 | 0 | 書き換える | +| 再開で反映しない引数が状態と違う | 引数ごとに 1 行 | 0 | 変えない | +| 再開の作り直しが失敗した | 新規と同じ | 1 | 書き換えない | + +行の先頭の印は、既存の初期化の出力に揃える。 + +| 場面 | 行の形 | +| --- | --- | +| 反映した | `↻ <項目>: <旧> → <新>` | +| 反映しない | `ℹ --<引数> は再開では反映しません(状態: <値> / 指定: <値>)` | +| 確認を通らなかった | `⚠ <名前> を担当から外しました(<理由>)` | +| 席を埋めた | `⚠ 使える者が <数> 者のため、席を<相手>で埋めます(観点が減ります)` | + +### 項目が無いときの読み方 + +**この変更の前に始めた実行の状態ファイルは書き換えない。** + +| 項目 | 無いときの読み方 | +| --- | --- | +| `participants` | ホストがあれば、参加の母集合から席を決める(この変更の前の輪番と同じ値)。ホストも無ければ `codex` / `agy` | +| `resume_changes` | 空として読む | +| ラウンドの記録の担当 | 担当の決まる順で引き直す | + +**再開で担当に関わる引数を渡したときだけ、参加者の記録を書く。** 渡さない再開では書き足さない。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 使える者の解決が、通らない者を外して続け、外した者へ確認を呼ばず、名前の矛盾と欠けを例外にすること | `plugins/ndf/scripts/tests/test_lib_participants.py` | +| 認証の確認が失敗で例外を上げず、理由を返すこと | 同 `test_auth_probe.py` | +| 席の埋め方が 3 者で従来の値と一致し、2 / 1 / 0 者で規則どおりに埋めること。席の名前の形 | `plugins/ndf/skills/cross-refactoring/tests/test_assignment.py` | +| 適用の輪番が参加者の数で回ること | `plugins/ndf/scripts/tests/test_lib_assignment.py` | +| 再開の反映が、表のとおりに書き換え・記録・知らせを行い、値が同じなら何もしないこと | 同 `test_lib_resume_args.py` | +| 新規の初期化が、確認を通らない者がいても状態ファイルを作り、全員を要する指定では作らないこと | `plugins/ndf/skills/cross-review/tests/test_state_review_pool.py` | +| 完了報告が参加者の節を出し、記録が無ければ「記録なし」と出すこと | 同 | +| 再開が渡した引数だけを反映し、渡さなかった引数を状態ファイルの値で補うこと | 同 `test_state_resume_args.py` | +| 席の名前が結果の受け口・起動・監視・計測を通ること | 同 `test_seat_names.py` | +| 前のラウンドの検査が、そのラウンドの担当で結果を数えること | 同 `test_state_round_guard.py` | +| 手順書が 1 者指定のシェル変数で担当を絞り直さないこと | 同 `test_skill_layout.py` | +| 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py --root .` | +| 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py --root .` | + +## 関連リンク + +- [issue #727](https://github.com/devbasex/ai-plugins/issues/727) — 使える者から担当を割り当てる共通層 +- [issue #687](https://github.com/devbasex/ai-plugins/issues/687) — 各ラウンドで 2 者を確保する規則と置き場所 +- [issue #478](https://github.com/devbasex/ai-plugins/issues/478) — 認証の確認を関門から把握へ +- [issue #648](https://github.com/devbasex/ai-plugins/issues/648) — 再開で渡した引数が無視される +- [PR #793](https://github.com/devbasex/ai-plugins/pull/793) — 実装 +- [`cross-review` のレビュワーの母集合と終了基準](../../plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md) +- [`cross-review` の状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) +- [`cross-review` の状態とレビューの手順](../../plugins/ndf/skills/cross-review/docs/01-state-and-review.md) +- [`cross-review` の手順](../../plugins/ndf/skills/cross-review/SKILL.md) +- [証拠ベースのレビューと効果の測定](cross-review-evidence-based.md) — 指摘の構造化・実行検証・反証と、状態ファイルのそれ以外の鍵 +- [起動 1 回の結末と上限の検知](cross-review-launch-outcome.md) — 結果なしの理由の語彙と起動し直しの可否 diff --git a/docs/specifications/cross-review-writes-to-conductor.md b/docs/specifications/cross-review-writes-to-conductor.md new file mode 100644 index 00000000..5a0a89ad --- /dev/null +++ b/docs/specifications/cross-review-writes-to-conductor.md @@ -0,0 +1,413 @@ +# cross-review: PR に出ている指摘が記録に残らず、同じ論点が 2 つのスレッドに分かれる → GitHub と git へ書くのはレビューを回す側だけになり、途中で止まっても同じものを二度書かない + +## 目的 + +**GitHub と git へ書くのは、レビューを回す側だけである。** レビューの担当は指摘の控えと +結果ファイルを書いて終わる。修正の担当はコミットまでで止まる。投稿・返信・スレッドの決着・ +修正のまとめ・修正の送信は、結果ファイルを取り込む側が行う。 + +**書き込みと記録が同じ手順の中で続けて起きる。** 担当が途中で止まっても、PR には指摘が +出ているのに記録には無い状態ができない。取り込みが途中で止まっても、やり直せば同じものを +二度書かない。 + +**修正を送ったという記録は、送り先のブランチの実物と一致する。** + +**投稿の本文は、取り込みのプロセスの中だけを通る。** 収束ループを駆動している側の応答に +載るのは、件数・参照・状態だけである。 + +**手順と表は +[`cross-review` の SKILL.md](../../plugins/ndf/skills/cross-review/SKILL.md)と +[`docs/`](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)が正である。** +投稿の種別ごとの照合の鍵は +[状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)が持つ。 +この文書が扱うのは、そこに書かない決定の理由と、共通層の契約である。 + +## 用語 + +本文は左の業務用語で書く。識別子は表・コードブロック・業務用語の初出の括弧書きにだけ置く。 +担当・席・結末の語は +[起動 1 回の結末](cross-review-launch-outcome.md#用語)と +[使える者だけで始める収束ループ](cross-review-participants-and-seats.md#用語)と同じ意味で使う。 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| レビューを回す側 | `state.py` と、それを呼ぶ `SKILL.md` の骨組み | 担当を起動し、結果を取り込み、判定する側 | +| 担当 | `launch-reviewer.sh` が起動する CLI | 1 ラウンドで 1 席ぶんのレビューを行う | +| 修正の担当 | `/ndf:fix` を実行するサブエージェント | 指摘を直してコミットする | +| 結果ファイル | `<席>-review-pr<番号>-result.json` | 担当の判定の要約 | +| 指摘の控え | `<席>-review-pr<番号>-round-payload.json` | 担当が出した指摘の全件と総評 | +| 修正の結果ファイル | `fix-pr<番号>-result.json` / `sweep-pr<番号>-result.json` | 修正の担当が書く戻り値 | +| 投稿の待ち行列 | `plugins/ndf/scripts/lib/post_queue.py` | 送る前に積み、上限で送れなければ残す仕組み | +| 投稿を組み立てる層 | `plugins/ndf/scripts/lib/result_posts.py` | 結果ファイルから投稿を組み立て、送る共通層 | +| 指摘の取り込み | `state.py read-result` | 控えを読んでレビューを投稿し、記録する | +| 修正の取り込み | `state.py merge-fix` | 修正を送り、返信・決着・まとめを投稿し、記録する | +| 単独の修正の口 | `result_posts.py fix` | 修正を単独で行うときと最終スイープで呼ぶ部分命令 | +| 先客 | — | 送ろうとした投稿と同じものとして、すでに PR にある投稿 | +| 総評 | レビューの本文(`body`) | インラインではなく、レビュー本体に書く文章 | + +## 対象範囲 + +| 扱う | 扱わない | +| --- | --- | +| レビューの投稿(総評とインライン) | PR の巻き直しの締め・作り直し・再開(もとから回す側が行い、待ち行列に積まない) | +| 指摘への返信・スレッドの決着・修正のまとめ | 反証(もとから投稿せず、ファイルへ書くだけ) | +| 修正の送信と、送り先に載ったことの確認 | 収束の判定・指摘の区分と数え方([証拠ベースのレビュー](cross-review-evidence-based.md)) | +| 起動し直した担当を初回と同じ経路へ通すこと | 結末の語彙と起動し直しの可否([起動 1 回の結末](cross-review-launch-outcome.md)) | +| 修正を単独で行うときの書き込み | 席の決め方([使える者だけで始める収束ループ](cross-review-participants-and-seats.md)) | +| — | 単発の `/ndf:pr-review` が担当に直接投稿させる流れ | + +## 背景 + +**書き込みと記録を別の相手が行っていた。** 担当は GitHub へレビューを投稿し、その後に結果 +ファイルを書いていた。この 2 つの間で担当が止まると、投稿は残り、記録は残らない。回す側は +記録しか読まないため「結果なし」と判定し、同じ担当を同じラウンドで起動し直した。起動し +直した担当は同じ論点をもう一度投稿した(#583)。 + +**実例は PR #578 の round 1 である。** 1 回目の起動がインライン 4 件を投稿し、起動し直した +2 回目が 1 件を投稿した。記録に入ったのは 2 回目の 1 件だけだった。利用者は 1 つの論点に +2 つのスレッドを見て、修正の担当は両方へ返信した。 + +**待ち行列は担当の投稿を通していなかった。** 上限で拒まれた投稿を残して後から流す仕組みは +回す側にあった。担当が自分で送るため、上限に当たった投稿はその場で失われた。 + +**送信の報告を確かめる手段が無かった。** 修正の担当はブランチ名だけを指定して送信していた。 +作業ツリーが切り離された頭で作られていると、この指定は現在の頭を送らないまま終了コード 0 で +終わる。取り込む側はその報告をそのまま記録した。 + +**根本原因は、書き込みの担い手が記録の担い手と別であることにある(#730)。** +書き込みを回す側へ集めると、この 3 つが同じ場所で解ける。 + +## 決定と理由 + +| 決定 | 理由 | +| --- | --- | +| GitHub と git への書き込みを回す側だけが行う | 書き込みと記録が同じ手順で続けば、片方だけが残る状態を作れない | +| 担当の投稿の後に回す側が実物を照合して記録を補う形を採らない | 照合は問い合わせを増やす。問い合わせが上限で失敗すると同じ食い違いが残る | +| 担当に待ち行列へ積ませる形を採らない | 待ち行列は回す側の作業ツリーにある。置き場所を担当へ開くと、巻き直しで捨てる範囲が変わる | +| 投稿は結果ファイルを読んだプロセスの中で組み立てる | 本文を引数や標準入力で渡すと、収束ループを駆動する側の応答に本文が載る | +| 「読んで記録する」と「投稿する」を 1 つの取り込みに閉じる | 分けると「記録したが投稿していない」状態がもう 1 つ増える | +| 送信に成功した項目は記録より先に待ち行列から消える形を変えない | 取り込みをやり直せば同じ状態になる。流し方を変えると、巻き直しのコメントの契約まで変わる | +| 二度書かない照合を投稿の 4 種別すべてに掛ける | 途中で止まった取り込みをやり直しても、どの種別も増えない | +| レビューの照合の鍵に判定の語を含めない | 起動し直して判定が変わると、判定の語を含む鍵は別の投稿と読んで二重に送る | +| レビューの照合をラウンドの開始時刻より後のレビューに限る | ラウンドの番号は実行ごとに 1 から数え直す。回し直した PR では前の実行のレビューに一致する | +| 開始時刻かレビューの時刻を読めないときは、番号と席だけで照合する | 二重に送る側へ倒さない | +| 担当の申告件数と GitHub の実数の突き合わせをやめる | 投稿する側と記録する側が同じになり、確かめる対象が無い | +| 差分の外を指す指摘は、拒まれてから総評へ移す | 投稿の前に判定すると差分の範囲を別に取り寄せる必要があり、失敗の分岐が増える | +| 拒まれた要求のインラインは、1 件ずつでなくすべて総評へ移す | 応答はどのインラインが原因かを指さない(実測) | +| 位置を解決できない拒まれ方だけを退避の契機にする | 判定の値や基準のコミットの誤りまで退避すると、別の不具合が飲み込まれる | +| 修正は現在の頭を指定して送り、送った後に照合する | ブランチ名だけの指定は、切り離された頭で何も送らずに成功する | +| 修正の書き込みを共通層の 1 か所にまとめる | cross-review から呼ぶ経路と単独で使う経路で、担い手と実装が分かれない | +| 単独の口は新しい入口のスクリプトにせず、共通層の部分命令にする | 手順に書く行が 1 本で済み、実装が 1 つになる | +| 担当は一時の名前で書いてから改名する | 読めた状態が書き終えた状態になり、取り込みが書きかけを読まない | +| 自分の PR での判定の格下げは、投稿する側が送信の時点で行う | 格下げるのは送る形だけで、収束の判定が読む本来の判定は落とさない | +| 起動し直しを繰り返しの先頭へ戻す形にする | 経路が 1 本なら、根拠の検証と反証を飛ばす枝が構造として無くなる | + +## 仕様 + +### 常に成り立つ条件 + +| 条件 | 破れたときの扱い | +| --- | --- | +| 担当へ渡すプロンプトは、投稿の手順(投稿の呼び出し・判定の格下げ・インラインの組み立て)を持たない | プロンプトの検査が落ちる | +| 担当が控えを書かずに止まった実行では、PR のレビューは増えない | — | +| 同じラウンド・同じ席のレビューは、この実行の中で 1 件しか PR に出ない | 先客があれば送らず、先客の参照を記録する | +| 取り込みの標準出力に、総評とインラインの本文は出ない | — | +| 記録に入るレビューの参照は、送信の応答か先客から取る | — | +| 修正の記録は、報告されたコミットが送り先のブランチに載ってから書く | 載っていなければ記録も投稿もせずに止まる | + +### 構成要素と責務 + +| 構成要素 | 責務 | +| --- | --- | +| 担当の起動(`launch-reviewer.sh`) | 投稿の手順を持たないプロンプトを作る。前の起動の一時の名前のファイルも消す | +| 指摘の取り込み(`read-result`) | 残りを流し、この担当のレビューを組み立てて送り、応答を記録し、指摘を取り込む | +| 修正の取り込み(`merge-fix`) | 修正を送って照合し、記録し、返信・決着・まとめを送る | +| 投稿を組み立てる層(`result_posts.py`) | 控えと結果ファイルから投稿を組み立てる。退避・格下げ・送信・照合を行う | +| 投稿の待ち行列(`post_queue.py`) | 送る前に先客を照合する。拒まれ方が位置によるものかを見分けて返す | +| 骨組み(`SKILL.md`) | 起動し直しを繰り返しの先頭へ戻す。最終スイープの書き込みを単独の修正の口で行う | + +投稿を組み立てる層が外へ見せる関数は次の 5 つである。**どれも本文を引数に取らず、ファイルの +パスを受け取る。** + +| 関数 | 入力 | 出力 | +| --- | --- | --- | +| `review_posts` | 控えと結果ファイルのパス、リポジトリ、PR、ラウンド、席、頭のコミット、自分の PR か、開始時刻 | 待ち行列へ積む項目の列 | +| `post_review` | 待ち行列と `review_posts` の入力、投稿者 | 送った結果(参照・インライン数・総評へ移した数・残した数・指摘数・失敗・送った形・本来の判定) | +| `fix_posts` | 修正の結果ファイルのパス、リポジトリ、PR、ラウンド | 返信 → 決着 → まとめの順の項目の列 | +| `post_fix` | 待ち行列と `fix_posts` の入力、投稿者 | まとめの参照・返信数・決着数・残した数・失敗 | +| `push_fix` | 作業ツリー、送り先のブランチ、報告されたコミット | 成否・送ったか・載っているか・説明 | + +### 担当が書くもの + +担当は 2 つのファイルを書く。**どちらも一時の名前(末尾 `.tmp`)で書き終えてから、控え → +結果ファイルの順に正式の名前へ改名する。** 結果ファイルが正式の名前で現れたことが、2 つとも +書き終えた印になる。 + +| 状態 | 取り込みの扱い | +| --- | --- | +| 結果ファイルが正式の名前である | 控えを読んで投稿する | +| 控えだけが正式の名前で、結果ファイルが無い | 結果なし。投稿は 0 件 | +| どちらも正式の名前に無い | 結果なし。投稿は 0 件 | + +担当が書く項目と、投稿する側が埋める項目は次のとおりである。 + +| ファイル | 担当が書く | 投稿する側が埋める | +| --- | --- | --- | +| 結果ファイル | 本来の判定(`event`)、重要度ごとの件数(`by_severity`) | —(記録の側に書く) | +| 指摘の控え | 総評(`summary`)、指摘の全件(`comments[]`) | 各指摘の送れた先(`posted_to` の `inline` / `body`) | + +指摘の各項目が持つ値は +[状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md)にある。 + +### 指摘の取り込み + +取り込み 1 回の中を、次の順に進める。 + +1. 待ち行列の残りを流す。残りは前の取り込みが上限で送れなかった投稿で、その担当の記録は + 積んだ時点で書いてある +2. 控えと結果ファイルから、この担当のレビューを 1 件組み立てて積む +3. 流す +4. 位置を解決できずに拒まれたら、インラインをすべて総評へ移して積み直し、もう 1 度流す +5. 送れたら、控えへ送れた先を書き戻す +6. 送信の応答を記録へ書き、指摘を取り込む + +```mermaid +sequenceDiagram + participant M as レビューを回す側 + participant A as 担当 + participant G as GitHub + M->>A: 起動(投稿の手順を持たないプロンプト) + A->>A: 控えと結果ファイルを書いて改名する + A-->>M: 終了 + M->>M: 控えを読み、レビューを待ち行列へ積む + M->>G: 先客を照合し、いなければ送る + G-->>M: レビューの参照 + M->>M: 参照と件数を記録し、指摘を取り込む +``` + +**組み立てるレビューの本文の先頭行は、ラウンド・席・本来の判定を持つ。** + +```text +## 🤖 cross-review | round | <席> | <本来の判定> +``` + +**位置を持つ指摘はインラインに、持たない指摘は総評に入る。** 位置を持つとは、ファイルが +あり、行が正の整数として読めることである。行が `"L42"` や `"40-45"` の形なら総評へ回す。 + +### 差分の外を指す指摘の退避 + +GitHub はレビューの作成を**要求ごとに全件拒む**。差分の外を指すインラインが 1 件あると、 +正しいインラインも総評も作られない。応答の誤りは語をつないだ 1 つの文字列で、どの +インラインが原因かを指さない。 + +| 送ったもの | 応答の誤り | 扱い | +| --- | --- | --- | +| 差分に無いファイルのインライン | `Path could not be resolved` | 総評へ移して送り直す | +| 差分にあるファイルの、塊の外の行のインライン | `Line could not be resolved` | 同上 | +| 判定の値に知らない語 | `Variable $event ... was provided invalid value` | 失敗として止まる | +| 基準のコミットに存在しない値 | `The commitOID is not part of the pull request` | 同上 | + +**退避の契機は、HTTP 422 で応答が `could not be resolved` を含むときだけである。** +**退避するのは、この取り込みが積んだ項目が拒まれたときだけである。** 先に積まれていた項目の +拒まれ方を取り違えると、先の項目を消して同じレビューを二重に積む。 + +総評へ移した指摘は、総評の末尾の見出し「差分の外を指す指摘」の下に、位置と重要度を添えて +並ぶ。1 ラウンドの指摘は多くて 10 件前後で、すべて移しても総評の上限(65536 文字)に収まる。 + +### 自分の PR での判定の格下げ + +GitHub は自分の PR への変更を求めるレビューを拒む。**投稿する側は、自分の PR のとき送る形 +だけを `COMMENT` へ落とす。** 本来の判定は本文の先頭行と記録の `intent` に残る。収束の判定は +`intent` を読むため、格下げがループの続き方を変えない。 + +### 二度書かない照合 + +待ち行列は送る前に、同じものが PR に先にあるかを照合する。**先客がいれば送らず、先客を +送信の応答の代わりに返す。** 呼び出し側は、送った場合と同じ経路で参照を記録する。 + +| 種別 | 照合の要点 | +| --- | --- | +| レビュー | 投稿者と、先頭行の席までの前方一致。判定の語を含めない。ラウンドの開始時刻より後のレビューに限る | +| 返信 | 返信先の指摘と、本文の先頭 80 文字 | +| スレッドの決着 | スレッドがすでに決着しているか | +| PR へのコメント(修正のまとめ) | 投稿者と、本文の先頭 80 文字。先頭行がラウンドとコミットを持つ | + +**照合を確かめられないときは送る側へ倒す。** 確かめられないのは GitHub へ届いていないとき +であり、送っても同じ失敗で積まれ直す。 + +**投稿者は、どの席でも同じ 1 つのアカウントである。** 作業環境の `gh` は 1 アカウントを +持ち、席ごとに認証を切り替える口は配布物に無い。そのため席の区別は先頭行が担う。 + +**すでに決着したスレッドをもう一度決着させても失敗しない(実測)。** 照合が送信を止め +損ねても、二重の決着は誤りにならない。 + +### 修正の取り込みと送信 + +修正の担当はコミットまでを行い、修正の結果ファイルを書く。取り込みは次の順に進む。 + +1. 現在の頭を送り先のブランチへ送る(`git push origin HEAD:<ブランチ名>`)。認証で落ちた + ときは、共通層の退避の値で 1 度だけやり直す +2. 送り先を取り寄せ、報告されたコミットがその履歴に含まれることを確かめる +3. 修正を記録する +4. 返信 → 決着 → まとめの順に積んで流す +5. まとめの参照を記録へ書き戻す + +**1 か 2 で失敗したら、記録も投稿もせずに止まる。** 同じ取り込みをやり直せば、同じ手順を +最初から通る。報告されたコミットが無いときは送らずに進む。 + +| 修正の結果ファイルの配列 | 送られるもの | +| --- | --- | +| 対応した指摘(`resolved_threads`) | 「対応しました(<コミット>)」の返信と、スレッドの決着 | +| 見送った指摘(`deferred`) | 見送りの理由の返信。項目が決着を求めるときだけ決着する | +| 採らない指摘(`rejected`) | 採らない理由の返信。同上 | +| — | 件数・決着数・CI の状態を載せた修正のまとめ | + +**返信を決着より先に送る。** 決着したスレッドは畳まれ、後から届いた返信を読み手が開かない。 + +### 単独の修正と最終スイープ + +修正を単独で行うときと、ループを抜けた後の最終スイープでは、単独の修正の口を 1 行で呼ぶ。 +**修正の取り込みと同じ関数を通る。** + +```bash +python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <修正の結果ファイル> \ + [--repo <所有者>/<リポジトリ>] [--head <ブランチ名>] [--worktree <作業ツリー>] [--round ] +``` + +| 引数を省いたとき | 決め方 | +| --- | --- | +| リポジトリと送り先のブランチ | 作業ツリーの中で `gh` に問い合わせる | +| 作業ツリー | いまいるディレクトリ | +| 修正の結果ファイル | 一時ディレクトリ(「状態ファイルの鍵」の末尾)の `fix-pr<番号>-result.json` | + +**送り先のブランチを決められないときは、返信へ進まず終了コード 1 で止まる。** 送っていない +修正へ「対応しました」と返信しないためである。出力は次の形で、本文を含まない。 + +```text +PUSHED=1 COMMIT_ON_HEAD=1 +POSTED summary_url=https://github.com/.../pull/<番号>#issuecomment-... +REPLIED=3 RESOLVED=2 QUEUED=0 +``` + +### 途中で止まったときの立て直し + +送信に成功した項目は、記録より先に待ち行列から消える。**立て直しは、待ち行列に項目が残る +ことに頼らない。** + +| 止まった場所 | 待ち行列の項目 | 立て直し | +| --- | --- | --- | +| 担当が控えか結果ファイルを書く前 | 無い | 結果なし。起動し直しても投稿は重ならない | +| 取り込みが送る前(上限で送れない) | 残る | 判定の終了コード 8 の枝が流し直し、参照を記録へ書き戻す | +| 取り込みが送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。照合が先客を見つけ、先客の参照を記録する | + +```mermaid +graph TD + A[取り込みが投稿を送る] --> B{送れたか} + B -->|送れていない| D[待ち行列に残る] + D --> E[判定の 8 の枝が流し直す] + B -->|送れた| C{記録まで済んだか} + C -->|済んだ| J[次の段へ進む] + C -->|止まった| K[取り込みをもう一度呼ぶ] + E --> F{先客がいるか} + K --> F + F -->|いる| G[送らずに
先客の参照を記録する] + F -->|いない| H[送って記録する] +``` + +**送れた後に控えへ送れた先を書けなければ、取り込みは止まる。** 控えは原子的に書くため、 +書きかけは残らない。やり直すと、照合が先客を見つけて同じ参照を記録する。 + +### 起動し直しの経路 + +骨組みのレビューの段は「起動 → 監視 → 取り込み → 根拠の検証 → 反証 → 判定」の繰り返しで +ある。**判定が結果なし(終了コード 7)を返したとき、名前の出た担当だけを入れて繰り返しの +先頭へ戻る。** 起動し直しは同じラウンドで 1 度だけである。 + +| 判定の終了コード | 扱い | +| --- | --- | +| 8(待ち行列に残りがある) | 流してから判定し直す。**7 より先に見る** | +| 7(結果なし)で、まだ起動し直していない | 名前の出た担当で繰り返しの先頭へ戻る | +| それ以外 | 繰り返しを抜ける | + +**経路が 1 本であるため、起動し直した担当の指摘も根拠の検証と反証を通る。** +起動し直せない結末(利用上限)の扱いは[起動 1 回の結末](cross-review-launch-outcome.md)が持つ。 + +## データ・設定 + +### 状態ファイルの鍵 + +ラウンドごとの担当の欄のうち、この仕様が決める鍵は次のとおりである。収束の判定が読む鍵 +(本来の判定・送った形・重要度ごとの件数)は形を変えない。 + +| 鍵 | 何が入るか | +| --- | --- | +| `review_url` | 送信の応答が返した参照。先客が見つかったときは先客の参照 | +| `queued` | 上限で送れず待ち行列に残っているとき真。流した直後に参照を書き戻して偽にする | +| `posted_inline` | インラインとして送れた件数 | +| `posted_body` | 総評へ入れた件数(位置を持たない指摘と、退避した指摘) | +| `comments` | `posted_inline` と同じ値 | +| `posted_as` | 送った形。自分の PR では `COMMENT` | + +修正の記録は、まとめの参照(`summary_comment_url`)を持つ。**投稿済みのレビューを探す鍵は +作らない。** 担当が投稿しないため、探す対象が無い。 + +レビューの照合は、ラウンドの記録が持つ開始時刻(`started_at`)を読む。 + +**待ち行列の置き場所は変えない。** 状態ファイルと同じ一時ディレクトリの `pending/` にあり、 +巻き直しのときは作業ツリーごと捨てられる。単独の修正の口は、一時ディレクトリを環境変数 +`CROSS_REVIEW_TMP_DIR` から、無ければ作業ツリーの `.cross_review/` から決める。 + +### 取り込みの出力と終了コード + +指摘の取り込みは、標準出力へ次の 3 行を出す。本文は出さない。 + +```text +POSTED review_url=https://github.com/.../pull/<番号>#pullrequestreview-... +INLINE=7 BODY=1 QUEUED=0 +FINDINGS=8 +``` + +| 行 | 意味 | +| --- | --- | +| `POSTED` | 送れたときだけ出る。記録した参照 | +| `INLINE` / `BODY` / `QUEUED` | 送れたインライン数・総評へ入れた数・待ち行列に残した数 | +| `FINDINGS` | 取り込んだ指摘の件数。判定が出す新しい指摘の数とは別の値 | + +修正の取り込みは、単独の修正の口と同じ形の行(送信・照合・まとめの参照・返信数・決着数・ +残した数)を出す。 + +**取り込み・判定・報告の終了コードは変えない。** 収束の判定と報告が読む変数 +(`REVIEWER_INTENTS` / `NEW_FINDINGS` / `CARRIED_OVER_THREADS` / `PENDING_POSTS`)も変えない。 +送れない・控えを書けない・修正が送り先に載らないときは、終了コード 1 で止まる。上限で送れなかったときは +止まらず、待ち行列に残して判定の 8 の枝へ渡す。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 担当のプロンプトに投稿の手順が無く、2 つのファイルを一時の名前で書いて控え → 結果ファイルの順に改名させること | `plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py` | +| 取り込みがレビューを送って応答を記録し、標準出力に本文を出さないこと | 同 `tests/test_read_result_posts.py` | +| 控えだけの状態と、何も書かなかった担当で、投稿が 0 件であること | 同上 | +| 位置を持つ指摘が 0 件でも、指摘があれば結果なしにならないこと | 同上 | +| 取り込みを 2 度呼んでもレビューが増えないこと | 同上 | +| 自分の PR で、送る形だけが `COMMENT` へ落ち、本来の判定が残ること | 同上と `plugins/ndf/scripts/tests/test_result_posts.py` | +| 位置を解決できない拒まれ方だけで総評へ移し、他の 422 と先の項目の拒まれ方では移さないこと | `plugins/ndf/scripts/tests/test_result_posts.py` / `test_post_queue.py` | +| 回し直した実行で、前の実行の同じラウンド・同じ席のレビューを先客と取り違えないこと | `plugins/ndf/scripts/tests/test_result_posts.py` | +| 控えへ送れた先を書けないとき、控えを壊さず取り込みを止めること | 同上と `plugins/ndf/scripts/tests/test_statefile_emit.py` | +| 返信・決着・まとめが 4 種別の照合を通り、2 度積んでも増えないこと | `plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py` / `plugins/ndf/scripts/tests/test_result_posts.py` | +| 修正を現在の頭で送り、報告されたコミットが送り先に無ければ止まること | 同 `test_result_posts.py` と `plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py` | +| 単独の修正の口が同じ層を使い、送り先のブランチを決められないとき返信へ進まないこと | `plugins/ndf/scripts/tests/test_result_posts.py` | +| 骨組みの起動し直しが繰り返しの先頭へ戻り、8 の枝を 7 より先に見ること | `plugins/ndf/skills/cross-review/tests/test_skill_layout.py` | +| 文書が投稿の担い手と種別ごとの契約を書いていること | `plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py` | + +## 関連リンク + +- [issue #730](https://github.com/devbasex/ai-plugins/issues/730) — GitHub と git への書き込みを回す側へ移す +- [issue #583](https://github.com/devbasex/ai-plugins/issues/583) — 投稿済みで記録なしの状態と、同じ論点の重なり +- [PR #801](https://github.com/devbasex/ai-plugins/pull/801) — 実装 +- [起動 1 回の結末](cross-review-launch-outcome.md) — 結果なしの理由と起動し直しの可否 +- [証拠ベースのレビューと効果の測定](cross-review-evidence-based.md) — 指摘の構造化・区分・収束の判定 +- [使える者だけで始める収束ループ](cross-review-participants-and-seats.md) — 席の名前と決め方 +- [`cross-review` の状態ファイルと入出力の契約](../../plugins/ndf/skills/cross-review/docs/04-contracts.md) +- [`cross-review` の修正と巻き直し](../../plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md) +- [`fix` の手順](../../plugins/ndf/skills/fix/SKILL.md) diff --git a/docs/specifications/test-monitor-env-isolation.md b/docs/specifications/test-monitor-env-isolation.md new file mode 100644 index 00000000..ade97f37 --- /dev/null +++ b/docs/specifications/test-monitor-env-isolation.md @@ -0,0 +1,73 @@ +# テストの前提: 監視の上限を環境変数で延ばしたシェルでは既定値を前提にするテストが落ち、収束ループの初期化が止まる → テストの実行中は監視の環境変数が共通の前提で外れ、どのシェルから起動しても同じ結果になる + +## 目的 + +**テストの実行中だけ、監視の上限を指す環境変数を利用者の環境から切り離す。** 上限を延ばした +シェルから全体のテストを起動しても、延ばしていないシェルと同じ件数が通る。 + +**切り離しはリポジトリの根の共通の前提(`conftest.py`)の 1 か所が持つ。** テストごとに +同じ除去を書かない。 + +## 用語 + +| 業務用語 | 識別子 | 何を指すか | +| --- | --- | --- | +| 監視の環境変数 | 接頭辞 `MONITOR_` を持つ環境変数(`MONITOR_STALL_AGY` / `MONITOR_TIMEOUT` など) | 無進捗の許容と打ち切りの上限を、担当ごと・共通に延ばす指定 | +| 共通の前提 | リポジトリの根の `conftest.py` | 全体のテストに共通する前提を 1 か所で用意するファイル | +| 根の設定ファイル | リポジトリの根の `pytest.ini` | テストの基準のディレクトリ(rootdir)を根へ固定するための空の設定 | + +## 背景 + +**上限は環境変数で延ばせる。** 運用で延ばしたシェルから全体のテストを起動すると、表の既定値を +前提にするテストが延ばした値を読んで落ちた。原因はテストの実行環境にあり、変更の中身には無い。 + +2026-09-21 の実測では、監視の環境変数を 2 つ設定したシェルで 3 件が落ち、設定していない +シェルでは 0 件だった(収集は 4603 件で同じ)。 + +**収束ループの初期化は、着手前のテストが通ることを条件にする。** そのため上限を延ばした +シェルでは、cross-refactoring の初期化がそこで止まった。 + +## 仕様 + +| 項目 | 振る舞い | +| --- | --- | +| 外す対象 | 名前が接頭辞 `MONITOR_` で始まる環境変数すべて | +| 外す時点 | テストの収集より前(`pytest_configure`) | +| 戻す時点 | テストの実行が終わったとき(`pytest_unconfigure`)。外した値をそのまま戻す | +| 子プロセス | 環境変数を受け継ぐため、テストが起動する別プロセスにも切り離しが効く | +| テストが自分で設定する値 | 打ち消さない。`monkeypatch.setenv` も、別プロセスへ渡す上書きも、切り離しの後に効く | +| 読まれる範囲 | どの束のディレクトリを起点にしても読まれる。根の設定ファイルが基準のディレクトリを根へ固定する | + +**名前を並べず、接頭辞だけで一致させる。** 上限の種類が増えるたびに一覧へ足し忘れる形を +作らないためである。 + +**収集より前に外す。** テストの本体を読み込む時点で上限を決める実装があり、セッションの前提 +(fixture)では間に合わない。 + +**根の設定ファイルは設定値を持たない。** pytest は起点から上へ設定ファイルを探し、見つかった +ところを基準にする。根に 1 つも無いと、束のディレクトリを起点にした実行では共通の前提が +読まれない。`testpaths` などを書くと、既存の実行が対象にする範囲が変わる。 + +**変えないもの。** 上限の解決順(起動時の明示の指定(`--timeout` / `--stall-timeout`)→ 担当ごとの +環境変数 → 共通の環境変数 → 表の既定)、上限の表の値、 +監視の本体と起動スクリプトの振る舞いは変えない。変わるのはテストの前提だけである。 + +## テスト観点 + +| 観点 | 確かめ方 | +| --- | --- | +| 監視の環境変数を設定したシェルでも、全体のテストの失敗が 0 件で、設定しないシェルと件数が一致すること | `MONITOR_TIMEOUT_AGY=1800 MONITOR_STALL_AGY=1800 uv run --with pytest pytest scripts/tests plugins/ndf -q` と、設定しない同じコマンドを比べる | +| 実行中のテストに接頭辞 `MONITOR_` の環境変数が 1 つも残らないこと | `scripts/tests/test_root_conftest.py` | +| テストが自分で設定した値は観測できること | 同上 | +| 別プロセスへ受け継がれないこと | 同上 | +| 実行が終わった後に元の値が戻ること | 同上 | +| 束のディレクトリを起点にした実行でも切り離しが効くこと | 同上 | +| テストごとの同じ除去が残っていないこと | `grep -rn "MONITOR_" --include="*.py"` で、接頭辞での除去と 1 変数ずつの除去がテストに無いことを見る | + +## 関連リンク + +- [issue #678](https://github.com/devbasex/ai-plugins/issues/678) — 上限を延ばしたシェルでテストが落ちる +- [PR #797](https://github.com/devbasex/ai-plugins/pull/797) — 実装 +- [起動 1 回の結末](cross-review-launch-outcome.md) — 監視の上限と結末の語彙 +- [共通の前提](../../conftest.py) +- [根の設定ファイル](../../pytest.ini) diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 8f1bc8df..00b9a752 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,15 +57,15 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.15.1` | 利用者が常用してよい | -| 開発版 | `10.16.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | -| 公開前の確認版 | `10.16.0-rc.1` | 正式版の候補。残るのは確認だけ | +| 正式版 | `10.16.0` | 利用者が常用してよい | +| 開発版 | `10.17.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | +| 公開前の確認版 | `10.17.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.15.1` の次を開発するなら `10.16.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.16.0` の次を開発するなら `10.17.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる -- **正式版を出すときは接尾辞を外す。** `10.16.0-dev.3` の次は `10.16.0` -- 順序は semver に従い `10.16.0-dev.1` < `10.16.0-rc.1` < `10.16.0` になる +- **正式版を出すときは接尾辞を外す。** `10.17.0-dev.3` の次は `10.17.0` +- 順序は semver に従い `10.17.0-dev.1` < `10.17.0-rc.1` < `10.17.0` になる ## ランタイムごとの取得と導入 diff --git a/issues/issue-598-537-limits-plan.md b/issues/issue-598-537-limits-plan.md index 070ceb9f..18cb6840 100644 --- a/issues/issue-598-537-limits-plan.md +++ b/issues/issue-598-537-limits-plan.md @@ -5,7 +5,7 @@ - 要求と受け入れ条件: [issue-662-598-537-619-584-583-requirements.md](issue-662-598-537-619-584-583-requirements.md)(P2 は AC30〜AC42 と AC70〜AC72) - 設計文書: [issue-662-598-537-619-584-583-design.md](issue-662-598-537-619-584-583-design.md)(P2 は決定 10〜13。決定 10 の cross-refactoring の例外を含む) - 契約の文書: [issue-662-598-537-619-584-583-design-contracts.md](issue-662-598-537-619-584-583-design-contracts.md)(「上限の表(P2)」「`limits.py`(P2)」「`launch-cli.sh` の第 7 引数(P2)」「`bg-wait.sh`(P2)」) -- 境界: [issue-647-592-553-design.md](issue-647-592-553-design.md) の決定 13 と「他の設計との契約」の D-A(P2)の行 +- 境界: #647 #592 #553 の設計(決定 13 と「他の設計との契約」の D-A(P2)の行。確定仕様は [適用の取り込み](../docs/specifications/cross-refactoring-apply-intake.md)) - 前段: P1(#662、PR #677、develop の d11c473) - 課題: #598 / #537(マイルストーン 21) diff --git a/issues/issue-624-478-648-contracts.md b/issues/issue-624-478-648-contracts.md deleted file mode 100644 index ffb784ce..00000000 --- a/issues/issue-624-478-648-contracts.md +++ /dev/null @@ -1,199 +0,0 @@ -# #624 / #478 / #648: 状態ファイル・引数・関数の契約 - -[issue-624-478-648-design.md](issue-624-478-648-design.md) の続きである。決定の理由は設計文書の「決定の記録」にあり、 -この文書は形だけを書く。**P4 は状態ファイルの形も引数も変えない。** P4 が変えるのは区分の条件(設計文書の決定 3)と -印の外し方(決定 5)で、「`state.py` の内部関数」の表の末尾 2 行と「変わらない項目の意味の変化」の先頭 2 行に -当たる。それ以外の契約は P5 のものである。 - -## データ構造(状態ファイル) - -### 増える項目 - -状態ファイル `cross-review-pr<番号>-state.json` の最上位に 6 項目が増える。**`version` の類は持たないため上げない。** -項目が無い状態ファイルは、この変更の前に始めた実行として読む(下の「移行」)。 - -| 項目 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `available_reviewers` | 文字列の配列 | 許さない(項目が無いことは許す) | 使える者。母集合(`review_pool(host)`)の順に並ぶ。`only` があれば `[only]`。項目が無いのは「この変更の前に始めた実行」で、「使える者が 0 者」ではない(0 者の状態ファイルは作らない) | -| `excluded_reviewers` | 文字列の配列 | 許す(空の配列) | `--exclude` で外した者。母集合の順。空は「外していない」 | -| `unavailable_reviewers` | オブジェクト(名前 → 理由の文字列) | 許す(空のオブジェクト) | 認証を通らなかった者と、`probe_auth` の `detail`。空は「全員が通った」か「確認を飛ばした」 | -| `require_all` | 真偽値 | 許さない | `--require-all` の値。新規の既定は `false` | -| `auth_skipped` | 真偽値 | 許さない | `NDF_SKIP_AUTH_CHECK` で確認を飛ばしたか。`unavailable_reviewers` が空である理由を区別する | -| `resume_changes` | オブジェクトの配列 | 許す(空の配列) | 再開で変えた値の記録。追記だけを行う | - -`resume_changes[]` の要素: - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `at` | 文字列(ISO 8601) | 再開した時刻(`_now()`) | -| `field` | 文字列 | 変えた項目の名前(`max_rounds` / `rotate_after` / `only` / `verify_commands` / `verify_exit_codes` / `excluded_reviewers` / `available_reviewers` / `unavailable_reviewers` / `require_all` / `auth_skipped`)。値が変わった項目だけを積む | -| `from` | 任意 | 変える前の値。項目が無かったときは `null` | -| `to` | 任意 | 変えた後の値 | - -### 変わらない項目の意味の変化 - -| 項目 | 変わること | -| --- | --- | -| `evidence_rounds` | P4 から、反証が揃わない取り込みで番号が**外れる**ことがある(決定 5)。**付く条件は変わらない** | -| `review_findings[].classification` | P4 から、`critiques` が空で単独の根拠付き `major` 以上が `needs_human_judgment` になる(決定 3) | -| `only` | P5 から、再開の `--only` で変わる。`--only none` で `null` へ戻る | -| `max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` | P5 から、再開で明示的に渡したときだけ変わる | -| `rounds[].reviewers` | 変わらない。**過去のラウンドの担当はこの記録が持ち、再開で書き換えない** | - -### 実体の関係 - -```mermaid -erDiagram - 状態ファイル ||--o{ ラウンド : rounds - 状態ファイル ||--o{ 再開で変えた値 : resume_changes - 状態ファイル ||--o{ 指摘 : review_findings - 指摘 ||--o{ 反証 : critiques - 状態ファイル { - string host - string only - array available_reviewers - array excluded_reviewers - object unavailable_reviewers - array evidence_rounds - } - ラウンド { - int round - array reviewers - } -``` - -### 機能とデータの対応 - -| 機能 | `available_reviewers` / `excluded_reviewers` / `unavailable_reviewers` | `only` / `max_rounds` など | `resume_changes` | `rounds[].reviewers` | `evidence_rounds` | `review_findings[].classification` | -| --- | --- | --- | --- | --- | --- | --- | -| F1 数える | — | — | — | R | R | U | -| F2 印を外す | — | — | — | R | U | — | -| F3〜F5 新規の `init` | C | C | C(空) | — | — | — | -| F5 `start-round` | R | R | — | C | — | — | -| F6 再開の `init` | U | U | U(追記) | R | — | — | -| F7 `report` | R | R | R | R | — | — | - -### 時系列の扱い - -`available_reviewers` などの値は上書きし、過去の値は `resume_changes` に事象として積む(決定 17)。ラウンドごとに -誰が担当したかは `rounds[].reviewers` が持つため、上書きで失われるのは「どの時点でどの一覧だったか」だけで、 -それを `resume_changes` が補う。 - -### 移行 - -**既存の状態ファイルは書き換えない。** 項目が無いときの読み方を決める。 - -| 項目が無いとき | 読み方 | -| --- | --- | -| `available_reviewers` | `host` があれば `review_pool(host)` の輪番(変更前と同じ)。`host` も無ければ `codex` / `agy` | -| `excluded_reviewers` / `unavailable_reviewers` / `resume_changes` | 空として読む | -| `require_all` / `auth_skipped` | `false` として読む | - -再開で担当に関わる引数を渡したときだけ、使える者を作り直して項目を書く(決定 15)。渡さない再開では書き足さない。 - -## 入出力の契約 - -### `state.py init` の引数 - -| 引数 | 型 | 既定(argparse) | 新規の経路 | 再開の経路 | 変更 | -| --- | --- | --- | --- | --- | --- | -| `pr` | 整数 | 必須 | — | — | 変わらない | -| `--max-rounds N` | 整数 | `None` | 無ければ 12 | 渡せば反映 | 既定を `None` へ | -| `--rotate-after K` | 整数 | `None` | 無ければ 8 | 渡せば反映 | 既定を `None` へ | -| `--only RUNTIME` | `claude`/`codex`/`agy`/`kiro`/`none` | `None` | `none` は無しと同じ | 渡せば反映。`none` で `null` | `none` を追加 | -| `--exclude NAMES` | カンマ区切りの名前。繰り返し可 | `None` | 外す | 渡せば置き換え。`none` で空 | **新設** | -| `--require-all` / `--no-require-all` | 真偽値 | `None` | 無ければ `false` | 渡せば反映 | **新設** | -| `--host RUNTIME` | 4 つの名前 | `None` | 無ければ推定 | 反映しない。違えば 1 行 | 再開での知らせを追加 | -| `--verify-command CMD` | 文字列。繰り返し可 | `None` | 無ければ空 | 渡せば置き換え | 再開で反映 | -| `--verify-exit-code N` | 整数。繰り返し可 | `None` | 無ければ空(判定側の既定 1) | 渡せば置き換え | 再開で反映 | -| `--worktree` / `--focus` / `--extra-instructions-file` | — | — | — | — | 変わらない | - -**`--exclude` の値の検査は 2 段に分かれる。** 名前の綴り(4 つの名前か `none`)は argparse の型が終了コード 2 で -弾く。母集合に含まれるか(ホスト自身でないか)は `init` がホストを確定した後に確かめ、終了コード 1 で弾く。 -`none` と他の名前を同時に渡したら終了コード 1 で弾く。 - -**再開で担当に関わる引数の一部だけを渡したとき、渡さなかった側は状態ファイルの値を使う。** 例えば `excluded_reviewers` が -`["agy"]` の状態ファイルへ `--only agy` だけを渡すと、状態ファイルの除外と矛盾するため終了コード 1 で弾く。 - -### `state.py init` の出力と終了コード - -標準出力の `KEY=VALUE`(`_print_init_result`)は変えない。**増えるのは標準エラーの行だけである。** - -| 場面 | 標準エラーに出るもの | 終了コード | 状態ファイル | -| --- | --- | --- | --- | -| 新規で全員が使える | 母集合と使える者の 1 行(現行の「ホスト / レビュワーの母集合」の行に続ける) | 0 | 作る | -| 新規で認証を通らない者がいる | 認証を通らなかった者と理由を 1 者 1 行 | 0 | 作る | -| 新規で使える者が 1 者 | 観点が 1 つになる警告 1 行 | 0 | 作る | -| 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`) | 飛ばしたことを 1 行(`auth.py` の既存の文言) | 0 | 作る(`auth_skipped: true`) | -| 新規で使える者が 0 者 | 使える者がいない理由 | 1 | 作らない | -| `--require-all` で欠けがある | 変更前と同じ「認証されていない CLI があります」の文言 | 1 | 作らない | -| `--exclude` が母集合の外 / `--only` と矛盾 / `none` と名前の混在 | 何が矛盾したか | 1 | 作らない | -| 再開で引数を反映した | 反映した項目ごとに `<項目>: <旧> → <新>` の 1 行 | 0 | 書き換える | -| 再開で `--host` が状態と違う | 反映しないことを 1 行 | 0 | `host` は変えない | -| 再開で作り直した使える者が 0 者 / 欠けあり | 新規と同じ | 1 | 書き換えない | - -### `state.py report` の出力 - -現行の「PR 履歴」の後に、次の節を足す。 - -```text -## 参加した者 -- 使える者: codex / kiro -- --exclude で外した者: agy -- 認証を通らなかった者: なし -- 再開で変えた値: 2026-09-15T12:00:00 excluded_reviewers [] → ["agy"] -``` - -項目が無い状態ファイル(この変更の前に始めた実行)では「使える者: 記録なし」と出す。`auth_skipped` が真のときは、 -3 行目を「認証を通らなかった者: 確認を飛ばした(`NDF_SKIP_AUTH_CHECK`)」と出す。 - -### 共通層の関数(`lib/assignment.py`) - -| 関数 | 入力 | 出力 | 失敗の形 | 変更 | -| --- | --- | --- | --- | --- | -| `review_pool(host)` | ホスト名 | 母集合 3 者 | ホストでない名前で `AssignmentError` | 変わらない | -| `review_candidates(host, excluded)` | ホスト名、外す名前の集合 | 母集合から外した一覧(母集合の順) | 母集合に無い名前を含むと `AssignmentError`(名前を並べる) | **新設** | -| `review_assign(round_no, available)` | ラウンド番号、使える者の一覧(`list` / `tuple`) | 担当(3 者以上は 1 者を外す、2 者以下はそのまま) | `round_no < 1`、空の一覧、文字列を渡したときに `AssignmentError` | **第 2 引数をホスト名から一覧へ変える** | -| `assign(round_no, host)` | — | — | — | 変わらない | - -**`review_assign` の呼び出し側は `state.py` の `_round_reviewers` だけである。** `git grep -n review_assign` で -他に当たるのは `cross-refactoring` のテスト、`docs/05` の説明、`issues/old/` の記録だけである。文字列を弾くのは、変更前の呼び方(`review_assign(1, "claude")`)が -黙って 1 文字ずつの一覧として通るのを防ぐためである。 - -### 共通層の関数(`lib/auth.py`) - -| 関数 | 入力 | 出力 | 失敗の形 | 変更 | -| --- | --- | --- | --- | --- | -| `probe_auth(runtimes, *, info, env=None)` | 確かめる名前の一覧 | `(結果, 飛ばしたか)`。結果は名前 → `{"command", "ok", "detail"}` | 例外を上げない。コマンドが無い・時間切れは `ok: false` と `detail` | **新設** | -| `check_auth(runtimes, *, info, die, env=None)` | 変わらない | 変わらない(飛ばしたときは `{}`) | 1 件でも失敗すれば `die` を呼ぶ | **振る舞いを変えない**(中で `probe_auth` を使ってよい) | - -### `state.py` の内部関数 - -| 関数 | 契約 | 変更 | -| --- | --- | --- | -| `_resolve_reviewers(host, only, excluded, require_all)` | 設計文書の「使える者の解決」の 5 手順を行い、`{"available_reviewers", "excluded_reviewers", "unavailable_reviewers", "auth_skipped"}` を返す。失敗は `die(code=1)` | **新設**(`_auth_targets` を置き換える) | -| `_validate_only(only, host, excluded)` | `only` が母集合に無い、または `excluded` に含まれるとき `die` | 引数を足す | -| `_round_reviewers(st, round_no)` | 設計文書の決定 12 の順で返す | 順を変える | -| `_resume_from_state(pr, repo, worktree, manual_extra_review, args)` | 決定 13〜17 の反映を行う。反映が失敗したら状態ファイルを書き換えずに終了コード 1 | 引数を足す | -| `_apply_resume_args(st, args)` | 渡された引数を `st` へ書き、`resume_changes` に積み、出す行の一覧を返す。書き込みは呼び出し側が 1 回で行う | **新設** | -| `_guard_previous_round(st, prev)` | `_no_result_agents` と `_round_passes` に `prev["reviewers"]`(無ければ `_round_reviewers(st, prev["round"])`)を渡す | 担当を渡す | -| `_classify_finding(finding)` | 順 4 に「`critiques` が空」を足す(P4) | 条件を足す | -| `_handle_incomplete_critiques(pr, st, round_no, missing)` | 先頭でそのラウンドの印を外す(P4) | 印を外す | - -### 手順書の骨組み(`SKILL.md` / `docs/01`) - -```bash -INIT_VARS=$("$SCRIPTS/state.py" init "$STATE_PR" \ - ${MAX_ROUNDS:+--max-rounds "$MAX_ROUNDS"} ${ROTATE_AFTER:+--rotate-after "$ROTATE_AFTER"} \ - ${HOST:+--host "$HOST"} \ - ${ONLY:+--only "$ONLY"} ${EXCLUDE:+--exclude "$EXCLUDE"} \ - ...) || exit $? - -for r in $REVIEWERS; do "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND"; done -"$SCRIPTS/monitor.py" "$STATE_PR" --agents "$REVIEWERS_CSV" || true -for r in $REVIEWERS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$r" || true; done -"$SCRIPTS/critique-round.sh" "$STATE_PR" "$ROUND" $REVIEWERS -``` - -**`--max-rounds` と `--rotate-after` も値があるときだけ渡す。** 現行の骨組みは `"$MAX_ROUNDS"` を常に渡すため、 -再開のたびに利用者が指定していない値で上書きする(決定 13 が防ぎたい形そのもの)。 diff --git a/issues/issue-624-478-648-design.md b/issues/issue-624-478-648-design.md deleted file mode 100644 index bf37c37b..00000000 --- a/issues/issue-624-478-648-design.md +++ /dev/null @@ -1,420 +0,0 @@ -# #624 / #478 / #648: 反証する担当がいない指摘を数え、使える担当だけで回し、再開で引数を反映する - -要求と受け入れ条件は [issue-624-478-648-requirements.md](issue-624-478-648-requirements.md) にある。この文書は -「どう作るか」だけを扱う。 - -**実装は 2 本の Pull Request に分ける**(決定 1)。 - -| Pull Request | 課題 | 中身 | 受け入れ条件 | -| --- | --- | --- | --- | -| P4 | #624、#583 の収束の部分 | 反証を受けていない単独の指摘を数える。反証が揃わない取り込みで印を外す | AC1〜AC9、AC31、AC32 | -| P5 | #478、#648 | 使える者の一覧と `--exclude`。再開で明示的に渡した引数を反映する | AC10〜AC32 | - -全体の実装順は P1 → P2 → P3(いずれも D-A)→ P4 → P5 である。P4 / P5 は P1〜P3 が入った `state.py` の上に載せる。 - -## 機能一覧 - -| # | 機能 | 誰が使うか | Pull Request | -| --- | --- | --- | --- | -| F1 | 反証する担当がいない根拠付きの `major` を、新しい指摘として数える | 1 者で回す利用者、担当が起動し直されたループ | P4 | -| F2 | 反証が揃わなかったラウンドを、全件を数える扱いへ戻す | 収束ループ全般 | P4 | -| F3 | 認証できない担当を外し、使える者でループを始める | CLI の一部が導入・認証されていない利用者 | P5 | -| F4 | `--exclude` で担当を名指しで外す | 打ち切り・利用上限が分かっている担当を避けたい利用者 | P5 | -| F5 | 使える者の数に応じて担当を決める(3 者は輪番、2 者は固定、1 者は警告) | 収束ループ全般 | P5 | -| F6 | 再開で `--max-rounds` などの明示した引数を反映し、反映しない引数を知らせる | 中断したループを進め方を変えて再開する利用者 | P5 | -| F7 | 完了報告に、参加した者・外した者・認証を通らなかった者(理由つき)・再開で変えた値を出す | 収束の結果を読む人 | P5 | - -## 決定の記録 - -決定 1 は分け方、決定 2〜5 は P4 の数え方、決定 6〜12 は P5 の担当の決め方、決定 13〜19 は P5 の再開と骨組みを扱う。 - -### 決定 1: P4(#624 と #583 の収束の部分)と P5(#478 と #648)に分け、P4 を先に出す - -#478 は使える者が 1 者のときに 1 者で回す分岐を入れる。#624 を直さないまま入れると、その分岐が必ず誤った収束を -踏む。P4 は担当の決め方に依存しないため、先に単独で出せる。#478 と #648 は同じ引数(`--exclude` / `--only`)の -再開での扱いを一緒に決める必要があり、1 本にまとめる。 - -3 本に分ける形は採らない。#648 だけを先に出すと、`--exclude` を足したときに再開の規則をもう一度書き換える。 - -### 決定 2: 数えない判断は「反証を受けた単独の指摘」だけに掛け、判定は指摘の記録から導く - -区分 4 の `support` の条件は「別の担当が独立に確かめたか」を問う。**問えるのは、別の担当が反証を返した指摘だけ -である。** 反証を 1 件も受けていない単独の指摘とは、`critiques` が空で `origin_runtimes` が 1 者の指摘である。担当 1 者のラウンドと、 -反証を取り込んだ後に取り込まれた指摘(#583)に現れる。**状態ファイルに項目を足さない。** 取り込み -(`_collect_review_findings`)は、同じ担当の指摘を新しい要素で置き換える。起動し直した担当の指摘は、必ず反証を -持たない状態で入る。`collect-critiques` は、全対象の反証が揃ったときだけ印を付ける。 -そのため印の付いた 2 者のラウンドでは、単独の指摘はすべて反証を持つ(再現 C)。 - -採らない案と理由: - -- **反証が 0 件のラウンドは絞り込まない(issue の候補 1)。** ラウンド単位では、2 者のうち 1 件だけが後から入った - #583 の形を塞げない -- **担当 1 者なら intent に従う(候補 2)。** 1 者の分岐だけを塞ぎ、2 者の起動し直しの経路が残る -- **指摘へ「反証の対象になった」印を書く(候補 3 の字面どおり)。** 書く経路(取り込み・起動し直し・統合)ごとに - 付け忘れが起き、旧い状態ファイルには印が無い。記録から導けば経路に依存しない - -### 決定 3: 反証を受けていない単独の指摘は、区分 4 の `support` の条件だけを外す - -外すのは担当の構造上満たせない条件だけにする。根拠(`has_evidence`)と重大度(`major` 以上)の条件は残す。 -**同じ指摘の扱いが担当の数で変わらない。** 2 者のラウンドで数えない `minor` は、1 者のラウンドでも数えない。 -区分の順 1〜3(実行検証の再現・棄却、`refute`)はそのまま先に当たる。 - -```text -順 4: has_evidence かつ major 以上 かつ(support が 1 件以上 / origin_runtimes が 2 者以上 / critiques が空) -``` - -`critiques` が空で `origin_runtimes` が 2 者以上の指摘は、元の条件(2 者以上)で既に当たる。そのため条件は -「`critiques` が空」だけを足せば足りる。**2 者の通常のラウンドの判定は変わらない**(決定 3・5 を当てた試作で既存の cross-review の -テスト 773 件が期待値を変えずに通った。落ちた 1 件は複製先が git の作業ツリーでないことによる)。 - -採らない案: **反証を受けていない指摘は全件(`minor` 以下も)数える。** 1 者で回すと `minor` だけの -`REQUEST_CHANGES` でもラウンドが続き、2 者のときと収束の基準が食い違う。1 者でも intent が pass -(`APPROVE` / 重大な指摘の無い `COMMENT`)なら `round_passes` で収束する。この案の利点は `minor` を -数えることだけになる。 - -### 決定 4: 区分の名前を増やさない - -決定 3 で数える指摘は `needs_human_judgment` に入れる。修正の担当が読む意味(根拠を持つ `major`、判断は修正の担当) -は同じである。名前を足すと、`COUNTED_CLASSIFICATIONS` を持つ `state.py` と `measure.py` の 2 か所と、 -`docs/04` / `docs/06` の語彙を揃えて変えることになる。反証を受けていないことは `critiques` が空であることで -後から読める。 - -### 決定 5: 反証が揃わない取り込みでは、先に付いていた印を外す - -`collect-critiques` は揃わないとき印を付けないが、**既に付いている印は外さない**。 -再現 B2 では、取り直しの後も `evidence_rounds` が `[1]` のままだった。そのとき「印を付けないため、このラウンドは全件を数えます」の出力と実際の -数え方が食い違う。`_handle_incomplete_critiques` の先頭でそのラウンドの番号を `evidence_rounds` から除く。 -**印は「直近の取り込みで揃ったか」を表す。** 起動し直しの後に反証を取り直す経路(D-A の P3)でも、揃えば印が戻り、 -揃わなければ全件を数える。 - -採らない案: 決定 3 だけで足りるとして印を残す。出力の文言と `docs/06` の契約(揃わなければ全件)が偽のまま残る。 - -### 決定 6: 認証の確認を関門から把握へ変え、使える者を状態ファイルに持つ - -`init` は母集合から外した者を除いた全員を確かめる。通った者は `available_reviewers` へ、通らなかった者は理由とともに -`unavailable_reviewers` へ書く。**再開のたびに確かめ直さない**(決定 15 の場合を除く)。途中で担当が -入れ替わると、前のラウンドの記録と突き合わせられなくなる。 - -共通層 `lib/auth.py` に止めない確認 `probe_auth` を足す。`check_auth` は `cross-refactoring` が使うため、 -振る舞い(1 件でも失敗すれば `die` を呼ぶ)を変えない(決定 10)。 - -採らない案: 起動の直前にラウンドごとに確かめる。ラウンドごとに最大 3 回の確認(1 回あたり上限 120 秒)が増える。 - -### 決定 7: 外す指定は `--exclude`(繰り返し・カンマ区切り)にする - -外したい理由は「この担当が落ちる」であり、名指しするのは外す側である。`--exclude agy` と `--exclude agy,kiro` と -`--exclude agy --exclude kiro` を同じ意味にする。骨組みのシェル変数 1 つ(`${EXCLUDE:+--exclude "$EXCLUDE"}`)で -複数を渡せる。母集合の外(ホスト自身)は `init` が弾く(`--only` と同じ扱い)。 - -採らない案: 使う側を並べる `--reviewers codex,kiro`。ホストが変わると一覧を書き直す必要があり、#478 の提案の -形とも違う。 - -### 決定 8: 使える者の数で分け、`review_assign` は使える者の一覧を受け取る - -```python -def review_assign(round_no: int, available: Sequence[str]) -> list[str]: - # 3 者以上: (round_no - 1) % len を外す / 1〜2 者: そのまま / 0 者: AssignmentError -``` - -**3 者の結果は変わらない。** 試作で 4 つのホストとラウンド 1〜12 の全組を比べた。`review_assign(r, review_pool(host))` は -変更前の `review_assign(r, host)` とすべて一致した。2 者は毎ラウンド同じ 2 者になり、#631 の「1 者も見ていない -担当がいる」状態が起きない。1 者は `init` が警告して回し、P4 の規則で数える。0 者は `init` が失敗し、状態ファイルを -作らない。 - -採らない案: 使える者が 2 者のときも輪番で 1 者を外す。毎ラウンド 1 者だけのレビューになり、観点が減る。 - -### 決定 9: `--require-all` で従来の関門を選べるようにする - -全員が揃わないなら始めたくない運用(リリース前の最終レビューなど)のために残す。付けると、認証を通らない者が -1 者でもいれば従来の文言で失敗する。`--exclude` で外した者は揃っていなくてよい(外すことを明示しているため)。 -再開で `--no-require-all` を渡せるよう `BooleanOptionalAction` にし、既定は `None` とする(決定 13)。 - -### 決定 10: `cross-refactoring` には除外の引数を足さず、`assign()` と `check_auth()` を変えない - -`cross-refactoring` の `assign()` は実装担当を先に決めてから残りを選ぶ。使える者が 2 者のとき、実装担当を外すと -レビュー担当が 1 者になる。#478 のコメントは「著者を含めて回し、著者の `APPROVE` を収束に数えない」形を提案している。 -この形は判定とプロンプトに及び、D-C の範囲(`refactor_lib` の適用の骨組み)と重なる。この変更では共通層の `assign()` と -`check_auth()` の振る舞いを保ち、`cross-refactoring` の除外は別の課題へ切り出す(#664)。 - -採らない案: `cross-refactoring` の `init`(`refactor_lib/commands/setup.py`)に除外だけを足す。提案者と実装の -母集合は減らせても、レビュー担当が 1 者になる形が残る。 - -### 決定 11: `--only` は使える者を 1 者に絞る指定として扱う - -`--only X` のとき使える者は `[X]` で、認証を確かめるのも `X` だけである(現行の `_auth_targets` と同じ)。 -`X` が `--exclude` に含まれるときは矛盾として `init` が弾く。状態ファイルの `only` は残す(`_agent_intent` の -`SKIP` と旧い状態ファイルが読むため)。 - -### 決定 12: 担当の決め方をラウンドの記録から先に見る順へ変え、前ラウンドの検査も記録の担当を読む - -```text -ラウンドの reviewers → only → available_reviewers の輪番 → host の輪番(review_pool)→ codex / agy -``` - -現行は `only` をラウンドの記録より先に見る。再開で `only` を変えられるようにすると(決定 13)、過去のラウンドの -担当まで変わる。`_finding_keys` は前のラウンドの指摘を別の担当の payload から読むことになる。`start-round` はラウンドを開く -ときに `_round_reviewers` の結果を記録する。新しい状態ファイルでは、順を変えても結果は同じである。 - -`_guard_previous_round` も同じ理由で `prev["reviewers"]` を渡す。現行は担当を渡さず、旧来の `codex` / `agy` で数える。 -担当が `agy` + `kiro` のラウンドでは `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す(再現 I)。 - -### 決定 13: 引数の既定値を `None` にし、明示的に渡した引数だけを再開で反映する - -`build_parser` の 8 つの引数の既定を `None` にする。対象は `--max-rounds` / `--rotate-after` / `--only` / `--host` / -`--verify-command` / `--verify-exit-code` / `--exclude` / `--require-all` である。新規の経路は `None` を定数の既定(`DEFAULT_MAX_ROUNDS = 12` / -`DEFAULT_ROTATE_AFTER = 8`)へ置き換える。再開の経路は `None` でない引数だけを状態ファイルへ書き、変えた項目ごとに -`<項目>: <旧> → <新>` を 1 行出す。`--verify-command` と `--verify-exit-code` は置き換える。 - -採らない案: 既定値と同じ値なら渡していないとみなす。`--max-rounds 12` で 20 から 12 へ戻したい指定を区別できない。 -再現 G では `init 1` と `init 1 --max-rounds 12` がどちらも 12 になった。 - -### 決定 14: `--host` は再開で反映せず、違う値のときだけ知らせる - -ホストが変わると母集合が変わり、過去のラウンドの輪番を再現できない。状態ファイルと違う値が渡されたら -「`--host` は再開では反映しない(状態: claude、指定: codex)」を 1 行出す。同じ値なら出さない。 -`--worktree` は状態ファイルを探す場所の指定であり、反映の対象ではない。 - -### 決定 15: 担当に関わる引数を渡した再開でだけ、認証を確かめ直して使える者を作り直す - -`--only` / `--exclude` / `--require-all` のいずれかを渡した再開では、決定 6 と同じ手順で使える者を作り直す。 -対象は母集合から外した者を除いた全員(`--only` があればその 1 者)である。**いずれも渡さない再開では確かめ直さない。** -作り直した結果が 0 者、または `--require-all` で欠けがあるときは、状態ファイルを書き換えずに終了コード 1 で終わる。 - -採らない案: 新しく加わる者だけを確かめる。前回に認証を通らなかった者がログインした後も戻れない。 - -反映は再開の後に開くラウンドから効く。骨組みは `init` の直後に必ず `start-round` を呼び、`start-round` は常に -新しい番号のラウンドを開く。そのため、開いたまま中断したラウンドの担当は書き換えない(要求の前提 3)。 - -### 決定 16: 再開で指定を外す値は `none` にする - -`--only none` は `only` を `null` へ、`--exclude none` は除外を空へ戻す。空文字列は骨組みの -`${ONLY:+--only "$ONLY"}` が渡さないため、外す手段にならない。新規の経路で `none` を渡すと、渡さないのと同じになる。 - -### 決定 17: 再開で変えた値は `resume_changes` に積む - -`available_reviewers` や `max_rounds` は再開で上書きされる。上書きだけでは、どの時点で何を変えたかが失われ、 -完了報告で「途中から agy を外した」ことが読めない。変えた項目を `{"at", "field", "from", "to"}` の形で -追記し、書き換えない(事象の記録)。ラウンドごとの担当は `rounds[].reviewers` が既に持つ。 - -### 決定 18: 骨組みは `$ONLY` で絞らず、`start-round` が返す担当を使う - -`start-round` の `REVIEWERS` は `only` を反映済みである。シェル変数の `$ONLY` でもう一度絞ると、状態ファイルと -シェル変数がずれたときに起動も監視も誰にも当たらない。そのうえ起動し直しの分岐だけが担当を起動する(#648 の 3 段の経過)。 -`SKILL.md` と `docs/01` の 2 か所で、担当を `$REVIEWERS` / `$REVIEWERS_CSV` に揃える。 -対象は Step 2 の起動・監視・取り込みと、Step 2.5 の反証である。`$ONLY` は `init` へ渡す 1 行にだけ残す。 - -### 決定 19: 起動した後に分かる使えなさで、担当を自動的に外す仕組みは作らない - -利用上限(kiro の `Monthly request limit reached`)やモデルの 404 は、起動した後に分かる。 -認証の確認は通る(#478 のコメント)。分類は D-A の #619 が `usage_limit` などとして作る。自動で外すと、一時的な打ち切りでも以後の -ラウンドから恒久的に外れる。この変更は利用者が `--exclude` で外し、再開で反映できる入口までを作る。 - -## 実測 - -**再現は、状態ファイルを最小の形で組んで `state.py` の関数を直接呼んで確かめた**(develop `b4a9f69`)。 - -| 場面 | 状態 | 変更前 | 決定 3・5 の試作 | -| --- | --- | --- | --- | -| A(#624) | 担当 `codex` 1 者、印あり、根拠付き `major` 1 件、反証なし | `(0, True)`、収束 | `(1, True)`、収束しない | -| B(#583) | 担当 `agy` + `kiro`、印あり、`agy` の根拠付き `major` が反証なし | `(0, True)`、収束 | `(1, True)`、収束しない | -| B2 | B の後に `collect-critiques`、`kiro` の反証が `agy` の指摘を覆わない | 終了コード 7、印 `[1]` のまま | 終了コード 7、印 `[]`、全件を数える | -| C | 2 者、相手が `insufficient_evidence` | `(0, True)`、収束 | 変わらない | -| D | `origin_runtimes` 2 者の `minor`、反証なし | `(0, True)`、収束 | 変わらない | -| A の `minor` / 根拠なし | A の指摘を `minor` / `has_evidence: false` にする | `(0, True)` | 変わらない(決定 3) | - -P5 の前提: - -| 場面 | 結果 | -| --- | --- | -| F(担当の優先) | `only: "codex"` の状態で、記録が `agy` + `kiro` のラウンド 1 を `_round_reviewers` が `["codex"]` と返す | -| G(#648) | `init 1` と `init 1 --max-rounds 12` はどちらも `max_rounds == 12`。再開の `init` に `--only codex --max-rounds 20 --verify-command pytest --host codex` を渡すと、状態ファイルは `only: null` / `max_rounds: 12` / `host: claude` / `verify_commands: []` のまま、出力にも出ない | -| H(#478) | `check_auth` は 3 者のうち 1 者の失敗で `die` を呼ぶ。`review_assign(r, "claude")` はラウンド 1〜3 で 3 者から 1 者ずつ外す | -| I(前ラウンドの検査) | 担当 `agy` + `kiro`・両者 `REQUEST_CHANGES`・`verdict` なし・修正の記録なしのラウンドを、`_no_result_agents(prev, None)` が `["codex"]` と読み、検査を通す | -| 引数の形 | `action="append"` とカンマ区切りの型で、`--exclude agy --exclude kiro` と `--exclude agy,kiro` が同じ集合になる。知らない名前は argparse が終了コード 2 で弾く | - -## 構成要素 - -| 要素 | 責務 | Pull Request | -| --- | --- | --- | -| 区分の判定(`_classify_finding`) | 順 4 の条件に「反証を受けていない」を足す(決定 3) | P4 | -| 反証の不足の扱い(`_handle_incomplete_critiques`) | そのラウンドの印を外してから、取り直す担当を返す(決定 5) | P4 | -| 担当の決定(`lib/assignment.py`) | 母集合から外した者を除く一覧を返す。使える者の一覧から担当を選ぶ(決定 7・8) | P5 | -| 認証の把握(`lib/auth.py`) | 止めずに確かめ、担当ごとの結果を返す(決定 6) | P5 | -| 使える者の解決(`state.py` の新しい関数) | 母集合・`--only`・`--exclude`・認証・`--require-all` から、使える者と外した者を決める。新規と再開の両方が呼ぶ | P5 | -| 再開の反映(`_resume_from_state`) | 明示的に渡した引数を状態ファイルへ書き、`resume_changes` に積み、反映しない引数を知らせる(決定 13〜17) | P5 | -| 担当の読み出し(`_round_reviewers` / `_guard_previous_round`) | ラウンドの記録を先に見る(決定 12) | P5 | -| 完了報告(`cmd_report`) | 使える者・外した者・認証を通らなかった者・再開で変えた値を出す | P5 | -| 骨組みと文書(`SKILL.md` / `docs/01` / `docs/04` / `docs/05` / `docs/06`) | `$ONLY` で絞らない。引数・状態ファイル・区分の規則を書く | P4 / P5 | - -P4 の要素(判定): - -```mermaid -graph TD - CC[反証の取り込み] --> IC[反証の不足の扱い] - NF[新しい指摘の数] --> CL[区分の判定] -``` - -P5 の要素(初期化とラウンド): - -```mermaid -graph TD - IN[init の新規の経路] --> RV[使える者の解決] - RS[再開の反映] --> RV - RV --> AU[認証の把握] - RV --> AS[担当の決定] - SR[start-round] --> GD[前ラウンドの検査] - SR --> RR[担当の読み出し] - GD --> RR - RR --> AS -``` - -図の辺は呼び出しを表す。`init` の新規の経路・`start-round`・反証の取り込み・新しい指摘の数は、変える要素の呼び出し元として -置いた既存の要素である。完了報告は状態ファイルを読むだけで他の要素を呼ばないため、骨組みと文書とともに図に含めない。 - -**文脈と配置は変わらない。** 動くのは、ホストの CLI から起動される `state.py` の 1 プロセスである。 -外部との出入りは `gh`(GitHub)と、各 CLI の認証の確認コマンドだけである。認証の確認の呼び出し先(`AUTH_PROBES`)は変えない。 - -## 置き場所 - -```text -plugins/ndf/ -├── scripts/lib/ -│ ├── assignment.py # P5: review_candidates を新設、review_assign の引数を変更 -│ └── auth.py # P5: probe_auth を新設(check_auth は変えない) -└── skills/ - ├── cross-review/ - │ ├── SKILL.md # P5 - │ ├── docs/01-state-and-review.md # P5 - │ ├── docs/04-contracts.md # P5 - │ ├── docs/05-pool-and-convergence.md # P4 / P5 - │ ├── docs/06-evidence.md # P4 - │ ├── scripts/state.py # P4 / P5 - │ └── tests/ # P4: test_classify_findings.py / test_critiques.py に追記 - │ # P5: test_state_review_pool.py に追記、test_state_resume_args.py を新設 - └── cross-refactoring/tests/test_assignment.py # P5: review_assign の呼び方を変え、AC15・AC16 のテストを足す -``` - -`dev.kiro` / `dev.agy` は `skills/` を symlink で参照するため、書き写す配布物は無い。 -`bash scripts/build-runtime-plugins.sh --check` で食い違いが無いことだけを確かめる。 - -状態ファイルの形・引数・関数の契約は [issue-624-478-648-contracts.md](issue-624-478-648-contracts.md) にある。 - -## 処理の流れ - -### 新しい指摘の数え方(P4) - -```mermaid -graph TD - J[judge] --> K{payload を読めたか} - K -->|いいえ| U["(0, False) 全員 pass に従う"] - K -->|はい| M{ラウンドに印があるか} - M -->|いいえ| ALL[payload の全件を数える] - M -->|はい| C[指摘ごとに区分を決める] - C --> R{実行検証で再現・棄却 / refute} - R -->|当たる| V[順 1〜3 の区分] - R -->|当たらない| E{根拠あり かつ major 以上} - E -->|いいえ| IE[insufficient_evidence 数えない] - E -->|はい| S{support / 提案者 2 者 / 反証なし} - S -->|いずれか| NH[needs_human_judgment 数える] - S -->|どれも無い| IE -``` - -反証の取り込みで揃わないとき、`_handle_incomplete_critiques` がそのラウンドの印を外す。次に `judge` が数えるとき -「印があるか」が「いいえ」へ進む。取り直して揃えば `_mark_evidence_round` が印を戻す。 - -### 初期化と再開(P5) - -新規の経路: - -```mermaid -graph TD - N[PR の情報・作業ツリー・既存コメント] --> H[ホストを確定] - H --> RV[使える者の解決] - RV -->|0 者 / --require-all で欠け| F[終了コード 1 状態を作らない] - RV -->|1 者| W[観点が 1 つと警告] - RV -->|2〜3 者| S[状態ファイルを書く] - W --> S -``` - -再開の経路(状態ファイルがあり `final` が `null`): - -```mermaid -graph TD - A[明示的に渡した引数を集める] --> HO{--host が状態と違う} - HO -->|はい| HM[反映しないことを 1 行出す] - HO -->|いいえ| RA{--only / --exclude / --require-all を渡した} - HM --> RA - RA -->|はい| RV[使える者の解決] - RA -->|いいえ| W[変えた項目を書き resume_changes に積む] - RV -->|1 者以上| W - RV -->|0 者 / 欠けあり| F[終了コード 1 状態は書かない] -``` - -使える者の解決は次の順で決める。 - -1. `--only` があれば母集合に含まれるかを確かめ、`--exclude` に含まれていれば弾く。対象は `[only]` -2. `--only` が無ければ、`--exclude` の各名前が母集合に含まれるかを確かめ、対象は母集合から除いた者 -3. 対象の認証を `probe_auth` で確かめる。`NDF_SKIP_AUTH_CHECK` が立っていれば全員を通ったものとする -4. `--require-all` が真で通らない者がいれば、従来の文言で終了コード 1 -5. 通った者が 0 者なら終了コード 1。1 者なら警告を 1 行出す - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 可用性 | 母集合の 1 者が使えないことで、収束ループを開始できない状態にならない | 認証の失敗を `unavailable_reviewers` へ記録し、使える者で続ける(決定 6) | AC10 のテスト | -| 性能・拡張性 | 認証の確認の回数は、新規の `init` で変更前を上回らない。再開で増えるのは担当に関わる引数を渡したときだけで、母集合の最大 3 者である | `--exclude` で外した者は確かめない。再開は決定 15 の条件でだけ確かめる | AC13・AC26 のテストで `probe_auth` の呼び出しを数える | -| 運用・保守性 | 担当が欠けたまま収束したことが `report` の出力だけで分かる。再開で反映しなかった引数が出力に出る | `report` が 3 つの一覧と `resume_changes` を出す。`--host` の不一致を 1 行出す | AC21・AC27 のテスト | - -## 申し送り(並行する設計との境界) - -| 相手 | 決めた契約 | -| --- | --- | -| D-A(P1〜P3) | **P4 の規則は、起動し直しの経路が証拠集約を通るかどうかに依存しない。** 通らなければ決定 3 が数え、通って反証が揃わなければ決定 5 が全件へ戻す。D-A が起動し直しの後に `critique-round.sh` を呼ぶときは、担当を `$REVIEWERS`(そのラウンドの担当)で渡し、`$ONLY` を使わない(決定 18) | -| D-A(P1〜P3) | `SKILL.md` の骨組みで D-B が触るのは Step 0 の `init` の引数、Step 2 の起動・監視・取り込みの 3 つのループ、Step 2.5 の `critique-round.sh` の引数である。Step 3 の `JUDGE_RC -eq 7` の分岐は D-A が持つ。P5 は P3 の後に載せるため、衝突は P5 の側で解く | -| D-A(P1〜P3) | `_no_result_agents` と `_handle_no_result_round` の中身(`NO_RESULT` の理由)は D-A が持つ。P5 は `_guard_previous_round` から呼ぶときに担当の一覧を渡すだけにする | -| D-A(#619) | 利用上限などの分類を、担当を外す判断へつなぐのは後続に回す(決定 19)。つなぐ場合の入口は再開の `--exclude` である | -| D-C | `refactor_lib` に触らない。共通層の `assign()` と `check_auth()` の振る舞いを変えない(決定 10)。`review_assign` のテストは共通層のテストの置き場所 `cross-refactoring/tests/test_assignment.py` にある。P5 はそこで既存の呼び出しを直し、AC15・AC16 のテストを足す。`refactor_lib` は `review_assign` を呼ばない | - -**D-A の決定に依存する点:** - -- P3 が起動し直しの後に `verify-findings` と `critique-round.sh` を通す形を採るか。どちらでも P4 は成り立つ。 - 変わるのは、#583 の起動し直しの指摘を `needs_human_judgment` として数えるか、全件として数えるかである -- P1 が `monitor.py` の結果をファイルに残す形と、`SKILL.md` の Step 2 の監視の行の書き方。P5 の `$ONLY` の削除と同じ行を触る -- P3 が `state.py` の `_resume_from_state` か `cmd_init` に手を入れるか。P5 は `_resume_from_state` の引数を増やす - -## テスト設計 - -置き場所は、`cross-refactoring/` で始まるもの以外は `plugins/ndf/skills/cross-review/tests/` の下である。 -`cross-refactoring/` で始まるものは `plugins/ndf/skills/` の下にある。 - -| 受け入れ条件 | 何で確かめるか | 置き場所 | -| --- | --- | --- | -| AC1〜AC4 | 状態ファイルを組み、`_new_finding_count` と `cmd_judge` の終了コードを見る(実測の A / B と、A の `minor` / 根拠なし) | `test_classify_findings.py` | -| AC5 | 印の付いた状態で `cmd_collect_critiques` を 2 回呼び、`evidence_rounds` と数え方を見る(実測の B2) | `test_critiques.py` | -| AC6・AC7 | `_classify_finding` に反証の値ごと・`origin_runtimes` 2 者の指摘を渡す | `test_classify_findings.py` | -| AC8 | 既存のテストを期待値を変えずに通す | 既存のまま | -| AC9 | `grep` の行を検査するテスト | `test_skill_layout.py` | -| AC10〜AC14 | `probe_auth` を差し替えて `_init_new_state` を呼ぶ(GitHub を呼ぶ関数は `conftest.py` の既存の差し替え)。状態ファイルの有無と終了コードを見る | `test_state_review_pool.py` | -| AC15・AC16 | `review_assign` を 4 ホスト × 12 ラウンドと 2 者の一覧で呼ぶ。AC15 は変更前の式を期待値として持つ | `cross-refactoring/tests/test_assignment.py` | -| AC17・AC18 | 使える者 1 者・0 者の `init` | `test_state_review_pool.py` | -| AC19 | `available_reviewers` を持たない状態 / `host` も持たない状態で `_round_reviewers` | `test_state_review_pool.py` | -| AC20 | 既存の `assign` の期待値と、`check_auth` の失敗で `die` が呼ばれるテスト | `cross-refactoring/tests/test_assignment.py` / `cross-refactoring/tests/test_init.py` | -| AC21 | 3 つの一覧と `resume_changes` を持つ状態ファイルで `cmd_report` の出力の 4 行を見る | `test_state_review_pool.py` | -| AC22〜AC27 | 状態ファイルを置いた作業ツリーを渡して `cmd_init` を呼び、状態ファイルと標準エラーを見る(実測の G) | `test_state_resume_args.py`(新設) | -| AC28 | 2 ファイルの `ONLY` を含む行を数えるテスト | `test_skill_layout.py` | -| AC29 | `verdict` の無い前ラウンドで `cmd_start_round` の終了コード 5 を見る(実測の I) | `test_state_round_guard.py` | -| AC30 | 文書の語を `grep` するテスト | `test_skill_layout.py` | -| AC31・AC32 | コマンドの終了コード | 継続的統合と手元 | - -## 未確認のまま残ること - -| 項目 | 内容 | いつ決まるか | -| --- | --- | --- | -| 1 者で回したときの収束までのラウンド数 | 決定 3 で `minor` と根拠の無い `major` を数えないため、1 者のループが早く収束しすぎないかは実測していない | P4 の後の運用で `measure.py` の出力を見る | -| 2 者固定(`codex` + `kiro`)の観点の偏り | `agy` を外して 2 者を固定したとき、3 者の輪番より指摘を見落とすかは測っていない | P5 の後の運用 | -| `cross-refactoring` の除外と、著者を含めて回す規則 | 決定 10 で切り出す。レビュー担当が 1 者になる形をどう扱うかは未決 | #664 | -| 起動した後に分かる使えなさで外す仕組み | 決定 19 で作らない。自動で外すか、利用者へ再開を促すかは未決 | D-A の #619 の後 | -| 再開で `--verify-command` を空へ戻す手段 | 持たない。要求が出たら `none` と同じ形で足せる | 要求が出たとき | -| 出力の文言 | 反映した行・反映しない行・警告の文言は、項目名と値を含むことだけを決めた | **実装で決める** | -| テストの置き場所 | テスト設計の表の置き場所は既存ファイルに合わせた目安である | **実装で決める** | diff --git a/issues/issue-624-478-648-requirements.md b/issues/issue-624-478-648-requirements.md deleted file mode 100644 index 367ebdf8..00000000 --- a/issues/issue-624-478-648-requirements.md +++ /dev/null @@ -1,285 +0,0 @@ -# #624 / #478 / #648: 反証する担当がいない指摘を数え、使える担当だけで回し、再開で引数を反映する - -設計は [issue-624-478-648-design.md](issue-624-478-648-design.md) にある。この文書は「何を満たすか」だけを扱う。 - -**3 つの課題は 2 本の Pull Request で直す**(設計文書の決定 1)。P4 は #624 と、#583 のうち収束の誤りの部分を -直す。P5 は #478 と #648 を直す。マージは P4 → P5 の順である。受け入れ条件も Pull Request ごとに分ける。 - -## 目的 - -- 反証する担当がいない指摘が、数える区分から黙って落ちなくなる。1 者で回しても、起動し直した担当の指摘でも、 - 修正を要する指摘が残ったまま収束しない -- 認証できない担当や、打ち切りが分かっている担当を外し、使える担当だけでレビューを回せる -- 中断した収束ループを、引数で進め方を変えて再開できる。反映しなかった引数は出力で分かる - -## 対象範囲 - -含む: - -- `state.py` の収束の数え方(区分の判定、証拠集約の印の外し方)(P4) -- `lib/assignment.py` の `review_assign` と、除外・使える者の一覧の算出(P5) -- `lib/auth.py` に止めない認証の確認を足す(P5) -- `state.py` の `init` の新規と再開の経路、`start-round` の担当の決め方、前ラウンドの検査、`report`(P5) -- `cross-review` の `SKILL.md` と `docs/`(01 / 04 / 05 / 06)、テスト(P4 / P5) - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| `cross-refactoring` の除外の引数 | 実装担当を外す規則と組むと、使える者が 2 者のときにレビュー担当が 1 者になる。著者を含めて回す規則(#478 のコメント)は判定とプロンプトに及び、D-C の範囲と重なる(設計文書の決定 10)。#664 へ切り出した | -| 起動した後に分かる使えなさ(利用上限・モデルの 404)で担当を自動で外すこと | 分類は D-A(#619)が作る。この変更は利用者が `--exclude` で外す入口だけを作る(決定 19) | -| 監視の上限・結果のファイル化・`NO_RESULT` の理由・`JUDGE_RC -eq 7` の分岐 | D-A(#662 #598 #537 #619 #584 #583)の範囲 | -| #583 の投稿の重なり | D-A の範囲。この変更が扱うのは #583 のうち収束の誤りの部分だけである | -| 担当 2 者で、相手が反証で `support` を返さない単独の指摘を数えないこと | #156 の設計どおりであり、#624 の対象外と issue に書かれている | -| 再開で `--verify-command` を空へ戻す手段 | 置き換えはできる。空へ戻す要求は出ていない | -| クラス図 | 型を追加・変更しない(関数と辞書で組んだ状態ファイルを扱う)。状態ファイルの形は契約文書のデータ構造の節が持つ | -| システムの文脈・配置の図 | 動くのは `state.py` の 1 プロセスで、外部との出入り(`gh` と認証の確認コマンド)は変わらない | -| `CHANGELOG.md` と版数 | 配布の工程が書く | - -## 受け入れ条件(P4: #624 と #583 の収束の部分) - -反証する担当がいない指摘を数える: - -- [ ] AC1: 担当が 1 者(`only: "codex"`)で、証拠集約の印が付いたラウンドがある。そのラウンドに根拠を持つ `major` の - 指摘が 1 件あり、反証は 0 件で、前のラウンドは無い。このとき `_new_finding_count` が `(1, True)` を返し、`judge` が終了コード 2 で - 終わる(変更前は `(0, True)` と終了コード 0。issue の再現) -- [ ] AC2: AC1 の指摘が `minor` のとき、`_new_finding_count` は `(0, True)` を返す。担当 2 者のときと同じく、 - `minor` 以下は数えない -- [ ] AC3: AC1 の指摘が根拠を持たないとき、`_new_finding_count` は `(0, True)` を返す。根拠を持たないとは、 - `evidence` か `falsification` が空であることをいう -- [ ] AC4: 担当が `agy` + `kiro` で印が付いたラウンドに、`agy` の根拠を持つ `major` が反証 0 件のまま入っている。 - 起動し直した担当の指摘が、反証を取り込んだ後に取り込まれた形である。このとき `_new_finding_count` が `(1, True)` を - 返す(#583 の収束の部分) - -反証の取り直しで揃わないときは印を外す: - -- [ ] AC5: 印が付いたラウンドで `collect-critiques` を実行し、反証が揃わない(終了コード 7)。このとき - `evidence_rounds` からそのラウンドの番号が消え、`_new_finding_count` は payload の全件を数える。取り直した後も - 揃わないとき(2 度目)も印は付かない - -退行しない: - -- [ ] AC6: 担当 2 者で、相手が単独の根拠を持つ `major` へ `insufficient_evidence` か `out_of_scope` を返した指摘は - 数えない。`support` なら数え、`refute` なら `rejected` になる -- [ ] AC7: `origin_runtimes` が 2 者の指摘の区分は変わらない(根拠を持つ `major` は数え、`minor` は数えない) -- [ ] AC8: 印を持たないラウンドの数え方(payload の全件)と、実行検証の区分(`reproduced` / `not_reproduced`)は - 変わらない。`tests/test_classify_findings.py` の既存のテストが、期待値を変えずに通る - -文書: - -- [ ] AC9: `docs/06-evidence.md` の区分の表の順 4 が、「反証を受けた単独の指摘だけが `support` を求められる」条件を - 書く。同じ文書の「走らせる順序」の節が、取り直しで揃わないときに印を外すことを書く。`docs/05-pool-and-convergence.md` の - 終了基準が、担当 1 者と起動し直した担当の指摘の数え方を書く。次の 2 つがそれぞれ 1 行以上を出す - - ```bash - grep -n "反証を受けた" plugins/ndf/skills/cross-review/docs/06-evidence.md - grep -n "印を外す" plugins/ndf/skills/cross-review/docs/06-evidence.md - ``` - -## 受け入れ条件(P5: #478 使える担当だけで回す) - -認証の確認を把握にする: - -- [ ] AC10: ホスト `claude` で `kiro` の認証の確認が失敗する。`init` は終了コード 0 で状態ファイルを作る。 - `available_reviewers` は `["codex", "agy"]` で、`unavailable_reviewers` は `kiro` と失敗の理由を持つ。 - 標準エラーに `kiro` を外したことが 1 行出る -- [ ] AC11: `--require-all` を付けると、AC10 と同じ状態で `init` が終了コード 1 で終わり、状態ファイルを作らない -- [ ] AC12: `NDF_SKIP_AUTH_CHECK=1` のとき、`available_reviewers` は母集合から `--exclude` で外した者を除いた全員に - なる。確認を飛ばしたことが出力に残る - -除外の引数: - -- [ ] AC13: ホスト `claude` で `--exclude agy` を渡す。`agy` の認証を確かめず、`excluded_reviewers` が `["agy"]`、 - `available_reviewers` が `["codex", "kiro"]` になる。`--exclude agy --exclude kiro` と `--exclude agy,kiro` は同じ - 状態ファイルを作る -- [ ] AC14: 母集合の外(ホスト自身)を `--exclude` に渡す。または `--only codex --exclude codex` を渡す。 - どちらも `init` が終了コード 1 で終わり、状態ファイルを作らない - -使える者の数で分ける: - -- [ ] AC15: 使える者が 3 者のとき、`start-round` が返す担当は変更前と同じ輪番になる。4 つのホストとラウンド 1〜12 - の全組で、`review_assign(r, review_pool(host))` が変更前の `review_assign(r, host)` と一致する -- [ ] AC16: 使える者が 2 者(`codex` / `kiro`)のとき、ラウンド 1〜4 の `start-round` がすべて `codex kiro` を返す -- [ ] AC17: 使える者が 1 者のとき、`init` は終了コード 0 で終わり、観点が 1 つになることを 1 行出す。 - `start-round` はその 1 者を返す -- [ ] AC18: 使える者が 0 者のとき、`init` が終了コード 1 で終わり、状態ファイルを作らない - -退行しない: - -- [ ] AC19: `available_reviewers` を持たない状態ファイルは、この変更の前に始めた実行である。このとき `start-round` が - 返す担当は変更前と同じになる。`host` も持たない状態ファイルは `codex` / `agy` のままである -- [ ] AC20: `cross-refactoring` の担当と認証の関門が変わらない。`cross-refactoring/tests/test_assignment.py` の `assign` の期待値 - (`EXPECTED_FOR_CLAUDE`)が変えずに通る。`check_auth` は失敗が 1 件でもあれば中断させる - -報告: - -- [ ] AC21: `state.py report` が、使える者・`--exclude` で外した者・認証を通らなかった者・再開で変えた値を、 - 1 行ずつ出す。認証を通らなかった者には理由を添える。4 つとも空でない状態ファイル(`resume_changes` が - 1 件以上)で 4 行とも出る - -## 受け入れ条件(P5: #648 再開で引数を反映する) - -明示的に渡した引数だけを反映する: - -- [ ] AC22: `max_rounds: 12` の状態ファイルで、再開の `init` に `--max-rounds 20` を渡す。`max_rounds` が 20 になり、 - 反映したことが `12 → 20` の形で 1 行出る。`resume_changes` には `field: "max_rounds"` / `from: 12` / `to: 20` の - 要素が 1 件積まれる。`--rotate-after` / `--verify-command` / `--verify-exit-code` も同じく反映される。 - `--verify-command` と `--verify-exit-code` は置き換え、継ぎ足さない -- [ ] AC23: 再開の `init` に `--max-rounds` などの引数を渡さない。このとき状態ファイルの次の 10 項目が変わらず、 - `max_rounds: 20` の状態ファイルが 12 へ戻らない - - | 区分 | 項目 | - | --- | --- | - | 進め方 | `max_rounds` / `rotate_after` / `verify_commands` / `verify_exit_codes` | - | 担当 | `only` / `excluded_reviewers` / `available_reviewers` / `unavailable_reviewers` / `require_all` / `auth_skipped` | -- [ ] AC24: `only: null` の状態ファイルで、再開の `init` に `--only codex` を渡す。`only` が `codex` になり、次の - `start-round` が `codex` だけを返す。記録を持つ過去のラウンドの `_round_reviewers` は記録のまま変わらない -- [ ] AC25: `only: "codex"` の状態ファイルで、再開の `init` に `--only none` を渡す。`only` が `null` になり、使える者が - 作り直される。`--exclude none` は `excluded_reviewers` を空にする -- [ ] AC26: 再開の `init` に `--exclude agy` を渡す。母集合から `agy` を除いた全員の認証を確かめ直し、 - `available_reviewers` から `agy` が消え、次の `start-round` が `agy` を返さない。担当に関わる引数(`--only` / - `--exclude` / `--require-all`)を渡さない再開では、認証を確かめ直さない -- [ ] AC27: `host: "claude"` の状態ファイルで、再開の `init` に `--host codex` を渡す。`host` は `claude` のまま変わらず、 - 反映しないことが 1 行出る。同じ値の `--host claude` では何も出ない - -手順書の骨組み: - -- [ ] AC28: `SKILL.md` と `docs/01-state-and-review.md` の骨組みが、`start-round` が返した担当をそのまま使う。 - 起動・監視・取り込み・反証の担当を `$ONLY` で絞らない。2 ファイルに対する `grep -n 'ONLY' ` の出力が、`init` へ - 引数を渡す行と引数の説明の行だけになる - -前のラウンドの検査: - -- [ ] AC29: 前のラウンドが `verdict` を持たず、担当 `agy` + `kiro` の両者が `REQUEST_CHANGES` で、修正の記録が無い。 - このとき `start-round` は終了コード 5 で止まる。変更前は旧来の 2 者 `codex` / `agy` で数え、`codex` を結果なしと - 読んで通していた - -文書: - -- [ ] AC30: 次の 4 ファイルが、それぞれの内容を書く - - | ファイル | 書く内容 | - | --- | --- | - | `SKILL.md` | 引数の表と `argument-hint` に `--exclude` と `--require-all` がある。`--only` の説明から「デバッグ用」が消える | - | `docs/05-pool-and-convergence.md` | 使える者の数による分岐と、除外 | - | `docs/04-contracts.md` | 状態ファイルの 6 項目(`available_reviewers` / `excluded_reviewers` / `unavailable_reviewers` / `require_all` / `auth_skipped` / `resume_changes`) | - | `docs/01-state-and-review.md` | 再開で反映する引数と、反映しない引数 | - -## 受け入れ条件(両方) - -- [ ] AC31: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC32: 次の 3 つが終了コード 0 で終わる - - ```bash - bash scripts/build-runtime-plugins.sh --check - claude plugin validate . - python3 scripts/check-skill-frontmatter.py - ``` - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 可用性 | 母集合の 1 者が使えないことで、収束ループを開始できない状態にならない(AC10) | -| 性能・拡張性 | 認証の確認の回数は、新規の `init` で変更前を上回らない(除外した者は確かめない)。再開で増えるのは担当に関わる引数を渡したときだけで、母集合の最大 3 者である | -| 運用・保守性 | 担当が欠けたまま収束したことが、`report` の出力だけで分かる(AC21)。再開で反映しなかった引数が出力に出る(AC27) | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 認証に失敗する CLI がある利用者 | `init` が止まらず、使える者で回る。従来の関門は `--require-all` で選べる | -| `--only` で回していた利用者 | 反証する担当がいない根拠付きの `major` が数えられ、最初のラウンドで収束しなくなる | -| 状態ファイルの形 | 最上位に 6 項目が増える。無い状態ファイルは従来の担当の決め方で読む | -| `init` の引数 | `--exclude` / `--require-all` が増え、`--only` が `none` を取る。既定値は変わらない(`--max-rounds 12` / `--rotate-after 8`) | -| 再開で修正の記録の無い前ラウンドがある実行 | 担当が `codex` / `agy` 以外のラウンドでも、前ラウンドの検査が止める(AC29) | -| `cross-refactoring` | 変わらない | - -## 前提 - -| # | 前提 | -| --- | --- | -| 1 | P1〜P3(D-A)が先に `develop` へ入る。P4 / P5 の `state.py` の変更はその上に載せる。P4 の規則は、起動し直しの経路が証拠集約を通る形(P3)でも通らない形でも成り立つように決める | -| 2 | 担当は最大 2 者である(`review_assign` が 3 者以上から 1 者を外すため)。反証を返せる担当は、提案者を除いて最大 1 者になる | -| 3 | 再開の `init` の後、骨組みは必ず `start-round` で新しいラウンドを開く。開いたまま中断したラウンドの担当を書き換える必要は無い | -| 4 | 認証の確認コマンド(`AUTH_PROBES`)とその判定は変えない | - -前提 3 の根拠: `SKILL.md` の骨組みは `init` の直後に `start-round` を呼ぶ。`start-round` は常に -`len(rounds) + 1` のラウンドを開く(`state.py` の `cmd_start_round`)。 - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q` | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認 | P5 の後、`--exclude agy` を付けた `cross-review` を 1 本の Pull Request で回し、`agy` が 1 度も起動しないことを `report` で見る | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 担当の決め方は `plugins/ndf/scripts/lib/assignment.py`、認証は `lib/auth.py` に置く(`cross-refactoring` と共有する共通層)。判定は `state.py` に置き、骨組みは結果を使うだけにする | -| コーディング規約 | 状態ファイルを最小の形で組み、関数を直接呼んで確かめる(`AGENTS.md` の DO) | -| テスト戦略 | 既存の形(`tests/conftest.py` の `state_mod` で `state.py` を読み込み、GitHub を呼ぶ関数を差し替える) | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査 | -| 確認してから行う | `init` の既定を「認証の失敗で止める」から「使える者で回す」へ変えること(利用者の判断を仰ぐ) | -| 行わない | `cross-refactoring` の担当の決め方と `refactor_lib` の変更、監視と起動の変更 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 母集合 | 全ランタイム − ホストの 3 者(`review_pool(host)`) | -| 使える者 | 母集合から、`--exclude` で外した者と認証を通らなかった者を除いた一覧(`available_reviewers`) | -| 担当 | そのラウンドにレビューする者。`start-round` がラウンドへ記録する | -| 証拠集約の印 | `evidence_rounds` に載るラウンド番号。統合・実行検証・反証を通り切ったラウンドに付く | -| 反証を受けた指摘 | 提案者でない担当の有効な反証(`CRITIQUE_VERDICTS` のいずれか)が 1 件以上結ばれた指摘 | -| 単独の指摘 | `origin_runtimes` が 1 者の指摘 | -| 再開 | 状態ファイルが残り `final` が `null` のときの `init`(`_resume_from_state`) | - -## 依頼(原文) - -### #624 - -> `cross-review` を `--only codex` で 1 者だけにして回すと、codex が `REQUEST_CHANGES` で新しい指摘を投稿したラウンドでも、 -> `judge` が収束と判定する(ndf 10.10.1、2026-09-13)。 -> -> | 候補 | 内容 | -> | --- | --- | -> | 反証の担当がいないラウンドは絞り込まない | 反証が 0 件のときは全件を数える(`_evidence_completed` を偽に扱う) | -> | `--only` では intent に従う | 担当が 1 者なら、全員 pass(`round_passes`)だけを収束の条件にする | -> | 印を指摘単位で持つ | 証拠集約の印をラウンド単位ではなく指摘単位(反証の対象になったか)で持ち、反証の対象にならなかった指摘は絞り込まない。#583 の起動し直しの経路もまとめて塞がる | - -### #583(収束の部分) - -> **起動し直した担当の指摘も、反証を受けないまま数えられない。** 証拠集約の印(`_mark_evidence_round`)は、 -> 1 回目の経路で反証を取り込んだ時点でラウンドに付く。起動し直した担当の指摘はその後に取り込まれるため、 -> 反証の対象にならないまま `_new_finding_count` の絞り込みにかかり、単独の major は `insufficient_evidence` へ落ちる。 - -### #478 - -> **認証確認を「関門」から「導入状況の把握」へ変える。** 使える者でレビューし、使えない者は最初から数えない。 -> -> 1. `init` で母集合の各ランタイムの認証を確かめ、**通った者の一覧を `state.json` へ記録する**(`available_reviewers`) -> 2. **明示的に外す手段を用意する**(例: `--exclude agy`)。**認証は通るが実行で落ちる担当を外す用途でも使う。** -> 3. 使える者の数で分岐する(3 者: 現行どおり / 2 者: 毎ラウンドその 2 者 / 1 者: 警告して 1 者 / 0 者: 失敗) -> 4. `review_assign()` は「**使える者が 3 者以上のときだけ 1 者を外す**」に変える -> 5. 全員揃っていることを要求したい運用のために `--require-all` を用意する -> 6. 完了報告に「このループに参加したのは誰か」を出す - -### #648 - -> `/ndf:cross-review` を中断・再開すると、`state.py init` に渡した `--only` / `--max-rounds` / `--rotate-after` / -> `--verify-command` / `--verify-exit-code` / `--host` が**黙って無視される**。 -> -> 再開経路でも、**明示的に渡された引数だけ**を状態ファイルへ反映する。反映できないと決めた引数(例えば `--host`)は、 -> **渡されたら 1 行知らせる**。黙って捨てない。 - -(各 issue の本文から抜粋。全文は `gh issue view 624` / `583` / `478` / `648`) diff --git a/issues/issue-647-592-553-design.md b/issues/issue-647-592-553-design.md deleted file mode 100644 index b6dc51c9..00000000 --- a/issues/issue-647-592-553-design.md +++ /dev/null @@ -1,499 +0,0 @@ -# #647 / #592 / #553: 適用ラウンドに試行の上限を置き、帰属行の後ろのトレーラーを読む - -要求と受け入れ条件は [issue-647-592-553-requirements.md](issue-647-592-553-requirements.md) にある。 -この文書は「どう作るか」だけを扱う。 - -**実装は 1 本の Pull Request(P6)にまとめる**(決定 1)。マイルストーン 21 の順序では P3 の後に載せ、 P4・P5 とは並行してよい。 - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| 1 | 結果を残さない担当の群を、担当を替えて 1 回だけ開き直し、2 回目も残さなければ取り消す | cross-refactoring を回す進行側 | -| 2 | 採用 0 件の提案ラウンドと項目の無い群で、適用担当を起動せずに次へ進む | 同上 | -| 3 | 修正の結果を群の担当から読み、結果が無ければ修正ラウンドを 1 つ進める | 同上 | -| 4 | 帰属行の段落が後ろに付いたコミットから必須トレーラーを読む | 同上(claude が適用担当の群) | -| 5 | 適用・修正の監視が、テストの実行中の無出力で担当を打ち切らない | 同上 | - -## 決定の記録 - -決定 1〜8 は #647 と修正ラウンド、決定 9〜10 は #592、決定 11〜12 は #553、決定 13 は無進捗の打ち切りを扱う。 - -### 決定 1: 3 課題と `merge-fix` の担当の食い違いを 1 本の Pull Request で直す - -#647 と #592 は同じ `next-apply-round` → `merge-apply` の繰り返しで止まらず、#553 は同じ適用の検証で群を落とす。 -`merge-fix` の食い違いは、#647 を直した後に同じ形(結果を取り込めない担当で修正と検証が往復する)で残る。いずれも `refactor_lib/commands/apply.py` -と `SKILL.md` の骨組みを触るため、分けると同じ箇所を 2 度変える。 - -`merge-fix` を別の issue に起票する形は採らない。起票しても直す場所と時期が P6 と同じになる。 - -### 決定 2: 同じ群の試行の上限を 2 回の固定値にし、引数を足さない - -2 回目は別の担当が試すため(決定 3)、2 回とも結果を残さなければ担当ではなく群の側を疑える。3 回以上にしても、輪番の 4 者のうち壊れた者に当たる確率が上がるだけである。 -値は `vocabulary.py` の `MAX_APPLY_ATTEMPTS` に置く。 - -`--max-apply-attempts` を足す形は採らない。`SKILL.md` は上限を 2 つ置くとどちらで止まったかを読み解く必要が出るとしており、 -利用者が変えたい理由も見当たらない。止まった理由は群の記録(`drop_reason`)が持つ。 - -### 決定 3: 2 回目の試行は次の輪番の担当が行い、利用上限でも進行全体を止めない - -結果を残さない原因の多くは担当の CLI の側にある(rf646 の agy の STALLED 4 回、claude の 429 の 3729 回)。 -同じ担当で開き直しても直らない。担当を替えれば、壊れた CLI が 1 者でも他の者が群を適用できる。替える先は、`apply_seq` を 1 ずつ進めて輪番の担当を引き、 -その群で失敗した担当のどれとも違う担当が出た最初の番号の担当である。引く式は群を割り当てたときと同じものを通す(決定 8)。 - -1 つ進めるだけにしない。`apply_seq` は提案ラウンドの全群を割り当てた後の番号で、群自身の番号ではない。輪番は 4 者で 1 周するため、 -群が 4 つあり先頭の群が失敗すると、次の番号の担当は失敗した担当と同じになる(`assign(1)` と `assign(5)` はどちらも codex。 -「実測」の節)。 - -利用上限(429)で進行全体を止める形は採らない。止めると他の 3 者で進められる群まで止まり、再開すると同じ者にまた当たる。429 は監視が早期の致命として 15 秒前後で打ち切る(D-A -の P3)ため、担当を替える 1 回の費用は小さい。利用上限だった事実は理由の名前(`usage_limit`)として群の記録と見送りの理由に残る。 - -### 決定 4: 開き直しの判定は `merge-apply` が持ち、骨組みは監視の終了コードで分岐しない - -`merge-apply` は結果ファイルを読めたかどうかを自分で知っている。監視の終了コードで分岐しても、結果ファイルが後から書かれた場合(#584)や、 -監視は `OK` でも JSON が壊れている場合は結果ファイルの側で決めるしかない。判定を 1 か所に置くと、骨組みは `|| continue` のまま変わらない。 - -骨組みで監視の終了コードを受けて `merge-apply` へ渡す形は採らない。渡しても振る舞いは変わらず、使い道は理由の記録だけである。 -理由は D-A が P1 で残す監視の結果ファイルから読む(「他の設計との契約」)。 - -### 決定 5: 試行番号は `next-apply-round` が進め、前の試行が失敗で閉じたときだけ進める - -`next-apply-round` が `pending` の群を開くとき、失敗した試行の記録の数が試行番号と等しければ試行番号を 1 進める。 -等しくなければ(取り込みの前に進行が止まった再開)そのまま開く。 - -`merge-apply` は同じ試行番号の失敗の記録が既にあれば、結果ファイルを読まずに、記録を足さず同じ終了コード 2 を返す。読んでから判定する形は採らない。 -結果ファイルの名前(`{agent}-apply-r<提案ラウンド>`)は群の番号を持たず、消すのは `launch-cli.sh` の起動時だけである。 -担当を替えた直後に叩き直すと、替えた先の担当が同じ提案ラウンドの先行の群で残した結果を読み、検証へ進んで群を落とす。進行はどこで止まっても叩き直せることが前提である(`docs/02-apply-and-review.md`「叩き直しても同じ判定を返す」)。 -叩き直しを 2 回目の試行と数えると、1 回の失敗で群を落とす。 - -開いた回数だけを数える形は採らない。進行側が落ちて再開しただけで試行が進む。 - -### 決定 6: 結果を残さない試行のコミットは、開き直す前に取り消す - -`next-apply-round` は `pending` の群を開くたびに起点を HEAD へ置き直す(`apply.py:304-309`)。 -結果を残さない担当がコミットだけ作って止まると、そのコミットは次の試行の起点より前に入り、検証を受けないまま次の群の push で Pull Request に出る。 -範囲にコミットがあれば、既存の `_revert_unverified_apply_round`(`apply.py:555-598`)から取り消しの本体(`revert_item_commits` -と起点の更新)だけを切り出して共有し、起点を取り消し後の HEAD にする。関数をそのまま呼ぶ形は採らない。群を `dropped` にして項目を見送るため、 -1 回目の失敗で群が閉じる。 - -### 決定 7: 着手前テストの未確認と範囲の未確定は中断(4)にする - -`init` は着手前テストが失敗していれば止まるため、`merge-apply` で `green` でないのは状態ファイルが壊れたときだけである。 -範囲を確定できないのも git の状態が壊れたときである。どちらも群を替えても直らず、 `SKILL.md` の終了コードの表は既に「範囲を確定できない」を 4 と書いている。 - -試行の上限へ含める形は採らない。全ての群が 2 回ずつ同じ理由で落ち、全件が見送りになってから気づく。 - -### 決定 8: 作業を任せる担当の決定は `rounds.impl_for_seq` の 1 つを通す - -`rounds.impl_for_seq(state, seq)` を新設し、輪番の通し番号から担当を引く呼び出しはその中だけにする。呼ぶのは次の 3 か所で、 -いずれも `apply_seq` を進めて CLI に作業を任せる担当を決める。 - -| 呼び出し元 | 決めるもの | -| --- | --- | -| `apply._assign_apply_rounds_to_state` | 群を割り当てたときの担当 | -| `apply._close_failed_attempt` | 結果を残さなかった群の交代先 | -| `gate._final_fix_impl`(`gate.py:128` を置き換える) | 最終ゲートの修正担当 | - -置き場所を `rounds.py` にするのは、`apply.py` と `gate.py` の両方が読む層だからである(`commands` どうしの取り込みを作らない)。 -D-B が除外(#478)を足すとき、変える呼び出しは 1 か所で済む。`assignment.py` は変えない。 - -`setup.cmd_start_round`(`setup.py:467`)の `assignment.assign` は通さない。引くのは提案ラウンドの記録上の担当で、 -骨組みは適用の前に `next-apply-round` が返す群の担当で `IMPL` を上書きするため、この担当は CLI を起動しない。 - -### 決定 9: 採用 0 件の提案ラウンドでは群を作らない - -`rounds.apply_groups` は、`apply_rounds` が空の配列のときも古い版の状態として扱う(`rounds.py:108-110` の `if groups:`)。 -そのため、ラウンド全体を 1 つの群にしている。`merge-proposals` は採用 0 件で `apply_rounds = []` を書くため、 -ここで項目 0 件の群が生まれる。鍵が無い(`None`)ときだけ古い版として扱い、空の配列はそのまま返す。 - -`merge-proposals` がテスト整備の採用 0 件で終了コード 2 を返す形は採らない。2 は構造改善の繰り返しを終える合図で、 -テスト整備では構造改善へ進む前に抜けてしまう。 - -### 決定 10: 項目の無い群は開かずに取り消し、取り込み済みで採用 0 件の群も取り消しへ直す - -決定 9 の後も、既に項目の無い群を持つ状態ファイル(rf587)は残る。`next-apply-round` は `items` が空の `pending` の群を `dropped`(`drop_reason: empty`)にして次の群を探す。 -`merge-apply` の取り込み済みの判定で採用 0 件だったときは、群が `dropped` でなければ `dropped` にしてから終了コード 2 を返す。 - -### 決定 11: トレーラーは、末尾から続くトレーラーの段落を git の判定で読む - -`commit_trailers` はメッセージを空行で段落に分け、末尾の段落から前へ向かって、1 段落ずつ `git interpret-trailers --parse` に掛ける。 -git がトレーラーの段落と判定しなかった段落で止め、それまでに読んだ段落の値を合わせる。同じ鍵は末尾に近い段落の値を採る。**1 段落目(題名)は掛けない。** -掛けると、本文がトレーラーだけのコミットで `Refactor: …` の形の題名をトレーラーとして読む(「実測」の節)。 - -| issue の案 | 採否 | 理由(「実測」の節) | -| --- | --- | --- | -| `git interpret-trailers --parse` へ替える | 採らない | `%(trailers:only,unfold)` と同じく最後の段落しか読まない | -| 全文から `^: ` を拾う | 採らない | 散文の段落にある `Round: …` の形の行を拾う | -| 雛形に最後の段落へ置くよう書く | 補助として採る(決定 12) | 実装担当が従わなければ落ちる | -| 進行側が `--amend` でトレーラーを足し直す | 採らない | SHA が変わり、結果ファイルの申告との対応が切れる | - -段落ごとの判定を git に任せるため、トレーラーの定義(区切り文字・折り返し・25% の規則)を自前で持たない。散文の段落で止まるため、本文中の `Key: value` の形の行は読まない。 - -`claude -p` に `--settings` で帰属行を消させる形は採らない。帰属行を書くのはモデルで、利用者の `CLAUDE.md` の指示でも足される。 -共通層の `launch-cli.sh` は cross-review も使う。 - -### 決定 12: 雛形のコミットの規約にも、必須トレーラーを最後の段落に置くことを書く - -進行側の検証は決定 11 で通る。一方、人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計すると、 -最後の段落しか読まない。`docs/02-apply-and-review.md` は、この集計を理由にトレーラー形式を選んでいる。帰属行を同じ段落に続けて書けば、 -git の標準の読み方でも取れる。 - -### 決定 13: 無進捗の許容を `--test-timeout` + 900 秒にし、雛形に進捗マーカーを足す - -適用・修正の担当はテストを 1 回実行し、その間は何も出力しない。テストの上限は `--test-timeout`(既定 900)である。 claude は `--output-format json` -のため、完了まで出力しない(監視の既定の許容 900 秒はこのため)。 2 つを足した値が、正常に動いていても出力が無い最長の時間になる。`init` がこの値を `IMPL_STALL_TIMEOUT` -として出し、骨組みの適用・修正・最終ゲートの修正の監視が `--stall-timeout` に渡す。`monitor.py` は変えない。 - -**監視の上限(`--timeout`)は渡さない。** 上限は D-A の P2 が `--phase` と `lib/limits.py` で工程ごとに決め(既定 3600 秒)、修正と -最終ゲートの修正が 420 秒で打ち切られる件も P2 が直す。P6 は P2 が入れた `--phase` の呼び出しに `--stall-timeout` だけを足す。 - -雛形の進捗マーカーは、次に STALLED が出たときに担当が動いていたかを読むためにも足す。rf646 の agy のログは作業ツリーとともに消えており、 -この設計の時点では確かめられなかった(未確認 1)。 - -**`--test-timeout` は、`apply` / `fix` / `final-fix` の監視の上限 − 900 未満を前提にする**(P2 の既定 3600 秒なら 2700 未満)。 -監視の上限は `MONITOR_TIMEOUT_<担当>` / `MONITOR_TIMEOUT` で上書きされうる。この値以上にすると許容が監視の上限以上になり、監視の上限で -打ち切られ、P2 の監視が担当名と 2 つの値を警告する(D-A の AC33)。D-A の AC31 が固定する「無進捗の許容 < 監視の上限」は担当ごとの -既定の許容の組で、この許容(既定 1800 < 3600)はその順序を崩さない。監視の上限を許容から導く形は採らない。上限の表は P2 の -`limits.py` が 1 つだけ持ち、適用の所要の実測も無い(未確認 6)。 - -進捗マーカーだけで足りるとする形は採らない。テストの実行中はマーカーを書けない。 `MONITOR_STALL_AGY` などの環境変数に委ねる形も採らない。 -担当ごとに値を覚えさせることになり、cross-review にも効く。 - -## 実測 - -### #647: 結果を残さない群が開き直され続ける - -一時のテストファイルで `cmd_next_apply_round` → `cmd_merge_apply` を直接 4 回呼んだ(コミットしない)。 -補助は `tests/conftest.py` の `no_git` / `patch_lib` と、`test_merge_apply.py` の `_state_with_items` -/ `git_facts` である。 - -| 状態 | 開いた群 | `merge-apply` の終了コード | 群の `status` | -| --- | --- | --- | --- | -| 群 2 つ(agy / codex)、結果ファイルなし | `[1, 1, 1, 1]` | `[2, 2, 2, 2]` | `pending`, `pending` | -| 同上で着手前テストが `red`(2 回) | `[1, 1]` | `[2, 2]` | `pending`, `pending`(項目は `blocked`) | - -輪番の担当は `plugins/ndf/scripts/lib/assignment.py` の `assign` を `seq` 1〜8(ホスト claude)で引くと `codex, agy, kiro, claude, codex, agy, kiro, claude` -だった。4 で 1 周するため、`apply_seq` を 1 進めるだけでは失敗した担当へ戻ることがある(決定 3)。 - -### 範囲へ入れたもの: `merge-fix` が提案ラウンドの担当を読む - -群の担当 agy、提案ラウンドの担当 codex で `agy-fix-r1-result.json` を置き、`cmd_merge_fix` を 3 回呼んだ。 -3 回とも終了コード 2 で `❌ codex の結果ファイルがありません`、`fix_rounds` は 0 のままだった。 `merge-fix` は `entry["impl"]`(`converge.py:463`)を読み、 -骨組みは `launch-cli.sh "$IMPL" fix` で群の担当を起動する。 - -### #592: 採用 0 件で項目の無い群が開く - -テスト整備ラウンドで提案 0 件の状態から `cmd_merge_proposals`(終了コード 0)→ 上と同じ 4 回を呼んだ。 - -| 結果ファイル | 開いた群 | 終了コード | 群 | -| --- | --- | --- | --- | -| なし | `[1, 1, 1, 1]` | `[2, 2, 2, 2]` | `items: []`、`status: pending` | -| `{"items": []}` | `[1, 1, 1, 1]` | `[2, 2, 2, 2]` | `items: []`、`status: applied`(rf587 と同じ形) | - -`apply_groups` を「鍵が `None` のときだけ群を作る」に差し替えて、同じ手順を試した。`next-apply-round` は 1 回目で終了コード 1 を返し、 -`apply_rounds` は `[]` のままだった。 - -### #553: 段落ごとの読み取り - -git 2.53.0 の一時リポジトリでコミットを作り、2 つの読み方を比べた。1 つは `git log -1 --format='%(trailers:only,unfold)'` である。 -もう 1 つは決定 11 の読み方の試作で、Python から段落ごとに `git interpret-trailers --parse` を呼ぶ。 - -| メッセージの末尾 | `%(trailers:only,unfold)` | 試作 | -| --- | --- | --- | -| 必須 4 つ / 空行 / `Co-Authored-By` | `Co-Authored-By` だけ | 5 つ | -| 必須 4 つ / 空行 / `Co-Authored-By` + `Claude-Session` | 帰属行 2 つだけ | 6 つ | -| 必須 4 つと `Co-Authored-By` が同じ段落 | 5 つ | 5 つ | -| `Round: 本文の説明行` / 散文 / 必須 4 つ / 帰属行 | 帰属行だけ | 帰属行と必須 4 つ(`Round` は `4`) | -| 散文 + `Item-Id: R9-999` の段落 / 帰属行 | 帰属行だけ | 帰属行だけ | - -`printf 'Refactor: 重複を除く\n\nRefactor: 重複を除く\n' | git interpret-trailers --parse` は `Refactor: 重複を除く` -を返した。題名を段落として git に掛けると、題名をトレーラーとして読む。 - -`git log -1 --format=%B | git interpret-trailers --parse` は 1 行目と同じく帰属行だけを返した。 -`interpret-trailers --parse` へ替えるだけでは効かない。 `git interpret-trailers --parse` は題名の無い入力(段落 1 つだけ)ではトレーラーを返さなかったため、 -試作は `<題名>\n\n<段落>` の形で渡している。 - -## 構成要素 - -| 要素 | 責務 | 課題 | -| --- | --- | --- | -| `rounds.apply_groups`(変更) | 鍵が無いときだけ古い版として群を 1 つ作る。空の配列はそのまま返す | #592 | -| `rounds.current_group`(変更) | 群が 1 つも無いときは中断(4)する | #592 | -| `apply.cmd_next_apply_round`(変更) | 項目の無い `pending` の群を取り消して飛ばす。開くときに試行番号を進める(決定 5) | #647 #592 | -| `apply.cmd_merge_apply`(変更) | 取り込み済みで採用 0 件の群を取り消しへ直す。着手前テストと範囲の検査を結果の読み取りより前に置き、4 で中断する。結果を読めなければ `_close_failed_attempt` へ渡す | #647 #592 | -| `apply._close_failed_attempt`(新設) | 範囲のコミットを取り消し、失敗した試行を記録する。上限未満なら担当を替え、上限なら群を取り消して項目を見送る | #647 | -| `rounds.impl_for_seq`(新設) | 輪番の通し番号から担当と要求モデルを返す。作業を任せる担当を決める呼び出しはすべてこれを通す(決定 8) | #647 | -| `gate._final_fix_impl`(変更) | 最終ゲートの修正担当を `rounds.impl_for_seq` で引く | #647 | -| `apply._monitor_reason`(新設) | `monitor_outcome.read_outcome(tmp_dir, "-apply-r")` の `reason` が `timeout` / `stalled` / `early_error` / `usage_limit` / `cli_timeout` / `pidfile_bad` ならその値を返す。`ok` / `missing` / ファイルなしなら `load_result` の問題(`missing` / `unparsable`)を返す(D-A の AC55 と同じ規則) | #647 | -| `gitfacts.load_result`(新設) | 結果ファイルを読み、`(payload, problem)` を返す。問題は `missing`(無い)/ `unparsable`(JSON として読めない・オブジェクトでない)。中断しない | #647 | -| `gitfacts.read_result`(変更なし) | 最終ゲートの修正(`gate.py`)が引き続き使う。結果が無いときの扱いは #674 | — | -| `converge.cmd_merge_fix`(変更) | 群の担当の結果を読む。結果を読めなければ範囲を取り消し、修正ラウンドを 1 つ進めて 2 で終わる | 範囲へ入れたもの | -| `gitfacts.commit_trailers`(変更) | 末尾から続くトレーラーの段落を読む(決定 11) | #553 | -| `vocabulary.py`(変更) | `MAX_APPLY_ATTEMPTS = 2` と `IMPL_STALL_MARGIN = 900` | #647 | -| `setup._emit_init`(変更) | `IMPL_STALL_TIMEOUT` を出す | #647 | -| `prompts/apply.md` / `fix.md`(変更) | 必須トレーラーを最後の段落に置く。進捗マーカー | #553 #647 | -| `prompts/final-fix.md`(変更) | 進捗マーカー | #647 | -| `SKILL.md`(変更) | 語の表の適用ラウンドの上限、「別の上限を置かない」の段落の削除、骨組みの監視の引数と終了コード 2 の注記 | #647 | -| `docs/02-apply-and-review.md` / `docs/04-fix-and-report.md`(変更) | Step 4 と Step 6 の骨組みの監視の引数、開き直しの規則、トレーラーの読み方、修正の結果が無いとき | 全体 | - -```mermaid -graph TD - N[next-apply-round] --> G[rounds.apply_groups
current_group] - A[merge-apply] --> G - A --> LR[gitfacts.load_result] - F[merge-fix] --> LR - A --> C[_close_failed_attempt] - C --> S[rounds.impl_for_seq] - S --> AS[assignment.assign] -``` - -```mermaid -graph TD - I[init] -->|IMPL_STALL_TIMEOUT| M[monitor.py] - M --> MR[監視の結果ファイル
D-A の P1] - C[_close_failed_attempt] --> R[_monitor_reason] - R --> MR -``` - -上の図は群の判定、下の図は監視との関係を描く。雛形・文書と、`commit_trailers`(`merge-apply` の検証が呼ぶ 1 本だけ)は含めない。`assignment.assign` と監視の結果ファイルは変えない要素である。 - -### 文脈と配置 - -```mermaid -graph TD - 利用者 --> ホスト[ホストの CLI セッション] - ホスト -->|骨組みの bash| RF[refactor.py] - ホスト -->|launch-cli.sh| 担当[適用・修正の担当 CLI] - ホスト --> 監視[monitor.py] - RF --> WORK[work/ の git] - 担当 --> WORK - 担当 --> 結果[結果ファイル
progress.log] - 監視 --> 結果 - 監視 --> 記録[監視の結果ファイル] - RF --> 記録 -``` - -**配置は変えない。** すべて利用者の機械のホストのセッションから起動するプロセスで、常駐しない。図は状態ファイルと Pull Request への push を含めない。この変更で増える辺は、`refactor.py` が監視の結果ファイルを読む 1 本だけである。 - -### 変わるファイル - -```text -plugins/ndf/skills/cross-refactoring/ -├── SKILL.md # 語の表・段落の削除・骨組み -├── docs/02-apply-and-review.md # Step 4 の骨組み・開き直し・トレーラー -├── docs/04-fix-and-report.md # Step 6 の骨組みの監視の引数・修正の結果が無いとき -├── prompts/apply.md # トレーラーの段落・進捗マーカー -├── prompts/fix.md # 同上 -├── prompts/final-fix.md # 進捗マーカー -├── scripts/refactor_lib/ -│ ├── rounds.py # apply_groups / current_group / impl_for_seq -│ ├── gitfacts.py # load_result / commit_trailers -│ ├── vocabulary.py # MAX_APPLY_ATTEMPTS / IMPL_STALL_MARGIN -│ └── commands/ -│ ├── apply.py # next-apply-round / merge-apply / 補助 3 つ -│ ├── converge.py # merge-fix -│ ├── gate.py # _final_fix_impl -│ └── setup.py # _emit_init -└── tests/ - ├── test_apply_attempts.py # 新設: AC1〜AC12、AC16 - ├── test_apply_rounds.py # AC13〜AC15 - ├── test_abandon_items.py # AC17〜AC19 - ├── test_commit_trailers_git.py # 新設: AC20〜AC26(一時リポジトリ) - ├── test_init.py # AC28 - ├── test_merge_apply.py # AC34 - ├── test_final_fix.py # AC37 - └── test_skill_terms.py # AC27、AC29〜AC32 -# dev.kiro / dev.agy の配布物は bash scripts/build-runtime-plugins.sh で同期する -``` - -## データ構造(状態ファイルの群) - -状態ファイル(`cross-refactoring-rf<番号>-state.json`)の `rounds[].apply_rounds[]` に項目を足す。 -**版は上げない。** 足す項目が無い状態ファイルは、試行番号 0・失敗の記録なしとして読む。 - -| 項目 | 型 | 値 | 空のときの意味 | -| --- | --- | --- | --- | -| `attempt` | 整数 | いま開いている試行の番号。1 から | 鍵なし = 0(まだ開いていない)。`merge-apply` は 0 を 1 回目として記録し、値を 1 に書く(変更前の版で開いた群を再開したとき) | -| `failed_attempts` | 配列 | 結果を残さなかった試行。1 件 = `{attempt, impl, reason, at, reverted}` | 鍵なし = 失敗なし | -| `failed_attempts[].reason` | 文字列 | 監視の理由の名前(`stalled` など)。監視が `ok` / `missing` か結果ファイルが無ければ `load_result` の問題(`missing` / `unparsable`) | — | -| `failed_attempts[].reverted` | 整数 | その試行の範囲から取り消したコミットの数 | 0 = コミットなし | -| `drop_reason` | 文字列 | `no_result`(試行の上限)/ `empty`(項目なし) | 鍵なし = 既存の経路で取り消した、または取り消していない | -| `impl` / `impl_model` | 既存 | 担当を替えたときに書き換える。**前の担当は `failed_attempts[].impl` に残る** | — | - -`impl` を書き換えても、どの担当がどの試行で失敗したかは `failed_attempts` から読める。群を取り消したときの見送りの理由(`deferred_items[].defer_reason`)は、 -`実装担当が結果を残しませんでした(agy: stalled → codex: missing)` の形にし、改修計画の「見送った項目」の表にそのまま出る。 - -ラウンドの項目(`rounds[]`)は `fix_merged_keys` に `":missing"` の形の鍵を足す(AC19)。 - -### 群の状態の遷移 - -```mermaid -stateDiagram-v2 - [*] --> pending: merge-proposals - pending --> dropped: next-apply-round(items が空) - pending --> pending: merge-apply(結果を残さない・attempt < 2)
担当を替える - pending --> dropped: merge-apply(結果を残さない・attempt = 2) - pending --> dropped: merge-apply(未割当・検証の失敗) - pending --> applied: merge-apply(取り込んだ) - applied --> dropped: merge-apply(取り込み済み・採用 0 件) - applied --> verified: verify-round(通った) - applied --> dropped: abandon-items / merge-test-judgements - verified --> [*] - dropped --> [*] -``` - -**`pending` のまま同じ担当で開き直す遷移は無い。** この変更で足す遷移は 4 本で、`items` が空の取り消し、担当の交代、 -試行の上限での取り消し、取り込み済み・採用 0 件の取り消しである。 - -## 入出力の契約 - -### コマンドの終了コード - -| コマンド | 0 | 1 | 2 | 4 | -| --- | --- | --- | --- | --- | -| `next-apply-round` | 群を開いた(変更なし) | 残りの群が無い(変更なし。群が無いラウンドも含む) | — | — | -| `merge-apply` | 取り込んだ(変更なし) | — | **この群を取り消した、または担当を替えて開き直す**(意味を広げる) | 着手前テストが `green` でない・範囲を確定できない(2 から変更)・群が無い(新) | -| `merge-fix` | 取り込んだ(変更なし) | — | 範囲を確定できない(変更なし)・**結果を読めない(新。修正ラウンドは進む)** | 群が無い(新) | -| `verify-round` / `abandon-items` / `merge-test-judgements` | 変更なし | — | 変更なし | 群が無い(新) | - -「群が無い(新)」は、群が 1 つも無いラウンドで `current_group` を呼んだときの中断である。`next-apply-round` は `current_group` を呼ばない。 - -骨組みは `merge-apply` の 2 で `continue` し、`merge-fix` の終了コードを見ない。**どちらも変えない。** - -### `init` の出力 - -`IMPL_STALL_TIMEOUT=` を足す。`test_timeout` は状態ファイルの値で、 -再開時も同じ値を出す。 - -### 骨組み(`SKILL.md` の「実行」の差分) - -```bash - "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-apply-r$ROUND" --phase apply \ - --stall-timeout "$IMPL_STALL_TIMEOUT" - # 終了コード 2 = この群を取り消した、または担当を替えて開き直す。修正ラウンドは回さない - rf merge-apply "$ID" "$ROUND" || continue -``` - -差分は `--stall-timeout` の 1 行だけで(`--phase apply` は P2 の形)、修正と最終ゲートの修正の監視にも同じ行を足す。`--timeout` は渡さない。 - -### 雛形に足す文 - -| 雛形 | 足す文 | -| --- | --- | -| `apply.md` / `fix.md` のコミットの規約 | 4 つのトレーラーは**メッセージの最後の段落**に置く。実行環境が帰属行(`Co-Authored-By:` など)を足すときは、空行を挟まず同じ段落に続ける | -| `apply.md` / `fix.md` / `final-fix.md` | 作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記する(`start` / `edit` / `test` / `commit` / `done` と対象だけ。推論は書かない)。形は cross-review の `launch-reviewer.sh` の「進捗マーカー」に揃える | - -## 処理の流れ - -### `next-apply-round` - -```mermaid -graph TD - S[群を順に見る] --> P{status が pending か
applied の群がある} - P -->|無い| E1[終了コード 1] - P -->|ある| K{status} - K -->|applied| O[開き直す
起点・試行番号を動かさない] - K -->|pending| EM{items が空} - EM -->|はい| D[dropped
drop_reason: empty] --> S - EM -->|いいえ| Q{失敗の記録の数 ==
attempt} - Q -->|はい| INC[attempt を 1 進め
起点を HEAD にする] - Q -->|いいえ・再開| OUT[APPLY_ROUND / IMPL を出す] - INC --> OUT - O --> OUT -``` - -再開で試行番号を進めないときも、`apply_round` と `apply_base_sha` は群の値から書き直す。 - -### `merge-apply` - -```mermaid -graph TD - B[置き土産を捨てる / 取り消しと push の再開] --> G{取り込み済みか} - G -->|採用あり| R0[終了コード 0] - G -->|採用 0 件| DR[群が dropped でなければ dropped] --> R2[終了コード 2] - G -->|未取り込み| DUP{同じ attempt の失敗の記録がある} - DUP -->|はい| R2 - DUP -->|いいえ| BL{着手前テストが green} - BL -->|いいえ| A4[終了コード 4] - BL -->|はい| RG{範囲を確定できる} - RG -->|いいえ| A4 - RG -->|はい| LD{load_result が読めた} - LD -->|はい| V[既存の検証と取り込み] - LD -->|いいえ| CF[_close_failed_attempt] --> R2 -``` - -`_close_failed_attempt` は次の順で行う。 - -1. 範囲にコミットがあれば全範囲を新しい順に取り消し、群と記録の起点を取り消し後の HEAD にする(push の印を先に立てる) -2. `failed_attempts` へ `{attempt, impl, reason: _monitor_reason(...), at, reverted}` を足す(`attempt` が 0 なら 1 として記録する) -3. `attempt < MAX_APPLY_ATTEMPTS` なら、`apply_seq` を 1 ずつ進めて `rounds.impl_for_seq` を引き、`failed_attempts[].impl` のどれとも違う担当が出たらその担当と要求モデルで `impl` / `impl_model` を書き換える。4 回進めても出なければ(除外で候補が 1 者しかない)、手順 4 と同じく群を取り消す -4. `attempt >= MAX_APPLY_ATTEMPTS` なら群の項目を `abandoned` にして `deferred_items` へ入れる。群は `dropped`(`drop_reason: no_result`)にし、`apply.merged_at` を立て、局面を `phase_after_group` にする -5. 保存し、取り消しがあったときだけ push する(`push_with_retry_marker`) - -### `merge-fix` - -担当は `current_group(entry)["impl"]`(無ければ `entry["impl"]`)から読む。`fix_merged_keys` に `":missing"` が -あれば、結果ファイルを読まずに 2 を返す(`merge-apply` の決定 5 と同じ理由。欠落の後に遅れて書かれた結果を取り込まない)。 -無ければ `load_result` を呼び、読めなければ範囲(`fix_base_sha`..HEAD)にコミットがあるときだけ `revert_unverified_range` で -取り消し、鍵を足し、`fix_rounds` を 1 進めて保存し、2 を返す。範囲を確定できないときの既存の扱い(`_resolve_fix_range`)と同じ形である。 - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 中断と再開を挟まない実行で、適用担当の起動が群の数 × 2 回、修正担当の起動が群の数 × `--max-fix-rounds` 回を超えない | 試行番号の上限(決定 2・5)と、結果が無い修正でも `fix_rounds` を進めること | AC3(開く回数)と AC18(`should-abandon` が上限で 0) | -| 運用・保守性 | 群を取り消した理由が改修計画の「見送った項目」から読める | `defer_reason` に担当と理由の名前を並べる。`plan.py` の表は `defer_reason` をそのまま出す | AC2 で `defer_reason` に `agy` と理由の名前が入ることを見る | - -## 他の設計との契約 - -| 相手 | 契約 | D-C の扱い | -| --- | --- | --- | -| D-A(P1) | 監視が担当ごとの結果を `-monitor.json` へ残し、`monitor_outcome.read_outcome(tmp_dir, stem)` で読める(D-A の契約の文書) | `_monitor_reason` だけが `read_outcome(tmp_dir, "-apply-r")` の `reason` を読む。`ok` / `missing` / ファイルなしは `load_result` の問題へ置き換える(D-A の AC55 と同じ規則。壊れた JSON は監視から見ると `ok`)。振る舞いは変えない。追記だけの `monitor-outcomes.jsonl` は読まない | -| D-A(P3) | 理由の名前は #619 の語彙を基本とし、claude の `"api_error_status":429` を `usage_limit` として早期に打ち切る | 名前を解釈しない。記録へ写すだけ | -| D-A(P1 の計測) | 工程の所要は監視の記録と `refactor.py` の `statefile.save` の差し込み口から組み立て、骨組みと `refactor_lib` の取り込みには足さない(D-A の決定 9) | 計測の呼び出しを置かない。`refactor.py` の差し込み口と `commands/report.py` の要約の行は D-A が足すため、P6 はその上に載せる。**要約の `apply_attempts` は、鍵 `"r<ラウンド>-g<群>"` → `{"attempts": , "failed": , "dropped_reason": }` を状態ファイルの群から作る**(D-A の AC14 の「群ごとの試行回数」の出どころ。監視の記録からは作らない)ことを前提 1 に含める | -| D-A(P2) | 監視の上限は `--phase` と `lib/limits.py` が工程で決め、cross-refactoring の 8 か所は `--timeout` を渡さない(D-A の AC37)。修正と最終ゲートの修正の 420 秒の打ち切りも P2 が直す。8 か所の引数は P2 が変え、D-C は上限の値を書かない(D-A の申し送り) | `--timeout` を足さず、`--phase` が `apply` / `fix` / `final-fix` の呼び出しに `--stall-timeout` だけを足す。**これは申し送りと D-A の決定 10(無進捗の許容は担当の軸だけで決め、既定値は `limits.py` の表だけが持つ)に対する例外である。** 工程(テスト 1 回を含む適用・修正)に依存する許容を、D-C が骨組みの引数で渡す。D-A の申し送りの行と決定 10 へ同じ例外を書き足すことを前提 1 に含める。許容が監視の上限以上になる組は P2 の警告(D-A の AC33)に任せる | -| D-A(全体) | 監視の終了コードの意味(2 / 3 / 4 / 5 / 6)と `--stall-timeout` の引数は変えない | 骨組みは終了コードで分岐しない(決定 4) | -| D-B(P4 / P5) | 除外(#478)は `assignment.assign` か、その呼び出し側に入る | 適用・交代・最終ゲートの修正の担当を決める呼び出しは `rounds.impl_for_seq` の 1 か所(決定 8)。D-B が先に入れば P6 がそこへ寄せ、P6 が先なら D-B がそこを変える。`setup.py:467` の提案ラウンドの担当は CLI を起動しないため対象外 | - -## テスト設計 - -実行は `uv run --with pytest pytest plugins/ndf/skills/cross-refactoring/tests -q`。 -状態の遷移は関数を直接呼ぶ既存の形(`no_git` / `patch_lib` / `git_facts`)、トレーラーは一時リポジトリで実際に git を実行する。 - -| 受け入れ条件 | 何で確かめるか | 置き場所 | -| --- | --- | --- | -| AC1、AC2、AC4、AC5 | 群 2 つの状態で結果ファイルを置かずに(AC4 は壊れた JSON と配列で)呼び、`status` / `impl` / `failed_attempts` / `apply_seq` / `deferred_items` を見る。AC5 は群 4 つ(`apply_seq` 4)で先頭の群を失敗させる | `test_apply_attempts.py` | -| AC3 | `next-apply-round` が 1 を返すまで繰り返し、呼び出し回数 5 と両群の `dropped` | `test_apply_attempts.py` | -| AC6、AC7 | `merge-apply` の 2 度呼び、`next-apply-round` の 2 度呼びで、記録の件数と `attempt` が変わらない。AC6 は替えた先の担当の古い結果ファイルを置いた状態でも確かめる | `test_apply_attempts.py` | -| AC8 | `commits_in_range` が 1 件返す状態で、`no_git` の記録に `git revert` が出て、群の `base_sha` が変わる | `test_apply_attempts.py` | -| AC9、AC10 | 着手前テスト `red`、`commits_in_range` が `None` で、`SystemExit` の値が 4 | `test_apply_attempts.py` | -| AC11 | 4 つの経路でパラメータ化し、終了コード 2 の後の群が `dropped` か失敗の記録付き `pending` | `test_apply_attempts.py` | -| AC12 | `-apply-r-monitor.json` に `reason: stalled` を置いた場合、ファイルが無い場合、`reason: ok` で結果ファイルが壊れた JSON の場合 | `test_apply_attempts.py` | -| AC13 | テスト整備ラウンドの採用 0 件から `merge-proposals` → `next-apply-round` が 1、`apply_rounds == []` | `test_apply_rounds.py` | -| AC14 | `apply_rounds` の鍵を消した状態で群が 1 つ作られる(既存のテストを確かめ直す) | `test_apply_rounds.py` | -| AC15 | rf587 の形の状態で `merge-apply` → 2、群 `dropped`、`next-apply-round` → 1 | `test_apply_rounds.py` | -| AC16 | `items: []` の `pending` の群と、項目のある群を並べて `next-apply-round` | `test_apply_attempts.py` | -| AC17〜AC19 | 群の担当と提案ラウンドの担当を分けた状態で `merge-fix`。AC18 は `should-abandon` まで回す | `test_abandon_items.py` | -| AC20〜AC24 | 一時リポジトリで各形のコミットを作り、`commit_trailers` の戻り値を比べる | `test_commit_trailers_git.py` | -| AC25 | 同じ一時リポジトリで `collect_commit_facts` → `verify_apply_round` が `None` | `test_commit_trailers_git.py` | -| AC26 | 同じ一時リポジトリで、題名が `Round: …` の形で本文がトレーラーの段落だけのコミットを作り、`Round` が題名の値にならない | `test_commit_trailers_git.py` | -| AC27、AC30 | 雛形の文言を `grep` で探す | `test_skill_terms.py` | -| AC28 | `_emit_init` の出力に `IMPL_STALL_TIMEOUT=1800`(`test_timeout` 900 の状態) | `test_init.py` | -| AC29、AC31 | `SKILL.md` の骨組みから `monitor.py` の呼び出し 4 つを取り出し、`--phase` が `apply` / `fix` / `final-fix` の 3 つが `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない。語の表の行と段落の有無 | `test_skill_terms.py` | -| AC32 | `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6 の `monitor.py` の引数(`--phase` と `--stall-timeout`、`--timeout` なし)が、`SKILL.md` の同じ呼び出しと一致する | `test_skill_terms.py` | -| AC33 | トレーラーの節の記載をレビューで見る | 手動 | -| AC34 | 既存の `test_a_verified_apply_round_marks_every_item_applied` に `failed_attempts` が無いことを足す | `test_merge_apply.py` | -| AC35、AC36 | 全体のテスト、`bash scripts/build-runtime-plugins.sh --check`、`claude plugin validate .`、`python3 scripts/check-skill-frontmatter.py` | 手動 | -| AC37 | `impl_for_seq` を差し替え、群の割り当て・担当の交代・最終ゲートの修正担当の 3 つが差し替えた担当になる | `test_final_fix.py` / `test_apply_attempts.py` | - -## 未確認のまま残ること - -| # | 項目 | 内容 | 決める時点 | -| --- | --- | --- | --- | -| 1 | rf646 で agy が打ち切りの時点で動いていたか | ログは作業ツリーとともに消えていた(`find / -name 'agy-apply-r*'` で該当なし)。決定 13 は動いていた場合も止まっていた場合も成り立つ | 次の実行で `progress.log` を読む | -| 2 | 担当が雛形の進捗マーカーに従うか | cross-review の agy は従っている(heartbeat に作業段階が出る)。claude / codex / kiro の適用で従うかは起動して確かめていない。従わなくても決定 13 の許容で打ち切られないのはテスト 1 回分まで | 実装後の最初の実行 | -| 3 | 担当を替えた後の担当も結果を残さない割合 | 2 回目で救える群の数は測っていない。実行の要約の `apply_attempts`(群ごとの `attempt` と `failed_attempts` の件数)で数える | 配布後 | -| 4 | Claude Code 以外の担当が帰属行を足すか | codex / agy / kiro のコミットで帰属行の段落を見ていない。決定 11 は誰が足しても同じに読む | 確かめなくてよい | -| 5 | 帰属行を同じ段落に続ける指示に claude が従うか | 決定 12 は補助で、従わなくても決定 11 で検証は通る | 実装後の最初の実行 | -| 6 | `--test-timeout` が監視の上限 − 900(既定 2700)以上の利用 | 適用・修正の所要を測っていない。#662 の計測で工程ごとの所要が取れたら、P2 の上限の表と合わせて見直す | 配布後 | diff --git a/issues/issue-647-592-553-requirements.md b/issues/issue-647-592-553-requirements.md deleted file mode 100644 index 54270cdc..00000000 --- a/issues/issue-647-592-553-requirements.md +++ /dev/null @@ -1,262 +0,0 @@ -# #647 / #592 / #553: cross-refactoring の適用ラウンドを有限回で終わらせ、帰属行の後ろでもトレーラーを読む - -設計は [issue-647-592-553-design.md](issue-647-592-553-design.md) にある。この文書は「何を満たすか」だけを扱う。 - -**3 つの課題は 1 本の Pull Request(P6)で直す。** #647 と #592 は入口が違うが、同じ -`next-apply-round` → `merge-apply` の繰り返しが止まらない。#553 は同じ適用の検証で群を落とす。 -マイルストーン 21 の実装順では P3(監視の結果の分類)の後に入る。 - -## 目的 - -- 実装担当が結果を残さなくても、適用ラウンドの繰り返しが有限回で終わり、後ろの群と収束の判定へ届く -- テスト整備ラウンドの採用が 0 件でも、項目の無い群を開かずに提案ラウンドへ進む -- 修正ラウンドも、結果を残さない担当で上限なしに繰り返さない -- Claude Code が帰属行を別の段落で足しても、必須トレーラーが読めて群が落ちない - -## 対象範囲 - -含む: - -- `merge-apply` が取り込みの前に抜ける 3 か所の扱い(#647) -- 同じ群の試行の上限と、2 回目の試行で担当を替えること(#647) -- 採用 0 件の提案ラウンドで群を作らないこと(#592) -- `merge-fix` が読む結果ファイルの担当と、結果が無いときの修正ラウンドの数え方(範囲内へ入れた。下の「範囲へ入れたもの」) -- `commit_trailers` の読み方と、適用・修正の雛形のコミットの規約(#553) -- 適用・修正・最終ゲートの修正の監視に渡す無進捗の許容と、雛形の進捗マーカー(#647 の STALLED 対策) -- `SKILL.md` の「この Skill で使う語」「適用ラウンドに別の上限を置かない」「実行」の骨組み -- `docs/02-apply-and-review.md` と `docs/04-fix-and-report.md` の対応箇所 - -含まない: - -| 扱わないもの | 理由 | -| --- | --- | -| `plugins/ndf/scripts/lib/monitor.py` の変更(429 の検知・理由の語彙・結果の記録) | D-A(#662 #619 #584 #583)が所有する。D-C は消費する側 | -| `plugins/ndf/scripts/lib/assignment.py` と除外の引数 | D-B(#624 #478 #648)が所有する | -| 工程ごとの所要時間の計測 | D-A(P1)が監視の記録と `statefile.save` の差し込み口から組み立てる。骨組みと `refactor_lib` の取り込みには足さない | -| 利用上限(429)で進行全体を止めること | 担当を替えて 1 回だけ再試行する扱いに含める(設計文書の決定 3) | -| 取り込み済みの群を開き直したときに適用担当を起動し直す無駄 | 回数は 1 回で有限。繰り返しにはならない | -| 骨組みの bash を `scripts/` へ出すこと | #560 | -| 最終ゲートの修正で担当が結果を残さないときのコミットの扱い | 繰り返しは `--max-fix-rounds` で既に止まり、残るのは未検証のコミットの扱いで主題が違う(#674) | -| `cross-review` 側の監視と担当 | 別の設計(D-A / D-B) | -| `CHANGELOG.md` と版数 | 配布の工程が書く | -| クラス図 | 型を変えない。変えるのは関数と、状態ファイルの辞書の項目だけで、群の項目は設計文書の「データ構造」が持つ | - -### 範囲へ入れたもの - -**`merge-fix` の担当の食い違い**(起票せず範囲内へ入れた。`out-of-scope` の 3 択の「範囲内へ入れる」)。 -`cmd_merge_fix` は提案ラウンドの担当(`entry["impl"]`)の結果ファイルを読むが、起動するのは群の担当 -(`next-apply-round` が返す `IMPL`)である。群の担当は群ごとの輪番で決まるため、2 つが一致しない群では -修正の結果を一度も取り込めない。`merge-fix` は結果が無いと `fix_rounds` を進めずに終了コード 2 で抜ける。 -骨組みは終了コードを見ないため、検証 → 修正 → 検証が上限なしに回る。一時テストで再現した。 -群の担当 agy・提案ラウンドの担当 codex で `merge-fix` を 3 回呼ぶと、3 回とも -`codex の結果ファイルがありません` で、`fix_rounds` は 0 のままだった。**#647 と同じ「結果を取り込めない -担当で繰り返しが止まらない」形で、適用の側だけ直すと修正の側が残る。** - -## 受け入れ条件(#647: 結果を残さない担当) - -試行の上限と担当の交代: - -- [ ] AC1: 群が 2 つ(1 つ目の担当 agy、2 つ目の担当 codex)の状態で、1 つ目の結果ファイルを置かずに - `next-apply-round` → `merge-apply` を呼ぶ。`merge-apply` は終了コード 2 で終わる。1 つ目の群は - `status: pending` のまま担当が agy 以外に替わり、失敗した試行の記録が 1 件(試行番号 1・担当 agy・理由)残る -- [ ] AC2: AC1 の後、替わった担当の結果ファイルも置かずにもう一度 `next-apply-round` → `merge-apply` を呼ぶ。 - 1 つ目の群は `status: dropped` になり、群の項目は `abandoned` になり、`deferred_items` に理由付きで入る -- [ ] AC3: 結果ファイルを 1 つも置かずに、`next-apply-round` が終了コード 1 を返すまで `next-apply-round` → - `merge-apply` を繰り返す。`next-apply-round` の呼び出しは 5 回(開く 4 回 + 尽きた 1 回)で終わり、 - 両方の群が `dropped` になる -- [ ] AC4: 結果ファイルが JSON として読めない場合と、JSON の配列の場合も、AC1 と同じ状態になる -- [ ] AC5: 群が 4 つ(`apply_seq` 4)あり、先頭の群(担当 codex)が結果を残さない。担当を替えた後の担当は codex - 以外で、`apply_seq` は進めた分だけ進み、他の群の担当は変わらない - -叩き直しと中断からの再開: - -- [ ] AC6: AC1 の直後に、`next-apply-round` を挟まず `merge-apply` をもう一度呼ぶ。替えた先の担当の結果ファイルが - 同じ提案ラウンドの先行の群のものとして残っていても、終了コード 2 で終わる。失敗した試行の記録は 1 件のまま、 - 担当と `apply_seq` も変わらない -- [ ] AC7: `next-apply-round` を `merge-apply` を挟まず 2 回呼ぶ(取り込みの前に進行が止まった再開)。 - 群の試行番号は 1 のまま進まない - -結果を残さない試行のコミット: - -- [ ] AC8: 結果ファイルが無く、群の起点から HEAD までにコミットが 1 件以上ある。`merge-apply` の後、 - そのコミットは取り消され、群の起点は取り消し後の HEAD になる(次に開いたときの範囲へ入らない) - -取り込みの前の他の 2 か所: - -- [ ] AC9: 着手前のテストの状態が `green` でない状態で `merge-apply` を呼ぶと、終了コード 4 で終わる -- [ ] AC10: 適用の範囲を確定できない(起点が無い、または git が範囲を返さない)状態で `merge-apply` を - 呼ぶと、終了コード 4 で終わる - -繰り返しが有限であること: - -- [ ] AC11: `merge-apply` が終了コード 2 で終わった後の群は、`dropped` か、失敗した試行の記録を持つ - `pending` のどちらかである。確かめる経路は 4 つ(結果を残さない / 未割当のコミット / 適用の検証の失敗 / - 取り込み済みで採用 0 件) - -理由の記録: - -- [ ] AC12: 監視の結果ファイル(D-A が P1 で足す)がその担当・その段の理由を持つとき、失敗した試行の記録と - 見送りの理由にその理由の名前(例 `stalled`)が入る。ファイルが無いときは `missing` が入る。監視の理由が `ok` で - 結果ファイルが壊れた JSON のときは `unparsable` が入り、`ok` は入らない - -## 受け入れ条件(#592: 採用 0 件と項目の無い群) - -- [ ] AC13: テスト整備ラウンドで提案が 0 件の状態で `merge-proposals` を呼んだ後、`next-apply-round` を - 呼ぶ。1 回目で終了コード 1 を返し、そのラウンドの `apply_rounds` は空の配列のままである -- [ ] AC14: `apply_rounds` の鍵を持たない状態ファイル(群を導入する前の版)では、`next-apply-round` が - 従来どおりラウンド全体を 1 つの群として開く -- [ ] AC15: rf587 で残った形の群で `merge-apply` を呼ぶと、終了コード 2 で終わり、群が `dropped` になる。 - 形は `status: applied`・`items: []`・`apply.merged_at` あり・`applied: []` である。続く `next-apply-round` は - 終了コード 1 を返す -- [ ] AC16: `status: pending`、`items: []` の群を持つ状態で `next-apply-round` を呼ぶ。その群は開かれずに - `dropped` になり、次の群があればそれを開き、無ければ終了コード 1 を返す - -## 受け入れ条件(範囲へ入れたもの: 修正ラウンド) - -- [ ] AC17: 群の担当が agy、提案ラウンドの担当が codex の状態で、`agy-fix-r1-result.json` を置いて - `merge-fix` を呼ぶ。agy の結果が取り込まれ、`fix_rounds` が 1 になる -- [ ] AC18: 修正の結果ファイルが無い状態で `merge-fix` を呼ぶと、終了コード 2 で終わり、`fix_rounds` が - 1 進む。`--max-fix-rounds` 回続けた後の `should-abandon` は終了コード 0(見送りへ移る)を返す -- [ ] AC19: AC18 の直後に、`verify-round` を挟まず `merge-fix` をもう一度呼んでも、`fix_rounds` は進まない。その間に修正の結果ファイルが現れても進まない - -## 受け入れ条件(#553: 帰属行の後ろのトレーラー) - -一時リポジトリで実際にコミットを作って確かめる: - -- [ ] AC20: 必須トレーラー 4 つの段落の後に、空行を挟んで `Co-Authored-By:` の段落が付いたコミットで、 - `commit_trailers` が 4 つとも値を返す -- [ ] AC21: AC20 の段落の後に `Co-Authored-By:` と `Claude-Session:` の 2 行の段落が付いても、4 つとも返す -- [ ] AC22: 必須トレーラーの段落と末尾の段落の間に散文の段落があるコミットで、散文より前にある - `Round: …` の形の行を読まない -- [ ] AC23: 末尾の段落に散文とトレーラーの形の行が混ざる(git がトレーラーの段落と判定しない)コミットで、 - その行を読まない -- [ ] AC24: 同じ鍵が 2 つの段落にあるとき、末尾に近い段落の値を返す -- [ ] AC25: AC20 の形のコミットを申告した適用ラウンドが、トレーラーの欠落で取り消されない -- [ ] AC26: 本文がトレーラーの段落 1 つだけで、題名が `Round: 本文の題名` の形のコミットで、題名を読まない - -雛形: - -- [ ] AC27: `prompts/apply.md` と `prompts/fix.md` のコミットの規約が、必須トレーラーをメッセージの - 最後の段落に置くことを書く - -## 受け入れ条件(無進捗の打ち切り) - -- [ ] AC28: `init` の出力に `IMPL_STALL_TIMEOUT` が入り、値が `--test-timeout` の値 + 900 である - (既定で 1800)。`--test-timeout` は `apply` / `fix` / `final-fix` の監視の上限 − 900 未満(P2 の既定なら 2700 未満)を前提とする -- [ ] AC29: `SKILL.md` の骨組みで、`--phase apply` / `fix` / `final-fix` の 3 つの `monitor.py` の呼び出しが - `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない -- [ ] AC30: 適用・修正・最終ゲートの修正の雛形(`prompts/apply.md` / `fix.md` / `final-fix.md`)が進捗マーカーを - 書く。作業段階ごとに `$RF_STEM-progress.log` へ 1 行追記する指示である - -## 受け入れ条件(文書) - -- [ ] AC31: `SKILL.md` の「この Skill で使う語」の適用ラウンドの行が、上限を決めるものとして同じ群の試行の - 上限(2 回)を書く。「適用ラウンドに別の上限を置かない」の段落は無くなる。 - `grep -n "別の上限を置かない\|別に置かない" SKILL.md` が何も出力しない -- [ ] AC32: `docs/02-apply-and-review.md` の Step 4 と `docs/04-fix-and-report.md` の Step 6 の骨組みが、`SKILL.md` の - 同じ呼び出しと同じ `monitor.py` の引数を持つ(`--phase` と `--stall-timeout "$IMPL_STALL_TIMEOUT"` を持ち、`--timeout` を持たない) -- [ ] AC33: `docs/02-apply-and-review.md` のトレーラーの節が 2 つを書く。`git log --format='%(trailers:…)'` が - 最後の段落しか読まないことと、進行側の読み方である - -## 受け入れ条件(退行しない) - -- [ ] AC34: 結果ファイルがあり検証を通る適用ラウンドは、変更前と同じく 1 回目の試行で取り込まれ、 - 失敗した試行の記録を持たない -- [ ] AC35: `uv run --with pytest pytest scripts/tests plugins/ndf -q` が通る -- [ ] AC36: 配布物の同期・定義・frontmatter の 3 つの検査が終了コード 0 で終わる(コマンドは「検証手段」の表) - -## 受け入れ条件(他の設計との契約) - -- [ ] AC37: `rounds.impl_for_seq` を差し替えると、群を割り当てたときの担当・結果を残さなかった群の交代先・最終ゲートの - 修正担当の 3 つが、差し替えた関数の返す担当になる - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 中断と再開を挟まない実行で、1 つの提案ラウンドで適用担当を起動する回数が群の数 × 2 回を超えない。修正担当を起動する回数も群の数 × `--max-fix-rounds` 回を超えない。取り込み済みの群の開き直しと、`merge-apply` の前に止まった再開は、その回数だけ起動が増える | -| 運用・保守性 | 群を取り消した理由(担当 2 者と、監視の理由の名前)が、改修計画の「見送った項目」の表から読める | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| `merge-apply` の終了コード | 着手前テストの未確認と範囲の未確定が 2 から 4(中断)へ変わる。`SKILL.md` の終了コードの表は既に 4 と書いている | -| 状態ファイル | 群に試行の記録が増える。既存の状態ファイルは記録が無いまま読める(試行 0 回として扱う) | -| 群の担当 | 結果を残さなかった群だけ、2 回目の試行で次の輪番の担当へ替わる | -| 無進捗の打ち切り | 適用・修正・最終ゲートの修正で、どの担当も既定の許容より長くなる(codex 180 秒 / agy・kiro 480 秒 / claude 900 秒 → 1800 秒) | -| トレーラーの読み取り | 最後の段落に加え、その直前に続くトレーラーの段落も読む。最終ゲートの修正にも同じ読み方が効く | - -## 前提 - -| # | 前提 | -| --- | --- | -| 1 | D-A の P1(監視の結果ファイルと `monitor_outcome.read_outcome`)、P2(`--phase` と `lib/limits.py` による監視の上限)、P3(理由の語彙)が先に `develop` へ入る。D-A の申し送り(cross-refactoring の監視の引数は P2 が変え、D-C は上限の値を書かない)と決定 10(無進捗の許容は担当の軸だけで決める)に、適用・修正・最終ゲートの修正の `--stall-timeout "$IMPL_STALL_TIMEOUT"` を D-C が渡す例外が書き足される。D-A の実行の要約の `apply_attempts` が、鍵 `"r<ラウンド>-g<群>"` → `{"attempts": 整数, "failed": 整数, "dropped_reason": 文字列または null}` を状態ファイルの群から作る。理由の名前は #619 の提案(`timeout` / `cli_timeout` / `usage_limit` / `early_error` / `stalled` / `not_posted` / `missing`)を基本とする | -| 2 | 監視の終了コード(2 = TIMEOUT / 3 = NO_RESULT / 4 = EARLY_ERROR / 5 = STALLED / 6 = PIDFILE_BAD)の意味は変わらない | -| 3 | `assignment.assign(seq, host)` の実装担当は 4 者の輪番で、`seq` を 1 ずつ進めれば 3 回以内に失敗した担当と別のランタイムが出る。D-B が除外を足した後も、除外されない者が 2 者以上いる | -| 4 | Claude Code が足す帰属行は、メッセージの末尾に独立した段落として付く(#553 の実測 `26a0fff`) | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --with pytest pytest scripts/tests plugins/ndf -q`(cross-refactoring だけなら `plugins/ndf/skills/cross-refactoring/tests`) | -| 配布物の同期 | `bash scripts/build-runtime-plugins.sh --check` | -| 定義の検査 | `claude plugin validate .` と `python3 scripts/check-skill-frontmatter.py` | -| 手動確認 | 次に cross-refactoring を回した実行で、`agy-apply-r*-progress.log` に作業段階が残るか(未確認のまま残ること 1) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 状態の判定は `refactor_lib/` に置き、骨組みの bash は判定を持たない(`SKILL.md` の「実行」) | -| コーディング規約 | 外部コマンド(`git interpret-trailers`)の挙動は書く前に実行して確かめる(`AGENTS.md` の DO) | -| テスト戦略 | 状態の遷移は既存の形(`tests/conftest.py` の `no_git` / `patch_lib` と `test_merge_apply.py` の `git_facts`)で関数を直接呼ぶ。トレーラーの読み取りは一時リポジトリで実際に git を実行する | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、配布物の同期の検査 | -| 確認してから行う | 同じ群の試行の上限を引数にすること(この変更では固定の 2 回) | -| 行わない | `monitor.py` / `assignment.py` の変更、骨組みを `scripts/` へ出すこと | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 群 | 適用ラウンド。書き換えるファイルが重ならない項目の集まりで、状態の `apply_rounds[]` の 1 件 | -| 試行 | 1 つの群に対して適用担当を起動し、`merge-apply` で取り込もうとした 1 回 | -| 結果を残さない | 結果ファイルが無い、JSON として読めない、JSON オブジェクトでない、のいずれか | -| 監視の結果ファイル | D-A が P1 で足す `-monitor.json`。監視が担当 1 者ごとの状態と理由を残し、`monitor_outcome.read_outcome` で読む | -| 帰属行 | Claude Code がコミットメッセージへ足す `Co-Authored-By:` / `Claude-Session:` の行 | -| トレーラーの段落 | `git interpret-trailers --parse` がトレーラーとして読む段落 | - -## 依頼(原文) - -### #647 - -> `/ndf:cross-refactoring` で、同じ適用ラウンド(書き換えるファイルが重ならない改善項目の群)の適用が**上限なしに再試行される**。後ろの群へ順番が回らず、収束の判定と最終ゲートへ届かない。 -> -> - **同じ群の適用の試行に上限を置き**、超えたら項目単位で見送る(`abandon-items` と同じ扱い)。あわせて群の状態を進め、次の群と次の担当へ移る -> - `SKILL.md:49` / `:56` の「適用ラウンドに別の上限を置かない」を、開き直しの上限を持つ形へ改める -> - 骨組みで `monitor.py` の終了コードを受け取り、STALLED / TIMEOUT / EARLY_ERROR を区別して `merge-apply` へ渡すか、進行を止めて報告する -> - `EARLY_ERROR` のうち利用上限(429)は**再試行しても直らない**ため、その場で進行を止めて報告する。`EARLY_ERROR_FATAL` に claude の `api_error_status":429` の形を足す -> - cross-refactoring の適用・修正の雛形にも進捗マーカー(`$STEM-progress.log`)の指示を足すか、適用の監視に `--stall-timeout` を渡す。先に rf646 の agy のログで、打ち切りの時点で agy が動いていたかを確かめる - -### #592 - -> **テスト整備ラウンドの採用が 0 件のとき、項目の無い適用ラウンドが開き、上限なしに同じ群を繰り返す。** 手順書の骨組み(`SKILL.md` の「実行」)をそのまま回すと、提案ラウンドへ進まない。 - -### #553 - -> `cross-refactoring` の適用ラウンドで、**claude が実装担当のときだけ**必須トレーラー(`Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model`)が読めず、群が丸ごと取り消される。 -> -> | 案 | 中身 | 気になる点 | -> | --- | --- | --- | -> | A | `commit_trailers()` を `git interpret-trailers --parse` へ変える | 同じく最終段落しか読まない。効かない | -> | B | メッセージ全文から `^: ` を正規表現で拾う | git のトレーラー定義から外れる。本文中の同名の行を拾いうる | -> | C | プロンプトへ「必須トレーラーを**最後の段落**に置く(帰属行より後ろ)」と書く | 実装担当の従い方に依存する | -> | D | 進行側が取り込みの直前に `git commit --amend` でトレーラーを足し直す | 実装担当のコミットを進行側が書き換える | - -(3 件の issue の本文から抜粋。全文は `gh issue view 647` / `gh issue view 592` / `gh issue view 553`) diff --git a/issues/issue-662-598-537-619-584-583-design-contracts.md b/issues/issue-662-598-537-619-584-583-design-contracts.md index f322a801..e8d6f928 100644 --- a/issues/issue-662-598-537-619-584-583-design-contracts.md +++ b/issues/issue-662-598-537-619-584-583-design-contracts.md @@ -122,6 +122,8 @@ classDiagram ### 理由の語彙 +**P3 の語彙と「P3 で足す文言」は、結果なしの理由を共通層の 1 か所で読む設計(#729)へ移した。確定仕様は [起動 1 回の結末](../docs/specifications/cross-review-launch-outcome.md) の「結末の語彙」「データ・設定」にある。** 以下は 2026-09-15 時点の記録として残す(`unparsable` の追加と起動し直しの可否は新しい設計だけが持つ)。 + **監視が書く理由**(`monitor_outcome.REASONS`): | 理由 | 状態 | 入る Pull Request | 何が起きたか | @@ -144,7 +146,7 @@ classDiagram | 理由 | 見るファイル | 文言(正規表現) | | --- | --- | --- | | `usage_limit` | err.log | `Monthly request limit reached` | -| `usage_limit` | claude の err.log と stdout.log | `"api_error_status"\s*:\s*429` | +| `usage_limit` | claude の err.log と stdout.log(新しい設計では全担当の err.log) | `"api_error_status"\s*:\s*429` | | `usage_limit` | err.log(既存の一致を付け替え) | `quota exceeded` / `rate limit exceeded` / `^HTTP/\d\S* 429 ` | | `early_error` | err.log(既存の一致を分ける) | `^HTTP/\d\S* (?:401\|403) ` | | `cli_timeout` | err.log(終了後だけ) | `print timeout after \S+ with turn in progress` | @@ -369,3 +371,5 @@ conftest.py P1(NDF_METRICS_DIR) | AC67 | `SKILL.md` の骨組みで、起動の行から判定の行までの間に `verify-findings` と `critique-round.sh` があり、起動し直しの専用の分岐が無い。各判定の直後に終了コード 8 の `flush` の枝がある | | AC68 / AC69 | 文書の `grep` | | AC70〜AC72 | 検証手段の表のコマンド。AC72 はテストの前後で `find` | + +**AC63〜AC67 の確かめ方は、#730 の設計が引き継いだ。確定仕様は [書き込みを回す側へ集める仕様](../docs/specifications/cross-review-writes-to-conductor.md) の「テスト観点」にある。** 担当が投稿しなくなるため、投稿済みのレビューを探す鍵(`prior_review_url`)を作らず、AC63〜AC65 の行は対象を失う。上の 2 行は 2026-09-15 時点の記録として残す。 diff --git a/issues/issue-662-598-537-619-584-583-requirements.md b/issues/issue-662-598-537-619-584-583-requirements.md index a3d2bb26..7d58b27c 100644 --- a/issues/issue-662-598-537-619-584-583-requirements.md +++ b/issues/issue-662-598-537-619-584-583-requirements.md @@ -157,6 +157,10 @@ ## 受け入れ条件(P3: #619 + #584 + #583 結果が失われる) +**#619 / #584 の受け入れ条件(AC50〜AC62、AC68〜AC69)は、利用上限で止まった担当を起動し直さず理由を報告する要求(#729)へ移した。確定仕様は [起動 1 回の結末](../docs/specifications/cross-review-launch-outcome.md) にある。** 以下は 2026-09-15 時点の記録として残す。 + +**#583 の受け入れ条件(AC63〜AC67)は、GitHub と git への書き込みをレビューを回す側だけにする要求(#730)が引き継いだ。** そのうち AC63〜AC65 は取り下げ、AC66 は送信の応答から取る形へ改め、AC67 は引き継いでいる。確定仕様は [書き込みを回す側へ集める仕様](../docs/specifications/cross-review-writes-to-conductor.md) にある。 + 文言と見るファイルの一覧は、契約の文書の「P3 で足す文言」にある。 理由を区別する(#619 / #598 の CLI の上限): diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 3061e987..292dc610 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.15.1", - "description": "Claude Code plugin (v10.15.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "version": "10.16.0", + "description": "Claude Code plugin (v10.16.0): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index 225702ba..8882ae58 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.15.1", - "description": "Codex plugin (v10.15.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.16.0", + "description": "Codex plugin (v10.16.0): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 07430b2d..1d209dda 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -89,7 +89,7 @@ bash plugins/ndf/dev.kiro/install.sh --dry-run ```bash python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v10.15.1) +# => NDF統合開発エージェント(Kiro CLI用 / v10.16.0) ``` ### agy @@ -119,16 +119,32 @@ agy plugin list # => {"imports":[{"name":"ndf","source":"antigravity","components":["skills","agents","hooks"]}]} ``` -## v10.15.1 へ更新するとき - -**`agent-layers.md` の「並行の本数」の節で、実行計画の持ち主の記載を直しました**(#762)。 -「本数の測り方と実行計画は `parallel-work.md` が持つ」と書いていた 1 文を、「本数を抑える下限は -`parallel-work.md`、本数の測り方と実行計画は `issue-plan-strategy` の -`references/execution-plan.md` が持つ」へ改め、`parallel-work.md` の境界の表と揃えました。 -変わるのは参照文書の 1 文だけで、Skill の数・手順・スクリプトは v10.15.0 のままです。破壊的な -変更はなく、記録の移行も要りません。変更点の一覧は [CHANGELOG.md](../../CHANGELOG.md) にあります。 - -**正式版です。** 開発版 `10.15.1-dev.1` と中身は同じで、`main` に載ります。 +## v10.16.0 へ更新するとき + +**`cross-review` と `cross-refactoring` の収束ループが、担当が揃わない・上限で止まる・結果を +残さないときにも止まらず終わるようにしました**(マイルストーン 13「agy の打ち切りと止まらない +収束ループ」、#478 #553 #583 #584 #592 #619 #624 #647 #648 #664 #678 #687 #706 #727 #728 #729 +#730 #732 #736)。Skill の数は変わりません。引数・Skill・スクリプトの削除や改名は無く、記録の +移行も要りません(前の版で始めた状態ファイルはそのまま読めます)。変更点の一覧は +[CHANGELOG.md](../../CHANGELOG.md) にあります。 + +**正式版です。** `main` に載ります。開発版 `10.16.0-dev.1` の中身に、statusline の変更(#806)を +加えました。statusline の変更は開発版を経ていません。 + +**`cross-refactoring` の既定の参加者から agy が外れます。** 既定は codex / kiro とホストです。 +これまでどおり agy に提案と適用をさせるなら `--include agy` を渡します。 + +| 変わったこと | 中身 | +| --- | --- | +| **使える者だけで始まります**(#478 #727 #664 #687) | 認証の確認を通らない CLI があっても `init` は止まらず、その者を外して続けます。外した者と理由は状態ファイルと完了報告に残ります。全員が揃わないなら始めたくないときは `--require-all` を付けます。`cross-review` は毎ラウンド 2 席を確保し、足りなければホスト、次に同じランタイムの 2 つ目(`codex-2` など)で埋めます | +| **参加者を名指しで変えられます**(#664) | 両 Skill に `--exclude` / `--include` が増えました(カンマ区切り・繰り返し可)。`cross-refactoring` は提案と適用を同じ参加者で回し、レビュー担当の役を無くしました | +| **再開で渡した引数が効きます**(#648) | 中断した収束ループを引数を変えて再開すると、上限は反映され、反映しない引数は「反映しない」と表示されます。黙って捨てられる引数はありません。指定を外すときは `none` を渡します | +| **利用上限を理由として報告します**(#729 #619 #584) | 担当の CLI が利用上限で止まると「結果なし」ではなく理由「利用上限」として残り、同じラウンドで起動し直しません。監視が止めた担当の子プロセスは、止めた後に結果を書きません | +| **未解決の重大な指摘を残して承認で終わりません**(#732 #624 #706) | 収束の判定で数えないのは、棄却した指摘と `minor` 以下の指摘だけになりました。誤りを示されていない `major` 以上は残る指摘として数えます | +| **GitHub と git へ書くのはレビューを回す側だけです**(#730 #583) | レビューの担当は指摘の控えを書くだけで、投稿は取り込み(`state.py read-result`)が行います。修正の担当はコミットまでで、送信・返信・決着・まとめは `state.py merge-fix` が行います。同じ論点が 2 つのスレッドに分かれず、途中で止まってもやり直しで二度書きません。**`/ndf:fix` を単独で使うときは、最後に `lib/result_posts.py fix` の 1 行で送信と返信を行います**(手順は `fix` の SKILL.md にあります) | +| **適用ラウンドが上限なしに開き直されません**(#728 #647 #592 #553) | 実装担当が結果を残さないと未検証のコミットを取り消し、同じ適用ラウンドは別の担当で 2 回まで試します。採用 0 件のラウンドでは担当を起動しません。帰属の段落が後ろに付いたコミットでも必須の記名を読みます | +| **statusline にサブエージェントの使用量が並びます**(#806) | Claude Code の NDF 標準 statusline が、実行中のサブエージェントのコンテキスト使用量を多い順に 3 本まで並べます(残りは `+2` のように本数だけ)。500k(Haiku 4.5 は 150k)を超えると赤になります。メインの表示から上限・使用率・コンテナ名・ホスト名を外しました。NDF 標準の statusLine には `refreshInterval: 5` が足されます(利用者が書いた値は変えません) | +| テストが監視の環境変数に左右されません(#678) | `MONITOR_` で始まる環境変数を延ばしたシェルから全体のテストを起動しても、同じ件数が通ります | 正式版のチャネル(ref を指定せずに登録した取得元)なら、次で入れ替わります。**動いているセッションには 反映されない**ため、更新したあとは起動し直してください。開発版を試すために `develop` を登録した @@ -145,13 +161,15 @@ codex plugin add ndf@ai-plugins ### 手元で確かめる -読むだけで、課題もファイルも書き換えません。`$SCRIPTS` はプラグインの `scripts/` の -絶対パスで、決め方は +どれも `--help` を読むだけで、課題もファイルも書き換えません。`$SCRIPTS` はプラグインの +`scripts/` の絶対パスで、決め方は [development-workflow/references/scripts-lookup.md](skills/development-workflow/references/scripts-lookup.md) にあります。 ```bash -grep -q "execution-plan.md" "$SCRIPTS/../skills/development-workflow/references/agent-layers.md"; echo "exit=$?" # 0 なら新しい版の参照文書が入っている +python3 "$SCRIPTS/lib/result_posts.py" fix --help >/dev/null; echo "exit=$?" # 0 なら修正の送信を行う共通層が入っている +python3 "$SCRIPTS/../skills/cross-review/scripts/state.py" init --help | grep -q -- '--require-all'; echo "exit=$?" # 0 なら cross-review が使える者だけで始まる +python3 "$SCRIPTS/../skills/cross-refactoring/scripts/refactor.py" init --help | grep -q -- '--include'; echo "exit=$?" # 0 なら cross-refactoring の参加者を名指しで変えられる ``` ## Playwright テストについて @@ -295,7 +313,7 @@ agy models # 認証の確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.15.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.16.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -317,14 +335,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.15.1/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.16.0/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.15.1 +# => ndf@ai-plugins installed, enabled 10.16.0 ``` 抑止していない Skill(`markdown-writing` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/dev.agy/plugin.json b/plugins/ndf/dev.agy/plugin.json index 29b91a1c..b371a454 100644 --- a/plugins/ndf/dev.agy/plugin.json +++ b/plugins/ndf/dev.agy/plugin.json @@ -1,5 +1,5 @@ { "name": "ndf", - "version": "10.15.1", - "description": "Antigravity CLI plugin (v10.15.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." + "version": "10.16.0", + "description": "Antigravity CLI plugin (v10.16.0): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." } diff --git a/plugins/ndf/scripts/lib/README.md b/plugins/ndf/scripts/lib/README.md index 3226c021..47700d1c 100644 --- a/plugins/ndf/scripts/lib/README.md +++ b/plugins/ndf/scripts/lib/README.md @@ -20,12 +20,13 @@ | [lock-common.sh](lock-common.sh) | 排他の取得と解放(#293) | 上の 2 つと `development-workflow` | | [monitor.py](monitor.py) | 別プロセスの多軸監視。対象と命名規則を引数で受ける | 収束ループの 2 つ | | [limits.py](limits.py) | 監視の上限(工程ごと)・無進捗の許容(担当ごと)・CLI の上限(監視の上限 + 120 秒)の表。既定値はここだけが持つ(#598 / #537) | 同上 | -| [monitor_outcome.py](monitor_outcome.py) | 監視の結果の理由の語彙と、結果ファイル・監視の記録の読み書き(#662) | 同上 | +| [monitor_outcome.py](monitor_outcome.py) | 監視の結果の理由の語彙(9 語)と起動し直しの可否、結果ファイル・監視の記録の読み書き(#662)、起動 1 回の結末を 1 つの値として読む `read_launch_outcome`(#729) | 同上 | | [launch-cli.sh](launch-cli.sh) | claude / codex / agy / kiro をランタイム名で分岐して背景起動する | 同上 | | [_tmpdir.sh](_tmpdir.sh) | 一時ディレクトリの解決。環境変数名とディレクトリ名を引数で受ける | 同上 | -| [statefile.py](statefile.py) | 状態ファイルの読み書きと KEY=VALUE 出力、保存の後の差し込み口 | 同上 | +| [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) | 同上 | diff --git a/plugins/ndf/scripts/lib/assignment.py b/plugins/ndf/scripts/lib/assignment.py index d4909d44..42c7b29c 100644 --- a/plugins/ndf/scripts/lib/assignment.py +++ b/plugins/ndf/scripts/lib/assignment.py @@ -1,24 +1,24 @@ """ホスト判定と担当の決定(収束ループ共通層)。 -**役割ごとに母集合が違う**ことがこの層の要点である。 +**母集合の既定は Skill ごとに違う**ことがこの層の要点である。 -| 母集合 | 定義 | 中身 | +| Skill | 母集合の既定 | 中身 | | --- | --- | --- | -| 提案・レビュー | 全ランタイム − ホスト | 常に 3 者 | -| 適用 | 全ランタイム | 常に 4 者 | +| cross-review | `review_pool(host)` | 全ランタイム − ホスト | +| cross-refactoring | `refactor_pool(host)` | `DEFAULT_REFACTOR_RUNTIMES`(codex / kiro)とホスト | -**担当の選び方は、適用の役があるかどうかで分かれる。** `assign()` は実装担当を先に決めて -から残りを絞り、`review_assign()` は母集合から直接 2 者を選ぶ。どちらも返すレビュー担当は -2 者である。 - -参加する 4 者はいずれも NDF の配布先であるため、**適用から外す者はいない**。 -ホストは提案・レビューから外れるが適用には入るため、2 つの母集合は重なるが -一致しない。輪番の式はホストによらず同じ形になる。 +参加者は「母集合の既定 ∪ 足す者 − 外す者」で決め(`resolve_participants`)、確認を +通った者だけを使える者(`available`)として記録する。担当の単位は席の名前 +(`SEAT_PATTERN`。`claude-2` のように同じランタイムの 2 つ目を表す)で、cross-review の +2 席は `review_seats` が、cross-refactoring の適用担当は `impl_assign` が決める(#727)。 +cross-refactoring は提案と適用を同じ参加者で回し、レビュー担当を持たない。 """ 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 +28,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"), @@ -52,7 +61,7 @@ def detect_host( """ホストを確定し、`(ホスト名, 判定根拠)` を返す。 判定根拠は `explicit`(`--host` の明示指定)か `env`(環境変数からの推定)。 - 誤検出すると**提案・レビューの母集合が狂う**(ホストが提案側に混ざる、 + 誤検出すると**母集合の既定が狂う**(ホストが cross-review の担当に混ざる、 参加すべき者が外れる)ため、呼び出し側は結果を必ず出力と状態ファイルへ残す。 推定できないときは例外を上げる。既定値を勝手に置くと、間違ったまま一周して @@ -76,62 +85,208 @@ def detect_host( def review_pool(host: str) -> list[str]: - """提案・レビューの母集合(全ランタイム − ホスト)。常に 3 者になる。""" + """cross-review の母集合の既定(全ランタイム − ホスト)。常に 3 者になる。""" if host not in HOST_RUNTIMES: raise AssignmentError(f"ホストになれないランタイムです: {host}") return [r for r in ALL_RUNTIMES if r != host] -def impl_pool() -> list[str]: - """適用の母集合(全ランタイム)。ホストによらず常に同じ。 +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()` の辞書へ足す。 """ - return list(ALL_RUNTIMES) + 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 review_assign(round_no: int, host: str) -> list[str]: - """ラウンド番号から**レビュー担当 2 者**を決める。適用の役を持たない工程が使う。 +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)。 - 母集合は `review_pool(host)` の 3 者で、外す 1 者をラウンドごとに回す。 + 順序: - レビュー担当 = 母集合 − 母集合[(ラウンド番号 - 1) % 3] + 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` として返す - `assign()` と分けているのは、**適用の役があるかどうかで選び方が変わる**ためである。 - `assign()` は先に実装担当を決めてから残りを絞るが、この工程には適用が無く、母集合から - 直接 2 者を選ぶ。3 者すべてを毎ラウンド起動しないのは、起動回数が 1.5 倍になるためで、 - ラウンドを重ねれば 3 者とも差分を見る。 + 名前の綴りの検査(argparse の型)はこの前段で済んでいる前提だが、ここでも + `ALL_RUNTIMES` に無い名前は弾く。 """ - if round_no < 1: - raise AssignmentError(f"ラウンド番号は 1 以上です: {round_no}") - pool = review_pool(host) - dropped = (round_no - 1) % len(pool) - return [r for i, r in enumerate(pool) if i != dropped] + 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 assign(round_no: int, host: str) -> tuple[str, list[str]]: - """ラウンド番号から `(実装担当, レビュー担当 2 者)` を決める。 +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 の値は + この関数より前の輪番(外す 1 者を `(round_no - 1) % 3` で回す式)と一致する。埋め合わせの候補は使える者に含まれない者だけを + 使い、含まれる者は飛ばす(同じ席の名前を 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"] - 輪番の単位は**ラウンド**である。1 ラウンドの適用を 1 者へ集約することで、 - レビュー担当を「実装担当以外」から機械的に決められる。 - 実装担当 = 適用候補[ラウンド番号 % 4] - 候補 = 提案・レビュー − 実装担当 - レビュー担当 = 候補が 2 者ならそのまま - 3 者なら 候補[(ラウンド番号 // 4) % 3] を除いた 2 者 +def impl_assign(round_no: int, participants: list[str]) -> str: + """cross-refactoring の適用担当 1 者を決める: `participants[round_no % len]`。 - 実装担当がホストと同じランタイムのとき、その者は提案・レビューの母集合に - 含まれないため候補が 3 者残る。**レビュー担当は常に 2 者**とし(起動回数を - 抑える方針と揃える)、余る 1 者はラウンドを跨いで順に外して負荷を均す。 + 式はこの関数より前の輪番(4 者の固定の順を `round_no % 4` で引く式)と同じで、 + 除数だけを参加者の数にする(設計の決定 7)。 + ラウンド 1 が `participants[1]` から始まるため、ホスト claude の既定 + (claude / codex / kiro)でもホストが最初に適用する形にならない。 """ if round_no < 1: raise AssignmentError(f"ラウンド番号は 1 以上です: {round_no}") - pool = impl_pool() - impl = pool[round_no % len(pool)] - candidates = [r for r in review_pool(host) if r != impl] - if len(candidates) > 2: - dropped = (round_no // len(pool)) % len(candidates) - candidates = [r for i, r in enumerate(candidates) if i != dropped] - return impl, candidates + if not participants: + raise AssignmentError("適用担当を選べる参加者がいません") + return participants[round_no % len(participants)] diff --git a/plugins/ndf/scripts/lib/auth.py b/plugins/ndf/scripts/lib/auth.py index 1fc7d63b..c981f282 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,53 +35,63 @@ SKIP_ENV = "NDF_SKIP_AUTH_CHECK" -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]]: - """参加する CLI の認証状態を確かめる。1 つでも欠けたら呼び出し側を中断させる。 +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 + - **出力と中断の手段は呼び出し側から受け取る。** 工程ごとに終了コードの意味が違う - (`cross-refactoring` の中断は 4、`cross-review` は 1)ため、この層で決めない。 +def _run_probe(probe: tuple[str, ...]) -> tuple[bool, str]: + """確認コマンドを 1 つ走らせ、`(通ったか, 理由)` を返す。例外は上げない。 - 確認コマンドは CLI の版で変わりうるので、`NDF_SKIP_AUTH_CHECK` で飛ばせるように - しておく。飛ばしたことは必ず出力へ残す(黙って劣化させない)。 + 理由は stderr か stdout の先頭 200 文字。終了コード 0 でも未認証の文言を含めば + 通らなかったものとする(kiro は成否を終了コードで表さない)。 """ - environ = os.environ if env is None else env - if environ.get(SKIP_ENV): - info(f"⚠ {SKIP_ENV} が設定されているため認証確認を飛ばしました") - return {} + 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] + - results: dict[str, dict[str, Any]] = {} - failed: list[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 - 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} 秒で応答しませんでした" + ok, detail = _run_probe(probe) 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})") - - if failed: - die( - "認証されていない CLI があります: " + " / ".join(failed) + "。" - "参加者が欠けたまま進むと、その者のレビューが無いまま収束します。" - "各 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`)が + 決める。確認コマンド・未認証の文言・時間切れの秒数・飛ばす環境変数は + この層の定数(`AUTH_PROBES` ほか)が持つ。 + + `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/launch-cli.sh b/plugins/ndf/scripts/lib/launch-cli.sh index 2c1416c2..d874ff2b 100755 --- a/plugins/ndf/scripts/lib/launch-cli.sh +++ b/plugins/ndf/scripts/lib/launch-cli.sh @@ -141,7 +141,15 @@ CLAUDE_ALLOWED_TOOLS=${NDF_CLAUDE_ALLOWED_TOOLS:-Bash,Read,Write,Edit,Glob,Grep} cd "$WORKDIR" +# **CLI を独立したプロセスグループで起動する(#584 / #729 の決定 10)。** ジョブ制御を +# 有効にして背景起動すると、CLI の pid がそのままプロセスグループの番号になる。監視は +# 止めるときにグループへシグナルを送るので、CLI の子プロセスが止めた後に結果ファイルを +# 書かない。`setsid` は macOS に標準で無いため使わない。起動の直後に戻し、この script の +# 残りはジョブ制御の影響を受けない。`set -m` は bash 3.2(macOS)にもある(bash.1 の +# 「Monitor mode. Job control is enabled.」)。 +set -m launch_runtime +set +m echo "$PID" > "$PID_FILE" disown 2>/dev/null || true diff --git a/plugins/ndf/scripts/lib/metrics.py b/plugins/ndf/scripts/lib/metrics.py index cdafe8ea..af2d625c 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( @@ -266,10 +276,9 @@ def _emit_table( lines += [*headers, *rows] -def format_report(metrics: dict[str, Any]) -> str: - """人が読む形へ整形する。比較の限界を必ず添える。""" - lines: list[str] = [] - impl_rows = [ +def _impl_rows(metrics: dict[str, Any]) -> list[str]: + """実装担当の表の行を組む。""" + return [ ( f"| {key} | {m['rounds']} | {m['applied']} | {m['abandoned']} | " f"{_fmt(m['first_review_approval_rate'])} | {_fmt(m['avg_fix_rounds'])} | " @@ -278,6 +287,23 @@ def format_report(metrics: dict[str, Any]) -> str: ) for key, m in metrics["impl"].items() ] + + +def _reviewer_rows(metrics: dict[str, Any]) -> list[str]: + """レビュー担当の表の行を組む。""" + return [ + ( + f"| {key} | {m['reviews']} | {m['findings']} | " + f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " + f"{m['seconds']:.0f} |" + ) + for key, m in metrics["reviewer"].items() + ] + + +def format_report(metrics: dict[str, Any]) -> str: + """人が読む形へ整形する。比較の限界を必ず添える。""" + lines: list[str] = [] _emit_table( lines, "実装担当", @@ -285,17 +311,9 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | 担当R | 適用 | 見送り | 初回承認率 | 平均修正R | 予算超過率 | テスト失敗率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |", ), - impl_rows, + _impl_rows(metrics), ) - reviewer_rows = [ - ( - f"| {key} | {m['reviews']} | {m['findings']} | " - f"{_fmt(m['resolution_rate'])} | {_fmt(m['agreement_rate'])} | " - f"{m['seconds']:.0f} |" - ) - for key, m in metrics["reviewer"].items() - ] lines.append("") _emit_table( lines, @@ -304,7 +322,7 @@ def format_report(metrics: dict[str, Any]) -> str: "| ランタイム / モデル | レビュー回数 | 指摘 | 修正に至った率 | 判定一致率 | 所要秒 |", "| --- | ---: | ---: | ---: | ---: | ---: |", ), - reviewer_rows, + _reviewer_rows(metrics), ) if metrics["unmeasured"]: diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index 5e7df1d3..cac43b06 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -14,16 +14,25 @@ `--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 再利用対策) 2. **sentinel** (codex のみ): err.log に `^tokens used$` 出現 3. **early-error pattern**: err.log に既知の致命的キーワードが出たら即中断 - - **FATAL** (auth/quota/sandbox 等の明確な致命): 検知時に kill + - **USAGE LIMIT** (利用上限。kiro の `Monthly request limit reached` / claude の + `"api_error_status":429` / HTTP 429 / quota・rate limit): 検知時に kill し、 + 状態は EARLY_ERROR のまま理由 `usage_limit` を結末に添える(#729 / #619)。 + claude だけは stdout.log の JSON も見る + - **FATAL** (auth/sandbox 等の明確な致命): 検知時に kill - **WARN** (生の `Error:` / `Traceback` 等の曖昧パターン): 警告ログのみ、kill せず通常判定を継続 - `--no-early-error` / `MONITOR_NO_EARLY_ERROR=1` で検知自体を無効化可 4. **result.json**: プロセス終了後に `/.cross_review/-review-pr-result.json` が - 生成されていなければ失敗扱い + 生成されていなければ失敗扱い。err.log に CLI 自身の上限の文言(agy の + `print timeout after <時間> with turn in progress`)があれば理由 `cli_timeout`(#729) 5. **hard timeout**: 既定は `--phase` の工程で上限の表(`limits.py`)から引く (省略時は `review`)。`--timeout` → `MONITOR_TIMEOUT_` → `MONITOR_TIMEOUT` の順で上書き可 6. **stall timeout**: err.log + stdout.log の合計サイズが一定時間変化しなければ @@ -35,7 +44,9 @@ 8. **result.json + age fallback**: sentinel を持たない agent (agy) 向け。 result.json の mtime が 30 秒以上前なら完了とみなし kill → OK 9. **失敗時 kill**: TIMEOUT / STALLED / EARLY_ERROR (FATAL のみ) / PIDFILE_BAD で - 返るとき、対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する + 返るとき、対象プロセスを SIGTERM (3 秒後に SIGKILL) で停止する。対象がプロセス + グループの先頭(`launch-cli.sh` は `set -m` で起動する)なら、グループへ送って + 子プロセスも止める(#584)。監視自身のグループへは送らない Usage: monitor.py target ∈ {codex, agy, both} @@ -84,10 +95,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`)だけが持ち、ここの名前は @@ -108,23 +148,43 @@ def _lib_dir() -> pathlib.Path: "1", "true", "yes", "on", } -# err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象)。 -# auth / quota / sandbox / HTTP 401-403-429 など、 -# プロセスが続行しても result を生成できないと判明しているケースだけを入れる。 +# **利用上限** の文言 (kill 対象。理由は `usage_limit`)。起動し直しても解けないため、 +# 他の致命と区別して結末に理由を添える(#729 の決定 4)。err.log は全担当で見る。 +# 照合は致命の表より **先** に行う。上限で落ちた後に別の致命が続く形が普通で、上限のほうが原因。 +USAGE_LIMIT_FATAL = [ + # kiro の実物(#619) + re.compile(r"Monthly request limit reached"), + # claude の `--output-format json` の結果行(#647)。`:` の前後の空白は問わない + re.compile(r'"api_error_status"\s*:\s*429'), + # quota / rate limit (`m.start()` をキーワード位置に合わせるため `^.*` を付けない。 + # `_match_is_quoted()` が backtick / 「」 引用を判定するために match 開始位置を使うため) + re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), + # HTTP 429 の状態行 + re.compile(r"^HTTP/\d\S* 429 ", re.MULTILINE), +] + +# err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象。理由は `early_error`)。 +# auth / sandbox / HTTP 401-403 など、プロセスが続行しても result を生成できないと +# 判明しているケースだけを入れる。利用上限は `USAGE_LIMIT_FATAL` の側。 EARLY_ERROR_FATAL = [ # HTTP エラーステータス行 (`HTTP/1.1 401 Unauthorized` 等) - re.compile(r"^HTTP/\d\S* (?:401|403|429) ", re.MULTILINE), + re.compile(r"^HTTP/\d\S* (?:401|403) ", re.MULTILINE), # 認証 / 権限系(行頭限定) re.compile(r"^(?:Authentication failed|Permission denied)", re.MULTILINE), - # quota / rate limit (`m.start()` をキーワード位置に合わせるため `^.*` を付けない。 - # `_match_is_quoted()` が backtick / 「」 引用を判定するために match 開始位置を使うため) - re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), # API key 系 re.compile(r"\bAPI key (?:not found|missing|invalid)\b", re.IGNORECASE), # codex 固有: sandbox エラー re.compile(r"\bsandbox error\b", re.IGNORECASE), ] +# **CLI 自身の上限** で結果を書かずに終わったことを示す文言(理由は `cli_timeout`)。 +# **終了した後、結果ファイルが無いときだけ** 照合する。生きている間に見ると途中の警告を +# 致命と読み、結果ファイルがあれば上限に当たっても書き終えているので使える(#729 の決定 5)。 +CLI_TIMEOUT_AFTER_EXIT = [ + # agy の `--print-timeout` の打ち切り(#598 / #537 の実物) + re.compile(r"print timeout after \S+ with turn in progress"), +] + # **警告の見た目で出る致命** パターン。`EARLY_ERROR_FATAL` と違い、行頭の # `warning:` を benign とする規則を適用しない(適用すると自分自身が消える)。 # 引用符・バックティック・markdown 引用による誤検知の除外だけを効かせる。 @@ -245,6 +305,12 @@ def _strip_ansi(text: str) -> str: re.compile(r'"is_error"\s*:\s*true'), ] +# claude の stdout.log に出る利用上限(理由は `usage_limit`)。err.log と stdout.log の +# どちらに出るか未確認のため両方を見る(#729 の決定 6)。JSON 向けの照合で除外を掛けない。 +CLAUDE_STDOUT_USAGE_LIMIT = [ + re.compile(r'"api_error_status"\s*:\s*429'), +] + # env を safe に int parse する。非数値時は warn を stderr に出して fallback 値を返す。 # 上限の表と同じ規則で読むため、表の側の実装を使う。 @@ -261,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 の解析時にだけ設定する。 @@ -358,9 +428,12 @@ class MonitorOutcome: exit_code: int icon: str detail: str + # 状態からは決まらない理由(`usage_limit` / `cli_timeout`)。`None` なら結末を書くときに + # `monitor_outcome.reason_for(status)` へ落ちる(#729 の決定 8)。 + reason: Optional[str] = None @classmethod - def create(cls, status: str, detail: str) -> "MonitorOutcome": + def create(cls, status: str, detail: str, reason: Optional[str] = None) -> "MonitorOutcome": exit_code, icon = { "OK": (0, "✅"), "TIMEOUT": (2, "⏰"), @@ -369,7 +442,15 @@ def create(cls, status: str, detail: str) -> "MonitorOutcome": "STALLED": (5, "🛑"), "PIDFILE_BAD": (6, "❓"), }[status] - return cls(status, exit_code, icon, detail) + return cls(status, exit_code, icon, detail, reason) + + +@dataclass(frozen=True) +class EarlyFatal: + """早期の致命の一致。どのファイルで・何が・理由は何か(`None` なら `early_error`)。""" + source: str + message: str + reason: Optional[str] = None @dataclass @@ -441,6 +522,20 @@ def _is_zombie(pid: int) -> bool: return state is not None and "Z" in state +def _leads_own_group(pid: int) -> bool: + """pid がプロセスグループの先頭で、かつ監視自身のグループではないか。 + + `launch-cli.sh` は `set -m` で起動するため CLI の pid = pgid になる。そうでない pid + (古い起動の手順・別の経路)は先頭でないか、監視と同じグループに居る。**監視自身の + グループへ送ると、進行側のシェルまで止まる**(#584 の候補で退けた形)。 + """ + try: + pgid = os.getpgid(pid) + except OSError: + return False + return pgid == pid and pgid != os.getpgrp() + + def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: """対象プロセスに SIGTERM、`sigterm_grace` 秒後も生きていたら SIGKILL。 @@ -448,13 +543,18 @@ def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: だと後から `gh api` 投稿や result.json 書き込みを実行してメインフローと 競合する。失敗扱いで返るときは必ず停止させる。 ゾンビプロセスにはシグナルを送れないためスキップする。 + + 対象がプロセスグループの先頭なら **グループへ送る**(#584 / #729 の決定 10)。pid だけへ + 送ると、CLI の子プロセスが残って止めた後に結果ファイルを書く。生存の確認は先頭の pid で見る。 """ if pid <= 0: return if _is_zombie(pid): return + send = (lambda sig: os.killpg(pid, sig)) if _leads_own_group(pid) else ( + lambda sig: os.kill(pid, sig)) try: - os.kill(pid, signal.SIGTERM) + send(signal.SIGTERM) except OSError: return deadline = time.monotonic() + sigterm_grace @@ -463,7 +563,7 @@ def _kill_pid(pid: int, sigterm_grace: float = 3.0) -> None: return time.sleep(0.5) try: - os.kill(pid, signal.SIGKILL) + send(signal.SIGKILL) except OSError: pass @@ -538,7 +638,12 @@ def _scan_patterns( def _scan_early_fatal(path: pathlib.Path) -> Optional[str]: - hit = _scan_patterns(path, EARLY_ERROR_FATAL) + """err.log の致命の一致(kill 対象)。**利用上限も含む。** + + 理由(`usage_limit` か `early_error` か)の区別はここでは行わず、`_early_error` が + `USAGE_LIMIT_FATAL` を先に照合して決める。この関数は「止めるべき文言があるか」だけを返す。 + """ + hit = _scan_patterns(path, USAGE_LIMIT_FATAL) or _scan_patterns(path, EARLY_ERROR_FATAL) if hit: return hit return _scan_patterns( @@ -552,12 +657,8 @@ def _scan_early_warn(path: pathlib.Path) -> Optional[str]: return _scan_patterns(path, EARLY_ERROR_WARN) -def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: - """claude の JSON 出力から承認失敗・実行失敗を検出する。 - - `--output-format json` は完了時に 1 個の JSON を吐くため、 - `permission_denials` が非空、または `is_error` が真であれば失敗が確定する。 - err.log 側の行単位パターンでは拾えないので専用に見る。 +def _scan_claude_stdout(path: pathlib.Path, patterns: list[re.Pattern[str]]) -> Optional[str]: + """claude の JSON 出力を `patterns` で照合し、一致の前後 80 文字を返す。 `_scan_patterns()` は使わない。あちらは行単位の benign 判定と引用符パリティ判定を 行うが、JSON は 1 行に多数の引用符を含むため、パリティ判定が「引用の内側」を @@ -567,13 +668,28 @@ def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: if data is None: return None data = _strip_ansi(data) - for pat in CLAUDE_STDOUT_FATAL: + for pat in patterns: m = pat.search(data) if m: return data[max(0, m.start() - 80):m.end() + 80].strip() return None +def _scan_claude_stdout_fatal(path: pathlib.Path) -> Optional[str]: + """claude の JSON 出力から承認失敗・実行失敗を検出する。 + + `--output-format json` は完了時に 1 個の JSON を吐くため、 + `permission_denials` が非空、または `is_error` が真であれば失敗が確定する。 + err.log 側の行単位パターンでは拾えないので専用に見る。 + """ + return _scan_claude_stdout(path, CLAUDE_STDOUT_FATAL) + + +def _scan_claude_stdout_usage_limit(path: pathlib.Path) -> Optional[str]: + """claude の JSON 出力から利用上限(`"api_error_status":429`)を検出する。""" + return _scan_claude_stdout(path, CLAUDE_STDOUT_USAGE_LIMIT) + + def _scan_codex_sentinel(path: pathlib.Path) -> bool: tail = _read_tail(path, 64 * 1024) if tail is None: @@ -626,7 +742,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}" @@ -646,19 +762,43 @@ def _lingering_completion( ) +def _scan_usage_limit(paths: AgentPaths, agent: str) -> EarlyFatal | None: + """利用上限の文言。err.log は全担当、stdout.log は claude だけ JSON 向けの照合で見る。""" + hit = _scan_patterns(paths.err_log, USAGE_LIMIT_FATAL) + if hit: + return EarlyFatal("err.log", hit, "usage_limit") + if _agent_runtime(agent) == "claude": + hit = _scan_claude_stdout_usage_limit(paths.stdout_log) + if hit: + return EarlyFatal("stdout.log", hit, "usage_limit") + return None + + +def _scan_fatal(paths: AgentPaths, agent: str) -> EarlyFatal | None: + """利用上限以外の致命(理由は `early_error`)。致命 → 警告の見た目の致命の順。""" + hit = _scan_early_fatal(paths.err_log) + if hit: + return EarlyFatal("err.log", hit) + if _agent_runtime(agent) == "claude": + hit = _scan_claude_stdout_fatal(paths.stdout_log) + if hit: + return EarlyFatal("stdout.log", hit) + return None + + def _early_error( paths: AgentPaths, agent: str, disabled: bool, -) -> tuple[tuple[str, str] | None, str | None]: +) -> tuple[EarlyFatal | None, str | None]: + """早期の致命と警告。**照合の順序は利用上限 → 致命 → 警告の見た目の致命。** + + 同じ err.log に利用上限と他の致命が並んでいれば理由は `usage_limit` になる(#729 の + 決定 6)。`disabled`(`--no-early-error`)は利用上限の検知も一緒に無効にする。 + """ if disabled: return None, None - fatal_err = _scan_early_fatal(paths.err_log) - fatal_source = "err.log" - if not fatal_err and agent == "claude": - fatal_err = _scan_claude_stdout_fatal(paths.stdout_log) - fatal_source = "stdout.log" - fatal = (fatal_source, fatal_err) if fatal_err else None + fatal = _scan_usage_limit(paths, agent) or _scan_fatal(paths, agent) return fatal, _scan_early_warn(paths.err_log) @@ -729,9 +869,9 @@ def _early_error_outcome( return None, warning if alive: _kill_pid(status.pid) - source, message = fatal return MonitorOutcome.create( - "EARLY_ERROR", f"early error (fatal) in {source}: {message[:200]}" + "EARLY_ERROR", f"early error (fatal) in {fatal.source}: {fatal.message[:200]}", + reason=fatal.reason, ), warning @@ -747,6 +887,15 @@ def _process_exit_outcome( f"process exited; sentinel={status.sentinel_seen}; " f"result_exists={status.result_exists}", ) + # 結果なしの理由を err.log から引く。CLI の上限の文言があれば `cli_timeout`、無ければ + # 状態からの既定(`missing`)に落ちる。 + cli_timeout = _scan_patterns(paths.err_log, CLI_TIMEOUT_AFTER_EXIT) + if cli_timeout: + return MonitorOutcome.create( + "NO_RESULT", + f"process exited but result.json missing (CLI timeout): {cli_timeout[:200]}", + reason="cli_timeout", + ) return MonitorOutcome.create( "NO_RESULT", f"process exited but result.json missing: {paths.result}" ) @@ -776,11 +925,15 @@ def monitor_agent( hard timeout / stall / sentinel / result.json のみで判定する。 """ paths, status, started, pid = _initialize_monitor(agent, pr, config.stem_template) + + def finish(outcome: MonitorOutcome) -> AgentStatus: + """status とログ文脈を閉じ込めて結末を確定する。終了時のログ文脈を変えるときは + ここ 1 か所を直せばよい(各終了分岐が同じ呼び出しを繰り返さない)。""" + return _finish_monitor(status, outcome, (config.log_prefix, agent)) + if pid is None: - return _finish_monitor( - status, + return finish( MonitorOutcome.create("PIDFILE_BAD", f"pidfile not found: {paths.pidfile}"), - (config.log_prefix, agent), ) status.pid = pid @@ -805,7 +958,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 せず常駐し続ける @@ -816,9 +969,7 @@ def monitor_agent( if alive and (status.sentinel_seen or cmdline_validated): completion_detail = _lingering_completion(paths, status, pid, started_wall) if completion_detail is not None: - return _finish_monitor( - status, MonitorOutcome.create("OK", completion_detail), (config.log_prefix, agent) - ) + return finish(MonitorOutcome.create("OK", completion_detail)) # result.json が書かれた後もプロセスがハングするケース (実測: # MCP サーバー切断待ち等で exit しない)。sentinel 機構を持たない agent 向け @@ -831,12 +982,12 @@ def monitor_agent( pid, agent, alive, cmdline_validated ) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # 2. hard timeout outcome = _timeout_outcome(elapsed, config.timeout, alive, pid) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # 3. early error # 明確な致命 (FATAL) のみ kill する。曖昧パターン (生 Error: / Traceback) は @@ -844,7 +995,7 @@ def monitor_agent( # echo するケースで誤 kill されるのを防ぐ。 outcome, warn_err = _early_error_outcome(paths, status, alive, config.no_early_error) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) if not warned_early_error and warn_err: print( f"{config.log_prefix}⚠️ {agent} early-error WARN " @@ -855,7 +1006,7 @@ def monitor_agent( outcome = _process_exit_outcome(paths, status, alive, config.require_result) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # 4. stall detection (err.log / stdout.log / progress.log をモニタ。 # agy は stdout 側だけ進捗が出るケースがあり、progress.log には @@ -866,7 +1017,7 @@ def monitor_agent( ) outcome = _stall_outcome(status, config.stall_timeout, pid, last_progress_size) if outcome: - return _finish_monitor(status, outcome, (config.log_prefix, agent)) + return finish(outcome) # poll 中の進捗ログ _emit_progress(config.log_prefix, agent, status) @@ -905,7 +1056,9 @@ def _record_outcome( stem = stem_template.format(agent=agent, id=pr) try: paths = AgentPaths.for_(agent, pr, stem_template) - st.reason = monitor_outcome.reason_for(st.status) + # 結末が理由を持てばそれを、無ければ状態からの既定を書く(#729 の決定 8) + st.reason = (st.outcome.reason if st.outcome and st.outcome.reason + else monitor_outcome.reason_for(st.status)) st.started_at = started_at st.ended_at = monitor_outcome.now_iso() try: @@ -931,6 +1084,10 @@ def _record_outcome( # `--phase` の値。省いたときは null(#598 / #537) "phase": phase, } + # 組み立てたキー集合を正本(`monitor_outcome.OUTCOME_KEYS`)と突き合わせる。 + # キーを片方だけへ足すと、ここで食い違いがその場で落ちる(#662)。 + assert set(outcome) == set(monitor_outcome.OUTCOME_KEYS), ( + set(outcome).symmetric_difference(monitor_outcome.OUTCOME_KEYS)) tmp_dir = paths.pidfile.parent monitor_outcome.write_outcome(tmp_dir, stem, outcome) monitor_outcome.append_journal(tmp_dir, outcome) @@ -945,12 +1102,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 の代わりに使う") @@ -1028,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/scripts/lib/monitor_outcome.py b/plugins/ndf/scripts/lib/monitor_outcome.py index 30ad5422..f2018c70 100644 --- a/plugins/ndf/scripts/lib/monitor_outcome.py +++ b/plugins/ndf/scripts/lib/monitor_outcome.py @@ -13,14 +13,21 @@ キーと値の形は `issues/issue-662-598-537-619-584-583-design-contracts.md` の 「監視の結果ファイル」にある。 -""" -from __future__ import annotations +**起動 1 回の結末を 1 つの値として読むのもここである(#729)。** `read_launch_outcome` が +結果ファイルの有無・読めるかと監視の結果を突き合わせ、使える結果(`payload`)か理由 +(`reason`)と起動し直しの可否(`relaunch_same_agent`)を返す。結果なしの判断と可否の表を +cross-review / cross-refactoring がそれぞれ持つと、語彙を足すたびに片方が古くなる。 +""" +# `from __future__ import annotations` を置かない。注釈が文字列になると `dataclass` が +# `sys.modules[<モジュール名>]` を引くが、読む側の多くはこのファイルを `importlib` で +# `sys.modules` に登録せずに読み込むため落ちる。実行時に評価できる形(3.10 以上)で書く。 import datetime as _dt import json import os import pathlib import threading +from dataclasses import dataclass from typing import Any, Optional try: # Windows には無い。無ければスレッドの排他だけで書く。 @@ -28,9 +35,23 @@ except ImportError: # pragma: no cover - POSIX では通らない fcntl = None # type: ignore[assignment] -# 監視が書く理由。**状態(`status`)からの対応だけで決まる。** `usage_limit` と -# `cli_timeout` は P3 で足す(それまでは `early_error` と `missing` に落ちる)。 -REASONS = ("ok", "timeout", "stalled", "early_error", "missing", "pidfile_bad") +# 理由の語彙。先頭の 6 語は監視の状態(`status`)から決まる。`usage_limit` / `cli_timeout` は +# 監視が文言の照合で結末に添えたときだけ現れ、`unparsable` は読む側(`read_launch_outcome`) +# だけが書く(#729 の決定 7)。 +REASONS = ( + "ok", "timeout", "stalled", "early_error", "missing", "pidfile_bad", + "usage_limit", "cli_timeout", "unparsable", +) + +# 同じ担当を同じ条件で起動し直しても解けない理由。利用上限は起動のたびに待ちと相手の +# 枠を使うだけで直らない(#619)。それ以外は対象や負荷で変わりうるので 1 度は起動し直せる。 +# **理由を足すときはこの集合だけを見直す。** 偽のときに何をするかは Skill が決める。 +NO_RELAUNCH_REASONS = frozenset({"usage_limit"}) + +# 監視がこの理由を書いていれば、監視が止めたか、結果を書けない終わり方をしたと分かっている。 +# 結果ファイルの状態を見ずにその値を採る(`ok` / `missing` は結果ファイルの側で決め直す)。 +_MONITOR_DECIDED_REASONS = frozenset( + {"timeout", "stalled", "early_error", "usage_limit", "cli_timeout", "pidfile_bad"}) _STATUS_REASON = { "OK": "ok", @@ -42,10 +63,12 @@ } # 結果ファイルのキー。**並びも契約の文書の表と揃える**(読む人が突き合わせやすい)。 +# `phase` は P2(#598 / #537)で足した `--phase` の値。省いたときは null で、契約の文書の +# とおり末尾に置く。 OUTCOME_KEYS = ( "agent", "stem", "status", "exit_code", "reason", "detail", "launched_at", "started_at", "ended_at", "elapsed", "idle_seconds", - "progress_tail", "result_exists", "pid", + "progress_tail", "result_exists", "pid", "phase", ) JOURNAL_NAME = "monitor-outcomes.jsonl" @@ -75,10 +98,20 @@ def reason_for(status: str) -> str: raise ValueError(f"監視の状態として知らない値です: {status!r}") from None +def relaunch_same_agent(reason: Optional[str]) -> bool: + """同じ担当を同じ条件で起動し直せば解けるか。`NO_RELAUNCH_REASONS` に無ければ可。""" + return reason not in NO_RELAUNCH_REASONS + + def outcome_path(tmp_dir: os.PathLike[str] | str, stem: str) -> pathlib.Path: return pathlib.Path(tmp_dir) / f"{stem}-monitor.json" +def default_result_path(tmp_dir: os.PathLike[str] | str, stem: str) -> pathlib.Path: + """結果ファイルの既定の置き場所。`launch-cli.sh` の `-result.json` と同じ形。""" + return pathlib.Path(tmp_dir) / f"{stem}-result.json" + + def journal_path(tmp_dir: os.PathLike[str] | str) -> pathlib.Path: return pathlib.Path(tmp_dir) / JOURNAL_NAME @@ -105,6 +138,65 @@ def read_outcome(tmp_dir: os.PathLike[str] | str, stem: str) -> Optional[dict[st return data if isinstance(data, dict) else None +@dataclass(frozen=True) +class LaunchOutcome: + """起動 1 回の結末。`payload` があれば使える結果、無ければ `reason` が理由。""" + payload: Optional[dict[str, Any]] + reason: Optional[str] + detail: str + monitor: Optional[dict[str, Any]] + relaunch_same_agent: bool + + +def _read_result_file(path: pathlib.Path) -> tuple[Optional[dict[str, Any]], str]: + """結果ファイルを読む。使える辞書か、無ければ結果なしの理由(`missing` / `unparsable`)。""" + try: + text = path.read_text(encoding="utf-8") + except (OSError, ValueError): + return None, "missing" + if not text.strip(): + return None, "missing" + try: + data = json.loads(text) + except ValueError: + return None, "unparsable" + return (data, "") if isinstance(data, dict) else (None, "unparsable") + + +def read_launch_outcome(tmp_dir: os.PathLike[str] | str, stem: str, + result_path: Optional[os.PathLike[str] | str] = None) -> LaunchOutcome: + """起動 1 回の結末を 1 つの値として読む(#729 の決定 2)。 + + **結果ファイルが JSON オブジェクトとして読めれば使える結果が勝つ。** 監視が止めた後にも + 結果ファイルが残っていれば、止める前に書き終えていた結果である。無いときの理由は、監視が + 理由を知っていればその値、知らなければ結果ファイルの状態(無い・空 → `missing`、あるが + 読めない → `unparsable`)で決める。 + + **失敗しない。** 例外・`SystemExit`・標準出力/標準エラーへの出力を出さない。壊れた監視の + 結果ファイルは無いものとして扱う。読む側(両 Skill の取り込み)が終了コードを決める。 + """ + path = (pathlib.Path(result_path) if result_path is not None + else default_result_path(tmp_dir, stem)) + payload, result_reason = _read_result_file(path) + monitor = read_outcome(tmp_dir, stem) + monitor_detail = str(monitor.get("detail") or "") if monitor else "" + if payload is not None: + return LaunchOutcome(payload=payload, reason=None, detail=monitor_detail, + monitor=monitor, relaunch_same_agent=True) + monitor_reason = monitor.get("reason") if monitor else None + reason = monitor_reason if monitor_reason in _MONITOR_DECIDED_REASONS else result_reason + detail = monitor_detail or _unusable_detail(path, result_reason) + return LaunchOutcome(payload=None, reason=reason, detail=detail, monitor=monitor, + relaunch_same_agent=relaunch_same_agent(reason)) + + +def _unusable_detail(path: pathlib.Path, result_reason: str) -> str: + """監視の詳細が無いときに `detail` へ置く、結果を読めなかった理由の 1 文。""" + if result_reason == "missing": + return f"結果ファイルが無い、または空です: {path}" + return f"結果ファイルが JSON オブジェクトとして読めません: {path}" + + def append_journal(tmp_dir: os.PathLike[str] | str, outcome: dict[str, Any]) -> None: """記録へ 1 行を追記する。 diff --git a/plugins/ndf/scripts/lib/post_queue.py b/plugins/ndf/scripts/lib/post_queue.py index e79511ab..39949f53 100755 --- a/plugins/ndf/scripts/lib/post_queue.py +++ b/plugins/ndf/scripts/lib/post_queue.py @@ -75,13 +75,6 @@ QUEUED = "queued" FAILED = "failed" -# レビューの判定と、GitHub 側に残る状態の対応。 -_REVIEW_STATE = { - "APPROVE": "APPROVED", - "REQUEST_CHANGES": "CHANGES_REQUESTED", - "COMMENT": "COMMENTED", -} - # 未解決のスレッドの識別子だけを読む問い合わせ。**解決の冪等はこの一覧だけで決まる** # (一覧に無ければ、既に解決されている)。 _UNRESOLVED_QUERY = """ @@ -115,6 +108,9 @@ _HTTP_RE = re.compile(r"\(HTTP (\d{3})\)") # 上限を指す語。一次・二次・GraphQL の 3 つの言い回しを拾う。 _RATE_WORDS = ("rate limit", "rate_limited", "abuse detection") +# 指した位置を差分の中に見つけられないことを表す語。実測した応答は +# `Line could not be resolved` と `Path could not be resolved` の 2 つ。 +_POSITION_WORD = "could not be resolved" class Attempt(NamedTuple): @@ -149,6 +145,10 @@ def message(self) -> str: for e in errors: if isinstance(e, dict): parts += [str(e.get("message") or ""), str(e.get("type") or "")] + elif isinstance(e, str): + # レビューの作成が拒まれたときは、語をつないだ文字列が並ぶ + # (実測: `["Line could not be resolved"]`)。 + parts.append(e) return " ".join(p for p in parts if p) def summary(self) -> str: @@ -210,6 +210,24 @@ def is_rate_limited(attempt: Attempt) -> bool: return False +def is_position_unresolved(attempt: Attempt) -> bool: + """この失敗が「指した位置を差分の中に見つけられない」ことによるものか。 + + **レビューの作成は要求ごとに全件が拒まれる。** 差分の外の行やファイルを指した + インラインが 1 件でもあると、正しいインラインも総評も作られない(実測、 + 2026-09-22)。応答は語をつないだ 1 つの文字列で、どの項目かは指さないため、 + 呼び出し側は要求のインラインをまとめて総評へ移して送り直す。 + + **同じ状態で返る別の拒まれ方と分ける。** 判定の値の誤り + (`Variable $event ... was provided invalid value`)と基準のコミットの誤り + (`The commitOID is not part of the pull request`)はこの語を持たない。 + 区別しないと、別の不具合が退避として飲み込まれる。 + """ + if attempt.ok or attempt.http != 422: + return False + return _POSITION_WORD in f"{attempt.message} {attempt.stderr}".lower() + + # ---------------- 送る内容の組み立て ---------------- @@ -233,11 +251,16 @@ def _request_review_post(repo: str, pr: int, fields: dict[str, Any]) -> dict[str body["commit_id"] = fields["commit_id"] if fields.get("comments"): body["comments"] = fields["comments"] + match = {"event": fields["event"], "body": fields.get("body", "")} + # **照合をラウンドの開始より後へ絞る。** ラウンドの番号は実行ごとに 1 から数え + # 直すため、番号と席の鍵だけでは前の実行のレビューに一致する。 + if fields.get("since"): + match["since"] = fields["since"] return { "request": {"method": "POST", "path": f"repos/{repo}/pulls/{int(pr)}/reviews", "fields": body}, - "match": {"event": fields["event"], "body": fields.get("body", "")}, + "match": match, } @@ -334,24 +357,66 @@ def _by_actor(row: dict[str, Any], actor: str | None) -> bool: return str((row.get("user") or {}).get("login") or "") == actor +def _head(body: Any) -> str: + return str(body or "")[:BODY_MATCH_CHARS] + + +def review_match_key(body: Any) -> str: + """レビューの本文から、同じ投稿かどうかを決める鍵を作る。 + + 先頭行は `## 🤖 cross-review | round | <席> | <判定>` である。**鍵に取るのは + 席までで、判定の語を含めない。** 含めると、起動し直して判定が変わったときに別の + 投稿と読まれ、同じラウンド・同じ席のレビューが 2 件になる(#730 #583)。 + + 先頭行がこの形でないときは、本文の先頭 `BODY_MATCH_CHARS` 文字へ落とす。 + """ + first = str(body or "").splitlines()[0] if str(body or "") else "" + parts = first.split("|") + if len(parts) < 4: + return _head(body) + return "|".join(parts[:3]).strip() + "|" + + def _comment_match(match: dict[str, Any], actor: str | None): - return lambda row: _by_actor(row, actor) and row.get("body") == match.get("body") + head = _head(match.get("body")) + return lambda row: _by_actor(row, actor) and _head(row.get("body")) == head + + +def _parse_time(value: Any) -> _dt.datetime | None: + try: + parsed = _dt.datetime.fromisoformat(str(value or "").replace("Z", "+00:00")) + except ValueError: + return None + return parsed if parsed.tzinfo is not None else None def _review_match(match: dict[str, Any], actor: str | None): - want = _REVIEW_STATE.get(str(match.get("event") or ""), "") - head = str(match.get("body") or "")[:BODY_MATCH_CHARS] + """同じラウンド・同じ席のレビューか。 + + **開始時刻を持つときは、それより後に出たレビューだけを見る。** 同じ実行の中の + 送り直しは見つかり、回し直す前の実行のレビューは外れる。開始時刻かレビューの + 時刻のどちらかを読めないときは、番号と席だけの照合へ落とす(二重に送る側へ + 倒さない)。 + """ + key = review_match_key(match.get("body")) + since = _parse_time(match.get("since")) + + def _in_this_run(row: dict[str, Any]) -> bool: + submitted = _parse_time(row.get("submitted_at")) + return since is None or submitted is None or submitted >= since + return lambda row: ( _by_actor(row, actor) - and str(row.get("state") or "") == want - and str(row.get("body") or "")[:BODY_MATCH_CHARS] == head + and review_match_key(row.get("body")) == key + and _in_this_run(row) ) def _reply_match(match: dict[str, Any], actor: str | None): + head = _head(match.get("body")) return lambda row: ( str(row.get("in_reply_to_id") or "") == str(match.get("in_reply_to")) - and row.get("body") == match.get("body") + and _head(row.get("body")) == head ) @@ -411,7 +476,7 @@ def already_posted(item: dict[str, Any]) -> bool | None: _SEQ_RE = re.compile(r"^(\d{4})-") -def _read_item(path: pathlib.Path) -> dict[str, Any] | None: +def read_item(path: pathlib.Path) -> dict[str, Any] | None: """待ち行列の項目を 1 件読む。読めなければ `None`。 項目は作成先の JSON ファイルへ直接書かれるため、書き込みの途中で終了すると @@ -424,6 +489,20 @@ def _read_item(path: pathlib.Path) -> dict[str, Any] | None: return item if isinstance(item, dict) else None +_read_item = read_item + + +def rejected_by_position(item: dict[str, Any]) -> bool: + """待ち行列に残った項目が、指した位置を解決できずに拒まれたものか。 + + 流した後に呼ぶ。`is_position_unresolved` と同じ判定を、項目へ残した状態と説明から + 行う(流す側は `Attempt` を返さないため)。 + """ + if int(item.get("last_status") or 0) != 422: + return False + return _POSITION_WORD in str(item.get("last_error") or "").lower() + + class FlushResult(NamedTuple): """流した結果。""" @@ -462,6 +541,20 @@ def items(self) -> list[tuple[pathlib.Path, dict[str, Any]]]: out.append((p, item)) return out + def drop(self, seq: Any) -> bool: + """連番で指した項目を 1 件取り除く。 + + 送れなかった項目を、送る内容を変えて積み直すときに使う(差分の外を指す指摘の + 退避)。**そのまま積み足すと、同じ論点の要求が 2 件並ぶ。** + """ + if seq is None: + return False + for path, item in self.items(): + if item.get("seq") == seq: + path.unlink(missing_ok=True) + return True + return False + def _next_seq(self) -> int: seqs = [int(m.group(1)) for m in (_SEQ_RE.match(p.name) for p in self.paths()) if m] @@ -487,6 +580,41 @@ def add(self, item: dict[str, Any], ident: str | int) -> pathlib.Path: json.dump(item, f, indent=2, ensure_ascii=False) return path + def _item_to_send( + self, path: pathlib.Path + ) -> tuple[dict[str, Any] | None, dict[str, Any] | None, + dict[str, Any] | None]: + """項目を読み、送る項目・既投稿・読込失敗のいずれかを返す。""" + item = _read_item(path) + if item is None: + return None, None, { + "path": str(path), + "last_error": f"待ち行列の項目を読めない ({path.name})", + } + found, row = posted_match(item) + if found is not True: + return item, None, None + if row is not None: + item["response"] = row + path.unlink(missing_ok=True) + return None, item, None + + def _send_item(self, path: pathlib.Path, item: dict[str, Any]) -> tuple[bool, Any]: + """1 項目を送り、成功時の応答または失敗情報を項目へ反映する。""" + attempt = send(item) + if attempt.ok: + try: + item["response"] = json.loads(attempt.stdout or "null") + except json.JSONDecodeError: + item["response"] = None + path.unlink(missing_ok=True) + return True, attempt + item["attempts"] = int(item.get("attempts") or 0) + 1 + item["last_error"] = attempt.summary() + item["last_status"] = attempt.http + path.write_text(json.dumps(item, indent=2, ensure_ascii=False), encoding="utf-8") + return False, attempt + def flush(self) -> FlushResult: """積んだ項目を連番の順に送る。 @@ -498,39 +626,18 @@ def flush(self) -> FlushResult: failed: dict[str, Any] | None = None rate_limited = False for path in self.paths(): - item = _read_item(path) - if item is None: - # **読めない項目を黙って飛ばさない。** `count()` はファイルを数え - # 続けるため、飛ばすと送りも失敗の報告もしないまま件数だけが残り、 - # 判定は終了コード 8 を返し続けて誰も直せない状態になる。ここで - # 止めて理由を返せば、その項目を捨てるか直すかを人が選べる。 - failed = { - "path": str(path), - "last_error": f"待ち行列の項目を読めない ({path.name})", - } + item, already_posted, read_failure = self._item_to_send(path) + if read_failure is not None: + failed = read_failure break - found, row = posted_match(item) - if found is True: - # **送った場合と同じ形で返す。** 呼び出し側は届いたことを応答から - # 確かめるため、既に届いていた項目にも見つけた投稿を積んで渡す。 - if row is not None: - item["response"] = row - path.unlink(missing_ok=True) - skipped.append(item) + if already_posted is not None: + skipped.append(already_posted) continue - attempt = send(item) - if attempt.ok: - try: - item["response"] = json.loads(attempt.stdout or "null") - except json.JSONDecodeError: - item["response"] = None - path.unlink(missing_ok=True) + assert item is not None + ok, attempt = self._send_item(path, item) + if ok: sent.append(item) continue - item["attempts"] = int(item.get("attempts") or 0) + 1 - item["last_error"] = attempt.summary() - path.write_text(json.dumps(item, indent=2, ensure_ascii=False), - encoding="utf-8") failed = item rate_limited = is_rate_limited(attempt) break diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py new file mode 100644 index 00000000..010e778e --- /dev/null +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -0,0 +1,550 @@ +#!/usr/bin/env python3 +"""結果ファイルを投稿へ変える層(#730 #583)。 + +**GitHub と git へ書くのは、レビューを回す側だけである。** 担当は指摘の控えと結果 +ファイルを書いて終わり、修正の担当はコミットまでを行う。この層が、その 2 種類の +結果ファイルを読んで投稿を組み立て、待ち行列を通して送り、送信の応答を返す。 + +**本文は引数にも標準出力にも出さない。** どの入口もファイルのパスを受け取り、本文は +この層の中だけを通る。収束ループを駆動している側の応答に本文が載ると、文脈の予算の +決めに反する。 + +**2 つの口を持つ。** 収束ループから呼ぶときは取り込みがこの層を呼び、単独で修正を +行うときは同じまとまりを部分命令として直接呼ぶ。入口のスクリプトを別に作らない。 + +```bash +python3 "$SCRIPTS/lib/result_posts.py" fix --repo <所有者>/<リポジトリ> --pr <番号> \\ + --result <結果ファイル> --head <ブランチ名> --worktree <作業ツリー> +``` +""" +from __future__ import annotations + +import argparse +import json +import os +import pathlib +import subprocess +import sys +from typing import Any, NamedTuple + +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) + +import post_queue # noqa: E402 +import statefile # noqa: E402 + +# レビューの本文の先頭行。**照合の鍵はラウンドと席までの前方一致である**ため、判定の +# 語はこの行の末尾に置く(`post_queue.review_match_key`)。 +REVIEW_HEAD = "## 🤖 cross-review | round {round_no} | {seat} | {event}" +# 差分の外を指すために総評へ移した指摘の見出し。 +EVACUATED_HEAD = "### 差分の外を指す指摘" +# 修正のまとめの先頭行。ラウンドとコミットを鍵の幅(先頭 80 文字)の中へ入れる。 +FIX_HEAD = "## 🔧 /ndf:fix サマリ | round {round_no} | commit {commit}" +FIX_HEAD_NO_ROUND = "## 🔧 /ndf:fix サマリ | commit {commit}" + +TMP_DIRNAME = ".cross_review" + + +# ---------------- 読み取り ---------------- + + +def _read_json(path: pathlib.Path | str | None) -> dict[str, Any]: + if path is None: + return {} + try: + data = json.loads(pathlib.Path(path).read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + return {} + return data if isinstance(data, dict) else {} + + +def _findings(payload: dict[str, Any]) -> list[dict[str, Any]]: + items = payload.get("comments") + if not isinstance(items, list): + return [] + return [i for i in items if isinstance(i, dict)] + + +def _dict_items(raw: Any) -> list[dict[str, Any]]: + if isinstance(raw, dict): + return [raw] + if isinstance(raw, list): + return [i for i in raw if isinstance(i, dict)] + return [] + + +# ---------------- レビューの投稿 ---------------- + + +def _line_no(value: Any) -> int | None: + """行番号として読めるときだけ正の int を返す。 + + **行は担当が書き出す外部入力である。** `"L42"` や `"40-45"` を `int()` へ渡すと + 例外でレビュー全体が失われるため、読めない値は位置を持たないものとして扱う。 + """ + if isinstance(value, bool): + return None + if isinstance(value, int): + return value if value > 0 else None + if isinstance(value, str) and value.strip().isdecimal(): + return int(value.strip()) or None + return None + + +def _can_be_inline(finding: dict[str, Any]) -> bool: + """指す先を持つか。**差分に含まれるかどうかは見ない。** + + 含まれるかは送ってみた応答が決める(設計の決定 9)。ここで見るのは、そもそも + 指す位置があるかどうかだけである。行が数として読めない指摘は総評へ回す。 + """ + return bool(finding.get("path")) and _line_no(finding.get("line")) is not None + + +def _evacuated_line(finding: dict[str, Any]) -> str: + where = str(finding.get("path") or "") + line = finding.get("line") + head = f"`{where}:{line}`" if where and line is not None else ( + f"`{where}`" if where else "") + severity = str(finding.get("severity") or "") + mark = f" [{severity}]" if severity else "" + text = str(finding.get("body") or "").strip() + return f"- {head}{mark} {text}".strip() + + +def _review_body(payload: dict[str, Any], round_no: int, seat: str, intent: str, + evacuated: list[dict[str, Any]]) -> str: + parts = [REVIEW_HEAD.format(round_no=round_no, seat=seat, event=intent)] + summary = str(payload.get("summary") or "").strip() + if summary: + parts.append(summary) + if evacuated: + parts.append(EVACUATED_HEAD) + parts.append("\n".join(_evacuated_line(f) for f in evacuated)) + return "\n\n".join(parts) + "\n" + + +def review_posts(payload_path: pathlib.Path | str, result_path: pathlib.Path | str, + repo: str, pr: int, round_no: int, seat: str, + head_sha: str | None, is_own_pr: bool, + evacuate_all: bool = False, + since: str | None = None) -> list[dict[str, Any]]: + """指摘の控えと結果ファイルから、待ち行列へ積む項目の列を組み立てる。 + + **判定の格下げはこの層が決める**(設計の決定 14)。自分の Pull Request へは変更を + 求めるレビューを送れないため、送る形だけを `COMMENT` へ落とす。本来の判定は + 先頭行と `extra` に残り、収束の判定はそちらを読む。 + + `since` はそのラウンドが始まった時刻である。二度書かない照合をこの時刻より後に + 出たレビューへ絞るために、待ち行列の項目へ持たせる。 + + `evacuate_all` が真のとき、位置を持つ指摘もすべて総評へ移す。送った要求が位置を + 解決できずに拒まれた後の送り直しで使う。 + """ + payload = _read_json(payload_path) + result = _read_json(result_path) + findings = _findings(payload) + intent = str(result.get("event") or result.get("intent") or "COMMENT") + posted_as = "COMMENT" if is_own_pr else intent + + inline = [] if evacuate_all else [f for f in findings if _can_be_inline(f)] + evacuated = [f for f in findings if f not in inline] + body = _review_body(payload, round_no, seat, intent, evacuated) + + fields: dict[str, Any] = {"body": body, "event": posted_as} + if head_sha: + fields["commit_id"] = head_sha + if since: + fields["since"] = since + if inline: + fields["comments"] = [ + {"path": str(f.get("path")), "line": _line_no(f.get("line")), + "side": "RIGHT", "body": str(f.get("body") or "")} + for f in inline + ] + extra = {"ident": f"{seat}-r{round_no}", "agent": seat, "seat": seat, + "round": round_no, + "intent": intent, "posted_as": posted_as, + "inline": len(inline), "body": len(evacuated)} + return [{"kind": "review-post", "fields": fields, "extra": extra}] + + +class ReviewOutcome(NamedTuple): + """レビューを 1 件送った結果。**本文は持たない。**""" + + review_url: str | None + posted_inline: int + posted_body: int + queued: int + findings: int + failed: bool + posted_as: str + intent: str + detail: str + + +def _find(items: list[dict[str, Any]], seq: Any) -> dict[str, Any] | None: + return next((i for i in items if i.get("seq") == seq), None) + + +def _response_url(item: dict[str, Any] | None) -> str | None: + response = (item or {}).get("response") + if not isinstance(response, dict): + return None + url = response.get("html_url") + if url: + return str(url) + if response.get("id"): + return f"#pullrequestreview-{response['id']}" + return None + + +def _write_destinations(payload_path: pathlib.Path | str, inline_count: int) -> str: + """控えへ、送れた先を書き戻す。**決めるのは投稿する側である。** + + **原子的に書き、失敗は呼び出し元へ返す。** 半端な控えが残ると、再実行で読めずに + 空として扱われ、記録済みの指摘を 0 件で置き換える。戻り値は失敗の説明で、 + 書けたときは空文字である。 + """ + path = pathlib.Path(payload_path) + payload = _read_json(path) + findings = _findings(payload) + if not findings: + return "" + inline_ids = {id(f) for f in findings if _can_be_inline(f)} if inline_count else set() + for f in findings: + f["posted_to"] = "inline" if id(f) in inline_ids else "body" + try: + statefile.write_json_atomic(path, payload) + except OSError as exc: + return f"控えへ送れた先を書けない ({path.name}: {exc})" + return "" + + +def post_review(queue: post_queue.Queue, payload_path: pathlib.Path | str, + result_path: pathlib.Path | str, repo: str, pr: int, round_no: int, + seat: str, head_sha: str | None, is_own_pr: bool, + actor: str | None = None, since: str | None = None) -> ReviewOutcome: + """レビューを 1 件、待ち行列を通して送る。 + + **位置を解決できずに拒まれたら、その要求のインラインをすべて総評へ移して送り直す。** + 応答はどの項目が原因かを指さないため、1 件ずつの特定はできない(実測)。 + 同じ状態で返る別の拒まれ方(判定の値の誤り・基準のコミットの誤り)は退避せず、 + 失敗として残す。 + + **退避するのは、今回積んだ項目が拒まれたときだけである。** 先に積まれていた項目 + (先客)の拒まれ方を今回分のものと取り違えると、先客を消して今回分を二重に積む。 + 今回分が送れていない限り、控えへ送れた先を書かない。 + """ + findings = len(_findings(_read_json(payload_path))) + item = review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr, since=since)[0] + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + flushed = queue.flush() + + ours_failed = flushed.failed is not None and flushed.failed.get("seq") == seq + if ours_failed and post_queue.rejected_by_position(flushed.failed): + queue.drop(flushed.failed.get("seq")) + item = review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr, evacuate_all=True, since=since)[0] + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + flushed = queue.flush() + + done = _find(flushed.sent, seq) or _find(flushed.skipped, seq) + # 送れた後に控えを書けなければ、取り込みを止める。再実行は既投稿として照合し直す。 + note_error = _write_destinations(payload_path, item["extra"]["inline"]) if done else "" + return ReviewOutcome( + review_url=_response_url(done), + posted_inline=item["extra"]["inline"] if done else 0, + posted_body=item["extra"]["body"] if done else 0, + queued=0 if done else 1, + findings=findings, + # 先客に止められた場合も、今回分は送れていない。上限だけは待てば流れる。 + failed=bool(note_error) or bool(not done and flushed.failed is not None + and not flushed.rate_limited), + posted_as=item["extra"]["posted_as"], + intent=item["extra"]["intent"], + detail=note_error or str((flushed.failed or {}).get("last_error") or ""), + ) + + +# ---------------- 修正の投稿 ---------------- + + +def _reply(comment_id: Any, body: str) -> dict[str, Any] | None: + try: + target = int(comment_id) + except (TypeError, ValueError): + return None + return {"kind": "review-reply", "fields": {"in_reply_to": target, "body": body}, + "extra": {"ident": f"reply-{target}"}} + + +def _fix_summary_body(fix: dict[str, Any], round_no: int | None, + resolved: int, deferred: int, rejected: int) -> str: + commit = str(fix.get("fix_commit") or fix.get("commit_sha") or "(なし)") + head = (FIX_HEAD.format(round_no=round_no, commit=commit) if round_no is not None + else FIX_HEAD_NO_ROUND.format(commit=commit)) + by = fix.get("by_severity") or {} + counts = " / ".join(f"{k}={by.get(k, 0)}" for k in ("critical", "major", "minor")) + lines = [ + head, + "", + f"対応件数: {counts}(合計 {fix.get('fixed_count', fix.get('fixed', 0))} 件)", + f"決着: {resolved} 件 / 見送り: {deferred} 件 / 却下: {rejected} 件", + f"CI: {fix.get('ci_status') or 'NONE'}", + ] + note = str(fix.get("ci_note") or "").strip() + if note: + lines += ["", note] + return "\n".join(lines) + "\n" + + +def fix_posts(result_path: pathlib.Path | str, repo: str, pr: int, + round_no: int | None = None) -> list[dict[str, Any]]: + """修正の結果ファイルから、待ち行列へ積む項目の列を組み立てる。 + + 並びは「返信 → 決着 → まとめ」である。返信を先に置くのは、決着したスレッドが + 畳まれた後に返信が届くと、読み手がその返信を開かないためである。 + """ + fix = _read_json(result_path) + resolved = _dict_items(fix.get("resolved_threads")) + deferred = _dict_items(fix.get("deferred")) + rejected = _dict_items(fix.get("rejected")) + commit = str(fix.get("fix_commit") or fix.get("commit_sha") or "") + + # (要素の列, 返信の定型句, 理由を取り出すキー)。決着は理由の代わりにコミットを添える + reply_rules = ( + (resolved, "対応しました。", None), + (deferred, "見送ります。", "reason_for_deferral"), + (rejected, "この指摘は採らない判断です。", "reason_for_rejection"), + ) + items: list[dict[str, Any]] = [] + for entries, lead, reason_key in reply_rules: + for entry in entries: + if reason_key is None: + note = f"({commit})" if commit else "" + else: + note = str(entry.get(reason_key) or entry.get("reason") or "") + reply = _reply(entry.get("comment_id"), f"{lead}{note}".strip()) + if reply: + items.append(reply) + # 見送り・却下は既定では決着させない(次のラウンドで見直す)。最終スイープは + # スレッドを残さないため、要素の `resolve` を真にして決着まで求める。 + closing = resolved + [e for e in deferred + rejected if e.get("resolve")] + for entry in closing: + thread_id = entry.get("thread_id") + if thread_id: + items.append({"kind": "thread-resolve", + "fields": {"thread_id": str(thread_id)}, + "extra": {"ident": f"resolve-{thread_id}"}}) + items.append({ + "kind": "pr-comment", + "fields": {"body": _fix_summary_body(fix, round_no, len(resolved), + len(deferred), len(rejected))}, + "extra": {"ident": f"fix-summary-{round_no if round_no is not None else commit}"}, + }) + return items + + +class FixOutcome(NamedTuple): + """修正の投稿を送った結果。""" + + summary_url: str | None + replied: int + resolved: int + queued: int + failed: bool + detail: str + + +def post_fix(queue: post_queue.Queue, result_path: pathlib.Path | str, repo: str, + pr: int, round_no: int | None = None, + actor: str | None = None) -> FixOutcome: + """返信・決着・まとめを待ち行列へ積んで流す。""" + seqs: dict[int, str] = {} + for item in fix_posts(result_path, repo, pr, round_no): + path = post_queue.enqueue(queue, item["kind"], repo, pr, item["fields"], + actor=actor, extra=item["extra"]) + seq = (post_queue.read_item(path) or {}).get("seq") + if seq is not None: + seqs[int(seq)] = item["kind"] + flushed = queue.flush() + + done = {int(i["seq"]): i for i in (flushed.sent + flushed.skipped) + if i.get("seq") is not None} + summary_url = None + for seq, kind in seqs.items(): + if kind == "pr-comment" and seq in done: + response = done[seq].get("response") + if isinstance(response, dict): + summary_url = response.get("html_url") or response.get("url") + failed = flushed.failed is not None and not flushed.rate_limited + return FixOutcome( + summary_url=str(summary_url) if summary_url else None, + replied=sum(1 for s, k in seqs.items() if k == "review-reply" and s in done), + resolved=sum(1 for s, k in seqs.items() if k == "thread-resolve" and s in done), + queued=flushed.remaining, + failed=bool(failed), + detail=str((flushed.failed or {}).get("last_error") or ""), + ) + + +# ---------------- 修正の送信 ---------------- + + +class PushResult(NamedTuple): + """送信の結果と、報告されたコミットが送り先に載っているか。""" + + ok: bool + pushed: bool + contains: bool + detail: str + + +# 認証の退避の値は共通層 1 か所が持つ(`git-credential.sh`)。ここへ写さない。 +_CREDENTIAL_LIB = pathlib.Path(__file__).resolve().parent / "git-credential.sh" + + +def _credential_fallback_args() -> list[str]: + r = subprocess.run( + ["bash", "-c", f'. "{_CREDENTIAL_LIB}"; ndf_git_credential_fallback_args'], + capture_output=True, text=True) + return [line for line in r.stdout.split("\n") if line] if r.returncode == 0 else [] + + +def _git(worktree: pathlib.Path | str, *args: str) -> subprocess.CompletedProcess: + """git を 1 度実行し、認証で落ちたときだけ helper を退避して 1 度だけやり直す(#524)。""" + cmd = ["git", "-C", str(worktree), *args] + first = subprocess.run(cmd, capture_output=True, text=True) + if first.returncode == 0 or args[0] not in ("push", "fetch"): + return first + fallback = _credential_fallback_args() + if not fallback: + return first + return subprocess.run(["git", "-C", str(worktree), *fallback, *args], + capture_output=True, text=True) + + +def push_fix(worktree: pathlib.Path | str, head_branch: str, + fix_commit: str | None) -> PushResult: + """現在の頭を送り先のブランチへ送り、報告されたコミットが載ったことを確かめる。 + + **ブランチ名だけを指定しない。** 作業ツリーが切り離された頭で作られている場合、 + ブランチ名だけの指定では現在の頭が送られないまま終了コード 0 で終わる。 + """ + if not fix_commit: + return PushResult(True, False, True, "コミットが無いため送らない") + if not (str(worktree or "") and head_branch): + return PushResult(False, False, False, + "送る先(作業ツリーとブランチ)が分からない") + pushed = _git(worktree, "push", "origin", f"HEAD:{head_branch}") + if pushed.returncode != 0: + return PushResult(False, False, False, + (pushed.stderr or pushed.stdout or "").strip()[:300]) + fetched = _git(worktree, "fetch", "origin", head_branch) + if fetched.returncode != 0: + return PushResult(False, True, False, + (fetched.stderr or "").strip()[:300]) + contains = _git(worktree, "merge-base", "--is-ancestor", fix_commit, "FETCH_HEAD") + if contains.returncode != 0: + return PushResult(False, True, False, + f"報告されたコミット {fix_commit} が origin/{head_branch} に載っていない") + return PushResult(True, True, True, "") + + +# ---------------- 部分命令 ---------------- + + +def _sh(*cmd: str, cwd: pathlib.Path | str | None = None) -> str: + r = subprocess.run(list(cmd), capture_output=True, text=True, cwd=cwd) + return r.stdout.strip() if r.returncode == 0 else "" + + +def queue_for(worktree: pathlib.Path) -> post_queue.Queue: + """待ち行列の置き場所。**引数では渡さない。** 作業ツリーの下の決まった名前から導く。""" + base = os.environ.get("CROSS_REVIEW_TMP_DIR") or str(worktree / TMP_DIRNAME) + return post_queue.Queue(pathlib.Path(base) / post_queue.QUEUE_DIRNAME) + + +class FixInputs(NamedTuple): + worktree: pathlib.Path + repo: str + head: str + result: pathlib.Path + fix: dict[str, Any] + + +def _resolve_fix_inputs(args: argparse.Namespace) -> tuple[FixInputs | None, str]: + """単独 fix 命令の入力を引数と既定値から解決する。 + + **リポジトリと頭は作業ツリーの中で解決する。** 呼び出し元の cwd が作業ツリーの + 外だと、`gh` が別のリポジトリを読むか解決に失敗し、頭が空のまま送信を飛ばす。 + """ + worktree = pathlib.Path(args.worktree or os.getcwd()).resolve() + repo = args.repo or _sh("gh", "repo", "view", "--json", "nameWithOwner", + "-q", ".nameWithOwner", cwd=worktree) + if not repo: + return None, "リポジトリを決められない(--repo を渡す)" + head = args.head or _sh("gh", "pr", "view", str(args.pr), "-R", repo, + "--json", "headRefName", "-q", ".headRefName", + cwd=worktree) + if not head: + # 送れない修正へ「対応しました」と返信しないため、返信へ進まず止める。 + return None, "送り先のブランチを決められない(--head を渡す)" + result = pathlib.Path(args.result) if args.result else ( + pathlib.Path(os.environ.get("CROSS_REVIEW_TMP_DIR") + or str(worktree / TMP_DIRNAME)) / f"fix-pr{args.pr}-result.json") + fix = _read_json(result) + if not fix: + return None, f"修正の結果ファイルを読めない: {result}" + return FixInputs(worktree, repo, head, result, fix), "" + + +def cmd_fix(args: argparse.Namespace) -> int: + inputs, error = _resolve_fix_inputs(args) + if inputs is None: + print(error, file=sys.stderr) + return 1 + worktree, repo, head, result, fix = inputs + + pushed = push_fix(worktree, head, fix.get("fix_commit") or fix.get("commit_sha")) + print(f"PUSHED={1 if pushed.pushed else 0} " + f"COMMIT_ON_HEAD={1 if pushed.contains else 0}") + if not pushed.ok: + print(pushed.detail, file=sys.stderr) + return 1 + + actor = args.actor or _sh("gh", "api", "user", "-q", ".login") or None + outcome = post_fix(queue_for(worktree), result, repo, int(args.pr), + round_no=args.round, actor=actor) + if outcome.summary_url: + print(f"POSTED summary_url={outcome.summary_url}") + print(f"REPLIED={outcome.replied} RESOLVED={outcome.resolved} " + f"QUEUED={outcome.queued}") + if outcome.failed: + print(outcome.detail, file=sys.stderr) + return 1 + return 0 + + +def main() -> None: + p = argparse.ArgumentParser(description="結果ファイルから投稿を組み立てて送る") + sub = p.add_subparsers(dest="cmd", required=True) + f = sub.add_parser("fix", help="修正の結果ファイルの返信・決着・まとめと送信") + f.add_argument("--repo") + f.add_argument("--pr", required=True) + f.add_argument("--result") + f.add_argument("--head") + f.add_argument("--worktree") + f.add_argument("--round", type=int) + f.add_argument("--actor") + f.set_defaults(func=cmd_fix) + args = p.parse_args() + sys.exit(args.func(args)) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/lib/run_metrics.py b/plugins/ndf/scripts/lib/run_metrics.py index f650a077..e746a8da 100755 --- a/plugins/ndf/scripts/lib/run_metrics.py +++ b/plugins/ndf/scripts/lib/run_metrics.py @@ -314,15 +314,24 @@ def _bound(value: Optional[str], *, upper: bool) -> Optional[_dt.datetime]: return parsed +def _within_time_bound(started: Optional[_dt.datetime], + since: Optional[_dt.datetime], + until: Optional[_dt.datetime], + until_exclusive: bool) -> bool: + if since and (started is None or started < since): + return False + if until and (started is None or (started >= until if until_exclusive else started > until)): + return False + return True + + def _select(rows: list[dict], args: argparse.Namespace) -> list[dict]: since, until = _bound(args.since, upper=False), _bound(args.until, upper=True) until_exclusive = bool(args.until and re.fullmatch(r"\d{4}-\d{2}-\d{2}", args.until)) out = [] for row in rows: started = _parse_time(row.get("started_at")) - if since and (started is None or started < since): - continue - if until and (started is None or (started >= until if until_exclusive else started > until)): + if not _within_time_bound(started, since, until, until_exclusive): continue if args.repo and row.get("repo") != args.repo: continue @@ -388,14 +397,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/statefile.py b/plugins/ndf/scripts/lib/statefile.py index 49e5cb9d..6e15626b 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` が実行の要約の書き出しを登録する。 @@ -41,16 +41,26 @@ def load(path: pathlib.Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) -def save(path: pathlib.Path, state: dict[str, Any]) -> None: - """状態ファイルを原子的に書く。 +def write_json_atomic(path: pathlib.Path, data: Any) -> None: + """JSON を原子的に書く。 同じディレクトリへ一時ファイルを書いてから `replace` する。途中で落ちても - 半端な JSON が残らないため、再開時に必ず読める。 + 半端な JSON が残らないため、再開時に必ず読める。失敗は例外で返し、一時 + ファイルは残さない。 """ path.parent.mkdir(parents=True, exist_ok=True) tmp = path.with_suffix(".json.tmp") - tmp.write_text(json.dumps(state, indent=2, ensure_ascii=False), encoding="utf-8") - tmp.replace(path) + try: + tmp.write_text(json.dumps(data, indent=2, ensure_ascii=False), encoding="utf-8") + tmp.replace(path) + except BaseException: + tmp.unlink(missing_ok=True) + raise + + +def save(path: pathlib.Path, state: dict[str, Any]) -> None: + """状態ファイルを原子的に書く(`write_json_atomic`)。""" + write_json_atomic(path, state) # **差し込み口の失敗で保存の呼び出し側を止めない。** 状態は既に書けている。 for hook in list(_AFTER_SAVE): try: @@ -82,3 +92,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/lib/worktree-common.sh b/plugins/ndf/scripts/lib/worktree-common.sh index 89fe42dc..547be0e5 100644 --- a/plugins/ndf/scripts/lib/worktree-common.sh +++ b/plugins/ndf/scripts/lib/worktree-common.sh @@ -1055,7 +1055,28 @@ wt_extract_write_target() { _wt_read_lines < <(_wt_extract_tokenize "$spaced") words=("${WT_LINES[@]+"${WT_LINES[@]}"}") - # 段 3: 現在地追跡と書き込み先抽出のメイン走査 + # 段 3・段 4: 語列を 1 度読み通し、現在地を追いながら書き込み先を出す。 + _wt_extract_scan "$base" ${words[@]+"${words[@]}"} +} + +# 語列を 1 度だけ読み通す走査。第 1 引数は相対パスの起点、残りが語である。 +# 書き込み先を 1 件でも出せば 0、出せなければ 1 を返す。 +# +# 語ごとの処理は 3 つに分かれる。 +# +# | 段 | 関数 | 何を決めるか | +# | --- | --- | --- | +# | 命令の位置 | `_wt_scan_command_position` | その語が命令名か、関数定義の途中か | +# | 段 3: 追跡 | `_wt_scan_track_word` | 現在地と複合構文の入れ子。当たれば次の語へ進む | +# | 段 4: 抽出 | `_wt_scan_emit_word` | 書き込み先の語を出す | +# +# 状態は走査の全体で共有するため、各段はこの関数の局所変数を直接読み書きする。 +_wt_extract_scan() { + local base="$1" + shift + local -a words=() + words=("$@") + # `cd` を追った現在地と、それが確かかどうか。起点を渡されない限り使わない。 local cwd="$base" cwd_known=1 # `cd` の効果が及ぶ範囲は、それが動くシェルの中に限られる。パイプの各区画と @@ -1496,8 +1517,9 @@ wt_extract_write_target() { _emit "$dest" } - for ((i = 0; i < n; i++)); do - w=${words[i]} + # 今の語が命令の位置にあるかを決め、関数定義の見分けを進める。結果は `at_cmd` と + # `cmd_prefix` / `cmd_wrapper` / `func_stage` / `prev` に置く。書き込み先は出さない。 + _wt_scan_command_position() { # コマンドの位置にある語だけを命令として扱う。`echo cd > f` の `cd` を # 移動として数えると、書き込み先の起点がずれる。 at_cmd=0 @@ -1606,13 +1628,19 @@ wt_extract_write_target() { case "$func_moving" in *"|$w|"*) cwd_known=0 ;; esac fi prev="$w" + } + + # 段 3: 現在地と複合構文の追跡。今の語が区切り・複合構文・`cd` のいずれかであれば + # 走査の状態を更新して 0 を返す(呼び出し側はその語で次へ進む)。当たらなければ 1 を + # 返し、段 4 が書き込み先を見る。 + _wt_scan_track_word() { case "$w" in "|"|"|&") # パイプの各区画は部分シェルで動く。入口の位置へ戻す。 cwd="$pipe_cwd"; cwd_known="$pipe_known" # 区画の中の `cd` は親の位置を変えない。`||` の判定に使う回数も戻す。 list_cds="$pipe_cds"; cd_is_last=0 - continue + return 0 ;; "&") # 背景実行は処理のまとまりごと部分シェルへ入る。入口の位置へ戻す。 @@ -1622,7 +1650,7 @@ wt_extract_write_target() { list_cwd="$cwd"; list_known="$cwd_known"; list_cds=0; list_or=0 list_and_uncertain=0; list_cond_cd=0 cd_is_last=0 - continue + return 0 ;; "&&") # 左辺が成功したときに走る。移動の効果は残る。パイプの入口だけ引き直す。 @@ -1636,7 +1664,7 @@ wt_extract_write_target() { # ことが確かである。 if [ "$list_or" = 1 ] && [ "$list_cds" != 0 ]; then cwd_known=0; fi pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds="$list_cds" - continue + return 0 ;; "||") # 右辺が**後続へ進まない命令**なら、そこを過ぎた時点で左辺の成功が確定 @@ -1667,7 +1695,7 @@ wt_extract_write_target() { list_cwd="$cwd"; list_known="$cwd_known"; list_cds=0 list_and_uncertain=0; list_cond_cd=0; cd_is_last=0 pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds=0 - continue + return 0 ;; "{") # `cd dir || { echo ...; exit 1; }` は `|| exit` より広く使われる。 @@ -1699,7 +1727,7 @@ wt_extract_write_target() { fi list_or=1 pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds="$list_cds" - continue + return 0 ;; __WT_SEP__|__WT_CASE_FALL__) # `;` と改行でも同じシェルが続く。両方の入口を引き直す。 @@ -1723,7 +1751,7 @@ wt_extract_write_target() { list_cwd="$cwd"; list_known="$cwd_known"; list_cds=0; list_or=0 list_and_uncertain=0; list_cond_cd=0 cd_is_last=0 - continue + return 0 ;; if|while|until|for|select|case) # 複合コマンドの入口。中で `cd` を追ったかを、閉じるときに比べるため控える。 @@ -1738,7 +1766,7 @@ wt_extract_write_target() { case_depth=$((case_depth + 1)) fi fi - continue + return 0 ;; "{"|"(") # 部分シェル (`(`) と、同じシェルで走るまとまり (`{`) の入口。どちらも @@ -1764,7 +1792,7 @@ wt_extract_write_target() { func_stage="" fi fi - continue + return 0 ;; "}") # `}` は予約語で、命令の位置にしか置けない。`echo }` の `}` は語である。 @@ -1781,7 +1809,7 @@ wt_extract_write_target() { pipe_cwd="$cwd"; pipe_known="$cwd_known"; pipe_cds=0 fi fi - continue + return 0 ;; __WT_SUBSHELL_END__) # 部分シェルの終わり。字句解析が切り出した `(` に対応するものだけが @@ -1793,7 +1821,7 @@ wt_extract_write_target() { _close_function_body _pop_group _pop_subshell - continue + return 0 ;; else|elif) # 条件が偽のときに走る。条件の中の `cd` は効いていない。 @@ -1801,7 +1829,7 @@ wt_extract_write_target() { [ "$cds" -gt "${block_cds[block_depth - 1]}" ]; then cwd_known=0 fi - continue + return 0 ;; fi|done|esac) # 本体が走ったかどうかは実行時に決まる。中で移動していたなら、閉じた後の @@ -1814,65 +1842,77 @@ wt_extract_write_target() { case_depth=$((case_depth - 1)) fi fi - continue - ;; - cd) - [ "$at_cmd" = 1 ] && [ -n "$base" ] || continue - # 部分シェルの中でも移動は追う。中の相対パスはここで解決する。親の位置は - # `)` で `_pop_subshell` が戻すため、この移動は外へ漏れない。 - # 移動先の語と、この `cd` に付いたリダイレクトを 1 回の走査で拾う。 - # **リダイレクト先は移動する前の位置で開かれる。** シェルはリダイレクトを - # 開いてから命令を実行するためである。移動後の位置で解決すると、主 - # ディレクトリ側への書き込みを作業ツリー側と取り違えて案内を出さない - # (検知漏れになる)。まだ `cwd` を更新していないここで解決する。 - dest="" - cd_end_of_options=0 - for ((k = i + 1; k < n; k++)); do - case "${words[k]}" in - __WT_REDIR__|__WT_APPEND__) - _redir_target "$k" - _emit "$_WT_REDIR_DEST" - k=$_WT_REDIR_END - continue - ;; - esac - if _wt_is_separator "${words[k]}"; then break; fi - case "${words[k]}" in - # `--` 以降はオプションの解釈を止める。`cd -- -dir` の `-dir` は - # 移動先であって `cd -` ではない。止めないと読み飛ばして、後続の - # 相対パスを抑止する。 - --) [ "$cd_end_of_options" = 1 ] || { cd_end_of_options=1; continue; } ;; - # **`-` だけは `--` の後でも直前の位置を指す。** bash では `-` が - # オプションではなく被演算子の綴りとして扱われるためで、`-` という - # 名前のディレクトリがあっても `$OLDPWD` へ移る(実測で確認)。 - # 字面からは追えないため、移動先を決めない。 - -) continue ;; - # `cd -` と同じく、オプションは移動先ではない。 - -*) [ "$cd_end_of_options" = 1 ] || continue ;; - esac - # 移動先は最初の被演算子である。リダイレクトを拾い切るため、 - # 見つけても区切りまで走査を続ける。 - [ -n "$dest" ] || dest=${words[k]} - done - # 走査が届いた位置を控える。`__WT_REDIR__` の枝が同じ語を二度拾わない。 - resolved_redir_end=$k - # `||` の右辺で戻せるかどうかの判定に使う。 - list_cds=$((list_cds + 1)); cd_is_last=1 - # `&&` を跨いだ先の `cd` は、走ったかどうかが左辺の成否で決まる。 - [ "$list_and_uncertain" = 0 ] || list_cond_cd=1 - # 複合コマンドを閉じるときの比較に使う。 - cds=$((cds + 1)) - case "$dest" in - # 引数なし (ホーム)・`cd -`・展開前の変数・チルダ展開。いずれも - # コマンドの字面からは移動先を決められない。 - ""|*'$'*|"~"*) cwd_known=0 ;; - /*) cwd=$(wt_normalize_path "$dest" "/"); cwd_known=1 ;; - *) [ "$cwd_known" = 1 ] && cwd=$(wt_normalize_path "$dest" "$cwd") ;; - esac + return 0 ;; + cd) _wt_scan_cd; return 0 ;; + esac + return 1 + } + + # `cd` の走査。移動先を現在地へ反映し、同じ命令に付いたリダイレクトを移動する前の + # 位置で解決する。命令の位置に無い `cd` と、起点を渡されない呼び方では何もしない。 + _wt_scan_cd() { + [ "$at_cmd" = 1 ] && [ -n "$base" ] || return 0 + # 部分シェルの中でも移動は追う。中の相対パスはここで解決する。親の位置は + # `)` で `_pop_subshell` が戻すため、この移動は外へ漏れない。 + # 移動先の語と、この `cd` に付いたリダイレクトを 1 回の走査で拾う。 + # **リダイレクト先は移動する前の位置で開かれる。** シェルはリダイレクトを + # 開いてから命令を実行するためである。移動後の位置で解決すると、主 + # ディレクトリ側への書き込みを作業ツリー側と取り違えて案内を出さない + # (検知漏れになる)。まだ `cwd` を更新していないここで解決する。 + dest="" + cd_end_of_options=0 + for ((k = i + 1; k < n; k++)); do + case "${words[k]}" in + __WT_REDIR__|__WT_APPEND__) + _redir_target "$k" + _emit "$_WT_REDIR_DEST" + k=$_WT_REDIR_END + continue + ;; + esac + if _wt_is_separator "${words[k]}"; then break; fi + case "${words[k]}" in + # `--` 以降はオプションの解釈を止める。`cd -- -dir` の `-dir` は + # 移動先であって `cd -` ではない。止めないと読み飛ばして、後続の + # 相対パスを抑止する。 + --) [ "$cd_end_of_options" = 1 ] || { cd_end_of_options=1; continue; } ;; + # **`-` だけは `--` の後でも直前の位置を指す。** bash では `-` が + # オプションではなく被演算子の綴りとして扱われるためで、`-` という + # 名前のディレクトリがあっても `$OLDPWD` へ移る(実測で確認)。 + # 字面からは追えないため、移動先を決めない。 + -) continue ;; + # `cd -` と同じく、オプションは移動先ではない。 + -*) [ "$cd_end_of_options" = 1 ] || continue ;; + esac + # 移動先は最初の被演算子である。リダイレクトを拾い切るため、 + # 見つけても区切りまで走査を続ける。 + [ -n "$dest" ] || dest=${words[k]} + done + # 走査が届いた位置を控える。`__WT_REDIR__` の枝が同じ語を二度拾わない。 + resolved_redir_end=$k + # `||` の右辺で戻せるかどうかの判定に使う。 + list_cds=$((list_cds + 1)); cd_is_last=1 + # `&&` を跨いだ先の `cd` は、走ったかどうかが左辺の成否で決まる。 + [ "$list_and_uncertain" = 0 ] || list_cond_cd=1 + # 複合コマンドを閉じるときの比較に使う。 + cds=$((cds + 1)) + case "$dest" in + # 引数なし (ホーム)・`cd -`・展開前の変数・チルダ展開。いずれも + # コマンドの字面からは移動先を決められない。 + ""|*'$'*|"~"*) cwd_known=0 ;; + /*) cwd=$(wt_normalize_path "$dest" "/"); cwd_known=1 ;; + *) [ "$cwd_known" = 1 ] && cwd=$(wt_normalize_path "$dest" "$cwd") ;; + esac + } + + # 段 4: 書き込み先の抽出。段 3 が扱わなかった語だけが渡る。出力は `_emit` が行い、 + # 相対パスはそこで現在地から解決される。 + _wt_scan_emit_word() { + case "$w" in __WT_REDIR__|__WT_APPEND__) # 移動前の位置で解決済みのリダイレクトは、その枝が拾い終えている。 - if [ "$i" -lt "$resolved_redir_end" ]; then continue; fi + if [ "$i" -lt "$resolved_redir_end" ]; then return 0; fi _redir_target "$i" _emit "$_WT_REDIR_DEST" # 命令の位置で読んだなら、被演算子の次に命令名が続く。 @@ -1900,11 +1940,20 @@ wt_extract_write_target() { _wt_extract_cp_mv_target "$i" ;; esac + } + + # 入口は各段を語ごとに順へ接続するだけにする。 + for ((i = 0; i < n; i++)); do + w=${words[i]} + _wt_scan_command_position + if _wt_scan_track_word; then continue; fi + _wt_scan_emit_word done unset -f _emit _push_group _pop_group _push_subshell _pop_subshell _or_group_exits \ _or_exit_redirs _close_function_body _wt_extract_sed_targets _wt_extract_cp_mv_target \ - _redir_span _wt_take_redirect_operand + _redir_span _wt_take_redirect_operand \ + _wt_scan_command_position _wt_scan_track_word _wt_scan_cd _wt_scan_emit_word [ "$found" = 1 ] || return 1 } diff --git a/plugins/ndf/scripts/statusline-switch.sh b/plugins/ndf/scripts/statusline-switch.sh index bccb616a..35657541 100755 --- a/plugins/ndf/scripts/statusline-switch.sh +++ b/plugins/ndf/scripts/statusline-switch.sh @@ -103,9 +103,20 @@ update_settings() { fi } +# 再描画の間隔 (秒)。メインが待機中でもイベントが起きず描き直されないため、 +# サブエージェントの使用量を追うには時間で描き直す必要がある +NDF_REFRESH_INTERVAL=5 + set_ndf_statusline() { - update_settings --arg cmd "$NDF_COMMAND" \ - '.statusLine = {type: "command", command: $cmd}' + update_settings --arg cmd "$NDF_COMMAND" --argjson ri "$NDF_REFRESH_INTERVAL" \ + '.statusLine = {type: "command", command: $cmd, refreshInterval: $ri}' +} + +# 既に NDF 標準を使っている設定へ、refreshInterval が無ければ足す。利用者が決めた値は残す +ensure_refresh_interval() { + if [ "$(jq -r '.statusLine | has("refreshInterval")' "$SETTINGS" 2>/dev/null)" = false ]; then + update_settings --argjson ri "$NDF_REFRESH_INTERVAL" '.statusLine.refreshInterval = $ri' + fi } # NDF 由来の旧 statusline を検出した際に、既存設定をバックアップした上で @@ -124,8 +135,9 @@ cmd_ensure() { deploy_script # 既に statusLine が設定されている場合 if [ -n "$(jq -r '.statusLine // empty' "$SETTINGS" 2>/dev/null)" ]; then - # 正規パスを指していれば deploy_script で本体が追従済み (何もしない) + # 正規パスを指していれば deploy_script で本体が追従済み。再描画の間隔だけ補う if is_ndf_statusline; then + ensure_refresh_interval return 0 fi # NDF が過去に配置したコピー (マーカー付き or レガシー statusline-command.sh) を @@ -146,6 +158,7 @@ cmd_ensure() { cmd_set() { deploy_script if is_ndf_statusline; then + ensure_refresh_interval echo "[ndf:statusline] 既に NDF 標準 statusline が設定されています" return 0 fi diff --git a/plugins/ndf/scripts/statusline.sh b/plugins/ndf/scripts/statusline.sh index 19d7c098..290bb880 100755 --- a/plugins/ndf/scripts/statusline.sh +++ b/plugins/ndf/scripts/statusline.sh @@ -1,60 +1,101 @@ #!/bin/bash # ndf-statusline: managed (do not edit; auto-updated by ndf:statusline) # NDF plugin 標準 statusline: -# <コンテナ名 or ホスト名> [<モデル名>: 使用トークン / 全体 (使用率%)] +# [<モデル名> 使用トークン │ <サブエージェントの説明> 使用トークン · ...] input=$(cat) -# コンテナ名を取得する。コンテナでなければホスト名にフォールバック -container_name="" - -# Docker/コンテナ環境かどうかを /.dockerenv で判定 -if [ -f /.dockerenv ]; then - # CONTAINER_NAME 環境変数が明示設定されていればそちらを優先 - container_name="${CONTAINER_NAME:-}" - - # Docker ソケットが使えれば docker inspect で compose 上のコンテナ名を取得 - # (/etc/hostname はコンテナIDなので、それをキーに引く) - if [ -z "$container_name" ] && [ -S /var/run/docker.sock ] && command -v docker >/dev/null 2>&1; then - container_id=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') - if [ -n "$container_id" ]; then - container_name=$(docker inspect --format '{{.Name}}' "$container_id" 2>/dev/null | sed 's|^/||') - fi - fi - - # 取れなければ /etc/hostname(コンテナID)をフォールバックとして使用 - if [ -z "$container_name" ] && [ -f /etc/hostname ]; then - container_name=$(cat /etc/hostname 2>/dev/null | tr -d '[:space:]') - fi -fi - -# コンテナ名が取れなければ hostname コマンドにフォールバック -if [ -z "$container_name" ]; then - container_name=$(hostname -s 2>/dev/null || hostname) -fi - -dir="$container_name" - # claude root のパスを取得(project_dir を優先し、なければ current_dir を使用) # jq 不在や無効な JSON 入力時に stderr が statusLine 描画に漏れないよう 2>/dev/null で抑制 claude_root=$(echo "$input" | jq -r '.workspace.project_dir // .workspace.current_dir // empty' 2>/dev/null) total_input=$(echo "$input" | jq -r '.context_window.total_input_tokens // empty' 2>/dev/null) ctx_size=$(echo "$input" | jq -r '.context_window.context_window_size // empty' 2>/dev/null) -used_pct=$(echo "$input" | jq -r '.context_window.used_percentage // empty' 2>/dev/null) +transcript=$(echo "$input" | jq -r '.transcript_path // empty' 2>/dev/null) -# モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック -model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null) +# モデル表示名を取得(ラベルとして使用)。取れなければ "ctx" にフォールバック。 +# 現行モデルの上限は 1M なので、"Opus 5 (1M context)" の括弧と空白は落として "Opus5" にする +model_name=$(echo "$input" | jq -r '.model.display_name // .model.id // empty' 2>/dev/null | sed 's/ *(.*)//; s/ //g') ctx_label="${model_name:-ctx}" +# 使用量がこの値を超えたら赤で知らせる。上限は出さないため、色で危険な水準を示す +WARN_TOKENS=500000 +WARN_COLOR='\033[0;31m' + ctx_info="" -if [ -n "$total_input" ] && [ -n "$ctx_size" ] && [ -n "$used_pct" ]; then - total_input_k=$(awk "BEGIN { printf \"%.1f\", $total_input / 1000 }") - ctx_size_k=$(awk "BEGIN { printf \"%.0f\", $ctx_size / 1000 }") - ctx_info=$(printf " \033[0;36m[%s: %sk / %sk tokens (%.0f%%)]" "$ctx_label" "$total_input_k" "$ctx_size_k" "$used_pct") +if [ -n "$total_input" ]; then + main_used="$((total_input / 1000))k" + # 上限が 200K 以下のモデル(Haiku 4.5)は 150k で知らせる。モデル名ではなく入力の上限で決める + main_limit=$WARN_TOKENS + [ -n "$ctx_size" ] && [ "$ctx_size" -le 200000 ] 2>/dev/null && main_limit=150000 + [ "$total_input" -gt "$main_limit" ] && main_used=$(printf "$WARN_COLOR%s\033[0;36m" "$main_used") + ctx_info=$(printf " \033[0;36m[%s %s" "$ctx_label" "$main_used") + + # 実行中のサブエージェントのコンテキスト使用量を並べる。statusLine の JSON は + # メインセッションの値しか持たないため、サブエージェントの記録から読む。 + # 記録は /subagents/agent-.jsonl にある + sub_dir="${transcript%.jsonl}/subagents" + rows="" + if [ -n "$transcript" ] && [ -d "$sub_dir" ]; then + now=$(date +%s) + # 直近 60 分以内に更新された記録を候補にし、実行中かどうかは記録の末尾で決める。 + # 更新の時刻では決めない。子を待つ supervisor や長いコマンドを待つ担当は、実行中でも + # 何分も書き足さない + # NUL 区切りで読む。空白を含むパスでも 1 ファイルとして扱う + while IFS= read -r -d '' f; do + # 末尾だけを読む。記録は長くなるため全体を走査しない。 + # 出力: モデル / 使用量 / 状態 (run | done | idle) + # 最後の user か assistant の行が tool_use を含まない assistant なら応答を書き終えている。 + # end_turn が付いていれば終了。付いていなければ tool_use の直前の text の途中かもしれない (idle) + st=$(tail -n 50 "$f" | jq -rs ' + (map(select(.type == "assistant" or .type == "user")) | last) as $l + | (map(select(.message.usage?)) | last) as $u + | if $u == null then empty else + [ ($u.message.model // ""), + ($u.message.usage | (.input_tokens // 0) + (.cache_creation_input_tokens // 0) + (.cache_read_input_tokens // 0)), + (if $l.type == "assistant" and ([$l.message.content[]?.type] | index("tool_use") | not) + then (if $l.message.stop_reason == "end_turn" then "done" else "idle" end) + else "run" end) ] + | @tsv end' 2>/dev/null) + [ -n "$st" ] || continue + IFS=$'\t' read -r model tokens state <<<"$st" + [ "$state" = done ] && continue + if [ "$state" = idle ]; then + # 30 秒以上書き足されていなければ終わったとみなす (GNU / BSD の stat の両方に対応) + mtime=$(stat -c %Y "$f" 2>/dev/null || stat -f %m "$f" 2>/dev/null) + [ -n "$mtime" ] && [ $((now - mtime)) -ge 30 ] && continue + fi + # 説明の先頭 4 文字をラベルにする。種類名は general-purpose がほとんどで見分けに使えない。 + # 空白と制御文字(\p{Cc} = U+0000-001F / U+007F-009F)を除く。ESC などを端末へ出さないため + label=$(jq -r '.description // empty | gsub("[\\p{Cc}\\s]"; "") | .[0:4]' "${f%.jsonl}.meta.json" 2>/dev/null) + [ -n "$label" ] || { label=$(basename "$f" .jsonl); label=${label#agent-}; label=${label:0:4}; } + # 500k を超えたら赤で知らせる。1M 未満のモデルは Haiku(200K)だけなので、Haiku は 150k で知らせる + limit=$WARN_TOKENS + case "$model" in *haiku*) limit=150000 ;; esac + warn=0 + [ "$tokens" -gt "$limit" ] && warn=1 + rows="$rows$tokens"$'\t'"$warn"$'\t'"$label"$'\n' + done < <(find "$sub_dir" -name 'agent-*.jsonl' -mmin -60 -print0 2>/dev/null) + fi + # 使用量の多い順に 3 本まで並べ、残りは本数だけを出す。80 桁の端末に収めるため + if [ -n "$rows" ]; then + subs="" + n=0 + while IFS=$'\t' read -r tokens warn label; do + n=$((n + 1)) + [ "$n" -gt 3 ] && continue + entry="$label $((tokens / 1000))k" + [ "$warn" = 1 ] && entry=$(printf "$WARN_COLOR%s\033[0;36m" "$entry") + subs="${subs:+$subs · }$entry" + done < <(printf "%s" "$rows" | sort -t $'\t' -k1,1nr) + [ "$n" -gt 3 ] && subs="$subs +$((n - 3))" + ctx_info="$ctx_info │ $subs" + fi + ctx_info="$ctx_info]" fi +# コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる if [ -n "$claude_root" ]; then - printf "\033[01;34m%s\033[00m \033[0;33m%s\033[00m%s" "$dir" "$claude_root" "$ctx_info" + printf "\033[0;33m%s\033[00m%s" "$claude_root" "$ctx_info" else - printf "\033[01;34m%s\033[00m%s" "$dir" "$ctx_info" + printf "%s" "${ctx_info# }" fi diff --git a/plugins/ndf/scripts/tests/test_auth_probe.py b/plugins/ndf/scripts/tests/test_auth_probe.py index 30eaa4d0..f8bca89a 100644 --- a/plugins/ndf/scripts/tests/test_auth_probe.py +++ b/plugins/ndf/scripts/tests/test_auth_probe.py @@ -1,9 +1,17 @@ +"""認証状態の確認(`lib/auth.py`)のテスト。 + +主題は止めない確認 `probe_auth`(#727)である。失敗しても例外を上げず、`ok` と理由を +返し、`NDF_SKIP_AUTH_CHECK` が立てば確認コマンドを 1 回も呼ばない(AC5 / AC6)。 +""" from __future__ import annotations import importlib.util import pathlib import subprocess import sys +from types import SimpleNamespace + +import pytest LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" @@ -18,28 +26,142 @@ def _load_auth(): return mod -def test_unknown_runtime_is_ignored(): - auth = _load_auth() - messages: list[str] = [] - failures: list[str] = [] +@pytest.fixture +def auth(): + return _load_auth() - results = auth.check_auth(["unknown"], info=messages.append, die=failures.append, env={}) - assert "unknown" not in results - assert failures == [] +def _completed(cmd, returncode=0, stdout="", stderr=""): + return subprocess.CompletedProcess(cmd, returncode, stdout, stderr) -def test_probe_timeout_is_reported(monkeypatch): - auth = _load_auth() +# ---------- 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] = [] - failures: 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 = auth.check_auth(["codex"], info=messages.append, die=failures.append, env={}) + + 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 str(auth.AUTH_PROBE_TIMEOUT) in results["codex"]["detail"] - assert len(failures) == 1 + assert results["agy"]["ok"] is True diff --git a/plugins/ndf/scripts/tests/test_lib_assignment.py b/plugins/ndf/scripts/tests/test_lib_assignment.py index a817073e..23f2b67c 100644 --- a/plugins/ndf/scripts/tests/test_lib_assignment.py +++ b/plugins/ndf/scripts/tests/test_lib_assignment.py @@ -2,70 +2,178 @@ from __future__ import annotations import importlib.util +import sys from pathlib import Path import pytest ASSIGNMENT = Path(__file__).resolve().parents[1] / "lib" / "assignment.py" -EXPECTED = { - "claude": [ - ("codex", ["agy", "kiro"]), - ("agy", ["codex", "kiro"]), - ("kiro", ["codex", "agy"]), - ("claude", ["codex", "kiro"]), - ("codex", ["agy", "kiro"]), - ("agy", ["codex", "kiro"]), - ("kiro", ["codex", "agy"]), - ("claude", ["codex", "agy"]), - ], - "codex": [ - ("codex", ["agy", "kiro"]), - ("agy", ["claude", "kiro"]), - ("kiro", ["claude", "agy"]), - ("claude", ["agy", "kiro"]), - ("codex", ["claude", "kiro"]), - ("agy", ["claude", "kiro"]), - ("kiro", ["claude", "agy"]), - ("claude", ["agy", "kiro"]), - ], - "agy": [ - ("codex", ["claude", "kiro"]), - ("agy", ["codex", "kiro"]), - ("kiro", ["claude", "codex"]), - ("claude", ["codex", "kiro"]), - ("codex", ["claude", "kiro"]), - ("agy", ["claude", "kiro"]), - ("kiro", ["claude", "codex"]), - ("claude", ["codex", "kiro"]), - ], - "kiro": [ - ("codex", ["claude", "agy"]), - ("agy", ["claude", "codex"]), - ("kiro", ["codex", "agy"]), - ("claude", ["codex", "agy"]), - ("codex", ["claude", "agy"]), - ("agy", ["claude", "codex"]), - ("kiro", ["claude", "agy"]), - ("claude", ["codex", "agy"]), - ], -} - - @pytest.fixture(scope="module") def assignment(): spec = importlib.util.spec_from_file_location("ndf_lib_assignment", ASSIGNMENT) mod = importlib.util.module_from_spec(spec) + # `@dataclass` は `sys.modules[cls.__module__]` を見るため、登録してから実行する + sys.modules[spec.name] = mod spec.loader.exec_module(mod) return mod -@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)] +@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", ("gemini", "unknown")) +@pytest.mark.parametrize("pool", ("review_pool", "refactor_pool")) +def test_the_default_pools_reject_a_host_outside_host_runtimes(assignment, pool, host): + """HOST_RUNTIMES に含まれないホストを、どちらの母集合の既定も拒否する。""" + with pytest.raises(assignment.AssignmentError) as excinfo: + getattr(assignment, pool)(host) + + assert "ホストになれないランタイムです" in str(excinfo.value) + + +# ---------- 適用の輪番(#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_with_one_participant_always_returns_that_participant(assignment): + participants = ["codex"] + assert [assignment.impl_assign(r, participants) for r in (1, 2)] == ["codex", "codex"] + + +def test_impl_assign_with_two_participants_rotates_between_them(assignment): + participants = ["codex", "kiro"] + assert [assignment.impl_assign(r, participants) for r in (1, 2)] == ["kiro", "codex"] + + +def test_impl_assign_rejects_a_bad_round(assignment): + with pytest.raises(assignment.AssignmentError): + assignment.impl_assign(0, ["claude", "codex"]) + + +def test_impl_assign_rejects_an_empty_list(assignment): + with pytest.raises(assignment.AssignmentError): + 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): + """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_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 + - assert actual == EXPECTED[host] - assert any(impl == host for impl, _ in actual) - assert any(impl != host for impl, _ in actual) - assert all(len(reviewers) == 2 for _, reviewers in actual) - assert all(impl not in reviewers for impl, reviewers in actual) +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"], []) diff --git a/plugins/ndf/scripts/tests/test_lib_participants.py b/plugins/ndf/scripts/tests/test_lib_participants.py new file mode 100644 index 00000000..0c479c19 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_lib_participants.py @@ -0,0 +1,234 @@ +"""使える者の解決(`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"] + + +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"] + diff --git a/plugins/ndf/scripts/tests/test_lib_resume_args.py b/plugins/ndf/scripts/tests/test_lib_resume_args.py new file mode 100644 index 00000000..2693b655 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_lib_resume_args.py @@ -0,0 +1,150 @@ +"""再開の反映(`statefile.apply_resume_args`)のテスト(#727 / #648)。 + +この関数は出力せず、標準エラーへ出す行の一覧を返す。予約語 `none` の正規化は +呼び出し側が済ませてから渡すので、値はそのまま `!=` で比べる。 +""" +from __future__ import annotations + +import argparse +import importlib.util +import sys +from pathlib import Path + +import pytest + +STATEFILE = Path(__file__).resolve().parents[1] / "lib" / "statefile.py" + + +@pytest.fixture(scope="module") +def statefile(): + spec = importlib.util.spec_from_file_location("ndf_lib_statefile_resume", STATEFILE) + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +@pytest.fixture +def frozen_now(statefile, monkeypatch): + monkeypatch.setattr(statefile, "now", lambda: "2026-09-19T10:00:00") + return "2026-09-19T10:00:00" + + +def _args(**values): + return argparse.Namespace(**values) + + +def test_replace_writes_the_value_and_records_the_change(statefile, frozen_now): + state = {"max_rounds": 12, "resume_changes": []} + spec = [statefile.ResumeField("max_rounds", "max_rounds", "replace")] + + lines = statefile.apply_resume_args(state, _args(max_rounds=20), spec) + + assert lines == ["↻ max_rounds: 12 → 20"] + assert state["max_rounds"] == 20 + assert state["resume_changes"] == [ + {"at": frozen_now, "field": "max_rounds", "from": 12, "to": 20}, + ] + + +def test_notify_returns_a_line_and_leaves_the_state_alone(statefile): + state = {"host": "claude", "resume_changes": []} + spec = [statefile.ResumeField("host", "host", "notify")] + + lines = statefile.apply_resume_args(state, _args(host="codex"), spec) + + assert lines == ["ℹ --host は再開では反映しません(状態: claude / 指定: codex)"] + assert state["host"] == "claude" + assert state["resume_changes"] == [] + + +def test_notify_uses_the_dashed_argument_name(statefile): + state = {"baseline_test": "pytest -q", "resume_changes": []} + spec = [statefile.ResumeField("baseline_test", "baseline_test", "notify")] + + lines = statefile.apply_resume_args(state, _args(baseline_test="make test"), spec) + + assert lines == ["ℹ --baseline-test は再開では反映しません(状態: pytest -q / 指定: make test)"] + + +def test_same_value_yields_no_line_and_no_record(statefile): + state = {"max_rounds": 12, "host": "claude", "resume_changes": []} + spec = [ + statefile.ResumeField("max_rounds", "max_rounds", "replace"), + statefile.ResumeField("host", "host", "notify"), + ] + + lines = statefile.apply_resume_args(state, _args(max_rounds=12, host="claude"), spec) + + assert lines == [] + assert state == {"max_rounds": 12, "host": "claude", "resume_changes": []} + + +def test_unspecified_argument_does_nothing(statefile): + """未指定(`None`)と属性そのものが無い場合の両方で何もしない。""" + state = {"max_rounds": 12, "only": "codex", "resume_changes": []} + spec = [ + statefile.ResumeField("max_rounds", "max_rounds", "replace"), + statefile.ResumeField("only", "only", "replace"), + statefile.ResumeField("host", "host", "notify"), + ] + + lines = statefile.apply_resume_args(state, _args(max_rounds=None, only=None), spec) + + assert lines == [] + assert state == {"max_rounds": 12, "only": "codex", "resume_changes": []} + + +def test_replace_creates_resume_changes_when_missing(statefile, frozen_now): + """この変更の前に始めた実行の状態ファイルにも積める(`resume_changes` が無い)。""" + state = {"only": "codex"} + spec = [statefile.ResumeField("only", "only", "replace")] + + lines = statefile.apply_resume_args(state, _args(only="kiro"), spec) + + assert lines == ["↻ only: codex → kiro"] + assert state["resume_changes"] == [ + {"at": frozen_now, "field": "only", "from": "codex", "to": "kiro"}, + ] + + +def test_replace_compares_values_as_given(statefile, frozen_now): + """`none` の正規化は呼び出し側の責務。正規化済みの `[]` と `None` をそのまま比べる。""" + state = {"verify_commands": ["pytest -q"], "only": "codex", "resume_changes": []} + spec = [ + statefile.ResumeField("verify_commands", "verify_commands", "replace"), + statefile.ResumeField("only", "only", "replace"), + ] + + lines = statefile.apply_resume_args(state, _args(verify_commands=[], only="codex"), spec) + + assert lines == ["↻ verify_commands: ['pytest -q'] → []"] + assert state["verify_commands"] == [] + assert state["only"] == "codex" + + +def test_several_fields_are_handled_in_one_call_in_spec_order(statefile, frozen_now): + state = {"max_rounds": 12, "rotate_after": 8, "host": "claude", "resume_changes": []} + spec = [ + statefile.ResumeField("max_rounds", "max_rounds", "replace"), + statefile.ResumeField("rotate_after", "rotate_after", "replace"), + statefile.ResumeField("host", "host", "notify"), + ] + + lines = statefile.apply_resume_args( + state, _args(max_rounds=20, rotate_after=4, host="codex"), spec, + ) + + assert lines == [ + "↻ max_rounds: 12 → 20", + "↻ rotate_after: 8 → 4", + "ℹ --host は再開では反映しません(状態: claude / 指定: codex)", + ] + assert (state["max_rounds"], state["rotate_after"], state["host"]) == (20, 4, "claude") + assert [c["field"] for c in state["resume_changes"]] == ["max_rounds", "rotate_after"] + + +def test_resume_field_is_a_named_tuple(statefile): + f = statefile.ResumeField("max_rounds", "max_rounds", "replace") + assert (f.arg, f.key, f.mode) == ("max_rounds", "max_rounds", "replace") + assert tuple(f) == ("max_rounds", "max_rounds", "replace") diff --git a/plugins/ndf/scripts/tests/test_lib_write_target_stages.py b/plugins/ndf/scripts/tests/test_lib_write_target_stages.py new file mode 100644 index 00000000..df89e5c1 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_lib_write_target_stages.py @@ -0,0 +1,105 @@ +"""`wt_extract_write_target` の公開入出力を段の分割の前に固定する(現状固定テスト)。 + +**正しさを主張しない。** 走査を段(前処理・字句化・追跡・抽出)へ分けるとき、公開 +入口の振る舞いが変わっていないことだけを検出するために置く。期待値は分割の前の +実装を実際に動かして採った値である。 + +固定するのは、走査が持つ状態のうち分割で跨ぐもの(現在地の追跡、複合構文の入れ子、 +リダイレクトの解決済みの位置)が結果へ現れる形と、書き込みの 4 形式である。 + +| 固定する入力 | 何を通すか | +| --- | --- | +| 各書き込み形式 | `sed -i` / `>` / `>>` / `tee` / `cp` / `mv` | +| `cd` | 相対パスの起点の移動と、決められない移動先 | +| パイプ・背景実行 | 区画ごとの現在地の巻き戻し | +| 部分シェル | 中の移動を外へ漏らさない隔離 | +| `case` | 枝ごとに入口の位置へ戻す | +| 関数定義 | 本体の移動を外へ漏らさず、呼び出しの後は決めない | +| 複合構文・リダイレクト | `if` の中の移動、命令名より前のリダイレクト、記述子の複製 | +""" +from __future__ import annotations + +import os +import pathlib +import subprocess + +import pytest + +LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" / "worktree-common.sh" + +# (名前, コマンド, 起点, 期待する書き込み先, 期待する終了コード) +# 起点が空文字のときは第 2 引数を渡さない呼び方(出力は字面のまま)。 +CASES = [ + # --- 書き込みの 4 形式 --- + ("sed_inplace", "sed -i 's/a/b/' docs/a.md", "", ["docs/a.md"], 0), + ("redirect", "echo hi > docs/a.md", "", ["docs/a.md"], 0), + ("append", "echo hi >> docs/a.md", "", ["docs/a.md"], 0), + ("tee", "echo hi | tee docs/a.md docs/b.md", "", ["docs/a.md", "docs/b.md"], 0), + ("cp", "cp src.md docs/a.md", "", ["docs/a.md"], 0), + ("mv", "mv -f src.md docs/a.md", "", ["docs/a.md"], 0), + ("cp_target_dir", "cp -t docs/ a.md b.md", "", ["docs/"], 0), + ("read_only", "cat docs/a.md", "", [], 1), + ("empty", "", "", [], 1), + ("fd_dup", "make build 2>&1", "", [], 1), + ("heredoc", "cat > report.md < y\nEOS", "", ["report.md"], 0), + ("sed_after_redirect", "sed -i 's/a/b/' x.md >log y.md", "", + ["x.md", "y.md", "log"], 0), + # --- 現在地の追跡 --- + ("cd_then_write", "cd .worktrees/x\nsed -i 's/a/b/' README.md", "/base", + ["/base/.worktrees/x/README.md"], 0), + ("unresolvable_cd", 'cd "$TARGET"\nsed -i \'s/a/b/\' README.md', "/base", [], 1), + ("redirect_before_command", ">/dev/null cd .worktrees/x\necho hi > README.md", + "/base", ["/base/.worktrees/x/README.md"], 0), + ("cd_or_exit", "cd .worktrees/x || exit 1\necho hi > README.md", "/base", + ["/base/.worktrees/x/README.md"], 0), + ("cd_or_group_exit", "cd .worktrees/x || { echo ng; exit 1; }\necho hi > README.md", + "/base", ["/base/.worktrees/x/README.md"], 0), + # --- 部分シェルになる区画(パイプ・背景実行・`( )`) --- + ("cd_in_pipe", "cd .worktrees/x | true\necho hi > README.md", "/base", + ["/base/README.md"], 0), + ("pipe_segment_cd", "cd .worktrees/x && echo hi | tee README.md", "/base", + ["/base/.worktrees/x/README.md"], 0), + ("background_job", "cd .worktrees/x & echo hi > README.md", "/base", + ["/base/README.md"], 0), + ("subshell_cd", "( cd .worktrees/x; echo hi > in.md )\necho hi > out.md", "/base", + ["/base/.worktrees/x/in.md", "/base/out.md"], 0), + # --- 複合構文 --- + ("case_branches", + "case $1 in\n a) cd .worktrees/x; echo hi > a.md ;;\n b) echo hi > b.md ;;\nesac", + "/base", ["/base/.worktrees/x/a.md", "/base/b.md"], 0), + ("if_block_cd", "if true; then cd .worktrees/x; fi\necho hi > README.md", + "/base", [], 1), + # --- 関数定義 --- + ("function_def", + "f() {\n cd .worktrees/x\n echo hi > inner.md\n}\necho hi > outer.md", + "/base", ["/base/.worktrees/x/inner.md", "/base/outer.md"], 0), + ("function_call_after_move", "f() { cd .worktrees/x; }\nf\necho hi > after.md", + "/base", [], 1), +] + + +def _extract(command: str, base: str) -> tuple[list[str], int]: + """公開入口だけを通す。改行を含むコマンドはヒアドキュメントで渡す。""" + call = 'wt_extract_write_target "$cmd"' if not base \ + else f'wt_extract_write_target "$cmd" "{base}"' + script = ( + f'set -uo pipefail\n. "{LIB}"\n' + "cmd=$(cat <<'WT_EOF'\n" + command + "\nWT_EOF\n)\n" + f"{call}; echo rc=$?\n" + ) + env = {**os.environ, "LC_ALL": "C"} + done = subprocess.run(["bash", "-c", script], capture_output=True, text=True, + env=env, timeout=120) + lines = [line for line in done.stdout.splitlines() if line] + rc = int(lines.pop().removeprefix("rc=")) + return lines, rc + + +@pytest.mark.parametrize( + ("command", "base", "targets", "rc"), + [pytest.param(*case[1:], id=case[0]) for case in CASES], +) +def test_the_public_entry_point_keeps_its_output( + command: str, base: str, targets: list[str], rc: int, +) -> None: + assert _extract(command, base) == (targets, rc) diff --git a/plugins/ndf/scripts/tests/test_limits.py b/plugins/ndf/scripts/tests/test_limits.py index 6b89b410..ca3a76da 100644 --- a/plugins/ndf/scripts/tests/test_limits.py +++ b/plugins/ndf/scripts/tests/test_limits.py @@ -26,26 +26,19 @@ @pytest.fixture() -def limits(monkeypatch): - # **表の既定値を読むテストである。** 実行した人の環境の `MONITOR_*` を外す(#678)。 - for key in [k for k in os.environ if k.startswith("MONITOR_")]: - monkeypatch.delenv(key) +def limits(): + # 表の既定値を読むテストである。実行した人の環境の `MONITOR_*` は、根の + # `conftest.py` が実行中だけ外す(#678)。 spec = importlib.util.spec_from_file_location("ndf_lib_limits", LIMITS) mod = importlib.util.module_from_spec(spec) spec.loader.exec_module(mod) return mod -def _clean_env(**over: str) -> dict[str, str]: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} - env.update(over) - return env - - def _run(*args: str, **env: str) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, str(LIMITS), *args], - env=_clean_env(**env), capture_output=True, text=True, + env={**os.environ, **env}, capture_output=True, text=True, ) diff --git a/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py b/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py index 333173c4..91af3943 100644 --- a/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py +++ b/plugins/ndf/scripts/tests/test_monitor_outcome_unit.py @@ -29,3 +29,157 @@ def test_read_journal_ignores_malformed_and_non_object_rows(tmp_path): ) assert _load_monitor_outcome().read_journal(tmp_path) == [valid] + + +def test_append_journal_preserves_order_and_utf8_content(tmp_path): + mod = _load_monitor_outcome() + outcomes = [ + {"agent": "codex", "status": "OK", "detail": "完了"}, + {"agent": "kiro", "status": "EARLY_ERROR", "detail": "認証が必要"}, + ] + + for outcome in outcomes: + mod.append_journal(tmp_path, outcome) + + assert mod.read_journal(tmp_path) == outcomes + + +# ---------- 理由の語彙と起動し直しの可否(#729 の AC1 / AC10) ---------- + +import pytest # noqa: E402 + +MONITOR_REASONS = ("timeout", "stalled", "early_error", "usage_limit", "cli_timeout", "pidfile_bad") + + +def test_reasons_are_the_nine_words(): + assert _load_monitor_outcome().REASONS == ( + "ok", "timeout", "stalled", "early_error", "missing", "pidfile_bad", + "usage_limit", "cli_timeout", "unparsable", + ) + + +@pytest.mark.parametrize(("status", "reason"), [ + ("OK", "ok"), ("TIMEOUT", "timeout"), ("STALLED", "stalled"), + ("EARLY_ERROR", "early_error"), ("NO_RESULT", "missing"), ("PIDFILE_BAD", "pidfile_bad"), +]) +def test_reason_for_keeps_the_six_status_mappings(status, reason): + assert _load_monitor_outcome().reason_for(status) == reason + + +def test_only_usage_limit_forbids_relaunching_the_same_agent(): + mod = _load_monitor_outcome() + assert mod.NO_RELAUNCH_REASONS == frozenset({"usage_limit"}) + for reason in mod.REASONS: + assert mod.relaunch_same_agent(reason) is (reason != "usage_limit"), reason + + +# ---------- 結末を 1 つの値として読む(#729 の AC8 / AC9 / AC11) ---------- + +STEM = "kiro-review-pr7" + + +def _place(tmp_path, *, monitor=None, result=None): + """監視の結果ファイルと結果ファイルを置く。`monitor` / `result` は書く中身(None は置かない)。""" + if monitor is not None: + (tmp_path / f"{STEM}-monitor.json").write_text(monitor, encoding="utf-8") + if result is not None: + (tmp_path / f"{STEM}-result.json").write_text(result, encoding="utf-8") + + +def _monitor_json(reason, detail="early error (fatal) in err.log: Monthly request limit reached"): + return json.dumps({"agent": "kiro", "stem": STEM, "status": "EARLY_ERROR", + "reason": reason, "detail": detail}) + + +@pytest.mark.parametrize("monitor", [None, "{broken", *[_monitor_json(r) for r in ("ok", "usage_limit")]]) +def test_readable_result_object_wins_whatever_the_monitor_says(tmp_path, monitor, capsys): + mod = _load_monitor_outcome() + _place(tmp_path, monitor=monitor, result='{"event": "APPROVE"}') + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload == {"event": "APPROVE"} + assert got.reason is None + assert got.relaunch_same_agent is True + if monitor and monitor != "{broken": + assert got.monitor == json.loads(monitor) + assert got.detail == json.loads(monitor)["detail"] + else: + assert got.monitor is None + assert got.detail == "" + assert capsys.readouterr() == ("", "") + + +@pytest.mark.parametrize("reason", MONITOR_REASONS) +@pytest.mark.parametrize("result", [None, "", "[1, 2]", "{broken"]) +def test_monitor_reason_is_taken_when_the_monitor_knows_why(tmp_path, reason, result): + mod = _load_monitor_outcome() + _place(tmp_path, monitor=_monitor_json(reason), result=result) + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload is None + assert got.reason == reason + assert got.relaunch_same_agent is (reason != "usage_limit") + assert got.monitor["reason"] == reason + assert got.detail == "early error (fatal) in err.log: Monthly request limit reached" + + +@pytest.mark.parametrize("monitor", [None, "{broken", '"scalar"', _monitor_json("ok"), _monitor_json("missing")]) +@pytest.mark.parametrize(("result", "reason"), [ + (None, "missing"), ("", "missing"), (" \n", "missing"), + ("[1, 2]", "unparsable"), ("{broken", "unparsable"), ('"text"', "unparsable"), +]) +def test_missing_or_unparsable_is_decided_by_the_result_file(tmp_path, monitor, result, reason, capsys): + mod = _load_monitor_outcome() + _place(tmp_path, monitor=monitor, result=result) + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload is None + assert got.reason == reason + assert got.relaunch_same_agent is True + if monitor in (None, "{broken", '"scalar"'): + assert got.monitor is None + # 監視の詳細が無いときは、読めなかった理由を 1 文で持つ + assert got.detail and got.detail.strip() == got.detail + else: + assert got.monitor == json.loads(monitor) + assert got.detail == json.loads(monitor)["detail"] + assert capsys.readouterr() == ("", "") + + +def test_detail_without_monitor_explains_why_the_result_is_unusable(tmp_path): + mod = _load_monitor_outcome() + (tmp_path / f"{STEM}-result.json").write_text("[1]", encoding="utf-8") + assert mod.read_launch_outcome(tmp_path, STEM).detail != mod.read_launch_outcome( + tmp_path, "other-stem").detail + + +def test_result_path_overrides_the_default_location(tmp_path): + mod = _load_monitor_outcome() + other = tmp_path / "elsewhere.json" + other.write_text('{"verdict": "ok"}', encoding="utf-8") + _place(tmp_path, result="[1]") + + got = mod.read_launch_outcome(tmp_path, STEM, result_path=other) + + assert got.payload == {"verdict": "ok"} + + +def test_result_path_that_is_a_directory_does_not_raise(tmp_path, capsys): + mod = _load_monitor_outcome() + (tmp_path / f"{STEM}-result.json").mkdir() + + got = mod.read_launch_outcome(tmp_path, STEM) + + assert got.payload is None + assert got.reason == "missing" + assert capsys.readouterr() == ("", "") + + +def test_launch_outcome_is_frozen(tmp_path): + mod = _load_monitor_outcome() + got = mod.read_launch_outcome(tmp_path, STEM) + with pytest.raises(Exception): + got.reason = "ok" diff --git a/plugins/ndf/scripts/tests/test_post_queue.py b/plugins/ndf/scripts/tests/test_post_queue.py index cb928c2e..5c1e8b08 100644 --- a/plugins/ndf/scripts/tests/test_post_queue.py +++ b/plugins/ndf/scripts/tests/test_post_queue.py @@ -128,6 +128,29 @@ def test_flush_stops_at_corrupt_json_and_keeps_following_items( ] +def test_drop_removes_only_the_item_with_the_requested_sequence( + tmp_path: pathlib.Path, +) -> None: + """現状固定。指定した連番の項目だけを取り除く。""" + paths = [_write_item(tmp_path, seq) for seq in range(1, 4)] + queue = post_queue.Queue(tmp_path) + + assert queue.drop(2) is True + assert [path.name for path in queue.paths()] == [paths[0].name, paths[2].name] + + +@pytest.mark.parametrize("seq", [None, 99]) +def test_drop_keeps_items_when_the_sequence_does_not_match( + tmp_path: pathlib.Path, seq: int | None +) -> None: + """現状固定。連番が無い場合は何も取り除かない。""" + paths = [_write_item(tmp_path, item_seq) for item_seq in range(1, 3)] + queue = post_queue.Queue(tmp_path) + + assert queue.drop(seq) is False + assert [path.name for path in queue.paths()] == [path.name for path in paths] + + def test_post_succeeds_directly_when_queue_is_empty( tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch ) -> None: @@ -267,3 +290,206 @@ def fake_send(item): assert len(sent_items) == 1 assert sent_items[0]["seq"] == 1 + + +# ---------------- 拒まれ方の区別(#730) ---------------- + +# 実測した応答(2026-09-22、Pull Request #794)。要求ごとに全件が拒まれ、 +# `errors` は語をつないだ 1 つの文字列で、どの項目かは指さない。 +_UNRESOLVED_LINE = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["Line could not be resolved"], + "status": "422", +}) +_UNRESOLVED_MANY = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["Line could not be resolved, Path could not be resolved," + " and Line could not be resolved"], + "status": "422", +}) +_BAD_EVENT = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["Variable $event of type PullRequestReviewEvent" + " was provided invalid value"], + "status": "422", +}) +_BAD_COMMIT = json.dumps({ + "message": "Unprocessable Entity", + "errors": ["The commitOID is not part of the pull request"], + "status": "422", +}) +_STDERR_422 = "gh: Unprocessable Entity (HTTP 422)\n" + + +def _attempt(stdout: str, stderr: str = _STDERR_422) -> Any: + return post_queue.Attempt(1, stdout, stderr) + + +@pytest.mark.parametrize("stdout", [_UNRESOLVED_LINE, _UNRESOLVED_MANY]) +def test_a_rejection_that_cannot_resolve_the_position_is_told_apart(stdout: str) -> None: + """行やファイルを解決できない拒まれ方だけを、退避の契機として見分ける。""" + assert post_queue.is_position_unresolved(_attempt(stdout)) is True + + +@pytest.mark.parametrize("stdout", [_BAD_EVENT, _BAD_COMMIT]) +def test_another_rejection_of_the_same_status_is_not_a_reason_to_move(stdout: str) -> None: + """判定の値の誤りと基準のコミットの誤りは、退避せず失敗として残す。""" + assert post_queue.is_position_unresolved(_attempt(stdout)) is False + + +def test_a_rejection_of_another_status_is_not_a_reason_to_move() -> None: + assert post_queue.is_position_unresolved( + post_queue.Attempt(1, '{"message":"Not Found"}', "gh: Not Found (HTTP 404)") + ) is False + + +def test_a_success_is_not_a_rejection() -> None: + assert post_queue.is_position_unresolved(post_queue.Attempt(0, "{}", "")) is False + + +@pytest.mark.parametrize("stdout", [_UNRESOLVED_LINE, _BAD_EVENT]) +def test_the_words_of_the_rejection_are_readable(stdout: str) -> None: + """応答の `errors` が文字列の列でも、失敗の説明に語が残る。""" + assert "could not be resolved" in _attempt(_UNRESOLVED_LINE).message + assert _attempt(stdout).message != "" + + +def test_a_rejection_that_cannot_resolve_the_position_is_not_a_rate_limit() -> None: + assert post_queue.is_rate_limited(_attempt(_UNRESOLVED_LINE)) is False + + +@pytest.mark.parametrize("item, expected", [ + ({"last_status": 422, "last_error": "Line could not be resolved"}, True), + ({"last_status": 422, "last_error": "Invalid event"}, False), + ({"last_status": 404, "last_error": "Line could not be resolved"}, False), + ({"last_status": None, "last_error": "Line could not be resolved"}, False), +]) +def test_a_queued_item_is_told_apart_by_its_status_and_words( + item: dict[str, Any], expected: bool) -> None: + """流した後に残った項目も、422 と位置の語がそろうときだけ位置の拒否と見る。""" + assert post_queue.rejected_by_position(item) is expected + + +# ---------------- 上限のときに待って再実行する(R1-005) ---------------- + +_OK = post_queue.Attempt(0, '{"id": 1}', "") +_RATE = post_queue.Attempt(1, "", "API rate limit exceeded (HTTP 429)") +_NORMAL_FAIL = post_queue.Attempt(1, "", "permission denied (HTTP 403)") + + +def _run_returning(responses: list[Any], calls: list[list[str]]): + """`run` の代わりに、応答列を順に返す疑似実装。呼ばれた cmd を記録する。""" + queue = list(responses) + + def fake_run(cmd, stdin=None): + calls.append(cmd) + return queue.pop(0) + + return fake_run + + +def _recording_sleep(waits: list[float]): + """時間を進めず、待った秒数だけ記録する疑似 sleep。""" + + def sleep(seconds): + waits.append(seconds) + + return sleep + + +def test_retry_returns_immediately_on_success( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。最初の実行が成功したら、待たずにその結果を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + monkeypatch.setattr(post_queue, "run", _run_returning([_OK], calls)) + monkeypatch.setattr(post_queue, "quota_remaining", lambda: 0) + + result = post_queue.retry(["gh", "pr", "create"], sleep=_recording_sleep(waits)) + + assert result is _OK + assert calls == [["gh", "pr", "create"]] + assert waits == [] + + +def test_retry_returns_immediately_on_a_normal_failure( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。上限でない失敗は、待たずにそのまま返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + monkeypatch.setattr(post_queue, "run", _run_returning([_NORMAL_FAIL], calls)) + monkeypatch.setattr(post_queue, "quota_remaining", lambda: 100) + + result = post_queue.retry(["gh", "pr", "create"], sleep=_recording_sleep(waits)) + + assert result is _NORMAL_FAIL + assert calls == [["gh", "pr", "create"]] + assert waits == [] + + +def test_retry_waits_and_re_runs_until_it_succeeds( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。上限のあいだ待って再実行し、成功したらその結果を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + monkeypatch.setattr( + post_queue, "run", _run_returning([_RATE, _RATE, _OK], calls) + ) + + result = post_queue.retry( + ["gh", "pr", "create"], + max_wait=900.0, + interval=30.0, + sleep=_recording_sleep(waits), + ) + + assert result is _OK + assert len(calls) == 3 + assert waits == [30.0, 30.0] + assert sum(waits) <= 900.0 + + +def test_retry_returns_the_last_rate_limited_attempt_when_the_wait_cap_is_reached( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。待機の上限に達したら、最後の上限応答を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + last_rate = post_queue.Attempt(1, "", "API rate limit exceeded (HTTP 429)") + responses = [_RATE, _RATE, _RATE, last_rate] + monkeypatch.setattr(post_queue, "run", _run_returning(responses, calls)) + + result = post_queue.retry( + ["gh", "pr", "create"], + max_wait=90.0, + interval=30.0, + sleep=_recording_sleep(waits), + ) + + assert result is last_rate + assert len(calls) == 4 + assert waits == [30.0, 30.0, 30.0] + assert sum(waits) <= 90.0 + + +def test_retry_returns_the_first_rate_limited_attempt_when_interval_is_zero( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """現状固定。待機間隔が 0 なら、待機も再実行もせず最初の応答を返す。""" + calls: list[list[str]] = [] + waits: list[float] = [] + first_rate = post_queue.Attempt(1, "", "API rate limit exceeded (HTTP 429)") + monkeypatch.setattr(post_queue, "run", _run_returning([first_rate], calls)) + + result = post_queue.retry( + ["gh", "pr", "create"], + interval=0, + sleep=_recording_sleep(waits), + ) + + assert result is first_rate + assert calls == [["gh", "pr", "create"]] + assert waits == [] diff --git a/plugins/ndf/scripts/tests/test_refresh.py b/plugins/ndf/scripts/tests/test_refresh.py new file mode 100644 index 00000000..72363c43 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_refresh.py @@ -0,0 +1,121 @@ +"""`lib/refresh.py` の `compare` の現状固定テスト(cross-refactoring R2-001)。 + +`compare` は取得失敗・前回記録なし・一致・不一致の 4 分岐を持つ純関数だが、 +instructions-check のテストは `refresh.fetch` をスタブへ差し替えるため本体を通らない。 +ここでは **現状の戻り値をそのまま正解として記録する**。正しさの主張ではない。 +""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys + + +LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" + + +def _load_refresh(): + # `from __future__ import annotations` 下の dataclass は `sys.modules` から + # 自モジュールを引くため、登録してから実行する。 + spec = importlib.util.spec_from_file_location("ndf_lib_refresh_unit", LIB / "refresh.py") + mod = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = mod + spec.loader.exec_module(mod) + return mod + + +def test_compare_returns_undecidable_when_fetch_failed(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=False, error="HTTP 404") + assert refresh.compare(result, "sha256:aa") == "判定できない" + + +def test_compare_reports_no_previous_record_when_previous_is_none(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, None) == "前回の記録が無い" + + +def test_compare_reports_no_previous_record_when_previous_is_empty(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, "") == "前回の記録が無い" + + +def test_compare_reports_unchanged_when_fingerprint_matches(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, "sha256:aa") == "変わっていない" + + +def test_compare_reports_changed_when_fingerprint_differs(): + refresh = _load_refresh() + result = refresh.FetchResult(url="https://example.test/x", ok=True, fingerprint="sha256:aa") + assert refresh.compare(result, "sha256:bb") == "変わった" + + +class _FakeResponse: + """`opener` が返す応答の疑似実装。1 度本文を返し、次は空で終わる。""" + + def __init__(self, body: bytes): + self._body = body + self._done = False + + def read(self, _size: int) -> bytes: + if self._done: + return b"" + self._done = True + return self._body + + def close(self) -> None: + pass + + +def _opener_success_and_failure(failing_urls: set[str]): + """URL ごとに成功/失敗を出し分ける opener を作る。 + + 失敗させたい URL では例外を投げ、それ以外は本文を返す。`fetch` は失敗を + 例外にせず理由へ畳むため、この差し替えで成功と失敗を混ぜられる。 + """ + + def opener(url, timeout=None): # noqa: ARG001 - timeout は使わない + if url in failing_urls: + raise OSError("接続できない") + return _FakeResponse(url.encode("utf-8")) + + return opener + + +def test_refresh_returns_one_row_per_source_and_counts_failures(): + refresh = _load_refresh() + sources = [ + {"name": "alpha", "url": "https://example.test/a", "checked_at": "2026-01-01", "claim": "A の主張"}, + {"name": "beta", "url": "https://example.test/b", "checked_at": "2026-01-02", "claim": "B の主張"}, + {"name": "gamma", "url": "https://example.test/c", "checked_at": "2026-01-03", "claim": "C の主張"}, + ] + opener = _opener_success_and_failure({"https://example.test/b"}) + + lines, failed = refresh.refresh(sources, timeout=1.0, opener=opener) + + # 返る行数は source 数と一致する(取れなかった URL も黙って落とさない)。 + assert len(lines) == len(sources) + # 失敗件数は失敗した source の数と一致する。 + assert failed == 1 + + +def test_refresh_row_carries_name_and_fetch_state(): + refresh = _load_refresh() + sources = [ + {"name": "alpha", "url": "https://example.test/a", "checked_at": "2026-01-01", "claim": "A の主張"}, + {"name": "beta", "url": "https://example.test/b", "checked_at": "2026-01-02", "claim": "B の主張"}, + ] + opener = _opener_success_and_failure({"https://example.test/b"}) + + lines, _failed = refresh.refresh(sources, timeout=1.0, opener=opener) + + # 各行に source の name が含まれる。 + assert "alpha" in lines[0] + assert "beta" in lines[1] + # 取得の成否が行へ現れる(成功/失敗をそのまま固定する)。 + assert "取得できた" in lines[0] + assert "取得できなかった" in lines[1] diff --git a/plugins/ndf/scripts/tests/test_result_posts.py b/plugins/ndf/scripts/tests/test_result_posts.py new file mode 100644 index 00000000..117e16f6 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_result_posts.py @@ -0,0 +1,749 @@ +"""結果ファイルを投稿へ変える層(#730 #583)。 + +担当が書いた指摘の控えと結果ファイルを読み、GitHub へ送る投稿を組み立てる。 +**本文は引数にも標準出力にも出さない。** 受け取るのはファイルのパスだけで、本文は +この層の中だけを通る。 + +| 何を確かめるか | 受け入れ条件 | +| --- | --- | +| 控えと結果からレビューの投稿が組み立つ | AC5 | +| 本文が引数に現れない | AC5 | +| 自分の Pull Request では送った形だけを落とす | AC32 | +| 位置を解決できない拒まれ方で、インラインを総評へ退避する | AC16 | +| 同じ状態の別の拒まれ方は退避せず失敗として残す | AC16 | +| インラインが 0 件でも結果なしにしない | AC17 | +| 返信・決着・まとめが積まれる | AC7 | +| 送信は現在の頭を指定し、載ったことを確かめる | AC8・AC9 | +""" +from __future__ import annotations + +import inspect +import json +import os +import pathlib +import subprocess +import sys + +import pytest + +LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" +if str(LIB) not in sys.path: + sys.path.insert(0, str(LIB)) + +import post_queue # noqa: E402 +import result_posts # noqa: E402 + +REPO = "o/r" +PR = 730 +ROUND = 3 +SEAT = "codex" +SHA = "1" * 40 +ACTOR = "takemi" + + +# ---------------- 偽の `gh` ---------------- + +_FAKE_GH = '''#!/usr/bin/env python3 +import json, os, sys + +argv = sys.argv[1:] +joined = " ".join(argv) +stdin = "" if sys.stdin.isatty() else sys.stdin.read() +log = os.environ["GH_FAKE_LOG"] +with open(log, "a", encoding="utf-8") as f: + f.write(json.dumps({"argv": argv, "stdin": stdin}, ensure_ascii=False) + "\\n") +rules_file = os.environ.get("GH_FAKE_RULES") +rules = json.load(open(rules_file, encoding="utf-8")) if rules_file else [] +prior = 0 +with open(log, encoding="utf-8") as f: + prior = sum(1 for line in f if line.strip()) - 1 +for rule in rules: + if "calls_lt" in rule and prior >= int(rule["calls_lt"]): + continue + if rule.get("match", "") in joined: + sys.stdout.write(rule.get("stdout", "")) + sys.stderr.write(rule.get("stderr", "")) + sys.exit(int(rule.get("exit", 0))) +sys.stdout.write("[]") +''' + + +class FakeGh: + def __init__(self, log: pathlib.Path, rules: pathlib.Path, monkeypatch) -> None: + self.log, self.rules, self._mp = log, rules, monkeypatch + + def set_rules(self, rules: list[dict]) -> None: + self.rules.write_text(json.dumps(rules), encoding="utf-8") + self._mp.setenv("GH_FAKE_RULES", str(self.rules)) + + def calls(self) -> list[dict]: + if not self.log.exists(): + return [] + return [json.loads(line) for line in + self.log.read_text(encoding="utf-8").splitlines() if line.strip()] + + def joined(self) -> list[str]: + return [" ".join(c["argv"]) for c in self.calls()] + + def sent(self) -> list[dict]: + """状態を変える呼び出しの本文だけを取り出す。""" + out = [] + for c in self.calls(): + if "--method POST" in " ".join(c["argv"]) and c["stdin"]: + out.append(json.loads(c["stdin"])) + return out + + +@pytest.fixture() +def fake_gh(monkeypatch, tmp_path) -> FakeGh: + bindir = tmp_path / "fake-bin" + bindir.mkdir(exist_ok=True) + script = bindir / "gh" + script.write_text(_FAKE_GH, encoding="utf-8") + script.chmod(0o755) + monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ.get('PATH', '')}") + monkeypatch.setenv("GH_FAKE_LOG", str(tmp_path / "gh-calls.log")) + monkeypatch.delenv("GH_FAKE_RULES", raising=False) + return FakeGh(tmp_path / "gh-calls.log", tmp_path / "gh-rules.json", monkeypatch) + + +# ---------------- 控えと結果ファイル ---------------- + +def _files(tmp_path: pathlib.Path, comments: list[dict] | None = None, + summary: str = "設計の筋は通っている。", event: str = "REQUEST_CHANGES", + ) -> tuple[pathlib.Path, pathlib.Path]: + payload = tmp_path / f"{SEAT}-review-pr{PR}-round{ROUND}-payload.json" + result = tmp_path / f"{SEAT}-review-pr{PR}-result.json" + if comments is None: + comments = [ + {"path": "a.py", "line": 12, "body": "[major / 正確性] 戻り値を確かめる", + "severity": "major"}, + {"path": "b.py", "line": 34, "body": "[minor / 可読性] 名前を揃える", + "severity": "minor"}, + ] + payload.write_text(json.dumps({"summary": summary, "comments": comments}, + ensure_ascii=False), encoding="utf-8") + result.write_text(json.dumps( + {"event": event, "by_severity": {"critical": 0, "major": 1, "minor": 1, "nit": 0}}, + ensure_ascii=False), encoding="utf-8") + return payload, result + + +def _review_items(tmp_path, **kw): + payload, result = _files(tmp_path, **kw) + return result_posts.review_posts( + payload, result, repo=REPO, pr=PR, round_no=ROUND, seat=SEAT, + head_sha=SHA, is_own_pr=False) + + +# ---------------- 組み立て ---------------- + +def test_a_review_is_built_from_the_note_and_the_result(tmp_path) -> None: + items = _review_items(tmp_path) + + assert [i["kind"] for i in items] == ["review-post"] + fields = items[0]["fields"] + assert fields["event"] == "REQUEST_CHANGES" + assert fields["commit_id"] == SHA + assert [c["path"] for c in fields["comments"]] == ["a.py", "b.py"] + + +def test_the_first_line_carries_the_round_and_the_seat(tmp_path) -> None: + body = _review_items(tmp_path)[0]["fields"]["body"] + + assert body.splitlines()[0] == \ + f"## 🤖 cross-review | round {ROUND} | {SEAT} | REQUEST_CHANGES" + assert post_queue.review_match_key(body) == \ + f"## 🤖 cross-review | round {ROUND} | {SEAT}|" + + +def test_the_body_is_never_an_argument() -> None: + """本文は引数として渡さない。どの関数もファイルのパスを受け取る。""" + for fn in (result_posts.review_posts, result_posts.fix_posts): + names = list(inspect.signature(fn).parameters) + assert "body" not in names and "payload" not in names + assert "payload_path" in inspect.signature(result_posts.review_posts).parameters + + +def test_only_what_is_sent_is_downgraded_on_ones_own_pull_request(tmp_path) -> None: + """自分の Pull Request では送った形だけを落とし、本来の判定は落とさない(AC32)。""" + payload, result = _files(tmp_path) + items = result_posts.review_posts( + payload, result, repo=REPO, pr=PR, round_no=ROUND, seat=SEAT, + head_sha=SHA, is_own_pr=True) + + fields = items[0]["fields"] + assert fields["event"] == "COMMENT" + assert fields["body"].splitlines()[0].endswith("| REQUEST_CHANGES") + assert items[0]["extra"]["intent"] == "REQUEST_CHANGES" + assert items[0]["extra"]["posted_as"] == "COMMENT" + + +def test_a_finding_without_a_position_goes_to_the_summary(tmp_path) -> None: + """位置を持たない指摘は、指す先が無いので総評へ入れる。""" + items = _review_items(tmp_path, comments=[ + {"body": "[major / 設計] 層の分け方を見直す", "severity": "major"}, + {"path": "a.py", "line": 12, "body": "[minor / 可読性] 名前を揃える", + "severity": "minor"}, + ]) + + fields = items[0]["fields"] + assert [c["path"] for c in fields["comments"]] == ["a.py"] + assert "層の分け方を見直す" in fields["body"] + assert items[0]["extra"]["inline"] == 1 + assert items[0]["extra"]["body"] == 1 + + +@pytest.mark.parametrize("line", ["L42", "40-45", "", " ", True, 1.5, [12]]) +def test_a_finding_whose_line_is_not_an_integer_goes_to_the_summary( + tmp_path, line) -> None: + """行が整数にならない指摘は、例外で落とさず総評へ入れる(外部入力のため)。""" + items = _review_items(tmp_path, comments=[ + {"path": "a.py", "line": line, "body": "[major / 正確性] 行が壊れている", + "severity": "major"}, + {"path": "b.py", "line": "34", "body": "[minor / 可読性] 名前を揃える", + "severity": "minor"}, + ]) + + fields = items[0]["fields"] + assert fields["comments"] == [ + {"path": "b.py", "line": 34, "side": "RIGHT", + "body": "[minor / 可読性] 名前を揃える"}] + assert "行が壊れている" in fields["body"] + assert items[0]["extra"]["inline"] == 1 + assert items[0]["extra"]["body"] == 1 + + +# ---------------- 送信と退避 ---------------- + +def _queue(tmp_path) -> post_queue.Queue: + return post_queue.Queue(tmp_path / "pending") + + +def _post_review(tmp_path, **kw): + payload, result = _files(tmp_path, **kw) + return result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR), payload + + +_REJECT_POSITION = { + "match": "pulls/730/reviews", "exit": 1, + "stdout": json.dumps({"message": "Unprocessable Entity", + "errors": ["Line could not be resolved"], "status": "422"}), + "stderr": "gh: Unprocessable Entity (HTTP 422)\n", +} +_REJECT_EVENT = { + "match": "pulls/730/reviews", "exit": 1, + "stdout": json.dumps({"message": "Unprocessable Entity", + "errors": ["Variable $event of type PullRequestReviewEvent" + " was provided invalid value"], "status": "422"}), + "stderr": "gh: Unprocessable Entity (HTTP 422)\n", +} +_ACCEPT = {"match": "pulls/730/reviews", "stdout": json.dumps( + {"id": 99, "html_url": "https://x/pull/730#pullrequestreview-99"})} +_RATE_LIMITED = { + "match": "pulls/730/reviews", "exit": 1, + "stdout": json.dumps({"message": "API rate limit exceeded"}), + "stderr": "gh: API rate limit exceeded (HTTP 429)\n", +} + + +def test_the_inlines_move_to_the_summary_when_the_position_is_not_resolved( + tmp_path, fake_gh) -> None: + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + dict(_REJECT_POSITION, calls_lt=3), + _ACCEPT, + ]) + + outcome, payload = _post_review(tmp_path) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-99" + assert outcome.posted_inline == 0 + assert outcome.posted_body == 2 + assert outcome.queued == 0 + # 2 度目の要求はインラインを持たず、指摘は総評に入る。 + last = fake_gh.sent()[-1] + assert "comments" not in last + assert "戻り値を確かめる" in last["body"] + # 控えには送れた先が残る。 + note = json.loads(payload.read_text(encoding="utf-8")) + assert [c["posted_to"] for c in note["comments"]] == ["body", "body"] + + +def test_another_rejection_of_the_same_status_is_not_moved(tmp_path, fake_gh) -> None: + """判定の値の誤りは退避の契機にしない。失敗として残す。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _REJECT_EVENT, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.failed is True + assert outcome.review_url is None + + +def test_a_position_rejection_of_an_earlier_item_is_not_taken_as_ours( + tmp_path, fake_gh) -> None: + """先に積まれた項目の位置エラーで、今回の分を退避しない。 + + 先客を消して今回分を二重に積むと、未投稿のまま控えへ送れた先を書き、取り込みを + 成功扱いにしてしまう。今回分が送れていない限り、失敗として残す。 + """ + earlier = post_queue.enqueue( + _queue(tmp_path), "review-post", REPO, PR, + {"body": f"## 🤖 cross-review | round {ROUND} | agy | COMMENT\n", + "event": "COMMENT", + "comments": [{"path": "c.py", "line": 9, "side": "RIGHT", "body": "先客"}]}, + actor=ACTOR, extra={"ident": f"agy-r{ROUND}"}) + earlier_seq = post_queue.read_item(earlier)["seq"] + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _REJECT_POSITION, + ]) + + outcome, payload = _post_review(tmp_path) + + assert outcome.failed is True + assert outcome.queued == 1 + assert outcome.review_url is None + note = json.loads(payload.read_text(encoding="utf-8")) + assert all("posted_to" not in comment for comment in note["comments"]) + # 先客は残り、今回分は 1 件だけ(退避した写しを足さない)。 + queued = [item for _, item in _queue(tmp_path).items()] + assert [i["seq"] for i in queued][0] == earlier_seq + assert len(queued) == 2 + assert [i["extra"].get("agent") for i in queued] == [None, SEAT] + + +def test_a_failed_write_of_the_note_keeps_it_whole_and_stops_the_take_in( + tmp_path, fake_gh, monkeypatch) -> None: + """控えの書き戻しが途中で落ちても控えは元のまま読め、取り込みは失敗として止まる。 + + 半端な控えを残すと、再実行で読めずに空として扱われ、記録済みの指摘を 0 件で + 置き換える。 + """ + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _ACCEPT, + ]) + payload, result = _files(tmp_path) + original = payload.read_text(encoding="utf-8") + real_write = pathlib.Path.write_text + + def half_write(self, data, *a, **kw): + if "posted_to" in data: + real_write(self, data[: len(data) // 2], *a, **kw) + raise OSError("disk full") + return real_write(self, data, *a, **kw) + + monkeypatch.setattr(pathlib.Path, "write_text", half_write) + outcome = result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR) + + assert outcome.failed is True + assert "控え" in outcome.detail + assert payload.read_text(encoding="utf-8") == original + assert [p.name for p in payload.parent.iterdir() if p.name.endswith(".tmp")] == [] + + +def test_a_rate_limited_review_remains_queued_without_marking_the_note( + tmp_path, fake_gh) -> None: + """現状固定。上限時は失敗にせず、未投稿の要求と控えをそのまま残す。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + _RATE_LIMITED, + ]) + + outcome, payload = _post_review(tmp_path) + + assert outcome.queued == 1 + assert outcome.failed is False + assert outcome.review_url is None + assert outcome.posted_inline == 0 + assert outcome.posted_body == 0 + note = json.loads(payload.read_text(encoding="utf-8")) + assert all("posted_to" not in comment for comment in note["comments"]) + queued = _queue(tmp_path).items() + assert len(queued) == 1 + assert queued[0][1]["kind"] == "review-post" + assert queued[0][1]["attempts"] == 1 + + +def test_a_review_that_is_already_on_github_is_not_posted_again( + tmp_path, fake_gh) -> None: + """投稿の後・記録の前に止まった実行をやり直しても、レビューは増えない(AC12)。""" + head = f"## 🤖 cross-review | round {ROUND} | {SEAT} | APPROVE" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", + "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", + "body": head + "\n\n先客", "id": 42, + "html_url": "https://x/pull/730#pullrequestreview-42"}])}, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-42" + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + + +_STARTED = "2026-09-22T12:00:00+09:00" + + +def _earlier_run_review(submitted_at: str) -> dict: + """同じラウンド番号・同じ席の、先に出ていたレビュー。""" + head = f"## 🤖 cross-review | round {ROUND} | {SEAT} | APPROVE" + return {"match": "pulls/730/reviews?", + "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", + "body": head + "\n\n前の実行", "id": 42, + "submitted_at": submitted_at, + "html_url": "https://x/pull/730#pullrequestreview-42"}])} + + +def test_a_review_of_an_earlier_run_is_not_taken_as_this_one(tmp_path, fake_gh) -> None: + """回し直した実行では、ラウンドの開始より前のレビューを同じ投稿と読まない。 + + ラウンドの番号は実行ごとに 1 から数え直すため、番号と席だけでは実行をまたいで + 一意にならない。 + """ + fake_gh.set_rules([_earlier_run_review("2026-09-22T02:59:59Z"), _ACCEPT]) + + payload, result = _files(tmp_path) + outcome = result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR, since=_STARTED) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-99" + assert len([c for c in fake_gh.joined() if "--method POST" in c]) == 1 + + +def test_a_review_sent_after_the_round_started_is_still_not_sent_again( + tmp_path, fake_gh) -> None: + """同じ実行の中で送った後に止まった分は、開始時刻で絞っても見つかる(AC12)。""" + fake_gh.set_rules([_earlier_run_review("2026-09-22T03:00:05Z")]) + + payload, result = _files(tmp_path) + outcome = result_posts.post_review( + _queue(tmp_path), payload, result, repo=REPO, pr=PR, round_no=ROUND, + seat=SEAT, head_sha=SHA, is_own_pr=False, actor=ACTOR, since=_STARTED) + + assert outcome.review_url == "https://x/pull/730#pullrequestreview-42" + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + + +def test_a_note_with_findings_is_not_a_missing_result(tmp_path, fake_gh) -> None: + """インラインとして送れたものが 0 件でも、その担当は結果なしにならない(AC17)。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + dict(_REJECT_POSITION, calls_lt=3), + _ACCEPT, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.findings == 2 + assert outcome.failed is False + + +def test_post_review_falls_back_to_fragment_url_when_response_has_id_only( + tmp_path, fake_gh) -> None: + """現状固定。応答に html_url がなく id だけのとき、review_url をフラグメントで補う。""" + fake_gh.set_rules([ + {"match": "pulls/730/reviews?", "stdout": "[]"}, + {"match": "pulls/730/reviews", "stdout": json.dumps({"id": 99})}, + ]) + + outcome, _ = _post_review(tmp_path) + + assert outcome.review_url == "#pullrequestreview-99" + assert outcome.posted_inline == 2 + assert outcome.posted_body == 0 + assert outcome.queued == 0 + assert outcome.failed is False + + +# ---------------- 修正の投稿 ---------------- + +def _fix_file(tmp_path, **over) -> pathlib.Path: + data = { + "pr": PR, + "fix_commit": "abc1234", + "ci_status": "SUCCESS", + "fixed_count": 2, + "by_severity": {"critical": 0, "major": 2, "minor": 0, "nit": 0}, + "resolved_threads": [ + {"thread_id": "PRRT_a", "comment_id": 111, "path": "a.py", "line": 12}, + {"thread_id": "PRRT_b", "comment_id": 222, "path": "b.py", "line": 34}, + ], + "deferred": [ + {"comment_id": 333, "thread_id": "PRRT_c", "severity": "nit", + "summary": "末尾の書き方", "reason_for_deferral": "好みの範囲"}, + ], + "rejected": [ + {"comment_id": 444, "path": "c.py", "line": 7, "severity": "minor", + "summary": "引用の形", "reason_for_rejection": "意図して展開している"}, + ], + } + data.update(over) + path = tmp_path / f"fix-pr{PR}-result.json" + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return path + + +def test_the_take_in_of_a_fix_builds_replies_resolves_and_a_summary(tmp_path) -> None: + items = result_posts.fix_posts(_fix_file(tmp_path), repo=REPO, pr=PR, + round_no=ROUND) + + kinds = [i["kind"] for i in items] + assert kinds.count("review-reply") == 4 # 決着 2 + 見送り 1 + 却下 1 + assert kinds.count("thread-resolve") == 2 + assert kinds.count("pr-comment") == 1 + assert kinds[-1] == "pr-comment" # まとめは最後 + + +def test_the_summary_carries_the_round_in_the_head_of_the_body(tmp_path) -> None: + """同じラウンドのまとめを 2 度積んでも増えないよう、鍵になる先頭へ入れる(AC14)。""" + items = result_posts.fix_posts(_fix_file(tmp_path), repo=REPO, pr=PR, + round_no=ROUND) + body = [i for i in items if i["kind"] == "pr-comment"][0]["fields"]["body"] + + assert f"round {ROUND}" in body[:post_queue.BODY_MATCH_CHARS] + assert "abc1234" in body[:post_queue.BODY_MATCH_CHARS] + + +def test_a_reply_points_at_the_comment_it_answers(tmp_path) -> None: + items = result_posts.fix_posts(_fix_file(tmp_path), repo=REPO, pr=PR, + round_no=ROUND) + targets = sorted(i["fields"]["in_reply_to"] for i in items + if i["kind"] == "review-reply") + + assert targets == [111, 222, 333, 444] + + +def test_a_fix_without_threads_still_posts_the_summary(tmp_path) -> None: + items = result_posts.fix_posts( + _fix_file(tmp_path, resolved_threads=[], deferred=[], rejected=[]), + repo=REPO, pr=PR, round_no=ROUND) + + assert [i["kind"] for i in items] == ["pr-comment"] + + +def test_fix_posts_skips_replies_with_non_numeric_comment_ids(tmp_path) -> None: + """現状固定。不正な返信先を飛ばしても、決着とまとめは組み立てる。""" + items = result_posts.fix_posts( + _fix_file( + tmp_path, + resolved_threads=[{"comment_id": "invalid", "thread_id": "PRRT_a"}], + deferred=[{"comment_id": "invalid", "thread_id": "PRRT_b"}], + rejected=[{"comment_id": "invalid", "thread_id": "PRRT_c"}], + ), + repo=REPO, + pr=PR, + round_no=ROUND, + ) + + assert [item["kind"] for item in items] == ["thread-resolve", "pr-comment"] + assert items[0]["fields"] == {"thread_id": "PRRT_a"} + assert items[0]["extra"] == {"ident": "resolve-PRRT_a"} + assert items[1]["extra"] == {"ident": f"fix-summary-{ROUND}"} + assert "決着: 1 件 / 見送り: 1 件 / 却下: 1 件" in items[1]["fields"]["body"] + + +# ---------------- 送信 ---------------- + +def _repo_with_remote(tmp_path) -> tuple[pathlib.Path, pathlib.Path]: + remote = tmp_path / "remote.git" + subprocess.run(["git", "init", "--bare", "-b", "main", str(remote)], + check=True, capture_output=True) + work = tmp_path / "work" + subprocess.run(["git", "clone", str(remote), str(work)], + check=True, capture_output=True) + for key, value in (("user.email", "t@example.com"), ("user.name", "t")): + subprocess.run(["git", "-C", str(work), "config", key, value], check=True) + (work / "a.txt").write_text("1\n", encoding="utf-8") + subprocess.run(["git", "-C", str(work), "add", "-A"], check=True) + subprocess.run(["git", "-C", str(work), "commit", "-m", "1"], + check=True, capture_output=True) + subprocess.run(["git", "-C", str(work), "push", "origin", "main"], + check=True, capture_output=True) + return work, remote + + +def _head(work: pathlib.Path) -> str: + return subprocess.run(["git", "-C", str(work), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True).stdout.strip() + + +def test_the_push_names_the_current_head(tmp_path) -> None: + """切り離された頭でも現在の頭が送られる(AC8)。""" + work, remote = _repo_with_remote(tmp_path) + subprocess.run(["git", "-C", str(work), "checkout", "--detach"], + check=True, capture_output=True) + (work / "a.txt").write_text("2\n", encoding="utf-8") + subprocess.run(["git", "-C", str(work), "commit", "-am", "2"], + check=True, capture_output=True) + commit = _head(work) + + outcome = result_posts.push_fix(work, "main", commit) + + assert outcome.ok is True and outcome.contains is True + remote_head = subprocess.run( + ["git", "-C", str(remote), "rev-parse", "refs/heads/main"], + check=True, capture_output=True, text=True).stdout.strip() + assert remote_head == commit + + +def test_the_push_fails_when_the_reported_commit_is_not_on_the_branch(tmp_path) -> None: + """報告されたコミットが送り先に載っていなければ止まる(AC9)。""" + work, _ = _repo_with_remote(tmp_path) + + outcome = result_posts.push_fix(work, "main", "0" * 40) + + assert outcome.ok is False and outcome.contains is False + + +def test_nothing_is_pushed_when_no_commit_was_made(tmp_path) -> None: + work, _ = _repo_with_remote(tmp_path) + + outcome = result_posts.push_fix(work, "main", None) + + assert outcome.ok is True and outcome.pushed is False + + +def test_the_push_fails_without_a_destination() -> None: + outcome = result_posts.push_fix("", "", "abc1234") + + assert outcome.ok is False and outcome.pushed is False + + +def test_a_rejected_push_stops_before_checking_the_branch(tmp_path) -> None: + """送信そのものが拒まれたら、載ったかを確かめずに理由を残して止まる。""" + work, _ = _repo_with_remote(tmp_path) + subprocess.run(["git", "-C", str(work), "remote", "set-url", "origin", + str(tmp_path / "missing.git")], check=True) + + outcome = result_posts.push_fix(work, "main", _head(work)) + + assert outcome[:3] == (False, False, False) + assert outcome.detail != "" + + +# ---------------- 単独で使う口 ---------------- + +def test_the_standalone_command_pushes_and_posts_with_the_same_layer( + tmp_path, fake_gh, monkeypatch) -> None: + """単独の `fix` も同じ層を使い、1 行のコマンドで送信と投稿を終える(AC21・AC22)。""" + work, remote = _repo_with_remote(tmp_path) + (work / "a.txt").write_text("2\n", encoding="utf-8") + subprocess.run(["git", "-C", str(work), "commit", "-am", "2"], + check=True, capture_output=True) + fix = _fix_file(tmp_path, fix_commit=_head(work)) + fake_gh.set_rules([ + {"match": "issues/730/comments?", "stdout": "[]"}, + {"match": "pulls/730/comments?", "stdout": "[]"}, + {"match": "reviewThreads", "stdout": "PRRT_a\nPRRT_b\n"}, + {"match": "issues/730/comments", "stdout": json.dumps( + {"id": 7, "html_url": "https://x/pull/730#issuecomment-7"})}, + {"match": "", "stdout": "{}"}, + ]) + monkeypatch.delenv("CROSS_REVIEW_TMP_DIR", raising=False) + + r = subprocess.run( + [sys.executable, str(LIB / "result_posts.py"), "fix", "--repo", REPO, + "--pr", str(PR), "--result", str(fix), "--head", "main", + "--worktree", str(work), "--round", str(ROUND), "--actor", ACTOR], + capture_output=True, text=True, env=os.environ.copy()) + + assert r.returncode == 0, r.stderr + assert "PUSHED=1 COMMIT_ON_HEAD=1" in r.stdout + assert "POSTED summary_url=https://x/pull/730#issuecomment-7" in r.stdout + assert "REPLIED=4 RESOLVED=2 QUEUED=0" in r.stdout + # 本文は出さない。 + assert "好みの範囲" not in r.stdout + + +def test_a_deferred_thread_marked_to_resolve_is_resolved(tmp_path) -> None: + """最終スイープは見送りも決着させる。要素の `resolve` が真なら決着を積む。""" + items = result_posts.fix_posts( + _fix_file(tmp_path, resolved_threads=[], rejected=[], deferred=[ + {"comment_id": 333, "thread_id": "PRRT_c", "resolve": True, + "reason_for_deferral": "好みの範囲"}]), + repo=REPO, pr=PR) + + assert [i["kind"] for i in items] == ["review-reply", "thread-resolve", "pr-comment"] + assert items[1]["fields"]["thread_id"] == "PRRT_c" + + +def test_a_deferred_thread_is_not_resolved_by_default(tmp_path) -> None: + items = result_posts.fix_posts( + _fix_file(tmp_path, resolved_threads=[], rejected=[]), repo=REPO, pr=PR) + + assert "thread-resolve" not in [i["kind"] for i in items] + + +def test_the_standalone_command_resolves_repo_and_head_in_the_worktree( + tmp_path, monkeypatch) -> None: + """`--worktree` を渡したら、リポジトリと頭の解決も作業ツリーの中で行う。 + + 呼び出し元の cwd が作業ツリーの外でも、`gh` が別のリポジトリを読まないため。 + """ + work = tmp_path / "work" + work.mkdir() + outside = tmp_path / "outside" + outside.mkdir() + monkeypatch.chdir(outside) + calls: list[tuple[list[str], str | None]] = [] + + def fake_run(cmd, **kw): + calls.append((list(cmd), kw.get("cwd"))) + inside = kw.get("cwd") is not None and \ + pathlib.Path(kw["cwd"]).resolve() == work.resolve() + out = "" + if inside and cmd[:3] == ["gh", "repo", "view"]: + out = REPO + elif inside and cmd[:3] == ["gh", "pr", "view"]: + out = "feat/x" + return subprocess.CompletedProcess(cmd, 0, stdout=out, stderr="") + + monkeypatch.setattr(result_posts.subprocess, "run", fake_run) + fix = _fix_file(tmp_path) + args = result_posts.argparse.Namespace( + repo=None, pr=str(PR), result=str(fix), head=None, worktree=str(work)) + + inputs, error = result_posts._resolve_fix_inputs(args) + + assert error == "" and inputs is not None + assert (inputs.repo, inputs.head) == (REPO, "feat/x") + pr_view = next(c for c, _ in calls if c[:3] == ["gh", "pr", "view"]) + assert pr_view[pr_view.index("-R") + 1] == REPO + + +def test_the_standalone_command_stops_when_the_branch_is_not_known( + tmp_path, monkeypatch, capsys) -> None: + """送り先のブランチを決められないときは、返信へ進まず止める。 + + 送っていない修正へ「対応しました」と返信しないため。 + """ + work = tmp_path / "work" + work.mkdir() + calls: list[list[str]] = [] + + def fake_run(cmd, **kw): + calls.append(list(cmd)) + return subprocess.CompletedProcess(cmd, 1, stdout="", stderr="no pr") + + monkeypatch.setattr(result_posts.subprocess, "run", fake_run) + monkeypatch.delenv("CROSS_REVIEW_TMP_DIR", raising=False) + fix = _fix_file(tmp_path) + args = result_posts.argparse.Namespace( + repo=REPO, pr=str(PR), result=str(fix), head=None, worktree=str(work), + round=ROUND, actor=ACTOR) + + assert result_posts.cmd_fix(args) == 1 + assert "ブランチ" in capsys.readouterr().err + # 返信・決着・まとめは 1 件も呼ばず、待ち行列にも積まない。 + assert [c for c in calls if c[:2] == ["gh", "api"]] == [] + assert not (work / result_posts.TMP_DIRNAME).exists() diff --git a/plugins/ndf/scripts/tests/test_statefile_emit.py b/plugins/ndf/scripts/tests/test_statefile_emit.py index 32e610a4..b260216a 100644 --- a/plugins/ndf/scripts/tests/test_statefile_emit.py +++ b/plugins/ndf/scripts/tests/test_statefile_emit.py @@ -42,3 +42,43 @@ def test_emit_joins_a_list_into_one_space_separated_word(mod, capsys) -> None: mod.emit(VALUES=["a b", "c"]) assert shlex.split(capsys.readouterr().out) == ["VALUES=a b c"] + + +def test_register_after_save_calls_the_same_hook_only_once(mod, tmp_path) -> None: + """現状固定。同じ差し込み口を 2 度登録しても保存後に 1 度だけ呼ぶ。""" + calls = [] + + def hook(path, state): + calls.append((path, state)) + + mod.register_after_save(hook) + mod.register_after_save(hook) + try: + path = tmp_path / "state.json" + state = {"round": 2} + mod.save(path, state) + finally: + mod.unregister_after_save(hook) + + assert calls == [(path, state)] + + +def test_save_writes_state_creates_parent_directory_and_calls_registered_hook(mod, tmp_path) -> None: + """現状固定。未作成の親ディレクトリを作って保存し、登録した差し込み口へ引数を渡す。""" + calls = [] + + def hook(path, state): + calls.append((path, state)) + + mod.register_after_save(hook) + try: + path = tmp_path / "nested" / "parent" / "state.json" + state = {"a": 1} + mod.save(path, state) + loaded = mod.load(path) + finally: + mod.unregister_after_save(hook) + + assert loaded == state + assert calls == [(path, state)] + diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 80c1869a..6bf574fc 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -1,7 +1,7 @@ --- name: cross-refactoring description: "Let several CLIs propose, apply, and review refactorings on a PR until no new proposal appears. Use when structural improvement should converge across runtimes(クロスリファクタリング・多AIリファクタリング・収束リファクタリング)." -argument-hint: "[PR番号] --scope PATH... [--host claude|codex|agy|kiro] [--model RT=MODEL] [--baseline-test CMD] [--max-test-rounds N] [--max-outer-rounds N] [--max-fix-rounds N] [--max-items-per-round N] [--ci-check NAME] [--workflow-step]" +argument-hint: "[PR番号] --scope PATH... [--host claude|codex|agy|kiro] [--exclude NAMES] [--include NAMES] [--require-all] [--model RT=MODEL] [--baseline-test CMD] [--max-test-rounds N] [--max-outer-rounds N] [--max-fix-rounds N] [--max-items-per-round N] [--ci-check NAME] [--workflow-step]" allowed-tools: - Bash - Read @@ -44,24 +44,25 @@ allowed-tools: | 語 | 何の単位か | 上限を決めるもの | | --- | --- | --- | -| テスト整備ラウンド | **足すべきテストを集める。** 3 者が提案し、採否を決める | `--max-test-rounds`(既定 2) | -| 提案ラウンド | **構造改善の提案を集める。** 3 者が提案し、採否を決める | `--max-outer-rounds`(既定 3) | -| 適用ラウンド | **同時に適用して検証する。** 書き換えるファイルが重ならない項目だけを含む。**上の 2 つのラウンドが共有する** | 別に置かない(`--max-items-per-round` が実質の上限) | +| テスト整備ラウンド | **足すべきテストを集める。** 参加者が提案し、採否を決める | `--max-test-rounds`(既定 2) | +| 提案ラウンド | **構造改善の提案を集める。** 参加者が提案し、採否を決める | `--max-outer-rounds`(既定 3) | +| 適用ラウンド | **同時に適用して検証する。** 書き換えるファイルが重ならない項目だけを含む。**上の 2 つのラウンドが共有する** | 同じ群を開き直すのは 2 回まで(引数を持たない固定値)。件数は `--max-items-per-round` が実質の上限 | | 修正ラウンド | **検証の失敗を直す。上の 2 つのラウンドが共有する** | `--max-fix-rounds`(既定 3) | | 改善項目 | 構造改善の提案の 1 件。`<ファイル>#<シンボル>` と兆候で識別する | — | | テスト項目 | テスト整備の提案の 1 件。固定する入口(`target`)と経路の種類(`case`)で識別する | — | **「バッチ」「パッチ」の語は使わない。** 読み手が別の意味で知っている語である。 -**適用ラウンドに別の上限を置かない。** 採用件数の上限が既に群の数を切っている。 -上限を 2 つ置くと、どちらで止まったのかを読み解く必要が出る。 +**同じ群を開き直すのは 2 回までである。** 2 回目は別の担当が試す。2 回とも結果を +残さなければ、担当ではなく群の側を疑える。止まった理由は群の記録(取り消しの理由と +結末の記録)が持つ。 ## 設計方針 | 観点 | 方針 | | --- | --- | | 参加者 | **全員 CLI プロセス。** ホストのサブエージェント機能は使わない。ホストと同じランタイムが実装担当のラウンドでも別プロセスで起動する | -| 役割の分離 | 提案は**ホストを除く 3 者**、適用は**参加する 4 者すべて**。両者は重なるが一致しない | +| 参加者 | **提案と適用を同じ参加者で回す。** 既定は codex / kiro とホストで、`--exclude` / `--include` で名指しで変える。確認を通らない者は外して続ける | | 検証の単位 | **適用ラウンド(群)に対して 1 回。** 判定は `--baseline-test` の合否で決まり、レビュー CLI は起動しない | | 収束しない項目 | **捨てる。** リファクタリングは任意の作業なので、揉める提案を Pull Request に残さない | | コミットの単位 | **1 適用ラウンド = 1 コミット。** テストも適用ラウンドの単位で 1 回だけ求める | @@ -86,6 +87,9 @@ allowed-tools: | `[PR番号]` | 対象の Pull Request | 必須 | | `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | | `--host claude\|codex\|agy\|kiro` | ホストの明示指定。未指定時は環境変数から推定(agy は推定できないため明示する) | 推定 | +| `--exclude NAMES` | 参加者から外す者(カンマ区切り・繰り返し可)。ホストも外せる。再開で `none` を渡すと空へ戻す | なし | +| `--include NAMES` | 参加者に足す者(例: `--include agy`)。再開で `none` を渡すと空へ戻す | なし | +| `--require-all` | 確認を通らない者が 1 者でもいれば中断する(終了コード 4)。付けなければ外して続ける | 外して続ける | | `--model RT=MODEL` | ランタイムごとのモデル。繰り返し指定できる | CLI の既定 | | `--baseline-test CMD` | 着手前と各コミットで実行するテスト。**振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須** | 必須 | | `--max-test-rounds N` | **テスト整備ラウンド**の上限。到達したら採用が残っていても提案ラウンドへ進む | `2` | @@ -104,40 +108,47 @@ allowed-tools: /ndf:cross-refactoring 130 --scope src --baseline-test "pytest -q" --sync-command "make generate" /ndf:cross-refactoring 130 --scope src --model codex=gpt-5.5 --model claude=claude-opus-5 /ndf:cross-refactoring 130 --scope src --host codex --max-outer-rounds 1 +/ndf:cross-refactoring 130 --scope src tests --baseline-test "pytest -q" --include agy --exclude kiro ``` -**モデルを比べたいなら `--model <ランタイム>=` を 4 つとも指定する。** -実際に動いたモデルを取得できるのは claude だけで、残る 3 者は指定値で代用する。 +**モデルを比べたいなら `--model <ランタイム>=` を参加者の全員に指定する。** +実際に動いたモデルを取得できるのは claude だけで、残る者は指定値で代用する。 指定が無いラウンドは何が動いたか分からないため、集計から分離される (kiro の既定 `auto` も同じ扱いになる)。 ## 担当の決め方 -ホストセッションは**進行の制御に徹し、提案とレビューには参加しない**。 -ただし**適用だけはホストと同じランタイムも担当しうる**。その場合も CLI プロセスとして -起動するため、ホストセッションの作業文脈からは切り離されている。 +ホストセッションは**進行の制御に徹する**。提案と適用はどちらも CLI プロセスとして +起動するため、ホストと同じランタイムが担当するときもホストセッションの作業文脈からは +切り離されている。 -| 母集合 | 定義 | 中身 | +| 参加者(`runtimes`) | 決め方 | ホストごとの既定 | | --- | --- | --- | -| 提案(`runtimes`) | 全ランタイム − ホスト | 常に 3 者 | -| 適用(`impl_capable`) | 全ランタイム | 常に claude / codex / agy / kiro | - -- **適用から外す者はいない。** 4 者はいずれも NDF の配布先で、適用で読ませる - `refactoring` / `tdd-cycle` / `quality-gates` を配っている -- **ホストは適用にだけ参加する。** 提案から外れているので、 - 「実装した者と提案した者が同一モデルにならない」構造は保たれる -- **適用担当は適用ラウンドごとに輪番を進める。** 1 つの提案ラウンドが複数の群を - 持てば、その分だけ輪番も進む。**`--max-outer-rounds` が切るのは提案の回数だけ**で、 - 輪番の 1 周とは対応しない +| 提案と適用の両方 | 既定(codex / kiro とホスト)+ `--include` − `--exclude`。確認を通らない者は外す | claude: claude / codex / kiro、codex: codex / kiro、agy: codex / agy / kiro、kiro: codex / kiro | + +- **agy は既定に入らない。** 起動の失敗が参加者の中で最も多く、提案の所要も最も長かった(#664)。 + 戻すときは `--include agy` を渡す +- **確認を通らない者は外して続ける。** 外した者と理由は状態ファイルと完了報告に残る。 + 全員が揃わないなら始めたくないときは `--require-all` を付ける。使える者が 0 者なら + 中断する(終了コード 4) +- **適用担当は適用ラウンドごとに輪番を進め、参加者の数のラウンドで 1 周する。** + ラウンド 1 は参加者の 2 番目から始まるため、ホストが最初に適用する形にならない。 + 提案者と適用者が同じランタイムになることは避けない(適用の結果はテストと Step 7 が見る) +- **`--max-outer-rounds` が切るのは提案の回数だけ**で、輪番の 1 周とは対応しない。 + 1 つの提案ラウンドが複数の群を持てば、その分だけ輪番も進む +- **レビュー担当はいない。** レビューは Step 7 の `cross-review` が担う 割り当ては `refactor.py start-round` が返し、状態ファイルへ記録する。**再開しても変わらない。** +再開で `--exclude` / `--include` / `--require-all` を渡したときだけ確認をやり直し、 +次に開くラウンドから反映する。上限(`--max-*-rounds` と `--test-timeout`)は再開で渡せば +反映し、状態に載る他の引数は状態と違えば「反映しない」と知らせる。 ## 前提 - `gh` CLI が認証済みで、`jq` と `uv`(または Python 3.10 以上)が使える -- 参加する CLI が**すべてログイン済み**である。`init` が認証状態を確認し、1 つでも - 未認証なら中断する(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま - 担当から脱落するため、確認しないと参加者が欠けた構成のまま進行する) +- 参加者の CLI がログイン済みである。`init` が認証状態を確認し、通らない者を担当から + 外して続ける(未認証の CLI は起動から 15 秒で終わり、結果を残さないまま担当から + 脱落するため、確認しないと参加者が欠けた構成のまま進行する) | ランタイム | 確認コマンド | | --- | --- | @@ -149,16 +160,17 @@ allowed-tools: 確認コマンドは CLI の版で変わりうる。誤検知するときは `NDF_SKIP_AUTH_CHECK=1` で 飛ばせる(飛ばしたことは出力に残る) -- ホストごとに次の CLI が使える(不足していると初期化時に失敗する) +- ホストごとに次の CLI が使える(使えない者は担当から外れる) | ホスト | 必要な CLI | | --- | --- | - | Claude Code | `codex` / `agy` / `kiro-cli` | - | Codex | `claude` / `agy` / `kiro-cli` | - | agy | `claude` / `codex` / `kiro-cli` | - | Kiro CLI | `claude` / `codex` / `agy` | + | Claude Code | `codex` / `kiro-cli` | + | Codex | `kiro-cli` | + | agy | `codex` / `kiro-cli` | + | Kiro CLI | `codex` | - 適用にはホスト自身も参加するため、ホストのコマンドも起動できる必要がある。 + ホスト自身も参加者に入るため、ホストのコマンドも起動できる必要がある。 + `--include` で足した者の CLI も要る。 **agy がホストのときは `--host agy` を明示する**(環境変数からは推定しない) - 対象の Pull Request が Draft で開いている(未作成なら `/ndf:pr` で先に作る) @@ -282,6 +294,8 @@ rf_eval() { rf_eval init "$PR" --scope $SCOPE \ --baseline-test "$BASELINE" ${HOST:+--host "$HOST"} \ + ${EXCLUDE:+--exclude "$EXCLUDE"} ${INCLUDE:+--include "$INCLUDE"} \ + ${REQUIRE_ALL:+--require-all} \ --max-test-rounds "$MAX_TEST" --max-outer-rounds "$MAX_OUTER" \ --max-fix-rounds "$MAX_FIX" --max-items-per-round "$MAX_ITEMS" \ ${CI_CHECK:+--ci-check "$CI_CHECK"} ${WORKFLOW_STEP:+--workflow-step} \ @@ -302,6 +316,7 @@ while :; do "$SCRIPTS/launch-cli.sh" "$a" "$PROPOSE_PHASE" "$ID" "$ROUND" done # 監視の上限は `--phase` の工程で上限の表(`lib/limits.py`)が決める。秒数を書かない。 + # 無進捗の許容だけは `init` が出す(テスト 1 回分の無出力で打ち切らないため)。 # **結果ファイルの名前は種類で変えない**ので、監視の雛形と工程(`propose`)は # テスト整備ラウンドでもそのまま使える。 "$LIB/monitor.py" "$ID" --agents "$RUNTIMES_CSV" --tmp-dir "$TMP_DIR" \ @@ -314,8 +329,9 @@ while :; do rf_eval next-apply-round "$ID" "$ROUND" || break # 終了コード 1 = 群が尽きた "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-apply-r$ROUND" --phase apply - # 終了コード 2 = 適用が通らずこの群を取り消した。修正ラウンドは回さない + --stem-template "{agent}-apply-r$ROUND" --phase apply \ + --stall-timeout "$IMPL_STALL_TIMEOUT" + # 終了コード 2 = この群を取り消した、または担当を替えて開き直す。修正ラウンドは回さない rf merge-apply "$ID" "$ROUND" || continue while :; do # 検証と修正の繰り返し @@ -325,7 +341,8 @@ while :; do fi "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-fix-r$ROUND" --phase fix + --stem-template "{agent}-fix-r$ROUND" --phase fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" rf merge-fix "$ID" "$ROUND" done # 次の群と、次のラウンドの提案に備えて読み取り用を同期する @@ -346,7 +363,8 @@ while :; do 1) echo "⚠ 最終ゲートが通らないまま修正の上限に達しました" >&2; break ;; 2) "$SCRIPTS/launch-cli.sh" "$FINAL_FIX_IMPL" final-fix "$ID" "$LIB/monitor.py" "$ID" --agents "$FINAL_FIX_IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-final-fix" --phase final-fix + --stem-template "{agent}-final-fix" --phase final-fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" rf merge-final-fix "$ID" ;; *) exit $gate ;; esac diff --git a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md index ed457e14..68434085 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md +++ b/plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md @@ -17,8 +17,7 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" | 変数 | 内容 | | --- | --- | | `ID` | 状態ファイルの鍵(最初に初期化した Pull Request 番号) | -| `RUNTIMES` / `RUNTIMES_CSV` | 提案の母集合(ホストを除く 3 者) | -| `IMPL_POOL` | 適用の母集合(参加する 4 者すべて) | +| `RUNTIMES` / `RUNTIMES_CSV` | 参加者(提案と適用の両方。既定は codex / kiro とホスト) | | `WORKTREE_ROOT` / `WORK` / `TMP_DIR` | 作業ディレクトリと一時ディレクトリ | | `REPO` / `HEAD_BRANCH` / `BASE_BRANCH` / `SCOPE` | 対象の情報 | @@ -26,12 +25,11 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 1. **ホストの確定** — `--host` の明示指定を第一とし、未指定時のみ環境変数 (`CLAUDE_PLUGIN_ROOT` / `CODEX_HOME` / `KIRO_AGENT` など)から推定する。 - 推定できなければ**既定値を置かずに失敗する**。誤検出すると提案の母集合が - 狂う(ホストが提案側に混ざる、参加すべき者が外れる)ため、確定結果は出力と状態 - ファイルの両方へ残す -2. **母集合の確定** — 提案(全 − ホスト)と適用(全)を**別々に**確定する。 - 提案の母集合にホストが含まれていたら初期化ごと失敗させる。 - 適用から外す者はいないが、**関数は分けたまま**にする(ホストを含むか否かが違う) + 推定できなければ**既定値を置かずに失敗する**。誤検出すると参加者が + 狂う(参加すべき者が外れる)ため、確定結果は出力と状態ファイルの両方へ残す +2. **参加者の確定** — 既定(codex / kiro とホスト)に `--include` を足し、`--exclude` を + 除く。**提案と適用は同じ参加者で回す。** 母集合に無い者を外す指定と、足す者と外す者の + 重なりは中断する(終了コード 4) 3. **モデルの確定** — `--model <ランタイム>=<モデル>` を繰り返し受け取る。指定値は **全ラウンドで固定**する。途中で変えると比較が成立しないため、再開時も変えない 4. **書き込み用の作業ディレクトリ作成** — `/work/` に head ブランチを checkout する。 @@ -40,12 +38,14 @@ export CROSS_REFACTORING_TMP_DIR="$TMP_DIR" 使うと古い HEAD に対して提案・適用してしまう。早送りできない(履歴が分かれた) ときは中断する。`git fetch` に失敗したときも中断する(古い `origin/` へ 早送りして「同期したつもり」になるのを防ぐ) -5. **認証状態の確認** — 参加する CLI を 1 つずつ確認し、未認証なら**初期化ごと中断する** - (終了コード 4)。存在確認だけでは足りない。未認証の CLI は起動から 15 秒で終わり、 - 結果ファイルを残さないまま担当から脱落するが、それでも初期化は成功として扱われるため、 - **参加者が 1 人欠けた構成のまま最後まで進んでしまう**(実測)。作業ディレクトリを - 作る前に確認する。確認コマンドは CLI の版で変わりうるので `NDF_SKIP_AUTH_CHECK=1` - で飛ばせるが、飛ばしたことは必ず出力へ残す +5. **認証状態の確認** — 参加者の CLI を 1 つずつ確認し、通らない者を**担当から外して + 続ける**。外した者と理由は状態ファイルの `participants` に残る。存在確認だけでは + 足りない。未認証の CLI は起動から 15 秒で終わり、結果ファイルを残さないまま担当から + 脱落するが、それでも初期化は成功として扱われるため、**確認しないと参加者が 1 人欠けた + 構成のまま最後まで進んでしまう**(実測)。着手前のテストより先に確認する。 + `--require-all` を付けると 1 者でも通らなければ中断し、使える者が 0 者のときも + 中断する(どちらも終了コード 4 で、状態ファイルを作らない)。確認コマンドは CLI の版で + 変わりうるので `NDF_SKIP_AUTH_CHECK=1` で飛ばせるが、飛ばしたことは必ず出力へ残す 6. **語彙の受け渡し** — 検証側が持つ兆候・手法・重要度の集合を状態ファイルの `vocabulary` へ書く。提案プロンプトはここから**許容値をそのまま列挙する**。 定義を 1 箇所に保ったまま、読ませ方の不確実性を減らすためである @@ -201,7 +201,7 @@ claude 17 本 / codex 1 メソッド / kiro 0 本と揃わなかった。最後 | --- | --- | | いつ | 初期化の後、**最初の提案ラウンドの前** | | 何回 | 新しいテストの提案が出なくなるまで。上限は `--max-test-rounds`(既定 2) | -| 誰が | 提案は 3 者(ホストを除く)。適用は輪番。**提案ラウンドと同じ母集合** | +| 誰が | 提案は参加者の全員。適用は輪番。**提案ラウンドと同じ参加者** | | 何を | 対象範囲のうちテストの薄い経路へ現状固定テストを足す。**構造は変えない** | | 何を足さないか | **このラウンドの後、構造改善の提案で `test_gap` を真にできない。** テストは既に足してある | | 収束の条件 | **採用 0 件**(提案ラウンドと同じ形) | @@ -242,7 +242,7 @@ done `smell`)と同じ役目を持つ。**`level` は鍵に入れない**(同じ経路を別の階層で 2 度 固定させないため)。同じ経路に複数の階層が挙がったときは**低い方**を採る。 -**経路を自由文だけで挙げさせない。** 同じ経路が 3 者から別の言い回しで出て、重複 +**経路を自由文だけで挙げさせない。** 同じ経路が複数の参加者から別の言い回しで出て、重複 排除が効かなくなる。語彙外の値を含む提案は、構造改善の提案と同じく降格して対象外へ 落とす。 @@ -257,9 +257,9 @@ done --stem-template "{agent}-propose-rf{id}-r$ROUND" --phase propose ``` -3 CLI を並列で起動し、同一のプロンプトで提案させる。**提案フェーズにホストは現れない** -(母集合にいないため)。ただし `launch-cli.sh` はホストと同じランタイムを起動しうる -(適用担当のとき)ので、「ホストなら起動しない」といった分岐を入れてはならない。 +参加者の CLI を並列で起動し、同一のプロンプトで提案させる。**ホストと同じランタイムも +参加者として起動する**(CLI プロセスなので、ホストセッションの作業文脈からは切り離されて +いる)。「ホストなら起動しない」といった分岐を入れてはならない。 提出形式は [prompts/propose.md](../prompts/propose.md) にある。 @@ -304,7 +304,7 @@ CLI の起動時に同名の結果ファイルを消すため、**提案の結 `init` が状態ファイルの `vocabulary` へ書き、`launch-cli.sh` が読んで差し込む。 **同じ一覧を 2 か所に書かない。** -**採用上限も同じ経路で渡す**(`--max-items-per-round`)。「3 者の合計で N 件までが +**採用上限も同じ経路で渡す**(`--max-items-per-round`)。「参加者の合計で N 件までが 採用される」と書き、**多く出すより採れる提案を出す**ことを求める。テスト整備ラウンドの プロンプトも同じ形で伝える。 @@ -346,7 +346,7 @@ CLI の起動時に同名の結果ファイルを消すため、**提案の結 鍵は種類で変わる。改善項目は `path` + `symbol` + `smell`、**テスト項目は `target` + `case`** である。 -**採用上限は提案の時点で伝える**(受け入れ条件 C2)。上限を知らせないと、3 者が +**採用上限は提案の時点で伝える**(受け入れ条件 C2)。上限を知らせないと、参加者が 上限を超える件数を出し、超えた分は見送りとして記録されて以後は「対象外」になる。 提案の労力がそのまま無駄になる。 diff --git a/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md b/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md index 928ab276..81185715 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md +++ b/plugins/ndf/skills/cross-refactoring/docs/02-apply-and-review.md @@ -9,17 +9,36 @@ eval "$("$SCRIPTS/refactor.py" next-apply-round "$ID" "$ROUND")" # 1 = 群が尽きた "$SCRIPTS/launch-cli.sh" "$IMPL" apply "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-apply-r$ROUND" --phase apply -"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = この群を取り消した / 4 = 中断 + --stem-template "{agent}-apply-r$ROUND" --phase apply \ + --stall-timeout "$IMPL_STALL_TIMEOUT" +"$SCRIPTS/refactor.py" merge-apply "$ID" "$ROUND" # 2 = 取り消した / 開き直す / 4 = 中断 ``` 終了コード 2 と 4 を**必ず区別する**。同じ扱いにすると、取り消しに失敗した状態を 「この群の失敗」として次の群へ進み、検証を通っていない変更が Pull Request に 残ったまま先へ進む(実測)。 -**適用の単位は適用ラウンド(群)である。** 群の中の項目は書き換えるファイルが -重ならないので、**まとめて 1 コミット**にできる。群と群は同じファイルを直列に -書き換えるため順序に依存し、後続の群は先行の群を適用した後の作業ツリーを読む。 +**無進捗の許容は `init` が出す**(`IMPL_STALL_TIMEOUT` = テストの制限時間 + 900 秒)。 +担当はテストの実行中は何も出力しないため、制限時間そのままでは打ち切られる。 + +### 実装担当が結果を残さなかったとき + +結果ファイルが無い・読めないまま終わることがある(無進捗の打ち切り・利用上限・起動の +直後に落ちた)。取り込みは、未検証のコミットを新しい順に取り消して起点を取り消し後の先端へ +進め、群の `failed_attempts[]` へ結末を 1 件記録し、終了コード 2 で終える。 + +| 何回目か | 何が起きるか | +| --- | --- | +| 1 回目 | その群で失敗した担当のどれとも違う、次の輪番の担当へ替えて開き直す | +| 2 回目 | 群を取り消し(`drop_reason: no_result`)、項目を見送りへ入れる。理由にはどの担当がどの理由で残さなかったかが並ぶ | +| 替える先が無い | 起動し直しの可否で決める。利用上限は待ちと相手の枠を使うだけなので 1 回目で取り消す | + +**項目が 1 件も無い群は開かない。** 採用 0 件の提案ラウンドは群を作らず、残っている +項目なしの群は取り消し済み(`drop_reason: empty`)にして次へ進む。 + +**適用の単位は適用ラウンド(群)である。** 群の中の項目は書き換えるファイルが重ならない +ので、**まとめて 1 コミット**にできる。群と群は同じファイルを直列に書き換えるため順序に +依存し、後続の群は先行の群を適用した後の作業ツリーを読む。 実装担当を**1 つの群につき 1 回**起動し、その群の項目を優先度順に**直列適用**させる。 並列適用はしない(同一ブランチへの同時コミットは競合と取り消し単位の曖昧化を招く)。 @@ -208,38 +227,6 @@ fi 控えておき、最後に `git reset --soft` で 1 コミットへまとめる。控えた地点より前へ 戻すと他の群のコミットを巻き込むため、起点は群の着手前に固定する。 -#### 改修計画は Pull Request のコメントに残す - -**なぜ直すのか(理由)とどう直すのか(手順)は、提案の時点でしか残らない。** -状態ファイルには入っているが、そのディレクトリは差分から除外されるため、 -Pull Request を読む側からは見えない。 - -**改修計画は実行の記録であって、リポジトリの知識ではない**(#436 決定 6)。既定の -置き場所は**対象の Pull Request のコメント 1 件**で、ラウンドが進むたびに**同じ -コメントを編集する**。 - -| 置き場所 | URL の安定 | 差分に混ざるか | 更新の手数 | -| --- | --- | --- | --- | -| **Pull Request のコメント 1 件**(既定) | **永続** | 混ざらない | 編集 1 回 | -| ファイル(`--plan-file`) | `` に依存。ブランチが消えると切れる | **混ざる** | コミットと push | - -- 内容は**状態から決まる**。同じ状態からは同じ本文が出る -- **取り消した項目の内訳を持つのは改修計画だけである。** 他の文章は件数だけ述べる -- 本文の先頭に印(``)を置く。状態ファイルの - 控えが失われても、印で同じコメントを引き当てられる。**引き当てられないと、 - ラウンドのたびに新しいコメントが積まれる** -- **投稿に失敗しても進行は止めない。** 記録が残らないことと、変更が検証を通って - いないことは別である。失敗したことは出力に残る - -**`--plan-file` は残す。** 明示したときだけファイルにする。この経路では公開を -生成物の同期と**同じコミット**に乗せる(分けると進行側のコミットが公開のたびに -2 つずつ積まれる)。空文字を渡すと記録しない。 - -**絶対パスと親へ抜ける経路は受け取った時点で拒む**(終了コード 4)。進行側は利用者の -リポジトリを触るため、作業ディレクトリの外へ書き出す余地を残さない。あわせて -`./issues/plan.md` のような表記も正規化する。git が返すパスと形が違うと、公開の -コミットメッセージが取り違えられる。 - #### 範囲の指定は検証にも効かせる `--scope` を必須にした目的は**提案の発散と変更の肥大を防ぐ**ことなので、指定を検証へ @@ -357,15 +344,15 @@ Pull Request に残る。**都合の悪い変更を申告しないだけで検 | コマンド | 二重処理を防ぐ鍵 | | --- | --- | | `merge-proposals` | `proposal_keys` が既にあるか | -| `merge-apply` | `apply.merged_at` が既にあるか | +| `merge-apply` | `apply.merged_at` が既にあるか。結果を残さなかった試行は、群の `failed_attempts[]` に同じ工程と試行番号があるか | | `merge-fix` | 試行番号(`verify-round` が進める)と結果ファイルの内容の組 | | `abandon-items` | `abandoned` が既にあるか | #### 結果ファイルの形が崩れていても落ちない -相手は LLM なので、`commits` が配列でない・要素が辞書でない・`sha` が文字列でない -といった崩れ方をする。結果ファイルを読む箇所は**型を確かめてから使い**、取り出せた -ものだけを扱う。落ちると進行が止まるだけで、何の検証にもならない。 +相手は LLM なので、`commits` が配列でない・要素が辞書でないといった崩れ方をする。 +結果ファイルを読む箇所は**型を確かめてから使い**、取り出せたものだけを扱う。**JSON +オブジェクトとして読めない結果ファイルは結果なしと同じ扱いにする**(理由は `unparsable`)。 **1 件の失敗でラウンドを止めない。** 失敗した項目だけを見送りにして、残りは採用する。 全件失敗のときだけ終了コード 2 を返し、次の提案ラウンドへ進む。 @@ -444,6 +431,19 @@ Impl-Model: gpt-5.5 自由文で「codex が実装」と書かせると集計に使えない。プロンプトに書くだけでは守られない ので、`merge-apply` が有無を検証する。 +**読み方は 2 つあり、見る範囲が違う。** + +| 読み手 | 読み方 | 見る範囲 | +| --- | --- | --- | +| 人(集計) | `git log --format='%(trailers:key=Impl-Model,valueonly)'` | **最後の段落だけ** | +| 進行側(検証) | 末尾の段落から前へ 1 段落ずつ `git interpret-trailers --parse` に掛け、記名の段落と判定しなかったところで止める | 末尾から続く記名の段落すべて | + +実行環境が `Co-Authored-By:` などの帰属行を別の段落として足しても、進行側はその段落を +飛ばして前まで読むため検証は通る。人の集計は最後の段落しか読まないので、**雛形では +帰属行を空行を挟まず同じ段落に続けるよう求めている**(従わなくても検証は通る)。 +**1 段落目(題名)は判定に掛けない。** 掛けると `Round: 本文の題名` の形の題名を記名 +として読む(実測)。散文と記名の形が混ざる段落は、git が記名の段落と判定しない。 + `Impl-Model` には**実際に使ったモデル名**を書かせる。既定モデルで走った場合は `default` として報告時に区別する。 diff --git a/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md b/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md index 82810416..c2711ab0 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md +++ b/plugins/ndf/skills/cross-refactoring/docs/04-fix-and-report.md @@ -10,8 +10,9 @@ if "$SCRIPTS/refactor.py" should-abandon "$ID" "$ROUND"; then else "$SCRIPTS/launch-cli.sh" "$IMPL" fix "$ID" "$ROUND" "$LIB/monitor.py" "$ID" --agents "$IMPL" --tmp-dir "$TMP_DIR" \ - --stem-template "{agent}-fix-r$ROUND" --phase fix - "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" + --stem-template "{agent}-fix-r$ROUND" --phase fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" + "$SCRIPTS/refactor.py" merge-fix "$ID" "$ROUND" # 2 = 結果なし / 範囲が確定しない # 修正後の状態を次の提案へ届ける "$SCRIPTS/prepare-worktrees.sh" "$ID" sync "$(git -C "$WORK" rev-parse HEAD)" fi @@ -28,6 +29,18 @@ fi **`--max-fix-rounds` は 1 つの適用ラウンドあたりの上限である。** 数え直しは `next-apply-round` が群を開くときに行う。 +**読むのは群の担当の結果である。** 骨組みが起動するのは群の担当であり、提案ラウンドの +担当とは限らない。食い違うと結果ファイルを一度も引けず、修正ラウンドが進まないまま +検証と修正を往復し続ける。 + +**修正の担当が結果を残さなかったときも、修正ラウンドは進める。** 未検証のコミットを +取り消し、群の `failed_attempts[]` へ 1 件(工程 `fix`)を記録し、終了コード 2 で +終える。進めないと見送りの判定が上限に達する条件を満たさない。**起動し直しても +解けない結末(利用上限)では、修正ラウンドの数を上限の値にする。** 次の見送りの判定が +そのまま見送りへ移すため、同じ担当を上限まで起動し直すことがなくなる。 + +**無進捗の許容は `init` が出す**(`IMPL_STALL_TIMEOUT`)。適用と同じ値である。 + ### 修正コミットも適用と同じ基準で見る `merge-fix` は修正コミットにも `Item-Id` / `Round` / `Impl-Runtime` / `Impl-Model` と @@ -228,7 +241,10 @@ Pull Request の読み手が持つため、失敗として報告に書く。 ```bash "$SCRIPTS/launch-cli.sh" "$FINAL_FIX_IMPL" final-fix "$ID" # 担当は final-gate が返す -"$SCRIPTS/refactor.py" merge-final-fix "$ID" # 取り込みも専用 +"$LIB/monitor.py" "$ID" --agents "$FINAL_FIX_IMPL" --tmp-dir "$TMP_DIR" \ + --stem-template "{agent}-final-fix" --phase final-fix \ + --stall-timeout "$IMPL_STALL_TIMEOUT" +"$SCRIPTS/refactor.py" merge-final-fix "$ID" # 2 = 結果なし / 範囲が確定しない ``` **`fix` フェーズと `merge-fix` は使い回せない。** どちらも適用ラウンド(群)の控えを @@ -263,6 +279,14 @@ Pull Request の読み手が持つため、失敗として報告に書く。 「上限に達しても取り消さない」のは*採用した改善項目*の話であって、検証を受けていない 修正コミットは別である。取り消せば HEAD は最終ゲートが見た地点へ戻る。 +**修正の担当が結果を残さなかったときも同じ手順を通る。** 取り消して起点を取り消し後の +先端へ進め、最終ゲートの記録の `failed_attempts[]` へ 1 件(工程 `final-fix`)を残し、 +終了コード 2 で最終ゲートへ判定を戻す。取り消さずに抜けると、次の最終ゲートがその +コミットを含む先端でテストし、落ちれば起点をそこへ置き直すため、未検証の差分が +Pull Request に残る。**修正ラウンドはここでは進めない。** 進めるのは次の最終ゲート +である。ただし**起動し直しても解けない結末(利用上限)では上限の値にする**。次の +最終ゲートは、テストが落ちれば終了コード 1(取り消さず報告)で終わる。 + ### テストで見つからない誤りは誰が拾うか Step 5 をテストへ置き換え、Step 7 を条件付きで省くと、実行の流れからレビューが @@ -307,6 +331,38 @@ Step 5 のテストが済ませている。同じ観点を後段へ渡すと、 | 実装担当 | 担当ラウンド数 / 適用成功した項目数 / 見送った項目数 / 平均修正ラウンド数 / 差分予算の超過率 / テスト失敗の発生率 / 所要時間 | | 提案の担当 | 提案件数 / 採用された率 / 他者と合意した率 / 所要時間 | +### 改修計画は Pull Request のコメントに残す + +**なぜ直すのか(理由)とどう直すのか(手順)は、提案の時点でしか残らない。** +状態ファイルには入っているが、そのディレクトリは差分から除外されるため、 +Pull Request を読む側からは見えない。 + +**改修計画は実行の記録であって、リポジトリの知識ではない**(#436 決定 6)。既定の +置き場所は**対象の Pull Request のコメント 1 件**で、ラウンドが進むたびに**同じ +コメントを編集する**。 + +| 置き場所 | URL の安定 | 差分に混ざるか | 更新の手数 | +| --- | --- | --- | --- | +| **Pull Request のコメント 1 件**(既定) | **永続** | 混ざらない | 編集 1 回 | +| ファイル(`--plan-file`) | `` に依存。ブランチが消えると切れる | **混ざる** | コミットと push | + +- 内容は**状態から決まる**。同じ状態からは同じ本文が出る +- **取り消した項目の内訳を持つのは改修計画だけである。** 他の文章は件数だけ述べる +- 本文の先頭に印(``)を置く。状態ファイルの + 控えが失われても、印で同じコメントを引き当てられる。**引き当てられないと、 + ラウンドのたびに新しいコメントが積まれる** +- **投稿に失敗しても進行は止めない。** 記録が残らないことと、変更が検証を通って + いないことは別である。失敗したことは出力に残る + +**`--plan-file` は残す。** 明示したときだけファイルにする。この経路では公開を +生成物の同期と**同じコミット**に乗せる(分けると進行側のコミットが公開のたびに +2 つずつ積まれる)。空文字を渡すと記録しない。 + +**絶対パスと親へ抜ける経路は受け取った時点で拒む**(終了コード 4)。進行側は利用者の +リポジトリを触るため、作業ディレクトリの外へ書き出す余地を残さない。あわせて +`./issues/plan.md` のような表記も正規化する。git が返すパスと形が違うと、公開の +コミットメッセージが取り違えられる。 + ### 報告も外へ出す文章である | 規約 | 報告での書き方 | diff --git a/plugins/ndf/skills/cross-refactoring/prompts/apply.md b/plugins/ndf/skills/cross-refactoring/prompts/apply.md index 8c257241..c3091132 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/apply.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/apply.md @@ -63,6 +63,28 @@ Impl-Model: $RF_MODEL - **`Item-Id` にはこの適用ラウンドの先頭の項目 ID を書く。** どの項目の ID でも 検証は通りますが、揃えておくと履歴が読みやすくなります - `Impl-Model` には**実際に使ったモデル名**を書く。分からなければ `default` +- **4 つのトレーラーはメッセージの最後の段落に置く。** 実行環境が + `Co-Authored-By:` などの帰属行を足すときは、**空行を挟まず同じ段落に続けます**。 + 人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計するとき、 + git は最後の段落しか読みません + +## 作業の進み具合を残す + +**作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記してください。** +テストの実行中は何も出力されないため、追記が無いと監視が「進んでいない」と見て +打ち切ります。書くのは段階と対象だけで、内容は要りません。 + +```bash +echo "test src/foo.py" >> "$RF_STEM-progress.log" +``` + +| 段階 | いつ書くか | +| --- | --- | +| `start` | 着手した | +| `edit` | ファイルを書き換えた | +| `test` | テストを実行する直前 | +| `commit` | コミットした | +| `done` | 結果ファイルを書き終えた | ## 守ること diff --git a/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md b/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md index a2dc4eb2..b3f49ad7 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/final-fix.md @@ -51,6 +51,24 @@ Impl-Model: $RF_MODEL **申告から漏れたコミットがあると、この修正の範囲ごと取り消されます。** 作った コミットは 1 件残らず結果ファイルへ書いてください。 +## 作業の進み具合を残す + +**作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記してください。** +テストの実行中は何も出力されないため、追記が無いと監視が「進んでいない」と見て +打ち切ります。書くのは段階と対象だけで、内容は要りません。 + +```bash +echo "test src/foo.py" >> "$RF_STEM-progress.log" +``` + +| 段階 | いつ書くか | +| --- | --- | +| `start` | 着手した | +| `edit` | ファイルを書き換えた | +| `test` | テストを実行する直前 | +| `commit` | コミットした | +| `done` | 結果ファイルを書き終えた | + ## 守ること - **push しない。** 公開するのは進行側だけで、**検証を通した後**に行います diff --git a/plugins/ndf/skills/cross-refactoring/prompts/fix.md b/plugins/ndf/skills/cross-refactoring/prompts/fix.md index 5115b0fe..d0fdf562 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/fix.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/fix.md @@ -63,6 +63,29 @@ Impl-Runtime: $RF_RUNTIME Impl-Model: $RF_MODEL ``` +- **4 つのトレーラーはメッセージの最後の段落に置く。** 実行環境が + `Co-Authored-By:` などの帰属行を足すときは、**空行を挟まず同じ段落に続けます**。 + 人が `git log --format='%(trailers:key=Impl-Model,valueonly)'` で集計するとき、 + git は最後の段落しか読みません + +## 作業の進み具合を残す + +**作業段階が進むたびに `$RF_STEM-progress.log` へ 1 行追記してください。** +テストの実行中は何も出力されないため、追記が無いと監視が「進んでいない」と見て +打ち切ります。書くのは段階と対象だけで、内容は要りません。 + +```bash +echo "test src/foo.py" >> "$RF_STEM-progress.log" +``` + +| 段階 | いつ書くか | +| --- | --- | +| `start` | 着手した | +| `edit` | ファイルを書き換えた | +| `test` | テストを実行する直前 | +| `commit` | コミットした | +| `done` | 結果ファイルを書き終えた | + ## 守ること - **push しない。** 公開するのは進行側だけで、**検証を通した後**に行います diff --git a/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md b/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md index 1e3c30f5..1a264189 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/propose-tests.md @@ -13,7 +13,7 @@ - 作業ディレクトリ: `$RF_WORKDIR`(**ここから外は読まない・書かない**) - 対象範囲: `$RF_SCOPE`(**この範囲の外は提案しない**) - 着手前のテスト: `$RF_BASELINE_TEST` -- 採用上限: 3 者の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます +- 採用上限: 参加者全員の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます ## 手順書 @@ -80,7 +80,7 @@ $RF_VOCAB_LEVELS - `path` は**テストを足す先**のファイル(リポジトリ相対) - `target` は**固定する入口**で、`<ファイル>#<シンボル>` の形で書く -- **`target` + `case` が同じ提案は 1 件へ統合されます。** 3 者が同じ経路を挙げる +- **`target` + `case` が同じ提案は 1 件へ統合されます。** 複数の参加者が同じ経路を挙げる ため、この 2 つが重複排除の鍵になります。**他のランタイムと合意した提案ほど 優先される**ので、独自性を狙わず素直に挙げてください - `level` は鍵に入りません。同じ経路に複数の階層が挙がったときは**低い方**が採られます diff --git a/plugins/ndf/skills/cross-refactoring/prompts/propose.md b/plugins/ndf/skills/cross-refactoring/prompts/propose.md index 621cd774..2deeff18 100644 --- a/plugins/ndf/skills/cross-refactoring/prompts/propose.md +++ b/plugins/ndf/skills/cross-refactoring/prompts/propose.md @@ -9,7 +9,7 @@ - 作業ディレクトリ: `$RF_WORKDIR`(**ここから外は読まない・書かない**) - 対象範囲: `$RF_SCOPE`(**この範囲の外は提案しない**) - 着手前のテスト: `$RF_BASELINE_TEST` -- 採用上限: 3 者の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます +- 採用上限: 参加者全員の提案を統合したうえで、**合計 $RF_MAX_ITEMS 件までが採用**されます ## 手順書 diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py index 79203a40..01df6cf5 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor.py @@ -61,7 +61,11 @@ cmd_report, cmd_status, ) -from refactor_lib.commands.setup import cmd_init, cmd_start_round # noqa: E402 +from refactor_lib.commands.setup import ( # noqa: E402 + cmd_init, + cmd_start_round, + runtime_list, +) from refactor_lib.measure import summary_extra # noqa: E402 import run_metrics # noqa: E402 @@ -83,6 +87,10 @@ def _write_run_summary(path: pathlib.Path, state: dict) -> None: SEVERITY_ORDER, ) +# **状態ファイルに載る引数の既定は `None` にする**(#727 の決定 13)。既定値を引数に +# 持たせると、再開で「渡さなかった」と「既定値を渡した」を区別できない。新規の +# 初期化が `commands/setup.py` の `NEW_RUN_DEFAULTS` で置き換える。 + # ---------------- main ---------------- @@ -94,39 +102,51 @@ def main() -> None: init = sub.add_parser( "init", - help="Step 0 — ホスト確定 / 母集合の確定 / 作業ディレクトリ root / 状態初期化") + help="Step 0 — ホスト確定 / 参加者の確定 / 作業ディレクトリ root / 状態初期化・再開") init.add_argument("pr", type=int) init.add_argument("--scope", nargs="+", required=True, help="対象範囲。提案が無制限に広がらないよう必須にしている") init.add_argument("--host", choices=list(assignment.HOST_RUNTIMES), default=None, help="ホストの明示指定。未指定時は環境変数から推定する") + # **参加者は既定に足し引きして決める**(#727 の決定 4)。既定は codex / kiro と + # ホストで、使う側を並べる形にしないのは、ホストが変わるたびに書き直さずに済むため。 + init.add_argument("--exclude", action="append", type=runtime_list, default=None, + help="参加者から外す者(ホストも外せる)。カンマ区切り・繰り返し可。" + "再開で none を渡すと空へ戻す") + init.add_argument("--include", action="append", type=runtime_list, default=None, + help="参加者に足す者(例: agy)。カンマ区切り・繰り返し可。" + "再開で none を渡すと空へ戻す") + init.add_argument("--require-all", dest="require_all", + action=argparse.BooleanOptionalAction, default=None, + help="確認を通らない者が 1 者でもいれば中断する。" + "既定は外して続ける") # **切るのは提案の回数であって、適用できる件数ではない**(#436 決定 8)。 # 適用ラウンドを分けたことで、1 回の提案で通せる件数は上限に縛られなくなった。 # 取り消した項目は除外されるため、同じ提案が積み上がって回数を食うこともない。 # **輪番の 1 周を根拠にしない。** 適用の担当は適用ラウンドごとに進むので、 # 1 つの提案ラウンドが複数の群を持てば輪番は 1 周しうる。 - init.add_argument("--max-outer-rounds", type=int, default=3, - help="構造改善の提案ラウンドの上限") + init.add_argument("--max-outer-rounds", type=int, default=None, + help="構造改善の提案ラウンドの上限 (default: 3)") # テスト整備は母集合が増えない(対象のコードを変えないため、テストが薄い経路の # 集合は最初から確定している)。2 回目に出るのは 1 回目の挙げ漏らしだけである。 - init.add_argument("--max-test-rounds", type=int, - default=DEFAULT_MAX_TEST_ROUNDS, + init.add_argument("--max-test-rounds", type=int, default=None, help="テスト整備ラウンドの上限。到達したら採用が残っていても " "構造改善の提案ラウンドへ進む " f"(default: {DEFAULT_MAX_TEST_ROUNDS})") - init.add_argument("--max-fix-rounds", type=int, default=3, - help="1 つの適用ラウンドあたりの修正ラウンドの上限") - init.add_argument("--max-items-per-round", type=int, default=5, - help="1 つの提案ラウンド/テスト整備ラウンドの採用上限") + init.add_argument("--max-fix-rounds", type=int, default=None, + help="1 つの適用ラウンドあたりの修正ラウンドの上限 (default: 3)") + init.add_argument("--max-items-per-round", type=int, default=None, + help="1 つの提案ラウンド/テスト整備ラウンドの採用上限 (default: 5)") init.add_argument("--ci-check", default=None, metavar="NAME", help="最終ゲートで手元のテストの代わりに見る検査の名前。" "**指定すると手元のテストは実行しない**(排他)。" "指定が無ければ手元のテストで判定する") - init.add_argument("--severity-threshold", default=DEFAULT_SEVERITY_THRESHOLD, - choices=[s for s in SEVERITY_ORDER if s != "unknown"]) + init.add_argument("--severity-threshold", default=None, + choices=[s for s in SEVERITY_ORDER if s != "unknown"], + help=f"この重要度未満は採用しない (default: {DEFAULT_SEVERITY_THRESHOLD})") init.add_argument("--model", action="append", metavar="RUNTIME=MODEL", help="ランタイムごとのモデル指定。繰り返し指定できる") - init.add_argument("--test-timeout", type=int, default=DEFAULT_TEST_TIMEOUT, + init.add_argument("--test-timeout", type=int, default=None, help="テスト 1 回あたりの上限秒数。超えたら失敗として扱う " f"(default: {DEFAULT_TEST_TIMEOUT})") init.add_argument("--sync-command", default=None, @@ -147,7 +167,7 @@ def main() -> None: "振る舞い不変を示す手段が無い書き換えは構造改善ではないため必須") # **起動のされ方は引数で受け取る**(#436 決定 7)。環境変数や控えの読み取りは、 # 起動元が違っても同じ値になりうる。呼ぶ側が明示すれば判定が 1 か所で済む。 - init.add_argument("--workflow-step", action="store_true", + init.add_argument("--workflow-step", action="store_true", default=None, help="`development-workflow` の 1 工程として起動したことを" "伝える。Step 7 の `cross-review` を省き、" "全体のテストで判定する") @@ -156,7 +176,7 @@ def main() -> None: for name, func, help_ in ( ("start-round", cmd_start_round, - "Step 2 — 提案ラウンドを開く。実装担当とレビュー担当を返す"), + "Step 2 — 提案ラウンドを開く。実装担当を返す"), ("merge-proposals", cmd_merge_proposals, "Step 3 — 提案の語彙検証・重複排除・優先度付け・採否"), ("advance", cmd_advance, "ラウンドの収束判定と、ラウンドの種類の切り替え"), diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py index 0e8d8fef..f18a57c4 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/apply.py @@ -10,9 +10,8 @@ import json import pathlib import sys -from typing import Any +from typing import Any, Optional -import assignment import statefile from .. import die, info @@ -27,20 +26,28 @@ read_result, record_observed_model, reported_shas, - revert_item_commits, round_of, safe_int, collect_commit_facts, commits_in_range, ) +from ..intake import ( + IntakeScope, + already_closed, + close_without_result, + discard_unverified, +) from ..paths import git_out, load_state, result_path, stem_for from ..proposals import assign_apply_rounds, merge_proposals, merge_test_proposals from ..rounds import ( TEST, apply_groups, + attempt_of, current_group, deferred_record, entry_kind, + group_reopening, + impl_for_seq, item_key, item_kind, item_label, @@ -73,6 +80,30 @@ class _ApplyCommitRange: in_range: set[str] +def _read_runtime_proposal( + result: pathlib.Path, +) -> Optional[list[dict[str, Any]]]: + runtime = result.name.split("-", 1)[0] + if not result.exists(): + info(f"⚠ {runtime} の提案結果がありません: {result}") + return None + try: + payload = json.loads(result.read_text(encoding="utf-8")) + except json.JSONDecodeError as e: + info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") + return None + if not isinstance(payload, dict): + info( + f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" + f"({type(payload).__name__})。提案なしとして扱います" + ) + return [] + items = payload.get("items") + if not isinstance(items, list): + return [] + return [item for item in items if isinstance(item, dict)] + + def _load_runtime_proposals( state: dict[str, Any], entry: dict[str, Any] ) -> dict[str, list[dict[str, Any]]]: @@ -87,28 +118,11 @@ def _load_runtime_proposals( state, runtime, stem_for(runtime, "propose", state["id"], entry["round"]), ) - if not result.exists(): - info(f"⚠ {runtime} の提案結果がありません: {result}") - continue - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - info(f"⚠ {runtime} の提案結果が JSON として読めません: {e}") - continue - if not isinstance(payload, dict): - # 配列や数値のまま `payload.get(...)` を呼ぶと落ちる。 - # 提案は無かったものとして続ける(1 者の不調で全体を止めない)。 - info( - f"⚠ {runtime} の提案結果が JSON オブジェクトではありません" - f"({type(payload).__name__})。提案なしとして扱います" - ) - proposals[runtime] = [] - entry["proposed"][runtime] = 0 + runtime_proposals = _read_runtime_proposal(result) + if runtime_proposals is None: continue - items = payload.get("items") - proposals[runtime] = [i for i in items if isinstance(i, dict)] \ - if isinstance(items, list) else [] - entry["proposed"][runtime] = len(proposals[runtime]) + proposals[runtime] = runtime_proposals + entry["proposed"][runtime] = len(runtime_proposals) return proposals @@ -131,18 +145,19 @@ def _assign_apply_rounds_to_state( seq = safe_int(state.get("apply_seq")) for n, group in enumerate(assign_apply_rounds(adopted), start=1): seq += 1 - impl, _ = assignment.assign(seq, state["host"]) + impl, requested = impl_for_seq(state, seq) for item in group: item["apply_round"] = n entry["apply_rounds"].append({ "apply_round": n, "impl": impl, - "impl_model": {"requested": state["models"].get(impl), "observed": None}, + "impl_model": {"requested": requested, "observed": None}, "items": [i["item_id"] for i in group], "status": "pending", "base_sha": None, "head_sha": None, "fix_rounds": 0, + "attempt": 0, }) state["apply_seq"] = seq @@ -275,37 +290,52 @@ def _item_summary(item: dict[str, Any]) -> str: -def cmd_next_apply_round(args: argparse.Namespace) -> None: - """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 - - 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 +def _select_next_apply_group( + groups: list[dict[str, Any]], +) -> tuple[Optional[dict[str, Any]], str]: + """次に開く群と、その群の開き直しの判定を返す。無ければ群は None。 - **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを - 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 + **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 + 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは + 収束ループの前提である。 - **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 - ラウンドあたりの上限だからである。 + **未着手の群は、開き直しの判定へ掛ける**(#647)。無条件に開き直すと、結果を + 残さない担当に当たり続けて上限なく起動する。項目が無い群と上限に達した群は、 + ここで取り消し済みにして次を探す。 """ - path, state = load_state(args.id) - entry = round_of(state, args.round) - groups = apply_groups(entry) + reopening = "" + for group in groups: + if group.get("status") not in {"pending", "applied"}: + continue + if group.get("status") == "applied": + return group, reopening + reopening = group_reopening(group) + if reopening in {"empty", "exhausted"}: + group["status"] = "dropped" + group.setdefault( + "drop_reason", "empty" if reopening == "empty" else "no_result" + ) + info( + f"適用ラウンド {group['apply_round']} は開きません" + f"({'項目なし' if reopening == 'empty' else '試行の上限'})" + ) + continue + return group, reopening + return None, reopening - # **`applied` の群も開き直す。** 適用は取り込んだが検証まで進めずに落ちた場合、 - # 飛ばすとその群の項目が採用でも取り消しでもないまま残る。再開できることは - # 収束ループの前提である。 - opened = next( - (g for g in groups if g.get("status") in {"pending", "applied"}), None - ) - if opened is None: - info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") - sys.exit(1) +def _prepare_apply_entry( + state: dict[str, Any], entry: dict[str, Any], + opened: dict[str, Any], reopening: str, +) -> None: + """開いた群の状態に応じて、提案ラウンドの適用の状態を組み立てる。""" entry["apply_round"] = opened["apply_round"] - if opened.get("status") == "pending": + if opened.get("status") == "pending" and reopening == "open": # 起点は**オーケストレータ側で**確定させる。実装担当の申告に委ねると、 # 欠落・不正時に範囲検査が無効になり、過去の任意のコミットが実在扱いになる。 head = git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) opened["base_sha"] = head + opened["attempt"] = attempt_of(opened) + 1 entry["apply_base_sha"] = head entry["fix_rounds"] = 0 entry["apply"] = { @@ -313,10 +343,38 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: "applied": [], "failed": [], "base_sha": head, "head_sha": None, "merged_at": None, } + elif opened.get("status") == "pending": + # 開いたまま閉じていない試行の再開。**起点も試行の番号も動かさない。** + info(f"↻ 適用ラウンド {opened['apply_round']} の試行を再開します") + entry["apply_base_sha"] = opened.get("base_sha") else: # 取り込み済みの群を開き直した。**起点も修正の回数も動かさない。** info(f"↻ 適用ラウンド {opened['apply_round']} は取り込み済みです(検証から再開)") entry["apply_base_sha"] = opened.get("base_sha") + + +def cmd_next_apply_round(args: argparse.Namespace) -> None: + """Step 4 — 次の適用ラウンドを開き、実装担当と対象の項目を返す。 + + 終了コード: 0 = 群を開いた / 1 = 残りの群が無い(提案ラウンドへ戻る)。 + + **群の起点はここで確定させる。** 後続の群は先行の群を適用した後の作業ツリーを + 読むため、起点はその時点の HEAD になる。取り消しの範囲もこの起点で決まる。 + + **修正ラウンドの数え直しも群ごとである。** `--max-fix-rounds` は 1 つの適用 + ラウンドあたりの上限だからである。 + """ + path, state = load_state(args.id) + entry = round_of(state, args.round) + groups = apply_groups(entry) + + opened, reopening = _select_next_apply_group(groups) + if opened is None: + statefile.save(path, state) + info(f"提案ラウンド {args.round} の適用ラウンドは残っていません") + sys.exit(1) + + _prepare_apply_entry(state, entry, opened, reopening) state["phase"] = "apply" statefile.save(path, state) @@ -334,10 +392,89 @@ def cmd_next_apply_round(args: argparse.Namespace) -> None: ) +def _check_already_merged_apply(ctx: _ApplyExecutionContext) -> bool: + """取り込み済み判定を行い、再実行を制御する。処理済みなら True を返す。 + + **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った + 取り消しコミットが「未割当」と判定され、群ごと取り消してしまう。 + """ + record = ctx.entry.get("apply") or {} + if record.get("merged_at") and record.get("apply_round", ctx.group["apply_round"]) \ + == ctx.group["apply_round"]: + applied_before = record.get("applied") or [] + info( + f"↻ 適用ラウンド {ctx.group['apply_round']} の適用は取り込み済みです" + f"(採用 {len(applied_before)} 件 / 失敗 " + f"{len(record.get('failed') or [])} 件)" + ) + if not applied_before: + # **採用 0 件の群は取り消し済みに直す**(#592)。残したままだと、 + # 次に群を開く操作がこの群を選び直して担当を起動し続ける。 + ctx.group["status"] = "dropped" + ctx.group.setdefault("drop_reason", "empty") + ctx.state["phase"] = phase_after_group(ctx.entry) + if not ctx.args.dry_run: + statefile.save(ctx.path, ctx.state) + sys.exit(2) + return True + return False + + +def _verify_baseline_test_gate(ctx: _ApplyExecutionContext) -> None: + """着手前テスト結果 (baseline) を検証し、成功でなければ適用をブロックする。 + + **着手前のテストの確認は、結果を読むより先に行う。** 成功と確認できていない + 状態で採ると、壊したのか元から壊れていたのかを判別する手段が無い。`red` だけ + でなく `unknown`(確認していない)も拒否する。 + """ + baseline = ctx.state.get("baseline_test") or {} + if baseline.get("status") != "green": + _block_group_items(ctx) + die( + f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" + "適用へ着手しません(全項目を blocked)", + code=4, + ) + + +def _finalize_apply_result( + ctx: _ApplyExecutionContext, + record: dict[str, Any], + failed: list[str], +) -> None: + """検証結果に応じて状態更新、取り消し、または公開プッシュを反映する。""" + # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の + # つもりで実行した利用者の進行が壊れる。 + if ctx.args.dry_run: + if failed: + drop_items(ctx.state, ctx.entry, failed, dry_run=True) + info("(dry-run)状態ファイルは更新していません") + applied = list(record["applied"]) + elif failed: + # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 + applied = _apply_drop(ctx.path, ctx.state, ctx.entry, ctx.group, failed) + else: + # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 + # ここで公開しないと Pull Request 上の差分が古いままになる。 + ctx.group["status"] = "applied" + # 次は `verify-round` がテストで検証する。ここではまだ群を閉じない。 + ctx.state["phase"] = "verify" + record["merged_at"] = statefile.now() + # 保留の印・保存・push・印の解除は 1 か所が持つ(`push_with_retry_marker`)。 + push_with_retry_marker(ctx.path, ctx.state, ctx.entry) + applied = list(record["applied"]) + + if not applied: + info("この適用ラウンドは取り消しました。検証は行いません") + sys.exit(2) + + def cmd_merge_apply(args: argparse.Namespace) -> None: """Step 4 — 適用ラウンド 1 つ分の適用結果を検証して取り込む。 - 終了コード: 0 = 取り込んだ / 2 = この群を取り消した(次の群へ進む)。 + 終了コード: 0 = 取り込んだ / 2 = この群を取り消した、または担当を替えて開き直す + (次の群へ進む) / 4 = 着手前のテストが成功と確認できていない・範囲を確定 + できない・群が無い。 **適用そのものが通らないときは修正ラウンドを回さない**(競合・対象が消えて いる・手順を外れた)。修正ラウンドはテストの失敗を直す工程であり、前提その @@ -354,22 +491,21 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: discard_impl_leftovers(state, state["worktrees"]["work"]) _resume_incomplete_apply(path, state, entry) - # **叩き直しても同じ判定を返す。** 取り込み済みで再実行すると、前回作った - # 取り消しコミットが「未割当」と判定され、群ごと取り消してしまう。 - record = entry.get("apply") or {} - if record.get("merged_at") and record.get("apply_round", group["apply_round"]) \ - == group["apply_round"]: - applied_before = record.get("applied") or [] - info( - f"↻ 適用ラウンド {group['apply_round']} の適用は取り込み済みです" - f"(採用 {len(applied_before)} 件 / 失敗 " - f"{len(record.get('failed') or [])} 件)" - ) - if not applied_before: - sys.exit(2) + if _check_already_merged_apply(ctx): return - payload, commit_range = _load_apply_context(ctx) + _verify_baseline_test_gate(ctx) + + scope = _apply_scope(ctx) + if already_closed(scope): + info("↻ この試行は結果なしとして記録済みです") + sys.exit(2) + + outcome = read_result(state, scope.impl, "apply", args.round) + if outcome.payload is None: + _close_failed_attempt(ctx, scope, outcome) + + payload, commit_range = _load_apply_context(ctx, outcome.payload) reported, unknown_ids = _collect_apply_reports(payload, group) @@ -382,30 +518,7 @@ def cmd_merge_apply(args: argparse.Namespace) -> None: ) record = _record_apply_result(entry, group, commit_range, applied, failed, payload) - - # `--dry-run` では git も状態ファイルも触らない。片方だけ進むと、確認の - # つもりで実行した利用者の進行が壊れる。 - if args.dry_run: - if failed: - drop_items(state, entry, failed, dry_run=True) - info("(dry-run)状態ファイルは更新していません") - applied = list(record["applied"]) - elif failed: - # `merged_at` は `_apply_drop` が取り消しの完了時点で立てる。 - applied = _apply_drop(path, state, entry, group, failed) - else: - # **全項目が通ったときも進行側が公開する。** 実装担当は push しないため、 - # ここで公開しないと Pull Request 上の差分が古いままになる。 - group["status"] = "applied" - # 次は `verify-round` がテストで検証する。ここではまだ群を閉じない。 - state["phase"] = "verify" - record["merged_at"] = statefile.now() - # 保留の印・保存・push・印の解除は 1 か所が持つ(`push_with_retry_marker`)。 - push_with_retry_marker(path, state, entry) - - if not applied: - info("この適用ラウンドは取り消しました。検証は行いません") - sys.exit(2) + _finalize_apply_result(ctx, record, failed) def _record_apply_result( @@ -444,26 +557,120 @@ def _block_group_items(ctx: _ApplyExecutionContext) -> None: statefile.save(ctx.path, ctx.state) -def _load_apply_context( - ctx: _ApplyExecutionContext, -) -> tuple[dict[str, Any], _ApplyCommitRange]: - impl = ctx.group.get("impl") or ctx.entry["impl"] - result = result_path(ctx.state, impl, stem_for(impl, "apply", ctx.state["id"], ctx.args.round)) - payload = read_result(result, impl) +def _apply_scope(ctx: _ApplyExecutionContext) -> IntakeScope: + """適用の取り込み 1 回分の範囲の値。 + + 起点は提案ラウンドの控えが持ち、群の起点も同じ値へ揃える。結末の記録は群が + 持つ。**試行の番号が 0 なら 1 として扱う**(この版より前に開いた群の再開)。 + """ + group = ctx.group + return IntakeScope( + holder=ctx.entry, + base_key="apply_base_sha", + records=group, + phase="apply", + attempt=attempt_of(group) or 1, + impl=group.get("impl") or ctx.entry["impl"], + label=f"R{ctx.entry['round']}-A{group['apply_round']}", + mirror=group, + ) - record_observed_model(ctx.entry, "impl", impl, ctx.state, "apply", ctx.args.round) - # 着手前のテストが**成功と確認できていない限り**適用結果を採らない。 - # `red` だけでなく `unknown`(確認していない)も拒否する。確認していない状態を - # 通すと、「壊したのか元から壊れていたのか」を判別する手段が無いまま進む。 - baseline = ctx.state.get("baseline_test") or {} - if baseline.get("status") != "green": +def _switch_apply_impl(state: dict[str, Any], group: dict[str, Any]) -> bool: + """結果を残さなかった群の担当を、次の輪番の別の担当へ替える。替えたら真。 + + **1 つ進めるだけにしない。** 輪番は参加者の数で 1 周するため、1 つ先が同じ担当 + になることがある。その群で失敗した担当のどれとも違う担当が出た最初の番号を採る。 + 参加者の数だけ進めても出なければ、替える先が無い(#728 の決定 8)。 + """ + tried = { + record.get("impl") for record in (group.get("failed_attempts") or []) + if record.get("phase") == "apply" + } + tried.add(group.get("impl")) + seq = safe_int(state.get("apply_seq")) + for _ in range(len(state.get("runtimes") or [])): + seq += 1 + impl, requested = impl_for_seq(state, seq) + if impl in tried: + continue + state["apply_seq"] = seq + group["impl"] = impl + group["impl_model"] = {"requested": requested, "observed": None} + info(f"↻ 適用ラウンド {group['apply_round']} の担当を {impl} へ替えます") + return True + return False + + +def _no_result_reason(group: dict[str, Any]) -> str: + """見送りの理由。どの担当がどの理由で結果を残さなかったかを並べる。""" + trail = " → ".join( + f"{record.get('impl')}: {record.get('reason')}" + for record in (group.get("failed_attempts") or []) + if record.get("phase") == "apply" + ) + return f"実装担当が結果を残しませんでした({trail})" + + +def _drop_group_without_result(ctx: _ApplyExecutionContext) -> None: + """結果を残せないまま上限に達した群を取り消し、項目を見送りへ入れる。""" + reason = _no_result_reason(ctx.group) + info(f"❌ {reason}") + for item_id in ctx.group["items"]: + item = find_item(ctx.state, item_id, required=False) + if item is None: + continue + item["status"] = "abandoned" + item["failure_reason"] = reason + ctx.group["status"] = "dropped" + ctx.group["drop_reason"] = "no_result" + ctx.entry["apply"] = { + "apply_round": ctx.group["apply_round"], + "applied": [], "failed": list(ctx.group["items"]), + "base_sha": ctx.entry.get("apply_base_sha"), + "head_sha": None, + "merged_at": statefile.now(), + } + ctx.state["phase"] = phase_after_group(ctx.entry) + _defer_abandoned_items(ctx.state, ctx.group) + + +def _close_failed_attempt( + ctx: _ApplyExecutionContext, scope: IntakeScope, outcome: Any +) -> None: + """適用担当が結果を残さなかった試行を閉じる。**必ず終了する。** + + 取り消しと記録を共通の手順へ通したあと、開き直しの判定で担当を替えるか群ごと + 取り消すかを決める。替える先が無いときだけ、起動し直しの可否で決める。 + """ + if ctx.args.dry_run: + info("(dry-run)適用結果がありません。状態ファイルは更新していません") + sys.exit(2) + closed = close_without_result(ctx.path, ctx.state, scope, outcome) + if closed.range_unknown: _block_group_items(ctx) die( - f"着手前のテストが成功と確認できていません(status={baseline.get('status')})。" - "適用へ着手しません(全項目を blocked)", - code=2, + "適用の範囲を確定できませんでした" + f"(起点 {ctx.entry.get('apply_base_sha')})。検証できない適用は採りません", + code=4, ) + ctx.group["attempt"] = scope.attempt + drop = group_reopening(ctx.group) != "open" + if not drop and not _switch_apply_impl(ctx.state, ctx.group): + # 替える先が無い。起動し直しても解けない結末(利用上限)なら、同じ担当で + # もう一度起動しても待ちと相手の枠を使うだけなので、ここで取り消す。 + drop = not closed.relaunch_same_agent + if drop: + _drop_group_without_result(ctx) + statefile.save(ctx.path, ctx.state) + sys.exit(2) + + +def _load_apply_context( + ctx: _ApplyExecutionContext, payload: dict[str, Any], +) -> tuple[dict[str, Any], _ApplyCommitRange]: + impl = ctx.group.get("impl") or ctx.entry["impl"] + record_observed_model(ctx.entry, impl, ctx.state, "apply", ctx.args.round) # 検証の材料は git から取る。結果ファイルから使うのは # 「どのコミットがこの群のものか」という対応付けだけ。 @@ -479,7 +686,7 @@ def _load_apply_context( "適用の範囲を確定できませんでした" f"(起点 {ctx.entry.get('apply_base_sha')} / HEAD {head_sha})。" "検証できない適用は採りません", - code=2, + code=4, ) return payload, _ApplyCommitRange(work, head_sha, ordered_range, in_range) @@ -560,23 +767,12 @@ def _revert_unverified_apply_round( ) -> None: """検証を通らない適用ラウンドの範囲を取り消し、状態と公開を反映する。""" # 範囲全体を取り消す。どのコミットが安全かを決められない以上、 - # 起点まで戻すのが最も確実である。順序は `revert_item_commits` が - # git の履歴から決め直す。 - whole_round = { - "item_id": f"R{ctx.entry['round']}-A{ctx.group['apply_round']}", - "commits": list(commit_range.ordered_range), - } - if not ctx.args.dry_run: - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push - # できずに終わると、未検証の変更が Pull Request に残ったままになる。 - ctx.entry["pending_push"] = True - statefile.save(ctx.path, ctx.state) - revert_item_commits(ctx.state, whole_round, ctx.args.dry_run) - if not ctx.args.dry_run: - # 取り消し後の状態を新しい起点にする。叩き直しても範囲が空になり、 - # 取り消しコミット自体を「未割当」として再び戻すことがない。 - ctx.entry["apply_base_sha"] = git_out(commit_range.work, ["rev-parse", "HEAD"]) - ctx.group["base_sha"] = ctx.entry["apply_base_sha"] + # 起点まで戻すのが最も確実である。取り消しの本体は 3 つの取り込みで共有する + # (`intake.discard_unverified`)。印を立てる順序も起点の更新もそちらが持つ。 + discard_unverified( + ctx.path, ctx.state, _apply_scope(ctx), commit_range.ordered_range, + dry_run=ctx.args.dry_run, + ) ctx.entry["apply"] = { "apply_round": ctx.group["apply_round"], "applied": [], "failed": list(ctx.group["items"]), @@ -662,22 +858,17 @@ def _record_apply_progress( }) -def _verify_apply_group( +def _collect_apply_group_facts( ctx: _ApplyExecutionContext, commit_range: _ApplyCommitRange, reported: dict[str, dict[str, Any]], -) -> tuple[list[str], list[str]]: - """適用ラウンドをまとめて検証し `(採用, 失敗)` を返す。 +) -> tuple[list[str], list[str], list[dict[str, Any]]]: + """群の申告から `(欠落項目, 申告 SHA, コミット事実)` を組み立てる。 - **判定は全件同時である**(決定 3)。群の中は 1 コミットなので、失敗を項目まで - 特定しても取り消しは分離できない。 + **群の全項目が同じコミットを申告する。** 申告の無い項目は、適用されたことを + 確かめる手がかりが無い。群の中は 1 コミットなので、1 件の欠落が群の全件を + 巻き込む(「群の中の道連れ」)。 """ - scope = ctx.state.get("target_scope") or [] - items = [find_item(ctx.state, i) for i in ctx.group["items"]] - - # **群の全項目が同じコミットを申告する。** 申告の無い項目は、適用されたことを - # 確かめる手がかりが無い。群の中は 1 コミットなので、1 件の欠落が群の全件を - # 巻き込む(「群の中の道連れ」)。 missing = [ i for i in ctx.group["items"] if not reported_shas(reported.get(i) or {}) ] @@ -688,14 +879,33 @@ def _verify_apply_group( commit_range.work, shas, commit_range.in_range, "", ctx.state["head_branch"], safe_int(ctx.state.get("test_timeout"), DEFAULT_TEST_TIMEOUT), ) + return missing, shas, facts + + +def _determine_apply_problem( + ctx: _ApplyExecutionContext, + items: list[dict[str, Any]], + missing: list[str], + facts: list[dict[str, Any]], +) -> str: + """欠落と `verify_apply_round` から、この適用ラウンドの問題点を決める。""" if missing: - problem = ( + return ( f"適用結果に項目がありません: {', '.join(missing)}" "(群の全項目を 1 つのコミットへまとめ、各項目へ同じ SHA を申告します)" ) - else: - problem = verify_apply_round(items, facts, scope) + scope = ctx.state.get("target_scope") or [] + return verify_apply_round(items, facts, scope) + +def _record_apply_group_outcome( + ctx: _ApplyExecutionContext, + items: list[dict[str, Any]], + shas: list[str], + facts: list[dict[str, Any]], + problem: str, +) -> None: + """保留判断・項目状態・進捗を記録し、結果を出力して保存する。""" # **機械で決まらなかったテストの差分を記録する**(#443)。落とさないが、 # 通ったものとしても扱わない。進行側がこれを見て段 2(`judge-test-changes`)を # 起動する。**空でないまま収束させない。** @@ -718,6 +928,22 @@ def _verify_apply_group( ) if not ctx.args.dry_run: statefile.save(ctx.path, ctx.state) + + +def _verify_apply_group( + ctx: _ApplyExecutionContext, + commit_range: _ApplyCommitRange, + reported: dict[str, dict[str, Any]], +) -> tuple[list[str], list[str]]: + """適用ラウンドをまとめて検証し `(採用, 失敗)` を返す。 + + **判定は全件同時である**(決定 3)。群の中は 1 コミットなので、失敗を項目まで + 特定しても取り消しは分離できない。 + """ + items = [find_item(ctx.state, i) for i in ctx.group["items"]] + missing, shas, facts = _collect_apply_group_facts(ctx, commit_range, reported) + problem = _determine_apply_problem(ctx, items, missing, facts) + _record_apply_group_outcome(ctx, items, shas, facts, problem) if problem: return [], list(ctx.group["items"]) return list(ctx.group["items"]), [] @@ -823,6 +1049,82 @@ def _resume_incomplete_apply( flush_pending_push(path, state, entry) +def _pending_judgements_for_round( + entry: dict[str, Any], group_no: int +) -> list[str]: + """この群の保留を取り出す。**全ての群をまとめて解かない。**""" + records = entry.get("pending_test_judgements") + if isinstance(records, dict): + return list(records.get(str(group_no), [])) + return [] + + +def _read_group_judge_verdicts( + state: dict[str, Any], impl: Optional[str], round_no: int, group_no: int, +) -> list[dict[str, Any]]: + """この群を判定した担当の結果ファイルを読み、dict の verdict だけを返す。 + + **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 + 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 + """ + if not impl: + return [] + result = result_path( + state, impl, + f"{impl}-judge-test-changes-r{round_no}-g{group_no}") + if not result.exists(): + return [] + try: + payload = json.loads(result.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError): + payload = {} + found = payload.get("verdicts") + if isinstance(found, list): + return [v for v in found if isinstance(v, dict)] + return [] + + +def _drop_round_on_changed_judgement( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group: dict[str, Any], problem: str, +) -> None: + """`changed` があった群を取り消し、項目へ印を残す。**必ず終了する。**""" + failed = list(group.get("items") or []) + # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 + for item_id in failed: + item = find_item(state, item_id, required=False) + if item: + item["status"] = "abandoned" + item["failure_reason"] = problem + _apply_drop(path, state, entry, group, failed) + # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた + # 分まで捨てない。 + record_pending_judgements(entry, group.get("apply_round") or 1, []) + statefile.save(path, state) + info(f"❌ 適用ラウンド {group.get('apply_round')}: {problem}") + # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 + sys.exit(2) + + +def _apply_group_judgements( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + group_no: int, verdicts: list[dict[str, Any]], +) -> None: + """当該群だけへ判定を適用し、残りをレビューへ引き継ぐと知らせる。 + + **解くのは、判定が実際に見た群の保留だけである。** 段 2 へ渡すのはその群の + 差分であるため、別の群で同じファイルが残っていてもそちらは解かない。 + """ + remaining = apply_judgements_to_group(entry, group_no, verdicts) + if remaining: + info( + f"{len(remaining)} 件はレビューへ引き継ぎます: " + ", ".join(remaining) + ) + else: + info("この適用群のテストの差分は、期待する振る舞いを変えていません") + statefile.save(path, state) + + def cmd_merge_test_judgements(args: argparse.Namespace) -> None: """段 2(AI エージェント)の答えを取り込む(#443)。 @@ -835,59 +1137,20 @@ def cmd_merge_test_judgements(args: argparse.Namespace) -> None: path, state = load_state(args.id) entry = round_of(state, args.round) # **判定の対象はこの群の保留である。** 全ての群をまとめて解かない。 - records = entry.get("pending_test_judgements") group_of_round = (current_group(entry) or {}).get("apply_round") or 1 - pending = list((records or {}).get(str(group_of_round), [])) \ - if isinstance(records, dict) else [] + pending = _pending_judgements_for_round(entry, group_of_round) if not pending: info("判定を待っているテストはありません") return - # **読むのは、この群を判定した担当の結果だけである。** 全ランタイムを読むと、 - # 前の群で別の担当が返した古い答えが混ざり、今回の `changed` を打ち消す。 impl = (current_group(entry) or {}).get("impl") or entry.get("impl") - verdicts: list[dict[str, Any]] = [] - if impl: - result = result_path( - state, impl, - f"{impl}-judge-test-changes-r{args.round}-g{group_of_round}") - if result.exists(): - try: - payload = json.loads(result.read_text(encoding="utf-8")) - except (OSError, json.JSONDecodeError): - payload = {} - found = payload.get("verdicts") - if isinstance(found, list): - verdicts = [v for v in found if isinstance(v, dict)] + verdicts = _read_group_judge_verdicts( + state, impl, args.round, group_of_round) outcome = merge_test_judgements(pending, verdicts) if outcome["problem"]: - group = current_group(entry) - failed = list(group.get("items") or []) - # **`entry["items"]` は項目 ID の並びである。** 実体は `state["items"]` にある。 - for item_id in failed: - item = find_item(state, item_id, required=False) - if item: - item["status"] = "abandoned" - item["failure_reason"] = outcome["problem"] - _apply_drop(path, state, entry, group, failed) - # **取り消した群の保留だけを消す。** 先行する群でレビューへ引き継ぐと決めた - # 分まで捨てない。 - record_pending_judgements(entry, group.get("apply_round") or 1, []) - statefile.save(path, state) - info(f"❌ 適用ラウンド {group.get('apply_round')}: {outcome['problem']}") - # **終了コードは 2 にする。** 進行側は「取り消した」と読んで次の群へ進む。 - sys.exit(2) + _drop_round_on_changed_judgement( + path, state, entry, current_group(entry), outcome["problem"]) - # **解くのは、判定が実際に見た群の保留だけである。** 段 2 へ渡すのはその群の - # 差分であるため、別の群で同じファイルが残っていてもそちらは解かない。 - group_no = (current_group(entry) or {}).get("apply_round") or 1 - remaining = apply_judgements_to_group(entry, group_no, verdicts) - if remaining: - info( - f"{len(remaining)} 件はレビューへ引き継ぎます: " + ", ".join(remaining) - ) - else: - info("この適用群のテストの差分は、期待する振る舞いを変えていません") - statefile.save(path, state) + _apply_group_judgements(path, state, entry, group_of_round, verdicts) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py index b4de4423..ecbeff79 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/converge.py @@ -11,7 +11,7 @@ import hashlib import pathlib import sys -from typing import Any +from typing import Any, Optional import statefile @@ -31,7 +31,12 @@ collect_commit_facts, commits_in_range, resolved_threads_on_github, - revert_unverified_range, +) +from ..intake import ( + IntakeScope, + already_closed, + close_without_result, + discard_unverified, ) from ..outbound import dropped_line, item_lines, plan_line from ..paths import git_out, load_state, result_path, stem_for @@ -178,6 +183,22 @@ def cmd_should_abandon(args: argparse.Namespace) -> None: sys.exit(2) +def _record_deferred_abandoned_items( + state: dict[str, Any], targets: list[str] +) -> None: + """取り消し対象項目の status を abandoned に更新し、未登録なら deferred_items に追記する。""" + already = {d.get("item_id") for d in state["deferred_items"]} + for item_id in targets: + item = find_item(state, item_id) + item["status"] = "abandoned" + item.setdefault( + "failure_reason", "修正ラウンドの上限に達してもテストが通らなかった") + if item_id in already: + continue + state["deferred_items"].append( + deferred_record(item, item_id, item["failure_reason"])) + + def cmd_abandon_items(args: argparse.Namespace) -> None: """Step 6 — テストが通らなかった適用ラウンドを取り消す。 @@ -219,16 +240,7 @@ def cmd_abandon_items(args: argparse.Namespace) -> None: run_drop(path, state, entry, targets) - already = {d.get("item_id") for d in state["deferred_items"]} - for item_id in targets: - item = find_item(state, item_id) - item["status"] = "abandoned" - item.setdefault( - "failure_reason", "修正ラウンドの上限に達してもテストが通らなかった") - if item_id in already: - continue - state["deferred_items"].append( - deferred_record(item, item_id, item["failure_reason"])) + _record_deferred_abandoned_items(state, targets) # 見送りの記録と印の解除を**同じ保存で**行う。保存してから push するので、 # push が失敗しても記録とローカルの git が食い違わない。 @@ -369,21 +381,17 @@ def _record_accepted_fix_commits( def _revert_invalid_fix_round( path: pathlib.Path, state: dict[str, Any], - entry: dict[str, Any], + scope: IntakeScope, ordered_range: list[str], ) -> set[str]: """検証を通らない修正ラウンドの範囲を取り消し、採用する解決スレッドを返す。 取り消した以上、解決の申告も採らないので**常に空集合を返す**。 """ - # 取り消しの本体は最終ゲートと共有する(`revert_unverified_range`)。控えの - # 形が同じなので、適用ラウンドと最終ゲートで別々に持たない。 + # 取り消しの本体は 3 つの取り込みで共有する(`intake.discard_unverified`)。 # **push は保存のあと。** ここで push して失敗すると、取り消しコミットは # ローカルに残るのに起点の更新が保存されず、叩き直しで二重に取り消してしまう。 - revert_unverified_range( - path, state, entry, ordered_range, - f"R{entry['round']}-fix{entry['fix_rounds'] + 1}", - ) + discard_unverified(path, state, scope, ordered_range) info("⚠ 修正を取り消したため、解決の申告は採用しません") return set() @@ -452,6 +460,7 @@ def _settle_fix_round( path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + scope: IntakeScope, ordered_range: list[str], resolved: set[str], unassigned: list[str], @@ -460,44 +469,111 @@ def _settle_fix_round( ) -> None: """検証結果に応じて修正ラウンドを取り消すか受理し、解決の印を付ける。""" if unassigned or problems: - resolved = _revert_invalid_fix_round(path, state, entry, ordered_range) + resolved = _revert_invalid_fix_round(path, state, scope, ordered_range) else: _record_accepted_fix_commits(state, accepted) _mark_resolved_fix_findings(entry, resolved) -def cmd_merge_fix(args: argparse.Namespace) -> None: - """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。""" - path, state = load_state(args.id) - entry = round_of(state, args.round) - discard_impl_leftovers(state, state["worktrees"]["work"]) - flush_pending_push(path, state, entry) - impl = entry["impl"] - result = result_path(state, impl, stem_for(impl, "fix", state["id"], args.round)) - payload = read_result(result, impl) +def _fix_scope(entry: dict[str, Any], impl: str) -> IntakeScope: + """修正の取り込み 1 回分の範囲の値。 - work = state["worktrees"]["work"] - head_now = git_out(work, ["rev-parse", "HEAD"]) or "" + 起点と公開の保留の印は提案ラウンドの控えが持ち、結末の記録は**その群**が持つ。 + 記録を群に置くのは、担当が群ごとに決まるためである。 + """ + return IntakeScope( + holder=entry, + base_key="fix_base_sha", + records=current_group(entry), + phase="fix", + attempt=safe_int(entry.get("fix_attempts")), + impl=impl, + label=f"R{entry['round']}-fix{safe_int(entry.get('fix_rounds')) + 1}", + ) + + +def _close_failed_fix( + path: pathlib.Path, + state: dict[str, Any], + entry: dict[str, Any], + scope: IntakeScope, + outcome: Any, +) -> None: + """修正の担当が結果を残さなかったときに、取り消して修正ラウンドを進める。 + + **必ず修正ラウンドを進める。** 進めないと見送りの判定が上限に達する条件を + 満たさず、検証と修正を往復し続ける(#647)。起動し直しても解けない結末 + (利用上限)では上限の値まで進め、次の判定で見送りへ移す(#728 の決定 10)。 + """ + closed = close_without_result(path, state, scope, outcome) + if closed.range_unknown: + entry["fix_rounds"] = safe_int(entry.get("fix_rounds")) + 1 + statefile.save(path, state) + die( + "修正の範囲を確定できませんでした" + f"(起点 {entry.get('fix_base_sha')})。検証できない修正は採りません", + code=2, + ) + limit = safe_int(state.get("max_fix_rounds"), 3) + if closed.relaunch_same_agent: + entry["fix_rounds"] = safe_int(entry.get("fix_rounds")) + 1 + else: + entry["fix_rounds"] = limit + statefile.save(path, state) + info(f"修正ラウンド {entry['fix_rounds']} / {limit}") + sys.exit(2) + + +def _fetch_fix_result( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + scope: IntakeScope, impl: str, round_no: int, +) -> tuple[Optional[dict[str, Any]], Optional[str]]: + """修正結果を取得し、`(payload, merge_key)` を返す。 + + 結果を残さなかった試行は `_close_failed_fix` が終了させる。取り込み済みの + 結果なら `(None, None)` を返し、呼び出し側が何もせず戻れるようにする。 + """ + outcome = read_result(state, impl, "fix", round_no) + if outcome.payload is None: + _close_failed_fix(path, state, entry, scope, outcome) + payload = outcome.payload + + result = result_path(state, impl, stem_for(impl, "fix", state["id"], round_no)) merge_key = _fix_merge_key(entry, result) if _already_merged_fix_result(entry, merge_key): - return - merged_keys = entry["fix_merged_keys"] + return None, None + return payload, merge_key - resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) +def _confirm_and_settle_fix( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + scope: IntakeScope, work: str, head_now: str, payload: dict[str, Any], +) -> set[str]: + """Git 範囲を確定し、修正コミットを検証して取り消すか受理する。 + + 採用した解決スレッドの集合を返す(取り込みの通知に使う)。 + """ + resolved = _resolved_fix_thread_ids(payload, state["repo"], state["current_pr"]) baseline = state.get("baseline_test") or {} ordered_range = _resolve_fix_range(path, state, entry, work, head_now) unassigned, problems, accepted = _inspect_fix_commits( state, work, payload, baseline, ordered_range ) _settle_fix_round( - path, state, entry, ordered_range, resolved, unassigned, problems, accepted + path, state, entry, scope, ordered_range, resolved, unassigned, problems, + accepted, ) + return resolved - merged_keys.append(merge_key) - entry["fix_rounds"] += 1 +def _record_and_publish_fix( + path: pathlib.Path, state: dict[str, Any], entry: dict[str, Any], + merge_key: str, payload: dict[str, Any], resolved: set[str], +) -> None: + """取り込み済みの鍵・修正回数・所要時間を記録し、保存して公開する。""" + entry["fix_merged_keys"].append(merge_key) + entry["fix_rounds"] += 1 entry.setdefault("durations", {})["fix"] = ( entry.get("durations", {}).get("fix", 0) + safe_int(payload.get("elapsed_seconds")) @@ -510,3 +586,36 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: f"修正を取り込みました(解決 {len(resolved)} スレッド / " f"修正ラウンド {entry['fix_rounds']})。{plan_line(state)}" ) + + +def cmd_merge_fix(args: argparse.Namespace) -> None: + """Step 6 — 修正結果を取り込み、修正ラウンドを 1 つ進める。 + + 終了コード: 0 = 取り込んだ / 2 = 範囲を確定できない、または担当が結果を + 残さなかった(どちらも修正ラウンドは進む) / 4 = 群が無い。 + """ + path, state = load_state(args.id) + entry = round_of(state, args.round) + discard_impl_leftovers(state, state["worktrees"]["work"]) + flush_pending_push(path, state, entry) + # **担当は群から読む。** 骨組みが起動するのは群の担当であり、提案ラウンドの + # 担当とは限らない。食い違うと結果ファイルを一度も引けない(#728 の決定 10)。 + group = current_group(entry) + impl = group.get("impl") or entry["impl"] + scope = _fix_scope(entry, impl) + if already_closed(scope): + info("↻ この修正の試行は結果なしとして記録済みです") + sys.exit(2) + + payload, merge_key = _fetch_fix_result( + path, state, entry, scope, impl, args.round + ) + if payload is None: + return + + work = state["worktrees"]["work"] + head_now = git_out(work, ["rev-parse", "HEAD"]) or "" + resolved = _confirm_and_settle_fix( + path, state, entry, scope, work, head_now, payload + ) + _record_and_publish_fix(path, state, entry, merge_key, payload, resolved) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py index e42eb409..85f2dd20 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/gate.py @@ -15,10 +15,10 @@ from __future__ import annotations import argparse +import pathlib import sys from typing import Any, Optional -import assignment import statefile from .. import die, info @@ -33,9 +33,15 @@ check_run_result, collect_commit_facts, commits_in_range, - revert_unverified_range, ) -from ..paths import git_out, load_state, result_path, stem_for +from ..intake import ( + IntakeScope, + already_closed, + close_without_result, + discard_unverified, +) +from ..paths import git_out, load_state +from ..rounds import impl_for_seq from ..verify import verify_final_fix_commit from ..vocabulary import DEFAULT_TEST_TIMEOUT from ..verify import unassigned_fix_commits @@ -125,44 +131,73 @@ def _final_fix_impl(state: dict[str, Any], gate: dict[str, Any]) -> str: if impl: return impl seq = safe_int(state.get("apply_seq")) + 1 - impl, _ = assignment.assign(seq, state["host"]) + impl, _ = impl_for_seq(state, seq) state["apply_seq"] = seq gate["impl"] = impl return impl -def cmd_merge_final_fix(args: argparse.Namespace) -> None: - """Step 7 — 最終ゲートの修正結果を取り込む。 +def _final_fix_scope(gate: dict[str, Any], impl: str) -> IntakeScope: + """最終ゲートの修正の取り込み 1 回分の範囲の値。 - **`merge-fix` では代用できない。** あちらは適用ラウンド(群)の控えを読み、 - 範囲の起点・担当・改善項目の 3 つをそこから取る。最終ゲートにはそのどれも無い。 - 実際に流用すると次の 3 つが起きる。 + 起点も結末の記録も最終ゲートの控えが持つ。改善項目にも提案ラウンドにも + 属さないため、群は関わらない。 + """ + rounds = safe_int(gate.get("fix_rounds")) + return IntakeScope( + holder=gate, + base_key="fix_base_sha", + records=gate, + phase="final-fix", + attempt=rounds, + impl=impl, + label=f"final-gate-fix{rounds}", + ) - | 流用したときに起きること | なぜ | - | --- | --- | - | 「起点 None」で止まり修正を取り込めない | 最後の群が検証を通っていれば `fix_base_sha` が無い | - | 正常なコミットまで取り消される | 古い起点が残っていると、そこから HEAD までが範囲になる | - | トレーラーが揃わず全件が不正になる | `Item-Id` を要求するが、最終ゲートの修正は項目に属さない | - 終了コード: 0 = 取り込んだ / 2 = 取り込めなかった(範囲を確定できない)。 - 合否そのものは判定せず、**次の `final-gate` が採った側で 1 度だけ見る**。 +def _close_failed_final_fix( + path: pathlib.Path, + state: dict[str, Any], + gate: dict[str, Any], + scope: IntakeScope, + outcome: Any, +) -> None: + """最終ゲートの修正担当が結果を残さなかったときに、取り消して判定へ戻す。 + + **修正ラウンドは進めない。** 進めるのは次の最終ゲートで、そこが上限を見る。 + 起動し直しても解けない結末(利用上限)だけは上限の値まで進め、次の最終ゲートを + 「取り消さず報告」で終わらせる(#728 の決定 11)。 """ - path, state = load_state(args.id) - gate = state.setdefault("final_gate", {"fix_rounds": 0, "checks": []}) - impl = str(gate.get("impl") or "") - if not impl: + closed = close_without_result(path, state, scope, outcome) + if closed.range_unknown: + statefile.save(path, state) die( - "最終ゲートの修正担当が記録されていません。" - "先に `final-gate` を実行してください", - code=4, + "最終ゲートの修正の範囲を確定できませんでした" + f"(起点 {gate.get('fix_base_sha')})。検証できない修正は採りません", + code=2, ) + if not closed.relaunch_same_agent: + gate["fix_rounds"] = safe_int(state.get("max_fix_rounds"), 3) + statefile.save(path, state) + sys.exit(2) - work = str(state["worktrees"]["work"]) - discard_impl_leftovers(state, work) - flush_pending_push(path, state, gate) - result = result_path(state, impl, stem_for(impl, "final-fix", state["id"])) - payload = read_result(result, impl) +def _collect_final_fix_range( + path: pathlib.Path, + state: dict[str, Any], + gate: dict[str, Any], + scope: IntakeScope, + impl: str, + work: str, +) -> tuple[dict[str, Any], str, list[str]]: + """修正担当の結果と、取り込む範囲(HEAD と起点からのコミット)を確定する。 + + 結果が無いとき・範囲を確定できないときは、ここで終了する。 + """ + outcome = read_result(state, impl, "final-fix") + if outcome.payload is None: + _close_failed_final_fix(path, state, gate, scope, outcome) + payload = outcome.payload head_now = git_out(work, ["rev-parse", "HEAD"]) or "" ordered_range = commits_in_range(work, gate.get("fix_base_sha"), head_now) if ordered_range is None: @@ -173,7 +208,16 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: "検証できない修正は採りません", code=2, ) + return payload, head_now, ordered_range + +def _verify_final_fix_commits( + state: dict[str, Any], + work: str, + payload: dict[str, Any], + ordered_range: list[str], +) -> tuple[list[str], list[str]]: + """申告されたコミットを検証し、未申告のコミットと問題の一覧を返す。""" claimed_shas = reported_shas(payload) unassigned = unassigned_fix_commits(work, claimed_shas, ordered_range) # **テストコマンドは渡さない。** 合否は `final-gate` が採った側で 1 度だけ見る @@ -187,7 +231,20 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: for c in facts ) if p ] - + return unassigned, problems + + +def _apply_final_fix_verdict( + path: pathlib.Path, + state: dict[str, Any], + gate: dict[str, Any], + scope: IntakeScope, + head_now: str, + ordered_range: list[str], + unassigned: list[str], + problems: list[str], +) -> None: + """検証の結果に応じて、修正を取り消すか最終ゲートの記録へ取り込む。""" if unassigned: info( f"❌ どの申告にも含まれていない修正コミットが {len(unassigned)} 件あります" @@ -200,15 +257,63 @@ def cmd_merge_final_fix(args: argparse.Namespace) -> None: # **ここは取り消す。** 「上限に達しても取り消さない」のは*採用した改善項目* # の話で、検証を受けていない修正コミットは別である。取り消せば HEAD は # 最終ゲートが見た地点へ戻り、公開済みの内容と食い違わない。 - revert_unverified_range( - path, state, gate, ordered_range, - f"final-gate-fix{safe_int(gate.get('fix_rounds'))}", - ) + discard_unverified(path, state, scope, ordered_range) else: gate["fix_base_sha"] = head_now gate.setdefault("fix_commits", []).extend(ordered_range) info(f"修正を取り込みました({len(ordered_range)} コミット)") + +def cmd_merge_final_fix(args: argparse.Namespace) -> None: + """Step 7 — 最終ゲートの修正結果を取り込む。 + + **`merge-fix` では代用できない。** あちらは適用ラウンド(群)の控えを読み、 + 範囲の起点・担当・改善項目の 3 つをそこから取る。最終ゲートにはそのどれも無い。 + 実際に流用すると次の 3 つが起きる。 + + | 流用したときに起きること | なぜ | + | --- | --- | + | 「起点 None」で止まり修正を取り込めない | 最後の群が検証を通っていれば `fix_base_sha` が無い | + | 正常なコミットまで取り消される | 古い起点が残っていると、そこから HEAD までが範囲になる | + | トレーラーが揃わず全件が不正になる | `Item-Id` を要求するが、最終ゲートの修正は項目に属さない | + + 終了コード: 0 = 取り込んだ / 2 = 取り込めなかった(範囲を確定できない、または + 担当が結果を残さなかった)。合否そのものは判定せず、**次の `final-gate` が + 採った側で 1 度だけ見る**。 + + **結果を残さなかったときも、作られたコミットは取り消す。** 取り消さずに抜けると、 + 次の最終ゲートがそのコミットを含む先端でテストし、落ちれば起点をそこへ置き直す。 + 未検証の差分が Pull Request に残る(#674)。 + """ + path, state = load_state(args.id) + gate = state.setdefault("final_gate", {"fix_rounds": 0, "checks": []}) + impl = str(gate.get("impl") or "") + if not impl: + die( + "最終ゲートの修正担当が記録されていません。" + "先に `final-gate` を実行してください", + code=4, + ) + + work = str(state["worktrees"]["work"]) + discard_impl_leftovers(state, work) + flush_pending_push(path, state, gate) + + scope = _final_fix_scope(gate, impl) + if already_closed(scope): + info("↻ この最終ゲートの修正の試行は結果なしとして記録済みです") + sys.exit(2) + + payload, head_now, ordered_range = _collect_final_fix_range( + path, state, gate, scope, impl, work, + ) + unassigned, problems = _verify_final_fix_commits( + state, work, payload, ordered_range, + ) + _apply_final_fix_verdict( + path, state, gate, scope, head_now, ordered_range, unassigned, problems, + ) + gate.setdefault("durations", {})["fix"] = ( gate.get("durations", {}).get("fix", 0) + safe_int(payload.get("elapsed_seconds")) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py index 06cc9626..c6dd584c 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/report.py @@ -104,8 +104,8 @@ def cmd_status(args: argparse.Namespace) -> None: _, state = load_state(args.id) print(f"# cross-refactoring rf{state['id']}({state['repo']} #{state['current_pr']})") print(f"ホスト: {state['host']}({state['host_detection']})") - print(f"提案・レビュー: {' / '.join(state['runtimes'])}") - print(f"適用の母集合: {' / '.join(state['impl_capable'])}") + # **母集合は 1 つである**(#727 の決定 5)。提案と適用は同じ参加者で回す。 + print(f"参加者(提案と適用): {' / '.join(state['runtimes'])}") print(f"局面: {state['phase']} / 提案ラウンド {state['outer_round']} " f"/ {state['max_outer_rounds']}") print(f"終了理由: {state.get('final') or '(未終了)'}") @@ -116,6 +116,30 @@ def cmd_status(args: argparse.Namespace) -> None: def cmd_report(args: argparse.Namespace) -> None: """Step 8 — ラウンド表・項目表・見送り項目・指標を出す。""" path, state = load_state(args.id) + _print_header(state) + print() + print("## ラウンド") + print() + print(_round_table(state)) + print() + print("## 改善項目") + print() + print(_item_table(state)) + print() + _print_participants(state) + # **取り消した項目の内訳は書かない**(#436 決定 6-b)。件数だけ述べ、内訳は + # 改修計画へ譲る。同じ一覧を 2 か所に置くと、片方だけが古くなる。 + print() + _print_deferred(state) + if args.metrics: + _print_metrics(state) + # **最後の行に置く**(#662 の AC23)。作業ツリーを消した後に要約を探す手がかりになる。 + print() + _print_run_metrics(path, state) + + +def _print_header(state: dict[str, Any]) -> None: + """見出し行と実行メタ情報(対象範囲・終了理由・改修計画・着手前テスト等)を出す。""" print(f"# cross-refactoring 実行報告 — {state['repo']} #{state['current_pr']}") print() print(f"- ホスト: {state['host']}({state['host_detection']})") @@ -134,55 +158,94 @@ def cmd_report(args: argparse.Namespace) -> None: print(f"- 最終ゲート: {gate.get('mode') or '—'}" f"({gate.get('status') or '未実行'}" f" / 修正 {gate.get('fix_rounds', 0)} 回)") + + +def _print_participants(state: dict[str, Any]) -> None: + """「参加した者」の節を出す(#727 の F6)。 + + 途中から誰を外したか・誰が確認を通らなかったかを、完了報告だけで読めるように + する。参加者の記録を持たない状態ファイル(この変更の前に始めた実行)では + 「記録なし」と出す。 + """ + print("## 参加した者") print() - print("## ラウンド") - print() - print(_round_table(state)) - print() - print("## 改善項目") - print() - print(_item_table(state)) - # **取り消した項目の内訳は書かない**(#436 決定 6-b)。件数だけ述べ、内訳は - # 改修計画へ譲る。同じ一覧を 2 か所に置くと、片方だけが古くなる。 + p = state.get("participants") + if not p: + print("- 使える者: 記録なし") + print() + return + + def _names(values: Any) -> str: + return " / ".join(values) if values else "なし" + + unavailable = p.get("unavailable") or {} + if unavailable: + failed = " / ".join(f"{n}({d})" for n, d in unavailable.items()) + elif p.get("probe_skipped"): + failed = "確認を飛ばした(NDF_SKIP_AUTH_CHECK)" + else: + failed = "なし" + print(f"- 母集合: {_names(p.get('pool'))}") + print(f"- 使える者: {_names(p.get('available'))}") + print(f"- --exclude で外した者: {_names(p.get('excluded'))}") + print(f"- --include で足した者: {_names(p.get('included'))}") + print(f"- 確認を通らなかった者: {failed}") + changes = state.get("resume_changes") or [] + if not changes: + print("- 再開で変えた値: なし") + else: + print("- 再開で変えた値:") + for c in changes: + print(f" - {c.get('at')} {c.get('field')}: " + f"{_change_value(c.get('from'))} → {_change_value(c.get('to'))}") print() + + +def _change_value(value: Any) -> str: + """再開で変えた値の 1 つを 1 行へ収める。参加者の記録は使える者だけを出す。""" + if isinstance(value, dict) and "available" in value: + return " / ".join(value.get("available") or []) or "なし" + return str(value) + + +def _print_deferred(state: dict[str, Any]) -> None: + """見送り節(件数と改修計画への参照)を出す。""" print("## 見送った提案") print() print(f"- 件数: {len(state['deferred_items'])} 件") print(f"- 内訳: 改修計画にある — {plan_reference(state)}") - if args.metrics: - print() - print("# 指標") - print() - print(metrics_lib.format_report(metrics_lib.aggregate(state))) - # **最後の行に置く**(#662 の AC23)。作業ツリーを消した後に要約を探す手がかりになる。 + + +def _print_metrics(state: dict[str, Any]) -> None: + """指標節を出す(`args.metrics` が真のときだけ呼ぶ)。""" + print() + print("# 指標") print() + print(metrics_lib.format_report(metrics_lib.aggregate(state))) + + +def _print_run_metrics(path: pathlib.Path, state: dict[str, Any]) -> None: + """run_metrics の要約 1 行を出す。""" print(run_metrics.report_line(path, state, "cross-refactoring", summary_extra)) def _round_table(state: dict[str, Any]) -> str: + """ラウンド表。**レビュー担当の列を持たない**(#727 の決定 6)。 + + レビュー工程は #436 で消えた。古い状態ファイルがレビュー担当を持っていても出さない。 + """ lines = [ - "| R | 種類 | 実装担当 | モデル | レビュー担当 | モデル | 採用 | 適用 | 見送り | 修正 | 初回承認 |", - "| --- | --- | --- | --- | --- | --- | ---: | ---: | ---: | ---: | --- |", + "| R | 種類 | 実装担当 | モデル | 採用 | 適用 | 見送り | 修正 |", + "| --- | --- | --- | --- | ---: | ---: | ---: | ---: |", ] for entry in state["rounds"]: - reviewers = entry.get("reviewers", []) - reviewer_models = entry.get("reviewer_models") or {} - reviews = entry.get("reviews") or [] - first_approved = "—" - if reviews: - first_approved = ( - "はい" if all(reviews[0].get(r) == "APPROVE" for r in reviewers) else "いいえ" - ) lines.append( f"| {entry['round']} | " f"{'テスト整備' if entry_kind(entry) == TEST else '構造改善'} | " f"{entry.get('impl', '—')} | " f"{models_lib.label((entry.get('impl_model') or {}).get('requested'))} | " - f"{' / '.join(reviewers) or '—'} | " - f"{' / '.join(models_lib.label((reviewer_models.get(r) or {}).get('requested')) for r in reviewers) or '—'} | " f"{entry.get('adopted', 0)} | {len(entry.get('apply', {}).get('applied', []))} | " - f"{len(entry.get('apply', {}).get('failed', []))} | {entry.get('fix_rounds', 0)} | " - f"{first_approved} |" + f"{len(entry.get('apply', {}).get('failed', []))} | {entry.get('fix_rounds', 0)} |" ) return "\n".join(lines) if state["rounds"] else "(ラウンドなし)" diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index 3b4a671d..6857f63f 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -1,7 +1,7 @@ """ラウンドの入口。`init` と `start-round` を持つ。 -対象の Pull Request の文脈・参加する CLI の認証・作業ツリーの用意・状態ファイルの -初期化と、提案ラウンドの開始を扱う。 +対象の Pull Request の文脈・参加者の決定・作業ツリーの用意・状態ファイルの +初期化と再開と、提案ラウンドの開始を扱う。 """ from __future__ import annotations @@ -21,7 +21,7 @@ import statefile from .. import ABORT, die, info -from ..gitfacts import run_with_timeout +from ..gitfacts import run_with_timeout, safe_int from ..paths import ( default_worktree_base, load_state, @@ -31,24 +31,123 @@ tmp_dir_for, ) from ..plan import PLAN_COMMENT, PLAN_FILE, PLAN_NONE, normalize_plan_file -from ..rounds import finish_outer_rounds, STRUCTURE, TEST, entry_kind, round_kind +from ..rounds import ( + STRUCTURE, + TEST, + entry_kind, + finish_outer_rounds, + impl_for_seq, + round_kind, +) from ..scope import require_scope_covers_tests from ..vocabulary import ( + DEFAULT_MAX_TEST_ROUNDS, + DEFAULT_SEVERITY_THRESHOLD, DEFAULT_TEST_TIMEOUT, + IMPL_STALL_MARGIN, REQUIRED_SKILLS, test_vocabulary, vocabulary, ) -def check_auth(runtimes: Iterable[str]) -> dict[str, dict[str, Any]]: - """参加する CLI の認証状態を確かめる。1 つでも欠けたら初期化を中断する。 +# 再開で指定を外す予約語(#727 の決定 15)。足す者・外す者に渡すと一覧を空へ戻す。 +NONE_WORD = "none" + +# 新規の初期化で、未指定の引数を置き換える現行の既定。**引数の既定は `None` にする。** +# 既定値を引数に持たせると、再開で「渡さなかった」と「既定値を渡した」を区別できない +# (#727 の決定 13)。 +NEW_RUN_DEFAULTS: dict[str, Any] = { + "max_outer_rounds": 3, + "max_test_rounds": DEFAULT_MAX_TEST_ROUNDS, + "max_fix_rounds": 3, + "max_items_per_round": 5, + "test_timeout": DEFAULT_TEST_TIMEOUT, + "severity_threshold": DEFAULT_SEVERITY_THRESHOLD, + "workflow_step": False, +} + +# 再開で渡した引数の反映の表(#727 の決定 13)。**状態ファイルに載る引数は、この 2 つの +# 表のどちらかに必ず載る。** `replace` は状態へ書いて記録へ積み、`notify` は状態と違う +# ときだけ「反映しない」と知らせる。 +RESUME_REPLACE_FIELDS = tuple( + statefile.ResumeField(key, key, "replace") + for key in ("max_outer_rounds", "max_test_rounds", "max_fix_rounds", + "max_items_per_round", "test_timeout") +) +RESUME_NOTIFY_FIELDS = ( + statefile.ResumeField("host", "host", "notify"), + statefile.ResumeField("scope", "target_scope", "notify"), + statefile.ResumeField("model", "models", "notify"), + statefile.ResumeField("baseline_test", "baseline_test", "notify"), + statefile.ResumeField("ci_check", "ci_check", "notify"), + statefile.ResumeField("severity_threshold", "severity_threshold", "notify"), + statefile.ResumeField("sync_command", "sync_command", "notify"), + statefile.ResumeField("plan_file", "plan_file", "notify"), + statefile.ResumeField("workflow_step", "workflow_step", "notify"), + statefile.ResumeField("worktree_root", "worktree_root", "notify"), +) + + +def runtime_list(value: str) -> list[str]: + """`--exclude` / `--include` の型。カンマ区切りの 4 つの名前、または `none`。""" + names = [n.strip() for n in value.split(",") if n.strip()] + if not names: + raise argparse.ArgumentTypeError("名前を 1 つ以上指定してください") + for name in names: + if name != NONE_WORD and name not in assignment.ALL_RUNTIMES: + raise argparse.ArgumentTypeError( + f"{'/'.join(assignment.ALL_RUNTIMES)} か {NONE_WORD} を指定してください: {name}") + return names - 実装は共通層(`lib/auth.py`)にある。**この工程の中断は終了コード 4 である**ため、 - 出力と中断の手段をここから渡す。 + +def _names_arg(args: argparse.Namespace, option: str) -> Optional[list[str]]: + """`--include` / `--exclude` を平らな一覧へ直す。未指定は `None`、`none` は空。 + + `action="append"` の入れ子を平らにし、`--exclude agy --exclude kiro` と + `--exclude agy,kiro` を同じにする。`none` と名前の混在は中断する。 """ - return auth.check_auth(runtimes, info=info, die=die) + raw = getattr(args, option, None) + if raw is None: + return None + names: list[str] = [] + for group in raw: + names.extend(group if isinstance(group, list) else [group]) + if NONE_WORD in names: + if len(names) > 1: + die(f"--{option} に {NONE_WORD} と名前を同時に指定できません: {', '.join(names)}") + return [] + return names + + +def resolve_participants( + host: str, include: list[str], exclude: list[str], require_all: bool, +) -> dict[str, Any]: + """参加者を決め、状態ファイルの `participants` を返す(#727 の決定 2〜5)。 + 母集合の既定は `refactor_pool(host)`(codex / kiro とホスト)。確認は止めない確認 + (`auth.probe_auth`)で、通らない者は外して続ける。名前の矛盾・全員を要する指定で + 欠け・使える者が 0 者は、この工程の中断(終了コード 4)へ写す。状態ファイルは + この関数の後に書かれるため、失敗したときは作られも書き換えられもしない。 + """ + try: + pool = assignment.refactor_pool(host) + resolved = assignment.resolve_participants( + pool, host=host, include=include, exclude=exclude, + probe=lambda names: auth.probe_auth(names, info=info), + require_all=require_all, + ) + except assignment.AssignmentError as e: + die(str(e)) + raise + info(f"ホスト: {host} / 母集合: {' / '.join(pool)}" + f" / 使える者: {' / '.join(resolved.available) or 'なし'}") + for name, reason in resolved.unavailable.items(): + info(f"⚠ {name} を担当から外しました({reason})") + if not resolved.available: + die(f"使える者がいません: 参加者の全員が確認を通りませんでした" + f"({' / '.join(f'{n}: {d}' for n, d in resolved.unavailable.items())})") + return resolved.to_state() def _apply_post_event(state: dict[str, Any], is_own_pr: bool) -> None: @@ -176,10 +275,8 @@ class InitialContext: tmp_dir: pathlib.Path host: str detection: str - runtimes: list[str] - impl_capable: list[str] + participants: dict[str, Any] model_spec: dict[str, Optional[str]] - auth: dict[str, dict[str, Any]] baseline: dict[str, Any] @@ -193,6 +290,7 @@ def _build_initial_state( (`cmd_init`)が済ませたうえで値として渡す。この関数が持つのは、状態ファイルに 何という鍵で何を残すかだけである。 """ + runtimes = list(ctx.participants["available"]) return { "id": args.pr, "started_at": statefile.now(), @@ -203,16 +301,17 @@ def _build_initial_state( "worktree_root": str(ctx.root), "worktrees": { "work": str(ctx.work), - **{r: str(ctx.root / r) for r in ctx.runtimes}, + **{r: str(ctx.root / r) for r in runtimes}, }, "tmp_dir": str(ctx.tmp_dir), "target_scope": list(args.scope), "host": ctx.host, "host_detection": ctx.detection, - "runtimes": ctx.runtimes, - "impl_capable": ctx.impl_capable, + # **提案の対象と適用の輪番が同じ一覧を読む**(#727 の決定 5)。使える者と同じ値。 + "runtimes": runtimes, + "participants": ctx.participants, + "resume_changes": [], "models": ctx.model_spec, - "auth": ctx.auth, # 提案プロンプトへ許容値をそのまま列挙するために持たせる。 # 定義は検証側(この CLI)にあり、状態ファイル経由で起動側へ渡す。 "vocabulary": vocabulary(), @@ -253,10 +352,11 @@ def _build_initial_state( def cmd_init(args: argparse.Namespace) -> None: - """Step 0 — ホストと母集合を確定し、作業ディレクトリ root と状態を用意する。 + """Step 0 — ホストと参加者を確定し、作業ディレクトリ root と状態を用意する。 - **提案・レビューの母集合(全 − ホスト)と適用の母集合(全 − agy)を - 別々に確定する。** 両者は重なるが一致しない。 + **母集合は 1 つである**(#727 の決定 5)。提案と適用は同じ参加者で回す。参加者は + codex / kiro とホストを既定とし、足す者・外す者で変える。確認を通らない者は外して + 続ける。前回の状態が残っていれば再開し、渡した引数を反映の表に従って扱う。 """ try: host, detection = assignment.detect_host(args.host) @@ -268,16 +368,8 @@ def cmd_init(args: argparse.Namespace) -> None: except models_lib.ModelSpecError as e: die(str(e)) return - - runtimes = assignment.review_pool(host) - impl_capable = assignment.impl_pool() - if host in runtimes: - die(f"提案・レビューの母集合にホスト {host} が含まれています(判定の誤り)") - _warn_unmeasurable_models(model_spec, set(runtimes) | set(impl_capable)) - - # **認証は作業ディレクトリを作る前に確かめる。** 未認証のまま進むと、 - # 参加者が欠けた構成のまま最後まで走り切ってしまう。 - auth = check_auth(sorted(set(runtimes) | set(impl_capable))) + include = _names_arg(args, "include") + exclude = _names_arg(args, "exclude") # リポジトリ名は git の設定から求め、Pull Request の応答で確かめる(#271)。 repo, base_branch, head_branch, is_own_pr, author = _fetch_pr_context(args.pr) @@ -305,12 +397,19 @@ def cmd_init(args: argparse.Namespace) -> None: if state_file.exists(): state = statefile.load(state_file) if state.get("final") is None: - info(f"↻ 前回中断した状態から再開します(提案ラウンド {state.get('outer_round', 0)})") - _apply_post_event(state, is_own_pr) - statefile.save(state_file, state) - _emit_init(state) + _resume(state_file, state, args, model_spec, include, exclude, is_own_pr) return + for key, value in NEW_RUN_DEFAULTS.items(): + if getattr(args, key, None) is None: + setattr(args, key, value) + + # **確認は着手前のテストより先に行う。** 使える者がいなければ、テストに時間を + # 使わずに止める。 + participants = resolve_participants( + host, include or [], exclude or [], bool(getattr(args, "require_all", None))) + _warn_unmeasurable_models(model_spec, participants["available"]) + baseline = _run_baseline_test(args.baseline_test, work, args.test_timeout) context = InitialContext( @@ -322,10 +421,8 @@ def cmd_init(args: argparse.Namespace) -> None: tmp_dir=tmp_dir, host=host, detection=detection, - runtimes=runtimes, - impl_capable=impl_capable, + participants=participants, model_spec=model_spec, - auth=auth, baseline=baseline, ) state = _build_initial_state(args, context) @@ -336,11 +433,81 @@ def cmd_init(args: argparse.Namespace) -> None: statefile.save(state_file, state) info(f"✅ 状態を初期化しました: {state_file}") info(f" ホスト: {host}({detection})") - info(f" 提案・レビュー: {' / '.join(runtimes)}") - info(f" 適用の母集合: {' / '.join(impl_capable)}") + info(f" 参加者(提案と適用): {' / '.join(state['runtimes'])}") + _emit_init(state) + + +def _resume( + state_file: pathlib.Path, + state: dict[str, Any], + args: argparse.Namespace, + model_spec: dict[str, Optional[str]], + include: Optional[list[str]], + exclude: Optional[list[str]], + is_own_pr: bool, +) -> None: + """前回中断した状態から再開する(#727 / #648 の決定 13〜16)。 + + 上限は渡せば反映し、状態に載る他の引数は状態と違えば知らせる。足す者・外す者・ + 全員を要する指定のどれかを渡したときだけ確かめ直し、**渡さなかった値は記録から + 補う**。作り直しは `resume_changes` に 1 件として積む。作り直しが失敗したときは + 書き込みの前に中断するため、状態ファイルは変わらない。 + """ + info(f"↻ 前回中断した状態から再開します(提案ラウンド {state.get('outer_round', 0)})") + for line in statefile.apply_resume_args(state, args, RESUME_REPLACE_FIELDS): + info(line) + view, given = _notify_view(state, args, model_spec) + for line in statefile.apply_resume_args(view, given, RESUME_NOTIFY_FIELDS): + info(line) + + require_all = getattr(args, "require_all", None) + if include is not None or exclude is not None or require_all is not None: + recorded = state.get("participants") or {} + participants = resolve_participants( + str(state["host"]), + include if include is not None else list(recorded.get("included") or []), + exclude if exclude is not None else list(recorded.get("excluded") or []), + bool(require_all) if require_all is not None else bool(recorded.get("require_all")), + ) + state.setdefault("resume_changes", []).append({ + "at": statefile.now(), "field": "participants", + "from": state.get("participants"), "to": participants, + }) + state["participants"] = participants + state["runtimes"] = list(participants["available"]) + worktrees = state.setdefault("worktrees", {}) + for runtime in state["runtimes"]: + worktrees.setdefault(runtime, str(pathlib.Path(state["worktree_root"]) / runtime)) + + _apply_post_event(state, is_own_pr) + statefile.save(state_file, state) _emit_init(state) +def _notify_view( + state: dict[str, Any], args: argparse.Namespace, + model_spec: dict[str, Optional[str]], +) -> tuple[dict[str, Any], argparse.Namespace]: + """「知らせる」の比較を、状態と引数の形を揃えて行うための写しを返す。 + + 状態は着手前のテストを `{command, status, checked_at}` で、モデルを全ランタイムの + 辞書で、作業ディレクトリ root を解決済みのパスで持つ。引数の形のまま比べると、 + 同じ値でも「違う」と知らせてしまう。 + """ + view = dict(state) + view["baseline_test"] = (state.get("baseline_test") or {}).get("command") + given = argparse.Namespace(**{f.arg: getattr(args, f.arg, None) for f in RESUME_NOTIFY_FIELDS}) + if given.model is not None: + given.model = model_spec + if given.worktree_root is not None: + given.worktree_root = str(pathlib.Path(given.worktree_root).resolve()) + if given.plan_file is not None: + given.plan_file = normalize_plan_file(given.plan_file) + if given.scope is not None: + given.scope = list(given.scope) + return view, given + + def _emit_init(state: dict[str, Any]) -> None: statefile.emit( ID=state["id"], @@ -348,13 +515,18 @@ def _emit_init(state: dict[str, Any]) -> None: HOST=state["host"], RUNTIMES=" ".join(state["runtimes"]), RUNTIMES_CSV=",".join(state["runtimes"]), - IMPL_POOL=" ".join(state["impl_capable"]), WORKTREE_ROOT=state["worktree_root"], WORK=state["worktrees"]["work"], TMP_DIR=state["tmp_dir"], HEAD_BRANCH=state["head_branch"], BASE_BRANCH=state["base_branch"], SCOPE=" ".join(state["target_scope"]), + # 適用・修正・最終ゲートの修正の担当はテストを 1 回実行し、その間は何も + # 出力しない。テストの制限時間そのままでは、実行中に打ち切られる(#553)。 + IMPL_STALL_TIMEOUT=( + safe_int(state.get("test_timeout"), DEFAULT_TEST_TIMEOUT) + + IMPL_STALL_MARGIN + ), ) @@ -459,7 +631,10 @@ def rounds_of_kind(state: dict[str, Any], kind: str) -> list[dict[str, Any]]: def cmd_start_round(args: argparse.Namespace) -> None: - """Step 2 — ラウンドを開き、実装担当とレビュー担当を返す。 + """Step 2 — ラウンドを開き、実装担当を返す。 + + **レビュー担当は返さない**(#727 の決定 6)。レビュー工程は #436 で消え、Step 7 の + cross-review が担う。 終了コード: 0 = ラウンドを開いた / 1 = 繰り返しが終了済み。 @@ -484,8 +659,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: round_no = len(rounds) + 1 existing = next((r for r in rounds if r["round"] == round_no), None) if existing is None: - impl, reviewers = assignment.assign(round_no, state["host"]) - models = state["models"] + impl, requested = impl_for_seq(state, round_no) existing = { "round": round_no, # **種類はラウンドごとに残す。** 上限を別々に数えるためと、提案の @@ -493,11 +667,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: "kind": kind, "started_at": statefile.now(), "impl": impl, - "impl_model": {"requested": models.get(impl), "observed": None}, - "reviewers": reviewers, - "reviewer_models": { - r: {"requested": models.get(r), "observed": None} for r in reviewers - }, + "impl_model": {"requested": requested, "observed": None}, "proposed": {}, "merged": 0, "adopted": 0, "deferred": 0, "items": [], @@ -521,7 +691,7 @@ def cmd_start_round(args: argparse.Namespace) -> None: seq = len(rounds_of_kind(state, kind)) info( f"=== {label} {seq} / {limit} " - f"(実装 {existing['impl']} / レビュー {' + '.join(existing['reviewers'])})===" + f"(実装 {existing['impl']})===" ) statefile.emit( ROUND=round_no, @@ -536,7 +706,5 @@ def cmd_start_round(args: argparse.Namespace) -> None: PROPOSE_PHASE="propose-tests" if kind == TEST else "propose", IMPL=existing["impl"], IMPL_MODEL=existing["impl_model"]["requested"], - REVIEWERS=" ".join(existing["reviewers"]), - REVIEWERS_CSV=",".join(existing["reviewers"]), MAX_FIX_ROUNDS=state["max_fix_rounds"], ) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py index d2f79144..f4b19107 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py @@ -7,6 +7,7 @@ import json import os import pathlib +import re import shutil import signal import subprocess @@ -15,6 +16,7 @@ import models as models_lib import statefile +from monitor_outcome import LaunchOutcome, read_launch_outcome from . import die, info from .paths import git_out, sh, stem_for @@ -97,14 +99,49 @@ def commit_trailers(work: str, sha: str) -> dict[str, str]: **結果ファイルの `trailers` は使わない。** JSON 上は仕様どおりでも、実際の `git commit` でトレーラーを書き忘れていれば集計に使えない。 + + **末尾の段落から前へ 1 段落ずつ読む**(#553)。実行環境が帰属の段落を後ろへ + 足すと、git の標準の読み方は最後の段落しか見ないため必須の記名が読めなくなる。 + トレーラーの段落と判定しなかった段落で止めるので、散文の中にある記名の形の行は + 拾わない。同じ鍵が 2 つの段落にあれば、末尾に近い段落の値を採る。 + + **1 段落目(題名)は掛けない。** 掛けると `Round: 本文の題名` の形の題名を + トレーラーとして読む。 """ - out = git_out(work, ["log", "-1", "--format=%(trailers:only,unfold)", sha]) + body = git_out(work, ["log", "-1", "--format=%B", sha], strip=False) + paragraphs = re.split(r"\n[ \t]*\n", (body or "").strip("\n")) trailers: dict[str, str] = {} - for line in (out or "").splitlines(): + for paragraph in reversed(paragraphs[1:]): + parsed = _parse_trailer_paragraph(paragraph) + if not parsed: + break + for key, value in parsed.items(): + trailers.setdefault(key, value) + return trailers + + +def _parse_trailer_paragraph(paragraph: str) -> dict[str, str]: + """1 つの段落を git の判定に掛け、トレーラーの段落なら鍵と値を返す。 + + **題名の行を補って渡す。** git はメッセージの 1 行目を題名として読むため、 + 段落だけを渡すと何も返らない(git 2.53.0 で実測)。判定そのものは git に委ね、 + 「何行以上なら記名の段落か」といった規則をこちら側に持たない。 + """ + if not paragraph.strip(): + return {} + result = subprocess.run( + ["git", "interpret-trailers", "--parse"], + input=f"subject\n\n{paragraph}\n", + capture_output=True, text=True, + ) + if result.returncode != 0: + return {} + parsed: dict[str, str] = {} + for line in result.stdout.splitlines(): key, sep, value = line.partition(":") if sep: - trailers[key.strip()] = value.strip() - return trailers + parsed[key.strip()] = value.strip() + return parsed def commit_diff_lines(work: str, sha: str) -> int: @@ -372,6 +409,43 @@ def resolved_threads_on_github(repo: str, pr: int) -> Optional[set[str]]: CHECK_RUNS_PER_PAGE = 100 +def _parse_check_runs(raw_json: Optional[str]) -> Optional[list[dict[str, Any]]]: + """API 出力から check_runs のリストを検証して返す。""" + if not raw_json: + return None + try: + body = json.loads(raw_json) + except json.JSONDecodeError: + return None + runs = body.get("check_runs") if isinstance(body, dict) else None + if not isinstance(runs, list): + return None + return [r for r in runs if isinstance(r, dict)] + + +def _filter_check_runs_by_name( + runs: list[dict[str, Any]], name: str +) -> list[dict[str, Any]]: + """名前が一致する run を選別する。""" + return [ + r for r in runs + if str(r.get("name") or "") == name + ] + + +def _aggregate_check_run_results(matched: list[dict[str, Any]]) -> Optional[str]: + """matched runs を pending・失敗結論・success の順で集約する。""" + if not matched: + return None + if any(str(r.get("status") or "").lower() != "completed" for r in matched): + return "pending" + for run in matched: + conclusion = str(run.get("conclusion") or "").lower() + if conclusion != "success": + return conclusion or "unknown" + return "success" + + def check_run_result(repo: str, sha: str, name: str) -> Optional[str]: """名前が一致した検査ジョブの結果を 1 つの語で返す。 @@ -390,28 +464,11 @@ def check_run_result(repo: str, sha: str, name: str) -> Optional[str]: f"?per_page={CHECK_RUNS_PER_PAGE}"], check=False, ) - if not out: + runs = _parse_check_runs(out) + if runs is None: return None - try: - body = json.loads(out) - except json.JSONDecodeError: - return None - runs = body.get("check_runs") if isinstance(body, dict) else None - if not isinstance(runs, list): - return None - matched = [ - r for r in runs - if isinstance(r, dict) and str(r.get("name") or "") == name - ] - if not matched: - return None - if any(str(r.get("status") or "").lower() != "completed" for r in matched): - return "pending" - for run in matched: - conclusion = str(run.get("conclusion") or "").lower() - if conclusion != "success": - return conclusion or "unknown" - return "success" + matched = _filter_check_runs_by_name(runs, name) + return _aggregate_check_run_results(matched) def revert_item_commits( @@ -450,40 +507,6 @@ def revert_item_commits( return len(shas) -def revert_unverified_range( - path: pathlib.Path, - state: dict[str, Any], - entry: dict[str, Any], - ordered_range: list[str], - label: str, -) -> None: - """検証を通らない範囲を取り消し、`entry` の起点を取り消し後の HEAD へ進める。 - - `entry` は**修正の控えを持つ辞書**である。適用ラウンドの控え(`rounds[]` の - 要素)と最終ゲートの控え(`final_gate`)の両方が同じ 3 つの鍵 - (`pending_push` / `fix_base_sha`)を持つため、どちらからも呼べる。 - `label` は取り消しの単位を人が読むための名前で、git の操作には効かない。 - """ - work = state["worktrees"]["work"] - # **状態へ記録する前に取り消す。** 先に記録すると、取り消し済みのコミットが - # 状態ファイルに残り、後の見送り処理が同じコミットをもう一度取り消そうとする。 - info("検証を通らない変更を残さないため、この修正ラウンドの範囲を取り消します") - # **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに push できずに - # 終わると、未検証の変更が Pull Request に残ったままになる。 - entry["pending_push"] = True - statefile.save(path, state) - revert_item_commits( - state, - {"item_id": label, "commits": list(ordered_range)}, - dry_run=False, - ) - # 取り消し後の状態を新しい起点にし、**その場で保存する**。ここで保存せずに - # 落ちると、次の実行は古い起点から範囲を取り直して取り消しコミット自体を - # 「未申告」と判定し、**取り消しを取り消して**しまう。 - entry["fix_base_sha"] = git_out(work, ["rev-parse", "HEAD"]) - statefile.save(path, state) - - def _reset_hard(work: str, sha: Optional[str]) -> None: """着手前の HEAD へ戻す。半端な履歴を Pull Request に残さないための後始末。""" if sha: @@ -672,6 +695,22 @@ def _record_drop_result( "reverted": len(ordered), "replayed": len(mapping)} +def _drop_legacy_by_item( + state: dict[str, Any], pending: list[str], dry_run: bool = False, +) -> dict[str, Any]: + """起点を記録していない状態ファイル(旧版)で、項目のコミットだけを戻す。 + + 積み直しの起点(`apply_base_sha`)が無いため範囲を確定できない。従来どおり + 項目のコミットを新しい順に取り消すだけで、残す項目の積み直しは行わない。 + """ + info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") + reverted = 0 + for item_id in pending: + reverted += revert_item_commits(state, find_item(state, item_id), dry_run) + return {"mode": "item", "dropped": pending, + "reverted": reverted, "replayed": 0} + + def drop_items( state: dict[str, Any], entry: dict[str, Any], drop_ids: list[str], dry_run: bool = False, @@ -704,13 +743,7 @@ def drop_items( ordered = commits_in_range(work, entry.get("apply_base_sha"), head or "HEAD") if ordered is None: # 起点を記録していない状態ファイル(旧版)では積み直せない。 - # 従来どおり項目のコミットだけを新しい順に戻す。 - info("⚠ 適用の範囲を確定できないため、項目のコミットだけを取り消します") - reverted = 0 - for item_id in pending: - reverted += revert_item_commits(state, find_item(state, item_id), dry_run) - return {"mode": "item", "dropped": pending, - "reverted": reverted, "replayed": 0} + return _drop_legacy_by_item(state, pending, dry_run) owner, keep_ids, replay = _drop_replay_plan(state, entry, pending, ordered) @@ -1079,33 +1112,29 @@ def find_item( return None -def read_result(path: pathlib.Path, runtime: str) -> dict[str, Any]: - """結果ファイルを読む。**JSON オブジェクトでなければ失敗させる。** +def read_result( + state: dict[str, Any], runtime: str, phase: str, round_no: Optional[int] = None +) -> LaunchOutcome: + """起動 1 回の結末を読む。**失敗しない。** + + 結果ファイルの名前の幹をここで 1 度だけ組み、共通層(`read_launch_outcome`)へ + 渡す。3 つの取り込みが同じ組み立てを通るため、監視へ渡した名前の雛形 + (`--stem-template`)と食い違う幹で読むことがない。 - 配列や数値が返ってきたまま呼び出し側へ渡すと、`payload.get(...)` で - `AttributeError` になって進行が止まる。読み込みの時点で弾く。 + **中断も出力もしない。** 結果を読めなかったときに何をするかは、読んだ側 + (取り込み)が終了コードとして決める。ここで `die` すると、未検証のコミットが + 取り消されないまま残る(#728)。 """ - if not path.exists(): - die(f"{runtime} の結果ファイルがありません: {path}", code=2) - try: - payload = json.loads(path.read_text(encoding="utf-8")) - except json.JSONDecodeError as e: - die(f"{runtime} の結果ファイルが JSON として読めません: {e}", code=2) - raise SystemExit(2) - if not isinstance(payload, dict): - die( - f"{runtime} の結果ファイルが JSON オブジェクトではありません" - f"({type(payload).__name__}): {path}", - code=2, - ) - return payload + return read_launch_outcome( + state["tmp_dir"], stem_for(runtime, phase, state["id"], round_no) + ) def record_observed_model( - entry: dict[str, Any], role: str, runtime: str, + entry: dict[str, Any], runtime: str, state: dict[str, Any], phase: str, round_no: Optional[int], ) -> None: - """CLI の出力から実際に使われたモデル名を拾って記録する。 + """実装担当の CLI の出力から、実際に使われたモデル名を拾って記録する。 取れるのは claude だけである。取れないランタイムは `None` のままにし、 報告では既定モデルのラウンドとして集計から区別する。 @@ -1119,13 +1148,8 @@ def record_observed_model( ) if not observed: return - if role == "impl": - entry["impl_model"]["observed"] = observed - requested = entry["impl_model"]["requested"] - else: - entry["reviewer_models"].setdefault(runtime, {"requested": None, "observed": None}) - entry["reviewer_models"][runtime]["observed"] = observed - requested = entry["reviewer_models"][runtime]["requested"] + entry["impl_model"]["observed"] = observed + requested = entry["impl_model"]["requested"] warning = models_lib.mismatch_warning(runtime, requested, observed) if warning: info(warning) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/intake.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/intake.py new file mode 100644 index 00000000..cb39b639 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/intake.py @@ -0,0 +1,175 @@ +"""取り込み 1 回分の共通手順(#728 の決定 4)。 + +適用・修正・最終ゲートの修正の 3 つの取り込みは、結果を読めたときも読めなかった +ときも同じことを行う。起点から HEAD までの範囲を確め、通らなければ取り消し、 +起点を取り消し後の HEAD へ進める。**違うのは起点の鍵と記録先だけ**なので、その差を +`IntakeScope` で受け取り、手順そのものはここ 1 か所に置く。 + +**`commands` 層に置かない。** 3 つのコマンドが読む層だからである(群の進行と同じ +理由。`commands` どうしの取り込みを作らない)。git の事実の読み取り +(`gitfacts`)にも置かない。取り消しは事実の読み取りではなく進行の手順である。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +import pathlib +from typing import Any, Optional + +import statefile + +from . import info +from .gitfacts import ( + commits_in_range, + push_with_retry_marker, + revert_item_commits, +) +from .paths import git_out + + +@dataclass +class IntakeScope: + """取り込み 1 つ分の「どこを見て、どこへ書くか」。 + + `holder` は公開の保留の印と起点を持つ辞書(提案ラウンドの控えか最終ゲートの + 控え)、`records` は結末の記録を持つ辞書(群か最終ゲートの控え)である。 + `mirror` は起点を同じ値に揃える辞書で、適用の取り込みだけが群を渡す。 + """ + + holder: dict[str, Any] + base_key: str + records: dict[str, Any] + phase: str + attempt: int + impl: str + label: str + mirror: Optional[dict[str, Any]] = None + + +@dataclass +class ClosedAttempt: + """結果なしを閉じた結果。 + + `range_unknown` が真のときは取り消しも記録も行っていない。**何で終わるかは + 呼び出し側が決める。** 適用は中断、修正と最終ゲートは既存の扱いへ戻す。 + """ + + reason: str + detail: str + reverted: int = 0 + relaunch_same_agent: bool = True + range_unknown: bool = False + tried: list[str] = field(default_factory=list) + + +def confirm_range(state: dict[str, Any], scope: IntakeScope) -> Optional[list[str]]: + """起点から HEAD までのコミットを新しい順で返す。確定できなければ `None`。 + + **空の配列と `None` を区別する。** 空は「1 件もコミットされていない」、`None` は + 「範囲を確定できなかった」である。混同すると、確定できないときに検査が素通りする。 + """ + work = state["worktrees"]["work"] + head = git_out(work, ["rev-parse", "HEAD"]) or "" + return commits_in_range(work, scope.holder.get(scope.base_key), head) + + +def discard_unverified( + path: pathlib.Path, + state: dict[str, Any], + scope: IntakeScope, + ordered_range: list[str], + dry_run: bool = False, +) -> int: + """検証を受けていない範囲を取り消し、起点を取り消し後の HEAD へ進める。 + + 順序は「印を立てて保存 → 新しい順に取り消す → 起点を書いて保存」である。 + **取り消しへ着手する前に印を立てる。** 取り消しは済んだのに公開できずに終わると、 + 未検証の変更が Pull Request に残ったままになる。**起点はその場で保存する。** + 保存せずに落ちると、次の実行が古い起点から範囲を取り直し、取り消しコミット自体を + 「未申告」と判定して取り消しを取り消してしまう。 + """ + if not ordered_range: + return 0 + info(f"検証を通らない変更を残さないため、{scope.label} の範囲を取り消します") + if not dry_run: + scope.holder["pending_push"] = True + statefile.save(path, state) + revert_item_commits( + state, + {"item_id": scope.label, "commits": list(ordered_range)}, + dry_run=dry_run, + ) + if not dry_run: + head = git_out(state["worktrees"]["work"], ["rev-parse", "HEAD"]) + scope.holder[scope.base_key] = head + if scope.mirror is not None: + scope.mirror["base_sha"] = head + statefile.save(path, state) + return len(ordered_range) + + +def already_closed(scope: IntakeScope) -> bool: + """この工程・この試行番号の結末を既に記録しているか。 + + **記録していれば結果ファイルを読まない。** 叩き直しのたびに読むと、後から + 現れた結果ファイルを、取り消し済みの範囲の申告として取り込んでしまう。 + """ + return any( + record.get("phase") == scope.phase and record.get("attempt") == scope.attempt + for record in (scope.records.get("failed_attempts") or []) + ) + + +def failed_impls(scope: IntakeScope) -> list[str]: + """この工程で結果を残さなかった担当を、記録の順に返す。""" + return [ + str(record.get("impl") or "") + for record in (scope.records.get("failed_attempts") or []) + if record.get("phase") == scope.phase + ] + + +def close_without_result( + path: pathlib.Path, + state: dict[str, Any], + scope: IntakeScope, + outcome: Any, +) -> ClosedAttempt: + """結果なしの起動を閉じる。範囲の確定 → 取り消し → 結末の記録の順で行う。 + + 記録は追記だけで、上書きしない。担当を替えると群の担当は書き換わるが、どの担当が + どの試行で失敗したかは記録から読める。 + """ + reason = str(outcome.reason or "missing") + detail = str(outcome.detail or "") + ordered_range = confirm_range(state, scope) + if ordered_range is None: + return ClosedAttempt( + reason=reason, + detail=detail, + relaunch_same_agent=bool(outcome.relaunch_same_agent), + range_unknown=True, + ) + reverted = discard_unverified(path, state, scope, ordered_range) + scope.records.setdefault("failed_attempts", []).append({ + "phase": scope.phase, + "attempt": scope.attempt, + "impl": scope.impl, + "reason": reason, + "detail": detail, + "at": statefile.now(), + "reverted": reverted, + }) + statefile.save(path, state) + if reverted: + push_with_retry_marker(path, state, scope.holder) + info( + f"⚠ {scope.impl} は結果を残しませんでした({reason})。" + f"取り消したコミットは {reverted} 件です" + ) + return ClosedAttempt( + reason=reason, + detail=detail, + reverted=reverted, + relaunch_same_agent=bool(outcome.relaunch_same_agent), + tried=failed_impls(scope), + ) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py index 8b68979b..3fbaf69d 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py @@ -199,10 +199,8 @@ def _plan_deferred_section(state: dict[str, Any]) -> list[str]: def _plan_round_section(state: dict[str, Any], entry: dict[str, Any]) -> list[str]: """1 ラウンド分の見出しと、そのラウンドの改善項目を並べる。""" - reviewers = " / ".join(entry.get("reviewers") or []) or "—" lines = [ - f"## ラウンド {entry['round']}" - f"(実装 {entry.get('impl', '—')} / レビュー {reviewers})", + f"## ラウンド {entry['round']}(実装 {entry.get('impl', '—')})", "", ] items = [i for i in state.get("items") or [] if i.get("round") == entry["round"]] diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py index 21a71be1..6752482a 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/proposals.py @@ -21,6 +21,27 @@ ) +def _degrade_if_unknown( + value: str, + allowed: Iterable[str], + source: str, + label: str, + location: str, +) -> tuple[str, bool]: + """語彙集合に含まれない値を `unknown` へ降格する。 + + 降格したときは警告を出し `(unknown, True)` を返す。含まれていれば値をそのまま + `(value, False)` で返す。`smell` / `technique` / `severity` の同じ降格ルールと、 + テスト提案の `case` / `level` の降格を 1 箇所に集め、警告文や降格処理の変更が + 散らばらないようにする。`location` は警告に添える位置表記で、構造改善側は + `path#symbol`、テスト側は `target` を渡す。 + """ + if value not in allowed: + info(f"⚠ {source}: 語彙外の{label} `{value}` — unknown へ降格 ({location})") + return "unknown", True + return value, False + + def _normalize_proposal(raw: dict[str, Any], source: str) -> Optional[dict[str, Any]]: """1 件の提案を正規化する。必須項目を欠くものは捨てる。 @@ -37,19 +58,13 @@ def _normalize_proposal(raw: dict[str, Any], source: str) -> Optional[dict[str, smell = str(raw.get("smell") or "").strip() technique = str(raw.get("technique") or "").strip() severity = str(raw.get("severity") or "").strip().lower() - degraded = False - if smell not in SMELLS: - info(f"⚠ {source}: 語彙外の兆候 `{smell}` — unknown へ降格 ({path}#{symbol})") - smell = "unknown" - degraded = True - if technique not in TECHNIQUES: - info(f"⚠ {source}: 語彙外の手法 `{technique}` — unknown へ降格 ({path}#{symbol})") - technique = "unknown" - degraded = True - if severity not in SEVERITY_ORDER: - info(f"⚠ {source}: 語彙外の重要度 `{severity}` — unknown へ降格 ({path}#{symbol})") - severity = "unknown" - degraded = True + smell, smell_degraded = _degrade_if_unknown( + smell, SMELLS, source, "兆候", f"{path}#{symbol}") + technique, technique_degraded = _degrade_if_unknown( + technique, TECHNIQUES, source, "手法", f"{path}#{symbol}") + severity, severity_degraded = _degrade_if_unknown( + severity, SEVERITY_ORDER, source, "重要度", f"{path}#{symbol}") + degraded = smell_degraded or technique_degraded or severity_degraded if degraded: severity = "unknown" @@ -120,17 +135,7 @@ def merge_proposals( `excluded_keys` には過去に見送った項目の鍵を渡す。見送った項目を毎ラウンド 再提案されると収束しないため、対象外として落とす。 """ - merged: dict[tuple[str, ...], dict[str, Any]] = {} - for source, items in proposals.items(): - for raw in items: - norm = _normalize_proposal(raw, source) - if norm is None: - continue - key = _dedupe_key(norm) - if key in merged: - _merge_one(merged[key], norm) - else: - merged[key] = norm + merged = _build_merged(proposals, _normalize_proposal, _merge_one) min_severity = SEVERITY_ORDER.get( threshold, SEVERITY_ORDER[DEFAULT_SEVERITY_THRESHOLD]) @@ -155,6 +160,30 @@ def reject(item: dict[str, Any]) -> Optional[str]: ) +def _build_merged( + proposals: dict[str, list[dict[str, Any]]], + normalize: Callable[[dict[str, Any], str], Optional[dict[str, Any]]], + merge_one: Callable[[dict[str, Any], dict[str, Any]], None], +) -> dict[tuple[str, ...], dict[str, Any]]: + """提案を正規化し、重複排除の鍵ごとに統合した辞書を返す。**種類で分けない。** + + 正規化と統合の関数だけが種類ごとに違う。重複排除の基準(`_dedupe_key`)が + 片方だけ直されて食い違わないよう 1 箇所に置く。 + """ + merged: dict[tuple[str, ...], dict[str, Any]] = {} + for source, items in proposals.items(): + for raw in items: + norm = normalize(raw, source) + if norm is None: + continue + key = _dedupe_key(norm) + if key in merged: + merge_one(merged[key], norm) + else: + merged[key] = norm + return merged + + def _select( merged: dict[tuple[str, ...], dict[str, Any]], *, @@ -208,14 +237,20 @@ def _normalize_test_proposal( info(f"⚠ {source}: path / target の無いテスト項目を無視しました: {raw!r:.120}") return None - case = str(raw.get("case") or "").strip().lower() - level = str(raw.get("level") or "").strip().lower() - if case not in TEST_CASES: - info(f"⚠ {source}: 語彙外の経路 `{case}` — unknown へ降格 ({target})") - case = "unknown" - if level not in TEST_LEVELS: - info(f"⚠ {source}: 語彙外の階層 `{level}` — unknown へ降格 ({target})") - level = "unknown" + case, _ = _degrade_if_unknown( + str(raw.get("case") or "").strip().lower(), + TEST_CASES, + source, + "経路", + target, + ) + level, _ = _degrade_if_unknown( + str(raw.get("level") or "").strip().lower(), + TEST_LEVELS, + source, + "階層", + target, + ) return { "kind": TEST, @@ -263,17 +298,7 @@ def merge_test_proposals( 採否の詰めは `merge_proposals` と同じ `_select` が行う。 """ - merged: dict[tuple[str, ...], dict[str, Any]] = {} - for source, items in proposals.items(): - for raw in items: - norm = _normalize_test_proposal(raw, source) - if norm is None: - continue - key = _dedupe_key(norm) - if key in merged: - _merge_test_one(merged[key], norm) - else: - merged[key] = norm + merged = _build_merged(proposals, _normalize_test_proposal, _merge_test_one) def reject(item: dict[str, Any]) -> Optional[str]: if item["case"] == "unknown" or item["level"] == "unknown": diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py index 857b0cfa..0b4c825c 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/rounds.py @@ -11,12 +11,14 @@ import pathlib -from typing import Any +from typing import Any, Optional +import assignment import statefile -from . import info +from . import die, info from .paths import git_out +from .vocabulary import MAX_APPLY_ATTEMPTS # ラウンドの種類。**宣言の無い状態ファイルは構造改善として読む**(この版より前で # 始めた実行を、再開の時点でテスト整備へ戻さないため)。 @@ -104,9 +106,13 @@ def apply_groups(entry: dict[str, Any]) -> list[dict[str, Any]]: 群を持たない状態ファイル(この版より前)は、**ラウンド全体を 1 つの群**として 読み、その場で記録する。中断から再開したときに、群の単位が実行のたびに 変わらないようにするためである。 + + **鍵が無いときだけ作る。空の配列はそのまま返す。** 採用が 0 件だった提案 + ラウンドは空の配列を書くため、ここで作ると項目が 1 件も無い群が生まれ、 + 担当を起動し続ける(#592)。 """ groups = entry.get("apply_rounds") - if groups: + if groups is not None: return groups entry["apply_rounds"] = [{ "apply_round": 1, @@ -124,12 +130,57 @@ def apply_groups(entry: dict[str, Any]) -> list[dict[str, Any]]: def current_group(entry: dict[str, Any]) -> dict[str, Any]: """進行中の適用ラウンド。まだ開いていなければ最初の群を返す。""" groups = apply_groups(entry) + if not groups: + die("このラウンドには適用ラウンド(群)がありません") current = entry.get("apply_round") or 1 for group in groups: if group.get("apply_round") == current: return group return groups[-1] + +def attempt_of(group: dict[str, Any]) -> int: + """群がいま開いている試行の番号。鍵が無ければ 0(まだ開いていない)。""" + value = group.get("attempt") + return value if isinstance(value, int) and not isinstance(value, bool) else 0 + + +def group_reopening(group: dict[str, Any]) -> str: + """この群をどう扱うか。**中断からの再開と失敗のやり直しを区別する**(#647)。 + + | 値 | 意味 | + | --- | --- | + | `open` | 開いて試行の番号を進める | + | `resume` | 開いたまま閉じていない試行を再開する。番号を進めない | + | `exhausted` | 上限に達した。開かない | + | `empty` | 項目が無い | + + 判定に使うのは群が持つ 2 つだけである。開いた回数(`attempt`)と、結末の記録の + うち工程が適用のものの件数である。**開いた回数だけを数えない。** 進行側が落ちて + 再開しただけで試行が進んでしまう。 + """ + if not (group.get("items") or []): + return "empty" + failed = len([ + record for record in (group.get("failed_attempts") or []) + if record.get("phase") == "apply" + ]) + if failed >= MAX_APPLY_ATTEMPTS: + return "exhausted" + return "resume" if attempt_of(group) > failed else "open" + + +def impl_for_seq(state: dict[str, Any], seq: int) -> tuple[str, Optional[str]]: + """輪番の通し番号から、作業を任せる担当と要求するモデルを引く。 + + **輪番を引く呼び出しはここだけにする**(#728 の決定 9)。読むのは 4 か所 + (ラウンドの開始・群の割り当て・結果なしの試行の交代先・最終ゲートの修正担当) + である。輪番は参加者の一覧(`runtimes`)の中で回す(#727 の決定 5・7)。この + 変更の前に始めた実行の状態ファイルも、適用専用の母集合を読まずに同じ一覧で決める。 + """ + impl = assignment.impl_assign(seq, list(state["runtimes"])) + return impl, (state.get("models") or {}).get(impl) + def phase_after_group(entry: dict[str, Any]) -> str: """この群を終えた後のフェーズ。残りの群があれば適用を続ける。""" remaining = [ diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py index 35bc2388..db5b9b10 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/verify.py @@ -189,6 +189,56 @@ def diff_budget_factor(technique: Optional[str]) -> int: return DIFF_BUDGET_FACTOR +def _verify_test_gap_present( + items: list[dict[str, Any]], facts: list[dict[str, Any]], +) -> Optional[str]: + """テストが乏しい項目を含む群で、現状固定テストの追加が先行しているか。""" + if any(i.get("test_gap") for i in items): + # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 + # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 + if not facts[0].get("touches_tests"): + return ( + "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" + f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" + ) + return None + + +def _verify_diff_budget( + items: list[dict[str, Any]], facts: list[dict[str, Any]], +) -> Optional[str]: + """実差分が、見積の行数から決まる差分予算に収まっているか。""" + estimated = sum(safe_int(i.get("estimated_diff_lines")) for i in items) + factor = max( + (diff_budget_factor(i.get("technique")) for i in items), + default=DIFF_BUDGET_FACTOR, + ) + budget = estimated * factor + actual = sum(int(c.get("diff_lines") or 0) for c in facts) + if budget and actual > budget: + return ( + f"実差分 {actual} 行が差分予算 {budget} 行" + f"(見積 {estimated} 行 × {factor})を超えました(範囲の逸脱)" + ) + return None + + +def _verify_apply_commit_count(facts: list[dict[str, Any]]) -> Optional[str]: + """適用ラウンドのコミットが 1 件に収まっているか。 + + 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が + 申告するのは正しい形なので、重ねた申告では落とさない。 + """ + count = len({c.get("sha") for c in facts}) + if count > 1: + return ( + f"適用ラウンドのコミットが {count} 件あります" + "(残すのは適用ラウンド = 1 コミット。" + "群の中の項目はまとめて 1 つのコミットにします)" + ) + return None + + def verify_apply_round( items: list[dict[str, Any]], facts: list[dict[str, Any]], scope: Optional[Iterable[str]] = None, @@ -221,14 +271,9 @@ def verify_apply_round( if problem: return problem - if any(i.get("test_gap") for i in items): - # テストが乏しいと申告された項目は、現状固定テストの追加が先行していること。 - # 「テストを足した」かどうかは、そのコミットがテストの置き場所を触ったかで見る。 - if not facts[0].get("touches_tests"): - return ( - "テストが乏しい項目を含むのに、現状固定テストの追加が伴っていません" - f"(先頭コミット {facts[0].get('sha', '?')} がテストを触っていません)" - ) + problem = _verify_test_gap_present(items, facts) + if problem: + return problem # **テストの期待値が変わっていないか**(#443)。段 1(機械)で決まるものだけを # ここで落とす。決まらないものは `pending_test_judgements` が集め、進行側が @@ -238,30 +283,12 @@ def verify_apply_round( if problem: return problem - estimated = sum(safe_int(i.get("estimated_diff_lines")) for i in items) - factor = max( - (diff_budget_factor(i.get("technique")) for i in items), - default=DIFF_BUDGET_FACTOR, - ) - budget = estimated * factor - actual = sum(int(c.get("diff_lines") or 0) for c in facts) - if budget and actual > budget: - return ( - f"実差分 {actual} 行が差分予算 {budget} 行" - f"(見積 {estimated} 行 × {factor})を超えました(範囲の逸脱)" - ) + problem = _verify_diff_budget(items, facts) + if problem: + return problem # 粒度は最後に見る。トレーラーや範囲の問題を粒度の失敗で覆い隠さない。 - # 数えるのは**実在するコミットの数**である。同じコミットを群の全項目が - # 申告するのは正しい形なので、重ねた申告では落とさない。 - count = len({c.get("sha") for c in facts}) - if count > 1: - return ( - f"適用ラウンドのコミットが {count} 件あります" - "(残すのは適用ラウンド = 1 コミット。" - "群の中の項目はまとめて 1 つのコミットにします)" - ) - return None + return _verify_apply_commit_count(facts) def commit_limit_for(item: dict[str, Any]) -> int: @@ -415,6 +442,15 @@ def pending_test_judgements(facts: Iterable[dict[str, Any]]) -> list[str]: return undecidable_test_changes(changes) +def _answers_by_path(verdicts: Iterable[dict[str, Any]]) -> dict[str, str]: + """段 2 の答えを、対象ファイルから引ける形にする。""" + return { + str(verdict.get("path")): str(verdict.get("verdict")) + for verdict in verdicts + if isinstance(verdict, dict) and verdict.get("path") + } + + def merge_test_judgements( pending: Iterable[str], verdicts: Iterable[dict[str, Any]], ) -> dict[str, Any]: @@ -428,10 +464,7 @@ def merge_test_judgements( **答えが欠けたものを `unchanged` に倒さない。** 倒すと、判定を返さないことが 通過の手段になる。知らない答えも同じ扱いにする。 """ - answers = { - str(v.get("path")): str(v.get("verdict")) - for v in verdicts if isinstance(v, dict) and v.get("path") - } + answers = _answers_by_path(verdicts) changed = sorted(p for p in pending if answers.get(p) == "changed") if changed: return { @@ -487,14 +520,10 @@ def apply_judgements_to_group( records = entry.get("pending_test_judgements") if not isinstance(records, dict): return [] - answers = { - str(v.get("path")): str(v.get("verdict")) - for v in verdicts if isinstance(v, dict) and v.get("path") - } + answers = _answers_by_path(verdicts) remaining = sorted( path for path in records.get(str(group), []) if answers.get(path) != "unchanged" ) record_pending_judgements(entry, group, remaining) return remaining - diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py index b17d6d63..50fa0c6f 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/vocabulary.py @@ -123,6 +123,16 @@ def vocabulary() -> dict[str, Any]: } +# 1 つの群に対して適用担当を起動し直す上限。**引数を足さない**(#647)。 +# 2 回目は別の担当が試す。2 回とも結果を残さなければ、担当ではなく群の側を疑える。 +# 3 回以上にしても、壊れた CLI に当たる確率が上がるだけである。 +MAX_APPLY_ATTEMPTS = 2 + +# 無進捗と見なすまでの余白。テストの制限時間(`--test-timeout`)へ足した値を +# 起動が `IMPL_STALL_TIMEOUT` として出す。適用と修正の担当はテストを 1 回実行し、 +# その間は何も出力しないため、制限時間そのままでは打ち切られる(#553)。 +IMPL_STALL_MARGIN = 900 + # 適用と修正のコミットに必須のトレーラー。1 つでも欠けたら当該項目を失敗にする。 # 自由文で「codex が実装」と書かせると集計に使えないため、必ずトレーラー形式にする。 REQUIRED_TRAILERS = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") diff --git a/plugins/ndf/skills/cross-refactoring/tests/conftest.py b/plugins/ndf/skills/cross-refactoring/tests/conftest.py index 3e0b6fc9..38695c64 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/conftest.py +++ b/plugins/ndf/skills/cross-refactoring/tests/conftest.py @@ -46,7 +46,7 @@ def refactor() -> types.ModuleType: _MODULES = ( "commands.apply", "commands.converge", "commands.gate", "commands.report", "commands.setup", - "gitfacts", "outbound", "paths", "plan", "proposals", + "gitfacts", "intake", "outbound", "paths", "plan", "proposals", "rounds", "scope", "verify", "vocabulary", ) diff --git a/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py b/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py index 9914d55a..b6d24994 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py +++ b/plugins/ndf/skills/cross-refactoring/tests/crossref_helpers.py @@ -36,7 +36,6 @@ def make_state(tmp_path: pathlib.Path, **overrides: Any) -> pathlib.Path: "host": host, "host_detection": "explicit", "runtimes": runtimes, - "impl_capable": ["claude", "codex", "kiro"], "models": {"claude": None, "codex": None, "agy": None, "kiro": None}, "skills": {"required": ["refactoring", "tdd-cycle", "quality-gates"]}, "max_outer_rounds": 3, diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py index 767986f6..4fe84471 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_abandon_items.py @@ -987,3 +987,147 @@ def test_the_drop_is_reported_as_a_count_only( assert "取り消し 2 件" in out assert "内訳は改修計画にある" in out assert "R1-001 を見送りました" not in out + + +# ---------- 修正の担当が結果を残さないとき(#728 の決定 10) ---------- + +def _fix_state_with_group_impl(tmp_path, group_impl="agy", fix_rounds=0): + """群の担当と提案ラウンドの担当が違う状態。""" + state_path = _state(tmp_path, [_finding("R1-001")], groups=[{ + "apply_round": 1, "impl": group_impl, + "impl_model": {"requested": None, "observed": None}, + "items": ["R1-001", "R1-002"], "status": "applied", + "base_sha": "base0", "head_sha": None, "fix_rounds": fix_rounds, + "attempt": 1, + }]) + state = read_state(state_path) + state["rounds"][0]["fix_rounds"] = fix_rounds + state["rounds"][0]["fix_base_sha"] = "FIX_BASE" + state["rounds"][0]["fix_attempts"] = 1 + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + return state_path + + +def _fix_args(): + return type("A", (), {"id": 130, "round": 1})() + + +def test_merge_fix_reads_the_result_of_the_group_agent( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC23: 群の担当の結果を読む。提案ラウンドの担当の結果ではない。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + fact = _fix_commit() + patch_lib("git_out", lambda work, args, **k: ( + args[-1].replace("^{commit}", "") + if args[:2] == ["rev-parse", "--verify"] else "HEAD_NOW")) + patch_lib("commits_in_range", lambda work, base, head: [fact["sha"]]) + patch_lib("collect_commit_facts", + lambda work, shas, rng, cmd, branch, timeout=None: [fact]) + patch_lib("resolved_threads_on_github", lambda repo, pr: set()) + write_result(state_path, "agy-fix-r1", { + "resolved_thread_ids": [], "elapsed_seconds": 3, + "commits": [{"sha": fact["sha"]}], + }) + + cmd_converge.cmd_merge_fix(_fix_args()) + + assert read_state(state_path)["rounds"][0]["fix_rounds"] == 1 + + +def test_a_missing_fix_result_advances_the_fix_round( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC24: 修正の結果が無ければ、記録を残して修正ラウンドを 1 進める。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + + with pytest.raises(SystemExit) as e: + cmd_converge.cmd_merge_fix(_fix_args()) + + assert e.value.code == 2 + entry = read_state(state_path)["rounds"][0] + assert entry["fix_rounds"] == 1 + records = entry["apply_rounds"][0]["failed_attempts"] + assert [(r["phase"], r["impl"], r["reason"]) for r in records] == [ + ("fix", "agy", "missing")] + + +def test_the_abandon_check_passes_after_the_fix_rounds_run_out( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC24: 上限の回数だけ結果が無ければ、見送りの判定が 0 を返す。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + + for attempt in range(1, 4): + state = read_state(state_path) + state["rounds"][0]["fix_attempts"] = attempt + state_path.write_text(__import__("json").dumps(state), encoding="utf-8") + with pytest.raises(SystemExit): + cmd_converge.cmd_merge_fix(_fix_args()) + + assert read_state(state_path)["rounds"][0]["fix_rounds"] == 3 + cmd_converge.cmd_should_abandon(_fix_args()) + + +def test_the_same_fix_attempt_does_not_advance_the_round_twice( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC25: 検証を挟まず叩き直しても、修正ラウンドは進まない。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + + for _ in range(2): + with pytest.raises(SystemExit) as e: + cmd_converge.cmd_merge_fix(_fix_args()) + assert e.value.code == 2 + + entry = read_state(state_path)["rounds"][0] + assert entry["fix_rounds"] == 1 + assert len(entry["apply_rounds"][0]["failed_attempts"]) == 1 + + +def test_a_usage_limit_on_the_fix_jumps_to_the_cap( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC26: 起動し直しても解けない結末では、修正ラウンドを上限の値にする。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head: []) + (state_path.parent / "agy-fix-r1-monitor.json").write_text( + __import__("json").dumps({"reason": "usage_limit", "detail": "上限"}), + encoding="utf-8") + + with pytest.raises(SystemExit) as e: + cmd_converge.cmd_merge_fix(_fix_args()) + + assert e.value.code == 2 + assert read_state(state_path)["rounds"][0]["fix_rounds"] == 3 + cmd_converge.cmd_should_abandon(_fix_args()) + + +def test_a_missing_fix_result_reverts_the_commits_in_range( + patch_lib, cmd_converge, tmp_path, env_tmp_dir, no_git +): + """AC27: 結果が無くても、起点から先端までのコミットは取り消す。""" + state_path = _fix_state_with_group_impl(tmp_path) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "AFTER_REVERT") + patch_lib("commits_in_range", lambda work, base, head: ["fix2", "fix1"]) + + with pytest.raises(SystemExit): + cmd_converge.cmd_merge_fix(_fix_args()) + + assert [c[-1] for c in no_git if c[:2] == ["git", "revert"]] == ["fix2", "fix1"] + entry = read_state(state_path)["rounds"][0] + assert entry["fix_base_sha"] == "AFTER_REVERT" + assert entry["apply_rounds"][0]["failed_attempts"][0]["reverted"] == 2 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py new file mode 100644 index 00000000..c1da7699 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_attempts.py @@ -0,0 +1,411 @@ +"""適用担当が結果を残さなかったときの試行と担当の交代(#647 / #728)。 + +**結果ファイルを置かずに「群を開く → 取り込む」を繰り返す。** 変更前は同じ群が +上限なしに開き直され、担当が作ったコミットは検証を受けずに残っていた。 +""" +from __future__ import annotations + +import json + +import pytest + +from crossref_helpers import make_state, read_state, write_result + +_ARGS_OPEN = {"id": 130, "round": 1} +_ARGS_MERGE = {"id": 130, "round": 1, "dry_run": False} + + +def _group(n, impl, items, **over): + base = { + "apply_round": n, "impl": impl, + "impl_model": {"requested": None, "observed": None}, + "items": list(items), "status": "pending", + "base_sha": None, "head_sha": None, "fix_rounds": 0, "attempt": 0, + } + base.update(over) + return base + + +def _entry(groups, items): + return { + "round": 1, "impl": "codex", "reviewers": ["agy", "kiro"], + "impl_model": {"requested": None, "observed": None}, + "reviewer_models": {}, "proposed": {}, "merged": len(items), + "adopted": len(items), "deferred": 0, + "items": list(items), + "apply_rounds": groups, "apply_round": 0, + "apply": {"applied": [], "failed": [], "base_sha": None, "head_sha": None}, + "fix_rounds": 0, "durations": {}, "reviews": [], + } + + +def _item(item_id, path="src/a.py", symbol="f"): + return { + "item_id": item_id, "round": 1, "path": path, "symbol": symbol, + "smell": "long_method", "technique": "extract_method", + "severity": "major", "status": "pending", "commits": [], + "estimated_diff_lines": 10, + } + + +@pytest.fixture +def two_groups(tmp_path): + """担当の違う 2 つの群を持つ状態ファイル。""" + groups = [_group(1, "agy", ["R1-001"]), _group(2, "codex", ["R1-002"])] + return make_state( + tmp_path, + items=[_item("R1-001"), _item("R1-002", path="src/b.py")], + rounds=[_entry(groups, ["R1-001", "R1-002"])], + phase="apply", outer_round=1, apply_seq=2, + ) + + +@pytest.fixture +def run(patch_lib, cmd_apply, env_tmp_dir, no_git): + """群を開く・取り込むを、git を呼ばずに実行する。""" + def _make(state_path, head="HEAD_NOW", in_range=None): + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: head) + patch_lib("commits_in_range", lambda work, base, head_: list(in_range or [])) + + def _open(): + return _exit_code(cmd_apply.cmd_next_apply_round, + type("A", (), dict(_ARGS_OPEN))()) + + def _merge(): + return _exit_code(cmd_apply.cmd_merge_apply, + type("A", (), dict(_ARGS_MERGE))()) + + return _open, _merge + return _make + + +def _exit_code(fn, args): + try: + fn(args) + except SystemExit as e: + return e.code + return 0 + + +# ---------- 結果を残さない担当(AC9 / AC10 / AC12) ---------- + +def test_the_first_failure_swaps_the_agent_and_keeps_the_group_pending( + two_groups, run +): + """AC9: 1 回目の結果なしでは、群は未着手のまま担当が替わる。""" + open_round, merge = run(two_groups) + + assert open_round() == 0 + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert group["status"] == "pending" + assert group["impl"] != "agy" + assert group["attempt"] == 1 + assert [(r["phase"], r["attempt"], r["impl"], r["reason"]) + for r in group["failed_attempts"]] == [("apply", 1, "agy", "missing")] + + +def test_the_second_failure_drops_the_group_and_defers_its_items(two_groups, run): + """AC10: 2 回目も残さなければ取り消し、見送りの理由に担当と理由を並べる。""" + open_round, merge = run(two_groups) + open_round(); merge() + second = read_state(two_groups)["rounds"][0]["apply_rounds"][0]["impl"] + + assert open_round() == 0 + assert merge() == 2 + + state = read_state(two_groups) + group = state["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "no_result") + assert group["attempt"] == 2 + assert state["items"][0]["status"] == "abandoned" + assert state["deferred_items"][0]["defer_reason"] == ( + f"実装担当が結果を残しませんでした(agy: missing → {second}: missing)" + ) + + +def test_a_broken_result_file_is_recorded_as_unparsable(two_groups, run): + """AC12: JSON として読めない結果と配列の結果も、結果なしとして閉じる。""" + open_round, merge = run(two_groups) + open_round() + (two_groups.parent / "agy-apply-r1-result.json").write_text( + '{"items": [', encoding="utf-8") + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert group["status"] == "pending" + assert [r["reason"] for r in group["failed_attempts"]] == ["unparsable"] + + +def test_a_json_array_result_is_also_unparsable(two_groups, run): + """AC12: 配列の結果ファイルも同じ扱いになる。""" + open_round, merge = run(two_groups) + open_round() + write_result(two_groups, "agy-apply-r1", [{"item_id": "R1-001"}]) + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert [r["reason"] for r in group["failed_attempts"]] == ["unparsable"] + + +# ---------- 繰り返しが有限回で終わる(AC11) ---------- + +def test_the_rounds_run_out_after_two_attempts_per_group(two_groups, run): + """AC11: 結果を 1 つも置かなければ、開く操作は 5 回目で尽きる。""" + open_round, merge = run(two_groups) + + opened = 0 + while open_round() == 0: + opened += 1 + merge() + assert opened <= 8, "群を開く操作が止まらない" + + assert opened == 4 + groups = read_state(two_groups)["rounds"][0]["apply_rounds"] + assert [g["status"] for g in groups] == ["dropped", "dropped"] + + +# ---------- 交代先の決め方(AC13 / AC14) ---------- + +def test_the_replacement_differs_from_the_agent_that_failed(tmp_path, run): + """AC13: 輪番が 1 周しても、失敗した担当とは違う担当が出る。""" + groups = [_group(n, "codex" if n == 1 else "agy", [f"R1-00{n}"]) + for n in range(1, 5)] + state_path = make_state( + tmp_path, + items=[_item(f"R1-00{n}", path=f"src/{n}.py") for n in range(1, 5)], + rounds=[_entry(groups, [f"R1-00{n}" for n in range(1, 5)])], + phase="apply", outer_round=1, apply_seq=4, + ) + open_round, merge = run(state_path) + + open_round(); merge() + + saved = read_state(state_path)["rounds"][0]["apply_rounds"] + assert saved[0]["impl"] != "codex" + assert [g["impl"] for g in saved[1:]] == ["agy", "agy", "agy"] + assert read_state(state_path)["apply_seq"] > 4 + + +def test_no_replacement_left_with_a_usage_limit_drops_the_group_at_once( + two_groups, run, patch_lib +): + """AC14: 交代先が無く利用上限なら、1 回目の失敗で群を取り消す。""" + patch_lib("impl_for_seq", lambda state, seq: ("agy", None)) + open_round, merge = run(two_groups) + open_round() + (two_groups.parent / "agy-apply-r1-monitor.json").write_text( + json.dumps({"reason": "usage_limit", "detail": "上限"}), encoding="utf-8") + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "no_result") + + +def test_no_replacement_left_without_a_usage_limit_keeps_the_same_agent( + two_groups, run, patch_lib +): + """AC14: 交代先が無く、起動し直せる結末なら同じ担当で 2 回目を開く。""" + patch_lib("impl_for_seq", lambda state, seq: ("agy", None)) + open_round, merge = run(two_groups) + open_round() + + assert merge() == 2 + + group = read_state(two_groups)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["impl"]) == ("pending", "agy") + assert open_round() == 0 + assert read_state(two_groups)["rounds"][0]["apply_rounds"][0]["attempt"] == 2 + + +# ---------- 再開と試行の番号(AC15 / AC16 / AC17 / AC18) ---------- + +def test_opening_twice_without_a_merge_does_not_advance_the_attempt(two_groups, run): + """AC15: 取り込みを挟まずに 2 回開いても、試行の番号は進まない。""" + open_round, _ = run(two_groups) + + open_round() + open_round() + + assert read_state(two_groups)["rounds"][0]["apply_rounds"][0]["attempt"] == 1 + + +def test_an_unverified_baseline_stops_before_reading_the_result( + tmp_path, patch_lib, cmd_apply, env_tmp_dir, no_git +): + """AC16: 着手前のテストが成功と確認できていなければ、結果を読まずに止まる。""" + groups = [_group(1, "agy", ["R1-001"])] + state_path = make_state( + tmp_path, items=[_item("R1-001")], + rounds=[_entry(groups, ["R1-001"])], phase="apply", outer_round=1, + baseline_test={"command": "pytest -q", "status": "red", "checked_at": "x"}, + ) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + read_calls: list[str] = [] + patch_lib("read_result", + lambda *a, **k: read_calls.append("読んだ") or (_ for _ in ()).throw( + AssertionError("結果を読んではいけない"))) + + code = _exit_code(cmd_apply.cmd_merge_apply, type("A", (), dict(_ARGS_MERGE))()) + + assert code == 4 + assert read_calls == [] + assert read_state(state_path)["items"][0]["status"] == "blocked" + + +def test_missing_result_with_undetermined_range_blocks_items_and_exits_4( + two_groups, run, patch_lib, no_git +): + """現状固定: 結果がなく範囲を確定できないときは項目を blocked にして終了コード 4 で中断する。""" + open_round, merge = run(two_groups) + assert open_round() == 0 + + patch_lib("commits_in_range", lambda work, base, head_: None) + + code = merge() + + assert code == 4 + state = read_state(two_groups) + assert state["items"][0]["status"] == "blocked" + group = state["rounds"][0]["apply_rounds"][0] + assert "failed_attempts" not in group + assert [c for c in no_git if c[:2] == ["git", "revert"]] == [] + + +def test_a_closed_apply_attempt_stops_before_reading_the_result( + tmp_path, patch_lib, cmd_apply, env_tmp_dir, no_git +): + """現状固定: 記録済みの試行を叩き直しても、結果を読み直さない。""" + group = _group( + 1, + "agy", + ["R1-001"], + attempt=1, + failed_attempts=[{ + "phase": "apply", "attempt": 1, "impl": "agy", + "reason": "missing", "detail": "", "at": "x", "reverted": 0, + }], + ) + state_path = make_state( + tmp_path, items=[_item("R1-001")], + rounds=[_entry([group], ["R1-001"])], phase="apply", outer_round=1, + ) + env_tmp_dir(state_path) + patch_lib("read_result", lambda *a, **k: (_ for _ in ()).throw( + AssertionError("結果を読んではいけない"))) + before = read_state(state_path) + + code = _exit_code(cmd_apply.cmd_merge_apply, type("A", (), dict(_ARGS_MERGE))()) + + after = read_state(state_path) + assert code == 2 + assert after == before + assert len(after["rounds"][0]["apply_rounds"][0]["failed_attempts"]) == 1 + + +def test_every_exit_2_leaves_the_group_dropped_or_pending_with_a_record( + two_groups, run +): + """AC17: 終了コード 2 の後の群は、取り消し済みか、記録を持つ未着手である。""" + open_round, merge = run(two_groups) + + for _ in range(4): + if open_round() != 0: + break + assert merge() == 2 + for group in read_state(two_groups)["rounds"][0]["apply_rounds"]: + if group["status"] == "pending" and group.get("attempt"): + assert group.get("failed_attempts"), group + else: + assert group["status"] in {"pending", "dropped"}, group + + +def test_both_sides_follow_the_reopening_decision(two_groups, run, patch_lib): + """AC18: 開き直しの判定を差し替えると、開く側も取り込み側も従う。""" + patch_lib("group_reopening", lambda group: "exhausted") + open_round, _ = run(two_groups) + + assert open_round() == 1 + + groups = read_state(two_groups)["rounds"][0]["apply_rounds"] + assert [g["status"] for g in groups] == ["dropped", "dropped"] + assert [g["drop_reason"] for g in groups] == ["no_result", "no_result"] + + +# ---------- 輪番から担当を引く関数(AC49) ---------- + +def test_the_group_assignment_goes_through_the_single_rotation_function( + rounds, monkeypatch +): + """AC49: 輪番から担当を引く関数を差し替えると、群の担当がそれに従う。""" + state = {"host": "claude", "runtimes": ["claude", "codex", "kiro"], + "models": {"kiro": "auto"}} + + impl, requested = rounds.impl_for_seq(state, 2) + + # 輪番は参加者の一覧の中で回る(#727 の決定 7: `runtimes[seq % n]`) + assert impl == "kiro" + assert requested == "auto" + + +# ---------- 修正結果が無く範囲も確定できないとき(converge.cmd_merge_fix / R1-003) ---------- +# +# **共通部品(intake.close_without_result)単体の範囲不明テストでは足りない。** +# `cmd_merge_fix` はその戻り値を受けて、この呼び出し側固有の進行——修正ラウンドを +# 1 つ進めて終了コード 2 を返す——を行う。ここではその呼び出し側の振る舞いを固定する。 + + +def _fix_entry(fix_base, fix_rounds=0, fix_attempts=1): + """修正フェーズに入った、担当と進行中の群を持つラウンド。""" + group = _group(1, "agy", ["R1-001"], status="applied", + base_sha="base0", head_sha="sha1") + return { + "round": 1, "impl": "codex", "reviewers": ["agy", "kiro"], + "impl_model": {"requested": None, "observed": None}, + "reviewer_models": {}, "proposed": {}, "merged": 1, + "adopted": 1, "deferred": 0, + "items": ["R1-001"], + "apply_rounds": [group], "apply_round": 1, + "apply": {"applied": ["R1-001"], "failed": [], + "base_sha": "base0", "head_sha": "sha1"}, + "fix_rounds": fix_rounds, "fix_attempts": fix_attempts, + "fix_base_sha": fix_base, "durations": {}, "reviews": [], + } + + +def _fix_state(tmp_path, fix_base="fixbase0", fix_rounds=0): + item = _item("R1-001") + item["status"] = "applied" + return make_state( + tmp_path, + items=[item], + rounds=[_fix_entry(fix_base, fix_rounds=fix_rounds)], + phase="fix", outer_round=1, + ) + + +def test_a_missing_fix_result_with_an_unknown_range_advances_the_round_and_exits_2( + tmp_path, patch_lib, cmd_converge, env_tmp_dir, no_git +): + """現状固定: 修正結果が無く範囲も確定できないと、修正ラウンドを 1 つ進めて + 終了コード 2 で中断する。`failed_attempts` は足さず、取り消しも行わない。""" + state_path = _fix_state(tmp_path, fix_rounds=0) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + patch_lib("commits_in_range", lambda work, base, head_: None) + + code = _exit_code(cmd_converge.cmd_merge_fix, + type("A", (), {"id": 130, "round": 1})()) + + assert code == 2 + entry = read_state(state_path)["rounds"][0] + assert entry["fix_rounds"] == 1, "修正ラウンドを 1 つ進める" + group = entry["apply_rounds"][0] + assert "failed_attempts" not in group, "範囲不明では失敗の記録を残さない" + assert [c for c in no_git if c[:2] == ["git", "revert"]] == [], "取り消さない" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py index 9e779758..2daa0f85 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_apply_rounds.py @@ -323,3 +323,117 @@ def test_phase_after_group_returns_to_propose_when_there_is_no_group(rounds): entry = _round_with_groups([], items=()) assert rounds.phase_after_group(entry) == "propose" + + +# ---------- 採用 0 件と項目の無い群(#592) ---------- + +def _empty_group(n, items=(), **over): + base = { + "apply_round": n, "impl": "codex", + "impl_model": {"requested": None, "observed": None}, + "items": list(items), "status": "pending", + "base_sha": None, "head_sha": None, "fix_rounds": 0, "attempt": 0, + } + base.update(over) + return base + + +def test_no_adopted_proposal_leaves_the_group_list_empty( + patch_lib, refactor, cmd_apply, tmp_path, env_tmp_dir, monkeypatch +): + """AC19: 採用 0 件のテスト整備ラウンドでは群を作らず、開く操作が 1 回で尽きる。""" + state_path = make_state( + tmp_path, rounds=[{**round_of(), "kind": "test"}], + phase="propose", outer_round=1, round_kind="test", + ) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "base0") + for runtime in ("codex", "agy", "kiro"): + write_result(state_path, f"{runtime}-propose-rf130-r1", {"items": []}) + refactor.cmd_merge_proposals(type("A", (), {"id": 130})()) + + assert read_state(state_path)["rounds"][0]["apply_rounds"] == [] + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + assert e.value.code == 1 + assert read_state(state_path)["rounds"][0]["apply_rounds"] == [] + + +def test_a_state_without_the_group_key_still_opens_the_whole_round( + patch_lib, refactor, tmp_path, env_tmp_dir, monkeypatch, capsys +): + """AC20: 群の鍵を持たない状態ファイルは、ラウンド全体を 1 群として開く。""" + entry = round_of(items=["R1-001"]) + entry["impl"] = "codex" + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + refactor.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + + assert "APPLY_ROUND=1" in capsys.readouterr().out + groups = read_state(state_path)["rounds"][0]["apply_rounds"] + assert [g["items"] for g in groups] == [["R1-001"]] + + +def test_a_merged_group_with_nothing_adopted_is_dropped_as_empty( + patch_lib, cmd_apply, tmp_path, env_tmp_dir, no_git +): + """AC21: 取り込み済みで採用 0 件の群(項目なし)を取り消し済みに直す。""" + entry = round_of() + entry["apply_rounds"] = [_empty_group( + 1, status="applied", base_sha="base0", attempt=1)] + entry["apply_round"] = 1 + entry["apply"] = {"apply_round": 1, "applied": [], "failed": [], + "base_sha": "base0", "head_sha": "h", "merged_at": "x"} + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_merge_apply( + type("A", (), {"id": 130, "round": 1, "dry_run": False})()) + assert e.value.code == 2 + + group = read_state(state_path)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "empty") + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + assert e.value.code == 1 + + +def test_a_pending_group_without_items_is_dropped_before_it_opens( + patch_lib, cmd_apply, tmp_path, env_tmp_dir, capsys +): + """AC22: 項目の無い未着手の群は開かれず、次の群があればそちらを開く。""" + entry = round_of(items=["R1-002"]) + entry["apply_rounds"] = [_empty_group(1), _empty_group(2, items=["R1-002"])] + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + + assert "APPLY_ROUND=2" in capsys.readouterr().out + groups = read_state(state_path)["rounds"][0]["apply_rounds"] + assert (groups[0]["status"], groups[0]["drop_reason"]) == ("dropped", "empty") + + +def test_a_round_whose_only_group_has_no_item_runs_out( + patch_lib, cmd_apply, tmp_path, env_tmp_dir +): + """AC22: 項目の無い群だけなら、開く操作は 1 を返す。""" + entry = round_of() + entry["apply_rounds"] = [_empty_group(1)] + state_path = make_state(tmp_path, rounds=[entry], phase="apply", outer_round=1) + env_tmp_dir(state_path) + patch_lib("git_out", lambda work, args, **k: "HEAD_NOW") + + with pytest.raises(SystemExit) as e: + cmd_apply.cmd_next_apply_round(type("A", (), {"id": 130, "round": 1})()) + + assert e.value.code == 1 + group = read_state(state_path)["rounds"][0]["apply_rounds"][0] + assert (group["status"], group["drop_reason"]) == ("dropped", "empty") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py b/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py index f68e0328..12f6475f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_assignment.py @@ -1,8 +1,7 @@ -"""担当の決定(ホスト判定 / 母集合 / 輪番)のテスト。 +"""担当の決定(ホスト判定 / 母集合の既定 / 輪番 / 席)のテスト。 -**`runtimes` と `impl_capable` を同一視しない**ことがここの主題である。 -前者はホストを除いた 3 者(提案・レビュー)、後者は参加する 4 者すべて(適用)で、 -重なるが一致しない。 +cross-refactoring は提案と適用を 1 つの参加者の一覧で回し(#727 の決定 5)、 +cross-review はホストを除く母集合から 2 席を決める。母集合の既定は Skill ごとに違う。 """ from __future__ import annotations @@ -61,10 +60,15 @@ def test_review_pool_is_all_minus_host(assignment, host): assert set(pool) == set(assignment.ALL_RUNTIMES) - {host} -@pytest.mark.parametrize("host", HOSTS) -def test_impl_pool_is_host_independent(assignment, host): - """適用の母集合はホストによらず参加する 4 者すべてになる。""" - assert assignment.impl_pool() == ["claude", "codex", "agy", "kiro"] +@pytest.mark.parametrize("host, expected", [ + ("claude", ["claude", "codex", "kiro"]), + ("codex", ["codex", "kiro"]), + ("agy", ["codex", "agy", "kiro"]), + ("kiro", ["codex", "kiro"]), +]) +def test_refactor_pool_is_codex_kiro_and_the_host(assignment, host, expected): + """AC31 / AC32 — cross-refactoring の既定はホストを含む。並びは固定の順。""" + assert assignment.refactor_pool(host) == expected def test_no_runtime_is_excluded_from_applying(assignment): @@ -75,88 +79,42 @@ def test_no_runtime_is_excluded_from_applying(assignment): assert not hasattr(assignment, "IMPL_EXCLUDED") -# ---------- 輪番 ---------- - -@pytest.mark.parametrize("host", HOSTS) -def test_impl_and_reviewers_never_overlap(assignment, host): - for round_no in range(1, 17): - impl, reviewers = assignment.assign(round_no, host) - assert impl not in reviewers, f"round {round_no} で実装担当がレビューにも入っている" - assert len(reviewers) == 2, f"round {round_no} のレビュー担当が 2 者でない" - assert host not in reviewers, f"round {round_no} でホストがレビューに入っている" - +# ---------- 適用の輪番(cross-refactoring) ---------- @pytest.mark.parametrize("host", HOSTS) -def test_every_participant_implements_within_four_rounds(assignment, host): - """4 ラウンドで 4 者が 1 度ずつ適用担当になる(`--max-outer-rounds` の既定と揃う)。""" - impls = [assignment.assign(r, host)[0] for r in range(1, 5)] - assert set(impls) == set(assignment.ALL_RUNTIMES) - assert len(set(impls)) == len(impls), f"同じ担当が 2 度入っている: {impls}" +def test_every_participant_implements_within_one_cycle(assignment, host): + """参加者の数のラウンドで、参加者が 1 度ずつ適用担当になる(#727 の決定 7)。""" + participants = assignment.refactor_pool(host) + impls = [assignment.impl_assign(r, participants) for r in range(1, len(participants) + 1)] + assert sorted(impls) == sorted(participants) @pytest.mark.parametrize("host", HOSTS) -def test_host_takes_impl_turn_at_least_once(assignment, host): - """ホストは適用にだけ参加する。4 ラウンド回れば必ず 1 度は担当する。""" - impls = {assignment.assign(r, host)[0] for r in range(1, 5)} - assert host in impls - - -def test_reviewers_narrow_to_two_when_impl_is_host(rounds, assignment): - """実装担当がホストと同じラウンドでも、レビュー担当は 3 者にならず 2 者になる。""" - host = "claude" - rounds = [r for r in range(1, 13) if assignment.assign(r, host)[0] == host] - assert rounds, "ホストが実装担当になるラウンドが無い" - for round_no in rounds: - _, reviewers = assignment.assign(round_no, host) - assert len(reviewers) == 2 - - -def test_excluded_reviewer_rotates_across_rounds(assignment): - """余る 1 者はラウンドを跨いで順に外れ、負荷が偏らないこと。""" - host = "claude" - pool = set(assignment.review_pool(host)) - excluded = [] - for round_no in range(1, 13): - impl, reviewers = assignment.assign(round_no, host) - if impl != host: - continue - excluded.append((pool - {impl} - set(reviewers)).pop()) - assert len(set(excluded)) > 1, f"常に同じ 1 者だけが外れている: {excluded}" +def test_the_host_does_not_implement_first(assignment, host): + """ラウンド 1 は参加者の 2 番目から始まる。ホストが最初に適用する形にならない。""" + participants = assignment.refactor_pool(host) + if participants[0] == host: + assert assignment.impl_assign(1, participants) != host @pytest.mark.parametrize("host", HOSTS) def test_assignment_is_deterministic(assignment, host): """再開しても担当が変わらないこと(同じ入力なら同じ結果)。""" + participants = assignment.refactor_pool(host) for round_no in range(1, 13): - assert assignment.assign(round_no, host) == assignment.assign(round_no, host) + assert assignment.impl_assign(round_no, participants) == \ + assignment.impl_assign(round_no, participants) def test_round_number_must_be_positive(assignment): with pytest.raises(assignment.AssignmentError): - assignment.assign(0, "claude") + assignment.impl_assign(0, ["claude", "codex", "kiro"]) -# ---------- 割り当てを直に固定する(#214 / #216) ---------- +# ---------- 固定の順(#214) ---------- # `gemini` があった位置へ `agy` を入れた(#214)。並べ替えると同じラウンド番号でも # 担当が変わり、これまでの記録と突き合わせられなくなる。 -# 適用の母集合を 4 者にしたため(#216)、ラウンド 2 以降の担当が 1 つずつずれる。 -# 適用担当は 4 ラウンドで 1 周し、レビュー担当は適用担当がホストと重なるラウンドで -# 1 者を落とすため 12 ラウンドで 1 周する。読み替えた結果を直に置く。 -EXPECTED_FOR_CLAUDE = { - 1: ("codex", ["agy", "kiro"]), - 2: ("agy", ["codex", "kiro"]), - 3: ("kiro", ["codex", "agy"]), - 4: ("claude", ["codex", "kiro"]), - 5: ("codex", ["agy", "kiro"]), - 6: ("agy", ["codex", "kiro"]), - 7: ("kiro", ["codex", "agy"]), - 8: ("claude", ["codex", "agy"]), - 9: ("codex", ["agy", "kiro"]), - 10: ("agy", ["codex", "kiro"]), - 11: ("kiro", ["codex", "agy"]), - 12: ("claude", ["agy", "kiro"]), -} def test_the_participant_list_keeps_the_replaced_position(assignment): @@ -168,40 +126,76 @@ def test_host_runtimes_covers_every_participant(assignment): assert assignment.HOST_RUNTIMES == assignment.ALL_RUNTIMES -@pytest.mark.parametrize("round_no", sorted(EXPECTED_FOR_CLAUDE)) -def test_the_rotation_matches_the_renamed_result(assignment, round_no): - assert assignment.assign(round_no, "claude") == EXPECTED_FOR_CLAUDE[round_no] +# ---------- 席の埋め方と席の名前(#727。cross-review が使う) ---------- +def _previous_review_rotation(round_no: int, pool: list[str]) -> list[str]: + """席の埋め方より前の輪番。3 者の母集合から `(round_no - 1) % 3` の者を外した 2 者。 -# ---------- レビューだけの輪番(cross-review が使う) ---------- + 関数は消えたため、式を期待値として持つ(AC8 の主張を保つ)。 + """ + dropped = (round_no - 1) % len(pool) + return [r for i, r in enumerate(pool) if i != dropped] -def test_review_assign_excludes_the_host(assignment): - """母集合はホストを除く 3 者で、返るのは常に 2 者である。""" + +def test_review_seats_match_the_previous_rotation_for_three_available(assignment): + """AC8: 使える者が 3 者のとき、変更前の輪番と同じ値になる(4 ホスト × ラウンド 1〜12)。""" for host in assignment.HOST_RUNTIMES: - for round_no in range(1, 10): - picked = assignment.review_assign(round_no, host) - assert len(picked) == 2 - assert host not in picked - assert set(picked) <= set(assignment.review_pool(host)) + pool = assignment.review_pool(host) + for round_no in range(1, 13): + assert assignment.review_seats(round_no, pool, []) == \ + _previous_review_rotation(round_no, pool), f"host={host} round={round_no}" -def test_review_assign_rotates_the_excluded_one(assignment): - """外す 1 者はラウンドごとに回り、3 ラウンドで 1 周する。""" - host = "claude" - pool = assignment.review_pool(host) - dropped = [ - set(pool) - set(assignment.review_assign(r, host)) for r in (1, 2, 3) - ] - assert [next(iter(d)) for d in dropped] == pool - # 4 ラウンド目は 1 ラウンド目と同じ担当へ戻る - assert assignment.review_assign(4, host) == assignment.review_assign(1, host) +def test_review_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_assign_rejects_a_bad_round(assignment): + +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_assign(0, "claude") + 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" -def test_review_assign_rejects_an_unknown_host(assignment): +@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.review_assign(1, "gemini") + 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])?$" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py b/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py new file mode 100644 index 00000000..946d6f4f --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_commit_trailers_git.py @@ -0,0 +1,167 @@ +"""実行環境が帰属の段落を足したコミットから記名を読む(#553)。 + +**一時リポジトリで実際に git を実行する。** 段落の切り分けは git の判定 +(`git interpret-trailers --parse`)に委ねているため、差し替えた出力で確かめても +本番の読み方を確かめたことにならない。 +""" +from __future__ import annotations + +import subprocess + +import pytest + +REQUIRED = ("Item-Id", "Round", "Impl-Runtime", "Impl-Model") + +_SIGNED = """Refactor: extract_method — src/foo.py#Bar.handle + +変更の説明。 + +Item-Id: R1-001 +Round: 1 +Impl-Runtime: claude +Impl-Model: claude-opus-5 + +Co-Authored-By: Claude Opus 5 +""" + + +def _git(*args, cwd): + return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, + check=True) + + +@pytest.fixture +def repo(tmp_path): + """コミットを積める一時リポジトリ。""" + path = tmp_path / "repo" + path.mkdir() + _git("init", "-q", "-b", "main", cwd=path) + _git("config", "user.email", "t@e.st", cwd=path) + _git("config", "user.name", "test", cwd=path) + (path / "src").mkdir() + (path / "src" / "foo.py").write_text("x = 1\n", encoding="utf-8") + _git("add", "-A", cwd=path) + _git("commit", "-qm", "init", cwd=path) + return path + + +@pytest.fixture +def commit(repo): + """メッセージを渡してコミットを作り、その完全な識別子を返す。""" + counter = {"n": 0} + + def _make(message: str) -> str: + counter["n"] += 1 + (repo / "src" / f"f{counter['n']}.py").write_text("y = 1\n", encoding="utf-8") + _git("add", "-A", cwd=repo) + _git("commit", "-q", "-m", message, cwd=repo) + return _git("rev-parse", "HEAD", cwd=repo).stdout.strip() + + return _make + + +def test_a_signature_paragraph_after_the_required_ones_is_skipped( + gitfacts, repo, commit +): + """AC32: 必須の記名の後ろに帰属の段落が付いても 4 つとも読める。""" + sha = commit(_SIGNED) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert [trailers.get(k) for k in REQUIRED] == [ + "R1-001", "1", "claude", "claude-opus-5"] + + +def test_two_attribution_lines_in_one_paragraph_are_also_skipped( + gitfacts, repo, commit +): + """AC33: 帰属の段落が 2 行でも 4 つとも読める。""" + sha = commit( + _SIGNED + "Claude-Session: https://example.test/session_1\n") + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert all(trailers.get(k) for k in REQUIRED) + assert trailers["Claude-Session"] == "https://example.test/session_1" + + +def test_a_prose_paragraph_stops_the_reading(gitfacts, repo, commit): + """AC34: 散文の段落より前にある記名の形の行は読まない。""" + sha = commit( + "Refactor: 題名\n\n" + "Item-Id: R1-001\nRound: 1\n" + "Impl-Runtime: claude\nImpl-Model: claude-opus-5\n\n" + "この段落は説明の散文です。\n\n" + "Co-Authored-By: Someone \n" + ) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers == {"Co-Authored-By": "Someone "} + + +def test_a_mixed_last_paragraph_is_not_read(gitfacts, repo, commit): + """AC35: 散文と記名の形が混ざる段落は、git が記名の段落と判定しない。""" + sha = commit( + "Refactor: 題名\n\n" + "ここは説明です。\nこちらも説明です。\nさらに説明です。\nRound: 3\n" + ) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers == {} + + +def test_the_paragraph_nearest_the_end_wins(gitfacts, repo, commit): + """AC36: 同じ鍵が 2 つの段落にあれば、末尾に近い方の値を採る。""" + sha = commit( + "Refactor: 題名\n\n" + "Item-Id: R1-001\nRound: 1\n" + "Impl-Runtime: claude\nImpl-Model: claude-opus-5\n\n" + "Impl-Model: claude-opus-5-later\n" + ) + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers["Impl-Model"] == "claude-opus-5-later" + assert trailers["Item-Id"] == "R1-001" + + +def test_a_subject_shaped_like_a_trailer_is_not_read(gitfacts, repo, commit): + """AC38: 題名が記名の形でも、1 段落目は判定に掛けない。""" + sha = commit("Round: 本文の題名\n\nItem-Id: R1-002\n") + + trailers = gitfacts.commit_trailers(str(repo), sha) + + assert trailers == {"Item-Id": "R1-002"} + + +def test_a_commit_with_an_attribution_paragraph_passes_the_apply_check( + gitfacts, verify, repo, commit +): + """AC37: 帰属の段落が付いたコミットは、記名の欠落で取り消されない。""" + sha = commit(_SIGNED) + facts = gitfacts.collect_commit_facts( + str(repo), [sha], {sha}, "", "main") + + problem = verify.verify_apply_round( + [{"item_id": "R1-001", "estimated_diff_lines": 100, + "technique": "extract_method", "test_gap": False}], + facts, ["src"], + ) + + assert problem is None + + +def test_the_commit_convention_asks_for_the_last_paragraph(prompts_dir): + """AC39: 適用と修正の雛形が、必須の記名を最後の段落へ置くことを書く。""" + for name in ("apply.md", "fix.md"): + text = (prompts_dir / name).read_text(encoding="utf-8") + assert "最後の段落" in text, name + assert "空行を挟まず" in text, name + + +@pytest.fixture +def prompts_dir(): + import pathlib + return pathlib.Path(__file__).resolve().parents[1] / "prompts" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py b/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py index ba0c22f9..15dde663 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_final_fix.py @@ -369,3 +369,147 @@ def test_the_propose_result_file_carries_the_round_number(paths): 始まった時点で 1 巡目の提案内容が失われる。 """ assert paths.stem_for("codex", "propose", 130, 2) == "codex-propose-rf130-r2" + + +# ---------- 最終ゲートの修正で結果が無いとき(#674 / #728 の決定 11) ---------- + +def test_a_missing_final_fix_result_reverts_and_moves_the_base( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """AC28: 結果が無ければ取り消し、起点を取り消し後の先端へ進める。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + assert e.value.code == 2 + gate = read_state(state_path)["final_gate"] + assert len(merge_spy["reverted"]) == 1 + assert gate["fix_base_sha"] == "HEADSHA" + assert [(r["phase"], r["impl"], r["reason"]) for r in gate["failed_attempts"]] == [ + ("final-fix", "codex", "missing")] + assert "fix_commits" not in gate + + +def test_a_missing_final_fix_with_an_unknown_range_keeps_the_attempt_open( + patch_lib, cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """現状固定: 結果も範囲も無ければ、記録も取り消しも行わず止まる。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + patch_lib("commits_in_range", lambda work, base, head: None) + before = read_state(state_path)["final_gate"] + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + gate = read_state(state_path)["final_gate"] + assert e.value.code == 2 + assert gate == before + assert gate["fix_rounds"] == 1 + assert gate["fix_base_sha"] == "BASE" + assert "failed_attempts" not in gate + assert merge_spy["reverted"] == [] + + +def test_the_next_gate_does_not_see_the_reverted_commits( + patch_lib, cmd_gate, tmp_path, env_tmp_dir, merge_spy, gate_spy +): + """AC29: 取り消した後の最終ゲートは、修正のコミットを数に入れない。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + with pytest.raises(SystemExit): + cmd_gate.cmd_merge_final_fix(_args()) + + gate_spy["test_code"] = 0 + cmd_gate.cmd_final_gate(_args()) + + gate = read_state(state_path)["final_gate"] + assert gate.get("fix_commits", []) == [] + assert gate["status"] == "passed" + + +def test_a_usage_limit_on_the_final_fix_jumps_to_the_cap( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """AC30: 起動し直しても解けない結末では、修正ラウンドを上限の値にする。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + (state_path.parent / "codex-final-fix-monitor.json").write_text( + __import__("json").dumps({"reason": "usage_limit", "detail": "上限"}), + encoding="utf-8") + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + assert e.value.code == 2 + assert read_state(state_path)["final_gate"]["fix_rounds"] == 3 + + +def test_the_gate_after_the_cap_reports_without_reverting( + cmd_gate, tmp_path, env_tmp_dir, merge_spy, gate_spy +): + """AC30: 上限に達した後の最終ゲートは、落ちても取り消さず報告で終わる。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + (state_path.parent / "codex-final-fix-monitor.json").write_text( + __import__("json").dumps({"reason": "usage_limit", "detail": "上限"}), + encoding="utf-8") + with pytest.raises(SystemExit): + cmd_gate.cmd_merge_final_fix(_args()) + + gate_spy["test_code"] = 1 + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_final_gate(_args()) + + assert e.value.code == 1 + assert read_state(state_path)["final_gate"]["status"] == "failed" + + +def test_a_verified_final_fix_keeps_no_failure_record( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """AC31: 検証を通る修正は、変更前と同じく取り込まれ、記録を持たない。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + write_result(state_path, "codex-final-fix", + {"elapsed_seconds": 7, "commits": [{"sha": "C1FULL"}]}) + + cmd_gate.cmd_merge_final_fix(_args()) + + gate = read_state(state_path)["final_gate"] + assert "failed_attempts" not in gate + assert gate["fix_commits"] == ["C1FULL"] + + +def test_the_same_final_fix_attempt_is_closed_only_once( + cmd_gate, tmp_path, env_tmp_dir, merge_spy +): + """同じ修正ラウンドで叩き直しても、結果ファイルを読まずに同じ終了コードを返す。""" + state_path = _failing_gate_state(tmp_path) + env_tmp_dir(state_path) + with pytest.raises(SystemExit): + cmd_gate.cmd_merge_final_fix(_args()) + write_result(state_path, "codex-final-fix", {"commits": [{"sha": "C1FULL"}]}) + + with pytest.raises(SystemExit) as e: + cmd_gate.cmd_merge_final_fix(_args()) + + assert e.value.code == 2 + assert len(read_state(state_path)["final_gate"]["failed_attempts"]) == 1 + + +def test_the_final_fix_agent_comes_from_the_single_rotation_function( + patch_lib, cmd_gate, tmp_path, env_tmp_dir, gate_spy +): + """AC49: 輪番から担当を引く関数を差し替えると、最終ゲートの修正担当も従う。""" + state_path = _gate_state(tmp_path) + env_tmp_dir(state_path) + gate_spy["test_code"] = 1 + patch_lib("impl_for_seq", lambda state, seq: ("kiro", "auto")) + + with pytest.raises(SystemExit): + cmd_gate.cmd_final_gate(_args()) + + assert read_state(state_path)["final_gate"]["impl"] == "kiro" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py index 6d9d3275..6b5e65b1 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_git_facts.py @@ -5,6 +5,7 @@ """ from __future__ import annotations +import json import subprocess import pytest @@ -276,39 +277,58 @@ def test_cutting_off_kills_children_that_ignore_sigterm(gitfacts, work): assert not marker.exists(), "SIGTERM を無視する子が生き残っている" -def test_read_result_aborts_when_the_file_is_missing(gitfacts, tmp_path): - """現状固定: 結果ファイルが無ければ終了コード 2 で中断する。 +def test_read_result_returns_a_value_when_the_file_is_missing(gitfacts, tmp_path, capsys): + """結果ファイルが無くても中断せず、結果なしの値を返す。 - 起動した CLI が結果を残さなかった場合であり、進行は次のラウンドへ進む。 + 中断すると、担当が作ったコミットが取り消されないまま Pull Request に残る + (#728)。何で終わるかは読んだ側(取り込み)が決める。 """ - with pytest.raises(SystemExit) as e: - gitfacts.read_result(tmp_path / "missing.json", "claude") - assert e.value.code == 2 + state = {"id": 130, "tmp_dir": str(tmp_path)} + outcome = gitfacts.read_result(state, "claude", "apply", 1) + assert outcome.payload is None + assert outcome.reason == "missing" + assert outcome.relaunch_same_agent is True + captured = capsys.readouterr() + assert (captured.out, captured.err) == ("", "") -def test_read_result_aborts_on_broken_json(gitfacts, tmp_path): - """現状固定: JSON として読めなければ終了コード 2 で中断する。""" - path = tmp_path / "result.json" - path.write_text('{"items": [', encoding="utf-8") - with pytest.raises(SystemExit) as e: - gitfacts.read_result(path, "claude") - assert e.value.code == 2 +def test_read_result_returns_unparsable_for_broken_json(gitfacts, tmp_path): + """JSON として読めない結果ファイルは、理由 `unparsable` の結果なしになる。""" + (tmp_path / "claude-apply-r1-result.json").write_text( + '{"items": [', encoding="utf-8") + state = {"id": 130, "tmp_dir": str(tmp_path)} + + outcome = gitfacts.read_result(state, "claude", "apply", 1) + + assert (outcome.payload, outcome.reason) == (None, "unparsable") @pytest.mark.parametrize("body", ['[{"item_id": "R1-001"}]', "42"]) -def test_read_result_aborts_when_the_json_is_not_an_object(gitfacts, tmp_path, body): - """現状固定: 配列や数値も終了コード 2 で中断する。 +def test_read_result_returns_unparsable_when_the_json_is_not_an_object( + gitfacts, tmp_path, body +): + """配列や数値も結果なしとして返す。 - 呼び出し側は `payload.get(...)` を呼ぶため、読み込みの時点で弾かないと - `AttributeError` になって進行が止まる。 + 呼び出し側は `payload.get(...)` を呼ぶため、辞書でないものを渡すと + `AttributeError` になって進行が止まる。読み込みの時点で結果なしへ寄せる。 """ - path = tmp_path / "result.json" - path.write_text(body, encoding="utf-8") + (tmp_path / "claude-apply-r1-result.json").write_text(body, encoding="utf-8") + state = {"id": 130, "tmp_dir": str(tmp_path)} + + outcome = gitfacts.read_result(state, "claude", "apply", 1) + + assert (outcome.payload, outcome.reason) == (None, "unparsable") - with pytest.raises(SystemExit) as e: - gitfacts.read_result(path, "claude") - assert e.value.code == 2 + +def test_read_result_reads_the_stem_of_each_phase(gitfacts, tmp_path): + """名前の幹は工程ごとに変わる。最終ゲートの修正だけラウンド番号を持たない。""" + (tmp_path / "codex-fix-r2-result.json").write_text('{"ok": 1}', encoding="utf-8") + (tmp_path / "codex-final-fix-result.json").write_text('{"ok": 2}', encoding="utf-8") + state = {"id": 130, "tmp_dir": str(tmp_path)} + + assert gitfacts.read_result(state, "codex", "fix", 2).payload == {"ok": 1} + assert gitfacts.read_result(state, "codex", "final-fix").payload == {"ok": 2} def test_find_item_returns_none_for_a_missing_id_when_not_required(gitfacts): @@ -322,6 +342,22 @@ def test_find_item_returns_none_for_a_missing_id_when_not_required(gitfacts): assert gitfacts.find_item(state, "R9-999", required=False) is None +def test_scoped_item_ids_falls_back_to_all_items_when_apply_round_is_missing( + gitfacts, +): + """現状固定: 現在の適用ラウンドの群がなければ entry 全体の項目を返す。""" + entry = { + "apply_round": 3, + "items": ["R2-003", "R2-001", "R2-002"], + "apply_rounds": [ + {"apply_round": 1, "items": ["R2-001"]}, + {"apply_round": 2, "items": ["R2-002"]}, + ], + } + + assert gitfacts.scoped_item_ids(entry) == ["R2-003", "R2-001", "R2-002"] + + def test_revert_item_commits_failure_message_includes_item_id(gitfacts, work, capsys): """現状固定: revert_item_commits 失敗時は項目 ID 接頭辞付きのエラー文を出して中断する。""" first = _commit(work, "one", {"src/a.py": "a = 1\n"}) @@ -356,3 +392,89 @@ def test_revert_range_failure_message_has_no_item_id_prefix(gitfacts, work, caps assert f"(HEAD を {second} へ戻しました)" in err assert _git("rev-parse", "HEAD", cwd=work).stdout.strip() == second + +def test_check_run_result_characterization(gitfacts, monkeypatch): + """check_run_result の公開契約を固定する現状固定テスト。""" + # 1. 引数が空なら None + assert gitfacts.check_run_result("", "sha", "ci") is None + assert gitfacts.check_run_result("repo", "", "ci") is None + assert gitfacts.check_run_result("repo", "sha", "") is None + + # 2. gh api の実行失敗(sh が None または空)なら None + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: None) + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: "") + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 3. 不正 JSON なら None + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: "not-json{") + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 4. check_runs 欠損(非 dict、または check_runs がリストでない)なら None + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: "[]") + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + monkeypatch.setattr(gitfacts, "sh", lambda *args, **kwargs: json.dumps({"check_runs": "not-a-list"})) + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 5. 対象名なし(一致する name がない)なら None + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [{"name": "other", "status": "completed", "conclusion": "success"}] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") is None + + # 6. 未完了(status != completed)なら "pending" + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "in_progress", "conclusion": None}, + {"name": "ci", "status": "completed", "conclusion": "success"}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "pending" + + # 7. 失敗(completed だが conclusion != success)ならその結論(または unknown) + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "completed", "conclusion": "failure"}, + {"name": "ci", "status": "completed", "conclusion": "success"}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "failure" + + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "completed", "conclusion": None}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "unknown" + + # 8. 全成功なら "success" + monkeypatch.setattr( + gitfacts, + "sh", + lambda *args, **kwargs: json.dumps({ + "check_runs": [ + {"name": "ci", "status": "completed", "conclusion": "success"}, + {"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"}, + ] + }), + ) + assert gitfacts.check_run_result("repo", "sha", "ci") == "success" + diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index ced38334..e5d8cdaa 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -77,8 +77,10 @@ def run_init(refactor_lib, paths, patch_lib, refactor, origin_repo, monkeypatch) 常に `author` なので、両者を一致させると自分の Pull Request になる。 """ refactor_lib = sys.modules["refactor_lib"] + probed: list[list[str]] = [] - def _run(args, viewer="someone-else"): + def _run(args, viewer="someone-else", probe=None): + """`probe` を渡すと確認を差し替える。`{ランタイム: 理由}` の者だけが通らない。""" real_sh = paths.sh def fake_sh(cmd, cwd=None, check=True): @@ -108,10 +110,24 @@ def fake_sh(cmd, cwd=None, check=True): patch_lib("sh", fake_sh) monkeypatch.chdir(origin_repo) monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) - # 認証確認は実際の CLI を起動する。ここでは対象外なので飛ばす - # (確認そのものは `test_init_checks_cli_authentication` で見る)。 - monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + # 認証確認は実際の CLI を起動する。既定では飛ばし、`probe` を渡したときだけ + # 止めない確認(`probe_auth`)を差し替えて結果を決める。 + if probe is None: + monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") + else: + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + cmd_setup = sys.modules["refactor_lib.commands.setup"] + probed.clear() + + def fake_probe(runtimes, *, info, env=None): + names = list(runtimes) + probed.append(names) + return {n: {"command": n, "ok": n not in probe, "detail": probe.get(n, "")} + for n in names}, False + + monkeypatch.setattr(cmd_setup.auth, "probe_auth", fake_probe) refactor.cmd_init(args) + _run.probed = probed return _run @@ -120,9 +136,13 @@ def refactor_abort(): return 4 -def _state_of(tmp_path): - path = (tmp_path / "rf130" / "work" / ".cross_refactoring" +def _state_path(tmp_path): + return (tmp_path / "rf130" / "work" / ".cross_refactoring" / "cross-refactoring-rf130-state.json") + + +def _state_of(tmp_path): + path = _state_path(tmp_path) return path, json.loads(path.read_text(encoding="utf-8")) @@ -136,15 +156,94 @@ def test_init_creates_the_writable_worktree_from_origin(run_init, tmp_path): assert head.stdout.strip() == HEAD_BRANCH -def test_init_records_cohorts_separately(run_init, tmp_path): - """提案・レビューと適用の母集合は別物である。""" - run_init(_args(tmp_path)) +def test_init_uses_codex_kiro_and_the_host_as_the_participants(run_init, tmp_path, capsys): + """AC31 — 既定の参加者は codex / kiro とホスト。agy は確かめず、母集合は 1 つだけ。""" + run_init(_args(tmp_path), probe={}) _, state = _state_of(tmp_path) - assert state["runtimes"] == ["codex", "agy", "kiro"] - assert state["impl_capable"] == ["claude", "codex", "agy", "kiro"] + assert state["runtimes"] == ["claude", "codex", "kiro"] + assert run_init.probed == [["claude", "codex", "kiro"]], "agy を確かめている" + assert "impl_capable" not in state + assert "IMPL_POOL=" not in capsys.readouterr().out + assert state["participants"]["available"] == ["claude", "codex", "kiro"] + assert state["participants"]["pool"] == ["claude", "codex", "kiro"] + assert state["resume_changes"] == [] assert state["host"] == "claude" assert state["host_detection"] == "explicit" - assert state["host"] not in state["runtimes"] + + +@pytest.mark.parametrize("host, expected", [ + ("codex", ["codex", "kiro"]), + ("agy", ["codex", "agy", "kiro"]), + ("kiro", ["codex", "kiro"]), +]) +def test_the_participants_follow_the_host(run_init, tmp_path, host, expected): + """AC32 — ホストが既定の参加者の表にいれば 2 者、いなければ 3 者になる。""" + run_init(_args(tmp_path, host=host), probe={}) + assert _state_of(tmp_path)[1]["runtimes"] == expected + + +@pytest.mark.parametrize("over, expected", [ + ({"include": [["agy"]]}, ["claude", "codex", "agy", "kiro"]), + ({"exclude": [["kiro"]]}, ["claude", "codex"]), + ({"exclude": [["claude"]]}, ["codex", "kiro"]), +]) +def test_include_and_exclude_change_the_participants(run_init, tmp_path, over, expected): + """AC33 — 足す者・外す者で名指しで変えられる。ホストも母集合にいるので外せる。""" + run_init(_args(tmp_path, **over), probe={}) + _, state = _state_of(tmp_path) + assert state["runtimes"] == expected + assert state["participants"]["included"] == over.get("include", [[]])[0] + assert state["participants"]["excluded"] == over.get("exclude", [[]])[0] + + +def test_a_failed_probe_drops_the_runtime_and_keeps_going(run_init, tmp_path, capsys): + """AC35 — 1 者の確認が通らなくても止めず、理由を残して使える者で始める。""" + run_init(_args(tmp_path), probe={"kiro": "Not logged in"}) + _, state = _state_of(tmp_path) + assert state["runtimes"] == ["claude", "codex"] + assert state["participants"]["unavailable"] == {"kiro": "Not logged in"} + assert "kiro を担当から外しました(Not logged in)" in capsys.readouterr().err + + +def test_require_all_stops_without_writing_the_state(run_init, tmp_path): + """AC35 — 全員を要する指定では従来の関門で止め、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, require_all=True), probe={"kiro": "Not logged in"}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + + +def test_no_available_runtime_stops_without_writing_the_state(run_init, tmp_path, capsys): + """AC36 — 使える者が 0 者なら終了コード 4 で止め、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path), probe={"claude": "x", "codex": "y", "kiro": "z"}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + assert "使える者がいません" in capsys.readouterr().err + + +@pytest.mark.parametrize("over", [ + {"exclude": [["agy"]]}, # 母集合に無い者は外せない + {"include": [["agy"]], "exclude": [["agy"]]}, # 足す者と外す者の重なり +]) +def test_contradicting_names_stop_the_init(run_init, tmp_path, over): + """名前の矛盾は共通層が弾き、この工程の中断(終了コード 4)へ写す。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, **over), probe={}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() + + +@pytest.mark.parametrize("over", [ + {"exclude": [["none", "kiro"]]}, + {"include": [["none", "agy"]]}, +]) +def test_none_mixed_with_runtime_names_stops_the_init(run_init, tmp_path, over): + """none とランタイム名の混在は中断(終了コード 4)し、状態ファイルを作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, **over), probe={}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() def test_init_records_models(run_init, tmp_path): @@ -175,18 +274,26 @@ def test_init_warns_when_kiro_is_given_auto_explicitly(run_init, tmp_path, capsy def test_init_warns_when_codex_or_agy_has_no_model(run_init, tmp_path, capsys): - """実測できないランタイムで指定が無いラウンドも、kiro の auto と同じく分離される。""" + """実測できないランタイムで指定が無いラウンドも、kiro の auto と同じく分離される。 + + 警告の対象は参加者だけである。既定で外れる agy は、足したときだけ警告する。 + """ run_init(_args(tmp_path, model=["kiro=claude-opus-5"])) warning = capsys.readouterr().err assert "codex のモデルが default です" in warning - assert "agy のモデルが default です" in warning + assert "agy のモデルが" not in warning + + +def test_init_warns_about_agy_when_it_is_included(run_init, tmp_path, capsys): + run_init(_args(tmp_path, model=["kiro=claude-opus-5"], include=[["agy"]])) + assert "agy のモデルが default です" in capsys.readouterr().err def test_init_does_not_warn_when_every_model_can_be_measured(run_init, tmp_path, capsys): """claude だけは指定が無くても実測できるため、警告の対象にならない。""" run_init(_args(tmp_path, model=[ "codex=gpt-5.5", "agy=gemini-3.8", "kiro=claude-opus-5", - ])) + ], include=[["agy"]])) assert "集計から分離されます" not in capsys.readouterr().err @@ -200,8 +307,7 @@ def test_init_accepts_agy_as_host(run_init, tmp_path): run_init(_args(tmp_path, host="agy")) _, state = _state_of(tmp_path) assert state["host"] == "agy" - assert state["runtimes"] == ["claude", "codex", "kiro"] - assert state["impl_capable"] == ["claude", "codex", "agy", "kiro"] + assert state["runtimes"] == ["codex", "agy", "kiro"] def test_init_runs_the_baseline_test(run_init, tmp_path): @@ -256,19 +362,74 @@ def _parsed_init_args(patch_lib, refactor, monkeypatch, *extra): return captured -def test_the_round_caps_have_their_own_defaults(patch_lib, refactor, monkeypatch): +def test_the_round_caps_are_unset_in_the_arguments(patch_lib, refactor, monkeypatch): + """引数の既定は未指定で、再開で「渡さなかった」と読める(#727 の決定 13)。""" + captured = _parsed_init_args(patch_lib, refactor, monkeypatch) + for key in ("max_test_rounds", "max_outer_rounds", "max_fix_rounds", + "max_items_per_round", "test_timeout", "severity_threshold", + "workflow_step", "include", "exclude", "require_all"): + assert captured[key] is None, key + + +def test_a_new_run_fills_the_round_caps_with_their_defaults(run_init, tmp_path): """E1 — 4 つの上限は別々の単位に掛かる(#436 決定 8)。 - `--max-outer-rounds` が 3 でよいのは、適用ラウンドを分けたことで**1 回の提案で - 通せる件数が上限に縛られなくなった**ためである。輪番の 1 周を根拠にしない - (適用の担当は適用ラウンドごとに進むので、1 つの提案ラウンドでも輪番は 1 周 - しうる)。 + 新規の初期化が現行の既定(提案 3 / テスト整備 2 / 修正 3 / 採用 5)へ置き換える。 """ - captured = _parsed_init_args(patch_lib, refactor, monkeypatch) - assert captured["max_test_rounds"] == 2 - assert captured["max_outer_rounds"] == 3 - assert captured["max_fix_rounds"] == 3 - assert captured["max_items_per_round"] == 5 + run_init(_args(tmp_path, max_outer_rounds=None, max_test_rounds=None, + max_fix_rounds=None, max_items_per_round=None, + test_timeout=None, severity_threshold=None, workflow_step=None)) + _, state = _state_of(tmp_path) + assert (state["max_outer_rounds"], state["max_test_rounds"], + state["max_fix_rounds"], state["max_items_per_round"]) == (3, 2, 3, 5) + assert state["test_timeout"] == 900 + assert state["severity_threshold"] == "minor" + assert state["workflow_step"] is False + + +def test_include_and_exclude_parse_names_and_none(patch_lib, refactor, monkeypatch): + """カンマ区切りと繰り返しの両方を受ける。綴りの誤りは argparse が弾く。""" + captured = _parsed_init_args(patch_lib, refactor, monkeypatch, + "--exclude", "kiro", "--include", "agy,claude", + "--require-all") + assert captured["exclude"] == [["kiro"]] + assert captured["include"] == [["agy", "claude"]] + assert captured["require_all"] is True + assert _parsed_init_args(patch_lib, refactor, monkeypatch, + "--exclude", "none")["exclude"] == [["none"]] + with pytest.raises(SystemExit) as e: + _parsed_init_args(patch_lib, refactor, monkeypatch, "--exclude", "gemini") + assert e.value.code == 2 + + +@pytest.mark.parametrize("empty", ["", " ", ","]) +def test_empty_include_is_rejected_before_init_runs( + patch_lib, refactor, monkeypatch, empty): + """R1-004 — 空の `--include` は argparse の型が弾き、初期化へ進まない。 + + `runtime_list` が空の値で `ArgumentTypeError` を上げ、argparse が終了コード 2 で + 止める。`cmd_init` は差し替えた入口を通らないため、捕えた引数は空のままになる。 + """ + captured = {} + monkeypatch.setattr(refactor, "cmd_init", + lambda args: captured.update(vars(args))) + monkeypatch.setattr( + refactor.sys, "argv", + ["refactor.py", "init", "130", "--scope", "src", "--host", "claude", + "--baseline-test", "true", "--include", empty], + ) + with pytest.raises(SystemExit) as e: + refactor.main() + assert e.value.code == 2 + assert captured == {}, "初期化処理へ進んでいる" + + +def test_none_mixed_with_a_runtime_name_in_exclude_stops_the_init(run_init, tmp_path): + """R1-004 — none と実行者名を混在させた `--exclude` は中断し、状態を作らない。""" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, exclude=[["none", "kiro"]]), probe={}) + assert e.value.code == refactor_abort() + assert not _state_path(tmp_path).exists() def test_the_ci_check_is_not_set_by_default(patch_lib, refactor, monkeypatch): @@ -319,13 +480,37 @@ def test_init_is_idempotent(run_init, tmp_path, capsys): def test_init_emits_shell_assignments(run_init, tmp_path, capsys): run_init(_args(tmp_path)) out = capsys.readouterr().out - assert "RUNTIMES_CSV=codex,agy,kiro" in out + assert "RUNTIMES_CSV=claude,codex,kiro" in out # 空白を含む値は必ず引用する。引用しないと呼び出し側の eval で語が割れる。 - assert "RUNTIMES='codex agy kiro'" in out - assert "IMPL_POOL='claude codex agy kiro'" in out + assert "RUNTIMES='claude codex kiro'" in out + assert "IMPL_POOL=" not in out assert "TMP_DIR=" in out and "WORK=" in out +def test_init_emits_the_stall_timeout_for_the_implementer(run_init, tmp_path, capsys): + """AC40: 無進捗の許容は、テストの制限時間に 900 秒を足した値である。 + + 適用と修正の担当はテストを 1 回実行し、その間は何も出力しない。制限時間 + そのままでは実行中に打ち切られる。 + """ + args = _args(tmp_path) + args.test_timeout = 900 # `--test-timeout` の既定 + + run_init(args) + + assert "IMPL_STALL_TIMEOUT=1800" in capsys.readouterr().out + + +def test_the_stall_timeout_follows_the_test_timeout(run_init, tmp_path, capsys): + """AC40: テストの制限時間を変えると、無進捗の許容も一緒に動く。""" + args = _args(tmp_path) + args.test_timeout = 1200 + + run_init(args) + + assert "IMPL_STALL_TIMEOUT=2100" in capsys.readouterr().out + + def test_existing_worktree_is_synced_to_origin(run_init, tmp_path, origin_repo): """再開までに head が進んでいたら、追いついてから始めること。 @@ -397,161 +582,109 @@ def test_init_records_the_vocabulary_for_the_prompt(run_init, tmp_path, vocabula assert state["vocabulary"]["smells"] == vocabulary.SMELLS -def _probe_result(cmd_setup, refactor, monkeypatch, outcomes): - """認証確認コマンドの結果を差し替える。`{ランタイム: (rc, 出力)}`。""" - def fake_run(cmd, **kwargs): - for runtime, probe in cmd_setup.auth.AUTH_PROBES.items(): - if list(cmd) == list(probe): - rc, out = outcomes.get(runtime, (0, "ok")) - return subprocess.CompletedProcess(cmd, rc, out, "") - raise AssertionError(f"想定外の呼び出し: {cmd}") - monkeypatch.setattr(cmd_setup.auth.subprocess, "run", fake_run) - - -def test_check_auth_passes_when_every_cli_is_logged_in(refactor, cmd_setup, monkeypatch): - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {}) - results = cmd_setup.check_auth(["claude", "codex", "agy", "kiro"]) - assert all(r["ok"] for r in results.values()) - - -def test_check_auth_fails_on_a_non_zero_exit(refactor_lib, cmd_setup, refactor, monkeypatch): - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {"kiro": (1, "")}) - with pytest.raises(SystemExit) as e: - cmd_setup.check_auth(["claude", "codex", "agy", "kiro"]) - assert e.value.code == refactor_abort() +# ---------- 再開(#727 / #648 の決定 13〜16) ---------- -def test_check_auth_fails_when_the_output_says_not_logged_in(refactor, cmd_setup, monkeypatch): - """終了コード 0 でも未認証を示すことがある(kiro は成否を終了コードで表さない)。""" - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {"kiro": (0, "Not logged in")}) - with pytest.raises(SystemExit): - cmd_setup.check_auth(["claude", "codex", "agy", "kiro"]) - - -def test_check_auth_fails_when_the_cli_is_missing(refactor, cmd_setup, monkeypatch): - cmd_setup = sys.modules["refactor_lib.commands.setup"] - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - - def missing(cmd, **kwargs): - raise FileNotFoundError(cmd[0]) - - monkeypatch.setattr(cmd_setup.auth.subprocess, "run", missing) - with pytest.raises(SystemExit): - cmd_setup.check_auth(["codex"]) - - -def test_check_auth_can_be_skipped_explicitly(refactor, cmd_setup, monkeypatch): - """確認コマンドは CLI の版で変わる。飛ばせる逃げ道を残す。""" - monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") - - def never(cmd, **kwargs): - raise AssertionError("認証確認を実行してはいけない") - - monkeypatch.setattr(cmd_setup.auth.subprocess, "run", never) - assert cmd_setup.check_auth(["codex", "agy"]) == {} - - -def test_init_checks_cli_authentication(patch_lib, refactor, cmd_setup, origin_repo, monkeypatch, tmp_path): - """未認証の CLI があれば初期化ごと中断すること。 - - 参加者が 1 人欠けた構成のまま進むと、その者の提案とレビューが無いまま収束する。 - """ - monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) - monkeypatch.chdir(origin_repo) - monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) - _probe_result(cmd_setup, refactor, monkeypatch, {"agy": (1, "Authentication failed")}) - patch_lib("sh", - lambda cmd, **k: pytest.fail("認証確認より前に gh を呼んでいる"), - ) - with pytest.raises(SystemExit) as e: - cmd_setup.cmd_init(_args(tmp_path)) - assert e.value.code == refactor_abort() - - -def test_init_downgrades_the_posting_event_on_own_pull_request(run_init, tmp_path): - """自分の Pull Request では投稿の event を `COMMENT` へ倒すこと。 - - GitHub は自分の Pull Request への `APPROVE` と `REQUEST_CHANGES` を - `HTTP 422` で拒む。倒さないとレビュー担当が投稿に失敗する。 - """ - run_init(_args(tmp_path), viewer="me") +@pytest.mark.parametrize("arg, value", [ + ("max_outer_rounds", 5), ("max_test_rounds", 4), + ("max_fix_rounds", 6), ("max_items_per_round", 8), +]) +def test_resume_reflects_a_changed_cap(run_init, tmp_path, capsys, arg, value): + """AC38 — 上限は再開で渡せば反映し、`旧 → 新` を 1 行出し、記録に 1 件積む。""" + run_init(_args(tmp_path)) + _, before = _state_of(tmp_path) + capsys.readouterr() + + run_init(_args(tmp_path, **{arg: value})) + _, after = _state_of(tmp_path) + assert after[arg] == value + assert f"{arg}: {before[arg]} → {value}" in capsys.readouterr().err + assert [c["field"] for c in after["resume_changes"]] == [arg] + + +@pytest.mark.parametrize("over, option", [ + ({"model": ["codex=x"]}, "--model"), + ({"host": "codex"}, "--host"), + ({"scope": ["other", "tests"]}, "--scope"), + ({"baseline_test": "pytest -q"}, "--baseline-test"), + ({"severity_threshold": "major"}, "--severity-threshold"), +]) +def test_resume_notifies_arguments_it_does_not_reflect( + run_init, tmp_path, capsys, origin_repo, over, option): + """AC39 — 反映しない引数は状態を変えず、引数ごとに 1 行知らせる。""" + (origin_repo / "other").mkdir(exist_ok=True) + run_init(_args(tmp_path)) + path, before = _state_of(tmp_path) + capsys.readouterr() + + run_init(_args(tmp_path, **over)) + _, after = _state_of(tmp_path) + err = capsys.readouterr().err + assert f"ℹ {option} は再開では反映しません" in err + for key in ("models", "host", "target_scope", "baseline_test", "severity_threshold"): + assert after[key] == before[key], key + assert after["resume_changes"] == [] + + +def test_resume_without_arguments_changes_nothing(run_init, tmp_path, capsys): + """AC39 — 何も渡さない再開では、上限・モデル・参加者が変わらず、確認もしない。""" + run_init(_args(tmp_path), probe={}) + _, before = _state_of(tmp_path) + + run_init(_args(tmp_path), probe={"kiro": "Not logged in"}) + _, after = _state_of(tmp_path) + assert run_init.probed == [], "担当に関わる引数を渡していないのに確かめ直している" + for key in ("max_outer_rounds", "max_test_rounds", "max_fix_rounds", + "max_items_per_round", "models", "runtimes", "participants"): + assert after[key] == before[key], key + assert "再開では反映しません" not in capsys.readouterr().err + + +def test_resume_with_exclude_rebuilds_the_participants(run_init, tmp_path): + """AC40 — 外す者を渡した再開では確かめ直し、渡さなかった足す者は記録から補う。""" + run_init(_args(tmp_path, include=[["agy"]]), probe={}) + run_init(_args(tmp_path, exclude=[["kiro"]]), probe={}) _, state = _state_of(tmp_path) - assert state["is_own_pr"] is True - assert state["event_downgrade"] is True + assert run_init.probed == [["claude", "codex", "agy"]] + assert state["runtimes"] == ["claude", "codex", "agy"] + assert state["participants"]["included"] == ["agy"] + assert state["participants"]["excluded"] == ["kiro"] + changes = state["resume_changes"] + assert [c["field"] for c in changes] == ["participants"], "作り直しは 1 件として積む" + assert changes[0]["from"]["available"] == ["claude", "codex", "agy", "kiro"] -def test_init_keeps_the_posting_event_on_someone_elses_pull_request(run_init, tmp_path): - """他者の Pull Request では判定をそのまま投稿すること。""" - run_init(_args(tmp_path), viewer="someone-else") - _, state = _state_of(tmp_path) - assert state["is_own_pr"] is False - assert state["event_downgrade"] is False - +def test_resume_with_include_adds_the_participant_worktree(run_init, tmp_path): + """足す者を渡した再開では参加者と作業ツリーの対応をともに補う。""" + run_init(_args(tmp_path), probe={}) -def test_init_continues_when_the_viewer_cannot_be_read(run_init, tmp_path): - """ログイン名を読めない環境でも `init` を続けること。 + run_init(_args(tmp_path, include=[["agy"]]), probe={}) - bot トークン(Actions の `GITHUB_TOKEN` など)は `/user` を読めず - `HTTP 403` を返す。この値は自分の Pull Request かどうかの判定にしか - 使わないので、読めなければ他者の Pull Request として扱う。 - """ - run_init(_args(tmp_path), viewer=None) _, state = _state_of(tmp_path) - assert state["is_own_pr"] is False - assert state["event_downgrade"] is False - - -def test_init_fills_the_posting_event_when_resuming_an_old_state(run_init, tmp_path): - """この指示が入る前の状態ファイルから再開しても投稿の event を倒すこと。 - - 再開の分岐は状態ファイルをそのまま使って戻る。項目が無い状態ファイルを - そのまま渡すと、起動側は空の指示を読み、自分の Pull Request で - `HTTP 422` を踏み続ける。 - """ - run_init(_args(tmp_path), viewer="me") - path, state = _state_of(tmp_path) - # 旧版が書いた状態ファイル(2 項目が無い)を再現する - for key in ("is_own_pr", "event_downgrade"): - state.pop(key) - state["outer_round"] = 2 - path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") - - run_init(_args(tmp_path), viewer="me") - - _, resumed = _state_of(tmp_path) - assert resumed["outer_round"] == 2, "再開であって初期化ではないこと" - assert resumed["is_own_pr"] is True - assert resumed["event_downgrade"] is True + assert state["runtimes"] == ["claude", "codex", "agy", "kiro"] + assert state["worktrees"]["agy"] == str(tmp_path / "rf130" / "agy") + changes = state["resume_changes"] + assert [change["field"] for change in changes] == ["participants"] + assert changes[0]["to"]["available"] == state["runtimes"] -# ---------- 改修計画の書き出し先 ---------- - -def test_init_defaults_the_plan_to_a_pull_request_comment(run_init, tmp_path): - """D4 — 既定は Pull Request のコメント 1 件(#436 決定 6)。 - - **改修計画は実行の記録であって、リポジトリの知識ではない。** ファイルに - すると差分に混ざり、URL がブランチの後片付けで切れる。 - """ - run_init(_args(tmp_path)) +def test_resume_with_none_clears_the_recorded_names(run_init, tmp_path): + """予約語 `none` は記録の一覧を空へ戻す(決定 15)。""" + run_init(_args(tmp_path, exclude=[["kiro"]]), probe={}) + run_init(_args(tmp_path, exclude=[["none"]]), probe={}) _, state = _state_of(tmp_path) - assert state["plan_mode"] == "comment" - assert state["plan_file"] == "" - + assert state["participants"]["excluded"] == [] + assert state["runtimes"] == ["claude", "codex", "kiro"] -def test_init_keeps_an_explicit_plan_file(run_init, tmp_path): - """**`--plan-file` は残す。** 明示したときだけファイルにする。""" - run_init(_args(tmp_path, plan_file="docs/plan.md")) - _, state = _state_of(tmp_path) - assert state["plan_mode"] == "file" - assert state["plan_file"] == "docs/plan.md" +def test_a_failed_rebuild_leaves_the_state_untouched(run_init, tmp_path): + """作り直しが 0 者なら終了コード 4 で止め、状態ファイルを書き換えない。""" + run_init(_args(tmp_path), probe={}) + path, _ = _state_of(tmp_path) + before = path.read_text(encoding="utf-8") -def test_init_accepts_an_empty_plan_file_as_off(run_init, tmp_path): - """計画を残したくないリポジトリのために、空文字で無効にできる。""" - run_init(_args(tmp_path, plan_file="")) - _, state = _state_of(tmp_path) - assert state["plan_mode"] == "none" - assert state["plan_file"] == "" + with pytest.raises(SystemExit) as e: + run_init(_args(tmp_path, exclude=[["kiro"]], max_outer_rounds=9), + probe={"claude": "x", "codex": "y"}) + assert e.value.code == refactor_abort() + assert path.read_text(encoding="utf-8") == before diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_intake.py b/plugins/ndf/skills/cross-refactoring/tests/test_intake.py new file mode 100644 index 00000000..18fda83f --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_intake.py @@ -0,0 +1,220 @@ +"""取り込みの共通手順(#728)。 + +3 つの取り込み(適用・修正・最終ゲートの修正)が、結果を残さなかった起動を同じ +手順で閉じることを確かめる。**共通層の読み取りは差し替えない。** 一時ディレクトリに +結果ファイルと監視の結果ファイルを置いて本物を通す。 +""" +from __future__ import annotations + +import json +import types + +import pytest + +from crossref_helpers import make_state, read_state + + +def _outcome(reason=None, payload=None, detail="", relaunch=True): + """`LaunchOutcome` と同じ欄を持つ値。読む側は 5 つの欄しか見ない。""" + return types.SimpleNamespace( + payload=payload, reason=reason, detail=detail, monitor=None, + relaunch_same_agent=relaunch, + ) + + +def _write_monitor(state_path, stem, reason, detail="打ち切りました"): + out = state_path.parent / f"{stem}-monitor.json" + out.write_text( + json.dumps({"reason": reason, "detail": detail}, ensure_ascii=False), + encoding="utf-8", + ) + return out + + +# ---------- 結末の読み取り(AC1 / AC2 / AC3) ---------- + +def test_a_missing_result_file_is_read_as_a_value(gitfacts, tmp_path, capsys): + """AC1: 結果ファイルが無くても中断せず、何も出力しない。""" + state = {"id": 130, "tmp_dir": str(tmp_path)} + + outcome = gitfacts.read_result(state, "agy", "apply", 1) + + assert (outcome.payload, outcome.reason) == (None, "missing") + assert capsys.readouterr() == ("", "") + + +def test_the_monitor_reason_decides_whether_the_same_agent_can_be_relaunched( + gitfacts, tmp_path +): + """AC2: 無進捗は起動し直せる。利用上限は起動し直せない。""" + state = {"id": 130, "tmp_dir": str(tmp_path)} + state_path = tmp_path / "dummy" + + (tmp_path / "agy-apply-r1-monitor.json").write_text( + json.dumps({"reason": "stalled", "detail": "無進捗"}), encoding="utf-8") + stalled = gitfacts.read_result(state, "agy", "apply", 1) + + (tmp_path / "claude-apply-r1-monitor.json").write_text( + json.dumps({"reason": "usage_limit", "detail": "上限"}), encoding="utf-8") + limited = gitfacts.read_result(state, "claude", "apply", 1) + + assert (stalled.reason, stalled.relaunch_same_agent) == ("stalled", True) + assert (limited.reason, limited.relaunch_same_agent) == ("usage_limit", False) + assert state_path.exists() is False + + +def test_the_stem_matches_the_template_the_orchestrator_passes_to_the_monitor(paths): + """AC3: 名前の幹は、骨組みが監視へ渡す雛形を担当名で埋めた値と一致する。""" + skill = ( + paths.pathlib.Path(__file__).resolve().parents[1] / "SKILL.md" + ).read_text(encoding="utf-8") + + expected = { + "apply": "{agent}-apply-r$ROUND", + "fix": "{agent}-fix-r$ROUND", + "final-fix": "{agent}-final-fix", + } + for phase, template in expected.items(): + assert f'--stem-template "{template}"' in skill, phase + built = template.replace("{agent}", "codex").replace("$ROUND", "2") + assert paths.stem_for("codex", phase, 130, 2) == built + + +# ---------- 取り込みの共通手順(AC4〜AC8) ---------- + +def _scope(intake, holder, records, phase, base_key="fix_base_sha", mirror=None): + return intake.IntakeScope( + holder=holder, base_key=base_key, records=records, phase=phase, + attempt=1, impl="agy", label="R1-A1", mirror=mirror, + ) + + +@pytest.fixture +def one_round(tmp_path): + """1 提案ラウンド・1 群の状態ファイル。""" + return make_state( + tmp_path, + items=[{"item_id": "R1-001", "path": "src/a.py", "symbol": "f", + "smell": "long_method", "status": "pending", "round": 1, + "commits": []}], + rounds=[{ + "round": 1, "impl": "codex", "items": ["R1-001"], + "apply_base_sha": "base0", "fix_base_sha": "base0", + "fix_rounds": 0, "fix_attempts": 1, + "apply_rounds": [{ + "apply_round": 1, "impl": "agy", + "impl_model": {"requested": None, "observed": None}, + "items": ["R1-001"], "status": "pending", + "base_sha": "base0", "head_sha": None, "fix_rounds": 0, + "attempt": 1, + }], + "apply_round": 1, "apply": {"applied": [], "failed": []}, + "durations": {}, "reviews": [], + }], + final_gate={"fix_rounds": 1, "checks": [], "impl": "agy", + "fix_base_sha": "base0"}, + ) + + +@pytest.mark.parametrize( + "phase,base_key,records_from", + [("apply", "apply_base_sha", "group"), + ("fix", "fix_base_sha", "group"), + ("final-fix", "fix_base_sha", "gate")], +) +def test_a_commit_in_range_is_reverted_and_the_base_moves_to_the_new_head( + intake, patch_lib, one_round, no_git, phase, base_key, records_from +): + """AC4 / AC5: 範囲のコミットを取り消し、起点を取り消し後の先端へ進める。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + gate = state["final_gate"] + holder = gate if records_from == "gate" else entry + records = gate if records_from == "gate" else group + patch_lib("commits_in_range", lambda work, base, head: ["c2", "c1"]) + patch_lib("git_out", lambda work, args, **kw: "newhead") + scope = _scope(intake, holder, records, phase, base_key=base_key, + mirror=group if phase == "apply" else None) + + closed = intake.close_without_result( + one_round, state, scope, _outcome(reason="stalled", detail="無進捗")) + + assert (closed.reverted, closed.range_unknown) == (2, False) + assert holder[base_key] == "newhead" + if phase == "apply": + assert group["base_sha"] == "newhead" + assert records["failed_attempts"] == [{ + "phase": phase, "attempt": 1, "impl": "agy", "reason": "stalled", + "detail": "無進捗", "at": records["failed_attempts"][0]["at"], "reverted": 2, + }] + assert [c[-1] for c in no_git if c[:2] == ["git", "revert"]] == ["c2", "c1"] + + +def test_an_empty_range_runs_neither_revert_nor_push( + intake, patch_lib, one_round, no_git +): + """AC6: 範囲にコミットが無ければ、取り消しも公開もしない。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + patch_lib("commits_in_range", lambda work, base, head: []) + patch_lib("git_out", lambda work, args, **kw: "head0") + scope = _scope(intake, entry, group, "fix") + + closed = intake.close_without_result(one_round, state, scope, _outcome("missing")) + + assert closed.reverted == 0 + assert [c for c in no_git if c[:2] in (["git", "revert"], ["git", "push"])] == [] + assert len(group["failed_attempts"]) == 1 + + +def test_the_same_attempt_is_not_recorded_twice(intake, patch_lib, one_round): + """AC7: 同じ工程・同じ試行番号は 1 件しか記録しない。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + patch_lib("commits_in_range", lambda work, base, head: []) + patch_lib("git_out", lambda work, args, **kw: "head0") + scope = _scope(intake, entry, group, "fix") + + intake.close_without_result(one_round, state, scope, _outcome("missing")) + assert intake.already_closed(scope) is True + + intake.close_without_result(one_round, state, scope, _outcome("missing")) + assert len(group["failed_attempts"]) == 2, "呼べば足すのは共通手順の責務である" + + +def test_a_range_that_cannot_be_determined_records_nothing( + intake, patch_lib, one_round, no_git +): + """AC8: 範囲を確定できないときは、取り消しも記録もしない。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + patch_lib("commits_in_range", lambda work, base, head: None) + patch_lib("git_out", lambda work, args, **kw: "head0") + scope = _scope(intake, entry, group, "fix") + + closed = intake.close_without_result(one_round, state, scope, _outcome("missing")) + + assert closed.range_unknown is True + assert "failed_attempts" not in group + assert [c for c in no_git if c[:2] == ["git", "revert"]] == [] + + +def test_the_failed_agents_are_listed_in_the_order_they_were_recorded( + intake, patch_lib, one_round +): + """交代先を決めるために、失敗した担当を記録の順で読めること。""" + state = read_state(one_round) + entry = state["rounds"][0] + group = entry["apply_rounds"][0] + group["failed_attempts"] = [ + {"phase": "apply", "attempt": 1, "impl": "agy", "reason": "stalled"}, + {"phase": "fix", "attempt": 1, "impl": "kiro", "reason": "missing"}, + {"phase": "apply", "attempt": 2, "impl": "codex", "reason": "missing"}, + ] + scope = _scope(intake, entry, group, "apply", base_key="apply_base_sha") + + assert intake.failed_impls(scope) == ["agy", "codex"] diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py index 595f7c1a..6ca8462f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_launch_agy_phases.py @@ -59,8 +59,7 @@ def _launch(tmp_path: pathlib.Path, phase: str) -> tuple[list[str], pathlib.Path subprocess.run( [str(LAUNCH), RUNTIME, phase, "130", "1"], env={ - # 実行した人の `MONITOR_*` で上限が変わらないよう外す(#678)。 - **{k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")}, + **os.environ, "CROSS_REFACTORING_TMP_DIR": str(state_path.parent), "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "NDF_TEST_ARGS_FILE": str(args_file), diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py b/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py index 9a4813b6..9f165b39 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_merge_apply.py @@ -236,9 +236,13 @@ def test_the_wider_factor_is_limited_to_the_vocabulary(vocabulary): # ---------- git から事実を取る ---------- def test_commit_trailers_are_read_from_git(patch_lib, gitfacts, monkeypatch): - """結果ファイルではなく実際のコミットメッセージから読む。""" + """結果ファイルではなく実際のコミットメッセージから読む。 + + 読むのは**題名の次の段落から後ろ**である。段落の切り分けそのものは実際の git で + 確かめる(`test_commit_trailers_git.py`)。 + """ patch_lib("git_out", - lambda work, args, **_kw: "Item-Id: R1-001\nRound: 1\n" + lambda work, args, **_kw: "Refactor: 題名\n\nItem-Id: R1-001\nRound: 1\n" "Impl-Runtime: codex\nImpl-Model: gpt-5.5", ) assert gitfacts.commit_trailers("/w", "abc") == { @@ -449,6 +453,10 @@ def test_a_verified_apply_round_marks_every_item_applied( assert state["rounds"][0]["apply"]["applied"] == ["R1-001", "R1-002"] assert all(i["status"] == "applied" for i in state["items"]) assert state["phase"] == "verify", "次はテストによる検証へ進む" + # AC46: 結果があり検証を通る適用は、1 回目の試行で取り込まれ記録を残さない + group = state["rounds"][0]["apply_rounds"][0] + assert "failed_attempts" not in group + assert "drop_reason" not in group def test_all_failed_exits_2(refactor, tmp_path, env_tmp_dir, no_git, git_facts): @@ -934,7 +942,7 @@ def test_unverified_baseline_blocks_every_item(refactor, tmp_path, env_tmp_dir): refactor.cmd_merge_apply( type("A", (), {"id": 130, "round": 1, "dry_run": False})() ) - assert e.value.code == 2 + assert e.value.code == 4 assert read_state(state_path)["items"][0]["status"] == "blocked" @@ -955,7 +963,7 @@ def test_unknown_baseline_also_blocks(refactor, tmp_path, env_tmp_dir): refactor.cmd_merge_apply( type("A", (), {"id": 130, "round": 1, "dry_run": False})() ) - assert e.value.code == 2 + assert e.value.code == 4 assert read_state(state_path)["items"][0]["status"] == "blocked" @@ -1005,7 +1013,7 @@ def test_range_that_cannot_be_determined_fails_closed(patch_lib, refactor, tmp_p refactor.cmd_merge_apply( type("A", (), {"id": 130, "round": 1, "dry_run": False})() ) - assert e.value.code == 2 + assert e.value.code == 4 assert read_state(state_path)["items"][0]["status"] == "blocked" @@ -1101,10 +1109,27 @@ def test_broken_apply_result_does_not_crash( assert read_state(state_path)["items"][0]["status"] == "abandoned" -def test_non_object_result_file_fails(refactor, tmp_path, env_tmp_dir, no_git): - """結果が JSON オブジェクトでなければ、読み込みの時点で弾く。""" +def test_non_object_result_file_is_a_missing_result( + refactor, tmp_path, env_tmp_dir, no_git +): + """結果が JSON オブジェクトでなければ、結果なしとして扱う。 + + 呼び出し側は `payload.get(...)` を呼ぶため、辞書でないものを渡すと進行が + 止まる。取り消しと記録を通し、理由 `unparsable` を残して次の試行へ渡す。 + """ items = [item(item_id="R1-001")] - state_path = _state_with_items(tmp_path, items) + state_path = _state_with_items( + tmp_path, items, + rounds_override=[{ + "apply_round": 1, "impl": "codex", + "impl_model": {"requested": "gpt-5.5", "observed": None}, + "items": ["R1-001"], "status": "pending", + "base_sha": "base0", "head_sha": None, "fix_rounds": 0, "attempt": 1, + }], + ) + state = read_state(state_path) + state["rounds"][0]["apply_base_sha"] = "base0" + state_path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") env_tmp_dir(state_path) write_result(state_path, "codex-apply-r1", ["配列で返ってきた"]) with pytest.raises(SystemExit) as e: @@ -1113,6 +1138,9 @@ def test_non_object_result_file_fails(refactor, tmp_path, env_tmp_dir, no_git): ) assert e.value.code == 2 + group = read_state(state_path)["rounds"][0]["apply_rounds"][0] + assert [r["reason"] for r in group["failed_attempts"]] == ["unparsable"] + def test_the_group_sharing_one_commit_is_accepted(paths, patch_lib, refactor, tmp_path, env_tmp_dir, monkeypatch, git_facts): """群の全項目が同じコミットを申告するのが**正しい形**である(決定 2)。 diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py b/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py index 49cc39ba..4a20f613 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_models_and_metrics.py @@ -343,8 +343,7 @@ def test_models_are_fixed_across_rounds(cmd_setup, tmp_path, env_tmp_dir): state = read_state(state_path) entry = state["rounds"][-1] assert entry["impl_model"]["requested"] == state["models"][entry["impl"]] - for r in entry["reviewers"]: - assert entry["reviewer_models"][r]["requested"] == state["models"][r] + assert "reviewer_models" not in entry # 次のラウンドを開けるように、いま開いたラウンドを閉じる entry["adopted"] = 1 state_path.write_text(json.dumps(state, ensure_ascii=False), encoding="utf-8") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py b/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py index 83ad6a49..15d8599f 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_monitor_phase_calls.py @@ -21,7 +21,7 @@ "SKILL.md": 4, "docs/01-state-and-propose.md": 1, "docs/02-apply-and-review.md": 2, - "docs/04-fix-and-report.md": 1, + "docs/04-fix-and-report.md": 2, } # 監視の雛形(stem)から、渡すべき工程を決める。 @@ -74,11 +74,14 @@ def test_the_call_does_not_pass_a_timeout(rel: str, call: str) -> None: @pytest.mark.skipif(shutil.which("grep") is None, reason="grep が無い") -def test_the_acceptance_grep_returns_8() -> None: - """要求の文書の AC38 のコマンドをそのまま実行する。""" +def test_the_acceptance_grep_returns_9() -> None: + """要求の文書の AC38 のコマンドをそのまま実行する。 + + 最終ゲートの修正の監視を修正の説明へも書いたため、9 件になる(#728)。 + """ command = ( 'grep -rn -A2 "monitor.py" plugins/ndf/skills/cross-refactoring/SKILL.md ' 'plugins/ndf/skills/cross-refactoring/docs | grep -c -- "--phase"' ) r = subprocess.run(["bash", "-c", command], cwd=REPO, capture_output=True, text=True) - assert r.stdout.strip() == "8", r.stdout + r.stderr + assert r.stdout.strip() == "9", r.stdout + r.stderr diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_paths.py b/plugins/ndf/skills/cross-refactoring/tests/test_paths.py index f113e556..3d2bc91a 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_paths.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_paths.py @@ -9,6 +9,7 @@ import json import pathlib +import tempfile STATE_ID = 130 @@ -82,3 +83,18 @@ def test_it_uses_the_cwd_when_the_env_var_is_unset(paths, tmp_path, monkeypatch) assert path == cwd_path assert state["phase"] == "from-cwd" + + +def test_the_explicit_worktree_base_is_resolved(paths, tmp_path, monkeypatch): + """現状固定: 明示した作業ディレクトリの親を絶対パスへ解決する。""" + monkeypatch.chdir(tmp_path) + monkeypatch.setenv("NDF_WORKTREE_BASE", "relative-worktrees") + + assert paths.default_worktree_base() == (tmp_path / "relative-worktrees").resolve() + + +def test_the_worktree_base_falls_back_to_the_system_tmpdir(paths, monkeypatch): + """現状固定: 明示指定が無ければシステム tmpdir 配下を使う。""" + monkeypatch.delenv("NDF_WORKTREE_BASE", raising=False) + + assert paths.default_worktree_base() == pathlib.Path(tempfile.gettempdir()) / "ndf-worktrees" diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py b/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py index a37e8522..077f3894 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_plan_comment.py @@ -134,6 +134,14 @@ def test_the_body_carries_the_marker_and_the_plan(plan, tmp_path): assert "R1-001" in body and "src/foo.py" in body +def test_the_round_heading_has_no_reviewers(plan, tmp_path): + """AC37 — 改修計画のラウンドの見出しにレビュー担当を出さない。古い記録にあっても。""" + _, state = _state(tmp_path) + body = plan.plan_comment_body(state) + assert "## ラウンド 1(実装 codex)" in body + assert "レビュー" not in body.split("## ラウンド 1", 1)[1].splitlines()[0] + + def test_a_failed_post_does_not_stop_the_run(plan, tmp_path, gh): """記録が残らないことと、変更が検証を通っていないことは別である。""" _, state = _state(tmp_path) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py index f7221bb6..d6adf114 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_rounds.py @@ -28,16 +28,16 @@ def round_of(round_no, **over): # ---------- start-round ---------- def test_start_round_opens_and_records_assignment(refactor, tmp_path, env_tmp_dir): - state_path = make_state(tmp_path) + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"]) env_tmp_dir(state_path) refactor.cmd_start_round(_args()) state = read_state(state_path) assert len(state["rounds"]) == 1 entry = state["rounds"][0] + # ラウンド 1 は参加者の 2 番目から始まり、ホストが最初に適用しない(#727 の決定 7) assert entry["impl"] == "codex" - assert entry["reviewers"] == ["agy", "kiro"] - assert entry["impl"] not in entry["reviewers"] + assert "reviewers" not in entry assert state["phase"] == "propose" @@ -79,6 +79,16 @@ def test_start_round_stops_when_already_final(refactor, tmp_path, env_tmp_dir): # ---------- advance(収束判定) ---------- +def test_advance_with_no_rounds_leaves_the_state_unchanged(cmd_report, tmp_path, env_tmp_dir): + state_path = make_state(tmp_path, rounds=[], final=None) + env_tmp_dir(state_path) + before = read_state(state_path) + + cmd_report.cmd_advance(_args()) + + assert read_state(state_path) == before + + def test_advance_continues_when_progress_is_made(cmd_report, tmp_path, env_tmp_dir): state_path = make_state(tmp_path, rounds=[round_of(1)]) env_tmp_dir(state_path) @@ -156,10 +166,75 @@ def test_report_renders_tables(cmd_report, tmp_path, env_tmp_dir, capsys): assert "R1-001" in out -def test_status_reports_cohorts(cmd_report, tmp_path, env_tmp_dir, capsys): - state_path = make_state(tmp_path) +def test_status_reports_one_cohort(cmd_report, tmp_path, env_tmp_dir, capsys): + """AC37 — 母集合は 1 行で出す。提案と適用で分けない(#727 の決定 5)。""" + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"]) + env_tmp_dir(state_path) + cmd_report.cmd_status(_args()) + out = capsys.readouterr().out + assert "参加者(提案と適用): claude / codex / kiro" in out + assert "提案・レビュー" not in out + assert "適用の母集合" not in out + + +def test_status_reads_an_older_state_with_the_implementation_cohort( + cmd_report, tmp_path, env_tmp_dir, capsys): + """AC41 — 適用専用の母集合を持つ古い状態ファイルも読める。表示は参加者の一覧だけ。""" + state_path = make_state(tmp_path, impl_capable=["claude", "codex", "agy", "kiro"]) env_tmp_dir(state_path) cmd_report.cmd_status(_args()) + assert "参加者(提案と適用): codex / agy / kiro" in capsys.readouterr().out + + +def _report_args(): + return type("A", (), {"id": 130, "metrics": False})() + + +def test_report_has_no_reviewer_column(cmd_report, tmp_path, env_tmp_dir, capsys): + """AC37 — ラウンド表にレビュー担当の列が無い。古い記録にあっても出さない。""" + state_path = make_state(tmp_path, rounds=[{ + "round": 1, "kind": "structure", "impl": "codex", + "impl_model": {"requested": None, "observed": None}, + "reviewers": ["agy", "kiro"], "reviewer_models": {}, "adopted": 1, + "apply": {"applied": [], "failed": []}, "fix_rounds": 0, "reviews": [], + }]) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) out = capsys.readouterr().out - assert "提案・レビュー: codex / agy / kiro" in out - assert "適用の母集合: claude / codex / kiro" in out + header = next(line for line in out.splitlines() if line.startswith("| R |")) + assert "レビュー担当" not in header + assert "| 1 | 構造改善 | codex |" in out + assert "agy / kiro" not in out + + +def test_report_prints_the_participants(cmd_report, tmp_path, env_tmp_dir, capsys): + """F6 — 参加者・外した者・足した者・確認を通らなかった者・再開で変えた値を出す。""" + state_path = make_state( + tmp_path, runtimes=["claude", "codex"], + participants={ + "pool": ["claude", "codex", "kiro"], "included": ["agy"], + "excluded": ["kiro"], "available": ["claude", "codex"], + "unavailable": {"agy": "Not logged in"}, + "probe_skipped": False, "require_all": False, + }, + resume_changes=[{"at": "2026-09-22T00:00:00", "field": "max_outer_rounds", + "from": 3, "to": 5}], + ) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + out = capsys.readouterr().out + assert "## 参加した者" in out + assert "- 母集合: claude / codex / kiro" in out + assert "- 使える者: claude / codex" in out + assert "- --exclude で外した者: kiro" in out + assert "- --include で足した者: agy" in out + assert "- 確認を通らなかった者: agy(Not logged in)" in out + assert "max_outer_rounds: 3 → 5" in out + + +def test_report_says_no_record_for_an_older_state(cmd_report, tmp_path, env_tmp_dir, capsys): + """AC41 — 参加者の記録を持たない古い状態ファイルでは「記録なし」と出す。""" + state_path = make_state(tmp_path, impl_capable=["claude", "codex", "agy", "kiro"]) + env_tmp_dir(state_path) + cmd_report.cmd_report(_report_args()) + assert "- 使える者: 記録なし" in capsys.readouterr().out diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py b/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py index 4ef02075..8277a2a3 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_skill_terms.py @@ -58,16 +58,72 @@ def test_every_cap_appears_in_the_argument_table(skill): assert f"`{cap} N`" in skill, f"引数の表に {cap} が無い" -def test_the_defaults_match_the_implementation(refactor, vocabulary, skill): - """既定値は 1 か所(`refactor.py`)が持ち、表はそれを写す。""" +def test_the_defaults_match_the_implementation(cmd_setup, vocabulary, skill): + """既定値は 1 か所(新規の初期化が置き換える表)が持ち、手順書の表はそれを写す。""" assert "| `--max-test-rounds N` | " in skill - for cap, default in (("--max-test-rounds", 2), ("--max-outer-rounds", 3), - ("--max-fix-rounds", 3), ("--max-items-per-round", 5)): + for cap in CAPS: + default = cmd_setup.NEW_RUN_DEFAULTS[cap[2:].replace("-", "_")] row = next(l for l in skill.splitlines() if l.startswith(f"| `{cap} N`")) assert f"`{default}`" in row, f"{cap} の既定が表と実装で食い違う" assert vocabulary.DEFAULT_MAX_TEST_ROUNDS == 2 +# ---------- 参加者と担当の決め方(#727 の AC42 / AC43) ---------- + +PARTICIPANT_ARGS = ("--exclude", "--include", "--require-all") +DOC01 = SKILL.parent / "docs" / "01-state-and-propose.md" +CLAUDE_MD = SKILL.parents[4] / "CLAUDE.md" + + +def test_the_participant_arguments_are_documented(skill): + """AC43 — 引数の表と `argument-hint` に 3 つの引数がある。""" + hint = next(l for l in skill.splitlines() if l.startswith("argument-hint:")) + rows = [l for l in skill.splitlines() if l.startswith("| `--")] + for arg in PARTICIPANT_ARGS: + assert arg in hint, f"argument-hint に {arg} が無い" + assert any(r.startswith(f"| `{arg}") for r in rows), f"引数の表に {arg} が無い" + + +def test_the_assignment_section_has_one_cohort(skill): + """AC43 — 担当の決め方は母集合を 1 つの表で書き、適用専用の母集合を持たない。""" + lines = skill.splitlines() + start = lines.index("## 担当の決め方") + end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) + section = "\n".join(lines[start:end]) + assert "impl_capable" not in section + assert sum(1 for l in lines[start:end] if l.startswith("| ---")) == 1 + assert "codex / kiro" in section and "--include agy" in section + + +def test_the_prerequisites_no_longer_demand_every_cli(skill): + """AC43 — 前提から「すべてログイン済み」が消え、要る CLI は codex / kiro-cli になる。""" + lines = skill.splitlines() + start = lines.index("## 前提") + end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) + section = "\n".join(lines[start:end]) + assert "すべてログイン済み" not in section + assert "| Claude Code | `codex` / `kiro-cli` |" in section + + +def test_init_variables_do_not_list_the_implementation_cohort(): + """AC43 — `init` が返す変数の表に適用専用の母集合が無い。""" + assert "IMPL_POOL" not in DOC01.read_text(encoding="utf-8") + + +def test_claude_md_describes_the_participants_and_the_rotation(): + """AC42 — 指示書の cross-refactoring の節が新しい母集合と輪番を書く(#736 を含む)。""" + text = CLAUDE_MD.read_text(encoding="utf-8") + lines = text.splitlines() + start = lines.index("## cross-refactoring") + end = next(i for i, l in enumerate(lines[start + 1:], start + 1) if l.startswith("## ")) + section = "\n".join(lines[start:end]) + assert "codex / kiro とホスト(ホストが codex / kiro なら 2 者)" in section + assert "適用担当は参加者の数のラウンドで 1 周する" in section + for stale in ("ホストを除く 3 者", "参加する 4 者", "codex / agy の両方", + "既定が 4"): + assert stale not in text, f"CLAUDE.md に {stale} が残っている" + + # ---------- 実行のコマンド列(A1 / B6) ---------- def _run_block(text: str) -> str: @@ -108,3 +164,57 @@ def test_the_command_sequence_does_not_launch_reviewers(skill): block = _run_block(skill) assert " review " not in block assert "verify-round" in block + + +# ---------- 結果なしと無進捗の許容(#728 / #647 / #553) ---------- + +DOCS = SKILL.parent / "docs" + + +def test_the_apply_round_row_states_the_attempt_cap(skill): + """AC43: 適用ラウンドの行が、同じ群を開き直す上限(2 回)を書く。""" + row = next(r for r in _terms_table(skill) if "適用ラウンド" in r) + + assert "2 回" in row + + +def test_the_single_cap_paragraph_is_gone(skill): + """AC43: 上限を 1 つに保つとしていた段落が残っていないこと。""" + assert "別の上限を置かない" not in skill + assert "別に置かない" not in skill + + +def test_every_implementer_phase_passes_the_stall_timeout(skill): + """AC41: 適用・修正・最終ゲートの修正の監視が、無進捗の許容だけを受け取る。""" + for phase in ("apply", "fix", "final-fix"): + block = skill.split(f"--phase {phase}", 1)[1].split("\n\n", 1)[0] + assert '--stall-timeout "$IMPL_STALL_TIMEOUT"' in block, phase + assert "--timeout " not in block, phase + + +def test_the_apply_document_describes_a_missing_result(skill): + """AC44: 適用の説明が、結果なしのときの取り消し・記録・終了コードを書く。""" + text = (DOCS / "02-apply-and-review.md").read_text(encoding="utf-8") + + assert "実装担当が結果を残さなかったとき" in text + assert "failed_attempts" in text + assert "終了コード" in text + assert '--stall-timeout "$IMPL_STALL_TIMEOUT"' in text + + +def test_the_fix_document_describes_a_missing_result(skill): + """AC44: 修正と最終ゲートの説明が、同じ 2 つを書く。""" + text = (DOCS / "04-fix-and-report.md").read_text(encoding="utf-8") + + assert text.count('--stall-timeout "$IMPL_STALL_TIMEOUT"') == 2 + assert "修正の担当が結果を残さなかったときも、修正ラウンドは進める" in text + assert "修正の担当が結果を残さなかったときも同じ手順を通る" in text + + +def test_the_trailer_section_states_both_ways_of_reading(skill): + """AC45: 記名の節が、人の集計と進行側の検証の 2 つの読み方を書く。""" + text = (DOCS / "02-apply-and-review.md").read_text(encoding="utf-8") + section = text.split("### コミットトレーラーの形式", 1)[1].split("\n### ", 1)[0] + + assert "最後の段落だけ" in section + assert "git interpret-trailers --parse" in section diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py b/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py index e783be16..3ff6a3a7 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_start_round_emits_runtimes.py @@ -1,10 +1,11 @@ -"""`start-round` が提案・レビューの母集合を返すこと(#518-1)。 +"""`start-round` が参加者の一覧と実装担当を返すこと(#518-1 / #727)。 **繰り返しの中で使う値は、繰り返しの中で得られる。** 母集合を `init` だけが返すと、 状態ファイルから再開する経路と、骨組みを抜粋して写す経路の両方で未定義になる。 """ from __future__ import annotations +import json import shlex import pytest @@ -56,18 +57,33 @@ def test_existing_values_are_untouched(refactor, tmp_path, env_tmp_dir, capsys): emitted = _emitted(capsys) for key in ( - "ROUND", "ROUND_KIND", "PROPOSE_PHASE", "IMPL", "IMPL_MODEL", - "REVIEWERS", "REVIEWERS_CSV", "MAX_FIX_ROUNDS", + "ROUND", "ROUND_KIND", "PROPOSE_PHASE", "IMPL", "IMPL_MODEL", "MAX_FIX_ROUNDS", ): assert key in emitted, f"{key} が出力から消えている" -def test_the_pool_is_wider_than_the_reviewers(refactor, tmp_path, env_tmp_dir, capsys): - """`REVIEWERS` で代用すると提案する者が 1 人減る。""" - state_path = make_state(tmp_path, runtimes=["codex", "agy", "kiro"]) +def test_start_round_has_no_reviewers(refactor, tmp_path, env_tmp_dir, capsys): + """AC34 — レビュー工程は #436 で消えた。存在しない役を出力にも記録にも残さない。""" + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"]) env_tmp_dir(state_path) refactor.cmd_start_round(_args()) emitted = _emitted(capsys) - assert len(emitted["RUNTIMES"].split()) == 3 - assert len(emitted["REVIEWERS"].split()) == 2 + assert "REVIEWERS" not in emitted + assert "REVIEWERS_CSV" not in emitted + entry = json.loads(state_path.read_text(encoding="utf-8"))["rounds"][0] + assert "reviewers" not in entry + assert "reviewer_models" not in entry + + +def test_the_implementer_rotates_within_the_participants( + refactor, tmp_path, env_tmp_dir, capsys): + """AC34 / AC41 — 実装担当は参加者の一覧から決まる。適用専用の母集合は読まない。""" + state_path = make_state(tmp_path, runtimes=["claude", "codex", "kiro"], + impl_capable=["claude", "codex", "agy", "kiro"]) + env_tmp_dir(state_path) + impls = [] + for _ in range(3): + refactor.cmd_start_round(_args()) + impls.append(_emitted(capsys)["IMPL"]) + assert impls == ["codex", "kiro", "claude"] diff --git a/plugins/ndf/skills/cross-review/SKILL.md b/plugins/ndf/skills/cross-review/SKILL.md index 15506ea2..67a9eb5e 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を繰り返す。 * 担当のいずれかが不具合などで実行できなくなった場合は異常終了とする @@ -49,8 +50,8 @@ state.json の読み書きや AI launcher 起動・完了待ちは全て委譲 | 観点 | 方針 | |---|---| -| レビュー投稿 | **AI 自身が `gh api` で PR に直接投稿**。メインはペイロードを保持しない | -| 投稿の確認 | **申告されたコメント数を GitHub 側と突き合わせる**。投稿が届いていなければ中断する(取得できない場合は申告を採用) | +| 投稿の担い手 | **GitHub と git へ書くのはレビューを回す側だけ**(#730)。担当は指摘の控えと結果ファイルを書き、取り込み(`read-result`)が組み立てて待ち行列から送る。修正の担当はコミットまでで、送信・返信・決着・まとめは `merge-fix` が行う。書き込みと記録が同じ手順で続くため、担当が途中で止まっても投稿だけが残らない | +| 投稿の記録 | 参照は送信の応答から、件数は送れたインラインの数から取る。本文は取り込みのプロセスの中だけを通り、メインの応答に載らない | | 修正 | **必ずサブエージェント (`general-purpose`) で実行**。メイン context に diff は載せない | | ユーザ問い合わせ | 自動判断を最大化(`critical`/`major`/`minor` は自動修正、ループ中の `nit` は deferred) | | 取りこぼし防止 | **ループ終了時(approved / max_rounds / oscillation / error いずれも)に最終スイープを必須実行**。`/ndf:fix` を再実行し、残った open review thread(最終 APPROVE ラウンドの minor/nit インラインコメント含む)を **全て解消**。修正可能なものは修正 + push、判断保留 nit も reply + resolveReviewThread して **open thread 0 で終了**。件数は `state.py verify-sweep` が GitHub 側の実数で確認する | @@ -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 者へ絞り、席の埋め合わせを行わない。** 母集合の外を指定したときと、その 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 @@ -119,9 +124,8 @@ 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` が自動実施) @@ -146,11 +150,12 @@ GitHub は **自分の PR には `REQUEST_CHANGES` でレビューを投稿で ```json "codex": { "intent": "REQUEST_CHANGES", // AI の本来判定。ループ収束判定に使う - "posted_as": "COMMENT", // 422 回避でダウングレードした結果 + "posted_as": "COMMENT", // 投稿する側が送信の時点で落とした形 "comments": 5, "review_url": "..." } ``` +格下げは投稿する側(取り込み)が送信の時点で行う。担当は本来の判定だけを書く。 `state.py judge` は `intent` を見るので、ダウングレード投稿してもループは続行する。 ## 全体フロー @@ -160,16 +165,13 @@ flowchart TD Start([事前確認 / loop 開始前に 1 回だけ]):::phase --> Init["worktree 作成 + state.json 初期化
・自分の PR 判定 → event downgrade 設定
・<worktree-base>/pr<PR> を用意
・既存コメントスナップショット保存"] Init --> Round["Round N start
current_pr = PR#"]:::phase - Round -.並列バックグラウンド.-> 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 が返す)
launch-reviewer.sh <席> を席ごとに起動
→ 指摘の控え + <席>-review-pr<PR>-result.json
read-result が組み立てて投稿(先頭: round N / 席 / intent)"] + Seats --> Decide{"判定 (intent ベース)"} Decide -->|"結果なし (2 度目は final = error)"| Relaunch["結果を残さなかった側だけ
同じラウンドで 1 度起動し直す"] Relaunch --> Decide - Decide -->|"両方 APPROVE / --only で外した側"| Approved([final = approved]):::ok - Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + push
・reply + resolveReviewThread
・deferred/rejected は reply のみ
→ $TMP_DIR/fix-pr<#>-result.json"] + Decide -->|"両席 APPROVE / --only で外した席"| Approved([final = approved]):::ok + Decide -->|一方でも REQUEST_CHANGES| Fix["Agent (general-purpose)
/ndf:fix <PR> --defer-nit を worktree 内で実行
・critical/major/minor 修正 + コミット(送らない)
→ $TMP_DIR/fix-pr<#>-result.json
merge-fix が送信・返信・決着・まとめ"] Fix --> Check{収束チェック} Check -->|max-rounds 到達| MaxR([final = max_rounds]):::stop @@ -180,7 +182,7 @@ flowchart TD Check -->|それ以外| Round Rotate --> Round - Approved --> Sweep["最終スイープ (必須)
Agent (general-purpose)
/ndf:fix <PR> を再実行
・残 open review thread を全て確認
・修正可能な minor/nit は修正 + push
・判断保留 nit も reply + resolveReviewThread
→ open thread 0 で終了"] + Approved --> Sweep["最終スイープ (必須)
Agent (general-purpose)
/ndf:fix <PR> を再実行
・残 open review thread を全て確認
・修正可能な minor/nit は修正 + コミット
・判断保留 nit も決着を求める
→ 共通層が送信・返信・決着 → open thread 0"] MaxR --> Sweep Osc --> Sweep Err --> Sweep @@ -206,11 +208,12 @@ 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" --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,44 +228,39 @@ while :; do eval "$ROUND_VARS" # Step 2: 並列レビュー(担当は start-round が REVIEWERS / REVIEWERS_CSV で返す) - 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}" + # **シェル変数で絞り直さない。** 返る一覧は 1 者指定と席の埋め合わせを反映済みである。 + # **起動し直しも同じ経路を通す**(#583)。7 のときは名前の出た担当だけを入れて先頭へ戻る。 + AGENTS=$REVIEWERS; AGENTS_CSV=$REVIEWERS_CSV; RELAUNCHED= + while :; do + for r in $AGENTS; do "$SCRIPTS/launch-reviewer.sh" "$r" "$STATE_PR" "$ROUND"; done + # 監視: 上限は上限の表(review 1200 秒 / stall は席のランタイム別 codex 180・agy 480・kiro 480・claude 900)。失敗時は kill して返す。担当は `--agents` で渡す。 + # Bash の 1 回 600 秒に収まらないため背景で起動し、wait(1 回 540 秒以内)を 124 のあいだ **別の Bash の呼び出しで** 呼び直す。**繰り返しを 1 回の呼び出しへ書かない**(2 回目の待ちで合計が 600 秒を超え、ホストに打ち切られる。docs/01)。 + # 監視と取り込みの終了コードは読まない。結果なしは NO_RESULT として state に残り、Step 3 が受け取る。 + "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$AGENTS_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 + # 取り込みがレビューを投稿する(担当は投稿しない、#730)。出力は件数と参照だけ。 + for r in $AGENTS; do "$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=中断)。引き継いだ指摘が残っていれば、 # 両者が承認しても 2 を返して修正の工程へ回す。置換の終了コードは変数で受けてから読む。 JUDGE_VARS=$("$SCRIPTS/state.py" judge "$STATE_PR"); JUDGE_RC=$?; eval "$JUDGE_VARS" - if [ "$JUDGE_RC" -eq 7 ]; then # 名前の出た担当だけを、同じラウンドで 1 度起動し直す - for a in $RELAUNCH_AGENTS; do "$SCRIPTS/launch-reviewer.sh" "$a" "$STATE_PR" "$ROUND"; done - "$SCRIPTS/bg-wait.sh" run "$TMP_DIR/review.rc" -- "$SCRIPTS/monitor.py" "$STATE_PR" --phase review --agents "$RELAUNCH_AGENTS_CSV" - "$SCRIPTS/bg-wait.sh" wait "$TMP_DIR/review.rc" # 同上。124 のあいだ、別の呼び出しとして呼び直す - for a in $RELAUNCH_AGENTS; do "$SCRIPTS/state.py" read-result "$STATE_PR" "$a" || true; done - JUDGE_VARS=$("$SCRIPTS/state.py" judge "$STATE_PR"); JUDGE_RC=$?; eval "$JUDGE_VARS" - fi - if [ "$JUDGE_RC" -eq 8 ]; then # 上限で積んだ投稿が残っている。流してから判定し直す + if [ "$JUDGE_RC" -eq 8 ]; then # 上限で積んだ投稿が残っている。流してから判定し直す(7 より先に見る) "$SCRIPTS/state.py" flush "$STATE_PR" JUDGE_VARS=$("$SCRIPTS/state.py" judge "$STATE_PR"); JUDGE_RC=$?; eval "$JUDGE_VARS" fi + if [ "$JUDGE_RC" -eq 7 ] && [ -z "$RELAUNCHED" ]; then # 同じラウンドで 1 度だけ起動し直す + AGENTS=$RELAUNCH_AGENTS; AGENTS_CSV=$RELAUNCH_AGENTS_CSV; RELAUNCHED=1; continue + fi + break + done case $JUDGE_RC in 0) break ;; 2) : ;; *) exit "$JUDGE_RC" ;; esac # 1=結果なしのまま中断 # 8 のまま残るのは上限が続いているとき。state は残るので、回復後に同じ引数で再開する。 @@ -312,12 +310,12 @@ while :; do fi done -# Step 7.5: 最終スイープ (必須) — どの終了経路 (approved / max_rounds / oscillation / -# error) でも、ループを抜けた直後に **メインが Agent(general-purpose) を起動** して -# /ndf:fix $STATE_PR を再実行し、$TMP_DIR/sweep-pr$STATE_PR-result.json を書かせる -# (bash 単体では Agent ツールを呼べない。プロンプトは docs/02 の Step 7.5)。 -# 最終 APPROVE ラウンドの minor/nit はループ内 fix を経由しないため、ここで拾わないと -# PR 上に未解決スレッドが残る。sweep 結果はメインが Step 8 の報告へ折り込む。 +# Step 7.5: 最終スイープ (必須) — どの終了経路でも、ループを抜けた直後に **メインが +# Agent(general-purpose) を起動** して /ndf:fix を再実行し、$TMP_DIR/sweep-pr$STATE_PR-result.json +# を書かせる(プロンプトは docs/02 の Step 7.5)。担当はコミットまでで、送信・返信・決着は +# 次の 1 行が行う(#730)。最終 APPROVE ラウンドの minor/nit はここで拾う。 +python3 "$SCRIPTS/../../../scripts/lib/result_posts.py" fix --pr "${CURRENT_PR:-$PR}" \ + --result "$TMP_DIR/sweep-pr$STATE_PR-result.json" --worktree "$WORKTREE" || exit $? # Step 7.5 後段: 最終スイープの結果を GitHub 側の実数で検証する (必須) # exit 0 = 未解決の指摘なし / exit 6 = 残っている (件数と理由を完了報告へ含めて続行) @@ -390,8 +388,8 @@ bash ループは Agent tool を呼べないため、light モードでは Step | 2 | #123 | codex=REQUEST_CHANGES (2) / kiro=APPROVE (0) | def456 (2 fixed) | ✅ | | 3 | #145 | codex=APPROVE (0) / agy=APPROVE (0) | — | — | - **担当はラウンドごとに変わる。** 4 つの名前を取りうるため、担当と判定を 1 つの列へ - まとめる。 + **担当はラウンドごとに変わる。** 席の名前(`claude-2` のような 2 つ目を含む)を取りうる + ため、担当と判定を 1 つの列へまとめる。 - **最終スイープ結果** (Step 7.5): `sweep-pr-result.json` の `resolved` / `fixed_in_sweep` / `remaining_open`。**`remaining_open` は 0 が正常**(残 open diff --git a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md index b8d74728..2cd4ef6b 100644 --- a/plugins/ndf/skills/cross-review/docs/01-state-and-review.md +++ b/plugins/ndf/skills/cross-review/docs/01-state-and-review.md @@ -55,13 +55,13 @@ done SCRIPTS="$SKILL_DIR/scripts" # state 初期化 / 再開(プリチェック・worktree 作成・既存コメントスナップショットを内部実行) -# ⚠ `eval "$(スクリプト)"` は、スクリプトが異常終了しても出力が空なら終了コード 0 に -# なる。コマンド置換の終了コードは eval 自身の終了コードにならないため、止まるべき -# 場面で止まらない。**必ず変数で受け、終了コードを見てから eval する。** +# ⚠ `eval "$(スクリプト)"` は、スクリプトが異常終了しても出力が空なら終了コード 0 になる。 +# コマンド置換の終了コードは eval 自身の終了コードにならないため、止まるべき場面で +# 止まらない。**必ず変数で受け、終了コードを見てから eval する。** +# **値のある引数だけを渡す**(渡すと再開で上書きする。04-contracts.md)。 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,8 @@ cd "$WORKTREE" **重要**: 以降の全ステップで `cd $WORKTREE` を強制。 サブエージェント(fix)を起動するときも、prompt 内で worktree path を明示する。 +再開で渡した引数の扱いは [04-contracts.md](04-contracts.md) の同じ名前の節にある。 + ## Step 1: Round 開始判定 ```bash @@ -152,24 +154,24 @@ eval "$ROUND_VARS" `--head-branch` で受け取った値を書き戻す。ラウンドの開始時にも取り直すのは、作業ツリーの 外で行われた変更に追従するためである。 -## Step 2: レビュー担当 2 者の並列レビュー(AI 直接投稿) +## Step 2: レビュー担当 2 者の並列レビュー **要点**: メインは launcher を **並列バックグラウンド** で起動するだけ。 -各 AI が `gh api` で投稿し `$TMP_DIR/-review-pr-result.json` に -サマリを書く。**ペイロード本体はメイン context に載せない**。 +各担当は **投稿しない。** 指摘の控え(`<席>-review-pr-round-payload.json`)と +結果ファイル(`<席>-review-pr-result.json`)を一時の名前で書き、控え → 結果の順に +改名する。投稿は取り込み(`read-result`)が行う(#730)。**本文はメイン context に載せない**。 ### 2.1 launcher 起動 + monitor ```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=$? @@ -191,28 +193,22 @@ fi |---|---| | pidfile + `kill -0` | プロセス生存確認。alive 確認後に `/proc//cmdline` で agent 名一致も検証 (PID 再利用対策)。**プロセスが既に死んでいる場合は result.json の有無のみで OK 判定**する (死亡直後 cmdline 不一致で誤検知しないため) | | codex sentinel | err.log に `^tokens used$` 出現で正常完了マーク | -| early-error | **行頭限定** で `^Error:` / `^FATAL:` / `^panic:` / `^Traceback ` / `^HTTP/1.1 401\|403\|429` / `^Approval mode overridden to "default"` / `^Authentication failed` / 「quota exceeded」「rate limit exceeded」「API key not found/missing/invalid」「sandbox error」を含む行を検出 (diff/doc 引用文中の同語句は誤検知しないよう anchor + benign フィルタ併用) | +| early-error | 致命の文言を検出して止める。**利用上限**(`Monthly request limit reached` / `"api_error_status":429` / 「quota exceeded」「rate limit exceeded」/ `^HTTP/1.1 429`)は理由 `usage_limit`、それ以外の致命(`^HTTP/1.1 401\|403` / `^Authentication failed` / 「API key not found/missing/invalid」「sandbox error」など)は理由 `early_error`。どちらも状態は `EARLY_ERROR`(終了コード 4)。err.log は全担当、stdout.log は claude だけ JSON 向けの照合で見る。diff / doc 引用文中の同語句は anchor + benign フィルタで除外する。`^Error:` / `^Traceback ` などは警告に留めて止めない | | stall timeout | err.log + stdout.log + progress.log の合計サイズが一定時間変化しなければ STALLED で中断。既定は **agent 別** (codex=**180s** / agy=**480s** / kiro=**480s** / claude=**900s**、上限の表)。agy は err.log がほぼ無音のため大きめに取る。上書き方法: CLI `--stall-timeout` (明示優先) > env `MONITOR_STALL_` (per-agent) > env `MONITOR_STALL` (両 agent 共通) > agent 別ビルトイン | | hard timeout | 既定は `--phase` の工程の値で、上限の表(`scripts/lib/limits.py`)が持つ(review / critique は **1200 秒**)。上書きは `--timeout` > env `MONITOR_TIMEOUT_` > env `MONITOR_TIMEOUT`。agy の CLI の上限(`--print-timeout`)は同じ解決の値 + 120 秒で、監視より先に打ち切らない。解決した stall が hard timeout 以上なら担当ごとに警告する | | progress.log heartbeat | launcher が任意で `-review-pr-progress.log` への短いフェーズマーカー出力を要求し、monitor が最終行を stderr の heartbeat に表示する。内部推論ではなく `scan` / `analyze` / `post` / `done` などの監視用ステータスだけを出す | -| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3) | +| result.json 存在 | プロセス終了後、result.json が無ければ NO_RESULT (exit 3)。err.log に CLI 自身の上限の文言(agy の `print timeout after … with turn in progress`)があれば理由は `cli_timeout`、無ければ `missing` | | **result.json + age fallback** | sentinel を持たない agent (agy) 向け。プロセスが alive のまま result.json の mtime が **30 秒以上前**なら完了とみなし kill → OK。結果を書いた後にプロセスが終わらないケースに対応 (codex は sentinel チェックが先に発火するため影響なし) | -| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象プロセスに SIGTERM → 3 秒後 SIGKILL。残存プロセスが後から `gh api` 投稿や result.json 書き込みを行うのを防ぐ | - -> ⚠ **罠**: `nohup ... &` でラッパーシェルは即終了し、ハーネスから -> 「タスク完了」通知が飛んでくる。これに惑わされず、`monitor.py` で -> 実プロセスの完了を pidfile / sentinel で確認すること。 -> -> ⚠ **`pgrep -fa ` で完了判定しない**: agy は long `-p` プロンプトを -> 引数に持つため、`grep` のキーワード選定で誤検知する。**pidfile 必須**。 -> -> ⚠ **sentinel 単独で完了判定しない**: codex がクラッシュすると `tokens used` が -> 永遠に出ない。`monitor.py` は sentinel と pidfile/result.json/err.log を併用する。 -> -> ⚠ **Docker 環境ではゾンビプロセスに注意**: `nohup ... & disown` で起動した -> プロセスは、終了後にゾンビ化する (PID 1 が proper init でない場合)。 -> `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。 -> **推奨: Docker 実行時に `--init` フラグを付ける** (tini が PID 1 になりゾンビを reap する)。 +| **失敗時 kill** | TIMEOUT / STALLED / EARLY_ERROR / PIDFILE_BAD で返るときは対象に SIGTERM → 3 秒後 SIGKILL。`launch-cli.sh` は CLI を独立したプロセスグループで起動する(`set -m`)ため、対象がグループの先頭なら**グループごと**止める。止めた後に子プロセスが `gh api` 投稿や result.json 書き込みを行うのを防ぐ(#584) | + +完了判定の罠を 4 つ挙げる。 + +| 罠 | 守ること | +|---|---| +| `nohup ... &` でラッパーシェルは即終了し、ハーネスから「タスク完了」通知が飛んでくる | 惑わされず、`monitor.py` で実プロセスの完了を pidfile / sentinel で確認する | +| agy は長い `-p` プロンプトを引数に持ち、`pgrep -fa ` はキーワード選定で誤検知する | `pgrep` で完了判定しない。**pidfile 必須** | +| codex がクラッシュすると sentinel `tokens used` が永遠に出ない | sentinel 単独で完了判定しない。`monitor.py` は pidfile / result.json / err.log を併用する | +| Docker では `nohup ... & disown` のプロセスが終了後にゾンビ化する(PID 1 が init でない場合) | `monitor.py` は `/proc//status` でゾンビを検出して dead 扱いする。**Docker 実行時は `--init` を付ける**(tini がゾンビを回収する) | AI への入出力の契約(2.2)と、AI が書き出すファイルの契約(2.3)は [04-contracts.md](04-contracts.md) にある。 @@ -221,31 +217,27 @@ 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` を分離保存する。 - -#### 申告されたコメント数を GitHub 側と突き合わせる +`state.rounds[-1].<席の名前>` に `intent / posted_as / comments / review_url / by_severity` を分離保存する(席の名前の形は `04-contracts.md`)。 -投稿は **AI 自身が `gh api` で行う**ため、失敗しても結果ファイルの申告だけは残る。 -申告のまま進むと、修正担当が読むべき指摘が GitHub 上に存在しないまま収束判定まで走る。 -実測では、2 件の申告に対しスレッドが 1 つも作られていなかった。 +#### 取り込みがレビューを投稿する -`read-result` は申告が 1 件以上のとき、`review_url` の識別子から -`repos//pulls//reviews//comments` を数えて突き合わせる。 +**書き込みと記録を 1 つの部分命令に閉じる**(#730)。`read-result` は「控えを読む → 投稿を +積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」の順に進む。記録の参照は送信の +応答から、件数(`comments`)は送れたインラインの数から取る。**担当の申告を GitHub の実数と +突き合わせる処理は無い**(投稿する側と記録する側が同じになったため)。標準出力に足すのは +件数・参照・状態の行(`POSTED review_url=` / `INLINE= BODY= QUEUED=` / `FINDINGS=`)だけである。 -| 申告 | GitHub 側 | 扱い | +| 止まった場所 | 待ち行列の項目 | 立て直し | | --- | --- | --- | -| 0 件 | 見に行かない | 突き合わせる相手がいない | -| n 件 | n 件以上 | 採用する。人の追記など申告以外の経路で増えうる | -| n 件 | n 件未満 | **中断する。** 投稿が届いていない | -| n 件 | 取得できない | 申告を採用し、確認できなかったことを出力へ残す | +| 送る前(上限などで送れていない) | 残る | 判定の終了コード 8 の枝が流し直す | +| 送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。照合(ラウンドと席までの前方一致)が先客を見つけ、増えない | -**「取得できなかった」と「0 件」を区別する。** 取得の失敗で止めると、GitHub 側の -一時的な不調でループが進まなくなる。 +本文の先頭行は `## 🤖 cross-review | round | <席> | <本来の判定>` である。自分の +Pull Request では送る形だけを `COMMENT` へ落とし、記録の `intent` は本来の判定のまま残す。 **誰がレビューし、いつ止めるかは [05-pool-and-convergence.md](05-pool-and-convergence.md) にある。** 母集合・担当の輪番・認証の確認と、終了基準の 3 つの層をそこで定める。 @@ -289,35 +281,45 @@ eval "$JUDGE_VARS" | `SKIP` | `--only` の指定で起動しなかった | 判定へ届かない(短絡する) | | `NO_RESULT` | 起動したが、使える結果が残らなかった | 通らない | -`NO_RESULT` は `read-result` が書き込む。理由は `no_result_reason` に残る。 +`NO_RESULT` は `read-result` が書き込む。理由は `no_result_reason` に、監視が残した詳細(err.log の +抜粋、最大 200 文字)は `monitor_detail` に残る(監視の結果ファイルがあったときだけ)。理由の語彙と +起動し直しの可否を持つのは共通層の `monitor_outcome.py` だけで、`read-result` はその値を写す(#729)。 -| 理由 | 何が起きたか | `read-result` の終了コード | -| --- | --- | --- | -| `missing` | 結果ファイルが無い、または空 | 1 | -| `unparsable` | JSON として読めない、または dict ではない | 3 | -| `no_verdict` | `event` も `intent` も無い | 1 | - -**記録が無いラウンドも結果なしとして読む。** 骨組みが取り込みを呼び忘れても、判定は -収束しない。 - -終了コード 7 のとき、判定は次の 2 行を追加で出力し、対象を `rounds[-1].relaunched` へ -記録する。**2 度目の判定は記録を見て中断へ回る。** +| 理由 | 何が起きたか | 起動し直し | `read-result` の終了コード | +| --- | --- | --- | --- | +| `missing` | 結果ファイルが無い・空で、監視の理由の文言も無い | 可 | 1 | +| `unparsable` | 結果ファイルがあるが JSON オブジェクトとして読めない | 可 | 3 | +| `no_verdict` | `event` も `intent` も無い | 可 | 1 | +| `not_posted` | 投稿が Pull Request に届いていない | 可 | 1 | +| `timeout` | 監視の上限で打ち切った | 可 | 1 | +| `stalled` | 無進捗の許容を超えて打ち切った | 可 | 1 | +| `early_error` | 利用上限以外の致命の文言で止めた | 可 | 1 | +| `usage_limit` | 担当の CLI の利用上限(月間・週間の枠)に達した | **否** | 1 | +| `cli_timeout` | CLI 自身の上限(agy の `--print-timeout`)で結果を書かずに終わった | 可 | 1 | +| `pidfile_bad` | pid ファイルが無い・別のプロセスを指す | 可 | 1 | + +**記録が無いラウンドも結果なしとして読む。** 骨組みが取り込みを呼び忘れても、判定は収束しない。 + +結果なしの担当があると、判定は理由を `NO_RESULT_REASONS='<担当>=<理由> ...'` の 1 行で出す。 +**起動し直しても解けない理由(`usage_limit`)を含むときは起動し直さず**、`final=error` として終了 +コード 1 で終える(標準エラーに担当・理由・`monitor_detail`)。利用上限は同じ担当を何度起動しても +解けず、待ちと相手の枠を消費するだけである(#619)。それ以外の理由なら終了コード 7 で次の 2 行を +追加で出力し、対象を `rounds[-1].relaunched` へ記録する。**2 度目の判定は記録を見て中断へ回る。** ```text +NO_RESULT_REASONS='agy=missing' RELAUNCH_AGENTS='agy' RELAUNCH_TARGET=agy ``` -`RELAUNCH_TARGET` は `codex` / `agy` / `both` のいずれかで、`monitor.py` へそのまま -渡せる値である。起動し直しはラウンドの中で完結するため、**ラウンドの数え方と上限の -意味は変わらない。** 回数を 1 度に限るのは、待ち時間の上限をラウンドあたり 2 回分に -収めるためである。 +`RELAUNCH_TARGET` は `codex` / `agy` / `both` のいずれかで、`monitor.py` へそのまま渡せる値である。 +起動し直しはラウンドの中で完結するため、**ラウンドの数え方と上限の意味は変わらない。** 回数を +1 度に限るのは、待ち時間の上限をラウンドあたり 2 回分に収めるためである。 ### 判定へ入れる対象 -判定は 2 つの対象を別々に見る。**投稿数と未解決の指摘の数は一致しない。** -投稿数はそのラウンドで外部の AI が新しく投稿した件数で、未解決の指摘は前のラウンドの -分も含む Pull Request 上の総数である。 +判定は 2 つの対象を別々に見る。**投稿数と未解決の指摘の数は一致しない。** 投稿数はそのラウンドで +外部の AI が新しく投稿した件数で、未解決の指摘は前のラウンドの分も含む Pull Request 上の総数である。 | 対象 | 判定への入れ方 | 数え方 | |---|---|---| diff --git a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md index 21a5995e..a0805eaa 100644 --- a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md +++ b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md @@ -20,21 +20,21 @@ **メインセッションでは修正コードを書かない。** `/ndf:fix` を `general-purpose` サブエージェントで起動する。 -**サブエージェントの責務(必須 6 点)**: +**サブエージェントの責務(必須 4 点)**: -1. critical / major / minor の修正コミット +1. critical / major / minor の修正コミット(**送らない**) 2. 修正テストの追加・実行 -3. 修正対象の thread に **reply 投稿** + **`resolveReviewThread` で Resolve** -4. nit / 判断が割れる minor は **修正せず deferred 記録**(reply は `[deferred / nit]` ラベル付き、Resolve しない) -5. **PR レベルの Summary コメントを `gh pr comment` で投稿**(対応件数 / 重要度別 / deferred 件数 / rejected 件数 / commit SHA を含む) -6. 戻り値ファイル `$TMP_DIR/fix-pr-result.json` を必ず書き出す +3. nit / 判断が割れる minor は **修正せず deferred 記録** +4. 戻り値ファイル `$TMP_DIR/fix-pr-result.json` を必ず書き出す (`$TMP_DIR` は env `CROSS_REVIEW_TMP_DIR` > `/.cross_review/` の順で解決。 詳細は `scripts/state.py _tmp_dir()` 参照。`/tmp/` 直書きでも `state.py merge-fix` は legacy fallback で拾う) -> ⚠ inline thread への reply + Resolve **だけでは不十分**。PR ページの -> conversation タブに表示される **PR レベルコメント** がレビュアーへの -> サマリ通知として必須(`/ndf:fix` SKILL.md の手順 7 で規定)。 -> サブエージェント起動プロンプトでも明示的に指示すること。 +**送信・返信・決着・まとめは取り込み(`state.py merge-fix`)が行う**(#730)。サブエージェントは +GitHub と git へ書かない。取り込みは現在の頭を指定して送り(`git push origin HEAD:<ブランチ名>`)、 +戻り値ファイルが報告したコミットが送り先に載ったことを確かめてから、`resolved_threads` / +`deferred` / `rejected` の配列から返信と決着を、件数からまとめを組み立てて待ち行列で送る。 +載っていなければ記録も投稿もせずに止まる。担当が送ると、切り離された頭ではブランチ名だけの +送信が何も送らずに終了コード 0 で終わり、送ったという報告と実物が食い違う。 ### サブエージェント起動例 @@ -101,47 +101,15 @@ worktree 外を触ると競合します。 `total_count: 0` を返す(実測)。保留として読むと、承認されたラウンドが収束しない。 4. critical/major + 該当 minor/nit の修正コミット(worktree 内のみ) 5. `./pint-changed.sh && ./larastan-changed.sh` 等の品質チェック -6. push: `git push origin {HEAD_BRANCH}` (--force / --no-verify 禁止) -7. **CI 再実行は待たない**(push 後の `--watch` 等は行わない、`ci_status` は push 時点での既知失敗のみ反映) -8. **各 thread に reply 投稿**: - - 修正済み: 「対応しました — <ファイル>:<行> で〇〇 (commit )」 - - deferred: 「[deferred / nit] 後続 PR で対応予定」 - - rejected: 「bot 指摘は誤読です — 理由: ...」 -9. **修正済み thread を `resolveReviewThread` で Resolve**: - ```bash - # thread_id は GraphQL で取得 - gh api graphql -f query=' - query {{ repository(owner:"...", name:"...") {{ - pullRequest(number: {PR}) {{ reviewThreads(first:100) {{ - nodes {{ id isResolved path line }} - }} }} - }} }}' - # 修正済みのみ resolve - gh api graphql -f query=' - mutation($id: ID!) {{ - resolveReviewThread(input: {{threadId: $id}}) {{ thread {{ isResolved }} }} - }}' -f id="$THREAD_ID" - ``` - - deferred / rejected の thread は **Resolve しない** -10. **PR レベル Summary コメントを投稿**(必須・inline reply とは別物): - ```bash - gh pr comment {PR} --body "$(cat <<'EOMD' - ## 🔧 /ndf:fix サマリ (round N) - - 対応件数: critical=X / major=Y / minor=Z (合計 N 件) - deferred: D 件 / rejected: R 件 - commit: - CI: SUCCESS | FAILURE | NONE - - ### 詳細 - - 各 thread の対応概要(行リンク付き) - EOMD - )" - ``` - - inline reply + Resolve だけでは「PR ページの Conversation タブ」に - まとめが出ず、レビュアー視点で見落とされる。**必ず投稿する** -11. 戻り値ファイル書き出し(下記フォーマット)。`summary_comment_url` には - 手順 10 の URL を入れる +6. コミットする。**送らない**(送信は取り込みが行う) +7. **CI 再実行は待たない**(`ci_status` はコミット時点での既知失敗のみ反映) +8. 各 thread の扱いを戻り値ファイルの配列へ入れる。**返信・決着・まとめは投稿しない** + (取り込みが配列から組み立てる): + - 修正済み: `resolved_threads`(`thread_id` と `comment_id`) + - deferred: `deferred`(`comment_id` と `reason_for_deferral`) + - rejected: `rejected`(`comment_id` と `reason_for_rejection`) +9. 戻り値ファイル書き出し(下記フォーマット)。`summary_comment_url` は書かない + (取り込みがまとめの投稿の応答から記録へ書く) ## 戻り値ファイル $TMP_DIR/fix-pr{PR}-result.json @@ -381,13 +349,15 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > > PR の **全 open review thread**(インライン / レビュー body / PR レベルコメント)を > `gh api` で洗い出し、cross-review の codex/agy が残したものを中心に **すべて解消**せよ: -> 1. 修正可能な `minor`/`nit` → コード修正 + push(同ブランチ、main へは push しない)し、 -> reply + GraphQL `resolveReviewThread` で Resolve。 -> 2. 修正しない(好み・判断保留)`nit` → 「[deferred / nit] 対応見送り: <理由>」を日本語で -> reply した上で **Resolve まで実行**(スレッドを open のまま残さない)。 -> 3. bot 誤指摘 → 却下理由を reply して Resolve。 +> 1. 修正可能な `minor`/`nit` → コード修正 + コミット(**送らない**)し、`resolved_threads` へ入れる。 +> 2. 修正しない(好み・判断保留)`nit` → 見送りの理由を添えて `deferred` へ入れ、 +> **`"resolve": true`** を付ける(スレッドを open のまま残さない)。 +> 3. bot 誤指摘 → 却下理由を添えて `rejected` へ入れ、`"resolve": true` を付ける。 +> +> **GitHub と git へ書かない。** 返信・決着・送信は、メインがこの結果ファイルを読んで +> 共通層の 1 行(`result_posts.py fix`)で行う。 > -> **修正で push した場合は、対象リポジトリの検証を 1 度通すこと。** 何を実行するかは +> **修正をコミットした場合は、対象リポジトリの検証を 1 度通すこと。** 何を実行するかは > 対象リポジトリを見て決める。**コマンドを推測して組み立てない。** > > 1. 実行手段を探す。`Makefile` の `test` / `lint` / `check` ターゲット、`package.json` の @@ -399,7 +369,7 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > 3. 1 つも見つからないときは実行しない > > **終了コードが 0 でない実行を残したまま完了としない。** その修正が原因なら直して -> push し直し、もう一度実行して 0 を確かめる。修正の前から落ちていたなら直さず、 +> コミットし直し、もう一度実行して 0 を確かめる。修正の前から落ちていたなら直さず、 > 何が落ちているかを最終メッセージへ書く(この工程の範囲外である)。どちらの場合も > `commands` には**最後に実行した結果**を残す。 > @@ -408,15 +378,16 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > メッセージへ書く。 > > 完了後、上の**結果ファイル**(`$TMP_DIR/sweep-pr-result.json`)に -> `{"resolved": N, "fixed_in_sweep": M, "commit": "", "remaining_open": K, +> `{"resolved": N, "fixed_in_sweep": M, "commit": "", "fix_commit": "", +> "resolved_threads": [...], "deferred": [...], "rejected": [...], "remaining_open": K, > "remaining_reason": "0 のときの理由|null>", "items": ["<1行要約>", ...], > "verification": {"commands": [{"command": "<実行したコマンド>", "exit": <終了コード>}], > "skipped_reason": "<実行しなかった理由|null>"}}` を > 書き出し、最終メッセージで内訳を日本語報告せよ。 > 検証を実行したときは `commands` に実行順で並べ、`skipped_reason` を `null` にする。 > 実行手段が見つからなかったときは `commands` を空にし、`skipped_reason` に**何を探して -> 見つからなかったか**を書く。push しなかったラウンドも `commands` を空にし、 -> `skipped_reason` に「push なし」と書く。 +> 見つからなかったか**を書く。コミットしなかったときも `commands` を空にし、 +> `skipped_reason` に「コミットなし」と書く。 > **`remaining_open` は 0 とする。** 0 にできない場合は `remaining_reason` に理由を書く。 > この値は申告であり、次の `verify-sweep` が GitHub 側の実数と突き合わせる。 diff --git a/plugins/ndf/skills/cross-review/docs/03-review-output.md b/plugins/ndf/skills/cross-review/docs/03-review-output.md index a74f062e..2927ae40 100644 --- a/plugins/ndf/skills/cross-review/docs/03-review-output.md +++ b/plugins/ndf/skills/cross-review/docs/03-review-output.md @@ -41,10 +41,11 @@ body 先頭に必ず以下を入れる: コード引用ブロック(``` ... ```)や現状説明だけのコメントは作らない。 **インラインは PR の差分に含まれる行にしか付かない。** 差分外の行を指定すると GitHub が -`HTTP 422 Line could not be resolved` を返し、**インラインだけでなくレビュー本体も投稿 -されない**(指摘が丸ごと失われ、PR 上には何も残らない)。差分に無い箇所を指摘するときは -body に「ファイル名:行 + 指摘」の形で書く。422 が返ったら該当インラインを body へ移して -再投稿する。 +`HTTP 422` を返し、**インラインだけでなくレビュー本体も作られない**(要求ごとに全件が拒まれ、 +応答はどのインラインが原因かを指さない)。担当はこれを気にせず、指す行が分かる指摘には +`path` と `line` を書く。**投稿する側(取り込み)が、拒まれた要求のインラインをすべて総評へ +移して送り直す。** 移す契機は応答が行やファイルを解決できないこと(`could not be resolved`)を +示すときだけで、判定の値や基準のコミットの誤りによる 422 は失敗として止める(#730)。 ### 3. body(総評)に書かないこと @@ -83,9 +84,11 @@ pint / larastan / test / build などは **中断** を原則とする。 ## アンチパターン - ❌ **修正をメインセッション内で行う** — context が一気に膨れる。必ずサブエージェント -- ❌ **AI に Markdown だけ返させる** — メインがパース・投稿する設計は禁物。AI 直接投稿 -- ❌ **result.json の申告だけで判定を進める** — 投稿が失敗しても件数は残る。GitHub 側の - 実数と突き合わせないと、修正担当が読むべき指摘が存在しないまま収束する +- ❌ **担当に GitHub へ投稿させる** — 投稿と記録を別の相手が行うと、担当が途中で止まった + ときに投稿だけが残り、記録には無い。起動し直した担当が同じ論点をもう一度投稿する(#583)。 + 担当は指摘の控えと結果ファイルを書くだけにし、取り込みが待ち行列を通して送る(#730) +- ❌ **本文をメインの応答へ載せて投稿する** — 投稿は結果ファイルを読んだプロセス(取り込み)の + 中で組み立てる。メインが読むのは件数と参照だけ - ❌ **nit を都度ユーザに問う** — ループ中は deferred 記録のみ。最終スイープ (Step 7.5) で Resolve - ❌ **未解決スレッドを残したまま終了する** — approved/max_rounds 等いずれの終了経路でも Step 7.5 の最終スイープを必ず実行し、open review thread 0 で終える。特に **最終 APPROVE @@ -120,7 +123,23 @@ pint / larastan / test / build などは **中断** を原則とする。 環境変数) で EARLY_ERROR 検知自体を無効化し、hard timeout / stall / sentinel / result.json のみで判定するモードに切り替える 3. **新しい致命パターンを観測した場合**: `EARLY_ERROR_FATAL` に追記する (PR で plugin に反映)。 - 曖昧パターンは `EARLY_ERROR_WARN` 側に置き、kill 対象にはしない + 曖昧パターンは `EARLY_ERROR_WARN` 側に置き、kill 対象にはしない。利用上限の文言は + `USAGE_LIMIT_FATAL`(claude の JSON は `CLAUDE_STDOUT_USAGE_LIMIT`)に置く + +### 上限に当たった場合の見分け方 + +止めたのが監視の上限か、担当の CLI の利用上限か、CLI 自身の上限かは、監視が書いた理由で +見分ける。`err.log` の目視より先にこれを読む(#619 #729)。 + +| 読む場所 | 何が分かるか | +| --- | --- | +| `$TMP_DIR/-review-pr-monitor.json` の `reason` | その担当の**最後の**起動の理由。`usage_limit` なら利用上限、`cli_timeout` なら CLI 自身の上限、`timeout` なら監視の上限、`missing` なら文言の無い結果なし | +| 同じファイルの `detail` | 一致した文言を含む err.log / stdout.log の抜粋(最大 200 文字) | +| `$TMP_DIR/monitor-outcomes.jsonl` | 起動ごとに 1 行が追記だけで積まれる。起動し直した担当の **1 回目の理由**はここに残る(`-monitor.json` は 2 回目で上書きされる)。`reason` と `ended_at` で並べて読む | +| 状態ファイルの `rounds[-1]..no_result_reason` / `monitor_detail` | `read-result` が写した値。`state.py report` のラウンド表には `=NO_RESULT(<理由>)` の形で出る | + +`usage_limit` は起動し直しても解けないため、判定は同じラウンドで起動し直さず終了コード 1 +で終える。枠が戻るまで待つか、その担当を外して回すかは進行側が決める。 - ❌ **fix サブエージェントが Resolve をスキップ** — reply だけでは未対応扱い。Resolve まで実行 - ❌ **review body に identifier prefix を付け忘れる** — GitHub UI 上で誰のレビューか不明になる diff --git a/plugins/ndf/skills/cross-review/docs/04-contracts.md b/plugins/ndf/skills/cross-review/docs/04-contracts.md index b73c2bf3..05e7a3ea 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`)。母集合から外れる @@ -106,17 +136,27 @@ **1 つの `(ラウンド, finding_id, 担当)` が持つ値は 1 つである。** 取り直した反証は 古い値へ積まず置き換える(積むと、`refute` を `support` へ訂正しても両方が並び、 区分の順で `refute` が先に当たって指摘が `rejected` のままになる) -- `review_findings[].classification` — 5 つの区分(#156)。**収束の判定が数えるのは - `verified_blocking` と `needs_human_judgment` の 2 つだけである** +- `review_findings[].classification` — 6 つの区分(#156、#732)。値は `verified_blocking` / + `verified_non_blocking` / `rejected` / `needs_human_judgment` / `unrefuted` / + `insufficient_evidence`。**収束の判定が数えるのは `verified_blocking` と + `needs_human_judgment` と `unrefuted` の 3 つである。** 数えないのは、誤りだと示された + 棄却と、承認を妨げない `minor` 以下だけである +- `review_findings[].unrefuted_reason` — 未反証の理由(#732)。**`classification` が + `unrefuted` のときだけ持つ。** 値は `no_critique`(反証を返した担当が 0 者)/ + `not_supported`(反証はあるが支持も否定も無い)。区分が変わると消える(`rejection_reason` + と同じ扱い) - `unmatched_critiques` — 結び先の無い反証(#156)。**捨てない**(反証 0 件のラウンドと、 結び先を誤ったラウンドを区別するため) - `evidence_rounds` — 証拠集約(統合・実行検証・反証)を通ったラウンドの番号(#156)。 - **収束の判定はこの印で母集合を決める。** 印を持つラウンドだけを区分の 2 つへ絞り、 - 持たないラウンドは従来どおり全件を数える。**`review_findings` の有無では判定しない** - (取り込みはこの変更より前から要素を積むため、区分も `verification` も持たない旧い - ラウンドが絞り込みに掛かり、修正必須の `major` が `insufficient_evidence` へ落ちて - 新規 0 件で収束する)。印を書くのは経路の最後(`collect-critiques`)で、**対象ごとに - 有効な反証が揃ったときだけである** + **収束の判定はこの印で母集合を決める。** 印を持つラウンドだけを数える 3 区分へ絞り、 + 持たないラウンドは従来どおり全件を数える。印の役割は、取り込みだけを済ませた旧いラウンドと、 + 反証が届いていないラウンドを、棄却と `minor` 以下も含めて全件を数える側に置くことである + (`major` は誤りを示されていなければ `unrefuted` として数えられるが、否定が届いていない + かもしれないラウンドでは全件を数える側が安全である)。**`review_findings` の有無では判定 + しない**(取り込みは印より前から要素を積むため、区分も `verification` も持たない旧い + ラウンドが絞り込みに掛かる)。印を書くのは経路の最後(`collect-critiques`)で、**対象 + ごとに有効な反証が揃ったときだけである**。揃わないときは付けないだけでなく、**先に付いて + いたそのラウンドの印を外す**(取り直しの後も印が残ると、出力と実際の数え方が食い違う) - `rounds[].critique_relaunched` — 反証を取り直した担当(#549 レビュー対応)。 **同じラウンドで 1 度だけ取り直す**ための控えである - `rejected_findings` — 却下した指摘を **per-item** で蓄積する。`rounds[].fix.rejected` は @@ -124,7 +164,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 の本来判定。**ループ判定はこれを見る**。担当ごとのキーの @@ -154,29 +203,51 @@ あいだは、両者が承認しても収束させない([01-state-and-review.md](01-state-and-review.md) の Step 3 参照) - `viewer_login` — 自分のログイン名。一度取って持つ控えで、待ち行列の冪等の照合が 「投稿者が自分か」を見るために使う -- `rounds[].codex.queued` — その結果の投稿を待ち行列へ積んだかどうか。真のあいだは - 届いたことの照会を飛ばす([01-state-and-review.md](01-state-and-review.md) の待ち行列の節参照) +- `rounds[].codex.queued` — 取り込みが送ったレビューが上限で送れず、待ち行列に残っているか。 + 流した直後に参照を書き戻して偽にする([01-state-and-review.md](01-state-and-review.md) の待ち行列の節参照) +- `rounds[].codex.review_url` — 送信の応答が返した参照。流し直しで先客が見つかったときは先客の参照 +- `rounds[].codex.posted_inline` / `posted_body` — インラインとして送れた件数と、差分の外を + 理由に総評へ移した件数(#730)。`comments` は `posted_inline` と同じ値 +- `rounds[].fix.summary_comment_url` — 修正のまとめの投稿の応答が返した参照(#730) - `rounds[].verdict` の `queued` — 通ったが待ち行列に投稿が残っているラウンド。収束させない - `sweep` — 最終スイープ後の検証結果。`remaining_open` は GitHub 側で数え直した実数で、 `declared_remaining_open` は結果ファイルの申告値。両者が食い違う場合は実数を採る +## 再開で渡した引数の扱い + +**黙って捨てる引数は無い。** 渡さなかった引数は状態ファイルの値のまま残り、`--worktree` / +`--focus` / `--extra-instructions-file` は状態に載らないため毎回の指定が使われる。 + +| 扱い | 引数 | 何が起きるか | +| --- | --- | --- | +| 反映する | `--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 が生成するプロンプトに以下を強制している: - **headRefOid (commit_id) を明示**: AI が自前で取得すると baseRefOid を誤って入れる事故が多発 - **作業 worktree の絶対パス**: 「ファイル読み取りは必ず worktree 配下の絶対パスを使う」(実 path は state.json の `worktree_path` を参照。`` は `NDF_WORKTREE_BASE` env > `<システム tmpdir>/ndf-worktrees` の優先順で解決) -- **event ダウングレード警告**: `event_downgrade=true` のときは payload の `event` を `COMMENT` に +- **投稿の手順を持たない**(#730): 担当は投稿しない。判定の格下げ(`event_downgrade`)も + 担当へ渡さず、投稿する側が送信の時点で行う - **既存コメント差分**: `$TMP_DIR/cross-review-pr-existing-comments.txt` を読んで重複指摘禁止 - **自動レビュー観点**: GitHub API の `pulls//files --paginate` で変更ファイルを全件取得して分類し、`common` / `docs_only` / `code` / `db_migration` / `test` / `dependency` / `config_ci` / `api_contract` / `auth_security` / `frontend` / `performance` / `deletion_rename` / `generated` / `i18n` / `infra` の該当テンプレートを state.json の `auto_review_instructions` に保存する - **手動追加レビュー観点**: `--focus` / `--extra-instructions-file` が指定されていれば state.json の `manual_extra_review_instructions` に保存し、自動テンプレートの後ろに連結した `review_instructions` を codex / agy 両 launcher が同じ「追加レビュー観点」セクションとしてプロンプトに差し込む - **進捗マーカー**: agy には `$TMP_DIR/agy-review-pr-progress.log` へ短いフェーズ名を追記させ、monitor の heartbeat で表示する。内部推論や長文説明は書かせない -- **review body 先頭 prefix**: +- **review body 先頭 prefix**(投稿する側が組み立てる): ``` - ## 🤖 cross-review | round | | + ## 🤖 cross-review | round | <席> | ``` `` は **本来の intent**(`posted_as` ではない)。 例: 自分PR で REQUEST_CHANGES を COMMENT にダウングロードしても、prefix は `REQUEST_CHANGES` のまま。 + 二度書かない照合はこの行の**席まで**の前方一致を鍵にする(判定の語を含めない) - **出力禁止事項**(SKILL.md「レビュー出力の制約」と一致): - 「良い点」「Strengths」などの褒めセクションを body に書かない - 修正アクションを伴わないインラインコメントは作らない(nit はインライン化しない) @@ -185,24 +256,27 @@ launcher が生成するプロンプトに以下を強制している: ## AI が書き出すファイル契約 -各 launcher は AI に以下 2 ファイルの書き出しを指示する: +各 launcher は AI に以下 2 ファイルの書き出しを指示する。**どちらも一時の名前(末尾 +`.tmp`)で書き終えてから、控え → 結果ファイルの順に改名させる**(#730)。結果ファイルが +正式の名前で現れたことが、2 つとも書き終えた印になる。控えだけが正式の名前で結果ファイルが +無い状態は、結果なしとして扱い投稿を 0 件にする。 | ファイル | 内容 | |---|---| -| `$TMP_DIR/-review-pr-result.json` | `{event, posted_as, comments_count, review_url, by_severity}` のサマリ | -| `$TMP_DIR/-review-pr-round-payload.json` | `{comments: [{path, line, body, severity, evidence, falsification, suggested_check, posted_to}, ...]}` | +| `$TMP_DIR/<席>-review-pr-result.json` | `{event, by_severity}`。担当が書くのはこの 2 つだけ | +| `$TMP_DIR/<席>-review-pr-round-payload.json` | `{summary, comments: [{path, line, body, severity, evidence, falsification, suggested_check}, ...]}` | -**`comments[]` が持つのは、その担当が出した指摘の全件である**(#156)。投稿した -インラインの写しではない。**差分の外を指すために総評へ書いた指摘も、`HTTP 422` で総評へ -移した指摘も載る。** そのため `result.json` の `comments_count`(投稿したインラインの数) -とは一致しない。 +**`comments[]` が持つのは、その担当が出した指摘の全件である**(#156)。位置を持つ指摘は +インラインとして送られ、位置を持たない指摘と、差分の外を理由に拒まれた要求の指摘は総評へ +入る。送れた先(`posted_to`)は投稿する側が控えへ書き戻す。記録の `comments` は送れた +インラインの数で、指摘の件数とは一致しない。 | 項目 | 何を書くか | 無いときの扱い | | --- | --- | --- | | `evidence` | 根拠。対象のコードと到達経路 | 空。`has_evidence` が偽になる | | `falsification` | 反証条件。これが成り立てば棄却できる | 同上 | | `suggested_check` | 実行できる検証手順 | 空 | -| `posted_to` | `inline` / `body` のどちらへ投稿したか | `inline` として扱う | +| `posted_to` | `inline` / `body` のどちらへ送れたか。**投稿する側が書く** | `inline` として扱う | **4 項目を持たない指摘も捨てない。** 捨てると、対応していない担当の指摘が記録から消える。 @@ -226,8 +300,25 @@ launcher が生成するプロンプトに以下を強制している: **落とすのは、書き込む中身が確定した後である。** 読めなかった再実行が、一度取り込めて いた記録を消さないようにする。 -`/ndf:pr-review` の result.json 出力規約に `posted_as` フィールドを含むこと -(自分PR ダウングレード時に GitHub に実際送った event。デフォルトは `event` と同値)。 +## 投稿の種別ごとの契約 + +**GitHub へ書くのはレビューを回す側だけで、すべて待ち行列(`scripts/lib/post_queue.py`)を +通る**(#730)。組み立てと送信は共通層の `scripts/lib/result_posts.py` が持つ。送る前に同じ +ものが先にあるかを照合し、あれば送らずに先客を応答として返す。 + +| 種別 | 積む側 | 組み立ての元 | 二度書かない照合の鍵 | +| --- | --- | --- | --- | +| `review-post` | 指摘の取り込み(`read-result`) | 指摘の控えと結果ファイル | 投稿者と、本文の先頭行の `## 🤖 cross-review \| round \| <席> \|` までの前方一致(判定の語を含めない) | +| `review-reply` | 修正の取り込み(`merge-fix`)/ 単独の `fix` | 修正の結果ファイルの `resolved_threads` / `deferred` / `rejected` | 返信先の指摘の識別子と、本文の先頭 80 文字 | +| `thread-resolve` | 同上 | `resolved_threads`(と、`resolve` が真の見送り・却下) | スレッドの識別子と、すでに決着しているかどうか | +| `pr-comment` | 同上(修正のまとめ)/ 巻き直し(`rotate-pr.sh`) | 修正の結果ファイルの件数とコミット | 投稿者と、本文の先頭 80 文字(まとめはラウンドとコミットを含む) | + +**差分の外を指すインラインで拒まれたら、その要求のインラインをすべて総評へ移して送り直す。** +契機は応答の `errors` が `could not be resolved` を含むときだけで、ほかの 422 は失敗として +止める。すでに決着したスレッドの決着をもう一度送っても失敗にならない(実測)。 + +修正の送信は `git push origin HEAD:<ブランチ名>` で行い、戻り値ファイルの `fix_commit` が +送り先に載ったことを確かめる。載っていなければ取り込みは失敗として止まる。 ## 監視と計測が残すファイル @@ -239,10 +330,20 @@ launcher が生成するプロンプトに以下を強制している: | `monitor-outcomes.jsonl` | `$TMP_DIR` | 監視の結果を 1 行 1 つで**追記だけ**で積む | 同上。消さない | | `cross-review-pr-<開始時刻の UTC>.json` | 要約の置き場所の `--/` | 実行の要約(所要・結末・ラウンド・起動と `measure`)。**本文・`detail` を含まない** | 状態を保存するたび(同じ実行は上書き) | -`reason` は `status` から決まる(`OK`→`ok` / `TIMEOUT`→`timeout` / `STALLED`→`stalled` / -`EARLY_ERROR`→`early_error` / `NO_RESULT`→`missing` / `PIDFILE_BAD`→`pidfile_bad`)。 +`reason` は既定では `status` から決まる(`OK`→`ok` / `TIMEOUT`→`timeout` / `STALLED`→`stalled` / +`EARLY_ERROR`→`early_error` / `NO_RESULT`→`missing` / `PIDFILE_BAD`→`pidfile_bad`)。監視が +文言で区別した 2 つだけが状態から決まらない: 利用上限は `EARLY_ERROR` のまま `usage_limit`、 +CLI 自身の上限で結果を書かずに終わったときは `NO_RESULT` のまま `cli_timeout`(#729)。 監視の標準出力と終了コードは変わらない。 +結果の取り込みは結果ファイルを自前で開かず、共通層の `monitor_outcome.read_launch_outcome(tmp_dir, +"-review-pr", result_path)` が返す値(使える結果 `payload` / 理由 `reason` / 監視の詳細 +`detail` / 起動し直しの可否 `relaunch_same_agent`)を読む。結果なしのときは +`rounds[-1].` に `intent: "NO_RESULT"`、`no_result_reason: `、監視の結果ファイルが +あれば `monitor_detail: ` を書く(**鍵が無い** = 監視の結果ファイルが無かった。空文字は +書かない)。理由の一覧は [01-state-and-review.md](01-state-and-review.md) の「結果を残さなかった +レビュアーの扱い」にある。 + **要約の置き場所は作業ツリーの外である。** `NDF_METRICS_DIR` → `$XDG_STATE_HOME/ndf/metrics` → `$HOME/.local/state/ndf/metrics` の順に決まり、`NDF_METRICS=0` のときは書かない。`state.py report` の最後の行が、書いた要約のパスか書かなかった理由を出す。束ねるのは共通層の `run_metrics.py aggregate` diff --git a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md index 48ef20bd..8a17ea8e 100644 --- a/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md +++ b/plugins/ndf/skills/cross-review/docs/05-pool-and-convergence.md @@ -14,24 +14,67 @@ 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 者指定と埋め合わせを反映済みで、絞ると状態ファイルとずれた +ときに起動も監視も誰にも当たらない。 -**`host` を持たない状態ファイルは `codex` / `agy` の 2 者として読む。** 中断した実行を -新しい版で再開したときに、担当が入れ替わって前のラウンドの記録と突き合わせられなくなる -ことを避ける。 +### 1 者だけで回す指定 -**`--only` はそのラウンドの担当を 1 者へ絞る。** 輪番が返す 2 者を担当のまま残すと、 -指定した 1 者が含まれないラウンドで誰も起動されない。そのとき全員が「指定によるスキップ」 -として扱われ、**レビューが行われていないのに収束する**。母集合の外を指定した場合は `init` が -起動する前に弾く。 +**`--only` はそのラウンドの担当を 1 者へ絞り、席の埋め合わせを行わない。** 利用者が 1 席と +決めた指定であるため、2 席へ戻さない。輪番が返す 2 者を担当のまま残すと、指定した 1 者が +含まれないラウンドで誰も起動されない。そのとき全員が「指定によるスキップ」として扱われ、 +**レビューが行われていないのに収束する**。母集合の外を指定した場合は `init` が弾く。 -**認証は `init` が起動前に確かめる。相手は実際に起動する担当だけである。** 未認証の CLI は -起動から短時間で終わり、結果を残さないまま担当から欠ける。`--only` で 1 者へ絞ったときに -母集合の全員を確かめると、そのラウンドで起動しない CLI の未認証で初期化が失敗する。確認コマンドは CLI の版で変わりうるため、 -`NDF_SKIP_AUTH_CHECK=1` で飛ばせる(飛ばしたことは出力へ残る)。 +**指定した 1 者が確認を通らなければ `init` が失敗する**(終了コード 1、状態ファイルを作らない)。 +埋め合わせを行わない以上、使える者が 0 者のまま席へ座るのはその 1 者だけであり、起動しても +結果が残らないラウンドが積み重なる。使える者が 0 者で `init` が失敗する点は、上の表の +「使える者の数 0」と同じ扱いである。 + +### 参加者の記録を持たない状態ファイル + +この変更の前に始めた実行の状態ファイルには `participants` が無い。そのときは `host` から +変更前と同じ輪番(`review_seats(round, review_pool(host), [])`)で 2 席を決める。`host` も +無ければ `codex` / `agy` の 2 者として読む。中断した実行を新しい版で再開したときに、担当が +入れ替わって前のラウンドの記録と突き合わせられなくなることを避ける。 ## 終了基準 @@ -51,6 +94,14 @@ Step 1(ラウンドの開始)と Step 3(判定)が読む基準を持つ しまう。測れないときは従来の判定(全員が pass か)に従い、出力の `NEW_FINDINGS` は `-` に なる。 +**担当が 1 者のラウンドと、起動し直した担当の指摘は、未反証(`unrefuted`)として新規性の層が +数える。** 担当が 1 者なら反証を返す相手がいない。起動し直した担当の指摘は、反証を取り込んだ +後に取り込まれるため反証を持たない。いずれも誰も誤りを示していない重大な指摘であり、数えない +と未解決のまま承認で収束する。1 者で回したラウンドの収束は、この新規性と、全員が通したかの +2 つで決まる。担当が承認か、重大な指摘の無いコメントを返せば、全員が通したとして収束する。 +修正要求なら、未反証の重大な指摘が新規に数えられ、修正の工程へ進む。修正の後のラウンドで同じ +指摘が戻れば、前のラウンドと一致して新規 0 件になる。戻らなければ承認で収束する。 + **全員 `APPROVE` は、最も止まらない参加者に律速される。** #156 が観測した 6 ラウンドでは、 一方が round 4 以降 3 ラウンド連続で承認を返す間、もう一方が round 6 まで指摘を出し続け、 round 5 の指摘は修正済みの箇所を指していた。再提出された指摘は Pull Request に残り、 diff --git a/plugins/ndf/skills/cross-review/docs/06-evidence.md b/plugins/ndf/skills/cross-review/docs/06-evidence.md index b68bdf75..d828fd68 100644 --- a/plugins/ndf/skills/cross-review/docs/06-evidence.md +++ b/plugins/ndf/skills/cross-review/docs/06-evidence.md @@ -97,12 +97,15 @@ 母集合を決める。`review_findings` の有無では、取り込みだけを済ませた旧いラウンドと 区別できない([04-contracts.md](04-contracts.md))。 -**通り切ったかどうかは、対象ごとに有効な反証が揃ったかで見る。** 結果ファイルの欠落や -不正でも印を付けると、実行検証を持たない単独の `major` が `insufficient_evidence` へ -落ち、新規 0 件のまま**未検証で収束する**。`collect-critiques` は揃わないときに印を -付けず、終了コード 7 と `CRITIQUE_RETRY_AGENTS` を返して再取得へ戻す。**取り直しは -同じラウンドで 1 度だけである**(`judge` の結果なしと同じ作法)。2 度目も揃わなければ -印を付けないまま進み、そのラウンドは全件を数える。 +**通り切ったかどうかは、対象ごとに有効な反証が揃ったかで見る。** 印の役割は、取り込み +だけを済ませた旧いラウンドと、反証が届いていないラウンドを、全件を数える側に置くことである。 +印の無いラウンドは棄却と軽微な指摘も数えるため、否定が届いていないかもしれないラウンドでは +全件を数える側が安全である。`collect-critiques` は揃わないときに印を付けず、**先に付いて +いたそのラウンドの印を外す**。外さずに取り直しへ進むと、「印を付けないため、このラウンドは +全件を数えます」の出力と実際の数え方が食い違う。そのうえで終了コード 7 と +`CRITIQUE_RETRY_AGENTS` を返して再取得へ戻す。**取り直しは同じラウンドで 1 度だけである** +(`judge` の結果なしと同じ作法)。2 度目も揃わなければ印を付けないまま進み、そのラウンドは +全件を数える。揃えば印を付ける処理が印を戻す。 **監視へ渡すのは、実際に起動した担当だけである。** `critique.sh` は反証の対象が無い 担当(自分の指摘だけ、または指摘 0 件)で `launch-cli.sh` を呼ばずに終わるため @@ -112,8 +115,9 @@ 冒頭で捨てる(`` はラウンドを名前に持たないため、残骸を起動済みと読む)。 **申告による統合(2 段目)は次のラウンドへ回さない。** 回すと、同じ主張を 2 者が別の -本文で出した組が、どちらも `origin_runtimes` 1 者・`support` 0 件のまま -`insufficient_evidence` へ落ち、統合される前に収束する。 +本文で出した組が、どちらも `origin_runtimes` 1 者・`support` 0 件のまま別々の `unrefuted` +として 2 件に数えられ、「2 者が独立に到達した」という一致が `needs_human_judgment` として +記録に残らない。軽微な指摘なら `insufficient_evidence` へ落ち、統合される前に収束する。 ## 反証 @@ -163,8 +167,8 @@ ことは別である。 **束ねた組の全員の `suggested_check` を対象にする。** 代表の値だけを読むと、代表が手順を -書いていない組は、束ねられた側が実行できる手順を書いていても `not_run` のまま -`insufficient_evidence` へ落ちる。**どちらが先に取り込まれたかで採否が変わる。** 代表が +書いていない組は、束ねられた側が実行できる手順を書いていても `not_run` のままで、機械が +再現した事実が区分に効かない。**どちらが先に取り込まれたかで区分が変わる。** 代表が 持つのは組の集約で、`reproduced` > `not_reproduced` > `not_run` の順で最初に当たった 1 件を採り、出所を `verification.finding_id` へ残す。**同じコマンドは 1 度しか実行しない。** @@ -172,19 +176,30 @@ **上から順に見て、最初に当たった区分を採る。** -| 順 | 区分 | 条件 | -| --- | --- | --- | -| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | -| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | -| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | -| 4 | `needs_human_judgment` | 根拠を持ち、`major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | -| 5 | `insufficient_evidence` | 上のいずれにも当たらない | +| 順 | 区分 | 条件 | 数える | 理由の項目 | +| --- | --- | --- | --- | --- | +| 1 | `verified_blocking` | 再現した、かつ `major` 以上 | はい | — | +| 2 | `verified_non_blocking` | 再現した、かつ `minor` 以下 | いいえ | — | +| 3 | `rejected` | 再現しなかった、または `refute` が 1 件以上 | いいえ | `rejection_reason` | +| 4 | `needs_human_judgment` | `major` 以上で、`support` が 1 件以上または `origin_runtimes` が 2 者以上 | はい | — | +| 5 | `unrefuted` | `major` 以上(上のいずれにも当たらない) | はい | `unrefuted_reason`(`no_critique` / `not_supported`) | +| 6 | `insufficient_evidence` | 上のいずれにも当たらない(`minor` 以下) | いいえ | — | **実行で再現した指摘を先に採るのは、順序そのもので「実行の結果を担当の支持より先に 見る」を表すためである。** 順 3 を先に置くと、機械が再現した事実を担当の再評価が覆す。 -**収束の判定が数えるのは、`verified_blocking` と `needs_human_judgment` の 2 つだけ** -である。棄却した指摘を数えると、そのぶんラウンドが増える。 +**収束の判定が数えるのは、`verified_blocking` と `needs_human_judgment` と `unrefuted` の +3 つ**である。数えないのは、誤りだと示された棄却と、承認を妨げない軽微な指摘だけである。 +棄却した指摘を数えると、そのぶんラウンドが増える。 + +**未反証(`unrefuted`)は、誰も誤りを示しておらず、独立に確かめた担当もいない重大な指摘で +ある。** 反証の「立証できない」「範囲外」は誤りだという主張ではないため、その指摘を数から +落とさない。数えないと、未解決の重大な指摘を残したまま承認で収束する。なぜ確かめられていない +かは理由(`unrefuted_reason`)として残す。反証を返した担当が 0 者なら `no_critique`、反証は +あるが支持も否定も無ければ `not_supported` である。修正の担当の扱いは `needs_human_judgment` +と同じで、直す・却下の理由を返す・範囲外として起票する、のいずれかを決める。根拠の 2 項目 +(`evidence` / `falsification`)の有無は区分を変えない。別の担当が支持した、または 2 者が +独立に出した時点で、確かめる目的は果たされている。 **`needs_human_judgment` を人へのエスカレーションにしない。** 決めるのは修正の担当で、 その判断は却下の記録(`rejected_findings`)へ残る。 @@ -221,7 +236,7 @@ $SCRIPTS/measure.py <状態ファイルのパス> [--output <パス>] | --- | --- | | `single` | 1 者だけの結果。**担当ごとに 1 通り出す**(誰を選ぶかで結果が変わるため) | | `majority` | `origin_runtimes` が 2 者以上の指摘 | -| `proposed` | 区分が `verified_blocking` または `needs_human_judgment` の指摘 | +| `proposed` | 区分が `verified_blocking` / `needs_human_judgment` / `unrefuted` の指摘 | | `oracle` | いずれかの担当が出した指摘のうち、**修正された**もの | **母集合は代表だけである。** 4 つとも `merged_into` を持つ要素を数えない。統合された側を @@ -232,8 +247,8 @@ $SCRIPTS/measure.py <状態ファイルのパス> [--output <パス>] 過去の記録の `single` が全件 0 になる。補った値は 1 者であるため `majority` には入らない。 **`proposed` が読むのは印(`evidence_rounds`)のあるラウンドだけである。** 印の無いラウンドを -母集合へ入れると、区分の付かない指摘が `insufficient_evidence` として落ち、再現率が実際より -低く出る。**分母も印のあるラウンドに限る**(分子だけを絞ると、印の混ざった記録で過小に出る)。 +母集合へ入れると、区分の付かない指摘が数える 3 区分のいずれにも当たらずに落ち、再現率が +実際より低く出る。**分母も印のあるラウンドに限る**(分子だけを絞ると、印の混ざった記録で過小に出る)。 分母が他の 3 つと違うことは `oracle_scope` と `oracle_base` の 2 つのキーで常に出す。 ### `oracle` の結び方 diff --git a/plugins/ndf/skills/cross-review/references/context-budget.md b/plugins/ndf/skills/cross-review/references/context-budget.md index 8dd941d6..7c513dbb 100644 --- a/plugins/ndf/skills/cross-review/references/context-budget.md +++ b/plugins/ndf/skills/cross-review/references/context-budget.md @@ -20,8 +20,10 @@ state.json と result.json だけ読む 2. **サブエージェント分離**: 修正は別 context window で実行 3. **PR ローテーション**: 1 PR あたりの会話履歴を抑える -4. **AI 直接投稿**: 中間ペイロードがメインを通らない +4. **投稿はプロセスの中で組み立てる**: 投稿の本文は担当が書いたファイルから取り込み + (`state.py read-result` / `merge-fix`)のプロセスの中だけを通り、メインの応答には件数・ + 参照・状態だけが載る 5. **state.json で再開可能**: メインが落ちても次回起動時に続きから 手順を変えるときは、この 5 つのどれかを崩していないかを確かめる。特に 1 と 4 は、 -外部 AI の出力をメインが読んで整形する形へ戻すと簡単に崩れる。 +担当の出力をメインが読んで整形する形や、本文を部分命令の引数で渡す形へ戻すと簡単に崩れる。 diff --git a/plugins/ndf/skills/cross-review/scripts/classifications.py b/plugins/ndf/skills/cross-review/scripts/classifications.py new file mode 100644 index 00000000..d25983e0 --- /dev/null +++ b/plugins/ndf/skills/cross-review/scripts/classifications.py @@ -0,0 +1,12 @@ +"""cross-review の区分に関する共有定義(#156、#732)。 + +収束の判定(`state.py`)と効果の測定(`measure.py`)が同じ区分を数えるための +唯一の定義を置く。片方だけに区分を足すと、判定が数えた指摘を測定が採らず、 +その方式の再現率が実際より低く出る(`test_measure.py` が両者の一致を固定する)。 +""" +from __future__ import annotations + +# 収束の判定が数える区分(#156、#732)。**残る 3 つは数えない。** 数えないのは、誤りだと +# 示された棄却と、承認を妨げない軽微な指摘だけである。棄却した指摘を数えると、そのぶん +# ラウンドが増える(#69 で同じ論点が 5 ラウンド続いた事象)。 +COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment", "unrefuted") diff --git a/plugins/ndf/skills/cross-review/scripts/critique.sh b/plugins/ndf/skills/cross-review/scripts/critique.sh index a46d62a2..edf59634 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() { @@ -96,6 +100,10 @@ cat > "$PROMPT" < +# 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 @@ -41,7 +46,6 @@ STATE=$TMP_DIR/cross-review-pr$STATE_PR-state.json load_context() { WORKTREE=$(jq -r '.worktree_path' "$STATE") REPO=$(jq -r '.repo' "$STATE") -EVENT_DOWNGRADE=$(jq -r '.event_downgrade // false' "$STATE") EXTRA_REVIEW_INSTRUCTIONS=$(jq -r '.review_instructions // .extra_review_instructions // ""' "$STATE") # PR (=current_pr) は gh コマンドのレビュー対象 PR 番号として使う。 # tmp パス側は STATE_PR で固定 (monitor.py / state.py との読み書き整合のため)。 @@ -54,14 +58,18 @@ SHA=$(jq -r '(.rounds[-1].head_sha // "")' "$STATE") } prepare_prompt_context() { -# 前ラウンドの結果を残さない。投稿失敗などで今ラウンドの result.json が +# 前ラウンドの結果を残さない。担当が止まって今ラウンドの 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" - -STEM=$TMP_DIR/$RUNTIME-review-pr$STATE_PR +# 一時の名前のファイルも消す。前の起動が書きかけで止まった残りを、改名の対象に +# しないため。 +rm -f "$TMP_DIR/$SEAT-review-pr$STATE_PR-result.json" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-result.json.tmp" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-payload.json" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-payload.json.tmp" \ + "$TMP_DIR/$SEAT-review-pr$STATE_PR-round$ROUND-api-payload.json" + +STEM=$TMP_DIR/$SEAT-review-pr$STATE_PR PROMPT=$STEM-prompt.md # 既存コメントは **プロンプトにインライン埋め込み** する。 # tmp dir は `/.cross_review/` を使うが、埋め込みなら読み取りの往復が @@ -90,18 +98,16 @@ fi render_review_prompt() { cat > "$PROMPT" <\` は本来の intent を書く。 ## 既存コメントスナップショット(重複指摘禁止) workspace 外を読まなくて済むよう、以下にインライン展開する: @@ -111,28 +117,24 @@ $EXISTING_INLINE \`\`\` $EXTRA_REVIEW_BLOCK -## 出力契約 -- review body の **先頭行** に必ず以下を入れる: - \`\`\` - ## 🤖 cross-review | round $ROUND | $RUNTIME | - \`\`\` - - \`\` は **本来の intent** (REQUEST_CHANGES / APPROVE / COMMENT) - -### 出力に **含めてはいけないもの**(Resolve 負荷を増やすため) -- ❌ **「良い点」/「Strengths」/「評価できる点」 section** — body にも書かない -- ❌ **対応アクションが無いインラインコメント** — 観察・感想・現状説明だけは禁止 -- ❌ **nit / スタイル指摘のインライン化** — 好みの問題はコメント化しない (無視する) -- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無いコメント** -- ❌ **\`event=COMMENT\` での雑感投稿** — 直すべき点が無ければ \`APPROVE\` にする - -### インラインコメントの書式 +## 指摘に **含めてはいけないもの**(Resolve 負荷を増やすため) +- ❌ **「良い点」/「Strengths」/「評価できる点」** — 総評にも書かない +- ❌ **対応アクションが無い指摘** — 観察・感想・現状説明だけは禁止 +- ❌ **nit / スタイル指摘** — 好みの問題は指摘にしない (無視する) +- ❌ **コード引用 (\`\`\` ... \`\`\`) だけで指摘内容が無い指摘** +- ❌ **判定 \`COMMENT\` での雑感** — 直すべき点が無ければ \`APPROVE\` にする + +### 指摘の書式 - \`[重要度 / カテゴリ]\` プレフィックス必須 (例: \`[major / 正確性]\`) -- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit はインライン化しない) -- 本文は **1 コメント = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く +- 重要度は \`critical\` / \`major\` / \`minor\` のみ使う (nit は指摘にしない) +- 本文は **1 指摘 = 1 修正アクション** で完結させる。1〜2 文で具体的な修正提案を書く +- 指す行が分かる指摘は \`path\` と \`line\` を埋める。**差分の外の行でもよい** + (差分の外を指す指摘は、投稿する側が総評へ移す) +- 設計レベル・PR 横断の指摘で行を指せないものは、\`path\` / \`line\` を省く -### body (総評) の書き方 +### 総評(\`summary\`)の書き方 - 設計レベル・PR 横断の **修正提案のみ** 書く -- 書くことが無ければ prefix 行 + 1 行サマリだけで良い (褒め言葉や評価文は不要) +- 書くことが無ければ 1 行サマリだけで良い (褒め言葉や評価文は不要) ### 進捗マーカー(監視用) - 無言ハングと区別できるよう、作業フェーズが進むたびに @@ -141,69 +143,53 @@ $EXTRA_REVIEW_BLOCK - \`start: review PR #$PR round $ROUND\` - \`scan: diff and existing comments\` - \`analyze: candidate findings\` - - \`post: submit review\` + - \`write: payload and result\` - \`done: result.json written\` - -### インラインコメントを付けられる行(422 対策・必須) -- インラインコメントは **この PR の差分に含まれる行にしか付けられない**。差分外の行を - 指定すると GitHub が \`HTTP 422 Line could not be resolved\` を返し、**インラインだけで - なくレビュー本体も投稿されない**(指摘が丸ごと失われる) -- 差分に無い箇所を指摘したいときは、インラインにせず **body に「ファイル名:行 + 指摘」 - の形で書く** -- それでも 422 が返ったときは、**該当インラインを body へ移して再投稿する**。 - 投稿を諦めない - -### 投稿できなかった場合(必須) -- gh api がエラーを返したら、err.log に詳細を残したうえで **result.json を必ず書いて - から終了する**。\`event\` は本来の intent、\`comments_count\` は 0、 - \`"post_error"\` に失敗理由(HTTP status とメッセージ)を入れる: - \`\`\`json - {"event": "REQUEST_CHANGES", "posted_as": "COMMENT", "comments_count": 0, - "review_url": "", "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0}, - "post_error": "422 Line could not be resolved"} - \`\`\` -- result.json を書かずに終了すると、収束ループは**前ラウンドの結果を使うか、結果なしで - 停止する**。エラー時ほど result.json が要る - -- 投稿後、サマリを **$STEM-result.json** に - **必ず以下のキーで** 書く: - \`\`\`json - { - "event": "APPROVE", - "posted_as": "COMMENT", - "comments_count": 3, - "review_url": "https://github.com/.../pull/$PR#pullrequestreview-...", - "by_severity": {"critical": 0, "major": 0, "minor": 0, "nit": 0} - } - \`\`\` - - \`intent\` / \`comment_count\` 等の別名は使わないこと - - \`event\` の値は \`APPROVE\` / \`REQUEST_CHANGES\` / \`COMMENT\` のいずれか - - \`event_downgrade=true\` のとき \`posted_as\` は \`COMMENT\` にダウングレード可 -- payload は **$STEM-round$ROUND-payload.json** に保存 - (\`{ "comments": [{path, line, body, severity, evidence, falsification, - suggested_check, posted_to}, ...] }\` 形式) - - **\`comments[]\` に載せるのは、あなたが出した指摘の全件である。** 投稿したインラインの - 写しではない。**差分の外を指すために body へ書いた指摘も、422 で body へ移した指摘も - 載せる**(載せないと進行側から見えない) - - \`posted_to\` は \`inline\` / \`body\` のどちらへ投稿したか - - \`path\` / \`line\` は body へ書いたときも埋める(body でも「ファイル名:行 + 指摘」の - 形で書くため、値は手元にある) - - \`evidence\` は根拠(対象のコードと到達経路)、\`falsification\` は反証条件 - (これが成り立てば棄却できる)、\`suggested_check\` は実行できる検証手順 - - **根拠と反証条件は、別の担当がその指摘を確かめるためのものである。** 確かめられない - 書き方(「一般によくない」など)は根拠にならない +## 書くファイル(2 つ) + +**どちらも一時の名前で書き終えてから、正式の名前へ改名する。改名の順序は控えが先、 +結果ファイルが後である。** 結果ファイルが正式の名前で現れたことが、2 つとも書き終えた +印になる。途中で止まったときは正式の名前のファイルを残さない。 + +1. 指摘の控えを **$STEM-round$ROUND-payload.json.tmp** に書く: + \`\`\`json + { + "summary": "総評(1〜数行)", + "comments": [ + {"path": "src/foo.py", "line": 42, "body": "[major / 正確性] ...", + "severity": "major", "evidence": "...", "falsification": "...", + "suggested_check": "..."} + ] + } + \`\`\` + - **\`comments[]\` に載せるのは、あなたが出した指摘の全件である** + - \`evidence\` は根拠(対象のコードと到達経路)、\`falsification\` は反証条件 + (これが成り立てば棄却できる)、\`suggested_check\` は実行できる検証手順 + - **根拠と反証条件は、別の担当がその指摘を確かめるためのものである。** 確かめられない + 書き方(「一般によくない」など)は根拠にならない +2. 判定を **$STEM-result.json.tmp** に **必ず以下のキーだけで** 書く: + \`\`\`json + { + "event": "REQUEST_CHANGES", + "by_severity": {"critical": 0, "major": 1, "minor": 0, "nit": 0} + } + \`\`\` + - \`event\` は本来の判定で、\`APPROVE\` / \`REQUEST_CHANGES\` / \`COMMENT\` のいずれか。 + \`intent\` などの別名は使わない +3. 改名する(**この順で**): + \`\`\`bash + mv "$STEM-round$ROUND-payload.json.tmp" "$STEM-round$ROUND-payload.json" + mv "$STEM-result.json.tmp" "$STEM-result.json" + \`\`\` ## 守るべきこと - **発見を終えるまで、参照してよい既存コメントは起動時に渡されたスナップショットに - 限る。** 同じラウンドの他の担当が投稿した指摘・結果ファイル・進捗ログは参照しない - (指摘を出し終えて投稿するまでの間の話で、投稿の手順が既存コメントを引くことは妨げない) - - **担当は並列に起動する。** 先に投稿した担当の指摘を読むと、独立に見つけた指摘と - 区別できなくなる。同じ指摘が 2 者から出たことに意味があるのは、互いを見ていない場合 - だけである -- **リポジトリ編集禁止**。gh api での投稿のみ許可 -- worktree 外のパスは触らない -- gh api 失敗時は err.log にエラー詳細を残し、**result.json を書いてから**終了する + 限る。** 同じラウンドの他の担当の結果ファイル・進捗ログは参照しない + - **担当は並列に起動する。** 他の担当の指摘を読むと、独立に見つけた指摘と区別できなく + なる。同じ指摘が 2 者から出たことに意味があるのは、互いを見ていない場合だけである +- **リポジトリ編集禁止。PR・GitHub・git への書き込みもしない** +- worktree 外のパスは、上の 2 つのファイルと進捗マーカー以外に触らない EOF } diff --git a/plugins/ndf/skills/cross-review/scripts/measure.py b/plugins/ndf/skills/cross-review/scripts/measure.py index 9fa53bf8..ee26e60e 100755 --- a/plugins/ndf/skills/cross-review/scripts/measure.py +++ b/plugins/ndf/skills/cross-review/scripts/measure.py @@ -26,14 +26,24 @@ import datetime as _dt import json import pathlib +import re import sys from typing import Any, NamedTuple +# 区分の定義は scripts 配下の共有モジュールに 1 か所だけ置く(#156、#732)。 +# `state.py` も同じ定義を読み、両者の一致は `test_measure.py` が固定する。 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from classifications import COUNTED_CLASSIFICATIONS # noqa: E402 -# **担当の名前は 4 つである。** `reviewers` を持たない古い記録で、結果を残した -# 担当を数えるために使う(`state.py` の `LEGACY_AGENTS` は 2 者で、母集合を -# 広げる前の既定値である。ここは記録にある値だけを数えるため一覧を広く取る)。 -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: @@ -63,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]: @@ -125,7 +131,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]: @@ -416,11 +423,6 @@ def _majority(representatives: list[dict[str, Any]], return _method_output(finding_ids, oracle_ids) -# 3 本目の区分のうち、この変更の方式が採る 2 つ(`state.py` の -# `COUNTED_CLASSIFICATIONS` と同じ)。**残る 3 つは採らない。** -COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment") - - def _evidence_rounds(st: dict[str, Any]) -> set[int]: """証拠集約(統合・実行検証・反証)を通ったラウンドの印。 @@ -445,6 +447,31 @@ def _all_rounds_marked(st: dict[str, Any], marked: set[int]) -> bool: return all(_round_no(rounds, i) in marked for i in range(len(rounds))) +def _scoped_oracle_ids( + representatives: list[dict[str, Any]], + oracle_ids: set[str] | None, + marked: set[int], +) -> set[str] | None: + """上限の方式の集合を、印のあるラウンドの指摘だけへ絞る(#156)。 + + 分母を全ラウンドのままにすると、印の混ざった記録で再現率が過小に出る。分子と + 同じ母集合(印のあるラウンド)へ絞るため、`finding_id` から `round` を引いて + `marked` に含まれるものだけを残す。 + + Returns: + - `oracle_ids` が `None`(上限を計算できない)なら `None` を返す。 + - `marked` が全ラウンドを覆うなら、絞り込みの結果は `oracle_ids` と同じになる。 + - 一部のラウンドだけが印を持つなら、そのラウンドの指摘だけが残る。 + """ + if oracle_ids is None: + return None + rounds_by_id = { + str(finding.get("finding_id")): _as_int(finding.get("round")) + for finding in representatives + } + return {fid for fid in oracle_ids if rounds_by_id.get(fid) in marked} + + def _proposed(st: dict[str, Any], representatives: list[dict[str, Any]], oracle_ids: set[str] | None) -> dict[str, Any]: """この変更の方式。**読むのは証拠集約を通ったラウンドだけである。** @@ -452,10 +479,11 @@ def _proposed(st: dict[str, Any], representatives: list[dict[str, Any]], 印の無いラウンドを母集合へ入れると、区分の付かない指摘が `insufficient_evidence` として落ち、方式の再現率が実際より低く出る。 - **分母も印のあるラウンドに限る。** 分子だけを絞ると、印の混ざった記録で - 再現率が過小に出る。印の無い round 1 と印のある round 2 に修正された指摘が - 1 件ずつあるとき、採れるのは round 2 の 1 件だけであり、全ラウンドの上限 - (2 件)で割ると**拾えるものを全部拾っても 0.5 にしかならない**。 + **分母も印のあるラウンドに限る**(絞り込みは `_scoped_oracle_ids` が持つ)。 + 分子だけを絞ると、印の混ざった記録で再現率が過小に出る。印の無い round 1 と + 印のある round 2 に修正された指摘が 1 件ずつあるとき、採れるのは round 2 の + 1 件だけであり、全ラウンドの上限(2 件)で割ると**拾えるものを全部拾っても + 0.5 にしかならない**。 **分母が全ラウンドと違うことは出力へ出す。** 添えないと、読む側がこの方式の 再現率を他の 3 つと同じ分母の値として読む。 @@ -473,16 +501,7 @@ def _proposed(st: dict[str, Any], representatives: list[dict[str, Any]], if _as_int(finding.get("round")) in marked and finding.get("classification") in COUNTED_CLASSIFICATIONS } - if oracle_ids is None: - base_ids = None - else: - rounds_by_id = { - str(finding.get("finding_id")): _as_int(finding.get("round")) - for finding in representatives - } - base_ids = { - fid for fid in oracle_ids if rounds_by_id.get(fid) in marked - } + base_ids = _scoped_oracle_ids(representatives, oracle_ids, marked) result = _method_output(finding_ids, base_ids) result["oracle_scope"] = ( "all_rounds" if _all_rounds_marked(st, marked) else "evidence_rounds" diff --git a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh index 5c7a26e8..e1e0d1f3 100755 --- a/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh +++ b/plugins/ndf/skills/cross-review/scripts/rotate-pr.sh @@ -150,6 +150,44 @@ cmd_prepare() { printf 'IS_DRAFT=%q\n' "$(jq -r '.isDraft' <<<"$pr_json")" } +# 旧 PR の close から新 PR 作成・結果出力までの共通手順 (light / squash)。 +# モード固有なのは「旧 PR へ残すコメント」「新 PR の body」「NEW_BRANCH として出す名前」 +# 「gh pr create の引数」の 4 つだけで、順序と ERR trap の扱いは両モードで同じ。 +# +# rotate_close_and_create <コメント> <新 PR の body> +rotate_close_and_create() { + local comment=$1 new_body=$2 new_branch=$3 + shift 3 + local create_args=("$@") + + # 1. 旧 PR を close (コメント残し) + post_pr_comment "$OLD_PR" "$comment" + gh_retry gh pr close "$OLD_PR" + + # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する + # (関数定義は file 冒頭で共通化, gemini round 6 指摘) + trap reopen_old_pr_on_failure ERR + + # 2. 新 PR 作成。body は --body-file - 経由で stdin から渡し、argv 長制限を回避する + # (gemini 指摘) + local new_pr_url + new_pr_url=$(printf '%s' "$new_body" | gh_retry gh pr create "${create_args[@]}") + + # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても + # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 + trap - ERR + + # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, + # gemini round 6 指摘)。URL 形式: https://github.com///pull/ + local new_pr=${new_pr_url##*/} + + echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 + # eval される契約。ブランチ名 / URL に shell メタ文字が混ざっても安全なよう %q で escape + printf 'NEW_PR=%q\n' "$new_pr" + printf 'NEW_PR_URL=%q\n' "$new_pr_url" + printf 'NEW_BRANCH=%q\n' "$new_branch" +} + # light モード本体: 同ブランチで旧 PR を close → 同 head/base で新 PR 作成。 execute_light() { local state_pr=$1 @@ -183,36 +221,18 @@ execute_light() { echo "🔼 git push origin HEAD:$head_branch (未 push commit が無ければ no-op)" >&2 git push origin HEAD:"$head_branch" - # 2. 旧 PR を close (コメント残し) - post_pr_comment "$OLD_PR" "ℹ️ レビューコメント履歴整理のため本 PR を一度 close し、同じブランチ \`$head_branch\` で新 PR を作り直します。ブランチの内容・base は変えません。" - gh_retry gh pr close "$OLD_PR" - - # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する - # (関数定義は file 冒頭で共通化, gemini round 6 指摘) - trap reopen_old_pr_on_failure ERR - - # 3. 新 PR を同 head/base で作成 (Draft 状態は元 PR から継承)。 - # body は --body-file - 経由で stdin から渡し、argv 長制限を回避する (gemini 指摘)。 + # 2. 新 PR を同 head/base で作成 (Draft 状態は元 PR から継承)。 local create_args=(--base "$base_branch" --head "$head_branch" --title "$new_title" --body-file -) if [ "$is_draft" = "true" ]; then create_args+=(--draft) fi - local new_pr_url - new_pr_url=$(printf '%s' "$new_body" | gh_retry gh pr create "${create_args[@]}") - - # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても - # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 - trap - ERR - - # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, - # gemini round 6 指摘)。URL 形式: https://github.com///pull/ - local new_pr=${new_pr_url##*/} - echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 - # eval される契約。head_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape - printf 'NEW_PR=%q\n' "$new_pr" - printf 'NEW_PR_URL=%q\n' "$new_pr_url" - printf 'NEW_BRANCH=%q\n' "$head_branch" + # 3. close → create → 結果出力は squash と共通。 + rotate_close_and_create \ + "ℹ️ レビューコメント履歴整理のため本 PR を一度 close し、同じブランチ \`$head_branch\` で新 PR を作り直します。ブランチの内容・base は変えません。" \ + "$new_body" \ + "$head_branch" \ + "${create_args[@]}" } # squash モード本体。 @@ -283,17 +303,7 @@ execute_squash() { -m "(cross-review rotation: PR #$OLD_PR を squash 統合)" git push -u origin "$new_branch" - # 2. 旧 PR を close (コメント残し) - post_pr_comment "$OLD_PR" "🔄 cross-review ループ進行中のため、本 PR を close し新規 PR に巻き直します。 round_in_pr=$ROUND_IN_PR で長尺化を回避。" - gh_retry gh pr close "$OLD_PR" - - # close 後に create が失敗した場合は旧 PR を reopen して rotation の途中停止を回避する - # (関数定義は file 冒頭で共通化, gemini round 6 指摘) - trap reopen_old_pr_on_failure ERR - - # 3. 新 PR 作成 - # body は --body-file - 経由で stdin から渡し、argv 長制限を回避する - # (execute_light と統一, gemini round 5 指摘) + # 2. 新 PR の body local new_body new_body=$(cat < EOF ) - local new_pr_url - new_pr_url=$(printf '%s' "$new_body" | gh_retry gh pr create --base "$base" --title "$new_title" --body-file -) - - # gh pr create 成功直後に trap を解除し、後続の URL parse / echo 等が失敗しても - # 新旧 PR が重複して開く事態を避ける (gemini round 6 指摘)。 - trap - ERR - # PR 番号は create 出力 URL の末尾セグメントから抽出 (gh pr view 追加呼び出しを削減, - # gemini round 6 指摘)。URL 形式: https://github.com///pull/ - local new_pr=${new_pr_url##*/} - - echo "✅ 新 PR #$new_pr: $new_pr_url" >&2 - # eval される契約。new_branch / URL に shell メタ文字が混ざっても安全なよう %q で escape - printf 'NEW_PR=%q\n' "$new_pr" - printf 'NEW_PR_URL=%q\n' "$new_pr_url" - printf 'NEW_BRANCH=%q\n' "$new_branch" + # 3. close → create → 結果出力は light と共通。 + rotate_close_and_create \ + "🔄 cross-review ループ進行中のため、本 PR を close し新規 PR に巻き直します。 round_in_pr=$ROUND_IN_PR で長尺化を回避。" \ + "$new_body" \ + "$new_branch" \ + --base "$base" --title "$new_title" --body-file - } cmd_execute() { diff --git a/plugins/ndf/skills/cross-review/scripts/state.py b/plugins/ndf/skills/cross-review/scripts/state.py index f0a02e60..9667dd61 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,7 +35,15 @@ 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) +import monitor_outcome # noqa: E402 起動 1 回の結末(#729) +import result_posts # noqa: E402 結果ファイルを投稿へ変える層(#730) + +# 区分の定義は scripts 配下の共有モジュールに 1 か所だけ置く(#156、#732)。 +# `measure.py` も同じ定義を読み、両者の一致は `test_measure.py` が固定する。 +sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent)) +from classifications import COUNTED_CLASSIFICATIONS # noqa: E402 # ---------------- helpers ---------------- @@ -1402,6 +1411,33 @@ def _pending_posts(pr: int) -> int: return _queue(pr).count() +def _flushed_review(item: dict[str, Any]) -> tuple[Any, Any, str] | None: + """待ち行列の項目から、確認するレビューの担当・ラウンド・URL を得る。""" + if item.get("kind") != "review-post": + return None + extra = item.get("extra") or {} + agent, round_no = extra.get("agent"), extra.get("round") + if not (agent and round_no): + return None + response = item.get("response") + response = response if isinstance(response, dict) else {} + url = str(response.get("html_url") or "") + if not url and response.get("id"): + url = f"#pullrequestreview-{response['id']}" + return agent, round_no, url + + +def _flushed_review_target( + state: dict[str, Any], agent: Any, round_no: Any) -> dict[str, Any] | None: + """レビューを積んだラウンドから、担当の書き戻し先を探す。""" + return next( + (entry for entry in state.get("rounds", []) + if entry.get("round") == round_no + and isinstance(entry.get(agent), dict)), + None, + ) + + def _confirm_flushed(pr: int, item: dict[str, Any]) -> None: """流した直後に、投稿が届いたことを 1 度だけ確かめる。 @@ -1415,24 +1451,14 @@ def _confirm_flushed(pr: int, item: dict[str, Any]) -> None: 確認を促す側にも働かない**(判定は `queued` を見て照会を飛ばす)。共通層が 見つけた投稿を `response` として渡すため、どちらも同じ経路で確かめられる。 """ - if item.get("kind") != "review-post": - return - extra = item.get("extra") or {} - agent, round_no = extra.get("agent"), extra.get("round") - if not (agent and round_no): + review = _flushed_review(item) + if review is None: return - resp = item.get("response") if isinstance(item.get("response"), dict) else {} - url = str(resp.get("html_url") or "") - if not url and resp.get("id"): - url = f"#pullrequestreview-{resp['id']}" + agent, round_no, url = review st = _load(pr) # **書き戻す先は、その項目が属するラウンドである。** 積んだラウンドと流した # ラウンドが同じとは限らないため、最後のラウンドへ書かない。 - target = next( - (e for e in st.get("rounds", []) - if e.get("round") == round_no and isinstance(e.get(agent), dict)), - None, - ) + target = _flushed_review_target(st, agent, round_no) if target is None: return exists = _review_exists(str(st.get("repo") or ""), @@ -1464,8 +1490,9 @@ def _auto_flush(pr: int) -> None: 実行していない場合に流れない。明示だけだと、進行側が忘れたときに待ち行列が残った まま収束の判定へ進む。流せなくても工程は止めない。 - **入口は、書き戻し先が揃っている場所だけである。** 取り込み(`read-result`)の - 入口では、そのラウンドの担当のエントリがまだ無い。詳細は `cmd_read_result` にある。 + **入口は、書き戻し先が揃っている場所だけである。** 積むのは取り込み + (`read-result`)だけで、積んだ時点でその担当の記録を書くため、流す時点では + 書き戻し先が揃っている。 """ q = _queue(pr) if not q.count(): @@ -1506,51 +1533,122 @@ def cmd_flush(args: argparse.Namespace) -> None: info(f"✅ 待ち行列は空です(送った {len(result.sent)} 件)") -def _print_init_result( - pr: object, - worktree: object, - tmp_dir: object, - repo: object, - head_branch: object, - base_branch: object, - is_own: bool, - event_downgrade: bool, - has_extra: bool, - carried_count: int, - resumed: bool, -) -> None: +class _InitResult(NamedTuple): + """cmd_init が標準出力の機械可読ブロックへ書く初期化結果。 + + 再開経路と新規経路が同じ組を渡すため、位置引数の並びではなく名前付きの + フィールドで受け渡す。 + """ + + pr: object + worktree: object + tmp_dir: object + repo: object + head_branch: object + base_branch: object + is_own: bool + event_downgrade: bool + has_extra: bool + carried_count: int + resumed: bool + + +def _print_init_result(result: _InitResult) -> None: """cmd_init の 2 経路(再開・新規)が共有する末尾の出力ブロック。 出力形式は再開側・新規側で同一のため 1 箇所へ寄せる。PR 番号だけは 元の両分岐に合わせて quote しない(数値のため)。 """ - print(f"PR={pr}") - print(f'WORKTREE={shlex.quote(str(worktree))}') - print(f'TMP_DIR={shlex.quote(str(tmp_dir))}') - print(f'REPO={shlex.quote(str(repo))}') - print(f'HEAD_BRANCH={shlex.quote(str(head_branch))}') - print(f'BASE_BRANCH={shlex.quote(str(base_branch))}') - print(f"IS_OWN_PR={'1' if is_own else '0'}") - print(f"EVENT_DOWNGRADE={'1' if event_downgrade else '0'}") - print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if has_extra else '0'}") - print(f"CARRIED_OVER_THREADS={carried_count}") - print(f"RESUMED={'1' if resumed else '0'}") + print(f"PR={result.pr}") + print(f'WORKTREE={shlex.quote(str(result.worktree))}') + print(f'TMP_DIR={shlex.quote(str(result.tmp_dir))}') + print(f'REPO={shlex.quote(str(result.repo))}') + print(f'HEAD_BRANCH={shlex.quote(str(result.head_branch))}') + print(f'BASE_BRANCH={shlex.quote(str(result.base_branch))}') + print(f"IS_OWN_PR={'1' if result.is_own else '0'}") + print(f"EVENT_DOWNGRADE={'1' if result.event_downgrade else '0'}") + print(f"HAS_EXTRA_REVIEW_INSTRUCTIONS={'1' if result.has_extra else '0'}") + print(f"CARRIED_OVER_THREADS={result.carried_count}") + print(f"RESUMED={'1' if result.resumed else '0'}") + + +# 再開で渡した引数の反映の表(#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 _resume_from_state( - pr: object, - repo: str, - worktree: str, - manual_extra_review: str, -) -> bool: - """既存 state からの再開経路。 - 再開に該当し出力まで済ませたら True、該当する state が無ければ False を返す。 - False のとき cmd_init は新規 init へ進む。 +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 _find_resumable_state( + pr: object, worktree: str, +) -> tuple[dict[str, Any], pathlib.Path] | None: + """既存 state を探し、再開できるものだけを (state, path) で返す。 + + 再開に該当しなければ `None`。**探索の入口は `_tmp_dir()` を使わない**(mkdir の + 副作用でパスが作られてしまう)。`CROSS_REVIEW_TMP_DIR` があればそれを、無ければ + `/.cross_review/` を直接組む。`final` が確定した state は再開しない。 """ - # 再開チェック: CROSS_REVIEW_TMP_DIR が設定されている場合はそちらを優先し、 - # 未設定なら /.cross_review/ を直接パスとして組む。 - # _tmp_dir() は mkdir 副作用があるため使用せず、パス解決のみ行う。 env_tmp = os.environ.get("CROSS_REVIEW_TMP_DIR") if env_tmp: resume_dir = pathlib.Path(env_tmp).resolve() @@ -1558,11 +1656,27 @@ def _resume_from_state( resume_dir = pathlib.Path(worktree) / ".cross_review" resume_state_file = resume_dir / f"cross-review-pr{pr}-state.json" if not resume_state_file.exists(): - return False + return None st = json.loads(resume_state_file.read_text(encoding="utf-8")) if st.get("final") is not None: - return False + return None + return st, resume_state_file + + +def _refresh_resume_state( + st: dict[str, Any], pr: object, repo: str, manual_extra_review: str, + args: argparse.Namespace, +) -> bool: + """再開する state を最新化し、書き換えたかどうかを返す。 + + 旧形式の補完・manual 指示の反映・`review_instructions` の再計算・引き継ぎの記録を + 行う。**保存はしない**(呼び出し側が変更有無を見て 1 度だけ書く)。 + """ 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) @@ -1586,37 +1700,67 @@ def _resume_from_state( # 再開した時点で残っている未解決の指摘を引き継ぎとして記録する。 if _record_carried_over(st, st.get("repo") or repo, st.get("current_pr") or pr): state_changed = True - if state_changed: - _write_state(resume_state_file, st) - info("↻ 追加レビュー観点を state に反映して再開") - # 待ち行列を流すのは、手元の `st` を書き戻した**後**である。流した結果 - # (`queued` の解除と、届かなかった投稿の結果なし)は `_confirm_flushed` が - # 状態ファイルへ直接書く。先に流すと、この関数がその後に書き戻す古い `st` が - # それらを消す。再開の入口で流すこと自体は変えないため、回復した後の - # 1 本目のコマンドで届く。 - # 渡すのは状態ファイルの鍵(`args.pr`)で、`current_pr` ではない。待ち行列も - # 状態ファイルも鍵で引くため、巻き直しの後に `current_pr` を渡すと引けない。 + return state_changed + + +def _sync_resume_worktree(st: dict[str, Any], pr: object, worktree: str) -> pathlib.Path: + """保存後の副作用(待ち行列の flush → tmp_dir 解決 → 作業ツリー同期)を順に行う。 + + 順序に意味がある: + 1. **待ち行列を流すのは、手元の `st` を書き戻した後である。** 流した結果 + (`queued` の解除と、届かなかった投稿の結果なし)は `_confirm_flushed` が + 状態ファイルへ直接書く。先に流すと、この後の書き戻しが古い `st` でそれらを + 消す。渡すのは状態ファイルの鍵(`args.pr`)で、`current_pr` ではない。 + 2. `tmp_dir` を解決して返す(`_print_init_result` が使う)。 + 3. **再開でも同期する。** 中断から再開までの間に head が進んでいることがあり、 + そのまま次のラウンドを回すと古い差分をレビューさせる。 + """ _auto_flush(pr) tmp_dir = _tmp_dir(worktree) wt = st.get("worktree_path") or "" - # 再開でも同期する。中断から再開までの間に head が進んでいることがあり、 - # そのまま次のラウンドを回すと古い差分をレビューさせる。 resume_head = str(st.get("head_branch") or "") if wt and resume_head and _is_registered_worktree(str(wt)): _sync_worktree(str(wt), int(st.get("current_pr") or pr), resume_head) + return tmp_dir + + +def _resume_from_state( + pr: object, + repo: str, + worktree: str, + manual_extra_review: str, + args: argparse.Namespace, +) -> bool: + """既存 state からの再開経路。 + + 再開に該当し出力まで済ませたら True、該当する state が無ければ False を返す。 + False のとき cmd_init は新規 init へ進む。 + """ + found = _find_resumable_state(pr, worktree) + if found is None: + return False + st, resume_state_file = found + + if _refresh_resume_state(st, pr, repo, manual_extra_review, args): + _write_state(resume_state_file, st) + info("↻ 追加レビュー観点を state に反映して再開") + + tmp_dir = _sync_resume_worktree(st, pr, worktree) info(f"↻ 前回中断 state から再開(round={len(st.get('rounds', []))})") _print_init_result( - st["current_pr"], - wt, - tmp_dir, - st.get("repo") or "", - st.get("head_branch") or "", - st.get("base_branch") or "", - bool(st.get("is_own_pr")), - bool(st.get("event_downgrade")), - bool(st.get("review_instructions")), - (st.get("carried_over") or {}).get("count", 0), - True, + _InitResult( + pr=st["current_pr"], + worktree=st.get("worktree_path") or "", + tmp_dir=tmp_dir, + repo=st.get("repo") or "", + head_branch=st.get("head_branch") or "", + base_branch=st.get("base_branch") or "", + is_own=bool(st.get("is_own_pr")), + event_downgrade=bool(st.get("event_downgrade")), + has_extra=bool(st.get("review_instructions")), + carried_count=(st.get("carried_over") or {}).get("count", 0), + resumed=True, + ) ) return True @@ -1641,7 +1785,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 +1816,7 @@ class _InitWorkspaceContext(NamedTuple): class _InitialAssignment(NamedTuple): host: str host_source: str + participants: dict[str, Any] class _InitialStateContext(NamedTuple): @@ -1789,27 +1934,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), @@ -1854,17 +2002,19 @@ def _finalize_initial_state( _write_state(ws_ctx.state_file, state) info(f"✅ state 初期化: {ws_ctx.state_file}") _print_init_result( - pr, - pr_ctx.worktree, - ws_ctx.tmp_dir, - pr_ctx.repo, - pr_ctx.meta.head_branch, - pr_ctx.meta.base_branch, - pr_ctx.is_own, - pr_ctx.event_downgrade, - bool(review_ctx.review_instructions), - 0, - False, + _InitResult( + pr=pr, + worktree=pr_ctx.worktree, + tmp_dir=ws_ctx.tmp_dir, + repo=pr_ctx.repo, + head_branch=pr_ctx.meta.head_branch, + base_branch=pr_ctx.meta.base_branch, + is_own=pr_ctx.is_own, + event_downgrade=pr_ctx.event_downgrade, + has_extra=bool(review_ctx.review_instructions), + carried_count=0, + resumed=False, + ) ) pr_ctx = _resolve_pr_and_ownership(pr, repo, worktree, args.worktree) @@ -1891,56 +2041,164 @@ 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}") + + +def _runtime_list(value: str) -> list[str]: + """`--exclude` / `--include` の型。カンマ区切りの 4 つの名前、または `none`。 - `--only` で 1 者へ絞ったときに母集合の全員を確かめると、そのラウンドで起動しない - CLI の未認証で初期化が失敗する。 + `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` が母集合に含まれることを確かめる。含まなければ起動する前に弾く。 - ホスト自身や、参加しないランタイムを指定しても、そのラウンドは 1 者も起動しない。 - **起動してから気づくと、レビューの無いラウンドが記録に残る。** +def _normalize_participant_args( + args: argparse.Namespace, +) -> tuple[str | None, list[str] | None, list[str] | None]: + """`--only` / `--include` / `--exclude` を読み手の形へ直し `(only, include, exclude)` を返す。 + + `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 者指定が確認を通らない、は終了 + コード 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] = [] + # **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): + 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: @@ -2004,44 +2262,36 @@ def _round_passes( return True -def _guard_previous_round(st: dict[str, Any], prev: dict[str, Any]) -> None: - """前のラウンドの後始末が終わっているかを確かめる。 - - 進行側が手で修正して次のラウンドへ進めると、修正の工程(Step 5)が担う返信と - Resolve が飛ばされる。飛ばされたまま進むと、未解決の指摘が残ったまま承認へ到達する。 - - 止めるのは次の 2 つ。 - - 1. 前のラウンドが修正必須の判定なのに、修正の記録が無い - 2. 前のラウンドで Resolve したと申告されたスレッドが、GitHub 側で未解決のまま +def _resolve_previous_verdict(st: dict[str, Any], prev: dict[str, Any]) -> str | None: + """保存されていない旧形式の判定を、ラウンドの結果から復元する。""" + verdict = prev.get("verdict") + if verdict is not None: + return verdict + reviewers = prev.get("reviewers") or _round_reviewers(st, prev.get("round") or 1) + if _no_result_agents(prev, st.get("only"), reviewers): + return "no_result" + return ("approved" if _round_passes(prev, st.get("only"), reviewers) + else "changes_requested") + + +def _require_fix_for_changes(round_no: Any, verdict: str | None, + fix: dict[str, Any] | None) -> None: + """修正必須の判定に修正記録が伴うことを確かめる。""" + if verdict != "changes_requested" or fix: + return + die( + f"round {round_no} は修正必須の判定でしたが、修正の記録がありません。" + " 返信と Resolve が飛ばされている可能性があります。" + " `/ndf:fix` を実行して戻り値ファイルを作り、`merge-fix` を通してから" + " 次のラウンドを開始してください", + code=5, + ) - 未解決の指摘を取得できないときは検査を行わず、確認できなかったことを残して進む。 - 取得の失敗で止めると、GitHub 側の一時的な不調でループが進まなくなる。 - スレッドの状態は、申告が行われた Pull Request(`prev["pr"]`)へ問い合わせる。 - ローテーションを挟んだラウンドでは Step 6 の `set-current-pr` が先に走るため、 - `current_pr` は既に新しい Pull Request を指している。そちらへ問い合わせると、 - 旧 Pull Request のスレッドが未解決のままでも一覧に現れず検査が素通りする。 - """ +def _verify_resolved_threads(st: dict[str, Any], prev: dict[str, Any], + fix: dict[str, Any] | None) -> None: + """Resolve 済みとの申告を GitHub の未解決スレッドと突き合わせる。""" round_no = prev.get("round") - verdict = prev.get("verdict") - if verdict is None: - # 判定の結果を持たない古い状態ファイルは、保存された重要度から判定し直す。 - # 項目が欠けたラウンドは結果なしであり、修正の記録を求める対象ではない。 - if _no_result_agents(prev, st.get("only")): - verdict = "no_result" - else: - verdict = "approved" if _round_passes(prev, st.get("only")) else "changes_requested" - fix = prev.get("fix") - if verdict == "changes_requested" and not fix: - die( - f"round {round_no} は修正必須の判定でしたが、修正の記録がありません。" - " 返信と Resolve が飛ばされている可能性があります。" - " `/ndf:fix` を実行して戻り値ファイルを作り、`merge-fix` を通してから" - " 次のラウンドを開始してください", - code=5, - ) - claimed = (fix or {}).get("resolved_thread_ids") or [] if not claimed: return @@ -2063,6 +2313,31 @@ def _guard_previous_round(st: dict[str, Any], prev: dict[str, Any]) -> None: ) +def _guard_previous_round(st: dict[str, Any], prev: dict[str, Any]) -> None: + """前のラウンドの後始末が終わっているかを確かめる。 + + 進行側が手で修正して次のラウンドへ進めると、修正の工程(Step 5)が担う返信と + Resolve が飛ばされる。飛ばされたまま進むと、未解決の指摘が残ったまま承認へ到達する。 + + 止めるのは次の 2 つ。 + + 1. 前のラウンドが修正必須の判定なのに、修正の記録が無い + 2. 前のラウンドで Resolve したと申告されたスレッドが、GitHub 側で未解決のまま + + 未解決の指摘を取得できないときは検査を行わず、確認できなかったことを残して進む。 + 取得の失敗で止めると、GitHub 側の一時的な不調でループが進まなくなる。 + + スレッドの状態は、申告が行われた Pull Request(`prev["pr"]`)へ問い合わせる。 + ローテーションを挟んだラウンドでは Step 6 の `set-current-pr` が先に走るため、 + `current_pr` は既に新しい Pull Request を指している。そちらへ問い合わせると、 + 旧 Pull Request のスレッドが未解決のままでも一覧に現れず検査が素通りする。 + """ + fix = prev.get("fix") + verdict = _resolve_previous_verdict(st, prev) + _require_fix_for_changes(prev.get("round"), verdict, fix) + _verify_resolved_threads(st, prev, fix) + + def _sync_before_round(st: dict[str, Any], pr: int) -> HeadRef | None: """ラウンドを開く前に、レビュー用の作業ツリーを Pull Request の head へ揃える。 @@ -2152,32 +2427,6 @@ def _as_count(value: object) -> int: return 0 -def _posted_comment_count(repo: str, pr: int, review_url: str | None) -> int | None: - """レビューに実際にぶら下がっているインラインコメントの数。 - - 取得できなければ `None` を返す。**「取得できなかった」と「0 件」を区別する。** - 取得の失敗で中断すると、GitHub 側の一時的な不調でループが止まる。 - - 投稿は AI 自身が `gh api` で行うため、失敗しても結果ファイルの申告だけは残る。 - 数え直す先は、申告された `review_url` の末尾にある識別子から決める。 - """ - if not repo or not review_url: - return None - m = re.search(r"pullrequestreview-(\d+)", str(review_url)) - if not m: - return None - try: - out = _sh( - ["gh", "api", f"repos/{repo}/pulls/{pr}/reviews/{m.group(1)}/comments", - "--paginate", "--jq", "length"], - check=False, - ) - except Exception: - return None - counts = [int(line) for line in str(out).split() if line.strip().isdigit()] - return sum(counts) if counts else None - - # Pull Request 上の未解決の指摘(Resolve されていない review thread)を数えるための問い合わせ。 # `--paginate` に載せるため、カーソルと `pageInfo` を持たせる。 _UNRESOLVED_THREADS_QUERY = """ @@ -2201,14 +2450,13 @@ def _posted_comment_count(repo: str, pr: int, review_url: str | None) -> int | N def _review_exists(repo: str, pr: int, review_url: str | None) -> bool | None: - """申告された `review_url` の指すレビューが GitHub 側にあるか。 + """`review_url` の指すレビューが GitHub 側にあるか。 取得できなければ `None` を返す。**「取得できなかった」と「無い」を区別する。** 取得の失敗で中断すると、GitHub 側の一時的な不調でループが止まる。 - 投稿は AI 自身が `gh api` で行うため、失敗しても結果ファイルには判定が残る。 - 判定だけを採ると、修正の担当が読むべき指摘が Pull Request に無いまま修正の工程が - 起動する(実測: `review_url` が空、重要度別の件数もすべて 0)。 + 上限で積んだ投稿を後から流したとき、その直後に 1 度だけ呼ぶ(`_confirm_flushed`)。 + 取り込みが自分で送った投稿は、送信の応答をそのまま記録にするため照会しない(#730)。 """ if not repo or not review_url: return False @@ -2404,12 +2652,17 @@ def cmd_unresolved_threads(args: argparse.Namespace) -> None: info(f"- {t['id']} {t.get('path', '')}:{t.get('line', '')}") -def _record_no_result(pr: int, agent: str, reason: str) -> None: +def _record_no_result( + pr: int, agent: str, reason: str, monitor_detail: str | None = None, +) -> None: """使える結果が残らなかったことを、そのラウンドへ残す。 判定(`cmd_judge`)はこの記録を読んで、起動し直しか中断かを決める。記録が無い ラウンドも結果なしとして読むため、ここで書けなかった場合も収束はしない。 + `monitor_detail` は監視の `detail`(#729)。**鍵が無い = 監視の結果ファイルが + 無かった**を保つため、`None` と空文字では鍵を書かない。 + 状態ファイルを読めないときとラウンドがまだ無いときは、何も書かずに戻る。呼び出し 元はこの直後に die するため、ここで新たに止める理由が無い。 """ @@ -2422,7 +2675,7 @@ def _record_no_result(pr: int, agent: str, reason: str) -> None: return if not isinstance(st, dict) or not st.get("rounds"): return - st["rounds"][-1][agent] = { + entry: dict[str, Any] = { "intent": NO_RESULT, "no_result_reason": reason, "posted_as": None, @@ -2430,6 +2683,9 @@ def _record_no_result(pr: int, agent: str, reason: str) -> None: "review_url": None, "by_severity": {}, } + if monitor_detail: + entry["monitor_detail"] = monitor_detail + st["rounds"][-1][agent] = entry _save(pr, st) @@ -2440,81 +2696,30 @@ def _die_no_result(pr: int, agent: str, reason: str, msg: str, code: int = 1) -> def _read_review_result_file(pr: int, agent: str, rfile: pathlib.Path) -> dict[str, Any]: - if not rfile.exists() or rfile.stat().st_size == 0: - _die_no_result(pr, agent, "missing", f"{agent}: result 未生成 ({rfile})") + """結果ファイルを、結末の共通層の値として読む(#729 の決定 2)。 - try: - r = json.loads(rfile.read_text(encoding="utf-8")) - except json.JSONDecodeError as exc: - _die_no_result( - pr, - agent, - "unparsable", - f"{agent}: result.json の parse に失敗 ({rfile}): {exc}", - code=3, - ) - - # gemini round 4 指摘: result.json は本来 dict だが、launcher の出力バグや - # 別実行の残骸で list / str が入り込むと `r.get(...)` で AttributeError になる。 - # 不正な review result はバグなので即時 die(code=3) で停止させる。 - if not isinstance(r, dict): - _die_no_result( - pr, - agent, - "unparsable", - f"{agent}: result.json が dict ではない " - f"({rfile}, type={type(r).__name__})。review launcher の出力形式不正。", - code=3, - ) - return r - - -def _verify_review_arrival( - pr: int, agent: str, repo: str, result: dict[str, Any] -) -> bool: - """投稿が Pull Request に届いたかを確かめ、待ち行列へ積んだかどうかを返す。 - - **投稿が届いたかを先に確かめる。** 判定だけが残り、指摘の中身が Pull Request に - 無いまま修正の工程へ進む経路を塞ぐ(#261)。届いていないときは結果なしとして - 記録し、判定の側の「同じラウンドで 1 度だけ起動し直す」経路へ乗せる。修正の担当 - から見ると、結果が残らなかった場合と、結果はあるが指摘が届いていない場合は同じ - 状態である(読むべき指摘が無い)。 - - **待ち行列へ積んだ投稿は、積んだ時点では届いていない。** ここで照会すると - 結果なしになり、起動し直しで同じ内容が二重に積まれる。届いたことは流した直後に - 1 度だけ確かめる(`_confirm_flushed`)。 + 結果ファイルを自前で開かない。監視の結果ファイル(`-monitor.json`)と突き合わせて + 使える結果か理由かを決めるのは `read_launch_outcome` で、ここが決めるのは終了コードだけ + である(読めない結果は 3、それ以外は 1。変更前と同じ)。理由の語彙も起動し直しの可否も + ここには置かない。 """ - queued = bool(result.get("queued")) - if queued: - info( - f"⚠ {agent}: 投稿を待ち行列へ積んでいます。" - "届いたことの確認は流した直後に行います" - ) - post_error = None if queued else result.get("post_error") - if post_error: - _die_no_result( - pr, - agent, - "not_posted", - f"{agent}: レビューの投稿に失敗しています (post_error={post_error})。" - " 指摘が Pull Request に届いていないため、結果なしとして扱います", - ) - exists = None if queued else _review_exists(repo, pr, result.get("review_url")) - if exists is False: - _die_no_result( - pr, - agent, - "not_posted", - f"{agent}: 投稿されたレビューを確認できません " - f"(review_url={result.get('review_url')!r})。" - " 指摘が Pull Request に届いていないため、結果なしとして扱います", - ) - if exists is None and not queued: - info( - f"⚠ {agent}: レビューの投稿を確認できませんでした。" - "申告をそのまま採用します" + outcome = monitor_outcome.read_launch_outcome( + _resolve_tmp_dir(pr), f"{agent}-review-pr{pr}", rfile) + if outcome.payload is not None: + return outcome.payload + reason = outcome.reason or "missing" + # 監視の結果ファイルがあるときだけ、その `detail` を残す(無いときの `outcome.detail` は + # 読めなかった理由の 1 文で、監視の詳細ではない) + monitor_detail = str(outcome.monitor.get("detail") or "") if outcome.monitor else None + _record_no_result(pr, agent, reason, monitor_detail) + if reason == "unparsable": + # 結果ファイルはあるが JSON の dict として parse できない。launcher の出力形式不正 + die( + f"{agent}: result.json の parse に失敗、または dict ではない ({rfile}):" + f" {outcome.detail}", + code=3, ) - return queued + die(f"{agent}: 使える結果が無い (reason={reason}, {rfile}): {outcome.detail}") # 指摘へ既定を与える項目。**持たない指摘も捨てない**(#156)。捨てると、4 項目へ @@ -2621,70 +2826,34 @@ def _collect_review_findings( return len(items) -def _resolve_result_aliases(r: dict[str, Any]) -> tuple[str | None, str | None, Any]: - """result.json の別名フィールドを正規のキーへ解決する。 - - `intent` / `comment_count` を使う変則 JSON を書き出す既知のケースに対応する。 - 仕様としては `event` / `comments_count` が正で、そちらを優先する。 - """ - intent = r.get("event") or r.get("intent") - posted_as = r.get("posted_as") or intent - comments = r.get("comments_count") - if comments is None: - comments = r.get("comment_count") - return intent, posted_as, comments - - -def _verify_declared_comments( - repo: str, - pr: int, - agent: str, - comments: Any, - review_url: str | None, - queued: bool, -) -> None: - """**申告を GitHub 側と突き合わせる。** - - 投稿は AI 自身が行うので、失敗しても結果ファイルには件数が残る。申告のまま進むと、 - 修正担当が読むべき指摘が GitHub 上に存在しないまま収束判定まで走る - (実測: 申告 2 件に対しスレッド 0)。 - """ - declared = _as_count(comments) - if declared > 0 and not queued: - actual = _posted_comment_count(repo, pr, review_url) - if actual is None: - info( - f"⚠ {agent}: 投稿されたコメント数を確認できませんでした。" - f"申告({declared} 件)をそのまま採用します" - ) - elif actual < declared: - die( - f"{agent}: インラインコメントの申告 {declared} 件に対し、" - f"GitHub 上には {actual} 件しかありません。投稿が届いていないため" - "中断します。レビューを投稿し直してから再実行してください" - ) - - def cmd_read_result(args: argparse.Namespace) -> None: - """Step 2.4 — codex/agy の result.json を state にマージ。 + """Step 2.4 — 担当の結果を読み、レビューを投稿して state にマージ。 使える結果が残らなかったときは、`NO_RESULT` と理由をラウンドへ残してから止める。 終了コードは現行のまま(無い・判定の値を持たないときは 1、JSON として読めない ときは 3)で、進む先を決めるのは次の判定である。 - **ここでは待ち行列を流さない。** 流すと `review-post` の書き戻し先(そのラウンドの - 担当のエントリ)がまだ無い時点で項目が消える。`_confirm_flushed` は書き戻せず、 - この後の取り込みが `queued: true` だけを保存するため、待ち行列が空で `queued` の - ままの状態ができる。判定はその状態で収束してしまい、投稿の存在も参照も確かめない。 - **両方の担当を取り込んだ後に流す**(`judge` の入口)。取り込みは判定の直前に - しかないため、流す時期が遅れるのは 1 コマンド分である。 + **「読んで記録する」と「投稿する」を 1 つに閉じる**(#730 の決定 4)。順序は + 「控えを読む → 投稿を積む → 流す → 送信の応答を記録へ書き戻す → 指摘を取り込む」 + である。分けると「投稿したが記録していない」に加えて「記録したが投稿していない」が + もう 1 つ増える。1 つに閉じれば、途中で止まった状態は「送れていない」か + 「送れたが記録が無い」の 2 つになる。 + + | 止まった場所 | 待ち行列の項目 | 立て直し | + | --- | --- | --- | + | 送る前(上限などで送れていない) | 残る | 判定の終了コード 8 の枝が流し直す | + | 送った後・記録の前 | 残らない | 取り込みをもう一度呼ぶ。照合が先客を見つける | + + **担当の申告と GitHub の実数を突き合わせない。** 投稿する側と記録する側が同じに + なるため、確かめる対象が無い。記録に入る URL は送信の応答から取り、件数は送れた + インラインの数から取る。 """ agent = args.agent pr = args.pr rfile = pathlib.Path(args.file or _resolve_tmp_dir(pr) / f"{agent}-review-pr{pr}-result.json") r = _read_review_result_file(pr, agent, rfile) - intent, posted_as, comments = _resolve_result_aliases(r) + intent = r.get("event") or r.get("intent") if intent is None: _die_no_result( @@ -2699,27 +2868,50 @@ def cmd_read_result(args: argparse.Namespace) -> None: if not st.get("rounds"): die(f"{agent}: state.rounds が空。`state.py start-round` を先に呼んでください") - repo = str(st.get("repo") or "") - - queued = _verify_review_arrival(pr, agent, repo, r) - - _verify_declared_comments(repo, pr, agent, comments, r.get("review_url"), queued) - - st["rounds"][-1][agent] = { - "intent": intent, - "posted_as": posted_as, - "comments": comments, - "review_url": r.get("review_url"), + # **先に残りを流す。** 残っているのは、前の取り込みで送れずに積んだ投稿だけで、 + # その担当の記録は積んだ時点で書いてある。流した結果をその記録へ書き戻してから、 + # この担当の投稿を後ろへ積む(Pull Request 上の順序を保つ)。 + _auto_flush(pr) + st = _load(pr) + last = st["rounds"][-1] + round_no = last.get("round") + posted = result_posts.post_review( + _queue(pr), + _payload_path(agent, pr, round_no), + rfile, + repo=str(st.get("repo") or ""), + pr=int(st.get("current_pr") or pr), + round_no=int(round_no or 1), + seat=agent, + head_sha=str(last.get("head_sha") or ""), + is_own_pr=bool(st.get("event_downgrade") or st.get("is_own_pr")), + actor=str(st.get("viewer_login") or "") or None, + since=str(last.get("started_at") or "") or None, + ) + if posted.failed: + die(f"{agent}: レビューを投稿できませんでした ({posted.detail})") + + last[agent] = { + "intent": posted.intent, + "posted_as": posted.posted_as, + "comments": posted.posted_inline, + "review_url": posted.review_url, "by_severity": r.get("by_severity", {}), - "queued": queued, + "queued": bool(posted.queued), + "posted_inline": posted.posted_inline, + "posted_body": posted.posted_body, } - # **指摘そのものは別に積む**(#156)。`comments` は投稿したインラインの数で、 - # GitHub 側の実数との突き合わせに使う。総評だけへ書いた指摘はそこに現れない。 - collected = _collect_review_findings(st, agent, pr, st["rounds"][-1]["round"]) + # **指摘そのものは別に積む**(#156)。`comments` は送れたインラインの数で、 + # 総評へ移した指摘はそこに現れない。 + collected = _collect_review_findings(st, agent, pr, round_no) _save(pr, st) - info(f"✅ {agent}: intent={intent} posted_as={posted_as} comments={comments}") - if collected: - info(f" 指摘の記録: {collected} 件") + if posted.review_url: + print(f"POSTED review_url={posted.review_url}") + print(f"INLINE={posted.posted_inline} BODY={posted.posted_body}" + f" QUEUED={posted.queued}") + print(f"FINDINGS={collected}") + info(f"✅ {agent}: intent={posted.intent} posted_as={posted.posted_as}" + f" comments={posted.posted_inline}") def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, Any]: @@ -2761,34 +2953,83 @@ def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, An return {"verdict": "success", "sha": sha} +def _no_result_reasons(last: dict[str, Any], no_result: list[str]) -> dict[str, str]: + """結果なしの担当ごとの理由を集め、`NO_RESULT_REASONS` を出す。 + + どの出口でも進行側が理由を読めるように、先頭で 1 度だけ出す(#729 の AC14)。 + """ + reasons = { + a: (last.get(a) or {}).get("no_result_reason") or "missing" for a in no_result + } + print(f"NO_RESULT_REASONS='{' '.join(f'{a}={r}' for a, r in reasons.items())}'") + return reasons + + +def _abort_no_result_round(pr: int, st: dict[str, Any], msg: str) -> None: + """結果なしのラウンドを異常終了させる共通処理。 + + `final=error` にして保存し、終了コード 1 で止める。最終スイープを通してから + 完了報告へ進むよう促す文言は呼び出し側が渡す。 + """ + st["final"] = "error" + st["ended_at"] = _now() + _save(pr, st) + die(msg, code=1) + + +def _record_relaunch( + pr: int, st: dict[str, Any], last: dict[str, Any], pending: list[str] +) -> None: + """同じラウンドで起動し直す担当を記録し、シェル向けの出力を出す。""" + last["relaunched"] = (last.get("relaunched") or []) + pending + _save(pr, st) + print(f"RELAUNCH_AGENTS='{' '.join(pending)}'") + print(f"RELAUNCH_AGENTS_CSV={','.join(pending)}") + # 互換のために残す。**`both` は codex / agy の 2 者だけを指す語**であるため、 + # 担当がそれ以外を含むラウンドでは CSV の側を使う。 + print(f"RELAUNCH_TARGET={'both' if len(pending) == 2 else pending[0]}") + info( + f"→ 結果を残さなかったレビュアーがいる: {' '.join(pending)}。" + "同じラウンドで 1 度だけ起動し直す。" + ) + + def _handle_no_result_round( pr: int, st: dict[str, Any], last: dict[str, Any], no_result: list[str] ) -> None: + """結果なしの担当があるラウンドの出口を決める。 + + 先に理由の行(`NO_RESULT_REASONS`)を出す。どの出口でも進行側が理由を読めるようにする + ためである(#729 の AC14)。**起動し直しの可否は結末の共通層だけが決める** + (`monitor_outcome.relaunch_same_agent`)。可否が偽の理由が 1 つでもあれば、誰も起動し直さず + 誤りの終わりへ進む。起動し直しても解けない理由で待つのは、相手の CLI の枠と時間を使うだけ + である(#619)。骨組みは既存の 1 の枝で受けるため、終了コードは増えない(決定 12)。 + """ last["verdict"] = "no_result" + reasons = _no_result_reasons(last, no_result) + blocked = [a for a, r in reasons.items() + if not monitor_outcome.relaunch_same_agent(r)] + if blocked: + for a in blocked: + detail = (last.get(a) or {}).get("monitor_detail") + info(f" {a}: reason={reasons[a]}" + (f" detail={detail}" if detail else "")) + _abort_no_result_round( + pr, st, + f"起動し直しても解けない理由で結果が残りませんでした: {' '.join(blocked)}。" + " 同じラウンドで起動し直さずに中断します。最終スイープを通してから" + "完了報告へ進んでください", + ) relaunched = last.get("relaunched") or [] pending = [a for a in no_result if a not in relaunched] if not pending: # 2 度続けて結果が残らないのは、対象や負荷ではなく実行環境の側の事象である。 - st["final"] = "error" - st["ended_at"] = _now() - _save(pr, st) - die( + _abort_no_result_round( + pr, st, f"起動し直した後も結果が残りませんでした: {' '.join(no_result)}。" " 実行環境の側の問題として中断します。最終スイープを通してから" "完了報告へ進んでください", - code=1, ) - last["relaunched"] = relaunched + pending - _save(pr, st) - print(f"RELAUNCH_AGENTS='{' '.join(pending)}'") - print(f"RELAUNCH_AGENTS_CSV={','.join(pending)}") - # 互換のために残す。**`both` は codex / agy の 2 者だけを指す語**であるため、 - # 担当がそれ以外を含むラウンドでは CSV の側を使う。 - print(f"RELAUNCH_TARGET={'both' if len(pending) == 2 else pending[0]}") - info( - f"→ 結果を残さなかったレビュアーがいる: {' '.join(pending)}。" - "同じラウンドで 1 度だけ起動し直す。" - ) + _record_relaunch(pr, st, last, pending) sys.exit(7) @@ -3065,41 +3306,14 @@ def _merged_root( return current -def _verify_findings( - st: dict[str, Any], - round_no: int, +def _run_finding_checks( + targets: list[dict[str, Any]], allowed: list[str], work: str, - reproduced_codes: Optional[list[int]] = None, - runner: Optional[Any] = None, + codes: set[int], + run: Any, ) -> None: - """`suggested_check` を実行し、結果を `verification` へ残す(#156)。 - - **担当の再評価より先に走らせる。** 機械が再現した事実は、担当の支持より確かである。 - - **束ねた組の全員を対象にする**(`docs/specifications/cross-review-evidence-based.md` - の「重複の統合」)。 - 代表の `suggested_check` だけを読むと、代表が手順を書いていない組は、束ねられた側が - 実行できる手順を書いていても実行回数 0・`not_run` のまま `insufficient_evidence` へ - 落ちる。**どちらが先に取り込まれたかで採否が変わる。** 1 段目の統合は実行検証より - **前**にあるため、束ねられた側を読み飛ばすと、その手順は一度も実行されない。 - - 代表が持つのは組の集約である。`reproduced` > `not_reproduced` > `not_run` の順で - 最初に当たった 1 件を採り、**出所を `verification.finding_id` へ残す**。 - - **同じコマンドは 1 度しか実行しない。** 組の全員が同じ `suggested_check` を書くのは - 普通に起こり(統合の条件は本文の一致である)、そのたびに走らせると実行が増える。 - - **`ran_at` は結果を受け取った記録にだけ入れる。** 初期化で入れると、実行していない - `not_run` の記録にも時刻が残り、実行済みに見える。記録の `result` / `exit_code` / - `ran_at` が同じことを指すようにする。 - """ - codes = set(reproduced_codes or VERIFY_REPRODUCED_CODES) - run = runner or _run_verify - targets = [ - f for f in st.get("review_findings") or [] if f.get("round") == round_no - ] - by_id = {f.get("finding_id"): f for f in targets} + """各指摘の検証コマンドを重複なく実行し、結果を記録する。""" ran: dict[tuple[str, ...], Optional[int]] = {} for finding in targets: @@ -3124,7 +3338,11 @@ def _verify_findings( record["result"] = "not_reproduced" finding["verification"] = record - # 代表は組から選び直す。**実行し直さない**(記録済みの結果を選ぶだけである)。 + +def _propagate_best_verification( + targets: list[dict[str, Any]], by_id: dict[Any, dict[str, Any]] +) -> None: + """束ねた組から最良の検証結果を代表へ反映する。""" for rep in targets: if rep.get("merged_into"): continue @@ -3141,6 +3359,45 @@ def _verify_findings( rep["verification"] = dict(best) +def _verify_findings( + st: dict[str, Any], + round_no: int, + allowed: list[str], + work: str, + reproduced_codes: Optional[list[int]] = None, + runner: Optional[Any] = None, +) -> None: + """`suggested_check` を実行し、結果を `verification` へ残す(#156)。 + + **担当の再評価より先に走らせる。** 機械が再現した事実は、担当の支持より確かである。 + + **束ねた組の全員を対象にする**(`docs/specifications/cross-review-evidence-based.md` + の「重複の統合」)。 + 代表の `suggested_check` だけを読むと、代表が手順を書いていない組は、束ねられた側が + 実行できる手順を書いていても実行回数 0・`not_run` のまま `insufficient_evidence` へ + 落ちる。**どちらが先に取り込まれたかで採否が変わる。** 1 段目の統合は実行検証より + **前**にあるため、束ねられた側を読み飛ばすと、その手順は一度も実行されない。 + + 代表が持つのは組の集約である。`reproduced` > `not_reproduced` > `not_run` の順で + 最初に当たった 1 件を採り、**出所を `verification.finding_id` へ残す**。 + + **同じコマンドは 1 度しか実行しない。** 組の全員が同じ `suggested_check` を書くのは + 普通に起こり(統合の条件は本文の一致である)、そのたびに走らせると実行が増える。 + + **`ran_at` は結果を受け取った記録にだけ入れる。** 初期化で入れると、実行していない + `not_run` の記録にも時刻が残り、実行済みに見える。記録の `result` / `exit_code` / + `ran_at` が同じことを指すようにする。 + """ + codes = set(reproduced_codes or VERIFY_REPRODUCED_CODES) + run = runner or _run_verify + targets = [ + f for f in st.get("review_findings") or [] if f.get("round") == round_no + ] + by_id = {f.get("finding_id"): f for f in targets} + _run_finding_checks(targets, allowed, work, codes, run) + _propagate_best_verification(targets, by_id) + + def cmd_verify_findings(args: argparse.Namespace) -> None: """Step 2.5 前段 — 重複を束ね(1 段目)、`suggested_check` を実行する(#156)。 @@ -3346,11 +3603,7 @@ def cmd_collect_critiques(args: argparse.Namespace) -> None: if not st.get("rounds"): die("state.rounds が空。`state.py start-round` を先に呼んでください") round_no = st["rounds"][-1]["round"] - findings = { - f.get("finding_id"): f - for f in st.get("review_findings") or [] - if f.get("round") == round_no - } + findings = _round_finding_index(st, round_no) reviewers = _round_reviewers(st, round_no) attached, covered = _attach_critiques(st, pr, round_no, findings, reviewers) @@ -3364,11 +3617,7 @@ def cmd_collect_critiques(args: argparse.Namespace) -> None: # **揃っていない対象は統合の後に数える。** 束ねられた側は対象から外れるため、 # 先に数えると、代表へ返された 1 件で足りる組を不足として扱う。 - missing: dict[str, list[str]] = {} - for agent in reviewers: - unmet = sorted(_critique_targets(st, round_no, agent) - covered[agent]) - if unmet: - missing[agent] = unmet + missing = _missing_critique_targets(st, round_no, reviewers, covered) if missing: _handle_incomplete_critiques(pr, st, round_no, missing) return @@ -3379,16 +3628,47 @@ def cmd_collect_critiques(args: argparse.Namespace) -> None: _save(pr, st) +def _round_finding_index( + st: dict[str, Any], round_no: int +) -> dict[Any, dict[str, Any]]: + """そのラウンドの指摘を `finding_id` で引ける索引にする。""" + return { + f.get("finding_id"): f + for f in st.get("review_findings") or [] + if f.get("round") == round_no + } + + +def _missing_critique_targets( + st: dict[str, Any], + round_no: int, + reviewers: list[str], + covered: dict[str, set[str]], +) -> dict[str, list[str]]: + """反証が揃っていない対象を、担当ごとに `finding_id` の並びで返す。""" + missing: dict[str, list[str]] = {} + for agent in reviewers: + unmet = sorted(_critique_targets(st, round_no, agent) - covered[agent]) + if unmet: + missing[agent] = unmet + return missing + + def _handle_incomplete_critiques( pr: int, st: dict[str, Any], round_no: int, missing: dict[str, list[str]] ) -> None: """有効な反証が揃わなかったラウンドの扱い(#549 レビュー対応)。 - **印は付けない。** 印の無いラウンドは従来どおり全件を数えるため、未検証の `major` - が区分の絞り込みで落ちて収束することがない。**取り直しは同じラウンドで 1 度だけ + **印は付けず、先に付いていた印は外す**(#732)。印の無いラウンドは従来どおり全件を + 数えるため、反証が届いていない `major` が区分の絞り込みで落ちて収束することがない。 + 取り直しの後もそのラウンドに印が残ると、「印を付けないため、このラウンドは全件を + 数えます」の出力と実際の数え方が食い違う。**取り直しは同じラウンドで 1 度だけ である**(`judge` の結果なしと同じ作法。2 度続けて揃わないのは対象ではなく実行 環境の側の事象であり、そのときも印を付けないまま工程を進める)。 """ + st["evidence_rounds"] = [ + r for r in st.get("evidence_rounds") or [] + if not _same_round_no(r, round_no)] entry = next( (r for r in st.get("rounds") or [] if r.get("round") == round_no), None) relaunched = list((entry or {}).get("critique_relaunched") or []) @@ -3408,6 +3688,15 @@ def _handle_incomplete_critiques( sys.exit(7) +def _same_round_no(value: Any, round_no: int) -> bool: + """印の番号がそのラウンドを指すか。**番号の読み方は `_evidence_completed` と同じ** + (`int` へ換算して比べ、旧い状態ファイルの文字列の番号も同じラウンドとして読む)。""" + try: + return int(value) == int(round_no) + except (TypeError, ValueError): + return False + + # 実行の結果の強さ。**組から選び直すときの順である。** _VERIFY_RANK = {"reproduced": 2, "not_reproduced": 1, "not_run": 0} @@ -3426,11 +3715,6 @@ def _declared_duplicate_targets(finding: dict[str, Any]) -> set: return targets -# 収束の判定が数える区分(#156)。**残る 3 つは数えない。** 棄却した指摘を数えると、 -# そのぶんラウンドが増える(#69 で同じ論点が 5 ラウンド続いた事象)。 -COUNTED_CLASSIFICATIONS = ("verified_blocking", "needs_human_judgment") - - def _verdicts(finding: dict[str, Any], verdict: str) -> list[str]: """その値を返した担当の一覧。""" return [ @@ -3441,11 +3725,16 @@ def _verdicts(finding: dict[str, Any], verdict: str) -> list[str]: def _classify_finding(finding: dict[str, Any]) -> str: - """1 件の指摘を 5 つの区分のいずれかへ分ける(#156)。 + """1 件の指摘を 6 つの区分のいずれかへ分ける(#156、#732)。 **上から順に見て、最初に当たった区分を採る。** 実行で再現した指摘を先に採ることで、 「実行の結果を担当の支持より先に見る」を順序そのもので表す。順 3 を先に置くと、 機械が再現した事実を担当の再評価が覆す。 + + **数えない側へ落とすのは、棄却(順 3)と `minor` 以下(順 6)だけである。** 誰にも + 誤りを示されていない `major` は、反証の有無・担当の数・根拠の 2 項目の有無によらず + `unrefuted`(順 5)として数える。「立証できない」「範囲外」は誤りだという主張では + ない(#706)。担当 1 者で反証する相手がいない指摘も同じである(#624)。 """ result = _verify_result(finding) major = _SEVERITY_RANK.get(str(finding.get("severity")), -1) >= _SEVERITY_RANK["major"] @@ -3456,18 +3745,32 @@ def _classify_finding(finding: dict[str, Any]) -> str: if result == "not_reproduced" or _verdicts(finding, "refute"): return "rejected" # **`minor` 以下は数えない。** 支持が 1 件付いただけでラウンドが増えるのを避ける。 - if finding.get("has_evidence") and major and ( - _verdicts(finding, "support") - or len(finding.get("origin_runtimes") or []) >= 2 - ): + if not major: + return "insufficient_evidence" + # **根拠の 2 項目は見ない。** 別の担当が支持した、または 2 者が独立に出した時点で + # 「確かめる」目的は果たされている(#706 で支持つきの 2 件が根拠の欠けで落ちた)。 + if _verdicts(finding, "support") or len(finding.get("origin_runtimes") or []) >= 2: return "needs_human_judgment" - return "insufficient_evidence" + return "unrefuted" + + +def _unrefuted_reason(finding: dict[str, Any]) -> str: + """なぜ独立に確かめられていないか。**反証の記録は提案者以外の値だけを持つ。** + + 空は「反証を返した担当が 0 者」を表す(`no_critique`)。1 件以上あれば、反証は + あるが支持も否定も無い(`not_supported`)。 + """ + return "not_supported" if finding.get("critiques") else "no_critique" def _apply_classification(finding: dict[str, Any]) -> str: - """区分を決めて要素へ書く。**棄却したものには理由を残す。**""" + """区分を決めて要素へ書く。**棄却と未反証には理由を残し、他の区分では消す。**""" classification = _classify_finding(finding) finding["classification"] = classification + if classification == "unrefuted": + finding["unrefuted_reason"] = _unrefuted_reason(finding) + else: + finding.pop("unrefuted_reason", None) if classification != "rejected": finding.pop("rejection_reason", None) return classification @@ -3492,9 +3795,9 @@ def _apply_classification(finding: dict[str, Any]) -> str: def _counted_finding_ids(st: dict[str, Any], round_no: int) -> list[str]: """新規性が数える指摘の `finding_id`(#156)。 - **数えるのは `verified_blocking` と `needs_human_judgment` だけである。** - 棄却した指摘を数えると、そのぶんラウンドが増える(#69 で同じ論点が 5 ラウンド - 続いた事象)。どちらも `major` 以上で、修正の工程へ渡る。 + **数えるのは `verified_blocking` と `needs_human_judgment` と `unrefuted` の 3 つ + である。** 棄却した指摘を数えると、そのぶんラウンドが増える(#69 で同じ論点が + 5 ラウンド続いた事象)。いずれも `major` 以上で、修正の工程へ渡る。 """ ids: list[str] = [] for finding in st.get("review_findings") or []: @@ -3636,38 +3939,64 @@ def _finding_keys( keys: list[tuple[str, int, str]] = [] for agent in _round_reviewers(st, round_no): p = _payload_path(agent, pr, round_no) - if not p.exists(): - continue - try: - payload = json.loads(p.read_text(encoding="utf-8")) - except json.JSONDecodeError: + payload = _read_finding_payload(agent, p) + if payload is None: continue - # gemini round 4 指摘: payload は本来 dict (comments: [...]) だが、 - # launcher のバグで list / str が入り込むと `payload.get(...)` で - # AttributeError になる。不正な review payload はバグなので - # 即時 die(code=3) で停止させる。 - if not isinstance(payload, dict): + keys.extend(_comment_keys(agent, p, payload)) + return keys + + +def _read_finding_payload(agent: str, p: pathlib.Path) -> dict[str, Any] | None: + """判定の直前に読む payload.json を dict として返す(第 1 段: 入力境界)。 + + 無い・JSON として読めないときは None を返して読み飛ばす。dict でないときは + launcher のバグとして `die(code=3)` で止める(`_load_payload` と違い、ここで + 止めても失われる記録が無い)。 + """ + if not p.exists(): + return None + try: + payload = json.loads(p.read_text(encoding="utf-8")) + except json.JSONDecodeError: + return None + # gemini round 4 指摘: payload は本来 dict (comments: [...]) だが、 + # launcher のバグで list / str が入り込むと `payload.get(...)` で + # AttributeError になる。不正な review payload はバグなので + # 即時 die(code=3) で停止させる。 + if not isinstance(payload, dict): + die( + f"{agent}: payload.json が dict ではない " + f"({p}, type={type(payload).__name__})。" + " review launcher の出力形式不正。", + code=3, + ) + return payload + + +def _comment_keys( + agent: str, p: pathlib.Path, payload: dict[str, Any] +) -> list[tuple[str, int, str]]: + """`comments[]` を (ファイル, 行, 正規化した本文) の 3 つ組へ変換する(第 2 段)。 + + 要素が dict でなければ `die(code=3)`。位置(path / line)が欠ける要素と、行が + 整数に読めない要素は読み飛ばす。 + """ + keys: list[tuple[str, int, str]] = [] + for c in payload.get("comments", []): + if not isinstance(c, dict): + # comments エントリが dict でない場合も同様に致命扱い die( - f"{agent}: payload.json が dict ではない " - f"({p}, type={type(payload).__name__})。" - " review launcher の出力形式不正。", + f"{agent}: payload.comments のエントリが dict ではない " + f"({p}, type={type(c).__name__})。", code=3, ) - for c in payload.get("comments", []): - if not isinstance(c, dict): - # comments エントリが dict でない場合も同様に致命扱い - die( - f"{agent}: payload.comments のエントリが dict ではない " - f"({p}, type={type(c).__name__})。", - code=3, - ) - path = c.get("path") - line = c.get("line") or c.get("start_line") - if path and line is not None: - try: - keys.append((str(path), int(line), _normalized_body(c.get("body")))) - except (TypeError, ValueError): - continue + path = c.get("path") + line = c.get("line") or c.get("start_line") + if path and line is not None: + try: + keys.append((str(path), int(line), _normalized_body(c.get("body")))) + except (TypeError, ValueError): + continue return keys @@ -3742,7 +4071,7 @@ def _new_finding_count(st: dict[str, Any], pr: int) -> tuple[int, bool]: # 「測れなかった」と扱うと、元の REQUEST_CHANGES のまま終わらない。 if not curr: return 0, False - # **証拠集約を通ったラウンドだけを、数える 2 つへ絞る**(#156)。通っていない + # **証拠集約を通ったラウンドだけを、数える 3 つへ絞る**(#156、#732)。通っていない # ラウンドは従来どおり全件を数える(旧い状態ファイルと、3 本目より前に開いた # ラウンドがこれに当たる)。**`review_findings` の有無では判定しない** # (旧版でも取り込みの時点で積まれるため、区分も検証結果も持たない旧いラウンドが @@ -3847,6 +4176,12 @@ def _count(v: Any) -> int: return 0 +# 読んだ修正の結果ファイルの場所。投稿の組み立ては本文を引数に取らず、ファイルの +# パスを受け取る(#730 の決定 3)。記録へは写らない(`_normalize_fix_result` は +# 決まった鍵だけを読む)。 +FIX_SOURCE_KEY = "_source_path" + + def _read_fix_result( pr: int | str, explicit_file: str | pathlib.Path | None, @@ -3871,7 +4206,9 @@ def _read_fix_result( ] if explicit is not None: - return _read_explicit_fix_result(explicit) + fix = _read_explicit_fix_result(explicit) + fix.setdefault(FIX_SOURCE_KEY, str(explicit)) + return fix fix = _find_fallback_fix_result(fallback_candidates, pr, round_started_ts) @@ -3923,6 +4260,7 @@ def _find_fallback_fix_result( candidate, pr, round_started_ts, is_canonical=is_canonical ) if is_fresh: + parsed.setdefault(FIX_SOURCE_KEY, str(candidate)) return parsed return None @@ -3951,31 +4289,39 @@ def _merge_fix_records(st: dict, fix: dict, pr: int) -> dict: return st["rounds"][-1]["fix"] -def _normalize_fix_result(fix: dict) -> dict[str, Any]: - """fix の戻り値から別名と劣化表現を吸収し、記録へ写す値にそろえる。""" - # key 名 fallback (サブエージェントが別名で書いた場合の救済)。 - # 正規は fix_commit / fixed_count、別名は commit_sha / fixed のみ受理する。 +def _resolve_fix_aliases(fix: dict) -> tuple[object, object]: + """fix の commit と fixed 件数を正規 key と別名から解決する。""" fix_commit = fix.get("fix_commit") or fix.get("commit_sha") fixed_count = fix.get("fixed_count") if fixed_count is None: fixed_count = fix.get("fixed", 0) + return fix_commit, fixed_count + + +def _normalize_deferred_like(raw: object) -> tuple[list[dict], int]: + """deferred の項目と、劣化表現を考慮した保存件数を返す。""" + items = _normalize_dict_items(raw) + if isinstance(raw, (list, dict)): + return items, len(items) + return items, _count(raw) + + +def _normalize_fix_result(fix: dict) -> dict[str, Any]: + """fix の戻り値から別名と劣化表現を吸収し、記録へ写す値にそろえる。""" + # key 名 fallback (サブエージェントが別名で書いた場合の救済)。 + # 正規は fix_commit / fixed_count、別名は commit_sha / fixed のみ受理する。 + fix_commit, fixed_count = _resolve_fix_aliases(fix) # deferred は list が正だが、LLM がスキーマを無視して文字列リスト # (例: ["nit: ..."]) や単一 dict、int(件数) を返すケースがある。後段の # deferred_nits 展開ループは dict 以外をスキップするため、まず dict 要素のみへ # 正規化する (単一 dict は 1 件として包む)。 - _deferred_raw = fix.get("deferred") - _deferred_nits = _normalize_dict_items(_deferred_raw) - # 保存件数の単一整合ルール: # - 構造化データ (list / dict) は per-item を保持できるので、展開件数 # (len(_deferred_nits)) を保存し deferred_nits の件数と一致させる。 # - int / 数値文字列は per-item データを失った「劣化表現」なので、件数を # 失わないよう _count() の値を保存する (展開はできないので nits は空)。 - if isinstance(_deferred_raw, (list, dict)): - _deferred_count = len(_deferred_nits) - else: - _deferred_count = _count(_deferred_raw) + _deferred_nits, _deferred_count = _normalize_deferred_like(fix.get("deferred")) # 却下も同じ正規化を通す。**件数だけが返る劣化表現(int)では per-item を作れない** # ため、そのときは記録を空にし、件数は `_count()` の値で残す。 @@ -4060,9 +4406,31 @@ def cmd_merge_fix(args: argparse.Namespace) -> None: fix = _read_fix_result(pr, args.file, round_started_ts) + # **送信と投稿は取り込む側が行う**(#730)。修正の担当はコミットまでで止まる。 + # 送れない・報告されたコミットが送り先に載っていないときは、記録も投稿もせずに + # 止まる。同じ取り込みをやり直せば、同じ手順を最初から通る。 + commit = fix.get("fix_commit") or fix.get("commit_sha") + pushed = result_posts.push_fix(str(st.get("worktree_path") or ""), + str(st.get("head_branch") or ""), commit) + if not pushed.ok: + die(f"修正を送れないか、報告されたコミットが送り先に載っていません: {pushed.detail}") + print(f"PUSHED={1 if pushed.pushed else 0} COMMIT_ON_HEAD={1 if pushed.contains else 0}") + round_fix = _merge_fix_records(st, fix, pr) _save(pr, st) + posted = result_posts.post_fix( + _queue(pr), fix[FIX_SOURCE_KEY], str(st.get("repo") or ""), + int(st.get("current_pr") or pr), round_no=st["rounds"][-1].get("round"), + actor=str(st.get("viewer_login") or "") or None) + st["rounds"][-1]["fix"]["summary_comment_url"] = posted.summary_url + _save(pr, st) + if posted.summary_url: + print(f"POSTED summary_url={posted.summary_url}") + print(f"REPLIED={posted.replied} RESOLVED={posted.resolved} QUEUED={posted.queued}") + if posted.failed: + die(f"返信・決着・まとめを投稿できませんでした ({posted.detail})") + # CI 分類 if (fix.get("ci_status") or "").upper() != "FAILURE": info(f"✅ fix マージ完了 (commit={round_fix['commit']} fixed={round_fix['fixed']})") @@ -4244,7 +4612,10 @@ def _print_round_summary(rounds: list) -> None: parts = [] for name in reviewers: entry = r.get(name) or {} - if entry: + if entry.get("intent") == NO_RESULT: + # 結果なしは投稿の数を持たない。括弧には理由を出す(#729 の AC17) + parts.append(f"{name}=NO_RESULT({entry.get('no_result_reason', '-')})") + elif entry: parts.append(f"{name}={entry.get('intent', '-')} ({entry.get('comments', '-')})") else: parts.append(f"{name}=-") @@ -4258,6 +4629,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 +4748,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) @@ -4334,26 +4760,27 @@ def cmd_report(args: argparse.Namespace) -> None: # ---------------- main ---------------- -def build_parser() -> argparse.ArgumentParser: - """副コマンドの引数を組み立てる。**テストが選択肢を検査できるように分ける。** +_Subparsers = argparse._SubParsersAction - 実機で `kiro` の結果が `invalid choice` で弾かれた。担当が 4 つの名前を取りうる - 以上、副コマンドの引数も同じ母集合を持たなければ、結果を残した担当が「結果なし」 - として扱われる。 - """ - # 副コマンドの説明はここ(`help`)だけが持つ。**モジュールの docstring へ写さない。** - # 2 か所へ書くと片方だけが実装から離れる。振動の検知の基準は実装が 3 つの一致へ - # 変わった後も、docstring 側が古い基準を出し続けていた(#329)。 - p = argparse.ArgumentParser(description=__doc__) - sub = p.add_subparsers(dest="cmd", required=True) +def _add_init_parser(sub: _Subparsers) -> None: sp = sub.add_parser("init", help="Step 0 — state 初期化 or 再開") sp.add_argument("pr", type=int) - sp.add_argument("--max-rounds", type=int, default=12) - 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", type=_runtime_or_none, default=None, + help="1 者だけで回す。席の埋め合わせを行わない。none で指定を外す") sp.add_argument( - "--only", choices=list(assignment.ALL_RUNTIMES), default=None, - help="片方だけで回す(デバッグ用)") + "--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。省略時は環境変数から推定する") @@ -4377,6 +4804,8 @@ def build_parser() -> argparse.ArgumentParser: ) sp.set_defaults(func=cmd_init) + +def _add_start_round_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "start-round", help="Step 1 — round 開始判定 (1=上限到達/5=後始末の未了/8=同期できない)", @@ -4384,12 +4813,16 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_start_round) + +def _add_read_result_parser(sub: _Subparsers) -> None: sp = sub.add_parser("read-result", help="Step 2.4 — review result を state にマージ") sp.add_argument("pr", type=int) - sp.add_argument("agent", choices=list(assignment.ALL_RUNTIMES)) + sp.add_argument("agent", type=_seat_arg) sp.add_argument("--file", default=None) sp.set_defaults(func=cmd_read_result) + +def _add_unresolved_threads_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "unresolved-threads", help="PR 上の未解決の指摘を数える (0=数えられた/1=取得できなかった)", @@ -4397,10 +4830,14 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_unresolved_threads) + +def _add_flush_parser(sub: _Subparsers) -> None: sp = sub.add_parser("flush", help="待ち行列に積んだ投稿を流す (常に 0)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_flush) + +def _add_judge_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "judge", help="Step 3 — intent ベース pass 判定 " @@ -4409,6 +4846,8 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_judge) + +def _add_check_oscillation_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "check-oscillation", help="Step 4 — 同じ箇所を指す指摘の割合を計算 (2=続行/4=振動で中断)", @@ -4416,27 +4855,37 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_check_oscillation) + +def _add_verify_findings_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "verify-findings", help="Step 2.5 前段 — 重複の統合(1 段目)と実行検証(#156)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_verify_findings) + +def _add_collect_critiques_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "collect-critiques", help="Step 2.5 後段 — 反証の結果を指摘へ結び、申告の重複を束ねる(#156)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_collect_critiques) + +def _add_merge_fix_parser(sub: _Subparsers) -> None: sp = sub.add_parser("merge-fix", help="Step 5 post — fix 戻り値マージ + CI 分類") sp.add_argument("pr", type=int) sp.add_argument("--file", default=None) sp.set_defaults(func=cmd_merge_fix) + +def _add_should_rotate_parser(sub: _Subparsers) -> None: sp = sub.add_parser("should-rotate", help="Step 6 — rotate 要否 (0=rotate/2=keep)") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_should_rotate) + +def _add_set_current_pr_parser(sub: _Subparsers) -> None: sp = sub.add_parser("set-current-pr", help="rotation 後の current_pr 更新") sp.add_argument( "--head-branch", @@ -4447,6 +4896,8 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("new_pr", type=int) sp.set_defaults(func=cmd_set_current_pr) + +def _add_verify_sweep_parser(sub: _Subparsers) -> None: sp = sub.add_parser( "verify-sweep", help="Step 7.5 後段 — 最終スイープ後の未解決の指摘を検証 (0=残なし/6=残あり)", @@ -4455,9 +4906,46 @@ def build_parser() -> argparse.ArgumentParser: sp.add_argument("--file", default=None) sp.set_defaults(func=cmd_verify_sweep) + +def _add_report_parser(sub: _Subparsers) -> None: sp = sub.add_parser("report", help="Step 8 — deferred nit + サマリ表示") sp.add_argument("pr", type=int) sp.set_defaults(func=cmd_report) + + +# 副コマンドの登録関数。**`--help` の一覧はこの順で出る**ため、並びを変えない。 +_SUBCOMMAND_REGISTRARS = ( + _add_init_parser, + _add_start_round_parser, + _add_read_result_parser, + _add_unresolved_threads_parser, + _add_flush_parser, + _add_judge_parser, + _add_check_oscillation_parser, + _add_verify_findings_parser, + _add_collect_critiques_parser, + _add_merge_fix_parser, + _add_should_rotate_parser, + _add_set_current_pr_parser, + _add_verify_sweep_parser, + _add_report_parser, +) + + +def build_parser() -> argparse.ArgumentParser: + """副コマンドの引数を組み立てる。**テストが選択肢を検査できるように分ける。** + + 実機で `kiro` の結果が `invalid choice` で弾かれた。担当が 4 つの名前を取りうる + 以上、副コマンドの引数も同じ母集合を持たなければ、結果を残した担当が「結果なし」 + として扱われる。 + """ + # 副コマンドの説明は各登録関数の `help` だけが持つ。**モジュールの docstring へ写さない。** + # 2 か所へ書くと片方だけが実装から離れる。振動の検知の基準は実装が 3 つの一致へ + # 変わった後も、docstring 側が古い基準を出し続けていた(#329)。 + p = argparse.ArgumentParser(description=__doc__) + sub = p.add_subparsers(dest="cmd", required=True) + for register in _SUBCOMMAND_REGISTRARS: + register(sub) return p diff --git a/plugins/ndf/skills/cross-review/scripts/wait-review.sh b/plugins/ndf/skills/cross-review/scripts/wait-review.sh index 9221e7d2..7b04bece 100755 --- a/plugins/ndf/skills/cross-review/scripts/wait-review.sh +++ b/plugins/ndf/skills/cross-review/scripts/wait-review.sh @@ -1,11 +1,14 @@ #!/usr/bin/env bash -# Wait for codex / agy review processes — monitor.py の薄いラッパ。 +# レビューの席の完了待ち — monitor.py の薄いラッパ。 # -# Usage: wait-review.sh [codex|agy|both] [--timeout SEC] [--stall-timeout SEC] +# Usage: wait-review.sh [<席の名前>|both] [--timeout SEC] [--stall-timeout SEC] +# +# 席の名前 claude | codex | agy | kiro(同じランタイムの 2 つ目は `-2`〜`-9` を付ける) +# both これまでの 2 者(codex / agy)を指す省略形 # # 既定値(上限の表 `scripts/lib/limits.py` が持つ。#598 / #537): # timeout 1200s (= 20 min、工程 review) env MONITOR_TIMEOUT_ / MONITOR_TIMEOUT で上書き -# stall-timeout 担当別 codex 180s / agy 480s / kiro 480s / claude 900s +# stall-timeout 席のランタイム別 codex 180s / agy 480s / kiro 480s / claude 900s # env MONITOR_STALL_ / MONITOR_STALL で上書き # poll 15s env MONITOR_POLL で上書き # diff --git a/plugins/ndf/skills/cross-review/tests/conftest.py b/plugins/ndf/skills/cross-review/tests/conftest.py index e9bcbc23..94bb32c3 100644 --- a/plugins/ndf/skills/cross-review/tests/conftest.py +++ b/plugins/ndf/skills/cross-review/tests/conftest.py @@ -89,7 +89,7 @@ def _default_host(monkeypatch) -> None: @pytest.fixture(autouse=True) -def _no_github(monkeypatch, state_mod) -> None: +def _no_github(monkeypatch) -> None: """テストから GitHub を呼ばない。 収束の判定は継続的統合を照会するようになった(#327)。差し替えを忘れると、 @@ -97,6 +97,10 @@ def _no_github(monkeypatch, state_mod) -> None: **差し替えていない `gh` の実行はその場で落とす。** `subprocess.run` そのものを差し替えるテストは、この見張りを上書きして先へ進む。 + + **state.py の内部関数の差し替えは持たない。** それは `state_mod` を利用する + テストだけが必要とする(`_no_github_state`)。ここに混ぜると、monitor.py や + measure.py だけを検査するテストまで state.py を読み込む。 """ real = subprocess.run @@ -109,10 +113,50 @@ def _guard(cmd, *args, **kwargs): return real(cmd, *args, **kwargs) monkeypatch.setattr(subprocess, "run", _guard) - # 照会は既定で「確かめられなかった」に倒す。判定は収束を止めない側へ倒すため、 - # 検査ジョブを見ない既存のテストは期待値を変えずに通る。 + + +@pytest.fixture(autouse=True) +def _no_github_state(request, monkeypatch) -> None: + """state.py の GitHub 照会を既定で「確かめられなかった」に倒す。 + + **`state_mod` を要求するテストだけへ適用する。** monitor.py や measure.py だけを + 検査するテストは `state_mod` を要求しないため、この差し替えを通らず state.py を + 読み込まない。要求するテストでは従来どおり実 GitHub 呼び出しを防ぐ。 + + 判定は収束を止めない側へ倒すため、検査ジョブを見ない既存のテストは期待値を + 変えずに通る。 + """ + if "state_mod" not in request.fixturenames: + return + state_mod = request.getfixturevalue("state_mod") monkeypatch.setattr(state_mod, "_fetch_check_runs", lambda repo, sha: None) monkeypatch.setattr(state_mod, "_fetch_pr_metadata", lambda pr, repo=None: None) + # **取り込みはレビューを投稿する**(#730)。投稿を見ないテストでは、組み立てまでを + # 本物で通し、送信だけを「届いた」に置き換える。偽の `gh` を要求するテストは + # 送信も含めて検査するため置き換えない。 + if "fake_gh" not in request.fixturenames: + rp = state_mod.result_posts + monkeypatch.setattr(rp, "post_review", _post_review_offline(rp)) + monkeypatch.setattr(rp, "push_fix", + lambda worktree, head, commit: rp.PushResult( + True, bool(commit), True, "")) + monkeypatch.setattr(rp, "post_fix", _post_fix_offline(rp)) + + +def _post_review_offline(rp): + """送信を行わず、組み立てた内容がそのまま届いたものとして結果を返す。""" + def _post(queue, payload_path, result_path, repo, pr, round_no, seat, head_sha, + is_own_pr, actor=None, since=None): + item = rp.review_posts(payload_path, result_path, repo, pr, round_no, seat, + head_sha, is_own_pr, since=since)[0] + extra = item["extra"] + findings = len(rp._findings(rp._read_json(payload_path))) + return rp.ReviewOutcome( + review_url=f"https://github.com/{repo}/pull/{pr}#pullrequestreview-1", + posted_inline=extra["inline"], posted_body=extra["body"], queued=0, + findings=findings, failed=False, posted_as=extra["posted_as"], + intent=extra["intent"], detail="") + return _post @pytest.fixture() @@ -249,3 +293,14 @@ def fake_gh(monkeypatch, tmp_path) -> FakeGh: def queue_mod() -> types.ModuleType: """共通層の待ち行列モジュール(#291)。""" return _load_module("ndf_post_queue", _POST_QUEUE) + + +def _post_fix_offline(rp): + """送信を行わず、組み立てた返信・決着・まとめがすべて届いたものとして返す。""" + def _post(queue, result_path, repo, pr, round_no=None, actor=None): + kinds = [i["kind"] for i in rp.fix_posts(result_path, repo, pr, round_no)] + return rp.FixOutcome( + summary_url=f"https://github.com/{repo}/pull/{pr}#issuecomment-1", + replied=kinds.count("review-reply"), resolved=kinds.count("thread-resolve"), + queued=0, failed=False, detail="") + return _post diff --git a/plugins/ndf/skills/cross-review/tests/test_classify_findings.py b/plugins/ndf/skills/cross-review/tests/test_classify_findings.py index 394b1f08..8207c248 100644 --- a/plugins/ndf/skills/cross-review/tests/test_classify_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_classify_findings.py @@ -100,16 +100,42 @@ def test_a_supported_minor_is_not_judged(state_mod): )) == "insufficient_evidence" -def test_support_without_evidence_is_insufficient(state_mod): +def test_support_without_evidence_needs_judgment(state_mod): + """**支持が付いた `major` は、根拠の 2 項目を欠いても人の判断待ちである**(#706。実測 F)。 + + 別の担当が支持を返した時点で「確かめる」目的は果たされている。根拠の欠けで落とすと、 + 2 人が同じことを言っている情報が判定に効かない。 + """ assert classify(state_mod, _finding( has_evidence=False, critiques=[_critique("kiro", "support")], - )) == "insufficient_evidence" + )) == "needs_human_judgment" + + +def test_two_proposers_are_enough_without_evidence(state_mod): + """2 者が独立に出した `major` は、根拠の 2 項目を欠いても人の判断待ちである(実測 H)。""" + assert classify(state_mod, _finding( + has_evidence=False, origin_runtimes=["codex", "kiro"], + )) == "needs_human_judgment" + + +# ---------- 順 5: 未反証(#732 #624 #706) ---------- +def test_a_major_nothing_matched_is_unrefuted(state_mod): + """誰にも誤りを示されていない `major` は数える側へ入る(実測 B)。""" + assert classify(state_mod, _finding()) == "unrefuted" -# ---------- 順 5 ---------- -def test_nothing_matched_is_insufficient(state_mod): - assert classify(state_mod, _finding()) == "insufficient_evidence" +def test_a_lone_major_with_evidence_is_unrefuted(state_mod): + """担当 1 者で反証する相手がいない `major` は未反証である(#624。実測 A)。""" + assert classify(state_mod, _finding(has_evidence=True)) == "unrefuted" + + +@pytest.mark.parametrize("verdict", ["insufficient_evidence", "out_of_scope"]) +def test_a_major_the_other_could_not_verify_is_unrefuted(state_mod, verdict): + """**「立証できない」「範囲外」は誤りだという主張ではない**(#706。実測 D・E)。""" + assert classify(state_mod, _finding( + has_evidence=True, critiques=[_critique("kiro", verdict)], + )) == "unrefuted" def test_not_run_is_not_the_same_as_not_reproduced(state_mod): @@ -123,7 +149,56 @@ def test_not_run_is_not_the_same_as_not_reproduced(state_mod): def test_a_finding_without_verification_is_readable(state_mod): f = _finding() del f["verification"] - assert classify(state_mod, f) == "insufficient_evidence" + assert classify(state_mod, f) == "unrefuted" + + +# ---------- 順 6: 立証不足(軽微な指摘の残余) ---------- + +def test_a_lone_minor_with_evidence_is_insufficient(state_mod): + """**`minor` 以下は反証の有無によらず数えない**(実測 C)。""" + assert classify(state_mod, _finding( + severity="minor", has_evidence=True)) == "insufficient_evidence" + + +# ---------- 未反証の理由 ---------- + +def test_an_unrefuted_finding_without_critiques_says_no_critique(state_mod): + f = _finding(has_evidence=True) + state_mod._apply_classification(f) + assert f["classification"] == "unrefuted" + assert f["unrefuted_reason"] == "no_critique" + + +def test_an_unrefuted_finding_with_critiques_says_not_supported(state_mod): + f = _finding(has_evidence=True, critiques=[_critique("kiro", "insufficient_evidence")]) + state_mod._apply_classification(f) + assert f["classification"] == "unrefuted" + assert f["unrefuted_reason"] == "not_supported" + + +def test_the_unrefuted_reason_is_dropped_when_the_classification_changes(state_mod): + """**理由は区分が未反証のときだけ存在する**(棄却の理由と同じ扱い)。""" + f = _finding(has_evidence=True) + state_mod._apply_classification(f) + assert f["unrefuted_reason"] == "no_critique" + + f["critiques"] = [_critique("kiro", "refute")] + state_mod._apply_classification(f) + + assert f["classification"] == "rejected" + assert "unrefuted_reason" not in f + assert "rejection_reason" in f + + +def test_other_classifications_carry_no_unrefuted_reason(state_mod): + for f in ( + _finding(verification=_verified("reproduced")), + _finding(has_evidence=True, critiques=[_critique("kiro", "support")]), + _finding(severity="minor"), + ): + state_mod._apply_classification(f) + assert f["classification"] != "unrefuted" + assert "unrefuted_reason" not in f # ---------- 棄却の理由 ---------- @@ -164,8 +239,11 @@ def counted(state_mod, findings, round_no=1): return state_mod._counted_finding_ids(st, round_no) -def test_only_two_classifications_are_counted(state_mod): - """**`rejected` と `insufficient_evidence` は数えない。**""" +def test_only_three_classifications_are_counted(state_mod): + """**数えないのは棄却(`rejected`)と軽微な指摘だけである**(#732)。 + + 誰にも誤りを示されていない `major`(`d`。未反証)は数える。 + """ out = counted(state_mod, [ _finding(finding_id="a", verification=_verified("reproduced")), _finding(finding_id="b", has_evidence=True, @@ -174,8 +252,9 @@ def test_only_two_classifications_are_counted(state_mod): _finding(finding_id="d"), _finding(finding_id="e", severity="minor", verification=_verified("reproduced")), + _finding(finding_id="f", severity="minor"), ]) - assert sorted(out) == ["a", "b"] + assert sorted(out) == ["a", "b", "d"] def test_a_merged_side_is_not_counted(state_mod): @@ -321,3 +400,102 @@ def test_measurability_is_decided_before_narrowing(state_mod, tmp_path, monkeypa assert measurable is True # 読めている assert count == 0 # 数える区分が 0 件 + + +# ---------- 担当 1 者・起動し直した担当の指摘を数える(#732 #624 #706) ---------- + +def _judge_rc(state_mod, pr): + import argparse + with pytest.raises(SystemExit) as e: + state_mod.cmd_judge(argparse.Namespace(pr=pr)) + return e.value.code + + +def _single_reviewer_state(tmp_path, finding, intent="REQUEST_CHANGES"): + """担当 1 者(`only: "codex"`)で印の付いたラウンドが 1 つある状態ファイル。 + + 担当 1 者は `only` で表す(`_round_reviewers` が最初に読む値)。判定が読めるよう、 + 状態ファイルと担当の payload を `CROSS_REVIEW_TMP_DIR` へ書く。 + """ + import json + st = { + "current_pr": 1, "repo": "o/r", "max_rounds": 12, "rotate_after": 8, + "only": "codex", "host": "claude", + "rounds": [{"round": 1, "pr": 1, "started_at": "2026-09-19T00:00:00+00:00", + "codex": {"intent": intent, + "by_severity": {finding["severity"]: 1}}}], + "evidence_rounds": [1], + "review_findings": [finding], + "deferred_nits": [], "carried_over": None, "final": None, + } + (tmp_path / "cross-review-pr1-state.json").write_text(json.dumps(st)) + _payload(tmp_path, "codex", 1, 1, [ + {"path": finding["path"], "line": finding["line"], "body": finding["body"], + "severity": finding["severity"]}]) + return st + + +def test_a_single_reviewer_unrefuted_major_is_counted(state_mod, tmp_path, monkeypatch): + """AC1: 反証する相手のいない `major` を数え、収束させない(#624 の再現)。 + + 変更前は `(0, True)` で新規 0 件となり、`REQUEST_CHANGES` のまま終了コード 0 + (承認)で終わっていた。 + """ + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + st = _single_reviewer_state(tmp_path, _finding(has_evidence=True)) + + assert state_mod._new_finding_count(st, 1) == (1, True) + assert st["review_findings"][0]["classification"] == "unrefuted" + assert _judge_rc(state_mod, 1) == 2 + + +def test_a_single_reviewer_major_without_evidence_is_still_counted( + state_mod, tmp_path, monkeypatch): + """AC2: 根拠の 2 項目を欠いても数える。`has_evidence` の値は残る。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + st = _single_reviewer_state(tmp_path, _finding(has_evidence=False)) + + assert state_mod._new_finding_count(st, 1) == (1, True) + assert st["review_findings"][0]["classification"] == "unrefuted" + assert st["review_findings"][0]["has_evidence"] is False + assert _judge_rc(state_mod, 1) == 2 + + +def test_a_single_reviewer_minor_is_not_counted(state_mod, tmp_path, monkeypatch): + """AC3: `minor` は担当 1 者でも数えない。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + st = _single_reviewer_state( + tmp_path, _finding(severity="minor", has_evidence=True)) + + assert state_mod._new_finding_count(st, 1) == (0, True) + assert st["review_findings"][0]["classification"] == "insufficient_evidence" + + +def test_a_relaunched_reviewers_major_without_critiques_is_counted( + state_mod, tmp_path, monkeypatch): + """AC4: 反証を取り込んだ後に入った担当の `major` を数える(#583 の収束の部分)。 + + `agy` + `kiro` のラウンドで、`kiro` の指摘には反証(`refute`)が付いて棄却され、 + `agy` の根拠を持つ `major` は反証 0 件のまま入っている。 + """ + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + agy = _finding(finding_id="agy-r1-0", agent="agy", origin_runtimes=["agy"], + path="a.py", line=1, body="x", has_evidence=True) + kiro = _finding(finding_id="kiro-r1-0", agent="kiro", origin_runtimes=["kiro"], + path="b.py", line=2, body="y", has_evidence=True, + critiques=[_critique("agy", "refute")]) + st = { + "current_pr": 1, "repo": "o/r", + "rounds": [{"round": 1, "pr": 1, "reviewers": ["agy", "kiro"]}], + "evidence_rounds": [1], + "review_findings": [agy, kiro], + } + _payload(tmp_path, "agy", 1, 1, [ + {"path": "a.py", "line": 1, "body": "x", "severity": "major"}]) + _payload(tmp_path, "kiro", 1, 1, [ + {"path": "b.py", "line": 2, "body": "y", "severity": "major"}]) + + assert state_mod._new_finding_count(st, 1) == (1, True) + assert agy["classification"] == "unrefuted" + assert agy["unrefuted_reason"] == "no_critique" + assert kiro["classification"] == "rejected" diff --git a/plugins/ndf/skills/cross-review/tests/test_critiques.py b/plugins/ndf/skills/cross-review/tests/test_critiques.py index 80b198f2..7c262dd0 100644 --- a/plugins/ndf/skills/cross-review/tests/test_critiques.py +++ b/plugins/ndf/skills/cross-review/tests/test_critiques.py @@ -8,6 +8,7 @@ import argparse import json import pathlib +import shutil import pytest @@ -351,6 +352,51 @@ def test_the_retry_happens_once_per_round(tmp_dir, state_mod): assert st.get("evidence_rounds", []) == [] +def test_an_incomplete_collection_removes_an_existing_marker(tmp_dir, state_mod): + """**反証が揃わない取り込みは、先に付いていた印を外す**(#732 の AC13)。 + + 印が残ると「印を付けないため、このラウンドは全件を数えます」の出力と実際の数え方が + 食い違い、反証が届いていない `major` が区分の絞り込みへ掛かる。 + """ + _write(tmp_dir, _state([_finding("agy-r1-0", "agy")], evidence_rounds=[1])) + (tmp_dir / f"agy-review-pr{PR}-round1-payload.json").write_text(json.dumps( + {"comments": [{"path": "a.py", "line": 1, "body": "x", + "severity": "major"}]})) + + collect(state_mod, expect_rc=7) + + st = _read(tmp_dir) + assert st["evidence_rounds"] == [] + assert state_mod._evidence_completed(st, 1) is False + count, measurable = state_mod._new_finding_count(st, PR) + assert (count, measurable) == (1, True) # payload の全件を数える + + +def test_a_marker_stays_off_after_the_second_incomplete_collection(tmp_dir, state_mod): + """取り直した後も揃わないとき(2 度目)も印は付かない。""" + _write(tmp_dir, _state([_finding("codex-r1-0", "codex")], evidence_rounds=[1])) + + collect(state_mod, expect_rc=7) + collect(state_mod, expect_rc=0) + + st = _read(tmp_dir) + assert st["evidence_rounds"] == [] + assert sorted(st["rounds"][0]["critique_relaunched"]) == ["agy", "kiro"] + + +def test_an_incomplete_collection_keeps_other_rounds_markers(tmp_dir, state_mod): + """外すのはそのラウンドの番号だけである。前のラウンドの印は残る。""" + _write(tmp_dir, _state( + [_finding("codex-r2-0", "codex", round=2)], + rounds=[{"round": 1, "pr": PR}, {"round": 2, "pr": PR}], + evidence_rounds=[1, 2], + )) + + collect(state_mod, expect_rc=7) + + assert _read(tmp_dir)["evidence_rounds"] == [1] + + def test_a_proposer_only_round_is_marked_without_any_file(tmp_dir, state_mod): """**反証の対象が無い担当は不足に数えない。** 全員が提案者なら印が付く。""" _write(tmp_dir, _state([ @@ -528,6 +574,90 @@ def test_a_stale_pidfile_is_not_read_as_a_launch(tmp_dir, tmp_path): assert elapsed < ROUND_TIME_LIMIT, f"{elapsed:.1f} 秒かかった(監視が待っている)" +def test_a_retry_launches_only_the_agents_requested_by_collect(tmp_path): + """現状固定: 終了コード 7 の再取得では不足した担当だけを起動し直す。""" + script_dir = tmp_path / "scripts" + script_dir.mkdir() + shutil.copy2(SCRIPTS / "critique-round.sh", script_dir / "critique-round.sh") + calls = tmp_path / "critique-calls.txt" + collects = tmp_path / "collect-count.txt" + + (script_dir / "_tmpdir.sh").write_text( + 'tmpdir() { printf "%s\\n" "$CROSS_REVIEW_TMP_DIR"; }\n', encoding="utf-8") + (script_dir / "critique.sh").write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$1" >> "$CRITIQUE_CALLS"\n' + 'touch "$CROSS_REVIEW_TMP_DIR/$1-critique-pr$2.pid"\n', encoding="utf-8") + (script_dir / "monitor.py").write_text( + "#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + (script_dir / "state.py").write_text( + "#!/usr/bin/env bash\n" + 'count=0; [ ! -f "$COLLECT_COUNT" ] || count=$(cat "$COLLECT_COUNT")\n' + 'count=$((count + 1)); printf "%s\\n" "$count" > "$COLLECT_COUNT"\n' + 'if [ "$count" -eq 1 ]; then\n' + " printf \"CRITIQUE_RETRY_AGENTS='kiro'\\n\"\n" + " exit 7\n" + "fi\n" + "exit 0\n", encoding="utf-8") + for name in ("critique.sh", "monitor.py", "state.py"): + (script_dir / name).chmod(0o755) + + env = dict( + os.environ, + CROSS_REVIEW_TMP_DIR=str(tmp_path), + CRITIQUE_CALLS=str(calls), + COLLECT_COUNT=str(collects), + ) + result = subprocess.run( + ["bash", str(script_dir / "critique-round.sh"), str(PR), "1", "agy", "kiro"], + capture_output=True, text=True, env=env, check=False, + ) + + assert result.returncode == 0, result.stderr + assert calls.read_text(encoding="utf-8").splitlines() == ["agy", "kiro", "kiro"] + assert collects.read_text(encoding="utf-8").strip() == "2" + + +def test_collect_failure_propagates_exit_code_without_retry(tmp_path): + """現状固定: collect-critiques が 7 以外(例: 5)を返したとき、終了コードを素通しして直ちに終了する。""" + script_dir = tmp_path / "scripts" + script_dir.mkdir() + shutil.copy2(SCRIPTS / "critique-round.sh", script_dir / "critique-round.sh") + calls = tmp_path / "critique-calls.txt" + collects = tmp_path / "collect-count.txt" + + (script_dir / "_tmpdir.sh").write_text( + 'tmpdir() { printf "%s\\n" "$CROSS_REVIEW_TMP_DIR"; }\n', encoding="utf-8") + (script_dir / "critique.sh").write_text( + "#!/usr/bin/env bash\n" + 'printf "%s\\n" "$1" >> "$CRITIQUE_CALLS"\n' + 'touch "$CROSS_REVIEW_TMP_DIR/$1-critique-pr$2.pid"\n', encoding="utf-8") + (script_dir / "monitor.py").write_text( + "#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") + (script_dir / "state.py").write_text( + "#!/usr/bin/env bash\n" + 'count=0; [ ! -f "$COLLECT_COUNT" ] || count=$(cat "$COLLECT_COUNT")\n' + 'count=$((count + 1)); printf "%s\\n" "$count" > "$COLLECT_COUNT"\n' + "exit 5\n", encoding="utf-8") + for name in ("critique.sh", "monitor.py", "state.py"): + (script_dir / name).chmod(0o755) + + env = dict( + os.environ, + CROSS_REVIEW_TMP_DIR=str(tmp_path), + CRITIQUE_CALLS=str(calls), + COLLECT_COUNT=str(collects), + ) + result = subprocess.run( + ["bash", str(script_dir / "critique-round.sh"), str(PR), "1", "agy", "kiro"], + capture_output=True, text=True, env=env, check=False, + ) + + assert result.returncode == 5 + assert collects.read_text(encoding="utf-8").strip() == "1" + assert calls.read_text(encoding="utf-8").splitlines() == ["agy", "kiro"] + + def test_the_stale_pidfile_is_removed_even_without_targets(tmp_dir, tmp_path): """捨てるのは、対象が無くて起動しない経路より前である。""" work = tmp_path / "work" diff --git a/plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py b/plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py new file mode 100644 index 00000000..771a92e3 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_judge_no_result_reason.py @@ -0,0 +1,278 @@ +"""判定が結果なしの理由を出し、起動し直せない理由があれば止めることのテスト(#729 AC14〜AC17)。 + +| ラウンドの結果なし | 出口 | 終了コード | +| --- | --- | --- | +| 理由がすべて起動し直してよいもの、まだ起動し直していない | `RELAUNCH_AGENTS` を出して起動し直す | 7 | +| 理由がすべて起動し直してよいもの、既に起動し直している | 中断(`final = error`) | 1 | +| 起動し直せない理由(利用上限)を 1 つでも含む | **起動し直さず**中断(`final = error`) | 1 | + +どの出口でも、判定は先に標準出力へ `NO_RESULT_REASONS='<担当>=<理由> ...'` の 1 行を出す。 +起動し直しの可否は結末の共通層(`monitor_outcome.relaunch_same_agent`)だけが決め、 +判定は `usage_limit` という値を知らない(AC12)。報告の表は結果なしの担当を +`<担当>=NO_RESULT(<理由>)` の形で出す(AC17)。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + +PR = 8729 +REPO = "o/r" +DETAIL = "Monthly request limit reached" + + +# ---------------- 足場 ---------------- + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向ける。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def _state(rounds: list[dict], **over) -> dict: + state = { + "current_pr": PR, + "repo": REPO, + "max_rounds": 12, + "rotate_after": 8, + "only": None, + "rounds": rounds, + "pr_history": [{"pr": PR, "rounds": len(rounds)}], + "deferred_nits": [], + "final": None, + } + state.update(over) + return state + + +def _round(no: int = 1, **over) -> dict: + entry: dict = {"round": no, "pr": PR, "started_at": "2026-09-19T00:00:00+00:00"} + entry.update(over) + return entry + + +def _approve() -> dict: + return { + "intent": "APPROVE", + "posted_as": "APPROVE", + "comments": 0, + "by_severity": {"critical": 0, "major": 0}, + } + + +def _no_result(reason: str, detail: str | None = None) -> dict: + """`read-result` が残す結果なしの形(`test_read_result_reason.py` が固定する)。""" + entry: dict = { + "intent": "NO_RESULT", + "no_result_reason": reason, + "posted_as": None, + "comments": None, + "review_url": None, + "by_severity": {}, + } + if detail: + entry["monitor_detail"] = detail + return entry + + +def _write(tmp_dir: pathlib.Path, state: dict) -> None: + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _read(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +def _judge(state_mod) -> int: + with pytest.raises(SystemExit) as e: + state_mod.cmd_judge(argparse.Namespace(pr=PR)) + return int(e.value.code or 0) + + +# ---------------- AC14: 理由の行 ---------------- + + +def test_judge_prints_the_reason_of_each_agent_without_a_result(tmp_dir, state_mod, capsys): + """結果なしの担当があると、標準出力に `NO_RESULT_REASONS='<担当>=<理由> ...'` が出る。""" + _write(tmp_dir, _state([_round(codex=_no_result("timeout"), agy=_no_result("stalled"))])) + + assert _judge(state_mod) == 7 + + out = capsys.readouterr().out + assert "NO_RESULT_REASONS='codex=timeout agy=stalled'" in out + + +def test_an_agent_without_an_entry_is_reported_as_missing(tmp_dir, state_mod, capsys): + """`read-result` すら記録を残していない担当は、理由 `missing` として出る。""" + _write(tmp_dir, _state([_round(codex=_approve())])) + + assert _judge(state_mod) == 7 + + assert "NO_RESULT_REASONS='agy=missing'" in capsys.readouterr().out + + +def test_the_reason_line_comes_before_the_relaunch_line(tmp_dir, state_mod, capsys): + """理由の行は起動し直しの指示より先に出る。骨組みが読む順序を固定する。""" + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("cli_timeout"))])) + + assert _judge(state_mod) == 7 + + out = capsys.readouterr().out + assert out.index("NO_RESULT_REASONS=") < out.index("RELAUNCH_AGENTS=") + + +# ---------------- AC15: 起動し直せない理由があれば止める ---------------- + + +def test_a_usage_limit_stops_the_review_without_a_relaunch(tmp_dir, state_mod, capsys): + """利用上限の担当があれば、起動し直さず `final = error` で 1 で止まる。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy=_no_result("usage_limit", DETAIL)), + ])) + + assert _judge(state_mod) == 1 + + st = _read(tmp_dir) + assert st["final"] == "error" + assert st["ended_at"] + assert st["rounds"][-1]["verdict"] == "no_result" + assert "relaunched" not in st["rounds"][-1] + captured = capsys.readouterr() + assert "NO_RESULT_REASONS='agy=usage_limit'" in captured.out + assert "RELAUNCH_AGENTS" not in captured.out + assert "agy" in captured.err + assert "usage_limit" in captured.err + assert DETAIL in captured.err + + +def test_a_usage_limit_stops_even_when_another_agent_could_be_relaunched( + tmp_dir, state_mod, capsys +): + """起動し直してよい担当が混ざっていても、利用上限が 1 つあれば誰も起動し直さない。""" + _write(tmp_dir, _state([ + _round(codex=_no_result("timeout"), agy=_no_result("usage_limit")), + ])) + + assert _judge(state_mod) == 1 + + st = _read(tmp_dir) + assert st["final"] == "error" + assert "relaunched" not in st["rounds"][-1] + captured = capsys.readouterr() + assert "NO_RESULT_REASONS='codex=timeout agy=usage_limit'" in captured.out + assert "RELAUNCH_AGENTS" not in captured.out + + +def test_a_usage_limit_without_monitor_detail_still_stops(tmp_dir, state_mod, capsys): + """`monitor_detail` の鍵が無くても、担当と理由が標準エラーに出て止まる。""" + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("usage_limit"))])) + + assert _judge(state_mod) == 1 + + err = capsys.readouterr().err + assert "agy" in err + assert "usage_limit" in err + + +def test_the_verdict_reads_relaunchability_from_the_common_layer( + tmp_dir, state_mod, monkeypatch, capsys +): + """可否を決めるのは共通層の集合であり、判定は理由の値を見ない(AC12)。 + + 共通層の「起動し直せない理由」に `timeout` を足すと、判定は `timeout` でも止まる。 + 判定が `usage_limit` を直に比べていれば、この変更は届かず 7 になる。 + """ + monkeypatch.setattr( + state_mod.monitor_outcome, "NO_RELAUNCH_REASONS", frozenset({"timeout"})) + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("timeout"))])) + + assert _judge(state_mod) == 1 + assert _read(tmp_dir)["final"] == "error" + + # 逆に `usage_limit` を外せば、判定は起動し直す + monkeypatch.setattr(state_mod.monitor_outcome, "NO_RELAUNCH_REASONS", frozenset()) + _write(tmp_dir, _state([_round(codex=_approve(), agy=_no_result("usage_limit"))])) + + assert _judge(state_mod) == 7 + assert "RELAUNCH_AGENTS='agy'" in capsys.readouterr().out + + +# ---------------- AC16: 起動し直してよい理由なら従来どおり ---------------- + + +def test_relaunchable_reasons_ask_for_one_relaunch(tmp_dir, state_mod, capsys): + """理由がすべて起動し直してよいものなら、1 度目は 7 で `RELAUNCH_AGENTS` を返す。""" + _write(tmp_dir, _state([ + _round(codex=_no_result("cli_timeout", "print timeout after 60m"), + agy=_no_result("early_error", "fatal: something")), + ])) + + assert _judge(state_mod) == 7 + + out = capsys.readouterr().out + assert "RELAUNCH_AGENTS='codex agy'" in out + assert "RELAUNCH_TARGET=both" in out + st = _read(tmp_dir) + assert st["rounds"][-1]["relaunched"] == ["codex", "agy"] + assert st["final"] is None + + +def test_a_second_no_result_with_relaunchable_reasons_stops(tmp_dir, state_mod, capsys): + """起動し直した後も結果が残らなければ、従来どおり `final = error` で 1 で止まる。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy=_no_result("timeout"), relaunched=["agy"]), + ])) + + assert _judge(state_mod) == 1 + + st = _read(tmp_dir) + assert st["final"] == "error" + assert st["rounds"][-1]["verdict"] == "no_result" + assert "NO_RESULT_REASONS='agy=timeout'" in capsys.readouterr().out + + +def test_rounds_without_a_no_result_do_not_print_the_reason_line(tmp_dir, state_mod, capsys): + """結果なしが無いラウンドでは理由の行が出ず、終了コード 0 の枝は変わらない。""" + _write(tmp_dir, _state([_round(codex=_approve(), agy=_approve())])) + + assert _judge(state_mod) == 0 + + assert "NO_RESULT_REASONS" not in capsys.readouterr().out + assert _read(tmp_dir)["final"] == "approved" + + +# ---------------- AC17: 報告の表 ---------------- + + +def test_the_round_summary_shows_the_reason_of_a_no_result(tmp_dir, state_mod, capsys): + """報告の表で、結果なしの担当は `<担当>=NO_RESULT(<理由>)` の形で出る。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy=_no_result("usage_limit", DETAIL), + verdict="no_result"), + ], final="error")) + + state_mod.cmd_report(argparse.Namespace(pr=PR)) + + out = capsys.readouterr().out + assert "agy=NO_RESULT(usage_limit)" in out + assert "NO_RESULT (" not in out + # 使える結果を残した担当の形は変わらない + assert "codex=APPROVE (0)" in out + + +def test_the_round_summary_shows_a_dash_when_the_reason_is_unknown( + tmp_dir, state_mod, capsys +): + """理由の鍵を持たない古い記録は `NO_RESULT(-)` として出る。""" + _write(tmp_dir, _state([ + _round(codex=_approve(), agy={"intent": "NO_RESULT"}, verdict="no_result"), + ])) + + state_mod.cmd_report(argparse.Namespace(pr=PR)) + + assert "agy=NO_RESULT(-)" in capsys.readouterr().out diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py b/plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py new file mode 100644 index 00000000..abf1e706 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_launch_cli_process_group.py @@ -0,0 +1,164 @@ +"""起動の手順が CLI を独立したプロセスグループで起動し、監視がグループへ止める(#729 の AC19〜AC21、#584)。 + +偽の CLI(3 秒後に **子プロセス** が結果ファイルを書く bash)を PATH に `codex` の名前で置き、 +`launch-cli.sh` から実際に起動する。監視が pid だけを止めると子が残って結果を書く(#584 の +再現)。グループへ止めれば書かれない。 + +- AC19: 起動した CLI の pid がプロセスグループの番号(pgid = pid) +- AC20: `_kill_pid` で止めて 4 秒待っても結果ファイルが無い +- AC21: 先頭でない pid では `os.killpg` を呼ばず、pid だけへ送る +""" +from __future__ import annotations + +import os +import pathlib +import signal +import subprocess +import time +from unittest import mock + +import pytest + +_HERE = pathlib.Path(__file__).resolve().parent +_LAUNCH_CLI = _HERE.parents[2] / "scripts" / "lib" / "launch-cli.sh" + +# 標準入力(プロンプト)を読み切ってから、子プロセスが 3 秒後に結果ファイルを書く。 +# 親(この script)は `wait` で子を待つので、監視から見て生きている。子を起こした後に +# 印(`.ready`)を書き、テストは印を待ってから止める(子が居ない時点で止めると、 +# グループで止めなくても結果が書かれず、テストが判別しない)。 +FAKE_CLI = """#!/usr/bin/env bash +cat >/dev/null +( sleep 3; echo '{"event": "APPROVE"}' > "$NDF_TEST_RESULT_FILE" ) & +: > "$NDF_TEST_RESULT_FILE.ready" +wait +""" + + +@pytest.fixture() +def launched(tmp_path): + """偽の CLI を起動し、(pid, 結果ファイルのパス) を返す。終わりに残ったプロセスを片付ける。""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "codex" + fake.write_text(FAKE_CLI, encoding="utf-8") + fake.chmod(0o755) + work = tmp_path / "work" + work.mkdir() + tmp_dir = tmp_path / "tmp" + tmp_dir.mkdir() + prompt = tmp_path / "prompt.md" + prompt.write_text("レビューしてください\n", encoding="utf-8") + stem = tmp_dir / "codex-review-pr7" + result = tmp_dir / "codex-review-pr7-result.json" + env = dict(os.environ, PATH=f"{bin_dir}:{os.environ['PATH']}", + NDF_TEST_RESULT_FILE=str(result)) + + proc = subprocess.run( + ["bash", str(_LAUNCH_CLI), "codex", str(work), str(prompt), str(stem)], + capture_output=True, text=True, env=env, timeout=30, + ) + assert proc.returncode == 0, proc.stderr + # 起動の案内の 1 行だけが出る(ジョブ制御の通知が混ざらない) + assert [l for l in proc.stderr.splitlines() if l.strip()] == [ + l for l in proc.stderr.splitlines() if "launched" in l], proc.stderr + pid = int((tmp_dir / "codex-review-pr7.pid").read_text().strip()) + for _ in range(500): + if (tmp_dir / "codex-review-pr7-result.json.ready").exists(): + break + time.sleep(0.01) + assert (tmp_dir / "codex-review-pr7-result.json.ready").exists(), "偽の CLI が子を起こしていない" + try: + yield pid, result + finally: + # **自分のグループへは送らない。** 実装前は CLI がこのテストと同じグループに居るため、 + # 無条件の killpg は pytest 自身を落とす(AC21 が防ぐ事故そのもの)。 + try: + if os.getpgid(pid) == pid and os.getpgid(pid) != os.getpgrp(): + os.killpg(pid, signal.SIGKILL) + else: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + + +def _alive(pid: int) -> bool: + try: + os.kill(pid, 0) + except OSError: + return False + return True + + +def test_launched_cli_leads_its_own_process_group(launched): + pid, _ = launched + assert _alive(pid) + assert os.getpgid(pid) == pid # AC19 + assert os.getpgid(pid) != os.getpgrp() # 起動元(このテスト)のグループではない + + +def test_killing_the_group_prevents_the_child_from_writing_the_result(launched, monitor_mod): + pid, result = launched + assert not result.exists() + + monitor_mod._kill_pid(pid) + time.sleep(4) + + assert not result.exists() # AC20 + assert not _alive(pid) or monitor_mod._is_zombie(pid) + + +def test_non_leader_pid_is_signalled_alone(monitor_mod): + """先頭でない pid(pgid ≠ pid)は pid だけへ送り、`os.killpg` を呼ばない(AC21)。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch("os.getpgid", return_value=4242), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4243) + killpg.assert_not_called() + kill.assert_called_once_with(4243, signal.SIGTERM) + + +def test_pid_in_the_monitors_own_group_is_signalled_alone(monitor_mod): + """pgid = pid でも監視自身のグループなら `os.killpg` を呼ばない(監視まで止まる)。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch("os.getpgid", return_value=4242), + mock.patch("os.getpgrp", return_value=4242), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4242) + killpg.assert_not_called() + kill.assert_called_once_with(4242, signal.SIGTERM) + + +def test_group_leader_gets_sigterm_then_sigkill_via_killpg(monitor_mod): + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=True), + mock.patch("os.getpgid", return_value=4242), + mock.patch("os.getpgrp", return_value=1), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4242, sigterm_grace=0.6) + assert killpg.call_args_list == [mock.call(4242, signal.SIGTERM), mock.call(4242, signal.SIGKILL)] + kill.assert_not_called() + + +def test_getpgid_failure_falls_back_to_the_pid(monitor_mod): + """`os.getpgid` が失敗する(もう居ない・権限が無い)ときは従来どおり pid だけへ送る。""" + with ( + mock.patch.object(monitor_mod, "_is_zombie", return_value=False), + mock.patch.object(monitor_mod, "_pid_alive", return_value=False), + mock.patch("os.getpgid", side_effect=ProcessLookupError), + mock.patch("os.killpg") as killpg, + mock.patch("os.kill") as kill, + ): + monitor_mod._kill_pid(4242) + killpg.assert_not_called() + kill.assert_called_once_with(4242, signal.SIGTERM) diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py b/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py index 0bd49631..64bd0a7b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py +++ b/plugins/ndf/skills/cross-review/tests/test_launch_print_timeout.py @@ -34,8 +34,7 @@ def _env(tmp_path: pathlib.Path, **over: str) -> dict[str, str]: stub = bin_dir / "agy" stub.write_text(STUB, encoding="utf-8") stub.chmod(0o755) - # 実行した人の `MONITOR_*` で値が変わらないよう外す(#678)。 - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} + env = dict(os.environ) env.pop("NDF_CRITIQUE_PRINT_TIMEOUT", None) env.update({ "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py index e4f50d10..d8c6c5cd 100644 --- a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py +++ b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_guards.py @@ -1,4 +1,4 @@ -"""未知ランタイムを副作用なしで拒否する入口の現状固定。""" +"""席の形に合わない名前を副作用なしで拒否する入口の現状固定。""" import os import pathlib import subprocess @@ -6,7 +6,7 @@ SCRIPT = pathlib.Path(__file__).resolve().parent.parent / "scripts/launch-reviewer.sh" -def test_unknown_runtime_exits_before_writing_files(tmp_path): +def test_a_name_outside_the_seat_pattern_exits_before_writing_files(tmp_path): result = subprocess.run( ["bash", str(SCRIPT), "bogus", "1", "1"], env={**os.environ, "CROSS_REVIEW_TMP_DIR": str(tmp_path)}, @@ -14,7 +14,7 @@ def test_unknown_runtime_exits_before_writing_files(tmp_path): ) assert result.returncode == 1 - assert "未知のランタイム" in result.stderr + assert "受け付けられない席の名前です" in result.stderr assert list(tmp_path.iterdir()) == [] diff --git a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py index b48a024a..f5e7ded0 100644 --- a/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py +++ b/plugins/ndf/skills/cross-review/tests/test_launch_reviewer_prompt_context.py @@ -44,32 +44,70 @@ def test_existing_comments_are_inlined_or_replaced_with_none(tmp_path, comments, assert snapshot == expected + "\n" -@pytest.mark.parametrize("event_downgrade, expected_line", [ - (True, "- event_downgrade: true"), - (False, "- event_downgrade: false"), -], ids=["downgrade-true", "downgrade-false"]) -def test_event_downgrade_is_reflected_in_prompt(tmp_path, event_downgrade, expected_line): - """現状固定: state.json の event_downgrade がプロンプトに反映される。""" +def _prompt(tmp_path, **state_over) -> str: state = { "current_pr": PR, "repo": "o/r", "worktree_path": str(tmp_path), - "event_downgrade": event_downgrade, + "event_downgrade": True, "rounds": [{"round": 1, "head_sha": "a" * 40}], } + state.update(state_over) (tmp_path / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) bin_dir = tmp_path / "bin" - bin_dir.mkdir() + bin_dir.mkdir(exist_ok=True) stub = bin_dir / "codex" stub.write_text("#!/bin/sh\nexit 0\n") stub.chmod(0o755) - result = subprocess.run( ["bash", str(SCRIPT), "codex", str(PR), "1"], capture_output=True, text=True, env={**os.environ, "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", "CROSS_REVIEW_TMP_DIR": str(tmp_path)}, ) - assert result.returncode == 0, result.stderr - prompt = (tmp_path / f"codex-review-pr{PR}-prompt.md").read_text() - assert expected_line in prompt + return (tmp_path / f"codex-review-pr{PR}-prompt.md").read_text() + + +# ---------------- 担当は結果だけを残す(#730) ---------------- + + +@pytest.mark.parametrize("word", [ + "gh api", # 投稿の呼び出し + "event_downgrade", # 判定の値の指定(格下げは投稿する側が決める) + "posted_as", + "comments_count", + "review_url", + "post_error", + "line could not be resolved", # インラインの組み立て(422 への対処) + "post: submit review", +]) +def test_the_prompt_has_no_step_to_post(tmp_path, word) -> None: + """担当へ渡すプロンプトに、レビューを投稿する手順が 1 つも無い(AC1・AC2)。""" + assert word.lower() not in _prompt(tmp_path).lower() + + +def test_the_prompt_asks_only_for_the_note_and_the_result(tmp_path) -> None: + """書かせるのは指摘の控えと結果ファイルの 2 つだけ。結果は判定と重要度別の件数(AC2)。""" + prompt = _prompt(tmp_path) + assert f"codex-review-pr{PR}-round1-payload.json" in prompt + assert f"codex-review-pr{PR}-result.json" in prompt + assert '"event"' in prompt and '"by_severity"' in prompt + + +def test_the_prompt_asks_to_rename_the_note_before_the_result(tmp_path) -> None: + """一時の名前で書き、控えを先・結果ファイルを後に改名させる(AC3)。""" + prompt = _prompt(tmp_path) + note_tmp = f"codex-review-pr{PR}-round1-payload.json.tmp" + result_tmp = f"codex-review-pr{PR}-result.json.tmp" + assert note_tmp in prompt and result_tmp in prompt + first_mv = prompt.index(f"mv ") + assert prompt.index(note_tmp, first_mv) < prompt.index(result_tmp, first_mv) + +def test_leftover_temporary_files_are_removed_before_launch(tmp_path) -> None: + """前の起動が残した一時の名前のファイルを持ち越さない。""" + for name in (f"codex-review-pr{PR}-result.json.tmp", + f"codex-review-pr{PR}-round1-payload.json.tmp"): + (tmp_path / name).write_text("{}") + _prompt(tmp_path) + assert not (tmp_path / f"codex-review-pr{PR}-result.json.tmp").exists() + assert not (tmp_path / f"codex-review-pr{PR}-round1-payload.json.tmp").exists() diff --git a/plugins/ndf/skills/cross-review/tests/test_measure.py b/plugins/ndf/skills/cross-review/tests/test_measure.py index 3d9b9737..a6844a56 100644 --- a/plugins/ndf/skills/cross-review/tests/test_measure.py +++ b/plugins/ndf/skills/cross-review/tests/test_measure.py @@ -142,6 +142,17 @@ def test_empty_state_does_not_crash(measure_mod): assert "methods" in result +@pytest.mark.parametrize("state", [None, [], "not-a-state"]) +def test_non_mapping_state_falls_back_to_an_empty_state(measure_mod, state): + """現状固定: 辞書以外の入力も空の状態として指標の全キーを返す。""" + result = measure_mod.measure(state) + + assert set(result) == {"pr", "prs", "rounds", "methods", "cost", "convergence"} + assert result["pr"] is None + assert result["prs"] == [] + assert result["rounds"] == 0 + + def test_wall_clock_is_null_while_the_run_has_not_ended(measure_mod): """終わっていない実行では実時間を出さない。**0 で埋めない。**""" st = _state(rounds=[_round(1)]) @@ -313,6 +324,50 @@ def test_oracle_counts_a_thread_without_a_position_as_unmatched(measure_mod): "found": 0, "unmatched": 1, "ambiguous": 0} +def test_oracle_counts_a_non_dict_position_as_unmatched(measure_mod): + """現状固定(R2-001)。位置の一覧に非辞書要素(文字列・null)が混じっても、 + + 落とさずに `unmatched` へ数え、例外を出さずに測定結果を返す。 + `_resolved_position` は辞書でない要素へ `(None, None)` を返し、 + `_add_oracle_match` がそれを `unmatched + 1` として扱う経路を固定する。 + """ + fix = { + "commit": "abc1234", "fixed": 1, "resolved_threads": 1, + "resolved_thread_ids": ["T1"], + "resolved_thread_positions": ["not-a-dict"], + } + st = _state( + rounds=[_round(1, fix=fix)], + review_findings=[_finding("codex-r1-0", 1, "a.py", 10)], + ) + + result = measure_mod.measure(st) + + assert result["methods"]["oracle"] == { + "found": 0, "unmatched": 1, "ambiguous": 0} + + +def test_oracle_counts_a_null_position_as_unmatched(measure_mod): + """現状固定(R2-001)。位置の一覧に `null` が混じっても `unmatched` に数える。 + + 非辞書要素の代表として `None`(JSON の null)でも同じ経路を通ることを固定する。 + """ + fix = { + "commit": "abc1234", "fixed": 1, "resolved_threads": 1, + "resolved_thread_ids": ["T1"], + "resolved_thread_positions": [None], + } + st = _state( + rounds=[_round(1, fix=fix)], + review_findings=[_finding("codex-r1-0", 1, "a.py", 10)], + ) + + result = measure_mod.measure(st) + + assert result["methods"]["oracle"] == { + "found": 0, "unmatched": 1, "ambiguous": 0} + + def test_oracle_does_not_count_a_finding_without_an_id(measure_mod): """`finding_id` を持たない指摘は結ばない(#558 レビュー)。 @@ -573,10 +628,31 @@ def test_proposed_reports_all_rounds_when_every_round_is_marked(measure_mod): "oracle_scope": "all_rounds", "oracle_base": 2} -def test_proposed_takes_only_the_two_counted_classifications(measure_mod): - """採るのは `verified_blocking` と `needs_human_judgment` の 2 つだけである。 +def test_proposed_normalizes_duplicate_and_invalid_evidence_rounds(measure_mod): + """現状固定: 有効な番号は型をそろえて一つの印にし、不正値は無視する。""" + st = _state( + evidence_rounds=["1", 1, "invalid", None], + rounds=[ + _round(1, fix=_fix(_position("T1", "a.py", 10))), + _round(2, fix=_fix(_position("T2", "b.py", 20))), + ], + review_findings=[ + _finding("codex-r1-0", 1, "a.py", 10, + classification="verified_blocking"), + _finding("codex-r2-0", 2, "b.py", 20, + classification="verified_blocking"), + ], + ) + + assert measure_mod.measure(st)["methods"]["proposed"] == { + "found": 1, "matched": 1, "of_oracle": 1.0, + "oracle_scope": "evidence_rounds", "oracle_base": 1} - 棄却した指摘と立証できなかった指摘は採らない。 + +def test_proposed_takes_only_the_three_counted_classifications(measure_mod): + """採るのは `verified_blocking` / `needs_human_judgment` / `unrefuted` の 3 つである(#732)。 + + 棄却した指摘と軽微な指摘(立証不足)は採らない。 """ st = _state( evidence_rounds=[1], @@ -588,10 +664,23 @@ def test_proposed_takes_only_the_two_counted_classifications(measure_mod): classification="insufficient_evidence"), _finding("agy-r1-0", 1, "d.py", 40, agent="agy", classification="needs_human_judgment"), + _finding("agy-r1-1", 1, "e.py", 50, agent="agy", + classification="unrefuted", unrefuted_reason="no_critique"), ], ) - assert measure_mod.measure(st)["methods"]["proposed"]["found"] == 2 + assert measure_mod.measure(st)["methods"]["proposed"]["found"] == 3 + + +def test_the_counted_classifications_match_the_state_script(measure_mod, state_mod): + """**収束の判定と測定は同じ指摘を数える**(#732 の AC11)。 + + 片方だけに `unrefuted` を足すと、判定が数えた指摘を測定が採らず、この方式の再現率が + 実際より低く出る。 + """ + assert measure_mod.COUNTED_CLASSIFICATIONS == state_mod.COUNTED_CLASSIFICATIONS + assert set(measure_mod.COUNTED_CLASSIFICATIONS) == { + "verified_blocking", "needs_human_judgment", "unrefuted"} def test_proposed_ignores_findings_from_unmarked_rounds(measure_mod): diff --git a/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py new file mode 100644 index 00000000..7956614c --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_merge_fix_posts.py @@ -0,0 +1,102 @@ +"""修正の取り込みが、送信と返信・決着・まとめを行う(#730 #585 #676)。 + +**修正の担当はコミットまでを行い、送らない。** 取り込みが現在の頭を指定して送り、 +報告されたコミットが送り先に載ったことを確かめてから、返信・決着・まとめを +待ち行列へ積んで流す。まとめの参照は投稿の応答から記録へ書く。 + +| 何を確かめるか | 受け入れ条件 | +| --- | --- | +| 返信・決着・まとめが積まれて流れる | AC7 | +| 送信は取り込む側が行う | AC8 | +| 報告されたコミットが送り先に無ければ止まる | AC9 | +| 同じ共通層を使う | AC21 | +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + +PR = 5850 +REPO = "o/r" + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +def _seed(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, "repo": REPO, "viewer_login": "takemi", + "worktree_path": str(tmp_dir), "head_branch": "feat/x", + "rounds": [{"round": 2, "pr": PR, "started_at": "2026-01-01T00:00:00+00:00"}], + "deferred_nits": [], "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _fix(tmp_dir: pathlib.Path) -> pathlib.Path: + path = tmp_dir / f"fix-pr{PR}-result.json" + path.write_text(json.dumps({ + "pr": PR, "fix_commit": "abc1234", "ci_status": "SUCCESS", "fixed_count": 1, + "by_severity": {"major": 1}, + "resolved_threads": [{"thread_id": "PRRT_a", "comment_id": 11}], + "deferred": [], "rejected": [], + }), encoding="utf-8") + return path + + +def _state(tmp_dir: pathlib.Path) -> dict: + return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + + +@pytest.fixture() +def calls(monkeypatch, state_mod): + """送信と投稿の呼び出しを記録する。共通層の口をそのまま差し替える。""" + rp = state_mod.result_posts + seen: dict = {"push": [], "post": []} + + def push(worktree, head, commit): + seen["push"].append((str(worktree), head, commit)) + return rp.PushResult(seen.get("push_ok", True), True, + seen.get("push_ok", True), "") + + def post(queue, result_path, repo, pr, round_no=None, actor=None): + items = rp.fix_posts(result_path, repo, pr, round_no) + seen["post"].append([i["kind"] for i in items]) + return rp.FixOutcome("https://x/pull/5850#issuecomment-9", 1, 1, 0, False, "") + + monkeypatch.setattr(rp, "push_fix", push) + monkeypatch.setattr(rp, "post_fix", post) + return seen + + +def test_the_take_in_pushes_the_head_and_posts_the_replies(tmp_dir, state_mod, calls): + _seed(tmp_dir) + _fix(tmp_dir) + + state_mod.cmd_merge_fix(argparse.Namespace(pr=PR, file=None)) + + assert calls["push"] == [(str(tmp_dir), "feat/x", "abc1234")] + assert calls["post"] == [["review-reply", "thread-resolve", "pr-comment"]] + fix = _state(tmp_dir)["rounds"][-1]["fix"] + assert fix["summary_comment_url"] == "https://x/pull/5850#issuecomment-9" + + +def test_the_take_in_stops_when_the_commit_is_not_on_the_branch( + tmp_dir, state_mod, calls): + """報告されたコミットが送り先に載っていなければ、記録も投稿もせずに止まる(AC9)。""" + _seed(tmp_dir) + _fix(tmp_dir) + calls["push_ok"] = False + + with pytest.raises(SystemExit) as e: + state_mod.cmd_merge_fix(argparse.Namespace(pr=PR, file=None)) + + assert e.value.code != 0 + assert calls["post"] == [] + assert "fix" not in _state(tmp_dir)["rounds"][-1] diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py b/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py index 400c416b..0c3bc47e 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_agy.py @@ -25,14 +25,15 @@ def test_the_monitor_accepts_the_new_name(tmp_path) -> None: # 起動待ちの 30 秒を使い切らないよう、終了済みの pid を先に置く。 (tmp_path / "agy-review-pr1.pid").write_text("2147483646\n", encoding="utf-8") r = _run("1", "agy", "--tmp-dir", str(tmp_path), "--timeout", "1", "--poll", "1") - assert "invalid choice" not in r.stderr + assert "席の名前の形が違います" not in r.stderr assert r.returncode != 2 def test_the_monitor_rejects_the_old_name() -> None: + """綴りの検査は席の名前の形が行う(#727)。通らなければ終了コード 2。""" r = _run("1", "gemini") assert r.returncode == 2 - assert "invalid choice" in r.stderr + assert "席の名前の形が違います" in r.stderr # ---------- 受け入れ条件 19(無進捗の許容時間) ---------- diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py b/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py index 625cebcd..45c030ee 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_generic_stem.py @@ -181,11 +181,8 @@ def test_claude_stdout_scan_ignores_missing_file(monitor_mod, tmp_path): # ---------- 5. 追加ランタイムの stall 既定 ---------- -def test_stall_defaults_cover_claude_and_kiro(monitor_mod, monkeypatch): +def test_stall_defaults_cover_claude_and_kiro(monitor_mod): """`claude -p` は完了まで無出力なので、最も長い既定を持つこと。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CLAUDE", raising=False) - monkeypatch.delenv("MONITOR_STALL_KIRO", raising=False) assert monitor_mod._agent_stall_default("claude") == 900 assert monitor_mod._agent_stall_default("kiro") == 480 diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py b/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py index 56b0b6d9..ebf1b0ee 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_outcome_file.py @@ -60,11 +60,10 @@ def _dead_pid() -> int: def _run_monitor(tmp_dir: pathlib.Path, *extra: str, script: pathlib.Path = _MONITOR_LIB, pr: int = 7, agents: str = "codex") -> subprocess.CompletedProcess: - env = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(script), str(pr), "--agents", agents, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env=env, timeout=60, + capture_output=True, text=True, timeout=60, ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py b/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py index 12414c80..b329c0d2 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_phase.py @@ -32,11 +32,10 @@ def _run(tmp_dir: pathlib.Path, *extra: str, agents: str = "agy", for agent in agents.split(","): (tmp_dir / f"{agent}-review-pr7.pid").write_text(str(_dead_pid())) (tmp_dir / f"{agent}-review-pr7-result.json").write_text('{"event": "APPROVE"}') - base = {k: v for k, v in os.environ.items() if not k.startswith("MONITOR_")} return subprocess.run( [sys.executable, str(_MONITOR_LIB), "7", "--agents", agents, "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], - capture_output=True, text=True, env={**base, **(env or {})}, timeout=60, + capture_output=True, text=True, env={**os.environ, **(env or {})}, timeout=60, ) diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py b/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py index 71f6f245..62d8c31d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_stall_default.py @@ -7,31 +7,26 @@ agy は err.log にほぼ進捗を出さないため、ビルトイン既定を 480s と大きめに 取って 1 度目の STALLED 誤検知を避ける。codex は従来通り 180s で変更なし。 + +実行した人の `MONITOR_*` は、根の `conftest.py` が実行中だけ外す(#678)。 """ from __future__ import annotations import pytest -def test_builtin_default_codex(monkeypatch, monitor_mod): +def test_builtin_default_codex(monitor_mod): """codex のビルトイン既定は 180s。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) assert monitor_mod._agent_stall_default("codex") == 180 -def test_builtin_default_agy(monkeypatch, monitor_mod): +def test_builtin_default_agy(monitor_mod): """agy のビルトイン既定は 480s (codex より大きい)。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) assert monitor_mod._agent_stall_default("agy") == 480 def test_per_agent_env_overrides_builtin(monkeypatch, monitor_mod): """env `MONITOR_STALL_AGY` 設定で agy 既定が上書きされる。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "600") assert monitor_mod._agent_stall_default("agy") == 600 # codex は影響を受けない @@ -46,8 +41,6 @@ def test_shared_env_applies_to_both(monkeypatch, monitor_mod): 本テストは monkeypatch で `MONITOR_STALL=240` に書き換え、両 agent が 240 を 返すことを確認する (= 共通 env が実際に反映されることの検証)。 """ - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) monkeypatch.setenv("MONITOR_STALL", "240") # 共通 env が両 agent に効く (per-agent 上書きなしの場合) assert monitor_mod._agent_stall_default("codex") == 240 @@ -58,16 +51,13 @@ def test_per_agent_env_takes_precedence_over_shared(monkeypatch, monitor_mod): """per-agent env > 共通 env の優先順位を確認する。""" monkeypatch.setenv("MONITOR_STALL", "240") monkeypatch.setenv("MONITOR_STALL_AGY", "777") - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) assert monitor_mod._agent_stall_default("agy") == 777 # codex 側は per-agent env が無いので 共通 env (= 240) にフォールバック assert monitor_mod._agent_stall_default("codex") == 240 -def test_unknown_agent_falls_back_to_default_stall(monkeypatch, monitor_mod): +def test_unknown_agent_falls_back_to_default_stall(monitor_mod): """ビルトインに無い agent 名は `DEFAULT_STALL` にフォールバックする。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) - monkeypatch.delenv("MONITOR_STALL_UNKNOWN", raising=False) assert monitor_mod._agent_stall_default("unknown") == monitor_mod.DEFAULT_STALL @@ -80,8 +70,6 @@ def test_shared_env_non_numeric_falls_back_to_builtin(monkeypatch, monitor_mod, gemini round 4 指摘: `int(os.environ[...])` は非数値で ValueError を出す。 監視プロセスを env 設定ミスでクラッシュさせないため、try/except で builtin に戻す。 """ - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) - monkeypatch.delenv("MONITOR_STALL_AGY", raising=False) monkeypatch.setenv("MONITOR_STALL", "abc") # codex / agy とも builtin 既定 (180 / 480) に戻る assert monitor_mod._agent_stall_default("codex") == 180 @@ -96,9 +84,7 @@ def test_per_agent_env_non_numeric_falls_back_to_builtin( monkeypatch, monitor_mod, capsys ): """env `MONITOR_STALL_` が非数値なら builtin にフォールバック。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "not-a-number") - monkeypatch.delenv("MONITOR_STALL_CODEX", raising=False) # agy は builtin (480) にフォールバック assert monitor_mod._agent_stall_default("agy") == 480 # codex は env 未設定なので builtin (180) @@ -112,7 +98,6 @@ def test_per_agent_env_non_numeric_does_not_affect_other_agent( monkeypatch, monitor_mod ): """non-numeric な per-agent env は対象 agent だけに影響する。""" - monkeypatch.delenv("MONITOR_STALL", raising=False) monkeypatch.setenv("MONITOR_STALL_AGY", "xxx") monkeypatch.setenv("MONITOR_STALL_CODEX", "200") # codex 側は正常 assert monitor_mod._agent_stall_default("codex") == 200 diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py new file mode 100644 index 00000000..4a04ff71 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py @@ -0,0 +1,281 @@ +"""利用上限と CLI の上限の文言の検知(#729 の AC2〜AC7、#619)。 + +監視は PATH の偽物ではなく **実プロセス** を相手にする(`test_monitor_outcome_file.py` と +同じ形)。終わったプロセスの pid ファイルと、文言を 1 行書いた err.log / stdout.log を +置いて監視を呼び、状態・終了コード・監視の結果ファイルの `reason` を見る。 + +- AC2: `Monthly request limit reached`(kiro の実物)→ `EARLY_ERROR` / `usage_limit` +- AC3: `"api_error_status":429`(claude)は err.log(全担当)と stdout.log(claude だけ)で拾う +- AC4: 既存の `quota exceeded` / `rate limit exceeded` / `HTTP/x 429` は `usage_limit`、 + `HTTP/x 401` / `403` と他の致命は `early_error` のまま +- AC5: 結果ファイル無しで終わり err.log に `print timeout after <時間> with turn in progress` + → `NO_RESULT` / `cli_timeout`。結果ファイルがあれば `OK` / `ok` +- AC6: 表・引用・バッククォート・grep 形式の中の文言は一致しない。stdout.log の JSON は除外を掛けない +- AC7: `usage_limit` は監視の結果ファイルと記録の `reason` に入り、標準出力のキーは変わらない +- 照合の順序は利用上限 → 致命 → 警告の見た目の致命(設計文書の未確認 5 を固定する) +""" +from __future__ import annotations + +import json +import pathlib +import subprocess +import sys + +import pytest + +_HERE = pathlib.Path(__file__).resolve().parent +_MONITOR_LIB = _HERE.parents[2] / "scripts" / "lib" / "monitor.py" + +STDOUT_KEYS = { + "agent", "status", "exit_code", "pid", "elapsed", "detail", "err_log_size", + "stdout_log_size", "progress_log_size", "progress_tail", "idle_seconds", + "result_exists", "sentinel_seen", +} + +# 設計文書「実測」の 10 行。見出しはそのまま、行は実物の形に合わせて組み立てた。 +KIRO_LIMIT = "Monthly request limit reached" +CLAUDE_429 = ('{"type":"result","subtype":"success","is_error":false,' + '"api_error_status":429,"result":"rate limited"}') +CLAUDE_429_SPACED = '{"type": "result", "api_error_status" : 429, "is_error": false}' +HTTP_429 = "HTTP/1.1 429 Too Many Requests" +HTTP_401 = "HTTP/1.1 401 Unauthorized" +IN_TABLE = "| usage_limit | Monthly request limit reached | 利用上限 |" +IN_BACKTICKS = "see `Monthly request limit reached` in err.log" +IN_QUOTE = "> Monthly request limit reached" +AGY_PRINT_TIMEOUT = "[agy] print timeout after 10m0s with turn in progress; returning partial output" +IN_GREP = ('plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py:12:' + ' KIRO_LIMIT = "Monthly request limit reached"') + +# (見出し, 行, 利用上限の表の一致, CLI の上限の表の一致, 利用上限を除いた致命の表の一致) +# 設計文書の「現行fatal」は変更前の表で測った値。HTTP 429 はこの変更で利用上限の表へ移る(AC4)。 +TEN_LINES = [ + ("kiro 実物", KIRO_LIMIT, True, False, False), + ("claude 429 JSON 1 行", CLAUDE_429, True, False, False), + ("claude 429 空白あり", CLAUDE_429_SPACED, True, False, False), + ("HTTP 429 行", HTTP_429, True, False, False), + ("HTTP 401 行", HTTP_401, False, False, True), + ("表の中", IN_TABLE, False, False, False), + ("バッククォート", IN_BACKTICKS, False, False, False), + ("引用行", IN_QUOTE, False, False, False), + ("agy print timeout", AGY_PRINT_TIMEOUT, False, True, False), + ("grep 形式", IN_GREP, False, False, False), +] + + +def _write(path: pathlib.Path, text: str) -> pathlib.Path: + path.write_text(text + "\n", encoding="utf-8") + return path + + +# ---------- 照合の単体(AC4 / AC6) ---------- + +@pytest.mark.parametrize(("label", "line", "usage_hit", "cli_timeout_hit", "fatal_hit"), TEN_LINES, + ids=[t[0] for t in TEN_LINES]) +def test_ten_measured_lines_match_as_the_design_records(tmp_path, monitor_mod, label, line, + usage_hit, cli_timeout_hit, fatal_hit): + log = _write(tmp_path / "err.log", line) + assert (monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is not None) is usage_hit + assert (monitor_mod._scan_patterns(log, monitor_mod.CLI_TIMEOUT_AFTER_EXIT) is not None) is cli_timeout_hit + assert (monitor_mod._scan_patterns(log, monitor_mod.EARLY_ERROR_FATAL) is not None) is fatal_hit + # 止めるべき文言があるかを返す `_scan_early_fatal` は、どちらの表の一致も拾う(既存テストの契約) + assert (monitor_mod._scan_early_fatal(log) is not None) is (usage_hit or fatal_hit) + + +def test_claude_stdout_json_is_matched_without_the_quote_exclusion(tmp_path, monitor_mod): + """stdout.log の JSON は 1 行に引用符が多く、行単位の除外を掛けると取りこぼす。""" + out = _write(tmp_path / "stdout.log", CLAUDE_429) + assert monitor_mod._scan_claude_stdout_usage_limit(out) is not None + assert monitor_mod._scan_claude_stdout_usage_limit(_write(tmp_path / "ok.log", '{"is_error":false}')) is None + + +@pytest.mark.parametrize("line", ["quota exceeded: please upgrade", "Rate limit exceeded for model", HTTP_429]) +def test_existing_limit_matches_moved_to_usage_limit(tmp_path, monitor_mod, line): + log = _write(tmp_path / "err.log", line) + assert monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is not None + assert monitor_mod._scan_patterns(log, monitor_mod.EARLY_ERROR_FATAL) is None + + +@pytest.mark.parametrize("line", [HTTP_401, "HTTP/2 403 Forbidden", "Authentication failed: token", + "Internal sandbox error: cannot start"]) +def test_other_fatal_lines_stay_early_error(tmp_path, monitor_mod, line): + log = _write(tmp_path / "err.log", line) + assert monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is None + assert monitor_mod._scan_early_fatal(log) is not None + + +# ---------- 照合の順序(利用上限 → 致命 → 警告の見た目の致命) ---------- + +def _paths(monitor_mod, tmp_path, agent="kiro"): + return monitor_mod.AgentPaths( + agent=agent, pr=7, + pidfile=tmp_path / "x.pid", err_log=tmp_path / "err.log", + stdout_log=tmp_path / "stdout.log", progress_log=tmp_path / "progress.log", + result=tmp_path / "result.json", + ) + + +@pytest.mark.parametrize("order", ["limit_first", "fatal_first"]) +def test_usage_limit_wins_over_other_fatal_lines_in_either_order(tmp_path, monitor_mod, order): + lines = [KIRO_LIMIT, "Authentication failed: retry"] + if order == "fatal_first": + lines.reverse() + _write(tmp_path / "err.log", "\n".join(lines)) + fatal, _warn = monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", False) + assert fatal is not None + assert fatal.reason == "usage_limit" + assert fatal.source == "err.log" + + +def test_usage_limit_wins_over_warning_shaped_fatal(tmp_path, monitor_mod): + _write(tmp_path / "err.log", + "WARNING: --trust-tools arg for custom tool foo\n" + KIRO_LIMIT) + fatal, _warn = monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", False) + assert fatal.reason == "usage_limit" + + +def test_fatal_without_usage_limit_has_no_reason(tmp_path, monitor_mod): + _write(tmp_path / "err.log", "Authentication failed: retry") + fatal, _warn = monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", False) + assert fatal.reason is None + assert fatal.message == "Authentication failed: retry" + + +def test_claude_stdout_usage_limit_is_seen_only_for_claude(tmp_path, monitor_mod): + _write(tmp_path / "stdout.log", CLAUDE_429) + fatal, _ = monitor_mod._early_error(_paths(monitor_mod, tmp_path, "claude"), "claude", False) + assert (fatal.source, fatal.reason) == ("stdout.log", "usage_limit") + fatal, _ = monitor_mod._early_error(_paths(monitor_mod, tmp_path, "kiro"), "kiro", False) + assert fatal is None + + +def test_disabled_early_error_disables_usage_limit_too(tmp_path, monitor_mod): + _write(tmp_path / "err.log", KIRO_LIMIT) + assert monitor_mod._early_error(_paths(monitor_mod, tmp_path), "kiro", True) == (None, None) + + +def test_monitor_outcome_create_keeps_the_two_argument_form(monitor_mod): + plain = monitor_mod.MonitorOutcome.create("EARLY_ERROR", "x") + assert (plain.reason, plain.exit_code) == (None, 4) + limited = monitor_mod.MonitorOutcome.create("EARLY_ERROR", "x", reason="usage_limit") + assert limited.reason == "usage_limit" + + +# ---------- 監視を実プロセスで呼ぶ(AC2 / AC3 / AC4 / AC7) ---------- + +def _dead_pid() -> int: + proc = subprocess.Popen(["true"]) + proc.wait() + return proc.pid + + +def _run_monitor(tmp_dir: pathlib.Path, agent: str, *extra: str) -> subprocess.CompletedProcess: + return subprocess.run( + [sys.executable, str(_MONITOR_LIB), "7", "--agents", agent, + "--tmp-dir", str(tmp_dir), "--poll", "1", *extra], + capture_output=True, text=True, timeout=60, + ) + + +def _finished(tmp_dir: pathlib.Path, agent: str, *, err: str = "", stdout: str = "", + result: bool = False) -> str: + stem = f"{agent}-review-pr7" + (tmp_dir / f"{stem}.pid").write_text(str(_dead_pid())) + if err: + _write(tmp_dir / f"{stem}-err.log", err) + if stdout: + _write(tmp_dir / f"{stem}-stdout.log", stdout) + if result: + (tmp_dir / f"{stem}-result.json").write_text('{"event": "APPROVE"}') + return stem + + +def _outcome(tmp_dir: pathlib.Path, stem: str) -> dict: + return json.loads((tmp_dir / f"{stem}-monitor.json").read_text(encoding="utf-8")) + + +@pytest.mark.parametrize(("agent", "err", "stdout"), [ + ("kiro", KIRO_LIMIT, ""), # AC2 + ("codex", CLAUDE_429, ""), # AC3: err.log は全担当 + ("claude", "", CLAUDE_429), # AC3: stdout.log は claude だけ + ("claude", "", CLAUDE_429_SPACED), # AC3: 空白の有無を問わない + ("agy", "quota exceeded: upgrade", ""), # AC4 + ("agy", HTTP_429, ""), # AC4 +]) +def test_usage_limit_stops_the_agent_as_early_error_with_reason_usage_limit(tmp_path, agent, err, stdout): + stem = _finished(tmp_path, agent, err=err, stdout=stdout) + + proc = _run_monitor(tmp_path, agent) + + assert proc.returncode == 4, proc.stderr + outcome = _outcome(tmp_path, stem) + assert (outcome["status"], outcome["reason"]) == ("EARLY_ERROR", "usage_limit") + assert outcome["detail"].startswith("early error (fatal) in ") + rows = [json.loads(l) for l in (tmp_path / "monitor-outcomes.jsonl").read_text().splitlines()] + assert rows[-1]["reason"] == "usage_limit" + # AC7: 標準出力のキーは変わらない + out = [json.loads(l) for l in proc.stdout.splitlines() if l.strip()] + assert len(out) == 1 and set(out[0]) == STDOUT_KEYS and out[0]["exit_code"] == 4 + + +@pytest.mark.parametrize("err", [HTTP_401, "HTTP/1.1 403 Forbidden", "Permission denied"]) +def test_other_fatal_lines_keep_reason_early_error(tmp_path, err): + stem = _finished(tmp_path, "kiro", err=err) + + proc = _run_monitor(tmp_path, "kiro") + + assert proc.returncode == 4, proc.stderr + assert (_outcome(tmp_path, stem)["status"], _outcome(tmp_path, stem)["reason"]) == ( + "EARLY_ERROR", "early_error") + + +def test_claude_json_in_kiro_stdout_is_not_seen(tmp_path): + """stdout.log を見るのは claude だけ。他の担当の stdout は結果なしのまま。""" + stem = _finished(tmp_path, "kiro", stdout=CLAUDE_429) + + proc = _run_monitor(tmp_path, "kiro") + + assert proc.returncode == 3 + assert _outcome(tmp_path, stem)["reason"] == "missing" + + +def test_quoted_usage_limit_in_err_log_is_not_a_hit(tmp_path): + stem = _finished(tmp_path, "kiro", err="\n".join([IN_TABLE, IN_BACKTICKS, IN_QUOTE, IN_GREP]), + result=True) + + proc = _run_monitor(tmp_path, "kiro") + + assert proc.returncode == 0, proc.stderr + assert _outcome(tmp_path, stem)["reason"] == "ok" + + +# ---------- CLI の上限(AC5) ---------- + +def test_cli_timeout_without_result_is_no_result_with_reason_cli_timeout(tmp_path): + stem = _finished(tmp_path, "agy", err=AGY_PRINT_TIMEOUT) + + proc = _run_monitor(tmp_path, "agy") + + assert proc.returncode == 3, proc.stderr + outcome = _outcome(tmp_path, stem) + assert (outcome["status"], outcome["reason"]) == ("NO_RESULT", "cli_timeout") + rows = [json.loads(l) for l in (tmp_path / "monitor-outcomes.jsonl").read_text().splitlines()] + assert rows[-1]["reason"] == "cli_timeout" + + +def test_cli_timeout_with_result_is_still_ok(tmp_path): + """上限に当たっても結果を書き終えていれば使える。""" + stem = _finished(tmp_path, "agy", err=AGY_PRINT_TIMEOUT, result=True) + + proc = _run_monitor(tmp_path, "agy") + + assert proc.returncode == 0, proc.stderr + assert (_outcome(tmp_path, stem)["status"], _outcome(tmp_path, stem)["reason"]) == ("OK", "ok") + + +def test_cli_timeout_in_a_table_row_is_not_a_hit(tmp_path): + stem = _finished(tmp_path, "agy", err=f"| cli_timeout | {AGY_PRINT_TIMEOUT} |") + + proc = _run_monitor(tmp_path, "agy") + + assert proc.returncode == 3 + assert _outcome(tmp_path, stem)["reason"] == "missing" diff --git a/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py b/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py index e2813e19..ba62b5d0 100644 --- a/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py +++ b/plugins/ndf/skills/cross-review/tests/test_queue_idempotency.py @@ -6,13 +6,16 @@ | 種別 | 照会 | 同じとみなす条件 | | --- | --- | --- | -| `pr-comment` | `repos/{リポジトリ}/issues/{番号}/comments` | 投稿者が自分で、本文が一致 | -| `review-post` | `repos/{リポジトリ}/pulls/{番号}/reviews` | 投稿者・判定・本文の先頭 80 文字が一致 | -| `review-reply` | `repos/{リポジトリ}/pulls/{番号}/comments` | `in_reply_to_id` と本文が一致 | +| `pr-comment` | `repos/{リポジトリ}/issues/{番号}/comments` | 投稿者が自分で、本文の先頭 80 文字が一致 | +| `review-post` | `repos/{リポジトリ}/pulls/{番号}/reviews` | 投稿者と、本文の先頭行のラウンドと席までの前方一致 | +| `review-reply` | `repos/{リポジトリ}/pulls/{番号}/comments` | 返信先の指摘の識別子と、本文の先頭 80 文字が一致 | | `thread-resolve` | 未解決のスレッドの一覧 | 識別子が一覧に無い | 本文の先頭 80 文字で比べるのは、振動の検知が指摘の同一性を測るときと同じ幅である。 **同じ判断に別々の値を持たない。** + +**レビューの照合の鍵に判定の語を含めない。** 含めると、起動し直して判定が変わったときに +別の投稿と読まれ、同じラウンド・同じ席のレビューが 2 件になる(#730 #583)。 """ from __future__ import annotations @@ -27,6 +30,12 @@ BODY = "同じ内容の本文。" * 12 # 80 文字より長い本文で、先頭の照合が効くことを見る +def _review_body(event: str, round_no: int = 3, seat: str = "codex") -> str: + """レビューの本文。先頭行がラウンドと席と判定を持つ(#730 AC10)。""" + return (f"## 🤖 cross-review | round {round_no} | {seat} | {event}\n\n" + + BODY) + + @pytest.fixture() def qdir(tmp_path) -> pathlib.Path: return tmp_path / "pending" @@ -80,12 +89,14 @@ def test_a_review_already_on_github_is_not_posted_again( q = queue_mod.Queue(qdir) queue_mod.enqueue( q, "review-post", REPO, PR, - {"body": BODY, "event": "REQUEST_CHANGES"}, actor=ACTOR) - # 末尾だけが違う本文でも、先頭 80 文字が同じなら同じ投稿とみなす。 + {"body": _review_body("REQUEST_CHANGES"), "event": "REQUEST_CHANGES"}, + actor=ACTOR) + # 末尾だけが違う本文でも、先頭行のラウンドと席までが同じなら同じ投稿とみなす。 fake_gh.set_rules([ {"match": f"pulls/{PR}/reviews", "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "CHANGES_REQUESTED", - "body": BODY + "(末尾の言い回しだけが違う)", + "body": _review_body("REQUEST_CHANGES") + + "(末尾の言い回しだけが違う)", "id": 4961230016, "html_url": "https://x/#pullrequestreview-4961230016"}])}, ]) @@ -100,16 +111,42 @@ def test_a_review_already_on_github_is_not_posted_again( "https://x/#pullrequestreview-4961230016" -def test_a_review_with_a_different_verdict_is_not_the_same( +def test_a_review_with_a_different_verdict_is_still_the_same( queue_mod, fake_gh, qdir) -> None: + """判定が変わっても、同じラウンド・同じ席なら 2 件目を作らない(#730 AC11)。""" + q = queue_mod.Queue(qdir) + queue_mod.enqueue( + q, "review-post", REPO, PR, + {"body": _review_body("REQUEST_CHANGES"), "event": "REQUEST_CHANGES"}, + actor=ACTOR) + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", + "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", + "body": _review_body("APPROVE")}])}, + {"match": "", "stdout": "{}"}, + ]) + + result = q.flush() + + assert len(result.skipped) == 1 + assert _posted(fake_gh.joined()) == [] + + +@pytest.mark.parametrize("other", [ + _review_body("APPROVE", round_no=4), + _review_body("APPROVE", seat="agy"), +]) +def test_a_review_of_another_round_or_seat_is_not_the_same( + queue_mod, fake_gh, qdir, other) -> None: q = queue_mod.Queue(qdir) queue_mod.enqueue( q, "review-post", REPO, PR, - {"body": BODY, "event": "REQUEST_CHANGES"}, actor=ACTOR) + {"body": _review_body("REQUEST_CHANGES"), "event": "REQUEST_CHANGES"}, + actor=ACTOR) fake_gh.set_rules([ {"match": f"pulls/{PR}/reviews?", "stdout": json.dumps([{"user": {"login": ACTOR}, "state": "APPROVED", - "body": BODY}])}, + "body": other}])}, {"match": "", "stdout": "{}"}, ]) @@ -124,9 +161,11 @@ def test_a_review_reply_already_on_github_is_not_posted_again( queue_mod.enqueue( q, "review-reply", REPO, PR, {"body": BODY, "in_reply_to": 987654}, actor=ACTOR) + # 末尾だけが違う返信でも、先頭 80 文字が同じなら同じ返信とみなす。 fake_gh.set_rules([ {"match": f"pulls/{PR}/comments", - "stdout": json.dumps([{"user": {"login": ACTOR}, "body": BODY, + "stdout": json.dumps([{"user": {"login": ACTOR}, + "body": BODY + "(末尾だけが違う)", "in_reply_to_id": 987654}])}, ]) diff --git a/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py b/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py new file mode 100644 index 00000000..977a280c --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_read_result_posts.py @@ -0,0 +1,230 @@ +"""指摘の取り込みが、控えからレビューを組み立てて送る(#730 #583)。 + +**書き込みはレビューを回す側だけが行う。** 担当は指摘の控えと結果ファイルを書いて +終わり、取り込みがそこから投稿を組み立て、待ち行列を通して送り、送信の応答を記録に +する。担当の申告と GitHub の実数を突き合わせる処理は無くなる。 + +| 何を確かめるか | 受け入れ条件 | +| --- | --- | +| 控えから投稿が組み立ち、1 回の呼び出しで送られる | AC5 | +| 応答に本文が出ない | AC6 | +| 投稿の後・記録の前で止めた実行をやり直しても増えない | AC12 | +| 記録の URL と件数が送信の応答から来る | AC15 | +| インラインが 0 件でも結果なしにならない | AC17 | +| 控えが無ければ投稿を 0 件にする | AC3 | +""" +from __future__ import annotations + +import argparse +import json +import pathlib + +import pytest + +PR = 730 +AGENT = "codex" +ROUND = 2 +REPO = "o/r" +ACTOR = "takemi" +SHA = "f" * 40 +INLINE_TEXT = "[major / 正確性] 戻り値を確かめる" +SUMMARY_TEXT = "層の分け方をそろえると読みやすい" + + +def _seed(tmp_dir: pathlib.Path, **over) -> None: + state = { + "current_pr": PR, + "repo": REPO, + "viewer_login": ACTOR, + "is_own_pr": False, + "event_downgrade": False, + "rounds": [{"round": ROUND, "pr": PR, "head_sha": SHA, + "started_at": "2026-09-22T00:00:00+00:00"}], + "final": None, + } + state.update(over) + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text( + json.dumps(state), encoding="utf-8") + + +def _note(tmp_dir: pathlib.Path, comments: list[dict] | None = None) -> pathlib.Path: + if comments is None: + comments = [{"path": "a.py", "line": 12, "body": INLINE_TEXT, + "severity": "major"}] + path = tmp_dir / f"{AGENT}-review-pr{PR}-round{ROUND}-payload.json" + path.write_text(json.dumps({"summary": SUMMARY_TEXT, "comments": comments}, + ensure_ascii=False), encoding="utf-8") + return path + + +def _result(tmp_dir: pathlib.Path, **over) -> pathlib.Path: + data = {"event": "REQUEST_CHANGES", "by_severity": {"major": 1}} + data.update(over) + path = tmp_dir / f"{AGENT}-review-pr{PR}-result.json" + path.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + return path + + +def _args() -> argparse.Namespace: + return argparse.Namespace(pr=PR, agent=AGENT, file=None) + + +def _state(tmp_dir: pathlib.Path) -> dict: + return json.loads( + (tmp_dir / f"cross-review-pr{PR}-state.json").read_text(encoding="utf-8")) + + +def _entry(tmp_dir: pathlib.Path) -> dict: + return _state(tmp_dir)["rounds"][-1][AGENT] + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +_ACCEPT = {"match": f"pulls/{PR}/reviews", "stdout": json.dumps( + {"id": 555, "html_url": f"https://github.com/o/r/pull/{PR}#pullrequestreview-555"})} +_NO_PRIOR = {"match": f"pulls/{PR}/reviews?", "stdout": "[]"} +_URL = f"https://github.com/o/r/pull/{PR}#pullrequestreview-555" + + +def test_the_take_in_posts_the_review_and_records_the_response( + tmp_dir, state_mod, fake_gh, capsys) -> None: + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + entry = _entry(tmp_dir) + assert entry["review_url"] == _URL # 送信の応答から取る(AC15) + assert entry["comments"] == 1 # 送れたインラインの数 + assert entry["intent"] == "REQUEST_CHANGES" + assert entry["queued"] is False + out = capsys.readouterr().out + assert f"POSTED review_url={_URL}" in out + assert "INLINE=1 BODY=0 QUEUED=0" in out + assert "FINDINGS=1" in out + + +def test_no_body_reaches_the_answer(tmp_dir, state_mod, fake_gh, capsys) -> None: + """取り込みの出力に、レビューの本文もインラインの本文も出ない(AC6)。""" + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + captured = capsys.readouterr() + assert INLINE_TEXT not in captured.out and INLINE_TEXT not in captured.err + assert SUMMARY_TEXT not in captured.out and SUMMARY_TEXT not in captured.err + assert len(captured.out.splitlines()) <= 20 + + +def test_the_body_is_sent_from_the_note(tmp_dir, state_mod, fake_gh) -> None: + """本文は控えから組み立てて送る。先頭行がラウンドと席を持つ(AC5・AC10)。""" + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + sent = [c for c in fake_gh.calls() if "--method POST" in " ".join(c["argv"])] + body = json.loads(sent[-1]["stdin"]) + assert body["body"].splitlines()[0] == \ + f"## 🤖 cross-review | round {ROUND} | {AGENT} | REQUEST_CHANGES" + assert body["commit_id"] == SHA + assert body["comments"][0]["path"] == "a.py" + + +def test_a_second_take_in_does_not_add_a_second_review( + tmp_dir, state_mod, fake_gh) -> None: + """投稿の後・記録の前で止めた実行をやり直しても、レビューは増えない(AC12・AC20)。""" + _seed(tmp_dir) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + state_mod.cmd_read_result(_args()) + posted_once = len([c for c in fake_gh.joined() if "--method POST" in c]) + + # 記録を消して、投稿だけが残った状態を作る。 + st = _state(tmp_dir) + st["rounds"][-1].pop(AGENT, None) + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text( + json.dumps(st), encoding="utf-8") + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", "stdout": json.dumps([{ + "user": {"login": ACTOR}, "state": "CHANGES_REQUESTED", "id": 555, + "body": f"## 🤖 cross-review | round {ROUND} | {AGENT} | REQUEST_CHANGES\n", + "html_url": _URL}])}, + ]) + + state_mod.cmd_read_result(_args()) + + assert len([c for c in fake_gh.joined() if "--method POST" in c]) == posted_once + assert _entry(tmp_dir)["review_url"] == _URL + + +def test_findings_without_an_inline_are_still_a_result( + tmp_dir, state_mod, fake_gh, capsys) -> None: + """指摘があってインラインが 0 件でも、その担当は結果なしにならない(AC17)。""" + _seed(tmp_dir) + _note(tmp_dir, comments=[{"body": SUMMARY_TEXT, "severity": "major"}]) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + entry = _entry(tmp_dir) + assert entry["intent"] == "REQUEST_CHANGES" + assert entry["comments"] == 0 + assert "INLINE=0 BODY=1" in capsys.readouterr().out + assert len(_state(tmp_dir)["review_findings"]) == 1 + + +def test_a_note_alone_is_treated_as_no_result(tmp_dir, state_mod, fake_gh) -> None: + """控えだけがあって結果ファイルが無ければ、投稿を 0 件にする(AC3・AC4)。""" + _seed(tmp_dir) + _note(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + with pytest.raises(SystemExit): + state_mod.cmd_read_result(_args()) + + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + + +def test_only_what_is_sent_is_downgraded_on_ones_own_pull_request( + tmp_dir, state_mod, fake_gh) -> None: + """自分の Pull Request では送った形だけを落とす(AC32)。""" + _seed(tmp_dir, is_own_pr=True, event_downgrade=True) + _note(tmp_dir) + _result(tmp_dir) + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + state_mod.cmd_read_result(_args()) + + sent = [c for c in fake_gh.calls() if "--method POST" in " ".join(c["argv"])] + assert json.loads(sent[-1]["stdin"])["event"] == "COMMENT" + entry = _entry(tmp_dir) + assert entry["intent"] == "REQUEST_CHANGES" and entry["posted_as"] == "COMMENT" + + +def test_a_reviewer_that_wrote_nothing_adds_no_review( + tmp_dir, state_mod, fake_gh) -> None: + """控えも結果も書かずに終わった担当では、レビューが 1 件も増えない(AC4)。""" + _seed(tmp_dir) + # 書きかけの一時の名前だけが残った状態も、正式の名前が無ければ結果なしである。 + (tmp_dir / f"{AGENT}-review-pr{PR}-round{ROUND}-payload.json.tmp").write_text("{") + fake_gh.set_rules([_NO_PRIOR, _ACCEPT]) + + with pytest.raises(SystemExit): + state_mod.cmd_read_result(_args()) + + assert [c for c in fake_gh.joined() if "--method POST" in c] == [] + assert _entry(tmp_dir)["intent"] == "NO_RESULT" diff --git a/plugins/ndf/skills/cross-review/tests/test_read_result_reason.py b/plugins/ndf/skills/cross-review/tests/test_read_result_reason.py new file mode 100644 index 00000000..d03423d6 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_read_result_reason.py @@ -0,0 +1,237 @@ +"""結果の取り込みが共通の結末を読み、理由と監視の詳細を残すことのテスト(#729 AC13)。 + +`read-result` は結果ファイルを自前で開かず、結末の共通層(`monitor_outcome`)の +`read_launch_outcome` を呼ぶ。使える結果が無いとき、`no_result_reason` に共通の `reason` +を残し、監視の結果ファイルがあれば `monitor_detail` に監視の `detail` を残す。 +終了コードは変更前と同じ(`unparsable` は 3、それ以外は 1)である。 + +| 監視の結果ファイル | 結果ファイル | `no_result_reason` | `monitor_detail` | 終了コード | +| --- | --- | --- | --- | --- | +| 監視が決めた理由(6 語) | 無い | その値 | 監視の `detail` | 1 | +| 監視が決めた理由(6 語) | 読めない | その値 | 監視の `detail` | 1 | +| 無い | 無い | `missing` | 鍵なし | 1 | +| 無い | 読めない | `unparsable` | 鍵なし | 3 | +| `ok` / `missing` | 無い | `missing` | 監視の `detail`(空なら鍵なし) | 1 | +| 問わない | 読める | (使える結果として取り込む) | — | 0 | + +stem の突き合わせ(設計文書の未確認 6)もここで固定する。起動の手順・監視・取り込みの +3 つが同じ形の stem を組み立てなければ、監視の結果ファイルを引けない。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import re + +import pytest + +PR = 7729 +AGENT = "kiro" +STEM = f"{AGENT}-review-pr{PR}" +DETAIL = "Monthly request limit reached" + +_LAUNCHER = pathlib.Path(__file__).resolve().parent.parent / "scripts" / "launch-reviewer.sh" + +# 監視が結末に書く理由のうち、結果ファイルの状態を見ずにその値を採るもの(設計文書の +# 「理由の語彙」の表で「監視」が書き、`ok` / `missing` でない 6 語)。 +MONITOR_DECIDED = ("timeout", "stalled", "early_error", "usage_limit", "cli_timeout", "pidfile_bad") + + +# ---------------- 足場 ---------------- + + +@pytest.fixture() +def tmp_dir(monkeypatch, tmp_path, state_mod): + """`CROSS_REVIEW_TMP_DIR` を tmp_path に向ける。""" + monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) + return tmp_path + + +@pytest.fixture(autouse=True) +def review_posted(monkeypatch, state_mod): + """投稿の実在確認は届いた前提にする。ここで見るのは結末の読み方である。""" + monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) + + +def _seed_state(tmp_dir: pathlib.Path) -> None: + state = { + "current_pr": PR, + "repo": "o/r", + "rounds": [{"round": 1, "pr": PR, "started_at": "2026-09-19T00:00:00+00:00"}], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + + +def _entry(tmp_dir: pathlib.Path) -> dict: + st = json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) + return st["rounds"][-1][AGENT] + + +def _write_monitor(tmp_dir: pathlib.Path, reason: str, detail: str = DETAIL) -> None: + """監視の結果ファイル `-monitor.json` を置く。キーは監視が書く形に倣う。""" + outcome = { + "agent": AGENT, + "stem": STEM, + "status": "EARLY_ERROR", + "exit_code": 4, + "reason": reason, + "detail": detail, + } + (tmp_dir / f"{STEM}-monitor.json").write_text(json.dumps(outcome, ensure_ascii=False)) + + +def _read_result(state_mod, rfile: pathlib.Path | None) -> int: + """`read-result` を呼び、終了コードを返す。`rfile` が None なら既定のパスを使う。""" + args = argparse.Namespace(pr=PR, agent=AGENT, file=str(rfile) if rfile else None) + with pytest.raises(SystemExit) as e: + state_mod.cmd_read_result(args) + return int(e.value.code or 0) + + +# ---------------- AC13: 理由と監視の詳細を残す ---------------- + + +@pytest.mark.parametrize("reason", MONITOR_DECIDED) +def test_the_monitor_reason_is_recorded_when_no_result_file_exists( + tmp_dir, state_mod, reason, capsys +): + """監視が理由を決めていれば、結果ファイルが無いときその理由と詳細が残る。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, reason) + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["intent"] == "NO_RESULT" + assert entry["no_result_reason"] == reason + assert entry["monitor_detail"] == DETAIL + err = capsys.readouterr().err + assert reason in err + assert DETAIL in err + + +def test_the_monitor_reason_wins_over_an_unreadable_result_file(tmp_dir, state_mod): + """監視が止めたと分かっているなら、読めない結果ファイルがあってもその理由で 1 で止まる。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "usage_limit") + rfile = tmp_dir / "result.json" + rfile.write_text("{ not json") + + assert _read_result(state_mod, rfile) == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "usage_limit" + assert entry["monitor_detail"] == DETAIL + + +def test_without_a_monitor_file_the_reason_is_missing_and_no_detail_key_is_written( + tmp_dir, state_mod +): + """監視の結果ファイルが無ければ `missing` で、`monitor_detail` の鍵そのものが無い。""" + _seed_state(tmp_dir) + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "missing" + assert "monitor_detail" not in entry + + +def test_an_unparsable_result_without_a_monitor_file_keeps_exit_code_3(tmp_dir, state_mod): + """監視の結果ファイルが無く結果が読めなければ、`unparsable` で従来どおり 3 で止まる。""" + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps([{"event": "APPROVE"}])) + + assert _read_result(state_mod, rfile) == 3 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "unparsable" + assert "monitor_detail" not in entry + + +@pytest.mark.parametrize("reason", ("ok", "missing")) +def test_a_monitor_that_did_not_decide_falls_back_to_the_result_file( + tmp_dir, state_mod, reason +): + """監視が `ok` / `missing` なら理由は結果ファイルの側で決まり、詳細だけ残る。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, reason, detail="exit 0") + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "missing" + assert entry["monitor_detail"] == "exit 0" + + +def test_an_empty_monitor_detail_does_not_write_the_key(tmp_dir, state_mod): + """監視の結果ファイルがあっても `detail` が空なら、`monitor_detail` を書かない。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "timeout", detail="") + + assert _read_result(state_mod, tmp_dir / "absent-result.json") == 1 + + entry = _entry(tmp_dir) + assert entry["no_result_reason"] == "timeout" + assert "monitor_detail" not in entry + + +def test_a_readable_result_file_wins_over_the_monitor_reason(tmp_dir, state_mod): + """監視が利用上限で止めた後でも、結果ファイルが読めればそれは使える結果である。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "usage_limit") + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps({ + "event": "APPROVE", "posted_as": "APPROVE", "comments_count": 0, + "review_url": "https://example/pr/1#1", "by_severity": {}, + })) + + state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent=AGENT, file=str(rfile))) + + entry = _entry(tmp_dir) + assert entry["intent"] == "APPROVE" + assert "no_result_reason" not in entry + + +def test_no_verdict_still_overrides_after_the_common_read(tmp_dir, state_mod): + """判定の値が無い結果は、共通の読み取りの後で cross-review が `no_verdict` に上書きする。""" + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "ok", detail="exit 0") + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps({"comments_count": 0})) + + assert _read_result(state_mod, rfile) == 1 + assert _entry(tmp_dir)["no_result_reason"] == "no_verdict" + + +# ---------------- 未確認 6: stem の突き合わせ ---------------- + + +def _launcher_stem_pattern() -> str: + """`launch-reviewer.sh` の `STEM=` の行から、ディレクトリを除いた stem の形を取る。""" + lines = [l for l in _LAUNCHER.read_text(encoding="utf-8").splitlines() + if re.match(r"^\s*STEM=", l)] + assert len(lines) == 1, lines + value = lines[0].split("=", 1)[1].strip() + assert value.startswith("$TMP_DIR/"), value + return value[len("$TMP_DIR/"):] + + +def test_the_three_stems_have_the_same_shape(tmp_dir, state_mod, monitor_mod): + """起動の手順・監視・取り込みの stem が同じ形で、監視の結果ファイルを引ける。""" + # 起動の手順(bash): 変数名を置き換えて形を比べる + # 起動の手順が持つのは席の名前(`$SEAT`)である。CLI はそこから引く(#727) + launcher = (_launcher_stem_pattern() + .replace("$SEAT", AGENT).replace("$STATE_PR", str(PR))) + # 監視(Python): 既定の stem テンプレート + monitor = monitor_mod.DEFAULT_STEM_TEMPLATE.format(agent=AGENT, id=PR) + assert launcher == monitor == STEM + + # 取り込み: 既定のパス(`--file` 無し)で、監視の結果ファイルの理由を引ける + _seed_state(tmp_dir) + _write_monitor(tmp_dir, "usage_limit") + assert _read_result(state_mod, None) == 1 + assert _entry(tmp_dir)["no_result_reason"] == "usage_limit" diff --git a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py index 1041e192..822a8197 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_rejected_findings.py @@ -92,7 +92,8 @@ def test_init_stores_an_empty_rejected_findings_list(tmp_dir, state_mod, monkeyp monkeypatch.setattr(state_mod, "_sync_worktree", lambda *args: None) monkeypatch.setattr(state_mod.subprocess, "run", lambda *args, **kwargs: subprocess.CompletedProcess(args[0], 0, stdout="", stderr="")) - monkeypatch.setattr(state_mod.auth, "check_auth", lambda *args, **kwargs: None) + monkeypatch.setattr(state_mod.auth, "probe_auth", lambda runtimes, **kwargs: ( + {r: {"command": r, "ok": True, "detail": ""} for r in runtimes}, False)) state_mod.cmd_init(argparse.Namespace( pr=PR, max_rounds=12, rotate_after=8, only=None, worktree=str(worktree), diff --git a/plugins/ndf/skills/cross-review/tests/test_review_findings.py b/plugins/ndf/skills/cross-review/tests/test_review_findings.py index 19ca5a56..2be1f07c 100644 --- a/plugins/ndf/skills/cross-review/tests/test_review_findings.py +++ b/plugins/ndf/skills/cross-review/tests/test_review_findings.py @@ -74,8 +74,7 @@ def tmp_dir(monkeypatch, tmp_path, state_mod): @pytest.fixture(autouse=True) def _no_github_checks(monkeypatch, state_mod): - """GitHub 側の突き合わせを通す。取り込みの形だけを見る。""" - monkeypatch.setattr(state_mod, "_posted_comment_count", lambda *a, **k: None) + """流した後の実在確認を通す。取り込みの形だけを見る。""" monkeypatch.setattr(state_mod, "_review_exists", lambda *a, **k: True) @@ -203,14 +202,18 @@ def test_a_state_file_without_the_key_is_readable(tmp_dir, state_mod): assert len(_read(tmp_dir)["review_findings"]) == 1 -def test_the_comment_count_is_unchanged(tmp_dir, state_mod): - """`comments_count` は投稿したインラインの数のままにする。""" +def test_the_comment_count_is_the_number_of_inlines_sent(tmp_dir, state_mod): + """件数は担当の申告ではなく、送れたインラインの数である(#730 AC15)。 + + 送り先を決めるのは投稿する側で、控えの `posted_to` は読まない。位置を持つ指摘は + どちらもインラインとして送る。 + """ _write(tmp_dir, _state()); _result(tmp_dir, comments_count=3) _payload(tmp_dir, [FULL, {**FULL, "posted_to": "body"}]) _read_result(state_mod) - assert _read(tmp_dir)["rounds"][-1][AGENT]["comments"] == 3 + assert _read(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 def test_a_missing_payload_does_not_break_the_import(tmp_dir, state_mod): @@ -336,12 +339,18 @@ def test_a_failed_reimport_does_not_erase_what_was_taken(tmp_dir, state_mod): SKILL = pathlib.Path(__file__).resolve().parents[1] -def test_the_prompt_asks_for_the_four_items(): +def test_the_prompt_asks_for_the_three_items(): text = (SKILL / "scripts/launch-reviewer.sh").read_text(encoding="utf-8") - for key in ("evidence", "falsification", "suggested_check", "posted_to"): + for key in ("evidence", "falsification", "suggested_check"): assert key in text, key +def test_the_prompt_does_not_ask_where_it_was_posted(): + """送れた先(`posted_to`)は投稿する側が書く。担当には書かせない(#730)。""" + text = (SKILL / "scripts/launch-reviewer.sh").read_text(encoding="utf-8") + assert "posted_to" not in text + + def test_the_prompt_asks_for_body_only_findings(): """総評だけへ書いた指摘も payload へ載せることを求める。""" text = (SKILL / "scripts/launch-reviewer.sh").read_text(encoding="utf-8") diff --git a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py index 238c3742..d1865dc2 100644 --- a/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py +++ b/plugins/ndf/skills/cross-review/tests/test_rotate_pr_queue.py @@ -221,7 +221,7 @@ def __init__(self, tmp_path: pathlib.Path) -> None: encoding="utf-8", ) - def run(self, create_ok: bool) -> subprocess.CompletedProcess[str]: + def run(self, create_ok: bool, mode: str = "light") -> subprocess.CompletedProcess[str]: env = { **os.environ, "PATH": f"{self.bin}{os.pathsep}{os.environ['PATH']}", @@ -231,7 +231,7 @@ def run(self, create_ok: bool) -> subprocess.CompletedProcess[str]: "GH_CREATE": "ok" if create_ok else "fail", } return subprocess.run( - ["bash", str(ROTATE), "execute", str(_STATE_PR), "--mode", "light"], + ["bash", str(ROTATE), "execute", str(_STATE_PR), "--mode", mode], capture_output=True, text=True, timeout=180, env=env, ) @@ -273,6 +273,36 @@ def test_a_create_success_closes_the_old_pr_and_opens_the_new_pr(rotation: _Rota assert not any(c.startswith("pr reopen") for c in rotation.gh_calls()) +def test_a_create_failure_in_squash_mode_reopens_the_old_pr_and_emits_no_new_pr( + rotation: _Rotation, +) -> None: + """現状固定: squash モードでも新 PR 作成が失敗すると非ゼロ終了で旧 PR が open へ戻り、NEW_PR は出ない。""" + out = rotation.run(create_ok=False, mode="squash") + + assert out.returncode != 0, out.stderr + states = rotation.pr_states() + assert states[str(_OLD_PR)] == "open" # reopen で戻る + assert str(_NEW_PR) not in states # 新 PR は作られていない + assert "NEW_PR=" not in out.stdout # 成功結果を出力していない + joined = rotation.gh_calls() + assert any(c.startswith(f"pr close {_OLD_PR}") for c in joined) + assert any(c.startswith(f"pr reopen {_OLD_PR}") for c in joined) + + +def test_a_create_success_in_squash_mode_closes_the_old_pr_and_opens_the_new_pr( + rotation: _Rotation, +) -> None: + """比較用: squash モードで作成が成功すると旧 PR は closed、新 PR は open、NEW_PR が作成結果を指す。""" + out = rotation.run(create_ok=True, mode="squash") + + assert out.returncode == 0, out.stderr + states = rotation.pr_states() + assert states[str(_OLD_PR)] == "closed" + assert states[str(_NEW_PR)] == "open" + assert f"NEW_PR={_NEW_PR}" in out.stdout + assert not any(c.startswith("pr reopen") for c in rotation.gh_calls()) + + # ---- execute の引数検証(R2-004、現状固定) ---- # # `--mode` の値検証と未知フラグの検出は gh/git を一切呼ばない純粋な引数解析であり、 @@ -297,3 +327,138 @@ def test_an_unknown_flag_is_rejected() -> None: assert out.returncode == 2 assert "unknown arg: --unknown-flag" in out.stderr + + +def test_mode_without_a_value_is_rejected() -> None: + """現状固定(R2-002)。`--mode` の直後に値が無いと `${2:?...}` で落ちる。 + + state.json も newtext.json も用意せず、gh/git を呼ぶ前の引数解析だけで止まる。 + `${2:?...}` は set -u と相まって execute のループに入る前に落ちるため、 + load_state(state.json 読み込み)にも到達しない。 + """ + out = subprocess.run( + ["bash", str(ROTATE), "execute", "123", "--mode"], + capture_output=True, text=True, timeout=60, + ) + + assert out.returncode != 0 + assert "--mode requires light|squash" in out.stderr + + +def test_execute_stops_when_state_json_is_missing(tmp_path) -> None: + """現状固定(R2-004)。state 不在なら外部コマンドを呼ばずに終了する。""" + tmp_dir = tmp_path / "tmp" + bin_dir = tmp_path / "bin" + calls = tmp_path / "calls.log" + tmp_dir.mkdir() + bin_dir.mkdir() + calls.write_text("", encoding="utf-8") + + fake_command = "#!/usr/bin/env bash\nprintf '%s\\n' \"$0 $*\" >> \"$CALLS\"\n" + for command in ("gh", "git"): + executable = bin_dir / command + executable.write_text(fake_command, encoding="utf-8") + executable.chmod(0o755) + + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "CROSS_REVIEW_TMP_DIR": str(tmp_dir), + "CALLS": str(calls), + } + out = subprocess.run( + ["bash", str(ROTATE), "execute", str(_STATE_PR)], + capture_output=True, text=True, timeout=60, env=env, + ) + + assert out.returncode == 1 + assert "state.json not found" in out.stderr + assert calls.read_text(encoding="utf-8") == "" + + +def test_no_arguments_prints_usage() -> None: + """現状固定(R2-002)。引数が 0 個のとき entrypoint は usage を出して exit 2。 + + state.json を用意せず、引数解析だけで止まることを確かめる。 + """ + out = subprocess.run( + ["bash", str(ROTATE)], + capture_output=True, text=True, timeout=60, + ) + + assert out.returncode == 2 + assert "Usage:" in out.stderr + + +def test_prepare_connects_state_pr_metadata_and_git_summary(tmp_path) -> None: + """現状固定: 公開 CLI が prepare.json と eval 用の代入を組み立てる。""" + state_pr = 41 + current_pr = 43 + tmp_dir = tmp_path / "tmp" + worktree = tmp_path / "worktree" + bin_dir = tmp_path / "bin" + tmp_dir.mkdir() + worktree.mkdir() + bin_dir.mkdir() + (tmp_dir / f"cross-review-pr{state_pr}-state.json").write_text(json.dumps({ + "worktree_path": str(worktree), + "current_pr": current_pr, + "repo": "o/r", + "viewer_login": "tester", + "rounds": [ + {"round": 1, "pr": 40}, + {"round": 2, "pr": current_pr}, + {"round": 3, "pr": current_pr}, + ], + }), encoding="utf-8") + (bin_dir / "gh").write_text( + "#!/usr/bin/env bash\n" + "printf '%s\\n' '{\"number\":43,\"url\":\"https://github.com/o/r/pull/43\"," + "\"title\":\"Current title\",\"body\":\"Current body\"," + "\"headRefName\":\"feature/prepare\",\"baseRefName\":\"develop\"," + "\"isDraft\":true}'\n", encoding="utf-8") + (bin_dir / "git").write_text( + "#!/usr/bin/env bash\n" + "case \"$1\" in\n" + " fetch|rev-parse) exit 0 ;;\n" + " log) printf 'abc123 First commit\\ndef456 Second commit\\n' ;;\n" + " diff) printf ' a.py | 2 ++\\n 1 file changed, 2 insertions(+)\\n' ;;\n" + " *) exit 3 ;;\n" + "esac\n", encoding="utf-8") + for command in ("gh", "git"): + (bin_dir / command).chmod(0o755) + + env = { + **os.environ, + "PATH": f"{bin_dir}{os.pathsep}{os.environ['PATH']}", + "CROSS_REVIEW_TMP_DIR": str(tmp_dir), + } + out = subprocess.run( + ["bash", str(ROTATE), "prepare", str(state_pr)], + capture_output=True, text=True, env=env, check=False, + ) + + assert out.returncode == 0, out.stderr + evaluated = subprocess.run( + ["bash", "-c", + 'eval "$1"; printf "%s\\n" "$PREPARE_JSON" "$OLD_PR" "$HEAD_BRANCH" ' + '"$BASE_BRANCH" "$IS_DRAFT"', "bash", out.stdout], + capture_output=True, text=True, check=True, + ).stdout.splitlines() + prepare_path = tmp_dir / f"rotate-pr{state_pr}-prepare.json" + assert evaluated == [ + str(prepare_path), str(current_pr), "feature/prepare", "develop", "true"] + assert json.loads(prepare_path.read_text(encoding="utf-8")) == { + "state_pr": state_pr, + "old_pr": current_pr, + "old_pr_url": "https://github.com/o/r/pull/43", + "worktree_path": str(worktree), + "head_branch": "feature/prepare", + "base_branch": "develop", + "is_draft": True, + "round_in_pr": 2, + "old_title": "Current title", + "old_body": "Current body", + "git_log": "abc123 First commit\ndef456 Second commit", + "git_diff_stat": " a.py | 2 ++\n 1 file changed, 2 insertions(+)", + } diff --git a/plugins/ndf/skills/cross-review/tests/test_seat_names.py b/plugins/ndf/skills/cross-review/tests/test_seat_names.py new file mode 100644 index 00000000..b09efd76 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_seat_names.py @@ -0,0 +1,287 @@ +"""席の名前を受け口が通すか(#727 の AC21)。 + +担当の単位は「席の名前」になった(設計の決定 10)。形は `assignment.SEAT_PATTERN` +(ランタイム名か、その名前に `-2`〜`-9` を付けたもの)。使える者が 2 者に満たない +ラウンドでは、同じランタイムの 2 つ目(`claude-2`)が席に入る。**受け口がこの形を +弾くと、結果を残した担当が「結果なし」として扱われる。** + +見るのは結果の受け口・起動スクリプト・監視の位置引数・計測の 4 つである。綴りの検査は +argparse の型が行い、通らなければ終了コード 2 になる。シェル側は席の形に合わない名前を +終了コード 1 で弾く。 +""" +from __future__ import annotations + +import argparse +import json +import pathlib +import sys + +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" + + +# ---------------- 起動スクリプト ---------------- +# +# 起動スクリプトは席の名前を受け、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" + + +# ---------------- 監視の上限と無進捗の許容 ---------------- + +@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): + """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_bg_wait.py b/plugins/ndf/skills/cross-review/tests/test_skill_bg_wait.py index 10b59100..a9f2c066 100644 --- a/plugins/ndf/skills/cross-review/tests/test_skill_bg_wait.py +++ b/plugins/ndf/skills/cross-review/tests/test_skill_bg_wait.py @@ -1,7 +1,7 @@ """cross-review の骨組みは 600 秒を超える監視を区切って待つ(#598 / #537 の AC41 / AC42)。 レビューの監視の上限は 1200 秒で、Claude Code の Bash ツールの 1 回(600 秒)に収まらない。 -骨組みはレビューの監視(起動し直しを含む)と `critique-round.sh` を `bg-wait.sh run` で +骨組みはレビューの監視(起動し直しも同じ行を通る、#583)と `critique-round.sh` を `bg-wait.sh run` で 背景に回し、`bg-wait.sh wait`(1 回 540 秒以内)を 124 のあいだ繰り返す。 """ from __future__ import annotations @@ -38,15 +38,15 @@ def _long_runner_lines() -> list[tuple[int, str]]: if not line.lstrip().startswith("#") and any(r in line for r in LONG_RUNNERS)] -def test_the_skeleton_has_the_three_long_runners() -> None: - """レビューの監視・起動し直しの監視・反証の 1 ラウンド。""" +def test_the_skeleton_has_the_two_long_runners() -> None: + """レビューの監視と反証の 1 ラウンド。起動し直しは同じ監視の行を通る(#583)。""" found = _long_runner_lines() - assert len(found) == 3 - assert sum('"$SCRIPTS/monitor.py"' in line for _, line in found) == 2 + assert len(found) == 2 + assert sum('"$SCRIPTS/monitor.py"' in line for _, line in found) == 1 assert sum('"$SCRIPTS/critique-round.sh"' in line for _, line in found) == 1 -@pytest.mark.parametrize("index", range(3)) +@pytest.mark.parametrize("index", range(2)) def test_each_long_runner_is_started_by_bg_wait_run(index: int) -> None: _, line = _long_runner_lines()[index] runner = next(r for r in LONG_RUNNERS if r in line) @@ -54,7 +54,7 @@ def test_each_long_runner_is_started_by_bg_wait_run(index: int) -> None: assert re.search(r'"\$SCRIPTS/bg-wait\.sh" run "[^"]+" -- $', head), line -@pytest.mark.parametrize("index", range(3)) +@pytest.mark.parametrize("index", range(2)) def test_each_run_is_followed_by_one_wait_on_the_same_rc(index: int) -> None: """**待ちは 1 回の呼び出しに 1 つだけ書く。** 繰り返しを 1 回の Bash へ書くと、 2 回目の待ちに入った時点で合計が 600 秒を超え、ホストに打ち切られる(#683 round 2)。""" diff --git a/plugins/ndf/skills/cross-review/tests/test_skill_layout.py b/plugins/ndf/skills/cross-review/tests/test_skill_layout.py index 07e75981..e5ed590d 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,97 @@ 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 + + +# ---- 区分の 6 つ目「未反証」(#732) ---- +# +# 誤りを示されていない `major` を `unrefuted` として数える。規約 3 文書・反証のプロンプト・ +# 確定仕様が同じ語で書いていることを固定する(AC18〜AC20)。 + +CRITIQUE_SH = HERE / "scripts/critique.sh" +EVIDENCE = DOCS / "06-evidence.md" +POOL = DOCS / "05-pool-and-convergence.md" +SPEC = HERE.parents[3] / "docs/specifications/cross-review-evidence-based.md" + + +def test_the_critique_prompt_says_insufficient_evidence_does_not_drop_the_finding() -> None: + """「立証できない」を返しても指摘は数から落ちないことを、プロンプトが担当へ言う(AC19)。""" + assert "数から落ち" in CRITIQUE_SH.read_text(encoding="utf-8") + + +@pytest.mark.parametrize("doc", (EVIDENCE, CONTRACTS, POOL), ids=lambda p: p.name) +def test_the_review_docs_name_the_unrefuted_classification(doc: pathlib.Path) -> None: + assert "unrefuted" in doc.read_text(encoding="utf-8"), doc.name + + +def test_the_evidence_doc_says_the_mark_is_removed_when_critiques_are_incomplete() -> None: + assert "印を外す" in EVIDENCE.read_text(encoding="utf-8") + + +def test_the_specification_holds_the_same_six_classifications() -> None: + """区分の表と行き先の表の両方が `unrefuted` を持つ(AC20)。""" + assert SPEC.read_text(encoding="utf-8").count("unrefuted") >= 2 + + +# ---------- 起動し直しは初回と同じ経路を通る(#583 #730) ---------- + + +def _loop() -> str: + """骨組みの繰り返し(`while :; do` から `done` まで)。""" + text = SKILL.read_text(encoding="utf-8") + block = text.split("## 実行ステップ概要(メインの bash 骨組み)", 1)[1] + block = block.split("```bash", 1)[1].split("\n```", 1)[0] + return block.split("while :; do", 1)[1] + + +@pytest.mark.parametrize("step", [ + '"$SCRIPTS/launch-reviewer.sh"', + '"$SCRIPTS/state.py" read-result', + '"$SCRIPTS/state.py" verify-findings', + '"$SCRIPTS/critique-round.sh"', +]) +def test_each_step_of_a_review_is_written_once(step: str) -> None: + """起動し直しの枝が自分の起動・取り込みを持たない。経路は 1 本である(AC18)。 + + 枝が別に持つと、根拠の検証と反証を飛ばして 2 度目の判定へ進む。 + """ + assert _loop().count(step) == 1, step + + +def test_the_relaunch_goes_back_to_the_head_of_the_review() -> None: + loop = _loop() + relaunch = loop.index('"$JUDGE_RC" -eq 7') + assert "continue" in loop[relaunch:loop.index("done", relaunch)] + + +def test_the_queue_branch_is_seen_before_the_relaunch() -> None: + """待ち行列に残りがあるときの枝は、各判定の直後、7 より先に見る(AC19)。""" + loop = _loop() + assert loop.index('"$JUDGE_RC" -eq 8') < loop.index('"$JUDGE_RC" -eq 7') diff --git a/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py b/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py index 72c3ff55..58da6c4e 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_not_posted.py @@ -1,19 +1,18 @@ -"""投稿が届いていないレビューを結果なしとして扱う(#261)。 +"""投稿が届いたことを、送った後に確かめる(#261 #730)。 -レビュアーが投稿に失敗したとき、判定だけが残り、指摘の中身が Pull Request に無いまま -修正の工程へ進んでいた。修正の担当は Pull Request のコメントを読んで直すため、指摘が -無ければ直すものが無く、ラウンドだけが 1 つ増える。 +**投稿するのはレビューを回す側である**(#730)。担当は投稿せず、結果ファイルに +投稿の失敗や参照を申告しない。取り込みは送信の応答をそのまま記録にするため、 +申告を読んで結果なしにする経路は無い。 -| 結果ファイルの状態 | 扱い | +残るのは、上限で積んだ投稿を後から流したときの確かめである。流した直後に参照から +照会し、届いていなければ結果なしとして記録して、判定の「同じラウンドで 1 度だけ +起動し直す」経路へ乗せる(`_confirm_flushed`)。この文書はその照会の振る舞いを見る。 + +| 照会の結果 | 扱い | | --- | --- | -| `post_error` に値がある | 届いていない。結果なしとして記録する | -| `review_url` が空、または識別子を取り出せない | 届いていない。結果なしとして記録する | -| 識別子から照会してレビューが存在する | 届いた。これまでどおり取り込む | -| 識別子から照会できない | 申告を採用し、確認できなかったことを出力へ残す | - -結果なしとして記録すると、判定(`state.py judge`)の「同じラウンドで 1 度だけ起動し直す」 -経路へ乗る。修正の担当から見ると、結果が残らなかった場合と、結果はあるが指摘が届いて -いない場合は同じ状態である(読むべき指摘が無い)。 +| 識別子から照会してレビューが存在する | 届いた | +| 識別子を取り出せない | 届いていない | +| 照会できない・何も返らない | 分からない(届いていないとは読まない) | """ from __future__ import annotations @@ -66,54 +65,6 @@ def _round(tmp_dir: pathlib.Path) -> dict: return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text())["rounds"][-1] -def test_a_post_error_is_recorded_as_no_result(tmp_dir, state_mod, monkeypatch): - """実測の形。`post_error` があり、`review_url` も空で件数もすべて 0。""" - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result( - _args(_result(tmp_dir, review_url="", post_error="gh api failed")) - ) - - assert e.value.code == 1 - assert _round(tmp_dir)[AGENT]["intent"] == "NO_RESULT" - assert _round(tmp_dir)[AGENT]["no_result_reason"] == "not_posted" - - -def test_an_empty_review_url_is_recorded_as_no_result(tmp_dir, state_mod, monkeypatch): - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "") - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir, review_url=""))) - - assert e.value.code == 1 - assert _round(tmp_dir)[AGENT]["no_result_reason"] == "not_posted" - - -def test_a_review_missing_on_github_is_recorded_as_no_result(tmp_dir, state_mod, monkeypatch): - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: False) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert e.value.code == 1 - assert _round(tmp_dir)[AGENT]["no_result_reason"] == "not_posted" - - -def test_an_unavailable_lookup_keeps_the_declaration(tmp_dir, state_mod, monkeypatch, capsys): - """照会できないときは申告を採用する。通信の失敗でループを止めない。""" - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: None) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _round(tmp_dir)[AGENT]["intent"] == "REQUEST_CHANGES" - assert "確認できませんでした" in capsys.readouterr().err - - def test_a_posted_review_is_merged(tmp_dir, state_mod, monkeypatch): _seed_state(tmp_dir) monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) @@ -159,16 +110,3 @@ def test_the_lookup_is_none_when_the_api_returns_nothing(state_mod, monkeypatch) monkeypatch.setattr(state_mod, "_sh", lambda cmd, check=True: "") assert state_mod._review_exists("o/r", PR, REVIEW_URL) is None - - -def test_a_no_result_record_sends_the_judge_to_a_relaunch(tmp_dir, state_mod, monkeypatch): - """結果なしの記録を受けて、判定が起動し直しへ進む(終了コード 7)。""" - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: False) - with pytest.raises(SystemExit): - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_judge(argparse.Namespace(pr=PR)) - - assert e.value.code == 7 diff --git a/plugins/ndf/skills/cross-review/tests/test_state_posted_comments.py b/plugins/ndf/skills/cross-review/tests/test_state_posted_comments.py deleted file mode 100644 index bf53ee2a..00000000 --- a/plugins/ndf/skills/cross-review/tests/test_state_posted_comments.py +++ /dev/null @@ -1,199 +0,0 @@ -"""申告されたインラインコメント数を、GitHub 側の実数と突き合わせる。 - -レビューの投稿は AI 自身が `gh api` で行うため、**投稿に失敗しても結果ファイルの -申告だけは残る**。申告を信じて先へ進むと、修正担当が読むべき指摘が GitHub 上に -存在しないまま収束判定まで走る。実測では 2 件の申告に対しスレッドが 1 つも -作られていなかった。 - -| 申告 | GitHub 側 | 扱い | -| --- | --- | --- | -| 0 件 | 見に行かない | 投稿が無いので突き合わせる相手がいない | -| 2 件 | 2 件 | そのまま採用する | -| 2 件 | 0 件 | 投稿が届いていないので中断する | -| 2 件 | 取得できない | 申告を採用し、確認できなかったことを残す | - -「取得できなかった」と「0 件」を混同しない。取得の失敗で止めると、GitHub 側の -一時的な不調でループが進まなくなる。 -""" -from __future__ import annotations - -import argparse -import json -import pathlib - -import pytest - -PR = 4242 -AGENT = "agy" -REVIEW_URL = f"https://github.com/o/r/pull/{PR}#pullrequestreview-4961230016" - - -def _seed_state(tmp_dir: pathlib.Path) -> None: - state = { - "current_pr": PR, - "repo": "o/r", - "rounds": [{"round": 1, "pr": PR, "started_at": "2026-08-18T00:00:00+00:00"}], - "final": None, - } - (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) - - -def _result(tmp_dir: pathlib.Path, **over) -> pathlib.Path: - payload = { - "event": "REQUEST_CHANGES", - "posted_as": "REQUEST_CHANGES", - "comments_count": 2, - "review_url": REVIEW_URL, - "by_severity": {"major": 2}, - } - payload.update(over) - rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps(payload)) - return rfile - - -def _args(rfile: pathlib.Path) -> argparse.Namespace: - return argparse.Namespace(pr=PR, agent=AGENT, file=str(rfile)) - - -def _read_state(tmp_dir: pathlib.Path) -> dict: - return json.loads((tmp_dir / f"cross-review-pr{PR}-state.json").read_text()) - - -@pytest.fixture() -def tmp_dir(monkeypatch, tmp_path, state_mod): - monkeypatch.setenv("CROSS_REVIEW_TMP_DIR", str(tmp_path)) - return tmp_path - - -@pytest.fixture(autouse=True) -def review_posted(monkeypatch, state_mod): - """投稿の実在確認は届いた前提にする。件数の突き合わせだけを見るため。""" - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) - - -@pytest.fixture() -def posted(monkeypatch, state_mod): - """GitHub 側の件数を差し替える。`None` は取得できなかったことを表す。""" - def _set(count): - monkeypatch.setattr( - state_mod, "_posted_comment_count", - lambda repo, pr, review_url: count, - ) - return _set - - -def test_declared_count_matching_github_is_accepted(tmp_dir, state_mod, posted): - _seed_state(tmp_dir) - posted(2) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 - - -def test_declared_comments_missing_on_github_aborts(tmp_dir, state_mod, posted): - """申告があるのに GitHub 側へ届いていなければ中断する。 - - そのまま進むと、修正担当が読むべき指摘が存在しないまま収束判定まで走る。 - """ - _seed_state(tmp_dir) - posted(0) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert e.value.code == 1 - assert AGENT not in _read_state(tmp_dir)["rounds"][-1] - - -def test_partially_posted_comments_abort(tmp_dir, state_mod, posted): - """一部しか届いていない場合も中断する。取りこぼしは全件欠落と同じ扱いにする。""" - _seed_state(tmp_dir) - posted(1) - - with pytest.raises(SystemExit): - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - -def test_more_comments_on_github_is_accepted(tmp_dir, state_mod, posted): - """GitHub 側が多い分には通す。人の追記など、申告以外の経路で増えうる。""" - _seed_state(tmp_dir) - posted(3) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 - - -def test_zero_declared_skips_the_check(tmp_dir, state_mod, monkeypatch): - """申告 0 件なら GitHub を見に行かない。""" - _seed_state(tmp_dir) - called: list = [] - monkeypatch.setattr( - state_mod, "_posted_comment_count", - lambda *a, **k: called.append(a) or 0, - ) - - state_mod.cmd_read_result(_args(_result(tmp_dir, event="APPROVE", comments_count=0))) - - assert called == [] - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 0 - - -def test_unavailable_github_count_keeps_the_declaration(tmp_dir, state_mod, posted): - """GitHub 側を取得できなければ申告を採用する。取得失敗で止めない。""" - _seed_state(tmp_dir) - posted(None) - - state_mod.cmd_read_result(_args(_result(tmp_dir))) - - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["comments"] == 2 - - -def test_missing_review_url_is_treated_as_not_posted(tmp_dir, state_mod, monkeypatch): - """投稿先の参照が無ければ、レビューが届いていないものとして扱う(#261)。 - - 件数の突き合わせより前に、投稿そのものが届いたかを見る。 - """ - _seed_state(tmp_dir) - monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: False) - - with pytest.raises(SystemExit) as e: - state_mod.cmd_read_result(_args(_result(tmp_dir, review_url=None))) - - assert e.value.code == 1 - assert _read_state(tmp_dir)["rounds"][-1][AGENT]["intent"] == "NO_RESULT" - - -# ---------------- 件数の取得 ---------------- - -def test_posted_count_reads_the_review_id_from_the_url(state_mod, monkeypatch): - calls: list[list[str]] = [] - monkeypatch.setattr( - state_mod, "_sh", - lambda cmd, check=True: calls.append(list(cmd)) or "2", - ) - - count = state_mod._posted_comment_count("o/r", PR, REVIEW_URL) - - assert count == 2 - assert calls and "repos/o/r/pulls/4242/reviews/4961230016/comments" in calls[0] - - -def test_posted_count_is_none_when_the_url_has_no_review_id(state_mod, monkeypatch): - monkeypatch.setattr( - state_mod, "_sh", - lambda cmd, check=True: pytest.fail("識別子が無いのに GitHub を呼んでいる"), - ) - - assert state_mod._posted_comment_count("o/r", PR, "https://example.test/") is None - - -def test_posted_count_is_none_when_the_api_fails(state_mod, monkeypatch): - def boom(cmd, check=True): - raise RuntimeError("network") - - monkeypatch.setattr(state_mod, "_sh", boom) - - assert state_mod._posted_comment_count("o/r", PR, REVIEW_URL) is None diff --git a/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py b/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py index bae7a2a0..79252286 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_queue_judge.py @@ -5,8 +5,8 @@ | 段階 | #261 の検査 | 待ち行列を入れた後 | | --- | --- | --- | -| 結果を取り込む | 投稿の失敗があれば結果なし | `queued` が真の結果はこの検査を通す | -| 結果を取り込む | 投稿先の参照の存在を照会 | `queued` が真のときは照会しない(識別子がまだ無い) | +| 結果を取り込む | 投稿の失敗があれば結果なし | 取り込みが自分で送る。上限で送れなければ積んで `queued` を真にする(#730) | +| 結果を取り込む | 投稿先の参照の存在を照会 | 照会しない。参照は送信の応答から取る(#730) | | 流した直後 | — | 参照を書き戻し、存在を 1 度だけ確かめる | | 判定 | 収束 | 待ち行列が空のときだけ | """ @@ -186,51 +186,28 @@ def test_a_review_that_did_not_arrive_is_recorded_as_no_result( # ---- 受け入れ条件 13 ---- -def test_a_queued_result_skips_the_arrival_check(state_mod, tmp_dir, - monkeypatch) -> None: - """積んだ時点では届いていない。照会すると結果なしになり、二重に積まれる。""" +def test_a_post_refused_by_the_limit_is_queued_and_recorded( + state_mod, queue_mod, fake_gh, tmp_dir) -> None: + """上限で送れなかった投稿は積まれ、記録は `queued` になる(#730)。""" _seed(tmp_dir, rounds=[{"round": 1, "pr": PR, "started_at": "2026-09-03T00:00:00+00:00"}]) - called: list = [] - monkeypatch.setattr(state_mod, "_review_exists", - lambda *a: called.append(a) or True) - monkeypatch.setattr(state_mod, "_posted_comment_count", - lambda *a: called.append(a) or 0) rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps({ - "event": "REQUEST_CHANGES", "posted_as": "COMMENT", "comments_count": 3, - "review_url": "", "queued": True, - "post_error": "API rate limit exceeded", - "by_severity": {"major": 3}, - }), encoding="utf-8") + rfile.write_text(json.dumps({"event": "REQUEST_CHANGES", + "by_severity": {"major": 3}}), encoding="utf-8") + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", "stdout": "[]"}, + {"match": f"pulls/{PR}/reviews", "exit": 1, + "stdout": '{"message":"API rate limit exceeded for user ID 1.","status":"403"}', + "stderr": "gh: API rate limit exceeded for user ID 1. (HTTP 403)\n"}, + ]) state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent="codex", file=str(rfile))) - assert called == [] entry = _state(tmp_dir)["rounds"][0]["codex"] assert entry["intent"] == "REQUEST_CHANGES" assert entry["queued"] is True - - -def test_a_normal_result_still_checks_the_arrival(state_mod, tmp_dir, - monkeypatch) -> None: - """`queued` を持たない結果ファイルの扱いは変えない(#261 のまま)。""" - _seed(tmp_dir, rounds=[{"round": 1, "pr": PR, - "started_at": "2026-09-03T00:00:00+00:00"}]) - called: list = [] - monkeypatch.setattr(state_mod, "_review_exists", - lambda *a: called.append(a) or True) - rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps({ - "event": "APPROVE", "comments_count": 0, "review_url": REVIEW_URL, - "by_severity": {}, - }), encoding="utf-8") - - state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent="codex", - file=str(rfile))) - - assert len(called) == 1 + assert queue_mod.Queue(tmp_dir / "pending").count() == 1 # ---- 流した結果を、再開の入口の書き戻しが消さない ---- @@ -275,34 +252,40 @@ def test_the_resume_keeps_what_the_flush_wrote_to_the_state( # 書き戻す側の変更も残る。どちらか一方だけが残る直し方にしない。 assert saved["manual_extra_review_instructions"] == "重点観点" assert queue_mod.Queue(tmp_dir / "pending").count() == 0 -# ---- 取り込みの前に流さない ---- +# ---- 取り込みの入口で残りを流す ---- -def test_the_read_result_does_not_flush_the_queue( - state_mod, queue_mod, fake_gh, tmp_dir) -> None: - """取り込みの入口では流さない。**書き戻し先がまだ無い。** +def test_the_take_in_flushes_what_was_left_before_posting( + state_mod, queue_mod, fake_gh, tmp_dir, monkeypatch) -> None: + """前の取り込みで積んだ投稿を先に流し、その担当の記録へ書き戻す(#730)。 - 流すと `_confirm_flushed` は書き戻せないまま項目が消え、この後の取り込みが - `queued: true` だけを保存する。待ち行列は空になるため判定は収束させ、投稿の - 存在も参照も一度も確かめられない。 + 積むのは取り込みだけで、積んだ時点でその担当の記録を書くため、書き戻し先がある。 """ _seed(tmp_dir, rounds=[{"round": 1, "pr": PR, - "started_at": "2026-09-03T00:00:00+00:00"}]) + "started_at": "2026-09-03T00:00:00+00:00", + "agy": {"intent": "APPROVE", "queued": True, + "by_severity": {}}}]) queue_mod.enqueue( queue_mod.Queue(tmp_dir / "pending"), "review-post", REPO, PR, - {"body": "指摘の本文", "event": "APPROVE"}, - actor="me", extra={"agent": "codex", "round": 1}) + {"body": "## 🤖 cross-review | round 1 | agy | APPROVE\n", "event": "APPROVE"}, + actor="me", extra={"agent": "agy", "round": 1}) rfile = tmp_dir / "result.json" - rfile.write_text(json.dumps({ - "event": "APPROVE", "comments_count": 0, "review_url": "", - "queued": True, "by_severity": {}, - }), encoding="utf-8") + rfile.write_text(json.dumps({"event": "APPROVE", "by_severity": {}}), + encoding="utf-8") + fake_gh.set_rules([ + {"match": f"pulls/{PR}/reviews?", "stdout": "[]"}, + {"match": "", "stdout": json.dumps( + {"id": 4961230016, "html_url": REVIEW_URL})}, + ]) + monkeypatch.setattr(state_mod, "_review_exists", lambda repo, pr, url: True) state_mod.cmd_read_result(argparse.Namespace(pr=PR, agent="codex", file=str(rfile))) - assert queue_mod.Queue(tmp_dir / "pending").count() == 1 - assert fake_gh.joined() == [] + rnd = _state(tmp_dir)["rounds"][0] + assert rnd["agy"]["queued"] is False and rnd["agy"]["review_url"] == REVIEW_URL + assert rnd["codex"]["queued"] is False + assert queue_mod.Queue(tmp_dir / "pending").count() == 0 def test_the_queued_reviews_are_confirmed_once_both_results_are_taken_in( diff --git a/plugins/ndf/skills/cross-review/tests/test_state_read_result.py b/plugins/ndf/skills/cross-review/tests/test_state_read_result.py index 323bff6e..6700da5a 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_read_result.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_read_result.py @@ -71,7 +71,8 @@ def test_canonical_schema(patched_tmp_dir, state_mod): assert merged["intent"] == "APPROVE" assert merged["posted_as"] == "APPROVE" assert merged["comments"] == 0 - assert merged["review_url"] == "https://example/pr/1#1" + # 参照は担当の申告ではなく送信の応答から取る(#730 AC15)。 + assert merged["review_url"].endswith("#pullrequestreview-1") assert merged["by_severity"]["critical"] == 0 @@ -93,9 +94,10 @@ def test_alias_schema_intent_and_comment_count(patched_tmp_dir, state_mod): st = _read_state(tmp_dir) merged = st["rounds"][-1][AGENT] assert merged["intent"] == "APPROVE" - # posted_as は別名 result.json には存在しないので intent と同値にフォールバック + # posted_as は投稿する側が決める。自分の Pull Request でなければ intent と同じ assert merged["posted_as"] == "APPROVE" - assert merged["comments"] == 3 + # 件数は担当の申告(comment_count)ではなく、送れたインラインの数(#730 AC15) + assert merged["comments"] == 0 def test_missing_event_and_intent_dies(patched_tmp_dir, state_mod): @@ -169,3 +171,27 @@ def test_invalid_json_result_file_dies(patched_tmp_dir, state_mod, capsys): assert e.value.code == 3 captured = capsys.readouterr() assert "parse" in captured.err.lower() or "parse" in captured.err + + +def test_the_take_in_passes_the_start_of_the_round( + patched_tmp_dir, state_mod, monkeypatch): + """取り込みは、二度書かない照合を絞るためにラウンドの開始時刻を渡す。 + + ラウンドの番号は回し直すと 1 から数え直すため、前の実行のレビューと取り違えない。 + """ + tmp_dir = patched_tmp_dir + _seed_state(tmp_dir) + rfile = tmp_dir / "result.json" + rfile.write_text(json.dumps({"event": "APPROVE", "comments_count": 0})) + seen = {} + offline = state_mod.result_posts.post_review + + def _spy(*a, **kw): + seen["since"] = kw.get("since") + return offline(*a, **kw) + + monkeypatch.setattr(state_mod.result_posts, "post_review", _spy) + + state_mod.cmd_read_result(_make_args(rfile)) + + assert seen["since"] == "2026-05-21T00:00:00+00:00" diff --git a/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py b/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py new file mode 100644 index 00000000..040baafb --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_state_resume_args.py @@ -0,0 +1,285 @@ +"""再開で渡した引数を状態ファイルへ反映する(#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_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") + st = resume("--only", "none") + assert st["only"] is None + assert "↻ only: codex → None" in capsys.readouterr().err + assert [c["field"] for c in st["resume_changes"]].count("only") == 1 + + +# ---------------- 外す者・足す者(AC28) ---------------- + +def test_exclude_reruns_the_probe_and_drops_the_name(resume, state_mod, tmp_path): + """AC28: `--exclude agy` は確認をやり直し、使える者から agy を外す。""" + _state(tmp_path) + st = resume("--exclude", "agy") + assert resume.calls == [["codex", "kiro"]] + assert st["participants"]["excluded"] == ["agy"] + assert st["participants"]["available"] == ["codex", "kiro"] + assert "agy" not in _seats(state_mod, tmp_path) + + +def test_the_participants_are_recorded_as_one_change(resume, tmp_path): + """決定 16: 参加者の作り直しは、項目ごとではなく 1 件として積む。""" + _state(tmp_path) + st = resume("--exclude", "agy") + changes = [c for c in st["resume_changes"] if c["field"] == "participants"] + assert len(changes) == 1 + assert changes[0]["from"]["excluded"] == [] + assert changes[0]["to"]["excluded"] == ["agy"] + + +def test_exclude_none_clears_the_exclusions(resume, tmp_path): + """AC28: `--exclude none` は外す者を空へ戻す。""" + _state(tmp_path, participants=_participants(excluded=["agy"], available=["codex", "kiro"])) + st = resume("--exclude", "none") + assert st["participants"]["excluded"] == [] + assert st["participants"]["available"] == ["codex", "agy", "kiro"] + + +def test_unpassed_arguments_come_from_the_state_file(resume, tmp_path): + """AC28 後半: 渡さなかった引数は状態ファイルの値で補う(決定 14)。""" + _state(tmp_path, participants=_participants( + included=["claude"], available=["claude", "codex", "agy", "kiro"])) + st = resume("--exclude", "agy") + assert st["participants"]["included"] == ["claude"] + assert st["participants"]["excluded"] == ["agy"] + assert st["participants"]["available"] == ["claude", "codex", "kiro"] + + +def test_require_all_alone_rebuilds_the_participants(resume, tmp_path): + """`--require-all` だけでも作り直す(担当に関わる引数のため)。""" + _state(tmp_path) + st = resume("--require-all") + assert st["participants"]["require_all"] is True + assert resume.calls == [["codex", "agy", "kiro"]] + + +def test_a_failed_rebuild_leaves_the_state_untouched(resume, state_mod, tmp_path, monkeypatch): + """作り直しが失敗したら、状態ファイルを書き換えずに終了コード 1 で終わる。""" + path = _state(tmp_path) + before = path.read_text(encoding="utf-8") + + def probe(runtimes, *, info, env=None): + return ({r: {"command": r, "ok": False, "detail": "未認証"} for r in runtimes}, False) + + monkeypatch.setattr(state_mod.auth, "probe_auth", probe) + with pytest.raises(SystemExit) as e: + resume("--exclude", "agy", "--require-all") + assert e.value.code == 1 + assert path.read_text(encoding="utf-8") == before + + +def test_a_state_without_participants_can_be_rebuilt(resume, tmp_path): + """`participants` を持たない状態ファイル(`host` だけ)でも作り直せる。""" + path = _state(tmp_path) + st = json.loads(path.read_text(encoding="utf-8")) + del st["participants"] + path.write_text(json.dumps(st, ensure_ascii=False), encoding="utf-8") + + saved = resume("--exclude", "agy") + assert saved["participants"]["available"] == ["codex", "kiro"] + changes = [c for c in saved["resume_changes"] if c["field"] == "participants"] + assert changes[0]["from"] is None + + +# ---------------- 知らせる引数(AC29) ---------------- + +def test_host_is_not_applied_but_reported(resume, tmp_path, capsys): + """AC29: `--host codex` は反映せず、1 行で知らせる。""" + _state(tmp_path) + st = resume("--host", "codex") + assert st["host"] == "claude" + err = capsys.readouterr().err + assert err.count("ℹ --host は再開では反映しません(状態: claude / 指定: codex)") == 1 + assert st["resume_changes"] == [] + + +def test_the_same_host_prints_nothing(resume, tmp_path, capsys): + """AC29 後半: 状態と同じ `--host claude` では何も出さない。""" + _state(tmp_path) + resume("--host", "claude") + assert "--host" not in capsys.readouterr().err diff --git a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py index 87204440..4fe3d06b 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_review_pool.py @@ -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,340 @@ 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_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"}) + 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")) + # 変更前の輪番: 母集合 claude / agy / kiro から `(round_no - 1) % 3` の者を外した 2 者 + previous = [["agy", "kiro"], ["claude", "kiro"], ["claude", "agy"]] + for round_no in range(1, 7): + assert state_mod._round_reviewers(st, round_no) == previous[(round_no - 1) % 3] + 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_rotation_head_branch.py b/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py index b5310c75..f14bd341 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_rotation_head_branch.py @@ -105,6 +105,55 @@ def test_an_empty_lookup_keeps_the_previous_branch(tmp_dir, state_mod, monkeypat assert _state(tmp_dir)["head_branch"] == OLD_BRANCH +def test_only_the_current_pr_entry_is_closed_when_history_has_past_prs( + tmp_dir, state_mod, monkeypatch +): + """現状固定(R2-005)。過去に閉じた PR を含む履歴で、直前の現在 PR だけを閉じる。 + + `pr_history` に閉じた過去 PR(`closed_at` 設定済み)と現在の PR(`closed_at` + が None)を順に持たせて `cmd_set_current_pr` を実行する。過去 PR は変わらず、 + 直前の現在 PR に `closed_at` と `rounds` が入り、新 PR エントリが + `closed_at: None` / `rounds: 0` で末尾へ足される分岐を固定する。 + """ + past_pr = 4200 + state = { + "current_pr": PR, + "repo": "o/r", + "head_branch": OLD_BRANCH, + "rounds": [ + {"round": 1, "pr": past_pr}, + {"round": 2, "pr": PR}, + {"round": 3, "pr": PR}, + ], + "pr_history": [ + {"pr": past_pr, "opened_at": "t0", "closed_at": "t1", "rounds": 1}, + {"pr": PR, "opened_at": "t2", "closed_at": None, "rounds": 0}, + ], + "final": None, + } + (tmp_dir / f"cross-review-pr{PR}-state.json").write_text(json.dumps(state)) + # 引数で枝名を渡し、GitHub を呼ばない経路で確かめる。 + monkeypatch.setattr( + state_mod, "_sh", lambda cmd, check=True: pytest.fail("GitHub を呼んでいる") + ) + + state_mod.cmd_set_current_pr(_args(head_branch=NEW_BRANCH)) + + history = _state(tmp_dir)["pr_history"] + # 過去 PR は変わらない。 + assert history[0] == { + "pr": past_pr, "opened_at": "t0", "closed_at": "t1", "rounds": 1} + # 直前の現在 PR に closed_at と rounds(その PR のラウンド数 2)が入る。 + assert history[1]["pr"] == PR + assert history[1]["closed_at"] is not None + assert history[1]["rounds"] == 2 + # 新 PR エントリが末尾に closed_at: None / rounds: 0 で足される。 + assert history[2]["pr"] == NEW_PR + assert history[2]["closed_at"] is None + assert history[2]["rounds"] == 0 + assert len(history) == 3 + + def test_the_skeleton_passes_the_new_branch(state_mod) -> None: """手順書と参照の骨組みが `--head-branch` を渡していることを固定する。""" here = pathlib.Path(__file__).resolve().parent.parent diff --git a/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py b/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py index b1b975d7..dd894ee8 100644 --- a/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py +++ b/plugins/ndf/skills/cross-review/tests/test_state_round_guard.py @@ -207,3 +207,29 @@ def test_unavailable_count_does_not_stop_the_round(tmp_dir, state_mod, unresolve assert len(_read(tmp_dir)["rounds"]) == 2 assert "確認できません" in capsys.readouterr().err + + +# ---------------- 前ラウンドの担当で数える(#727: AC23) ---------------- + + +def test_the_guard_counts_the_reviewers_recorded_on_the_round(tmp_dir, state_mod, unresolved, capsys): + """AC23: 判定の結果を持たない前ラウンドは、そのラウンドの担当で数え直す。 + + 担当を渡さず `codex` / `agy` で数えると、担当が `agy` + `kiro` のラウンドでは + `codex` を結果なしと読み、修正の記録が無いまま次のラウンドへ通す。 + """ + unresolved([]) + prev = { + "round": 1, "pr": PR, "started_at": "2026-08-31T00:00:00+00:00", + "reviewers": ["agy", "kiro"], + "agy": {"intent": "REQUEST_CHANGES", "by_severity": {"major": 1}}, + "kiro": {"intent": "REQUEST_CHANGES", "by_severity": {"major": 1}}, + } + _write(tmp_dir, _state([prev], host="claude")) + + with pytest.raises(SystemExit) as e: + state_mod.cmd_start_round(argparse.Namespace(pr=PR)) + + assert e.value.code == 5 + assert "修正の記録" in capsys.readouterr().err + assert len(_read(tmp_dir)["rounds"]) == 1 diff --git a/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py b/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py new file mode 100644 index 00000000..b9006590 --- /dev/null +++ b/plugins/ndf/skills/cross-review/tests/test_writes_by_conductor_docs.py @@ -0,0 +1,52 @@ +"""GitHub と git へ書くのをレビューを回す側だけにした決定が、文書に反映されている(#730)。 + +| 受け入れ条件 | 文書 | 消える記述 | 入る記述 | +| --- | --- | --- | --- | +| AC25 | `SKILL.md` の設計方針の表 | 担当が `gh api` で直接投稿する | 投稿の担い手とその理由 | +| AC26 | `docs/03-review-output.md` | 担当の直接投稿の決定 | 待ち行列を通す形 | +| AC27 | `docs/02-fix-and-rotation.md` | 担当の送信の行 | 取り込む側の送信 | +| AC28 | `references/context-budget.md` | 中間ペイロードがメインを通らない | 本文がプロセスの中だけを通る | +| AC29 | `docs/04-contracts.md` | — | 投稿の種別ごとの契約 | +""" +from __future__ import annotations + +import pathlib + +import pytest + +HERE = pathlib.Path(__file__).resolve().parent.parent + + +def _read(rel: str) -> str: + return (HERE / rel).read_text(encoding="utf-8") + + +def test_the_policy_table_names_who_posts() -> None: + text = _read("SKILL.md") + assert "AI 自身が `gh api` で PR に直接投稿" not in text + assert "| 投稿の担い手 |" in text + + +def test_the_review_output_goes_through_the_queue() -> None: + text = _read("docs/03-review-output.md") + assert "AI 直接投稿" not in text + assert "待ち行列" in text + + +def test_the_fix_procedure_does_not_push() -> None: + text = _read("docs/02-fix-and-rotation.md") + assert "git push origin {HEAD_BRANCH}" not in text + assert "HEAD:<ブランチ名>" in text + + +def test_the_context_budget_keeps_bodies_in_the_process() -> None: + text = _read("references/context-budget.md") + assert "中間ペイロードがメインを通らない" not in text + assert "プロセスの中だけを通り" in text + + +@pytest.mark.parametrize("kind", ["review-post", "review-reply", "thread-resolve", "pr-comment"]) +def test_the_contract_lists_each_kind_of_post(kind: str) -> None: + text = _read("docs/04-contracts.md") + section = text.split("## 投稿の種別ごとの契約", 1)[1].split("\n## ", 1)[0] + assert f"| `{kind}` |" in section diff --git a/plugins/ndf/skills/fix/SKILL.md b/plugins/ndf/skills/fix/SKILL.md index 7b1c51d0..db38d841 100644 --- a/plugins/ndf/skills/fix/SKILL.md +++ b/plugins/ndf/skills/fix/SKILL.md @@ -56,19 +56,33 @@ PR: ) ``` -サブエージェント側ではこの SKILL.md を読み込んで、自己完結で -**修正 → コミット → push → reply → Resolve Conversation** まで実行する。 -メインへの戻り値は最小限のサマリのみ。 +サブエージェント側ではこの SKILL.md を読み込んで、**修正 → コミット → 戻り値ファイル** +までを行う。メインへの戻り値は最小限のサマリのみ。 -**push が credential helper の不全で落ちたときは退避する**(#524)。`gh` が認証済みでも -`git` だけが `Authentication failed` を返す環境がある。 +**修正の担当は GitHub と git へ書かない。** 送信・返信・スレッドの決着・まとめの投稿は、 +戻り値ファイルを読んだ側が行う(#730)。担当が送ると、送ったという報告と実物が食い違う +状態(切り離された頭では、ブランチ名だけの送信が何も送らずに終了コード 0 で終わる)と、 +途中で止まったときに投稿だけが残る状態が作れる。 + +| 起動のされ方 | 書き込みを行う側 | +| --- | --- | +| `/ndf:cross-review` から | 修正の取り込み(`state.py merge-fix`)が行う | +| 単独で呼んだ | 戻り値ファイルを書いた後、次の 1 行を実行する | ```bash -git -c credential.helper= -c credential.helper='!gh auth git-credential' push +python3 "$SCRIPTS/lib/result_posts.py" fix --pr <番号> --result <戻り値ファイル> \ + [--repo <所有者>/<リポジトリ>] [--head <ブランチ名>] [--worktree <作業ツリー>] [--round ] ``` -**空の値を先に置く。** `credential.helper` は複数の値を持てる設定で、`git` は宣言された -順に問い合わせる。空の値だけが一覧を空へ戻す。 +`$SCRIPTS` の決め方は `development-workflow` の `references/scripts-lookup.md` にある。 +`--repo` / `--head` / `--worktree` を省いたときは、いまいる作業ツリーと Pull Request から引く。 +**送り先のブランチを決められないときは、返信へ進まず終了コード 1 で止まる。** 送っていない +修正へ「対応しました」と返信しないためである。 +このコマンドが現在の頭を送り先へ送り(`git push origin HEAD:<ブランチ名>`)、報告した +コミットが送り先に載ったことを確かめてから、返信・決着・まとめを待ち行列を通して送る。 +出力は件数と参照だけで、本文を出さない。 +送信が認証で落ちたときの退避(#524)もこのコマンドが行う。値は共通層の +`scripts/lib/git-credential.sh` 1 か所が持つ。 ## コメントの取得(3 ソース) @@ -203,14 +217,11 @@ GitHub MCP を使う場合は `mcp__github__get_pull_request_comments` を利用 4. 問題点を修正。**コード行数が減る方向の修正は積極的に実施**(重複排除、不要分岐除去) 5. **コミット前の再確認** — 作業中に新しいコメントが追加されていないか再取得し、CI 状態も 現時点だけ確認する(完了待ちはしない)。新しい指摘・失敗があれば手順 3 に戻る -6. コミット・プッシュ -7. **PR レベルの Summary コメントを投稿**(対応件数 + deferred 件数を明記) -8. 対応したインラインコメントに個別に返信 -9. **deferred スレッドには `[deferred / nit]` ラベル付き返信** を投稿(Resolve はしない) -10. reviewer に再レビューを依頼 -11. 対応完了したスレッドを **Resolve Conversation** にする -12. **本文の決めたことの節を設計文書に揃える**(**コミットの有無によらず**実行する。後述) -13. **戻り値ファイルを書き出す**(後述) +6. コミットする。**送らない** +7. **本文の決めたことの節を設計文書に揃える**(**コミットの有無によらず**実行する。後述) +8. **戻り値ファイルを書き出す**(後述)。対応したスレッド・見送り・却下をそれぞれの配列へ + 入れる。返信・決着・まとめはこの配列から組み立てられる +9. 単独で呼んだときだけ、「起動モード」の 1 行を実行して送信と投稿を終える ### 本文の決めたことの節を揃える @@ -278,33 +289,30 @@ review 指摘と CI エラーは**同じ PR で一緒に修正**する。同じ ## 返信と Resolve -### 返信の書き分け +**返信・決着・まとめは戻り値ファイルから組み立てる。** 担当が投稿の呼び出しを書かない。 +組み立てと送信は共通層(`lib/result_posts.py`)が 1 か所で持ち、`/ndf:cross-review` から +呼んだときも単独で呼んだときも同じ実装を通る。 -| 状況 | 返信の型 | -|---|---| -| 修正した | `対応しました — <ファイル>:<行> で〇〇 (commit )` | -| 別 PR で対応 | `別 PR で対応予定です。PR 説明の「やらないこと」に記載のとおり、<理由>` | -| deferred | `[deferred / nit] 後続 PR で対応予定` | -| rejected | `bot 指摘は誤読です — 理由: ...` | -| 対応不要 | `確認しました。<対応不要と判断した理由>` | -| 範囲外 | `範囲外と判断し、#<番号> として残しました` — `/ndf:out-of-scope` で起票してから返信する。起票先のリポジトリもその Skill が決める(flaky テスト・CI の失敗は例外で、この PR で直す) | +| 戻り値ファイルの配列 | 送られるもの | +| --- | --- | +| `resolved_threads` | 指摘への「対応しました(<コミット>)」の返信と、スレッドの決着 | +| `deferred` | 見送りの理由(`reason_for_deferral`)の返信。決着しない | +| `rejected` | 採らない理由(`reason_for_rejection`)の返信。決着しない | +| (すべて) | 対応件数・決着・見送り・却下・CI を並べた Pull Request のまとめ | -```bash -# 特定のコメントに返信(in_reply_to にコメント ID を指定) -gh api repos/{owner}/{repo}/pulls/{pr_number}/comments \ - -f body="対応しました。" -F in_reply_to={comment_id} -``` +- 返信の宛先は各要素の `comment_id`、決着の宛先は `thread_id`(`PRRT_...`)である。 + `thread_id` はレビューコメントの `node_id`(`PRRC_...`)ではない。下の query の + `nodes[].id` から取る(次の節の query) +- 同じ返信・同じまとめを 2 度送っても増えない(本文の先頭 80 文字で先客を照合する)。 + すでに決着したスレッドをもう一度決着させても失敗にならない -### Resolve Conversation +### 対応の対象は未解決の指摘を数え直して決める(必須) -**修正済みのスレッドのみ** Resolve する。`deferred` / `rejected` は次ラウンドで再評価する -ため Resolve しない。 +**投稿数を対象の数として使わない。** レビュー結果の `comments_count` は +そのラウンドで新しく投稿された件数であり、PR 上に残っている未解決の指摘の数ではない。 +前のラウンドの分や、中断の前に投稿された分がこの数の外にある。 -`resolveReviewThread` が要求するのは **review thread** の ID(`PRRT_...`)であり、 -レビューコメントの `node_id`(`PRRT_` ではなく `PRRC_...`)ではない。 -`repos/{owner}/{repo}/pulls/comments/` から引ける `node_id` はコメント側の ID -なので **Resolve には使えない**。必ず下記 query の `nodes[].id` を使い、 -`comments.nodes[].databaseId`(返信に使ったコメント ID)または本文と突き合わせて特定する。 +スレッドの一覧は次の query で読む(読み取りだけで、書き込みはしない)。 ```bash # スレッド一覧を thread ID (PRRT_...) 付きで取得 @@ -323,21 +331,9 @@ gh api graphql -f query=' }' --jq '.data.repository.pullRequest.reviewThreads.nodes[] | select(.isResolved == false) | {thread_id: .id, path, line, comment_id: .comments.nodes[0].databaseId}' - -# 上で得た thread_id(PRRT_...)を THREAD_ID に入れて Resolve -gh api graphql -f query=' - mutation($id: ID!) { - resolveReviewThread(input: {threadId: $id}) { thread { isResolved } } - }' -f id="$THREAD_ID" ``` -### 対応の対象は未解決の指摘を数え直して決める(必須) - -**投稿数を対象の数として使わない。** レビュー結果の `comments_count` は -そのラウンドで新しく投稿された件数であり、PR 上に残っている未解決の指摘の数ではない。 -前のラウンドの分や、中断の前に投稿された分がこの数の外にある。 - -上の query を `isResolved == false` で絞った結果が対象の全量である。 +この query を `isResolved == false` で絞った結果が対象の全量である。 `/ndf:cross-review` から呼ばれた場合は、次のコマンドでも同じ数を取れる(引数は state.json の キー、つまり最初に `init` した PR 番号を渡す。対象の PR は state.json 側で解決される)。 @@ -351,28 +347,15 @@ eval "$UNRESOLVED_VARS" `eval` 自身の終了コードが 0 になり、スクリプトの `exit 1` が消える。未解決の件数を 取得できていないのに 0 件と読んで、対象が無いものとして先へ進むことになる。 -返信と Resolve を終えたら、**同じ query をもう一度実行して残数を確認する**。 +送信と投稿を終えたら、**同じ query をもう一度実行して残数を確認する**。 deferred / rejected として意図的に残したもの以外が残っていれば、対応が漏れている。 ### PR レベル Summary コメント(必須) インラインへの返信と Resolve **だけでは不十分**。PR ページの Conversation タブに -まとめが出ないと、レビュアー視点で見落とされる。 - -```bash -gh pr comment --body "$(cat <<'EOMD' -## 🔧 /ndf:fix サマリ - -対応件数: critical=X / major=Y / minor=Z (合計 N 件) -deferred: D 件 / rejected: R 件 -commit: -CI: SUCCESS | FAILURE | NONE - -### 詳細 -- 各 thread の対応概要(行リンク付き) -EOMD -)" -``` +まとめが出ないと、レビュアー視点で見落とされる。**まとめは戻り値ファイルから組み立てて +送られる**(上の表の最後の行)。先頭行にラウンドとコミットが入るため、同じラウンドの +まとめは 1 件だけになる。 ## 戻り値フォーマット(必須) @@ -401,11 +384,12 @@ EOMD {"comment_id": 3222849090, "path": "scripts/state.py", "line": 120, "severity": "minor", "summary": "heredoc を <<'JSON' にせよ", "reason_for_rejection": "$SHA を意図的に展開する必要があり、クオート化すると逆に壊れる"} - ], - "summary_comment_url": "https://github.com/.../pull/67#issuecomment-..." + ] } ``` +- **まとめの参照(`summary_comment_url`)は書かない。** 投稿する側が、まとめの投稿の応答から + 記録へ書く - `resolved_threads` / `deferred` / `rejected` は **必ず配列**で返す(件数の int は誤り)。 該当が無ければ空配列 - **`rejected` の各要素は `path` / `line` / `severity` を持つ。** 却下した論点が次のラウンドで diff --git a/plugins/ndf/skills/statusline/SKILL.md b/plugins/ndf/skills/statusline/SKILL.md index 4e94cdab..33ad4329 100644 --- a/plugins/ndf/skills/statusline/SKILL.md +++ b/plugins/ndf/skills/statusline/SKILL.md @@ -9,18 +9,24 @@ allowed-tools: # Statusline 切り替えコマンド -NDF 標準 statusline (コンテナ名/ホスト名 + project_dir + コンテキスト使用率) と +NDF 標準 statusline (project_dir + メインとサブエージェントのコンテキスト使用量) と 既存のカスタム statusline を切り替える。 ## 表示内容 ``` -<コンテナ名|ホスト名> [<モデル名>: 12.3k / 200k tokens (6%)] + [Opus5 61k │ 修正:PR 167k · 検証:#8 42k] ``` -- コンテナ環境 (`/.dockerenv` あり) ではコンテナ名、それ以外ではホスト名を表示 -- `CONTAINER_NAME` 環境変数があればそちらを優先 -- 角括弧内のラベルは利用中モデルの表示名 (例: `Opus 4.8`)。取得できない場合は `ctx` にフォールバック +- コンテナ名・ホスト名は出さない。区別は端末やエディタのウィンドウタイトルに任せる +- 角括弧の先頭は利用中モデルの表示名と、メインセッションのコンテキスト使用量。表示名の括弧と空白は落とす (`Opus 5 (1M context)` → `Opus5`)。取得できない場合は `ctx` にフォールバック +- **上限と使用率は出さない。** 現行モデルの上限は Haiku 4.5 (200K) を除いて 1M で、使用量だけで足りる +- `│` の後に実行中のサブエージェントを並べる。statusLine の JSON はメインセッションの値しか持たないため、`/subagents/agent-.jsonl` の最後の `usage` から読む + - 直近 60 分以内に記録が更新され、終わっていないものを実行中とみなす。更新の時刻では決めない (子を待つ supervisor や長いコマンドを待つ担当は、実行中でも何分も書き足さない)。止められた担当は、記録が `tool_use` で終わったまま 60 分残ることがある。記録の最後の user / assistant の行が `tool_use` を含まない assistant で、`end_turn` が付いているか 30 秒以上書き足されていなければ終わったとみなす + - 使用量の多い順に 3 本まで並べ、残りは `+2` のように本数だけを出す。80 桁の端末に収めるため + - ラベルは `agent-.meta.json` の `description` から空白を除いた先頭 4 文字で、空白を挟んで使用量を続ける。project_dir と 3 本を並べても 80 桁に収めるため。種類名 (`agentType`) はほとんどが `general-purpose` で見分けに使えない。説明が無ければ ID の先頭を出す +- メイン・サブエージェントとも、500k を超えたら使用量を赤で表示する。上限が 200K 以下のモデル (Haiku 4.5) は 150k で赤にする。メインは入力の `context_window.context_window_size`、サブエージェントは記録のモデル名で判定する + - サブエージェントの記録の置き場所と形は公式ドキュメントに無い内部の仕様で、Claude Code の更新で変わりうる。読めなければ何も出さない ## 使用方法 @@ -76,3 +82,8 @@ statusline の変更は次回セッション開始時 (または statusline 再 既に statusline が設定されている場合はそちらが優先され、何も変更しない。 NDF 標準 statusline の利用中は、プラグイン更新時にスクリプト (`~/.claude/ndf-statusline.sh`) の内容が自動で追従する。 + +NDF 標準 statusline は `refreshInterval: 5` (秒) を持つ。メインセッションがバックグラウンドの +サブエージェントを待つ間は再描画のイベントが起きず、サブエージェントの使用量が止まって見える +ためである。既に NDF 標準を使っている設定に `refreshInterval` が無ければ、`ensure` と `set` が +足す。利用者が書いた値は変えない。 diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_render.py b/plugins/ndf/skills/statusline/tests/test_statusline_render.py new file mode 100644 index 00000000..7d3f0318 --- /dev/null +++ b/plugins/ndf/skills/statusline/tests/test_statusline_render.py @@ -0,0 +1,132 @@ +"""statusline.sh の表示(メインとサブエージェントの使用量)を検証する。 + +擬似の transcript(/sess.jsonl)とサブエージェントの記録 +(/sess/subagents/agent-.jsonl / .meta.json)を作り、標準入力へ JSON を渡して +`bash statusline.sh` の出力を突き合わせる。 +""" +from __future__ import annotations + +import json +import os +import re +import shutil +import subprocess +import time +from pathlib import Path + +import pytest + +for _cmd in ("bash", "jq"): + if shutil.which(_cmd) is None: + pytest.skip(f"{_cmd} not available", allow_module_level=True) + +STATUSLINE = Path(__file__).resolve().parents[3] / "scripts" / "statusline.sh" +RED = "\033[0;31m" +ANSI = re.compile(r"\033\[[0-9;]*m") + + +def _usage(tokens: int) -> dict: + return {"input_tokens": tokens, "cache_creation_input_tokens": 0, "cache_read_input_tokens": 0} + + +def write_agent(root: Path, agent_id: str, *, tokens: int = 10_000, end: str = "tool_use", + model: str = "claude-opus-5", description: str | None = None, + age: float = 0) -> Path: + """end: tool_use(実行中)/ end_turn(終了)/ text(stop_reason 無しの text で終わる)""" + sub = root / "sess" / "subagents" + sub.mkdir(parents=True, exist_ok=True) + content = [{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}] if end == "tool_use" \ + else [{"type": "text", "text": "done"}] + stop = "end_turn" if end == "end_turn" else None + lines = [ + {"type": "user", "message": {"role": "user", "content": "go"}}, + {"type": "assistant", "message": {"model": model, "content": content, + "stop_reason": stop, "usage": _usage(tokens)}}, + ] + f = sub / f"agent-{agent_id}.jsonl" + f.write_text("".join(json.dumps(line) + "\n" for line in lines)) + if description is not None: + (sub / f"agent-{agent_id}.meta.json").write_text(json.dumps({"description": description})) + if age: + t = time.time() - age + os.utime(f, (t, t)) + return f + + +def render(root: Path, total: int = 100_000, size: int | None = None) -> str: + cw: dict = {"total_input_tokens": total} + if size is not None: + cw["context_window_size"] = size + payload = {"transcript_path": str(root / "sess.jsonl"), + "model": {"display_name": "Opus 5 (1M context)"}, "context_window": cw} + r = subprocess.run(["bash", str(STATUSLINE)], input=json.dumps(payload), + capture_output=True, text=True, check=True) + return r.stdout + + +def plain(out: str) -> str: + return ANSI.sub("", out) + + +def test_tool_use_is_shown_and_end_turn_is_not(tmp_path): + write_agent(tmp_path, "aaaa1111", tokens=42_000, description="実行中の担当") + write_agent(tmp_path, "bbbb2222", tokens=77_000, end="end_turn", description="終わった担当") + out = plain(render(tmp_path)) + assert "実行中の 42k" in out + assert "終わった" not in out + + +def test_text_older_than_30s_is_hidden(tmp_path): + write_agent(tmp_path, "aaaa1111", end="text", age=60, description="古いtext") + write_agent(tmp_path, "bbbb2222", end="text", description="新しいtext") + out = plain(render(tmp_path)) + assert "古いte" not in out + assert "新しいt" in out + + +def test_top3_and_rest_count(tmp_path): + for i, tok in enumerate([10_000, 20_000, 30_000, 40_000, 50_000]): + write_agent(tmp_path, f"id{i}xxxx", tokens=tok, description=f"担当{i}号機") + out = plain(render(tmp_path)) + assert "│ 担当4号 50k · 担当3号 40k · 担当2号 30k +2]" in out + + +def test_haiku_warns_at_150k_but_opus_does_not(tmp_path): + write_agent(tmp_path, "aaaa1111", tokens=160_000, model="claude-haiku-4-5", description="はいく") + out = render(tmp_path) + assert f"{RED}はいく 160k" in out + shutil.rmtree(tmp_path / "sess") + write_agent(tmp_path, "aaaa1111", tokens=160_000, model="claude-opus-5", description="おーぱす") + assert RED not in render(tmp_path) + + +def test_main_warns_over_500k(tmp_path): + assert RED + "553k" in render(tmp_path, total=553_000) + assert RED not in render(tmp_path, total=160_000) + + +def test_main_with_200k_window_warns_at_150k(tmp_path): + assert RED + "160k" in render(tmp_path, total=160_000, size=200_000) + assert RED not in render(tmp_path, total=160_000, size=1_000_000) + + +def test_label_from_description_or_id(tmp_path): + write_agent(tmp_path, "abcd9999", tokens=12_000, description="Fix PR comments") + write_agent(tmp_path, "wxyz8888", tokens=11_000) + out = plain(render(tmp_path)) + assert "FixP 12k" in out + assert "wxyz 11k" in out + + +def test_control_chars_in_description_are_dropped(tmp_path): + # 説明に ESC などの制御文字があっても端末へ出さない(画面消去などを実行させない) + write_agent(tmp_path, "aaaa1111", tokens=21_000, description="\u001b[2J\u009b画面消去") + out = render(tmp_path) + assert "\u001b[2J" not in out + assert "\u009b" not in out + assert "[2J画 21k" in plain(out) + +def test_path_with_spaces(tmp_path): + root = tmp_path / "my project dir" + write_agent(root, "aaaa1111", tokens=33_000, description="空白下") + assert "空白下 33k" in plain(render(root)) diff --git a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py b/plugins/ndf/skills/statusline/tests/test_statusline_switch.py index c875a371..afcac8b9 100644 --- a/plugins/ndf/skills/statusline/tests/test_statusline_switch.py +++ b/plugins/ndf/skills/statusline/tests/test_statusline_switch.py @@ -148,3 +148,58 @@ def test_unset_statusline_gets_ndf_default(tmp_path: Path) -> None: assert result.returncode == 0, result.stderr assert _settings(tmp_path)["statusLine"]["command"] == NDF_COMMAND + + +def test_unset_statusline_gets_refresh_interval(tmp_path: Path) -> None: + """新規設定では refreshInterval も書く。待機中もサブエージェントの表示を更新するため。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text("{}") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 5 + + +def test_official_ndf_path_gets_missing_refresh_interval(tmp_path: Path) -> None: + """既に NDF 標準を使っていて refreshInterval が無ければ足す。""" + _claude(tmp_path) + _write_settings(tmp_path, NDF_COMMAND) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 5 + + +def test_existing_refresh_interval_is_kept(tmp_path: Path) -> None: + """利用者が決めた refreshInterval は上書きしない。""" + claude = _claude(tmp_path) + (claude / "settings.json").write_text( + json.dumps( + { + "statusLine": { + "type": "command", + "command": NDF_COMMAND, + "refreshInterval": 30, + } + } + ) + ) + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert _settings(tmp_path)["statusLine"]["refreshInterval"] == 30 + + +def test_user_custom_gets_no_refresh_interval(tmp_path: Path) -> None: + """利用者独自の statusline には refreshInterval を足さない。""" + claude = _claude(tmp_path) + (claude / "mybar.sh").write_text(CUSTOM) + _write_settings(tmp_path, "bash ~/.claude/mybar.sh") + + result = _run_ensure(tmp_path) + + assert result.returncode == 0, result.stderr + assert "refreshInterval" not in _settings(tmp_path)["statusLine"] diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 00000000..47b2c6e3 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,8 @@ +# 起点のディレクトリに関わらず、テストの基準のディレクトリ(rootdir)をリポジトリの根へ +# 解決させるために置く。pytest は起点から上へ設定ファイルを探し、見つかったところで止まる。 +# 根に設定ファイルが 1 つも無いと、テストの束のディレクトリを起点にした実行では基準が +# そこで止まり、根の共通の前提(`conftest.py`)が読み込まれない。監視の上限を指す環境変数 +# (接頭辞 `MONITOR_`)の除去のように、どの起点でも効かなければならない前提がここに載る。 +# +# **設定値は足さない。** `testpaths` などを書くと、既存の実行が対象にする範囲が変わる。 +[pytest] diff --git a/scripts/tests/test_push_fallback_docs.py b/scripts/tests/test_push_fallback_docs.py index a6835002..b5dc1053 100644 --- a/scripts/tests/test_push_fallback_docs.py +++ b/scripts/tests/test_push_fallback_docs.py @@ -19,12 +19,15 @@ REPO_ROOT = Path(__file__).resolve().parents[2] LIB = REPO_ROOT / "plugins/ndf/scripts/lib/git-credential.sh" -# 退避を案内する手順書。`cross-refactoring` は実装が退避するため、案内の形が違う。 +# 退避を案内する手順書。`cross-refactoring` と `fix` は実装が退避するため、案内の形が +# 違う(`fix` の送信は共通層の `result_posts.py` が行う、#730)。 DOCS_WITH_COMMAND = ( "plugins/ndf/skills/pr/SKILL.md", +) +DOCS_WITH_REFERENCE = ( + "plugins/ndf/skills/cross-refactoring/SKILL.md", "plugins/ndf/skills/fix/SKILL.md", ) -DOCS_WITH_REFERENCE = ("plugins/ndf/skills/cross-refactoring/SKILL.md",) def fallback_args() -> list[str]: diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 9306fb20..f575da6d 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -1,10 +1,11 @@ """リポジトリの根の設定が持つ前提を固定する(#232 / #233 / #235)。 -3 つのことを確かめる。 +4 つのことを確かめる。 1. 起点をリポジトリの根に置いても収集が中断しない(`pytest_plugins` の宣言の位置) 2. 前提の外部コマンドが無いとき、読み飛ばさずに 0 以外の終了コードで終わる 3. テストの実行中は git の全体設定と system の設定を読まない +4. テストの実行中は監視の上限を指す環境変数を読まない(#678) 前提の不足は、`PATH` を絞った子プロセスとして pytest を起動して確かめる。実行環境の `PATH` は書き換えない。 @@ -165,3 +166,116 @@ def test_metrics_dir_points_to_a_temporary_directory_during_tests() -> None: assert metrics, "NDF_METRICS_DIR が設定されていない" assert Path(metrics).resolve().is_relative_to(Path(tempfile.gettempdir()).resolve()) assert "NDF_METRICS" not in os.environ + + +# ---------- 監視の上限を指す環境変数の切り離し(#678) ---------- + + +def _root_conftest_module(): + """根の設定を別名で読み込む。控えの辞書を汚さずに、外す側と戻す側を直接呼ぶ。""" + import importlib.util + + spec = importlib.util.spec_from_file_location("ndf_root_conftest", ROOT_CONFTEST) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +def test_no_monitor_variable_survives_into_a_test(monkeypatch: pytest.MonkeyPatch) -> None: + """外す側を呼んだ後は、監視の上限を指す環境変数が 1 つも残らない。 + + **確かめる前に自分で 1 つ差し込む。** 周りのシェルが上限を持たないと、外す仕組みを + 壊しても素通りする。差し込んでおけば、起動したシェルが何を持っていても同じことを + 確かめられる。控えを汚さないよう、根の設定は別名で読み込む。 + """ + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + try: + mod.pytest_configure(None) + + remaining = [k for k in os.environ if k.startswith("MONITOR_")] + assert remaining == [], remaining + finally: + mod.pytest_unconfigure(None) + + +def test_a_test_can_still_set_its_own_monitor_variable(monkeypatch: pytest.MonkeyPatch) -> None: + """個別に設定した値は打ち消されない。切り離しは実行の前に 1 度だけ効く。""" + monkeypatch.setenv("MONITOR_STALL_AGY", "600") + assert os.environ["MONITOR_STALL_AGY"] == "600" + + +def test_the_child_process_does_not_inherit_a_monitor_variable( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """外した後に起動した子プロセスは、監視の上限を指す環境変数を受け継がない。 + + 起動する側で外し直さなくてよいことを確かめる。**確かめる前に自分で 1 つ差し込む。** + 周りのシェルが上限を持たないと、外す仕組みを壊しても素通りする。控えを汚さないよう、 + 根の設定は別名で読み込む。 + """ + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + try: + mod.pytest_configure(None) + + out = subprocess.run( + [sys.executable, "-c", + "import os; print([k for k in os.environ if k.startswith('MONITOR_')])"], + capture_output=True, text=True, + ) + assert out.stdout.strip() == "[]", out.stdout + finally: + mod.pytest_unconfigure(None) + + +def test_the_values_are_put_back_after_the_run(monkeypatch: pytest.MonkeyPatch) -> None: + """外した値は、実行が終わったときに戻る。""" + mod = _root_conftest_module() + monkeypatch.setenv("MONITOR_STALL_AGY", "1800") + + mod.pytest_configure(None) + + assert "MONITOR_STALL_AGY" not in os.environ + assert mod._saved_monitor_env == {"MONITOR_STALL_AGY": "1800"} + + mod.pytest_unconfigure(None) + + assert os.environ["MONITOR_STALL_AGY"] == "1800" + assert mod._saved_monitor_env == {} + + +def test_the_isolation_holds_from_a_bundle_directory() -> None: + """束のディレクトリを起点にしても切り離しが効く。 + + テストの基準のディレクトリ(rootdir)が起点で止まると、根の設定が読み込まれず、 + 上限を延ばしたシェルから起動したときだけ落ちる。根の設定ファイル(`pytest.ini`)が + 基準をリポジトリの根へ固定していることを、実際の起動で確かめる。 + **監視の環境変数は明示的に足す。** 実行中は根の設定が外した後のため、渡す環境へ + 足さないと再現しない。 + """ + bundle = REPO_ROOT / "plugins/ndf/skills/cross-review/tests" + env = dict(os.environ) + env.pop("NDF_TESTS_ALLOW_MISSING_COMMANDS", None) + env.update({"MONITOR_STALL_AGY": "1800", "MONITOR_TIMEOUT": "1800"}) + + result = subprocess.run( + [sys.executable, "-m", "pytest", "test_monitor_stall_default.py", "-q", + "--no-header", "-p", "no:cacheprovider"], + cwd=str(bundle), + capture_output=True, + text=True, + env=env, + ) + + assert result.returncode == 0, result.stdout + result.stderr + + +def test_the_prefix_is_declared_once() -> None: + """接頭辞は根の設定だけが持つ。テストの側へ書き戻すと、同じ除去がまた散る。""" + body = _read_root_conftest() + + assert 'MONITOR_ENV_PREFIX = "MONITOR_"' in body + assert "def pytest_unconfigure" in body diff --git a/scripts/tests/test_shared_lib_layout.py b/scripts/tests/test_shared_lib_layout.py index 735fe606..569e0c51 100644 --- a/scripts/tests/test_shared_lib_layout.py +++ b/scripts/tests/test_shared_lib_layout.py @@ -322,3 +322,24 @@ def test_the_identifier_still_has_to_be_an_integer() -> None: out = _monitor("10.2.0", "--agents", "deploy") assert out.returncode != 0 assert "invalid int value" in out.stderr + + +# ---------- 呼び手の無くなった旧関数(#727 の AC7) ---------- + +# 使える者の解決と席・適用の割り当てが共通層の新しい関数へ移り、呼び手が無くなった 4 つ。 +# **片方の Skill にだけ古い形が残らない**(親 #727 の完了条件)ことを、名前が残らない +# ことで固定する。部品(`scripts/`)だけを見る。テストと文書は古い値を期待値や経緯として +# 持ちうる。 +RETIRED_FUNCTIONS = ("check_auth", "impl_pool", "review_assign", "assign") + + +def test_the_retired_assignment_functions_are_gone() -> None: + result = subprocess.run( + ["git", "grep", "-n", "-w", + *[arg for name in RETIRED_FUNCTIONS for arg in ("-e", name)], + "--", "plugins/ndf/scripts/lib/", + "plugins/ndf/skills/cross-review/scripts/", + "plugins/ndf/skills/cross-refactoring/scripts/"], + cwd=ROOT, capture_output=True, text=True, + ) + assert result.returncode == 1, f"旧関数の名前が残っている:\n{result.stdout}"