diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index cb96c3f51..331f0fd6e 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.16.0): 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.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/AGENTS.md b/AGENTS.md index 2ed45aa94..9532ad999 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -126,7 +126,7 @@ ai-plugins/ ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.16.0)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです(v10.16.1)。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 - Skill の実体は `plugins/ndf/skills/` の1箇所。配布先は `plugins/ndf/manifests/*-skills.txt` が決める - Claude Code版は 8個の専門サブエージェント、公開Skills、PreToolUse/SessionStart/Stopフックを提供 - Codex版は Codex向け公開Skillsと任意Slack通知hookを提供 diff --git a/CHANGELOG.md b/CHANGELOG.md index 376f8c516..88becbe02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,26 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.16.1] - 2026-09-22 + +### 修正 + +- **codex と claude の利用上限の実物の文言を、監視の照合の表へ足した**(#811)。 + `^(?:ERROR:\s*)?You['’]ve hit your (?:[\w'’ ]+ )?(?:limit|budget)\b` と + `^(?:ERROR:\s*)?exceeded retry limit, last status: 429\b` の 2 行で、codex の 2 形と claude の 5 形に + 一致する。結末の理由が「結果ファイル無し」ではなく「利用上限」になり、同じラウンドで起動し直さない。 + **行頭で始まる形だけを読む**ため、担当が差分・文書・テストを読み上げた行では止まらない。再試行の上限は + 最後の状態が 429 のときだけ利用上限と読む(503 などの一時的な誤りは起動し直せば解けうる) +- **認証の確認が、確認コマンドを起動できない理由を問わず「通らない」として返すようにした**(#813)。 + `PATH` に読めないディレクトリがあると、コマンドがどこにも無いときに見つからない例外ではなく権限の例外が + 上がり、`cross-review` と `cross-refactoring` の開始の手順ごと落ちていた。実行形式でないファイルも + 同じ形で落ちる。どちらも理由 `コマンドを実行できません(<理由>)` として返り、使える者だけで始まる + +### 変更 + +- 収束ループの共通層の内部構造を整理した(`lib/result_posts.py` の投稿の組み立て、`lib/run_metrics.py` の + 集計、`lib/transcript_agents.py` の記録の読み取り)。振る舞いは変えていない + ## [ndf 10.16.0] - 2026-09-22 ### 追加 diff --git a/README.md b/README.md index f861f3bdb..ac2b45696 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Claude Code / Codex / Kiro CLI / agy 向けのスキル・MCP設定を共有す このマーケットプレイスは、チーム全体でAI開発ツール(Claude Code / Codex / Kiro CLI / agy)の導入を加速するための事前設定されたプラグインを提供します。 -**NDFプラグイン v10.16.0** は、同じ `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.1** は、同じ `ndf@ai-plugins` という名前で Claude Code / Codex / Kiro CLI / agy へ配布されるプラグインです。配布物は `plugins/ndf/` の1ディレクトリにまとまっており、Skill の実体は `plugins/ndf/skills/` の1箇所だけです。どのランタイムへ配るかは `plugins/ndf/manifests/*-skills.txt` が決めます。 - **公開Skills**: Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個に分離。 - **元Skills(45個)**: @@ -110,7 +110,7 @@ hook を効かせる手順と、新しい版へ入れ替える手順は | プラグイン名 | バージョン | 説明 | 詳細 | |------------|----------|------|------| -| **ndf** | 10.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) | +| **ndf** | 10.16.1 | Claude Code / Codex / Kiro CLI / agy へ 1 ディレクトリから配布する NDF プラグイン。8個の専門エージェント(Claude版)、公開Skills(Claude Code向け core 45個、Kiro向け core 44個、Codex向け core 43個、agy向け core 43個)、4ランタイム共通の作業ツリー運用フック(PreToolUse / SessionStart / userPromptSubmit / agentSpawn / PreInvocation)、Claude Stopフック、Codex/Kiro向け通知・実行補助を提供。v4.0.0 で Codex MCP サーバを廃止し、`/ndf:external-ai` skill + `corder` エージェント経由の CLI 直接実行に一本化。 | [README](./plugins/ndf/README.md) | | **playwright-kit** | 2.0.3 | Playwright による E2E テストの計画・実装・証跡管理を提供するプラグイン。ページ役割からのテスト計画、動画 / trace 付きスクリプト実装、レポート生成と Drive 保管、playwright_kit ランタイム(init、a11y / CWV スキャン)の 4 Skill。NDF v7.0.0 で分離。 | [README](./plugins/playwright-kit/README.md) | ### 変更履歴 diff --git a/docs/ndf-version-decisions.md b/docs/ndf-version-decisions.md index 06f23abd4..512711f55 100644 --- a/docs/ndf-version-decisions.md +++ b/docs/ndf-version-decisions.md @@ -1,4 +1,4 @@ -# NDF の版ごとの決定と理由(v10.12.0〜v10.16.0) +# NDF の版ごとの決定と理由(v10.12.0〜v10.16.1) `CLAUDE.md` から移した、出た版の記録である。**その版で何を決め、なぜそう決めたか**を残す。 変更点の列挙は `CHANGELOG.md` にあり、こちらは判断の理由を持つ。**`CLAUDE.md` へ書くのは @@ -238,3 +238,22 @@ agy の 7 回だけで、提案の所要の中央値も agy が最も長かっ **テストの実行中は `MONITOR_` で始まる環境変数を、根の `conftest.py` の 1 か所で外す**(#678)。 名前を並べずに接頭辞で一致させるのは、上限の種類が増えるたびに一覧へ足し忘れる形を作らないためで、 収集より前に外すのは、テストの本体を読み込む時点で上限を決める実装があるためである。 + +v10.16.1 で 10.16.0 のリリース後テストの不合格 2 件を塞いだ(マイルストーン 17「10.16.0 の +リリース後テストで不合格だった条件」、#811 #813)。 + +**照合の表へ足す文言は、実物に出所を持つものだけにする**(#811)。記録(CLI の会話と収束ループの +標準エラー)か、導入済みの実行ファイルに埋め込まれた文字列に当たるものだけを足した。出所の無い行は +合っているかをテストで確かめられず、**書いた側の思い込みがテストの期待値にも入って通ってしまう**。 +claude の旧い形は記録にも実行ファイルにも 0 件のため足していない。**足す文言は行頭で始まる形に +固定する** ── 担当は作業中のコマンドの出力(差分・ファイルの中身)も標準エラーへ書くため、文言を +含む文書やテストを読み上げた行に一致させない。見逃しは 1 度起動し直すだけで済むが、誤検知は結果を +書ける担当を止める。**期間や種類は 1 行の照合で覆う** ── 形ごとに行を足すと、CLI が種類を増やす +たびに照合が遅れる。再試行の上限は最後の状態が 429 のときだけ利用上限と読む ── 同じ文言は 503 +などの一時的な誤りでも出て、そちらは起動し直せば解けうる。 + +**確認コマンドを起動できないときは、例外の種類を問わず「通らない」として返す**(#813)。権限の +拒否だけを足す形では、実行形式でないファイルで同じ落ち方が残る。**読めない検索のパスで見つからない +ときも、理由は「実行できない」として出す** ── Python の起動は「見つからない」と「実行権が無い」を +同じ例外で返し、見分けるには検索のパスを自分で辿り直すことになって、確認を 1 つ走らせる関数の範囲を +超える。理由に権限の拒否が出れば、利用者は検索のパスとファイルの権限を見ればよい。 diff --git a/docs/specifications/cross-review-launch-outcome.md b/docs/specifications/cross-review-launch-outcome.md index d6339487a..dc849449c 100644 --- a/docs/specifications/cross-review-launch-outcome.md +++ b/docs/specifications/cross-review-launch-outcome.md @@ -71,6 +71,44 @@ pid だけへシグナルを送っており、CLI が起こした子プロセス **既存の致命の照合は、kiro の実物と claude の JSON に一致しない。** #619 が再現した形である。 +**codex と claude の上限の文言は、2026-09-22 に `develop`(90c0f06f、Python 3.14.4、 +codex-cli 0.154.0、claude 2.1.278)で集めた実物から採った。** 出所と件数は次のとおりである。 + +| CLI | 集めた経路 | 絞り込みの条件 | 件数 | +| --- | --- | --- | ---: | +| codex | 記録(`~/.codex/sessions`)の誤りの本文(`error.message`) | 誤りの種別が `usage_limit_exceeded`。1 形 | 62 | +| codex | 収束ループのラウンドで上限に当たった標準エラーの記録(2026-09-22 16:35 UTC、132 行) | 行頭に `ERROR: ` の印が付く。1 形 | 2 | +| codex | 導入済みの実行ファイルに埋め込まれた文字列 | 書き出しが `You've hit your usage limit` または `exceeded retry limit` | 6 形 | +| claude | 会話の記録(`~/.claude/projects`)の本文 | API の誤りの印(`isApiErrorMessage`)が真。対象期間 2026-09-01〜22。4 形 | 5,103 | +| claude | 導入済みの実行ファイルに埋め込まれた文字列 | 期間を書かない形 | 1 形 | +| kiro / agy | 手元の記録(`~/.kiro` / `~/.gemini` / `antigravity-cli/cli.log`) | 利用上限の実物 | 0 | + +**codex の 6 形は、照合では 2 形(利用上限・再試行の上限)に集約される。** 実行ファイルの +6 形は、書き出しに続く案内文の違いまで数えた文字列の数である。照合が読むのは書き出しの +2 通りだけで、続く案内文は読まない(「上限の検知」)。 + +**claude の 5 形は、書き出しの後に差し込む期間と種類だけが違う。** 記録の 4 形と実行ファイルの +1 形の内訳は次のとおりである。 + +| 種類 | 記録の件数 | +| --- | ---: | +| 週 | 3,751 | +| セッション | 758 | +| 個人の支出 | 593 | +| 月の支出 | 1 | +| 期間を書かない形 | 0(出所は実行ファイルの文字列) | + +**逐語の文言の正本は、照合のテストの入力である** +(`plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py`)。この文書は出所と形の +種類だけを持ち、文言そのものを写さない。同じ文言を 2 か所に置くと、CLI が文言を変えたときに +片方だけが古くなり、どちらが実物かを読み手が決められなくなる。 + +**claude の旧い形(`Claude AI usage limit reached`)は、記録にも実行ファイルにも 0 件である。** +出所が無いため照合の表に置かない。記録に現れたら、同じ手順で出所を書いて足す。 + +**JSON の形で起動した claude が上限のときに標準エラーの記録へ書いた実物は、手元に 0 件で +ある。** 起動した claude の上限を読む経路は、標準出力の状態コードのままである。 + **プロセスグループの停止は 2026-09-15 に測った。** ジョブ制御を有効にして起動した CLI を グループへのシグナルで止めると、3 秒後に子プロセスが書く結果ファイルは書かれなかった。 @@ -86,6 +124,8 @@ pid だけへシグナルを送っており、CLI が起こした子プロセス | CLI 自身の上限は結果なしの状態のまま、理由だけを分ける | agy は自分の上限に当たると終了コード 0 で終わり、結果ファイルを書かない。監視から見れば「終わったが結果が無い」で正しい | | 利用上限の文言は、標準エラーの記録を全担当で見る | 既存の照合が引用・表・grep 形式を除外する。実測では claude の JSON もこの判定に飲み込まれなかった | | 標準出力の記録は claude だけ、JSON 向けの照合で見る | JSON は 1 行に引用符を多く含む。行単位の引用の判定が、引用の内側と判定してしまう | +| 照合の表へ足す文言は、記録か導入済みの実行ファイルに出所を持つものだけにする | 出所の無い行は、合っているかをテストで確かめられない。書いた側の思い込みがテストの期待値にも入り、通ってしまう | +| 利用上限の文言は、期間や種類を問わず 1 行の照合で読む | claude は書き出しの後に期間や種類(週・セッション・支出)を差し込み、codex も同じ書き出しで 5 形を持つ。形ごとに行を足すと、CLI が種類を増やすたびに照合が遅れる | | 読めない結果を共通の語彙に入れる | 結果ファイルが JSON として読めないことは、2 つの Skill が同じ形で見ている | | 判定の値が無い・未投稿は cross-review 固有に残す | 結果ファイルの中身とレビューの投稿の話で、監視も cross-refactoring も知りえない | | 監視の結末に理由の欄を持たせ、無ければ状態からの既定を使う | 利用上限と CLI の上限は、監視の状態からは決まらない | @@ -152,10 +192,37 @@ pid だけへシグナルを送っており、CLI が起こした子プロセス | `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` | 同上 | 同上 | 下の**利用上限の行**(codex と claude。期間や種類を差し込む形を 1 行で覆う) | +| `usage_limit` | 同上 | 同上 | 下の**再試行の上限の行**(codex) | | `usage_limit` | claude の標準出力の記録 | 同上 | `"api_error_status"\s*:\s*429` | -| `early_error` | 標準エラーの記録 | 同上 | `^HTTP/\d\S* (?:401\|403) ` と、残りの既存の致命 | +| `early_error` | 標準エラーの記録 | 同上 | `^HTTP/\d\S* 401 ` / `^HTTP/\d\S* 403 ` と、残りの既存の致命 | | `cli_timeout` | 標準エラーの記録 | **終了した後、結果ファイルが無いときだけ** | `print timeout after \S+ with turn in progress` | +**選択肢を持つ 2 行は、表の外に置く。** 表のセルに入れると縦棒を退避する必要があり、退避した +縦棒は選択肢の区切りではなくリテラルの縦棒として読める。次の 2 行は実装 +(`plugins/ndf/scripts/lib/monitor.py`)の文字列と 1 字ずつ一致する。 + +**利用上限の行**(codex と claude): + +```text +^(?:ERROR:\s*)?You['’]ve hit your (?:[\w'’ ]+ )?(?:limit|budget)\b +``` + +**再試行の上限の行**(codex): + +```text +^(?:ERROR:\s*)?exceeded retry limit, last status: 429\b +``` + +**codex と claude の上限の 2 行は、行頭で始まる形だけを読む。** 担当は作業中のコマンドの +出力(差分・ファイルの中身)も標準エラーの記録へ書くため、文言を含む文書やテストを読み +上げた行が記録に入る。行頭に固定すると、文の途中・差分の行・字下げした文字列は一致しない。 +codex は誤りの行の先頭に印(`ERROR: `)を付けるため、その印を省略できる形にしてある。 +**見逃しは 1 度起動し直すだけで済むが、誤検知は結果を書ける担当を止める。** + +**再試行の上限は、最後の状態が 429 のときだけ利用上限と読む。** 同じ文言は状態コードを +差し込んで作られ、503 などの一時的な誤りでも出る。一時的な誤りは起動し直せば解けうる。 + **照合の順序は、利用上限 → 致命 → 警告の見た目の致命である。** 同じ記録に利用上限と他の 致命が両方あれば、理由は利用上限になる。上限で落ちた後に別の文言が続く形が普通で、上限の ほうが原因であるためである。 @@ -234,7 +301,8 @@ cross-refactoring の取り込みが従う契約を、ここで定める。**契 | 観点 | 確かめ方 | | --- | --- | | 理由の語彙と起動し直しの可否が 1 か所にあり、結末を読む関数が失敗しないこと | `plugins/ndf/scripts/tests/test_monitor_outcome_unit.py` | -| 利用上限の文言を検知し、引用・表・grep 形式で誤検知しないこと | `plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` | +| 実物の行(codex の 2 形を行頭の印の有無で 3 行、claude の 5 形)が利用上限の照合に一致し、表・バッククォート・引用・grep 形式・差分・文の途中の行が一致しないこと。照合の関数を直接呼んで確かめる | `plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py` | +| 監視のプロセスを通したとき、codex の 3 行(2 形と行頭の印あり)と claude の週・セッションの 2 行で理由が利用上限・終了コード 4 になること。再試行の上限の 503 の行と、引用・差分の行では止まらないこと | 同上 | | CLI の上限の文言を、終了して結果ファイルが無いときだけ理由にすること | 同 `tests/test_launch_print_timeout.py` | | CLI が独立したプロセスグループで起動し、グループごと止まること | 同 `tests/test_launch_cli_process_group.py` | | 結果の取り込みが理由と監視の詳細を残し、終了コードを変えないこと | 同 `tests/test_read_result_reason.py` | @@ -243,13 +311,30 @@ cross-refactoring の取り込みが従う契約を、ここで定める。**契 | 文書の分量が分割の基準を超えないこと | `python3 scripts/check-doc-line-limit.py` | | 参照のリンクが解決できること | `python3 scripts/check-markdown-links.py` | +### リリース後テストで確かめる観点 + +**次の 3 つは、10.16.1 を配布した利用者の環境で確かめる。** 上の観点は監視へ文言を与えて +結末を読むが、配布物を導入した先で同じ結末になることと、利用者の検索のパスで開始の手順が +通ることは、配布した版を使う環境でしか確かめられない。 + +| 観点 | 確かめ方 | +| --- | --- | +| CLI が 1 者欠けても、開始の手順が終了コード 0 で終わり、使える者だけで 2 席を埋めること | 利用者の検索のパス(`PATH`)から `kiro-cli` だけを隠し、読めない `/root/.local/bin` を含んだまま、検証用の Pull Request でクロスレビューの開始の手順を実行する。席の決め方は[参加者と席](cross-review-participants-and-seats.md)にある | +| 導入先の監視で、codex の 2 形と claude の 5 形が理由「利用上限」・起動し直しの可否が偽になること | 導入先の監視へ実物の行を 1 つずつ標準エラーの記録として与え、結末を読む | +| 10.16.0 で合格した kiro の 1 行と claude の JSON の上限の状態コードが、同じ理由・同じ可否のままであること | 同じ手順で、2 つの形を 1 つずつ与えて結末を読む | + +**JSON の形で起動した claude が上限のときに標準エラーの記録へ書く文言も、このときに確かめる。** +手元の記録には 0 件のため、照合の表には足していない(「背景」)。 + ## 関連リンク - [issue #729](https://github.com/devbasex/ai-plugins/issues/729) — 結果なしの判断を共通層へ移す - [issue #619](https://github.com/devbasex/ai-plugins/issues/619) — 利用上限で止まった担当の空振りの起動し直し - [issue #584](https://github.com/devbasex/ai-plugins/issues/584) — 止めた担当が後から結果ファイルを書く +- [issue #811](https://github.com/devbasex/ai-plugins/issues/811) — codex と claude の実物の文言が照合の表に無い - [結果なしの取り込みと開き直し](cross-refactoring-apply-intake.md) — cross-refactoring 側の読み取りを使う仕様 - [PR #791](https://github.com/devbasex/ai-plugins/pull/791) — 実装 +- [PR #820](https://github.com/devbasex/ai-plugins/pull/820) — codex と claude の実物の文言を照合の表へ足す実装 - [`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 index fbe0175be..0409dd644 100644 --- a/docs/specifications/cross-review-participants-and-seats.md +++ b/docs/specifications/cross-review-participants-and-seats.md @@ -79,6 +79,8 @@ | --- | --- | | 使える者を決める規則を共通層の 1 つの関数へ置き、Skill は結果を状態ファイルと終了コードへ写すだけにする | 規則を Skill ごとに書くと、参加の母集合の作り方・除外の検査・確認の扱いが 2 か所にでき、片方だけが古くなる | | 認証の確認は止めずに結果だけを返す形にし、確認コマンド・未認証の文言・時間切れの秒数は変えない | 確認と中断が 1 つの関数にあると、使える者で回す経路から呼べない。止めるかどうかは全員を要する指定が決める | +| 確認コマンドを起動できないときは、例外の種類を問わず「通らない」として返す | 権限の拒否だけを足す形では、実行形式でないファイルで同じ落ち方が残る。起動できない理由が何であっても、その CLI は使えない | +| 読めない検索のパスで見つからないときも、理由は「実行できない」として出す | 見つからないことと実行権が無いことは同じ例外で返る。見分けるには検索のパスを自分で辿り直すことになり、確認を 1 つ走らせる関数の範囲を超える。理由に権限の拒否が出れば、利用者は検索のパスとファイルの権限を見ればよい | | 参加者は「既定に足す者を加え、外す者を除く」で決め、既定は Skill ごとの関数が持つ | 使う側を並べる形は、ホストが変わるたびに一覧を書き直すことになる | | 全員が揃わないなら始めたくない運用のために、従来の関門を指定で選べるようにする | 既定を変えるだけでは、揃っていることを要求する運用が選べなくなる | | 席は 2 つとし、使える者 → ホスト → 同じランタイムの 2 つ目の順で埋める | 各ラウンドで 2 つの目で見ることを最優先にする。同じ言語モデルの 2 つの文脈より、違う言語モデルの 2 つの文脈のほうが観点が分かれる | @@ -158,6 +160,20 @@ graph TD **外した者へは確認コマンドを呼ばない。** 確認の回数は「参加者の数 + 埋め合わせが要るときの ホストの 1 回」を超えない。 +**確認コマンドを起動できないときは、理由を問わず「通らない」として返す。** 理由は 3 つに +分かれる。 + +| 起動できない理由 | 返す理由 | +| --- | --- | +| コマンドがどこにも無い(見つからない例外) | `コマンドが見つかりません` | +| 確認の秒数の中で応答しない | `<秒数> 秒で応答しませんでした` | +| 実行できない(読めないディレクトリを含む検索のパスでの権限の拒否、実行権の無いファイル、実行形式でないファイル) | `コマンドを実行できません(<例外の説明>)` | + +**検索のパス(`PATH`)に読めないディレクトリがあると、コマンドがどこにも無いときでも +見つからない例外ではなく権限の例外が上がる。** 実行形式でないファイルも同じ形で落ちる。 +3 つ目をまとめて返すことで、どの理由でも参加者の解決は続き、使える者だけで収束ループが +始まる。 + cross-review の新規の初期化は、この結果に席の埋め合わせを足して状態ファイルへ書く。 ```mermaid @@ -373,7 +389,8 @@ graph TD | 観点 | 確かめ方 | | --- | --- | | 使える者の解決が、通らない者を外して続け、外した者へ確認を呼ばず、名前の矛盾と欠けを例外にすること | `plugins/ndf/scripts/tests/test_lib_participants.py` | -| 認証の確認が失敗で例外を上げず、理由を返すこと | 同 `test_auth_probe.py` | +| 認証の確認が失敗で例外を上げず、理由を返すこと。起動できない 2 形(読めないディレクトリを含む検索のパス・実行形式でないファイル)でも理由を返すこと | 同 `test_auth_probe.py` | +| 2 つの開始の手順が、読めないディレクトリを含む検索のパスで、欠けた者を外して始まること | `plugins/ndf/skills/cross-review/tests/test_state_review_pool.py` / `plugins/ndf/skills/cross-refactoring/tests/test_init.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` | @@ -391,8 +408,10 @@ graph TD - [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 #813](https://github.com/devbasex/ai-plugins/issues/813) — 読めない検索のパスで確認が権限の例外を上げ、開始の手順ごと落ちる - [issue #648](https://github.com/devbasex/ai-plugins/issues/648) — 再開で渡した引数が無視される - [PR #793](https://github.com/devbasex/ai-plugins/pull/793) — 実装 +- [PR #820](https://github.com/devbasex/ai-plugins/pull/820) — 起動できない例外を「通らない」として返す実装 - [`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) diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 00b9a7527..e4432a166 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.16.0` | 利用者が常用してよい | +| 正式版 | `10.16.1` | 利用者が常用してよい | | 開発版 | `10.17.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.17.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.16.0` の次を開発するなら `10.17.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.16.1` の次を開発するなら `10.17.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.17.0-dev.3` の次は `10.17.0` diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 292dc6107..ddf8c0dee 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.16.0", - "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.", + "version": "10.16.1", + "description": "Claude Code plugin (v10.16.1): 8 specialized agents and 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index 8882ae580..62ca0211b 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.16.0", - "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.", + "version": "10.16.1", + "description": "Codex plugin (v10.16.1): 43 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 1d209dda9..e13d1e946 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -89,7 +89,7 @@ bash plugins/ndf/dev.kiro/install.sh --dry-run ```bash python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v10.16.0) +# => NDF統合開発エージェント(Kiro CLI用 / v10.16.1) ``` ### agy @@ -119,32 +119,21 @@ agy plugin list # => {"imports":[{"name":"ndf","source":"antigravity","components":["skills","agents","hooks"]}]} ``` -## v10.16.0 へ更新するとき +## v10.16.1 へ更新するとき -**`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) にあります。 +**収束ループが、codex と claude の利用上限で止まった担当を起動し直さなくなり、読めない +ディレクトリを含む `PATH` でも始まるようにしました**(マイルストーン 17「10.16.0 のリリース後 +テストで不合格だった条件」、#811 #813)。Skill の数は変わりません。引数・Skill・スクリプトの +削除や改名は無く、記録の移行も要りません(前の版で始めた状態ファイルはそのまま読めます)。 +変更点の一覧は [CHANGELOG.md](../../CHANGELOG.md) にあります。 -**正式版です。** `main` に載ります。開発版 `10.16.0-dev.1` の中身に、statusline の変更(#806)を -加えました。statusline の変更は開発版を経ていません。 - -**`cross-refactoring` の既定の参加者から agy が外れます。** 既定は codex / kiro とホストです。 -これまでどおり agy に提案と適用をさせるなら `--include agy` を渡します。 +**正式版です。** `main` に載ります。中身は開発版 `10.16.1-dev.1` と同じで、版数の接尾辞だけを +外しました。 | 変わったこと | 中身 | | --- | --- | -| **使える者だけで始まります**(#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_` で始まる環境変数を延ばしたシェルから全体のテストを起動しても、同じ件数が通ります | +| **codex と claude の利用上限を理由として報告します**(#811) | 担当が利用上限で止まったときの実物の文言(codex の 2 形・claude の 5 形)を照合に足しました。理由が「結果ファイル無し」ではなく「利用上限」になり、同じラウンドで起動し直しません。行頭で始まる行だけを読むため、担当が差分や文書を読み上げた行では止まりません | +| **読めないディレクトリを含む `PATH` でも始まります**(#813) | 認証の確認が、確認コマンドを起動できない理由(権限の拒否・実行形式でないファイル)を「通らない」として返します。`PATH` に読めないディレクトリがあり、CLI が 1 者欠けている環境でも、2 つの開始の手順は終了コード 0 で終わり、使える者だけで始まります | 正式版のチャネル(ref を指定せずに登録した取得元)なら、次で入れ替わります。**動いているセッションには 反映されない**ため、更新したあとは起動し直してください。開発版を試すために `develop` を登録した @@ -161,15 +150,14 @@ codex plugin add ndf@ai-plugins ### 手元で確かめる -どれも `--help` を読むだけで、課題もファイルも書き換えません。`$SCRIPTS` はプラグインの +どれもファイルを読むだけで、課題もファイルも書き換えません。`$SCRIPTS` はプラグインの `scripts/` の絶対パスで、決め方は [development-workflow/references/scripts-lookup.md](skills/development-workflow/references/scripts-lookup.md) にあります。 ```bash -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 の参加者を名指しで変えられる +grep -q "You\['’\]ve hit your" "$SCRIPTS/lib/monitor.py"; echo "exit=$?" # 0 なら codex と claude の上限の文言を読む +grep -q 'except OSError' "$SCRIPTS/lib/auth.py"; echo "exit=$?" # 0 なら起動できない確認コマンドで落ちない ``` ## Playwright テストについて @@ -313,7 +301,7 @@ agy models # 認証の確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.16.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.16.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -335,14 +323,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.16.0/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.16.1/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.16.0 +# => ndf@ai-plugins installed, enabled 10.16.1 ``` 抑止していない Skill(`markdown-writing` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/dev.agy/plugin.json b/plugins/ndf/dev.agy/plugin.json index b371a4545..5a522db25 100644 --- a/plugins/ndf/dev.agy/plugin.json +++ b/plugins/ndf/dev.agy/plugin.json @@ -1,5 +1,5 @@ { "name": "ndf", - "version": "10.16.0", - "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." + "version": "10.16.1", + "description": "Antigravity CLI plugin (v10.16.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." } diff --git a/plugins/ndf/scripts/lib/auth.py b/plugins/ndf/scripts/lib/auth.py index c981f2824..e7fe4b4f2 100644 --- a/plugins/ndf/scripts/lib/auth.py +++ b/plugins/ndf/scripts/lib/auth.py @@ -49,6 +49,10 @@ def _run_probe(probe: tuple[str, ...]) -> tuple[bool, str]: 理由は stderr か stdout の先頭 200 文字。終了コード 0 でも未認証の文言を含めば 通らなかったものとする(kiro は成否を終了コードで表さない)。 + + **起動できない理由が何であっても「通らない」として返す**(#813)。PATH に読めない + ディレクトリがあると、コマンドがどこにも無いときに権限の例外が上がる。実行形式で + ないファイルも同じ形で落ちる。見つからない例外は下位にあるため先に捕まえる。 """ try: r = subprocess.run(list(probe), capture_output=True, text=True, @@ -57,6 +61,8 @@ def _run_probe(probe: tuple[str, ...]) -> tuple[bool, str]: return False, "コマンドが見つかりません" except subprocess.TimeoutExpired: return False, f"{AUTH_PROBE_TIMEOUT} 秒で応答しませんでした" + except OSError as exc: + return False, f"コマンドを実行できません({exc.strerror or exc})" 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] diff --git a/plugins/ndf/scripts/lib/monitor.py b/plugins/ndf/scripts/lib/monitor.py index cac43b06f..f21a05422 100755 --- a/plugins/ndf/scripts/lib/monitor.py +++ b/plugins/ndf/scripts/lib/monitor.py @@ -161,6 +161,15 @@ def _seat_or_both(value: str) -> str: re.compile(r"\b(?:quota exceeded|rate limit exceeded)\b", re.IGNORECASE), # HTTP 429 の状態行 re.compile(r"^HTTP/\d\S* 429 ", re.MULTILINE), + # codex と claude の実物(#811)。**行頭で始まる形だけを読む。** 担当は作業中の + # コマンドの出力(差分・ファイルの中身)も err.log へ書くため、文書やテストを + # 読み上げた行に一致させない。codex は誤りの行に `ERROR: ` を付ける。 + # claude は `You've hit your ` の後に期間や種類(週・セッション・支出)を差し込む。 + re.compile(r"^(?:ERROR:\s*)?You['’]ve hit your (?:[\w'’ ]+ )?(?:limit|budget)\b", + re.MULTILINE), + # codex の再試行の上限。**最後の状態が 429 のときだけ利用上限と読む**(503 などの + # 一時的な誤りは起動し直せば解けうる)。 + re.compile(r"^(?:ERROR:\s*)?exceeded retry limit, last status: 429\b", re.MULTILINE), ] # err.log の行頭に近い形で出る **明確な致命** パターン (kill 対象。理由は `early_error`)。 diff --git a/plugins/ndf/scripts/lib/result_posts.py b/plugins/ndf/scripts/lib/result_posts.py index 010e778e5..a9213a7e8 100644 --- a/plugins/ndf/scripts/lib/result_posts.py +++ b/plugins/ndf/scripts/lib/result_posts.py @@ -122,6 +122,31 @@ def _review_body(payload: dict[str, Any], round_no: int, seat: str, intent: str, return "\n\n".join(parts) + "\n" +def _split_findings(findings: list[dict[str, Any]], evacuate_all: bool, + ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]: + """指摘をインラインと総評へ振り分ける。""" + 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] + return inline, evacuated + + +def _review_fields(body: str, posted_as: str, inline: list[dict[str, Any]], + head_sha: str | None, since: str | None) -> dict[str, Any]: + """レビュー API へ渡す fields を組み立てる。""" + 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 + ] + return fields + + 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, @@ -145,21 +170,9 @@ def review_posts(payload_path: pathlib.Path | str, result_path: pathlib.Path | s 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] + inline, evacuated = _split_findings(findings, evacuate_all) 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 - ] + fields = _review_fields(body, posted_as, inline, head_sha, since) extra = {"ident": f"{seat}-r{round_no}", "agent": seat, "seat": seat, "round": round_no, "intent": intent, "posted_as": posted_as, @@ -302,19 +315,9 @@ def _fix_summary_body(fix: dict[str, Any], round_no: int | None, 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 "") - +def _reply_items(resolved: list[dict[str, Any]], deferred: list[dict[str, Any]], + rejected: list[dict[str, Any]], commit: str) -> list[dict[str, Any]]: + """決着・見送り・却下の各要素へ付ける返信の項目を、その順に組み立てる。""" # (要素の列, 返信の定型句, 理由を取り出すキー)。決着は理由の代わりにコミットを添える reply_rules = ( (resolved, "対応しました。", None), @@ -331,15 +334,40 @@ def fix_posts(result_path: pathlib.Path | str, repo: str, pr: int, reply = _reply(entry.get("comment_id"), f"{lead}{note}".strip()) if reply: items.append(reply) + return items + + +def _closing_thread_items(resolved: list[dict[str, Any]], deferred: list[dict[str, Any]], + rejected: list[dict[str, Any]]) -> list[dict[str, Any]]: + """スレッドを決着させる項目を組み立てる。""" # 見送り・却下は既定では決着させない(次のラウンドで見直す)。最終スイープは # スレッドを残さないため、要素の `resolve` を真にして決着まで求める。 closing = resolved + [e for e in deferred + rejected if e.get("resolve")] + items: list[dict[str, Any]] = [] 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}"}}) + return items + + +def fix_posts(result_path: pathlib.Path | str, repo: str, pr: int, + round_no: int | None = None) -> list[dict[str, Any]]: + """修正の結果ファイルから、待ち行列へ積む項目の列を組み立てる。 + + 並びは「返信 → 決着 → まとめ」である。返信を先に置くのは、決着したスレッドが + 畳まれた後に返信が届くと、読み手がその返信を開かないためである。 + """ + fix = _read_json(result_path) + resolved = _dict_items(fix.get("resolved_threads")) + deferred = _dict_items(fix.get("deferred")) + rejected = _dict_items(fix.get("rejected")) + commit = str(fix.get("fix_commit") or fix.get("commit_sha") or "") + + items = _reply_items(resolved, deferred, rejected, commit) + items += _closing_thread_items(resolved, deferred, rejected) items.append({ "kind": "pr-comment", "fields": {"body": _fix_summary_body(fix, round_no, len(resolved), diff --git a/plugins/ndf/scripts/lib/run_metrics.py b/plugins/ndf/scripts/lib/run_metrics.py index e746a8da6..9dd8838e4 100755 --- a/plugins/ndf/scripts/lib/run_metrics.py +++ b/plugins/ndf/scripts/lib/run_metrics.py @@ -328,16 +328,15 @@ def _within_time_bound(started: Optional[_dt.datetime], 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)) + # (args の属性名, 行のキー)。指定の無い(偽の)軸は絞り込まない + equality_filters = (("repo", "repo"), ("kind", "kind"), ("version", "ndf_version")) out = [] for row in rows: started = _parse_time(row.get("started_at")) if not _within_time_bound(started, since, until, until_exclusive): continue - if args.repo and row.get("repo") != args.repo: - continue - if args.kind and row.get("kind") != args.kind: - continue - if args.version and row.get("ndf_version") != args.version: + if any(getattr(args, attr) and row.get(key) != getattr(args, attr) + for attr, key in equality_filters): continue out.append(row) return out diff --git a/plugins/ndf/scripts/lib/transcript_agents.py b/plugins/ndf/scripts/lib/transcript_agents.py index 73798f000..7f8f3411f 100644 --- a/plugins/ndf/scripts/lib/transcript_agents.py +++ b/plugins/ndf/scripts/lib/transcript_agents.py @@ -264,32 +264,46 @@ def _tool_use_ids(rows: list[dict]) -> list[str]: return ids -def _aggregate_token_metrics(rows: list[dict], record: AgentRecord) -> None: - """固定費・最大充填・応答数・モデルを合成でない応答だけで数える(AC24)。 +def _token_stats(rows: list[dict]) -> tuple[int | None, int | None, int | None]: + """合成でない応答から固定費・最大充填・実作業を返す。""" + fixed = None + peak = None + for row in rows: + if row.get("type") != "assistant" or _is_synthetic(row): + continue + total = _input_total(row) + if total is not None: + if fixed is None: + fixed = total + peak = total if peak is None else max(peak, total) + work = peak - fixed if fixed is not None and peak is not None else None + return fixed, peak, work - `record` の `fixed` / `peak` / `work` / `responses` / `model` を埋める。 - """ + +def _model_stats(rows: list[dict]) -> tuple[int, str | None]: + """合成でない応答から応答数と最頻出モデルを返す。""" seen: set[str] = set() models: Counter = Counter() for row in rows: if row.get("type") != "assistant" or _is_synthetic(row): continue - total = _input_total(row) - if total is not None: - if record.fixed is None: - record.fixed = total - record.peak = total if record.peak is None else max(record.peak, total) message_id = _message(row).get("id") if isinstance(message_id, str) and message_id and message_id not in seen: seen.add(message_id) model = _message(row).get("model") if isinstance(model, str) and model: models[model] += 1 - record.responses = len(seen) - if record.fixed is not None and record.peak is not None: - record.work = record.peak - record.fixed - if models: - record.model = models.most_common(1)[0][0] + model = models.most_common(1)[0][0] if models else None + return len(seen), model + + +def _aggregate_token_metrics(rows: list[dict], record: AgentRecord) -> None: + """固定費・最大充填・応答数・モデルを合成でない応答だけで数える(AC24)。 + + `record` の `fixed` / `peak` / `work` / `responses` / `model` を埋める。 + """ + record.fixed, record.peak, record.work = _token_stats(rows) + record.responses, record.model = _model_stats(rows) def _count_interruptions(rows: list[dict]) -> int: @@ -516,18 +530,18 @@ def interrupted( 再び現れない(契約の終わり方の表)。 """ picked = set(agents or ()) + # 記録の属性名 → 期待値。指定の無い(None の)軸は絞り込まない + wanted = {attr: value for attr, value in (("layer", layer), ("depth", depth), + ("parent_agent_id", parent)) + if value is not None} out: list[AgentRecord] = [] for record in records: if record.ending != "rate_limit": continue - if layer is not None and record.layer != layer: - continue - if depth is not None and record.depth != depth: + if any(getattr(record, attr) != value for attr, value in wanted.items()): continue if picked and record.agent_id not in picked: continue - if parent is not None and record.parent_agent_id != parent: - continue out.append(record) return out diff --git a/plugins/ndf/scripts/tests/test_auth_probe.py b/plugins/ndf/scripts/tests/test_auth_probe.py index f8bca89a5..f6de12c26 100644 --- a/plugins/ndf/scripts/tests/test_auth_probe.py +++ b/plugins/ndf/scripts/tests/test_auth_probe.py @@ -53,6 +53,61 @@ def missing(*args, **kwargs): assert messages == ["❌ codex: codex login status"] +def test_probe_reports_a_command_it_cannot_start_with_a_real_unreadable_path( + auth, monkeypatch, tmp_path): + """AC7: 読めないディレクトリだけの PATH で、確認コマンドが見つからないとき。 + + 実際に権限を外したディレクトリを PATH に置いて再現する。権限が効かない実行者 + (root)では条件が成り立たないため、その場合は飛ばす。同じ理由の文言は、例外を + 差し込む次のテストがどちらの実行者でも確かめる。 + """ + unreadable = tmp_path / "unreadable" + unreadable.mkdir() + unreadable.chmod(0o000) + try: + try: + list(unreadable.iterdir()) + pytest.skip("権限が効かない実行者のため、読めない PATH を再現できない") + except PermissionError: + pass + monkeypatch.setenv("PATH", str(unreadable)) + + results, _ = auth.probe_auth(["codex"], info=lambda _m: None, env={}) + finally: + unreadable.chmod(0o700) + + assert results["codex"]["ok"] is False + assert results["codex"]["detail"] == "コマンドを実行できません(Permission denied)" + + +def test_probe_reports_a_command_it_cannot_start(auth, monkeypatch): + """AC7: 起動が権限の例外で終わるときも、例外を上げずに理由を返す。""" + def denied(*args, **kwargs): + raise PermissionError(13, "Permission denied") + + monkeypatch.setattr(auth.subprocess, "run", denied) + + results, _ = auth.probe_auth(["codex"], info=lambda _m: None, env={}) + + assert results["codex"]["ok"] is False + assert results["codex"]["detail"] == "コマンドを実行できません(Permission denied)" + + +def test_probe_reports_a_command_that_is_not_an_executable_format(auth, monkeypatch, tmp_path): + """AC8: 実行形式でないファイルを確認コマンドにしたときも「通らない」を返す。""" + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + fake = bin_dir / "codex" + fake.write_text("\x7fnot an executable\n", encoding="utf-8") + fake.chmod(0o755) + monkeypatch.setenv("PATH", str(bin_dir)) + + results, _ = auth.probe_auth(["codex"], info=lambda _m: None, env={}) + + assert results["codex"]["ok"] is False + assert results["codex"]["detail"] == "コマンドを実行できません(Exec format error)" + + def test_probe_reports_a_timeout(auth, monkeypatch): def time_out(*args, **kwargs): raise subprocess.TimeoutExpired(args[0], kwargs["timeout"]) diff --git a/plugins/ndf/scripts/tests/test_metrics.py b/plugins/ndf/scripts/tests/test_metrics.py new file mode 100644 index 000000000..f8c210e1d --- /dev/null +++ b/plugins/ndf/scripts/tests/test_metrics.py @@ -0,0 +1,245 @@ +"""担当ごとの指標集計に対する現状固定テスト。""" +from __future__ import annotations + +import importlib.util +import pathlib +import sys + +LIB = pathlib.Path(__file__).resolve().parents[1] / "lib" +if str(LIB) not in sys.path: + sys.path.insert(0, str(LIB)) + +SPEC = importlib.util.spec_from_file_location("metrics", LIB / "metrics.py") +assert SPEC is not None and SPEC.loader is not None +metrics = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(metrics) + + +def test_aggregate_current_metrics_for_representative_state() -> None: + """現状固定。代表的な状態辞書から得られる全バケットと比率を記録する。""" + state = { + "items": [ + {"item_id": "R1-001", "status": "done", "budget_exceeded": True}, + {"item_id": "R1-002", "status": "abandoned", "test_failed": True}, + ], + "rounds": [ + { + "round": 1, + "impl": "codex", + "impl_model": {"requested": "gpt-5", "observed": None}, + "reviewers": ["claude", "agy"], + "reviewer_models": { + "claude": {"requested": "sonnet", "observed": "sonnet"}, + "agy": {"requested": "gpt-5", "observed": None}, + }, + "items": ["R1-001", "R1-002"], + "durations": {"apply": 100, "fix": 20}, + "fix_rounds": 2, + "reviewer_seconds": {"claude": 30, "agy": 40}, + "reviews": [ + { + "claude": "REQUEST_CHANGES", + "agy": "REQUEST_CHANGES", + "findings": [ + {"reviewer": "claude", "resolved": True}, + {"reviewer": "claude", "resolved": False}, + {"reviewer": "agy", "resolved": True}, + ], + }, + { + "claude": "APPROVE", + "agy": "REQUEST_CHANGES", + "findings": [], + }, + ], + } + ], + } + + assert metrics.aggregate(state) == { + "impl": { + "codex / gpt-5": { + "rounds": 1, + "applied": 1, + "abandoned": 1, + "fix_rounds": 2, + "budget_exceeded": 1, + "test_failed": 1, + "first_review_total": 1, + "first_review_approved": 0, + "seconds": 120.0, + "first_review_approval_rate": 0.0, + "avg_fix_rounds": 2.0, + "budget_exceeded_rate": 0.5, + "test_failure_rate": 0.5, + } + }, + "reviewer": { + "agy / gpt-5": { + "reviews": 2, + "findings": 1, + "findings_resolved": 1, + "verdict_pairs": 2, + "verdict_agreements": 1, + "seconds": 40.0, + "resolution_rate": 1.0, + "agreement_rate": 0.5, + }, + "claude / sonnet": { + "reviews": 2, + "findings": 2, + "findings_resolved": 1, + "verdict_pairs": 2, + "verdict_agreements": 1, + "seconds": 30.0, + "resolution_rate": 0.5, + "agreement_rate": 0.5, + }, + }, + "unmeasured": [], + "assumed": [ + "round 1: codex は指定した gpt-5 で動いた前提で数える" + "(実測不可)(実装担当)", + "round 1: agy は指定した gpt-5 で動いた前提で数える" + "(実測不可)(レビュー担当)", + ], + } + + +def test_aggregate_current_metrics_for_unmeasured_models_and_missing_verdicts() -> None: + """現状固定。分離理由と判定なしレビューを担当ごとに記録する。""" + state = { + "items": [{"item_id": "R2-001", "status": "done"}], + "rounds": [ + { + "round": 2, + "impl": "claude", + "impl_model": { + "requested": "claude-opus", + "observed": "claude-sonnet", + }, + "reviewers": ["claude", "agy", "kiro", "codex"], + "reviewer_models": { + "claude": {"requested": "claude-sonnet", "observed": "claude-sonnet"}, + "agy": {"requested": "gpt-5", "observed": None}, + "kiro": {"requested": "auto", "observed": None}, + "codex": {"requested": None, "observed": None}, + }, + "items": ["R2-001"], + "reviews": [ + {"claude": "APPROVE", "findings": []}, + {"claude": "APPROVE", "agy": "APPROVE", "findings": []}, + ], + } + ], + } + + result = metrics.aggregate(state) + + # モデル不一致は警告されるが、現状では指定モデルの実装集計に残る。 + assert list(result["impl"]) == ["claude / claude-opus"] + assert result["unmeasured"] == [ + "round 2: ⚠ claude: 指定したモデル claude-opus と実際に動いたモデル " + "claude-sonnet が食い違っています。比較には使えません", + "round 2: kiro の auto はラウンドごとに違うモデルが動きうるため、" + "レビュー担当の集計から分離する", + "round 2: codex はモデルを指定しておらず、実際に動いたモデルも取得できないため、" + "レビュー担当の集計から分離する", + ] + assert set(result["reviewer"]) == {"agy / gpt-5", "claude / claude-sonnet"} + assert result["reviewer"]["claude / claude-sonnet"]["reviews"] == 2 + assert result["reviewer"]["agy / gpt-5"]["reviews"] == 1 + assert result["reviewer"]["claude / claude-sonnet"]["agreement_rate"] == 1.0 + assert result["reviewer"]["agy / gpt-5"]["agreement_rate"] == 1.0 + + +def test_format_report_current_structure() -> None: + """現状固定。format_report の見出し・表の行・注意書きを含む出力構造を記録する。""" + report_metrics = { + "impl": { + "codex / gpt-5": { + "rounds": 1, + "applied": 1, + "abandoned": 0, + "first_review_approval_rate": 1.0, + "avg_fix_rounds": 0.0, + "budget_exceeded_rate": 0.0, + "test_failure_rate": 0.0, + "seconds": 120.0, + } + }, + "reviewer": { + "claude / sonnet": { + "reviews": 1, + "findings": 2, + "resolution_rate": 0.5, + "agreement_rate": 1.0, + "seconds": 30.0, + } + }, + "unmeasured": ["round 2: kiro の auto は分離"], + "assumed": ["round 1: codex は前提で数える"], + } + + report = metrics.format_report(report_metrics) + + # 見出しが含まれること + assert "## 実装担当" in report + assert "## レビュー担当" in report + assert "## 集計から分離したラウンド" in report + assert "## 指定値で代用したラウンド" in report + assert "## 比較として読むときの限界" in report + + # 各表の要点(行データ)が含まれること + assert "| codex / gpt-5 |" in report + assert "| claude / sonnet |" in report + + # unmeasured / assumed / caveats のリスト項目が含まれること + assert "- round 2: kiro の auto は分離" in report + assert "- round 1: codex は前提で数える" in report + for caveat in metrics.COMPARISON_CAVEATS: + assert f"- {caveat}" in report + + # 全体の行数を現状の値で固定する + assert len(report.splitlines()) == 29 + + +def test_format_report_empty_tables_emits_no_records() -> None: + """現状固定。impl と reviewer が空のときに「(記録なし)」が出力されることを記録する。""" + report_metrics = { + "impl": {}, + "reviewer": {}, + "unmeasured": [], + "assumed": [], + } + + report = metrics.format_report(report_metrics) + + # 実装担当とレビュー担当の見出し直後に「(記録なし)」が出ること + assert "## 実装担当\n\n(記録なし)" in report + assert "## レビュー担当\n\n(記録なし)" in report + + # unmeasured / assumed が空のときは見出しが出ないこと + assert "## 集計から分離したラウンド" not in report + assert "## 指定値で代用したラウンド" not in report + + # 比較の限界の見出しと注意書きが残ること + assert "## 比較として読むときの限界" in report + for caveat in metrics.COMPARISON_CAVEATS: + assert f"- {caveat}" in report + + # 完全な出力行の一致を固定する + expected_lines = [ + "## 実装担当", + "", + "(記録なし)", + "", + "## レビュー担当", + "", + "(記録なし)", + "", + "## 比較として読むときの限界", + "", + *[f"- {caveat}" for caveat in metrics.COMPARISON_CAVEATS], + ] + assert report.splitlines() == expected_lines diff --git a/plugins/ndf/scripts/tests/test_models.py b/plugins/ndf/scripts/tests/test_models.py index 20f9bd915..5298df2d9 100644 --- a/plugins/ndf/scripts/tests/test_models.py +++ b/plugins/ndf/scripts/tests/test_models.py @@ -10,7 +10,14 @@ if str(LIB) not in sys.path: sys.path.insert(0, str(LIB)) -from models import ModelSpecError, parse_model_args +from models import ( + ModelSpecError, + is_measurable, + mismatch_warning, + observed_model, + parse_model_args, + separation_reason, +) @pytest.mark.parametrize( @@ -30,3 +37,102 @@ def test_parse_model_args_rejects_invalid_specs( parse_model_args(values) assert message_part in str(exc_info.value) + + +@pytest.mark.parametrize( + ("runtime", "stdout_text"), + [ + ("codex", ""), + ("claude", '{"result": "ok"}'), + ("claude", '{"modelUsage": {broken json'), + ], +) +def test_observed_model_returns_none_when_model_cannot_be_observed( + runtime: str, stdout_text: str +) -> None: + """現状固定。公開出力からモデルを特定できない経路は None を返す。""" + assert observed_model(runtime, stdout_text) is None + + +def test_observed_model_selects_model_with_most_input_tokens() -> None: + """現状固定。複数モデルでは入力トークンが最大のモデル名を返す。""" + stdout_text = """{ + "modelUsage": { + "claude-sonnet": {"inputTokens": 120}, + "claude-opus": {"inputTokens": 450}, + "claude-haiku": {"inputTokens": 30} + } + }""" + + assert observed_model("claude", stdout_text) == "claude-opus" + + +@pytest.mark.parametrize( + ("runtime", "model", "expected"), + [ + ( + "kiro", + None, + "kiro の auto はラウンドごとに違うモデルが動きうる", + ), + ( + "kiro", + "auto", + "kiro の auto はラウンドごとに違うモデルが動きうる", + ), + ( + "codex", + None, + "codex はモデルを指定しておらず、実際に動いたモデルも取得できない", + ), + ("claude", None, None), + ("kiro", "claude-sonnet", None), + ("codex", "gpt-5", None), + ], +) +def test_separation_reason_current_behavior( + runtime: str, model: str | None, expected: str | None +) -> None: + """現状固定。kiro の auto / 未指定かつ実測不可 / 分離しないの 3 分岐を記録する。""" + assert separation_reason(runtime, model) == expected + + +@pytest.mark.parametrize( + ("runtime", "model", "expected"), + [ + ("claude", None, True), + ("codex", "gpt-5", True), + ("kiro", "auto", False), + ("kiro", None, False), + ("codex", None, False), + ], +) +def test_is_measurable_current_behavior( + runtime: str, model: str | None, expected: bool +) -> None: + """現状固定。分離理由の有無に対応する計測可否を記録する。""" + assert is_measurable(runtime, model) is expected + + +@pytest.mark.parametrize( + ("requested", "observed"), + [ + ("gpt-5", None), + (None, "gpt-5"), + (None, None), + ("gpt-5", "gpt-5"), + ], +) +def test_mismatch_warning_returns_none_when_no_conflict( + requested: str | None, observed: str | None +) -> None: + """現状固定。実測なし / 指定なし / 両方なし / 一致では None を返す。""" + assert mismatch_warning("claude", requested, observed) is None + + +def test_mismatch_warning_reports_conflict() -> None: + """現状固定。指定値と実測値が食い違うと警告文字列を返す。""" + assert mismatch_warning("claude", "claude-opus", "claude-sonnet") == ( + "⚠ claude: 指定したモデル claude-opus と実際に動いたモデル claude-sonnet が" + "食い違っています。比較には使えません" + ) diff --git a/plugins/ndf/scripts/tests/test_run_metrics.py b/plugins/ndf/scripts/tests/test_run_metrics.py index c5b58999a..fb276d8d2 100644 --- a/plugins/ndf/scripts/tests/test_run_metrics.py +++ b/plugins/ndf/scripts/tests/test_run_metrics.py @@ -310,3 +310,34 @@ def test_aggregate_by_reason(metrics_tree): def test_aggregate_with_no_summaries(tmp_path): proc = _aggregate(tmp_path / "empty") assert proc.returncode == 0, proc.stderr + + +# ---------- _select の等値の絞り込み(現状固定) ---------- + +_SELECT_ROWS = [ + {"id": "a", "repo": "o/x", "kind": "cross-review", "ndf_version": "1.0.0"}, + {"id": "b", "repo": "o/y", "kind": "cross-review", "ndf_version": "1.0.0"}, + {"id": "c", "repo": "o/x", "kind": "cross-refactoring", "ndf_version": "2.0.0"}, + {"id": "d", "repo": "o/x", "kind": "cross-review", "ndf_version": "2.0.0"}, +] + + +def _select_args(**over): + import argparse + base = {"since": None, "until": None, "repo": None, "kind": None, "version": None} + base.update(over) + return argparse.Namespace(**base) + + +@pytest.mark.parametrize("over, expected", [ + ({}, ["a", "b", "c", "d"]), + ({"repo": "o/x"}, ["a", "c", "d"]), + ({"kind": "cross-refactoring"}, ["c"]), + ({"version": "1.0.0"}, ["a", "b"]), + ({"repo": "o/x", "kind": "cross-review", "version": "2.0.0"}, ["d"]), + ({"repo": "o/z"}, []), + ({"repo": "", "kind": "", "version": ""}, ["a", "b", "c", "d"]), +]) +def test_select_equality_filters(rm, over, expected): + picked = rm._select(_SELECT_ROWS, _select_args(**over)) + assert [row["id"] for row in picked] == expected diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index e5d8cdaa7..aac848e07 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -10,6 +10,7 @@ import os import pathlib import subprocess +import types import pytest @@ -79,8 +80,11 @@ def run_init(refactor_lib, paths, patch_lib, refactor, origin_repo, monkeypatch) refactor_lib = sys.modules["refactor_lib"] probed: list[list[str]] = [] - def _run(args, viewer="someone-else", probe=None): - """`probe` を渡すと確認を差し替える。`{ランタイム: 理由}` の者だけが通らない。""" + def _run(args, viewer="someone-else", probe=None, real_probe=False): + """`probe` を渡すと確認を差し替える。`{ランタイム: 理由}` の者だけが通らない。 + + `real_probe` を立てると差し替えず、止めない確認をそのまま走らせる(#813)。 + """ real_sh = paths.sh def fake_sh(cmd, cwd=None, check=True): @@ -112,7 +116,9 @@ def fake_sh(cmd, cwd=None, check=True): monkeypatch.delenv("CROSS_REFACTORING_TMP_DIR", raising=False) # 認証確認は実際の CLI を起動する。既定では飛ばし、`probe` を渡したときだけ # 止めない確認(`probe_auth`)を差し替えて結果を決める。 - if probe is None: + if real_probe: + monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) + elif probe is None: monkeypatch.setenv("NDF_SKIP_AUTH_CHECK", "1") else: monkeypatch.delenv("NDF_SKIP_AUTH_CHECK", raising=False) @@ -205,6 +211,59 @@ def test_a_failed_probe_drops_the_runtime_and_keeps_going(run_init, tmp_path, ca assert "kiro を担当から外しました(Not logged in)" in capsys.readouterr().err +def test_init_starts_when_an_unreadable_path_hides_a_missing_cli( + run_init, tmp_path, monkeypatch, capsys): + """AC11 — 読めないディレクトリを含む PATH で CLI が 1 つ欠けても始まる(#813)。 + + 確認コマンドは、終わりが分かる短い実行ファイルへ差し替える。欠ける 1 者だけは + どこにも無い名前にし、読めないディレクトリを PATH の末尾へ足す。 + """ + cmd_setup = sys.modules["refactor_lib.commands.setup"] + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + stub = bin_dir / "ndf-stub-ok" + stub.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + stub.chmod(0o755) + unreadable = tmp_path / "unreadable" + unreadable.mkdir() + unreadable.chmod(0o000) + monkeypatch.setattr(cmd_setup.auth, "AUTH_PROBES", { + "claude": ("ndf-stub-ok",), "codex": ("ndf-stub-ok",), + "agy": ("ndf-stub-ok",), "kiro": ("ndf-stub-missing",), + }) + monkeypatch.setenv("PATH", f"{os.environ['PATH']}:{bin_dir}:{unreadable}") + try: + run_init(_args(tmp_path), real_probe=True) + finally: + unreadable.chmod(0o700) + + _, state = _state_of(tmp_path) + assert state["runtimes"] == ["claude", "codex"] + assert list(state["participants"]["unavailable"]) == ["kiro"] + assert "kiro を担当から外しました" in capsys.readouterr().err + + +def test_init_starts_when_a_probe_cannot_be_launched(run_init, tmp_path, monkeypatch): + """AC11 — 起動できない例外は、権限が効かない実行者でも「外して続ける」になる。""" + cmd_setup = sys.modules["refactor_lib.commands.setup"] + + def run(cmd, **kwargs): + if cmd[0] == "kiro-cli": + raise PermissionError(13, "Permission denied") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + # 差し替える先は認証の確認が見る名前だけにする。標準ライブラリの属性を差し替えると、 + # 同じモジュールを使う git の呼び出しまで偽物になる。 + monkeypatch.setattr(cmd_setup.auth, "subprocess", types.SimpleNamespace( + run=run, TimeoutExpired=subprocess.TimeoutExpired)) + run_init(_args(tmp_path), real_probe=True) + + _, state = _state_of(tmp_path) + assert state["runtimes"] == ["claude", "codex"] + assert state["participants"]["unavailable"] == { + "kiro": "コマンドを実行できません(Permission denied)"} + + def test_require_all_stops_without_writing_the_state(run_init, tmp_path): """AC35 — 全員を要する指定では従来の関門で止め、状態ファイルを作らない。""" with pytest.raises(SystemExit) as e: diff --git a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py index 4a04ff71e..d10f26d46 100644 --- a/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py +++ b/plugins/ndf/skills/cross-review/tests/test_monitor_usage_limit.py @@ -62,6 +62,59 @@ ] +# 実物の行の逐語の正本(#811)。確定仕様 +# (docs/specifications/cross-review-launch-outcome.md)の「背景」は出所と形の種類だけを持ち、 +# 文言そのものはここにある。 +# 推測で作った文言は入れない。出所は記録(`~/.codex/sessions` / `~/.claude/projects`)と +# 導入済みの実行ファイルの文字列である。 +CODEX_USAGE_LIMIT = ("You've hit your usage limit. Visit " + "https://chatgpt.com/codex/settings/usage to purchase more credits " + "or try again at 5:44 PM.") +CODEX_USAGE_LIMIT_PREFIXED = f"ERROR: {CODEX_USAGE_LIMIT}" +CODEX_RETRY_429 = "ERROR: exceeded retry limit, last status: 429" +CODEX_RETRY_503 = "ERROR: exceeded retry limit, last status: 503 Service Unavailable" +CODEX_BAD_REQUEST = ('ERROR: {"type":"error","status":400,"error":{"type":' + '"invalid_request_error","message":"The \'ndf-no-such-model-xyz\' model ' + 'is not supported when using Codex with a ChatGPT account."}}') +CLAUDE_WEEKLY = "You've hit your weekly limit · resets Sep 22, 6am (UTC)" +CLAUDE_SESSION = "You've hit your session limit · resets 6:30pm (UTC)" +CLAUDE_INDIVIDUAL_SPEND = ("You've hit your individual spend limit · run /usage-credits " + "to raise it, or visit claude.ai/admin-settings/usage") +CLAUDE_MONTHLY_SPEND = ("You've hit your monthly spend limit. Run /usage-credits to manage " + "your limit and keep using the model or switch models to continue " + "this chat.") +CLAUDE_BARE_LIMIT = "You've hit your limit" + +# 実物として一致すべき 7 行(codex 2 形 + claude 5 形)。行頭の印の有無は codex が決める。 +MEASURED_USAGE_LIMIT_LINES = [ + ("codex 利用上限", CODEX_USAGE_LIMIT), + ("codex 利用上限 行頭の印あり", CODEX_USAGE_LIMIT_PREFIXED), + ("codex 再試行の上限 429", CODEX_RETRY_429), + ("claude 週", CLAUDE_WEEKLY), + ("claude セッション", CLAUDE_SESSION), + ("claude 個人の支出", CLAUDE_INDIVIDUAL_SPEND), + ("claude 月の支出", CLAUDE_MONTHLY_SPEND), + ("claude 期間なし", CLAUDE_BARE_LIMIT), +] + +# 一致してはいけない行。状態が 429 でない再試行の上限・400 の行・引用と差分の形。 +NOT_USAGE_LIMIT_LINES = [ + ("codex 再試行の上限 503", CODEX_RETRY_503), + ("codex 400 の行", CODEX_BAD_REQUEST), + ("表(バッククォートあり)", f"| usage_limit | `{CODEX_USAGE_LIMIT}` | 利用上限 |"), + ("表(バッククォートなし)", f"| usage_limit | {CLAUDE_WEEKLY} | 利用上限 |"), + ("本文のバッククォート", f"上限の文言は `{CLAUDE_SESSION}` である"), + ("本文の「」", f"上限の文言は「{CLAUDE_WEEKLY}」である"), + ("文の途中", f"担当は {CODEX_USAGE_LIMIT} を出して止まった"), + ("リスト", f"- {CLAUDE_MONTHLY_SPEND}"), + ("引用", f"> {CLAUDE_WEEKLY}"), + ("grep 形式", f"plugins/ndf/scripts/lib/monitor.py:160: # {CLAUDE_BARE_LIMIT}"), + ("Python の文字列", f' CLAUDE_WEEKLY = "{CLAUDE_WEEKLY}"'), + ("差分の追加行", f"+{CODEX_USAGE_LIMIT_PREFIXED}"), + ("差分の文脈行", f" {CLAUDE_SESSION}"), +] + + def _write(path: pathlib.Path, text: str) -> pathlib.Path: path.write_text(text + "\n", encoding="utf-8") return path @@ -81,6 +134,22 @@ def test_ten_measured_lines_match_as_the_design_records(tmp_path, monitor_mod, l assert (monitor_mod._scan_early_fatal(log) is not None) is (usage_hit or fatal_hit) +@pytest.mark.parametrize(("label", "line"), MEASURED_USAGE_LIMIT_LINES, + ids=[t[0] for t in MEASURED_USAGE_LIMIT_LINES]) +def test_measured_codex_and_claude_lines_are_usage_limits(tmp_path, monitor_mod, label, line): + """AC1〜AC3 / AC6: 実測の実物の行は利用上限の表に一致する。""" + log = _write(tmp_path / "err.log", line) + assert monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is not None + + +@pytest.mark.parametrize(("label", "line"), NOT_USAGE_LIMIT_LINES, + ids=[t[0] for t in NOT_USAGE_LIMIT_LINES]) +def test_quoted_and_non_limit_lines_are_not_usage_limits(tmp_path, monitor_mod, label, line): + """AC2 の後半 / AC5: 状態が 429 でない行と、引用・差分・文の途中は一致しない。""" + log = _write(tmp_path / "err.log", line) + assert monitor_mod._scan_patterns(log, monitor_mod.USAGE_LIMIT_FATAL) is None + + 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) @@ -217,6 +286,46 @@ def test_usage_limit_stops_the_agent_as_early_error_with_reason_usage_limit(tmp_ assert len(out) == 1 and set(out[0]) == STDOUT_KEYS and out[0]["exit_code"] == 4 +@pytest.mark.parametrize(("agent", "err"), [ + ("codex", CODEX_USAGE_LIMIT_PREFIXED), # AC1 + ("codex", CODEX_USAGE_LIMIT), # AC1 + ("codex", CODEX_RETRY_429), # AC2 + ("claude", CLAUDE_WEEKLY), # AC3 + ("claude", CLAUDE_SESSION), # AC3 +]) +def test_measured_lines_stop_the_agent_with_reason_usage_limit(tmp_path, agent, err): + """AC1〜AC3: 実測の行で担当が止まり、理由は利用上限、終了コードは 4。""" + stem = _finished(tmp_path, agent, err=err) + + 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") + + +def test_the_retry_limit_with_another_status_is_not_a_usage_limit(tmp_path): + """AC2 の後半: 最後の状態が 429 以外なら利用上限にしない。""" + stem = _finished(tmp_path, "codex", err=CODEX_RETRY_503) + + proc = _run_monitor(tmp_path, "codex") + + assert proc.returncode == 3, proc.stderr + assert _outcome(tmp_path, stem)["reason"] == "missing" + + +def test_quoted_measured_lines_do_not_stop_the_agent(tmp_path): + """AC5: 引用・差分・文の途中に出た実物の文言では止まらない。""" + stem = _finished(tmp_path, "codex", + err="\n".join(line for _label, line in NOT_USAGE_LIMIT_LINES), + result=True) + + proc = _run_monitor(tmp_path, "codex") + + assert proc.returncode == 0, proc.stderr + assert _outcome(tmp_path, stem)["reason"] == "ok" + + @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) 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 4fe3d06bc..43242c8e3 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 @@ -9,9 +9,15 @@ from __future__ import annotations import json +import subprocess +import types import pytest +# 子プロセスの起動の本物。テストのフィクスチャが差し替える前に控える(#813 の AC10 で +# 認証の確認だけを本物のまま走らせるため)。 +_REAL_RUN = subprocess.run + def _state(tmp_path, **over): """最小の状態ファイルを組み立ててパスを返す。""" @@ -370,8 +376,9 @@ def new_init(state_mod, monkeypatch, tmp_path): 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)) + def run(*argv: str, failing=None, only=None, real_probe=False): + if not real_probe: + 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()) @@ -506,6 +513,68 @@ def test_the_second_seat_falls_back_to_a_second_copy_when_the_host_is_unavailabl assert _start_round(state_mod, tmp_path) == ["codex", "codex-2"] +# ---------- 読めないディレクトリを含む PATH(#813: AC10) ---------- + +def _stub_cli(bin_dir, *names: str) -> None: + """確認コマンドが終了コード 0 で終わる短い実行ファイルを置く。""" + bin_dir.mkdir(exist_ok=True) + for name in names: + path = bin_dir / name + path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8") + path.chmod(0o755) + + +def _real_subprocess(state_mod, monkeypatch): + """認証の確認だけを本物の起動に戻す。 + + `new_init` は子プロセスの起動を差し替える。差し替え先は標準ライブラリの同じ + モジュールのため、属性を戻すと開始の手順の側まで本物になる。認証の確認が見る + 名前だけを別の入れ物へ向けて、2 つを分ける。 + """ + monkeypatch.setattr(state_mod.auth, "subprocess", types.SimpleNamespace( + run=_REAL_RUN, TimeoutExpired=subprocess.TimeoutExpired)) + + +def test_init_starts_when_an_unreadable_path_hides_a_missing_cli( + new_init, state_mod, monkeypatch, tmp_path, capsys): + """AC10: 読めないディレクトリを含む PATH で CLI が 1 つ欠けても、開始の手順は終わる。 + + 権限が効かない実行者(root)では、欠けた CLI の理由が「コマンドが見つかりません」に + なる。外れて続くことは同じであり、理由の文言は認証の確認のテストが持つ。 + """ + _stub_cli(tmp_path / "bin", "codex", "agy") + unreadable = tmp_path / "unreadable" + unreadable.mkdir() + unreadable.chmod(0o000) + monkeypatch.setenv("PATH", f"{tmp_path / 'bin'}:{unreadable}") + _real_subprocess(state_mod, monkeypatch) + try: + st = new_init(real_probe=True) + finally: + unreadable.chmod(0o700) + + assert st["participants"]["available"] == ["codex", "agy"] + assert list(st["participants"]["unavailable"]) == ["kiro"] + assert _start_round(state_mod, tmp_path) == ["codex", "agy"] + + +def test_init_starts_when_a_probe_cannot_be_launched(new_init, state_mod, monkeypatch, tmp_path): + """AC10: 起動できない例外がどの実行者でも「外して続ける」になることを確かめる。""" + def run(cmd, **kwargs): + if cmd[0] == "kiro-cli": + raise PermissionError(13, "Permission denied") + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(state_mod.auth, "subprocess", types.SimpleNamespace( + run=run, TimeoutExpired=subprocess.TimeoutExpired)) + + st = new_init(real_probe=True) + + assert st["participants"]["available"] == ["codex", "agy"] + assert st["participants"]["unavailable"] == { + "kiro": "コマンドを実行できません(Permission denied)"} + + @pytest.mark.parametrize("argv", [ ("--exclude", "claude"), ("--only", "codex", "--exclude", "codex"),