From d0acd67438c3a79a21900a53ede484eea7c31300 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 00:33:20 +0000 Subject: [PATCH 01/46] =?UTF-8?q?Docs:=20ndf-distribution-runtimes.md=20?= =?UTF-8?q?=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/glossary.md | 6 +- docs/glossary/glossary.json | 6 +- docs/specifications/README.md | 1 + .../ndf-distribution-runtimes.md | 155 +++++++++ issues/issue-2231-design.md | 329 ------------------ issues/issue-2231-requirements.md | 175 ---------- 6 files changed, 162 insertions(+), 510 deletions(-) create mode 100644 docs/specifications/ndf-distribution-runtimes.md delete mode 100644 issues/issue-2231-design.md delete mode 100644 issues/issue-2231-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 5f3a85aad..f22ee0a7a 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -376,7 +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) | — | — | — | +| 相手役 | — | cross-review のレビュー担当、cross-refactoring の提案・実装の担当として、ホストから CLI プロセスで起動されるランタイム(#2231) | — | — | `docs/specifications/ndf-distribution-runtimes.md` | ## NDF の cross-review(`ndf-cross-review`) @@ -584,8 +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) | — | — | — | +| マニフェスト | — | plugins/ndf/manifests/<ランタイム>-skills.txt。ランタイムごとに配る Skill の一覧で、配布の唯一の基準(#2231) | — | — | `docs/specifications/ndf-distribution-runtimes.md` | +| 配布先のランタイム | — | マーケットプレイスのプラグインを導入できる形で届けるランタイム。#2231 の後は Claude Code と codex で、相手役のランタイムとは別に決まる(#2231) | — | — | `docs/specifications/ndf-distribution-runtimes.md` | ## NDF の指示書チェック(`ndf-instructions`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 845c3ce4e..cc5007acb 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4691,19 +4691,19 @@ "term": "相手役", "context": "ndf-agent-cli", "meaning": "cross-review のレビュー担当、cross-refactoring の提案・実装の担当として、ホストから CLI プロセスで起動されるランタイム(#2231)", - "pending_source": "issues/issue-2231-requirements.md" + "source": "docs/specifications/ndf-distribution-runtimes.md" }, { "term": "マニフェスト", "context": "ndf-release", "meaning": "plugins/ndf/manifests/<ランタイム>-skills.txt。ランタイムごとに配る Skill の一覧で、配布の唯一の基準(#2231)", - "pending_source": "issues/issue-2231-requirements.md" + "source": "docs/specifications/ndf-distribution-runtimes.md" }, { "term": "配布先のランタイム", "context": "ndf-release", "meaning": "マーケットプレイスのプラグインを導入できる形で届けるランタイム。#2231 の後は Claude Code と codex で、相手役のランタイムとは別に決まる(#2231)", - "pending_source": "issues/issue-2231-design.md" + "source": "docs/specifications/ndf-distribution-runtimes.md" }, { "term": "最終工程の記録", diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 91eb4b67c..aac9eb2a3 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -112,5 +112,6 @@ | [ndf-supervise-test-limits-from-whole-runs.md](ndf-supervise-test-limits-from-whole-runs.md) | 予算なしのテストの上限を、所要の記録と直近の全体テストの実測(whole.jsonl)の大きい方の 3 倍で決め、テストのステップの開始で決め直すこと | | [ndf-step-result-gaps-and-reading.md](ndf-step-result-gaps-and-reading.md) | 手順のスクリプトの結果 JSON の欠け(`gaps`)・過程(`process`)・判断(`decisions`)と、`ok` を欠けの無いときだけにする検査(`validate_result`・欠けの残る結果の `stopped` / 終了コード 6)、シェルから同じ形を出す `step_result.py emit` と `step-result.sh`、読む側の共通の関数(`step_read.py`)、記録のスクリプト(`progress-record.sh`・`projects-sync.sh`)の返し方と呼ぶ側が止まらずに報告へ載せること、子の 7 件がこの形をどう使ったか。形と終了コードの表は `plugins/ndf/scripts/lib/README.md` が正 | | [ndf-run-state-owner-and-sweep.md](ndf-run-state-owner-and-sweep.md) | 実行をまたいで残る状態(レビュー worktree `pr`・`rf`、セッションの状態ファイル、`ndf-deps.*`、`fix-pr-*`、`ndf-tests-*`、投稿キューの項目)に持ち主と寿命を記録し、終わった実行の残骸を cross-review と cross-refactoring の開始・merged の `cleanup`・テストのセッションの終わりで片付ける理由と境界(`--force` を使わない、未 push のコミットは自分で数える、寿命の窓は 24 時間、生きている持ち主・open の PR・作業の残る worktree・判定できない状態は理由付きで残す、別の実行が使う worktree では開始を止める、今回分でない位置エラーの `review-post` は控えへ外す)、持ち主の記録の形、`sweep_states`・`remove_review_worktree`・`Queue.flush(keep)`/`outcome` の契約。状態の一覧は `lib/run_state_kinds.py` の `KINDS` が正 | +| [ndf-distribution-runtimes.md](ndf-distribution-runtimes.md) | 配布先を Claude Code と Codex に限り、agy・Kiro CLI は cross 系の相手役としてだけ起動すること(#2231)。導入物の不在の検査(`test_distribution_runtimes.py`)、相手役の起動経路が配布物を読まないこと、前の版で入れた手元の扱い。利用者向けの残し方と消し方は `plugins/ndf/README.md` が正 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-distribution-runtimes.md b/docs/specifications/ndf-distribution-runtimes.md new file mode 100644 index 000000000..b397ad6bf --- /dev/null +++ b/docs/specifications/ndf-distribution-runtimes.md @@ -0,0 +1,155 @@ +# 配布先は Claude Code と Codex で、agy・Kiro CLI は cross 系の相手役としてだけ起動する + +マーケットプレイスのすべてのプラグイン(ndf・playwright-kit・mcp-* 10 個)を、Claude Code と Codex へすべての Skill で +配る。agy と Kiro CLI へは何も配らず、cross-review / cross-refactoring の相手役として、ホスト(Claude Code・Codex)から +CLI プロセスで起動するだけにする(#2231)。 + +**この文書が配布先の正本である。** 次の確定仕様に残る agy・Kiro CLI への配布・導入物(`dev.agy/`・`dev.kiro/`・ +`agy-skills.txt`・`kiro-skills.txt`)・その生成と検査・スモークの記述は、配布先を 4 つにしていた時点のもので、 +配布先についてはこの文書が優先する。 + +| 確定仕様 | 配布先を 4 つにしていた時点の記述 | +| --- | --- | +| [runtime-plugin-distribution.md](runtime-plugin-distribution.md) | Kiro CLI・agy の名前空間ディレクトリ、manifest、agy のエージェント定義の生成 | +| [runtime-plugin-container-smoke.md](runtime-plugin-container-smoke.md) | Kiro の像・adapter・確認 | +| [ndf-knowledge-and-kiro.md](ndf-knowledge-and-kiro.md) | Kiro CLI の導入(installer・symlink・steering) | + +## 概要 + +| ランタイム | 役割 | 届けるもの | +| --- | --- | --- | +| Claude Code | 配布先・ホスト | すべてのプラグイン。NDF は `plugins/ndf/manifests/claude-skills.txt` の Skill | +| Codex | 配布先・ホスト | すべてのプラグイン。NDF は `plugins/ndf/manifests/codex-skills.txt` の Skill | +| agy | 相手役 | 何も配らない | +| Kiro CLI | 相手役 | 何も配らない | + +agy・Kiro CLI のためだけに保っていた導入物・その生成と検査・スモークテスト・リリース後の導入の確認を外し、 +agy・Kiro CLI だけで起きる不具合(Skill の本文の切り詰めなど)の置き場を無くした。相手役の起動経路は配布物を +読まないため、配布を止めても相手役の振る舞いは変わらない。 + +## 用語 + +配布先のランタイム・相手役・マニフェスト・ホストの定義は、用語集([docs/glossary.md](../glossary.md))にある。 + +## 背景 + +利用者の指示(2026-10-31)は「claude と codex のみ、すべての plugin を配布する。agy、kiro は配布せず、cross 系で +呼ばれるだけ」である。agy・Kiro CLI へ cross 系をホストとして回せる Skill だけを配る案は採らない。ホストとしての +使い方を残さないため、相手役に要る Skill は 0 本になる。マニフェストを空のファイルにして導入物の枠を残す案も +採らない。中身の無い導入物のために生成・検査・スモークを保つことになる。 + +この変更は配布物の取得元を変える操作(プロジェクト MVV の P1)に当たり、承認ゲート 1 の指示と承認ゲート 2 の承認で行った。 + +## 仕様 + +### 常に成り立つ条件 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| I1 | どのプラグインにも `manifests/agy-skills.txt`・`manifests/kiro-skills.txt`・`dev.agy/`・`dev.kiro/` が無く、`.ndf/supervise.json` の `release.runtimes` は `claude`・`codex` の部分集合である | `scripts/tests/test_distribution_runtimes.py` が落ちる | +| I2 | Claude Code と Codex の配布物(両マニフェスト・各 `.codex-plugin/plugin.json`・`plugins/ndf/hooks/codex.json`・codex 向けの生成物)は配布先を絞った前と同じである | `build-runtime-plugins.sh --check`・`validate-runtime-plugins.sh`・既存の pytest | +| I3 | agy・Kiro CLI を相手役にする起動(`plugins/ndf/scripts/lib/launch-cli.sh` の `agy` / `kiro` の分岐、`cross-refactoring/scripts/prepare-worktrees.sh` の手順書の写し先)は配布先と関わらずに動く | 起動の既存の pytest | +| I4 | 前の版で Kiro CLI へ入れた手元の hook が呼ぶ共通のスクリプト(`plugins/ndf/scripts/hook.py` の `--runtime kiro`・`worktree-guard.sh`・`worktree-session.sh`・`wait-notify.py`)が残る | `worktree/tests/test_guard.py` の Kiro の入力のテスト | +| I5 | ワークフローのジョブと ruleset の必須のチェックが一致する | `required-checks-check`(`scripts/check-required-checks.py`) | + +### 相手役として起動されるとき + +```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 CLI) + H->>P: cross-refactoring の初期化 + P->>P: ホストの導入先から REQUIRED_SKILLS を担当の worktree へ写す + H->>L: 依頼文と worktree を渡す + L->>R: CLI プロセスで起動(--agent もプラグインの導入先も指さない) + R->>R: 依頼文と worktree の中の手順書だけを読む + R-->>H: 結果ファイル +``` + +- cross-review のレビュー担当の依頼文は Skill を読まずに完結する(`cross-review/scripts/launch-reviewer.sh` の `render_review_prompt`) +- cross-refactoring は `REQUIRED_SKILLS`(`refactoring`・`tdd-cycle`・`quality-gates`)を**ホストの導入先**から担当の worktree + (Kiro CLI は `.kiro/skills`、agy は `.agents/skills`)へ写す +- 起動の形の正本は `plugins/ndf/skills/external-ai/references/cli-kiro.md`・`cli-agy.md` にある。相手役に使えるかは + `.ndf/runtimes.json` と各 CLI の認証で決まる。cross-review の既定の参加者(claude・codex・kiro とホスト、agy は `--include agy`)は変わらない + +### 配布物を組み立てて確かめる流れ + +```mermaid +graph LR + M[claude・codex のマニフェスト] --> B[build-runtime-plugins.sh] + B --> C[codex の openai.yaml と mcp-* の .codex-plugin] + C --> V[validate-runtime-plugins.sh] + D[test_distribution_runtimes.py] --> V + C --> S[スモーク claude・codex] + R[release.runtimes claude・codex] --> VI[release-verification-steps.py verify-install] +``` + +## データ・設定 + +| 置き場 | 値 | +| --- | --- | +| `plugins/ndf/manifests/` | `claude-skills.txt`・`codex-skills.txt` だけ(行数は manifests が持つ) | +| `plugins/playwright-kit/manifests/` | `claude-skills.txt`・`codex-skills.txt` だけ | +| `.ndf/supervise.json` の `release.runtimes` | `["claude", "codex"]` | +| `.ndf/pace.json` の `verify` | `release-verification-steps.py verify-install --ref develop --runtimes claude,codex` | +| `release-verification-steps.py` の `--runtimes` | 既定 `claude,codex` | +| `.ndf/project.json` の `required_checks` | スモークは `runtime-smoke (claude)`・`runtime-smoke (codex)` | +| `.github/workflows/runtime-plugin-smoke.yml` の matrix | `runtime: [claude, codex]`。`runtime-plugin-authenticated-smoke.yml` の選択肢も `all`・`claude`・`codex` | +| `tests/runtime-smoke/` | `Containerfile.{base,claude,codex}`・`adapters/{claude,codex}.sh` | + +`.ndf/worktree.json` の `.kiro/`(相手役の設定を置く場所)と、`scripts/token-usage.py` の kiro・agy の使用量の読み取り +(相手役の使用量)は相手役の経路として残る。 + +## 運用 + +### 前の版で agy・Kiro CLI へ入れた利用者の手元 + +こちらから手元を消す操作はしない(共通原則 C6)。最後に配った版は v10.27.4 である。残し方と消し方の正本は +`plugins/ndf/README.md` の「agy・Kiro CLI への配布の終了」にある。 + +| ランタイム | 配布を止めた後 | +| --- | --- | +| agy | 導入の写し(`~/.gemini/config/plugins/ndf/`)の絶対パスを指すため、そのまま動く。新しい版は届かない | +| Kiro CLI | `.kiro/agents/ndf.json` の hook は clone の共通のスクリプトを呼び、それが残る(I4)ため、clone を進めても動く。installer は無くなる | + +共通のスクリプトの Kiro の分岐は消さない。消すと clone を進めた Kiro CLI の利用者で、プロンプトを送るたびに hook が失敗する。 +いつ消すかは棚卸しで利用の実態を見て決める。 + +### 範囲の外に残したもの + +Claude Code・Codex へ配る Skill の本文とスクリプトに残る、ホストとしての Kiro CLI・agy の分岐(例: `plugins/mcp/mcp-redash/scripts/redash-mcp-config.js` +の `syncKiroAgent`、cross-review の `docs/01-state-and-review.md` のホストの導入先の候補)は消していない。相手役としての記述 +(担当の worktree の `.kiro/skills`・`.agents/skills`)と同じファイルに並び、取り違えると相手役の経路を壊すためである。 +棚卸しで扱う。 + +`KIRO.md` は指示書のチェック(`.ndf/instructions.json`)が読む指示書として残し、相手役としての Kiro CLI の説明だけを持つ。 + +### 確かめていないこと + +| 項目 | 内容 | +| --- | --- | +| プロジェクト MVV との食い違い | Mission は 4 つのランタイムを挙げる。改訂は #2296 で扱う | +| 相手役が配布を止めた後も同じ結果を返すか | 起動の経路をコードで読んで判断した。実物はリリース後テストで確かめる | + +## テスト観点 + +- `scripts/tests/test_distribution_runtimes.py` が、`plugins/` の下の agy・Kiro CLI の導入物とマニフェストの不在と、 + `release.runtimes` が `claude`・`codex` に収まることを確かめること。`plugins/mcp/mcp-notion/dev.kiro/` を 1 つ戻すと落ち、 + `release.runtimes` に `kiro` を足すと落ちること +- `build-runtime-plugins.sh --check`・`validate-runtime-plugins.sh` が agy・Kiro CLI の生成物を求めずに通ること +- Codex のマニフェスト・`.codex-plugin`・`hooks/codex.json` が配布先を絞った前と同じであること +- 相手役の起動の 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`)が通ること +- `worktree/tests/test_guard.py` の Kiro の入力のテストが通ること +- スモークの matrix が `[claude, codex]` で通り、`required-checks-check` が通ること +- リリース後に、新しい版を入れた環境から `/ndf:cross-review --include agy` と `/ndf:cross-refactoring ` を回し、 + agy・Kiro CLI の結果ファイルが揃うこと + +## 関連リンク + +- [runtime-plugin-distribution.md](runtime-plugin-distribution.md): Claude Code・Codex の配布の形 +- [cross-review-participants-and-seats.md](cross-review-participants-and-seats.md): 参加者プール +- [docs/versioning-and-distribution.md](../versioning-and-distribution.md): 版と配布の正本 +- #2231 diff --git a/issues/issue-2231-design.md b/issues/issue-2231-design.md deleted file mode 100644 index c40bfc4e6..000000000 --- a/issues/issue-2231-design.md +++ /dev/null @@ -1,329 +0,0 @@ -# 配布: 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 deleted file mode 100644 index 514cabcce..000000000 --- a/issues/issue-2231-requirements.md +++ /dev/null @@ -1,175 +0,0 @@ -# #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 | From 363be1faddc828e44a928622dde8e1059f477237 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 00:33:25 +0000 Subject: [PATCH 02/46] =?UTF-8?q?Docs:=20ndf-sprint-close-final-stage.md?= =?UTF-8?q?=20=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specifications/README.md | 1 + .../ndf-sprint-close-final-stage.md | 266 ++++++++++++++ issues/issue-2228-design-decisions.md | 89 ----- issues/issue-2228-design.md | 333 ------------------ issues/issue-2228-requirements.md | 169 --------- 5 files changed, 267 insertions(+), 591 deletions(-) create mode 100644 docs/specifications/ndf-sprint-close-final-stage.md delete mode 100644 issues/issue-2228-design-decisions.md delete mode 100644 issues/issue-2228-design.md delete mode 100644 issues/issue-2228-requirements.md diff --git a/docs/specifications/README.md b/docs/specifications/README.md index aac9eb2a3..2568956e3 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -113,5 +113,6 @@ | [ndf-step-result-gaps-and-reading.md](ndf-step-result-gaps-and-reading.md) | 手順のスクリプトの結果 JSON の欠け(`gaps`)・過程(`process`)・判断(`decisions`)と、`ok` を欠けの無いときだけにする検査(`validate_result`・欠けの残る結果の `stopped` / 終了コード 6)、シェルから同じ形を出す `step_result.py emit` と `step-result.sh`、読む側の共通の関数(`step_read.py`)、記録のスクリプト(`progress-record.sh`・`projects-sync.sh`)の返し方と呼ぶ側が止まらずに報告へ載せること、子の 7 件がこの形をどう使ったか。形と終了コードの表は `plugins/ndf/scripts/lib/README.md` が正 | | [ndf-run-state-owner-and-sweep.md](ndf-run-state-owner-and-sweep.md) | 実行をまたいで残る状態(レビュー worktree `pr`・`rf`、セッションの状態ファイル、`ndf-deps.*`、`fix-pr-*`、`ndf-tests-*`、投稿キューの項目)に持ち主と寿命を記録し、終わった実行の残骸を cross-review と cross-refactoring の開始・merged の `cleanup`・テストのセッションの終わりで片付ける理由と境界(`--force` を使わない、未 push のコミットは自分で数える、寿命の窓は 24 時間、生きている持ち主・open の PR・作業の残る worktree・判定できない状態は理由付きで残す、別の実行が使う worktree では開始を止める、今回分でない位置エラーの `review-post` は控えへ外す)、持ち主の記録の形、`sweep_states`・`remove_review_worktree`・`Queue.flush(keep)`/`outcome` の契約。状態の一覧は `lib/run_state_kinds.py` の `KINDS` が正 | | [ndf-distribution-runtimes.md](ndf-distribution-runtimes.md) | 配布先を Claude Code と Codex に限り、agy・Kiro CLI は cross 系の相手役としてだけ起動すること(#2231)。導入物の不在の検査(`test_distribution_runtimes.py`)、相手役の起動経路が配布物を読まないこと、前の版で入れた手元の扱い。利用者向けの残し方と消し方は `plugins/ndf/README.md` が正 | +| [ndf-sprint-close-final-stage.md](ndf-sprint-close-final-stage.md) | スプリント課題を最終工程の記録(閉じる条件 3・`--final-stage`)を確かめてから閉じること、振り返りの行を記録の PR の最後の配布の記録と比べる基準(`dist_comment_id`)、まとめのプランの並び(retro の後に close)、既定ブランチ宛の Pull Request の closing keywords を閉じる予定の参照へ置き換えること(#2228・#2082・#2119)。手順は `progress-tracking` の「スプリントを閉じる」が正 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-sprint-close-final-stage.md b/docs/specifications/ndf-sprint-close-final-stage.md new file mode 100644 index 000000000..f4d3b0fc3 --- /dev/null +++ b/docs/specifications/ndf-sprint-close-final-stage.md @@ -0,0 +1,266 @@ +# スプリント課題を最終工程の記録を確かめてから閉じ、既定ブランチ宛の Pull Request のマージでは閉じない + +NDF のどの経路で閉じても、閉じた課題はその実行の最終工程の記録を持つ。`sprint-close.py` が課題ごとに +最終工程の記録(閉じる条件 3)を確かめ、まとめのプランは振り返りの後に課題を閉じ、既定ブランチ宛の +Pull Request の本文には GitHub がマージで課題を閉じる closing keywords を残さない(#2228。子の #2082・#2119 を含む)。 + +**手順は各 Skill の `SKILL.md` が正である。** 閉じる条件の表・呼ぶ Skill と `--final-stage` の値の対応・ +プランの並びの正本は `plugins/ndf/skills/progress-tracking/SKILL.md` の「スプリントを閉じる」にあり、 +ここへ書き写さない。この文書は、決めたことの理由と、書く側と読む側が共有する形を残す。 + +| 何を読むか | 正本 | +| --- | --- | +| 閉じる条件 1〜3・呼ぶ側と `--final-stage` の値・まとめのプランの並び | `plugins/ndf/skills/progress-tracking/SKILL.md` の「スプリントを閉じる」 | +| closing keywords を本文だけに書くことと、既定ブランチ宛での置き換え | `plugins/ndf/skills/pr/SKILL.md` | +| 後片付けの止まり方・閉じる条件 1・2 の理由・リリース記録の形 | [ndf-cleanup-and-bundle-closing.md](ndf-cleanup-and-bundle-closing.md) | + +## 概要 + +閉じる経路は 3 つあり、変更前はどれも工程の通過を見なかった。 + +| 経路 | 変更前に起きたこと | 今の扱い | +| --- | --- | --- | +| GitHub が既定ブランチへのマージで本文の closing keywords の課題を閉じる | volareinc/carmo-screening の PR 344 の `Closes #342` で、受け入れ条件 11 件が未チェックのまま閉じた(#2082) | 既定ブランチ宛の本文を NDF の手順で書くとき、closing keywords を閉じる予定の参照へ置き換える | +| `sprint-close.py` をプラン・conductor・Skill のどれかが打つ | s2086 で #2086 がリリース後テストの前に閉じ、不合格の条件を持ったまま閉じて残った(#2119) | `sprint-close.py` が最終工程の記録を確かめ、無ければ閉じない | +| ボードの `Done` でボードの自動化が閉じる | 上の経路の中の 1 手 | 判定を通った課題だけ `Done` にする(変わらない) | + +閉じる時点の決まりは `progress-tracking` の 1 か所、判定は `sprint-close.py`(と `sprint_close_lib/`)の 1 か所、 +閉じる指定の形は `lib/closing.py` の 1 か所に置く。まとめのプランと Skill の本文は `--final-stage` の値を渡すだけで、 +閉じてよいかを判定しない。 + +## 用語 + +最終工程・最終工程の記録・閉じる予定の参照・スプリント課題・closing keywords・参照だけの課題の定義は、 +用語集([docs/glossary.md](../glossary.md))にある。 + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| `plugins/ndf/scripts/sprint-close.py` | 引数 `--final-stage` を受け、課題ごとに閉じる条件 1〜3 を判定して閉じる | +| `plugins/ndf/scripts/sprint_close_lib/final_stage.py` | 閉じる条件 3 の判定 `final_stage_gap(stage, body, since)` と、満たさない課題の項目 `gap_item` | +| `plugins/ndf/scripts/lib/dist_record.py` | `parse_record(text, parts)` が、最後の配布の記録のコメントの識別子 `dist_comment_id` を返す | +| `plugins/ndf/scripts/sprint_close_lib/collect.py` | `read_record` が記録の PR を 1 回読み、本文とコメントを連結した文字列と部分ごとの(先頭の行の番号, コメントの識別子)を返す | +| `plugins/ndf/scripts/lib/closing.py` | 閉じる予定の参照の語 `DEFERRED`(`最終工程の後に閉じる`)、置き換え `defer_closing`、2 つの形を読む `closing_issues` / `closing_refs` / `closing_numbers`、closing keywords だけを読む `closing_words` | +| `plugins/ndf/scripts/lib/closing-issues.sh` | シェルの読み手。閉じる予定の参照も取り出す。出力の形(`<リポジトリ>\t<番号>`)は変わらない | +| `plugins/ndf/scripts/lib/repo.py` | `merge_closes_issues(root, base)`(宛先のマージで GitHub が閉じるか)と `defer_for_base(root, base, text)`(当たるときだけ `defer_closing` に通す) | +| `plugins/ndf/scripts/pr-steps.py`・`plugins/ndf/scripts/supervise_lib/pr.py` | 本文を送る前に `defer_for_base` を通す | +| `plugins/ndf/scripts/supervise_lib/sprint.py`・`sprint_close_steps.py`・`release_templates.py` | まとめのプランを `merge →(release-verify)→ retro → close → refine` の順に組む | + +```mermaid +graph LR + subgraph 書く側 + PRS[pr-steps.py] --> DFB[lib/repo.py defer_for_base] + PLPR[supervise_lib/pr.py] --> DFB + DFB --> MCI[merge_closes_issues] + DFB --> DEF[lib/closing.py defer_closing] + end + subgraph 読む側 + SC[sprint-close.py] --> CIS[lib/closing-issues.sh] + MRG[merged・hook] --> CIS + REL[release_lib] --> CPY[lib/closing.py closing_refs] + SC --> FS[sprint_close_lib/final_stage.py] + end + subgraph まとめのプラン + RV[release-verify] --> RT[retro] --> CL[close] --> RF[refine] + end + CL --> SC +``` + +## 仕様 + +### 常に成り立つ条件 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| I1 | 課題を閉じてボードを `Done` にするのは、その課題のその実行の最終工程の記録があるときだけである。振り返りの行は、前の実行のものを数えない | 閉じない。`kept_open`(既に閉じていれば `closed_unverified`)と、通っていない工程の名前を含む `reason` で返す | +| I2 | 最終工程の記録を読む置き場(課題の本文)を読めないとき、記録があるとは読まない | 閉じない。`failed` と打ち直しの `cmd`、`status: stopped`(終了コード 1) | +| I3 | `--final-stage` を省いたときの最終工程は `振り返り`(最も後ろ)である。省いても閉じる条件は緩まない | — | +| I4 | 最終工程が `リリース後テスト` のとき、閉じる条件 2 を `--with-verification` の有無にかかわらず見る | 閉じない | +| I5 | `--record-pr 0`(本番の記録なし)と `--record-pr unknown` の扱いは条件 3 の前と変わらない。`unknown` は本文を読まずにすべて `kept_open` で返す | — | +| I6 | まとめのプランの `next` のつながりで、`close` は `retro` の後、`refine` の前にある。`retro` と `release-verify` の指示は課題を閉じない。`close` は `--final-stage 振り返り` を明示して渡す | プランを組む関数のテストで落ちる | +| I7 | 宛先が既定ブランチ(既定ブランチか宛先を決められないときを含む)の Pull Request を NDF の手順で書くと、本文に closing keywords が残らず、置き換えた指し先は同じ課題を指す閉じる予定の参照になる | — | +| I8 | 宛先が既定ブランチでない Pull Request の本文は書き換えない | — | +| I9 | 閉じる予定の参照は closing keywords を含まない | `closing_words` が一致を返したらテストで落ちる | +| I10 | スプリント課題は closing keywords・閉じる予定の参照・`--issues` の和であり、閉じる予定の参照が指す課題を参照だけの課題にしない | — | + +### `sprint-close.py` の入出力 + +```text +sprint-close.py --record-pr [--final-stage 配布|リリース後テスト|振り返り] + [--prs 1,2] [--issues 3,4] [--with-verification] [--label …] [--dry-run] [--root ] [--repo ] +``` + +| 項目 | 値 | +| --- | --- | +| `--final-stage` | 3 つの値のどれか。省けば `振り返り`。それ以外は呼び出しの誤り(終了コード 3) | +| 条件 3 を満たさない課題 | `result: kept_open`・`reason: 最終工程(<工程>)の記録が無い: <何が無いか>`。既に閉じていれば `closed_unverified`(`閉じているが閉じる条件 3 を満たさない: …`。開き直さない) | +| 課題の本文を読めない | `result: failed`・同じ呼び出しの `cmd`。`status: stopped`・終了コード 1 | +| `metrics.final_stage` | 判定に使った最終工程 | +| `decisions` | `{"decision": "最終工程を <工程> として判定した", "basis": "--final-stage <値>(省略時は振り返り)"}` を 1 件 | +| 終了コード | 0 / 1 / 2 / 3 の意味は条件 3 の前と同じ | + +最終工程ごとの記録と読む置き場: + +| 最終工程 | 最終工程の記録 | 読む置き場 | +| --- | --- | --- | +| `配布` | 閉じる条件 1(最後の配布の記録の `段階:` が `本番` か `配布なし`。`--record-pr 0` は満たす) | 記録の PR | +| `リリース後テスト` | 閉じる条件 2(`--with-verification` を付けたのと同じ判定) | 記録の PR | +| `振り返り` | 課題の本文に、行頭から `振り返り: https://github.com/<所有者>/<リポジトリ>/(issues|pull)/<番号>#issuecomment-<識別子>` の行があり、そのうち最大の `<識別子>` が比べる基準(`dist_comment_id`)以上である | 課題の本文と記録の PR | + +`振り返り` で満たさないときの `<何が無いか>` は 3 つである。 + +| 状態 | `<何が無いか>` | +| --- | --- | +| 比べる基準が `None` | `比べる配布の記録のコメントが無い` | +| 行が 1 つも無い | `課題の本文に振り返りの記録の行が無い` | +| 行の識別子がすべて基準より小さい | `課題の本文の振り返りの行が今回の配布の記録より前のもの` | + +### 比べる基準(`dist_comment_id`) + +振り返りの行は課題の本文に残り続ける。前のスプリントで振り返りを通り、開いたまま持ち越した課題が、 +今回の振り返りの前に条件 3 を満たさないよう、行が指すコメントの識別子を記録の PR の最後の配布の記録と比べる。 + +| 最後の配布の記録の置き場 | 基準 | +| --- | --- | +| 記録の PR のコメント | そのコメントの識別子(`comments[].url` の `#issuecomment-<識別子>` の数字) | +| 記録の PR の本文 | 記録の PR の最初のコメントの識別子。コメントが 1 件も無ければ `None` | +| `--record-pr 0` | `0`(`NO_BASE`。配布の記録が無いため、行があれば満たす) | + +比べ方は「以上」である。本文にある記録で、今回の振り返りを記録の PR の最初のコメントとして投稿したとき、 +その識別子が基準と等しくなるためである。記録の PR は条件 1 と同じ 1 回の `gh pr view --json body,comments` で読み、 +`gh` の呼び出しを増やさない。 + +### 課題 1 件の判定の流れ + +```mermaid +flowchart TD + A[課題 1 件] --> B{record-pr が unknown か
条件 1 を満たさない} + B -->|当たる| K1[kept_open] + B -->|当たらない| C{条件 2 を見るか
with-verification
または最終工程がリリース後テスト} + C -->|見る| D[錠の中で状態と本文を 1 回読む] + C -->|見ない| E[最終工程が振り返りなら
状態と本文を 1 回読む] + D -->|読めない| F[failed] + E -->|読めない| F + D --> G{条件 2} + G -->|満たさない| K2[kept_open / closed_unverified] + G -->|満たす| H + E --> H{条件 3
final_stage_gap} + H -->|記録が無い| K3[kept_open / closed_unverified] + H -->|記録がある| I[受け入れ条件の印 → ボード Done → 閉じる → 読み直す] +``` + +条件 3 は条件 2 と同じ 1 回の本文の読みを使う。印を書くのは条件 3 も満たしたときだけで、満たさない課題の +本文は書かない。`--dry-run` は判定まで行い、書かない。 + +### まとめのプランの並び + +```mermaid +flowchart LR + M[merge] -->|配布の記録あり| RV[release-verify
課題を閉じない] + M -->|配布の記録なし| RT + RV --> RT[retro
課題を閉じない] + RT --> CL[close
sprint-close.py --final-stage 振り返り --with-verification] + CL -->|0 で終わったときだけ| RF[refine] +``` + +まとめのプランはどのモードでも `retro` を持つため、プランの中の最終工程はどのモードでも振り返りになる。 +`配布` と `リリース後テスト` は、プランの外で最終工程を担う Skill(`light` の `release`、振り返りを通らない +`operation` の `release-verification` か `release`)だけが渡す。 + +### 閉じる予定の参照と本文の置き換え + +```text +最終工程の後に閉じる: <指し先> +``` + +`<指し先>` は closing keywords と同じ 3 つ(`#<番号>`・`<所有者>/<リポジトリ>#<番号>`・課題の URL)。`:` の前後の +空白は 0 個以上を許す。1 つの指し先に 1 つ書く。 + +置き換えるかは Pull Request の宛先(新しく作るなら `--base` か起点、既存なら `baseRefName`)で決める。 +`merge_closes_issues` は、既定ブランチ(`default_branch`)を決められない・宛先が分からない・宛先が既定ブランチ +(`origin/` を外して比べる)のときに真を返す。 + +`pr-steps.py create` / `update` の結果: + +| 項目 | 値 | +| --- | --- | +| 本文 | 当たれば `Closes #12` などを `最終工程の後に閉じる: #12` へ置き換えてから送る。指し先の字面は変えない | +| `metrics.deferred_closing` | 置き換えた指し先の字面の配列(置き換えなければ空) | +| `items` | 置き換えた指し先ごとに `{"kind": "closing", "name": "<指し先>", "result": "deferred"}` | +| `metrics.closing_issues_in_body` | 2 つの形の指し先をどちらも載せる | +| `status` | 置き換えは `ok` のまま(欠けではない) | + +プランの pr のステップ(`supervise_lib/pr.py`)も同じ判定で置き換え、置き換えた指し先をステップの状態の +`deferred_closing` に残す。 + +## 決定の理由 + +- **最終工程の記録は GitHub に残った記録から読む。** 配布の記録・リリース後テストの行・課題の本文の振り返りの行は + どれも GitHub にあり、conductor・supervisor・別の機械のどこから打っても同じものを読む。プランの状態ディレクトリと + 通過記録は手元のファイルで、別の機械や手打ちの経路からは読めない。振り返りの行は記録の PR ではなく課題の本文から + 読む(#2086 の行は記録の PR と違う PR を指していた) +- **最終工程は引数で受け、省けば振り返りにする。** 課題の本文の `モード:` は実例でどれも `—` で、最終工程を導けない。 + 省いたときに最も厳しい値になるため、呼ぶ側の書き違いで閉じる条件が緩まない +- **`--final-stage リリース後テスト` は条件 2 を必ず見る。** 2 つの引数を別々に必須にすると、片方の付け忘れで + 最終工程を確かめずに閉じる経路が残る +- **前の版で工程を通った課題のための移行の手段を作らない。** 振り返りを通った課題はすでに本文に行を持つ。 + 判定を飛ばす引数を足すと、新しい経路でも判定を飛ばす手段になる。行を持たない課題は `kept_open` で残り、 + `next` が「振り返りを通すか、確かめて手で閉じる」を示す +- **既定ブランチ宛の closing keywords は止めずに置き換える。** プランの pr のステップは本文を機械で組むため、 + 止めると既定ブランチを起点にするリポジトリでスプリントのたびに無人の実行が止まる。置き換えは宛先で決める。 + GitHub が閉じるかを決めるのは各 Pull Request の宛先であり、起点の設定ではない +- **閉じる予定の参照は専用の形にする。** `Refs #N` は参照だけの課題の意味と衝突する。`NDF-Closes` のような造語は + 語の境界の後に `Closes` が来て closing keywords として一致する。`--issues` だけで集めると、付け忘れで閉じる課題が + 0 件になる +- **振り返りの行は形だけを見て、指すコメントは読まない。** コメントを読むと課題ごとに `gh` の呼び出しが 1 回増える。 + 行は `retrospective` が投稿の後にだけ書く +- **プランで閉じるのは `close` だけにし、Skill からの呼び出しも残す。** プランを通らない実行にも閉じる担い手が要る。 + 判定は `sprint-close.py` が行うため、両方が打っても早すぎる時点では閉じず、2 度目は `already_closed` になる。 + プランの `retro` に閉じさせないのは、担当が `--with-verification` を省くと条件 2 を見ずに閉じるためである + +## 運用 + +- 振り返りを通らずに開いたまま残った課題は、振り返りを通すか、確かめて手で閉じる(手で `gh issue close` を打つ + 経路は NDF の判定の外) +- GitHub の画面で本文を書き直したときは置き換えない(NDF の手順を通らない書き込み) + +### 確かめていないこと + +| 項目 | 内容 | +| --- | --- | +| コメントの識別子の大小と投稿の順 | 識別子が投稿の順に大きくなることに頼る(#2086 と記録の PR 2109 の 1 組で確かめた)。順に並ばない例が出たら投稿時刻の比較へ替える | +| 配布の記録が本文だけにある記録の PR | 最初のコメントを基準にする。コメントの無い記録の PR では、振り返りを最終工程とする課題が閉じない(安全側)。実例は確かめていない | +| `--record-pr 0` の前の実行の行 | 比べる基準が無く、前のスプリントの行を持つ持ち越しの課題は、その実行の振り返りの前に条件 3 を満たしうる | +| 振り返りの行の中身 | 行の URL のコメントが実在するかは読まない | +| `--with-verification` を省いた振り返りの経路 | 条件 2 を見ずに閉じうる(#2293) | + +## テスト観点 + +テストは `plugins/ndf/scripts/tests/` の `test_sprint_close_final_stage.py`・`test_closing.py`・`test_supervise_pr_closing.py`・ +`test_sprint_close_issues.py`・`test_sprint_close_references.py` ほかにある。 + +- 本文に振り返りの行が無い課題は、`--final-stage` を省いて打つと `--with-verification` の有無の両方で `kept_open` と + 「振り返り」を含む `reason` が返り、閉じる・ボードを書く `gh` の呼び出しが無いこと +- 振り返りの行の識別子が最後の配布の記録のコメントの識別子より小さい課題は閉じず、大きい行を足すと閉じること +- 最後の配布の記録がコメントにあればその識別子、本文にあれば最初のコメントの識別子、コメントが無ければ `None` が + 基準になり、`None` では閉じないこと。基準と等しい識別子を数えること。記録の PR を読む `gh` の呼び出しが 1 回であること +- 課題の本文の読みが失敗すると `failed`・`status: stopped`・終了コード 1 になること +- 行がすべて合格で振り返りの行がある課題で、印・`Done`・閉じるが同じ順で起きること。`--record-pr 0` で行があれば閉じ、 + `unknown` では本文を読まずにすべて `kept_open` になること +- `--final-stage リリース後テスト` で `--with-verification` を付けずに打つと、不合格の行を持つ課題が閉じないこと +- まとめのプランの `next` をたどった並びが `release-verify → retro → close → refine`(記録なしでは `retro → close → refine`)で、 + どのモードでも `close` が `retro` の後、`refine` の前にあること +- 既定ブランチと起点が同じ一時のリポジトリで `pr-steps.py create` / `update` と `supervise_lib/pr.py` の `_publish` が + `gh` に渡す本文に closing keywords が無く、閉じる予定の参照が同じ指し先で残ること。既定ブランチを決められない構成でも + 同じであること +- 閉じる予定の参照だけを持つ Pull Request で `sprint-close.py --prs <番号> --dry-run` を打つと、その課題が閉じる対象として + 判定され、参照だけの課題にも終了コード 2 にもならないこと。`closing-issues.sh` と `closing.closing_refs` が同じ本文から + 同じ指し先を返すこと +- 宛先が既定ブランチでない構成(`develop` 宛)では本文が書き換わらないこと + +## 関連リンク + +- [ndf-cleanup-and-bundle-closing.md](ndf-cleanup-and-bundle-closing.md): 閉じる条件 1・2 と後片付け +- [docs/glossary.md](../glossary.md): 用語集 +- #2228・#2082・#2119 diff --git a/issues/issue-2228-design-decisions.md b/issues/issue-2228-design-decisions.md deleted file mode 100644 index 52e51fd50..000000000 --- a/issues/issue-2228-design-decisions.md +++ /dev/null @@ -1,89 +0,0 @@ -# #2228 の設計: 決定の記録 - -設計文書の本体は [issue-2228-design.md](issue-2228-design.md) にある。要求の未決 1〜4 は決定 1・4・5・6・8 で決める。 - -## 決定の記録 - -### 決定 1: 別の機械とセッションからも同じ判定になるよう、最終工程の記録は GitHub に残った記録から読む - -判定の材料は、配布の記録(`段階:`)・リリース後テストの行・課題の本文の `振り返り: <コメントの URL>` の行の 3 つにする(要求の未決 1 の候補 (b))。どれも GitHub にあり、conductor・supervisor・別の機械のどこから打っても同じものを読む。配布とリリース後テストは今の閉じる条件 1・2 がすでに読んでおり、足すのは振り返りの行だけになる。 - -振り返りの行は、`retrospective` の「辿る経路を作る」がすでに課題ごとに書いている(#2086・#2216・#2227・#2229 で確かめた)。記録の PR のコメントは読まない。#2086 の行は記録の PR(2109)と違う PR(2110)を指しており、記録の PR から探すと見落とす。 - -#2227 の結果 JSON(候補 (a))は採らない。プランの状態ディレクトリは手元の一時の置き場で、conductor が手で打つ経路では存在しない。通過記録に「工程を出た」記録を足す案(候補 (c))も採らない。通過記録はリポジトリの外の手元のファイルで、別の機械からは読めない。 - -根拠: Value 5 / Value 6 / Vision(MVV 版 3) - -### 決定 2: 呼ぶ側の書き違いで閉じる条件が緩まないよう、最終工程は引数で受け、省けば振り返りにする - -`sprint-close.py` に `--final-stage 配布|リリース後テスト|振り返り` を足し、省いたときは最も後ろの `振り返り` として判定する。s2086 のように conductor が手で打つ経路では、何も付けなければ振り返りの記録が要る。緩めるには、最終工程を担う Skill(`release`・`release-verification`)がその値を明示する必要がある。どの Skill がどの値を渡すかは `progress-tracking` の「スプリントを閉じる」の呼ぶ Skill の表が持つ。 - -課題の本文の `## 進行` の `モード:` から最終工程を導く案は採らない。実際の課題(#2086・#2172・#2216・#2227・#2229)ではどれも `—` で、導けない。工程表(`WF_STAGE_MATRIX`)を Python から読む部品を新しく作っても、`operation` は経路でリリース後テストと振り返りの有無が変わり、モードだけでは決まらない。 - -根拠: Value 2 / Value 4(MVV 版 3) - -### 決定 3: 最終工程がリリース後テストの経路で条件 2 を見落とさないよう、その値は条件 2 を必ず見る - -`--final-stage リリース後テスト` を渡したときは、`--with-verification` を付けなくても閉じる条件 2 を判定する。この経路の最終工程の記録はリリース後テストの行そのものであり、条件 2 を見ずに閉じると最終工程の記録を確かめないまま閉じる。 - -2 つの引数を別々に必須にする案は採らない。片方を付け忘れると、最終工程を確かめずに閉じる経路が残る。 - -根拠: Value 6(MVV 版 3) - -### 決定 4: 前の版で工程を通った課題のために移行の手段を作らない - -この版より前に振り返りを通った課題は、すでに本文に振り返りの行を持つ(決定 1)。行を持たずに開いている課題は `kept_open` で残り、`reason` と `next` が「振り返りを通すか、確かめて手で閉じる」を示す(要求の未決 2)。 - -古い課題を一度だけ閉じる引数(判定を飛ばす `--force` の類)は足さない。足すと、新しい経路でも判定を飛ばす手段として使える。手で `gh issue close` を打つ経路は要求の対象範囲の外にあり、すでにある。 - -根拠: Value 1 / Value 6(MVV 版 3) - -### 決定 5: 無人の実行を止めずにマージで閉じなくするため、既定ブランチ宛の本文の closing keywords は止めずに置き換える - -既定ブランチ宛の Pull Request の本文を NDF の手順(`pr-steps.py create` / `update` とプランの pr のステップ)で書くとき、closing keywords を閉じる予定の参照へ置き換えてから送る(要求の未決 3 の前半)。指し先の字面は変えないため、何を閉じる予定だったかは本文に残る。置き換えたことは `metrics.deferred_closing` と `items` に載る。 - -止める案は採らない。プランの pr のステップは本文を機械で組み、`## 閉じる課題` に `Closes #N` を並べる。止めると、既定ブランチを起点にするリポジトリではスプリントのたびに無人の実行が止まる。 - -根拠: Value 2 / Value 5(MVV 版 3) - -### 決定 6: 閉じる課題を本文から集め続けるため、閉じる予定の参照は専用の形にし、読み手の共通の部品で読む - -閉じる予定の参照の形は `最終工程の後に閉じる: <指し先>` にする(要求の未決 3 の後半)。GitHub の closing keywords を含まないため GitHub は閉じず、`lib/closing-issues.sh` と `lib/closing.py` が closing keywords と同じ指し先の規則で読む。`sprint-close.py`・`merged`・工程の飛ばしの検知の hook・リリースの材料は今の部品を通して読むため、読み手ごとの直しは要らない。 - -`Refs #N` は採らない。「参照」はすでに参照だけの課題(閉じる対象にしない課題)の意味で使っており、同じ形に 2 つの意味が付く。`NDF-Closes #N` のような英語の造語も採らない。語の境界の後に `Closes` が来るため、NDF の読み手(`lib/closing.py` の `CLOSING`)が closing keywords として一致させ、GitHub が閉じるかを読み手ごとに確かめ直すことになる。`--issues` だけで集める案も採らない。受け入れ条件 8 は `--prs` だけで課題が集まることを求め、手で打つ経路で `--issues` を付け忘れると閉じる課題が 0 件になる。 - -最終工程の判定(`final_stage_gap`)は `sprint-close.py` に置き、`lib` に置かない。読み手が 1 つで、`lib/README.md` の「2 つ以上の読み手が使う部品だけを置く」に当たらない。 - -根拠: Value 6 / Value 8(MVV 版 3) - -### 決定 7: 判定の費用を増やさないため、振り返りの行は形だけを見て、指すコメントは読まない - -条件 3 は課題の本文にある行の形(`振り返り: https://github.com/…#issuecomment-<識別子>`)だけで判定する。本文は条件 2 と同じ 1 回の読みで取る。行の URL のコメントを `gh api` で読めば実在を確かめられるが、課題ごとに `gh` の呼び出しが 1 回増える。行は `retrospective` が投稿の後にだけ書く。 - -根拠: Value 10(MVV 版 3) - -### 決定 8: まとめのプランで閉じる時点を 1 つにするため、閉じるのは close のステップだけにし、Skill からの呼び出しは残す - -まとめのプランは `merge →(release-verify)→ retro → close → refine` の順にし、`retro` の指示に `release-verify` と同じく「課題を閉じない(次の close が閉じる)」を足す。プランの外(conductor が Skill を直接通す経路)では、今と同じく最終工程を担う Skill が「スプリントを閉じる」を行う(要求の未決 4)。 - -Skill から呼び出しを外す案は採らない。プランを通らない実行で、閉じる担い手がいなくなる。`sprint-close.py` は判定を自分で行うため、Skill とプランの両方が打っても、早すぎる時点では閉じず、2 度目は `already_closed` になる。プランの中で `retro` に閉じさせない理由は、担当が `--with-verification` を省いて打つと条件 2 を見ずに閉じるためである。 - -モードで並びを分ける案も採らない。今のまとめのプランはどのモードでも `retro` を持ち、`close` を `retro` の後に置けば、受け入れ条件 6 の「最終工程の後、棚卸しの前」をどのモードでも満たす。 - -根拠: Value 4 / Value 6(MVV 版 3) - -### 決定 9: 書き換えの要否を確実に決めるため、宛先が既定ブランチかで決め、既定ブランチを決められないときは置き換える - -置き換えるかは Pull Request の宛先(新しく作るなら `--base` か起点、既存なら `baseRefName`)が既定ブランチ(`lib/repo.py` の `default_branch`)と一致するかで決める。既定ブランチを決められないときは置き換える。置き換えても閉じる予定の参照として NDF の手順で閉じられ、失うものが無い。置き換えずに既定ブランチ宛だったときは、マージで閉じる。 - -`.ndf/worktree.json` の `base_branch` と既定ブランチを比べる案は採らない。GitHub が閉じるかを決めるのは各 Pull Request の宛先であり、`sprint/<名前>` 宛の課題の Pull Request は起点の設定に関わらずマージで閉じない。 - -根拠: Value 5(MVV 版 3) - -### 決定 10: 前のスプリントから持ち越した課題が今回の振り返りの前に閉じないよう、振り返りの行は記録の PR の配布の記録より後のものだけを数える - -振り返りの行は課題の本文に残り続けるため、形だけを見ると、前のスプリントで振り返りを通って開いたまま持ち越した課題が、今回の配布とリリース後テストの後、今回の振り返りの前に条件 3 を満たす。そこで、行が指すコメントの識別子が、記録の PR の最後の配布の記録(閉じる条件 1 が判定する `段階:`)のコメントの識別子以上の行だけを、その実行の振り返りとして数える。配布の記録が本文にあるときは本文に識別子が無いため、記録の PR の最初のコメントの識別子を基準にする。比べ方を「より大きい」でなく「以上」にするのは、今回の振り返りを記録の PR の最初のコメントとして投稿したとき、その識別子が基準と等しくなり、振り返りを終えても閉じられなくなるためである(コメントにある配布の記録と振り返りは別のコメントなので、等しくなるのはこの場合だけ)。記録の PR は条件 1 がすでに 1 回読んでおり、コメントの URL も同じ読みで取れるため、`gh` の呼び出しは増えない(決定 7 と両立する)。#2086 の行(`6392995482`)は記録の PR 2109 の配布の記録(`6391552027`)より大きいことを確かめた。 - -行の指すコメントを読んで投稿時刻を比べる案は採らない。課題ごとに `gh` の呼び出しが 1 回増える(決定 7)。記録の PR の番号を行に書き足して照合する案も採らない。#2086 の行は記録の PR と違う PR を指しており(決定 1)、今ある行と照合できない。 - -根拠: Value 6 / Value 10(MVV 版 3) diff --git a/issues/issue-2228-design.md b/issues/issue-2228-design.md deleted file mode 100644 index b5a9a4d75..000000000 --- a/issues/issue-2228-design.md +++ /dev/null @@ -1,333 +0,0 @@ -# sprint-close: 課題を最終工程の記録を確かめてから閉じ、既定ブランチ宛の PR のマージでは閉じない(#2228) - -## 目的 - -- **何が壊れているか**: 課題が工程の途中で閉じる。`sprint-close.py` は工程の通過を見ず、まとめのプランは振り返りの前に閉じる(#2119)。既定ブランチ宛の Pull Request は本文の closing keywords で、マージの時点に GitHub が閉じる(#2082) -- **誰が困るか**: 棚卸しと振り返りで課題の一覧を読む人と AI。閉じた課題は棚卸しの対象から外れ、不合格の条件や通っていない工程が見えなくなる(s2086 の #2086) -- **直すと何が成り立つか**: NDF のどの経路で閉じても、閉じた課題はその実行の最終工程の記録を持つ。閉じる時点の決まりは `progress-tracking` の 1 か所、判定は `sprint-close.py` の 1 か所にそろう - -## 適用範囲 - -- **働く範囲**: 配布先のリポジトリでも働く。`sprint-close.py`・まとめのプラン・`pr-steps.py`・プランの pr のステップはプラグインの配布物である -- **プロジェクトごとに違うもの**: 既定ブランチと開発の起点(`.ndf/worktree.json` の `base_branch`)は git と設定から読む。最終工程は呼ぶ側が引数 `--final-stage` で渡す -- **当たるモード**: 5 つのモードすべて。最終工程はモードと経路で変わる(`light` は配布、振り返りを通る経路は振り返り、振り返りを通らない `operation` はリリース後テストか配布) - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| s2086 で `sprint-close.py --record-pr 2109 --prs 2092,2088,2090` が #2086 をリリース後テストの前に閉じ、その後のリリース後テストで受け入れ条件 10 が不合格になったが閉じたまま残った(#2119) | 実測 | 呼ぶ側の手順の並びに頼ると、並びを 1 つ書き違えただけで閉じる | -| volareinc/carmo-screening の PR 344 の本文の `Closes #342` で、マージの時刻(2026-10-19T10:07:23Z)に #342 が閉じ、受け入れ条件 11 件が未チェックのまま残った(#2082) | 実測 | 既定ブランチ宛の本文に closing keywords が残ると、NDF の判定より先に GitHub が閉じる | -| #2086・#2216・#2227・#2229 の本文に `振り返り: https://github.com/devbasex/ai-plugins/pull/<番号>#issuecomment-<識別子>` の行があり、#2086 の行は記録の PR(2109)と違う PR(2110)を指す(2026-10-31 に `gh issue view` で確かめた) | 実測 | 振り返りの記録の有無は、記録の PR ではなく課題の本文から課題ごとに読める | -| #2086 の振り返りの行のコメントの識別子(`6392995482`)は、記録の PR 2109 の配布の記録(`段階: 本番`)のコメントの識別子(`6391552027`)より大きい(2026-10-31 に `gh issue view` と `gh pr view --json comments` で確かめた) | 実測 | 振り返りの行がその実行のものかは、記録の PR の最後の配布の記録のコメントより後に投稿されたかで、課題の本文と記録の PR から読める | -| 同じ 4 件と #2172 の本文の `## 進行` の `モード:` は `—` である(同上) | 実測 | 課題の本文のモードからは最終工程を決められない | -| GitHub は既定ブランチへのマージのときだけ本文の closing keywords で課題を閉じる(`lib/closing-issues.sh` の冒頭の説明と、`develop` 宛のこのリポジトリで課題がマージで閉じない今の運用) | 実測 | 書き換えるのは宛先が既定ブランチの Pull Request だけでよい | - -要求と受け入れ条件は #2228 の本文にある(コピーは [issue-2228-requirements.md](issue-2228-requirements.md))。この文書は「どう作るか」だけを扱う。 -決定の記録は [issue-2228-design-decisions.md](issue-2228-design-decisions.md) に分けた。子の #2082・#2119 の設計もこの文書が持つ。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| 工程(`ndf-workflow`) | 最終工程・最終工程の記録・スプリント課題・閉じる予定の参照・閉じる条件 | -| GitHub(外部の系) | closing keywords・既定ブランチ・課題の状態(OPEN / CLOSED) | - -工程が下流、GitHub が上流の関係で、工程の側が**腐敗防止層**を置く。GitHub の closing keywords は「既定ブランチへマージしたら閉じる」という上流の規則で、工程の「最終工程の後に閉じる」を壊す。PR の本文を書く手順は `lib/closing.py` の `defer_closing` を通し、既定ブランチ宛の本文では closing keywords を工程の語(閉じる予定の参照)へ翻訳する。読む側は 2 つの形を同じスプリント課題として読む。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 課題の閉じ方 | `sprint-close.py`(課題の状態・ボードの `Done`・受け入れ条件の印) | スプリント課題(`(リポジトリ, 番号)`) | — | 最終工程・最終工程の記録・判定の結果(`result` / `reason`) | -| 閉じる指定 | `lib/closing.py` の `defer_closing`(本文の閉じる指定の形を決める唯一の関数。`pr-steps.py` と `supervise_lib/pr.py` は通して書くだけ) | Pull Request の本文 | — | closing keywords・閉じる予定の参照 | -| まとめのプラン | `supervise_lib/sprint_close_steps.py` と `sprint.py` の `close_plan` | プラン | ステップ | ステップの並び(`next`) | - -課題の閉じ方は閉じる指定を ID(課題の参照)でだけ読む。まとめのプランは課題の閉じ方を `sprint-close.py` の起動でだけ使い、閉じてよいかを自分で判定しない。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 課題の閉じ方 | 課題を閉じ、ボードを `Done` にするのは、その課題のその実行の最終工程の記録があるときだけである。振り返りの行は、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子(記録が本文にあるときは「記録の PR の読み」の基準)以上のものだけを数え、前の実行(前のスプリント)の行は数えない | 閉じない。`kept_open` と、通っていない工程の名前を含む `reason` で返す | -| I2 | 課題の閉じ方 | 最終工程の記録を読む置き場(課題の本文)を読めないとき、記録があるとは読まない | 閉じない。`failed` と打ち直しの `cmd` で返し、`status` は `stopped`(終了コード 1) | -| I3 | 課題の閉じ方 | `--final-stage` を省いたときの最終工程は `振り返り`(最も後ろ)である。省いても閉じる条件は緩まない | — (既定値で守る) | -| I4 | 課題の閉じ方 | 最終工程が `リリース後テスト` のとき、閉じる条件 2(リリース後テストの行がすべて合格)を `--with-verification` の有無にかかわらず見る | 閉じない(今の条件 2 の扱い) | -| I5 | 課題の閉じ方 | `--record-pr 0` と `--record-pr unknown` の今の扱いは変わらない。`unknown` は最終工程の記録を読まずにすべて `kept_open` で返す | — | -| I6 | まとめのプラン | `next` のつながりで、`close` は `retro` の後、`refine` の前にある。`retro` と `release-verify` の指示は課題を閉じない。まとめのプランはどのモードでも `retro` を持つため、プランの中の最終工程はどのモードでも振り返りで、`close` は `--final-stage 振り返り` を明示して渡す(I3 の既定に頼らない)。`配布` と `リリース後テスト` は、プランの外で最終工程を担う Skill(`light` の `release`、振り返りを通らない `operation` の `release-verification` か `release`)だけが渡す | プランを組む関数のテストで落ちる | -| I7 | 閉じる指定 | 宛先が既定ブランチ(既定ブランチを決められないときを含む)の Pull Request を NDF の手順で書くと、本文に closing keywords が残らず、置き換えた指し先は同じ課題を指す閉じる予定の参照になる | — (書く前に `defer_closing` を通す) | -| I8 | 閉じる指定 | 宛先が既定ブランチでない Pull Request の本文は書き換えない | — | -| I9 | 閉じる指定 | 閉じる予定の参照は GitHub の closing keywords を含まない | `closing_words` が一致を返したらテストで落ちる | -| I10 | 課題の閉じ方 | スプリント課題は closing keywords・閉じる予定の参照・`--issues` の和であり、閉じる予定の参照が指す課題を参照だけの課題にしない | — | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 課題の Pull Request・スプリントの Pull Request がマージされた | 承認ゲート 2 の後のマージ(`merge-when-green`) | GitHub(既定ブランチ宛でも閉じる予定の参照では閉じない。I7) | -| E2 | 配布を終え、配布の記録を記録の PR に置いた | `release`・`release-steps.py record` | `sprint-close.py`(閉じる条件 1) | -| E3 | リリース後テストの結果を記録した | `release-verification` | `sprint-close.py`(閉じる条件 2) | -| E4 | 振り返りの記録を投稿し、課題の本文に辿る経路の行を足した | `retrospective` | `sprint-close.py`(閉じる条件 3) | -| E5 | 課題ごとに最終工程の記録を判定した | `sprint-close.py` | 結果 JSON の読み手(プランの `close` のステップ・conductor・Skill の担当) | -| E6 | 判定を通った課題のボードを `Done` にし、閉じた | `sprint-close.py` | ボードの自動化・`backlog-refinement` | -| E7 | 棚卸しを始めた | プランの `refine`・工程表の次の工程 | `backlog-refinement` | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 最終工程 | 棚卸しの前にその実行で最後に通る工程。振り返りを通るなら振り返り、通らずリリース後テストを通るならリリース後テスト、どちらも通らなければ配布。まとめのプランは `retro` を必ず通すため、プランの中ではどのモードでも振り返りになる(I6) | 意味の変更(`ndf-workflow`。配布の場合を足す) | -| 最終工程の記録 | 最終工程を終えたことを示す、GitHub に残った記録。配布は配布の記録の `段階:`、リリース後テストはその課題の行、振り返りは課題の本文の `振り返り: <コメントの URL>` の行のうち、指すコメントの識別子が記録の PR の最後の配布の記録のコメントの識別子(記録が本文にあるときは「記録の PR の読み」の基準)以上のもの(その実行の振り返り) | 追加(`ndf-workflow`) | -| 閉じる予定の参照 | Pull Request の本文に `最終工程の後に閉じる: #<番号>` と書く形。GitHub は閉じず、`sprint-close.py` がスプリント課題として集める | 追加(`ndf-workflow`) | -| スプリント課題 | 「スプリントを閉じる」が閉じる対象にする課題。スプリントの Pull Request の本文の closing keywords と閉じる予定の参照が指す課題と、`--issues` で渡した課題の和 | 意味の変更(`ndf-workflow`。閉じる予定の参照を足す) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 課題ごとに最終工程の記録を確かめ、無ければ閉じずに理由を返す | まとめのプランの `close`・最終工程を担う Skill の担当・conductor | -| F2 | 呼ぶ側が最終工程を引数で渡す(省けば振り返り) | 同上 | -| F3 | まとめのプランを「リリース後テスト → 振り返り → 課題を閉じる → 棚卸し」の順に組む | `supervise.py new close` を打つ conductor | -| F4 | 既定ブランチ宛の Pull Request の本文の closing keywords を閉じる予定の参照へ置き換える | `/ndf:pr`(`pr-steps.py`)とプランの pr のステップ | -| F5 | 閉じる予定の参照をスプリント課題として集める | `sprint-close.py`・`merged`・工程の飛ばしの検知の hook・リリースの材料 | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| `scripts/sprint-close.py` | 引数 `--final-stage` を受け、課題ごとに閉じる条件 3(最終工程の記録)を判定する。判定の関数 `final_stage_gap(stage, body)` はこのファイルに置く(読み手が 1 つのため `lib` に置かない。決定 6) | -| `scripts/lib/closing.py` | 閉じる予定の参照の形(`DEFERRED`)を持つ。`defer_closing(text)` が closing keywords を置き換え、`closing_issues` / `closing_refs` / `closing_numbers` が 2 つの形をどちらも読む。`closing_words` は closing keywords だけを読む(コミットの検査のため) | -| `scripts/lib/closing-issues.sh` | 閉じる予定の参照も取り出す。出力の形(`<リポジトリ>\t<番号>`)は変えない | -| `scripts/lib/repo.py` | `merge_closes_issues(root, base)`: 宛先が既定ブランチか、既定ブランチを決められないときに真 | -| `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 が閉じる)」を足す | -| `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`) | 閉じる時点・順序・閉じる条件の写しを外し、正本を指す。順序の説明の文を新しい並びに直す | -| `docs/glossary/glossary.json`・`docs/glossary.md` | 用語の表の 4 語を反映し、`glossary.py render` で作り直す | - -```mermaid -graph LR - subgraph 書く側 - PRS[pr-steps.py] --> DEF[lib/closing.py defer_closing] - PLPR[supervise_lib/pr.py] --> DEF - PRS --> MCI[lib/repo.py merge_closes_issues] - PLPR --> MCI - end - subgraph 読む側 - SC[sprint-close.py] --> CIS[lib/closing-issues.sh] - MRG[merged・hook] --> CIS - REL[release_lib] --> CPY[lib/closing.py closing_refs] - end - subgraph まとめのプラン - RV[release-verify] --> RT[retro] --> CL[close] --> RF[refine] - end - CL --> SC - DOC[progress-tracking の正本] -.決まり.-> SC - DOC -.決まり.-> CL -``` - -図に含めない要素: 契約の写し(`4-close.json`)・正本を指すように付け替える Skill と references・用語集。どれも呼び出しの関係を持たない。 - -### システムの文脈 - -```mermaid -graph LR - NDF[NDF の手順] -->|本文を書く・課題を閉じる| GH[(GitHub: PR・課題・ボード)] - GH -->|本文・コメント・課題の本文を読む| NDF - GH -->|既定ブランチへのマージで closing keywords の課題を閉じる| GH -``` - -配置は変わらない。どの要素も conductor・supervisor・worker が動く機械の上で起動され、状態は GitHub に置く(手元の通過記録・プランの状態ディレクトリを判定に使わない。決定 1)。 - -## 構造 - -触る関数とその関係(Python のモジュールを型として描く)。 - -```mermaid -classDiagram - class sprint_close { - +cmd_close(a) - +final_stage_gap(stage, body) str|None - -_close_item(a, ctx, repo, n, kept_all, verdicts) - -_judge_and_mark(a, ctx, repo, n, rows) - } - class closing { - +DEFERRED - +defer_closing(text) (str, list) - +closing_words(text) list - +closing_issues(text) list - +closing_refs(text, repo) list - } - class repo { - +default_branch(root) - +merge_closes_issues(root, base) bool - } - class pr_steps { - +upsert(a, must_exist) - } - class supervise_pr { - -_publish(ctx, base, branch, title, body) - } - pr_steps --> closing - pr_steps --> repo - supervise_pr --> closing - supervise_pr --> repo - sprint_close --> closing : closing-issues.sh 経由 -``` - -## 入出力の契約 - -### `sprint-close.py` - -```text -sprint-close.py --record-pr [--final-stage 配布|リリース後テスト|振り返り] - [--prs 1,2] [--issues 3,4] [--with-verification] [--label …] [--dry-run] [--root ] [--repo ] -``` - -| 項目 | 変更後 | -| --- | --- | -| `--final-stage` | 新しい引数。値は 3 つ。省けば `振り返り`。それ以外の値は呼び出しの誤り(終了コード 3) | -| 閉じる条件 3 | 最終工程ごとに、その課題の最終工程の記録を見る(下の表)。条件 1・2 の後に判定する | -| `items[].reason`(条件 3 を満たさない) | `最終工程(<工程>)の記録が無い: <何が無いか>`。`result` は `kept_open`。既に閉じていれば `closed_unverified`(開き直さない。今の条件 2 と同じ扱い) | -| 課題の本文を読めない | `result: failed`・`reason: 本文を読めない(最終工程の記録を確かめられない)`・`cmd` は同じ呼び出し。`status: stopped`・終了コード 1(今の `failed` の扱い) | -| `metrics.final_stage` | 新しい値。判定に使った最終工程 | -| `decisions` | 1 件足す。`{"decision": "最終工程を <工程> として判定した", "basis": "--final-stage <値>(省略時は振り返り)"}` | -| 終了コード | 0 / 1 / 2 / 3 の意味は変えない | - -| 最終工程 | 最終工程の記録 | 読む置き場 | -| --- | --- | --- | -| `配布` | 閉じる条件 1(最後の配布の記録の `段階:` が `本番` か `配布なし`。`--record-pr 0` は満たす) | 記録の PR(今と同じ) | -| `リリース後テスト` | 閉じる条件 2(`--with-verification` を付けたのと同じ判定) | 記録の PR(今と同じ) | -| `振り返り` | 課題の本文に、行頭から `振り返り: https://github.com/<所有者>/<リポジトリ>/(issues|pull)/<番号>#issuecomment-<識別子>` の行があり、そのうち 1 つ以上の `<識別子>` が、比べる基準の識別子(下の「記録の PR の読み」の `dist_comment_id`)以上(等しいのは、本文にある記録で、振り返りを記録の PR の最初のコメントとして投稿したとき。`--record-pr 0` は配布の記録が無いため、行があれば満たす) | 課題の本文(`gh issue view --json state,body`)と記録の PR(閉じる条件 1 の 1 回の読み。`gh` の呼び出しを増やさない) | - -記録の PR の読み(`read_record` と `lib/dist_record.py` の `parse_record`)は、比べる基準の識別子を閉じる条件 1 と同じ 1 回の `gh pr view --json body,comments` から返すように変える。 - -| 関数 | 変更後 | -| --- | --- | -| `read_record` | 連結した文字列に加えて、部分ごとの「先頭の行の番号と、コメントの識別子」の並びを返す。コメントの識別子は `comments[].url` の `#issuecomment-<識別子>` の数字。本文(先頭の部分)の識別子は `None` | -| `parse_record` | 引数に部分の並びを足し、戻り値に `dist_comment_id` を足す。最後の配布の記録の見出しの行を含む部分の識別子である。その部分が本文なら、記録の PR の最初のコメントの識別子を使い、コメントが 1 件も無ければ `None`。記録の PR はその実行で作るため、最初のコメントは前の実行の振り返りより後にあり、比べ方が「以上」なので、今回の振り返りがその最初のコメントでも数える | -| 条件 3(振り返り)で `dist_comment_id` が `None` | 比べる基準が無いため満たさない。`kept_open`(既に閉じていれば `closed_unverified`)と `最終工程(振り返り)の記録が無い: 比べる配布の記録のコメントが無い` を返す | - -### `pr-steps.py create` / `update` - -| 項目 | 変更後 | -| --- | --- | -| 本文 | 宛先が `merge_closes_issues` に当たれば、`Closes #12` などを `最終工程の後に閉じる: #12` へ置き換えてから送る。指し先の字面(`#12`・`owner/repo#12`・課題の URL)は変えない | -| `metrics.deferred_closing` | 新しい値。置き換えた指し先の字面の配列(置き換えなければ空) | -| `items` | 置き換えたら `{"kind": "closing", "name": "<指し先>", "result": "deferred"}` を指し先ごとに足す | -| `metrics.closing_issues_in_body` | 2 つの形の指し先をどちらも載せる | -| `status` | 置き換えは `ok` のまま(欠けではない) | - -### 閉じる予定の参照の形 - -```text -最終工程の後に閉じる: <指し先> -``` - -`<指し先>` は closing keywords と同じ 3 つ(`#<番号>`・`<所有者>/<リポジトリ>#<番号>`・課題の URL)。`:` の前後の空白は 0 個以上を許す。1 つの指し先に 1 つ書く(closing keywords と同じ規則)。 - -## 処理の流れ - -### `sprint-close.py` の課題 1 件の判定 - -```mermaid -flowchart TD - A[課題 1 件] --> B{record-pr が unknown か
条件 1 を満たさない} - B -->|当たる| K1[kept_open
今の理由] - B -->|当たらない| C{条件 2 を見るか
with-verification
または最終工程がリリース後テスト} - C -->|見る| D[錠の中で状態と本文を 1 回読む] - C -->|見ない| E[最終工程が振り返りなら
状態と本文を 1 回読む] - D -->|読めない| F[failed] - E -->|読めない| F - D --> G{条件 2} - G -->|満たさない| K2[kept_open / closed_unverified] - G -->|満たす| H - E --> H{条件 3
final_stage_gap} - H -->|記録が無い| K3[kept_open / closed_unverified
最終工程の記録が無い] - H -->|記録がある| I[受け入れ条件の印 → ボード Done → 閉じる → 読み直す] -``` - -条件 3 は条件 2 と同じ 1 回の本文の読みを使う(`gh` の呼び出しを増やさない)。比べる基準の識別子は、条件 1 で記録の PR を読んだ 1 回の結果から `parse_record` が返す `dist_comment_id` を使う(「記録の PR の読み」)。印を書くのは条件 3 も満たしたときだけで、条件 3 を満たさない課題の本文は書かない。`--dry-run` は判定まで行い、書かない。 - -### まとめのプランの並び - -```mermaid -flowchart LR - M[merge] -->|配布の記録あり| RV[release-verify
課題を閉じない] - M -->|配布の記録なし| RT - RV --> RT[retro
課題を閉じない] - RT --> CL[close
sprint-close.py --final-stage 振り返り] - CL -->|0 で終わったときだけ| RF[refine] -``` - -### Pull Request の本文を送る - -```mermaid -sequenceDiagram - participant W as pr-steps.py / supervise_lib/pr.py - participant R as lib/repo.py - participant C as lib/closing.py - participant G as gh - W->>R: merge_closes_issues(root, base) - alt 宛先が既定ブランチ(決められないときを含む) - W->>C: defer_closing(body) - C-->>W: 置き換えた本文と指し先 - end - W->>G: gh pr create / gh pr edit(本文) -``` - -## 非機能の実現方式 - -| 大項目 | 実現方式 | -| --- | --- | -| 運用・保守性 | 閉じる時点の決まりは `progress-tracking` の「スプリントを閉じる」だけが持つ。判定は `sprint-close.py` の `final_stage_gap` と既存の条件 1・2 だけに置き、まとめのプランと Skill の本文は `--final-stage` の値を渡すだけにする。閉じる指定の形は `lib/closing.py` だけが持ち、`closing-issues.sh` は同じ形を読む(2 つの読み手が同じ結果を返すことをテストで縛る) | -| 移行性 | 同じ版で呼ぶ側(まとめのプラン・Skill の本文)も直す。利用者の手での移行は要らない。この版より前に振り返りを通った課題は既に本文に辿る経路の行を持つ(#2216・#2227・#2229 で確かめた)ため、そのまま閉じられる。行を持たずに開いている課題は `kept_open` で残り、`next` が「振り返りを通すか、確かめて手で閉じる」を示す(決定 4) | - -## 決定の記録 - -[issue-2228-design-decisions.md](issue-2228-design-decisions.md) に分けた。 - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1 | 文書の参照はチェックスクリプトと `grep` で見る(文言を照合するテストは書かない) | — (マージ前に `grep` で、閉じる条件と順序を持つ文書が正本の 1 か所であることを見る) | -| 受け入れ条件 2・I1・I3 | 本文に辿る経路の行が無い課題で、`--final-stage` を省いて打つと、`--with-verification` の有無の両方で `kept_open` と「振り返り」を含む `reason` が返り、閉じる・ボードを書く `gh` の呼び出しが無い | 既定値を `配布` にする・条件 3 を `--with-verification` の経路だけで見る・ボードを判定の前に書く | -| 受け入れ条件 2・I1(前の実行の行) | 本文に振り返りの行があるが、その識別子が記録の PR の最後の配布の記録のコメントの識別子より小さい課題(前のスプリントから持ち越した課題)で、`--final-stage` を省いて打つと `kept_open` と「振り返り」を含む `reason` が返る。識別子が大きい行を足すと閉じる | 行の形だけを見る・配布の記録の識別子と比べない・最初の配布の記録と比べる | -| 受け入れ条件 3・I2 | 課題の本文の読みが失敗すると `failed`・`status: stopped`・終了コード 1 になる | 読めないときに記録ありと読む・`kept_open` で `ok` を返す | -| 受け入れ条件 2・I1(比べる基準の取り方) | 記録の PR の最後の配布の記録がコメントにあれば、`parse_record` の `dist_comment_id` がそのコメントの識別子になる。本文にあれば最初のコメントの識別子、コメントが無ければ `None` で、`None` のときは振り返りの行があっても `kept_open` と「比べる配布の記録のコメントが無い」を含む `reason` が返る。本文にある記録で、振り返りの行が記録の PR の最初のコメントを指す課題は閉じる(識別子が基準と等しい)。どの場合も記録の PR を読む `gh` の呼び出しは 1 回 | 識別子を取るために `gh` をもう 1 回呼ぶ・本文の記録で行の有無だけを見る・最初の配布の記録のコメントを使う・基準と等しい識別子を数えない | -| 受け入れ条件 4・I4・I5 | 辿る経路の行があり、行がすべて合格の課題で印・`Done`・閉じるが今と同じ順で起きる。`--record-pr 0` で辿る経路の行があれば閉じる。`unknown` では本文を読まずにすべて `kept_open` | 条件 3 を条件 2 より前に置いて印を書かない・`unknown` で本文を読む | -| I4 | `--final-stage リリース後テスト` で `--with-verification` を付けずに打つと、不合格の行を持つ課題が閉じない | 最終工程の値を条件 2 に結び付けない | -| 受け入れ条件 5・I6 | 振り返りを通るまとめのプランの `next` をたどった `id` の並びが `release-verify → retro → close → refine`(記録なしでは `retro → close → refine`)。`retro` と `release-verify` の指示に閉じない旨がある | `close` の `next` を `retro` に戻す・`merge` の `next` を `close` にする | -| 受け入れ条件 6 | `light` を含むどのモードで組んでも、`close` は最後の工程のステップ(`retro`)の後、`refine` の前にある | モードで並びを分けて `close` を `retro` の前に置く | -| 受け入れ条件 7・I7・I9 | 既定ブランチと起点が同じ一時のリポジトリで `pr-steps.py create` / `update` を流すと、`gh` に渡る本文に closing keywords が無く、閉じる予定の参照が同じ指し先で残る。既定ブランチを決められない構成でも同じ。置き換えた本文に `closing_words` が何も返さない | 置き換えを `create` だけにする・既存の PR の宛先を見ない・印の語に `close` を含める | -| 受け入れ条件 7(プランの経路) | `supervise_lib/pr.py` の `_publish` が既定ブランチ宛で `gh` に渡す本文に closing keywords が無い | `_publish` で置き換えを通さない | -| 受け入れ条件 8・I10 | 閉じる予定の参照だけを持つ PR で `sprint-close.py --prs <番号> --dry-run` を打つと、その課題が `would_close`(辿る経路の行があるとき)で、参照だけの課題にも終了コード 2 にもならない。`closing-issues.sh` と `closing.closing_refs` が同じ本文から同じ指し先を返す | `closing-issues.sh` だけに形を足す・参照だけの課題の除外に新しい形を入れない | -| 受け入れ条件 9・I8 | 起点が既定ブランチと違う構成(宛先 `develop`)では、本文が書き換わらず、`sprint-close.py` が closing keywords から課題を集める(既存のテストが通る) | 宛先を見ずに常に置き換える | -| 受け入れ条件 10 | 子の課題で確かめる | — | -| 受け入れ条件 11 | リリース後テストで、最初のスプリントの課題の `closedAt` が本文の辿る経路の行が指すコメントの投稿時刻より後であることを見る | — | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2228 | 実装する | — | `plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/lib/closing.py`、`plugins/ndf/scripts/lib/dist_record.py`、`plugins/ndf/scripts/lib/closing-issues.sh`、`plugins/ndf/scripts/lib/repo.py`、`plugins/ndf/scripts/pr-steps.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/supervise.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/progress-tracking/SKILL.md`、`plugins/ndf/skills/pr/SKILL.md`、`plugins/ndf/skills/release-verification/SKILL.md`、`plugins/ndf/skills/retrospective/SKILL.md`、`plugins/ndf/skills/release/SKILL.md`、`plugins/ndf/skills/merged/SKILL.md`、`plugins/ndf/skills/development-workflow/references/`、`docs/glossary/glossary.json`、`docs/glossary.md` | -| #2082 | 取り込む | #2228 | — | -| #2119 | 取り込む | #2228 | — | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| コメントの識別子の大小と投稿の順 | GitHub のコメントの識別子が投稿の順に大きくなることに頼る(#2086 と記録の PR 2109 の 1 組で確かめた)。順に並ばない例が出たら、投稿時刻の比較へ替える | -| 配布の記録が本文だけにある記録の PR | 本文には識別子が無いため、最初のコメントの識別子を基準に使い、等しいものも数える。記録の PR がその実行で作られ、最初のコメントが前の実行の振り返りより後にあることに頼る。コメントの無い記録の PR では振り返りを最終工程とする課題が閉じない(安全側に倒す)。記録の PR が本文の配布の記録だけで終わる実例は確かめていない | -| `--record-pr 0` の前の実行の行 | 配布の記録が無いため比べる基準が無く、前のスプリントの行を持つ持ち越しの課題は、その実行の振り返りの前に条件 3 を満たしうる。本番の記録が無い経路に限る | -| 辿る経路の行の中身 | 行の URL のコメントが実在するか・振り返りの記録の形かは読まない(課題ごとに `gh` を 1 回増やすため。決定 7) | -| `--with-verification` を付け忘れた振り返りの経路 | 振り返りの経路で `--with-verification` を省くと、条件 2 を見ずに閉じる(今と同じ)。順序はプランが守るが、不合格の行を持つ課題が閉じうる。範囲外の課題 #2293 として起票した | -| `closing-issues.sh` の日本語の一致 | `grep -E` が利用者の locale(`C` を含む)で `最終工程の後に閉じる` をバイト列として一致させるかを、実装で走らせて確かめる | -| 利用者が GitHub の画面で本文を書き直したとき | NDF の手順を通らない書き込みは置き換えない(要求の対象範囲の外) | diff --git a/issues/issue-2228-requirements.md b/issues/issue-2228-requirements.md deleted file mode 100644 index 384999e3f..000000000 --- a/issues/issue-2228-requirements.md +++ /dev/null @@ -1,169 +0,0 @@ -# #2228: 課題を閉じる時点を 1 つにし、最終の工程を通ってから閉じる - -正は課題の本文(#2228)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 依頼(原文) - -この課題は、起票者(AI)が子の 2 件(#2082・#2119)をまとめて起こした親の課題である。利用者の指示の原文は課題にも記録にも見当たらない(2026-10-31 の利用者の方針 5 点〔`--loop` を残す・codex / agy / kiro への配布を cross 系に絞る・全工程で統一した JSON レポート・並列に強く・テストを削る〕には、課題を閉じる時点の項目が無い)。マイルストーン「26 自律実行の信頼性」の説明の主題 2 と、起票時の本文を写す。 - -> 課題を閉じる時点を 1 つにし、工程を通ってから閉じる(親 B) - -(書き手: 起票者〔マイルストーンの説明に AI が書いた主題。利用者の発言の原文ではない〕、日付: 2026-10-31) - -起票時の本文(題「課題を閉じる時点を 1 つにし、最終の工程を通ってから閉じる」): - -> ## 何が困るか -> -> 課題が工程の途中で閉じる。閉じる経路が 3 つあり、どれも工程の通過を見ない。 -> -> | 子 | 閉じる経路 | -> | --- | --- | -> | #2082 | 既定ブランチ宛の実装 PR の本文のclosing keywordsで、GitHub がマージの時点に閉じる | -> | #2119 | `sprint-close.py` が工程の通過を見ない。雛形の `supervise_lib/sprint.close_plan` は release-verify → close → retro の順で、振り返りの前に閉じる(`progress-tracking/SKILL.md` の「振り返りを通る変更では、retrospective が記録を投稿した後に閉じる」と食い違う) | -> -> ## 修正レイヤー -> -> **課題を閉じる時点の正本。** `progress-tracking` の「スプリントを閉じる」を正本とし、`sprint-close.py` の入口で工程の通過を確かめる。`sprint.close_plan` の順序と、`pr` の宛先の表(closing keywordsを使う条件)をそれに従わせる。 -> -> ## 完了条件 -> -> - 最終の工程を通っていない課題は、どの経路でも閉じない -> - 閉じる順序が `progress-tracking` と雛形で一致する -> - 工程の通過は親 A のレポートから判定できる(A に依存) - -(書き手: 起票者、日付: 2026-10-31。利用者の指示の原文として扱わない。引用の中の「closing keywords」は、原文では用語集で廃した語で書かれていたものを用語集の語へ置き換えた) - -子の課題: - -- #2082「既定ブランチ宛の実装 PR の本文のclosing keywordsで、マージの時点に課題が閉じ、仕上げの工程と受け入れ条件のチェックが閉じた課題に残る」(volareinc/carmo-screening#342 の振り返りから起票) -- #2119「`sprint-close.py` が工程の通過を見ず、振り返りの前に課題を閉じる」(#2086 の振り返りから起票。スプリント s2086 で、リリース後テストの前に #2086 が閉じ、その後のリリース後テストで受け入れ条件 10 が不合格になったが閉じたまま残った) -- 親 A は #2227「全工程の結果を統一した JSON レポートにし、失敗・未収束・未記録を ok で返さない」 - -## 起票者の推定 - -利用者の指示の原文が無いため、題・本文の条件はどれも起票者の推定である。そのうち要求の形を決める条件を並べる。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 閉じる時点の正本は `progress-tracking` の「スプリントを閉じる」の 1 か所である | 本文の「修正レイヤー」 | 受け入れ条件 1 | -| S2 | 工程の通過を確かめるのは `sprint-close.py` の入口である(呼ぶ側の手順の並びに頼らない) | 本文の「修正レイヤー」、#2119 の起票者の推定 (a) | 受け入れ条件 2・3 | -| S3 | まとめのプラン(`supervise.py new close`)の並びを、振り返り → 課題を閉じる → 棚卸しにする | 本文の「何が困るか」、#2119 の受け入れ条件 3・4 | 受け入れ条件 5・6 | -| S4 | 既定ブランチ宛の Pull Request の本文には、マージで課題を閉じる closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる GitHub の機能)を残さない | 本文の「何が困るか」の #2082 の行、#2082 の起票者の推定の 1 案目。#2082 の 2 案目(自動で閉じるのを前提にし、閉じた後でも受け入れ条件に印を付けられるようにする)は、「最終の工程を通っていない課題は、どの経路でも閉じない」と両立しない | 受け入れ条件 7・8・9。2 案目は採らない | -| S5 | 工程の通過は親 A(#2227)のレポートから判定できる | 本文の「完了条件」3 点目 | 前提 3・未決 1。判定の材料を何にするかは設計で決める | -| S6 | 閉じる経路は 3 つある | 本文の「何が困るか」1 行目。表には 2 行しか無い | 前提 1 で、この要求が扱う経路を 3 つに数え直す | - -## 目的 - -- 課題が閉じた時点で、その実行の最終工程(棚卸しの前に最後に通る工程)が終わっているようにする。閉じた課題は棚卸しの対象から外れ、不合格の条件や未通過の工程が見えなくなるためである(#2119 の s2086 の経緯) -- 閉じる時点の決まりを 1 か所に置き、スクリプト・まとめのプラン・Pull Request の本文の規則がそれに従うようにする。今は文書の決まりと雛形の並びが食い違い、決まりを守るのは呼ぶ側(conductor・引継ぎ文書)の手順の並びに頼っている - -## 前提 - -- 前提 1: この要求が扱う「閉じる経路」は次の 3 つである。(a) GitHub が Pull Request のマージの時点に、本文の closing keywords で課題を閉じる(#2082)、(b) `sprint-close.py` を、まとめのプラン・conductor・Skill の手順のどれかが打つ(#2119)、(c) `sprint-close.py` がボードの `Status` を `Done` にしたことで、ボードの `Auto-close issue` が課題を閉じる((b) の中の 1 手で、(b) の判定を通らないと起きない) -- 前提 2: 「最終工程」は、`progress-tracking` の「スプリントを閉じる」の定義(棚卸しの前にその実行で最後に通る工程)と、`development-workflow` の工程表(`workflow-common.sh` の `WF_STAGE_MATRIX`)のモードごとの必須・条件付きの区分で決まる。`light` は `配布`、`standard` と `legacy-refactor` と `documentation` は `振り返り`、`operation` はその実行で通る条件付きの工程による -- 前提 3: 親 A(#2227)の結果 JSON の共通形式が先に入る。工程の通過を判定する材料は、#2227 の形式の結果(`status` が `ok` のもの)か、それと同じ版に入る記録から取る。今の通過記録(`progress-record.sh` が積むファイル)と本文の `## 進行` は「工程に入った時点」の記録で、工程を終えたことを表さないため、それだけで通過とは読まない -- 前提 4: `--record-pr 0`(本番の記録なし)と `--record-pr unknown`(記録の置き場が分からない)の今の扱いは変えない。工程の通過の判定は、その上に足す閉じる条件である -- 前提 5: 子の 2 件(#2082・#2119)の受け入れ条件は、この課題の受け入れ条件に含める(受け入れ条件 10)。設計はこの課題で 2 件をまとめて行い、実装の Pull Request を子ごとに分けるかは計画で決める -- 前提 6: 振り返りの記録は、記録の Pull Request のコメントとして投稿される(`retrospective` の今の決まり)。この要求はその置き場を変えない - -## 対象範囲 - -含む: -- `progress-tracking` の「スプリントを閉じる」を閉じる時点の正本として、「最終工程を終えてから閉じる」の判定の材料と順序を書く -- `sprint-close.py` の入口で、課題ごとに最終工程を終えたかを判定し、終えていない課題を閉じない -- まとめのプラン(`supervise_lib/sprint.py` の `close_plan` と `sprint_close_steps.py`)の並びを正本に合わせる -- `pr` の宛先の表と「closing keywords は本文だけに書く」を、既定ブランチ宛の Pull Request で closing keywords を残さない形にする。closing keywords を持たない Pull Request から閉じる課題を集める手段(`--issues` か、それに代わるもの)を `sprint-close.py` に持たせる -- 正本を写している文書(`retrospective`・`release-verification`・`release`・`development-workflow` の references)の参照の付け替え - -含まない: -- 利用者が手で `gh issue close` や GitHub の画面で閉じること(NDF の経路ではない) -- 棚卸しが「やらない」と決めた課題を `not planned` で閉じること(`backlog-refinement` の `no-work.md`。工程を通らずに閉じるのが意図の経路で、最終工程の判定の対象にしない) -- `check-trigger.py` が Pull Request を閉じること(課題ではない) -- 親 A(#2227)の結果 JSON の形式そのものの変更 -- 他のリポジトリ(例: volareinc/carmo-screening)の既存の課題・Pull Request の書き直し - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 課題の Pull Request・スプリントの Pull Request がマージされた | 承認ゲート 2 の後のマージ | 宛先が既定ブランチで本文に closing keywords が残っていた → GitHub が課題を閉じる(受け入れ条件 7・8 で起きなくする) | — | -| E2 | 配布(開発版・本番)を終え、配布の記録を記録の Pull Request に置いた | `release` | 配布が失敗した → 閉じる条件 1 を満たさず、課題は開いたまま残る(今と同じ) | E1 | -| E3 | リリース後テストの結果を記録した | `release-verification`(リリース後テストを通る経路のとき) | 不合格・保留の行がある → 課題は閉じない(今の閉じる条件 2) | E2 | -| E4 | 振り返りの記録を記録の Pull Request のコメントに投稿した | `retrospective`(振り返りを通る経路のとき) | 投稿できなかった → 最終工程を終えていないと判定され、課題は閉じない(受け入れ条件 2) | E3(通るとき)、無ければ E2 | -| E5 | `sprint-close.py` が課題ごとに最終工程を終えたかを判定した | 最終工程を担う Skill・まとめのプランの close のステップ・conductor の手打ち | 判定の材料が読めない → 終えたとは読まず、閉じない(受け入れ条件 3)。終えていない → `kept_open` と通っていない工程を返す(受け入れ条件 2) | — (呼ぶ側の順序に頼らない。E4 の前に打たれても閉じない) | -| E6 | 判定を通った課題のボードを `Done` にし、課題を閉じた | E5 | 閉じられなかった → `failed` と打ち直すコマンド(今と同じ) | E5 | -| E7 | 棚卸しを始めた | まとめのプランの refine のステップ・工程表の次の工程 | — | E6 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 最終工程 | 用語集の語。棚卸しの前にその実行で最後に通る工程(前提 2) | -| スプリント課題 | 用語集の語。`sprint-close.py` が閉じる対象にする課題 | -| closing keywords | 用語集の語。Pull Request の本文に `Closes #123` のように書くと、マージ時に GitHub がその課題を閉じる機能 | -| 参照だけの課題 | 用語集の語。スプリントの Pull Request が番号で参照し、スプリント課題に入らない開いた課題 | -| 通過記録 | 用語集の語。進捗記録が実際に書かれた工程を課題ごとに残したファイル。工程に入った時点の記録である | - -## 受け入れ条件 - -各条件の末尾の括弧は、確かめる時期と手段を表す。 - -1. - [ ] `progress-tracking` の「スプリントを閉じる」が、課題を閉じてよい条件として「その課題の最終工程を終えたこと」と、それを判定する材料(未決 1 で決めるもの)を定める。`retrospective`・`release-verification`・`release`・`pr`・`development-workflow` の references は閉じる時点と順序の決まりを写さず、この節を指す(マージ前: `grep` で、閉じる時点の決まりを持つ文書がこの節の 1 か所であることを見る) -2. - [ ] 最終工程を終えていない課題(例: 振り返りを通るモードで振り返りの記録が無い)を含めて `sprint-close.py --record-pr <記録のPR> --issues <番号>` を打つ → `--with-verification` の有無にかかわらず、その課題は閉じず、ボードも `Done` にならず、結果 JSON の `items` に `kept_open` と、終えていない工程の名前を含む `reason` で載る(マージ前: `gh` と判定の材料を差し替えたテスト) -3. - [ ] 最終工程を終えたかを判定する材料が読めない(置き場が無い・読めない・`ok` の結果が無い)課題で同じコマンドを打つ → 終えたとは読まず、その課題は閉じず、`kept_open` か `failed` で理由つきで載り、結果 JSON の `status` は `ok` にならない(#2227 の読む側の規則に従う)(マージ前: テスト) -4. - [ ] 最終工程を終え、リリース後テストの行がすべて合格の課題で `--with-verification` を付けて打つ → 今と同じく受け入れ条件へ `[x]` を付けてボードを `Done` にし、閉じる(退行しない)。`--record-pr 0` と `--record-pr unknown` の今の扱いも変わらない(マージ前: 既存のテストと追加のテスト) -5. - [ ] 振り返りを通るモードで `supervise.py new close` が書くまとめのプランの `steps` の `id` を、`next` のつながりの順に並べる → リリース後テスト(配布の記録があるとき)→ 振り返り → 課題を閉じる → 棚卸しの順になり、`progress-tracking` の「スプリントを閉じる」の表と一致する(マージ前: プランを組んで並びを見るテスト) -6. - [ ] 振り返りを通らないモード・経路(`light`、振り返りを通らない `operation`)のまとめのプランでも、課題を閉じるステップはその経路の最終工程のステップの後、棚卸しのステップの前にある(マージ前: テスト) -7. - [ ] 開発の起点(`.ndf/worktree.json` の `base_branch`、無ければ既定ブランチ)が既定ブランチと同じ構成で、課題の Pull Request を NDF の手順(`pr-steps.py create` / `update`)で作る → でき上がった Pull Request の本文に、マージで課題を閉じる closing keywords(`Closes` / `Fixes` / `Resolves` と活用形)が残らない(#2082 の受け入れ条件 1)(マージ前: 一時のリポジトリの構成で `pr-steps.py` を流すテスト) -8. - [ ] 上と同じ構成で、スプリントの Pull Request(既定ブランチ宛)を NDF の手順で作る → 同じく closing keywords が本文に残らない。その Pull Request だけから成るスプリントで `sprint-close.py --record-pr <記録のPR> --prs <番号> --dry-run` を打つ → 対象の課題が `kept_open`(参照だけの課題)や終了コード 2(集めた課題が 0 件)にならず、閉じる対象(最終工程を終えていれば `would_close`)として判定される(#2082 の受け入れ条件 2・3)(マージ前: テスト) -9. - [ ] 開発の起点が既定ブランチと違う構成(このリポジトリの `develop` 宛)では、今と同じく本文の closing keywords が残り、`sprint-close.py` がそこから課題を集める(退行しない。#2082 の受け入れ条件 4)(マージ前: 既存のテストと追加のテスト) -10. - [ ] 子の 2 件(#2082・#2119)が、それぞれの受け入れ条件を満たして閉じている(子の課題で確かめ、この課題では閉じていることを見る) -11. - [ ] この版の後の最初のスプリントの仕上げで、引継ぎ文書や conductor の手順の並びに関わらず、どの課題もその最終工程の記録より前に閉じない(リリース後: スプリントの課題の `closedAt` と、振り返りの記録のコメントの投稿時刻(振り返りを通らない経路ではその経路の最終工程の記録の時刻)を比べる) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 運用・保守性 | 閉じる時点の決まりは `progress-tracking` の 1 か所、判定は `sprint-close.py`(とそれが使う `lib`)の 1 か所に置く。まとめのプランと Skill の本文に、閉じてよいかの判定を書き分けない(MVV の Value 6) | -| 移行性 | `sprint-close.py` の閉じる条件が増える(契約の変更)。呼ぶのは同じ版の `plugins/ndf` の中のプラン・Skill の本文だけなので、同じ版で呼ぶ側も直す。利用者の手での移行は要らない。この版より前に工程を通り、判定の材料を持たないまま開いている課題の扱いは未決 2 | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わる。`sprint-close.py` の閉じる条件・`kept_open` の理由・(足すなら)引数と、`pr-steps.py` が作る本文の closing keywords の扱い。読み手は同じ版の `plugins/ndf` の中だけで、版をまたぐ互換の経路は持たない(`互換なし`) | -| データ | 課題の本文・ボードの形は変えない。判定の材料の置き場を新しく足すかは未決 1 | -| 既存の振る舞い | 今は呼べば閉じていた課題が、最終工程を終えるまで `kept_open` で残る(意図した変化)。既定ブランチ宛の Pull Request の本文から closing keywords が消え、マージで課題が閉じなくなる(意図した変化) | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(`sprint-close.py` の判定・まとめのプランの並び・`pr-steps.py` の本文の単体テストと、`gh` を差し替えた結合テスト) | -| 静的解析 | CI の既存のチェック(`python3 scripts/check-skill-frontmatter.py`・`python3 plugins/ndf/scripts/instructions-check.py --root .` ほか) | -| 手動確認 | リリース後テストで、この版の後の最初のスプリントの仕上げの結果 JSON と、課題の `closedAt`・振り返りの記録の投稿時刻を見る(受け入れ条件 11)。既定ブランチが起点のリポジトリのスプリントが同じ期間にあれば、課題がマージの時点で OPEN のまま残ったことも見る(#2082 の受け入れ条件 5) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 判定のコードは `plugins/ndf/scripts/`(`sprint-close.py` と `lib/`)、プランの組み立ては `supervise_lib/`、決まりの正本は `plugins/ndf/skills/progress-tracking/SKILL.md`。Skill ごとの `scripts/` に同じ役割の関数を置かない(`lib/README.md` の「置いてよいもの・いけないもの」) | -| コーディング規約 | 結果 JSON は `lib/step_result.py` の形(#2227 の後の形)。`.md` の文言を照合するテストは書かない(AGENTS.md の DON'T) | -| テスト戦略 | 判定とプランの並びは単体テスト、`sprint-close.py` と `pr-steps.py` は `gh` を差し替えた結合テスト(`tdd-cycle` の testing-levels) | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存のテスト全体の実行、正本を写している文書の付け替え、まとめのプランと `sprint-close.py` の同時の更新 | -| 確認してから行う | `sprint-close.py` の閉じる条件・引数の変更と、`pr-steps.py` が作る本文の変更(契約の変更)。承認ゲート 1 の設計と承認ゲート 2 で人が見る | -| 行わない | 他のリポジトリの既存の課題・Pull Request の書き直し、#2227 の結果 JSON の形式の変更、棚卸しの `not planned` の閉じ方の変更 | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 1. 最終工程を「終えた」と判定する材料を何にするか。候補: (a) #2227 の結果 JSON(最終工程を担う Skill・ステップの `ok` の結果)、(b) 最終工程の成果物(振り返りの記録のコメント・リリース後テストの記録・配布の記録)が記録の Pull Request にあること、(c) 通過記録に「工程を出た」記録を足す。通過記録はリポジトリの外の手元のファイルで、別の機械・別のセッションからは読めないことを考える | 設計(承認ゲート 1 で利用者が確かめる) | 設計 PR | -| 2. この版より前に工程を通り、判定の材料を持たないまま開いている課題を、この版の `sprint-close.py` がどう扱うか(`kept_open` で残して手で閉じるか、移行の手段を持つか) | 設計 | 設計 PR | -| 3. 既定ブランチ宛の Pull Request の本文に closing keywords が書かれたとき、止めるか参照(`Refs #N`)へ置き換えるか(#2082 は「問わない」としている)と、閉じる課題を集める手段を `--issues` にするか本文の別の記法にするか | 設計 | 設計 PR | -| 4. 最終工程を担う Skill(`retrospective` など)が「スプリントを閉じる」を呼ぶ今の形を残すか、まとめのプランの close のステップだけにするか | 設計 | 設計 PR | From 135e2a02bad6e211604b5047189b017cc5791b84 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 00:33:30 +0000 Subject: [PATCH 03/46] =?UTF-8?q?Docs:=20=E9=85=8D=E5=B8=83=E5=85=88?= =?UTF-8?q?=E3=82=92=204=20=E3=81=A4=E3=81=AB=E3=81=97=E3=81=A6=E3=81=84?= =?UTF-8?q?=E3=81=9F=E6=99=82=E7=82=B9=E3=81=AE=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=203=20=E6=9C=AC=E3=81=AB=E3=80=81=E9=85=8D=E5=B8=83?= =?UTF-8?q?=E5=85=88=E3=81=AE=E6=AD=A3=E6=9C=AC=EF=BC=88ndf-distribution-r?= =?UTF-8?q?untimes.md=EF=BC=89=E3=82=92=E6=8C=87=E3=81=99=E6=B3=A8?= =?UTF-8?q?=E8=A8=98=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-knowledge-and-kiro.md | 2 ++ docs/specifications/runtime-plugin-container-smoke.md | 2 ++ docs/specifications/runtime-plugin-distribution.md | 2 ++ 3 files changed, 6 insertions(+) diff --git a/docs/specifications/ndf-knowledge-and-kiro.md b/docs/specifications/ndf-knowledge-and-kiro.md index 4213f4f39..c6149dcf2 100644 --- a/docs/specifications/ndf-knowledge-and-kiro.md +++ b/docs/specifications/ndf-knowledge-and-kiro.md @@ -1,5 +1,7 @@ # NDF 知識構造・Kiro CLI 仕様 +> **配布先は Claude Code と Codex である(#2231)。** この文書に残る agy・Kiro CLI への配布・導入物・生成・検査・スモークの記述は配布先を 4 つにしていた時点のもので、配布先については [ndf-distribution-runtimes.md](ndf-distribution-runtimes.md) が優先する。 + ## 概要 NDF の知識配置(`AGENTS.md` と版と配布の正本、README の役割を含む)、Serena MCP 分離、Kiro CLI 対応に関する確定仕様。 diff --git a/docs/specifications/runtime-plugin-container-smoke.md b/docs/specifications/runtime-plugin-container-smoke.md index 274638d06..be8d359b6 100644 --- a/docs/specifications/runtime-plugin-container-smoke.md +++ b/docs/specifications/runtime-plugin-container-smoke.md @@ -1,5 +1,7 @@ # Runtime Plugin Container Smoke Test 仕様 +> **配布先は Claude Code と Codex である(#2231)。** この文書に残る agy・Kiro CLI への配布・導入物・生成・検査・スモークの記述は配布先を 4 つにしていた時点のもので、配布先については [ndf-distribution-runtimes.md](ndf-distribution-runtimes.md) が優先する。 + ## 概要 runtime 分離後の Claude Code / Codex / Kiro 向け plugin 配布物を、ホスト環境を汚染しない Docker コンテナ内で smoke test する仕様。 diff --git a/docs/specifications/runtime-plugin-distribution.md b/docs/specifications/runtime-plugin-distribution.md index 1719a1ef7..22dc864c2 100644 --- a/docs/specifications/runtime-plugin-distribution.md +++ b/docs/specifications/runtime-plugin-distribution.md @@ -1,5 +1,7 @@ # Runtime Plugin Distribution 仕様 +> **配布先は Claude Code と Codex である(#2231)。** この文書に残る agy・Kiro CLI への配布・導入物・生成・検査・スモークの記述は配布先を 4 つにしていた時点のもので、配布先については [ndf-distribution-runtimes.md](ndf-distribution-runtimes.md) が優先する。 + ## 概要 AI Plugins marketplace は、Claude Code / Codex / Kiro CLI / agy へ同じ plugin を配布する。 From b9300f92725851949b47e78a977bb12d5971e187 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:21:10 +0000 Subject: [PATCH 04/46] =?UTF-8?q?Docs:=20#2230=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=AE=E5=86=99=E3=81=97=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2230-requirements.md | 162 ++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) create mode 100644 issues/issue-2230-requirements.md diff --git a/issues/issue-2230-requirements.md b/issues/issue-2230-requirements.md new file mode 100644 index 000000000..3af296ca4 --- /dev/null +++ b/issues/issue-2230-requirements.md @@ -0,0 +1,162 @@ +# #2230: リリース記録の PR を、まとめのプランを組む時点でなく実行の時点で解決する + +正は課題の本文(#2230)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +この課題は、起票者(AI)が子の 2 件(#2044・#2121)をまとめて起こした親の課題である。利用者の指示の原文は課題にも記録にも見当たらない(2026-10-31 の利用者の方針 5 点〔`--loop` を残す・codex / agy / kiro への配布を cross 系に絞る・全工程で統一した JSON レポート・並列に強く・テストを削る〕には、リリース記録の PR を決める時点の項目が無い)。マイルストーン「26 自律実行の信頼性」の説明の主題 4 と、起票時の本文を写す。 + +> リリース記録の PR を、プランを組む時点でなく実行の時点で決める(親 D) + +(書き手: 起票者〔マイルストーンの説明に AI が書いた主題。利用者の発言の原文ではない〕、日付: 2026-10-31) + +起票時の本文(題「リリース記録の PR を、まとめのプランを組む時点でなく実行の時点で解決する」): + +> ## 何が困るか +> +> まとめ(sprint-close)のプランが、リリース記録の PR をプランを組む時点で決める。merge・手動の経路やループの経路では、その時点に記録の PR がまだ無い。 +> +> | 子 | 経路 | +> | --- | --- | +> | #2044 | merge・手動の経路。`close_waves` が最終の検査・リリース・まとめを 1 つのキューに組み、`record_pr_ref` が `unknown` を返す | +> | #2121 | 開発ループの承認ゲート 2 の後のまとめが、配布の記録の PR を読まない | +> +> ## 修正レイヤー +> +> **`supervise_lib/sprint_routes.record_pr_ref` と `close_plan`。** 記録の PR を、プランを組む時点ではなく実行の時点で解決する(`supervise_lib/queue.py` の `{queue_pr:<名>}` の解決を広げる)。 +> +> ## 受け入れ条件 +> +> - どの経路でも、まとめのステップが実行の時点で記録の PR を解決する +> - 解決できないときは `unknown` で進めず、止まる(親 A の規則に従う) + +(書き手: 起票者、日付: 2026-10-31。利用者の指示の原文として扱わない) + +子の課題: + +- #2044「new close: merge・手動の経路のスプリントで、リリース記録の PR をまとめの close へ渡す手段が無く、課題が --record-pr unknown で開いたまま残る」(#1875 の設計の工程で範囲外として起票、2026-10-17。受け入れ条件 3 件を持つ) +- #2121「開発ループの承認ゲート 2 の後のまとめが配布の記録の PR を読まない」(#2089 の cross-review で見送った 2 件から起票、2026-10-25。問題 1〔`--record-pr` が配布の記録の PR でない〕と問題 2〔manual ステージを含む経路で、まとめが手作業の前に走りうる〕を持つ) +- 親 A は #2227「全工程の結果を統一した JSON レポートにし、失敗・未収束・未記録を ok で返さない」 + +この要求は、人へ問わずに進める起動(AI の起動指示、2026-11-01: 「人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く」)で書いた。決められない点は「前提」と「未決」に置く。 + +## 起票者の推定 + +利用者の指示の原文が無いため、題・本文の条件はどれも起票者の推定である。そのうち要求の形を決める条件を並べる。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 記録の PR は、まとめのプランを組む時点でなく、まとめのステップを流す時点で決める | 本文の「何が困るか」「受け入れ条件」1 点目 | 受け入れ条件 1・2・3 | +| S2 | 解決できないときは `unknown` で進めず止まる(親 A の「欠け・未記録を ok で返さない」に従う) | 本文の「受け入れ条件」2 点目、#2227 の「直し方の方針」 | 受け入れ条件 4・5 | +| S3 | 直す場所は `sprint_routes.record_pr_ref` と `close_plan` で、解決は queue の `{queue_pr:<名>}` の解決を広げて行う | 本文の「修正レイヤー」、#2121 の直し方の候補 B | 前提 1。解決の手段(差し込みを広げるか、ほかの手段か)は未決 1 として設計で決める | +| S4 | 子の 2 件(#2044・#2121 の問題 1)は同じ層の直しで解ける | 本文の子の表、#2044 の「現状と修正レイヤー」 | 受け入れ条件 1〜3 | +| S5 | #2121 の問題 2(manual ステージを含む経路で、まとめが手作業の前に走りうる)はこの課題の範囲に入らない | 本文の子の表が #2121 を問題 1 だけで書いていること | 含まない。ただし記録の PR が無い時点でまとめの close が走れば止まる(受け入れ条件 4)ため、手作業の前に課題が閉じる害はこの課題で防ぐ | +| S6 | merge・手で届ける経路でも、記録の PR が解決できたならリリース後テストのステップをまとめに置く(close は `--with-verification` を渡すため、置かないと課題が閉じない) | #2044 の「残ること」(本番まで出て受け入れ条件を満たしたスプリントでも毎回手で閉じる)と、`_close_step` が常に `--with-verification` を渡すこと | 受け入れ条件 3 | + +## 目的 + +- 本番まで届けて受け入れ条件を満たしたスプリントで、リリースの経路(雛形・昇格・merge・手で届ける・検証の環境だけ・開発ループの周回)にかかわらず、まとめの close がリリース記録を読んで課題を閉じられるようにする(今は merge・手で届ける経路で毎回手で閉じ、開発ループでは検査のスプリント PR を記録として読む) +- 記録の PR が見つからないとき、まとめが「閉じずに ok」で先へ進まず、止まって理由を返すようにする(親 A の規則) + +## 前提 + +- 前提 1: 直す層は、まとめのプランが記録の PR を決める箇所(`supervise_lib/sprint_routes.record_pr_ref`・`supervise_lib/sprint.close_plan`)と、ステージを流す時点の差し込みの解決(`supervise_lib/queue_fill.py`)、開発ループのまとめの組み立て(`lib/sprint_loop.gate2_pipeline`・`lib/sprint_loop_gate.build_close`)である。`sprint-close.py` の閉じる条件(`--with-verification`・最終工程の記録)は変えない +- 前提 2: merge・手で届ける経路でも、`release` の手順はリリース記録(`## 配布の記録`)を Pull Request に残す(`plugins/ndf/skills/release/SKILL.md` の「出力物」: リリースの PR の本文、PR を作らない形ではスプリントの最後にマージした PR へコメント)。この課題はその記録を読む側を直し、記録を書く手順の形は変えない(記録の PR の番号を置く場所を足すかは未決 1) +- 前提 3: 「実行の時点」は、まとめのステージ(close の queue)がそのプランを流し始める時点を指す。まとめのプランを組む時点(`supervise.py new close`・`new sprint` の組み立て、開発ループの承認ゲート 2 の時点)に記録の PR が無くてもよい +- 前提 4: 届けない経路だけのスプリント(記録の PR が `0`。`sprint_routes.RECORD_NONE`)は、記録を読まずに閉じる今の扱いを変えない +- 前提 5: 呼ぶ側が `--record-pr <番号>` を明示して `new close` を打ったときは、その番号を使う今の扱いを変えない + +## 対象範囲 + +含む: + +- merge(`merged-by-check`)・手で届ける・検証の環境だけ(`production: false` の行だけ)・手動反映の本番系の昇格の経路で、まとめの close が `unknown` でなく、実行の時点に解決したリリース記録の PR を受け取る(#2044) +- 開発ループ(`sprint-state.py loop`)の承認ゲート 2 の後のまとめが、検査のスプリント PR でなく、本番のリリース記録の PR を受け取る(#2121 の問題 1) +- 記録の PR を解決できないとき、まとめのステップが止まり、課題を開いたまま ok で返さない +- 上の経路で記録の PR が解決できたときに、まとめのリリース後テストのステップがその PR を受け取る + +含まない: + +- #2121 の問題 2(manual ステージを含む経路で、手作業を終えるまでまとめを始めない並びの直し)。子の #2121 で扱う。この課題では、記録が無い時点で走ったまとめが止まることだけを保証する +- リリース記録の形(`lib/dist_record.py`・`progress-tracking` の「配布の記録」の表)と、記録を書く `release-steps.py record` の出力の変更 +- `sprint-close.py` の閉じる条件(配布の段階・リリース後テスト・最終工程の記録)の変更 +- 雛形・昇格の経路で、本番のプランが飛ばされた(最終の検査で変更が無い)ときの扱い +- 課題を閉じる時点と順序(親 B の #2228)、結果の共通形式そのもの(親 A の #2227) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | まとめのプランを組んだ(記録の PR は未解決の参照として持つ) | `supervise.py new sprint` / `new close`、開発ループの承認ゲート 2 の後の `gate2_pipeline` | 組み立てが断る(今と同じ) | — | +| E2 | 本番のリリースを終え、リリース記録を Pull Request に残した | 雛形・昇格のプラン、merge の経路の導入の確認、手で行う `/ndf:release` | 記録が残らない → E4 で解決できない | E1 の後でも前でもよい(並びは前提 3) | +| E3 | まとめのステージを流し始めた | queue がまとめのステージへ進んだ(`--then`・単独の queue) | — | E2 の後であることを並びが保証しない経路がある(#2121 の問題 2。含まない) | +| E4 | 記録の PR を解決した | E3 | 見つからない → E5 | E2 | +| E5 | 記録の PR が見つからず、まとめが止まった | E4 の失敗 | — | E3 | +| E6 | リリース後テストのステップが記録の PR を受け取った | E4 の成功 | 今と同じ(リリース後テストの失敗) | E4 | +| E7 | まとめの close が記録の PR を `--record-pr` に受けて課題を閉じた | E6 の後の close のステップ | 閉じる条件を満たさない課題は開いたまま(今と同じ) | E4・E6 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| リリース記録 | release が Pull Request へ残す `## 配布の記録` のブロック。段階・版・スプリントの Pull Request を持つ(用語集の語) | +| 記録の置き場が分からない | まとめの close が読むリリース記録の Pull Request を決められない状態。今は `--record-pr unknown` で渡し、課題を開いたまま ok で返す(用語集の語。この課題の後は、まとめのプランがこの値を組まない) | + +## 受け入れ条件 + +- [ ] 1. merge(`merged-by-check`)・手で届ける・検証の環境だけの経路のスプリントで、`supervise.py new sprint` / `new close` が組むまとめの close のステップは `--record-pr unknown` を持たず、ステップを流す時点でリリース記録(`## 配布の記録`)を置いた PR の番号を `--record-pr` に受け取る(#2044 の受け入れ条件 1) +- [ ] 2. 開発ループの承認ゲート 2 の後のまとめの close は、止まった記録の `values.pr`(検査のスプリント PR)でなく、本番のリリース記録を置いた PR の番号を `--record-pr` に受け取り、配布して閉じる条件を満たした課題を閉じる(#2121 の受け入れ条件 1) +- [ ] 3. 受け入れ条件 1・2 の経路で記録の PR を解決したとき、まとめのリリース後テストのステップは同じ PR の番号を受け取る +- [ ] 4. まとめのステップを流す時点で記録の PR が見つからないとき、まとめは止まる(close を走らせないか、close が 0 でない終了コードで終わる)。課題は開いたまま残り、まとめの報告の結果は「完了」にならない。止まった理由に、探した経路と見つからなかったことが載る(#2044 の受け入れ条件 2) +- [ ] 5. どの経路のまとめのプランも、`--record-pr unknown` を組まない +- [ ] 6. 雛形(`{queue_pr:release-prod}`)と昇格(`{queue_pr:promote}`)の経路のまとめのプランの内容と、それを流したときの close の引数・出力は変わらない(#2044 の受け入れ条件 3) +- [ ] 7. 届けない経路だけのスプリントのまとめ(`--record-pr 0`)と、`--record-pr <番号>` を明示した `new close` の振る舞いは変わらない +- [ ] 8. 開発ループを使わない起動の出力と終了コードは、受け入れ条件 1・4・5 で変わる部分のほかは変わらない(#2121 の受け入れ条件 3) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 記録の PR の解決は 1 か所で行い、経路ごと(通常の起動・開発ループ)に別の解決の関数を持たない(Value 6) | +| 移行性 | 実行中のスプリントで、この版より前に組んだまとめのプラン(`--record-pr unknown` を持つもの)は、今の振る舞い(閉じずに ok)のまま流れてよい。組み直しは求めない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | `supervise.py new sprint` / `new close` が書くまとめのプランの close の引数が、merge・手で届ける経路で変わる。`sprint-close.py --record-pr unknown` の受け付けを残すかは未決 2。queue の差し込みの規則(`supervise_lib/plan.py` の説明)が広がる場合は、その説明も変わる | +| データ | 無し(リリース記録の形は変えない) | +| 既存の振る舞い | merge・手で届ける経路のまとめが、記録が見つからないときに「閉じずに ok」から「止まる」へ変わる。開発ループの承認ゲート 2 の後のまとめが、本番のリリース記録を読む | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(プランの組み立て・queue の差し込みの解決・開発ループの `gate2_pipeline` の単体テスト) | +| 静的解析・型検査 | リポジトリの CI と同じ検査(`ruff`。設定は `pyproject.toml`) | +| 手動確認 | リリース後テストで、merge の経路か手で届ける経路のスプリントを 1 本流し、まとめの close がリリース記録の PR を受けて課題を閉じること、記録を置かずに流したときにまとめが止まることを確かめる | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。変更は `plugins/ndf/scripts/` の中に置く。既定で動くコードから `plugins/ndf/scripts/experimental/` を参照しない | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。結果の形は `plugins/ndf/scripts/lib/README.md` の「手順のスクリプトの結果」 | +| テスト戦略 | 振る舞い(組んだプランの引数・差し込みの解決・止まる条件)を単体テストで押さえる。`.md` の文言を照合するテストは書かない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、雛形・昇格の経路の出力が変わらないことの確認 | +| 確認してから行う | queue の差し込みの規則(公開の契約)の拡張、`sprint-close.py` の引数の変更(承認ゲート 1 の設計で人が見る) | +| 行わない | リリース記録の形の変更、課題を閉じる条件の変更、#2121 の問題 2 の並びの直し | + +## MVV との突き合わせ + +反する疑いは無い。Value 6(根本原因の場所で直す: 子の 2 件を記録の PR を決める層で直す)、Value 5(どのプロジェクトでも使える形: 経路はリリースの宣言から決まり、ai-plugins の形を埋め込まない)、Value 2(利用者の手を止めない: 本番まで届けたスプリントの課題を手で閉じなくてよくなる)に沿う。記録が見つからないときに止まるのは、共通原則の優先順位(承認資料と記録の正しさを効率より上に置く)と親 A に沿う。 + +## 未決 + +1. **記録の PR を実行の時点で解決する手段。** 候補は (a) queue の `{queue_pr:<名>}` の探し場所を広げる(開発ループのまとめは本番のプランと別の queue・別のディレクトリにある)、(b) 手で行うリリースのステージ・merge の経路の導入の確認に、リリース記録を置いた PR の番号を記録するステップを足し、まとめがそれを読む(#2044 の直し方の案)、(c) まとめの close の前に、スプリントの PR を指すリリース記録を GitHub から探すステップを置く。設計(`design`)で決める +2. **`sprint-close.py --record-pr unknown` の受け付けを残すか。** まとめのプランは組まなくなる(受け入れ条件 5)。手で打つ入口として残すか、親 A に合わせて止まる(ok で返さない)値にするか、受け付けをやめるかを設計で決める From 69a634970c7b7d76cb467e8e2157b2d6e42df244 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:21:42 +0000 Subject: [PATCH 05/46] =?UTF-8?q?Docs:=20#2164=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=AE=E5=86=99=E3=81=97=E3=82=92=E7=BD=AE=E3=81=8F?= =?UTF-8?q?=EF=BC=88=E7=89=88=E6=95=B0=E3=82=92=E9=85=8D=E5=B8=83=E3=81=AE?= =?UTF-8?q?=E3=82=B9=E3=83=86=E3=83=83=E3=83=97=E3=82=92=E6=B5=81=E3=81=99?= =?UTF-8?q?=E6=99=82=E7=82=B9=E3=81=AB=E6=B1=BA=E3=82=81=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2164-requirements.md | 159 ++++++++++++++++++++++++++++++ 1 file changed, 159 insertions(+) create mode 100644 issues/issue-2164-requirements.md diff --git a/issues/issue-2164-requirements.md b/issues/issue-2164-requirements.md new file mode 100644 index 000000000..b82f8b644 --- /dev/null +++ b/issues/issue-2164-requirements.md @@ -0,0 +1,159 @@ +# #2164: スプリントのプランが作った時点の版数を配布と本番のプランへ書き込み、並行する別のスプリントがその版を先に出すとプランを作り直すまで止まる + +正は課題の本文(#2164)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +この課題には利用者が書いた文が無い。本文と追記はどれも AI が書いた(書き手の判定は各引用の下)。利用者の指示の原文として扱う文は無く、下の受け入れ条件は承認ゲート 1 で利用者が確かめる。 + +> スプリントのプランは、作った時点の版数(開発版 `10.23.0-dev.1`・本番 `10.23.0`)を配布と本番のプランへ書き込む。プランを作ってから配布するまでの間に、並行する別のスプリントが同じ版数を本番へ出すと、そのプランの配布と本番のプランは使えなくなる。 +> +> - スプリント m2087(#2087・#2104・#2142)は 2026-10-25 に `/work/ai-plugins/.ndf/tmp/sprint-m2087/sprint.json` を作り、`7-release.json`・`9-release-prod.json` が 10.23.0 を持った +> - その後、chrome-cdp(PR #2153)が 10.23.0 として本番へ出た +> - 2026-10-29、conductor は 10.23.0 の scripts でプランを作り直した(`/work/ai-plugins/.ndf/tmp/sprint-m2087b/sprint.json`、開発版 10.24.0-dev.1)。作り直しの間、キューは進まなかった +> - 古いプランのまま流したときに、配布の工程が版数の衝突を検出して止まるのか、そのまま進むのかは確かめていない +> +> (「どこで見つけたか」「なぜこの変更の範囲外なのか」「直さないと何が起きるか」「由来: PR #2160」の節は略さず課題の起票時の本文にある) + +(書き手: 起票者(スプリント m2087 の振り返りで範囲外の課題として起票した AI)、日付: 2026-10-29 ごろ。根拠: 本文が `out-of-scope` の節の形で、「由来 PR #2160」を持つ) + +> ## 再発(2026-10-31、スプリント m26-1) +> +> - 別のスプリント m1426 が並行して develop へ入り、先に v10.27.1 まで本番へ出した。m26-1 のスプリント PR https://github.com/devbasex/ai-plugins/pull/2268 は版の行で衝突し、conductor が版を 10.27.0 → 10.27.2 へ書き換えて流し直した(本番 PR https://github.com/devbasex/ai-plugins/pull/2281 ・ https://github.com/devbasex/ai-plugins/pull/2282 ) +> - 2 回目の発生。並行スプリントのたびに起きる前提で扱う + +(書き手: 起票者(m26-1 の conductor)、日付: 2026-10-31) + +> ## 受け入れ条件 +> +> 1. スプリントの配布と本番のプランは、版数をプランを作る時点で書き込まない。配布のステップを流す時点に、その時点の develop と main の版から決める +> 2. プランを作ってから配布するまでの間に、並行する別のスプリントがその版を出しても、プランを作り直さずに、次に空いている版で配布が進む +> 3. 本番のプランは、同じスプリントの開発版が使った版の系列(接尾辞を外した版)で出す。決めた版数は配布と本番の結果 JSON に載る +> 4. 版数を明示して作ったプランは、その版がすでに出ていれば配布の前に止まり、別の版で黙って進めない + +(書き手: 起票者(棚卸しで足した AI)、日付: 2026-10-31 ごろ) + +> 同じ事象を PR #2247 の作業中に確認した。 +> +> 同じ事象を PR #2298 の作業中に確認した。 + +(書き手: 起票者(`out-of-scope` の追記の形)、日付: 2026-10-31) + +## 起票者の推定 + +題・本文はすべて起票者が書いた。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 版数をプランを作る時点では決めず、配布のステップを流す時点に、その時の本番の版から決めるのがよい | 本文「起票者の推定」(m2087 の作り直しの経緯) | 受け入れ条件 1・2 | +| S2 | 古いプランのまま流すと、配布の工程が既に使われた版数でタグか CHANGELOG を作ろうとして止まる | 本文「起票者の推定」(推測。確かめていない) | **採らない**。2026-11-01 に使い捨ての worktree で `release-steps.py bump --plugin ndf --to 10.27.5-dev.1 --base develop` を打つと、develop・main とも 10.27.5 のところで「10.27.5 → 10.27.5-dev.1 へ上げた」と終了コード 0 で進み、5 ファイルを書き換えた。開発版の配布は止まらず、版を戻したまま develop へ入る。止まるのは本番の `release` が「タグ ndf--v10.27.5 は既にある」を見たときで、開発版の PR はすでに develop へマージ済みになっている(`release-steps.py` の `cmd_release`)。受け入れ条件 5 の根拠 | +| S3 | 受け入れ条件 1〜4 の文(本文の「受け入れ条件」の節) | 棚卸し | 受け入れ条件 1〜4 として引き継ぎ、観測できる形へ書き直した。2 の「配布」は開発版の配布と読む(1 が「配布と本番」を分けて書くため)。本番は 3 と 6 で扱う | +| S4 | 本番の版が、承認資料に書いた版とずれるときの扱い(本文に記述なし。この要求で足した) | 承認ゲート 2 の承認資料は版数を名前と中身に持つ(`issues/approval-ndf-v<版>.md`)。共通原則の Value 10 の但し書き(承認資料に載せる材料の正しさは効率の順位に当てない) | 受け入れ条件 6。問わずに選んだ案: 本番は承認資料の版で出し、その版がすでに出ていれば本番の前に止まって人へ戻す。別の版へ黙って移すと、人が承認した資料と出したものの版が食い違うため | +| S5 | 同じプランの配布のステップを流し直したとき、一度決めた版を使い続ける(本文に記述なし。この要求で足した) | 配布のステップは失敗後の流し直しがある(`bump` の「打ち直し」の扱い) | 受け入れ条件 7。流し直すたびに版を決め直すと、自分が出した開発版を「使われた版」と見て次の版へ進んでしまうため | + +## 目的 + +- 並行する別のスプリントが先に版を出しても、プランを作り直さずに配布まで進める(Vision: 人が手を止めるのは承認の 2 回に寄る。2 回の再発では conductor の作り直しと版の書き換えで止まった) +- 古いプランが既に使われた版より古い版へ戻して配布する事故を、配布の前に止める + +## 前提 + +- 前提 1: 対象は `supervise.py` が組むスプリントのプランのうち、配布を雛形で組むもの(`.ndf/release.json` の `form: package-plugin`。版数が要るのは `delivery.needs_version` が真の経路だけ)。雛形で組まない経路は版数を持たないので変えない +- 前提 2: 「使われた版」(この要求の中だけの言い方。リリース済み版より広く開発版も含む)は、開発版なら origin のベースブランチ(`.ndf/worktree.json` の `base_branch`。ai-plugins は develop)の `plugin.json` の版、正式版なら origin のリリースタグ(`--v<版>`)と本番のブランチの版で判定する。どちらも配布のステップの開始時に `git fetch` した後の値を読む +- 前提 3: 開発版の版の決め方は「版と配布の方針」に従う。ベースブランチの版が未リリースの正式版 X の開発版 `X-dev.N` なら `X-dev.(N+1)`、そうでなければ(ベースブランチの版が正式版、または X のタグがすでにある)最新のリリースタグの版を上げ幅で上げた Y の `Y-dev.1` +- 前提 4: 上げ幅(PATCH / MINOR / MAJOR)はプランを作る時点で指定でき、指定が無ければ PATCH とする(マイルストーンの間は PATCH で出す運用に合わせる)。上げ幅を版の決め方へどう渡すかは設計が決める +- 前提 5: m26-1 でスプリント PR が「版の行で衝突した」仕組み(スプリントの PR が版の行を持った理由)は調べていない。この要求は配布と本番のプランが版を決める時点だけを扱い、スプリント PR の衝突は受け入れ条件 2 が満たされたときに起きなくなるかを設計で確かめる + +## 対象範囲 + +含む: +- `supervise.py new sprint` / `new close` が組む開発版と本番のプラン(MVV 判定で進めるもの・`pace: normal` の配布を含む)が、版をどの時点に決めるか +- 版を名前に持つもの(worktreeのパス・ブランチ名 `release/v<版>`・承認資料のパス・CHANGELOG の節・タグ)が、決めた版に揃うこと +- 配布の `bump` が、既に使われた版以下へ版を戻さないこと +- 決めた版を開発版と本番の結果 JSON(`metrics.version`)に載せること + +含まない: +- 雛形で組まない配布の経路(`delivery` の行ごとの経路) +- 上げ幅を中身から自動で決めること(前提 4 のとおり指定か既定の PATCH) +- スプリントでない単独の `supervise.py new release` の版の決め方(版を明示して作るので、受け入れ条件 4 の止まり方だけが当たる) +- 他のプラグインの上げ幅(`bump-others`・`changed-plugins`)の決め方 +- 承認ゲート 2 の承認の記録の形(`approved_sha` で中身を照合する仕組み) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | スプリントのプランを作った(版数を持たない、または利用者が明示した版を持つ) | conductor の `supervise.py new sprint` | 宣言が欠ければ今と同じく作らずに止まる | — | +| E2 | 並行する別のスプリントが、ベースブランチか本番のブランチへ版を出した | 別のスプリントの配布 | — | E1 の後、E3 の前(起きないこともある) | +| E3 | 開発版の配布のステップが、ベースブランチ・本番のブランチ・タグを読んで開発版の版を決めた | キューが配布のプランに入った | origin を読めなければ版を推定せず止まる(受け入れ条件 8) | E1 | +| E4 | 明示した版がすでに出ていると分かり、配布の前に止まった | E3 で明示の版を照合した | — | E3 | +| E5 | 開発版をベースブランチへ出し、決めた版を結果 JSON に載せた | E3 | 今の失敗の経路(judge)のまま | E3 | +| E6 | 承認ゲート 2 で利用者が承認した(承認資料は E3 で決めた版の正式版を持つ) | 利用者 | — | E5 | +| E7 | 本番のステップが、承認資料の版がまだ出ていないことを確かめた | キューが本番のプランに入った | すでに出ていれば本番の前に止まり人へ戻す(受け入れ条件 6) | E6 | +| E8 | 本番へ出し、タグを打ち、決めた版を結果 JSON に載せた | E7 | 今の失敗の経路(judge・judge-record)のまま | E7 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 開発版 | 次に出す正式版の版数へ接尾辞(`-dev.N`)を付けた版。ベースブランチへ載る | +| 正式版 | 接尾辞の無い版。本番のブランチへ載り、リリースタグを持つ | + +## 受け入れ条件 + +- [ ] 1. 版数を明示せずに作ったスプリントのプラン(`sprint.json` と、開発版・本番のプランのファイル)は、具体的な版数(`10.27.6-dev.1` のような文字列)を持たない。worktreeのパス・ブランチ名・承認資料のパスも、プランを作った時点では版を含まない +- [ ] 2. 開発版の配布のステップは、流した時点の origin のベースブランチの版と最新のリリースタグから版を決める。例: ベースブランチが `10.27.5`・最新のタグが `ndf--v10.27.5`・上げ幅 PATCH なら `10.27.6-dev.1`。ベースブランチが `10.27.6-dev.1` でタグ `ndf--v10.27.6` が無ければ `10.27.6-dev.2` +- [ ] 3. プランを作った後、配布の前に別のスプリントがベースブランチか本番のブランチへ版を出しても、同じプランをそのまま流すと、2 の規則で次に空いている版に決まり開発版の配布が終わる。conductor がプランを作り直す・版を書き換える操作は要らない(作った時点のベースブランチが `10.27.5`、配布の前に別のスプリントが `10.27.6` を本番へ出したなら `10.27.7-dev.1`) +- [ ] 4. 版数を明示して作ったプランは、明示した版がすでに出ていれば(開発版はベースブランチの版と同じかそれより大きい版がある、正式版は同じ版のタグがある)、`bump` より前に止まり、止まった理由と既に出ている版を結果 JSON に載せる。別の版へ移って進まない +- [ ] 5. 配布の `bump` は、新しい版がベースブランチの版以下(semver の比較で同じか小さい)なら、ファイルを書き換えずに止まる(例: ベースブランチが `10.27.5` のとき `--to 10.27.5-dev.1` は止まる)。同じステップの打ち直し(作業場所がすでに新しい版で、ベースブランチの版より大きい)は今と同じく ok で返る +- [ ] 6. 本番のプランは、同じスプリントの開発版の版の系列(接尾辞を外した版。承認資料が持つ版)で出す。その版のタグがすでにあれば、本番のブランチへ入れる前に止まり、人へ戻す(止まった理由・既に出ている版・次に空いている版を結果 JSON に載せる)。別の版で黙って進めない +- [ ] 7. 同じプランの開発版の配布のステップを流し直すと、最初に決めた版を使う(自分が出した開発版を「使われた版」と見て次の版へ進まない) +- [ ] 8. 版を決めるときに origin のベースブランチ・本番のブランチ・タグのどれかを読めなければ、版を推定せずに止まり、読めなかったものを結果 JSON に載せる +- [ ] 9. 開発版と本番の結果 JSON(`release-steps.py release` の出力)の `metrics.version` に、決めた版が載る +- [ ] 10. 版数を明示して作ったプランで、明示の版がまだ出ていなければ、今と同じ版・同じステップで配布が進む(退行しない) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 版を決めた根拠(読んだベースブランチの版・最新のタグ・上げ幅)が配布の結果 JSON か状態のファイルに残り、後から読んで決め方を確かめられる | +| 移行性 | この変更より前に作ったプラン(版数を持つもの)は、明示の版を持つプランとして扱い、受け入れ条件 4・6 の止まり方が当たる。作り直しは求めない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。`supervise.py new sprint` / `new close` の `--version`(`close` は `--prod` も)が、雛形の配布で必須から任意になる(明示すれば今と同じ)。上げ幅を受ける手段が加わる。互換の経路あり(今の呼び方はそのまま動く) | +| データ | プランのファイル(`sprint.json`・開発版と本番のプラン)の版の持ち方が変わる。決めた版の置き場が加わる | +| 既存の振る舞い | `release-steps.py bump` が版を戻す呼び方を止める。開発版・本番のプランのworktree・ブランチ・承認資料の名前が流した時点で決まる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(版の決め方・`bump` の止まり方・プランが版を持たないことを単体で確かめる。受け入れ条件 2〜8 は一時の git リポジトリでベースブランチ・タグを作って確かめる) | +| 静的解析・型検査 | リポジトリの CI と同じ検査(`python3 scripts/check-skill-frontmatter.py`・`python3 plugins/ndf/scripts/instructions-check.py --root .`・`claude plugin validate .`) | +| 手動確認 | 次に並行するスプリントで、プランを作り直さずに開発版と本番が出たか(conductor の手当ての記録に版の書き換えが 0 件か)をリリース後テストで確かめる | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 版の決め方は `plugins/ndf/scripts/` の配布の共通の層に 1 か所で置き、開発版と本番・スプリントと最終の検査で分けない(Value 6)。実験版の置き場は使わない(既定で働くプランと配布を変えるため安定版の経路) | +| コーディング規約 | `AGENTS.md` のポリシーと、版数の扱いは `docs/versioning-and-distribution.md` の「版の付け方と開発版の配布」に従う | +| テスト戦略 | 単体テストで版の決め方と止まり方を、プランを組む関数のテストでプランが版を持たないことを確かめる。`.md` の文言は照合しない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存のテストの実行、配布のステップの結果 JSON の形を保つ | +| 確認してから行う | `--version` の必須を外す公開インタフェースの変更(承認ゲート 1・2 で人が見る) | +| 行わない | 版の規則そのもの(接尾辞の形・タグの形・チャネル)の変更(P1 に当たる)、既に作ったプランのファイルの書き換え、他のプラグインの上げ幅の変更 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 起票者の推定 S4(本番の版がすでに出ていたら止めて人へ戻す)と S5(流し直しで版を保つ)を採るか | 利用者 | 承認ゲート 1 | +| 版を決めて保つ場所(状態のファイルか、プランのプレースホルダをキューが埋めるか)と、上げ幅を渡す手段 | 設計(`design`) | 設計 PR | From 915d9c637f4a6665ab54692b1d89fae3d5b11622 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:29:07 +0000 Subject: [PATCH 06/46] =?UTF-8?q?Docs:=20#2164=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88=E7=89=88=E6=95=B0?= =?UTF-8?q?=E3=82=92=E3=82=AD=E3=83=A5=E3=83=BC=E3=81=AE=E5=B7=AE=E3=81=97?= =?UTF-8?q?=E8=BE=BC=E3=81=BF=E3=81=A7=E6=B5=81=E3=81=99=E6=99=82=E7=82=B9?= =?UTF-8?q?=E3=81=AB=E6=B1=BA=E3=82=81=E3=80=81bump=20=E3=81=8C=E7=89=88?= =?UTF-8?q?=E3=81=AE=E6=88=BB=E3=82=8A=E3=82=92=E6=AD=A2=E3=82=81=E3=82=8B?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 設計文書 issues/issue-2164-design.md(398 行) - 用語集へ「版の差し込み」「版の記録」「明示の版」を足し、docs/glossary.md を作り直す Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 + docs/glossary/glossary.json | 18 ++ issues/issue-2164-design.md | 398 ++++++++++++++++++++++++++++++++++++ 3 files changed, 419 insertions(+) create mode 100644 issues/issue-2164-design.md diff --git a/docs/glossary.md b/docs/glossary.md index f22ee0a7a..57c3b5712 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -323,6 +323,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 判断の項目 | `decisions` | 結果 JSON の decisions。スクリプトが決めたことと、その根拠を並べる | — | — | `docs/specifications/ndf-step-result-gaps-and-reading.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` | +| 版の差し込み | — | 配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める | — | — | — | +| 版の記録 | — | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠を持つ。1 本のプランにつき 1 回だけ書き、流し直しはこれを読む | — | — | — | +| 明示の版 | — | `new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index cc5007acb..201f99394 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4716,6 +4716,24 @@ "context": "ndf-workflow", "meaning": "Pull Request の本文に `最終工程の後に閉じる: #<番号>` と書く形。GitHub は閉じず、sprint-close.py がスプリント課題として集める。宛先が既定ブランチの Pull Request では、NDF の手順が closing keywords をこの形へ置き換える", "source": "plugins/ndf/skills/progress-tracking/SKILL.md" + }, + { + "term": "版の差し込み", + "context": "ndf-workflow", + "meaning": "配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める", + "source": "" + }, + { + "term": "版の記録", + "context": "ndf-workflow", + "meaning": "配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠を持つ。1 本のプランにつき 1 回だけ書き、流し直しはこれを読む", + "source": "" + }, + { + "term": "明示の版", + "context": "ndf-workflow", + "meaning": "`new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる", + "source": "" } ] } diff --git a/issues/issue-2164-design.md b/issues/issue-2164-design.md new file mode 100644 index 000000000..28e6ad842 --- /dev/null +++ b/issues/issue-2164-design.md @@ -0,0 +1,398 @@ +# スプリントの配布: 版数を配布のステップを流す時点に決め、並行するスプリントが先に版を出してもプランを作り直さずに出せる(#2164) + +## 目的 + +- **何が壊れているか**: スプリントのプランは、作った時点の版数を開発版と本番のプランへ書き込む。配布までの間に並行する別のスプリントがその版を出すと、プランは使えなくなる。古いプランのまま流すと、開発版の `bump` は版を戻したまま進み、本番の `release` がタグの重複に気づく時点では開発版の PR がすでにベースブランチへ入っている +- **誰が困るか**: スプリントを回す conductor(m2087 ではプランを作り直し、m26-1 では版を手で書き換えた。どちらもキューが止まった)と、版が戻った開発版を受け取る利用者 +- **直すと何が成り立つか**: 版を明示せずに作ったプランは、流した時点の origin から次に空いている版を決めて出す。明示した版がすでに出ていれば、`bump` より前に止まる + +## 適用範囲 + +- **働く範囲**: NDF を使うすべてのリポジトリのうち、`.ndf/supervise.json` の `release.form` が `package-plugin`(雛形で配る形)で、`delivery.needs_version` が真の経路だけ(要求の前提 1 は `.ndf/release.json` と書くが、`release.form` を持つのは `.ndf/supervise.json` である) +- **プロジェクトごとに違うもの**: ベースブランチと本番のブランチは `.ndf/worktree.json`、配るプラグインは `.ndf/supervise.json` の `release.plugin` から読む。上げ幅は `new sprint` / `new close` の引数で受ける +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 2026-11-01 に使い捨ての worktree で `release-steps.py bump --plugin ndf --to 10.27.5-dev.1 --base develop` を打つと、develop・main とも 10.27.5 のところで終了コード 0 で 5 ファイルを書き換えた | 実測(要求の起票者の推定 S2 の欄) | 今の `bump` は版を戻す呼び方を止めない。止める場所は `bump` か、その前である | +| `release-steps.py` の `cmd_release` は、開発版の PR をベースブランチへマージした後に `タグ --v<版> は既にある` を調べる | コードの読み取り(`plugins/ndf/scripts/release-steps.py` の `cmd_release`) | 今の唯一の止まり方は、開発版を出した後に働く | +| m2087(2026-10-29)はプランの作り直し、m26-1(2026-10-31)は版の書き換え(10.27.0 → 10.27.2)で進んだ | 実測(課題の本文の 2 回の発生) | 並行するスプリントのたびに起き、conductor の手当てで埋めている | +| キューは `{queue_pr:<名>}`・`{queue_state:<名>}` を、ステージを流す直前に名前の一致するプランの結果で埋め、埋められなければ流さずに `stopped` で返す | コードの読み取り(`supervise_lib/queue_fill.py`・`queue.py` の `fill_stage`。#2079) | 流す時点の値をプランへ入れる仕組みが既にある | + +要求と受け入れ条件は #2164 の本文にある(コピーは `issues/issue-2164-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | プラン・キュー・版の差し込み・版の記録・明示の版 | +| NDF のリリース(`ndf-release`) | 開発版・正式版・上げ幅・リリースタグ・`bump` | + +リリースが供給者、開発ワークフローが顧客の関係(顧客 / 供給者)。リリースは「次に空いている版」と「その版はもう出ているか」の 2 つの判定を 1 つのモジュール(`lib/release_version.py`)で提供し、キューの差し込みと `bump` がそれを呼ぶ。キューは判定の結果を版の記録とプランへ写すだけで、版の規則を持たない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 配布のプラン | 作るのは配布の雛形(`release_templates.py`)、作った後に書き換えるのはキューの版の差し込みだけ | 1 本のプランのファイル | — | プランの「版」の欄(プラグイン・チャネル・上げ幅・明示の版・系列)・版の差し込み | +| 版の記録 | キューの版の差し込み | 1 本のプランの版の決定(状態ディレクトリの `version.json`) | — | 決めた版・基底の版・決め方(決めた / 明示)・根拠(読んだ ref と版・最新のタグ・上げ幅) | +| 作業場所の版 | 配布の `bump` | 作業場所の `plugin.json` の `version` | 版数を持つほかのファイル | 旧版・新版 | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 配布のプラン | 版を明示せずに作ったプランのファイルと `sprint.json` は、具体的な版数を持たない。版を名前に持つ値(作業場所・`branch`・承認資料のパス・ステップの引数)は版の差し込みで書く | 雛形のテストが落ちる | +| I2 | 版の記録 | 決めた開発版は、読んだ origin のベースブランチの版より大きく、その基底の版のリリースタグは無い | 判定のテストが落ちる | +| I3 | 版の記録 | 1 本のプランにつき 1 回だけ書き、書いた後は流し直しでも書き換えない。流し直しは記録の版を使う | 差し込みのテストが落ちる | +| I4 | 版の記録 | 本番のプランの版は、明示が無ければ同じスプリントの開発版の記録の基底の版に等しい | 開発版の記録が無ければ差し込みを埋めず、プランを流さない | +| I5 | 版の記録 | 明示の版がすでに出ていれば記録を書かず、差し込みを埋めず、プランを流さない(開発版はベースブランチの版が同じか大きい、正式版は同じ版のタグがある) | キューの結果が `stopped` になり、既に出ている版と次に空いている版を項目に載せる | +| I6 | 版の記録 | origin のベースブランチ・本番のブランチ・タグのどれかを読めなければ、記録を書かない | I5 と同じく流さず、読めなかったものを項目に載せる | +| I7 | 作業場所の版 | `bump` は、新しい版がベースブランチの版以下か、新しい版が正式版でそのタグがあれば、ファイルを書き換えない | `bump` が `stopped`・終了コード 3 で返し、ステップは judge を通らずに止まる | +| I8 | 配布のプラン | 明示の版がまだ出ていなければ、差し込みの後のプランのステップと版は今の雛形が組むものと同じである | 雛形のテストが落ちる | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | スプリントのプランを作った(版の差し込みを持つ、または明示の版を持つ) | `supervise.py new sprint` / `new close` | キュー(E3 で差し込みを埋める) | +| E2 | 並行する別のスプリントが、ベースブランチか本番のブランチへ版を出した | 別のスプリントの配布 | E3 の判定(origin の版とタグとして読む) | +| E3 | 開発版のプランの版を決めた(版の記録を書き、差し込みを埋めた) | キューの版の差し込み | 開発版のプランの全ステップ・E7(本番の版の系列) | +| E4 | 明示の版がすでに出ていると分かり、プランを流さなかった | キューの版の差し込み | conductor(キューの結果 `stopped`) | +| E5 | 開発版をベースブランチへ出し、決めた版を結果 JSON に載せた | `release-steps.py release` | 承認資料(`approval-facts`)・E6 | +| E6 | 承認ゲート 2 で利用者が承認した | 利用者 | 本番のプラン(`approve` のステップ) | +| E7 | 本番のプランの版を、開発版の記録の基底の版に決め、タグが無いことを確かめた | キューの版の差し込み | 本番のプランの全ステップ。タグがあれば conductor(キューの結果 `stopped`) | +| E8 | 本番へ出し、タグを打ち、決めた版を結果 JSON に載せた | `release-steps.py release` | `record`・まとめの `close` | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 版の差し込み | 配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める | 追加(`ndf-workflow`) | +| 版の記録 | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠を持つ。1 本のプランにつき 1 回だけ書き、流し直しはこれを読む | 追加(`ndf-workflow`) | +| 明示の版 | `new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる | 追加(`ndf-workflow`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 版を渡さずにスプリントのプランを作る(上げ幅は `--level`、既定 PATCH) | conductor | +| F2 | 開発版のプランを流す時点で、origin から次に空いている開発版を決める | キュー | +| F3 | 本番のプランを流す時点で、開発版の記録から本番の版を決め、タグが無いことを確かめる | キュー | +| F4 | 明示の版がすでに出ていれば、プランを流さずに止める | キュー | +| F5 | 版を戻す `bump` と、既にタグのある正式版への `bump` を止める | 配布のステップ(新旧どのプランからも) | +| F6 | 決めた版と根拠を、版の記録と結果 JSON から読む | conductor・振り返り | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 版の判定(`plugins/ndf/scripts/lib/release_version.py`、新設) | origin を `git fetch` してベースブランチと本番のブランチの `plugin.json` の版・最新のリリースタグを読み、次に空いている開発版を決める(`decide_dev`)。ある版がもう出ているかを判定する(`taken`)。読めなかったものを返す。ファイルを書かない | +| キューの版の差し込み(`supervise_lib/queue_fill.py` に `fill_release_version` を足す) | プランの「版」の欄を読み、版の記録があればそれを使い、無ければ版の判定を呼んで記録を書き、差し込みを埋める。埋められなければ理由と判定の結果を返す | +| キューのステージの組み立て(`supervise_lib/queue.py` の `fill_stage`) | `fill_queue_prs`・`fill_queue_pr` の後に `fill_release_version` を呼び、返った判定の結果を流さなかった項目の `version` に載せる | +| 配布の雛形(`supervise_lib/release_templates.py`) | 版が無ければ、版を書く箇所すべてに版の差し込みを書き、プランへ「版」の欄を置く。版があれば今と同じ値を書き、「版」の欄の `明示` に入れる。`bump` のステップに `on_exit: {"3": "stop"}` を置く | +| スプリントのプランの組み立て(`supervise_lib/sprint_waves.py`・`sprint.py`) | 開発版と本番のプランへ明示の版(無ければ空)と上げ幅を渡す。本番の版を `prod_version(a.version)` で作らず、明示が無ければ系列(開発版のプランの名前 `release`)を渡す | +| 承認ゲート 2 の文(`supervise_lib/sprint_routes.py` の `normal_gate_2_stage`) | 承認資料のパスを、版を含む具体のパスではなく「開発版のプランの版の記録の `base` から作るパス」として書く | +| 版の引数(`supervise_lib/new_args.py`・`decl.py` の `require_versions`) | `--version`・`--prod` を雛形の配布でも任意にし、`--level`(`patch` / `minor` / `major`)を `sprint` と `close` に足す | +| 配布の `bump`(`release-steps.py` の `cmd_bump`) | 書き換える前に版の判定の `taken` と下限の比較を呼び、当たれば `stopped`・終了コード 3 で返す | +| Skill 本文(`development-workflow` の `references/pace.md`・`references/waiting.md`) | `new sprint` / `new close` の例から必須の `--version`・`--prod` を外し、版が決まる時点と止まったときの扱いを書く | + +```mermaid +graph TD + subgraph プランを作る + NEW[new sprint / close の版の引数とスプリントのプランの組み立て] --> TPL[配布の雛形] + end + subgraph 流す + Q[キューのステージの組み立て] --> FILL[キューの版の差し込み] + FILL --> JUDGE[版の判定] + FILL --> REC[(版の記録)] + Q --> ENG[プランの実行] + ENG --> BUMP[配布の bump] + BUMP --> JUDGE + end + TPL -->|版の差し込みと 版の欄| PLAN[(配布のプラン)] + FILL -->|差し込みを埋める| PLAN + JUDGE -->|git fetch と読み取り| ORIGIN[origin のブランチとタグ] + GATE[承認ゲート 2 の文] -.->|承認資料のパスを読む| REC +``` + +図に含めない要素: Skill 本文(手順の説明で、処理の流れに現れない)。 + +### 置き場所 + +```text +plugins/ndf/scripts/ +├── lib/ +│ └── release_version.py # 新設: 版の判定 +├── release-steps.py # cmd_bump に止まり方を足す +├── supervise_lib/ +│ ├── queue_fill.py # fill_release_version を足す +│ ├── queue.py # fill_stage から呼ぶ +│ ├── release_templates.py # 版の差し込みと 版の欄 +│ ├── sprint_waves.py # 明示の版・上げ幅・系列を渡す +│ ├── sprint.py # prod_version で本番の版を作らない +│ ├── sprint_routes.py # 承認ゲート 2 の文 +│ ├── new_args.py # --level・--version と --prod の help +│ └── decl.py # require_versions を外す +└── tests/ # 下の「テスト設計」 +plugins/ndf/skills/development-workflow/references/ +├── pace.md +└── waiting.md +docs/glossary/glossary.json # 用語 3 つ(glossary.py render で docs/glossary.md) +``` + +## 構造 + +```mermaid +classDiagram + class VersionInputs { + +plugin + +base_ref + +production_ref + +level + } + class Observed { + +base_version + +production_version + +latest_tag + +unreadable + } + class Decision { + +version + +base + +how + +existing + +next_free + +observed + } + VersionInputs ..> Observed : observe() + Observed ..> Decision : decide_dev() / taken() +``` + +- `observe(repo, inputs) -> Observed`: `git fetch -q origin --tags` の後に読む。読めなかったものは `unreadable` に名前(`origin/`・`origin/`・`tags`)で入れる。本番のブランチを宣言していないリポジトリでは本番のブランチを読まない(読めないには数えない)。タグが 1 つも無いことは読めないに数えず、`latest_tag` を空にする +- `decide_dev(obs, level) -> Decision`: 下の「処理の流れ」の規則で開発版を決める。`unreadable` が空でなければ決めない +- `taken(obs, version) -> Decision`: 開発版はベースブランチの版が `version` 以上、正式版は `--v` のタグがあるとき、`existing` に既に出ている版、`next_free` に次に空いている版を入れる +- いずれも純粋な関数ではない(`observe` が git を打つ)。`decide_dev` と `taken` は `Observed` だけから決まり、テストは `Observed` を組んで渡せる + +## データ構造 + +### プランの「版」の欄 + +配布のプランの最上位に置く(`作業場所` と同じ階層)。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `プラグイン` | 文字列 | 許さない | 版を上げるプラグイン(`release.plugin`) | +| `チャネル` | `dev` / `prod` | 許さない | 開発版か本番か | +| `上げ幅` | `patch` / `minor` / `major` | 許さない | 開発版を新しい系列で始めるときの上げ幅。既定 `patch` | +| `明示` | 文字列 | 許す | 明示の版。空(`null`)は「流す時点に決める」 | +| `系列` | 文字列 | 許す | 本番のプランだけ。版を読む開発版のプランの名前(スプリントでは `release`)。空は「明示の版を使う」。`チャネル` が `prod` で `明示` と `系列` が両方空なら、雛形が作らない | + +**この欄を持たないプラン(この変更より前に作ったもの)には、キューは何もしない。** その版は `bump` の止まり方(I7)だけが守る。 + +### 版の記録(`<プランの状態ディレクトリ>/version.json`) + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `version` | 文字列 | 許さない | 出す版(開発版は `X.Y.Z-dev.N`、本番は `X.Y.Z`) | +| `base` | 文字列 | 許さない | `version` の接尾辞を外した版。承認資料のパス `issues/approval--v.md` の版 | +| `how` | `decided` / `explicit` / `series` | 許さない | 決めた・明示を確かめた・開発版の記録から読んだ | +| `level` | 文字列 | 許さない | 使った上げ幅 | +| `observed` | オブジェクト | 許さない | 読んだ値(`base_ref`・`base_version`・`production_ref`・`production_version`・`latest_tag`)。本番のブランチを読まなかったときは `production_ref` を空にする(「該当なし」) | +| `series_from` | 文字列 | 許す | `how` が `series` のときだけ、読んだ開発版の記録のパス | +| `at` | 文字列(ISO 8601) | 許さない | 書いた時刻 | + +**時系列の扱い**: 記録は 1 回だけ書き、上書きしない(I3)。決め直しは起こらないので、履歴を持つ構造は要らない。プランを作り直したときは状態ディレクトリも新しくなり、別の記録になる。 + +| 機能 | 配布のプラン | 版の記録 | 作業場所の版 | +| --- | --- | --- | --- | +| F1 プランを作る | C | — | — | +| F2 開発版を決める | U(差し込みを埋める) | C | — | +| F3 本番の版を決める | U | C(自分)・R(開発版) | — | +| F4 明示の版で止める | R | — | — | +| F5 bump を止める | — | — | R | +| F6 根拠を読む | — | R | — | + +**移行**: 既に作ったプランのファイルは書き換えない(要求の「境界」)。「版」の欄を持たないプランは今と同じに流れ、版が戻る呼び方だけを `bump` が止める。 + +## 入出力の契約 + +### `supervise.py new sprint` / `new close` + +| 項目 | 変更 | +| --- | --- | +| `--version` | 雛形の配布でも任意。渡せば明示の版(今と同じプラン) | +| `--prod`(`close`) | 任意。渡せば本番の明示の版。渡さなければ開発版の記録の基底の版 | +| `--level`(新設、`sprint` と `close`) | `patch` / `minor` / `major`。既定 `patch`。明示の版を渡したときは使わない | +| 互換性 | 今の呼び方(`--version` あり)はそのまま動き、プランの中身は I8 のとおり今と同じ。`--version` を省いた呼び方は、今は `DeclError` で止まるため、壊れる呼び出し側は無い | + +`supervise.py new release` は変えない(`--version` は今までどおり必須で、明示の版として「版」の欄を持つ)。 + +### キューの結果(`supervise.py queue` の結果 JSON) + +版の差し込みを埋められなかったプランは、今の #2079 と同じ項目(`result: 流さなかった`・`unfilled: true`・`reason`)に `version` を足して返す。キューの `status` は今と同じく `stopped` になる。 + +| `version` の鍵 | 意味 | +| --- | --- | +| `cause` | `taken`(明示の版か本番の版がすでに出ている)/ `unreadable`(origin を読めない)/ `no_series`(本番が読む開発版の記録が無い) | +| `version` | 確かめた版(`unreadable` で開発版を決められなかったときは空) | +| `existing` | 既に出ている版(ベースブランチの版かタグ) | +| `next_free` | 次に空いている版(`taken` のときだけ。人へ戻す材料で、自動では使わない) | +| `unreadable` | 読めなかったものの名前の並び | + +埋めたプランは、記録のパスを進捗ログへ 1 行書く。決めた版は流したプランの `release` のステップの結果 JSON の `metrics.version` に載る(今の `cmd_release` が `--version` の値を載せるので、差し込みが埋まれば決めた版になる)。 + +### `release-steps.py bump` + +| 項目 | 内容 | +| --- | --- | +| 新しい失敗 | `--to` がベースブランチ(`origin/`)の版以下、または `--to` が正式版で `--v` のタグがある | +| 出力 | `status: stopped`・終了コード 3。`metrics` に `from`・`to`・`base_version`・`existing`・`next_free`。ファイルを書き換えない | +| 打ち直し | 作業場所がすでに `--to` で、ベースブランチの版が `--to` より小さいときは今と同じ `ok`(`already: true`) | +| ベースブランチの版を読めない | 書き換えずに `stopped`・終了コード 2(推定で進めない) | +| 互換性 | 他のプラグインを上げる `bump-others` も同じ `bump` を呼び、同じ止まり方が当たる | + +## 処理の流れ + +### 開発版の版を決める + +```mermaid +sequenceDiagram + participant Q as キュー + participant F as 版の差し込み + participant R as 版の記録 + participant J as 版の判定 + participant O as origin + Q->>F: ステージを流す前(queue_pr の後) + F->>R: 記録はあるか + alt 記録がある(流し直し) + R-->>F: 記録の版 + else 記録が無い + F->>J: observe(上げ幅つき) + J->>O: git fetch --tags と読み取り + alt どれかを読めない + J-->>F: unreadable + F-->>Q: 流さない(version.cause = unreadable) + else 明示の版がある + J-->>F: taken の判定 + opt すでに出ている + F-->>Q: 流さない(version.cause = taken) + end + F->>R: how = explicit で書く + else 明示の版が無い + J-->>F: decide_dev の版 + F->>R: how = decided で書く + end + end + F->>F: プランの差し込みを記録の値で埋める + F-->>Q: 流す +``` + +**開発版を決める規則(`decide_dev`)**: ベースブランチの版を B、最新の正式版のタグの版を T、本番のブランチの版を P とする。 + +1. B が `X-dev.N` で、タグ `--vX` が無ければ `X-dev.(N+1)` +2. そうでなければ、T・P・B の基底の版のうち最も大きいものを上げ幅で上げた Y の `Y-dev.1` + +例(受け入れ条件 2・3): B = `10.27.5`・T = `10.27.5`・PATCH なら `10.27.6-dev.1`。B = `10.27.6-dev.1` でタグ `ndf--v10.27.6` が無ければ `10.27.6-dev.2`。作った時点の B が `10.27.5` で、配布の前に別のスプリントが `10.27.6` を本番へ出したなら(B = `10.27.6`・T = `10.27.6`)`10.27.7-dev.1`。どちらの規則でも結果は B より大きい(I2)。 + +### 本番の版を決める + +本番のプランの差し込みは、記録が無ければ次の順に決める。 + +1. 「版」の欄に `明示` があればその版、無ければ `系列` の名前の開発版のプランの記録(同じディレクトリの、名前の一致するプランの状態ディレクトリ。`results_named` と同じ探し方)の `base`。開発版の記録が無ければ流さない(`no_series`) +2. `observe` の後に `taken` で `--v<版>` のタグを調べ、あれば流さない(`taken`。`next_free` を添える) +3. `how` を `explicit` か `series` にして記録を書き、差し込みを埋める + +**本番は次に空いている版へ自動で移らない。** 承認ゲート 2 で人が承認した承認資料の版と、出す版が食い違うためである(要求の S4・受け入れ条件 6)。 + +### `bump` の止まり方 + +`cmd_bump` は旧版を読んだ後、書き換えの前に `observe` で `origin/` の版を読み、`--to` との比較と、`--to` が正式版のときの `taken` を打つ。打ち直し(旧版と新版が同じでベースブランチの版が小さい)の判定は今の順のまま先に行う。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | 版を決めた根拠(読んだベースブランチの版・最新のタグ・上げ幅)が配布の結果 JSON か状態のファイルに残り、後から読んで決め方を確かめられる | 版の記録の `observed` と `level` に読んだ値を書き、止めたときはキューの項目の `version` に載せる | 一時の git リポジトリで差し込みを流し、記録の `observed` が作ったブランチとタグの版に一致することを見る | +| 移行性 | この変更より前に作ったプラン(版数を持つもの)は、明示の版を持つプランとして扱い、受け入れ条件 4・6 の止まり方が当たる。作り直しは求めない | 「版」の欄を持たないプランはキューが触らず、`bump` の止まり方(I7)がベースブランチの版以下と既にタグのある正式版を止める | 「版」の欄を持たないプランの `bump` へ、ベースブランチと同じ版とタグのある正式版を渡して止まることを見る | + +## 決定の記録 + +### 決定 1: 作業場所とブランチの名前まで流す時点の版に揃えるため、版をキューの差し込みで決める + +版は作業場所のパス・`branch`・承認資料のパス・ステップの引数のすべてに入り、プランの実行は最初のステップより前に作業場所を作る(`paths.ensure_worktree`)。キューの差し込みはプランの実行より前にプランのファイルを書き換える既存の層(`{queue_pr:<名>}`・#2079)で、埋められなければ流さずに `stopped` を返す扱いもそのまま使える。版の判定をプランの先頭のステップに置く案は、作業場所を作った後にしか動かず、名前を揃えられないため採らない。conductor が流す前に版を書き込む案は、今の手当てを手順にするだけで止まりどころが残るため採らない。 + +根拠: Vision / Value 4(MVV 版 3) + +### 決定 2: 流し直しで自分の開発版を「使われた版」と見ないため、決めた版を版の記録に 1 回だけ書いて使い続ける + +差し込みはプランのファイルを書き換えるため、多くの流し直しでは埋まった値がそのまま残る。それでも、記録を書いてからプランを書き換える間に落ちたときと、`release` が開発版を出した後の流し直しで、判定をやり直すと次の版へ進んでしまう。記録を先に書き、記録があれば判定を打たないことで、どの時点の流し直しでも同じ版になる(受け入れ条件 7)。プランのファイルだけを手がかりにする案は、上の間に落ちたときに判定をやり直すため採らない。 + +根拠: Value 1 / Value 4(MVV 版 3) + +### 決定 3: 承認した版と出す版を食い違わせないため、本番の版は開発版の記録から読み、タグがあれば止めて人へ戻す + +本番の版はスプリントの開発版の系列(接尾辞を外した版)で、承認資料はその版を名前と中身に持つ。本番の時点で origin から決め直すと、並行するスプリントが先に出したときに承認していない版で出る。タグがあれば流さず、既に出ている版と次に空いている版をキューの結果に載せて人へ戻す(受け入れ条件 6)。次に空いている版へ自動で移る案は、承認資料の材料の正しさを効率より下に置くことになるため採らない。 + +根拠: Value 10 の但し書き / Value 2(MVV 版 3) + +### 決定 4: 開発版と本番、キューと `bump` で版の規則を分けないため、判定を `lib/release_version.py` の 1 か所に置く + +次に空いている版を決める規則と、ある版がもう出ているかの判定は、キューの差し込み(開発版・本番・明示)と `bump` の 4 か所が使う。`lib/versions.py` の `next_version`・`compare_versions`・`release_base` を使い、git を読む部分だけを足す。`release-steps.py` の中に置く案は、`supervise_lib` が `release-steps.py` を読み込むことになり、層の向きが逆になるため採らない。 + +根拠: Value 6 / Value 5(MVV 版 3) + +### 決定 5: 既に作ったプランを書き換えずに版の戻りを止めるため、`bump` に下限の比較とタグの判定を足す + +要求の移行性は、作り直しを求めずに古いプランへ受け入れ条件 4・6 の止まり方を当てることを求める。古いプランは「版」の欄を持たないためキューは触れないが、開発版も本番も必ず `bump` を通り、`bump` はどのプランよりも前にファイルを書き換える唯一の場所である。止まったときは終了コード 3 を返し、ステップの `on_exit` で judge を通さずに止める。judge を通すと、修正(fix)の担当が版を書き換えて別の版で進みうるためである(受け入れ条件 4 の「別の版へ移って進まない」)。古いプランのファイルをキューが読み替える案は、要求の境界(既に作ったプランのファイルを書き換えない)に反するため採らない。 + +根拠: Value 1 / Value 6(MVV 版 3) + +### 決定 6: 上げ幅を中身から推さずに渡せるようにするため、`new sprint` / `new close` に `--level` を足し、プランの「版」の欄に載せる + +要求は上げ幅をプランを作る時点で指定でき、既定を PATCH とする(前提 4)。上げ幅は判定が新しい系列を始めるとき(規則の 2)だけ使い、ベースブランチが同じ系列の開発版なら使わない。既存の `bump-others` の上げ幅の表(承認資料の表)へ載せる案は、本番の承認資料が開発版の後にしかできず、開発版の版を決める時点で読めないため採らない。 + +根拠: Value 5(MVV 版 3) + +### 決定 7: `.ndf/release.json` の `{version}` と取り違えないため、版の差し込みの語を `{release_version}` と `{release_base}` にする + +`.ndf/release.json` の手順は `{version}` を `release-steps.py run` が埋める語として既に使っている。同じ語を差し込みに使うと、キューが手順の語まで埋める。基底の版を別の語にするのは、雛形が版から基底を作る `re.sub(r"-.*$", "", v)` を差し込みの語へ当てられないためである。 + +根拠: Value 8(MVV 版 3) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I1 | `--version` を省いた `new sprint`(pace の normal・fast・auto)と `new close` が書いた `sprint.json` と開発版・本番のプランに、`X.Y.Z` の形の版が無く、作業場所・`branch`・承認資料のパスが版の差し込みを持つ | 雛形が `a.version` の代わりに空の文字列や `prod_version(None)` を書くと落ちる | +| 受け入れ条件 2・I2 | `decide_dev` が要求の 2 つの例で `10.27.6-dev.1`・`10.27.6-dev.2` を返し、結果がつねにベースブランチの版より大きい | 規則の 1 をタグを見ずに当てる、または規則の 2 で T・P を見ないと落ちる | +| 受け入れ条件 3 | 一時の git リポジトリで、プランを作った後に origin のベースブランチと本番へ `10.27.6` とタグを足してからキューの差し込みを流すと、プランの版が `10.27.7-dev.1` に埋まる | 版をプランを作る時点の値で埋めると落ちる | +| 受け入れ条件 4・I5 | 明示の開発版がベースブランチの版以下のとき、差し込みがプランを流さず、項目の `version.cause` が `taken` で `existing` を持ち、プランのファイルが変わらない | 明示の版を確かめずに流す、または `next_free` で埋めると落ちる | +| 受け入れ条件 5・I7 | ベースブランチが `10.27.5` のとき `bump --to 10.27.5-dev.1` が終了コード 3 で止まり、作業場所のファイルが変わらない。打ち直しは `ok`。タグのある正式版への `bump` も止まる | 比較を `<` にする(同じ版を通す)、またはタグを見ないと落ちる | +| 受け入れ条件 6・I4 | 本番の差し込みが開発版の記録の `base` で埋まり、そのタグがあれば流さずに `existing` と `next_free` を載せる | 本番の版を origin から決め直すと落ちる | +| 受け入れ条件 7・I3 | 開発版の記録を書いた後、origin のベースブランチを記録の版へ進めてから差し込みを流し直すと、同じ版で埋まり記録が変わらない | 記録があっても判定を打つと落ちる | +| 受け入れ条件 8・I6 | origin を読めない(取り除いたリモート・無いブランチ)と、差し込みが記録を書かずに流さず、`unreadable` に名前が入る | 読めない値を空や 0 として判定すると落ちる | +| 受け入れ条件 9 | 差し込みを埋めた開発版のプランの `release` のステップの `cmd` が決めた版を `--version` に持つ | 差し込みを `release` のステップに書き漏らすと落ちる | +| 受け入れ条件 10・I8 | `--version` を渡した `new sprint` / `new close` のプランを差し込みの後に比べると、ステップと版がこの変更の前の雛形の出力と同じ(「版」の欄を除く) | 明示の版のプランに差し込みを書くと落ちる | +| 非機能(運用・保守性) | 版の記録の `observed` と `level` が、読んだブランチとタグの版と渡した上げ幅に一致する | 根拠を書かずに版だけを書くと落ちる | +| 非機能(移行性) | 「版」の欄を持たないプランをキューが書き換えず、その `bump` が受け入れ条件 5 のとおり止まる | キューが欄の無いプランへ判定を打つと落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2164 | 実装する | — | `plugins/ndf/scripts/lib/release_version.py`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/supervise_lib/queue_fill.py`、`plugins/ndf/scripts/supervise_lib/queue.py`、`plugins/ndf/scripts/supervise_lib/release_templates.py`、`plugins/ndf/scripts/supervise_lib/sprint_waves.py`、`plugins/ndf/scripts/supervise_lib/sprint.py`、`plugins/ndf/scripts/supervise_lib/sprint_routes.py`、`plugins/ndf/scripts/supervise_lib/new_args.py`、`plugins/ndf/scripts/supervise_lib/decl.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/pace.md`、`plugins/ndf/skills/development-workflow/references/waiting.md`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| スプリント PR の版の行の衝突(要求の前提 5) | m26-1 でスプリント PR が版の行を持った仕組みは読んでいない。この設計は配布のプランだけを変え、スプリントのブランチは版の行を書かないため、版の行の衝突は起きなくなると見込む。リリース後テストで、並行するスプリントのスプリント PR が版の行で衝突しないかを見る | +| 並行するスプリントが同じ系列の開発版を出したとき | 先のスプリントが `10.27.6-dev.1` をベースブランチへ出しただけ(本番はまだ)なら、後のスプリントは規則の 1 で `10.27.6-dev.2` になり、2 つのスプリントの本番が同じ `10.27.6` を求める。後に本番へ進む方は決定 3 のとおり止まって人へ戻る。この止まりが実際にどれだけ起きるかは、リリース後テストで conductor の手当ての記録から数える | +| `sprint-state.py loop sprint` の `--dev` | ループは今も `--dev` を必須にして `new sprint --version` へ渡す。明示の版として受け入れ条件 4・10 が当たり、退行はしないが、ループからは流す時点の版を選べない。要求の対象範囲(`new sprint` / `new close`)の外のため、範囲外の課題として起こす | +| `bump-others` の止まり方 | 他のプラグインの `bump` にも下限とタグの判定が当たる。他のプラグインのタグの付け方が `ndf` と違うリポジトリで誤って止まらないかは、実装のテストで `bump-others` の経路を 1 度通して確かめる | +| キューの耐久ステップでの判定 | 差し込みは耐久ステップ(`ndf.supervise.queue.fill`)の中で origin を読む。耐久の記録からの再生では判定を打ち直さず、記録の値が使われる見込みだが、実装で再生の経路を 1 度通して確かめる | From cda0bb6765bbd169b2976cfb91fc0409579cc387 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:31:12 +0000 Subject: [PATCH 07/46] =?UTF-8?q?Docs:=20#2230=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=EF=BC=88=E3=81=BE=E3=81=A8=E3=82=81=E3=82=92=E6=B5=81?= =?UTF-8?q?=E3=81=99=E6=99=82=E7=82=B9=E3=81=AB=E3=83=AA=E3=83=AA=E3=83=BC?= =?UTF-8?q?=E3=82=B9=E8=A8=98=E9=8C=B2=E3=81=AE=20PR=20=E3=82=92=20GitHub?= =?UTF-8?q?=20=E3=81=AE=E5=8F=82=E7=85=A7=E3=81=8B=E3=82=89=E8=A6=8B?= =?UTF-8?q?=E3=81=A4=E3=81=91=E3=82=8B=EF=BC=89=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 +- docs/glossary/glossary.json | 9 +- issues/issue-2230-design.md | 384 ++++++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+), 2 deletions(-) create mode 100644 issues/issue-2230-design.md diff --git a/docs/glossary.md b/docs/glossary.md index f22ee0a7a..43d4dc62c 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -242,7 +242,8 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 取り込んだ課題 | — | 受け入れ内容を別の課題の実装へ含めると決めた課題。設計の結果の表か `new sprint --absorb` で決める。実装のプランは `実行の条件` で飛ばして実行しない。`--absorb` で決めたときは自分の設計のプランを持たない | — | — | `docs/specifications/ndf-supervise-queue-resume-and-gate-evidence.md` | | 承認の証跡 | — | 承認ゲートで利用者が承認したことを示す、プランの外で残った記録。スプリント状態ファイルの承認ゲートの記録(`--by user --outcome approved`)・承認ラベル・承認資料の承認の記録のどれか。プランのステップは承認の証跡があるときだけ、承認を前提にした操作へ進む | — | — | `docs/specifications/ndf-supervise-queue-resume-and-gate-evidence.md` | | 手動確認の行 | — | 要求の検証手段の表で、項目が「手動確認」で始まり、2 列目が「無し」の形(「無し」「なし」「不要」「ない」「-」「—」「―」の単独か、直後に括弧・句読点・空白が続く形)でない行。項目が「手動確認(マージ前)」ならマージ前、それ以外はリリース後テストに確かめる | — | — | — | -| 記録の置き場が分からない | — | まとめの close が読むリリース記録の Pull Request を、プランが組まれた時点で決められない状態。sprint-close.py --record-pr unknown で渡し、課題を開いたまま残す | — | — | `docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md` | +| 記録の PR | — | リリース記録(## 配布の記録)を置いた Pull Request。まとめの close が --record-pr に受け、リリース後テストが記録を書く先。まとめを流し始める時点に解決する(#2230) | — | — | — | +| 記録の置き場が分からない | — | まとめの close が読むリリース記録の Pull Request が分からない状態。sprint-close.py --record-pr unknown で渡し、課題を開いたまま残す。まとめのプランは組まず(#2230)、手で打つ入口と前の版で組んだプランだけが渡す | — | — | `docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md` | | 設計の結果 | — | 設計文書の「設計の結果」の節の表。課題ごとに扱い(実装する / 取り込む / 閉じる)・取り込み先・触るファイルを書き、実装のステージの前に機械が読む | — | — | — | | 決定の見出し | — | 設計文書の囲みの外にある、決定 1 件を表す見出しの行。`## 決定の記録` で始まる H2 の節の `### ` の行か、H1 に「決定の記録」を含み `## 決定の記録` の節を持たない文書の `## 決定 <数字>…` / `### 決定 <数字>…` の行。pr-body-decisions.sh が設計 PR の本文の「決めたこと」へ写す | — | — | `docs/specifications/ndf-workflow-unit-and-gates.md` | | 決定を分けたファイル | — | 設計文書から決定の記録の節だけを別に置いたファイル。名前は `issue-<番号>-design-decisions.md` | — | — | `docs/specifications/ndf-workflow-unit-and-gates.md` | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index cc5007acb..2e65a3c6f 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -3153,10 +3153,17 @@ "source": "", "pending_source": "issues/old/issue-1485-design.md" }, + { + "term": "記録の PR", + "context": "ndf-workflow", + "meaning": "リリース記録(## 配布の記録)を置いた Pull Request。まとめの close が --record-pr に受け、リリース後テストが記録を書く先。まとめを流し始める時点に解決する(#2230)", + "source": "", + "pending_source": "issues/issue-2230-design.md" + }, { "term": "記録の置き場が分からない", "context": "ndf-workflow", - "meaning": "まとめの close が読むリリース記録の Pull Request を、プランが組まれた時点で決められない状態。sprint-close.py --record-pr unknown で渡し、課題を開いたまま残す", + "meaning": "まとめの close が読むリリース記録の Pull Request が分からない状態。sprint-close.py --record-pr unknown で渡し、課題を開いたまま残す。まとめのプランは組まず(#2230)、手で打つ入口と前の版で組んだプランだけが渡す", "source": "docs/specifications/ndf-sprint-misjudgments-unknown-record-and-manual-none.md" }, { diff --git a/issues/issue-2230-design.md b/issues/issue-2230-design.md new file mode 100644 index 000000000..a3ac0819f --- /dev/null +++ b/issues/issue-2230-design.md @@ -0,0 +1,384 @@ +# まとめの close: どのリリースの経路でも、まとめを流す時点でリリース記録の PR を見つけて課題を閉じる(#2230) + +## 目的 + +- **何が壊れているか**: まとめ(`supervise.py new close`・`new sprint` のまとめのステージ)のプランが、リリース記録の PR をプランを組む時点で決める。merge・手で届ける・検証の環境だけ・手動反映の本番系の経路では、組む時点に記録の PR が無いので `--record-pr unknown` を組み、課題が閉じないまま ok で返る(#2044)。開発ループの承認ゲート 2 の後のまとめは、検査のスプリント PR を記録の PR として受け取る(#2121 の問題 1) +- **誰が困るか**: 本番まで届けたスプリントの課題を毎回手で閉じる利用者と、配布済みの課題が開いたまま次の周回で選び直される開発ループ +- **直すと何が成り立つか**: どの経路でも、まとめを流し始める時点にリリース記録の PR を見つけ、リリース後テストと close が同じ PR を受け取る。見つからなければまとめは流れずに止まり、探した課題と PR を理由に載せる + +## 適用範囲 + +- **働く範囲**: 配布先のリポジトリでも働く(`plugins/ndf/scripts/` のまとめの組み立て・queue の差し込み・開発ループ) +- **プロジェクトごとに違うもの**: リリースの経路は今と同じく宣言(`.ndf/supervise.json` の `release.form`・`.ndf/project.json` の `delivery`)から決まる。記録を探すときに読むのは GitHub の課題と Pull Request の参照だけで、新しい設定と引数は足さない +- **当たるモード**: `pace: fast` / `pace: auto` のまとめ(`new close`)と、開発ループ(`sprint-state.py loop`)の周回のまとめ。`normal` のスプリントはまとめのプランを持たないため当たらない + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 課題 #2089 の履歴の参照(`gh api repos/devbasex/ai-plugins/issues/2089/timeline` の `cross-referenced`)が PR #2091・#2102・#2103・#2110・#2126・#2128・#2137・#2138 を返し、本文が `Closes #2089` を持つのは #2103 だけだった。#2103 の履歴の参照は #2120・#2126 を返し、#2126 の最後のリリース記録が `段階: 本番` と `スプリント: PR #2098 / #2102 / #2103 / …` を持つ | 実測(2026-11-01) | 課題 → 課題を閉じるスプリントの PR → それを参照する PR の 2 段で、記録を置いた PR に届く | +| 同じ実測で、#2138 のリリース記録(`段階: 本番`)の `スプリント:` に #2128(#2089 を参照するが閉じない確定仕様化の PR)が入っていた | 実測(2026-11-01) | 「課題を参照する PR」で絞ると後のスプリントの記録を選ぶ。「課題を閉じる PR」で絞る必要がある | +| `release` の「出力物」が、PR を作らない形ではスプリントの最後にマージした PR へ、それ以外はリリースの PR へ `## 配布の記録` を置き、`スプリント:` の行にスプリントの PR を並べると決めている(`plugins/ndf/skills/release/SKILL.md`) | 既存の形 | どの経路でも記録は Pull Request にあり、スプリントの PR を番号で指す | +| #2121 の再現(10.21.0 の `gate2_pipeline` に `values.pr = 2103` を渡すと `build_close` に `record_pr=2103` が渡る) | 実測(2026-10-25) | 開発ループの今の形は検査の PR を記録の PR として渡す | + +要求と受け入れ条件は #2230 の本文にある(コピーは `issues/issue-2230-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 開発の工程(`ndf-workflow`) | まとめのプラン・queue の差し込み・記録の PR・開発ループの周回 | +| リリース(`ndf-release`) | リリース記録(`## 配布の記録` のブロックと、その `段階:`・`スプリント:` の行) | + +開発の工程がリリースの順応者である。まとめはリリース記録の形(`lib/dist_record.py`)をそのまま読み、形を変えない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| まとめのプラン | 組み立ては `supervise_lib/sprint.close_plan`。組んだ後に書き換えてよいのは `supervise_lib/queue_fill` の差し込みの置き換えだけ(今の規則、#2079) | まとめのプラン(`close.json`) | ステップ(`release-verify`・`close`) | 記録の PR の参照 | +| リリース記録 | `release`(`release-steps.py record`・手で行う `/ndf:release`)。この変更は読むだけ | 記録を置いた PR | `## 配布の記録` のブロック | 段階・スプリントの PR の並び | +| 開発ループの周回 | `lib/sprint_loop` | 周回(`lap`) | — | 周回の結果(完了 / 失敗) | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | まとめのプラン | 組んだまとめのプランは `--record-pr unknown` を持たない | 組み立ての単体テストで落とす | +| I2 | まとめのプラン | 記録の PR の参照が `0` でないとき、`release-verify` のステップと `close` のステップは同じ参照を持つ | 組み立ての単体テストで落とす | +| I3 | まとめのプラン | `{queue_record}` を解決できない(記録が見つからない・GitHub を読めない)ときは、プランを書き換えずに流さず、理由にまとめの課題と探した PR を載せる | queue の結果が `stopped` になり、まとめは流れない | +| I4 | まとめのプラン | `{queue_record}` が採る記録は、`段階:` が本番か配布なしのものだけである | 本番の前の記録だけなら見つからない扱い(I3) | +| I5 | まとめのプラン | `{queue_record}` が採る記録は、`スプリント:` の PR のどれかが、本文の closing keywords か閉じる予定の参照でまとめの課題を指すものだけである | ほかのスプリントの記録は候補から外す | +| I6 | まとめのプラン | 開発ループを使わない起動で、雛形の経路は `{queue_pr:release-prod}`、昇格の経路は `{queue_pr:promote}` を今と同じ形で組む | 組み立ての単体テストで落とす | +| I7 | まとめのプラン | 届けない経路だけのスプリントは `0`、`--record-pr <番号>` を明示した `new close` はその番号を組む | 組み立ての単体テストで落とす | +| I8 | 開発ループの周回 | 差し込みを埋められずに流さなかった計画がある周回は、完了で締めない | 周回を失敗で締める | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | まとめのプランを組んだ(記録の PR は `{queue_record}` などの参照として持つ) | `close_plan`(`new sprint`・`new close`、開発ループの `build_close`) | queue | +| E2 | 本番のリリースを終え、リリース記録を Pull Request に残した | 雛形・昇格のプラン、手で行う `/ndf:release` | GitHub(E4 が読む) | +| E3 | まとめのステージを流し始めた | queue | `queue_fill` | +| E4 | 記録の PR を解決した | `queue_fill`(`{queue_pr:…}` か `{queue_record}`) | まとめのプラン(参照を番号へ置き換える) | +| E5 | 記録の PR が見つからず、まとめが止まった | `queue_fill` | queue の結果(`stopped`)、開発ループの `loop lap` | +| E6 | リリース後テストのステップが記録の PR を受け取った | まとめのプランの `release-verify` | `/ndf:release-verification` | +| E7 | まとめの close が記録の PR を `--record-pr` に受けて課題を閉じた | まとめのプランの `close` | `sprint-close.py` | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 記録の PR | リリース記録(`## 配布の記録`)を置いた Pull Request。まとめの close が `--record-pr` に受け、リリース後テストが記録を書く先 | 追加(`ndf-workflow`) | +| 記録の置き場が分からない | まとめの close が読むリリース記録の Pull Request が分からない状態。`sprint-close.py --record-pr unknown` で渡し、課題を開いたまま残す。この版からまとめのプランは組まず、手で打つ入口と前の版で組んだプランだけが渡す | 意味の変更(`ndf-workflow`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | merge・手で届ける・検証の環境だけ・手動反映の本番系の経路のまとめが、流し始める時点にリリース記録の PR を見つけて close へ渡す | `pace: fast` / `auto` でスプリントを閉じる conductor | +| F2 | 開発ループの周回のまとめが、本番のリリース記録の PR を見つけて close へ渡す | 開発ループ | +| F3 | 記録の PR が見つからないとき、まとめを流さずに止め、探した課題と PR を理由に出す | conductor と利用者 | +| F4 | 記録の PR を見つけたとき、リリース後テストのステップが同じ PR を受け取る | `/ndf:release-verification` | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| `sprint_close_lib/record_find.py`(新設) | まとめの課題から GitHub の参照を辿り、本番か配布なしの段階のリリース記録を置いた PR を 1 本決める。探した課題と PR と、本番の前の記録だけがあった PR を返す。GitHub を読めなければ理由を返す | +| `supervise_lib/queue_fill.py` | 差し込み `{queue_record}` を、計画の `課題` と `リポジトリ` から `record_find` で解決した番号で置き換える。解決できなければ計画を書き換えずに理由を返す | +| `supervise_lib/plan.py` | 置き換わらずに残った差し込みの検出(`QUEUE_NAMED`)に `{queue_record}` を入れる。説明(差し込みの規則)に `{queue_record}` を足す | +| `supervise_lib/sprint_routes.py` | `record_pr_ref` が、雛形と昇格の経路(開発ループを除く)は今の `{queue_pr:…}`、届けない経路だけなら `0`、それ以外は `{queue_record}` を返す。`RECORD_UNKNOWN` を消し、`has_record` は `0` でなければ真にする | +| `supervise_lib/sprint.py` | `close_plan` は変えない(`has_record` の意味が変わり、`{queue_record}` のときも `release-verify` を置く) | +| `lib/sprint_loop.py` | `gate2_pipeline` が `values.pr` を `build_close` へ渡さない。`cmd_lap` が差し込みを埋められずに流さなかった計画(`unfilled`)を失敗として数える | +| `lib/sprint_loop_gate.py` | `build_close` の `record_pr` 引数を消す | +| `lib/dist_record.py` | リリース記録の段階が本番か配布なしかの判定(`released`)を 1 つ持つ。`sprint-close.py` と `record_find` が同じ判定を使う | +| `sprint-close.py` | 閉じる条件の判定で `released` を使う(振る舞いは変えない)。`--record-pr unknown` の受け付けと振る舞いは変えず、説明から「`sprint_routes.py` が渡す」を外す | +| `skills/development-workflow/references/pace.md` | スプリントの終わりの `--record-pr` の説明を `{queue_record}` に直す | +| `skills/progress-tracking/SKILL.md` | `--record-pr unknown` の説明に、まとめのプランは組まないことを足す | +| `docs/glossary/glossary.json` と `docs/glossary.md` | 用語の表の 2 行を反映する(この設計の変更に含める) | + +```mermaid +graph LR + subgraph 組み立て["プランを組む時点"] + routes["sprint_routes.record_pr_ref"] + close["sprint.close_plan"] + loop["sprint_loop.gate2_pipeline / build_close"] + end + subgraph 実行["まとめを流し始める時点"] + queue["queue"] + fill["queue_fill"] + find["record_find(新設)"] + rec["dist_record.released"] + end + subgraph 閉じる["まとめのステップ"] + verify["release-verify"] + sc["sprint-close.py"] + end + lap["sprint_loop.cmd_lap"] + runp["supervise.py run"] -->|"残った {queue_record} で止まる"| left["plan.leftover_placeholders"] + loop --> close + close --> routes + close -->|まとめのプラン| queue + queue --> fill + fill -->|"{queue_record}"| find + find --> rec + sc --> rec + fill -->|番号を書いたプラン| verify + verify --> sc + queue -->|結果| lap +``` + +図には文書(`pace.md`・`progress-tracking`・用語集)を含めない。 + +### システムの文脈と配置 + +```mermaid +graph LR + conductor["conductor / 開発ループ"] -->|"queue(まとめのステージ)"| queue["supervise.py queue(手元のプロセス)"] + queue -->|"履歴の参照・本文・コメントを読む(gh)"| gh["GitHub(課題と Pull Request)"] + release["release(リリース記録を書く)"] -->|"## 配布の記録"| gh + queue -->|"sprint-close.py(gh)"| gh +``` + +解決は queue のプロセスの中で、まとめのプランを流す前に 1 回行う。GitHub へは今の `gh` の呼び出し(`lib/gh_call.py`)で読むだけで、書かない。 + +### パッケージ・モジュール構成 + +```text +plugins/ndf/scripts/ +├── sprint_close_lib/ +│ ├── collect.py # read_record・_read_sprint_pr(変えない。record_find が使う) +│ └── record_find.py # 新設: find_record +├── supervise_lib/ +│ ├── plan.py # QUEUE_NAMED と説明 +│ ├── queue_fill.py # {queue_record} の置き換え +│ ├── sprint_routes.py # record_pr_ref・has_record +│ └── sprint.py # close_plan(変えない) +├── lib/ +│ ├── dist_record.py # released +│ ├── sprint_loop.py # gate2_pipeline・cmd_lap +│ └── sprint_loop_gate.py # build_close +└── sprint-close.py # released を使う +``` + +## 構造 + +```mermaid +classDiagram + class RecordHit { + +pr: Optional~int~ + +issues: tuple~int~ + +searched: tuple~int~ + +pre_release: tuple~int~ + +error: str + +reason() str + } + class record_find { + +find_record(root, repo, issues) RecordHit + } + class queue_fill { + +fill_queue_pr(plan, items) Optional~str~ + -_fill_record(text, plan) tuple + } + class dist_record { + +parse_record(text, parts) dict + +released(stage) bool + } + class collect { + +read_record(root, repo, n) + } + queue_fill ..> record_find : queue_record の差し込み + record_find ..> RecordHit + record_find ..> collect + record_find ..> dist_record +``` + +`RecordHit` は値オブジェクトで、`pr` が `None` のとき `reason()` が理由の文を返す。`searched` は記録を読んだ PR、`pre_release` は記録はあるが段階が本番の前だった PR、`error` は GitHub を読めなかったときの理由である。 + +## 入出力の契約 + +### 差し込み `{queue_record}`(追加) + +| 項目 | 書くこと | +| --- | --- | +| 名前 | `{queue_record}`。計画の JSON のどこに書いてもよい(コマンドとプロンプトの両方) | +| 入力 | 計画の `課題`(整数の並び。空なら解決できない)と `リポジトリ`(`gh` を打つ作業場所。無ければ計画の置き場) | +| 出力(成功) | 計画の中の `{queue_record}` をすべて、記録の PR の番号(10 進の文字列)で置き換えて書き戻す | +| 失敗の形 | 計画を書き換えず、`差し込みを埋める前の計画の結果が見つからない: {queue_record}(<理由>)` を返す。queue はその計画を `流さなかった`・`unfilled` にし、queue の結果は `stopped` になる(今の `{queue_pr:…}` と同じ)。理由は「課題 #a #b を閉じる PR #x #y と、それを参照する PR #p #q を読んだが、本番か配布なしの段階のリリース記録が無い」の形で、本番の前の記録だけがあれば「(本番の前の記録だけ: PR #r)」を足す。GitHub を読めなければ「GitHub を読めない: 」 | +| `run` で流したとき | 置き換わらずに残った `{queue_record}` を `leftover_placeholders` が拾い、コマンドのステップを打たずに止まる(今の `{queue_pr:…}` と同じ) | +| 互換性 | 新しい差し込みで、既存の差し込みの意味は変えない | + +記録の PR を決める規則(`find_record`): + +| 順 | すること | +| --- | --- | +| 1 | まとめの課題ごとに、課題の履歴の参照(`cross-referenced`)のうち Pull Request のものを集める | +| 2 | 1 の PR のうち、本文の closing keywords か閉じる予定の参照(`lib/closing.closing_refs`)がまとめの課題を指すものを、スプリントの PR とする | +| 3 | 候補を、スプリントの PR と、スプリントの PR の履歴の参照のうち Pull Request のものの和にする | +| 4 | 候補ごとに本文とコメントを読み(`read_record`)、最後のリリース記録(`parse_record`)を取る | +| 5 | `スプリント:` の PR が 2 のスプリントの PR を 1 本以上含み、段階が本番か配布なし(`released`)の記録だけを残す | +| 6 | 残った中で、記録を置いたコメントの識別子が最も大きいもの(本文に置いた記録は 0 として数え、同じなら PR の番号の大きいもの)を採る | + +### まとめのプランの `close` と `release-verify` のステップ(変わる) + +| 起動 | 経路 | 今 | この変更の後 | +| --- | --- | --- | --- | +| `new sprint` / `new close`(開発ループの外) | 雛形 | `{queue_pr:release-prod}` | 変えない | +| 同上 | 昇格(手動反映の本番系を除く) | `{queue_pr:promote}` | 変えない | +| 同上 | 届けない経路だけ | `0`(`release-verify` なし) | 変えない | +| 同上 | merge・手で届ける・検証の環境だけ・手動反映の本番系 | `unknown`(`release-verify` なし) | `{queue_record}`(`release-verify` を置く) | +| `new close --record-pr <番号>` | どれでも | その番号 | 変えない | +| 開発ループ(`new close --pace auto`) | 届けない経路だけ | `0` | 変えない | +| 同上 | それ以外 | 最初の組み立ては経路と同じ参照、承認ゲート 2 の後は `values.pr` の番号 | `{queue_record}` | + +`close` のステップは `sprint-close.py --record-pr <参照> --issues <課題> --final-stage 振り返り --with-verification` の形のまま、`release-verify` のプロンプトは「配布の記録は Pull Request #<参照> にある」の形のままで、参照だけが変わる。 + +### `sprint-close.py --record-pr`(変えない) + +`` を今のまま受け、`unknown` は今と同じく課題を理由つきの `kept_open` で返して終了コード 0 で終わる。まとめのプランは `unknown` を組まないため、`unknown` を渡すのは手で打つ入口と、この版より前に組んだまとめのプランだけになる。 + +## 処理の流れ + +まとめのステージを流し始める時点の流れ(通常の起動・開発ループで同じ): + +```mermaid +sequenceDiagram + participant Q as queue + participant F as queue_fill + participant R as record_find + participant G as GitHub + participant P as まとめのプラン + Q->>F: fill_queue_pr(まとめのプラン, 前のステージの結果) + F->>P: 課題・リポジトリを読む + F->>R: find_record(根, リポジトリ, 課題) + R->>G: 課題の履歴の参照 + R->>G: 参照した PR の本文(閉じる課題) + R->>G: スプリントの PR の履歴の参照 + R->>G: 候補の本文とコメント + alt 本番か配布なしの記録がある + R-->>F: RecordHit(pr=N) + F->>P: {queue_record} を N へ置き換えて書き戻す + Q->>P: 流す(spec → pr → merge → release-verify(N) → retro → close(N) → refine) + else 見つからない・GitHub を読めない + R-->>F: RecordHit(pr=None, 理由) + F-->>Q: 理由(プランは書き換えない) + Q-->>Q: 流さなかった・unfilled、結果は stopped + end +``` + +まとめのプランを組む時点の参照の決め方(明示の番号は `close_plan`、それ以外は `record_pr_ref`): + +```mermaid +graph TD + a{"--record-pr を明示した"} -->|はい| n["その番号"] + a -->|いいえ| b{"経路がすべて届けない経路"} + b -->|はい| z["0"] + b -->|いいえ| c{"開発ループのまとめ(--pace auto)"} + c -->|はい| q["{queue_record}"] + c -->|いいえ| d{"雛形の経路がある"} + d -->|はい| t["{queue_pr:release-prod}"] + d -->|いいえ| e{"昇格の経路があり、手動反映の本番系でない"} + e -->|はい| p["{queue_pr:promote}"] + e -->|いいえ| q +``` + +開発ループの承認ゲート 2 の後は、`gate2_pipeline` が `build_close` を `record_pr` なしで呼び、組んだまとめのプランは `{queue_record}` を持つ。続きの 1 行(`run <本番> --from <ステップ> && queue <残り> --then <まとめ> && wait && loop lap`)の中で、まとめのステージを流し始める時点に解決する。解決できずにまとめを流さなかったら、`loop lap` が周回を失敗で締める(I8)。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | 記録の PR の解決は 1 か所で行い、経路ごと(通常の起動・開発ループ)に別の解決の関数を持たない(Value 6) | GitHub から探す解決は `record_find.find_record` の 1 つだけにし、通常の起動と開発ループのどちらも差し込み `{queue_record}` を通して呼ぶ。段階の判定は `dist_record.released` を `sprint-close.py` と共有する | `find_record` の呼び手が `queue_fill` だけであることを検索で確かめる。通常の起動と開発ループのまとめのプランがどちらも `{queue_record}` を持つことを単体テストで確かめる | +| 移行性 | 実行中のスプリントで、この版より前に組んだまとめのプラン(`--record-pr unknown` を持つもの)は、今の振る舞い(閉じずに ok)のまま流れてよい。組み直しは求めない | `sprint-close.py` の `unknown` の受け付けと振る舞いを変えない(決定 6)。`{queue_record}` を持たない計画では、`queue_fill` は GitHub を読まない | `sprint-close.py --record-pr unknown --issues ` の既存のテストが変わらずに通る | + +## 決定の記録 + +### 決定 1: どの経路でも同じ手段で記録の PR を見つけるため、まとめの課題から GitHub の参照を辿ってリリース記録を探す + +merge・手で届ける経路では、記録の PR を作るのが人の `/ndf:release` で、どのプランもその番号を持たない。記録は必ず Pull Request にあり、`スプリント:` の行でスプリントの PR を番号で指すため、課題 → スプリントの PR → それを参照する PR の 2 段で届く(#2089 で実測)。記録を書く手順も記録の形も変えずに済む。 + +queue の `{queue_pr:<名>}` の探し場所を広げる案は、記録の PR を持つプランが無い merge・手で届ける経路を解けない。手で行うリリースのステージに記録の PR の番号を書き留めるステップを足す案は、人が行う手順に書き留めの操作を増やし、書き忘れたときに見つからない。 + +根拠: Value 6 / Value 5 / Value 2(MVV 版 3) + +### 決定 2: 別のスプリントの記録を選ばないため、本番か配布なしの段階で、`スプリント:` の PR がまとめの課題を閉じる記録だけを採る + +課題を参照するだけの PR で絞ると、後のスプリントで同じ課題に触れた確定仕様化の PR を通じて、後のスプリントの記録を選ぶ(#2089 では #2128 を通じて #2138 を選ぶ)。閉じる PR に絞ると #2126 だけが残る。段階を本番か配布なしに限るのは、`sprint-close.py` が閉じてよいとする段階と同じにするためで、開発版の記録だけがある時点では見つからない扱いにして止める。 + +本番の前の記録も採って close に判定させる案は、close が「本番への配布の前」で課題を開いたまま ok で返り、親 #2227 の「未記録を ok で返さない」に反する。 + +根拠: Value 6(MVV 版 3) + +### 決定 3: 解決の時点を 1 つにするため、queue の差し込み `{queue_record}` として、まとめのステージを流し始める時点に 1 回解決する + +プランの中で値を後のステップへ渡す手段は queue の差し込みだけで、`{queue_pr:…}` と同じ所で解決すれば、リリース後テストと close が同じ番号を受け取る(I2)。見つからないときにまとめ全体を流さないのは、本番へ届いていないスプリントで確定仕様化と振り返りを先に進めないためである。 + +まとめのプランの先頭に記録を探すステップを置く案は、ステップの出力を後のステップのコマンドとプロンプトへ渡す手段が無い。`sprint-close.py` に探させる案は、その前のリリース後テストが番号を受け取れない。 + +根拠: Value 4 / Value 6(MVV 版 3) + +### 決定 4: 雛形と昇格の経路の今の振る舞いを保つため、開発ループの外ではその 2 つの経路に `{queue_pr:…}` を残す + +この 2 つの経路は記録の PR をプランが持ち、今の形で解けている(受け入れ条件 6)。GitHub を読む回数も増やさない。 + +すべての経路を `{queue_record}` にそろえる案は、受け入れ条件 6(プランの内容が変わらない)に反する。 + +根拠: Value 1(MVV 版 3) + +### 決定 5: 開発ループのまとめが承認ゲートの有無で解けたり解けなかったりしないよう、経路にかかわらず `{queue_record}` を組む + +開発ループの承認ゲート 2 の後のまとめは、本番のプランを `run --from` で流した後の別の queue にあり、本番のプランとも別のディレクトリにあるため、`{queue_pr:release-prod}` を解けない。`{queue_record}` は queue とディレクトリに依らずに解ける。止まった記録の `values.pr` は判定に渡した検査のスプリント PR で、記録の PR ではないため渡さない。 + +承認ゲート 2 の後だけ `{queue_record}` にする案は、同じ周回のまとめのプランが承認ゲートを通ったかで 2 通りになる。`{queue_pr:…}` の探し場所を周回の外のプランへ広げる案は、queue の契約を広げ、前の周回のプランを読むおそれがある。 + +根拠: Value 6(MVV 版 3) + +### 決定 6: 前の版で組んだプランをそのまま流せるよう、`sprint-close.py --record-pr unknown` の受け付けと振る舞いを変えない + +まとめのプランは `unknown` を組まなくなる(I1)が、実行中のスプリントにはこの版より前に組んだプランが残る(非機能の条件の移行性)。手で打つ入口としても残る。`unknown` を ok で返すかどうかは、結果の共通形式を決める親 #2227 で扱う。 + +受け付けをやめる案と止まる値にする案は、前の版で組んだプランを流すと呼び出しの誤りか止まりになり、移行性の条件に反する。 + +根拠: Value 2 / Value 1(MVV 版 3) + +### 決定 7: 記録が見つからない周回を完了と数えないため、開発ループは差し込みを埋められずに流さなかった計画を失敗として締める + +今の `cmd_lap` は `流さなかった` を失敗に数えない。前のステージの失敗で後ろを流さなかったときは前のステージの結果が失敗を表すが、差し込みを埋められなかったまとめは前のステージがすべて完了のため、周回が完了で締まる(受け入れ条件 4 の「完了にならない」に反する)。`unfilled` の印は queue が今も残しているため、その印で数える。 + +根拠: Value 6(MVV 版 3) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・5 / I1 | merge・手で届ける・検証の環境だけ・手動反映の本番系の経路で `new close` が組むまとめの `close` のステップが `--record-pr {queue_record}` を持ち、`unknown` を持たない | `record_pr_ref` が `unknown` を返すように戻すと落ちる | +| 受け入れ条件 3 / I2 | 上の経路と開発ループで、まとめのプランが `release-verify` を持ち、そのプロンプトと `close` のコマンドが同じ参照を持つ | `has_record` が `{queue_record}` を偽とするように壊すと落ちる | +| 受け入れ条件 1・3 | `{queue_record}` を持つプランを `fill_queue_pr` に通すと、`find_record` が返した番号で `release-verify` と `close` の両方が置き換わる | 置き換えを 1 か所だけにすると落ちる | +| 受け入れ条件 4 / I3 | `find_record` が見つからない・GitHub を読めないを返すと、`fill_queue_pr` が計画を書き換えずに理由を返し、理由にまとめの課題と読んだ PR が入る。queue の結果が `stopped` になる | 見つからないときに `0` で埋めるように壊すと落ちる | +| I3 | `{queue_record}` が残ったプランを `run` で流すと、コマンドのステップを打たずに止まる | `QUEUE_NAMED` から `{queue_record}` を外すと落ちる | +| I4 | 候補の最後の記録が `段階: 検証` だけなら見つからない扱いになり、理由に本番の前の記録の PR が載る | `released` を通さずに採るように壊すと落ちる | +| I5 / 決定 2 | 課題を参照するが閉じない PR が `スプリント:` に入った、後のスプリントの記録を採らない(#2089 の #2126 と #2138 の形) | スプリントの PR の絞り込みを「参照する PR」に緩めると落ちる | +| 決定 2 | 同じスプリントの記録が本文とコメントの両方にあれば、コメントの識別子が最も大きい記録の PR を採る | 並びを PR の番号だけにすると落ちる | +| 受け入れ条件 6 / I6 | 雛形と昇格の経路の `new close` が組むまとめのプランが、変更の前と同じ内容になる | 開発ループの外でも `{queue_record}` を組むように壊すと落ちる | +| 受け入れ条件 7 / I7 | 届けない経路だけなら `--record-pr 0` で `release-verify` を持たない。`--record-pr <番号>` を明示するとその番号を組む | 明示した番号を無視すると落ちる | +| 受け入れ条件 2 | 開発ループの承認ゲート 2 の後の `gate2_pipeline` が組むまとめのプランが、止まった記録の `values.pr` でなく `{queue_record}` を持つ | `values.pr` を `build_close` へ渡すように戻すと落ちる | +| 受け入れ条件 4 / I8 | 差し込みを埋められずに流さなかったまとめを含む `done` を `loop lap` に渡すと、周回が失敗で締まる | `unfilled` を数えないように戻すと落ちる | +| 受け入れ条件 8 | 開発ループを使わない起動の既存のテスト(雛形・昇格・届けない経路のまとめ、`sprint-close.py` の `unknown`)が変わらずに通る | 既存のテストの期待値を書き換えないと通らない変更を入れると落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2230 | 実装する | — | `plugins/ndf/scripts/sprint_close_lib/record_find.py`、`plugins/ndf/scripts/supervise_lib/queue_fill.py`、`plugins/ndf/scripts/supervise_lib/plan.py`、`plugins/ndf/scripts/supervise_lib/sprint_routes.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/dist_record.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/pace.md`、`plugins/ndf/skills/progress-tracking/SKILL.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 実際の周回での解決 | merge か手で届ける経路のスプリントを 1 本流し、まとめが記録の PR を見つけて課題を閉じること・記録を置かずに流すと止まることは、リリース後テストで確かめる(要求の「検証手段」の手動確認) | +| GitHub の呼び出しの回数 | 課題 1 件・スプリントの PR 1 本の #2089 では、履歴の参照 2 回・本文 8 回・記録の読み取り 3 回の 13 回だった。課題の多いスプリントでの回数と所要は実装の後に測る | +| 同じ課題が 2 つのスプリントで閉じられたとき | 前のスプリントで閉じる PR を出したが閉じずに持ち越した課題は、2 つのスプリントの記録が候補に残り、コメントの識別子の大きい後の記録を採る。前のスプリントのまとめを後から流し直すと後の記録を読む。今の運用で起きるかは確かめていない | +| リリース PR がスプリントの PR を番号で指さない形 | `release` の手順は `スプリント:` の行に番号を書くため参照が残る。手で書いた記録が番号を書かなかったときは見つからない扱いで止まる | From 245caf29da3a428da97f55607949af22d460d9dd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:40:02 +0000 Subject: [PATCH 08/46] =?UTF-8?q?docs(design):=20I5=20=E3=81=A7=E3=81=BE?= =?UTF-8?q?=E3=81=A8=E3=82=81=E3=81=AE=E5=AF=BE=E8=B1=A1=E3=82=B9=E3=83=97?= =?UTF-8?q?=E3=83=AA=E3=83=B3=E3=83=88=E3=81=AE=E8=A8=98=E9=8C=B2=E3=82=92?= =?UTF-8?q?=E7=89=B9=E5=AE=9A=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=91=E3=82=8C?= =?UTF-8?q?=E3=81=B0=E6=AD=A2=E3=82=81=E3=82=8B=20(#2230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 持ち越した課題を 2 つのスプリントで閉じる PR を出すと、最新の記録を採る規則では別のスプリントの記録を選び、 sprint-close.py が対象外の課題まで閉じる。まとめの課題すべてを閉じる PR を含む記録に限り、 スプリントの並びが 2 通り以上残れば見つからない扱いで止める。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2230-design.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/issues/issue-2230-design.md b/issues/issue-2230-design.md index a3ac0819f..76f59cfdb 100644 --- a/issues/issue-2230-design.md +++ b/issues/issue-2230-design.md @@ -50,7 +50,7 @@ | I2 | まとめのプラン | 記録の PR の参照が `0` でないとき、`release-verify` のステップと `close` のステップは同じ参照を持つ | 組み立ての単体テストで落とす | | I3 | まとめのプラン | `{queue_record}` を解決できない(記録が見つからない・GitHub を読めない)ときは、プランを書き換えずに流さず、理由にまとめの課題と探した PR を載せる | queue の結果が `stopped` になり、まとめは流れない | | I4 | まとめのプラン | `{queue_record}` が採る記録は、`段階:` が本番か配布なしのものだけである | 本番の前の記録だけなら見つからない扱い(I3) | -| I5 | まとめのプラン | `{queue_record}` が採る記録は、`スプリント:` の PR のどれかが、本文の closing keywords か閉じる予定の参照でまとめの課題を指すものだけである | ほかのスプリントの記録は候補から外す | +| I5 | まとめのプラン | `{queue_record}` が採る記録は、`スプリント:` の PR が、まとめの課題の 1 件ごとに、本文の closing keywords か閉じる予定の参照でその課題を指す PR を 1 本以上含むものだけである。条件を満たす記録の `スプリント:` の並び(番号の集合)が 2 通り以上残れば、まとめの対象スプリントの記録を特定できないとして採らない | ほかのスプリントの記録は候補から外し、特定できなければ見つからない扱いで止める(I3) | | I6 | まとめのプラン | 開発ループを使わない起動で、雛形の経路は `{queue_pr:release-prod}`、昇格の経路は `{queue_pr:promote}` を今と同じ形で組む | 組み立ての単体テストで落とす | | I7 | まとめのプラン | 届けない経路だけのスプリントは `0`、`--record-pr <番号>` を明示した `new close` はその番号を組む | 組み立ての単体テストで落とす | | I8 | 開発ループの周回 | 差し込みを埋められずに流さなかった計画がある周回は、完了で締めない | 周回を失敗で締める | @@ -207,7 +207,7 @@ classDiagram | 名前 | `{queue_record}`。計画の JSON のどこに書いてもよい(コマンドとプロンプトの両方) | | 入力 | 計画の `課題`(整数の並び。空なら解決できない)と `リポジトリ`(`gh` を打つ作業場所。無ければ計画の置き場) | | 出力(成功) | 計画の中の `{queue_record}` をすべて、記録の PR の番号(10 進の文字列)で置き換えて書き戻す | -| 失敗の形 | 計画を書き換えず、`差し込みを埋める前の計画の結果が見つからない: {queue_record}(<理由>)` を返す。queue はその計画を `流さなかった`・`unfilled` にし、queue の結果は `stopped` になる(今の `{queue_pr:…}` と同じ)。理由は「課題 #a #b を閉じる PR #x #y と、それを参照する PR #p #q を読んだが、本番か配布なしの段階のリリース記録が無い」の形で、本番の前の記録だけがあれば「(本番の前の記録だけ: PR #r)」を足す。GitHub を読めなければ「GitHub を読めない: 」 | +| 失敗の形 | 計画を書き換えず、`差し込みを埋める前の計画の結果が見つからない: {queue_record}(<理由>)` を返す。queue はその計画を `流さなかった`・`unfilled` にし、queue の結果は `stopped` になる(今の `{queue_pr:…}` と同じ)。理由は「課題 #a #b を閉じる PR #x #y と、それを参照する PR #p #q を読んだが、本番か配布なしの段階のリリース記録が無い」の形で、本番の前の記録だけがあれば「(本番の前の記録だけ: PR #r)」を足す。対象スプリントを特定できないときは「課題 #a #b を閉じる記録が 2 つ以上のスプリントにある: PR #r(スプリント: #x #y)・PR #s(スプリント: #u #v)。`--record-pr <番号>` を明示して組み直す」の形にする。GitHub を読めなければ「GitHub を読めない: 」 | | `run` で流したとき | 置き換わらずに残った `{queue_record}` を `leftover_placeholders` が拾い、コマンドのステップを打たずに止まる(今の `{queue_pr:…}` と同じ) | | 互換性 | 新しい差し込みで、既存の差し込みの意味は変えない | @@ -219,8 +219,9 @@ classDiagram | 2 | 1 の PR のうち、本文の closing keywords か閉じる予定の参照(`lib/closing.closing_refs`)がまとめの課題を指すものを、スプリントの PR とする | | 3 | 候補を、スプリントの PR と、スプリントの PR の履歴の参照のうち Pull Request のものの和にする | | 4 | 候補ごとに本文とコメントを読み(`read_record`)、最後のリリース記録(`parse_record`)を取る | -| 5 | `スプリント:` の PR が 2 のスプリントの PR を 1 本以上含み、段階が本番か配布なし(`released`)の記録だけを残す | -| 6 | 残った中で、記録を置いたコメントの識別子が最も大きいもの(本文に置いた記録は 0 として数え、同じなら PR の番号の大きいもの)を採る | +| 5 | 段階が本番か配布なし(`released`)で、`スプリント:` の PR が、まとめの課題の 1 件ごとに 2 でその課題を閉じるとした PR を 1 本以上含む記録だけを残す | +| 6 | 残った記録の `スプリント:` の並び(番号の集合)が 2 通り以上なら、まとめの対象スプリントを特定できないとして見つからない扱いにする(課題を持ち越して 2 つのスプリントで閉じる PR を出した形)。読む回数は増やさない(2 で集めた PR だけで判定する) | +| 7 | 残った中で、記録を置いたコメントの識別子が最も大きいもの(本文に置いた記録は 0 として数え、同じなら PR の番号の大きいもの)を採る | ### まとめのプランの `close` と `release-verify` のステップ(変わる) @@ -306,7 +307,7 @@ queue の `{queue_pr:<名>}` の探し場所を広げる案は、記録の PR ### 決定 2: 別のスプリントの記録を選ばないため、本番か配布なしの段階で、`スプリント:` の PR がまとめの課題を閉じる記録だけを採る -課題を参照するだけの PR で絞ると、後のスプリントで同じ課題に触れた確定仕様化の PR を通じて、後のスプリントの記録を選ぶ(#2089 では #2128 を通じて #2138 を選ぶ)。閉じる PR に絞ると #2126 だけが残る。段階を本番か配布なしに限るのは、`sprint-close.py` が閉じてよいとする段階と同じにするためで、開発版の記録だけがある時点では見つからない扱いにして止める。 +課題を参照するだけの PR で絞ると、後のスプリントで同じ課題に触れた確定仕様化の PR を通じて、後のスプリントの記録を選ぶ(#2089 では #2128 を通じて #2138 を選ぶ)。閉じる PR に絞ると #2126 だけが残る。さらに、まとめの課題のすべてについて閉じる PR を `スプリント:` に含む記録に限り、それでも `スプリント:` の並びが 2 通り以上残れば止める。課題を持ち越して 2 つのスプリントで閉じる PR を出すと、どちらの記録も課題を閉じる PR を含み、最新を採ると別のスプリントの記録を選ぶ。`sprint-close.py` はその記録の `スプリント:` の PR から閉じる課題を集めるため、別のスプリントの記録を採るとリリース後テストの書き込み先が変わり、対象外の課題まで閉じる。課題を閉じるのは戻しにくい操作なので、推測で採らずに止め、人が `--record-pr <番号>` を明示して組み直す(I7 の入口)。段階を本番か配布なしに限るのは、`sprint-close.py` が閉じてよいとする段階と同じにするためで、開発版の記録だけがある時点では見つからない扱いにして止める。 本番の前の記録も採って close に判定させる案は、close が「本番への配布の前」で課題を開いたまま ok で返り、親 #2227 の「未記録を ok で返さない」に反する。 @@ -361,6 +362,8 @@ queue の `{queue_pr:<名>}` の探し場所を広げる案は、記録の PR | I3 | `{queue_record}` が残ったプランを `run` で流すと、コマンドのステップを打たずに止まる | `QUEUE_NAMED` から `{queue_record}` を外すと落ちる | | I4 | 候補の最後の記録が `段階: 検証` だけなら見つからない扱いになり、理由に本番の前の記録の PR が載る | `released` を通さずに採るように壊すと落ちる | | I5 / 決定 2 | 課題を参照するが閉じない PR が `スプリント:` に入った、後のスプリントの記録を採らない(#2089 の #2126 と #2138 の形) | スプリントの PR の絞り込みを「参照する PR」に緩めると落ちる | +| I5 / 決定 2 | まとめの課題を閉じる PR を `スプリント:` に含み、`スプリント:` の並びが違う本番の記録が 2 つ残ると(課題を持ち越して 2 つのスプリントで閉じる PR を出した形)、見つからない扱いになり、理由に両方の記録の PR が載る | 識別子の最も大きい記録を採るように緩めると落ちる | +| I5 | まとめの課題の一部しか閉じる PR を `スプリント:` に含まない記録を採らない | 「1 本以上含む」をまとめの課題全体で 1 本に緩めると落ちる | | 決定 2 | 同じスプリントの記録が本文とコメントの両方にあれば、コメントの識別子が最も大きい記録の PR を採る | 並びを PR の番号だけにすると落ちる | | 受け入れ条件 6 / I6 | 雛形と昇格の経路の `new close` が組むまとめのプランが、変更の前と同じ内容になる | 開発ループの外でも `{queue_record}` を組むように壊すと落ちる | | 受け入れ条件 7 / I7 | 届けない経路だけなら `--record-pr 0` で `release-verify` を持たない。`--record-pr <番号>` を明示するとその番号を組む | 明示した番号を無視すると落ちる | @@ -380,5 +383,5 @@ queue の `{queue_pr:<名>}` の探し場所を広げる案は、記録の PR | --- | --- | | 実際の周回での解決 | merge か手で届ける経路のスプリントを 1 本流し、まとめが記録の PR を見つけて課題を閉じること・記録を置かずに流すと止まることは、リリース後テストで確かめる(要求の「検証手段」の手動確認) | | GitHub の呼び出しの回数 | 課題 1 件・スプリントの PR 1 本の #2089 では、履歴の参照 2 回・本文 8 回・記録の読み取り 3 回の 13 回だった。課題の多いスプリントでの回数と所要は実装の後に測る | -| 同じ課題が 2 つのスプリントで閉じられたとき | 前のスプリントで閉じる PR を出したが閉じずに持ち越した課題は、2 つのスプリントの記録が候補に残り、コメントの識別子の大きい後の記録を採る。前のスプリントのまとめを後から流し直すと後の記録を読む。今の運用で起きるかは確かめていない | +| 同じ課題が 2 つのスプリントで閉じられたとき | 前のスプリントで閉じる PR を出したが閉じずに持ち越した課題は、2 つのスプリントの記録が候補に残るため、見つからない扱いで止まり、人が `--record-pr <番号>` を明示して組み直す(I5)。止まる頻度は今の運用で確かめていない | | リリース PR がスプリントの PR を番号で指さない形 | `release` の手順は `スプリント:` の行に番号を書くため参照が残る。手で書いた記録が番号を書かなかったときは見つからない扱いで止まる | From 53d588f2242d5538f5daa3fb6806843d68cc0371 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:41:06 +0000 Subject: [PATCH 09/46] =?UTF-8?q?Docs:=20#2164=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=20bump=20=E3=81=AE=E6=AD=A2=E3=81=BE?= =?UTF-8?q?=E3=82=8A=E6=96=B9=E3=81=AE=E7=B5=82=E4=BA=86=E3=82=B3=E3=83=BC?= =?UTF-8?q?=E3=83=89=E3=82=92=E6=98=8E=E7=A4=BA=E3=81=97=E3=80=81=E8=87=AA?= =?UTF-8?q?=E5=88=86=E3=81=AE=E9=85=8D=E5=B8=83=E3=81=AE=E5=BE=8C=E3=81=AE?= =?UTF-8?q?=E6=89=93=E3=81=A1=E7=9B=B4=E3=81=97=E3=82=92=20ok=20=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2164-design.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/issues/issue-2164-design.md b/issues/issue-2164-design.md index 28e6ad842..7f381bd8c 100644 --- a/issues/issue-2164-design.md +++ b/issues/issue-2164-design.md @@ -52,7 +52,7 @@ | I4 | 版の記録 | 本番のプランの版は、明示が無ければ同じスプリントの開発版の記録の基底の版に等しい | 開発版の記録が無ければ差し込みを埋めず、プランを流さない | | I5 | 版の記録 | 明示の版がすでに出ていれば記録を書かず、差し込みを埋めず、プランを流さない(開発版はベースブランチの版が同じか大きい、正式版は同じ版のタグがある) | キューの結果が `stopped` になり、既に出ている版と次に空いている版を項目に載せる | | I6 | 版の記録 | origin のベースブランチ・本番のブランチ・タグのどれかを読めなければ、記録を書かない | I5 と同じく流さず、読めなかったものを項目に載せる | -| I7 | 作業場所の版 | `bump` は、新しい版がベースブランチの版以下か、新しい版が正式版でそのタグがあれば、ファイルを書き換えない | `bump` が `stopped`・終了コード 3 で返し、ステップは judge を通らずに止まる | +| I7 | 作業場所の版 | `bump` は、新しい版がベースブランチの版以下か、新しい版が正式版でそのタグがあれば、ファイルを書き換えない | `bump` が `stopped`・終了コード 3(`emit` へ `code=EXIT_PRECONDITION` を明示して渡す)で返し、ステップは `on_exit` で judge を通らずに止まる | | I8 | 配布のプラン | 明示の版がまだ出ていなければ、差し込みの後のプランのステップと版は今の雛形が組むものと同じである | 雛形のテストが落ちる | ### ドメインイベント @@ -94,11 +94,11 @@ | 版の判定(`plugins/ndf/scripts/lib/release_version.py`、新設) | origin を `git fetch` してベースブランチと本番のブランチの `plugin.json` の版・最新のリリースタグを読み、次に空いている開発版を決める(`decide_dev`)。ある版がもう出ているかを判定する(`taken`)。読めなかったものを返す。ファイルを書かない | | キューの版の差し込み(`supervise_lib/queue_fill.py` に `fill_release_version` を足す) | プランの「版」の欄を読み、版の記録があればそれを使い、無ければ版の判定を呼んで記録を書き、差し込みを埋める。埋められなければ理由と判定の結果を返す | | キューのステージの組み立て(`supervise_lib/queue.py` の `fill_stage`) | `fill_queue_prs`・`fill_queue_pr` の後に `fill_release_version` を呼び、返った判定の結果を流さなかった項目の `version` に載せる | -| 配布の雛形(`supervise_lib/release_templates.py`) | 版が無ければ、版を書く箇所すべてに版の差し込みを書き、プランへ「版」の欄を置く。版があれば今と同じ値を書き、「版」の欄の `明示` に入れる。`bump` のステップに `on_exit: {"3": "stop"}` を置く | +| 配布の雛形(`supervise_lib/release_templates.py`) | 版が無ければ、版を書く箇所すべてに版の差し込みを書き、プランへ「版」の欄を置く。版があれば今と同じ値を書き、「版」の欄の `明示` に入れる。`bump` のステップに `on_exit: {"2": "stop", "3": "stop"}` を置く(キーは `bump` が `emit` へ明示する終了コードと一致させる) | | スプリントのプランの組み立て(`supervise_lib/sprint_waves.py`・`sprint.py`) | 開発版と本番のプランへ明示の版(無ければ空)と上げ幅を渡す。本番の版を `prod_version(a.version)` で作らず、明示が無ければ系列(開発版のプランの名前 `release`)を渡す | | 承認ゲート 2 の文(`supervise_lib/sprint_routes.py` の `normal_gate_2_stage`) | 承認資料のパスを、版を含む具体のパスではなく「開発版のプランの版の記録の `base` から作るパス」として書く | | 版の引数(`supervise_lib/new_args.py`・`decl.py` の `require_versions`) | `--version`・`--prod` を雛形の配布でも任意にし、`--level`(`patch` / `minor` / `major`)を `sprint` と `close` に足す | -| 配布の `bump`(`release-steps.py` の `cmd_bump`) | 書き換える前に版の判定の `taken` と下限の比較を呼び、当たれば `stopped`・終了コード 3 で返す | +| 配布の `bump`(`release-steps.py` の `cmd_bump`) | 書き換える前に版の判定の `taken` と下限の比較を呼び、当たれば `stopped`・終了コード 3 で返す。終了コードは `emit(..., code=EXIT_PRECONDITION)` と明示する(`stopped` の既定は 1 で、既存の staleness の停止と同じ書き方では `on_exit` が働かない) | | Skill 本文(`development-workflow` の `references/pace.md`・`references/waiting.md`) | `new sprint` / `new close` の例から必須の `--version`・`--prod` を外し、版が決まる時点と止まったときの扱いを書く | ```mermaid @@ -251,9 +251,9 @@ classDiagram | 項目 | 内容 | | --- | --- | | 新しい失敗 | `--to` がベースブランチ(`origin/`)の版以下、または `--to` が正式版で `--v` のタグがある | -| 出力 | `status: stopped`・終了コード 3。`metrics` に `from`・`to`・`base_version`・`existing`・`next_free`。ファイルを書き換えない | -| 打ち直し | 作業場所がすでに `--to` で、ベースブランチの版が `--to` より小さいときは今と同じ `ok`(`already: true`) | -| ベースブランチの版を読めない | 書き換えずに `stopped`・終了コード 2(推定で進めない) | +| 出力 | `status: stopped`・終了コード 3(`emit` へ `code=EXIT_PRECONDITION` を明示する。既定の 1 では雛形の `on_exit` が働かない)。`metrics` に `from`・`to`・`base_version`・`existing`・`next_free`。ファイルを書き換えない | +| 打ち直し | 作業場所がすでに `--to` で、ベースブランチの版が `--to` より小さいときは今と同じ `ok`(`already: true`)。ベースブランチの版が `--to` と等しいときは、`release/v` → ベースブランチのマージ済みの PR を 1 回だけ照会し、あれば自分の配布として `ok`(`already: true`)。無ければ上の新しい失敗(別のスプリントが出した版)。照会はこの等しい場合だけで、他の経路の呼び出しの回数は変えない。照会できなければ下の「読めない」と同じ扱い | +| ベースブランチの版を読めない | 書き換えずに `stopped`・終了コード 2(`code=EXIT_UNREADABLE` を明示。推定で進めない)。雛形の `on_exit` の `"2"` で judge を通らずに止める。judge を通すと修正の担当が版を推して書き換えうるためである。`bump` が既に終了コード 2 を返す呼び出しの誤り・ファイルを読めないときも同じく止まるが、どれも修正の担当が推して進めてよいものではない | | 互換性 | 他のプラグインを上げる `bump-others` も同じ `bump` を呼び、同じ止まり方が当たる | ## 処理の流れ @@ -311,7 +311,7 @@ sequenceDiagram ### `bump` の止まり方 -`cmd_bump` は旧版を読んだ後、書き換えの前に `observe` で `origin/` の版を読み、`--to` との比較と、`--to` が正式版のときの `taken` を打つ。打ち直し(旧版と新版が同じでベースブランチの版が小さい)の判定は今の順のまま先に行う。 +`cmd_bump` は旧版を読んだ後、書き換えの前に `observe` で `origin/` の版を読み、`--to` との比較と、`--to` が正式版のときの `taken` を打つ。打ち直し(旧版と新版が同じでベースブランチの版が小さい)の判定は今の順のまま先に行う。旧版と新版が同じでベースブランチの版が等しいときだけ、`release/v` → ベースブランチのマージ済みの PR を照会し、あれば打ち直しとして `ok` を返す(`release` が開発版を出した後の流し直し。決定 2)。 ## 非機能の実現方式 @@ -330,7 +330,7 @@ sequenceDiagram ### 決定 2: 流し直しで自分の開発版を「使われた版」と見ないため、決めた版を版の記録に 1 回だけ書いて使い続ける -差し込みはプランのファイルを書き換えるため、多くの流し直しでは埋まった値がそのまま残る。それでも、記録を書いてからプランを書き換える間に落ちたときと、`release` が開発版を出した後の流し直しで、判定をやり直すと次の版へ進んでしまう。記録を先に書き、記録があれば判定を打たないことで、どの時点の流し直しでも同じ版になる(受け入れ条件 7)。プランのファイルだけを手がかりにする案は、上の間に落ちたときに判定をやり直すため採らない。 +差し込みはプランのファイルを書き換えるため、多くの流し直しでは埋まった値がそのまま残る。それでも、記録を書いてからプランを書き換える間に落ちたときと、`release` が開発版を出した後の流し直しで、判定をやり直すと次の版へ進んでしまう。記録を先に書き、記録があれば判定を打たないことで、どの時点の流し直しでも同じ版になる(受け入れ条件 7)。`release` が開発版を出した後の流し直しでは、ベースブランチの版がすでに記録の版に等しく、下限の比較(I7)だけでは止まってしまう。そのため `bump` は、等しいときに `release/v<版>` → ベースブランチのマージ済みの PR があれば自分の配布として打ち直しの `ok` を返す(入出力の契約の `bump` の「打ち直し」)。プランのファイルだけを手がかりにする案は、上の間に落ちたときに判定をやり直すため採らない。 根拠: Value 1 / Value 4(MVV 版 3) @@ -348,7 +348,7 @@ sequenceDiagram ### 決定 5: 既に作ったプランを書き換えずに版の戻りを止めるため、`bump` に下限の比較とタグの判定を足す -要求の移行性は、作り直しを求めずに古いプランへ受け入れ条件 4・6 の止まり方を当てることを求める。古いプランは「版」の欄を持たないためキューは触れないが、開発版も本番も必ず `bump` を通り、`bump` はどのプランよりも前にファイルを書き換える唯一の場所である。止まったときは終了コード 3 を返し、ステップの `on_exit` で judge を通さずに止める。judge を通すと、修正(fix)の担当が版を書き換えて別の版で進みうるためである(受け入れ条件 4 の「別の版へ移って進まない」)。古いプランのファイルをキューが読み替える案は、要求の境界(既に作ったプランのファイルを書き換えない)に反するため採らない。 +要求の移行性は、作り直しを求めずに古いプランへ受け入れ条件 4・6 の止まり方を当てることを求める。古いプランは「版」の欄を持たないためキューは触れないが、開発版も本番も必ず `bump` を通り、`bump` はどのプランよりも前にファイルを書き換える唯一の場所である。止まったときは終了コード 3 を `emit` へ明示して返し(`stopped` の既定の終了コードは 1 のため、明示しないと `on_exit` が働かない)、ステップの `on_exit` で judge を通さずに止める。専用の終了コードを新設せずに 3 を使うのは、`lib/step_result.py` の `EXIT_PRECONDITION` が「各スクリプトが定めた正常な否定の結果」も含むと定めており、版がもう出ているという止まりはその否定の結果に当たるためである(75 は基盤の待ちという別の意味で、再試行の対象になるため使い回さない)。judge を通すと、修正(fix)の担当が版を書き換えて別の版で進みうるためである(受け入れ条件 4 の「別の版へ移って進まない」)。古いプランのファイルをキューが読み替える案は、要求の境界(既に作ったプランのファイルを書き換えない)に反するため採らない。 根拠: Value 1 / Value 6(MVV 版 3) @@ -372,7 +372,7 @@ sequenceDiagram | 受け入れ条件 2・I2 | `decide_dev` が要求の 2 つの例で `10.27.6-dev.1`・`10.27.6-dev.2` を返し、結果がつねにベースブランチの版より大きい | 規則の 1 をタグを見ずに当てる、または規則の 2 で T・P を見ないと落ちる | | 受け入れ条件 3 | 一時の git リポジトリで、プランを作った後に origin のベースブランチと本番へ `10.27.6` とタグを足してからキューの差し込みを流すと、プランの版が `10.27.7-dev.1` に埋まる | 版をプランを作る時点の値で埋めると落ちる | | 受け入れ条件 4・I5 | 明示の開発版がベースブランチの版以下のとき、差し込みがプランを流さず、項目の `version.cause` が `taken` で `existing` を持ち、プランのファイルが変わらない | 明示の版を確かめずに流す、または `next_free` で埋めると落ちる | -| 受け入れ条件 5・I7 | ベースブランチが `10.27.5` のとき `bump --to 10.27.5-dev.1` が終了コード 3 で止まり、作業場所のファイルが変わらない。打ち直しは `ok`。タグのある正式版への `bump` も止まる | 比較を `<` にする(同じ版を通す)、またはタグを見ないと落ちる | +| 受け入れ条件 5・I7 | ベースブランチが `10.27.5` のとき `bump --to 10.27.5-dev.1` が終了コード 3 で止まり、作業場所のファイルが変わらない。打ち直しは `ok`。ベースブランチが `--to` と等しく `release/v` のマージ済みの PR があれば `ok`(`already: true`)、無ければ終了コード 3。雛形の `bump` のステップの `on_exit` のキーが `bump` の返す 2・3 と一致する。タグのある正式版への `bump` も止まる | 比較を `<` にする(同じ版を通す)、タグを見ない、`emit` へ終了コードを明示しない(既定の 1 で返る)、または自分の配布の打ち直しを止めると落ちる | | 受け入れ条件 6・I4 | 本番の差し込みが開発版の記録の `base` で埋まり、そのタグがあれば流さずに `existing` と `next_free` を載せる | 本番の版を origin から決め直すと落ちる | | 受け入れ条件 7・I3 | 開発版の記録を書いた後、origin のベースブランチを記録の版へ進めてから差し込みを流し直すと、同じ版で埋まり記録が変わらない | 記録があっても判定を打つと落ちる | | 受け入れ条件 8・I6 | origin を読めない(取り除いたリモート・無いブランチ)と、差し込みが記録を書かずに流さず、`unreadable` に名前が入る | 読めない値を空や 0 として判定すると落ちる | @@ -396,3 +396,4 @@ sequenceDiagram | `sprint-state.py loop sprint` の `--dev` | ループは今も `--dev` を必須にして `new sprint --version` へ渡す。明示の版として受け入れ条件 4・10 が当たり、退行はしないが、ループからは流す時点の版を選べない。要求の対象範囲(`new sprint` / `new close`)の外のため、範囲外の課題として起こす | | `bump-others` の止まり方 | 他のプラグインの `bump` にも下限とタグの判定が当たる。他のプラグインのタグの付け方が `ndf` と違うリポジトリで誤って止まらないかは、実装のテストで `bump-others` の経路を 1 度通して確かめる | | キューの耐久ステップでの判定 | 差し込みは耐久ステップ(`ndf.supervise.queue.fill`)の中で origin を読む。耐久の記録からの再生では判定を打ち直さず、記録の値が使われる見込みだが、実装で再生の経路を 1 度通して確かめる | +| 記録を書いてから自分の `release` がマージされるまでに、別のスプリントが記録の版を出したとき | 版の記録は 1 回だけ書く(I3)ため、流し直しは記録の古い版を使い、`bump` が I7 で終了コード 3 で止まり続け、プランを作り直すしかない。I3 を「自分の `release/v<版>` がベースブランチへマージされるまで」に狭めて決め直す案は、差し込みが埋めた後のプランのファイル・作った作業場所と `branch` を新しい版で作り直す仕組みを要し、この設計の範囲を広げるため、承認ゲート 1 で人が決める(レビューの指摘 PRRT_kwDOSII3uc6yWWo2) | From 1c73f2191f62d1cc6cd7b28e0b1475b7ca98f30a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:52:52 +0000 Subject: [PATCH 10/46] =?UTF-8?q?=E8=A8=AD=E8=A8=88=E6=96=87=E6=9B=B8?= =?UTF-8?q?=E3=81=AE=E5=BB=83=E6=AD=A2=E8=AA=9E=E3=80=8C=E4=BF=AE=E6=AD=A3?= =?UTF-8?q?=E3=81=AE=E6=8B=85=E5=BD=93=E3=80=8D=E3=82=92=E3=80=8C=E4=BF=AE?= =?UTF-8?q?=E6=AD=A3=E6=8B=85=E5=BD=93=E3=80=8D=E3=81=B8=E8=A8=80=E3=81=84?= =?UTF-8?q?=E6=8F=9B=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2164-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-2164-design.md b/issues/issue-2164-design.md index 7f381bd8c..4be40493d 100644 --- a/issues/issue-2164-design.md +++ b/issues/issue-2164-design.md @@ -253,7 +253,7 @@ classDiagram | 新しい失敗 | `--to` がベースブランチ(`origin/`)の版以下、または `--to` が正式版で `--v` のタグがある | | 出力 | `status: stopped`・終了コード 3(`emit` へ `code=EXIT_PRECONDITION` を明示する。既定の 1 では雛形の `on_exit` が働かない)。`metrics` に `from`・`to`・`base_version`・`existing`・`next_free`。ファイルを書き換えない | | 打ち直し | 作業場所がすでに `--to` で、ベースブランチの版が `--to` より小さいときは今と同じ `ok`(`already: true`)。ベースブランチの版が `--to` と等しいときは、`release/v` → ベースブランチのマージ済みの PR を 1 回だけ照会し、あれば自分の配布として `ok`(`already: true`)。無ければ上の新しい失敗(別のスプリントが出した版)。照会はこの等しい場合だけで、他の経路の呼び出しの回数は変えない。照会できなければ下の「読めない」と同じ扱い | -| ベースブランチの版を読めない | 書き換えずに `stopped`・終了コード 2(`code=EXIT_UNREADABLE` を明示。推定で進めない)。雛形の `on_exit` の `"2"` で judge を通らずに止める。judge を通すと修正の担当が版を推して書き換えうるためである。`bump` が既に終了コード 2 を返す呼び出しの誤り・ファイルを読めないときも同じく止まるが、どれも修正の担当が推して進めてよいものではない | +| ベースブランチの版を読めない | 書き換えずに `stopped`・終了コード 2(`code=EXIT_UNREADABLE` を明示。推定で進めない)。雛形の `on_exit` の `"2"` で judge を通らずに止める。judge を通すと修正担当が版を推して書き換えうるためである。`bump` が既に終了コード 2 を返す呼び出しの誤り・ファイルを読めないときも同じく止まるが、どれも修正担当が推して進めてよいものではない | | 互換性 | 他のプラグインを上げる `bump-others` も同じ `bump` を呼び、同じ止まり方が当たる | ## 処理の流れ From 72e5452131d16f109ccc27b88fb380b027ff2e75 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 01:53:15 +0000 Subject: [PATCH 11/46] =?UTF-8?q?docs(design):=20I5=20=E3=81=AE=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=82=92=E9=96=89=E3=81=98=E3=82=8B=20PR=20=E3=81=8C?= =?UTF-8?q?=E8=A6=8B=E3=81=A4=E3=81=8B=E3=81=A3=E3=81=9F=E8=AA=B2=E9=A1=8C?= =?UTF-8?q?=E3=81=AB=E9=99=90=E3=82=8A=E3=80=81RecordHit=20=E3=81=AB?= =?UTF-8?q?=E7=AB=B6=E5=90=88=E3=81=97=E3=81=9F=E8=A8=98=E9=8C=B2=E3=82=92?= =?UTF-8?q?=E6=8C=81=E3=81=9F=E3=81=9B=E3=82=8B=20(#2230)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 閉じる PR を持たない課題が混ざったまとめで毎回止まらないよう、I5・手順 2/5・決定 2 を直す - 手順 2 で課題ごとの閉じる PR の対応を保つことを明記する - RecordHit に ambiguous(記録の PR と スプリント: の集合の組)を足す - テスト設計に 2 行を足す Co-Authored-By: Claude Opus 5.5 --- issues/issue-2230-design.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/issues/issue-2230-design.md b/issues/issue-2230-design.md index 76f59cfdb..301b21ab0 100644 --- a/issues/issue-2230-design.md +++ b/issues/issue-2230-design.md @@ -50,7 +50,7 @@ | I2 | まとめのプラン | 記録の PR の参照が `0` でないとき、`release-verify` のステップと `close` のステップは同じ参照を持つ | 組み立ての単体テストで落とす | | I3 | まとめのプラン | `{queue_record}` を解決できない(記録が見つからない・GitHub を読めない)ときは、プランを書き換えずに流さず、理由にまとめの課題と探した PR を載せる | queue の結果が `stopped` になり、まとめは流れない | | I4 | まとめのプラン | `{queue_record}` が採る記録は、`段階:` が本番か配布なしのものだけである | 本番の前の記録だけなら見つからない扱い(I3) | -| I5 | まとめのプラン | `{queue_record}` が採る記録は、`スプリント:` の PR が、まとめの課題の 1 件ごとに、本文の closing keywords か閉じる予定の参照でその課題を指す PR を 1 本以上含むものだけである。条件を満たす記録の `スプリント:` の並び(番号の集合)が 2 通り以上残れば、まとめの対象スプリントの記録を特定できないとして採らない | ほかのスプリントの記録は候補から外し、特定できなければ見つからない扱いで止める(I3) | +| I5 | まとめのプラン | `{queue_record}` が採る記録は、`スプリント:` の PR が、まとめの課題のうち閉じる PR(本文の closing keywords か閉じる予定の参照でその課題を指す PR)が 1 本以上見つかった課題の 1 件ごとに、その課題を閉じる PR を 1 本以上含むものだけである。閉じる PR を持たない課題(PR が `#n` と参照するだけの課題・PR を出さずに片付いた課題)は条件に入れず、閉じる PR が見つかった課題が 0 件なら見つからない扱いにする。条件を満たす記録の `スプリント:` の並び(番号の集合)が 2 通り以上残れば、まとめの対象スプリントの記録を特定できないとして採らない | ほかのスプリントの記録は候補から外し、特定できなければ見つからない扱いで止める(I3) | | I6 | まとめのプラン | 開発ループを使わない起動で、雛形の経路は `{queue_pr:release-prod}`、昇格の経路は `{queue_pr:promote}` を今と同じ形で組む | 組み立ての単体テストで落とす | | I7 | まとめのプラン | 届けない経路だけのスプリントは `0`、`--record-pr <番号>` を明示した `new close` はその番号を組む | 組み立ての単体テストで落とす | | I8 | 開発ループの周回 | 差し込みを埋められずに流さなかった計画がある周回は、完了で締めない | 周回を失敗で締める | @@ -174,6 +174,7 @@ classDiagram +searched: tuple~int~ +pre_release: tuple~int~ +error: str + +ambiguous: tuple~tuple~ +reason() str } class record_find { @@ -196,7 +197,7 @@ classDiagram record_find ..> dist_record ``` -`RecordHit` は値オブジェクトで、`pr` が `None` のとき `reason()` が理由の文を返す。`searched` は記録を読んだ PR、`pre_release` は記録はあるが段階が本番の前だった PR、`error` は GitHub を読めなかったときの理由である。 +`RecordHit` は値オブジェクトで、`pr` が `None` のとき `reason()` が理由の文を返す。`searched` は記録を読んだ PR、`pre_release` は記録はあるが段階が本番の前だった PR、`error` は GitHub を読めなかったときの理由である。`ambiguous` は対象スプリントを特定できなかったときに残った記録の組(記録の PR の番号と、その `スプリント:` の PR の番号の集合)の並びで、`reason()` は「2 つ以上のスプリントにある」の理由文をここから組む。 ## 入出力の契約 @@ -216,11 +217,11 @@ classDiagram | 順 | すること | | --- | --- | | 1 | まとめの課題ごとに、課題の履歴の参照(`cross-referenced`)のうち Pull Request のものを集める | -| 2 | 1 の PR のうち、本文の closing keywords か閉じる予定の参照(`lib/closing.closing_refs`)がまとめの課題を指すものを、スプリントの PR とする | +| 2 | 1 の PR のうち、本文の closing keywords か閉じる予定の参照(`lib/closing.closing_refs`。(リポジトリ, 課題番号) の組を返す)がまとめの課題を指すものを、スプリントの PR とする。あわせて課題ごとの対応(課題番号 → その課題を閉じる PR の番号の集合)を保ち、5 で使う。閉じる PR が 1 本も見つからない課題は対応に入れない。対応が空(閉じる PR が見つかった課題が 0 件)なら、ここで見つからない扱いにする | | 3 | 候補を、スプリントの PR と、スプリントの PR の履歴の参照のうち Pull Request のものの和にする | | 4 | 候補ごとに本文とコメントを読み(`read_record`)、最後のリリース記録(`parse_record`)を取る | -| 5 | 段階が本番か配布なし(`released`)で、`スプリント:` の PR が、まとめの課題の 1 件ごとに 2 でその課題を閉じるとした PR を 1 本以上含む記録だけを残す | -| 6 | 残った記録の `スプリント:` の並び(番号の集合)が 2 通り以上なら、まとめの対象スプリントを特定できないとして見つからない扱いにする(課題を持ち越して 2 つのスプリントで閉じる PR を出した形)。読む回数は増やさない(2 で集めた PR だけで判定する) | +| 5 | 段階が本番か配布なし(`released`)で、`スプリント:` の PR が、2 の対応にある課題(閉じる PR が見つかった課題)の 1 件ごとに、2 でその課題を閉じるとした PR を 1 本以上含む記録だけを残す。対応に無い課題は条件に入れない | +| 6 | 残った記録の `スプリント:` の並び(番号の集合)が 2 通り以上なら、まとめの対象スプリントを特定できないとして見つからない扱いにし、残った記録の PR と `スプリント:` の集合の組を `RecordHit.ambiguous` に入れる(課題を持ち越して 2 つのスプリントで閉じる PR を出した形)。読む回数は増やさない(2 で集めた PR だけで判定する) | | 7 | 残った中で、記録を置いたコメントの識別子が最も大きいもの(本文に置いた記録は 0 として数え、同じなら PR の番号の大きいもの)を採る | ### まとめのプランの `close` と `release-verify` のステップ(変わる) @@ -307,7 +308,7 @@ queue の `{queue_pr:<名>}` の探し場所を広げる案は、記録の PR ### 決定 2: 別のスプリントの記録を選ばないため、本番か配布なしの段階で、`スプリント:` の PR がまとめの課題を閉じる記録だけを採る -課題を参照するだけの PR で絞ると、後のスプリントで同じ課題に触れた確定仕様化の PR を通じて、後のスプリントの記録を選ぶ(#2089 では #2128 を通じて #2138 を選ぶ)。閉じる PR に絞ると #2126 だけが残る。さらに、まとめの課題のすべてについて閉じる PR を `スプリント:` に含む記録に限り、それでも `スプリント:` の並びが 2 通り以上残れば止める。課題を持ち越して 2 つのスプリントで閉じる PR を出すと、どちらの記録も課題を閉じる PR を含み、最新を採ると別のスプリントの記録を選ぶ。`sprint-close.py` はその記録の `スプリント:` の PR から閉じる課題を集めるため、別のスプリントの記録を採るとリリース後テストの書き込み先が変わり、対象外の課題まで閉じる。課題を閉じるのは戻しにくい操作なので、推測で採らずに止め、人が `--record-pr <番号>` を明示して組み直す(I7 の入口)。段階を本番か配布なしに限るのは、`sprint-close.py` が閉じてよいとする段階と同じにするためで、開発版の記録だけがある時点では見つからない扱いにして止める。 +課題を参照するだけの PR で絞ると、後のスプリントで同じ課題に触れた確定仕様化の PR を通じて、後のスプリントの記録を選ぶ(#2089 では #2128 を通じて #2138 を選ぶ)。閉じる PR に絞ると #2126 だけが残る。さらに、閉じる PR が見つかったまとめの課題のすべてについて、その閉じる PR を `スプリント:` に含む記録に限り(閉じる PR を持たない課題は条件に入れない。入れると、PR が参照するだけの課題が 1 件混ざったまとめは記録が 1 つでも毎回止まる)、それでも `スプリント:` の並びが 2 通り以上残れば止める。課題を持ち越して 2 つのスプリントで閉じる PR を出すと、どちらの記録も課題を閉じる PR を含み、最新を採ると別のスプリントの記録を選ぶ。`sprint-close.py` はその記録の `スプリント:` の PR から閉じる課題を集めるため、別のスプリントの記録を採るとリリース後テストの書き込み先が変わり、対象外の課題まで閉じる。課題を閉じるのは戻しにくい操作なので、推測で採らずに止め、人が `--record-pr <番号>` を明示して組み直す(I7 の入口)。段階を本番か配布なしに限るのは、`sprint-close.py` が閉じてよいとする段階と同じにするためで、開発版の記録だけがある時点では見つからない扱いにして止める。 本番の前の記録も採って close に判定させる案は、close が「本番への配布の前」で課題を開いたまま ok で返り、親 #2227 の「未記録を ok で返さない」に反する。 @@ -364,6 +365,8 @@ queue の `{queue_pr:<名>}` の探し場所を広げる案は、記録の PR | I5 / 決定 2 | 課題を参照するが閉じない PR が `スプリント:` に入った、後のスプリントの記録を採らない(#2089 の #2126 と #2138 の形) | スプリントの PR の絞り込みを「参照する PR」に緩めると落ちる | | I5 / 決定 2 | まとめの課題を閉じる PR を `スプリント:` に含み、`スプリント:` の並びが違う本番の記録が 2 つ残ると(課題を持ち越して 2 つのスプリントで閉じる PR を出した形)、見つからない扱いになり、理由に両方の記録の PR が載る | 識別子の最も大きい記録を採るように緩めると落ちる | | I5 | まとめの課題の一部しか閉じる PR を `スプリント:` に含まない記録を採らない | 「1 本以上含む」をまとめの課題全体で 1 本に緩めると落ちる | +| I5 | まとめの課題に閉じる PR を持たない課題(PR が `#n` と参照するだけの課題)が混ざっても、閉じる PR が見つかった課題の閉じる PR を `スプリント:` に含む記録が 1 つだけなら、その記録を採る | 条件をまとめの課題の全件に掛けるように戻すと落ちる | +| I5 | まとめの課題のどれにも閉じる PR が見つからなければ、記録を読まずに見つからない扱いになる | 対応が空のまま 5 へ進め、条件を満たしたとみなすように壊すと落ちる | | 決定 2 | 同じスプリントの記録が本文とコメントの両方にあれば、コメントの識別子が最も大きい記録の PR を採る | 並びを PR の番号だけにすると落ちる | | 受け入れ条件 6 / I6 | 雛形と昇格の経路の `new close` が組むまとめのプランが、変更の前と同じ内容になる | 開発ループの外でも `{queue_record}` を組むように壊すと落ちる | | 受け入れ条件 7 / I7 | 届けない経路だけなら `--record-pr 0` で `release-verify` を持たない。`--record-pr <番号>` を明示するとその番号を組む | 明示した番号を無視すると落ちる | From 824d90998b4c092264d5930ef22c5d562762f14c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 02:42:44 +0000 Subject: [PATCH 12/46] =?UTF-8?q?Docs:=20#2164=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=AB=E3=80=81=E9=85=8D=E5=B8=83=E3=81=AE=E3=83=9E?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=BE=E3=81=A7=E7=89=88=E3=82=92=E6=B1=BA?= =?UTF-8?q?=E3=82=81=E7=9B=B4=E3=81=99=E4=BB=95=E7=B5=84=E3=81=BF=E3=82=92?= =?UTF-8?q?=E8=B6=B3=E3=81=99=EF=BC=88=E6=89=BF=E8=AA=8D=E3=82=B2=E3=83=BC?= =?UTF-8?q?=E3=83=88=201=20=E3=81=AE=E6=A1=88=20B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 +- docs/glossary/glossary.json | 8 ++- issues/issue-2164-design.md | 120 ++++++++++++++++++++++++++++++------ 3 files changed, 111 insertions(+), 20 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 57c3b5712..f363af898 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -324,7 +324,8 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 最終工程の記録 | — | 最終工程を終えたことを示す、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` | | 版の差し込み | — | 配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める | — | — | — | -| 版の記録 | — | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠を持つ。1 本のプランにつき 1 回だけ書き、流し直しはこれを読む | — | — | — | +| 版の記録 | — | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠と、決め直しの履歴を持つ。流し直しはこれを読む | — | — | — | +| 版の決め直し | — | 開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない | — | — | — | | 明示の版 | — | `new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 201f99394..18f0e190b 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4726,7 +4726,13 @@ { "term": "版の記録", "context": "ndf-workflow", - "meaning": "配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠を持つ。1 本のプランにつき 1 回だけ書き、流し直しはこれを読む", + "meaning": "配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠と、決め直しの履歴を持つ。流し直しはこれを読む", + "source": "" + }, + { + "term": "版の決め直し", + "context": "ndf-workflow", + "meaning": "開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない", "source": "" }, { diff --git a/issues/issue-2164-design.md b/issues/issue-2164-design.md index 4be40493d..aca8482bf 100644 --- a/issues/issue-2164-design.md +++ b/issues/issue-2164-design.md @@ -39,7 +39,7 @@ | 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | | 配布のプラン | 作るのは配布の雛形(`release_templates.py`)、作った後に書き換えるのはキューの版の差し込みだけ | 1 本のプランのファイル | — | プランの「版」の欄(プラグイン・チャネル・上げ幅・明示の版・系列)・版の差し込み | -| 版の記録 | キューの版の差し込み | 1 本のプランの版の決定(状態ディレクトリの `version.json`) | — | 決めた版・基底の版・決め方(決めた / 明示)・根拠(読んだ ref と版・最新のタグ・上げ幅) | +| 版の記録 | キューの版の差し込み | 1 本のプランの版の決定(状態ディレクトリの `version.json`) | 差し込み前のプランの写し(状態ディレクトリの `version-plan.json`) | 決めた版・基底の版・決め方(決めた / 明示 / 系列)・根拠(読んだ ref と版・最新のタグ・上げ幅)・決め直しの履歴 | | 作業場所の版 | 配布の `bump` | 作業場所の `plugin.json` の `version` | 版数を持つほかのファイル | 旧版・新版 | ### 不変条件 @@ -48,12 +48,13 @@ | --- | --- | --- | --- | | I1 | 配布のプラン | 版を明示せずに作ったプランのファイルと `sprint.json` は、具体的な版数を持たない。版を名前に持つ値(作業場所・`branch`・承認資料のパス・ステップの引数)は版の差し込みで書く | 雛形のテストが落ちる | | I2 | 版の記録 | 決めた開発版は、読んだ origin のベースブランチの版より大きく、その基底の版のリリースタグは無い | 判定のテストが落ちる | -| I3 | 版の記録 | 1 本のプランにつき 1 回だけ書き、書いた後は流し直しでも書き換えない。流し直しは記録の版を使う | 差し込みのテストが落ちる | -| I4 | 版の記録 | 本番のプランの版は、明示が無ければ同じスプリントの開発版の記録の基底の版に等しい | 開発版の記録が無ければ差し込みを埋めず、プランを流さない | +| I3 | 版の記録 | 自分の `release/v<版>` がベースブランチへマージされるまでは、流し直しで記録の版がすでに別のスプリントに出ていると分かったときに限り決め直せる(決定 8)。マージの後と、記録の版がまだ出ていないときは書き換えず、流し直しは記録の版を使う。明示の版(`how = explicit`)は決め直さない | 差し込みのテストが落ちる | +| I4 | 版の記録 | 本番のプランの版は、明示が無ければ同じスプリントの開発版の記録の基底の版に等しい。開発版の記録が決め直されたら、承認ゲート 2 の前なら本番も新しい基底の版を使い、後なら本番のプランを流さない | 開発版の記録が無ければ差し込みを埋めず、プランを流さない。承認の後に系列が変わっていればキューの結果が `stopped`(`cause: series_changed`) | | I5 | 版の記録 | 明示の版がすでに出ていれば記録を書かず、差し込みを埋めず、プランを流さない(開発版はベースブランチの版が同じか大きい、正式版は同じ版のタグがある) | キューの結果が `stopped` になり、既に出ている版と次に空いている版を項目に載せる | | I6 | 版の記録 | origin のベースブランチ・本番のブランチ・タグのどれかを読めなければ、記録を書かない | I5 と同じく流さず、読めなかったものを項目に載せる | | I7 | 作業場所の版 | `bump` は、新しい版がベースブランチの版以下か、新しい版が正式版でそのタグがあれば、ファイルを書き換えない | `bump` が `stopped`・終了コード 3(`emit` へ `code=EXIT_PRECONDITION` を明示して渡す)で返し、ステップは `on_exit` で judge を通らずに止まる | | I8 | 配布のプラン | 明示の版がまだ出ていなければ、差し込みの後のプランのステップと版は今の雛形が組むものと同じである | 雛形のテストが落ちる | +| I9 | 版の記録 | 決め直したときは、差し込み前のプランの写しを新しい版で埋め直し、前の版のステップの記録を退避し、前の版・新しい版・理由・時刻を記録の履歴へ足す。履歴は消さない | 差し込みのテストが落ちる | ### ドメインイベント @@ -67,13 +68,15 @@ | E6 | 承認ゲート 2 で利用者が承認した | 利用者 | 本番のプラン(`approve` のステップ) | | E7 | 本番のプランの版を、開発版の記録の基底の版に決め、タグが無いことを確かめた | キューの版の差し込み | 本番のプランの全ステップ。タグがあれば conductor(キューの結果 `stopped`) | | E8 | 本番へ出し、タグを打ち、決めた版を結果 JSON に載せた | `release-steps.py release` | `record`・まとめの `close` | +| E9 | 開発版の流し直しで、記録の版が別のスプリントに出ていると分かり、記録の版を決め直した | キューの版の差し込み | 開発版のプランの全ステップ(新しい作業場所と `branch` で頭から)・E7 | ### 用語 | 用語 | 意味 | 用語集への反映 | | --- | --- | --- | | 版の差し込み | 配布のプランが具体的な版の代わりに持つ `{release_version}`(出す版)と `{release_base}`(その接尾辞を外した版)。キューがステージを流す直前に版の記録の値で埋める | 追加(`ndf-workflow`) | -| 版の記録 | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠を持つ。1 本のプランにつき 1 回だけ書き、流し直しはこれを読む | 追加(`ndf-workflow`) | +| 版の記録 | 配布のプランの状態ディレクトリの `version.json`。キューが決めた、または確かめた版と、決めた根拠と、決め直しの履歴を持つ。流し直しはこれを読む | 追加(`ndf-workflow`) | +| 版の決め直し | 開発版の流し直しで、版の記録の版が自分の配布のマージより前に別のスプリントに出ていたとき、キューが次に空いている版を決め、差し込み前のプランの写しから埋め直すこと。明示の版と、自分の配布のマージの後には行わない | 追加(`ndf-workflow`) | | 明示の版 | `new sprint` / `new close` の `--version`・`--prod`、`new release` の `--version` で渡した版。プランを作る時点で決まり、キューはその版がまだ出ていないかだけを確かめる | 追加(`ndf-workflow`) | ## 機能一覧 @@ -86,19 +89,20 @@ | F4 | 明示の版がすでに出ていれば、プランを流さずに止める | キュー | | F5 | 版を戻す `bump` と、既にタグのある正式版への `bump` を止める | 配布のステップ(新旧どのプランからも) | | F6 | 決めた版と根拠を、版の記録と結果 JSON から読む | conductor・振り返り | +| F7 | 開発版の流し直しで、自分の配布のマージより前に記録の版が別のスプリントに出ていれば、版を決め直してプランを埋め直す | キュー | ## 構成要素 | 要素 | 責務 | | --- | --- | -| 版の判定(`plugins/ndf/scripts/lib/release_version.py`、新設) | origin を `git fetch` してベースブランチと本番のブランチの `plugin.json` の版・最新のリリースタグを読み、次に空いている開発版を決める(`decide_dev`)。ある版がもう出ているかを判定する(`taken`)。読めなかったものを返す。ファイルを書かない | -| キューの版の差し込み(`supervise_lib/queue_fill.py` に `fill_release_version` を足す) | プランの「版」の欄を読み、版の記録があればそれを使い、無ければ版の判定を呼んで記録を書き、差し込みを埋める。埋められなければ理由と判定の結果を返す | +| 版の判定(`plugins/ndf/scripts/lib/release_version.py`、新設) | origin を `git fetch` してベースブランチと本番のブランチの `plugin.json` の版・最新のリリースタグを読み、次に空いている開発版を決める(`decide_dev`)。ある版がもう出ているかを判定する(`taken`)。`release/v<版>` → ベースブランチのマージ済みの PR があるか(自分の配布か)を照会する(`own_released`)。読めなかったものを返す。ファイルを書かない | +| キューの版の差し込み(`supervise_lib/queue_fill.py` に `fill_release_version` を足す) | プランの「版」の欄を読む。記録が無ければ差し込み前のプランの写しを残し、版の判定を呼んで記録を書き、差し込みを埋める。記録があれば、開発版は記録の版がもう出ていないかを確かめ、自分の配布の前に出ていれば決め直す(決定 8)。埋められなければ理由と判定の結果を返す | | キューのステージの組み立て(`supervise_lib/queue.py` の `fill_stage`) | `fill_queue_prs`・`fill_queue_pr` の後に `fill_release_version` を呼び、返った判定の結果を流さなかった項目の `version` に載せる | | 配布の雛形(`supervise_lib/release_templates.py`) | 版が無ければ、版を書く箇所すべてに版の差し込みを書き、プランへ「版」の欄を置く。版があれば今と同じ値を書き、「版」の欄の `明示` に入れる。`bump` のステップに `on_exit: {"2": "stop", "3": "stop"}` を置く(キーは `bump` が `emit` へ明示する終了コードと一致させる) | | スプリントのプランの組み立て(`supervise_lib/sprint_waves.py`・`sprint.py`) | 開発版と本番のプランへ明示の版(無ければ空)と上げ幅を渡す。本番の版を `prod_version(a.version)` で作らず、明示が無ければ系列(開発版のプランの名前 `release`)を渡す | | 承認ゲート 2 の文(`supervise_lib/sprint_routes.py` の `normal_gate_2_stage`) | 承認資料のパスを、版を含む具体のパスではなく「開発版のプランの版の記録の `base` から作るパス」として書く | | 版の引数(`supervise_lib/new_args.py`・`decl.py` の `require_versions`) | `--version`・`--prod` を雛形の配布でも任意にし、`--level`(`patch` / `minor` / `major`)を `sprint` と `close` に足す | -| 配布の `bump`(`release-steps.py` の `cmd_bump`) | 書き換える前に版の判定の `taken` と下限の比較を呼び、当たれば `stopped`・終了コード 3 で返す。終了コードは `emit(..., code=EXIT_PRECONDITION)` と明示する(`stopped` の既定は 1 で、既存の staleness の停止と同じ書き方では `on_exit` が働かない) | +| 配布の `bump`(`release-steps.py` の `cmd_bump`) | 書き換える前に版の判定の `taken` と下限の比較を呼び(打ち直しの照会は `own_released`)、当たれば `stopped`・終了コード 3 で返す。終了コードは `emit(..., code=EXIT_PRECONDITION)` と明示する(`stopped` の既定は 1 で、既存の staleness の停止と同じ書き方では `on_exit` が働かない) | | Skill 本文(`development-workflow` の `references/pace.md`・`references/waiting.md`) | `new sprint` / `new close` の例から必須の `--version`・`--prod` を外し、版が決まる時点と止まったときの扱いを書く | ```mermaid @@ -142,7 +146,7 @@ plugins/ndf/scripts/ plugins/ndf/skills/development-workflow/references/ ├── pace.md └── waiting.md -docs/glossary/glossary.json # 用語 3 つ(glossary.py render で docs/glossary.md) +docs/glossary/glossary.json # 用語 4 つ(glossary.py render で docs/glossary.md) ``` ## 構造 @@ -169,13 +173,22 @@ classDiagram +next_free +observed } + class Record { + +version + +base + +how + +observed + +history + } VersionInputs ..> Observed : observe() Observed ..> Decision : decide_dev() / taken() + Decision ..> Record : 書く / 決め直す ``` - `observe(repo, inputs) -> Observed`: `git fetch -q origin --tags` の後に読む。読めなかったものは `unreadable` に名前(`origin/`・`origin/`・`tags`)で入れる。本番のブランチを宣言していないリポジトリでは本番のブランチを読まない(読めないには数えない)。タグが 1 つも無いことは読めないに数えず、`latest_tag` を空にする - `decide_dev(obs, level) -> Decision`: 下の「処理の流れ」の規則で開発版を決める。`unreadable` が空でなければ決めない - `taken(obs, version) -> Decision`: 開発版はベースブランチの版が `version` 以上、正式版は `--v` のタグがあるとき、`existing` に既に出ている版、`next_free` に次に空いている版を入れる +- `own_released(repo, branch, base) -> bool | None`: `branch`(`release/v<版>`)→ `base` のマージ済みの PR を 1 回照会する。照会できなければ `None`(読めない)。`bump` の打ち直しと、差し込みの決め直しの判定が使う - いずれも純粋な関数ではない(`observe` が git を打つ)。`decide_dev` と `taken` は `Observed` だけから決まり、テストは `Observed` を組んで渡せる ## データ構造 @@ -204,18 +217,36 @@ classDiagram | `level` | 文字列 | 許さない | 使った上げ幅 | | `observed` | オブジェクト | 許さない | 読んだ値(`base_ref`・`base_version`・`production_ref`・`production_version`・`latest_tag`)。本番のブランチを読まなかったときは `production_ref` を空にする(「該当なし」) | | `series_from` | 文字列 | 許す | `how` が `series` のときだけ、読んだ開発版の記録のパス | -| `at` | 文字列(ISO 8601) | 許さない | 書いた時刻 | +| `at` | 文字列(ISO 8601) | 許さない | 今の版を書いた時刻 | +| `history` | 配列 | 許す(空の配列) | 決め直しの履歴。1 回の決め直しにつき 1 件を末尾へ足す(下の表) | + +`history` の 1 件: -**時系列の扱い**: 記録は 1 回だけ書き、上書きしない(I3)。決め直しは起こらないので、履歴を持つ構造は要らない。プランを作り直したときは状態ディレクトリも新しくなり、別の記録になる。 +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `from` | 文字列 | 許さない | 前の版 | +| `to` | 文字列 | 許さない | 新しい版 | +| `reason` | `taken` / `series_changed` | 許さない | 前の版が別のスプリントに出ていた / 本番が読む開発版の記録の版が変わった | +| `existing` | 文字列 | 許す | `taken` のとき、読んだベースブランチの版かタグ | +| `observed` | オブジェクト | 許す | 前の版を捨てたときに読んだ値(`version.json` の `observed` と同じ形) | +| `left` | オブジェクト | 許さない | 前の版で使った `作業場所`・`branch` と、ステップの記録を退避したディレクトリ(`superseded/v<前の版>/`)。消さずに残し、後片付けに任せる | +| `at` | 文字列(ISO 8601) | 許さない | 決め直した時刻 | + +### 差し込み前のプランの写し(`<プランの状態ディレクトリ>/version-plan.json`) + +版の差し込みを最初に埋める直前のプランのファイルを、そのまま写したもの。`{queue_pr:<名>}` などほかの差し込みは埋まった後で、版の差し込みだけが残っている。決め直しはこの写しを新しい版で埋めてプランのファイルを書き直す。写しは書き換えない。 + +**時系列の扱い**: 記録の版は、自分の配布のマージより前に別のスプリントに出ていたときだけ決め直し(I3)、前の版は `history` に残す(I9)。`at` は今の版を書いた時刻で、最初に決めた時刻は `history` の先頭の件の前の版として読む。プランを作り直したときは状態ディレクトリも新しくなり、別の記録になる。 | 機能 | 配布のプラン | 版の記録 | 作業場所の版 | | --- | --- | --- | --- | | F1 プランを作る | C | — | — | -| F2 開発版を決める | U(差し込みを埋める) | C | — | +| F2 開発版を決める | U(差し込みを埋める) | C(写しも) | — | | F3 本番の版を決める | U | C(自分)・R(開発版) | — | | F4 明示の版で止める | R | — | — | | F5 bump を止める | — | — | R | | F6 根拠を読む | — | R | — | +| F7 決め直す | U(写しから埋め直す) | U(版と履歴)・R(写し) | — | **移行**: 既に作ったプランのファイルは書き換えない(要求の「境界」)。「版」の欄を持たないプランは今と同じに流れ、版が戻る呼び方だけを `bump` が止める。 @@ -238,13 +269,13 @@ classDiagram | `version` の鍵 | 意味 | | --- | --- | -| `cause` | `taken`(明示の版か本番の版がすでに出ている)/ `unreadable`(origin を読めない)/ `no_series`(本番が読む開発版の記録が無い) | +| `cause` | `taken`(明示の版か本番の版がすでに出ている)/ `unreadable`(origin を読めない、または自分の配布の照会ができない)/ `no_series`(本番が読む開発版の記録が無い)/ `series_changed`(承認ゲート 2 の後に、本番が読む開発版の記録の版が変わった) | | `version` | 確かめた版(`unreadable` で開発版を決められなかったときは空) | | `existing` | 既に出ている版(ベースブランチの版かタグ) | | `next_free` | 次に空いている版(`taken` のときだけ。人へ戻す材料で、自動では使わない) | | `unreadable` | 読めなかったものの名前の並び | -埋めたプランは、記録のパスを進捗ログへ 1 行書く。決めた版は流したプランの `release` のステップの結果 JSON の `metrics.version` に載る(今の `cmd_release` が `--version` の値を載せるので、差し込みが埋まれば決めた版になる)。 +埋めたプランは、記録のパスを進捗ログへ 1 行書く。決め直したプランは、前の版・新しい版・残した `作業場所` と `branch` を進捗ログへ 1 行書き、プランは流す(`stopped` にしない)。決めた版は流したプランの `release` のステップの結果 JSON の `metrics.version` に載る(今の `cmd_release` が `--version` の値を載せるので、差し込みが埋まれば決めた版になる)。 ### `release-steps.py bump` @@ -271,7 +302,24 @@ sequenceDiagram F->>R: 記録はあるか alt 記録がある(流し直し) R-->>F: 記録の版 + F->>J: observe と taken(記録の版) + J->>O: git fetch --tags と読み取り + alt どれかを読めない + F-->>Q: 流さない(version.cause = unreadable) + else 記録の版がすでに出ている + F->>J: own_released(記録の branch) + alt 自分の配布がマージ済み + Note over F: 打ち直し。記録の版を使う + else how = explicit + F-->>Q: 流さない(version.cause = taken) + else how = decided + J-->>F: decide_dev の版 + F->>R: 版を書き換え、履歴を足し、ステップの記録を退避する + F->>F: 差し込み前の写しを新しい版で埋め直す + end + end else 記録が無い + F->>R: 差し込み前の写しを残す F->>J: observe(上げ幅つき) J->>O: git fetch --tags と読み取り alt どれかを読めない @@ -299,9 +347,26 @@ sequenceDiagram 例(受け入れ条件 2・3): B = `10.27.5`・T = `10.27.5`・PATCH なら `10.27.6-dev.1`。B = `10.27.6-dev.1` でタグ `ndf--v10.27.6` が無ければ `10.27.6-dev.2`。作った時点の B が `10.27.5` で、配布の前に別のスプリントが `10.27.6` を本番へ出したなら(B = `10.27.6`・T = `10.27.6`)`10.27.7-dev.1`。どちらの規則でも結果は B より大きい(I2)。 +### 版を決め直す + +開発版のプランの流し直しで記録があるときは、判定を打って記録の版がもう出ていないかを確かめる(`taken`: B が記録の版以上)。出ていなければ記録の版を使う(受け入れ条件 7)。出ていれば次の順に扱う。 + +1. `own_released` で記録の `branch`(`release/v<記録の版>`)→ ベースブランチのマージ済みの PR を照会する。あれば自分の配布の後の流し直しで、記録の版を使う(`bump` の打ち直しと同じ判定)。照会できなければ流さない(`unreadable`) +2. `how` が `explicit` なら流さない(`taken`。受け入れ条件 4) +3. `how` が `decided` なら `decide_dev` で新しい版を決め、次をこの順に行う + 1. 状態ディレクトリのステップの記録(`state.json`・`progress.jsonl`・`report.md`・ステップの出力)を `superseded/v<前の版>/` へ移す。版の記録・差し込み前の写しは移さない + 2. 版の記録の `version`・`base`・`observed`・`at` を新しい値にし、`history` の末尾へ前の版の 1 件を足す + 3. 差し込み前の写しを新しい版で埋め、プランのファイルを書き直す + +例: 記録の版 `10.27.6-dev.1` で `bump` まで流して止まり、その間に別のスプリントが `10.27.6` を本番へ出した(B = `10.27.6`)。自分の `release/v10.27.6-dev.1` のマージ済みの PR は無いので、`10.27.7-dev.1` に決め直し、作業場所と `branch` を `release/v10.27.7-dev.1` の名前で作り直して、プランを頭から流す。前の作業場所と `branch` は消さずに `history` の `left` に残す。 + +順序は、落ちたときに流し直しで同じ結果へ戻るように選ぶ。1 の後に落ちれば記録はまだ前の版で、流し直しが同じ判定で決め直しに入る。2 の後に落ちれば記録はもう新しい版で、まだ出ていないので 3 を埋め直しとして行う(記録の版とプランの版が違えば、写しから埋め直す)。 + +**本番は自分で決め直さない。** 本番の記録がある流し直しでは、`系列` の開発版の記録の `base` が本番の記録の版と違うときだけ扱う。本番のプランの状態に承認ゲート 2 の通過(`approve` のステップの結果)が無ければ、上の 1〜3 と同じ手順で新しい基底の版に取り直し(`reason: series_changed`)、あれば流さない(`series_changed`)。開発版は自分の配布のマージの後に決め直さず、本番のプランは開発版のプランが完了してから流れるため、通常の流れではこの扱いに当たらない。開発版のプランを手で作り直して流したときなどに、承認した版と出す版が食い違うのを止める守りである(決定 3)。 + ### 本番の版を決める -本番のプランの差し込みは、記録が無ければ次の順に決める。 +本番のプランの差し込みは、記録が無ければ次の順に決める(記録がある流し直しは「版を決め直す」の最後の段落)。 1. 「版」の欄に `明示` があればその版、無ければ `系列` の名前の開発版のプランの記録(同じディレクトリの、名前の一致するプランの状態ディレクトリ。`results_named` と同じ探し方)の `base`。開発版の記録が無ければ流さない(`no_series`) 2. `observe` の後に `taken` で `--v<版>` のタグを調べ、あれば流さない(`taken`。`next_free` を添える) @@ -328,9 +393,9 @@ sequenceDiagram 根拠: Vision / Value 4(MVV 版 3) -### 決定 2: 流し直しで自分の開発版を「使われた版」と見ないため、決めた版を版の記録に 1 回だけ書いて使い続ける +### 決定 2: 流し直しで自分の開発版を「使われた版」と見ないため、決めた版を版の記録に書いて使い続ける -差し込みはプランのファイルを書き換えるため、多くの流し直しでは埋まった値がそのまま残る。それでも、記録を書いてからプランを書き換える間に落ちたときと、`release` が開発版を出した後の流し直しで、判定をやり直すと次の版へ進んでしまう。記録を先に書き、記録があれば判定を打たないことで、どの時点の流し直しでも同じ版になる(受け入れ条件 7)。`release` が開発版を出した後の流し直しでは、ベースブランチの版がすでに記録の版に等しく、下限の比較(I7)だけでは止まってしまう。そのため `bump` は、等しいときに `release/v<版>` → ベースブランチのマージ済みの PR があれば自分の配布として打ち直しの `ok` を返す(入出力の契約の `bump` の「打ち直し」)。プランのファイルだけを手がかりにする案は、上の間に落ちたときに判定をやり直すため採らない。 +差し込みはプランのファイルを書き換えるため、多くの流し直しでは埋まった値がそのまま残る。それでも、記録を書いてからプランを書き換える間に落ちたときと、`release` が開発版を出した後の流し直しで、判定をやり直すと次の版へ進んでしまう。記録を先に書き、記録があれば新しい版を決めずに記録の版を使うことで、どの時点の流し直しでも同じ版になる(受け入れ条件 7)。`release` が開発版を出した後の流し直しでは、ベースブランチの版がすでに記録の版に等しく、下限の比較(I7)だけでは止まってしまう。そのため `bump` は、等しいときに `release/v<版>` → ベースブランチのマージ済みの PR があれば自分の配布として打ち直しの `ok` を返す(入出力の契約の `bump` の「打ち直し」)。プランのファイルだけを手がかりにする案は、上の間に落ちたときに判定をやり直すため採らない。記録の版を書き換えてよいのは、自分の配布のマージより前に別のスプリントがその版を出したときだけである(決定 8)。 根拠: Value 1 / Value 4(MVV 版 3) @@ -364,6 +429,18 @@ sequenceDiagram 根拠: Value 8(MVV 版 3) +### 決定 8: 記録を書いた後に別のスプリントが先に版を出してもプランを作り直さずに出すため、自分の配布のマージまでは記録の開発版を決め直す + +記録を 1 回だけ書く形では、記録を書いてから自分の `release/v<版>` がマージされるまでに別のスプリントがその版を出すと、流し直しは記録の古い版を使い、`bump` が I7 で止まり続け、プランを作り直すしかない。これは要求が無くそうとした作り直しそのものである(受け入れ条件 3)。2026-11-01 の承認ゲート 1 で、利用者はこの間の決め直しを認める案を選んだ(レビューの指摘 PRRT_kwDOSII3uc6yWWo2)。 + +決め直すのは、開発版の `how = decided` の記録で、流し直しの判定で記録の版がもう出ていて、自分の配布のマージ済みの PR が無いときだけである。自分の配布の後は `bump` の打ち直しと同じ照会(`own_released`)で見分け、決め直さない(受け入れ条件 7)。明示の版は人が選んだ版のため決め直さず止める(受け入れ条件 4)。本番は承認した版で出すため自分では決め直さず、開発版の記録の版が変わったら承認ゲート 2 の前だけ追い、後なら止める(決定 3)。 + +作り直す対象は、記録を最初に書く直前のプランを状態ディレクトリへ写して持ち、決め直しはその写しを新しい版で埋めてプランのファイルを書き直す。作業場所と `branch` は新しい版の名前でプランの実行が作り(`paths.ensure_worktree`)、前の版のステップの記録は退避してプランを頭から流す。前の作業場所・`branch` と、開いたままの PR があればそれは消さずに履歴へ残し、後片付けに任せる。差し込みは GitHub へ書かない層で、閉じる・消す操作をここへ入れると耐久ステップの再生で副作用を打ち直しうるためである。 + +ほかの案は次の理由で採らない。流す直前に毎回写しから埋め直す案は、記録が変わらない流し直しでもプランのファイルを書き換え、今の `{queue_pr:<名>}` の「埋めたら書き換えない」扱いと分かれる。作り直す対象(作業場所・`branch`・承認資料のパス・ステップの引数)の一覧を記録に持つ案は、雛形が版を書く箇所を増やすたびに一覧を直す必要があり、雛形と記録で版の置き場所を二重に持つ。 + +根拠: Vision / Value 4 / Value 10 の但し書き(MVV 版 3) + ## テスト設計 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | @@ -374,7 +451,12 @@ sequenceDiagram | 受け入れ条件 4・I5 | 明示の開発版がベースブランチの版以下のとき、差し込みがプランを流さず、項目の `version.cause` が `taken` で `existing` を持ち、プランのファイルが変わらない | 明示の版を確かめずに流す、または `next_free` で埋めると落ちる | | 受け入れ条件 5・I7 | ベースブランチが `10.27.5` のとき `bump --to 10.27.5-dev.1` が終了コード 3 で止まり、作業場所のファイルが変わらない。打ち直しは `ok`。ベースブランチが `--to` と等しく `release/v` のマージ済みの PR があれば `ok`(`already: true`)、無ければ終了コード 3。雛形の `bump` のステップの `on_exit` のキーが `bump` の返す 2・3 と一致する。タグのある正式版への `bump` も止まる | 比較を `<` にする(同じ版を通す)、タグを見ない、`emit` へ終了コードを明示しない(既定の 1 で返る)、または自分の配布の打ち直しを止めると落ちる | | 受け入れ条件 6・I4 | 本番の差し込みが開発版の記録の `base` で埋まり、そのタグがあれば流さずに `existing` と `next_free` を載せる | 本番の版を origin から決め直すと落ちる | -| 受け入れ条件 7・I3 | 開発版の記録を書いた後、origin のベースブランチを記録の版へ進めてから差し込みを流し直すと、同じ版で埋まり記録が変わらない | 記録があっても判定を打つと落ちる | +| 受け入れ条件 7・I3 | 開発版の記録を書いた後、origin のベースブランチを記録の版へ進め、`release/v<記録の版>` のマージ済みの PR がある(照会を差し替える)状態で差し込みを流し直すと、同じ版で埋まり記録が変わらない。ベースブランチが記録の版より小さいときも同じ | 自分の配布の照会を見ずに決め直す、またはまだ出ていない記録の版を決め直すと落ちる | +| 決定 8・I3・I9 | 開発版の記録 `10.27.6-dev.1` を書いた後、origin のベースブランチとタグを `10.27.6` へ進め、マージ済みの PR が無い状態で流し直すと、プランの版・作業場所・`branch` が `10.27.7-dev.1` で埋まり直し、記録の `history` に `from: 10.27.6-dev.1`・`to: 10.27.7-dev.1`・`reason: taken`・`left` が 1 件入り、前のステップの記録が `superseded/v10.27.6-dev.1/` にある | 記録の古い版で流す、写しでなく埋めた後のプランを置き換えようとして版が残る、履歴を上書きする、または前のステップの記録を残したまま流して途中から再開すると落ちる | +| 決定 8(落ちたときの戻り) | 決め直しの手順の 1 の後・2 の後で止めてから流し直すと、どちらも同じ新しい版で埋まり、`history` は 1 件だけである | 記録とプランの版の食い違いを埋め直さない、または履歴を 2 度足すと落ちる | +| 決定 8・受け入れ条件 4 | `how = explicit` の記録で同じ状況を作ると、決め直さずに流さず `version.cause` が `taken` になる | 明示の版を決め直すと落ちる | +| 決定 8・I4 | 本番の記録の後に開発版の記録の `base` を変えると、本番の状態に `approve` の結果が無ければ新しい基底の版で埋まり直し(`reason: series_changed`)、あれば流さず `version.cause` が `series_changed` になる | 承認の後に新しい版で埋める、または承認の前に古い版のまま流すと落ちる | +| I6(流し直し) | 記録がある流し直しで origin か自分の配布の照会を読めないと、記録とプランを変えずに流さない | 読めないときに記録の版で進める、または決め直すと落ちる | | 受け入れ条件 8・I6 | origin を読めない(取り除いたリモート・無いブランチ)と、差し込みが記録を書かずに流さず、`unreadable` に名前が入る | 読めない値を空や 0 として判定すると落ちる | | 受け入れ条件 9 | 差し込みを埋めた開発版のプランの `release` のステップの `cmd` が決めた版を `--version` に持つ | 差し込みを `release` のステップに書き漏らすと落ちる | | 受け入れ条件 10・I8 | `--version` を渡した `new sprint` / `new close` のプランを差し込みの後に比べると、ステップと版がこの変更の前の雛形の出力と同じ(「版」の欄を除く) | 明示の版のプランに差し込みを書くと落ちる | @@ -396,4 +478,6 @@ sequenceDiagram | `sprint-state.py loop sprint` の `--dev` | ループは今も `--dev` を必須にして `new sprint --version` へ渡す。明示の版として受け入れ条件 4・10 が当たり、退行はしないが、ループからは流す時点の版を選べない。要求の対象範囲(`new sprint` / `new close`)の外のため、範囲外の課題として起こす | | `bump-others` の止まり方 | 他のプラグインの `bump` にも下限とタグの判定が当たる。他のプラグインのタグの付け方が `ndf` と違うリポジトリで誤って止まらないかは、実装のテストで `bump-others` の経路を 1 度通して確かめる | | キューの耐久ステップでの判定 | 差し込みは耐久ステップ(`ndf.supervise.queue.fill`)の中で origin を読む。耐久の記録からの再生では判定を打ち直さず、記録の値が使われる見込みだが、実装で再生の経路を 1 度通して確かめる | -| 記録を書いてから自分の `release` がマージされるまでに、別のスプリントが記録の版を出したとき | 版の記録は 1 回だけ書く(I3)ため、流し直しは記録の古い版を使い、`bump` が I7 で終了コード 3 で止まり続け、プランを作り直すしかない。I3 を「自分の `release/v<版>` がベースブランチへマージされるまで」に狭めて決め直す案は、差し込みが埋めた後のプランのファイル・作った作業場所と `branch` を新しい版で作り直す仕組みを要し、この設計の範囲を広げるため、承認ゲート 1 で人が決める(レビューの指摘 PRRT_kwDOSII3uc6yWWo2) | +| 記録を書いてから自分の `release` がマージされるまでに、別のスプリントが記録の版を出したとき | 決めた(決定 8)。2026-11-01 の承認ゲート 1 で利用者が案 B(自分の配布のマージまでは記録の版を決め直せる)を選んだ | +| 決め直しで残した前の作業場所・`branch`・PR | 決め直しは前の版の作業場所・`branch` を消さず、`release` が開いた PR があれば開いたまま残す(決定 8)。後片付けがこれらを拾うか、PR を誰かが誤ってマージしないかは、実装で決め直しの経路を 1 度通し、後片付けの対象に入るかを確かめる | +| 決め直しとキューの再開 | ステップの記録を退避した後、キューの再開(`queue_resume.py`)と耐久ステップの再生がプランを頭から流すかは読んでいない。実装で、決め直しの後に `queue` を打ち直して最初のステップから流れることを 1 度確かめる | From 4133880e845754a2d5ae689039466273dbd14b5c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:16:11 +0000 Subject: [PATCH 13/46] =?UTF-8?q?Feat:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=AE=20PR=20=E3=82=92=E6=B5=81?= =?UTF-8?q?=E3=81=99=E6=99=82=E7=82=B9=E3=81=AB=20{queue=5Frecord}=20?= =?UTF-8?q?=E3=81=A7=E6=8E=A2=E3=81=99=EF=BC=88#2230=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - sprint_close_lib/record_find.py を新設。まとめの課題を閉じる PR とそれを参照する PR から、 段階が本番か配布なしで、スプリント: が閉じる PR を含むリリース記録の PR を 1 本決める。 2 つ以上のスプリントにあれば特定できないとして採らない - queue_fill が {queue_record} を埋め、見つからなければ計画を書き換えずに流さない(unfilled)。 plan.QUEUE_NAMED に {queue_record} を足し、run でも残れば止まる - sprint_routes.record_pr_ref は unknown を組まず、merge・手で届ける・検証の環境だけ・ 手動反映の本番系と開発ループのまとめで {queue_record} を返す。雛形と昇格は今のまま - 開発ループの承認ゲート 2 の後のまとめへ values.pr を渡さない。差し込みを埋められずに 流さなかった計画のある周回は失敗で締める - dist_record.released を足し、sprint-close.py と共有する(振る舞いは変えない) Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/dist_record.py | 8 +- plugins/ndf/scripts/lib/sprint_loop.py | 7 +- plugins/ndf/scripts/lib/sprint_loop_gate.py | 7 +- plugins/ndf/scripts/sprint-close.py | 10 +- .../scripts/sprint_close_lib/record_find.py | 133 +++++++++++++ plugins/ndf/scripts/supervise_lib/plan.py | 13 +- .../ndf/scripts/supervise_lib/queue_fill.py | 28 ++- .../scripts/supervise_lib/sprint_routes.py | 23 ++- plugins/ndf/scripts/tests/test_record_find.py | 174 ++++++++++++++++++ plugins/ndf/scripts/tests/test_sprint_loop.py | 42 +++++ .../tests/test_supervise_close_auto.py | 5 +- .../ndf/scripts/tests/test_supervise_new.py | 3 +- .../tests/test_supervise_pace_verify.py | 16 +- 13 files changed, 428 insertions(+), 41 deletions(-) create mode 100644 plugins/ndf/scripts/sprint_close_lib/record_find.py create mode 100644 plugins/ndf/scripts/tests/test_record_find.py diff --git a/plugins/ndf/scripts/lib/dist_record.py b/plugins/ndf/scripts/lib/dist_record.py index fd1777f67..d6fd156cb 100644 --- a/plugins/ndf/scripts/lib/dist_record.py +++ b/plugins/ndf/scripts/lib/dist_record.py @@ -1,7 +1,7 @@ """リリース記録(`## 配布の記録`)の形を 1 か所に持つ(#1273 の決定 3)。 形の正本は `skills/progress-tracking/SKILL.md` の「配布の記録」の表である。書く側(`release-steps.py record`)は -`format_record` で組み、読む側(`sprint-close.py`)は `parse_record` で読む。見出しと行の名前をほかのモジュールに写さない。 +`format_record` で組み、読む側(`sprint-close.py`・`sprint_close_lib/record_find.py`)は `parse_record` で読む。見出しと行の名前をほかのモジュールに写さない。 使う側は `deps.require("md")` を先に呼ぶ。 """ @@ -38,6 +38,12 @@ def format_record(prev: str | None, new: str, prs, *, stage_note: str, version_n ) +def released(stage) -> bool: + """段階が本番か配布なしか(課題を閉じてよい段階。`sprint-close.py` と `sprint_close_lib/record_find.py` が使う)。""" + stage = stage or "" + return stage.startswith("本番") or stage.startswith("配布なし") + + def _strip_note(text): """版の文字列から全角の括弧の注記を落とす。""" return re.sub(r"\s*(.*$", "", text).strip() diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 501d3a961..0e57a7cd1 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -247,8 +247,8 @@ def fresh(at): def gate2_pipeline(env: Env, rec: dict, lap: dict, stop: dict) -> str: """承認ゲート 2 の承認の後の続き: 止まった計画を経路に合う再開のステップから続け → 同じ queue の残りのステージと まとめの queue → wait → loop lap の 1 行。続けるステップが無い計画(gate-2 のプラン)は手で行う本番を先に示す。""" - pr = (stop.get("values") or {}).get("pr") - close = build_close(env, rec, lap, lap.get("issues_kept") or selected_issues(lap), record_pr=pr if isinstance(pr, int) else None) + # 止まった記録の values.pr は検査のスプリント PR で、記録の PR ではない。まとめは {queue_record} で流す時点に探す(#2230 の決定 5) + close = build_close(env, rec, lap, lap.get("issues_kept") or selected_issues(lap)) done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" plan = lap.get("gate2_plan") or lap.get("prod_plan") or "<本番のプラン>" moved = {w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]} @@ -447,7 +447,8 @@ def cmd_lap(env: Env, name: str, failed: bool, done: str | None, deferred_issues if any(i.get("result") == "関門" for i in items): return handle_gate(env, rec, before, lap, items) state = jsonio.read(lap["state_path"], missing={}, broken={}, want=dict) if lap.get("state_path") else {} - bad = [i for i in items if i.get("result") not in FINISHED and i.get("result") != NOT_RUN] + # 差し込みを埋められずに流さなかった計画(unfilled。記録の PR が見つからないまとめなど)は失敗に数える(#2230 の決定 7) + bad = [i for i in items if i.get("unfilled") or (i.get("result") not in FINISHED and i.get("result") != NOT_RUN)] outcome = FAILED if failed or bad or failure_key(state) else DONE close_lap(env, rec, lap, outcome, state, deferred_issues) return after_close(env, rec, before, f"周回 {lap['n']} を {outcome} で締めた") diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 42073ae6c..2c841de36 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -74,8 +74,9 @@ def refusal_order(summary: str) -> dict: # ---------------------------------------------------------------- sprint -def build_close(env: Env, rec: dict, lap: dict, issues: list[int], record_pr=None, tag: str = "close") -> list[str]: - """まとめのステージ(new close --pace auto)を組み、プランのパスを返す。断れば LoopError。""" +def build_close(env: Env, rec: dict, lap: dict, issues: list[int], tag: str = "close") -> list[str]: + """まとめのステージ(new close --pace auto)を組み、プランのパスを返す。断れば LoopError。 + 記録の PR は渡さない(まとめのプランが {queue_record} を持ち、流す時点に探す。#2230)。""" prev = rec["laps"][lap["n"] - 2] if lap["n"] >= 2 else None carry = ((prev or {}).get("refine") or {}).get("deferred") or [] out = Path(lap["dir"]) / tag @@ -86,8 +87,6 @@ def build_close(env: Env, rec: dict, lap: dict, issues: list[int], record_pr=Non cmd += ["--version", v["dev"], "--prod", v.get("prod") or prod_of(v["dev"])] if carry: cmd += ["--carry", *map(str, carry)] - if record_pr: - cmd += ["--record-pr", str(record_pr)] if lap.get("milestone"): cmd += ["--milestone", lap["milestone"]] code, so, se = env.run(cmd, cwd=str(env.main_dir())) diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index 5a23794ad..bda70fb25 100755 --- a/plugins/ndf/scripts/sprint-close.py +++ b/plugins/ndf/scripts/sprint-close.py @@ -33,8 +33,8 @@ {kind:"board_note"} の項目に理由が載り、課題を閉じる処理は続ける。metrics.final_stage は判定に使った最終工程。 `--record-pr 0` は「本番の記録なし」(最終の検査で変更が無く本番を飛ばした)。配布の記録を読まず、 閉じる条件 1・2 を見ずに --issues の課題を閉じる(条件 3 の振り返りは、比べる配布の記録が無いため行があれば満たす)。--issues と一緒のときだけ受ける。 -`--record-pr unknown` は「記録の置き場が分からない」(リリースの経路が雛形でも昇格でもなく、まとめのプランが -記録の PR を決められない)。配布の記録を読まず、どの課題も閉じず、ボードも書かずに --issues の課題を理由つきの +`--record-pr unknown` は「記録の置き場が分からない」(手で打つ入口と、#2230 より前に組んだまとめのプランが渡す。 +今のまとめのプランは組まず、記録の PR を流す時点に `{queue_record}` で解決する)。配布の記録を読まず、どの課題も閉じず、ボードも書かずに --issues の課題を理由つきの kept_open で返す(終了コード 0)。--issues と一緒のときだけ受ける。 終了コード: 0 = 失敗なし / 1 = 失敗あり(棚卸しへ進まない)/ 2 = 一覧が取れない・ 閉じる課題が 0 件・--record-pr 0 / unknown に --issues が無い / 3 = 呼び出しの誤り。 @@ -71,7 +71,7 @@ import locks # noqa: E402 import step_read # noqa: E402 projects-sync.sh の結果 JSON を読む(#2227) -from dist_record import parse_record # noqa: E402 記録の形は lib/dist_record.py が持つ(#1273) +from dist_record import parse_record, released # 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 @@ -86,7 +86,7 @@ ) TOOL = "sprint-close" -RECORD_UNKNOWN = "unknown" # --record-pr の「記録の置き場が分からない」(supervise_lib/sprint_routes.py が渡す。#1875) +RECORD_UNKNOWN = "unknown" # --record-pr の「記録の置き場が分からない」(手で打つ入口と、#2230 より前に組んだまとめのプランが渡す。#1875) UNKNOWN_REASON = "配布の記録の置き場が分からない(リリースの経路が雛形でも昇格でもない。リリース記録を確かめて手で閉じる)" LOCK_TIMEOUT = 30 # 課題ごとの錠の待ち(body-lock の既定と同じ) @@ -221,7 +221,7 @@ def _closing_blocker(a, rec, record_repo): kept_all = UNKNOWN_REASON # 記録を読めないので閉じない(#1875) elif not rec["found"] or not stage: kept_all = "配布の記録が読めない" - elif not (stage.startswith("本番") or stage.startswith("配布なし")): + elif not released(stage): kept_all = "本番への配布の前(本番への配布の後に閉じる)" verdicts = None verify = a.with_verification or a.final_stage == STAGE_VERIFY # 最終工程がリリース後テストなら必ず見る(I4) diff --git a/plugins/ndf/scripts/sprint_close_lib/record_find.py b/plugins/ndf/scripts/sprint_close_lib/record_find.py new file mode 100644 index 000000000..c180310b3 --- /dev/null +++ b/plugins/ndf/scripts/sprint_close_lib/record_find.py @@ -0,0 +1,133 @@ +"""まとめの課題から GitHub の参照をたどり、リリース記録(`## 配布の記録`)を置いた PR を 1 本決める(#2230)。 + +queue の差し込み `{queue_record}`(`supervise_lib/queue_fill.py`)だけが呼ぶ。決め方(設計 `issues/issue-2230-design.md` +の `find_record` の規則): + +1. まとめの課題ごとに、課題の履歴の参照(`cross-referenced`)のうち同じリポジトリの Pull Request を集める +2. そのうち本文の closing keywords か閉じる予定の参照(`closing.closing_refs`)がその課題を指すものを、スプリントの PR とする。 + 閉じる PR が見つかった課題が 0 件なら見つからない +3. 候補はスプリントの PR と、スプリントの PR の履歴の参照のうち Pull Request のものの和 +4. 候補ごとに最後のリリース記録を読み、段階が本番か配布なし(`dist_record.released`)で、`スプリント:` の PR が + 閉じる PR の見つかった課題の 1 件ごとにその閉じる PR を 1 本以上含むものだけを残す +5. 残った記録の `スプリント:` の並び(番号の集合)が 2 通り以上なら特定できない(見つからない) +6. 残った中で記録のコメントの識別子(本文は 0)が最も大きいもの、同じなら PR の番号の大きいものを採る + +GitHub は `gh`(`lib/gh_call.py`)で読むだけで、書かない。 +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass + +import gh_call +from closing import closing_refs +from dist_record import parse_record, released +from step_result import StepError + +from sprint_close_lib.collect import read_record + + +@dataclass(frozen=True) +class RecordHit: + """記録の PR を探した結果。`pr` が None なら見つからず、`reason()` が理由の文を返す。""" + + pr: int | None = None + issues: tuple[int, ...] = () + closers: tuple[int, ...] = () # まとめの課題を閉じる PR(スプリントの PR) + searched: tuple[int, ...] = () # 記録を読んだ PR + pre_release: tuple[int, ...] = () # 記録はあるが段階が本番の前だった PR + error: str = "" # GitHub を読めなかったときの理由 + ambiguous: tuple[tuple[int, tuple[int, ...]], ...] = () # (記録の PR, その `スプリント:` の PR の集合) の並び + + def reason(self) -> str: + if self.pr is not None: + return "" + issues = " ".join(f"#{n}" for n in self.issues) or "(無し)" + if self.error: + return f"GitHub を読めない: {self.error}" + if self.ambiguous: + sets = "・".join(f"PR #{pr}(スプリント: {' '.join(f'#{n}' for n in prs)})" for pr, prs in self.ambiguous) + return f"課題 {issues} を閉じる記録が 2 つ以上のスプリントにある: {sets}。`--record-pr <番号>` を明示して組み直す" + if not self.closers: + return f"課題 {issues} を閉じる PR が見つからない(課題の履歴の参照を読んだ)" + closers = " ".join(f"#{n}" for n in self.closers) + others = [n for n in self.searched if n not in self.closers] + refs = f"と、それを参照する PR {' '.join(f'#{n}' for n in others)} " if others else "" + tail = f"(本番の前の記録だけ: PR {' '.join(f'#{n}' for n in self.pre_release)})" if self.pre_release else "" + return f"課題 {issues} を閉じる PR {closers} {refs}を読んだが、本番か配布なしの段階のリリース記録が無い{tail}" + + +class _Unreadable(Exception): + pass + + +def _repo_of(root) -> str: + p = gh_call.gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"], cwd=root) + name = p.stdout.strip() + if p.returncode != 0 or not name: + raise _Unreadable((p.stderr or "記録のリポジトリを決められない").strip()[:300]) + return name + + +def _referencing_prs(root, repo: str, n: int) -> list[dict]: + """課題か PR `n` の履歴の参照のうち、同じリポジトリの Pull Request(番号と本文)。""" + p = gh_call.gh(["api", "--paginate", "--slurp", f"repos/{repo}/issues/{n}/timeline"], cwd=root) + if p.returncode != 0: + raise _Unreadable((p.stderr or "").strip()[:300] or f"#{n} の履歴の参照を読めない") + try: + pages = json.loads(p.stdout or "[]") + except ValueError: + raise _Unreadable(f"#{n} の履歴の参照の出力を読めない") + events = [e for page in pages for e in (page if isinstance(page, list) else [page])] + out: dict[int, dict] = {} + for e in events: + src = (e or {}).get("source", {}).get("issue") or {} + if e.get("event") != "cross-referenced" or src.get("pull_request") is None: + continue + if ((src.get("repository") or {}).get("full_name") or repo).lower() != repo.lower(): + continue + out.setdefault(int(src["number"]), {"number": int(src["number"]), "body": src.get("body") or ""}) + return list(out.values()) + + +def find_record(root, repo: str | None, issues) -> RecordHit: + """まとめの課題 `issues` のリリース記録を置いた PR を決める。`root` は `gh` を打つ作業場所、`repo` は owner/name(None なら `root` から決める)。""" + issues = tuple(int(n) for n in issues) + if not issues: + return RecordHit(error="まとめの課題が無い") + try: + repo = repo or _repo_of(root) + closers_of: dict[int, set[int]] = {} + for n in issues: + hit = {pr["number"] for pr in _referencing_prs(root, repo, n) if (repo, n) in closing_refs(pr["body"], repo)} + if hit: + closers_of[n] = hit + closers = tuple(sorted(set().union(*closers_of.values()))) if closers_of else () + if not closers: + return RecordHit(issues=issues) + cands = set(closers) + for pr in closers: + cands |= {r["number"] for r in _referencing_prs(root, repo, pr)} + kept, pre = [], [] + for pr in sorted(cands): + try: + rec = parse_record(*read_record(root, repo, pr)) + except StepError as e: + raise _Unreadable(str(e)) + if not rec["found"]: + continue + if not released(rec["stage"]): + pre.append(pr) + continue + sprint = set(rec["sprint_prs"]) + if all(sprint & want for want in closers_of.values()): + kept.append((rec["dist_comment_id"] or 0, pr, tuple(sorted(sprint)))) + except _Unreadable as e: + return RecordHit(issues=issues, error=str(e)) + base = {"issues": issues, "closers": closers, "searched": tuple(sorted(cands)), "pre_release": tuple(pre)} + if len({k[2] for k in kept}) > 1: + return RecordHit(**base, ambiguous=tuple((pr, prs) for _, pr, prs in sorted(kept, key=lambda k: k[1]))) + if not kept: + return RecordHit(**base) + return RecordHit(pr=max(kept)[1], **base) diff --git a/plugins/ndf/scripts/supervise_lib/plan.py b/plugins/ndf/scripts/supervise_lib/plan.py index 47aa485e8..d2646f874 100644 --- a/plugins/ndf/scripts/supervise_lib/plan.py +++ b/plugins/ndf/scripts/supervise_lib/plan.py @@ -144,8 +144,11 @@ 名前(ファイル名の stem か、その末尾の `-<計画名>`)が一致する計画の Pull Request 1 本(前のステージに無ければ 同じディレクトリの計画の報告。完了して Pull Request が無い・飛ばされたなら `0`)、`{queue_state:<計画名>}` は 同じ名前の計画の状態ディレクトリの絶対パス。名前の一致する計画の結果(完了した報告・状態ディレクトリ)が見つからなければ -埋めずに、そのプランを流さない(`0`・空文字で黙って埋めない。#2079)。`{queue_state?:<計画名>}` は無いことを読む側が扱う差し込みで、無ければ空文字で埋め、残っても止めない。置き換わらずに `{queue_pr:…}`・`{queue_state:…}` -が残ったコマンドのステップは、run でも queue でもコマンドを打たずに止まる +埋めずに、そのプランを流さない(`0`・空文字で黙って埋めない。#2079)。`{queue_state?:<計画名>}` は無いことを読む側が扱う差し込みで、無ければ空文字で埋め、残っても止めない。 +`{queue_record}` は計画の `課題` のリリース記録(`## 配布の記録`)を置いた PR の番号で、課題を閉じる PR とそれを参照する PR を +GitHub から読み、段階が本番か配布なしで `スプリント:` がその閉じる PR を含む記録を採る。見つからない・2 つ以上のスプリントに +ある・GitHub を読めなければ、同じく埋めずにそのプランを流さない(#2230)。置き換わらずに `{queue_pr:…}`・`{queue_state:…}`・ +`{queue_record}` が残ったコマンドのステップは、run でも queue でもコマンドを打たずに止まる ステップの遷移: - `next` に `end` を書くと、そこでフェーズを完了として終える @@ -205,12 +208,12 @@ # queue が --then のプランを流す前に、先行のプランの Pull Request の番号へ置き換える印 QUEUE_PRS = "{queue_prs}" -# queue が名前の一致する計画の結果で置き換える差し込み({queue_pr:<名>}・{queue_state:<名>}) -QUEUE_NAMED = re.compile(r"\{queue_(?:pr|state):[A-Za-z0-9._-]+\}") +# queue が名前の一致する計画の結果とリリース記録で置き換える差し込み({queue_pr:<名>}・{queue_state:<名>}・{queue_record}) +QUEUE_NAMED = re.compile(r"\{queue_(?:pr|state):[A-Za-z0-9._-]+\}|\{queue_record\}") def leftover_placeholders(text: str) -> list[str]: - """置き換わらずに残った {queue_pr:<名>}・{queue_state:<名>}(出た順、重なりは 1 つ。#2079)。""" + """置き換わらずに残った {queue_pr:<名>}・{queue_state:<名>}・{queue_record}(出た順、重なりは 1 つ。#2079・#2230)。""" return list(dict.fromkeys(QUEUE_NAMED.findall(text or ""))) diff --git a/plugins/ndf/scripts/supervise_lib/queue_fill.py b/plugins/ndf/scripts/supervise_lib/queue_fill.py index 2ca39af0b..4f280efa1 100644 --- a/plugins/ndf/scripts/supervise_lib/queue_fill.py +++ b/plugins/ndf/scripts/supervise_lib/queue_fill.py @@ -1,10 +1,12 @@ """queue の差し込み(`{queue_pr:<名>}`・`{queue_state:<名>}`)を、名前の一致する計画の結果で埋める(#1488・#2079)。 +`{queue_record}` は、計画の `課題` のリリース記録を置いた PR を GitHub から探して埋める(#2230。`sprint_close_lib/record_find.py`)。 `queue.py` から切り出した。`queue` は同じ名前で読み込み直して公開する。 """ from __future__ import annotations +import json import os import re from pathlib import Path @@ -16,6 +18,7 @@ QUEUE_PR = re.compile(r"\{queue_pr:([A-Za-z0-9._-]+)\}") # 名前の一致する計画の Pull Request 1 本 +QUEUE_RECORD = "{queue_record}" # 計画の `課題` のリリース記録を置いた PR(#2230) QUEUE_STATE = re.compile(r"\{queue_state(\??):([A-Za-z0-9._-]+)\}") # 名前の一致する計画の状態ディレクトリ(#1488)。`?` 付きは無ければ空 @@ -76,22 +79,41 @@ def _fill_states(text: str, states: list[tuple[str, str]], items: list[dict], pl return text, missing +def _fill_record(text: str, plan: str) -> tuple[str, list[str]]: + """{queue_record} を、計画の `課題` のリリース記録を置いた PR の番号で置き換える。見つからなければ理由の文で返す。""" + from sprint_close_lib.record_find import find_record # GitHub を読む差し込みを持つ計画だけが読み込む + + try: + info = json.loads(text) + except ValueError: + return text, [f"{QUEUE_RECORD}(計画の JSON を読めない)"] + issues = [n for n in info.get("課題") or [] if isinstance(n, int)] if isinstance(info, dict) else [] + root = (info.get("リポジトリ") if isinstance(info, dict) else None) or str(Path(plan).parent) + hit = find_record(root, None, issues) + if hit.pr is None: + return text, [f"{QUEUE_RECORD}({hit.reason()})"] + return text.replace(QUEUE_RECORD, str(hit.pr)), [] + + def fill_queue_pr(plan: str, items: list[dict]) -> str | None: """計画の {queue_pr:<名>} を、前のステージの名前の一致する計画の Pull Request 1 本で置き換える。 前のステージに無ければ同じディレクトリの計画の報告を読む(関門の後に単独で流すとき)。完了して PR が無い・飛ばされたなら 0。 {queue_state:<名>} は、名前の一致する計画の状態ディレクトリの絶対パスで置き換える(#1488)。結果(完了した報告・ 状態ディレクトリ)が見つからなければ、0・空文字で埋めずに計画を書き換えず理由を返す(#2079)。 - {queue_state?:<名>} だけは、無いことを読む側が扱うと明示した差し込みで、無ければ空文字で埋める(単独で流す本番の approve)。""" + {queue_state?:<名>} だけは、無いことを読む側が扱うと明示した差し込みで、無ければ空文字で埋める(単独で流す本番の approve)。 + {queue_record} は計画の `課題` のリリース記録を置いた PR で置き換え、見つからなければ同じく書き換えずに理由を返す(#2230)。""" try: text = Path(plan).read_text() except OSError as e: return f"計画を読めない: {e}" names, states = sorted(set(QUEUE_PR.findall(text))), sorted(set(QUEUE_STATE.findall(text))) - if not names and not states: + record = QUEUE_RECORD in text + if not names and not states and not record: return None text, missing_prs = _fill_prs(text, names, items, plan) text, missing_states = _fill_states(text, states, items, plan) - missing = missing_prs + missing_states + text, missing_record = _fill_record(text, plan) if record else (text, []) + missing = missing_prs + missing_states + missing_record if missing: return "差し込みを埋める前の計画の結果が見つからない: " + "、".join(missing) write_text_atomic(Path(plan), text) diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index 374fe6896..5e237a2f9 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -58,27 +58,30 @@ def has_release_template(a) -> bool: RECORD_NONE = "0" # 本番の記録なし(配布なし。sprint-close.py は閉じる条件 1・2 を見ない。#1336) -RECORD_UNKNOWN = "unknown" # 記録の置き場が分からない(sprint-close.py は課題を開いたまま残す。#1875) +RECORD_FOUND = "{queue_record}" # まとめを流し始める時点に queue が GitHub から探すリリース記録の PR(#2230) def has_record(record_pr: str) -> bool: - """まとめの close が読む配布の記録の PR({queue_pr:…})があるか。""" - return record_pr not in (RECORD_NONE, RECORD_UNKNOWN) + """まとめの close が読む配布の記録の PR(番号か {queue_pr:…}・{queue_record})があるか。""" + return record_pr != RECORD_NONE def record_pr_ref(a) -> str: - """まとめの close が読む配布の記録の PR: 雛形の本番の PR か、昇格の PR(#837)。昇格は route_waves が昇格の - プラン(計画名 promote)を置くときだけ。経路がすべて届けない経路なら RECORD_NONE、それ以外の届ける経路 - (merge・手で届ける・検証の環境だけ・手動反映の本番系の昇格)は記録の置き場が分からず RECORD_UNKNOWN。""" + """まとめの close が読む配布の記録の PR。経路がすべて届けない経路なら RECORD_NONE。開発ループのまとめ(pace: auto)は + 経路にかかわらず RECORD_FOUND(承認ゲート 2 の後のまとめは本番のプランと別の queue にある。#2230 の決定 5)。それ以外は + 雛形の本番の PR か、昇格の PR(#837。route_waves が昇格のプランを置くときだけ)で、残りの届ける経路(merge・手で届ける・ + 検証の環境だけ・手動反映の本番系)は RECORD_FOUND。""" + rs = routes_of(a) + if all(r.stage == delivery.STAGE_NONE for r in rs): + return RECORD_NONE + if getattr(a, "pace", None) == "auto": + return RECORD_FOUND if has_release_template(a): return "{queue_pr:release-prod}" - rs = routes_of(a) manual_prod = getattr(a, "state", None) and dev_channel_of(a).value == delivery.MANUAL_PRODUCTION if not manual_prod and any(r.stage == delivery.STAGE_PROMOTE for r in rs): return "{queue_pr:promote}" - if all(r.stage == delivery.STAGE_NONE for r in rs): - return RECORD_NONE - return RECORD_UNKNOWN + return RECORD_FOUND def manual_release_wave(a) -> dict: diff --git a/plugins/ndf/scripts/tests/test_record_find.py b/plugins/ndf/scripts/tests/test_record_find.py new file mode 100644 index 000000000..dc3e52faa --- /dev/null +++ b/plugins/ndf/scripts/tests/test_record_find.py @@ -0,0 +1,174 @@ +"""まとめの差し込み `{queue_record}` と、リリース記録を置いた PR の探し方(#2230 の I3〜I5・決定 2・3)。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +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 sprint_close_lib import record_find # noqa: E402 +from sprint_close_lib.record_find import RecordHit, find_record # noqa: E402 +from supervise_lib import queue # noqa: E402 +from supervise_lib.plan import leftover_placeholders # noqa: E402 +from supervise_lib.queue_fill import fill_queue_pr # noqa: E402 + +REPO = "o/r" + + +def record(stage="本番(npm)", sprint=(10,)): + return f"## 配布の記録\n\n段階: {stage}\n版: 1.0.0 → 1.1.0(minor)\nスプリント: PR " + " / ".join(f"#{n}" for n in sprint) + "\n" + + +class FakeGh: + """課題と PR の履歴の参照(refs: 番号 → [(PR, 本文)])と、PR の本文とコメント(prs: 番号 → (本文, [(識別子, 本文)]))。""" + + def __init__(self, refs, prs=None, fail=()): + self.refs, self.prs, self.fail, self.calls = refs, prs or {}, set(fail), [] + + def __call__(self, args, stdin=None, cwd=None): + self.calls.append(args) + if args[:2] == ["repo", "view"]: + return gh_call.GhResult(0, REPO + "\n", "") + if args[0] == "api": + n = int(args[-1].split("/")[-2]) + if n in self.fail: + return gh_call.GhResult(1, "", "HTTP 502") + events = [ + { + "event": "cross-referenced", + "source": {"issue": {"number": pr, "body": body, "pull_request": {}, "repository": {"full_name": REPO}}}, + } + for pr, body in self.refs.get(n, []) + ] + return gh_call.GhResult(0, json.dumps([events + [{"event": "labeled"}]]), "") + if args[:2] == ["pr", "view"]: + body, comments = self.prs.get(int(args[2]), ("", [])) + cs = [{"body": b, "url": f"https://github.com/{REPO}/pull/{args[2]}#issuecomment-{cid}"} for cid, b in comments] + return gh_call.GhResult(0, json.dumps({"body": body, "comments": cs}), "") + raise AssertionError(args) + + +@pytest.fixture +def fake(monkeypatch): + def install(refs, prs=None, fail=()): + g = FakeGh(refs, prs, fail) + monkeypatch.setattr(gh_call, "gh", g) + return g + + return install + + +def pr_views(g): + return [int(a[2]) for a in g.calls if a[:2] == ["pr", "view"]] + + +def test_skips_the_later_sprint_reached_through_a_pr_that_only_refers_to_the_issue(fake): + """#2089 の形: 閉じる PR #2103 の記録 #2126 を採り、参照だけの #2128 を通じた後のスプリントの記録 #2138 を採らない。""" + g = fake( + { + 2089: [(2103, "Closes #2089"), (2128, "#2089 の確定仕様化"), (2091, "refs #2089")], + 2103: [(2120, "#2103"), (2126, "#2103")], + 2128: [(2138, "#2128")], + }, + {2126: ("", [(500, record(sprint=(2098, 2103)))]), 2138: ("", [(900, record(sprint=(2128,)))])}, + ) + hit = find_record(".", None, [2089]) + assert hit.pr == 2126 and hit.closers == (2103,) + assert 2138 not in pr_views(g) + + +def test_two_sprints_closing_the_same_issue_are_not_guessed(fake): + """課題を持ち越して 2 つのスプリントで閉じる PR を出した形: どちらも採らず、理由に両方の記録の PR が載る。""" + fake( + {5: [(10, "Closes #5"), (20, "最終工程の後に閉じる: #5")], 10: [(30, "#10")], 20: [(40, "#20")]}, + {30: ("", [(100, record(sprint=(10,)))]), 40: ("", [(200, record(sprint=(20, 21)))])}, + ) + hit = find_record(".", None, [5]) + assert hit.pr is None and [pr for pr, _ in hit.ambiguous] == [30, 40] + assert "PR #30(スプリント: #10)" in hit.reason() and "PR #40(スプリント: #20 #21)" in hit.reason() + + +def test_a_record_covering_only_some_of_the_issues_is_not_taken(fake): + fake( + {5: [(10, "Closes #5")], 6: [(11, "Fixes #6")], 10: [(30, "#10")]}, + {30: ("", [(100, record(sprint=(10,)))])}, + ) + assert find_record(".", None, [5, 6]).pr is None + + +def test_an_issue_without_a_closing_pr_does_not_block_the_record(fake): + fake( + {5: [(10, "Closes #5")], 7: [(12, "#7 に触れた")], 10: [(30, "#10")]}, + {30: ("", [(100, record(sprint=(10,)))])}, + ) + assert find_record(".", None, [5, 7]).pr == 30 + + +def test_no_closing_pr_at_all_reads_no_record(fake): + g = fake({5: [(12, "#5 に触れた")]}) + hit = find_record(".", None, [5]) + assert hit.pr is None and pr_views(g) == [] and "#5 を閉じる PR が見つからない" in hit.reason() + + +def test_a_record_before_the_production_release_is_not_taken(fake): + fake({5: [(10, "Closes #5")], 10: [(30, "#10")]}, {30: ("", [(100, record(stage="検証(staging)"))])}) + hit = find_record(".", None, [5]) + assert hit.pr is None and hit.pre_release == (30,) + assert "閉じる PR #10 と、それを参照する PR #30 を読んだ" in hit.reason() and "本番の前の記録だけ: PR #30" in hit.reason() + + +def test_the_record_in_the_latest_comment_wins_over_a_body_record(fake): + fake( + {5: [(10, "Closes #5")], 10: [(30, "#10"), (40, "#10")]}, + {30: ("", [(900, record())]), 40: (record(), [])}, + ) + assert find_record(".", None, [5]).pr == 30 + + +def test_github_errors_are_reported(fake): + fake({}, fail=[5]) + hit = find_record(".", None, [5]) + assert hit.pr is None and hit.reason().startswith("GitHub を読めない: HTTP 502") + + +def close_plan(tmp_path, issues=(5,)): + p = tmp_path / "close.json" + steps = [ + {"id": "release-verify", "type": "work", "prompt": "配布の記録は Pull Request #{queue_record} にある。"}, + {"id": "close", "type": "run", "cmd": "sprint-close.py --record-pr {queue_record} --issues 5"}, + ] + p.write_text(json.dumps({"課題": list(issues), "リポジトリ": str(tmp_path), "steps": steps}, ensure_ascii=False)) + return p + + +def test_fill_writes_the_found_record_into_every_step(tmp_path, monkeypatch): + seen = [] + monkeypatch.setattr(record_find, "find_record", lambda root, repo, issues: (seen.append((root, issues)), RecordHit(pr=30))[1]) + p = close_plan(tmp_path) + assert fill_queue_pr(str(p), []) is None + steps = json.loads(p.read_text())["steps"] + assert "#30 にある" in steps[0]["prompt"] and "--record-pr 30 " in steps[1]["cmd"] + assert seen == [(str(tmp_path), [5])] + + +def test_fill_leaves_the_plan_and_the_stage_is_unfilled_when_no_record_is_found(tmp_path, monkeypatch): + monkeypatch.setattr(record_find, "find_record", lambda root, repo, issues: RecordHit(issues=(5,))) + p = close_plan(tmp_path) + before = p.read_text() + skipped, runnable = queue.fill_stage(True, [str(p)], []) + assert runnable == [] and skipped[0]["unfilled"] is True + assert "{queue_record}(課題 #5 を閉じる PR が見つからない" in skipped[0]["reason"] + assert p.read_text() == before and leftover_placeholders(before) == ["{queue_record}"] + + +def test_plans_without_the_marker_do_not_read_github(tmp_path, monkeypatch): + monkeypatch.setattr(record_find, "find_record", lambda *a: pytest.fail("GitHub を読んだ")) + p = tmp_path / "x.json" + p.write_text(json.dumps({"課題": [5], "steps": [{"id": "a", "cmd": "sprint-close.py --record-pr unknown"}]})) + assert fill_queue_pr(str(p), []) is None diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 7d9f0dbc7..95229f92f 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -931,3 +931,45 @@ def test_refused_continuation_after_gate_1_is_rebuilt_from_the_kept_issues(env, assert got["status"] == "ok" and "loop lap l1" in got["metrics"]["next"] close = next(c for c in reversed(env.calls) if "close" in c) assert close[close.index("--issue") + 1 : close.index("--state")] == ["12"] + + +def test_gate_2_resume_builds_the_close_without_the_check_pr_as_the_record(env): + """#2230 の受け入れ条件 2: 承認ゲート 2 の後のまとめへ、止まった記録の values.pr(検査のスプリント PR)を --record-pr で渡さない。""" + lap = built_lap(env) + assert sl_gate.cmd_gate(env, "l1", write_done(env, [{"plan": str(Path(lap["out"]) / "prod.json"), "result": "関門"}]))["exit"] == 3 + r = rec(env) + r["stop"]["values"]["pr"] = 2103 + sl.write_record(env, r) + approve_gate_2(sl.current_lap(rec(env))) + env.calls.clear() + nxt = sl.cmd_start(env, "l1", [], {})["metrics"]["next"] + closes = [c for c in env.calls if "new" in c and "close" in c] + assert closes and all("--record-pr" not in c for c in closes) and "loop lap l1" in nxt + + +def test_lap_with_an_unfilled_close_is_closed_as_failed(env): + """#2230 の決定 7: 差し込みを埋められずに流さなかったまとめ(記録の PR が見つからない)がある周回は完了で締めない。""" + lap = built_lap(env, nums=(11,)) + set_state(env, lap) + items = [ + {"plan": str(Path(lap["out"]) / "check.json"), "result": "完了"}, + { + "plan": str(Path(lap["out"]) / "close" / "1-close.json"), + "result": "流さなかった", + "reason": "{queue_record}(…)", + "unfilled": True, + }, + ] + sl.cmd_lap(env, "l1", False, write_done(env, items), []) + assert rec(env)["laps"][0]["outcome"] == sl.FAILED + + +def test_lap_with_a_plan_skipped_after_a_finished_stage_is_still_done(env): + lap = built_lap(env, nums=(11,)) + set_state(env, lap) + items = [ + {"plan": str(Path(lap["out"]) / "check.json"), "result": "完了"}, + {"plan": str(Path(lap["out"]) / "prod.json"), "result": "流さなかった", "reason": "前のステージを流さなかった"}, + ] + sl.cmd_lap(env, "l1", False, write_done(env, items), []) + assert rec(env)["laps"][0]["outcome"] == sl.DONE diff --git a/plugins/ndf/scripts/tests/test_supervise_close_auto.py b/plugins/ndf/scripts/tests/test_supervise_close_auto.py index a3f877334..b93f641d2 100644 --- a/plugins/ndf/scripts/tests/test_supervise_close_auto.py +++ b/plugins/ndf/scripts/tests/test_supervise_close_auto.py @@ -16,12 +16,15 @@ def close(tmp_path, *extra): def test_auto_builds_only_the_wrapup_stage_with_the_record_marker(tmp_path): + """#2230 の決定 5: 開発ループのまとめは雛形の経路でも {queue_record} を組み、リリース後テストも同じ参照を受ける。""" manifest = close(tmp_path, "--pace", "auto") assert [w["name"] for w in manifest["ステージ"]] == ["まとめ"] assert manifest["進め方"] == "auto" plan = load(manifest["ステージ"][0]["plans"][0]) assert plan["進め方"] == "auto" - assert "--record-pr {queue_pr:release-prod}" in steps_of(plan)["close"]["cmd"] + steps = steps_of(plan) + assert "--record-pr {queue_record} " in steps["close"]["cmd"] + assert "#{queue_record}" in steps["release-verify"]["prompt"] def test_carry_reaches_the_refinement_prompt_and_record_pr_overrides(tmp_path): diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 8fd5da3ed..4c6c113ae 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -127,7 +127,8 @@ def test_new_close_without_release_form_puts_manual_stage(tmp_path, delivery): note = manifest["ステージ"][1]["note"] assert "delivery" in note and ("不明" in note if delivery else "無い" in note) close = json.loads(Path(manifest["ステージ"][2]["plans"][0]).read_text()) - assert "--record-pr unknown " in next(s["cmd"] for s in close["steps"] if s["id"] == "close") # #1875 + assert "--record-pr {queue_record} " in next(s["cmd"] for s in close["steps"] if s["id"] == "close") # #2230 + assert "unknown" not in json.dumps(close["steps"], ensure_ascii=False) def test_new_sprint_and_close_with_package_plugin_still_need_versions(tmp_path): diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 05c15e83d..868568f82 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -312,13 +312,13 @@ 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。""" +def test_close_without_a_plan_record_resolves_it_when_run(tmp_path, rows): + """#2230 の受け入れ条件 1・3・5: 雛形も昇格も無い届ける経路(merge・手で届ける・検証の環境だけ)は --record-pr unknown を + 組まず、{queue_record} を 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] - assert [s for s in steps if s.get("next") == "close"] + by = {s["id"]: s for s in steps} + assert "--record-pr {queue_record} " in by["close"]["cmd"] and "unknown" not in by["close"]["cmd"] + assert "#{queue_record}" in by["release-verify"]["prompt"] def test_close_of_no_delivery_keeps_record_pr_zero(tmp_path): @@ -343,9 +343,9 @@ def chain(steps) -> list[str]: [ ([STG, PRD], ["merge", "release-verify", "retro", "close", "refine"]), ([], ["merge", "retro", "close", "refine"]), - ([STG], ["merge", "retro", "close", "refine"]), + ([STG], ["merge", "release-verify", "retro", "close", "refine"]), ], - ids=["record", "no-delivery", "unknown"], + ids=["record", "no-delivery", "queue-record"], ) def test_close_comes_after_the_retro_and_before_the_refine(tmp_path, rows, want): """#2228 の受け入れ条件 5・6(I6): next のつながりで リリース後テスト → 振り返り → 課題を閉じる → 棚卸し。 From 00de444a60ff99d69fb9e2b52b84cacd1bc7440e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:16:11 +0000 Subject: [PATCH 14/46] =?UTF-8?q?Docs:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=AE=20--record-pr=20=E3=81=AE=E8=AA=AC=E6=98=8E=E3=82=92?= =?UTF-8?q?=20{queue=5Frecord}=20=E3=81=AB=E7=9B=B4=E3=81=99=EF=BC=88#2230?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/skills/development-workflow/references/pace.md | 6 +++++- plugins/ndf/skills/progress-tracking/SKILL.md | 3 ++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index 683fbf468..da0dcd88d 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -209,7 +209,11 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの 工程を記録するため、通過記録の報告が `まとめる:` から `記録あり:` へ移る。 リリースの経路がテンプレートで組むものでなければ `--version`・`--prod` は要らず、開発版と本番の代わりに下の経路のステージが入り、 `close` のステップの `--record-pr` は、昇格の経路(手動反映の本番系を除く)なら `{queue_pr:promote}`、経路がすべて届けない経路なら `0`、 -それ以外は `unknown`(記録の置き場が分からない。課題を開いたまま理由つきで残す)になる。 +それ以外(merge・手で届ける・検証の環境だけ・手動反映の本番系)は `{queue_record}` になる。開発ループのまとめ(`new close --pace auto`)は、 +届けない経路だけでなければ経路にかかわらず `{queue_record}` になる。`{queue_record}` は queue がまとめを流し始める時点に、まとめの +課題を閉じる PR とそれを参照する PR から、段階が本番か配布なしのリリース記録を置いた PR を探して埋め、`release-verify` と `close` が +同じ番号を受ける。見つからない・2 つ以上のスプリントにある・GitHub を読めなければ、まとめを流さずに止まり(queue の結果は `stopped`、 +開発ループの周回は失敗で締まる)、理由に探した課題と PR が載る。2 つ以上のスプリントにあるときは `--record-pr <番号>` を明示して組み直す。 ## リリースの経路からステージを組む diff --git a/plugins/ndf/skills/progress-tracking/SKILL.md b/plugins/ndf/skills/progress-tracking/SKILL.md index c4ffd3e84..1754d3b6c 100644 --- a/plugins/ndf/skills/progress-tracking/SKILL.md +++ b/plugins/ndf/skills/progress-tracking/SKILL.md @@ -247,7 +247,8 @@ python3 "$SCRIPTS/sprint-close.py" --record-pr <記録のPR番号> [--final-stag 確かめていないことが終わったように見える。 `--record-pr 0`(本番の記録なし)は条件 1・2 を見ない。`--record-pr unknown`(記録の置き場が分からない)は -本文を読まずにすべて `kept_open` で返す。 +本文を読まずにすべて `kept_open` で返す。まとめのプランは `unknown` を組まず、記録の PR を流す時点に `{queue_record}` で探す +(`development-workflow` の `references/pace.md` の「スプリントの終わり」)。`unknown` を渡すのは手で打つ入口と、前の版で組んだまとめのプランだけである。 ### 閉じる予定の参照 From 75ee6f594f9904ceb031f1fc63add21b551c42dc Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:28:47 +0000 Subject: [PATCH 15/46] =?UTF-8?q?Docs:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=8C=20{queue=5Frecord}=20=E3=82=92=E7=B5=84=E3=82=80?= =?UTF-8?q?=E5=A4=89=E6=9B=B4=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98?= =?UTF-8?q?=E3=81=B8=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B=EF=BC=88#2230?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf-production-release-record.md | 10 ++++- ...udgments-unknown-record-and-manual-none.md | 41 ++++++++++--------- .../ndf-workflow-development-loop.md | 8 ++-- 3 files changed, 34 insertions(+), 25 deletions(-) diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index b9e1fc6fa..23a5f804c 100644 --- a/docs/specifications/ndf-production-release-record.md +++ b/docs/specifications/ndf-production-release-record.md @@ -167,14 +167,20 @@ python3 release-steps.py record --version <版> --prs ... [--plugin < ### まとめのプランの記録の PR と `release-verify` まとめのプラン(`supervise_lib/sprint.py` の `close_plan`)は、記録の PR の参照を経路から決め(`supervise_lib/sprint_routes.py` の -`record_pr_ref`)、記録の PR が `{queue_pr:…}` で決まる経路ではマージの後・`close` の前に work ステップ `release-verify`(`/ndf:release-verification`)を置く。 +`record_pr_ref`)、記録の PR の参照が `0` でない経路ではマージの後・`close` の前に work ステップ `release-verify`(`/ndf:release-verification`)を置く。 | 経路 | `close` の `--record-pr` | `release-verify` | | --- | --- | --- | | 雛形(`release.form`) | `{queue_pr:release-prod}` | 置く | | 昇格(`route_waves` が昇格のプランを置き、開発版のチャネルが手で届ける本番でないとき) | `{queue_pr:promote}` | 置く | | 経路がすべて届けない経路(none) | `0`(本番の記録なし。条件を見ずに閉じる) | 置かない | -| それ以外(merge・manual・検証の環境だけ・手動反映の本番系の昇格) | `unknown`(記録の置き場が分からない。課題を理由つきの `kept_open` で開いたまま残し、ボードも書かない) | 置かない | +| それ以外(merge・manual・検証の環境だけ・手動反映の本番系の昇格) | `{queue_record}`(まとめを流し始める時点に queue が GitHub の参照から記録の PR を探して埋める。#2230) | 置く | +| 開発ループのまとめ(`new close --pace auto`)で、経路がすべて届けない経路でないとき | 経路にかかわらず `{queue_record}`(承認ゲート 2 の後のまとめは本番のプランと別の queue にあり、`{queue_pr:…}` を解けない) | 置く | + +`{queue_record}` は、まとめの課題を閉じる PR とそれを参照する PR のリリース記録のうち、段階が本番か配布なしで `スプリント:` が +その閉じる PR を含む記録を置いた PR の番号で埋まる(`sprint_close_lib/record_find.py`)。見つからない・2 つ以上のスプリントにある・ +GitHub を読めなければ、まとめを流さずに止まる(queue の結果は `stopped`、開発ループの周回は失敗で締まる)。まとめのプランは +`sprint-close.py --record-pr unknown` を組まず、`unknown` を渡すのは手で打つ入口と #2230 より前に組んだプランだけである。 `release-verify` は、記録の PR にリリース後テストの記録をコメントで置き、課題は閉じない。実行時に番号が `0` になったとき (最終の検査で変更が無く、まとめの queue の本番が飛ばされたとき)は何もせずに終える。 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 34fafe161..ce5e70205 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 @@ -4,10 +4,12 @@ スプリントの機械の判定が、記録の無い状態や「確かめる中身が無い」行を取り違えないようにする決まりを 2 つ置く。 -- **記録の置き場が分からないまとめの `close` は、課題を閉じない。** リリースの経路が雛形(`release.form`)でも昇格(promote)でも - なく、届ける経路を 1 つでも含むとき、まとめのプランは `sprint-close.py --record-pr unknown` を打つ(`new close --record-pr <番号>` - で組んだときはその番号を打つ。#2089)。`sprint-close.py` は - 配布の記録を読まず、どの課題も閉じず、ボードも書かずに、`--issues` の課題を理由つきの `kept_open` で返す(終了コード 0) +- **記録の置き場が分からない `close` は、課題を閉じない。** `sprint-close.py --record-pr unknown` は + 配布の記録を読まず、どの課題も閉じず、ボードも書かずに、`--issues` の課題を理由つきの `kept_open` で返す(終了コード 0)。 + #2230 から、まとめのプランは `unknown` を組まない。リリースの経路が雛形(`release.form`)でも昇格(promote)でもなく、届ける経路を + 1 つでも含むとき(と開発ループのまとめ)は `--record-pr {queue_record}` を組み、まとめを流し始める時点に queue が記録の PR を探して埋める + ([ndf-production-release-record.md](ndf-production-release-record.md) の「まとめのプランの記録の PR と `release-verify`」)。 + `unknown` を渡すのは手で打つ入口と、#2230 より前に組んだまとめのプランだけである - **「手動確認は無い」と書いた行は、手動確認の行として数えない。** 要求の「検証手段」の表で 2 列目が「無し」の形の行は、 PR 本文の `## 手動確認` の節に載らず、merge-gate も承認ゲート(終了コード 10)で止まらない @@ -51,7 +53,7 @@ | # | 条件 | 破れたときの扱い | | --- | --- | --- | | I1 | まとめのプランの記録の参照が `0`(`RECORD_NONE`)になるのは、リリースの経路がすべて届けない経路(`STAGE_NONE`)のときだけである | テストが落とす | -| I2 | 記録の参照が `{queue_pr:…}` でない(`has_record` が偽)とき、プランに `release-verify` を置かず、merge の次は `retro`(その次が `close`。#2228)になる | テストが落とす | +| I2 | 記録の参照が `0` のとき(`has_record` が偽。#2230 からは `0` のときだけ偽)、プランに `release-verify` を置かず、merge の次は `retro`(その次が `close`。#2228)になる | テストが落とす | | I3 | `--record-pr unknown` の実行は、どの課題も閉じず、ボードも書かず、課題の本文も読まない(gh へ書き込まない) | テストが落とす | | I4 | `--record-pr 0` の意味(記録を読まず、閉じる条件 1・2 を見ずに `--issues` を閉じる(条件 3 の振り返りの記録は見る)。`--issues` が無ければ終了コード 2)は変わらない | 既存のテストが落とす | | I5 | 要求の 2 列目が「無し」の形の行は `ManualRow` にならない。それ以外の空でない 2 列目は手動確認の行になる | テストが落とす | @@ -66,10 +68,11 @@ | --- | --- | | 雛形の経路がある | `{queue_pr:release-prod}` | | 昇格の経路があり、開発版のチャネルが手動反映の本番系(`delivery.MANUAL_PRODUCTION`)でない | `{queue_pr:promote}` | -| 経路がすべて `STAGE_NONE`(`delivery` が `[]` を含む) | `0`(`RECORD_NONE`) | -| それ以外(merge・手で届ける・検証の環境だけ・手動反映の本番系の昇格) | `unknown`(`RECORD_UNKNOWN`) | +| それ以外(merge・手で届ける・検証の環境だけ・手動反映の本番系の昇格) | `{queue_record}`(`RECORD_FOUND`。#2230) | -語 `0` と `unknown` と `has_record` は `supervise_lib/sprint_routes.py` に置き、`close_plan` はそれを呼ぶだけにする。 +#2230 から、最初に経路がすべて `STAGE_NONE`(`delivery` が `[]` を含む)かを見て `0`(`RECORD_NONE`)を返し、次に開発ループのまとめ +(`--pace auto`)なら `{queue_record}` を返し、その後に上の表を上から見る。`unknown`(`RECORD_UNKNOWN`)は `sprint_routes.py` から消えた。 +語 `0` と `{queue_record}` と `has_record` は `supervise_lib/sprint_routes.py` に置き、`close_plan` はそれを呼ぶだけにする。 #### `sprint-close.py --record-pr` @@ -105,17 +108,19 @@ ```mermaid flowchart TD - A["record_pr_ref"] --> B{"雛形の経路がある"} + A["record_pr_ref"] --> D{"経路がすべて STAGE_NONE"} + D -- はい --> Z["0"] + D -- いいえ --> L{"開発ループのまとめ(--pace auto)"} + L -- はい --> U["{queue_record}"] + L -- いいえ --> B{"雛形の経路がある"} B -- はい --> T["{queue_pr:release-prod}"] B -- いいえ --> C{"昇格の経路があり、手動反映の本番系でない"} C -- はい --> P["{queue_pr:promote}"] - C -- いいえ --> D{"経路がすべて STAGE_NONE"} - D -- はい --> Z["0"] - D -- いいえ --> U["unknown"] + C -- いいえ --> U T --> V["merge → release-verify → retro → close"] P --> V + U --> V Z --> W["merge → retro → close(release-verify なし)"] - U --> W ``` `sprint-close.py` の中では、`cmd_close` が `--issues` の有無を確かめ、`_read_sprint_record` が `0` と `unknown` で記録を読まずに @@ -130,7 +135,7 @@ PR の一覧を空で返す。`_closing_blocker` は `unknown` のとき全課 | 決定 | 理由 | 採らなかった案 | | --- | --- | --- | | `--record-pr` に語 `unknown` を足し、`0` の意味は変えない | 語ならプランの `cmd` と結果を読む人がそのまま意味を取れる。`0` を手で打つ既存の手順の意味が黙って変わらない | 負の数(`-1`)で表す。`0` を「置き場を知らない」に変え、配布なしに別の値を足す | -| 検証の環境だけ(本番の行の無い)経路・手動反映の本番系の昇格も `unknown` にする | 開いたまま残った課題は人が 1 回の操作で閉じられるが、条件を見ずに閉じた課題は確かめていないことが結果から読めない | 検証の環境だけの経路を配布なしとして `0` で閉じる | +| 検証の環境だけ(本番の行の無い)経路・手動反映の本番系の昇格も `unknown` にする(#2230 で `{queue_record}` に置き換わった。閉じる条件を見ずに閉じない方針は変わらない) | 開いたまま残った課題は人が 1 回の操作で閉じられるが、条件を見ずに閉じた課題は確かめていないことが結果から読めない | 検証の環境だけの経路を配布なしとして `0` で閉じる | | 「無し」の判定を `rows_of` の 1 か所に置き、語の直後を括弧・句読点・空白・文末に限る | PR 本文の節も merge-gate の数え方も `rows_of` の出力から作られる。後続を限れば「無しの場合は〜」のような確認の文を落とさない | merge-gate で「無し」の行を数えない(PR 本文に未チェックの行が残り続ける)。要求の雛形の文を変える(雛形に「無ければ行を消す」が既にある) | ## データ・設定 @@ -157,7 +162,7 @@ PR の一覧を空で返す。`_closing_blocker` は `unknown` のとき全課 | 観点 | テスト | | --- | --- | -| merge・手で届ける・検証の環境だけの経路で、`close` の `cmd` が `--record-pr unknown` になり `release-verify` が無いこと(I1・I2) | `test_supervise_pace_verify.py` の `test_close_without_a_known_record_keeps_the_issues_open` | +| merge・手で届ける・検証の環境だけの経路で、`close` の `cmd` が `--record-pr {queue_record}` になり `unknown` を持たず、`release-verify` が同じ参照を持つこと(I1・I2。#2230) | `test_supervise_pace_verify.py` の `test_close_without_a_plan_record_resolves_it_when_run` | | `delivery: []` で `--record-pr 0` のまま変わらないこと(I1) | 同じファイルの `test_close_of_no_delivery_keeps_record_pr_zero` | | `--record-pr unknown --issues …` で全課題が `kept_open`・理由つき・終了コード 0、gh の書き込みが 0 回で、`summary` が先頭の文を持つこと(I3) | `test_sprint_close_issues.py` の `test_record_unknown_keeps_the_given_issues_open_with_a_reason` | | `unknown` に `--issues` が無ければ終了コード 2、それ以外の値・負の数は終了コード 3 | 同じファイルの `test_record_unknown_alone_returns_two`・`test_record_pr_rejects_other_values` | @@ -170,15 +175,13 @@ PR の一覧を空で返す。`_closing_blocker` は `unknown` のとき全課 | 項目 | 内容 | | --- | --- | -| 記録の PR を `close` へ渡す手段が無い | merge・手で届ける・検証の環境だけの経路の課題は、`unknown` で開いたまま残り、人が手で閉じる。受け渡しの手段は課題 #2044 が扱う | -| 手動反映の本番系の昇格はテストで縛っていない | 見本のリポジトリ(`make_repo`)は起点 develop・本番 main で、起点と本番が同じ形を組めない。`record_pr_ref` の分岐(`manual_prod`)は昇格を飛ばして `unknown` へ落ちる形で入っている | -| 語 `unknown` が 2 か所にある | `sprint-close.py` は `supervise_lib` を import しない独立のスクリプトで、`RECORD_UNKNOWN` を `sprint_routes.py` と別に持つ(コメントで指し合う) | +| 手動反映の本番系の昇格はテストで縛っていない | 見本のリポジトリ(`make_repo`)は起点 develop・本番 main で、起点と本番が同じ形を組めない。`record_pr_ref` の分岐(`manual_prod`)は昇格を飛ばして `{queue_record}` へ落ちる形で入っている | | 既に閉じた課題も `kept_open` で載る | `unknown` は課題の状態を読まずに返すため、closing keywords で GitHub が先に閉じた課題も `kept_open` に並ぶ | | 流し直さない PR 本文 | 既に作られた PR 本文の「無し」の未チェック行は、pr ステップを流し直さない限り残る | ## 関連リンク -- 課題: #1875(記録の置き場が分からない close)・#1675(「無し」の手動確認の行)。実装は PR #2051 +- 課題: #1875(記録の置き場が分からない close)・#1675(「無し」の手動確認の行)。実装は PR #2051。まとめのプランが `unknown` の代わりに `{queue_record}` を組む変更は #2230 - [ndf-production-release-record.md](ndf-production-release-record.md): 経路ごとの記録の PR と `release-verify` - [ndf-sprint-close-acceptance-criteria.md](ndf-sprint-close-acceptance-criteria.md): 閉じる条件 2 と受け入れ条件の印(`unknown` で本文を読まない条件 I8) - [ndf-sprint-close-referenced-issues.md](ndf-sprint-close-referenced-issues.md): 参照だけの課題(`unknown` ではスプリントの PR を読まない) diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 1c6044eb7..85047351e 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -202,9 +202,9 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | --- | --- | --- | | `--pace auto` | まとめのステージだけを組む(上の表) | 今の形(`fast` のスプリントの終わり)。出力は変わらない | | `--carry N...` | 棚卸しのステップの指示へ前の周回の持ち越しを `--add` で渡す | 指示は今のまま | -| `--record-pr N` | 課題を閉じるステップ(`sprint-close.py --record-pr`)へ本番の記録の Pull Request を渡す | 配布の経路から導く印(`{queue_pr:release-prod}` など)を残し、queue が前のステージの本番のプランの Pull Request で埋める | +| `--record-pr N` | 課題を閉じるステップ(`sprint-close.py --record-pr`)へ本番の記録の Pull Request を渡す | 経路がすべて届けない経路なら `0`、それ以外は経路にかかわらず `{queue_record}` を残し、queue がまとめを流し始める時点に GitHub の参照からリリース記録の PR を探して埋める(#2230) | -周回の中では `--record-pr` を渡さない。渡すのは承認ゲート 2 の承認の後にまとめを組み直すとき(`stop.values.pr`)だけである。 +ループは `--record-pr` を渡さない。承認ゲート 2 の承認の後にまとめを組み直すときも渡さない(`stop.values.pr` は検査のスプリント PR で、記録の PR ではない。#2230)。 ### 承認ゲートで止まったとき(`gate`、`lap --done`) @@ -240,7 +240,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 失敗の鍵は、スプリント状態の計画の行のうち結果が `まだ`・`流さなかった`・`完了`・`関門` でない最初の行の `{issues, kind, step}` である。理由の文は含めない。`loop lap --failed` で締めて該当の行が無ければ `{issues: 選んだ課題, kind: 周回, step: conductor}` にする。周回は、`--failed`・done に完了でも関門でも流さなかったでもない -結果がある・失敗の鍵がある、のどれかで `失敗`、ほかは `完了` で締まる。 +結果がある・done に差し込みを埋められずに流さなかった計画(`unfilled`。記録の PR が見つからないまとめなど。#2230)がある・失敗の鍵がある、のどれかで `失敗`、ほかは `完了` で締まる。 `loop start` の再開は止まった理由ごとに次のとおりである。 @@ -249,7 +249,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | `user_stop` | ファイルを消して解ける | | 予算 | 再開の引数で上書きした予算で判定し直す。まだ予算以上なら 3 | | `same_failure` | 再開の起動そのものを解けた印とし、失敗の数を数え直す | -| `gate2_returned` | 答えが無ければ 3。差し戻しなら周回を `人へ戻した` で締める。承認なら `next` に、止まった計画を経路に合うステップ(雛形の本番は `--from bump`、昇格は `--from promote-approved`、マージで止まった計画は `--from merge-approved`)から `supervise.py run` で続け → 同じ queue の残りのステージ → `--record-pr ` で組み直したまとめ → `wait` → `loop lap` の 1 行を載せる。続けるステップが無い計画は、手で行う本番を先に示す | +| `gate2_returned` | 答えが無ければ 3。差し戻しなら周回を `人へ戻した` で締める。承認なら `next` に、止まった計画を経路に合うステップ(雛形の本番は `--from bump`、昇格は `--from promote-approved`、マージで止まった計画は `--from merge-approved`)から `supervise.py run` で続け → 同じ queue の残りのステージ → `--record-pr` なしで組み直したまとめ(`{queue_record}` を持つ)→ `wait` → `loop lap` の 1 行を載せる。続けるステップが無い計画は、手で行う本番を先に示す | | `refused`(続きの queue) | 人の判断待ちへ移した課題を戻さず、残した課題で続きを組み直す。組めなければ 3 | | `refused`(ほか) | `next` に同じ周回での `loop sprint` のやり直しを載せる(モードと版は conductor が決め直す) | | `mvv_changed` | 解けない。新しい名前でループを始め直すよう示す(開始時の値を今の値へ置き換えない) | From 2673edc66a80e0ddcc9f02c115f65605b95b1679 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:48:25 +0000 Subject: [PATCH 16/46] =?UTF-8?q?Feat:=20=E9=85=8D=E5=B8=83=E3=81=AE=20bum?= =?UTF-8?q?p=20=E3=81=8C=E7=89=88=E3=82=92=E6=88=BB=E3=81=99=E5=91=BC?= =?UTF-8?q?=E3=81=B3=E6=96=B9=E3=81=A8=E6=97=A2=E3=81=AB=E3=82=BF=E3=82=B0?= =?UTF-8?q?=E3=81=AE=E3=81=82=E3=82=8B=E7=89=88=E3=82=92=E6=AD=A2=E3=82=81?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2164=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 版の判定を lib/release_version.py に置き、bump は書き換える前に origin のベースブランチの版と タグを読む。ベースブランチの版以下・既にタグのある正式版は終了コード 3、読めなければ 2 で止める。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/release_version.py | 162 ++++++++++++++++++ plugins/ndf/scripts/release-steps.py | 65 ++++++- plugins/ndf/scripts/tests/release_repo.py | 11 ++ .../tests/test_bump_keeps_instructions.py | 5 +- .../ndf/scripts/tests/test_release_steps.py | 66 ++++++- 5 files changed, 296 insertions(+), 13 deletions(-) create mode 100644 plugins/ndf/scripts/lib/release_version.py diff --git a/plugins/ndf/scripts/lib/release_version.py b/plugins/ndf/scripts/lib/release_version.py new file mode 100644 index 000000000..c6d8e61fb --- /dev/null +++ b/plugins/ndf/scripts/lib/release_version.py @@ -0,0 +1,162 @@ +"""配布の版の判定(#2164)。次に空いている開発版を決め、ある版がもう出ているかを判定する。 + +キューの版の差し込み(`supervise_lib/queue_fill.py` の `fill_release_version`)と `release-steps.py bump` が呼ぶ 1 か所で、 +開発版と本番・キューと `bump` で版の規則を分けない(#2164 の決定 4)。ファイルを書かない。 + +- `observe(repo, plugin, base, production)`: `git fetch -q origin --tags` の後に、origin のベースブランチと本番のブランチの + `plugin.json` の版と、`--v<版>` の正式版のタグを読む。読めなかったものは `unreadable` に名前で入れる +- `decide_dev(obs, level)`: 次に空いている開発版 +- `taken(obs, version)`: その版がもう出ているか(開発版はベースブランチの版が同じか大きい、正式版は同じ版のタグがある) +- `own_released(repo, branch, base)`: `branch` → `base` のマージ済みの PR があるか(照会できなければ None) + +版の比較は `versions`(semver)を使う。呼ぶ側は先に `deps.require("versions")` を済ませておく。 +""" + +from __future__ import annotations + +import json +import subprocess +from dataclasses import asdict, dataclass, field +from pathlib import Path + +import versions + +LEVELS = ("patch", "minor", "major") + + +@dataclass +class Observed: + plugin: str + base_ref: str # origin/<ベースブランチ> + base_version: str | None + production_ref: str # origin/<本番のブランチ>。本番のブランチを宣言していなければ空 + production_version: str | None + latest_tag: str | None # 最新の正式版のタグの版(タグが 1 つも無ければ None) + tags: list[str] = field(default_factory=list) # 正式版のタグの版(`--v` を外したもの) + unreadable: list[str] = field(default_factory=list) + + def as_dict(self) -> dict: + """版の記録の `observed`(読んだ値)。タグの一覧と読めなかったものは載せない。""" + d = asdict(self) + for k in ("tags", "unreadable", "plugin"): + d.pop(k) + return d + + +@dataclass +class Decision: + version: str | None # 決めた・確かめた版(決められなければ None) + base: str | None # version の接尾辞を外した版 + how: str # decided / explicit / series + existing: str | None = None # すでに出ている版(ベースブランチの版かタグ) + next_free: str | None = None # 次に空いている版(すでに出ているときだけ) + observed: dict = field(default_factory=dict) + unreadable: list[str] = field(default_factory=list) + + @property + def is_taken(self) -> bool: + return self.existing is not None + + +def plugin_json_rel(plugin: str) -> str: + """リポジトリの根からのプラグインの `plugin.json` の位置(`release-steps.py` の `plugin_dir` と同じ置き方)。""" + d = f"plugins/{plugin}" if plugin in ("ndf", "playwright-kit") else f"plugins/mcp/{plugin}" + return f"{d}/.claude-plugin/plugin.json" + + +def _git(repo: str | Path, *args: str) -> subprocess.CompletedProcess: + return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True) + + +def _version_at(repo: str | Path, ref: str, rel: str) -> str | None: + p = _git(repo, "show", f"{ref}:{rel}") + if p.returncode: + return None + try: + v = json.loads(p.stdout)["version"] + except (ValueError, KeyError, TypeError): + return None + return v if isinstance(v, str) and versions.parse_version(v) else None + + +def observe(repo: str | Path, plugin: str, base: str, production: str | None, fetch: bool = True) -> Observed: + """origin を取り直してから、ベースブランチ・本番のブランチ・タグを読む。本番のブランチが無ければ読まない(読めないに数えない)。 + タグが 1 つも無いことは読めないに数えない。取り直しに落ちたら、読んだ値が古いかもしれないため `origin` を読めないに数える。""" + unreadable: list[str] = [] + if fetch and _git(repo, "fetch", "-q", "origin", "--tags").returncode: + unreadable.append("origin") + rel = plugin_json_rel(plugin) + base_ref = f"origin/{base}" + base_version = _version_at(repo, base_ref, rel) + if base_version is None: + unreadable.append(base_ref) + prod_ref = f"origin/{production}" if production else "" + prod_version = _version_at(repo, prod_ref, rel) if prod_ref else None + if prod_ref and prod_version is None: + unreadable.append(prod_ref) + p = _git(repo, "tag", "--list", f"{plugin}--v*") + tags: list[str] = [] + if p.returncode: + unreadable.append("tags") + else: + head = f"{plugin}--v" + for t in p.stdout.split(): + v = t[len(head) :] + ver = versions.parse_version(v) + if ver is not None and not ver.prerelease: + tags.append(v) + latest = max(tags, key=versions.version_order) if tags else None + return Observed(plugin, base_ref, base_version, prod_ref, prod_version, latest, tags, unreadable) + + +def _max_base(vs: list[str | None]) -> str: + bases = [versions.release_base(v) for v in vs if v] + return max(bases, key=versions.version_order) + + +def decide_dev(obs: Observed, level: str = "patch") -> Decision: + """次に空いている開発版。ベースブランチの版 B が `X-dev.N` で X のタグが無ければ `X-dev.(N+1)`、そうでなければ + 最新のタグ T・本番のブランチ P・B の基底の版のうち最も大きいものを上げ幅で上げた Y の `Y-dev.1`。読めないものがあれば決めない。""" + if obs.unreadable: + return Decision(None, None, "decided", observed=obs.as_dict(), unreadable=list(obs.unreadable)) + b = obs.base_version or "" + if versions.dev_number(b) is not None and versions.release_base(b) not in obs.tags: + v = versions.next_dev(b) + else: + v = f"{versions.next_version(_max_base([obs.latest_tag, obs.production_version, b]), level)}-dev.1" + return Decision(v, versions.release_base(v), "decided", observed=obs.as_dict()) + + +def taken(obs: Observed, version: str, how: str = "explicit", level: str = "patch") -> Decision: + """`version` がもう出ているか。開発版(接尾辞つき)はベースブランチの版が同じか大きいとき、正式版は同じ版のタグがあるとき、 + `existing` に既に出ている版、`next_free` に次に空いている版を入れる。読めないものがあれば判定しない(`unreadable`)。""" + d = Decision(version, versions.release_base(version), how, observed=obs.as_dict(), unreadable=list(obs.unreadable)) + if obs.unreadable: + return d + if versions.parse_version(version).prerelease: + if obs.base_version and versions.compare_versions(obs.base_version, version) >= 0: + d.existing = obs.base_version + d.next_free = decide_dev(obs, level).version + return d + if version in obs.tags: + d.existing = f"{obs.plugin}--v{version}" + nxt = version + while nxt in obs.tags: + nxt = versions.next_patch(nxt) + d.next_free = nxt + return d + + +def own_released(repo: str | Path, branch: str, base: str) -> bool | None: + """`branch` → `base` のマージ済みの PR があるか(自分の配布か)。1 回だけ照会し、照会できなければ None。""" + import gh_call + + r = gh_call.gh( + ["pr", "list", "--head", branch, "--base", base, "--state", "merged", "--json", "number", "--limit", "1"], cwd=str(repo) + ) + if r.returncode: + return None + try: + return bool(json.loads(r.stdout or "[]")) + except ValueError: + return None diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index e8451713e..e647ae447 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -94,6 +94,7 @@ EXIT_GATE, EXIT_INFRA_WAIT, EXIT_PRECONDITION, + EXIT_UNREADABLE, PRESENT_HEADINGS, StepError, approval_present, @@ -383,6 +384,58 @@ def plan_bump(root, pdir, plugin, old, new): return ed +def bump_guard(root, plugin, old, new, base) -> str | None: + """`bump` が書き換える前の判定(#2164 の I7)。打ち直しなら origin のベースブランチの版を返し、書き換えてよければ None。 + 新しい版がベースブランチの版以下か、正式版でそのタグがあれば `stopped`・終了コード 3 で止める(版を戻さない)。 + ベースブランチの版を読めない・自分の配布の照会ができなければ、推定で進めずに終了コード 2 で止める。 + 作業場所がすでに新しい版で、ベースブランチの版が等しいときだけ `release/v<新しい版>` → ベースブランチのマージ済みの + PR を 1 回照会し、あれば自分の配布の後の流し直しとして打ち直しにする。""" + import release_version + import versions + + if versions.parse_version(new) is None: + raise StepError(f"版の形が違う: {new}(X.Y.Z / X.Y.Z-dev.N / X.Y.Z-rc.N)", EXIT_UNREADABLE) + obs = release_version.observe(root, plugin, base, None) + metrics = {"plugin": plugin, "from": old, "to": new, "base_version": obs.base_version} + if obs.unreadable: + emit( + result(TOOL, "stopped", f"版を確かめる値を読めない: {'・'.join(obs.unreadable)}。推定で上げない", [], {**metrics, "unreadable": obs.unreadable}), + code=EXIT_UNREADABLE, + ) + cmp = versions.compare_versions(obs.base_version, new) + if old == new and cmp < 0: + return obs.base_version + if old == new and cmp == 0: + own = release_version.own_released(root, f"release/v{new}", base) + if own is None: + emit( + result(TOOL, "stopped", f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない", [], {**metrics, "unreadable": ["pr"]}), + code=EXIT_UNREADABLE, + ) + if own: + return obs.base_version + d = release_version.taken(obs, new) + if cmp >= 0 or d.is_taken: + existing = obs.base_version if cmp >= 0 else d.existing + if d.is_taken: + nxt = d.next_free + elif versions.parse_version(new).prerelease: + nxt = release_version.decide_dev(obs).version + else: + nxt = versions.next_patch(obs.base_version) + emit( + result( + TOOL, + "stopped", + f"{plugin} の {new} はすでに出ている({existing})。版を戻さない(次に空いている版は {nxt})", + [], + {**metrics, "existing": existing, "next_free": nxt}, + ), + code=EXIT_PRECONDITION, + ) + return None + + def cmd_bump(a): root = git_root(a.root) a.base = a.base or delivery.load_delivery(root).base # 既定は宣言のベースブランチ(#1336) @@ -392,18 +445,16 @@ def cmd_bump(a): except (OSError, ValueError, KeyError) as e: raise StepError(f"旧版を plugin.json から読めない: {e}", 2) new = a.to - if old == new: - base_ver = base_version(root, pdir, f"origin/{a.base}") - if base_ver is None or base_ver == new: - raise StepError(f"旧版と新版が同じ: {old}" + ("" if base_ver else f"(origin/{a.base} の版を読めない)")) - # 同じステップの打ち直し(作業場所の版はすでに上げてある) + already = bump_guard(root, a.plugin, old, new, a.base) + if already: + # 同じステップの打ち直し(作業場所の版はすでに上げてある)か、自分の配布の後の流し直し emit( result( TOOL, "ok", - f"{a.plugin} はすでに {new}(origin/{a.base} は {base_ver})。上げ直さない", + f"{a.plugin} はすでに {new}(origin/{a.base} は {already})。上げ直さない", [], - {"plugin": a.plugin, "from": base_ver, "to": new, "already": True}, + {"plugin": a.plugin, "from": already, "to": new, "already": True}, ) ) ed = plan_bump(root, pdir, a.plugin, old, new) diff --git a/plugins/ndf/scripts/tests/release_repo.py b/plugins/ndf/scripts/tests/release_repo.py index 185a4472d..38a171c92 100644 --- a/plugins/ndf/scripts/tests/release_repo.py +++ b/plugins/ndf/scripts/tests/release_repo.py @@ -27,3 +27,14 @@ def seed_release_repo(tmp_path: Path) -> tuple[Path, Path]: (seed / ".ndf" / "worktree.json").write_text(json.dumps({"version": 1, "base_branch": "trunk", "production_branch": "live"})) (seed / ".ndf" / "supervise.json").write_text(json.dumps({"release": {"form": "package-plugin", "plugin": "foo"}})) return origin, seed + + +def push_origin(root: Path, branch: str = "develop") -> Path: + """root の HEAD を、隣に置く bare の origin の `branch` へ送り、取り直す(`bump` は origin を取り直してから版を読む。#2164)。""" + bare = root.parent / f"{root.name}-origin.git" + if not bare.exists(): + subprocess.run(["git", "init", "-q", "--bare", str(bare)], check=True) + _git(root, "remote", "add", "origin", str(bare)) + _git(root, "push", "-q", "-f", "--tags", "origin", f"HEAD:refs/heads/{branch}") + _git(root, "fetch", "-q", "origin") + return bare diff --git a/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py b/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py index 82bda019d..fef5033a7 100644 --- a/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py +++ b/plugins/ndf/scripts/tests/test_bump_keeps_instructions.py @@ -15,6 +15,9 @@ import pytest +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from release_repo import push_origin # noqa: E402 + SCRIPTS = Path(__file__).resolve().parents[1] RELEASE_STEPS = SCRIPTS / "release-steps.py" INSTRUCTIONS_CHECK = SCRIPTS / "instructions-check.py" @@ -70,7 +73,7 @@ def read_size(root: Path) -> int: def commit(root: Path) -> None: git(root, "add", "-A") git(root, "commit", "-q", "-m", "t") - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) def check(root: Path) -> subprocess.CompletedProcess: diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index d1f6b5a99..a622aa38f 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -7,6 +7,7 @@ from __future__ import annotations import json +import os import subprocess import sys from pathlib import Path @@ -16,6 +17,7 @@ SCRIPT = Path(__file__).resolve().parents[1] / "release-steps.py" sys.path.insert(0, str(Path(__file__).resolve().parent)) import inproc # noqa: E402 +from release_repo import push_origin # noqa: E402 PY = sys.executable @@ -558,7 +560,7 @@ def bump_repo(root: Path, base_version: str, work_version: str) -> None: f.write_text(json.dumps({"version": base_version}), encoding="utf-8") git(root, "add", "-A") git(root, "commit", "-q", "-m", "base") - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) if work_version != base_version: f.write_text(json.dumps({"version": work_version}), encoding="utf-8") git(root, "commit", "-q", "-am", "bump") @@ -572,11 +574,65 @@ def test_bump_rerun_after_the_version_was_raised_is_ok(repo): assert res["status"] == "ok" and res["metrics"]["from"] == "1.2.3" and res["items"] == [] -def test_bump_to_the_version_of_the_base_still_stops(repo): +def fake_gh(tmp: Path, out: str, code: int = 0) -> dict: + """`gh pr list` の答えを固定する偽の gh を PATH の先頭に置いた環境(自分の配布の照会。#2164)。""" + d = tmp / "fakebin" + d.mkdir(exist_ok=True) + (d / "gh").write_text(f"#!/bin/sh\nprintf '%s' '{out}'\nexit {code}\n") + (d / "gh").chmod(0o755) + return {**os.environ, "PATH": f"{d}:{os.environ['PATH']}"} + + +def run_env(root: Path, env: dict, *args: str) -> subprocess.CompletedProcess: + return subprocess.run([PY, str(SCRIPT), *args[:1], "--root", str(root), *args[1:]], capture_output=True, text=True, env=env) + + +def last_json(p: subprocess.CompletedProcess) -> dict: + return json.loads(p.stdout.strip().splitlines()[-1]) + + +def manifest(root: Path) -> str: + return (root / "plugins/ndf/.claude-plugin/plugin.json").read_text(encoding="utf-8") + + +def test_bump_below_or_at_the_base_stops_without_writing(repo): + """ベースブランチが 10.27.5 のとき 10.27.5-dev.1 へは上げない(版を戻さない。受け入れ条件 5)。""" + bump_repo(repo, "10.27.5", "10.27.5") + before = manifest(repo) + p = run(repo, "bump", "--plugin", "ndf", "--to", "10.27.5-dev.1") + res = last_json(p) + assert p.returncode == 3 and res["status"] == "stopped", p.stdout + assert res["metrics"]["existing"] == "10.27.5" and res["metrics"]["next_free"] == "10.27.6-dev.1" + assert manifest(repo) == before + + +def test_bump_to_a_release_with_a_tag_stops(repo): + bump_repo(repo, "1.2.3", "1.2.3") + git(repo, "tag", "ndf--v1.2.4") + push_origin(repo) + p = run(repo, "bump", "--plugin", "ndf", "--to", "1.2.4") + res = last_json(p) + assert p.returncode == 3 and res["metrics"]["existing"] == "ndf--v1.2.4" and res["metrics"]["next_free"] == "1.2.5" + + +def test_bump_at_the_base_is_a_rerun_only_after_its_own_release(repo, tmp_path): + """作業場所とベースブランチがともに新しい版なら、release/v<版> のマージ済みの PR があるときだけ打ち直し(決定 2)。""" bump_repo(repo, "1.2.4", "1.2.4") + own = run_env(repo, fake_gh(tmp_path, '[{"number": 7}]'), "bump", "--plugin", "ndf", "--to", "1.2.4") + assert own.returncode == 0 and last_json(own)["metrics"]["already"] is True, own.stdout + other = run_env(repo, fake_gh(tmp_path, "[]"), "bump", "--plugin", "ndf", "--to", "1.2.4") + assert other.returncode == 3 and last_json(other)["metrics"]["existing"] == "1.2.4" + unknown = run_env(repo, fake_gh(tmp_path, "", 1), "bump", "--plugin", "ndf", "--to", "1.2.4") + assert unknown.returncode == 2 and last_json(unknown)["metrics"]["unreadable"] == ["pr"] + + +def test_bump_without_origin_stops_as_unreadable(repo): + bump_repo(repo, "1.2.3", "1.2.3") + git(repo, "remote", "remove", "origin") + before = manifest(repo) p = run(repo, "bump", "--plugin", "ndf", "--to", "1.2.4") - assert p.returncode != 0 - assert "旧版と新版が同じ" in p.stdout + p.stderr + assert p.returncode == 2 and "origin" in last_json(p)["metrics"]["unreadable"] + assert manifest(repo) == before def test_bump_leaves_a_line_repeated_elsewhere_to_the_hand(repo): @@ -650,7 +706,7 @@ def test_changed_plugins_without_the_tag_is_two(repo): def test_bump_raises_the_other_plugin_from_changed_plugins(repo): root = changed_repo(repo) - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) _, res = changed(root, "--since", "ndf--v1.0.0") it = res["items"][0] p = run(root, "bump", "--plugin", it["name"], "--to", it["to"]) From e440741c06d54affaca815f7f74437a977c958c1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:58:02 +0000 Subject: [PATCH 17/46] =?UTF-8?q?Feat:=20=E7=89=88=E3=82=92=E6=B8=A1?= =?UTF-8?q?=E3=81=95=E3=81=9A=E3=81=AB=E4=BD=9C=E3=81=A3=E3=81=9F=E3=82=B9?= =?UTF-8?q?=E3=83=97=E3=83=AA=E3=83=B3=E3=83=88=E3=81=AE=E9=85=8D=E5=B8=83?= =?UTF-8?q?=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=81=8C=E7=89=88=E3=81=AE?= =?UTF-8?q?=E5=B7=AE=E3=81=97=E8=BE=BC=E3=81=BF=E3=82=92=E6=8C=81=E3=81=A4?= =?UTF-8?q?=EF=BC=88#2164=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit new sprint / new close の --version・--prod を任意にし、--level を足す。雛形は版が無ければ {release_version}・{release_base} を書き、プランへ「版」の欄を置く。bump は 2・3 で judge を通らずに止める。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/decl.py | 12 +----- plugins/ndf/scripts/supervise_lib/new_args.py | 21 ++++++---- .../supervise_lib/release_templates.py | 35 +++++++++++++++-- plugins/ndf/scripts/supervise_lib/sprint.py | 7 ++-- .../scripts/supervise_lib/sprint_routes.py | 18 +++++---- .../ndf/scripts/supervise_lib/sprint_waves.py | 29 +++++++++----- .../fixtures/supervise-contract/contract.json | 4 +- .../plans/close/2-release.json | 11 ++++++ .../plans/close/3-release-prod.json | 11 ++++++ .../supervise-contract/plans/release.json | 11 ++++++ .../plans/sprint/10-release-prod.json | 11 ++++++ .../plans/sprint/8-release.json | 11 ++++++ .../ndf/scripts/tests/test_release_steps.py | 8 ++-- plugins/ndf/scripts/tests/test_supervise.py | 2 +- .../ndf/scripts/tests/test_supervise_new.py | 38 +++++++++++++++---- 15 files changed, 173 insertions(+), 56 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/decl.py b/plugins/ndf/scripts/supervise_lib/decl.py index d4281b323..26d5e868e 100644 --- a/plugins/ndf/scripts/supervise_lib/decl.py +++ b/plugins/ndf/scripts/supervise_lib/decl.py @@ -232,7 +232,7 @@ def apply_decls(a) -> None: - 実装の上限(a.impl_timeout)と想定最大時間(a.refactor_budget_minutes): 引数 → supervise.json の同じ名前の項目。 宣言の形は引数があっても見る(#1916 の決定 5) - リリースの経路(a.routes)は sprint と close だけが使い、`sprint_routes.apply_routes` が delivery_decl(宣言。a.delivery)と require_versions で組む。 + リリースの経路(a.routes)は sprint と close だけが使い、`sprint_routes.apply_routes` が delivery_decl(宣言。a.delivery)で組む。 """ roots = decl_roots(a.worktree, getattr(a, "repo", None)) wt = read_decl(roots, WORKTREE_DECL) @@ -326,16 +326,6 @@ def delivery_decl(a) -> "delivery.DeliveryDecl": ) -def require_versions(a) -> None: - """版数(--version・close は --prod も)は、リリースの経路が雛形(template)で組むときだけ要る(#1336 の I6)。""" - if not delivery.needs_version(a.routes): - return - need = [("version", "--version", "版数"), *([("prod", "--prod", "本番の版")] if a.kind == "close" else [])] - gaps = [f"{what}({flag}。release.form の雛形は版数で組む)" for attr, flag, what in need if not getattr(a, attr, None)] - if gaps: - raise DeclError(f"new {a.kind} に要る宣言が無い: " + "・".join(gaps)) - - def _project_decl(roots, sv: dict) -> tuple[dict, str | None]: """roots の順に `.ndf/project.json` を探し、`test` を持つ最初の宣言。無ければ supervise.json の test を 1 つの suite として読む。""" import test_strategy as ts diff --git a/plugins/ndf/scripts/supervise_lib/new_args.py b/plugins/ndf/scripts/supervise_lib/new_args.py index 8f7be077c..d1dacb553 100644 --- a/plugins/ndf/scripts/supervise_lib/new_args.py +++ b/plugins/ndf/scripts/supervise_lib/new_args.py @@ -222,8 +222,8 @@ def positive_int(text: str) -> int: { "help": { "*": "配る版(例 10.17.11-dev.1)", - "sprint": "開発版の版(例 3.8.0-dev.1。release.form の雛形で配るときだけ要る)", - "close": "開発版の版(最終の検査で変更があったときに配る。release.form の雛形で配るときだけ要る)", + "sprint": "開発版の明示の版(例 3.8.0-dev.1。省けば配布のステップを流す時点に origin から次に空いている版を決める)", + "close": "開発版の明示の版(最終の検査で変更があったときに配る。省けば配布のステップを流す時点に決める)", } }, "release sprint close", @@ -302,7 +302,15 @@ def positive_int(text: str) -> int: }, "sprint", ), - ("--prod", {"help": "本番の版(例 10.18.0。release.form の雛形で配るときだけ要る)"}, "close"), + ("--prod", {"help": "本番の明示の版(例 10.18.0。省けば開発版のプランの版の記録の基底の版)"}, "close"), + ( + "--level", + { + "choices": ["patch", "minor", "major"], + "help": "版を流す時点に決めるときの上げ幅(既定 patch。新しい系列を始めるときだけ使い、明示の版には使わない)", + }, + "sprint close", + ), ( "--record-pr", {"type": int, "help": "課題を閉じるステップへ渡す本番の記録の Pull Request(省けば配布の経路から導く。#2089)"}, @@ -320,8 +328,8 @@ def positive_int(text: str) -> int: "fix": "--worktree か --branch・--tests・--title", "check": "--pr か --since-last(--id と組)", "release": "--version・--prs(か --prs-from-queue)・--channel", - "sprint": "--name・--issue(release.form の雛形で配るときは --version も)", - "close": "--name・--issue・--state(release.form の雛形で配るときは --version・--prod も)", + "sprint": "--name・--issue", + "close": "--name・--issue・--state", } @@ -412,8 +420,7 @@ def check_new(ap: argparse.ArgumentParser, a) -> None: def check_sprint(ap: argparse.ArgumentParser, a) -> None: - """sprint / close の引数の組を確かめる。版数(--version・--prod)はリリースの経路が雛形のときだけ要り、 - 宣言を読んだ後に apply_decls が確かめる(#1336)。""" + """sprint / close の引数の組を確かめる。版数(--version・--prod)は任意で、無ければ配布のステップを流す時点に決める(#2164)。""" if not (a.name and a.issue): ap.error(f"new {a.kind} には --name・--issue が要る") if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]*", a.name): diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index 464b9fd2e..04a7e5789 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -12,8 +12,10 @@ from supervise_lib.plan import QUEUE_PRS from supervise_lib.verify_steps import INFRA_WAIT_STOP, handoff_step, plan_limits, reject_step, with_margin from supervise_lib.procedures import with_record +from supervise_lib.queue_fill import RELEASE_BASE, RELEASE_VERSION # 版の差し込み(キューが流す直前に版の記録で埋める。#2164 の決定 7) +BUMP_STOP = {"2": "stop", "3": "stop"} # bump が版を読めない(2)・版がもう出ている(3)なら judge を通らずに止める(#2164 の I7) MVV_NOTE = "{state_dir}/work/mvv-note.md" # mvv-gate.py が書く判定の記録(PR のコメントか承認資料の末尾) RULE_RELEASE_DEV = ( "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)" @@ -39,6 +41,30 @@ def plan_release(a) -> dict: return maker(a) +DEV_SERIES = "release" # スプリントの本番のプランが版を読む開発版のプランの名前(#2164) + + +def release_names(version: str | None) -> tuple[str, str]: + """配布のプランの (作業場所のリポジトリからの位置, branch)。版が無ければ版の差し込みで書き、キューが流す時点に埋める。""" + v = version or RELEASE_VERSION + return f".worktrees/release/v{v}", f"release/v{v}" + + +def release_version_of(a) -> tuple[str, str]: + """(出す版, その接尾辞を外した版)。明示の版が無ければ版の差し込み(#2164)。""" + v = getattr(a, "version", None) + return (v, re.sub(r"-.*$", "", v)) if v else (RELEASE_VERSION, RELEASE_BASE) + + +def version_field(a, plugin: str, dev: bool) -> dict: + """プランの「版」の欄(#2164)。キューの版の差し込みが読む。本番で明示の版も系列も無ければ組めない。""" + explicit = getattr(a, "version", None) or None + series = None if dev or explicit else getattr(a, "series", None) or None + if not dev and not explicit and not series: + raise DeclError("本番の配布に要る版が無い(--prod か、版を読む開発版のプランの系列)") + return {"プラグイン": plugin, "チャネル": "dev" if dev else "prod", "上げ幅": getattr(a, "level", None) or "patch", "明示": explicit, "系列": series} + + def _validate_release_decl(rel: dict, dev: bool, a) -> tuple[str, list[str]]: """宣言の release の plugin・runtimes と、本番の配布の本番のブランチを確かめる。""" plugin, runtimes = rel.get("plugin"), rel.get("runtimes") @@ -79,8 +105,7 @@ def _cleanup_step(v: str, prs: str, repo: str | None) -> dict: def _dev_approval_steps(a, repo: str | None, approval: str, prs: str, rts: str) -> list[dict]: """開発版の approval-facts → 提示物の欄 → 他のプラグインの上げ幅(→ 助言の MVV 判定)のステップ。""" - v = a.version - base = re.sub(r"-.*$", "", v) + v, base = release_version_of(a) advise = getattr(a, "advise", None) # 助言の MVV 判定を置くスプリントの状態(pace: normal。#1400) prev = f" --prev-tag {a.prev_tag}" if a.prev_tag else "" m = re.fullmatch(r"\{queue_pr:([A-Za-z0-9._-]+)\}", prs.strip()) @@ -156,6 +181,7 @@ def _prepare_steps(a, plugin: str, v: str, prs: str, dev: bool, after_notes: str "type": "run", "stage": "配布", "cmd": f"{STEPS_PY} bump --plugin {plugin} --to {v} --base {a.base}", + "on_exit": dict(BUMP_STOP), "on_fail": "judge", "next": "changelog" if dev else "bump-others", }, @@ -305,10 +331,10 @@ def plan_release_package_plugin(a) -> dict: (本番のブランチ)→ 後片付け。本番は release の後に record(本番のリリースの PR へリリース記録)を挟み、落ちたら judge-record(record か stop)へ回す。sync-check は同期とチェックの宣言があるときだけ置く。 説明文と提示物の欄は release-steps.py notes が PR 本文の「利用者向けの変化」から組む(LLM を使わない)。""" - v, dev = a.version, a.channel == "dev" + dev = a.channel == "dev" plugin, runtimes = _validate_release_decl(a.release, dev, a) rts = ",".join(runtimes) - base = re.sub(r"-.*$", "", v) # 開発版の本番承認の提示物は正式版の番号で作る + v, base = release_version_of(a) # 開発版の本番承認の提示物は正式版の番号で作る # --prs-from-queue なら、queue が先行の計画の Pull Request の番号で QUEUE_PRS を置き換える prs = " ".join([*map(str, a.prs), *([QUEUE_PRS] if getattr(a, "prs_from_queue", False) else [])]) repo = a.repo or (a.worktree.split("/.worktrees/")[0] if "/.worktrees/" in a.worktree else None) @@ -350,6 +376,7 @@ def plan_release_package_plugin(a) -> dict: "作業場所": a.worktree, "branch": a.branch or f"release/v{v}", "起点": f"origin/{a.base}", + "版": version_field(a, plugin, dev), "規則": rule, "steps": steps, } diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 6fa66f4de..d355b9844 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -51,8 +51,9 @@ from supervise_lib.sprint_close_steps import _close_step, _refine_step, _retro_step, _spec_pr_steps -def prod_version(version: str) -> str: - return re.sub(r"-.*$", "", version) +def prod_version(version: str | None) -> str | None: + """明示の開発版から本番の明示の版を作る。開発版が無ければ None(本番は開発版のプランの版の記録から読む。#2164)。""" + return re.sub(r"-.*$", "", version) if version else None DESIGN_RESULTS_STAGE = "設計の結果" @@ -185,7 +186,7 @@ def close_waves(a) -> list[dict]: return [ check, {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev", changed)}, "then_of": "最終の検査"}, - {"name": "本番", "plans": {"release-prod": plan_mvv_release(a, repo, a.prod, "prod", changed)}, "then_of": "最終の検査"}, + {"name": "本番", "plans": {"release-prod": plan_mvv_release(a, repo, getattr(a, "prod", None), "prod", changed)}, "then_of": "最終の検査"}, close, ] diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index 5e237a2f9..6e482ff21 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -8,7 +8,7 @@ from gate_answer import GATE_1, GATE_2 from pace import PaceError, read_pace from sprint_mvv import MVV_PACES -from supervise_lib.decl import DeclError, decl_roots, delivery_decl, require_versions +from supervise_lib.decl import DeclError, decl_roots, delivery_decl from supervise_lib.paths import HERE, SELF from supervise_lib.plan import QUEUE_PRS, sprint_state_path from supervise_lib.delivery_templates import GATE_2_MATERIAL, PromoteParams, plan_gate_2, plan_promote, plan_verify @@ -32,10 +32,9 @@ def dev_channel_of(a) -> delivery.DevChannel: def apply_routes(a) -> None: - """a.routes(リリースの経路)を宣言から組み、雛形で組む経路なら版数を確かめる(足りなければ DeclError)。""" + """a.routes(リリースの経路)を宣言から組む。雛形で組む経路でも版数は要らない(無ければ配布のステップを流す時点に決める。#2164)。""" a.delivery = delivery_decl(a) a.routes = delivery.routes(a.delivery, tuple(RELEASE_FORMS)) - require_versions(a) def routes_of(a) -> list: @@ -212,18 +211,21 @@ def normal_gate_2(a) -> str: ) -def normal_gate_2_stage(a, repo: str | None, prod: str, advise: bool) -> str: +def normal_gate_2_stage(a, repo: str | None, prod: str | None, advise: bool) -> str: """pace: normal の承認ゲート 2 のステージの文。承認の手順はここの 1 か所に置き、配布の note に重ねない(#1914 の決定 1)。 - 承認資料は本番のプランの release と同じく元のリポジトリの下の絶対パスで示す(作業場所の git の根に依らない)。""" - approval = material_path(repo, f"issues/approval-{(a.release or {}).get('plugin')}-v{prod}.md") + 承認資料は本番のプランの release と同じく元のリポジトリの下の絶対パスで示す(作業場所の git の根に依らない)。 + 本番の版を明示していなければ、承認資料の版は開発版のプランの版の記録(状態ディレクトリの version.json)の base である(#2164)。""" + plugin = (a.release or {}).get("plugin") + approval = material_path(repo, f"issues/approval-{plugin}-v{prod or ''}.md") + where = "" if prod else "。 は開発版のプラン release の状態ディレクトリの version.json の base" if getattr(a, "state", None): # 本番のプランの先頭の approve が状態の記録を証跡に承認の記録を書く(#1426 の決定 1) return ( - f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、利用者が答えたら " + f"開発版の配布が書いた承認資料({approval}{where})を示して本番への配布の承認を取り、利用者が答えたら " f"`{user_gate_cmd(a, GATE_2, False)}` を打ち、承認されたら「本番」のステージの command を打つ(差し戻されたら打たない)。" "本番のプランの先頭の approve が、開発版の facts の承認したコミットとこの記録を照らして承認資料へ承認の記録を書く" + ("。承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)" if advise else "") ) return ( - f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {shlex.quote(approval)} " + f"開発版の配布が書いた承認資料({approval}{where})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {shlex.quote(approval)} " "--approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" ) diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index a539ed8ac..b69d52c42 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -15,7 +15,7 @@ from supervise_lib.decl import decl_fields from supervise_lib.paths import DESIGN_READY, GLOSSARY_PY, MVV_PY, PUSH_DESIGN, SELF, SPEC_COPY_PY, SYNC_DESIGN_BODY, WORKTREE_SETUP from supervise_lib.procedures import DESIGN_RESULTS, pr_step, requirements_path, sprint_materials, sprint_out, with_record -from supervise_lib.release_templates import MVV_NOTE, advise_steps, plan_release +from supervise_lib.release_templates import DEV_SERIES, MVV_NOTE, advise_steps, plan_release, release_names from supervise_lib.plan import sprint_state_path from supervise_lib.templates import plan_check, plan_check_since, plan_impl from supervise_lib.verify_steps import handoff_step, merge_steps, reject_step @@ -337,18 +337,23 @@ def plan_sprint_check(a, repo: str) -> dict: def plan_sprint_release(a, repo: str, advise: bool = False, channel: str = "dev", version: str | None = None) -> dict: """pace: normal の配布。出す PR は検査のプランのスプリントの PR(`{queue_pr:check}`)で、検査の queue の --then でも 単独で流し直しても埋まる(#1915)。本番(channel が prod)も同じ組み立てで、承認ゲート 2 の後に単独で流す(#1914)。 - `advise` なら開発版の承認資料の後に助言の MVV 判定を置く(pace: normal と --state。#1400)。本番には置かない。""" - version = version or a.version + `advise` なら開発版の承認資料の後に助言の MVV 判定を置く(pace: normal と --state。#1400)。本番には置かない。 + `version` は明示の版(開発版は省けば --version)で、無ければ版を流す時点に決める(開発版は origin から、本番は開発版の + プランの版の記録から。#2164)。""" + version = version or (a.version if channel == "dev" else None) + wt, branch = release_names(version) ns = argparse.Namespace( **decl_fields(a), version=version, + level=getattr(a, "level", None), + series=DEV_SERIES if channel == "prod" else None, prs=["{queue_pr:check}"], prs_from_queue=False, channel=channel, repo=repo, prev_tag=None, - worktree=f"{repo}/.worktrees/release/v{version}", - branch=f"release/v{version}", + worktree=f"{repo}/{wt}", + branch=branch, issue=a.issue, mode=a.mode, advise=a.state if advise and channel == "dev" else None, @@ -465,19 +470,25 @@ def plan_fast_check(a, repo: str, name: str, final: bool = False, review_only: b return plan_check_since(ns) -def plan_mvv_release(a, repo: str, version: str, channel: str, condition: dict | None = None, prs: list[str] | None = None) -> dict: +def plan_mvv_release( + a, repo: str, version: str | None, channel: str, condition: dict | None = None, prs: list[str] | None = None +) -> dict: """pace: fast と auto のリリース。開発版は承認資料を gate_as_ok で写し、本番は先頭の mvv のステップが関門 2 を判定する。 - prs を渡せばその Pull Request(`{queue_pr:<名>}` でもよい)を出す版の PR にし、省けば前のステージの PR のすべて。""" + prs を渡せばその Pull Request(`{queue_pr:<名>}` でもよい)を出す版の PR にし、省けば前のステージの PR のすべて。 + `version` は明示の版で、無ければ版を流す時点に決める(#2164)。""" + wt, branch = release_names(version) ns = argparse.Namespace( **decl_fields(a), version=version, + level=getattr(a, "level", None), + series=DEV_SERIES if channel == "prod" else None, prs=prs or [], prs_from_queue=prs is None, channel=channel, repo=repo, prev_tag=None, - worktree=f"{repo}/.worktrees/release/v{version}", - branch=f"release/v{version}", + worktree=f"{repo}/{wt}", + branch=branch, issue=a.issue, mode=a.mode, mvv=a.state, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json index 0484b648c..653f52dc8 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json @@ -10,12 +10,12 @@ "design-glossary": "usage: supervise.py design-glossary [-h] [--mode MODE] [--root ROOT] --out OUT", "note": "usage: supervise.py note [-h] --report REPORT [--next NEXT] [--section SECTION] doc", "sync-check": "usage: supervise.py sync-check [-h] [--root ROOT] [--commit]", - "new sprint": "usage: supervise.py new sprint [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--scope PATH [PATH ...]] [--impl-timeout SECONDS] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--design DESIGN [DESIGN ...]] [--designed DESIGNED [DESIGNED ...]] [--pace {normal,fast,auto}] [--state STATE] [--absorb ISSUE:HOST [ISSUE:HOST ...]]", + "new sprint": "usage: supervise.py new sprint [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--scope PATH [PATH ...]] [--impl-timeout SECONDS] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--design DESIGN [DESIGN ...]] [--designed DESIGNED [DESIGNED ...]] [--pace {normal,fast,auto}] [--state STATE] [--absorb ISSUE:HOST [ISSUE:HOST ...]] [--level {patch,minor,major}]", "new impl": "usage: supervise.py new impl [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--impl-timeout SECONDS] [--title TITLE] [--summary SUMMARY] [--prompt PROMPT] [--prompt-file PROMPT_FILE] [--files PATH [PATH ...]] [--after NAME [NAME ...]] [--changes CHANGES] [--branch BRANCH] [--escape-of ESCAPE_OF]", "new fix": "usage: supervise.py new fix [-h] [--worktree WORKTREE] [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--title TITLE] [--summary SUMMARY] [--branch BRANCH] [--escape-of ESCAPE_OF]", "new check": "usage: supervise.py new check [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--scope PATH [PATH ...]] [--refactor-budget-minutes MINUTES] [--pr PR] [--since-last] [--id ID] [--final] [--since-ref SINCE_REF] [--review-only] [--sprint SPRINT]", "new release": "usage: supervise.py new release [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--mode MODE] [--out OUT] [--branch BRANCH] [--version VERSION] [--prs PRS [PRS ...]] [--prs-from-queue] [--channel {dev,prod}] [--prev-tag PREV_TAG] [--repo REPO] [--mvv MVV] [--dev-state DEV_STATE] [--state STATE]", - "new close": "usage: supervise.py new close [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--pace {normal,fast,auto}] [--state STATE] [--prod PROD] [--record-pr RECORD_PR] [--carry CARRY [CARRY ...]] [--milestone MILESTONE]", + "new close": "usage: supervise.py new close [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--pace {normal,fast,auto}] [--state STATE] [--prod PROD] [--level {patch,minor,major}] [--record-pr RECORD_PR] [--carry CARRY [CARRY ...]] [--milestone MILESTONE]", "history import": "usage: supervise.py history import [-h] [--history HISTORY] progress [progress ...]" }, "results": { diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json index 1064ab17d..fb6fab526 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/2-release.json @@ -7,6 +7,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0-dev.2", "branch": "release/v1.2.0-dev.2", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "dev", + "上げ幅": "patch", + "明示": "1.2.0-dev.2", + "系列": null + }, "規則": "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度(retry)。認証や権限の不足・タグの重複は stop。", "上限": 112, "steps": [ @@ -15,6 +22,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0-dev.2 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "changelog" }, 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 b817949ba..7d100f1b7 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 @@ -7,6 +7,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0", "branch": "release/v1.2.0", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "prod", + "上げ幅": "patch", + "明示": "1.2.0", + "系列": null + }, "規則": "関門 2 は利用者か MVV 判定が承認した(先頭の mvv のステップが 0 を返したときだけ先へ進む)。run のステップが落ちたら、直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度。タグの重複・権限の不足は stop。", "上限": 100, "steps": [ @@ -31,6 +38,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "bump-others" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json index 4da837f05..2d54ae4f9 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/release.json @@ -7,6 +7,13 @@ "作業場所": "/repo", "branch": "release/v1.2.0-dev.1", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "dev", + "上げ幅": "patch", + "明示": "1.2.0-dev.1", + "系列": null + }, "規則": "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度(retry)。認証や権限の不足・タグの重複は stop。", "上限": 112, "steps": [ @@ -15,6 +22,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0-dev.1 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "changelog" }, 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 801c31119..753a0dedc 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 @@ -8,6 +8,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0", "branch": "release/v1.2.0", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "prod", + "上げ幅": "patch", + "明示": "1.2.0", + "系列": null + }, "規則": "利用者は関門 2 を承認した。run のステップが落ちたら、直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度。タグの重複・権限の不足は stop。", "上限": 99, "steps": [ @@ -26,6 +33,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "bump-others" }, diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json index c8e4e4d19..38bf4de87 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/8-release.json @@ -8,6 +8,13 @@ "作業場所": "/repo/.worktrees/release/v1.2.0-dev.1", "branch": "release/v1.2.0-dev.1", "起点": "origin/main", + "版": { + "プラグイン": "foo", + "チャネル": "dev", + "上げ幅": "patch", + "明示": "1.2.0-dev.1", + "系列": null + }, "規則": "run のステップが落ちたら、出力を読んで直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度(retry)。認証や権限の不足・タグの重複は stop。", "上限": 112, "steps": [ @@ -16,6 +23,10 @@ "type": "run", "stage": "配布", "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0-dev.1 --base main", + "on_exit": { + "2": "stop", + "3": "stop" + }, "on_fail": "judge", "next": "changelog" }, diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index a622aa38f..805b8153d 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -574,7 +574,7 @@ def test_bump_rerun_after_the_version_was_raised_is_ok(repo): assert res["status"] == "ok" and res["metrics"]["from"] == "1.2.3" and res["items"] == [] -def fake_gh(tmp: Path, out: str, code: int = 0) -> dict: +def fake_gh_list(tmp: Path, out: str, code: int = 0) -> dict: """`gh pr list` の答えを固定する偽の gh を PATH の先頭に置いた環境(自分の配布の照会。#2164)。""" d = tmp / "fakebin" d.mkdir(exist_ok=True) @@ -618,11 +618,11 @@ def test_bump_to_a_release_with_a_tag_stops(repo): def test_bump_at_the_base_is_a_rerun_only_after_its_own_release(repo, tmp_path): """作業場所とベースブランチがともに新しい版なら、release/v<版> のマージ済みの PR があるときだけ打ち直し(決定 2)。""" bump_repo(repo, "1.2.4", "1.2.4") - own = run_env(repo, fake_gh(tmp_path, '[{"number": 7}]'), "bump", "--plugin", "ndf", "--to", "1.2.4") + own = run_env(repo, fake_gh_list(tmp_path, '[{"number": 7}]'), "bump", "--plugin", "ndf", "--to", "1.2.4") assert own.returncode == 0 and last_json(own)["metrics"]["already"] is True, own.stdout - other = run_env(repo, fake_gh(tmp_path, "[]"), "bump", "--plugin", "ndf", "--to", "1.2.4") + other = run_env(repo, fake_gh_list(tmp_path, "[]"), "bump", "--plugin", "ndf", "--to", "1.2.4") assert other.returncode == 3 and last_json(other)["metrics"]["existing"] == "1.2.4" - unknown = run_env(repo, fake_gh(tmp_path, "", 1), "bump", "--plugin", "ndf", "--to", "1.2.4") + unknown = run_env(repo, fake_gh_list(tmp_path, "", 1), "bump", "--plugin", "ndf", "--to", "1.2.4") assert unknown.returncode == 2 and last_json(unknown)["metrics"]["unreadable"] == ["pr"] diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index 2b27cdc23..3e211592a 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -1674,7 +1674,7 @@ def test_new_sprint_without_design_skips_gate(tmp_path): assert names == ["スプリントブランチ", "実装", "検査", "配布", "関門 2", "本番"] -@pytest.mark.parametrize("args", [["--issue", "1"], ["--name", "a b", "--issue", "1"], ["--name", "m"], ["--name", "m", "--issue", "1"]]) +@pytest.mark.parametrize("args", [["--issue", "1"], ["--name", "a b", "--issue", "1"], ["--name", "m"]]) def test_new_sprint_rejects_bad_args(tmp_path, args): assert cli("new", "sprint", "--worktree", str(tmp_path), *args).returncode == 2 diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 4c6c113ae..f7e2e5cce 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -4,6 +4,7 @@ import json import os +import re import subprocess import sys from pathlib import Path @@ -13,6 +14,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] sys.path.insert(0, str(Path(__file__).resolve().parent)) import inproc # noqa: E402 +from release_repo import push_origin # noqa: E402 SUPERVISE = SCRIPTS / "supervise.py" PY = sys.executable @@ -131,13 +133,35 @@ def test_new_close_without_release_form_puts_manual_stage(tmp_path, delivery): assert "unknown" not in json.dumps(close["steps"], ensure_ascii=False) -def test_new_sprint_and_close_with_package_plugin_still_need_versions(tmp_path): - """#1336 の I6: 雛形で組む経路だけが版数を要する。""" +VERSION_LIKE = re.compile(r"\b\d+\.\d+\.\d+(?:-(?:dev|rc)\.\d+)?\b") + + +def release_plans(out: Path) -> dict[str, dict]: + return {f.stem: json.loads(f.read_text()) for f in sorted(out.glob("*release*.json"))} + + +def test_new_sprint_and_close_without_versions_write_no_version(tmp_path): + """#2164 の受け入れ条件 1・I1: 版を渡さずに作ったプランと sprint.json は具体的な版を持たず、作業場所・branch・承認資料は + 版の差し込みで書く。本番は開発版のプラン release の版の記録を読む(系列)。""" root = plain_repo(tmp_path, {"form": "package-plugin", "plugin": "foo", "runtimes": ["claude"]}) - 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] + (root / ".ndf" / "worktree.json").write_text('{"version": 1, "base_branch": "main", "production_branch": "live"}\n') + out = tmp_path / "m" + p = cli(*new_sprint_args(root, out, "--level", "minor", name="m6", issue=("216",), version=None), cwd=root) + assert p.returncode == 0, p.stderr + c = close_cli(root, tmp_path / "c") + assert c.returncode == 0, c.stderr + for d in (out, tmp_path / "c"): + assert not VERSION_LIKE.search((d / "sprint.json").read_text()) + plans = release_plans(d) + assert plans, d + for name, plan in plans.items(): + assert not VERSION_LIKE.search(json.dumps(plan, ensure_ascii=False)), name + assert plan["branch"] == "release/v{release_version}" and plan["作業場所"].endswith("/release/v{release_version}") + prod = plan["版"]["チャネル"] == "prod" + assert plan["版"]["明示"] is None and plan["版"]["系列"] == ("release" if prod else None) + approval = "approval-foo-v{release_base}.md" + assert approval in json.dumps(plan["steps"], ensure_ascii=False), name + assert {p["版"]["上げ幅"] for p in release_plans(out).values()} == {"minor"} SAMPLES = { @@ -520,7 +544,7 @@ def plugin_repo(tmp_path): (root / "plugins" / "mcp" / "mcp-serena" / "README.md").write_text("changed\n") git(root, "add", "-A") git(root, "commit", "-q", "-m", "serena") - git(root, "update-ref", "refs/remotes/origin/develop", "HEAD") + push_origin(root) return root From 48485f785b2e4f3774a1048fae7a84a0eac2b9f7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:58:02 +0000 Subject: [PATCH 18/46] =?UTF-8?q?Feat:=20=E3=82=AD=E3=83=A5=E3=83=BC?= =?UTF-8?q?=E3=81=8C=E9=85=8D=E5=B8=83=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E7=89=88=E3=82=92=E6=B5=81=E3=81=99=E6=99=82=E7=82=B9?= =?UTF-8?q?=E3=81=AB=E6=B1=BA=E3=82=81=E3=80=81=E7=89=88=E3=81=AE=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=AB=E6=AE=8B=E3=81=99=EF=BC=88#2164=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 開発版は origin から次に空いている版を決め、記録の版が自分の配布の前に別のスプリントに出ていれば 決め直す。本番は開発版の記録の base で出し、タグがあれば止める。埋められなければ項目の version に理由を載せる。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/queue.py | 9 +- .../ndf/scripts/supervise_lib/queue_fill.py | 231 ++++++++++++ .../ndf/scripts/tests/test_release_version.py | 333 ++++++++++++++++++ 3 files changed, 570 insertions(+), 3 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_release_version.py diff --git a/plugins/ndf/scripts/supervise_lib/queue.py b/plugins/ndf/scripts/supervise_lib/queue.py index 6d0de9c6d..43c42b3cf 100644 --- a/plugins/ndf/scripts/supervise_lib/queue.py +++ b/plugins/ndf/scripts/supervise_lib/queue.py @@ -19,7 +19,7 @@ from supervise_lib.decl import DeclError, queue_decl_of from supervise_lib.paths import queue_done_path, queue_plans_path, state_dir_of, wait_cursor_path from supervise_lib.plan import QUEUE_PRS, pr_number -from supervise_lib.queue_fill import FINISHED, fill_queue_pr, named, results_named, write_text_atomic # noqa: F401 -- 公開を保つ +from supervise_lib.queue_fill import FINISHED, fill_queue_pr, fill_release_version, named, results_named, write_text_atomic # noqa: F401 -- 公開を保つ def attention_lines(prog: Path, offset: int) -> tuple[list[dict], int]: @@ -195,7 +195,7 @@ def start_listing(done_path: Path, all_plans: list[str], **extra) -> None: def fill_stage(first: bool, stage: list[str], items: list[dict]) -> tuple[list[dict], list[str]]: - """ステージを流す前に、プランの {queue_pr:<名>} と QUEUE_PRS を埋める。(流さないプランの items, 流すプラン) を返す。 + """ステージを流す前に、プランの {queue_pr:<名>} と QUEUE_PRS と版の差し込みを埋める。(流さないプランの items, 流すプラン) を返す。 後続のステージ(first でない)は、前のすべてのプランが 完了 のときだけ流す。埋められないプランは流さずに理由と `unfilled` を残す(#2079)。""" not_done = [i for i in items if i["result"] != "完了"] @@ -211,8 +211,11 @@ def fill_stage(first: bool, stage: list[str], items: list[dict]) -> tuple[list[d skipped, runnable = [], [] for p in stage: err = (None if prs is None else fill_queue_prs(p, prs)) or fill_queue_pr(p, items) + version = None + if not err: # 版の差し込みは、ほかの差し込みを埋めた後に(差し込み前の写しに版だけを残す。#2164) + err, version = fill_release_version(p, items) if err: - skipped.append({"plan": p, "result": NOT_RUN, "reason": err, "unfilled": True}) + skipped.append({"plan": p, "result": NOT_RUN, "reason": err, "unfilled": True, **({"version": version} if version else {})}) else: runnable.append(p) return skipped, runnable diff --git a/plugins/ndf/scripts/supervise_lib/queue_fill.py b/plugins/ndf/scripts/supervise_lib/queue_fill.py index 4f280efa1..53a1fbb00 100644 --- a/plugins/ndf/scripts/supervise_lib/queue_fill.py +++ b/plugins/ndf/scripts/supervise_lib/queue_fill.py @@ -126,3 +126,234 @@ def write_text_atomic(path: Path, text: str) -> None: tmp = path.with_name(f".{path.name}.{os.getpid()}.tmp") tmp.write_text(text) os.replace(tmp, path) + + +# ---- 版の差し込み(#2164) ---- + +RELEASE_VERSION, RELEASE_BASE = "{release_version}", "{release_base}" # 版の差し込みの語(雛形の release_templates も読む) +VERSION_RECORD = "version.json" # 版の記録 +VERSION_PLAN = "version-plan.json" # 差し込み前のプランの写し +SUPERSEDED = "superseded" # 決め直した前の版のステップの記録の退避先 +KEEP_ON_REDECIDE = {VERSION_RECORD, VERSION_PLAN, SUPERSEDED, "plan.json"} # 決め直しで退避しないもの(plan.json は状態の実体の写し) +GATE_2_STEPS = ("approve", "mvv") # 本番のプランの先頭で承認ゲート 2 を通すステップ + + +def _now() -> str: + import datetime + + return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") + + +def _read_json(path: Path) -> dict | None: + try: + d = json.loads(path.read_text()) + except (OSError, ValueError): + return None + return d if isinstance(d, dict) else None + + +def _write_json(path: Path, obj: dict) -> None: + write_text_atomic(path, json.dumps(obj, ensure_ascii=False, indent=2) + "\n") + + +def _filled(text: str, version: str) -> str: + import versions + + return text.replace(RELEASE_VERSION, version).replace(RELEASE_BASE, versions.release_base(version)) + + +def _stopped(cause: str, version: str | None = None, existing: str | None = None, next_free: str | None = None, unreadable=None): + """埋められないときの (理由, キューの項目の version)。""" + info = {"cause": cause, "version": version, "existing": existing, "next_free": next_free, "unreadable": list(unreadable or [])} + why = { + "taken": f"版 {version} はすでに出ている({existing}。次に空いている版は {next_free})。別の版で黙って進めない", + "unreadable": f"版を決める値を読めない: {'・'.join(info['unreadable'])}。推定で決めない", + "no_series": "本番が版を読む開発版のプランの版の記録が無い", + "series_changed": f"承認ゲート 2 の後に、本番が読む開発版の記録の版が変わった({existing} → {version})", + }[cause] + return f"版の差し込みを埋められない: {why}", info + + +class _Fill: + """1 本のプランの版の差し込み。状態ディレクトリの版の記録と差し込み前の写しを読み書きする。""" + + def __init__(self, plan: str, text: str, info: dict, items: list[dict]): + import delivery + + self.path, self.text, self.info, self.items = Path(plan), text, info, items + self.field = info["版"] + self.sd = state_dir_of(str(self.path.absolute())) + self.sd.mkdir(parents=True, exist_ok=True) + self.repo = info.get("リポジトリ") or "." + self.base = str(info.get("起点") or "").removeprefix("origin/") + self.production = delivery.load_delivery(self.repo).production + self.plugin = self.field.get("プラグイン") + self.level = self.field.get("上げ幅") or "patch" + self.explicit = self.field.get("明示") or None + + @property + def record_path(self) -> Path: + return self.sd / VERSION_RECORD + + def observe(self): + import release_version + + return release_version.observe(self.repo, self.plugin, self.base, self.production) + + def branch_of(self, version: str) -> str: + b = self.info.get("branch") + return b if b and RELEASE_VERSION not in b else f"release/v{version}" + + def log(self, text: str, **extra) -> None: + line = json.dumps({"kind": "version", "at": _now(), "text": text, **extra}, ensure_ascii=False) + with (self.sd / "progress.jsonl").open("a") as f: + f.write(line + "\n") + + def write_record(self, version: str, how: str, observed: dict, series_from: str | None = None) -> dict: + import versions + + if not (self.sd / VERSION_PLAN).exists(): + write_text_atomic(self.sd / VERSION_PLAN, self.text) + rec = { + "version": version, + "base": versions.release_base(version), + "how": how, + "level": self.level, + "observed": observed, + "series_from": series_from, + "at": _now(), + "history": [], + } + _write_json(self.record_path, rec) + return rec + + def ensure_filled(self, rec: dict) -> None: + """プランが記録の版で埋まっていなければ、差し込み前の写しを記録の版で埋めて書き直す(決め直しの途中で落ちた後も同じ)。""" + if RELEASE_VERSION not in self.text and RELEASE_BASE not in self.text and rec["version"] in self.text: + return + src = self.sd / VERSION_PLAN + text = src.read_text() if src.is_file() else self.text + write_text_atomic(self.path, _filled(text, rec["version"])) + self.log(f"版の差し込みを {rec['version']} で埋めた({self.record_path})", version=rec["version"], record=str(self.record_path)) + + def redecide(self, rec: dict, new: str, reason: str, existing: str | None, observed: dict) -> dict: + """記録の版を決め直す。ステップの記録を退避 → 記録を書き換え → 写しから埋め直す の順(落ちても流し直しで同じ所へ戻る)。""" + import versions + + old = rec["version"] + dst = self.sd / SUPERSEDED / f"v{old}" + dst.mkdir(parents=True, exist_ok=True) + for f in sorted(self.sd.iterdir()): + if f.name in KEEP_ON_REDECIDE: + continue + target = dst / f.name + n = 1 + while target.exists(): + target, n = dst / f"{f.name}.{n}", n + 1 + os.replace(f, target) + left = {"作業場所": self.info.get("作業場所"), "branch": self.info.get("branch"), "superseded": str(dst)} + hist = {"from": old, "to": new, "reason": reason, "existing": existing, "observed": observed, "left": left, "at": _now()} + rec = {**rec, "version": new, "base": versions.release_base(new), "observed": observed, "at": _now()} + rec["history"] = [*(rec.get("history") or []), hist] + _write_json(self.record_path, rec) + self.log( + f"版を {old} から {new} へ決め直した({reason})。前の作業場所 {left['作業場所']} と branch {left['branch']} は残した", + version=new, + record=str(self.record_path), + ) + return rec + + def series_record(self) -> tuple[dict | None, str | None]: + """本番が版を読む開発版のプラン(`系列` の名前)の版の記録と、そのパス。最も新しく書かれたもの。""" + found = [] + for i in results_named(self.field.get("系列") or "", self.items, str(self.path)): + if not i.get("plan"): + continue + f = state_dir_of(str(Path(i["plan"]).absolute())) / VERSION_RECORD + rec = _read_json(f) + if rec and rec.get("base"): + found.append((f.stat().st_mtime, rec, str(f))) + if not found: + return None, None + _, rec, f = max(found, key=lambda t: t[0]) + return rec, f + + def approved(self) -> bool: + """本番のプランの状態に承認ゲート 2 の通過(approve か mvv のステップが 0 で終えた記録)があるか。""" + state = _read_json(self.sd / "state.json") or {} + return any(e.get("id") in GATE_2_STEPS and e.get("exit") == 0 for e in state.get("log") or [] if isinstance(e, dict)) + + +def _fill_dev(f: _Fill, rec: dict | None): + import release_version + + obs = f.observe() + if obs.unreadable: + return _stopped("unreadable", (rec or {}).get("version") or f.explicit, unreadable=obs.unreadable) + if rec is None: + d = release_version.taken(obs, f.explicit, "explicit", f.level) if f.explicit else release_version.decide_dev(obs, f.level) + if d.is_taken: + return _stopped("taken", d.version, d.existing, d.next_free) + rec = f.write_record(d.version, d.how, obs.as_dict()) + else: + d = release_version.taken(obs, rec["version"], rec.get("how") or "decided", f.level) + if d.is_taken: + own = release_version.own_released(f.repo, f.branch_of(rec["version"]), f.base) + if own is None: + return _stopped("unreadable", rec["version"], unreadable=["pr"]) + if not own: + if rec.get("how") == "explicit": + return _stopped("taken", d.version, d.existing, d.next_free) + rec = f.redecide(rec, release_version.decide_dev(obs, f.level).version, "taken", d.existing, obs.as_dict()) + f.ensure_filled(rec) + return None, None + + +def _fill_prod(f: _Fill, rec: dict | None): + import release_version + + if rec is not None and rec.get("how") != "series": + f.ensure_filled(rec) + return None, None + series, series_from = (None, None) if f.explicit else f.series_record() + if not f.explicit and series is None: + return _stopped("no_series") + want = f.explicit or series["base"] + if rec is not None and rec["version"] == want: + f.ensure_filled(rec) + return None, None + if rec is not None and f.approved(): + return _stopped("series_changed", want, rec["version"]) + obs = f.observe() + if obs.unreadable: + return _stopped("unreadable", want, unreadable=obs.unreadable) + d = release_version.taken(obs, want, "explicit" if f.explicit else "series") + if d.is_taken: + return _stopped("taken", d.version, d.existing, d.next_free) + if rec is None: + rec = f.write_record(want, d.how, obs.as_dict(), series_from) + else: + rec = f.redecide(rec, want, "series_changed", rec["version"], obs.as_dict()) + f.ensure_filled(rec) + return None, None + + +def fill_release_version(plan: str, items: list[dict]) -> tuple[str | None, dict | None]: + """配布のプランの版の差し込み(`{release_version}`・`{release_base}`)を、版の記録の値で埋める(#2164)。 + (埋められない理由, キューの項目に載せる `version`) を返す。どちらも None なら流してよい。「版」の欄を持たないプランは触らない。 + + 開発版は、記録が無ければ origin から次に空いている版を決めて(明示の版はまだ出ていないかを確かめて)記録を書く。記録が + あれば記録の版を使い、それがもう出ていて自分の配布のマージ済みの PR が無ければ決め直す(明示の版は止める)。本番は + 明示の版か `系列` の開発版の記録の base で出し、タグがあれば止める。承認ゲート 2 の後に開発版の記録の版が変わったら止める。""" + try: + text = Path(plan).read_text() + info = json.loads(text) + except (OSError, ValueError) as e: + return f"計画を読めない: {e}", None + if not isinstance(info, dict) or not isinstance(info.get("版"), dict): + return None, None + f = _Fill(plan, text, info, items) + if not f.plugin or not f.base: + return "版の差し込みを埋められない: プランの「版」の欄にプラグインが無いか、起点が無い", None + rec = _read_json(f.record_path) + return (_fill_prod if f.field.get("チャネル") == "prod" else _fill_dev)(f, rec) diff --git a/plugins/ndf/scripts/tests/test_release_version.py b/plugins/ndf/scripts/tests/test_release_version.py new file mode 100644 index 000000000..5539fa577 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_release_version.py @@ -0,0 +1,333 @@ +"""配布の版を流す時点に決める(#2164): 版の判定(lib/release_version.py)とキューの版の差し込み(queue_fill.fill_release_version)。 + +一時の bare の origin にベースブランチ develop・本番のブランチ main・タグ `ndf--v<版>` を作り、プランを作った後に +別のスプリントが版を出した状況を、origin へ版を送って再現する。自分の配布のマージ済みの PR の照会は差し替える。 +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS)) +sys.path.insert(0, str(SCRIPTS / "lib")) + +import release_version as rv # noqa: E402 +from supervise_lib import queue, queue_fill # noqa: E402 +from supervise_lib.paths import state_dir_of # noqa: E402 + + +def git(root: Path, *args: str) -> str: + return subprocess.run(["git", "-C", str(root), *args], capture_output=True, text=True, check=True).stdout + + +def obs(base, latest=None, prod=None, tags=None) -> rv.Observed: + tags = tags if tags is not None else ([latest] if latest else []) + return rv.Observed("ndf", "origin/develop", base, "origin/main", prod, latest, tags) + + +# ---- 版の判定 ---- + + +@pytest.mark.parametrize( + ("base", "latest", "prod", "level", "want"), + [ + ("10.27.5", "10.27.5", "10.27.5", "patch", "10.27.6-dev.1"), # 受け入れ条件 2 の例 1 + ("10.27.6-dev.1", "10.27.5", "10.27.5", "patch", "10.27.6-dev.2"), # 例 2(10.27.6 のタグが無い) + ("10.27.6", "10.27.6", "10.27.6", "patch", "10.27.7-dev.1"), # 受け入れ条件 3(別のスプリントが 10.27.6 を出した) + ("10.27.6-dev.1", "10.27.6", "10.27.6", "patch", "10.27.7-dev.1"), # ベースブランチの系列のタグがもうある + ("10.27.5", "10.27.5", "10.28.0", "patch", "10.28.1-dev.1"), # 本番のブランチが先にある + ("10.27.5", "10.27.5", "10.27.5", "minor", "10.28.0-dev.1"), + ], +) +def test_decide_dev_takes_the_next_free_version_above_the_base(base, latest, prod, level, want): + d = rv.decide_dev(obs(base, latest, prod), level) + assert d.version == want and d.base == want.split("-")[0] + assert rv.versions.compare_versions(d.version, base) > 0 # I2 + + +def test_decide_dev_does_not_decide_when_something_is_unreadable(): + o = obs("10.27.5", "10.27.5") + o.unreadable = ["origin/main"] + assert rv.decide_dev(o).version is None and rv.decide_dev(o).unreadable == ["origin/main"] + + +def test_taken_compares_dev_versions_with_the_base_and_releases_with_tags(): + o = obs("10.27.5", "10.27.5", "10.27.5") + assert rv.taken(o, "10.27.5-dev.1").existing == "10.27.5" + assert rv.taken(o, "10.27.5-dev.1").next_free == "10.27.6-dev.1" + assert not rv.taken(o, "10.27.6-dev.1").is_taken + assert rv.taken(o, "10.27.5").existing == "ndf--v10.27.5" and rv.taken(o, "10.27.5").next_free == "10.27.6" + assert not rv.taken(o, "10.27.6").is_taken + + +# ---- 一時の origin ---- + + +class World: + """bare の origin と、版を送る作業用の clone。`repo` はプランのリポジトリ(版を読む側)。""" + + def __init__(self, tmp: Path, version: str = "10.27.5"): + self.origin = tmp / "origin.git" + subprocess.run(["git", "init", "-q", "--bare", "-b", "develop", str(self.origin)], check=True) + self.repo = tmp / "repo" + self.repo.mkdir() + git(self.repo, "init", "-q", "-b", "develop") + for k, v in (("user.email", "t@example.com"), ("user.name", "t"), ("commit.gpgsign", "false")): + git(self.repo, "config", k, v) + (self.repo / ".ndf").mkdir() + (self.repo / ".ndf" / "worktree.json").write_text(json.dumps({"version": 1, "base_branch": "develop", "production_branch": "main"})) + git(self.repo, "remote", "add", "origin", str(self.origin)) + self.publish(version, "develop", "main", tag=True) + + def publish(self, version: str, *branches: str, tag: bool = False) -> None: + """別のスプリントの配布を模す: plugin.json を version にしたコミットを branches へ送り、tag なら正式版のタグも送る。""" + f = self.repo / "plugins" / "ndf" / ".claude-plugin" / "plugin.json" + f.parent.mkdir(parents=True, exist_ok=True) + f.write_text(json.dumps({"version": version})) + git(self.repo, "add", "-A") + git(self.repo, "commit", "-q", "-m", version) + for b in branches: + git(self.repo, "push", "-q", "-f", "origin", f"HEAD:refs/heads/{b}") + if tag: + git(self.repo, "tag", f"ndf--v{version}") + git(self.repo, "push", "-q", "origin", f"ndf--v{version}") + + +@pytest.fixture +def world(tmp_path, monkeypatch): + monkeypatch.setenv("NDF_SV_STATE_DIR", str(tmp_path / "sv")) + return World(tmp_path) + + +def write_plan(tmp: Path, world: World, name: str = "7-release", channel: str = "dev", explicit=None, series=None, level="patch") -> Path: + v = explicit or "{release_version}" + base = explicit.split("-")[0] if explicit else "{release_base}" + plan = { + "フェーズ": "配布", + "作業場所": f"{world.repo}/.worktrees/release/v{v}", + "branch": f"release/v{v}", + "起点": "origin/develop", + "版": {"プラグイン": "ndf", "チャネル": channel, "上げ幅": level, "明示": explicit, "系列": series}, + "リポジトリ": str(world.repo), + "steps": [ + {"id": "bump", "type": "run", "cmd": f"bump --to {v}"}, + {"id": "facts", "type": "run", "cmd": f"approval-facts --version {base} --approval issues/approval-ndf-v{base}.md"}, + ], + } + d = tmp / "plans" + d.mkdir(exist_ok=True) + p = d / f"{name}.json" + p.write_text(json.dumps(plan, ensure_ascii=False)) + return p + + +def record(plan: Path) -> dict: + return json.loads((state_dir_of(str(plan.absolute())) / queue_fill.VERSION_RECORD).read_text()) + + +def own(monkeypatch, answer): + calls = [] + + def fake(repo, branch, base): + calls.append(branch) + return answer + + monkeypatch.setattr(rv, "own_released", fake) + return calls + + +def fill(plan: Path, items=None): + return queue_fill.fill_release_version(str(plan), items or []) + + +# ---- 開発版 ---- + + +def test_dev_plan_takes_the_version_that_is_free_when_it_runs(tmp_path, world): + """受け入れ条件 3: 作った後に別のスプリントが 10.27.6 を本番へ出しても、作り直さずに 10.27.7-dev.1 で埋まる。""" + plan = write_plan(tmp_path, world, level="patch") + world.publish("10.27.6", "develop", "main", tag=True) + assert fill(plan) == (None, None) + got = json.loads(plan.read_text()) + assert got["branch"] == "release/v10.27.7-dev.1" and got["作業場所"].endswith("/release/v10.27.7-dev.1") + assert got["steps"][0]["cmd"] == "bump --to 10.27.7-dev.1" # 受け入れ条件 9: release の --version と同じ差し込み + assert "approval-ndf-v10.27.7.md" in got["steps"][1]["cmd"] + rec = record(plan) + assert rec["version"] == "10.27.7-dev.1" and rec["base"] == "10.27.7" and rec["how"] == "decided" and rec["level"] == "patch" + assert rec["observed"] == { + "base_ref": "origin/develop", + "base_version": "10.27.6", + "production_ref": "origin/main", + "production_version": "10.27.6", + "latest_tag": "10.27.6", + } + assert rec["history"] == [] + + +def test_explicit_dev_version_that_is_already_out_stops_before_the_plan_runs(tmp_path, world): + """受け入れ条件 4・I5: 明示の版がベースブランチの版以下なら、記録を書かず、プランを書き換えずに止める。""" + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + before = plan.read_text() + err, info = fill(plan) + assert err and info["cause"] == "taken" and info["existing"] == "10.27.5" and info["next_free"] == "10.27.6-dev.1" + assert plan.read_text() == before + assert not (state_dir_of(str(plan.absolute())) / queue_fill.VERSION_RECORD).exists() + + +def test_explicit_dev_version_that_is_free_runs_unchanged(tmp_path, world): + """受け入れ条件 10・I8: 明示の版がまだ出ていなければ、プランはそのまま流れる。""" + plan = write_plan(tmp_path, world, explicit="10.27.6-dev.1") + before = plan.read_text() + assert fill(plan) == (None, None) + assert plan.read_text() == before and record(plan)["how"] == "explicit" + + +def test_rerun_keeps_the_recorded_version(tmp_path, world, monkeypatch): + """受け入れ条件 7・I3: 流し直しは記録の版を使う。ベースブランチがまだ小さければ照会せず、自分の配布の後なら照会して同じ版。""" + plan = write_plan(tmp_path, world) + fill(plan) + rec = record(plan) + calls = own(monkeypatch, True) + assert fill(plan) == (None, None) and calls == [] + world.publish("10.27.6-dev.1", "develop") # 自分の release/v10.27.6-dev.1 がマージされた + assert fill(plan) == (None, None) and calls == ["release/v10.27.6-dev.1"] + assert record(plan) == rec and json.loads(plan.read_text())["branch"] == "release/v10.27.6-dev.1" + + +def test_rerun_redecides_when_another_sprint_took_the_version_first(tmp_path, world, monkeypatch): + """決定 8・I9: 記録の版が自分の配布のマージより前に別のスプリントに出ていたら、写しから次の版で埋め直す。""" + plan = write_plan(tmp_path, world) + fill(plan) + sd = state_dir_of(str(plan.absolute())) + (sd / "state.json").write_text('{"log": [{"id": "bump", "exit": 3}]}') + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, False) + assert fill(plan) == (None, None) + got = json.loads(plan.read_text()) + assert got["branch"] == "release/v10.27.7-dev.1" and got["steps"][0]["cmd"] == "bump --to 10.27.7-dev.1" + rec = record(plan) + assert rec["version"] == "10.27.7-dev.1" + [h] = rec["history"] + assert (h["from"], h["to"], h["reason"], h["existing"]) == ("10.27.6-dev.1", "10.27.7-dev.1", "taken", "10.27.6") + assert h["left"]["branch"] == "release/v10.27.6-dev.1" and h["left"]["作業場所"].endswith("/release/v10.27.6-dev.1") + assert (sd / "superseded" / "v10.27.6-dev.1" / "state.json").is_file() and not (sd / "state.json").exists() + + +def test_redecide_interrupted_after_the_record_refills_once(tmp_path, world, monkeypatch): + """決定 8(落ちたときの戻り): 記録を書き換えた後、プランを書き直す前に落ちても、流し直しで同じ版に埋まり履歴は 1 件。""" + plan = write_plan(tmp_path, world) + fill(plan) + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, False) + real = queue_fill._Fill.ensure_filled + + def crash(self, rec): + raise RuntimeError("落ちた") + + monkeypatch.setattr(queue_fill._Fill, "ensure_filled", crash) + with pytest.raises(RuntimeError): + fill(plan) + assert json.loads(plan.read_text())["branch"] == "release/v10.27.6-dev.1" + monkeypatch.setattr(queue_fill._Fill, "ensure_filled", real) + assert fill(plan) == (None, None) + assert json.loads(plan.read_text())["branch"] == "release/v10.27.7-dev.1" and len(record(plan)["history"]) == 1 + + +def test_explicit_record_is_not_redecided(tmp_path, world, monkeypatch): + """決定 8・受け入れ条件 4: 明示の版の記録は、別のスプリントに出ていても決め直さずに止める。""" + plan = write_plan(tmp_path, world, explicit="10.27.6-dev.1") + fill(plan) + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, False) + err, info = fill(plan) + assert err and info["cause"] == "taken" and record(plan)["version"] == "10.27.6-dev.1" and record(plan)["history"] == [] + + +def test_unreadable_origin_stops_without_a_record(tmp_path, world, monkeypatch): + """受け入れ条件 8・I6: origin を読めなければ記録を書かずに止め、読めなかったものを載せる。記録がある流し直しも変えない。""" + plan = write_plan(tmp_path, world) + git(world.repo, "remote", "set-url", "origin", str(tmp_path / "gone.git")) + err, info = fill(plan) + assert err and info["cause"] == "unreadable" and "origin" in info["unreadable"] + assert not (state_dir_of(str(plan.absolute())) / queue_fill.VERSION_RECORD).exists() + git(world.repo, "remote", "set-url", "origin", str(world.origin)) + fill(plan) + rec, text = record(plan), plan.read_text() + world.publish("10.27.6", "develop", "main", tag=True) + own(monkeypatch, None) # 自分の配布の照会ができない + err, info = fill(plan) + assert info["cause"] == "unreadable" and info["unreadable"] == ["pr"] and record(plan) == rec and plan.read_text() == text + + +def test_plan_without_the_version_field_is_left_alone(tmp_path, world): + """非機能(移行性): 「版」の欄を持たない、この変更より前のプランには触らない。""" + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + body = json.loads(plan.read_text()) + del body["版"] + plan.write_text(json.dumps(body)) + before = plan.read_text() + assert fill(plan) == (None, None) and plan.read_text() == before + + +# ---- 本番 ---- + + +def dev_and_prod(tmp_path, world): + dev = write_plan(tmp_path, world) + fill(dev) + (state_dir_of(str(dev.absolute())) / "report.md").write_text("- 結果: 完了\n") + prod = write_plan(tmp_path, world, "8-release-prod", "prod", series="release") + return dev, prod + + +def test_prod_plan_uses_the_base_of_the_dev_record(tmp_path, world): + """受け入れ条件 6・I4: 本番は開発版の記録の base で埋まる。""" + _, prod = dev_and_prod(tmp_path, world) + assert fill(prod) == (None, None) + assert json.loads(prod.read_text())["branch"] == "release/v10.27.6" and record(prod)["how"] == "series" + + +def test_prod_plan_stops_when_the_release_is_already_tagged(tmp_path, world): + """受け入れ条件 6: 本番の版のタグがすでにあれば、次に空いている版へ移らずに止め、既にある版と次に空いている版を載せる。""" + _, prod = dev_and_prod(tmp_path, world) + world.publish("10.27.6", "develop", "main", tag=True) + before = prod.read_text() + err, info = fill(prod) + assert err and info["cause"] == "taken" and info["existing"] == "ndf--v10.27.6" and info["next_free"] == "10.27.7" + assert prod.read_text() == before + + +def test_prod_plan_without_the_dev_record_stops(tmp_path, world): + prod = write_plan(tmp_path, world, "8-release-prod", "prod", series="release") + err, info = fill(prod) + assert err and info["cause"] == "no_series" + + +def test_prod_follows_a_changed_dev_record_only_before_gate_2(tmp_path, world): + """決定 8・I4: 本番の記録の後に開発版の記録の base が変わったら、承認ゲート 2 の前は追い、後は止める。""" + dev, prod = dev_and_prod(tmp_path, world) + fill(prod) + rec_path = state_dir_of(str(dev.absolute())) / queue_fill.VERSION_RECORD + rec = json.loads(rec_path.read_text()) + rec_path.write_text(json.dumps({**rec, "version": "10.27.7-dev.1", "base": "10.27.7"})) + assert fill(prod) == (None, None) + assert json.loads(prod.read_text())["branch"] == "release/v10.27.7" and record(prod)["history"][0]["reason"] == "series_changed" + (state_dir_of(str(prod.absolute())) / "state.json").write_text('{"log": [{"id": "approve", "exit": 0}]}') + rec_path.write_text(json.dumps({**rec, "version": "10.27.8-dev.1", "base": "10.27.8"})) + err, info = fill(prod) + assert err and info["cause"] == "series_changed" and json.loads(prod.read_text())["branch"] == "release/v10.27.7" + + +# ---- キューのステージ ---- + + +def test_fill_stage_reports_the_version_of_a_plan_it_did_not_run(tmp_path, world): + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + skipped, runnable = queue.fill_stage(True, [str(plan)], []) + assert runnable == [] and skipped[0]["unfilled"] is True and skipped[0]["version"]["cause"] == "taken" From 54416c7493a46e0dc34c5e521f6adc6067c216d1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 03:58:54 +0000 Subject: [PATCH 19/46] =?UTF-8?q?Docs:=20=E3=82=B9=E3=83=97=E3=83=AA?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AE=E9=85=8D=E5=B8=83=E3=81=AE=E7=89=88?= =?UTF-8?q?=E3=81=8C=E6=B5=81=E3=81=99=E6=99=82=E7=82=B9=E3=81=AB=E6=B1=BA?= =?UTF-8?q?=E3=81=BE=E3=82=8B=E3=81=93=E3=81=A8=E3=81=A8=E6=AD=A2=E3=81=BE?= =?UTF-8?q?=E3=82=8A=E6=96=B9=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88#2164?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../development-workflow/references/pace.md | 32 +++++++++++++++---- .../references/waiting.md | 5 +-- 2 files changed, 29 insertions(+), 8 deletions(-) diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index da0dcd88d..7046d8294 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -200,14 +200,13 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの | 開発版 | `release`(`facts` は `gate_as_ok`) | 次の `--then` | | 本番 | `release-prod`(先頭が `mvv` のステップ) | 最後の `--then`。`関門` ならキューの結果が `gate` になり、承認の後に `resume` に `--from release-prod=bump` を足して続ける | -**スプリントの終わり**は `supervise.py new close --name M --worktree <根> --issue N... --version <開発版> --prod <正式版> ---state <状態>` が組む。最終の検査(実行条件 `eval --final`)→ 開発版と本番(実行条件 `changed --id -final`。 +**スプリントの終わり**は `supervise.py new close --name M --worktree <根> --issue N... --state <状態>` が組む。最終の検査(実行条件 `eval --final`)→ 開発版と本番(実行条件 `changed --id -final`。 最終の検査で変更があったときだけ)→ まとめ(`spec` 確定仕様化 → Pull Request →(配布の記録があれば `release-verify` リリース後テスト →)`retro` 振り返り → `close` 後片付け → `refine` 棚卸し) を `--then` のステージで流す。`close` のステップは `sprint-close.py --record-pr {queue_pr:release-prod} --issues <課題> --final-stage 振り返り --with-verification` で、本番が飛ばされたときは `--record-pr 0`(本番の記録なし)になる。`refine` は `close` が 0 で終わったときだけ届き(スプリントを閉じてから棚卸しへ進む)、棚卸しを無人で通す(承認の要る反映は 行わず、承認資料のパスと番号を報告に載せて終える)。まとめのプランは課題すべてへ 工程を記録するため、通過記録の報告が `まとめる:` から `記録あり:` へ移る。 -リリースの経路がテンプレートで組むものでなければ `--version`・`--prod` は要らず、開発版と本番の代わりに下の経路のステージが入り、 +リリースの経路がテンプレートで組むものでなければ、開発版と本番の代わりに下の経路のステージが入り、 `close` のステップの `--record-pr` は、昇格の経路(手動反映の本番系を除く)なら `{queue_pr:promote}`、経路がすべて届けない経路なら `0`、 それ以外(merge・手で届ける・検証の環境だけ・手動反映の本番系)は `{queue_record}` になる。開発ループのまとめ(`new close --pace auto`)は、 届けない経路だけでなければ経路にかかわらず `{queue_record}` になる。`{queue_record}` は queue がまとめを流し始める時点に、まとめの @@ -218,9 +217,30 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの ## リリースの経路からステージを組む **`new sprint` / `new close` は、リリースの経路(`lib/delivery.py` の `routes`)から検査の後のステージを組む。** -`.ndf/supervise.json` に `release.form` があれば経路はテンプレート(`template`)で、今の開発版と本番のステージを置き、版数 -(`--version`、`close` は `--prod` も)が要る。無ければ `.ndf/project.json` の `delivery` の行ごとに経路を決め、版数は要らない。 -選んだ経路は `sprint.json` の `リリースの経路` に並ぶ。 +`.ndf/supervise.json` に `release.form` があれば経路はテンプレート(`template`)で、今の開発版と本番のステージを置く。 +無ければ `.ndf/project.json` の `delivery` の行ごとに経路を決める。選んだ経路は `sprint.json` の `リリースの経路` に並ぶ。 + +**テンプレートの経路の版数は、配布のプランを流す時点に決まる。** `--version`(`close` は `--prod` も)は明示の版で、 +省けばプランは具体的な版の代わりに版の差し込み(`{release_version}`・`{release_base}`)を持ち、作業場所・`branch`・承認資料の +パスも差し込みで書く。queue が開発版のプランを流す直前に origin のベースブランチの版と最新のリリースタグから次に空いている +版を決め(新しい系列を始めるときの上げ幅は `--level`、既定は `patch`)、プランの状態ディレクトリの版の記録(`version.json`)へ +決めた版・読んだ値・上げ幅を書いてから差し込みを埋める。流し直しは記録の版を使い、自分の配布がベースブランチへマージされる +前に別のスプリントがその版を出していたときだけ、次に空いている版へ決め直す(前の版・理由・前の作業場所と `branch` は +記録の `history` に残し、前のステップの記録は状態ディレクトリの `superseded/v<前の版>/` へ移してプランを頭から流す)。 +本番のプランは開発版のプラン `release` の版の記録の `base` で出す。 + +**次のときはプランを流さずに止まる。** queue の結果は `stopped` で、流さなかったプランの項目の `version` に `cause` と +材料が載る。別の版で黙って進めないため、どの版で出すかは人へ戻す。 + +| `cause` | いつ | 載る材料 | +| --- | --- | --- | +| `taken` | 明示の版がすでに出ている(開発版はベースブランチの版が同じか大きい、正式版は同じ版のタグがある)。本番の版のタグがすでにある | `existing`(既に出ている版)・`next_free`(次に空いている版) | +| `unreadable` | origin のベースブランチ・本番のブランチ・タグのどれか、または自分の配布の PR の照会を読めない | `unreadable`(読めなかったもの) | +| `no_series` | 本番が版を読む開発版のプランの版の記録が無い | — | +| `series_changed` | 承認ゲート 2 を通った後に、開発版の記録の版が変わった | `existing`(承認した版)・`version`(新しい版) | + +配布の `bump` も、新しい版がベースブランチの版以下か、既にタグのある正式版なら書き換えずに止まる(終了コード 3。 +読めなければ 2)。どちらも judge を通らずにプランが止まる。版の差し込みを持たない前のプランにも、この止まり方が当たる。 | `delivery` の行 | 経路 | 検査の後に置くステージ | | --- | --- | --- | diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index f83cc7e84..b5738d9d4 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -161,7 +161,8 @@ python3 plugins/ndf/scripts/supervise.py wait q/done.json ステージの `command` と `resume` をそのまま写す。 1. プランを書き出す。ステージごとのプランと目録(`$O/sprint.json`)ができる: - `sv new sprint --name m6 --worktree <リポジトリの根> --issue 1052 1053 --design 1052 --version 10.18.0-dev.1 --state $S --out $O`。 + `sv new sprint --name m6 --worktree <リポジトリの根> --issue 1052 1053 --design 1052 --state $S --out $O`。 + 版数は渡さず、開発版のプランを流す時点に queue が決める(明示するなら `--version`) 設計の前に別の課題を取り込むと決めていれば `--absorb <課題>:<取り込み先>` を足す(取り込んだ課題は設計のプランを持たない) 2. 設計: `sv queue $O/1-design-1052.json --max 3 --done $O/done-1.json` と `sv wait $O/done-1.json` 3. 承認ゲート 1: キューの結果が `gate` なら、設計 Pull Request をまとめて 1 回の承認に載せる。利用者が答えたら、設計 PR ごとに @@ -172,7 +173,7 @@ python3 plugins/ndf/scripts/supervise.py wait q/done.json 付けてからマージする(記録が無ければ承認ゲートで止まる)。設計の結果のプランがマージした設計文書の「設計の結果」の表を 実装のプランへ写し、検査のプランがスプリントの Pull Request をベースブランチへマージし、開発版のリリースプランが続けて流れる 5. 承認ゲート 2: キューの結果が `gate`(開発版の facts のステップ)なら、`sprint.json` の `関門 2` のステージの `gate` に沿って - 承認資料を添えて本番の承認(`ndf-gate:2`)を取る。利用者が答えたら `sprint-state.py gate $S "関門 2" --what <要約> --by user --outcome approved|rejected` + 承認資料(版は `$O/8-release-state/version.json` の `base`)を添えて本番の承認(`ndf-gate:2`)を取る。利用者が答えたら `sprint-state.py gate $S "関門 2" --what <要約> --by user --outcome approved|rejected` を打つ。差し戻されたら 6 を打たない 6. 本番: `sv queue $O/10-release-prod.json --max 3 --done $O/done-10.json` と `sv wait $O/done-10.json`。先頭の `approve` が 5 の記録を見て承認資料へ承認の記録を書き、出す PR は検査の報告から埋まる。最後のステップが後片付けを行う From 8b8c50bd81ef53d8db369a9b566aa8404ec55510 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 04:08:01 +0000 Subject: [PATCH 20/46] =?UTF-8?q?Fix:=20=E7=89=88=E3=81=AE=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=8C=E3=83=97=E3=83=A9=E3=82=B0=E3=82=A4=E3=83=B3?= =?UTF-8?q?=E3=81=AE=E7=BD=AE=E3=81=8D=E6=96=B9=E3=82=92=E4=BB=AE=E5=AE=9A?= =?UTF-8?q?=E3=81=9B=E3=81=9A=E3=80=81=E3=83=99=E3=83=BC=E3=82=B9=E3=83=96?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=83=81=E3=81=AE=E6=9C=A8=E3=81=8B=E3=82=89?= =?UTF-8?q?=20plugin.json=20=E3=82=92=E6=8E=A2=E3=81=99=EF=BC=88#2164?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bump は自分の作業場所の位置を渡す。キューの差し込みを通すテストは差し込みを差し替え、 origin を読む bump のテストは bare の origin を用意する。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/release_version.py | 23 +++++++++++-------- plugins/ndf/scripts/release-steps.py | 3 ++- .../ndf/scripts/tests/test_step_scripts.py | 4 +++- plugins/ndf/scripts/tests/test_supervise.py | 1 + .../ndf/scripts/tests/test_supervise_pace.py | 1 + 5 files changed, 21 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/scripts/lib/release_version.py b/plugins/ndf/scripts/lib/release_version.py index c6d8e61fb..d9ffc10df 100644 --- a/plugins/ndf/scripts/lib/release_version.py +++ b/plugins/ndf/scripts/lib/release_version.py @@ -58,10 +58,14 @@ def is_taken(self) -> bool: return self.existing is not None -def plugin_json_rel(plugin: str) -> str: - """リポジトリの根からのプラグインの `plugin.json` の位置(`release-steps.py` の `plugin_dir` と同じ置き方)。""" - d = f"plugins/{plugin}" if plugin in ("ndf", "playwright-kit") else f"plugins/mcp/{plugin}" - return f"{d}/.claude-plugin/plugin.json" +def plugin_json_rel(repo: str | Path, ref: str, plugin: str) -> str | None: + """`ref` の木で、名前が `plugin` のディレクトリの `.claude-plugin/plugin.json` の位置。無い・2 つ以上なら None。""" + p = _git(repo, "ls-tree", "-r", "--name-only", ref) + if p.returncode: + return None + tail = f"{plugin}/.claude-plugin/plugin.json" + hits = [f for f in p.stdout.splitlines() if f == tail or f.endswith(f"/{tail}")] + return hits[0] if len(hits) == 1 else None def _git(repo: str | Path, *args: str) -> subprocess.CompletedProcess: @@ -79,19 +83,20 @@ def _version_at(repo: str | Path, ref: str, rel: str) -> str | None: return v if isinstance(v, str) and versions.parse_version(v) else None -def observe(repo: str | Path, plugin: str, base: str, production: str | None, fetch: bool = True) -> Observed: +def observe(repo: str | Path, plugin: str, base: str, production: str | None, fetch: bool = True, rel: str | None = None) -> Observed: """origin を取り直してから、ベースブランチ・本番のブランチ・タグを読む。本番のブランチが無ければ読まない(読めないに数えない)。 - タグが 1 つも無いことは読めないに数えない。取り直しに落ちたら、読んだ値が古いかもしれないため `origin` を読めないに数える。""" + タグが 1 つも無いことは読めないに数えない。取り直しに落ちたら、読んだ値が古いかもしれないため `origin` を読めないに数える。 + `rel` は `plugin.json` のリポジトリの根からの位置で、省けばベースブランチの木から探す。""" unreadable: list[str] = [] if fetch and _git(repo, "fetch", "-q", "origin", "--tags").returncode: unreadable.append("origin") - rel = plugin_json_rel(plugin) base_ref = f"origin/{base}" - base_version = _version_at(repo, base_ref, rel) + rel = rel or plugin_json_rel(repo, base_ref, plugin) + base_version = _version_at(repo, base_ref, rel) if rel else None if base_version is None: unreadable.append(base_ref) prod_ref = f"origin/{production}" if production else "" - prod_version = _version_at(repo, prod_ref, rel) if prod_ref else None + prod_version = _version_at(repo, prod_ref, rel) if prod_ref and rel else None if prod_ref and prod_version is None: unreadable.append(prod_ref) p = _git(repo, "tag", "--list", f"{plugin}--v*") diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index e647ae447..5593a8443 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -395,7 +395,8 @@ def bump_guard(root, plugin, old, new, base) -> str | None: if versions.parse_version(new) is None: raise StepError(f"版の形が違う: {new}(X.Y.Z / X.Y.Z-dev.N / X.Y.Z-rc.N)", EXIT_UNREADABLE) - obs = release_version.observe(root, plugin, base, None) + rel = (plugin_dir(root, plugin) / ".claude-plugin" / "plugin.json").relative_to(root).as_posix() + obs = release_version.observe(root, plugin, base, None, rel=rel) metrics = {"plugin": plugin, "from": old, "to": new, "base_version": obs.base_version} if obs.unreadable: emit( diff --git a/plugins/ndf/scripts/tests/test_step_scripts.py b/plugins/ndf/scripts/tests/test_step_scripts.py index e6d564125..cfe238d9b 100644 --- a/plugins/ndf/scripts/tests/test_step_scripts.py +++ b/plugins/ndf/scripts/tests/test_step_scripts.py @@ -16,6 +16,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] sys.path.insert(0, str(Path(__file__).resolve().parent)) import inproc # noqa: E402 +from release_repo import push_origin # noqa: E402 PY = sys.executable @@ -354,8 +355,9 @@ def plugin_repo(repo, name="mcp-x", version="1.0.0"): def test_bump_updates_manifest_and_leaves_the_readme(repo, env): """受け入れ条件 6: bump は plugin の README に触れず、版ごとの更新の節が無いことを manual に出さない。""" pdir = plugin_repo(repo) + push_origin(repo) # bump は origin のベースブランチの版を読んでから上げる(#2164) before = (pdir / "README.md").read_bytes() - code, out, err = call("release-steps.py", ["bump", "--plugin", "mcp-x", "--to", "1.1.0", "--root", str(repo)], env) + code, out, err = call("release-steps.py", ["bump", "--plugin", "mcp-x", "--to", "1.1.0", "--base", "develop", "--root", str(repo)], env) assert code == 0, err check_shape(out) assert out["tool"] == "release" and out["status"] == "ok" diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index 3e211592a..53bfdf604 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -2059,6 +2059,7 @@ def batch(plans, adm, args): return real(plans, adm, args) monkeypatch.setattr(flow, "run_stage", batch) + monkeypatch.setattr(queue, "fill_release_version", lambda plan, items: (None, None)) # 版の差し込みは test_release_version が見る done = tmp_path / "done.json" res = queue.cmd_queue(impl, 3, poll=0.1, then=[str(rel)], done=str(done)) assert res["status"] == "ok", res diff --git a/plugins/ndf/scripts/tests/test_supervise_pace.py b/plugins/ndf/scripts/tests/test_supervise_pace.py index f6fec9272..776f387fe 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace.py @@ -486,6 +486,7 @@ def batch(plans, adm, args): return [{"plan": q, "result": "完了", "report": str(paths.state_dir_of(q) / "report.md")} for q in plans] monkeypatch.setattr(flow, "run_stage", batch) + monkeypatch.setattr(queue, "fill_release_version", lambda plan, items: (None, None)) # 版の差し込みは test_release_version が見る if alone: # 検査の関門の後・開発版の出し直し: 検査の報告だけが残っている report_with_pr(check, "https://github.com/o/r/pull/1915") queue.cmd_queue([release], 3, poll=0.05) From 626b582c8f5554a3412324a530308acad89afbb7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 04:16:33 +0000 Subject: [PATCH 21/46] =?UTF-8?q?Update:=20=E5=AE=A3=E8=A8=80=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E5=BD=A2=E3=82=92=E5=BD=93=E3=81=A6=E3=82=8B=EF=BC=88?= =?UTF-8?q?lint-fix=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/ndf/scripts/lib/release_version.py | 4 +--- plugins/ndf/scripts/release-steps.py | 16 ++++++++++++++-- .../scripts/supervise_lib/release_templates.py | 8 +++++++- plugins/ndf/scripts/supervise_lib/sprint.py | 6 +++++- .../ndf/scripts/supervise_lib/sprint_waves.py | 4 +--- 5 files changed, 28 insertions(+), 10 deletions(-) diff --git a/plugins/ndf/scripts/lib/release_version.py b/plugins/ndf/scripts/lib/release_version.py index d9ffc10df..c77cc8c7c 100644 --- a/plugins/ndf/scripts/lib/release_version.py +++ b/plugins/ndf/scripts/lib/release_version.py @@ -156,9 +156,7 @@ def own_released(repo: str | Path, branch: str, base: str) -> bool | None: """`branch` → `base` のマージ済みの PR があるか(自分の配布か)。1 回だけ照会し、照会できなければ None。""" import gh_call - r = gh_call.gh( - ["pr", "list", "--head", branch, "--base", base, "--state", "merged", "--json", "number", "--limit", "1"], cwd=str(repo) - ) + r = gh_call.gh(["pr", "list", "--head", branch, "--base", base, "--state", "merged", "--json", "number", "--limit", "1"], cwd=str(repo)) if r.returncode: return None try: diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 5593a8443..644b6a17a 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -400,7 +400,13 @@ def bump_guard(root, plugin, old, new, base) -> str | None: metrics = {"plugin": plugin, "from": old, "to": new, "base_version": obs.base_version} if obs.unreadable: emit( - result(TOOL, "stopped", f"版を確かめる値を読めない: {'・'.join(obs.unreadable)}。推定で上げない", [], {**metrics, "unreadable": obs.unreadable}), + result( + TOOL, + "stopped", + f"版を確かめる値を読めない: {'・'.join(obs.unreadable)}。推定で上げない", + [], + {**metrics, "unreadable": obs.unreadable}, + ), code=EXIT_UNREADABLE, ) cmp = versions.compare_versions(obs.base_version, new) @@ -410,7 +416,13 @@ def bump_guard(root, plugin, old, new, base) -> str | None: own = release_version.own_released(root, f"release/v{new}", base) if own is None: emit( - result(TOOL, "stopped", f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない", [], {**metrics, "unreadable": ["pr"]}), + result( + TOOL, + "stopped", + f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない", + [], + {**metrics, "unreadable": ["pr"]}, + ), code=EXIT_UNREADABLE, ) if own: diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index 04a7e5789..4a6addca6 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -62,7 +62,13 @@ def version_field(a, plugin: str, dev: bool) -> dict: series = None if dev or explicit else getattr(a, "series", None) or None if not dev and not explicit and not series: raise DeclError("本番の配布に要る版が無い(--prod か、版を読む開発版のプランの系列)") - return {"プラグイン": plugin, "チャネル": "dev" if dev else "prod", "上げ幅": getattr(a, "level", None) or "patch", "明示": explicit, "系列": series} + return { + "プラグイン": plugin, + "チャネル": "dev" if dev else "prod", + "上げ幅": getattr(a, "level", None) or "patch", + "明示": explicit, + "系列": series, + } def _validate_release_decl(rel: dict, dev: bool, a) -> tuple[str, list[str]]: diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index d355b9844..65d83ff48 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -186,7 +186,11 @@ def close_waves(a) -> list[dict]: return [ check, {"name": "開発版", "plans": {"release": plan_mvv_release(a, repo, a.version, "dev", changed)}, "then_of": "最終の検査"}, - {"name": "本番", "plans": {"release-prod": plan_mvv_release(a, repo, getattr(a, "prod", None), "prod", changed)}, "then_of": "最終の検査"}, + { + "name": "本番", + "plans": {"release-prod": plan_mvv_release(a, repo, getattr(a, "prod", None), "prod", changed)}, + "then_of": "最終の検査", + }, close, ] diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index b69d52c42..728218681 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -470,9 +470,7 @@ def plan_fast_check(a, repo: str, name: str, final: bool = False, review_only: b return plan_check_since(ns) -def plan_mvv_release( - a, repo: str, version: str | None, channel: str, condition: dict | None = None, prs: list[str] | None = None -) -> dict: +def plan_mvv_release(a, repo: str, version: str | None, channel: str, condition: dict | None = None, prs: list[str] | None = None) -> dict: """pace: fast と auto のリリース。開発版は承認資料を gate_as_ok で写し、本番は先頭の mvv のステップが関門 2 を判定する。 prs を渡せばその Pull Request(`{queue_pr:<名>}` でもよい)を出す版の PR にし、省けば前のステージの PR のすべて。 `version` は明示の版で、無ければ版を流す時点に決める(#2164)。""" From 766a923b4ab7b4baca34284afc475aa40c47db00 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 04:20:09 +0000 Subject: [PATCH 22/46] =?UTF-8?q?Fix:=20=E6=A7=8B=E9=80=A0=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=80=81?= =?UTF-8?q?bump=20=E3=81=AE=E7=89=88=E3=81=AE=E5=88=A4=E5=AE=9A=E3=82=92?= =?UTF-8?q?=20release=5Fversion=20=E3=81=B8=E7=A7=BB=E3=81=97=E3=80=81?= =?UTF-8?q?=E5=90=8C=E3=81=98=E5=90=8D=E5=89=8D=E3=81=AE=E8=A3=9C=E5=8A=A9?= =?UTF-8?q?=E9=96=A2=E6=95=B0=E3=82=92=E6=94=B9=E3=82=81=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2164=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/release_version.py | 56 ++++++++++++++-- plugins/ndf/scripts/release-steps.py | 65 ++----------------- .../ndf/scripts/supervise_lib/queue_fill.py | 24 +++---- 3 files changed, 68 insertions(+), 77 deletions(-) diff --git a/plugins/ndf/scripts/lib/release_version.py b/plugins/ndf/scripts/lib/release_version.py index c77cc8c7c..bf6cc51bb 100644 --- a/plugins/ndf/scripts/lib/release_version.py +++ b/plugins/ndf/scripts/lib/release_version.py @@ -60,7 +60,7 @@ def is_taken(self) -> bool: def plugin_json_rel(repo: str | Path, ref: str, plugin: str) -> str | None: """`ref` の木で、名前が `plugin` のディレクトリの `.claude-plugin/plugin.json` の位置。無い・2 つ以上なら None。""" - p = _git(repo, "ls-tree", "-r", "--name-only", ref) + p = _git_run(repo, "ls-tree", "-r", "--name-only", ref) if p.returncode: return None tail = f"{plugin}/.claude-plugin/plugin.json" @@ -68,12 +68,12 @@ def plugin_json_rel(repo: str | Path, ref: str, plugin: str) -> str | None: return hits[0] if len(hits) == 1 else None -def _git(repo: str | Path, *args: str) -> subprocess.CompletedProcess: +def _git_run(repo: str | Path, *args: str) -> subprocess.CompletedProcess: return subprocess.run(["git", "-C", str(repo), *args], capture_output=True, text=True) def _version_at(repo: str | Path, ref: str, rel: str) -> str | None: - p = _git(repo, "show", f"{ref}:{rel}") + p = _git_run(repo, "show", f"{ref}:{rel}") if p.returncode: return None try: @@ -88,7 +88,7 @@ def observe(repo: str | Path, plugin: str, base: str, production: str | None, fe タグが 1 つも無いことは読めないに数えない。取り直しに落ちたら、読んだ値が古いかもしれないため `origin` を読めないに数える。 `rel` は `plugin.json` のリポジトリの根からの位置で、省けばベースブランチの木から探す。""" unreadable: list[str] = [] - if fetch and _git(repo, "fetch", "-q", "origin", "--tags").returncode: + if fetch and _git_run(repo, "fetch", "-q", "origin", "--tags").returncode: unreadable.append("origin") base_ref = f"origin/{base}" rel = rel or plugin_json_rel(repo, base_ref, plugin) @@ -99,7 +99,7 @@ def observe(repo: str | Path, plugin: str, base: str, production: str | None, fe prod_version = _version_at(repo, prod_ref, rel) if prod_ref and rel else None if prod_ref and prod_version is None: unreadable.append(prod_ref) - p = _git(repo, "tag", "--list", f"{plugin}--v*") + p = _git_run(repo, "tag", "--list", f"{plugin}--v*") tags: list[str] = [] if p.returncode: unreadable.append("tags") @@ -163,3 +163,49 @@ def own_released(repo: str | Path, branch: str, base: str) -> bool | None: return bool(json.loads(r.stdout or "[]")) except ValueError: return None + + +@dataclass +class BumpStop: + """`bump` を止める理由(要約・数値・終了コード)。""" + + summary: str + metrics: dict + code: int + + +def bump_check(repo: str | Path, plugin: str, old: str, new: str, base: str, rel: str) -> tuple[str | None, BumpStop | None]: + """`bump` が書き換える前の判定(#2164 の I7)。打ち直しなら (origin のベースブランチの版, None)、書き換えてよければ (None, None)。 + 新しい版がベースブランチの版以下か、正式版でそのタグがあれば終了コード 3 で止める(版を戻さない)。 + 版の形が違う・ベースブランチの版を読めない・自分の配布の照会ができなければ、推定で進めずに終了コード 2 で止める。 + 作業場所がすでに新しい版で、ベースブランチの版が等しいときだけ `release/v<新しい版>` → ベースブランチのマージ済みの + PR を 1 回照会し、あれば自分の配布の後の流し直しとして打ち直しにする。""" + if versions.parse_version(new) is None: + return None, BumpStop(f"版の形が違う: {new}(X.Y.Z / X.Y.Z-dev.N / X.Y.Z-rc.N)", {"plugin": plugin, "to": new}, 2) + obs = observe(repo, plugin, base, None, rel=rel) + metrics = {"plugin": plugin, "from": old, "to": new, "base_version": obs.base_version} + if obs.unreadable: + why = f"版を確かめる値を読めない: {'・'.join(obs.unreadable)}。推定で上げない" + return None, BumpStop(why, {**metrics, "unreadable": obs.unreadable}, 2) + cmp = versions.compare_versions(obs.base_version, new) + if old == new and cmp < 0: + return obs.base_version, None + if old == new and cmp == 0: + own = own_released(repo, f"release/v{new}", base) + if own is None: + why = f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない" + return None, BumpStop(why, {**metrics, "unreadable": ["pr"]}, 2) + if own: + return obs.base_version, None + d = taken(obs, new) + if cmp < 0 and not d.is_taken: + return None, None + existing = obs.base_version if cmp >= 0 else d.existing + if d.is_taken: + nxt = d.next_free + elif versions.parse_version(new).prerelease: + nxt = decide_dev(obs).version + else: + nxt = versions.next_patch(obs.base_version) + why = f"{plugin} の {new} はすでに出ている({existing})。版を戻さない(次に空いている版は {nxt})" + return None, BumpStop(why, {**metrics, "existing": existing, "next_free": nxt}, 3) diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 644b6a17a..89d9cc502 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -94,7 +94,6 @@ EXIT_GATE, EXIT_INFRA_WAIT, EXIT_PRECONDITION, - EXIT_UNREADABLE, PRESENT_HEADINGS, StepError, approval_present, @@ -385,68 +384,14 @@ def plan_bump(root, pdir, plugin, old, new): def bump_guard(root, plugin, old, new, base) -> str | None: - """`bump` が書き換える前の判定(#2164 の I7)。打ち直しなら origin のベースブランチの版を返し、書き換えてよければ None。 - 新しい版がベースブランチの版以下か、正式版でそのタグがあれば `stopped`・終了コード 3 で止める(版を戻さない)。 - ベースブランチの版を読めない・自分の配布の照会ができなければ、推定で進めずに終了コード 2 で止める。 - 作業場所がすでに新しい版で、ベースブランチの版が等しいときだけ `release/v<新しい版>` → ベースブランチのマージ済みの - PR を 1 回照会し、あれば自分の配布の後の流し直しとして打ち直しにする。""" + """`bump` が書き換える前の判定(#2164 の I7。中身は release_version.bump_check)。打ち直しなら origin のベースブランチの版。""" import release_version - import versions - if versions.parse_version(new) is None: - raise StepError(f"版の形が違う: {new}(X.Y.Z / X.Y.Z-dev.N / X.Y.Z-rc.N)", EXIT_UNREADABLE) rel = (plugin_dir(root, plugin) / ".claude-plugin" / "plugin.json").relative_to(root).as_posix() - obs = release_version.observe(root, plugin, base, None, rel=rel) - metrics = {"plugin": plugin, "from": old, "to": new, "base_version": obs.base_version} - if obs.unreadable: - emit( - result( - TOOL, - "stopped", - f"版を確かめる値を読めない: {'・'.join(obs.unreadable)}。推定で上げない", - [], - {**metrics, "unreadable": obs.unreadable}, - ), - code=EXIT_UNREADABLE, - ) - cmp = versions.compare_versions(obs.base_version, new) - if old == new and cmp < 0: - return obs.base_version - if old == new and cmp == 0: - own = release_version.own_released(root, f"release/v{new}", base) - if own is None: - emit( - result( - TOOL, - "stopped", - f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない", - [], - {**metrics, "unreadable": ["pr"]}, - ), - code=EXIT_UNREADABLE, - ) - if own: - return obs.base_version - d = release_version.taken(obs, new) - if cmp >= 0 or d.is_taken: - existing = obs.base_version if cmp >= 0 else d.existing - if d.is_taken: - nxt = d.next_free - elif versions.parse_version(new).prerelease: - nxt = release_version.decide_dev(obs).version - else: - nxt = versions.next_patch(obs.base_version) - emit( - result( - TOOL, - "stopped", - f"{plugin} の {new} はすでに出ている({existing})。版を戻さない(次に空いている版は {nxt})", - [], - {**metrics, "existing": existing, "next_free": nxt}, - ), - code=EXIT_PRECONDITION, - ) - return None + already, stop = release_version.bump_check(root, plugin, old, new, base, rel) + if stop: + emit(result(TOOL, "stopped", stop.summary, [], stop.metrics), code=stop.code) + return already def cmd_bump(a): diff --git a/plugins/ndf/scripts/supervise_lib/queue_fill.py b/plugins/ndf/scripts/supervise_lib/queue_fill.py index 53a1fbb00..d90de2819 100644 --- a/plugins/ndf/scripts/supervise_lib/queue_fill.py +++ b/plugins/ndf/scripts/supervise_lib/queue_fill.py @@ -138,13 +138,13 @@ def write_text_atomic(path: Path, text: str) -> None: GATE_2_STEPS = ("approve", "mvv") # 本番のプランの先頭で承認ゲート 2 を通すステップ -def _now() -> str: +def _version_now() -> str: import datetime return datetime.datetime.now(datetime.timezone.utc).isoformat(timespec="seconds") -def _read_json(path: Path) -> dict | None: +def _read_version_json(path: Path) -> dict | None: try: d = json.loads(path.read_text()) except (OSError, ValueError): @@ -152,7 +152,7 @@ def _read_json(path: Path) -> dict | None: return d if isinstance(d, dict) else None -def _write_json(path: Path, obj: dict) -> None: +def _write_version_json(path: Path, obj: dict) -> None: write_text_atomic(path, json.dumps(obj, ensure_ascii=False, indent=2) + "\n") @@ -205,7 +205,7 @@ def branch_of(self, version: str) -> str: return b if b and RELEASE_VERSION not in b else f"release/v{version}" def log(self, text: str, **extra) -> None: - line = json.dumps({"kind": "version", "at": _now(), "text": text, **extra}, ensure_ascii=False) + line = json.dumps({"kind": "version", "at": _version_now(), "text": text, **extra}, ensure_ascii=False) with (self.sd / "progress.jsonl").open("a") as f: f.write(line + "\n") @@ -221,10 +221,10 @@ def write_record(self, version: str, how: str, observed: dict, series_from: str "level": self.level, "observed": observed, "series_from": series_from, - "at": _now(), + "at": _version_now(), "history": [], } - _write_json(self.record_path, rec) + _write_version_json(self.record_path, rec) return rec def ensure_filled(self, rec: dict) -> None: @@ -252,10 +252,10 @@ def redecide(self, rec: dict, new: str, reason: str, existing: str | None, obser target, n = dst / f"{f.name}.{n}", n + 1 os.replace(f, target) left = {"作業場所": self.info.get("作業場所"), "branch": self.info.get("branch"), "superseded": str(dst)} - hist = {"from": old, "to": new, "reason": reason, "existing": existing, "observed": observed, "left": left, "at": _now()} - rec = {**rec, "version": new, "base": versions.release_base(new), "observed": observed, "at": _now()} + hist = {"from": old, "to": new, "reason": reason, "existing": existing, "observed": observed, "left": left, "at": _version_now()} + rec = {**rec, "version": new, "base": versions.release_base(new), "observed": observed, "at": _version_now()} rec["history"] = [*(rec.get("history") or []), hist] - _write_json(self.record_path, rec) + _write_version_json(self.record_path, rec) self.log( f"版を {old} から {new} へ決め直した({reason})。前の作業場所 {left['作業場所']} と branch {left['branch']} は残した", version=new, @@ -270,7 +270,7 @@ def series_record(self) -> tuple[dict | None, str | None]: if not i.get("plan"): continue f = state_dir_of(str(Path(i["plan"]).absolute())) / VERSION_RECORD - rec = _read_json(f) + rec = _read_version_json(f) if rec and rec.get("base"): found.append((f.stat().st_mtime, rec, str(f))) if not found: @@ -280,7 +280,7 @@ def series_record(self) -> tuple[dict | None, str | None]: def approved(self) -> bool: """本番のプランの状態に承認ゲート 2 の通過(approve か mvv のステップが 0 で終えた記録)があるか。""" - state = _read_json(self.sd / "state.json") or {} + state = _read_version_json(self.sd / "state.json") or {} return any(e.get("id") in GATE_2_STEPS and e.get("exit") == 0 for e in state.get("log") or [] if isinstance(e, dict)) @@ -355,5 +355,5 @@ def fill_release_version(plan: str, items: list[dict]) -> tuple[str | None, dict f = _Fill(plan, text, info, items) if not f.plugin or not f.base: return "版の差し込みを埋められない: プランの「版」の欄にプラグインが無いか、起点が無い", None - rec = _read_json(f.record_path) + rec = _read_version_json(f.record_path) return (_fill_prod if f.field.get("チャネル") == "prod" else _fill_dev)(f, rec) From 785de5017cc4f7ad0e608a40182e6ef6252676e3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 04:32:27 +0000 Subject: [PATCH 23/46] =?UTF-8?q?Docs:=20=E6=9C=AC=E7=95=AA=E3=81=AE?= =?UTF-8?q?=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AE=E7=89=88=E3=82=92=E7=9C=81?= =?UTF-8?q?=E3=81=84=E3=81=9F=E3=81=A8=E3=81=8D=E3=81=AB=E9=96=8B=E7=99=BA?= =?UTF-8?q?=E7=89=88=E3=81=AE=E7=89=88=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=9F=8B=E3=81=BE=E3=82=8B=E3=81=93=E3=81=A8=E3=82=92?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=B8=E5=8F=8D=E6=98=A0?= =?UTF-8?q?=E3=81=99=E3=82=8B=EF=BC=88#2164=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-production-release-record.md | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index 23a5f804c..ac26f8495 100644 --- a/docs/specifications/ndf-production-release-record.md +++ b/docs/specifications/ndf-production-release-record.md @@ -235,10 +235,14 @@ stateDiagram-v2 本番のプランは開発版と同じ `sprint_waves.plan_sprint_release` に `channel="prod"` と `version=prod_version(--version)`(接尾辞を外した版。 `10.18.0-dev.1` → `10.18.0`)を渡して組む。worktree とブランチは `release/v<正式版>`、出す PR は `{queue_pr:check}` である。 +`--version` を省いたときは `version` が空で、worktree・ブランチ・承認資料のパスは版の差し込み(`{release_version}`・`{release_base}`)で書き、 +プランの「版」の欄の `系列` に開発版のプランの名前 `release` を入れる。キューが本番のプランを流す直前に、開発版のプランの版の記録(`version.json`)の +`base` で埋める(#2164)。 雛形(`release_templates.plan_release`)が組んだステップを目録の側で書き換えず、`new release --version <正式版> --prs {queue_pr:check} --channel prod` と同じ出力に `課題` が入る(スプリントの工程は雛形に対して順応者の関係にある)。 -承認ゲート 2 の文は、開発版の配布が書いた承認資料(元のリポジトリの下の `issues/approval-<プラグイン>-v<正式版>.md` の絶対パス)を示して +承認ゲート 2 の文は、開発版の配布が書いた承認資料(元のリポジトリの下の `issues/approval-<プラグイン>-v<正式版>.md` の絶対パス。版を明示しなかったときは +`v` と書き、`` が開発版のプラン `release` の状態ディレクトリの `version.json` の `base` であることを添える)を示して 本番への配布の承認を取ることを書く。`--state` を渡さないときは、承認されたら `release-steps.py approve --approval <承認資料> --approved-sha --by user` で承認の記録を書いてから「本番」のステージの `command` を打つこと(差し戻されたら打たない)を書く。`--state` を渡したときは、利用者が答えたら `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved|rejected` を打ち、承認されたら「本番」のステージの `command` を打つことと、 @@ -253,7 +257,7 @@ stateDiagram-v2 | --- | --- | --- | | N1 | 「本番」のステージは `then_of` を持たず、どの queue の `--then` にも入らない(承認ゲート 2 は利用者が答える。連結すると承認を待たずに本番が流れる) | テストで落とす | | N2 | 並びは「配布」→「`関門 2`」→「本番」で、間に他のステージが入らない | テストで落とす | -| N3 | 本番のプランの版は `--version` から接尾辞を外したもの、チャネルは `prod`、出す PR は `{queue_pr:check}` | テストで落とす | +| N3 | 本番のプランの版は `--version` から接尾辞を外したもの(`--version` を省けば版の差し込みで、開発版の版の記録の `base` で埋まる)、チャネルは `prod`、出す PR は `{queue_pr:check}` | テストで落とす | | N4 | `--state` を渡しても、本番のプランは MVV 判定のステップ(`mvv`・`note`・`handoff`)を持たない(`advise` は `channel` が `dev` のときだけ渡す) | テストで落とす | | N5 | 「`関門 2`」を置くとき、承認ゲート 2 の文はその `gate` の 1 か所にだけあり、「配布」の `note` に重ねない。ほかの経路(N6 と `route_waves`)では「配布」か経路のステージの `note` が正本である | テストで落とす | | N6 | 本番のブランチが決まっていないときは「`関門 2`」と「本番」を置かず、「配布」で終える(`--state` なら `note` に `normal_gate_2` の文)。`next` に置かなかった理由を 1 文足す(`no_prod_stage`) | テストで落とす | From 0b5fafc7ee1bbf790bb76e1c99446d37846db233 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:38:14 +0000 Subject: [PATCH 24/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/sprint=5Floop.py#cmd=5Fsprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manifest のステージ配列から queue と pipeline の文字列を組む部分を sprint_pipeline として抽出した。 手で行うステージの有無による 2 つの経路の文字列を現状固定テストで固定した。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop.py | 29 ++++++++++--------- plugins/ndf/scripts/tests/test_sprint_loop.py | 16 ++++++++++ 2 files changed, 32 insertions(+), 13 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 0e57a7cd1..73aca8a91 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -298,6 +298,21 @@ def new_sprint_argv(root: str, rec: dict, lap: dict, extra: list[str]) -> list[s return new +def sprint_pipeline(index: list[dict], close: list[str], done: Path, rec: dict) -> tuple[str, str]: + """manifest のステージ配列から queue と pipeline の文字列を組む。最初の command の後に手で行うステージか + 別の command(単独の queue の承認ゲート 2)があれば、それを終えるまでまとめへ進めない。""" + first = next(e for e in index if e.get("command")) + hands = [e for e in index[index.index(first) + 1 :] if e.get("command") or "manual" in e] # 関門は queue と loop gate が扱う + if not hands: + queue = first["command"] + " --then " + " ".join(map(shlex.quote, close)) + f" --done {shlex.quote(str(done))}" + return queue, f"{queue} && {wait_lap(rec, done)}" + queue = ( + f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, close)) + f" --max 3 --done {shlex.quote(str(done))}" + ) + todo = " → ".join(e.get("command") or f"{e['name']}({e['manual']} で行う)" for e in hands) + return queue, f"{first['command']} を流し、続けて {todo} を終えてから: {queue} && {wait_lap(rec, done)}" + + def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra: list[str]) -> dict: with open_record(env, name) as (rec, before): lap = current_lap(rec) @@ -332,21 +347,9 @@ def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra index = json.loads(manifest.read_text(encoding="utf-8"))["ステージ"] lap["manifest"] = str(manifest) lap["prod_plan"] = next((p for e in index if e.get("name") == "本番" for p in e.get("plans", [])), None) - first = next(e for e in index if e.get("command")) done = d / "done-1.json" + queue, pipeline = sprint_pipeline(index, close, done, rec) lap["dones"] = [str(done)] - hands = [e for e in index[index.index(first) + 1 :] if e.get("command") or "manual" in e] # 関門は queue と loop gate が扱う - if not hands: - queue = first["command"] + " --then " + " ".join(map(shlex.quote, close)) + f" --done {shlex.quote(str(done))}" - pipeline = f"{queue} && {wait_lap(rec, done)}" - else: # 手で行うステージか別の command(単独の queue の承認ゲート 2)があれば、それを終えるまでまとめへ進めない - queue = ( - f"python3 {shlex.quote(str(SUPERVISE))} queue " - + " ".join(map(shlex.quote, close)) - + f" --max 3 --done {shlex.quote(str(done))}" - ) - todo = " → ".join(e.get("command") or f"{e['name']}({e['manual']} で行う)" for e in hands) - pipeline = f"{first['command']} を流し、続けて {todo} を終えてから: {queue} && {wait_lap(rec, done)}" if rec.get("status") == "stopped": # refused からのやり直し rec["stops"].append({**rec["stop"], "resumed_at": env.now()}) rec["stop"], rec["status"] = None, "running" diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 95229f92f..0c470f19b 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -918,6 +918,22 @@ def run(cmd, cwd=None, stdin=None): assert rest.startswith("python3 ") and "1-close.json" in rest and "loop lap l1" in rest +def test_sprint_pipeline_chains_close_after_the_first_command_without_hands(tmp_path): + done, rec_ = tmp_path / "done-1.json", {"name": "l1"} + index = [{"name": "設計", "gate": True}, {"name": "実装", "command": "Q1"}] + queue, pipeline = sl.sprint_pipeline(index, ["c1", "c 2"], done, rec_) + assert queue == f"Q1 --then c1 'c 2' --done {done}" + assert pipeline == f"{queue} && {sl_gate.wait_lap(rec_, done)}" + + +def test_sprint_pipeline_waits_for_hands_before_the_close(tmp_path): + done, rec_ = tmp_path / "done-1.json", {"name": "l1"} + index = [{"name": "実装", "command": "Q1"}, {"name": "ゲート 2", "command": "Q2"}, {"name": "リリース", "manual": "/ndf:release"}] + queue, pipeline = sl.sprint_pipeline(index, ["c1"], done, rec_) + assert queue == f"python3 {sl.SUPERVISE} queue c1 --max 3 --done {done}" + assert pipeline == f"Q1 を流し、続けて Q2 → リリース(/ndf:release で行う) を終えてから: {queue} && {sl_gate.wait_lap(rec_, done)}" + + def test_refused_continuation_after_gate_1_is_rebuilt_from_the_kept_issues(env, monkeypatch): lap = built_lap(env) real = sl_gate.continuation From da67d1f6545f96e03d425346d5315020833a7e4b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:38:37 +0000 Subject: [PATCH 25/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/sprint=5Fclose=5Flib/record=5Ffind.py#fi?= =?UTF-8?q?nd=5Frecord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 閉じる PR の収集(_closers_of)・候補の展開(_candidates)・記録の読み取りと振り分け(_released_records)を それぞれ抽出し、find_record には順に呼ぶ流れと RecordHit の組み立てだけを残した。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../scripts/sprint_close_lib/record_find.py | 62 ++++++++++++------- 1 file changed, 40 insertions(+), 22 deletions(-) diff --git a/plugins/ndf/scripts/sprint_close_lib/record_find.py b/plugins/ndf/scripts/sprint_close_lib/record_find.py index c180310b3..7db527b4b 100644 --- a/plugins/ndf/scripts/sprint_close_lib/record_find.py +++ b/plugins/ndf/scripts/sprint_close_lib/record_find.py @@ -91,6 +91,43 @@ def _referencing_prs(root, repo: str, n: int) -> list[dict]: return list(out.values()) +def _closers_of(root, repo: str, issues: tuple[int, ...]) -> dict[int, set[int]]: + """まとめの課題ごとに、その課題を閉じる PR の集合。閉じる PR が見つからない課題は含めない。""" + closers_of: dict[int, set[int]] = {} + for n in issues: + hit = {pr["number"] for pr in _referencing_prs(root, repo, n) if (repo, n) in closing_refs(pr["body"], repo)} + if hit: + closers_of[n] = hit + return closers_of + + +def _candidates(root, repo: str, closers: tuple[int, ...]) -> set[int]: + """閉じる PR と、それを参照する PR の和。""" + cands = set(closers) + for pr in closers: + cands |= {r["number"] for r in _referencing_prs(root, repo, pr)} + return cands + + +def _released_records(root, repo: str, cands: set[int], closers_of: dict[int, set[int]]) -> tuple[list, list[int]]: + """候補ごとに最後のリリース記録を読み、(本番か配布なしで閉じる PR を含む記録, 本番の前の記録の PR) に振り分ける。""" + kept, pre = [], [] + for pr in sorted(cands): + try: + rec = parse_record(*read_record(root, repo, pr)) + except StepError as e: + raise _Unreadable(str(e)) + if not rec["found"]: + continue + if not released(rec["stage"]): + pre.append(pr) + continue + sprint = set(rec["sprint_prs"]) + if all(sprint & want for want in closers_of.values()): + kept.append((rec["dist_comment_id"] or 0, pr, tuple(sorted(sprint)))) + return kept, pre + + def find_record(root, repo: str | None, issues) -> RecordHit: """まとめの課題 `issues` のリリース記録を置いた PR を決める。`root` は `gh` を打つ作業場所、`repo` は owner/name(None なら `root` から決める)。""" issues = tuple(int(n) for n in issues) @@ -98,31 +135,12 @@ def find_record(root, repo: str | None, issues) -> RecordHit: return RecordHit(error="まとめの課題が無い") try: repo = repo or _repo_of(root) - closers_of: dict[int, set[int]] = {} - for n in issues: - hit = {pr["number"] for pr in _referencing_prs(root, repo, n) if (repo, n) in closing_refs(pr["body"], repo)} - if hit: - closers_of[n] = hit + closers_of = _closers_of(root, repo, issues) closers = tuple(sorted(set().union(*closers_of.values()))) if closers_of else () if not closers: return RecordHit(issues=issues) - cands = set(closers) - for pr in closers: - cands |= {r["number"] for r in _referencing_prs(root, repo, pr)} - kept, pre = [], [] - for pr in sorted(cands): - try: - rec = parse_record(*read_record(root, repo, pr)) - except StepError as e: - raise _Unreadable(str(e)) - if not rec["found"]: - continue - if not released(rec["stage"]): - pre.append(pr) - continue - sprint = set(rec["sprint_prs"]) - if all(sprint & want for want in closers_of.values()): - kept.append((rec["dist_comment_id"] or 0, pr, tuple(sorted(sprint)))) + cands = _candidates(root, repo, closers) + kept, pre = _released_records(root, repo, cands, closers_of) except _Unreadable as e: return RecordHit(issues=issues, error=str(e)) base = {"issues": issues, "closers": closers, "searched": tuple(sorted(cands)), "pre_release": tuple(pre)} From 658e5e3032f4b633bf1ac2483da19e82cfeb839a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:41:25 +0000 Subject: [PATCH 26/46] =?UTF-8?q?Refactor:=20consolidate=5Fduplication=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/supervise=5Flib/release=5Ftempl?= =?UTF-8?q?ates.py#release=5Fversion=5Fof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 開発版から接尾辞を外す正規表現が release_version_of と sprint.prod_version に別々に書かれていたため、 release_templates に strip_suffix を 1 つ置いて両方から呼ぶ形にした。versions.release_base(semver)は 形の違う版で例外を投げ、ビルドメタデータも落とすため振る舞いが変わるので使わない。 sprint.py で使われなくなった re の import を消した。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/release_templates.py | 7 ++++++- plugins/ndf/scripts/supervise_lib/sprint.py | 5 ++--- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index 4a6addca6..474dc789f 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -50,10 +50,15 @@ def release_names(version: str | None) -> tuple[str, str]: return f".worktrees/release/v{v}", f"release/v{v}" +def strip_suffix(v: str) -> str: + """開発版の版数から接尾辞(最初の `-` から後ろ)を外す。""" + return re.sub(r"-.*$", "", v) + + def release_version_of(a) -> tuple[str, str]: """(出す版, その接尾辞を外した版)。明示の版が無ければ版の差し込み(#2164)。""" v = getattr(a, "version", None) - return (v, re.sub(r"-.*$", "", v)) if v else (RELEASE_VERSION, RELEASE_BASE) + return (v, strip_suffix(v)) if v else (RELEASE_VERSION, RELEASE_BASE) def version_field(a, plugin: str, dev: bool) -> dict: diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index 65d83ff48..9ed641eb0 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -3,7 +3,6 @@ from __future__ import annotations import json -import re import shlex from pathlib import Path @@ -32,7 +31,7 @@ from supervise_lib.new_args import NEW_ARGS from supervise_lib.paths import CHECK_PY, SELF from supervise_lib.procedures import sprint_out, with_record -from supervise_lib.release_templates import release_verify_step +from supervise_lib.release_templates import release_verify_step, strip_suffix from supervise_lib.plan import sprint_state_path from supervise_lib.sprint_routes import ( normal_gate_1, @@ -53,7 +52,7 @@ def prod_version(version: str | None) -> str | None: """明示の開発版から本番の明示の版を作る。開発版が無ければ None(本番は開発版のプランの版の記録から読む。#2164)。""" - return re.sub(r"-.*$", "", version) if version else None + return strip_suffix(version) if version else None DESIGN_RESULTS_STAGE = "設計の結果" From 9e28a3c4ec8e62a4cffb039231605cb7110ed916 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:53:25 +0000 Subject: [PATCH 27/46] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/sprint=5Floop.py#cmd=5Fsprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出した sprint_pipeline でファイルが 500 行を超えたため、wait_lap と同じ sprint_loop_gate.py へ移し、sprint_loop.py からは読み込んで使う(sl.sprint_pipeline の参照は変わらない)。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/lib/sprint_loop.py | 16 +--------------- plugins/ndf/scripts/lib/sprint_loop_gate.py | 15 +++++++++++++++ 2 files changed, 16 insertions(+), 15 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 73aca8a91..d0baf4ef2 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -61,6 +61,7 @@ loop_ok, select_cmd, wait_lap, + sprint_pipeline, refusal_order, build_close, refused_stop, @@ -298,21 +299,6 @@ def new_sprint_argv(root: str, rec: dict, lap: dict, extra: list[str]) -> list[s return new -def sprint_pipeline(index: list[dict], close: list[str], done: Path, rec: dict) -> tuple[str, str]: - """manifest のステージ配列から queue と pipeline の文字列を組む。最初の command の後に手で行うステージか - 別の command(単独の queue の承認ゲート 2)があれば、それを終えるまでまとめへ進めない。""" - first = next(e for e in index if e.get("command")) - hands = [e for e in index[index.index(first) + 1 :] if e.get("command") or "manual" in e] # 関門は queue と loop gate が扱う - if not hands: - queue = first["command"] + " --then " + " ".join(map(shlex.quote, close)) + f" --done {shlex.quote(str(done))}" - return queue, f"{queue} && {wait_lap(rec, done)}" - queue = ( - f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, close)) + f" --max 3 --done {shlex.quote(str(done))}" - ) - todo = " → ".join(e.get("command") or f"{e['name']}({e['manual']} で行う)" for e in hands) - return queue, f"{first['command']} を流し、続けて {todo} を終えてから: {queue} && {wait_lap(rec, done)}" - - def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra: list[str]) -> dict: with open_record(env, name) as (rec, before): lap = current_lap(rec) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 2c841de36..4eec68cdc 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -67,6 +67,21 @@ def wait_lap(rec: dict, done: Path) -> str: return f"python3 {shlex.quote(str(SUPERVISE))} wait {shlex.quote(str(done))} && python3 {shlex.quote(str(SPRINT_STATE))} loop lap {rec['name']} --done {shlex.quote(str(done))}" +def sprint_pipeline(index: list[dict], close: list[str], done: Path, rec: dict) -> tuple[str, str]: + """manifest のステージ配列から queue と pipeline の文字列を組む。最初の command の後に手で行うステージか + 別の command(単独の queue の承認ゲート 2)があれば、それを終えるまでまとめへ進めない。""" + first = next(e for e in index if e.get("command")) + hands = [e for e in index[index.index(first) + 1 :] if e.get("command") or "manual" in e] # 関門は queue と loop gate が扱う + if not hands: + queue = first["command"] + " --then " + " ".join(map(shlex.quote, close)) + f" --done {shlex.quote(str(done))}" + return queue, f"{queue} && {wait_lap(rec, done)}" + queue = ( + f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, close)) + f" --max 3 --done {shlex.quote(str(done))}" + ) + todo = " → ".join(e.get("command") or f"{e['name']}({e['manual']} で行う)" for e in hands) + return queue, f"{first['command']} を流し、続けて {todo} を終えてから: {queue} && {wait_lap(rec, done)}" + + def refusal_order(summary: str) -> dict: return {**step_result.result(TOOL, "stopped", summary, [], {"status": "refused"}), "exit": EXIT_REFUSED} From 55507b429b0a04987e6f1d16263577bc4292aa1a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:53:25 +0000 Subject: [PATCH 28/46] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/sprint=5Fclose=5Flib/record=5Ffind.py#find=5Fr?= =?UTF-8?q?ecord?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出した _candidates が backlog-refinement の upkeep_rank.py の同名関数と衝突したため、_record_candidates へ改名した。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/sprint_close_lib/record_find.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/sprint_close_lib/record_find.py b/plugins/ndf/scripts/sprint_close_lib/record_find.py index 7db527b4b..626245ab9 100644 --- a/plugins/ndf/scripts/sprint_close_lib/record_find.py +++ b/plugins/ndf/scripts/sprint_close_lib/record_find.py @@ -101,7 +101,7 @@ def _closers_of(root, repo: str, issues: tuple[int, ...]) -> dict[int, set[int]] return closers_of -def _candidates(root, repo: str, closers: tuple[int, ...]) -> set[int]: +def _record_candidates(root, repo: str, closers: tuple[int, ...]) -> set[int]: """閉じる PR と、それを参照する PR の和。""" cands = set(closers) for pr in closers: @@ -139,7 +139,7 @@ def find_record(root, repo: str | None, issues) -> RecordHit: closers = tuple(sorted(set().union(*closers_of.values()))) if closers_of else () if not closers: return RecordHit(issues=issues) - cands = _candidates(root, repo, closers) + cands = _record_candidates(root, repo, closers) kept, pre = _released_records(root, repo, cands, closers_of) except _Unreadable as e: return RecordHit(issues=issues, error=str(e)) From 3fcdd276c7557e9eb02e7302574f9207e7f84515 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:54:50 +0000 Subject: [PATCH 29/46] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop.py#gate2?= =?UTF-8?q?=5Fanswer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/tests/test_sprint_loop.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 0c470f19b..51925f6f9 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -989,3 +989,75 @@ def test_lap_with_a_plan_skipped_after_a_finished_stage_is_still_done(env): ] sl.cmd_lap(env, "l1", False, write_done(env, items), []) assert rec(env)["laps"][0]["outcome"] == sl.DONE + + +# ---------------------------------------------------------------- gate2_answer の現状固定(I-004) + + +def g2_lap(tmp_path, gates=None): + state = tmp_path / "state.json" + state.write_text(json.dumps({"gates": gates or []})) + return {"state_path": str(state)}, str(state.resolve()) + + +def g2_ledger(*rows, raw=None): + import gate_rejections as gr + + p = gr.ledger_path() + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(raw if raw is not None else "".join(json.dumps(r, ensure_ascii=False) + "\n" for r in rows)) + + +def test_gate2_answer_is_none_without_a_state_path_or_answers(tmp_path): + assert sl.gate2_answer({}) is None + assert sl.gate2_answer({}, "2026-10-01T00:00:00Z") is None + lap, _ = g2_lap(tmp_path) + assert sl.gate2_answer(lap) is None + assert sl.gate2_answer({"state_path": str(tmp_path / "missing.json")}) is None + + +def test_gate2_answer_reads_user_approvals_of_gate_2_only_after_since(tmp_path): + gates = [ + {"name": "関門 2", "by": "mvv", "at": "2026-10-05T00:00:00Z"}, # MVV の通過は数えない + {"name": "関門 1", "by": "user", "at": "2026-10-05T00:00:00Z"}, # 別の関門は数えない + {"name": "関門 2", "by": "user", "at": "2026-10-01T00:00:00Z"}, # since より前 + ] + lap, _ = g2_lap(tmp_path, gates) + assert sl.gate2_answer(lap, "2026-10-02T00:00:00Z") is None + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" # 同じ時刻は数える + assert sl.gate2_answer(lap) == "approved" + lap2, _ = g2_lap(tmp_path, [{"name": "関門 2", "by": "user"}]) # 時刻の無い答え + assert sl.gate2_answer(lap2) == "approved" + assert sl.gate2_answer(lap2, "2026-10-01T00:00:00Z") is None + + +def test_gate2_answer_reads_rejections_of_the_same_sprint_from_the_ledger(tmp_path): + lap, sp = g2_lap(tmp_path) + g2_ledger( + {"gate": "関門 2", "sprint": sp, "at": "2026-10-03T00:00:00Z"}, # outcome の無い行は差し戻し + {"gate": "関門 2", "sprint": sp, "at": "2026-10-09T00:00:00Z", "outcome": "approved"}, # 承認の行は読まない + {"gate": "関門 1", "sprint": sp, "at": "2026-10-09T00:00:00Z"}, + {"gate": "関門 2", "sprint": "/other.json", "at": "2026-10-09T00:00:00Z"}, + ) + assert sl.gate2_answer(lap, "2026-10-02T00:00:00Z") == "rejected" + assert sl.gate2_answer(lap, "2026-10-04T00:00:00Z") is None + assert sl.gate2_answer(lap) == "rejected" + + +def test_gate2_answer_takes_the_latest_answer(tmp_path): + lap, sp = g2_lap(tmp_path, [{"name": "関門 2", "by": "user", "at": "2026-10-05T00:00:00Z"}]) + g2_ledger({"gate": "関門 2", "sprint": sp, "at": "2026-10-04T00:00:00Z", "outcome": "rejected"}) + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" + g2_ledger({"gate": "関門 2", "sprint": sp, "at": "2026-10-06T00:00:00Z", "outcome": "rejected"}) + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "rejected" + g2_ledger({"gate": "関門 2", "sprint": sp, "at": "2026-10-05T00:00:00Z", "outcome": "rejected"}) # 同じ時刻は先の承認 + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" + + +def test_gate2_answer_treats_a_broken_ledger_as_empty(tmp_path): + lap, _ = g2_lap(tmp_path, [{"name": "関門 2", "by": "user", "at": "2026-10-05T00:00:00Z"}]) + g2_ledger(raw="{壊れた行\n") + assert sl.gate2_answer(lap, "2026-10-01T00:00:00Z") == "approved" + (tmp_path / "x").mkdir() + lap2, _ = g2_lap(tmp_path / "x") + assert sl.gate2_answer(lap2) is None From df164e862ddf4ae75790d5d0cbd4179772428e3e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:55:35 +0000 Subject: [PATCH 30/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/sprint=5Floop.py#gate2=5Fanswer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承認と差し戻しの時系列から最新の答えを選ぶ部分を latest_gate2_answer(gates, rejections, since) として 抽出した。gate2_answer には状態ファイルと差し戻しログを読み、同じ周回・関門 2 の差し戻しに絞る部分だけを残す。 fresh と EPOCH の規則、同じ時刻は承認を採る順序、JsonReadError を空とする扱いはそのまま。 Item-Id: I-004 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop.py | 27 +++++++++++++++----------- 1 file changed, 16 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index d0baf4ef2..dce2d16a3 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -223,6 +223,20 @@ def resume_stopped(env: Env, rec: dict, before: dict) -> dict: def gate2_answer(lap: dict, since: str | None = None) -> str | None: """承認ゲート 2 の答え: 止まった時刻(since)より後の、周回の状態の利用者の承認と差し戻しの記録のうち最新のもの。 approved / rejected、どちらも無ければ None。since より前の答え(同じ周回の前の関門への答え)は数えない。""" + path = lap.get("state_path") or "" + state = jsonio.read(path, missing={}, broken={}, want=dict) if path else {} + try: + rows = gr.read_rejections() + except jsonio.JsonReadError: + rows = [] + sp = str(Path(path).resolve()) if path else None + # 承認の行(#2183)は状態ファイルの gates の方で読む + rejections = [r for r in rows if gr.is_rejection(r) and r.get("gate") == GATE_2 and r.get("sprint") == sp] + return latest_gate2_answer(state.get("gates", []), rejections, since) + + +def latest_gate2_answer(gates: list[dict], rejections: list[dict], since: str | None) -> str | None: + """関門 2 の利用者の承認(gates)と差し戻し(rejections)のうち、since 以後で最新のもの。同じ時刻は承認を採る。""" after = clock.parse(since, naive="utc") if since else None def fresh(at): @@ -231,17 +245,8 @@ def fresh(at): return t or EPOCH return t if t is not None and t >= after else None - state = jsonio.read(lap.get("state_path") or "", missing={}, broken={}, want=dict) if lap.get("state_path") else {} - answers = [ - (k, "approved") for g in state.get("gates", []) if g.get("name") == GATE_2 and g.get("by") != "mvv" and (k := fresh(g.get("at"))) - ] - try: - rows = gr.read_rejections() - except jsonio.JsonReadError: - rows = [] - sp = str(Path(lap.get("state_path") or "").resolve()) if lap.get("state_path") else None - rows = [r for r in rows if gr.is_rejection(r)] # 承認の行(#2183)は状態ファイルの gates の方で読む - answers += [(k, "rejected") for r in rows if r.get("gate") == GATE_2 and r.get("sprint") == sp and (k := fresh(r.get("at")))] + answers = [(k, "approved") for g in gates if g.get("name") == GATE_2 and g.get("by") != "mvv" and (k := fresh(g.get("at")))] + answers += [(k, "rejected") for r in rejections if (k := fresh(r.get("at")))] return max(answers, key=lambda a: a[0])[1] if answers else None From 5cf9281b9e68af851c122201c5e78e86eaec2405 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:56:19 +0000 Subject: [PATCH 31/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/sprint-close.py#=5Fclosing=5Fblocker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit kept_all を段階的に上書きする if/elif 連鎖を、配布の段階から閉じられない理由を返す _stage_blocker と、 リリース後テストの記録から (理由, verdicts) を返す _verify_verdicts に分けた。_closing_blocker は _stage_blocker → (理由が無く、リリース後テストを見るとき) _verify_verdicts の 2 段だけにした。 Item-Id: I-005 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/sprint-close.py | 55 +++++++++++++++++------------ 1 file changed, 32 insertions(+), 23 deletions(-) diff --git a/plugins/ndf/scripts/sprint-close.py b/plugins/ndf/scripts/sprint-close.py index bda70fb25..feadede8c 100755 --- a/plugins/ndf/scripts/sprint-close.py +++ b/plugins/ndf/scripts/sprint-close.py @@ -212,32 +212,41 @@ def _read_sprint_record(a, root, record_repo): return rec, a.prs or rec["sprint_prs"] -def _closing_blocker(a, rec, record_repo): +def _stage_blocker(a, rec): + """本番の記録の PR と配布の段階から、閉じられない理由を返す。閉じてよければ None。""" stage = rec["stage"] or "" - kept_all = None if a.record_pr == 0: - pass # 本番の記録なし: 閉じる条件(配布とリリース後テスト)を見ない - elif a.record_pr == RECORD_UNKNOWN: - kept_all = UNKNOWN_REASON # 記録を読めないので閉じない(#1875) - elif not rec["found"] or not stage: - kept_all = "配布の記録が読めない" - elif not released(stage): - kept_all = "本番への配布の前(本番への配布の後に閉じる)" - verdicts = None + return None # 本番の記録なし: 閉じる条件(配布とリリース後テスト)を見ない + if a.record_pr == RECORD_UNKNOWN: + return UNKNOWN_REASON # 記録を読めないので閉じない(#1875) + if not rec["found"] or not stage: + return "配布の記録が読めない" + if not released(stage): + return "本番への配布の前(本番への配布の後に閉じる)" + return None + + +def _verify_verdicts(rec, record_repo): + """リリース後テストの記録から (閉じられない理由か None, verdicts) を返す。""" + blk = rec["verify_block"] + if blk is None: + stage = rec["stage"] or "" + return ( + "配布なしの後のリリース後テストの記録が無い" if stage.startswith("配布なし") else "本番の版のリリース後テストの記録が無い" + ), None + verdicts = verification_verdicts(blk, record_repo) + if verdicts is not None: + return None, verdicts + # 課題を指す行が無い表: すべての条件をマージ前に確かめた課題だけ判定へ進める(#2144) + return NO_MAPPING, ({} if acceptance.verdict_table_readable(blk) else None) + + +def _closing_blocker(a, rec, record_repo): + kept_all = _stage_blocker(a, rec) 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 = ( - "配布なしの後のリリース後テストの記録が無い" if stage.startswith("配布なし") else "本番の版のリリース後テストの記録が無い" - ) - else: - verdicts = verification_verdicts(blk, record_repo) - if verdicts is None: - kept_all = NO_MAPPING - if acceptance.verdict_table_readable(blk): - verdicts = {} # 課題を指す行が無い表: すべての条件をマージ前に確かめた課題だけ判定へ進める(#2144) - return kept_all, verdicts + if kept_all is not None or not verify or a.record_pr == 0: + return kept_all, None + return _verify_verdicts(rec, record_repo) def _read_state_body(root, repo, n): From 4375d8de624ee41f3b3205d72053f34ad211f531 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:58:34 +0000 Subject: [PATCH 32/46] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/supervise=5Flib/queue=5Ffill.py?= =?UTF-8?q?#=5Ffill=5Fdev?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 記録がある開発版の確かめ直し(出ているか → 自分の配布か → 明示か → 決め直し)を _recheck_dev へ抽出し、早期 return で入れ子を平らにした。_fill_dev は記録の有無で 2 つの経路を選ぶだけになる。振る舞いは変えない。 Item-Id: I-006 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/supervise_lib/queue_fill.py | 29 +++++++++++++------ 1 file changed, 20 insertions(+), 9 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/queue_fill.py b/plugins/ndf/scripts/supervise_lib/queue_fill.py index d90de2819..f7e0ed40a 100644 --- a/plugins/ndf/scripts/supervise_lib/queue_fill.py +++ b/plugins/ndf/scripts/supervise_lib/queue_fill.py @@ -284,6 +284,23 @@ def approved(self) -> bool: return any(e.get("id") in GATE_2_STEPS and e.get("exit") == 0 for e in state.get("log") or [] if isinstance(e, dict)) +def _recheck_dev(f: _Fill, obs, rec: dict): + """記録がある開発版の版を確かめ直す。(使う記録, 止める理由) を返す。止めるときは記録が None。""" + import release_version + + d = release_version.taken(obs, rec["version"], rec.get("how") or "decided", f.level) + if not d.is_taken: + return rec, None + own = release_version.own_released(f.repo, f.branch_of(rec["version"]), f.base) + if own is None: + return None, _stopped("unreadable", rec["version"], unreadable=["pr"]) + if own: + return rec, None + if rec.get("how") == "explicit": + return None, _stopped("taken", d.version, d.existing, d.next_free) + return f.redecide(rec, release_version.decide_dev(obs, f.level).version, "taken", d.existing, obs.as_dict()), None + + def _fill_dev(f: _Fill, rec: dict | None): import release_version @@ -296,15 +313,9 @@ def _fill_dev(f: _Fill, rec: dict | None): return _stopped("taken", d.version, d.existing, d.next_free) rec = f.write_record(d.version, d.how, obs.as_dict()) else: - d = release_version.taken(obs, rec["version"], rec.get("how") or "decided", f.level) - if d.is_taken: - own = release_version.own_released(f.repo, f.branch_of(rec["version"]), f.base) - if own is None: - return _stopped("unreadable", rec["version"], unreadable=["pr"]) - if not own: - if rec.get("how") == "explicit": - return _stopped("taken", d.version, d.existing, d.next_free) - rec = f.redecide(rec, release_version.decide_dev(obs, f.level).version, "taken", d.existing, obs.as_dict()) + rec, stopped = _recheck_dev(f, obs, rec) + if stopped: + return stopped f.ensure_filled(rec) return None, None From b7171f114702186afbd0cceb49a085960462eafb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:59:17 +0000 Subject: [PATCH 33/46] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/release=5Fversion.py#bum?= =?UTF-8?q?p=5Fcheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit タグの無い正式版がベースブランチの版以下のときに終了コード 3 で止め、次に空いている版を ベースブランチの次のパッチとする経路を固定する(既存テストが通っていなかった経路)。 Item-Id: I-007 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/tests/test_release_steps.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_release_steps.py b/plugins/ndf/scripts/tests/test_release_steps.py index 805b8153d..05cc8a195 100644 --- a/plugins/ndf/scripts/tests/test_release_steps.py +++ b/plugins/ndf/scripts/tests/test_release_steps.py @@ -615,6 +615,14 @@ def test_bump_to_a_release_with_a_tag_stops(repo): assert p.returncode == 3 and res["metrics"]["existing"] == "ndf--v1.2.4" and res["metrics"]["next_free"] == "1.2.5" +def test_bump_to_a_release_below_the_base_without_a_tag_stops(repo): + """現状固定: タグの無い正式版でもベースブランチの版以下なら止め、次に空いている版はベースブランチの次のパッチ。""" + bump_repo(repo, "1.2.5", "1.2.5") + p = run(repo, "bump", "--plugin", "ndf", "--to", "1.2.4") + res = last_json(p) + assert p.returncode == 3 and res["metrics"]["existing"] == "1.2.5" and res["metrics"]["next_free"] == "1.2.6", p.stdout + + def test_bump_at_the_base_is_a_rerun_only_after_its_own_release(repo, tmp_path): """作業場所とベースブランチがともに新しい版なら、release/v<版> のマージ済みの PR があるときだけ打ち直し(決定 2)。""" bump_repo(repo, "1.2.4", "1.2.4") From ad2116c9533df7ded669e6dbaef1c43b83e72eca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 05:59:45 +0000 Subject: [PATCH 34/46] =?UTF-8?q?Refactor:=20flatten=5Fconditional=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/release=5Fversion.py#bump?= =?UTF-8?q?=5Fcheck?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit old == new のときの打ち直しの判定を _rerun へ、止めるときの既に出ている版と次に空いている 版の組み立てを _taken_stop へ抽出した。bump_check は形の検査 → 観測 → 打ち直し → 書き換えてよいかの判定 → 止める理由の直線になる。振る舞いは変えない。 Item-Id: I-007 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/release_version.py | 33 +++++++++++++++------- 1 file changed, 23 insertions(+), 10 deletions(-) diff --git a/plugins/ndf/scripts/lib/release_version.py b/plugins/ndf/scripts/lib/release_version.py index bf6cc51bb..632db0106 100644 --- a/plugins/ndf/scripts/lib/release_version.py +++ b/plugins/ndf/scripts/lib/release_version.py @@ -188,18 +188,31 @@ def bump_check(repo: str | Path, plugin: str, old: str, new: str, base: str, rel why = f"版を確かめる値を読めない: {'・'.join(obs.unreadable)}。推定で上げない" return None, BumpStop(why, {**metrics, "unreadable": obs.unreadable}, 2) cmp = versions.compare_versions(obs.base_version, new) - if old == new and cmp < 0: - return obs.base_version, None - if old == new and cmp == 0: - own = own_released(repo, f"release/v{new}", base) - if own is None: - why = f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない" - return None, BumpStop(why, {**metrics, "unreadable": ["pr"]}, 2) - if own: - return obs.base_version, None + if old == new: + rerun = _rerun(repo, new, base, obs, cmp, metrics) + if rerun is not None: + return rerun d = taken(obs, new) if cmp < 0 and not d.is_taken: return None, None + return None, _taken_stop(plugin, new, obs, cmp, d, metrics) + + +def _rerun(repo: str | Path, new: str, base: str, obs: Observed, cmp: int, metrics: dict) -> tuple[str | None, BumpStop | None] | None: + """作業場所がすでに新しい版のときの打ち直しの判定。打ち直しか止めるなら (already, stop)、どちらでもなければ None。""" + if cmp < 0: + return obs.base_version, None + if cmp > 0: + return None + own = own_released(repo, f"release/v{new}", base) + if own is None: + why = f"release/v{new} → {base} のマージ済みの PR を照会できない。推定で進めない" + return None, BumpStop(why, {**metrics, "unreadable": ["pr"]}, 2) + return (obs.base_version, None) if own else None + + +def _taken_stop(plugin: str, new: str, obs: Observed, cmp: int, d: Decision, metrics: dict) -> BumpStop: + """新しい版がすでに出ているときに止める理由(既に出ている版と次に空いている版)。""" existing = obs.base_version if cmp >= 0 else d.existing if d.is_taken: nxt = d.next_free @@ -208,4 +221,4 @@ def bump_check(repo: str | Path, plugin: str, old: str, new: str, base: str, rel else: nxt = versions.next_patch(obs.base_version) why = f"{plugin} の {new} はすでに出ている({existing})。版を戻さない(次に空いている版は {nxt})" - return None, BumpStop(why, {**metrics, "existing": existing, "next_free": nxt}, 3) + return BumpStop(why, {**metrics, "existing": existing, "next_free": nxt}, 3) From e633eca3a06bba002a4c71d533c453bf57165723 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:01:36 +0000 Subject: [PATCH 35/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/lib/sprint=5Floop.py#cmd=5Fsprint?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit まとめの queue のコマンドの組み立て(gate2_pipeline・sprint_pipeline・continuation の 3 か所)を sprint_loop_gate.queue_command へ抽出し、--max 3 を QUEUE_MAX にまとめた。 止まった記録を stops へ移して running に戻す 2 行(resume_stopped と cmd_sprint)を resume_record へ抽出した。出力の文字列は変わらない。 Item-Id: I-008 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop.py | 16 ++++++++++------ plugins/ndf/scripts/lib/sprint_loop_gate.py | 16 +++++++++++----- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index dce2d16a3..6b49626c0 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -62,6 +62,7 @@ select_cmd, wait_lap, sprint_pipeline, + queue_command, refusal_order, build_close, refused_stop, @@ -170,6 +171,12 @@ def start_new(env: Env, name: str, decl: dict, project, milestones: list[str], b return loop_ok(f"開発ループ {name} を始めた", rec, status="new", next_=select_cmd(name)) +def resume_record(env: Env, rec: dict) -> None: + """止まった記録を解く: stop を再開の時刻つきで stops へ移し、running に戻す。""" + rec["stops"].append({**rec["stop"], "resumed_at": env.now()}) + rec["stop"], rec["status"] = None, "running" + + def resume_stopped(env: Env, rec: dict, before: dict) -> dict: """止まった理由ごとに解けたかを見る(I7)。解ければ stop を stops へ移す。""" stop, name = rec["stop"], rec["name"] @@ -210,8 +217,7 @@ def resume_stopped(env: Env, rec: dict, before: dict) -> dict: nxt = f"{queue} && {wait_lap(rec, done)}" elif cond == "refused" and lap: nxt = f"python3 {shlex.quote(str(SPRINT_STATE))} loop sprint {name} --mode <モード> --dev <開発版>(モードと版を決め直す)" - rec["stops"].append({**stop, "resumed_at": env.now()}) - rec["stop"], rec["status"] = None, "running" + resume_record(env, rec) write_record(env, rec, before) update_handoff(env, rec, f"再開した({cond} が解けた)", resume_command(rec), nxt) return loop_ok(f"開発ループ {name} を再開した({cond} が解けた)", rec, status="resumed", next_=nxt, resolved=cond) @@ -263,8 +269,7 @@ def gate2_pipeline(env: Env, rec: dict, lap: dict, stop: dict) -> str: for s in stages_after(lap, plan) ] stages = [s for s in rest if s] + [close] - queue = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + " --max 3" - queue += "".join(" --then " + " ".join(map(shlex.quote, s)) for s in stages[1:]) + f" --done {shlex.quote(str(done))}" + queue = queue_command(stages, done) lap.setdefault("dones", []).append(str(done)) step = gate2_resume_step(plan) if step is None: @@ -342,8 +347,7 @@ def cmd_sprint(env: Env, name: str, mode: str, dev: str, prod: str | None, extra queue, pipeline = sprint_pipeline(index, close, done, rec) lap["dones"] = [str(done)] if rec.get("status") == "stopped": # refused からのやり直し - rec["stops"].append({**rec["stop"], "resumed_at": env.now()}) - rec["stop"], rec["status"] = None, "running" + resume_record(env, rec) write_record(env, rec, before) update_handoff(env, rec, f"周回 {lap['n']} のスプリントを組んだ。pipeline を流す", resume_command(rec), pipeline) return loop_ok( diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 4eec68cdc..a94127064 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -63,6 +63,15 @@ def select_cmd(name: str) -> str: return f"python3 {shlex.quote(str(SPRINT_STATE))} loop select {name}" +QUEUE_MAX = 3 # まとめの queue の同時に流す計画の上限(supervise.py queue --max) + + +def queue_command(stages: list[list[str]], done: Path) -> str: + """まとめの queue の 1 行: 先頭のステージを --max で流し、残りのステージを --then でつなぎ、--done で終わる。""" + head = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + f" --max {QUEUE_MAX}" + return head + "".join(" --then " + " ".join(map(shlex.quote, s)) for s in stages[1:]) + f" --done {shlex.quote(str(done))}" + + def wait_lap(rec: dict, done: Path) -> str: return f"python3 {shlex.quote(str(SUPERVISE))} wait {shlex.quote(str(done))} && python3 {shlex.quote(str(SPRINT_STATE))} loop lap {rec['name']} --done {shlex.quote(str(done))}" @@ -75,9 +84,7 @@ def sprint_pipeline(index: list[dict], close: list[str], done: Path, rec: dict) if not hands: queue = first["command"] + " --then " + " ".join(map(shlex.quote, close)) + f" --done {shlex.quote(str(done))}" return queue, f"{queue} && {wait_lap(rec, done)}" - queue = ( - f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, close)) + f" --max 3 --done {shlex.quote(str(done))}" - ) + queue = queue_command([close], done) todo = " → ".join(e.get("command") or f"{e['name']}({e['manual']} で行う)" for e in hands) return queue, f"{first['command']} を流し、続けて {todo} を終えてから: {queue} && {wait_lap(rec, done)}" @@ -354,8 +361,7 @@ def continuation(env: Env, rec: dict, lap: dict, moved: list[int], kept: list[in stages.append(build_close(env, rec, lap, kept, tag=tag)) done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" lap.setdefault("dones", []).append(str(done)) - head = f"python3 {shlex.quote(str(SUPERVISE))} queue " + " ".join(map(shlex.quote, stages[0])) + " --max 3" - return head + "".join(" --then " + " ".join(map(shlex.quote, s)) for s in stages[1:]) + f" --done {shlex.quote(str(done))}", done + return queue_command(stages, done), done GATE_2_RESUME_STEPS = ("bump", "promote-approved", "merge-approved") # 承認ゲート 2 の承認の後に入るステップ(pace.md の続け方) From 21e70ab00eb7071d4003caf9a6122d37e3354c05 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:02:30 +0000 Subject: [PATCH 36/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/release-steps.py#cmd=5Frelease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 開発版の PR の使い回し・作成とマージを _merge_release_pr へ、本番の PR の作成と承認した先端を 指定したマージの再試行(HEAD_MOVED_RETRIES・承認ゲートでの停止)を _merge_prod_pr へ抽出した。 cmd_release は前提検査・push・各段階の呼び出し・タグ公開・結果の組み立てを担う。 副作用の順序と出力は変わらない。 Item-Id: I-009 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/release-steps.py | 71 +++++++++++++++++----------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index 89d9cc502..d99351328 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -702,6 +702,47 @@ def _release_pr_body(root, a, plugin, plugins): ) +def _merge_release_pr(root, a, plugin, plugins, branch, base): + """開発版の PR(release/v<版> → ベースブランチ)を使い回すか作ってマージし、PR 番号とマージコミットを返す。""" + ver = a.version + pr = _reusable_release_pr(root, branch, base) + if pr is None: + body = _release_pr_body(root, a, plugin, plugins) + pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{ver}", body), "state": "OPEN"} + release_pr = pr["number"] + release_commit = wait_and_merge(root, release_pr, ci_wait=a.ci_wait) if pr["state"] == "OPEN" else merge_commit_of(root, release_pr) + return release_pr, release_commit + + +def _merge_prod_pr(root, a, plugin, base, prod, release_pr, approved, allowed, verdict, items, metrics): + """本番の PR(ベースブランチ → 本番チャネル)を使い回すか作り、承認と比べた先端を指定してマージする。 + 先端が動いたら比べ直し、許可リストの下に収まる限り HEAD_MOVED_RETRIES 回まで入れ直す。収まらないか + 回数を使い切ったら承認ゲートで止める。本番の PR 番号・マージコミット・最後に比べた判定を返す。""" + ver = a.version + mp = find_pr(root, base, prod, states=("OPEN",)) + main_pr = ( + mp["number"] + if mp + else create_pr( + root, + prod, + base, + f"Release: {plugin} v{ver} を {prod} へ", + f"{plugin} v{ver} を {prod} へ出す(開発版の PR #{release_pr})。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)", + ) + ) + for left in range(HEAD_MOVED_RETRIES, -1, -1): + try: + merge = wait_and_merge(root, main_pr, expect=verdict.tip, ci_wait=a.ci_wait) # 比べた先端だけを本番チャネルへ入れる + break + except HeadMoved: + moved = approval.check_approved(root, approved, base, allowed) + if not moved.ok or not left: + emit(approval.gate(root, a, plugin, base, moved, items, metrics, moved=not left), EXIT_GATE) + verdict = moved # 進んだ分が許可リストの下に収まるので、比べ直した先端で入れ直す(#2221) + return main_pr, merge, verdict + + def cmd_release(a): root = git_root(a.root) ver = a.version @@ -719,13 +760,7 @@ def cmd_release(a): git(root, "push", "-q", "-u", "origin", "HEAD") - # 開発版の PR(release/v<版> → ベースブランチ) - pr = _reusable_release_pr(root, branch, base) - if pr is None: - body = _release_pr_body(root, a, plugin, plugins) - pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{ver}", body), "state": "OPEN"} - release_pr = pr["number"] - release_commit = wait_and_merge(root, release_pr, ci_wait=a.ci_wait) if pr["state"] == "OPEN" else merge_commit_of(root, release_pr) + release_pr, release_commit = _merge_release_pr(root, a, plugin, plugins, branch, base) metrics = { "channel": a.channel, @@ -751,27 +786,7 @@ def cmd_release(a): if not verdict.ok: emit(approval.gate(root, a, plugin, base, verdict, items, metrics), EXIT_GATE) metrics.update({"approved_sha": approved, "compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) - mp = find_pr(root, base, prod, states=("OPEN",)) - main_pr = ( - mp["number"] - if mp - else create_pr( - root, - prod, - base, - f"Release: {plugin} v{ver} を {prod} へ", - f"{plugin} v{ver} を {prod} へ出す(開発版の PR #{release_pr})。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)", - ) - ) - for left in range(HEAD_MOVED_RETRIES, -1, -1): - try: - merge = wait_and_merge(root, main_pr, expect=verdict.tip, ci_wait=a.ci_wait) # 比べた先端だけを本番チャネルへ入れる - break - except HeadMoved: - moved = approval.check_approved(root, approved, base, allowed) - if not moved.ok or not left: - emit(approval.gate(root, a, plugin, base, moved, items, metrics, moved=not left), EXIT_GATE) - verdict = moved # 進んだ分が許可リストの下に収まるので、比べ直した先端で入れ直す(#2221) + main_pr, merge, verdict = _merge_prod_pr(root, a, plugin, base, prod, release_pr, approved, allowed, verdict, items, metrics) metrics.update({"compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) items += approval.tolerated_items(verdict) git(root, "fetch", "-q", "origin") From a2c8d6bb3c32d06a96c00bd9bc1d8079a721aeaf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:04:47 +0000 Subject: [PATCH 37/46] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/release-steps.py#cmd=5Frelease?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 抽出でファイルが 1266 行になり、構造検査の例外リストの上限(1252 行)を超えた。振る舞いを変えずに 行数を詰めた: 本番の PR の題・本文を変数へ置いて create_pr の呼び出しを 1 行にし、抽出先の docstring を 短くし、ver の一時変数と cmd_release の空行を外し、マージコミットの補完を `or` にした(git は merge が空のときだけ呼ばれる点は同じ)。1251 行。 Item-Id: I-009 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/release-steps.py | 33 ++++++++-------------------- 1 file changed, 9 insertions(+), 24 deletions(-) diff --git a/plugins/ndf/scripts/release-steps.py b/plugins/ndf/scripts/release-steps.py index d99351328..3bb1c202e 100755 --- a/plugins/ndf/scripts/release-steps.py +++ b/plugins/ndf/scripts/release-steps.py @@ -704,33 +704,21 @@ def _release_pr_body(root, a, plugin, plugins): def _merge_release_pr(root, a, plugin, plugins, branch, base): """開発版の PR(release/v<版> → ベースブランチ)を使い回すか作ってマージし、PR 番号とマージコミットを返す。""" - ver = a.version pr = _reusable_release_pr(root, branch, base) if pr is None: body = _release_pr_body(root, a, plugin, plugins) - pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{ver}", body), "state": "OPEN"} - release_pr = pr["number"] - release_commit = wait_and_merge(root, release_pr, ci_wait=a.ci_wait) if pr["state"] == "OPEN" else merge_commit_of(root, release_pr) - return release_pr, release_commit + pr = {"number": create_pr(root, base, branch, f"Release: {plugin} v{a.version}", body), "state": "OPEN"} + n = pr["number"] + return n, (wait_and_merge(root, n, ci_wait=a.ci_wait) if pr["state"] == "OPEN" else merge_commit_of(root, n)) def _merge_prod_pr(root, a, plugin, base, prod, release_pr, approved, allowed, verdict, items, metrics): - """本番の PR(ベースブランチ → 本番チャネル)を使い回すか作り、承認と比べた先端を指定してマージする。 - 先端が動いたら比べ直し、許可リストの下に収まる限り HEAD_MOVED_RETRIES 回まで入れ直す。収まらないか - 回数を使い切ったら承認ゲートで止める。本番の PR 番号・マージコミット・最後に比べた判定を返す。""" - ver = a.version + """本番の PR を使い回すか作り、承認と比べた先端を指定してマージする。先端が動いたら比べ直し、許可リストの下に + 収まる限り HEAD_MOVED_RETRIES 回まで入れ直し、それ以外は承認ゲートで止める。(PR 番号, マージコミット, 判定) を返す。""" mp = find_pr(root, base, prod, states=("OPEN",)) - main_pr = ( - mp["number"] - if mp - else create_pr( - root, - prod, - base, - f"Release: {plugin} v{ver} を {prod} へ", - f"{plugin} v{ver} を {prod} へ出す(開発版の PR #{release_pr})。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)", - ) - ) + title = f"Release: {plugin} v{a.version} を {prod} へ" + body = f"{plugin} v{a.version} を {prod} へ出す(開発版の PR #{release_pr})。\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)" + main_pr = mp["number"] if mp else create_pr(root, prod, base, title, body) for left in range(HEAD_MOVED_RETRIES, -1, -1): try: merge = wait_and_merge(root, main_pr, expect=verdict.tip, ci_wait=a.ci_wait) # 比べた先端だけを本番チャネルへ入れる @@ -759,9 +747,7 @@ def cmd_release(a): bump.require_bumped(root, plugin, ver) git(root, "push", "-q", "-u", "origin", "HEAD") - release_pr, release_commit = _merge_release_pr(root, a, plugin, plugins, branch, base) - metrics = { "channel": a.channel, "version": ver, @@ -790,8 +776,7 @@ def cmd_release(a): metrics.update({"compared_head": verdict.tip, "tolerated": len(verdict.tolerated)}) items += approval.tolerated_items(verdict) git(root, "fetch", "-q", "origin") - if not merge: - merge = git(root, "rev-parse", f"origin/{prod}").stdout.strip() + merge = merge or git(root, "rev-parse", f"origin/{prod}").stdout.strip() _publish_tag_and_release(root, plugin, ver, tag, merge) metrics.update({"main_pr": main_pr, "tag": tag, "merge_commit": merge}) From a258c056481028134071379c15c2267311ac7be0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:06:40 +0000 Subject: [PATCH 38/46] =?UTF-8?q?Refactor:=20extract=5Fmethod=20=E2=80=94?= =?UTF-8?q?=20plugins/ndf/scripts/supervise=5Flib/queue.py#cmd=5Fqueue?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit then からステージの並びとすべてのプランを作る部分を _normalize_stages へ、 宣言の読み取り・done の初期化・知らせ始める位置と前の一覧の読み取りを _prepare_queue へ抜き出した。cmd_queue は正規化 → 準備 → 起動 → 待ちの流れになる。 durable の import は本体に残す。 Item-Id: I-010 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/queue.py | 36 +++++++++++++++------- 1 file changed, 25 insertions(+), 11 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/queue.py b/plugins/ndf/scripts/supervise_lib/queue.py index 43c42b3cf..066f2e862 100644 --- a/plugins/ndf/scripts/supervise_lib/queue.py +++ b/plugins/ndf/scripts/supervise_lib/queue.py @@ -130,17 +130,11 @@ def cmd_queue( import durable from supervise_lib import flow # DBOS の import は run と queue だけが払う - then_stages = [list(t) for t in then] if then and not isinstance(then[0], str) else ([list(then)] if then else []) - stages = [list(plans), *then_stages] - all_plans = [p for stage in stages for p in stage] - done_path = queue_done_path(plans, done) - try: - decl = queue_decl_of([_read_plan(p) for p in all_plans]) - except DeclError as e: - return _not_started(str(e), max_, done_path) - done_path.unlink(missing_ok=True) # 前の queue の終わりを待つ側が読まないように、始めに消す - seen = {p: progress_size(p) for p in all_plans} # 前の実行の行は知らせない - listing = _read_listing(queue_plans_path(done_path)) + stages, all_plans = _normalize_stages(plans, then) + prepared = _prepare_queue(all_plans, plans, done, max_) + if isinstance(prepared, dict): + return prepared + decl, done_path, seen, listing = prepared try: prefix = flow.launch_queue(done_path, max_, decl["resources"], listing is not None and listing.get("plans") != all_plans) except durable.DurableError as e: @@ -167,6 +161,26 @@ def cmd_queue( durable.close() +def _normalize_stages(plans: list[str], then: list | None) -> tuple[list[list[str]], list[str]]: + """then(プランの並びかステージの並び)を、先頭のステージ plans に続くステージの並びとすべてのプランの並びにする。""" + then_stages = [list(t) for t in then] if then and not isinstance(then[0], str) else ([list(then)] if then else []) + stages = [list(plans), *then_stages] + return stages, [p for stage in stages for p in stage] + + +def _prepare_queue(all_plans: list[str], plans: list[str], done: str | None, max_: int): + """宣言を読み、done を消して知らせ始める位置と前の一覧を読む。宣言の形が違えば _not_started の結果を返す。""" + done_path = queue_done_path(plans, done) + try: + decl = queue_decl_of([_read_plan(p) for p in all_plans]) + except DeclError as e: + return _not_started(str(e), max_, done_path) + done_path.unlink(missing_ok=True) # 前の queue の終わりを待つ側が読まないように、始めに消す + seen = {p: progress_size(p) for p in all_plans} # 前の実行の行は知らせない + listing = _read_listing(queue_plans_path(done_path)) + return decl, done_path, seen, listing + + def _read_plan(plan: str) -> dict: try: data = json.loads(Path(plan).read_text(encoding="utf-8")) From 6a0f786d9eecefc901ebd3da23bdc1095bb6d7f4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:08:43 +0000 Subject: [PATCH 39/46] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop.py#resume?= =?UTF-8?q?=5Fstopped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: I-011 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/tests/test_sprint_loop.py | 137 ++++++++++++++++++ 1 file changed, 137 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 51925f6f9..af2dffcb5 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1061,3 +1061,140 @@ def test_gate2_answer_treats_a_broken_ledger_as_empty(tmp_path): (tmp_path / "x").mkdir() lap2, _ = g2_lap(tmp_path / "x") assert sl.gate2_answer(lap2) is None + + +# ---------------- 現状固定: resume_stopped の止まった理由ごとの分岐 ---------------- + + +def stopped_on(env, cond, **stop): + """記録を cond で止まった状態にして返す(resume_stopped を直接通すため)。""" + r = rec(env) + r["status"], r["stop"] = "stopped", {"condition": cond, "at": "2026-10-25T00:00:00Z", "values": {}, **stop} + sl.write_record(env, r) + return rec(env) + + +def test_resume_stopped_mvv_changed_stays_stopped_and_points_to_a_new_loop(env): + start(env) + r = stopped_on(env, "mvv_changed") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "新しい名前でループを始め直す" in out["summary"] + assert rec(env)["status"] == "stopped" and rec(env)["stop"]["condition"] == "mvv_changed" + + +def test_resume_stopped_user_stop_removes_the_stop_file_and_resumes(env): + start(env) + sl.cmd_stop(env, "l1") + stop_file = env.root / ".ndf" / "loop" / "l1" / "stop" + assert stop_file.exists() + r = stopped_on(env, "user_stop") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["status"] == "ok" and out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == "user_stop" + assert out["metrics"]["next"] == sl.select_cmd("l1") and "user_stop が解けた" in out["summary"] + assert not stop_file.exists() + after = rec(env) + assert after["status"] == "running" and after["stop"] is None and after["stops"][-1]["condition"] == "user_stop" + assert "resumed_at" in after["stops"][-1] + + +def test_resume_stopped_budget_still_exceeded_writes_the_record_and_stays_stopped(env): + start(env, laps=1) + run_lap(env) + r = rec(env) + assert r["stop"]["condition"] == "budget_laps" + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "予算(laps)を超えたまま(消費 1 / 予算 1)" in out["summary"] and "--budget-laps" in out["summary"] + assert rec(env)["status"] == "stopped" + + +def test_resume_stopped_budget_within_the_limit_resumes(env): + start(env, laps=3) + r = stopped_on(env, "budget_laps") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == "budget_laps" + assert out["metrics"]["next"] == sl.select_cmd("l1") and rec(env)["status"] == "running" + + +@pytest.mark.parametrize("cond", ["same_failure", "no_issue", "gate2_returned", "refused"]) +def test_resume_stopped_other_conditions_without_a_lap_resume_with_select(env, cond): + start(env) + r = stopped_on(env, cond) + assert sl.current_lap(r) is None + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["status"] == "ok" and out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == cond + assert out["metrics"]["next"] == sl.select_cmd("l1") and f"再開した({cond} が解けた)" in out["summary"] + assert rec(env)["stops"][-1]["condition"] == cond + + +def test_resume_stopped_gate2_without_an_answer_stays_stopped(env, monkeypatch): + built_lap(env) + monkeypatch.setattr(sl, "gate2_answer", lambda lap, since=None: None) + r = stopped_on(env, "gate2_returned") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "承認ゲート 2 の答え(承認か差し戻し)がまだ無い" in out["summary"] + assert rec(env)["status"] == "stopped" + + +def test_resume_stopped_gate2_rejected_closes_the_lap_as_returned_and_selects_next(env, monkeypatch): + built_lap(env) + seen = [] + monkeypatch.setattr(sl, "gate2_answer", lambda lap, since=None: seen.append(since) or "rejected") + r = stopped_on(env, "gate2_returned") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert seen == ["2026-10-25T00:00:00Z"] + assert out["metrics"]["status"] == "resumed" and out["metrics"]["resolved"] == "gate2_returned" + assert out["metrics"]["next"] == sl.select_cmd("l1") + after = rec(env) + assert after["laps"][0]["outcome"] == sl.RETURNED and sl.current_lap(after) is None and after["status"] == "running" + + +def test_resume_stopped_gate2_approved_runs_the_gate2_pipeline(env, monkeypatch): + built_lap(env) + monkeypatch.setattr(sl, "gate2_answer", lambda lap, since=None: "approved") + monkeypatch.setattr(sl, "gate2_pipeline", lambda env, rec, lap, stop: f"PIPE {lap['n']} {stop['condition']}") + r = stopped_on(env, "gate2_returned") + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["metrics"]["next"] == "PIPE 1 gate2_returned" and out["metrics"]["status"] == "resumed" + assert sl.current_lap(rec(env)) is not None + + +def test_resume_stopped_refused_in_a_lap_asks_to_rebuild_the_sprint(env): + built_lap(env) + r = stopped_on(env, "refused", values={"step": "new sprint"}) + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + nxt = out["metrics"]["next"] + assert out["metrics"]["status"] == "resumed" and "loop sprint l1 --mode <モード> --dev <開発版>" in nxt + + +def test_resume_stopped_refused_continuation_rebuilds_the_queue_with_kept_issues(env, monkeypatch): + lap = built_lap(env) + seen = [] + + def cont(env_, rec_, lap_, moved, kept): + seen.append((lap_["n"], moved, kept)) + return "QUEUE", Path("/tmp/done.json") + + monkeypatch.setattr(sl, "continuation", cont) + r = rec(env) + r["waiting"] = [{"number": 11, "lap": lap["n"]}, {"number": 99, "lap": lap["n"] + 1}] + r["laps"][-1]["issues_kept"] = [12] + sl.write_record(env, r) + r = stopped_on(env, "refused", values={"step": sl.CONTINUATION_STEP}) + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert seen == [(1, [11], [12])] + assert out["metrics"]["next"] == f"QUEUE && {sl.wait_lap(r, Path('/tmp/done.json'))}" + assert out["metrics"]["status"] == "resumed" + + +@pytest.mark.parametrize("err", [sl.LoopError("組めない"), OSError("os"), ValueError("v"), KeyError("k"), StopIteration()]) +def test_resume_stopped_refused_continuation_failure_stays_stopped(env, monkeypatch, err): + built_lap(env) + + def cont(*a): + raise err + + monkeypatch.setattr(sl, "continuation", cont) + r = stopped_on(env, "refused", values={"step": sl.CONTINUATION_STEP}) + out = sl.resume_stopped(env, r, json.loads(json.dumps(r))) + assert out["exit"] == 3 and "承認ゲート 1 の後の続きをまだ組めない: " in out["summary"] + assert rec(env)["status"] == "stopped" From 719fcb3246e3a934a8dbde3e64017a5028d84b2d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:09:52 +0000 Subject: [PATCH 40/46] =?UTF-8?q?Refactor:=20replace=5Fwith=5Flookup=5Ftab?= =?UTF-8?q?le=20=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop.py#resum?= =?UTF-8?q?e=5Fstopped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 止まった理由ごとの再開の振る舞いを if-elif の連鎖から、理由ごとの手続き(_resume_mvv_changed / _resume_user_stop / _resume_budget / _resume_gate2 / _resume_refused)と対応表へ移した。 手続きは (止まったままの結果 or None, 次のコマンド) を返す共通の形にそろえ、budget_* は接頭辞で引く。 resume_stopped は対応表を引いて手続きを呼び、再開の共通の後処理だけを持つ。振る舞いは変えない。 Item-Id: I-011 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop.py | 116 +++++++++++++++++-------- 1 file changed, 80 insertions(+), 36 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 6b49626c0..f8de2538c 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -177,46 +177,90 @@ def resume_record(env: Env, rec: dict) -> None: rec["stop"], rec["status"] = None, "running" -def resume_stopped(env: Env, rec: dict, before: dict) -> dict: - """止まった理由ごとに解けたかを見る(I7)。解ければ stop を stops へ移す。""" - stop, name = rec["stop"], rec["name"] - cond, nxt = stop["condition"], select_cmd(name) - lap = current_lap(rec) - if cond == "mvv_changed": - return stopped_result( +def _resume_mvv_changed(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + return ( + stopped_result( env, rec, "プロジェクト MVV が改訂されたため、このループは再開しない。新しい名前でループを始め直す(/ndf:development-workflow --loop <新しい名前>)", - ) - if cond == "user_stop": - (loop_dir(env, name) / STOP_FILE).unlink(missing_ok=True) - elif cond.startswith("budget_"): - unit = cond.removeprefix("budget_") - used = consumed(rec)[unit] - if used >= rec["budget"][unit]: - write_record(env, rec, before) - return stopped_result( + ), + nxt, + ) + + +def _resume_user_stop(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + (loop_dir(env, rec["name"]) / STOP_FILE).unlink(missing_ok=True) + return None, nxt + + +def _resume_budget(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + unit = stop["condition"].removeprefix("budget_") + used = consumed(rec)[unit] + if used >= rec["budget"][unit]: + write_record(env, rec, before) + return ( + stopped_result( env, rec, f"予算({unit})を超えたまま(消費 {used} / 予算 {rec['budget'][unit]})。--budget-{unit} で上書きして再開する" - ) - elif cond == "gate2_returned" and lap: - answer = gate2_answer(lap, stop.get("at")) - if answer is None: - return stopped_result(env, rec, "承認ゲート 2 の答え(承認か差し戻し)がまだ無い") - if answer == "rejected": - close_lap(env, rec, lap, RETURNED) - else: - nxt = gate2_pipeline(env, rec, lap, stop) - elif cond == "refused" and lap and (stop.get("values") or {}).get("step") == CONTINUATION_STEP: - # 承認ゲート 1 の後の続きを組めなかった: 判断待ちへ移した課題を戻さず、残した課題で続きを組み直す - moved = [w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]] - try: - queue, done = continuation(env, rec, lap, moved, lap.get("issues_kept") or []) - except (LoopError, OSError, ValueError, KeyError, StopIteration) as e: - write_record(env, rec, before) - return stopped_result(env, rec, f"承認ゲート 1 の後の続きをまだ組めない: {str(e)[:200]}") - nxt = f"{queue} && {wait_lap(rec, done)}" - elif cond == "refused" and lap: - nxt = f"python3 {shlex.quote(str(SPRINT_STATE))} loop sprint {name} --mode <モード> --dev <開発版>(モードと版を決め直す)" + ), + nxt, + ) + return None, nxt + + +def _resume_gate2(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + if not lap: + return None, nxt + answer = gate2_answer(lap, stop.get("at")) + if answer is None: + return stopped_result(env, rec, "承認ゲート 2 の答え(承認か差し戻し)がまだ無い"), nxt + if answer == "rejected": + close_lap(env, rec, lap, RETURNED) + return None, nxt + return None, gate2_pipeline(env, rec, lap, stop) + + +def _resume_refused(env: Env, rec: dict, before: dict, stop: dict, lap: dict | None, nxt: str) -> tuple[dict | None, str]: + if not lap: + return None, nxt + if (stop.get("values") or {}).get("step") != CONTINUATION_STEP: + return ( + None, + f"python3 {shlex.quote(str(SPRINT_STATE))} loop sprint {rec['name']} --mode <モード> --dev <開発版>(モードと版を決め直す)", + ) + # 承認ゲート 1 の後の続きを組めなかった: 判断待ちへ移した課題を戻さず、残した課題で続きを組み直す + moved = [w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]] + try: + queue, done = continuation(env, rec, lap, moved, lap.get("issues_kept") or []) + except (LoopError, OSError, ValueError, KeyError, StopIteration) as e: + write_record(env, rec, before) + return stopped_result(env, rec, f"承認ゲート 1 の後の続きをまだ組めない: {str(e)[:200]}"), nxt + return None, f"{queue} && {wait_lap(rec, done)}" + + +_RESUME_HANDLERS = { + "mvv_changed": _resume_mvv_changed, + "user_stop": _resume_user_stop, + "gate2_returned": _resume_gate2, + "refused": _resume_refused, +} + + +def _resume_handler(cond: str): + """止まった理由に対応する再開の手続き。budget_* は接頭辞で引く。無ければ None(そのまま解く)。""" + if cond.startswith("budget_"): + return _resume_budget + return _RESUME_HANDLERS.get(cond) + + +def resume_stopped(env: Env, rec: dict, before: dict) -> dict: + """止まった理由ごとに解けたかを見る(I7)。解ければ stop を stops へ移す。""" + stop, name = rec["stop"], rec["name"] + cond, nxt = stop["condition"], select_cmd(name) + handler = _resume_handler(cond) + if handler: + stopped, nxt = handler(env, rec, before, stop, current_lap(rec), nxt) + if stopped is not None: + return stopped resume_record(env, rec) write_record(env, rec, before) update_handoff(env, rec, f"再開した({cond} が解けた)", resume_command(rec), nxt) From 54f92884c76de00434f015c8ab06bc0565d14b22 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:12:18 +0000 Subject: [PATCH 41/46] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/sprint=5Floop.py#resume=5Fstopped?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 対応表への置き換えで sprint_loop.py が 541 行になり、構造検査(500 行)を超えた。 承認ゲート 2 の答えの読み取りと承認後の続き(EPOCH / gate2_answer / latest_gate2_answer / gate2_pipeline)を新しいモジュール sprint_loop_gate2.py へ移し、sprint_loop から読み込む。 再開の手続きは sprint_loop の名前空間の gate2_answer / gate2_pipeline を引くままで、振る舞いは変えない。 使われなくなった import を外した。 Item-Id: I-011 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/lib/sprint_loop.py | 64 +---------------- plugins/ndf/scripts/lib/sprint_loop_gate2.py | 75 ++++++++++++++++++++ 2 files changed, 77 insertions(+), 62 deletions(-) create mode 100644 plugins/ndf/scripts/lib/sprint_loop_gate2.py diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index f8de2538c..d2fa224a3 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -12,8 +12,6 @@ from pathlib import Path from typing import Callable -import clock -import gate_rejections as gr import jsonio import project_mvv as pm import repo as repo_lib @@ -34,7 +32,6 @@ FAILED, RETURNED, GATE_1, - GATE_2, FINISHED, NOT_RUN, LoopError, @@ -62,15 +59,11 @@ select_cmd, wait_lap, sprint_pipeline, - queue_command, refusal_order, build_close, refused_stop, copy_mvv_gate, read_done_items, - plan_info, - gate2_resume_step, - stages_after, continuation, CONTINUATION_STEP, mark_issue, @@ -81,6 +74,8 @@ after_close, ) +from sprint_loop_gate2 import gate2_answer, gate2_pipeline + # ---------------------------------------------------------------- start @@ -267,61 +262,6 @@ def resume_stopped(env: Env, rec: dict, before: dict) -> dict: return loop_ok(f"開発ループ {name} を再開した({cond} が解けた)", rec, status="resumed", next_=nxt, resolved=cond) -EPOCH = clock.parse("1970-01-01T00:00:00Z") # 時刻の無い答え(止まった時刻の無い古い記録のとき)の並びの値 - - -def gate2_answer(lap: dict, since: str | None = None) -> str | None: - """承認ゲート 2 の答え: 止まった時刻(since)より後の、周回の状態の利用者の承認と差し戻しの記録のうち最新のもの。 - approved / rejected、どちらも無ければ None。since より前の答え(同じ周回の前の関門への答え)は数えない。""" - path = lap.get("state_path") or "" - state = jsonio.read(path, missing={}, broken={}, want=dict) if path else {} - try: - rows = gr.read_rejections() - except jsonio.JsonReadError: - rows = [] - sp = str(Path(path).resolve()) if path else None - # 承認の行(#2183)は状態ファイルの gates の方で読む - rejections = [r for r in rows if gr.is_rejection(r) and r.get("gate") == GATE_2 and r.get("sprint") == sp] - return latest_gate2_answer(state.get("gates", []), rejections, since) - - -def latest_gate2_answer(gates: list[dict], rejections: list[dict], since: str | None) -> str | None: - """関門 2 の利用者の承認(gates)と差し戻し(rejections)のうち、since 以後で最新のもの。同じ時刻は承認を採る。""" - after = clock.parse(since, naive="utc") if since else None - - def fresh(at): - t = clock.parse(at, naive="utc") if at else None - if after is None: - return t or EPOCH - return t if t is not None and t >= after else None - - answers = [(k, "approved") for g in gates if g.get("name") == GATE_2 and g.get("by") != "mvv" and (k := fresh(g.get("at")))] - answers += [(k, "rejected") for r in rejections if (k := fresh(r.get("at")))] - return max(answers, key=lambda a: a[0])[1] if answers else None - - -def gate2_pipeline(env: Env, rec: dict, lap: dict, stop: dict) -> str: - """承認ゲート 2 の承認の後の続き: 止まった計画を経路に合う再開のステップから続け → 同じ queue の残りのステージと - まとめの queue → wait → loop lap の 1 行。続けるステップが無い計画(gate-2 のプラン)は手で行う本番を先に示す。""" - # 止まった記録の values.pr は検査のスプリント PR で、記録の PR ではない。まとめは {queue_record} で流す時点に探す(#2230 の決定 5) - close = build_close(env, rec, lap, lap.get("issues_kept") or selected_issues(lap)) - done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" - plan = lap.get("gate2_plan") or lap.get("prod_plan") or "<本番のプラン>" - moved = {w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]} - rest = [ - [p for p in s if not (set(plan_info(p).get("課題") or []) and set(plan_info(p).get("課題") or []) <= moved)] - for s in stages_after(lap, plan) - ] - stages = [s for s in rest if s] + [close] - queue = queue_command(stages, done) - lap.setdefault("dones", []).append(str(done)) - step = gate2_resume_step(plan) - if step is None: - return f"手で行う本番のステージ({plan} の後)を終えてから: {queue} && {wait_lap(rec, done)}" - run = f"python3 {shlex.quote(str(SUPERVISE))} run {shlex.quote(plan)} --from {step}" - return f"{run} && {queue} && {wait_lap(rec, done)}" - - # ---------------------------------------------------------------- sprint diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate2.py b/plugins/ndf/scripts/lib/sprint_loop_gate2.py new file mode 100644 index 000000000..e612ffba2 --- /dev/null +++ b/plugins/ndf/scripts/lib/sprint_loop_gate2.py @@ -0,0 +1,75 @@ +"""開発ループの承認ゲート 2 の答えの読み取りと、承認の後の続きのコマンド(sprint_loop から分けた)。""" + +from __future__ import annotations + +import shlex +from pathlib import Path + +import clock +import gate_rejections as gr +import jsonio + +from sprint_loop_record import GATE_2, SUPERVISE, Env, selected_issues +from sprint_loop_gate import ( + build_close, + gate2_resume_step, + plan_info, + queue_command, + stages_after, + wait_lap, +) + + +EPOCH = clock.parse("1970-01-01T00:00:00Z") # 時刻の無い答え(止まった時刻の無い古い記録のとき)の並びの値 + + +def gate2_answer(lap: dict, since: str | None = None) -> str | None: + """承認ゲート 2 の答え: 止まった時刻(since)より後の、周回の状態の利用者の承認と差し戻しの記録のうち最新のもの。 + approved / rejected、どちらも無ければ None。since より前の答え(同じ周回の前の関門への答え)は数えない。""" + path = lap.get("state_path") or "" + state = jsonio.read(path, missing={}, broken={}, want=dict) if path else {} + try: + rows = gr.read_rejections() + except jsonio.JsonReadError: + rows = [] + sp = str(Path(path).resolve()) if path else None + # 承認の行(#2183)は状態ファイルの gates の方で読む + rejections = [r for r in rows if gr.is_rejection(r) and r.get("gate") == GATE_2 and r.get("sprint") == sp] + return latest_gate2_answer(state.get("gates", []), rejections, since) + + +def latest_gate2_answer(gates: list[dict], rejections: list[dict], since: str | None) -> str | None: + """関門 2 の利用者の承認(gates)と差し戻し(rejections)のうち、since 以後で最新のもの。同じ時刻は承認を採る。""" + after = clock.parse(since, naive="utc") if since else None + + def fresh(at): + t = clock.parse(at, naive="utc") if at else None + if after is None: + return t or EPOCH + return t if t is not None and t >= after else None + + answers = [(k, "approved") for g in gates if g.get("name") == GATE_2 and g.get("by") != "mvv" and (k := fresh(g.get("at")))] + answers += [(k, "rejected") for r in rejections if (k := fresh(r.get("at")))] + return max(answers, key=lambda a: a[0])[1] if answers else None + + +def gate2_pipeline(env: Env, rec: dict, lap: dict, stop: dict) -> str: + """承認ゲート 2 の承認の後の続き: 止まった計画を経路に合う再開のステップから続け → 同じ queue の残りのステージと + まとめの queue → wait → loop lap の 1 行。続けるステップが無い計画(gate-2 のプラン)は手で行う本番を先に示す。""" + # 止まった記録の values.pr は検査のスプリント PR で、記録の PR ではない。まとめは {queue_record} で流す時点に探す(#2230 の決定 5) + close = build_close(env, rec, lap, lap.get("issues_kept") or selected_issues(lap)) + done = Path(lap["dir"]) / f"done-{len(lap.get('dones', [])) + 1}.json" + plan = lap.get("gate2_plan") or lap.get("prod_plan") or "<本番のプラン>" + moved = {w["number"] for w in rec.get("waiting", []) if w.get("lap") == lap["n"]} + rest = [ + [p for p in s if not (set(plan_info(p).get("課題") or []) and set(plan_info(p).get("課題") or []) <= moved)] + for s in stages_after(lap, plan) + ] + stages = [s for s in rest if s] + [close] + queue = queue_command(stages, done) + lap.setdefault("dones", []).append(str(done)) + step = gate2_resume_step(plan) + if step is None: + return f"手で行う本番のステージ({plan} の後)を終えてから: {queue} && {wait_lap(rec, done)}" + run = f"python3 {shlex.quote(str(SUPERVISE))} run {shlex.quote(plan)} --from {step}" + return f"{run} && {queue} && {wait_lap(rec, done)}" From 62f27fe27caffb5b48c8c2ccbb69eea84041ea3c Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:50:38 +0000 Subject: [PATCH 42/46] =?UTF-8?q?Fix:=20=E9=85=8D=E5=B8=83=E4=B8=8D?= =?UTF-8?q?=E8=A6=81=E3=81=AE=E5=AE=9F=E8=A1=8C=E3=81=AE=E6=9D=A1=E4=BB=B6?= =?UTF-8?q?=E3=82=92=E7=89=88=E3=81=AE=E7=85=A7=E4=BC=9A=E3=82=88=E3=82=8A?= =?UTF-8?q?=E5=85=88=E3=81=AB=E6=89=93=E3=81=A4=EF=BC=88#2164=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 「版」と「実行の条件」を持つ配布のプランは、queue の差し込みで版を照会する前に条件を打つ。 配布不要(skip_code)なら版を照会・差し込みせずに流し、engine が条件を打ち直して完了で終える。 明示の版が既出でも unfilled で止まらず、まとめ・課題のクローズへ進む。 条件の実行は engine と queue で共有する run_condition(steps.py)へ寄せた。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/supervise_lib/engine.py | 32 +++---------------- plugins/ndf/scripts/supervise_lib/queue.py | 19 +++++++++++ plugins/ndf/scripts/supervise_lib/steps.py | 29 +++++++++++++++++ .../ndf/scripts/tests/test_release_version.py | 10 ++++++ 4 files changed, 63 insertions(+), 27 deletions(-) diff --git a/plugins/ndf/scripts/supervise_lib/engine.py b/plugins/ndf/scripts/supervise_lib/engine.py index 2cbac833e..65c8f7a66 100644 --- a/plugins/ndf/scripts/supervise_lib/engine.py +++ b/plugins/ndf/scripts/supervise_lib/engine.py @@ -10,7 +10,6 @@ import os import shutil -import subprocess import time from datetime import datetime from pathlib import Path @@ -26,7 +25,7 @@ from supervise_lib.pr import PrStep from supervise_lib.slow import SLOW_EXIT, SlowAction, SlowWatch from supervise_lib.state import RunState -from supervise_lib.steps import JudgeStep, RunStep, StepContext, is_gate, last_json +from supervise_lib.steps import COND_RUN, COND_SKIP, JudgeStep, RunStep, StepContext, is_gate, last_json, run_condition from supervise_lib.worker_steps import DriveStep, WorkStep @@ -412,34 +411,13 @@ def _next_after_slow(self, e: SlowAction, sid: str, step: dict) -> tuple[str | N def check_condition(self, cond: dict) -> tuple[str, str] | None: """計画の実行の条件を、作業ツリーを作る前に打つ。流すなら None、流さないなら (結果, 理由)。""" - cmd = str(cond.get("cmd") or "").replace("{state_dir}", str(self.state.dir)) - wt = Path(self.plan["作業場所"]) - cwd = self.plan.get("リポジトリ") or ( - str(wt) if wt.is_dir() else str(wt).split("/.worktrees/")[0] if "/.worktrees/" in str(wt) else None - ) - if cwd and not Path(cwd).is_dir(): - cwd = None - started = time.time() - try: - p = subprocess.run(cmd, shell=True, cwd=cwd, capture_output=True, text=True, timeout=cond.get("timeout", 900)) - code, text = p.returncode, p.stdout + p.stderr - except subprocess.TimeoutExpired as e: - code, text = 124, f"打ち切り({e.timeout} 秒)" - out = last_json(text) or {} - summary = str(out.get("summary") or text.strip()[-200:] or f"exit={code}") + verdict, code, summary, seconds = run_condition(cond, self.plan, self.state.dir) self.state.progress_write( - { - "kind": "step", - "step": "実行の条件", - "type": "run", - "exit": code, - "seconds": round(time.time() - started, 1), - "summary": summary[:300], - } + {"kind": "step", "step": "実行の条件", "type": "run", "exit": code, "seconds": seconds, "summary": summary[:300]} ) - if code == 0: + if verdict == COND_RUN: return None - if code == cond.get("skip_code", 3): + if verdict == COND_SKIP: return "完了", f"実行の条件に当たらない({summary})" self.state.attention("止まった", f"実行の条件を判定できない(exit={code}): {summary}") return "止まった", f"実行の条件を判定できない(exit={code}: {summary})" diff --git a/plugins/ndf/scripts/supervise_lib/queue.py b/plugins/ndf/scripts/supervise_lib/queue.py index 066f2e862..1e3e23adf 100644 --- a/plugins/ndf/scripts/supervise_lib/queue.py +++ b/plugins/ndf/scripts/supervise_lib/queue.py @@ -226,6 +226,9 @@ def fill_stage(first: bool, stage: list[str], items: list[dict]) -> tuple[list[d for p in stage: err = (None if prs is None else fill_queue_prs(p, prs)) or fill_queue_pr(p, items) version = None + if not err and _release_skipped_by_condition(p): + runnable.append(p) # 版を照会せずに流す。engine が実行の条件を打ち直して完了で終える + continue if not err: # 版の差し込みは、ほかの差し込みを埋めた後に(差し込み前の写しに版だけを残す。#2164) err, version = fill_release_version(p, items) if err: @@ -235,6 +238,22 @@ def fill_stage(first: bool, stage: list[str], items: list[dict]) -> tuple[list[d return skipped, runnable +def _release_skipped_by_condition(plan: str) -> bool: + """「版」と「実行の条件」を持つ配布のプランで、条件が配布不要(skip_code)を返すか。 + + 条件で飛ぶ配布は版を使わないため、版の照会・差し込みより先に条件を打つ。明示の版が既出でも止めない(#2164)。 + 判定できないときと流すときは False(版を埋めてから流し、engine が条件を打ち直す)。""" + try: + info = json.loads(Path(plan).read_text()) + except (OSError, ValueError): + return False + if not isinstance(info, dict) or not isinstance(info.get("版"), dict) or not isinstance(info.get("実行の条件"), dict): + return False + from supervise_lib.steps import COND_SKIP, run_condition # 条件のあるプランだけが読み込む + + return run_condition(info["実行の条件"], info, state_dir_of(str(Path(plan).absolute())))[0] == COND_SKIP + + def touched_files(body: str) -> list[str]: """プランの JSON の `触るファイル`(読めない・無いなら空)。""" try: diff --git a/plugins/ndf/scripts/supervise_lib/steps.py b/plugins/ndf/scripts/supervise_lib/steps.py index e156d0894..0e6640191 100644 --- a/plugins/ndf/scripts/supervise_lib/steps.py +++ b/plugins/ndf/scripts/supervise_lib/steps.py @@ -50,6 +50,35 @@ def last_json(text: str) -> dict | None: return None +# ---- 計画の実行の条件(engine の実行の前と、queue の版の差し込みの前で共有する) ---- + +COND_RUN, COND_SKIP, COND_UNKNOWN = "run", "skip", "unknown" + + +def condition_cwd(plan: dict) -> str | None: + """条件を打つ場所: プランの `リポジトリ`、無ければ作業場所(まだ無ければその親のリポジトリ)。""" + wt = Path(plan.get("作業場所") or "") + cwd = plan.get("リポジトリ") or ( + str(wt) if str(wt) and wt.is_dir() else str(wt).split("/.worktrees/")[0] if "/.worktrees/" in str(wt) else None + ) + return cwd if cwd and Path(cwd).is_dir() else None + + +def run_condition(cond: dict, plan: dict, state_dir: str | Path) -> tuple[str, int, str, float]: + """条件を打ち、(COND_RUN / COND_SKIP / COND_UNKNOWN, 終了コード, 要約, 秒) を返す。""" + cmd = str(cond.get("cmd") or "").replace("{state_dir}", str(state_dir)) + started = time.time() + try: + p = subprocess.run(cmd, shell=True, cwd=condition_cwd(plan), capture_output=True, text=True, timeout=cond.get("timeout", 900)) + code, text = p.returncode, p.stdout + p.stderr + except subprocess.TimeoutExpired as e: + code, text = 124, f"打ち切り({e.timeout} 秒)" + out = last_json(text) or {} + summary = str(out.get("summary") or text.strip()[-200:] or f"exit={code}") + verdict = COND_RUN if code == 0 else COND_SKIP if code == cond.get("skip_code", 3) else COND_UNKNOWN + return verdict, code, summary, round(time.time() - started, 1) + + def prior_results(state) -> dict[str, dict]: """前の実行の結果(`--from` の再開で inputs が読む。#1676 の I7・I8)。前の実行の範囲(`RunState.prior_rows`)の `step` の行のうち、ステップごとに最後の行の `exit` と、出力ファイル(`out`)の本文を返す。`out` の無い古い行は `summary`。""" diff --git a/plugins/ndf/scripts/tests/test_release_version.py b/plugins/ndf/scripts/tests/test_release_version.py index 5539fa577..56079579d 100644 --- a/plugins/ndf/scripts/tests/test_release_version.py +++ b/plugins/ndf/scripts/tests/test_release_version.py @@ -331,3 +331,13 @@ def test_fill_stage_reports_the_version_of_a_plan_it_did_not_run(tmp_path, world plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") skipped, runnable = queue.fill_stage(True, [str(plan)], []) assert runnable == [] and skipped[0]["unfilled"] is True and skipped[0]["version"]["cause"] == "taken" + + +@pytest.mark.parametrize(("code", "runs"), [(3, True), (0, False)]) +def test_fill_stage_asks_the_condition_before_the_version(tmp_path, world, code, runs): + """実行の条件が配布不要なら、明示の版が既出でも版を照会せずに流す(engine が条件で完了にする)。要るなら従来どおり止める。""" + plan = write_plan(tmp_path, world, explicit="10.27.5-dev.1") + info = json.loads(plan.read_text()) + plan.write_text(json.dumps({**info, "実行の条件": {"cmd": f"exit {code}", "skip_code": 3}}, ensure_ascii=False)) + skipped, runnable = queue.fill_stage(True, [str(plan)], []) + assert (runnable == [str(plan)] and skipped == []) if runs else (runnable == [] and skipped[0]["unfilled"] is True) From b88f88fc327579e8658cb8939b8f4e668e1343e3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 06:58:11 +0000 Subject: [PATCH 43/46] =?UTF-8?q?Fix:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=B8=E5=91=A8=E5=9B=9E=E3=81=AE=E7=89=88=E3=82=92=E6=98=8E?= =?UTF-8?q?=E7=A4=BA=E3=81=AE=E7=89=88=E3=81=A8=E3=81=97=E3=81=A6=E6=B8=A1?= =?UTF-8?q?=E3=81=95=E3=81=AA=E3=81=84=EF=BC=88#2164=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 最終の検査で変更があると、出し終えた版が既出として止まり、まとめと周回が失敗で締まっていた。 まとめの配布の版は流す時点に次に空いている版で決める。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop_gate.py | 8 +++----- plugins/ndf/scripts/tests/test_sprint_loop.py | 2 ++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index a94127064..e6438c52c 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -31,7 +31,6 @@ NOT_RUN, LoopError, Env, - prod_of, write_record, open_record, current_lap, @@ -98,15 +97,14 @@ def refusal_order(summary: str) -> dict: def build_close(env: Env, rec: dict, lap: dict, issues: list[int], tag: str = "close") -> list[str]: """まとめのステージ(new close --pace auto)を組み、プランのパスを返す。断れば LoopError。 - 記録の PR は渡さない(まとめのプランが {queue_record} を持ち、流す時点に探す。#2230)。""" + 記録の PR は渡さない(まとめのプランが {queue_record} を持ち、流す時点に探す。#2230)。 + 版も渡さない。周回の版はもう出ているため、明示すると最終の検査で変更があったときに既出で止まる。 + まとめの配布の版は流す時点に次に空いている版で決める(#2164)。""" prev = rec["laps"][lap["n"] - 2] if lap["n"] >= 2 else None carry = ((prev or {}).get("refine") or {}).get("deferred") or [] out = Path(lap["dir"]) / tag cmd = [sys.executable, str(SUPERVISE), "new", "close", "--pace", "auto", "--name", lap["sprint"], "--worktree", str(env.main_dir())] cmd += ["--issue", *map(str, issues), "--state", lap["state_path"], "--out", str(out), "--mode", lap.get("mode") or "standard"] - v = lap.get("versions") or {} - if v.get("dev"): - cmd += ["--version", v["dev"], "--prod", v.get("prod") or prod_of(v["dev"])] if carry: cmd += ["--carry", *map(str, carry)] if lap.get("milestone"): diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index af2dffcb5..cca5555d1 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -610,6 +610,8 @@ def test_lap_copies_values_carry_reaches_the_next_close_and_list_shows_laps(env) sl_select.cmd_select(env, "l1") out = sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.2", None, []) assert "--carry 31 32" in out["metrics"]["close_command"] and "--pace auto" in out["metrics"]["close_command"] + # 周回の版は出し終えているので、まとめへ明示の版として渡さない(流す時点に次に空いている版で決める。#2164) + assert "--version" not in out["metrics"]["close_command"] and "--prod" not in out["metrics"]["close_command"] assert "1-close.json" in out["metrics"]["queue"].split("--then")[-1] assert out["metrics"]["pipeline"].count("&&") == 2 and "wait" in out["metrics"]["pipeline"] rows = sl.cmd_list(env, "l1")["items"] From 346d0a76125fa15d87180bb6c5f440e19ec6e1d0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 07:24:30 +0000 Subject: [PATCH 44/46] Release: ndf v10.27.6-dev.1 --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 10 ++++++++++ README.md | 4 ++-- docs/versioning-and-distribution.md | 4 ++-- plugins/ndf/.claude-plugin/plugin.json | 4 ++-- plugins/ndf/.codex-plugin/plugin.json | 4 ++-- plugins/ndf/README.md | 6 +++--- 7 files changed, 22 insertions(+), 12 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 041495617..c6c7b678a 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.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.", + "description": "Claude Code plugin (v10.27.6-dev.1): 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" diff --git a/CHANGELOG.md b/CHANGELOG.md index 126ee3a53..31541f097 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,16 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.6] - 2026-11-01 + +- ndf のスプリントのまとめのプランは、`--record-pr` に `{queue_record}` を組みます。(#2333)(#2348) +- 記録の PR は、プランを組む時点ではなく、そのプランを流す時点で探されます。(#2333)(#2348) +- ndf: 版を渡さずに作ったスプリントの配布のプランには、版の差し込みが入ります。版はキューがプランを流す時点に決まり、版の記録に残ります。(#2345)(#2348) +- ndf: 本番のプランで版を省くと、開発版の版の記録から版が埋まります。(#2345)(#2348) +- ndf: 配布の bump は、版を戻す呼び方と、既にタグのある版の呼び方では止まります。(#2345)(#2348) +- ndf: 版数を持つプランは明示の版を持つプランとして扱われ、上の止まり方が当たります。(#2345)(#2348) +- ndf: 版の判定は、プラグインの置き方を前提にしません。ベースブランチの木から plugin.json を探します。(#2345)(#2348) + ## [ndf 10.27.5] - 2026-11-01 - ndf の cross-refactoring は、想定最大時間を省くと下限の値で動きます。(#2307)(#2312) diff --git a/README.md b/README.md index b807067a0..34c4a6efb 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.5 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.6-dev.1 | 開発の作業手順(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 向け)。 @@ -69,7 +69,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.27.5** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.6-dev.1** に入る 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 diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 99e2a88be..f43e50e2c 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.5` | 利用者が常用してよい | +| 正式版 | `10.27.6` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.5` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.6` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 37e416598..f6e7fc83a 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "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.", + "version": "10.27.6-dev.1", + "description": "Claude Code plugin (v10.27.6-dev.1): 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 925630ca3..a5c1a361a 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "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.", + "version": "10.27.6-dev.1", + "description": "Codex plugin (v10.27.6-dev.1): 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 51c6caa8c..565bd1bb0 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -350,7 +350,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.27.5/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.6-dev.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -372,14 +372,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.5/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.6-dev.1/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.27.5 +# => ndf@ai-plugins installed, enabled 10.27.6-dev.1 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 From 02e64775850e0cf84029ffcbcb0cbfb8de5688ff Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 07:27:48 +0000 Subject: [PATCH 45/46] Release: ndf v10.27.6 --- .claude-plugin/marketplace.json | 2 +- README.md | 4 ++-- plugins/ndf/.claude-plugin/plugin.json | 4 ++-- plugins/ndf/.codex-plugin/plugin.json | 4 ++-- plugins/ndf/README.md | 6 +++--- 5 files changed, 10 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index c6c7b678a..ea4347f89 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.6-dev.1): 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.6): 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" diff --git a/README.md b/README.md index 34c4a6efb..fef863906 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.6-dev.1 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.6 | 開発の作業手順(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 向け)。 @@ -69,7 +69,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.27.6-dev.1** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.6** に入る 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 diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index f6e7fc83a..3a0fea21c 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.6-dev.1", - "description": "Claude Code plugin (v10.27.6-dev.1): 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.6", + "description": "Claude Code plugin (v10.27.6): 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 a5c1a361a..03e71f55c 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.6-dev.1", - "description": "Codex plugin (v10.27.6-dev.1): 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.6", + "description": "Codex plugin (v10.27.6): 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 565bd1bb0..a09221c44 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -350,7 +350,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.27.6-dev.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.6/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -372,14 +372,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.6-dev.1/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.6/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.6-dev.1 +# => ndf@ai-plugins installed, enabled 10.27.6 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 From 9a0f6606653b2808eb33456170978fc25c1ee253 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Sun, 1 Nov 2026 07:29:39 +0000 Subject: [PATCH 46/46] =?UTF-8?q?Release:=20ndf=20v10.27.6=20=E3=81=AE?= =?UTF-8?q?=E3=83=88=E3=83=BC=E3=82=AF=E3=83=B3=E6=B6=88=E8=B2=BB=E3=81=AE?= =?UTF-8?q?=E8=A8=98=E9=8C=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../metrics/ndf-token-usage/2026-11-01-2.json | 512 ++++++++++++++++++ docs/metrics/ndf-token-usage/2026-11-01-2.md | 101 ++++ 2 files changed, 613 insertions(+) create mode 100644 docs/metrics/ndf-token-usage/2026-11-01-2.json create mode 100644 docs/metrics/ndf-token-usage/2026-11-01-2.md diff --git a/docs/metrics/ndf-token-usage/2026-11-01-2.json b/docs/metrics/ndf-token-usage/2026-11-01-2.json new file mode 100644 index 000000000..2ef0ede9a --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-01-2.json @@ -0,0 +1,512 @@ +{ + "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": 5, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.5", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 4, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.5", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "sessions": 6, + "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": 5, + "cost": 609748.7899999999, + "context": 5190714.4, + "out": 22361.8, + "minutes": 34.61276666641235, + "p": 57743.2, + "k": 41.0, + "w5": 0.0, + "w1h": 122216.6, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 869285, + "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": 2, + "cost": 13268.8, + "context": 5085.0, + "out": 720.0, + "minutes": 0.13833333333333334, + "k": 1.0, + "w5": 0.0, + "w1h": 4827.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 + }, + { + "version": "10.27.5", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 4, + "cost": 628660.8125, + "context": 4202245.75, + "out": 31985.5, + "minutes": 6.078583333889643, + "p": 58940.75, + "k": 32.5, + "w5": 0.0, + "w1h": 132594.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.5", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 13560.6, + "context": 5018.0, + "out": 905.0, + "minutes": 0.16666666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 4504.0 + }, + { + "version": "10.27.5", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 6, + "cost": 989440.9833333333, + "context": 10168213.166666666, + "out": 40871.666666666664, + "minutes": 50.66420277820693, + "p": 54986.166666666664, + "k": 55.666666666666664, + "w5": 0.0, + "w1h": 141822.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 2284154, + "rewrite_gap_median": null + }, + { + "version": "10.27.5", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 28648.0, + "context": 13485.0, + "out": 336.0, + "minutes": 0.09166666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 13483.0 + }, + { + "version": "10.27.5", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 60431.0, + "context": 26729.0, + "out": 1395.0, + "minutes": 0.29333333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 26727.0 + }, + { + "version": "10.27.5", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 5, + "cost": 16865.239999999998, + "context": 5537.8, + "out": 1238.2, + "minutes": 0.20500000000000002, + "k": 1.0, + "w5": 0.0, + "w1h": 5331.0 + }, + { + "version": "10.27.5", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 7, + "cost": 188001.88571428572, + "context": 645098.0, + "out": 11814.42857142857, + "minutes": 1.8713904755456106, + "p": 38655.28571428572, + "k": 8.714285714285714, + "w5": 80548.57142857143, + "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.5", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.316", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 15, + "cost": 163444.40333333332, + "context": 927183.6, + "out": 7098.2, + "minutes": 7.498333333333333, + "k": 13.6, + "w5": 0.0, + "w1h": 41830.2 + } + ], + "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-01T07:27:00Z", + "sessions": 20, + "sessions_with_pr": 0, + "unlinked_external": 3002, + "unlinked_ledger": 2, + "skipped": { + "ndf の Skill が無い": 638, + "版を判定できない": 19 + }, + "released": "10.27.6", + "previous": "2026-11-01.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-11-01-2.md b/docs/metrics/ndf-token-usage/2026-11-01-2.md new file mode 100644 index 000000000..0307ac8aa --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-01-2.md @@ -0,0 +1,101 @@ +# ndf の版ごとのトークン消費と所要時間(2026-11-01 集計・10.27.6 の配布) + +**10.27.6 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-11-01T07:27:00Z`。表の最初の行は 10.27.3(前の記録: 2026-11-01.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.6 --until 2026-11-01T07:27:00Z --min-version 10.27.3 +python3 scripts/token-usage.py --min-version 10.27.3 --until 2026-11-01T07:27:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 無し。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.3, 10.27.4, 10.27.5, 10.27.6。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 無し。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(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 | 10 | 56.9k | 47.2 | 0.00M | 0.16M | 1 | 1 | 0.10 | +| 10.27.3 | supervisor | 9 | - | 1.0 | 0.00M | 0.01M | - | - | - | +| 10.27.3 | worker | 15 | - | 22.2 | 0.00M | 0.05M | - | - | - | +| 10.27.5 | conductor | 10 | 56.6k | 46.4 | 0.00M | 0.14M | 0 | 0 | 0.00 | +| 10.27.5 | supervisor | 16 | 38.7k | 4.4 | 0.04M | 0.00M | 0 | 0 | 0.00 | +| 10.27.5 | worker | 15 | - | 13.6 | 0.00M | 0.04M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 20 件 / PR を作った会話: 0 件 / 寄せ先の無い外部 CLI: 3002 件 / 寄せ先の無い帳簿の行: 2 件 + +換算は 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 | - | - | 10 | 0.81M | 6.67M | 34.1k | 58.0 | +| 10.27.3 | supervisor | judge | claude -p | 3 | 0.03M | 0.01M | 0.5k | 0.1 | +| 10.27.3 | supervisor | pr | claude -p | 6 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.3 | worker | work | claude -p | 15 | 0.25M | 1.77M | 11.4k | 9.6 | +| 10.27.5 | conductor | - | - | 10 | 0.85M | 7.78M | 37.3k | 32.8 | +| 10.27.5 | supervisor | judge | claude -p | 1 | 0.03M | 0.01M | 0.3k | 0.1 | +| 10.27.5 | supervisor | mvv | claude -p | 1 | 0.06M | 0.03M | 1.4k | 0.3 | +| 10.27.5 | supervisor | pr | claude -p | 7 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.5 | supervisor | その他 | ndf:worker | 7 | 0.19M | 0.65M | 11.8k | 1.9 | +| 10.27.5 | worker | work | claude -p | 15 | 0.16M | 0.93M | 7.1k | 7.5 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.3 | conductor | - | - | 10 | 56.9k | 47.2 | 0.00M | 0.16M | 1 | 1 | 0.19M | 3.17M | 89.9 | +| 10.27.3 | supervisor | judge | claude -p | 3 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.3 | supervisor | pr | claude -p | 6 | - | 1.0 | 0.00M | 0.00M | - | - | - | - | - | +| 10.27.3 | worker | work | claude -p | 15 | - | 22.2 | 0.00M | 0.05M | - | - | - | - | - | +| 10.27.5 | conductor | - | - | 10 | 56.6k | 46.4 | 0.00M | 0.14M | 0 | 0 | 0.00M | 2.28M | - | +| 10.27.5 | supervisor | judge | claude -p | 1 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.5 | supervisor | mvv | claude -p | 1 | - | 1.0 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.5 | supervisor | pr | claude -p | 7 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.5 | supervisor | その他 | ndf:worker | 7 | 38.7k | 8.7 | 0.08M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.5 | worker | work | claude -p | 15 | - | 13.6 | 0.00M | 0.04M | - | - | - | - | - | + +### 外部 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 | - |