diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 2a3939ba3..041495617 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.27.3): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 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.27.5): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 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" @@ -22,7 +22,7 @@ { "name": "playwright-kit", "source": "./plugins/playwright-kit", - "description": "Claude Code plugin (v3.0.2): 4 Playwright E2E skills — test planning by page role, script authoring with video / trace evidence, report generation with collected evidence, and the playwright_kit runtime (init, a11y / CWV scans).", + "description": "Claude Code plugin (v4.0.0): 4 Playwright E2E skills — test planning by page role, script authoring with video / trace evidence, report generation with collected evidence, and the playwright_kit runtime (init, a11y / CWV scans).", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index fdd26324b..930b397a9 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -26,6 +26,15 @@ on: - "pytest.ini" - "pyproject.toml" +# 同じ Pull Request で新しい push が来たら、古い実行を取り消す(#2154・#2291 の決定 5)。 +# push(main / develop)は実行ごとに別の組へ入れて最後まで回す。同じ組に新しい実行が入ると、 +# 待っている前の実行は cancel-in-progress が false でも取り消され、コミットごとの記録が欠けるため。 +# 取り消した実行でも集約のジョブ pytest は always() で走り、古いコミットに失敗を付ける。 +# head のコミットのチェックには新しい実行の結果だけが付く +concurrency: + group: ${{ github.workflow }}-${{ github.event_name == 'pull_request' && github.event.pull_request.number || github.run_id }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + jobs: # 配布の PR(版数と説明だけの差分、または develop で通ったコミットを進めるだけの main 宛の # PR)では重いジョブを省く(#1055)。判定は scripts/ci-heavy-skip.py が差分と develop の @@ -75,8 +84,11 @@ jobs: - uses: actions/setup-node@v7 with: node-version: "22" - - name: Install uv - run: python3 -m pip install --upgrade uv + # uv と依存の取得を uv.lock をキーにしたキャッシュで縮める(#2291 の決定 6) + - uses: astral-sh/setup-uv@v10.3.3 + with: + enable-cache: true + cache-dependency-glob: uv.lock # 起点はリポジトリの根に置く。環境は根の pyproject.toml と uv.lock が固定する(#1142 の決定 22)。 # playwright_kit・plugins/ndf の全グループ(--all-extras)・pytest と pytest-xdist が入り、 # 包みを呼ぶ側のテストも同じプロセスで外部パッケージを import できる。 diff --git a/.github/workflows/runtime-plugin-authenticated-smoke.yml b/.github/workflows/runtime-plugin-authenticated-smoke.yml index 15e89f833..25acb945a 100644 --- a/.github/workflows/runtime-plugin-authenticated-smoke.yml +++ b/.github/workflows/runtime-plugin-authenticated-smoke.yml @@ -12,8 +12,6 @@ on: - all - claude - codex - - kiro - - agy jobs: authenticated-smoke: diff --git a/.github/workflows/runtime-plugin-smoke-latest.yml b/.github/workflows/runtime-plugin-smoke-latest.yml index 4495e9fe0..3b53df3e5 100644 --- a/.github/workflows/runtime-plugin-smoke-latest.yml +++ b/.github/workflows/runtime-plugin-smoke-latest.yml @@ -31,7 +31,7 @@ jobs: strategy: fail-fast: false matrix: - runtime: [claude, codex, kiro, agy] + runtime: [claude, codex] env: RUNTIME: ${{ matrix.runtime }} LATEST_VERSIONS: tmp/runtime-smoke-latest/cli-versions.env diff --git a/.github/workflows/runtime-plugin-smoke.yml b/.github/workflows/runtime-plugin-smoke.yml index ff951c3ad..46bdad413 100644 --- a/.github/workflows/runtime-plugin-smoke.yml +++ b/.github/workflows/runtime-plugin-smoke.yml @@ -52,7 +52,7 @@ jobs: strategy: fail-fast: false matrix: - runtime: [claude, codex, kiro, agy] + runtime: [claude, codex] env: SKIP_HEAVY: ${{ needs.ci-scope.outputs.skip == 'true' }} steps: diff --git a/.ndf/pace.json b/.ndf/pace.json index 06976c088..8751d905f 100644 --- a/.ndf/pace.json +++ b/.ndf/pace.json @@ -3,12 +3,12 @@ "fast": { "enabled": true, "modes": ["light", "standard", "legacy-refactor"], - "verify": "python3 plugins/ndf/scripts/release-verification-steps.py verify-install --ref develop --runtimes claude,codex,kiro" + "verify": "python3 plugins/ndf/scripts/release-verification-steps.py verify-install --ref develop --runtimes claude,codex" }, "auto": { "enabled": true, "modes": ["light", "standard", "legacy-refactor"], - "verify": "python3 plugins/ndf/scripts/release-verification-steps.py verify-install --ref develop --runtimes claude,codex,kiro" + "verify": "python3 plugins/ndf/scripts/release-verification-steps.py verify-install --ref develop --runtimes claude,codex" }, "areas": [ {"name": "駆動", "common": true, diff --git a/.ndf/project.json b/.ndf/project.json index 8e2327cb4..d27377eb6 100644 --- a/.ndf/project.json +++ b/.ndf/project.json @@ -192,10 +192,8 @@ "required-checks-check", "runtime-plugin-build-check", "runtime-plugin-validate", - "runtime-smoke (agy)", "runtime-smoke (claude)", "runtime-smoke (codex)", - "runtime-smoke (kiro)", "script-structure-check", "skill-frontmatter-check", "skill-repo-assumptions-check", @@ -209,7 +207,7 @@ }, "delivery": [ { - "target": "ndf プラグイン(Claude Code・Codex・Kiro)", + "target": "ndf プラグイン(Claude Code・Codex)", "kind": "manual", "trigger": "release の工程で版を上げ、main へマージしてタグ ndf--v<版> を打つ", "branch": "main", diff --git a/.ndf/supervise.json b/.ndf/supervise.json index f2ac08a8f..5a6789896 100644 --- a/.ndf/supervise.json +++ b/.ndf/supervise.json @@ -23,6 +23,6 @@ "release": { "form": "package-plugin", "plugin": "ndf", - "runtimes": ["claude", "codex", "kiro"] + "runtimes": ["claude", "codex"] } } diff --git a/AGENTS.md b/AGENTS.md index afdd75dfc..e4517f7c0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,7 +2,7 @@ ## プロジェクト概要 -**Claude Code / Codex / Kiro CLI / agy 向けプラグインマーケットプレイス**の開発プロジェクトです。チーム全体で AI 開発ツールの導入を加速するための事前設定されたプラグインを提供します。 +**Claude Code / Codex 向けプラグインマーケットプレイス**の開発プロジェクトです。Kiro CLI と agy へは配らず、cross-review / cross-refactoring の相手役として起動するだけです。チーム全体で AI 開発ツールの導入を加速するための事前設定されたプラグインを提供します。 **リポジトリ**: https://github.com/devbasex/ai-plugins @@ -116,15 +116,15 @@ ai-plugins/ ├── .claude-plugin/ │ └── marketplace.json # マーケットプレイス定義(必須) ├── plugins/ -│ ├── ndf/ # NDF(4ランタイム共通の単一ディレクトリ) -│ ├── playwright-kit/ # playwright-kit(3ランタイム共通の単一ディレクトリ) +│ ├── ndf/ # NDF(Claude Code・Codex 共通の単一ディレクトリ) +│ ├── playwright-kit/ # playwright-kit(Claude Code・Codex 共通の単一ディレクトリ) │ └── mcp/ -│ └── mcp-*/ # MCPプラグイン10個(3ランタイム共通) +│ └── mcp-*/ # MCPプラグイン10個(Claude Code・Codex 共通) ├── docs/ # リポジトリ知識 ├── factory/ # Web サービス(Neural Development Factory)。プラグインと分け、自分の LICENSE を持つ ├── AGENTS.md # 共通エントリポイント ├── CLAUDE.md # Claude Code固有設定 -├── KIRO.md # Kiro CLI固有設定 +├── KIRO.md # 相手役として起動される Kiro CLI の説明 └── README.md # プロジェクト説明 ``` @@ -144,16 +144,15 @@ ai-plugins/ | [docs/articles/](docs/articles/README.md) | 社外ブログへ載せる記事の正本。**書いた日時点の記録で、以後の構成変更には追随しない** | | [docs/claude-code-skills-survey/](docs/claude-code-skills-survey/01-findings.md) | Claude Code Skills調査レポート(3 本) | | [docs/development-history/](docs/development-history/) | 2026-09-02 までの開発履歴と知見。**以降の振り返りは、起点の issue か Pull Request のコメントに残す**(`/ndf:retrospective`) | -| [plugins/ndf/README.md](plugins/ndf/README.md) | NDFプラグイン(4ランタイム共通) | +| [plugins/ndf/README.md](plugins/ndf/README.md) | NDFプラグイン(Claude Code・Codex 共通) | ## NDFプラグインについて -**NDFプラグイン**は、このマーケットプレイスの主要プラグインです。plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 -- Skill の実体は `plugins/ndf/skills/` の1箇所。配布先は `plugins/ndf/manifests/*-skills.txt` が決める +**NDFプラグイン**は、このマーケットプレイスの主要プラグインです。plugin 名は Claude Code・Codex のどちらでも `ndf` を維持し、配布物は `plugins/ndf/` の1ディレクトリにまとまっています。 +- Skill の実体は `plugins/ndf/skills/` の1箇所。配布先は Claude Code と Codex の 2 つで、配る Skill は `plugins/ndf/manifests/{claude,codex}-skills.txt` が決める - Claude Code版は専門サブエージェントと実行主体(supervisor・worker)の定義(`plugins/ndf/agents/`)、公開Skills、PreToolUse/SessionStart/Stopフックを提供 - Codex版は Codex向け公開Skillsと任意Slack通知hookを提供 -- Kiro版は `plugins/ndf/dev.kiro/install.sh` で `.kiro/skills/`、`.kiro/steering/ndf-policies.md`、`.kiro/agents/ndf.json` を生成 -- agy版は `plugins/ndf/dev.agy/` を `agy plugin install` で導入し、公開Skills・エージェント定義・PreToolUse/PreInvocationフックを提供 +- Kiro CLI と agy へは何も配らない。cross-review のレビュー担当、cross-refactoring の提案・実装の担当(相手役)として、ホスト(Claude Code・Codex)から CLI プロセスで起動する。相手役が読む手順書はホストの導入先から担当の worktree へ写す(起動の形の正本は `plugins/ndf/skills/external-ai/references/cli-kiro.md`・`cli-agy.md`) - 外部AI委譲は `/ndf:external-ai` skill と `corder` エージェント経由で Codex / agy を呼び出す 詳細は各 runtime README と `docs/ndf-plugin-reference.md` を参照。 diff --git a/CHANGELOG.md b/CHANGELOG.md index 99ff26442..126ee3a53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,42 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.5] - 2026-11-01 + +- ndf の cross-refactoring は、想定最大時間を省くと下限の値で動きます。(#2307)(#2312) +- ndf の cross-refactoring は、時間の枠が足りないときに、リファクタリングを飛ばした結果を返します。(#2307)(#2312) +- ndf の supervise は、飛ばしたリファクタリングを止めずにコードレビューへ進めます。(#2307)(#2312) +- ndf の supervise は、駆動の見込みに合わせてステップの締め切りを延ばします。(#2307)(#2312) +- ndf プラグインの supervise.py は、検査のプランの review が収束しないとき、後続のステップへ進まずに止まる(#2302)(#2318) +- `supervise.py` の本番のプランで、導入の確認は stage「配布」として記録される(#2303)(#2318) +- 導入の確認は「リリース後テスト」として記録されない(#2303)(#2318) +- ndf: 受け入れ条件ごとに、マージ前に確かめるかリリース後に確かめるかの区分を Issue の本文へ記録できます(#2305)(#2318) +- ndf: リリースの承認資料とスプリントの閉じる判定は、Issue の本文に記録された区分をもとに受け入れ条件を扱います(#2305)(#2318) +- ndf: 完了判定(quality-gates)は、受け入れ条件ごとに区分を記録します。要求定義では、退行しない条件の前提を今の振る舞いで確かめます(#2305)(#2318) +- NDF の配布先は Claude Code と codex の 2 つです。(#2306)(#2318) +- agy と kiro には NDF の導入物を配布しません。この 2 つは相手役として扱います。(#2306)(#2318) +- ndf: 既定ブランチ宛の PR の本文にある closing keywords は、「閉じる予定の参照」に置き換わる(#2308)(#2318) +- ndf: `sprint-close.py` は、最終工程の記録を確かめてから Issue を閉じる(#2308)(#2318) +- ndf: まとめのプランは「振り返り → Issue を閉じる → 棚卸し」の順に進む(#2308)(#2318) +- ndf: Issue を閉じる時点の決まりは、progress-tracking Skill の「スプリントを閉じる」にまとまっている(#2308)(#2318) + +### 移行の手順 + +- ndf の cross-refactoring: 書き出し済みのプランは自動では書き直しません。古いプランで止まったときは、今と同じく `--refactor-budget-minutes` を付けてプランを書き直してください。(#2307)(#2312) +- NDF を agy または kiro へ入れている場合、新しい版からは更新が届きません。配布から外れた Skill を手元から消すかどうかは、利用者が判断してください。NDF の側から利用者の手元の導入物を消すことはしません。(#2306)(#2318) +- `sprint-close.py` の閉じる条件が増える(契約の変更)。呼ぶのは同じ版の `plugins/ndf` の中のプラン・Skill の本文だけなので、同じ版で呼ぶ側も直す。利用者の手での移行は要らない。この版より前に工程を通り、判定の材料を持たないまま開いている課題の扱いは未決 2(#2228の要求の移行性)(#2308)(#2318) + +## [ndf 10.27.4] - 2026-10-31 + +- ndf の cross-refactoring は、想定最大時間を省くと下限の値で動きます。(#2307)(#2312) +- ndf の cross-refactoring は、時間の枠が足りないときに、リファクタリングを飛ばした結果を返します。(#2307)(#2312) +- ndf の supervise は、飛ばしたリファクタリングを止めずにコードレビューへ進めます。(#2307)(#2312) +- ndf の supervise は、駆動の見込みに合わせてステップの締め切りを延ばします。(#2307)(#2312) + +### 移行の手順 + +- ndf の cross-refactoring: 書き出し済みのプランは自動では書き直しません。古いプランで止まったときは、今と同じく `--refactor-budget-minutes` を付けてプランを書き直してください。(#2307)(#2312) + ## [ndf 10.27.3] - 2026-10-31 - ランナーの中で `issue-file.py create` を使って別のリポジトリへ Issue を立てようとすると、その場では起票せず、起票の依頼として書き出されます。(#2279)(#2283) diff --git a/KIRO.md b/KIRO.md index f15c72bb7..1faad537c 100644 --- a/KIRO.md +++ b/KIRO.md @@ -1,57 +1,17 @@ -# AI Plugins - Kiro CLI開発ガイドライン +# AI Plugins - Kiro CLI の扱い 共通の開発ガイドラインは **@AGENTS.md** を参照してください。 -このファイルにはKiro CLI固有の設定のみを記載します。 +このファイルには Kiro CLI に固有のことだけを書きます。 -## NDFプラグインのセットアップ +## 配布しない。相手役として起動する -### 前提条件 -- Kiro CLI がインストール済み -- Node.js(Slack通知を使う場合) -- Codex CLI(`/ndf:external-ai` skill で外部AI委譲を使う場合、または `--with-codex` で Kiro に Codex MCP サーバ設定を生成する場合): `npm install -g @openai/codex` +NDF とマーケットプレイスのプラグインは Kiro CLI へ配りません(配布先は Claude Code と Codex)。Kiro CLI は、cross-review のレビュー担当と cross-refactoring の提案・実装の担当(相手役)として、ホスト(Claude Code・Codex)から CLI プロセスで起動されるだけです。 -### インストール +- 起動の形(`kiro-cli chat --no-interactive --trust-all-tools …`)の正本は `plugins/ndf/skills/external-ai/references/cli-kiro.md` にあります +- 相手役に `--agent` は渡しません。相手役が読む手順書は、ホストの導入先から担当の worktree の `.kiro/skills/` へ写します(`plugins/ndf/skills/cross-refactoring/scripts/prepare-worktrees.sh`) +- 相手役に使えるかは `.ndf/runtimes.json` と `kiro-cli` の認証で決まります -```bash -# 基本(Skills + agentSpawn / userPromptSubmit フック) -bash plugins/ndf/dev.kiro/install.sh +## 既に Kiro CLI へ NDF を入れた利用者 -# Slack通知も有効化 -bash plugins/ndf/dev.kiro/install.sh --with-slack - -# 全部入り(Slack通知 + Kiro 側 Codex MCP 設定生成) -# 注: NDF の外部 AI 委譲は /ndf:external-ai skill が CLI を直接実行し、Codex MCP に依存しない。 -# --with-codex は Kiro セッションで `mcp__codex__*` を直接呼びたい場合だけ付ける -# (Codex MCP サーバの設定と .kiro/prompts/codex.md を追加する)。 -bash plugins/ndf/dev.kiro/install.sh --with-slack --with-codex -``` - -インストーラーは `plugins/ndf/skills/` から `.kiro/skills/` への symlink、`.kiro/steering/ndf-policies.md`、`.kiro/agents/ndf.json` を生成します。リンクする Skill は `plugins/ndf/manifests/kiro-skills.txt` が決めます。`ndf-policies` だけは steering の生成元として使い、`.kiro/skills/` へは symlink しません(Skill と steering の二重注入を避けるため)。 - -`.kiro/agents/ndf.json` の `hooks` には worktree 運用の 2 つが入ります。 - -| hook | 実行するもの | 起きること | -|---|---|---| -| `agentSpawn` | `scripts/worktree-session.sh` | メインディレクトリに残った未コミット変更の提示。ブランチの追従は既定では行わず、`.ndf/worktree.json` の `follow_branch: true` で有効にする | -| `userPromptSubmit` | `scripts/worktree-guard.sh` | worktree で作業する旨の案内 | - -Kiro CLI では tool 実行前の hook を使いません。この事象でモデルへ案内を渡す手段が終了コード 2 に限られ、それが tool の実行を拒否するためです。拒否しない方針のもとでは置けないため、パスを見ない案内をプロンプト送信時の hook が担います。 - -**どちらもリポジトリ側に `.ndf/worktree.json` があるときだけ動きます。** 無ければ何も出力せず終了コード 0 で終わります。書き方は `plugins/ndf/README.md` の「Hooks」節にあります。 - -### Slack通知の設定(オプション) - -`.env` に以下を設定: -``` -SLACK_CHANNEL_ID=C0123456789 -SLACK_BOT_TOKEN=xoxb-... -SLACK_USER_MENTION=<@U0123456789> -``` - -### 利用方法 - -- スキルはdescriptionのキーワードに基づいて自動参照されます -- ワークフロー系スキル(pr, fix, review等)は手動で指示してください - - 例: 「prスキルの手順に従ってPRを作成して」 -- `plugins/ndf/manifests/kiro-skills.txt` に skill が追加された場合、再度インストーラーを実行してください +配布を終えた版より前に `plugins/ndf/dev.kiro/install.sh` で入れた手元の残し方と消し方は、`plugins/ndf/README.md` の「agy・Kiro CLI への配布の終了」にあります。 diff --git a/README.md b/README.md index e3ea5e31e..b807067a0 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # AI Plugins -Claude Code / Codex / Kiro CLI / agy で使うプラグインを配るマーケットプレイスです。 +Claude Code / Codex で使うプラグインを配るマーケットプレイスです。 入れると、Pull Request の作成・レビュー・修正といった開発の作業を、手順どおりに AI へ任せられます。 よく使う外部サービス(Notion・BigQuery・データベースなど)へつなぐ設定も入ります。 @@ -22,29 +22,10 @@ codex plugin marketplace add https://github.com/devbasex/ai-plugins codex plugin add ndf@ai-plugins ``` -### Kiro CLI +### Kiro CLI と agy -Kiro CLI はマーケットプレイスから入れられません。clone したディレクトリでインストーラーを実行します。 - -```bash -git clone https://github.com/devbasex/ai-plugins.git -cd ai-plugins -bash plugins/ndf/dev.kiro/install.sh -``` - -起動の方法は [plugins/ndf/README.md の「Kiro CLI」](./plugins/ndf/README.md#kiro-cli) にあります。 - -### agy - -agy もマーケットプレイスから入れられません。clone したディレクトリから導入します。 - -```bash -git clone https://github.com/devbasex/ai-plugins.git -cd ai-plugins -agy plugin install plugins/ndf/dev.agy -``` - -hook を効かせる手順は [plugins/ndf/README.md の「agy」](./plugins/ndf/README.md#agy) にあります。 +Kiro CLI と agy へはプラグインを配りません。cross-review と cross-refactoring が、別の AI の意見を得る相手役として CLI を起動するだけです(Claude Code か Codex に ndf を入れれば使えます)。 +前の版で入れた手元の扱いは [plugins/ndf/README.md の「agy・Kiro CLI への配布の終了」](./plugins/ndf/README.md#agykiro-cli-への配布の終了) にあります。 更新の方法と Slack 通知の設定は [plugins/ndf/README.md](./plugins/ndf/README.md) にあります。 @@ -67,10 +48,10 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.3 | 開発の作業手順(Skill)と専門のサブエージェント。4 つの AI ツールへ同じ名前で入る | [README](./plugins/ndf/README.md) | -| **playwright-kit** | 3.0.2 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | +| **ndf** | 10.27.5 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **playwright-kit** | 4.0.0 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | -外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex / Kiro CLI 向け)。 +外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex 向け)。 使うものだけを入れます(Claude Code なら `/plugin install <プラグイン名>@ai-plugins`)。 | プラグイン名 | つなぐ先 | @@ -88,8 +69,8 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.27.3** に入る Skill の数は、AI ツールごとに違います。 -Claude Code向け core 49個、Kiro向け core 46個、Codex向け core 45個、agy向け core 45個です。 +**NDFプラグイン v10.27.5** に入る Skill の数は、AI ツールごとに違います。 +Claude Code向け core 49個、Codex向け core 45個です。 元Skills(49個)の内訳は次のとおりです。 - Pull Request とレビュー (7): pr, pr-tests, fix, pr-review, cherry-pick-pr, deploy, merged - 開発の進め方 (17): development-workflow, requirements-design, design, document-rebuild, document-sources, document-drafting, tdd-cycle, refactoring, quality-gates, release, release-verification, retrospective, out-of-scope, progress-tracking, backlog-refinement, layout-review, estimate @@ -105,7 +86,7 @@ Skill のほかに、次のものが入ります。 | 部品 | できること | | --- | --- | | サブエージェント(Claude Code) | 調査・データ分析・デバッグ・コードレビュー・CI/CD などを任せる専門のサブエージェント 8 個 | -| 外部 AI への委譲 | Codex や agy をバックグラウンドで動かし、別の AI の意見を得る | +| 外部 AI への委譲 | Codex・Kiro CLI・agy をバックグラウンドで動かし、別の AI の意見を得る | | hook | 会話の記録を 90 日以上残す。回答や承認を待つときに Slack へ知らせる。リポジトリに `.ndf/worktree.json` があるときは git worktree での作業を案内する | ## もっと知る diff --git a/conftest.py b/conftest.py index 79a4edaf8..ef6f8edb1 100644 --- a/conftest.py +++ b/conftest.py @@ -16,6 +16,8 @@ シェルから起動しても、既定値を前提にするテストが同じ結果になる(#678) 6. `SHARD_TOTAL` と `SHARD_INDEX` が与えられたら、収集した項目をファイル単位で分け、 自分の分だけを残す。継続的統合がジョブを分けて回すために使う(#882) +7. pytest が SIGTERM で止められたとき・終わったときに、テストが起こした子孫のプロセスを止める + (`scripts/pytest_child_cleanup.py`。#2291) どの束のディレクトリを起点にしても読まれるよう、テストの基準のディレクトリ(rootdir)は 根の設定ファイル(`pytest.ini`)がリポジトリの根へ固定する。 @@ -37,9 +39,11 @@ # `pytester` は playwright-kit の pytest plugin の自己テストが使う。pytest はこの宣言を # 最上位の conftest.py でしか受け付けない。 -pytest_plugins = ["pytester"] - +# `pytest_child_cleanup` は、テストが起こした子孫のプロセスを、pytest が SIGTERM で止められたとき・ +# 終わったときに止める(#2291)。親と xdist の各 worker で働く。 ROOT = Path(__file__).resolve().parent +sys.path.insert(0, str(ROOT / "scripts")) +pytest_plugins = ["pytester", "pytest_child_cleanup"] # `plugins//dev.agy/skills/` は配布 Skill への symlink である。実体は # `plugins//skills/` にあり、そちらを収集する。両方をたどると同じテストが 2 度 diff --git a/docs/glossary.md b/docs/glossary.md index 5d609dcf2..5f3a85aad 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -318,9 +318,10 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 欠けの残る結果 | — | 手順は終わったが、出すはずの材料・記録の一部が無いか、目標(収束など)に届かなかった結果。stopped と終了コード 6 と gaps で返し、ok で返さない | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | | 結果の置き場 | — | 読む側が結果 JSON や材料を探す場所(プランの実行の記録・記録のファイル)。置き場が無いことと、材料が 0 件であることを分けて扱う | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | | 欠け | `gaps` | 結果 JSON の gaps の 1 件。出すはずだったのに出せなかった材料・記録、届かなかった目標を、種類・名前・理由で書く | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | +| ステップの延長 | — | drive のステップの子孫(駆動と、駆動が起動した init)が自分の見込みの秒を延長のファイル(NDF_STEP_EXTEND_FILE の指すパス)へ書き、エンジンがその試行の締め切りを with_margin を足して後ろへずらすこと。前へはずらさない | — | — | — | | 過程の項目 | `process` | 結果 JSON の process。スクリプトが実行したこと(コマンド・操作)と、その結果の要旨を順に並べる | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | | 判断の項目 | `decisions` | 結果 JSON の decisions。スクリプトが決めたことと、その根拠を並べる | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.md` | -| 最終工程の記録 | — | 最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行。sprint-close.py が閉じる前に確かめる | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | +| 最終工程の記録 | — | 最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行のうち、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上のもの(その実行の振り返り)。sprint-close.py が閉じる前に確かめる | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | | 閉じる予定の参照 | — | Pull Request の本文に `最終工程の後に閉じる: #<番号>` と書く形。GitHub は閉じず、sprint-close.py がスプリント課題として集める。宛先が既定ブランチの Pull Request では、NDF の手順が closing keywords をこの形へ置き換える | — | — | `plugins/ndf/skills/progress-tracking/SKILL.md` | ## NDF の Slack 通知(`ndf-notification`) @@ -375,6 +376,7 @@ cross-review / cross-refactoring が参加者の CLI を選び、起動し、監 | モデルを引けない | `model_unavailable` | 担当の CLI が、指定か設定のモデルを使えないと返したこと(参加の確認と起動結果の理由 model_unavailable) | — | — | `docs/specifications/cross-participant-admission-check.md` | | 認証の失効 | `auth_expired` | 認証の状態確認は通るが、更新トークンの失効などで実行のときに認証を作り直せないこと(参加の確認と起動結果の理由 auth_expired) | — | — | `docs/specifications/cross-participant-admission-check.md` | | 既定のモデルへの切り替え | `default_models` | 引数で明示していないモデルを引けない CLI を、既定のモデルで担当に入れること(participants.default_models) | — | — | `docs/specifications/cross-participant-admission-check.md` | +| 相手役 | — | cross-review のレビュー担当、cross-refactoring の提案・実装の担当として、ホストから CLI プロセスで起動されるランタイム(#2231) | — | — | — | ## NDF の cross-review(`ndf-cross-review`) @@ -466,8 +468,12 @@ Pull Request のレビューを CLI へ委譲し、新しい指摘が出なく | 対象のリポジトリ | — | cross-refactoring が Pull Request を取り、作業ディレクトリを作るリポジトリ。打った場所(現在のディレクトリ)が属する git の worktree の根で決まる | — | — | — | | 提案担当 | — | cross-refactoring の propose で改善の提案を出す参加者 | — | — | `docs/specifications/cross-assignee-reassignment-on-no-result.md` | | 着手前のテスト | — | `cross-refactoring` の init が改修の前に 1 回走らせるテスト。CI に任せる戦略では --scope のテストの置き場所の範囲テスト、ほかは全体テスト(round-only はラウンドテストも) | — | — | — | +| 想定最大時間の下限 | `required_budget_minutes` | 着手前のテストの実測から求める、手順の枠と計画の後の見込みが収まる想定最大時間の最小値(分)。init が「--budget-minutes を N 以上に」と案内する N | — | — | — | +| 着手前のテストの初回の上限 | — | 同じ範囲の打ち切りと実測が配分の履歴に無いときの、着手前のテストの上限(秒)。max(0.10·B, 3·s) で、s は手元の全体テストの直近の実測 m → 宣言の w の順に採る(どちらも無ければ 0.10·B) | — | — | — | +| 想定最大時間の出所 | `budget_source` | その実行の想定最大時間をどこから決めたか。explicit(引数か宣言で渡した)・default(省いて既定の 30 分を使った)・floor(省いて想定最大時間の下限まで上げた)の 3 つ | — | — | — | +| 時間の枠の不足 | — | 着手前のテストが上限で打ち切られたか、明示した想定最大時間が想定最大時間の下限に届かないこと。init は SKIP を出して止まり、駆動はリファクタリングを飛ばした欠けの残る結果を返す | — | — | — | | 手順の枠 | — | `cross-refactoring` で提案とリファクタリング計画に充てる時間。着手前のテストの終わり(started_at + 実測)から 0.20·B と 0.10·B。枠が想定最大時間に収まらずに止めた後の init の打ち直しでは打ち直した時刻から数える | — | — | — | -| 範囲テストの所要 | — | CI に任せる戦略で、着手前に走らせる範囲テストの所要の見込み(秒)。同じ範囲の履歴の実測か、範囲が全体テストの suite のパスを覆うときの w | — | — | — | +| 範囲テストの所要 | — | CI に任せる戦略で、着手前に走らせる範囲テストの所要の見込み(秒)。同じ範囲の履歴の実測 → 手元の全体テストの直近の実測 m → 宣言の w の順に採る | — | — | — | | 積み直し | — | 取り消しで、取り消しの起点から HEAD までの残すコミット(未公開のもの)を cherry-pick で順に積み直すこと。項目に属するコミットが衝突したら、その項目だけを外して続ける | — | — | `docs/specifications/cross-refactoring-failed-item-rules.md` | | 外した項目 | — | cross-refactoring の取り消しの積み直しで、自分のコミットが衝突したために取り消した改善項目。外した理由(どの項目の取り消しで、どのコミットが衝突したか)を持つ | — | — | `docs/specifications/cross-refactoring-failed-item-rules.md` | | 直さなかった項目 | — | cross-refactoring で、結果を残して終わった修正の起動の後、修正の範囲に自分の Item-Id のコミットが 1 つも無い改善項目。次の検証の最初に取り消す | — | — | `docs/specifications/cross-refactoring-failed-item-rules.md` | @@ -578,6 +584,8 @@ release が走らせるリリースの種別・リリースコマンド・公開 | スプリント経由の PR | — | 範囲の PR のうち、merge commit でベースブランチへ入った PR の 2 つ目の親の側(スプリントブランチ)へ merge commit で入った PR。変化と移行の手順はスプリント PR の本文が集めて持つ | — | — | `docs/specifications/ndf-release-approval-included-prs.md` | | 承認の材料 | — | 承認資料が集める 5 つの材料。レビュー(cross-review のラウンド数と見送った指摘)・リファクタリング(採用と取り消しの件数)・全体テスト(コマンド・合否・件数)・設計文書の未確認・未実施のリリース後テスト | — | — | `docs/specifications/ndf-release-approval-materials.md` | | 配布物の変化なし | — | PR の本文の `## 利用者向けの変化` が「無し」(「なし」も同じ)だけを持つこと。承認資料の「含む PR」の行に印を付け、CHANGELOG の版の節と「配る中身」には載せない。節の無い本文(題名で代える)とは別に扱う | — | — | `docs/specifications/ndf-release-approval-included-prs.md` | +| マニフェスト | — | plugins/ndf/manifests/<ランタイム>-skills.txt。ランタイムごとに配る Skill の一覧で、配布の唯一の基準(#2231) | — | — | — | +| 配布先のランタイム | — | マーケットプレイスのプラグインを導入できる形で届けるランタイム。#2231 の後は Claude Code と codex で、相手役のランタイムとは別に決まる(#2231) | — | — | — | ## NDF の指示書チェック(`ndf-instructions`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 683566801..845c3ce4e 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -3439,6 +3439,36 @@ "meaning": "`cross-refactoring` の init が改修の前に 1 回走らせるテスト。CI に任せる戦略では --scope のテストの置き場所の範囲テスト、ほかは全体テスト(round-only はラウンドテストも)", "source": "" }, + { + "term": "想定最大時間の下限", + "context": "ndf-cross-refactoring", + "meaning": "着手前のテストの実測から求める、手順の枠と計画の後の見込みが収まる想定最大時間の最小値(分)。init が「--budget-minutes を N 以上に」と案内する N", + "code": "required_budget_minutes", + "source": "", + "pending_source": "issues/issue-2047-requirements.md" + }, + { + "term": "着手前のテストの初回の上限", + "context": "ndf-cross-refactoring", + "meaning": "同じ範囲の打ち切りと実測が配分の履歴に無いときの、着手前のテストの上限(秒)。max(0.10·B, 3·s) で、s は手元の全体テストの直近の実測 m → 宣言の w の順に採る(どちらも無ければ 0.10·B)", + "source": "", + "pending_source": "issues/issue-2047-design.md" + }, + { + "term": "想定最大時間の出所", + "context": "ndf-cross-refactoring", + "meaning": "その実行の想定最大時間をどこから決めたか。explicit(引数か宣言で渡した)・default(省いて既定の 30 分を使った)・floor(省いて想定最大時間の下限まで上げた)の 3 つ", + "code": "budget_source", + "source": "", + "pending_source": "issues/issue-2047-design.md" + }, + { + "term": "時間の枠の不足", + "context": "ndf-cross-refactoring", + "meaning": "着手前のテストが上限で打ち切られたか、明示した想定最大時間が想定最大時間の下限に届かないこと。init は SKIP を出して止まり、駆動はリファクタリングを飛ばした欠けの残る結果を返す", + "source": "", + "pending_source": "issues/issue-2047-design.md" + }, { "term": "手順の枠", "context": "ndf-cross-refactoring", @@ -3448,8 +3478,9 @@ { "term": "範囲テストの所要", "context": "ndf-cross-refactoring", - "meaning": "CI に任せる戦略で、着手前に走らせる範囲テストの所要の見込み(秒)。同じ範囲の履歴の実測か、範囲が全体テストの suite のパスを覆うときの w", - "source": "" + "meaning": "CI に任せる戦略で、着手前に走らせる範囲テストの所要の見込み(秒)。同じ範囲の履歴の実測 → 手元の全体テストの直近の実測 m → 宣言の w の順に採る", + "source": "", + "pending_source": "issues/issue-2047-design.md" }, { "term": "gws", @@ -4593,6 +4624,13 @@ "code": "gaps", "source": "docs/specifications/ndf-step-result-gaps-and-reading.md" }, + { + "term": "ステップの延長", + "context": "ndf-workflow", + "meaning": "drive のステップの子孫(駆動と、駆動が起動した init)が自分の見込みの秒を延長のファイル(NDF_STEP_EXTEND_FILE の指すパス)へ書き、エンジンがその試行の締め切りを with_margin を足して後ろへずらすこと。前へはずらさない", + "source": "", + "pending_source": "issues/issue-2047-design.md" + }, { "term": "過程の項目", "context": "ndf-workflow", @@ -4649,10 +4687,28 @@ "meaning": "状態の一覧に従い、持ち主の終わった残る状態を外し、残したものと理由を返す処理(run_state.sweep_states)。git が拒めば止まる操作だけで行う(#2229)", "source": "docs/specifications/ndf-run-state-owner-and-sweep.md" }, + { + "term": "相手役", + "context": "ndf-agent-cli", + "meaning": "cross-review のレビュー担当、cross-refactoring の提案・実装の担当として、ホストから CLI プロセスで起動されるランタイム(#2231)", + "pending_source": "issues/issue-2231-requirements.md" + }, + { + "term": "マニフェスト", + "context": "ndf-release", + "meaning": "plugins/ndf/manifests/<ランタイム>-skills.txt。ランタイムごとに配る Skill の一覧で、配布の唯一の基準(#2231)", + "pending_source": "issues/issue-2231-requirements.md" + }, + { + "term": "配布先のランタイム", + "context": "ndf-release", + "meaning": "マーケットプレイスのプラグインを導入できる形で届けるランタイム。#2231 の後は Claude Code と codex で、相手役のランタイムとは別に決まる(#2231)", + "pending_source": "issues/issue-2231-design.md" + }, { "term": "最終工程の記録", "context": "ndf-workflow", - "meaning": "最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行。sprint-close.py が閉じる前に確かめる", + "meaning": "最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行のうち、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上のもの(その実行の振り返り)。sprint-close.py が閉じる前に確かめる", "source": "plugins/ndf/skills/progress-tracking/SKILL.md" }, { diff --git a/docs/metrics/ndf-token-usage/2026-10-31-4.json b/docs/metrics/ndf-token-usage/2026-10-31-4.json new file mode 100644 index 000000000..e70695a2a --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-10-31-4.json @@ -0,0 +1,750 @@ +{ + "per_pr": [ + { + "version": "10.27.2", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 4, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 2, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 1118319.2000000002, + "supervisor_cost": 2699241.2, + "worker_cost": 4452251.100000001, + "context": 68196904.0, + "out": 402148.0, + "minutes": 106.72280000448227, + "codex_input": 1462304.0, + "codex_out": 7772.0, + "kiro_credit": 0.0, + "claude_seat_cost": 261353.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 4, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 3, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.27.2", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 4, + "cost": 680884.9125, + "context": 4521281.25, + "out": 36403.25, + "minutes": 7.080933334430059, + "p": 57038.75, + "k": 37.0, + "w5": 0.0, + "w1h": 139863.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.2", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 13672.8, + "context": 5009.5, + "out": 831.0, + "minutes": 0.15249999999999997, + "k": 1.0, + "w5": 0.0, + "w1h": 4751.5 + }, + { + "version": "10.27.2", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 2, + "cost": 168366.95, + "context": 698144.5, + "out": 9142.5, + "minutes": 2.145, + "k": 18.0, + "w5": 0.0, + "w1h": 44982.5 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 2, + "cost": 600520.675, + "context": 3672113.5, + "out": 12184.0, + "minutes": 89.11565000017484, + "p": 49121.0, + "k": 33.5, + "w5": 0.0, + "w1h": 182525.0, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 181424, + "rewrite_tokens_after_5m": 181424, + "read_tokens_after_5m": 423463, + "rewrite_gap_median": 95.13685833215713 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 3, + "cost": 24596.333333333332, + "context": 11345.0, + "out": 381.6666666666667, + "minutes": 0.11722222222222224, + "k": 1.0, + "w5": 0.0, + "w1h": 11343.0 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 2, + "cost": 59013.775, + "context": 26660.0, + "out": 1250.5, + "minutes": 0.24666666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 26372.5 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 5, + "cost": 17983.6, + "context": 5823.0, + "out": 1467.6, + "minutes": 0.25033333333333335, + "k": 1.0, + "w5": 0.0, + "w1h": 5309.0 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 4, + "cost": 8516.4625, + "context": 4419.0, + "out": 366.75, + "minutes": 0.13375, + "k": 1.0, + "w5": 0.0, + "w1h": 3312.75 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 1, + "cost": 450012.5, + "context": 2551384.0, + "out": 14241.0, + "minutes": 19.23748333454132, + "p": 38247.0, + "k": 31.0, + "w5": 209313.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 104205, + "rewrite_tokens_after_5m": 104205, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 15.63755000034968 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 16, + "cost": 99862.728125, + "context": 571362.5625, + "out": 4972.125, + "minutes": 8.454791666666667, + "k": 10.9375, + "w5": 0.0, + "w1h": 23802.125 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1118319.2000000002, + "context": 11620328.0, + "out": 40406.0, + "minutes": 106.72280000448227, + "p": 44391.0, + "k": 75.0, + "w5": 0.0, + "w1h": 171850.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 738193, + "rewrite_gap_median": null + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 28407.0, + "context": 13472.0, + "out": 293.0, + "minutes": 0.08833333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 13470.0 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 62707.0, + "context": 27107.0, + "out": 1699.0, + "minutes": 0.3266666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 27105.0 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "仕上げ", + "agent_type": "ndf:supervisor", + "count": 1, + "cost": 1771812.2999999998, + "context": 16487500.0, + "out": 77314.0, + "minutes": 28.592616665363312, + "p": 48561.0, + "k": 101.0, + "w5": 467220.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 204077, + "rewrite_tokens_after_5m": 204077, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 6.266050000985463 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "取り込み", + "agent_type": "ndf:supervisor", + "count": 1, + "cost": 836314.9000000001, + "context": 6528538.0, + "out": 22279.0, + "minutes": 25.300700000921886, + "p": 48517.0, + "k": 47.0, + "w5": 331992.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 155734, + "rewrite_tokens_after_5m": 155734, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 13.055133334795634 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 6, + "cost": 132861.46666666667, + "context": 546493.5, + "out": 7137.833333333333, + "minutes": 1.365, + "k": 15.666666666666666, + "w5": 0.0, + "w1h": 35805.166666666664 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "worker", + "role": "その他", + "agent_type": "ndf:worker", + "count": 5, + "cost": 490087.9600000001, + "context": 3950028.4, + "out": 29225.0, + "minutes": 7.365150000254314, + "p": 38781.4, + "k": 38.2, + "w5": 121990.8, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "layer": "worker", + "role": "修正", + "agent_type": "ndf:worker", + "count": 2, + "cost": 602321.25, + "context": 5245428.0, + "out": 35602.5, + "minutes": 6.966233330965042, + "p": 38932.5, + "k": 46.0, + "w5": 134957.5, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 4, + "cost": 684303.8999999999, + "context": 4618756.75, + "out": 36107.0, + "minutes": 9.560062499841054, + "p": 58786.75, + "k": 34.5, + "w5": 0.0, + "w1h": 139879.75, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 82689, + "rewrite_gap_median": null + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 28059.0, + "context": 13498.0, + "out": 213.0, + "minutes": 0.060000000000000005, + "k": 1.0, + "w5": 0.0, + "w1h": 13496.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 12860.6, + "context": 4988.0, + "out": 777.0, + "minutes": 0.16416666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 4474.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 2, + "cost": 41913.125, + "context": 171739.5, + "out": 1883.5, + "minutes": 2.6599999999999997, + "k": 6.0, + "w5": 0.0, + "w1h": 12255.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1299527.0, + "context": 12898070.0, + "out": 60076.0, + "minutes": 179.26763333082198, + "p": 44736.0, + "k": 89.0, + "w5": 0.0, + "w1h": 181562.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 1092386, + "rewrite_gap_median": null + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 20413.6, + "context": 6182.0, + "out": 1810.0, + "minutes": 0.2916666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 5668.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 9, + "cost": 306336.85000000003, + "context": 2429295.777777778, + "out": 14102.444444444445, + "minutes": 13.691296296296294, + "k": 24.444444444444443, + "w5": 0.0, + "w1h": 58623.444444444445 + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 3, + "cost": 386492.45, + "context": 2466716.0, + "out": 15934.666666666666, + "minutes": 4.183566665649414, + "p": 62678.666666666664, + "k": 25.666666666666668, + "w5": 0.0, + "w1h": 94069.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 12412.0, + "context": 4857.0, + "out": 540.0, + "minutes": 0.12666666666666665, + "k": 1.0, + "w5": 0.0, + "w1h": 4855.0 + } + ], + "external": [ + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 1, + "context": 1051708.0, + "out": 10682.0, + "cost": 261353.0, + "minutes": 2.5858500003814697, + "p": 29559.0, + "k": 17.0, + "w5": 0.0, + "w1h": 79654.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.27.2", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 1462304.0, + "cached": 1344640.0, + "out": 7772.0, + "minutes": 3.431533332665761, + "p": 27263.0, + "k": 17.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2544513.0, + "cached": 2378496.0, + "out": 6689.0, + "minutes": 3.2293999989827475, + "p": 26935.0, + "k": 22.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + } + ], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.27.2", + "until": "2026-10-31T18:35:00Z", + "sessions": 15, + "sessions_with_pr": 1, + "unlinked_external": 2933, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 614, + "版を判定できない": 19 + }, + "released": "10.27.4", + "previous": "2026-10-31-3.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-10-31-4.md b/docs/metrics/ndf-token-usage/2026-10-31-4.md new file mode 100644 index 000000000..d76af9063 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-10-31-4.md @@ -0,0 +1,117 @@ +# ndf の版ごとのトークン消費と所要時間(2026-10-31 集計・10.27.4 の配布) + +**10.27.4 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-10-31T18:35:00Z`。表の最初の行は 10.27.2(前の記録: 2026-10-31-3.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.4 --until 2026-10-31T18:35:00Z --min-version 10.27.2 +python3 scripts/token-usage.py --min-version 10.27.2 --until 2026-10-31T18:35:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 10.27.2(1 件)。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.3, 10.27.4。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.27.2, 10.27.3。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(PR 1 本あたり) + +換算の合計は conductor・supervisor・worker の換算の和。前の版との差は、表の 1 つ上の行との比である。±30% を超えた版を下の「読み取り」で扱う。 + +| ndf の版 | 会話 | PR | 換算の合計 | 前の版との差 | conductor | supervisor | worker | 入力 | 所要 | codex 入力 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.2 | 1 | 1 | 8.27M | - | 1.12M | 2.70M | 4.45M | 68.20M | 106.7 | 1.46M | + +## 版と層ごとの呼び出しとキャッシュ(1 起動あたり) + +P と k と書き込みは 1 起動あたり、書き直しとうち 5 分超は合計の回数である。 + +| ndf の版 | 層 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | うち 5 分超 | 1 起動あたりの書き直し | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.2 | conductor | 7 | 53.0k | 41.4 | 0.00M | 0.16M | 2 | 2 | 0.29 | +| 10.27.2 | supervisor | 21 | 45.1k | 9.4 | 0.05M | 0.01M | 3 | 3 | 1.00 | +| 10.27.2 | worker | 31 | 38.8k | 19.0 | 0.03M | 0.02M | 0 | 0 | 0.00 | +| 10.27.3 | conductor | 8 | 58.5k | 38.0 | 0.00M | 0.13M | 0 | 0 | 0.00 | +| 10.27.3 | supervisor | 6 | - | 1.0 | 0.00M | 0.01M | - | - | - | +| 10.27.3 | worker | 11 | - | 21.1 | 0.00M | 0.05M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 15 件 / PR を作った会話: 1 件 / 寄せ先の無い外部 CLI: 2933 件 / 寄せ先の無い帳簿の行: 0 件 + +換算は input を 1 とした費用(cache read はモデル別: claude-opus-5-5 0.05 / claude-fable-5-1 0.025 / 他 0.1。cache write 5 分 1.25・1 時間 2 / output 5)。入力は input + cache read + cache write。所要は分。 + +### PR 1 本あたり + +| 動いた版 | 会話 | PR | conductor 換算 | supervisor 換算 | worker 換算 | 入力 | 出力 | 所要 | codex 入力 | codex 出力 | kiro credit | claude 席 換算 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.2 | 1 | 1 | 1.12M | 2.70M | 4.45M | 68.20M | 402.1k | 106.7 | 1.46M | 7.8k | 0.00 | 0.26M | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.2 | conductor | - | - | 7 | 0.72M | 5.29M | 30.1k | 44.8 | +| 10.27.2 | supervisor | judge | claude -p | 4 | 0.03M | 0.01M | 0.4k | 0.1 | +| 10.27.2 | supervisor | mvv | claude -p | 3 | 0.06M | 0.03M | 1.4k | 0.3 | +| 10.27.2 | supervisor | pr | claude -p | 7 | 0.02M | 0.01M | 1.3k | 0.2 | +| 10.27.2 | supervisor | slow | claude -p | 4 | 0.01M | 0.00M | 0.4k | 0.1 | +| 10.27.2 | supervisor | その他 | ndf:worker | 1 | 0.45M | 2.55M | 14.2k | 19.2 | +| 10.27.2 | supervisor | 仕上げ | ndf:supervisor | 1 | 1.77M | 16.49M | 77.3k | 28.6 | +| 10.27.2 | supervisor | 取り込み | ndf:supervisor | 1 | 0.84M | 6.53M | 22.3k | 25.3 | +| 10.27.2 | worker | work | claude -p | 24 | 0.11M | 0.58M | 5.9k | 6.2 | +| 10.27.2 | worker | その他 | ndf:worker | 5 | 0.49M | 3.95M | 29.2k | 7.4 | +| 10.27.2 | worker | 修正 | ndf:worker | 2 | 0.60M | 5.25M | 35.6k | 7.0 | +| 10.27.3 | conductor | - | - | 8 | 0.65M | 4.85M | 31.5k | 28.8 | +| 10.27.3 | supervisor | judge | claude -p | 1 | 0.03M | 0.01M | 0.2k | 0.1 | +| 10.27.3 | supervisor | pr | claude -p | 5 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.3 | worker | work | claude -p | 11 | 0.26M | 2.02M | 11.9k | 11.7 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.2 | conductor | - | - | 7 | 53.0k | 41.4 | 0.00M | 0.16M | 2 | 2 | 0.18M | 1.16M | 95.1 | +| 10.27.2 | supervisor | judge | claude -p | 4 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.2 | supervisor | mvv | claude -p | 3 | - | 1.0 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.2 | supervisor | pr | claude -p | 7 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.2 | supervisor | slow | claude -p | 4 | - | 1.0 | 0.00M | 0.00M | - | - | - | - | - | +| 10.27.2 | supervisor | その他 | ndf:worker | 1 | 38.2k | 31.0 | 0.21M | 0.00M | 1 | 1 | 0.10M | 0.00M | 15.6 | +| 10.27.2 | supervisor | 仕上げ | ndf:supervisor | 1 | 48.6k | 101.0 | 0.47M | 0.00M | 1 | 1 | 0.20M | 0.00M | 6.3 | +| 10.27.2 | supervisor | 取り込み | ndf:supervisor | 1 | 48.5k | 47.0 | 0.33M | 0.00M | 1 | 1 | 0.16M | 0.00M | 13.1 | +| 10.27.2 | worker | work | claude -p | 24 | - | 12.7 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.2 | worker | その他 | ndf:worker | 5 | 38.8k | 38.2 | 0.12M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.2 | worker | 修正 | ndf:worker | 2 | 38.9k | 46.0 | 0.13M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.3 | conductor | - | - | 8 | 58.5k | 38.0 | 0.00M | 0.13M | 0 | 0 | 0.00M | 1.18M | - | +| 10.27.3 | supervisor | judge | claude -p | 1 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.3 | supervisor | pr | claude -p | 5 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.3 | worker | work | claude -p | 11 | - | 21.1 | 0.00M | 0.05M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.2 | claude | cross-review | claude-opus-5-5 | 1 | 1.05M | 0.00M | 10.7k | 0.00 | 2.6 | +| 10.27.2 | codex | cross-review | gpt-6.1-sol | 1 | 1.46M | 1.34M | 7.8k | 0.00 | 3.4 | +| 10.27.3 | codex | cross-review | gpt-6.1-sol | 1 | 2.54M | 2.38M | 6.7k | 0.00 | 3.2 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.2 | claude | cross-review | claude-opus-5-5 | 1 | 29.6k | 17.0 | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.2 | codex | cross-review | gpt-6.1-sol | 1 | 27.3k | 17.0 | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.3 | codex | cross-review | gpt-6.1-sol | 1 | 26.9k | 22.0 | 0 | 0 | 0.00M | 0.00M | - | diff --git a/docs/metrics/ndf-token-usage/2026-11-01.json b/docs/metrics/ndf-token-usage/2026-11-01.json new file mode 100644 index 000000000..d2a2cae9d --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-01.json @@ -0,0 +1,324 @@ +{ + "per_pr": [ + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 4, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 4, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 4, + "cost": 684303.8999999999, + "context": 4618756.75, + "out": 36107.0, + "minutes": 9.560062499841054, + "p": 58786.75, + "k": 34.5, + "w5": 0.0, + "w1h": 139879.75, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 82689, + "rewrite_gap_median": null + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 28059.0, + "context": 13498.0, + "out": 213.0, + "minutes": 0.060000000000000005, + "k": 1.0, + "w5": 0.0, + "w1h": 13496.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 12860.6, + "context": 4988.0, + "out": 777.0, + "minutes": 0.16416666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 4474.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 2, + "cost": 41913.125, + "context": 171739.5, + "out": 1883.5, + "minutes": 2.6599999999999997, + "k": 6.0, + "w5": 0.0, + "w1h": 12255.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 2345933.05, + "context": 22316802.0, + "out": 85018.0, + "minutes": 369.1328833341598, + "p": 44736.0, + "k": 129.0, + "w5": 0.0, + "w1h": 412671.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 188767, + "rewrite_tokens_after_5m": 188767, + "read_tokens_after_5m": 2215987, + "rewrite_gap_median": 89.92836666901907 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 24825.0, + "context": 11131.0, + "out": 513.0, + "minutes": 0.12666666666666665, + "k": 1.0, + "w5": 0.0, + "w1h": 11129.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 20413.6, + "context": 6182.0, + "out": 1810.0, + "minutes": 0.2916666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 5668.0 + }, + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 10, + "cost": 297613.435, + "context": 2308860.5, + "out": 13957.1, + "minutes": 13.019166666666665, + "k": 25.4, + "w5": 0.0, + "w1h": 57610.0 + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 4, + "cost": 485160.15, + "context": 3859892.0, + "out": 17870.75, + "minutes": 25.876800000667572, + "p": 57634.5, + "k": 35.5, + "w5": 0.0, + "w1h": 103970.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 309937, + "rewrite_gap_median": null + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 27485.0, + "context": 11586.0, + "out": 863.0, + "minutes": 0.19499999999999998, + "k": 1.0, + "w5": 0.0, + "w1h": 11584.0 + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 12412.0, + "context": 4857.0, + "out": 540.0, + "minutes": 0.12666666666666665, + "k": 1.0, + "w5": 0.0, + "w1h": 4855.0 + }, + { + "version": "10.27.3", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 3, + "cost": 205052.33333333334, + "context": 1017256.6666666666, + "out": 9200.0, + "minutes": 2.7994444444444446, + "k": 22.333333333333332, + "w5": 0.0, + "w1h": 55461.333333333336 + } + ], + "external": [ + { + "version": "10.27.3", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2544513.0, + "cached": 2378496.0, + "out": 6689.0, + "minutes": 3.2293999989827475, + "p": 26935.0, + "k": 22.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + } + ], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.27.3", + "until": "2026-11-01T00:10:00Z", + "sessions": 9, + "sessions_with_pr": 0, + "unlinked_external": 2951, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 617, + "版を判定できない": 19 + }, + "released": "10.27.5", + "previous": "2026-10-31-4.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-11-01.md b/docs/metrics/ndf-token-usage/2026-11-01.md new file mode 100644 index 000000000..ae53c6c36 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-01.md @@ -0,0 +1,86 @@ +# ndf の版ごとのトークン消費と所要時間(2026-11-01 集計・10.27.5 の配布) + +**10.27.5 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-11-01T00:10:00Z`。表の最初の行は 10.27.3(前の記録: 2026-10-31-4.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.5 --until 2026-11-01T00:10:00Z --min-version 10.27.3 +python3 scripts/token-usage.py --min-version 10.27.3 --until 2026-11-01T00:10:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 無し。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.3, 10.27.4, 10.27.5。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.27.3。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(PR 1 本あたり) + +換算の合計は conductor・supervisor・worker の換算の和。前の版との差は、表の 1 つ上の行との比である。±30% を超えた版を下の「読み取り」で扱う。 + +| ndf の版 | 会話 | PR | 換算の合計 | 前の版との差 | conductor | supervisor | worker | 入力 | 所要 | codex 入力 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +## 版と層ごとの呼び出しとキャッシュ(1 起動あたり) + +P と k と書き込みは 1 起動あたり、書き直しとうち 5 分超は合計の回数である。 + +| ndf の版 | 層 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | うち 5 分超 | 1 起動あたりの書き直し | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.3 | conductor | 9 | 56.7k | 45.4 | 0.00M | 0.15M | 1 | 1 | 0.11 | +| 10.27.3 | supervisor | 8 | - | 1.0 | 0.00M | 0.01M | - | - | - | +| 10.27.3 | worker | 15 | - | 22.2 | 0.00M | 0.05M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 9 件 / PR を作った会話: 0 件 / 寄せ先の無い外部 CLI: 2951 件 / 寄せ先の無い帳簿の行: 0 件 + +換算は input を 1 とした費用(cache read はモデル別: claude-opus-5-5 0.05 / claude-fable-5-1 0.025 / 他 0.1。cache write 5 分 1.25・1 時間 2 / output 5)。入力は input + cache read + cache write。所要は分。 + +### PR 1 本あたり + +| 動いた版 | 会話 | PR | conductor 換算 | supervisor 換算 | worker 換算 | 入力 | 出力 | 所要 | codex 入力 | codex 出力 | kiro credit | claude 席 換算 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.3 | conductor | - | - | 9 | 0.78M | 6.25M | 33.4k | 56.8 | +| 10.27.3 | supervisor | judge | claude -p | 3 | 0.03M | 0.01M | 0.5k | 0.1 | +| 10.27.3 | supervisor | pr | claude -p | 5 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.3 | worker | work | claude -p | 15 | 0.25M | 1.77M | 11.4k | 9.6 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.3 | conductor | - | - | 9 | 56.7k | 45.4 | 0.00M | 0.15M | 1 | 1 | 0.19M | 2.61M | 89.9 | +| 10.27.3 | supervisor | judge | claude -p | 3 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.3 | supervisor | pr | claude -p | 5 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.3 | worker | work | claude -p | 15 | - | 22.2 | 0.00M | 0.05M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.3 | codex | cross-review | gpt-6.1-sol | 1 | 2.54M | 2.38M | 6.7k | 0.00 | 3.2 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.3 | codex | cross-review | gpt-6.1-sol | 1 | 26.9k | 22.0 | 0 | 0 | 0.00M | 0.00M | - | diff --git a/docs/ndf-experiments.md b/docs/ndf-experiments.md index f39a13bd0..b5000698e 100644 --- a/docs/ndf-experiments.md +++ b/docs/ndf-experiments.md @@ -16,6 +16,7 @@ | `runner-trial.py` | #1142 の不足 i の設計の前に、プランの実行とキューを置き換える候補(LangGraph の SqliteSaver・Burr の SQLitePersister・DBOS Transact の SQLite)で、`supervise.py new impl` の雛形(ステップを sleep と決めた終了コードの偽物にしたもの)・キュー・kill -9 からの再開・進捗ログと `supervise.py wait` を保てるかを確かめる | 2026-09-26・`exp/runner-trial` | 2026-09-26: 3 候補ともサーバーを立てず(プロセスの待ち受けのポート 0)SQLite のファイルだけで動き、確かめた 5 つが成り立った(`check <候補>` の結果がすべて ok)。表せるか: `on_fail`・judge の選択・上限 20・承認ゲート(止まり、承認の無い打ち直しは同じ所で止まったまま、承認で続く)を 5 つのシナリオで supervise.py の規則どおりの経路で流した。LangGraph の `interrupt()` は続けるときノードを頭から流し直すため、止める前の副作用はノードの外に置く。`recursion_limit` は invoke ごとに数え直すため、上限は状態で数える。Burr は遷移を `when(cur=...)` で行き先ごとに並べ、承認ゲートは `halt_before` で止める。DBOS は承認ゲートで `DBOS.recv` の待ちのままプロセスを抜け(`os._exit`)、打ち直しの `DBOS.launch()` が記録から続ける(落ちた後は状態が ENQUEUED に見える)。キュー: 6 本(ステージ 4 本 → 2 本)を同時 2 本・GraphQL の枠 1 本で流し、同時は最大 2 本・GraphQL は最大 1 本(枠の待ちが 1〜8 回起きた)で、後のステージは前のステージがすべて終わってから流れ、前のステージの 1 本が止まると後のステージは流れなかった。キューを持つのは DBOS だけ(`register_queue` の `worker_concurrency`。資源のタグは別のキューの `concurrency`)。LangGraph と Burr はキューを持たないため、子プロセスと flock の枠で自前にした(約 60 行)。6 本の所要は LangGraph 6.9 秒・Burr 6.0 秒・DBOS 12.4 秒(DBOS はキューの問い合わせの間隔 0.1 秒の分だけ遅い)。再開: 3 候補とも kill -9 のときに流れていたステップ(test-limited)から流し直し、済んだステップは流し直さなかった。費用(uv 0.12.19・キャッシュの冷えた状態。LangGraph / Burr / DBOS の順): 解決 0.28 / 0.45 / 0.21 秒、導入 0.32 / 0.20 / 0.45 秒(キャッシュが温まって 0.03 / 0.01 / 0.01 秒)、大きさ 54.9 / 20.2 / 60.6 MB、依存 41 / 2 / 11 件(LangGraph は langchain-core・langsmith・pydantic-core を、DBOS は SQLite でも SQLAlchemy・psycopg-binary を入れる)、import 冷えて 0.82 / 0.06 / 0.78 秒・温まって 0.26 / 0.04 / 0.24 秒、起動し直しを含むエントリポイント(温まって)0.31 / 0.08 / 0.31 秒。進捗ログと wait: 進捗ログの行(step・attention)はライブラリでなく NDF の部品がステップの中で書き、今の `supervise.py wait` は手を入れずに attention で 20、done で 0 を返した | 本体へ移した(DBOS Transact を SQLite で `lib/durable.py` の包みから使う。#1142 のスプリント 2c: 依存と包み #1538・プランの実行 #1548・投稿キュー #1545・cross-review の drive #1540・cross-refactoring の drive #1541・キューと重なりと資源の枠 #1551)。試行は Q6 で消した | | `hook-trial.py` | #1142 の決定 20(hook を 1 本の Python のエントリポイントへまとめる)の前に、成り立ちと退行を確かめる。今の hook のテストの入力のすべてを tree-sitter-bash(py-tree-sitter)で判定して今の判定と比べる・hook 1 回の所要・環境が無いときに判定をせずに通るか・claude-agent-sdk が `supervise_lib/claude.py` の `claude_cmd`・`call_claude` の契約を満たすか・bump-my-version が `release-steps.py` の `cmd_bump` の 15 箇所の書き換えを満たすか | 2026-09-26・`exp/hook-trial` | 2026-09-26: 5 つのうち 3 つが成り立ち、2 つが成り立たなかった。判定: hook のテスト(3121 件)が渡した入力を集めた。書き込み先の推定 552 件・コマンド 546 件のうち、前景の sleep とプランを起こす副命令は全件で一致した。書き込み先は 2 件が食い違った。`! case … esac` は tree-sitter-bash が読めず(否定した複合コマンドは ERROR になる)、worktree の側と取り違える。`cp a.txt $(basename b).txt` は今の実装が `b).txt` を出し、テストがそれを期待として固定している(tree-sitter の側は展開を含む語として出さない)。構文木の癖が 5 つあり、包みの側で直した: 並びとパイプの末尾のリダイレクトが並び全体に付く・リダイレクトの後ろの語が destination として並ぶ・`<>` が ERROR になる・`time` がコマンド名になる・ヒアドキュメントの本文の `$((…))` をコマンド置換と読み、バッククォートは読まない。所要(40 回ずつの中央値。宣言のあるリポジトリ): 今の `worktree-guard.sh` は 26.8〜76.1 ms、1 本のエントリポイント(用意済みの環境の `bin/python` を直に起動)は 20.1〜21.6 ms で、どの入力でも速かった。内訳は python の起動 6.9 ms・tree-sitter の import 込み 16.9 ms。環境が無いとき: hook の command を `sh -c '[ -x "$0" ] || exit 0; exec "$0" "$1"'` の形にすれば、出力なし・終了コード 0 で通る。python を直に指す形は終了コード 127 で終わる。tree-sitter を import できない python でも終了コード 0 で通る。claude-agent-sdk 0.2.160: 3 つの形(最小構成・work の Tool と Serena・Skill を回す形と `--resume`)の引数を今の引数と同じに組めた。打ち切ると子のプロセスは残らない。結果の値(本文・使用量・modelUsage・費用・往復・セッション)の形も同じだった。ただし 2 つが成り立たない。1 つ目は、今のテストの偽物の CLI(JSON を 1 つ書く)が SDK の下で動かないこと。2 つ目は、同じ依頼(haiku)で入力が 281 → 1039 トークン、費用が 2.73 倍になり、`usage` の入力(281)が modelUsage(1039)と合わないこと。同梱の CLI でも手元の CLI(どちらも 2.1.283)でも同じだった。bump-my-version 1.5.1: `10.17.30-dev.1` → `-dev.2` と → `10.17.31` の両方で、`cmd_bump` と同じ差分(7 と 8 ファイル)を書いた。手で直す箇所の報告と `check-doc-staleness.py` の実行は NDF の側に残る。2026-09-26(T2b): SDK の 2 点はどちらも SDK の設定で揃えられた。費用: SDK は子の CLI へ `CLAUDE_AGENT_SDK_VERSION` を必ず立て、CLI はこれを見て会話のタイトルを作る呼び出し(haiku・`source=generate_session_title`)を本体の前に 1 回足す。`usage` は本体の呼び出しだけを、modelUsage と費用は両方を数えるため合わない。`claude -p` の直の起動(`CLAUDE_CODE_ENTRYPOINT=sdk-cli`)では足さない。引数と環境を 1 つずつ入れ替えて測ると、`--input-format stream-json`・`CLAUDECODE` を外すこと・`CLAUDE_CODE_SDK_READS_SESSION_STATE` は入力を変えず、`CLAUDE_AGENT_SDK_VERSION` か `CLAUDE_CODE_ENTRYPOINT=sdk-py` を立てたときだけ 275 → 1033 トークンになった。`ClaudeAgentOptions(env={"CLAUDE_CODE_DISABLE_TERMINAL_TITLE": "1"})` で呼び出しが止まり、`hook-trial.py sdk --live --runs 3` で入力 280 トークン(usage と modelUsage が一致)・費用 1.01 倍(止めない形は 1038 トークン・2.77 倍)になった。`CLAUDE_CODE_ENTRYPOINT` を `env` で上書きしても `CLAUDE_AGENT_SDK_VERSION` が残るため止まらない。偽物の CLI: SDK は起動の直後に stream-json の `control_request`(`initialize`)を書いて `control_response` を待つ。今の偽物は標準入力の EOF を待つため双方が待ち合って止まる。制御の要求へ答えて `user` を 1 つ読み `result` を 1 つ書く偽物(`cli_path`・約 15 行)と、プロセスを起こさない `query(transport=...)` の差し替えは、どちらも ResultMessage を返した | 本体へ: tree-sitter-bash と hook の 1 本化はスプリント 2b の D5、bump-my-version は D7。claude-agent-sdk は採らない(`issues/issue-1142-design-libraries.md` の決定 24) | | `training-optout.py` | LLM へ入力を渡す前に、そのアカウントが入力を学習に使わない設定かを 1 行の JSON で返す。claude は OAuth の `account/settings` の `grove_enabled`、他は `unsupported` | 2026-10-02・`exp/training-optout` | 2026-10-02: Claude Max の実物のアカウントで `training: false`(`grove_updated_at` 2026-08-13)・終了コード 0。`--runtime kiro` を足すと `unsupported` で 3 | 2026-10-02: 本体へ昇格(`plugins/ndf/scripts/training-optout.py`。`check` に codex の読み取り、`session-start` で SessionStart の書き換えを足した。#1597) | +| `whole-test-timing.py` | 全体テストを宣言の順に同じ条件(開始時の load average・junit の集計)で測り、件数・各テストの所要の合計・遅いテストの上位 30 件・ファイルの上位 10 本を出す。着手前後のテストの集合を台帳と照らし、台帳に無い消えたテストと skip に変わったテストを出す | 2026-10-31・`feat/issue-2291-l20261031-5` | 2026-10-31: #2291 の着手前後の計測と台帳の照合に使った(結果は `issues/issue-2291-test-ledger.md`) | 未定 | ## 実験版の仕組みそのものの直し diff --git a/docs/ndf-plugin-reference.md b/docs/ndf-plugin-reference.md index 688f51637..cfb73f872 100644 --- a/docs/ndf-plugin-reference.md +++ b/docs/ndf-plugin-reference.md @@ -2,14 +2,12 @@ ## 概要 -NDF は Claude Code / Codex / Kiro CLI 向けの開発支援プラグイン群です。marketplace 上の plugin 名は全ランタイムで `ndf` を維持し、配布物は `plugins/ndf/` の 1 ディレクトリにまとまっています。 +NDF は Claude Code / Codex 向けの開発支援プラグイン群です。marketplace 上の plugin 名はどちらでも `ndf` を維持し、配布物は `plugins/ndf/` の 1 ディレクトリにまとまっています。配布先は Claude Code と Codex の 2 つで、Kiro CLI と agy へは配りません。この 2 つは cross-review / cross-refactoring の相手役として、ホスト(Claude Code・Codex)から CLI プロセスで起動されるだけです(「相手役のランタイム」)。 | ランタイム | 導入方法 | 公開 Skill | |---|---|---| | Claude Code | `.claude-plugin/marketplace.json` の `ndf` | 49 個 | | Codex | `.claude-plugin/marketplace.json` の `ndf` | 45 個 | -| Kiro CLI | `plugins/ndf/dev.kiro/install.sh` | 46 個 | -| agy | `plugins/ndf/dev.agy/` を `agy plugin install` | 45 個 | Skill の実体は `plugins/ndf/skills/` の 1 箇所だけです。ランタイム別の複製はありません。Skill や 共通スクリプトを変更する場合は `plugins/ndf/` を直接編集します。 @@ -25,11 +23,10 @@ plugins/ndf/ ├── agents/ # Claude Code のサブエージェント(専門 8 個と、3 層の定義 3 個(supervisor 2・worker 1)) ├── hooks/claude.json # Claude Code の SessionStart / Stop hook ├── hooks/codex.json # Codex の Stop hook -├── scripts/ # hook と Skill から呼ぶスクリプト -└── dev.kiro/ # Kiro CLI の installer・エージェント定義・プロンプト +└── scripts/ # hook と Skill から呼ぶスクリプト ``` -`dev.kiro` は Agent Plugins 1.0.0 §8.2 が定めるクライアント拡張ディレクトリです。生成物は +生成物は `skills/<名前>/agents/openai.yaml`(Codex の暗黙起動ポリシー)だけで、commit 対象です。利用者が plugin install 時に build を実行する必要はありません。 @@ -39,10 +36,8 @@ plugin install 時に build を実行する必要はありません。 |---|---| | Claude Code | `.claude-plugin/plugin.json`(`agents/` 11 個(専門 8 個と、3 層の定義 3 個(supervisor 2・worker 1))、`hooks/claude.json`、`skills` 配列 49 個)、`scripts/` | | Codex | `.codex-plugin/plugin.json`(`hooks/codex.json`、`skills` 配列 45 個)、`scripts/` | -| Kiro CLI | `dev.kiro/`(installer・agent config template・workflow prompts)、`manifests/kiro-skills.txt`、`skills/`、`scripts/` | -| agy | `dev.agy/`(マニフェスト・`hooks.json`・`skills/` の symlink・`model` を tier 名へ変えた `agents/` の写し・`scripts` への symlink) | -同じディレクトリを 4 ランタイムが共有しますが、読む対象はマニフェストと installer が決めるため、 +同じディレクトリを 2 ランタイムが共有しますが、読む対象はマニフェストが決めるため、 公開される Skill と hook はランタイムごとに異なります。Claude Code 専用の agents / statusline / transcript retention hook は Codex の `skills` 配列と `hooks/codex.json` には入りません。 @@ -54,7 +49,6 @@ NDF の Skill 実装は `plugins/ndf/skills/` にあります。公開セット |---|---| | `plugins/ndf/manifests/claude-skills.txt` | `.claude-plugin/plugin.json` の `skills` 配列 | | `plugins/ndf/manifests/codex-skills.txt` | `.codex-plugin/plugin.json` の `skills` 配列 | -| `plugins/ndf/manifests/kiro-skills.txt` | `dev.kiro/install.sh` が張る symlink | **`skills/` に置く Skill は、少なくとも 1 つの manifest へ載せます。** 配らない Skill の置き場所 (`optional-skills/`)は v10.5.0 で無くしました(#116)。 @@ -71,17 +65,17 @@ NDF の Skill 実装は `plugins/ndf/skills/` にあります。公開セット - quality / execution: `docker-container-access`, `qa-security-scan` (Playwright による E2E テストは v7.0.0 で `playwright-kit` プラグインへ分離) - external services: `external-ai`, `official-skills-autoloader`(Claude Code のみ) -- runtime 設定: `statusline`(Claude Code と Kiro CLI のみ)、`install-wrapper` / `restart`(カットポイントで claude を起動し直すラッパー。Claude Code のみ) +- runtime 設定: `statusline`(Claude Code のみ)、`install-wrapper` / `restart`(カットポイントで claude を起動し直すラッパー。Claude Code のみ) - policy: `ndf-policies`, `problem-solving`, `logging-guidelines` - 外部サービスと測定: `google-workspace`, `skill-stats`, `ml-model-structure`, - `notion-writing`(v10.5.0 で 4 ランタイムすべてへ配布へ回しました。#116 / #144) + `notion-writing`(v10.5.0 で配布へ回しました。#116 / #144) ## MCP Plugins -MCP plugin も `plugins/mcp//` の 1 ディレクトリにまとまっています。サーバ定義は `.mcp.json` の 1 箇所だけで、3 runtime が同じファイルを読みます。 +MCP plugin も `plugins/mcp//` の 1 ディレクトリにまとまっています。サーバ定義は `.mcp.json` の 1 箇所だけで、Claude Code と Codex が同じファイルを読みます(Codex だけ `.codex.mcp.json` を読ませる例外があります)。 -Claude Code と Codex は marketplace から同じ plugin 名で install します。Kiro CLI は `plugins/mcp//dev.kiro/install.sh` で対象プロジェクトの `.mcp.json` と必要な Kiro agent 設定を更新します。 +Claude Code と Codex は marketplace から同じ plugin 名で install します。 ## Build / Validation @@ -91,15 +85,13 @@ bash scripts/build-runtime-plugins.sh --check bash scripts/validate-runtime-plugins.sh claude plugin validate plugins/ndf python3 -m json.tool plugins/ndf/.codex-plugin/plugin.json >/dev/null -bash plugins/ndf/dev.kiro/install.sh --dry-run bash scripts/runtime-smoke-test.sh --runtime claude bash scripts/runtime-smoke-test.sh --runtime codex -bash scripts/runtime-smoke-test.sh --runtime kiro ``` -`--check` は生成物(`plugins/<プラグイン名>/skills//agents/openai.yaml` と `plugins/mcp/<プラグイン名>/dev.kiro/install.sh`)が最新であることを検証します。`validate-runtime-plugins.sh` は生成物同期、JSON / manifest、marketplace source、Kiro installer、Markdown ローカルリンクをまとめて確認します。 +`--check` は生成物(`plugins/<プラグイン名>/skills//agents/openai.yaml`)が最新であることを検証します。`validate-runtime-plugins.sh` は生成物同期、JSON / manifest、marketplace source、Markdown ローカルリンクをまとめて確認します。agy・Kiro CLI の導入物(`agy-skills.txt`・`kiro-skills.txt`・`dev.agy/`・`dev.kiro/`)が戻っていないことは `scripts/tests/test_distribution_runtimes.py` が確かめます。 -`runtime-smoke-test.sh` は Docker コンテナ内に repo copy、`/tmp/runtime-home`、`/tmp/runtime-project`、`/tmp/runtime-secrets` を分離して作成し、Claude / Codex / Kiro それぞれで `ndf` と `mcp-bigquery` 相当の実 install、Skill / MCP / hook / agent config の assertion、JUnit / log artifact 出力を確認します。PR CI は `--with-secrets=off` の非認証 smoke のみを実行し、認証付き smoke は `runtime-plugin-authenticated-smoke.yml` の protected workflow で実行します。 +`runtime-smoke-test.sh` は Docker コンテナ内に repo copy、`/tmp/runtime-home`、`/tmp/runtime-project`、`/tmp/runtime-secrets` を分離して作成し、Claude / Codex それぞれで `ndf` と `mcp-bigquery` 相当の実 install、Skill / MCP / hook の assertion、JUnit / log artifact 出力を確認します。PR CI は `--with-secrets=off` の非認証 smoke のみを実行し、認証付き smoke は `runtime-plugin-authenticated-smoke.yml` の protected workflow で実行します。 ローカル hook を使う場合は `bash scripts/install-dev-hooks.sh` で `.githooks/` を有効化します。 @@ -107,6 +99,10 @@ bash scripts/runtime-smoke-test.sh --runtime kiro Codex MCP サーバは廃止済みです。外部 AI 委譲は `/ndf:external-ai` Skill と Claude Code 版の `corder` エージェントから Codex / agy を直接呼び出す方式を標準とします。 +### 相手役のランタイム + +Kiro CLI と agy は cross-review のレビュー担当と cross-refactoring の提案・実装の担当(相手役)で、プラグインは配りません。ホストは `plugins/ndf/scripts/lib/launch-cli.sh` で CLI を起動し、`--agent` もプラグインの導入先も渡しません。cross-refactoring の担当が読む手順書(`refactoring`・`tdd-cycle`・`quality-gates`)は、ホストの導入先から担当の worktree(Kiro CLI は `.kiro/skills`、agy は `.agents/skills`)へ写します(`plugins/ndf/skills/cross-refactoring/scripts/prepare-worktrees.sh`)。起動の形の正本は `plugins/ndf/skills/external-ai/references/cli-kiro.md`・`cli-agy.md` です。 + ## 承認ゲートの外の問い合わせ `AskUserQuestion` は、問いの区分(入力の `metadata.source`)が人へ回してよい区分のときだけ利用者へ出ます。区分の無い・ @@ -131,8 +127,6 @@ Codex MCP サーバは廃止済みです。外部 AI 委譲は `/ndf:external-ai |---|---|---|---| | Claude Code 2.1.314 | `AskUserQuestion`(`metadata.source` を持つ) | 止められる(PreToolUse の `deny`) | 止める | | Codex CLI 0.164.0 | `request_user_input`(`codex features list` で `default_mode_request_user_input` が `under development`・`false`。exec では使えない) | 確かめていない(PreToolUse が発火するか、区分の欄があるか) | 対象外。Skill の手順の規則(`asking.md`)だけで守る | -| agy 1.4.7 | `ask_question` | 確かめていない(PreToolUse の `toolCall.name` と、拒否の理由がモデルへ届くか) | 対象外。Skill の手順の規則だけで守る | -| Kiro CLI 2.34.0 | 見つからない(組み込みの Tool に無く、MCP の elicitation だけ) | — | 対象外(止める対象が無い) | hook の無いランタイムでは、区分に当たる問いを本文か各ランタイムの問い合わせの Tool で出し、区分の名前を問いの先頭に書きます。 止めた問いでは Slack の待ち通知を送りません。 @@ -146,7 +140,6 @@ hook の無いランタイムでは、区分に当たる問いを本文か各ラ |---|---| | Claude Code | Stop・Notification(`permission_prompt` ほか)・PermissionRequest(`ExitPlanMode`)・PreToolUse(`AskUserQuestion`。問い合わせの guard が止めた問いを除く)hook で `wait-notify.py --runtime claude` を実行 | | Codex | `NDF_CODEX_SLACK_NOTIFY=true` の場合のみ Stop・PermissionRequest hook で `wait-notify.py --runtime codex` を実行 | -| Kiro CLI | `plugins/ndf/dev.kiro/install.sh --with-slack` で `stop` hook に `wait-notify.py --runtime kiro` を生成 | 機密情報は環境変数で管理し、リポジトリにはコミットしません。 @@ -154,7 +147,6 @@ hook の無いランタイムでは、区分に当たる問いを本文か各ラ - [Claude Code版 README](../plugins/ndf/README.md) - [Codex版 README](../plugins/ndf/README.md) -- [Kiro CLI版 README](../plugins/ndf/README.md) - [NDF プラグイン README](../plugins/ndf/README.md) - [開発ワークフローの用語集](../plugins/ndf/skills/development-workflow/references/glossary.md) — ステップ・パイプライン・セッション・承認ゲートなど、`development-workflow` とその周りで使う語 - [runtime plugin container smoke 仕様](specifications/runtime-plugin-container-smoke.md) diff --git a/docs/plugin-development-guide.md b/docs/plugin-development-guide.md index c693a8a39..21e2b0324 100644 --- a/docs/plugin-development-guide.md +++ b/docs/plugin-development-guide.md @@ -35,13 +35,9 @@ marketplace 定義はこの 1 つだけです。Codex は専用の定義を持 同じ `source` を読みます。`policy` / `category` / `interface` は Codex が要求する項目で、同じ entry へ 含めます(Claude Code はこれらを読み込み時に無視し、`claude plugin validate` は warning 付きで通ります)。 -Kiro CLI は marketplace を読まないため、installer で導入します。 - -```bash -bash plugins/ndf/dev.kiro/install.sh -bash plugins/playwright-kit/dev.kiro/install.sh -bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh -``` +配布先は Claude Code と Codex の 2 つです。Kiro CLI と agy へはどのプラグインも配りません。この 2 つは +cross-review / cross-refactoring の相手役として CLI を起動するだけで、導入物を持ちません(`dev.kiro/`・`dev.agy/`・ +`kiro-skills.txt`・`agy-skills.txt` を足すと `scripts/tests/test_distribution_runtimes.py` が落とします)。 ## プラグイン構造 @@ -62,25 +58,18 @@ plugins/{plugin-name}/ │ └── {skill-name}/ │ └── SKILL.md ├── manifests/ # ランタイム別の配布 Skill 一覧(Skill を配る場合) -│ └── {claude,codex,kiro}-skills.txt +│ └── {claude,codex}-skills.txt ├── hooks/ # フック(オプション) │ ├── claude.json # Claude Code 用 │ └── codex.json # Codex 用 -├── dev.kiro/ # Kiro CLI 用(installer など) -│ └── install.sh -├── dev.agy/ # agy 用(マニフェスト・hooks.json・symlink) -│ ├── plugin.json -│ └── hooks.json └── README.md # プラグイン説明 ``` -4 ランタイムが同じディレクトリを読みます。読む対象はマニフェストと installer が決めるため、 -公開される Skill と hook はランタイムごとに異なります。`dev.kiro` と `dev.agy` は Agent Plugins -仕様 §8.2 が定めるクライアント拡張ディレクトリです。 +2 ランタイムが同じディレクトリを読みます。読む対象はマニフェストが決めるため、 +公開される Skill と hook はランタイムごとに異なります。 -**ルート直下の `plugin.json` は、agy へ配るためには置きません。** 置くと Codex がそちらを -優先して読み、`skills/` の実体を全件配ってしまいます(`plugins/ndf` で実測)。agy 向けの -定義は `dev.agy/plugin.json` へ置きます。 +**`.codex-plugin/` を持つプラグインはルート直下に `plugin.json` を置きません。** 置くと Codex がそちらを +優先して読み、`skills/` の実体を全件配ってしまいます(`plugins/ndf` で実測)。 ルートの `plugin.json`(Agent Plugins 形式)は、`skills/` を全件公開してよく hook も持たない プラグインにだけ置きます。判断の基準は @@ -134,11 +123,11 @@ plugins/{plugin-name}/ 2. **ディレクトリ構造を作成** ```bash - mkdir -p plugins/{plugin-name}/{.claude-plugin,.codex-plugin,commands,agents,skills,manifests,hooks,dev.kiro} + mkdir -p plugins/{plugin-name}/{.claude-plugin,.codex-plugin,commands,agents,skills,manifests,hooks} ``` 使わないディレクトリは作らなくて構いません。Skill を配らないなら `skills/` と - `manifests/`、hook が無いなら `hooks/`、Kiro CLI へ配らないなら `dev.kiro/` は不要です。 + `manifests/`、hook が無いなら `hooks/` は不要です。 3. **plugin.jsonを作成** - 必須フィールドをすべて含める @@ -185,25 +174,23 @@ frontmatter と命名の書き方は [AUTHORING.md](../plugins/ndf/skills/AUTHOR | 1 | `plugins/ndf/skills/<名前>/SKILL.md` | 実体 | `check-skill-frontmatter.py`(形だけ) | | 2 | `plugins/ndf/manifests/<ランタイム>-skills.txt` | 配るランタイムの分だけ名前を 1 行 | `validate-runtime-plugins.sh`(どの manifest にも無いときだけ。配る先は判断) | | 3 | `plugins/ndf/.claude-plugin/plugin.json` / `.codex-plugin/plugin.json` の `skills` 配列 | `./skills/<名前>`(manifest に載せたランタイムの分) | `validate-runtime-plugins.sh` | -| 4 | `bash scripts/build-runtime-plugins.sh` の生成物 | `dev.agy/skills/<名前>`(agy へ配るとき)、`skills/<名前>/agents/openai.yaml`(`disable-model-invocation: true` のとき)をコミット | `build-runtime-plugins.sh --check` | -| 5 | `README.md` の概要「公開Skills」とプラグイン一覧表の `ndf` 行(2 行) | `Claude Code向け core 45個` の形で 4 ランタイム | `check-doc-staleness.py` | +| 4 | `bash scripts/build-runtime-plugins.sh` の生成物 | `skills/<名前>/agents/openai.yaml`(`disable-model-invocation: true` のとき)をコミット | `build-runtime-plugins.sh --check` | +| 5 | `README.md` の概要「公開Skills」とプラグイン一覧表の `ndf` 行(2 行) | `Claude Code向け core 45個` の形で 2 ランタイム | `check-doc-staleness.py` | | 6 | `README.md` の「元Skills(N個)」 | 実体の数 | `check-doc-staleness.py` | | 7 | `README.md` のカテゴリ内訳 | `- 運用 (2): skill-stats, statusline` の形。名前と括弧の数 | `check-doc-staleness.py`(合計だけ。どのカテゴリに入れるかは判断) | -| 8 | `plugins/ndf/README.md` の配布先の表 4 行 | `\| Claude Code \| 45 個 \|` の形 | `check-doc-staleness.py` | +| 8 | `plugins/ndf/README.md` の配布先の表 2 行 | `\| Claude Code \| 45 個 \|` の形 | `check-doc-staleness.py` | | 9 | `plugins/ndf/README.md` のレイアウト図 | `唯一の実体(45 個)` | `check-doc-staleness.py` | -| 10 | `plugin.json` 3 本(claude・codex・`dev.agy`)の `description` | `45 focused NDF skills` の形 | `validate-runtime-plugins.sh` | +| 10 | `plugin.json` 2 本(claude・codex)の `description` | `45 focused NDF skills` の形 | `validate-runtime-plugins.sh` | | 11 | `.claude-plugin/marketplace.json` の `ndf` の `description` | 10 と同じ形(Claude Code の数) | `validate-runtime-plugins.sh` | -| 12 | `scripts/tests/test_agy_distribution.py` の `EXPECTED_COUNTS` | 4 ランタイムの数 | `pytest`(件数の assert だけで、直す場所を名指ししない) | -| 13 | `plugins/ndf/README.md` の agy の節「Skill N 個」 | agy の数 | **拾わない**(#611) | -| 14 | `plugins/ndf/README.md` の「Codex の暗黙起動抑止」の数と表 | `disable-model-invocation: true` のときだけ | **拾わない**(#611) | -| 15 | `docs/ndf-plugin-reference.md` の数 | 4 ランタイムの数と実体の数 | **拾わない**(#611) | +| 12 | `plugins/ndf/README.md` の「Codex の暗黙起動抑止」の数と表 | `disable-model-invocation: true` のときだけ | **拾わない** | +| 13 | `docs/ndf-plugin-reference.md` の数 | 2 ランタイムの数と実体の数 | **拾わない** | **数の形は 3 通りあります。** `README.md` は `core 45個`(空白なし)、`plugins/ndf/README.md` は `45 個`、`plugin.json` は `45 focused NDF skills` です。数だけを機械的に置き換えると、どれかに 当たりません。数はランタイムごとに manifest の行数から決めます。 ```bash -for r in claude codex kiro agy; do +for r in claude codex; do printf '%s ' "$r"; grep -cvE '^[[:space:]]*(#|$)' "plugins/ndf/manifests/$r-skills.txt" done ls -d plugins/ndf/skills/*/ | wc -l # 実体の数(6・9) @@ -245,12 +232,9 @@ uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto 上書きされる**(`--scope local` を指定しても起きる)。続けて `marketplace remove` すると clone と導入記録まで消える。 -手元で確かめる手段は 2 つある。 +手元で確かめる手段は次のとおり。 ```bash -# Kiro CLI: installer が任意のディレクトリへ導入する。取得元を書き換えない -bash plugins/ndf/dev.kiro/install.sh --project <検証用ディレクトリ> --yes - # Claude Code: 1 つのプラグインをディレクトリから読み込む claude --plugin-dir plugins/ndf ``` @@ -294,6 +278,29 @@ uv run --with pytest --with pytest-xdist pytest scripts/tests -q -n auto 対象プロジェクト(`pyproject.toml`)が無く、`pytest` が環境にも入っていないためです。 `plugins/ndf/skills/*/tests/` の既存のテストも同じ形で実行します。 +### テストを削る基準 + +全体テスト(`.ndf/project.json` の `test.suites` の全部)は手元で 180 秒以内に終わる大きさに保つ。テストの数がそのまま全体テストの長さと保守の費用になるため、守る振る舞いを別のテストが確かめているテストは残さない。次のどれかに当たるテストは消すか、1 件にまとめる。 + +| # | 基準 | 例 | +| --- | --- | --- | +| 1 | 重複: 同じ経路を、別の名前で同じ入力から確かめている | 同じ CLI を同じ引数で起動し、同じ終了コードを見るテストが 2 件ある | +| 2 | 消えた機能の残骸: 廃止した機能・オプションを確かめている | 外した副命令が「無い」ことだけを確かめる | +| 3 | `.md` の文言の固定 | Skill の本文に決まった文があるかを照合する | +| 4 | 実装の細部の固定: 内部の関数名・呼び出しの回数・中間の値を固定し、利用者に見える振る舞いを確かめていない | 判定は NDF の `tdd-cycle` の `references/test-quality.md` の「判定の基準」に従う | +| 5 | 総当たり: 1 つの振る舞いを、全テンプレート・全組み合わせで確かめている | 代表は「分岐ごとに 1 つ・境界の両側」で選び、残りを消す | +| 6 | suite・CI のジョブが既に走らせる検査を、テストの中でもう一度走らせる | `script-structure` の suite と同じ検査を、テストがリポジトリ全体へ流す | + +消す・まとめるときは、1 件ごとに、当たった基準の番号と、守っていた振る舞いを今後どのテストが守るか(守らなくてよいならその理由)を PR から追える形で残す。落ちるから消すのは基準に当たらない。 + +遅いテストを速くするときは、確かめる振る舞いを弱めずに次の 3 つの手段を使う。テストを速くするためにテストの対象のコードを変えない。 + +- 待ちの値を、対象のコードが既に持つ環境変数で縮める +- 1 つの CLI の配線(引数・終了コード・結果 JSON)は代表の数件の起動で確かめ、分岐はプロセスの中で関数を呼んで確かめる。スクリプトの起動だけで 1 件 0.2〜0.4 秒かかる +- git のリポジトリのような重い準備は、プロセスごとに 1 度作って複製する + +テストが起こした子孫のプロセスは、pytest が SIGTERM で止められたときと終わったときに、根の `conftest.py` が読み込む `scripts/pytest_child_cleanup.py` が止める。テストの中で止め忘れても次のテストへ持ち越さないが、テストは自分で起こしたプロセスを自分で止める。 + ローカル hook は任意で導入できます。 ```bash diff --git a/docs/project-overview.md b/docs/project-overview.md index 4c7f059f5..7db4e20e8 100644 --- a/docs/project-overview.md +++ b/docs/project-overview.md @@ -23,17 +23,17 @@ Claude Code / Codex プラグインマーケットプレイス(内部用)と ``` ai-plugins/ ├── .claude-plugin/ -│ └── marketplace.json # マーケットプレイス定義(3ランタイム共通) +│ └── marketplace.json # マーケットプレイス定義(Claude Code・Codex 共通) ├── plugins/ -│ ├── ndf/ # NDF(3ランタイム共通の単一ディレクトリ) -│ ├── playwright-kit/ # playwright-kit(3ランタイム共通の単一ディレクトリ) +│ ├── ndf/ # NDF(Claude Code・Codex 共通の単一ディレクトリ) +│ ├── playwright-kit/ # playwright-kit(Claude Code・Codex 共通の単一ディレクトリ) │ └── mcp/ -│ └── mcp-*/ # MCPプラグイン10個(3ランタイム共通) +│ └── mcp-*/ # MCPプラグイン10個(Claude Code・Codex 共通) ├── docs/ # リポジトリ知識 ├── factory/ # Web サービス(Neural Development Factory)。プラグインと分け、自分の LICENSE を持つ ├── AGENTS.md # 共通エントリポイント ├── CLAUDE.md # Claude Code固有設定 -├── KIRO.md # Kiro CLI固有設定 +├── KIRO.md # 相手役として起動される Kiro CLI の説明 └── README.md # プロジェクトドキュメント ``` diff --git a/docs/specifications/cross-refactoring-init-test-and-phase-windows.md b/docs/specifications/cross-refactoring-init-test-and-phase-windows.md index bbf123686..f58633b76 100644 --- a/docs/specifications/cross-refactoring-init-test-and-phase-windows.md +++ b/docs/specifications/cross-refactoring-init-test-and-phase-windows.md @@ -6,9 +6,11 @@ 提案とリファクタリング計画の枠は着手前のテストの終わりから数え、全体の締め切り(`started_at + B`)は動かさない。 伸びた分はリファクタリング計画で採る件数が減ることで吸収する - **枠が想定最大時間に収まらないときは、提案者を起動せずに止まる。** 要る想定最大時間の下限を出し、予算を広げて - `init` を打ち直すと、着手前のテストを走らせ直さずに続く -- **CI に任せる戦略の着手前のテストの上限は、範囲テストの所要が分かればそれを下回らない。** 所要は同じ範囲の履歴の実測か、 - 範囲が全体テストの suite を覆うときの w から取る。範囲の広いスプリントでも、予算を広げずに init を通る + `init` を打ち直すと、着手前のテストを走らせ直さずに続く。止まるのは想定最大時間を明示した実行だけで、省いた実行は + `init` が想定最大時間の下限まで上げて続ける +- **着手前のテストの上限は、所要の見込み s が分かればそれを下回らない。** s は CI に任せる戦略なら同じ範囲の履歴の実測を先に採り、 + 無ければ(ほかの戦略も)手元の全体テストの直近の実測 m、無ければ宣言の w から取る(戦略を問わない。w は置き場所が suite を覆うかを問わない)。 + 範囲の広いスプリントでも、予算を広げずに init を通る 共通の原則は「**着手前のテストの上限と、その後の手順の枠を、同じ算術の層(`test_strategy.limits` と `timeline`)で入力だけから決める**」である。 @@ -87,13 +89,16 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 #1385 と同じ打ち切りに戻る - **枠が想定最大時間に収まらなければ、`init` で状態を保存してから止める。** 枠を残りの時間へ縮めて起動すると、縮めた枠 (90 秒)で提案 0 件になった実測がある。予算を自動で広げないのは、想定最大時間が利用者の与えた上限だからである。 - 保存しておけば、予算を広げた打ち直しは着手前のテストを走らせ直さずに続く + 保存しておけば、予算を広げた打ち直しは着手前のテストを走らせ直さずに続く。自動で広げないのは明示した + 想定最大時間(出所 `explicit`)だけにした。省いた実行(出所 `default`)は利用者の上限を持たないため、`init_test.settle_window` が + 下限まで上げて出所を `floor` にする。明示で収まらないときは `SKIP=window` を出して止まり、駆動はリファクタリングを飛ばした + 欠けの残る結果(終了コード 6)で返す。理由は [#2047 の設計](../../issues/issue-2047-design.md) の決定 2・決定 3 にある - **止めた後の打ち直しでだけ、枠の起点を打ち直した時刻へずらす。** 起点を `started_at + x` のまま残すと、止まってから人が 打ち直すまでの数分が提案の枠から引かれ、下限どおりの予算で提案者が余裕だけで起動される。提案や計画の途中で落ちた再開まで ずらすと、提案が揃っているのに枠が想定最大時間を越えて止まる。計画を取り込んだ後は採った件数が期限に合わせてあるため、 保存した期限で続ける - **要る想定最大時間の下限には、人が打ち直すまでの 300 秒(`RESUME_GRACE_SECONDS`)を足す。** 足さないと、出した下限で - 打ち直したときに経過の分だけ o が伸びて再び収まらない + 打ち直したときに経過の分だけ o が伸びて再び収まらない。打ち直さずにその場で上げる `settle_window` は足さない(`grace_seconds=0`。#2047 の決定 2) - **範囲テストの所要は、着手前のテストの行を履歴に別の種類(`kind: init_test`)として足して実測から引く。** 実行の行は 最終ゲートを通った実行だけが書くため、中断した実行と init で打ち切られた実行が残らず、範囲が suite の `paths` を覆わない リポジトリではいつまでも所要が分からない。打ち切りの行は上限の秒を下限の実測として残し、次の実行は `3·上限` で走らせる @@ -118,10 +123,10 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | I2 | `propose_end_at = started_at + o + 0.20·B`、`plan_end_at = propose_end_at + 0.10·B`。o は `resumed_at` が無ければ x(`baseline_test.seconds`。無ければ 0)、あれば `max(x, resumed_at − started_at)` | 単体テストで落とす | | I3 | `resumed_at` を書くのは、止めた印(`window_stopped_at`)を持つ状態をリファクタリング計画の前に打ち直したときだけである。印の無い再開と計画を取り込んだ後の再開は `resumed_at` を変えない | `init` のテストで落とす | | I4 | `fix_end_at`・`final_end_at`・実装の終わりは `started_at` から数え、x に依らない(項目ごとの期限は持たない) | 単体テストで落とす | -| I5 | `plan_end_at` に計画の後の見込み(`after_plan_minutes`。バッファの見込みと最短の項目 1 件)を足した時刻が `final_end_at` を越える状態で提案者を起動しない。`init`(新規と、計画の前の再開)は止めた印を残して状態を保存し、要る想定最大時間の下限を出して終了コード 4 で止まる。収まれば印を消す | `init` のテストで落とす | +| I5 | `plan_end_at` に計画の後の見込み(`after_plan_minutes`。バッファの見込みと最短の項目 1 件)を足した時刻が `final_end_at` を越える状態で提案者を起動しない。`init`(新規と、計画の前の再開)は、想定最大時間の出所が `explicit`(`budget_source` を持たない旧い状態も同じ)なら `SKIP=window` を出し、止めた印を残して状態を保存し、要る想定最大時間の下限を出して終了コード 4 で止まる。`default` / `floor` なら下限まで上げて続ける。収まれば印を消す | `init` のテストで落とす | | I6 | 計画を取り込んだ後の再開は `limits` を組み直さず、`window_problem` も見ない | `init` のテストで落とす | -| I7 | CI に任せる戦略の `init_test_timeout` は、範囲テストの所要 s が分かれば `max(0.10·B, 3·s)`、分からなければ `0.10·B`。ほかの戦略と予算なしの値は s を渡しても変わらない | 単体テストで落とす | -| I8 | s は、同じ戦略・同じ置き場所の集合の `init_test` 行の直近 10 行の秒の最大 → 置き場所が全体テストの suite の `paths` を覆うときの w → 無し、の順に採る。CI に任せる戦略でなければ履歴を読まない | 単体テストで落とす | +| I7 | `init_test_timeout` は、戦略を問わず着手前のテストの所要の見込み s が分かれば `max(0.10·B, 3·s)`、分からなければ今の値(CI に任せる戦略は `0.10·B`、ほかは `max(0.10·B, 3·w)`)。予算なしの値は s を渡しても変わらない | 単体テストで落とす | +| I8 | s は、CI に任せる戦略なら同じ戦略・同じ置き場所の集合の `init_test` 行の直近 10 行の秒の最大 → 手元の全体テストの直近の実測 m(`recent`)→ 宣言の w(`whole`。置き場所が suite を覆うかは問わない)→ 無し、の順に採る。CI に任せる戦略でなければ履歴を読まず m → w を採る | 単体テストで落とす | | I9 | 新規の `init` では、着手前のテストに使った上限と、状態から組み直した `init_test_timeout` が一致する(s は `baseline_test` に残る)。再開で予算を置き換えたときは置き換えた後の予算から組み直す | 単体テストで落とす | | I10 | 着手前のテストは成否と打ち切りを問わず `init_test` 行を 1 行足してから次へ進むか止まる | `init` のテストで落とす | | I11 | `init_test` 行は配分テーブルにも `ndf_record` にも数えない。`schema` が 2 未満の行と `kind` を持たない行は実行の行として読み、範囲の一致に使わない。読んでも落ちない | 単体テストで落とす | @@ -130,8 +135,8 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | 関数 | 契約 | | --- | --- | -| `limits(strategy, budget_minutes, ..., scope_seconds=None, scope_source=None)` | キーワード引数 2 つを足した(既定 `None` で、今の呼び出しはそのまま通る)。予算があるときだけ `basis` に `scope_seconds` / `scope_source` を書く(`scope_seconds` が `None` なら両方 `None`)。予算が無いとき(supervise)は表も `basis` の形も変えない。`test_timeout` と `whole_timeout` の代わりの値(x・w が無いとき)は s を入れない着手前の上限のまま | -| `covers_whole(strategy, locations, kind=TEST)` | `kind` の suite の `paths` の全要素を、置き場所のどれかが同じか祖先として覆えば真(純粋)。置き場所は正規化して比べる。`.` と glob の要素は置き場所 `.` だけが覆う。`kind` の suite が無いか置き場所が無ければ偽 | +| `limits(strategy, budget_minutes, ..., scope_seconds=None, scope_source=None)` | キーワード引数 2 つを足した(既定 `None` で、今の呼び出しはそのまま通る)。`scope_seconds` は戦略を問わず着手前の上限に効く。予算があるときだけ `basis` に `scope_seconds` / `scope_source` を書く(`scope_seconds` が `None` なら両方 `None`)。予算が無いとき(supervise)は表も `basis` の形も変えない。`test_timeout` と `whole_timeout` の代わりの値(x・w が無いとき)は s を入れない着手前の上限のまま | +| `covers_whole(strategy, locations, kind=TEST)` | `kind` の suite の `paths` の全要素を、置き場所のどれかが同じか祖先として覆えば真(純粋)。置き場所は正規化して比べる。`.` と glob の要素は置き場所 `.` だけが覆う。`kind` の suite が無いか置き場所が無ければ偽。s の決め方には使わない | ### 手順の枠(`refactor_lib/timeline.py`) @@ -141,7 +146,7 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | `window_offset(state)` | o(秒)。I2 の式で、状態の `baseline_test.seconds`・`started_at`・`resumed_at` を読み、締め切りをずらした止まっていた時間(`pause.shifted_seconds`。#1944)を足す | | `of_state(state)` | `window_offset(state)` を `compute` へ渡す。`test_limits(state)` は `baseline_test.scope_seconds` / `scope_source` を `limits` へ渡す(I9) | | `RESUME_GRACE_SECONDS` | 300。人が文を読んで打ち直すまでの見込み | -| `required_budget_minutes(offset_seconds, after_plan=0)` | `ceil(((o + RESUME_GRACE_SECONDS) / 60 + after_plan) / (1 − PROPOSE_SHARE − PLAN_SHARE))`(分)。見込みが 0 なら o = 1300 で 39、1700 で 48 | +| `required_budget_minutes(offset_seconds, after_plan=0, grace_seconds=RESUME_GRACE_SECONDS)` | `ceil(((o + grace_seconds) / 60 + after_plan) / (1 − PROPOSE_SHARE − PLAN_SHARE))`(分)。見込みが 0 なら o = 1300 で 39、1700 で 48。`settle_window` の上げる経路だけ `grace_seconds=0` | | `window_problem(limits)` | `plan_end_at + after_plan_minutes > final_end_at` なら止める理由の文(`limits` から o を逆算し、下限を `required_budget_minutes` で出す)、収まれば `None`(純粋)。見込みの出し方は [実装の時間と採り直しの確定仕様](cross-refactoring-implementation-window-and-readopt.md) にある | ### 着手前のテスト(`refactor_lib/baseline.py`・`refactor_lib/init_test.py`) @@ -150,18 +155,20 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | --- | --- | | `baseline.locations_of(strategy, scope, work)` | CI に任せる戦略(`round-only` を除く)で着手前に走らせる範囲テストの置き場所を、正規化して並べ替えた集合で返す。ほかの戦略は空。`commands_of` も同じ関数で置き場所を求める | | `baseline.run_baseline(...)` | 上限を超えたら止めずに記録を返す(`timed_out: true`・`seconds` は上限の秒。既存失敗の読み取りは行わない)。止めるのは起動の失敗だけ。記録に `locations`(`scope` のときだけ)と `timed_out` を足した | -| `init_test.resolve_scope_seconds(prep, scope, w)` | `(s, 出所)`。I8 の順で `history` / `whole` / `(None, None)`。CI に任せる戦略でなければ履歴を読まずに `(None, None)` | +| `init_test.resolve_scope_seconds(prep, scope, w, m=None)` | `(s, 出所)`。I8 の順で `history` / `recent` / `whole` / `(None, None)`。CI に任せる戦略でなければ履歴を読まずに m → w を採る | +| `init_test.recent_whole_seconds(prep)` | 手元の全体テストの直近の実測 m(`whole_runs.recent_whole`)。無ければ `None` | +| `init_test.extend_for_init_test(budget_minutes, timeout)` | 上限 T が `0.10·B` を超えるときだけ、テストの前にステップの延長へ `B·60 + (T − 0.10·B·60)` 秒を書く(`NDF_STEP_EXTEND_FILE` が無ければ何もしない。#2047 の決定 4) | | `init_test.append_init_test(prep, budget_minutes, pr, baseline)` | `init_test` 行を 1 行追記する。書けなければ知らせて続ける | -| `init_test.abort_timed_out(strategy, budget_minutes, baseline, timeout)` | 打ち切りの文で終了コード 4 にする。`scope` の CI に任せる戦略なら、次の実行の上限の見込み(`3·上限` を s とした `limits`)を添える | -| `init_test.stop_if_window_short(state_file, state)` | `window_problem` が文を返せば `window_stopped_at` を書いて保存し、終了コード 4。`None` なら印を消し、消したときだけ保存する | +| `init_test.abort_timed_out(strategy, budget_minutes, baseline, timeout)` | `SKIP=init-timeout` と `SKIP_DETAIL` を出し、打ち切りの文で終了コード 4 にする。`scope` の CI に任せる戦略なら、次の実行の上限の見込み(`3·上限` を s とした `limits`)を添える | +| `init_test.settle_window(state_file, state)` | `window_problem` が `None` なら印を消し、消したときだけ保存する。文を返し、出所(`budget_source`)が `explicit` なら `SKIP=window` と `SKIP_DETAIL` を出し、`window_stopped_at` を書いて保存し、終了コード 4。`default` / `floor` なら `budget_minutes` を `grace_seconds=0` の下限へ上げ、`limits` を組み直して出所を `floor`・`budget_floor` を書いて保存し、`ℹ` の 1 行を出して続ける | `refactor.py init` の順序(`commands/setup.py`): -1. `_verify_init`: w と c を読み、`resolve_scope_seconds` で s を求めて `limits` から上限を出す → `run_baseline` → +1. `_verify_init`: w と c と m を読み、`resolve_scope_seconds` で s を求めて `limits` から上限を出す → `extend_for_init_test` → `run_baseline` → `baseline_test` に `scope_seconds` / `scope_source` を残す → `append_init_test` → 打ち切りなら `abort_timed_out` -2. `cmd_init`: `_save_initial_state` で状態を保存した後に `stop_if_window_short`。止まらなければ出力(`PHASE=` など)へ進む +2. `cmd_init`: 既定を埋める前に出所(`explicit` / `default`)を決め、`_save_initial_state` で状態を保存した後に `settle_window`。止まらなければ出力(`PHASE=`・`BUDGET_SOURCE=` など)へ進む 3. `_resume`: 計画の前後を問わず先に `pause.resume_after_pause` で止まっていた時間を測る。計画の前(`state.plan` が無い)なら、 - 印があるときだけ `resumed_at` を今にし、`limits` を `of_state` で組み直して `stop_if_window_short`。計画の後は組み直さない(I6) + 引数で想定最大時間を渡せば出所を `explicit` にし、印があるときだけ `resumed_at` を今にし、`limits` を `of_state` で組み直して `settle_window`。計画の後は組み直さない(I6) ### 履歴の行(`refactor_lib/allocation.py`) @@ -199,9 +206,10 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 | --- | --- | --- | --- | --- | | `baseline_test` | `locations` | 着手前に走らせた範囲テストの置き場所(`scope` 以外は `null`) | `baseline.run_baseline` | `allocation.init_test_row` | | `baseline_test` | `timed_out` | 上限で打ち切ったか | 同上 | `setup._verify_init`・`allocation.init_test_row` | -| `baseline_test` | `scope_seconds` / `scope_source` | 上限に使った s と出所(`history` / `whole`。分からなければ両方 `null`) | `setup._verify_init` | `timeline.test_limits` | +| `baseline_test` | `scope_seconds` / `scope_source` | 上限に使った s と出所(`history` / `recent` / `whole`。分からなければ両方 `null`) | `setup._verify_init` | `timeline.test_limits` | | `limits.basis` | `scope_seconds` / `scope_source` | 同上の写し | `test_strategy.limits` | `plan.limits_section`(計画のコメントの「入力:」の行) | -| 状態の根 | `window_stopped_at` | 枠が収まらずに止めた時刻(止めた印) | `init_test.stop_if_window_short` | `setup._resume` | +| 状態の根 | `window_stopped_at` | 枠が収まらずに止めた時刻(止めた印) | `init_test.settle_window` | `setup._resume` | +| 状態の根 | `budget_source` / `budget_floor` | 想定最大時間の出所(`explicit` / `default` / `floor`)と、上げたときの前後の分 | `setup.cmd_init`・`setup._resume`・`init_test.settle_window` | `settle_window`・`_emit_init`・`allocation.build_row`・`report` | | 状態の根 | `resumed_at` | 止めた後に打ち直した時刻 | `setup._resume` | `timeline.window_offset` | `limits` を持つ旧い状態ファイルは書き出した値を使い、持たないものは `of_state` が新しい式で組む。`baseline_test.scope_seconds` が @@ -230,12 +238,16 @@ init が終了コード 4 で止まった。同じ範囲の実測は 320 秒(6 `baseline_test` から組み直した `init_test_timeout` も 960(`test_the_scope_test_seconds_feed_the_next_init_limit`) - 打ち切りでも `seconds: 180.0`・`timed_out: true` の行を足してから終了コード 4 で止まり、次の上限は 540 (`test_a_timed_out_init_test_is_recorded_before_stopping`) -- 置き場所が suite の `paths` を覆い履歴に一致が無ければ s = w、w が無ければ無し(`test_the_whole_test_covers_the_scope`) +- 置き場所が suite の `paths` を覆い履歴に一致が無ければ s = w、w が無ければ無し(`test_the_whole_test_covers_the_scope`)。 + #2047 の振る舞い(m を w より先に採る・明示で収まらなければ `SKIP=window`・省けば下限へ上げる・打ち切りの `SKIP=init-timeout`・ + テストの前のステップの延長)は `test_the_init_estimate_order`・`test_the_first_init_limit_reads_the_recent_whole_test`・ + `test_an_omitted_budget_rises_to_the_lower_bound`・`test_an_explicit_budget_short_of_the_floor_emits_skip`・ + `test_a_timed_out_init_test_emits_skip`・`test_a_long_init_limit_extends_the_step_before_the_test` が縛る (以上 `plugins/ndf/skills/cross-refactoring/tests/test_init.py`) - `limits` が CI に任せる戦略・B 30 で s 320 → 960、424 → 1272、無し → 180、30 → 180 を返し、`basis` に s と出所が残る (`test_the_ci_strategy_init_limit_does_not_fall_below_the_scope_test`)。s で `test_timeout`・`whole_timeout`・`ci_wait_timeout` は変わらない (`test_the_scope_test_does_not_change_the_other_limits`) -- `local-full` / `round-only` は s を渡しても `max(0.10·B, 3·w)`(`test_other_strategies_ignore_the_scope_test`)。予算なしの表は +- `local-full` / `round-only` も s を渡せば `max(0.10·B, 3·s)`、渡さなければ `max(0.10·B, 3·w)`(`test_other_strategies_take_the_init_test_estimate`。#2047)。予算なしの表は s を渡しても変わらず、`basis` に `scope_seconds` を持たない(`test_supervise_limits_stay_the_same`) - `covers_whole` が祖先・正規化・片方だけ・子・`.`・glob・空の置き場所・テストの suite が無いときを区別する (`test_covers_whole`・`test_covers_whole_needs_a_test_suite`)(以上 `plugins/ndf/scripts/tests/test_lib_test_strategy.py`) diff --git a/docs/specifications/cross-refactoring-time-budget.md b/docs/specifications/cross-refactoring-time-budget.md index 4b034061b..c63427dc0 100644 --- a/docs/specifications/cross-refactoring-time-budget.md +++ b/docs/specifications/cross-refactoring-time-budget.md @@ -163,7 +163,7 @@ | 引数 | 扱い | 既定 | | --- | --- | --- | -| `--budget-minutes N` | 1 以上の整数。それ以外は `init` が終了コード 4(argparse の 2 にしない) | 30(`vocabulary.DEFAULT_BUDGET_MINUTES`) | +| `--budget-minutes N` | 1 以上の整数。それ以外は `init` が終了コード 4(argparse の 2 にしない)。明示した値は上げない | 30(`vocabulary.DEFAULT_BUDGET_MINUTES`)。省いて手順の枠が収まらなければ想定最大時間の下限まで上げる | | `--implementer NAME` | 参加者の中の 1 者。参加者に無ければ終了コード 4 | 下の決め方 | | `--round-test CMD` | 範囲テストを組み立てる元。**`--baseline-test` の実行器が既知でなければ必須**(無ければ提案の前に終了コード 4) | `--baseline-test` を元にする | | `--max-test-rounds` / `--max-outer-rounds` / `--max-items-per-round` / `--max-fix-rounds` / `--test-timeout` | 廃止。受け取ると標準エラーへ `⚠ <引数> は廃止しました(#933)。--budget-minutes で所要を決めます` を出し、値を使わずに続ける(`setup.DEPRECATED_ARGS`) | — | @@ -251,7 +251,7 @@ | 値 | 要点 | 係数・関数 | | --- | --- | --- | | 余裕 | 0.05·B | `MARGIN_SHARE` | -| 着手前のテスト 1 回の上限 | 0.10·B(w はまだ無い) | `INIT_TEST_SHARE` | +| 着手前のテスト 1 回の上限 | max(0.10·B, 3·s)。s(着手前のテストの所要の見込み)が分からなければ 0.10·B か max(0.10·B, 3·w) | `INIT_TEST_SHARE` | | テスト 1 回の上限 | max(3·w, 0.01·B) | `TEST_FACTOR` / `TEST_FLOOR_SHARE` | | 提案の枠の終わり / リファクタリング計画の枠の終わり | 開始 + 0.20·B / さらに + 0.10·B(開始 + 0.30·B) | `PROPOSE_SHARE` / `PLAN_SHARE` | | テストの追加・実装の終わり | 実装の終わり = 最終ゲート修正の打ち切り − R − Σ(採っていて未検証の項目の verify)。テストの追加の終わりはそこから同じ項目の implement を引く | `budget.implement_end` / `budget.add_tests_end` | diff --git a/docs/specifications/ndf-backlog-refinement-stage.md b/docs/specifications/ndf-backlog-refinement-stage.md index c442d6236..4fd2befd6 100644 --- a/docs/specifications/ndf-backlog-refinement-stage.md +++ b/docs/specifications/ndf-backlog-refinement-stage.md @@ -100,8 +100,8 @@ conductor が打ち切った。今の形では次のように動く。 ### スプリントの終わりのプランの `refine` ステップ -`supervise.py new close` が組むプラン(`supervise_lib/sprint.py` の `close_plan`)は、`retro` の後に -`refine` を置く。`retro` の `next` は `refine` で、`retro` の `prompt` は棚卸しを含まない。 +`supervise.py new close` が組むプラン(`supervise_lib/sprint.py` の `close_plan`)は、`retro` の後に課題を閉じる `close` を、 +その後に `refine` を置く。`retro` の `next` は `close`、`close` の `next` は `refine` で、`retro` の `prompt` は棚卸しを含まない。 | キー | 値 | | --- | --- | @@ -131,7 +131,7 @@ worker の歯止めで、費用の上限は件数が持つ。プランの `上 | 不正な上限は候補を集めずに終了コード 2 | 同上の `test_candidates_reject_invalid_limit_before_collecting` | | 候補が 0 件の棚卸しは上限の導入で持ち越しにならない | 同上の `test_candidates_zero_is_ok_under_limit` | | 書き込み先・plan の `repo`・記録のどれかが食い違えば、1 件も書かずに終了コード 3 | 同上の `test_apply_refuses_*` | -| `new close` のプランで `retro` の次が `refine`、`refine` の `stage` が `棚卸し` で `timeout` を持ち、プロンプトが無人の扱いを渡す | `plugins/ndf/scripts/tests/test_supervise_pace.py`(`test_close_runs_spec_close_and_retro_once_each_in_order`) | +| `new close` のプランで `retro` の次が `close`、その次が `refine`、`refine` の `stage` が `棚卸し` で `timeout` を持ち、プロンプトが無人の扱いを渡す | `plugins/ndf/scripts/tests/test_supervise_pace.py`(`test_close_runs_spec_close_and_retro_once_each_in_order`) | | `STEP_PHASES` がフェーズの表と一致する | `plugins/ndf/scripts/tests/test_transcript_agents.py`(`test_the_step_names_match_the_phase_table`) | | `stage 棚卸し` が記録され、5 モードで必須として扱われる | `plugins/ndf/scripts/tests/test_progress_record_stages.py`(`test_the_refinement_stage_is_recorded_and_required_in_every_mode`) | | 最終工程を持つ 3 つの Skill(`retrospective` / `release-verification` / `release`)が棚卸しを呼ばない。順序の制約と規則 14 がそれぞれ 1 か所にある | 文書を `grep -n` で読んで確かめる(文言を照合するテストは書かない) | diff --git a/docs/specifications/ndf-cleanup-and-bundle-closing.md b/docs/specifications/ndf-cleanup-and-bundle-closing.md index 4e49ca1be..0674275d2 100644 --- a/docs/specifications/ndf-cleanup-and-bundle-closing.md +++ b/docs/specifications/ndf-cleanup-and-bundle-closing.md @@ -29,8 +29,8 @@ Request の head のリモートブランチは、同意を求めずに消す。 有無にかかわらず消さない。 **スプリント課題は、最終工程を出るときに閉じる。** 閉じる条件は「本番リリースまで済んで -いる、またはリリースを飛ばした」と「リリース後テストを通る経路では受け入れ条件がすべて `合格`」の -2 つで、課題ごとに見る。`merged` は課題を閉じない。 +いる、またはリリースを飛ばした」と「リリース後テストを通る経路では受け入れ条件がすべて `合格`」と +「その実行の最終工程の記録がある(`--final-stage`、省けば振り返り。#2228)」の 3 つで、課題ごとに見る。`merged` は課題を閉じない。 **リリース記録は Pull Request に置く。** `release` の出力物は会話に出るだけで別の実行からは 読めないため、書く側の形を 1 つに決め、読む側はその形だけを読む。 @@ -227,7 +227,7 @@ Pull Request を選ぶため、別のスプリント課題を閉じうる。 | `既に閉じていた` | 最初の読みが CLOSED(既定ブランチへのマージで GitHub が先に閉じた場合を含む) | | `失敗(理由)` | 最後の読みが CLOSED でない、または読み取りが 0 以外で終わった | | `開いたまま(理由)` | 閉じる条件に当たらない | -| `閉じているが条件を満たさない(理由)`(`closed_unverified`) | 最初の読みが CLOSED で、閉じる条件 2 を満たさない | +| `閉じているが条件を満たさない(理由)`(`closed_unverified`) | 最初の読みが CLOSED で、閉じる条件 2 か 3 を満たさない | **`失敗` が 1 件でもあれば、最終工程を完了と報告せず、棚卸しへ進まずに止まる。** `開いたまま` と `閉じているが条件を満たさない` は止める理由にせず、棚卸しへ進んでよい。 @@ -262,7 +262,7 @@ Pull Request を選ぶため、別のスプリント課題を閉じうる。 最後のブロックを読む(版数を上げない配布。[ndf-production-release-record.md](ndf-production-release-record.md) の 「リリース後テストの選び方」)。`配布なし` のときは、その記録より後に投稿された最後のブロックを読む。 表の `課題` の列が無いとき、または `課題` の列の頭が課題の参照(`#<番号>` / `<所有者>/<リポジトリ>#<番号>`)である -行が 1 行も無いときは、スプリント課題すべてを `開いたまま(条件と課題の対応が読めない)` にする。頭が課題の参照で +行が 1 行も無いときは、スプリント課題すべてを `開いたまま(条件と課題の対応が読めない)` にする(見出しが読める表で、すべての条件をマージ前に確かめたと本文に記録した課題は除く。#2144)。頭が課題の参照で ない行(`—`・`PR 1869` など)は飛ばし、ほかの課題の判定を止めない。 ### ボードの自動化 @@ -299,8 +299,10 @@ Pull Request を選ぶため、別のスプリント課題を閉じうる。 一時ディレクトリにあり、共通の git ディレクトリと別のファイルシステムのことがあるため、`mv` が コピーになる。 -**チャネルを分けないリポジトリでは、既定ブランチへのマージで GitHub が先に閉じる。** 「スプリントを -閉じる」は `既に閉じていた` を数えるだけで、二重に閉じようとしても結果は変わらない。 +**チャネルを分けないリポジトリでも、NDF の手順で書いた Pull Request の本文では GitHub が先に閉じない。** +既定ブランチ宛の本文の closing keywords は、`pr-steps.py` とプランの pr のステップが閉じる予定の参照 +(`最終工程の後に閉じる: #<番号>`)へ置き換える。手で書いた本文・コミットメッセージの closing keywords で +GitHub が先に閉じたときは、「スプリントを閉じる」は `既に閉じていた` を数えるだけで、二重に閉じようとしても結果は変わらない。 ## 関連リンク diff --git a/docs/specifications/ndf-sprint-close-acceptance-criteria.md b/docs/specifications/ndf-sprint-close-acceptance-criteria.md index 433775342..397398015 100644 --- a/docs/specifications/ndf-sprint-close-acceptance-criteria.md +++ b/docs/specifications/ndf-sprint-close-acceptance-criteria.md @@ -72,7 +72,7 @@ | # | 条件 | 破れたときの扱い | | --- | --- | --- | | I1 | `課題` の列が `^(<所有者>/<リポジトリ>)?#<番号>` に当たらない行は、どの課題の判定にも使わず、表全体の読みも止めない | — (読み飛ばす) | -| I2 | 課題を指す行が 1 行も無い表は、どの課題にも対応を持たない | 全件を `kept_open`(条件と課題の対応が読めない)。閉じた課題も `closed_unverified` にしない | +| I2 | 課題を指す行が 1 行も無い表は、どの課題にも対応を持たない | 全件を `kept_open`(条件と課題の対応が読めない)。閉じた課題も `closed_unverified` にしない。ただし本文の `## 受け入れ条件の確認` ですべての条件をマージ前に確かめたと記録した課題は、行が要らないため課題ごとの判定へ進める(#2144) | | I3 | 受け入れ条件の番号は、節のコードブロックの外にある行頭(字下げなし)のチェックボックスの行を上から数えた順(1 から)である。取り消し線で差し替えた行は数えない | — (数え方の定義) | | I4 | `closed_unverified` は課題ごとの判定(その課題の行が無い・合格でない行がある・合格の行が無い番号がある)にだけ使う。全体を止める経路(配布の記録が読めない・本番の前・対応が読めない)では閉じた課題も `kept_open` のままにする | — | | I5 | 印付けで変わるのは、節の中のチェックボックスの `[ ]` の空白 1 文字だけである(節の外・取り消し線の行・説明の行・改行の形は変わらない) | 書き込まず、その課題を `failed`(受け入れ条件の印を書けない) | diff --git a/docs/specifications/ndf-sprint-close-referenced-issues.md b/docs/specifications/ndf-sprint-close-referenced-issues.md index 3b073c0e7..c257845b9 100644 --- a/docs/specifications/ndf-sprint-close-referenced-issues.md +++ b/docs/specifications/ndf-sprint-close-referenced-issues.md @@ -21,6 +21,8 @@ 語の正本の行は `development-workflow` の [`references/glossary.md`](../../plugins/ndf/skills/development-workflow/references/glossary.md) にもある。 closing keywords は、Pull Request の本文に課題の番号と一緒に書くと、マージのときに GitHub がその課題を自動で閉じるキーワードである。 +閉じる予定の参照(`最終工程の後に閉じる: #12`。既定ブランチ宛の本文で NDF の手順が closing keywords を置き換えた形)も、 +`closing-issues.sh` が closing keywords と同じ指し先の規則で本文から集め、参照だけの課題にしない。 ## 背景 diff --git a/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md b/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md index 110729faf..34fafe161 100644 --- a/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md +++ b/docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md @@ -51,9 +51,9 @@ | # | 条件 | 破れたときの扱い | | --- | --- | --- | | I1 | まとめのプランの記録の参照が `0`(`RECORD_NONE`)になるのは、リリースの経路がすべて届けない経路(`STAGE_NONE`)のときだけである | テストが落とす | -| I2 | 記録の参照が `{queue_pr:…}` でない(`has_record` が偽)とき、プランに `release-verify` を置かず、merge の次は `close` になる | テストが落とす | +| I2 | 記録の参照が `{queue_pr:…}` でない(`has_record` が偽)とき、プランに `release-verify` を置かず、merge の次は `retro`(その次が `close`。#2228)になる | テストが落とす | | I3 | `--record-pr unknown` の実行は、どの課題も閉じず、ボードも書かず、課題の本文も読まない(gh へ書き込まない) | テストが落とす | -| I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件を見ずに `--issues` を閉じる。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | +| I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件 1・2 を見ずに `--issues` を閉じる(条件 3 の振り返りの記録は見る)。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | | I5 | 要求の 2 列目が「無し」の形の行は `ManualRow` にならない。それ以外の空でない 2 列目は手動確認の行になる | テストが落とす | ### 入出力の契約 @@ -112,9 +112,9 @@ flowchart TD C -- いいえ --> D{"経路がすべて STAGE_NONE"} D -- はい --> Z["0"] D -- いいえ --> U["unknown"] - T --> V["merge → release-verify → close"] + T --> V["merge → release-verify → retro → close"] P --> V - Z --> W["merge → close(release-verify なし)"] + Z --> W["merge → retro → close(release-verify なし)"] U --> W ``` diff --git a/docs/specifications/ndf-supervise-step-budgets.md b/docs/specifications/ndf-supervise-step-budgets.md index 068bff483..6d52035af 100644 --- a/docs/specifications/ndf-supervise-step-budgets.md +++ b/docs/specifications/ndf-supervise-step-budgets.md @@ -7,6 +7,8 @@ 同じ値を課題ごとの実装のプランと検査のプランのすべてへ引き継ぐ。**`refactor` の `timeout` は常に `with_margin(M × 60)` で、 M は想定最大時間、無ければ cross-refactoring の既定の予算(`DEFAULT_BUDGET_MINUTES`)である。** NDF は実装の上限・想定最大時間の 固定値を新しく持たない。 +実行の時点では、cross-refactoring の駆動がステップの延長(`NDF_STEP_EXTEND_FILE`)で締め切りを後ろへずらしうる。 +プランに書く `timeout` は変えない。 この文書は、その形に**なぜしたか**(背景・決定と理由・常に成り立つ条件・境界・既知の限界・テスト観点)を残す。 **宣言の形と引数の正はスクリプトにある。** diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 6eec23341..1c6044eb7 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -190,7 +190,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | --- | --- | | `sprint-state.py init --name <名>- --pace auto --issue ... --dev V --prod V --goal <再開のコマンド> [--mvv mvv.md --milestone M]` | `--prod` を省けば開発版の接尾辞を外した版 | | `supervise.py new sprint --pace auto --issue <選んだ課題> --design <承認済みでない課題> [--designed ] ... [<-- の後の引数>]` | `loop answer --approved` で答えた課題は設計のプランを作らず、設計の結果のステージが設計 PR の設計の結果を読む | -| `supervise.py new close --pace auto --issue <選んだ課題> --state [--carry <前の周回の refine.deferred>] [--milestone M]` | まとめのステージ(確定仕様化 → Pull Request → 課題を閉じる → 振り返り → 棚卸し)だけを組む。最終の検査・開発版・本番は置かず、`fast` 節を読まない | +| `supervise.py new close --pace auto --issue <選んだ課題> --state [--carry <前の周回の refine.deferred>] [--milestone M]` | まとめのステージ(確定仕様化 → Pull Request → 振り返り → 課題を閉じる → 棚卸し。#2228)だけを組む。最終の検査・開発版・本番は置かず、`fast` 節を読まない | `pipeline` は、最初のステージの queue の最後に `--then <まとめのプラン>` をつなぎ、`supervise.py wait ` → `loop lap <名> --done ` を続けた 1 行である。手で行うステージか別の queue のステージが残るときは、それを終えてから diff --git a/docs/specifications/runtime-plugin-distribution.md b/docs/specifications/runtime-plugin-distribution.md index 4b6d6d0af..1719a1ef7 100644 --- a/docs/specifications/runtime-plugin-distribution.md +++ b/docs/specifications/runtime-plugin-distribution.md @@ -324,6 +324,5 @@ MCP plugin を変更する場合: - [NDF プラグイン README](../../plugins/ndf/README.md) - [playwright-kit プラグイン README](../../plugins/playwright-kit/README.md) - [Runtime Smoke の CLI の版](runtime-smoke-cli-versions.md) -- [`scripts/lib/agy_agents.py`](../../scripts/lib/agy_agents.py)(agy に配る定義の生成と model の対応表) - [Agent Plugins 仕様](https://github.com/agentplugins/agent-plugins-spec) - 課題 [#2087](https://github.com/devbasex/ai-plugins/issues/2087)(agy に配るエージェント定義) diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index c4445c363..99e2a88be 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -47,7 +47,7 @@ Codex は全ブランチを取得するが、登録した ref を基準にする **接尾辞は人が読むための目印である。** Claude Code の直接インストール経路は版数を **キャッシュキーとしての文字列一致**でしか見ず、`-dev` や `-rc` を prerelease として -扱わない。Codex と Kiro も同様で、Agent Plugins Specification には解釈の規定が無い。 +扱わない。Codex も同様で、Agent Plugins Specification には解釈の規定が無い。 semver の順序で除外されるのは、プラグイン間の依存解決(`dependencies`)の経路だけである。 それでも接尾辞を付けるのは、**入れたくない利用者が版数を見て判断できるようにする**ためである。 @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.3` | 利用者が常用してよい | +| 正式版 | `10.27.5` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.3` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.5` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` @@ -69,6 +69,10 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` ## ランタイムごとの取得と導入 +**配布先は Claude Code と Codex の 2 つである。** Kiro CLI と agy へは配らず(#2231。最後に配ったのは +`10.27.4`)、cross-review / cross-refactoring の相手役として CLI を起動するだけである。前の版で入れた手元の扱いは +[plugins/ndf/README.md の「agy・Kiro CLI への配布の終了」](../plugins/ndf/README.md#agykiro-cli-への配布の終了)にある。 + **取得元の登録と、プラグインの導入は別の操作である。** 登録は取得元の一覧を書き換えるだけで、 導入済みの実体には触れない。実体は版ごとのディレクトリ(`plugins/cache/<取得元>/<名前>/<版>/`) に置かれ、導入の記録がそのうち 1 つを指す。**登録を切り替えただけでは、その記録は元の版を @@ -84,17 +88,6 @@ codex plugin marketplace add devbasex/ai-plugins codex plugin add ndf@ai-plugins ``` -**agy には入れ替えの操作が無い。** `agy plugin --help` の副コマンドは -`list` / `import` / `install` / `uninstall` / `enable` / `disable` / `validate` / `link` / -`help` で、導入済みを新しい版へ入れ替えるものが無い。他の 3 ランタイムは持つ -(Claude Code は `claude plugin update`、Codex は取得元を更新してから `codex plugin add`、 -Kiro CLI はインストーラの再実行)。 - -**`install` は既存の導入を拒まず、上書きする。ただし消えたファイルは残る**(agy 1.1.26 / -2026-09-05 に実測)。同じ名前で導入し直すと `plugin.json` の版数も Skill の中身も入れ替わるが、 -**取得元から取り除いたファイルは実体に残り続ける**。配布物が減る版へ上げると、消したはずの -Skill が利用者の手元で生き続ける。そのため案内は外してから入れ直す 2 手のままにする。 - **ローカルのディレクトリを同じ名前でマーケットプレイスとして追加しない。** 登録の鍵は取得元では なく `marketplace.json` の `name` で、**1 つの名前につき 1 つしか登録できない**(公式ドキュメントに "Each user can register only one marketplace per name" とある)。 @@ -111,17 +104,9 @@ Skill が利用者の手元で生き続ける。そのため案内は外して 定まらず検証の手段にならない。手元での確認は次のいずれかで行う。どれも取得元を書き換えない。 ```bash -bash plugins/ndf/dev.kiro/install.sh --project <検証用ディレクトリ> --yes # Kiro claude --plugin-dir plugins/ndf # Claude Code -agy plugin validate plugins/ndf/dev.agy # agy(読み込みの確認) ``` -**agy は導入したプラグインの `hooks.json` を読み込まない。** 読む先は -`~/.gemini/config/hooks.json` の 1 か所だけで、プラグイン配下とプロジェクト直下のどちらに -置いても `loaded 1 named hooks from 1 hooks.json file(s)` のまま変わらない(agy 1.1.26 で -実測)。導入した hook を効かせるには `bash plugins/ndf/dev.agy/install-hooks.sh` を実行して -その 1 か所へ差し込む。冪等で、他の項目には触れない。 - ## 開発版を試す ```bash @@ -143,16 +128,6 @@ ref の書き方は `owner/repo@ref` と `git-url#ref` の 2 つで、 `claude plugin marketplace add --help` には出ないが実機で動く。Codex は `--ref` を持ち、 `owner/repo@ref` も受け取る。`claude plugin install` に版を指定する手段は無い(これは確定)。 -**Kiro と agy はマーケットプレイスの経路を持たない。** clone した作業ディレクトリから -導入するため、ref にあたるのは clone の checkout である。clone 直後は既定ブランチ(`main`) -なので、正式版を使うだけなら追加の操作は要らない。 - -```bash -git -C checkout develop # 開発版を試すときだけ -bash plugins/ndf/dev.kiro/install.sh --project <ディレクトリ> --yes -agy plugin uninstall ndf && agy plugin install /plugins/ndf/dev.agy -``` - **サードパーティのマーケットプレイスは自動更新が既定で無効である。** `main` へ出した版が 即座に全利用者へ届くわけではなく、利用者が `marketplace update` を実行した時点で届く。 @@ -237,7 +212,7 @@ claude plugin tag plugins/ndf --push # ndf--v<版> を作って origin へ 落ちる。**記載を消してもチェックは通らない。** 位置を決める語が見つからなければ、読み取れない こととして落ちる。 -**定義ファイルが 7 箇所である。** 3 つの `plugin.json` はいずれも +**定義ファイルが 5 箇所である。** 2 つの `plugin.json` はいずれも `version` と `description` の両方に版数を持つため、片方だけ直すとチェックで止まる。 | 箇所 | 何を書くか | @@ -246,8 +221,6 @@ claude plugin tag plugins/ndf --push # ndf--v<版> を作って origin へ | `plugins/ndf/.claude-plugin/plugin.json` の `description` | `(vX.Y.Z)` の形で版数 | | `plugins/ndf/.codex-plugin/plugin.json` の `version` | 版数そのもの | | `plugins/ndf/.codex-plugin/plugin.json` の `description` | `(vX.Y.Z)` の形で版数 | -| `plugins/ndf/dev.agy/plugin.json` の `version` | 版数そのもの | -| `plugins/ndf/dev.agy/plugin.json` の `description` | `(vX.Y.Z)` の形で版数 | | `.claude-plugin/marketplace.json` の該当プラグインの `description` | 同上 | **plugin の README は版ごとの更新の節(`## vX.Y.Z へ更新するとき`)を持たない。** README は現行版の説明と、 @@ -255,7 +228,7 @@ CHANGELOG.md を参照する固定の「`## 更新情報`」の節だけを持 置く。配布の手順(`release-steps.py` の `bump`・`changelog`・`notes`)は README に触れない。節が戻ると `scripts/check-doc-staleness.py` が落とす(`plugins/*/README.md`・`plugins/mcp/*/README.md`)。 -**説明文書の本文が 6 箇所である。** いずれも利用者が読む入口にあり、古いまま残ると現行版を +**説明文書の本文が 5 箇所である。** いずれも利用者が読む入口にあり、古いまま残ると現行版を 誤って伝えるか、書かれたとおりに実行できない。 **指示書(`AGENTS.md` / `CLAUDE.md`)には版数を書かない。** 開発版の配布の `bump` が版数を @@ -267,7 +240,6 @@ CHANGELOG.md を参照する固定の「`## 更新情報`」の節だけを持 | `README.md` の概要 | `**NDFプラグイン vX.Y.Z**` | `ndf` の `plugin.json` | | `README.md` のプラグイン一覧表 | 版数の列 | 行ごとに `plugins/<名前>/.claude-plugin/plugin.json` | | `docs/versioning-and-distribution.md` の「版の付け方と開発版の配布」章 | 版数の例 | `ndf` の `plugin.json`(基底の比較) | -| `plugins/ndf/README.md` の Kiro の確認例 | 期待出力の版数 | `ndf` の `plugin.json` | | `plugins/ndf/README.md` の Codex のパス例 | `~/.codex/plugins/cache/.../ndf/X.Y.Z/`(2 箇所) | `ndf` の `plugin.json` | | `plugins/ndf/README.md` の `codex plugin list` の出力例 | 期待出力の版数 | `ndf` の `plugin.json` | @@ -305,9 +277,9 @@ CHANGELOG.md を参照する固定の「`## 更新情報`」の節だけを持 両方に版を持たせると `plugin.json` が無警告で優先され、食い違いに気づけなくなる。 `scripts/validate-runtime-plugins.sh` が突き合わせるのは、説明文書に書かれた Skill の数と、 -**版数を書いた箇所**です。定義ファイルが 7 箇所、説明文書の本文が -6 箇所あります(`README.md` の概要とプラグイン一覧表、正本に 1 箇所、 -`plugins/ndf/README.md` の Kiro と Codex の確認例 3 種類)。**記載を消してもチェックは通りません。** +**版数を書いた箇所**です。定義ファイルが 5 箇所、説明文書の本文が +5 箇所あります(`README.md` の概要とプラグイン一覧表、正本に 1 箇所、 +`plugins/ndf/README.md` の Codex の確認例 2 種類)。**記載を消してもチェックは通りません。** 一覧は上の 2 つの表にあります。 ## チェックに載らず手で直す箇所 @@ -340,12 +312,12 @@ CHANGELOG.md を参照する固定の「`## 更新情報`」の節だけを持 そのまま残す。現行版を指しているかは文脈で決まる。**チェックが突き合わせるのは、周囲の固定の語で 位置を決めた記載だけである。** 履歴と記録は最初から走査に入らない。 -**説明文書の本文に書かれた版数は取りこぼしやすい。** 上の 7 箇所を +**説明文書の本文に書かれた版数は取りこぼしやすい。** 上の本文の 5 箇所を チェックの対象へ入れ、この取りこぼしを機械が拾う。 ## 取得元の登録を確かめる -**取得元の登録そのものを確かめるときは、設定ディレクトリを隔離する。** 「ランタイムごとの取得と導入」の手元での確認の 3 つは配布物を +**取得元の登録そのものを確かめるときは、設定ディレクトリを隔離する。** 「ランタイムごとの取得と導入」の手元での確認(`claude --plugin-dir`)は配布物を 直接読み込む確認で、取得元の登録から導入までの経路は通らない。開発版チャネルの取得手順は その経路そのものであるため、別の設定ディレクトリを与えて確かめる。**利用者の登録には触れない** (claude 2.1.261 / codex-cli 0.153.2 で実測)。 diff --git a/issues/issue-2047-design.md b/issues/issue-2047-design.md new file mode 100644 index 000000000..55dfc4217 --- /dev/null +++ b/issues/issue-2047-design.md @@ -0,0 +1,386 @@ +# 検査のプラン: 想定最大時間を省いてもリファクタリングが下限の時間で通り、収まらないときはコードレビューへ進む(#2047) + +## 目的 + +- **何が壊れているか**: 検査のプランのリファクタリング(`refactor`)が、既定の想定最大時間 30 分と、想定最大時間の 10% で決まる着手前のテストの初回の上限(180 秒)のせいで、`refactor.py init` の終了コード 4 で落ちて abort へ進む。#2047 の本文には 2026-10-17 から 2026-10-31 までに 7 本の PR(#2043・#2053・#2103・#2152・#2250・#2268・#2283)の事例が積まれ、多くは conductor がプランを書き直して流し直した +- **誰が困るか**: 無人のキューで検査を流す開発者と conductor。検査の記録が「落ちた」になり、リファクタリングもコードレビューも行われない +- **直すと何が成り立つか**: 着手前のテストの初回の上限が既知の所要から決まる。想定最大時間を省いたプランは下限まで上げてリファクタリングを通し、明示した想定最大時間に収まらないときはリファクタリングを飛ばしてコードレビューへ進む。検査の記録とフェーズレポートには飛ばした理由が残る + +## 適用範囲 + +- **働く範囲**: NDF の配布物(`cross-refactoring` の init と駆動、`supervise.py` の検査の雛形・駆動のステップ・検査の記録)。配布先のリポジトリの検査のプランでも同じに働く +- **プロジェクトごとに違うもの**: 想定最大時間は今どおり `--refactor-budget-minutes` か `.ndf/supervise.json` の `refactor_budget_minutes` で受ける。テストの所要は `.ndf/project.json` の `test_duration` と手元の全体テストの実測(`whole_runs`)から読む。新しい設定は足さない +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| PR #2250 の検査で、着手前のテストの上限が 180 → 540 → 1,620 秒と打ち切りのたびに 3 倍になり、2 回の打ち切りで約 13 分を失った。`.ndf/project.json` の `test_duration` は 1,518 秒を持っていた(#2047 の本文「発生の記録(2026-10-31、開発ループ l20261031 の周回 2)」) | 実測 | 初回の上限が既知の所要を読まないことが打ち切りの原因である | +| `refactor_lib/commands/setup.py` の `_verify_init` は、CI に任せる戦略で範囲テストの履歴が無いと `init_test_timeout = 0.10·B` にする(`init_test.resolve_scope_seconds` が `None` を返す) | 実測(コードを読んだ) | 履歴の無い初回は、B = 30 分で 180 秒に決まる | +| 着手前のテスト 335 秒で「`--budget-minutes` を 43 以上に」で止まった(#2047 の本文「再発(2026-10-25)」)。`timeline.required_budget_minutes(335, 19.5)` は 43、`(495, 19.5)` は 47、打ち直しの見込み 300 秒を引いた `(195, 19.5)` は 40 を返す(2026-10-31 に打って確かめた) | 実測 | 既定の 30 分は、全体テストが数分かかるリポジトリで下限に届かない。人が打ち直さないなら 300 秒の見込みは要らない | +| `supervise_lib/steps.py` の `fill_test_limit` は、テストのステップの上限をプランを書いた時点でなく流し始めた時点の実測から広げる(#1631 の決定 3) | 実測(コードを読んだ) | ステップの timeout を書いた後の実測で広げる前例がある(決定 4) | +| `supervise_lib/templates.py` の `review` のステップは、収束しなかった終わり(終了コード 6 と `gaps`)を `on_exit` で後続へ進める(#2227 の決定 1) | 実測(コードを読んだ) | 欠けの残る結果を abort へ回さずに進める経路が既にある(決定 3) | + +要求と受け入れ条件は #2047 の本文にある(コピーは `issues/issue-2047-requirements.md`)。この文書は「どう作るか」だけを扱う。受け入れ条件の番号はコピーの「受け入れ条件」の 1〜8 を指す。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| cross-refactoring(`ndf-cross-refactoring`) | 想定最大時間・想定最大時間の下限・着手前のテスト・手順の枠・想定最大時間の出所・時間の枠の不足 | +| 開発の工程(`ndf-workflow`) | プラン・ステップ・欠け・欠けの残る結果・検査の記録・フェーズレポート・ステップの延長 | + +関係は公開された言語である。cross-refactoring の駆動(`drive.py`)は、共通の結果 JSON の形(`lib/step_result.py`。`status`・`gaps`・終了コード 6)とステップの延長のファイルの形(決定 4)で開発の工程へ伝える。開発の工程は cross-refactoring の状態ファイルを読まない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| リファクタリングの実行 | `refactor.py init`(`refactor_lib/commands/setup.py`・`refactor_lib/init_test.py`) | 状態ファイル(`state.json`) | 着手前のテスト(`baseline_test`) | 想定最大時間と出所(`budget_minutes`・`budget_source`・`budget_floor`)・時間の上限の表(`limits`) | +| 駆動の結果 | `cross-refactoring/scripts/drive.py` | 結果 JSON | 欠け(`gaps` の 1 件) | 終了コード・件数(`metrics`) | +| プランの実行 | `supervise.py run`(`supervise_lib/engine.py`・`worker_steps.py`・`steps.py`・`claude.py`) | 実行の状態(`state.json` の `log`) | ステップの試行 | ステップの締め切り・延長の記録(`cur.extended_timeout`)・欠け(`cur.gaps`) | +| 検査の記録 | `check-trigger.py record` | 検査の行(`kind: check`) | — | 件数(`findings`) | + +プランの雛形(`supervise_lib/templates.py` の `_inspect_steps`)はプランの実行の集約へ入る前の書き出しで、`refactor` のステップの `on_exit` を持つ。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | リファクタリングの実行 | 着手前のテストの上限は、想定最大時間 B があれば `max(0.10·B, 3·s)`。s は「同じ範囲の配分の履歴(CI に任せる戦略だけ)→ 手元の全体テストの直近の実測 m → 宣言の w → 無し」の順に採り、無しなら `0.10·B` | 単体テストで落とす | +| I2 | リファクタリングの実行 | `budget_source` が `explicit` の実行では、`init` は `budget_minutes` を渡された値から変えない | `init` のテストで落とす | +| I3 | リファクタリングの実行 | `budget_source` が `default` か `floor` の実行で手順の枠が収まらないとき、`init` は `budget_minutes` を `required_budget_minutes(o, after, grace=0)` へ上げ、`budget_source` を `floor` にして `limits` を組み直す。組み直した `limits` で `window_problem` は `None` になる | `init` のテストで落とす | +| I4 | リファクタリングの実行 | `init` が `SKIP=<種類>` を出すのは、着手前のテストの打ち切り(`init-timeout`)と、`budget_source` が `explicit` で手順の枠が収まらないとき(`window`)の 2 つだけで、どちらも終了コード 4 のまま終わる。ほかの中断は `SKIP` を出さない | `init` のテストで落とす | +| I5 | 駆動の結果 | 駆動は `init` が `SKIP` を出した中断だけを欠けの残る結果(`stopped`・`gaps` 1 件・終了コード 6)で返す。ほかの中断は今どおり終了コード 1 で返す | 駆動のテストで落とす | +| I6 | プランの実行 | 雛形の書く `refactor` のステップは `on_exit` に `"6": "review"` だけを持つ。終了コード 1 は今どおり `on_fail` の `abort` へ行く | 雛形のテストで落とす | +| I7 | プランの実行 | ステップの延長は締め切りを後ろへずらすだけで、前へずらさない。延長した締め切りは「試行の開始 + `with_margin`(延長のファイルの秒)」で、今の締め切り(プランの `timeout` から決まるものか、それまでに延ばしたもの)より前なら使わない | 単体テストで落とす | +| I8 | 検査の記録 | リファクタリングを飛ばした検査の行は `findings.refactor_skipped` に種類と理由を持ち、飛ばしたことだけを理由に `result` を `failed` にしない。飛ばして進んだ後の工程(`review` など)が実際に失敗して abort した検査は、今どおり `failed` を記録する | `check-trigger.py` のテストで落とす | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 検査のプランを書いた(`refactor` のステップの `on_exit` と timeout が決まった) | `supervise.py new check` / `new sprint` / `new close`(`_inspect_steps`) | プランの実行 | +| E2 | リファクタリングのステップを始めた(延長のファイルの置き場を子へ渡した) | `supervise_lib/steps.py` の `RunStep.run_cmd` | 駆動(`drive.py`) | +| E3 | 着手前のテストの上限を決めた | `refactor.py init`(`_verify_init`) | 着手前のテスト(`baseline.run_baseline`)・延長のファイル(上限 T が `0.10·B` を超えるとき、テストを始める前に `init` が書く) | +| E4 | 着手前のテストが終わった / 打ち切られた | `baseline.run_baseline` | `init`(打ち切りなら E7 の飛ばした経路) | +| E5 | 想定最大時間が決まった(明示のまま / 下限まで上げた) | `init_test.settle_window` | 状態ファイル・標準出力・駆動(`BUDGET_SOURCE=floor` なら延長のファイルを書く) | +| E6 | 手順の枠が想定最大時間に収まった / 収まらなかった | `init_test.settle_window` | `init`(明示で収まらなければ E7 の飛ばした経路) | +| E7 | リファクタリングを終えた / 時間の枠に収まらず飛ばした | 駆動(`drive.py`) | プランの実行(終了コード 0 か 6 で `review` へ、1 で `abort` へ) | +| E8 | 検査がコードレビューへ進み、検査の記録にリファクタリングの結果を残した | プランの実行・`check-trigger.py record` | 検査の記録・フェーズレポート | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 想定最大時間の出所 | その実行の想定最大時間をどこから決めたか。`explicit`(引数か宣言で渡した)・`default`(省いて既定の 30 分を使った)・`floor`(省いて想定最大時間の下限まで上げた)の 3 つ。状態ファイルの `budget_source` | 追加(`ndf-cross-refactoring`) | +| 時間の枠の不足 | 着手前のテストが上限で打ち切られたか、明示した想定最大時間が想定最大時間の下限に届かないこと。`init` は `SKIP=init-timeout` / `SKIP=window` を出して終了コード 4 で止まり、駆動はリファクタリングを飛ばした欠けの残る結果を返す | 追加(`ndf-cross-refactoring`) | +| ステップの延長 | drive のステップの子孫(駆動と、駆動が起動した `init`)が、自分の見込みの秒を延長のファイル(環境変数 `NDF_STEP_EXTEND_FILE` の指すパス)へ書き、エンジンがその試行の締め切りを `with_margin` を足して後ろへずらすこと。前へはずらさない | 追加(`ndf-workflow`) | +| 着手前のテストの初回の上限 | 同じ範囲の打ち切りと実測が配分の履歴に無いときの、着手前のテストの上限(秒)。`max(0.10·B, 3·s)` で、s は手元の全体テストの直近の実測 m → 宣言の w の順に採る | 意味の変更(`ndf-cross-refactoring`。決め方を足した) | +| 範囲テストの所要 | CI に任せる戦略で、着手前に走らせる範囲テストの所要の見込み(秒)。同じ範囲の履歴の実測 → 手元の全体テストの直近の実測 m → 宣言の w の順に採る(w は範囲が全体テストを覆うかを問わない) | 意味の変更(`ndf-cross-refactoring`。出所に m を足し、w の条件を外した) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 着手前のテストの上限を、履歴が無いときは既知の所要(m → w)から決める | `refactor.py init` | +| F2 | 想定最大時間を省いた実行で、手順の枠が収まらなければ下限まで上げて続け、出所を状態と標準出力に残す | `refactor.py init` | +| F3 | 時間の枠の不足を `SKIP` で知らせ、駆動が欠けの残る結果に変える | `refactor.py init`・`drive.py` | +| F4 | 検査の雛形の `refactor` のステップが、欠けの残る結果(6)をコードレビューへ進める | `supervise.py new check` / `new sprint` / `new close` | +| F5 | 駆動の子が書いた見込みの秒で、drive のステップの締め切りを延ばす | `supervise.py run` | +| F6 | 検査の記録とフェーズレポートに、飛ばしたことと理由を残す | `check-trigger.py record`・`supervise.py run` の報告 | + +## 構成要素 + +| 要素 | 責務 | 変える中身 | +| --- | --- | --- | +| `plugins/ndf/scripts/lib/test_strategy.py` の `limits` | 予算があるときの `init_test_timeout` を出す | 戦略を問わず、`scope_seconds` を渡されたら `max(0.10·B, 3·s)` にする(今は CI に任せる戦略だけ)。`whole_timeout`・`test_timeout`・`ci_wait_timeout` と予算なしの表は変えない | +| `refactor_lib/init_test.py` の `resolve_scope_seconds` | 着手前のテストの所要の見込み s と出所を決める | I1 の順に変える。出所に `recent`(m)を足し、`whole`(w)は置き場所が全体テストを覆うかを問わずに使う。CI に任せる戦略でなければ履歴を読まず m → w を採る | +| `refactor_lib/init_test.py` の `settle_window`(新設。`stop_if_window_short` を置き換える) | 手順の枠を確かめ、出所に応じて上げるか止める | I3 と I4。上げたら `budget_floor` を書いて保存し、`ℹ` の 1 行を出す。明示で収まらなければ `SKIP=window` を出してから今の文で止まる | +| `refactor_lib/init_test.py` の `abort_timed_out` | 着手前のテストの打ち切りで止める | 止まる前に `SKIP=init-timeout` と `SKIP_DETAIL` を出す | +| `refactor_lib/timeline.py` の `required_budget_minutes` | 想定最大時間の下限を出す | 引数 `grace_seconds`(既定 `RESUME_GRACE_SECONDS`)を足す。`settle_window` の上げる経路だけ 0 を渡す | +| `refactor_lib/commands/setup.py` の `cmd_init`・`_resume`・`_build_initial_state`・`_emit_init` | `init` の順序と状態の組み立て | 既定を埋める前に出所を決めて状態へ書く。`_verify_init` が m を読み、上限 T が `0.10·B` を超えたら、着手前のテストを始める前に延長のファイル(環境変数 `NDF_STEP_EXTEND_FILE` があるときだけ)へ `B·60 + (T − 0.10·B·60)` を書く。2 か所の `stop_if_window_short` を `settle_window` に替える。`_emit_init` に `BUDGET_SOURCE` を足す | +| `refactor_lib/plan.py` の上限の節(計画のコメントの「入力:」の行)と `_verify_init` の `info` の行 | s と出所を読み手へ示す | 「範囲テストの所要 s」の呼び名を「着手前のテストの所要の見込み s」に替え、出所の `recent` を表示できるようにする(ほかの戦略でも s が入るため) | +| `refactor_lib/allocation.py` の `build_row` | 実行の行を配分の履歴へ書く | `budget_source` の列を足す | +| `refactor_lib/commands/report.py` | 実行の報告 | 想定最大時間の行に出所を添える(`floor` なら「下限から決めた」と既定の分) | +| `cross-refactoring/scripts/drive.py` | 1 回の実行を進めて結果 JSON を返す | `rf` が `SKIP` を見て `Skip` を投げ、`refactor_drive` がそれを欠けの残る結果に変える。`start` の後、`BUDGET_SOURCE=floor` なら延長のファイルを書く | +| `plugins/ndf/scripts/lib/drive_pause.py` | 駆動の結果の形と終了コード | 欠けを 1 件持つ中断の結果を組む `incomplete(tool, summary, metrics, gap)` を足す(終了コード 6) | +| `supervise_lib/templates.py` の `_inspect_steps` | 検査の assess・refactor・review を書く | `refactor` に `"on_exit": {"6": "review"}` を足す。`timeout` と `cmd` は変えない | +| `supervise_lib/steps.py` の `RunStep.run_cmd` と `supervise_lib/claude.py` の `run_ticking` | ステップのコマンドを締め切りまで待つ | drive のステップだけ、試行ごとに延長のファイルのパスを環境変数で渡し(前の試行のファイルは消す)、待ちの周期ごとに読んで締め切りを延ばす(I7)。延ばしたら `cur.extended_timeout` と進捗ログの 1 行に残す | +| `supervise_lib/worker_steps.py` の `DriveStep.execute` | drive のステップの結果を控える | 欠けの残る結果の `gaps` を `cur.gaps` に控える | +| `supervise_lib/state.py` の `write_report` | フェーズレポート | `- 欠けを残して進んだステップ: (<種類>: <理由>)` の行を足す(`cur.gaps` を持つ試行。無ければ「無し」) | +| `plugins/ndf/scripts/check-trigger.py` の `findings_of` | 検査の行の件数 | `refactor` の件数の `skipped`(種類と理由)を `findings.refactor_skipped` へ写す | +| `cross-refactoring/SKILL.md`・`cross-refactoring/docs/01-state-and-propose.md` | 引数の既定と駆動の終了コードの表を利用者へ示す | `--budget-minutes` の既定の欄を「省けば 30。手順の枠が収まらなければ下限まで上げる」にし、駆動の終了コードの表に 6(時間の枠の不足で飛ばした)の行を足す。`init` の順序に `settle_window` を書く | + +```mermaid +graph LR + subgraph supervise["supervise(開発の工程)"] + tpl["templates._inspect_steps"] + run["steps.RunStep.run_cmd
claude.run_ticking"] + drv["worker_steps.DriveStep"] + rep["state.write_report"] + end + subgraph cr["cross-refactoring"] + drive["drive.py"] + init["refactor.py init
setup / init_test"] + tl["timeline.required_budget_minutes"] + end + subgraph lib["共通の層"] + ts["test_strategy.limits"] + wr["whole_runs.recent_whole"] + dp["drive_pause.incomplete"] + end + rec["check-trigger.py record"] + tpl -->|"on_exit 6 → review"| drv + drv --> run + run -->|"NDF_STEP_EXTEND_FILE"| drive + drive --> init + init --> ts + init --> wr + init --> tl + drive --> dp + drive -->|"延長のファイル"| run + drv --> rep + drv --> rec +``` + +## 構造 + +型は増やさない。足すのは状態ファイルの欄・駆動の例外 1 つ・関数 2 つ(`settle_window`・`incomplete`)である。 + +```mermaid +classDiagram + class Stop { + +code: int + } + class Skip { + +kind: str + +detail: str + } + Stop <|-- Skip + class Drive { + +rf(args, ok) tuple + +start() None + } + Drive ..> Skip : SKIP を見て投げる +``` + +`Skip` は `Stop` を継ぐ。`refactor_drive` の `except Stop` より前で `Skip` を捕まえ、`drive_pause.incomplete` で返す。捕まえ損ねても `Stop` として今の中断(終了コード 1)に落ちるため、abort へ進む今の振る舞いより悪くならない。 + +## データ構造 + +### 状態ファイルに足す欄(`refactor_lib` の `state.json`) + +既存の欄は消さず、意味も変えない。`budget_minutes` は上げた後の値を持つ。 + +| 置き場 | 欄 | 型 | 中身 | 書く者 | 読む者 | +| --- | --- | --- | --- | --- | --- | +| 根 | `budget_source` | str | `explicit` / `default` / `floor` | `cmd_init`(新規)・`_resume`(引数を渡したら `explicit`)・`settle_window`(上げたら `floor`) | `settle_window`・`_emit_init`・`build_row`・`report` | +| 根 | `budget_floor` | dict / 無し | 上げたときだけ: `from_minutes`(上げる前)・`minutes`(上げた後)・`offset_seconds`(o)・`after_plan_minutes`・`at` | `settle_window` | `report` | +| `baseline_test` | `scope_source` | str / null | 出所に `recent` を足す(`history` / `recent` / `whole` / null) | `_verify_init` | `timeline.test_limits`・計画のコメントの「入力:」の行 | + +`budget_source` を持たない状態ファイル(この変更より前に作った実行)は `explicit` として読む。今の振る舞い(止まる)に倒すためである。 + +### 配分の履歴の実行の行(`allocation.build_row`) + +| 列 | 型 | 中身 | +| --- | --- | --- | +| `budget_source` | str | 状態の `budget_source`。列を持たない旧い行は読む側で `null` として扱い、集計(`build_table`)には使わない | + +`init_test` の行は変えない。上げる前の B で走らせたため、`budget_minutes` は上げる前の値のまま残る。 + +### 延長のファイル(`NDF_STEP_EXTEND_FILE` の指すパス) + +| 項目 | 型 | 中身 | +| --- | --- | --- | +| `seconds` | int | 子孫が見込む、この試行の開始からの所要の上限(秒)。`init` は着手前のテストの前に `B·60 + (T − 0.10·B·60)`(T は E3 で決めた上限)を、駆動は想定最大時間を上げた後に `budget_minutes × 60` を書く。後から書いた値が今の締め切りより小さければ締め切りは変わらない(I7) | +| `reason` | str | 1 行の理由(例: `想定最大時間を下限の 40 分へ上げた`) | + +置き場はプランの状態ディレクトリの `<試行の番号>-<ステップ>.extend.json`。書くのは `init`(E3)と駆動(E5)、読むのはエンジンだけで、試行の開始で消す。 + +### 検査の行(`check-trigger.py` の `findings`) + +| 鍵 | 型 | 中身 | +| --- | --- | --- | +| `refactor_skipped` | dict / 無し | 飛ばしたときだけ: `kind`(`window` / `init-timeout`)・`reason`(`SKIP_DETAIL` の 1 行) | + +## 入出力の契約 + +### `refactor.py init` の出力 + +| 鍵 | 出す時 | 値 | +| --- | --- | --- | +| `BUDGET_SOURCE` | 新規・再開の `_emit_init` | `explicit` / `default` / `floor` | +| `BUDGET_MINUTES` | 同上(今もある) | 上げた後の値 | +| `SKIP` | 時間の枠の不足で止まる直前 | `init-timeout` / `window` | +| `SKIP_DETAIL` | 同上 | 1 行の理由。`window` は「要る下限 N 分 > 想定最大時間 B 分(着手前のテスト x 秒)」で、N は打ち直しの見込みを足さない下限(決定 2 と同じ `grace_seconds=0`。人へ出す `❌` の文は今どおり 300 秒を足した値)、`init-timeout` は「着手前のテストが上限 T 秒で終わらなかった」 | + +終了コードは変えない(0 / 4)。`SKIP` を読まない呼び手(手順書どおりの単独起動・この変更より前の駆動)は今どおり中断として扱う。 + +上げたときの標準エラーの 1 行(`statefile.info`): + +```text +ℹ 想定最大時間を省いたため、下限の 40 分へ上げました(既定 30 分では提案とリファクタリング計画の枠と計画の後の見込み 19.5 分が収まらない。着手前のテスト 495 秒) +``` + +### `drive.py` の結果 JSON(時間の枠の不足) + +```json +{"tool": "cross-refactoring-drive", "status": "stopped", + "summary": "時間の枠に収まらずリファクタリングを飛ばした: 要る下限 36 分 > 想定最大時間 30 分(着手前のテスト 335 秒)", + "items": [], "metrics": {"skipped": {"kind": "window", "reason": "要る下限 36 分 > 想定最大時間 30 分(着手前のテスト 335 秒)"}, "exit": 4}, + "gaps": [{"kind": "refactor", "name": "window", "reason": "時間の枠に収まらず飛ばした: 要る下限 36 分 > 想定最大時間 30 分(着手前のテスト 335 秒)"}]} +``` + +終了コード 6。`metrics` は状態ファイルがあれば今の件数(`counts()`)に `skipped` を足し、無ければ(打ち切りは状態を保存する前)`skipped` と `exit` だけを持つ。summary と `reason` に「落ちた」「失敗」の語を入れない(進捗ログの `PROGRESS_FAIL` に当てない)。 + +### プランの `refactor` のステップ + +```json +{"id": "refactor", "type": "drive", "drive": "cross-refactoring", "timeout": 1980, + "cmd": "...", "on_exit": {"6": "review"}, "on_fail": "abort", "next": "review"} +``` + +`timeout` は今どおり(想定最大時間を省けば `with_margin(30 × 60)` = 1,980 秒)。延長はステップの延長が実行の時点で行う。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant E as supervise(RunStep) + participant D as drive.py + participant I as refactor.py init + E->>D: 起動(NDF_STEP_EXTEND_FILE, timeout 1980) + D->>I: init(--budget-minutes 無し) + I->>I: 出所 = default、s = 履歴 → m → w + I->>I: 着手前のテストの上限 T = max(0.10·B, 3·s) + opt T > 0.10·B + I->>E: 延長のファイル {seconds: B·60 + (T − 0.10·B·60)} + end + I->>I: 着手前のテスト(上限 T) + alt 打ち切り + I-->>D: SKIP=init-timeout, exit 4 + D-->>E: stopped + gaps, exit 6 + E->>E: on_exit 6 → review + else 終わった + I->>I: settle_window + alt 収まらず出所が explicit + I-->>D: SKIP=window, exit 4 + D-->>E: stopped + gaps, exit 6 + E->>E: on_exit 6 → review + else 収まる、または出所が default / floor + opt 収まらず出所が default / floor + I->>I: B = 下限(grace 0)、出所 = floor、limits を組み直す + I-->>D: BUDGET_SOURCE=floor, BUDGET_MINUTES=40 + D->>E: 延長のファイル {seconds: 2400} + E->>E: 締め切り = 開始 + with_margin(2400) = 2640 秒 + end + I-->>D: exit 0 + D->>D: 提案 → 計画 → 実装 → 検証 → 最終ゲート + D-->>E: ok, exit 0 → review + end + end +``` + +図に含めない要素: 書き出しの時点に働く `templates._inspect_steps`(`on_exit` を書く)、`init` の中の `test_strategy.limits`・`whole_runs.recent_whole`・`timeline.required_budget_minutes`、終わった後に働く `allocation.build_row`・`report`・`plan.py` の上限の節・`worker_steps.DriveStep`(`cur.gaps`)・`state.write_report`・`check-trigger.py record`、利用者向けの文書(`SKILL.md`・`docs/01-state-and-propose.md`)、結果を組む `drive_pause.incomplete`。 + +時間の枠と関係の無い中断(起動の失敗・認証・最終ゲートの失敗・テンプレートの試走の不備)は `SKIP` を出さず、駆動は終了コード 1 を返し、`on_fail` の `abort` へ進む(受け入れ条件 7)。 + +再開(`_resume`)では、計画の前なら `settle_window` を同じに通す。引数で想定最大時間を渡せば出所は `explicit` になり、渡さなければ状態の出所のまま判定する。 + +## 非機能の実現方式 + +| 大項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 性能・拡張性 | 初回の上限の延びは既知の所要の 3 倍まで | I1 の式。m・w が無ければ今と同じ `0.10·B` | +| 運用・保守性 | 上げたこと・飛ばしたことを記録だけで追える | 状態の `budget_source`・`budget_floor`、`init` の `ℹ` の 1 行、配分の履歴の `budget_source`、ステップの `cur.extended_timeout` と進捗ログ、フェーズレポートの「欠けを残して進んだステップ」、検査の行の `findings.refactor_skipped` | +| 移行性 | 書き出し済みのプランは書き直さない | プランの `on_exit` が無い旧いプランでは、欠けの残る結果(6)は今どおり `on_fail` の `abort` へ行く。`budget_source` の無い状態ファイルは `explicit` として読む | + +## 決定の記録 + +### 決定 1: 初回の上限を既知の所要から決めるため、着手前のテストの所要の見込みを「履歴 → m → w」で全戦略に引く + +着手前のテストの上限は `max(0.10·B, 3·s)` の 1 本にし、s の出所を `resolve_scope_seconds` の 1 か所で決める。CI に任せる戦略は範囲テストの履歴を先に採り、無ければ手元の全体テストの直近の実測 m、無ければ宣言の w を採る。範囲テストは全体テストの部分なので、全体テストの所要は上限の見込みとして上側に倒れる。ほかの戦略は今も `max(0.10·B, 3·w)` で、w の前に m を採るだけになる。 + +w を置き場所が全体テストを覆うときだけ使う今の条件は外す。覆わないときに s を無しにすると、#2250 と同じく 180 秒から 3 倍ずつ上がる。上限を `max(0.10·B, 3·m, 3·w)` にする形は採らない。w が CI の step の合計(1,518 秒)のとき、手元の 620 秒の実測があっても上限が 77 分になり、テストが固まったときの待ちが 3 倍の上限を大きく超える。 + +根拠: Value 3 / Value 6(MVV 版 3) + +### 決定 2: 想定最大時間を省いた実行を止めないため、init が下限まで上げて続け、出所を状態に残す + +想定最大時間を省いたとき(出所が `default`)、`init` は手順の枠が収まらなければ `budget_minutes` を下限へ上げ、出所を `floor` にして続ける。上げた下限には人が打ち直すまでの 300 秒を足さない。打ち直さずにその場で続けるため、o は着手前のテストの実測のまま伸びない(例: x = 495 秒・計画の後の見込み 19.5 分なら 40 分。今の案内は 47 分)。 + +プランを書く時点で supervise が想定最大時間を決める形は採らない。下限は着手前のテストの実測 x と計画の後の見込み(cross-refactoring の配分の履歴)から決まり、プランを書く時点ではどちらも分からない。式を supervise へ写すと、同じ役割の式が 2 か所に分かれる。 + +上げた後に組み直す `limits` の `init_test_timeout` は、上げた B から出した値になり、走らせたときの上限と食い違いうる(確定仕様の I9 は上げなかった実行だけに掛かる)。着手前のテストは 1 回の実行で 1 度しか走らないため、組み直した値は使われない。 + +想定最大時間を省いたときに上げる幅に上限を設けない(前提 2)。費用の上限を持ちたい利用者は `refactor_budget_minutes` を書けば、決定 3 の経路になる。 + +根拠: Value 2 / Value 6(MVV 版 3) + +### 決定 3: 明示した想定最大時間を利用者の上限として守るため、収まらないときはリファクタリングを飛ばした欠けの残る結果として返し、コードレビューへ進める + +出所が `explicit` で手順の枠が収まらないときと、着手前のテストが打ち切られたときは、`init` が `SKIP` を出して今どおり終了コード 4 で止まる。駆動はこの中断だけを欠けの残る結果(`stopped`・`gaps`・終了コード 6)に変え、検査の雛形は `on_exit` の `"6": "review"` でコードレビューへ進める。想定最大時間は黙って上げない。 + +時間の枠の不足を `refactor.py` の新しい終了コードで分ける形は採らない。`refactor.py` の終了コードは手順書と単独起動の呼び手が分岐に使っており、増やすと呼び手の分岐をすべて見直すことになる。`ABORT_DETAIL` と同じ KEY=VALUE の出力なら、読まない呼び手は今どおり中断として扱う。駆動の終了コード 6 は共通の結果 JSON の契約(`lib/step_result.py`)で「欠けの残る結果」の意味を既に持ち、エンジンも `on_exit` で読める。 + +根拠: Value 10 / Value 2 / Value 6(MVV 版 3) + +### 決定 4: ステップの timeout で切られないため、想定最大時間を上げた駆動がステップの締め切りを実行の時点で延ばす + +想定最大時間を省いたプランの `refactor` のステップは、`timeout` を今どおり既定の 30 分から書く。駆動は `init` が想定最大時間を上げたら、延長のファイルに `budget_minutes × 60` を書く。着手前のテストの上限 T が `0.10·B` を超えるとき(決定 1 の `3·s`)は、`init` がテストを始める前に `B·60 + (T − 0.10·B·60)` を書く。たとえば既知の所要が 1,518 秒なら T は 4,554 秒で、既定の 30 分なら 6,174 秒を書く。これが無いと、親が 1,980 秒で先に切り、上限内で終わるテストも、打ち切りの `SKIP` から `review` への遷移も届かない。エンジンは drive のステップの試行の待ちの周期ごとにそのファイルを読み、締め切りを「試行の開始 + `with_margin`(秒)」まで後ろへずらす。前へはずらさない。 + +プランを書く時点で timeout を大きく見込む形は採らない。上げた想定最大時間は x と計画の後の見込みで決まり、書く時点では上限を出せない。大きな固定値を置くと、テストが固まったときに待つ時間が根拠なく延びる。延ばす値を子が決め、余裕の式(`with_margin`)を supervise の 1 か所に残す。テストのステップの上限を流し始めた時点で広げる `fill_test_limit` と同じ考えである。 + +根拠: Value 4 / Value 6(MVV 版 3) + +### 決定 5: 飛ばしたことを記録だけで追えるようにするため、検査の行とフェーズレポートに欠けの理由を写す + +検査の行は `findings.refactor_skipped` に種類と理由を持ち、検査の終わり方(`merged` / `no_change`)は今どおり PR の状態から決める。フェーズレポートは `cur.gaps` を持つ試行を「欠けを残して進んだステップ」の行に並べる。この行は `review` の収束しなかった終わり(終了コード 6)にも同じに出る。 + +`refactor` だけを見る専用の行は作らない。欠けの残る結果は共通の契約で、エンジンは欠けの種類を知らなくても理由を写せる。 + +根拠: Value 7 / Value 6(MVV 版 3) + +### 決定 6: 受け入れ条件 6 の「上限」は、着手前のテストの上限を除く時間の上限の表として読む + +受け入れ条件 1・2 は、想定最大時間を明示したかを問わずに着手前のテストの初回の上限を変える。受け入れ条件 6 が変えないとする「上限」は、想定最大時間から決まる残りの値(`test_timeout`・`whole_timeout`・`ci_wait_timeout`・手順の枠・締め切り)として読む。m がある実行では、明示した想定最大時間でも着手前のテストの上限が `3·m` になる。 + +根拠: 根拠なし(MVV 版 3) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I1 | CI に任せる戦略で履歴が無く m = 620 のとき、`_verify_init` が着手前のテストへ渡す上限は 1,860 秒以上。ほかの戦略でも m があれば `3·m` を採る | m を読まない・w を先に採る・CI に任せる戦略でだけ効かせると落ちる | +| 受け入れ条件 2・I1 | m が無く w があれば上限は `3·w` 以上(置き場所が全体テストを覆わなくても)。どちらも無ければ `0.10·B` | `covers_whole` の条件を残す・無しのときに 0 や w を返すと落ちる | +| 受け入れ条件 3・I3 | 想定最大時間を省き、x = 495 秒で既定 30 分の枠が収まらない入力で、`init` は終了コード 0 で終わり、状態の `budget_minutes` が下限・`budget_source` が `floor`・`budget_floor` があり、標準エラーに `ℹ` の 1 行、出力に `BUDGET_SOURCE=floor` が出る。組み直した `limits` で `window_problem` は `None` | 上げずに 4 で止める・300 秒を足す・`limits` を組み直さないと落ちる | +| 受け入れ条件 3(再開) | 上げた実行を計画の前に引数なしで再開すると出所は `floor` のまま、引数を渡すと `explicit` になる | 再開で出所を `default` に戻す・引数を渡しても上げ続けると落ちる | +| 受け入れ条件 4・I7 | 延長のファイルに 2,400 秒を書いた子は、プランの `timeout` 1,980 秒を過ぎても切られず、2,640 秒で切られる。ファイルの秒がプランの値より小さければ締め切りは変わらない。`cur.extended_timeout` が残る | 延長を読まない・締め切りを前へずらす・余裕を足さない・前の試行のファイルを読むと落ちる | +| 受け入れ条件 1・I7(`init`) | 既定 30 分・s = 1,518 秒の入力で、`init` は着手前のテストを始める前に延長のファイルへ 6,174 秒を書く。T が `0.10·B` 以下なら書かない。後から駆動が小さい秒を書いても締め切りは前へ戻らない | テストの後に書く・書かない・後の小さい値で締め切りを戻すと落ちる | +| 受け入れ条件 4(駆動) | `BUDGET_SOURCE=floor` の `init` の後、駆動が延長のファイルを書く。`default` / `explicit` では書かない | 出所を問わずに書く・書かないと落ちる | +| 受け入れ条件 5・I4・I5 | 明示 30 分・x = 495 秒の入力で `init` は `SKIP=window` を出して 4、駆動は `gaps` 1 件の `stopped` と終了コード 6 を返す。打ち切りでも `SKIP=init-timeout` で同じ | `SKIP` を出さない・駆動が 1 を返す・summary に「落ちた」を入れると落ちる | +| 受け入れ条件 5・I6 | 検査のプランを流すテストで、`refactor` が 6 を返すと次は `review`、検査は abort へ進まない | `on_exit` を書かない・`"6"` を abort へ向けると落ちる | +| 受け入れ条件 5・I8 | 飛ばして `review` まで終えた検査の行は `findings.refactor_skipped` を持ち `result` が `failed` でない。飛ばした後に `review` が失敗して abort した検査は `findings.refactor_skipped` を持ち `result` が `failed`。フェーズレポートに「欠けを残して進んだステップ: refactor」の行が出る | 理由を写さない・飛ばしただけの検査を failed として書く・後の失敗まで failed にしないと落ちる | +| 受け入れ条件 6・I2 | 明示 60 分・下限 43 分の入力で、`budget_minutes`・`limits`(着手前のテストの上限を除く)・手順の枠の時刻が変更前と同じ | 明示でも下限へ寄せる・出所で式を変えると落ちる | +| 受け入れ条件 7・I4・I5 | 起動の失敗・認証の失敗・テンプレートの試走の不備・最終ゲートの失敗では `SKIP` が出ず、駆動は 1、プランは abort へ進む | 中断をすべて 6 にすると落ちる | +| 受け入れ条件 8 | `new check` / `new sprint` / `new close` の 3 つで書いたプランの `refactor` が同じ `on_exit` を持つ | 雛形の 1 つだけに足すと落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2047 | 実装する | — | `plugins/ndf/scripts/lib/test_strategy.py`、`plugins/ndf/scripts/lib/drive_pause.py`、`plugins/ndf/skills/cross-refactoring/scripts/drive.py`、`plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/`、`plugins/ndf/skills/cross-refactoring/tests/`、`plugins/ndf/skills/cross-refactoring/SKILL.md`、`plugins/ndf/skills/cross-refactoring/docs/01-state-and-propose.md`、`plugins/ndf/scripts/supervise_lib/templates.py`、`plugins/ndf/scripts/supervise_lib/steps.py`、`plugins/ndf/scripts/supervise_lib/claude.py`、`plugins/ndf/scripts/supervise_lib/worker_steps.py`、`plugins/ndf/scripts/supervise_lib/state.py`、`plugins/ndf/scripts/check-trigger.py`、`plugins/ndf/scripts/tests/`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 上げる幅に上限を設けないこと(前提 2) | 要求の未決として承認ゲート 1 で利用者が決める。決定 2 は上限を設けない形で書いた | +| 受け入れ条件 6 の読み方(決定 6) | 承認ゲート 1 で利用者が確かめる。違う読み方なら、明示した実行では m を使わない形へ直す | +| 遅れの見張りとの重なり | 遅れの見張り(`slow.py`)は延長を知らずに履歴の想定で判定する。延ばした試行で「打ち切る」が選ばれうるかは実装で 1 度流して確かめる | +| 飛ばした実行の状態ファイル | 明示で収まらずに飛ばした実行は、止めた印つきの状態ファイルを残す(今と同じ)。同じ PR の次の検査は、その状態から再開して同じく飛ばしうる。今の振る舞いのままにし、困った記録が出たら別の課題にする | +| キューの全体の時間 | スプリントのキューの側に時間の上限があるかは見ていない。延びたリファクタリングが後続のステージを遅らせる量は、リリース後テストで実測する | diff --git a/issues/issue-2047-requirements.md b/issues/issue-2047-requirements.md new file mode 100644 index 000000000..5a6127b3d --- /dev/null +++ b/issues/issue-2047-requirements.md @@ -0,0 +1,213 @@ +# #2047: 検査のプランのリファクタリングが既定の予算で下限に届かず、33 行の差分でも refactor が落ちて abort へ進む + +正は課題の本文(#2047)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何が起きたか + +2026-10-17、PR #2043(#1873。11 ファイル・33 行)の検査のプラン(`supervise.py new check --pr 2043`)で、リファクタリング(`refactor`)のステップが終了コード 1 で落ち、プランは abort へ進んだ。 + +- 出力: 「`--budget-minutes` を 39 以上にして 5 分以内に init を打ち直すと、着手前のテストを走らせ直さずに続けます(下限では 1 件だけが入ります)」 +- プランの `refactor` の cmd は `--budget-minutes` を持たず(`.ndf/supervise.json` に `refactor_budget_minutes` も無い)、timeout は 1980 秒だった +- `new check --refactor-budget-minutes 40` で書き直し、`run --from refactor` で再開した + +## 問題 + +- `new check` が書く既定の予算が、cross-refactoring の下限(着手前のテストの時間から求める値)を下回り、小さな差分でも必ず落ちる。下限は init が知っているのに、プランを書く時点で予算へ反映されていない +- 落ちたときは abort へ進み、検査の記録も「落ちた」になる。案内の「5 分以内に打ち直す」は、無人のキューでは間に合わない + +## 依頼(案) + +- `new check` / `new sprint` の検査のプランが、予算を下限以上にして書く(予算を省いたときは下限を使う)か、`refactor` の下限不足を abort ではなく予算を上げての打ち直しへつなぐ + +## 関連 + +- #1944(再開で前回の時間枠を引き継ぐ。今回の再開でも当たりうる)・#1263 + +## 由来 + +#2008 の進行中、#1873 の検査で見つけた + + + +## 再発(2026-10-17) + +- a2 のスプリント PR #2053 の検査でも同じく止まった(着手前テスト 481 秒、下限 38 分に対し既定 30 分)。`--budget-minutes 60` を足して再開した +- PR #2043(#1873)の検査でも 40 分を指定して 1 度通らなかった + + +再発(スプリント a2 の検査 PR #2053): 既定の予算 30 分に対し下限 38 分。https://github.com/devbasex/ai-plugins/issues/2008#issuecomment-6233607975 + + +## 再発(2026-10-25、#2089 のスプリント s2089) + +- スプリント PR #2103 の検査のプラン(`6-check.json`。雛形のまま `--budget-minutes` 30 相当)で `refactor` が 2 回落ちて abort へ進んだ(`~/.local/state/ndf/sv/s2089/6-check-state/progress.jsonl`) + - 1 回目(08:52 JST): 着手前のテストが **180 秒**(予算 30 分の 10%)で打ち切られた。履歴が無い範囲の初回で、次の上限は 540 秒の見込みと記録された + - 2 回目(08:58 JST): 着手前のテストは約 335 秒で終わったが、「`--budget-minutes` を 43 以上に」で落ちた + - conductor が `--budget-minutes 60` に書き直して 3 回目(09:45 JST)で完了した(46 分・採用 14 件) +- 着手前のテストの上限(予算の 10%)と予算の下限はどちらも予算から決まるため、既定の 30 分のままでは、全体テストが 3 分を超えるこのリポジトリで 2 段で止まる + + +## 再発(2026-10-29、#2087 のスプリント m2087) + +- スプリント PR #2152 の検査のプラン(`/work/ai-plugins/.ndf/tmp/sprint-m2087b/4-check.json`、既定の予算 30 分)で `refactor` が `refactor.py init` の終了コード 4 で落ち、abort へ進んだ(2 回目の実行。記録は `/work/ai-plugins/.ndf/tmp/sprint-m2087b/4-check-state.run2`)。着手前のテストは約 8 分(3 回目の実行で 495.5 秒) +- `--refactor-budget-minutes 90` でプランを書き直し、`done-4c.json` で流し直して通った +- 振り返り: PR #2160 のコメント + + +## 発生の記録(2026-10-31、開発ループ l20261031 の周回 2) + +- 検査のプラン(スプリント PR https://github.com/devbasex/ai-plugins/pull/2250 、132 ファイル)の refactor が、着手前のテストの打ち切りで `refactor.py init` exit 4 → abort へ進んだ +- 上限は 180 秒 → 540 秒 → 1,620 秒と、打ち切りのたびに 3 倍になった。`.ndf/project.json` の `test_duration` は 1,518 秒(CI の 4 ステップの合計)を持っていたが、初回の上限はこれを読んでいない +- 手で測ると factory の vitest は 12 秒で、残りは pytest の全体(`plugins/ndf/scripts/tests` ほか)だった。2 回分の打ち切りで約 13 分を失った + + + +## 発生の記録(2026-10-31、スプリント m26-1) + +- 検査のプラン(スプリント PR https://github.com/devbasex/ai-plugins/pull/2268 )の refactor で、着手前のテストが上限 180 秒 → 540 秒で 2 回打ち切られた。3 回目(上限 1,620 秒)でテストは 652 秒で通ったが、予算 30 分に収まらず `init` が止まった +- conductor が `6-check.json` の refactor に `--budget-minutes 75`・timeout 4,800 秒を足して流し直して通った。同じ時の全体テスト(6 並列)は 620 秒 +- 初回の上限が実測(`.ndf/project.json` の `test_duration`、直前の全体テスト)を読まず、打ち切った回の値から 3 倍ずつしか上がらない、という前回と同じ形。5 回目の発生 + +## 追加の事例(2026-10-31) + +スプリントの PR https://github.com/devbasex/ai-plugins/pull/2283 で、変更前のテストに約 9 分かかり、リファクタリングの工程の時間の枠を使い切った。提案は 1 件も入らなかった(重複として閉じた #2287 から写した)。全体テストを軽くする #2291 と合わせて直す。 + +## 依頼(原文) + +> - `new check` が書く既定の予算が、cross-refactoring の下限(着手前のテストの時間から求める値)を下回り、小さな差分でも必ず落ちる。下限は init が知っているのに、プランを書く時点で予算へ反映されていない +> - 落ちたときは abort へ進み、検査の記録も「落ちた」になる。案内の「5 分以内に打ち直す」は、無人のキューでは間に合わない + +(書き手: 起票者、日付: 2026-10-17。本文の「問題」の節) + +> - `new check` / `new sprint` の検査のプランが、予算を下限以上にして書く(予算を省いたときは下限を使う)か、`refactor` の下限不足を abort ではなく予算を上げての打ち直しへつなぐ + +(書き手: 起票者、日付: 2026-10-17。本文の「依頼(案)」の節) + +> - 初回の上限が実測(`.ndf/project.json` の `test_duration`、直前の全体テスト)を読まず、打ち切った回の値から 3 倍ずつしか上がらない、という前回と同じ形。5 回目の発生 + +(書き手: 起票者、日付: 2026-10-31。本文の「発生の記録(2026-10-31、スプリント m26-1)」の節) + +> 全体テストを軽くする #2291 と合わせて直す。 + +(書き手: 起票者、日付: 2026-10-31。本文の「追加の事例」の節。重複として閉じた #2287 から写した文) + +利用者の指示の原文はこの課題に無い。マイルストーン「27 テストの軽量化とリファクタリングの時間、文書の読みやすさ」に入っていることだけが、利用者がこの課題を扱うと決めた記録である。 + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 想定最大時間を省いたときは、着手前のテストの時間から求めた下限を想定最大時間として使う | 本文の「依頼(案)」の前半 | 受け入れ条件 3・4 | +| S2 | 下限に届かないときは abort ではなく、想定最大時間を上げて打ち直す | 本文の「依頼(案)」の後半 | 採らない。想定最大時間を明示したときは、その想定最大時間を利用者の決めた上限として扱い、黙って上げない(Value 10)。代わりに abort せずにリファクタリングを飛ばしてコードレビューへ進む(受け入れ条件 5) | +| S3 | 着手前のテストの初回の上限は、`.ndf/project.json` の `test_duration` か直前の全体テストの実測を読んで決める | 本文の「発生の記録(2026-10-31、開発ループ l20261031 の周回 2)」と「スプリント m26-1」の節 | 受け入れ条件 1・2 | +| S4 | 全体テストを軽くする #2291 と合わせて直す | 本文の「追加の事例」の節 | 前提 4・含まない。同じマイルストーンで進めるが、全体テストの速さはこの課題の受け入れ条件にしない | + +## 目的 + +- 検査のプラン(`supervise.py new check` / `new sprint` / `new close` が書くプラン)のリファクタリングのステップが、想定最大時間の既定値(30 分)と着手前のテストの上限の決め方のせいで落ち、検査が abort へ進むことを無くす +- 無人のキューで、人が想定最大時間を書き直して打ち直さなくても、検査がコードレビューまで進む状態にする + +## 前提 + +- 前提 1: 「想定最大時間を明示した」とは、`--refactor-budget-minutes` の引数か `.ndf/supervise.json` の `refactor_budget_minutes` で想定最大時間を書いたことを指す。どちらも無いときを「想定最大時間を省いた」と呼ぶ +- 前提 2: 想定最大時間を省いたときに想定最大時間を下限まで上げることに、別の上限は設けない。費用の上限を持ちたい利用者は `refactor_budget_minutes` を書く(前提 1 の明示になり、受け入れ条件 5 の扱いになる) +- 前提 3: 初回の上限に使う既知の所要は、手元の全体テストの直近の実測(`whole_runs` の記録)を先に採り、無ければ `.ndf/project.json` の `test_duration` を採る。どちらも無いときは今の値(想定最大時間の 10%)のままにする +- 前提 4: 全体テストそのものを速くする作業は #2291 が持つ。この課題は、テストが遅いままでも検査が止まらないことだけを受け入れ条件にする +- 前提 5: この変更より前に書き出したプランのファイル(`--budget-minutes` を持たない `refactor` の cmd と、既定の想定最大時間から求めた timeout)は書き直さない。新しい振る舞いは、この変更の後に書いたプランから効く + +## 対象範囲 + +含む: +- 着手前のテストの初回の上限の決め方(既知の所要を読む) +- 想定最大時間を省いたときの、リファクタリングの想定最大時間の決め方(下限を下回らない) +- 想定最大時間を省いたときの、プランの `refactor` のステップの timeout(リファクタリングの実行中に切れない) +- リファクタリングが時間の枠に収まらないときの、プランの進み方(abort ではなくコードレビューへ進む)と検査の記録 +- `new check` / `new sprint` / `new close` の 3 つの雛形(どれも同じリファクタリングのステップを書く) + +含まない: +- 全体テストを速くすること(#2291) +- 打ち切ったときに子のプロセスが残る問題(#2285) +- 再開で前回の時間枠を引き継ぐ問題(#1944) +- cross-refactoring の枠の配分(提案 0.20·B・リファクタリング計画 0.10·B・打ち直しの見込み 300 秒)の見直し +- 想定最大時間の既定値(30 分)そのものの変更 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 検査のプランを書いた(`refactor` のステップの想定最大時間と timeout が決まった) | `supervise.py new check` / `new sprint` / `new close` | 引数・宣言の誤りで書かずに止まる(今のまま) | — | +| E2 | リファクタリングのステップを始めた | プランの実行が `assess` の後に `refactor` へ進んだ | — | E1 | +| E3 | 着手前のテストの上限を決めた | `refactor.py init` | 既知の所要が読めなければ想定最大時間の 10% を使う(前提 3) | E2 | +| E4 | 着手前のテストが終わった / 打ち切られた | E3 の上限の中でテストが終わる / 上限に達する | 打ち切り → E7(飛ばした) | E3 | +| E5 | リファクタリングの想定最大時間が決まった | E4 の実測 x から下限を求める | — | E4 | +| E6 | 手順の枠が想定最大時間に収まった / 収まらなかった | E5 | 収まらない(想定最大時間を明示し、下限に届かない) → E7(飛ばした) | E5 | +| E7 | リファクタリングを終えた / 時間の枠に収まらず飛ばした | E6 の後の改修、または E4・E6 の失敗の経路 | ステップの timeout で切られる経路は起きてはいけない(受け入れ条件 4) | E6 または E4 | +| E8 | 検査がコードレビューへ進み、検査の記録にリファクタリングの結果を残した | E7 | — | E7 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 想定最大時間 | 用語集のとおり(`--budget-minutes`)。プランでは `--refactor-budget-minutes` か `.ndf/supervise.json` の `refactor_budget_minutes` で渡す。本文の「依頼(原文)」の「予算」はこの語を指す | +| 想定最大時間の下限 | 着手前のテストの実測 x から求める、手順の枠が収まる想定最大時間の最小値(`timeline.required_budget_minutes`)。init が「`--budget-minutes` を N 以上に」と案内する N | +| 着手前のテスト | `refactor.py init` が改修の前に走らせるテスト(範囲テストか全体テスト) | +| 着手前のテストの初回の上限 | 同じ範囲の打ち切りの記録が配分の履歴に無いときの、着手前のテストの上限(秒) | + +## 受け入れ条件 + +- [ ] 1. 手元の全体テストの直近の実測 m(`whole_runs` の記録)があり、同じ範囲の配分の履歴が無いとき、着手前のテストの上限は 3·m 秒以上になる(例: m = 620 秒なら 1,860 秒以上。今は想定最大時間 30 分の 10% の 180 秒) +- [ ] 2. m が無く `.ndf/project.json` の `test_duration` の所要 w があるとき、着手前のテストの上限は 3·w 秒以上になる。m も w も無いときは今と同じ想定最大時間の 10% になる +- [ ] 3. 想定最大時間を省いたとき、着手前のテストの実測 x から求めた下限が既定の想定最大時間(30 分)を超えても、`refactor.py init` は「`--budget-minutes` を N 以上に」で止まらず(終了コード 4 を返さず)、想定最大時間を下限以上にして続ける。使った想定最大時間と、それを下限から決めたことが状態ファイルと標準出力に残る(例: x = 495 秒で、今は止まる入力が通る) +- [ ] 4. 想定最大時間を省いて書いたプランの `refactor` のステップは、受け入れ条件 3 で上げた想定最大時間でリファクタリングが終わる前に、ステップの timeout で切られない +- [ ] 5. 想定最大時間を明示し、下限がその想定最大時間を超えるとき、および着手前のテストが上限で打ち切られたとき、プランは abort へ進まずコードレビュー(`review`)へ進む。検査の記録とフェーズレポートには、リファクタリングを「時間の枠に収まらず飛ばした」ことと、理由(要る下限の分、または打ち切った上限の秒)が残り、「落ちた」とは書かれない +- [ ] 6. 想定最大時間を明示し、下限がその想定最大時間以下のときの振る舞い(想定最大時間・上限・枠の配分)は今と変わらない +- [ ] 7. 起動の失敗・最終ゲートの失敗など、時間の枠と関係のない理由でリファクタリングが落ちたときは、今と同じく abort へ進む(受け入れ条件 5 の経路に混ざらない) +- [ ] 8. `new check` / `new sprint` / `new close` の 3 つの雛形のどれで書いたプランでも、受け入れ条件 3〜5 が同じに効く + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 受け入れ条件 1・2 の上限は、テストが固まったときに待つ時間の最大を延ばす。延びる分は既知の所要の 3 倍までにとどまる | +| 運用・保守性 | 想定最大時間を下限から決めたこと(受け入れ条件 3)と、リファクタリングを飛ばしたこと(受け入れ条件 5)は、人が後から記録だけで経緯を追える | +| 移行性 | 書き出し済みのプランは書き直さない(前提 5)。古いプランで止まったときは、今と同じく `--refactor-budget-minutes` を付けて書き直す | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる(互換あり)。`refactor.py` で `--budget-minutes` を省いたときの意味が「30 分固定」から「30 分と下限の大きい方」になる。明示したときの意味は変わらない。`supervise.py new` の引数は変わらない | +| データ | 状態ファイルと配分の履歴に、想定最大時間の出所(明示 / 下限から決めた)を足す見込み。既存の行は読める形のまま | +| 既存の振る舞い | 着手前のテストの初回の上限、想定最大時間を省いたときの想定最大時間、時間の枠の不足でのリファクタリングの失敗の経路 | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/skills/cross-refactoring/tests plugins/ndf/scripts/tests -q -n 4` | +| 静的解析・型検査 | `bash scripts/check-lint.sh` | +| 手動確認 | 次のスプリントの検査のプランを想定最大時間を省いて書き、リファクタリングが下限の想定最大時間で通るか、飛ばしてコードレビューへ進むかを、フェーズレポートと状態ファイルで見る(リリース後テストで確かめる) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。着手前のテストと想定最大時間は `plugins/ndf/skills/cross-refactoring/scripts/` と `plugins/ndf/scripts/lib/test_strategy.py`、プランの雛形は `plugins/ndf/scripts/supervise_lib/` が持つ。同じ役割の値を両方に写さない(Value 6) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`bash scripts/check-lint.sh` | +| テスト戦略 | 上限・下限・想定最大時間の決め方は単体テストで、プランの進み方(abort へ進まない)は `supervise.py` のプランを実行するテストで担保する。`.md` の文言は照合しない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 範囲のテストと lint の実行、受け入れ条件 3・5 の入力(x = 495 秒・想定最大時間 30 分)を再現するテスト | +| 確認してから行う | 状態ファイル・配分の履歴の形を変えること(承認ゲート 1・2 で人が見る) | +| 行わない | テストの高速化、枠の配分の比率と既定の想定最大時間の変更、書き出し済みのプランの書き換え | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 想定最大時間を省いたときのステップの timeout を、プランを書く時点の見込みで決めるか、実行の時点で延ばすか(受け入れ条件 4 の作り方) | `design` | 設計 PR | +| リファクタリングを飛ばしたことを、`refactor.py` の終了コードで分けるか、駆動の結果 JSON で分けるか(受け入れ条件 5・7 の作り方) | `design` | 設計 PR | +| 想定最大時間を省いたときに上げる想定最大時間に上限を設けないこと(前提 2)でよいか | 利用者(承認ゲート 1) | 設計の承認 | diff --git a/issues/issue-2216-design-decisions.md b/issues/issue-2216-design-decisions.md deleted file mode 100644 index 24f6fcac8..000000000 --- a/issues/issue-2216-design-decisions.md +++ /dev/null @@ -1,97 +0,0 @@ -# #2216 の設計: 決定の記録 - -頭の文書は [issue-2216-design.md](issue-2216-design.md) にある。根拠の行の MVV は ai-plugins のプロジェクト MVV(版 3)である。 - -## 決定の記録 - -### 決定 1: 照合のために受け口がほかの集約を読まないよう、取り込むリポジトリを実行ジョブの token の claim で運ぶ - -実行の制御(#2186)は実行ジョブを起動するとき、実行ジョブの指示の `REPO` と token を同じスプリントの実行から作るため、token にリポジトリを入れる費用は 1 つの値だけである。署名された値はランナーが書き換えられず、受け口は今と同じく token だけで照合を閉じられる。確定仕様は期限より前の取り消しを「#2186 の集約を読むことになるため」持たないと決めており、照合のためにスプリントの実行を読む形はその判断と食い違う。#2186 の設計(PR #2236)の決定 1 も同じ形を採り、照合を I37 として置いた。 - -受け口がスプリントの実行からリポジトリを引いて照らす形は採らない。#2186 の表の形が決まるまで読み出しの口を作れず、事象 1 件ごとに読み出しと 503 の経路が増える。 - -根拠: Value 6 / Value 4 / C2(MVV 版 3) - -### 決定 2: 照らせない token を残さないため、`repo` を必須の claim にし、無い token は 401 にする - -`sprint_run_id` と同じく必須の claim にすれば、無い・形が違う token は検証の段で `malformed` になり、受け口と #2173 で同じ扱いになる。要求の受け入れ条件 4 が求める「202 にならず保存しない」を、照合の分岐を増やさずに満たす。 - -`repo` を任意にし、`pull_request` つきの `step.recorded` のときだけ 403 にする形は採らない。照らせない token が有効なまま残り、照合の側に「claim が無い」分岐が増える。旧い token との互換は前提 3 のとおり持たない。 - -根拠: Value 6 / C2(MVV 版 3) - -### 決定 3: 照合と `pr_ref` と起票の代行が同じ規則で名前をそろえるよう、小文字にする関数を `github.ts` に 1 つだけ置く - -GitHub はリポジトリの名前の大文字と小文字を区別せず、`pr_ref` は既に `owner/name` を小文字にそろえる。照合・起票先の照合(I45)・登録の引き当て・台帳の要約値が別々にそろえると、照合を通った値が引き当てで外れる食い違いが起こりうる。`repoKey` をすべてが使う。token の `repo` の形は契約の `RepoFullName` の `pattern` を読んで確かめ、正規表現を写さない。 - -根拠: Value 6(MVV 版 3) - -### 決定 4: 照合の単位をリポジトリの名前(`owner/name`)にし、GitHub のリポジトリの識別子にしない - -事象の `pull_request` が運ぶのは `repo`(`owner/name`)と番号だけで、識別子で照らすには進捗の事象の契約とランナーの申告を変えることになる。名前で照らすと、実行ジョブの途中で改名したときその後の申告は 403 になるが、改名の後は `pr_ref` も既に合わなくなる(確定仕様の運用の節)ため、照合が新しく失わせる情報は無い。起票の依頼の引き当ても同じく名前で行い、改名の後の依頼は `not_registered` の下書きになる(人が起票できる)。 - -要求の未決 2 は #2186 の設計に置いたままにする。#2186 が識別子で照らすと決めたときは、token の `repo` と事象の契約を同じ変更で替える。 - -根拠: Value 1 / Value 10(MVV 版 3) - -### 決定 5: #2171 の設計の決定 7 を、`pr_ref` を「token のリポジトリと照らした申告」から置く形へ書き換える - -#2171 の決定 7 は、宛先のスプリントの実行を知るのが Pull Request を作ったランナーだけであることから、`pr_ref` をランナーの申告で置くと決めた。この理由は変わらず、申告で置く形も残す。変えるのは、申告の `repo` が token のリポジトリと一致するときだけ置くことである。確定仕様の背景の段落にこの条件と理由(申告だけで置くと、正しい token を持つランナーが関係の無いリポジトリの Pull Request を自分の実行へ結び付けられる)を書き足す。 - -根拠: Value 7 / C2(MVV 版 3) - -### 決定 6: ランナーの権限を広げずに別のリポジトリの修正を届けるため、起票の依頼を `runner` の新しい種類 `issue.requested` にし、受け口は記録だけをする - -利用者の答えのとおり、ランナーは依頼を送るだけで、起票はサービスが行う。依頼を進捗の事象にすれば、既存の token の検証・冪等の鍵・送り直しがそのまま使え、依頼と結果が実行の列に並ぶため #2172 が新しい読み出しを持たずに見せられる。`payload` は起票先・題・本文の 3 つだけにする。ラベルと相手の課題の番号は相手先の流儀に属し(#2239)、本文の「由来」の節が元の課題を指す。題の上限 256 字は GitHub の題の上限で、本文の上限 50,000 字は、4 バイトの文字だけでも事象の 256 KiB に収まる長さである。 - -受け口は登録を読まず、起票先が token の `repo` と同じ依頼だけを 403 にする(I45)。実行のリポジトリへはランナーが自分の導入の token で起票でき、同じことに 2 つの経路を作らないためである。起票してよいかの判定を受け口で行う形は採らない。受け口が #2186 の集約と GitHub を読み、決定 1 の境目が崩れる。依頼を `attention` の `payload` に載せる形も採らない。`attention` は人の手を止める知らせで、サービスが起票した依頼まで人へ知らせることになる。 - -根拠: Value 6 / Value 4 / C2(MVV 版 3) - -### 決定 7: NDF の起票の手順を変えずにランナーから依頼を出すため、`issue-file.py create` に環境変数 `NDF_ISSUE_REQUEST_FILE` の分岐を 1 つ足す - -NDF は別のリポジトリへの起票を `issue-file.py create` に集めており、起票先が開発対象リポジトリと別なら承認資料を書いて 10 で止まる。ランナーの中ではこの承認を人に問えず、`gh` の token も 1 リポジトリに絞られている。そこでランナーが変数を渡したときだけ、承認資料の代わりに依頼の 1 行(`{"repo", "title", "body"}`)をファイルへ足して 0 を返す。人の承認の代わりはサービスの判定(決定 11)が持つ。手順書(`out-of-scope`)は変わらず、ランナーの外では変数が無いため今どおり止まる。 - -ランナーが NDF の承認資料(Markdown)を読み取って依頼にする形は採らない。承認資料は人が読む形で、題と本文の切り出しが壊れやすく、NDF は 10 で止まって人へ戻すため実行ジョブが承認待ちになる。conductor に API を直接呼ばせる形も採らない。実行ジョブの token を NDF の手順へ渡すことになる。 - -根拠: Value 6 / P1(MVV 版 3) - -### 決定 8: 流儀の判定を 2 か所に書かないため、起票の代行は #2239 の判定を型の口 `FilingStyleResolver` で呼ぶだけにし、既定を GitHub Issues とテンプレート無しにする - -相手先の課題追跡の宣言・Issue テンプレート・外部チケットを読む判定は #2239 が持ち、NDF とサービスが同じ判定を使う形も #2239 が決める。ここは判定を呼ぶ口と、判定が GitHub Issues 以外を返したときに下書きへ回す(`unsupported_tracker`)扱いだけを持つ。#2239 が入るまでの既定は「GitHub Issues へ、テンプレートを使わず、依頼の題と本文のまま」で、`saved-answers.ts` と同じく同じファイルの中身を替えれば済む形にする。相手先のファイルを読む token(Contents の読み取り)の要否も #2239 が決める。起票に使う token は Issues の書き込みだけのまま広げない。 - -起票の代行の中に `.ndf/project.json` と `.github/ISSUE_TEMPLATE/` を読む規則を書く形は採らない。NDF の側と判定が 2 つになり、片方だけ直したときに食い違う。 - -根拠: Value 4 / Value 6(MVV 版 3) - -### 決定 9: 人の手を止める承認を増やさないため、下書きは #2173 の承認の依頼にせず、結果の事象 `drafted` を知らせとして回す - -承認を求める種類はゲート 1・ゲート 2 の 2 つだけで、判断は知らせである(全体の契約の I11)。下書きの起票はスプリントの進行を止める答えを要らず、人が相手先の流儀に沿って起票すれば済む。#2173 は `outcome` が `drafted` の `issue.request_resolved` を知らせの受け手に足し、#2172 は依頼の題と本文を、写せる形の下書きとして見せる。知らせの送り方(Web・Slack)は #2173 が決める。 - -承認の依頼の 3 つ目の種類にする形は採らない。I11 を破り、答えを待つ間に実行ジョブが止まる。 - -根拠: Mission / C2(MVV 版 3) - -### 決定 10: 同じ依頼から Issue を 2 件作らないため、結果を `control` の直の追記で 1 件だけ残し、起票の前に台帳の行を条件つきで書き、作成の要求の後はやり直さない - -結果は依頼 1 件に 1 件で、鍵を `<依頼の鍵>:resolved` にすれば鍵の表が重複を拒む。書き手は受け口の認証を通らないサービスの中の処理のため、`appendControlEvent` と同じ `control` の出所で、受ける種類を `issue.request_resolved` に限った `appendIssueRequestResolved` を足す。同じ中身の別の依頼(送り直し・再開のジョブ)は、台帳の行(`RUN#` / `ISSUE#`)を条件つきで書くことで 1 件目だけが起票へ進み、2 件目以降は 1 件目の結果を `duplicate_of` つきで写す。Issue の作成の要求を出す直前に `attempted_at` を書き、それがある行は作り直さずに `filing_failed` の下書きにする。GitHub が作ったのに応答が届かなかったとき、2 件目を作らないためである。 - -出所に `service` を足す形は採らない。種類の表と全体の契約の I25 の受け手がすべて新しい値を扱うことになり、「サービスの中から直に書く」という意味は `control` で足りる。作成の失敗をやり直す形も採らない。GitHub の Issue の作成は冪等の鍵を持たない。 - -根拠: Value 6 / Value 2(MVV 版 3) - -### 決定 11: ほかの利用者のリポジトリへ書かず、情報の公開範囲を広げないため、サービスが起票する先を「依頼の実行のアカウントの登録」と「公開範囲が広がらない向き」に限り、token を 1 リポジトリと Issues の書き込みに絞る - -利用者の答えのとおり、起票してよいのは同じアカウントが登録し App を入れたリポジトリだけである。アカウントは依頼の `sprint_run_id` からサービスの表で引き、依頼の値はリポジトリの名前を引く鍵にしか使わない。導入の token は `repository_ids` に起票先の数の ID 1 つ、`permissions` に `issues: write` だけを入れて作り、GitHub の口の中で 1 回使って捨てる(I47)。App の導入が起票先を含まなければ作成が断られ、`not_installed` の下書きになる。理由はアカウントの中の登録だけから決め、未登録とほかのアカウントの登録を同じ `not_registered` にする(I50)。 - -加えて、起票先が実行のリポジトリより広く見えるなら起票しない(`visibility`。広さは private < internal < public)。AI が書いた本文は非公開のコードや設定を含みうる。同じアカウントのリポジトリでも公開のリポジトリへ出すと誰でも読め、internal のリポジトリへ出すと企業の中の全員が読める。この向きだけは人が本文を見てから起票する。ほかのアカウントの登録でも、そのアカウントの同意があれば起票する形は採らない。同意の記録と取り消しの経路が要り、利用者の答えの範囲を超える。 - -根拠: C2 / Value 2(MVV 版 3) - -### 決定 12: 暴走した実行が自分のリポジトリへ大量に起票しないよう、スプリントの実行ごとの起票の数を 10 までにし、秘密らしい値を含む依頼は起票しない - -ほかの利用者のリポジトリへの起票は決定 11 で塞がり、残る害は 1 つの実行が自分の登録したリポジトリへ同じ種類の Issue を並べることである。数の行を条件つきで増やし(I49)、超えた依頼は `limit_exceeded` の下書きにする。上限の 10 は実測に基づかない初期値で、環境変数 `ISSUE_REQUEST_LIMIT_PER_RUN` で変え、本番の結果の事象で見直す。下書きは数に入れない。下書きはアカウントの中の人へ回るだけで、外へ出ない。 - -秘密らしい値は、AWS のアクセスキー(`AKIA` / `ASIA` と 16 字)・PEM の秘密鍵の始まりの行・GitHub の token(`ghp_`・`gho_`・`ghu_`・`ghs_`・`ghr_`・`github_pat_`)・Anthropic の API キー(`sk-ant-`)・JWT の形(実行ジョブの token)を、題と本文の両方で探す。当たれば `secret_suspected` の下書きにする。依頼の事象はアカウントの中でだけ読まれ、外へ出す前の最後の検査は起票の代行が行う。依頼の本文はログに出さない。日や起票先ごとの数も数える形は採らない。行と条件が増え、1 つの実行の暴走を止めるには実行ごとの数で足りる。 - -根拠: Value 10 / C2(MVV 版 3) diff --git a/issues/issue-2216-design.md b/issues/issue-2216-design.md deleted file mode 100644 index d433672b4..000000000 --- a/issues/issue-2216-design.md +++ /dev/null @@ -1,402 +0,0 @@ -# 進捗の受け口: ランナーが申告する Pull Request をその実行のリポジトリに限り、別のリポジトリの修正はサービスが起票するか下書きを人へ回す(#2216) - -## 目的 - -- **何が壊れているか**: 進捗の受け口は、正しい実行ジョブの token を持つランナーが `step.recorded` で申告した Pull Request を、リポジトリを確かめずに `pr_ref` として残す。関係の無いリポジトリの Pull Request を申告すると、その `pr.updated`・`ci.updated`(GitHub の Webhook)が申告した側のスプリントの実行へ結び付く -- **何が足りないか**: 申告をその実行のリポジトリに限ると、実行ジョブの中で別のリポジトリの修正が要るとわかったとき、それを相手先へ届ける経路が無い。ランナーの token は 1 リポジトリに絞られており、広げると上の照合の意味が無くなる -- **誰が困るか**: ほかの利用者(自分の Pull Request と CI の状態が、別のアカウントの進捗に見える)と、スプリントを流す利用者(別のリポジトリの直すべき点が実行ジョブの中で失われる)。いまは factory/ を本番へ出していないため、影響を受けた利用者はいない -- **直すと何が成り立つか**: - - 実行ジョブの token が、そのジョブの取り込むリポジトリ(`owner/name`)を運ぶ。受け口は申告された Pull Request のリポジトリを token の値と照らし、違えば 403 で残さない - - ランナーは別のリポジトリへの起票を、進捗の事象 `issue.requested` として依頼するだけにする。サービスは依頼を読み、同じアカウントが登録し GitHub App を入れたリポジトリなら、1 リポジトリと Issues の書き込みだけに絞った token で起票する。そうでない先は起票せず、下書きを人へ回す。どちらにしたかは事象 `issue.request_resolved` に残る - -## 適用範囲 - -- **働く範囲**: このリポジトリの `factory/`(API の進捗の受け口・実行ジョブの token・起票の代行・ランナーの翻訳)と、NDF の `plugins/ndf/scripts/issue-file.py` の 1 つの分岐(ランナーの中でだけ働く) -- **プロジェクトごとに違うもの**: 起票の件数の上限(環境変数 `ISSUE_REQUEST_LIMIT_PER_RUN`、既定 10)。相手先の流儀は #2239 が判定する -- **当たるモード**: `standard`(認可の規則と、利用者に代わって外へ書く条件を変える。共通原則 C2) - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| [PR #2209](https://github.com/devbasex/ai-plugins/pull/2209) のクロスレビューの指摘(major)「`step.recorded` の `pull_request` が、その実行に許可されたリポジトリの参照であることを照合してから `pr_ref` を登録してください」 | 外部の一次情報(レビューの指摘) | 申告の値だけで `pr_ref` を置く今の形が、認可の欠けとして指摘されたこと | -| 利用者の差し戻しと答え([#2216](https://github.com/devbasex/ai-plugins/issues/2216) の本文の「依頼(原文)」、2026-10-31) | 利用者の指示 | 申告を限るのは良い。別のリポジトリの修正はサービスが代わりに起票し、権限の無い先は下書きを人へ回す。ランナーの token は広げない。流儀の判定は #2239 | -| 確定仕様 [ndf-factory-service-boundary.md](../docs/specifications/ndf-factory-service-boundary.md) の実行ジョブの指示 `REPO`(`owner/name`・必須)と ER 図「リポジトリの登録 \|\|--o{ スプリントの実行」 | 外部の一次情報(確定仕様) | 1 つの実行ジョブが取り込むリポジトリはちょうど 1 つで、token を発行する実行の制御がその値を持つこと | -| 確定仕様 [ndf-factory-contracts-and-progress.md](../docs/specifications/ndf-factory-contracts-and-progress.md) の背景「期限より前の取り消しは実行ジョブの状態(#2186 の集約)を読むことになるため持たない」 | 外部の一次情報(確定仕様) | 受け口の照合は token だけで閉じ、ほかの集約を読まない形が既に選ばれていること | -| #2186 の設計([PR #2236](https://github.com/devbasex/ai-plugins/pull/2236))の決定 1・6・7 と I37 | 関連の設計 | token の `repo` の主張と照合(I37)を #2186 も前提にしていること。App の権限に Issues の読み書きがあり、導入の token は 1 リポジトリに絞って作ること | -| GitHub の文書「Create an installation access token for an app」(`POST /app/installations/{installation_id}/access_tokens` の `repository_ids` と `permissions`) | 外部の一次情報 | 導入の token を、リポジトリと権限の両方で導入より狭く作れること | -| NDF の `out-of-scope` の `references/issue-target.md` と `issue-file.py create` | このリポジトリの手順 | NDF は別のリポジトリへの起票を「他のリポジトリへの公開」として、承認資料を書いて止まる(終了コード 10)。人に問えない起動では人へ戻す | - -要求と受け入れ条件は [#2216](https://github.com/devbasex/ai-plugins/issues/2216) の本文にある(コピーは [issues/issue-2216-requirements.md](issue-2216-requirements.md))。この文書は「どう作るか」だけを扱う。決定の記録は [issues/issue-2216-design-decisions.md](issue-2216-design-decisions.md) にある。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| Neural Development Factory(`ndf-factory`) | 実行ジョブの token・進捗の事象・Pull Request の参照・スプリントの実行・リポジトリの登録・起票の依頼・起票の依頼の結果・起票の代行・下書き | -| NDF の開発ワークフロー(`ndf-workflow`) | 範囲外の課題の起票・起票先・開発対象リポジトリ | - -`ndf-workflow` が上流、`ndf-factory` が下流の腐敗防止層(全体の契約のまま)。NDF の「起票先が開発対象リポジトリと別」という記録を、ランナーが `issue.requested` へ翻訳する。GitHub は外部の系で、起票の代行は #2186 の GitHub の口を通してだけ書く。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 進捗の記録 | 進捗の受け口(`factory/api/src/progress/`) | スプリントの実行の識別子ごとの列 | 進捗の事象 | 種類(`issue.requested`・`issue.request_resolved` を足す)・Pull Request の参照・起票した Issue の参照 | -| 起票の依頼の台帳 | 起票の代行(`factory/api/src/issue-requests/`) | スプリントの実行ごとの台帳 | 依頼(中身の要約値ごと) | 状態(`filing`・`filed`・`drafted`)・起票した数 | -| スプリントの実行と実行ジョブ | 実行の制御(#2186) | スプリントの実行 | 実行ジョブ | 取り込むリポジトリ(`owner/name`) | -| リポジトリの登録 | 取り込み(#2186) | 登録 | — | GitHub のリポジトリ(数の ID・`owner/name`)・導入の識別子 | - -- **進捗の記録はスプリントの実行を読まない。** 取り込むリポジトリは実行の制御が token へ署名して渡し、受け口は token の値だけで照らす(決定 1)。起票の依頼も、受け口は token と事象だけで受け付け、登録を読まない(決定 6) -- **起票の代行は、ほかの集約を読むだけで書かない。** スプリントの実行(アカウントを知る)と登録(導入を知る)は #2186 の公開する関数で読む。書くのは自分の台帳と、進捗の記録への直の追記(`issue.request_resolved` だけ)である -- **1 つの処理で書き換える集約は 1 つ。** 台帳の依頼の行と起票した数の行は同じ集約で、1 つのトランザクションで書く(決定 10) - -### 不変条件 - -確定仕様の I25〜I31 と、#2186 の I32〜I43 に続けて番号を振る。I37 は #2186 の設計が先に置いた番号をそのまま使う。I26 と I31 は意味を変える。 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I26(変更) | 進捗の記録 | 出所ごとに書ける種類は決まっている。`runner` は `issue.requested` を足し、`control` は `job.finished` と `issue.request_resolved` の 2 つになる | 403 で保存しない(`appendControlEvent` 系は `forbidden`) | -| I31(変更) | 進捗の記録 | 実行ジョブの token は `account_id` を運ばない。運ぶのは実行ジョブ・スプリントの実行・取り込むリポジトリ・期限の 4 つだけである。token の値・署名の鍵・Webhook の秘密・起票に使う導入の token は応答・ログ・事象に出ない | `signRunJobToken` の `claims` は 4 つの鍵だけを型で受ける。ログは決まった 4 列だけにする | -| I37 | 進捗の記録 | `runner` の経路で残る `step.recorded` が `payload.pull_request` を持つなら、その `repo` は token の `repo` と大文字と小文字を区別せずに一致する | 403(I30 の本文)で保存しない。`pr_ref` も置かない | -| I44 | 進捗の記録 | 実行ジョブの token は取り込むリポジトリ(`repo`、`owner/name` の形)を必ず運ぶ | 無い・形が違う token は無効(`malformed`)で、受け口はどの種類の事象も 401 で保存しない | -| I45 | 進捗の記録 | `runner` の経路で残る `issue.requested` の `repo` は、token の `repo` と大文字と小文字を区別せずに違う | 403 で保存しない | -| I46 | 起票の依頼の台帳 | サービスが Issue を作るのは、依頼の実行が属するアカウントの登録のうち名前の一致するものがあり、その導入で token を作れ、流儀の判定が GitHub Issues を返し、公開範囲が広がらず、秘密らしい値が無く、起票した数が上限より少ないときだけである | どれかが欠ければ起票せず、`drafted` と理由を残す | -| I47 | 起票の依頼の台帳 | 起票に使う導入の token は、起票先の 1 リポジトリ(数の ID)と `issues: write` だけで作り、GitHub の口の中で 1 回使って捨てる。保存しない・返さない・ランナーへ渡さない | GitHub の口の型が token を返さない(`createIssue` は Issue の参照だけを返す) | -| I48 | 起票の依頼の台帳 | 1 つのスプリントの実行で、起票先・題・本文の等しい依頼から作る Issue は 1 件までで、どの依頼にも結果はちょうど 1 件ある | 台帳の行を条件つきで書いてから GitHub を呼ぶ。結果の冪等の鍵は `<依頼の鍵>:resolved` | -| I49 | 起票の依頼の台帳 | 1 つのスプリントの実行で `filed` の数は上限(`ISSUE_REQUEST_LIMIT_PER_RUN`)以下 | 数の行の条件つきの増加が通らなければ起票せず `drafted`(`limit_exceeded`) | -| I50 | 進捗の記録 | 結果の理由と応答は、ほかのアカウントの登録の有無を明かさない | 未登録と「ほかのアカウントだけが登録」を同じ理由 `not_registered` にする | - -### ドメインイベント - -要求の番号を引き継ぐ。E1〜E3 は #2186 が起こし、この変更は E3 の token の中身と、E5・E6・E10 の照合と、E11〜E14 を決める。 - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 利用者がリポジトリを登録した | 取り込み(#2186) | 実行の制御・起票の代行(判定で読む) | -| E2 | スプリントの実行を始めた | 実行の制御(#2186) | 実行の制御(E3) | -| E3 | 実行ジョブの token を発行した(`repo` に E2 のリポジトリを入れる) | 実行の制御(`signRunJobToken`) | ランナー | -| E4 | ランナーが `pull_request` つきの `step.recorded` を送った | ランナー | 進捗の受け口 | -| E5 | 受け口が token を確かめた(`repo` が無ければ 401) | 実行ジョブの token の検証 | 出所の照合 | -| E6 | 受け口が `pull_request.repo` を token の `repo` と照らした(違えば 403) | 出所の照合 | 追記(E7) | -| E7 | 事象を `pr_ref` つきで追記した | 進捗の記録の保存 | GitHub の Webhook の宛先の引き当て(E8)・一覧と詳細(#2172) | -| E8 | GitHub の Webhook を `pr_ref` で宛先へ結び付けた | 受け口の `github` の経路 | 一覧と詳細(#2172) | -| E9 | ランナーが `issue.requested` を送った | ランナー(NDF の書き出した依頼を翻訳) | 進捗の受け口 | -| E10 | 受け口が依頼を記録した(起票先が token の `repo` と同じなら 403) | 出所の照合と追記 | 起票の代行(事象の表のストリーム) | -| E11 | 起票の代行が起票してよいかを判定した | 起票の代行 | E12 か E13 | -| E12 | 起票先へ Issue を作った | 起票の代行(GitHub の口) | GitHub | -| E13 | 下書きを人へ回した | 起票の代行 | 承認と通知(#2173 の知らせ)・一覧と詳細(#2172) | -| E14 | `issue.request_resolved` を直に追記した | 起票の代行(`appendIssueRequestResolved`) | 一覧と詳細(#2172)・承認と通知(#2173、`drafted` のとき) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 実行ジョブの token | 実行の制御が実行ジョブ 1 つごとに発行する、期限つきの token。そのジョブのスプリントの実行にだけ進捗の事象を書け、Pull Request の参照はそのジョブの取り込むリポジトリのものだけを残せる。承認の依頼の答えを読める | 意味は変えない(前回の変更のまま) | -| 起票の依頼 | 実行ジョブの中で NDF が別のリポジトリへの起票を決めたとき、ランナーが進捗の事象 `issue.requested` でサービスへ送る依頼。起票先・題・本文を持ち、ランナーは自分では起票しない | 追加 | -| 起票の依頼の結果 | 起票の依頼 1 件ごとに 1 件残る進捗の事象 `issue.request_resolved`。サービスが起票した Issue の参照か、下書きへ回した理由を持つ | 追加 | -| 起票の代行 | 記録された起票の依頼を読み、起票してよい先へはサービスが 1 リポジトリと Issues の書き込みだけに絞った導入の token で起票し、そうでない先は下書きを人へ回す、サービスの処理 | 追加 | -| 下書き(起票の依頼) | サービスが起票しなかった起票の依頼の題と本文。人が相手先の流儀に沿って起票するために回る | 追加 | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 実行ジョブの token に、そのジョブの取り込むリポジトリを入れて発行する | 実行の制御(#2186) | -| F2 | 取り込むリポジトリを持たない token を無効として拒む | 進捗の受け口・承認と通知(#2173) | -| F3 | 申告された Pull Request のリポジトリが token のものと違う `step.recorded` を拒む | 進捗の受け口 | -| F4 | 別のリポジトリへの起票の依頼を受け付けて記録する(実行のリポジトリ宛ては拒む) | 進捗の受け口(ランナーの送信に当たる) | -| F5 | 依頼を判定し、起票してよい先へ起票する | 起票の代行(自動) | -| F6 | 起票しない依頼を下書きとして人へ回し、結果を残す | 起票の代行 → #2173・#2172 | -| F7 | NDF の別のリポジトリへの起票を、ランナーの中では依頼として書き出す | NDF(`issue-file.py create`)→ ランナー | - -## 構成要素 - -| 要素 | 場所 | 変えること | -| --- | --- | --- | -| 実行ジョブの token | `factory/api/src/progress/run-job-token.ts` | `RunJobTokenClaims` に `repo` を足し、`signRunJobToken` は本体の claim `repo` に入れる。検証は `requiredClaims` に `repo` を足し、形を契約の `RepoFullName` の `pattern`(`progressEventSchema` の `$defs` から読む)で確かめ、違えば `malformed`。`VerifiedRunJob` は `repo` を持つ | -| 出所の照合 | `factory/api/src/progress/authorize.ts` | `WRITABLE_TYPES` の `runner` に `issue.requested`、`control` に `issue.request_resolved` を足す。`authorizeRunner` に I37 と I45 を足す。`authorizeControl` は種類ごとに鍵の形を見る(`issue.request_resolved` は `::resolved` で、`payload.request_key` が `:`) | -| GitHub の翻訳 | `factory/api/src/progress/github.ts` | 名前をそろえる関数 `repoKey`(小文字にする)を置き、`prRef`・照合・起票の代行の要約値が使う | -| 直の追記 | `factory/api/src/progress/append.ts`・`factory/api/src/index.ts` | `appendIssueRequestResolved(event)` を足して export する。`appendControlEvent` と同じく、契約の検査・`control` の照合・追記の 3 つを通り、受けるのは `issue.request_resolved` だけ | -| 契約 | `factory/contracts/schemas/progress-event.schema.json`・`openapi/ingest.yaml` | 種類 `issue.requested` と `issue.request_resolved` を足す(「入出力の契約」)。403 の説明に 2 つの照合を足す | -| 起票の代行 | `factory/api/src/issue-requests/`(新規): `resolve.ts`(判定と流れ)・`ledger.ts`(台帳)・`secrets.ts`(秘密らしい値)・`style.ts`(流儀の判定の口)・`ports.ts`(読む口と GitHub の口の型) | 依頼 1 件を判定し、起票するか下書きにし、結果を追記する(「処理の流れ」)。ほかの集約と GitHub は型の口で受け、テストは偽物 | -| 起票の代行の handler | `factory/api/src/handlers/issue-requests.ts`(新規) | 事象の表の DynamoDB Streams のうち `type` が `issue.requested` の新しい項目を受け、1 件ずつ `resolveIssueRequest` を呼ぶ。ストリームとフィルタの定義はインフラ(private) | -| GitHub の口(#2186) | `factory/api/src/github/` | `createIssue(installationId, repositoryId, { title, body })` と `repositoryVisibility(installationId, repositoryId)` を足す。`createIssue` は `repository_ids: [repositoryId]`・`permissions: { issues: "write" }` で token を作り、その場で Issue を作って捨てる(I47) | -| ランナーの翻訳(#2186) | `factory/runner/src/translate/` | 翻訳の表に `issue.requested` の 1 行を足す。ランナーは `NDF_ISSUE_REQUEST_FILE` を作業のディレクトリの外の決まったパスに置いて conductor へ渡し、伸びた行を事象にする | -| NDF の起票 | `plugins/ndf/scripts/issue-file.py` | `create` で起票先が開発対象リポジトリと別、かつ環境変数 `NDF_ISSUE_REQUEST_FILE` があるとき、承認資料を書かずにそのファイルへ 1 行を足し、`ok`(`items[].result` が `requested`)で 0 を返す(決定 7) | -| 確定仕様 | `docs/specifications/ndf-factory-contracts-and-progress.md`・`ndf-factory-service-boundary.md` | token の表・照合の表・種類の表・不変条件・背景(決定 5 と 6 の理由)・テスト観点。全体の契約の事象の種類と受け手の表に 2 行 | -| 用語集 | `docs/glossary/glossary.json`・`docs/glossary.md` | 4 語を足す(この設計の変更で直す) | - -```mermaid -graph TD - subgraph ランナー側["ランナー(#2186)"] - NDF[NDF の issue-file.py] - TR[翻訳] - end - subgraph 受け口["進捗の受け口"] - TOKEN[token の検証] - AUTH[出所の照合] - STORE[(事象の表)] - end - subgraph 代行["起票の代行"] - H[handler] - RES[判定と流れ] - LED[(台帳)] - STY[流儀の判定の口
#2239] - end - subgraph 制御["#2186"] - RUNS[スプリントの実行と登録を読む関数] - GHP[GitHub の口] - end - NDF -->|依頼の行| TR - TR -->|issue.requested| TOKEN - TOKEN --> AUTH --> STORE - STORE -->|ストリーム| H --> RES - RES --> LED - RES --> STY - RES --> RUNS - RES -->|createIssue| GHP - RES -->|issue.request_resolved| STORE -``` - -図は実行時に動く要素だけを描く。契約・確定仕様・用語集は図に含めない。 - -**文脈**: 外部との出入りは 1 つ増える。起票の代行が GitHub の REST(導入の token の作成と Issue の作成)を呼ぶ。 - -```mermaid -graph LR - RUNNER[ランナー] -->|token つきの進捗の事象| API[Neural Development Factory の API] - GITHUB[GitHub] -->|Webhook| API - API --> DB[(事象の表)] - DB -->|ストリーム| FILER[起票の代行] - FILER -->|導入の token・Issue の作成| GITHUB - FILER --> DB -``` - -**配置**: 起票の代行は受け口と別の Lambda で、事象の表のストリームから起動する。受け口の Lambda の読み出しと GitHub への呼び出しは増えない。 - -## 構造 - -```mermaid -classDiagram - class RunJobTokenClaims { - jobId: string - sprintRunId: string - repo: string - expiresAt: Date - } - class authorize_ts { - authorizeRunner(event, job) boolean - authorizeControl(event) boolean - } - class resolve_ts { - resolveIssueRequest(record, deps) Promise~Resolution~ - } - class IssueRequestDeps { - sprintRunAccount(sprintRunId) - accountRegistrations(accountId) - github: IssueGitHub - style: FilingStyleResolver - ledger: IssueLedger - append(event) - limitPerRun: number - } - class IssueGitHub { - createIssue(installationId, repositoryId, issue) Promise~IssueRef~ - repositoryVisibility(installationId, repositoryId) Promise~Visibility~ - } - class FilingStyleResolver { - resolve(target) Promise~FilingStyle~ - } - class IssueLedger { - claim(sprintRunId, digest, requestKey) Promise~Claim~ - countFiled(sprintRunId, limit) Promise~boolean~ - settle(sprintRunId, digest, outcome) Promise~void~ - } - authorize_ts ..> RunJobTokenClaims : repo を読む - resolve_ts ..> IssueRequestDeps - IssueRequestDeps --> IssueGitHub - IssueRequestDeps --> FilingStyleResolver - IssueRequestDeps --> IssueLedger -``` - -`FilingStyleResolver` の既定は常に `{ tracker: "github_issues", template: null }` を返す(決定 8)。#2239 が同じ型の中身を替える。`sprintRunAccount` と `accountRegistrations` は #2186 が `index.ts` から公開する関数を渡す。 - -## データ構造 - -起票の代行の台帳は DynamoDB の 1 つの表(`ISSUE_REQUESTS_TABLE_NAME`)に置く。読み出しは強い整合で、GSI は持たない。時系列は「今の値の上書き」で、過去は進捗の記録(`issue.requested` と `issue.request_resolved`)が持つ。上書きで失うのは `filing` の時刻だけで、結果の事象の `at` から足りる。 - -| 行 | `pk` | `sk` | 列 | -| --- | --- | --- | --- | -| 依頼 | `RUN#` | `ISSUE#` | `state`(`filing` / `filed` / `drafted`)・`request_key`(最初の依頼の冪等の鍵)・`issue_repo`・`issue_number`・`issue_url`(`filed` のとき)・`reason`(`drafted` のとき)・`attempted_at`(Issue の作成の要求を出す直前に書く。決定 10)・`updated_at` | -| 起票した数 | `RUN#` | `FILED_COUNT` | `count`(I49) | - -`digest` は `{"repo": repoKey(repo), "title": title, "body": body}` を鍵の順にそろえた JSON の sha256 である(NDF の `issue-file.py` の提示の要約値と同じ作り方で、ラベルを含めない)。 - -| 機能 | 依頼の行 | 数の行 | 進捗の記録 | スプリントの実行・登録 | -| --- | --- | --- | --- | --- | -| F4 受け付け | — | — | C(`issue.requested`) | — | -| F5 起票 | C・U | U | C(`issue.request_resolved`) | R | -| F6 下書き | C・U | — | C(`issue.request_resolved`) | R | - -## 入出力の契約 - -| 項目 | 内容 | -| --- | --- | -| 置き場所 | `factory/contracts/schemas/progress-event.schema.json`(事象)・`factory/contracts/openapi/ingest.yaml`(経路と応答)。実行ジョブの token の形は確定仕様の「実行ジョブの token」と `run-job-token.ts` | -| 変わる約束 | (1) `POST /ingest/events` は、`payload.pull_request.repo` が token の `repo` と違う `step.recorded` と、`payload.repo` が token の `repo` と同じ `issue.requested` に 403 を返す。本文は今の 403 と同じ(I30)。(2) 実行ジョブの token の本体に `repo`(必須)が増え、無い token は 401。(3) 種類が 2 つ増える。(4) `appendIssueRequestResolved` を export する | -| 互換性の扱い | 旧い token とは互換を持たない(前提 3)。種類を足すだけで既存の種類の形は変わらないため `schema_version` は 1 のまま。`pull_request` を持たない事象・既存のほかの種類・`api`・`github` の経路・`appendControlEvent` の応答と保存は変わらない | -| 検査の手段 | `cd factory && npm run check`(契約の lint・型検査・全テスト) | - -**token の `repo` に入れる値は、実行ジョブの指示の `REPO` と同じ値である。** 発行する実行の制御(#2186)が両方を同じスプリントの実行から作る。 - -| 種類 | 出所 | `payload` | 主な受け手 | -| --- | --- | --- | --- | -| `issue.requested` | `runner` | `repo`(`RepoFullName`。起票先)・`title`(1〜256 字)・`body`(1〜50,000 字) | 起票の代行・#2172 | -| `issue.request_resolved` | `control`(起票の代行が直に書く) | `request_key`(依頼の冪等の鍵)・`outcome`(`filed` / `drafted`)・`issue`(`{repo, number, url}`。`filed` のときだけ必須、`drafted` では持たない)・`reason`(`drafted` のときだけ必須。下の表)・`duplicate_of`(同じ中身の最初の依頼の鍵か `null`) | #2172・#2173(`drafted` のとき) | - -| `reason` | いつ | -| --- | --- | -| `not_registered` | 依頼の実行のアカウントの登録に、名前が `repoKey` で一致するものが無い(ほかのアカウントだけが登録している場合も同じ。I50) | -| `not_installed` | 登録はあるが、その導入で起票先に絞った token を作れない(App を外した・導入の対象から外した) | -| `unsupported_tracker` | 流儀の判定が GitHub Issues 以外を返した(#2239 の後に起こる) | -| `visibility` | 起票先が実行のリポジトリより広く見える(private < internal < public。決定 11) | -| `secret_suspected` | 題か本文に秘密らしい値がある(決定 12) | -| `limit_exceeded` | 起票した数が上限に達している | -| `filing_failed` | GitHub が Issue の作成を断った・応答が無い・前の試みが途中で止まった | - -**`issue.requested` の冪等の鍵は `runner` のほかの種類と同じ `:` である。** `issue.request_resolved` の鍵は `<依頼の鍵>:resolved`、`job_id` は依頼の `job_id`、`sprint_run_id` は依頼と同じにする。鍵の表は鍵だけで一意を見るため、依頼 1 件に結果は 1 件しか残らない(I48)。 - -**ランナーの翻訳の 1 行**(#2186 の「ランナーの翻訳」の表へ足す): - -| 事象 | NDF の何から | 規則 | -| --- | --- | --- | -| `issue.requested` | `NDF_ISSUE_REQUEST_FILE` の 1 行(`{"repo", "title", "body"}` の JSON) | 値をそのまま写す。題か本文が上限を超える行は送らず、`attention`(`reason` は `issue_request_too_large`)にする | - -## 処理の流れ - -### 受け付け - -照合の順序は今どおり、大きさと JSON(400)→ token(401)→ 契約の定義(400)→ 出所の照合(403)→ 追記である。 - -```mermaid -sequenceDiagram - participant R as ランナー - participant A as 受け口(runner の経路) - participant Z as 出所の照合 - participant D as 事象の表 - R->>A: POST /ingest/events - A->>A: 大きさ・JSON(400)・token(repo が無ければ 401)・契約の定義(400) - A->>Z: authorizeRunner(event, job) - Z->>Z: source・種類・識別子・鍵(I25〜I27) - opt step.recorded で pull_request がある - Z->>Z: repoKey(pull_request.repo) と repoKey(job.repo) が等しい(I37) - end - opt issue.requested - Z->>Z: repoKey(payload.repo) と repoKey(job.repo) が違う(I45) - end - alt どれかが合わない - A-->>R: 403(本文は理由によらず同じ) - end - A->>D: 追記 - A-->>R: 202 か 503 -``` - -### 起票の代行 - -```mermaid -sequenceDiagram - participant S as 事象の表のストリーム - participant F as 起票の代行 - participant L as 台帳 - participant C as #2186 の読む関数 - participant Y as 流儀の判定の口 - participant G as GitHub の口 - S->>F: issue.requested の新しい項目 - F->>L: claim(run, digest, 依頼の鍵) - alt 同じ digest の行がある - L-->>F: 既にある(state と参照) - F->>F: filing なら投げてやり直させる。filed・drafted なら同じ結果に duplicate_of を付ける - else 新しい行(filing) - F->>F: 秘密らしい値 → secret_suspected - F->>C: sprintRunAccount・accountRegistrations - F->>F: 名前の一致が無い → not_registered - F->>Y: resolve(起票先) - F->>F: github_issues でない → unsupported_tracker - F->>G: repositoryVisibility(起票先と実行のリポジトリ) - F->>F: 公開範囲が広がる → visibility - F->>L: 起票した数を上限まで増やす(条件つき) - F->>F: 通らない → limit_exceeded - F->>G: createIssue(1 リポジトリ・issues: write の token) - G-->>F: Issue の参照、または断り(token を作れない → not_installed、ほか → filing_failed) - F->>L: settle(filed か drafted) - end - F->>S: appendIssueRequestResolved(<依頼の鍵>:resolved) -``` - -- **判定の順序は、外へ出す前に安く確かめられるものから並べる。** 秘密の検査は GitHub も表も読まない。数の増加は起票の直前に置き、起票しなかった依頼で枠を使わない。`limit_exceeded` の前に数を増やし、GitHub が断ったら同じトランザクションの外で 1 つ戻す -- **読み出しの失敗(#2186 の関数・GitHub の 5xx の可視性の読み出し)は投げてやり直させる。** 行は `filing` のまま残り、やり直しの 1 回目は自分の行を `filing` として読む。そのため claim は「同じ `request_key` の `filing` なら続きから進める」とし、ほかの `request_key` の `filing` なら投げる -- **Issue の作成の要求を出した後の失敗はやり直さない。** GitHub が作ったのに応答が届かなかった場合に 2 件目を作らないためで、`filing_failed` の下書きにする(決定 10) -- **下書きは結果の事象で人へ回る。** #2173 は `outcome` が `drafted` の `issue.request_resolved` を知らせとして扱い、#2172 は依頼の題と本文を下書きとして見せる(決定 9) - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| セキュリティ | 照合の値はランナーが書き換えられない出所から得る。token と鍵をログ・応答・例外の文に出さない。起票に使う token は 1 リポジトリと Issues の書き込みだけで、保存せず、ランナーへ渡さない。起票する先は依頼の実行のアカウントが登録し App を入れたリポジトリだけ | 照合の値は Ed25519 で署名された token の `repo`。起票の判定はサービスの表(実行と登録)だけを読み、依頼の値はリポジトリの名前を引く鍵にしか使わない。導入の token は GitHub の口の中で作って使い、型が外へ返さない(I47)。本文はログに出さず、ログの列は種類・鍵・結果・理由だけ | 別の鍵の token が 401、`repo` を変えた申告が 403。偽の GitHub の口で token の作成の要求が `repository_ids` 1 つと `issues: write` だけ。結果の事象・ログに token と本文が無い | -| 性能・拡張性 | 受け口は依頼の受け付けで GitHub を呼ばず、ほかの集約を読まない。照合の読み出しは増やさない | 受け口の照合は token と事象の値どうしの比較だけ。起票は別の Lambda がストリームから行う | 照合の関数が保存の口を受け取らない形(型)で縛る | -| 可用性 | 起票の処理が失敗しても、依頼の記録と実行ジョブの進行は止まらない | 受け付けは記録で 202 を返して終わる。ランナーは結果を待たない | 起票の代行の偽物が投げても受け口のテストが 202 のまま | - -## 決定の記録 - -[issues/issue-2216-design-decisions.md](issue-2216-design-decisions.md) にある。 - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1・I37 | `repo` が `org-a/repo-a` の token で、`pull_request.repo` が `org-b/repo-b` の `step.recorded` が 403 と I30 の本文で、事象の表に 1 件も増えない | 照合を外す・`pr_ref` を置いた後に比べると落ちる | -| 受け入れ条件 2 | 1 の後の `org-b/repo-b` の Webhook が 202 で `pr.updated` を残さない | 拒んだ事象の `pr_ref` を書くと落ちる | -| 受け入れ条件 3 | `org-a/repo-a` と `Org-A/Repo-A` のどちらでも 202、`pr_ref` は小文字。token を `Org-A/Repo-A` で発行しても同じ | 大文字と小文字を区別する・照合と `prRef` で別のそろえ方をすると落ちる | -| 受け入れ条件 4・I44 | `repo` の無い・形の違う token は `malformed` で、どの種類も 401 で保存 0 件 | `requiredClaims` から外す・形を確かめないと落ちる | -| 受け入れ条件 5 | 決定 1 で受け口がほかの集約を読まないため対象が無い。テストを書かない | — | -| 受け入れ条件 6 | 既存のテストが、テストの token の道具の既定の `repo` で変わらずに通る | 既定の `repo` を入れ忘れる・`withExpiry` が `repo` を落とすと落ちる | -| 受け入れ条件 7・I30 | 照合で落ちた 403 の本文がほかの 403 と同じで、`repo` の値が応答とログに無い | 理由ごとの文言を返すと落ちる | -| 受け入れ条件 8 | 形の違う `pull_request`・`issue.requested`(題が 257 字・本文が空)は token と違っても 400 | 照合を契約の検査より先にすると落ちる | -| 受け入れ条件 11 | `org-b/repo-b` 宛ての `issue.requested` が 202 で 1 件残る | `runner` の書ける種類に足し忘れると落ちる | -| 受け入れ条件 12・I45 | `org-a/repo-a`・`ORG-A/repo-a` 宛てが 403 で保存 0 件 | 比較の向きを逆にする・大文字と小文字を区別すると落ちる | -| 受け入れ条件 13・I46 | 偽の登録と GitHub の口で、`createIssue` が依頼の題と本文で 1 回呼ばれ、結果が `filed` と Issue の参照を持つ | 名前で引かずに依頼の値を導入へ渡す・結果を書かないと落ちる | -| 受け入れ条件 14・I47 | 偽の GitHub の口が受けた token の作成の要求が、起票先の数の ID 1 つと `{ issues: "write" }` だけ。結果・ログに token の値が無い。ランナー向けの token の `repo` は 1 つのまま | 権限を省く(導入のすべての権限になる)・`repository_ids` を省くと落ちる | -| 受け入れ条件 15・I50 | 登録が無い・ほかのアカウントだけが登録・token を作れない、の 3 つで `createIssue` が呼ばれず、結果が `drafted` と `not_registered` / `not_registered` / `not_installed` | ほかのアカウントの登録を引く・理由を分けると落ちる | -| 受け入れ条件 16・I48 | 同じ中身の依頼を別の `seq`・別のジョブで 2 件流すと `createIssue` は 1 回、2 件目の結果は同じ Issue の参照と `duplicate_of`。同じ依頼を 2 回流すと結果は 1 件 | 台帳を見ずに起票する・結果の鍵を依頼ごとにしないと落ちる | -| 受け入れ条件 17・I49 | 上限 2 にして中身の違う依頼を 3 件流すと、3 件目が `limit_exceeded` で `createIssue` は 2 回。2 件目で GitHub が断ると数が戻り、3 件目は起票される | 数を判定の前に増やしたまま戻さない・条件なしで増やすと落ちる | -| 受け入れ条件 18 | 本文に `AKIA` で始まる 20 字・PEM の始まりの行・`ghp_` の token の形を含む依頼が `secret_suspected` で、ログの列に本文が無い | 題を見ない・ログへ本文を出すと落ちる | -| 受け入れ条件 19 | 既定の流儀の判定の口が `github_issues` とテンプレート無しを返し、偽の口が `redmine` を返すと `unsupported_tracker` | 判定の口を通らずに起票すると落ちる | -| 受け入れ条件 9・20 | 確定仕様の書き換えは文書の変更で、テストを書かない(`.md` の文言を照合するテストは書かない) | — | -| 受け入れ条件 10 | `cd factory && npm run check` が 0 で終わる | — | -| 公開範囲(決定 11) | 起票先が公開・実行のリポジトリが非公開か社内、起票先が社内・実行のリポジトリが非公開で `visibility`、広さが同じか起票先の方が狭いときは起票する | 向きを逆にすると落ちる | -| 途中で止まった試み(決定 10) | 行が同じ `request_key` の `filing` のまま流し直すと続きから進み、`attempted_at` があれば作り直さず `filing_failed` | 印を見ずに作り直すと落ちる | -| NDF の分岐(F7) | `NDF_ISSUE_REQUEST_FILE` があり起票先が別のとき、ファイルに 1 行増え、承認資料を書かず 0。変数が無いときと起票先が同じときは今どおり | 変数が無いのに書き出すと落ちる(pytest、`plugins/ndf/scripts/tests/`) | -| I31(変更) | token の本体の鍵が `aud`・`exp`・`iat`・`iss`・`repo`・`sprint_run_id`・`sub` だけ | `account_id` を載せると落ちる | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2216 | 実装する | — | `factory/api/src/progress/`(`run-job-token.ts`・`authorize.ts`・`github.ts`・`append.ts` とテスト)、`factory/api/src/index.ts`、`factory/api/src/issue-requests/`(新規)、`factory/api/src/handlers/issue-requests.ts`(新規)、`factory/api/src/testing/run-job-tokens.ts`・`ingest.ts`、`factory/contracts/schemas/progress-event.schema.json`・`openapi/ingest.yaml`、`plugins/ndf/scripts/issue-file.py` とテスト、`docs/specifications/ndf-factory-contracts-and-progress.md`・`ndf-factory-service-boundary.md` | -| #2186 | 取り込み先の作業として渡す | #2186 | GitHub の口の 2 関数、スプリントの実行と登録を読む 2 関数の export、ランナーの翻訳の 1 行と `NDF_ISSUE_REQUEST_FILE` | -| #2239 | 流儀の判定の口の中身を渡す | #2239 | `factory/api/src/issue-requests/style.ts` の中身(判定は NDF と 1 か所で共有する形を #2239 が決める) | -| #2173・#2172 | 受け手を渡す | #2173・#2172 | `drafted` の結果の知らせ(#2173)、依頼と結果の見せ方(#2172) | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| #2186 の設計との整合 | 不変条件の番号(I37 と I44 以降)・GitHub の口の 2 関数・読む 2 関数・ランナーの翻訳の 1 行は、#2186 の設計(PR #2236)がこの形で受けることを #2186 の承認ゲート 1 で確かめる | -| 改名の扱い | 照合も登録の引き当ても名前で行う。改名を追う形は #2186 の設計の未決 2 のまま残る | -| ストリームの形 | DynamoDB Streams のフィルタと、失敗したときの再試行の回数と行き先はインフラ(private)が決める。この設計は「投げればやり直す」だけを前提にする | -| 上限の値 | `ISSUE_REQUEST_LIMIT_PER_RUN` の 10 は実測に基づかない。本番の結果の事象で `limit_exceeded` の数を見て見直す | -| 実環境での確認 | 本番へ出した後、2 つのリポジトリを登録したアカウントで、照合と起票と下書きの 3 つをリリース後テストで確かめる(#2186・#2172・#2173 の後) | diff --git a/issues/issue-2216-requirements.md b/issues/issue-2216-requirements.md deleted file mode 100644 index 33292723b..000000000 --- a/issues/issue-2216-requirements.md +++ /dev/null @@ -1,224 +0,0 @@ -# #2216: step.recorded の Pull Request の参照を、その実行に許可されたリポジトリと照合する - -正は課題の本文(#2216)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 依頼(原文) - -起票の時点の本文をそのまま写す(2026-10-31 の要求と受け入れ条件の工程で、本文をこの仕様へ置き換えた)。 - -> ## 背景 -> -> スプリント m2171(#2171)のクロスレビューで、認可の指摘(major)が 1 件見送られた(https://github.com/devbasex/ai-plugins/pull/2209 )。 -> -> > `factory/api/src/progress/authorize.ts` — `step.recorded` の `pull_request` が、その実行に許可されたリポジトリの参照であることを照合してから `pr_ref` を登録してください -> -> 見送った理由: 照合に要る「その実行に許可されたリポジトリ」を、#2171 の受け口は持たない。実行ジョブの token の claims は `job_id`・`sprint_run_id`・期限だけで、リポジトリの登録は #2186 の範囲(#2171 の設計の決定 7 は `pr_ref` をランナーの申告で置くと決めた)。 -> -> ## 何が起きうるか -> -> 正しい token を持つランナーが、自分の実行と関係の無いリポジトリの Pull Request を `step.recorded` で申告すると、その Pull Request の `pr.updated`・`ci.updated`(GitHub の Webhook)が自分のスプリントの実行へ結び付く。他の利用者のリポジトリの Pull Request の状態が、別の実行の進捗に見える。 -> -> いまは factory/ を本番へ出していないため、利用者への影響は無い。 -> -> ## 直し方の候補(#2186 の設計で決める) -> -> | 案 | 内容 | -> | --- | --- | -> | A | 実行ジョブの token に、許可したリポジトリ(`owner/name`)の claim を足し、受け口で `pr_ref` と照合する | -> | B | 受け口がスプリントの実行からリポジトリを引き、照合する | -> -> どちらも確定仕様(`docs/specifications/` の進捗の受け口)と #2171 の設計の決定 7 を変える。 -> -> ## 受け入れ条件 -> -> - 許可されていないリポジトリの Pull Request を申告した `step.recorded` は 403 を返し、`pr_ref` を登録しない -> - 許可されたリポジトリの申告は今どおり 202 で残る -> -> 関連: #2171・#2186 - -(書き手: 起票者、日付: 2026-10-31。スプリント m2171 のクロスレビューで見送った指摘を、作業中の AI が起票した本文である。利用者の指示の原文は含まない。課題のコメント「同じ事象を PR #2209 の作業中に確認した。」(2026-10-31)は書き手を確かめられないため「不明」とし、条件を足さない) - -関連の課題の本文で、この課題の扱いを決めている文: - -> **`step.recorded` の Pull Request の参照を、その実行に許可されたリポジトリと照合する形は、この課題の設計で決める(#2216)。** 照合に要るリポジトリの登録をこの課題が持つためである。 - -(書き手: 起票者、日付: 2026-10-31。#2186 の本文の「依存」の節) - -利用者の指示(設計 PR https://github.com/devbasex/ai-plugins/pull/2233 の承認ゲート 1、2026-10-31、原文): - -> #2233 は「その実行ジョブに許可したリポジトリのものだけに限る」のは良いけど、では別リポジトリの修正が必要な場合にどうするかの案が無いので再考。対象リポジトリの流儀に従ってissueやticketを出すなどする必要があるはず。 - -続く問いへの利用者の答え(2026-10-31): - -- 起票の担い手: **サービスが代わりに起票する**。ランナーは起票の依頼を進捗の事象として送るだけにする。同じアカウントが登録し App を入れたリポジトリなら、サービスが起票専用の token で起票する。権限の無い先は下書きを人へ回す。ランナーの token は 1 リポジトリのまま広げない -- 範囲: **相手先の流儀を読んで起票する仕組みは別 Issue に分ける**(https://github.com/devbasex/ai-plugins/issues/2239 )。この課題は受け口の照合と、別リポジトリの起票の依頼を受け付けて記録し、サービスが起票するか人へ回す経路までを持つ - -(書き手: 利用者、日付: 2026-10-31) - -## 利用者の条件 - -依頼の原文のうち、利用者が書いた条件(設計 PR https://github.com/devbasex/ai-plugins/pull/2233 の承認ゲート 1 の差し戻しと、続く問いへの答え。2026-10-31)。 - -| # | 条件 | この要求での扱い | -| --- | --- | --- | -| U1 | ランナーが申告する Pull Request は、その実行ジョブに許可したリポジトリのものだけに限る(「良い」) | 受け入れ条件 1〜3 | -| U2 | 別のリポジトリの修正が要る場合の経路を持つ。対象のリポジトリの流儀に従って Issue やチケットを出す | 受け入れ条件 11〜19。流儀の判定は #2239 | -| U3 | 起票はサービスが代わりに行う。ランナーは起票の依頼を進捗の事象として送るだけ | 受け入れ条件 11・13 | -| U4 | 同じアカウントが登録し GitHub App を入れたリポジトリなら、サービスが起票専用の token で起票する | 受け入れ条件 13・14 | -| U5 | 権限の無い先(未登録・別のアカウント・App 未導入)は起票せず、下書きを人へ回す | 受け入れ条件 15 | -| U6 | ランナーの token は 1 リポジトリのまま広げない | 受け入れ条件 14 | -| U7 | 相手先の流儀を読んで起票する仕組みは別の課題(#2239)に分ける。この課題は受け口の照合と、起票の依頼を受け付けて記録し、サービスが起票するか人へ回す経路までを持つ | 対象範囲・受け入れ条件 19 | - -## 起票者の推定 - -利用者の条件(U1〜U7)に無く、起票者と作業中の AI が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 許可されていないリポジトリの Pull Request を申告した `step.recorded` は 403 を返し、`pr_ref` を登録しない | 起票の時点の本文の「受け入れ条件」、PR https://github.com/devbasex/ai-plugins/pull/2209 のクロスレビューの指摘(major) | 受け入れ条件 1・2(U1 の具体の形) | -| S2 | 許可されたリポジトリの申告は今どおり 202 で残る | 起票の時点の本文の「受け入れ条件」 | 受け入れ条件 3 | -| S3 | 照合の形(token の claim / スプリントの実行から引く)は #2186 の設計で決める | 起票の時点の本文の「直し方の候補」、#2186 の本文の「依存」 | 未決 1。#2186 の設計(https://github.com/devbasex/ai-plugins/pull/2236 )は token の claim を選んだ | -| S4 | factory/ を本番へ出していないため、利用者への影響は無い | 起票の時点の本文の「何が起きうるか」 | 前提 3 | -| S5 | 直すと確定仕様(進捗の受け口)と #2171 の設計の決定 7 が変わる | 起票の時点の本文の「直し方の候補」 | 受け入れ条件 9 | -| S6 | 同じスプリントの実行で同じ依頼を 2 回送っても Issue は 1 件だけ作る | 作業中の AI の起動指示(2026-10-31)。ランナーの送り直しと再開のジョブで同じ依頼が届くため | 受け入れ条件 16 | -| S7 | 1 つのスプリントの実行がサービスに起票させる件数に上限を置く | 作業中の AI の起動指示(2026-10-31、「依頼の回数の上限」) | 受け入れ条件 17 | -| S8 | 本文に秘密らしい値を含む依頼は起票しない | 作業中の AI の起動指示(2026-10-31、「本文に秘密を載せない」) | 受け入れ条件 18 | - -## 目的 - -- 実行ジョブの token を正しく持つランナーでも、自分のスプリントの実行と関係の無いリポジトリの Pull Request を自分の実行へ結び付けられないようにする。ほかの利用者のリポジトリの Pull Request と CI の状態(`pr.updated`・`ci.updated`)が、別のスプリントの実行の進捗に見えることを防ぐ -- 実行ジョブの中で別のリポジトリの修正が要るとわかったとき、ランナーの権限を広げずに、その修正を相手先へ届ける経路を持つ。サービスが起票してよい先にはサービスが起票し、起票してよいと言えない先は下書きを人へ回す - -## 前提 - -- 前提 1: 照合に使う「そのスプリントの実行に許可されたリポジトリ」は #2186 のリポジトリの登録とスプリントの実行が持つ。この課題の実装は、#2186 がスプリントの実行とリポジトリの結び付き(または token の claim)を作った後に行う -- 前提 2: 1 つのスプリントの実行が扱うリポジトリはちょうど 1 つである(確定仕様 `docs/specifications/ndf-factory-service-boundary.md` の ER 図「リポジトリの登録 ||--o{ スプリントの実行」と、実行ジョブの指示の `REPO` が 1 つであること) -- 前提 3: factory/ は本番へ出していない。受け口の応答が 202 から 403 へ変わる場合について、旧い token・旧いランナーとの互換の経路を持たない -- 前提 4: リポジトリの名前(`owner/name`)は大文字と小文字を区別せずに比べる(GitHub の名前の扱いと、既存の `pr_ref` が小文字にそろえる形に合わせる) -- 前提 5: `pull_request` を持たない `step.recorded` と、ほかの種類の事象の照合は変えない(新しく足す起票の依頼の種類を除く) -- 前提 6: 403 の本文は既存の I30 のとおり理由によらず同じにする(照合で落ちたことを送り手へ明かさない) -- 前提 7: サービスが起票に使う GitHub App は、#2186 の決定 6 のとおり Issues の読み書きの権限を持つ。サービスは App の秘密鍵から、1 つのリポジトリと Issues の書き込みだけに絞った導入の token を作れる(#2186 の GitHub の口) -- 前提 8: 「同じアカウント」は、起票の依頼を送った実行ジョブのスプリントの実行が属するアカウントである。ほかのアカウントの登録の有無は、サービスが起票するかの判定にも、依頼への応答にも使わない(登録の有無を外へ明かさない) - -## 対象範囲 - -含む: -- 進捗の受け口(`POST /ingest/events`、`runner` の経路)で、`step.recorded` の `payload.pull_request.repo` を、その事象のスプリントの実行に許可されたリポジトリと照合する -- 照合に要る情報の受け渡し(token の claim の発行と検証) -- 別のリポジトリの起票の依頼を、進捗の事象の新しい種類として受け付けて記録する(契約 `factory/contracts/` の変更) -- 記録した依頼を読み、サービスが起票してよい先ならサービスが起票し、そうでない先は下書きを人へ回し、どちらにしたかを進捗の事象に残す -- ランナーが NDF の起票の依頼を事象へ翻訳する口(#2186 のランナーの翻訳の表に 1 行)と、NDF がランナーの中で別のリポジトリへの起票を依頼として書き出す形 -- 確定仕様 `docs/specifications/ndf-factory-contracts-and-progress.md`・`ndf-factory-service-boundary.md` の照合の規則・事象の種類・不変条件・決定の書き換え -- 照合と起票の経路のテスト - -含まない: -- リポジトリの登録・GitHub App の導入・スプリントの実行の作成(#2186) -- 相手先の流儀(課題追跡の宣言 `.ndf/project.json` の `issues`・Issue テンプレート・外部チケット)を読む判定(#2239)。この課題は判定を呼ぶ口と、#2239 が入るまでの既定だけを持つ -- 下書きを受けた人への知らせの送り方(Web・Slack。#2173)と、下書きの見せ方(#2172) -- `github` の経路(Webhook)の照合の変更。宛先は今どおり `pr_ref-index` で引く -- 既に保存した事象の `pr_ref` の洗い直し(本番のデータが無い。前提 3) -- スプリントの実行の途中でリポジトリを改名したときの追従(未決 2) -- 作った Issue の番号を依頼の元の課題へ書き戻すこと -- インフラの定義(DynamoDB の表・GSI・ストリーム・秘密。private リポジトリ) - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 利用者がリポジトリを登録した | 利用者の操作(#2186) | 登録されず、スプリントの実行を始められない(#2186) | — | -| E2 | スプリントの実行を始めた(登録したリポジトリに結び付いた) | 利用者の操作(#2186) | 実行が作られない(#2186) | E1 | -| E3 | 実行ジョブの token を発行した | 実行の制御がジョブを起動した(#2186) | ジョブが起動しない(#2186) | E2 | -| E4 | ランナーが `pull_request` つきの `step.recorded` を送った | NDF のステップが Pull Request を作った・更新した | 送信の失敗はランナーの再送(#2186) | E3 | -| E5 | 受け口が token を確かめた | E4・E9 | 401 で保存しない(既存) | E4 か E9 | -| E6 | 受け口が `pull_request.repo` を、そのスプリントの実行に許可されたリポジトリと照合した | E5 の後の出所の照合 | 一致しない・許可されたリポジトリが決まらない → 403 で保存しない(受け入れ条件 1・2・4) | E2、E5 | -| E7 | 事象を `pr_ref` つきで追記した | E6 を通った | 保存の失敗は既存どおり 503 | E6 | -| E8 | GitHub の Webhook を `pr_ref` で宛先のスプリントの実行へ結び付けた | Webhook の受信 | 宛先が 1 つに決まらなければ 202 で保存しない(既存) | E7 | -| E9 | ランナーが別のリポジトリの起票の依頼を送った | NDF が実行ジョブの中で、実行のリポジトリと別のリポジトリへの起票を決めた | 送信の失敗はランナーの再送 | E3 | -| E10 | 受け口が起票の依頼を記録した | E5 の後の出所の照合を通った | 起票先が実行のリポジトリと同じ → 403 で保存しない(受け入れ条件 12)。保存の失敗は 503 | E9 | -| E11 | サービスが起票してよいかを判定した | E10 の記録 | 判定に要る読み出しが失敗 → 判定をやり直す(起票も下書きもまだしない) | E10 | -| E12 | サービスが起票先へ Issue を作った | E11 で起票してよいと決まった | GitHub が断った・応答が無い → 起票せず下書きへ回す(E13) | E11 | -| E13 | 下書きを人へ回した | E11 で起票しないと決まった・E12 の失敗 | — | E11 | -| E14 | 起票の依頼の結果(起票した Issue の参照か、下書きへ回した理由)を進捗の事象に残した | E12・E13 | 保存の失敗は書き直す(同じ依頼に結果は 1 件) | E12 か E13 | - -E6 は E2 の結び付きを仮定する。結び付きが無い状態(#2186 の前)は受け入れ条件 4 が扱う。 - -## 受け入れ条件 - -- [ ] 1. 前提: スプリントの実行 R の許可されたリポジトリが `org-a/repo-a`、R の実行ジョブの正しい token がある - 操作: `payload.pull_request.repo` が `org-b/repo-b` の `step.recorded` を `POST /ingest/events` へ送る - 結果: 403 と `{"error": {"code": "forbidden", "message": "この事象を書く権限がありません"}}` が返り、事象の表に 1 件も増えない(`pr_ref` の付いた項目も増えない) -- [ ] 2. 受け入れ条件 1 のあと、`org-b/repo-b` の Pull Request の `pull_request` の Webhook を送っても、R の事象の列に `pr.updated` が残らない -- [ ] 3. 前提は 1 と同じで、`payload.pull_request.repo` が `org-a/repo-a`(大文字と小文字の違う `Org-A/Repo-A` も含む)の `step.recorded` は 202 で残り、`pr_ref` は `org-a/repo-a#<番号>` になる -- [ ] 4. そのスプリントの実行の許可されたリポジトリが決まらない(token に claim が無い)とき、`pull_request` つきの `step.recorded` は 202 にならず、保存されない(401 か 403。どちらにするかは設計で決める) -- [ ] 5. 受け口がスプリントの実行からリポジトリを引く形を採る場合、引く関数が投げると 503 を返し、保存されない(token の claim を採る場合はこの条件を消し、消した事実を残す) -- [ ] 6. `pull_request` を持たない `step.recorded`、起票の依頼を除くほかの種類の `runner` の事象、`api`・`github` の経路と `appendControlEvent` の応答と保存は変わらない(既存のテストがすべて通る) -- [ ] 7. 403 の本文は既存のほかの 403 と同じで、許可されたリポジトリの名前と照合で落ちたことを含まない(I30) -- [ ] 8. 照合の順序は既存どおり、大きさと JSON(400)→ token(401)→ 契約の定義(400)→ 出所の照合(403)である。契約の定義に合わない `pull_request` と起票の依頼は 400 のまま -- [ ] 9. 確定仕様 `docs/specifications/ndf-factory-contracts-and-progress.md` の `runner` の照合の規則と不変条件に、この照合が書かれ、`pr_ref` をランナーの申告だけで置くとした決定(#2171 の設計の決定 7)の書き換えが理由つきで残る -- [ ] 10. `cd factory && npm run check` が終了コード 0 で終わる -- [ ] 11. 前提: R の許可されたリポジトリが `org-a/repo-a`、R の実行ジョブの正しい token がある - 操作: 起票先が `org-b/repo-b` で、題と本文を持つ起票の依頼の事象を `POST /ingest/events` へ送る - 結果: 202 が返り、R の事象の列に依頼が 1 件残る -- [ ] 12. 前提は 11 と同じで、起票先が `org-a/repo-a`(大文字と小文字の違いも含む)の起票の依頼は 403 で保存されない(実行のリポジトリへはランナーが自分の権限で起票する) -- [ ] 13. 前提: R の属するアカウント A が `org-b/repo-b` を登録しており、その登録の GitHub App の導入が `org-b/repo-b` を含む - 操作: 受け入れ条件 11 の依頼が記録される - 結果: サービスが `org-b/repo-b` に、依頼の題と本文の Issue を 1 件作る。R の事象の列に、依頼の結果が 1 件残り、作った Issue の参照(リポジトリ・番号・URL)を持つ -- [ ] 14. 受け入れ条件 13 で起票に使う token は、GitHub への作成の要求で `org-b/repo-b` の 1 つと Issues の書き込みだけに絞られている。その token の値は事象・ログ・応答に出ず、ランナーへ渡らない。R の実行ジョブの token が運ぶリポジトリは `org-a/repo-a` の 1 つのままで、ランナーへ渡す導入の token も `org-a/repo-a` の 1 つに絞られたまま変わらない -- [ ] 15. 前提: `org-b/repo-b` がアカウント A に登録されていない(ほかのアカウントだけが登録している場合を含む)、または A の登録の App の導入が `org-b/repo-b` を含まない - 操作: 受け入れ条件 11 の依頼が記録される - 結果: Issue は作られない。R の事象の列に、依頼の結果が下書きへ回した理由とともに 1 件残り、依頼の題と本文が下書きとして人へ回る。理由は、ほかのアカウントの登録の有無を明かさない -- [ ] 16. 同じスプリントの実行で、起票先・題・本文の等しい依頼を 2 回記録しても(送り直し・再開のジョブを含む)、作る Issue は 1 件である。2 件目の結果は 1 件目の Issue の参照を持つ -- [ ] 17. 1 つのスプリントの実行でサービスが作る Issue は、設計で決める上限の件数までである。上限を超えた依頼は起票せず、下書きへ回り、結果の理由が上限を示す -- [ ] 18. 本文か題に秘密らしい値(AWS のアクセスキー・PEM の秘密鍵の始まりの行・GitHub の token の形)を含む依頼は起票せず、下書きへ回る。依頼の本文はログに出ない -- [ ] 19. サービスが起票の形(GitHub Issues か、ほかの課題追跡か・テンプレート)を決めるときは、#2239 の判定を呼ぶ 1 つの口を通る。#2239 が入るまでの既定は「GitHub Issues へ、テンプレートを使わず、依頼の題と本文のまま」である -- [ ] 20. 確定仕様(`ndf-factory-contracts-and-progress.md`・`ndf-factory-service-boundary.md`)に、起票の依頼と結果の 2 つの種類・照合・サービスが起票する条件・下書きを回す先が書かれる - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| セキュリティ | 照合に使うリポジトリの値は、ランナーが書き換えられない出所(サービスが署名した token か、サービスの表)から得る。ランナーの申告した値だけで許可を決めない。token の値・鍵をログ・応答・例外の文に出さない(既存の決まり)。起票に使う token は 1 リポジトリと Issues の書き込みだけに絞り、保存せず、ランナーへ渡さない。サービスが起票する先は、依頼を送った実行のアカウントが登録し App を入れたリポジトリだけである(ほかの利用者のリポジトリへ起票できない) | -| 性能・拡張性 | 受け口は起票の依頼の受け付けで GitHub を呼ばず、ほかの集約を読まない(起票は記録の後に別の処理が行う)。照合の読み出しは増やさない | -| 可用性 | 起票の処理が失敗しても、依頼の記録と実行ジョブの進行は止まらない | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 配布物(プラグイン)は、NDF がランナーの中で別のリポジトリへの起票を依頼として書き出す形が増える(ランナーの外の NDF の振る舞いは変わらない)。factory の受け口の応答は、許可されないリポジトリの申告で 202 から 403 へ変わる(本番へ出していないため移行は要らない。前提 3)。実行ジョブの token の claim が増え、#2186 の発行と受け口の検証が変わる。進捗の事象の種類が 2 つ増える | -| データ | 進捗の事象に 2 つの種類が増える。起票の依頼を重ねずに数えるための記録がサービスの表に増える(表の形は設計で決める) | -| 既存の振る舞い | `runner` の経路の `step.recorded` の照合が 1 つ増える。ほかの経路と既存の種類は変わらない | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `cd factory && npm run test -w api`(vitest。受け口のアプリのテストで受け入れ条件 1〜8・11・12、起票の処理の単体テストで 13〜19 を GitHub の口の偽物で確かめる) | -| 静的解析・型検査 | `cd factory && npm run check`(契約の lint・型検査・全テスト) | -| 手動確認 | リリース後テストへ回す: 本番へ出した後、2 つのリポジトリを登録したアカウントで、片方のスプリントの実行のランナーがもう片方の Pull Request を申告しても進捗の画面に出ないこと。同じランナーがもう片方への起票を依頼すると Issue が 1 件でき、登録していないリポジトリへの依頼は下書きとして人へ届くこと(#2186・#2172・#2173 の後) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 照合は `factory/api/src/progress/` に置く。`plugins/` と `.claude-plugin/marketplace.json` は `factory/` を参照しない(`AGENTS.md`、`scripts/check-service-boundary.py`)。インフラの定義と秘密はこのリポジトリに置かない | -| コーディング規約 | 既存の `factory/api` の TypeScript の書き方。型検査は `npm run typecheck -w api` | -| テスト戦略 | 照合の関数の単体テストと、受け口のアプリを通した応答と保存の件数のテスト(既存の `app.test.ts`・`index.test.ts` の形)。起票の処理は GitHub の口と表の偽物で確かめる。`.md` の文言を照合するテストは書かない | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | `cd factory && npm run check`、既存の 403 の本文と照合の順序を保つ | -| 確認してから行う | 認可の規則の変更(共通原則 C2)。承認ゲート 1(設計)と 2(本番への配布)で人が見る。実行ジョブの token の claim の追加(#2186 の発行と合わせる)。サービスが利用者に代わって起票する条件 | -| 行わない | `github` の経路の振る舞いの変更、既存データの洗い直し、#2186 の範囲のリポジトリの登録の実装、ランナーの実行ジョブの token と導入の token の範囲を 1 リポジトリより広げること、相手先の流儀の判定を #2239 と別に書くこと | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 1. 照合の形(token に許可したリポジトリの claim を足す / 受け口がスプリントの実行からリポジトリを引く) | #2186 の設計(承認ゲート 1 で利用者が確かめる) | #2186 の設計の承認 | -| 2. スプリントの実行の途中でリポジトリを改名したとき、新しい名前の申告を許すか(照合を名前でなく GitHub のリポジトリの識別子で行うか) | #2186 の設計 | #2186 の設計の承認 | -| 3. 許可されたリポジトリが決まらないときの応答を 401 と 403 のどちらにするか(受け入れ条件 4) | この課題の設計 | この課題の設計の承認 | -| 4. 起票の依頼と結果の事象の形(種類の名前と `payload`)、サービスが起票する件数の上限、下書きを人へ回す先(#2173 の承認の依頼か知らせか) | この課題の設計 | この課題の設計の承認 | -| 5. 相手先の流儀を読む判定を、NDF とサービスの両方が 1 か所から使う形 | #2239 の設計 | #2239 の設計の承認 | diff --git a/issues/issue-2228-design.md b/issues/issue-2228-design.md index 111b34635..b5a9a4d75 100644 --- a/issues/issue-2228-design.md +++ b/issues/issue-2228-design.md @@ -104,7 +104,7 @@ | `scripts/pr-steps.py` | `upsert` で、宛先(新しく作るなら `--base` か起点、既存なら `baseRefName`)が `merge_closes_issues` に当たれば本文を `defer_closing` に通す。置き換えた指し先を `metrics.deferred_closing` に載せる | | `scripts/supervise_lib/pr.py` | `_publish` で同じ判定と置き換えを行ってから `gh pr create` / `gh pr edit` へ渡す | | `scripts/supervise_lib/sprint_close_steps.py`・`sprint.py`・`release_templates.py` | まとめのプランの並びを `merge →(release-verify)→ retro → close → refine` にする。`close` は `--final-stage 振り返り` を明示し、`retro` の指示に「課題を閉じない(次の close が閉じる)」を足す | -| `tests/fixtures/supervise-contract/plans/close/4-close.json` | まとめのプランの契約の写しを新しい並びに合わせる | +| `plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json` | まとめのプランの契約の写しを新しい並びに合わせる | | `skills/progress-tracking/SKILL.md` の「スプリントを閉じる」 | 閉じる時点の正本。閉じる条件 3・`--final-stage` の値と呼ぶ Skill の対応・閉じる予定の参照・プランの並びを持つ | | `skills/pr/SKILL.md` の「closing keywords は本文だけに書く」 | 既定ブランチ宛では NDF の手順が置き換えることを書き、閉じる時点は正本を指す | | `skills/release-verification/SKILL.md`・`retrospective`・`release`・`merged` と `development-workflow` の references(`pace.md`・`loop.md`・`stage-completeness.md`・`glossary.md`) | 閉じる時点・順序・閉じる条件の写しを外し、正本を指す。順序の説明の文を新しい並びに直す | diff --git a/issues/issue-2231-design.md b/issues/issue-2231-design.md new file mode 100644 index 000000000..c40bfc4e6 --- /dev/null +++ b/issues/issue-2231-design.md @@ -0,0 +1,329 @@ +# 配布: Claude Code と codex へすべてのプラグインを配り、agy・kiro は cross 系の相手役としてだけ呼ぶ(#2231) + +## 目的 + +- **何が壊れているか**: NDF とマーケットプレイスのプラグインを 4 つのランタイム(Claude Code・codex・kiro・agy)へ配っている。agy・kiro のためだけに、導入物(`plugins/ndf/dev.agy/`・ndf と playwright-kit と mcp-* 10 個の `dev.kiro/`)・その生成と検査・スモークテスト・リリース後の導入の確認を保ち、agy・kiro だけで起きる不具合(agy の本文の切り詰め #2163、Kiro の導入の確認の版の誤り #1889 など)が増え続ける +- **誰が困るか**: NDF を保守する開発者(agy・kiro の差分を吸収する層を保つ)。agy・kiro が NDF の工程で担うのは cross-review / cross-refactoring の相手役だけで、配った Skill は相手役の経路で使われない +- **直すと何が成り立つか**: 配布先は Claude Code と codex の 2 つになり、どちらにもすべてのプラグインとすべての Skill が今と同じ形で届く。agy・kiro には何も配らず、cross-review / cross-refactoring は今と同じ起動の経路で agy・kiro を相手役として呼ぶ + +## 適用範囲 + +- **働く範囲**: このリポジトリの配布物(`plugins/` の agy・kiro 向けの部分)と、それを組み立てる・確かめるスクリプト・テスト・CI・リリースの宣言(`.ndf/supervise.json`・`.ndf/pace.json`)・文書。利用者の手元では、この変更を含む版から agy・kiro 向けの新しい版が届かなくなる +- **プロジェクトごとに違うもの**: 無し(配布先は NDF の配布物が持ち、利用者のリポジトリの設定では変えない。相手役に使えるランタイムは今どおり `.ndf/runtimes.json` が決める) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「claude とcodexのみ、すべてのpluginを配布することとします。agy、kiroは配布せず、cross系で呼ばれるだけ。」(2026-10-31、承認ゲート 1) | 利用者の指示の原文(会話。#2231 の本文の先頭に引用) | 配布先は Claude Code と codex だけで、どちらにもすべてのプラグインを配る。agy・kiro は相手役だけ | +| `plugins/ndf/scripts/lib/launch-cli.sh` は agy を `agy --dangerously-skip-permissions --output-format json …`、kiro を `kiro-cli chat --no-interactive --trust-all-tools …` で起動し、`--agent ndf` もプラグインの導入先も指さない | 実測(コードを読んだ) | 相手役の agy・kiro は、agy・kiro へ配った導入物(`dev.agy/`・`.kiro/agents/ndf.json`)を読まない | +| `plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` の `render_review_prompt` はレビュー担当の依頼文を Skill を読まずに完結させる。`plugins/ndf/skills/cross-refactoring/scripts/prepare-worktrees.sh` は `REQUIRED_SKILLS`(`refactoring`・`tdd-cycle`・`quality-gates`)を**ホストの導入先**から担当の worktree(kiro は `.kiro/skills`、agy は `.agents/skills`)へ写す | 実測(コードを読んだ) | 相手役が読む手順書はホスト(Claude Code・codex)の導入先から来る。agy・kiro への配布を止めても、相手役の経路は変わらない | +| `plugins/ndf/dev.agy/install-hooks.sh` は `bash ./scripts/<名前>` を導入先(既定 `~/.gemini/config/plugins/ndf`)の絶対パスへ書き換えて `~/.gemini/config/hooks.json` へ差し込む。`plugins/ndf/dev.kiro/install.sh` は利用者の clone の `plugins/ndf/skills/` へ symlink を張り、`.kiro/agents/ndf.json` の hook は clone の `plugins/ndf/scripts/worktree-guard.sh`・`worktree-session.sh`・`wait-notify.py --runtime kiro` を指す | 実測(コードを読んだ) | 既に導入した agy の手元は導入先の写しだけで動き、リポジトリから `dev.agy/` を消しても切れない。kiro の手元は clone の共通のスクリプトを呼ぶため、それを残せば動き続ける(決定 4) | +| ruleset「protect main and develop」の必須のチェックに `runtime-smoke (kiro)`・`runtime-smoke (agy)` がある(`gh api repos/devbasex/ai-plugins/rulesets/22332172`、2026-10-31) | 実測 | スモークの matrix から 2 つを外すと、ruleset を直すまで結果の返らない必須のチェックが残る(決定 6) | + +要求と受け入れ条件は #2231 の本文にある(コピーは `issues/issue-2231-requirements.md`)。この文書は「どう作るか」だけを扱う。受け入れ条件の番号は本文の「受け入れ条件(方針の変更の後)」の 1〜6 を指す。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 配布(`ndf-distribution`) | 配布先・マニフェスト・導入物・リリースの対象ランタイム | +| cross 系の参加(`cross-review-participants`) | ホスト・相手役・担当の worktree へ写す手順書(`REQUIRED_SKILLS`) | + +cross 系の参加が顧客、配布が供給者の関係(顧客 / 供給者)。cross 系は「ホストの導入先に要る Skill」を宣言し、配布はホスト(Claude Code・codex)へその宣言を満たす一覧を配る。相手役のランタイムへの配布は、cross 系の宣言に含まれない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 配布先 | マニフェストのファイル名(`plugins/*/manifests/<ランタイム>-skills.txt`)・導入物のディレクトリ(`dev.agy/`・`dev.kiro/`)・`.ndf/supervise.json` の `release.runtimes` | 配布先のランタイムの集合(claude・codex) | — | ランタイムの名前 | +| Claude Code と codex の配布物 | `plugins/ndf/manifests/{claude,codex}-skills.txt`・`.claude-plugin/`・`.codex-plugin/`・`hooks/`・`plugins/playwright-kit/manifests/`。**この変更では変えない** | 各プラグインの `plugin.json` | Skill の参照 | 版数・Skill の数 | +| 相手役の起動経路 | `launch-cli.sh`・`launch-reviewer.sh`・`cross-refactoring/scripts/`・`lib/auth.py`・`lib/runtime_policy.py`。**この変更では変えない** | 起動の共通スクリプト(`launch-cli.sh`) | 担当 | ランタイムの名前・CLI の引数 | +| 必須のチェック | ruleset(利用者が承認ゲート 2 で直す)と `.ndf/project.json` の `required_checks` | ruleset | チェック | チェックの名前 | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 配布先 | どのプラグインにも `agy-skills.txt`・`kiro-skills.txt`・`dev.agy/`・`dev.kiro/` が無く、`release.runtimes` は `claude`・`codex` の部分集合である | 新しい pytest(`scripts/tests/test_distribution_runtimes.py`)が落ちる | +| I2 | Claude Code と codex の配布物 | `claude-skills.txt`(49 行)・`codex-skills.txt`(45 行)・各 `.codex-plugin/plugin.json`・`plugins/ndf/hooks/codex.json`・playwright-kit の `claude-skills.txt`・`codex-skills.txt` が変更前と同じである | 実装の PR の差分に現れる。`build-runtime-plugins.sh --check`・`validate-runtime-plugins.sh`・既存の pytest(`test_codex_skill_policies.py` など)が通る | +| I3 | 相手役の起動経路 | agy・kiro を相手役にする起動(`launch-cli.sh` の `agy` / `kiro` の分岐、`prepare-worktrees.sh` の `skill_dir_for`)が変更前と同じに動く | 既存の pytest(`test_lib_launch_cli_runtimes.py`・`cross-review/tests/test_launch_cli_runtimes.py`・`test_launch_agy.py`・`cross-refactoring/tests/test_launch_agy_phases.py`・`test_prepare_worktrees.py`)が落ちる | +| I4 | 配布先 | 既に導入した kiro の手元の hook が呼ぶ共通のスクリプト(`plugins/ndf/scripts/worktree-guard.sh`・`worktree-session.sh`・`wait-notify.py`・`hook.py` の `--runtime kiro`)が残る | 既存の pytest(`worktree/tests/test_guard.py` の `test_kiro_fs_write_is_normalized` など kiro の入力を見るもの)が落ちる | +| I5 | 必須のチェック | ワークフローのジョブと ruleset の必須のチェックが一致する | `required-checks-check`(`scripts/check-required-checks.py`)が落ちる | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 配布先を Claude Code と codex に決めた | 利用者の指示(承認ゲート 1、2026-10-31) | 実装(E2・E3) | +| E2 | agy・kiro 向けの一覧・導入物・生成・検査・スモーク・リリースの対象を外した | 実装 | CI(I1・I2・I5) | +| E3 | ruleset の必須のチェックから `runtime-smoke (kiro)`・`runtime-smoke (agy)` を外した | 利用者(承認ゲート 2) | `required-checks-check`(I5) | +| E4 | 新しい版を配った。agy・kiro 向けの版は届かなくなった | リリース | 既に導入した agy・kiro の利用者(README・CHANGELOG の案内) | +| E5 | Claude Code をホストにした cross-review / cross-refactoring で、agy・kiro が相手役として結果を返した | `/ndf:cross-review`・`/ndf:cross-refactoring` | リリース後テスト(受け入れ条件 3) | +| E6 | 直す必要が無くなった open の課題を棚卸しで閉じた | `/ndf:backlog-refinement` | 課題の一覧(「配布を止めると要らなくなる課題」の表を材料にする) | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 配布先のランタイム | マーケットプレイスのプラグインを導入できる形で届けるランタイム。この変更の後は Claude Code と codex で、相手役のランタイムとは別に決まる | 追加 | +| 相手役 | cross-review のレビュー担当、cross-refactoring の提案・実装の担当として、ホストから CLI プロセスで起動されるランタイム | なし(#2231 の要求のコミットで追加済み。意味を変えない) | +| マニフェスト | `plugins/ndf/manifests/<ランタイム>-skills.txt`。ランタイムごとに配る Skill の一覧で、配布の唯一の基準(playwright-kit も同じ形の `plugins/playwright-kit/manifests/` を持つ) | なし(同上) | +| ホスト | 収束ループを起動している CLI のランタイム | なし(既存の語。意味を変えない) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | Claude Code と codex へ、すべてのプラグインとすべての Skill を今と同じ形で配る(変えない) | Claude Code・codex の利用者 | +| F2 | Claude Code・codex をホストにした cross-review / cross-refactoring で、agy・kiro が相手役を務める(変えない) | Claude Code・codex の利用者 | +| F3 | agy・kiro の導入物が配布物へ戻ったら CI が落とす | NDF の開発者 | +| F4 | 既に agy・kiro へ導入した利用者が、配布の終了を知り、手元を残すか消すかを自分で選べる(README・CHANGELOG の案内) | 既存の agy・kiro の利用者 | + +## 構成要素 + +| 要素 | 責務 | 変え方 | +| --- | --- | --- | +| マニフェスト(`plugins/ndf/manifests/`・`plugins/playwright-kit/manifests/`) | 配布先ごとの Skill の一覧 | `agy-skills.txt`・`kiro-skills.txt`(ndf)と `kiro-skills.txt`(playwright-kit)を消す。claude・codex は変えない(I2) | +| agy の導入物(`plugins/ndf/dev.agy/`) | agy へ渡すプラグインの木 | ディレクトリごと消す(`plugin.json`・`skills/`・`scripts` のリンク・`agents/` 11 本・`hooks.json`・`install-hooks.sh`) | +| kiro の導入物(`plugins/ndf/dev.kiro/`・`plugins/playwright-kit/dev.kiro/`・`plugins/mcp/mcp-*/dev.kiro/` 10 個) | 利用者のリポジトリの `.kiro/` へ配置する | ディレクトリごと消す | +| 生成の手順(`scripts/build-runtime-plugins.sh`・`scripts/lib/agy_agents.py`) | マニフェストから導入物を作る | agy の Skill のリンクとエージェント定義の写し、mcp-* の kiro の installer の生成を外す。`agy_agents.py` を消す。codex の生成(`agents/openai.yaml`・mcp-* の `.codex-plugin`)は変えない | +| 定義の検査(`scripts/validate-runtime-plugins.sh`・`scripts/lib/validate_manifests.py`・`scripts/check-skill-frontmatter.py`・`scripts/check-doc-staleness.py`・`scripts/ci-heavy-skip.py`・`scripts/check-skill-repo-assumptions.py`・`scripts/check-cross-skill-refs.py`) | 版数・Skill 数・一覧の予算・文書の古さの突き合わせ | `dev.agy/plugin.json`・`hooks.json` の検査、Kiro・agy の一覧の予算、ランタイムの名前の表から agy・kiro を外す。相手役の名前(`claude,codex,kiro` の起動の例など)を見る規則は相手役の経路の記述として残す | +| 配布先の検査(新しい pytest `scripts/tests/test_distribution_runtimes.py`) | I1 | 足す | +| ランタイムのスモークテスト(`scripts/runtime-smoke-test.sh`・`scripts/runtime-smoke-latest.py`・`tests/runtime-smoke/`・`.github/workflows/runtime-plugin-smoke*.yml`・`runtime-plugin-authenticated-smoke.yml`) | 導入できることを確かめる | `--runtime` と matrix から `kiro`・`agy` を外す。`Containerfile.{agy,kiro}`・`adapters/{agy,kiro}.sh`・`assertions/assert-kiro-agent.sh` を消し、`cli-versions.env` の `KIRO_*`・`AGY_*` の行を消す。claude・codex の確認は変えない | +| agy・kiro の導入物だけを見るテスト | 生成物の形を固定する | 消す: `scripts/tests/` の `test_agy_agents.py`・`test_agy_distribution.py`・`test_agy_install_hooks.py`・`test_agy_listing_budget.py`・`test_validate_agy_manifest.py`・`test_kiro_installer_project.py`・`test_kiro_mcp_installer_tools.py`・`agy_build_helpers.py`、`plugins/ndf/skills/worktree/tests/test_agy_hooks.py`、`plugins/mcp/mcp-serena/tests/test_serena_lsp_kiro.py`。一部だけ見るもの(`worktree/tests/test_hook_wiring.py`・`plugins/ndf/scripts/tests/test_hook_deadline.py` の `dev.kiro/install.sh` を読む分など)はその部分を外す | +| リリースの宣言と導入の確認(`.ndf/supervise.json`・`.ndf/pace.json`・`plugins/ndf/scripts/release-verification-steps.py`・`plugins/ndf/scripts/release-steps.py`) | リリースの対象ランタイムと版数を持つ箇所 | `release.runtimes` を `["claude", "codex"]` に、`pace.json` の `verify` の `--runtimes` を `claude,codex` にする。`verify_kiro`・`compare_kiro` と `--runtimes` の `kiro` を外し、既定を `claude,codex` にする。版数を持つ箇所から `dev.agy/plugin.json` を外す | +| 必須のチェック(ruleset・`.ndf/project.json` の `required_checks`) | マージを塞ぐチェックの一覧 | `runtime-smoke (kiro)`・`runtime-smoke (agy)` を外す。ruleset は利用者が承認ゲート 2 で直す(決定 6) | +| 既存の kiro の手元が呼ぶ共通のスクリプト(`plugins/ndf/scripts/hook.py`・`hook_lib/worktree.py`・`worktree-guard.sh`・`worktree-session.sh`・`wait-notify.py`) | Claude Code・codex と共有する hook の入口 | 振る舞いは変えない(決定 4)。結線先を `dev.kiro/install.sh`・`dev.agy/hooks.json` と書くコメントだけを「既に導入した手元のため」へ直す | +| 文書(`AGENTS.md`・`CLAUDE.md`・`KIRO.md`・`README.md`・`plugins/ndf/README.md`・`plugins/playwright-kit/README.md`・`plugins/mcp/mcp-*/README.md`・`docs/ndf-plugin-reference.md`・`docs/versioning-and-distribution.md`・`docs/plugin-development-guide.md`・`docs/project-overview.md`・`docs/glossary.md`・`plugins/ndf/skills/AUTHORING.md`・`tests/runtime-smoke/README.md`・`CHANGELOG.md`) | 配布の説明 | 「4 ランタイムへ配る」前提を「Claude Code と codex へ配り、agy・kiro は相手役」へ書き換える。kiro・agy の導入の手順を消す。`KIRO.md` は相手役としての kiro の説明へ縮める(決定 7)。`plugins/ndf/docs/kiro-cli.md` は消す | + +**変えないもの:** 相手役の起動経路(I3)、`plugins/ndf/skills/external-ai/`(`references/cli-agy.md`・`cli-kiro.md` を含む)、`lib/auth.py` の認証確認、`.ndf/runtimes.json` の扱い、`scripts/token-usage.py` の kiro・agy の使用量の読み取り(相手役の使用量)、`.ndf/worktree.json` の `.kiro/`(相手役の設定を置く場所)、Skill の本文と Skill のスクリプトに残るホストとしての kiro・agy の分岐(決定 5)。 + +### 図 + +```mermaid +graph TD + subgraph 配布先 + MC[Claude Code の一覧と配布物] + MX[codex の一覧と配布物] + end + subgraph 消すもの + DA[dev.agy] + DK[各プラグインの dev.kiro] + MA[agy・kiro の一覧] + end + subgraph 相手役の起動経路 + L[launch-cli.sh] + P[prepare-worktrees.sh] + end + H[ホストの導入先] --> P + MC --> H + MX --> H + P --> W[担当の worktree の手順書] + L --> AG[agy CLI] + L --> KI[kiro-cli] + W --> AG + W --> KI + T[配布先の検査] -.落とす.-> DA + T -.落とす.-> DK + T -.落とす.-> MA +``` + +図に含めないもの: 文書・スモークテスト・リリースの宣言(どれも配布先の 2 つだけを書き写す先)と、変えない共通の hook の入口。 + +### 置き場所 + +```text +plugins/ +├── ndf/ +│ ├── manifests/{agy,kiro}-skills.txt # 消す +│ ├── dev.agy/ # 消す +│ ├── dev.kiro/ # 消す +│ ├── docs/kiro-cli.md # 消す +│ └── scripts/release-*.py # kiro と dev.agy の分を外す +├── playwright-kit/ +│ ├── manifests/kiro-skills.txt # 消す +│ └── dev.kiro/ # 消す +└── mcp/mcp-*/dev.kiro/ # 10 個とも消す +scripts/ +├── build-runtime-plugins.sh # agy・kiro の生成を外す +├── lib/agy_agents.py # 消す +├── lib/validate_manifests.py # dev.agy の検査を外す +├── runtime-smoke-test.sh # kiro・agy を外す +├── script-structure-allow/plugins__ndf__dev.kiro__install.sh--lines.json # 消す +└── tests/test_distribution_runtimes.py # 新設(I1) +tests/runtime-smoke/ # agy・kiro の像・adapter・確認を消す +.github/workflows/runtime-plugin-*.yml # matrix から kiro・agy を外す +.ndf/{supervise,pace,project}.json # kiro・agy を外す +``` + +## 処理の流れ + +### 相手役として起動されるとき(変えない) + +```mermaid +sequenceDiagram + participant H as ホスト(Claude Code / codex) + participant P as 準備(prepare-worktrees.sh) + participant L as 起動の共通スクリプト(launch-cli.sh) + participant R as 相手役(agy・kiro) + H->>P: cross-refactoring の初期化 + P->>P: ホストの導入先から REQUIRED_SKILLS を担当の worktree へ写す + H->>L: 依頼文と worktree を渡す + L->>R: CLI プロセスで起動(agy は --add-dir、kiro は --agent を付けない) + R->>R: 依頼文と worktree の中の手順書だけを読む + R-->>H: 結果ファイル +``` + +**agy・kiro へ配った導入物は、この流れのどこにも現れない。** そのため配布を止めても相手役の振る舞いは変わらない(受け入れ条件 3 は既存の pytest とリリース後テストで確かめる)。 + +### 配布物を組み立てて確かめる流れ + +```mermaid +graph LR + M[claude・codex のマニフェスト] --> B[生成の手順] + B --> C[codex の openai.yaml と mcp-* の .codex-plugin] + C --> V[定義の検査] + D[配布先の検査] --> V + C --> S[スモーク claude・codex] + R[release.runtimes claude・codex] --> VI[リリース後の導入の確認] +``` + +実装の順序: 導入物と一覧の削除 → 生成の手順(`--check` が通る形へ) → 定義の検査と配布先の検査 → テストの削除と縮小 → スモークと CI → リリースの宣言と導入の確認 → 文書と CHANGELOG。ruleset の変更は承認ゲート 2 で利用者が行い、その後に `required-checks-check` が通る。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | agy・kiro のためだけに保つものを減らす | 導入物・生成・検査・スモーク・テストを消す | 実装の PR に、消したファイル数と行数(`git diff --shortstat develop`)、CI のスモークのジョブ数(4 → 2)を書く | +| 移行性 | 既に agy・kiro へ導入した利用者の手元を消さない | 決定 4。手元が呼ぶ共通のスクリプトを残し、README・CHANGELOG に配布の終了と消し方を書く | 実装で、一時の HOME・一時のリポジトリに変更前の版を agy・kiro へ入れ、変更後の clone で kiro の hook が動くことと、README の消し方のコマンドを打って結果を確かめる | +| システム環境 | 相手役に使う CLI の版の下限は変えない | `launch-cli.sh` の起動の形と `lib/auth.py` を変えない | 差分に現れないことを見る | + +### 既に導入した利用者の手元 + +| ランタイム | 手元に残るもの | この変更の後どうなるか | README に書く案内 | +| --- | --- | --- | --- | +| agy | `~/.gemini/config/plugins/ndf`(導入の写し)と `~/.gemini/config/hooks.json` の `ndf-worktree` の項目 | 写しの絶対パスを指すため、そのまま動く。新しい版は届かない(`agy plugin install /plugins/ndf/dev.agy` は新しい clone では対象が無く失敗する) | 配布の終了と最後の版。消すときは、写しが残るうちに最後の版のタグの `install-hooks.sh --uninstall` で `hooks.json` の項目を外し、`agy plugin uninstall ndf` を打つ | +| kiro | `.kiro/skills/` の symlink(利用者の clone を指す)・`.kiro/steering/ndf-policies.md`・`.kiro/prompts/` の 6 本・`.kiro/agents/ndf.json`(hook が clone の共通のスクリプトを指す)と、mcp-* の installer が書いた設定 | clone を新しい版へ進めても、Skill の実体と共通のスクリプトが残るためそのまま動く。installer は無くなり、打ち直すと「ファイルが無い」で止まる | 配布の終了と最後の版。残したい利用者は clone を最後の版のタグへ固定できる。消すときのパスとコマンド(`ndf.json` を既定のエージェントにした利用者は `kiro-cli agent set-default kiro_default` で戻してから消す) | + +## 決定の記録 + +### 決定 1: 配布先を利用者の指示どおり Claude Code と codex に限るため、agy・kiro 向けの一覧・導入物・リリースの対象をすべて外す + +`plugins/ndf/manifests/agy-skills.txt`・`kiro-skills.txt`、`plugins/playwright-kit/manifests/kiro-skills.txt`、`plugins/ndf/dev.agy/`、ndf・playwright-kit・mcp-* 10 個の `dev.kiro/` を消し、それを作る・確かめる生成・検査・スモーク・テストと、`.ndf/supervise.json` の `release.runtimes`・`.ndf/pace.json` の導入の確認から kiro を外す(「構成要素」の表)。 + +前の版のこの設計は「codex・agy・kiro へ cross 系をホストとして回せる 8 本だけを配る」(前の決定 1)と「codex・agy・kiro をホストにした cross 系を保証の内に残す」(前の決定 2)を推した。利用者は承認ゲート 1 で次の 2 点を覆した。 + +- **8 本を配る案は採らない。** 8 本は agy・kiro をホストとして回すための一覧だった。利用者は agy・kiro を「cross系で呼ばれるだけ」とし、ホストとしての使い方を残さない。相手役に要る Skill は 0 本である(「あるべき姿の根拠」の 2・3 行目)ため、agy・kiro に配るものは無い +- **codex も絞る案は採らない。** 利用者は codex を Claude Code と同じ配布先にし、すべてのプラグインを配るとした(決定 2) +- **一覧を空のファイルにして導入物の枠を残す案も採らない。** 中身の無い導入物のために生成・検査・スモークを保つことになり、減らしたい費用が残る + +この変更は配布物の取得元を変える操作(P1)に当たる。利用者の指示(承認ゲート 1)と承認ゲート 2 の承認で行う。要求の前の版の未決 1(配る Skill の一覧)・未決 2(ホストの扱い)は、この決定で解消する。 + +根拠: 利用者の指示(2026-10-31)/ P1 / Value 1・Value 6(MVV 版 3) + +### 決定 2: codex の利用者が今と同じものを受け取るため、codex の配布物は変えない + +`codex-skills.txt`(45 行)、`plugins/ndf/.codex-plugin/plugin.json`、`plugins/ndf/hooks/codex.json`(開始時のモデル学習の停止 `training-optout.py` を含む)、codex 向けの生成物(`skills/*/agents/openai.yaml`)、mcp-* 10 個の `.codex-plugin/`、playwright-kit の `codex-skills.txt` を変えない(I2)。前の版の設計が持っていた例外(`cherry-pick-pr`・`deploy` の `openai.yaml` の削除)は無くなる。 + +codex のマニフェストを Claude Code の 49 行へ揃える案は採らない。利用者の指示は「配布は縮めない」で、増やすことは求めていない。差の 4 本(`install-wrapper`・`official-skills-autoloader`・`restart`・`statusline`)は今も codex へ配っていない Skill で、この変更の範囲に無い。 + +根拠: 利用者の指示(2026-10-31)/ 判断の範囲は目の前の変更に限る(共通原則) + +### 決定 3: 相手役の結果を変えないため、agy・kiro を呼ぶ起動経路はコードも引数も変えない + +`plugins/ndf/scripts/lib/launch-cli.sh`、`plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh`、`plugins/ndf/skills/cross-refactoring/scripts/`(`launch-cli.sh`・`prepare-worktrees.sh`・`refactor_lib/`)、`lib/auth.py`、`lib/runtime_policy.py`、`external-ai` の Skill を触らない(I3)。cross-review の既定の母集合(claude・codex・kiro とホスト、agy は `--include agy`)も変えない。 + +相手役の名前を見る検査の規則(`check-skill-repo-assumptions.py` の `claude,codex,kiro` など)は、起動の例として残るものがあるため、検査を直すときに相手役の経路の記述を落とさないかを個別に見る。 + +根拠: 受け入れ条件 3 / Value 2(MVV 版 3) + +### 決定 4: 既に導入した agy・kiro の利用者の手元を壊さないため、手元を消す操作をせず、手元が呼ぶ共通のスクリプトを残し、配布の終了と消し方を案内する + +扱いは「放置して動かし続け、案内する」にする。 + +- **消さない。** installer・スクリプト・リリースの工程のどれも、利用者の `~/.gemini/`・`.kiro/` に触れない(要求の前提・C6) +- **動かし続ける。** agy の手元は導入の写しだけで動くため、何もしなくても動く。kiro の手元の hook は clone の `plugins/ndf/scripts/worktree-guard.sh`・`worktree-session.sh`・`wait-notify.py --runtime kiro` を呼ぶ。これらは Claude Code・codex と共有するため残り、`hook.py`・`hook_lib/worktree.py`・`wait-notify.py` の kiro の分岐も残す(I4) +- **案内する。** `plugins/ndf/README.md` と `CHANGELOG.md` に、agy・kiro への配布を終えたこと・最後に配った版(この変更を含む版の 1 つ前)・手元を残すときの固定のしかた・消すときのコマンド(「既に導入した利用者の手元」の表)を書く。コマンドは書く前に一時の HOME で打って確かめる + +共通のスクリプトの kiro の分岐を消す案は採らない。clone を進めた kiro の利用者で、プロンプトを送るたびに hook が失敗する。手元を消す移行スクリプトを配る案も採らない。利用者の手元を消す操作で、配布を止める版から実行の手段(installer)も無くなる。分岐をいつ消すかは決めず、棚卸しで利用の実態を見て決める。 + +根拠: C6 / 上位の原則(人を守る)/ Value 2(MVV 版 3) + +### 決定 5: 変更の範囲を配布物に限るため、Skill の本文と Skill のスクリプトに残るホストとしての kiro・agy の分岐はこの変更で消さない + +例: `plugins/mcp/mcp-redash/scripts/redash-mcp-config.js` の `syncKiroAgent`、`statusline`・`worktree`・`official-skills-autoloader` の本文の Kiro の記述、cross-review の `docs/01-state-and-review.md` のホストの導入先の候補(`.kiro/skills`・`~/.gemini/config/plugins/ndf`)。どれも Claude Code・codex へ配る Skill の中にあり、agy・kiro へ配らなくなった後は使われない。 + +一緒に消す案は採らない。相手役としての kiro・agy の記述(担当の worktree の `.kiro/skills`、`.agents/skills`)と同じファイルに並び、取り違えると受け入れ条件 3 を壊す。Skill の本文の変更は Claude Code・codex の利用者に届く変更でもあり、配布先を絞るこの変更と分けて確かめる方が戻しやすい。残る分岐は「配布を止めると要らなくなる課題」の #2058 とあわせて棚卸しで扱う。 + +根拠: 判断の範囲は目の前の変更に限る(共通原則)/ Value 1(MVV 版 3) + +### 決定 6: マージを塞ぐチェックを実在するジョブと一致させるため、ruleset の必須のチェックから `runtime-smoke (kiro)`・`runtime-smoke (agy)` を外す操作は承認ゲート 2 で利用者が行う + +実装の PR はスモークの matrix と `.ndf/project.json` の `required_checks` から 2 つを外す。ruleset は利用者が承認ゲート 2 で直す(`scripts/required-checks-allow.json` の `awaiting` の 2 件と同じ扱い)。直すまでは、実装の PR で 2 つのチェックの結果が返らず、`required-checks-check` は ruleset とジョブの食い違いを出す。 + +matrix に `kiro`・`agy` を残して何もしないジョブにする案は採らない。確かめていないものを通ったと見せる。ruleset を実装の担当が直す案も採らない。マージできる条件を変えるリポジトリの設定の変更で、適用の前に利用者の確認を経る。 + +根拠: Value 2(承認を 2 回に寄せる)/ C2(誰が何をできるかが変わる)(MVV 版 3) + +### 決定 7: 指示書の置き場を保つため、`KIRO.md` は消さずに相手役としての kiro の説明へ縮め、`plugins/ndf/docs/kiro-cli.md` は消す + +`KIRO.md` は指示書のチェック(`.ndf/instructions.json`)が読む 3 つの指示書の 1 つで、受け入れ条件 4 が「2 ランタイム配布+相手役 2 つの構成を書く」対象に挙げる。導入の手順を消し、kiro は配布先ではなく相手役として起動されること・起動の形の正本(`external-ai` の `references/cli-kiro.md`)を書く。`plugins/ndf/docs/kiro-cli.md` は kiro へ導入する利用者向けの手引きで、読み手がいなくなるため消し、参照を張り替える。 + +`KIRO.md` を消す案は採らない。指示書のチェックの設定と #1968 の扱いまで変えることになり、範囲を広げる。 + +根拠: 受け入れ条件 4 / Value 7(正本は 1 か所)(MVV 版 3) + +### 決定 8: 配布先の正しさを機械で保つため、agy・kiro の導入物が戻ったら落ちる pytest を置き、agy・kiro の導入物だけを見るテストは消す + +`scripts/tests/test_distribution_runtimes.py` は、`plugins/` の下に `agy-skills.txt`・`kiro-skills.txt`・`dev.agy/`・`dev.kiro/` が無いこと、`.ndf/supervise.json` の `release.runtimes` が `claude`・`codex` の部分集合であることを確かめる(I1)。消すテストは「構成要素」の表のとおりで、消したものの形を固定するテストは見る対象が無くなる。 + +`build-runtime-plugins.sh --check` だけに任せる案は採らない。生成の手順から agy・kiro の部分を消すと、手で足した `dev.kiro/` を見る者がいなくなる。 + +根拠: Value 4 / Value 3(MVV 版 3) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I1 | `test_distribution_runtimes.py` が導入物と一覧の不在と `release.runtimes` を確かめる。`build-runtime-plugins.sh --check`・`validate-runtime-plugins.sh` が agy・kiro の生成物を求めずに通る | `plugins/mcp/mcp-notion/dev.kiro/install.sh` を 1 つ戻すと落ちる。`release.runtimes` に `kiro` を足すと落ちる | +| 受け入れ条件 2・I2 | `git diff develop -- plugins/ndf/manifests/codex-skills.txt plugins/*/.codex-plugin plugins/mcp/*/.codex-plugin plugins/ndf/hooks/codex.json plugins/playwright-kit/manifests/codex-skills.txt` が空で、`build-runtime-plugins.sh --check` が通る。リリース後に `release-verification-steps.py verify-install --runtimes claude,codex` が通る | codex の生成を消すと `--check` か codex のスモークが落ちる | +| 受け入れ条件 3・I3 | 既存の pytest(I3 の行の 5 本)が変更前と同じに通る。リリース後テストで、新しい版を入れた環境から `/ndf:cross-review --include agy` と `/ndf:cross-refactoring ` を 1 回ずつ回し、agy・kiro の結果ファイルが揃う | `launch-cli.sh` の `kiro` の分岐を消すと既存の pytest が落ちる。NO_RESULT が出たら失敗として記録する | +| I4 | `worktree/tests/test_guard.py` の kiro の入力のテストが通る。installer を読むテスト(`test_hook_wiring.py`・`scripts/tests/test_hook_deadline.py` の `dev.kiro/install.sh` の分)は外す | `hook.py` の `RUNTIMES` から `kiro` を消すと落ちる | +| 受け入れ条件 4 | `check-markdown-links.py --root .`・`instructions-check.py --root .`・`check-doc-staleness.py` が通る。文言を照合するテストは書かない(`AGENTS.md`) | 消した `plugins/ndf/docs/kiro-cli.md` への参照を残すとリンク検査が落ちる | +| 受け入れ条件 5・I5 | スモークの matrix が `[claude, codex]` で通り、ruleset を直した後に `required-checks-check` が通る | matrix に `kiro` を残すと、消した adapter が無くて落ちる | +| 受け入れ条件 6 | 「配布を止めると要らなくなる課題」の表が実装の PR の本文に写る | — | +| 退行 | `uv run --frozen --project . --all-extras pytest . -q -n 4` と `claude plugin validate .` が通る | — | + +## 配布を止めると要らなくなる課題 + +受け入れ条件 6 の材料である。`gh issue list --state open --search "agy in:title,body"`(`kiro`・`Antigravity` も同じ)で拾った 31 件から選んだ。閉じるかは棚卸し(`/ndf:backlog-refinement`)が決める。実装の PR では閉じない。 + +| 課題 | 扱いの案 | 理由 | +| --- | --- | --- | +| #2163 agy が NDF の Skill 5 本の本文を先頭 24000 バイトで切る | 閉じる | agy へ Skill を配らない。相手役の agy は担当の worktree の手順書を依頼文の明示パスで読み、Skill として読み込まない | +| #1889 verify-install の Kiro は `--plugins` を見ず ndf の版を読む | 閉じる | `release-verification-steps.py` の `verify_kiro` を消す。関連の #1756 は kiro に限らないため残る | +| #2058 mcp-redash の設定スクリプトが Kiro の `default.json` を tools 無しで作る | 閉じる | Kiro へ mcp-redash を配らない。`syncKiroAgent` の残りを消すかは決定 5 のとおり棚卸しで決める | +| #2115 ndf-plugin-reference を 4 ランタイムの今の構成に揃える | 閉じる候補 | 受け入れ条件 4 で `docs/ndf-plugin-reference.md` を 2 ランタイムの構成へ書き直す。残る食い違いがあるかを棚卸しで見る | + +残すもの(agy・kiro に触れるが、相手役の経路か配布と関わらない): #1603・#1981(相手役の agy の出力の形とモデル)、#1735・#2093(相手役の起動を含むテストの揺れ)、#1960(相手役へ渡す手順書の量)、#1968(`KIRO.md` は指示書として残る。決定 7)、#690(リポジトリの description と topics は 4 ランタイムを前提にしており、閉じずに中身を 2 ランタイムへ直す)、#694・#715・#1142・#1571・#1957・#1979・#1983・#2028・#2049・#2071・#2164・#2176・#2205・#2228・#2288・#158・#2168(本文で agy・kiro に触れるが主題が別)、#2296(MVV の改訂)。 + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2231 | 実装する | — | `plugins/ndf/manifests/`、`plugins/ndf/dev.agy/`、`plugins/ndf/dev.kiro/`、`plugins/ndf/docs/kiro-cli.md`、`plugins/ndf/scripts/release-verification-steps.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/hook_lib/worktree.py`、`plugins/ndf/scripts/progress-record.sh`、`plugins/ndf/skills/worktree/tests/`、`plugins/ndf/skills/release-verification/SKILL.md`、`plugins/ndf/skills/AUTHORING.md`、`plugins/ndf/README.md`、`plugins/playwright-kit/manifests/`、`plugins/playwright-kit/dev.kiro/`、`plugins/playwright-kit/README.md`、`plugins/mcp/`、`scripts/build-runtime-plugins.sh`、`scripts/validate-runtime-plugins.sh`、`scripts/lib/`、`scripts/check-skill-frontmatter.py`、`scripts/check-doc-staleness.py`、`scripts/ci-heavy-skip.py`、`scripts/check-skill-repo-assumptions.py`、`scripts/check-cross-skill-refs.py`、`scripts/runtime-smoke-test.sh`、`scripts/runtime-smoke-latest.py`、`scripts/script-structure-allow/`、`scripts/tests/`、`tests/runtime-smoke/`、`.github/workflows/`、`.ndf/supervise.json`、`.ndf/pace.json`、`.ndf/project.json`、`AGENTS.md`、`CLAUDE.md`、`KIRO.md`、`README.md`、`CHANGELOG.md`、`docs/ndf-plugin-reference.md`、`docs/versioning-and-distribution.md`、`docs/plugin-development-guide.md`、`docs/project-overview.md`、`docs/glossary.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| プロジェクト MVV との食い違い | Mission(Claude Code / Codex / Kiro / agy のどれを使うチームにも開発工程を届ける)が、2 ランタイムへの配布と食い違う。改訂は #2296 で扱う。利用者の指示は MVV より上位のため、この設計は改訂を待たずに進める | +| ruleset の必須のチェックの変更(決定 6) | 承認ゲート 2 で利用者が `runtime-smoke (kiro)`・`runtime-smoke (agy)` を外すまで、実装の PR の 2 つのチェックは結果を返さない | +| 既に導入した利用者の手元が動き続けるか(決定 4) | コードを読んで判断した。agy の写しで hook が動き続けること、clone を進めた kiro で hook が動き続けること、README の消し方のコマンドは、実装で一時の HOME と一時のリポジトリに変更前の版を入れて確かめる | +| 相手役が今と同じ結果を返すか(受け入れ条件 3) | 起動の経路をコードで読み、配布物を使わないと判断した。実物はリリース後テストで確かめる | +| ホストとしての kiro・agy の分岐の扱い(決定 5) | Skill の本文とスクリプトに残る分岐を、いつ・どの課題で消すかは決めていない。棚卸しで決める | +| 確定仕様の記述 | `docs/specifications/`(`ndf-knowledge-and-kiro.md` など)の kiro・agy の導入の記述は、リリース後の確定仕様化で直す。この変更の実装では触らない | diff --git a/issues/issue-2231-requirements.md b/issues/issue-2231-requirements.md new file mode 100644 index 000000000..514cabcce --- /dev/null +++ b/issues/issue-2231-requirements.md @@ -0,0 +1,175 @@ +# #2231: 配布を Claude Code と codex だけにし(すべてのプラグイン)、agy・kiro へは配らず cross 系の相手役だけにする + +正は課題の本文(#2231)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 方針の変更(2026-10-31 夜、承認ゲート 1 で利用者が指示) + +> claude とcodexのみ、すべてのpluginを配布することとします。agy、kiroは配布せず、cross系で呼ばれるだけ。 + +(書き手: 利用者、日付: 2026-10-31。会話の原文) + +- **Claude Code と codex**: マーケットプレイスのすべてのプラグイン(ndf・playwright-kit・mcp-*)を、すべての Skill で配る。codex の配布は縮めない +- **agy と kiro**: 何も配らない(`plugins/ndf/manifests/agy-skills.txt`・`kiro-skills.txt`、`dev.agy/`・各プラグインの `dev.kiro/` の導入物、`release.runtimes` の kiro を外す)。cross-review / cross-refactoring から相手役の CLI として呼ばれる経路だけを保つ +- 下の「依頼(原文)」と受け入れ条件のうち、codex に配布を絞る部分はこの変更で置き換わる。プロジェクト MVV の Mission(4 ランタイム)の改訂は別の課題で扱う + +## 受け入れ条件(方針の変更の後) + +- [ ] 1. `agy-skills.txt`・`kiro-skills.txt` と agy・kiro 向けの導入物(`dev.agy/`、ndf・playwright-kit・mcp-* の `dev.kiro/`)が配布物から無くなり、`release` の対象ランタイムが claude と codex だけになる(マージ前にテストとビルドで確かめる) +- [ ] 2. codex へ配る Skill とプラグインは変更前と同じ(すべて)である(マージ前に manifests とビルドで確かめる) +- [ ] 3. agy・kiro を相手役にした cross-review と cross-refactoring の起動経路(`launch-cli.sh`・`launch-reviewer.sh`)が変わらず通る(マージ前にテスト、リリース後に実物で確かめる) +- [ ] 4. AGENTS.md・CLAUDE.md・KIRO.md・README・`docs/ndf-plugin-reference.md`・`docs/versioning-and-distribution.md`・`docs/plugin-development-guide.md` が 2 ランタイム配布+相手役 2 つの構成を書く(マージ前にリンク検査と指示書のチェックで確かめる) +- [ ] 5. ランタイムのスモークテストと CI から agy・kiro の導入の検査が外れ、codex の検査は残る(マージ前に CI で確かめる) +- [ ] 6. 配布をやめると直す必要が無くなる agy・kiro だけの open の課題の一覧を設計に載せる(閉じるのは棚卸し) + +--- + +## 依頼(原文) + +> **codex・agy・kiro へは、cross-review / cross-refactoring に要る Skill 以外を配布しない。** + +(書き手: 利用者(起票者が「決めたこと(2026-10-31)」として本文へ引用した文。会話の原文は確かめていない)、日付: 2026-10-31) + +> ## 何が困るか +> +> NDF は Claude Code・Codex・Kiro CLI・agy の 4 つのランタイムへ、ほぼ同じ Skill を配っている(`plugins/ndf/manifests/*-skills.txt`)。同じ機能を 4 通りに保つ費用がかかり、ランタイムごとの差分を吸収する層・スモークテスト・不具合(agy の本文の切り詰め #2163 など)が増え続ける。 +> +> Codex・agy・kiro が NDF の工程で担っているのは、cross-review と cross-refactoring の相手役である。工程の本体(supervise・relay・承認ゲート・hook)は Claude Code で動かす。 +> +> ## 直すもの +> +> - `plugins/ndf/manifests/codex-skills.txt`・`agy-skills.txt`・`kiro-skills.txt` を、cross-review / cross-refactoring に要る Skill だけにする(要る Skill の一覧は設計で決める) +> - `dev.agy/`・`dev.kiro/` の agents・hook・steering のうち、その 2 つに要らないものを外す +> - AGENTS.md の「4 ランタイム共通」の前提、各ランタイムの README、`docs/ndf-plugin-reference.md`、`docs/versioning-and-distribution.md` を新しい配布に合わせる +> - ランタイムのスモークテストを、配る Skill に合わせて絞る +> +> ## 受け入れ条件 +> +> - 3 つのマニフェストに、cross-review / cross-refactoring に要らない Skill が無い +> - 絞った後も、codex・agy・kiro を相手役にした cross-review と cross-refactoring が通る +> - 配布を絞ると直す必要が無くなる open の課題(agy・kiro・codex だけで起きる不具合)を棚卸しで閉じる + +(書き手: 起票者、日付: 2026-10-31。上の引用以外は利用者の指示の原文として扱わない) + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | codex・agy・kiro が NDF の工程で担うのは cross-review / cross-refactoring の**相手役**だけで、工程の本体(supervise・relay・承認ゲート・hook)は Claude Code で動かす | 本文「何が困るか」の 2 段落目 | 前提 1。受け入れ条件 1・2 の「要る」の判定基準にする | +| S2 | 要る Skill の一覧は設計で決める | 本文「直すもの」の 1 行目 | 未決 1(設計で決め、承認ゲート 1 で利用者が確かめる) | +| S3 | `dev.agy/`・`dev.kiro/` の agents・hook・steering も、2 つに要らないものを外す(利用者の文は「Skill」だけを言う) | 本文「直すもの」の 2 行目 | 受け入れ条件 3・4 | +| S4 | AGENTS.md・各ランタイムの README・`docs/ndf-plugin-reference.md`・`docs/versioning-and-distribution.md` を新しい配布に合わせる | 本文「直すもの」の 3 行目、方針メモ「4 ランタイム共通の前提(AGENTS.md)を変える」 | 受け入れ条件 6 | +| S5 | ランタイムのスモークテストを配る Skill に合わせて絞る | 本文「直すもの」の 4 行目 | 受け入れ条件 7 | +| S6 | 配布を絞ると直す必要が無くなる open の課題を棚卸しで閉じる | 本文「受け入れ条件」の 3 行目 | 受け入れ条件 9。閉じるのは棚卸しの工程で行い、実装の PR では閉じない | +| S7 | codex・agy・kiro を**ホスト**にして cross-review / cross-refactoring を起動する使い方(`--host codex` など)は残さない | 起票者(この要求を書いた AI)が S1 から推した。本文には書かれていない | 前提 2・未決 2。承認ゲート 1 で利用者が確かめる | + +## 目的 + +- codex・agy・kiro へ配る NDF を、cross-review / cross-refactoring の相手役に要るものだけにし、同じ機能を 4 通りに保つ費用(ランタイムごとの差分を吸収する層・スモークテスト・ランタイムだけで起きる不具合)を減らす +- 絞った後も、Claude Code をホストにした cross-review / cross-refactoring で codex・agy・kiro が相手役として今と同じ結果を返す + +## 前提 + +- 前提 1: Claude Code 以外のランタイムが NDF の工程で担う役割は、cross-review のレビュー担当と cross-refactoring の提案・実装の担当(相手役)だけである(S1) +- 前提 2: cross-review / cross-refactoring のホスト(収束ループを起動する CLI)は Claude Code に限る。codex・agy・kiro をホストにする使い方は、この変更の後は保証しない(S7。誤っても、配る Skill の一覧へ行を足せば戻せる) +- 前提 3: 相手役が読む手順は、ホストの側が担当のworktreeへ置くか、起動の依頼文に直接書く。今の実装では、cross-review のレビュー担当の依頼文は Skill を読まずに完結し(`plugins/ndf/skills/cross-review/scripts/launch-reviewer.sh` の `render_review_prompt`)、cross-refactoring は `refactoring`・`tdd-cycle`・`quality-gates` をホストの導入先から担当のworktreeへ写す(`plugins/ndf/skills/cross-refactoring/scripts/prepare-worktrees.sh`・`refactor_lib/vocabulary.py` の `REQUIRED_SKILLS`)。相手役のランタイムに配った Skill に頼る経路が他に無いことは、設計で確かめる +- 前提 4: Claude Code へ配る Skill(`plugins/ndf/manifests/claude-skills.txt`)は変えない +- 前提 5: 利用者の手元に既に入っている codex・agy・kiro 向けの Skill を、こちらから消す操作はしない。配布物から消えた Skill が手元に残るかは各ランタイムの導入の振る舞いに従い、その扱いを利用者向けの文書に書く + +## 対象範囲 + +含む: +- `plugins/ndf/manifests/codex-skills.txt`・`agy-skills.txt`・`kiro-skills.txt` の中身 +- `plugins/ndf/dev.agy/`・`plugins/ndf/dev.kiro/` のエージェント定義・hook・steering・プロンプトのうち、相手役に要らないもの +- 配布物を組み立てる・確かめるスクリプトとテスト(マニフェストを読むもの、ランタイムのスモークテスト)を、新しい配布に合わせる +- `AGENTS.md` の「4 ランタイム共通」の記述、各ランタイムの README、`docs/ndf-plugin-reference.md`、`docs/versioning-and-distribution.md` +- 配布を絞ると直す必要が無くなる open の課題の一覧(棚卸しへ渡す材料) + +含まない: +- Claude Code へ配る Skill・エージェント・hook の変更 +- cross-review / cross-refactoring 自体の振る舞いの変更(参加者プール・輪番・収束の規則) +- playwright-kit・mcp-* プラグインの配布 +- 一覧にした open の課題を実装の PR で閉じること(閉じるのは棚卸し) +- 利用者の手元の導入済みの Skill を消す操作 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 相手役に要る Skill の一覧を決めた | 設計 | 一覧に漏れがあると E5・E6 で相手役が手順を読めない。受け入れ条件 2 のテストが落とす | — | +| E2 | 3 つのマニフェストを一覧に合わせて絞った | 実装 | 要らない Skill が残る・要る Skill が欠ける。受け入れ条件 1・2 のテストが落とす | E1 | +| E3 | `dev.agy/`・`dev.kiro/` から要らないエージェント・hook・steering を外した | 実装 | 外したものを導入の手順・hook の設定が参照したまま残り、導入が失敗する。受け入れ条件 3・4・5 が落とす | E1 | +| E4 | 利用者が新しい版を codex・agy・kiro へ導入した | リリースと利用者の更新 | 導入の検証(`claude plugin validate .`・ランタイムのスモークテスト)が落ちる | E2・E3 | +| E5 | Claude Code をホストにした cross-review で、codex・agy・kiro がレビュー担当として結果ファイルを返した | `/ndf:cross-review` | 結果ファイルが無い(NO_RESULT)。受け入れ条件 8 の確認で見る | E4 | +| E6 | Claude Code をホストにした cross-refactoring で、codex・agy・kiro が提案・実装の担当として結果を返した | `/ndf:cross-refactoring` | 手順書(`refactoring` など)がworktreeに無く、提案が語彙外で全件降格する。受け入れ条件 8 の確認で見る | E4 | +| E7 | 直す必要が無くなった open の課題を棚卸しで閉じた | `/ndf:backlog-refinement` | 一覧の漏れ。受け入れ条件 9 | E4 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 相手役 | cross-review のレビュー担当、cross-refactoring の提案・実装の担当として、ホストから CLI プロセスで起動されるランタイム | +| ホスト | cross-review / cross-refactoring の収束ループを起動し、修正(`/ndf:fix`)と記録を行う CLI | +| マニフェスト | `plugins/ndf/manifests/<ランタイム>-skills.txt`。ランタイムごとに配る Skill の一覧 | + +## 受け入れ条件 + +- [ ] 1. `plugins/ndf/manifests/codex-skills.txt`・`agy-skills.txt`・`kiro-skills.txt` のどの行も、設計で決めた「相手役に要る Skill の一覧」に含まれる。一覧に無い Skill がマニフェストにあると落ちる検査が CI で動く +- [ ] 2. 相手役が起動時に読む Skill(cross-refactoring の `REQUIRED_SKILLS` など、設計で洗い出したもの)が、ホストの導入先からworktreeへ写せる。欠けると落ちる検査が CI で動く +- [ ] 3. `plugins/ndf/dev.agy/` の配布物(エージェント定義・`hooks.json`・`skills/`・`scripts/`)に、相手役に要らないものが無い。何を残したかと理由は設計文書に表で残る +- [ ] 4. `plugins/ndf/dev.kiro/install.sh` が生成するもの(`.kiro/skills/`・`.kiro/steering/`・`.kiro/agents/ndf.json`)と `dev.kiro/` のプロンプトに、相手役に要らないものが無い +- [ ] 5. `claude plugin validate .` が終了コード 0 で終わり、`python3 scripts/check-skill-frontmatter.py` と `python3 plugins/ndf/scripts/instructions-check.py --root .` が通る +- [ ] 6. `AGENTS.md`・各ランタイムの README・`docs/ndf-plugin-reference.md`・`docs/versioning-and-distribution.md` に「4 ランタイムへほぼ同じ Skill を配る」前提の記述が残らず、codex・agy・kiro が相手役だけを担うことと、配る Skill の一覧の正本(マニフェスト)を指す記述がある +- [ ] 7. ランタイムのスモークテスト(`scripts/runtime-smoke-test.sh` と `.github/workflows/runtime-plugin-smoke*.yml`)が、codex・agy・kiro について配る Skill だけを確かめ、配らない Skill の存在を求めない。CI のスモークのジョブが通る +- [ ] 8. 新しい配布物を導入した環境で、Claude Code をホストに codex・agy・kiro を相手役にした cross-review 1 回と cross-refactoring 1 回が、各相手役の結果ファイルを返して終わる(NO_RESULT が出ない) +- [ ] 9. 配布を絞ると直す必要が無くなる open の課題の一覧(番号と理由)が、実装の PR か本課題に残り、棚卸しで閉じるか残すかが決まる +- [ ] 10. 退行しない: Claude Code 向けのマニフェスト(`claude-skills.txt`)と Claude Code の配布物は変わらず、既存の pytest(`uv run --frozen --project . --all-extras pytest . -q -n 4`)が通る + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | codex・agy・kiro へ配る Skill の数が、変更前(codex 45・agy 45・kiro 46)から減り、減った数を実装の PR に実測で書く(Value 3)。ランタイムのスモークテストの所要(秒)を変更の前後で実装の PR に書く | +| 移行性 | 既に codex・agy・kiro へ NDF を入れた利用者が新しい版へ更新したとき、配布から外れた Skill が手元でどうなるか(消える / 残る)をランタイムごとに確かめ、残る場合の消し方を README に書く。こちらから利用者の手元を消さない(C6) | +| システム環境 | 対象のランタイムは今と同じ(Claude Code・Codex・Kiro CLI・agy)。相手役の起動に使う CLI の版の下限は変えない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。codex・agy・kiro で `$ndf:` などとして呼べた Skill の大半が配布から外れる。互換の経路は持たない(`互換なし`)。Claude Code の公開インタフェースは変わらない | +| データ | 変わらない | +| 既存の振る舞い | codex・agy・kiro をホストにした NDF の工程(`--host codex` など)は保証の外になる(前提 2)。Claude Code をホストにした cross-review / cross-refactoring の振る舞いは変わらない | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| 起動 | `claude plugin validate .`(終了コード 0)。`bash plugins/ndf/dev.kiro/install.sh` を一時ディレクトリで実行し、生成物を見る | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析・型検査 | `python3 scripts/check-skill-frontmatter.py`・`python3 plugins/ndf/scripts/instructions-check.py --root .`・CI の `lint` / `runtime-plugin-validate` / `runtime-plugin-smoke` | +| 手動確認 | リリース後テストで、新しい版を入れた環境から `/ndf:cross-review ` と `/ndf:cross-refactoring ` を 1 回ずつ回し、codex・agy(`--include agy`)・kiro の結果ファイルが揃うことを見る(受け入れ条件 8) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 配る Skill の正本は `plugins/ndf/manifests/`(`CLAUDE.md` の「NDF の Skill 構成」)。Skill の実体は `plugins/ndf/skills/` の 1 か所のまま | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T) | +| テスト戦略 | マニフェストと一覧の突き合わせ・worktreeへ写す Skill の有無は単体のテストで、導入できることはスモークテストで、相手役として動くことはリリース後テストで確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テスト・検査の実行、外したファイルを参照する箇所の掃除 | +| 確認してから行う(承認ゲート 1・2 で人が見る) | 配る Skill の一覧(未決 1)、ホストの役割を残すか(未決 2)、`AGENTS.md` の運用の節の書き換え(C7)、公開インタフェースの縮小 | +| 行わない | Claude Code の配布物の変更、cross 系の振る舞いの変更、open の課題を実装の PR で閉じること、利用者の手元の Skill を消すこと | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 相手役に要る Skill の一覧(前提 3 のとおりなら、相手役のランタイムへ配る Skill は 0 件にもなりうる。その場合にマニフェストを空にするか、cross-review・cross-refactoring だけを残すか) | 設計で案を作り、利用者が承認ゲート 1 で決める | 承認ゲート 1 | +| 2. codex・agy・kiro をホストにする使い方を残すか(残すなら `cross-review`・`cross-refactoring`・`fix`・`pr-review` と cross-refactoring の `REQUIRED_SKILLS` が要る) | 利用者(承認ゲート 1) | 承認ゲート 1 | +| 3. 配布から外れた Skill が利用者の手元に残るか(ランタイムごと) | 設計で実測して決める | 設計 PR | +| 4. プロジェクト MVV の Mission(「Claude Code / Codex / Kiro / agy のどれを使うチームにも、要求から本番の配布までの開発工程を…届け」)と Vision が、この変更の後の配布(codex・agy・kiro は相手役だけ)と食い違う。利用者の決定(2026-10-31)は MVV より上位なので要求は進めるが、MVV の改訂(`development-workflow` の `references/project-mvv.md` の「改訂」)を起こすか | 利用者(承認ゲート 1。改訂は利用者の承認で成立する) | 承認ゲート 1 | diff --git a/issues/issue-2291-design.md b/issues/issue-2291-design.md new file mode 100644 index 000000000..abacbc446 --- /dev/null +++ b/issues/issue-2291-design.md @@ -0,0 +1,315 @@ +# 全体テスト: 手元と PR の CI の両方で 3 分以内に終わり、打ち切ってもプロセスを残さない(#2291) + +## 目的 + +- **何が壊れているか**: 手元の全体テストが約 9 分、PR の `Python tests` が 10 分を超える。上限で打ち切ると、テストが自分の session で起こした子が残る +- **誰が困るか**: テストを使う工程(cross-refactoring の着手前のテスト・最終ゲート・完了判定)を回す conductor と、PR の CI を待つ開発者 +- **直すと何が成り立つか**: 全体テストが 180 秒以内に終わり、工程の時間の枠を本来の作業に回せる。打ち切っても worktree を書き換え続けるプロセスが残らない + +## 適用範囲 + +- **働く範囲**: このリポジトリ(ai-plugins)のテストと CI だけ。配布する NDF の振る舞い(`test-run.py` の結果 JSON・終了コード・打ち切りの手順)は変えない +- **プロジェクトごとに違うもの**: 無し(このリポジトリのテストと `.github/workflows/pytest.yml` だけを変える) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「手元の全体テストを 3 分以内にする」「あまり意味のないテストは徹底的に削る」「PR の pytest の所要時間を 3 分以内にする」 | 利用者の指示の原文 | 目標の秒と、削ることを先に置く方針 | +| 2026-10-30 の手元の全体テスト: 10,483 件・各テストの所要の合計 2,003 秒。0.3 秒以上のテスト 1,366 件で 1,646 秒(82%)、0.3 秒未満の 9,117 件で 357 秒 | 実測(`reports/20261030-150005/report.md` を集計) | 時間は少数の重いテストに集まり、軽いテストを消しても 3 分に届かない | +| 2026-10-31 の pytest 以外の suite: script-structure 29.6 秒・lint 11.0 秒・factory の `npm ci` 15.7 秒と `check` 17.1 秒(load average 15.8。参考値) | 実測(この設計を書くときに測った) | pytest に回せる秒の見当 | +| `supervise.py --help` の起動 0.43 秒・`relay.py --help` 0.24 秒・素の python 0.015 秒(load average 約 17) | 実測(同上) | 子のプロセスでスクリプトを起こすテスト 1 件ごとに、起動だけで 0.2〜0.4 秒かかる | + +要求と受け入れ条件は #2291 の本文にある(コピーは `issues/issue-2291-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | 全体テスト・範囲テスト・全体テストの実測。この変更はこのリポジトリの全体テストの中身と CI だけを変え、語の意味は変えない | + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 全体テストの実測の記録 | 計測のスクリプト(実験版) | 1 回の計測 | suite ごとの実行 | 開始時の load average・suite ごとの秒・壁時計・件数・各テストの所要の合計・遅いテストの表 | +| テストの台帳 | 実装の担当(`issues/issue-2291-test-ledger.md`) | 着手前に既定の実行で走っていたテストの集合 | テスト 1 件(nodeid) | 処置(消した・まとめた・速くした・そのまま)・当たった基準・守るテスト・着手前後の秒 | +| テストのプロセスの木 | 根の conftest が読み込む後始末の部品 | pytest のプロセス(親・xdist の worker) | テストが起こした子孫のプロセス | 止める契機(SIGTERM・終了) | +| PR の CI の実行 | `.github/workflows/pytest.yml` | 1 つの PR の `Python tests` の実行 | 分割のジョブ・集約のジョブ `pytest` | 取り消しの単位(PR の番号) | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | テストの台帳 | 着手前に既定の実行で走っていたテストは、着手後も走るか、台帳に当たった基準と守るテスト(または守らなくてよい理由)つきで載る | 台帳の照合が終了コード 1 で落ち、載っていない nodeid を出す | +| I2 | テストの台帳 | 着手前に既定の実行で走っていたテストが、着手後に skip・deselect に変わらない | 台帳の照合が終了コード 1 で落ち、変わった nodeid を出す | +| I3 | 全体テストの実測の記録 | 受け入れ条件 1 の判定に使う計測は、開始時の 1 分の load average が CPU の数以下である | その計測を「参考」として残し、判定に数えない | +| I4 | テストのプロセスの木 | pytest のどのプロセスが SIGTERM で止められても、テストが起こした子孫は 10 秒以内に 0 件になる。自分の session を作った子と、親が先に終わった孫も数える | 後始末の部品のテストが落ちる | +| I5 | PR の CI の実行 | 同じ PR の `Python tests` は、新しい push の後に古い実行が進み続けない | 取り消しの確かめ(受け入れ条件 9)が落ちる | +| I6 | PR の CI の実行 | 集約のジョブの名前 `pytest` と、分割のジョブの失敗・取り消しを失敗として返す判定は変わらない | `required-checks-check` か受け入れ条件 9 の確かめが落ちる | +| I7 | テストの台帳 | テストの対象のコード(プラグインの振る舞い)を変えない。変えてよいのはテスト・テストの道具・根の conftest・CI・文書・実験版だけである | 差分のパスの確かめが落ちる。必要になったら止めて人へ戻す | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 着手前の遅いテストの表と件数・合計時間を測った | 計測のスクリプト | テストの台帳(着手前の集合)・E2・E4 | +| E2 | 削る基準を文書にした | 実装の担当(`docs/plugin-development-guide.md`) | E3・テストを後から書く開発者 | +| E3 | 基準に当たるテストを消した・まとめた | 実装の担当 | テストの台帳 | +| E4 | 遅いテストを速くした | 実装の担当 | テストの台帳 | +| E5 | 全体テストを上限で打ち切った | `test-run.py whole --limit`・cross-refactoring の着手前のテスト | テストのプロセスの木(SIGTERM) | +| E6 | 打ち切った全体テストのプロセスがすべて止まった | 後始末の部品 | 打ち切った側(`stop_group` の猶予の中で終わる) | +| E7 | CI の pytest が新しい push で走り直し、同じ PR の古い実行が止まった | GitHub Actions の concurrency | 必須のチェック `pytest`(新しい実行の結果だけが head に付く) | +| E8 | 着手後の全体テストを 3 回測った | 計測のスクリプト | 実装の PR(受け入れ条件 1・2)・テストの台帳の照合 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 全体テスト | 用語集のとおり。この変更では `.ndf/project.json` の `test.suites` の全部 | 変更なし | +| 全体テストの実測 | 用語集のとおり | 変更なし | +| 着手前のテスト | 用語集のとおり | 変更なし | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | テストを削る基準を読んで、テストを書く・消すときに当てる | テストを書く開発者と AI | +| F2 | 全体テストを前提 1〜3 の条件で測り、件数・所要・遅いテストの表を得る | 実装の担当(着手前・着手後) | +| F3 | 着手前後のテストの集合を照らし、台帳に載っていない消えたテストと skip に変わったテストを出す | 実装の担当・レビュー | +| F4 | 全体テストが 180 秒以内に終わる(消す・まとめる・速くする) | テストを使う工程と開発者 | +| F5 | 全体テストを打ち切っても、テストが起こしたプロセスが残らない | テストを使う工程と開発者 | +| F6 | PR の `Python tests` が 180 秒以内に結果を出し、新しい push で古い実行が止まる | PR を待つ開発者と merge-when-green | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 削る基準(`docs/plugin-development-guide.md` の「検証とテスト」に節を足す) | 決定 2 の 6 つの基準と、消すときに台帳へ残すもの。実装の細部の判定は `tdd-cycle` の `references/test-quality.md` を指し、写さない | +| 計測のスクリプト(`plugins/ndf/scripts/experimental/whole-test-timing.py`、実験版) | `measure`: load average を読み、宣言の suite を順に流して秒を測り、pytest の junit から件数・合計・遅いテストの上位 30 件・ファイルの上位 10 本・nodeid ごとの結果を JSON に書く。`diff`: 着手前後の JSON と台帳を照らす(I1・I2) | +| テストの台帳(`issues/issue-2291-test-ledger.md`) | 着手前後の実測・遅いテストの表と処置・消したテストの 1 件ごとの基準と守るテスト。PR 本文の上限を避けて置き、PR 本文から指す | +| 後始末の部品(`scripts/pytest_child_cleanup.py`、根の `conftest.py` が `pytest_plugins` で読む) | pytest の親と各 worker を子の回収役にし、付け替わって終わった孫を回収し、SIGTERM と終了の時点で子孫を止める。止めるのは `plugins/ndf/scripts/lib/procs.py` の `child_pids` と `stop_tree` で、`child_pids(os.getpid(), recursive=False)` の各 pid へ `stop_tree` をかけ、自分の pid には送らない | +| 重いテストの書き直し(`plugins/ndf/scripts/tests/`・`plugins/ndf/skills/*/tests/`・`scripts/tests/`) | 決定 3 の 3 つの手段で、遅いテストを消す・まとめる・速くする | +| `Python tests` のワークフロー(`.github/workflows/pytest.yml`) | PR ごとの取り消し(決定 5)と、準備の短縮(決定 6)。分割の数・集約のジョブは変えない | +| 実験版の台帳(`docs/ndf-experiments.md`) | 計測のスクリプトの 1 行(使った結果と行き先) | + +型は足さない。計測のスクリプトと後始末の部品は関数だけでできており、クラス図の対象が無い。 + +```mermaid +graph TD + subgraph 文書 + G[削る基準] + L[テストの台帳] + end + subgraph 手元の計測 + M[計測のスクリプト] + end + subgraph テストの実行 + C[根の conftest] + R[後始末の部品] + P[procs の木の停止] + T[重いテストの書き直し] + end + subgraph CI + W[Python tests のワークフロー] + end + G --> T + T --> L + M --> L + C --> R --> P + W --> C + X[実験版の台帳] --> M +``` + +## 処理の流れ + +### 実装の順序 + +```mermaid +graph TD + A[E1 着手前を測る] --> B[E2 削る基準を書く] + B --> C[E3 消す・まとめる] + A --> D[E4 速くする] + C --> E[台帳の照合] + D --> E + E -->|載っていない nodeid がある| C + E -->|0 件| F[E8 着手後を 3 回測る] + F -->|3 回とも 180 秒以内| H[PR に実測と台帳を載せ、実験版の台帳に 1 行] + F -->|届かない| I[届いた値と差を PR に書き、未決 1 で人へ戻す] + J[後始末の部品] --> F + K[ワークフローの取り消しと短縮] --> H +``` + +**後始末の部品は着手前の計測より先に入れてよい。** 計測が高負荷で打ち切られたときに子が残らないようにするためで、テストの件数と所要には効かない。 + +### 打ち切ったときの後始末 + +```mermaid +sequenceDiagram + participant N as 打ち切る側 + participant P as pytest の親 + participant W as worker + participant S as 自分の session の子 + N->>P: SIGTERM(グループへ) + N->>W: SIGTERM(グループへ) + W->>W: 子孫を列挙する + W->>S: SIGTERM、猶予 2 秒の後も残れば SIGKILL + W-->>N: 既定の SIGTERM で終わる + P->>P: 子孫を列挙して残りを止める + P-->>N: 既定の SIGTERM で終わる + Note over N: stop_group の猶予 5 秒の中で終わる。
残ればグループへ SIGKILL +``` + +- 親だけが SIGTERM を受けたとき(`kill -TERM <親の pid>`、受け入れ条件 7)は、worker は SIGTERM を受けない。親が子孫の木(worker とその子孫)をまとめて止める +- 子の回収役にしてあるため、親(中継など)が先に終わった孫は init ではなく最も近い pytest のプロセスへ付け替わり、列挙から漏れない +- 付け替わった孫が終わると、その pytest のプロセスが回収しない限りゾンビとして残る。ゾンビはグループの存否(`os.killpg(pgid, 0)`)と `os.kill(pid, 0)` を成功させるため、打ち切る側の `stop_group` が猶予の 5 秒を毎回待ち切る。そこで各プロセスは SIGCHLD を受けるたびに、自分が起こした覚えの無い直接の子のゾンビだけを回収する(決定 4) +- 正常に終わったとき(`pytest_unconfigure`)も、各プロセスは残った子孫を止める。テストが止め忘れた子を次のテストへ持ち越さない +- 子の回収役にできない環境(Linux 以外)では何もしない。テストの結果は変えない + +### PR の CI + +```mermaid +sequenceDiagram + participant D as 開発者 + participant A as 古い実行 + participant B as 新しい実行 + participant H as head のチェック pytest + D->>A: push 1 + D->>B: push 2(同じ PR) + B->>A: 同じ concurrency の組なので取り消す + A-->>A: 分割のジョブが cancelled + B->>H: 分割のジョブが通れば成功を付ける + Note over A,H: 古い実行の結果は古いコミットに付き、
head のチェックには付かない +``` + +`main` / `develop` への push は取り消さない(マージ後の確認の記録をコミットごとに残す)。push の実行は実行ごとに別の組(`github.run_id`)へ入れ、同じ組で待っている前の実行が取り消されないようにする(決定 5)。 + +## 入出力の契約 + +### 計測のスクリプト + +| 副命令 | 入力 | 出力 | 終了コード | +| --- | --- | --- | --- | +| `measure --root --out ` | `.ndf/project.json` の `test.suites` | NDF の結果の形(`tool`・`status`・`summary`・`items`・`metrics`)。`metrics` に `load_average_1m`・`cpus`・`reference`(load average が CPU の数を超えた)・`wall_seconds`・suite ごとの `seconds` と終了コード・`tests`・`sum_seconds`・`skipped`。`items` に遅いテストの上位 30 件とファイルの上位 10 本。`--out` へ nodeid ごとの結果と秒を書く | 0: 全 suite が通った / 1: どれかが落ちた / 2: 宣言か junit を読めない | +| `diff --before --after --ledger ` | 2 つの `--out` と台帳 | 消えた nodeid のうち台帳の表に無いもの・skip に変わった nodeid・着手前後の件数と合計 | 0: どちらも 0 件 / 1: どちらかがある / 2: 読めない | + +- 壁時計は最初の suite の起動から最後の suite の終了までで、`uv` の仮想環境を作る時間は含めない(前提 3)。suite のコマンドは宣言のまま流し、足すのは何も無い +- 1 本あたりの秒は junit の `time`(準備・本体・後始末の和)を使う。受け入れ条件 3 の `pytest --durations=30` と同じ並びを、計測をやり直さずに得るためである(決定 7) + +### 後始末の部品 + +| 契機 | 振る舞い | +| --- | --- | +| 起動(`pytest_configure`) | 親と各 worker を子の回収役にする。`subprocess.Popen` と `os.fork` を包み、自分が起こした pid を記録し始める。SIGCHLD の扱いを置く | +| 子の起動と記録(包んだ `subprocess.Popen` と `os.fork`) | 元の `Popen.__init__` / `os.fork` を呼ぶ前に、`threading.Lock` で守った「起動中の数」を 1 増やす。元の呼び出しが戻って pid を記録し終えたら(失敗して例外になったときも `finally` で)1 減らす。0 に戻ったときに「見送った回収」の印があれば、印を消して SIGCHLD と同じ回収をその場で 1 回やり直す。`os.fork` の子の側では、起動中の数・印・記録を空にしてから戻る(子は別のプロセスで、親の記録を持ち越さない) | +| SIGCHLD | 起動中の数が 1 以上なら回収せず、「見送った回収」の印を立てて戻る(起動から記録までの間に終わった自分の子は、まだ記録に無いため「覚えの無いゾンビ」に見え、回収すると `Popen._try_wait` が ECHILD を受けて終了コードを 0 にする)。SIGCHLD の扱いは主スレッドで走り、主スレッドの包んだ呼び出しの途中にも割り込むため、ロックを待たない(`acquire(blocking=False)` が取れなければ印を立てて戻る。待つと自分が持つロックで止まる)。起動中の数が 0 なら、直接の子(`child_pids(os.getpid(), recursive=False)`)のうち、ゾンビで、かつ自分が起こした pid の記録に無いものだけを `os.waitpid(pid, os.WNOHANG)` で 1 つずつ回収する。`os.waitpid(-1, ...)` は使わない(`subprocess.Popen` や `os.fork` の呼び手が待つ終了コードを横取りする) | +| SIGTERM | 直接の子(`child_pids(os.getpid(), recursive=False)`)の各 pid へ `stop_tree` をかけ、自分の pid には送らない(`stop_tree` は pid 自身とそのグループも止めるため、自分へ向けると自分の SIGTERM の扱いへ入り直すか、自分を SIGKILL で止める)。止める pid が複数あるときは、猶予の 2 秒を pid ごとに順に待たず、各 pid の `stop_tree(pid, grace=2)` をスレッドで並べて呼び、全部の終わりをまとめて待つ(`stop_group` の猶予 5 秒に収める。`stop_tree` と同じ役割の関数は作らない)。その後、既定の SIGTERM の扱いへ戻して自分へ送り直す。終了の仕方は今と同じ | +| 終了(`pytest_unconfigure`) | SIGTERM と同じ呼び方で残った子孫を止め、記録に無いゾンビを回収し、包んだ `subprocess.Popen` と `os.fork`・SIGCHLD の扱いを元へ戻す | + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 手元の全体テストが 180 秒以内(受け入れ条件 1) | 決定 1 の予算で、各テストの所要の合計を減らす(決定 2・3) | 計測のスクリプトの `measure` を load average 8 以下で 3 回 | +| 性能・拡張性 | PR の pytest の CI が 180 秒以内(受け入れ条件 8) | 手元と同じ削減に、uv のキャッシュで準備を縮める(決定 6) | 実装の PR の 3 回の run を `gh run view <番号> --json startedAt,jobs` で読む | +| 運用・保守性 | 打ち切ったテストのプロセスが残らない(受け入れ条件 6・7) | 後始末の部品(決定 4) | `test-run.py whole --limit 30` と `kill -TERM` の後、10 秒後に `pgrep -f ` が 0 件 | +| 運用・保守性 | 削る基準が文書にあり、後からテストを書く側が参照できる(受け入れ条件 4) | `docs/plugin-development-guide.md` の節(決定 2) | 台帳の各行の基準の番号が節の番号と一致する | +| システム環境 | 並列の数は `-n 4` のまま。ほかの作業と並んでもメモリを使い切らない | 並列の数を変えない。重い準備の共有はプロセスごとに 1 つまで(決定 3) | 宣言の `command` の差分が無いこと | + +## 決定の記録 + +### 決定 1: 3 分に入る道筋を先に数で持つため、suite ごとに秒の予算を割り、pytest は各テストの所要の合計を約 460 秒へ減らす + +pytest 以外の 3 つの suite は、高負荷の下で計 73 秒、CI の経過時間で計 55 秒かかる。着手前の計測(E1)で負荷 8 以下の値を得て、pytest の予算を「180 秒 − 3 つの suite の秒」と決める。3 つの suite が 50 秒なら pytest は 130 秒で、収集の固定の秒を 15 秒とみると、`-n 4` で各テストの所要の合計を約 460 秒(今の 2,003 秒の 23%)にする。2026-10-30 の実測では 0.3 秒未満の 9,117 件だけで 357 秒あるため、0.3 秒以上の 1,366 件(1,646 秒)のほとんどを消すか 0.3 秒未満にしなければ届かない。そこで「0.3 秒以上のテストとして残してよいのは合計 120 秒まで」を実装の目安にし、その枠は CLI の配線を確かめる代表の起動に使う。予算は余白を足さずに置く。 + +pytest 以外の suite(script-structure など)を速くする作業は、E1 の値で予算が足りないと分かったときだけ行う。先に手を付けると、効きの大きい pytest より前に時間を使う。 + +根拠: Value 3 / Value 10(MVV 版 3) + +### 決定 2: テストを書く側が後から当てられるよう、削る基準を 6 つにして `docs/plugin-development-guide.md` に置く + +基準は #2204 の 5 つ(重複・消えた機能の残骸・`.md` の文言の固定・実装の細部の固定・総当たり)に、6 つ目「suite・CI のジョブが既に走らせる検査を、テストの中でもう一度走らせる」を足す。`scripts/tests/test_check_script_structure.py::test_repository_matches_its_allow_list`(8.1 秒)は suite `script-structure` と同じ検査をリポジトリ全体へ流しており、重複の基準では「同じ経路を別の名前で」に読めず漏れるためである。置き場は、テストの書き方の案内がある開発ガイドの「検証とテスト」とする。`AGENTS.md` には置かない。`AGENTS.md` は判断の基準だけを持ち、全セッションが毎回読むため、6 つの基準と台帳の書き方は重い。実装の細部の固定の判定(「振る舞いを変えずに実装を書き換えたら落ちるか」)は NDF の `tdd-cycle` の `references/test-quality.md` が持つので、節はそこを指して写さない。 + +総当たりの基準は「代表の組み合わせで足りる」とだけ書かず、代表の選び方を「分岐ごとに 1 つ・境界の両側」と書く。選び方が無いと、どこまで削ってよいかを毎回議論することになる。 + +根拠: Value 7 / Value 8(MVV 版 3) + +### 決定 3: 確かめる振る舞いを弱めずに速くするため、手段を 3 つに絞り、テストの対象のコードは変えない + +手段は次の 3 つとする。(1) 待ちの値を、対象のコードが既に持つ環境変数で縮める(例: 中継の `NDF_RELAY_QUIET`・`NDF_RELAY_POLL`)。(2) 1 つの CLI の配線(引数・終了コード・結果 JSON)は代表の数件の起動で確かめ、分岐はプロセスの中で関数を呼んで確かめる。スクリプトの起動だけで 1 件 0.2〜0.4 秒かかり、分岐の数だけ起動すると予算に入らない。(3) git のリポジトリのような重い準備は、プロセスごとに 1 度作って複製する。 + +テストの対象のコードを速くする案(`supervise.py` の import を遅らせて起動を縮めるなど)は採らない。要求の対象範囲が対象のコードの変更を外しており、利用者の CLI の振る舞いに触れる変更をテストの短縮と同じ PR に混ぜることになる。3 つの手段で 180 秒に届かないときは、届いた値と差を PR に書き、未決 1 に従って人へ戻す。その報告に、起動の短縮を次の候補として添える。 + +根拠: Value 1 / Value 10(MVV 版 3) + +### 決定 4: NDF を通さない打ち切りでも子を残さないため、後始末を根の conftest が読む部品に置き、pytest の各プロセスを子の回収役にする + +子が残るのは、テストが自分の session で起こした子がプロセスグループの外に出るためで、打ち切る側(`test_triage.run_command`・`refactor_lib.process.run_with_timeout`)はグループにしかシグナルを送れない。グループの外の子を知っているのはテストを走らせる pytest のプロセスだけなので、後始末を pytest の側に置く。打ち切る側はどちらも SIGTERM を先に送り 5 秒待つため、pytest の側の後始末はその猶予の中で終わる。Linux の子の回収役(`PR_SET_CHILD_SUBREAPER`)にするのは、`launch-cli.sh` の `setsid` のように親がすぐ終わる起動でも、孫が init へ付け替わって列挙から漏れないようにするためである。止める処理は `lib/procs.py` の `child_pids` と `stop_tree` を使い、同じ役割の関数を作らない。`stop_tree(pid)` は pid 自身とそのグループも止めるため、自分の pid へは向けず、`child_pids(os.getpid(), recursive=False)` の各 pid へかけ、複数あればスレッドで並べてまとめて待つ。 + +子の回収役にすると、付け替わった孫が終わったときの回収も pytest のプロセスの役目になる。回収しないとゾンビが残り、`stop_group` のグループの存否の判定と `os.kill(pid, 0)` が成功し続けるため、打ち切る側が猶予の 5 秒を毎回待ち切り、既存の `test_lib_test_triage.py::test_a_timed_out_shell_command_takes_its_children_down` も落ちる。そこで SIGCHLD を受けるたびに、直接の子のゾンビを回収する。回収するのは自分が起こした覚えの無い pid だけに限り、`os.waitpid(-1, ...)` で全部を回収しない。全部を回収すると、`subprocess.Popen` の `wait` や、テストが `os.fork` の後に呼ぶ `os.waitpid(pid, ...)` の終了コードを横取りし、ECHILD で取り違える。自分が起こした pid は、`subprocess.Popen`(`subprocess.run` と `asyncio` の子プロセスもここを通る)と `os.fork`(テストが直接呼ぶ。`multiprocessing` の fork もここを通る)を包んで記録する。子の起動と記録の間は回収しない。fork の直後に終わった子(`subprocess.run(["false"])` のようにすぐ終わる子)や、別スレッドの `Popen` が起こした子は、pid を記録する前に SIGCHLD を起こし、そのときは記録に無いため「覚えの無いゾンビ」に見える。そこで包んだ呼び出しは呼ぶ前に起動中の数を増やし、記録の後に減らす。SIGCHLD の扱いは起動中の数が 1 以上なら回収を見送って印だけを立て、数が 0 に戻った時点で包んだ側が見送った回収をやり直す。`signal.pthread_sigmask` で SIGCHLD を塞ぐ方法は、塞げるのが呼んだスレッドだけで別スレッドの `Popen` を守れないため採らない。 + +NDF の打ち切る側にも子孫を止める処理を足す案は採らない。受け入れ条件 7 が求める NDF を通さない打ち切りでは効かず、pytest の側と同じ役割を 2 か所に持つことになる。テストごとに後始末する案も採らない。1 万件のテストごとに子孫を列挙すると、それだけで所要が増える。 + +根拠: Value 6(MVV 版 3) + +### 決定 5: 同じ PR で古い CI の実行を進めないため、`Python tests` に PR の番号ごとの concurrency を置き、集約のジョブの判定は変えない + +組は、`pull_request` のときは `<ワークフロー名>-`、それ以外(`main` / `develop` への push など)のときは `<ワークフロー名>-` と実行ごとに一意にし、取り消しは `pull_request` のときだけ行う。`main` / `develop` への push はコミットごとの記録として最後まで回す。push の組を ref ごとにしない理由は、GitHub の concurrency が、同じ組に新しい実行が入ると、待っている前の実行を `cancel-in-progress: false` でも取り消すためである。マージが続くと途中のコミットの実行が回らず、コミットごとの記録が欠ける。集約のジョブ `pytest` の `if: always()` と、分割のジョブの取り消しを失敗として返す判定はそのまま残す。`if: ${{ !cancelled() }}` に変えると、取り消した実行で集約のジョブが skipped になり、GitHub は skipped の必須のチェックを成功として扱う。head のコミットの実行を手で取り消すと、テストを通さずにマージできる状態になる。古い実行の集約のジョブは古いコミットに結果を付け、head のコミットのチェックには付かない。 + +根拠: Value 2(MVV 版 3) + +### 決定 6: CI の準備を縮めるため、uv をキャッシュつきで入れ、分割の数 2 は変えない + +`pip install uv` と依存の取得を、uv の公式の導入の action のキャッシュで置き換える。分割の数を増やすと CI では速くなるが、要求が「並列の数を上げて 3 分に入れる」ことを行わないとしているため、変えない。手元と同じ削減で CI も縮むため、分割を増やさずに 180 秒を狙う。 + +根拠: Value 3 / Value 10(MVV 版 3) + +### 決定 7: 着手前後を同じ手順で測るため、計測を実験版のスクリプトにし、台帳を PR 本文の外のファイルに置く + +着手前と着手後で 4 回以上、同じ条件(load average・宣言の順・junit の集計)で測るため、手で組むと条件がずれる。呼んだときだけ働く補助のスクリプトなので実験版に置き、既定の振る舞いから参照しない。遅いテストの表は junit の `time` から作る。`--durations=30` を足して流すと宣言のコマンドが変わり、受け入れ条件 1 の計測と別の実行になるためである。消したテストは数百件になりうるため、台帳は `issues/issue-2291-test-ledger.md` に置き、PR 本文はそこを指す。 + +根拠: Value 4 / Value 9(MVV 版 3) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I3 | `measure` が宣言の順に suite を流し、最初の起動から最後の終了までを壁時計とし、開始時の load average が CPU の数を超えたら `reference` を立てる | 宣言の順を入れ替えて流す・load average を読まずに `reference` を偽にすると落ちる | +| 受け入れ条件 2 | `measure` の `tests`・`sum_seconds`・`wall_seconds` が junit と時計から出る。`diff` が着手前後の件数と合計を並べる | junit の skip を件数から外し忘れる・`time` の和を取り違えると落ちる | +| 受け入れ条件 3 | `measure` の `items` に、1 本あたりの秒の長い順の上位 30 件と、ファイルごとの合計の上位 10 本が出る | 並びを昇順にする・ファイルの合計を件数で数えると落ちる | +| 受け入れ条件 4・I1 | `diff` が、着手前にあって着手後に無い nodeid のうち台帳の表に無いものを出し、終了コード 1 を返す | 台帳の行を 1 つ消す・パラメータつきの nodeid を基の名前に丸めて照らすと落ちる | +| 受け入れ条件 5・I2 | `diff` が、着手前に通った・落ちた nodeid のうち着手後に skip になったものを出し、終了コード 1 を返す | 既存のテストに skip の印を足すと落ちる | +| 受け入れ条件 6・I4 | 自分の session で sleep を起こすテストを含む小さな束を、NDF の打ち切る側で上限まで流して打ち切ると、10 秒後にその束のパスを含むプロセスが 0 件 | 後始末の部品を読み込まない・子の回収役にしない(親が先に終わる孫が残る)と落ちる | +| 受け入れ条件 7・I4 | 同じ束を pytest(`-n 2`)で流し、親だけに SIGTERM を送ると、10 秒後に子孫が 0 件 | 後始末を worker にだけ置く(親だけが SIGTERM を受けると残る)と落ちる | +| 受け入れ条件 6・I4 | 後始末の部品を読み込んだまま、既存の `test_lib_test_triage.py::test_a_timed_out_shell_command_takes_its_children_down` が通る。`os.fork` の後に `os.waitpid(pid, 0)` で待つテストと `subprocess.Popen` の終了コードが変わらない | SIGCHLD で付け替わった孫を回収しない(ゾンビで猶予を待ち切る)・`os.waitpid(-1, ...)` で全部を回収する(終了コードを横取りする)と落ちる | +| 受け入れ条件 6・I4 | 後始末の部品を読み込んだまま、`subprocess.run(["false"])` を主スレッドと別スレッド(4 本)から 200 回ずつ繰り返し呼んでも、returncode がすべて 1 のまま | 起動中の数を数えずに SIGCHLD で回収する(記録の前に終わった子を回収し、ECHILD で 0 になる)・SIGCHLD の扱いがロックを待つ(主スレッドで止まる)と落ちる | +| 受け入れ条件 6・I4 | SIGTERM の後始末が自分の pid を止める対象に入れず、直接の子が 2 つあっても 5 秒以内に終わる | `stop_tree(os.getpid())` と書く・猶予を pid ごとに順に待つと落ちる | +| 受け入れ条件 9・I5 | `main` / `develop` への push を続けて 2 回行うと、2 つの run がどちらも最後まで回る | push の組を ref ごとにする(待っている前の実行が取り消される)と落ちる | +| 受け入れ条件 8 | 実装の PR の 3 回の run で、開始から `pytest` の結果までが 180 秒以内 | 手元の削減が CI に効いていない・準備が縮んでいないと超える | +| 受け入れ条件 9・I5 | 実行中に空のコミットを push すると、古い run の分割のジョブが cancelled になり、head の `pytest` は新しい run の結果になる | `cancel-in-progress` を外すと古い実行が残る | +| 受け入れ条件 10・I6 | 必須のチェックがすべて通り、`required-checks-check` が集約のジョブの名前を見つける | 集約のジョブの名前を変えると落ちる | +| I7 | 実装の PR の差分のパスが、テスト・テストの道具・根の conftest・後始末の部品・ワークフロー・文書・実験版・`issues/` だけ | `plugins/ndf/scripts/` のテストでないファイルを変えると、差分のパスの確かめで見つかる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2291 | 実装する | — | `conftest.py`、`scripts/pytest_child_cleanup.py`、`scripts/tests/`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/cross-refactoring/tests/`、`plugins/ndf/skills/cross-review/tests/`、`plugins/ndf/skills/worktree/tests/`、`plugins/ndf/skills/development-workflow/tests/`、`plugins/ndf/scripts/experimental/`、`.github/workflows/pytest.yml`、`docs/plugin-development-guide.md`、`docs/ndf-experiments.md`、`issues/issue-2291-test-ledger.md` | +| #2204 | 取り込む | #2291 | — | +| #2154 | 取り込む | #2291 | — | + +#2285 は 2 項目目だけをこの課題で扱い、1 項目目が残るため表に載せない(要求の前提 4)。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 予算の値 | pytest 以外の suite の秒と収集の固定の秒は高負荷の下の値しか無い。負荷 8 以下の値は E1 で決まり、決定 1 の「約 460 秒」はそこで置き換わる | +| 3 つの手段で届くか | 0.3 秒以上の 1,366 件をどこまで消せるか・速くできるかは、ファイルごとに読むまで分からない。届かなければ未決 1 で人へ戻す(決定 3) | +| 取り消した実行の結論 | concurrency で取り消した run で、`if: always()` の集約のジョブが走って失敗を古いコミットに付けたとき、run の `conclusion` が `cancelled` になるかは実行して確かめる。`failure` になったら、受け入れ条件 9 の確かめ方との食い違いとしてゲート 2 で人へ戻し、集約の判定は緩めない(決定 5) | +| worker の SIGCHLD の扱い | Python はシグナルの扱いを主スレッドでしか置けない。xdist の worker で `pytest_configure` とテストの本体が主スレッドで走り、SIGCHLD の扱いが置けて、`stop_group` の待ちの間に呼ばれるかは実行して確かめる。置けなければ、上の既存のテストが落ちる形で実装の中で見つかる(決定 4) | +| CI の 180 秒 | 分割の数を変えずに届くかは、実装の PR の run で初めて分かる。届かなければ未決 1 と同じ扱いで人へ戻す | +| 未決 2・3 | #2154 を取り込むか(受け入れ条件 8・9)と、#2285 の 1 項目目を残す扱いは、承認ゲート 1 で利用者が決める | diff --git a/issues/issue-2291-requirements.md b/issues/issue-2291-requirements.md new file mode 100644 index 000000000..b5a51575c --- /dev/null +++ b/issues/issue-2291-requirements.md @@ -0,0 +1,180 @@ +# #2291: 全体テストが重すぎて工程の時間の枠に収まらない。手元の全体テストを 3 分以内にする + +正は課題の本文(#2291)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何が起きたか + +全体テストが重すぎて、テストを使う工程が時間の枠に収まらない。2026-10-31 の開発ループでは、スプリントの PR(https://github.com/devbasex/ai-plugins/pull/2283 )のリファクタリングの工程で、変更前に流す全体テストに約 9 分かかった。その結果、リファクタリングを一度も検討できずに工程が終わった(https://github.com/devbasex/ai-plugins/issues/2287 )。 + +今の重さ: + +| 測ったもの | 値 | 出所 | +| --- | --- | --- | +| CI の pytest のテストの step の合計 | 1,113 秒(約 18.5 分) | `.ndf/project.json` の `test_duration.measured`(`.github/workflows/pytest.yml` の run 40160663747) | +| CI の pytest のワークフローの経過時間 | 616 秒 | `.ndf/project.json` の `ci.workflows` | +| 同上(この要求を書いたときの `.ndf/project.json`) | テストの step 4 個の合計 1,548 秒(run 41193796288)・経過時間 922 秒(4 ジョブ) | `.ndf/project.json`(2026-10-31 11:01 UTC の解析) | +| 手元の全体テスト(pytest と factory の vitest) | 約 9 分。1 回目は 180 秒の上限で打ち切られた | 上の PR のリファクタリングの工程の記録 | +| 手元の pytest だけ(`-n 4`、高負荷の下) | 580 秒で打ち切った時点で約 40%。1 件の失敗が出た(どのテストかは打ち切りで出力されず未確認) | この要求を書くときに測った(2026-10-31 22:19〜22:29 JST、load average 17.8〜20.9、CPU 8 個・メモリ 30 GB) | +| 各テストの所要の合計 | 10,483 件・2,003 秒。`plugins/ndf/scripts/tests` が 1,373 秒(69%)、遅い方から 1% のテストで全体の 26% | 2026-10-30 の手元の全体テスト(https://github.com/devbasex/ai-plugins/issues/2204 の本文) | +| テストのファイルの数 | 229 本 | `plugins/ndf/scripts/tests`・`scripts/tests`・`tests` | + +打ち切りの後に子のプロセスが残ることも、この要求を書くときに再現した。上の pytest を `timeout 580` で止めた後、中継のテストの `plugins/ndf/scripts/relay.py run` と `plugins/ndf/scripts/tests/fixtures/relay_fake_claude.py` が動き続けていた(手で止めた)。NDF のテストの実行の入口(`plugins/ndf/scripts/lib/test_triage.py` の `run_command`)は打ち切るときにプロセスグループごと止めるが、テストの中で自分の session を作って起動した子はそのグループの外に出る。 + +## 依頼(原文) + +> **手元の全体テストを 3 分以内にする** + +(書き手: 利用者(起票者がこの課題の本文に引用)、日付: 2026-10-31) + +> あまり意味のないテストは徹底的に削る + +(書き手: 利用者(2026-10-31 の NDF の方針 5 点の 5 番目。https://github.com/devbasex/ai-plugins/issues/2204 が受け皿)、日付: 2026-10-31) + +> PR の pytest(`Python tests` ワークフロー)の所要時間を 3 分以内にする + +(書き手: 利用者(起票者が https://github.com/devbasex/ai-plugins/issues/2154 の本文に「利用者の指定」として引用)、日付: 2026-10-28) + +上の 3 つ以外の題・本文(「何が起きたか」の表、元の受け入れ条件 1〜4、「この課題の中で扱うもの」)は起票者(課題を書いた AI、2026-10-31)の文で、利用者の指示の原文としては扱わない。起票者が足した条件は次の「起票者の推定」に並べる。 + +## 起票者の推定 + +承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 「全体テスト」は `.ndf/project.json` の `test.suites` の全部(pytest・factory・script-structure・lint)を、工程が使う並列の数で流したもの | 元の受け入れ条件 1(起票者) | 受け入れ条件 1・前提 1〜3 | +| S2 | 3 回測った値を PR に残す | 元の受け入れ条件 1(起票者) | 受け入れ条件 1 | +| S3 | 削ったテスト・まとめたテストは、守っていた振る舞いを別のテストが守っていることを PR に示す | 元の受け入れ条件 2(起票者)。方針 5 は「削る」だけを言う | 受け入れ条件 4 | +| S4 | 遅いテストの上位を測った表と、何をして何秒減ったかを PR に残す | 元の受け入れ条件 3(起票者) | 受け入れ条件 3 | +| S5 | 時間切れで打ち切ったとき子のプロセスが残らないことを、この課題で直す | 元の受け入れ条件 4・https://github.com/devbasex/ai-plugins/issues/2285 (起票者) | 受け入れ条件 6・7 | +| S6 | 削る基準を決めて消す(#2204)・PR の pytest を 3 分以内にする(#2154)・子のプロセスが残る(#2285)をこの課題の中で扱う | 本文の「この課題の中で扱うもの」(起票者) | 対象範囲。#2154 は受け入れ条件 8・9 | +| S7 | 削る基準は「重複・消えた機能の残骸・`.md` の文言の固定・実装の細部の固定・総当たり」の 5 つで、`AGENTS.md` か `docs/` に置く | https://github.com/devbasex/ai-plugins/issues/2204 の「削る基準(設計で確定する)」(起票者) | 受け入れ条件 4。基準の中身は設計で確定する | +| S8 | 並列の数を上げて 3 分に入れることはしない。3 分は今の並列の数(pytest は `-n 4`)で測る | この要求を書いた AI の判断。同じマシンで最大 6 本の作業が並ぶ運用で、全体テストを 6 本同時に流したとき残りのメモリが 7.4 GB だった(2026-09-25 の実測) | 前提 2 | +| S9 | 既定の実行で飛ばすテスト(skip・deselect・marker で外すもの)を増やして 3 分に入れることはしない | この要求を書いた AI の判断。飛ばしたテストは全体テストから消えるのに、受け入れ条件 4 の対応を残さずに済んでしまう | 受け入れ条件 5 | +| S10 | 測るのは開始時の 1 分の load average が CPU の数以下のとき | この要求を書いた AI の判断。高負荷の下では同じ pytest が 580 秒で 40% までしか進まなかった | 前提 1 | + +## 目的 + +- テストを使う工程(リファクタリングの着手前のテスト・最終ゲート・完了判定)が、時間の枠のほとんどを全体テストに使わずに本来の作業へ時間を回せるようにする +- PR ごとの CI の待ちを縮め、1 回の配布までの待ちを減らす +- テストの数を、守る振る舞いに見合う数まで減らし、保守の費用を下げる + +## 前提 + +- 前提 1: 手元で測るマシンは、この開発コンテナ(CPU 8 個・メモリ 30 GB)である。開始時の 1 分の load average が 8 以下のときに測り、その値を記録に添える。8 を超える負荷の下で測った値は参考として別に残し、受け入れ条件 1 の判定に使わない +- 前提 2: 並列の数は今の宣言の値のまま(pytest の `-n 4`)にする。並列の数を変えるのは受け入れ条件の手段に含めない +- 前提 3: 「3 分」は壁時計の 180 秒で、`.ndf/project.json` の `test.suites` の `command` を宣言の順に 1 本ずつ流し、最初の起動から最後の終了までを測る。`uv` の仮想環境は作成済みの状態から測る(作成の時間は数えない)。factory の suite のコマンドに含まれる `npm ci` の時間は数える +- 前提 4: https://github.com/devbasex/ai-plugins/issues/2285 の 1 項目目(打ち切りで止まったときに作業場所の持ち主の記録へ終わった時刻を書く)は、この課題では扱わず #2285 に残す。この課題が扱うのは #2285 の 2 項目目(子のプロセス)だけである +- 前提 5: https://github.com/devbasex/ai-plugins/issues/2287 (閉じた課題)の「工程の時間の枠の決め方」は、この課題では変えない。全体テストが縮むことで枠に収まるようにする + +## 対象範囲 + +含む: +- 意味の薄いテストを削る基準を文書にし、基準に当たるテストを消す・まとめる(#2204) +- 遅いテストを速くする(待ちの短縮・重い準備の共有など。手段は設計で決める) +- PR の pytest の CI を 3 分以内にし、同じ PR への新しい push で古い実行を止める(#2154) +- 全体テストを上限で打ち切ったとき、テストが起こした子孫のプロセスを残さない(#2285 の 2 項目目) + +含まない: +- 並列の数を上げて 3 分に入れること(前提 2) +- テストを既定の実行から外して 3 分に入れること(受け入れ条件 5) +- 作業場所の持ち主の記録に終わった時刻を書くこと(#2285 の 1 項目目。前提 4) +- リファクタリングの工程の時間の枠の決め方の変更(前提 5) +- テストの対象のコード(プラグインの振る舞い)の変更。テストを速くするためにテストの対象を変える必要が出たら、その変更は止めて設計で扱いを決める + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 着手前の遅いテストの表と件数・合計時間を測った | 実装の着手 | 測れない(高負荷・失敗)なら、前提 1 の条件が揃うまで測り直す。揃わなければ高負荷の値を参考として残し、その旨を書く | — | +| E2 | 削る基準を文書にした | E1 の表 | 基準を決められないテストは消さずに残し、PR に理由を書く | E1 | +| E3 | 基準に当たるテストを消した・まとめた | E2 | 守っていた振る舞いを守るテストを示せなければ消さない | E2 | +| E4 | 遅いテストを速くした | E1 の表の上位 | 速くすると振る舞いの確かめが弱まるなら、速くせずに残し、PR に理由を書く | E1 | +| E5 | 全体テストを上限で打ち切った | test-run.py・cross-refactoring の着手前のテストの上限 | 子孫のプロセスが残る(受け入れ条件 6 が落とす) | — | +| E6 | 打ち切った全体テストのプロセスがすべて止まった | E5 | 止まらないプロセスがあれば、そのプロセスを起こしたテストを直す | E5 | +| E7 | CI の pytest が新しい push で走り直し、同じ PR の古い実行が止まった | PR への push | 古い実行が止まらない・必須のチェック `pytest` が取り消しで落ちたままになる(受け入れ条件 9 が落とす) | — | +| E8 | 着手後の全体テストを 3 回測った | E3・E4 の後 | 3 分に入らなければ、届いた値と残りの差を PR に書き、未決の 1 に従う | E3・E4 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 全体テスト | 用語集のとおり。この課題では `.ndf/project.json` の `test.suites` の全部(前提 3) | +| 着手前のテスト | 用語集のとおり(`cross-refactoring` の init が改修の前に 1 回走らせるテスト) | +| 全体テストの実測 | 用語集のとおり(同じリポジトリで先に走った全体テストの壁時計の秒) | +| 範囲テスト | 用語集のとおり | + +## 受け入れ条件 + +- [ ] 1. 前提 1〜3 の条件で手元の全体テストを 3 回流し、3 回とも全 suite が通り、壁時計が 180 秒以内に収まる。3 回の秒・suite ごとの秒・開始時の load average を PR に残す +- [ ] 2. 着手前と着手後の両方で、全体テストの件数・各テストの所要の合計・壁時計を、同じマシン・同じ並列の数で測った値が PR に並ぶ +- [ ] 3. 着手前の遅いテストの上位 30 件(1 本あたりの秒の長い順。`pytest --durations=30` の出力)と、ファイルごとの合計の上位 10 本の表が PR にあり、各行に「何をしたか(消した・まとめた・速くした・そのまま)」と着手後の秒が書かれている +- [ ] 4. テストを削る基準が `AGENTS.md` か `docs/` の文書にあり、消したテスト・まとめたテストの 1 件ごとに、当たった基準と、守っていた振る舞いを守るテストの名前(守らなくてよいならその理由)が PR から追える +- [ ] 5. 着手前に既定の実行で走っていたテストのうち、着手後に既定の実行で skip・deselect されるものが 0 件である(消したテストは受け入れ条件 4 の対応に載り、この数に入れない) +- [ ] 6. 全体テストを NDF の上限で打ち切ったとき(`plugins/ndf/scripts/test-run.py whole --limit <秒>`、cross-refactoring の着手前のテスト)、打ち切りから 10 秒後に、その worktree のパスをコマンドラインに含むプロセスが 0 件である(`pgrep -f ` で確かめる)。中継のテストの `relay_fake_claude.py` のように、テストの中で自分の session を作って起動する子も数える +- [ ] 7. pytest の実行がシグナル(SIGTERM)で止められたとき、テストが起こした子孫のプロセスが 10 秒以内に止まる(受け入れ条件 6 と同じ確かめ方。NDF を通さない打ち切りでも残さない) +- [ ] 8. PR の `Python tests` ワークフロー(`.github/workflows/pytest.yml`)の、ワークフローの開始から必須のチェック `pytest` の結果が付くまでの時間が、実装の PR の 3 回の実行で 3 回とも 180 秒以内である。3 回の run の番号と秒を PR に残す +- [ ] 9. 同じ PR に、`Python tests` の実行中に新しい push をすると、古い実行が取り消され、新しい実行の結果だけが必須のチェック `pytest` に付く(取り消された実行で `pytest` が失敗のまま残らない) +- [ ] 10. 着手後も、必須のチェック(`.ndf/project.json` の `ci.required_checks`)がすべて通る + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 手元の全体テストが 180 秒以内(受け入れ条件 1)。PR の pytest の CI が 180 秒以内(受け入れ条件 8) | +| 運用・保守性 | 打ち切ったテストのプロセスが残らない(受け入れ条件 6・7)。削る基準が文書にあり、後からテストを書く側が参照できる(受け入れ条件 4) | +| システム環境 | 並列の数は `-n 4` のまま(前提 2)。メモリ 30 GB のマシンで、ほかの作業と並んでも全体テストがメモリを使い切らない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。`test-run.py` などの結果 JSON・終了コードの契約は変えない | +| データ | 無し | +| 既存の振る舞い | テストの件数と所要が変わる。`.github/workflows/pytest.yml` の実行の取り消しと分割が変わる。テストの道具(fixture・偽の CLI)の子のプロセスの止め方が変わる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `.ndf/project.json` の `test.suites` の `command` を宣言の順に流し、全体を `time` で測る(受け入れ条件 1・2)。遅いテストは `uv run --frozen --project . --all-extras pytest . -q -n 4 --durations=30`(受け入れ条件 3) | +| 静的解析・型検査 | `bash scripts/check-lint.sh`・`python3 scripts/check-script-structure.py` | +| 打ち切りの確かめ | `python3 plugins/ndf/scripts/test-run.py whole --limit 30` で打ち切り、10 秒後に `pgrep -f ` が 0 件(受け入れ条件 6)。pytest を `kill -TERM` で止めて同じ確かめ(受け入れ条件 7) | +| CI | 実装の PR の `Python tests` の run を `gh run list -w "Python tests"`・`gh run view <番号> --json startedAt,updatedAt,jobs` で読む(受け入れ条件 8)。実行中に空のコミットを push し、古い run の `conclusion` が `cancelled` で、必須のチェック `pytest` が新しい run の結果になることを確かめる(受け入れ条件 9) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | テストは今の置き場(`plugins/ndf/scripts/tests`・`plugins/*/skills/*/tests`・`scripts/tests`・`tests`・`factory`)のまま。削る基準の文書は `AGENTS.md` か `docs/`(#2204) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストを書かない(`AGENTS.md` の DON'T)。ruff(`pyproject.toml`) | +| テスト戦略 | テストを消すときは、守っていた振る舞いを残るテストが守るかを 1 件ずつ示す(受け入れ条件 4)。速くするときは、確かめる振る舞いを弱めない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 着手前と着手後の実測(受け入れ条件 2・3)。消したテストの対応の記録(受け入れ条件 4)。既存のテストと静的解析の実行 | +| 確認してから行う | `.ndf/project.json` の変更(C7。承認ゲート 2 で人が見る)。必須のチェックの名前・集約ジョブに触れる CI の変更(承認ゲート 2) | +| 行わない | 並列の数を上げる・テストを既定の実行から外すことで 3 分に入れる。テストの対象のコードの変更。#2285 の 1 項目目と #2287 の時間の枠の変更。依頼の範囲外のリファクタリング | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 削る・速くするを尽くしても 180 秒に届かないときに、届いた値で受け入れるか、残りを別の課題へ回すか(プロジェクト MVV の Value 10 は、上限を超える品質の向上を別の課題へ回すとする。ただしこの 3 分は利用者が指定した目標である) | 利用者 | 承認ゲート 1(設計の承認) | +| 2. #2154 の CI の 3 分(受け入れ条件 8・9)をこの課題の受け入れ条件に入れてよいか(起票者の推定 S6) | 利用者 | 承認ゲート 1 | +| 3. #2285 の 1 項目目を #2285 に残す扱い(前提 4)でよいか | 利用者 | 承認ゲート 1 | + +## 関連 + +- リファクタリングの工程が、変更前のテストに時間の枠を使い切った https://github.com/devbasex/ai-plugins/issues/2287 +- 時間切れで作業場所が「使用中」のまま残り、テストのプロセスも残る https://github.com/devbasex/ai-plugins/issues/2285 + +## この課題の中で扱うもの + +- 意味の薄いテストを削る基準を決めて消す #2204 +- PR の pytest が 1 回 10〜13 分かかる #2154 +- 時間切れのときに子のプロセスが残る #2285 + +🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/issues/issue-2291-test-ledger.md b/issues/issue-2291-test-ledger.md new file mode 100644 index 000000000..6cdd39db2 --- /dev/null +++ b/issues/issue-2291-test-ledger.md @@ -0,0 +1,94 @@ +# 全体テストの台帳(#2291・#2204) + +削る基準の番号は `docs/plugin-development-guide.md` の「テストを削る基準」の表の番号である。計測は実験版の +`plugins/ndf/scripts/experimental/whole-test-timing.py`(`measure` と `diff`)で取った。 + +## 着手前後の実測 + +計測のマシンはこの開発コンテナ(CPU 8 個・メモリ 30 GB)、並列の数は宣言のまま(pytest の `-n 4`)。 + +| 計測 | 日時(JST) | 開始時の load average | 壁時計 | suite ごとの秒 | 件数(skip) | 各テストの所要の合計 | +| --- | --- | --- | --- | --- | --- | --- | +| 着手前 | 2026-10-31 23:21 | 7.63 | 962.9 秒 | pytest 932.5 秒・factory 15.8 秒・script-structure 8.9 秒・lint 5.7 秒(pytest は 1 件が落ちた。根の conftest の文字列を固定していた `scripts/tests/test_root_conftest.py::test_the_root_conftest_declares_the_plugin` で、後始末の部品を根の conftest に足した後に測ったため。下の表で消した) | 10777(4) | 3454.6 秒 | +| 着手後 | 2026-11-01 01:46 | 1.33 | 635.8 秒 | pytest 607.5 秒・factory 14.8 秒・script-structure 8.4 秒・lint 5.1 秒 | 10781(4) | 2273.8 秒 | + +- 着手前と着手後で開始時の load average が違う(7.63 と 1.33。どちらも CPU の数 8 以下)。所要の差には負荷の差も入っている。後始末の部品とテストの書き直しの前半を入れた後の途中の計測(load average 1.31)は壁時計 623.0 秒・合計 2,295.0 秒で、着手後とほぼ同じだった +- 180 秒に届いていない。着手後の壁時計 635.8 秒のうち pytest が 607.5 秒で、残りの 3 つの suite は計 28.3 秒である。180 秒に入れるには pytest を約 150 秒(各テストの所要の合計で約 550 秒)にする必要があり、今の 2,273.8 秒から約 1,700 秒を減らす差が残る +- 0.3 秒未満のテスト(途中の計測で 9,028 件)だけで合計 481 秒ある。残りの時間は、本物のスクリプトと子のプロセスを起こして待つ結合テスト(中継・supervise の queue と再開・cross-review と cross-refactoring の drive)に集まっている +- 照合(`whole-test-timing.py diff`): 台帳に無い消えたテスト 0 件・skip に変わったテスト 0 件。着手後の件数が増えたのは、後始末の部品と計測の実験版のテストを足したため + +## 遅いテストの表 + +「速くした」は、ファイルを書き換え、着手後の秒が着手前の 8 割未満になったもの。「そのまま」は書き換えていないか、縮みが 2 割に届かないもの(負荷の差による縮みを含む)。 + +### 1 本あたりの秒の長い順の上位 30 件(着手前) + +| # | テスト | 着手前の秒 | 処置 | 着手後の秒 | +| --- | --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_template_plan_gets_through_each_fix_loop_three_times` | 78.52 | まとめた | 14.39 | +| 2 | `scripts/tests/test_root_conftest.py::test_a_bundle_outside_the_table_is_not_checked` | 41.70 | 速くした | 0.48 | +| 3 | `plugins/ndf/skills/cross-refactoring/tests/test_fix_rules_git.py::test_items_failing_the_same_structure_check_are_dropped_after_one_fix` | 18.72 | そのまま | 12.20 | +| 4 | `plugins/ndf/scripts/tests/test_supervise_contract.py::test_results_keep_their_keys_and_exit_codes` | 16.82 | 速くした | 7.49 | +| 5 | `plugins/ndf/scripts/tests/test_drive_refactor.py::test_refactor_drive_workflow_step_finishes_with_counts` | 12.91 | そのまま | 5.04 | +| 6 | `scripts/tests/test_check_script_structure.py::test_repository_matches_its_allow_list` | 12.76 | 消した | — | +| 7 | `plugins/ndf/scripts/tests/test_relay.py::test_run_waits_for_user_input_quiet` | 11.45 | 速くした | 6.51 | +| 8 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_the_release_without_a_gap_says_nothing_more` | 10.66 | そのまま | 5.78 | +| 9 | `plugins/ndf/scripts/tests/test_drive_refactor.py::test_refactor_drive_pauses_for_cross_review_then_finalizes` | 9.99 | そのまま | 4.95 | +| 10 | `plugins/ndf/scripts/tests/test_relay.py::test_slow_usage_endpoint_does_not_stall_relay` | 9.88 | 速くした | 4.82 | +| 11 | `plugins/ndf/scripts/tests/test_supervise_contract.py::test_subcommands_keep_their_arguments` | 9.13 | 速くした | 0.10 | +| 12 | `plugins/ndf/scripts/tests/test_base_sync.py::test_impl_route_goes_back_three_times_and_merges` | 8.42 | そのまま | 6.84 | +| 13 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py::test_the_same_head_returns_the_previous_result` | 7.77 | そのまま | 3.12 | +| 14 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_a_conditional_stage_without_a_record_is_shown_apart` | 7.76 | そのまま | 4.95 | +| 15 | `plugins/ndf/scripts/tests/test_supervise_queue.py::test_a_needed_plan_of_an_earlier_stage_or_with_a_gate_record` | 7.52 | そのまま | 5.48 | +| 16 | `plugins/ndf/scripts/tests/test_base_sync.py::test_check_route_goes_back_and_reaches_record` | 7.52 | 速くした | 5.99 | +| 17 | `scripts/tests/test_agy_distribution.py::test_generated_links_are_not_collected_twice` | 7.50 | そのまま | 5.75 | +| 18 | `plugins/ndf/scripts/tests/test_relay.py::test_run_spin_broken_by_long_section` | 7.21 | そのまま | 6.25 | +| 19 | `plugins/ndf/scripts/tests/test_relay.py::test_guard_question_blocks_exit` | 6.88 | 速くした | 3.05 | +| 20 | `plugins/ndf/scripts/tests/test_supervise.py::test_queue_tells_only_the_files_of_plans_running_when_the_worker_starts` | 6.50 | そのまま | 6.05 | +| 21 | `plugins/ndf/skills/cross-refactoring/tests/test_whole_test_triage_git.py::test_a_failure_caused_by_the_change_goes_to_fix_and_is_kept_when_fixed` | 6.34 | そのまま | 4.51 | +| 22 | `plugins/ndf/scripts/tests/test_supervise_queue.py::test_tagged_steps_do_not_exceed_the_resource_limit` | 6.13 | そのまま | 5.81 | +| 23 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py::test_a_record_without_head_returns_the_previous_result` | 6.10 | そのまま | 2.98 | +| 24 | `plugins/ndf/scripts/tests/test_supervise_queue_continue.py::test_resume_continues_from_the_stopped_step_and_runs_the_next_stage` | 6.09 | そのまま | 5.58 | +| 25 | `scripts/tests/test_lock_common.py::test_the_timeout_override_reaches_only_the_stage_state` | 5.96 | そのまま | 5.94 | +| 26 | `plugins/ndf/skills/cross-refactoring/tests/test_stop_revert_git.py::test_plan_a_reverts_only_the_culprit_and_the_rechecked_gate_passes` | 5.90 | そのまま | 3.11 | +| 27 | `plugins/ndf/skills/cross-refactoring/tests/test_whole_test_triage_git.py::test_without_time_to_fix_the_newest_flagged_items_are_reverted_until_it_passes` | 5.83 | そのまま | 5.09 | +| 28 | `plugins/ndf/scripts/tests/test_progress_record_stages.py::test_the_record_stage_of_supervise_is_recorded` | 5.78 | そのまま | 2.10 | +| 29 | `plugins/ndf/scripts/tests/test_drive_review.py::test_review_drive_pauses_for_fix_then_sweep_then_finishes` | 5.72 | そのまま | 4.45 | +| 30 | `plugins/ndf/skills/cross-refactoring/tests/test_stop_revert_git.py::test_every_child_limit_from_the_cut_off_to_the_recheck_ends_by_the_end` | 5.61 | そのまま | 3.07 | + +### ファイルごとの合計の上位 10 本(着手前) + +| # | ファイル | 着手前(件・秒) | 処置 | 着手後(件・秒) | +| --- | --- | --- | --- | --- | +| 1 | `plugins/ndf/scripts/tests/test_relay.py` | 423 件・303.0 | 速くした | 423 件・205.5 | +| 2 | `plugins/ndf/scripts/tests/test_supervise.py` | 184 件・181.6 | 速くした | 184 件・143.1 | +| 3 | `plugins/ndf/scripts/tests/test_supervise_limits.py` | 15 件・103.2 | 速くした | 15 件・31.4 | +| 4 | `plugins/ndf/scripts/tests/test_supervise_queue.py` | 35 件・99.4 | そのまま | 35 件・85.7 | +| 5 | `plugins/ndf/skills/cross-review/tests/test_review_drive_resume.py` | 25 件・71.7 | そのまま | 25 件・48.7 | +| 6 | `plugins/ndf/scripts/tests/test_glossary.py` | 105 件・71.0 | 速くした | 105 件・7.9 | +| 7 | `plugins/ndf/scripts/tests/test_progress_record_stages.py` | 36 件・65.4 | そのまま | 36 件・32.2 | +| 8 | `plugins/ndf/scripts/tests/test_supervise_new.py` | 70 件・52.8 | 速くした | 70 件・11.1 | +| 9 | `plugins/ndf/scripts/tests/test_relay_account.py` | 54 件・51.4 | そのまま | 54 件・38.8 | +| 10 | `scripts/tests/test_root_conftest.py` | 18 件・48.9 | 速くした・消した | 15 件・5.2 | + + +## 消した・まとめたテスト + +| テスト | 処置 | 基準 | 守るテスト・守らなくてよい理由 | +| --- | --- | --- | --- | +| `scripts/tests/test_root_conftest.py::test_the_root_conftest_declares_the_plugin` | 消した | 4 | 根の conftest の文字列を固定していた。`pytester` が読まれなければ `plugins/playwright-kit/skills/playwright-kit-ops/tests` の `pytester` を使うテストが落ちる | +| `scripts/tests/test_root_conftest.py::test_the_failure_names_the_bundle_and_the_command` | まとめた | 1 | 同じ起動を `scripts/tests/test_root_conftest.py::test_a_missing_command_fails_the_collection` が行い、束とコマンドの名前も確かめる | +| `scripts/tests/test_root_conftest.py::test_the_prefix_is_declared_once` | 消した | 4 | 根の conftest の文字列を固定していた。接頭辞の除去は `scripts/tests/test_root_conftest.py::test_no_monitor_variable_survives_into_a_test` が守る | +| `scripts/tests/test_check_script_structure.py::test_repository_matches_its_allow_list` | 消した | 6 | suite `script-structure`(`python3 scripts/check-script-structure.py`)と CI の `.github/workflows/script-structure.yml` が同じ検査をリポジトリ全体へ流す | +| `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_template_plan_gets_through_each_fix_loop_three_times` | まとめた | 5 | 17 本の雛形のプランのうち、修正のループの入口の組が同じものは同じ遷移を通る。組ごとに 1 本(impl・check・release・release-prod の形)を流す `plugins/ndf/scripts/tests/test_supervise_limits.py::test_every_fix_loop_shape_gets_through_each_fix_loop_three_times` が守る。修正のループの無いプランは上限に届く経路が無い | + +## 打ち切りの確かめ(受け入れ条件 6・7) + +2026-11-01 02:00 JST ごろ に手元で確かめた。数えたのは、作業場所のパスをコマンドラインに含むプロセスのうち、打ち切る前から動いていた作業者のセッションと言語サーバーを除いたもの。 + +| 打ち切り方 | 打ち切る前 | 打ち切りから 10 秒後 | +| --- | --- | --- | +| `test_triage.run_command` に宣言の pytest のコマンドを上限 30 秒で渡す(cross-refactoring の着手前のテストと同じ打ち切る側。30.2 秒で打ち切った) | — | 0 件 | +| `pytest -n 4`(`test_relay.py`・`test_supervise_queue.py`)を 25 秒流し、親だけへ `kill -TERM` | 11 件 | 0 件 | + +`python3 plugins/ndf/scripts/test-run.py whole --limit 30` は、PR の無いこのブランチでは CI のチェックの結論を待つ側へ進み、手元のテストを流さなかったため、上の 1 行目で打ち切る側を直に呼んで確かめた。 diff --git a/plugins/mcp/mcp-aws-docs/.claude-plugin/plugin.json b/plugins/mcp/mcp-aws-docs/.claude-plugin/plugin.json index 7511c4151..e2e41fe98 100644 --- a/plugins/mcp/mcp-aws-docs/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-aws-docs/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-aws-docs", - "version": "2.0.2", + "version": "3.0.0", "description": "AWS Documentation MCP server for accessing official AWS documentation", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-aws-docs/.codex-plugin/plugin.json b/plugins/mcp/mcp-aws-docs/.codex-plugin/plugin.json index d45029771..cc697de96 100644 --- a/plugins/mcp/mcp-aws-docs/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-aws-docs/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-aws-docs", - "version": "2.0.2", + "version": "3.0.0", "description": "AWS Documentation MCP server for accessing official AWS documentation", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-aws-docs/README.md b/plugins/mcp/mcp-aws-docs/README.md index e0c24c9ff..184063988 100644 --- a/plugins/mcp/mcp-aws-docs/README.md +++ b/plugins/mcp/mcp-aws-docs/README.md @@ -31,14 +31,6 @@ AWS公式ドキュメントにアクセスするためのMCPサーバープラ codex plugin add mcp-aws-docs@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-aws-docs/dev.kiro/install.sh -``` - ### 必要な環境変数 - 追加の環境変数は不要です。 diff --git a/plugins/mcp/mcp-aws-docs/dev.kiro/install.sh b/plugins/mcp/mcp-aws-docs/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-aws-docs/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-bigquery/.claude-plugin/plugin.json b/plugins/mcp/mcp-bigquery/.claude-plugin/plugin.json index 67518f9ca..dc046cb4d 100644 --- a/plugins/mcp/mcp-bigquery/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-bigquery/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-bigquery", - "version": "3.0.1", + "version": "4.0.0", "description": "Google official remote BigQuery MCP server for data analysis and SQL queries", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-bigquery/.codex-plugin/plugin.json b/plugins/mcp/mcp-bigquery/.codex-plugin/plugin.json index 4fea4480f..c694e79da 100644 --- a/plugins/mcp/mcp-bigquery/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-bigquery/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-bigquery", - "version": "3.0.1", + "version": "4.0.0", "description": "Google official remote BigQuery MCP server for data analysis and SQL queries", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-bigquery/README.md b/plugins/mcp/mcp-bigquery/README.md index 91931676f..0fc4bfcc9 100644 --- a/plugins/mcp/mcp-bigquery/README.md +++ b/plugins/mcp/mcp-bigquery/README.md @@ -1,7 +1,7 @@ # BigQuery MCP Google が運用するリモートの BigQuery MCP サーバー(`https://bigquery.googleapis.com/mcp`)を -Claude Code・Codex・Kiro CLI から使うためのプラグインです。 +Claude Code・Codex から使うためのプラグインです。 ## 概要 @@ -64,19 +64,17 @@ Claude Code・Codex・Kiro CLI から使うためのプラグインです。 |---|---|---| | Claude Code | `~/.claude/settings.json`(またはプロジェクトの `.claude/settings.json`) | `"permissions": {"deny": ["mcp__plugin_mcp-bigquery_bigquery__execute_sql", "mcp__bigquery__execute_sql"]}` | | Codex | `~/.codex/config.toml` | 下の例 | -| Kiro CLI | `.kiro/agents/default.json` の `mcpServers.bigquery` | `"disabledTools": ["execute_sql"]`(`install.sh` を流し直したら書き直す) | ```toml [plugins."mcp-bigquery@ai-plugins".mcp_servers.bigquery] disabled_tools = ["execute_sql"] ``` -Claude Code の 2 つ目の名前は、Kiro CLI の `install.sh` がプロジェクト直下の `.mcp.json` にも -`bigquery` を書くためです。同じプロジェクトで Claude Code を使うと、プロジェクトのサーバーとして +Claude Code の 2 つ目の名前は、前の版の Kiro CLI 向けの `install.sh` がプロジェクト直下の `.mcp.json` にも +`bigquery` を書いたためです。同じプロジェクトで Claude Code を使うと、プロジェクトのサーバーとして `mcp__bigquery__execute_sql` の名前で読まれ、1 つ目の名前の deny では止まりません。 -どれもツールの一覧から `execute_sql` が消えます(Claude Code 2.1.295・Codex 0.160.0・ -kiro-cli 2.24.1 で確認)。組織として止めるときは、IAM の deny ポリシーで `execute_sql` を禁じます。 +どれもツールの一覧から `execute_sql` が消えます(Claude Code 2.1.295・Codex 0.160.0 で確認)。組織として止めるときは、IAM の deny ポリシーで `execute_sql` を禁じます。 ## ランタイムごとの認証 @@ -86,13 +84,6 @@ kiro-cli 2.24.1 で確認)。組織として止めるときは、IAM の deny |---|---|---| | Claude Code | `headersHelper` | 接続のたびに `gcloud auth print-access-token` を実行する(401/403 でも取り直す) | | Codex | `http_headers_helper` | 同上 | -| Kiro CLI | `headers` の `${BIGQUERY_ACCESS_TOKEN}` | **自動では取り直さない**。起動前に環境変数へ入れる | - -Kiro CLI では、起動の前に次を実行してください。1 時間たったら入れ直して再起動します。 - -```bash -export BIGQUERY_ACCESS_TOKEN=$(gcloud auth print-access-token) -``` Codex は `http_headers_helper` を、`HOME`・`PATH` など限られた環境変数だけを渡して実行します。 `CLOUDSDK_CONFIG` で gcloud の設定の場所を変えていると、helper の中の gcloud は既定の場所 @@ -114,7 +105,7 @@ v2 までのローカルサーバー(`mcp-server-bigquery`)をやめ、リ |---|---|---| | サーバー | `uvx mcp-server-bigquery`(stdio) | `https://bigquery.googleapis.com/mcp`(HTTP) | | 認証 | サービスアカウントの鍵ファイル | gcloud にログインした利用者のトークン | -| 環境変数 | `BIGQUERY_PROJECT` `BIGQUERY_LOCATION` `BIGQUERY_DATASET` `BIGQUERY_KEY_FILE` | 不要(Kiro CLI のみ `BIGQUERY_ACCESS_TOKEN`) | +| 環境変数 | `BIGQUERY_PROJECT` `BIGQUERY_LOCATION` `BIGQUERY_DATASET` `BIGQUERY_KEY_FILE` | 不要 | | ツール名 | `execute-query` `list-tables` `describe-table` | `execute_sql_readonly` `list_table_ids` `get_table_info` など | v2 は依存する `mcp` パッケージの 2 系で起動できなくなっていました(`'Server' object has no @@ -134,14 +125,6 @@ attribute 'list_tools'`)。 codex plugin add mcp-bigquery@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-bigquery/dev.kiro/install.sh -``` - ## 更新情報 版ごとの変更と移行の手順は [CHANGELOG.md](https://github.com/devbasex/ai-plugins/blob/main/CHANGELOG.md) の `[mcp-bigquery <版>]` の節にあります。 diff --git a/plugins/mcp/mcp-bigquery/dev.kiro/install.sh b/plugins/mcp/mcp-bigquery/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-bigquery/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-chrome-devtools/.claude-plugin/plugin.json b/plugins/mcp/mcp-chrome-devtools/.claude-plugin/plugin.json index afee4cac6..50749c25d 100644 --- a/plugins/mcp/mcp-chrome-devtools/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-chrome-devtools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-chrome-devtools", - "version": "2.0.2", + "version": "3.0.0", "description": "Chrome DevTools MCP server for browser automation and testing", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-chrome-devtools/.codex-plugin/plugin.json b/plugins/mcp/mcp-chrome-devtools/.codex-plugin/plugin.json index 9012c5a8f..f8d08da8d 100644 --- a/plugins/mcp/mcp-chrome-devtools/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-chrome-devtools/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-chrome-devtools", - "version": "2.0.2", + "version": "3.0.0", "description": "Chrome DevTools MCP server for browser automation and testing", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-chrome-devtools/README.md b/plugins/mcp/mcp-chrome-devtools/README.md index 872f67511..c77e14bac 100644 --- a/plugins/mcp/mcp-chrome-devtools/README.md +++ b/plugins/mcp/mcp-chrome-devtools/README.md @@ -33,14 +33,6 @@ Chrome DevTools MCPサーバーをClaude Codeで利用するためのプラグ codex plugin add mcp-chrome-devtools@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-chrome-devtools/dev.kiro/install.sh -``` - ### 必要な環境変数 - 追加の環境変数は不要です。 diff --git a/plugins/mcp/mcp-chrome-devtools/dev.kiro/install.sh b/plugins/mcp/mcp-chrome-devtools/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-chrome-devtools/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-dbhub/.claude-plugin/plugin.json b/plugins/mcp/mcp-dbhub/.claude-plugin/plugin.json index f5786d64e..c31e22d11 100644 --- a/plugins/mcp/mcp-dbhub/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-dbhub/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-dbhub", - "version": "2.0.3", + "version": "3.0.0", "description": "DBHub MCP server for database operations and SQL queries", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-dbhub/.codex-plugin/plugin.json b/plugins/mcp/mcp-dbhub/.codex-plugin/plugin.json index 4d560f2e0..6728c9618 100644 --- a/plugins/mcp/mcp-dbhub/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-dbhub/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-dbhub", - "version": "2.0.3", + "version": "3.0.0", "description": "DBHub MCP server for database operations and SQL queries", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-dbhub/README.md b/plugins/mcp/mcp-dbhub/README.md index c2942ad70..0ac5bfddf 100644 --- a/plugins/mcp/mcp-dbhub/README.md +++ b/plugins/mcp/mcp-dbhub/README.md @@ -32,14 +32,6 @@ codex plugin add mcp-dbhub@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-dbhub/dev.kiro/install.sh -``` - ### 必要な環境変数 - 追加の環境変数は不要です。 @@ -54,9 +46,6 @@ bash plugins/mcp/mcp-dbhub/dev.kiro/install.sh # Codex codex plugin marketplace upgrade ai-plugins codex plugin add mcp-dbhub@ai-plugins - -# Kiro CLI -bash plugins/mcp/mcp-dbhub/dev.kiro/install.sh ``` ## 設定 diff --git a/plugins/mcp/mcp-dbhub/dev.kiro/install.sh b/plugins/mcp/mcp-dbhub/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-dbhub/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-devin/.claude-plugin/plugin.json b/plugins/mcp/mcp-devin/.claude-plugin/plugin.json index dc28337b2..3b6298e25 100644 --- a/plugins/mcp/mcp-devin/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-devin/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-devin", - "version": "2.0.2", + "version": "3.0.0", "description": "Devin MCP server for accessing repository documentation and code knowledge via DeepWiki", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-devin/.codex-plugin/plugin.json b/plugins/mcp/mcp-devin/.codex-plugin/plugin.json index 24d9e9971..465bc672a 100644 --- a/plugins/mcp/mcp-devin/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-devin/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-devin", - "version": "2.0.2", + "version": "3.0.0", "description": "Devin MCP server for accessing repository documentation and code knowledge via DeepWiki", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-devin/README.md b/plugins/mcp/mcp-devin/README.md index ff081d50e..e1a8d8965 100644 --- a/plugins/mcp/mcp-devin/README.md +++ b/plugins/mcp/mcp-devin/README.md @@ -43,14 +43,6 @@ DEVIN_API_KEY=your-devin-api-key codex plugin add mcp-devin@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-devin/dev.kiro/install.sh -``` - ### 必要な環境変数 - `DEVIN_API_KEY` diff --git a/plugins/mcp/mcp-devin/dev.kiro/install.sh b/plugins/mcp/mcp-devin/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-devin/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-markitdown/.claude-plugin/plugin.json b/plugins/mcp/mcp-markitdown/.claude-plugin/plugin.json index 41fd801a1..3bb82113f 100644 --- a/plugins/mcp/mcp-markitdown/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-markitdown/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-markitdown", - "version": "2.0.2", + "version": "3.0.0", "description": "MarkItDown MCP server for converting documents (PDF, Office, images, etc.) to Markdown", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-markitdown/.codex-plugin/plugin.json b/plugins/mcp/mcp-markitdown/.codex-plugin/plugin.json index 77429d205..27ee57860 100644 --- a/plugins/mcp/mcp-markitdown/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-markitdown/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-markitdown", - "version": "2.0.2", + "version": "3.0.0", "description": "MarkItDown MCP server for converting documents (PDF, Office, images, etc.) to Markdown", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-markitdown/README.md b/plugins/mcp/mcp-markitdown/README.md index 219bfbacd..5387eb5df 100644 --- a/plugins/mcp/mcp-markitdown/README.md +++ b/plugins/mcp/mcp-markitdown/README.md @@ -30,14 +30,6 @@ codex plugin add mcp-markitdown@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-markitdown/dev.kiro/install.sh -``` - ### 必要な環境変数 - 追加の環境変数は不要です。 diff --git a/plugins/mcp/mcp-markitdown/dev.kiro/install.sh b/plugins/mcp/mcp-markitdown/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-markitdown/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-notion/.claude-plugin/plugin.json b/plugins/mcp/mcp-notion/.claude-plugin/plugin.json index 8539ff266..308155dc2 100644 --- a/plugins/mcp/mcp-notion/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-notion/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-notion", - "version": "2.0.2", + "version": "3.0.0", "description": "Notion MCP server for note management and knowledge base operations", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-notion/.codex-plugin/plugin.json b/plugins/mcp/mcp-notion/.codex-plugin/plugin.json index 6641513d4..958e1e588 100644 --- a/plugins/mcp/mcp-notion/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-notion/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-notion", - "version": "2.0.2", + "version": "3.0.0", "description": "Notion MCP server for note management and knowledge base operations", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-notion/README.md b/plugins/mcp/mcp-notion/README.md index 6f4dcf859..2e80e97d3 100644 --- a/plugins/mcp/mcp-notion/README.md +++ b/plugins/mcp/mcp-notion/README.md @@ -47,14 +47,6 @@ NOTION_TOKEN= codex plugin add mcp-notion@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-notion/dev.kiro/install.sh -``` - ### 必要な環境変数 - 追加の環境変数は不要です。 diff --git a/plugins/mcp/mcp-notion/dev.kiro/install.sh b/plugins/mcp/mcp-notion/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-notion/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-playwright/.claude-plugin/plugin.json b/plugins/mcp/mcp-playwright/.claude-plugin/plugin.json index 31c556dfb..dc96010c7 100644 --- a/plugins/mcp/mcp-playwright/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-playwright/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-playwright", - "version": "2.0.3", + "version": "3.0.0", "description": "Playwright MCPを自動セットアップするプラグイン。Chromiumブラウザの自動インストールとMCP設定を提供します。", "author": { "name": "NDF Team", diff --git a/plugins/mcp/mcp-playwright/.codex-plugin/plugin.json b/plugins/mcp/mcp-playwright/.codex-plugin/plugin.json index fdf46d1ed..eb38dfb6f 100644 --- a/plugins/mcp/mcp-playwright/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-playwright/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-playwright", - "version": "2.0.3", + "version": "3.0.0", "description": "Playwright MCPを自動セットアップするプラグイン。Chromiumブラウザの自動インストールとMCP設定を提供します。", "author": { "name": "NDF Team", diff --git a/plugins/mcp/mcp-playwright/README.md b/plugins/mcp/mcp-playwright/README.md index 91b1832bc..435239178 100644 --- a/plugins/mcp/mcp-playwright/README.md +++ b/plugins/mcp/mcp-playwright/README.md @@ -31,14 +31,6 @@ Playwright MCPを自動的にセットアップし、ブラウザ自動化機能 codex plugin add mcp-playwright@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-playwright/dev.kiro/install.sh -``` - ### 必要な環境変数 - `HOME` diff --git a/plugins/mcp/mcp-playwright/dev.kiro/install.sh b/plugins/mcp/mcp-playwright/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-playwright/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-redash/.claude-plugin/plugin.json b/plugins/mcp/mcp-redash/.claude-plugin/plugin.json index 6802c2b76..ee098bf3b 100644 --- a/plugins/mcp/mcp-redash/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-redash/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-redash", - "version": "3.0.2", + "version": "4.0.0", "description": "Redash MCP server with multi-environment support (arbitrary suffix)", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-redash/.codex-plugin/plugin.json b/plugins/mcp/mcp-redash/.codex-plugin/plugin.json index 935424536..99ef94772 100644 --- a/plugins/mcp/mcp-redash/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-redash/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-redash", - "version": "3.0.2", + "version": "4.0.0", "description": "Redash MCP server with multi-environment support (arbitrary suffix)", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-redash/README.md b/plugins/mcp/mcp-redash/README.md index 83b9d288e..044614759 100644 --- a/plugins/mcp/mcp-redash/README.md +++ b/plugins/mcp/mcp-redash/README.md @@ -33,14 +33,6 @@ Redash MCP サーバーをマルチ環境対応で利用するための Claude C codex plugin add mcp-redash@ai-plugins ``` -### Kiro CLI - -Kiro では repository clone 後、対象 plugin の installer を project root で実行します。 - -```bash -bash plugins/mcp/mcp-redash/dev.kiro/install.sh -``` - ### 必要な環境変数 - `REDASH_API_KEY` diff --git a/plugins/mcp/mcp-redash/dev.kiro/install.sh b/plugins/mcp/mcp-redash/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-redash/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-serena/.claude-plugin/plugin.json b/plugins/mcp/mcp-serena/.claude-plugin/plugin.json index 6b0e8a691..4f2ef5986 100644 --- a/plugins/mcp/mcp-serena/.claude-plugin/plugin.json +++ b/plugins/mcp/mcp-serena/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-serena", - "version": "2.1.10", + "version": "3.0.0", "description": "Serena MCP server (serena-agent 1.7.0) for symbol-level code reading and editing, with a per-project language-server setup skill and hooks that steer repeated grep/reads toward symbol tools", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-serena/.codex-plugin/plugin.json b/plugins/mcp/mcp-serena/.codex-plugin/plugin.json index 7efd53eb2..dcf356b1f 100644 --- a/plugins/mcp/mcp-serena/.codex-plugin/plugin.json +++ b/plugins/mcp/mcp-serena/.codex-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "mcp-serena", - "version": "2.1.10", + "version": "3.0.0", "description": "Serena MCP server (serena-agent 1.7.0) for symbol-level code reading and editing, with a per-project language-server setup skill and hooks that steer repeated grep/reads toward symbol tools", "author": { "name": "takemi-ohama", diff --git a/plugins/mcp/mcp-serena/README.md b/plugins/mcp/mcp-serena/README.md index 166c7df83..de628ae10 100644 --- a/plugins/mcp/mcp-serena/README.md +++ b/plugins/mcp/mcp-serena/README.md @@ -1,7 +1,7 @@ # mcp-serena Serena MCP サーバを提供するプラグインです。シンボル単位の読み書き(定義・参照・シンボル単位の -置換)と、言語サーバの診断を Claude Code・Codex・Kiro CLI で使えるようにします。 +置換)と、言語サーバの診断を Claude Code・Codex で使えるようにします。 ## 最初に: 言語サーバの設定はプロジェクトごとに行う @@ -51,14 +51,6 @@ Codex では `--context codex` で起動します(`.codex.mcp.json`)。 **Codex では、プラグインの hook は信頼するまで動きません**(Codex 0.156 で実測。何も表示されずに走らないだけになる)。対話の Codex で `/hooks` を開き、mcp-serena の hook を信頼してください。起動時に出る「Review hooks」の案内からも信頼できます。 -### Kiro CLI - -repository を clone した後、対象のプロジェクトの根で installer を実行します。 - -```bash -bash plugins/mcp/mcp-serena/dev.kiro/install.sh -``` - ### 前提 - `uv`(`uvx`)。Serena は `uvx --from serena-agent==1.7.0` で起動します @@ -75,9 +67,6 @@ bash plugins/mcp/mcp-serena/dev.kiro/install.sh # Codex codex plugin marketplace upgrade ai-plugins codex plugin add mcp-serena@ai-plugins - -# Kiro CLI -bash plugins/mcp/mcp-serena/dev.kiro/install.sh ``` ## 公式の `serena` プラグインと併用しない @@ -98,7 +87,7 @@ bash plugins/mcp/mcp-serena/dev.kiro/install.sh | ランタイム | 定義 | 文脈 | | --- | --- | --- | -| Claude Code / Kiro CLI | `.mcp.json` | `claude-code` | +| Claude Code | `.mcp.json` | `claude-code` | | Codex | `.codex.mcp.json` | `codex` | どちらも `SERENA_HOME=.serena`(起動したディレクトリからの相対)で動きます。worktree ごとに diff --git a/plugins/mcp/mcp-serena/dev.kiro/install.sh b/plugins/mcp/mcp-serena/dev.kiro/install.sh deleted file mode 100755 index 9b57feca6..000000000 --- a/plugins/mcp/mcp-serena/dev.kiro/install.sh +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY diff --git a/plugins/mcp/mcp-serena/tests/test_serena_lsp_kiro.py b/plugins/mcp/mcp-serena/tests/test_serena_lsp_kiro.py deleted file mode 100644 index c63c0eb97..000000000 --- a/plugins/mcp/mcp-serena/tests/test_serena_lsp_kiro.py +++ /dev/null @@ -1,21 +0,0 @@ -"""Kiro の installer(AC29)。agentSpawn へ写った SessionStart は Kiro では何も出さない(決定 15)。""" - -import json -import os -import subprocess - -from serena_lsp_testlib import PLUGIN, files_of, make_repo - - -def test_kiro_agent_spawn_prints_nothing_without_claude_plugin_root(tmp_path): - project = make_repo(tmp_path / "p", files_of(py=30)) - env = {k: v for k, v in os.environ.items() if k not in ("CLAUDE_PLUGIN_ROOT", "PLUGIN_ROOT", "CODEX_PLUGIN_ROOT")} - subprocess.run(["bash", str(PLUGIN / "dev.kiro/install.sh"), "--project", str(project)], check=True, capture_output=True, env=env) - agent = json.loads((project / ".kiro/agents/default.json").read_text()) - commands = [h["command"] for h in agent["hooks"]["agentSpawn"] if "serena-lsp.py" in h["command"]] - assert commands - for command in commands: - proc = subprocess.run( - ["sh", "-c", command], input=json.dumps({"cwd": str(project)}), capture_output=True, text=True, env=env, cwd=project - ) - assert (proc.returncode, proc.stdout) == (0, "") diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 753bfb3b8..37e416598 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.3", - "description": "Claude Code plugin (v10.27.3): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 49 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.27.5", + "description": "Claude Code plugin (v10.27.5): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 49 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 d9e205d01..925630ca3 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.3", - "description": "Codex plugin (v10.27.3): 45 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.27.5", + "description": "Codex plugin (v10.27.5): 45 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 7d5bbbcfb..51c6caa8c 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -4,14 +4,13 @@ PR 運用、レビュー、調査、実装計画、仕様書化、開発方法 完了判定)、Docker container access、statusline、外部 AI 委譲、Slack 通知を提供します。 配布物は `plugins/ndf/` の 1 ディレクトリにまとまっています。Skill の実体は `skills/` の -1 箇所だけで、どのランタイムへ配るかは `manifests/*-skills.txt` が決めます。 +1 箇所だけで、どのランタイムへ配るかは `manifests/*-skills.txt` が決めます。配布先は Claude Code と +Codex の 2 つです。Kiro CLI と agy へは配らず、cross-review / cross-refactoring の相手役として起動するだけです。 | ランタイム | 公開 Skill | マニフェスト | | --- | --- | --- | | Claude Code | 49 個 | `.claude-plugin/plugin.json` | | Codex | 45 個 | `.codex-plugin/plugin.json` | -| Kiro CLI | 46 個 | `dev.kiro/install.sh`(プラグイン機構が無いため installer で導入) | -| agy | 45 個 | `dev.agy/plugin.json`(取得元の登録が無いため clone から導入) | ## レイアウト @@ -26,23 +25,9 @@ plugins/ndf/ ├── hooks/claude.json # Claude Code の PreToolUse / SessionStart / Stop hook ├── hooks/codex.json # Codex の PreToolUse / SessionStart / Stop hook ├── scripts/ # hook と Skill から呼ぶスクリプト -├── dev.kiro/ # Kiro CLI の installer・エージェント定義・プロンプト -├── dev.agy/ # agy のマニフェスト・hook 定義・配布 Skill への symlink └── README.md ``` -`dev.kiro` と `dev.agy` は Agent Plugins 仕様 §8.2 が定めるクライアント拡張ディレクトリです。 - -`dev.agy/skills/` は `manifests/agy-skills.txt` から生成する symlink です -(`scripts/build-runtime-plugins.sh`)。`dev.agy/scripts` は `scripts/` を指し、実体を -4 ランタイムで共有します。**agy は配る Skill を絞る手段を利用者側の設定にしか持たない**ため、 -絞り込みはここへ何を並べるかで表します。 - -`dev.agy/agents/` は `agents/` から同じスクリプトが生成する写しです。agy は `model` に tier 名 -(`flash` / `pro` / `inherit`)しか受け付けないため、frontmatter の `model:` の 1 行だけを -対応表(`sonnet` → `flash`、`opus` → `pro`)で置き換えます。対応表は `scripts/lib/agy_agents.py` -が持ちます。写しは手で直さず、`agents/` を直してから生成し直します(`--check` が食い違いを落とします)。 - **`skills/` に置く Skill は、少なくとも 1 つの manifest へ載せます。** 配らない Skill の 置き場所(`optional-skills/`)は v10.5.0 で無くしました。どこからも起動できない Skill を 置き続ける理由が、公開数を一定に保つことだけだったためです。 @@ -67,62 +52,17 @@ codex plugin marketplace add https://github.com/devbasex/ai-plugins codex plugin add ndf@ai-plugins ``` -### Kiro CLI - -Kiro CLI にはプラグイン機構が無いため、installer が `.kiro/` へ配置します。リポジトリ root で -実行してください。 - -```bash -# 基本(Skills + steering + agentSpawn hook) -bash plugins/ndf/dev.kiro/install.sh - -# Slack 通知も有効化 -bash plugins/ndf/dev.kiro/install.sh --with-slack - -# Slack 通知 + Kiro 側 Codex MCP 設定も生成 -bash plugins/ndf/dev.kiro/install.sh --with-slack --with-codex - -# 書き込みを行わず内容だけ確認 -bash plugins/ndf/dev.kiro/install.sh --dry-run -``` - -導入後は `kiro-cli chat --agent ndf` で起動します。installer は `manifests/kiro-skills.txt` に -載る Skill だけを `.kiro/skills/` へ symlink します。版数は `.claude-plugin/plugin.json` から -読み取り、実行時に `NDF バージョン: <版数>` として表示します。 - -導入済みプロジェクトの版数は次で確認できます。 - -```bash -python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v10.27.3) -``` - -### agy +### agy・Kiro CLI への配布の終了 -agy にも取得元の登録がありません。clone した `dev.agy` を直接導入します。**更新の副コマンドが -無い**ため、新しい版へは入れ直します(`install` は上書きするが、消したファイルは実体に残る)。 +agy と Kiro CLI へは配りません。最後に配ったのは v10.27.4 です。cross-review と cross-refactoring は、 +Claude Code か Codex をホストにして agy・Kiro CLI を相手役として今までどおり起動します。相手役に配布物は要りません。 -```bash -agy plugin install plugins/ndf/dev.agy # 初回 -agy plugin uninstall ndf && agy plugin install plugins/ndf/dev.agy # 新しい版へ -``` - -導入すると `manifests/agy-skills.txt` に載る Skill 45 個と、エージェント 11 個(専門 8 個と、3 層の定義 3 個(supervisor 2・worker 1))、hook 1 個が -`~/.gemini/config/plugins/ndf/` へコピーされます。symlink は実体へ解決されてコピーされるため、 -clone を消しても導入した内容は残ります。 - -**hook はコピーされるだけで、agy はそれを読み込みません**(agy 1.1.26 で実測)。読む先は -`~/.gemini/config/hooks.json` の 1 か所だけです。次を実行して差し込みます。**冪等で、他の -項目には触れません**(`--dry-run` で内容を確認でき、`--uninstall` で外せます)。 +前の版で入れた手元は、こちらからは消しません。残すか消すかを選べます。 -```bash -bash plugins/ndf/dev.agy/install-hooks.sh -``` - -```bash -agy plugin list -# => {"imports":[{"name":"ndf","source":"antigravity","components":["skills","agents","hooks"]}]} -``` +| ランタイム | 残すとき | 消すとき | +| --- | --- | --- | +| agy | 導入の写し(`~/.gemini/config/plugins/ndf/`)だけで動き続ける。新しい版は届かない | 写しが残るうちに `bash ~/.gemini/config/plugins/ndf/install-hooks.sh --uninstall` で `~/.gemini/config/hooks.json` の項目を外し、`agy plugin uninstall ndf` を打つ | +| Kiro CLI | `.kiro/agents/ndf.json` の hook は clone の `plugins/ndf/scripts/` を呼ぶため、clone を進めても動く。Skill を固定したいときは clone を `ndf--v10.27.4` のタグへ戻す | 前の installer は `.kiro/agents/ndf.json` に利用者が足した `mcpServers`・hook・その他の鍵を引き継いでいたため、消す前に `cp .kiro/agents/ndf.json ndf.json.bak` で退避し、残したい項目を使い続けるエージェント(`.kiro/agents/` の別のファイル)へ写す。`ndf` を既定のエージェントにしたなら `kiro-cli agent set-default kiro_default` で戻し、`.kiro/agents/ndf.json`・`.kiro/steering/ndf-policies.md`・`.kiro/prompts/` の NDF の 5 本(`--with-codex` で入れたなら `codex.md` も)・`.kiro/skills/` の NDF への symlink(mcp-* を入れたなら `.kiro/mcp_runtime/` と `.kiro/agents/default.json` の該当の項目も)を消す | ## 更新情報 @@ -137,34 +77,22 @@ Skill 名は変わらないため `/playwright-` まで打てば候補に出ま ```bash /plugin install playwright-kit@ai-plugins # Claude Code codex plugin add playwright-kit@ai-plugins # Codex -bash plugins/playwright-kit/dev.kiro/install.sh # Kiro CLI ``` ## Hooks -### worktree 運用(4 ランタイム共通) +### worktree 運用(Claude Code と Codex) 開発の変更を `.worktrees/` の worktree の中で行い、リポジトリを clone したディレクトリ (メインディレクトリ)では行わない運用を支えます。**編集は止めません。** 案内が出ても操作は成立します。 -| 起きること | 担う hook | Claude Code | Codex | Kiro CLI | agy | -| --- | --- | --- | --- | --- | --- | -| メインディレクトリの保護対象パスを編集しようとすると案内が出る | tool 実行前 | `PreToolUse` | `PreToolUse` | — | `PreToolUse` | -| worktree で作業する旨の案内がプロンプトごとに出る | プロンプト送信時 | — | — | `userPromptSubmit` | — | -| メインディレクトリに残った未コミット変更が提示される | セッション開始時 | `SessionStart` | `SessionStart` | `agentSpawn` | `PreInvocation` | -| メインディレクトリのブランチが稼働中の worktree へ追従する(既定では動かさない。worktree の設定の `follow_branch: true` で有効にする) | セッション開始時 | `SessionStart` | `SessionStart` | `agentSpawn` | `PreInvocation` | - -Kiro CLI に tool 実行前の案内が無いのは、この事象でモデルへ案内を渡す手段が終了コード 2 に -限られ、それが tool の実行を拒否するためです。拒否しない方針のもとでは置けないため、パスを -見ない案内をプロンプト送信時の hook が担います。 - -**agy の 2 つは `install-hooks.sh` を実行するまで届きません。** agy はプラグインの -`hooks.json` を読み込まないためです(「インストール / agy」を参照)。 +| 起きること | 担う hook | Claude Code | Codex | +| --- | --- | --- | --- | +| メインディレクトリの保護対象パスを編集しようとすると案内が出る | tool 実行前 | `PreToolUse` | `PreToolUse` | +| メインディレクトリに残った未コミット変更が提示される | セッション開始時 | `SessionStart` | `SessionStart` | +| メインディレクトリのブランチが稼働中の worktree へ追従する(既定では動かさない。worktree の設定の `follow_branch: true` で有効にする) | セッション開始時 | `SessionStart` | `SessionStart` | -**agy は案内を作る時点と渡せる時点が離れています。** tool 実行前の hook がモデルへ文言を返す -口は拒否のときにしか働かないため、案内はセッションの記録へ積み、次のモデル呼び出しの前に -`injectSteps` で渡します。セッション開始時にあたる事象も持たないため、モデル呼び出しの通し番号が -0 のときを開始時として扱います。 +前の版で agy・Kiro CLI へ入れた手元の hook も、同じ `scripts/` の入口を呼び続けます(「agy・Kiro CLI への配布の終了」)。 **この仕組みはリポジトリ側の設定ファイル `.ndf/worktree.json` があるときだけ動きます。** 設定が無いリポジトリでは、いずれの hook も何も出力せず終了コード 0 で終わります。 @@ -206,8 +134,6 @@ bash <プラグインのパス>/scripts/worktree-setup.sh init | --- | --- | --- | | Claude Code | hook + 規約 | hook + 再開コマンド | | Codex | 規約だけ | 再開コマンドだけ | -| Kiro CLI | 規約だけ | 再開コマンドだけ | -| agy | 規約だけ | 再開コマンドだけ | 規約は `skills/development-workflow/references/waiting.md`(待ち方)と `skills/development-workflow/references/context-window.md`(会話を切る)にあります。 @@ -221,7 +147,7 @@ bash <プラグインのパス>/scripts/worktree-setup.sh init プロジェクトごとの働き方は `.ndf/ask.json` で変えます(無ければ `enforce`)。今の振る舞いへ戻すには `{"version": 1, "mode": "off"}` を書きます。 -区分の表と宣言の形は `skills/development-workflow/references/asking.md`、ランタイムごとの扱い(Codex・Kiro CLI・agy は対象外)は +区分の表と宣言の形は `skills/development-workflow/references/asking.md`、ランタイムごとの扱い(Codex は対象外)は [docs/ndf-plugin-reference.md の「承認ゲートの外の問い合わせ」](../../docs/ndf-plugin-reference.md#承認ゲートの外の問い合わせ) にあります。 ### 学習の設定を Off にする(Claude Code と Codex) @@ -300,8 +226,6 @@ Codex の Stop・PermissionRequest hook(`hooks/codex.json`)は `NDF_CODEX_SL ません。** `~/.codex/config.toml` の `[hooks.state]` に対象 hook の `enabled = true` が要ります。 `/hooks` で対象 hook を確認し、利用するプロジェクトで有効化してください。 -Kiro CLI では installer が `.kiro/agents/ndf.json` の `hooks` を生成します。 - ## Slack 通知 利用者の回答か承認が無いと進まない時点でだけ、Slack へ知らせます。応答が終わっても、待っていなければ @@ -379,9 +303,6 @@ NDF_CODEX_SLACK_NOTIFY=true 機密値は `.env` などで管理し、リポジトリへコミットしないでください。通知の記録(同じ待ちを 2 度送らないための 直前の 1 件)は `${XDG_STATE_HOME:-~/.local/state}/ndf/wait-notify/` にセッションごとに置き、7 日で消します。 -**Kiro は `install.sh --with-slack` を打ち直してください。** 通知の入口は `scripts/wait-notify.py` の 1 本です。 -`stop` hook が配布物に無い `scripts/slack-notify.js` を指す `.kiro/agents/ndf.json` では、通知が届きません。 - ## 外部 AI 委譲 `/ndf:external-ai` skill または `corder` エージェントから外部 AI 委譲を使う場合は、利用環境に @@ -429,7 +350,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.27.3/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.5/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -451,14 +372,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.3/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.5/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.27.3 +# => ndf@ai-plugins installed, enabled 10.27.5 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 @@ -479,12 +400,6 @@ codex plugin list | grep 'ndf@ai-plugins' 配布した Skill は抑止すると `$` も効きません**。この差は codex-cli 0.146.1 で実測した もので、公式ドキュメントには記載がありません。 -## Kiro CLI の詳細 - -installer の主なオプション・既定エージェントの切り替え・導入スコープ・旧バージョンからの -移行・再インストール時に保持される設定と、Kiro CLI 側の制限は -[docs/kiro-cli.md](docs/kiro-cli.md) にあります。 - ## 実機検証の記録 kiro-cli の実機検証と、Skill 数がコンテキスト量へ与える影響の実測は diff --git a/plugins/ndf/dev.agy/agents/code-reviewer.md b/plugins/ndf/dev.agy/agents/code-reviewer.md deleted file mode 100644 index 3ed2d6ff8..000000000 --- a/plugins/ndf/dev.agy/agents/code-reviewer.md +++ /dev/null @@ -1,111 +0,0 @@ ---- -name: code-reviewer -model: flash -description: | - git diff / PR差分の一般的なコードレビューに特化したエージェント。可読性、設計、バグ、セキュリティ、テスト観点からチェックし、重要度順に指摘を返します。Codex CLIによる外部AI第二意見が欲しい場合は代わりにcorderエージェントを使ってください。 - **Use this agent proactively** for: git diff review, PR review before merge, style/design/bug/security checks on recent changes. - 積極的に委譲すべき場面: git diffレビュー、PRマージ前チェック、最近のコミットの品質レビュー、可読性・設計・バグ・セキュリティの総合チェック。 ---- - -# コードレビュアーエージェント - -あなたはコードレビューの専門家です。git差分またはPR差分を読み、**重要度順**に指摘を整理して返します。corderと異なり外部AI(Codex)は使わず、Claude単体で広い観点から素早くレビューします。 - -## 専門領域 - -### 1. 可読性 -- 命名の明確さ -- 関数/クラスの責務の単一性 -- マジックナンバー、長すぎる関数 -- コメントの必要性(WHYが書かれているか) - -### 2. 設計 -- 重複、early abstraction、YAGNI違反 -- 層分離、依存の向き -- エラーハンドリングの過不足 -- 既存アーキテクチャとの整合 - -### 3. バグ -- null / undefined / 空配列エッジケース -- 型ミスマッチ、暗黙変換の罠 -- 非同期処理の競合、例外の握り潰し -- off-by-one、境界条件 - -### 4. セキュリティ(軽量チェック) -- 機密情報のハードコード、ログ出力 -- ユーザー入力のエスケープ漏れ(SQLi, XSS) -- 認可チェック欠落 -- 重要: 本格的なOWASP Top10レビューは**qaエージェント**に委譲 - -### 5. テスト -- 変更箇所のテスト有無 -- カバレッジの妥当性 -- テストが仕様を表現しているか - -## 作業プロセス - -1. **差分取得**: `git diff main...HEAD`, `git diff HEAD`, または指定PR番号で `gh pr diff` -2. **全体把握**: 変更の目的を1文で言語化(PR説明文やコミットメッセージから) -3. **ファイル別レビュー**: 変更行ごとに観点適用 -4. **重要度判定**: 🛑 blocker / ⚠️ major / 💡 suggestion / 💅 nit -5. **構造化報告**: ファイル、行番号、観点、理由、修正案の順で提示 -6. **ポジティブ指摘**: 良い変更は明示的に認める - -## 指摘フォーマット - -``` -### : -[🛑 blocker | ⚠️ major | 💡 suggestion | 💅 nit] <一行要約> - -**問題**: <具体的に何がまずいか> -**理由**: <なぜそれがまずいか> -**提案**: <具体的な修正案(コード片)> -``` - -## 使用ツール - -### Claude Code built-in -- `Bash` - `git diff`, `gh pr diff`, `gh pr view` -- `Read` - 差分周辺コンテキストの確認 -- `Grep` - 同じパターンが他にないか検索 - -## レビューの原則 - -- **原因を指摘する**: 同じ原因の問題が他にもありそうなら、それも指摘する -- **既存コードを尊重**: スタイル統一目的の大改造は指摘しない -- **過剰指摘を避ける**: すべて指摘せず、重要度 major 以上を中心に -- **YAGNI**: 実装されていない将来機能への「〜も追加すべき」指摘はしない -- **根拠を添える**: 「このコードベースのルールで」「このバグが起きる」のように、このコードベースに即した根拠を書く - -## サブエージェント呼び出しの制約 - -他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa, debugger, devops-engineer)を呼び出してはいけません。Codex第二意見が必要な場合は **呼び出し元にcorderを提案**してください。 - -## 使用例 - -### 現在のブランチのレビュー -``` -入力: 「このブランチをレビューして」 -処理: - 1. Bash: git diff main...HEAD - 2. Bash: git log main..HEAD --oneline - 3. 全体意図の把握 - 4. ファイル別に指摘を整理 - 5. 重要度順に報告 -``` - -### PRレビュー -``` -入力: 「PR #42 をレビュー」 -処理: - 1. Bash: gh pr view 42 - 2. Bash: gh pr diff 42 - 3. レビュー実施 - 4. 重要度順に報告(blocker/major/suggestion/nit) -``` - -## 制約事項 - -- diff が巨大な場合(>1000行)はファイル単位で分割レビュー -- 生成コード(lockファイル、migration等)は詳細レビュー対象外 -- バイナリファイルはレビュー不可 diff --git a/plugins/ndf/dev.agy/agents/corder.md b/plugins/ndf/dev.agy/agents/corder.md deleted file mode 100644 index d5ab5c020..000000000 --- a/plugins/ndf/dev.agy/agents/corder.md +++ /dev/null @@ -1,123 +0,0 @@ ---- -name: corder -model: flash -description: | - Codex CLI 直接実行による外部AI第二意見レビューや、Serena/Context7 MCPを活用する大規模コード調査の専門エージェント。通常のコード編集はメインセッションで直接行い、このエージェントは独立したAIレビューが必要な場合や、広範なシンボル横断調査が必要な場合にのみ委譲してください。 - **Use this agent proactively** for: Codex CLI-based independent code review (second opinion), large-scale symbol analysis via Serena, library API lookup via Context7. - 積極的に委譲すべき場面: Codex CLIによるAIコードレビュー(第二意見)、Serenaを使う大規模シンボル解析、Context7によるライブラリAPI調査。 ---- - -# コーディングエージェント - -あなたは高品質なコード生成とレビューの専門家です。Codex CLI(バックグラウンド実行)、Serena MCP、Context7 MCPを活用して、最新のベストプラクティスに基づいた、保守性の高いコードを生成・レビューします。 - -## Codex の呼び方 - -`mcp__codex__*`(Codex MCP サーバ)は無い。Codex は `/ndf:external-ai` skill の `external-ai.py run codex` で呼ぶ(下の「Codex CLI」)。 - -## 専門領域 - -### 1. コード設計と実装 -- クリーンで読みやすいコードの作成 -- 設計パターンとアーキテクチャの適用 -- テスタブルなコード設計 -- パフォーマンスを考慮した実装 - -### 2. コード品質保証 -- Codex CLI による第二意見レビュー(ファイル逐語照合、大規模コードベース調査) -- セキュリティ脆弱性のチェック -- ベストプラクティスの適用確認 -- リファクタリング提案 - -### 3. コードベース理解 -- Serena MCPによるシンボル検索と分析 -- 既存コードの構造理解 -- 依存関係の把握 - -### 4. 最新情報の活用 -- Context7による最新のコード例取得 -- フレームワーク・ライブラリの最新ドキュメント参照 - -## 使用可能なツール - -### Codex CLI(`external-ai.py run` の 1 行) - -起動・上限つきの待ち・回収は `/ndf:external-ai` skill の `scripts/external-ai.py` が行う(手順は同 skill の「共通の実行手順」、Codex 固有の差分は `references/cli-codex.md`)。 - -```bash -# 1. プロンプトを一意の一時ファイルに書く(本文はファイル書き込みツールで。出力先に $FINAL を書く) -TMP=$(mktemp -d) -FINAL="$TMP/codex-result.md" - -# 2. 起動から回収までを 1 行で($SKILL_DIR は /ndf:external-ai skill のディレクトリ) -python3 "$SKILL_DIR/scripts/external-ai.py" run codex \ - --prompt-file "$TMP/prompt.md" --output-file "$FINAL" \ - --phase review --workdir "$PWD" -# => 最後の 1 行が JSON。{"status": "ok", ..., "metrics": {"outcome": "ok", "result": "...", ...}} -``` - -- Claude Code では Bash の `run_in_background: true` でこの 1 行を起動し、完了通知を 1 回受ける(決まりは `development-workflow/references/waiting.md`) -- `status` が `ok` なら `metrics.result` のファイルを読む。`stopped` なら `metrics.outcome`(`no_result` / `timeout` / `usage_limit` / `auth` など)に応じた次の手を `/ndf:external-ai` skill の表で選ぶ -- CLI の有無・認証・モデルを先に確かめるときは `external-ai.py check codex`。`metrics.default_model` が返ったら、続く `run` に `--model <その名前>` を渡す - -### Serena MCP -- `mcp__plugin_mcp-serena_serena__*`(Claude Code。Codex では `mcp__serena__*`)- シンボル検索、リファレンス検索、コード編集 -- **memory系は使用禁止**(NDFポリシー) - -### Context7 MCP -- `mcp__context7__*` - 最新のコード例とドキュメント取得 - -## 作業プロセス - -1. **要件理解**: 実装する機能の要件を明確化 -2. **コードベース調査**: Serenaで既存コード構造を理解 -3. **最新情報収集**: Context7で最新のベストプラクティスを確認 -4. **設計**: アーキテクチャと実装方針を決定 -5. **実装**: クリーンなコードを作成 -6. **レビュー**: `external-ai.py run codex`(`/ndf:external-ai` skill)で独立レビューを依頼 -7. **改善**: レビュー結果に基づいて修正 -8. **テスト**: 動作確認とテストコード作成 - -## コーディングスタイル - -- DRY(Don't Repeat Yourself)原則の遵守 -- SOLID原則の適用 -- 明確な変数名・関数名の使用 -- 適切なコメントとドキュメント -- エラーハンドリングの実装 -- セキュリティを考慮した実装 - -## ベストプラクティス - -- 実装前にSerenaで既存コードパターンを確認 -- Context7で最新のフレームワーク仕様を参照 -- 実装後は必ず Codex CLI (`/ndf:external-ai`) で第二意見レビュー -- テストコードも併せて作成 -- 破壊的変更は事前に影響範囲を確認 - -## サブエージェント呼び出しの制約 - -### 無限呼び出し防止ルール - -**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 - -❌ **サブエージェント呼び出し禁止:** -- 他のサブエージェント(`ndf:director`, `ndf:data-analyst`, `ndf:researcher`, `ndf:qa`, `ndf:debugger`, `ndf:devops-engineer`, `ndf:code-reviewer`)を呼び出してはいけません - -✅ **利用可能:** -- `external-ai.py run codex` による Codex CLI の呼び出し -- Serena MCP、Context7 MCP等の各種MCPツール -- ただし、無限ループが発生しないよう注意してください - -### 理由 - -- サブエージェント間の相互呼び出しは無限ループや core dump を引き起こす可能性がある -- 専門的なタスクは直接ツール/CLIを使用して実行する -- 複雑なタスクの分割や他エージェントへの委譲は director エージェントの役割 - -## 制約事項 - -- セキュリティリスクのあるコードは作成しない -- 非推奨のAPIやライブラリは使用を避ける -- パフォーマンスへの影響を常に考慮 -- プロジェクトのコーディング規約を遵守 diff --git a/plugins/ndf/dev.agy/agents/data-analyst.md b/plugins/ndf/dev.agy/agents/data-analyst.md deleted file mode 100644 index 633c7af91..000000000 --- a/plugins/ndf/dev.agy/agents/data-analyst.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: data-analyst -model: flash -description: | - BigQueryとDBHubを活用したデータ分析とSQL操作の専門エージェント。 - **Use this agent proactively** for: SQL queries, BigQuery operations, data analysis, statistics, data export (CSV/JSON/Excel). - 積極的に委譲すべき場面: SQLクエリ、BigQuery操作、データ分析、統計処理、データエクスポート。 ---- - -# データアナリストエージェント - -あなたはデータ分析の専門家です。BigQueryとDBHubのMCPツールを活用して、SQL生成、実行、結果の解釈、データのファイル出力を担当します。 - -## 専門領域 - -### 1. SQL生成と実行 -- BigQueryとDBHubを使ったクエリ実行 -- 効率的なSQLの設計と最適化 -- 複雑なJOIN、サブクエリ、ウィンドウ関数の活用 -- データ集計と統計分析 - -### 2. データ解釈 -- クエリ結果の分析と解釈 -- データの傾向とパターンの発見 -- 異常値やエラーの検出 -- ビジネスインサイトの抽出 - -### 3. データ出力 -- CSV、JSON、Excel、Markdownテーブル形式でのデータエクスポート -- 結果データのファイル保存 -- レポート生成とデータ可視化の準備 -- データサマリーの作成 - -## SQL最適化パターン - -遅いクエリは EXPLAIN で実行計画を確認し、該当パターンを適用してから再度 EXPLAIN で改善を確認する。 - -| パターン | 問題 | 解決策 | -|---------|------|--------| -| N+1削減 | ループ内SQL | JOINで1回に統合 | -| インデックス | フルスキャン | WHERE/JOIN列にインデックス | -| JOIN最適化 | 不要な大規模JOIN | 必要な列のみ取得 | -| ウィンドウ関数 | 複雑なサブクエリ | ROW_NUMBER(), RANK()使用 | -| EXISTS vs IN | 遅いIN句 | EXISTSで書く | -| LIMIT活用 | 全件取得 | SQLでページング | - -| DO | DON'T | -|----|-------| -| EXPLAINで実行計画を確認 | 不要なDISTINCT | -| インデックスは選択的に作成 | 関数をWHERE句の列に適用 | -| 必要な列のみSELECT | 過剰なJOIN | -| 早期フィルタリング | サブクエリの多用 | - -## エクスポート形式の選択 - -| 形式 | 用途 | 注意点 | -|------|------|--------| -| CSV | 単純なデータ、他システム連携 | Excelで開くならUTF-8 BOMを付与 | -| JSON | API連携、構造化データ | 日付は ISO 8601 に統一 | -| Excel | 複雑なレポート、書式設定 | 1シート1,048,576行の上限を超えたら分割 | -| Markdown | ドキュメント埋め込み | パイプ文字のエスケープ | - -| DO | DON'T | -|----|-------| -| ヘッダー行を含める | 全データをメモリに展開 | -| 大きなデータはストリーミングで書き出す | 日付フォーマットの不統一 | -| 文字コードは UTF-8 に統一 | 特殊文字のエスケープ忘れ | - -## 使用可能なMCPツール - -### BigQuery MCP -- `mcp__plugin_mcp-bigquery_bigquery__*` - BigQueryクエリ実行(`execute_sql_readonly`)、データセット・テーブルの参照 - -### DBHub MCP -- `mcp__dbhub__*` - PostgreSQL、MySQL、SQLite等のデータベース操作 - -## 作業プロセス - -1. **要件理解**: ユーザーの分析ニーズを明確化 -2. **データ探索**: スキーマ確認、テーブル構造の理解 -3. **SQL設計**: 効率的なクエリの作成 -4. **実行と検証**: クエリ実行、結果の妥当性確認 -5. **解釈と報告**: データの意味を解釈し、わかりやすく説明 -6. **ファイル出力**: 必要に応じて結果をファイル保存 - -## ベストプラクティス - -- クエリ実行前にスキーマを確認 -- 大量データの場合はLIMIT句で確認 -- エラーが発生した場合は原因を分析し、修正案を提示 -- 結果は常に解釈付きで報告 -- ファイル出力時は適切な形式とファイル名を選択 - -## サブエージェント呼び出しの制約 - -### 無限呼び出し防止ルール - -**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 - -❌ **サブエージェント呼び出し禁止:** -- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** - -✅ **MCP利用可能:** -- BigQuery MCP、DBHub MCP等の各種MCPツールは利用可能 -- ただし、無限ループが発生しないよう注意してください - -### 理由 - -- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある -- 専門的なタスクは直接MCPツールを使用して実行する -- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 - -## 制約事項 - -- 破壊的な操作(DELETE、DROP等)は慎重に扱い、ユーザーに確認する(`AskUserQuestion` の `metadata.source` は、利用者のデータを変えるなら `ndf-ask:redline:C3`、戻せない操作なら `ndf-ask:redline:C4`) -- 個人情報や機密データの取り扱いに注意 -- パフォーマンスに影響する重いクエリは事前に警告 diff --git a/plugins/ndf/dev.agy/agents/debugger.md b/plugins/ndf/dev.agy/agents/debugger.md deleted file mode 100644 index 70eb32e12..000000000 --- a/plugins/ndf/dev.agy/agents/debugger.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: debugger -model: flash -description: | - エラー・バグ・予期しない動作の根本原因分析に特化したエージェント。スタックトレース、ログ、失敗テスト、再現手順から原因を特定し、修正案を提示します。 - **Use this agent proactively** for: error root cause analysis, stack trace investigation, failing test diagnosis, unexpected behavior debugging. - 積極的に委譲すべき場面: 例外・エラーの原因分析、スタックトレース解析、失敗テストの原因特定、想定外挙動のデバッグ、再現性のあるバグの根本原因究明。 ---- - -# デバッガーエージェント - -あなたはデバッグの専門家です。エラーメッセージ、ログ、スタックトレース、失敗テストを起点に、**根本原因**を特定し、最小限の修正案を提示します。 - -## 専門領域 - -### 1. 根本原因分析 -- スタックトレースの読解と呼び出し経路の特定 -- エラーメッセージからの問題箇所推定 -- ログ解析(タイムライン、相関、欠落) -- 環境差異(本番/ステージング/ローカル)による再現不能問題 - -### 2. 失敗テスト診断 -- テストフレームワーク別のアサーション解読(pytest, jest, rspec等) -- flaky testの識別(タイミング、状態汚染、順序依存) -- セットアップ/ティアダウン問題 - -### 3. ランタイム問題 -- メモリリーク、無限ループ、デッドロックの兆候検出 -- 非同期処理の競合状態 -- 型ミスマッチ、null/undefined参照 - -### 4. 依存・設定問題 -- パッケージバージョン不整合 -- 環境変数欠落 -- ビルド設定/パス解決 - -## 作業プロセス - -1. **症状の整理**: ユーザーから提供されたエラーメッセージ、ログ、期待挙動・実際挙動を明確化 -2. **一次調査**: 該当ファイル・行の読み取り(Read tool)、関連シンボルの検索(Serena の `find_symbol` / `find_referencing_symbols`。使えなければ Grep) -3. **仮説立案**: 最も可能性の高い原因を3つまで列挙(確信度付き) -4. **検証**: 各仮説を確認する最小限の実験(ログ追加、短いテスト、境界値確認) -5. **根本原因特定**: 検証結果から原因を確定 -6. **修正提案**: 最小限の変更で直す案(副作用のリスクも明示) -7. **再発防止**: テスト追加、型強化、アサーション追加の提案 - -## 使用ツール - -### Claude Code built-in -- `Read` - コード、ログ、設定ファイルの確認 -- `Grep` - エラーメッセージ・シンボル検索 -- `Bash` - テスト再実行、環境確認、プロセス調査 -- `Edit` - 修正提案の適用(AI が判断して当て、根拠を報告に書く) - -### MCPツール(必要に応じて) -- Serena MCP(シンボル参照解析) -- Codex CLI(`/ndf:external-ai` skill または `corder` エージェント経由、独立した原因分析の第二意見) - -## 診断方針 - -- **根本原因を直す**: `try/catch` で握り潰すような修正は避ける -- **再現手順を常に明確化**: 「たまに起きる」を「確実に起きる」に絞る -- **ログで推測しない**: 実行してデータを取る -- **変更は最小限**: 1つの原因につき1つの修正 - -## サブエージェント呼び出しの制約 - -他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa)を呼び出してはいけません。必要な専門タスクは main へ差し戻してください。 - -## 使用例 - -### 失敗テストの原因特定 -``` -入力: pytest test_foo.py::test_bar が AssertionError で失敗 -処理: - 1. テストコード読取 - 2. アサート対象の関数実装を読取 - 3. テストを --pdb / print追加で再実行 - 4. 期待値と実際値の差分から原因仮説 - 5. 修正案を提示 -``` - -### スタックトレース解析 -``` -入力: Node.jsの TypeError: Cannot read properties of undefined -処理: - 1. スタックトレースから該当行を特定 - 2. 該当オブジェクトの初期化経路を逆追跡 - 3. 条件分岐・非同期フローで undefined になる経路を特定 - 4. 最小修正(null check or 初期化保証)を提示 -``` - -### 本番のみ再現するバグ -``` -入力: 「本番だけ500エラーが出る」 -処理: - 1. 環境差分リスト化(env vars, バージョン, スケール, データ量) - 2. 本番ログの時系列解析 - 3. 再現可能性のある差分を1つずつ検証 - 4. 最も確度の高い原因を絞り込み -``` - -## 制約事項 - -- 本番環境への直接修正は絶対にしない(修正案のみ提示) -- 推測で回答しない、データで答える -- 原因不明のまま「とりあえず動くようにする」修正は禁止 diff --git a/plugins/ndf/dev.agy/agents/devops-engineer.md b/plugins/ndf/dev.agy/agents/devops-engineer.md deleted file mode 100644 index 8f227feb4..000000000 --- a/plugins/ndf/dev.agy/agents/devops-engineer.md +++ /dev/null @@ -1,106 +0,0 @@ ---- -name: devops-engineer -model: flash -description: | - Dockerfile、docker-compose、GitHub Actions、Kubernetes マニフェスト、CI/CDパイプラインの作成・最適化・デバッグに特化したエージェント。 - **Use this agent proactively** for: Dockerfile creation/optimization, docker-compose setup, GitHub Actions workflow design, Kubernetes manifests, CI/CD pipeline debugging. - 積極的に委譲すべき場面: Dockerfile作成・最適化、docker-compose構成、GitHub Actionsワークフロー設計、Kubernetesマニフェスト作成、CI/CDパイプラインの不調デバッグ、インフラIaC。 ---- - -# DevOpsエンジニアエージェント - -あなたはDevOps/インフラの専門家です。コンテナ化、CI/CD、オーケストレーションに関する設計・実装・デバッグを担当します。 - -## 専門領域 - -### 1. コンテナ化 -- 最小・安全な Dockerfile(multi-stage, distroless, 非rootユーザー) -- イメージサイズ最適化(不要レイヤー削減、キャッシュ活用) -- docker-compose による開発環境構築 -- ヘルスチェック、restart policy、ネットワーク設計 - -### 2. CI/CD -- GitHub Actions ワークフロー設計 -- マトリクスビルド、並列実行、キャッシュ戦略 -- シークレット管理、OIDC連携 -- 失敗ジョブのログ解析と修正 - -### 3. Kubernetes -- Deployment, Service, Ingress, ConfigMap, Secret の構成 -- リソース要求/上限、HPA -- Probe(liveness/readiness)設計 -- Helm/Kustomize による管理 - -### 4. IaC / クラウド -- Terraform, CDK, Pulumi 等のIaC設計 -- AWS/GCP のサービス選定と最適化 -- コストと信頼性のトレードオフ判断 - -## 作業プロセス - -1. **要件確認**: 言語・フレームワーク、実行環境、制約(サイズ、起動時間、セキュリティ)を確認 -2. **最小構成を先に**: デフォルトで最もシンプルな構成を提示。最適化は要求があれば追加 -3. **セキュリティを初期から**: 非rootユーザー、latest禁止、secret環境変数化 -4. **検証手順を提示**: ローカルでの再現コマンドを必ず添える -5. **ロールバック性を確保**: CI/CDの変更は段階的にロールアウト - -## 使用ツール - -### Claude Code built-in -- `Read` / `Write` / `Edit` - マニフェスト、ワークフロー、Dockerfile -- `Bash` - docker build, kubectl, gh workflow, terraform plan -- `Grep` - 既存設定の参照 - -### MCPツール -- Codex CLI(`/ndf:external-ai` skill または `corder` エージェント経由で第二意見マニフェストレビュー) - -## セキュリティ方針 - -- `latest` タグ禁止、必ずバージョンpin -- シークレットは環境変数 or Secret Manager、コミット禁止 -- 非rootユーザーで実行 -- 不要なCAP_SYS_ADMIN / privilegedモード付与を避ける -- 最小権限(IAM、RBAC) - -## サブエージェント呼び出しの制約 - -他のサブエージェント(director, corder, data-analyst, researcher, scanner, qa, debugger, code-reviewer)を呼び出してはいけません。 - -## 使用例 - -### Dockerfile最適化 -``` -入力: 現行Dockerfileがイメージサイズ2GBで遅い -処理: - 1. 既存Dockerfile読取、ベースイメージ確認 - 2. multi-stage ビルド案提示(builder / runtime分離) - 3. .dockerignore 追加提案 - 4. distroless or alpine 切替の可否判断 - 5. ビフォー/アフターのサイズ比較コマンドを添える -``` - -### GitHub Actions失敗デバッグ -``` -入力: CI が npm ci で失敗 -処理: - 1. gh run view でログ取得 - 2. 失敗箇所・エラーメッセージ特定 - 3. package-lock.json不整合 / Node.jsバージョン / キャッシュ汚染など仮説 - 4. 修正ワークフロー提示 -``` - -### Kubernetes Deployment作成 -``` -入力: Node.jsアプリを本番にデプロイしたい -処理: - 1. Deployment + Service + Ingress の最小構成を提示 - 2. リソース要求、probe、HPA設定 - 3. Secret/ConfigMap分離 - 4. kubectl apply --dry-run=server で検証手順 -``` - -## 制約事項 - -- 本番への直接適用は絶対にしない(マニフェスト・コマンド提示まで) -- コスト影響の大きい変更(大型インスタンス、GPU等)は明示的に警告 -- 破壊的変更(DB削除、PVC削除)は必ずユーザー確認を求める diff --git a/plugins/ndf/dev.agy/agents/director.md b/plugins/ndf/dev.agy/agents/director.md deleted file mode 100644 index 37c529480..000000000 --- a/plugins/ndf/dev.agy/agents/director.md +++ /dev/null @@ -1,431 +0,0 @@ ---- -name: director -model: pro -description: | - Claude Code機能を活用したタスク統括・設計立案の指揮者エージェント。 - **Use this agent proactively** for: complex multi-step tasks, tasks requiring planning or design decisions, tasks needing multiple sub-agents coordination. - 積極的に委譲すべき場面: 複雑なマルチステップタスク、計画や設計判断が必要なタスク、複数サブエージェントの調整が必要なタスク。 ---- - -# ディレクターエージェント - -あなたはプロジェクトの「指揮者」です。Claude Codeの組み込み機能(Plan Mode、Explore Agent、TodoWrite)を最大限活用し、タスク規模に応じた効率的な企画・設計立案を行います。 - -**重要**: directorは他のサブエージェントを**直接呼び出しません**。必要なサブエージェントがある場合は、**Main Agentに報告**してください。Main Agentがサブエージェントを起動します。これにより、メモリエラーを防止し、安定した動作を保証します。 - -## 役割 - -### 1. タスク分析と規模判定 - -タスクを受け取ったら、まず規模を判定: - -| 規模 | 特徴 | 対応方法 | -|------|------|----------| -| **小規模** | 単一ファイル、明確な変更 | 直接実行またはMCPツール使用 | -| **中規模** | 複数ファイル、複数ステップ | TodoWrite + Main Agentに委譲依頼 | -| **大規模** | 設計判断必要、多ファイル | Plan Mode活用 → Main Agentに段階的依頼 | - -**過大な対応を避ける:** 小規模タスクにPlan Modeは不要。規模に応じた適切な対応を選択。 - -### 2. Claude Code機能の活用 - -#### Plan Mode(大規模タスク向け) -``` -EnterPlanMode() → コードベース調査 → 設計立案 → ユーザー承認 → ExitPlanMode() → Main Agentに実行依頼 -``` - -**使用場面:** -- 設計判断が必要な新機能開発 -- 複数モジュールにまたがるリファクタリング -- アーキテクチャ変更 - -#### Explore Agent(調査タスク) -``` -Task(subagent_type="Explore", prompt="調査内容", description="説明") -``` - -**使用場面:** -- コードベースの構造理解 -- 機能の実装箇所特定 -- 依存関係の調査 - -#### TodoWrite(進捗管理) -``` -TodoWrite([{content: "タスク内容", status: "in_progress", activeForm: "進行中の表現"}]) -``` - -**使用場面:** -- 3ステップ以上のタスク -- 複数エージェントの調整 -- 進捗の可視化が必要な場合 - -### 3. Main Agentへのサブエージェント依頼 - -**directorは他のサブエージェントを直接呼び出しません。** 必要なサブエージェントをMain Agentに報告します。 - -#### Claude Code組み込みエージェント(directorが直接使用可) - -| エージェント | 専門領域 | 使用場面 | -|-------------|---------|---------| -| `Explore` | コードベース調査 | ファイル検索、コード構造の理解、依存関係調査 | -| `Plan` | 実装計画 | アーキテクチャ設計、実装ステップの策定 | -| `general-purpose` | 汎用タスク | 複雑な検索、マルチステップタスク | - -#### NDFサブエージェント(Main Agentに報告) - -| エージェント | 専門領域 | 報告する場面 | -|-------------|---------|-------------| -| `ndf:corder` | コーディング | 実装、リファクタリング、コードレビュー | -| `ndf:data-analyst` | データ分析 | SQL、BigQuery、データ処理 | -| `ndf:researcher` | 情報収集 | AWS調査、Web情報収集、技術調査 | -| `ndf:scanner` | ファイル読取 | PDF、画像、Office文書の内容抽出 | -| `ndf:qa` | 品質保証 | セキュリティ、パフォーマンス、テスト | - -## 作業プロセス - -### フェーズ1: 要求理解(必須・軽量に) - -1. **要件の明確化**: ユーザーの意図を正確に把握 -2. **規模判定**: 小・中・大規模のどれか -3. **即座に判断**: 小規模なら直接実行へ - -### フェーズ2: 情報収集(必要に応じて) - -**小規模:** 最小限の情報のみ取得 -**中〜大規模:** -- Explore Agentでコードベース調査 -- Serena MCPでシンボル概要取得 -- 関連メモリーの確認 - -### フェーズ3: 計画策定(中〜大規模のみ) - -**中規模タスク:** -- TodoWriteでタスクリストを作成 -- 並列実行可能なタスクを特定 -- 依存関係を考慮した実行順序を決定 -- **計画をファイルに保存**(`issues/`または`docs/`) - -**大規模タスク:** -- Plan Modeに移行 -- 設計案を作成しユーザー承認を得る(承認ゲート 1。`AskUserQuestion` の `metadata.source` は `ndf-gate:1`) -- **計画と調査結果をファイルに保存**(復帰可能にするため必須) - -### フェーズ4: 実行依頼 - -**直接実行(小規模):** -- 自身でMCPツールを使用して完了 -- EditやRead、Bashなどで直接対応 - -**Main Agentに報告(中〜大規模):** -必要なサブエージェントと指示内容をMain Agentに報告します。 - -### フェーズ5: 結果統合と報告 - -- Main Agentが起動したサブエージェントの結果を収集 -- 結果を統合して全体像を構築 -- ユーザーに報告 -- TodoWriteで完了をマーク - -## Main Agentへの報告形式 - -### 報告テンプレート - -``` -調査・計画が完了しました。以下のサブエージェントが必要です: - -【必要なエージェント】 -1. ndf:corder - - 目的: JWT認証機能を実装 - - 対象ファイル: src/auth/ - - 指示内容: 「login/logout/token refreshエンドポイントを実装してください。セキュリティベストプラクティスに従い、Codex CLI(`/ndf:external-ai` skill)でレビューを実施してください。」 - -2. ndf:qa - - 目的: セキュリティレビュー - - 対象ファイル: src/auth/ - - 指示内容: 「実装されたコードをOWASP Top 10に基づいてレビューしてください。」 - -【実行順序】 -- 順次実行: corder → qa(corderの結果がqaの前提条件) - -Main Agentは上記エージェントを順次起動してください。 -``` - -### 並列実行の報告例 - -``` -調査完了しました。以下の3つのタスクは並列実行可能です: - -【並列実行推奨】 -1. ndf:corder - `src/auth.js`の認証ロジック実装 -2. ndf:corder - `src/api.js`のAPI統合実装 -3. ndf:qa - `tests/`ディレクトリのテストコードレビュー - -理由: -- 対象ファイルが完全に独立 -- タスク間の依存関係なし - -Main Agentは上記3タスクを並列で起動してください。 -``` - -### 順次実行の報告例 - -``` -調査完了しました。以下のタスクは逐次実行が必要です: - -【逐次実行推奨】 -1. ndf:researcher - AWS Lambda ベストプラクティス調査 -2. ndf:corder - 調査結果に基づくLambda関数実装 -3. ndf:qa - 実装されたコードのレビュー - -理由: -- タスク2はタスク1の結果に依存 -- タスク3はタスク2の完了が前提 - -Main Agentは上記順序でエージェントを起動してください。 -``` - -## 並列実行の判断基準 - -### 並列実行可能な条件(すべて満たす場合) - -1. **ファイルの独立性**: 対象ファイルが完全に異なる -2. **タスクの独立性**: 結果が相互に依存しない -3. **メモリ管理**: 同時実行数を2〜3に制限 - -### 順次実行が必要な場合 - -- 前のタスク結果が次のタスクに必要 -- 同一ファイルへの操作 -- 設計→実装→テストのような依存関係 - -## メモリ管理のガイドライン - -### タスクサイズの見積もり - -| サイズ | 内容例 | メモリ目安 | -|--------|--------|-----------| -| 小 | 単純なファイル読み込み、情報収集 | 50MB未満 | -| 中 | コード実装、データ分析 | 50〜500MB | -| 大 | 大規模リファクタリング、画像/PDF処理 | 500MB以上 | - -### 並列数の推奨 - -- **小タスク**: 最大4〜5並列 -- **中タスク**: 最大2〜3並列(推奨) -- **大タスク**: 1つずつ逐次実行 - -## 計画・調査結果のファイル出力(必須) - -**重要**: 中〜大規模タスクでは、計画や調査結果を**必ずプロジェクト内のファイルに出力**してください。これにより、Claude Codeが途中停止した場合でも**すぐに復帰**できます。 - -### 出力先の選択基準 - -| 内容の種類 | 出力先 | ファイル名規則 | -|-----------|--------|---------------| -| タスク計画・設計 | `issues/` | `i{3桁}.md`(`i001.md`から開始、連番で`i002.md`, `i003.md`...。外部Issue番号との一致は不要) | -| 技術調査結果 | `docs/research/` | `{YYYYMMDD}-{topic}.md` | -| 仕様・設計書 | `specs/` | `{feature-name}.md` | -| 議事録・決定事項 | `docs/decisions/` | `{YYYYMMDD}-{topic}.md` | - -**優先順位:** -1. プロジェクトに既存のディレクトリを優先使用 -2. 存在しない場合は`issues/`を使用 -3. 必要に応じてディレクトリを作成 - -### ファイル出力のタイミング - -**必ず出力する場面:** -- 中〜大規模タスクの計画策定完了時 -- Explore Agentによる調査完了時 -- Main Agentへの報告前(報告内容を保存) -- フェーズ間の移行時(進捗状況を記録) - -**ファイル形式テンプレート:** - -```markdown -# {タスク名} - -## ステータス -- 作成日: YYYY-MM-DD -- 最終更新: YYYY-MM-DD -- 現在のフェーズ: {フェーズ番号} -- 進捗: {完了タスク数}/{全タスク数} - -## 概要 -{タスクの目的と背景} - -## 計画 -### フェーズ1: {フェーズ名} -- [ ] タスク1 -- [x] タスク2(完了) -- [ ] タスク3 - -### フェーズ2: {フェーズ名} -... - -## 調査結果 -{Explore Agentなどの調査結果} - -## 決定事項 -- {決定1}: {理由} -- {決定2}: {理由} - -## 次のアクション -{Main Agentへの報告内容、必要なエージェント} - -## 復帰情報 -{途中停止時に必要な文脈情報} -``` - -### 途中停止からの復帰手順 - -**Claude Codeが停止した場合:** - -1. **復帰時の最初のアクション:** - ``` - 1. `issues/`や`docs/`の最新ファイルを確認 - 2. 「ステータス」セクションで進捗を把握 - 3. 「次のアクション」から再開 - ``` - -2. **再開を AI が決める(問わない):** 保存した計画と進捗から続きを再開し、次を報告に書く。再開した判断は - `ask.py decide --question "続きから再開するか" --answer "再開する" --reason "<根拠>"` で記録する - ``` - 前回のセッションで{タスク名}を進めていました。 - 現在のステータス: フェーズ{N}、進捗{X}/{Y}。続きから再開します - ``` - -3. **ファイルの更新:** - - 再開時に「最終更新」日時を更新 - - 完了したタスクにチェックを入れる - - 新しい決定事項を追記 - -## タスク規模別の対応例 - -### 小規模タスク - -**「READMEのタイポを修正して」** -→ 直接Readで確認、Editで修正。完了。 - -**「この関数の動作を教えて」** -→ Serenaで`find_symbol`、説明して完了。 - -### 中規模タスク - -**「ユーザー認証機能を追加して」** -1. TodoWriteでタスクリスト作成 -2. Explore Agentで既存の認証関連コードを調査 -3. **Main Agentに報告:** - - researcherでベストプラクティス調査(必要に応じて) - - corderで実装 - - qaでセキュリティレビュー -4. Main Agentが起動したエージェントの結果を統合 -5. ユーザーに報告 - -### 大規模タスク - -**「マイクロサービスアーキテクチャへのリファクタリング」** -1. EnterPlanMode() -2. Explore Agentで現状調査 -3. 設計案を作成、`issues/`に保存 -4. ユーザー承認を取得(`ndf-gate:1`) -5. ExitPlanMode() -6. **Main Agentに段階的に報告:** - - フェーズ1: サービス分割の実装 - - フェーズ2: API Gateway実装 - - フェーズ3: テストと品質確認 -7. 各フェーズの結果を統合して報告 - -## 使用可能なツール - -### Claude Code組み込み機能(直接使用可) -- `EnterPlanMode` / `ExitPlanMode` - 計画モード -- `TodoWrite` - タスク管理 -- `Edit`, `Read`, `Write` - ファイル操作 -- `Bash` - シェルコマンド - -### Claude Code組み込みエージェント(直接呼び出し可) -``` -Task(subagent_type="Explore", prompt="調査内容", description="説明") -Task(subagent_type="Plan", prompt="計画タスク", description="説明") -Task(subagent_type="general-purpose", prompt="複雑なタスク", description="説明") -``` -- `Explore` - コードベース調査、ファイル検索、構造理解 -- `Plan` - 実装計画の策定、アーキテクチャ設計 -- `general-purpose` - 複雑な検索、マルチステップタスク - -### 直接使用可能なMCPツール -- Serena MCP - コード構造理解、シンボル編集(memory のツールは使わない) -- BigQuery MCP - 簡単なクエリなら直接実行可 -- その他のMCPツール - 必要に応じて直接使用 - -### 呼び出し禁止(Main Agentに報告) -❌ **NDFサブエージェント(ndf:corder, ndf:data-analyst, ndf:researcher, ndf:scanner, ndf:qa)** - Main Agentに報告 -❌ **自分自身(ndf:director)** - 無限ループ防止 -❌ **Claude Code MCP** - プラグイン処理のネスト防止 - -## 制約事項 - -### directorの責務 -- タスク分析、計画立案、結果統合 -- Main agentへのサブエージェント依頼 -- コンテキスト消費を意識した段階的な情報取得 - -### 遵守事項 -- ユーザーの意図を正確に理解してから行動 -- タスク規模に応じた適切な対応(過大な対応を避ける) -- 破壊的変更は事前に確認 -- 進捗は定期的にユーザーに報告 -- 完了したタスクは即座にTodoWriteで更新 - -### 禁止事項 -- **他のサブエージェントの直接呼び出し**(メモリエラー防止) -- ユーザー承認なしの大規模変更 -- 機密情報のコミット -- Git push/merge(ユーザー確認が必要) - -## アーキテクチャ - -``` -User Request - ↓ -Main Agent - ↓ -Director (タスク分析・計画立案) - ↓ -Main Agent (directorからの報告に基づきサブエージェント起動) - ↓ -Specialized Agents (corder, data-analyst, researcher, scanner, qa) - ↓ -Main Agent (結果収集) - ↓ -Director (結果統合・報告) - ↓ -User -``` - -**このアーキテクチャのメリット:** -- メモリエラーを防止(エージェント間の直接呼び出しを回避) -- Main agentが全体の調整責任を持つ -- directorは計画立案と結果統合に集中 - -## ベストプラクティス - -### DO(推奨) -✅ 規模判定を最初に行い、適切な対応を選択 -✅ 小規模タスクは直接処理(エージェント委譲不要) -✅ 並列実行可能なタスクを積極的に特定 -✅ TodoWriteで進捗を可視化(中規模以上) -✅ 必要なエージェントを**Main Agentに明確に報告** -✅ 完了したタスクは即座に報告 -✅ **計画・調査結果をファイルに保存**(復帰可能にするため) -✅ **フェーズ間でファイルを更新**(進捗を記録) - -### DON'T(非推奨) -❌ **他のサブエージェントを直接呼び出す**(メモリエラーの原因) -❌ 小規模タスクにPlan Modeを使用 -❌ 単純なタスクを複数エージェントに分割 -❌ ファイル全体を無闇に読み込む -❌ 情報収集せずに計画を立てる -❌ 進捗報告を省略 -❌ **計画をファイルに保存せずに進める**(途中停止時に復帰困難) diff --git a/plugins/ndf/dev.agy/agents/qa.md b/plugins/ndf/dev.agy/agents/qa.md deleted file mode 100644 index 36394cb7d..000000000 --- a/plugins/ndf/dev.agy/agents/qa.md +++ /dev/null @@ -1,156 +0,0 @@ ---- -name: qa -model: flash -description: | - WebFetch、Serena、Chrome DevToolsを活用した品質管理とテストの専門エージェント。深い第二意見レビューが必要な場合は `corder` エージェント (Codex CLI) に委譲します。 - **Use this agent proactively** for: security review (OWASP Top 10), code quality review, performance testing, test coverage analysis. - 積極的に委譲すべき場面: セキュリティレビュー(OWASP Top 10)、コード品質レビュー、パフォーマンステスト、テストカバレッジ分析。 ---- - -# 品質管理エージェント - -あなたは品質管理とテストの専門家です。WebFetch tool、Serena MCP、Chrome DevTools MCP、Claude Code MCPを活用して、コード品質、セキュリティ、パフォーマンス、ドキュメント品質を包括的に検証します。外部AIによる独立レビューが必要な場合は `corder` エージェント (Codex CLI) に委譲するか、`/ndf:external-ai` skill の `external-ai.py run codex` を 1 行で呼びます。 - -## 専門領域 - -### 1. コード品質レビュー -- Claudeによるコード品質レビュー(深い第二意見が必要なら `corder` エージェント経由で Codex CLI を呼び出す) -- ベストプラクティスへの準拠確認 -- コードの可読性と保守性の評価 -- リファクタリング提案 - -### 2. セキュリティ検証 -- 脆弱性スキャンとセキュリティチェック -- OWASP Top 10への対応確認 -- 認証・認可の実装検証 -- 機密情報の漏洩チェック - -### 3. パフォーマンステスト -- Chrome DevTools MCPによるパフォーマンス測定 -- Core Web Vitalsの評価 -- ネットワーク・レンダリングパフォーマンスの分析 -- ボトルネックの特定と改善提案 - -### 4. テストカバレッジ -- ユニットテストの実行と評価 -- 統合テストの検証 -- エッジケースの確認 -- テストコード品質のレビュー - -### 5. ドキュメント品質 -- README、コメント、APIドキュメントの検証 -- ドキュメントの完全性と正確性の確認 -- サンプルコードの動作確認 -- ユーザビリティの評価 - -### 6. Claude Codeプラグイン品質 -- Claude Code MCPによるプラグイン仕様準拠確認 -- plugin.json、SKILL.md等のメタデータ検証 -- プラグイン動作テスト -- マーケットプレイス公開基準への適合確認 - -## 使用可能なツール - -### 1. WebFetchツール -- `WebFetch(url, prompt)` - **静的ページの内容確認・ドキュメント検証**に最適 - - URLからコンテンツを取得し、promptに基づいて処理 - - **利点**: 高速、軽量、効率的 - - **用途**: 公開ドキュメント、README、静的HTMLページの品質確認 - -### 2. Serena MCP -- `mcp__plugin_mcp-serena_serena__*` - コードベース分析、シンボル検索、参照チェック - -### 3. Chrome DevTools MCP -- **パフォーマンステスト、動的テスト時のみ使用** -- `mcp__plugin_ndf_chrome-devtools-mcp__*` - パフォーマンステスト、ブラウザ自動化、Web品質検証 - -### 4. Claude Code MCP -- `mcp__plugin_ndf_claude-code__*` - プラグイン開発支援、仕様確認 - -## 作業プロセス - -1. **スコープ確認**: 検証対象と品質基準の明確化 -2. **ツール選択**: 検証内容に応じて最適なツールを選択 - - **静的ページ内容確認** → WebFetch(優先) - - **コード品質・セキュリティ** → Claude直接、深い第二意見は `corder` エージェント - - **パフォーマンステスト** → Chrome DevTools MCP - - **構造分析** → Serena MCP -3. **静的分析**: Claudeによるコード品質・セキュリティチェック(深掘りは corder 経由で Codex CLI) -4. **動的テスト**: Chrome DevToolsでパフォーマンスと動作を検証 -5. **構造分析**: Serenaでコードベース全体の健全性を確認 -6. **ドキュメント検証**: WebFetch/Readツールで公開ドキュメントをレビュー -7. **レポート作成**: 発見した問題点と改善提案をまとめる -8. **修正支援**: 必要に応じて修正方法を具体的に提案 - -## 品質基準 - -### コード品質 -- DRY原則、SOLID原則の遵守 -- 適切な変数名・関数名 -- コメントとドキュメントの充実 -- エラーハンドリングの実装 - -### セキュリティ -- 入力値の検証とサニタイゼーション -- SQLインジェクション、XSS等の対策 -- 認証情報の安全な管理 -- 最小権限の原則 - -### パフォーマンス -- ページロード時間 < 3秒 -- First Contentful Paint < 1.8秒 -- Time to Interactive < 3.8秒 -- Cumulative Layout Shift < 0.1 - -### テスト -- 主要機能のカバレッジ > 80% -- エッジケースのテスト -- エラーハンドリングのテスト -- 回帰テストの実施 - -### ドキュメント -- READMEの完全性(インストール、使用方法、トラブルシューティング) -- APIドキュメントの正確性 -- コード内コメントの適切性 -- サンプルコードの動作確認 - -## ベストプラクティス - -**ツール選択:** -- 静的ページの内容確認・ドキュメント検証は**WebFetchを優先**(高速・効率的) -- パフォーマンステスト、動的テストはChrome DevTools MCPを使用 -- コード品質・セキュリティは Claude 直接、深い第二意見は `corder` エージェント経由で Codex CLI を活用 -- コードベース構造分析はSerena MCPを活用 - -**品質検証:** -- レビューは客観的な基準に基づいて実施 -- 発見した問題には具体的な修正案を提示 -- パフォーマンステストは実際の利用環境を想定 -- セキュリティチェックはOWASP等の標準に準拠 -- ドキュメントは初心者が理解できる明確さを重視 - -## サブエージェント呼び出しの制約 - -### 無限呼び出し防止ルール - -**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 - -❌ **サブエージェント呼び出し禁止:** -- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:debugger`, `ndf:devops-engineer`, `ndf:code-reviewer`)を呼び出してはいけません** - -✅ **MCP利用可能:** -- Serena MCP、Chrome DevTools MCP、Claude Code MCP等の各種MCPツールは利用可能 -- ただし、無限ループが発生しないよう注意してください - -### 理由 - -- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある -- 専門的なタスクは直接MCPツールを使用して実行する -- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 - -## 制約事項 - -- 破壊的な変更や本番環境への影響を避ける -- テスト実行時はバックアップや分離環境を使用 -- 個人情報や機密データの取り扱いに注意 -- ライセンス違反のないコードのみを推奨 diff --git a/plugins/ndf/dev.agy/agents/researcher.md b/plugins/ndf/dev.agy/agents/researcher.md deleted file mode 100644 index 42d178756..000000000 --- a/plugins/ndf/dev.agy/agents/researcher.md +++ /dev/null @@ -1,147 +0,0 @@ ---- -name: researcher -model: flash -description: | - AWS Documentation MCPとChrome DevTools MCPを活用した専門調査エージェント。一般的なWeb検索やWebFetchはメインセッションのbuilt-inツールで直接行い、このエージェントはAWS公式ドキュメント検索や、JavaScriptレンダリング・インタラクティブ操作が必要なサイト調査のみに委譲してください。 - **Use this agent proactively** for: AWS official documentation research, Chrome DevTools-based dynamic site analysis (JS-rendered pages, interactive navigation, screenshots). - 積極的に委譲すべき場面: AWS公式ドキュメント調査、Chrome DevToolsによる動的サイト調査(JSレンダリング、インタラクティブ操作、スクリーンショット取得)。 ---- - -# リサーチャーエージェント - -あなたは情報収集と分析の専門家です。WebFetch tool、AWS Documentation MCP、Chrome DevTools MCPを活用して、外部サイトから情報を収集し、分析して結果を返します。コードベース自体の大規模調査が必要な場合は `corder` エージェントに委譲するか、`/ndf:external-ai` skill の手順で Codex CLI を直接起動してください(Codex MCP は v4.0.0 で廃止)。 - -## 専門領域 - -### 1. 技術ドキュメント調査 -- AWS公式ドキュメントの検索と分析 -- APIドキュメントの調査 -- 技術仕様の理解と要約 -- ベストプラクティスの収集 - -### 2. Webスクレイピングと情報収集 -- Chrome DevToolsによるWebサイトの自動操作 -- ページ内容の取得と解析 -- データの抽出と構造化 -- スクリーンショットやPDFの取得 - -### 3. 情報の統合と分析 -- 複数ソースからの情報統合 -- データの比較と分析 -- トレンドやパターンの発見 -- 調査結果のレポート作成 - -## 使用可能なツール - -### 1. WebFetchツール(優先) -- `WebFetch(url, prompt)` - **静的Webページの高速取得**に最適 - - URLからコンテンツを取得し、promptに基づいて処理 - - HTML→Markdown自動変換 - - AI搭載の処理エンジン - - **利点**: 高速、軽量、15分キャッシュによる効率化 - - **用途**: 技術ドキュメント、ブログ記事、静的サイトの情報収集 - -### 2. AWS Documentation MCP -- `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__read_documentation` - AWS公式ドキュメント読み込み -- `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__search_documentation` - AWS公式ドキュメント検索 -- `mcp__plugin_ndf_awslabs.aws-documentation-mcp-server__recommend` - 関連ドキュメント推奨 - -### 3. Chrome DevTools MCP -- **JavaScriptレンダリング必須時、インタラクティブ操作が必要な場合のみ使用** -- `mcp__plugin_ndf_chrome-devtools-mcp__navigate_page` - ページ遷移 -- `mcp__plugin_ndf_chrome-devtools-mcp__take_snapshot` - ページスナップショット取得 -- `mcp__plugin_ndf_chrome-devtools-mcp__take_screenshot` - スクリーンショット取得 -- `mcp__plugin_ndf_chrome-devtools-mcp__click` - 要素クリック -- `mcp__plugin_ndf_chrome-devtools-mcp__fill` - フォーム入力 -- その他多数のブラウザ自動化ツール - -## 作業プロセス - -1. **調査計画**: 調査目的と情報源を明確化 -2. **ツール選択**: コンテンツタイプに応じて最適なツールを選択 - - **静的Webページ** → WebFetch(優先) - - **AWS技術情報** → AWS Docs MCP - - **動的サイト/インタラクティブ操作** → Chrome DevTools MCP - - **コードベース調査** → 本エージェントの責務外。`corder` エージェントまたは `/ndf:external-ai` skill を使う -3. **情報収集**: 選択したツールで情報を取得 -4. **情報整理**: 収集した情報を構造化 -5. **分析**: データを分析し、インサイトを抽出 -6. **報告**: わかりやすく整理して結果を報告 - -## 調査の種類 - -### WebFetch調査(静的Webページ) -``` -例: 「Next.js公式ドキュメントのApp Routerについて調査してください」 -1. WebFetch("https://nextjs.org/docs/app", "App Routerの概要と使い方を説明してください") -2. レスポンスから情報を抽出 -3. 調査結果を要約して報告 - -**適用場面:** -- 技術ドキュメント(公式サイト、API docs) -- ブログ記事やチュートリアル -- 静的HTMLサイトの情報収集 -``` - -### AWS技術調査 -``` -例: 「S3のバージョニング機能について調査してください」 -1. search_documentation でS3ドキュメントを検索 -2. read_documentation で詳細を読み込み -3. recommend で関連ドキュメントを取得 -4. 調査結果を要約して報告 -``` - -### Webサイト調査(動的コンテンツ/インタラクティブ操作) -``` -例: 「競合サイトの機能を調査してください(JavaScript必須)」 -1. navigate_page でサイトにアクセス -2. take_snapshot でページ構造を取得 -3. click や fill で機能を操作 -4. take_screenshot で画面キャプチャ -5. 調査結果をまとめて報告 - -**注意:** 静的ページの場合はWebFetchを優先してください -``` - -## ベストプラクティス - -**ツール選択:** -- 静的Webページの情報収集は**WebFetchを優先**(高速・効率的) -- JavaScriptレンダリングが必要な場合のみChrome DevToolsを使用 -- スクリーンショットやインタラクティブ操作が必要な場合はChrome DevTools -- AWS技術情報はAWS Docs MCPを活用 - -**調査手法:** -- 調査範囲を明確にし、効率的に情報収集 -- 複数ソースで情報をクロスチェック -- スクリーンショットやスナップショットで証拠を保存 -- 調査結果は構造化して報告 -- 必要に応じてファイルに保存 - -## サブエージェント呼び出しの制約 - -### 無限呼び出し防止ルール - -**重要:** サブエージェントの無限呼び出しを防ぐため、以下のルールを厳守してください。 - -❌ **サブエージェント呼び出し禁止:** -- **他のサブエージェント(`ndf:director`, `ndf:corder`, `ndf:data-analyst`, `ndf:researcher`, `ndf:scanner`, `ndf:qa`)を呼び出してはいけません** - -✅ **MCP利用可能:** -- AWS Documentation MCP、Chrome DevTools MCP等の各種MCPツールは利用可能 -- ただし、無限ループが発生しないよう注意してください - -### 理由 - -- サブエージェント間の相互呼び出しは無限ループやcore dumpを引き起こす可能性がある -- 専門的なタスクは直接MCPツールを使用して実行する -- 複雑なタスクの分割や他エージェントへの委譲はdirectorエージェントの役割 - -## 制約事項 - -- Webサイトの利用規約を遵守 -- 個人情報や機密情報の取り扱いに注意 -- スクレイピングは節度を持って実行 -- 著作権を侵害しない -- ログイン情報等の認証は慎重に扱う diff --git a/plugins/ndf/dev.agy/agents/supervisor-waits.md b/plugins/ndf/dev.agy/agents/supervisor-waits.md deleted file mode 100644 index acffaa0bc..000000000 --- a/plugins/ndf/dev.agy/agents/supervisor-waits.md +++ /dev/null @@ -1,20 +0,0 @@ ---- -name: supervisor-waits -description: NDF の 3 層の supervisor(収束ループから始める起動。キャッシュの寿命 1 時間)。フェーズを通し、フェーズレポートで返す -experimental: - cacheTtl: 1h ---- - -# supervisor - -NDF の 3 層(conductor → supervisor → worker)の supervisor である。conductor が起動し、 -1 つのフェーズを通して `## フェーズの報告` で返す。規約の正本は `development-workflow` の -`references/agent-layers.md` の「conductor → supervisor」にある。 - -**守る規則は起動指示の「守る規則」が渡す。** この定義は規則を写さない。起動指示に規則が -無いときは、正本の規則に従う。 - -**寿命の違う 2 つの定義があり、本文は同じである。** `supervisor` はキャッシュの寿命が 5 分で、 -収束ループの工程(リファクタリング・コードレビュー・ドキュメントレビュー)をフェーズの途中で始めると -hook が止める。止められたら `結果: スイッチポイント` で返す(規則 12)。`supervisor-waits` は寿命が -1 時間で、収束ループの工程から始める起動に使い、hook は止めない。 diff --git a/plugins/ndf/dev.agy/agents/supervisor.md b/plugins/ndf/dev.agy/agents/supervisor.md deleted file mode 100644 index a7d3b2a63..000000000 --- a/plugins/ndf/dev.agy/agents/supervisor.md +++ /dev/null @@ -1,18 +0,0 @@ ---- -name: supervisor -description: NDF の 3 層の supervisor。1 つのフェーズ(連続する工程)を通し、フェーズレポートで返す ---- - -# supervisor - -NDF の 3 層(conductor → supervisor → worker)の supervisor である。conductor が起動し、 -1 つのフェーズを通して `## フェーズの報告` で返す。規約の正本は `development-workflow` の -`references/agent-layers.md` の「conductor → supervisor」にある。 - -**守る規則は起動指示の「守る規則」が渡す。** この定義は規則を写さない。起動指示に規則が -無いときは、正本の規則に従う。 - -**寿命の違う 2 つの定義があり、本文は同じである。** `supervisor` はキャッシュの寿命が 5 分で、 -収束ループの工程(リファクタリング・コードレビュー・ドキュメントレビュー)をフェーズの途中で始めると -hook が止める。止められたら `結果: スイッチポイント` で返す(規則 12)。`supervisor-waits` は寿命が -1 時間で、収束ループの工程から始める起動に使い、hook は止めない。 diff --git a/plugins/ndf/dev.agy/agents/worker.md b/plugins/ndf/dev.agy/agents/worker.md deleted file mode 100644 index 5a9e66658..000000000 --- a/plugins/ndf/dev.agy/agents/worker.md +++ /dev/null @@ -1,28 +0,0 @@ ---- -name: worker -description: NDF の 3 層の worker。1 つの作業(調査・修正・検証・集計)を行い、作業の報告で返す -disallowedTools: Skill, Agent ---- - -# worker - -NDF の 3 層(conductor → supervisor → worker)の worker である。supervisor が起動し、 -1 つの作業を行って `## 作業の報告` で返す。規約の正本は `development-workflow` の -`references/agent-layers.md` の「supervisor → worker」にある。 - -**Skill と Agent のツールはこの定義で外してある。** 手順は起動指示の「手順」に従う。 -`SKILL.md` を読まない。手順が足りないときは `結果: 判断が要る` で返す。 - -守る規則: - -1. 人間へ問わない。別のサブエージェントを起動しない -2. 進行を記録しない(記録は supervisor が行う) -3. 収束の判定・設計の決定・受け入れ条件の書き換えを行わない。判断が要るときは - `結果: 判断が要る` で返す -4. 最後の応答の末尾に `## 作業の報告`(作業・結果・見つけたもの・置き場所・次にすること)を置く -5. 背景の処理を残したまま応答を終えない。待つときは `run_in_background` で起動して完了通知を待つ -6. Skill を起動しない。`SKILL.md` を読まない。手順は起動指示の「手順」に従う -7. 手段(コマンド・手順の順序・Tool)は状況に合わせて変えてよい。作業を妨げる不具合が作業場所の中で直せ、 - 即時修正の 4 条件(`development-workflow` の SKILL.md の「即時修正」)を満たすなら、直して続け「見つけたもの」に書く。 - 作業場所の状態が想定と違っても(detached HEAD など)作業場所の中で完遂する。置き場所(作業場所・ブランチ・ - 結果ファイルや出力のパス)と受け入れ条件・範囲は変えない。置き場所どうしが食い違ったら `結果: 判断が要る` で返す diff --git a/plugins/ndf/dev.agy/hooks.json b/plugins/ndf/dev.agy/hooks.json deleted file mode 100644 index 635d4304d..000000000 --- a/plugins/ndf/dev.agy/hooks.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "ndf-worktree": { - "PreToolUse": [ - { - "matcher": "Edit|MultiEdit|Write|NotebookEdit|fs_write|edit_file|write_file|str_replace_editor|replace|write_to_file|replace_file_content|apply_patch|Bash|shell|execute_bash|local_shell|run_command|run_shell_command", - "hooks": [ - { - "type": "command", - "command": "bash ./scripts/worktree-guard.sh", - "timeout": 5 - } - ] - } - ], - "PreInvocation": [ - { - "type": "command", - "command": "bash ./scripts/worktree-session.sh", - "timeout": 10 - } - ] - } -} diff --git a/plugins/ndf/dev.agy/install-hooks.sh b/plugins/ndf/dev.agy/install-hooks.sh deleted file mode 100755 index 4e1684150..000000000 --- a/plugins/ndf/dev.agy/install-hooks.sh +++ /dev/null @@ -1,184 +0,0 @@ -#!/usr/bin/env bash -# NDF hook installer for agy (Antigravity CLI) -# Usage: bash plugins/ndf/dev.agy/install-hooks.sh [--uninstall] [--dry-run] -# [--config PATH] [--plugin-dir PATH] -# -# `agy plugin install` は hooks.json を複製するが、**agy はそれを読み込まない**。読む先は -# 利用者の `~/.gemini/config/hooks.json` の 1 か所だけである(agy 1.1.26 で実測。プラグイン -# 配下とプロジェクト直下のどちらに置いても `loaded 1 named hooks from 1 hooks.json file(s)` -# のまま変わらない)。このスクリプトが、導入したプラグインの名前付き hook をその 1 か所へ -# 差し込む。 -# -# 冪等である。同じ名前の項目は置き換えるだけで、他の項目には触れない。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" - -CONFIG_FILE="${HOME}/.gemini/config/hooks.json" -PLUGIN_DIR="${HOME}/.gemini/config/plugins/ndf" -UNINSTALL=false -DRY_RUN=false - -while [ "$#" -gt 0 ]; do - case "$1" in - --uninstall) UNINSTALL=true ;; - --dry-run) DRY_RUN=true ;; - --config) - [ "$#" -ge 2 ] || { echo "ERROR: --config requires a path" >&2; exit 2; } - CONFIG_FILE="$2"; shift ;; - --plugin-dir) - [ "$#" -ge 2 ] || { echo "ERROR: --plugin-dir requires a path" >&2; exit 2; } - PLUGIN_DIR="$2"; shift ;; - -h|--help) - sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//' - exit 0 ;; - *) echo "ERROR: unknown option: $1" >&2; exit 2 ;; - esac - shift -done - -# 差し込む定義の出どころは、導入した実体の hooks.json である。実体が無いときだけ、この -# リポジトリの dev.agy/hooks.json へ落ちる(導入前に中身を確かめられるようにするため)。 -# -# **--uninstall でも定義を読む。** 消す対象を「配布する定義が持つ名前」に限るため、 -# 名前の出どころが要る(#417 の 8)。 -SOURCE_HOOKS="$PLUGIN_DIR/hooks.json" -[ -f "$SOURCE_HOOKS" ] || SOURCE_HOOKS="$SCRIPT_DIR/hooks.json" - -if [ ! -f "$SOURCE_HOOKS" ]; then - echo "ERROR: hook の定義が見つからない: $SOURCE_HOOKS" >&2 - exit 1 -fi - -command -v python3 >/dev/null 2>&1 || { - echo "ERROR: python3 が要る(JSON の統合に使う)" >&2 - exit 1 -} - -python3 - "$CONFIG_FILE" "$SOURCE_HOOKS" "$PLUGIN_DIR" "$UNINSTALL" "$DRY_RUN" <<'PYEOF' -import json -import os -import pathlib -import re -import shlex -import shutil -import sys -import tempfile - -config_path = pathlib.Path(sys.argv[1]) -source_path = pathlib.Path(sys.argv[2]) -# **相対のまま保存しない。** agy はリポジトリの外でも起動するため、実行時の現在地は -# プラグインの位置と揃わない(#417 の 3)。案内は clone からの相対パスで書いてあり、 -# 書かれたとおりに実行すると踏む。 -plugin_dir = pathlib.Path(sys.argv[3]).expanduser().resolve() -uninstall = sys.argv[4] == "true" -dry_run = sys.argv[5] == "true" - -# 書き換えの対象にする command の形。`bash ./scripts/<名前>` だけを見る。 -RELATIVE_COMMAND = re.compile(r"^bash \./scripts/([A-Za-z0-9._-]+)$") - -unrewritten: list[str] = [] - - -def load(path: pathlib.Path) -> dict: - """JSON を読む。無ければ空、壊れていれば止まる(黙って上書きしない)。""" - if not path.exists(): - return {} - text = path.read_text(encoding="utf-8").strip() - if not text: - return {} - try: - value = json.loads(text) - except json.JSONDecodeError as exc: - sys.exit(f"ERROR: {path} を JSON として読めない: {exc}") - if not isinstance(value, dict): - sys.exit(f"ERROR: {path} の中身が名前付き hook の辞書ではない") - return value - - -def rewrite_command(command: str) -> str: - """`bash ./scripts/x.sh` を、導入先の絶対パスで組み立て直す。 - - **置換ではなく組み立て直しである。** 置換はシェルの語としての正しさを保証しない。 - 導入先に空白や特殊文字が含まれると、**正常終了するのに hook が効かない**状態に - なる(#417 の 4)。対象の形に当たらない command は書き換えず、書き換えなかった - ことを出力へ出す(黙って相対のまま保存しない)。 - """ - matched = RELATIVE_COMMAND.match(command) - if not matched: - if "./scripts/" in command: - unrewritten.append(command) - return command - target = plugin_dir / "scripts" / matched.group(1) - return f"bash {shlex.quote(str(target))}" - - -def absolutize(node: object) -> object: - """`command` の値だけを、導入先の絶対パスへ直す。""" - if isinstance(node, dict): - return { - key: ( - rewrite_command(value) - if key == "command" and isinstance(value, str) - else absolutize(value) - ) - for key, value in node.items() - } - if isinstance(node, list): - return [absolutize(value) for value in node] - return node - - -source = load(source_path) -config = load(config_path) -# **消すのは、配布する定義が持つ名前だけである。** 接頭辞で選ぶと、利用者が自分で -# 書いた `ndf-` 始まりの hook までまとめて消える(#417 の 8)。 -names = sorted(n for n in source if n in config) if uninstall else sorted(source) - -if uninstall: - updated = {k: v for k, v in config.items() if k not in names} -else: - updated = dict(config) - for name in names: - updated[name] = absolutize(source[name]) - -for command in unrewritten: - print(f"NOTE: 対象の形に当たらないため書き換えられなかった command: {command}") - -if updated == config: - print(f"変更なし: {config_path}") - print(f"対象の hook: {', '.join(names) if names else '(なし)'}") - sys.exit(0) - -action = "削除" if uninstall else "差し込み" -print(f"{action}: {', '.join(names) if names else '(なし)'}") -print(f"書き込み先: {config_path}") - -if dry_run: - print("--dry-run のため書き込まない。書き込む内容:") - print(json.dumps(updated, ensure_ascii=False, indent=2)) - sys.exit(0) - -config_path.parent.mkdir(parents=True, exist_ok=True) -if config_path.exists(): - backup = config_path.with_suffix(config_path.suffix + ".bak") - shutil.copy2(config_path, backup) - print(f"退避: {backup}") - -# **置き換えで書く。** 直接書くと長さを 0 へ戻してから書き直すため、中断すると利用者の -# hook が壊れた JSON のまま残る(#417 の 8)。同じディレクトリへ書いてから移す。 -handle = tempfile.NamedTemporaryFile( - "w", encoding="utf-8", dir=str(config_path.parent), - prefix=config_path.name + ".", suffix=".tmp", delete=False, -) -try: - with handle: - handle.write(json.dumps(updated, ensure_ascii=False, indent=2) + "\n") - handle.flush() - os.fsync(handle.fileno()) - os.replace(handle.name, config_path) -except BaseException: - pathlib.Path(handle.name).unlink(missing_ok=True) - raise -print("完了。agy を起動し直すと反映される") -PYEOF diff --git a/plugins/ndf/dev.agy/plugin.json b/plugins/ndf/dev.agy/plugin.json deleted file mode 100644 index 2fa321db3..000000000 --- a/plugins/ndf/dev.agy/plugin.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "name": "ndf", - "version": "10.27.3", - "description": "Antigravity CLI plugin (v10.27.3): 45 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/dev.agy/scripts b/plugins/ndf/dev.agy/scripts deleted file mode 120000 index a339954df..000000000 --- a/plugins/ndf/dev.agy/scripts +++ /dev/null @@ -1 +0,0 @@ -../scripts \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/backlog-refinement b/plugins/ndf/dev.agy/skills/backlog-refinement deleted file mode 120000 index 0163331d3..000000000 --- a/plugins/ndf/dev.agy/skills/backlog-refinement +++ /dev/null @@ -1 +0,0 @@ -../../skills/backlog-refinement \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/cherry-pick-pr b/plugins/ndf/dev.agy/skills/cherry-pick-pr deleted file mode 120000 index b1be2abb3..000000000 --- a/plugins/ndf/dev.agy/skills/cherry-pick-pr +++ /dev/null @@ -1 +0,0 @@ -../../skills/cherry-pick-pr \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/chrome-cdp b/plugins/ndf/dev.agy/skills/chrome-cdp deleted file mode 120000 index 3c83baec8..000000000 --- a/plugins/ndf/dev.agy/skills/chrome-cdp +++ /dev/null @@ -1 +0,0 @@ -../../skills/chrome-cdp \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/cross-refactoring b/plugins/ndf/dev.agy/skills/cross-refactoring deleted file mode 120000 index 2e33174a4..000000000 --- a/plugins/ndf/dev.agy/skills/cross-refactoring +++ /dev/null @@ -1 +0,0 @@ -../../skills/cross-refactoring \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/cross-review b/plugins/ndf/dev.agy/skills/cross-review deleted file mode 120000 index 5e19a3360..000000000 --- a/plugins/ndf/dev.agy/skills/cross-review +++ /dev/null @@ -1 +0,0 @@ -../../skills/cross-review \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/decision-request b/plugins/ndf/dev.agy/skills/decision-request deleted file mode 120000 index cc949c2d2..000000000 --- a/plugins/ndf/dev.agy/skills/decision-request +++ /dev/null @@ -1 +0,0 @@ -../../skills/decision-request \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/deploy b/plugins/ndf/dev.agy/skills/deploy deleted file mode 120000 index 9b4912f9b..000000000 --- a/plugins/ndf/dev.agy/skills/deploy +++ /dev/null @@ -1 +0,0 @@ -../../skills/deploy \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/design b/plugins/ndf/dev.agy/skills/design deleted file mode 120000 index 288cb4e14..000000000 --- a/plugins/ndf/dev.agy/skills/design +++ /dev/null @@ -1 +0,0 @@ -../../skills/design \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/development-workflow b/plugins/ndf/dev.agy/skills/development-workflow deleted file mode 120000 index 3b252c082..000000000 --- a/plugins/ndf/dev.agy/skills/development-workflow +++ /dev/null @@ -1 +0,0 @@ -../../skills/development-workflow \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/docker-container-access b/plugins/ndf/dev.agy/skills/docker-container-access deleted file mode 120000 index be3faec7f..000000000 --- a/plugins/ndf/dev.agy/skills/docker-container-access +++ /dev/null @@ -1 +0,0 @@ -../../skills/docker-container-access \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/document-drafting b/plugins/ndf/dev.agy/skills/document-drafting deleted file mode 120000 index c25e273aa..000000000 --- a/plugins/ndf/dev.agy/skills/document-drafting +++ /dev/null @@ -1 +0,0 @@ -../../skills/document-drafting \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/document-rebuild b/plugins/ndf/dev.agy/skills/document-rebuild deleted file mode 120000 index 2b1a27fb7..000000000 --- a/plugins/ndf/dev.agy/skills/document-rebuild +++ /dev/null @@ -1 +0,0 @@ -../../skills/document-rebuild \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/document-sources b/plugins/ndf/dev.agy/skills/document-sources deleted file mode 120000 index 4b46abf0c..000000000 --- a/plugins/ndf/dev.agy/skills/document-sources +++ /dev/null @@ -1 +0,0 @@ -../../skills/document-sources \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/document-style b/plugins/ndf/dev.agy/skills/document-style deleted file mode 120000 index c0d355f04..000000000 --- a/plugins/ndf/dev.agy/skills/document-style +++ /dev/null @@ -1 +0,0 @@ -../../skills/document-style \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/document-systems b/plugins/ndf/dev.agy/skills/document-systems deleted file mode 120000 index 417342832..000000000 --- a/plugins/ndf/dev.agy/skills/document-systems +++ /dev/null @@ -1 +0,0 @@ -../../skills/document-systems \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/estimate b/plugins/ndf/dev.agy/skills/estimate deleted file mode 120000 index d726b2193..000000000 --- a/plugins/ndf/dev.agy/skills/estimate +++ /dev/null @@ -1 +0,0 @@ -../../skills/estimate \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/external-ai b/plugins/ndf/dev.agy/skills/external-ai deleted file mode 120000 index dc45f6085..000000000 --- a/plugins/ndf/dev.agy/skills/external-ai +++ /dev/null @@ -1 +0,0 @@ -../../skills/external-ai \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/fix b/plugins/ndf/dev.agy/skills/fix deleted file mode 120000 index c9534c936..000000000 --- a/plugins/ndf/dev.agy/skills/fix +++ /dev/null @@ -1 +0,0 @@ -../../skills/fix \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/google-workspace b/plugins/ndf/dev.agy/skills/google-workspace deleted file mode 120000 index 3bceed204..000000000 --- a/plugins/ndf/dev.agy/skills/google-workspace +++ /dev/null @@ -1 +0,0 @@ -../../skills/google-workspace \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/implementation-plan b/plugins/ndf/dev.agy/skills/implementation-plan deleted file mode 120000 index 83547e127..000000000 --- a/plugins/ndf/dev.agy/skills/implementation-plan +++ /dev/null @@ -1 +0,0 @@ -../../skills/implementation-plan \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/investigation-rules b/plugins/ndf/dev.agy/skills/investigation-rules deleted file mode 120000 index 0b63b44fb..000000000 --- a/plugins/ndf/dev.agy/skills/investigation-rules +++ /dev/null @@ -1 +0,0 @@ -../../skills/investigation-rules \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/issue-plan-strategy b/plugins/ndf/dev.agy/skills/issue-plan-strategy deleted file mode 120000 index ce66014c9..000000000 --- a/plugins/ndf/dev.agy/skills/issue-plan-strategy +++ /dev/null @@ -1 +0,0 @@ -../../skills/issue-plan-strategy \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/layout-review b/plugins/ndf/dev.agy/skills/layout-review deleted file mode 120000 index 9b9b56a14..000000000 --- a/plugins/ndf/dev.agy/skills/layout-review +++ /dev/null @@ -1 +0,0 @@ -../../skills/layout-review \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/logging-guidelines b/plugins/ndf/dev.agy/skills/logging-guidelines deleted file mode 120000 index 6987f9ad6..000000000 --- a/plugins/ndf/dev.agy/skills/logging-guidelines +++ /dev/null @@ -1 +0,0 @@ -../../skills/logging-guidelines \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/merged b/plugins/ndf/dev.agy/skills/merged deleted file mode 120000 index dc91bf9bf..000000000 --- a/plugins/ndf/dev.agy/skills/merged +++ /dev/null @@ -1 +0,0 @@ -../../skills/merged \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/ml-model-structure b/plugins/ndf/dev.agy/skills/ml-model-structure deleted file mode 120000 index abf7c0518..000000000 --- a/plugins/ndf/dev.agy/skills/ml-model-structure +++ /dev/null @@ -1 +0,0 @@ -../../skills/ml-model-structure \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/ndf-policies b/plugins/ndf/dev.agy/skills/ndf-policies deleted file mode 120000 index fe50a72fb..000000000 --- a/plugins/ndf/dev.agy/skills/ndf-policies +++ /dev/null @@ -1 +0,0 @@ -../../skills/ndf-policies \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/notion-writing b/plugins/ndf/dev.agy/skills/notion-writing deleted file mode 120000 index 4eb4605b1..000000000 --- a/plugins/ndf/dev.agy/skills/notion-writing +++ /dev/null @@ -1 +0,0 @@ -../../skills/notion-writing \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/out-of-scope b/plugins/ndf/dev.agy/skills/out-of-scope deleted file mode 120000 index 801078dd8..000000000 --- a/plugins/ndf/dev.agy/skills/out-of-scope +++ /dev/null @@ -1 +0,0 @@ -../../skills/out-of-scope \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/plan-to-spec b/plugins/ndf/dev.agy/skills/plan-to-spec deleted file mode 120000 index 0d3b1bf7f..000000000 --- a/plugins/ndf/dev.agy/skills/plan-to-spec +++ /dev/null @@ -1 +0,0 @@ -../../skills/plan-to-spec \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/pr b/plugins/ndf/dev.agy/skills/pr deleted file mode 120000 index 5167ba85c..000000000 --- a/plugins/ndf/dev.agy/skills/pr +++ /dev/null @@ -1 +0,0 @@ -../../skills/pr \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/pr-review b/plugins/ndf/dev.agy/skills/pr-review deleted file mode 120000 index 0daced676..000000000 --- a/plugins/ndf/dev.agy/skills/pr-review +++ /dev/null @@ -1 +0,0 @@ -../../skills/pr-review \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/pr-tests b/plugins/ndf/dev.agy/skills/pr-tests deleted file mode 120000 index e6b0e830e..000000000 --- a/plugins/ndf/dev.agy/skills/pr-tests +++ /dev/null @@ -1 +0,0 @@ -../../skills/pr-tests \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/problem-solving b/plugins/ndf/dev.agy/skills/problem-solving deleted file mode 120000 index 2cfb3d9cc..000000000 --- a/plugins/ndf/dev.agy/skills/problem-solving +++ /dev/null @@ -1 +0,0 @@ -../../skills/problem-solving \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/progress-tracking b/plugins/ndf/dev.agy/skills/progress-tracking deleted file mode 120000 index 0b6c6c7b8..000000000 --- a/plugins/ndf/dev.agy/skills/progress-tracking +++ /dev/null @@ -1 +0,0 @@ -../../skills/progress-tracking \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/qa-security-scan b/plugins/ndf/dev.agy/skills/qa-security-scan deleted file mode 120000 index a3089de12..000000000 --- a/plugins/ndf/dev.agy/skills/qa-security-scan +++ /dev/null @@ -1 +0,0 @@ -../../skills/qa-security-scan \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/quality-gates b/plugins/ndf/dev.agy/skills/quality-gates deleted file mode 120000 index 66afc786f..000000000 --- a/plugins/ndf/dev.agy/skills/quality-gates +++ /dev/null @@ -1 +0,0 @@ -../../skills/quality-gates \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/refactoring b/plugins/ndf/dev.agy/skills/refactoring deleted file mode 120000 index 70a03362b..000000000 --- a/plugins/ndf/dev.agy/skills/refactoring +++ /dev/null @@ -1 +0,0 @@ -../../skills/refactoring \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/release b/plugins/ndf/dev.agy/skills/release deleted file mode 120000 index 8d3c1a425..000000000 --- a/plugins/ndf/dev.agy/skills/release +++ /dev/null @@ -1 +0,0 @@ -../../skills/release \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/release-verification b/plugins/ndf/dev.agy/skills/release-verification deleted file mode 120000 index a2745ba4b..000000000 --- a/plugins/ndf/dev.agy/skills/release-verification +++ /dev/null @@ -1 +0,0 @@ -../../skills/release-verification \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/requirements-design b/plugins/ndf/dev.agy/skills/requirements-design deleted file mode 120000 index 9c666662f..000000000 --- a/plugins/ndf/dev.agy/skills/requirements-design +++ /dev/null @@ -1 +0,0 @@ -../../skills/requirements-design \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/retrospective b/plugins/ndf/dev.agy/skills/retrospective deleted file mode 120000 index 837cc05d8..000000000 --- a/plugins/ndf/dev.agy/skills/retrospective +++ /dev/null @@ -1 +0,0 @@ -../../skills/retrospective \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/skill-stats b/plugins/ndf/dev.agy/skills/skill-stats deleted file mode 120000 index 3dc2587e0..000000000 --- a/plugins/ndf/dev.agy/skills/skill-stats +++ /dev/null @@ -1 +0,0 @@ -../../skills/skill-stats \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/tdd-cycle b/plugins/ndf/dev.agy/skills/tdd-cycle deleted file mode 120000 index ffa0c5d76..000000000 --- a/plugins/ndf/dev.agy/skills/tdd-cycle +++ /dev/null @@ -1 +0,0 @@ -../../skills/tdd-cycle \ No newline at end of file diff --git a/plugins/ndf/dev.agy/skills/worktree b/plugins/ndf/dev.agy/skills/worktree deleted file mode 120000 index 28d0b89b0..000000000 --- a/plugins/ndf/dev.agy/skills/worktree +++ /dev/null @@ -1 +0,0 @@ -../../skills/worktree \ No newline at end of file diff --git a/plugins/ndf/dev.kiro/agents/ndf.json.template b/plugins/ndf/dev.kiro/agents/ndf.json.template deleted file mode 100644 index b82fd3bad..000000000 --- a/plugins/ndf/dev.kiro/agents/ndf.json.template +++ /dev/null @@ -1,18 +0,0 @@ -{ - "name": "ndf", - "description": "NDF統合開発エージェント(Kiro CLI用 / v{{VERSION}})", - "tools": [ - "*" - ], - "resources": [ - "file://AGENTS.md", - "file://README.md" - ], - "hooks": { - "agentSpawn": [ - { - "command": "if [ -f \"${PWD}/CLAUDE.ndf.md\" ] || [ -f \"$HOME/.claude/CLAUDE.ndf.md\" ]; then echo \"[NDF] CLAUDE.ndf.md が検出されました。廃止済みです。cleanup を実行して削除してください。\"; fi" - } - ] - } -} diff --git a/plugins/ndf/dev.kiro/install.sh b/plugins/ndf/dev.kiro/install.sh deleted file mode 100755 index 22493894b..000000000 --- a/plugins/ndf/dev.kiro/install.sh +++ /dev/null @@ -1,561 +0,0 @@ -#!/usr/bin/env bash -# NDF Plugin Installer for Kiro CLI -# Usage: bash plugins/ndf/dev.kiro/install.sh [--project PATH] [--scope workspace|global] -# [--set-default] [--yes] -# [--with-slack] [--with-codex] [--dry-run] -# -# このスクリプトは Agent Plugins 仕様 §8.2 のクライアント拡張ディレクトリ(dev.kiro/)に -# 置く。プラグインルートは 1 つ上の階層で、Skill と scripts はそちらから読む。Kiro CLI 固有の -# エージェント定義とプロンプトだけがこのディレクトリにある。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -AGENT_NAME="ndf" -TEMPLATE_FILE="$SCRIPT_DIR/agents/$AGENT_NAME.json.template" -PLUGIN_SKILLS_DIR="$PLUGIN_DIR/skills" -PLUGIN_PROMPTS_DIR="$SCRIPT_DIR/prompts" -POLICY_SKILL_FILE="$PLUGIN_SKILLS_DIR/ndf-policies/SKILL.md" -SKILL_MANIFEST="$PLUGIN_DIR/manifests/kiro-skills.txt" -MANIFEST_FILE="$PLUGIN_DIR/.claude-plugin/plugin.json" -# 版数はルートのマニフェストだけが持つ。Kiro 向けに複製した VERSION ファイルは置かない -# (同じ値を 2 箇所に置くと、片方だけが古くなる)。 -# Skill の統合・改名により配布を終えた prompt。過去のインストールで .kiro/prompts/ に残った分を除去する -# review.md は v6.0.0 で pr-review.md へ改名した -DEPRECATED_PROMPTS="clean.md review.md" - -# Parse options -PROJECT_ROOT="$(pwd)" -PROJECT_GIVEN=false -SCOPE="workspace" -SET_DEFAULT=false -ASSUME_YES=false -WITH_SLACK=false -WITH_CODEX=false -DRY_RUN=false -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - # cd の裸のエラーで終わらせず、何を直せばよいかを案内して止める(#415)。 - # 作り方は存在しないパスにだけ添える(ファイルに mkdir -p は失敗する)。 - # 案内のパスは printf '%q' で bash の語 1 つとして読める形へ整える。 - [ -e "$2" ] || { - echo "ERROR: --project points at a path that does not exist: $2" >&2 - printf 'HINT: mkdir -p %q\n' "$2" >&2 - exit 2 - } - [ -d "$2" ] || { echo "ERROR: --project points at a path that is not a directory: $2" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - PROJECT_GIVEN=true - shift - ;; - --scope) - [ "$#" -ge 2 ] || { echo "ERROR: --scope requires workspace or global" >&2; exit 2; } - SCOPE="$2" - shift - ;; - --set-default) SET_DEFAULT=true ;; - --yes|-y) ASSUME_YES=true ;; - --with-slack) WITH_SLACK=true ;; - --with-codex) WITH_CODEX=true ;; - --dry-run) DRY_RUN=true ;; - --help|-h) - echo "Usage: bash plugins/ndf/dev.kiro/install.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --project PATH install into PATH instead of current directory (--scope workspace のみ)" - echo " --scope SCOPE workspace(既定, プロジェクトの .kiro/)または global(~/.kiro/)" - echo " --set-default kiro-cli の既定エージェントを ndf に切り替える(オプトイン)" - echo " -y, --yes --set-default の確認プロンプトを省略する" - echo " --with-slack stopフックに回答・承認待ちのSlack通知を追加" - echo " --with-codex Codex MCPサーバ設定と直接実行用プロンプトを追加" - echo " --dry-run 書き込みを行わず実行内容を表示" - echo " -h, --help このヘルプを表示" - exit 0 - ;; - *) - echo "ERROR: unknown option: $1" >&2 - exit 2 - ;; - esac - shift -done - -case "$SCOPE" in - workspace) - KIRO_DIR="$PROJECT_ROOT/.kiro" - ;; - global) - [ -n "${HOME:-}" ] || { echo "ERROR: --scope global には HOME が必要です" >&2; exit 2; } - KIRO_DIR="$HOME/.kiro" - if [ "$PROJECT_GIVEN" = true ]; then - echo "WARN: --scope global では --project は使用されません" >&2 - fi - ;; - *) - echo "ERROR: invalid --scope: $SCOPE (workspace|global)" >&2 - exit 2 - ;; -esac - -SKILLS_DIR="$KIRO_DIR/skills" -PROMPTS_DIR="$KIRO_DIR/prompts" -STEERING_FILE="$KIRO_DIR/steering/ndf-policies.md" -AGENT_FILE="$KIRO_DIR/agents/$AGENT_NAME.json" -LEGACY_AGENT_FILE="$KIRO_DIR/agents/default.json" - -require_path() { - local test_flag="$1" path="$2" - test "$test_flag" "$path" || { - echo "ERROR: $path が見つかりません" >&2 - exit 1 - } -} - -# 掃除するのは現在の checkout($PLUGIN_SKILLS_DIR 配下)を指す既存リンクだけに限る。 -# 別の場所を指す利用者のリンクは残す。旧 installer が別 checkout から張ったリンクは -# ここでは掛からず、後段の migrate_policy_skill_entry などが名前単位で扱う。 -remove_managed_skill_links() { - mkdir -p "$SKILLS_DIR" - local skill_link target target_abs plugin_skills_abs - plugin_skills_abs="$(realpath -m "$PLUGIN_SKILLS_DIR")" - while IFS= read -r skill_link; do - target="$(readlink "$skill_link")" - case "$target" in - /*) target_abs="$target" ;; - *) target_abs="$(realpath -m "$(dirname "$skill_link")/$target")" ;; - esac - case "$target_abs" in - "$plugin_skills_abs"/*) rm -f "$skill_link" ;; - esac - done < <(find "$SKILLS_DIR" -mindepth 1 -maxdepth 1 -type l | sort) -} - -# ndf-policies は Step 3 で steering として展開する。Skill としてもリンクすると -# Kiro 組み込みルールの Skill 読み込みと steering 読み込みで文脈へ二重注入されるため、 -# ここではリンクしない。manifest には残す(steering の生成元として必要なため)。 -# 旧 installer が別 checkout から張ったリンクは remove_managed_skill_links の掃除 -# (現在の $PLUGIN_SKILLS_DIR 配下を指すものだけ削除)に掛からないため、ここで -# リンク先に関係なく既存のエントリを取り除いてから skip する。 -migrate_policy_skill_entry() { - local skill_name="$1" - # 削除するのは旧 installer が張ったシンボリックリンクだけに限る。実体 - # ディレクトリや通常ファイルは利用者が置いたものの可能性があるため、 - # 消さずに案内して手動対応に委ねる。 - if [ -L "$SKILLS_DIR/$skill_name" ]; then - if [ "$DRY_RUN" = false ]; then - rm -f "$SKILLS_DIR/$skill_name" - fi - echo " REMOVED: $skill_name (steering へ移行済みのため .kiro/skills のリンクを削除)" - elif [ -e "$SKILLS_DIR/$skill_name" ]; then - echo " WARN: $SKILLS_DIR/$skill_name はシンボリックリンクではありません。" >&2 - echo " steering (.kiro/steering/ndf-policies.md) と二重に読み込まれるため、" >&2 - echo " 内容を確認のうえ手動で退避または削除してください。" >&2 - fi - echo " SKIP: $skill_name (steering として配置)" -} - -# manifest 掲載の Skill を .kiro/skills/ へリンクする。張った本数はグローバルの -# SKILL_COUNT へ明示して返し(標準出力は " linked:" 等の案内だけに保つ)、呼び出し側は -# その変数を読む。ndf-policies は migrate_policy_skill_entry に委ね、リンクは張らない。 -install_manifest_skills() { - SKILL_COUNT=0 - local src_dir skill_name - while IFS= read -r src_dir; do - skill_name="$(basename "$src_dir")" - - if [ ! -f "$src_dir/SKILL.md" ]; then - echo " SKIP: $skill_name (SKILL.mdなし)" - continue - fi - - if [ "$skill_name" = "ndf-policies" ]; then - migrate_policy_skill_entry "$skill_name" - continue - fi - - if [ "$DRY_RUN" = false ]; then - ln -sfn "$PLUGIN_SKILLS_DIR/$skill_name" "$SKILLS_DIR/$skill_name" - fi - echo " linked: $skill_name" - SKILL_COUNT=$((SKILL_COUNT + 1)) - # 配る Skill は manifests/kiro-skills.txt が決める。skills/ にはどの runtime へも配る - # Skill が並んでおり、Kiro 向けはその一部である。ディレクトリを列挙すると、他の runtime - # だけへ配る Skill まで張ってしまう。 - done < <(sed 's/#.*//' "$SKILL_MANIFEST" | sed 's/[[:space:]]*$//' \ - | grep -v '^$' | sort | sed "s#^#$PLUGIN_SKILLS_DIR/#") -} - -# 再インストールで消すのは installer が配った prompt だけに限る。--with-codex を -# 外したときの codex.md と、配布を終えた $DEPRECATED_PROMPTS を除き、利用者の prompt は残す。 -cleanup_managed_prompts() { - mkdir -p "$PROMPTS_DIR" - if [ "$WITH_CODEX" = false ]; then - rm -f "$PROMPTS_DIR/codex.md" - fi - local deprecated_prompt - for deprecated_prompt in $DEPRECATED_PROMPTS; do - if [ -f "$PROMPTS_DIR/$deprecated_prompt" ]; then - rm -f "$PROMPTS_DIR/$deprecated_prompt" - echo " removed (deprecated): ${deprecated_prompt%.md}" - fi - done -} - -# 配布元の prompt を .kiro/prompts/ へコピーする。codex.md の有無のチェックはコピーと -# 案内の出力を終えた後に行う(チェックより前の標準出力と配置を変えないため)。 -install_prompts() { - local prompt_file prompt_name - while IFS= read -r prompt_file; do - prompt_name="$(basename "$prompt_file")" - [ "$prompt_name" = "codex.md" ] && [ "$WITH_CODEX" = false ] && continue - if [ "$DRY_RUN" = false ]; then - cp "$prompt_file" "$PROMPTS_DIR/$prompt_name" - fi - echo " prompt: ${prompt_name%.md}" - done < <(find "$PLUGIN_PROMPTS_DIR" -maxdepth 1 -type f -name '*.md' | sort) - - if [ "$WITH_CODEX" = true ]; then - require_path -f "$PLUGIN_PROMPTS_DIR/codex.md" - fi -} - -echo "=== NDF Plugin Installer for Kiro CLI ===" -echo " スコープ: $SCOPE ($KIRO_DIR)" - -require_path -d "$PLUGIN_SKILLS_DIR" -require_path -f "$SKILL_MANIFEST" -require_path -f "$MANIFEST_FILE" -NDF_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST_FILE")" -if [ -z "$NDF_VERSION" ]; then - echo "ERROR: $MANIFEST_FILE から版数を読み取れません" >&2 - exit 1 -fi - -require_path -f "$TEMPLATE_FILE" -require_path -f "$POLICY_SKILL_FILE" - -# --- Step 1: Create symlinks in /skills/ --- -echo "Skills シンボリックリンクを作成中..." -if [ "$DRY_RUN" = false ]; then - remove_managed_skill_links -fi -install_manifest_skills - -# --- Step 2: Create prompts in /prompts/ for workflow skills --- -echo "ワークフロープロンプトを作成中..." -if [ "$DRY_RUN" = false ]; then - cleanup_managed_prompts -fi -install_prompts - -if [ "$WITH_SLACK" = true ]; then echo "Slack通知: 有効"; else echo "Slack通知: 無効 (--with-slack で有効化)"; fi -if [ "$WITH_CODEX" = true ]; then echo "Codex CLI連携: 有効"; else echo "Codex CLI連携: 無効 (--with-codex で有効化)"; fi - -if [ "$DRY_RUN" = true ]; then - echo "" - echo "DRY RUN: 書き込みは行いませんでした" - if [ -f "$LEGACY_AGENT_FILE" ]; then - echo " 旧設定 $LEGACY_AGENT_FILE を検出(実行時に移行可否を判定します)" - fi - echo " NDF バージョン: $NDF_VERSION" - echo " エージェント設定: $AGENT_FILE" - echo " 常時指示: $STEERING_FILE" - echo " Skills数: $SKILL_COUNT" - exit 0 -fi - -# --- Step 3: Generate steering (always-on instructions) --- -# steering はエージェント選択に依存せず読み込まれるため、常時指示はここへ置く。 -mkdir -p "$(dirname "$STEERING_FILE")" -python3 - "$POLICY_SKILL_FILE" "$STEERING_FILE" <<'PY' -import sys -from pathlib import Path - -source, dest = (Path(p) for p in sys.argv[1:3]) -text = source.read_text(encoding="utf-8") -if text.startswith("---\n"): - end = text.find("\n---\n", 3) - if end != -1: - text = text[end + len("\n---\n"):] -body = text.strip("\n") -header = ( - "\n" - "\n" -) -dest.write_text(f"{header}\n{body}\n", encoding="utf-8") -PY -echo "常時指示を生成: $STEERING_FILE" - -# --- Step 4: Migrate legacy default agent --- -MIGRATED_FROM_LEGACY=false -if [ -f "$LEGACY_AGENT_FILE" ]; then - cp "$LEGACY_AGENT_FILE" "${LEGACY_AGENT_FILE}.bak" - echo "" - echo "WARN: 旧エージェント設定 $LEGACY_AGENT_FILE を検出しました。" - echo " バックアップ: ${LEGACY_AGENT_FILE}.bak" - if python3 -c ' -import json, sys -try: - config = json.load(open(sys.argv[1], encoding="utf-8")) - # JSON がオブジェクト以外(配列など)でも AttributeError にせず、壊れた JSON と - # 同じ「NDF 生成物ではない」扱いへ倒す。 - # 旧 installer が生成したものだけを識別する。description に NDF が含まれる - # だけでは、利用者が NDF 用に自作した default エージェントまで移行対象に - # なり、テンプレートで上書きしてしまう。旧テンプレート固有の値との一致を - # 求める。 - LEGACY_DESCRIPTION = "NDF統合開発エージェント(Kiro CLI用)" - LEGACY_RESOURCE = "skill://.kiro/skills/**/SKILL.md" - LEGACY_HOOK_MARK = "CLAUDE.ndf.md" - - def has_legacy_hook(cfg): - hooks = cfg.get("hooks") - if not isinstance(hooks, dict): - return False - spawn = hooks.get("agentSpawn") - if not isinstance(spawn, list): - return False - return any( - isinstance(h, dict) and LEGACY_HOOK_MARK in str(h.get("command") or "") - for h in spawn - ) - - resources = config.get("resources") if isinstance(config, dict) else None - matched = ( - isinstance(config, dict) - and config.get("name") == "default" - and config.get("description") == LEGACY_DESCRIPTION - and ( - (isinstance(resources, list) and LEGACY_RESOURCE in resources) - or has_legacy_hook(config) - ) - ) -except Exception: - sys.exit(1) -sys.exit(0 if matched else 1) -' "$LEGACY_AGENT_FILE"; then - echo " これは旧版 NDF installer の生成物です。" - if [ -f "$AGENT_FILE" ]; then - # 移行先が既にある場合に上書きすると、そちらの利用者設定を失う。手動判断へ回す。 - echo " ただし $AGENT_FILE が既に存在するため自動移行しません。" - echo " 移行手順:" - echo " 1. 必要な設定が ${LEGACY_AGENT_FILE}.bak にだけ残っていないか確認する" - echo " 2. 不要になったら rm $LEGACY_AGENT_FILE ${LEGACY_AGENT_FILE}.bak" - else - # 旧設定を移行先へ置いてから Step 5 に進める。Step 5 は既存ファイルから - # installer 管理外のキーを引き継ぐため、これだけで利用者設定の移行と - # テンプレート由来キー(エージェント名など)の最新化が両方完了する。 - mv "$LEGACY_AGENT_FILE" "$AGENT_FILE" - MIGRATED_FROM_LEGACY=true - echo " $AGENT_FILE へ自動移行しました(利用者が追記した設定は下で引き継ぎます)。" - echo " 不要になったら: rm ${LEGACY_AGENT_FILE}.bak" - fi - else - echo " NDF 以外が管理している設定です。移行手順:" - echo " 1. 必要な mcpServers / hooks を $AGENT_FILE へ写す" - echo " 2. 不要になったら rm $LEGACY_AGENT_FILE ${LEGACY_AGENT_FILE}.bak" - echo " Kiro 用 MCP プラグインの installer は default.json を更新するため、" - echo " MCP を併用する場合は上記の写し替えが必要です。" - echo " 写した設定は本 installer を再実行しても保持されます。" - fi - echo "" -fi - -# --- Step 5: Generate agent config --- -mkdir -p "$KIRO_DIR/agents" - -# Step 4 で移行した直後は ${LEGACY_AGENT_FILE}.bak が同じ内容のバックアップなので取らない。 -if [ -f "$AGENT_FILE" ] && [ "$MIGRATED_FROM_LEGACY" = false ]; then - cp "$AGENT_FILE" "${AGENT_FILE}.bak" - echo "既存設定をバックアップ: ${AGENT_FILE}.bak" -fi - -# installer が管理するのはテンプレート由来のキー(name / description / tools / -# resources / hooks.agentSpawn)と、フラグで切り替える hooks.stop / mcpServers.codex -# だけ。それ以外(利用者が足した mcpServers エントリ、独自フック、独自キー)は -# 既存の $AGENT_FILE から引き継ぐ。再インストールで写し替えた設定が消えないようにする。 -python3 - "$TEMPLATE_FILE" "$WITH_SLACK" "$WITH_CODEX" "$AGENT_FILE" "$PLUGIN_DIR" "$NDF_VERSION" <<'PY' -import json -import shlex -import sys -from pathlib import Path - -template_file, with_slack, with_codex, agent_file, plugin_dir, ndf_version = sys.argv[1:7] -with open(template_file, encoding="utf-8") as f: - config = json.load(f) - -# 導入後にも版数を確認できるよう description へ埋める。Kiro には plugin.json が -# ないため、これと VERSION ファイルが版数を示す唯一の手段になる。 -config["description"] = config["description"].replace("{{VERSION}}", ndf_version) - -# installer が上書きする範囲 -managed_keys = set(config) | {"mcpServers"} -managed_hooks = set(config.get("hooks") or {}) | {"stop", "userPromptSubmit"} -managed_servers = {"codex"} - -hooks = config.setdefault("hooks", {}) - -# 作業ツリー運用の hook。判定は 3 ランタイム共通のスクリプトが持つ。 -# Kiro CLI は tool 実行前の hook からモデルへ案内を渡す手段が終了コード 2 に -# 限られ、これは tool の実行を拒否する。拒否しない方針のもとでは置けないため、 -# パスを見ない案内をプロンプト送信時の hook が担う(詳細設計 06 の決定 5)。 -guard_script = Path(plugin_dir) / "scripts" / "worktree-guard.sh" -session_script = Path(plugin_dir) / "scripts" / "worktree-session.sh" -hooks.setdefault("agentSpawn", []).append( - { - "command": f"bash {shlex.quote(str(session_script))}", - "timeout_ms": 10000, - } -) -hooks["userPromptSubmit"] = [ - { - "command": f"bash {shlex.quote(str(guard_script))}", - "timeout_ms": 5000, - } -] - -if with_slack == "true": - notify_script = Path(plugin_dir) / "scripts" / "wait-notify.py" - hooks["stop"] = [ - { - "command": f"python3 {shlex.quote(str(notify_script))} --runtime kiro", - "timeout_ms": 15000, - } - ] -else: - hooks.pop("stop", None) - -servers = {} -if with_codex == "true": - servers["codex"] = { - "command": "codex", - "args": ["mcp-server"], - "env": {}, - } - -existing = {} -agent_path = Path(agent_file) -if agent_path.is_file(): - try: - loaded = json.loads(agent_path.read_text(encoding="utf-8")) - except (json.JSONDecodeError, UnicodeDecodeError) as exc: - print(f" WARN: 既存の {agent_file} を読めないため引き継ぎません: {exc}") - else: - if isinstance(loaded, dict): - existing = loaded - else: - print(f" WARN: 既存の {agent_file} が JSON オブジェクトではないため引き継ぎません") - -kept = [] -for key, value in existing.items(): - if key not in managed_keys: - config[key] = value - kept.append(key) -# hooks / mcpServers が dict 以外(配列や文字列)だと .items() で落ちるため、 -# 壊れた JSON と同じく警告して引継ぎ対象から外す。 -for section, target, managed in ( - ("hooks", hooks, managed_hooks), - ("mcpServers", servers, managed_servers), -): - value = existing.get(section) - if value is None: - continue - if not isinstance(value, dict): - print(f" WARN: 既存の {agent_file} の {section} が JSON オブジェクトではないため引き継ぎません") - continue - for key, item in value.items(): - if key not in managed: - target[key] = item - kept.append(f"{section}.{key}") - -if not hooks: - config.pop("hooks", None) -if servers: - config["mcpServers"] = servers -else: - config.pop("mcpServers", None) - -with open(agent_file, "w", encoding="utf-8") as f: - json.dump(config, f, indent=2, ensure_ascii=False) - f.write("\n") - -if kept: - print(" 利用者管理の設定を引き継ぎました: " + ", ".join(sorted(kept))) -PY - -# --- Step 6: Optionally switch the default agent --- -if [ "$SET_DEFAULT" = true ]; then - echo "" - if ! command -v kiro-cli >/dev/null 2>&1; then - echo "ERROR: kiro-cli が見つからないため既定エージェントを変更できません" >&2 - exit 1 - fi - # kiro-cli は workspace エージェントを cwd 配下の .kiro/agents からのみ検出する。 - # 呼び出し元 cwd のままだと、--project で別ディレクトリへ導入したエージェントを - # 見つけられない(または同名の別エージェントを既定にしてしまう)。生成した - # $AGENT_FILE を確実に指すディレクトリで kiro-cli を実行する。 - # workspace: 導入先プロジェクトルート ($PROJECT_ROOT/.kiro/agents) - # global: $HOME ($HOME/.kiro/agents = 生成先。global エージェントはどこからでも - # 解決できるが、cwd 側の同名 workspace エージェントに隠されないようにする) - case "$SCOPE" in - workspace) KIRO_CWD="$PROJECT_ROOT" ;; - *) KIRO_CWD="$HOME" ;; - esac - esc="$(printf '\033')" - # kiro-cli 2.16.1 の agent list は一覧を標準エラー出力へ書く - kiro_default_agent() { - (cd "$KIRO_CWD" && kiro-cli agent list 2>&1) \ - | sed -e "s/${esc}\\[[0-9;]*m//g" \ - | awk '/^\*/ { print $2; exit }' - } - # 表示用の取得は失敗しても続行する(未ログイン等でも set-default の結果は後段で検証する) - current_default="$(kiro_default_agent || true)" - echo "既定エージェントの操作ディレクトリ: $KIRO_CWD" - echo "現在の既定エージェント: ${current_default:-不明}" - echo "変更後の既定エージェント: $AGENT_NAME" - proceed=true - if [ "$ASSUME_YES" = false ]; then - if [ -t 0 ]; then - printf '既定エージェントを %s に変更しますか? [y/N]: ' "$AGENT_NAME" - # EOF (Ctrl+D) で read が非ゼロ終了しても set -e で落とさず、既定の N へ倒す - read -r answer || answer="" - case "$answer" in - [yY]|[yY][eE][sS]) ;; - *) proceed=false ;; - esac - else - echo "確認入力を取得できないため、--set-default の指定を承認とみなして続行します" - fi - fi - if [ "$proceed" = true ]; then - (cd "$KIRO_CWD" && kiro-cli agent set-default "$AGENT_NAME") - # kiro-cli 2.16.1 の set-default はエージェント未検出でも終了コード 0 を返すため、 - # 反映結果を agent list で検証する。 - if [ "$(kiro_default_agent || true)" != "$AGENT_NAME" ]; then - echo "ERROR: 既定エージェントを $AGENT_NAME に変更できませんでした($KIRO_CWD で検出できず)" >&2 - exit 1 - fi - echo "既定エージェントを $AGENT_NAME に変更しました(元に戻す: kiro-cli agent set-default ${current_default:-kiro_default})" - else - echo "既定エージェントは変更しませんでした" - fi -fi - -echo "" -echo "=== インストール完了 ===" -echo " NDF バージョン: $NDF_VERSION" -echo " エージェント設定: $AGENT_FILE" -echo " 常時指示: $STEERING_FILE" -echo " Skills数: $SKILL_COUNT (シンボリックリンク: $SKILLS_DIR)" -echo "" -echo "前提: NDF は 1M コンテキストのモデルを対象に配布 Skill を組んでいます。" -echo " 起動時に読み込む文脈は $SKILL_COUNT 個の SKILL.md と AGENTS.md / README.md / 常時指示です。" -echo " 既定の auto はこの条件を満たします。モデルを固定する場合も 1M のものを選んでください。" -echo "" -echo "Kiro CLIを起動して動作確認してください:" -echo " kiro-cli chat --agent $AGENT_NAME" -if [ "$SET_DEFAULT" = false ]; then - echo "" - echo "既定エージェントとして起動したい場合は --set-default を付けて再実行してください。" -fi diff --git a/plugins/ndf/dev.kiro/prompts/codex.md b/plugins/ndf/dev.kiro/prompts/codex.md deleted file mode 100644 index ddb3b52d0..000000000 --- a/plugins/ndf/dev.kiro/prompts/codex.md +++ /dev/null @@ -1,4 +0,0 @@ -Codex CLIにコード生成・レビュー・調査を委譲してください。 - -external-aiスキルの手順に従って実行してください。Codex CLI 固有の呼び出し方法は -`external-ai` スキルの `references/cli-codex.md`(Kiro では `.kiro/skills/external-ai/references/cli-codex.md`)を参照します。引数があればそのまま使用します。 diff --git a/plugins/ndf/dev.kiro/prompts/fix.md b/plugins/ndf/dev.kiro/prompts/fix.md deleted file mode 100644 index 468dacabd..000000000 --- a/plugins/ndf/dev.kiro/prompts/fix.md +++ /dev/null @@ -1,3 +0,0 @@ -PRのレビューコメントを確認し、修正対応を実行してください。 - -fixスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/plugins/ndf/dev.kiro/prompts/merged.md b/plugins/ndf/dev.kiro/prompts/merged.md deleted file mode 100644 index f0a158330..000000000 --- a/plugins/ndf/dev.kiro/prompts/merged.md +++ /dev/null @@ -1,3 +0,0 @@ -PRマージ後のクリーンアップ(main更新、worktree/ブランチ削除)、または作業中ブランチへの main 取り込みを実行してください。 - -mergedスキルの「用途の切り分け」で依頼の意図を判定し、該当する節の手順に従って実行してください。引数があればそのまま使用します。 diff --git a/plugins/ndf/dev.kiro/prompts/pr-review.md b/plugins/ndf/dev.kiro/prompts/pr-review.md deleted file mode 100644 index 1b4479df1..000000000 --- a/plugins/ndf/dev.kiro/prompts/pr-review.md +++ /dev/null @@ -1,3 +0,0 @@ -PRを専門家としてレビューし、Approve/Request Changesを判定してください。 - -pr-reviewスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/plugins/ndf/dev.kiro/prompts/pr-tests.md b/plugins/ndf/dev.kiro/prompts/pr-tests.md deleted file mode 100644 index 85fb20dab..000000000 --- a/plugins/ndf/dev.kiro/prompts/pr-tests.md +++ /dev/null @@ -1,3 +0,0 @@ -PRのTest Planを自動実行し、結果をPRコメントに反映してください。 - -pr-testsスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/plugins/ndf/dev.kiro/prompts/pr.md b/plugins/ndf/dev.kiro/prompts/pr.md deleted file mode 100644 index 43ca66bab..000000000 --- a/plugins/ndf/dev.kiro/prompts/pr.md +++ /dev/null @@ -1,3 +0,0 @@ -commit, push, PR作成を一括実行してください。 - -prスキルの手順に従って実行してください。引数があればそのまま使用します。 diff --git a/plugins/ndf/docs/kiro-cli.md b/plugins/ndf/docs/kiro-cli.md deleted file mode 100644 index 2981b9557..000000000 --- a/plugins/ndf/docs/kiro-cli.md +++ /dev/null @@ -1,104 +0,0 @@ -# Kiro CLI の運用と制限 - -Kiro CLI 固有の詳細です。導入の手順そのものは -[README の「Kiro CLI」](../README.md#kiro-cli)にあります。 - -## Kiro CLI の運用 - -### 主なオプション - -| オプション | 内容 | -| --- | --- | -| `--project PATH` | 現在のディレクトリではなく PATH へ導入する(`--scope workspace` のみ有効) | -| `--scope workspace\|global` | `workspace`(既定)はプロジェクトの `.kiro/`、`global` は `~/.kiro/` へ導入する | -| `--set-default` | `kiro-cli` の既定エージェントを `ndf` に切り替える(オプトイン) | -| `-y`, `--yes` | `--set-default` の確認プロンプトを省略する | -| `--with-slack` | stop フックに回答・承認待ちの Slack 通知(`scripts/wait-notify.py --runtime kiro`)を追加する | -| `--with-codex` | Codex MCP サーバ設定(`ndf.json` の `mcpServers.codex`)と、Codex CLI 直接実行用プロンプトを追加する | -| `--dry-run` | 書き込みを行わず実行内容を表示する | - -### 既定エージェントの切り替え - -Kiro の既定エージェントは組み込みの `kiro_default` です。`kiro-cli chat` を素で起動する限り、NDF のフック・外部 AI 連携・`resources` は読み込まれません。既定として使いたい場合は `--set-default` を付けます。 - -```bash -bash plugins/ndf/dev.kiro/install.sh --set-default -``` - -利用者の既存設定を無断で奪わないよう、`--set-default` は明示指定したときだけ動作します。実行前に現在の既定エージェントを表示し、対話端末では確認を取ります。元に戻す場合は次のとおりです。 - -```bash -kiro-cli agent set-default kiro_default -``` - -`--scope workspace`(既定)で導入したエージェントはそのプロジェクトでしか見つかりません。既定を `ndf` にしたまま別のディレクトリで `kiro-cli chat` を起動すると `user defined default ndf not found. Falling back to in-memory default` になります。どこでも既定として使いたい場合は `--scope global` と併用してください。 - -### 導入スコープ - -| スコープ | Skills | 常時指示 | エージェント定義 | -| --- | --- | --- | --- | -| `workspace`(既定) | `.kiro/skills/` | `.kiro/steering/ndf-policies.md` | `.kiro/agents/ndf.json` | -| `global` | `~/.kiro/skills/` | `~/.kiro/steering/ndf-policies.md` | `~/.kiro/agents/ndf.json` | - -常時適用したい指示は steering へ置きます。steering はエージェント選択に依存せず読み込まれるため、既定エージェントを書き換えない運用でも効きます。`.kiro/steering/ndf-policies.md` は `plugins/ndf/skills/ndf-policies/SKILL.md` から生成されるため、直接編集しないでください。 - -`ndf-policies` は steering の生成元としてのみ使うため、`.kiro/skills/` へは symlink しません。Kiro は `.kiro/skills/*/SKILL.md` と `.kiro/steering/**/*.md` の両方を文脈へ読み込むため、両方に置くと同じ内容が 2 回注入されます。`plugins/ndf/manifests/kiro-skills.txt` には引き続き載せます(`plugins/ndf/skills/ndf-policies/SKILL.md` が steering の生成元だからです)。 - -`--project` で別ディレクトリへ導入する場合も `--set-default` は正しく動きます。`kiro-cli` は workspace エージェントを cwd 配下の `.kiro/agents/` からのみ検出するため、installer は `kiro-cli` を導入先(`--scope workspace` なら `--project` のパス、`--scope global` なら `$HOME`)で実行します。`kiro-cli agent set-default` はエージェントが見つからなくても終了コード 0 を返すので、installer は実行後に `agent list` で反映を検証し、切り替わっていなければ失敗させます。 - -### 旧バージョンからの移行 - -v4 系の installer は `.kiro/agents/default.json` を生成していました。この設定は Kiro の既定エージェントにならず、フックも `resources` も無効のままでした。エージェント名を `ndf` に変えたため、再インストールが必要です。 - -旧 installer が張った `.kiro/skills/ndf-policies` の symlink は、リンク先が現在のプラグイン配下でなくても再インストール時に削除します(削除するのはリンク自体だけで、リンク先の実体には触れません)。`.kiro/skills/ndf-policies` が symlink ではなく実体のディレクトリやファイルだった場合は、利用者が置いたものの可能性があるため installer は削除せず警告を出します。二重注入を避けるため、内容を確認のうえ手動で退避または削除してください。別の checkout パスから導入した環境でも、steering との二重注入が再インストール 1 回で解消されます。 - -```bash -# 1. 再インストール(旧 default.json は自動でバックアップ・移行されます) -bash plugins/ndf/dev.kiro/install.sh --with-slack - -# 2. 必要なら既定エージェントを切り替える -bash plugins/ndf/dev.kiro/install.sh --set-default - -# 3. 移行を確認したらバックアップを削除する -rm .kiro/agents/default.json.bak -``` - -`.kiro/agents/default.json` は再インストール時に必ず `.kiro/agents/default.json.bak` へバックアップされます。そのうえで installer は次のように振る舞います。 - -| 旧 `default.json` | `ndf.json` | 振る舞い | -| --- | --- | --- | -| 旧版 NDF installer の生成物(`name` が `default`、`description` が旧テンプレートと完全一致、かつ旧 `resources` の `skill://.kiro/skills/**/SKILL.md` または `agentSpawn` フックの `CLAUDE.ndf.md` チェックを持つ) | なし | `ndf.json` へ自動移行する(`default.json` は残らない)。独自に追記した `mcpServers` / フック / 独自キーは下表のマージで保持される | -| 同上 | あり | 自動移行しない(`ndf.json` の設定を失わないため)。`default.json` は残るので、必要な設定を写したうえで削除する | -| NDF 以外が管理している(利用者が作成したものなど) | 問わない | 自動移行しない。勝手に移行すると利用者の設定を壊すため、バックアップと移行手順の案内のみを行う | - -`--dry-run` では上記の移行を含め一切の書き込みを行いません(旧設定を検出したことだけ表示します)。 - -Kiro 用 MCP プラグインの installer(`plugins/mcp/<プラグイン名>/dev.kiro/install.sh`)は `.kiro/agents/default.json` を更新します。自動移行後に MCP installer を実行すると `default.json` が再び作られるため、`mcpServers` を `.kiro/agents/ndf.json` へ写してください。写し替えは一度だけで済みます。`install.sh` を再実行しても、写した `mcpServers` は保持されます。 - -### 再インストール時に保持される設定 - -`install.sh` は `.kiro/agents/ndf.json` を毎回テンプレートから再生成しますが、上書きするのは installer が管理するキーだけです。既存ファイルにある利用者管理の設定は読み取ってマージし直します。 - -| 区分 | キー | 再実行時の扱い | -| --- | --- | --- | -| installer 管理 | `name` / `description` / `tools` / `resources` / `hooks.agentSpawn` | テンプレートから再生成する(上書き) | -| installer 管理 | `hooks.stop` | `--with-slack` の有無で生成・削除する | -| installer 管理 | `mcpServers.codex` | `--with-codex` の有無で生成・削除する | -| 利用者管理 | 上記以外の `mcpServers` エントリ、`hooks` の項目、トップレベルキー | そのまま引き継ぐ | - -引き継いだ項目は実行ログに `利用者管理の設定を引き継ぎました: mcpServers.bigquery` のように表示します。再生成の前に `.kiro/agents/ndf.json.bak` へバックアップも取るため、意図しない結果になった場合は差し戻せます。 - -`mcpServers.codex` だけは installer 管理です。`--with-codex` を付けずに再実行すると削除されるため、Codex MCP を使う場合は `--with-codex` を付けたまま運用してください。 - -## Kiro CLI の制限 - -### `allowed-tools` は事前承認にならない - -Skill frontmatter の `allowed-tools` は、プロジェクト配置(`.kiro/skills/`)では事前承認として機能しません([kirodotdev/Kiro#6055](https://github.com/kirodotdev/Kiro/issues/6055))。`allowed-tools: execute_bash` を持つ Skill でも `Command execute_bash is rejected because it matches one or more rules on the denied list` になります。 - -Kiro では、Skill の実行時にツール利用の確認が入る前提で操作してください。NDF の Skill 本文は「無確認で実行される」前提を持ちません。 - -### プラグイン機構がない - -Kiro CLI には Skill・フック・外部連携・常時指示をまとめて配布する仕組みがありません([kirodotdev/Kiro#8578](https://github.com/kirodotdev/Kiro/issues/8578))。Kiro IDE の Powers は CLI では使えないため、`install.sh` による導入を継続します。 - diff --git a/plugins/ndf/manifests/agy-skills.txt b/plugins/ndf/manifests/agy-skills.txt deleted file mode 100644 index 4b6339464..000000000 --- a/plugins/ndf/manifests/agy-skills.txt +++ /dev/null @@ -1,45 +0,0 @@ -cherry-pick-pr -cross-refactoring -cross-review -deploy -development-workflow -docker-container-access -chrome-cdp -external-ai -fix -implementation-plan -layout-review -investigation-rules -decision-request -issue-plan-strategy -logging-guidelines -document-style -notion-writing -merged -ndf-policies -out-of-scope -backlog-refinement -estimate -progress-tracking -plan-to-spec -pr -pr-review -pr-tests -problem-solving -qa-security-scan -quality-gates -refactoring -release-verification -requirements-design -design -document-sources -document-drafting -document-rebuild -document-systems -retrospective -tdd-cycle -worktree -release -google-workspace -ml-model-structure -skill-stats diff --git a/plugins/ndf/manifests/kiro-skills.txt b/plugins/ndf/manifests/kiro-skills.txt deleted file mode 100644 index 483b9a15f..000000000 --- a/plugins/ndf/manifests/kiro-skills.txt +++ /dev/null @@ -1,46 +0,0 @@ -pr -pr-tests -fix -pr-review -cross-review -cross-refactoring -merged -ndf-policies -document-style -notion-writing -docker-container-access -chrome-cdp -implementation-plan -layout-review -investigation-rules -decision-request -problem-solving -logging-guidelines -cherry-pick-pr -deploy -external-ai -statusline -issue-plan-strategy -plan-to-spec -qa-security-scan -development-workflow -requirements-design -design -document-sources -document-drafting -document-rebuild -document-systems -tdd-cycle -refactoring -quality-gates -worktree -out-of-scope -backlog-refinement -estimate -progress-tracking -release-verification -retrospective -release -google-workspace -ml-model-structure -skill-stats diff --git a/plugins/ndf/scripts/acceptance-check.py b/plugins/ndf/scripts/acceptance-check.py new file mode 100755 index 000000000..ba2802796 --- /dev/null +++ b/plugins/ndf/scripts/acceptance-check.py @@ -0,0 +1,150 @@ +#!/usr/bin/env python3 +"""受け入れ条件の確認の区分を課題の本文へ記録する・読む(#2144。完了判定 `quality-gates` が呼ぶ)。 + + python3 acceptance-check.py record --issue <番号> [--repo <所有者>/<リポジトリ>] + [--pre-merge '<条件の番号>=<手段>']... [--post-release <条件の番号>]... + python3 acceptance-check.py show --issue <番号> [--repo <所有者>/<リポジトリ>] + +記録の置き場は課題の本文の `## 受け入れ条件の確認` の節の表(`| 条件 | 区分 | 手段 |`)である。区分は +`マージ前`(マージ前に確かめた。手段にテスト名か実行したコマンドを書く)か `リリース後`(リリースの後でしか +確かめられない)のどちらか。record は今の表へ渡した番号の行を足すか置き換え、課題ごとの錠の中で本文を読んでから +書く。番号は本文の `## 受け入れ条件` にある番号だけを受ける(規則は lib/acceptance.py)。 + +この記録を読むのは、承認資料の「未実施のリリース後テスト」(release_lib/materials.read_unverified。マージ前の +条件を載せない)と、sprint-close.py --with-verification の閉じる条件 2(マージ前の条件に合格の行を求めない)である。 + +結果は lib/step_result.py の形の 1 行の JSON(`tool` は `acceptance-check`)。items は条件ごとに +{kind:"criterion", name:<番号>, result:"マージ前"|"リリース後", means?}。終了コード: 0 = 済んだ(変更なしを含む)/ +1 = 本文を書けない / 2 = 本文を読めない・引数の誤り(受け入れ条件に無い番号・手段の無いマージ前・同じ番号を 2 度)/ +3 = リポジトリを決められない・本文に受け入れ条件が無い。 +""" + +from __future__ import annotations + +import argparse +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) +import deps # noqa: E402 + +deps.require("md", "mdtable", "locks", "durable", "waits") +import acceptance # noqa: E402 +import gh_call # noqa: E402 +import gh_parts # noqa: E402 +import gh_rest # noqa: E402 +import gh_sections # noqa: E402 +import mdtable # noqa: E402 +from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, EXIT_VIOLATION, StepError, emit, main_with, result # noqa: E402 + +TOOL = "acceptance-check" + + +def parse_pre_merge(values) -> dict[int, str]: + """`<番号>=<手段>` の並びを {番号: 手段} にする。形が違えば StepError(2)。""" + out: dict[int, str] = {} + for v in values or (): + num, sep, means = v.partition("=") + if not sep or not num.strip().isdigit() or not means.strip(): + raise StepError(f"--pre-merge は '<条件の番号>=<手段>' で渡す: {v!r}", EXIT_UNREADABLE) + out[int(num)] = means.strip() + return out + + +def merged_checks(body: str, pre_merge: dict[int, str], post_release) -> dict[int, acceptance.Check]: + """本文の今の記録へ渡した区分を重ねた記録。受け入れ条件に無い番号・両方に出る番号は StepError。""" + numbers = {c.number for c in acceptance.criteria(body)} + if not numbers: + raise StepError("本文に受け入れ条件(`## 受け入れ条件` のチェックボックス)が無い", EXIT_PRECONDITION) + post = set(post_release or ()) + both = sorted(post & set(pre_merge)) + if both: + raise StepError(f"マージ前とリリース後の両方に渡した番号: {both}", EXIT_UNREADABLE) + unknown = sorted((post | set(pre_merge)) - numbers) + if unknown: + raise StepError(f"受け入れ条件に無い番号: {unknown}(本文の条件は 1〜{max(numbers)})", EXIT_UNREADABLE) + checks = dict(acceptance.read_check_kinds(body)) + checks.update({n: acceptance.Check(acceptance.PRE_MERGE, m) for n, m in pre_merge.items()}) + checks.update({n: acceptance.Check(acceptance.POST_RELEASE, "") for n in post}) + return checks + + +def checks_table(checks: dict[int, acceptance.Check]) -> str: + """`## 受け入れ条件の確認` の節の中身(番号の順の表)。""" + rows = [(str(n), c.kind, c.means if c.kind == acceptance.PRE_MERGE else acceptance.NO_MEANS) for n, c in sorted(checks.items())] + return mdtable.table_markdown(acceptance.CHECKS_HEAD, rows, align=("left", "left", "left")) + + +def recorded_body(body: str, pre_merge: dict[int, str], post_release) -> str: + """記録を書いた本文。節の外は変えない。""" + table = checks_table(merged_checks(body, pre_merge, post_release)) + return gh_sections.replace_section(body, acceptance.CHECKS_HEADING, table) + + +def _criterion_items(checks: dict[int, acceptance.Check]) -> list[dict]: + return [ + {"kind": "criterion", "name": str(n), "result": c.kind, **({"means": c.means} if c.means else {})} + for n, c in sorted(checks.items()) + ] + + +def _repo_slug(a) -> str: + slug = gh_call.resolve_repo(a.repo) + if not slug: + raise StepError("リポジトリを決められない(--repo を渡す)", EXIT_PRECONDITION) + return slug + + +def _fetch_issue_body(slug: str, n: int) -> str: + body = gh_rest.fetch_body(slug, n) + if body is None: + raise StepError(f"{slug}#{n} の本文を読めない", EXIT_UNREADABLE) + return body + + +def cmd_record(a): + pre = parse_pre_merge(a.pre_merge) + if not pre and not a.post_release: + raise StepError("--pre-merge か --post-release を 1 つ以上渡す", EXIT_UNREADABLE) + slug = _repo_slug(a) + with gh_parts.issue_lock(slug, a.issue): # 読みから書きまでを課題ごとの錠の中で行う + body = _fetch_issue_body(slug, a.issue) + new = recorded_body(body, pre, a.post_release) + if new != body and not gh_rest.update_body(slug, a.issue, new): + emit(result(TOOL, "stopped", f"{slug}#{a.issue} の本文を書けない"), EXIT_VIOLATION) + checks = acceptance.read_check_kinds(new) + pre_n = sum(c.kind == acceptance.PRE_MERGE for c in checks.values()) + state = "変更なし" if new == body else "記録した" + emit( + result(TOOL, "ok", f"#{a.issue}: {state}(マージ前 {pre_n} 件・リリース後 {len(checks) - pre_n} 件)", _criterion_items(checks)), 0 + ) + + +def cmd_show(a): + slug = _repo_slug(a) + checks = acceptance.read_check_kinds(_fetch_issue_body(slug, a.issue)) + emit(result(TOOL, "ok", f"#{a.issue}: 記録 {len(checks)} 件", _criterion_items(checks)), 0) + + +def build_parser(): + ap = argparse.ArgumentParser(prog="acceptance-check.py", description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + p = sub.add_parser("record", help="条件ごとの区分(マージ前 / リリース後)と手段を本文へ書く") + p.add_argument("--issue", type=int, required=True) + p.add_argument("--repo") + p.add_argument("--pre-merge", action="append", default=[], help="'<条件の番号>=<手段(テスト名・実行したコマンド)>'") + p.add_argument("--post-release", action="append", type=int, default=[], help="リリースの後でしか確かめられない条件の番号") + p.set_defaults(func=cmd_record) + p = sub.add_parser("show", help="本文の記録を読む") + p.add_argument("--issue", type=int, required=True) + p.add_argument("--repo") + p.set_defaults(func=cmd_show) + return ap + + +def main(argv=None): + return main_with(build_parser(), lambda a: TOOL, argv) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/check-trigger.py b/plugins/ndf/scripts/check-trigger.py index 5b77d7de7..b1de1410f 100755 --- a/plugins/ndf/scripts/check-trigger.py +++ b/plugins/ndf/scripts/check-trigger.py @@ -495,9 +495,16 @@ def findings_of(state: Path) -> tuple[dict, str]: **({"unconfirmed": ref["unconfirmed"]} if "unconfirmed" in ref else {}), # 最終ゲートを経ていない実行だけ(#1652) # drive が返したキーだけを写す(古い drive の `findings` はコメントの数で、stats が読むときに読み替える) **{k: rev[k] for k in REVIEW_COUNTS if k in rev}, + # 時間の枠に収まらずリファクタリングを飛ばした(#2047 の I8)。種類と理由だけを写す + **( + {"refactor_skipped": {"kind": sk.get("kind"), "reason": sk.get("reason")}} if isinstance(sk := ref.get("skipped"), dict) else {} + ), } + # 欠けを残して後続へ進んだステップ(終了コード 6 と gaps)は落ちたステップに数えない failed = [ - e.get("id") for e in log if e.get("exit") not in (0, None) and not e.get("gate") and not str(e.get("id", "")).startswith("abort") + e.get("id") + for e in log + if e.get("exit") not in (0, None) and not e.get("gate") and not e.get("gaps") and not str(e.get("id", "")).startswith("abort") ] return findings, (failed[-1] if failed else "") diff --git a/plugins/ndf/scripts/experimental/whole-test-timing.py b/plugins/ndf/scripts/experimental/whole-test-timing.py new file mode 100644 index 000000000..75ee0f1cf --- /dev/null +++ b/plugins/ndf/scripts/experimental/whole-test-timing.py @@ -0,0 +1,208 @@ +#!/usr/bin/env python3 +"""whole-test-timing.py(試行): 全体テストを同じ条件で測り、着手前後のテストの集合を照らす(#2291)。 + + python3 whole-test-timing.py measure --root --out + python3 whole-test-timing.py diff --before --after --ledger + +measure は `.ndf/project.json` の `test.suites` の `command` を宣言の順に 1 本ずつ流し、最初の起動から最後の +終了までを壁時計とする。開始時の 1 分の load average が CPU の数を超えたら `reference`(判定に使わない参考の値)を +立てる。junit を宣言した suite は、junit の `time`(準備・本体・後始末の和)から件数・合計・遅いテストの上位 30 件・ +ファイルごとの合計の上位 10 本を出し、`--out` へ nodeid ごとの結果と秒を書く。 + +diff は着手前にあって着手後に無い nodeid のうち台帳に書かれていないものと、着手前に走っていて着手後に skip に +なった nodeid を出す。nodeid はパラメータの部分まで含めて文字どおりに照らす。 + +結果は lib/step_result.py の形の 1 行の JSON(`tool: "whole-test-timing"`)。 +""" + +from __future__ import annotations + +import argparse +import json +import os +import re +import subprocess +import sys +import time +import xml.etree.ElementTree as ET +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "lib")) +from step_result import EXIT_UNREADABLE, StepError, emit, main_with, result # noqa: E402 + +TOOL = "whole-test-timing" +TOP_TESTS = 30 +TOP_FILES = 10 + + +def load_suites(root: Path) -> list[dict]: + try: + data = json.loads((root / ".ndf" / "project.json").read_text(encoding="utf-8")) + suites = data["test"]["suites"] + except (OSError, ValueError, KeyError, TypeError) as e: + raise StepError(f".ndf/project.json の test.suites を読めない: {e}", EXIT_UNREADABLE) + if not isinstance(suites, list) or not all(isinstance(s, dict) and s.get("command") for s in suites): + raise StepError("test.suites の各要素に command が要る", EXIT_UNREADABLE) + return suites + + +def _nodeid(case: ET.Element) -> str: + """xunit1 の testcase から pytest の nodeid を組む(file と classname の差がクラスの部分)。""" + file = case.get("file") or "" + name = case.get("name") or "" + classname = case.get("classname") or "" + module = file[:-3].replace("/", ".") if file.endswith(".py") else "" + rest = classname[len(module) :].lstrip(".") if module and classname.startswith(module) else "" + parts = [file or classname, *(p for p in rest.split(".") if p), name] + return "::".join(parts) + + +def _outcome(case: ET.Element) -> str: + for tag in ("failure", "error", "skipped"): + if case.find(tag) is not None: + return {"failure": "failed", "error": "error", "skipped": "skipped"}[tag] + return "passed" + + +def read_junit(path: Path) -> dict[str, dict]: + """nodeid → {"outcome", "seconds"}。同じ nodeid が 2 度出たら(準備と後始末の誤り)秒を足し、悪い方の結果を残す。""" + try: + tree = ET.parse(path) + except (OSError, ET.ParseError) as e: + raise StepError(f"junit を読めない: {path}: {e}", EXIT_UNREADABLE) + tests: dict[str, dict] = {} + for case in tree.iter("testcase"): + nid = _nodeid(case) + sec = float(case.get("time") or 0.0) + out = _outcome(case) + if nid in tests: + prev = tests[nid] + prev["seconds"] = round(prev["seconds"] + sec, 3) + if out in ("failed", "error"): + prev["outcome"] = out + continue + tests[nid] = {"outcome": out, "seconds": round(sec, 3)} + return tests + + +def summarize_tests(tests: dict[str, dict]) -> tuple[dict, list[dict]]: + ran = {k: v for k, v in tests.items() if v["outcome"] != "skipped"} + metrics = { + "tests": len(ran), + "skipped": len(tests) - len(ran), + "sum_seconds": round(sum(v["seconds"] for v in tests.values()), 1), + } + slow = sorted(tests.items(), key=lambda kv: (-kv[1]["seconds"], kv[0]))[:TOP_TESTS] + files: dict[str, list] = {} + for nid, v in tests.items(): + f = files.setdefault(nid.split("::", 1)[0], [0.0, 0]) + f[0] += v["seconds"] + f[1] += 1 + top_files = sorted(files.items(), key=lambda kv: (-kv[1][0], kv[0]))[:TOP_FILES] + items = [{"kind": "test", "name": nid, "seconds": v["seconds"], "result": v["outcome"]} for nid, v in slow] + items += [{"kind": "file", "name": f, "seconds": round(s, 1), "count": n, "result": "sum"} for f, (s, n) in top_files] + return metrics, items + + +def run_suites(root: Path, suites: list[dict], log_dir: Path) -> tuple[list[dict], float]: + log_dir.mkdir(parents=True, exist_ok=True) + runs = [] + first = time.monotonic() + for s in suites: + name = str(s.get("name") or len(runs)) + start = time.monotonic() + with (log_dir / f"{name}.log").open("w", encoding="utf-8") as log: + p = subprocess.run(["bash", "-c", s["command"]], cwd=root, stdout=log, stderr=subprocess.STDOUT) + runs.append({"name": name, "seconds": round(time.monotonic() - start, 1), "exit": p.returncode}) + return runs, round(time.monotonic() - first, 1) + + +def cmd_measure(a) -> None: + root = Path(a.root or ".").resolve() + suites = load_suites(root) + load1 = os.getloadavg()[0] + cpus = os.cpu_count() or 1 + out = Path(a.out).resolve() + runs, wall = run_suites(root, suites, out.with_suffix(".logs")) + tests: dict[str, dict] = {} + for s in suites: + if s.get("junit"): + tests.update(read_junit(root / s["junit"])) + counted, items = summarize_tests(tests) + metrics = { + "load_average_1m": round(load1, 2), + "cpus": cpus, + "reference": load1 > cpus, + "wall_seconds": wall, + "suites": runs, + **counted, + } + out.write_text(json.dumps({"metrics": metrics, "tests": tests}, ensure_ascii=False, indent=1), encoding="utf-8") + failed = [r["name"] for r in runs if r["exit"] != 0] + status = "stopped" if failed else "ok" + summary = f"壁時計 {wall} 秒・{counted['tests']} 件・合計 {counted['sum_seconds']} 秒・load average {load1:.2f}" + if failed: + summary += f"。落ちた suite: {', '.join(failed)}" + emit(result(TOOL, status, summary, items, metrics)) + + +def ledger_nodeids(text: str) -> set[str]: + """台帳の表の行にバッククォートで書かれた nodeid(`::` を含むもの)。""" + ids = set() + for line in text.splitlines(): + if line.lstrip().startswith("|"): + ids.update(m for m in re.findall(r"`([^`]+)`", line) if "::" in m) + return ids + + +def _read_out(path: str) -> dict: + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + return data["tests"] + except (OSError, ValueError, KeyError, TypeError) as e: + raise StepError(f"計測の記録を読めない: {path}: {e}", EXIT_UNREADABLE) + + +def compare_sets(before: dict, after: dict, ledger: set[str]) -> dict: + ran_before = {k for k, v in before.items() if v["outcome"] != "skipped"} + missing = sorted(k for k in ran_before if k not in after and k not in ledger) + skipped = sorted(k for k in ran_before if k in after and after[k]["outcome"] == "skipped") + return {"missing": missing, "now_skipped": skipped} + + +def cmd_diff(a) -> None: + before, after = _read_out(a.before), _read_out(a.after) + try: + ledger = ledger_nodeids(Path(a.ledger).read_text(encoding="utf-8")) + except OSError as e: + raise StepError(f"台帳を読めない: {a.ledger}: {e}", EXIT_UNREADABLE) + found = compare_sets(before, after, ledger) + b, f = summarize_tests(before)[0], summarize_tests(after)[0] + metrics = {"before": b, "after": f, "missing": len(found["missing"]), "now_skipped": len(found["now_skipped"])} + items = [{"kind": "missing", "name": n, "result": "not-in-ledger"} for n in found["missing"]] + items += [{"kind": "now_skipped", "name": n, "result": "skipped"} for n in found["now_skipped"]] + status = "stopped" if items else "ok" + summary = ( + f"着手前 {b['tests']} 件・{b['sum_seconds']} 秒 → 着手後 {f['tests']} 件・{f['sum_seconds']} 秒。" + f"台帳に無い消えたテスト {len(found['missing'])} 件・skip に変わったテスト {len(found['now_skipped'])} 件" + ) + emit(result(TOOL, status, summary, items, metrics)) + + +def main(argv=None): + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + sub = ap.add_subparsers(dest="cmd", required=True) + m = sub.add_parser("measure") + m.add_argument("--root", default=".") + m.add_argument("--out", required=True) + m.set_defaults(func=cmd_measure) + d = sub.add_parser("diff") + d.add_argument("--before", required=True) + d.add_argument("--after", required=True) + d.add_argument("--ledger", required=True) + d.set_defaults(func=cmd_diff) + return main_with(ap, lambda a: TOOL, argv) + + +if __name__ == "__main__": + main() diff --git a/plugins/ndf/scripts/hook_lib/worktree.py b/plugins/ndf/scripts/hook_lib/worktree.py index ec4a2ad9a..76ee5279e 100644 --- a/plugins/ndf/scripts/hook_lib/worktree.py +++ b/plugins/ndf/scripts/hook_lib/worktree.py @@ -1,6 +1,7 @@ """メインディレクトリを編集しようとしたときに、worktree で作業する旨を伝える guard(#1142 の決定 20)。 -結線先は 3 つある(`hooks/*.json`・`dev.kiro/install.sh`・`dev.agy/hooks.json`)。 +結線先は `hooks/*.json`(Claude Code・Codex)。Kiro CLI と agy の分岐は、配布を終える前(#2231)に導入した手元の hook が +この入口を呼び続けるために残す。 - PreToolUse(Claude Code / Codex CLI): 編集先のパスを見た案内を `additionalContext` でモデルへ渡す - プロンプト送信時(Kiro CLI): パスを見ない案内を標準出力へ書く - PreToolUse(agy): 案内をセッションの状態ファイルの `pending` へ積み、次のモデル呼び出しの前に diff --git a/plugins/ndf/scripts/lib/acceptance.py b/plugins/ndf/scripts/lib/acceptance.py index a262a8d6d..7ce52bf54 100644 --- a/plugins/ndf/scripts/lib/acceptance.py +++ b/plugins/ndf/scripts/lib/acceptance.py @@ -80,6 +80,56 @@ def criteria_lines(body: str) -> list[tuple[Criterion, str]]: return out +# --- 受け入れ条件の確認の区分(#2144。完了判定が書き、承認資料と閉じる判定が読む) + + +CHECKS_HEADING = "## 受け入れ条件の確認" +PRE_MERGE = "マージ前" # マージ前に確かめた。手段(テスト名・実行したコマンド)を必ず持つ +POST_RELEASE = "リリース後" # リリースの後でしか確かめられない +KINDS = (PRE_MERGE, POST_RELEASE) +CHECKS_HEAD = ("条件", "区分", "手段") +NO_MEANS = "—" + + +class Check(NamedTuple): + """1 つの受け入れ条件の確認の区分。means はマージ前に確かめた手段(リリース後なら空)。""" + + kind: str + means: str + + +def read_check_kinds(body: str) -> dict[int, Check]: + """本文の `## 受け入れ条件の確認` の表を {条件の番号: Check} で返す。節・表が無ければ空(今の課題)。 + + `条件` の列の頭の数字を番号として読む。区分が `マージ前` / `リリース後` のどちらでもない行と、手段の無い + `マージ前` の行は読まない(確かめた手段を書けない条件は確認済みと言えない)。使う側は `deps.require("md")` を先に呼ぶ。""" + import md + + section = gh_sections.get_section(body or "", CHECKS_HEADING) + found = md.tables(section) if section else [] + if not found: + return {} + head = [c.strip() for c in found[0].header] + if any(h not in head for h in CHECKS_HEAD): + return {} + ni, ki, mi = (head.index(h) for h in CHECKS_HEAD) + out: dict[int, Check] = {} + for row in found[0].rows: + cs = [c.strip() for c in row] + num, kind, means = re.match(r"^(\d+)", cs[ni]), cs[ki], cs[mi].strip("`").strip() + if not num or kind not in KINDS: + continue + if kind == PRE_MERGE and (not means or means == NO_MEANS): + continue + out[int(num.group(1))] = Check(kind, "" if kind == POST_RELEASE else cs[mi]) + return out + + +def pre_merge_numbers(body: str) -> frozenset: + """マージ前に確かめたと記録された受け入れ条件の番号。""" + return frozenset(n for n, c in read_check_kinds(body).items() if c.kind == PRE_MERGE) + + # --- リリース後テストの表(sprint-close.py の閉じる判定と release-steps.py の承認の材料が同じ規則で読む。#1885 の決定 8) @@ -105,44 +155,58 @@ def verification_verdicts(block, record_repo): found = md.tables(block) if not found: return None - head = [c.strip() for c in found[0].header] - if "課題" not in head or "結果" not in head: + cols = _column_indices(found[0].header) + if cols is None: return None - ci, ri = head.index("課題"), head.index("結果") - ai = head.index("受け入れ条件") if "受け入れ条件" in head else None acc = {} for row in found[0].rows: - cs = [c.strip() for c in row] # md は行のセルを見出しの数へ揃える - m = _ISSUE_CELL.match(cs[ci]) - if not m: + parsed = _parse_verdict_row(row, cols, record_repo) + if parsed is None: continue - key = (m.group(1) or record_repo, int(m.group(2))) - ok = cs[ri].startswith("合格") + key, ok, num = parsed all_pass, passed, failed = acc.get(key, (True, set(), set())) - num = _CRITERION_CELL.match(cs[ai]) if ai is not None else None - if num: - (passed if ok else failed).add(int(num.group(1))) + if num is not None: + (passed if ok else failed).add(num) acc[key] = (all_pass and ok, passed, failed) if not acc: return None return {k: IssueRows(a, frozenset(p - f)) for k, (a, p, f) in acc.items()} -_VERSION = re.compile(r"v?(\d+\.\d+\.\d+)") +def verdict_table_readable(block): + """表があり、見出しに課題と結果の列があるか。verification_verdicts の None が「課題を指す行が無い」だけかを分ける。""" + import md + + found = md.tables(block) + return bool(found) and _column_indices(found[0].header) is not None + + +def _column_indices(header): + """見出しから (課題, 結果, 受け入れ条件) の列の位置を返す。受け入れ条件の列が無ければ 3 つ目は None。 + 課題か結果の列が無ければ None。""" + head = [c.strip() for c in header] + if "課題" not in head or "結果" not in head: + return None + ai = head.index("受け入れ条件") if "受け入れ条件" in head else None + return head.index("課題"), head.index("結果"), ai + + +def _parse_verdict_row(row, cols, record_repo): + """1 行を (key, 合格か, 受け入れ条件の番号または None) に解く。課題を指さない行は None。""" + ci, ri, ai = cols + cs = [c.strip() for c in row] # md は行のセルを見出しの数へ揃える + m = _ISSUE_CELL.match(cs[ci]) + if not m: + return None + key = (m.group(1) or record_repo, int(m.group(2))) + num = _CRITERION_CELL.match(cs[ai]) if ai is not None else None + return key, cs[ri].startswith("合格"), int(num.group(1)) if num else None def version_verdicts(text, version, record_repo): """PR の本文とコメント(text)の `## リリース後テスト` のブロックのうち、`対象の版:` の基底が version の基底と 一致する最後のブロックの表を読む({(repo, number): IssueRows})。無い・読めなければ空。""" import dist_record - import md - want = _VERSION.match(str(version)) - lines = text.splitlines() - sections = [s for s in md.md_sections(text) if lines[s.heading.line].startswith("#")] - blocks = [ - b - for b in dist_record._verify_blocks(lines, sections) - if want and (m := _VERSION.match(b["ver"] or "")) and m.group(1) == want.group(1) - ] - return (verification_verdicts("\n".join(blocks[-1]["lines"]), record_repo) or {}) if blocks else {} + block = dist_record.verify_block_for_base(text, version) + return (verification_verdicts(block, record_repo) or {}) if block is not None else {} diff --git a/plugins/ndf/scripts/lib/closing-issues.sh b/plugins/ndf/scripts/lib/closing-issues.sh index a675da40f..d00974c79 100755 --- a/plugins/ndf/scripts/lib/closing-issues.sh +++ b/plugins/ndf/scripts/lib/closing-issues.sh @@ -1,5 +1,5 @@ #!/usr/bin/env bash -# NDF plugin: Pull Request の本文から、閉じる語が指す issue を取り出す。 +# NDF plugin: Pull Request の本文から、閉じる語と閉じる予定の参照が指す issue を取り出す。 # # closing-issues.sh [--repo <所有者>/<リポジトリ>] [本文のファイル] # 引数が無ければ標準入力から読む。`<所有者>/<リポジトリ>` と `<番号>` をタブで @@ -16,6 +16,9 @@ # # 起点ブランチが既定ブランチでないリポジトリでは、マージしても自動クローズが働かない。 # 後片付けの工程がこの一覧を読み、まだ OPEN のものだけを閉じる。 +# +# **閉じる予定の参照(`最終工程の後に閉じる: #12`)も同じ指し先の規則で取り出す**(#2228)。 +# 既定ブランチ宛の本文では NDF の手順が閉じる語をこの形へ置き換える。形の正本は lib/closing.py の DEFERRED。 set -uo pipefail DEFAULT_REPO="" @@ -46,7 +49,7 @@ body=$(cat -- "${1:--}") # **Pull Request の URL は拾わない。** 閉じる対象は issue である。 NAME='[A-Za-z0-9._-]+' REF="(https?://github\.com/$NAME/$NAME/issues/[0-9]+|$NAME/$NAME#[0-9]+|#[0-9]+)" -KEYWORD='\b(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]*' +KEYWORD='(\b(close[sd]?|fix(e[sd])?|resolve[sd]?)[[:space:]]*:?[[:space:]]*|最終工程の後に閉じる[[:space:]]*:[[:space:]]*)' # **1 件も見つからないことは正常な結果である。** `grep` の終了コードをそのまま返すと、 # 閉じる語の無い Pull Request で失敗したように見える。 diff --git a/plugins/ndf/scripts/lib/closing.py b/plugins/ndf/scripts/lib/closing.py index 2aa5e1ace..5903502b1 100644 --- a/plugins/ndf/scripts/lib/closing.py +++ b/plugins/ndf/scripts/lib/closing.py @@ -1,16 +1,34 @@ -"""本文の課題を閉じる closing keywords(GitHub が課題を自動で閉じるキーワード `Closes #n` など)と、課題の参照の読み取り。 -`pr-steps.py`(closing keywords の検査)と `release-steps.py`(版に含む PR から課題へたどる。宛先が既定ブランチでない PR は -GitHub の `closingIssuesReferences` が空になる)、`sprint-close.py`(参照だけの課題)が使う。""" +"""本文の課題を閉じる closing keywords(GitHub が課題を自動で閉じるキーワード `Closes #n` など)と、閉じる予定の参照 +(`最終工程の後に閉じる: #n`。GitHub は閉じず、`sprint-close.py` がスプリント課題として集める)と、課題の参照の読み取り。 +`pr-steps.py`(closing keywords の検査と、既定ブランチ宛の本文の置き換え)と `supervise_lib/pr.py`(同じ置き換え)、 +`release-steps.py`(版に含む PR から課題へたどる。宛先が既定ブランチでない PR は GitHub の `closingIssuesReferences` が +空になる)、`sprint-close.py`(参照だけの課題)が使う。閉じる指定の形はこのモジュールだけが持ち、 +`closing-issues.sh` は同じ形を読む(#2228)。""" from __future__ import annotations import re -CLOSING = re.compile( - r"\b(close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*" - r"(https?://github\.com/[\w.-]+/[\w.-]+/issues/\d+|[\w.-]+/[\w.-]+#\d+|#\d+)", - re.I, -) +_TARGET = r"(https?://github\.com/[\w.-]+/[\w.-]+/issues/\d+|[\w.-]+/[\w.-]+#\d+|#\d+)" +_KEYWORD = r"\b(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?)\s*:?\s*" +DEFERRED = "最終工程の後に閉じる" # 閉じる予定の参照の語。GitHub の closing keywords を含まない(#2228 の I9) +_DEFERRED_LEAD = DEFERRED + r"\s*:\s*" + +CLOSING = re.compile(_KEYWORD + _TARGET, re.I) +_ANY_CLOSING = re.compile(r"(?:" + _KEYWORD + r"|" + _DEFERRED_LEAD + r")" + _TARGET, re.I) + + +def defer_closing(text): + """closing keywords を閉じる予定の参照へ置き換える。(置き換えた本文, 置き換えた指し先の字面の並び) を返す。 + + 指し先の字面(`#12`・`owner/repo#12`・課題の URL)は変えない。宛先が既定ブランチの Pull Request の本文だけに使う。""" + targets: list[str] = [] + + def repl(m): + targets.append(m.group(1)) + return f"{DEFERRED}: {m.group(1)}" + + return CLOSING.sub(repl, text or ""), targets def closing_words(text): @@ -19,12 +37,12 @@ def closing_words(text): def closing_issues(text): - """closing keywords が指す課題の字面(`#12`・`owner/repo#12`・課題の URL)。""" - return [m.group(2) for m in CLOSING.finditer(text or "")] + """closing keywords と閉じる予定の参照が指す課題の字面(`#12`・`owner/repo#12`・課題の URL)を現れた順で返す。""" + return [m.group(1) for m in _ANY_CLOSING.finditer(text or "")] def closing_numbers(text) -> list[int]: - """closing keywords が指す課題の番号を、重ねずに現れた順で返す。""" + """closing keywords と閉じる予定の参照が指す課題の番号を、重ねずに現れた順で返す。""" out: list[int] = [] for ref in closing_issues(text): n = int(re.search(r"(\d+)$", ref).group(1)) @@ -45,7 +63,7 @@ def issue_ref(ref, repo): def closing_refs(text, repo) -> list[tuple[str, int]]: - """closing keywords が指す課題を (owner/name, 番号) で、重ねずに現れた順で返す。`#12` は `repo` の課題。""" + """closing keywords と閉じる予定の参照が指す課題を (owner/name, 番号) で、重ねずに現れた順で返す。`#12` は `repo` の課題。""" out: list[tuple[str, int]] = [] for ref in closing_issues(text): got = issue_ref(ref, repo) diff --git a/plugins/ndf/scripts/lib/dist_record.py b/plugins/ndf/scripts/lib/dist_record.py index 070851680..fd1777f67 100644 --- a/plugins/ndf/scripts/lib/dist_record.py +++ b/plugins/ndf/scripts/lib/dist_record.py @@ -59,6 +59,11 @@ def _read_dist_block(block): return out +def _heading_sections(text, lines): + """本文の節のうち、見出しの行が `#` で始まるもの(ブロックの切り出しと配布の記録の読み取りが共有する)。""" + return [s for s in md.md_sections(text) if lines[s.heading.line].startswith("#")] + + def _verify_blocks(lines, sections): """リリース後テストのブロックを切り出す。各ブロックは {"start": 開始行, "ver": 対象の版, "lines": 行の並び}。""" blocks = [] @@ -80,6 +85,20 @@ def _verify_blocks(lines, sections): _EXPLICIT_VERSION = re.compile(r"v?\d+\.\d+\.\d+") +_BASE_VERSION = re.compile(r"v?(\d+\.\d+\.\d+)") + + +def verify_block_for_base(text, version): + """本文(text)のリリース後テストのブロックのうち、`対象の版:` の基底が version の基底と一致する最後のブロックの + 本文。無ければ None。parse_record の完全な版の一致とは別の規則で、受け入れ条件の版ごとの判定が使う。""" + want = _BASE_VERSION.match(str(version)) + lines = text.splitlines() + blocks = [ + b + for b in _verify_blocks(lines, _heading_sections(text, lines)) + if want and (m := _BASE_VERSION.match(b["ver"] or "")) and m.group(1) == want.group(1) + ] + return "\n".join(blocks[-1]["lines"]) if blocks else None def _is_explicit_version(ver): @@ -103,22 +122,41 @@ def _pick_verify_block(blocks, stage, version, dist_start): return "\n".join(after[-1]["lines"]) if after else None -def parse_record(text): +def _dist_comment_id(parts, dist_line): + """最後の配布の記録の見出しの行を含む部分のコメントの識別子(振り返りの行を比べる基準。#2228 の決定 10)。 + + parts は (先頭の行の番号, コメントの識別子) の並び(本文は識別子が None)。記録が本文にあれば、記録の PR の + 最初のコメントの識別子を使い、コメントが 1 件も無ければ None。parts が無ければ None。""" + if not parts: + return None + hit = None + for start, cid in parts: + if start <= dist_line: + hit = cid + if hit is not None: + return hit + return next((cid for _, cid in parts if cid is not None), None) + + +def parse_record(text, parts=None): """最後の配布の記録と、使うリリース後テストのブロックを読む。 - 戻り値: {"found", "stage", "version", "sprint_prs", "verify_block"}。 + parts は text を組んだ部分ごとの (先頭の行の番号, コメントの識別子) の並び(sprint-close.py の read_record が返す)。 + 戻り値: {"found", "stage", "version", "sprint_prs", "verify_block", "dist_comment_id"}。 verify_block は本番なら対象の版が一致する最後のブロック(無ければ最後の配布の記録より後で、対象の版が版数でない 最後のブロック)、 配布なしなら最後の配布の記録より後の最後のブロック。無ければ None。 + dist_comment_id は最後の配布の記録のコメントの識別子(_dist_comment_id。記録が無ければ None)。 """ lines = text.splitlines() - sections = [s for s in md.md_sections(text) if lines[s.heading.line].startswith("#")] + sections = _heading_sections(text, lines) dists = [s for s in sections if lines[s.heading.line].startswith(DIST)] dist_start = dists[-1].heading.line if dists else None - out = {"found": dist_start is not None, "stage": None, "version": None, "sprint_prs": [], "verify_block": None} + out = {"found": dist_start is not None, "stage": None, "version": None, "sprint_prs": [], "verify_block": None, "dist_comment_id": None} if dist_start is None: return out out.update(_read_dist_block(lines[dists[-1].start : dists[-1].end])) blocks = _verify_blocks(lines, sections) out["verify_block"] = _pick_verify_block(blocks, out["stage"] or "", out["version"], dist_start) + out["dist_comment_id"] = _dist_comment_id(parts, dist_start) return out diff --git a/plugins/ndf/scripts/lib/drive_pause.py b/plugins/ndf/scripts/lib/drive_pause.py index d789b5978..5811ea14f 100644 --- a/plugins/ndf/scripts/lib/drive_pause.py +++ b/plugins/ndf/scripts/lib/drive_pause.py @@ -18,6 +18,7 @@ | 1 | stopped | — | 中断(`metrics.exit` に元の終了コード) | 理由(summary)を報告する | | 1 | stopped | — | 収束ループがレビューの成立しないまま終わった(`metrics.final` が `error`。報告は `items[0].report`・`gaps` 無し) | 理由と件数を報告し、後続へ進まない | | 6 | stopped | — | 最後まで回ったが収束しなかった(`metrics.final` が `max_rounds`・`oscillation`。報告は `items[0].report`・`gaps` に `kind: review`) | 理由と件数を報告する。プランは `on_exit` の `"6"` で行き先を分けられる(無ければ `on_fail`) | +| 6 | stopped | — | 時間の枠に収まらずリファクタリングを飛ばした(`metrics.skipped` に種類と理由。`gaps` に `kind: refactor`。#2047) | 同上 | `items[0]` の項目: `pause`・`prompt_file`・`result_file`・`round` は必ずある。任意の項目は次の通り。 @@ -28,12 +29,18 @@ 再開は同じコマンドを打ち直すだけである。進みは各 drive が一時ディレクトリの状態ファイルに持つ。 件数(metrics)の中身は drive ごとに決め、各 drive の docstring が並べる。 + +ステップの延長(#2047 の決定 4): drive のステップを流すエンジンは、環境変数 `NDF_STEP_EXTEND_FILE` に延長のファイルの +パスを渡す。駆動とその子は、この試行の開始からの所要の見込み(秒)を `{"seconds": <秒>, "reason": "<1 行>"}` で書き、 +エンジンは締め切りを後ろへだけずらす。書くのは `write_extension`、読むのは `read_extension` だけである。 """ from __future__ import annotations +import json +import os from pathlib import Path -from typing import Callable +from typing import Callable, Optional import step_result as sr @@ -46,6 +53,7 @@ } EXIT_DONE = 0 EXIT_STOPPED = 1 +EXTEND_ENV = "NDF_STEP_EXTEND_FILE" class Stop(Exception): @@ -89,6 +97,39 @@ def unconverged(tool: str, summary: str, report: Path | str, metrics: dict) -> d return sr.result(tool, "stopped", summary, [{"report": str(report)}], {**metrics, "exit": sr.EXIT_INCOMPLETE}, gaps=[gap]) +def incomplete(tool: str, summary: str, metrics: dict, gap: dict) -> dict: + """欠けを 1 件持つ中断の結果(終了コード 6)。プランは `on_exit` の `"6"` で後続へ進められる(#2047 の I5)。""" + return sr.result(tool, "stopped", summary, [], metrics, gaps=[gap]) + + +def write_extension(seconds: float, reason: str) -> bool: + """延長のファイルへ見込みの秒を書く。`NDF_STEP_EXTEND_FILE` が無い(エンジンの外で打った)か書けなければ False。 + 今のファイルの秒より小さければ書き換えない(締め切りは前へずらさない。#2047 の I7)。""" + target = os.environ.get(EXTEND_ENV) + if not target: + return False + got = read_extension(target) + if got is not None and got[0] >= float(seconds): + return True + try: + Path(target).write_text(json.dumps({"seconds": int(seconds), "reason": str(reason)}, ensure_ascii=False), encoding="utf-8") + except OSError: + return False + return True + + +def read_extension(path: Path | str) -> Optional[tuple[float, str]]: + """延長のファイルの (秒, 理由)。無い・読めない・秒が正の数でなければ None。""" + try: + data = json.loads(Path(path).read_text(encoding="utf-8")) + except (OSError, ValueError): + return None + seconds = data.get("seconds") if isinstance(data, dict) else None + if isinstance(seconds, bool) or not isinstance(seconds, (int, float)) or seconds <= 0: + return None + return float(seconds), str(data.get("reason") or "") + + def stopped(tool: str, msg: str, metrics: dict, code: int) -> dict: return sr.result(tool, "stopped", msg, [], {**metrics, "exit": code}) diff --git a/plugins/ndf/scripts/lib/procs.py b/plugins/ndf/scripts/lib/procs.py index 039364470..9c7c303f0 100644 --- a/plugins/ndf/scripts/lib/procs.py +++ b/plugins/ndf/scripts/lib/procs.py @@ -82,14 +82,19 @@ def pid_is_zombie(pid: int) -> bool: def cmdline_contains(pid: int, expected: str) -> bool | None: - """pid の起動の引数(空白でつないだもの)が `expected` を含むか(大文字と小文字を区別しない)。読めなければ None。""" + """pid の起動の引数(空白でつないだもの)が `expected` を含むか(大文字と小文字を区別しない)。読めなければ None。 + + 空の引数も読めないとみなす。終了の途中(ゾンビになる前)の pid は、psutil が例外でなく `[]` を返す。 + False(別のプロセス)と取ると、すぐ終わる CLI を監視が PIDFILE_BAD で止める(CI の不定の失敗)。 + """ p = _process(pid) if p is None: return None try: - return expected.lower() in " ".join(p.cmdline()).lower() + args = p.cmdline() except psutil.Error: return None + return expected.lower() in " ".join(args).lower() if args else None def parent_and_name(pid: int) -> tuple[int, str] | None: diff --git a/plugins/ndf/scripts/lib/repo.py b/plugins/ndf/scripts/lib/repo.py index a60a2de9e..fd3c30115 100644 --- a/plugins/ndf/scripts/lib/repo.py +++ b/plugins/ndf/scripts/lib/repo.py @@ -12,6 +12,7 @@ from pathlib import Path import proc +from closing import defer_closing ORIGIN_URL = re.compile(r"[:/](?P[^/:]+)/(?P[^/]+?)(?:\.git)?/?$") WORKTREE_DECL = Path(".ndf") / "worktree.json" @@ -85,6 +86,21 @@ def default_branch(root) -> str | None: return None +def merge_closes_issues(root, base) -> bool: + """宛先 `base` の Pull Request をマージすると、GitHub が本文の closing keywords で課題を閉じるか。 + + GitHub が閉じるのは既定ブランチへのマージだけである。既定ブランチを決められない・宛先が分からないときも真を返す + (置き換えても閉じる予定の参照として NDF の手順で閉じられ、失うものが無い。#2228 の決定 9)。""" + d = default_branch(root) + return d is None or not base or str(base).removeprefix("origin/") == d + + +def defer_for_base(root, base, text): + """宛先 base の Pull Request の本文を、マージで GitHub が課題を閉じるとき(merge_closes_issues)だけ + closing.defer_closing に通す。(本文, 置き換えた指し先の字面の並び) を返す。`pr-steps.py` と `supervise_lib/pr.py` が使う。""" + return defer_closing(text) if merge_closes_issues(root, base) else (text or "", []) + + def base_branch(root) -> str | None: """開発の起点。`.ndf/worktree.json` の `base_branch` → 既定ブランチ(`default_branch`)の順。決まらなければ `None`。""" return declared_base(root) or default_branch(root) diff --git a/plugins/ndf/scripts/lib/test_strategy.py b/plugins/ndf/scripts/lib/test_strategy.py index d460234a1..4c4bf1585 100644 --- a/plugins/ndf/scripts/lib/test_strategy.py +++ b/plugins/ndf/scripts/lib/test_strategy.py @@ -43,7 +43,7 @@ CI_WHOLE_THRESHOLD_SECONDS = 600.0 # 時間の係数(決定 8)。B は予算(分)、w は全体テストの所要、x は着手前に手元で走らせたテストの実測、c は CI の壁時計。 -INIT_TEST_SHARE = 0.10 # 着手前のテスト 1 回の上限 = max(0.10·B, 3·w)(CI に任せる戦略は max(0.10·B, 3·s)。s が分からなければ 0.10·B) +INIT_TEST_SHARE = 0.10 # 着手前のテスト 1 回の上限 = max(0.10·B, 3·s)(s は着手前のテストの所要の見込み。分からなければ 0.10·B) TEST_FACTOR = 3.0 # テスト 1 回の上限 = max(3·x, 0.01·B) TEST_FLOOR_SHARE = 0.01 CI_WAIT_SHARE = 0.05 # CI の待ちの上限 = max(3·c, 0.05·B) @@ -820,8 +820,8 @@ def limits( ) -> dict[str, Any]: """時間の上限(秒)の表(決定 8)。`basis` に入力を並べる。 - `scope_seconds` は CI に任せる戦略で着手前に走らせる範囲テストの所要の見込み s(`scope_source` はその出所。 - `history` / `whole`)。分かれば着手前の上限を `max(0.10·B, 3·s)` にする(#1555)。ほかの戦略と予算なしでは使わない。 + `scope_seconds` は着手前のテストの所要の見込み s(`scope_source` はその出所。`history` / `recent` / `whole`)。 + 分かれば戦略を問わず着手前の上限を `max(0.10·B, 3·s)` にする(#1555・#2047 の I1)。予算なしでは使わない。 予算が無い(supervise)ときは `3·max(w, m)` と `3·c` を上限にし、所要も無ければ `UNKNOWN_DURATION_LIMITS` を使って `basis.unknown_duration` を真にする(`3·m` が上回るときだけ広げる)。`recent_seconds` は先に走った全体テストの @@ -848,7 +848,7 @@ def limits( else: base_timeout = _ceil(max(seconds * INIT_TEST_SHARE, TEST_FACTOR * w)) init_timeout = base_timeout - if strategy.whole_on_ci and scope_seconds is not None: + if scope_seconds is not None: init_timeout = _ceil(max(seconds * INIT_TEST_SHARE, TEST_FACTOR * float(scope_seconds))) if x is None: test_timeout = base_timeout diff --git a/plugins/ndf/scripts/pr-steps.py b/plugins/ndf/scripts/pr-steps.py index 02872eea2..364e67edb 100644 --- a/plugins/ndf/scripts/pr-steps.py +++ b/plugins/ndf/scripts/pr-steps.py @@ -17,7 +17,6 @@ from __future__ import annotations import argparse -import json import re import sys import tempfile @@ -40,13 +39,16 @@ result, run, ) -import gh_parts # noqa: E402 from closing import closing_issues, closing_words # noqa: E402 import repo # noqa: E402 +from repo import defer_for_base # noqa: E402 import tool_paths # noqa: E402 from design_diff import DESIGN_DIFF_HEADING # noqa: E402 from pr_mode import needs_review, pr_target, split_stages, with_mode_line # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from pr_steps_lib.gh_pr import existing_pr, pr_for_report, send_body, target_pr # noqa: E402 PR の読み書き + TOOL = "pr" SCRIPTS = Path(__file__).resolve().parent REVIEW_MARK = "" @@ -96,51 +98,6 @@ def compare_ref(root, base): raise StepError(f"起点 {base} が無い(git fetch origin {base} を先に打つ)", EXIT_PRECONDITION) -def repo_owner_name(root): - p = gh_parts.gh(["repo", "view", "--json", "owner,name"], cwd=root) - if p.returncode == 0: - try: - d = json.loads(p.stdout) - return d["owner"]["login"], d["name"] - except (ValueError, KeyError, TypeError): - pass - slug = repo.owner_repo(root) - if not slug: - raise StepError("リポジトリの所有者と名前を決められない", EXIT_UNREADABLE) - owner, _, name = slug.partition("/") - return owner, name - - -def existing_pr(root, branch): - """head が branch の OPEN の PR({number, url, isDraft, baseRefName})。無ければ None。上限なら REST。""" - p = gh_parts.gh(["pr", "list", "--head", branch, "--state", "open", "--json", "number,url,isDraft,baseRefName"], cwd=root) - if p.returncode == 0: - try: - prs = json.loads(p.stdout or "[]") - except ValueError: - raise StepError("gh pr list の出力を読めない", EXIT_UNREADABLE) - return prs[0] if prs else None - if not gh_parts.is_rate_limited(p.stderr): - raise StepError(f"gh pr list が失敗: {p.stderr.strip()[:300]}", EXIT_PRECONDITION) - owner, name = repo_owner_name(root) - q = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls?head={owner}:{branch}&state=open"], cwd=root) - if q.returncode != 0: - raise StepError(f"REST でも既存の PR を引けない: {q.stderr.strip()[:300]}") - try: - prs = json.loads(q.stdout or "[]") - except ValueError: - raise StepError("REST の出力を読めない", EXIT_UNREADABLE) - if not prs: - return None - pr = prs[0] - return { - "number": pr["number"], - "url": pr["html_url"], - "isDraft": pr.get("draft", False), - "baseRefName": pr.get("base", {}).get("ref", ""), - } - - def diff_numbers(root, ref): # 3 点(分岐点からの差分)。2 点だと起点が進んでいたとき他 PR の変更まで数える last = (git(root, "diff", "--shortstat", f"{ref}...HEAD").stdout.strip().splitlines() or [""])[-1] @@ -333,64 +290,17 @@ def decisions_sync(root, number): return run(["bash", str(script), "sync", str(number)], cwd=root, check=False).returncode -def rest_create(root, branch, base, title, body, draft): - owner, name = repo_owner_name(root) - payload = {"title": title, "head": branch, "base": base, "body": body, "draft": bool(draft)} - with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False) - tmp = f.name - try: - p = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls", "--input", tmp], cwd=root) - finally: - Path(tmp).unlink(missing_ok=True) # 本文を /tmp に残さない - if p.returncode != 0: - raise StepError(f"REST でも作成が失敗: {p.stderr.strip()[:300]}") - d = json.loads(p.stdout) - return d["number"], d["html_url"] - - -def send_body(root, branch, body, body_path, a, must_exist): - """gh へ本文ファイルを渡して PR を作る・更新する。(番号, URL, action) を返す。""" - if getattr(a, "pr", None): - pr = {"number": a.pr, "url": f"#{a.pr}"} - else: - pr = existing_pr(root, branch) - if pr: - args = ["pr", "edit", str(pr["number"]), "--body-file", body_path] - if a.title: - args += ["--title", a.title] - p = gh_parts.gh(args, cwd=root) - if p.returncode != 0: - raise StepError(f"gh pr edit {pr['number']} が失敗: {p.stderr.strip()[:300]}") - return pr["number"], pr["url"], "updated" - if must_exist: - raise StepError(f"{branch} の OPEN の PR が無い(create を使う)", EXIT_PRECONDITION) - if not a.title: - raise StepError("--title が要る", EXIT_UNREADABLE) - base = a.base or repo.base_branch(root) or "main" - args = ["pr", "create", "--base", base, "--title", a.title, "--body-file", body_path] - if a.draft: - args.append("--draft") - p = gh_parts.gh(args, cwd=root) - if p.returncode == 0: - url = (p.stdout.strip().splitlines() or [""])[-1] - m = re.search(r"/pull/(\d+)", url) - return (int(m.group(1)) if m else None), url, "created" - if gh_parts.is_rate_limited(p.stderr): - number, url = rest_create(root, branch, base, a.title, body, a.draft) - return number, url, "created_rest" - raise StepError(f"gh pr create が失敗: {p.stderr.strip()[:300]}") - - def upsert(a, must_exist): root = git_root(a.root) branch = current_branch(root) body = with_mode_line(body_with_mark(a.body_file), getattr(a, "mode", None), split_stages(getattr(a, "stages", None))) + pr, base = target_pr(root, branch, a, must_exist) + body, deferred = defer_for_base(root, base, body) # 既定ブランチ宛はマージで閉じないよう置き換える(#2228 の I7) with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f: f.write(body) body_path = f.name try: - number, url, action = send_body(root, branch, body, body_path, a, must_exist) + number, url, action = send_body(root, branch, body, body_path, a, pr, base) finally: Path(body_path).unlink(missing_ok=True) # 本文を /tmp に残さない sync_exit = decisions_sync(root, number) if number else None @@ -401,9 +311,11 @@ def upsert(a, must_exist): "branch": branch, "draft": bool(getattr(a, "draft", False)), "closing_issues_in_body": closing_issues(body), + "deferred_closing": deferred, "decisions_sync_exit": sync_exit, } items = [{"kind": "pr", "name": url, "result": action}] + items += [{"kind": "closing", "name": t, "result": "deferred"} for t in deferred] if sync_exit is not None: items.append({"kind": "decisions", "name": "pr-body-decisions.sh sync", "result": f"exit={sync_exit}"}) nxt = None @@ -459,32 +371,6 @@ def cmd_update(a): # --- report ----------------------------------------------------------------------- -def pr_for_report(root, number): - fields = "number,title,url,isDraft,baseRefName,headRefName,body" - p = gh_parts.gh(["pr", "view", str(number), "--json", fields], cwd=root) - if p.returncode == 0: - try: - return json.loads(p.stdout) - except ValueError: - raise StepError("gh pr view の出力を読めない", EXIT_UNREADABLE) - if not gh_parts.is_rate_limited(p.stderr): - raise StepError(f"gh pr view {number} が失敗: {p.stderr.strip()[:300]}") - owner, name = repo_owner_name(root) - q = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls/{number}"], cwd=root) - if q.returncode != 0: - raise StepError(f"REST でも PR を引けない: {q.stderr.strip()[:300]}") - d = json.loads(q.stdout) - return { - "number": d["number"], - "title": d["title"], - "url": d["html_url"], - "isDraft": d.get("draft", False), - "baseRefName": d["base"]["ref"], - "headRefName": d["head"]["ref"], - "body": d.get("body") or "", - } - - def section(body, heading): """見出しが `heading` で始まる(大文字と小文字を区別しない)`## ` の節の行(子の節を含む)。""" return [line for title, lines in h2_sections(body) if title.lower().startswith(heading.lower()) for line in lines] diff --git a/plugins/ndf/scripts/pr_steps_lib/__init__.py b/plugins/ndf/scripts/pr_steps_lib/__init__.py new file mode 100644 index 000000000..35ec6cdcf --- /dev/null +++ b/plugins/ndf/scripts/pr_steps_lib/__init__.py @@ -0,0 +1 @@ +"""pr-steps.py から分けたモジュール。""" diff --git a/plugins/ndf/scripts/pr_steps_lib/gh_pr.py b/plugins/ndf/scripts/pr_steps_lib/gh_pr.py new file mode 100644 index 000000000..08ef8c9b1 --- /dev/null +++ b/plugins/ndf/scripts/pr_steps_lib/gh_pr.py @@ -0,0 +1,162 @@ +"""PR を gh で読み書きする部品(`pr-steps.py` から分けた)。上限(rate limit)なら REST へ退避する。 + +書く先の PR と宛先を決める `target_pr`、本文ファイルで作る・更新する `send_body`、既存の PR と report の読み取りを持つ。 +出す(emit)のは呼び手である。 +""" + +from __future__ import annotations + +import json +import re +import tempfile +from pathlib import Path + +import gh_parts +import repo +from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, StepError + + +def repo_owner_name(root): + p = gh_parts.gh(["repo", "view", "--json", "owner,name"], cwd=root) + if p.returncode == 0: + try: + d = json.loads(p.stdout) + return d["owner"]["login"], d["name"] + except (ValueError, KeyError, TypeError): + pass + slug = repo.owner_repo(root) + if not slug: + raise StepError("リポジトリの所有者と名前を決められない", EXIT_UNREADABLE) + owner, _, name = slug.partition("/") + return owner, name + + +def _gh_or_rest(root, args, rest_path, parse_rest, *, label, fail, rest_fail, fail_code=1, empty=""): + """gh を呼び、成功なら JSON を返す。上限なら REST(rest_path(owner, name))の出力を parse_rest で読む。""" + p = gh_parts.gh(args, cwd=root) + if p.returncode == 0: + try: + return json.loads(p.stdout or empty) + except ValueError: + raise StepError(f"{label} の出力を読めない", EXIT_UNREADABLE) + if not gh_parts.is_rate_limited(p.stderr): + raise StepError(f"{fail}: {p.stderr.strip()[:300]}", fail_code) + owner, name = repo_owner_name(root) + q = gh_parts.gh(["api", rest_path(owner, name)], cwd=root) + if q.returncode != 0: + raise StepError(f"{rest_fail}: {q.stderr.strip()[:300]}") + return parse_rest(q.stdout) + + +def existing_pr(root, branch): + """head が branch の OPEN の PR({number, url, isDraft, baseRefName})。無ければ None。上限なら REST。""" + + def parse_rest(out): + try: + prs = json.loads(out or "[]") + except ValueError: + raise StepError("REST の出力を読めない", EXIT_UNREADABLE) + if not prs: + return [] + pr = prs[0] + return [ + { + "number": pr["number"], + "url": pr["html_url"], + "isDraft": pr.get("draft", False), + "baseRefName": pr.get("base", {}).get("ref", ""), + } + ] + + prs = _gh_or_rest( + root, + ["pr", "list", "--head", branch, "--state", "open", "--json", "number,url,isDraft,baseRefName"], + lambda owner, name: f"repos/{owner}/{name}/pulls?head={owner}:{branch}&state=open", + parse_rest, + label="gh pr list", + fail="gh pr list が失敗", + rest_fail="REST でも既存の PR を引けない", + fail_code=EXIT_PRECONDITION, + empty="[]", + ) + return prs[0] if prs else None + + +def pr_for_report(root, number): + def parse_rest(out): + d = json.loads(out) + return { + "number": d["number"], + "title": d["title"], + "url": d["html_url"], + "isDraft": d.get("draft", False), + "baseRefName": d["base"]["ref"], + "headRefName": d["head"]["ref"], + "body": d.get("body") or "", + } + + return _gh_or_rest( + root, + ["pr", "view", str(number), "--json", "number,title,url,isDraft,baseRefName,headRefName,body"], + lambda owner, name: f"repos/{owner}/{name}/pulls/{number}", + parse_rest, + label="gh pr view", + fail=f"gh pr view {number} が失敗", + rest_fail="REST でも PR を引けない", + ) + + +def target_pr(root, branch, a, must_exist): + """書く先の PR と宛先。(既存の PR か None, 宛先のブランチか None) を返す。 + + 宛先は、既存の PR なら baseRefName、新しく作るなら --base か起点(#2228 の決定 9)。""" + if getattr(a, "pr", None): + return {"number": a.pr, "url": f"#{a.pr}"}, pr_for_report(root, a.pr).get("baseRefName") or None + pr = existing_pr(root, branch) + if pr: + return pr, pr.get("baseRefName") or None + if must_exist: + raise StepError(f"{branch} の OPEN の PR が無い(create を使う)", EXIT_PRECONDITION) + if not a.title: + raise StepError("--title が要る", EXIT_UNREADABLE) + return None, a.base or repo.base_branch(root) or "main" + + +def send_body(root, branch, body, body_path, a, pr, base): + """gh へ本文ファイルを渡して PR を作る・更新する。(番号, URL, action) を返す。""" + if pr: + args = ["pr", "edit", str(pr["number"]), "--body-file", body_path] + if a.title: + args += ["--title", a.title] + p = gh_parts.gh(args, cwd=root) + if p.returncode != 0: + raise StepError(f"gh pr edit {pr['number']} が失敗: {p.stderr.strip()[:300]}") + return pr["number"], pr["url"], "updated" + args = ["pr", "create", "--base", base, "--title", a.title, "--body-file", body_path] + if a.draft: + args.append("--draft") + p = gh_parts.gh(args, cwd=root) + if p.returncode == 0: + url = (p.stdout.strip().splitlines() or [""])[-1] + m = re.search(r"/pull/(\d+)", url) + return (int(m.group(1)) if m else None), url, "created" + if gh_parts.is_rate_limited(p.stderr): + number, url = rest_create(root, branch, base, a.title, body, a.draft) + return number, url, "created_rest" + raise StepError(f"gh pr create が失敗: {p.stderr.strip()[:300]}") + + +def rest_create(root, branch, base, title, body, draft): + owner, name = repo_owner_name(root) + payload = {"title": title, "head": branch, "base": base, "body": body, "draft": bool(draft)} + with tempfile.NamedTemporaryFile("w", suffix=".json", delete=False, encoding="utf-8") as f: + json.dump(payload, f, ensure_ascii=False) + tmp = f.name + try: + p = gh_parts.gh(["api", f"repos/{owner}/{name}/pulls", "--input", tmp], cwd=root) + finally: + Path(tmp).unlink(missing_ok=True) # 本文を /tmp に残さない + if p.returncode != 0: + raise StepError(f"REST でも作成が失敗: {p.stderr.strip()[:300]}") + d = json.loads(p.stdout) + return d["number"], d["html_url"] diff --git a/plugins/ndf/scripts/progress-record.sh b/plugins/ndf/scripts/progress-record.sh index f64e9271a..8e5b52564 100644 --- a/plugins/ndf/scripts/progress-record.sh +++ b/plugins/ndf/scripts/progress-record.sh @@ -43,8 +43,8 @@ TOOL=progress-record . "$SCRIPT_DIR/lib/projects-common.sh" 2>/dev/null \ || sr_emit --tool "$TOOL" --status stopped --summary "lib/projects-common.sh を読めない" --code 3 # 工程名・モード・進め方の一覧と通過記録は development-workflow のライブラリが持つ(#725 の決定 2・3)。 -# **同じプラグインの根の相対で辿る。** `cd -P` で実体へ解決しない。agy の配置(dev.agy/scripts と -# dev.agy/skills/development-workflow がどちらも symlink)でも、`scripts/` の 1 つ上に `skills/` がある。 +# **同じプラグインの根の相対で辿る。** `cd -P` で実体へ解決しない。`scripts/` が symlink で置かれた配置でも、 +# `scripts/` の 1 つ上に `skills/` がある。 # shellcheck source=../skills/development-workflow/scripts/lib/workflow-common.sh . "$SCRIPT_DIR/../skills/development-workflow/scripts/lib/workflow-common.sh" 2>/dev/null \ || sr_emit --tool "$TOOL" --status stopped --summary "development-workflow の workflow-common.sh を読めない" --code 3 diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 7976a0b25..e8451713e 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -35,7 +35,7 @@ python3 release-steps.py approve --approval <承認資料> --facts-from <開発版の状態> [--answer-from <状態>] --by user python3 release-steps.py approval-facts --version <版> --prs ... [--prev-tag <タグ>] [--plugin <名前>] [--root ] python3 release-steps.py notes --version <版> --prs ... [--approval <提示物>] - [--verified claude,codex,kiro] [--ref <ブランチ>] [--plugin <名前>] [--root ] + [--verified claude,codex] [--ref <ブランチ>] [--plugin <名前>] [--root ] release --channel prod は承認したコミットを受け取り、配布の PR をマージした後の `origin/<ベースブランチ>` の先端と比べ、 承認の外の変更があれば本番チャネルの PR もタグも作らずに承認ゲート(10)で止まる(release_lib/approval.py。#815)。 @@ -330,15 +330,11 @@ def base_version(root, pdir, ref): return None -def plan_bump(root, pdir, plugin, old, new): - """`plugin` の版を `old` から `new` へ上げる箇所を集めた `BumpPlan` を返す(まだ書き換えない)。""" - ed = bump.BumpPlan(root, old, new) - desc_re = r'^\s*"description"\s*:.*\(v' + re.escape(old) + r"\)" - +def _bump_manifests(ed, pdir, old, desc_re): + """plugin.json 群の version と description の版を書き換える箇所を `ed` へ足す。""" for rel, _ in ( (".claude-plugin/plugin.json", True), (".codex-plugin/plugin.json", False), - ("dev.agy/plugin.json", False), ("plugin.json", False), ): f = pdir / rel @@ -348,6 +344,9 @@ def plan_bump(root, pdir, plugin, old, new): if f"(v{old})" in f.read_text(encoding="utf-8"): ed.sub(f, desc_re, f"{rel} の description") + +def _bump_marketplace_and_readme(ed, root, plugin, old, desc_re): + """marketplace.json の description と README.md のプラグイン一覧表の版を書き換える箇所を `ed` へ足す。""" mp = root / ".claude-plugin" / "marketplace.json" if mp.is_file(): s, e = bump.marketplace_range(ed.lines(mp), plugin) @@ -362,14 +361,25 @@ def plan_bump(root, pdir, plugin, old, new): ed.sub(readme, r"^\| \*\*" + re.escape(plugin) + r"\*\* \|", "README.md のプラグイン一覧表") elif plugin in ("ndf", "playwright-kit"): ed.manual.append(f"README.md のプラグイン一覧表に {plugin} の行が無い") - if plugin == "ndf": - ed.sub(readme, r"\*\*NDFプラグイン v", "README.md の概要の版") - nr = pdir / "README.md" - ed.sub(nr, r"(Kiro CLI用 / v", "plugins/ndf/README.md の Kiro の確認例") - ed.sub(nr, r"/plugins/cache/ai-plugins/ndf/", "plugins/ndf/README.md の Codex のパス例", count=2) - ed.sub(nr, r"ndf@ai-plugins\s+installed", "plugins/ndf/README.md の codex plugin list の出力例") - bump.versioning_doc(ed) + +def _bump_ndf_docs(ed, root, pdir): + """ndf だけが持つ版の記載(README 概要・Codex のパス例・list の出力例・版の文書)を `ed` へ足す。""" + ed.sub(root / "README.md", r"\*\*NDFプラグイン v", "README.md の概要の版") + nr = pdir / "README.md" + ed.sub(nr, r"/plugins/cache/ai-plugins/ndf/", "plugins/ndf/README.md の Codex のパス例", count=2) + ed.sub(nr, r"ndf@ai-plugins\s+installed", "plugins/ndf/README.md の codex plugin list の出力例") + bump.versioning_doc(ed) + + +def plan_bump(root, pdir, plugin, old, new): + """`plugin` の版を `old` から `new` へ上げる箇所を集めた `BumpPlan` を返す(まだ書き換えない)。""" + ed = bump.BumpPlan(root, old, new) + desc_re = r'^\s*"description"\s*:.*\(v' + re.escape(old) + r"\)" + _bump_manifests(ed, pdir, old, desc_re) + _bump_marketplace_and_readme(ed, root, plugin, old, desc_re) + if plugin == "ndf": + _bump_ndf_docs(ed, root, pdir) return ed @@ -861,6 +871,36 @@ def kept_sections(root, approval): raise StepError(f"前の承認資料 {approval} を読めない(書き換えずに止める): {e}", 2) +def included_pr_facts(prs, data, checks, repo): + """含む PR ごとの事実から、機械向けの items と承認資料の「含む PR」の行を作る。 + + data は PR 番号 → 取得済みの PR、checks は PR 番号 → CI の結果(pr_check_buckets の戻り)。""" + items, rows = [], [] + for p in prs: + n, d = p.number, data.get(p.number) or {} + oid = (d.get("mergeCommit") or {}).get("oid") or "" + pr_checks = checks[n] + passed = sum(1 for c in pr_checks if c.get("bucket") == "pass") + url = d.get("url") or f"https://github.com/{repo}/pull/{n}" + items.append( + { + "kind": "pr", + "name": f"#{n}", + "result": str(d.get("state", "")).lower() or "unknown", + "url": url, + "title": d.get("title", ""), + "merge_commit": oid, + "checks_passed": passed, + "checks": len(pr_checks), + "via": p.via, + "no_change": included.no_change(d), + } + ) + facts = [str(d.get("state", "")), oid[:8] if oid else "—", f"CI {passed}/{len(pr_checks)}"] + rows.append(included.included_row(n, d.get("title", ""), facts, p.via, included.no_change(d))) + return items, rows + + def cmd_approval_facts(a): root = git_root(a.root) repo = repo_lib.owner_repo(root) @@ -883,30 +923,8 @@ def cmd_approval_facts(a): ins = int(m.group(1)) if (m := re.search(r"(\d+) insertions?", stat)) else 0 dels = int(m.group(1)) if (m := re.search(r"(\d+) deletions?", stat)) else 0 - items, rows = [], [] - for p in prs: - n, d = p.number, data.get(p.number) or {} - oid = (d.get("mergeCommit") or {}).get("oid") or "" - checks = pr_check_buckets(root, n) - passed = sum(1 for c in checks if c.get("bucket") == "pass") - url = d.get("url") or f"https://github.com/{repo}/pull/{n}" - items.append( - { - "kind": "pr", - "name": f"#{n}", - "result": str(d.get("state", "")).lower() or "unknown", - "url": url, - "title": d.get("title", ""), - "merge_commit": oid, - "checks_passed": passed, - "checks": len(checks), - "via": p.via, - "no_change": included.no_change(d), - } - ) - facts = [str(d.get("state", "")), oid[:8] if oid else "—", f"CI {passed}/{len(checks)}"] - rows.append(included.included_row(n, d.get("title", ""), facts, p.via, included.no_change(d))) - + checks = {p.number: pr_check_buckets(root, p.number) for p in prs} + items, rows = included_pr_facts(prs, data, checks, repo) compare = f"https://github.com/{repo}/compare/{prev}...{dev}" path = approval_present( TOOL, @@ -1049,37 +1067,7 @@ def cmd_notes(a): migration = [f"- {i}" for *_, ms in notes for i in ms] fallback = sum(1 for *_, by_title, _ in notes if by_title) if path: - verified = [r for r in (a.verified or "").split(",") if r] - ref = a.ref or release_decl(root, a)[0] - decl = (release_decl(root, a)[0], repo_lib.owner_repo(root) or "", a.version, a.prs) - m = materials.approval_materials( - root, - decl, - getattr(a, "check_state", None), - prs, - data, - lambda r, n: issue_body(root, n) if materials.same_repo(r, decl[1]) else issue_body(root, n, repo=r), - ) - results, machine = materials.check_lines(m), materials.risk_lines(m, bool(risks), materials.now_jst()) - items = materials.write_approval(path, a.version, bullets, risks, verified, ref, migration, results, machine) - items += materials.items_of(m) + unmerged_items(skipped) + rep_items - emit( - result( - TOOL, - "ok", - f"提示物の欄を {len(notes)} 件の PR から書いた{unmerged_note(skipped)}", - items, - { - "version": a.version, - "prs": len(notes), - "lines": len(bullets), - "migration": len(migration), - "approval": str(path), - "unmerged": skipped, - "replaced": gone, - }, - ) - ) + _emit_approval_notes(root, a, path, prs, data, notes, bullets, risks, migration, skipped, rep_items, gone) return items = write_notes(root, a.version, plugin_of(root, a), bullets, migration) + unmerged_items(skipped) + rep_items emit( @@ -1088,15 +1076,48 @@ def cmd_notes(a): "ok", f"{len(notes)} 件の PR の利用者向けの変化を書いた{unmerged_note(skipped)}", items, - { - "version": a.version, - "prs": len(notes), - "lines": len(bullets), - "migration": len(migration), - "fallback": fallback, - "unmerged": skipped, - "replaced": gone, - }, + _notes_metrics(a, notes, bullets, migration, skipped, gone, fallback=fallback), + ) + ) + + +def _notes_metrics(a, notes, bullets, migration, skipped, gone, **extra): + """notes の 2 つの経路に共通の metrics。経路ごとの鍵(approval / fallback)は extra で migration の後へ入れる。""" + return { + "version": a.version, + "prs": len(notes), + "lines": len(bullets), + "migration": len(migration), + **extra, + "unmerged": skipped, + "replaced": gone, + } + + +def _emit_approval_notes(root, a, path, prs, data, notes, bullets, risks, migration, skipped, rep_items, gone): + """承認の資料(--approval)の経路: 資料を集めて提示物の欄を書き、結果を出す。""" + verified = [r for r in (a.verified or "").split(",") if r] + base = release_decl(root, a)[0] + ref = a.ref or base + decl = (base, repo_lib.owner_repo(root) or "", a.version, a.prs) + m = materials.approval_materials( + root, + decl, + getattr(a, "check_state", None), + prs, + data, + lambda r, n: issue_body(root, n) if materials.same_repo(r, decl[1]) else issue_body(root, n, repo=r), + ) + results, machine = materials.check_lines(m), materials.risk_lines(m, bool(risks), materials.now_jst()) + items = materials.write_approval(path, a.version, bullets, risks, verified, ref, migration, results, machine) + items += materials.items_of(m) + unmerged_items(skipped) + rep_items + emit( + result( + TOOL, + "ok", + f"提示物の欄を {len(notes)} 件の PR から書いた{unmerged_note(skipped)}", + items, + _notes_metrics(a, notes, bullets, migration, skipped, gone, approval=str(path)), ) ) @@ -1192,7 +1213,7 @@ def build_parser() -> argparse.ArgumentParser: ) p.add_argument("--plugin", help="配るプラグイン(既定は宣言の release.plugin)") p.add_argument("--approval", help="本番承認の提示物。渡すと「配る中身」「検証への配布で確かめたこと」の欄を書く") - p.add_argument("--verified", default="", help="--approval: 導入を確かめた経路(カンマ区切り。例 claude,codex,kiro)") + p.add_argument("--verified", default="", help="--approval: 導入を確かめた経路(カンマ区切り。例 claude,codex)") p.add_argument("--ref", help="--approval: 検証への配布で導入した ref(既定は宣言のベースブランチ)") p.add_argument( "--check-state", diff --git a/plugins/ndf/scripts/release-verification-steps.py b/plugins/ndf/scripts/release-verification-steps.py index a88fa97bd..eb206e5b5 100755 --- a/plugins/ndf/scripts/release-verification-steps.py +++ b/plugins/ndf/scripts/release-verification-steps.py @@ -2,7 +2,7 @@ """release-verification-steps.py: リリース後テストの導入確認(#862。試作は #827 の phase-steps.py)。 python3 release-verification-steps.py verify-install --ref <ベースブランチ|本番チャネル> --expect <版> - [--plugins <名前>,...] [--runtimes claude,codex,kiro] [--root ] + [--plugins <名前>,...] [--runtimes claude,codex] [--root ] 隔離した HOME で ref から導入し、導入された版と、前の正式版のタグから変わったファイルの中身が ref と一致するかを確かめる。利用者の HOME の設定が変わっていないことも確かめる。 @@ -26,6 +26,7 @@ import sys import tempfile from pathlib import Path +from typing import NamedTuple sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) from step_result import ( @@ -138,7 +139,7 @@ def same_entry(s, t): """s と t の中身が同じか。symlink はリンク先の文字列か、辿った先の中身で比べる。 導入先は symlink をそのまま残すことも、辿った先を複製することもあるため、どちらでも一致とする。 - ディレクトリ(例: dev.agy/skills/<名前> → ../../skills/<名前>)は配下のファイルをすべて比べる。 + ディレクトリは配下のファイルをすべて比べる。 """ s, t = Path(s), Path(t) if s.is_symlink() and t.is_symlink() and os.readlink(s) == os.readlink(t): @@ -164,7 +165,7 @@ def compare_files(src_root, dst, rel_plugin, files, label, keeps_symlinks=True): if not s.exists(): continue # ref で消えたファイル if not keeps_symlinks and s.is_symlink(): - continue # 導入先に入らない(例: dev.agy/skills/<名前> → ../../skills/<名前>) + continue # 導入先に入らない if not same_entry(s, t): out.append(f"{label}: {f}") return out @@ -220,83 +221,6 @@ def verify_codex(env, ref, plugins, expect, where): return res, dirs -def verify_kiro(env, src, tmp, expect): - proj = Path(tmp) / "kiro-project" - proj.mkdir() - res = {"exit": 0, "version": None} - code, out = run_env_i(["bash", str(src / "plugins/ndf/dev.kiro/install.sh"), "--project", str(proj), "--yes"], env, cwd=str(src)) - res["exit"] = code - if code != 0: - res["error"] = out.strip()[-500:] - return res, None - agent = proj / ".kiro" / "agents" / "ndf.json" - if agent.is_file(): - try: - desc = json.loads(agent.read_text(encoding="utf-8")).get("description", "") - except (OSError, ValueError): - desc = "" - m = re.search(r"v(\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?)", desc) - res["version"] = m.group(1) if m else None - return res, proj - - -KIRO_PLUGIN = "plugins/ndf" -KIRO_POLICY = "ndf-policies" - - -def kiro_manifest_skills(src): - """manifests/kiro-skills.txt に載る Skill 名(コメント・空行を除く)。""" - text = (Path(src) / KIRO_PLUGIN / "manifests" / "kiro-skills.txt").read_text(encoding="utf-8") - return sorted({line.split("#", 1)[0].strip() for line in text.splitlines()} - {""}) - - -def kiro_steering_body(policy_file): - """install.sh が steering へ写す ndf-policies の本文(frontmatter を除いた部分)。""" - text = Path(policy_file).read_text(encoding="utf-8") - if text.startswith("---\n"): - end = text.find("\n---\n", 3) - if end != -1: - text = text[end + len("\n---\n") :] - return text.strip("\n") - - -def compare_kiro(src, proj): - """Kiro の導入先(/.kiro)を ref の展開物と比べ、一致しないものを返す。 - - manifest の Skill は .kiro/skills/<名前> のリンク、dev.kiro/prompts/*.md は .kiro/prompts/ の複製、 - ndf-policies は .kiro/steering/ndf-policies.md の本文、agents/ndf.json は JSON として読めることを見る。 - """ - src, kiro = Path(src), Path(proj) / ".kiro" - plugin = src / KIRO_PLUGIN - out = [] - for name in kiro_manifest_skills(src): - s, t = plugin / "skills" / name, kiro / "skills" / name - if not (s / "SKILL.md").is_file(): - continue # installer も SKIP する - if name == KIRO_POLICY: - if t.exists() or t.is_symlink(): - out.append(f"kiro: {t.relative_to(proj)} が残っている(steering へ移した Skill)") - continue - if not same_entry(s, t): - out.append(f"kiro: {t.relative_to(proj)}") - for f in sorted((plugin / "dev.kiro" / "prompts").glob("*.md")): - if f.name == "codex.md": - continue # --with-codex のときだけ配る - t = kiro / "prompts" / f.name - if not (t.is_file() and filecmp.cmp(f, t, shallow=False)): - out.append(f"kiro: {t.relative_to(proj)}") - steering = kiro / "steering" / f"{KIRO_POLICY}.md" - body = kiro_steering_body(plugin / "skills" / KIRO_POLICY / "SKILL.md") - if not (steering.is_file() and steering.read_text(encoding="utf-8").endswith(f"\n{body}\n")): - out.append(f"kiro: {steering.relative_to(proj)}") - agent = kiro / "agents" / "ndf.json" - try: - json.loads(agent.read_text(encoding="utf-8")) - except (OSError, ValueError): - out.append(f"kiro: {agent.relative_to(proj)}") - return out - - def install_source(root, a): """導入元(owner/repo・マーケットプレイスの名前・本番チャネル)と、確かめるプラグイン。値は origin の URL と宣言から読む (#1336 の決定 8)。--ref は宣言のベースブランチか本番チャネルに限る。""" @@ -327,64 +251,86 @@ def _extract_source(root, ref_rev, tmp) -> Path: return src -def _verify_runtimes(a, root, src, tmp, runtimes, plugins, rel, prev, ref_rev, where): +class VerifyContext(NamedTuple): + """verify-install の 1 回で組む値の束(ref・版・前のタグ・対象のプラグインと導入元)。""" + + ref: str + expect: str + root: Path + ref_rev: str + prev: str | None + runtimes: list[str] + plugins: list[str] + rel: dict[str, str] + where: object + + +def _verify_runtimes(ctx: VerifyContext, src, tmp): """ランタイムごとに導入して確かめる。(ランタイムごとの結果, 中身の不一致) を返す。""" runtimes_res, mismatch = {}, [] changed = { - p: ([f for f in git(root, "diff", "--name-only", prev, ref_rev, "--", r).stdout.split() if f] if prev else []) - for p, r in rel.items() + p: ([f for f in git(ctx.root, "diff", "--name-only", ctx.prev, ctx.ref_rev, "--", r).stdout.split() if f] if ctx.prev else []) + for p, r in ctx.rel.items() } env = isolated_env(tmp) for name, fn in (("claude", verify_claude), ("codex", verify_codex)): - if name not in runtimes: + if name not in ctx.runtimes: continue - res, dirs = fn(env, a.ref, plugins, a.expect, where) + res, dirs = fn(env, ctx.ref, ctx.plugins, ctx.expect, ctx.where) runtimes_res[name] = res for p, d in dirs.items(): if d is None: mismatch.append(f"{name}: {p} の導入先が無い") else: - mismatch += compare_files(src, d, rel[p], changed[p], name, keeps_symlinks=(name != "codex")) - if "kiro" in runtimes: - runtimes_res["kiro"], proj = verify_kiro(env, src, tmp, a.expect) - if proj is not None: - mismatch += compare_kiro(src, proj) + mismatch += compare_files(src, d, ctx.rel[p], changed[p], name, keeps_symlinks=(name != "codex")) return runtimes_res, mismatch -def _build_result(a, ref_rev, prev, runtimes_res, mismatch, before, after) -> dict: +def _runtime_result(r, expect) -> str: + """ランタイム 1 件の判定: 導入が失敗 → failed、版が --expect と違う → version_mismatch、それ以外 ok。""" + v = r.get("version") + # mcp-serena などは別の版を持つので、ndf があれば ndf だけを --expect と比べる + vs = ([v.get("ndf")] if "ndf" in v else list(v.values())) if isinstance(v, dict) else [v] + if r.get("exit") != 0: + return "failed" + if any(x != expect for x in vs): + return "version_mismatch" + return "ok" + + +def _env_items(before, after) -> list[dict]: + """利用者の環境の差分を、変わった鍵ごとの項目にする。""" + if before == after: + return [] + return [ + {"kind": "user_env", "name": k, "result": "changed", "before": before[k], "after": after[k]} + for k in before + if before[k] != after[k] + ] + + +def _build_result(ctx: VerifyContext, runtimes_res, mismatch, before, after) -> dict: """ランタイムごとの結果・中身の不一致・利用者の環境の差分から結果をまとめる。""" - items = [] env_same = before == after - if not env_same: - for k in before: - if before[k] != after[k]: - items.append({"kind": "user_env", "name": k, "result": "changed", "before": before[k], "after": after[k]}) + items = _env_items(before, after) ok = env_same and not mismatch for name, r in runtimes_res.items(): - v = r.get("version") - # mcp-serena などは別の版を持つので、ndf があれば ndf だけを --expect と比べる - vs = ([v.get("ndf")] if "ndf" in v else list(v.values())) if isinstance(v, dict) else [v] - res = "ok" - if r.get("exit") != 0: - res = "failed" - elif any(x != a.expect for x in vs): - res = "version_mismatch" + res = _runtime_result(r, ctx.expect) if res != "ok": ok = False items.append({"kind": "runtime", "name": name, "result": res, **r}) items += [{"kind": "file", "name": m, "result": "mismatch"} for m in mismatch] metrics = { - "ref": a.ref, - "rev": ref_rev[:8], - "prev_tag": prev, - "expect": a.expect, + "ref": ctx.ref, + "rev": ctx.ref_rev[:8], + "prev_tag": ctx.prev, + "expect": ctx.expect, "user_env_unchanged": env_same, "mismatch": len(mismatch), } bad_rt = [i["name"] for i in items if i["kind"] == "runtime" and i["result"] != "ok"] summary = ( - f"{a.ref}({ref_rev[:8]})から {', '.join(runtimes_res)} へ導入し v{a.expect} を確かめた" + f"{ctx.ref}({ctx.ref_rev[:8]})から {', '.join(runtimes_res)} へ導入し v{ctx.expect} を確かめた" if ok else f"導入の確認が通らない(runtime: {', '.join(bad_rt) or 'なし'} / 中身の不一致 {len(mismatch)} 件" f" / 利用者の環境が{'変わらない' if env_same else '変わった'})" @@ -396,7 +342,7 @@ def cmd_verify_install(a): root = git_root(a.root) where, plugins = install_source(root, a) runtimes = [r.strip() for r in a.runtimes.split(",") if r.strip()] - bad = [r for r in runtimes if r not in ("claude", "codex", "kiro")] + bad = [r for r in runtimes if r not in ("claude", "codex")] if bad: raise StepError(f"未知の runtime: {','.join(bad)}", EXIT_UNREADABLE) rel = {p: plugin_dir(root, p).relative_to(root).as_posix() for p in plugins} @@ -407,17 +353,18 @@ def cmd_verify_install(a): tags = git(root, "tag", "--list", f"{prefix}*", "--sort=-v:refname").stdout.split() cur = f"{prefix}{a.expect}" prev = next((t for t in tags if t != cur and "-" not in t[len(prefix) :]), None) + ctx = VerifyContext(a.ref, a.expect, root, ref_rev, prev, runtimes, plugins, rel, where) before = user_env_snapshot() tmp = tempfile.mkdtemp(prefix="ndf-verify-install-") try: src = _extract_source(root, ref_rev, tmp) - runtimes_res, mismatch = _verify_runtimes(a, root, src, tmp, runtimes, plugins, rel, prev, ref_rev, where) + runtimes_res, mismatch = _verify_runtimes(ctx, src, tmp) finally: shutil.rmtree(tmp, ignore_errors=True) after = user_env_snapshot() - emit(_build_result(a, ref_rev, prev, runtimes_res, mismatch, before, after)) + emit(_build_result(ctx, runtimes_res, mismatch, before, after)) def build_parser(): @@ -430,7 +377,7 @@ def build_parser(): p.add_argument("--ref", required=True, help="導入する ref(宣言のベースブランチか本番チャネル)") p.add_argument("--expect", required=True, type=version_arg) p.add_argument("--plugins", help="カンマ区切り(例 ndf,mcp-serena。既定は宣言の release.plugin)") - p.add_argument("--runtimes", default="claude,codex,kiro", help="カンマ区切り(claude,codex,kiro)") + p.add_argument("--runtimes", default="claude,codex", help="カンマ区切り(claude,codex)") p.set_defaults(func=cmd_verify_install) return ap diff --git a/plugins/ndf/scripts/release_lib/materials.py b/plugins/ndf/scripts/release_lib/materials.py index 8cedacbb6..4969b8271 100644 --- a/plugins/ndf/scripts/release_lib/materials.py +++ b/plugins/ndf/scripts/release_lib/materials.py @@ -270,7 +270,8 @@ def read_unconfirmed(root: Path, base: str, issues) -> tuple[Unconfirmed, ...] | def read_unverified(bodies: dict[tuple[str, int], str], verdicts: dict, repo: str) -> tuple[Unverified, ...] | None: """閉じる課題((owner/name, 番号))の受け入れ条件のうち、印が無く、リリース後テストの表(verdicts)にその - リポジトリの課題として合格の行が無いもの。どの課題にも受け入れ条件が無ければ None。""" + リポジトリの課題として合格の行が無いもの。本文の `## 受け入れ条件の確認` でマージ前に確かめたと記録された + 条件は載せない(#2144。記録の無い課題は今までどおり)。どの課題にも受け入れ条件が無ければ None。""" out, found = [], False for (owner, n), body in bodies.items(): rows = acceptance.criteria_lines(body or "") @@ -282,7 +283,8 @@ def read_unverified(bodies: dict[tuple[str, int], str], verdicts: dict, repo: st continue passed = v.passed if v is not None else frozenset() other = None if same_repo(owner, repo) else owner - out += [Unverified(n, c.number, text, other) for c, text in rows if not c.checked and c.number not in passed] + done = passed | acceptance.pre_merge_numbers(body or "") + out += [Unverified(n, c.number, text, other) for c, text in rows if not c.checked and c.number not in done] return tuple(out) if found else None diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index 601b07573..5a23794ad 100755 --- a/plugins/ndf/scripts/sprint-close.py +++ b/plugins/ndf/scripts/sprint-close.py @@ -1,19 +1,25 @@ #!/usr/bin/env python3 """sprint-close.py: スプリントを閉じる(progress-tracking の「スプリントを閉じる」の手順)。 - python3 sprint-close.py --record-pr [--prs 1,2] [--issues 3,4] [--with-verification] + python3 sprint-close.py --record-pr [--final-stage 配布|リリース後テスト|振り返り] + [--prs 1,2] [--issues 3,4] [--with-verification] [--label "<マイルストーン>の<工程名>"] [--dry-run] [--root ] 1. 記録の PR の本文とコメントを読み、最後の「## 配布の記録」ブロックを取る 2. スプリントの PR の一覧(--prs、無ければブロックの `スプリント:` の行)の本文から、closing keywords - (GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)が指す課題を集める + (GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)と閉じる予定の参照 + (`最終工程の後に閉じる: #N`。既定ブランチ宛の本文で closing keywords を置き換えた形)が指す課題を集める (lib/closing-issues.sh)。--issues の課題(記録のリポジトリ)を足す(`pace: fast` の実装 PR は closing keywords を持たない)。PR を読んだのに集めた課題が 0 件なら、何も書かずに止まる(終了コード 2) -3. 閉じる条件 1(本番への配布まで済んだ、または配布なし)と、--with-verification のときは +3. 閉じる条件 1(本番への配布まで済んだ、または配布なし)と、--with-verification か最終工程がリリース後テストのときは 条件 2(その課題のリリース後テストの行がすべて合格で、本文の受け入れ条件の番号のすべてに合格の行がある) を課題ごとに判定する。`課題` の列が課題を指さない行(`—`・`PR 1869` など)は飛ばす。条件 2 は課題ごとの錠 - (lib/gh_parts.py の issue_lock)の中で状態と本文を 1 回で読んで判定し、満たした記録のリポジトリの課題は - 本文の受け入れ条件に印(`[x]`)を付けてから閉じる(規則は lib/acceptance.py。#1683) + (lib/gh_parts.py の issue_lock)の中で状態と本文を 1 回で読んで判定し(本文の `## 受け入れ条件の確認` で + マージ前に確かめたと記録された条件には合格の行を求めない。#2144)、満たした記録のリポジトリの課題は + 本文の受け入れ条件に印(`[x]`)を付けてから閉じる(規則は lib/acceptance.py。#1683)。 + 最後に条件 3(最終工程の記録。--final-stage、省けば振り返り)を判定する。振り返りは課題の本文の + `振り返り: <コメントの URL>` の行のうち、コメントの識別子が記録の PR の最後の配布の記録のコメントの識別子以上の + ものがあること(final_stage_gap。#2228)。条件 2 と同じ 1 回の本文の読みを使い、満たさなければ印も書かない 4. 条件を満たす課題ごとに (a) 状態を読む → (b) 記録のリポジトリの課題ならボードを Done → (c) 読み直して OPEN なら閉じる → (d) 読み直して CLOSED を確かめる 5. スプリントの PR の題と本文が番号で参照し、2 で集めた課題に入らない開いた課題(参照だけの課題、 @@ -24,9 +30,9 @@ closed / already_closed / failed / kept_open / closed_unverified(既に閉じていたが条件 2 を満たさない。 開き直さない)(--dry-run では would_close)。checked は印を付けた受け入れ条件の番号、would_check は --dry-run で付ける予定の番号。ボードへ Done を書けなかった(`projects-sync.sh` の結果 JSON が ok でない)ときは -{kind:"board_note"} の項目に理由が載り、課題を閉じる処理は続ける。 +{kind:"board_note"} の項目に理由が載り、課題を閉じる処理は続ける。metrics.final_stage は判定に使った最終工程。 `--record-pr 0` は「本番の記録なし」(最終の検査で変更が無く本番を飛ばした)。配布の記録を読まず、 -閉じる条件も見ずに --issues の課題を閉じる。--issues と一緒のときだけ受ける。 +閉じる条件 1・2 を見ずに --issues の課題を閉じる(条件 3 の振り返りは、比べる配布の記録が無いため行があれば満たす)。--issues と一緒のときだけ受ける。 `--record-pr unknown` は「記録の置き場が分からない」(リリースの経路が雛形でも昇格でもなく、まとめのプランが 記録の PR を決められない)。配布の記録を読まず、どの課題も閉じず、ボードも書かずに --issues の課題を理由つきの kept_open で返す(終了コード 0)。--issues と一緒のときだけ受ける。 @@ -39,7 +45,6 @@ import argparse import json import shlex -import subprocess import sys from contextlib import nullcontext from pathlib import Path @@ -65,32 +70,27 @@ import gh_rest # noqa: E402 import locks # noqa: E402 import step_read # noqa: E402 projects-sync.sh の結果 JSON を読む(#2227) -from closing import referenced_numbers # noqa: E402 from dist_record import parse_record # noqa: E402 記録の形は lib/dist_record.py が持つ(#1273) +sys.path.insert(0, str(HERE)) +from sprint_close_lib.collect import read_record, referenced_items, sprint_issues # noqa: E402 +from sprint_close_lib.final_stage import ( # noqa: E402 閉じる条件 3(#2228) + FINAL_GAP_LEAD, + FINAL_STAGES, + NO_BASE, + STAGE_RETRO, + STAGE_VERIFY, + final_stage_gap, + gap_item, +) + TOOL = "sprint-close" RECORD_UNKNOWN = "unknown" # --record-pr の「記録の置き場が分からない」(supervise_lib/sprint_routes.py が渡す。#1875) UNKNOWN_REASON = "配布の記録の置き場が分からない(リリースの経路が雛形でも昇格でもない。リリース記録を確かめて手で閉じる)" LOCK_TIMEOUT = 30 # 課題ごとの錠の待ち(body-lock の既定と同じ) -# --- 記録の読み取り ------------------------------------------------------------- - - -def read_record(root, repo, n): - """記録の PR の本文とコメントを投稿の順に 1 つの文字列にする。""" - p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "body,comments"], cwd=root) - if p.returncode != 0: - raise StepError(f"記録の PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) - try: - d = json.loads(p.stdout) - except ValueError: - raise StepError(f"記録の PR #{n} の出力を読めない", EXIT_UNREADABLE) - parts = [d.get("body") or ""] + [(c or {}).get("body") or "" for c in d.get("comments") or []] - return "\n".join(parts) - - IssueRows = acceptance.IssueRows # リリース後テストの表は承認の材料と同じ規則で読む(lib/acceptance.py。#1885 の決定 8) verification_verdicts = acceptance.verification_verdicts @@ -98,59 +98,27 @@ def read_record(root, repo, n): NO_ROWS = "リリース後テストの行が無い" NOT_PASSED = "リリース後テストに合格でない条件がある(不合格・保留)" UNVERIFIED = "閉じているが閉じる条件 2 を満たさない: " +UNREADABLE_BODY = "本文を読めない(最終工程の記録を確かめられない)" +NO_MAPPING = "条件と課題の対応が読めない" -def judge_acceptance(rows, criteria): +def judge_acceptance(rows, criteria, pre_merge=frozenset()): """閉じる条件 2 の判定。(満たすか, 満たさない理由, 印を付ける番号) を返す。 - 受け入れ条件が 0 個なら表の行の合否だけで決める。""" + 受け入れ条件が 0 個なら表の行の合否だけで決める。pre_merge(本文の `## 受け入れ条件の確認` でマージ前に + 確かめたと記録された番号。#2144)の条件には合格の行を求めない。すべての条件がそれなら行が無くても満たす。""" + if rows is None and criteria and all(c.number in pre_merge for c in criteria): + return True, None, [c.number for c in criteria if not c.checked] if rows is None: return False, NO_ROWS, [] if not rows.all_pass: return False, NOT_PASSED, [] - missing = [c.number for c in criteria if c.number not in rows.passed] + missing = [c.number for c in criteria if c.number not in rows.passed and c.number not in pre_merge] if missing: return False, "合格の行が無い受け入れ条件: " + ", ".join(map(str, missing)), [] return True, None, [c.number for c in criteria if not c.checked] -# --- 課題の収集 ----------------------------------------------------------------- - - -def _read_sprint_pr(root, repo, n): - """スプリントの PR の題と本文を 1 回で読む。""" - p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "title,body"], cwd=root) - if p.returncode != 0: - raise StepError(f"スプリントの PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) - try: - d = json.loads(p.stdout) - except ValueError: - raise StepError(f"スプリントの PR #{n} の出力を読めない", EXIT_UNREADABLE) - return d.get("title") or "", d.get("body") or "" - - -def sprint_issues(root, repo, prs): - """(closing keywords が指す課題, 題と本文が参照する記録のリポジトリの番号) を返す。 - - closing keywords は本文だけから読む(GitHub は題の closing keywords で課題を閉じない)。""" - seen, out, refs = set(), [], [] - for n in prs: - title, body = _read_sprint_pr(root, repo, n) - refs += [r for r in referenced_numbers(title + "\n" + body, repo) if r not in refs] - c = subprocess.run( - ["bash", str(HERE / "lib" / "closing-issues.sh"), "--repo", repo], cwd=root, input=body, capture_output=True, text=True - ) - for ln in c.stdout.splitlines(): - if "\t" not in ln: - continue - r, num = ln.split("\t", 1) - key = (r.strip(), int(num.strip())) - if key not in seen: - seen.add(key) - out.append(key) - return out, refs - - # --- 閉じる --------------------------------------------------------------------- @@ -160,17 +128,34 @@ def issue_state(root, repo, n): class CloseContext: - """全課題で一定の閉じる処理の材料。notes は課題ごとのボードの記録の失敗を溜める。retry_cmd は今の引数で組んだやり直しの cmd。""" + """全課題で一定の閉じる処理の材料。notes は課題ごとのボードの記録の失敗を溜める。retry_cmd は今の引数で組んだやり直しの cmd。 + final_stage と since は条件 3(final_stage_gap)の材料。""" - def __init__(self, root, record_repo, comment, argv=()): + def __init__(self, root, record_repo, comment, argv=(), final_stage=STAGE_RETRO, since=None): self.root, self.record_repo, self.comment = root, record_repo, comment + self.final_stage, self.since = final_stage, since # 条件 3 の最終工程と、振り返りの行を比べる基準 self.notes = [] self.retry_cmd = shlex.join(["python3", str(Path(__file__).resolve()), *argv]) +def _issue_base(repo, n): + """課題 1 件の結果の項目の共通部分。""" + return {"kind": "issue", "repo": repo, "number": n} + + +def _failed(base, reason, ctx): + """やり直しの cmd を添えた failed の項目。""" + return {**base, "result": "failed", "reason": reason, "cmd": ctx.retry_cmd} + + +def _dry_run_result(state): + """試行(dry_run)の結果: CLOSED なら already_closed、それ以外は would_close。""" + return "already_closed" if state == "CLOSED" else "would_close" + + def close_one(ctx, repo, n): root, record_repo, comment, notes = ctx.root, ctx.record_repo, ctx.comment, ctx.notes - it = {"kind": "issue", "repo": repo, "number": n} + it = _issue_base(repo, n) before = issue_state(root, repo, n) if before is None: return {**it, "result": "failed", "reason": "before を読めない", "cmd": f"gh issue close {n} --repo {repo}"} @@ -223,7 +208,7 @@ def _read_sprint_record(a, root, record_repo): return {"found": True, "stage": "配布なし(本番の記録なし)", "sprint_prs": [], "verify_block": None}, [] if a.record_pr == RECORD_UNKNOWN: return {"found": False, "stage": "記録の置き場が分からない", "sprint_prs": [], "verify_block": None}, [] - rec = parse_record(read_record(root, record_repo, a.record_pr)) + rec = parse_record(*read_record(root, record_repo, a.record_pr)) return rec, a.prs or rec["sprint_prs"] @@ -239,7 +224,8 @@ def _closing_blocker(a, rec, record_repo): elif not (stage.startswith("本番") or stage.startswith("配布なし")): kept_all = "本番への配布の前(本番への配布の後に閉じる)" verdicts = None - if kept_all is None and a.with_verification and a.record_pr != 0: + verify = a.with_verification or a.final_stage == STAGE_VERIFY # 最終工程がリリース後テストなら必ず見る(I4) + if kept_all is None and verify and a.record_pr != 0: blk = rec["verify_block"] if blk is None: kept_all = ( @@ -248,7 +234,9 @@ def _closing_blocker(a, rec, record_repo): else: verdicts = verification_verdicts(blk, record_repo) if verdicts is None: - kept_all = "条件と課題の対応が読めない" + kept_all = NO_MAPPING + if acceptance.verdict_table_readable(blk): + verdicts = {} # 課題を指す行が無い表: すべての条件をマージ前に確かめた課題だけ判定へ進める(#2144) return kept_all, verdicts @@ -264,38 +252,46 @@ def _read_state_body(root, repo, n): return d["state"], d.get("body") or "" -def _judge_and_mark(a, ctx, repo, n, rows): - """錠の中で状態と本文を読み、条件 2 を判定し、満たせば印を書く。(結果の項目か None, 印を付けた番号) を返す。""" - base = {"kind": "issue", "repo": repo, "number": n} +def _judge_and_mark(a, ctx, repo, n, rows, kept=None): + """錠の中で状態と本文を読み、条件 2 と条件 3 を判定し、満たせば印を書く。(結果の項目か None, 印を付けた番号) を返す。 + + kept は課題を指す行の無い表の理由(NO_MAPPING)。そのときはすべての条件がマージ前の課題だけを判定へ進め、 + 本文を読めない課題とそれ以外の課題は kept の kept_open にする(同じ 1 回の本文の読みで決める。#2144)。""" + base = _issue_base(repo, n) got = _read_state_body(ctx.root, repo, n) if got is None: - return {**base, "result": "failed", "reason": "本文を読めない", "cmd": ctx.retry_cmd}, [] + return ({**base, "result": "kept_open", "reason": kept} if kept else _failed(base, UNREADABLE_BODY, ctx)), [] state, body = got - ok, why, to_check = judge_acceptance(rows, acceptance.criteria(body)) + ok, why, to_check = judge_acceptance(rows, acceptance.criteria(body), acceptance.pre_merge_numbers(body)) own = repo == ctx.record_repo # 他のリポジトリの課題の本文は書かない(I7) + if not ok and kept: + return {**base, "result": "kept_open", "reason": kept}, [] if not ok: if state == "CLOSED": return {**base, "result": "closed_unverified", "reason": UNVERIFIED + why}, [] return {**base, "result": "kept_open", "reason": why}, [] + gap = final_stage_gap(ctx.final_stage, body, ctx.since) + if gap: # 条件 3 を満たさない課題の本文は書かない + return gap_item(base, state, gap), [] if a.dry_run: - out = {**base, "result": "already_closed" if state == "CLOSED" else "would_close"} + out = {**base, "result": _dry_run_result(state)} return ({**out, "would_check": to_check} if own and to_check else out), [] if not (own and to_check): return None, [] new = acceptance.check_criteria(body, to_check) if not acceptance.only_marks(body, new) or not gh_rest.update_body(repo, n, new): - return {**base, "result": "failed", "reason": "受け入れ条件の印を書けない", "cmd": ctx.retry_cmd}, [] + return _failed(base, "受け入れ条件の印を書けない", ctx), [] return None, to_check -def _close_verified(a, ctx, repo, n, rows): +def _close_verified(a, ctx, repo, n, rows, kept=None): """--with-verification の 1 課題。本文の読みから書きまでを課題ごとの錠の中で行い、閉じるのは錠の外で行う。""" lock = gh_parts.issue_lock(repo, n, LOCK_TIMEOUT) if repo == ctx.record_repo and not a.dry_run else nullcontext() try: with lock: - done, checked = _judge_and_mark(a, ctx, repo, n, rows) + done, checked = _judge_and_mark(a, ctx, repo, n, rows, kept) except locks.LockTimeout: - return {"kind": "issue", "repo": repo, "number": n, "result": "failed", "reason": "本文の錠を取れない", "cmd": ctx.retry_cmd} + return _failed(_issue_base(repo, n), "本文の錠を取れない", ctx) if done is not None: return done out = close_one(ctx, repo, n) @@ -303,40 +299,33 @@ def _close_verified(a, ctx, repo, n, rows): def _close_item(a, ctx, repo, n, kept_all, verdicts): - base = {"kind": "issue", "repo": repo, "number": n} + base = _issue_base(repo, n) + if kept_all == NO_MAPPING and verdicts == {}: # 課題を指す行の無い表: 判定は錠の中の 1 回の本文の読みで行う + return _close_verified(a, ctx, repo, n, None, kept_all) if kept_all: return {**base, "result": "kept_open", "reason": kept_all} if verdicts is not None: return _close_verified(a, ctx, repo, n, verdicts.get((repo, n))) + if ctx.final_stage == STAGE_RETRO: + got = _read_state_body(ctx.root, repo, n) + if got is None: # 記録の置き場を読めないときは記録があるとは読まない(I2) + return _failed(base, UNREADABLE_BODY, ctx) + state, body = got + gap = final_stage_gap(ctx.final_stage, body, ctx.since) + if gap: + return gap_item(base, state, gap) + if a.dry_run: + return {**base, "result": _dry_run_result(state)} if a.dry_run: st = issue_state(ctx.root, repo, n) return { **base, - "result": "already_closed" if st == "CLOSED" else "would_close", + "result": _dry_run_result(st), **({"reason": "状態を読めない"} if st is None else {}), } return close_one(ctx, repo, n) -NOT_GIVEN = "closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)も --issues も無い参照" - - -def _referenced_items(root, repo, refs, issues): - """参照だけの課題の行。1 番号につき状態と URL を 1 回読み、Pull Request と閉じた課題は行にしない。""" - items = [] - for n in refs: - if (repo, n) in issues: - continue - p = gh_call.gh(["issue", "view", str(n), "--repo", repo, "--json", "state,url", "-q", "[.state, .url] | @tsv"], cwd=root) - state, _, url = p.stdout.strip().partition("\t") - base = {"kind": "issue", "repo": repo, "number": n, "result": "kept_open"} - if p.returncode != 0 or not state: - items.append({**base, "reason": f"状態を読めない({NOT_GIVEN})"}) - elif "/pull/" not in url and state != "CLOSED": - items.append({**base, "reason": NOT_GIVEN}) - return items - - def _close_summary(items, prs, dry_run, referenced): kinds = ("closed", "already_closed", "failed", "closed_unverified", "kept_open", "would_close") count = {k: sum(1 for i in items if i["result"] == k) for k in kinds} @@ -354,74 +343,85 @@ def _close_summary(items, prs, dry_run, referenced): def _stop_no_issues(prs, refs): """スプリントの PR を読んだのに閉じる課題が 0 件。課題もボードも書かずに止まる(参照の状態は読まない)。""" seen = f"(PR が参照した番号: {' '.join(f'#{n}' for n in refs)})" if refs else "" + _stop_close( + f"閉じる課題が 0 件(スプリントの PR {len(prs)} 本に closing keywords(GitHub が課題を自動で閉じるキーワード" + " Closes / Fixes / Resolves)が無く、--issues も無い)", + f"閉じる課題を --issues で渡して打ち直す{seen}", + {"issues": 0, "prs": len(prs)}, + ) + + +def _stop_close(summary, nxt, metrics=None): + """閉じる前に止まる(items は空・終了コードは EXIT_UNREADABLE)。""" emit( - result( - TOOL, - "stopped", - f"閉じる課題が 0 件(スプリントの PR {len(prs)} 本に closing keywords(GitHub が課題を自動で閉じるキーワード" - " Closes / Fixes / Resolves)が無く、--issues も無い)", - [], - {"issues": 0, "prs": len(prs)}, - next=f"閉じる課題を --issues で渡して打ち直す{seen}", - ), + result(TOOL, "stopped", summary, [], {"issues": 0} if metrics is None else metrics, next=nxt), EXIT_UNREADABLE, ) -def cmd_close(a): - if a.record_pr in (0, RECORD_UNKNOWN) and not a.issues: - what = "0(本番の記録なし)" if a.record_pr == 0 else f"{RECORD_UNKNOWN}(記録の置き場が分からない)" +def _collect_close_issues(a, root, record_repo, prs): + """閉じる課題(スプリントの PR が閉じる課題と --issues)と、PR が参照しただけの番号を集める。0 件なら止まる。""" + given = [(record_repo, n) for n in a.issues or []] + if not prs and not given: + _stop_close("スプリントの PR の一覧が取れない。推測せず運用者に一覧を聞く", "運用者に一覧を聞き、--prs で渡して打ち直す") + issues, refs = sprint_issues(root, record_repo, prs) if prs else ([], []) + issues += [k for k in given if k not in issues] + if not issues: + _stop_no_issues(prs, refs) + return issues, refs + + +def _emit_close_result(a, items, notes, metrics, summary, count, decisions): + """結果を出す: 失敗があれば止める → 記録の置き場が分からなければ閉じない旨 → 通常(最終工程の記録の欠けを案内)。""" + if count["failed"]: emit( result( TOOL, "stopped", - f"--record-pr {what} は --issues と一緒に渡す", - [], - {"issues": 0}, - next="閉じる課題を --issues で渡して打ち直す", - ), - EXIT_UNREADABLE, + summary, + items + notes, + metrics, + next="失敗した課題の cmd でやり直す。棚卸しへ進まない", + decisions=decisions, + ) ) + if a.record_pr == RECORD_UNKNOWN: + summary = f"記録の置き場が分からないため閉じない(リリースの経路が雛形でも昇格でもない): {summary}" + nxt = "リリース記録とリリース後テストの記録を確かめ、受け入れ条件を満たした課題を手で閉じる" + emit(result(TOOL, "ok", summary, items + notes, metrics, next=nxt, decisions=decisions)) + nxt = None + if any(FINAL_GAP_LEAD in (i.get("reason") or "") for i in items): + nxt = "最終工程の記録が無い課題は、その工程(振り返りなど)を通してから打ち直す。前の版で工程を通った課題は確かめて手で閉じる" + emit(result(TOOL, "ok", summary, items + notes, metrics, next=nxt, decisions=decisions)) + + +def cmd_close(a): + if a.record_pr in (0, RECORD_UNKNOWN) and not a.issues: + what = "0(本番の記録なし)" if a.record_pr == 0 else f"{RECORD_UNKNOWN}(記録の置き場が分からない)" + _stop_close(f"--record-pr {what} は --issues と一緒に渡す", "閉じる課題を --issues で渡して打ち直す") root = git_root(a.root) record_repo = _record_repo(a, root) - given = [(record_repo, n) for n in a.issues or []] rec, prs = _read_sprint_record(a, root, record_repo) - if not prs and not given: - emit( - result( - TOOL, - "stopped", - "スプリントの PR の一覧が取れない。推測せず運用者に一覧を聞く", - [], - {"issues": 0}, - next="運用者に一覧を聞き、--prs で渡して打ち直す", - ), - EXIT_UNREADABLE, - ) - issues, refs = sprint_issues(root, record_repo, prs) if prs else ([], []) - issues += [k for k in given if k not in issues] - if not issues: - _stop_no_issues(prs, refs) + issues, refs = _collect_close_issues(a, root, record_repo, prs) kept_all, verdicts = _closing_blocker(a, rec, record_repo) items = [] comment = f"スプリント({a.label})を通りました" if a.label else "スプリントの終わりの工程を通りました" - ctx = CloseContext(root, record_repo, comment, a.argv) + since = NO_BASE if a.record_pr == 0 else rec.get("dist_comment_id") + ctx = CloseContext(root, record_repo, comment, a.argv, a.final_stage, since) notes = ctx.notes for repo, n in issues: items.append(_close_item(a, ctx, repo, n, kept_all, verdicts)) - refs_items = _referenced_items(root, record_repo, refs, issues) + refs_items = referenced_items(root, record_repo, refs, issues) items += refs_items count, metrics, summary = _close_summary(items, prs, a.dry_run, referenced=len(refs_items)) - if count["failed"]: - emit(result(TOOL, "stopped", summary, items + notes, metrics, next="失敗した課題の cmd でやり直す。棚卸しへ進まない")) - if a.record_pr == RECORD_UNKNOWN: - summary = f"記録の置き場が分からないため閉じない(リリースの経路が雛形でも昇格でもない): {summary}" - nxt = "リリース記録とリリース後テストの記録を確かめ、受け入れ条件を満たした課題を手で閉じる" - emit(result(TOOL, "ok", summary, items + notes, metrics, next=nxt)) - emit(result(TOOL, "ok", summary, items + notes, metrics)) + metrics["final_stage"] = a.final_stage + decisions = [ + {"decision": f"最終工程を {a.final_stage} として判定した", "basis": f"--final-stage {a.final_stage}(省略時は{STAGE_RETRO})"} + ] + _emit_close_result(a, items, notes, metrics, summary, count, decisions) class Parser(argparse.ArgumentParser): @@ -467,6 +467,12 @@ def build_parser(): ) ap.add_argument("--repo", help="記録のリポジトリ(owner/name)。省けば gh repo view で決める") ap.add_argument("--with-verification", action="store_true", help="リリース後テストを通る経路(閉じる条件 2 を見る)") + ap.add_argument( + "--final-stage", + choices=FINAL_STAGES, + default=STAGE_RETRO, + help="その実行の最終工程(閉じる条件 3)。省けば振り返り。リリース後テストは条件 2 を必ず見る", + ) ap.add_argument("--label", help="閉じるときのコメントに入れる「<マイルストーン>の<工程名>」") ap.add_argument("--dry-run", action="store_true", help="状態を読むだけで、ボードも課題も書かない") ap.add_argument("--root", help="対象のリポジトリの根(既定はカレントの git の根)") diff --git a/plugins/ndf/scripts/sprint_close_lib/__init__.py b/plugins/ndf/scripts/sprint_close_lib/__init__.py new file mode 100644 index 000000000..73217df0e --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/__init__.py @@ -0,0 +1 @@ +"""sprint-close.py から分けたモジュール。""" diff --git a/plugins/ndf/scripts/sprint_close_lib/collect.py b/plugins/ndf/scripts/sprint_close_lib/collect.py new file mode 100644 index 000000000..845f96125 --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/collect.py @@ -0,0 +1,86 @@ +"""記録の PR とスプリントの PR の読み取りと、閉じる課題・参照だけの課題の集め方(`sprint-close.py` から分けた)。 + +記録の形は `lib/dist_record.py`、closing keywords の読み取りは `lib/closing.py` が持つ。 +出す(emit)のは呼び手である。 +""" + +from __future__ import annotations + +import json +import re + +import gh_call +from closing import closing_refs, referenced_numbers +from step_result import EXIT_UNREADABLE, StepError + +_COMMENT_ID = re.compile(r"#issuecomment-(\d+)$") + + +def read_record(root, repo, n): + """記録の PR の本文とコメントを投稿の順に 1 つの文字列にする。 + + (連結した文字列, 部分ごとの (先頭の行の番号, コメントの識別子) の並び) を返す。本文の識別子は None、コメントの + 識別子は `comments[].url` の `#issuecomment-<識別子>` の数字(読めなければ None)。""" + p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "body,comments"], cwd=root) + if p.returncode != 0: + raise StepError(f"記録の PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) + try: + d = json.loads(p.stdout) + except ValueError: + raise StepError(f"記録の PR #{n} の出力を読めない", EXIT_UNREADABLE) + comments = [c or {} for c in d.get("comments") or []] + parts = [(d.get("body") or "", None)] + for c in comments: + m = _COMMENT_ID.search(c.get("url") or "") + parts.append((c.get("body") or "", int(m.group(1)) if m else None)) + starts, line = [], 0 + for text, cid in parts: + starts.append((line, cid)) + line += len(text.split("\n")) + return "\n".join(t for t, _ in parts), starts + + +def _read_sprint_pr(root, repo, n): + """スプリントの PR の題と本文を 1 回で読む。""" + p = gh_call.gh(["pr", "view", str(n), "--repo", repo, "--json", "title,body"], cwd=root) + if p.returncode != 0: + raise StepError(f"スプリントの PR #{n} を読めない: {p.stderr.strip()[:300]}", EXIT_UNREADABLE) + try: + d = json.loads(p.stdout) + except ValueError: + raise StepError(f"スプリントの PR #{n} の出力を読めない", EXIT_UNREADABLE) + return d.get("title") or "", d.get("body") or "" + + +def sprint_issues(root, repo, prs): + """(closing keywords が指す課題, 題と本文が参照する記録のリポジトリの番号) を返す。 + + closing keywords は本文だけから読む(GitHub は題の closing keywords で課題を閉じない)。""" + seen, out, refs = set(), [], [] + for n in prs: + title, body = _read_sprint_pr(root, repo, n) + refs += [r for r in referenced_numbers(title + "\n" + body, repo) if r not in refs] + for key in closing_refs(body, repo): + if key not in seen: + seen.add(key) + out.append(key) + return out, refs + + +NOT_GIVEN = "closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)も --issues も無い参照" + + +def referenced_items(root, repo, refs, issues): + """参照だけの課題の行。1 番号につき状態と URL を 1 回読み、Pull Request と閉じた課題は行にしない。""" + items = [] + for n in refs: + if (repo, n) in issues: + continue + p = gh_call.gh(["issue", "view", str(n), "--repo", repo, "--json", "state,url", "-q", "[.state, .url] | @tsv"], cwd=root) + state, _, url = p.stdout.strip().partition("\t") + base = {"kind": "issue", "repo": repo, "number": n, "result": "kept_open"} + if p.returncode != 0 or not state: + items.append({**base, "reason": f"状態を読めない({NOT_GIVEN})"}) + elif "/pull/" not in url and state != "CLOSED": + items.append({**base, "reason": NOT_GIVEN}) + return items diff --git a/plugins/ndf/scripts/sprint_close_lib/final_stage.py b/plugins/ndf/scripts/sprint_close_lib/final_stage.py new file mode 100644 index 000000000..e5e274684 --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/final_stage.py @@ -0,0 +1,41 @@ +"""閉じる条件 3(最終工程の記録。#2228)の判定(`sprint-close.py` から分けた)。 + +配布とリリース後テストの記録は条件 1・2 が見るため、ここは振り返りの記録だけを判定する。出す(emit)のは呼び手である。 +""" + +from __future__ import annotations + +import re + +UNVERIFIED_FINAL = "閉じているが閉じる条件 3 を満たさない: " +STAGE_DIST, STAGE_VERIFY, STAGE_RETRO = "配布", "リリース後テスト", "振り返り" +FINAL_STAGES = (STAGE_DIST, STAGE_VERIFY, STAGE_RETRO) +NO_BASE = 0 # --record-pr 0 の比べる基準(配布の記録が無いため、どの識別子も数える) +_RETRO_LINE = re.compile(r"^振り返り:\s*https://github\.com/[\w.-]+/[\w.-]+/(?:issues|pull)/\d+#issuecomment-(\d+)\s*$", re.M) +FINAL_GAP = "最終工程({stage})の記録が無い: {what}" +FINAL_GAP_LEAD = "最終工程(" + + +def final_stage_gap(stage, body, since): + """最終工程の記録が無ければ、何が無いかを含む理由を返す。あれば None。 + + 配布とリリース後テストの記録は条件 1・2 が見るため、ここでは振り返りだけを判定する。振り返りは本文の + `振り返り: <コメントの URL>` の行のうち、識別子が since(記録の PR の最後の配布の記録のコメントの識別子)以上の + ものを数える。since が None なら比べる基準が無いため満たさない。""" + if stage != STAGE_RETRO: + return None + if since is None: + return FINAL_GAP.format(stage=stage, what="比べる配布の記録のコメントが無い") + ids = [int(x) for x in _RETRO_LINE.findall(body or "")] + if not ids: + return FINAL_GAP.format(stage=stage, what="課題の本文に振り返りの記録の行が無い") + if max(ids) < since: + return FINAL_GAP.format(stage=stage, what="課題の本文の振り返りの行が今回の配布の記録より前のもの") + return None + + +def gap_item(base, state, gap): + """条件 3 を満たさない課題の項目。閉じていれば開き直さない。""" + if state == "CLOSED": + return {**base, "result": "closed_unverified", "reason": UNVERIFIED_FINAL + gap} + return {**base, "result": "kept_open", "reason": gap} diff --git a/plugins/ndf/scripts/supervise_lib/claude.py b/plugins/ndf/scripts/supervise_lib/claude.py index 0822ee16c..770f6dbbe 100644 --- a/plugins/ndf/scripts/supervise_lib/claude.py +++ b/plugins/ndf/scripts/supervise_lib/claude.py @@ -77,10 +77,8 @@ def kill_group(p: subprocess.Popen) -> None: """子をプロセスグループごと止める(shell=True のステップの孫も残さない。lib/procs.py)。止まらなければ 5 秒で見切る。""" procs.stop_tree(p.pid, grace=0) # 先頭が先に終わった(ゾンビの)グループの残りを procs.stop_tree は止めない。孫を残さないためグループへも送る - try: + with contextlib.suppress(OSError): os.killpg(p.pid, signal.SIGKILL) - except OSError: - pass try: p.communicate(timeout=5) except (subprocess.TimeoutExpired, ValueError, OSError): @@ -102,7 +100,9 @@ def run_ticking( err_path を渡すと stderr をそのファイルへ書かせる(待ちの間に最後の行を読めるように)。 子は新しいセッションで起こし、`watch_children` の知らせる先へ渡す(孤児の片付け)。打ち切りは子のプロセスグループを止めて subprocess.TimeoutExpired を投げる。 - tick() が例外を投げたら(遅れの見張りの打ち切り)、子のプロセスグループを止めてから投げ直す。""" + tick() が例外を投げたら(遅れの見張りの打ち切り)、子のプロセスグループを止めてから投げ直す。 + kw の extend(開始の時刻) は待ちの周期ごとに呼び、返した時刻が締め切りより後なら締め切りを後ろへずらす(#2047 の I7)。""" + extend = kw.pop("extend", None) with open(err_path, "w", encoding="utf-8") if err_path else contextlib.nullcontext() as errf: # 閉じ済みでも閉じ直してよい p = subprocess.Popen( cmd, @@ -118,13 +118,13 @@ def run_ticking( note(p.pid, True) deadline = time.time() + timeout if timeout else None try: - return _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout) + return _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout, extend) finally: if note: note(p.pid, False) -def _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout) -> subprocess.CompletedProcess: +def _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd, timeout, extend=None) -> subprocess.CompletedProcess: """every 秒ごとに tick() を呼びながら p の終わりを待つ。打ち切りと tick() の例外では子のプロセスグループを止める。""" first = True while True: @@ -137,6 +137,8 @@ def _communicate_with_ticks(p, errf, err_path, every, deadline, input, tick, cmd return subprocess.CompletedProcess(cmd, p.returncode, out, err) except subprocess.TimeoutExpired: first = False + if extend and deadline is not None and (later := extend(deadline - timeout)) and later > deadline: + deadline, timeout = later, timeout + later - deadline if deadline is not None and time.time() >= deadline: kill_group(p) raise subprocess.TimeoutExpired(cmd, timeout) from None diff --git a/plugins/ndf/scripts/supervise_lib/pr.py b/plugins/ndf/scripts/supervise_lib/pr.py index 41a6d6f2f..6e8140ad6 100644 --- a/plugins/ndf/scripts/supervise_lib/pr.py +++ b/plugins/ndf/scripts/supervise_lib/pr.py @@ -13,6 +13,7 @@ import mdtable from pr_mode import with_mode_line from release_lib.others import MIGRATION_HEADING +from repo import defer_for_base from supervise_lib.claude import TAIL from supervise_lib.paths import DECISIONS_SH from supervise_lib.pr_materials import CHANGES_HEADING, Materials, gather_materials @@ -317,10 +318,32 @@ def _llm_body(self, ctx, step: dict, body: str, changes: str, issues: str, mats: return body def _publish(self, ctx, base: str, branch: str, title: str, body: str, retitle: bool = False) -> tuple[bool, str]: - """既存の PR があれば本文を書き直し(retitle なら題も。#1289 の決定 6)、無ければ Draft で作る。""" - found = gh_call.gh( - ["pr", "list", "--head", branch, "--state", "open", "--json", "url", "--jq", ".[0].url"], cwd=ctx.cwd - ).stdout.rstrip() + """既存の PR があれば本文を書き直し(retitle なら題も。#1289 の決定 6)、無ければ Draft で作る。 + + 宛先(既存の PR なら baseRefName、無ければ base)が既定ブランチなら、本文の closing keywords を閉じる予定の参照へ + 置き換えてから送る(マージで課題を閉じない。#2228 の I7)。""" + found, _, found_base = ( + gh_call.gh( + [ + "pr", + "list", + "--head", + branch, + "--state", + "open", + "--json", + "url,baseRefName", + "--jq", + ".[0] | [.url, .baseRefName] | @tsv", + ], + cwd=ctx.cwd, + ) + .stdout.strip() + .partition("\t") + ) + body, deferred = defer_for_base(ctx.cwd, (found_base or None) if found else base, body) + if deferred: + ctx.state.cur["deferred_closing"] = deferred if found: p = gh_call.gh(["pr", "edit", found, *(["--title", title] if retitle else []), "--body", body], cwd=ctx.cwd) url = found diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index ed22eba17..464b9fd2e 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -236,7 +236,7 @@ def judge_record_step() -> dict: def release_verify_step(sprint: str, refs: str, record_pr: str) -> dict: - """リリース後テスト(/ndf:release-verification)。記録を配布の記録の PR へ置き、まとめの close がそれを読む(#1683)。""" + """リリース後テスト(/ndf:release-verification)。記録を配布の記録の PR へ置き、振り返りの後のまとめの close がそれを読む(#1683)。""" return { "id": "release-verify", "type": "work", @@ -245,9 +245,11 @@ def release_verify_step(sprint: str, refs: str, record_pr: str) -> dict: "stage": "リリース後テスト", "timeout": 3600, "prompt": f"/ndf:release-verification スプリント {sprint}({refs})。配布の記録は Pull Request #{record_pr} にある。" - "リリース後テストの記録をその Pull Request へコメントで書く。課題は閉じない(次の close が閉じる)。" + "受け入れ条件ごとに確かめ、スプリントの Pull Request の「手動確認」でリリース後テストへ回した項目と、" + "マージ前に未検証として残した項目も行として載せて、リリース後テストの記録をその Pull Request へコメントで書く。" + "課題は閉じない(次の close が閉じる)。" "番号が 0 なら本番へ出ていないため、何もせずに終える。", - "next": "close", + "next": "retro", } @@ -259,7 +261,9 @@ def _release_verify_steps(a, v: str, dev: bool, repo: str | None, ref: str, rts: { "id": "verify", "type": "run", - "stage": "配布" if dev else "リリース後テスト", + # 導入の確認だけを流すため、本番でも「リリース後テスト」と記録しない。受け入れ条件ごとの確認と + # 配布の記録の PR への記録は、まとめの release-verify(release_verify_step)が担う(#2288) + "stage": "配布", "timeout": 1500, "cwd": repo, "cmd": f"sh -c 'git pull -q --ff-only origin {a.base} && {VERIFY_PY} verify-install --ref {ref} --expect {v} --runtimes {rts}'", diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index c9ef44671..6fa66f4de 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -48,7 +48,7 @@ route_waves, ) from supervise_lib.verify_steps import merge_steps -from supervise_lib.sprint_close_steps import _close_step, _retro_refine_steps, _spec_pr_steps +from supervise_lib.sprint_close_steps import _close_step, _refine_step, _retro_step, _spec_pr_steps def prod_version(version: str) -> str: @@ -139,7 +139,7 @@ def auto_sprint_plans(a) -> list[dict]: def close_plan(a, repo: str) -> dict: - """まとめ: 確定仕様化 → Pull Request →(配布の記録があれば)リリース後テスト → 課題を閉じる → 振り返り → 棚卸し。""" + """まとめ: 確定仕様化 → Pull Request →(配布の記録があれば)リリース後テスト → 振り返り → 課題を閉じる → 棚卸し。""" issues = ",".join(map(str, a.issue)) refs = " ".join(f"#{i}" for i in a.issue) branch = f"spec/{a.name}" @@ -158,10 +158,11 @@ def close_plan(a, repo: str) -> dict: "進め方": "auto" if getattr(a, "pace", None) == "auto" else "fast", "steps": [ *_spec_pr_steps(a, refs), - *merge_steps(a, next="release-verify" if has_record(record_pr) else "close"), + *merge_steps(a, next="release-verify" if has_record(record_pr) else "retro"), *([release_verify_step(a.name, refs, record_pr)] if has_record(record_pr) else []), + _retro_step(a, refs, stats), _close_step(a, repo, record_pr, issues), - *_retro_refine_steps(a, refs, stats), + _refine_step(a, refs), ], }, a, diff --git a/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py b/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py index 591c14435..b744baa38 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py @@ -1,4 +1,7 @@ -"""new close のまとめのステップ(確定仕様化 → Pull Request・課題を閉じる・振り返りと棚卸し)。`sprint.close_plan` が組む。""" +"""new close のまとめのステップ(確定仕様化 → Pull Request・振り返り → 課題を閉じる → 棚卸し)。`sprint.close_plan` が組む。 + +閉じる時点の決まりの正本は `skills/progress-tracking/SKILL.md` の「スプリントを閉じる」。閉じてよいかは sprint-close.py が +判定し、プランは並び(振り返りの後に close)と `--final-stage` の値だけを持つ(#2228 の I6)。""" from __future__ import annotations @@ -36,7 +39,7 @@ def _spec_pr_steps(a, refs: str) -> list[dict]: def _close_step(a, repo: str, record_pr: str, issues: str) -> dict: - """課題を閉じる(sprint-close.py)。""" + """課題を閉じる(sprint-close.py)。プランはどのモードでも retro を通すため、最終工程は振り返りを明示して渡す。""" return { "id": "close", "type": "run", @@ -44,8 +47,8 @@ def _close_step(a, repo: str, record_pr: str, issues: str) -> dict: "cwd": repo, "timeout": 900, "cmd": f"python3 {HERE / 'sprint-close.py'} --record-pr {record_pr} --issues {issues} " - f"--with-verification --label {shlex.quote(f'スプリント {a.name}の後片付け')}", - "next": "retro", + f"--final-stage 振り返り --with-verification --label {shlex.quote(f'スプリント {a.name}の後片付け')}", + "next": "refine", } @@ -58,34 +61,36 @@ def carry_text(a) -> str: return f"前の周回の持ち越し {nums} を `upkeep.py` の `--add` で候補へ渡す({' '.join(map(str, carry))})。" -def _retro_refine_steps(a, refs: str, stats: str) -> list[dict]: - """振り返りと棚卸し。""" - return [ - { - "id": "retro", - "type": "work", - "full": True, - "kind": "振り返り", - "stage": "振り返り", - "timeout": 3600, - "prompt": f"/ndf:retrospective スプリント {a.name}({refs})。材料に検査の記録の集計(`{stats}` の出力: " - "トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の" - "見直しが要るかを書く。", - "next": "refine", - }, - { - "id": "refine", - "type": "work", - "full": True, - "kind": "棚卸し", - "stage": "棚卸し", - "timeout": 3600, - "prompt": f"/ndf:backlog-refinement スプリント {a.name}({refs})。棚卸しの工程として無人で通す。{carry_text(a)}" - "候補が上限を超えて持ち越し(`metrics.deferred`)が出ても、`--limit` を上げて打ち直さず、番号と件数を" - "報告に載せる。`upkeep.py apply` が終了コード 10 を返したら、承認を求めず、承認資料のパス" - "(`presentation_path`)と対象の番号を報告に載せて終える。`--repo` を渡さない(記録のリポジトリの外へ" - "書かない)。親 issue の起票と子 issue の結び付けは行わず、クラスタと提案する親 issue の内容(題・子の" - "番号)を報告の「人の判断待ち」に載せて終える。", - "next": "end", - }, - ] +def _retro_step(a, refs: str, stats: str) -> dict: + """振り返り。課題は閉じない(次の close が閉じる)。""" + return { + "id": "retro", + "type": "work", + "full": True, + "kind": "振り返り", + "stage": "振り返り", + "timeout": 3600, + "prompt": f"/ndf:retrospective スプリント {a.name}({refs})。材料に検査の記録の集計(`{stats}` の出力: " + "トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の" + "見直しが要るかを書く。課題は閉じない(次の close が閉じる)。", + "next": "close", + } + + +def _refine_step(a, refs: str) -> dict: + """棚卸し。""" + return { + "id": "refine", + "type": "work", + "full": True, + "kind": "棚卸し", + "stage": "棚卸し", + "timeout": 3600, + "prompt": f"/ndf:backlog-refinement スプリント {a.name}({refs})。棚卸しの工程として無人で通す。{carry_text(a)}" + "候補が上限を超えて持ち越し(`metrics.deferred`)が出ても、`--limit` を上げて打ち直さず、番号と件数を" + "報告に載せる。`upkeep.py apply` が終了コード 10 を返したら、承認を求めず、承認資料のパス" + "(`presentation_path`)と対象の番号を報告に載せて終える。`--repo` を渡さない(記録のリポジトリの外へ" + "書かない)。親 issue の起票と子 issue の結び付けは行わず、クラスタと提案する親 issue の内容(題・子の" + "番号)を報告の「人の判断待ち」に載せて終える。", + "next": "end", + } diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index b809e4bc0..374fe6896 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -57,7 +57,7 @@ def has_release_template(a) -> bool: return any(r.stage == delivery.STAGE_TEMPLATE for r in routes_of(a)) -RECORD_NONE = "0" # 本番の記録なし(配布なし。sprint-close.py は閉じる条件を見ずに閉じる。#1336) +RECORD_NONE = "0" # 本番の記録なし(配布なし。sprint-close.py は閉じる条件 1・2 を見ない。#1336) RECORD_UNKNOWN = "unknown" # 記録の置き場が分からない(sprint-close.py は課題を開いたまま残す。#1875) diff --git a/plugins/ndf/scripts/supervise_lib/state.py b/plugins/ndf/scripts/supervise_lib/state.py index b4d997de4..4be8ac900 100644 --- a/plugins/ndf/scripts/supervise_lib/state.py +++ b/plugins/ndf/scripts/supervise_lib/state.py @@ -403,15 +403,13 @@ def retell_gates(self, recorded: list[dict]) -> None: def _step_rows(self) -> str: """LLM を使ったステップの表の行。無ければ「無し」の 1 行。""" - return ( - "\n".join( - f"| {e['id']} | {e['llm']['turns']} | {e.get('seconds', '')} | {e['llm']['cache_read']} | " - f"{e['llm']['cache_write']} | {e['llm']['output']} | ${e['llm']['cost']:.3f} |" - for e in self.log - if e.get("llm") - ) - or "| 無し | | | | | | |" - ) + rows = [ + f"| {e['id']} | {e['llm']['turns']} | {e.get('seconds', '')} | {e['llm']['cache_read']} | " + f"{e['llm']['cache_write']} | {e['llm']['output']} | ${e['llm']['cost']:.3f} |" + for e in self.log + if e.get("llm") + ] + return "\n".join(rows) or "| 無し | | | | | | |" def _extra_lines(self) -> str: """認証の切り替え・利用上限・遅れの行。記録があるものだけを出す。""" @@ -450,7 +448,7 @@ def _extra_lines(self) -> str: return extra def write_report(self, plan: dict, result: str, reason: str) -> str: - """`## フェーズの報告` を組み、`report.md` へ書いて返す。""" + """`## フェーズの報告` を組み、`report.md` へ書いて返す。欠けを残して進んだステップは欠けの理由を写す(#2047 の決定 5)。""" l = self.llm self.read_worker_lines() pc = self.pcount @@ -460,19 +458,18 @@ def write_report(self, plan: dict, result: str, reason: str) -> str: if "counts" in e: counts[e["id"]] = e["counts"] counts_line = "; ".join(f"{k}: {counts_text(v)}" for k, v in counts.items()) or "無し" + gaps = [ + f"{e['id']}(" + ", ".join(f"{g.get('name') or g.get('kind')}: {g.get('reason')}" for g in e["gaps"]) + ")" + for e in self.log + if e.get("gaps") + ] if self.gates: gate_line = "; ".join(f"ステップ {g['id']}(exit={g['exit']})" for g in self.gates) else: gate_line = "本番の系へ届く操作" if result == "関門" else "無し" - presented = ( - ", ".join( - [ - *(g["presentation"] for g in self.gates if g.get("presentation")), - *(e["presentation"] for e in self.log if e.get("gate_as_ok") and e.get("presentation")), - ] - ) - or "無し" - ) + shown = [g["presentation"] for g in self.gates if g.get("presentation")] + shown += [e["presentation"] for e in self.log if e.get("gate_as_ok") and e.get("presentation")] + presented = ", ".join(shown) or "無し" text = f"""## フェーズの報告 - フェーズ: {plan.get("フェーズ")} @@ -488,6 +485,7 @@ def write_report(self, plan: dict, result: str, reason: str) -> str: - 理由: {reason} - 通ったステップ: {steps} - 件数: {counts_line} +- 欠けを残して進んだステップ: {"; ".join(gaps) or "無し"} - LLM の使用量: 入力 {l["input"]} / cache read {l["cache_read"]} / cache write {l["cache_write"]} / 出力 {l["output"]} / ${l["cost"]:.3f} - CLI(drive・work の中): {usage_aggregate.runtimes_text(self.log)} - 記録: {self.dir} diff --git a/plugins/ndf/scripts/supervise_lib/steps.py b/plugins/ndf/scripts/supervise_lib/steps.py index b6635947d..e156d0894 100644 --- a/plugins/ndf/scripts/supervise_lib/steps.py +++ b/plugins/ndf/scripts/supervise_lib/steps.py @@ -181,7 +181,30 @@ def no_reports(self, ctx) -> str: return "" return str(test.get("no_reports") or "") if isinstance(test, dict) else "" - def run_cmd(self, ctx, step: dict) -> tuple[int, str]: + @staticmethod + def extension(ctx, step: dict, path: Path, timeout) -> int | None: + """延長のファイルの秒から、この呼び出しの開始からの締め切り(`with_margin`)を返す。今の `timeout` 以下なら None。 + 延ばしたら `cur.extended_timeout` と進捗ログの 1 行に残す(#2047 の決定 4)。""" + import drive_pause + from supervise_lib.verify_steps import with_margin + + got = drive_pause.read_extension(path) + if got is None: + return None + longer = with_margin(int(got[0])) + if longer <= int(timeout): + return None + cur = ctx.state.cur + if isinstance(cur, dict) and int((cur.get("extended_timeout") or {}).get("timeout") or 0) < longer: + cur["extended_timeout"] = {"seconds": int(got[0]), "timeout": longer, "planned": int(timeout), "reason": got[1]} + write = getattr(ctx.state, "progress_write", None) + if write: + write({"kind": "extend", "step": step.get("id"), "timeout": longer, "planned": int(timeout), "reason": got[1]}) + return longer + + def run_cmd(self, ctx, step: dict, extend_file: Path | None = None) -> tuple[int, str]: + """コマンドを締め切りまで待つ。`extend_file` を渡すと(drive のステップ)、子へ `NDF_STEP_EXTEND_FILE` で置き場を渡し、 + 子が書いた見込みの秒で締め切りを後ろへずらす(#2047 の決定 4)。""" cmd = step.get("cmd") or paths.PRESETS.get(step.get("preset", ""), "") if not cmd: return 2, f"ステップ {step['id']} に cmd も知っている preset も無い" @@ -205,6 +228,12 @@ def run_cmd(self, ctx, step: dict) -> tuple[int, str]: env["PYTEST_ADDOPTS"] = " ".join(a for a in addopts if a) # 子が起動する CLI の使用量の帳簿の行にプランとステップを付ける(#1953 の決定 7・決定 11) env.update(NDF_USAGE_PLAN=getattr(ctx, "plan_path", None) or "", NDF_USAGE_STEP=str(step.get("id") or "")) + extend = None + if extend_file is not None: + import drive_pause + + env[drive_pause.EXTEND_ENV] = str(extend_file) + extend = lambda started: (t := self.extension(ctx, step, Path(extend_file), timeout)) and started + t # noqa: E731 ctx.state.run_log = ctx.state.dir / "run-stderr.log" try: p = run_ticking( @@ -216,6 +245,7 @@ def run_cmd(self, ctx, step: dict) -> tuple[int, str]: timeout=timeout, env=env, err_path=ctx.state.run_log, + extend=extend, ) return p.returncode, p.stdout + p.stderr except subprocess.TimeoutExpired as e: diff --git a/plugins/ndf/scripts/supervise_lib/templates.py b/plugins/ndf/scripts/supervise_lib/templates.py index e44741704..1cd9e6960 100644 --- a/plugins/ndf/scripts/supervise_lib/templates.py +++ b/plugins/ndf/scripts/supervise_lib/templates.py @@ -254,6 +254,9 @@ def _inspect_steps(a, pr_ref: str, scope: str) -> list[dict]: "timeout": with_margin((budget or default_refactor_budget_minutes()) * 60), "cmd": f"ndf_scope={scope} || exit 2; python3 {DRIVES['cross-refactoring']} {pr_ref} --workflow-step --scope $ndf_scope" f"{budget_arg}{refactor_template_arg(a)}", + # 時間の枠に収まらずリファクタリングを飛ばした終わり(終了コード 6 と gaps)はコードレビューへ進む(#2047 の I6)。 + # 想定最大時間を省いて下限まで上げたときの締め切りは、駆動が書くステップの延長で実行の時点に延ばす + "on_exit": {"6": "review"}, "on_fail": "abort", "next": "review", }, @@ -265,9 +268,8 @@ def _inspect_steps(a, pr_ref: str, scope: str) -> list[dict]: "stage": "実装レビュー", "timeout": 3600, "args": f"{pr_ref} --max-rounds 4", + # 収束しなかった終わり(終了コード 6 と gaps)も落ちたとして止まり、後続の検査へ進めない(#2143) "on_fail": "abort", - # 収束しなかった終わり(終了コード 6 と gaps)は見送りとして材料へ載せて進む(#2227 の決定 1) - "on_exit": {"6": "base-sync"}, "next": "base-sync", # 全体テストの前にベースブランチを取り込む(#1917) }, ] diff --git a/plugins/ndf/scripts/supervise_lib/worker_steps.py b/plugins/ndf/scripts/supervise_lib/worker_steps.py index 8b69178b0..01931f013 100644 --- a/plugins/ndf/scripts/supervise_lib/worker_steps.py +++ b/plugins/ndf/scripts/supervise_lib/worker_steps.py @@ -173,7 +173,9 @@ def drive_cmd(self, step: dict) -> str: script = paths.DRIVES.get(step.get("drive", "")) return f"python3 {script} {step.get('args', '')}".strip() if script else "" - def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict] | None = None) -> tuple[bool, dict | None, str]: + def drive_loop( + self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict] | None = None, extend_file: Path | None = None + ) -> tuple[bool, dict | None, str]: """駆動を打ち、pause のたびに worker へ渡して打ち直す。(成功, 最後の結果, 出力) を返す。 入れ子の駆動(最終ゲートの item.command)の metrics は `inner` へ足す(外側の結果は内側の件数を持たない)。""" @@ -181,7 +183,7 @@ def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict texts = [] max_pauses = step.get("max_pauses", DEFAULT_MAX_PAUSES) for _ in range(max_pauses + 1): - code, text = self.run.run_cmd(ctx, {**step, "cmd": cmd}) + code, text = self.run.run_cmd(ctx, {**step, "cmd": cmd}, extend_file) texts.append(text) out = last_json(text) if out is None: @@ -195,7 +197,7 @@ def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict ctx.state.cur.setdefault("pauses", []).append(kind) res_file = Path(item["result_file"]) if item.get("command") and depth == 0: - ok, sub = self._drive_nested(ctx, step, item, res_file, depth, inner, texts) + ok, sub = self._drive_nested(ctx, step, item, res_file, depth, inner, texts, extend_file) if not ok: return False, sub, "\n".join(texts) + f"\n{kind} の駆動が止まった" continue @@ -205,10 +207,18 @@ def drive_loop(self, ctx, step: dict, cmd: str, depth: int = 0, inner: list[dict return False, None, "\n".join(texts) + f"\npause が上限 {max_pauses} を超えた" def _drive_nested( - self, ctx, step: dict, item: dict, res_file: Path, depth: int, inner: list[dict] | None, texts: list[str] + self, + ctx, + step: dict, + item: dict, + res_file: Path, + depth: int, + inner: list[dict] | None, + texts: list[str], + extend_file: Path | None = None, ) -> tuple[bool, dict | None]: """item.command の駆動を回し、通れば件数から答えを結果ファイルへ書く。(成功, 内側の最後の結果) を返す。""" - ok, sub, sub_text = self.drive_loop(ctx, step, item["command"], depth + 1, inner) + ok, sub, sub_text = self.drive_loop(ctx, step, item["command"], depth + 1, inner, extend_file) if inner is not None and sub and isinstance(sub.get("metrics"), dict): inner.append(sub["metrics"]) texts.append(sub_text) @@ -243,7 +253,10 @@ def execute(self, ctx, step: dict) -> tuple[bool, str]: return False, err # 入れ子の駆動(最終ゲートの item.command)の metrics を控える。外側の結果は内側の件数を持たない inner: list[dict] = [] - ok, out, text = self.drive_loop(ctx, step, cmd, inner=inner) + # ステップの延長の置き場(#2047 の決定 4)。試行ごとに分け、前の試行のファイルは読まない + extend_file = Path(ctx.state.dir) / f"{len(getattr(ctx.state, 'log', []) or []) + 1:02d}-{step['id']}.extend.json" + extend_file.unlink(missing_ok=True) + ok, out, text = self.drive_loop(ctx, step, cmd, inner=inner, extend_file=extend_file) note_runtimes(ctx, step, started) if out or inner: ctx.state.cur["counts"] = dict((out or {}).get("metrics") or {}) @@ -251,5 +264,7 @@ def execute(self, ctx, step: dict) -> tuple[bool, str]: ctx.state.cur["counts"]["inner"] = inner[-1] # 欠けの残る終わり(stopped と gaps。収束しなかったなど)は終了コード 6 として控え、on_exit で分けられるようにする code = 0 if ok else (6 if out and out.get("status") == "stopped" and out.get("gaps") else 1) + if code == 6: + ctx.state.cur["gaps"] = list(out.get("gaps") or []) # フェーズレポートの「欠けを残して進んだステップ」(#2047 の決定 5) ctx.state.cur.update(exit=code, text=text, seconds=round(time.time() - started, 1)) return ok, text diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json index e853f6377..634954216 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/check.json @@ -26,6 +26,9 @@ "stage": "構造改善", "timeout": 1980, "cmd": "ndf_scope=$(python3 /scripts/refactor-scope.py --pr 5) || exit 2; python3 /skills/cross-refactoring/scripts/drive.py 5 --workflow-step --scope $ndf_scope", + "on_exit": { + "6": "review" + }, "on_fail": "abort", "next": "review" }, @@ -38,9 +41,6 @@ "timeout": 3600, "args": "5 --max-rounds 4", "on_fail": "abort", - "on_exit": { - "6": "base-sync" - }, "next": "base-sync" }, { diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json index 13395b806..d2a81c28c 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/1-check.json @@ -53,6 +53,9 @@ "stage": "構造改善", "timeout": 1980, "cmd": "ndf_scope=$(python3 /scripts/check-trigger.py scope --id m9-final --state {state_dir} --root .) || exit 2; python3 /skills/cross-refactoring/scripts/drive.py {pr} --workflow-step --scope $ndf_scope", + "on_exit": { + "6": "review" + }, "on_fail": "abort", "next": "review" }, @@ -65,9 +68,6 @@ "timeout": 3600, "args": "{pr} --max-rounds 4", "on_fail": "abort", - "on_exit": { - "6": "base-sync" - }, "next": "base-sync" }, { diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json index e4a59670a..b817949ba 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/3-release-prod.json @@ -95,7 +95,7 @@ { "id": "verify", "type": "run", - "stage": "リリース後テスト", + "stage": "配布", "timeout": 1500, "cwd": "/repo", "cmd": "sh -c 'git pull -q --ff-only origin main && python3 /scripts/release-verification-steps.py verify-install --ref main --expect 1.2.0 --runtimes claude'", diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json index ef1922b9a..21fc67a68 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json @@ -79,16 +79,7 @@ "kind": "リリース後テスト", "stage": "リリース後テスト", "timeout": 3600, - "prompt": "/ndf:release-verification スプリント m9(#15)。配布の記録は Pull Request #{queue_pr:release-prod} にある。リリース後テストの記録をその Pull Request へコメントで書く。課題は閉じない(次の close が閉じる)。番号が 0 なら本番へ出ていないため、何もせずに終える。", - "next": "close" - }, - { - "id": "close", - "type": "run", - "stage": "後片付け", - "cwd": "/repo", - "timeout": 900, - "cmd": "python3 /scripts/sprint-close.py --record-pr {queue_pr:release-prod} --issues 15 --with-verification --label 'スプリント m9の後片付け'", + "prompt": "/ndf:release-verification スプリント m9(#15)。配布の記録は Pull Request #{queue_pr:release-prod} にある。受け入れ条件ごとに確かめ、スプリントの Pull Request の「手動確認」でリリース後テストへ回した項目と、マージ前に未検証として残した項目も行として載せて、リリース後テストの記録をその Pull Request へコメントで書く。課題は閉じない(次の close が閉じる)。番号が 0 なら本番へ出ていないため、何もせずに終える。", "next": "retro" }, { @@ -98,7 +89,16 @@ "kind": "振り返り", "stage": "振り返り", "timeout": 3600, - "prompt": "/ndf:retrospective スプリント m9(#15)。材料に検査の記録の集計(`python3 /scripts/check-trigger.py stats --root /repo` の出力: トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の見直しが要るかを書く。", + "prompt": "/ndf:retrospective スプリント m9(#15)。材料に検査の記録の集計(`python3 /scripts/check-trigger.py stats --root /repo` の出力: トリガーが立った回数・検査ごとの指摘の件数・検査の後に逃げた不具合の件数)を使い、閾値の見直しが要るかを書く。課題は閉じない(次の close が閉じる)。", + "next": "close" + }, + { + "id": "close", + "type": "run", + "stage": "後片付け", + "cwd": "/repo", + "timeout": 900, + "cmd": "python3 /scripts/sprint-close.py --record-pr {queue_pr:release-prod} --issues 15 --final-stage 振り返り --with-verification --label 'スプリント m9の後片付け'", "next": "refine" }, { diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json index 09c8fa976..801c31119 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/10-release-prod.json @@ -90,7 +90,7 @@ { "id": "verify", "type": "run", - "stage": "リリース後テスト", + "stage": "配布", "timeout": 1500, "cwd": "/repo", "cmd": "sh -c 'git pull -q --ff-only origin main && python3 /scripts/release-verification-steps.py verify-install --ref main --expect 1.2.0 --runtimes claude'", diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json index ca40da0f0..7ee5b040c 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/7-check.json @@ -54,6 +54,9 @@ "stage": "構造改善", "timeout": 1980, "cmd": "ndf_scope=$(python3 /scripts/refactor-scope.py --pr {pr}) || exit 2; python3 /skills/cross-refactoring/scripts/drive.py {pr} --workflow-step --scope $ndf_scope", + "on_exit": { + "6": "review" + }, "on_fail": "abort", "next": "review" }, @@ -66,9 +69,6 @@ "timeout": 3600, "args": "{pr} --max-rounds 4", "on_fail": "abort", - "on_exit": { - "6": "base-sync" - }, "next": "base-sync" }, { diff --git a/plugins/ndf/scripts/tests/inproc.py b/plugins/ndf/scripts/tests/inproc.py new file mode 100644 index 000000000..48ca67430 --- /dev/null +++ b/plugins/ndf/scripts/tests/inproc.py @@ -0,0 +1,67 @@ +"""スクリプトの `main(argv)` を、テストのプロセスの中で呼ぶ(#2291 の決定 3 の (2))。 + +スクリプトを子のプロセスで起動すると、起動と import だけで 1 回 0.2〜0.4 秒かかる。CLI の配線(引数・終了コード・ +結果 JSON)は代表の数件の起動で確かめ、分岐はこの関数で確かめる。引数を取らない `main()` は `sys.argv` から読む。返す値は `subprocess.run` と同じ形 +(`returncode`・`stdout`・`stderr`)で、起動を置き換えても読み手を変えずに済む。 + +作業ディレクトリ・環境変数・`sys.argv`・標準入出力は呼ぶ間だけ差し替え、戻す。モジュールは 1 度だけ読み込む。 +""" + +from __future__ import annotations + +import contextlib +import importlib.util +import inspect +import io +import os +import subprocess +import sys +from pathlib import Path + +_modules: dict[str, object] = {} + + +def load(script: Path | str): + key = str(Path(script).resolve()) + if key not in _modules: + name = "inproc_" + Path(key).stem.replace("-", "_") + spec = importlib.util.spec_from_file_location(name, key) + mod = importlib.util.module_from_spec(spec) + sys.modules[name] = mod # dataclass などがモジュールを sys.modules から引く + spec.loader.exec_module(mod) + _modules[key] = mod + return _modules[key] + + +def _code(e: SystemExit, err: io.StringIO) -> int: + if e.code is None: + return 0 + if isinstance(e.code, int): + return e.code + err.write(f"{e.code}\n") + return 1 + + +def run(script: Path | str, *args: str, cwd: Path | str | None = None, env: dict | None = None, input: str | None = None): + """`[python, script, *args]` を子のプロセスで起動したのと同じ形の結果を返す。""" + mod = load(script) + argv = [str(a) for a in args] + out, err = io.StringIO(), io.StringIO() + saved = (dict(os.environ), sys.argv, sys.stdin) + try: + if env is not None: + os.environ.clear() + os.environ.update({k: str(v) for k, v in env.items()}) + sys.argv = [str(script), *argv] + sys.stdin = io.StringIO(input or "") + with contextlib.chdir(cwd or os.getcwd()), contextlib.redirect_stdout(out), contextlib.redirect_stderr(err): + try: + r = mod.main(argv) if inspect.signature(mod.main).parameters else mod.main() + code = r if isinstance(r, int) else 0 + except SystemExit as e: + code = _code(e, err) + finally: + os.environ.clear() + os.environ.update(saved[0]) + sys.argv, sys.stdin = saved[1], saved[2] + return subprocess.CompletedProcess([sys.executable, str(script), *argv], code, out.getvalue(), err.getvalue()) diff --git a/plugins/ndf/scripts/tests/sprint_args.py b/plugins/ndf/scripts/tests/sprint_args.py new file mode 100644 index 000000000..2c155d5c0 --- /dev/null +++ b/plugins/ndf/scripts/tests/sprint_args.py @@ -0,0 +1,25 @@ +"""supervise.py の `new sprint` を起動する引数列の共通の土台。 + +既定の名前・課題・版数を持ち、テストごとの差分(名前・課題・設計・版数と、末尾に足すフラグ)だけを受ける。 +""" + +from __future__ import annotations + +from pathlib import Path + + +def new_sprint_args( + worktree: Path | str, + out: Path | str, + *extra: str, + name: str = "m", + issue: tuple[str, ...] = ("1",), + design: tuple[str, ...] = (), + version: str | None = "10.18.0-dev.1", +) -> list[str]: + args = ["new", "sprint", "--name", name, "--worktree", str(worktree), "--issue", *issue] + if design: + args += ["--design", *design] + if version is not None: + args += ["--version", version] + return [*args, "--out", str(out), *extra] diff --git a/plugins/ndf/scripts/tests/test_acceptance_checks.py b/plugins/ndf/scripts/tests/test_acceptance_checks.py new file mode 100644 index 000000000..8831fdbef --- /dev/null +++ b/plugins/ndf/scripts/tests/test_acceptance_checks.py @@ -0,0 +1,216 @@ +"""受け入れ条件の確認の区分(#2144)。完了判定が `acceptance-check.py record` で本文へ書き、承認資料 +(release_lib/materials.read_unverified)と sprint-close.py の閉じる条件 2 が同じ記録を読む。 + +本文の読み書き(gh_rest.fetch_body / update_body)は代役に差し替え、課題ごとの錠は一時の置き場で取る。 +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +SCRIPTS = HERE.parent +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import deps # noqa: E402 + +deps.require("md", "mdtable", "locks") +import acceptance # noqa: E402 +from release_lib import materials # noqa: E402 + + +def _load(name: str, file: str): + spec = importlib.util.spec_from_file_location(name, SCRIPTS / file) + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + return mod + + +check = _load("acceptance_check", "acceptance-check.py") +sprint_close = _load("sprint_close_for_checks", "sprint-close.py") + +BODY = "# 課題\n\n## 受け入れ条件\n\n- [ ] 1. 一\n- [ ] 2. 二\n- [x] 3. 三\n- [ ] 4. 四\n\n## 進行\n\nモード: standard\n" + + +@pytest.fixture +def issue(monkeypatch, tmp_path): + """課題 o/r#7 の本文の代役。bodies["o/r#7"] を読み書きする。""" + monkeypatch.setenv("NDF_LOCK_DIR", str(tmp_path / "locks")) + store = {"o/r#7": BODY, "writes": 0} + + def fetch(repo, n): + return store.get(f"{repo}#{n}") + + def update(repo, n, body): + store[f"{repo}#{n}"] = body + store["writes"] += 1 + return True + + monkeypatch.setattr(check.gh_rest, "fetch_body", fetch) + monkeypatch.setattr(check.gh_rest, "update_body", update) + return store + + +def run(capsys, *argv): + with pytest.raises(SystemExit) as e: + check.main(list(argv)) + return e.value.code, json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + + +# --- 記録を書く・読む(受け入れ条件 1) ------------------------------------------- + + +def test_record_writes_a_table_that_read_check_kinds_reads(issue, capsys): + code, out = run( + capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "1=pytest tests/test_x.py::test_a", "--post-release", "4" + ) + assert code == 0 and out["status"] == "ok" + body = issue["o/r#7"] + assert acceptance.read_check_kinds(body) == { + 1: acceptance.Check("マージ前", "pytest tests/test_x.py::test_a"), + 4: acceptance.Check("リリース後", ""), + } + assert [(i["name"], i["result"]) for i in out["items"]] == [("1", "マージ前"), ("4", "リリース後")] + assert acceptance.criteria(body) == acceptance.criteria(BODY) # 受け入れ条件の節は変えない + + +def test_record_again_overrides_and_keeps_the_rest(issue, capsys): + run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "1=a", "--post-release", "2") + code, _ = run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "2=b") + assert code == 0 + assert acceptance.read_check_kinds(issue["o/r#7"]) == {1: acceptance.Check("マージ前", "a"), 2: acceptance.Check("マージ前", "b")} + writes = issue["writes"] + code, out = run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "2=b") + assert code == 0 and "変更なし" in out["summary"] and issue["writes"] == writes + + +@pytest.mark.parametrize( + "args", + [ + ["--pre-merge", "9=a"], # 受け入れ条件に無い番号 + ["--pre-merge", "1"], # 手段が無い + ["--pre-merge", "1=a", "--post-release", "1"], # 両方 + [], # 何も渡さない + ], +) +def test_record_rejects_bad_arguments_without_writing(issue, capsys, args): + code, out = run(capsys, "record", "--issue", "7", "--repo", "o/r", *args) + assert code == 2 and out["status"] == "stopped" + assert issue["o/r#7"] == BODY + + +def test_record_without_criteria_stops(issue, capsys): + issue["o/r#7"] = "# 課題\n\n本文だけ\n" + code, _ = run(capsys, "record", "--issue", "7", "--repo", "o/r", "--post-release", "1") + assert code == 3 + + +def test_show_reads_the_record(issue, capsys): + run(capsys, "record", "--issue", "7", "--repo", "o/r", "--pre-merge", "2=`ruff check .`") + code, out = run(capsys, "show", "--issue", "7", "--repo", "o/r") + assert code == 0 and out["items"] == [{"kind": "criterion", "name": "2", "result": "マージ前", "means": "`ruff check .`"}] + + +def test_read_check_kinds_skips_rows_without_means_or_kind(): + body = ( + BODY + + "\n## 受け入れ条件の確認\n\n| 条件 | 区分 | 手段 |\n| --- | --- | --- |\n| 1 | マージ前 | — |\n| 2 | 済み | x |\n| 4 | リリース後 | — |\n" + ) + assert acceptance.read_check_kinds(body) == {4: acceptance.Check("リリース後", "")} + assert acceptance.read_check_kinds(BODY) == {} + + +# --- 承認資料(受け入れ条件 2・3) --------------------------------------------------- + + +def _recorded(pre, post): + return check.recorded_body(BODY, pre, post) + + +def test_unverified_leaves_out_pre_merge_and_keeps_post_release(): + body = _recorded({1: "pytest -k a", 2: "pytest -k b"}, [4]) + got = materials.read_unverified({("o/r", 7): body}, {}, "o/r") + assert [u.number for u in got] == [4] + + +def test_unverified_without_a_record_lists_every_unchecked_criterion(): + got = materials.read_unverified({("o/r", 7): BODY}, {}, "o/r") + assert [u.number for u in got] == [1, 2, 4] + + +# --- 閉じる条件 2(受け入れ条件 4) -------------------------------------------------- + + +def _judge(body, rows): + return sprint_close.judge_acceptance(rows, acceptance.criteria(body), acceptance.pre_merge_numbers(body)) + + +def test_pre_merge_criteria_need_no_verification_row(): + body = _recorded({1: "pytest -k a", 2: "pytest -k b"}, [4]) + ok, why, to_check = _judge(body, acceptance.IssueRows(True, frozenset({3, 4}))) + assert ok and why is None and to_check == [1, 2, 4] + + +def test_post_release_criteria_still_need_a_row(): + body = _recorded({1: "pytest -k a"}, [2, 4]) + ok, why, _ = _judge(body, acceptance.IssueRows(True, frozenset({4}))) + assert not ok and "2" in why + + +def test_all_pre_merge_closes_without_rows_and_unrecorded_still_needs_rows(): + body = _recorded({1: "a", 2: "b", 3: "c", 4: "d"}, []) + assert _judge(body, None)[0] + assert _judge(BODY, None) == (False, sprint_close.NO_ROWS, []) + + +# --- 版ごとのリリース後テストの表(version_verdicts の現状固定) ------------------------ + + +def _verify(ver, rows, verdict="合格"): + table = "| 課題 | 受け入れ条件 | 結果 |\n| --- | --- | --- |\n" + "".join(f"| {i} | {c} | {r} |\n" for i, c, r in rows) + return f"## リリース後テスト\n\n対象の版: {ver}\n\n{table}\n合否: {verdict}\n" + + +def test_version_verdicts_reads_the_block_whose_base_version_matches(): + text = _verify("1.0.1-dev.1", [("#7", "2. 二", "合格"), ("x/y#8", "1. 一", "不合格")]) + assert acceptance.version_verdicts(text, "v1.0.1", "o/r") == { + ("o/r", 7): acceptance.IssueRows(True, frozenset({2})), + ("x/y", 8): acceptance.IssueRows(False, frozenset()), + } + + +def test_version_verdicts_uses_the_last_matching_block_and_ignores_other_versions(): + text = "\n".join( + [ + _verify("1.0.1", [("#7", "1. 一", "不合格")]), + _verify("1.0.1(注記)", [("#7", "1. 一", "合格")]), + _verify("1.0.2", [("#9", "1. 一", "合格")]), + ] + ) + assert acceptance.version_verdicts(text, "1.0.1", "o/r") == {("o/r", 7): acceptance.IssueRows(True, frozenset({1}))} + + +@pytest.mark.parametrize( + "text, version", + [ + ("", "1.0.1"), + ("本文だけ\n", "1.0.1"), + (_verify("1.0.2", [("#7", "1. 一", "合格")]), "1.0.1"), + (_verify("main c427284", [("#7", "1. 一", "合格")]), "1.0.1"), + (_verify("1.0.1", [("#7", "1. 一", "合格")]), "main"), + (_verify("1.0.1", [("—", "導入", "合格")]), "1.0.1"), + ("## リリース後テスト\n\n対象の版: 1.0.1\n\n| 課題 | 結果 |\n| --- | --- |\n| #7 | 合格 |\n", "1.0.1"), + ], +) +def test_version_verdicts_returns_empty_when_nothing_matches(text, version): + assert acceptance.version_verdicts(text, version, "o/r") == {} + + +def test_version_verdicts_skips_a_block_without_a_version_line(): + text = "## リリース後テスト\n\n| 課題 | 結果 |\n| --- | --- |\n| #7 | 合格 |\n\n合否: 合格\n" + assert acceptance.version_verdicts(text, "1.0.1", "o/r") == {} diff --git a/plugins/ndf/scripts/tests/test_base_sync.py b/plugins/ndf/scripts/tests/test_base_sync.py index c1d758a82..05c49b2a0 100644 --- a/plugins/ndf/scripts/tests/test_base_sync.py +++ b/plugins/ndf/scripts/tests/test_base_sync.py @@ -15,6 +15,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + sys.path.insert(0, str(SCRIPTS)) sys.path.insert(0, str(SCRIPTS / "lib")) from step_result import validate_result # noqa: E402 @@ -99,7 +102,7 @@ def merging(self): def merged(args, cwd, env=None): - p = subprocess.run([PY, str(MERGED), *args], capture_output=True, text=True, cwd=cwd, env=env) + p = inproc.run(MERGED, *args, cwd=cwd, env=env) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None if out is not None: diff --git a/plugins/ndf/scripts/tests/test_check_trigger.py b/plugins/ndf/scripts/tests/test_check_trigger.py index 92a2dc574..b110bd4d6 100644 --- a/plugins/ndf/scripts/tests/test_check_trigger.py +++ b/plugins/ndf/scripts/tests/test_check_trigger.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SCRIPT = SCRIPTS / "check-trigger.py" sys.path.insert(0, str(SCRIPTS / "lib")) from step_result import validate_result # noqa: E402 @@ -132,7 +135,7 @@ def merge_pr(root: Path, n: int, branch: str, files: dict[str, int]) -> str: def call(root: Path, env: dict, *args: str) -> tuple[int, dict | None, str]: - p = subprocess.run([sys.executable, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, env=env, cwd=root) + p = inproc.run(SCRIPT, *args, "--root", str(root), env=env, cwd=root) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines and lines[-1].startswith("{") else None if out is not None: @@ -439,6 +442,16 @@ def test_findings_have_no_unconfirmed_when_the_refactor_step_did_not_return_it(t assert "unconfirmed" not in findings +def test_findings_copy_a_skipped_refactor_without_failing_it(tmp_path): + """#2047 の I8: 飛ばした refactor(6 と gaps)は理由を写し、落ちたステップに数えない。後の review が落ちればそれを返す。""" + skipped = {"kind": "window", "reason": "要る下限 61 分 > 想定最大時間 30 分"} + ref = {"id": "refactor", "exit": 6, "gaps": [{"kind": "refactor"}], "counts": {"skipped": skipped, "exit": 4}} + findings, failed = _check_trigger_module().findings_of(state_dir(tmp_path, [ref, {"id": "review", "exit": 0}])) + assert (findings["refactor_skipped"], failed) == (skipped, "") + findings, failed = _check_trigger_module().findings_of(state_dir(tmp_path, [ref, {"id": "review", "exit": 1}])) + assert (findings["refactor_skipped"], failed) == (skipped, "review") + + def test_review_fires_on_one_pr_without_the_other_triggers(repo, env): write_decl(repo, {**TRIGGERS, "score": 99, "lines": 10_000}) assert call(repo, env, "eval", "--review")[0] == 3 diff --git a/plugins/ndf/scripts/tests/test_closing.py b/plugins/ndf/scripts/tests/test_closing.py index 5e8a517cb..3b769c9fb 100644 --- a/plugins/ndf/scripts/tests/test_closing.py +++ b/plugins/ndf/scripts/tests/test_closing.py @@ -34,3 +34,49 @@ def test_reference_forms(text, expected): def test_order_of_appearance_without_duplicates(): assert referenced_numbers("#3 https://github.com/o/r/issues/1 #3 Fixes #2 #1", "o/r") == [3, 1, 2] + + +# --- 閉じる予定の参照(#2228) -------------------------------------------------------- + +import os # noqa: E402 +import subprocess # noqa: E402 + +from closing import DEFERRED, closing_issues, closing_numbers, closing_refs, closing_words, defer_closing # noqa: E402 + +SH = Path(__file__).resolve().parents[1] / "lib" / "closing-issues.sh" +BODY = ( + "## 閉じる課題\n\nCloses #12\nfixes: o/r#3, #4\nResolves https://github.com/o/r/issues/9\n" + "最終工程の後に閉じる:#20\nRefs #30\nclosed #12\n" +) + + +def test_defer_closing_keeps_the_targets_and_leaves_no_closing_keywords(): + new, targets = defer_closing(BODY) + assert targets == ["#12", "o/r#3", "https://github.com/o/r/issues/9", "#12"] + assert closing_words(new) == [] + assert closing_issues(new) == closing_issues(BODY) + assert f"{DEFERRED}: o/r#3" in new and "Refs #30" in new + + +def test_the_deferred_form_is_read_as_a_closing_target_in_order(): + text = "最終工程の後に閉じる: #20\nCloses #12\n最終工程の後に閉じる : https://github.com/o/r/issues/9" + assert closing_issues(text) == ["#20", "#12", "https://github.com/o/r/issues/9"] + assert closing_numbers(text) == [20, 12, 9] + assert closing_refs(text, "a/b") == [("a/b", 20), ("a/b", 12), ("o/r", 9)] + assert closing_words(text) == ["Closes #12"] + + +def test_the_deferred_form_needs_the_colon(): + assert closing_issues("最終工程の後に閉じる #20") == [] + + +@pytest.mark.parametrize("locale", ["C", "C.UTF-8"]) +def test_closing_issues_sh_reads_the_same_targets_as_closing_refs(locale): + """2 つの読み手(closing-issues.sh と closing.closing_refs)が同じ本文から同じ指し先を返す。""" + for text in (BODY, defer_closing(BODY)[0]): + p = subprocess.run( + ["bash", str(SH), "--repo", "a/b"], input=text, capture_output=True, text=True, env={**os.environ, "LC_ALL": locale} + ) + assert p.returncode == 0, p.stderr + got = [(r, int(n)) for r, n in (ln.split("\t") for ln in p.stdout.splitlines() if ln)] + assert got == closing_refs(text, "a/b") diff --git a/plugins/ndf/scripts/tests/test_glossary.py b/plugins/ndf/scripts/tests/test_glossary.py index cb81479a1..31fa8f27d 100644 --- a/plugins/ndf/scripts/tests/test_glossary.py +++ b/plugins/ndf/scripts/tests/test_glossary.py @@ -14,6 +14,9 @@ import pytest +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SCRIPTS = Path(__file__).resolve().parents[1] SCRIPT = SCRIPTS / "glossary.py" PY = sys.executable @@ -37,12 +40,20 @@ def write_json(root, rel, obj): return write(root, rel, json.dumps(obj, ensure_ascii=False, indent=2) + "\n") -def run(root, *args): - p = subprocess.run([PY, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, cwd=root) +def _parsed(p): lines = p.stdout.strip().splitlines() return p.returncode, (json.loads(lines[-1]) if lines else None), p.stderr +def run(root, *args): + """分岐はプロセスの中で main を呼んで確かめる(起動の配線は `run_cli` の代表の数件)。""" + return _parsed(inproc.run(SCRIPT, *args, "--root", str(root), cwd=root)) + + +def run_cli(root, *args): + return _parsed(subprocess.run([PY, str(SCRIPT), *args, "--root", str(root)], capture_output=True, text=True, cwd=root)) + + def commit(root, msg="c"): git(root, "add", "-A") git(root, "commit", "-q", "-m", msg) @@ -816,7 +827,7 @@ def test_init_places_outside_root_are_2(bare, tmp_path, idx): def test_command_lines_are_step_results(repo): for args in (["gate", "--mode", "standard"], ["render", "--check"], ["check"], ["diff", "--base", "develop"]): - code, out, _ = run(repo, *args) + code, out, _ = run_cli(repo, *args) assert code == 0 and out["tool"] == "glossary" and out["status"] == "ok" assert os.access(SCRIPT, os.X_OK) diff --git a/plugins/ndf/scripts/tests/test_hook_deadline.py b/plugins/ndf/scripts/tests/test_hook_deadline.py index 72a83155f..877532aaf 100644 --- a/plugins/ndf/scripts/tests/test_hook_deadline.py +++ b/plugins/ndf/scripts/tests/test_hook_deadline.py @@ -9,7 +9,6 @@ import json import os -import re import subprocess import sys import time @@ -130,15 +129,8 @@ def walk(node, under_pre: bool) -> None: return found -@pytest.mark.parametrize("rel", ["hooks/claude.json", "hooks/codex.json", "dev.agy/hooks.json"], ids=["claude", "codex", "agy"]) +@pytest.mark.parametrize("rel", ["hooks/claude.json", "hooks/codex.json"], ids=["claude", "codex"]) def test_the_hook_limit_leaves_the_margin(rel): limits = _hook_timeouts(PLUGIN / rel) assert limits assert all(deadline.DEADLINE_SECONDS + deadline.MARGIN_SECONDS <= t for t in limits) - - -def test_the_kiro_prompt_hook_limit_leaves_the_margin(): - text = (PLUGIN / "dev.kiro" / "install.sh").read_text(encoding="utf-8") - m = re.search(r'hooks\["userPromptSubmit"\]\s*=\s*\[.*?guard_script.*?"timeout_ms":\s*(\d+)', text, re.S) - assert m - assert deadline.DEADLINE_SECONDS + deadline.MARGIN_SECONDS <= int(m.group(1)) / 1000 diff --git a/plugins/ndf/scripts/tests/test_instructions_check.py b/plugins/ndf/scripts/tests/test_instructions_check.py index 48cd2cfd1..e3d524ec9 100644 --- a/plugins/ndf/scripts/tests/test_instructions_check.py +++ b/plugins/ndf/scripts/tests/test_instructions_check.py @@ -18,6 +18,8 @@ import pytest SCRIPT = Path(__file__).resolve().parents[1] / "instructions-check.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 # --- 一時リポジトリの組み立て ------------------------------------------------ @@ -60,12 +62,8 @@ def declare(root: Path, decl: dict) -> None: def run(root: Path, *args: str, cwd: Path | None = None) -> subprocess.CompletedProcess: - return subprocess.run( - [sys.executable, str(SCRIPT), "--root", str(root), *args], - capture_output=True, - text=True, - cwd=str(cwd) if cwd else None, - ) + """分岐はプロセスの中で main を呼ぶ(起動の配線は `test_entry_point_runs_from_a_copy_without_lib_on_sys_path` などの起動のテスト)。""" + return inproc.run(SCRIPT, "--root", str(root), *args, cwd=cwd) def errors(proc: subprocess.CompletedProcess) -> list[str]: diff --git a/plugins/ndf/scripts/tests/test_lib_procs.py b/plugins/ndf/scripts/tests/test_lib_procs.py index 34b19fbd0..42fbce818 100644 --- a/plugins/ndf/scripts/tests/test_lib_procs.py +++ b/plugins/ndf/scripts/tests/test_lib_procs.py @@ -51,6 +51,17 @@ def test_zombie_counts_as_dead_and_missing_pids_are_quiet(): assert procs.wait_gone(p.pid, timeout=0.1) +def test_empty_cmdline_of_an_exiting_process_is_unreadable(monkeypatch): + """終了の途中(ゾンビになる前)の pid は psutil が空の引数を返す。別のプロセス(False)と取らない。""" + + class Exiting: + def cmdline(self): + return [] + + monkeypatch.setattr(procs, "_process", lambda pid: Exiting()) + assert procs.cmdline_contains(12345, "claude") is None + + def test_memory_and_cgroup(tmp_path: Path): avail, total = procs.memory_available() assert 0 < avail <= total diff --git a/plugins/ndf/scripts/tests/test_lib_test_strategy.py b/plugins/ndf/scripts/tests/test_lib_test_strategy.py index 5b39ee9be..0a2913337 100644 --- a/plugins/ndf/scripts/tests/test_lib_test_strategy.py +++ b/plugins/ndf/scripts/tests/test_lib_test_strategy.py @@ -582,13 +582,15 @@ def test_the_scope_test_does_not_change_the_other_limits(): @pytest.mark.parametrize("name", [ts.LOCAL_FULL, ts.ROUND_ONLY]) -def test_other_strategies_ignore_the_scope_test(name): - """AC12: local-full / round-only は max(0.10·B, 3·w) のまま。s を渡しても使わない。""" +def test_other_strategies_take_the_init_test_estimate(name): + """#2047 の I1: 戦略を問わず、s を渡せば着手前の上限は max(0.10·B, 3·s)。渡さなければ max(0.10·B, 3·w) のまま。""" strategy = ts.Strategy( name, "args", [ts.Suite("unit", "pytest .", paths=["."])], round_command="pytest ." if name == ts.ROUND_ONLY else None ) - assert ts.limits(strategy, 30, whole_seconds_value=424.0, scope_seconds=900.0, scope_source="history")["init_test_timeout"] == 1272 - assert ts.limits(strategy, 30, scope_seconds=900.0, scope_source="history")["init_test_timeout"] == 180 + # 手元の全体テストの直近の実測 m = 620 秒が w = 1,518 秒より先に効く(3·m = 1,860 秒) + assert ts.limits(strategy, 30, whole_seconds_value=1518.0, scope_seconds=620.0, scope_source="recent")["init_test_timeout"] == 1860 + assert ts.limits(strategy, 30, whole_seconds_value=424.0)["init_test_timeout"] == 1272 + assert ts.limits(strategy, 30)["init_test_timeout"] == 180 def test_supervise_limits_stay_the_same(): diff --git a/plugins/ndf/scripts/tests/test_pr_steps.py b/plugins/ndf/scripts/tests/test_pr_steps.py index 352f64705..0e68ccb7d 100644 --- a/plugins/ndf/scripts/tests/test_pr_steps.py +++ b/plugins/ndf/scripts/tests/test_pr_steps.py @@ -17,6 +17,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable FAKE_GH = """#!{py} @@ -123,7 +126,7 @@ def repo(tmp_path): def call(args, env, cwd): - p = subprocess.run([PY, str(SCRIPTS / "pr-steps.py"), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / "pr-steps.py", *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None return p.returncode, out, p.stderr @@ -511,3 +514,71 @@ def test_plan_sprint_base_summary_names_the_sprint_review(repo, env): code, out, err = call(["plan", "--base", "sprint/m1"], env, repo) assert code == 0, err assert out["summary"].endswith("新しい PR を作る(スプリントブランチ宛て。実装レビューはスプリントの PR で通す)") + + +# --- 既定ブランチ宛の本文の closing keywords(#2228・#2082) ------------------------- + + +def set_default(repo, name): + """origin の HEAD(既定ブランチ)を name にする。name が origin に無ければ develop から作る。""" + if name != "develop": + git(repo, "push", "-q", "origin", f"develop:{name}") + git(repo, "fetch", "-q", "origin") + git(repo, "remote", "set-head", "origin", name) + + +def sent_body(env): + return Path(env["FAKE_GH_LOG"] + ".body").read_text(encoding="utf-8") + + +def test_create_to_the_default_branch_defers_the_closing_keywords(repo, env, tmp_path): + sys.path.insert(0, str(SCRIPTS / "lib")) + from closing import closing_words + + set_default(repo, "develop") # 起点と既定ブランチが同じ構成 + text = "## Summary\n\nCloses #858\nfixes: o/r#3\nResolves https://github.com/o/r/issues/9\n" + code, out, err = call(["create", "--title", "題", "--body-file", str(body_file(tmp_path, text))], env, repo) + assert code == 0, err + sent = sent_body(env) + assert closing_words(sent) == [] + assert "最終工程の後に閉じる: #858" in sent and "最終工程の後に閉じる: o/r#3" in sent + m = out["metrics"] + assert m["deferred_closing"] == ["#858", "o/r#3", "https://github.com/o/r/issues/9"] + assert m["closing_issues_in_body"] == m["deferred_closing"] + assert [i["name"] for i in out["items"] if i["kind"] == "closing" and i["result"] == "deferred"] == m["deferred_closing"] + assert out["status"] == "ok" + + +def test_update_of_a_pr_to_the_default_branch_defers_the_closing_keywords(repo, env, tmp_path): + set_default(repo, "main") + env["FAKE_GH_PRS"] = json.dumps( + {"feature/x": [{"number": 7, "url": "https://github.com/o/r/pull/7", "isDraft": False, "baseRefName": "main"}]} + ) + code, out, err = call(["update", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert "Closes" not in sent_body(env) and out["metrics"]["deferred_closing"] == ["#858"] + + +def test_update_by_number_reads_the_base_of_that_pr(repo, env, tmp_path): + set_default(repo, "main") + env["FAKE_GH_VIEW"] = json.dumps({"7": {"baseRefName": "main"}}) + code, out, err = call(["update", "--pr", "7", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert out["metrics"]["deferred_closing"] == ["#858"] + + +def test_create_without_a_known_default_branch_defers_the_closing_keywords(repo, env, tmp_path): + code, out, err = call(["create", "--title", "題", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert "Closes #858" not in sent_body(env) and out["metrics"]["deferred_closing"] == ["#858"] + + +@pytest.mark.parametrize("existing", [False, True]) +def test_a_pr_to_another_branch_keeps_the_closing_keywords(repo, env, tmp_path, existing): + set_default(repo, "main") # 起点 develop ≠ 既定ブランチ main(このリポジトリの構成) + if existing: + env["FAKE_GH_PRS"] = json.dumps({"feature/x": [{"number": 7, "url": "u", "isDraft": False, "baseRefName": "develop"}]}) + code, out, err = call(["create", "--title", "題", "--body-file", str(body_file(tmp_path))], env, repo) + assert code == 0, err + assert "Closes #858" in sent_body(env) + assert out["metrics"]["deferred_closing"] == [] and out["metrics"]["closing_issues_in_body"] == ["#858"] diff --git a/plugins/ndf/scripts/tests/test_progress_record_stages.py b/plugins/ndf/scripts/tests/test_progress_record_stages.py index 0c90eb47c..ba0daf7ee 100644 --- a/plugins/ndf/scripts/tests/test_progress_record_stages.py +++ b/plugins/ndf/scripts/tests/test_progress_record_stages.py @@ -23,7 +23,6 @@ SCRIPTS = Path(__file__).resolve().parents[1] SYNC = SCRIPTS / "projects-sync.sh" RECORD = SCRIPTS / "progress-record.sh" -AGY_RECORD = SCRIPTS.parent / "dev.agy" / "scripts" / "progress-record.sh" WF_LIB = SCRIPTS.parent / "skills" / "development-workflow" / "scripts" / "lib" / "workflow-common.sh" STAGE_CHECK = SCRIPTS.parent / "skills" / "development-workflow" / "scripts" / "stage-check.sh" GUARD = SCRIPTS.parent / "skills" / "development-workflow" / "scripts" / "workflow-guard.sh" @@ -340,14 +339,6 @@ def test_the_body_lists_the_stages_of_the_one_stage_table(env: Env) -> None: assert name not in common -def test_the_agy_layout_records_through_the_symlink(env: Env) -> None: - """agy の配置(dev.agy/scripts は symlink)から呼んでも積まれる。""" - assert AGY_RECORD.exists() - out = env.run(str(AGY_RECORD), "42", "設計") - assert out.returncode == 0, out.stderr - assert env.stages(42) == ["設計"] - - @pytest.mark.parametrize("verb", ["view", "edit"]) def test_a_failing_gh_still_records_the_stage_once(env: Env, verb: str) -> None: """gh が失敗しても通過記録は 1 回だけ積まれ、結果は ok 以外(#671)。""" diff --git a/plugins/ndf/scripts/tests/test_project_decl.py b/plugins/ndf/scripts/tests/test_project_decl.py index 1662f51e0..93a1e1431 100644 --- a/plugins/ndf/scripts/tests/test_project_decl.py +++ b/plugins/ndf/scripts/tests/test_project_decl.py @@ -18,6 +18,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable SECRET = "AKIAABCDEFGHIJKLMNOP" DB_URL = "mysql://app:hunter2secret@db.internal:3306/app" @@ -142,7 +145,7 @@ def plain(tmp_path): def run(env, *args, cwd=None): - p = subprocess.run([PY, str(SCRIPTS / "project-decl.py"), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / "project-decl.py", *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() try: out = json.loads(lines[-1]) if lines else None diff --git a/plugins/ndf/scripts/tests/test_project_mvv.py b/plugins/ndf/scripts/tests/test_project_mvv.py index 6891bc24d..99adbf097 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv.py +++ b/plugins/ndf/scripts/tests/test_project_mvv.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + REPO = SCRIPTS.parents[2] MVV_PY = SCRIPTS / "project-mvv.py" GATE_PY = SCRIPTS / "mvv-gate.py" @@ -111,7 +114,7 @@ def calls(env: dict, what: str = "") -> int: def run(env: dict, *args: str, script: Path = MVV_PY) -> tuple[int, dict, str]: - p = subprocess.run([PY, str(script), *args], capture_output=True, text=True, env=env["env"], cwd=env["root"], timeout=60) + p = inproc.run(script, *args, env=env["env"], cwd=env["root"]) lines = [ln for ln in p.stdout.splitlines() if ln.startswith("{")] out = json.loads(lines[-1]) if lines else {} if out: diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py index 9abe1a696..aec663950 100644 --- a/plugins/ndf/scripts/tests/test_relay.py +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -486,8 +486,10 @@ def child_input(self, n): def wait_start(self, n, timeout=15): self.wait(lambda: len(self.starts()) >= n, timeout, f"{n} 回目の起動") - # 子が端末を raw にするまで少し待つ(raw の前の入力は行の編集に掛かる) - time.sleep(0.3) + # 子が端末を raw にするまで待つ(raw の前の入力は行の編集に掛かる)。偽の claude は raw にした直後に + # 入力の記録のファイルを作る + raw = self.fake_dir / f"input-{self.starts()[n - 1]['pid']}" + self.wait(raw.exists, timeout, f"{n} 回目の子が端末を raw にする") def finish(self, timeout=20): try: @@ -643,13 +645,16 @@ def test_run_chain_three_sections(term): def test_run_waits_for_user_input_quiet(term): - """利用者の入力から静止の秒数がたつまで /exit を送らない(AC6)。""" - t = term(env={"NDF_RELAY_QUIET": "1.5"}) + """利用者の入力から静止の秒数がたつまで /exit を送らない(AC6)。 + + 打ちかけの文字の後ろに /exit が付くため子は終わらず、終わりの待ちの後に止められる。待ちは 1 秒に縮める。""" + t = term(env={"NDF_RELAY_QUIET": "1.5", "NDF_RELAY_EXIT_WAIT": "1"}) t.wait_start(1) t.type("mark x\r") for _ in range(6): time.sleep(0.4) t.type("a") # 打っている途中 + assert len(t.starts()) == 1 assert b"/exit" not in t.child_input(0) t.wait_start(2) @@ -2136,7 +2141,7 @@ def question_rows(t, n=0): def test_guard_question_blocks_exit(term): """G1: 合図の後に質問が出たら /exit を書かない。答えた後の Stop がブロックを出せば切り替わる。""" - t = term(env={"NDF_RELAY_QUIET": "1.5"}) + t = term(env={"NDF_RELAY_QUIET": "0.5"}) t.wait_start(1) t.type("mark 次\r") # 合図の後に応答が再開して質問が出た形 t.type("q open\r") @@ -2144,7 +2149,7 @@ def test_guard_question_blocks_exit(term): assert question_rows(t)[0] == {"action": "open", "stdout": "", "code": 0} d = pathlib.Path(t.starts()[0]["relay_dir"]) assert (d / "question").exists() and (d / "next.json").exists() - time.sleep(3) + time.sleep(1.2) # 静止の待ちの 2 倍を超えても書かない assert len(t.starts()) == 1 and b"/exit" not in t.child_input(0) t.type("q close\r") t.wait(lambda: not (d / "question").exists(), what="合図が消える") @@ -2222,10 +2227,10 @@ def test_guard_lock_held_then_question_appears(term): fcntl.flock(f, fcntl.LOCK_EX) t.type("mark 次\r") t.wait(lambda: (d / "next.json").exists(), what="合図") - time.sleep(1.5) + time.sleep(0.8) # 静止の待ち(0.3 秒)の 2 倍を超えても書かない assert b"/exit" not in t.child_input(0) (d / "question").write_text("") - time.sleep(1.5) + time.sleep(0.8) assert len(t.starts()) == 1 and b"/exit" not in t.child_input(0) t.type("q close\r") t.type("mark 次\r") @@ -3515,7 +3520,7 @@ def test_slow_usage_endpoint_does_not_stall_relay(term, accounts): accounts.add("b", util5=30) t = account_term(term, accounts) t.wait_start(1) - accounts.fake.delay = 8 # 定期の確認(1 秒ごと)の取得が待たされる + accounts.fake.delay = 3 # 定期の確認(1 秒ごと)の取得が、下で打つ時点(1.5 秒後)にまだ待たされている time.sleep(1.5) pid = t.starts()[0]["pid"] sent = time.time() diff --git a/plugins/ndf/scripts/tests/test_release_record.py b/plugins/ndf/scripts/tests/test_release_record.py index c7d1847d0..aa6836cff 100644 --- a/plugins/ndf/scripts/tests/test_release_record.py +++ b/plugins/ndf/scripts/tests/test_release_record.py @@ -212,7 +212,9 @@ def test_sprint_close_closes_with_the_record_the_step_wrote(repo, gh, tmp_path): encoding="utf-8", ) env = {**gh.env, "PATH": f"{bindir}{os.pathsep}{os.environ['PATH']}", "FAKE_GH_STATE": str(state)} - code, out, err = harness.call("sprint-close.py", ["--record-pr", "40", "--repo", "o/r", "--issues", "5", "--dry-run"], env, repo) + code, out, err = harness.call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "40", "--repo", "o/r", "--issues", "5", "--dry-run"], env, repo + ) assert code == 0, (out, err) assert harness.issues_of(out)["o/r#5"]["result"] == "would_close" diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index 515ddc6cf..d1f6b5a99 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -14,6 +14,9 @@ import pytest SCRIPT = Path(__file__).resolve().parents[1] / "release-steps.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable @@ -497,7 +500,7 @@ def test_notes_and_changelog_stop_when_no_pr_is_merged(repo, tmp_path): before = [f.read_bytes() for f in files] base = ["--root", str(repo), "--version", "1.2.3-dev.1", "--prs", "13"] for args in (["notes", *base], ["notes", *base, "--approval", "issues/approval.md"], ["changelog", *base]): - p = subprocess.run([PY, str(SCRIPT), *args], capture_output=True, text=True, env=env) + p = inproc.run(SCRIPT, *args, env=env) assert p.returncode == 3, args assert [f.read_bytes() for f in files] == before, args @@ -767,7 +770,7 @@ def m744_repo(root: Path) -> tuple[Path, str]: def others_run(root: Path, env: dict, *args: str) -> tuple[int, dict]: - p = subprocess.run([PY, str(SCRIPT), "changed-plugins", "--root", str(root), *args], capture_output=True, text=True, env=env) + p = inproc.run(SCRIPT, "changed-plugins", "--root", str(root), *args, env=env) return p.returncode, json.loads(p.stdout.strip().splitlines()[-1]) diff --git a/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py b/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py index 8ffb86cd4..2d0ed67fe 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_acceptance.py @@ -8,6 +8,7 @@ from __future__ import annotations import importlib.util +import shlex import sys from pathlib import Path @@ -23,7 +24,15 @@ sys.path.insert(0, str(HERE.parent / "lib")) import acceptance # noqa: E402 -CLOSE = ["--record-pr", "20", "--repo", "o/r", "--with-verification"] +CLOSE = [ + "--record-pr", + "20", + "--repo", + "o/r", + "--with-verification", + "--final-stage", + "配布", +] # 条件 2 を見る(条件 3 は test_sprint_close_final_stage.py) THREE = "# 課題\n\n## 受け入れ条件\n\n- [ ] 1. 一\n- [ ] 2. 二\n- [ ] 3. 三\n\n## 進行\n\nモード: standard\n" @@ -96,6 +105,21 @@ def test_a_table_without_issue_rows_keeps_every_issue_open(repo, gh): assert not calls(gh, ["issue", "close"]) and not calls(gh, ["api"]) +def test_a_table_without_issue_rows_closes_an_issue_verified_before_merge(repo, gh): + """導入の確認だけの表でも、すべての条件をマージ前に確かめた課題は閉じる。他は対応の読めない kept_open(#2144)。""" + record = "\n## 受け入れ条件の確認\n\n| 条件 | 区分 | 手段 |\n| --- | --- | --- |\n" + "".join( + f"| {i} | マージ前 | `pytest` |\n" for i in (1, 2, 3) + ) + setup(gh, [("—", "導入の確認", "合格")], {"o/r#1": THREE + record, "o/r#2": THREE}, prs="Fixes #1, fixes #2") + code, out, err = call("sprint-close.py", CLOSE, gh.env, repo) + assert code == 0, (out, err) + got = issues_of(out) + assert got["o/r#1"]["result"] == "closed" and got["o/r#1"]["checked"] == [1, 2, 3] + assert (got["o/r#2"]["result"], got["o/r#2"]["reason"]) == ("kept_open", "条件と課題の対応が読めない") + reads = [c for c in calls(gh, ["issue", "view"]) if "state,body" in c] + assert sorted(c[2] for c in reads) == ["1", "2"] # 本文は課題ごとに 1 回だけ読む + + # --- 閉じる条件 2 と印(受け入れ条件 3〜11) ------------------------------------------ @@ -109,6 +133,16 @@ def test_a_criterion_without_a_passing_row_keeps_the_issue_open(repo, gh): assert gh.get()["issue_bodies"]["o/r#1"] == THREE +def test_a_pre_merge_criterion_needs_no_passing_row(repo, gh): + """本文の `## 受け入れ条件の確認` でマージ前に確かめたと記録した条件 3 は、合格の行が無くても閉じる(#2144)。""" + record = "\n## 受け入れ条件の確認\n\n| 条件 | 区分 | 手段 |\n| --- | --- | --- |\n| 3 | マージ前 | `pytest -k three` |\n" + setup(gh, [("#1", "1. 一", "合格"), ("#1", "2. 二", "合格")], {"o/r#1": THREE + record}) + code, out, err = call("sprint-close.py", CLOSE, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0, (out, err) + assert it["result"] == "closed" and it["checked"] == [1, 2, 3] + + def test_all_criteria_passed_closes_and_marks_before_closing(repo, gh): """1〜3 がそろうと閉じ、本文の 3 個のチェックボックスに印が付く。書くのは閉じる前(受け入れ条件 4)。""" setup(gh, [("#1", f"{i}. c", "合格") for i in (1, 2, 3)], {"o/r#1": THREE}) @@ -182,7 +216,7 @@ def test_a_failed_write_does_not_close(repo, gh): code, out, err = call("sprint-close.py", CLOSE, gh.env, repo) it = issues_of(out)["o/r#1"] assert code == 1 and it["result"] == "failed" and it["reason"] == "受け入れ条件の印を書けない" - assert it["cmd"].endswith("sprint-close.py " + " ".join(CLOSE)) + assert it["cmd"].endswith("sprint-close.py " + shlex.join(CLOSE)) assert not calls(gh, ["issue", "close"]) @@ -195,7 +229,13 @@ def test_issue_without_checkboxes_is_judged_by_the_rows_only(repo, gh, body): assert not calls(gh, ["api"]) -@pytest.mark.parametrize("args", [["--record-pr", "20", "--repo", "o/r"], ["--record-pr", "0", "--repo", "o/r", "--issues", "1"]]) +@pytest.mark.parametrize( + "args", + [ + ["--record-pr", "20", "--repo", "o/r", "--final-stage", "配布"], + ["--record-pr", "0", "--repo", "o/r", "--issues", "1", "--final-stage", "配布"], + ], +) def test_paths_without_verification_do_not_read_the_body(repo, gh, args): """--with-verification なし・--record-pr 0 は本文を読まず書かない(受け入れ条件 12)。""" setup(gh, [("#1", "1. c", "合格")], {"o/r#1": THREE}) @@ -226,7 +266,7 @@ def test_body_is_read_inside_the_issue_lock_and_a_held_lock_fails(tmp_path, monk """本文の読み書きは課題ごとの錠の中で行う。錠を取れなければ failed(非機能の条件)。""" monkeypatch.setenv("NDF_LOCK_DIR", str(tmp_path)) mod = load_sprint_close() - ctx = mod.CloseContext(str(tmp_path), "o/r", "c") + ctx = mod.CloseContext(str(tmp_path), "o/r", "c", final_stage="配布") seen = [] def read(root, repo_, n): @@ -256,7 +296,9 @@ def test_a_check_that_changes_more_than_the_marks_is_not_written(tmp_path, monke monkeypatch.setattr(mod.acceptance, "check_criteria", lambda body, nums: body.replace("# 課題", "# 変えた")) wrote = [] monkeypatch.setattr(mod.gh_rest, "update_body", lambda *a: wrote.append(a) or True) - it = mod._close_verified(_A(), mod.CloseContext(str(tmp_path), "o/r", "c"), "o/r", 1, mod.IssueRows(True, frozenset({1, 2, 3}))) + it = mod._close_verified( + _A(), mod.CloseContext(str(tmp_path), "o/r", "c", final_stage="配布"), "o/r", 1, mod.IssueRows(True, frozenset({1, 2, 3})) + ) assert it["result"] == "failed" and not wrote diff --git a/plugins/ndf/scripts/tests/test_sprint_close_final_stage.py b/plugins/ndf/scripts/tests/test_sprint_close_final_stage.py new file mode 100644 index 000000000..9f6b5cfcb --- /dev/null +++ b/plugins/ndf/scripts/tests/test_sprint_close_final_stage.py @@ -0,0 +1,166 @@ +"""sprint-close.py の閉じる条件 3(最終工程の記録)と閉じる予定の参照(#2228・#2119・#2082)。 + +gh の偽物は test_sprint_close_merge_green.py のものを使う。記録の PR のコメントは url に `#issuecomment-<識別子>` を持つ。 +""" + +from __future__ import annotations + +import importlib.util +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +_spec = importlib.util.spec_from_file_location("sprint_close_harness_final", HERE / "test_sprint_close_merge_green.py") +harness = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(harness) +call, issues_of, DIST_PROD, verify_block = harness.call, harness.issues_of, harness.DIST_PROD, harness.verify_block +repo, gh = harness.repo, harness.gh # pytest の fixture + +URL = "https://github.com/o/r/pull/20#issuecomment-{}" + + +def retro(cid): + return f"# 課題\n\n本文\n\n振り返り: https://github.com/o/r/pull/21#issuecomment-{cid}\n" + + +def comment(body, cid): + return {"body": body, "url": URL.format(cid)} + + +def setup(gh, issue_body, *, dist_in_comment=False, comments=None, state="OPEN", prs="Fixes #1"): + """記録の PR 20 と、PR 11 が閉じる課題 #1。dist_in_comment なら配布の記録を識別子 200 のコメントに置く。""" + if comments is None: + comments = [comment(DIST_PROD, 200)] if dist_in_comment else [comment("メモ", 100)] + gh.set( + records={"20": {"body": "記録" if dist_in_comment else DIST_PROD, "comments": comments}}, + bodies={"11": prs, "12": ""}, + issues={"o/r#1": [state]}, + issue_bodies={"o/r#1": issue_body}, + ) + + +def wrote(gh): + st = gh.get() + return "closed" in st or "written" in st or any(c[:2] == ["project", "item-edit"] for c in st["calls"]) + + +@pytest.mark.parametrize("verify", [False, True]) +def test_an_issue_without_the_retro_record_is_kept_open(repo, gh, verify): + comments = [comment("メモ", 100)] + ([comment(verify_block("1.1.0", [("#1", "合格")]), 101)] if verify else []) + setup(gh, "# 課題\n\n本文\n", comments=comments) + args = ["--record-pr", "20", "--repo", "o/r"] + (["--with-verification"] if verify else []) + code, out, err = call("sprint-close.py", args, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0 and it["result"] == "kept_open" and "振り返り" in it["reason"], err + assert not wrote(gh) + assert out["metrics"]["final_stage"] == "振り返り" + assert out["decisions"][0]["decision"] == "最終工程を 振り返り として判定した" + assert "振り返り" in (out.get("next") or "") + + +def test_a_retro_line_of_a_previous_run_does_not_count(repo, gh): + setup(gh, retro(150), dist_in_comment=True) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert it["result"] == "kept_open" and "振り返り" in it["reason"] and "前のもの" in it["reason"] + setup(gh, retro(150) + retro(250), dist_in_comment=True) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + assert sum(1 for c in gh.get()["calls"] if c[:3] == ["pr", "view", "20"]) == 1 + + +def test_a_record_in_the_body_compares_with_the_first_comment(repo, gh): + setup(gh, retro(100), comments=[comment("振り返りの記録", 100), comment("x", 300)]) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + + +def test_a_record_without_comments_has_no_base_and_keeps_open(repo, gh): + setup(gh, retro(100), comments=[]) + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert it["result"] == "kept_open" and "比べる配布の記録のコメントが無い" in it["reason"] + + +def test_a_closed_issue_without_the_record_is_not_reopened(repo, gh): + setup(gh, "本文", state="CLOSED") + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert it["result"] == "closed_unverified" and "振り返り" in it["reason"] + assert not any(c[:2] == ["issue", "reopen"] for c in gh.get()["calls"]) + + +@pytest.mark.parametrize("verify", [False, True]) +def test_an_unreadable_body_fails_and_stops(repo, gh, verify): + comments = [comment("メモ", 100)] + ([comment(verify_block("1.1.0", [("#1", "合格")]), 101)] if verify else []) + setup(gh, retro(100), comments=comments, state=None) + args = ["--record-pr", "20", "--repo", "o/r"] + (["--with-verification"] if verify else []) + code, out, _ = call("sprint-close.py", args, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 1 and out["status"] == "stopped" and it["result"] == "failed" and "最終工程" in it["reason"] + assert it["cmd"].endswith(" ".join(args)) and not wrote(gh) + + +def test_the_retro_record_and_passing_rows_close_and_mark(repo, gh): + body = "# 課題\n\n## 受け入れ条件\n\n- [ ] 1. 一\n\n振り返り: https://github.com/o/r/pull/21#issuecomment-500\n" + rows = "| 課題 | 受け入れ条件 | 実行したこと | 実行時刻 | 結果 |\n| --- | --- | --- | --- | --- |\n| #1 | 1. 一 | x | t | 合格 |\n" + setup(gh, body, comments=[comment(f"## リリース後テスト\n対象の版: 1.1.0(2026-09-24)\n\n{rows}\n合否: 済\n", 101)]) + code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0 and it["result"] == "closed" and it["checked"] == [1], it + assert gh.get()["issue_bodies"]["o/r#1"].count("- [x] 1.") == 1 + + +def test_record_zero_closes_with_a_retro_line_and_keeps_without(repo, gh): + setup(gh, retro(5)) + code, out, _ = call("sprint-close.py", ["--record-pr", "0", "--repo", "o/r", "--issues", "1"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + setup(gh, "本文") + code, out, _ = call("sprint-close.py", ["--record-pr", "0", "--repo", "o/r", "--issues", "1"], gh.env, repo) + assert issues_of(out)["o/r#1"]["result"] == "kept_open" + + +def test_record_unknown_does_not_read_the_body(repo, gh): + setup(gh, retro(5)) + code, out, _ = call("sprint-close.py", ["--record-pr", "unknown", "--repo", "o/r", "--issues", "1"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "kept_open" + assert not any(c[:2] == ["issue", "view"] for c in gh.get().get("calls", [])) + + +def test_the_verification_stage_always_reads_the_rows(repo, gh): + setup(gh, "本文", comments=[comment(verify_block("1.1.0", [("#1", "不合格")]), 101)]) + args = ["--record-pr", "20", "--repo", "o/r", "--final-stage", "リリース後テスト"] + code, out, _ = call("sprint-close.py", args, gh.env, repo) + it = issues_of(out)["o/r#1"] + assert code == 0 and it["result"] == "kept_open" and "合格でない" in it["reason"] + assert out["metrics"]["final_stage"] == "リリース後テスト" + + +def test_the_distribution_stage_closes_without_a_retro_line(repo, gh): + setup(gh, "本文") + code, out, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--final-stage", "配布"], gh.env, repo) + assert code == 0 and issues_of(out)["o/r#1"]["result"] == "closed" + + +def test_an_unknown_final_stage_is_a_call_error(repo, gh): + setup(gh, "本文") + code, _, _ = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--final-stage", "棚卸し"], gh.env, repo) + assert code == 3 + + +def test_a_pr_with_only_deferred_references_collects_its_issue(repo, gh): + """既定ブランチ宛で置き換えた本文だけのスプリントでも、課題が閉じる対象になる(#2082 の受け入れ条件 3)。""" + setup(gh, retro(100), prs="## 閉じる課題\n\n最終工程の後に閉じる: #1\n") + code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--prs", "11", "--dry-run"], gh.env, repo) + assert code == 0, err + assert issues_of(out)["o/r#1"]["result"] == "would_close" and out["metrics"]["referenced_only"] == 0 + assert not wrote(gh) + + +def test_final_stage_gap_counts_only_lines_from_the_start(): + mod = harness.load_sprint_close() + body = "- 振り返り: https://github.com/o/r/pull/2#issuecomment-9\n" + assert "行が無い" in mod.final_stage_gap("振り返り", body, 1) + assert mod.final_stage_gap("振り返り", body.lstrip("- "), 9) is None + assert mod.final_stage_gap("配布", "", None) is None diff --git a/plugins/ndf/scripts/tests/test_sprint_close_issues.py b/plugins/ndf/scripts/tests/test_sprint_close_issues.py index 15f59f9ca..9a5014a16 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_issues.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_issues.py @@ -65,7 +65,12 @@ def get(self): def test_record_zero_closes_the_given_issues_without_reading_a_record(repo, gh): gh.set(issues={"o/r#1": ["OPEN"], "o/r#2": ["CLOSED"]}) - code, out, err = call("sprint-close.py", ["--record-pr", "0", "--issues", "1,2", "--repo", "o/r", "--with-verification"], gh.env, repo) + code, out, err = call( + "sprint-close.py", + ["--final-stage", "配布", "--record-pr", "0", "--issues", "1,2", "--repo", "o/r", "--with-verification"], + gh.env, + repo, + ) assert code == 0, (out, err) res = issues_of(out) assert res["o/r#1"]["result"] == "closed" and res["o/r#2"]["result"] == "already_closed" @@ -73,7 +78,7 @@ def test_record_zero_closes_the_given_issues_without_reading_a_record(repo, gh): def test_record_zero_alone_returns_two(repo, gh): - code, out, err = call("sprint-close.py", ["--record-pr", "0", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "0", "--repo", "o/r"], gh.env, repo) assert code == 2 and out["status"] == "stopped" assert gh.get().get("calls", []) == [] @@ -90,19 +95,19 @@ def test_record_unknown_keeps_the_given_issues_open_with_a_reason(repo, gh): assert gh.get().get("calls", []) == [] # 記録も状態も読まず、閉じもボードも書かない assert out["summary"].startswith("記録の置き場が分からない") and out["metrics"]["closed"] == 0 gh.set(issues={"o/r#1": ["OPEN"], "o/r#2": ["OPEN"]}) - _, zero, _ = call("sprint-close.py", ["--record-pr", "0", "--issues", "1,2", "--repo", "o/r"], gh.env, repo) + _, zero, _ = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "0", "--issues", "1,2", "--repo", "o/r"], gh.env, repo) assert zero["summary"] != out["summary"] and "置き場" not in zero["summary"] def test_record_unknown_alone_returns_two(repo, gh): - code, out, err = call("sprint-close.py", ["--record-pr", "unknown", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "unknown", "--repo", "o/r"], gh.env, repo) assert code == 2 and out["status"] == "stopped" and "unknown" in out["summary"] assert gh.get().get("calls", []) == [] @pytest.mark.parametrize("value", ["-1", "none"]) def test_record_pr_rejects_other_values(repo, gh, value): - code, _, _ = call("sprint-close.py", ["--record-pr", value, "--issues", "1", "--repo", "o/r"], gh.env, repo) + code, _, _ = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", value, "--issues", "1", "--repo", "o/r"], gh.env, repo) assert code == 3 @@ -112,7 +117,9 @@ def test_issues_are_added_to_the_closing_words_of_the_prs(repo, gh): bodies={"11": "Fixes #1", "12": ""}, issues={"o/r#1": ["OPEN"], "o/r#3": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--issues", "3,1"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--issues", "3,1"], gh.env, repo + ) assert code == 0, (out, err) assert sorted(issues_of(out)) == ["o/r#1", "o/r#3"] assert all(i["result"] == "closed" for i in issues_of(out).values()) @@ -123,6 +130,6 @@ def test_issues_alone_do_not_need_the_sprint_prs(repo, gh): records={"20": {"body": "## 配布の記録\n段階: 本番(2026-09-25 承認)\n版: 1.0.0 → 1.1.0\n", "comments": []}}, issues={"o/r#5": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--issues", "5"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--issues", "5"], gh.env, repo) assert code == 0, (out, err) assert issues_of(out)["o/r#5"]["result"] == "closed" diff --git a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py index 07a52ce24..dd6ff4e51 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py @@ -18,6 +18,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + sys.path.insert(0, str(SCRIPTS / "lib")) import approved_commit # noqa: E402 from step_result import validate_result # noqa: E402 @@ -177,7 +180,7 @@ def get(self): def call(script, args, env, cwd): - p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) + p = inproc.run(SCRIPTS / script, *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None if out is not None: @@ -206,7 +209,9 @@ def test_sprint_close_four_outcomes(repo, gh): bodies={"11": "Fixes #1, closes #2", "12": "Resolves #3 and fixes other/x#4"}, issues={"o/r#1": ["OPEN"], "o/r#2": ["CLOSED"], "o/r#3": ["OPEN"], "other/x#4": [None]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--label", "M26の振り返り"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--label", "M26の振り返り"], gh.env, repo + ) res = issues_of(out) assert res["o/r#1"]["result"] == "closed" and res["o/r#1"]["cmd"] == "gh issue reopen 1 --repo o/r" assert res["o/r#2"]["result"] == "already_closed" @@ -223,7 +228,7 @@ def test_sprint_close_failed_when_close_does_not_take(repo, gh): issues={"o/r#1": ["OPEN"]}, close_works=False, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r"], gh.env, repo) it = issues_of(out)["o/r#1"] assert code == 1 and it["result"] == "failed" and it["cmd"] == "gh issue close 1 --repo o/r" @@ -234,7 +239,7 @@ def test_sprint_close_kept_open_before_production(repo, gh): bodies={"11": "Fixes #1"}, issues={"o/r#1": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r"], gh.env, repo) assert code == 0 and out["status"] == "ok" assert issues_of(out)["o/r#1"]["result"] == "kept_open" assert "closed" not in gh.get() @@ -249,7 +254,9 @@ def test_sprint_close_verification_per_issue(repo, gh): bodies={"11": "Fixes #1", "12": "Fixes #2 fixes #3"}, issues={"o/r#1": ["OPEN"], "o/r#2": ["OPEN"], "o/r#3": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo + ) res = issues_of(out) assert res["o/r#1"]["result"] == "closed" assert res["o/r#2"]["result"] == "kept_open" and "合格でない" in res["o/r#2"]["reason"] @@ -259,13 +266,15 @@ def test_sprint_close_verification_per_issue(repo, gh): def test_sprint_close_no_verification_record_keeps_all(repo, gh): gh.set(records={"20": {"body": DIST_PROD, "comments": []}}, bodies={"11": "Fixes #1", "12": ""}, issues={"o/r#1": ["OPEN"]}) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--with-verification"], gh.env, repo + ) assert code == 0 and issues_of(out)["o/r#1"]["reason"] == "本番の版のリリース後テストの記録が無い" def test_sprint_close_empty_list_is_2(repo, gh): gh.set(records={"20": {"body": "## 配布の記録\n段階: 配布なし(文書だけ)\n", "comments": []}}) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r"], gh.env, repo) + code, out, err = call("sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r"], gh.env, repo) assert code == 2 and out["status"] == "stopped" @@ -275,7 +284,9 @@ def test_sprint_close_dry_run_writes_nothing(repo, gh): bodies={"11": "Fixes #1"}, issues={"o/r#1": ["OPEN"]}, ) - code, out, err = call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--prs", "11", "--dry-run"], gh.env, repo) + code, out, err = call( + "sprint-close.py", ["--final-stage", "配布", "--record-pr", "20", "--repo", "o/r", "--prs", "11", "--dry-run"], gh.env, repo + ) assert code == 0 and issues_of(out)["o/r#1"]["result"] == "would_close" assert "closed" not in gh.get() diff --git a/plugins/ndf/scripts/tests/test_sprint_close_references.py b/plugins/ndf/scripts/tests/test_sprint_close_references.py index 5a77e2154..9ff3ddfd4 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_references.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_references.py @@ -22,7 +22,7 @@ def run(gh, repo, *extra): - return call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", *extra], gh.env, repo) + return call("sprint-close.py", ["--record-pr", "20", "--repo", "o/r", "--final-stage", "配布", *extra], gh.env, repo) def issue_calls(gh, verb, n=None): diff --git a/plugins/ndf/scripts/tests/test_sprint_procedures.py b/plugins/ndf/scripts/tests/test_sprint_procedures.py index 16a557711..78fecdfa0 100644 --- a/plugins/ndf/scripts/tests/test_sprint_procedures.py +++ b/plugins/ndf/scripts/tests/test_sprint_procedures.py @@ -16,6 +16,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" SPRINT_STATE = SCRIPTS / "sprint-state.py" REPO = SCRIPTS.parents[2] @@ -70,7 +73,7 @@ def new_sprint(tmp_path: Path, pace: str, design: bool = True) -> dict: args += ["--design", "11"] if design else [] if pace != "normal": args += ["--pace", pace, "--state", str(sprint_state(tmp_path, pace))] - p = subprocess.run([PY, str(SUPERVISE), *args, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, "--out", str(out), cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return {w["name"]: w for w in load(out / "sprint.json")["ステージ"]} @@ -87,7 +90,7 @@ def test_sprint_waves_does_not_rebuild_the_shared_procedures(): def new_single(tmp_path: Path, kind: str, *extra: str) -> dict: repo = tmp_path / "repo" if (tmp_path / "repo").is_dir() else make_repo(tmp_path) out = tmp_path / f"{kind}-{len(list(tmp_path.glob('*.json')))}.json" - p = subprocess.run([PY, str(SUPERVISE), "new", kind, *extra, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, "new", kind, *extra, "--out", str(out), cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return load(out) diff --git a/plugins/ndf/scripts/tests/test_step_scripts.py b/plugins/ndf/scripts/tests/test_step_scripts.py index e2d6281b8..e6d564125 100644 --- a/plugins/ndf/scripts/tests/test_step_scripts.py +++ b/plugins/ndf/scripts/tests/test_step_scripts.py @@ -14,6 +14,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable FAKE_GH = """#!{py} @@ -72,8 +75,12 @@ def repo(tmp_path): return root -def call(script, args, env, cwd=None): - p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) +def call(script, args, env, cwd=None, proc=False): + """分岐はプロセスの中で main を呼ぶ。子のプロセスへ渡す入口(phase-steps.py)は `proc=True` で起動して確かめる。""" + if proc: + p = subprocess.run([PY, str(SCRIPTS / script), *args], capture_output=True, text=True, env=env, cwd=cwd) + else: + p = inproc.run(SCRIPTS / script, *args, env=env, cwd=cwd) lines = p.stdout.strip().splitlines() out = json.loads(lines[-1]) if lines else None return p.returncode, out, p.stderr @@ -431,7 +438,7 @@ def load_verification(): @pytest.mark.parametrize("installed", ["symlink", "copy", "differs", "missing"]) def test_compare_files_follows_a_symlink_to_a_directory(tmp_path, installed): - """dev.agy/skills/<名前> → ../../skills/<名前> のようなディレクトリへの symlink を比べられる。""" + """<配置>/<名前> → ../skills/<名前> のようなディレクトリへの symlink を比べられる。""" mod = load_verification() src = tmp_path / "src" / "p" (src / "skills" / "a").mkdir(parents=True) @@ -467,49 +474,6 @@ def test_compare_files_skips_symlinks_for_a_runtime_that_drops_them(tmp_path): assert out == ["codex: p/skills/a/SKILL.md"] -def kiro_project(tmp_path): - """この checkout の dev.kiro/install.sh で /proj へ導入し、(src, proj) を返す。""" - src = SCRIPTS.parents[2] - proj = tmp_path / "proj" - proj.mkdir() - p = subprocess.run( - ["bash", str(src / "plugins/ndf/dev.kiro/install.sh"), "--project", str(proj), "--yes"], - capture_output=True, - text=True, - cwd=str(src), - ) - assert p.returncode == 0, p.stdout + p.stderr - return src, proj - - -def test_compare_kiro_accepts_what_the_installer_made(tmp_path): - src, proj = kiro_project(tmp_path) - assert load_verification().compare_kiro(src, proj) == [] - - -@pytest.mark.parametrize("broken", ["skill_link", "prompt", "steering", "agent", "policy_link"]) -def test_compare_kiro_reports_missing_or_changed_outputs(tmp_path, broken): - """Kiro の生成物(skills / prompts / steering / agents)の欠落・内容違いを不一致にする。""" - src, proj = kiro_project(tmp_path) - kiro = proj / ".kiro" - if broken == "skill_link": - (kiro / "skills" / "pr").unlink() - want = "kiro: .kiro/skills/pr" - elif broken == "prompt": - (kiro / "prompts" / "pr.md").write_text("changed\n", encoding="utf-8") - want = "kiro: .kiro/prompts/pr.md" - elif broken == "steering": - (kiro / "steering" / "ndf-policies.md").write_text("stale\n", encoding="utf-8") - want = "kiro: .kiro/steering/ndf-policies.md" - elif broken == "agent": - (kiro / "agents" / "ndf.json").write_text("{", encoding="utf-8") - want = "kiro: .kiro/agents/ndf.json" - else: - (kiro / "skills" / "ndf-policies").symlink_to(src / "plugins/ndf/skills/ndf-policies") - want = "kiro: .kiro/skills/ndf-policies が残っている(steering へ移した Skill)" - assert load_verification().compare_kiro(src, proj) == [want] - - # --- phase-steps.py(互換の入口) -------------------------------------------- @@ -518,13 +482,13 @@ def test_phase_steps_forwards_with_root_before_or_after(repo, env, root_first): spec_repo(repo) sub = ["spec-finalize", "--spec", "docs/specifications/x.md", "--design", "docs/design/x-design.md"] args = ["--root", str(repo), *sub] if root_first else [*sub, "--root", str(repo)] - code, out, err = call("phase-steps.py", args, env, cwd=str(repo.parent)) + code, out, err = call("phase-steps.py", args, env, cwd=str(repo.parent), proc=True) assert code == 0, err assert out["tool"] == "plan-to-spec" and out["status"] == "ok" def test_phase_steps_forwards_exit_code(repo, env): - code, out, _ = call("phase-steps.py", [f"--root={repo}", "bump", "--plugin", "nope", "--to", "1.1.0"], env) + code, out, _ = call("phase-steps.py", [f"--root={repo}", "bump", "--plugin", "nope", "--to", "1.1.0"], env, proc=True) assert code == 3 and out["tool"] == "release" diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index a77bb6b61..2b27cdc23 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -16,6 +16,9 @@ SCRIPTS = Path(__file__).resolve().parents[1] SUPERVISE = SCRIPTS / "supervise.py" +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + PY = sys.executable sys.path.insert(0, str(SCRIPTS / "lib")) @@ -29,6 +32,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from account_fake import FakeAnthropic, accounts, window # noqa: E402,F401 +from sprint_args import new_sprint_args # noqa: E402 FAKE_GH = """#!{py} import json, sys @@ -325,6 +329,78 @@ def test_an_unconverged_review_stops_the_plan(tmp_path, monkeypatch, final, ran) steps[0]["on_exit"] = {"6": "after"} s, text = run_plan(tmp_path, steps) assert [e["id"] for e in s.state.log] == ["review", "after"], text + # 検査のプランの review(templates の雛形そのもの)も、収束しなければ後続の base-sync へ進まない + from types import SimpleNamespace + + from supervise_lib.templates import _inspect_steps + + review = next(s for s in _inspect_steps(SimpleNamespace(), "1", "x") if s["id"] == "review") + review = {**review, "drive": "fake", "args": "", "next": "after"} + s, text = run_plan(tmp_path, [review, *steps[1:]]) + assert [e["id"] for e in s.state.log] == ran, text + + +def _skipping_drive(tmp_path, monkeypatch): + drive = tmp_path / "skip-drive.py" + drive.write_text( + f"import json, sys\nsys.path.insert(0, {str(SCRIPTS / 'lib')!r})\nimport drive_pause as dp\n" + "gap = {'kind': 'refactor', 'name': 'window', 'reason': '時間の枠に収まらず飛ばした: 要る下限 61 分 > 想定最大時間 30 分'}\n" + "out = dp.incomplete('d', 's', {'skipped': {'kind': 'window', 'reason': 'r'}, 'exit': 4}, gap)\n" + "print(json.dumps(out)); sys.exit(dp.exit_code(out))\n" + ) + monkeypatch.setitem(paths.DRIVES, "skip", drive) + return [ + {"id": "refactor", "type": "drive", "drive": "skip", "on_exit": {"6": "review"}, "on_fail": "abort", "next": "review"}, + {"id": "review", "type": "run", "cmd": "true", "next": "end"}, + {"id": "abort", "type": "run", "cmd": "true", "next": "end"}, + ] + + +def test_a_skipped_refactor_goes_on_to_the_review(tmp_path, monkeypatch): + """#2047 の I6・決定 5: 時間の枠の不足で飛ばした refactor(6 と gaps)は review へ進み、報告に欠けの理由が出る。""" + steps = _skipping_drive(tmp_path, monkeypatch) + s, text = run_plan(tmp_path, steps) + assert [e["id"] for e in s.state.log] == ["refactor", "review"], text + assert s.state.log[0]["exit"] == 6 and s.state.log[0]["gaps"][0]["name"] == "window" + assert "- 欠けを残して進んだステップ: refactor(window: 時間の枠に収まらず飛ばした: 要る下限 61 分 > 想定最大時間 30 分)" in text + # on_exit を持たない旧いプランは今どおり abort へ行く(移行性) + del steps[0]["on_exit"] + s, text = run_plan(tmp_path, steps) + assert [e["id"] for e in s.state.log] == ["refactor", "abort"], text + + +def _extending_drive(tmp_path, monkeypatch, seconds): + """延長のファイルへ `seconds` を書いてから 2 秒眠る駆動。""" + drive = tmp_path / "slow-drive.py" + drive.write_text( + f"import json, os, sys, time\nsys.path.insert(0, {str(SCRIPTS / 'lib')!r})\nimport drive_pause as dp\n" + f"dp.write_extension({seconds}, '想定最大時間を下限の 40 分へ上げた')\ntime.sleep(2)\n" + "out = dp.done('d', 's', 'r.md', {})\nprint(json.dumps(out)); sys.exit(dp.exit_code(out))\n" + ) + monkeypatch.setitem(paths.DRIVES, "slow", drive) + return [{"id": "refactor", "type": "drive", "drive": "slow", "timeout": 1, "on_fail": "end", "next": "end"}] + + +def test_the_drive_extends_the_step_deadline(tmp_path, monkeypatch): + """#2047 の決定 4・I7: 子が書いた秒に余裕を足した締め切りまで待ち、延ばしたことを試行と進捗ログに残す。""" + s, text = run_plan(tmp_path, _extending_drive(tmp_path, monkeypatch, 1)) + assert s.state.log[0]["exit"] == 0, text + assert s.state.log[0]["extended_timeout"] == {"seconds": 1, "timeout": 31, "planned": 1, "reason": "想定最大時間を下限の 40 分へ上げた"} + rows = [json.loads(line) for line in (s.state.dir / "progress.jsonl").read_text().splitlines()] + assert any(r["kind"] == "extend" and r["timeout"] == 31 for r in rows) + + +def test_a_drive_without_the_extension_is_cut_at_the_plan_timeout(tmp_path, monkeypatch): + steps = _extending_drive(tmp_path, monkeypatch, 0) + s, _ = run_plan(tmp_path, steps) + assert s.state.log[0]["exit"] == 1 and "extended_timeout" not in s.state.log[0] + + +def test_run_ticking_moves_the_deadline_only_later(): + """I7: extend の時刻が締め切りより前なら使わない。後ろなら延ばす。""" + assert claude.run_ticking("sleep 1.5", shell=True, timeout=0.5, every=0.1, extend=lambda started: started + 5).returncode == 0 + with pytest.raises(subprocess.TimeoutExpired): + claude.run_ticking("sleep 3", shell=True, timeout=0.5, every=0.1, extend=lambda started: started + 0.1) @pytest.mark.parametrize("drive", ["cross-review", "cross-refactoring"]) @@ -447,7 +523,7 @@ def test_queue_creates_worktrees_in_order_before_run(tmp_path, monkeypatch): def cli(*args, cwd=None): """既定ではこのリポジトリの根で打つ(作業場所に宣言が無いとき、今のディレクトリの .ndf/ を読む)。""" - return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd or REPO) + return inproc.run(SUPERVISE, *args, cwd=cwd or REPO) AI_PLUGINS_WORDS = ("plugins/", "playwright-kit", "build-runtime-plugins", "claude,codex,kiro", "develop", "scripts/check-") @@ -657,22 +733,7 @@ def test_new_check_skips_refactor_for_modes_without_it(tmp_path, mode): def test_new_sprint_check_for_light_goes_from_pr_to_review(tmp_path): out = tmp_path / "m" - p = cli( - "new", - "sprint", - "--name", - "m", - "--worktree", - str(tmp_path), - "--issue", - "1", - "--mode", - "light", - "--version", - "10.18.0-dev.1", - "--out", - str(out), - ) + p = cli(*new_sprint_args(tmp_path, out, "--mode", "light")) assert p.returncode == 0, p.stderr waves = {w["name"]: w for w in json.loads((out / "sprint.json").read_text())["ステージ"]} steps = {s["id"]: s for s in json.loads(Path(waves["検査"]["plans"][0]).read_text())["steps"]} @@ -885,6 +946,7 @@ def _report_state_rich(st): - 理由: 無し - 通ったステップ: {empty} - 件数: 無し +- 欠けを残して進んだステップ: 無し - LLM の使用量: 入力 0 / cache read 0 / cache write 0 / 出力 0 / $0.000 - CLI(drive・work の中): 無し - 記録: {dir} @@ -912,6 +974,7 @@ def _report_state_rich(st): - 理由: 承認を待つ - 通ったステップ: impl(exit=0) → judge[続ける] → show(exit=12) → quiet(exit=12) → wait(exit=0) → wait2(exit=None) - 件数: impl: passed 3 / skipped 0; judge: 無し +- 欠けを残して進んだステップ: 無し - LLM の使用量: 入力 56 / cache read 200 / cache write 40 / 出力 14 / $1.012 - CLI(drive・work の中): 無し - 記録: {dir} @@ -1451,6 +1514,7 @@ def test_new_release_dev_and_prod(tmp_path): assert st["facts"]["presentation_to"] == "issues/approval-ndf-v10.17.11.md" assert st["facts"]["gate_next"] == "explain" and st["verify"]["cwd"] == "/r" assert "--ref develop" in st["verify"]["cmd"] and "--channel dev" in st["release"]["cmd"] + assert st["verify"]["stage"] == "配布" # 開発版の導入の確認は「配布」(#2288 の AC2) p = cli( "new", @@ -1484,7 +1548,9 @@ def test_new_release_dev_and_prod(tmp_path): "fix", "judge-record", ] - assert "--ref main" in st["verify"]["cmd"] and st["verify"]["stage"] == "リリース後テスト" + # 導入の確認だけのステップは「リリース後テスト」と記録しない。それは仕上げの release-verify が担う(#2288 の AC1) + assert "--ref main" in st["verify"]["cmd"] and st["verify"]["stage"] == "配布" + assert not [s for s in plan["steps"] if s.get("stage") == "リリース後テスト"] assert st["verify"]["next"] == "cleanup" and "facts" not in st # 先頭の approve が開発版の facts の出力から承認したコミットを読む(#1426 の AC9) assert "approve --approval issues/approval-ndf-v10.17.11.md --facts-from '{queue_state?:release}' --by user" in st["approve"]["cmd"] @@ -1512,23 +1578,7 @@ def test_new_release_requires_version(): def test_new_sprint_writes_waves_in_order(tmp_path): out = tmp_path / "m" - p = cli( - "new", - "sprint", - "--name", - "v10-18", - "--worktree", - str(tmp_path), - "--issue", - "11", - "12", - "--design", - "11", - "--version", - "10.18.0-dev.1", - "--out", - str(out), - ) + p = cli(*new_sprint_args(tmp_path, out, name="v10-18", issue=("11", "12"), design=("11",))) assert p.returncode == 0, p.stderr res = json.loads(p.stdout) assert res["status"] == "ok" @@ -1618,7 +1668,7 @@ def test_new_sprint_writes_waves_in_order(tmp_path): def test_new_sprint_without_design_skips_gate(tmp_path): out = tmp_path / "m" - p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + p = cli(*new_sprint_args(tmp_path, out)) assert p.returncode == 0, p.stderr names = [w["name"] for w in json.loads((out / "sprint.json").read_text())["ステージ"]] assert names == ["スプリントブランチ", "実装", "検査", "配布", "関門 2", "本番"] @@ -2463,10 +2513,7 @@ def test_new_check_builds_the_refactor_scope_from_the_pr_files(tmp_path): def test_new_sprint_check_hands_the_declared_tests_and_scope_to_the_same_builder(tmp_path): out = tmp_path / "m" - p = cli( - "new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", - "--tests", "tests/cli", "--scope", "lib/a", "--out", str(out), - ) # fmt: skip + p = cli(*new_sprint_args(tmp_path, out, "--tests", "tests/cli", "--scope", "lib/a")) assert p.returncode == 0, p.stderr check = next(out.glob("*-check.json")) refactor = next(s for s in json.loads(check.read_text())["steps"] if s["id"] == "refactor") @@ -2493,7 +2540,7 @@ def test_refactor_step_stops_with_the_scope_exit_code_before_starting_the_drive( def test_new_sprint_check_and_release_run_without_a_whole_skill(tmp_path): out = tmp_path / "m" - p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + p = cli(*new_sprint_args(tmp_path, out)) assert p.returncode == 0, p.stderr waves = {w["name"]: w for w in json.loads((out / "sprint.json").read_text())["ステージ"]} for name in ("検査", "配布"): @@ -2510,7 +2557,7 @@ def test_new_sprint_check_and_release_run_without_a_whole_skill(tmp_path): def test_normal_sprint_puts_gate_2_and_prod_after_the_dev_release(tmp_path): """normal の雛形の経路は配布の後に関門 2 と本番を置き、本番は承認の後に単独で流す(#1914)。""" out = tmp_path / "m" - p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + p = cli(*new_sprint_args(tmp_path, out)) assert p.returncode == 0, p.stderr index = json.loads((out / "sprint.json").read_text())["ステージ"] assert [w["name"] for w in index][-3:] == ["配布", "関門 2", "本番"] @@ -2533,7 +2580,7 @@ def test_prod_plan_queued_alone_takes_the_check_pr(tmp_path): from supervise_lib import paths, queue out = tmp_path / "m" - p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + p = cli(*new_sprint_args(tmp_path, out)) assert p.returncode == 0, p.stderr waves = {w["name"]: w for w in json.loads((out / "sprint.json").read_text())["ステージ"]} check, prod = waves["検査"]["plans"][0], waves["本番"]["plans"][0] diff --git a/plugins/ndf/scripts/tests/test_supervise_contract.py b/plugins/ndf/scripts/tests/test_supervise_contract.py index cf12e44e5..4478b05ab 100644 --- a/plugins/ndf/scripts/tests/test_supervise_contract.py +++ b/plugins/ndf/scripts/tests/test_supervise_contract.py @@ -26,6 +26,9 @@ PLUGIN = SCRIPTS.parent SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + FIXTURES = Path(__file__).resolve().parent / "fixtures" / "supervise-contract" SUBCOMMANDS = ["run", "history", "expected", "example", "new", "queue", "wait", "design-glossary", "note", "sync-check"] KINDS = ["sprint", "impl", "fix", "check", "release", "close"] @@ -37,6 +40,11 @@ def cli(*args: str, cwd: Path, timeout: float = 120) -> subprocess.CompletedProcess: + """分岐と結果の形はプロセスの中で main を呼んで確かめる(起動の配線は `cli_proc` の代表の 1 件)。""" + return inproc.run(SUPERVISE, *args, cwd=cwd, env=dict(os.environ, COLUMNS="200")) + + +def cli_proc(*args: str, cwd: Path, timeout: float = 120) -> subprocess.CompletedProcess: env = dict(os.environ, COLUMNS="200") return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd, env=env, timeout=timeout) @@ -214,6 +222,6 @@ def test_the_fixture_plans_run_without_claude(tmp_path, monkeypatch): stub.write_text("#!/bin/sh\necho called >&2\nexit 99\n") stub.chmod(0o755) monkeypatch.setenv("PATH", f"{bindir}{os.pathsep}{os.environ['PATH']}") - p = cli("run", str(runnable(tmp_path, "ok")), cwd=tmp_path) + p = cli_proc("run", str(runnable(tmp_path, "ok")), cwd=tmp_path) assert p.returncode == 0 and "called" not in p.stderr, p.stderr assert shutil.which("claude") == str(bindir / "claude") diff --git a/plugins/ndf/scripts/tests/test_supervise_limits.py b/plugins/ndf/scripts/tests/test_supervise_limits.py index 6a0c24c2e..e3661a2a3 100644 --- a/plugins/ndf/scripts/tests/test_supervise_limits.py +++ b/plugins/ndf/scripts/tests/test_supervise_limits.py @@ -120,11 +120,22 @@ def reason_of(text: str) -> str: # --- L1・I1: 雛形ごとに、修正のループのそれぞれを 3 回ずつ通しても上限で止まらない --- -def test_every_template_plan_gets_through_each_fix_loop_three_times(plans, tmp_path): +def _failing_sets(plans: dict[str, dict]) -> dict[frozenset, tuple[str, dict]]: + """修正のループの入口の組ごとに、最初のプランを 1 つ(総当たりを代表の組に絞る。修正のループの無いプランは除く)。""" + reps: dict[frozenset, tuple[str, dict]] = {} for name, plan in plans.items(): fixes, _ = limits.loops(plan) failing = {k.split("→")[0] for k in fixes} - failing = {c for c in failing if any(s["id"] == c and s.get("on_fail") for s in plan["steps"])} + failing = frozenset(c for c in failing if any(s["id"] == c and s.get("on_fail") for s in plan["steps"])) + if failing: + reps.setdefault(failing, (name, plan)) + return reps + + +def test_every_fix_loop_shape_gets_through_each_fix_loop_three_times(plans, tmp_path): + reps = _failing_sets(plans) + assert len(reps) >= 4, sorted(n for n, _ in reps.values()) # impl・check・release・release-prod の形 + for failing, (name, plan) in reps.items(): visits: dict[str, int] = {} def decide(sid, step, failing=failing, visits=visits): diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 8668ebbf1..8fd5da3ed 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -11,19 +11,25 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable sys.path.insert(0, str(SCRIPTS)) from supervise_lib import commands, engine, pr as pr_step # noqa: E402 +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from sprint_args import new_sprint_args # noqa: E402 + # new sprint は --design に無い課題の本文を gh で読む(#1767)。見本の本文で答える pytestmark = pytest.mark.usefixtures("issue_bodies") def cli(*args, cwd): - return subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=cwd) + return inproc.run(SUPERVISE, *args, cwd=cwd) def git(cwd, *args): @@ -43,25 +49,7 @@ def plain_repo(tmp_path, release=None): def new_sprint(root, out, *extra): - return cli( - "new", - "sprint", - "--name", - "m6", - "--worktree", - str(root), - "--issue", - "216", - "247", - "--design", - "216", - "--version", - "3.8.0-dev.1", - "--out", - str(out), - *extra, - cwd=root, - ) + return cli(*new_sprint_args(root, out, *extra, name="m6", issue=("216", "247"), design=("216",), version="3.8.0-dev.1"), cwd=root) # --- #1192: リリースの雛形の無い形 ------------------------------------------------- @@ -145,7 +133,7 @@ def test_new_close_without_release_form_puts_manual_stage(tmp_path, delivery): def test_new_sprint_and_close_with_package_plugin_still_need_versions(tmp_path): """#1336 の I6: 雛形で組む経路だけが版数を要する。""" root = plain_repo(tmp_path, {"form": "package-plugin", "plugin": "foo", "runtimes": ["claude"]}) - p = cli("new", "sprint", "--name", "m6", "--worktree", str(root), "--issue", "216", "--out", str(tmp_path / "m"), cwd=root) + p = cli(*new_sprint_args(root, tmp_path / "m", name="m6", issue=("216",), version=None), cwd=root) assert p.returncode == 2 and "--version" in p.stderr p = close_cli(root, tmp_path / "c", "--version", "3.8.0-dev.1") assert p.returncode == 2 and "--prod" in p.stderr and "--version" not in p.stderr.split("無い:")[-1] @@ -199,7 +187,7 @@ def test_samples_start_and_close_a_sprint_without_versions(tmp_path, name): (root / ".ndf" / "project.json").write_text(json.dumps({"delivery": rows}, ensure_ascii=False)) (root / ".ndf" / "pace.json").write_text(json.dumps({"version": 1, "fast": {"enabled": True, "verify": "true"}})) out = tmp_path / "m" - p = cli("new", "sprint", "--name", "m6", "--worktree", str(root), "--issue", "216", "--out", str(out), cwd=root) + p = cli(*new_sprint_args(root, out, name="m6", issue=("216",), version=None), cwd=root) assert p.returncode == 0, p.stderr manifest = json.loads((out / "sprint.json").read_text()) assert [(r["route"], r["stage"]) for r in manifest["リリースの経路"]] == expected diff --git a/plugins/ndf/scripts/tests/test_supervise_pace.py b/plugins/ndf/scripts/tests/test_supervise_pace.py index e27bc1f90..f6fec9272 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace.py @@ -25,6 +25,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from pace_state import sprint_state # noqa: E402 +from sprint_args import new_sprint_args # noqa: E402 # new sprint は --design に無い課題の本文を gh で読む(#1767)。見本の本文で答える @@ -57,26 +58,7 @@ def assert_transitions_exist(plan: dict) -> None: def new_fast_sprint(tmp_path, state, *extra): out = tmp_path / "m" p = cli( - "new", - "sprint", - "--name", - "m26", - "--worktree", - str(tmp_path), - "--issue", - "11", - "12", - "--design", - "11", - "--version", - "10.18.0-dev.1", - "--pace", - "fast", - "--state", - str(state), - "--out", - str(out), - *extra, + *new_sprint_args(tmp_path, out, "--pace", "fast", "--state", str(state), *extra, name="m26", issue=("11", "12"), design=("11",)) ) return p, out @@ -167,25 +149,7 @@ def test_fast_sprint_is_refused_without_the_declaration(tmp_path): (repo / ".ndf" / "worktree.json").write_text('{"version": 1, "base_branch": "develop", "production_branch": "main"}') (repo / ".ndf" / "supervise.json").write_text((REPO / ".ndf" / "supervise.json").read_text()) out = tmp_path / "m" - p = cli( - "new", - "sprint", - "--name", - "m", - "--worktree", - str(repo), - "--issue", - "1", - "--version", - "1.0.0-dev.1", - "--pace", - "fast", - "--state", - str(sprint_state(tmp_path, "fast")), - "--out", - str(out), - cwd=repo, - ) + p = cli(*new_sprint_args(repo, out, "--pace", "fast", "--state", str(sprint_state(tmp_path, "fast")), version="1.0.0-dev.1"), cwd=repo) assert p.returncode == 1 and "pace.json" in json.loads(p.stdout)["summary"] @@ -291,7 +255,7 @@ def test_check_since_last_with_pr_is_a_usage_error(tmp_path): # ---------- new close ---------- -def test_close_runs_spec_close_and_retro_once_each_in_order(tmp_path): +def test_close_runs_spec_retro_and_close_once_each_in_order(tmp_path): out = tmp_path / "close" p = cli( "new", @@ -325,19 +289,21 @@ def test_close_runs_spec_close_and_retro_once_each_in_order(tmp_path): stages = [s.get("stage") for s in close["steps"]] for stage in ("確定仕様化", "後片付け", "振り返り"): assert stages.count(stage) == 1 - assert stages.index("確定仕様化") < stages.index("後片付け") < stages.index("振り返り") + assert stages.index("確定仕様化") < stages.index("振り返り") < stages.index("後片付け") # 振り返りの後に閉じる(#2228) cl = steps_of(close)["close"]["cmd"] assert "--record-pr {queue_pr:release-prod}" in cl and "--issues 11,12" in cl and "--with-verification" in cl + assert "--final-stage 振り返り" in cl assert close["課題"] == [11, 12] and close.get("記録") cmd = waves["最終の検査"]["command"] assert cmd.count("--then") == 3 assert_transitions_exist(close) # 棚卸しは振り返りの後の別のステップ(#842)。振り返りのプロンプトは棚卸しを含まない steps = steps_of(close) - assert steps["retro"]["next"] == "refine" and "backlog-refinement" not in steps["retro"]["prompt"] + assert steps["retro"]["next"] == "close" and steps["close"]["next"] == "refine" + assert "backlog-refinement" not in steps["retro"]["prompt"] refine = steps["refine"] assert refine["stage"] == "棚卸し" and refine["type"] == "work" and refine["timeout"] > 0 - assert refine["next"] == "end" and stages.index("振り返り") < stages.index("棚卸し") + assert refine["next"] == "end" and stages.index("後片付け") < stages.index("棚卸し") # 無人で通す: 上限を上げて打ち直さない・終了コード 10 で承認を求めない・記録のリポジトリの外へ書かない for words in ("--limit` を上げて打ち直さず", "承認を求めず", "`--repo` を渡さない", "親 issue の起票"): assert words in refine["prompt"] @@ -504,7 +470,7 @@ def test_queue_pr_placeholder_reads_a_sibling_plan_when_queued_alone(tmp_path): @pytest.mark.parametrize("alone", [True, False]) def test_normal_sprint_release_fills_the_check_pr_alone_or_after_check(tmp_path, monkeypatch, alone): out = tmp_path / "m" - p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + p = cli(*new_sprint_args(tmp_path, out)) assert p.returncode == 0, p.stderr waves = {w["name"]: w for w in load(out / "sprint.json")["ステージ"]} check, release = waves["検査"]["plans"][0], waves["配布"]["plans"][0] diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_auto.py b/plugins/ndf/scripts/tests/test_supervise_pace_auto.py index bfee70a33..46dc36e01 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_auto.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_auto.py @@ -15,6 +15,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" SPRINT_STATE = SCRIPTS / "sprint-state.py" REPO = SCRIPTS.parents[2] @@ -61,7 +64,7 @@ def new_sprint(tmp_path, repo, state, *extra, pace="auto", design=True): args = ["new", "sprint", "--name", "m27", "--worktree", str(repo), "--issue", "11", "12"] args += ["--design", "11"] if design else [] args += ["--version", "10.18.0-dev.1", "--pace", pace, "--state", str(state), "--out", str(out), *extra] - p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, cwd=repo) return p, out diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 90de1fe16..05c15e83d 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -14,6 +14,9 @@ import pytest SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import inproc # noqa: E402 + SUPERVISE = SCRIPTS / "supervise.py" STEPS = SCRIPTS / "release-steps.py" PY = sys.executable @@ -53,7 +56,7 @@ def new_sprint(tmp_path, repo, pace: str | None = "auto"): args = ["new", "sprint", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--out", str(out)] if pace: args += ["--pace", pace, "--state", str(sprint_state(tmp_path, "auto"))] - p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, cwd=repo) assert p.returncode == 0, p.stdout + p.stderr return load(out / "sprint.json")["ステージ"] @@ -61,7 +64,7 @@ def new_sprint(tmp_path, repo, pace: str | None = "auto"): def new_close(tmp_path, repo): out = tmp_path / "c" args = ["new", "close", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--state", str(sprint_state(tmp_path, "auto"))] - p = subprocess.run([PY, str(SUPERVISE), *args, "--out", str(out)], capture_output=True, text=True, cwd=repo) + p = inproc.run(SUPERVISE, *args, "--out", str(out), cwd=repo) return p, out @@ -298,9 +301,11 @@ def test_close_of_the_promote_route_reads_the_promote_pr(tmp_path): ids = [s["id"] for s in steps] close = next(s for s in steps if s["id"] == "close") assert "--record-pr {queue_pr:promote} " in close["cmd"] - assert ids.index("release-verify") == ids.index("close") - 1 + assert ids.index("release-verify") < ids.index("retro") < ids.index("close") rv = steps[ids.index("release-verify")] assert rv["type"] == "work" and rv["prompt"].startswith("/ndf:release-verification") and "{queue_pr:promote}" in rv["prompt"] + # 「リリース後テスト」と記録するのは、受け入れ条件ごとに確かめて記録を書くこのステップだけ(#2288 の AC1) + assert [s["id"] for s in steps if s.get("stage") == "リリース後テスト"] == ["release-verify"] PRD_ON_BASE = {**PRD, "trigger": "develop へのマージ", "branch": "develop"} # ベースブランチへのマージで本番へ届く @@ -309,7 +314,7 @@ def test_close_of_the_promote_route_reads_the_promote_pr(tmp_path): @pytest.mark.parametrize("rows", [[STG], [PRD_ON_BASE], [API], [STG, API]], ids=["stg-only", "merged", "manual", "stg-manual"]) def test_close_without_a_known_record_keeps_the_issues_open(tmp_path, rows): """#1875 1875-1・1875-2: 雛形も昇格も無い届ける経路(merge・手で届ける・検証の環境だけ)は --record-pr unknown で、 - リリース後テストを置かず merge の次が close。""" + リリース後テストを置かず merge の次が振り返り、その次が close。""" steps = close_steps(tmp_path, rows) close = next(s for s in steps if s["id"] == "close") assert "--record-pr unknown " in close["cmd"] and "release-verify" not in [s["id"] for s in steps] @@ -321,3 +326,47 @@ def test_close_of_no_delivery_keeps_record_pr_zero(tmp_path): steps = close_steps(tmp_path, []) close = next(s for s in steps if s["id"] == "close") assert "--record-pr 0 " in close["cmd"] and "release-verify" not in [s["id"] for s in steps] + + +def chain(steps) -> list[str]: + """merge から `next` をたどった id の並び(end まで)。""" + by = {s["id"]: s for s in steps} + out, cur = [], "merge" + while cur in by and cur not in out: + out.append(cur) + cur = by[cur].get("next") + return out + + +@pytest.mark.parametrize( + "rows, want", + [ + ([STG, PRD], ["merge", "release-verify", "retro", "close", "refine"]), + ([], ["merge", "retro", "close", "refine"]), + ([STG], ["merge", "retro", "close", "refine"]), + ], + ids=["record", "no-delivery", "unknown"], +) +def test_close_comes_after_the_retro_and_before_the_refine(tmp_path, rows, want): + """#2228 の受け入れ条件 5・6(I6): next のつながりで リリース後テスト → 振り返り → 課題を閉じる → 棚卸し。 + 振り返りとリリース後テストの指示は課題を閉じない。close は最終工程を振り返りとして渡す。""" + steps = close_steps(tmp_path, rows) + assert chain(steps)[-len(want) :] == want and chain(steps)[0] == "merge" + by = {s["id"]: s for s in steps} + assert "--final-stage 振り返り" in by["close"]["cmd"] + for sid in ("retro", "release-verify"): + if sid in by: + assert "課題は閉じない" in by[sid]["prompt"] + + +@pytest.mark.parametrize("mode", ["light", "operation", "documentation", "legacy-refactor"]) +def test_close_comes_after_the_retro_in_every_mode(tmp_path, mode): + """#2228 の受け入れ条件 6: どのモードで組んでも close は振り返りの後、棚卸しの前にある。""" + repo = make_repo(tmp_path, []) + out = tmp_path / "c" + args = ["new", "close", "--name", "m2", "--worktree", str(repo), "--issue", "11", "--mode", mode] + args += ["--state", str(sprint_state(tmp_path, "auto")), "--out", str(out)] + p = subprocess.run([PY, str(SUPERVISE), *args], capture_output=True, text=True, cwd=repo) + assert p.returncode == 0, p.stdout + p.stderr + close = next(load(f) for f in sorted(out.glob("*.json")) if load(f).get("フェーズ") == "まとめ") + assert chain(close["steps"])[-3:] == ["retro", "close", "refine"] diff --git a/plugins/ndf/scripts/tests/test_supervise_pr_closing.py b/plugins/ndf/scripts/tests/test_supervise_pr_closing.py new file mode 100644 index 000000000..c3db43b60 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_supervise_pr_closing.py @@ -0,0 +1,73 @@ +"""supervise_lib/pr.py の _publish: 既定ブランチ宛の本文の closing keywords を閉じる予定の参照へ置き換える(#2228 の I7・I8)。""" + +from __future__ import annotations + +import subprocess +import sys +from pathlib import Path +from types import SimpleNamespace + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) + +import gh_call # noqa: E402 +from closing import closing_words # noqa: E402 +from supervise_lib.pr import PrStep # noqa: E402 + +BODY = "## 閉じる課題\n\nCloses #12\nCloses #13\n" + + +def git(cwd, *args): + subprocess.run(["git", "-C", str(cwd), *args], capture_output=True, text=True, check=True) + + +@pytest.fixture +def repo(tmp_path): + """origin の HEAD が main の作業場所(develop も origin にある)。""" + origin = tmp_path / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "main", str(origin)], check=True) + root = tmp_path / "wt" + root.mkdir() + git(root, "init", "-q", "-b", "main") + git(root, "-c", "user.email=t@example.com", "-c", "user.name=t", "commit", "-q", "--allow-empty", "-m", "init") + git(root, "remote", "add", "origin", str(origin)) + git(root, "push", "-q", "origin", "main", "main:develop") + git(root, "fetch", "-q", "origin") + git(root, "remote", "set-head", "origin", "main") + return root + + +def publish(monkeypatch, repo, base, found=""): + calls = [] + + def fake(args, stdin=None, cwd=None): + calls.append(args) + out = found if args[:2] == ["pr", "list"] else ("https://github.com/o/r/pull/5\n" if args[:2] == ["pr", "create"] else "") + return gh_call.GhResult(0, out, "") + + monkeypatch.setattr(gh_call, "gh", fake) + ctx = SimpleNamespace(cwd=str(repo), plan={}, state=SimpleNamespace(cur={})) + ok, _ = PrStep()._publish(ctx, base, "feat/x", "題", BODY) + assert ok + sent = next(c for c in calls if c[:2] in (["pr", "create"], ["pr", "edit"])) + return sent[sent.index("--body") + 1], ctx.state.cur + + +def test_a_new_pr_to_the_default_branch_gets_deferred_references(monkeypatch, repo): + body, cur = publish(monkeypatch, repo, "main") + assert closing_words(body) == [] and "最終工程の後に閉じる: #13" in body + assert cur["deferred_closing"] == ["#12", "#13"] + + +def test_an_existing_pr_is_judged_by_its_own_base(monkeypatch, repo): + body, _ = publish(monkeypatch, repo, "develop", found="https://github.com/o/r/pull/5\tmain\n") + assert closing_words(body) == [] + + +@pytest.mark.parametrize("base, found", [("develop", ""), ("main", "https://github.com/o/r/pull/5\tdevelop\n")]) +def test_a_pr_to_another_branch_keeps_the_closing_keywords(monkeypatch, repo, base, found): + body, cur = publish(monkeypatch, repo, base, found=found) + assert closing_words(body) == ["Closes #12", "Closes #13"] and "deferred_closing" not in cur diff --git a/plugins/ndf/scripts/tests/test_whole_test_timing.py b/plugins/ndf/scripts/tests/test_whole_test_timing.py new file mode 100644 index 000000000..af428c357 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_whole_test_timing.py @@ -0,0 +1,102 @@ +"""実験版 `whole-test-timing.py` の計測と照合(#2291 の決定 7)。 + +- measure は宣言の順に suite を流し、最初の起動から最後の終了までを壁時計にする。負荷が CPU の数を超えたら参考の値にする +- junit の skip を件数から外し、遅いテストを秒の長い順に、ファイルを秒の合計の順に並べる +- diff は台帳に無い消えたテストと、skip に変わったテストを、nodeid を丸めずに出す +""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).resolve().parents[1] / "experimental" / "whole-test-timing.py" +_spec = importlib.util.spec_from_file_location("whole_test_timing", SCRIPT) +wtt = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(wtt) + +JUNIT = """ + + + + +""" + + +def _repo(tmp_path: Path, suites: list[dict]) -> Path: + (tmp_path / ".ndf").mkdir() + (tmp_path / ".ndf" / "project.json").write_text(json.dumps({"test": {"suites": suites}})) + return tmp_path + + +def _measure(monkeypatch, tmp_path, suites, load): + monkeypatch.setattr(wtt.os, "getloadavg", lambda: (load, 0.0, 0.0)) + monkeypatch.setattr(wtt.os, "cpu_count", lambda: 8) + root = _repo(tmp_path, suites) + out = tmp_path / "out.json" + with pytest.raises(SystemExit) as e: + wtt.main(["measure", "--root", str(root), "--out", str(out)]) + return e.value.code, json.loads(out.read_text()) + + +def test_measure_runs_suites_in_order_and_reads_junit(monkeypatch, tmp_path, capsys): + (tmp_path / "j.xml").write_text(JUNIT) + suites = [ + {"name": "first", "command": "echo 1 >> order", "junit": "j.xml"}, + {"name": "second", "command": "sleep 0.2; echo 2 >> order"}, + ] + code, out = _measure(monkeypatch, tmp_path, suites, load=3.0) + assert code == 0 + assert (tmp_path / "order").read_text().split() == ["1", "2"] + m = out["metrics"] + assert [s["name"] for s in m["suites"]] == ["first", "second"] + assert m["wall_seconds"] >= 0.2 and m["reference"] is False + assert (m["tests"], m["skipped"], m["sum_seconds"]) == (3, 1, 4.1) + assert out["tests"]["pkg/tests/test_a.py::TestK::test_slow[x-1]"] == {"outcome": "passed", "seconds": 3.0} + items = json.loads(capsys.readouterr().out)["items"] + tests = [i["name"] for i in items if i["kind"] == "test"] + assert tests[:2] == ["pkg/tests/test_a.py::TestK::test_slow[x-1]", "pkg/tests/test_b.py::test_mid"] + assert [(i["name"], i["seconds"]) for i in items if i["kind"] == "file"] == [ + ("pkg/tests/test_a.py", 3.1), + ("pkg/tests/test_b.py", 1.0), + ] + + +def test_measure_marks_high_load_as_reference_and_fails_on_a_failed_suite(monkeypatch, tmp_path): + code, out = _measure(monkeypatch, tmp_path, [{"name": "bad", "command": "exit 3"}], load=9.5) + assert code == 1 + assert out["metrics"]["reference"] is True + assert out["metrics"]["suites"][0]["exit"] == 3 + + +def _diff(tmp_path, before, after, ledger): + for name, tests in (("b.json", before), ("a.json", after)): + (tmp_path / name).write_text(json.dumps({"tests": tests})) + (tmp_path / "ledger.md").write_text(ledger) + with pytest.raises(SystemExit) as e: + wtt.main( + ["diff", "--before", str(tmp_path / "b.json"), "--after", str(tmp_path / "a.json"), "--ledger", str(tmp_path / "ledger.md")] + ) + return e.value.code + + +def test_diff_lists_removed_tests_missing_from_the_ledger_without_rounding_params(tmp_path, capsys): + ran = {"outcome": "passed", "seconds": 1.0} + before = {"t.py::a[1]": ran, "t.py::a[2]": ran, "t.py::b": ran} + after = {"t.py::b": ran} + code = _diff(tmp_path, before, after, "| `t.py::a[1]` | 消した | 1 | `t.py::b` |\n| `t.py::a` | 消した |\n") + assert code == 1 + out = json.loads(capsys.readouterr().out) + assert [i["name"] for i in out["items"]] == ["t.py::a[2]"] + + +def test_diff_lists_tests_that_became_skipped(tmp_path, capsys): + before = {"t.py::a": {"outcome": "passed", "seconds": 1.0}, "t.py::s": {"outcome": "skipped", "seconds": 0.0}} + after = {"t.py::a": {"outcome": "skipped", "seconds": 0.0}} + assert _diff(tmp_path, before, after, "") == 1 + out = json.loads(capsys.readouterr().out) + assert [(i["kind"], i["name"]) for i in out["items"]] == [("now_skipped", "t.py::a")] + assert _diff(tmp_path, before, before, "") == 0 diff --git a/plugins/ndf/skills/AUTHORING.md b/plugins/ndf/skills/AUTHORING.md index b95157cd7..271b520ab 100644 --- a/plugins/ndf/skills/AUTHORING.md +++ b/plugins/ndf/skills/AUTHORING.md @@ -1,7 +1,8 @@ # Skill 執筆規約 -`plugins/ndf/skills/` は 4 ランタイム(Claude Code / Codex / Kiro / agy)へ配布する Skill の -編集元である。ここでは frontmatter の書き方を規約として定める。本文の書き方は各 `SKILL.md` +`plugins/ndf/skills/` は Claude Code と Codex へ配布する Skill の編集元である。Kiro と agy へは配らないが、 +cross-refactoring が担当の worktree へ写す手順書(`refactoring` など)を相手役の Kiro・agy が Skill として読むため、 +下の表の Kiro・agy の列(可搬性の規則)は残す。ここでは frontmatter の書き方を規約として定める。本文の書き方は各 `SKILL.md` に委ねるが、**本文のうち、実行先のリポジトリに依存する部分は規約が扱う**(「対象リポジトリを 仮定しない」)。手順の中身が正しいかと、その手順が ai-plugins でしか成立しないかは別の問題で、 後者は Skill を書く人ごとに判断が分かれるためである。 @@ -33,7 +34,7 @@ python3 scripts/check-skill-repo-assumptions.py --report # 走査の規模と ## `description` を単一の真実とする -4 ランタイムに共通する土台は Agent Skills 仕様の 6 項目(`name` / `description` / `license` / +読む 4 ランタイムに共通する土台は Agent Skills 仕様の 6 項目(`name` / `description` / `license` / `compatibility` / `metadata` / `allowed-tools`)である。ただし共通に効く度合いは項目ごとに異なる。 | 項目 | 4 ランタイムでの扱い | @@ -139,11 +140,9 @@ description: "Delete merged branches and worktrees, stopping only where git refu ([棚卸の計画](../../../issues/old/ndf-development-skills/07-tasks.md) の Task 0-8) - 「常時注入のみ」に相当する機能は Codex と Kiro と agy にない。3 つとも `user-invocable: false` を解釈せず、この分類の Skill も通常の Skill として扱う。唯一の対象で - ある `ndf-policies` は 4 ランタイムすべてへ配布している(`plugins/ndf/manifests/`)ため、 - Codex と agy では暗黙起動されうる。したがってこの分類の Skill は `description` に**「知識として参照する。手順として - 実行しない」旨を明記する**。Kiro は Skill として配らず `.kiro/steering/` へ常時指示として - 置き換えることで回避する([棚卸の計画](../../../issues/old/ndf-development-skills/07-tasks.md) - の Task 0-9)。`description` の書き換えは同計画の Task 0-10 で行う + ある `ndf-policies` は Codex へも配布している(`plugins/ndf/manifests/`)ため、 + Codex では暗黙起動されうる。したがってこの分類の Skill は `description` に**「知識として参照する。手順として + 実行しない」旨を明記する**([棚卸の計画](../../../issues/old/ndf-development-skills/07-tasks.md) の Task 0-10) - **Claude Code では** `disable-model-invocation: true` の Skill は `description` がコンテキスト へ載らず、`user-invocable: false` は載る。Codex と Kiro と agy にはこのキーがなく `description` は常に読まれるため、明示指示専用にする Skill は `description` 自体へ「利用者が明示的に指示した @@ -284,9 +283,7 @@ Skill 名の部分だからである。ただし配布先に何が入ってい | `SKILL.md` 行数 | 500 行。超えるものは補助ファイルへ分割 | 仕様の推奨、コンパクション対策 | | `SKILL.md` 本文 | 5,000 トークン | 仕様の推奨 | | Claude Code の初期 Skill 一覧の合計 | **27,900 文字**(10,000 トークン = コンテキストの 1%)。1 項目は `description` + `when_to_use` を合わせて 1,536 文字で切り詰め | Claude Code 公式ドキュメント。既定モデル Opus 5 のコンテキスト 1,000,000 から算出。文字数は較正した換算比による | -| Codex の初期 Skill 一覧の合計 | **15,177 文字**(5,440 トークン = コンテキストの 2%) | Codex 公式ドキュメント。既定モデル gpt-5.6-sol のコンテキスト 272,000 から算出。**4 ランタイムで最も厳しく、実質の制約になる** | -| Kiro の初期 Skill 一覧の合計 | **27,900 文字**(10,000 トークン = コンテキストの 1%) | 公式ドキュメントに規定が無いため、コンテキスト長が同じ Claude Code の 1% を借りる。既定モデル auto のコンテキスト 1,000,000 から算出 | -| agy の初期 Skill 一覧の合計 | **27,900 文字**(10,000 トークン = コンテキストの 1%) | 公式ドキュメントに規定が無いため、Kiro と同じく Claude Code の 1% を借りる。既定モデルは Gemini 3.x Flash 系で、コンテキスト長を出す手段が見当たらないため 1,000,000 を前提に置く | +| Codex の初期 Skill 一覧の合計 | **15,177 文字**(5,440 トークン = コンテキストの 2%) | Codex 公式ドキュメント。既定モデル gpt-5.6-sol のコンテキスト 272,000 から算出。**配布先の 2 ランタイムで厳しい側で、実質の制約になる** | | 全 Skill の frontmatter 合計 | 20,000 文字(**警告のみ**) | リポジトリ固有の目安。ランタイムが課す制約ではないため、超過しても警告にとどめる。**plugin family をまたいだ合計**。値は Codex の予算から逆算する([目安の決め方](#frontmatter-合計の目安は-codex-の予算から逆算する)) | ### 予算はトークンで効く @@ -333,7 +330,6 @@ Skill を増減したときや、既定モデルが変わったときに再実 | --- | --- | ---: | --- | ---: | ---: | | Claude Code | Opus 5 | 1,000,000 | 1% | 10,000 | 27,900 | | Codex | gpt-5.6-sol | 272,000 | 2% | 5,440 | 15,177 | -| Kiro | auto | 1,000,000 | 1%(Claude Code から借りる) | 10,000 | 27,900 | 文字換算は較正値(2026-09-01 実測で 2.79 文字/トークン)による。`--calibrate` を実行すると この値は更新される。 @@ -379,19 +375,6 @@ Skill の一覧 7.4k は、他のプラグインと組み込みを含む全量 コンテキストの内訳を測り、上の表を差し替えたうえで値を決める。据え置いたまま Skill を足すと 警告が出続け、目安として働かなくなる。 -**Kiro の規定は Claude Code から借りている。** 公式ドキュメント(kiro.dev/docs/skills)に一覧 -予算の記述が無いため、既定モデル `auto` のコンテキスト長が同じ 1,000,000 である Claude Code の -1% を当てる。憶測で独自の値を置くより、同じ土俵の実在する規定へ揃えるほうが根拠が残る。 -コンテキスト 1,000,000 は過去の実測の記録で、kiro-cli 2.20.1 では同じ手段(`--list-models`)を -取れない。値を更新するときは、その時点で一覧を取れる手段から確かめ直す。 -**NDF は Kiro でも 1M コンテキストのモデルだけを対象とする**(`dev.kiro/install.sh` が導入時に -この前提を出力する。モデルの一覧を取る手段が kiro-cli の版によって変わるため、機械でのチェックは -置いていない)。 - -起動時に読み込むコンテキスト量(Kiro が全 `SKILL.md` を読む合計)に**上限は置かない**。 -コンテキスト長が同じ Claude Code に上限を置かないため、Kiro だけを縛る根拠が無い。数は smoke test のログへ残るため、増え方は -後から追える。 - **コンテキストが最も長い Claude Code ではなく、Codex が全体の制約になる。** 比率が 2 倍でも コンテキストが 1/3.7 のため、予算は Claude Code の約半分にしかならない。Skill を増やすときは Codex の 5,440 文字を基準に考える。 @@ -405,7 +388,6 @@ Claude Code 側は `skillListingBudgetFraction` 設定または `SLASH_COMMAND_T | --- | :-: | :-: | :-: | :-: | | Claude Code | ○ | ○ | ○(`description` と合算して 1,536 文字で切り詰め) | **×** | | Codex | ○ | ○ | × | **○** | -| Kiro | ○ | ○ | × | 規定なし(多い側で見積もる) | パスを載せるのは Codex だけである(*"In Codex, the initial list also includes each skill's file path."*)。Claude Code の公式記述は *"a listing of skill names and descriptions"* で @@ -429,9 +411,9 @@ Codex は起動時に全 Skill の `name` と `description` とファイルパ 超過分を逃がす。 逃がし先は配布先で選ぶ。Claude Code だけに配布する Skill は `when_to_use` へ移してよい。 -一方 `when_to_use` は Codex と Kiro と agy では読まれないため、この 3 ランタイムへも配布する -Skill のトリガ語を `when_to_use` へ移すと、そのランタイムでは一覧に載らず暗黙起動に効かなくなる。 -4 ランタイムへ配布する Skill はトリガ語を `description` に残したまま要約し、手順の説明を +一方 `when_to_use` は Codex では読まれないため、Codex へも配布する +Skill のトリガ語を `when_to_use` へ移すと、Codex では一覧に載らず暗黙起動に効かなくなる。 +Codex へも配布する Skill はトリガ語を `description` に残したまま要約し、手順の説明を 本文へ逃がす。 Claude Code のコンパクション後は、呼び出し済み Skill の先頭 5,000 トークンのみが再添付され、 diff --git a/plugins/ndf/skills/backlog-refinement/tests/test_issue_upkeep_layout.py b/plugins/ndf/skills/backlog-refinement/tests/test_issue_upkeep_layout.py index bf833e8f9..7289e8d61 100644 --- a/plugins/ndf/skills/backlog-refinement/tests/test_issue_upkeep_layout.py +++ b/plugins/ndf/skills/backlog-refinement/tests/test_issue_upkeep_layout.py @@ -36,9 +36,9 @@ def section(text: str, heading: str) -> str: return heading + (rest[: ends[0]] if ends else rest) -@pytest.mark.parametrize("runtime", ["claude", "codex", "kiro", "agy"]) +@pytest.mark.parametrize("runtime", ["claude", "codex"]) def test_the_skill_is_distributed(runtime: str) -> None: - """4 つの manifest すべてに載る。""" + """配布先の 2 つの manifest に載る。""" manifest = ROOT / "plugins" / "ndf" / "manifests" / f"{runtime}-skills.txt" names = [line.split("#", 1)[0].strip() for line in manifest.read_text(encoding="utf-8").splitlines()] assert "backlog-refinement" in names diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index bab60f56f..5ff3a7196 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -14,7 +14,7 @@ allowed-tools: # 多ランタイム・リファクタリング(想定最大時間に収める 1 回のリファクタリング計画の実行) **参加者の全員が 1 度だけ多面的に提案し、選ばれた 1 者が想定最大時間に収まる分を -リファクタリング計画・テスト追加・実装・検証する。** 所要の上限は `--budget-minutes`(既定 30 分)で利用者が決める。 +リファクタリング計画・テスト追加・実装・検証する。** 所要の上限は `--budget-minutes` で利用者が決める。省けば 30 分から始め、着手前のテストが長く手順の枠が収まらなければ想定最大時間の下限まで上げる。 `refactoring` Skill は「テストで守りながら 1 手ずつ直す」手順を持つが、 **何を直すかの発見**と**直した結果の検証**を持たない。この Skill がその 2 つを補う。 @@ -70,7 +70,7 @@ allowed-tools: | --- | --- | --- | | `[PR番号]` | 対象の Pull Request | 必須 | | `--scope PATH...` | 対象範囲。**提案が無制限に広がらないよう必須。** 検証にも効くので、現状固定テストの置き場所も含める | 必須 | -| `--budget-minutes N` | 想定最大時間(分)。1 以上の整数でなければ `init` が止める(終了コード 4) | `30` | +| `--budget-minutes N` | 想定最大時間(分)。1 以上の整数でなければ `init` が止める(終了コード 4)。明示した値は上げない | 省けば `30`。手順の枠が収まらなければ想定最大時間の下限まで上げる(想定最大時間の出所 `floor`) | | `--implementer NAME` | リファクタリング計画以降を担う 1 者。参加者に無ければ `init` が止める | ホスト → 先頭 | | `--host claude\|codex\|agy\|kiro` | ホストの明示指定。未指定時は環境変数から推定(agy は推定できないため明示する) | 推定 | | `--exclude NAMES` | 参加者から外す者(カンマ区切り・繰り返し可)。再開で `none` を渡すと空へ戻す | なし | @@ -255,7 +255,8 @@ bash "$LIB/bg-wait.sh" wait "$RC" # 1 回 540 秒以内。124 = まだ終わ | --- | --- | --- | | 0 | finalize まで終わった | `items[0].report`(`refactor.py report` の出力)と `metrics` を「完了報告」へ写す | | 23(`cross-review`) | 単独起動の最終ゲート | `prompt_file` を読み、`items[0].cwd`(対象のリポジトリの根)で `items[0].command`(cross-review の駆動)を回す。最終ステータスは cross-review の駆動が `result_file` へ書き、自分では書かない。同じコマンドを打ち直し、結果 JSON の `metrics.review_status` が `approved` のときだけ Draft を解除する。それ以外は Draft のまま最終ステータスを報告して止まる | -| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・着手前のテストが長く手順の枠が想定最大時間に収まらない(要る `--budget-minutes` の下限を出す。下限以上で `init` を打ち直すと着手前のテストを走らせずに続く)・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・着手前のテストの打ち切り・範囲テストのテンプレートが `{paths}` に入るテストのファイルを受けない(着手前のテストの後に試走で見分けた。受けるかを確かめられないときも止め、直す箇所と記録 `template-check.json` を出す)・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | +| 6 | 時間の枠の不足でリファクタリングを飛ばした(着手前のテストの打ち切りか、明示した想定最大時間が手順の枠の下限に届かない。`metrics.skipped` に種類 `init-timeout` / `window` と理由、`gaps` に 1 件。`metrics.exit` は 4) | 理由を報告する。検査のプランは `on_exit` の `"6"` でコードレビューへ進む。単独で打ったときは、要る `--budget-minutes` の下限以上で `init` を打ち直すと着手前のテストを走らせずに続く | +| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・範囲テストのテンプレートが `{paths}` に入るテストのファイルを受けない(着手前のテストの後に試走で見分けた。受けるかを確かめられないときも止め、直す箇所と記録 `template-check.json` を出す)・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | 再開は同じコマンドを打ち直すだけである。進みは耐久の記録(既定の置き場は `~/.local/state/ndf/dbos/refactor-<鍵>.sqlite` )が持ち、 記録のある手順は流し直さない。`metrics` は状態ファイルから数えた件数(`items` / `adopted` / `reverted` / 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 b0648e07c..7d2442bd1 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 @@ -63,11 +63,19 @@ 13. **着手前のテスト** — 戦略ごとに走らせる(`local-full` は全体テスト、`local-scoped-ci-whole` は `--scope` のテストの 置き場所の範囲テスト、`round-only` は全体テストとラウンドテスト)。**落ちても止めない。** 落ちたテストは JUnit から読んで `baseline_test.existing_failures`(既存失敗)に書き、最終ゲートは既存失敗の外で新しく落ちたテストが無ければ通る。 - JUnit を読めなければ `null` と理由を書く。上限(`limits.init_test_timeout`)を超えたときだけ止める。 + JUnit を読めなければ `null` と理由を書く。上限(`limits.init_test_timeout`)を超えたときだけ止める(`SKIP=init-timeout` を出して終了コード 4)。 + 上限は `max(0.10·B, 3·s)`。s は着手前のテストの所要の見込みで、CI に任せる戦略の同じ範囲の配分の履歴 → 手元の全体テストの直近の実測 + (`whole_runs`)→ 宣言の所要の順に採り(`baseline_test.scope_source` は `history` / `recent` / `whole`)、どれも無ければ `0.10·B` にする。 + 上限が `0.10·B` を超えるときは、走らせる前にステップの延長(環境変数 `NDF_STEP_EXTEND_FILE` の指すファイル)へ `B·60 + (上限 − 0.10·B·60)` 秒を書く。 実測の秒は `baseline_test.seconds`、宣言の所要は `whole_seconds`・`ci_seconds` に残し、時間の上限とバッファに使う 14. **測定の設定の読み込み** — 書き込み用の作業ディレクトリの `.ndf/code-metrics.json` を読み、 既定の対応と重ねて状態の `code_metrics.config` に書く。`status` は `pending` から始まる。 `limits.measure_timeout` もここで書く(下の「指標の測定」) +15. **手順の枠の確かめ**(`init_test.settle_window`)— 提案とリファクタリング計画の枠と計画の後の見込みが想定最大時間に収まるかを見る。 + 収まらないとき、想定最大時間の出所(状態の `budget_source`)が `default` / `floor` なら、打ち直しの見込みを足さない下限まで + `budget_minutes` を上げ、出所を `floor`・上げる前と後を `budget_floor` に残して上限の表を組み直し、`ℹ` の 1 行を出して続ける。 + `explicit`(引数か宣言で渡した。`budget_source` を持たない旧い状態ファイルも同じ)なら上げず、`SKIP=window` と要る下限を出して + 止めた印(`window_stopped_at`)を残し、終了コード 4 で止まる。出力の `BUDGET_SOURCE` に出所を出す ### 再開 @@ -85,7 +93,7 @@ | --- | --- | | `--max-fix-rounds` / `--test-timeout` | 廃止を知らせて無視する(決定 24。修正は締め切りまで、テストの上限は予算から導く) | | `--baseline-test` / `--round-test` | 知らせるだけ(戦略は `init` で解いたまま) | -| `--budget-minutes` | **リファクタリング計画の前**(`phase` が `propose` か `plan` でリファクタリング計画が無い)だけ置き換え、上限の表(`limits`)を組み直す。後は知らせるだけ。採用の件数・実装の終わり・バッファはリファクタリング計画の時点の予算で固定されている | +| `--budget-minutes` | **リファクタリング計画の前**(`phase` が `propose` か `plan` でリファクタリング計画が無い)だけ置き換え、想定最大時間の出所を `explicit` にして上限の表(`limits`)を組み直す。渡さなければ出所は状態のまま。後は知らせるだけ。採用の件数・実装の終わり・バッファはリファクタリング計画の時点の予算で固定されている | | `--implementer` | 知らせるだけ(置き換えない) | | `--exclude` / `--include` / `--require-all` | 参加者を作り直す。**実装担当が外れたら**、リファクタリング計画の前は決め方を当て直し、リファクタリング計画の後は止める | | そのほか状態に載る引数 | 状態と違えば「反映しない」と知らせる | diff --git a/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md b/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md index d11669868..58e382f48 100644 --- a/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md +++ b/plugins/ndf/skills/cross-refactoring/docs/02-plan-and-implement.md @@ -119,9 +119,9 @@ rf_eval merge-plan "$ID" # TESTS_NEEDED=0|1。2 = 項目 0 件(readop **時間に関わる数値は、この節の式だけで決まる**(決定 23・24、#1334 決定 8)。B は想定最大時間 (`--budget-minutes`)、w は宣言の全体テストの所要(`test_duration`。`ndf-record` → `ci-junit` → `ci-steps` の順)、 x は着手前に手元で走らせたテストの実測秒(`baseline_test.seconds`)、c は CI の壁時計(`ci.workflows[].wall_seconds` のうち -`test.ci.check` を持つもの、分からなければ最大)、s は CI に任せる戦略で着手前に走らせる範囲テストの所要の見込み -(配分の履歴の同じ戦略・同じテストの置き場所の集合の着手前のテストの行の直近 10 行の最大 → 置き場所が全体テストの suite の -`paths` を覆うときの w → 無し、の順。出所は `history` / `whole`)、o は手順の枠の起点のずれ(x。無ければ 0)、開始は `started_at`、R はバッファ(最終ゲートの全体テスト、 +`test.ci.check` を持つもの、分からなければ最大)、s は着手前のテストの所要の見込み +(CI に任せる戦略だけ配分の履歴の同じ戦略・同じテストの置き場所の集合の着手前のテストの行の直近 10 行の最大を先に採り、無ければ +手元の全体テストの直近の実測 m → 宣言の w → 無し、の順。出所は `history` / `recent` / `whole`。#2047)、o は手順の枠の起点のずれ(x。無ければ 0)、開始は `started_at`、R はバッファ(最終ゲートの全体テスト、 修正 1 回、最終ゲート修正 1 回と、`--ci-check` を付けた手元の戦略だけ危険フラグの全体テスト)、L は 1 件の長さ(配分テーブルの 手法のうち最も短い `implement` + `verify`。テストの追加を除く)である。テストと CI の待ちの係数は共通層 `scripts/lib/test_strategy.py` に、手順の枠の係数は `refactor_lib/timeline.py` に置き、実行の途中で数値を決めるために LLM へ問わない。入力は `limits.basis` に並べる。 @@ -129,7 +129,7 @@ x は着手前に手元で走らせたテストの実測秒(`baseline_test.sec | 値 | 式 | 書き出す時点 | | --- | --- | --- | | 余裕 | `0.05·B` | `init` | -| 着手前のテスト 1 回の上限 | `local-full` / `round-only` は `max(0.10·B, 3·w)`、`local-scoped-ci-whole` は s が分かれば `max(0.10·B, 3·s)`、分からなければ `0.10·B`(走らせるのは置き場所の範囲テストだけ) | `init` | +| 着手前のテスト 1 回の上限 | 戦略を問わず s が分かれば `max(0.10·B, 3·s)`。分からなければ `local-full` / `round-only` は `max(0.10·B, 3·w)`、`local-scoped-ci-whole` は `0.10·B`(走らせるのは置き場所の範囲テストだけ)。`0.10·B` を超えるときは、走らせる前にステップの延長へ `B·60 + (上限 − 0.10·B·60)` 秒を書く | `init` | | 範囲テスト 1 回の上限(`test_timeout`) | `max(3·x, 0.01·B)`。x が無ければ着手前の上限(s を入れない式) | `init` | | 手元の全体テスト 1 回の上限(`whole_timeout`) | `max(3·w, 0.01·B)`。w が無ければ着手前の上限(s を入れない式) | `init` | | CI の待ちの上限(`ci_wait_timeout`) | `max(3·c, 0.05·B)`。c が無ければ `0.20·B` | `init` | @@ -153,8 +153,11 @@ x は着手前に手元で走らせたテストの実測秒(`baseline_test.sec 収まる件数が減ることで吸収する(直しの試行の打ち切りと H は開始から数える)。 **枠の後に 1 件も入らなければ、提案者を起動せずに止める。** リファクタリング計画の枠の終わりに計画の後に要る時間の見込み -(R + L)を足した時刻が `開始 + B` を越えると、`init` は状態を保存して止めた印(`window_stopped_at`)を残し、終了コード 4 で +(R + L)を足した時刻が `開始 + B` を越えると、想定最大時間を明示した実行(出所 `explicit`)では、`init` は `SKIP=window` を出し、 +状態を保存して止めた印(`window_stopped_at`)を残し、終了コード 4 で 止まる。文は要る想定最大時間の下限 `ceil(((o + 300) / 60 + R + L) / 0.70)` 分を出す(300 秒は打ち直すまでの見込み)。 +想定最大時間を省いた実行(出所 `default` / `floor`)は止めず、`init_test.settle_window` が B を 300 秒を足さない下限 +`ceil((o / 60 + R + L) / 0.70)` 分まで上げ、出所を `floor` にして上限の表を組み直して続ける。 下限で打ち直すと 1 件だけが入る。`--budget-minutes` を下限以上にして `init` を 打ち直すと、着手前のテストを走らせ直さずに続き、枠は打ち直した時刻から数える(o = `max(x, resumed_at − 開始)`)。打ち直しが 遅れて再び収まらなければ、その時点の o から同じ式で下限を出して止まる。止めた印の無い再開は、計画の前も後も diff --git a/plugins/ndf/skills/cross-refactoring/scripts/drive.py b/plugins/ndf/skills/cross-refactoring/scripts/drive.py index 0b16e734c..153895163 100755 --- a/plugins/ndf/skills/cross-refactoring/scripts/drive.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/drive.py @@ -14,7 +14,7 @@ 耐久の記録の実行の鍵は `refactor-<状態の置き場の sha256 の先頭 12 字>`(置き場が求まらなければ `<作業ディレクトリ>#`)。done で終わり状態ファイルが残る実行の回は、PR の head と結果を変える引数が同じなら記録した結果を返す(#1263)。 -止まるときの JSON の形と終了コードの表はライブラリの `scripts/lib/drive_pause.py` にある(使うのは 23 だけ。中断の `metrics.exit` が 4 なら refactor.py の中断)。 +止まるときの JSON の形と終了コードの表はライブラリの `scripts/lib/drive_pause.py` にある(止まりは 23 だけ、欠けの残る結果は 6。中断の `metrics.exit` が 4 なら refactor.py の中断)。 子の起動・KEY=VALUE の読み取りは `scripts/lib/loop_drive.py` にある。最終ステータスは決めない。最終ゲートの cross-review の 駆動が `--result-file` で回答ファイルへ書いた値(`loop_drive.review_status` が決めたもの)をそのまま finalize へ渡す(#1656)。 対象のリポジトリは打った場所(現在のディレクトリが属する git の作業ツリーの根)で決まる。決められなければ耐久の記録を開かずに @@ -25,6 +25,9 @@ unpublished は手元の HEAD が公開した地点より進んでいるか(plan-comment が判定できなければ null)。 done・stopped・pause の各出口で、結果 JSON を組む前に `refactor.py plan-comment` を 1 度だけ打ち、リファクタリング計画のコメントを書き直す(#1692)。 最終ゲートを経ずに終わった実行は完了にせず、中断(stopped・metrics.exit に最終ゲートの終了コード)で終える(#1482)。 +`init` が時間の枠の不足(`SKIP=init-timeout` / `SKIP=window`)で止まったときだけ、リファクタリングを飛ばした欠けの残る結果 +(stopped・`gaps` 1 件・`metrics.skipped`・終了コード 6)で返す(#2047)。`init` が想定最大時間を下限まで上げた(`BUDGET_SOURCE=floor`)ら、 +ステップの延長(`drive_pause.write_extension`)へ想定最大時間の秒を書く。 """ from __future__ import annotations @@ -122,6 +125,14 @@ def _target_root() -> Path: raise Stop(str(e), e.code) from None +class Skip(Stop): + """時間の枠の不足でリファクタリングを飛ばす(`init` の `SKIP`)。捕まえ損ねても `Stop` として中断に落ちる(#2047)。""" + + def __init__(self, kind: str, detail: str, code: int): + super().__init__(f"時間の枠に収まらずリファクタリングを飛ばした: {detail}", code) + self.kind, self.detail = kind, detail + + class Drive(AssigneeLaunch): def __init__(self, pr: int, init_args: list[str], root: str | None = None): self.pr = pr @@ -152,6 +163,8 @@ def launch(self, seat: str, phase: str) -> int: def rf(self, *args: str, ok=(0,)) -> tuple[int, dict]: rc, out = self.call(_refactor_cmd(*args)) vs = parse_vars(out) + if rc == ABORT and (kind := vs.get("SKIP")): # 時間の枠の不足(#2047 の I5)。リファクタリングを飛ばして返す + raise Skip(kind, vs.get("SKIP_DETAIL") or "", rc) if rc == ABORT or rc not in ok: # 子が出した中断の理由の 1 行(ABORT_DETAIL)を添える(#1944 の決定 6) raise Stop(f"refactor.py {args[0]} が終了コード {rc} で止まった" + (f": {why}" if (why := vs.get("ABORT_DETAIL")) else ""), rc) self.v.update(vs) @@ -271,6 +284,9 @@ def start(self) -> None: if "TMP_DIR" not in self.v or "ID" not in self.v: raise Stop("refactor.py init が TMP_DIR / ID を返さない", 2) self.own("touch") + if self.v.get("BUDGET_SOURCE") == "floor": # 下限まで上げた想定最大時間で、親のステップの締め切りを延ばす(#2047 の決定 4) + minutes = int(self.v.get("BUDGET_MINUTES") or 0) + dp.write_extension(minutes * 60, f"想定最大時間を下限の {minutes} 分へ上げた") self.extra_env["CROSS_REFACTORING_TMP_DIR"] = self.v["TMP_DIR"] self.sh("prepare-worktrees.sh", ["bash", str(HERE / "prepare-worktrees.sh"), self.v["ID"]]) @@ -385,6 +401,15 @@ def done(self, extra: dict | None = None) -> dict: c, ) + def skipped(self, e: Skip) -> dict: + """時間の枠の不足で飛ばした結果(欠けの残る結果・終了コード 6)。状態ファイルが無ければ(打ち切りは保存の前)件数は持たない。""" + if not self.comment_done: + self.refresh_plan_comment() + skipped = {"kind": e.kind, "reason": e.detail} + metrics = {**(self.counts() if self.has_state() else {}), "skipped": skipped, "exit": e.code} + gap = {"kind": "refactor", "name": e.kind, "reason": f"時間の枠に収まらず飛ばした: {e.detail}"} + return dp.incomplete(TOOL, str(e), metrics, gap) + def stopped(self, e: Stop) -> dict: if not self.comment_done: self.refresh_plan_comment() @@ -484,6 +509,8 @@ def refactor_drive(pr: int, init_args: list[str], root: str | None = None) -> di result = d.done() result["items"] = [*result.get("items", []), *json.loads(d.v.get("SWEPT") or "[]")] # 開始の片付けの結果 d.own("finish") + except Skip as e: + result = d.skipped(e) except Stop as e: result = d.stopped(e) head = pr_head(d.call, pr) if result.get("status") == "ok" else None diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py index 4fea1637b..2100f5286 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/allocation.py @@ -307,6 +307,7 @@ def build_row(state: dict[str, Any]) -> dict[str, Any]: "pr": state.get("pr", state.get("current_pr")), "implementer": state.get("implementer"), "budget_minutes": state.get("budget_minutes"), + "budget_source": state.get("budget_source"), # 想定最大時間の出所(#2047)。旧い行は持たない(集計には使わない) "elapsed_seconds": elapsed, "phases": phase_seconds(state), "kinds": kinds, 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 f17c06bb0..cee2cb983 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 @@ -204,6 +204,14 @@ def _reassigned_lines(state: dict[str, Any]) -> list[str]: return lines +def _budget_source_text(state: dict[str, Any]) -> str: + """想定最大時間の出所の添え書き(#2047)。下限まで上げた実行だけ「下限から決めた」と上げる前の分を添える。""" + if state.get("budget_source") != "floor": + return "" + before = (state.get("budget_floor") or {}).get("from_minutes") + return f"(下限から決めた。既定 {before} 分)" + + def _print_header(state: dict[str, Any]) -> None: budget_seconds = int(state.get("budget_minutes") or 0) * 60 # 止まっていた時間の分だけ締め切りをずらしたなら、所要からその秒を除いて想定最大時間と比べる(#1491・決定 8) @@ -217,7 +225,7 @@ def _print_header(state: dict[str, Any]) -> None: print(f"- 件数: {counts_line(state)}") print(f"- 対象範囲: {', '.join(state['target_scope']) or '(未指定)'}") print( - f"- 想定最大時間: {state.get('budget_minutes')} 分 / 所要: {elapsed / 60:.1f} 分" + f"- 想定最大時間: {state.get('budget_minutes')} 分{_budget_source_text(state)} / 所要: {elapsed / 60:.1f} 分" f"(差 {(budget_seconds - elapsed) / 60:+.1f} 分。{paused}cross-review を除く)" ) print(f"- 実装担当: {state.get('implementer')}({state.get('implementer_reason')}) / モデル: {_implementer_model_text(state)}") 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 e1c52cfdc..423ab1778 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 @@ -317,6 +317,8 @@ def _build_initial_state(args: argparse.Namespace, ctx: InitialContext) -> dict[ "id": args.pr, "started_at": ctx.started_at, "budget_minutes": args.budget_minutes, + # 想定最大時間の出所(#2047)。`explicit` / `default` / `floor`(`init_test.settle_window` が下限まで上げた) + "budget_source": getattr(args, "budget_source", None) or "explicit", "repo": ctx.repo, "current_pr": args.pr, "base_branch": ctx.base_branch, @@ -407,6 +409,8 @@ def cmd_init(args: argparse.Namespace) -> None: _emit_run(prep) return + # 想定最大時間の出所(#2047)。既定を埋める前に決める。省いた実行だけ、手順の枠が収まらなければ下限まで上げる + args.budget_source = "explicit" if getattr(args, "budget_minutes", None) is not None else "default" for key, value in NEW_RUN_DEFAULTS.items(): if getattr(args, key, None) is None: setattr(args, key, value) @@ -414,7 +418,8 @@ def cmd_init(args: argparse.Namespace) -> None: participants, baseline, round_record = _verify_init(args, inputs, prep) state = _save_initial_state(args, inputs, prep, participants, baseline, round_record, started_at) # **手順の枠と計画の後の見込みが想定最大時間に収まらなければ、提案者を起動せずに止める**(#1385 I4・#1743 I8)。 - init_test.stop_if_window_short(prep.state_file, state) + # 想定最大時間を省いた実行は止めずに下限まで上げる(#2047 の I3)。 + init_test.settle_window(prep.state_file, state) # **出力は入口から直接呼ぶ。** 手順書の変数の出所のチェック(`scripts/check-skill-shell-vars.py`)は 1 階層だけたどる。 _emit_init(state) _emit_run(prep) @@ -605,14 +610,16 @@ def _verify_init( ) _warn_unmeasurable_models(inputs.model_spec, participants["available"]) - # 着手前のテストの上限は予算と宣言の所要から導く(決定 8)。CI に任せる戦略は範囲テストの所要 s も入れる(#1555)。 + # 着手前のテストの上限は予算と、着手前のテストの所要の見込み s(履歴 → 手元の全体テストの直近の実測 m → 宣言の w)から導く + # (決定 8・#1555・#2047 の I1)。 w, w_source = ts.whole_seconds(prep.decl) c = ts.ci_wall_seconds(prep.decl, (prep.strategy.ci or {}).get("check") if prep.strategy.ci else None) - s, s_source = init_test.resolve_scope_seconds(prep, list(args.scope), w) + s, s_source = init_test.resolve_scope_seconds(prep, list(args.scope), w, init_test.recent_whole_seconds(prep)) kw: dict[str, Any] = {"whole_seconds_value": w, "whole_source": w_source, "ci_seconds": c, "scope_seconds": s, "scope_source": s_source} timeout = ts.limits(prep.strategy, int(args.budget_minutes), **kw)["init_test_timeout"] if s is not None: - info(f" 着手前のテストの上限: {timeout} 秒(範囲テストの所要 {s} 秒 / {s_source})") + info(f" 着手前のテストの上限: {timeout} 秒(着手前のテストの所要の見込み {s} 秒 / {s_source})") + init_test.extend_for_init_test(int(args.budget_minutes), timeout) # 親のステップの締め切りで先に切られない(#2047 の決定 4) baseline = baseline_lib.run_baseline(prep.strategy, prep.work, timeout, list(args.scope), prep.tmp_dir) baseline.update({"whole_seconds": w, "whole_source": w_source, "ci_seconds": c, "scope_seconds": s, "scope_source": s_source}) init_test.append_init_test(prep, int(args.budget_minutes), args.pr, baseline) @@ -753,6 +760,8 @@ def _resume( info(f"↻ 前回中断した状態から再開します(手順 {state.get('phase')})") ensure_record(state, getattr(args, "code_metrics", None)) budget_spec = RESUME_BUDGET_REPLACE if _before_plan(state) else RESUME_BUDGET_NOTIFY + if getattr(args, "budget_minutes", None) is not None and _before_plan(state): + state["budget_source"] = "explicit" # 渡した想定最大時間は利用者の上限として守る(#2047 の I2) for line in statefile.apply_resume_args(state, args, budget_spec): info(line) view, given = _notify_view(state, args, inputs.model_spec) @@ -772,7 +781,7 @@ def _resume( if state.get("window_stopped_at"): state["resumed_at"] = statefile.now() state["limits"] = timeline.of_state(state, forecast.after_plan(state)) - init_test.stop_if_window_short(state_file, state) + init_test.settle_window(state_file, state) statefile.save(state_file, state) _emit_init(state) @@ -853,6 +862,7 @@ def _emit_init(state: dict[str, Any]) -> None: # 再開の地点。駆動は終わった手順を飛ばす(AC24)。 PHASE=state.get("phase") or "propose", BUDGET_MINUTES=state["budget_minutes"], + BUDGET_SOURCE=init_test.budget_source(state), # 想定最大時間の出所(#2047)。駆動は floor ならステップの延長を書く WORKTREE_ROOT=state["worktree_root"], WORK=state["worktrees"]["work"], TMP_DIR=state["tmp_dir"], diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py index cce63b36f..525f3eb80 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py @@ -1,13 +1,15 @@ -"""`init` の着手前のテストの周り: 範囲テストの所要の見込み・配分の履歴への追記・打ち切りと手順の枠での停止(#1385 #1555)。 +"""`init` の着手前のテストの周り: 着手前のテストの所要の見込み・配分の履歴への追記・打ち切りと手順の枠での停止か想定最大時間の引き上げ(#1385 #1555 #2047)。 `commands/setup.py` から呼ぶ。`prep` は `setup._InitPreparation`(`strategy`・`work`・`repo` を持つもの)を受ける。 """ from __future__ import annotations +import math import pathlib from typing import Any, Optional +import drive_pause import run_metrics import statefile import test_strategy as ts @@ -21,22 +23,41 @@ def history_file(repo: str) -> pathlib.Path: return allocation.history_path(run_metrics.metrics_dir(), repo) -def resolve_scope_seconds(prep: Any, scope: list[str], w: Optional[float]) -> tuple[Optional[float], Optional[str]]: - """CI に任せる戦略の範囲テストの所要 s と出所(I7・決定 5)。履歴の同じ戦略・同じ置き場所の実測(`history`)→ - 置き場所が全体テストの suite のパスを覆うときの w(`whole`)→ 無し。ほかの戦略では履歴を読まない。""" - if not prep.strategy.whole_on_ci: - return None, None - locations = baseline_lib.locations_of(prep.strategy, scope, prep.work) - if not locations: - return None, None - measured = allocation.scope_seconds(allocation.read_history(history_file(prep.repo)), prep.strategy.name, locations) - if measured is not None: - return measured, "history" - if w is not None and ts.covers_whole(prep.strategy, locations): +def resolve_scope_seconds( + prep: Any, scope: list[str], w: Optional[float], m: Optional[float] = None +) -> tuple[Optional[float], Optional[str]]: + """着手前のテストの所要の見込み s と出所(#1555 の I7・#2047 の I1)。CI に任せる戦略だけ、履歴の同じ戦略・同じ置き場所の + 実測(`history`)を先に採る。無ければ手元の全体テストの直近の実測 m(`recent`)→ 宣言の w(`whole`)→ 無し。 + 範囲テストは全体テストの部分なので、全体テストの所要は上限の見込みとして上側に倒れる。""" + if prep.strategy.whole_on_ci: + locations = baseline_lib.locations_of(prep.strategy, scope, prep.work) + if locations: + measured = allocation.scope_seconds(allocation.read_history(history_file(prep.repo)), prep.strategy.name, locations) + if measured is not None: + return measured, "history" + if m is not None: + return float(m), "recent" + if w is not None: return float(w), "whole" return None, None +def recent_whole_seconds(prep: Any) -> Optional[float]: + """手元の全体テストの直近の実測 m(`whole_runs`)。無ければ None。""" + got = whole_runs.recent_whole(prep.work, ts.whole_key(prep.strategy)) + return float(got[0]) if got else None + + +def extend_for_init_test(budget_minutes: int, timeout: int) -> None: + """着手前のテストの上限 T が `0.10·B` を超えるなら、テストを始める前にステップの延長を書く(#2047 の決定 4)。 + 見込みは `B·60 + (T − 0.10·B·60)`。エンジンの外で打ったときは何もしない。""" + base = math.ceil(float(budget_minutes) * 60 * ts.INIT_TEST_SHARE) + if timeout <= base: + return + seconds = int(budget_minutes) * 60 + (int(timeout) - base) + drive_pause.write_extension(seconds, f"着手前のテストの上限 {timeout} 秒が想定最大時間の 10%({base} 秒)を超える") + + def append_init_test(prep: Any, budget_minutes: int, pr: Any, baseline: dict[str, Any]) -> None: """着手前のテストの行を履歴へ 1 行足す(E9)。打ち切りでも足す。書けなければ知らせて続ける。""" try: @@ -56,7 +77,8 @@ def record_whole_run(prep: Any, baseline: dict[str, Any]) -> None: def abort_timed_out(strategy: ts.Strategy, budget_minutes: int, baseline: dict[str, Any], timeout: int) -> None: - """着手前のテストの打ち切りで止める。履歴に残したことと、次の実行の上限の見込みを添える。""" + """着手前のテストの打ち切りで止める。時間の枠の不足(`SKIP=init-timeout`)を出し、履歴に残したことと、次の実行の上限の見込みを添える。""" + statefile.emit(SKIP="init-timeout", SKIP_DETAIL=f"着手前のテストが上限 {timeout} 秒で終わらなかった") lines = [f"着手前のテストが {timeout} 秒で終わりませんでした({baseline.get('command')})。打ち切りました"] if baseline.get("mode") == "scope" and strategy.whole_on_ci: following = ts.limits(strategy, budget_minutes, scope_seconds=float(timeout), scope_source="history")["init_test_timeout"] @@ -66,13 +88,51 @@ def abort_timed_out(strategy: ts.Strategy, budget_minutes: int, baseline: dict[s die("\n".join(lines)) -def stop_if_window_short(state_file: pathlib.Path, state: dict[str, Any]) -> None: - """手順の枠が想定最大時間に収まらなければ、止めた印を残して保存し、終了コード 4 で止める。収まれば印を消す。""" +def budget_source(state: dict[str, Any]) -> str: + """想定最大時間の出所。持たない状態ファイル(この変更より前の実行)は `explicit`(止まる側)として読む。""" + return str(state.get("budget_source") or "explicit") + + +def settle_window(state_file: pathlib.Path, state: dict[str, Any]) -> None: + """手順の枠が想定最大時間に収まるかを確かめ、出所に応じて上げるか止める(#2047 の I3・I4)。 + + 収まれば止めた印を消す。出所が `default` / `floor` なら想定最大時間を下限(打ち直しの見込みを足さない)へ上げ、 + 出所を `floor` にして上限の表を組み直す。`explicit` なら `SKIP=window` を出し、止めた印を残して保存し、終了コード 4 で止める。""" problem = timeline.window_problem(state["limits"]) if problem is None: if state.pop("window_stopped_at", None) is not None: statefile.save(state_file, state) return - state["window_stopped_at"] = statefile.now() + offset = timeline.window_offset(state) + after = float(state["limits"].get("after_plan_minutes") or 0.0) + floor = timeline.required_budget_minutes(offset, after, grace_seconds=0) + before = int(state["budget_minutes"]) + x = (state.get("baseline_test") or {}).get("seconds") + spent = f"着手前のテスト {x} 秒" if x is not None else f"枠の起点 {math.ceil(offset)} 秒" + if budget_source(state) == "explicit": + statefile.emit(SKIP="window", SKIP_DETAIL=f"要る下限 {floor} 分 > 想定最大時間 {before} 分({spent})") + state["window_stopped_at"] = statefile.now() + statefile.save(state_file, state) + die(problem) + return + minutes = max(floor, before) + state["budget_minutes"] = minutes + state["limits"] = timeline.of_state(state) + while timeline.window_problem(state["limits"]) is not None: # 時刻の丸めで 1 分届かないときだけ回る + minutes += 1 + state["budget_minutes"] = minutes + state["limits"] = timeline.of_state(state) + state["budget_source"] = "floor" + state["budget_floor"] = { + "from_minutes": before, + "minutes": minutes, + "offset_seconds": round(offset, 1), + "after_plan_minutes": after, + "at": statefile.now(), + } + state.pop("window_stopped_at", None) statefile.save(state_file, state) - die(problem) + info( + f"ℹ 想定最大時間を省いたため、下限の {minutes} 分へ上げました({before} 分では提案とリファクタリング計画の枠と" + f"計画の後の見込み {after} 分が収まらない。{spent})" + ) 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 0308b2cdc..af7b7a50e 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/plan.py @@ -315,7 +315,7 @@ def limits_section(limits: dict[str, Any]) -> list[str]: "", f"入力: 予算 {basis.get('budget_minutes')} 分 / 全体テストの所要 w {basis.get('w')}({basis.get('w_source') or '—'})" f" / 着手前の実測 x {basis.get('x')} / CI の壁時計 c {basis.get('c')}" - f" / 範囲テストの所要 s {basis.get('scope_seconds')}({basis.get('scope_source') or '—'}) / 戦略 {basis.get('strategy')}", + f" / 着手前のテストの所要の見込み s {basis.get('scope_seconds')}({basis.get('scope_source') or '—'}) / 戦略 {basis.get('strategy')}", ] ) lines.extend(["", "無音の打ち切りは手順の監視の上限と同じ値である。項目ごとの期限は持たない(実装を止めるのは実装の終わりだけ)。", ""]) diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py index 6f03a3ea3..ce7fd0e2b 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/timeline.py @@ -229,11 +229,12 @@ def of_state(state: dict[str, Any], after_plan: Optional[dict[str, Any]] = None) ) -def required_budget_minutes(offset_seconds: float, after_plan: float = 0.0) -> int: +def required_budget_minutes(offset_seconds: float, after_plan: float = 0.0, grace_seconds: float = RESUME_GRACE_SECONDS) -> int: """枠の起点が o のとき、提案と改修計画の枠の後に見込み(R + L)が収まる想定最大時間の下限(分)。 - 人が打ち直すまでの見込みを足す(I8)。見込みが 0 なら枠だけが収まる下限である。""" + 人が打ち直すまでの見込み `grace_seconds` を足す(I8)。打ち直さずにその場で上げる経路(`init_test.settle_window`)は 0 を渡す + (#2047 の決定 2)。見込みが 0 なら枠だけが収まる下限である。""" share = round(1.0 - PROPOSE_SHARE - PLAN_SHARE, 9) - return math.ceil(round(((float(offset_seconds) + RESUME_GRACE_SECONDS) / 60 + float(after_plan or 0.0)) / share, 9)) + return math.ceil(round(((float(offset_seconds) + float(grace_seconds)) / 60 + float(after_plan or 0.0)) / share, 9)) def window_problem(limits: dict[str, Any]) -> Optional[str]: diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py b/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py index 95f570d87..538458b38 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_drive_contract.py @@ -106,3 +106,64 @@ def test_readopt_round_launches_the_implement_cli_for_tested_items(): d, calls = _readopt_drive(["verified", "tested"]) d.readopt_rounds() assert calls == ["readopt", "cli:add-tests", "merge-tests", "cli:implement", "merge-implement", "verify", "readopt"] + + +# ---------- #2047: 時間の枠の不足は飛ばした欠けの残る結果、下限まで上げたらステップの延長 ---------- + + +@pytest.mark.parametrize( + "kind, detail", + [ + ("window", "要る下限 61 分 > 想定最大時間 30 分(着手前のテスト 495.0 秒)"), + ("init-timeout", "着手前のテストが上限 180 秒で終わらなかった"), + ], +) +def test_a_window_shortage_returns_the_skipped_gap(tmp_path, monkeypatch, capsys, kind, detail): + """I5: init の SKIP だけを、gaps 1 件の stopped・終了コード 6 で返す。summary と理由に「落ちた」「失敗」を入れない。""" + + def fake(cmd, env=None, cwd=None): + return 4, f"SKIP={kind}\nSKIP_DETAIL='{detail}'\n" + + monkeypatch.setattr(rf, "call", fake) + with pytest.raises(SystemExit) as e: + rf.main(["5"]) + out = json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + assert e.value.code == 6 + assert out["status"] == "stopped" + assert out["metrics"] == {"skipped": {"kind": kind, "reason": detail}, "exit": 4} + assert out["gaps"] == [{"kind": "refactor", "name": kind, "reason": f"時間の枠に収まらず飛ばした: {detail}"}] + for text in (out["summary"], out["gaps"][0]["reason"]): + assert "落ちた" not in text and "失敗" not in text + + +def _started_drive(tmp_path, monkeypatch, source: str): + """init が BUDGET_SOURCE を返す start を、子を呼ばずに打つ。延長のファイルのパスを返す。""" + target = tmp_path / "01-refactor.extend.json" + monkeypatch.setenv(drive_pause.EXTEND_ENV, str(target)) + d = rf.Drive(5, []) + out = f"ID=7\nTMP_DIR={tmp_path}\nBUDGET_MINUTES=40\nBUDGET_SOURCE={source}\n" + monkeypatch.setattr(rf, "call", lambda cmd, env=None, cwd=None: (0, out if "init" in cmd else "")) + d.start() + return target + + +def test_a_floor_budget_extends_the_step(tmp_path, monkeypatch): + """決定 4: 下限まで上げた想定最大時間(40 分)の秒を延長のファイルへ書く。""" + target = _started_drive(tmp_path, monkeypatch, "floor") + assert drive_pause.read_extension(target) == (2400.0, "想定最大時間を下限の 40 分へ上げた") + + +@pytest.mark.parametrize("source", ["default", "explicit"]) +def test_other_budget_sources_do_not_extend_the_step(tmp_path, monkeypatch, source): + assert not _started_drive(tmp_path, monkeypatch, source).exists() + + +def test_the_extension_never_moves_back(tmp_path, monkeypatch): + """I7: 後から小さい秒を書いても、延長のファイルの秒は前へ戻らない。""" + target = tmp_path / "x.extend.json" + monkeypatch.setenv(drive_pause.EXTEND_ENV, str(target)) + assert drive_pause.write_extension(6174, "着手前のテスト") + assert drive_pause.write_extension(2400, "下限") + assert drive_pause.read_extension(target) == (6174.0, "着手前のテスト") + monkeypatch.delenv(drive_pause.EXTEND_ENV) + assert not drive_pause.write_extension(9000, "エンジンの外") diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index dcf286e37..d123e5814 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 json import os import shlex +import shutil import subprocess import types @@ -25,15 +26,10 @@ def _git(*args, cwd): return subprocess.run(["git", *args], cwd=cwd, capture_output=True, text=True, check=True) -@pytest.fixture -def origin_repo(tmp_path): - """origin にだけ head ブランチがあるリポジトリ。 - - `git worktree add ` はローカルにブランチが無いと失敗するため、 - その経路を踏ませる形で用意する。 - """ - origin = tmp_path / "origin.git" - repo = tmp_path / "repo" +def _build_origin_repo(base): + """origin にだけ head ブランチがあるリポジトリを `base` の下に作る。""" + origin = base / "origin.git" + repo = base / "repo" subprocess.run(["git", "init", "-q", "--bare", str(origin)], check=True) subprocess.run(["git", "clone", "-q", str(origin), str(repo)], check=True, capture_output=True) _git("config", "user.email", "t@e.st", cwd=repo) @@ -52,6 +48,26 @@ def origin_repo(tmp_path): # ローカルの head ブランチを消し、origin にだけある状態にする _git("checkout", "-q", "main", cwd=repo) _git("branch", "-qD", HEAD_BRANCH, cwd=repo) + + +@pytest.fixture(scope="session") +def _origin_template(tmp_path_factory): + base = tmp_path_factory.mktemp("origin-template") + _build_origin_repo(base) + return base + + +@pytest.fixture +def origin_repo(tmp_path, _origin_template): + """origin にだけ head ブランチがあるリポジトリ。 + + `git worktree add ` はローカルにブランチが無いと失敗するため、 + その経路を踏ませる形で用意する。git の組み立てはプロセスごとに 1 度だけ行い、テストごとに複製する。 + """ + for name in ("origin.git", "repo"): + shutil.copytree(_origin_template / name, tmp_path / name, symlinks=True) + repo = tmp_path / "repo" + _git("remote", "set-url", "origin", str(tmp_path / "origin.git"), cwd=repo) return repo @@ -2148,3 +2164,131 @@ def test_the_pull_request_check_adds_no_gh_call(run_init, tmp_path, capsys): run_init(_args(tmp_path)) assert stopped == run_init.gh_calls assert sum(1 for c in stopped if c[2:3] and c[2].startswith("repos/")) == 1 + + +# ---------- #2047: 想定最大時間を省いたら下限まで上げ、明示したら飛ばす合図を出す ---------- + + +def _floor_minutes(state, x): + """打ち直しの見込みを足さない下限(決定 2)。""" + import math + + return math.ceil(round((x / 60 + float(state["limits"]["after_plan_minutes"])) / 0.70, 9)) + + +def test_an_omitted_budget_rises_to_the_lower_bound(run_init, tmp_path, init_clock, slow_baseline, capsys): + """受け入れ条件 3・I3: x = 495 秒で既定 30 分の枠が収まらなくても止まらず、下限まで上げて続ける。""" + timeline = sys.modules["refactor_lib.timeline"] + slow_baseline["seconds"] = 495.0 + init_clock(0) + run_init(_args(tmp_path)) + captured = capsys.readouterr() + _, state = _state_of(tmp_path) + floor = _floor_minutes(state, 495.0) + assert floor > 30 and floor < timeline.required_budget_minutes(495.0, state["limits"]["after_plan_minutes"]) + assert (state["budget_minutes"], state["budget_source"]) == (floor, "floor") + assert state["budget_floor"]["from_minutes"] == 30 and state["budget_floor"]["minutes"] == floor + assert state["limits"]["budget_minutes"] == floor and timeline.window_problem(state["limits"]) is None + assert f"下限の {floor} 分へ上げました" in captured.err + assert "BUDGET_SOURCE=floor" in captured.out and f"BUDGET_MINUTES={floor}" in captured.out + assert "PHASE=" in captured.out and "SKIP=" not in captured.out + + +def test_a_resume_keeps_or_replaces_the_budget_source(run_init, tmp_path, init_clock, slow_baseline): + """受け入れ条件 3(再開): 引数なしの再開は floor のまま、引数を渡すと explicit になる。""" + slow_baseline["seconds"] = 495.0 + init_clock(0) + run_init(_args(tmp_path)) + raised = _state_of(tmp_path)[1]["budget_minutes"] + run_init(_args(tmp_path)) + _, state = _state_of(tmp_path) + assert (state["budget_minutes"], state["budget_source"]) == (raised, "floor") + run_init(_args(tmp_path, budget_minutes="120")) + _, state = _state_of(tmp_path) + assert (state["budget_minutes"], state["budget_source"]) == (120, "explicit") + + +def test_an_explicit_budget_short_of_the_floor_emits_skip(run_init, tmp_path, init_clock, slow_baseline, capsys): + """受け入れ条件 5・I2・I4: 明示した 30 分は上げず、SKIP=window と下限(打ち直しの見込みなし)を出して 4 で止まる。""" + slow_baseline["seconds"] = 495.0 + captured = _stopped_init(run_init, tmp_path, init_clock, capsys) + _, state = _state_of(tmp_path) + floor = _floor_minutes(state, 495.0) + assert (state["budget_minutes"], state["budget_source"]) == (30, "explicit") and state["window_stopped_at"] + assert "SKIP=window" in captured.out + assert f"要る下限 {floor} 分 > 想定最大時間 30 分(着手前のテスト 495.0 秒)" in captured.out + assert "以上にして" in captured.err, "人へ出す文は今どおり" + + +def test_an_explicit_budget_that_fits_does_not_change(run_init, tmp_path, init_clock, slow_baseline, capsys): + """受け入れ条件 6: 明示した想定最大時間が下限以上なら、想定最大時間と手順の枠は今と同じ。""" + slow_baseline["seconds"] = 495.0 + init_clock(0) + run_init(_args(tmp_path, budget_minutes="120")) + _, state = _state_of(tmp_path) + assert (state["budget_minutes"], state["budget_source"]) == (120, "explicit") and "budget_floor" not in state + assert _window_seconds(state, "final_end_at") == 120 * 60 + assert _window_seconds(state, "propose_end_at") == 495 + 120 * 60 * 0.20 + assert "SKIP=" not in capsys.readouterr().out + + +def test_a_state_without_the_source_reads_as_explicit(refactor): + assert sys.modules["refactor_lib.init_test"].budget_source({"budget_minutes": 30}) == "explicit" + + +def test_a_timed_out_init_test_emits_skip(verify_init, capsys): + """受け入れ条件 5・I4: 着手前のテストの打ち切りは SKIP=init-timeout と上限の秒を出す。""" + verify_init.run["timed_out"] = True + with pytest.raises(SystemExit): + verify_init.call() + out = capsys.readouterr().out + assert "SKIP=init-timeout" in out and "上限 180 秒で終わらなかった" in out + + +def _record_recent(verify_init, seconds): + whole_runs = sys.modules["whole_runs"] + ts = sys.modules["test_strategy"] + work = verify_init.history.parents[2] / "work" + whole_runs.record_whole(work, ts.whole_key(verify_init.strategy), seconds, "test") + + +def test_the_first_init_limit_reads_the_recent_whole_test(verify_init): + """受け入れ条件 1・I1: 履歴が無くても、手元の全体テストの直近の実測 m = 620 秒なら上限は 3·m = 1,860 秒。""" + _record_recent(verify_init, 620.0) + _, baseline, _ = verify_init.call() + assert verify_init.given == [1860] + assert (baseline["scope_seconds"], baseline["scope_source"]) == (620.0, "recent") + + +@pytest.mark.parametrize("name", ["local-full", "local-scoped-ci-whole"]) +def test_the_init_estimate_order(refactor, tmp_path, name): + """受け入れ条件 2・I1: m → w → 無し。w は置き場所が全体テストを覆うかを問わない。""" + ts = sys.modules["test_strategy"] + init_test = sys.modules["refactor_lib.init_test"] + strategy = ts.Strategy(name, "args", [ts.Suite("unit", "pytest .", scope_command="pytest {paths}", paths=["elsewhere"])]) + prep = types.SimpleNamespace(strategy=strategy, work=tmp_path, repo="acme/demo") + assert init_test.resolve_scope_seconds(prep, ["tests"], 1518.0, 620.0) == (620.0, "recent") + assert init_test.resolve_scope_seconds(prep, ["tests"], 1518.0) == (1518.0, "whole") + assert init_test.resolve_scope_seconds(prep, ["tests"], None) == (None, None) + + +def test_a_long_init_limit_extends_the_step_before_the_test(verify_init, monkeypatch, tmp_path): + """決定 4: 既定 30 分・s = 1,518 秒なら、着手前のテストを始める前に延長のファイルへ 6,174 秒を書く。""" + drive_pause = sys.modules["drive_pause"] + target = tmp_path / "01-refactor.extend.json" + monkeypatch.setenv(drive_pause.EXTEND_ENV, str(target)) + seen = [] + real = verify_init.setup.baseline_lib.run_baseline + monkeypatch.setattr( + verify_init.setup.baseline_lib, "run_baseline", lambda *a, **k: (seen.append(drive_pause.read_extension(target)), real(*a, **k))[1] + ) + _record_recent(verify_init, 1518.0) + verify_init.call() + assert verify_init.given == [4554] and seen[0][0] == 6174.0 + + +def test_a_short_init_limit_does_not_extend_the_step(verify_init, monkeypatch, tmp_path): + target = tmp_path / "01-refactor.extend.json" + monkeypatch.setenv(sys.modules["drive_pause"].EXTEND_ENV, str(target)) + verify_init.call() + assert verify_init.given == [180] and not target.exists() diff --git a/plugins/ndf/skills/development-workflow/references/glossary.md b/plugins/ndf/skills/development-workflow/references/glossary.md index 28b2e8ff0..862d47007 100644 --- a/plugins/ndf/skills/development-workflow/references/glossary.md +++ b/plugins/ndf/skills/development-workflow/references/glossary.md @@ -60,7 +60,7 @@ flowchart TB | --- | --- | --- | --- | | マイルストーン | 着手の順序を表す単位。スプリントはこの中から切り出す | GitHub の milestone。open は `<2 桁の連番> <主題>`、closed は `v<版数>` | [parallel-work.md](parallel-work.md) の「用語」 | | スプリント | 1 回のリリースとして出す課題と Pull Request のセット。版数を持つプロジェクトでは 1 つの版になる。区切りは 1 回のリリースとして出す中身で決める。工程はスプリント単位で 1 回ずつ通し、モードもスプリントで 1 つにする | ブランチ `sprint/<名前>`、目録 `sprint.json`、状態のファイル(例 `sprint-state.json`) | [parallel-work.md](parallel-work.md) の「用語」 | -| スプリント課題 | 「スプリントを閉じる」が閉じる対象にする課題。スプリントに含まれる Pull Request の本文が GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)で指す課題と、`--issues` で渡した課題の和 | `sprint-close.py` が閉じる | [progress-tracking](../../progress-tracking/SKILL.md) | +| スプリント課題 | 「スプリントを閉じる」が閉じる対象にする課題。スプリントに含まれる Pull Request の本文が GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)か閉じる予定の参照(`最終工程の後に閉じる: #N`)で指す課題と、`--issues` で渡した課題の和 | `sprint-close.py` が閉じる | [progress-tracking](../../progress-tracking/SKILL.md) | | 工程 | 工程表(モードごとに起動する Skill の表)の 1 行。要求と受け入れ条件・設計・実装・リリースなど | 進捗記録の `stage` | [../SKILL.md](../SKILL.md) の「モードごとに起動する Skill」 | | フェーズ | supervisor 1 つ(またはプラン 1 本)が通す、連続する工程のグループ。設計・実装・検査・取り込み・仕上げ・リリースの 6 つ | プランの `"フェーズ"`、Agent の `description` の先頭語 | [agent-layers.md](agent-layers.md) の「フェーズ」 | | プラン | `supervise.py` が流す 1 本の JSON。フェーズの手順をステップの列として持つ | `plan.json`、`supervise.py new impl` / `check` / `release` / `sprint` / `close` | [supervise.py](../../../scripts/supervise.py) の docstring | diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index 6f0eee90d..6fdde0e7f 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -12,7 +12,7 @@ 2. `loop select l20261025` が `upkeep.py rank` を打ち、#11 と #12 を選ぶ。#13 は「順位(切り出しの境界の外)」で外す 3. conductor がモードを `standard`、版を `10.20.0-dev.1` と決め、`loop sprint l20261025 --mode standard --dev 10.20.0-dev.1` を打つ 4. 返った `pipeline` をそのまま流す。設計 → 承認ゲート 1(MVV 判定)→ 実装 → 検査 → 開発版 → 本番(承認ゲート 2)→ - まとめ(確定仕様化・課題を閉じる・振り返り・棚卸し)→ `loop lap` + まとめ(確定仕様化・振り返り・課題を閉じる・棚卸し)→ `loop lap` 5. #12 の設計が承認ゲート 1 で「判定できない」だった。`loop lap`(または `loop gate`)が #12 を人の判断待ちへ移し、 課題の本文に `## 人の判断待ち(開発ループ l20261025)` を書き、#11 だけで続きの `pipeline` を返す 6. `loop lap` が周回を締め、止まる条件に当たらなければ `ndf-next` を返す。ラッパーが次のセッションを起動し、手順 1 から続ける diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index a0e567703..683fbf468 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -71,7 +71,7 @@ graph LR | --- | --- | --- | | その場で通す | 作業場所の用意 / 計画 / 実装 / 完了判定 / Pull Request / 後片付け / 配布 / リリース後テスト | 課題ごと。実装のプランが範囲テスト → Draft の Pull Request(CI と並べる)→ 全体テスト → doc-lint → マージまでを通す。リリースは開発版と verify-install まで。本番は下のゲート 2 に従う | | トリガーで通す | リファクタリング / コードレビュー | 検査のトリガーが立ったとき、次の開発版の前に 1 回。範囲は前回の検査からの差分 | -| スプリントの終わりにまとめる | 確定仕様化 / 振り返り(受け入れ条件の確認と課題を閉じる作業を含む)/ 棚卸し | スプリントの終わりに 1 回ずつ | +| スプリントの終わりにまとめる | 確定仕様化 / 振り返り / 課題を閉じる(受け入れ条件の確認を含む。`progress-tracking` の「スプリントを閉じる」)/ 棚卸し | スプリントの終わりに 1 回ずつ | | 省かない | 要求と受け入れ条件 / 設計 / ドキュメント再構成 / ドキュメントレビュー | モードの定めどおり。設計の承認が要る変更は設計 Pull Request を出す | | 承認ゲート | `normal` | `auto` | `fast` | @@ -202,9 +202,9 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの **スプリントの終わり**は `supervise.py new close --name M --worktree <根> --issue N... --version <開発版> --prod <正式版> --state <状態>` が組む。最終の検査(実行条件 `eval --final`)→ 開発版と本番(実行条件 `changed --id -final`。 -最終の検査で変更があったときだけ)→ まとめ(`spec` 確定仕様化 → Pull Request → `close` 後片付け → `retro` 振り返り → `refine` 棚卸し) +最終の検査で変更があったときだけ)→ まとめ(`spec` 確定仕様化 → Pull Request →(配布の記録があれば `release-verify` リリース後テスト →)`retro` 振り返り → `close` 後片付け → `refine` 棚卸し) を `--then` のステージで流す。`close` のステップは `sprint-close.py --record-pr {queue_pr:release-prod} --issues <課題> ---with-verification` で、本番が飛ばされたときは `--record-pr 0`(本番の記録なし)になる。`refine` は `close` が 0 で終わったときだけ届き(スプリントを閉じてから棚卸しへ進む)、棚卸しを無人で通す(承認の要る反映は +--final-stage 振り返り --with-verification` で、本番が飛ばされたときは `--record-pr 0`(本番の記録なし)になる。`refine` は `close` が 0 で終わったときだけ届き(スプリントを閉じてから棚卸しへ進む)、棚卸しを無人で通す(承認の要る反映は 行わず、承認資料のパスと番号を報告に載せて終える)。まとめのプランは課題すべてへ 工程を記録するため、通過記録の報告が `まとめる:` から `記録あり:` へ移る。 リリースの経路がテンプレートで組むものでなければ `--version`・`--prod` は要らず、開発版と本番の代わりに下の経路のステージが入り、 diff --git a/plugins/ndf/skills/development-workflow/references/stage-completeness.md b/plugins/ndf/skills/development-workflow/references/stage-completeness.md index 161fd5c0c..50dde00a5 100644 --- a/plugins/ndf/skills/development-workflow/references/stage-completeness.md +++ b/plugins/ndf/skills/development-workflow/references/stage-completeness.md @@ -192,7 +192,7 @@ bash "$SCRIPTS/../skills/development-workflow/scripts/stage-check.sh" report < ## Pull Request の作成の時点で見るもの -**リリース(`配布`)の記録まで待たない。** `gh pr create` を観測し、本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)が指す課題の通過記録を +**リリース(`配布`)の記録まで待たない。** `gh pr create` を観測し、本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)と閉じる予定の参照(`最終工程の後に閉じる: #N`)が指す課題の通過記録を 読んで、記録の無い必須の工程を案内する(#424)。 | 何を見るか | 出すもの | diff --git a/plugins/ndf/skills/development-workflow/tests/test_document_rebuild_stage.py b/plugins/ndf/skills/development-workflow/tests/test_document_rebuild_stage.py index 26012a089..149e13184 100644 --- a/plugins/ndf/skills/development-workflow/tests/test_document_rebuild_stage.py +++ b/plugins/ndf/skills/development-workflow/tests/test_document_rebuild_stage.py @@ -26,7 +26,7 @@ NEW_STAGE = "ドキュメント再構成" RENAMED = {"設計レビュー": "ドキュメントレビュー", "レビュー": "実装レビュー"} -RUNTIMES = ("claude", "codex", "kiro", "agy") +RUNTIMES = ("claude", "codex") def lib_body() -> str: diff --git a/plugins/ndf/skills/merged/SKILL.md b/plugins/ndf/skills/merged/SKILL.md index de44ab8b6..738fbd247 100644 --- a/plugins/ndf/skills/merged/SKILL.md +++ b/plugins/ndf/skills/merged/SKILL.md @@ -198,14 +198,14 @@ python3 "$SCRIPTS/merged-steps.py" probe (--pr | --head <ブランチ # **gate も同じ実体を読む**ため、Skill の下にコピーは無い。 CLOSING="$SCRIPTS/lib/closing-issues.sh" -# 1. 本文から closing keywords が指す先を取り出す(<所有者>/<リポジトリ> と <番号> をタブ区切りで出す) +# 1. 本文から closing keywords と閉じる予定の参照が指す先を取り出す(<所有者>/<リポジトリ> と <番号> をタブ区切りで出す) gh pr view --json body -q .body | bash "$CLOSING" # 2. まだ OPEN のものだけに絞り、報告へ載せる gh issue view <番号> --repo <所有者>/<リポジトリ> --json state -q .state # OPEN / CLOSED ``` -- GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)が 1 つも無ければ何もしない。スクリプトは何も出さずに終了コード 0 で終わる +- GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)も閉じる予定の参照(`最終工程の後に閉じる: #N`。`progress-tracking` の「スプリントを閉じる」)も 1 つも無ければ何もしない。スクリプトは何も出さずに終了コード 0 で終わる - **この一覧は、後片付けの進捗記録を書く先でもある**(`progress-tracking` の「工程の単位と 記録する課題」。後片付けは Pull Request 単位で、マージした側が本文の closing keywords(`Closes #N` などの記法)が指す課題 すべてへ書く) @@ -278,7 +278,7 @@ bash "$SCRIPTS/../skills/development-workflow/scripts/stage-check.sh" report ..origin/<名前>`)を添える | | 止まった対象 | 「止まる条件」の「止まったときの一覧」と、同意の結果 | | 未完了 | 退避が失敗した worktreeのパス、退避先・退避済みのパス・失敗したパスと `mv` の出力(退避済みのものは退避先に残す。戻すなら同じ `mv` の逆)、「原因を取り除いた後に `/ndf:merged ` をもう一度実行する」 | -| スプリント課題 | このマージの closing keywords(`Closes #N` などの記法)が指す課題のうち OPEN のもの。「閉じるのはスプリントの最終工程(`progress-tracking` の「スプリントを閉じる」)」と添える | +| スプリント課題 | このマージの closing keywords(`Closes #N` などの記法)と閉じる予定の参照が指す課題のうち OPEN のもの。「閉じるのはスプリントの最終工程(`progress-tracking` の「スプリントを閉じる」)」と添える | | スプリントの最後か | 最後 / 残りあり / **判断できない** | あわせて次も載せる。 diff --git a/plugins/ndf/skills/pr/SKILL.md b/plugins/ndf/skills/pr/SKILL.md index e050ccc2c..2e326fa7b 100644 --- a/plugins/ndf/skills/pr/SKILL.md +++ b/plugins/ndf/skills/pr/SKILL.md @@ -152,6 +152,8 @@ URL は最終行に生のまま置く(Markdown リンクにすると利用者 課題の Pull Request は `--base sprint/<名前>` で出す。スプリントの Pull Request は、 スプリントブランチの worktree から `--base` を省いて出し、本文にスプリントが閉じる課題を番号ごとに書く。 +開発の起点(`base_branch`)が既定ブランチと同じリポジトリでは、上の develop の行の Pull Request が既定ブランチ宛になる。 +そのときの本文の扱いは次の節にある。 ## closing keywords は本文だけに書く @@ -159,6 +161,11 @@ URL は最終行に生のまま置く(Markdown リンクにすると利用者 メッセージに書くと、マージでそのコミットが指す課題だけが先に閉じ、スプリントが割れる。**本文の closing keywords は 外さない**(`stage-completeness.md` と `progress-tracking` の「スプリントを閉じる」が本文を入力にする)。 +**宛先が既定ブランチの Pull Request では、`pr-steps.py create` / `update` が本文の closing keywords を閉じる予定の参照 +(`最終工程の後に閉じる: #<番号>`)へ置き換えてから送る。** 書く側は closing keywords で書いてよい。置き換えた +指し先は結果の `metrics.deferred_closing` に載る。課題を閉じる時点と閉じる予定の参照の形は `progress-tracking` の +「スプリントを閉じる」が正本である。 + ## 設計 Pull Request の本文 **head のブランチ名が `design/` で始まる Pull Request の本文は、決定の中身を持たない**(決定は設計文書だけが持つ)。 diff --git a/plugins/ndf/skills/progress-tracking/SKILL.md b/plugins/ndf/skills/progress-tracking/SKILL.md index 8a242d800..c4ffd3e84 100644 --- a/plugins/ndf/skills/progress-tracking/SKILL.md +++ b/plugins/ndf/skills/progress-tracking/SKILL.md @@ -142,7 +142,7 @@ worker の起動指示へはこれをコピーし、本文を読ませない。 | 工程の単位(`parallel-work.md` の表) | 記録する側 | 記録する課題 | | --- | --- | --- | | 課題 | その課題を扱う supervisor | その課題 | -| Pull Request(後片付けを含む) | その工程を行った側。後片付けはマージした側 | その Pull Request の本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)が指す課題すべて | +| Pull Request(後片付けを含む) | その工程を行った側。後片付けはマージした側 | その Pull Request の本文の GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)と閉じる予定の参照(下の「閉じる予定の参照」)が指す課題すべて | | スプリント(確定仕様化・リリース・体裁レビュー・リリース後テスト・振り返り) | その工程を行った側。リリース以降はオーケストレーター | スプリント課題すべて | **記録は 1 回の実行に課題を並べてよい。** どの記録も記録のスクリプトが自分で通過記録へ積むため、 @@ -153,20 +153,28 @@ worker の起動指示へはこれをコピーし、本文を読ませない。 ## スプリントを閉じる -**契機は最終工程を出るときである。** この手順はそこで **1 度だけ**行う。 +**閉じる時点の決まりの正本はこの節だけである。** 呼ぶ側(Skill・まとめのプラン・`pr`)は閉じる時点と順序と +閉じる条件を写さず、この節を指す。worker は呼ばない(supervisor か conductor が呼ぶ)。 + +**課題を閉じてよいのは、その課題がその実行の最終工程を終えたときだけである。** NDF のどの経路 +(まとめのプランの `close`・最終工程を担う Skill・conductor の手打ち)で打っても、`sprint-close.py` が課題ごとに +最終工程の記録(閉じる条件 3)を確かめ、無ければ閉じない。呼ぶ側の手順の並びに頼らない。 **最終工程とは、棚卸しの前にその実行で最後に通る工程である**(工程表は `development-workflow` の 「モードごとに起動する Skill」。`operation` のリリース後テストと振り返りはそれぞれ条件付き)。 検証リリースで止めた実行も、その実行の最終工程でこの手順を行う。課題は閉じる条件に 当たらず OPEN のまま残り、後で本番へのリリースを行った実行の最終工程が改めて行う。 -**正本はここだけである。** 呼ぶ側は手順をコピーせず、この節を指す。worker は呼ばない -(supervisor か conductor が呼ぶ)。 +| 呼ぶ側 | 呼ぶ時点 | 呼ぶ条件 | `--final-stage` | +| --- | --- | --- | --- | +| まとめのプラン(`supervise.py new close`)の `close` のステップ | 振り返りの後、棚卸しの前 | プランで仕上げる実行(どのモードも `retro` を通す) | `振り返り` | +| `retrospective` | 記録を投稿し、課題の本文に辿る経路の行を足した後 | 振り返りを通る変更 | `振り返り` | +| `release-verification` | 検証結果を記録した後 | 後に振り返りが続かない変更(振り返りを通らない `operation`) | `リリース後テスト` | +| `release` | 配布を終えた後 | 後にリリース後テストも振り返りも続かない変更(`light`、どちらも通らない `operation`) | `配布` | -| 呼ぶ Skill | 呼ぶ時点 | 呼ぶ条件 | -| --- | --- | --- | -| `retrospective` | 記録を投稿した後 | 振り返りを通る変更 | -| `release-verification` | 検証結果を記録した後 | 後に振り返りが続かない変更(振り返りを通らない `operation`) | -| `release` | 配布を終えた後 | 後にリリース後テストも振り返りも続かない変更(`light`、どちらも通らない `operation`) | +まとめのプランの並びは `merge →(配布の記録があれば)release-verify → retro → close → refine` である。 +`release-verify` と `retro` の指示は課題を閉じない。プランの中では最終工程が振り返りになるため、`close` が閉じる。 +プランの外で Skill を直接通すときは、上の表の Skill がこの手順を行う。両方が打っても、早すぎる時点では +閉じる条件 3 で閉じず、2 度目は `already_closed` になる。 **閉じた後は、工程表の次の工程 `棚卸し`(`backlog-refinement`)へ進む。「スプリントを閉じる → 棚卸し」の 順序の正本はここである。** 逆にすると、`backlog-refinement` の手順 1 が読む「このスプリントで閉じた課題」が @@ -181,20 +189,25 @@ worker の起動指示へはこれをコピーし、本文を読ませない。 (別の実行なら `retrospective` の「Pull Request の番号を特定する」で引く)。 ```bash -python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> \ - [--with-verification] [--label "<マイルストーン>の<工程名>"] [--prs <番号,番号>] [--dry-run] +python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> [--final-stage <配布|リリース後テスト|振り返り>] \ + [--with-verification] [--label "<マイルストーン>の<工程名>"] [--prs <番号,番号>] [--issues <番号,番号>] [--dry-run] ``` -- `--with-verification`: リリース後テストを通る経路で付ける(閉じる条件 2 を見る) +- `--final-stage`: その実行の最終工程(上の表の値)。省けば `振り返り`。省いても閉じる条件は緩まない +- `--with-verification`: リリース後テストを通る経路で付ける(閉じる条件 2 を見る)。`--final-stage リリース後テスト` + では付けなくても条件 2 を見る - `--prs`: 同じ実行の中で `release` が承認の提示に並べた一覧を渡すとき。省けば最後の 「## 配布の記録」の `スプリント:` の行を使う +- `--issues`: スプリントの PR の本文が指さない課題を足すとき(`pace: fast` の実装 PR など) - スクリプトが行うこと: 配布の記録の読み取り → スプリント課題(スプリントの PR の本文の closing keywords - (GitHub が課題を自動で閉じるキーワード。`Closes #N` などの記法)が指す課題と `--issues` の課題)の収集 → - 閉じる条件の判定 → 課題ごとに「状態を読む → 記録のリポジトリの課題ならボードを Done → 読み直して OPEN なら閉じる → + (GitHub が課題を自動で閉じるキーワード。`Closes #N` などの記法)と閉じる予定の参照が指す課題と `--issues` の課題)の収集 → + 閉じる条件 1〜3 の判定 → 課題ごとに「状態を読む → 記録のリポジトリの課題ならボードを Done → 読み直して OPEN なら閉じる → 読み直して CLOSED を確かめる」→ 参照だけの課題の報告。他のリポジトリの課題にはボードを書かない - `--with-verification` の経路では、課題ごとの錠の中で状態と本文を 1 回で読んで閉じる条件 2 を判定し、満たした 記録のリポジトリの課題は、本文の `## 受け入れ条件` のチェックボックスに印(`[x]`)を付けてから閉じる。変えるのは - チェックボックスの空白 1 文字だけで、節の外と説明の行は変えない。他のリポジトリの課題の本文は書かない + チェックボックスの空白 1 文字だけで、節の外と説明の行は変えない。他のリポジトリの課題の本文は書かない。 + 閉じる条件 3 を満たさない課題には印を付けない +- 閉じる条件 3 は条件 2 と同じ 1 回の本文の読みを使う(`--final-stage 振り返り` で条件 2 を見ないときも本文を 1 回読む) - 参照だけの課題: スプリントの PR の題か本文が `#<番号>` か課題の URL で参照し、スプリント課題に入らない 記録のリポジトリの開いた課題。閉じずボードも書かず、`kept_open` で載せる。件数は `metrics.referenced_only` にある @@ -204,20 +217,21 @@ python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> \ | --- | --- | | 0 | `items` の表を報告に貼り、棚卸しの工程へ進む | | 1 | 失敗した課題がある。`items[].reason` と `cmd`(やり直すコマンド)を報告し、最終工程を完了と報告せず、棚卸しへ進まずに止まる | -| 2 | スプリントの PR の一覧が取れない。推測せず運用者に一覧を聞き、`--prs` で打ち直す。または、PR を読んだのにスプリント課題が 0 件(PR の本文に closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)が無く、`--issues` も無い)。`next` に並ぶ PR が参照した番号から閉じる課題を選び、`--issues` で渡して打ち直す | +| 2 | スプリントの PR の一覧が取れない。推測せず運用者に一覧を聞き、`--prs` で打ち直す。または、PR を読んだのにスプリント課題が 0 件(PR の本文に closing keywords(GitHub が課題を自動で閉じるキーワード Closes / Fixes / Resolves)も閉じる予定の参照も無く、`--issues` も無い)。`next` に並ぶ PR が参照した番号から閉じる課題を選び、`--issues` で渡して打ち直す | | 3 | 呼び出しの誤り。引数を直して打ち直す | `items[]` の `kind: "issue"` の `result` は次の 5 つのどれかになる(`--dry-run` では `would_close` も出る)。`kind: "board_note"` はボードへ Done を書けなかった理由(`projects-sync.sh` の結果 JSON が `ok` でない)で、報告にそのまま載せる。課題を閉じる処理は止めない。 印を付けた受け入れ条件の番号は `checked`、`--dry-run` で付ける予定の番号は `would_check` に載る。 +判定に使った最終工程は `metrics.final_stage` と `decisions` に載る。 | `result` | 意味 | 報告に載せるもの | | --- | --- | --- | | `closed` | 閉じた(ボードの自動化が閉じた場合を含む) | 番号と `cmd`(戻し方 `gh issue reopen`) | | `already_closed` | 手順の前から閉じていた(既定ブランチへのマージで GitHub が閉じた場合を含む) | 番号 | -| `failed` | 状態を読めない、または閉じた後も CLOSED でない。`--with-verification` では、本文の錠を取れない・本文を読めない・受け入れ条件の印を書けない(閉じない) | 番号・`reason`・`cmd`(やり直す `gh issue close`。本文の失敗では同じ `sprint-close.py` の呼び出し) | -| `closed_unverified` | 既に閉じていたが閉じる条件 2 を満たさない。開き直さない | 番号と `reason`(満たさない理由と番号)。受け入れ条件を確かめて印を付けるか、開き直すかを人が決める | -| `kept_open` | 閉じる条件に当たらない。または参照だけの課題(`reason` が「closing keywords(…)も --issues も無い参照」。状態を読めなければ先頭に「状態を読めない」) | 番号と `reason`(いつ閉じるか。参照だけの課題は、閉じるなら `--issues` で渡す) | +| `failed` | 状態を読めない、または閉じた後も CLOSED でない。本文を読めない(最終工程の記録を確かめられない。閉じない)。`--with-verification` では、本文の錠を取れない・受け入れ条件の印を書けない(閉じない) | 番号・`reason`・`cmd`(やり直す `gh issue close`。本文の失敗では同じ `sprint-close.py` の呼び出し) | +| `closed_unverified` | 既に閉じていたが閉じる条件 2 か 3 を満たさない。開き直さない | 番号と `reason`(満たさない理由と番号)。受け入れ条件を確かめて印を付けるか、開き直すかを人が決める | +| `kept_open` | 閉じる条件に当たらない(条件 3 では `reason` が「最終工程(<工程>)の記録が無い: <何が無いか>」)。または参照だけの課題(`reason` が「closing keywords(…)も --issues も無い参照」。状態を読めなければ先頭に「状態を読めない」) | 番号と `reason`(いつ閉じるか。参照だけの課題は、閉じるなら `--issues` で渡す) | **`kept_open` と `closed_unverified` は止める理由にしない。** 棚卸しへ進んでよい。 @@ -226,11 +240,31 @@ python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> \ | # | 条件 | 満たさないとき | | --- | --- | --- | | 1 | 最後の「## 配布の記録」の `段階:` が `本番` または `配布なし` で始まる | スプリント課題すべてを OPEN のまま残す | -| 2 | リリース後テストを通る経路では、その課題の行がすべて `合格` で、課題の本文の `## 受け入れ条件` のチェックボックスの番号(上から数えた順、1 から)のすべてに合格の行がある。同じ番号に合格でない行があればその番号は合格でない。チェックボックスが無い課題は行の合否だけで見る | その課題を OPEN のまま残す(`reason` に合格の行が無い番号)。既に閉じていれば `closed_unverified` | +| 2 | リリース後テストを通る経路(`--with-verification` か `--final-stage リリース後テスト`)では、その課題の行がすべて `合格` で、課題の本文の `## 受け入れ条件` のチェックボックスの番号(上から数えた順、1 から)のすべてに合格の行がある。同じ番号に合格でない行があればその番号は合格でない。チェックボックスが無い課題は行の合否だけで見る | その課題を OPEN のまま残す(`reason` に合格の行が無い番号)。既に閉じていれば `closed_unverified` | +| 3 | その課題の最終工程の記録がある。`配布` は条件 1、`リリース後テスト` は条件 2 がそれに当たる。`振り返り` は課題の本文に行頭から `振り返り: https://github.com/<所有者>/<リポジトリ>/(issues\|pull)/<番号>#issuecomment-<識別子>` の行があり、そのうち 1 つ以上の識別子が、記録の PR の最後の配布の記録を含むコメントの識別子以上である(配布の記録が本文にあれば記録の PR の最初のコメントの識別子と比べ、コメントが無ければ満たさない。`--record-pr 0` は行があれば満たす)。前の実行の振り返りの行は数えない | その課題を OPEN のまま残す(`reason` に通っていない工程)。既に閉じていれば `closed_unverified`。本文を読めなければ `failed` | **検証リリースは閉じる条件を満たさない。** 利用者が常用する版にまだ届いていない段階で、 確かめていないことが終わったように見える。 +`--record-pr 0`(本番の記録なし)は条件 1・2 を見ない。`--record-pr unknown`(記録の置き場が分からない)は +本文を読まずにすべて `kept_open` で返す。 + +### 閉じる予定の参照 + +**既定ブランチ宛の Pull Request の本文には closing keywords を残さない。** GitHub は既定ブランチへのマージの時点に +closing keywords の課題を閉じ、最終工程より先に閉じてしまう。NDF の手順(`pr-steps.py create` / `update` と +まとめのプランの pr のステップ)は、宛先が既定ブランチ(既定ブランチを決められないときを含む)なら、本文の +closing keywords を閉じる予定の参照へ置き換えてから送る。宛先が既定ブランチでない Pull Request の本文は書き換えない。 + +```text +最終工程の後に閉じる: <指し先> +``` + +`<指し先>` は closing keywords と同じ 3 つ(`#<番号>`・`<所有者>/<リポジトリ>#<番号>`・課題の URL)で、1 つの指し先に +1 つ書く。GitHub はこの形では閉じず、`sprint-close.py`(`lib/closing-issues.sh`)がスプリント課題として集める。 +形の正本は `$SCRIPTS/lib/closing.py` の `DEFERRED` である。置き換えた指し先は `pr-steps.py` の結果の +`metrics.deferred_closing` に載る。 + ### 配布の記録 **配布の記録は Pull Request に置く。** 書く側はこの形で書き、`sprint-close.py` はこの形だけを読む。 @@ -244,7 +278,7 @@ python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> \ `対象の版:` が本番の版と一致する最後のブロック(無ければ、版数を上げない配布として、最後の配布の記録より後で `対象の版:` が版数でない最後のブロック。`配布なし` ならその記録より後の最後のブロック)を使う。 表の `課題` の列の頭が課題の参照でない行は飛ばす。課題を指す行が 1 行も無い表は、スプリント課題すべてを -「条件と課題の対応が読めない」で OPEN のまま残す。 +「条件と課題の対応が読めない」で OPEN のまま残す(すべての条件をマージ前に確かめたと本文に記録した課題は除く)。 ## 関連 diff --git a/plugins/ndf/skills/quality-gates/SKILL.md b/plugins/ndf/skills/quality-gates/SKILL.md index e663a3c7e..005d89934 100644 --- a/plugins/ndf/skills/quality-gates/SKILL.md +++ b/plugins/ndf/skills/quality-gates/SKILL.md @@ -129,6 +129,21 @@ flowchart LR 条件ごとに検証手段を書く。手段を書けない条件は満たしたと言えない。 +**条件ごとに、マージ前に確かめたか、リリースの後でしか確かめられないかを課題の本文へ記録する。** +承認資料の「未実施のリリース後テスト」は、この記録で `マージ前` とした条件を載せない。記録の無い条件は、 +マージ前に確かめていても載る。閉じる判定(`sprint-close.py --with-verification`)も、`マージ前` の条件には +リリース後テストの合格の行を求めない。 + +```bash +python3 "$SCRIPTS/acceptance-check.py" record --issue 2144 \ + --pre-merge '1=pytest tests/test_acceptance_checks.py' --pre-merge '4=pytest tests/test_sprint_close_acceptance.py' \ + --post-release 5 --post-release 6 +``` + +本文の `## 受け入れ条件の確認` の節に `| 条件 | 区分 | 手段 |` の表として残り、打ち直すと渡した番号の行だけが +置き換わる。`マージ前` には手段(テスト名か実行したコマンド)が要る。打っても確かめられない条件(本番の利用者の +環境・次のスプリントで見ることなど)だけを `リリース後` にする。記録は `acceptance-check.py show --issue <番号>` で読める。 + **「人手の確認」として残す前に、機械で打てないかを確かめる。** 端末・画面・操作を伴う条件でも、 擬似端末(`script` / `expect` / pty)に本物のクライアントをつなぎ、その端末へ入力を打てば、振る舞いは マージ前に確かめられる。使い捨ての環境(実装で建てたイメージ・コンテナ)があれば、その中で打つ。 diff --git a/plugins/ndf/skills/release-verification/SKILL.md b/plugins/ndf/skills/release-verification/SKILL.md index 2e100dd8f..8cf42b0f2 100644 --- a/plugins/ndf/skills/release-verification/SKILL.md +++ b/plugins/ndf/skills/release-verification/SKILL.md @@ -66,7 +66,7 @@ allowed-tools: ```bash python3 "$SCRIPTS/release-verification-steps.py" verify-install --ref --expect <版> \ - [--plugins ndf,...] [--runtimes claude,codex,kiro] --root . + [--plugins ndf,...] [--runtimes claude,codex] --root . ``` 隔離した HOME で ref から導入し、導入された版と、前の正式版のタグから変わったファイルの中身が @@ -153,11 +153,9 @@ ref と一致するか、利用者の HOME の設定が変わっていないか **後に振り返りが続かない変更(振り返りを通らない `operation`)では、この工程がその実行の 最終工程である。** 検証結果を記録した後に、`progress-tracking` の「スプリントを閉じる」を -行う。**手順はそこが正本で、ここにはコピーしない。** 振り返りを通る変更では、そちらが行うため -ここでは行わない。閉じた後の順序(棚卸しへ進む)は、その正本にある。 - -**不合格・保留の条件を持つ課題と、合格の行が無い受け入れ条件を持つ課題は閉じない。** その課題は `開いたまま` になり、判定と理由が -報告に載る。確かめていないことが終わったように見えないためである。 +`--final-stage リリース後テスト` で行う。**手順・閉じる条件・閉じた後の順序はそこが正本で、ここにはコピーしない。** +振り返りを通る変更と、まとめのプランの `release-verify` のステップでは、課題を閉じない(振り返りの後に閉じる)。 +閉じなかった課題は `開いたまま` になり、判定と理由が報告に載る。 ## 関連 diff --git a/plugins/ndf/skills/release/SKILL.md b/plugins/ndf/skills/release/SKILL.md index 2cf867d9f..84ac62b4d 100644 --- a/plugins/ndf/skills/release/SKILL.md +++ b/plugins/ndf/skills/release/SKILL.md @@ -330,8 +330,8 @@ Pull Request へコメントで投稿する)。 そのことと根拠のコマンドを残す。書かずに省くと、リリースし忘れたのか、リリースが不要だったのかを 後から区別できない。**飛ばしたときも同じブロックを残す。** `段階: 配布なし(<飛ばした理由>)` と `スプリント:` の 2 行だけを書き(`版:` は書かない)、置き場所は Pull Request を作らない形と同じにする。 -**この記録が無いと、スプリント課題が閉じられない**(閉じる条件は「本番リリースまで済んだ、または -リリースを飛ばした」。読み方は `progress-tracking` の「スプリントを閉じる」が持つ)。 +**この記録が無いと、スプリント課題が閉じられない**(閉じる条件と読み方は `progress-tracking` の +「スプリントを閉じる」が持つ)。 ## モードごとの要否 @@ -346,7 +346,7 @@ Pull Request へコメントで投稿する)。 **後にリリース後テストも振り返りも続かない変更(`light`、どちらも通らない `operation`)では、 この工程がその実行の最終工程である。** 配布を終えた後に、 -`progress-tracking` の「スプリントを閉じる」を行う。**手順と、閉じた後の順序(棚卸しへ進む)はそこが正本で、ここにはコピーしない。** +`progress-tracking` の「スプリントを閉じる」を `--final-stage 配布` で行う。**手順と、閉じた後の順序(棚卸しへ進む)はそこが正本で、ここにはコピーしない。** 続く工程があるなら呼ばない(`release-verification` か `retrospective` が行う)。 **検証リリースで止めた実行でも呼ぶ。** 課題は閉じる条件に当たらず `開いたまま` になり、 閉じるのは後で本番リリースを行った実行の最終工程である。 diff --git a/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md b/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md index 4577e4c05..aba3d8b5e 100644 --- a/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md +++ b/plugins/ndf/skills/requirements-design/references/acceptance-criteria.md @@ -84,6 +84,18 @@ - [ ] 個人情報がログへ出力されない ``` +**退行しない条件は、前提にした今の振る舞いを書く前に 1 度実行して確かめる。** 「今と同じく〜する」は、 +今の振る舞いが本当にそうであることを前提にしている。前提が事実と違うと、条件はリリース後テストで初めて崩れる +(例: 「収束しないときは止まる」と書いたが、`final` が `max_rounds` のときは変更前から止まらなかった)。 +確かめた手段(実行したコマンドかテストの名前)とその結果を、条件のそばか課題の本文に書く。 + +```markdown +- [ ] 区分の記録が無い課題では、今と同じく印の無い条件がすべて載る(退行しない) + 前提の確認: `pytest tests/test_release_materials.py -k unverified` → 印の無い 3 件が載る(10.27.3) +``` + +前提が事実と違ったら、条件を今の振る舞いに合わせて書き直すか、違いを別の課題にする。 + ## 受け入れ条件とテストの対応 受け入れ条件は、そのままテストの名前になる粒度で書く。1 条件に複数の判定を詰め込むと、 diff --git a/plugins/ndf/skills/retrospective/SKILL.md b/plugins/ndf/skills/retrospective/SKILL.md index 602b35204..bf43cfa23 100644 --- a/plugins/ndf/skills/retrospective/SKILL.md +++ b/plugins/ndf/skills/retrospective/SKILL.md @@ -352,9 +352,11 @@ gh issue edit --repo "$RECORD_REPO" --body-file /tmp/issue-body.md ## スプリントを閉じる -**振り返りを通る変更では、この工程がその実行の最終工程である。** 記録を投稿した後に、 -`progress-tracking` の「スプリントを閉じる」を行う。**手順はそこが正本で、ここには写さない。** -ボードの `Status` を `Done` にするのも、課題を閉じるのも、その手順の中で行う。 +**振り返りを通る変更では、この工程がその実行の最終工程である。** 記録を投稿し、課題の本文に辿る経路の行を +足した後に、`progress-tracking` の「スプリントを閉じる」を `--final-stage 振り返り` で行う。辿る経路の行は、 +その手順が確かめる最終工程の記録である。**手順と閉じる条件はそこが正本で、ここには写さない。** +ボードの `Status` を `Done` にするのも、課題を閉じるのも、その手順の中で行う。まとめのプランの `retro` の +ステップでは課題を閉じない(次の `close` のステップが閉じる)。 ## 振り返りの範囲 diff --git a/plugins/ndf/skills/worktree/tests/test_agy_hooks.py b/plugins/ndf/skills/worktree/tests/test_agy_hooks.py deleted file mode 100644 index 6515e4c69..000000000 --- a/plugins/ndf/skills/worktree/tests/test_agy_hooks.py +++ /dev/null @@ -1,276 +0,0 @@ -"""agy の hook の経路を検証する(#215 の受け入れ条件 A4〜A6)。 - -agy は案内を作る時点と渡せる時点が離れている。**tool 実行前の hook がモデルへ文言を返す口 -(`reason`)は、拒否のときにしか働かない**(設計の決定 4 の実測)。NDF の誘導は操作を止め -ないため、案内はセッションの記録へ積み、次のモデル呼び出しの前に `PreInvocation` が -`injectSteps[].userMessage` で渡す。 - -セッション開始時にあたる事象も無いため、モデル呼び出しの通し番号(`invocationNum`)が 0 の -ときを開始時として扱う(決定 5)。 - -入力の形は agy 1.1.25 で実測したものである。tool の名前は `toolCall.name`、編集先は -`toolCall.args.TargetFile`、作業ディレクトリは `workspacePaths[0]`、セッションの識別子は -`conversationId` にある。**`write_file` は権限の名前であって tool の名前ではない。** -""" - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - -import pytest - -from hook_lib import payload -from worktree_helpers import GUARD, SCRIPTS_DIR, SESSION, write_declaration - -HOOKS_JSON = SCRIPTS_DIR.parent / "dev.agy" / "hooks.json" -CONVERSATION = "c-0001" - - -def run_hook(script: Path, payload: dict, cwd: Path, tmpdir: Path) -> dict: - env = os.environ.copy() - env["LC_ALL"] = "C" - env["TMPDIR"] = str(tmpdir) - proc = subprocess.run( - ["bash", str(script)], - input=json.dumps(payload), - cwd=str(cwd), - env=env, - capture_output=True, - text=True, - ) - return {"rc": proc.returncode, "out": proc.stdout, "err": proc.stderr} - - -def edit_call(path: Path, cwd: Path, tool: str = "write_to_file") -> dict: - """agy の `PreToolUse` の入力。編集先は絶対パスで届く。""" - return { - "conversationId": CONVERSATION, - "stepIdx": 3, - "workspacePaths": [str(cwd)], - "modelName": "gemini-3.8-flash-high", - "toolCall": {"name": tool, "args": {"TargetFile": str(path), "CodeContent": "x"}}, - } - - -def command_call(command: str, cwd: Path) -> dict: - """`run_command` は実行ディレクトリを `Cwd` で渡す。""" - return { - "conversationId": CONVERSATION, - "stepIdx": 4, - "workspacePaths": [str(cwd)], - "toolCall": {"name": "run_command", "args": {"CommandLine": command, "Cwd": str(cwd)}}, - } - - -def invocation(cwd: Path, number: int = 1) -> dict: - """agy の `PreInvocation` の入力。""" - return { - "conversationId": CONVERSATION, - "invocationNum": number, - "initialNumSteps": 4, - "workspacePaths": [str(cwd)], - } - - -def declared(main_repo: Path) -> Path: - write_declaration(main_repo, json.dumps({"version": 1})) - return main_repo - - -def state_file(tmpdir: Path) -> Path: - return tmpdir / f"ndf-worktree-{CONVERSATION}.json" - - -def pending_of(tmpdir: Path) -> list[str]: - path = state_file(tmpdir) - if not path.is_file(): - return [] - return json.loads(path.read_text(encoding="utf-8")).get("pending", []) - - -def injected(result: dict) -> str: - """`injectSteps` の `userMessage` を連結して返す。無ければ空文字。""" - if not result["out"].strip(): - return "" - steps = json.loads(result["out"]).get("injectSteps", []) - return "\n".join(step.get("userMessage", "") for step in steps) - - -# --- A4: 誘導がモデルへ届く ------------------------------------------------- - - -def test_protected_edit_is_queued_and_allowed(main_repo: Path, tmp_path: Path) -> None: - """主ディレクトリの編集は記録へ積まれ、tool の実行は止まらない。""" - declared(main_repo) - tmpdir = tmp_path / "state" - tmpdir.mkdir() - target = main_repo / "plugins" / "ndf" / "README.md" - - result = run_hook(GUARD, edit_call(target, main_repo), cwd=main_repo, tmpdir=tmpdir) - - assert result["rc"] == 0, result["err"] - assert json.loads(result["out"]) == {"decision": "allow"} - queued = pending_of(tmpdir) - assert len(queued) == 1 - assert "plugins/ndf/README.md" in queued[0] - - -def test_pending_reaches_the_next_invocation(main_repo: Path, tmp_path: Path) -> None: - """積まれた案内は、次のモデル呼び出しの前に `userMessage` として渡される。""" - declared(main_repo) - tmpdir = tmp_path / "state" - tmpdir.mkdir() - target = main_repo / "plugins" / "ndf" / "README.md" - run_hook(GUARD, edit_call(target, main_repo), cwd=main_repo, tmpdir=tmpdir) - - result = run_hook(SESSION, invocation(main_repo), cwd=main_repo, tmpdir=tmpdir) - - assert result["rc"] == 0, result["err"] - assert "plugins/ndf/README.md" in injected(result) - - -def test_pending_is_delivered_once(main_repo: Path, tmp_path: Path) -> None: - """取り出した案内は記録から消える。同じ案内を毎回渡さない。""" - declared(main_repo) - tmpdir = tmp_path / "state" - tmpdir.mkdir() - run_hook( - GUARD, - edit_call(main_repo / "plugins" / "ndf" / "README.md", main_repo), - cwd=main_repo, - tmpdir=tmpdir, - ) - run_hook(SESSION, invocation(main_repo), cwd=main_repo, tmpdir=tmpdir) - - assert pending_of(tmpdir) == [] - again = run_hook(SESSION, invocation(main_repo, number=2), cwd=main_repo, tmpdir=tmpdir) - assert injected(again) == "" - - -@pytest.mark.parametrize("tool", ["write_to_file", "replace_file_content"]) -def test_edit_tools_are_covered(main_repo: Path, tmp_path: Path, tool: str) -> None: - """編集を伴う tool は 2 つとも誘導の対象になる。 - - `write_file` は権限の名前であり、`toolCall.name` には届かない(実測)。 - """ - declared(main_repo) - tmpdir = tmp_path / "state" - tmpdir.mkdir() - target = main_repo / "src" / "app.py" - - run_hook(GUARD, edit_call(target, main_repo, tool=tool), cwd=main_repo, tmpdir=tmpdir) - - assert any("src/app.py" in item for item in pending_of(tmpdir)) - - -def test_shell_write_is_covered(main_repo: Path, tmp_path: Path) -> None: - """`run_command` は書き込みの形から編集先を推定する。起点は `Cwd` である。""" - declared(main_repo) - tmpdir = tmp_path / "state" - tmpdir.mkdir() - - run_hook(GUARD, command_call("echo x > src/app.py", main_repo), cwd=main_repo, tmpdir=tmpdir) - - assert any("src/app.py" in item for item in pending_of(tmpdir)) - - -def test_first_invocation_reports_stray_changes(main_repo: Path, tmp_path: Path) -> None: - """通し番号が 0 のとき、主ディレクトリの逸脱を提示する(開始時にあたる)。""" - declared(main_repo) - (main_repo / "README.md").write_text("# changed\n", encoding="utf-8") - tmpdir = tmp_path / "state" - tmpdir.mkdir() - - result = run_hook(SESSION, invocation(main_repo, number=0), cwd=main_repo, tmpdir=tmpdir) - - assert "未コミット変更" in injected(result) - - -def test_later_invocation_does_not_repeat_the_report(main_repo: Path, tmp_path: Path) -> None: - """通し番号が 0 でないときは逸脱の提示を行わない。毎回の呼び出しで繰り返さない。""" - declared(main_repo) - (main_repo / "README.md").write_text("# changed\n", encoding="utf-8") - tmpdir = tmp_path / "state" - tmpdir.mkdir() - - result = run_hook(SESSION, invocation(main_repo, number=3), cwd=main_repo, tmpdir=tmpdir) - - assert injected(result) == "" - assert json.loads(result["out"]) == {} - - -# --- A5: 作業ツリーの中では出さない ----------------------------------------- - - -def test_edit_inside_the_worktree_queues_nothing(main_repo: Path, worktree: Path, tmp_path: Path) -> None: - """作業ツリーの中の編集は記録へ積まれない。""" - declared(main_repo) - tmpdir = tmp_path / "state" - tmpdir.mkdir() - target = worktree / "plugins" / "ndf" / "README.md" - - result = run_hook(GUARD, edit_call(target, worktree), cwd=worktree, tmpdir=tmpdir) - - assert result["rc"] == 0, result["err"] - assert pending_of(tmpdir) == [] - - -def test_allowed_path_queues_nothing(main_repo: Path, tmp_path: Path) -> None: - """許可されたパスの編集は記録へ積まれない。""" - declared(main_repo) - tmpdir = tmp_path / "state" - tmpdir.mkdir() - - run_hook(GUARD, edit_call(main_repo / "docs" / "note.md", main_repo), cwd=main_repo, tmpdir=tmpdir) - - assert pending_of(tmpdir) == [] - - -# --- A6: 宣言が無いリポジトリでは何もしない --------------------------------- - - -def test_no_declaration_produces_no_output(main_repo: Path, tmp_path: Path) -> None: - """`.ndf/worktree.json` が無ければ、どちらの hook も何も出力しない。""" - tmpdir = tmp_path / "state" - tmpdir.mkdir() - target = main_repo / "plugins" / "ndf" / "README.md" - - guard = run_hook(GUARD, edit_call(target, main_repo), cwd=main_repo, tmpdir=tmpdir) - session = run_hook(SESSION, invocation(main_repo, number=0), cwd=main_repo, tmpdir=tmpdir) - - assert guard["rc"] == 0 and guard["out"].strip() == "" - assert session["rc"] == 0 and session["out"].strip() == "" - - -# --- hook の結線 ------------------------------------------------------------- - - -def test_hooks_json_matcher_matches_the_library() -> None: - """`dev.agy/hooks.json` の `matcher` は共通ライブラリの一覧から作る。 - - tool 名の一覧を 2 箇所に持つと、判定を足しても hook が起動しない状態になる。 - """ - config = json.loads(HOOKS_JSON.read_text(encoding="utf-8")) - matchers = {entry.get("matcher") for hook in config.values() for entry in hook.get("PreToolUse", [])} - assert payload.tool_matcher() in matchers, matchers - - -def test_hooks_json_wires_both_scripts() -> None: - """`PreToolUse` は誘導を、`PreInvocation` は開始時と案内の受け渡しを担う。 - - `PreInvocation` は matcher を取らず、handler の配列を直に持つ(agy の書式)。 - """ - config = json.loads(HOOKS_JSON.read_text(encoding="utf-8")) - pre_tool = [hook["command"] for definition in config.values() for entry in definition.get("PreToolUse", []) for hook in entry["hooks"]] - pre_invocation = [handler["command"] for definition in config.values() for handler in definition.get("PreInvocation", [])] - assert any("worktree-guard.sh" in c for c in pre_tool), pre_tool - assert any("worktree-session.sh" in c for c in pre_invocation), pre_invocation - - -@pytest.mark.parametrize("tool", ["write_to_file", "replace_file_content"]) -def test_agy_tool_names_are_in_the_library(tool: str) -> None: - """agy の tool 名は共通ライブラリの一覧に載っている。""" - assert tool in payload.EDIT_TOOLS, payload.EDIT_TOOLS diff --git a/plugins/ndf/skills/worktree/tests/test_hook_wiring.py b/plugins/ndf/skills/worktree/tests/test_hook_wiring.py index 49390b2ca..c801697bb 100644 --- a/plugins/ndf/skills/worktree/tests/test_hook_wiring.py +++ b/plugins/ndf/skills/worktree/tests/test_hook_wiring.py @@ -16,7 +16,6 @@ from worktree_helpers import SCRIPTS_DIR HOOKS_DIR = SCRIPTS_DIR.parent / "hooks" -INSTALL_SH = SCRIPTS_DIR.parent / "dev.kiro" / "install.sh" def guard_entries(runtime: str) -> list[dict]: @@ -43,12 +42,3 @@ def test_sessionstart_runs_the_session_script(runtime: str) -> None: config = json.loads((HOOKS_DIR / f"{runtime}.json").read_text(encoding="utf-8")) commands = [hook["command"] for entry in config["hooks"]["SessionStart"] for hook in entry["hooks"]] assert any("worktree-session.sh" in c for c in commands), commands - - -def test_kiro_installer_wires_both_hooks() -> None: - """Kiro CLI は導入スクリプトがエージェント定義へ書き込む。""" - body = INSTALL_SH.read_text(encoding="utf-8") - assert "worktree-session.sh" in body - assert "worktree-guard.sh" in body - assert '"userPromptSubmit"' in body - assert 'hooks.setdefault("agentSpawn"' in body diff --git a/plugins/playwright-kit/.claude-plugin/plugin.json b/plugins/playwright-kit/.claude-plugin/plugin.json index 73fe74efb..b72f1183f 100644 --- a/plugins/playwright-kit/.claude-plugin/plugin.json +++ b/plugins/playwright-kit/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "playwright-kit", - "version": "3.0.2", - "description": "Claude Code plugin (v3.0.2): 4 Playwright E2E skills — test planning by page role, script authoring with video / trace evidence, report generation with collected evidence, and the playwright_kit runtime (init, a11y / CWV scans).", + "version": "4.0.0", + "description": "Claude Code plugin (v4.0.0): 4 Playwright E2E skills — test planning by page role, script authoring with video / trace evidence, report generation with collected evidence, and the playwright_kit runtime (init, a11y / CWV scans).", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/playwright-kit/README.md b/plugins/playwright-kit/README.md index b83d17fcf..15c024d66 100644 --- a/plugins/playwright-kit/README.md +++ b/plugins/playwright-kit/README.md @@ -1,9 +1,8 @@ # playwright-kit Plugin -Playwright による E2E テストの計画・実装・証跡管理を提供するプラグインです。3 ランタイム分の +Playwright による E2E テストの計画・実装・証跡管理を提供するプラグインです。Claude Code と Codex の 配布物を `plugins/playwright-kit/` の 1 ディレクトリにまとめており、Skill の実体は `skills/` の -1 箇所だけです。ランタイム固有のファイルは `.claude-plugin/`(Claude Code)と `dev.kiro/` -(Kiro CLI)に分かれ、Codex はルートの `plugin.json`(Agent Plugins 形式)を読みます。 +1 箇所だけです。Claude Code は `.claude-plugin/` を、Codex はルートの `plugin.json`(Agent Plugins 形式)を読みます。 NDF プラグイン v6.1.0 までは NDF に同梱していました。**Skill 名は変えていない**ため、 `/playwright-` まで打てば従来どおり 4 個が候補に出ます。 @@ -36,24 +35,6 @@ codex plugin marketplace add devbasex/ai-plugins codex plugin add playwright-kit@ai-plugins ``` -### Kiro CLI - -Kiro にはプラグイン機構がないため、installer が `.kiro/skills/` へ symlink を張ります。 - -```bash -# プロジェクトへ導入(既定) -bash plugins/playwright-kit/dev.kiro/install.sh - -# ホーム配下へ導入 -bash plugins/playwright-kit/dev.kiro/install.sh --scope global - -# 書き込みを行わず内容だけ確認 -bash plugins/playwright-kit/dev.kiro/install.sh --dry-run -``` - -NDF の installer と違い、エージェント定義・常時指示・プロンプト・フックは扱いません -(このプラグインは Skill だけを配ります)。 - ### 更新 ```bash @@ -64,9 +45,6 @@ NDF の installer と違い、エージェント定義・常時指示・プロ # Codex codex plugin marketplace upgrade ai-plugins codex plugin add playwright-kit@ai-plugins - -# Kiro CLI -bash plugins/playwright-kit/dev.kiro/install.sh ``` ## 更新情報 diff --git a/plugins/playwright-kit/dev.kiro/install.sh b/plugins/playwright-kit/dev.kiro/install.sh deleted file mode 100755 index 06d2dbd82..000000000 --- a/plugins/playwright-kit/dev.kiro/install.sh +++ /dev/null @@ -1,139 +0,0 @@ -#!/usr/bin/env bash -# playwright-kit Plugin Installer for Kiro CLI -# Usage: bash plugins/playwright-kit/dev.kiro/install.sh [--project PATH] [--scope workspace|global] [--dry-run] -# -# Kiro CLI にはプラグイン機構がないため、プラグインの skills/ を導入先の skills/ へ symlink する。 -# NDF の installer と違い、エージェント定義・常時指示・プロンプト・フックは扱わない -# (この plugin は Skill だけを配る)。 -# -# このスクリプトは Agent Plugins 仕様 §8.2 のクライアント拡張ディレクトリ(dev.kiro/)に -# 置く。プラグインルートは 1 つ上の階層になる。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_SKILLS_DIR="$PLUGIN_DIR/skills" -MANIFEST_FILE="$PLUGIN_DIR/plugin.json" - -PROJECT_ROOT="$(pwd)" -PROJECT_GIVEN=false -SCOPE="workspace" -DRY_RUN=false - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - PROJECT_GIVEN=true - shift - ;; - --scope) - [ "$#" -ge 2 ] || { echo "ERROR: --scope requires workspace or global" >&2; exit 2; } - SCOPE="$2" - shift - ;; - --dry-run) DRY_RUN=true ;; - --help|-h) - echo "Usage: bash plugins/playwright-kit/dev.kiro/install.sh [OPTIONS]" - echo "" - echo "Options:" - echo " --project PATH install into PATH instead of current directory (--scope workspace のみ)" - echo " --scope SCOPE workspace(既定, プロジェクトの .kiro/)または global(~/.kiro/)" - echo " --dry-run 書き込みを行わず実行内容を表示" - echo " -h, --help このヘルプを表示" - exit 0 - ;; - *) echo "ERROR: unknown option: $1" >&2; exit 2 ;; - esac - shift -done - -case "$SCOPE" in - workspace) - KIRO_DIR="$PROJECT_ROOT/.kiro" - ;; - global) - [ -n "${HOME:-}" ] || { echo "ERROR: --scope global には HOME が必要です" >&2; exit 2; } - KIRO_DIR="$HOME/.kiro" - if [ "$PROJECT_GIVEN" = true ]; then - echo "WARN: --scope global では --project は使用されません" >&2 - fi - ;; - *) - echo "ERROR: invalid --scope: $SCOPE (workspace|global)" >&2 - exit 2 - ;; -esac - -SKILLS_DIR="$KIRO_DIR/skills" - -echo "=== playwright-kit Plugin Installer for Kiro CLI ===" -echo " スコープ: $SCOPE ($KIRO_DIR)" - -if [ ! -d "$PLUGIN_SKILLS_DIR" ]; then - echo "ERROR: $PLUGIN_SKILLS_DIR が見つかりません" >&2 - exit 1 -fi -# 版数はルートマニフェストだけが持つ。Kiro 向けに複製した VERSION ファイルは置かない -# (同じ値を 2 箇所に置くと、片方だけが古くなる)。 -if [ ! -f "$MANIFEST_FILE" ]; then - echo "ERROR: $MANIFEST_FILE が見つかりません" >&2 - exit 1 -fi -PLUGIN_VERSION="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST_FILE")" -if [ -z "$PLUGIN_VERSION" ]; then - echo "ERROR: $MANIFEST_FILE から版数を読み取れません" >&2 - exit 1 -fi - -# 既存リンクの掃除は、このプラグイン配下を指すものだけに限る。 -# 利用者が置いた実体ディレクトリや他プラグインのリンクには触れない。 -if [ "$DRY_RUN" = false ]; then - mkdir -p "$SKILLS_DIR" - plugin_skills_abs="$(realpath -m "$PLUGIN_SKILLS_DIR")" - while IFS= read -r skill_link; do - target="$(readlink "$skill_link")" - case "$target" in - /*) target_abs="$target" ;; - *) target_abs="$(realpath -m "$(dirname "$skill_link")/$target")" ;; - esac - case "$target_abs" in - "$plugin_skills_abs"/*) rm -f "$skill_link" ;; - esac - done < <(find "$SKILLS_DIR" -mindepth 1 -maxdepth 1 -type l | sort) -fi - -SKILL_COUNT=0 -while IFS= read -r src_dir; do - skill_name="$(basename "$src_dir")" - if [ ! -f "$src_dir/SKILL.md" ]; then - echo " SKIP: $skill_name (SKILL.mdなし)" - continue - fi - if [ -e "$SKILLS_DIR/$skill_name" ] && [ ! -L "$SKILLS_DIR/$skill_name" ]; then - echo " WARN: $SKILLS_DIR/$skill_name はシンボリックリンクではありません。" >&2 - echo " 内容を確認のうえ手動で退避または削除してください。" >&2 - continue - fi - if [ "$DRY_RUN" = false ]; then - ln -sfn "$PLUGIN_SKILLS_DIR/$skill_name" "$SKILLS_DIR/$skill_name" - fi - echo " linked: $skill_name" - SKILL_COUNT=$((SKILL_COUNT + 1)) -done < <(find "$PLUGIN_SKILLS_DIR" -mindepth 1 -maxdepth 1 -type d | sort) - -if [ "$DRY_RUN" = true ]; then - echo "" - echo "DRY RUN: 書き込みは行いませんでした" - echo " playwright-kit バージョン: $PLUGIN_VERSION" - echo " Skills数: $SKILL_COUNT (配置先: $SKILLS_DIR)" - exit 0 -fi - -echo "" -echo "=== インストール完了 ===" -echo " playwright-kit バージョン: $PLUGIN_VERSION" -echo " Skills数: $SKILL_COUNT (シンボリックリンク: $SKILLS_DIR)" -echo "" -echo "playwright_kit の実行環境は playwright-kit-ops skill の手順で用意してください。" diff --git a/plugins/playwright-kit/manifests/kiro-skills.txt b/plugins/playwright-kit/manifests/kiro-skills.txt deleted file mode 100644 index c6e9e5661..000000000 --- a/plugins/playwright-kit/manifests/kiro-skills.txt +++ /dev/null @@ -1,4 +0,0 @@ -playwright-planning -playwright-authoring -playwright-evidence -playwright-kit-ops diff --git a/plugins/playwright-kit/plugin.json b/plugins/playwright-kit/plugin.json index 4f35ccda5..d2477ec93 100644 --- a/plugins/playwright-kit/plugin.json +++ b/plugins/playwright-kit/plugin.json @@ -1,8 +1,8 @@ { "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json", "name": "playwright-kit", - "version": "3.0.2", - "description": "Agent Plugins package (v3.0.2): 4 Playwright E2E skills — test planning by page role, script authoring with video / trace evidence, report generation with collected evidence, and the playwright_kit runtime (init, a11y / CWV scans).", + "version": "4.0.0", + "description": "Agent Plugins package (v4.0.0): 4 Playwright E2E skills — test planning by page role, script authoring with video / trace evidence, report generation with collected evidence, and the playwright_kit runtime (init, a11y / CWV scans).", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/scripts/build-runtime-plugins.sh b/scripts/build-runtime-plugins.sh index 3344d3f76..ed4498d69 100755 --- a/scripts/build-runtime-plugins.sh +++ b/scripts/build-runtime-plugins.sh @@ -9,8 +9,7 @@ usage() { Usage: bash scripts/build-runtime-plugins.sh [--check] Generate the tracked build outputs: Codex implicit-invocation policies for -single-directory plugins, the agy skill links and agent definitions under -dev.agy/, and the runtime copies of MCP plugins. +single-directory plugins. Options: --check Compare generated output with the working tree and fail on drift. @@ -38,287 +37,15 @@ sync_codex_skill_policies() { python3 "$ROOT_DIR/scripts/lib/codex_skill_policies.py" "$plugin_dir/skills" "$manifest" "$CHECK" } -# agy は配る Skill を絞る手段を利用者側の設定にしか持たない(`plugins.json` の絞り込みは -# プラグインのディレクトリ名に掛かり、同梱の Skill には届かない)。そこで絞り込みは -# dev.agy/skills/ へ何を並べるかで表す。並べる内容は manifests/agy-skills.txt が決める。 -# hook の実体は 4 ランタイムで共有するため、同じ位置へ symlink を張る。エージェント定義は -# agy の受け付ける `model` へ変えた写しを置く(sync_agy_agents)。 -# Agent Plugins 仕様 §8.2 のクライアント拡張ディレクトリに当たる。 -sync_agy_skill_links() { - local family="$1" - local plugin_dir="$ROOT_DIR/plugins/$family" - - [ -f "$plugin_dir/manifests/agy-skills.txt" ] || return 0 - python3 - "$plugin_dir" "$CHECK" <<'PY' -import os -import sys -from pathlib import Path - -plugin_dir, check = Path(sys.argv[1]), sys.argv[2] == "true" -dev_dir = plugin_dir / "dev.agy" -links_dir = dev_dir / "skills" - -published = [ - line.split("#", 1)[0].strip() - for line in (plugin_dir / "manifests/agy-skills.txt").read_text(encoding="utf-8").splitlines() - if line.split("#", 1)[0].strip() -] - -# 生成する symlink と、その向き先。skills/ は基準が決め、scripts は固定である。 -expected = {links_dir / name: f"../../skills/{name}" for name in published} -expected.update({dev_dir / "scripts": "../scripts"}) - -actual = set() -if links_dir.is_dir(): - actual |= set(links_dir.iterdir()) -if (dev_dir / "scripts").is_symlink(): - actual.add(dev_dir / "scripts") -stale = sorted(actual - set(expected)) - -failed = False - - -def report(message: str) -> None: - global failed - print(message, file=sys.stderr) - failed = True - - -for path, target in sorted(expected.items()): - if check: - if not path.is_symlink(): - report(f"Generated link missing: {path}") - elif os.readlink(path) != target: - report(f"Generated link points elsewhere: {path} -> {os.readlink(path)}") - continue - path.parent.mkdir(parents=True, exist_ok=True) - if path.is_symlink() or path.exists(): - path.unlink() - path.symlink_to(target) - -for path in stale: - if check: - report(f"Generated link is stale: {path}") - else: - path.unlink() - -raise SystemExit(1 if failed else 0) -PY -} - -# Kiro CLI にはプラグイン機構が無いため、MCP プラグインごとに installer を置く。内容は -# プラグイン名に依存しないので、雛形を 1 つ持って各プラグインの dev.kiro/ へ書き出す。 -# Agent Plugins 仕様 §8.2 のクライアント拡張ディレクトリに当たる。 -write_kiro_mcp_installer() { - local plugin_dir="$1" - local installer="$plugin_dir/dev.kiro/install.sh" - local tmp - - tmp="$(mktemp)" - cat > "$tmp" <<'EOF' -#!/usr/bin/env bash -# MCP Plugin Installer for Kiro CLI -# Usage: bash plugins/mcp/<プラグイン名>/dev.kiro/install.sh [--project PATH] [--dry-run] -# -# scripts/build-runtime-plugins.sh が生成します。直接編集しないでください。 -set -euo pipefail - -SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" -PLUGIN_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" -PLUGIN_NAME="$(basename "$PLUGIN_DIR")" -PROJECT_ROOT="$(pwd)" -KIRO_DIR="$PROJECT_ROOT/.kiro" -KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" -KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" -KIRO_SKILLS_DIR="$KIRO_DIR/skills" -TARGET_MCP="$PROJECT_ROOT/.mcp.json" -SOURCE_MCP="$PLUGIN_DIR/.mcp.json" -SOURCE_HOOKS="$PLUGIN_DIR/hooks/hooks.json" -SOURCE_SKILLS="$PLUGIN_DIR/skills" -DRY_RUN=false - -usage() { - cat <<'USAGE' -Usage: bash install.sh [--project PATH] [--dry-run] - -Install this MCP plugin into the current project's Kiro settings. -USAGE -} - -while [ "$#" -gt 0 ]; do - case "$1" in - --project) - [ "$#" -ge 2 ] || { echo "ERROR: --project requires a path" >&2; exit 2; } - PROJECT_ROOT="$(cd "$2" && pwd)" - KIRO_DIR="$PROJECT_ROOT/.kiro" - KIRO_AGENT_FILE="$KIRO_DIR/agents/default.json" - KIRO_PLUGIN_LINK="$KIRO_DIR/mcp_runtime/$PLUGIN_NAME" - KIRO_SKILLS_DIR="$KIRO_DIR/skills" - TARGET_MCP="$PROJECT_ROOT/.mcp.json" - shift - ;; - --dry-run) DRY_RUN=true ;; - -h|--help) usage; exit 0 ;; - *) echo "Unknown option: $1" >&2; usage >&2; exit 2 ;; - esac - shift -done - -if [ ! -f "$SOURCE_MCP" ]; then - echo "ERROR: source .mcp.json not found: $SOURCE_MCP" >&2 - exit 1 -fi - -if [ "$DRY_RUN" = true ]; then - echo "Would merge $SOURCE_MCP into $TARGET_MCP" - echo "Would link plugin runtime into $KIRO_PLUGIN_LINK" - if [ -d "$SOURCE_SKILLS" ]; then - echo "Would link skills from $SOURCE_SKILLS into $KIRO_SKILLS_DIR" - fi - if [ -f "$SOURCE_HOOKS" ]; then - echo "Would merge hooks from $SOURCE_HOOKS into $KIRO_AGENT_FILE" - fi - exit 0 -fi - -mkdir -p "$KIRO_DIR/mcp_runtime" "$KIRO_SKILLS_DIR" "$(dirname "$KIRO_AGENT_FILE")" -if [ -e "$KIRO_PLUGIN_LINK" ] && [ ! -L "$KIRO_PLUGIN_LINK" ]; then - echo "ERROR: $KIRO_PLUGIN_LINK already exists and is not a symlink" >&2 - exit 1 -fi -ln -sfn "$PLUGIN_DIR" "$KIRO_PLUGIN_LINK" - -if [ -d "$SOURCE_SKILLS" ]; then - find "$SOURCE_SKILLS" -mindepth 1 -maxdepth 1 -type d | sort | while IFS= read -r skill_dir; do - skill_name="$(basename "$skill_dir")" - skill_link="$KIRO_SKILLS_DIR/$skill_name" - [ -f "$skill_dir/SKILL.md" ] || continue - if [ -e "$skill_link" ] && [ ! -L "$skill_link" ]; then - echo "ERROR: $skill_link already exists and is not a symlink" >&2 - exit 1 - fi - ln -sfn "$skill_dir" "$skill_link" - echo "Linked Kiro skill: $skill_name" - done -fi - -python3 - "$SOURCE_MCP" "$TARGET_MCP" "$SOURCE_HOOKS" "$KIRO_AGENT_FILE" "$KIRO_PLUGIN_LINK" <<'PY' -import json -import sys -from pathlib import Path - -source_path = Path(sys.argv[1]) -target_path = Path(sys.argv[2]) -hooks_path = Path(sys.argv[3]) -agent_path = Path(sys.argv[4]) -plugin_link = sys.argv[5] -source = json.loads(source_path.read_text()) -target = json.loads(target_path.read_text()) if target_path.exists() else {} -source_servers = source.get("mcpServers", {}) -target.setdefault("mcpServers", {}) -target["mcpServers"].update(source_servers) -target_path.write_text(json.dumps(target, indent=2, ensure_ascii=False) + "\n") - -if agent_path.exists(): - agent = json.loads(agent_path.read_text()) -else: - agent = { - "name": "default", - "resources": ["skill://.kiro/skills/**/SKILL.md"], - } - -# Kiro は tools に挙がったツールだけをモデルへ見せる。欄が無いと MCP のツールを -# 呼べない(#1873)。利用者が書いた tools は上書きしない。 -agent.setdefault("tools", ["*"]) -agent.setdefault("mcpServers", {}) -agent["mcpServers"].update(source_servers) - -if hooks_path.exists(): - source_hooks = json.loads(hooks_path.read_text()).get("hooks", {}) - target_hooks = agent.setdefault("hooks", {}) - event_map = { - "SessionStart": "agentSpawn", - "Stop": "stop", - } - # Kiro にはプラグインルートを示す環境変数が無い。hook の command が使う - # プレースホルダを、installer が張った symlink の絶対パスへ置き換える。 - placeholder = "${PLUGIN_ROOT:-${CODEX_PLUGIN_ROOT:-${CLAUDE_PLUGIN_ROOT}}}" - for source_event, groups in source_hooks.items(): - target_event = event_map.get(source_event, source_event) - target_list = target_hooks.setdefault(target_event, []) - existing = { - entry.get("command") - for entry in target_list - if isinstance(entry, dict) - } - for group in groups: - for hook in group.get("hooks", []): - command = hook.get("command") - if not command: - continue - command = command.replace(placeholder, plugin_link) - if command in existing: - continue - entry = {"command": command} - if "timeout_ms" in hook: - entry["timeout_ms"] = hook["timeout_ms"] - target_list.append(entry) - existing.add(command) - -agent_path.write_text(json.dumps(agent, indent=2, ensure_ascii=False) + "\n") - -print(f"Installed MCP servers: {', '.join(sorted(source_servers))}") -print(f"Updated: {target_path}") -print(f"Updated: {agent_path}") -PY -EOF - - if [ "$CHECK" = true ]; then - if [ ! -f "$installer" ] || ! diff -q "$tmp" "$installer" >/dev/null; then - echo "Generated file is out of date: ${installer#$ROOT_DIR/}" >&2 - rm -f "$tmp" - return 1 - fi - rm -f "$tmp" - return 0 - fi - mkdir -p "$(dirname "$installer")" - mv "$tmp" "$installer" - chmod +x "$installer" -} - -# agy 1.4 系は `model` に tier 名しか受け付けないため、エージェント定義は symlink で共有せず、 -# `model` だけを tier 名へ変えた写しを dev.agy/agents/ へ置く(#2087)。 -sync_agy_agents() { - local family="$1" - local plugin_dir="$ROOT_DIR/plugins/$family" - - [ -d "$plugin_dir/dev.agy" ] || return 0 - python3 "$ROOT_DIR/scripts/lib/agy_agents.py" "$plugin_dir" "$CHECK" -} - -sync_mcp_plugins() { - local plugin_dir - - for plugin_dir in "$ROOT_DIR"/plugins/mcp/*; do - [ -f "$plugin_dir/.mcp.json" ] || continue - write_kiro_mcp_installer "$plugin_dir" - done -} - for dir in "$ROOT_DIR"/plugins/*; do [ -d "$dir/manifests" ] || continue family="$(basename "$dir")" case "$family" in - *-shared|*-claude|*-codex|*-kiro) continue ;; + *-shared|*-claude|*-codex) continue ;; esac sync_codex_skill_policies "$family" - sync_agy_skill_links "$family" - sync_agy_agents "$family" done -sync_mcp_plugins - if [ "$CHECK" = true ]; then echo "runtime plugin generated files are up to date" else diff --git a/scripts/check-cross-skill-refs.py b/scripts/check-cross-skill-refs.py index 5ec706235..9f03bce8d 100755 --- a/scripts/check-cross-skill-refs.py +++ b/scripts/check-cross-skill-refs.py @@ -2,7 +2,7 @@ """Skill の境界をまたぐ**配布に依存する参照**を数え、例外の一覧に無いものを失敗として返す。 Skill は配布の基準(`plugins/ndf/manifests/*-skills.txt`)で 1 つずつ配るかを決める。 -**配る Skill を絞る配布先(agy)では、基準に無い Skill が配布した先から消える。** +**配る Skill を絞る配布先(今は Codex。Claude Code より 4 本少ない)では、基準に無い Skill が配布した先から消える。** 別の Skill の `scripts/` や `references/` を読み込む・起動する・`sys.path` へ入れる参照は、 その相手を配らない配布先で解決できない。**実行するか読むかは、相手が欠けたときの結果を 変えない。** diff --git a/scripts/check-doc-staleness.py b/scripts/check-doc-staleness.py index 3ee0729f3..e9036a8fd 100644 --- a/scripts/check-doc-staleness.py +++ b/scripts/check-doc-staleness.py @@ -59,17 +59,10 @@ def plugin_json_path(name: str) -> str: return f"plugins/{name}/.claude-plugin/plugin.json" -# `README.md` はランタイムを Claude Code / Kiro / Codex / agy の順、`plugins/ndf/README.md` は -# Claude Code / Codex / Kiro CLI / agy の順で書いている。位置ではなく名前で対応づける。表記も -# 2 本で違う(`Kiro` と `Kiro CLI`)ため、対応表を文書ごとに持つ。**agy はどちらも同じ表記で -# ある。** コマンド名がそのまま配布先の名前であり、製品名で言い換えると読み手が結び付けられない。 -ROOT_README_RUNTIMES = {"Claude Code": "claude", "Kiro": "kiro", "Codex": "codex", "agy": "agy"} -PLUGIN_README_RUNTIMES = { - "Claude Code": "claude", - "Codex": "codex", - "Kiro CLI": "kiro", - "agy": "agy", -} +# 配布先は Claude Code と Codex の 2 つで、`README.md`・`plugins/ndf/README.md` のどちらも +# この順で書いている。位置ではなく名前で対応づけ、表記が分かれたときに備えて対応表を文書ごとに持つ。 +ROOT_README_RUNTIMES = {"Claude Code": "claude", "Codex": "codex"} +PLUGIN_README_RUNTIMES = {"Claude Code": "claude", "Codex": "codex"} def runtime_alternation(labels: dict[str, str]) -> str: @@ -232,7 +225,7 @@ class RepositoryMetrics: source: str -# 点で照合する版数記載(G・K・L・M)の一覧。点の照合を足すときはここへ 1 行足す。 +# 点で照合する版数記載(G・L・M)の一覧。点の照合を足すときはここへ 1 行足す。 # 同じ文書の中では並びの順に報告する。 POINT_VERSION_SPECS: list[PointVersionSpec] = [ PointVersionSpec( @@ -241,12 +234,6 @@ class RepositoryMetrics: "**NDFプラグイン v<版>**", re.compile(r"\*\*NDFプラグイン v" + VERSION + r"\*\*"), ), # G - PointVersionSpec( - PLUGIN_README, - "Kiro の確認例の版数", - "(Kiro CLI用 / v<版>)", - re.compile(r"Kiro CLI用 / v" + VERSION + r")"), - ), # K PointVersionSpec( PLUGIN_README, "Codex のキャッシュパスの例の版数", @@ -782,7 +769,7 @@ def main() -> int: root = Path(args.root).resolve() report = Report() - counts = {runtime: manifest_skill_count(root, runtime, report) for runtime in ("claude", "codex", "kiro", "agy")} + counts = {runtime: manifest_skill_count(root, runtime, report) for runtime in ("claude", "codex")} skills = skill_dir_count(root, SKILLS_DIR, report) version = plugin_version(root, report) diff --git a/scripts/check-skill-frontmatter.py b/scripts/check-skill-frontmatter.py index d8aba6cd5..9f1dee701 100644 --- a/scripts/check-skill-frontmatter.py +++ b/scripts/check-skill-frontmatter.py @@ -7,7 +7,7 @@ チェックは 3 種類に分かれる。 - **individual** — Skill 単位。仕様準拠・安全性・可搬性・運用 -- **aggregate** — 配布先ごとの初期一覧予算(Claude Code / Codex / Kiro CLI / agy)と +- **aggregate** — 配布先ごとの初期一覧予算(Claude Code / Codex)と frontmatter 総量。 予算はチェック対象の plugin family すべての合計に対して判定する - **cross** — Skill 間。トリガ語の重複と、既知の外部 Skill 名との衝突 @@ -95,36 +95,15 @@ # Codex(公式ドキュメント "Build skills"): # "at most 2% of the model's context window, or 8,000 characters when the # context window is unknown" -# 既定モデル gpt-5.6-sol のコンテキストは 272,000(kiro-cli --list-models で実測)。 +# 既定モデル gpt-5.6-sol のコンテキストは 272,000(kiro-cli --list-models で実測した過去の記録)。 # 2% = 5,440 トークン。コンテキスト長が判明しているため 8,000 のフォールバックは使わない。 # 比率は 2 倍でもコンテキストが 1/3.7 のため、**予算は Claude Code の約半分**にしかならず、 # 実質ここが全体の制約になる。 -# -# Kiro: 公式ドキュメント(kiro.dev/docs/skills)に一覧予算の規定が無い。 -# 既定モデル auto のコンテキストは 1,000,000。**この値は過去の実測の記録である。** -# 当時は `kiro-cli --list-models` で一覧を取れたが、2.20.1 にそのオプションは無く、 -# 同じ手段では再検証できない(`--list` は認証を要求する)。値を更新するときは、 -# その時点で一覧を取れる手段を確かめ、この行の出典も書き直すこと。Claude Code の -# Opus 5 と同じである。**NDF は Kiro でも 1M コンテキストのモデルだけを対象とする** -# (`plugins/ndf/dev.kiro/install.sh` が導入時にこの前提を出力する。モデルの一覧を -# 取る手段が kiro-cli の版によって変わるため、機械でのチェックは置いていない)。 -# 規定が無い以上どこかから基準を借りるほかなく、コンテキスト長が同じ Claude Code の -# 1% を当てる。憶測で独自の値を置くより、同じ土俵の実在する規定へ揃えるほうが根拠が残る。 -# -# agy(Antigravity CLI): 公式ドキュメント(antigravity.google/docs/skills)に一覧予算の -# 規定が無い。既定のモデルは Gemini 3.x Flash 系(`agy models` の先頭が -# `gemini-3.8-flash-high`。1.1.25 で実測)で、コンテキスト長を出す手段は見当たらない -# (`agy models` は名前と表示名だけを返し、`/context` にあたるコマンドも無い)。 -# **この 2 つの値は Claude Code から借りたものである。** Gemini 3.x Flash の -# コンテキスト長 1,000,000 は Claude Code の Opus 5 と同じであり、規定が無い以上 -# どこかから基準を借りるほかない。憶測で独自の値を置くより、同じコンテキスト長を持つ -# 実在する規定へ揃えるほうが根拠が残る(Kiro CLI と同じ扱い)。 # 一覧に何が載るかはランタイムごとに違う。**公式に記述があるのは Claude Code と Codex # だけ**である。 # Claude Code: "loads a listing of skill names and descriptions into context" # Codex: "In Codex, the initial list also includes each skill's file path." -# Kiro CLI と agy は一覧の構成も公式に記述が無いため、多い側(パスを含む)で見積もる。 class Listing(NamedTuple): @@ -136,12 +115,9 @@ class Listing(NamedTuple): includes_path: bool # 一覧に Skill のファイルパスが載るか -# Kiro と agy の割合は Claude Code から借りる(上の Kiro・agy のコメント) LISTINGS = { "claude": Listing(1_000_000, 0.01, "error", False), "codex": Listing(272_000, 0.02, "error", True), - "kiro": Listing(1_000_000, 0.01, "error", True), - "agy": Listing(1_000_000, 0.01, "error", True), } # 全 Skill の frontmatter 合計。**plugin family をまたいで合計する**(利用者の環境では @@ -550,7 +526,7 @@ def load_manifests(skills_dir: pathlib.Path) -> dict[str, set[str]]: """ man_dir = skills_dir.parent / "manifests" out: dict[str, set[str]] = {} - for runtime in ("claude", "codex", "kiro", "agy"): + for runtime in ("claude", "codex"): f = man_dir / f"{runtime}-skills.txt" if not f.exists(): continue @@ -788,7 +764,7 @@ def _discover_skills_dirs() -> list[pathlib.Path]: for d in sorted(pathlib.Path("plugins").glob("*")): if not (d / "manifests").is_dir() or not (d / "skills").is_dir(): continue - if d.name.endswith(("-claude", "-codex", "-kiro")): + if d.name.endswith(("-claude", "-codex")): continue found.append(d / "skills") # どの manifest にも載せない Skill も規約のチェックは受ける。配らないだけで、 diff --git a/scripts/check-skill-repo-assumptions.py b/scripts/check-skill-repo-assumptions.py index 2f3887337..649c553f6 100755 --- a/scripts/check-skill-repo-assumptions.py +++ b/scripts/check-skill-repo-assumptions.py @@ -111,7 +111,7 @@ "plugins/ndf/scripts/project_lib/model.py": "NDF 自身の宣言の形(project.schema.json)の URL。対象リポジトリを指していない", } -RUNTIMES = ("claude", "codex", "kiro", "agy") +RUNTIMES = ("claude", "codex") def canon(path: pathlib.Path | str) -> str: diff --git a/scripts/ci-heavy-skip.py b/scripts/ci-heavy-skip.py index f98e5af81..7c3a61e3b 100644 --- a/scripts/ci-heavy-skip.py +++ b/scripts/ci-heavy-skip.py @@ -52,7 +52,7 @@ ) MANIFEST_RE = re.compile( r"^(\.claude-plugin/marketplace\.json" - r"|plugins/.+/(\.claude-plugin/|\.codex-plugin/|dev\.agy/)?plugin\.json)$" + r"|plugins/.+/(\.claude-plugin/|\.codex-plugin/)?plugin\.json)$" ) MANIFEST_LINE_RE = re.compile(r'^\s*"(version|description)"\s*:') ANCESTOR_LIMIT = 20 diff --git a/scripts/lib/agy_agents.py b/scripts/lib/agy_agents.py deleted file mode 100644 index ce1e36be0..000000000 --- a/scripts/lib/agy_agents.py +++ /dev/null @@ -1,167 +0,0 @@ -#!/usr/bin/env python3 -"""agy に配るエージェント定義(`dev.agy/agents/*.md`)を生成・確認する(`scripts/build-runtime-plugins.sh` の一部)。 - -agy 1.4 系はエージェント定義の `model` に tier 名(`flash` / `pro` / `inherit`)しか受け付けず、 -Claude Code の型の名前(`sonnet` / `opus`)を持つ定義を読み飛ばす(#2087)。そこで `agents/*.md` を写し、 -frontmatter の `model:` の 1 行だけを対応表で tier 名へ置き換える。YAML として読み直さないのは、 -写しと元の差を `model:` の 1 行に限ってバイト列で確かめるためである(設計の決定 3)。 - - python3 scripts/lib/agy_agents.py - -2 つ目が `true` なら書かずに確かめ、食い違いを標準エラーに出して終了コード 1 で終わる。 -""" - -from __future__ import annotations - -import sys -from pathlib import Path - -# agy 1.4 系が `model` に受け付ける tier 名。欄が無いときは agy の既定(`inherit`)になる。 -AGY_TIERS = frozenset({"flash", "pro", "inherit"}) - -# Claude Code の型の名前 → agy の tier 名。安い型は安い tier へ、高い型は高い tier へ写す(設計の決定 2)。 -# ここに無い値は生成を止める(設計の決定 4)。足すときは 1 行足す。 -MODEL_MAP = { - "sonnet": "flash", - "opus": "pro", - "haiku": "flash", - "inherit": "inherit", -} - - -class UnknownModel(Exception): - """対応表に無い `model` の値。""" - - -def model_value(line: str) -> str: - """`model: <値>` の行から値を取り出す(引用符は外す)。""" - value = line.split(":", 1)[1].strip() - if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"": - value = value[1:-1] - return value - - -def _model_line_index(lines: list[str]) -> int | None: - """frontmatter の `model:` の行の添字。frontmatter か欄が無ければ None(行末の改行の有無は問わない)。""" - if not lines or lines[0].strip() != "---": - return None - for index, line in enumerate(lines[1:], start=1): - if line.strip() == "---": - return None - if line.startswith("model:"): - return index - return None - - -def frontmatter_model(text: str) -> str | None: - """frontmatter の `model` の値。欄が無ければ None。""" - lines = text.splitlines() - index = _model_line_index(lines) - return None if index is None else model_value(lines[index]) - - -def agy_copy(text: str, model_map: dict[str, str]) -> str: - """frontmatter の `model:` の 1 行だけを tier 名へ置き換えた写し。欄が無ければそのまま返す。""" - lines = text.splitlines(keepends=True) - index = _model_line_index(lines) - if index is None: - return text - line = lines[index] - value = model_value(line) - if value not in model_map: - raise UnknownModel(value) - ending = line[len(line.rstrip("\r\n")) :] - lines[index] = f"model: {model_map[value]}{ending}" - return "".join(lines) - - -def map_errors(model_map: dict[str, str]) -> list[str]: - """対応表の値のうち tier 名でないもの。""" - return [f"Model map value is not an agy tier: {key}: {value}" for key, value in sorted(model_map.items()) if value not in AGY_TIERS] - - -def sync(plugin_dir: Path, check: bool, model_map: dict[str, str] | None = None) -> bool: - """生成物を書く(`check` なら確かめる)。食い違いがあれば偽。""" - model_map = MODEL_MAP if model_map is None else model_map - dev_dir = plugin_dir / "dev.agy" - source_dir = plugin_dir / "agents" - if not dev_dir.is_dir(): - return True - root = plugin_dir.resolve().parents[1] - - def rel(path: Path) -> str: - try: - return str(path.relative_to(root)) - except ValueError: - return str(path) - - errors = map_errors(model_map) - for message in errors: - print(message, file=sys.stderr) - if errors: - return False - - out_dir = dev_dir / "agents" - expected = _expected_copies(source_dir, out_dir, model_map, rel) - if expected is None: - return False - if check: - return _check_copies(out_dir, expected, rel) - _write_copies(out_dir, expected) - return True - - -def _expected_copies(source_dir: Path, out_dir: Path, model_map: dict[str, str], rel) -> dict[Path, str] | None: - """写しの置き場 → 中身。対応表に無い `model` があれば標準エラーに出して None。""" - sources = sorted(source_dir.glob("*.md")) if source_dir.is_dir() else [] - expected: dict[Path, str] = {} - ok = True - for source in sources: - text = source.read_text(encoding="utf-8") - try: - expected[out_dir / source.name] = agy_copy(text, model_map) - except UnknownModel as exc: - print(f"Unknown model for agy: {rel(source)}: {exc}", file=sys.stderr) - ok = False - return expected if ok else None - - -def _check_copies(out_dir: Path, expected: dict[Path, str], rel) -> bool: - """書かずに確かめる。食い違いを標準エラーに出し、あれば偽。""" - if out_dir.is_symlink(): - print(f"Generated agents must be files, not a symlink: {rel(out_dir)}", file=sys.stderr) - return False - ok = True - actual = {p for p in out_dir.iterdir()} if out_dir.is_dir() else set() - for path, content in sorted(expected.items()): - if not path.is_file(): - print(f"Generated agent missing: {rel(path)}", file=sys.stderr) - ok = False - elif path.read_text(encoding="utf-8") != content: - print(f"Generated agent is out of date: {rel(path)}", file=sys.stderr) - ok = False - for path in sorted(actual - set(expected)): - print(f"Generated agent is stale: {rel(path)}", file=sys.stderr) - ok = False - return ok - - -def _write_copies(out_dir: Path, expected: dict[Path, str]) -> None: - """写しを書き、元に無い写しを消す。""" - # 古い symlink(`../agents`)を外してから書く。外さずに書くと元の定義を書き換える。 - if out_dir.is_symlink(): - out_dir.unlink() - out_dir.mkdir(parents=True, exist_ok=True) - for path, content in expected.items(): - path.write_text(content, encoding="utf-8") - for path in sorted(set(out_dir.iterdir()) - set(expected)): - path.unlink() - - -def main(argv: list[str]) -> int: - plugin_dir, check = Path(argv[0]), argv[1] == "true" - return 0 if sync(plugin_dir, check) else 1 - - -if __name__ == "__main__": - raise SystemExit(main(sys.argv[1:])) diff --git a/scripts/lib/validate_manifests.py b/scripts/lib/validate_manifests.py index 9df978c46..8e864ce52 100644 --- a/scripts/lib/validate_manifests.py +++ b/scripts/lib/validate_manifests.py @@ -51,59 +51,11 @@ def shaped(model: type[_Open], data: Any) -> _Open | None: return None -root = Path(sys.argv[1]) -# 検出済みの plugin family は呼び出し側から受け取る(検出を 2 箇所に持つと、 -# 一方だけが新しい family を拾ってチェック範囲が食い違う)。 -families = sys.argv[2:] - - -def plugin_dir_of(family: str) -> Path: - """プラグインの配布ディレクトリ。3 ランタイムとも同じ場所を読む。""" - return root / f"plugins/{family}" - - def read_json(path: Path): with path.open(encoding="utf-8") as fh: return json.load(fh) -errors: list[str] = [] - -claude_marketplace = read_json(root / ".claude-plugin/marketplace.json") -for plugin in claude_marketplace.get("plugins", []): - entry = shaped(Sourced, plugin) - if entry is None: - errors.append(f".claude-plugin marketplace plugin {plugin.get('name')} has invalid source") - continue - source = entry.source - plugin_dir = (root / source).resolve() - if not plugin_dir.is_dir(): - errors.append(f"Claude marketplace source missing: {source}") - continue - if not (plugin_dir / ".claude-plugin/plugin.json").is_file(): - errors.append(f"Claude plugin manifest missing under {source}") - -# Codex は専用のマーケットプレイス定義が無ければ .claude-plugin/marketplace.json へ -# フォールバックする。定義は 1 つに統合したので、同じエントリを Codex 側の要件でもチェックする。 -# Codex は plugin.json(Agent Plugins 形式)> .codex-plugin > .claude-plugin の順で採るため、 -# ルートマニフェストか Codex 用マニフェストのどちらかがあれば良い。 -for plugin in claude_marketplace.get("plugins", []): - entry = shaped(Sourced, plugin) - if entry is None: - continue - source = entry.source - plugin_dir = (root / source).resolve() - if not plugin_dir.is_dir(): - continue - if not ((plugin_dir / ".codex-plugin/plugin.json").is_file() or (plugin_dir / "plugin.json").is_file()): - errors.append(f"Codex plugin manifest missing under {source}(.codex-plugin/plugin.json かルートの plugin.json のどちらかが要る)") - # Codex は policy / category / interface を要求する。統合した定義から欠けると - # Codex 側の一覧に出ない。 - for key in ("policy", "category", "interface"): - if key not in plugin: - errors.append(f"marketplace entry {plugin.get('name')} に {key} がない(Codex が要求する)") - - # 版数と Skill 数は plugin.json と marketplace の description に重複して書かれている。 # `.claude-plugin/marketplace.json` と Codex 版 plugin.json は build-runtime-plugins.sh の # 生成対象ではなく、古い値が残っても JSON としては妥当なため他のチェックに掛からない。 @@ -121,271 +73,343 @@ def _version_in_description(lib: Path): return module.VERSION_IN_DESCRIPTION -try: - VERSION_IN_DESCRIPTION = _version_in_description(root / "scripts" / "lib") -except (ImportError, AttributeError, OSError) as exc: - raise SystemExit(f"版数の書式を読み込めない(scripts/lib/version_pattern.py): {exc}") # `<数> ... skills` の形で書く規約。版数(8.0.0)や製品名(E2E)の数字を拾わないよう前後が # 英数字・ドットでない整数だけを見て、さらに `skills` との間に挟める語を 3 語までに絞る。 # こうしないと離れた位置にある無関係な数(`8 specialized agents` など)を Skill 数と誤認する。 DESCRIBED_SKILL_COUNT = re.compile(r"(? None: - described_entry = shaped(Described, manifest) - if described_entry is None: - return - description = described_entry.description - found = VERSION_IN_DESCRIPTION.search(description) - if not found: - errors.append(f"{label} の description に `(vX.Y.Z)` 形式の版数がない") - elif found.group(1) != version: - errors.append(f"{label} の description の版数が古い(description: v{found.group(1)} / Claude 版 plugin.json: v{version})") - if expected is None: - return - # 抽出できないこと自体をエラーにする。素通りさせると、Skill 数の記述を消すか書式を変える - # だけでこのチェックを無効化できてしまう。 - described = described_skill_count(description) - if described is None: - errors.append(f"{label} の description から Skill 数を読み取れない(`<数> ... skills` の形で書く。{source}: {expected})") - elif described != expected: - errors.append(f"{label} の description の Skill 数が食い違う(description: {described} / {source}: {expected})") - - -for family in families: - claude_dir = plugin_dir_of(family) - claude_plugin_path = claude_dir / ".claude-plugin/plugin.json" - if not claude_plugin_path.is_file(): - continue - claude_plugin = read_json(claude_plugin_path) - versioned = shaped(Versioned, claude_plugin) - if versioned is None: - errors.append(f"{family} の claude plugin.json に version がない") - continue - version = versioned.version - check_description( - str(claude_plugin_path.relative_to(root)), - claude_plugin, - version, - manifest_skill_count(family, "claude"), - "claude-skills.txt", - ) - codex_plugin_path = plugin_dir_of(family) / ".codex-plugin/plugin.json" - if codex_plugin_path.is_file(): - codex_plugin = read_json(codex_plugin_path) - if codex_plugin.get("version") != version: - errors.append( - f"{codex_plugin_path.relative_to(root)} の version が claude 版と" - f"食い違う(codex: {codex_plugin.get('version')} / claude: {version})" - ) - check_description( - str(codex_plugin_path.relative_to(root)), - codex_plugin, - version, - manifest_skill_count(family, "codex"), - "codex-skills.txt", - ) - # ルートマニフェスト(Agent Plugins 形式)は絞り込みを持たず `skills/` を全件公開する。 - # description の Skill 数は manifest ではなく実体の数と突き合わせる。 - # agy 向けの定義はクライアント拡張ディレクトリ(Agent Plugins 1.0.0 §8.2)へ置く。 - # ルート直下へ置くと Codex がそちらを優先して読み、配布 Skill が codex-skills.txt では - # なく skills/ の実体になる。agy には取得元の登録が無く `agy plugin list` も版数を出さない - # ため、利用者が版を判断できる手がかりは clone した中身の版数だけである。 - agy_manifest_path = plugin_dir_of(family) / "dev.agy/plugin.json" - if agy_manifest_path.is_file(): - agy_manifest = read_json(agy_manifest_path) - if agy_manifest.get("version") != version: - errors.append( - f"{agy_manifest_path.relative_to(root)} の version が claude 版と" - f"食い違う(agy: {agy_manifest.get('version')} / claude: {version})" - ) - check_description( - str(agy_manifest_path.relative_to(root)), - agy_manifest, - version, - manifest_skill_count(family, "agy"), - "agy-skills.txt", - ) - root_manifest_path = plugin_dir_of(family) / "plugin.json" - if root_manifest_path.is_file(): - root_manifest = read_json(root_manifest_path) - if root_manifest.get("version") != version: - errors.append( - f"{root_manifest_path.relative_to(root)} の version が claude 版と" - f"食い違う(root: {root_manifest.get('version')} / claude: {version})" - ) - check_description( - str(root_manifest_path.relative_to(root)), - root_manifest, - version, - published_skill_count(family), - "skills/ の実体", - ) - expected_source = "./" + claude_dir.relative_to(root).as_posix() - for plugin in claude_marketplace.get("plugins", []): - if plugin.get("source") != expected_source: - continue - check_description( - f".claude-plugin/marketplace.json の {plugin.get('name')}", - plugin, - version, - manifest_skill_count(family, "claude"), - "claude-skills.txt", - ) - -for family in families: - source = plugin_dir_of(family) - for manifest in sorted((source / "manifests").glob("*-skills.txt")): - runtime = manifest.name.removesuffix("-skills.txt") - runtime_skills = plugin_dir_of(family) / "skills" - if not runtime_skills.is_dir(): - errors.append(f"runtime skills directory missing: {runtime_skills.relative_to(root)}") - continue - for raw in manifest.read_text(encoding="utf-8").splitlines(): - skill = raw.split("#", 1)[0].strip() - if not skill: +class VersionPatternUnavailable(Exception): + """版数の書式(`scripts/lib/version_pattern.py`)を読み込めない。""" + + +class ManifestChecker: + """1 つの根の下の定義ファイルを突き合わせ、見つけた食い違いを `errors` へためる。 + + 検出済みの plugin family は呼び出し側から受け取る(検出を 2 箇所に持つと、 + 一方だけが新しい family を拾ってチェック範囲が食い違う)。 + """ + + def __init__(self, root: Path, families: list[str]) -> None: + self.root = root + self.families = families + self.errors: list[str] = [] + self.claude_marketplace: dict = {} + self.version_in_description: re.Pattern[str] | None = None + + def plugin_dir_of(self, family: str) -> Path: + """プラグインの配布ディレクトリ。3 ランタイムとも同じ場所を読む。""" + return self.root / f"plugins/{family}" + + def run(self) -> list[str]: + """すべての検査を決まった順序で走らせ、食い違いの一覧を返す。""" + self.claude_marketplace = read_json(self.root / ".claude-plugin/marketplace.json") + self.check_marketplace() + self.load_version_pattern() + self.check_versions() + self.check_manifest_skills() + self.check_root_manifest_skills() + self.check_unlisted_skills() + self.check_skill_arrays() + self.check_mcp() + return self.errors + + def check_marketplace(self) -> None: + for plugin in self.claude_marketplace.get("plugins", []): + entry = shaped(Sourced, plugin) + if entry is None: + self.errors.append(f".claude-plugin marketplace plugin {plugin.get('name')} has invalid source") continue - if "/" in skill or ".." in skill: - errors.append(f"invalid skill name in {manifest.relative_to(root)}: {skill}") + source = entry.source + plugin_dir = (self.root / source).resolve() + if not plugin_dir.is_dir(): + self.errors.append(f"Claude marketplace source missing: {source}") continue - if not (source / "skills" / skill / "SKILL.md").is_file(): - errors.append(f"{family} source skill missing: {skill}") - if not (runtime_skills / skill / "SKILL.md").is_file(): - errors.append(f"{family} {runtime} runtime skill missing: {skill}") - -# ルートマニフェストを置く family は `skills/` を全件公開する。Codex はこのマニフェストを -# 優先して読むため、codex-skills.txt に載らない Skill を `skills/` へ置くと配布先が増える。 -# 実体と codex 用 manifest が一致していることを確かめる。 -for family in families: - if not (plugin_dir_of(family) / "plugin.json").is_file(): - continue - source = plugin_dir_of(family) - manifest = source / "manifests/codex-skills.txt" - if not manifest.is_file(): - continue - listed = {line.split("#", 1)[0].strip() for line in manifest.read_text(encoding="utf-8").splitlines() if line.split("#", 1)[0].strip()} - present = {d.name for d in (source / "skills").iterdir() if (d / "SKILL.md").is_file()} - for extra in sorted(present - listed): - errors.append( - f"{family} の skills/ にあるが codex-skills.txt に無い: {extra}" - "(ルートマニフェストは skills/ を全件公開するため Codex へ配られる)" - ) - -# 単一ディレクトリ構成では skills/ に全 runtime 分の実体が並ぶ。どの manifest にも -# 載らない Skill をここへ置くと、ルートマニフェストや将来の絞り込み漏れで配られる。 -# skills/ に置く Skill は少なくとも 1 つの manifest へ載せる規約なので、その違反を -# 検出する(配らない置き場所は v10.5.0 で無くなった。#116)。 -for family in families: - source = plugin_dir_of(family) - listed: set[str] = set() - for manifest in sorted((source / "manifests").glob("*-skills.txt")): - listed |= { - line.split("#", 1)[0].strip() for line in manifest.read_text(encoding="utf-8").splitlines() if line.split("#", 1)[0].strip() - } - for skill_dir in sorted((source / "skills").iterdir()): - if not (skill_dir / "SKILL.md").is_file(): - continue - if skill_dir.name not in listed: - errors.append( - f"{family} の skills/ にあるがどの manifest にも載っていない: {skill_dir.name}" - "(skills/ に置く Skill は少なくとも 1 つの manifest へ載せる)" - ) - -# マニフェストの skills は配列で明示する。配列に載っていない Skill はランタイムから -# 読み込まれないため、manifest と一致していないと配布漏れになる。ディレクトリの中身を -# 見る上のチェックでは検出できないので、ここで突き合わせる。 -for family in families: - for runtime, manifest_key in (("claude", ".claude-plugin"), ("codex", ".codex-plugin")): - skills_manifest = plugin_dir_of(family) / f"manifests/{runtime}-skills.txt" - plugin_json = plugin_dir_of(family) / f"{manifest_key}/plugin.json" - if not (skills_manifest.is_file() and plugin_json.is_file()): - continue - expected = [line.split("#", 1)[0].strip() for line in skills_manifest.read_text(encoding="utf-8").splitlines()] - expected_set = {name for name in expected if name} - manifest_data = json.loads(plugin_json.read_text(encoding="utf-8")) - declared = manifest_data.get("skills") + if not (plugin_dir / ".claude-plugin/plugin.json").is_file(): + self.errors.append(f"Claude plugin manifest missing under {source}") + + # Codex は専用のマーケットプレイス定義が無ければ .claude-plugin/marketplace.json へ + # フォールバックする。定義は 1 つに統合したので、同じエントリを Codex 側の要件でもチェックする。 + # Codex は plugin.json(Agent Plugins 形式)> .codex-plugin > .claude-plugin の順で採るため、 + # ルートマニフェストか Codex 用マニフェストのどちらかがあれば良い。 + for plugin in self.claude_marketplace.get("plugins", []): + entry = shaped(Sourced, plugin) + if entry is None: + continue + source = entry.source + plugin_dir = (self.root / source).resolve() + if not plugin_dir.is_dir(): + continue + if not ((plugin_dir / ".codex-plugin/plugin.json").is_file() or (plugin_dir / "plugin.json").is_file()): + self.errors.append( + f"Codex plugin manifest missing under {source}(.codex-plugin/plugin.json かルートの plugin.json のどちらかが要る)" + ) + # Codex は policy / category / interface を要求する。統合した定義から欠けると + # Codex 側の一覧に出ない。 + for key in ("policy", "category", "interface"): + if key not in plugin: + self.errors.append(f"marketplace entry {plugin.get('name')} に {key} がない(Codex が要求する)") + + def load_version_pattern(self) -> None: + """読み込めないときは素通りさせずに止める(`VersionPatternUnavailable`)。""" try: - schema.load_shape(SkillPaths, manifest_data) - bad: set[str] = set() - except schema.ShapeError as exc: - bad = {where for where, _ in exc.problems} - if "skills" in bad: - # 配列以外(ディレクトリ指定・欠落)を許すと、この突き合わせが黙って skip され - # 配布漏れの再発を検出できなくなる。配列で明示する形式に固定する。 - errors.append( - f"{family} の {runtime} plugin.json の skills が配列ではない" - f"(実際: {type(declared).__name__})。manifest との突き合わせができない" + self.version_in_description = _version_in_description(self.root / "scripts" / "lib") + except (ImportError, AttributeError, OSError) as exc: + raise VersionPatternUnavailable(str(exc)) from exc + + def manifest_skill_count(self, family: str, runtime: str): + manifest = self.plugin_dir_of(family) / f"manifests/{runtime}-skills.txt" + if not manifest.is_file(): + return None + return sum(1 for line in manifest.read_text(encoding="utf-8").splitlines() if line.split("#", 1)[0].strip()) + + def published_skill_count(self, family: str): + """ルートマニフェストが公開する Skill 数。Agent Plugins 1.0.0 §6.1 が + `skills/` を固定位置と定めており、絞り込みを持たないため実体の数と一致する。""" + skills_dir = self.plugin_dir_of(family) / "skills" + if not skills_dir.is_dir(): + return None + return sum(1 for d in skills_dir.iterdir() if (d / "SKILL.md").is_file()) + + def check_description(self, label: str, manifest: dict, version: str, expected, source: str) -> None: + described_entry = shaped(Described, manifest) + if described_entry is None: + return + description = described_entry.description + found = self.version_in_description.search(description) + if not found: + self.errors.append(f"{label} の description に `(vX.Y.Z)` 形式の版数がない") + elif found.group(1) != version: + self.errors.append(f"{label} の description の版数が古い(description: v{found.group(1)} / Claude 版 plugin.json: v{version})") + if expected is None: + return + # 抽出できないこと自体をエラーにする。素通りさせると、Skill 数の記述を消すか書式を変える + # だけでこのチェックを無効化できてしまう。 + described = described_skill_count(description) + if described is None: + self.errors.append(f"{label} の description から Skill 数を読み取れない(`<数> ... skills` の形で書く。{source}: {expected})") + elif described != expected: + self.errors.append(f"{label} の description の Skill 数が食い違う(description: {described} / {source}: {expected})") + + def check_versions(self) -> None: + for family in self.families: + claude_dir = self.plugin_dir_of(family) + claude_plugin_path = claude_dir / ".claude-plugin/plugin.json" + if not claude_plugin_path.is_file(): + continue + claude_plugin = read_json(claude_plugin_path) + versioned = shaped(Versioned, claude_plugin) + if versioned is None: + self.errors.append(f"{family} の claude plugin.json に version がない") + continue + version = versioned.version + self.check_description( + str(claude_plugin_path.relative_to(self.root)), + claude_plugin, + version, + self.manifest_skill_count(family, "claude"), + "claude-skills.txt", ) - else: - # 比較はパス全体で行う。basename だけを見ると `./wrong/pr` のように - # 実在しない場所を指す項目を通してしまう(claude CLI が無い環境では - # 後段の `claude plugin validate` も skip されるため気づけない)。 - expected_entries = {f"./skills/{name}" for name in expected_set} - declared_entries = set() - for index, entry in enumerate(declared): - if f"skills[{index}]" in bad: - errors.append(f"{runtime} plugin.json の skills 配列に文字列以外の項目がある({type(entry).__name__})") + codex_plugin_path = self.plugin_dir_of(family) / ".codex-plugin/plugin.json" + if codex_plugin_path.is_file(): + codex_plugin = read_json(codex_plugin_path) + if codex_plugin.get("version") != version: + self.errors.append( + f"{codex_plugin_path.relative_to(self.root)} の version が claude 版と" + f"食い違う(codex: {codex_plugin.get('version')} / claude: {version})" + ) + self.check_description( + str(codex_plugin_path.relative_to(self.root)), + codex_plugin, + version, + self.manifest_skill_count(family, "codex"), + "codex-skills.txt", + ) + # ルートマニフェスト(Agent Plugins 形式)は絞り込みを持たず `skills/` を全件公開する。 + # description の Skill 数は manifest ではなく実体の数と突き合わせる。 + root_manifest_path = self.plugin_dir_of(family) / "plugin.json" + if root_manifest_path.is_file(): + root_manifest = read_json(root_manifest_path) + if root_manifest.get("version") != version: + self.errors.append( + f"{root_manifest_path.relative_to(self.root)} の version が claude 版と" + f"食い違う(root: {root_manifest.get('version')} / claude: {version})" + ) + self.check_description( + str(root_manifest_path.relative_to(self.root)), + root_manifest, + version, + self.published_skill_count(family), + "skills/ の実体", + ) + expected_source = "./" + claude_dir.relative_to(self.root).as_posix() + for plugin in self.claude_marketplace.get("plugins", []): + if plugin.get("source") != expected_source: continue - declared_entries.add(entry) - for missing in sorted(expected_entries - declared_entries): - errors.append(f"{family} の {runtime} plugin.json の skills 配列に載っていない: {missing}(manifest には登録済み)") - for extra in sorted(declared_entries - expected_entries): - errors.append( - f"{family} の {runtime} plugin.json の skills 配列に余分な項目: {extra}" - "(manifest に無い、またはパスが `./skills/` の形式でない)" + self.check_description( + f".claude-plugin/marketplace.json の {plugin.get('name')}", + plugin, + version, + self.manifest_skill_count(family, "claude"), + "claude-skills.txt", ) -# MCP プラグインも 1 ディレクトリにまとめた。runtime ごとの配布物は無く、 -# 3 runtime が同じ .mcp.json を読む(Codex だけ .codex.mcp.json を読ませる例外がある)。 -for mcp in sorted((root / "plugins/mcp").iterdir()): - if not mcp.is_dir(): - continue - if not (mcp / ".mcp.json").is_file(): - errors.append(f"MCP config missing: plugins/mcp/{mcp.name}/.mcp.json") - continue - if not (mcp / ".claude-plugin/plugin.json").is_file(): - errors.append(f"Claude plugin manifest missing: plugins/mcp/{mcp.name}/.claude-plugin/plugin.json") - if not (mcp / ".codex-plugin/plugin.json").is_file(): - errors.append(f"Codex plugin manifest missing: plugins/mcp/{mcp.name}/.codex-plugin/plugin.json") - if not (mcp / "dev.kiro/install.sh").is_file(): - errors.append(f"Kiro MCP installer missing: plugins/mcp/{mcp.name}/dev.kiro/install.sh") - # Codex は .mcp.json を manifest の mcpServers から読む。指定が無いと - # サーバが 1 つも登録されない。Codex だけ別の起動定義を読ませるときは - # `./.codex.mcp.json` を指す(mcp-serena の `--context codex`。#818)。 - codex_manifest = mcp / ".codex-plugin/plugin.json" - if codex_manifest.is_file(): - declared = read_json(codex_manifest).get("mcpServers") - allowed = ("./.mcp.json", "./.codex.mcp.json") - if declared not in allowed or not (mcp / declared).is_file(): - errors.append( - f"plugins/mcp/{mcp.name}/.codex-plugin/plugin.json の mcpServers が " - f"実在する `./.mcp.json` か `./.codex.mcp.json` でない(実際: {declared!r})" - ) - -if errors: - for error in errors: - print(f"ERROR: {error}", file=sys.stderr) - raise SystemExit(1) + def check_manifest_skills(self) -> None: + for family in self.families: + source = self.plugin_dir_of(family) + for manifest in sorted((source / "manifests").glob("*-skills.txt")): + runtime = manifest.name.removesuffix("-skills.txt") + runtime_skills = self.plugin_dir_of(family) / "skills" + if not runtime_skills.is_dir(): + self.errors.append(f"runtime skills directory missing: {runtime_skills.relative_to(self.root)}") + continue + for raw in manifest.read_text(encoding="utf-8").splitlines(): + skill = raw.split("#", 1)[0].strip() + if not skill: + continue + if "/" in skill or ".." in skill: + self.errors.append(f"invalid skill name in {manifest.relative_to(self.root)}: {skill}") + continue + if not (source / "skills" / skill / "SKILL.md").is_file(): + self.errors.append(f"{family} source skill missing: {skill}") + if not (runtime_skills / skill / "SKILL.md").is_file(): + self.errors.append(f"{family} {runtime} runtime skill missing: {skill}") + + def check_root_manifest_skills(self) -> None: + # ルートマニフェストを置く family は `skills/` を全件公開する。Codex はこのマニフェストを + # 優先して読むため、codex-skills.txt に載らない Skill を `skills/` へ置くと配布先が増える。 + # 実体と codex 用 manifest が一致していることを確かめる。 + for family in self.families: + if not (self.plugin_dir_of(family) / "plugin.json").is_file(): + continue + source = self.plugin_dir_of(family) + manifest = source / "manifests/codex-skills.txt" + if not manifest.is_file(): + continue + listed = { + line.split("#", 1)[0].strip() for line in manifest.read_text(encoding="utf-8").splitlines() if line.split("#", 1)[0].strip() + } + present = {d.name for d in (source / "skills").iterdir() if (d / "SKILL.md").is_file()} + for extra in sorted(present - listed): + self.errors.append( + f"{family} の skills/ にあるが codex-skills.txt に無い: {extra}" + "(ルートマニフェストは skills/ を全件公開するため Codex へ配られる)" + ) -print("runtime plugin manifests and generated paths are valid") + def check_unlisted_skills(self) -> None: + # 単一ディレクトリ構成では skills/ に全 runtime 分の実体が並ぶ。どの manifest にも + # 載らない Skill をここへ置くと、ルートマニフェストや将来の絞り込み漏れで配られる。 + # skills/ に置く Skill は少なくとも 1 つの manifest へ載せる規約なので、その違反を + # 検出する(配らない置き場所は v10.5.0 で無くなった。#116)。 + for family in self.families: + source = self.plugin_dir_of(family) + listed: set[str] = set() + for manifest in sorted((source / "manifests").glob("*-skills.txt")): + listed |= { + line.split("#", 1)[0].strip() + for line in manifest.read_text(encoding="utf-8").splitlines() + if line.split("#", 1)[0].strip() + } + for skill_dir in sorted((source / "skills").iterdir()): + if not (skill_dir / "SKILL.md").is_file(): + continue + if skill_dir.name not in listed: + self.errors.append( + f"{family} の skills/ にあるがどの manifest にも載っていない: {skill_dir.name}" + "(skills/ に置く Skill は少なくとも 1 つの manifest へ載せる)" + ) + + def check_skill_arrays(self) -> None: + # マニフェストの skills は配列で明示する。配列に載っていない Skill はランタイムから + # 読み込まれないため、manifest と一致していないと配布漏れになる。ディレクトリの中身を + # 見る上のチェックでは検出できないので、ここで突き合わせる。 + for family in self.families: + for runtime, manifest_key in (("claude", ".claude-plugin"), ("codex", ".codex-plugin")): + skills_manifest = self.plugin_dir_of(family) / f"manifests/{runtime}-skills.txt" + plugin_json = self.plugin_dir_of(family) / f"{manifest_key}/plugin.json" + if not (skills_manifest.is_file() and plugin_json.is_file()): + continue + expected = [line.split("#", 1)[0].strip() for line in skills_manifest.read_text(encoding="utf-8").splitlines()] + expected_set = {name for name in expected if name} + manifest_data = json.loads(plugin_json.read_text(encoding="utf-8")) + declared = manifest_data.get("skills") + try: + schema.load_shape(SkillPaths, manifest_data) + bad: set[str] = set() + except schema.ShapeError as exc: + bad = {where for where, _ in exc.problems} + if "skills" in bad: + # 配列以外(ディレクトリ指定・欠落)を許すと、この突き合わせが黙って skip され + # 配布漏れの再発を検出できなくなる。配列で明示する形式に固定する。 + self.errors.append( + f"{family} の {runtime} plugin.json の skills が配列ではない" + f"(実際: {type(declared).__name__})。manifest との突き合わせができない" + ) + else: + # 比較はパス全体で行う。basename だけを見ると `./wrong/pr` のように + # 実在しない場所を指す項目を通してしまう(claude CLI が無い環境では + # 後段の `claude plugin validate` も skip されるため気づけない)。 + expected_entries = {f"./skills/{name}" for name in expected_set} + declared_entries = set() + for index, entry in enumerate(declared): + if f"skills[{index}]" in bad: + self.errors.append(f"{runtime} plugin.json の skills 配列に文字列以外の項目がある({type(entry).__name__})") + continue + declared_entries.add(entry) + for missing in sorted(expected_entries - declared_entries): + self.errors.append( + f"{family} の {runtime} plugin.json の skills 配列に載っていない: {missing}(manifest には登録済み)" + ) + for extra in sorted(declared_entries - expected_entries): + self.errors.append( + f"{family} の {runtime} plugin.json の skills 配列に余分な項目: {extra}" + "(manifest に無い、またはパスが `./skills/` の形式でない)" + ) + + def check_mcp(self) -> None: + # MCP プラグインも 1 ディレクトリにまとめた。runtime ごとの配布物は無く、 + # 2 runtime が同じ .mcp.json を読む(Codex だけ .codex.mcp.json を読ませる例外がある)。 + for mcp in sorted((self.root / "plugins/mcp").iterdir()): + if not mcp.is_dir(): + continue + if not (mcp / ".mcp.json").is_file(): + self.errors.append(f"MCP config missing: plugins/mcp/{mcp.name}/.mcp.json") + continue + if not (mcp / ".claude-plugin/plugin.json").is_file(): + self.errors.append(f"Claude plugin manifest missing: plugins/mcp/{mcp.name}/.claude-plugin/plugin.json") + if not (mcp / ".codex-plugin/plugin.json").is_file(): + self.errors.append(f"Codex plugin manifest missing: plugins/mcp/{mcp.name}/.codex-plugin/plugin.json") + # Codex は .mcp.json を manifest の mcpServers から読む。指定が無いと + # サーバが 1 つも登録されない。Codex だけ別の起動定義を読ませるときは + # `./.codex.mcp.json` を指す(mcp-serena の `--context codex`。#818)。 + codex_manifest = mcp / ".codex-plugin/plugin.json" + if codex_manifest.is_file(): + declared = read_json(codex_manifest).get("mcpServers") + allowed = ("./.mcp.json", "./.codex.mcp.json") + if declared not in allowed or not (mcp / declared).is_file(): + self.errors.append( + f"plugins/mcp/{mcp.name}/.codex-plugin/plugin.json の mcpServers が " + f"実在する `./.mcp.json` か `./.codex.mcp.json` でない(実際: {declared!r})" + ) + + +def main(argv: list[str]) -> None: + checker = ManifestChecker(Path(argv[1]), argv[2:]) + try: + errors = checker.run() + except VersionPatternUnavailable as exc: + raise SystemExit(f"版数の書式を読み込めない(scripts/lib/version_pattern.py): {exc}") + if errors: + for error in errors: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) + print("runtime plugin manifests and generated paths are valid") + + +if __name__ == "__main__": + main(sys.argv) diff --git a/scripts/pytest_child_cleanup.py b/scripts/pytest_child_cleanup.py new file mode 100644 index 000000000..02e64dea0 --- /dev/null +++ b/scripts/pytest_child_cleanup.py @@ -0,0 +1,195 @@ +"""テストが起こした子孫のプロセスを、pytest が止められたとき・終わったときに残さない(#2291 の決定 4)。 + +根の `conftest.py` が `pytest_plugins` で読み込む。pytest の親と xdist の各 worker で、次の 3 つを行う。 + +1. 子の回収役(Linux の `PR_SET_CHILD_SUBREAPER`)になる。テストが自分の session で起こした子の親が先に + 終わっても、孫は init ではなく最も近い pytest のプロセスへ付け替わり、止める対象から漏れない +2. SIGCHLD を受けるたびに、自分が起こした覚えの無い直接の子のゾンビだけを回収する。付け替わった孫の + ゾンビが残ると、打ち切る側のグループの存否の判定が成功し続け、猶予を毎回待ち切る。 + 自分が起こした pid は `subprocess.Popen` と `os.fork` を包んで記録し、その終了コードは横取りしない。 + 起動から記録までの間(起動中の数が 1 以上の間)は回収を見送り、数が 0 に戻った時点でやり直す +3. SIGTERM を受けたとき・pytest が終わるとき(`pytest_unconfigure`)に、直接の子の木を止める。 + 止めるのは `lib/procs.py` の `stop_tree` で、自分の pid には向けない + +子の回収役にできない環境(Linux 以外・psutil が無い)では何もしない。テストの結果は変えない。 +""" + +from __future__ import annotations + +import ctypes +import os +import signal +import subprocess +import sys +import threading +from pathlib import Path + +PR_SET_CHILD_SUBREAPER = 36 +GRACE = 2.0 + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "plugins" / "ndf" / "scripts" / "lib")) +try: + import procs +except ImportError: # psutil が無い環境では何もしない + procs = None + +_lock = threading.Lock() +_own: set[int] = set() +_state = {"starting": 0, "deferred": False, "config": None} +_saved: dict = {} + + +def _become_subreaper() -> bool: + if procs is None or not sys.platform.startswith("linux"): + return False + try: + libc = ctypes.CDLL(None, use_errno=True) + return libc.prctl(PR_SET_CHILD_SUBREAPER, 1, 0, 0, 0) == 0 + except (OSError, AttributeError): + return False + + +def _reap_unknown() -> None: + """直接の子のうち、ゾンビで、自分が起こした記録に無いものだけを 1 つずつ回収する(ロックを持って呼ぶ)。""" + for pid in procs.child_pids(os.getpid(), recursive=False): + if pid in _own or not procs.pid_is_zombie(pid): + continue + try: + os.waitpid(pid, os.WNOHANG) + except ChildProcessError: + pass + + +def _reap_while_deferred() -> None: + """見送った回収を、起動中の数が 0 の間にやり直す。""" + while _state["deferred"]: + with _lock: + if _state["starting"]: + return + _state["deferred"] = False + _reap_unknown() + + +def _begin() -> None: + with _lock: + _state["starting"] += 1 + + +def _end(pid: int | None) -> None: + with _lock: + if pid: + _own.add(pid) + _state["starting"] -= 1 + _reap_while_deferred() + + +def _reset_in_forked_child() -> None: + global _lock + _lock = threading.Lock() + _own.clear() + _state["starting"] = 0 + _state["deferred"] = False + + +def _on_sigchld(signum, frame) -> None: + # 主スレッドで走り、主スレッドの包んだ呼び出しの途中にも割り込むため、ロックを待たない + if not _lock.acquire(blocking=False): + _state["deferred"] = True + return + try: + if _state["starting"]: + _state["deferred"] = True + else: + _state["deferred"] = False + _reap_unknown() + finally: + _lock.release() + + +def stop_children() -> None: + """直接の子の各 pid へ `stop_tree` を並べてかけ、全部の終わりをまとめて待つ。自分の pid には送らない。""" + me = os.getpid() + pids = [p for p in procs.child_pids(me, recursive=False) if p != me] + threads = [threading.Thread(target=procs.stop_tree, args=(p, GRACE), daemon=True) for p in pids] + for t in threads: + t.start() + for t in threads: + t.join() + + +def _on_sigterm(signum, frame) -> None: + stop_children() + signal.signal(signal.SIGTERM, signal.SIG_DFL) + os.kill(os.getpid(), signal.SIGTERM) + + +def _wrap_popen_init(orig): + def __init__(self, *args, **kwargs): + _begin() + pid = None + try: + orig(self, *args, **kwargs) + pid = getattr(self, "pid", None) + finally: + _end(pid) + + return __init__ + + +def _wrap_fork(orig): + def fork(): + _begin() + pid = None + try: + pid = orig() + finally: + if pid == 0: + _reset_in_forked_child() + else: + _end(pid) + return pid + + return fork + + +def install() -> bool: + """子の回収役になり、包みとシグナルの扱いを置く。置けたら真。""" + if _saved or not _become_subreaper(): + return False + try: + _saved["sigchld"] = signal.signal(signal.SIGCHLD, _on_sigchld) + signal.siginterrupt(signal.SIGCHLD, False) + _saved["sigterm"] = signal.signal(signal.SIGTERM, _on_sigterm) + except ValueError: # 主スレッドでないとシグナルの扱いを置けない + if "sigchld" in _saved: + signal.signal(signal.SIGCHLD, _saved.pop("sigchld")) + return False + _saved["popen_init"] = subprocess.Popen.__init__ + _saved["fork"] = os.fork + subprocess.Popen.__init__ = _wrap_popen_init(_saved["popen_init"]) + os.fork = _wrap_fork(_saved["fork"]) + return True + + +def uninstall() -> None: + """残った子孫を止め、記録に無いゾンビを回収し、包みとシグナルの扱いを元へ戻す。""" + if not _saved: + return + stop_children() + with _lock: + _reap_unknown() + subprocess.Popen.__init__ = _saved.pop("popen_init") + os.fork = _saved.pop("fork") + signal.signal(signal.SIGCHLD, _saved.pop("sigchld")) + signal.signal(signal.SIGTERM, _saved.pop("sigterm")) + + +def pytest_configure(config) -> None: + if _state["config"] is None and install(): + _state["config"] = config + + +def pytest_unconfigure(config) -> None: + if _state["config"] is config: + uninstall() + _state["config"] = None diff --git a/scripts/runtime-smoke-latest.py b/scripts/runtime-smoke-latest.py index e08fe704b..b7775e5eb 100755 --- a/scripts/runtime-smoke-latest.py +++ b/scripts/runtime-smoke-latest.py @@ -1,7 +1,7 @@ #!/usr/bin/env python3 """最新の版のスモークの手順のうち、判断の要らない 2 つを持つ(#1982)。 -- `resolve --runtime `: 取得元の最新の版を、CLI の版の一覧 +- `resolve --runtime `: 取得元の最新の版を、CLI の版の一覧 (`tests/runtime-smoke/cli-versions.env`)と同じ `KEY=VALUE` の形で標準出力へ出す。 取得元に届かない・形が読めないときは終了コード 1 で、標準エラーへ取得元の URL を書く - `report --runtime <名前> --run-url [--versions <パス>] [--repo ]`: 題が @@ -22,16 +22,10 @@ import urllib.request from pathlib import Path -RUNTIMES = ("claude", "codex", "kiro", "agy") +RUNTIMES = ("claude", "codex") VERSION_RE = re.compile(r"^[0-9]+(\.[0-9]+)+$") -HEX_RE = re.compile(r"^[0-9a-f]+$") NPM_DIST_TAGS = "https://registry.npmjs.org/-/package/{package}/dist-tags" -KIRO_MANIFEST = "https://prod.download.cli.kiro.dev/stable/latest/manifest.json" -AGY_MANIFEST = "https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/{platform}.json" -# arch の名前は `uname -m` の値に揃える(一覧のキーの末尾)。agy の manifest は別の名前で引く -AGY_PLATFORMS = {"X86_64": "linux_amd64", "AARCH64": "linux_arm64"} -KIRO_ARCHES = {"X86_64": "x86_64", "AARCH64": "aarch64"} TITLE = "最新の版のスモークが落ちた: {runtime}" @@ -59,12 +53,6 @@ def _version(url: str, value: object) -> str: return value -def _hex(url: str, value: object, length: int) -> str: - if not isinstance(value, str) or len(value) != length or not HEX_RE.match(value): - raise ResolveError(url, f"checksum が読めない: {value!r}") - return value - - def _npm_latest(package: str) -> str: url = NPM_DIST_TAGS.format(package=package) data = fetch_json(url) @@ -80,45 +68,7 @@ def resolve_codex() -> list[tuple[str, str]]: return [("CODEX_VERSION", _npm_latest("@openai/codex"))] -def resolve_kiro() -> list[tuple[str, str]]: - url = KIRO_MANIFEST - data = fetch_json(url) - if not isinstance(data, dict) or not isinstance(data.get("packages"), list): - raise ResolveError(url, "packages が無い") - version = _version(url, data.get("version")) - pairs = [("KIRO_CLI_VERSION", version)] - for key, arch in KIRO_ARCHES.items(): - download = f"{version}/kirocli-{arch}-linux-musl.tar.gz" - found = [p for p in data["packages"] if isinstance(p, dict) and p.get("download") == download] - if len(found) != 1: - raise ResolveError(url, f"{download} が manifest に 1 つだけ載っていない") - pairs.append((f"KIRO_CLI_SHA256_{key}", _hex(url, found[0].get("sha256"), 64))) - return pairs - - -def resolve_agy() -> list[tuple[str, str]]: - versions: dict[str, str] = {} - urls: list[tuple[str, str]] = [] - sums: list[tuple[str, str]] = [] - for key, platform in AGY_PLATFORMS.items(): - url = AGY_MANIFEST.format(platform=platform) - data = fetch_json(url) - if not isinstance(data, dict): - raise ResolveError(url, "形が読めない") - version = _version(url, data.get("version")) - dl = data.get("url") - # 版つきの取得先だけを受ける(版を含まない url は固定にならない) - if not isinstance(dl, str) or not dl.startswith("https://") or f"/{version}-" not in dl: - raise ResolveError(url, f"版つきの url が読めない: {dl!r}") - versions[platform] = version - urls.append((f"AGY_URL_{key}", dl)) - sums.append((f"AGY_SHA512_{key}", _hex(url, data.get("sha512"), 128))) - if len(set(versions.values())) != 1: - raise ResolveError(AGY_MANIFEST.format(platform="*"), f"arch ごとの版が違う: {versions}") - return [("AGY_VERSION", next(iter(versions.values()))), *urls, *sums] - - -RESOLVERS = {"claude": resolve_claude, "codex": resolve_codex, "kiro": resolve_kiro, "agy": resolve_agy} +RESOLVERS = {"claude": resolve_claude, "codex": resolve_codex} def resolve(runtime: str) -> list[tuple[str, str]]: diff --git a/scripts/runtime-smoke-test.sh b/scripts/runtime-smoke-test.sh index db5ac38de..b018ebb7d 100755 --- a/scripts/runtime-smoke-test.sh +++ b/scripts/runtime-smoke-test.sh @@ -13,7 +13,7 @@ usage() { Usage: bash scripts/runtime-smoke-test.sh [OPTIONS] Options: - --runtime claude|codex|kiro|agy|all runtime to test (default: all) + --runtime claude|codex|all runtime to test (default: all) --with-secrets off|auto|required secret handling mode (default: off) --secret-file KEY=PATH inject an allowlisted file secret --keep-container keep the container for local debugging @@ -44,7 +44,7 @@ while [ "$#" -gt 0 ]; do shift done -case "$RUNTIME" in claude|codex|kiro|agy|all) ;; *) echo "ERROR: invalid --runtime: $RUNTIME" >&2; exit 2 ;; esac +case "$RUNTIME" in claude|codex|all) ;; *) echo "ERROR: invalid --runtime: $RUNTIME" >&2; exit 2 ;; esac case "$WITH_SECRETS" in off|auto|required) ;; *) echo "ERROR: invalid --with-secrets: $WITH_SECRETS" >&2; exit 2 ;; esac if [ "$KEEP_CONTAINER" = true ] && [ "$WITH_SECRETS" != off ]; then echo "ERROR: --keep-container cannot be used with --with-secrets=$WITH_SECRETS" >&2 @@ -57,7 +57,7 @@ mkdir -p "$ARTIFACT_DIR" ARTIFACT_DIR="$(cd "$ARTIFACT_DIR" && pwd)" RUNTIMES=("$RUNTIME") -[ "$RUNTIME" = all ] && RUNTIMES=(claude codex kiro agy) +[ "$RUNTIME" = all ] && RUNTIMES=(claude codex) RAW_SECRET_NAMES=( ANTHROPIC_API_KEY diff --git a/scripts/script-structure-allow/plugins__ndf__dev.kiro__install.sh--lines.json b/scripts/script-structure-allow/plugins__ndf__dev.kiro__install.sh--lines.json deleted file mode 100644 index 3148cc866..000000000 --- a/scripts/script-structure-allow/plugins__ndf__dev.kiro__install.sh--lines.json +++ /dev/null @@ -1 +0,0 @@ -{"path": "plugins/ndf/dev.kiro/install.sh", "name": "", "kind": "lines", "reason": "L0 の時点で 561 行。移行の範囲の外。触って 500 行以下にしたら消す", "lines": 561} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__pr-steps.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__pr-steps.py--lines.json deleted file mode 100644 index 0ecfbea43..000000000 --- a/scripts/script-structure-allow/plugins__ndf__scripts__pr-steps.py--lines.json +++ /dev/null @@ -1 +0,0 @@ -{"path": "plugins/ndf/scripts/pr-steps.py", "name": "", "kind": "lines", "reason": "一括の整形(#1323)で物理行が増え、ツールのパスの分類と除外(#1436)で 6 行増えた。500 行以下にしたら消す", "lines": 599} diff --git a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json index 67bf5f245..16d899e55 100644 --- a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__drive.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/skills/cross-refactoring/scripts/drive.py", "name": "", "kind": "lines", "reason": "#2229 の前の時点で 500 行(上限ちょうど)。#2229(rf の持ち主の記録を動いた時刻・終わりで更新する)で増えた。共有の処理は lib/run_owner.py へ置き、呼び出しだけが残る。触って 500 行以下にしたら消す", "lines": 509} +{"path": "plugins/ndf/skills/cross-refactoring/scripts/drive.py", "name": "", "kind": "lines", "reason": "#2229 の前の時点で 500 行(上限ちょうど)。#2229(rf の持ち主の記録を動いた時刻・終わりで更新する)で増えた。共有の処理は lib/run_owner.py へ置き、呼び出しだけが残る。触って 500 行以下にしたら消す。#2047(時間の枠の不足で飛ばした結果・想定最大時間を下限へ上げたときのステップの延長)で増えた。延長の書き出しは lib/drive_pause.py へ置いた", "lines": 536} diff --git a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json index 17d314a98..dc0a7b642 100644 --- a/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__skills__cross-refactoring__scripts__refactor_lib__commands__setup.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py", "name": "", "kind": "lines", "reason": "L0 の時点で 913 行。C4 は重複だけをライブラリへ置き換える。触って 500 行以下にしたら消す。#1290(参加の確認へ --model の明示を渡す)で増えた。#1658(入口で Pull Request の状態を確かめる)で増えた。#2229(開始の片付けと持ち主の記録。共有の処理は lib/run_owner.py・lib/run_state.py へ置いた)で増えた", "lines": 943} +{"path": "plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py", "name": "", "kind": "lines", "reason": "L0 の時点で 913 行。C4 は重複だけをライブラリへ置き換える。触って 500 行以下にしたら消す。#1290(参加の確認へ --model の明示を渡す)で増えた。#1658(入口で Pull Request の状態を確かめる)で増えた。#2229(開始の片付けと持ち主の記録。共有の処理は lib/run_owner.py・lib/run_state.py へ置いた)で増えた。#2047(想定最大時間の出所の記録と下限への引き上げ。判定は refactor_lib/init_test.py へ置いた)で増えた", "lines": 953} diff --git a/scripts/tests/agy_build_helpers.py b/scripts/tests/agy_build_helpers.py deleted file mode 100644 index fe6661e70..000000000 --- a/scripts/tests/agy_build_helpers.py +++ /dev/null @@ -1,82 +0,0 @@ -"""生成物の同期(`scripts/build-runtime-plugins.sh`)のテスト補助。 - -生成の本体はシェルスクリプトで、自分の位置からリポジトリの根を決める。実物のリポジトリで -動かすと `dev.agy/skills/` を書き換えてしまうため、一時ディレクトリへ同じ配置の木を作り、 -スクリプトを複製してそちらで動かす。**複製ではなく実物を複製する**(複製を置くと、生成の -規則が 2 つに分かれる)。 - -Skill 名は実物(`cherry-pick-pr` など)と重ならない値にしてある。テストが実物の manifest へ -依存していないことを、値そのもので示すためである。 -""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[2] -BUILD = REPO_ROOT / "scripts/build-runtime-plugins.sh" -AGY_AGENTS = REPO_ROOT / "scripts/lib/agy_agents.py" - -FAMILY = "ndf" -VERSION = "9.3.0" -AGY_SKILLS = ["alpha", "bravo", "charlie"] - - -def build_tree(base: Path, skills: list[str] | None = None) -> Path: - """agy の生成に要るものだけを備えた木を作り、その根を返す。""" - skills = AGY_SKILLS if skills is None else skills - root = base / "repo" - plugin = root / f"plugins/{FAMILY}" - - (root / "scripts").mkdir(parents=True) - (root / "scripts/build-runtime-plugins.sh").write_text(BUILD.read_text(encoding="utf-8"), encoding="utf-8") - (root / "scripts/lib").mkdir() - (root / "scripts/lib/agy_agents.py").write_text(AGY_AGENTS.read_text(encoding="utf-8"), encoding="utf-8") - # MCP プラグインの同期はディレクトリを走査する。空でも存在していないと読めない。 - (root / "plugins/mcp").mkdir(parents=True) - - (plugin / "manifests").mkdir(parents=True) - (plugin / "manifests/agy-skills.txt").write_text( - "# コメント行と空行は数えない\n\n" + "".join(f"{name}\n" for name in skills), - encoding="utf-8", - ) - for name in skills: - (plugin / "skills" / name).mkdir(parents=True) - (plugin / "skills" / name / "SKILL.md").write_text(f"# {name}\n", encoding="utf-8") - (plugin / "agents").mkdir(parents=True) - (plugin / "scripts").mkdir(parents=True) - - (plugin / "dev.agy").mkdir(parents=True) - (plugin / "dev.agy/plugin.json").write_text( - json.dumps({"name": FAMILY, "version": VERSION}, ensure_ascii=False, indent=2) + "\n", - encoding="utf-8", - ) - return root - - -def run_build(root: Path, check: bool = False) -> subprocess.CompletedProcess[str]: - """複製したスクリプトを木の中で実行し、終了コードと出力を観測する。""" - args = ["bash", str(root / "scripts/build-runtime-plugins.sh")] - if check: - args.append("--check") - return subprocess.run(args, capture_output=True, text=True, check=False) - - -def output_of(result: subprocess.CompletedProcess[str]) -> str: - return result.stdout + result.stderr - - -def links_dir(root: Path) -> Path: - return root / f"plugins/{FAMILY}/dev.agy/skills" - - -def agents_dir(root: Path) -> Path: - """エージェント定義の正(Claude Code の定義)。""" - return root / f"plugins/{FAMILY}/agents" - - -def agy_agents_dir(root: Path) -> Path: - """agy に配る定義(生成した写し)。""" - return root / f"plugins/{FAMILY}/dev.agy/agents" diff --git a/scripts/tests/doc_staleness_helpers.py b/scripts/tests/doc_staleness_helpers.py index 297a41d3c..4712e9f35 100644 --- a/scripts/tests/doc_staleness_helpers.py +++ b/scripts/tests/doc_staleness_helpers.py @@ -28,12 +28,10 @@ # v10.5.0 で無くなった。#116)。 SKILL_DIRS = ["alpha", "bravo", "charlie", "delta", "echo"] -# ランタイムごとの配布 Skill 数。4 つとも別の値にして、取り違えを検出できるようにする。 +# ランタイムごとの配布 Skill 数。2 つを別の値にして、取り違えを検出できるようにする。 MANIFESTS = { "claude": ["alpha", "bravo", "charlie", "delta", "echo"], "codex": ["alpha", "bravo", "charlie"], - "kiro": ["alpha", "bravo", "charlie", "delta"], - "agy": ["alpha", "bravo"], } # 記号(G〜M)は `issues/parallel-batch-03/04-issue-209.md` の「チェックする記載」に対応する。 @@ -42,7 +40,7 @@ **NDFプラグイン v9.3.0** のチェック用の最小構成です。 -- **公開Skills**: Claude Code向け core 5個、Kiro向け core 4個、Codex向け core 3個、agy向け core 2個に分離。 +- **公開Skills**: Claude Code向け core 5個、Codex向け core 3個に分離。 - **元Skills(5個)**: - 第1群 (4): alpha, bravo, charlie, delta - 第2群 (1): echo @@ -111,7 +109,7 @@ 最初のタグは `ndf--v8.5.4` である。それより前の版(`8.4.0` 以前)はタグでは戻せない。 """ -# K: Kiro の確認例 / L: Codex のキャッシュパスの例(2 箇所) / M: `codex plugin list` の出力例 +# L: Codex のキャッシュパスの例(2 箇所) / M: `codex plugin list` の出力例 PLUGIN_README = """# NDF Plugin 配布物は 1 ディレクトリにまとまっています。 @@ -120,8 +118,6 @@ | --- | --- | --- | | Claude Code | 5 個 | `.claude-plugin/plugin.json` | | Codex | 3 個 | `.codex-plugin/plugin.json` | -| Kiro CLI | 4 個 | `dev.kiro/install.sh` | -| agy | 2 個 | `dev.agy/plugin.json` | ## レイアウト @@ -135,13 +131,6 @@ 版ごとの変更と移行の手順は CHANGELOG.md の `[ndf <版>]` の節にあります。 -## Kiro CLI で確かめる - -```bash -kiro agent list -# => NDF統合開発エージェント(Kiro CLI用 / v9.3.0) -``` - ## Codex で確かめる ```text @@ -259,7 +248,6 @@ def retarget_version(root: Path, version: str) -> None: edit(versioning, f"`{old_next}-rc.1`", f"`{new_next}-rc.1`") plugin_readme = root / "plugins/ndf/README.md" - edit(plugin_readme, f"Kiro CLI用 / v{VERSION})", f"Kiro CLI用 / v{version})") edit_all(plugin_readme, f"ndf/{VERSION}/skills/", f"ndf/{version}/skills/", 2) edit(plugin_readme, f"enabled {VERSION} ", f"enabled {version} ") diff --git a/scripts/tests/test_agy_agents.py b/scripts/tests/test_agy_agents.py deleted file mode 100644 index 1ae1b7507..000000000 --- a/scripts/tests/test_agy_agents.py +++ /dev/null @@ -1,192 +0,0 @@ -"""agy に配るエージェント定義の生成(scripts/lib/agy_agents.py。#2087)。 - -agy 1.4 系は `model` に tier 名(`flash` / `pro` / `inherit`)しか受け付けない。`dev.agy/agents/` には -`plugins/ndf/agents/` の写しを置き、`model:` の 1 行だけを tier 名へ変える。agy の無い環境で、 -写しの追従し忘れ・手直し・余り・足りなさと、受け付けられない `model` を見つける。 -""" - -from __future__ import annotations - -import importlib.util -import os -import shutil -from pathlib import Path - -from agy_build_helpers import agents_dir, agy_agents_dir, build_tree, output_of, run_build - -REPO_ROOT = Path(__file__).resolve().parents[2] -PLUGIN_DIR = REPO_ROOT / "plugins/ndf" - -_spec = importlib.util.spec_from_file_location("agy_agents", REPO_ROOT / "scripts/lib/agy_agents.py") -assert _spec and _spec.loader -agy_agents = importlib.util.module_from_spec(_spec) -_spec.loader.exec_module(agy_agents) - - -def _agent(name: str, model: str | None, body: str = "本文") -> str: - model_line = f"model: {model}\n" if model is not None else "" - return f"---\nname: {name}\n{model_line}description: |\n {name} の説明\n---\n\n{body}\n" - - -def _tree(tmp_path: Path, agents: dict[str, str | None]) -> Path: - root = build_tree(tmp_path) - for name, model in agents.items(): - (agents_dir(root) / f"{name}.md").write_text(_agent(name, model), encoding="utf-8") - return root - - -def _generated(root: Path) -> Path: - result = run_build(root) - assert result.returncode == 0, output_of(result) - return agy_agents_dir(root) - - -def test_repo_copies_use_agy_tiers() -> None: - """配る写しの `model` は tier 名か欄なし(受け入れ条件 7・I3)。""" - copies = sorted((PLUGIN_DIR / "dev.agy/agents").glob("*.md")) - assert copies - for path in copies: - model = agy_agents.frontmatter_model(path.read_text(encoding="utf-8")) - assert model is None or model in agy_agents.AGY_TIERS, f"{path}: {model}" - - -def test_repo_copies_follow_every_definition() -> None: - """写しのファイル名の集合は、エージェント定義の集合と等しい(I1)。""" - assert not (PLUGIN_DIR / "dev.agy/agents").is_symlink() - sources = sorted(p.name for p in (PLUGIN_DIR / "agents").glob("*.md")) - copies = sorted(p.name for p in (PLUGIN_DIR / "dev.agy/agents").iterdir()) - assert copies == sources - - -def test_model_map_values_are_tiers() -> None: - """対応表の値はすべて tier 名(I4)。""" - assert set(agy_agents.MODEL_MAP.values()) <= agy_agents.AGY_TIERS - - -def test_build_converts_model_and_keeps_the_rest(tmp_path: Path) -> None: - """`model:` の 1 行だけが変わり、ほかは元とバイト列で一致する(受け入れ条件 6・7、I2)。""" - root = _tree(tmp_path, {"cheap": "sonnet", "strong": "opus", "plain": None}) - out = _generated(root) - for name, tier in (("cheap", "flash"), ("strong", "pro"), ("plain", None)): - source = (agents_dir(root) / f"{name}.md").read_text(encoding="utf-8") - copy = (out / f"{name}.md").read_text(encoding="utf-8") - assert agy_agents.frontmatter_model(copy) == tier - assert [line for line in source.splitlines() if not line.startswith("model:")] == [ - line for line in copy.splitlines() if not line.startswith("model:") - ] - assert run_build(root, check=True).returncode == 0 - - -def test_build_does_not_touch_the_definitions(tmp_path: Path) -> None: - """古い symlink のままでも、生成は元の定義を書き換えない。`--check` は symlink を落とす(I6)。""" - root = _tree(tmp_path, {"cheap": "sonnet"}) - before = (agents_dir(root) / "cheap.md").read_bytes() - shutil.rmtree(_generated(root)) - os.symlink("../agents", agy_agents_dir(root)) - - result = run_build(root, check=True) - assert result.returncode == 1 - assert "Generated agents must be files, not a symlink" in output_of(result) - - out = _generated(root) - assert not out.is_symlink() and out.is_dir() - assert (agents_dir(root) / "cheap.md").read_bytes() == before - assert run_build(root, check=True).returncode == 0 - - -def test_check_detects_missing_copy(tmp_path: Path) -> None: - """定義を足して生成し直さないと `--check` が落ち、足りない写しを示す(I1)。""" - root = _tree(tmp_path, {"cheap": "sonnet"}) - _generated(root) - (agents_dir(root) / "added.md").write_text(_agent("added", "sonnet"), encoding="utf-8") - result = run_build(root, check=True) - assert result.returncode == 1 - assert "Generated agent missing: plugins/ndf/dev.agy/agents/added.md" in output_of(result) - assert (_generated(root) / "added.md").is_file() - - -def test_check_detects_stale_copy(tmp_path: Path) -> None: - """定義を消して生成し直さないと `--check` が落ち、余った写しを示す。生成し直すと消える(I1)。""" - root = _tree(tmp_path, {"cheap": "sonnet", "gone": "opus"}) - _generated(root) - (agents_dir(root) / "gone.md").unlink() - result = run_build(root, check=True) - assert result.returncode == 1 - assert "Generated agent is stale: plugins/ndf/dev.agy/agents/gone.md" in output_of(result) - assert not (_generated(root) / "gone.md").exists() - - -def test_check_detects_body_drift_and_hand_edits(tmp_path: Path) -> None: - """定義の本文を変えて生成し直さない・写しを手で直す、のどちらも `--check` が落ちる(I2)。""" - root = _tree(tmp_path, {"cheap": "sonnet"}) - out = _generated(root) - source = agents_dir(root) / "cheap.md" - source.write_text(source.read_text(encoding="utf-8").replace("本文", "本文!"), encoding="utf-8") - result = run_build(root, check=True) - assert result.returncode == 1 - assert "Generated agent is out of date: plugins/ndf/dev.agy/agents/cheap.md" in output_of(result) - - _generated(root) - copy = out / "cheap.md" - copy.write_text(copy.read_text(encoding="utf-8").replace("model: flash", "model: sonnet"), encoding="utf-8") - assert run_build(root, check=True).returncode == 1 - - -def test_unknown_model_stops_build_and_check(tmp_path: Path) -> None: - """対応表に無い値は、生成も `--check` も止め、パスと値を示す(I5)。""" - root = _tree(tmp_path, {"odd": "claude-opus-5-5"}) - for check in (False, True): - result = run_build(root, check=check) - assert result.returncode == 1 - assert "Unknown model for agy: plugins/ndf/agents/odd.md: claude-opus-5-5" in output_of(result) - assert not agy_agents_dir(root).exists() - - -def test_broken_model_map_stops_build(tmp_path: Path) -> None: - """対応表の値が tier 名でないと、生成を始めずに止まる(I4)。""" - root = _tree(tmp_path, {"cheap": "sonnet"}) - plugin_dir = root / "plugins/ndf" - assert agy_agents.sync(plugin_dir, check=False, model_map={"sonnet": "sonnet"}) is False - assert not agy_agents_dir(root).exists() - - -# 現状固定テスト: agy_copy と frontmatter_model は frontmatter の走査を別々に持つ。 -# 走査をまとめても、次の入力での出力が変わらないことを固定する。 -_FRONTMATTER_CASES = [ - # (入力, frontmatter_model の値, agy_copy の出力) - ("", None, ""), - ("model: sonnet\n", None, "model: sonnet\n"), - ("本文\n---\nmodel: sonnet\n---\n", None, "本文\n---\nmodel: sonnet\n---\n"), - ("---\nname: a\n---\n\nmodel: sonnet\n", None, "---\nname: a\n---\n\nmodel: sonnet\n"), - ("---\nname: a\n---\n", None, "---\nname: a\n---\n"), - ("---\nname: a\nmodel: sonnet\n---\n", "sonnet", "---\nname: a\nmodel: flash\n---\n"), - ("--- \nmodel: opus\n ---\n", "opus", "--- \nmodel: pro\n ---\n"), - ("---\nmodel: 'opus'\n---\n", "opus", "---\nmodel: pro\n---\n"), - ('---\nmodel: "haiku"\n---\n', "haiku", "---\nmodel: flash\n---\n"), - ("---\nmodel: inherit \n---\n", "inherit", "---\nmodel: inherit\n---\n"), - ("---\r\nmodel: sonnet\r\n---\r\n", "sonnet", "---\r\nmodel: flash\r\n---\r\n"), - ("---\nmodel: sonnet", "sonnet", "---\nmodel: flash"), - ("---\nname: a\nmodel: sonnet\n", "sonnet", "---\nname: a\nmodel: flash\n"), - ("---\n model: sonnet\n---\n", None, "---\n model: sonnet\n---\n"), - ("---\nmodel: sonnet\nmodel: opus\n---\n", "sonnet", "---\nmodel: flash\nmodel: opus\n---\n"), -] - - -def test_characterize_frontmatter_model_and_agy_copy() -> None: - """frontmatter の走査の境界(無い・閉じない・欄なし・引用符・CRLF・字下げ・2 つ目の欄)を固定する。""" - for text, model, copy in _FRONTMATTER_CASES: - assert agy_agents.frontmatter_model(text) == model, repr(text) - assert agy_agents.agy_copy(text, agy_agents.MODEL_MAP) == copy, repr(text) - - -def test_characterize_agy_copy_unknown_model() -> None: - """対応表に無い値は UnknownModel で止まり、値(引用符を外したもの)を持つ。frontmatter の外の値は見ない。""" - for text, value in (("---\nmodel: gpt\n---\n", "gpt"), ("---\nmodel: 'x y'\n---\n", "x y"), ("---\nmodel:\n---\n", "")): - try: - agy_agents.agy_copy(text, agy_agents.MODEL_MAP) - except agy_agents.UnknownModel as exc: - assert exc.args == (value,) - else: - raise AssertionError(repr(text)) - text = "---\nname: a\n---\nmodel: gpt\n" - assert agy_agents.agy_copy(text, agy_agents.MODEL_MAP) == text diff --git a/scripts/tests/test_agy_distribution.py b/scripts/tests/test_agy_distribution.py deleted file mode 100644 index d69cfcb04..000000000 --- a/scripts/tests/test_agy_distribution.py +++ /dev/null @@ -1,185 +0,0 @@ -"""配布先ランタイム agy の配置を固定する(#215 の受け入れ条件 A8 / A12)。 - -agy はプラグインのディレクトリ直下の `plugin.json` で対象を判別する。**その位置へ置くと -Codex の配布 Skill が `.codex-plugin/plugin.json` の `skills` 配列ではなく `skills/` の実体に -変わる**(設計の決定 1 の実測)。増える 2 個は Claude Code だけで動くものであるため、 -ルート直下へ置かないこと自体を固定する。 - -配る Skill の基準は `manifests/agy-skills.txt` だけが持ち、`dev.agy/skills/` の symlink は -`scripts/build-runtime-plugins.sh` が生成する。基準と生成物が食い違ったまま配られないよう、 -`--check` が不足・余分・向き先の違いを検出することを確かめる。 -""" - -from __future__ import annotations - -import os -import subprocess -import sys -from pathlib import Path - -import pytest - -from agy_build_helpers import ( - AGY_SKILLS, - FAMILY, - build_tree, - links_dir, - output_of, - run_build, -) - -REPO_ROOT = Path(__file__).resolve().parents[2] -PLUGIN_DIR = REPO_ROOT / f"plugins/{FAMILY}" -MANIFEST_DIR = PLUGIN_DIR / "manifests" - -# 配布数は課題ごとに変わりうるが、**agy を足したことで他の 3 つが変わらない**ことは -# #215 の受け入れ条件(A12)である。値を書いて固定する。v10.5.0 で 5 個 -# (`optional-skills/` の 4 個と `notion-writing`)を 4 ランタイムすべてへ足したため、 -# 4 つとも同じ 5 だけ増えている(#116 / #144)。Google の 2 つの Skill を `google-workspace` -# の 1 つへまとめたため、4 つとも同じ 1 だけ減っている(#744)。見積もりの `estimate` を -# 4 つとも足したため、同じ 1 だけ増えている(#1856)。 -EXPECTED_COUNTS = {"claude": 49, "codex": 45, "kiro": 46, "agy": 45} - - -def manifest_names(runtime: str) -> list[str]: - path = MANIFEST_DIR / f"{runtime}-skills.txt" - assert path.is_file(), f"{path} が無い" - return [line.split("#", 1)[0].strip() for line in path.read_text(encoding="utf-8").splitlines() if line.split("#", 1)[0].strip()] - - -def test_root_plugin_json_is_absent() -> None: - """ルート直下の `plugin.json` を置かない。 - - 置くと Codex がそちらを優先して読み、`skills/` の実体を全件配る。agy 向けの定義は - `dev.agy/plugin.json` にある(Agent Plugins 1.0.0 §8.2 のクライアント拡張ディレクトリ)。 - """ - assert not (PLUGIN_DIR / "plugin.json").exists(), ( - f"plugins/{FAMILY}/plugin.json があると Codex の配布 Skill が manifest ではなく" - " skills/ の実体になる(agy 向けの定義は dev.agy/plugin.json へ置く)" - ) - - -@pytest.mark.parametrize("runtime,expected", sorted(EXPECTED_COUNTS.items())) -def test_manifest_counts_do_not_change(runtime: str, expected: int) -> None: - """4 ランタイムの配布数。agy を足しても既存の 3 つは動かない。""" - assert len(manifest_names(runtime)) == expected - - -def test_agy_manifest_starts_from_the_codex_set() -> None: - """agy へ配る集合は Codex と同じ。 - - Claude Code だけで動く 2 個(`official-skills-autoloader` / `statusline`)を外した - 集合である(設計の決定 3)。 - """ - assert manifest_names("agy") == manifest_names("codex") - - -def test_dev_agy_links_follow_the_manifest() -> None: - """`dev.agy/skills/` は基準のとおりに並び、実体の Skill を指す。""" - links = PLUGIN_DIR / "dev.agy/skills" - assert sorted(p.name for p in links.iterdir()) == sorted(manifest_names("agy")) - for name in manifest_names("agy"): - link = links / name - assert link.is_symlink(), f"{link} が symlink でない" - assert os.readlink(link) == f"../../skills/{name}" - assert (link / "SKILL.md").is_file() - - -def test_dev_agy_shares_the_implementation() -> None: - """hook の実体は 4 ランタイムで共有する(設計の決定 6)。エージェント定義は写しを置く(#2087)。""" - link = PLUGIN_DIR / "dev.agy" / "scripts" - assert link.is_symlink(), f"{link} が symlink でない" - assert os.readlink(link) == "../scripts" - - -def test_build_creates_the_links(tmp_path: Path) -> None: - """基準から生成できる。生成した直後は `--check` が通る。""" - root = build_tree(tmp_path) - result = run_build(root) - assert result.returncode == 0, output_of(result) - assert sorted(p.name for p in links_dir(root).iterdir()) == sorted(AGY_SKILLS) - assert run_build(root, check=True).returncode == 0 - - -def test_check_detects_a_missing_link(tmp_path: Path) -> None: - """symlink を 1 本消すと `--check` が落ちる。""" - root = build_tree(tmp_path) - run_build(root) - (links_dir(root) / AGY_SKILLS[0]).unlink() - - result = run_build(root, check=True) - assert result.returncode != 0 - assert AGY_SKILLS[0] in output_of(result) - - -def test_check_detects_an_extra_link(tmp_path: Path) -> None: - """基準に無い名前を足すと `--check` が落ちる。""" - root = build_tree(tmp_path) - run_build(root) - (links_dir(root) / "delta").symlink_to("../../skills/delta") - - result = run_build(root, check=True) - assert result.returncode != 0 - assert "delta" in output_of(result) - - -def test_check_detects_a_wrong_target(tmp_path: Path) -> None: - """向き先が違う symlink も落ちる。実体を指していなければ配られない。""" - root = build_tree(tmp_path) - run_build(root) - link = links_dir(root) / AGY_SKILLS[0] - link.unlink() - link.symlink_to(f"../../skills/{AGY_SKILLS[1]}") - - result = run_build(root, check=True) - assert result.returncode != 0 - assert AGY_SKILLS[0] in output_of(result) - - -def test_build_removes_a_stale_link(tmp_path: Path) -> None: - """基準から外した Skill の symlink は、生成し直すと消える。""" - root = build_tree(tmp_path) - run_build(root) - (links_dir(root) / "delta").symlink_to("../../skills/delta") - - assert run_build(root).returncode == 0 - assert not (links_dir(root) / "delta").is_symlink() - - -def test_build_skips_a_family_without_the_manifest(tmp_path: Path) -> None: - """`agy-skills.txt` を持たない family では何も作らない。 - - agy へ配らないプラグイン(`playwright-kit`)を巻き込まないことを固定する。 - """ - root = build_tree(tmp_path) - (root / f"plugins/{FAMILY}/manifests/agy-skills.txt").unlink() - - assert run_build(root).returncode == 0 - assert not links_dir(root).exists() - - -def test_generated_links_are_not_collected_twice() -> None: - """`dev.agy/skills/` の symlink を、テストの収集がたどらない。 - - symlink の先は `skills/` の実体であり、そこには Skill ごとのテストがある。両方を - たどると同じテストが 2 度数えられる(agy を配布先へ加えた時点で 1569 件が 2974 件へ - 増えた)。件数は退行の判定に使うため、二重に数えられる状態を残さない。 - """ - result = subprocess.run( - [ - sys.executable, - "-m", - "pytest", - "--collect-only", - "-q", - "-p", - "no:cacheprovider", - f"plugins/{FAMILY}", - ], - cwd=REPO_ROOT, - capture_output=True, - text=True, - check=False, - ) - assert result.returncode == 0, result.stdout + result.stderr - assert "dev.agy" not in result.stdout, [line for line in result.stdout.splitlines() if "dev.agy" in line] diff --git a/scripts/tests/test_agy_install_hooks.py b/scripts/tests/test_agy_install_hooks.py deleted file mode 100644 index a294bdf1a..000000000 --- a/scripts/tests/test_agy_install_hooks.py +++ /dev/null @@ -1,263 +0,0 @@ -"""agy へ hook を差し込む導入スクリプトの振る舞いを固定する(#305)。 - -**`agy plugin install` は `hooks.json` を複製するが、agy はそれを読み込まない。** -読む先は利用者の `~/.gemini/config/hooks.json` の 1 か所だけである(agy 1.1.26 で実測。 -プラグイン配下とプロジェクト直下のどちらに置いても -`loaded 1 named hooks from 1 hooks.json file(s)` のまま変わらない)。 -`dev.agy/install-hooks.sh` がその 1 か所へ差し込む。 - -ここで見るのは、**他の項目に触れないこと**・**冪等であること**・**相対の指定を -導入先の絶対パスへ直すこと**・**壊れた設定を黙って上書きしないこと**の 4 つである。 -""" - -from __future__ import annotations - -import json -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -INSTALLER = ROOT / "plugins" / "ndf" / "dev.agy" / "install-hooks.sh" - -# 利用者が既に持っている項目。差し込みで消えないことを見る。 -OTHER = {"other-tool": {"PreInvocation": [{"type": "command", "command": "true"}]}} - - -def run(*args: str) -> subprocess.CompletedProcess: - return subprocess.run(["bash", str(INSTALLER), *args], capture_output=True, text=True) - - -@pytest.fixture() -def plugin_dir(tmp_path: Path) -> Path: - """導入済みの実体を模す。`hooks.json` と、指し先のスクリプトを置く。""" - target = tmp_path / "plugins" / "ndf" - (target / "scripts").mkdir(parents=True) - (target / "scripts" / "worktree-guard.sh").write_text("", encoding="utf-8") - (target / "hooks.json").write_text( - json.dumps( - { - "ndf-worktree": { - "PreToolUse": [ - { - "matcher": "Write|write_to_file", - "hooks": [ - { - "type": "command", - "command": "bash ./scripts/worktree-guard.sh", - "timeout": 5, - } - ], - } - ] - } - } - ), - encoding="utf-8", - ) - return target - - -@pytest.fixture() -def config(tmp_path: Path) -> Path: - path = tmp_path / "config" / "hooks.json" - path.parent.mkdir(parents=True) - path.write_text(json.dumps(OTHER), encoding="utf-8") - return path - - -def test_it_adds_the_plugin_hook_without_touching_other_entries(config: Path, plugin_dir: Path) -> None: - result = run("--config", str(config), "--plugin-dir", str(plugin_dir)) - - assert result.returncode == 0, result.stderr - written = json.loads(config.read_text(encoding="utf-8")) - assert sorted(written) == ["ndf-worktree", "other-tool"] - assert written["other-tool"] == OTHER["other-tool"] - - -def test_it_rewrites_relative_commands_to_the_installed_path(config: Path, plugin_dir: Path) -> None: - """利用者の設定へ差し込むと、実行時の現在地はプラグインの位置と揃わない。""" - run("--config", str(config), "--plugin-dir", str(plugin_dir)) - - written = json.loads(config.read_text(encoding="utf-8")) - command = written["ndf-worktree"]["PreToolUse"][0]["hooks"][0]["command"] - assert command == f"bash {plugin_dir}/scripts/worktree-guard.sh" - assert "./scripts/" not in command - - -def test_running_it_twice_changes_nothing(config: Path, plugin_dir: Path) -> None: - run("--config", str(config), "--plugin-dir", str(plugin_dir)) - first = config.read_text(encoding="utf-8") - - result = run("--config", str(config), "--plugin-dir", str(plugin_dir)) - - assert result.returncode == 0 - assert "変更なし" in result.stdout - assert config.read_text(encoding="utf-8") == first - - -def test_dry_run_does_not_write(config: Path, plugin_dir: Path) -> None: - before = config.read_text(encoding="utf-8") - - result = run("--config", str(config), "--plugin-dir", str(plugin_dir), "--dry-run") - - assert result.returncode == 0 - assert config.read_text(encoding="utf-8") == before - - -def test_uninstall_removes_only_the_plugin_entries(config: Path, plugin_dir: Path) -> None: - run("--config", str(config), "--plugin-dir", str(plugin_dir)) - - result = run("--config", str(config), "--plugin-dir", str(plugin_dir), "--uninstall") - - assert result.returncode == 0, result.stderr - written = json.loads(config.read_text(encoding="utf-8")) - assert sorted(written) == ["other-tool"] - - -def test_it_creates_the_config_when_absent(tmp_path: Path, plugin_dir: Path) -> None: - missing = tmp_path / "fresh" / "hooks.json" - - result = run("--config", str(missing), "--plugin-dir", str(plugin_dir)) - - assert result.returncode == 0, result.stderr - assert sorted(json.loads(missing.read_text(encoding="utf-8"))) == ["ndf-worktree"] - - -def test_it_stops_on_a_broken_config(tmp_path: Path, plugin_dir: Path) -> None: - """読めない設定を黙って上書きしない。利用者の hook がまとめて消える。""" - broken = tmp_path / "broken.json" - broken.write_text("{ this is not json", encoding="utf-8") - - result = run("--config", str(broken), "--plugin-dir", str(plugin_dir)) - - assert result.returncode != 0 - assert broken.read_text(encoding="utf-8") == "{ this is not json" - - -def test_it_falls_back_to_the_repository_definition(config: Path, tmp_path: Path) -> None: - """導入前でも中身を確かめられる。実体が無ければリポジトリの定義を読む。""" - result = run( - "--config", - str(config), - "--plugin-dir", - str(tmp_path / "not-installed"), - "--dry-run", - ) - - assert result.returncode == 0, result.stderr - assert "ndf-worktree" in result.stdout - - -def test_the_repository_definition_is_the_one_that_is_distributed() -> None: - """差し込む定義は、配布する `hooks.json` と同じものである。""" - source = json.loads((INSTALLER.parent / "hooks.json").read_text(encoding="utf-8")) - assert sorted(source) == ["ndf-worktree"] - assert sorted(source["ndf-worktree"]) == ["PreInvocation", "PreToolUse"] - - -def test_a_relative_plugin_dir_is_resolved_to_an_absolute_path(config: Path, plugin_dir: Path, tmp_path: Path) -> None: - """agy はリポジトリの外でも起動する。相対のまま保存すると hook が落ちる(#417 の 3)。 - - `plugins/ndf/README.md` の案内は clone からの相対パスで書いてあるため、書かれた - とおりに実行すると踏む。 - """ - result = subprocess.run( - [ - "bash", - str(INSTALLER), - "--config", - str(config), - "--plugin-dir", - str(plugin_dir.relative_to(tmp_path)), - ], - cwd=str(tmp_path), - capture_output=True, - text=True, - ) - - assert result.returncode == 0, result.stderr - written = json.loads(config.read_text(encoding="utf-8")) - command = written["ndf-worktree"]["PreToolUse"][0]["hooks"][0]["command"] - assert command == f"bash {plugin_dir}/scripts/worktree-guard.sh" - - -@pytest.fixture() -def spaced_plugin_dir(tmp_path: Path) -> Path: - """空白とシェルの特殊文字を含む導入先。""" - target = tmp_path / "plugin space" / "ndf$x" - (target / "scripts").mkdir(parents=True) - (target / "scripts" / "worktree-guard.sh").write_text("#!/usr/bin/env bash\nexit 0\n", encoding="utf-8") - (target / "hooks.json").write_text( - json.dumps( - { - "ndf-worktree": { - "PreInvocation": [ - { - "type": "command", - "command": "bash ./scripts/worktree-guard.sh", - "timeout": 5, - } - ] - } - } - ), - encoding="utf-8", - ) - return target - - -def test_a_command_with_a_spaced_path_is_runnable(config: Path, spaced_plugin_dir: Path) -> None: - """文字列の一致だけでは、シェルの語として壊れていることを捕まえられない(#417 の 4)。 - - **正常終了するのに hook が効かない**形になるため、利用者から見て失敗が見えない。 - """ - result = run("--config", str(config), "--plugin-dir", str(spaced_plugin_dir)) - assert result.returncode == 0, result.stderr - - written = json.loads(config.read_text(encoding="utf-8")) - command = written["ndf-worktree"]["PreInvocation"][0]["command"] - executed = subprocess.run(["bash", "-c", command], capture_output=True, text=True) - assert executed.returncode == 0, f"{command}\n{executed.stderr}" - - -def test_uninstall_keeps_entries_the_plugin_does_not_distribute(config: Path, plugin_dir: Path) -> None: - """`ndf-` で始まる利用者の項目まで消さない(#417 の 8)。""" - mine = {"PreInvocation": [{"type": "command", "command": "true"}]} - current = json.loads(config.read_text(encoding="utf-8")) - current["ndf-mine"] = mine - config.write_text(json.dumps(current), encoding="utf-8") - run("--config", str(config), "--plugin-dir", str(plugin_dir)) - - result = run("--config", str(config), "--plugin-dir", str(plugin_dir), "--uninstall") - - assert result.returncode == 0, result.stderr - written = json.loads(config.read_text(encoding="utf-8")) - assert sorted(written) == ["ndf-mine", "other-tool"] - assert written["ndf-mine"] == mine - - -def test_the_write_leaves_no_temporary_file_behind(config: Path, plugin_dir: Path) -> None: - """置き換えで書く。中断で壊れた設定を残さない(#417 の 8)。""" - result = run("--config", str(config), "--plugin-dir", str(plugin_dir)) - - assert result.returncode == 0, result.stderr - leftovers = sorted(p.name for p in config.parent.iterdir() if p.name not in {"hooks.json", "hooks.json.bak"}) - assert leftovers == [] - assert sorted(json.loads(config.read_text(encoding="utf-8"))) == ["ndf-worktree", "other-tool"] - - -def test_it_reports_a_command_it_could_not_rewrite(config: Path, tmp_path: Path) -> None: - """黙って相対パスのまま保存すると、正常終了して効かない状態になる(決定 6)。""" - target = tmp_path / "odd" - (target / "scripts").mkdir(parents=True) - (target / "hooks.json").write_text( - json.dumps({"ndf-odd": {"PreInvocation": [{"type": "command", "command": "sh -c './scripts/x.sh'"}]}}), - encoding="utf-8", - ) - - result = run("--config", str(config), "--plugin-dir", str(target)) - - assert result.returncode == 0, result.stderr - assert "書き換えられなかった" in result.stdout + result.stderr diff --git a/scripts/tests/test_agy_listing_budget.py b/scripts/tests/test_agy_listing_budget.py deleted file mode 100644 index 6c64805a4..000000000 --- a/scripts/tests/test_agy_listing_budget.py +++ /dev/null @@ -1,114 +0,0 @@ -"""agy の初期一覧を予算の判定へ入れる(#215 の受け入れ条件 A10)。 - -配布先が 1 つ増えても、`manifests/agy-skills.txt` を読む経路と予算を突き合わせる経路が -無ければ、agy の一覧だけが判定されないまま増え続ける。**予算そのものは Claude Code から -借りた値である**(agy に規定が無いため。`scripts/check-skill-frontmatter.py` の注記)。 -借りた事実は注記に残し、ここでは「借りた値で判定が働くこと」を固定する。 - -一時ディレクトリへ作った木に対して実行する。実物の Skill は読むだけで、書き換えない。 -""" - -from __future__ import annotations - -import subprocess -import sys -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parents[2] -CHECKER = REPO_ROOT / "scripts/check-skill-frontmatter.py" - -FAMILY = "ndf" - - -def write_skill(skills_dir: Path, name: str, description: str) -> None: - (skills_dir / name).mkdir(parents=True, exist_ok=True) - (skills_dir / name / "SKILL.md").write_text( - f'---\nname: {name}\ndescription: "{description}"\n---\n\n# {name}\n', - encoding="utf-8", - ) - - -def build_tree(base: Path, count: int, description: str) -> Path: - """agy の manifest を持つ木を作り、その `skills/` を返す。""" - plugin = base / f"plugins/{FAMILY}" - skills_dir = plugin / "skills" - names = [f"probe-{index:03d}" for index in range(count)] - for name in names: - write_skill(skills_dir, name, description) - (plugin / "manifests").mkdir(parents=True, exist_ok=True) - for runtime in ("claude", "codex", "kiro", "agy"): - (plugin / f"manifests/{runtime}-skills.txt").write_text("".join(f"{name}\n" for name in names), encoding="utf-8") - return skills_dir - - -def run_checker(skills_dir: Path, *args: str) -> subprocess.CompletedProcess[str]: - return subprocess.run( - [sys.executable, str(CHECKER), "--skills-dir", str(skills_dir), *args], - capture_output=True, - text=True, - check=False, - ) - - -def report_row(output: str, runtime: str) -> list[str]: - for line in output.splitlines(): - columns = line.split() - if columns and columns[0] == runtime: - return columns - raise AssertionError(f"{runtime} の行が出力に無い\n{output}") - - -def test_report_lists_agy_with_a_budget(tmp_path: Path) -> None: - """報告に agy の行が出て、上限を持つ。""" - skills_dir = build_tree(tmp_path, count=3, description="Probe skill for the listing budget.") - result = run_checker(skills_dir, "--report") - - assert result.returncode == 0, result.stdout + result.stderr - row = report_row(result.stdout, "agy") - assert row[1].isdigit() and int(row[1]) > 0, row - assert row[2].isdigit() and int(row[2]) > 0, row - - -def test_agy_borrows_the_claude_budget(tmp_path: Path) -> None: - """上限は Claude Code と同じ値である(同じコンテキスト長の規定を借りている)。""" - skills_dir = build_tree(tmp_path, count=3, description="Probe skill for the listing budget.") - result = run_checker(skills_dir, "--report") - - assert report_row(result.stdout, "agy")[2] == report_row(result.stdout, "claude")[2] - - -def test_agy_listing_counts_the_path(tmp_path: Path) -> None: - """agy の一覧はパスを含む側で見積もる。Claude Code の合計より大きくなる。 - - 一覧の構成に公式の記述が無いため、多い側で見積もる(Kiro CLI と同じ扱い)。 - """ - skills_dir = build_tree(tmp_path, count=3, description="Probe skill for the listing budget.") - result = run_checker(skills_dir, "--report") - - agy = int(report_row(result.stdout, "agy")[1]) - claude = int(report_row(result.stdout, "claude")[1]) - assert agy > claude, result.stdout - - -def test_agy_budget_overflow_is_reported(tmp_path: Path) -> None: - """予算を超えると agy の指摘が出る。読み取るだけで判定していない状態を弾く。""" - skills_dir = build_tree(tmp_path, count=60, description="x" * 900) - result = run_checker(skills_dir) - - output = result.stdout + result.stderr - assert "ops/agy-listing" in output, output - assert result.returncode != 0 - - -def test_no_agy_manifest_means_no_agy_row(tmp_path: Path) -> None: - """`agy-skills.txt` を持たない木では agy の行が出ない。 - - manifest が唯一の配布の基準であることを、無い側でも確かめる。 - """ - skills_dir = build_tree(tmp_path, count=3, description="Probe skill for the listing budget.") - (skills_dir.parent / "manifests/agy-skills.txt").unlink() - result = run_checker(skills_dir, "--report") - - assert result.returncode == 0, result.stdout + result.stderr - with_agy = [line for line in result.stdout.splitlines() if line.split()[:1] == ["agy"]] - assert not with_agy, result.stdout diff --git a/scripts/tests/test_check_required_checks.py b/scripts/tests/test_check_required_checks.py index 611d7dbef..7b46124a5 100644 --- a/scripts/tests/test_check_required_checks.py +++ b/scripts/tests/test_check_required_checks.py @@ -21,14 +21,12 @@ assert spec.loader is not None spec.loader.exec_module(crc) -# 2026-10-05 の develop の必須のチェック 12 個 -REQUIRED_12 = [ +# 2026-10-05 の develop の必須のチェック 12 個から、配布をやめた runtime-smoke の kiro・agy を除いた 10 個(#2231) +REQUIRED_10 = [ "guard", "pytest", "runtime-smoke (claude)", "runtime-smoke (codex)", - "runtime-smoke (kiro)", - "runtime-smoke (agy)", "runtime-plugin-build-check", "skill-frontmatter-check", "skill-repo-assumptions-check", @@ -36,7 +34,7 @@ "markdown-link-check", "instruction-files-check", ] -# 承認ゲート 2 で ruleset へ足した 7 つ(#653)。足した後の必須は 19 個 +# 承認ゲート 2 で ruleset へ足した 7 つ(#653)。足した後の必須は 17 個 ADDED_7 = [ "doc-line-limit-check", "glossary-check", @@ -46,7 +44,7 @@ "script-structure-check", "skill-shell-vars-check", ] -REQUIRED_19 = REQUIRED_12 + ADDED_7 +REQUIRED_17 = REQUIRED_10 + ADDED_7 def rules(*contexts: str) -> list[dict]: @@ -117,12 +115,12 @@ def fake_run(cmd, **kw): def test_this_repository_has_only_the_awaiting_six_and_itself(tmp_path, capsys): - """AC6: 12 個の必須と必須にしないジョブの宣言だけで、差は 6 つと突き合わせのチェック自身だけ。""" + """AC6: 10 個の必須と必須にしないジョブの宣言だけで、差は 6 つと突き合わせのチェック自身だけ。""" allow = json.loads((REPO / "scripts" / "required-checks-allow.json").read_text(encoding="utf-8")) (tmp_path / "allow.json").write_text( json.dumps({"version": 1, "not_required": allow["not_required"], "awaiting": allow["awaiting"]}), encoding="utf-8" ) - (tmp_path / "rules.json").write_text(json.dumps(rules(*REQUIRED_12)), encoding="utf-8") + (tmp_path / "rules.json").write_text(json.dumps(rules(*REQUIRED_10)), encoding="utf-8") code = crc.main( [ "--root", @@ -404,7 +402,7 @@ def test_the_job_runs_only_on_pull_requests(): def test_cli_runs_as_a_script(tmp_path): rules_file = tmp_path / "rules.json" - rules_file.write_text(json.dumps(rules(*REQUIRED_19)), encoding="utf-8") + rules_file.write_text(json.dumps(rules(*REQUIRED_17)), encoding="utf-8") p = subprocess.run( [sys.executable, str(CHECK), "--root", str(REPO), "--repo", "o/r", "--branch", "develop", "--rules-file", str(rules_file)], capture_output=True, diff --git a/scripts/tests/test_check_script_structure.py b/scripts/tests/test_check_script_structure.py index 1b4c60e8f..00dfccae4 100644 --- a/scripts/tests/test_check_script_structure.py +++ b/scripts/tests/test_check_script_structure.py @@ -227,12 +227,6 @@ def run_repo(repo: Path) -> tuple[int, dict]: return p.returncode, json.loads(p.stdout.strip().splitlines()[-1]) -def test_repository_matches_its_allow_list(): - """実物の木は例外リストと合う。直した移行ステップは、同じ PR で例外リストの行を消す。""" - p = subprocess.run([sys.executable, str(CHECK)], capture_output=True, text=True, cwd=REPO) - assert p.returncode == 0, p.stdout[-2000:] + p.stderr[-2000:] - - def test_structure_baseline_runs_on_a_given_root(tmp_path: Path): put(tmp_path, "scripts/a.py", "def f():\n return 1\n") put(tmp_path, "scripts/b.py", "def f():\n return 1\n") diff --git a/scripts/tests/test_cross_review_codistribution.py b/scripts/tests/test_cross_review_codistribution.py index 445cd67a0..70b3d2a35 100644 --- a/scripts/tests/test_cross_review_codistribution.py +++ b/scripts/tests/test_cross_review_codistribution.py @@ -20,7 +20,7 @@ def test_the_manifests_are_found() -> None: """配る基準そのものが見つかること。**空の一覧で通さない。**""" - assert len(MANIFESTS) >= 4, [p.name for p in MANIFESTS] + assert len(MANIFESTS) >= 2, [p.name for p in MANIFESTS] @pytest.mark.parametrize("manifest", MANIFESTS, ids=lambda p: p.stem) diff --git a/scripts/tests/test_distribution_runtimes.py b/scripts/tests/test_distribution_runtimes.py new file mode 100644 index 000000000..7b9a4d2b6 --- /dev/null +++ b/scripts/tests/test_distribution_runtimes.py @@ -0,0 +1,56 @@ +"""配布先は Claude Code と Codex だけである(#2231 の I1)。 + +agy と Kiro CLI へは何も配らず、cross-review / cross-refactoring の相手役として起動するだけにした。 +その導入物(`agy-skills.txt`・`kiro-skills.txt`・`dev.agy/`・`dev.kiro/`)がどのプラグインにも戻らないこと、 +リリースの対象ランタイム(`.ndf/supervise.json` の `release.runtimes`)が `claude`・`codex` の中に +収まることを確かめる。生成の手順は agy・kiro の導入物を作らないため、手で足したものはここでしか見つからない。 +""" + +from __future__ import annotations + +import json +from pathlib import Path + +REPO = Path(__file__).resolve().parents[2] +DISTRIBUTED = {"claude", "codex"} +RETIRED_FILES = ("agy-skills.txt", "kiro-skills.txt") +RETIRED_DIRS = ("dev.agy", "dev.kiro") + + +def retired_paths(root: Path) -> list[str]: + """`plugins/` の下にある agy・kiro の導入物を、根からの相対パスで返す。""" + found = [p for name in RETIRED_FILES for p in root.glob(f"plugins/**/manifests/{name}")] + found += [p for name in RETIRED_DIRS for p in root.glob(f"plugins/**/{name}") if p.is_dir()] + return sorted(p.relative_to(root).as_posix() for p in found) + + +def release_runtimes(root: Path) -> set[str]: + data = json.loads((root / ".ndf/supervise.json").read_text(encoding="utf-8")) + return set(data["release"]["runtimes"]) + + +def test_no_plugin_ships_agy_or_kiro_files() -> None: + assert retired_paths(REPO) == [] + + +def test_release_runtimes_are_claude_and_codex_only() -> None: + runtimes = release_runtimes(REPO) + assert runtimes + assert runtimes <= DISTRIBUTED, runtimes - DISTRIBUTED + + +def test_a_restored_kiro_installer_is_found(tmp_path: Path) -> None: + """mcp-* の `dev.kiro/install.sh` を 1 つ戻すと見つかる(検査が空振りしていないこと)。""" + installer = tmp_path / "plugins/mcp/mcp-notion/dev.kiro/install.sh" + installer.parent.mkdir(parents=True) + installer.write_text("#!/bin/sh\n", encoding="utf-8") + manifest = tmp_path / "plugins/ndf/manifests/agy-skills.txt" + manifest.parent.mkdir(parents=True) + manifest.write_text("pr\n", encoding="utf-8") + assert retired_paths(tmp_path) == ["plugins/mcp/mcp-notion/dev.kiro", "plugins/ndf/manifests/agy-skills.txt"] + + +def test_a_kiro_release_runtime_is_found(tmp_path: Path) -> None: + (tmp_path / ".ndf").mkdir() + (tmp_path / ".ndf/supervise.json").write_text(json.dumps({"release": {"runtimes": ["claude", "codex", "kiro"]}}), encoding="utf-8") + assert release_runtimes(tmp_path) - DISTRIBUTED == {"kiro"} diff --git a/scripts/tests/test_doc_staleness.py b/scripts/tests/test_doc_staleness.py index 0d2cad35b..1648fdb0b 100644 --- a/scripts/tests/test_doc_staleness.py +++ b/scripts/tests/test_doc_staleness.py @@ -74,9 +74,7 @@ def test_root_defaults_to_current_directory() -> None: ("before", "after", "expected"), [ ("Claude Code向け core 5個", "Claude Code向け core 4個", "5"), - ("Kiro向け core 4個", "Kiro向け core 3個", "4"), ("Codex向け core 3個", "Codex向け core 2個", "3"), - ("agy向け core 2個", "agy向け core 1個", "2"), ], ) def test_runtime_skill_count_mismatch_fails(tree: Path, before: str, after: str, expected: str) -> None: @@ -92,9 +90,7 @@ def test_runtime_skill_count_mismatch_fails(tree: Path, before: str, after: str, "fragment", [ "Claude Code向け core 5個、", - "Kiro向け core 4個、", - "Codex向け core 3個、", - "agy向け core 2個", + "Codex向け core 3個", ], ) def test_runtime_skill_count_removed_fails(tree: Path, fragment: str) -> None: @@ -167,8 +163,6 @@ def test_category_breakdown_removed_fails(tree: Path) -> None: [ ("| Claude Code | 5 個 |", "| Claude Code | 4 個 |", "5"), ("| Codex | 3 個 |", "| Codex | 2 個 |", "3"), - ("| Kiro CLI | 4 個 |", "| Kiro CLI | 9 個 |", "4"), - ("| agy | 2 個 |", "| agy | 7 個 |", "2"), ], ) def test_distribution_table_mismatch_fails(tree: Path, before: str, after: str, expected: str) -> None: @@ -185,8 +179,6 @@ def test_distribution_table_mismatch_fails(tree: Path, before: str, after: str, [ "| Claude Code | 5 個 | `.claude-plugin/plugin.json` |\n", "| Codex | 3 個 | `.codex-plugin/plugin.json` |\n", - "| Kiro CLI | 4 個 | `dev.kiro/install.sh` |\n", - "| agy | 2 個 | `dev.agy/plugin.json` |\n", ], ) def test_distribution_table_row_removed_fails(tree: Path, row: str) -> None: @@ -265,10 +257,10 @@ def test_failure_output_names_file_label_and_both_values(tree: Path) -> None: def test_missing_manifest_fails(tree: Path) -> None: """数える相手が無いことを、読み取れた値と一致しているとみなさない。""" - (tree / "plugins/ndf/manifests/kiro-skills.txt").unlink() + (tree / "plugins/ndf/manifests/codex-skills.txt").unlink() result = run_check(tree) assert result.returncode != 0 - assert "kiro-skills.txt" in output_of(result) + assert "codex-skills.txt" in output_of(result) def test_missing_plugin_json_fails(tree: Path) -> None: @@ -315,7 +307,6 @@ def agents_md(tree: Path) -> Path: ("mark", "document", "before", "after"), [ ("G", "README.md", "**NDFプラグイン v9.3.0**", "**NDFプラグイン v9.2.1**"), - ("K", "plugins/ndf/README.md", "Kiro CLI用 / v9.3.0)", "Kiro CLI用 / v9.2.1)"), ( "M", "plugins/ndf/README.md", @@ -352,7 +343,6 @@ def test_codex_cache_path_partial_stale_fails(tree: Path) -> None: ("mark", "document", "fragment", "occurrences"), [ ("G", "README.md", "**NDFプラグイン v9.3.0** のチェック用の最小構成です。\n", 1), - ("K", "plugins/ndf/README.md", "# => NDF統合開発エージェント(Kiro CLI用 / v9.3.0)\n", 1), ("L", "plugins/ndf/README.md", "~/.codex/plugins/cache/ai-plugins/ndf/9.3.0/skills/deploy/SKILL.md", 2), ("M", "plugins/ndf/README.md", "# => ndf@ai-plugins installed, enabled 9.3.0 \n", 1), ], @@ -804,7 +794,7 @@ def test_versions_outside_the_section_do_not_fail(tree: Path) -> None: def test_bumping_only_the_plugin_version_reports_every_body_claim(tree: Path) -> None: - """この課題が起きた経路そのもの。説明文書を直さずに版だけ上げると 6 種類が挙がる。""" + """この課題が起きた経路そのもの。説明文書を直さずに版だけ上げると 5 種類が挙がる。""" bump_plugin_version(tree, "9.4.0") result = run_check(tree) assert result.returncode != 0 @@ -813,7 +803,6 @@ def test_bumping_only_the_plugin_version_reports_every_body_claim(tree: Path) -> "概要の版数", "プラグイン一覧表の ndf の版数", "版の付け方の節の版数", - "Kiro の確認例の版数", "Codex のキャッシュパスの例の版数", "`codex plugin list` の出力例の版数", ): @@ -907,12 +896,12 @@ def test_manifest_skill_count_with_only_blank_and_comment_lines_returns_zero(tmp `None` と区別されている経路である。 """ module = _load_checker() - manifest = tmp_path / "plugins/ndf/manifests/kiro-skills.txt" + manifest = tmp_path / "plugins/ndf/manifests/codex-skills.txt" manifest.parent.mkdir(parents=True) manifest.write_text("# コメント行\n\n \n# 別のコメント\n", encoding="utf-8") report = module.Report() - assert module.manifest_skill_count(tmp_path, "kiro", report) == 0 + assert module.manifest_skill_count(tmp_path, "codex", report) == 0 assert report.errors == [] diff --git a/scripts/tests/test_external_ai_codistribution.py b/scripts/tests/test_external_ai_codistribution.py index 478120c9b..4780bbdfb 100644 --- a/scripts/tests/test_external_ai_codistribution.py +++ b/scripts/tests/test_external_ai_codistribution.py @@ -50,7 +50,7 @@ def test_some_skill_points_at_external_ai() -> None: def test_the_manifests_are_found() -> None: - assert len(MANIFESTS) == 4, [m.name for m in MANIFESTS] + assert len(MANIFESTS) == 2, [m.name for m in MANIFESTS] @pytest.mark.parametrize("manifest", MANIFESTS, ids=lambda p: p.name) diff --git a/scripts/tests/test_kiro_installer_project.py b/scripts/tests/test_kiro_installer_project.py deleted file mode 100644 index 0dd8627ae..000000000 --- a/scripts/tests/test_kiro_installer_project.py +++ /dev/null @@ -1,485 +0,0 @@ -"""Kiro のインストーラが `--project` の誤った値を案内つきで止めることを固定する(#415)。 - -変更前は `cd` の裸のエラーと終了コード 1 で終わり、何を直せばよいかが読み取れなかった。 -ここで見るのは、**誤りの種類ごとに文言が分かれること**・**存在しないパスにだけ -`mkdir -p` の案内が付くこと**・**案内のパスが bash の語 1 つとして読み戻せること**・ -**正しい値では振る舞いが変わらないこと**の 4 つである。 - -終了コードはどちらの誤りも 2 であるため、文言まで見ないと 2 つの誤りを区別できない。 -正しい値の確認は `--dry-run` で行い、利用者の環境へ書き込まない。 -""" - -from __future__ import annotations - -import json -import os -import shutil -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -INSTALLER = ROOT / "plugins" / "ndf" / "dev.kiro" / "install.sh" - - -def run(*args: str, home: Path, cwd: Path | None = None) -> subprocess.CompletedProcess: - # `--scope global` は HOME の下を導入先にする。誤って書き込んでも利用者の HOME に - # 届かないよう、一時ディレクトリを HOME として渡す。 - env = {**os.environ, "HOME": str(home)} - return subprocess.run( - ["bash", str(INSTALLER), *args], - capture_output=True, - text=True, - env=env, - cwd=cwd, - ) - - -def hint_words(stderr: str) -> list[str]: - """`HINT: mkdir -p ` に続く部分を bash で読み戻し、語の並びを返す。 - - `shlex.split` は `$'...'` の形を解さないため使わない(タブを含むパスで `$` が残る)。 - """ - prefix = "HINT: mkdir -p " - lines = [line for line in stderr.splitlines() if line.startswith(prefix)] - assert len(lines) == 1, stderr - quoted = lines[0][len(prefix) :] - out = subprocess.run( - ["bash", "-c", 'eval "set -- $1"; printf "%s\\0" "$@"', "_", quoted], - capture_output=True, - text=True, - check=True, - ).stdout - return out.split("\0")[:-1] - - -def assert_no_bare_cd_error(proc: subprocess.CompletedProcess) -> None: - assert "cd:" not in proc.stdout + proc.stderr - - -@pytest.mark.parametrize( - ("missing_path", "extra_args"), - [ - (Path("skills"), ()), - (Path("manifests/kiro-skills.txt"), ()), - (Path("dev.kiro/prompts/codex.md"), ("--with-codex",)), - ], -) -def test_missing_prerequisite_stops_with_path_error(tmp_path: Path, missing_path: Path, extra_args: tuple[str, ...]) -> None: - # 現状固定: 必須パスの種類やチェック位置によらず、同じ形式のエラー 1 行と - # 終了コード 1 で停止する。 - plugin_dir = tmp_path / "ndf" - shutil.copytree(INSTALLER.parents[1], plugin_dir) - missing = plugin_dir / missing_path - if missing.is_dir(): - shutil.rmtree(missing) - else: - missing.unlink() - - project = tmp_path / "project" - project.mkdir() - env = {**os.environ, "HOME": str(tmp_path)} - proc = subprocess.run( - [ - "bash", - str(plugin_dir / "dev.kiro" / "install.sh"), - "--project", - str(project), - "--dry-run", - "--yes", - *extra_args, - ], - capture_output=True, - text=True, - env=env, - ) - - assert proc.returncode == 1 - assert proc.stderr.splitlines() == [f"ERROR: {missing} が見つかりません"] - - -def test_project_without_path_stops_with_error(tmp_path: Path) -> None: - proc = run("--project", home=tmp_path) - - assert proc.returncode == 2 - assert proc.stderr.splitlines() == ["ERROR: --project requires a path"] - assert_no_bare_cd_error(proc) - - -@pytest.mark.parametrize("name", ["missing", "my project", "tab\tx"]) -def test_missing_path_stops_with_error_and_hint(tmp_path: Path, name: str) -> None: - target = tmp_path / name - proc = run("--project", str(target), "--yes", home=tmp_path) - - assert proc.returncode == 2 - lines = proc.stderr.splitlines() - assert lines[0] == f"ERROR: --project points at a path that does not exist: {target}" - assert lines[1].startswith("HINT: mkdir -p ") - assert len(lines) == 2 - # 案内のパスは、空白やタブを含んでも語 1 つとして渡したパスへ戻る - assert hint_words(proc.stderr) == [str(target)] - assert not target.exists() - assert_no_bare_cd_error(proc) - - -def test_empty_path_stops_with_error_and_hint(tmp_path: Path) -> None: - proc = run("--project", "", "--yes", home=tmp_path) - - assert proc.returncode == 2 - lines = proc.stderr.splitlines() - assert lines[0] == "ERROR: --project points at a path that does not exist: " - assert lines[1].startswith("HINT: mkdir -p ") - assert len(lines) == 2 - assert hint_words(proc.stderr) == [""] - assert list(tmp_path.iterdir()) == [] - assert_no_bare_cd_error(proc) - - -def test_file_path_stops_without_hint(tmp_path: Path) -> None: - target = tmp_path / "afile" - target.write_text("", encoding="utf-8") - proc = run("--project", str(target), "--yes", home=tmp_path) - - assert proc.returncode == 2 - assert proc.stderr.splitlines() == [f"ERROR: --project points at a path that is not a directory: {target}"] - assert "HINT:" not in proc.stderr - assert_no_bare_cd_error(proc) - - -def test_global_scope_with_missing_path_stops_the_same_way(tmp_path: Path) -> None: - target = tmp_path / "missing" - proc = run("--scope", "global", "--project", str(target), "--yes", home=tmp_path) - - assert proc.returncode == 2 - lines = proc.stderr.splitlines() - assert lines[0] == f"ERROR: --project points at a path that does not exist: {target}" - assert hint_words(proc.stderr) == [str(target)] - assert len(lines) == 2 - assert "WARN:" not in proc.stderr - assert_no_bare_cd_error(proc) - - -def test_global_scope_with_existing_path_warns_and_succeeds(tmp_path: Path) -> None: - # --scope global と存在するディレクトリの併用では、PROJECT_GIVEN が真になって - # WARN を出しつつ正常終了する。誤りではないため終了コードは 0 で、案内 (HINT) も - # 出ない。存在しないパス併用時のエラー停止(上のテスト)とは別の経路である。 - project = tmp_path / "project" - project.mkdir() - proc = run( - "--scope", - "global", - "--project", - str(project), - "--dry-run", - "--yes", - home=tmp_path, - ) - - assert proc.returncode == 0, proc.stderr - assert "WARN: --scope global では --project は使用されません" in proc.stderr - assert "ERROR:" not in proc.stderr - assert "HINT:" not in proc.stderr - assert_no_bare_cd_error(proc) - - -def test_existing_directory_is_unchanged(tmp_path: Path) -> None: - project = tmp_path / "project" - project.mkdir() - proc = run("--project", str(project), "--dry-run", "--yes", home=tmp_path) - - assert proc.returncode == 0, proc.stderr - assert "ERROR:" not in proc.stderr - assert_no_bare_cd_error(proc) - # --dry-run は導入先へ書き込まない - assert list(project.iterdir()) == [] - - -def test_reinstall_preserves_user_managed_agent_config(tmp_path: Path) -> None: - project = tmp_path / "project" - project.mkdir() - first = run("--project", str(project), "--yes", home=tmp_path) - assert first.returncode == 0, first.stderr - - agent_file = project / ".kiro" / "agents" / "ndf.json" - config = json.loads(agent_file.read_text(encoding="utf-8")) - config["customKey"] = {"enabled": True} - config.setdefault("mcpServers", {})["myserver"] = { - "command": "myserver", - "args": ["serve"], - } - config.setdefault("hooks", {})["myhook"] = [{"command": "echo custom", "timeout_ms": 1000}] - agent_file.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - - second = run("--project", str(project), "--yes", home=tmp_path) - assert second.returncode == 0, second.stderr - - reinstalled = json.loads(agent_file.read_text(encoding="utf-8")) - assert reinstalled["customKey"] == {"enabled": True} - assert reinstalled["mcpServers"]["myserver"] == { - "command": "myserver", - "args": ["serve"], - } - assert reinstalled["hooks"]["myhook"] == [{"command": "echo custom", "timeout_ms": 1000}] - assert reinstalled["hooks"]["agentSpawn"] - assert reinstalled["hooks"]["userPromptSubmit"] - - -def test_reinstall_removes_optional_features_when_flags_omitted(tmp_path: Path) -> None: - project = tmp_path / "project" - project.mkdir() - first = run( - "--project", - str(project), - "--with-slack", - "--with-codex", - "--yes", - home=tmp_path, - ) - assert first.returncode == 0, first.stderr - - agent_file = project / ".kiro" / "agents" / "ndf.json" - config = json.loads(agent_file.read_text(encoding="utf-8")) - assert "stop" in config["hooks"] - assert "codex" in config["mcpServers"] - - config["customKey"] = {"enabled": True} - config["mcpServers"]["myserver"] = { - "command": "myserver", - "args": ["serve"], - } - config["hooks"]["myhook"] = [{"command": "echo custom", "timeout_ms": 1000}] - agent_file.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") - - second = run("--project", str(project), "--yes", home=tmp_path) - assert second.returncode == 0, second.stderr - - reinstalled = json.loads(agent_file.read_text(encoding="utf-8")) - assert "stop" not in reinstalled["hooks"] - assert "codex" not in reinstalled.get("mcpServers", {}) - assert reinstalled["mcpServers"]["myserver"] == { - "command": "myserver", - "args": ["serve"], - } - assert reinstalled["hooks"]["myhook"] == [{"command": "echo custom", "timeout_ms": 1000}] - assert reinstalled["customKey"] == {"enabled": True} - assert reinstalled["hooks"]["agentSpawn"] - assert reinstalled["hooks"]["userPromptSubmit"] - - -def test_reinstall_removes_deprecated_prompts_and_keeps_user_prompt(tmp_path: Path) -> None: - # 配布を終えた clean.md と review.md は再インストールで除去し、利用者が置いた - # 無関係な prompt は残す。削除範囲が広がる退行を検出するための現状固定である。 - project = tmp_path / "project" - prompts_dir = project / ".kiro" / "prompts" - prompts_dir.mkdir(parents=True) - (prompts_dir / "clean.md").write_text("old clean\n", encoding="utf-8") - (prompts_dir / "review.md").write_text("old review\n", encoding="utf-8") - custom_text = "利用者が置いた prompt\n" - (prompts_dir / "custom.md").write_text(custom_text, encoding="utf-8") - - proc = run("--project", str(project), "--yes", home=tmp_path) - - assert proc.returncode == 0, proc.stderr - assert " removed (deprecated): clean" in proc.stdout.splitlines() - assert " removed (deprecated): review" in proc.stdout.splitlines() - assert not (prompts_dir / "clean.md").exists() - assert not (prompts_dir / "review.md").exists() - assert (prompts_dir / "pr.md").is_file() - assert (prompts_dir / "custom.md").read_text(encoding="utf-8") == custom_text - - # --with-codex を付けないため codex.md は配布されない - distributed = {p.name for p in (INSTALLER.parent / "prompts").glob("*.md") if p.name != "codex.md"} - assert distributed - assert {p.name for p in prompts_dir.iterdir()} == distributed | {"custom.md"} - - -@pytest.mark.parametrize( - ("existing", "warn_marker"), - [ - # 不正 JSON: 読み込みが例外になり、読めない旨で引継ぎを断念する。 - ("{ not json", "を読めないため引き継ぎません"), - # JSON オブジェクト以外(配列): dict でないため引継ぎを断念する。 - ("[]", "が JSON オブジェクトではないため引き継ぎません"), - # hooks がオブジェクト以外(配列): その節だけ引継ぎを断念する。 - ('{"hooks": []}', "の hooks が JSON オブジェクトではないため引き継ぎません"), - # mcpServers がオブジェクト以外(文字列): その節だけ引継ぎを断念する。 - ('{"mcpServers": "x"}', "の mcpServers が JSON オブジェクトではないため引き継ぎません"), - ], -) -def test_reinstall_with_malformed_agent_config_warns_and_continues(tmp_path: Path, existing: str, warn_marker: str) -> None: - # 既存 ndf.json が不正 JSON・JSON オブジェクト以外・hooks / mcpServers が - # オブジェクト以外のとき、引継ぎを断念して警告を出しつつ導入を続ける経路を固定する。 - # 終了コードは 0 で、出力された ndf.json は JSON オブジェクトとして読め、 - # テンプレート由来の agentSpawn と userPromptSubmit を必ず持つ。 - project = tmp_path / "project" - (project / ".kiro" / "agents").mkdir(parents=True) - agent_file = project / ".kiro" / "agents" / "ndf.json" - agent_file.write_text(existing, encoding="utf-8") - - proc = run("--project", str(project), "--yes", home=tmp_path) - - assert proc.returncode == 0, proc.stderr - # 引継ぎ断念の警告は Python 側の print で標準出力へ出る。 - assert f"WARN: 既存の {agent_file} {warn_marker}" in proc.stdout - assert_no_bare_cd_error(proc) - - reinstalled = json.loads(agent_file.read_text(encoding="utf-8")) - assert isinstance(reinstalled, dict) - assert reinstalled["hooks"]["agentSpawn"] - assert reinstalled["hooks"]["userPromptSubmit"] - - -@pytest.mark.parametrize( - ("cwd_part", "arg", "resolved_part"), - [(".", "project/sub", "project/sub"), ("project", ".", "project")], -) -def test_relative_path_is_resolved_against_cwd(tmp_path: Path, cwd_part: str, arg: str, resolved_part: str) -> None: - # 相対パスは実行時のカレントディレクトリを起点に `cd` と `pwd` で絶対パスへ解決され、 - # スコープの表示にはその絶対パスが出る。 - base = tmp_path.resolve() - (base / "project" / "sub").mkdir(parents=True) - proc = run("--project", arg, "--dry-run", "--yes", home=base, cwd=base / cwd_part) - - assert proc.returncode == 0, proc.stderr - assert f"スコープ: workspace ({base / resolved_part}/.kiro)" in proc.stdout - assert "ERROR:" not in proc.stderr - assert_no_bare_cd_error(proc) - - -@pytest.mark.parametrize("case", ["symlink", "file", "directory"]) -def test_ndf_policies_skill_migration(tmp_path: Path, case: str) -> None: - # ndf-policies の旧シンボリックリンクは削除する一方、同名の通常ファイルまたは - # 実体ディレクトリは保持する分岐と、どちらでも steering を生成する結果を固定する。 - project = tmp_path / f"project_{case}" - skills_dir = project / ".kiro" / "skills" - skills_dir.mkdir(parents=True) - target = skills_dir / "ndf-policies" - - if case == "symlink": - dummy = tmp_path / "legacy_target" - dummy.mkdir() - target.symlink_to(dummy) - elif case == "file": - target.write_text("user managed ndf-policies file\n", encoding="utf-8") - elif case == "directory": - target.mkdir() - (target / "custom.md").write_text("user managed content\n", encoding="utf-8") - - proc = run("--project", str(project), "--yes", home=tmp_path) - - assert proc.returncode == 0, proc.stderr - assert_no_bare_cd_error(proc) - - if case == "symlink": - assert not target.exists() - assert not target.is_symlink() - assert " REMOVED: ndf-policies (steering へ移行済みのため .kiro/skills のリンクを削除)" in proc.stdout - assert f"WARN: {target} はシンボリックリンクではありません。" not in proc.stderr - elif case == "file": - assert target.is_file() - assert not target.is_symlink() - assert target.read_text(encoding="utf-8") == "user managed ndf-policies file\n" - assert f"WARN: {target} はシンボリックリンクではありません。" in proc.stderr - assert " REMOVED: ndf-policies" not in proc.stdout - elif case == "directory": - assert target.is_dir() - assert not target.is_symlink() - assert (target / "custom.md").read_text(encoding="utf-8") == "user managed content\n" - assert f"WARN: {target} はシンボリックリンクではありません。" in proc.stderr - assert " REMOVED: ndf-policies" not in proc.stdout - - steering_file = project / ".kiro" / "steering" / "ndf-policies.md" - assert steering_file.is_file() - steering_content = steering_file.read_text(encoding="utf-8") - assert "" in steering_content - assert "" in steering_content - assert "# NDFポリシー" in steering_content - assert "## ブランチ運用の原則" in steering_content - - source_skill = ROOT / "plugins" / "ndf" / "skills" / "ndf-policies" / "SKILL.md" - source_body = source_skill.read_text(encoding="utf-8").split("\n---\n", 1)[1].strip("\n") - assert source_body in steering_content - - -def _kiro_manifest_skills() -> set[str]: - """manifests/kiro-skills.txt が配布対象として列挙する Skill 名の集合。 - - install.sh の Step 1 と同じ整形(コメント除去・末尾空白除去・空行除去)で読む。 - """ - manifest = INSTALLER.parents[1] / "manifests" / "kiro-skills.txt" - names: set[str] = set() - for raw in manifest.read_text(encoding="utf-8").splitlines(): - line = raw.split("#", 1)[0].rstrip() - if line: - names.add(line) - return names - - -def test_manifest_skills_are_linked_with_count_and_output(tmp_path: Path) -> None: - # 現状固定: Step 1 の Skill 配布パイプラインは manifest 掲載 Skill のうち - # ndf-policies を除いた分だけ .kiro/skills/ へ symlink を張り、その本数を - # 「Skills数」として出力し、各 Skill に " linked: <名前>" を出す。リンク先は - # プラグインの skills/<名前> を指す。構造改善で本数・リンク先・出力が動かないことを守る。 - project = tmp_path / "project" - project.mkdir() - proc = run("--project", str(project), "--yes", home=tmp_path) - assert proc.returncode == 0, proc.stderr - - skills_dir = project / ".kiro" / "skills" - plugin_skills = INSTALLER.parents[1] / "skills" - manifest_skills = _kiro_manifest_skills() - expected_linked = manifest_skills - {"ndf-policies"} - - # 実際に張られた symlink とそのリンク先を確認する。 - linked = {p.name for p in skills_dir.iterdir() if p.is_symlink()} - assert linked == expected_linked - for name in expected_linked: - assert (skills_dir / name).resolve() == (plugin_skills / name).resolve() - - # ndf-policies は steering へ回すためリンクしない。 - assert not (skills_dir / "ndf-policies").is_symlink() - - # 出力の「linked」行と「Skills数」が本数と一致する。 - linked_lines = {line[len(" linked: ") :] for line in proc.stdout.splitlines() if line.startswith(" linked: ")} - assert linked_lines == expected_linked - assert f" Skills数: {len(expected_linked)} (シンボリックリンク: {skills_dir})" in proc.stdout - - -def test_reinstall_removes_only_managed_skill_links(tmp_path: Path) -> None: - # 現状固定: 掃除が消すのは現在の checkout(プラグインの skills/)配下を指す - # 既存リンクだけである。別の場所を指す利用者のリンクは残す。再インストールでも - # 掃除→再リンクで最終状態が manifest 掲載分と一致する。 - project = tmp_path / "project" - project.mkdir() - first = run("--project", str(project), "--yes", home=tmp_path) - assert first.returncode == 0, first.stderr - - skills_dir = project / ".kiro" / "skills" - plugin_skills = INSTALLER.parents[1] / "skills" - - # 利用者が別の場所を指して張ったリンク(掃除対象外)。 - foreign_target = tmp_path / "foreign_skill" - foreign_target.mkdir() - (skills_dir / "user-external").symlink_to(foreign_target) - - # 現在の checkout を指すが manifest に無い名前のリンク(掃除対象)。 - stale = skills_dir / "stale-managed" - stale.symlink_to(plugin_skills / "pr") - - second = run("--project", str(project), "--yes", home=tmp_path) - assert second.returncode == 0, second.stderr - - manifest_skills = _kiro_manifest_skills() - expected_managed = manifest_skills - {"ndf-policies"} - - # 現在の checkout 配下を指す managed リンクは manifest 掲載分だけが残る。 - assert not (skills_dir / "stale-managed").exists() - # 別の場所を指す利用者のリンクは残る。 - assert (skills_dir / "user-external").is_symlink() - assert (skills_dir / "user-external").resolve() == foreign_target.resolve() - - managed_now = { - p.name for p in skills_dir.iterdir() if p.is_symlink() and str(p.resolve()).startswith(str(plugin_skills.resolve()) + os.sep) - } - assert managed_now == expected_managed diff --git a/scripts/tests/test_kiro_mcp_installer_tools.py b/scripts/tests/test_kiro_mcp_installer_tools.py deleted file mode 100644 index 9efa8833c..000000000 --- a/scripts/tests/test_kiro_mcp_installer_tools.py +++ /dev/null @@ -1,71 +0,0 @@ -"""MCP プラグインの Kiro 用 installer が default.json に `tools` を持たせることを固定する(#1873)。 - -Kiro CLI はエージェント定義の `tools` に挙がったツールだけをモデルへ見せる。欄が無いと -MCP サーバーへの接続は成功しても、MCP のツールを呼べない。 - -見るのは 3 つである。**新しく作る default.json に `tools: ["*"]` が入ること**・ -**既存の default.json に `tools` が無ければ足すこと**・**利用者が書いた `tools` は -上書きしないこと**。installer は `scripts/build-runtime-plugins.sh` が全 MCP プラグインへ -同じ内容で書き出すため、全プラグインの installer を通す。 -""" - -from __future__ import annotations - -import json -import os -import subprocess -from pathlib import Path - -import pytest - -ROOT = Path(__file__).resolve().parents[2] -INSTALLERS = sorted(ROOT.glob("plugins/mcp/mcp-*/dev.kiro/install.sh")) - - -def install(installer: Path, project: Path, home: Path) -> dict: - env = {**os.environ, "HOME": str(home)} - subprocess.run( - ["bash", str(installer), "--project", str(project)], - check=True, - capture_output=True, - text=True, - env=env, - ) - return json.loads((project / ".kiro/agents/default.json").read_text(encoding="utf-8")) - - -def test_installers_exist() -> None: - assert INSTALLERS - - -@pytest.mark.parametrize("installer", INSTALLERS, ids=lambda p: p.parents[1].name) -def test_new_agent_file_allows_all_tools(tmp_path: Path, installer: Path) -> None: - project = tmp_path / "project" - project.mkdir() - - agent = install(installer, project, tmp_path) - - assert agent["tools"] == ["*"] - - -def test_existing_agent_file_without_tools_gets_all_tools(tmp_path: Path) -> None: - project = tmp_path / "project" - agent_file = project / ".kiro/agents/default.json" - agent_file.parent.mkdir(parents=True) - agent_file.write_text(json.dumps({"name": "default", "mine": 1}), encoding="utf-8") - - agent = install(INSTALLERS[0], project, tmp_path) - - assert agent["tools"] == ["*"] - assert agent["mine"] == 1 - - -def test_user_tools_are_kept(tmp_path: Path) -> None: - project = tmp_path / "project" - agent_file = project / ".kiro/agents/default.json" - agent_file.parent.mkdir(parents=True) - agent_file.write_text(json.dumps({"name": "default", "tools": ["fs_read"]}), encoding="utf-8") - - agent = install(INSTALLERS[0], project, tmp_path) - - assert agent["tools"] == ["fs_read"] diff --git a/scripts/tests/test_pytest_child_cleanup.py b/scripts/tests/test_pytest_child_cleanup.py new file mode 100644 index 000000000..b21a3ee8d --- /dev/null +++ b/scripts/tests/test_pytest_child_cleanup.py @@ -0,0 +1,145 @@ +"""テストが起こした子孫のプロセスを、pytest を止めたときに残さない(#2291 の受け入れ条件 6・7、決定 4)。 + +- 打ち切る側(`test_triage.stop_group`)がグループを止めても、自分の session の子と、親が先に終わった孫が残らない +- 親だけに SIGTERM を送っても、xdist の worker とその子孫が残らない +- 子の回収役にしても、`subprocess` と `os.fork` の呼び手が受け取る終了コードは変わらない +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import textwrap +import threading +import time +from pathlib import Path + +import psutil +import pytest + +ROOT = Path(__file__).resolve().parents[2] +SCRIPTS = ROOT / "scripts" +sys.path.insert(0, str(SCRIPTS)) +sys.path.insert(0, str(ROOT / "plugins" / "ndf" / "scripts" / "lib")) + +import pytest_child_cleanup # noqa: E402 +import test_triage # noqa: E402 + +pytestmark = pytest.mark.skipif(not sys.platform.startswith("linux"), reason="子の回収役は Linux だけ") + + +def _bundle(tmp_path: Path) -> Path: + """自分の session の子と、親が先に終わる孫を起こして待つテスト 2 件の束。""" + b = tmp_path / "bundle" + b.mkdir() + (b / "pytest.ini").write_text("[pytest]\n") + (b / "conftest.py").write_text(f"import sys\nsys.path.insert(0, {str(SCRIPTS)!r})\npytest_plugins = ['pytest_child_cleanup']\n") + sleeper = f"{sys.executable} -c 'import time; time.sleep(60)' {b}" + (b / "test_spawn.py").write_text( + textwrap.dedent(f""" + import subprocess, sys, time, pytest + @pytest.mark.parametrize("n", [0, 1]) + def test_spawn(n): + subprocess.Popen([sys.executable, "-c", "import time; time.sleep(60)", {str(b)!r}], start_new_session=True) + subprocess.Popen(["setsid", "sh", "-c", {sleeper + " &"!r}]) + open({str(b)!r} + f"/ready-{{n}}", "w").close() + time.sleep(60) + """) + ) + return b + + +def _start(b: Path) -> subprocess.Popen: + p = subprocess.Popen( + [sys.executable, "-m", "pytest", str(b), "-q", "-n", "2", "-p", "no:cacheprovider"], + cwd=b, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + start_new_session=True, + ) + deadline = time.monotonic() + 60 + while not all((b / f"ready-{n}").exists() for n in (0, 1)): + assert time.monotonic() < deadline, "束のテストが子を起こすところまで進まない" + time.sleep(0.1) + time.sleep(0.5) # setsid の親が終わり、孫が付け替わるのを待つ + return p + + +def _left(b: Path) -> list[int]: + found = [] + for p in psutil.process_iter(["cmdline", "status"]): + try: + if p.info["status"] != psutil.STATUS_ZOMBIE and str(b) in " ".join(p.info["cmdline"] or []): + found.append(p.pid) + except psutil.Error: + pass + return found + + +def _assert_none_left(b: Path) -> None: + deadline = time.monotonic() + 10 + while (left := _left(b)) and time.monotonic() < deadline: + time.sleep(0.2) + for pid in left: + try: + os.kill(pid, signal.SIGKILL) + except OSError: + pass + assert left == [], f"止めた後も残っている: {left}" + + +def test_stopping_the_group_leaves_no_test_children(tmp_path): + b = _bundle(tmp_path) + p = _start(b) + test_triage.stop_group(p) + _assert_none_left(b) + + +def test_sigterm_to_the_parent_alone_stops_workers_and_their_children(tmp_path): + b = _bundle(tmp_path) + p = _start(b) + os.kill(p.pid, signal.SIGTERM) + p.wait(timeout=10) + _assert_none_left(b) + + +@pytest.mark.filterwarnings("ignore:This process:DeprecationWarning") +def test_exit_codes_reach_their_callers_under_the_reaper(): + assert pytest_child_cleanup._saved, "根の conftest が後始末の部品を置いていない" + codes: list[int] = [] + + def run(): + codes.extend(subprocess.run(["false"]).returncode for _ in range(200)) + + threads = [threading.Thread(target=run) for _ in range(4)] + for t in threads: + t.start() + run() + for t in threads: + t.join() + assert codes == [1] * 1000 + + pid = os.fork() + if pid == 0: + os._exit(7) + assert os.waitstatus_to_exitcode(os.waitpid(pid, 0)[1]) == 7 + + +def test_stopping_children_spares_itself_and_waits_for_them_together(tmp_path): + script = textwrap.dedent(f""" + import subprocess, sys, time + sys.path.insert(0, {str(SCRIPTS)!r}) + import pytest_child_cleanup as c + kids = [subprocess.Popen(["bash", "-c", "trap '' TERM; sleep 60"]) for _ in range(2)] + time.sleep(0.3) + start = time.monotonic() + c.stop_children() + print(round(time.monotonic() - start, 1), [k.poll() is not None for k in kids]) + """) + out = subprocess.run([sys.executable, "-c", script], capture_output=True, text=True, timeout=30) + assert out.returncode == 0, out.stderr + seconds, stopped = out.stdout.split(" ", 1) + assert float(seconds) < 5 + assert stopped.strip() == "[True, True]" diff --git a/scripts/tests/test_refactoring_codistribution.py b/scripts/tests/test_refactoring_codistribution.py index dd06e10a1..03f538f70 100644 --- a/scripts/tests/test_refactoring_codistribution.py +++ b/scripts/tests/test_refactoring_codistribution.py @@ -18,7 +18,7 @@ def test_the_manifests_are_found() -> None: """配る基準そのものが見つかること。**空の一覧で通さない。**""" - assert len(MANIFESTS) >= 4, [p.name for p in MANIFESTS] + assert len(MANIFESTS) >= 2, [p.name for p in MANIFESTS] @pytest.mark.parametrize("manifest", MANIFESTS, ids=lambda p: p.stem) diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 817fa5a7a..8fa10ed91 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -37,14 +37,14 @@ def _read_root_conftest() -> str: return ROOT_CONFTEST.read_text(encoding="utf-8") -def _run_pytest(target: str, *, path: str | None = None, env_extra: dict | None = None): +def _run_pytest(target: str, *args: str, path: str | None = None, env_extra: dict | None = None): env = dict(os.environ) env.pop("NDF_TESTS_ALLOW_MISSING_COMMANDS", None) if path is not None: env["PATH"] = path env.update(env_extra or {}) return subprocess.run( - [sys.executable, "-m", "pytest", target, "-q", "--no-header", "-p", "no:cacheprovider"], + [sys.executable, "-m", "pytest", target, *args, "-q", "--no-header", "-p", "no:cacheprovider"], cwd=str(REPO_ROOT), capture_output=True, text=True, @@ -71,11 +71,6 @@ def _path_without(tmp_path: Path, *names: str) -> str: return str(bin_dir) -def test_the_root_conftest_declares_the_plugin() -> None: - """`pytest_plugins` は最上位の conftest.py でしか宣言できない。""" - assert 'pytest_plugins = ["pytester"]' in _read_root_conftest() - - def test_the_skill_conftest_no_longer_declares_the_plugin() -> None: body = (REPO_ROOT / "plugins/playwright-kit/skills/playwright-kit-ops/tests/conftest.py").read_text(encoding="utf-8") assert 'pytest_plugins = ["pytester"]' not in body @@ -98,18 +93,11 @@ def test_no_bundle_skips_itself_when_a_command_is_missing() -> None: @pytest.mark.skipif(shutil.which("jq") is None, reason="jq を外した PATH を組み立てられない") def test_a_missing_command_fails_the_collection(tmp_path: Path) -> None: - """前提のコマンドが無ければ、読み飛ばさずに 0 以外の終了コードで終わる。""" - result = _run_pytest(BUNDLE, path=_path_without(tmp_path, "jq")) - - assert result.returncode != 0 - assert "jq" in result.stdout + result.stderr - - -@pytest.mark.skipif(shutil.which("jq") is None, reason="jq を外した PATH を組み立てられない") -def test_the_failure_names_the_bundle_and_the_command(tmp_path: Path) -> None: + """前提のコマンドが無ければ、読み飛ばさずに 0 以外の終了コードで終わり、束とコマンドの名前を出す。""" result = _run_pytest(BUNDLE, path=_path_without(tmp_path, "jq")) out = result.stdout + result.stderr + assert result.returncode != 0 assert BUNDLE in out assert "jq" in out @@ -138,8 +126,8 @@ def test_the_outside_bundle_is_really_outside_the_table() -> None: def test_a_bundle_outside_the_table_is_not_checked(tmp_path: Path) -> None: - """一覧に無い束だけを収集したときは、前提を確かめない。""" - result = _run_pytest(OUTSIDE_BUNDLE, path=_path_without(tmp_path, "jq")) + """一覧に無い束だけを収集したときは、前提を確かめない(確かめるのは収集の時点なので、収集だけで足りる)。""" + result = _run_pytest(OUTSIDE_BUNDLE, "--collect-only", path=_path_without(tmp_path, "jq")) assert result.returncode == 0, result.stdout + result.stderr @@ -269,14 +257,6 @@ def test_the_isolation_holds_from_a_bundle_directory() -> None: 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 - - # ジョブの分割(#882)。**分けても項目が欠けず、重ならず、ファイルが割れないこと**を、 # 実際の収集で確かめる。分割が項目を落としても各ジョブと集約のジョブは成功し得るため、 # 継続的統合の結果だけでは退行に気づけない。 diff --git a/scripts/tests/test_runtime_smoke_latest.py b/scripts/tests/test_runtime_smoke_latest.py index 0dbaaeb90..243781bd0 100644 --- a/scripts/tests/test_runtime_smoke_latest.py +++ b/scripts/tests/test_runtime_smoke_latest.py @@ -15,8 +15,6 @@ REPO = Path(__file__).resolve().parents[2] SCRIPT = REPO / "scripts" / "runtime-smoke-latest.py" -SHA256 = {"x86_64": "a" * 64, "aarch64": "b" * 64} -SHA512 = {"linux_amd64": "c" * 128, "linux_arm64": "d" * 128} @pytest.fixture @@ -28,34 +26,10 @@ def mod(monkeypatch): return m -def sources(claude="2.1.300", codex="0.163.0", kiro="2.32.0", agy="1.3.5", agy_arm=None): - agy_arm = agy_arm or agy - - def kiro_pkg(arch, ext="tar.gz", sha=None): - return {"download": f"{kiro}/kirocli-{arch}-linux-musl.{ext}", "sha256": sha or SHA256[arch]} - +def sources(claude="2.1.300", codex="0.163.0"): return { "https://registry.npmjs.org/-/package/@anthropic-ai/claude-code/dist-tags": {"latest": claude, "next": "9.9.9"}, "https://registry.npmjs.org/-/package/@openai/codex/dist-tags": {"latest": codex, "alpha": "0.200.0-alpha.1"}, - "https://prod.download.cli.kiro.dev/stable/latest/manifest.json": { - "version": kiro, - "packages": [ - kiro_pkg("x86_64", "tar.xz", "e" * 64), - kiro_pkg("x86_64"), - kiro_pkg("aarch64"), - {"download": f"{kiro}/kirocli-x86_64-linux.tar.gz", "sha256": "f" * 64}, - ], - }, - "https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/linux_amd64.json": { - "version": agy, - "url": f"https://example.invalid/antigravity-cli/{agy}-1/linux-x64/cli_linux_x64.tar.gz", - "sha512": SHA512["linux_amd64"], - }, - "https://antigravity-cli-auto-updater-974169037036.us-central1.run.app/manifests/linux_arm64.json": { - "version": agy_arm, - "url": f"https://example.invalid/antigravity-cli/{agy_arm}-1/linux-arm/cli_linux_arm64.tar.gz", - "sha512": SHA512["linux_arm64"], - }, } @@ -80,14 +54,6 @@ def test_resolve_all_emits_versions_list_shape(mod, monkeypatch, capsys): assert pairs == { "CLAUDE_CODE_VERSION": "2.1.300", "CODEX_VERSION": "0.163.0", - "KIRO_CLI_VERSION": "2.32.0", - "KIRO_CLI_SHA256_X86_64": SHA256["x86_64"], - "KIRO_CLI_SHA256_AARCH64": SHA256["aarch64"], - "AGY_VERSION": "1.3.5", - "AGY_URL_X86_64": "https://example.invalid/antigravity-cli/1.3.5-1/linux-x64/cli_linux_x64.tar.gz", - "AGY_URL_AARCH64": "https://example.invalid/antigravity-cli/1.3.5-1/linux-arm/cli_linux_arm64.tar.gz", - "AGY_SHA512_X86_64": SHA512["linux_amd64"], - "AGY_SHA512_AARCH64": SHA512["linux_arm64"], } # 一覧と同じキーの集合を出す(版を上げる手順で写せる) listed = REPO / "tests" / "runtime-smoke" / "cli-versions.env" @@ -105,11 +71,9 @@ def test_resolve_one_runtime_emits_only_its_keys(mod, monkeypatch, capsys): "runtime, table", [ ("claude", sources(claude="latest")), - ("kiro", {**sources(), "https://prod.download.cli.kiro.dev/stable/latest/manifest.json": {"version": "2.32.0", "packages": []}}), - ("agy", sources(agy_arm="1.3.6")), - ("agy", {k: v for k, v in sources().items() if "linux_arm64" not in k}), + ("codex", {k: v for k, v in sources().items() if "@openai/codex" not in k}), ], - ids=["bad-version", "kiro-no-musl", "agy-arch-mismatch", "unreachable"], + ids=["bad-version", "unreachable"], ) def test_resolve_fails_with_source_url(mod, monkeypatch, capsys, runtime, table): use_sources(mod, monkeypatch, table) @@ -146,25 +110,25 @@ def test_report_creates_issue_with_runtime_versions_and_run_url(mod, monkeypatch gh = FakeGh([{"number": 5, "title": "最新の版のスモークが落ちた: codex"}]) monkeypatch.setattr(mod, "run_gh", gh) versions = tmp_path / "v.env" - versions.write_text("AGY_VERSION=1.3.5\n", encoding="utf-8") - assert run(mod, "report", "--runtime", "agy", "--run-url", RUN_URL, "--versions", str(versions), "--repo", "o/r") == 0 + versions.write_text("CLAUDE_CODE_VERSION=2.1.300\n", encoding="utf-8") + assert run(mod, "report", "--runtime", "claude", "--run-url", RUN_URL, "--versions", str(versions), "--repo", "o/r") == 0 assert gh.kinds() == [["issue", "list"], ["issue", "create"]] args, body = gh.calls[1] - assert args[args.index("--title") + 1] == "最新の版のスモークが落ちた: agy" + assert args[args.index("--title") + 1] == "最新の版のスモークが落ちた: claude" assert args[args.index("--repo") + 1] == "o/r" - assert "agy" in body and "AGY_VERSION=1.3.5" in body and RUN_URL in body + assert "claude" in body and "CLAUDE_CODE_VERSION=2.1.300" in body and RUN_URL in body assert not any(word in body for word in ("Closes", "Fixes", "Resolves")) def test_report_comments_on_open_issue_instead_of_creating(mod, monkeypatch, tmp_path): gh = FakeGh( [ - {"number": 7, "title": "最新の版のスモークが落ちた: agy"}, - {"number": 8, "title": "最新の版のスモークが落ちた: agy (old)"}, + {"number": 7, "title": "最新の版のスモークが落ちた: codex"}, + {"number": 8, "title": "最新の版のスモークが落ちた: codex (old)"}, ] ) monkeypatch.setattr(mod, "run_gh", gh) - assert run(mod, "report", "--runtime", "agy", "--run-url", RUN_URL) == 0 + assert run(mod, "report", "--runtime", "codex", "--run-url", RUN_URL) == 0 assert gh.kinds() == [["issue", "list"], ["issue", "comment"]] args, body = gh.calls[1] assert args[2] == "7" @@ -173,5 +137,5 @@ def test_report_comments_on_open_issue_instead_of_creating(mod, monkeypatch, tmp def test_report_fails_when_gh_fails(mod, monkeypatch, capsys): monkeypatch.setattr(mod, "run_gh", FakeGh([], fail=True)) - assert run(mod, "report", "--runtime", "kiro", "--run-url", RUN_URL) == 1 + assert run(mod, "report", "--runtime", "codex", "--run-url", RUN_URL) == 1 assert "gh" in capsys.readouterr().err diff --git a/scripts/tests/test_runtime_smoke_versions.py b/scripts/tests/test_runtime_smoke_versions.py index 87c6874d6..f572a3d85 100644 --- a/scripts/tests/test_runtime_smoke_versions.py +++ b/scripts/tests/test_runtime_smoke_versions.py @@ -1,6 +1,6 @@ """スモークが入れる CLI の版の固定と、最新の版のスモークの workflow の形(#1982 の I2・I3・I5)。 -- I2: CLI の版の一覧(`tests/runtime-smoke/cli-versions.env`)のキーの集合が、4 つの Containerfile が +- I2: CLI の版の一覧(`tests/runtime-smoke/cli-versions.env`)のキーの集合が、2 つの Containerfile が 宣言する `ARG` の名前の和集合と一致し、各 `ARG` は接頭辞のランタイムの Containerfile にだけ現れ、 値は空でなく、`ARG` は既定値を持たない - I3: 配布の経路の取得先は版数を含む取得先だけ(`latest`・公式のインストーラを使わない) @@ -20,7 +20,7 @@ SMOKE = REPO / "tests" / "runtime-smoke" VERSIONS = SMOKE / "cli-versions.env" LATEST_WORKFLOW = REPO / ".github" / "workflows" / "runtime-plugin-smoke-latest.yml" -PREFIXES = {"claude": "CLAUDE_CODE_", "codex": "CODEX_", "kiro": "KIRO_CLI_", "agy": "AGY_"} +PREFIXES = {"claude": "CLAUDE_CODE_", "codex": "CODEX_"} ARG_RE = re.compile(r"^ARG\s+([A-Za-z_][A-Za-z0-9_]*)(=.*)?\s*$", re.MULTILINE) @@ -64,10 +64,8 @@ def test_versions_match_containerfile_args(): def test_versions_are_concrete(): versions = read_versions(VERSIONS) - for key in ("CLAUDE_CODE_VERSION", "CODEX_VERSION", "KIRO_CLI_VERSION", "AGY_VERSION"): + for key in ("CLAUDE_CODE_VERSION", "CODEX_VERSION"): assert re.fullmatch(r"[0-9]+(\.[0-9]+)+", versions[key]), f"{key} が版数でない: {versions[key]}" - for key in ("AGY_URL_X86_64", "AGY_URL_AARCH64"): - assert f"/{versions['AGY_VERSION']}-" in versions[key], f"{key} が AGY_VERSION の版を含まない" @pytest.mark.parametrize("runtime", sorted(PREFIXES)) diff --git a/scripts/tests/test_skill_frontmatter_listing.py b/scripts/tests/test_skill_frontmatter_listing.py index 76865bc73..60a12ddd5 100644 --- a/scripts/tests/test_skill_frontmatter_listing.py +++ b/scripts/tests/test_skill_frontmatter_listing.py @@ -1,7 +1,7 @@ """check-skill-frontmatter.py の measure_aggregate が数える初期一覧の文字数を固定する。 配布先ごとの数え方(Claude Code は description と when_to_use を連結して切り詰め、 -パスを載せない。Codex / Kiro / agy は description だけでパスを載せる)と、 +パスを載せない。Codex は description だけでパスを載せる)と、 manifest に無い Skill を数えないこと、frontmatter 総量を固定する。 """ @@ -37,7 +37,7 @@ def _skill(dir_name: str, block: str, **fm) -> dict: def test_counts_name_description_and_path_per_runtime(tmp_path): m = _load() - skills_dir = _tree(tmp_path, {"claude": ["alpha"], "codex": ["alpha"], "kiro": ["alpha"], "agy": ["alpha"]}) + skills_dir = _tree(tmp_path, {"claude": ["alpha"], "codex": ["alpha"]}) skills = [_skill("alpha", "x" * 10, name="alpha", description="desc", when_to_use="wtu")] rel = len("skills/alpha/SKILL.md") got = m.measure_aggregate(skills, skills_dir) @@ -45,8 +45,6 @@ def test_counts_name_description_and_path_per_runtime(tmp_path): "listings": { "claude": len("alpha") + len("descwtu"), "codex": len("alpha") + len("desc") + rel, - "kiro": len("alpha") + len("desc") + rel, - "agy": len("alpha") + len("desc") + rel, }, "frontmatter_total": 10, } @@ -83,7 +81,7 @@ def test_missing_frontmatter_falls_back_to_dir_name(tmp_path): def test_manifest_comments_are_ignored(tmp_path): m = _load() skills_dir = _tree(tmp_path, {}) - (skills_dir.parent / "manifests/kiro-skills.txt").write_text("# header\nalpha # note\n\n", encoding="utf-8") + (skills_dir.parent / "manifests/codex-skills.txt").write_text("# header\nalpha # note\n\n", encoding="utf-8") skills = [_skill("alpha", "", name="alpha", description="dd")] got = m.measure_aggregate(skills, skills_dir) - assert got["listings"] == {"kiro": len("alpha") + 2 + len("skills/alpha/SKILL.md")} + assert got["listings"] == {"codex": len("alpha") + 2 + len("skills/alpha/SKILL.md")} diff --git a/scripts/tests/test_validate_agy_manifest.py b/scripts/tests/test_validate_agy_manifest.py deleted file mode 100644 index 33965d8fc..000000000 --- a/scripts/tests/test_validate_agy_manifest.py +++ /dev/null @@ -1,98 +0,0 @@ -"""agy の定義ファイルをチェックの対象へ入れる(#215 の受け入れ条件 A9)。 - -版数を持つ箇所は 13 から 15 へ増える(`dev.agy/plugin.json` の `version` と `description`)。 -agy には取得元の登録が無く、`agy plugin list` も版数を出さないため、利用者が版を判断できる -手がかりは clone した中身の版数だけである。古いまま残らないよう、Claude 版 `plugin.json` を -基準に突き合わせる。 - -**記載を消してチェックを通せない形にする。** Skill 数を読み取れないこと自体も失敗として扱う。 -""" - -from __future__ import annotations - -import json -from pathlib import Path - -from validate_manifests_helpers import ( - MANIFESTS, - VERSION, - build_tree, - description, - output_of, - run_check, -) - -AGY_PLUGIN_JSON = "plugins/ndf/dev.agy/plugin.json" - - -def agy_manifest(root: Path) -> Path: - return root / AGY_PLUGIN_JSON - - -def test_matching_agy_manifest_passes(tmp_path: Path) -> None: - """版数と Skill 数が揃っていれば通る。""" - result = run_check(build_tree(tmp_path), tmp_path) - assert result.returncode == 0, output_of(result) - - -def test_old_version_fails(tmp_path: Path) -> None: - """`version` が Claude 版と食い違うと落ちる。""" - root = build_tree(tmp_path) - path = agy_manifest(root) - payload = json.loads(path.read_text(encoding="utf-8")) - payload["version"] = "9.2.0" - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - result = run_check(root, tmp_path) - assert result.returncode == 1 - assert f"{AGY_PLUGIN_JSON} の version が claude 版と食い違う" in output_of(result) - - -def test_old_version_in_description_fails(tmp_path: Path) -> None: - """`description` の版数だけが古い場合も落ちる。""" - root = build_tree(tmp_path) - path = agy_manifest(root) - payload = json.loads(path.read_text(encoding="utf-8")) - payload["description"] = description("9.2.0", len(MANIFESTS["agy"])) - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - result = run_check(root, tmp_path) - assert result.returncode == 1 - assert f"{AGY_PLUGIN_JSON} の description の版数が古い" in output_of(result) - - -def test_wrong_skill_count_fails(tmp_path: Path) -> None: - """`description` の Skill 数は `agy-skills.txt` の行数と突き合わせる。""" - root = build_tree(tmp_path) - path = agy_manifest(root) - payload = json.loads(path.read_text(encoding="utf-8")) - payload["description"] = description(VERSION, len(MANIFESTS["agy"]) + 1) - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - result = run_check(root, tmp_path) - assert result.returncode == 1 - output = output_of(result) - assert f"{AGY_PLUGIN_JSON} の description の Skill 数が食い違う" in output - assert "agy-skills.txt" in output - - -def test_removed_skill_count_fails(tmp_path: Path) -> None: - """Skill 数の記載を消してもチェックは通らない。""" - root = build_tree(tmp_path) - path = agy_manifest(root) - payload = json.loads(path.read_text(encoding="utf-8")) - payload["description"] = f"Fixture plugin (v{VERSION}): nothing countable here." - path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - result = run_check(root, tmp_path) - assert result.returncode == 1 - assert f"{AGY_PLUGIN_JSON} の description から Skill 数を読み取れない" in output_of(result) - - -def test_family_without_dev_agy_is_skipped(tmp_path: Path) -> None: - """`dev.agy/` を持たない family は素通りする。agy へ配らないプラグインを巻き込まない。""" - root = build_tree(tmp_path) - agy_manifest(root).unlink() - - result = run_check(root, tmp_path) - assert result.returncode == 0, output_of(result) diff --git a/scripts/tests/test_validate_manifests_version.py b/scripts/tests/test_validate_manifests_version.py index f7b383082..72759ae54 100644 --- a/scripts/tests/test_validate_manifests_version.py +++ b/scripts/tests/test_validate_manifests_version.py @@ -3,16 +3,11 @@ 開発版の版数には接尾辞(`-dev.<連番>` / `-rc.<連番>`)が付く(`docs/versioning-and-distribution.md` の「版の付け方と開発版の配布」)。数字 3 つの直後に閉じ括弧を求める書式では `(v9.7.0-dev.1)` に一致せず、開発版を出すたびにこのチェックで止まっていた(#254)。 - -あわせて、agy の定義ファイル(`plugins/ndf/dev.agy/plugin.json`)の版数が -Claude 版と突き合わされることを固定する(#215 の受け入れ条件 A11)。 """ from __future__ import annotations -import json from pathlib import Path -from typing import Any, Callable import pytest @@ -50,7 +45,6 @@ def test_prerelease_version_dropped_in_description_fails(tmp_path: Path) -> None for label in ( "plugins/ndf/.claude-plugin/plugin.json", "plugins/ndf/.codex-plugin/plugin.json", - "plugins/ndf/dev.agy/plugin.json", ".claude-plugin/marketplace.json の ndf", ): assert f"{label} の description の版数が古い" in output, output @@ -96,95 +90,3 @@ def test_skill_count_is_read_beside_the_version(tmp_path: Path, version: str) -> "plugins/ndf/.claude-plugin/plugin.json の description の Skill 数が食い違う" "(description: 4 / claude-skills.txt: 5)" in output_of(result) ) - - -# --- agy の定義ファイルの版数(#215 の受け入れ条件 A11) --- -# -# `plugins/ndf/dev.agy/plugin.json` は `version` と `description` の 2 箇所に版数を持つ。 -# どちらも Claude 版の `plugin.json` と突き合わせる。**片方だけ直した状態で配れないこと**を、 -# 古い値と記載の欠落の両方で確かめる。 - -AGY_MANIFEST = "plugins/ndf/dev.agy/plugin.json" - - -def rewrite_agy_manifest(root: Path, mutate: Callable[[dict[str, Any]], None]) -> None: - """agy の定義ファイルだけを崩す。他のランタイムの定義には触らない。""" - path = root / AGY_MANIFEST - manifest = json.loads(path.read_text(encoding="utf-8")) - mutate(manifest) - path.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") - - -def test_agy_consistent_manifest_passes(tmp_path: Path) -> None: - """崩していない木では、agy の定義ファイルも含めて通る。""" - root = build_tree(tmp_path) - assert (root / AGY_MANIFEST).is_file() - result = run_check(root, tmp_path) - assert result.returncode == 0, output_of(result) - - -def test_agy_stale_version_fails(tmp_path: Path) -> None: - """`version` が前の版のままなら落ちる。""" - root = build_tree(tmp_path) - rewrite_agy_manifest(root, lambda m: m.update(version="9.2.1")) - - result = run_check(root, tmp_path) - - assert result.returncode == 1 - out = output_of(result) - assert f"{AGY_MANIFEST} の version が claude 版と食い違う" in out, out - assert "9.2.1" in out and helpers.VERSION in out, out - - -def test_agy_missing_version_fails(tmp_path: Path) -> None: - """`version` の記載を消しても通らない。読み取れないこと自体を失敗として扱う。""" - root = build_tree(tmp_path) - rewrite_agy_manifest(root, lambda m: m.pop("version")) - - result = run_check(root, tmp_path) - - assert result.returncode == 1 - assert f"{AGY_MANIFEST} の version が claude 版と食い違う" in output_of(result) - - -def test_agy_stale_version_in_description_fails(tmp_path: Path) -> None: - """`description` の版数だけが前の版でも落ちる。""" - root = build_tree(tmp_path) - rewrite_agy_manifest( - root, - lambda m: m.update(description=m["description"].replace(f"(v{helpers.VERSION})", "(v9.2.1)")), - ) - - result = run_check(root, tmp_path) - - assert result.returncode == 1 - out = output_of(result) - assert f"{AGY_MANIFEST} の description の版数が古い" in out, out - - -def test_agy_missing_version_in_description_fails(tmp_path: Path) -> None: - """`description` から版数を消しても通らない。""" - root = build_tree(tmp_path) - rewrite_agy_manifest( - root, - lambda m: m.update(description=m["description"].replace(f"(v{helpers.VERSION})", "")), - ) - - result = run_check(root, tmp_path) - - assert result.returncode == 1 - assert f"{AGY_MANIFEST} の description に `(vX.Y.Z)` 形式の版数がない" in output_of(result) - - -def test_agy_skill_count_is_read_from_its_own_manifest(tmp_path: Path) -> None: - """Skill 数の突き合わせ先は `agy-skills.txt` である。他のランタイムの数と取り違えない。""" - root = build_tree(tmp_path) - rewrite_agy_manifest( - root, - lambda m: m.update(description=m["description"].replace("2 focused skills", "3 focused skills")), - ) - - result = run_check(root, tmp_path) - - assert result.returncode == 1 - assert f"{AGY_MANIFEST} の description の Skill 数が食い違う(description: 3 / agy-skills.txt: 2)" in output_of(result) diff --git a/scripts/tests/test_validate_mcp_codex_servers.py b/scripts/tests/test_validate_mcp_codex_servers.py index f1cb6c04d..f15c5ff35 100644 --- a/scripts/tests/test_validate_mcp_codex_servers.py +++ b/scripts/tests/test_validate_mcp_codex_servers.py @@ -16,13 +16,12 @@ def _mcp_plugin(root: Path, declared, files=(".mcp.json",)) -> None: plugin = root / "plugins/mcp/mcp-x" - for rel in (".claude-plugin", ".codex-plugin", "dev.kiro"): + for rel in (".claude-plugin", ".codex-plugin"): (plugin / rel).mkdir(parents=True) for name in files: (plugin / name).write_text('{"mcpServers": {}}\n', encoding="utf-8") (plugin / ".claude-plugin/plugin.json").write_text('{"name": "mcp-x"}\n', encoding="utf-8") (plugin / ".codex-plugin/plugin.json").write_text(json.dumps({"name": "mcp-x", "mcpServers": declared}), encoding="utf-8") - (plugin / "dev.kiro/install.sh").write_text("#!/bin/sh\n", encoding="utf-8") @pytest.mark.parametrize( diff --git a/scripts/tests/validate_manifests_helpers.py b/scripts/tests/validate_manifests_helpers.py index 5181936ff..5ee312ff0 100644 --- a/scripts/tests/validate_manifests_helpers.py +++ b/scripts/tests/validate_manifests_helpers.py @@ -29,7 +29,6 @@ MANIFESTS = { "claude": ["alpha", "bravo", "charlie", "delta", "echo"], "codex": ["alpha", "bravo", "charlie"], - "agy": ["alpha", "bravo"], } @@ -79,23 +78,6 @@ def build_tree(base: Path, version: str = VERSION, described: str | None = None) encoding="utf-8", ) - # agy 向けの定義はクライアント拡張ディレクトリに置く。Skill の絞り込みを持たないため、 - # 突き合わせ先は agy-skills.txt の行数である。 - (ndf / "dev.agy").mkdir(parents=True) - (ndf / "dev.agy/plugin.json").write_text( - json.dumps( - { - "name": FAMILY, - "version": version, - "description": description(described, len(MANIFESTS["agy"])), - }, - ensure_ascii=False, - indent=2, - ) - + "\n", - encoding="utf-8", - ) - (root / ".claude-plugin").mkdir(parents=True) (root / ".claude-plugin/marketplace.json").write_text( json.dumps( diff --git a/scripts/validate-runtime-plugins.sh b/scripts/validate-runtime-plugins.sh index 74b5ff6de..e852f77de 100755 --- a/scripts/validate-runtime-plugins.sh +++ b/scripts/validate-runtime-plugins.sh @@ -12,7 +12,7 @@ run bash "$ROOT_DIR/scripts/build-runtime-plugins.sh" --check # Skill を配る plugin family を manifests/ の有無から検出する(plugins/mcp/* は別系統)。 # 固定リストを置かないのは、family を足したときにチェック対象から漏れる経路を作らないためである。 -# 後段の静的解析(claude plugin validate / Kiro installer の dry-run)もこの一覧で回す。 +# 後段の静的解析(claude plugin validate)もこの一覧で回す。 FAMILIES=() for plugin_dir in "$ROOT_DIR"/plugins/*; do [ -d "$plugin_dir/manifests" ] || continue @@ -36,14 +36,6 @@ for family in "${FAMILIES[@]}"; do run python3 -m json.tool "$root_manifest" >/dev/null done -for family in "${FAMILIES[@]}"; do - for agy_manifest in "$ROOT_DIR/plugins/$family/dev.agy/plugin.json" \ - "$ROOT_DIR/plugins/$family/dev.agy/hooks.json"; do - [ -f "$agy_manifest" ] || continue - run python3 -m json.tool "$agy_manifest" >/dev/null - done -done - while IFS= read -r mcp_config; do run python3 -m json.tool "$mcp_config" >/dev/null done < <(find "$ROOT_DIR/plugins/mcp" -maxdepth 2 -name .mcp.json | sort) @@ -60,32 +52,6 @@ else echo "==> claude CLI not found; skipped claude plugin validate" fi -# agy はクライアント拡張ディレクトリを 1 つのプラグインとして読む。CLI が無い環境では -# 読み飛ばす(claude plugin validate と同じ扱い)。 -if command -v agy >/dev/null 2>&1; then - for family in "${FAMILIES[@]}"; do - [ -f "$ROOT_DIR/plugins/$family/dev.agy/plugin.json" ] || continue - run agy plugin validate "$ROOT_DIR/plugins/$family/dev.agy" - done -else - echo "==> agy CLI not found; skipped agy plugin validate" -fi - -# Kiro の installer は Agent Plugins 仕様 §8.2 のクライアント拡張ディレクトリに置く。 -for family in "${FAMILIES[@]}"; do - installer="$ROOT_DIR/plugins/$family/dev.kiro/install.sh" - [ -f "$installer" ] || continue - run bash "$installer" --dry-run >/dev/null -done - -# --with-codex を持つのは NDF の installer だけ(Codex 向け Skill も併せて配置する経路)。 -# family 共通の引数ではないため、ここだけは対象を明示してチェックする。 -run bash "$ROOT_DIR/plugins/ndf/dev.kiro/install.sh" --dry-run --with-codex >/dev/null - -while IFS= read -r installer; do - run bash "$installer" --dry-run >/dev/null -done < <(find "$ROOT_DIR/plugins/mcp" -path '*/dev.kiro/install.sh' | sort) - run python3 "$ROOT_DIR/scripts/check-markdown-links.py" --root "$ROOT_DIR" # 説明文書(README.md / plugins/ndf/README.md)に書かれた Skill 数と版数を、 diff --git a/tests/runtime-smoke/Containerfile.agy b/tests/runtime-smoke/Containerfile.agy deleted file mode 100644 index f383e4deb..000000000 --- a/tests/runtime-smoke/Containerfile.agy +++ /dev/null @@ -1,30 +0,0 @@ -FROM ai-plugins-runtime-smoke-base - -# 版・取得先・checksum は tests/runtime-smoke/cli-versions.env に書き、scripts/runtime-smoke-test.sh が -# build-arg で渡す。ARG に既定値を持たせない(版の出所を一覧の 1 つにする)。 -# 公式のインストーラは最新の manifest だけを読むため使わず、manifest に載る版つきの url を取り、 -# 一覧の sha512 で確かめて入れる。取得・checksum・版の照合のどれかが落ちたら像の作成を落とす。 -ARG AGY_VERSION -ARG AGY_URL_X86_64 -ARG AGY_URL_AARCH64 -ARG AGY_SHA512_X86_64 -ARG AGY_SHA512_AARCH64 - -RUN set -eu; \ - test -n "$AGY_VERSION"; \ - arch="$(uname -m)"; \ - case "$arch" in \ - x86_64) url="$AGY_URL_X86_64"; sha512="$AGY_SHA512_X86_64" ;; \ - aarch64) url="$AGY_URL_AARCH64"; sha512="$AGY_SHA512_AARCH64" ;; \ - *) echo "対応していない arch: $arch" >&2; exit 1 ;; \ - esac; \ - test -n "$url"; \ - test -n "$sha512"; \ - work="$(mktemp -d)"; \ - curl -fsSL -o "$work/agy.tar.gz" "$url"; \ - echo "$sha512 $work/agy.tar.gz" | sha512sum -c -; \ - tar -xzf "$work/agy.tar.gz" -C "$work" antigravity; \ - install -m 755 "$work/antigravity" /usr/local/bin/agy; \ - rm -rf "$work"; \ - actual="$(agy --version | grep -oE '[0-9]+(\.[0-9]+)+' | head -n 1)"; \ - [ "$actual" = "$AGY_VERSION" ] || { echo "agy の版が一覧と違う: $actual != $AGY_VERSION" >&2; exit 1; } diff --git a/tests/runtime-smoke/Containerfile.kiro b/tests/runtime-smoke/Containerfile.kiro deleted file mode 100644 index 27f4aea5f..000000000 --- a/tests/runtime-smoke/Containerfile.kiro +++ /dev/null @@ -1,28 +0,0 @@ -FROM ai-plugins-runtime-smoke-base - -# 版と checksum は tests/runtime-smoke/cli-versions.env に書き、scripts/runtime-smoke-test.sh が -# build-arg で渡す。ARG に既定値を持たせない(版の出所を一覧の 1 つにする)。 -# 公式のインストーラは版を受けず、bookworm の glibc では glibc 版が動かないため、版つきの musl 版の -# tarball を取り、一覧の sha256 で確かめて入れる。取得・checksum・版の照合のどれかが落ちたら像の作成を落とす。 -ARG KIRO_CLI_VERSION -ARG KIRO_CLI_SHA256_X86_64 -ARG KIRO_CLI_SHA256_AARCH64 - -RUN set -eu; \ - test -n "$KIRO_CLI_VERSION"; \ - arch="$(uname -m)"; \ - case "$arch" in \ - x86_64) sha256="$KIRO_CLI_SHA256_X86_64" ;; \ - aarch64) sha256="$KIRO_CLI_SHA256_AARCH64" ;; \ - *) echo "対応していない arch: $arch" >&2; exit 1 ;; \ - esac; \ - test -n "$sha256"; \ - work="$(mktemp -d)"; \ - curl -fsSL -o "$work/kirocli.tar.gz" \ - "https://prod.download.cli.kiro.dev/stable/${KIRO_CLI_VERSION}/kirocli-${arch}-linux-musl.tar.gz"; \ - echo "$sha256 $work/kirocli.tar.gz" | sha256sum -c -; \ - tar -xzf "$work/kirocli.tar.gz" -C "$work"; \ - install -m 755 "$work/kirocli/bin/kiro-cli" "$work/kirocli/bin/kiro-cli-chat" "$work/kirocli/bin/kiro-cli-term" /usr/local/bin/; \ - rm -rf "$work"; \ - actual="$(kiro-cli --version | grep -oE '[0-9]+(\.[0-9]+)+' | head -n 1)"; \ - [ "$actual" = "$KIRO_CLI_VERSION" ] || { echo "kiro-cli の版が一覧と違う: $actual != $KIRO_CLI_VERSION" >&2; exit 1; } diff --git a/tests/runtime-smoke/README.md b/tests/runtime-smoke/README.md index f4ddbd985..11644129f 100644 --- a/tests/runtime-smoke/README.md +++ b/tests/runtime-smoke/README.md @@ -1,13 +1,11 @@ # Runtime Smoke Tests -`scripts/runtime-smoke-test.sh` runs Claude, Codex, Kiro, and agy plugin smoke tests in disposable Docker containers. The host home directory and credential directories are not mounted. +`scripts/runtime-smoke-test.sh` runs Claude and Codex plugin smoke tests in disposable Docker containers. The host home directory and credential directories are not mounted. ```bash bash scripts/runtime-smoke-test.sh bash scripts/runtime-smoke-test.sh --runtime claude bash scripts/runtime-smoke-test.sh --runtime codex -bash scripts/runtime-smoke-test.sh --runtime kiro -bash scripts/runtime-smoke-test.sh --runtime agy ``` Artifacts are written to `tmp/runtime-smoke//` by default: @@ -37,16 +35,14 @@ The authenticated GitHub workflow passes only `ANTHROPIC_API_KEY` and `OPENAI_AP ## CLI の版 -像は 4 ランタイムの CLI を、CLI の版の一覧 `tests/runtime-smoke/cli-versions.env` に書いた版で入れる。版を上げるときに書き換えるのはこのファイルだけで、Containerfile は版を持たない(`ARG` に既定値を置かない)。 +像は 2 ランタイムの CLI を、CLI の版の一覧 `tests/runtime-smoke/cli-versions.env` に書いた版で入れる。版を上げるときに書き換えるのはこのファイルだけで、Containerfile は版を持たない(`ARG` に既定値を置かない)。 | ランタイム | 一覧のキー | 取得先 | | --- | --- | --- | | claude | `CLAUDE_CODE_VERSION` | npm の `@anthropic-ai/claude-code@<版>` | | codex | `CODEX_VERSION` | npm の `@openai/codex@<版>` | -| kiro | `KIRO_CLI_VERSION`・`KIRO_CLI_SHA256_` | `https://prod.download.cli.kiro.dev/stable/<版>/kirocli--linux-musl.tar.gz`(sha256 で確かめる) | -| agy | `AGY_VERSION`・`AGY_URL_`・`AGY_SHA512_` | 一覧の url(sha512 で確かめる) | -`` は `uname -m` の値を大文字にしたもの(`X86_64`・`AARCH64`)。`scripts/runtime-smoke-test.sh` は一覧の各行を `--build-arg` で像の作成へ渡し、`--cli-versions <パス>` で別の一覧を受ける。像の作成は、取得・checksum・`--version` の出力から取り出した版数と一覧の版数の完全一致のどれかが落ちたら失敗する。 +`scripts/runtime-smoke-test.sh` は一覧の各行を `--build-arg` で像の作成へ渡し、`--cli-versions <パス>` で別の一覧を受ける。像の作成は、取得・`--version` の出力から取り出した版数と一覧の版数の完全一致のどれかが落ちたら失敗する。 配布の経路のスモーク(`runtime-plugin-smoke.yml` の `runtime-smoke (<ランタイム>)`)と認証つきのスモークは、一覧の版だけを入れる。その時点の最新の版は、別の workflow `runtime-plugin-smoke-latest.yml` が毎日 06:00 JST に develop の上で試す(Actions の画面から手でも起動できる。PR と push では走らず、必須のチェックに入らない)。落ちたら、題が `最新の版のスモークが落ちた: <ランタイム>` の課題をランタイムごとに 1 件作り、開いていればコメントを足す。課題には入れた版と実行の URL が載る。 @@ -55,10 +51,10 @@ The authenticated GitHub workflow passes only `ANTHROPIC_API_KEY` and `OPENAI_AP 1. 最新の版を一覧の形で出す(失敗の課題に載った版を使ってもよい) ```bash - python3 scripts/runtime-smoke-latest.py resolve --runtime + python3 scripts/runtime-smoke-latest.py resolve --runtime ``` -2. 出力の行で、`tests/runtime-smoke/cli-versions.env` の同じキーの行を置き換える(kiro と agy は checksum と url の行も) +2. 出力の行で、`tests/runtime-smoke/cli-versions.env` の同じキーの行を置き換える 3. 手元でスモークを走らせ、`tmp/runtime-smoke/<ランタイム>/version.log` の版が一覧の版と同じことを見る ```bash diff --git a/tests/runtime-smoke/adapters/agy.sh b/tests/runtime-smoke/adapters/agy.sh deleted file mode 100755 index a17de80f7..000000000 --- a/tests/runtime-smoke/adapters/agy.sh +++ /dev/null @@ -1,18 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -source /workspace/ai-plugins/tests/runtime-smoke/lib/common.sh - -cd "$PROJECT_DIR" -record_version agy --version - -run_step "agy validate ndf" agy plugin validate "$REPO_ROOT/plugins/ndf/dev.agy" -run_step "agy install ndf" agy plugin install "$REPO_ROOT/plugins/ndf/dev.agy" -# 2 回目で壊れないことを見る。agy には入れ替えの操作が無く、利用者は install を繰り返す。 -run_step "agy install ndf idempotent" agy plugin install "$REPO_ROOT/plugins/ndf/dev.agy" -run_step "agy plugin list" agy plugin list - -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-plugin-files.sh" agy -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh" agy -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-no-host-contamination.sh" agy -write_junit agy diff --git a/tests/runtime-smoke/adapters/kiro.sh b/tests/runtime-smoke/adapters/kiro.sh deleted file mode 100755 index 88f7ce516..000000000 --- a/tests/runtime-smoke/adapters/kiro.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -set -euo pipefail - -source /workspace/ai-plugins/tests/runtime-smoke/lib/common.sh - -cd "$PROJECT_DIR" -record_version kiro-cli --version - -run_step "kiro install ndf" bash "$REPO_ROOT/plugins/ndf/dev.kiro/install.sh" --project "$PROJECT_DIR" --with-slack -run_step "kiro install ndf idempotent" bash "$REPO_ROOT/plugins/ndf/dev.kiro/install.sh" --project "$PROJECT_DIR" --with-slack -run_step "kiro install playwright-kit" bash "$REPO_ROOT/plugins/playwright-kit/dev.kiro/install.sh" --project "$PROJECT_DIR" -run_step "kiro install playwright-kit idempotent" bash "$REPO_ROOT/plugins/playwright-kit/dev.kiro/install.sh" --project "$PROJECT_DIR" -run_step "kiro install mcp-bigquery" bash "$REPO_ROOT/plugins/mcp/mcp-bigquery/dev.kiro/install.sh" --project "$PROJECT_DIR" -run_step "kiro install mcp-bigquery idempotent" bash "$REPO_ROOT/plugins/mcp/mcp-bigquery/dev.kiro/install.sh" --project "$PROJECT_DIR" - -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-plugin-files.sh" kiro -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-mcp-config.sh" kiro "$PROJECT_DIR/.mcp.json" -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-hook-fixtures.sh" kiro -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-kiro-agent.sh" workspace - -run_step "kiro install ndf global" bash "$REPO_ROOT/plugins/ndf/dev.kiro/install.sh" --scope global --with-slack -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-kiro-agent.sh" global -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh" kiro -"$REPO_ROOT/tests/runtime-smoke/assertions/assert-no-host-contamination.sh" kiro -write_junit kiro diff --git a/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh b/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh index cb76944d3..f7e2a1405 100755 --- a/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh +++ b/tests/runtime-smoke/assertions/assert-authenticated-smoke.sh @@ -66,24 +66,6 @@ case "$runtime" in fi run_bigquery_secret_check ;; - kiro) - if command -v kiro-cli >/dev/null 2>&1 && [ -n "${ANTHROPIC_API_KEY:-}${OPENAI_API_KEY:-}" ]; then - timeout 30s kiro-cli doctor >> "$log" 2>&1 || true - auth_ran=true - fi - run_bigquery_secret_check - ;; - agy) - # agy の認証は CLI が持つ。API キーの環境変数を取らないため、`agy models` の成否で - # 認証済みかを判定する。Skill 一覧の取得は認証を要するので、ここへ置く。 - if command -v agy >/dev/null 2>&1 && timeout 60s agy models >> "$log" 2>&1; then - timeout 120s agy --output-format text --dangerously-skip-permissions \ - -p="読み込めている Skill の名前を、1 行に 1 つだけ列挙して。説明は書かないで。" \ - >> "$log" 2>&1 || true - auth_ran=true - fi - run_bigquery_secret_check - ;; *) echo "unknown runtime: $runtime" >&2; exit 2 ;; esac diff --git a/tests/runtime-smoke/assertions/assert-hook-fixtures.sh b/tests/runtime-smoke/assertions/assert-hook-fixtures.sh index c45931069..3dda52be8 100755 --- a/tests/runtime-smoke/assertions/assert-hook-fixtures.sh +++ b/tests/runtime-smoke/assertions/assert-hook-fixtures.sh @@ -17,24 +17,5 @@ case "$runtime" in PLUGIN_ROOT="$REPO_ROOT/plugins/ndf" \ python3 "$REPO_ROOT/plugins/ndf/scripts/wait-notify.py" --runtime codex < "$REPO_ROOT/tests/runtime-smoke/fixtures/hook-stop.json" >/dev/null ;; - kiro) - test -f "$PROJECT_DIR/.kiro/agents/ndf.json" - jq -e '.hooks.agentSpawn[0].command' "$PROJECT_DIR/.kiro/agents/ndf.json" >/dev/null - stop_command="$(jq -r '.hooks.stop[0].command // empty' "$PROJECT_DIR/.kiro/agents/ndf.json")" - test -n "$stop_command" - stop_script="$(python3 - "$stop_command" <<'PY' -import shlex -import sys - -parts = shlex.split(sys.argv[1]) -if len(parts) < 4 or parts[0] != "python3" or parts[2:4] != ["--runtime", "kiro"]: - raise SystemExit(1) -print(parts[1]) -PY -)" - test -f "$stop_script" - PLUGIN_ROOT="$REPO_ROOT/plugins/ndf" \ - python3 "$stop_script" --runtime kiro < "$REPO_ROOT/tests/runtime-smoke/fixtures/hook-stop.json" >/dev/null - ;; *) echo "unknown runtime: $runtime" >&2; exit 2 ;; esac diff --git a/tests/runtime-smoke/assertions/assert-kiro-agent.sh b/tests/runtime-smoke/assertions/assert-kiro-agent.sh deleted file mode 100755 index 3a4c8e7de..000000000 --- a/tests/runtime-smoke/assertions/assert-kiro-agent.sh +++ /dev/null @@ -1,390 +0,0 @@ -#!/usr/bin/env bash -# Kiro の ndf エージェントが「選択できる状態」で導入されたことをチェックする。 -# 旧実装は .kiro/agents/default.json を生成していたが、Kiro の既定は組み込みの -# kiro_default であり、生成したエージェントは選択されないままだった。 -# -# Usage: assert-kiro-agent.sh [workspace|global] -set -euo pipefail - -scope="${1:-workspace}" -: "${REPO_ROOT:=/workspace/ai-plugins}" -: "${PROJECT_DIR:=/tmp/runtime-project}" -: "${ARTIFACT_DIR:=/tmp/runtime-artifacts}" -: "${HOME:=/tmp/runtime-home}" - -AGENT_NAME="ndf" -# INSTALL_ARGS: この scope の導入先を再現する installer 引数 -case "$scope" in - workspace) - KIRO_DIR="$PROJECT_DIR/.kiro" - INSTALL_ARGS=(--project "$PROJECT_DIR") - ;; - global) - KIRO_DIR="$HOME/.kiro" - INSTALL_ARGS=(--scope global) - ;; - *) echo "unknown scope: $scope" >&2; exit 2 ;; -esac - -AGENT_FILE="$KIRO_DIR/agents/$AGENT_NAME.json" -STEERING_FILE="$KIRO_DIR/steering/ndf-policies.md" -LOG="$ARTIFACT_DIR/kiro-agent-$scope.log" - -mkdir -p "$ARTIFACT_DIR" -: > "$LOG" - -test -f "$AGENT_FILE" -test -s "$STEERING_FILE" -# `| grep -q .` は使わない(SIGPIPE で 141 になる。assert-plugin-files.sh の注記を参照) -hit="$(find -L "$KIRO_DIR/skills" -path '*/SKILL.md' -print -quit)" -[ -n "$hit" ] || { echo "not found: SKILL.md under $KIRO_DIR/skills" >&2; exit 1; } - -# Kiro には plugin.json を読む仕組みが無いため、版数は installer が埋める -# エージェント description でしか確認できない。installer が読む Claude 版 -# マニフェストの版数と一致することをチェックする。 -MANIFEST_FILE="$REPO_ROOT/plugins/ndf/.claude-plugin/plugin.json" -test -s "$MANIFEST_FILE" -ndf_version="$(python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["version"])' "$MANIFEST_FILE")" -# grep だと 5.0.0 の . が任意文字に一致するため、JSON を読んで厳密に照合する。 -python3 - "$AGENT_FILE" "$ndf_version" >> "$LOG" <<'PY' -import json -import sys -from pathlib import Path - -agent_file, version = sys.argv[1:3] -description = json.loads(Path(agent_file).read_text(encoding="utf-8")).get("description", "") -expected = f"NDF統合開発エージェント(Kiro CLI用 / v{version})" -if description != expected: - raise SystemExit( - f"installed agent description must be {expected!r}, got {description!r}" - ) -print(f"kiro version surfaced: v{version}") -PY - -# ndf-policies は steering として配置する。Skill としても置くと Kiro 組み込みルールの -# Skill 読み込みと steering 読み込みで文脈へ二重注入される。 -if [ -e "$KIRO_DIR/skills/ndf-policies" ]; then - echo "ndf-policies must be delivered via steering only: $KIRO_DIR/skills/ndf-policies" >&2 - exit 1 -fi - -# エージェント定義と、起動時に読み込まれる文脈量をチェックする。 -# -# **文脈量に上限は置かない。計測して記録するだけにする。** -# 以前は 200,000 文字で落としていた。根拠は 2026-08-08 / kiro-cli 2.16.1 でのこの -# フィクスチャの実測 112,404 文字に対する余裕分で、当時のモデルのコンテキスト長を -# 前提にしていた。NDF は Kiro でも 1M コンテキストのモデルだけを対象とするようになり -# (既定 `auto` のコンテキストは 1,000,000。過去の実測の記録で、kiro-cli 2.20.1 に -# `--list-models` は無く同じ手段では再検証できない。出典は -# `scripts/check-skill-frontmatter.py` の Kiro の節にまとめてある)、 -# これは Claude Code の Opus 5 と同じである。**その Claude Code には、起動時に読み込む -# 文脈量の上限が無い。** 同じコンテキスト長のランタイムを片方だけ縛る根拠が無いため、 -# 上限を外して Claude Code へ揃える。 -# -# 数は `$LOG` へ残るため、増え方は後から追える。一覧の予算のほうは -# `scripts/check-skill-frontmatter.py` が Claude Code と同じ 1% でチェックする。 -python3 - "$AGENT_FILE" "$KIRO_DIR" "$PROJECT_DIR" "$STEERING_FILE" >> "$LOG" <<'PY' -import json -import sys -from pathlib import Path - -agent_file, kiro_dir, project_dir, steering_file = sys.argv[1:5] -config = json.loads(Path(agent_file).read_text(encoding="utf-8")) - -if config.get("name") != "ndf": - raise SystemExit(f"agent name must be 'ndf': {config.get('name')!r}") -if not config.get("tools"): - raise SystemExit(f"agent config declares no tools: {agent_file}") - -resources = config.get("resources", []) -for entry in resources: - if entry.startswith("skill://"): - raise SystemExit(f"skill:// resource duplicates the built-in rule: {entry}") - if "ndf-policies" in entry: - raise SystemExit(f"ndf-policies must be delivered via steering, not resources: {entry}") -if len(resources) != len(set(resources)): - raise SystemExit(f"duplicated resources in {agent_file}") - -context_files = sorted(Path(kiro_dir).glob("skills/*/SKILL.md")) -project = Path(project_dir) -context_files += [p for p in (project / "AGENTS.md", project / "README.md") if p.is_file()] -context_files.append(Path(steering_file)) -total = sum(len(p.read_text(encoding="utf-8")) for p in context_files) -# 上限は置かない(上のコメントを参照)。増え方を追えるように記録だけ残す。 -print(f"context files: {len(context_files)} / chars: {total}") -PY - -# 旧 installer が別 checkout から張った .kiro/skills/ndf-policies symlink は、現在の -# プラグイン配下を指さないため「自分が張ったリンクだけ消す」掃除に掛からない。旧導入済み -# プロジェクトでも steering との二重注入が解消されることをチェックする。 -STALE_ROOT="$ARTIFACT_DIR/stale-checkout-$scope/skills/ndf-policies" -mkdir -p "$STALE_ROOT" -echo "stale" > "$STALE_ROOT/SKILL.md" -ln -sfn "$STALE_ROOT" "$KIRO_DIR/skills/ndf-policies" -bash "$REPO_ROOT/plugins/ndf/dev.kiro/install.sh" "${INSTALL_ARGS[@]}" --with-slack >> "$LOG" 2>&1 -if [ -e "$KIRO_DIR/skills/ndf-policies" ] || [ -L "$KIRO_DIR/skills/ndf-policies" ]; then - echo "installer left a stale ndf-policies skill link: $KIRO_DIR/skills/ndf-policies" >&2 - exit 1 -fi -test -f "$STALE_ROOT/SKILL.md" # リンク先の実体まで消していないこと -echo "installer removed a stale ndf-policies skill link" >> "$LOG" - -# --- workspace 限定のチェック(ここから fi まで。heredoc の終端子の都合でインデントしない) --- -if [ "$scope" = workspace ]; then -# 利用者が $AGENT_FILE へ写した設定(MCP プラグインの mcpServers など)が、 -# installer の再実行で失われないことをチェックする。kiro-cli には依存しない。 -python3 - "$AGENT_FILE" <<'PY' -import json -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -config = json.loads(path.read_text(encoding="utf-8")) -config.setdefault("mcpServers", {})["smoke-user-mcp"] = { - "command": "echo", - "args": ["user-managed"], -} -config.setdefault("hooks", {})["userPromptSubmit"] = [{"command": "true"}] -config["smokeUserKey"] = "keep-me" -path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") -PY -bash "$REPO_ROOT/plugins/ndf/dev.kiro/install.sh" --project "$PROJECT_DIR" --with-slack >> "$LOG" 2>&1 -python3 - "$AGENT_FILE" <<'PY' -import json -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -config = json.loads(path.read_text(encoding="utf-8")) -if "smoke-user-mcp" not in config.get("mcpServers", {}): - raise SystemExit(f"reinstall dropped a user-managed mcpServers entry: {path}") -if "userPromptSubmit" not in config.get("hooks", {}): - raise SystemExit(f"reinstall dropped a user-managed hook: {path}") -if config.get("smokeUserKey") != "keep-me": - raise SystemExit(f"reinstall dropped a user-managed key: {path}") -# installer 管理のキーはテンプレートから再生成されている -if config.get("name") != "ndf" or not config.get("hooks", {}).get("agentSpawn"): - raise SystemExit(f"reinstall did not regenerate installer-managed keys: {path}") -if not config.get("hooks", {}).get("stop"): - raise SystemExit(f"--with-slack did not regenerate the stop hook: {path}") -PY -# チェック用に注入した設定を取り除き、以降のチェックへ持ち越さない -python3 - "$AGENT_FILE" <<'PY' -import json -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -config = json.loads(path.read_text(encoding="utf-8")) -config.get("mcpServers", {}).pop("smoke-user-mcp", None) -if not config.get("mcpServers"): - config.pop("mcpServers", None) -config.get("hooks", {}).pop("userPromptSubmit", None) -config.pop("smokeUserKey", None) -path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") -PY -echo "reinstall preserved user-managed agent settings" >> "$LOG" - -# 旧 .kiro/agents/default.json からの自動移行をチェックする。kiro-cli には依存しない。 -MIGRATION_ROOT="$ARTIFACT_DIR/kiro-legacy-migration" -rm -rf "$MIGRATION_ROOT" - -# $1: プロジェクト, $2: ndf ならば旧 installer の生成物そのままの fixture、 -# それ以外なら NDF 生成物と判定されない fixture を書く。 -# -# installer の判定は「旧テンプレート固有の description の完全一致」+「旧 resources -# の skill:// 指定 または agentSpawn フックの CLAUDE.ndf.md チェック」なので、 -# 自動移行ケースの fixture は旧 default.json.template と同じ値を持たせる。 -write_legacy_agent() { - mkdir -p "$1/.kiro/agents" - if [ "$2" = ndf ]; then - cat > "$1/.kiro/agents/default.json" <<'JSON' -{ - "name": "default", - "description": "NDF統合開発エージェント(Kiro CLI用)", - "tools": ["*"], - "resources": [ - "file://AGENTS.md", - "file://README.md", - "file://.kiro/skills/ndf-policies/SKILL.md", - "skill://.kiro/skills/**/SKILL.md" - ], - "hooks": { - "agentSpawn": [ - { "command": "if [ -f \"${PWD}/CLAUDE.ndf.md\" ]; then echo \"[NDF] CLAUDE.ndf.md\"; fi" } - ] - }, - "mcpServers": { "legacy-user-mcp": { "command": "echo", "args": ["legacy"] } } -} -JSON - else - cat > "$1/.kiro/agents/default.json" <<'JSON' -{ - "name": "default", - "description": "my own agent", - "resources": ["file://AGENTS.md"], - "mcpServers": { "legacy-user-mcp": { "command": "echo", "args": ["legacy"] } } -} -JSON - fi -} -install_into() { - bash "$REPO_ROOT/plugins/ndf/dev.kiro/install.sh" --project "$1" "${@:2}" >> "$LOG" 2>&1 -} - -# 1. NDF 生成物 + ndf.json なし → 自動移行し、利用者の mcpServers を引き継ぐ -case_ndf="$MIGRATION_ROOT/ndf-generated" -write_legacy_agent "$case_ndf" ndf -install_into "$case_ndf" -test ! -e "$case_ndf/.kiro/agents/default.json" -test -f "$case_ndf/.kiro/agents/default.json.bak" -python3 - "$case_ndf/.kiro/agents/ndf.json" <<'PY' -import json -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -config = json.loads(path.read_text(encoding="utf-8")) -if config.get("name") != "ndf": - raise SystemExit(f"legacy migration did not refresh installer-managed keys: {path}") -if "legacy-user-mcp" not in config.get("mcpServers", {}): - raise SystemExit(f"legacy migration dropped a user-managed mcpServers entry: {path}") -PY - -# 2. NDF 生成物と判定できない default.json → 移行せず元のまま残す -case_user="$MIGRATION_ROOT/user-owned" -write_legacy_agent "$case_user" user -install_into "$case_user" -test -f "$case_user/.kiro/agents/default.json" -test -f "$case_user/.kiro/agents/default.json.bak" -python3 - "$case_user/.kiro/agents/ndf.json" <<'PY' -import json -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -config = json.loads(path.read_text(encoding="utf-8")) -if "legacy-user-mcp" in config.get("mcpServers", {}): - raise SystemExit(f"a non-NDF default.json must not be migrated automatically: {path}") -PY - -# 3. default.json と ndf.json の両方がある → 移行せず既存の ndf.json を尊重する -case_both="$MIGRATION_ROOT/both" -mkdir -p "$case_both" -install_into "$case_both" -python3 - "$case_both/.kiro/agents/ndf.json" <<'PY' -import json -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -config = json.loads(path.read_text(encoding="utf-8")) -config["smokeExistingKey"] = "keep-me" -path.write_text(json.dumps(config, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") -PY -write_legacy_agent "$case_both" ndf -install_into "$case_both" -test -f "$case_both/.kiro/agents/default.json" -python3 - "$case_both/.kiro/agents/ndf.json" <<'PY' -import json -from pathlib import Path -import sys - -path = Path(sys.argv[1]) -config = json.loads(path.read_text(encoding="utf-8")) -if config.get("smokeExistingKey") != "keep-me": - raise SystemExit(f"legacy migration overwrote an existing ndf.json: {path}") -if "legacy-user-mcp" in config.get("mcpServers", {}): - raise SystemExit(f"legacy migration overwrote an existing ndf.json: {path}") -PY - -# 4. --dry-run → 移行を含め一切書き込まない -case_dry="$MIGRATION_ROOT/dry-run" -write_legacy_agent "$case_dry" ndf -dry_state() { (cd "$case_dry" && find . | sort && find . -type f -exec sha256sum {} + | sort); } -before_dry="$(dry_state)" -install_into "$case_dry" --dry-run -if [ "$(dry_state)" != "$before_dry" ]; then - echo "--dry-run modified the project: $case_dry" >&2 - exit 1 -fi -echo "installer migrated a legacy default.json only when it is safe" >> "$LOG" -fi -# --- workspace 限定のチェックここまで --- - -if ! command -v kiro-cli >/dev/null 2>&1; then - echo "kiro-cli agent checks skipped: kiro-cli is not available" >> "$LOG" - exit 0 -fi - -# kiro-cli は workspace エージェントを cwd 配下の .kiro/agents からしか検出しない。 -# scope ごとに、生成した $AGENT_FILE を検出できる cwd と installer の再実行引数を選ぶ。 -case "$scope" in - workspace) KIRO_CWD="$PROJECT_DIR" ;; - # global エージェントはどこからでも解決できるはずなので、.kiro を持たない中立の - # ディレクトリを cwd にする。$HOME を使うと $HOME/.kiro が workspace 扱いにもなり、 - # 「Global として見えている」ことのチェックにならない。 - global) KIRO_CWD="$ARTIFACT_DIR" ;; -esac -if [ "$scope" = global ] && [ -e "$KIRO_CWD/.kiro" ]; then - echo "global agent checks need a cwd without .kiro: $KIRO_CWD" >&2 - exit 1 -fi - -esc="$(printf '\033')" -agent_list() { - # kiro-cli 2.16.1 の agent list は一覧を標準エラー出力へ書く - (cd "$KIRO_CWD" && kiro-cli agent list 2>&1) | sed -e "s/${esc}\\[[0-9;]*m//g" -} -current_default() { - agent_list | awk '/^\*/ { print $2; exit }' -} - -if ! agent_list > "$ARTIFACT_DIR/kiro-agent-list-$scope.txt"; then - echo "kiro-cli agent checks skipped: agent list failed" >> "$LOG" - exit 0 -fi -# global scope の $KIRO_CWD には .kiro がないため、ここに $AGENT_NAME が出ること自体が -# 「Global: ~/.kiro/agents 経由でどこからでも解決できる」ことのチェックになる。 -# **パイプの右で判定しない。** `grep -q` は最初の一致で終わるため、`awk` がまだ書いて -# いる間にパイプが閉じ、`set -o pipefail` のもとでは一致していても 141 になる -# (assert-plugin-files.sh の注記を参照)。結果を変数で受けてから照合する。 -names="$(awk '{ print $1, $2 }' "$ARTIFACT_DIR/kiro-agent-list-$scope.txt")" -if ! grep -qw "$AGENT_NAME" <<<"$names"; then - echo "agent list ($scope) does not contain $AGENT_NAME" >&2 - cat "$ARTIFACT_DIR/kiro-agent-list-$scope.txt" >&2 - exit 1 -fi - -before_default="$(current_default)" -echo "default agent before: ${before_default:-unknown}" >> "$LOG" - -# kiro-cli の既定エージェントは ~/.local/share/kiro-cli/data.sqlite3 に保存されるマシン全体の -# 設定であり、このチェックは必ず元へ戻す必要がある。set-default は agent list と同じく workspace -# エージェントを cwd 配下からしか検出せず、しかも未検出でも終了コード 0 を返すため、 -# agent_list と同じ $KIRO_CWD から実行し、戻ったことを agent list で検証する。 -# 途中のチェックが落ちても復旧するよう trap で実行する。 -restore_default() { - [ -n "$before_default" ] || return 0 - [ "$before_default" != "$AGENT_NAME" ] || return 0 - (cd "$KIRO_CWD" && kiro-cli agent set-default "$before_default") >> "$LOG" 2>&1 || true - restored_default="$(current_default || true)" - echo "default agent restored: ${restored_default:-unknown}" >> "$LOG" - if [ "$restored_default" != "$before_default" ]; then - echo "failed to restore the default agent: ${restored_default:-unknown} (expected $before_default)" >&2 - return 1 - fi -} -# shellcheck disable=SC2154 # rc は trap の文字列の中で代入する(#1323) -trap 'rc=$?; restore_default || rc=1; exit $rc' EXIT - -# kiro-cli はエージェントを cwd / $HOME 配下からのみ検出する。workspace では --project で -# 別ディレクトリへ導入したときに --set-default が効くことをチェックするため、PROJECT_DIR 以外の -# cwd から実行する。global でも同様に $HOME 以外の cwd から実行して既定切替をチェックする。 -(cd "$ARTIFACT_DIR" && bash "$REPO_ROOT/plugins/ndf/dev.kiro/install.sh" "${INSTALL_ARGS[@]}" --with-slack --set-default --yes) >> "$LOG" 2>&1 -after_default="$(current_default)" -echo "default agent after: ${after_default:-unknown}" >> "$LOG" -if [ "$after_default" != "$AGENT_NAME" ]; then - echo "--set-default did not switch the default agent ($scope): ${after_default:-unknown}" >&2 - exit 1 -fi diff --git a/tests/runtime-smoke/assertions/assert-plugin-files.sh b/tests/runtime-smoke/assertions/assert-plugin-files.sh index 55d37e463..16a621d68 100755 --- a/tests/runtime-smoke/assertions/assert-plugin-files.sh +++ b/tests/runtime-smoke/assertions/assert-plugin-files.sh @@ -17,19 +17,6 @@ require_path() { [ -n "$hit" ] || { echo "not found: $pattern under $root" >&2; exit 1; } } -# symlink をたどる版。Kiro CLI は `.kiro/skills/` へ symlink を張る。 -require_path_follow() { - local root="$1" pattern="$2" hit - hit="$(find -L "$root" -path "$pattern" -print -quit)" - [ -n "$hit" ] || { echo "not found: $pattern under $root" >&2; exit 1; } -} - -require_name() { - local root="$1" pattern="$2" hit - hit="$(find "$root" -name "$pattern" -print -quit)" - [ -n "$hit" ] || { echo "not found: $pattern under $root" >&2; exit 1; } -} - case "$runtime" in claude) require_path "$HOME" '*/.claude-plugin/plugin.json' @@ -44,72 +31,5 @@ case "$runtime" in require_path "$HOME" '*/skills/*/SKILL.md' require_path "$HOME" '*/hooks/codex.json' ;; - kiro) - test -f "$PROJECT_DIR/.kiro/agents/ndf.json" - # tools が未宣言だと Kiro CLI はツールなしのエージェントとして読み込み、 - # skill が SKILL.md を読むことも git / gh を実行することもできなくなる。 - python3 -c ' -import json, sys -config = json.load(open(sys.argv[1])) -tools = config.get("tools") -if not tools: - sys.exit("agent config declares no tools: " + sys.argv[1]) -' "$PROJECT_DIR/.kiro/agents/ndf.json" - require_path_follow "$PROJECT_DIR/.kiro/skills" '*/SKILL.md' - # playwright-kit は別プラグインとして導入する(NDF の manifest には含まれない) - test -L "$PROJECT_DIR/.kiro/skills/playwright-planning" - test -f "$PROJECT_DIR/.kiro/skills/playwright-planning/SKILL.md" - test -f "$PROJECT_DIR/.kiro/prompts/pr.md" - test -s "$PROJECT_DIR/.kiro/steering/ndf-policies.md" - test -L "$PROJECT_DIR/.kiro/mcp_runtime/mcp-bigquery" - ;; - agy) - # agy は導入時にプラグインのディレクトリ全体を複製する。symlink は実体へ解決されるため、 - # 導入先では独立したファイルになる。 - plugin_dir="$HOME/.gemini/config/plugins/ndf" - test -f "$plugin_dir/plugin.json" - test -f "$plugin_dir/hooks.json" - require_path "$plugin_dir/skills" '*/SKILL.md' - require_name "$plugin_dir/agents" '*.md' - test -f "$plugin_dir/scripts/worktree-guard.sh" - # 配る Skill の基準は manifest だけが持つ。導入先の並びが基準と一致することを見る。 - python3 - "$REPO_ROOT/plugins/ndf/manifests/agy-skills.txt" "$plugin_dir/skills" <<'AGYPY' -import sys -from pathlib import Path - -manifest, installed = Path(sys.argv[1]), Path(sys.argv[2]) -expected = sorted( - line.split("#", 1)[0].strip() - for line in manifest.read_text(encoding="utf-8").splitlines() - if line.split("#", 1)[0].strip() -) -present = sorted(d.name for d in installed.iterdir() if (d / "SKILL.md").is_file()) -if expected != present: - sys.exit( - "installed agy skills differ from the manifest: " - f"missing={sorted(set(expected) - set(present))} " - f"extra={sorted(set(present) - set(expected))}" - ) -print(f"agy skills installed: {len(present)}") -AGYPY - # ファイルの存在だけでは、agy がどの要素を取り込んだかを確かめられない。取り込みの - # 記録を読み、並びに依存せず 3 つが揃っていることを見る。 - # agy 1.3.4 から既定の出力はテキストになり、JSON は --json のときだけ出る。形も - # {"imports": [{"components": [...]}]} から、要素ごとに skills / agents / hooks の - # 一覧を持つ配列へ変わった。どちらの形も読む。 - agy plugin list --json | python3 -c ' -import json, sys -data = json.load(sys.stdin) -imports = data.get("imports", []) if isinstance(data, dict) else data -entry = next((i for i in imports if i.get("name") == "ndf"), None) -if entry is None: - sys.exit("agy plugin list does not contain ndf") -components = entry.get("components") or [k for k in ("agents", "hooks", "skills") if entry.get(k)] -components = sorted(components) -if components != ["agents", "hooks", "skills"]: - sys.exit(f"agy imported unexpected components: {components}") -print("agy components: " + ", ".join(components)) -' - ;; *) echo "unknown runtime: $runtime" >&2; exit 2 ;; esac diff --git a/tests/runtime-smoke/cli-versions.env b/tests/runtime-smoke/cli-versions.env index a23fa084d..f8968872a 100644 --- a/tests/runtime-smoke/cli-versions.env +++ b/tests/runtime-smoke/cli-versions.env @@ -1,4 +1,4 @@ -# CLI の版の一覧: スモークの像へ入れる 4 ランタイムの CLI の版数・取得先・checksum。 +# CLI の版の一覧: スモークの像へ入れる 2 ランタイムの CLI の版数・取得先・checksum。 # 版を上げるときに書き換える唯一の場所。手順は tests/runtime-smoke/README.md の「版を上げる」。 # 1 行 1 つの KEY=VALUE。値は引用符で囲まない。キーは Containerfile の ARG の名前と同じ。 # arch の名前は `uname -m` の値(x86_64 / aarch64)に揃える。 @@ -9,14 +9,3 @@ CLAUDE_CODE_VERSION=2.1.296 # codex: npm の @openai/codex の版数 CODEX_VERSION=0.162.1 -# kiro: 版数と kirocli--linux-musl.tar.gz の sha256 -KIRO_CLI_VERSION=2.31.0 -KIRO_CLI_SHA256_X86_64=3d0782f274f4180f4f5759ebb780fd46e8b9927261e5774dae1fd486f195cf5e -KIRO_CLI_SHA256_AARCH64=d309b7ce5368cbd01eeb9fc40f18da05ba3d4b39b2ce5457a7731eec9ea3de3c - -# agy: 版数と manifest の url・sha512 -AGY_VERSION=1.3.4 -AGY_URL_X86_64=https://storage.googleapis.com/antigravity-public/antigravity-cli/1.3.4-5524158944247808/linux-x64/cli_linux_x64.tar.gz -AGY_URL_AARCH64=https://storage.googleapis.com/antigravity-public/antigravity-cli/1.3.4-5524158944247808/linux-arm/cli_linux_arm64.tar.gz -AGY_SHA512_X86_64=dbdacfc5a1d9874fbbea3a21026d3990b4728e2e3487fd0316dc53cae9a3091023f969425828f3e2e0fadda43cea07dd978ea96f4ca7c202adf2201f419c56dd -AGY_SHA512_AARCH64=24a1d463319701f4943b2d4932cb86ca34d60d2e9fedcd9018ca31809773d719f272512068911632db175d7233088551033bcecc0a2a56b1bcfa041269687eaf