diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index eb966d35f..d26d4fcac 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.21.0): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 48 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.22.0): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 48 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/.ndf/backlog.json b/.ndf/backlog.json new file mode 100644 index 000000000..10dac1648 --- /dev/null +++ b/.ndf/backlog.json @@ -0,0 +1,3 @@ +{ + "quality": {"label": "quality", "ratio": 0.2, "min": 1} +} diff --git a/.ndf/project.json b/.ndf/project.json index 819c366f0..5b3e7a03e 100644 --- a/.ndf/project.json +++ b/.ndf/project.json @@ -5,18 +5,18 @@ { "name": "python", "frameworks": [], - "files": 633, + "files": 917, "version": "3.11" }, { "name": "shell", "frameworks": [], - "files": 77 + "files": 79 }, { "name": "javascript", "frameworks": [], - "files": 2 + "files": 8 } ], "test": { @@ -92,9 +92,9 @@ "test_duration": { "measured": [ { - "seconds": 424.0, + "seconds": 1113.0, "source": "ci-steps", - "detail": "run 36284835140(.github/workflows/pytest.yml)のテストの step 4 個の合計" + "detail": "run 40160663747(.github/workflows/pytest.yml)のテストの step 4 個の合計" } ] }, @@ -104,66 +104,77 @@ { "path": ".github/workflows/glossary.yml", "jobs": 1, - "wall_seconds": 12.0 + "wall_seconds": 10.0 }, { "path": ".github/workflows/lint.yml", "jobs": 1, - "wall_seconds": 16.0 + "wall_seconds": 15.0 }, { "path": ".github/workflows/pr-base-guard.yml", "jobs": 1, - "wall_seconds": 9.0 + "wall_seconds": 8.0 }, { "path": ".github/workflows/pr-body-decisions.yml", "jobs": 1, - "wall_seconds": 12.0 + "wall_seconds": 9.0 }, { "path": ".github/workflows/pytest.yml", "jobs": 4, - "wall_seconds": 296.0 + "wall_seconds": 616.0 }, { "path": ".github/workflows/runtime-plugin-authenticated-smoke.yml", "jobs": 1 }, + { + "path": ".github/workflows/runtime-plugin-smoke-latest.yml", + "jobs": 1 + }, { "path": ".github/workflows/runtime-plugin-smoke.yml", "jobs": 5, - "wall_seconds": 60.0 + "wall_seconds": 65.0 }, { "path": ".github/workflows/runtime-plugin-validate.yml", - "jobs": 8, - "wall_seconds": 25.0 + "jobs": 9, + "wall_seconds": 28.0 }, { "path": ".github/workflows/script-structure.yml", "jobs": 1, - "wall_seconds": 13.0 + "wall_seconds": 15.0 }, { "path": "dynamic/pages/pages-build-deployment", "jobs": 3, - "wall_seconds": 29.0 + "wall_seconds": 27.0 } ], "required_checks": [ + "doc-line-limit-check", + "glossary-check", "guard", "instruction-files-check", + "lint", "markdown-link-check", + "pr-body-decisions-check", "pytest", + "required-checks-check", "runtime-plugin-build-check", "runtime-plugin-validate", "runtime-smoke (agy)", "runtime-smoke (claude)", "runtime-smoke (codex)", "runtime-smoke (kiro)", + "script-structure-check", "skill-frontmatter-check", - "skill-repo-assumptions-check" + "skill-repo-assumptions-check", + "skill-shell-vars-check" ] }, "services": { @@ -208,22 +219,23 @@ "notes": [] }, "analysis": { - "analyzer": 1, - "at": "2026-09-27T03:04:04+00:00", + "analyzer": 2, + "at": "2026-10-25T04:18:08+00:00", "inputs": { - ".github/workflows/glossary.yml": "5b79e8c79e18ad4df0d8f2f63e909c4481bf1aa6", + ".github/workflows/glossary.yml": "8ca7da924298f00cf75220e28294a9b0bb473743", ".github/workflows/lint.yml": "56129aea1429a46cadbcdc1d99e02eb264cbbb95", ".github/workflows/pr-base-guard.yml": "71c0cda668b533694c48064813b68bbd03cd6efc", - ".github/workflows/pr-body-decisions.yml": "189d6e18ce07444d9947b72cc4f91e2181aeb8db", - ".github/workflows/pytest.yml": "28b588d100201a77607a6e601bb50ac547243698", - ".github/workflows/runtime-plugin-authenticated-smoke.yml": "1235813a9da744349b7a8a632aebdb7e3398d060", - ".github/workflows/runtime-plugin-smoke.yml": "f5ddec37af8f7893488433084f0a2cb082d3f718", - ".github/workflows/runtime-plugin-validate.yml": "a35749abebcf580d6a7bd00175741e2d69cb78fd", - ".github/workflows/script-structure.yml": "69dbe26a83b9728e823e2f68314ed07e025f62be", - "AGENTS.md": "284d264afd238bb74f2450c8c97c45887d081c19", - "CLAUDE.md": "9ddeb20e7b8480012e218e60891971907f886206", - "KIRO.md": "73383b2d2a593eaaba35d626f73931df78da47c1", - "pyproject.toml": "8070c90c878003392767b4fc877edcd5ab9fd9ce", + ".github/workflows/pr-body-decisions.yml": "4c9a78fae1d3766973daa66110ad9c6a212d0fe6", + ".github/workflows/pytest.yml": "fdd26324b1360e1f56c215873ecff6060b161fbc", + ".github/workflows/runtime-plugin-authenticated-smoke.yml": "15e89f83316c0f275f94c1dfff1981a0add61477", + ".github/workflows/runtime-plugin-smoke-latest.yml": "4495e9fe0d54537c07e98dc93d5592258801261c", + ".github/workflows/runtime-plugin-smoke.yml": "ff951c3adc274597eba75904fe5498f5f9175d4e", + ".github/workflows/runtime-plugin-validate.yml": "ff3bfbce63461fca5e024045154bf53487cf16f6", + ".github/workflows/script-structure.yml": "8d07e6bbe7a20e80da9d8fa830d53be7c698a254", + "AGENTS.md": "1f92a717ed8590f528efe8132975c288a16bf6b9", + "CLAUDE.md": "3e34760f5ad5d1df9f965f1acb795e279babdb1a", + "KIRO.md": "f15c72bb7ec964a5c3b04e649972d3948f03b12a", + "pyproject.toml": "4fe2a1d2450f2ce5a90e34f20340a467e35d0d01", "pytest.ini": "47b2c6e3d414cf343d7f45cda0d08980d28b1a5d" }, "branches": { @@ -234,10 +246,9 @@ ] }, "written": { - "languages": "5d3339f70f1b230e90928da915da309a6854c2277d0c14ba9e8fea42c7cb3a75", - "test": "82dfd6cdb46070f65d57323b597a620812d3dc70f945bf55f727498a1867e0dd", - "test_duration": "fee345186423404ef87f5fc2eaad90581a699fc14222e23c9be0a44d81fe24a4", - "ci": "410acdb0a2a2713bb935fa7d61e39474cb512549a87695d6e6b9c74f9e394db9", + "languages": "320a7cb8a0983114f280919dfd4a931307ee34f149b1983226b870e32817124f", + "test_duration": "3dc9bfb50364d4c9e4fea7b4ec4f17084026c8d9d7168f8c87b33647c60aad01", + "ci": "110c8db4b84130256ae88b38e3e4ece30c91da1c568be8cc6ac8b564864d8ab4", "services": "53f4e4c15a04bde773f9d0d89be06cd65f1a9248c0603b06b78bfa8a5f8a0771", "delivery": "d4f1c6e5121b913e9147eb9552c3c78f2a4944bba1562c14c6a4056b1da7ac77", "issues": "c851aa29d3c11745f1307194db49eba6a488771771ea86c21c7237e400e336b8", diff --git a/CHANGELOG.md b/CHANGELOG.md index abd92a74d..1ad052625 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,22 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.22.0] - 2026-10-25 + +- Docs: 開発ループ(--loop)の確定仕様を docs/specifications へ置く(#2089)(#2128) +- `pace: normal` の `new sprint` は、承認ゲート 2 と本番のステージを含むスプリントの目録を書き出す(#2122)(#2133) +- normal のスプリントでは、承認ゲート 2 の後に目録の本番のステージを打って本番へ進める(#2122)(#2133) +- 既に書き出した `sprint.json` を持つ進行中のスプリントでは、`new release --channel prod` で本番のプランを作れる(#2122)(#2133) +- ndf の `cross-refactoring` の init は、範囲テストのテンプレートを試しに走らせます。ファイルを受け取らないテンプレートであれば、init はそこで止まります。(#2123)(#2133) +- ndf の `development-workflow` の宣言の契約に、`{paths}` に入るパスの種類が書かれています。(#2123)(#2133) +- ndf の `development-workflow` の宣言の契約に、ファイルを受けないランナーの書き方が書かれています。(#2123)(#2133) +- ndf の fast / auto ペースの昇格は、導入の確認で確かめたコミットと一致するときだけマージされます。(#2124)(#2133) +- ndf の development-workflow スキルの pace.md に、昇格のプランで確かめたコミットと照合する手順と、止まった後の続け方が載っています。(#2124)(#2133) +- cross-review の最終スイープは、テストの無い宣言があっても止まらず、探し方へ進む(#2125)(#2133) +- cross-review の駆動が出す件数に、継続的統合の判定が載る(#2125)(#2133) +- supervise で駆動が stopped で止まったとき、止まった理由に元の終了コードが添えられる(#2125)(#2133) +- Update: 棚卸しの品質の枠を宣言する(#2131)(#2134) + ## [ndf 10.21.0] - 2026-10-25 - `new close` で `--pace auto`・`--carry`・`--record-pr` を指定できる(#2102)(#2103) diff --git a/README.md b/README.md index 116d006e6..987977e04 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.21.0 | 開発の作業手順(Skill)と専門のサブエージェント。4 つの AI ツールへ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.22.0 | 開発の作業手順(Skill)と専門のサブエージェント。4 つの AI ツールへ同じ名前で入る | [README](./plugins/ndf/README.md) | | **playwright-kit** | 3.0.1 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | 外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex / Kiro CLI 向け)。 @@ -88,7 +88,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.21.0** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.22.0** に入る Skill の数は、AI ツールごとに違います。 Claude Code向け core 48個、Kiro向け core 45個、Codex向け core 44個、agy向け core 44個です。 元Skills(48個)の内訳は次のとおりです。 - Pull Request とレビュー (7): pr, pr-tests, fix, pr-review, cherry-pick-pr, deploy, merged diff --git a/docs/glossary.md b/docs/glossary.md index 7a2edee97..65a08d5bb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -207,11 +207,11 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | JUnit の置き場 | `junit` | テストのコマンドが JUnit XML を書くファイルの、作業ディレクトリからの相対パス(宣言の suites[].junit)。NDF はコマンドへ引数を足さず、このファイルを読む | — | — | — | | スプリント | `sprint` | 1 回のリリースとして出す課題と Pull Request のセット。版数を持つプロジェクトでは 1 つの版になる。期間ではなく、1 回のリリースとして出す中身で切る。工程はスプリント単位で 1 回ずつ通し、モードもスプリントで 1 つにする | ミッション | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | スプリント状態ファイル | `sprint_state` | スプリントのプラン・done・承認ゲートの記録・MVV・版を持つファイル(パスは呼ぶ側が決め、手順書の例は `sprint-state.json`。目録 `sprint.json` とは別のファイル) | ミッション状態ファイル、ミッションの状態 | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | -| 開発ループ | — | スプリントを 1 つ終えるたびに、棚卸しの結果から次のスプリントを選んで続けて始める進め方。引数で切り替え、止まる条件に当たるまで繰り返す | — | — | — | -| 周回 | — | 開発ループの 1 回分。次のスプリントの選定から、そのスプリントの棚卸しと止まる条件の判定まで | — | — | — | -| ループの予算 | — | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数え、超えたら止まる | — | — | — | -| 止まる条件 | — | 開発ループを止める、機械で判定できる条件。予算を超えた・同じ失敗が続いた・着手できる課題が無い・MVV の外の課題しか残らない・利用者が止めた | — | — | — | -| ループの記録 | — | 開発ループの周回ごとの選定・承認ゲートの判定・版・費用と秒・止まる条件の判定を持つ記録。メインディレクトリの .ndf/loop/<名>/loop.json が正本で、laps.md はそこから描画する | — | — | — | +| 開発ループ | — | スプリントを 1 つ終えるたびに、棚卸しの結果から次のスプリントを選んで続けて始める進め方。引数で切り替え、止まる条件に当たるまで繰り返す | — | — | `docs/specifications/ndf-workflow-development-loop.md` | +| 周回 | — | 開発ループの 1 回分。次のスプリントの選定から、そのスプリントの棚卸しと止まる条件の判定まで | — | — | `docs/specifications/ndf-workflow-development-loop.md` | +| ループの予算 | — | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数え、超えたら止まる | — | — | `docs/specifications/ndf-workflow-development-loop.md` | +| 止まる条件 | — | 開発ループを止める、機械で判定できる条件。予算を超えた・同じ失敗が続いた・着手できる課題が無い・MVV の外の課題しか残らない・利用者が止めた | — | — | `docs/specifications/ndf-workflow-development-loop.md` | +| ループの記録 | — | 開発ループの周回ごとの選定・承認ゲートの判定・版・費用と秒・止まる条件の判定を持つ記録。メインディレクトリの .ndf/loop/<名>/loop.json が正本で、laps.md はそこから描画する | — | — | `docs/specifications/ndf-workflow-development-loop.md` | | スプリントブランチ | — | 課題の Pull Request を集め、ベースブランチへの Pull Request をスプリントで 1 本にするブランチ(`sprint/<名前>`) | ミッションブランチ、ミッションのブランチ | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | スプリント課題 | — | 「スプリントを閉じる」が閉じる対象にする課題。スプリントの Pull Request の本文が GitHub の closing keywords(PR 本文に `Closes #123` のように書くと、マージ時にその課題を自動で閉じる機能)で指す課題と、--issues で渡した課題の和 | ミッション課題、ミッションの課題 | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | closing keywords | `closing_words` | GitHub の機能。Pull Request の本文に `Closes #123` のように書くと、マージのときにその課題を自動で閉じる。キーワードは Closes / Fixes / Resolves と活用形で、課題の参照が続く。GitHub を使う人なら誰でも知っている機能ではないため、文書では使う箇所ごとに説明を添える | 閉じる語 | — | `docs/specifications/ndf-sprint-close-referenced-issues.md` | @@ -589,7 +589,7 @@ backlog-refinement と out-of-scope が課題を分類し、起票するとき | 提示の要約値 | — | 承認資料に載せた内容から作る sha256。issue-file.py の create では起票先・題・本文・ラベル、note では打つ先・番号・1 行から作り、同意の後の --approved に渡して、示した内容と打つ内容が同じことを確かめる | — | — | `docs/specifications/out-of-scope-issue-file.md` | | 上流リポジトリ | — | NDF の Skill・エージェント・hook の実体を持つリポジトリ | 配布元のリポジトリ | — | `plugins/ndf/skills/out-of-scope/references/issue-target.md` | | 開発対象リポジトリ | — | NDF を使って開発している側のリポジトリ。gh repo view が返すもの | 開発対象のリポジトリ | — | `plugins/ndf/skills/out-of-scope/references/issue-target.md` | -| 品質の枠 | — | 棚卸しの順位付けで、品質向上の Issue(宣言したラベルの Issue)に先に割り当てる容量の割合。最低の件数を持ち、残りの容量を今の順位で埋める。.ndf/backlog.json の quality 節で宣言したときだけ効く | — | — | — | +| 品質の枠 | — | 棚卸しの順位付けで、品質向上の Issue(宣言したラベルの Issue)に先に割り当てる容量の割合。最低の件数を持ち、残りの容量を今の順位で埋める。.ndf/backlog.json の quality 節で宣言したときだけ効く | — | — | `docs/specifications/ndf-workflow-development-loop.md` | | プロダクトバックログ | — | open の課題を着手の順に並べた全体。マイルストーンの順(上の層)→ マイルストーンの中の順位(下の層)の辞書順で読む | — | — | — | | スプリントバックログの候補 | — | 1 つのマイルストーンの中の課題を順位で並べたもの。次のスプリントはその先頭から容量の分を切り出す | — | — | — | | 遅延コスト | — | 課題の着手を遅らせると失う量の相対値。実害の大きさ・時間的重要度・リスク低減の 3 つの見積の段階の和。略して CoD | — | — | — | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 4ae72a109..5a0fc18eb 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -2536,37 +2536,37 @@ "term": "開発ループ", "context": "ndf-workflow", "meaning": "スプリントを 1 つ終えるたびに、棚卸しの結果から次のスプリントを選んで続けて始める進め方。引数で切り替え、止まる条件に当たるまで繰り返す", - "pending_source": "issues/issue-2089-requirements.md" + "source": "docs/specifications/ndf-workflow-development-loop.md" }, { "term": "周回", "context": "ndf-workflow", "meaning": "開発ループの 1 回分。次のスプリントの選定から、そのスプリントの棚卸しと止まる条件の判定まで", - "pending_source": "issues/issue-2089-requirements.md" + "source": "docs/specifications/ndf-workflow-development-loop.md" }, { "term": "ループの予算", "context": "ndf-workflow", "meaning": "開発ループが使ってよい量の上限。周回の数・費用・経過時間で数え、超えたら止まる", - "pending_source": "issues/issue-2089-requirements.md" + "source": "docs/specifications/ndf-workflow-development-loop.md" }, { "term": "止まる条件", "context": "ndf-workflow", "meaning": "開発ループを止める、機械で判定できる条件。予算を超えた・同じ失敗が続いた・着手できる課題が無い・MVV の外の課題しか残らない・利用者が止めた", - "pending_source": "issues/issue-2089-requirements.md" + "source": "docs/specifications/ndf-workflow-development-loop.md" }, { "term": "ループの記録", "context": "ndf-workflow", "meaning": "開発ループの周回ごとの選定・承認ゲートの判定・版・費用と秒・止まる条件の判定を持つ記録。メインディレクトリの .ndf/loop/<名>/loop.json が正本で、laps.md はそこから描画する", - "pending_source": "issues/issue-2089-design.md" + "source": "docs/specifications/ndf-workflow-development-loop.md" }, { "term": "品質の枠", "context": "ndf-issue-upkeep", "meaning": "棚卸しの順位付けで、品質向上の Issue(宣言したラベルの Issue)に先に割り当てる容量の割合。最低の件数を持ち、残りの容量を今の順位で埋める。.ndf/backlog.json の quality 節で宣言したときだけ効く", - "pending_source": "issues/issue-2089-design.md" + "source": "docs/specifications/ndf-workflow-development-loop.md" }, { "term": "スプリントブランチ", diff --git a/docs/metrics/ndf-token-usage/2026-10-25-3.json b/docs/metrics/ndf-token-usage/2026-10-25-3.json new file mode 100644 index 000000000..f004941e1 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-10-25-3.json @@ -0,0 +1,154 @@ +{ + "per_pr": [ + { + "version": "10.21.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.310", + "reviewers": "claude", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.21.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.310", + "reviewers": "claude", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 374741.7, + "context": 3198684.0, + "out": 10276.0, + "minutes": 103.02016666730245, + "p": 40036.0, + "k": 35.0, + "w5": 0.0, + "w1h": 83770.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 377242, + "rewrite_gap_median": null + }, + { + "version": "10.21.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.310", + "reviewers": "claude", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 18041.0, + "context": 7889.0, + "out": 453.0, + "minutes": 0.12666666666666665, + "k": 1.0, + "w5": 0.0, + "w1h": 7887.0 + }, + { + "version": "10.21.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.310", + "reviewers": "claude", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 4, + "cost": 148679.9375, + "context": 753244.75, + "out": 7631.0, + "minutes": 5.645416666666666, + "k": 20.0, + "w5": 0.0, + "w1h": 37346.0 + } + ], + "external": [ + { + "version": "10.21.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.310", + "reviewers": "claude", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 2, + "context": 163502.5, + "out": 2114.5, + "cost": 68050.15, + "minutes": 0.5113166670004526, + "p": 24023.0, + "k": 6.0, + "w5": 0.0, + "w1h": 25277.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, + "call_seats": 2 + }, + { + "version": "10.21.0", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.310", + "reviewers": "claude", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 5, + "context": 758889.0, + "out": 9222.2, + "cost": 182342.47, + "minutes": 2.603163334528605, + "p": 22884.0, + "k": 15.6, + "w5": 0.0, + "w1h": 50388.4, + "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": 5 + } + ], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.20.0", + "until": "2026-10-25T07:03:00Z", + "sessions": 1, + "sessions_with_pr": 0, + "unlinked_external": 9, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 23 + }, + "released": "10.22.0", + "previous": "2026-10-25-2.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-10-25-3.md b/docs/metrics/ndf-token-usage/2026-10-25-3.md new file mode 100644 index 000000000..eb1aacaeb --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-10-25-3.md @@ -0,0 +1,86 @@ +# ndf の版ごとのトークン消費と所要時間(2026-10-25 集計・10.22.0 の配布) + +**10.22.0 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-10-25T07:03:00Z`。表の最初の行は 10.20.0(前の記録: 2026-10-25-2.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.22.0 --until 2026-10-25T07:03:00Z --min-version 10.20.0 +python3 scripts/token-usage.py --min-version 10.20.0 --until 2026-10-25T07:03:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 無し。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.20.0, 10.21.0, 10.22.0。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.21.0。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(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.21.0 | conductor | 1 | 40.0k | 35.0 | 0.00M | 0.08M | 0 | 0 | 0.00 | +| 10.21.0 | supervisor | 1 | - | 1.0 | 0.00M | 0.01M | - | - | - | +| 10.21.0 | worker | 4 | - | 20.0 | 0.00M | 0.04M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 1 件 / PR を作った会話: 0 件 / 寄せ先の無い外部 CLI: 9 件 / 寄せ先の無い帳簿の行: 0 件 + +換算は input を 1 とした費用(cache read はモデル別: claude-opus-5-5 0.05 / claude-fable-5-1 0.025 / 他 0.1。cache write 5 分 1.25・1 時間 2 / output 5)。入力は input + cache read + cache write。所要は分。 + +### PR 1 本あたり + +| 動いた版 | 会話 | PR | conductor 換算 | supervisor 換算 | worker 換算 | 入力 | 出力 | 所要 | codex 入力 | codex 出力 | kiro credit | claude 席 換算 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.21.0 | conductor | - | - | 1 | 0.37M | 3.20M | 10.3k | 103.0 | +| 10.21.0 | supervisor | judge | claude -p | 1 | 0.02M | 0.01M | 0.5k | 0.1 | +| 10.21.0 | worker | work | claude -p | 4 | 0.15M | 0.75M | 7.6k | 5.6 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.21.0 | conductor | - | - | 1 | 40.0k | 35.0 | 0.00M | 0.08M | 0 | 0 | 0.00M | 0.38M | - | +| 10.21.0 | supervisor | judge | claude -p | 1 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.21.0 | worker | work | claude -p | 4 | - | 20.0 | 0.00M | 0.04M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.21.0 | claude | cross-review | claude-opus-5-5 | 2 | 0.16M | 0.00M | 2.1k | 0.00 | 0.5 | +| 10.21.0 | claude | cross-refactoring | claude-opus-5-5 | 5 | 0.76M | 0.00M | 9.2k | 0.00 | 2.6 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.21.0 | claude | cross-review | claude-opus-5-5 | 2 | 24.0k | 6.0 | 0 | 0 | 0.00M | 0.00M | - | +| 10.21.0 | claude | cross-refactoring | claude-opus-5-5 | 5 | 22.9k | 15.6 | 0 | 0 | 0.00M | 0.00M | - | diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 1c298e449..c32323321 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -95,5 +95,6 @@ | [ndf-sprint-misjudgments-unknown-record-and-manual-none.md](ndf-sprint-misjudgments-unknown-record-and-manual-none.md) | スプリントの誤判定: 記録の置き場が分からない close は課題を開いたまま残し、「無し」の手動確認の行を数えない(#1875 #1675) | | [ndf-slide-generation.md](ndf-slide-generation.md) | スライドの生成スクリプトの経路(汎用テンプレートのページの枠組みと座標・theme.json の重ね合わせと検査・台本からのスピーカーノートと尺・build-deck.js と script-time.js と slides-thumbnails.py の終了コード・常に成り立つ条件・決定の理由)。作り方の手順は release の form-slide.md が正 | | [ndf-document-rebuild.md](ndf-document-rebuild.md) | 文書の組み直し(文書形式と文書の目的の切り方、document-style との正本の割り当て、キーワードと読み手役の確かめ・題の付け方の置き場、スラッシュコマンドの一覧から外す Skill、常に成り立つ条件と決定の理由)。手順は document-rebuild の SKILL.md が正 | +| [ndf-workflow-development-loop.md](ndf-workflow-development-loop.md) | 開発ループ(`--loop`)。`sprint-state.py loop` の副命令の契約(終了コード 0/1/3・止まった結果の形)、選定の規則、承認ゲートで止まったときの扱い、止まる条件と再開、ループの記録(`.ndf/loop/<名>/`)の形、`new close --pace auto` と品質の枠、常に成り立つ条件、決定の理由と外部の動向。手順は `development-workflow` の `references/loop.md` が正 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-release-approved-commit.md b/docs/specifications/ndf-release-approved-commit.md index a88382342..9d1ff9820 100644 --- a/docs/specifications/ndf-release-approved-commit.md +++ b/docs/specifications/ndf-release-approved-commit.md @@ -51,7 +51,7 @@ | 扱う | 扱わない | | --- | --- | | `release-steps.py approval-facts` が承認したコミットを書くこと | `--channel dev` の振る舞い(開発版はマージした変更がそのまま載るチャネルで、承認を要しない。引数も結果も変わらない) | -| `release-steps.py approve` が承認の記録を書くこと、`mvv-gate.py check --gate release` が `by: mvv` で書くこと | 昇格の経路(`merged-steps.py promote`)。比較の関数は promote からも呼べる形で `lib/` に置いてある(「決定の理由」) | +| `release-steps.py approve` が承認の記録を書くこと、`mvv-gate.py check --gate release` が `by: mvv` で書くこと | 昇格の経路(`merged-steps.py promote`)。昇格は `compare_approved` を使わず、導入の確認の承認資料の確かめたコミットを `--expect-head-from` で受けて `--expect-head` と同じ照合を使う(#1529。振る舞いの正本は `merged-steps.py` の冒頭) | | `release-steps.py release --channel prod` が承認したコミットを受け取り、承認の外のコミットがあれば止まること | 承認資料のほかの欄、MVV 判定の判定の中身 | | `merged-steps.py merge-when-green --expect-head` が比べた先端だけをマージすること | 食い違ったときに承認資料を自動で作り直して再承認を求める仕組み(止まって人へ戻すまでを扱う) | | 本番のリリースプラン(`supervise.py new release --channel prod`。`--mvv` 付きを含む)が承認資料を渡すこと | | @@ -154,7 +154,7 @@ A = 承認したコミット、T = 比べた先端、H = 配布の PR の先端 | 4 | `git rev-list --no-merges A..T ^H` から、配布の PR の `mergeCommit` と、配布の PR のコミットと同じ `git patch-id --stable` のものを除いた残り | `outside_commits`(I9) | | 5 | `git merge-tree --write-tree A H` の木と `T^{tree}` | 等しければ `match`、違えば `tree_differs`、`merge-tree` が失敗すれば `undecidable` | -`allowed`(入ってよい PR)を空にすると、5 は「T の木が A の木と等しいか」になる(昇格の経路から使う形)。 +`allowed`(入ってよい PR)を空にすると、5 は「T の木が A の木と等しいか」になる(昇格の経路はこの形を使わない。#1529)。 `allowed` が 2 つ以上のときは期待する木を作らず `undecidable` を返す。`git merge-tree --write-tree` は git 2.38 からで、 使えない git では 0 以外を返し `undecidable`(I6)に倒れる。 @@ -241,7 +241,7 @@ git を読むだけで書かず、終了コードも出力も持たない。ブ | 比べる時点は配布の PR のマージの後の 1 つ | 配布の PR はベースブランチへ入るだけで本番へは届かない。マージの前にも比べると判定の時点が 2 つになる。承認したコミットの形と有無・承認の記録だけは最初に確かめ、何もマージせずに止まる | | 判定できないときは承認ゲートで止まる(I6) | 通すと承認の外の変更を見落としうる。失敗(1)にすると、本番のリリースプランの judge が直す(fix)か同じステップをもう一度(retry)を選び、人の承認を経ずに打ち直されうる | | `--mvv` 付きのプランは承認ゲートで `handoff` へ進む(I7) | MVV 判定が承認したのは承認資料の中身で、承認の外のコミットは判定の材料に入っていない。人が外のコミットを見てから承認し直す。`gate_next` を置かないと、10 を受けて `next` の `verify` へ進み、配っていない版の導入を確かめに行く | -| 比べる関数を `lib/` に置く | 昇格の経路にも同じ欠けがあり、`release-steps.py` の中に置くと同じ役割の関数が 2 つになる。`allowed` を空にすれば promote の判定になる | +| 比べる関数を `lib/` に置く | 昇格の経路にも同じ欠けがあり、`release-steps.py` の中に置くと同じ役割の関数が 2 つになる。`allowed` を空にすれば promote の判定になると見込んだが、#1529 は木が等しい別のコミットを通すため採らず、昇格は `--expect-head` の照合を使う | ## 既知の制約 @@ -268,7 +268,7 @@ git を読むだけで書かず、終了コードも出力も持たない。ブ | MVV 判定の記録(I10) | `mvv-gate.py check --gate release` が従うと判定したときだけ承認資料へ `by: mvv` の承認の記録を書き、判定できない場合と `--advise` では書かないこと(`test_mvv_gate.py`) | | 本番のリリースプラン(I7) | 本番の `release` のステップが `--approval <承認資料>` を持ち、`--mvv` 付きは `gate_next: handoff`、無しは `end` であること。開発版のステップに `--approval` も `gate_next` も無いこと(`test_supervise_new.py`) | | 退行しない | `--channel dev` の引数・`items`・`metrics` が変わらないこと。`--channel prod` で配布の PR がマージ済みのとき、打ち直しで続きから進み、一致すれば `ok` になること | -| 昇格の形 | `allowed` を空にしたとき、先端の木が承認したコミットの木と等しいかで判定すること | +| `allowed` が空の形 | `allowed` を空にしたとき、先端の木が承認したコミットの木と等しいかで判定すること | ## 関連リンク diff --git a/docs/specifications/ndf-supervise-base-sync.md b/docs/specifications/ndf-supervise-base-sync.md index 1a805b482..a7b98c714 100644 --- a/docs/specifications/ndf-supervise-base-sync.md +++ b/docs/specifications/ndf-supervise-base-sync.md @@ -282,6 +282,7 @@ sequenceDiagram 止まったときの理由は「ステップ が失敗した(exit=N)」に、そのステップの `summary` と、`on_fail` でそこへ回した直前のステップの `: ` が続く(決定 7)。実装のプランは `base-return` の要約が、検査のプランは `abort` の理由の後に `base-return:` の要約が載る。 +どちらの `summary` も、結果 JSON の `status` が `stopped` で `metrics.exit` が整数なら、後ろへ「(元の終了コード N)」が添う。 ## セキュリティ diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md new file mode 100644 index 000000000..6eec23341 --- /dev/null +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -0,0 +1,405 @@ +# 開発ループ(`--loop`): 棚卸しの順位から次のスプリントを機械で選び、レッドラインと止まる条件のときだけ止まって周回を続ける + +## 目的 + +- **スプリントの境で人の手が止まらない。** `/goal /ndf:development-workflow --loop <名>` を 1 回起動すれば、NDF が + 「選ぶ → 設計 → 実装 → 検査 → 配布 → 振り返り → 棚卸し → 次を選ぶ」を繰り返す。周回のスプリントは `pace: auto` で組み、 + 承認ゲート 1・2 は `mvv-gate.py check` の判定だけで通る +- **人が手を止めるのはレッドラインと止まる条件のときだけである。** 承認ゲート 1 で「従う」以外になった課題は人の判断待ちへ + 外して残りで続け、承認ゲート 2 で人へ戻したときだけ問う。予算・同じ失敗・着手できる課題が無い・MVV の外しか残らない・ + 利用者の止める指示では、問わずに理由と再開のコマンドを残して止まる +- **どの周回も後から追える。** 何を選び(外した課題と理由を含む)、承認ゲートでどう判定され、何の版を出し、いくら使い、 + なぜ止まったかが、ループの記録 1 か所に残る +- **`--loop` を付けない起動は今と同じに動く。** ループの記録を作らず、棚卸しの後に次のスプリントを始めない + +例: マイルストーン「01 近い」の順位が上から #11・#12・#13 で、切り出しの境界が #12 のとき、今の実装は次のように動く。 + +| 順 | 起きること | +| --- | --- | +| 1 | `sprint-state.py loop start l20261025` が使ってよい条件を確かめ、`.ndf/loop/l20261025/loop.json` を作る(`status: new`) | +| 2 | `loop select` が `upkeep.py rank` を打ち、#11・#12 を「順位」で選び、#13 を「順位(切り出しの境界の外)」で外して周回 1 を足す | +| 3 | conductor がモードと版を決め、`loop sprint --mode standard --dev 10.20.0-dev.1` が `sprint-state.py init`・`new sprint --pace auto`・`new close --pace auto` を打って `pipeline` を返す | +| 4 | #12 の設計が承認ゲート 1 で「判定できない」。`loop lap --done` が #12 を人の判断待ちへ移し、課題の本文に `## 人の判断待ち(開発ループ l20261025)` を書き、#11 だけの続きの `pipeline` を返す | +| 5 | 本番とまとめの後、`loop lap` が周回 1 を `完了` で締め、止まる条件に当たらないので `ndf_next` を返す。ラッパーが次のセッションを起動する | +| 6 | 3 周を終えた境で `budget_laps` に当たり、終了コード 3 と `[ndf-loop] stopped l20261025 budget_laps` の 1 行で止まる | + +**手順は Skill が持つ。** ここに書き写さない。 + +| 何を | 正本 | +| --- | --- | +| `--loop` を受けたら手順を移す入口 | `development-workflow` の [SKILL.md](../../plugins/ndf/skills/development-workflow/SKILL.md) の「開発ループ(`--loop`)」 | +| conductor が周回ごとに打つ順・止まったときの出し方・人の判断待ちの答え方 | [references/loop.md](../../plugins/ndf/skills/development-workflow/references/loop.md) | +| `.ndf/pace.json` の `loop` 節の項目の表 | [references/pace.md](../../plugins/ndf/skills/development-workflow/references/pace.md) の「設定(`.ndf/pace.json`)」 | +| 引継ぎ文書の名 `loop-<ループ名>` | [references/handoff.md](../../plugins/ndf/skills/development-workflow/references/handoff.md) | +| 棚卸しの `quality` 節(品質の枠)の書き方 | `backlog-refinement` の [references/ranking.md](../../plugins/ndf/skills/backlog-refinement/references/ranking.md) | + +この文書が扱うのは、`sprint-state.py loop` の副命令の契約、選定・止まる条件・再開の規則、ループの記録の形、 +常に成り立つ条件、決定の理由、テスト観点である。 + +## 用語 + +定義は [用語集](../glossary.md) が正である(`.ndf/glossary.json` の `document`)。この文書で使う語は +「開発ループ」「周回」「ループの予算」「止まる条件」「ループの記録」「品質の枠」「承認ゲート」「人の判断待ち」である。 + +## 対象範囲 + +| 扱う | 扱わない | +| --- | --- | +| `sprint-state.py loop` の副命令(`start` / `select` / `exclude` / `sprint` / `gate` / `answer` / `approve-mvv` / `lap` / `stop` / `status` / `list`) | 今の 1 スプリントの進め方(`normal` / `auto` / `fast`)の振る舞い | +| `.ndf/pace.json` の `loop` 節と `read_loop` | `mvv-gate.py` の判定の規則・レッドラインの定義・`boundary_paths` の既定(ループのために緩めない) | +| `supervise.py new close` の `--pace auto`・`--carry`・`--record-pr`、`new sprint` の `--designed` | 持ち越しをマシンをまたいで残す置き場(#1640) | +| `upkeep.py rank` の品質の枠(`.ndf/backlog.json` の `quality` 節) | Codex・Kiro・agy でのラッパーと同じ自動の切り替え(ラッパーは Claude Code だけ) | +| 引継ぎ文書の名 `loop-<ループ名>`(`handoff.py`) | このリポジトリの `.ndf/pace.json` でループを有効にすること(共通原則の C7。利用者が別に決める) | + +ループは NDF の安定版の経路にあり、配布先のリポジトリでも働く。自動の切り替えは Claude Code のラッパー(`relay.py`)の +下だけで働く。ほかのランタイムとラッパーの外では、周回の境とカットポイントで `ndf-next` を出して止まり、利用者が貼り直せば +同じ周回から続く。`loop` 節の無いリポジトリでは何も変わらない。 + +## 背景 + +`/goal /ndf:development-workflow` は 1 スプリントの振り返り・棚卸しで終わっていた。次のスプリントを始めるには、人が順位を +読んで課題を決め、`/goal` の文面を書き直していた。止まる条件と選んだ理由は、会話ごとに手で書く `relay.md` のテンプレートの +文面にしか無かった。利用者の依頼(2026-10-25)は「振り返りと Issue の見直しの後、見直しの結果から次のスプリントを設定して +自動で始める」「`pace: auto` を基本とし、MVV のレッドライン以外で承認を一切求めない」「今の goal を決めた進め方も引数で +切り替えて残す」であり、#2089 で入れた(ndf 10.21.0)。 + +位置付けは Shapiro の 5 段階の Level 4(人は仕様と MVV の水準で関わり、後から確かめる)で、Level 5(dark factory)ではない。 +MVV のレッドラインで人へ戻すという要求がそれを決める。 + +## 仕様 + +### 構成要素と責務 + +| 要素 | 責務 | +| --- | --- | +| [`sprint-state.py`](../../plugins/ndf/scripts/sprint-state.py) の副命令 `loop` | 入口。引数の解析と振り分けは [`lib/sprint_loop_cli.py`](../../plugins/ndf/scripts/lib/sprint_loop_cli.py)。`loop sprint` の `--` から後は解析せず `new sprint` へそのまま渡す。既存の副命令は変えない | +| [`lib/sprint_loop.py`](../../plugins/ndf/scripts/lib/sprint_loop.py) | `start`(使ってよい条件・再開)・`sprint`・`answer`・`approve-mvv`・`stop`・`lap`・`status`・`list` | +| [`lib/sprint_loop_select.py`](../../plugins/ndf/scripts/lib/sprint_loop_select.py) | 選定(`select` / `exclude`)。入力は `rank` の出力・記録・マイルストーンの説明だけ | +| [`lib/sprint_loop_gate.py`](../../plugins/ndf/scripts/lib/sprint_loop_gate.py) | 承認ゲートで止まった done の扱い(`gate`。`lap --done` も同じに扱う)・続きの queue・まとめのステージ(`new close --pace auto`)の組み立て・周回の締め | +| [`lib/sprint_loop_record.py`](../../plugins/ndf/scripts/lib/sprint_loop_record.py) | 記録の読み書き(排他・原子的な書き込み・終わった周回の書き換えの拒否)・消費・止まる条件の判定・再開のコマンド・引継ぎ文書・`laps.md` の描画。外の世界(gh・`upkeep.py rank`・`supervise.py`)は `Env` の関数を通して呼び、テストで差し替える | +| [`lib/pace.py`](../../plugins/ndf/scripts/lib/pace.py) の `read_loop` | `loop` 節を読み、既定(`LOOP_DEFAULTS`)を埋めて形を確かめる。`read_pace` は `loop` 節を検証しない | +| [`supervise_lib/sprint.py`](../../plugins/ndf/scripts/supervise_lib/sprint.py)・[`new_args.py`](../../plugins/ndf/scripts/supervise_lib/new_args.py) | `new close --pace auto` でまとめのステージだけを組む。`--carry`・`--record-pr`、`new sprint --designed` | +| [`handoff.py`](../../plugins/ndf/scripts/handoff.py) | 名の形(`NAME_RE`)に `loop-<名>` を持つ | +| `backlog-refinement` の [`upkeep_rank.py`](../../plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank.py) の `quality_slot`・[`upkeep_rank_config.py`](../../plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank_config.py) の `_parse_quality` | 品質の枠を取り、切り出しの境界から枠の分を除く | +| 既存のまま使うもの | `relay.py`(セッションの切り替え)・`mvv-gate.py`(判定とレッドライン)・`supervise.py new sprint --pace auto` / `queue` / `wait` / `run` | + +**判断は conductor に残す。** モードの判定と版の決め方はスクリプトにしない。`auto` が許さないモード(`operation` / +`documentation`)の課題と MVV に反する疑いのある課題は、conductor が `loop exclude` で外して選定をやり直す。 +スクリプトは LLM を呼ばない。 + +### 常に成り立つ条件 + +| # | 条件(保つこと) | 破れたときの扱い | +| --- | --- | --- | +| I1 | 周回は追記だけで、番号は 1 から 1 ずつ増える。終わった周回(`outcome` が `進行中` でない)の行は書き換えない | 書き込みを拒み、記録が壊れているとして `stopped`(終了コード 1) | +| I2 | 進行中の周回は高々 1 つ | `select` が「進行中の周回がある」で 1 | +| I3 | 予算・同じ失敗・止める指示は周回の境(選定の前と周回を締めた後)でだけ判定する。周回の途中で `stop` を書くのは `gate2_returned`・`mvv_changed`・`refused` だけで、周回は進行中のまま残す | 途中の止める指示は次の境まで効かない | +| I4 | 選定は入力(`rank` の出力・記録の人の判断待ちと除外・`approved` の行の設計 PR の状態・ループの設定・スプリント MVV の承認)だけで決まり、同じ入力からは同じ組と並びが出る | テストで落とす | +| I5 | 答えの無い人の判断待ちの課題と、差し戻した課題は選ばない | 選定の行に理由つきで外す | +| I6 | ループは承認ゲートを自分で通さない。`by: mvv` の承認の記録を書くのは `mvv-gate.py` だけで、「従う」以外の判定を通過として扱わない | テストで落とす | +| I7 | 止まったループは、`loop start` が止まった理由を判定し直して解けたと確かめるまで、周回を始めない | `select` が止まったままの結果(3)を返す | +| I8 | 予算の消費(周回の数・費用・動いていた時間)は再開をまたいで引き継ぐ。動いていた時間は、終わった周回の `started_at`〜`ended_at` の和から、`stops` の `at`〜`resumed_at` のうち各周回の区間と重なる部分を引いたもの。周回の境で止まっていた時間は引かない | テストで落とす | +| I9 | 周回を始めるときと承認ゲートで止まったときのプロジェクト MVV の sha256 は、ループの開始時の値と一致し、承認済みである | `mvv_changed` で止まる | + +### 起動の引数と再開のコマンド + +```text +/goal /ndf:development-workflow --loop [<ループ名>] [--milestones ,...] [--budget-laps N] [--budget-usd X] [--budget-hours H] +``` + +| 引数 | 意味 | 無いとき | +| --- | --- | --- | +| `<ループ名>` | 記録の名前(英数字・`-`・`_`。先頭は英数字)。同じ名前の記録があれば再開する | `l` | +| `--milestones` | 選定の対象を絞る | 開いているマイルストーンすべて(`rank` の `order`) | +| `--budget-*` | 予算を上書きする。変わったときだけ `budget_history` に `{at, from, to}` を 1 行足す | `loop.budget` | + +再開のコマンド(`ndf-next` の中身。`resume_command`)は +`/goal /ndf:development-workflow --loop <名> -- 終わり: [ndf-loop] stopped <名> の行を出したとき` である。 +名前を必ず含め、`loop start --no-goal` で始めた記録(`goal: false`)では先頭の `/goal ` を付けない。`--` から後ろは +`/goal` の終わりの条件で、Skill は引数として読まない。 + +### 設定(`.ndf/pace.json` の `loop` 節) + +```json +{"loop": {"enabled": false, "budget": {"laps": 3, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 3}} +``` + +| 項目 | 形 | 無いとき | 誤っているとき | +| --- | --- | --- | --- | +| `loop.enabled` | `true` だけが真 | 偽(ループを断る) | — | +| `loop.budget` | オブジェクト。`laps`・`usd`・`hours` の正の数(bool は不可)。欠けたキーは既定で埋め、ほかのキーは不可 | 3 / 60 / 24 | `PaceError` | +| `loop.same_failure` / `loop.max_issues` | 1 以上の整数(bool は不可) | 2 / 3 | `PaceError` | + +`PaceError` は `loop start` だけを断る。`read_pace` を使う `fast` / `auto` / `check-trigger` / `mvv-gate` は節の誤りの +影響を受けない。 + +### 副命令の契約(`sprint-state.py loop`) + +結果は既存と同じ 1 行の JSON(`tool: sprint-state`)。終了コードは 0 = 進める / 1 = 断った・呼び出しの順の誤り / +3 = 止まる条件に当たった(`step_result` の「正常な否定の結果」。失敗ではない)。1 と 3 は `metrics.status` の +`refused` / `stopped` でも見分ける。4 は `merge-when-green` が使っているため使わない。 + +| 副命令 | 主な入力 | 0 のときの `metrics` | 1 / 3 | +| --- | --- | --- | --- | +| `start [<名>] [--milestones] [--budget-*] [--no-goal]` | `loop` 節・`auto` 節・プロジェクト MVV・記録 | `status`(`new` / `resumed` / `in_lap`)・`next`(`in_lap` は引継ぎ文書の「次にやること」を指す。`resumed` は `loop select` か、止まった理由ごとの続き) | 1 = 使ってよい条件に当たらない(`items` に理由、`next` に `/ndf:development-workflow #<課題>`。記録を作らない)/ 3 = 止まった理由が解けていない | +| `select <名>` | `upkeep.py rank`(その場で打つ)・マイルストーンの説明・記録 | `status: selected`・`selected`・`excluded`・`milestone`・`rank_digest` | 1 = 進行中の周回がある・`rank` が 0 と 20 以外を返した / 3 = 止まったまま・境の判定で止まった・`no_issue` / `outside_mvv` | +| `exclude <名> <課題> --reason <文>` | 選定の後・`sprint` の前の進行中の周回 | `select` と同じ形(選定をやり直す) | 1 = スプリントを組む前の周回が無い / 3 = すべて外れた(その周回を取り下げ、除外の行は残す) | +| `sprint <名> --mode M --dev V [--prod V] [-- ]` | 進行中の周回の選定・前の周回の `refine.deferred` | `status: sprint`・`state_path`・`out`・`manifest`・`close_command`・`queue`・`pipeline` | 1 = 進行中の周回が無い・承認ゲート 1 の後の周回 / 3 = `init`・`new sprint`・`new close` のどれかが断った(`refused`) | +| `gate <名> --done ` | 承認ゲートで止まった done・`mvv-gate.jsonl`・スプリント状態 | `status: continue`・`gate: 1`・`waiting`・`pipeline`(残りで続く)、または周回を締めて `ndf_next` | 1 = 組んだ周回が無い・done に関門が無い / 3 = `gate2_returned`・`mvv_changed`・続きを組めない(`refused`)・締めた後の境の判定 | +| `answer <名> <課題> --approved\|--rejected [--pr N]` | 答えの無い人の判断待ちの行 | `status: answered` | 1 = 待っていない課題 | +| `approve-mvv <名> --milestone M` | マイルストーンの説明 | `status: approved`(`sprint_mvv_approvals` に `{milestone, sha256, at, by: user}`) | 3 = 説明に `## Mission` / `## Vision` / `## Value` がそろわない | +| `lap <名> [--failed] [--done D] [--deferred-issues N...]` | スプリント状態(`update` を内で打つ)・done・upkeep の記録 | `status: continue`・`ndf_next`(`ndf-next` のブロック) | done に関門があれば `gate` と同じ / 1 = 進行中の周回が無い / 3 = 境の判定で止まった | +| `stop <名>` | — | `path`(`.ndf/loop/<名>/stop` に時刻を書く) | — | +| `status <名> [--json]` / `list <名> [--json]` | 記録・最後の周回の `relay_dir` の `log.jsonl` | `status`・`consumed`・`budget`・`waiting`・止まっていれば `stop` と `stop_line`・ラッパーが上限で止めていれば `relay_max_starts` / 周回の一覧(何周目・選んだ課題・版・費用・秒・結果・止まった理由・後へ回した Issue) | 1 = 記録が無い。`--json` が無ければ文で出す | + +**止まった結果は形が決まっている。** `items` は `[{condition, values, resume}]`、`metrics` は `status: stopped`・`stop`・ +`stop_line`(`[ndf-loop] stopped <名> `)・`consumed`。止まるたびに引継ぎ文書の「次に実行するコマンド」を +再開のコマンドへ置き換える。 + +**`ndf_next` は周回を締めて続くときだけ返る。** 止まったときは返らない。conductor は `ndf-next` のブロックを出さず、 +`stop_line` を最後の行に写す(手順は `loop.md` の「止まったとき」)。 + +### 選定(`select` / `exclude`) + +マイルストーンを `rank` の `order` の順(`--milestones` で絞ったときはその中だけ)に見て、1 件でも選べた最初の +マイルストーンで決める。1 つのスプリントは 1 つのマイルストーンから組む。 + +1. 候補は、切り出しの境界(`boundary.number`)までの行(境界が無ければ先頭から `max_issues` 件。`boundary.empty` なら + 0 件)、大きい課題の枠が当てたとき(`large_slot.status` が `当てた`)の `large_slot.slice`、品質の枠の課題(`quality_slot.issues`)である +2. 説明にスプリント MVV(`## Mission` / `## Vision` / `## Value`)があり、その sha256 が `sprint_mvv_approvals` に無い + マイルストーンは、候補をすべて「スプリント MVV が未承認」で外す +3. 候補ごとに外す理由を順に見る: 「人の判断待ち(棚卸し)」(`waiting_decision`)→「人の判断待ち」(答えの無い行)→ + 「差し戻し」(`rejected`)→「設計 PR がマージ待ち」(`approved` で `merged_at` が無く、`gh pr view` が `MERGED` でない。 + `MERGED` なら `merged_at` を足して以後打たない)→ この周回の `excluded` の理由 → + 「周回の中で起票された」(周回の `created`)→「容量(尺度を超える)」 +4. 外れなかった候補に `max_issues` 件の枠を、品質の枠の課題から先に、残りを順位の順に割り当てる。選んだ行の理由は + 「品質の枠」か「順位」、枠からあふれた行は「容量」で外す +5. 選べたら、そのマイルストーンの候補でない行を「順位(切り出しの境界の外)」で外した行として足す。外した行も + `selection` に載せ、`rank` の `metrics.digest` を `rank_digest` に書く。スプリント MVV があれば周回のディレクトリの + `mvv.md` に写し、`loop sprint` が `sprint-state.py init --mvv` へ渡して承認ゲート `MVV` の記録を状態へ写す +6. どのマイルストーンでも選べず、候補が 0 件なら `no_issue`、候補があってすべて外れたなら `outside_mvv` で止まる + (`values` は対象のマイルストーンか理由ごとの件数) + +`rank` の 20(段階の欠けた課題がある)は、欠けた課題が `rows` に載らないだけなので続ける。 + +### 周回のスプリントとまとめのステージ(`sprint`) + +`loop sprint` は周回のディレクトリ(`.ndf/loop/<名>/lap-/`)に状態ファイル `state.json` を置き、次の 3 つを順に打つ。 + +| 打つもの | 契約 | +| --- | --- | +| `sprint-state.py init --name <名>- --pace auto --issue ... --dev V --prod V --goal <再開のコマンド> [--mvv mvv.md --milestone M]` | `--prod` を省けば開発版の接尾辞を外した版 | +| `supervise.py new sprint --pace auto --issue <選んだ課題> --design <承認済みでない課題> [--designed ] ... [<-- の後の引数>]` | `loop answer --approved` で答えた課題は設計のプランを作らず、設計の結果のステージが設計 PR の設計の結果を読む | +| `supervise.py new close --pace auto --issue <選んだ課題> --state [--carry <前の周回の refine.deferred>] [--milestone M]` | まとめのステージ(確定仕様化 → Pull Request → 課題を閉じる → 振り返り → 棚卸し)だけを組む。最終の検査・開発版・本番は置かず、`fast` 節を読まない | + +`pipeline` は、最初のステージの queue の最後に `--then <まとめのプラン>` をつなぎ、`supervise.py wait ` → +`loop lap <名> --done ` を続けた 1 行である。手で行うステージか別の queue のステージが残るときは、それを終えてから +まとめを流す文になる。 + +`new close` に足した引数の契約: + +| 引数 | 意味 | 無いとき | +| --- | --- | --- | +| `--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` を渡さない。渡すのは承認ゲート 2 の承認の後にまとめを組み直すとき(`stop.values.pr`)だけである。 + +### 承認ゲートで止まったとき(`gate`、`lap --done`) + +| 止まった所 | 扱い | +| --- | --- | +| どこでも、プロジェクト MVV が開始時と違う・未承認 | `mvv_changed` で止まる | +| 設計のプラン(承認ゲート 1)。判定が「従う」以外・`boundary` が空でない・機械のチェック・判定を読めない | 計画の課題を `waiting` へ移し(`{number, reason, verdict, basis, gate: 1, pr, lap, at}`。`pr` は報告の設計 PR、判定は `mvv-gate.jsonl` のその PR の最後の行)、課題の本文の節 `## 人の判断待ち(開発ループ <名>)` を書き直す。設計が完了も関門もしなかった課題は続きから外す。残った課題(`issues_kept`)で、関門 1 の後のステージから外した課題だけの計画を除き、共通の計画の `課題` と `closes` からも外し、まとめを組み直して最後の `--then` につなぐ | +| 同上で残りが無い | 周回を `人へ戻した`(設計が完了しなかった課題があれば `失敗`)で締め、境の判定へ進む | +| 同上で続きを組めない | `refused`(`step: 続きの queue`)で止まる | +| 承認ゲート 1 以外(承認ゲート 2) | 判定の写しを周回へ足し、止まった計画を `gate2_plan` に控え、`gate2_returned`(`values: {pr, verdict, reasons}`)で止まる。本番は出さない | + +承認ゲート 2 の答えは、止まった時刻より後の、周回の状態の `関門 2` の利用者の記録(`by` が `mvv` でない)と +`gate-rejections.jsonl` の差し戻しのうち最新のもので決める。 + +`loop answer --approved` は `sprint-state.py gate <その周回の状態> 関門 1 --by user --outcome approved [--pr N]` を内で打つ。 +`--rejected` は `--outcome rejected` で差し戻しの記録を残す。どちらも課題の本文の節を書き直す。 + +### 止まる条件と再開 + +周回の境の判定(`boundary_stop`)は次の順で、最初に当たったものを止まった理由にする。判定に使った値は `values` に残す。 + +| 順 | `condition` | 判定 | `values` | +| --- | --- | --- | --- | +| 1 | `user_stop` | `.ndf/loop/<名>/stop` がある | `{at}` | +| 2 | `mvv_changed` | プロジェクト MVV が未承認か、sha256 が開始時と違う | `{started, now}` | +| 3 | `same_failure` | 終わった周回を新しい順に見て、`失敗` の周回が同じ `failure` の鍵で `same_failure` 回以上続く。`完了` の周回で数え直す。`same_failure` で止まった後の最後の再開(`resumed_at`)より前に始まった周回は数えない。ほかの理由の再開は区切りにしない | `{key, count}` | +| 4 | `budget_laps` / `budget_usd` / `budget_hours` | 終わった周回の数・`cost` の和・動いていた時間(I8)が予算以上 | `{consumed, budget}` | + +選定の時点では `no_issue` / `outside_mvv`、周回の途中では `gate2_returned` / `mvv_changed` / `refused` +(`{step, exit, summary}`)が加わる。`stop` は `{condition, values, lap, at}` の形で、解けたら `resumed_at` を足して +`stops` へ移す。 + +失敗の鍵は、スプリント状態の計画の行のうち結果が `まだ`・`流さなかった`・`完了`・`関門` でない最初の行の +`{issues, kind, step}` である。理由の文は含めない。`loop lap --failed` で締めて該当の行が無ければ +`{issues: 選んだ課題, kind: 周回, step: conductor}` にする。周回は、`--failed`・done に完了でも関門でも流さなかったでもない +結果がある・失敗の鍵がある、のどれかで `失敗`、ほかは `完了` で締まる。 + +`loop start` の再開は止まった理由ごとに次のとおりである。 + +| 止まった理由 | 解けたかの見方と、その後 | +| --- | --- | +| `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 行を載せる。続けるステップが無い計画は、手で行う本番を先に示す | +| `refused`(続きの queue) | 人の判断待ちへ移した課題を戻さず、残した課題で続きを組み直す。組めなければ 3 | +| `refused`(ほか) | `next` に同じ周回での `loop sprint` のやり直しを載せる(モードと版は conductor が決め直す) | +| `mvv_changed` | 解けない。新しい名前でループを始め直すよう示す(開始時の値を今の値へ置き換えない) | + +### ループの記録(データ) + +**置き場はメインディレクトリで、コミットしない。** worktree の後片付けで消えず、`git status` に `?? .ndf/loop/` と出る。 +`.ndf/` の設定(C7)ではなく、スクリプトが作る記録である。読み書きは `locks.exclusive`(60 秒)で囲み、 +`jsonio.write_atomic` で書く。 + +| 置き場 | 中身 | +| --- | --- | +| `.ndf/loop/<名>/loop.json` | 正本(下の表) | +| `.ndf/loop/<名>/laps.md` | 人が読む描画(周回の一覧・周回ごとの詳細・人の判断待ち・止まった理由)。記録を書くたびに作り直す | +| `.ndf/loop/<名>/lap-/` | 周回のスプリント状態・プランの出力・done・`mvv.md` | +| `.ndf/loop/<名>/stop` | 止める指示(`loop stop` が置き、`user_stop` の再開で消す) | +| `.ndf/handoff/loop-<名>.md` | 引継ぎ文書。「現在地」「次にやること」「次に実行するコマンド」だけを置き換える | + +`loop.json`(`version: 1`): + +| 項目 | 意味 | +| --- | --- | +| `name` / `repo` / `started_at` / `goal` | 名前・`OWNER/REPO`・開始の時刻(再開で変えない)・`/goal` で始めたか | +| `project_mvv` | 開始時の承認済みプロジェクト MVV(`{version, sha256}`) | +| `budget` / `budget_history` | 効いている予算と上書きの履歴 | +| `same_failure` / `max_issues` / `milestones` | 開始時に設定と引数から写した値 | +| `status` / `stop` / `stops` | `running` / `stopped`・今の止まった理由・過去の止まった理由(追記だけ) | +| `laps[]` | 周回(追記だけ。I1) | +| `waiting[]` | 人の判断待ち。答えは行を消さずに `answer`・`answered_at` を足す。`approved` の行はマージを確かめた時刻 `merged_at` を足す | +| `excluded[]` | `{number, reason, lap}`(`loop exclude`) | +| `sprint_mvv_approvals[]` | `{milestone, sha256, at, by}` | + +周回(`laps[]` の 1 行)の主な項目: + +| 項目 | 意味 | +| --- | --- | +| `n` / `sprint` / `dir` / `state_path` / `out` / `manifest` | 番号・スプリントの名前(`<ループ名>-`)・周回のディレクトリ・状態ファイル・プランの出力・マニフェスト | +| `milestone` / `rank_digest` / `selection[]` | 選んだマイルストーン・選定に使った `rank` の digest・`{number, milestone, rank, decision: 選んだ\|外した, reason}` | +| `mode` / `versions` | conductor が決めたモードと `{dev, prod}` | +| `sprint_command` / `close_command` / `dones[]` / `prod_plan` / `gate2_plan` / `issues_kept` | 打ったコマンド・done のパス・本番のプラン・承認ゲート 2 で止まった計画・承認ゲート 1 の後に残した課題 | +| `gates[]` | 承認ゲートの判定の写し(`{name, by, verdict, basis, pr, at}`) | +| `retro` / `refine` | 振り返りの置き場(まとめのプランの報告)・棚卸しの結果 `{report, rank_changes, deferred[]}`(upkeep の記録の `candidates.json`・`rank.json` から) | +| `created[]` / `deferred_issues[]` | 周回の中で起票された課題(その周回の選定に入れない)・上限で後へ回した Issue(`lap --deferred-issues`) | +| `cost` / `seconds` | スプリント状態の計画の行の和 | +| `outcome` / `failure` | `進行中` / `完了` / `失敗` / `人へ戻した`・失敗の鍵 | +| `relay_dir` / `started_at` / `ended_at` | 締めたセッションの `NDF_RELAY_DIR`・周回の開始と終わり | + +**周回はスプリント状態を `state_path` で参照し、締めるときに費用・秒・承認ゲート・版を写して固定する。** 以後スプリント状態が +変わっても、周回の行は書き換えない。 + +### 品質の枠(`upkeep.py rank`) + +`.ndf/backlog.json` の `quality` 節(`{"label": "quality", "ratio": 0.2, "min": 1}`。キーの既定はこの値)を宣言すると、 +`rank` はラベル `label` の品質向上の Issue から、容量の `ratio`(切り捨て。最低 `min` 件。容量が無ければ `min` 件)を +先に取り、切り出しの境界は残りの容量で決める。枠の中の並びは、実害の観測(`observed_harm`)→ 起票の古い順 → 今の順位。 +大きい課題の枠の 1 切れ・尺度を超える課題・未完了の依存先が枠に入っていない課題は取らない。枠で容量を使い切ったら +境界は `empty`(通常の候補は 0 件)になる。結果は `quality_slot` と順位の表の「品質の枠」に出る。宣言が無ければ +順位と境界は今と同じである。 + +ループの中で品質を上げる工程(クロスレビュー・クロスリファクタリング・検査)は今の上限(ラウンド・時間の予算・`timeout`)で +打ち切る。残った指摘は conductor が `out-of-scope` で、`quality.label` のラベルを付けた Issue に起こし、番号を +`loop lap --deferred-issues` で周回へ書く。人を害する・秘密・利用者のデータ・戻せない操作に当たる指摘だけは、上限に達しても +その周回で直すか、レッドラインとして人へ戻す。 + +## 既知の限界 + +| 項目 | 内容 | +| --- | --- | +| 周回の `created[]` の書き手 | 選定は `created` を外す理由に読むが、10.21.0 の副命令には `created` を書く経路が無い。周回の中で起票された課題がその周回で選ばれないのは、`rank` が段階の付いた課題だけを並べ、段階は棚卸しが付けるためである | +| 品質の枠の並び | 決定で挙げた「同じ手当てや失敗の繰り返しの回数」は `rank` の入力に無いため数えない(実害の観測 → 起票の古い順 → 今の順位) | +| 予算の既定値 | 標本は 2 件(#2065・#2086 の設計と実装)で、まとめの費用を含まない。リリース後テストの実測で見直す | +| 1 周回の費用の上限 | 予算は周回の境でだけ判定するため、超過は高々 1 周回分になる。1 周回が予算を大きく超えるなら、周回の中の上限を別の課題で考える | +| 持ち越しの読み先 | upkeep の記録の置き場(`$NDF_UPKEEP_STATE_DIR`、既定は一時ディレクトリ)が同じマシンで周回をまたいで残る前提である。マシンをまたぐ置き場は #1640 が持つ | +| `/goal` の評価役 | 止まったときの 1 行を評価役が「条件を満たした」と読んでゴールを終えるかは、評価役の解釈に任される。終わりの条件を再開のコマンドの末尾に書いて読みやすくしている(決定 12) | +| 失敗で締めた周回の後始末 | 失敗で締めた周回のスプリントブランチと開いた Pull Request は、今の conductor の手当てのまま残る | + +## 決定の理由 + +| # | 決定 | 理由 | +| --- | --- | --- | +| 1 | ループの記録を新しい集約(`loop.json`)にし、入口を `sprint-state.py loop` に足す | スプリント状態ファイルに足すと周回ごとに別のファイルができ、一覧に全周回のファイルを探すことになる。新しいスクリプトにすると引数の読み方と結果の出し方が 2 つに分かれる。新しいキューや常駐の処理は作らない | +| 2 | 選定を `rank` の出力と記録だけで決め、1 つのマイルストーンから組む | AI が範囲を広げないため。複数のマイルストーンから混ぜるとスプリント MVV が 2 つ以上になり、着手の順序を飛び越える | +| 3 | モードと版は conductor が決め、対象外は `loop exclude` で選定へ戻す | 判断をスクリプトへ移さない。外した理由が記録に残るため、同じ入力から同じ組になる | +| 4 | 承認ゲート 1 で人へ戻した課題は人の判断待ちとして外して続け、承認ゲート 2 で人へ戻したら止まる(承認ゲート 1 で利用者が選択) | 「レッドライン以外で承認を一切求めない」と「決められないときは人へ戻す」の両方を満たす。ループを止めて待つ案は「判定できない」1 件で全体が止まり、「判定できない」を「従う」と見なす案は共通原則に反する。ゲート 2 で止まるのは、本番を出さずに進むと次の本番に判定を通っていない変更が混ざるため | +| 5 | 予算を周回の数・費用(USD)・経過時間の 3 つにし、既定を 3 周・60 USD・24 時間にする(同上) | 費用と秒はスプリント状態が既に埋めている量で、新しく測る仕組みが要らない。トークン数は周回の単位で集めておらず、費用で同じ目的を果たす。`/goal` の計数は `--continue` / `--resume` でリセットされ、周回ごとにセッションが替わるため使えない。既定は 1 件約 6 USD・L 2〜7 時間の実測から、1 周 3 件で約 20 USD・3 周で 60 USD、24 時間とした | +| 6 | 記録を `.ndf/loop/<名>/` に置き、`laps.md` を描画し、引継ぎ文書は現在地だけを持つ(同上) | 引継ぎ文書と同じく後片付けで消えず、コミットが要らず、引継ぎ文書の 300 行の上限に周回を載せずに済む。課題のコメントは一覧に GitHub の検索が要り(当てにならないことは #1640 で実測)、`docs/` は周回ごとに Pull Request が要る | +| 7 | `--loop` と `loop` 節で切り替え、`loop` 節を `read_pace` から分けて読む(同上) | 今の進め方を壊さないため。`pace: loop` を足す案は、周回の中が `auto` そのものなので承認の扱いを 2 つの節で二重に持つ | +| 8 | 失敗の鍵を「課題の組・計画の種類・止まったところ」にし、既定 2 回で止める(同上) | 理由の文を鍵に含めると文面の揺れで別の鍵になる。2 回は「1 回は立て直してやり直す」を許す回数。Claude Code の安全弁(Tool を使わない応答の連続・Stop hook の block 8 回)はターン単位で、周回をまたぐ失敗を捕まえない | +| 9 | 止まったときは `ndf-next` のブロックを出さず、止まる条件は周回の境でだけ判定する | ブロックを出すとラッパーが次のセッションを起動し、同じ理由で止まり直す。途中で止めるとブランチと Pull Request が半端に残り、再開の起点が境とずれる | +| 10 | `new close --pace auto` でまとめのステージだけを組む | 本番から次の周回までを 1 本のパイプラインで流すため。conductor が `retrospective` と `backlog-refinement` を順に起動する案は、周回の終わりの手順が文面に残る | +| 11 | スプリント MVV を持つマイルストーンは、利用者が承認した sha256 があるときだけ選ぶ(同上) | 周回ごとに承認を求めず、かつスプリントのレッドライン(R)を効かせるため。スプリント MVV を持つマイルストーンを対象から外す案は採らなかった | +| 12 | 止まったときは決まった語の 1 行を出し、再開のコマンドの末尾に終わりの条件を書く(同上で案 B) | `/goal` の評価役はコマンドもファイルも見ず、会話の出力だけで判定する。公式の「条件は出力で示せる形に書く」に合わせた | +| 13 | 品質を上げる工程は今の上限で打ち切り、残った指摘は Issue にして次の周回へ回す(利用者の指定。コスト > スピード > 品質) | 周回の中で上限を延ばすと品質を費用とスピードより先に置く。記録に書くだけでは選定に乗らず埋もれる | +| 14 | 棚卸しの順位付けで品質向上の Issue に容量の枠を取る(承認ゲート 1 で利用者が選択) | 決定 13 だけでは遅延コストの低い品質向上が後ろに積み残る。枠は毎スプリント品質を進めつつ割合で量を抑え、順位の式は変えない。滞留の加点は上がるまで 0 件の周回があり段階の意味を変え、件数の閾値は波が大きい | + +### 外部の動向から取り入れたもの + +一次情報は 2026-10-24 に本文を開いて確かめた。起票時に挙げた解説記事は根拠に使っていない。 + +| 考え方 | 出典 | 受けた所 | +| --- | --- | --- | +| 反復には上限を含む止まる条件を必ず置き、人へ戻すのはチェックポイントと行き詰まりに限る。骨格は決まった順の workflow にし、最小から始める | Building effective agents | 止まる条件・承認ゲート 2・同じ失敗。選定・判定・記録は LLM を呼ばないスクリプトで、周回は直列(I2) | +| 1 回に 1 つの塊だけを進め、回の始めに前回の記録を読み、終わりに書いてから次へ進む | Effective harnesses・Huntley | 1 周回 1 スプリント・1 マイルストーン。`loop start` が読み、`loop lap` が書いてから `ndf_next` を返す | +| 完了の判定を書き換えにくい構造化データに置き、作る役と判定する役を分ける。1 回の長い実行は 3〜6 時間・124〜200 USD に達した | Effective harnesses・Harness design・StrongDM | `loop.json` が正本、`laps.md` は描画。承認ゲートの判定は `mvv-gate.py` だけが書く(I6)。予算の上限を必須にする | +| 完了は主張であって証明ではない。人が後から追える記録を残す | Osmani・Huntley | 周回に判定の写し・版・振り返りの置き場を固定する(I1)。`laps.md` と `loop list` | +| 人が持つのは優先順位・受け入れ条件・成果の確認 | OpenAI | 優先順位は MVV と棚卸しの順位、確認は承認ゲート 2 と周回の記録 | +| `/goal` の計数は再開でリセットされ、評価役は出力だけで判定する | Claude Code の `/goal` | 予算の累計は記録が持つ(I8)。止まったときの 1 行(決定 12) | + +取り入れなかったもの: 人がコードを書かず・レビューもしない規則と消費の目安(StrongDM。配布するプラグインの検査を外せない)、 +止まる条件を持たず収束するまで回す形(StrongDM)、Level 5 を目標にすること(Shapiro)、マージ前の検査を最小にする運用 +(OpenAI。記事自身が低スループットの環境では無責任と条件を付けている)、新しい進捗ファイル(Effective harnesses。進捗は +既に Issue・Pull Request・状態ファイルにある)、Stop hook を自作して外側のループを強制すること(`/goal` と二重になり block の +上限に干渉する)、選定にエントロピーの返済の枠を常に入れること(OpenAI。入力が `rank` の出力だけで決まる形が崩れる)、 +worker の `claude -p` に `--max-budget-usd` を渡すこと(ステップの途中で止まり半端に残る)。 + +## テスト観点 + +止まる条件・選定・記録は、`rank` の出力・記録・設定を入力にして関数を直接呼ぶ単体テストで縛る。`gh`・`claude`・ +`upkeep.py rank`・`supervise.py` は `Env` で差し替える([`tests/test_sprint_loop.py`](../../plugins/ndf/scripts/tests/test_sprint_loop.py)、 +`new close --pace auto` は [`tests/test_supervise_close_auto.py`](../../plugins/ndf/scripts/tests/test_supervise_close_auto.py))。 + +| 観点 | 壊したら落ちるべきこと | +| --- | --- | +| `--loop` を付けない起動・`--pace` を付けない `new close`・`loop` 以外の副命令・`read_pace` の出力が今のまま | `new close` の既定を `auto` のまとめにする・`read_pace` が `loop` 節を検証する | +| `loop start` が `loop.enabled` 偽・`auto` 節の欠け・プロジェクト MVV の未承認・`loop` 節の形の誤りのそれぞれで 1 を返し、1 スプリントの起動の形を出し、記録を作らない | 確かめを 1 つ外す・断るときに記録を作る | +| 承認ゲートのどの判定でも、ループが承認の記録を書かず、「判定できない」の課題を人の判断待ちへ移す。承認ゲート 1 で全課題が外れたら周回を締めて境の判定へ進み、承認ゲート 2 ではどの判定でも 3 で止まる | `unknown` を通過として扱う・ループが `gate --by mvv` を打つ・ゲート 2 の後に次の周回を始める | +| 同じ `rank` の出力と記録から同じ組と並びが出る。境界まで・`max_issues` まで・1 つのマイルストーンから。品質の枠と大きい課題の枠の 1 切れが候補に入り、品質の枠が `max_issues` の中で先に取られる | 並びを集合で扱う・境界を無視する・2 つのマイルストーンから選ぶ | +| 選定の行が、選んだ行と外した行の両方を理由つきで持つ。周回の中で起票された課題がその周回に入らない | 外した行を書かない・`created` を候補に足す | +| 周回の数・費用・動いていた時間のそれぞれが予算以上で `lap` が 3 を返し、進行中の周回では予算で `stop` を書かない | `>` と `>=` を取り違える・`gate` で予算を判定する | +| 同じ鍵の失敗が `same_failure` 回続くと止まり、完了の周回で数え直す。`same_failure` で止まって再開した後の 1 回目では止まらず、ほかの理由の再開は区切りにしない | 鍵を理由の文で作る・累計で数える・再開より前の周回を数える | +| 候補 0 件で `no_issue`、すべて外れて `outside_mvv` | 2 つを取り違える・0 件で周回を足す | +| `loop stop` のファイルで次の境に止まり、再開でファイルが消える | 周回の途中で止める・再開でファイルを残す | +| 止まった出力の `items` に条件・値・再開のコマンドがあり、`stop_line` の条件が `stop.condition` と一致し、引継ぎ文書の「次に実行するコマンド」が再開のコマンドになる | 再開のコマンドを書かない・条件の語を食い違わせる | +| 再開で周回の数・費用・動いていた時間を引き継ぎ、番号が続く。止まっていた時間は経過時間に入らず、境で止まった時間は引かない | 再開で消費を 0 に戻す・壁時計で数える・境の時間を引いて負にする | +| 周回を締めた後、周回の行が選定・判定の写し・版・振り返りの置き場・棚卸しの結果・費用と秒・後へ回した Issue を持ち、`list` が一覧を出す | どれかを写さない・後へ回した Issue を同じ周回の候補に足す | +| 前の周回の持ち越しが `--carry` で棚卸しの指示に載り、まとめのステージが最後の `--then` につながり、`gate2_returned` からの再開の `pipeline` が止まった計画の続き → 残りのステージ → `--record-pr` 付きのまとめ → `wait` → `lap` になる | 持ち越しを読まない・まとめをつながない・`--record-pr` を固定の値で渡す | +| 人の判断待ちの課題が答えの前は選ばれず、`--approved` の後は設計 PR がマージされた後の周回で `--designed` として選ばれ、`--rejected` の後は選ばれない | 答えを待たずに選ぶ・差し戻しを選び直す・マージ前に選ぶ | +| `quality` 節を宣言した `rank` が同じ入力から同じ境界を返し、枠の分を先に取って印を付ける。宣言が無いと出力が今と同じ | 割合の丸めを違える・宣言が無いのに枠を取る | +| 終わった周回の行を書き換える書き込みが拒まれ、進行中の周回があると `select` が 1、止まったループで `select` が 3 | 締めた周回を上書きする・2 つ目の周回を足す・`start` を経ずに周回を始める | +| 周回の途中の `gate2_returned`・`mvv_changed`・`refused` は周回を進行中のまま残し、`mvv_changed` の再開は解けずに新しい名前での始め直しを示す | 途中の停止で周回を締める・`mvv_changed` の再開で旧周回を続ける | + +## 関連リンク + +- 課題: #2089(要求と受け入れ条件)、#1640(持ち越しの置き場)、#996(`/goal` に頼らない自走) +- 手順: [`development-workflow` の references/loop.md](../../plugins/ndf/skills/development-workflow/references/loop.md)・[references/pace.md](../../plugins/ndf/skills/development-workflow/references/pace.md)・[references/relay.md](../../plugins/ndf/skills/development-workflow/references/relay.md) +- 関係する確定仕様: [ndf-relay-segment-restart.md](ndf-relay-segment-restart.md)(ラッパー)・[ndf-backlog-refinement-stage.md](ndf-backlog-refinement-stage.md)(スプリントの終わりの棚卸し)・[ndf-workflow-unit-and-gates.md](ndf-workflow-unit-and-gates.md)(承認ゲート) +- 外部の一次情報: + - Anthropic「Building effective agents」 https://www.anthropic.com/engineering/building-effective-agents + - Anthropic「Effective harnesses for long-running agents」 https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents + - Anthropic「Harness design for long-running application development」 https://www.anthropic.com/engineering/harness-design-long-running-apps + - OpenAI「Harness engineering: leveraging Codex in an agent-first world」 https://openai.com/index/harness-engineering/ (日付は検索結果の値で、本文からは確かめていない) + - Claude Code の `/goal` https://code.claude.com/docs/en/goal 、hook https://code.claude.com/docs/en/hooks-guide + - 語の原典: Addy Osmani「Loop Engineering」 https://addyosmani.com/blog/loop-engineering/ 、StrongDM「Software Factories And The Agentic Moment」 https://factory.strongdm.ai/ (Story のページだけを確かめた)、Dan Shapiro「The Five Levels」 https://www.danshapiro.com/blog/2026/01/the-five-levels-from-spicy-autocomplete-to-the-software-factory/ 、Geoffrey Huntley「everything is a ralph loop」 https://ghuntley.com/loop/ diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 700a62d02..e09c0a788 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,15 +57,15 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.21.0` | 利用者が常用してよい | -| 開発版 | `10.22.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | -| 公開前の確認版 | `10.22.0-rc.1` | 正式版の候補。残るのは確認だけ | +| 正式版 | `10.22.0` | 利用者が常用してよい | +| 開発版 | `10.23.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | +| 公開前の確認版 | `10.23.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.21.0` の次を開発するなら `10.22.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.22.0` の次を開発するなら `10.23.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる -- **正式版を出すときは接尾辞を外す。** `10.22.0-dev.3` の次は `10.22.0` -- 順序は semver に従い `10.22.0-dev.1` < `10.22.0-rc.1` < `10.22.0` になる +- **正式版を出すときは接尾辞を外す。** `10.23.0-dev.3` の次は `10.23.0` +- 順序は semver に従い `10.23.0-dev.1` < `10.23.0-rc.1` < `10.23.0` になる ## ランタイムごとの取得と導入 diff --git a/issues/issue-2089-design.md b/issues/issue-2089-design.md deleted file mode 100644 index c2314009b..000000000 --- a/issues/issue-2089-design.md +++ /dev/null @@ -1,818 +0,0 @@ -# development-workflow: 1 スプリントの振り返りと棚卸しで止まり、次のスプリントを人が選んで起動し直す → `--loop` で、棚卸しの順位から次のスプリントを機械で選び、レッドラインと止まる条件のときだけ止まって周回を続ける(#2089) - -## 目的 - -- **何が壊れているか**: `/goal /ndf:development-workflow` は 1 スプリントの振り返り・棚卸しで終わる。次のスプリントを選んで始めるには、人が順位を読んで課題を決め、`/goal` の文面を書き直す。止まる条件と選んだ理由は、会話ごとに手で書くテンプレートの文面にしか無い -- **誰が困るか**: スプリントを続けて回したい利用者。承認ゲートを MVV 判定に任せても(`pace: auto`)、スプリントの境で毎回手が止まる -- **直すと何が成り立つか**: 利用者が `--loop` を付けて 1 回起動すれば、NDF が「選ぶ → 設計 → 実装 → 検査 → 配布 → 振り返り → 棚卸し → 次を選ぶ」を繰り返す。人が手を止めるのはレッドラインと止まる条件のときだけで、周回ごとの選定・判定・版・費用が 1 つの記録に残る。`--loop` を付けない起動は今と同じに動く - -## 適用範囲 - -- **働く範囲**: 配布先のリポジトリでも働く(NDF の安定版の経路)。自動の切り替えは Claude Code のラッパー(`relay.py`)の下だけで働く。ほかのランタイムとラッパーの外では、カットポイントで `ndf-next` を出して止まる。貼り直せば同じ周回から続く -- **プロジェクトごとに違うもの**: ループを許すか・予算・同じ失敗の回数・1 周の課題の上限は `.ndf/pace.json` の `loop` 節で受ける。ループの名前・対象のマイルストーン・予算の上書きは起動の引数で受ける。テストのコマンド・ベースブランチ・配布の手段は今の宣言(`.ndf/project.json`・`.ndf/worktree.json`・`.ndf/supervise.json`)をそのまま使う -- **当たるモード**: ループの周回は `pace: auto` で組み、`auto` が許すモード(既定 `light` / `standard` / `legacy-refactor`)の課題だけを選ぶ。この変更自体は `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| 依頼の原文「振り返りとissue見直しの完了後、見直し結果に基づいて次のsprintを設定し、自動で次のsprintを開始できるようにしたい」「従来のgoalを明確に定めた開発手法もこれまで通り動くこと (引数で切り替え可能とする)」(#2089、2026-10-25) | 利用者の指示の原文 | 次のスプリントを棚卸しの結果から選んで続けること、引数で切り替えて今の進め方を残すこと | -| 「pace: autoを基本とし、MVVのレッドライン以外で承認を一切必要としないモードとします。」(#2089、2026-10-25) | 利用者の指示の原文 | 周回の中では `AskUserQuestion` も本文の承認待ちも出さず、レッドラインだけで人へ戻すこと | -| `relay.md` の「`/goal` のテンプレートを特定の課題に縛らない」と「止まるのは承認ゲート 2 つだけになる」(今の運用) | 既存の形 | 次のスプリントの選び方を文面で書く運用が既にあり、それを機械の手順へ移すのが最小の変更であること | -| 過去の課題の実測(`estimate.py actuals`、2026-10-24): #2065 は A 1,817 秒・L 6,828 秒・C 5.55 USD、#2086 は A 2,462 秒・L 25,993 秒・C 6.11 USD(どちらも設計と実装まで) | 実測 | 予算の既定値の案(決定 5)の出所 | -| Anthropic のエンジニアリングブログ 3 本・OpenAI「Harness engineering」・Claude Code の `/goal` と hook の公式ドキュメント(2026-10-24 に本文を確認。出典は「外部の動向」) | 外部の一次情報 | 止まる条件と上限を必ず置くこと・作る役と判定する役を分けること・`/goal` の評価役が出力だけで判定し再開で計数を戻すこと。決定 5・8・12 の根拠 | - -要求と受け入れ条件は #2089 の本文にある(コピーは `issues/issue-2089-requirements.md`)。この文書は「どう作るか」だけを扱う。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| NDF の開発ワークフロー(`ndf-workflow`) | 開発ループ・周回・ループの予算・止まる条件・ループの記録・承認ゲート・スプリント状態ファイル | -| NDF の課題の棚卸し(`ndf-issue-upkeep`) | 順位・切り出しの境界・容量・持ち越し・人の判断待ち | - -**関係は順応者である。** 開発ループ(下流)は、棚卸しの `rank` の出力(上流)をそのまま読み、順位の付け方・境界の決め方を自分で持たない。順位の付け方を変えるのは棚卸しの側で、ループは翻訳の層を置かない(Value 6 の「同じ役割の定数・関数を Skill ごとに分けない」)。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 開発ループ | `lib/sprint_loop.py`(`sprint-state.py loop` の副命令だけが呼ぶ) | ループ(名前で識別) | 周回(番号で識別)・人の判断待ちの課題(課題の番号で識別) | 予算・選定の行・止まった理由・失敗の鍵・承認ゲートの判定の写し | -| スプリントの状態(既存) | `sprint-state.py`(今のまま) | スプリント | 計画の行・承認ゲートの記録 | 版・MVV の参照 | - -**ループはスプリントの状態を ID(状態ファイルのパス)で参照し、中身を持たない。** 周回の終わりに、費用・秒・承認ゲートの判定・版をスプリントの状態から写して周回の行へ固定する。以後スプリントの状態が変わっても、周回の行は書き換えない。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 開発ループ | 周回は追記だけで、番号は 1 から 1 ずつ増える。終わった周回の行は書き換えない | 書き込みを拒み、終了コード 1(記録が壊れている)で止まる | -| I2 | 開発ループ | 進行中の周回は高々 1 つ | 新しい周回の開始を拒む(`loop select` が「進行中の周回がある」で 1) | -| I3 | 開発ループ | 予算(`budget_*`)・同じ失敗(`same_failure`)・止める指示(`user_stop`)は周回の境(周回の開始の前と終わりの後)でだけ判定し、周回の途中ではこの 3 つで `stop` を書かない。例外は周回の途中で起きる 3 つで、進行中のまま `stop` を書いて止まる: 承認ゲート 2 を人へ戻した(`gate2_returned`)・プロジェクト MVV が改訂された(`mvv_changed`)・周回を組めなかった(`refused`)。周回は締めずに残す。再開(`loop start`)の後は止まった理由ごとの再開の表に従う: `gate2_returned` は承認の記録があれば同じ周回を続け、差し戻しの記録があればその周回を `人へ戻した` で締める。`refused` は同じ周回で `loop sprint` をやり直す。`mvv_changed` は再開で解けず、新しい名前でループを始め直す(同じ周回を続けない) | 周回の途中の停止の指示は、次の境まで効かない | -| I4 | 開発ループ | 選定は入力(`rank` の出力・ループの記録の人の判断待ちと除外・`answer: approved` の行の設計 PR の状態・宣言・スプリント MVV の承認)だけで決まる。同じ入力からは同じ組と同じ並びが出る | — (テストで縛る) | -| I5 | 開発ループ | 答えの無い人の判断待ちの課題は選ばない | 選定の行に「人の判断待ち」で外す | -| I6 | 開発ループ | ループは承認ゲートを自分で通さない。承認ゲートの記録(`by: mvv`)を書くのは `mvv-gate.py` だけで、「従う」以外の判定を「従う」として扱わない | — (テストで縛る) | -| I7 | 開発ループ | 止まったループは、`loop start` が止まった理由を判定し直して解けたと確かめるまで、周回を始めない | 同じ理由で止まり直す | -| I8 | 開発ループ | 予算の消費(周回の数・費用・経過時間)は再開をまたいで引き継ぐ。経過時間は動いていた時間で数える: 周回の `started_at`〜`ended_at` の和から、周回の途中で止まっていた時間(`stops` の行の `at`〜`resumed_at` のうち、その周回の `started_at`〜`ended_at` と重なる部分)を引く。周回の境で止まった時間は周回の区間の外にあるため引かない。止まって利用者の答えを待った時間は予算を消費しない | — (テストで縛る) | -| I9 | 開発ループ | 周回を始めるときのプロジェクト MVV の sha256 は、ループの開始時に記録した値と一致する | 周回を始めずに「MVV が改訂された」で止まる | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 利用者がループを始めた | 利用者(`--loop` の起動)→ `loop start` | 開発ループ(記録を作る・断る) | -| E2 | 周回を始めた | `loop select` | 開発ループ(周回の行を足す) | -| E3 | 次のスプリントの課題の組を選んだ | `loop select` | conductor(モード判定と版の決定)・スプリントの状態(`init`) | -| E4 | 課題を MVV の外・人の判断待ちとして外した | `loop select`・`loop exclude`・`loop gate` | 開発ループ(選定の行・人の判断待ち)・課題の本文 | -| E5 | スプリントを `pace: auto` で組んだ | `loop sprint`(`sprint-state.py init` と `supervise.py new sprint --pace auto`) | supervise のキュー | -| E6 | 承認ゲート 1 を MVV 判定で通した、またはレッドラインで人へ戻した | `mvv-gate.py check --gate design` | 開発ループ(`loop gate` が人の判断待ちへ移す)・スプリントの状態 | -| E7 | 実装・検査・開発版の配布を終えた | supervise のキュー | スプリントの状態(`update`) | -| E8 | 承認ゲート 2 を MVV 判定で通した、またはレッドラインで人へ戻した | `mvv-gate.py check --gate release` | 開発ループ(人へ戻したら `loop gate` が止める) | -| E9 | 本番へ配布した | 本番のプラン | スプリントの状態 | -| E10 | 振り返りと棚卸しを終えた | まとめのプラン(`new close --pace auto`) | 開発ループ(`loop lap` が持ち越しと順位の変化を読む) | -| E11 | 周回の記録を書き、止まる条件を判定した | `loop lap` | 開発ループ・引継ぎ文書・conductor(続ける / 止まる) | -| E12 | ループが止まった | `loop lap`・`loop select`・`loop gate`・`loop start` | 利用者(止まった理由と再開のコマンド)・引継ぎ文書 | -| E13 | 利用者がループを再開した | 利用者(再開のコマンド)→ `loop start` | 開発ループ(止まった理由を判定し直す) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 開発ループ | スプリントを 1 つ終えるたびに、棚卸しの結果から次のスプリントを選んで続けて始める進め方。引数で切り替え、止まる条件に当たるまで繰り返す | 追加(要求の工程で追加済み) | -| 周回 | 開発ループの 1 回分。次のスプリントの選定から、そのスプリントの棚卸しと止まる条件の判定まで | 追加(要求の工程で追加済み) | -| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数え、超えたら止まる | 追加(要求の工程で追加済み) | -| 止まる条件 | 開発ループを止める、機械で判定できる条件。予算を超えた・同じ失敗が続いた・着手できる課題が無い・MVV の外の課題しか残らない・利用者が止めた | 追加(要求の工程で追加済み) | -| ループの記録 | 開発ループの周回ごとの選定・承認ゲートの判定・版・費用と秒・止まる条件の判定を持つファイル(`.ndf/loop/<名>/loop.json`)と、そこから作る人が読む文書(`laps.md`) | 追加 | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | ループを始める・再開する(使ってよい条件を確かめ、断るときは 1 スプリントの起動の形を返す) | 利用者(`--loop` の起動)・conductor | -| F2 | 次のスプリントの課題の組を選び、選んだ・外した理由を周回へ書く | conductor | -| F3 | 課題を外す(モードが対象外・MVV に反する疑い) | conductor | -| F4 | 周回のスプリントを `pace: auto` で組む(状態の初期化とプランの書き出しの組み立て) | conductor | -| F5 | 承認ゲートで止まった判定を分け、承認ゲート 1 は課題を人の判断待ちへ移して残りで続け、承認ゲート 2 は止める | conductor | -| F6 | 人の判断待ちに答える(承認・差し戻し) | 利用者 | -| F7 | スプリントの終わりのまとめ(確定仕様化・課題を閉じる・振り返り・棚卸し)を `auto` で組み、前の周回の持ち越しを棚卸しへ渡す | conductor(`supervise.py new close --pace auto`) | -| F8 | 周回を締め、止まる条件を判定し、次の `ndf-next` か止まった理由を返す | conductor | -| F9 | ループを止める指示を置く | 利用者 | -| F10 | 周回の一覧と今の状態を出す | 利用者・conductor | -| F11 | スプリント MVV を承認してループへ渡す(決定 11) | 利用者 | - -## 決定の記録 - -### 決定 1: 新しい基盤を増やさないため、ループの記録を新しい集約にし、入口を `sprint-state.py loop` に足す - -ループは複数のスプリントを束ねる。1 スプリントの状態ファイルへ周回の項目を足すと、1 つのファイルが別の集約を 2 つ持つ。そこで記録は別のファイル(`loop.json`)にし、入口は既存の `sprint-state.py` の副命令として足す。結果 JSON の形・排他・原子的な書き込み・引継ぎ文書の節の置き換えは、`sprint-state.py` の部品をそのまま使う。新しいキューや常駐の処理は作らない。 - -採らなかった案は 2 つある。 - -| 案 | 採らない理由 | -| --- | --- | -| スプリント状態ファイルに `loop` の項目を足す | 周回ごとに別の状態ファイルができ、周回の一覧を出すのに全周回のファイルを探すことになる | -| 新しいスクリプト(`sprint-loop.py`)を作る | 引数の読み方と結果の出し方が `sprint-state.py` と 2 つに分かれる | - -根拠: Value 4 / Value 6(MVV 版 2) - -### 決定 2: AI が範囲を広げないため、選定を `rank` の出力とループの記録だけで決め、1 つのマイルストーンから組む - -マイルストーンを順(`rank` の `order`)に見て、選べる課題がある最初のマイルストーンから選ぶ。選ぶのは、順位の先頭から切り出しの境界まで(境界が無ければ `max_issues` 件)である。1 つのスプリントは 1 つのマイルストーンから組むため、スプリント MVV の照合が 1 つで済む。外した行も理由つきで残すため、後から「なぜこの組か」を追える。 - -複数のマイルストーンから順位の値(CoD)の高い順に混ぜて選ぶ案は採らない。スプリント MVV が 2 つ以上になり、マイルストーンの順(着手の順序)を飛び越えるためである。 - -根拠: Value 4 / Value 6(MVV 版 2) - -### 決定 3: 判断を AI に残すため、モードと版は conductor が決め、対象外は `loop exclude` で選定へ戻す - -モードの判定(`development-workflow` の手順 2)と版の決め方(`release` の規則)は判断であり、スクリプトにしない。conductor の判定で外す課題は 2 種類ある。`auto` が許さないモード(`operation` / `documentation`)の課題と、MVV に反する疑いのある課題である。どちらも `loop exclude` で外し、選定をやり直す。外した理由は周回の記録に残るため、同じ入力(除外の一覧を含む)からは同じ組になる(I4)。 - -選定の時点でモードを推す仕組みを足す案は、判断をスクリプトへ移すため採らない。 - -根拠: Value 4(MVV 版 2) - -### 決定 4: 承認を求めずに続けるため、ゲート 1 で人へ戻した課題は人の判断待ちとして外し、ゲート 2 で人へ戻したら止まる(未決 1) - -**承認ゲート 1 での選択(2026-10-25、利用者):** 案 A【推奨】を選んだ。比べた案は次のとおり。 - -| 案 | 振る舞い | 得失 | -| --- | --- | --- | -| A【推奨】 | ゲート 1: その課題を人の判断待ちとして外し、残りの課題で続ける。ゲート 2: その周回の本番を出さずに止まる | 利用者の指定「レッドライン以外で承認を一切求めない」と、共通原則の「決められないときは人へ戻す」の両方を満たす。ゲート 2 で止まるのは、本番を出さずに次の周回へ進むと、次の本番に判定を通っていない変更が混ざるため | -| B | どちらのゲートでもループを止めて承認を待つ | 単純だが、「判定できない」1 件で全体が止まり、利用者の指定に反する | -| C | 「判定できない」を「従う」と見なす | 共通原則の「決められないときも人へ戻す」に反する。採らない案として示す | - -A を選ぶとき、「判定できない」「反する疑い」は止める理由ではなく外す理由になる。レッドライン(`boundary_paths`・MVV の宣言の変更・判定の `boundary`)も同じく外す理由で、ゲート 2 だけが止める理由である。 - -根拠: Value 2 / C1〜C8(MVV 版 2) - -### 決定 5: 測れる量で止めるため、予算を周回の数・費用(USD)・経過時間の 3 つにし、既定を 3 周・60 USD・24 時間にする(未決 2) - -費用(USD)と秒は、スプリント状態ファイルが done と報告から既に埋めている量で、新しく測る仕組みが要らない。トークン数は周回の単位で集めておらず、費用で上限を掛ければ同じ目的を果たすため足さない。 - -**累計はループの記録が持ち、`/goal` の計数に頼らない。** `/goal` はターン・時間・トークンを表示する。ただし `--continue` / `--resume` で基準がリセットされる(Claude Code の `/goal` のドキュメント)。ループは周回の境ごとにセッションを切り替えるため、`/goal` の計数ではループ全体の消費を数えられない(I8)。上限を必ず置く根拠は 2 つある。1 つは、反復の上限を止まる条件に含めるという Building effective agents の指針である。もう 1 つは、1 回の長い実行が 3〜6 時間・124〜200 USD に達した Harness design の実測である。判定は周回の境だけのため、超過は高々 1 周回分になる。 - -既定値の出所は過去の課題の実測(#2065: 5.55 USD・L 6,828 秒、#2086: 6.11 USD・L 25,993 秒。どちらも設計と実装まで)である。1 周 3 件で 1 件 6 USD とすると約 18 USD、まとめと検査を足して約 20 USD になり、3 周で 60 USD に当たる。1 件の L が 2〜7 時間のため、3 件を並列で回す周回を 3 回で 24 時間を上限にする。標本は 2 件で、まとめ(振り返り・棚卸し)の費用を含まないため、検証用のリポジトリで 2 周回した実測で見直す(リリース後テスト)。 - -**承認ゲート 1 での選択(2026-10-25、利用者):** 推奨どおり。既定値はこの 3 つにし、トークン数は予算に足さない。 - -根拠: Value 3 / Value 4(MVV 版 2) - -### 決定 6: 人が読める記録を 1 つにするため、ループの記録を `.ndf/loop/<名>/` に置き、`laps.md` を描画し、引継ぎ文書は現在地だけを持つ(未決 3) - -**承認ゲート 1 での選択(2026-10-25、利用者):** 案 A【推奨】を選んだ。比べた案は次のとおり。 - -| 案 | 置き場 | 得失 | -| --- | --- | --- | -| A【推奨】 | メインディレクトリの `.ndf/loop/<名>/loop.json`(正本)と `laps.md`(描画)。引継ぎ文書 `loop-<名>` は現在地と次のコマンドだけ | 引継ぎ文書と同じく worktree の後片付けで消えず、コミットも要らない。引継ぎ文書の 300 行の上限に周回の記録を載せずに済む。マシンをまたがない | -| B | 課題か Pull Request のコメントへ周回ごとに書く | マシンをまたいで残るが、周回の一覧を 1 つのコマンドで出すには GitHub を検索する(検索が当てにならないことは #1640 で実測済み)。選定の行が多いと読みにくい | -| C | `docs/` の文書としてコミットする | 周回ごとに Pull Request が要り、ループの中で承認の要らない変更を増やす | - -**記録はコミットしない。** マシンをまたいで残す置き場は #1640 が棚卸しの持ち越しと合わせて決める。それまでは今の経路で、マシンを替えても要点を追える。周回の記録は、課題の本文(人の判断待ち)とスプリントの Pull Request(承認ゲートの判定のコメント)にも残る。 - -根拠: Value 7(正本は 1 か所に置き、写さずに指す)/ Value 2(MVV 版 2) - -### 決定 7: 今の進め方を壊さないため、ループを引数 `--loop` と `.ndf/pace.json` の `loop` 節で切り替え、`loop` 節を `read_pace` から分けて読む(未決 4) - -`--loop` を付けない起動は、今の判定の手順へそのまま進む。`loop` 節は `read_loop` だけが読む。そのため、節が無い・壊れているリポジトリでも `fast` / `auto` / トリガー / MVV 判定の振る舞いは変わらない。名前を省いた起動は日付の名前で始め、`ndf-next` には名前を必ず書く(再開で別のループを始めないため)。 - -**承認ゲート 1 での選択(2026-10-25、利用者):** 推奨どおり。引数と設定は次の形にする。 - -| 形 | 案 | -| --- | --- | -| 引数 | `--loop [名] [--milestones] [--budget-*]` | -| 設定 | `loop.enabled`・`budget`・`same_failure`・`max_issues` | - -`.ndf/pace.json` の進め方の節として `pace: loop` を足す案は採らない。周回の中は `auto` そのものであり、承認の扱いを 2 つの節で二重に持つことになるためである。 - -根拠: Value 5(MVV 版 2) - -### 決定 8: 同じ失敗を機械で数えるため、失敗の鍵を「課題の組・計画の種類・止まったステップ」にし、既定 2 回で止める(未決 5) - -周回が `失敗` で締まるときは 2 つある。conductor がその周回を先へ進められないと判断して `loop lap --failed` を打ったときと、キューが `完了` でも `関門` でもない結果で終わったときである。鍵はスプリント状態ファイルの行(完了でもまだでもない最初の行)の課題・種類・止まったステップから作る。2 回は「1 回は立て直してやり直す」を許し、同じ所で 2 度落ちたら人へ戻す回数である。 - -鍵に失敗の理由の文を含める案は、文面の揺れで同じ失敗が別の鍵になるため採らない。 - -**NDF が数えるのは、Claude Code の安全弁が周回をまたぐ失敗を捕まえないためである。** `/goal` は、Tool を使わない応答が数ターン続くと止める。hook は、Tool の呼び出しを挟まない Stop の block が 8 回続くと止める(hooks-guide)。どちらもターン単位で、周回ごとにセッションが替わると数え直しになる。行き詰まりで人へ戻すには、周回の記録に鍵を残して数えるほかない(Building effective agents の "when encountering blockers")。 - -**承認ゲート 1 での選択(2026-10-25、利用者):** 推奨どおり。この鍵で 2 回にする。 - -根拠: Value 2 / Value 4(MVV 版 2) - -### 決定 9: 再起動ループを起こさないため、止まったときは `ndf-next` のブロックを出さず、止まる条件は周回の境でだけ判定する - -止まったときにブロックを出すと、ラッパーが次のセッションを起動し、同じ理由で止まり直す。止まった理由と再開のコマンドは本文と引継ぎ文書に残し、利用者が貼れば同じ周回の次から続く。周回の途中では止めない(受け入れ条件 7)。途中で止めるとスプリントのブランチと Pull Request が半端に残り、再開の起点が周回の境とずれる。利用者の止める指示(`loop stop`)も次の境で効く。 - -根拠: Value 2(MVV 版 2) - -### 決定 10: `auto` の周回をスプリントの終わりまで機械で通すため、`new close --pace auto` でまとめのステージだけを組む - -今の `auto` は、確定仕様化・振り返り・棚卸しを課題ごとに conductor が通す。`new close` は `fast` のまとめ(最終の検査と `fast` 節の導入の確認を含む)である。`auto` の周回では検査と本番はスプリントのステージで済んでいるため、まとめのステージだけを組む引数を足す。棚卸しのステップは今の無人の指示のままで、前の周回の持ち越しを `--carry` で渡す(受け入れ条件 16)。持ち越しの番号は、`loop lap` が upkeep の候補の記録(`candidates.json` の `metrics.deferred`)から読んで周回へ書く。 - -`new close` を通さずに conductor が `retrospective` と `backlog-refinement` を順に起動する案は採らない。周回の終わりの手順が文面に残り、パイプラインで本番から次の周回まで続けられないためである。 - -根拠: Value 4 / Value 6(MVV 版 2) - -### 決定 11: スプリント MVV のレッドラインを緩めないため、スプリント MVV を持つマイルストーンは、利用者が承認した sha256 があるときだけ選ぶ - -周回ごとにスプリント MVV の承認を人へ求めない(要求の前提 2)。一方でスプリント MVV を無視してプロジェクト MVV だけで判定すると、スプリントのレッドライン(R)が効かなくなる。そこで、説明にスプリント MVV を持つマイルストーンは、ループの記録の `sprint_mvv_approvals` に同じ sha256 があるときだけ選ぶ。選んだときは、`loop sprint` が周回の状態へ承認ゲート `MVV` の記録を写す。承認は利用者が `sprint-state.py loop approve-mvv <名> --milestone M` で足す操作で、ループは問わない。 - -**承認ゲート 1 での選択(2026-10-25、利用者):** この形(承認済みの sha256 があるときだけ選ぶ)にする。スプリント MVV を持つマイルストーンをループの対象から外す(承認の操作を作らない)案は採らない。 - -根拠: Vision / Value 2(MVV 版 2) - -### 決定 12: `/goal` の評価役に止まったことを読ませるため、止まったときは決まった語の 1 行を出す - -`/goal` の評価役はコマンドもファイルも見ず、会話の出力だけで条件を判定する(Claude Code の `/goal` のドキュメント)。今の起動の条件は `/ndf:development-workflow --loop <名>` だけである。止まった応答を「終わった」と読むかは、評価役の解釈に任される。読まなければ評価役が止めを拒み、conductor は Tool を使わない応答を繰り返し、`/goal` の安全弁で止まるまで費用を使う。そこで止まったときは、1 行 `[ndf-loop] stopped <名> ` を最後に出す。この 1 行は `loop` の結果の `metrics.stop_line` を写す。続くとき(周回の境とカットポイント)は今どおり `ndf-next` を出し、条件は未達のままラッパーが切り替える(`relay.md` の付則)。 - -**承認ゲート 1 での選択(2026-10-25、利用者):** 案 B【推奨】を選んだ。比べた案は次のとおり。 - -| 案 | 振る舞い | 得失 | -| --- | --- | --- | -| A | 1 行を出すだけ。起動の条件は今の形 | 引数の形は変わらない。評価役がその 1 行を終わりと読むかは条件の文面に無いため、リリース後テストで確かめるまで分からない | -| B【推奨】 | 1 行を出し、`ndf-next` と再開のコマンドの条件の末尾に `-- 終わり: [ndf-loop] stopped <名> の行を出したとき` を足す | 公式の「条件は出力で示せる形に書く」「測れる終わりの状態を 1 つ持つ」に合う。Skill の引数の読み方に `--` から後を読まない規則が 1 つ増える | - -根拠: Value 2 / Value 4(MVV 版 2) - -### 決定 13: コスト > スピード > 品質の順を守るため、品質を上げる工程は今の上限で打ち切り、残った指摘は Issue にして次の周回へ回す - -利用者の指定(承認ゲート 1、2026-10-25。#2089 の Value 8)である。品質を上げる工程は、今ある上限をそのまま使う。クロスレビューはラウンドの上限、クロスリファクタリングは時間の予算、検査はプランの `timeout` を持つ。ループのために上限を緩めたり、上限を超えて同じ周回でやり直したりしない。上限に達して残った指摘(クロスレビューの `unresolved` / `deferred`、クロスリファクタリングの見送り)は、conductor が `out-of-scope` で Issue に起こす。番号は `loop lap --deferred-issues <番号...>` で周回の記録へ書く(受け入れ条件 21)。起こした Issue は、受け入れ条件 6 のとおり次の周回の棚卸しで順位が付いてから選定の対象になる。費用と時間の全体の上限は決定 5 の予算が持つ。 - -採らない案は 2 つある。 - -| 案 | 採らない理由 | -| --- | --- | -| 周回の中で上限を延ばして指摘を直し切る | 品質を費用とスピードより先に置くことになり、Value 8 の順に反する | -| 残った指摘を記録に書くだけで Issue にしない | 次の周回の選定(`rank` の出力)に乗らず、品質の向上が行われないまま埋もれる | - -共通原則の範囲(人を害する・秘密・利用者のデータ・戻せない操作)に当たる指摘は、この順より先に効く。上限に達してもその周回で直すか、レッドラインとして人へ戻す(決定 4)。 - -根拠: Value 8 / Value 2(MVV 版 2) - -### 決定 14: 品質を置き去りにしないため、棚卸しの順位付けで品質向上の Issue に容量の枠を取る - -利用者の指定(承認ゲート 1、2026-10-25)である。決定 13 で品質向上を Issue へ回すだけでは、遅延コスト(CoD)の低い品質向上の Issue が順位の後ろに積み残る。そこで `upkeep.py rank` に品質の基準を足す。基準は `.ndf/backlog.json` の `quality` 節で宣言する。品質向上の Issue はラベル(既定 `quality`)で見分け、決定 13 で起こす Issue にはこのラベルを付ける。宣言が無いリポジトリでは順位は今と変わらない(受け入れ条件 22)。 - -**承認ゲート 1 での選択(2026-10-25、利用者):** 案 A【推奨】を選んだ。枠の中の並びは「利用者へ届いた不具合(流出不具合)→ 同じ手当てや失敗の繰り返しの回数 → 後へ回してからの期間」の順で、同じなら今の順位に従う(プロジェクト MVV 版 3 の Value 10 が挙げる基準)。比べた案は次のとおり。 - -| 案 | 基準 | 得失 | -| --- | --- | --- | -| A【推奨】 容量の枠 | 切り出しの境界を決めるとき、容量の一定割合(既定 20%、最低 1 件)を品質向上の Issue の順位の先頭から先に取る。残りの容量を今の順位で埋める | 品質向上が毎スプリント必ず進み、割合で量を抑えられるため「コスト > スピード > 品質」を崩さない。順位の式(CoD)は変えない | -| B 滞留の加点 | 品質向上の Issue が切り出されずに残った棚卸しの回数に応じて、時間の重要度(TC)の段階を 1 つずつ上げる | 古いものから自然に上がるが、上がるまでの周回は品質向上が 0 件のこともある。段階の意味(実害の時間的な重要度)を変えてしまう | -| C 件数の閾値 | 開いている品質向上の Issue が閾値(例 10 件)を超えたら、次のスプリントはそれらを先頭に置く | 溜まるまで何もせず、超えたときにスプリントの全部を品質へ寄せるため、波が大きい | - -調整は順位の表に「品質の枠」の印で残し、周回の記録の選定の理由(決定 2)にも書く。 - -根拠: Value 8 / Value 3 / Value 10(MVV 版 3) - -## 外部の動向 - -**位置付けは Shapiro の Level 4(人は仕様と MVV の水準で関わり、後から確かめる)であって、Level 5(dark factory)ではない。** MVV のレッドラインで人へ戻すという要求がそれを決める。 - -**一次情報は 2026-10-24 に別の担当が WebSearch・WebFetch で本文を開いて確かめた。** 主軸は Anthropic・OpenAI のエンジニアリングブログと Claude Code の公式ドキュメントである。個人ブログと StrongDM は語の原典として扱う。起票時に挙げた解説記事(medium・mindstudio・noqta・BCG Platinion・substack・ai.engineer)は根拠に使っていない。 - -### 取り入れる考え方 - -| 考え方 | 出典 | この設計のどこで受けるか | -| --- | --- | --- | -| 反復には上限を含む止まる条件を必ず置き、人へ戻すのは「チェックポイント」と「行き詰まり」に限る("stopping conditions (such as a maximum number of iterations)"、"pause for human feedback at checkpoints or when encountering blockers") | Building effective agents | 止まる条件の 8 つ(データ構造の `stop`)。チェックポイントはレッドラインと承認ゲート 2(決定 4)、行き詰まりは同じ失敗(決定 8)と選べる課題が無いとき | -| 骨格は決まった順の workflow にし、各工程の中だけをエージェントに任せる。最小から始める | Building effective agents | 選定・判定・記録は LLM を呼ばないスクリプト(`lib/sprint_loop.py`)。周回は直列で 1 つずつ(I2)。並列の周回は入れない | -| 1 回に 1 つの塊だけを進める("work on only one feature at a time"、"performs one task per loop") | Effective harnesses・Huntley | 1 周回 1 スプリント・1 マイルストーン(決定 2) | -| 回の始めに前回の記録を読んでから次を選び、終わりに記録を書いてから次へ進む | Effective harnesses | `loop start` が記録を読み、`loop lap` が周回を締めて書いてから `ndf-next` を返す(1 周の流れ) | -| 完了の判定を書き換えにくい構造化データに置き、作る側に判定の項目を消させない("It is unacceptable to remove or edit tests")。完了の条件は実装の前に合意する(スプリント契約)。判定の材料を作る側の外に置く(StrongDM のシナリオ) | Effective harnesses・Harness design・StrongDM | 受け入れ条件は要求の工程で固定し、承認ゲート 1 の MVV 判定を通ってから実装する(今の `development-workflow`)。worker は受け入れ条件を書き換えない(今の規則)。ループの記録は JSON(`loop.json`)を正本にし、`laps.md` は描画だけ(決定 6) | -| 作る役と判定する役を分ける("agents reliably skew positive when grading their own work") | Harness design | 承認ゲートの判定は `mvv-gate.py` だけが書き、ループは自分で通さない(I6)。検査はクロスレビューと CI のまま | -| 完了は主張であって証明ではない。周回の記録に証跡を添える("“done” is a claim and not a proof") | Osmani | 周回の行に承認ゲートの判定の写し・Pull Request・版・振り返りの置き場を固定する(I1) | -| 周回ごとの費用と時間を記録し、予算の上限を必須にする(実測で 1 回 3〜6 時間・124〜200 USD) | Harness design | 予算の 3 つ(決定 5)と、周回の `cost` / `seconds` | -| `/goal` の計数(ターン・時間・トークン)は再開でリセットされる | `/goal` | 予算の累計は NDF のループの記録が持ち、`/goal` の計数に頼らない(I8・決定 5)。今の設計と一致する | -| `/goal` の評価役はコマンドもファイルも見ず、会話の出力だけで判定する。条件は出力で示せる形に書く | `/goal` | 止まったときに決まった語の 1 行を出す。推奨の案 B では再開のコマンドの条件にもその語を書く(決定 12) | -| Claude Code の安全弁(Tool を使わない応答が数ターン続く・Stop hook の block が 8 回続く)はターン単位で、周回をまたぐ失敗は捕まえない | `/goal`・hooks-guide | 同じ失敗は NDF が周回単位で数える(決定 8) | -| 人が持つのは優先順位・受け入れ条件・成果の確認("We prioritize work, translate user feedback into acceptance criteria, and validate outcomes.")。判断が要るときだけ人へ戻す | OpenAI | 優先順位は MVV と棚卸しの順位、確認は承認ゲート 2 と周回の記録。周回の中で問わない(決定 4) | -| 周回の中で見つけた欠けた規則は、その場で直し続けずに仕組みの Issue にする("so it never happens again") | Huntley・OpenAI | 周回の中の起票は `created[]` に残し、棚卸しが段階を付けた後の周回で候補になる(受け入れ条件 6) | -| 人が後から追える記録を残す("watch the loop"、"Build the loop. Stay the engineer.") | Huntley・Osmani | `laps.md` と `loop list`(何を選び、なぜ選び、何で終わったか) | -| ハーネスの部品はモデルの弱点の仮定で、効かなくなれば外す("every component in a harness encodes an assumption about what the model can't do on its own") | Harness design | 周回の記録の判定と費用を振り返りが読み、効いていない工程を Issue にする(今の `retrospective` の経路。この課題で仕組みは足さない) | - -### 取り入れない考え方 - -| 考え方 | 出典 | 取り入れない理由 | -| --- | --- | --- | -| 人がコードを書かない・レビューしない規則と、「1 人 1 日 1,000 USD 以上」の目安 | StrongDM | 要求は予算で止まることで、消費を増やすことを目標にしない。配布するプラグインの品質の検査(クロスレビュー・CI・承認ゲート 2)を外せない | -| 止まる条件を持たず「収束するまで回す」形 | StrongDM | 要求の止まる条件と相容れない | -| Level 5(人が要らない工場)を目標にすること | Shapiro | レッドラインで人へ戻す要求と逆である | -| マージ前の検査を最小にし、不安定なテストは再実行で流す | OpenAI | 記事自身が "This would be irresponsible in a low-throughput environment." と条件を付けている。NDF は利用者へ配布するため、CI とクロスレビューの検査は残す | -| 新しい進捗ファイル(`claude-progress.txt`)を足す | Effective harnesses | 進捗は既に Issue・Pull Request・スプリント状態ファイルにある。ループが足すのは複数のスプリントを束ねる記録だけで、1 スプリントの進捗を写さない(決定 1・6) | -| Stop hook を自作して外側のループを強制する | hooks・`/goal` | `/goal` は既に Stop hook の包みで、二重にすると block の上限(8 回)と `stop_hook_active` に干渉する。周回の切り替えは今のラッパー(`relay.py`)が持つ | -| 棚卸しの候補に「エントロピーの返済」(文書の鮮度・規則の逸脱の掃除)の枠を常に入れる | OpenAI | 選定に枠を足すと、入力が `rank` の出力だけで決まる形(決定 2・I4)が崩れる。掃除の Issue は棚卸しが段階を付ければ順位で選ばれる。枠が要るかは棚卸しの側の課題とする | -| `/goal` の「直すまで消えないエラー」(認証の失敗・クレジット切れなど)を止まる条件に足す | `/goal` | そのエラーはセッションそのものを終えるため、NDF のスクリプトが判定する機会が無い。記録には進行中の周回が残り、引継ぎ文書の再開のコマンドで `loop start` が `in_lap` から続ける(`relay.md` の「落ちたときの続け方」) | -| worker の `claude -p` に `--max-budget-usd` を渡して 1 回ごとに上限を掛ける | 非対話の上限 | 上限で止まると、ステップの途中でブランチと Pull Request が半端に残る(決定 9 と同じ理由)。予算は周回の境で判定し、超過は高々 1 周回分になる。1 周回の上限が要るかはリリース後テストの実測で見る(「未確認のまま残ること」) | - -### 出典 - -| 区分 | 出典 | 発行者・日付 | 確かめた範囲 | -| --- | --- | --- | --- | -| 主軸 | 「Effective harnesses for long-running agents」 https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents | Anthropic Engineering(Justin Young)・2025-11-26 | 本文 | -| 主軸 | 「Harness design for long-running application development」 https://www.anthropic.com/engineering/harness-design-long-running-apps | Anthropic Engineering(Prithvi Rajasekaran)・2026-03-24 | 本文 | -| 主軸 | 「Building effective agents」 https://www.anthropic.com/engineering/building-effective-agents | Anthropic Engineering(Erik Schluntz・Barry Zhang)・2024-12-19 | 本文 | -| 主軸 | 「Harness engineering: leveraging Codex in an agent-first world」 https://openai.com/index/harness-engineering/ | OpenAI(Ryan Lopopolo)・2026-02-11 | openai.com は 403 のため読み取りのプロキシ経由で本文を取得した。**日付は検索結果の値で、本文からは確かめていない** | -| 主軸 | Claude Code の `/goal` https://code.claude.com/docs/en/goal 、hook https://code.claude.com/docs/en/hooks ・ https://code.claude.com/docs/en/hooks-guide 、Agent SDK と非対話の上限 https://code.claude.com/docs/en/agent-sdk/agent-loop ・ https://code.claude.com/docs/en/agent-sdk/configuration ・ https://code.claude.com/docs/en/headless | Anthropic・2026-10-24 時点 | 本文 | -| 語の原典(loop engineering) | Addy Osmani「Loop Engineering」 https://addyosmani.com/blog/loop-engineering/ | 個人ブログ・2026-06-07 | 本文。これより早い用例は検索の範囲で見つからなかった(無いとは言えない) | -| 語の原典(software factory) | StrongDM「Software Factories And The Agentic Moment」 https://factory.strongdm.ai/ | StrongDM AI(Justin McCarthy)・2026-02-06 | **Story のページだけ。Principles・Techniques の下位ページは未読** | -| 語の原典(dark factory) | Dan Shapiro「The Five Levels」 https://www.danshapiro.com/blog/2026/01/the-five-levels-from-spicy-autocomplete-to-the-software-factory/ | 個人ブログ・2026-01-23 | 本文 | -| 語の原典(Ralph loop) | Geoffrey Huntley「everything is a ralph loop」 https://ghuntley.com/loop/ | 個人ブログ・2026-01-17 | 本文 | -| 未読 | arXiv「Loop Engineering: Building Blocks, Adoption, and Impact」 https://arxiv.org/pdf/2608.21884 | — | **検索で見つけたが開いていない。根拠に使わない** | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| `development-workflow` の `SKILL.md`(節を 1 つ足す) | `--loop` の引数を受けたら `references/loop.md` の手順へ移ることだけを書く。既存の節は変えない | -| `references/loop.md`(新規) | conductor が周回ごとに打つ順・止まったときの出し方・人の判断待ちの答え方。判断(モード・版)と機械の手順(選定・判定・記録)の分担 | -| `sprint-state.py`(副命令 `loop` を足す) | `loop start` / `select` / `exclude` / `sprint` / `gate` / `answer` / `approve-mvv` / `lap` / `stop` / `status` / `list` の入口。引数を読み、`lib/sprint_loop.py` を呼び、結果 JSON を出す。既存の副命令は変えない | -| `lib/sprint_loop.py`(新規) | 開発ループの集約の持ち主。記録の読み書き(排他)・使ってよい条件・選定・止まる条件の判定・周回の締め・`laps.md` の描画。LLM を呼ばない | -| `lib/pace.py`(関数を足す) | `read_loop(root)`: `loop` 節を読み、既定を埋め、形を確かめる。`read_pace` は変えない(`loop` 節が壊れていても `fast` / `auto` は今どおり動く) | -| `supervise_lib/sprint.py`・`new_args.py`(`new close` に引数を足す) | `--pace auto` のときはまとめのステージだけを組む。`--record-pr` で本番の記録の Pull Request を受ける。`--carry` で前の周回の持ち越しを棚卸しのステップへ渡す | -| `handoff.py`(名の形を 1 つ足す) | 名の形(`NAME_RE`)に `loop-<ループ名>` を足す。今の 3 つの形の扱いは変えない | -| `references/handoff.md`(名の表に 1 行) | 引継ぎの対象が開発ループのときの名 `loop-<ループ名>` を表の先頭に足す | -| `references/pace.md`(設定の表に行を足す) | `loop` 節の項目を「設定(`.ndf/pace.json`)」の表へ足し、`loop.md` を指す | -| 既存のまま使うもの | `relay.py`(切り替え)・`upkeep.py rank`(順位)・`mvv-gate.py`(判定とレッドライン)・`supervise.py new sprint --pace auto` / `queue` / `wait` | - -**図に含めない要素**: `references/handoff.md`・`references/pace.md` は文書の行を足すだけで、処理の流れに現れない。`SKILL.md` の節も `references/loop.md` へ移る入口だけである。`handoff.py` は `lib/sprint_loop.py` が引継ぎ文書(H)を書くときの部品で、L → H の辺に含める。 - -```mermaid -graph TB - subgraph 利用者 - U[--loop の起動・答え・止める指示] - end - subgraph conductor - C[references/loop.md の手順
判断: モード・版] - end - subgraph スクリプト - SS[sprint-state.py loop] - L[lib/sprint_loop.py] - P[lib/pace.py read_loop] - SV[supervise.py new sprint / new close / queue] - R[upkeep.py rank] - G[mvv-gate.py] - end - subgraph 記録 - LJ[.ndf/loop/名/loop.json と laps.md] - ST[スプリント状態ファイル] - H[.ndf/handoff/loop-名.md] - end - RL[relay.py ラッパー] - U --> C - C --> SS --> L - L --> P - L --> R - L --> ST - L --> LJ - L --> H - C --> SV - SV --> G --> ST - C -->|ndf-next| RL -->|次のセッション| C -``` - -### システムの文脈と配置 - -```mermaid -graph LR - subgraph 利用者の端末 - RL[ラッパー relay.py] --> CL[claude: conductor] - CL --> SC[NDF のスクリプト] - SC --> MD[メインディレクトリの .ndf/loop・.ndf/handoff] - SC --> SD[~/.local/state/ndf: プランの出力・upkeep の記録・mvv-gate.jsonl] - end - SC --> GH[GitHub: 課題・マイルストーン・Pull Request] - SC --> CP[claude -p: worker と MVV 判定] -``` - -**ループの記録はメインディレクトリの `.ndf/loop/` に置き、コミットしない。** 引継ぎ文書(`.ndf/handoff/`)と同じ扱いで、worktree の後片付けで消えず、`git status` に `?? .ndf/loop/` と出る。`.ndf/` の設定(C7)ではなく、スクリプトが作る記録である。 - -### パッケージ・モジュール構成 - -```text -plugins/ndf/ -├── scripts/ -│ ├── sprint-state.py # 副命令 loop を足す -│ ├── handoff.py # 名の形に loop-<名> を足す -│ ├── lib/ -│ │ ├── pace.py # read_loop を足す -│ │ └── sprint_loop.py # 新規: 開発ループの集約 -│ ├── supervise_lib/ -│ │ ├── sprint.py # new close --pace auto / --carry / --record-pr -│ │ └── new_args.py # 引数を足す -│ └── tests/ -│ └── test_sprint_loop.py # 新規 -└── skills/development-workflow/ - ├── SKILL.md # 節「開発ループ(--loop)」を足す - └── references/ - ├── loop.md # 新規 - ├── handoff.md # 名の表に 1 行 - └── pace.md # 設定の表に loop 節の行 -``` - -## 構造 - -```mermaid -classDiagram - class Loop { - name - started_at - status: running|stopped - project_mvv: MvvRef - budget: Budget - laps: Lap[] - waiting: Waiting[] - excluded: Exclusion[] - sprint_mvv_approvals: Approval[] - stop: StopReason? - consumed() Consumption - } - class Lap { - n - sprint - state_path - selection: SelectionRow[] - gates: GateCopy[] - versions - refine - cost - seconds - outcome: 進行中|完了|失敗|人へ戻した - failure: FailureKey? - } - class Budget { - laps - usd - hours - } - class SelectionRow { - number - milestone - rank - decision: 選んだ|外した - reason - } - class StopReason { - condition - values - at - resume - } - class FailureKey { - issues - kind - step - } - class SprintState { - <<既存>> - plans - gates - } - Loop "1" *-- "many" Lap - Loop *-- Budget - Loop *-- StopReason - Lap *-- "many" SelectionRow - Lap *-- FailureKey - Lap ..> SprintState : state_path で参照 -``` - -`Loop.consumed()` は周回の行の和(数・費用・秒)とループの開始からの経過時間を返す純粋な関数で、書き込まない。 - -## データ構造 - -### ループの記録(`.ndf/loop/<名>/loop.json`) - -```mermaid -erDiagram - LOOP ||--o{ LAP : has - LOOP ||--o{ WAITING : has - LOOP ||--o{ EXCLUSION : has - LOOP ||--o{ SPRINT_MVV_APPROVAL : has - LAP ||--o{ SELECTION_ROW : has - LAP ||--o{ GATE_COPY : has - LAP }o--|| SPRINT_STATE : "state_path" -``` - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `version` | 数 | 記録の形の版。`1` | -| `name` | 文字列 | ループの名前(英数字・`-`・`_`) | -| `repo` | 文字列 | `OWNER/REPO` | -| `started_at` | ISO 8601 | ループの開始の時刻(再開で変えない)。経過時間は周回の時刻から数え、この値を起点にしない(I8) | -| `project_mvv` | `{version, sha256}` | 開始時の承認済みプロジェクト MVV | -| `budget` | `{laps, usd, hours}` | 効いている予算(宣言の値。再開の引数で上書きしたら上書きの値) | -| `budget_history` | 配列 | 予算の上書きの履歴(`{at, from, to}`)。上書きのたびに 1 行足す | -| `same_failure` / `max_issues` | 数 | 宣言から写した値 | -| `milestones` | 文字列の配列 | 起動の引数で絞ったマイルストーン(空ならすべて) | -| `status` | `running` / `stopped` | 今の状態 | -| `stop` | オブジェクトか null | 止まった理由(`{condition, values, lap, at}`。`condition` と `values` は下の表、`lap` は止まったときの周回の番号、`at` は止まった時刻)。`running` に戻るときは `stops` へ移して null にする | -| `stops` | 配列 | 過去の止まった理由(追記だけ)。`stop` を移すときに再開の時刻 `resumed_at` を足す | -| `laps[]` | 配列 | 周回(追記だけ。I1) | -| `waiting[]` | 配列 | 人の判断待ち(`{number, reason, gate, pr, lap, at, answer?, answered_at?, merged_at?}`)。答えは行を消さずに `answer` を足す。`approved` の行は、選定で設計 PR のマージを確かめた時刻 `merged_at` を足す | -| `excluded[]` | 配列 | 周回ごとの除外(`{number, reason, lap}`。モードが対象外・MVV に反する疑い) | -| `sprint_mvv_approvals[]` | 配列 | 利用者が承認したスプリント MVV(`{milestone, sha256, at, by}`) | - -周回(`laps[]` の 1 行): - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `n` | 数 | 周回の番号 | -| `sprint` | 文字列 | スプリントの名前(`<ループ名>-`) | -| `state_path` / `out` | パス | スプリント状態ファイルとプランの出力先 | -| `milestone` | 文字列 | 選んだ課題のマイルストーン | -| `rank_digest` | 文字列 | 選定に使った `rank` の `metrics.digest` | -| `selection[]` | 配列 | `{number, milestone, rank, decision, reason}`。外した行も載せる | -| `mode` | 文字列 | conductor が判定したモード | -| `gates[]` | 配列 | 承認ゲートの判定の写し(`{name, by, verdict, basis, pr, at}`)。`mvv-gate.jsonl` とスプリント状態の `gates[]` から写す | -| `versions` | `{dev, prod}` | 配布した版 | -| `retro` | 文字列 | 振り返りの置き場(まとめのプランの報告の URL) | -| `refine` | オブジェクト | `{report, rank_changes, deferred[]}`。棚卸しの報告・順位の変化・持ち越し | -| `created[]` | 数の配列 | 周回の中で起票された課題(選定の対象にしない。I4 の入力にもしない) | -| `cost` / `seconds` | 数 | スプリント状態ファイルとまとめのプランの行の和 | -| `outcome` | 文字列 | `進行中` / `完了` / `失敗` / `人へ戻した` | -| `failure` | オブジェクトか null | `{issues, kind, step}`(決定 8) | -| `relay_dir` | パスか null | 周回を締めたセッションの `NDF_RELAY_DIR` | -| `started_at` / `ended_at` | ISO 8601 | 周回の開始と終わり | - -止まった理由(`stop`): - -| `condition` | 判定 | `values` | -| --- | --- | --- | -| `budget_laps` / `budget_usd` / `budget_hours` | 消費が予算以上 | `{consumed, budget}` | -| `same_failure` | 直近の失敗の周回が同じ鍵で `same_failure` 回続いた | `{key, count}` | -| `no_issue` | 選定の候補が 0 件 | `{milestones}` | -| `outside_mvv` | 候補が 1 件以上あり、すべて外れた | `{reasons}`(理由ごとの件数) | -| `user_stop` | 止める指示のファイルがある | `{at}` | -| `gate2_returned` | 承認ゲート 2 を人へ戻した | `{pr, verdict, reasons}` | -| `mvv_changed` | プロジェクト MVV の sha256 が開始時と違う・未承認になった | `{started, now}` | -| `refused` | 周回を組めなかった(`new sprint` が断った・状態を書けない) | `{step, exit, summary}` | - -### CRUD - -| 機能 | ループの記録 | 周回 | 人の判断待ち | スプリント状態 | 引継ぎ文書 | 課題の本文 | -| --- | --- | --- | --- | --- | --- | --- | -| F1 `loop start` | C / U(再開) | R | R | R | C / U | — | -| F2 `loop select` | U | C | R | — | — | — | -| F3 `loop exclude` | U | U(選定の行) | — | — | — | — | -| F4 `loop sprint` | U | U | — | C | — | — | -| F5 `loop gate` | U | U | C | R | U | U | -| F6 `loop answer` | U | — | U(答えを足す) | — | — | U | -| F8 `loop lap` | U | U(締める) | R | R | U | — | -| F9 `loop stop` | R | — | — | — | — | — | -| F11 `loop approve-mvv` | U | — | — | — | — | — | -| F10 `loop status` / `list` | R | R | R | R | — | — | - -## 入出力の契約 - -### 起動の引数(`development-workflow`) - -```text -/goal /ndf:development-workflow --loop [<ループ名>] [--milestones ,...] [--budget-laps N] [--budget-usd X] [--budget-hours H] -``` - -| 引数 | 意味 | 無いとき | -| --- | --- | --- | -| `--loop` | 開発ループで進める | 今の 1 スプリントの進め方(受け入れ条件 1) | -| `<ループ名>` | 記録の名前。同じ名前の記録があれば再開する | `l`(同じ名前の記録があればそれを再開する) | -| `--milestones` | 選定の対象を絞る | 開いているマイルストーンすべて | -| `--budget-*` | 予算を上書きする(記録の `budget_history` に残る) | 宣言の値 | - -`ndf-next` と再開のコマンドは `/goal /ndf:development-workflow --loop <ループ名>` の形で、名前を必ず含める。`/goal` で始めていなければ `/goal` を付けない。決定 12 で案 B を選んだときは、末尾に終わりの条件の節(`-- 終わり: [ndf-loop] stopped <ループ名> の行を出したとき`)を足す。Skill は `--` から後を引数として読まない。 - -### 設定(`.ndf/pace.json` の `loop` 節) - -```json -{"loop": {"enabled": false, "budget": {"laps": 3, "usd": 60, "hours": 24}, "same_failure": 2, "max_issues": 3}} -``` - -| 項目 | 型 | 無いとき | 意味 | -| --- | --- | --- | --- | -| `loop.enabled` | bool | 偽(ループを断る) | ループを許すか | -| `loop.budget.laps` / `usd` / `hours` | 正の数 | 3 / 60 / 24 | ループの予算(決定 5) | -| `loop.same_failure` | 1 以上の整数 | 2 | 同じ失敗で止まる回数(決定 8) | -| `loop.max_issues` | 1 以上の整数 | 3 | 1 周の課題の上限(棚卸しの容量が無いときの切り出しの数。容量があっても超えない) | - -**ループは `auto` 節も要る。** 周回は `pace: auto` で組むため、`auto.enabled` と `auto.verify` が無ければ `loop start` が断る。`loop` 節の形が誤っていれば `read_loop` が `PaceError` を投げ、`loop start` だけが断る。`read_pace` を使う `fast` / `auto` / `check-trigger` / `mvv-gate` は今どおり動く。 - -### 副命令(`sprint-state.py loop`) - -結果は既存と同じ `step_result` の 1 行の JSON(`tool: sprint-state`)。 - -| 副命令 | 入力 | 出力(`metrics` の主なキー) | 終了コード | -| --- | --- | --- | --- | -| `loop start <名> [--milestones M,...] [--budget-* ...] [--root DIR]` | 宣言・プロジェクト MVV・記録 | `status`(`new` / `resumed` / `in_lap`)・`lap`・`consumed`・`budget`・`next`(次に打つ副命令かプランの続き) | 0 = 始めた・続ける / 1 = 断った(`items` に理由、`next` に 1 スプリントの起動の形 `/ndf:development-workflow #<課題>`)/ 3 = 止まったまま(止まった理由が解けていない。`items` に理由と再開のコマンド) | -| `loop select <名>` | `upkeep.py rank` の出力(その場で打つ)・記録・`answer: approved` で `merged_at` が無く、今回の候補にある行の設計 PR の状態(`gh pr view` をその場で打つ) | `lap`・`selected[]`・`excluded[]`・`milestone`・`rank_digest` | 0 = 選んだ / 3 = 止まった(`no_issue` / `outside_mvv`。`stop` を書いた)/ 1 = 進行中の周回がある・`rank` が 2 か 3 を返した | -| `loop exclude <名> <課題> --reason <文>` | 進行中の周回の選定 | 外した課題・選定をやり直した結果(`select` と同じ形) | 0 / 3(すべて外れた)/ 1 | -| `loop sprint <名> --mode M --dev V [--prod V] [--] ` | 進行中の周回の選定・前の周回の `refine.deferred` | 打ったコマンド・`state_path`・`out`・マニフェストのパス・`close_command`(内で打った `new close --pace auto --issue <選んだ課題> --carry <前の周回の refine.deferred> ...`。持ち越しが無ければ `--carry` を付けない)・最初の `queue` のコマンド(まとめのステージを最後の `--then` につないだもの)・`pipeline`(本番から `loop lap` までの 1 行。下の「1 周の流れ」) | 0 / 3(`sprint-state.py init` か `new sprint --pace auto` か `new close --pace auto` が断った。`stop: refused` を書く) | -| `loop gate <名> --done ` | キューの done・`mvv-gate.jsonl`・スプリントの状態 | `gate`(`1` / `2`)・人の判断待ちへ移した課題・続きの `queue` のコマンド(外した課題の計画を除き、外した課題を除いた `new close --pace auto` を組み直して最後の `--then` につないだもの)・`pipeline` | 0 = 残りで続ける、または残りが無く周回を締めて次の周回へ進む(`ndf_next` を返す)/ 3 = 止まった(承認ゲート 2・MVV が改訂された・締めた後の判定で止まる条件に当たった) | -| `loop answer <名> <課題> --approved\|--rejected [--pr N]` | 人の判断待ち | 答えを書いた行 | 0 / 1(待っていない課題) | -| `loop lap <名> [--failed]` | スプリントの状態(`update` を内で打つ)・まとめのプランの done と報告・upkeep の候補の記録・止める指示のファイル | 締めた周回・`stop`(止まったとき)・`ndf_next`(続くとき) | 0 = 続く / 3 = 止まった | -| `loop approve-mvv <名> --milestone M` | マイルストーンの説明のスプリント MVV | 承認した sha256(`sprint_mvv_approvals` に 1 行) | 0 / 3(説明に `## Mission` / `## Vision` / `## Value` がそろわない) | -| `loop stop <名>` | — | 置いたファイルのパス | 0 | -| `loop status <名> [--json]` / `loop list <名> [--json]` | 記録・`relay_dir` の `log.jsonl` | 今の状態 / 周回の一覧(何周目・選んだ課題・版・費用・止まった理由) | 0 / 1(記録が無い) | - -**3 は「止まる条件に当たった」で、失敗ではない。** `step_result` の 3(各スクリプトが定めた正常な否定の結果)に当てる。4 は `merge-when-green` のベースブランチとの衝突が使っているため使わない。断る(1)と止まった(3)は、`metrics.status` の `refused` / `stopped` でも見分ける。止まったときの出力の `items` には、止まった条件・判定に使った値・再開のコマンドを必ず載せる。`metrics.stop_line` には決まった語の 1 行(決定 12)を載せる。 - -### `supervise.py new close` に足す引数 - -| 引数 | 意味 | 既定 | -| --- | --- | --- | -| `--pace auto` | 最終の検査・開発版・本番を置かず、まとめのステージ(確定仕様化 → Pull Request → 課題を閉じる → 振り返り → 棚卸し)だけを組む。`fast` 節を読まない | 無し(今の形。`fast` のまとめ) | -| `--record-pr N` | 課題を閉じるステップ(`sprint-close.py --record-pr`)に渡す本番の記録の Pull Request。ループの周回では渡さない(下の注) | 無し(今の導き方) | -| `--carry N...` | 棚卸しのステップの指示へ「前の周回の持ち越し #N... を `--add` で渡す」を足す | 無し(指示は今のまま) | - -`--pace` を渡さない `new close` の出力は変わらない(受け入れ条件 18)。 - -**`--record-pr` の値の出所は、同じキューの前のステージの本番のプランである。** `loop sprint` はまとめのステージを周回のキューの最後の `--then` につなぐため、`--record-pr` を渡さず、`new close` の今の導き方(経路に合わせて `{queue_pr:release-prod}` / `{queue_pr:promote}` / `0` / `unknown`。`pace.md` の「スプリントの終わり」)の印を残す。キューがまとめのステージを流す前に、前のステージの本番のプランの Pull Request で印を埋める(`supervise_lib/queue.py` の `fill_queue_pr`)。`--record-pr N` は、周回の外で人がまとめだけを組み直すときに使う。 - -## 処理の流れ - -### 1 周の流れ - -```mermaid -sequenceDiagram - participant C as conductor - participant S as sprint-state.py loop - participant R as upkeep.py rank - participant Q as supervise.py queue - participant G as mvv-gate.py - participant RL as ラッパー - C->>S: loop start 名 - S-->>C: new / resumed / in_lap(断れば 1、止まったままなら 3) - C->>S: loop select 名 - S->>R: rank - R-->>S: 順位・境界・人の判断待ちの印 - S-->>C: 選んだ課題と外した理由(0 件なら 3) - Note over C: モード判定と版の決定(判断)。対象外は loop exclude - C->>S: loop sprint 名 --mode M --dev V --prod V - S->>Q: sprint-state.py init → new sprint --pace auto → new close --pace auto --carry 持ち越し - S-->>C: 最初の queue のコマンドと pipeline - C->>Q: pipeline(queue 設計 → 本番 → まとめまで --then → wait → loop lap) - Q->>G: 承認ゲート 1・2 の MVV 判定 - alt 承認ゲートで止まった - Q-->>C: gate(10) - C->>S: loop gate 名 --done - S-->>C: ゲート 1: 外した残りの queue / ゲート 2: 3(止まる) - end - Q->>S: loop lap 名(pipeline の最後) - S-->>C: 続く: ndf-next / 止まった: 3 と理由 - C->>RL: ndf-next(続くときだけ) - RL->>C: 次のセッション(loop start から) -``` - -**本番のキューから `loop lap` までを 1 本のパイプラインで流す。** 形は今の「本番の後のカットポイント」(`relay.md`)と同じである。まとめのステージは `loop sprint` が周回の組み立てのときに `new close --pace auto` で組み、周回のキューの最後の `--then` につなぐ(持ち越しは前の周回の `refine.deferred` から `--carry` へ、`--record-pr` は前のステージの本番のプランから。「`supervise.py new close` に足す引数」の注)。続ける順は `supervise.py queue <設計 … 本番 … まとめ>` → `wait` → `loop lap` で、この 1 行を `loop sprint` と `loop gate` が `pipeline` として出す。conductor は `pipeline` をそのまま流す。ゲート 2 の通過から次のセッションの起動までに、conductor が組み立てる文は無い。 - -**周回の途中のカットポイント(ゲート 1 の後など)は今のまま切る。** `ndf-next` の中身はループの再開のコマンドである。次のセッションの `loop start` が、`in_lap` と引継ぎ文書 `loop-<名>` の「次にやること」を返す。conductor は今と同じく引継ぎ文書の続きから進める。 - -### 選定(`loop select`) - -1. 進行中の周回があれば 1(I2)。プロジェクト MVV の sha256 が開始時と違えば `mvv_changed` で止まる(I9) -2. `upkeep.py rank --root <根>` を打つ。見積は記録(`estimates.json`)と説明の `### 順位` の表から採られ、棚卸しの担当が付けた段階がそのまま効く。2 か 3 なら 1 で返す(選定を推さない)。20(段階の欠けた課題がある)は、欠けた課題が `rows` に載らないだけなので続ける -3. マイルストーンを `rank` の `order` の順に見る(`--milestones` で絞ったときはその中だけ)。各マイルストーンで: - - スプリント MVV が未承認なら、そのマイルストーンの行をすべて「スプリント MVV が未承認」で外す。未承認とは、説明に `## Mission` / `## Vision` / `## Value` がそろい、その sha256 が `sprint_mvv_approvals` に無いことである - - 候補は `rows` を `rank` の順に見た次の 2 つ: - - `boundary.number` までの行(境界が無ければ先頭から `max_issues` 件) - - `large_slot.status` が収まる(fit)ときの `large_slot.slice` - - 候補から次の行を外す(括弧は外した理由): - - `waiting_decision` が真(人の判断待ち) - - `waiting[]` に答えの無い行がある(人の判断待ち) - - `waiting[]` の行が `answer: rejected`(差し戻し。答えた後の周回でも外し続ける) - - `waiting[]` の行が `answer: approved` で、その行の `pr`(設計 PR)がまだマージされていない(設計 PR がマージ待ち)。マージは `gh pr view --json state` の `MERGED` で確かめる。打つのは `approved` の行のうち、`merged_at` が無く、その課題が今回の `rank` の候補にあるものだけで、`MERGED` を確かめた行には `merged_at` を足して以後打たない。この PR の状態は I4 の入力である - - この周回の `excluded[]` にある(その理由) - - `size` が尺度を超える(容量) - - 残りを `rank` の順に `max_issues` 件まで選ぶ。超えた行は「容量」で外す - - 1 件でも選べたら、このマイルストーンで決める(1 つのスプリントは 1 つのマイルストーンから組む) -4. どのマイルストーンでも選べず、候補が 0 件なら `no_issue`、候補があってすべて外れたなら `outside_mvv` で止まる -5. 周回の行を足し、選んだ行と外した行を `selection[]` に、`rank` の `metrics.digest` を `rank_digest` に書く - -**入力に無いものを選定に使わない。** 周回の中で起票された課題(`created[]`)は、棚卸しが段階を付けた後の周回で初めて候補になる(受け入れ条件 6)。`rank` が段階のある課題だけを並べるためである。 - -### 承認ゲートで止まったとき(`loop gate`) - -| キューが止まった所 | 判定の記録(`mvv-gate.jsonl` の行) | 扱い | -| --- | --- | --- | -| 設計のプラン(承認ゲート 1) | 機械のチェック 1・2(プロジェクト MVV・MVV の承認の不一致) | `mvv_changed` で止まる | -| 同上 | 機械のチェック 4・5(`boundary_paths`・MVV の宣言を変える)、判定の `boundary` が空でない、判定が「従わない」「判定できない」、判定を読めない | その課題を人の判断待ちへ移し(`waiting[]`・課題の本文)、残りの課題の計画で続きの `queue` を組む。残りが無ければ周回を `人へ戻した` で締めて次の周回へ(止まる条件の判定を通す) | -| 本番のプラン(承認ゲート 2) | どの判定でも | `gate2_returned` で止まる。今の「承認ゲートで止まった後の続け方」(`pace.md`)で利用者が答え、再開のコマンドで続ける | - -**ゲート 1 の続きは、マニフェストの `resume` から外した課題の計画(設計・実装)を除いて組む。** 承認ゲート 1 の判定はプランの中で課題ごとに走るため、1 本が `関門` でも他の設計 PR はマージ済みである。 - -**課題の本文には `## 人の判断待ち(開発ループ <名>)` の節を書く。** 判定・理由・根拠の項目・設計 PR の URL と、答え方(`loop answer`)を載せる。節が既にあれば本文全体を書き直す形で置き換える。 - -### 人の判断待ちに答えたとき(`loop answer`) - -| 答え | 記録 | その後 | -| --- | --- | --- | -| `--approved` | `waiting[]` の行に `answer: approved`。今の承認の記録(`sprint-state.py gate <その周回の状態> "関門 1" --by user --outcome approved --pr N`)を内で打ち、覆しの記録も今どおり残る | 利用者が設計 PR をマージした後の周回で、選定の対象に戻る。その課題は `--design` に入れず、`loop sprint` が `supervise.py design-results` で設計の結果を読んで実装のプランへ渡す | -| `--rejected` | `waiting[]` の行に `answer: rejected`。差し戻しの記録(`gate-rejections.jsonl`)を今どおり残す | 選定の対象に戻さない(答えのある行は外し続ける。理由「差し戻し」) | - -### 止まる条件の判定(`loop lap`) - -```mermaid -stateDiagram-v2 - [*] --> 進行中: loop select(周回を足す) - 進行中 --> 進行中: loop gate(ゲート 1 を外して続ける) - 進行中 --> 締め: まとめのプランの終わり / --failed / 残りが無い - 締め --> 判定 - 判定 --> 待機: どの条件にも当たらない(ndf-next を返す) - 判定 --> 停止: 条件に当たった(stop を書く) - 進行中 --> 停止: ゲート 2 を人へ戻した / MVV が改訂された / 組めなかった - 待機 --> 進行中: loop select - 停止 --> 判定し直し: loop start(再開) - 判定し直し --> 待機: 理由が解けた(stop を stops へ移す) - 判定し直し --> 停止: 解けていない(3) -``` - -判定の順は次のとおりで、最初に当たったものを止まった理由にする(判定に使った値はすべて `values` に残す)。 - -1. `user_stop`: `.ndf/loop/<名>/stop` がある -2. `mvv_changed`: プロジェクト MVV が開始時と違う・承認と一致しない -3. `same_failure`: 直近の `outcome: 失敗` の周回を新しい順に見て、`failure` が同じ鍵の周回が `same_failure` 回続く(間に `完了` の周回があれば数え直す)。`condition: same_failure` の `stops` の最後の行の `resumed_at`(同じ失敗で止まった後の最後の再開)より前に始まった周回は数えない。ほかの理由(`gate2_returned` など)の再開は区切りにしない -4. `budget_laps` / `budget_usd` / `budget_hours`: 終わった周回の数・費用の和・動いていた時間(I8。周回の `started_at`〜`ended_at` の和から、`stops` の各行の `at`〜`resumed_at` のうち各周回の `started_at`〜`ended_at` と重なる部分だけを引いたもの。境で止まった時間は引かない)が予算以上 -5. (選定の時点で)`no_issue` / `outside_mvv` - -**再開(`loop start`)は止まった理由ごとに解けたかを見る。** - -| 止まった理由 | 解けたかの見方と、その後 | -| --- | --- | -| `user_stop` | 再開でファイルを消して解ける | -| 予算(`budget_laps` / `budget_usd` / `budget_hours`) | 再開の引数で上書きした予算で判定し直す | -| `same_failure` | 止まった後に利用者が直したことを機械で知れないため、再開の起動そのものを解けた印とし、失敗の数を数え直す(`stops` に移した行の `resumed_at` が数え直しの区切りになる。判定の手順 3) | -| `gate2_returned` | その周回の状態に `関門 2` の記録(利用者の承認)があれば解ける。本番のプランを `run <本番のプラン> --from bump` で続けてから(`pace.md` の「承認ゲートで止まった後の続け方」)、まとめのステージへ進む。ゲート 2 で止まるとキューの残り(まとめのステージ)は流れないため、`loop start` は `next` に `pipeline`(`run <本番のプラン> --from bump` → まとめの `supervise.py queue` → `wait` → `loop lap` の 1 行)を載せる。まとめは `new close --pace auto --issue <選んだ課題> [--carry ...] --record-pr ` で組み直す(`--record-pr` の値は、承認ゲート 2 で示した本番のプランの Pull Request。キューの中なら `{queue_pr:release-prod}` が埋まるのと同じ値)。差し戻しの記録があれば、その周回を `人へ戻した` で締めて次の周回へ進む | -| `refused` | 再開の起動そのものを解けた印とし(断った原因を利用者が直したことを機械で知れないため)、`next` に同じ周回での `loop sprint <名>` のやり直しを載せる(モードと版は conductor が決め直す。決定 3)。やり直しの中で `new sprint` / `new close` を打ち直し、また断れば `stop: refused` を書いて 3 で止まる | -| `mvv_changed` | 開始時の値を今の承認済みの値へ置き換えず、新しい名前でループを始め直すよう示す(承認の記録の起点を混ぜない) | - -### 止まったときの出し方 - -**止まったときは `ndf-next` のブロックを出さない。** ラッパーがブロックを拾うと次のセッションを起動し、同じ理由で止まり直して再起動ループの上限まで回る。conductor は止まった理由・判定に使った値・再開のコマンドを本文に書く。**最後に決まった語の 1 行 `[ndf-loop] stopped <ループ名> ` を出して**応答を終える(決定 12)。1 行は `loop` の結果 JSON の `metrics.stop_line` をそのまま写し、conductor が文面を作らない。再開のコマンドは引継ぎ文書の「次に実行するコマンド」の節にも `ndf-next` の形で残す(受け入れ条件 12)。 - -| 止まった理由 | 利用者へ問うか | -| --- | --- | -| `gate2_returned` | 問う(レッドラインか判定が「従う」以外の承認ゲート 2。今の承認ゲートの出し方で `AskUserQuestion`) | -| それ以外 | 問わない。本文で理由と再開のコマンドを示して終える(本文の承認待ちの文にしない) | - -**ラッパーの 1 日の起動回数の上限に当たったとき**は、ラッパーが次のセッションを起動せず、引継ぎ文書には直前の `ndf-next` が残る。`loop status` は、最後の周回の `relay_dir` の `log.jsonl` に `stop`(`max-starts`)の行があるかを見る。あれば「ラッパーが上限で止めた」と再開のコマンドを出す(非機能の可用性)。 - -## 非機能の実現方式 - -| 大項目 | 条件 | 実現方式 | -| --- | --- | --- | -| 可用性 | ラッパーの起動回数の上限で理由と再開のコマンドを残す | 周回の途中のカットポイントで引継ぎ文書へ再開のコマンドを書く(今の手順)。`loop status` がラッパーの記録の `stop` の行を読んで出す。ラッパーは変えない | -| 運用・保守性 | 止まる条件の判定は LLM を呼ばない。閾値は設定で受ける | `lib/sprint_loop.py` は LLM を呼ばない(判断はモードと版だけで、conductor が持つ)。閾値は `.ndf/pace.json` の `loop` 節と起動の引数だけから読み、スクリプトに既定値の定数を 1 か所だけ置く | -| セキュリティ | レッドラインと `boundary_paths` をループでも今と同じに効かせる | ループは承認ゲートを自分で通さない(I6)。判定は `mvv-gate.py check` をそのまま通り、`loop gate` は判定の記録を読んで外す・止めるだけ。`loop` 節に `boundary_paths` やレッドラインを変える項目を置かない | -| 移行性 | ループの設定が無いリポジトリでは何も変わらない | `loop` 節は `read_loop` だけが読む。`--loop` を付けない起動・`--pace` を付けない `new close` の振る舞いと出力は変わらない | -| コストとスピード | コスト > スピード > 品質の順(Value 8) | 品質を上げる工程は今の上限(ラウンド・時間の予算・`timeout`)で打ち切り、残りを Issue にする(決定 13)。全体の上限は予算(決定 5) | -| 排他 | 同じループの記録を 2 つのセッションが同時に書かない | 記録の読み書きは `locks`(`sprint-state.py gate` と同じ排他)で囲み、`jsonio.write_atomic` で書く | - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 1・18 | `--pace` を渡さない `new close`、`loop` を打たない `sprint-state.py` の各副命令、`read_pace` の出力が今のまま(既存のテストがすべて通る) | `new close` の既定を `auto` のまとめに変える・`read_pace` が `loop` 節を検証する | -| 2 | `loop start` が `loop.enabled` 偽・`auto` 節の欠け・プロジェクト MVV の未承認か不一致・`loop` 節の形の誤りのそれぞれで 1 を返し、`next` に 1 スプリントの起動の形を出し、記録を作らない | どれか 1 つの確かめを外す・断るときに記録を作る | -| 3・I6 | `loop gate` が判定の記録のどの行でも承認ゲートの記録を書かず、「判定できない」の行を人の判断待ちへ移す。ゲート 1 で全課題が外れたら周回を `人へ戻した` で締めて次の周回へ進み、ゲート 2 ではどの判定でも 3 で止まる | `unknown` を通過として扱う・`loop gate` が `sprint-state.py gate --by mvv` を打つ・ゲート 2 で人へ戻した後に次の周回を始める | -| 4・I4 | 同じ `rank` の出力と記録から、`select` が同じ組と並びを返す。境界まで・`max_issues` まで・1 つのマイルストーンから | 並びを集合で扱う・境界を無視する・2 つのマイルストーンから選ぶ | -| 5 | 選定の行が、選んだ行と外した行の両方を理由(順位・容量・スプリント MVV が未承認・人の判断待ち・除外の理由)つきで持つ | 外した行を書かない | -| 6 | 周回の中で起票された課題が、その周回の選定に入らない | `created[]` を候補に足す | -| 7 | 周回の数・費用・経過時間のそれぞれが予算以上で `loop lap` が 3 を返し、周回の途中(進行中の周回)では `stop` を書かない | 判定を `>` と `>=` で取り違える・`loop gate` で予算を判定する | -| 8 | 同じ鍵の失敗が `same_failure` 回続くと止まり、間に完了の周回があれば数え直す。`same_failure` で止まって再開した後の 1 回目の失敗では止まらない。`gate2_returned` から再開して締まった失敗の周回は数える | 鍵を理由の文で作る・連続でなく累計で数える・再開より前の周回を数える・`same_failure` 以外の再開を数え直しの区切りにする | -| 9・10 | 候補 0 件で `no_issue`、候補がすべて外れて `outside_mvv` | 2 つを取り違える・0 件で周回を足す | -| 21 | `loop lap --deferred-issues` で渡した番号が周回の記録に件数と番号で残り、`loop status` の周回の一覧に出る。その番号は同じ周回の選定に入らない | 番号を書かない・同じ周回の候補に足す | -| 22 | `quality` 節を宣言した `rank` が、同じ入力から同じ境界を返し、品質向上の Issue を枠の分だけ先に取って印を付ける。宣言が無いと出力が今と同じ | 枠を割合でなく件数で丸め違える・宣言が無いときに枠を取る・印を出さない | -| 11 | `loop stop` のファイルで次の境に止まり、再開でファイルが消える | 周回の途中で止める・再開でファイルを残して止まり直す | -| 12 | 止まった出力の `items` に条件・値・再開のコマンドがあり、`metrics.stop_line` が `[ndf-loop] stopped <名> ` の形で、引継ぎ文書の「次に実行するコマンド」の節が再開のコマンドになる | 再開のコマンドを書かない・`stop_line` の条件の語を `stop.condition` と食い違わせる | -| 13・I8 | 再開した `loop start` が周回の数・費用・動いていた時間を引き継ぎ、次の周回の番号が続く。止まっていた時間(`stops` の `at`〜`resumed_at`)は経過時間に入らず、一晩止めて再開した周回を締めても `budget_hours` で止まらない。周回の境で止まった時間(`user_stop` などで周回の区間の外にある時間)を引かない | 再開で消費を 0 に戻す・経過時間を `started_at` からの壁時計で数える・境で止まった時間を引いて経過時間を減らす(負にする) | -| 14・15 | `loop lap` の後、周回の行が選定・判定の写し・版・振り返りの置き場・棚卸しの結果・費用と秒・判定を持ち、`loop list` が一覧を出す | どれかの項目を写さない | -| 16 | 前の周回の持ち越しが `loop sprint` → `new close --carry` の棚卸しの指示に載り、`loop sprint` の `close_command` と最初の `queue` のコマンドの最後の `--then` にまとめのステージがあり、`pipeline` が `queue` → `wait` → `loop lap` の 1 行で、`gate2_returned` から再開した `loop start` の `next` の `pipeline` が `run <本番のプラン> --from bump` → まとめの `queue` → `wait` → `loop lap` の 1 行(まとめの `--record-pr` が `stop` の `values.pr`)で、まとめの `close` のステップの `--record-pr` が前のステージの本番のプランの印になる | 持ち越しを読まない・まとめのステージをつながない・`--record-pr` を固定の値で渡す | -| 17・I5 | 人の判断待ちの課題が答えの前は選ばれず、`--approved` の後は設計 PR がマージされた後の周回で選ばれ、`--rejected` の後は選ばれない | 答えを待たずに選ぶ・差し戻しを選び直す・マージ前の `approved` を選ぶ | -| 19 | 新しい常駐の処理・キューを作らない(設計の範囲。レビューで見る) | — | -| 20 | 設計文書の出典(レビューで見る) | — | -| I1 | 終わった周回の行を書き換える呼び出しが 1 で拒まれる | 締めた周回を上書きする | -| I2 | 進行中の周回があると `select` が 1 | 2 つ目の周回を足す | -| I3 | `loop gate` と `loop sprint` が `stop` を予算・失敗・停止の指示で書かず、`gate2_returned`・`mvv_changed`・`refused` では周回を進行中のまま `stop` を書く。再開は、`gate2_returned` の承認と `refused` で同じ周回を続け、`gate2_returned` の差し戻しでその周回を `人へ戻した` で締め、`mvv_changed` では解けずに新しい名前での始め直しを示す | 途中で予算を判定する・ゲート 2 で `stop` を書かない・途中の停止で周回を締める・`mvv_changed` の再開で旧周回を続ける | -| I7 | 止まったループで `select` が 3 を返し、`start` が解けたと判定した後だけ通る | `start` を経ずに周回を始める | -| I9 | 周回の開始でプロジェクト MVV の sha256 が違えば `mvv_changed` で止まる | 開始時の値を今の値で置き換える | - -止まる条件と選定は、`rank` の出力・記録・宣言を入力にして `lib/sprint_loop.py` の関数を直接呼ぶ単体テストで縛る。`gh`・`claude`・`upkeep.py rank` は差し替える。 - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2089 | 実装する | — | `plugins/ndf/scripts/sprint-state.py`、`plugins/ndf/scripts/handoff.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/pace.py`、`plugins/ndf/scripts/supervise_lib/sprint.py`、`plugins/ndf/scripts/supervise_lib/new_args.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/SKILL.md`、`plugins/ndf/skills/development-workflow/references/loop.md`、`plugins/ndf/skills/development-workflow/references/handoff.md`、`plugins/ndf/skills/development-workflow/references/pace.md`、`docs/glossary/` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| 外部の一次情報の残り | OpenAI の記事の日付(検索結果の値)、StrongDM の Principles・Techniques のページ、arXiv 2608.21884 は確かめていない。どれも決定の根拠に使っていない | -| 予算の既定値 | 実測の標本が 2 件で、まとめの費用を含まない(決定 5)。リリース後テストの 2 周の実測で見直す | -| 外した課題を除いた続きの `queue` | マニフェストの `resume` から計画を除いて組めるか、`design-results` がマージされていない設計 PR を含む課題の組をどう扱うかは、実装(`tdd-cycle`)で走らせて確かめる | -| まとめのステージを周回のキューへつなぐ形 | `new close --pace auto` のステージを周回の `queue` の最後の `--then` につないだとき、`close` のステップの `{queue_pr:release-prod}` などの印が前のステージの本番のプランから埋まるかを、実装で走らせて確かめる | -| 人の判断待ちに答えた課題の設計の結果 | `--design` に入れない課題へ `supervise.py design-results` で設計の結果を渡せるかを、実装で確かめる | -| 失敗した周回の後始末 | 失敗で締めた周回のスプリントブランチと開いた Pull Request を次の周回がどう扱うか(今の conductor の手当てのまま残すか)。実装で 1 度失敗の周回を流して決める | -| 判定の記録の読み分け | `mvv-gate.jsonl` の行から機械のチェックのどれで外れたかを読めるか。読めなければ `mvv-gate.py` の結果の `items` を done から読む形にする | -| 持ち越しの読み先 | upkeep の候補の記録の置き場(`$NDF_UPKEEP_STATE_DIR`、既定は一時ディレクトリ)が周回をまたいで残るか。同じマシンの中では残る前提で、マシンをまたぐ置き場は #1640 が持つ | -| `/goal` の条件の判定 | 止まったときの 1 行(決定 12)を、評価役が「条件を満たした」か「達成できない」と判定してゴールを終えるか。リリース後テストで、止まる条件に当たる周回を 1 度流して確かめる | -| 1 周回の費用の上限 | 予算は周回の境で判定するため、1 周回の超過は止められない(決定 5)。リリース後テストの実測で 1 周回が予算を大きく超えるなら、周回の中の上限を別の課題で考える | diff --git a/issues/issue-2089-requirements.md b/issues/issue-2089-requirements.md deleted file mode 100644 index 77e5fcb79..000000000 --- a/issues/issue-2089-requirements.md +++ /dev/null @@ -1,242 +0,0 @@ -# #2089: development-workflow: 棚卸しの結果から次のスプリントを決めて自動で始め、スプリントを繰り返す開発ループのモードを持つ - -正は課題の本文(#2089)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 何をしたいか - -`/goal /ndf:development-workflow` を、スプリントの終わり(振り返り・Issue の見直し)で止めずに、見直しの結果から次のスプリントを決めて自動で始められるようにする。1 スプリントで終わる今の進め方に加えて、スプリントを繰り返す「開発ループ」を NDF のモードとして持たせる。 - -## MVV - -- **Mission**(利用者の指定 2026-10-25): `/goal /ndf:development-workflow` を完全自動でループ開発できるようにする -- **Vision**(承認ゲート 1 で利用者が選んだ書き直し 2026-10-25。元の指定「NDF に、完全に自動で開発を進める Software Factory のモードを持たせる」は作るものを書いていて目指す姿になっていないとの指摘による): 利用者が MVV と予算を決めて開発ループを始めれば、NDF が棚卸し・次のスプリントの選定・設計・実装・配布・振り返りを止まらずに周回し、ソフトウェアを育て続ける。人が手を掛けるのは MVV・受け入れ条件・検証の仕組みと、レッドラインに当たったときの判断だけになる。どの周回も、何を選び、なぜ選び、何を出し、なぜ止まったかを後から追える -- **Value**(利用者の依頼で AI が作成。要求の工程で利用者に確かめる) - 1. **承認は MVV のレッドラインだけで止まる。**(利用者の指定 2026-10-25)ループのモードは `pace: auto` を基本とし、承認ゲート 1(設計 Pull Request のマージ)と承認ゲート 2(本番への配布)も MVV の判定で通す。人の承認を求めるのは MVV のレッドラインに当たったときだけで、それ以外では承認を一切求めない。今の `auto` は判定が「従う」以外(「判定できない」「反する疑い」)なら人へ戻すため、その扱いをこのモードでどう変えるかと、`.ndf/pace.json` の `boundary_paths`(触ると人へ戻すパス)との関係は設計で決める - 2. **止まる条件を必ず持つ。** 予算(トークン・時間)・同じ失敗の連続・着手できる Issue が無い・MVV の外の Issue しか残らない、のどれかでループを止め、止まった理由と再開のコマンドを残す。外部の事例でも、止まる条件の無いループは「進んでいると思い込んだまま予算を使い切る」と警告されている - 3. **次のスプリントは棚卸しの結果だけで決める。** 選ぶ根拠は `backlog-refinement` の順位とマイルストーンの順位、MVV で、ループの中で AI が範囲を思いつきで広げない。選んだ理由をスプリントごとに記録する - 4. **1 周ごとに後から追える記録を残す。** 振り返り・棚卸し・次のスプリントの選定・配布した版を、周回ごとに人が読める形で残す - 5. **今の進め方を壊さない。** ゴールを明確に定めた 1 スプリントの開発は、今までどおり動く。ループのモードは引数で切り替える - 6. **今ある仕組みを使う。** 中継(`relay.py` と `ndf-next`)・`sprint-state.py`・`supervise.py new sprint`・棚卸しの `rank` を組み合わせ、新しい仕組みは足りない部分だけにする - 7. **外部の最新動向を取り入れる。** loop engineering(起動の条件・結果の評価・止まる条件・状態の引き継ぎを設計する)と software factory(人はコードを書かず、検証の仕組みと仕様に手を掛ける)の考え方を調べ、出典を付けて設計に反映する - 8. **コスト > スピード > 品質の順で優先する。**(利用者の指定 2026-10-25、承認ゲート 1 で追加)周回はできるだけ低コスト・高スピードで回し、品質を上げる工程(レビューのラウンド・検査・やり直し)は費用と時間の上限の中でだけ行う。上限を超えてまで行いたい品質の向上は、その場で続けず別の Issue に起こし、棚卸しで順位が付いてから後の周回で行う。上限は周回ごとの実測(費用・秒)で見直す。ただし品質を置き去りにしない。棚卸しでは基準を決めて品質向上の Issue の優先順位を調整し、品質向上が後回しのまま積み残らないようにする(利用者の指定 2026-10-25) - -## 依頼(原文) - -> 並行して次のissueを起こしてください。 -> -> * mission: /goal /ndf:development-workflow を完全自動でループ開発できるようにする -> * vision: ndfに完全に自動で開発を進める Software Factory のモードを持たせる -> * value: (AIで作成) -> -> 概要: -> * 現在の /ndf:development-workflow は、一つのsprintにおける振り返りまでしか定義されていない。 -> * 振り返りとissue見直しの完了後、見直し結果に基づいて次のsprintを設定し、自動で次のsprintを開始できるようにしたい -> * これにより、開発loopが完成し、loop engineering が実現できる -> * AIエージェントを活用した loop engineering、 software factory についての外部情報を調査し、最新動向を取り入れること -> * 従来のgoalを明確に定めた開発手法もこれまで通り動くこと (引数で切り替え可能とする) - -(書き手: 利用者、日付: 2026-10-25) - -> > 人の判断が要るのは今の承認ゲート 2 つと MVV のレッドラインだけにする。 -> pace: autoを基本とし、MVVのレッドライン以外で承認を一切必要としないモードとします。 - -(書き手: 利用者、日付: 2026-10-25、起票の後の Value 1 への指示) - -## 今の仕組みで足りているもの・足りないもの(起票時に読んだ範囲) - -| 仕組み | 今できること | ループに足りないこと | -| --- | --- | --- | -| 工程表(`development-workflow`) | 1 スプリントの工程を振り返り・棚卸しまで通す | 棚卸しの後の「次のスプリントを決めて始める」工程が無い | -| 中継(`relay.md` の「`/goal` のテンプレート」) | 本番の後に `ndf-next` で次のセッションを起動する。テンプレートを特定の Issue に縛らなければ「効果の順の残りから次のスプリントを選ぶ」と書ける | 次のスプリントの選び方・止まる条件が、テンプレートの文面(会話ごとに手で書く)にしか無い | -| `sprint-state.py` | スプリントの状態と次のセッションの `/goal` の文面を持つ | 周回の数・予算・止まった理由を持たない | -| 棚卸し(`backlog-refinement` の `rank`) | 着手の順位を決める | 順位から次のスプリントの課題の組(大きさ・並列の本数)を作る手順が無い。上限を超えて持ち越した候補が次の棚卸しへ渡らない(#1640) | - -## 外部の動向(起票時の検索。要求・設計の工程で一次情報に当たって調べ直す) - -- loop engineering は 2026 年に広まった語で、プロンプトを磨くのではなく「いつ起動するか・結果をどう評価するか・いつ追加の実行を起こすか・状態をどう記録して次へ渡すか」を設計する実践と説明されている。止まる条件とガードレールの無いループが予算を使い切る危険が繰り返し指摘されている([Loop Engineering with Agents](https://dassum.medium.com/loop-engineering-with-agents-5e9b984e8d8a)・[What is loop engineering](https://mindstudio.ai/blog/what-is-loop-engineering-autonomous-ai-agent-workflows)・[loop engineering developer guide 2026](https://noqta.tn/en/blog/loop-engineering-ai-agents-developer-guide-2026)) -- software factory は、個々のコーディングエージェントから、開発の全工程を自律で回す仕組みへ移ることを指す語として使われている。「人がコードを書かずレビューもしない」事例が報告される一方で、「自律でも統制の無いものではない」と人の関与を残す主張もある([the agentic software factory(BCG Platinion)](https://www.bcgplatinion.com/insights/the-agentic-software-factory)・[The Autonomous Software Factory](https://nickjmilani.substack.com/p/the-autonomous-software-factory)・[Building your own software factory(AI Engineer)](https://ai.engineer/talks/building-your-own-software-factory)) -- 出典の多くは事業者のブログや解説記事で、成果の数字は自己申告である。設計の根拠にするときは一次情報(Anthropic・OpenAI の公式ドキュメント、発表の原典)を確かめる - -## 関連 - -- #996 — `/goal` に頼らない自走(閉じた。3 層の切り替えと止まりの防止) -- #1640 — 棚卸しで上限を超えて持ち越した候補が次の棚卸しへ渡らない -- `plugins/ndf/skills/development-workflow/references/relay.md` の「`/goal` のテンプレート」・`pace.md`(`auto` / `fast` の MVV 判定) - -## 起票者の推定 - -題・本文のうち、利用者の指示の原文(「依頼(原文)」の 2 件)に無く、起票者(AI)が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | Value 2: 止まる条件(予算・同じ失敗の連続・着手できる Issue が無い・MVV の外の Issue しか残らない)を必ず持ち、止まった理由と再開のコマンドを残す | 起票者が外部の解説記事から足した | 受け入れ条件 7〜13。予算の単位と既定値は未決 2 | -| S2 | Value 3: 次のスプリントは棚卸しの順位・マイルストーンの順位・MVV だけで選び、選んだ理由を残す | 起票者 | 受け入れ条件 4〜6 | -| S3 | Value 4: 周回ごとに振り返り・棚卸し・選定・配布した版を人が読める形で残す | 起票者 | 受け入れ条件 14・15。置き場は未決 3 | -| S4 | Value 5: 1 スプリントの今の進め方を壊さず、ループは引数で切り替える | 利用者の原文「引数で切り替え可能とする」を起票者が言い換えた | 受け入れ条件 1・18 | -| S5 | Value 6: 中継・`sprint-state.py`・`supervise.py new sprint`・`rank` を組み合わせ、新しい仕組みは足りない部分だけ | 起票者(プロジェクト MVV の Value 4・5 と、利用者のメモリ「基盤は既存ライブラリを使う」) | 受け入れ条件 19 | -| S6 | Value 7: loop engineering と software factory を一次情報で調べ、出典を付けて設計に反映する | 利用者の原文「外部情報を調査し、最新動向を取り入れること」を起票者が具体化した | 受け入れ条件 20 | -| S7 | Value 1 の後半: 今の `auto` の「判定できない」「反する疑い」の扱いと `boundary_paths` の関係を設計で決める | 起票者 | 受け入れ条件 2・3、未決 1 | - -**要求の工程で Value 2〜7 から変えた点**(conductor の指示「変えた点は本文に理由を書く」による): - -- Value 2 の予算の単位「トークン・時間」を「スプリント状態ファイルが既に持つ量(費用・秒)と周回の数」に置き換えた。今の仕組みはトークン数を周回の単位で集めておらず、費用(USD)と秒は `sprint-state.py update` が done から埋めている(プロジェクト MVV の Value 3「測って決める」・Value 4)。トークン数を足すかは未決 2 で決める -- Value 1 の「判定できない・反する疑い」の扱いは、「ループを止めて承認を待つ」ではなく「その課題を人の判断待ちとして外し、ほかの課題で続ける」を推奨にした(未決 1)。理由: NDF の共通原則は「決められないときも人へ戻す」を求め、「従う」と見なして進めることはできない。一方で利用者の指定は「レッドライン以外で承認を一切求めない」で、ループを止めて待つと指定に反する。課題を外して記録に載せる形は両方を満たす -- Value 6 に #1640(持ち越しが次の棚卸しへ渡らない)との関係を足した。ループの中では前の周回の持ち越しを次の周回の棚卸しへ渡す(受け入れ条件 16)。マシンをまたいで残る置き場は #1640 が持つ - -## 目的 - -- 利用者が 1 回ループを始めれば、NDF がスプリントを「選ぶ → 設計 → 実装 → 検査 → 配布 → 振り返り → 棚卸し → 次を選ぶ」と繰り返し、人が手を止めるのはレッドラインに当たったときと、ループが止まる条件に当たったときだけになる状態にする -- 1 スプリントの今の進め方(ゴールを決めた `/goal /ndf:development-workflow #<課題>`)は、引数を変えなければ今と同じに動く - -## 前提 - -- 前提 1: ループを始めるのは利用者の操作であり、ループの開始をもって承認ゲート 1・2 を MVV 判定に任せる事前の委任とする(プロジェクト MVV の Vision「利用者が進め方ごとに事前に選ぶ」)。委任が有効なのは、`.ndf/pace.json` がループを許し、プロジェクト MVV が承認済みでハッシュが一致するときだけである -- 前提 2: ループの各周回は承認済みのプロジェクト MVV で判定する。周回ごとにスプリント MVV の承認を人へ求めない(マイルストーンに承認済みのスプリント MVV があればそれを使い、未承認のスプリント MVV しか無いマイルストーンの課題は選ばない) -- 前提 3: レッドラインは今の定義(NDF の共通原則 C1〜C8・プロジェクト MVV の P1・スプリント MVV の R・`.ndf/pace.json` の `boundary_paths`・`mvv-gate.py` の機械のチェック)をそのまま使い、ループのために緩めない -- 前提 4: ループは Claude Code のラッパー(`relay.py`)の下で動くことを主な形とする。ラッパーの外では、カットポイントで `ndf-next` を出して止まり、利用者が貼り直せば同じ周回から続く(今の中継と同じ) -- 前提 5: #2086(document-restructuring → document-rebuild)が触るファイル(`plugins/ndf/skills/document-restructuring/`・`markdown-writing/`・文書系の Skill)はこの課題の対象に入れない。`development-workflow` の共通の文書は、節を足すか新しい `references/` の文書を作る形で触る -- 前提 6: この実行では WebSearch・WebFetch が許可されておらず、外部の動向は要求の工程で一次情報を確かめていない。確かめるのは設計の工程である(受け入れ条件 20) - -## 対象範囲 - -含む: -- `development-workflow` にループのモード(引数で切り替える)を足す -- 棚卸しの結果から次のスプリントの課題の組を機械で選ぶ手順と、選んだ理由の記録 -- 止まる条件の機械の判定・止まった理由と再開のコマンドの記録 -- 周回ごとの記録(振り返り・棚卸し・選定・配布した版・MVV 判定の結果・費用と秒) -- ループの中の承認ゲート 1・2 の扱い(MVV 判定とレッドライン)と、「判定できない・反する疑い」の扱い -- ループの中で前の周回の持ち越しを次の周回の棚卸しへ渡すこと -- `.ndf/pace.json` にループを許す設定を足すこと(設定の形と既定値) -- loop engineering と software factory の一次情報の調査と、取り入れる・取り入れない考え方の記録(設計文書) - -含まない: -- 今の 1 スプリントの進め方(`normal` / `auto` / `fast`)の振る舞いを変えること -- `mvv-gate.py` の判定の規則・レッドラインの定義・`boundary_paths` の既定を緩めること -- 持ち越しをマシンをまたいで残す置き場(#1640 が持つ) -- #2086 が触る Skill(前提 5) -- Codex・Kiro・agy でラッパーと同じ自動の切り替えを作ること(ラッパーは Claude Code だけ。前提 4) -- このリポジトリの `.ndf/pace.json` でループを有効にすること(共通原則の C7。利用者が別に決める) - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 利用者がループを始めた | 利用者がループの引数を付けて `development-workflow` を起動した | ループを許す設定が無い・プロジェクト MVV が未承認か不一致なら、ループを始めずに理由と 1 スプリントの起動の形を示す | — | -| E2 | 周回を始めた | E1、または E11 の後に止まる条件に当たらなかった | 状態を書けなければ止まる(止まった理由を残す) | E1 | -| E3 | 次のスプリントの課題の組を選んだ | E2 | 選べる課題が無ければ止まる条件「着手できる課題が無い」へ | E2・前の周回の E10 | -| E4 | 課題を MVV の外・人の判断待ちとして外した | E3 の候補の MVV 判定・レッドライン | 全候補が外れたら止まる条件「MVV の外しか残らない」へ | E3 | -| E5 | スプリントを `pace: auto` で組んだ | E3 | `supervise.py new sprint` が断れば止まる(理由を残す) | E3 | -| E6 | 承認ゲート 1 を MVV 判定で通した、またはレッドラインで人へ戻した | 設計 PR の MVV 判定 | 人へ戻した課題はその周回から外し、残りで続ける(未決 1) | E5 | -| E7 | 実装・検査・開発版の配布を終えた | E6 | 同じ失敗が続けば止まる条件へ | E6 | -| E8 | 承認ゲート 2 を MVV 判定で通した、またはレッドラインで人へ戻した | 本番の MVV 判定 | 人へ戻したら、その周回の本番は出さずに止まる(未決 1) | E7 | -| E9 | 本番へ配布した | E8 | 配布の失敗は今の `release` の扱い | E8 | -| E10 | 振り返りと棚卸しを終えた | E9 | 失敗は今の `new close` の扱い。棚卸しの持ち越しを周回の記録へ書く | E9 | -| E11 | 周回の記録を書き、止まる条件を判定した | E10 | 記録を書けなければ止まる | E10 | -| E12 | ループが止まった | 止まる条件のどれか・利用者の停止 | — | どの時点でも | -| E13 | 利用者がループを再開した | E12 で残した再開のコマンド | 止まった理由が解けていなければ同じ理由で止まる | E12 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 開発ループ | スプリントを 1 つ終えるたびに、棚卸しの結果から次のスプリントを選んで続けて始める進め方。引数で切り替え、止まる条件に当たるまで繰り返す | -| 周回 | 開発ループの 1 回分。次のスプリントの選定から、そのスプリントの棚卸しと止まる条件の判定まで | -| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数え、超えたら止まる | -| 止まる条件 | 開発ループを止める、機械で判定できる条件。予算を超えた・同じ失敗が続いた・着手できる課題が無い・MVV の外の課題しか残らない・利用者が止めた | - -## 受け入れ条件 - -起動と切り替え: - -- [ ] 1. ループの引数を付けない `development-workflow` の起動(`#<課題>` やマイルストーンを指す今の形)は、ループの記録を作らず、棚卸しの後に次のスプリントを始めない(今の振る舞いのまま) -- [ ] 2. ループの引数を付けた起動は、`.ndf/pace.json` がループを許していない・プロジェクト MVV が未承認か承認と一致しない・ループの予算の設定が読めない、のどれかで周回を始めず、断った理由と 1 スプリントの起動の形を返す(機械で判定する) -- [ ] 3. ループの周回のスプリントは `pace: auto` で組まれ、承認ゲート 1・2 は `mvv-gate.py check` の判定だけで通る。レッドライン(機械のチェック 1〜5 と判定の `boundary`)に当たったときだけ利用者の承認を求め、それ以外の判定で `AskUserQuestion` も本文の承認待ちも出さない - -次のスプリントの選定: - -- [ ] 4. 次のスプリントの課題の組は、直前の棚卸しの `rank` の結果(マイルストーンの順位・切り出しの境界・容量)と承認済みの MVV だけから機械で決まる。同じ入力(`rank` の結果・MVV・前の周回の記録)からは同じ組が選ばれる -- [ ] 5. 選定は、課題ごとに「選んだ・外した」と理由(順位・容量・MVV の外・人の判断待ち・レッドラインに当たる見込み)を周回の記録へ書く -- [ ] 6. ループの中で AI が新しく起票した課題は、その周回の組へ入らず、次の周回の棚卸しで順位が付いてから選定の対象になる - -止まる条件: - -- [ ] 7. 周回の数・費用・経過時間のどれかがループの予算を超えたら、次の周回を始めずに止まる。判定は周回の境で行い、周回の途中では止めない(止める点はカットポイントと同じ) -- [ ] 8. 同じ失敗(同じ課題の同じステップの失敗)が宣言の回数続いたら止まる -- [ ] 9. 選定で選べる課題が 0 件なら「着手できる課題が無い」で止まる -- [ ] 10. 候補がすべて MVV の外・人の判断待ちとして外れたら「MVV の外しか残らない」で止まる -- [ ] 11. 利用者は 1 つのコマンドかファイルでループを止められ、止める指示は次の周回の境で効く -- [ ] 12. 止まったときは、止まった条件・判定に使った値・再開のコマンド(`ndf-next` の形)を周回の記録と引継ぎ文書に残し、レッドラインと止まる条件以外では利用者へ問わない -- [ ] 13. 再開のコマンドで始めたループは、止まった周回の次から続き、周回の数と予算の消費を引き継ぐ - -記録: - -- [ ] 14. 周回ごとに、選定(課題と理由)・承認ゲート 1・2 の判定(MVV 判定の結果と根拠の項目)・配布した版・振り返りの置き場・棚卸しの結果(順位の変化・持ち越し)・費用と秒・止まる条件の判定が、人が読める 1 つの記録に残る -- [ ] 15. ループの記録から、周回の一覧(何周目・選んだ課題・版・費用・止まった理由)を 1 つのコマンドで出せる -- [ ] 16. 前の周回の棚卸しの持ち越し(`metrics.deferred`)は、次の周回の棚卸しへ渡る(手で `--add` しない) - -人へ戻した課題: - -- [ ] 17. レッドラインか「判定できない・反する疑い」で人へ戻した課題は、承認を待つ間ループを止めず、周回の記録と課題の本文に「人の判断待ち」と理由が残る。人が答えた後の周回でだけ再び選定の対象になる(未決 1 の推奨の形。利用者が別の案を選べば、この条件を書き換えて事実を残す) - -退行しないこと・作り方: - -- [ ] 18. 今の 1 スプリントの進め方(`normal` / `auto` / `fast`)で、`supervise.py new sprint`・`new close`・`sprint-state.py`・`relay.py` の出力と終了コードが変わらない(既存のテストが通る) -- [ ] 19. ループの状態(周回の数・予算の消費・止まった理由・選定の理由)は、既存の `sprint-state.py`・引継ぎ文書・中継(`relay.py` と `ndf-next`)を広げて持つ。新しい基盤(キュー・常駐の処理)を作らない。新しいスクリプトを足すときは、既存のものでは足りない理由を設計文書に書く -- [ ] 20. 設計文書に、loop engineering と software factory の一次情報(Anthropic・OpenAI の公式ドキュメントかエンジニアリングブログ、発表の原典)の出典 URL と、取り入れる考え方・取り入れない考え方を理由付きで書く。起票時に挙げた解説記事だけを根拠にしない -- [ ] 21. 品質を上げる工程(クロスレビュー・クロスリファクタリング・検査)が上限(ラウンド・時間・費用)に達して残した指摘は、その周回で続けずに Issue として起票され、周回の記録に件数と Issue 番号が残る(Value 8)。起票した Issue は受け入れ条件 6 のとおり次の周回の棚卸しを経てから選定の対象になる -- [ ] 22. 棚卸しの順位付けは、宣言した基準で品質向上の Issue の優先順位を調整する(基準の形は設計の決定 14)。同じ入力からは同じ順位になり、調整の有無と理由が順位の表か周回の記録に残る。基準を宣言していないリポジトリでは、順位は今と変わらない(Value 8) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 可用性 | ラッパーの 1 日の起動回数の上限(`NDF_RELAY_MAX_STARTS`、既定 20)に当たったら、ループの止まる条件と同じ形で理由と再開のコマンドを残す | -| 運用・保守性 | 止まる条件の判定は LLM を呼ばずにスクリプトが行う(プロジェクト MVV の Value 4)。閾値はリポジトリの設定で受け、スクリプトに埋め込まない(Value 5) | -| セキュリティ | レッドラインと `boundary_paths` はループの中でも今と同じに効く。ループのために緩める設定を作らない(共通原則の C1〜C8・P1) | -| 移行性 | ループの設定が無いリポジトリでは、何も変わらない | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わる(足すだけ)。`development-workflow` の引数・`.ndf/pace.json` の項目・スクリプトの副命令を足す。既存の引数・項目・副命令の意味は変えない | -| データ | スプリント状態ファイルか引継ぎ文書に周回の項目を足す。既存の項目は変えず、古い形のファイルも読める | -| 既存の振る舞い | 変わらない(受け入れ条件 1・18) | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf -q -n 4`(止まる条件・選定・記録・断る条件は、gh と claude を差し替えた単体テストで確かめる) | -| 静的解析 | `python3 scripts/check-skill-frontmatter.py`・`python3 plugins/ndf/scripts/instructions-check.py --root .`・`claude plugin validate .` | -| 手動確認 | リリース後テストで、検証用のリポジトリでループを 2 周以上回し、周回の記録・止まる条件(着手できる課題が無い)・再開のコマンドを確かめる | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | `AGENTS.md`。安定版の経路(既定で働くもの)で入れる。既定の振る舞いから実験版(`plugins/ndf/scripts/experimental/`)を参照しない | -| コーディング規約 | `plugins/ndf/skills/AUTHORING.md`・`.md` の文言を照合するテストは書かない(`AGENTS.md`) | -| テスト戦略 | `tdd-cycle` の testing-levels。止まる条件と選定は入力(`rank` の結果・状態ファイル)から出力を決める単体テストで担保する | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行・レッドラインの判定を今のまま通す・止まる条件の判定を周回の境ごとに行う | -| 確認してから行う | `.ndf/pace.json` の項目の追加・スプリント状態ファイルの形の追加(承認ゲート 1・2 で人が見る) | -| 行わない | `mvv-gate.py` の判定とレッドラインを緩める・#2086 が触る Skill の変更・このリポジトリでループを有効にする設定の変更 | - -## 未決 - -利用者が任せた設計の選択である。設計文書の「決定の記録」に案と推奨を書き、承認ゲート 1 で利用者が選ぶ。 - -| 項目 | 案と推奨 | 誰が決めるか | 期限 | -| --- | --- | --- | --- | -| 1. 「判定できない」「反する疑い」と、レッドラインで人へ戻したときのループの扱い | (A) その課題を人の判断待ちとして外し、残りで続ける(承認ゲート 2 では、その周回の本番を出さずに止まる)【推奨】/ (B) ループを止めて承認を待つ / (C) 「判定できない」を「従う」と見なす(共通原則の「決められないときも人へ戻す」に反するため採らない案として示す) | 利用者 | 承認ゲート 1 | -| 2. ループの予算の単位と既定値 | 周回の数・費用(USD)・経過時間の 3 つで、どれかを超えたら止まる。既定値は設計の工程で過去のスプリントの実測(費用・秒)から案を出す。トークン数を足すか | 利用者 | 承認ゲート 1 | -| 3. 周回の記録の置き場 | スプリント状態ファイルを周回ごとに持ち、ループの記録はそれらを束ねる 1 つのファイル+引継ぎ文書の節 / Issue か Pull Request のコメント / `docs/` の文書。推奨は設計の工程で既存の置き場と比べて決める | 利用者 | 承認ゲート 1 | -| 4. ループの引数の形と、ループを許す設定の形 | `development-workflow --loop [マイルストーン]` の形と、`.ndf/pace.json` の `loop` 節(`enabled`・予算・同じ失敗の回数)。推奨は設計の工程で決める | 利用者 | 承認ゲート 1 | -| 5. 同じ失敗の「同じ」の判定と回数 | 同じ課題の同じステップの失敗が 2 回続く、を出発点に設計で決める | 利用者 | 承認ゲート 1 | - -外部の動向で設計の工程が確かめる一次情報の候補(この工程では未確認): Anthropic「Effective harnesses for long-running agents」(https://www.anthropic.com/engineering/effective-harnesses-for-long-running-agents )・Anthropic「Building effective agents」(https://www.anthropic.com/engineering/building-effective-agents )・OpenAI「Harness engineering」(https://openai.com/index/harness-engineering/ )・Claude Code の `/goal` と hook の公式ドキュメント(https://code.claude.com/docs/ )。 diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 6fc27e846..340442aaf 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.21.0", - "description": "Claude Code plugin (v10.21.0): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 48 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.22.0", + "description": "Claude Code plugin (v10.22.0): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 48 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 a54e48e20..861baa401 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.21.0", - "description": "Codex plugin (v10.21.0): 44 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.22.0", + "description": "Codex plugin (v10.22.0): 44 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 311359ccd..768a62d98 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -89,7 +89,7 @@ bash plugins/ndf/dev.kiro/install.sh --dry-run ```bash python3 -c "import json;print(json.load(open('.kiro/agents/ndf.json'))['description'])" -# => NDF統合開発エージェント(Kiro CLI用 / v10.21.0) +# => NDF統合開発エージェント(Kiro CLI用 / v10.22.0) ``` ### agy @@ -412,7 +412,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.21.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.22.0/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -434,14 +434,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.21.0/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.22.0/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.21.0 +# => ndf@ai-plugins installed, enabled 10.22.0 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/dev.agy/plugin.json b/plugins/ndf/dev.agy/plugin.json index 58edd4e68..6e4a21bfe 100644 --- a/plugins/ndf/dev.agy/plugin.json +++ b/plugins/ndf/dev.agy/plugin.json @@ -1,5 +1,5 @@ { "name": "ndf", - "version": "10.21.0", - "description": "Antigravity CLI plugin (v10.21.0): 44 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." + "version": "10.22.0", + "description": "Antigravity CLI plugin (v10.22.0): 44 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation, and worktree guidance hooks." } diff --git a/plugins/ndf/scripts/lib/approved_commit.py b/plugins/ndf/scripts/lib/approved_commit.py index 4f78cc3f9..02e3feeba 100644 --- a/plugins/ndf/scripts/lib/approved_commit.py +++ b/plugins/ndf/scripts/lib/approved_commit.py @@ -2,10 +2,11 @@ - 承認資料(`release-steps.py approval-facts` が書く Markdown)の欄の名前・SHA の形の検査・読み取り・承認の記録の書き込み - 承認したコミットから比べた先端までに、許したもの(配布の PR)の外のコミットや中身が無いかの比較(`compare_approved`) +- 導入の確認の承認資料(`release-steps.py verify-facts` が書く)の、確かめたコミットの行の形と読み取り + (`verified_line` / `verified_sha`。昇格の経路 `merged-steps.py promote --expect-head-from` が読む。#1529) `compare_approved` は git を読むだけで書かず、終了コードも出力も持たずに判定(`Verdict`)を値で返す。読み替えは呼び手が行う。 -ブランチ名は受け取らず SHA だけを受け取る。`allowed` を空にすると、先端の木が承認したコミットの木と等しいかの判定になる -(昇格の経路 `merged-steps.py promote` から使う形)。 +ブランチ名は受け取らず SHA だけを受け取る。`allowed` を空にすると、先端の木が承認したコミットの木と等しいかの判定になる。 """ from __future__ import annotations @@ -153,6 +154,32 @@ def material_row(sha: str) -> tuple[str, str]: return (ROW, parse_sha(sha)) +# --- 確かめたコミット(導入の確認の承認資料。#1529) ------------------------------ + +_VERIFIED_HEAD = "- ベースブランチ " +_VERIFIED_MID = " の先頭: `" +_VERIFIED_TAIL = "`。導入の確認はこのコミットで走らせた" +_VERIFIED_RE = re.compile("^" + re.escape(_VERIFIED_HEAD) + r"\S+" + re.escape(_VERIFIED_MID) + "([^`]*)" + re.escape(_VERIFIED_TAIL) + "$") + + +def verified_line(base: str, sha: str) -> str: + """導入の確認の承認資料(`release-steps.py verify-facts`)の、確かめたコミットの行。""" + return f"{_VERIFIED_HEAD}{base}{_VERIFIED_MID}{sha}{_VERIFIED_TAIL}" + + +def verified_sha(path) -> str: + """導入の確認の承認資料から確かめたコミットを読む(`merged-steps.py promote --expect-head-from`)。 + 資料が無い・行が無い・行が 2 つ以上・40 桁の 16 進でなければ ValueError(照合を飛ばさない)。""" + p = Path(path) + if not p.is_file(): + raise ValueError(f"導入の確認の承認資料 {p} が無い") + found = [m.group(1) for m in (_VERIFIED_RE.match(line) for line in p.read_text(encoding="utf-8").split("\n")) if m] + if len(found) != 1: + what = "無い" if not found else f"{len(found)} 行ある" + raise ValueError(f"導入の確認の承認資料 {p} に確かめたコミットの行が{what}") + return parse_sha(found[0]) + + # --- 比較 --------------------------------------------------------------------- @@ -195,6 +222,28 @@ def _expected_tree(root, approved, allowed) -> str | None: return out.split()[0] if out else None +def _outside_of_prs(root, approved, allowed) -> list[str] | None: + """approved..配布の PR の先端のうち、配布の PR のコミットでないもの(I3)。読めなければ None。""" + outside: list[str] = [] + for pr in allowed: + listed = _rev_list(root, f"{approved}..{pr.head}") + if listed is None: + return None + outside += [c for c in listed if c not in pr.commits and c not in outside] + return outside + + +def _outside_to_tip(root, approved, tip, allowed) -> list[str] | None: + """approved..tip の(マージでない)コミットのうち、配布の PR の先端から届かず、配布の PR の mergeCommit でも + 同じ patch-id でもないもの(I9)。読めなければ None。""" + listed = _rev_list(root, "--no-merges", f"{approved}..{tip}", *(f"^{pr.head}" for pr in allowed)) + if listed is None: + return None + merges = {pr.merge_commit for pr in allowed if pr.merge_commit} + patches = {pid for pr in allowed for c in pr.commits if (pid := _patch_id(root, c))} + return [c for c in listed if c not in merges and _patch_id(root, c) not in patches] + + def compare_approved(root, approved: str, tip: str, allowed: list[AllowedPR] = ()) -> Verdict: """承認したコミット approved から比べた先端 tip までを比べる(#815 の設計の「compare の中の順序」)。 @@ -215,19 +264,13 @@ def compare_approved(root, approved: str, tip: str, allowed: list[AllowedPR] = ( if not _ok(root, "merge-base", "--is-ancestor", approved, tip): v.reason = NOT_ANCESTOR return v - outside: list[str] = [] - for pr in allowed: - listed = _rev_list(root, f"{approved}..{pr.head}") - if listed is None: - return v - outside += [c for c in listed if c not in pr.commits and c not in outside] + outside = _outside_of_prs(root, approved, allowed) + if outside is None: + return v if not outside: - listed = _rev_list(root, "--no-merges", f"{approved}..{tip}", *(f"^{pr.head}" for pr in allowed)) - if listed is None: + outside = _outside_to_tip(root, approved, tip, allowed) + if outside is None: return v - merges = {pr.merge_commit for pr in allowed if pr.merge_commit} - patches = {pid for pr in allowed for c in pr.commits if (pid := _patch_id(root, c))} - outside = [c for c in listed if c not in merges and _patch_id(root, c) not in patches] if outside: v.reason = OUTSIDE_COMMITS v.outside = [Outside(c, _subject(root, c)) for c in outside] diff --git a/plugins/ndf/scripts/lib/test_strategy.py b/plugins/ndf/scripts/lib/test_strategy.py index fe8fd9c8a..165e384bb 100644 --- a/plugins/ndf/scripts/lib/test_strategy.py +++ b/plugins/ndf/scripts/lib/test_strategy.py @@ -343,6 +343,129 @@ def scope_runs(strategy: Strategy, targets: list[str], changed: list[str]) -> li return runs +# ---------- 範囲テストのテンプレートの試走(#1630) ---------- + +# 判定(`template_verdict`)。`ok` だけが続ける(#1630 の I2)。 +PROBE_OK = "ok" +PROBE_DEFECT = "defect" +PROBE_UNDECIDED = "undecided" +# 候補を選んだ場所(`probe_paths`)。上から順に、最初に 1 本以上見つかった場所を使う(#1630 の I3)。 +PROBE_FROM_SCOPE = "scope" +PROBE_FROM_REPO = "repo" +PROBE_FROM_PATHS = "paths" +# 試走の基準(着手前のテストの成否。`template_verdict` が読む)。 +REFERENCE_GREEN = "green" +REFERENCE_RED = "red" +REFERENCE_NONE = "none" +# 試走に入れるテストのファイルの最大の本数。1 本は受けて 2 本目を受けない雛形(`discover -s a b`)を見分ける。 +PROBE_MAX_FILES = 2 + + +@dataclass +class ProbePick: + """試走に入れるパス(0〜2 本)と、選んだ場所、その suite が受け持つと言い切れるか(`owned`)。""" + + paths: list[str] + source: Optional[str] + owned: bool + + +@dataclass +class ProbeVerdict: + """試走の判定。`status` は `ok` / `defect` / `undecided`。`ok` 以外は `init` を止める。""" + + status: str + reason: str + + @property + def stops(self) -> bool: + return self.status in (PROBE_DEFECT, PROBE_UNDECIDED) + + +def _root_of(p) -> str: + """suite の `paths` の要素の前後の空白と末尾の `/` を外した形。""" + return str(p).strip().rstrip("/") + + +def _is_whole_root(r: str) -> bool: + """`_root_of` の形がリポジトリ全体(`.` か空)を指すか。""" + return r in (".", "") + + +def _declares_whole(suite: Suite) -> bool: + """suite の `paths` が `.` か空(`suite_for` が全てのファイルを当てる)か。""" + return not suite.paths or any(_is_whole_root(_root_of(p)) for p in suite.paths) + + +def probe_roots(suite: Suite) -> list[str]: + """suite の `paths` のうち、配下の追跡ファイルを候補にしてよい要素(`.` と glob を除く)。""" + roots = [_root_of(p) for p in suite.paths] + return [r for r in roots if not _is_whole_root(r) and not any(c in r for c in _GLOB_CHARS)] + + +def probe_paths( + strategy: Strategy, + suite: Suite, + scope_files: list[str], + repo_files: list[str], + paths_files: list[str], + failing_files: list[str], +) -> ProbePick: + """試走に入れるテストのファイルを選ぶ(#1630 の I3)。プロセスもファイルも触らない。 + + `scope_files` は `--scope` のテストの置き場所の配下のテストのファイルで、`suite_groups` と同じ規則(`suite_for`、 + 当たらなければ最初のテストの suite)でこの suite に当たるもの。`repo_files` はリポジトリの追跡ファイルのうち + テストの名前の形のもので、`suite_for` がこの suite を返したものだけ(最初の suite へ寄せない)。`paths_files` は + suite の `paths`(`probe_roots`)の配下の追跡ファイル(名前の形を問わない)。どれも `failing_files` + (既存失敗のファイル)を除き、並べ替えた先頭から `PROBE_MAX_FILES` 本を取る。 + + `owned` が偽なのは、`paths` の候補(テストでないファイルが入りうる)と、`paths` が `.` か空の suite の + `repo` の候補(宣言していない置き場所のテストが入りうる)である。""" + failing = {target_path(f) for f in failing_files} + scoped = strategy.scoped_suites(TEST) + first = scoped[0] if scoped else None + + def keep(files: list[str], pick: Any) -> list[str]: + out = sorted({target_path(f) for f in files if target_path(f)} - failing) + return [f for f in out if pick(f)][:PROBE_MAX_FILES] + + own = keep(scope_files, lambda f: (suite_for(strategy, f, TEST) or first) is suite) + if own: + return ProbePick(own, PROBE_FROM_SCOPE, True) + own = keep(repo_files, lambda f: suite_for(strategy, f, TEST) is suite) + if own: + return ProbePick(own, PROBE_FROM_REPO, not _declares_whole(suite)) + own = keep(paths_files, lambda f: True) + if own: + return ProbePick(own, PROBE_FROM_PATHS, False) + return ProbePick([], None, False) + + +def template_verdict( + result: Optional[Outcome], reference: str, failures_known: bool, owned: bool, not_run: str = "候補のファイルが無い" +) -> ProbeVerdict: + """試走の判定(#1630 の判定の表。上の行から当てる)。`result` が `None` なら走らせていない(理由は `not_run`)。 + + `reference` はその suite の着手前のテストの成否(`green` / `red` / `none`)、`failures_known` は既存失敗を読めて + 試走のファイルから除けたか、`owned` は入れたファイルをその suite が受け持つと言い切れるか(`ProbePick.owned`)。""" + if result is None: + return ProbeVerdict(PROBE_UNDECIDED, f"{not_run}ため試走していない") + if result.status == TIMED_OUT: + return ProbeVerdict(PROBE_UNDECIDED, "試走が上限で打ち切られた") + if result.status == PASSED: + return ProbeVerdict(PROBE_OK, "試走が通った") + if result.status != FAILED: + return ProbeVerdict(PROBE_UNDECIDED, f"試走を起動できなかった({result.reason})") + code = f"終了コード {result.code}" + if not owned: + return ProbeVerdict(PROBE_UNDECIDED, f"試走が{code}で落ちたが、入れたファイルをこの suite が受け持つと言い切れない") + if reference == REFERENCE_GREEN: + return ProbeVerdict(PROBE_DEFECT, f"着手前のテストは通り、試走が{code}で落ちた") + if failures_known: + return ProbeVerdict(PROBE_DEFECT, f"既存失敗のファイルを除いた試走が{code}で落ちた") + return ProbeVerdict(PROBE_UNDECIDED, f"着手前のテストが通らず既存失敗も読めないため、試走の{code}の原因を決められない") + + # ---------- 宣言の読み取り ---------- @@ -601,11 +724,17 @@ def verify_commands(decl: dict[str, Any]) -> Optional[list[str]]: """cross-review の最終スイープに名指しする全体テスト(両方の種別。I13)。宣言の `test` が無い・不明なら `None`。 `test` があって解けない(不正な `kind` など)ときは `StrategyError` をそのまま上げる。宣言が無いときと - 分けないと、不正な宣言が最終スイープの既定の探し方へ黙って流れる。 + 分けないと、不正な宣言が最終スイープの既定の探し方へ黙って流れる。種別・雛形の誤りを先に当て、誤りが無く + `strategy` を書かず、どの suite も `command` と `scope_command` を持たない宣言(テストの無い宣言。 + `{"suites": []}` を含む)は名指しするコマンドが無いとして `None` を返す(#1593)。 """ test, _ = _test_of(decl) if test is None: return None + suites = _suites_of(test) + _check_templates(suites) + if not test.get("strategy") and not any(s.command or s.scope_command for s in suites): + return None return resolve(decl).whole_commands() or None diff --git a/plugins/ndf/scripts/merged-steps.py b/plugins/ndf/scripts/merged-steps.py index 6a4005efc..4ca87bdb6 100755 --- a/plugins/ndf/scripts/merged-steps.py +++ b/plugins/ndf/scripts/merged-steps.py @@ -6,7 +6,7 @@ python3 merged-steps.py merge-gate (--base <宛先> | --pr ) [--pr ] [--root ] python3 merged-steps.py merge-when-green [--gate-approved user|mvv] [--method merge|squash|rebase] [--interval 秒] [--timeout 秒] [--stale-after 秒] [--no-cleanup] [--expect-head ] [--root ] - python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--gate-approved user|mvv] + python3 merged-steps.py promote --head <ベースブランチ> --base <本番チャネル> [--prepare] [--gate-approved user|mvv] [--expect-head-from <資料>] cleanup: マージ済みの PR の作業ツリーとローカルブランチを外し、主ディレクトリを取り込む。 `git worktree remove` が拒否した作業ツリーは、未追跡・無視されたファイルを退避してから外す。作り直せる生成物 @@ -19,12 +19,11 @@ 最初の読みで宛先(baseRefName)を判定し、自動反映の本番チャネルか判定できない宛先なら、--gate-approved が無い限り CI を待たずに承認ゲート 2 で止まる(status: gate・metrics.gate: production-merge。判定は lib/delivery.py。#1336)。 merge-gate: 宛先の判定だけを行う(0 = 進めてよい / 10 = 承認ゲート 2)。 -promote: 昇格の Pull Request(ベースブランチ → 本番チャネル)を探すか作り、merge-when-green と同じ判定と待ちで -マージする(後片付けはしない)。--prepare は用意して承認資料を書くところで 0 で終える。 -実行が終わったのにチェックが pending のまま --stale-after 秒続けば、そのジョブを 1 度だけ -`gh run rerun --job` で再実行し、再実行でも取り残されれば止まる。実行が終わりジョブに結論があれば、チェックの表示が -pending のままでも待たずにその結論で扱う。ジョブがランナーを待つ間は、待ち行列の件数を待ちの 1 周ごとに stderr へ 1 行出す。 -同じチェック(workflow の名前と名前の組)は最新の項目だけで数え、古い項目の失敗は items に result: superseded で残す。 +promote: 昇格の Pull Request(ベースブランチ → 本番チャネル)を探すか作り、merge-when-green と同じ判定と待ちでマージする(後片付けはしない)。 +--prepare は用意して承認資料を書くところで 0 で終える。--expect-head-from は導入の確認の承認資料の確かめたコミットを --expect-head と同じ照合に使う(違えば head_moved で 1、読めなければ 2。#1529)。 +実行が終わったのにチェックが pending のまま --stale-after 秒続けば、そのジョブを 1 度だけ `gh run rerun --job` で再実行し、 +再実行でも取り残されれば止まる。実行が終わりジョブに結論があれば、チェックの表示が pending のままでも待たずにその結論で扱う。 +ジョブがランナーを待つ間は、待ち行列の件数を待ちの 1 周ごとに stderr へ 1 行出す。同じチェック(workflow の名前と名前の組)は最新の項目だけで数え、古い項目の失敗は items に result: superseded で残す。 Runner が付かずに取り消されたジョブ(ステップ 0 件・Runner の名前が空)は CI の基盤待ちとし、実行が終わって --infra-gap 秒の後に `gh run rerun --failed` で打ち直して待ち直す(--infra-reruns 回まで)。使い切るか待ちの上限に 達すると終了コード 75 で止まる(items[].result: infra_wait)。中身の失敗が同時にあれば中身の失敗で止まる。 @@ -479,6 +478,7 @@ def build_parser(): pm.add_argument("--base", required=True, help="本番チャネル") pm.add_argument("--prepare", action="store_true", help="Pull Request を用意して承認資料を書くところで終える(マージしない)") pm.add_argument("--out", help="--prepare の承認資料の置き場(既定は提示物の置き場)") + pm.add_argument("--expect-head-from", metavar="資料", help="導入の確認の承認資料。確かめたコミットが PR の先端のときだけマージする") merge.add_wait_args(pm) pm.set_defaults(func=cmd_promote) pr = sub.add_parser( diff --git a/plugins/ndf/scripts/merged_lib/merge.py b/plugins/ndf/scripts/merged_lib/merge.py index 4f53d7c86..a5d9538fd 100644 --- a/plugins/ndf/scripts/merged_lib/merge.py +++ b/plugins/ndf/scripts/merged_lib/merge.py @@ -10,11 +10,12 @@ import json +import approved_commit import delivery import gh_parts import manual_checks from merged_lib.checks import TOOL, GreenWatch, pr_state -from step_result import StepError, approval_present, emit, git_root, result +from step_result import EXIT_UNREADABLE, StepError, approval_present, emit, git_root, result def add_wait_args(m): @@ -182,6 +183,30 @@ def _conflicted(root, n, stderr: str) -> bool: return False +def _merge_pr(root, a, n, watch, items, waits): + """緑を確かめた先頭で gh pr merge を走らせ、マージの記録を items へ足す。失敗すれば stopped を emit する。""" + sha = watch.last_sha # --expect-head があれば、GreenWatch が読むたびにこれと比べている + pin = ["--match-head-commit", sha] if sha else [] # 緑を確かめた先頭(--expect-head なら比べた先端)だけをマージする + p = gh_parts.gh(["pr", "merge", str(n), "--admin", f"--{a.method}", *pin], cwd=root) + if p.returncode != 0: + if _conflicted(root, n, p.stderr or ""): + watch._stop_conflict("gh pr merge がマージのコミットを作れずに拒んだ") + emit( + result( + TOOL, + "stopped", + f"gh pr merge --admin が失敗: {p.stderr.strip()[:300]}", + items + [{"kind": "pr", "name": f"#{n}", "result": "stopped", "reason": p.stderr.strip()[:300]}], + {"waits": waits}, + next=_rerun_superseded_next(n, items), + ) + ) + merged = {"kind": "pr", "name": f"#{n}", "result": "merged", "method": a.method, "head": sha} + if a.gate_approved: + merged["gate_approved"] = a.gate_approved # 誰が承認ゲート 2 を通したか(監査で辿る。#1336 の I8) + items.append(merged) + + def merge_when_green(a, cleanup, next_cmd=None, plan_step="merge-approved"): root = git_root(a.root) n = a.pr @@ -203,26 +228,7 @@ def gate(info): gate_metrics = {"verdict": verdict.value, "target": verdict.target} if verdict else {} if not any(i["kind"] == "pr" and i["result"] == "already_merged" for i in items): - sha = watch.last_sha # --expect-head があれば、GreenWatch が読むたびにこれと比べている - pin = ["--match-head-commit", sha] if sha else [] # 緑を確かめた先頭(--expect-head なら比べた先端)だけをマージする - p = gh_parts.gh(["pr", "merge", str(n), "--admin", f"--{a.method}", *pin], cwd=root) - if p.returncode != 0: - if _conflicted(root, n, p.stderr or ""): - watch._stop_conflict("gh pr merge がマージのコミットを作れずに拒んだ") - emit( - result( - TOOL, - "stopped", - f"gh pr merge --admin が失敗: {p.stderr.strip()[:300]}", - items + [{"kind": "pr", "name": f"#{n}", "result": "stopped", "reason": p.stderr.strip()[:300]}], - {"waits": waits}, - next=_rerun_superseded_next(n, items), - ) - ) - merged = {"kind": "pr", "name": f"#{n}", "result": "merged", "method": a.method, "head": sha} - if a.gate_approved: - merged["gate_approved"] = a.gate_approved # 誰が承認ゲート 2 を通したか(監査で辿る。#1336 の I8) - items.append(merged) + _merge_pr(root, a, n, watch, items, waits) if a.no_cleanup: emit( @@ -299,7 +305,15 @@ def _prepare_promote(root, a, n, item): def promote(a, cleanup): """ベースブランチ(--head)から本番チャネル(--base)への昇格の Pull Request を作り(あれば使い)、承認ゲート 2 の後にマージする。 - head はベースブランチなので、マージの後に後片付けをしない(#1336 の I7)。""" + head はベースブランチなので、マージの後に後片付けをしない(#1336 の I7)。 + --expect-head-from があれば、最初に導入の確認の承認資料から確かめたコミットを読み、merge-when-green の + --expect-head と同じ照合に使う。読めなければ gh を呼ばずに EXIT_UNREADABLE で止まる(照合を飛ばさない。#1529)。""" + src = getattr(a, "expect_head_from", None) + if src: + try: + a.expect_head = approved_commit.verified_sha(src) + except ValueError as e: + raise StepError(f"確かめたコミットを読めないため昇格しない: {e}", EXIT_UNREADABLE) from e root = git_root(a.root) n, created = _find_or_create_promote_pr(root, a.head, a.base) item = {"kind": "pr", "name": f"#{n}", "result": "created" if created else "found", "base": a.base, "head_branch": a.head} diff --git a/plugins/ndf/scripts/project_lib/model.py b/plugins/ndf/scripts/project_lib/model.py index 37241aa6e..75161b7a2 100644 --- a/plugins/ndf/scripts/project_lib/model.py +++ b/plugins/ndf/scripts/project_lib/model.py @@ -47,7 +47,12 @@ class Container(schema.Shape): class Suite(schema.Shape): """テストの 1 まとまり。`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)に、シェルの引用で守った範囲の - パスが入る。`command` と `scope_command` はシェルで走らせる。 + パスの並びが入る。入るパスの種類は場所で違う: cross-refactoring の改修の範囲テストではテストのファイルのパス + (計画の `test_targets` の `::` より前)、全体テストを CI に任せる戦略の着手前のテストでは `--scope` のテストの置き場所 + (ディレクトリかファイル)、静的解析(`kind: lint`)では変更したファイルのパス。テストの `scope_command` はテストの + ファイルのパスを受けるコマンドで書く(unittest なら `discover -s {paths}` ではなく `python3 -m unittest {paths}`)。 + ファイルを受けない `scope_command` は、cross-refactoring の `init` がテストのファイルを入れた試走で見分け、改修の前に + 止める。`command` と `scope_command` はシェルで走らせる。 `junit` はコマンドが JUnit XML を書く作業ディレクトリからの相対パス(無ければ落ちたテストの見分けは走らせ直しへ落ちる)。 `kind` は種別で、`test`(テスト)か `lint`(静的解析。整形の検査を含む)。書かなければ `test`。静的解析の suite の範囲は 変更したファイルのうち `paths` に当たるもの。`paths` の要素は接頭辞(ディレクトリ)か glob(`*.sh` など)で、空ならすべて。 diff --git a/plugins/ndf/scripts/release_lib/deploy.py b/plugins/ndf/scripts/release_lib/deploy.py index 5088ce196..0bab01fac 100644 --- a/plugins/ndf/scripts/release_lib/deploy.py +++ b/plugins/ndf/scripts/release_lib/deploy.py @@ -23,6 +23,7 @@ import subprocess from pathlib import Path +import approved_commit import delivery import proc from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, StepError, emit, git_root, result @@ -68,7 +69,7 @@ def verify_markdown(base: str, sha: str, verify: str, code: int) -> str: "", "## 確認したコミット", "", - f"- ベースブランチ {base} の先頭: `{sha}`。導入の確認はこのコミットで走らせた", + approved_commit.verified_line(base, sha), "", *verify_lines(verify, code), ] diff --git a/plugins/ndf/scripts/supervise_lib/delivery_templates.py b/plugins/ndf/scripts/supervise_lib/delivery_templates.py index 7da01cff5..4ee4eb5f9 100644 --- a/plugins/ndf/scripts/supervise_lib/delivery_templates.py +++ b/plugins/ndf/scripts/supervise_lib/delivery_templates.py @@ -68,7 +68,8 @@ def plan_promote( - normal(`mvv` 無し): promote(承認の引数無し。承認ゲート 2 で終える)→ promote-approved(`--from` でだけ入る) - fast / auto(`mvv` にスプリントの状態): verify(導入の確認。落ちたら止まる。#1457)→ prepare(PR と承認資料)→ mvv(承認ゲート 2 の MVV 判定)→ note(判定のコメント)→ - promote(`--gate-approved mvv`)。note か promote が落ちたら handoff が承認ゲートへ落とす + promote(`--gate-approved mvv`)。promote は mvv と同じ導入の確認の承認資料(VERIFY_MATERIAL)を `--expect-head-from` に受け、 + 確かめたコミットのときだけマージする(#1529)。note か promote が落ちたら handoff が承認ゲートへ落とす """ head, base = a.base, production or a.production_branch if not base: @@ -139,7 +140,7 @@ def plan_promote( "type": "run", "stage": "配布", "timeout": timeout, - "cmd": promote + wait + " --gate-approved mvv", + "cmd": promote + wait + f" --expect-head-from {VERIFY_MATERIAL} --gate-approved mvv", "on_fail": "handoff", "gate_next": "end", "next": "record", diff --git a/plugins/ndf/scripts/supervise_lib/engine.py b/plugins/ndf/scripts/supervise_lib/engine.py index 0a4726f50..d7934fea2 100644 --- a/plugins/ndf/scripts/supervise_lib/engine.py +++ b/plugins/ndf/scripts/supervise_lib/engine.py @@ -30,6 +30,17 @@ from supervise_lib.worker_steps import DriveStep, WorkStep +def _result_summary(text: str) -> str: + """ステップの出力の最後の JSON の summary。`status` が `stopped` で `metrics.exit` が整数なら「(元の終了コード N)」を添える。""" + res = last_json(text) or {} + summary = str(res.get("summary") or "") + metrics = res.get("metrics") + code = metrics.get("exit") if isinstance(metrics, dict) else None + if summary and res.get("status") == "stopped" and isinstance(code, int) and not isinstance(code, bool): + return f"{summary}(元の終了コード {code})" + return summary + + class PolicyClaudeRunner(ClaudeRunner): """ランタイムの宣言(#1598)を起動の前に照らす `ClaudeRunner`。 @@ -335,12 +346,13 @@ def _next_after_step(self, sid: str, step: dict, ok: bool) -> tuple[str | None, def _stop_detail(self, sid: str) -> str: """止まった理由へ添える要約: このステップの結果の summary と、直前のステップが on_fail でここへ回した失敗の - summary(検査のプランが abort へ回した理由を読めるように。#1917 の決定 7)。""" + summary(検査のプランが abort へ回した理由を読めるように。#1917 の決定 7)。結果が駆動の stopped で + `metrics.exit`(止めたスクリプトの元の終了コード。`lib/drive_pause.py`)を持てば、要約の後ろへ添える(#1593)。""" st = self.state - parts = [(last_json(st.cur.get("text", "")) or {}).get("summary") or ""] + parts = [_result_summary(st.cur.get("text", ""))] prev = st.log[-1].get("id") if st.log else None if prev and prev == st.failed_step and prev != sid and self.steps.get(prev, {}).get("on_fail") == sid: - why = (last_json((st.results.get(prev) or {}).get("text", "")) or {}).get("summary") or "" + why = _result_summary((st.results.get(prev) or {}).get("text", "")) parts.append(f"{prev}: {why}" if why else "") return "".join(f": {p}" for p in parts if p) diff --git a/plugins/ndf/scripts/supervise_lib/plan.py b/plugins/ndf/scripts/supervise_lib/plan.py index 1634ae5b5..d70738778 100644 --- a/plugins/ndf/scripts/supervise_lib/plan.py +++ b/plugins/ndf/scripts/supervise_lib/plan.py @@ -50,11 +50,13 @@ `external-ai.py run` で起動する(起動・上限つきの待ち・回収はそのコマンドが持つ)。書かなければ最小構成の claude -p。 drive のステップ: `cmd`(または `"drive": "cross-review" | "cross-refactoring"` と `"args"`)を打ち、最後の行の JSON を読む。 -- `status` が `ok` なら成功。`metrics`(ラウンド数・指摘・未解決・適用・取り消しなど)を報告の「件数」へ載せる +- `status` が `ok` なら成功。`metrics`(ラウンド数・指摘・未解決・適用・取り消し・cross-review の継続的統合の判定 `ci` + と理由 `ci_reason` など)を報告の「件数」へ載せる - `gate` なら `items[0]` の `prompt_file` を worker に渡し、`result_file` を書かせてから同じコマンドを打ち直す。 `command` を持つ pause(最終ゲートの cross-review)は、その駆動を同じ形で回し、`metrics.review_status` を `result_file` へ書く -- `stopped`・結果ファイルが書かれない・`"max_pauses"`(既定 12)を超える、のどれかなら失敗 +- `stopped`・結果ファイルが書かれない・`"max_pauses"`(既定 12)を超える、のどれかなら失敗。`stopped` の止まった理由には + 駆動の `summary` と、`metrics.exit` があれば「(元の終了コード N)」が添う ステップごとの作業場所: run と work のステップに `"cwd"` を書くと、そのステップだけ別の場所で動く(取り込みで PR ごとに 作業ツリーが違うとき)。work のステップでは worker へ渡す「作業場所」もその `cwd` になる。 @@ -196,11 +198,17 @@ from __future__ import annotations import re +from pathlib import Path # queue が --then のプランを流す前に、先行のプランの Pull Request の番号へ置き換える印 QUEUE_PRS = "{queue_prs}" +def sprint_state_path(a) -> str: + """スプリントの状態ファイルの絶対パス(sprint.py と sprint_waves.py が共有する)。""" + return str(Path(a.state).resolve()) + + def _part_prompt(base_prompt: str, i: int, parts: list[dict]) -> str: """i 番目(1 始まり)のパートの prompt。元の prompt へ、触るファイルと終え方を足す。""" part = parts[i - 1] diff --git a/plugins/ndf/scripts/supervise_lib/release_templates.py b/plugins/ndf/scripts/supervise_lib/release_templates.py index 8cfac2539..7a8924ccd 100644 --- a/plugins/ndf/scripts/supervise_lib/release_templates.py +++ b/plugins/ndf/scripts/supervise_lib/release_templates.py @@ -128,7 +128,7 @@ def _add_prod_mvv_gate(steps: list[dict], a, repo: str | None, approval: str, pr # 関門 2 を MVV で判定する。関門(10)なら報告は 結果: 関門 で止まり、conductor が承認を取ってから # run <計画> --from bump で続ける。従えば判定の記録を --pr の PR のすべてへコメントしてから bump へ進み、 # コメントが落ちたら handoff が関門 2 の by: mvv の記録を外して関門で終える(#1370 の I8) - material = _material_path(repo, approval) + material = material_path(repo, approval) state = str(Path(a.mvv).resolve()) steps[0:0] = mvv_gate_steps( f"{MVV_PY} check --sprint {shlex.quote(state)} --gate release " @@ -165,7 +165,7 @@ def _prepare_steps(a, plugin: str, v: str, prs: str, dev: bool, after_notes: str "id": "bump-others", "type": "run", "stage": "配布", - "cmd": bump_others_cmd(a, plugin, _material_path(repo, approval)), + "cmd": bump_others_cmd(a, plugin, material_path(repo, approval)), "on_fail": "judge", "next": "changelog", } @@ -201,7 +201,7 @@ def _release_step(a, v: str, dev: bool, repo: str | None, approval: str) -> dict "probe": {"cmd": f"{MERGED_PY} probe --head {{branch}} --head {{base}} --act"}, } if not dev: - step["cmd"] += f" --approval {shlex.quote(_material_path(repo, approval))}" + step["cmd"] += f" --approval {shlex.quote(material_path(repo, approval))}" step["gate_next"] = "handoff" if getattr(a, "mvv", None) else "end" return step @@ -373,7 +373,7 @@ def advise_steps(state: str, gate: str, args: str, note: dict, head: dict | None def advise_release_steps(a, repo: str | None, approval: str, prs: str) -> list[dict]: """開発版の explain の後の助言の MVV 判定(#1400)。承認資料と出す版の PR を材料にし、判定の記録を承認資料の末尾へ足す。 判定によらず、想定外の失敗でも mvv-note へ進み、プランの結果は facts の関門のままである。""" - material = _material_path(repo, approval) + material = material_path(repo, approval) state = shlex.quote(str(Path(a.advise).resolve())) args = f"--material {shlex.quote(material)} --pr {prs} --mode {a.mode}" + (f" --root {shlex.quote(repo)}" if repo else "") note = { @@ -411,7 +411,7 @@ def mvv_gate_steps(mvv_cmd: str, note_cmd: str, next_id: str) -> list[dict]: ] -def _material_path(repo, approval): +def material_path(repo, approval): """承認資料の置き場。元のリポジトリが分かればその下。""" return f"{repo}/{approval}" if repo else approval diff --git a/plugins/ndf/scripts/supervise_lib/sprint.py b/plugins/ndf/scripts/supervise_lib/sprint.py index fab902c68..e20caee95 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint.py +++ b/plugins/ndf/scripts/supervise_lib/sprint.py @@ -28,10 +28,14 @@ plan_mvv_release, ) from supervise_lib.new_args import NEW_ARGS -from supervise_lib.paths import CHECK_PY, HERE, SELF +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.plan import sprint_state_path from supervise_lib.sprint_routes import ( + normal_gate_1, + normal_gate_2, + normal_gate_2_stage, MVV_PACES, dev_channel_of, has_record, @@ -227,8 +231,9 @@ def advises(a, closing: bool = False) -> bool: return bool(getattr(a, "state", None)) and not closing -def sprint_state_path(a) -> str: - return str(Path(a.state).resolve()) +def has_prod_branch(a) -> bool: + """本番のブランチ(--production-branch か .ndf/worktree.json の production_branch)があるか。""" + return bool(getattr(a, "production_branch", None)) PACE_PLANS = {"fast": fast_sprint_plans, "auto": auto_sprint_plans} # 進め方ごとのステージの組み立て(normal は sprint_plans) @@ -256,33 +261,16 @@ def sprint_plans(a) -> list[dict]: if not has_release_template(a): return waves + route_waves(a, repo, "検査", note=normal_gate_2(a) if advise else None) release = {"name": "配布", "plans": {"release": plan_sprint_release(a, repo, advise)}, "then_of": "検査"} - if advise: - release["note"] = normal_gate_2(a) - return waves + [release] - - -def user_gate_cmd(a, gate: str, pr: bool) -> str: - """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" - state = shlex.quote(sprint_state_path(a)) - return ( - f"python3 {shlex.quote(str(HERE / 'sprint-state.py'))} gate {state} {shlex.quote(gate)} --what <要約> --by user" - + (" --pr <設計 PR>" if pr else "") - + " --outcome approved|rejected" - ) - - -def normal_gate_1(a) -> str: - return ( - "設計 Pull Request をまとめて承認してマージする。承認資料には各設計 PR の助言の MVV 判定(PR のコメント。無ければ「MVV なし」)を" - f"載せる。利用者が答えたら、設計 PR ごとに `{user_gate_cmd(a, '関門 1', True)}` を打つ" - ) - - -def normal_gate_2(a) -> str: - return ( - "承認ゲート 2: 承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)。利用者が本番への配布に答えたら、" - f"`{user_gate_cmd(a, '関門 2', False)}` を打つ" - ) + if not has_prod_branch(a): # 本番を組めないなら今のまま配布で終える(#1914 の決定 3) + if advise: + release["note"] = normal_gate_2(a) + return waves + [release] + prod = prod_version(a.version) + return waves + [ + release, + {"name": "関門 2", "gate": normal_gate_2_stage(a, repo, prod, advise)}, + {"name": "本番", "plans": {"release-prod": plan_sprint_release(a, repo, channel="prod", version=prod)}}, + ] def sprint_command(a, drop: tuple[str, ...] = (), design: list[int] | None = None) -> str: @@ -348,7 +336,7 @@ def cmd_new_sprint(a, waves: list[dict] | None = None) -> dict: f"スプリント {a.name} の計画を {plans} 本・{len(index)} ステージで書いた: {manifest}", index, {"waves": len(index), "plans": plans, "manifest": str(manifest)}, - next=next_text(pace, index, advise=pace == "normal" and advises(a, closing)), + next=next_text(pace, index, advise=pace == "normal" and advises(a, closing), no_prod=no_prod_stage(a, pace, closing)), ) @@ -415,13 +403,19 @@ def manifest_head(a, pace: str, closing: bool = False) -> dict: return head -def next_text(pace: str, index: list[dict], advise: bool = False) -> str: +def no_prod_stage(a, pace: str, closing: bool) -> bool: + """normal の雛形の経路で、本番のブランチが無いため本番のステージを置かなかったか(#1914 の決定 3)。""" + return pace == "normal" and not closing and not has_prod_branch(a) and has_release_template(a) + + +def next_text(pace: str, index: list[dict], advise: bool = False, no_prod: bool = False) -> str: """次に打つ手の文面。auto だけ別の文で、manual のステージがあれば追記する。`advise`(normal と --state)なら - 承認資料に助言の MVV 判定を載せ、答えを状態へ書くことを足す。""" + 承認資料に助言の MVV 判定を載せ、答えを状態へ書くことを足す。`no_prod` なら本番を置かなかった理由を足す。""" nxt = "ステージの番号の順に command を打つ。関門のステージでは承認を取ってから次へ進む" if advise: nxt += ( - "。承認資料には助言の MVV 判定を載せ、利用者が答えたら関門 1 のステージの gate・配布のステージの note にある " + "。承認資料には助言の MVV 判定を載せ、利用者が答えたら関門 1 のステージの gate・承認ゲート 2 の文(関門 2 のステージの gate か、" + "配布の後のステージの note)にある " "sprint-state.py gate(--what と --by user。関門 1 は --pr も)を打つ" ) if pace == "auto": @@ -429,6 +423,8 @@ def next_text(pace: str, index: list[dict], advise: bool = False) -> str: "最初のステージの command を打つ(後ろのステージは --then で続く)。queue が gate を返したら、承認資料に判定の理由と" "根拠の項目を添えて承認を取り、関門のステージの説明に沿って続きのステージの resume を打つ" ) + if no_prod: + nxt += "。本番のブランチ(--production-branch か .ndf/worktree.json の production_branch)が無いため、承認ゲート 2 と本番は置いていない" for manual in (e for e in index if "manual" in e): nxt += f"。{manual['name']}は {manual['manual']} で行う({manual['note']})" return nxt diff --git a/plugins/ndf/scripts/supervise_lib/sprint_routes.py b/plugins/ndf/scripts/supervise_lib/sprint_routes.py index bc13228a5..a66d60660 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_routes.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_routes.py @@ -2,13 +2,16 @@ from __future__ import annotations +import shlex + import delivery 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.plan import QUEUE_PRS +from supervise_lib.paths import HERE +from supervise_lib.plan import QUEUE_PRS, sprint_state_path from supervise_lib.delivery_templates import GATE_2_MATERIAL, plan_gate_2, plan_promote, plan_verify -from supervise_lib.release_templates import RELEASE_FORMS +from supervise_lib.release_templates import RELEASE_FORMS, material_path from supervise_lib.verify_steps import plan_limits @@ -163,3 +166,38 @@ def manual_production_waves(a, repo: str, then_of: str, mvv: str, condition: dic }, ] return waves + + +def user_gate_cmd(a, gate: str, pr: bool) -> str: + """利用者の答えを状態へ書くコマンド(覆しの記録。#1400 の AC6)。承認ゲート 1 は答えた設計 PR を --pr で渡す。""" + state = shlex.quote(sprint_state_path(a)) + return ( + f"python3 {shlex.quote(str(HERE / 'sprint-state.py'))} gate {state} {shlex.quote(gate)} --what <要約> --by user" + + (" --pr <設計 PR>" if pr else "") + + " --outcome approved|rejected" + ) + + +def normal_gate_1(a) -> str: + return ( + "設計 Pull Request をまとめて承認してマージする。承認資料には各設計 PR の助言の MVV 判定(PR のコメント。無ければ「MVV なし」)を" + f"載せる。利用者が答えたら、設計 PR ごとに `{user_gate_cmd(a, '関門 1', True)}` を打つ" + ) + + +def normal_gate_2(a) -> str: + return ( + "承認ゲート 2: 承認資料の末尾に助言の MVV 判定が載る(無ければ「MVV なし」)。利用者が本番への配布に答えたら、" + f"`{user_gate_cmd(a, '関門 2', False)}` を打つ" + ) + + +def normal_gate_2_stage(a, repo: str | None, prod: str, 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") + text = ( + f"開発版の配布が書いた承認資料({approval})を示して本番への配布の承認を取り、承認されたら `python3 {shlex.quote(str(HERE / 'release-steps.py'))} approve --approval {shlex.quote(approval)} " + "--approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" + ) + return text + "。" + normal_gate_2(a) if advise else text diff --git a/plugins/ndf/scripts/supervise_lib/sprint_waves.py b/plugins/ndf/scripts/supervise_lib/sprint_waves.py index 722559a16..bc89f05a2 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_waves.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_waves.py @@ -15,6 +15,7 @@ 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.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 @@ -318,29 +319,31 @@ def plan_sprint_check(a, repo: str) -> dict: return plan -def plan_sprint_release(a, repo: str, advise: bool = False) -> dict: - """開発版の配布。出す PR は検査のプランのスプリントの PR(`{queue_pr:check}`)で、検査の queue の --then でも - 単独で流し直しても埋まる(#1915)。`advise` なら承認資料の後に助言の MVV 判定を置く(pace: normal と --state。#1400)。""" +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 ns = argparse.Namespace( **decl_fields(a), - version=a.version, + version=version, prs=["{queue_pr:check}"], prs_from_queue=False, - channel="dev", + channel=channel, repo=repo, prev_tag=None, - worktree=f"{repo}/.worktrees/release/v{a.version}", - branch=f"release/v{a.version}", + worktree=f"{repo}/.worktrees/release/v{version}", + branch=f"release/v{version}", issue=a.issue, mode=a.mode, - advise=a.state if advise else None, + advise=a.state if advise and channel == "dev" else None, ) return plan_release(ns) def advise_design_steps(a) -> list[dict]: """助言の MVV 判定(#1400)の mvv・mvv-note のステップ。判定によらず、想定外の失敗でも関門 1 の judge(gate)へ進む。""" - state = shlex.quote(str(Path(a.state).resolve())) + state = shlex.quote(sprint_state_path(a)) note = { "timeout": 300, "cmd": f"sh -c '[ ! -f {MVV_NOTE} ] || gh pr comment {{pr}} --body-file {MVV_NOTE}'", @@ -369,7 +372,7 @@ def plan_mvv_design(a, n: int, repo: str) -> dict: """pace: fast と auto の設計: 関門 1 の judge を MVV 判定のステップへ替える。従えばラベルとコメントを付けてマージする。 ラベルの付与かマージが落ちたら handoff で関門 1 の by: mvv の記録を外し、プランを関門で終える(#1370 の I8)。""" plan = plan_sprint_design(a, n, repo) - state = shlex.quote(str(Path(a.state).resolve())) + state = shlex.quote(sprint_state_path(a)) note = MVV_NOTE # 用語チェックの当たりが直し切れずに残ったら、mvv の判定へ渡さず関門 1 の judge(gate)へ回す push = "" @@ -400,7 +403,7 @@ def plan_mvv_design(a, n: int, repo: str) -> dict: "next": "merge-gate", }, *merge_steps(a, next="end", on_fail="handoff"), - handoff_step(str(Path(a.state).resolve()), "関門 1", "承認ラベルの付与かマージ"), + handoff_step(sprint_state_path(a), "関門 1", "承認ラベルの付与かマージ"), ] plan["規則"] = ( "設計の cross-review は上限 3 ラウンドで関門 1 の判定(mvv のステップ)へ渡す(収束を待たない)。駆動そのものが失敗したら gate。" diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/9-release-prod.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/9-release-prod.json new file mode 100644 index 000000000..fd65a8fa3 --- /dev/null +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/9-release-prod.json @@ -0,0 +1,160 @@ +{ + "フェーズ": "配布(本番)", + "課題": [ + 16, + 17 + ], + "モード": "standard", + "作業場所": "/repo/.worktrees/release/v1.2.0", + "branch": "release/v1.2.0", + "起点": "origin/main", + "規則": "利用者は関門 2 を承認した。run のステップが落ちたら、直せるもの(版数の書き漏れ・文書の形)は fix。外部の待ち(CI・ネットワーク)の揺れなら同じステップをもう一度。タグの重複・権限の不足は stop。", + "上限": 98, + "steps": [ + { + "id": "bump", + "type": "run", + "stage": "配布", + "cmd": "python3 /scripts/release-steps.py bump --plugin foo --to 1.2.0 --base main", + "on_fail": "judge", + "next": "bump-others" + }, + { + "id": "bump-others", + "type": "run", + "stage": "配布", + "cmd": "out=$(python3 /scripts/release-steps.py changed-plugins --plugin foo --decided /repo/issues/approval-foo-v1.2.0.md) || { printf \"%s\\n\" \"$out\"; exit 1; }; printf \"%s\\n\" \"$out\"; printf \"%s\\n\" \"$out\" | python3 -c \"import json,sys; [print(i[\\\"name\\\"], i[\\\"to\\\"]) for i in json.loads(sys.stdin.read().strip().splitlines()[-1])[\\\"items\\\"]]\" | while read -r n to; do python3 /scripts/release-steps.py bump --plugin \"$n\" --to \"$to\" --base main || exit 1; done", + "on_fail": "judge", + "next": "changelog" + }, + { + "id": "changelog", + "type": "run", + "cmd": "python3 /scripts/release-steps.py changelog --version 1.2.0 --prs {queue_pr:check}", + "on_fail": "judge", + "next": "notes" + }, + { + "id": "notes", + "type": "run", + "stage": "配布", + "cmd": "sh -c 'python3 /scripts/release-steps.py notes --version 1.2.0 --prs {queue_pr:check} && git add -A && (git diff --cached --quiet || git commit -q -m \"Release: foo v1.2.0\")'", + "on_fail": "judge", + "next": "snapshot" + }, + { + "id": "snapshot", + "type": "run", + "stage": "配布", + "timeout": 900, + "cmd": "sh -c 'python3 /scripts/release-steps.py run --root . --stage production --version 1.2.0 && git add -A && (git diff --cached --quiet || git commit -q -m \"Release: foo v1.2.0 のトークン消費の記録\")'", + "on_fail": "judge", + "next": "release" + }, + { + "id": "release", + "type": "run", + "stage": "配布", + "timeout": 7920, + "cmd": "python3 /scripts/release-steps.py release --version 1.2.0 --ci-wait 3600 --channel prod --approval /repo/issues/approval-foo-v1.2.0.md", + "on_exit": { + "75": "stop" + }, + "on_fail": "judge", + "next": "record", + "probe": { + "cmd": "python3 /scripts/merged-steps.py probe --head {branch} --head {base} --act" + }, + "gate_next": "end" + }, + { + "id": "record", + "type": "run", + "stage": "配布", + "timeout": 300, + "cmd": "python3 /scripts/release-steps.py record --version 1.2.0 --prs {queue_pr:check}", + "pr_from": "release_pr_url", + "on_fail": "judge-record", + "next": "verify" + }, + { + "id": "verify", + "type": "run", + "stage": "リリース後テスト", + "timeout": 1500, + "cwd": "/repo", + "cmd": "sh -c 'git pull -q --ff-only origin main && python3 /scripts/release-verification-steps.py verify-install --ref main --expect 1.2.0 --runtimes claude'", + "on_fail": "judge", + "next": "cleanup" + }, + { + "id": "cleanup", + "type": "run", + "stage": "後片付け", + "cwd": "/repo", + "cmd": "sh -c 'python3 /scripts/merged-steps.py cleanup $(gh pr list --state merged --limit 30 --json number,headRefName --jq \".[] | select(.headRefName | startswith(\\\"release/v1.2.0\\\")) | .number\") {queue_pr:check}'", + "on_fail": "judge", + "next": "end" + }, + { + "id": "judge", + "type": "judge", + "inputs": [ + "bump", + "bump-others", + "changelog", + "notes", + "snapshot", + "release", + "verify", + "cleanup" + ], + "question": "落ちたステップを直す(fix)か、同じステップをもう一度(retry)か、止める(stop)か。retry なら decision に落ちたステップの id を返す", + "choices": [ + "fix", + "bump", + "bump-others", + "changelog", + "notes", + "snapshot", + "release", + "verify", + "cleanup", + "stop" + ] + }, + { + "id": "fix", + "type": "work", + "kind": "修正", + "inputs": [ + "bump", + "bump-others", + "changelog", + "notes", + "snapshot", + "release", + "verify", + "cleanup" + ], + "prompt": "落ちたステップの出力を読み、原因を直してコミットする(push しない)。直したら次は落ちたステップからやり直す。", + "back_to_failed": true, + "next": "release" + }, + { + "id": "judge-record", + "type": "judge", + "inputs": [ + "record" + ], + "question": "リリース記録の書き込み(record)が落ちた。本番の配布は済んでいる。GitHub の揺れなら record をもう一度、権限の不足・本番のリリースの PR が無いなら止める(stop)", + "choices": [ + "record", + "stop" + ] + } + ], + "base_branch": "main", + "リポジトリ": "/repo", + "記録": "/scripts/projects-sync.sh" +} diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json index 84b35ad58..206aa1645 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/sprint.json @@ -65,6 +65,19 @@ ], "then_of": "検査", "resume": "python3 /scripts/supervise.py queue /out/sprint/7-release.json --max 3" + }, + { + "wave": 8, + "name": "関門 2", + "gate": "開発版の配布が書いた承認資料(/repo/issues/approval-foo-v1.2.0.md)を示して本番への配布の承認を取り、承認されたら `python3 /scripts/release-steps.py approve --approval /repo/issues/approval-foo-v1.2.0.md --approved-sha --by user` で承認を記録してから「本番」のステージの command を打つ(差し戻されたら打たない)" + }, + { + "wave": 9, + "name": "本番", + "plans": [ + "/out/sprint/9-release-prod.json" + ], + "command": "python3 /scripts/supervise.py queue /out/sprint/9-release-prod.json --max 3" } ] } diff --git a/plugins/ndf/scripts/tests/test_approved_commit.py b/plugins/ndf/scripts/tests/test_approved_commit.py index c3c43e8ad..22c68d148 100644 --- a/plugins/ndf/scripts/tests/test_approved_commit.py +++ b/plugins/ndf/scripts/tests/test_approved_commit.py @@ -192,3 +192,29 @@ def test_material_without_the_row_is_value_error(tmp_path): assert not isinstance(e.value, ac.Unapproved) with pytest.raises(ValueError): ac.material_sha(tmp_path / "none.md") + + +# --- 確かめたコミット(導入の確認の承認資料。#1529) -------------------------------- + + +def test_verified_sha_reads_what_verify_markdown_wrote(tmp_path: Path): + """#1529 の I3: verify-facts の承認資料の行は verified_line が書き、verified_sha が同じ SHA を読む。""" + sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + from release_lib.deploy import verify_markdown + + sha = "0123456789abcdef" * 2 + "01234567" + text = verify_markdown("trunk", sha.upper(), "true", 0) + assert ac.verified_line("trunk", sha.upper()) in text.split("\n") + p = tmp_path / "approval-verify.md" + p.write_text(text) + assert ac.verified_sha(p) == sha + + +@pytest.mark.parametrize("text", [None, "# 無い\n", "{l}\n{l}\n", "{short}\n"]) +def test_verified_sha_rejects_an_unreadable_material(tmp_path: Path, text): + """#1529 の I2: 資料が無い・行が無い・2 つ以上・40 桁でなければ ValueError。""" + p = tmp_path / "approval-verify.md" + if text is not None: + p.write_text(text.format(l=ac.verified_line("trunk", "a" * 40), short=ac.verified_line("trunk", "a" * 39))) + with pytest.raises(ValueError): + ac.verified_sha(p) diff --git a/plugins/ndf/scripts/tests/test_lib_test_strategy.py b/plugins/ndf/scripts/tests/test_lib_test_strategy.py index cccef261c..5b39ee9be 100644 --- a/plugins/ndf/scripts/tests/test_lib_test_strategy.py +++ b/plugins/ndf/scripts/tests/test_lib_test_strategy.py @@ -482,6 +482,28 @@ def test_verify_commands_stops_on_an_invalid_declaration(): ts.verify_commands(decl) +@pytest.mark.parametrize( + "test", + [{"suites": []}, {"suites": [{"name": "docs", "runner": "none"}]}, {"suites": [{"name": "md", "kind": "lint"}]}], +) +def test_verify_commands_name_nothing_for_a_declaration_without_tests(test): + """#1593 I1 — テストの無い宣言(`{"suites": []}` を含む)は名指しするコマンドが無いとして `None`。""" + assert ts.verify_commands({"test": test}) is None + + +@pytest.mark.parametrize( + "test, key", + [ + ({"suites": [{"name": "py", "kind": "unit"}]}, r"test\.suites\[0\]\.kind"), + ({"strategy": "local-scoped-ci-whole", "suites": []}, "scope_command"), + ], +) +def test_verify_commands_still_stop_on_a_wrong_declaration_without_tests(test, key): + """#1593 I2 — command の無い宣言でも、種別の誤りと解けない戦略は今のとおり止める。""" + with pytest.raises(ts.StrategyError, match=key): + ts.verify_commands({"test": test}) + + # ---------- #1437: 雛形から組んだ全体テストの注記 ---------- PYTEST_DECL = {"test": {"suites": [{"name": "py", "runner": "pytest", "command": "pytest -q"}]}} @@ -599,3 +621,73 @@ def test_covers_whole(paths, locations, expected): def test_covers_whole_needs_a_test_suite(): lint_only = ts.Strategy(ts.LOCAL_SCOPED_CI_WHOLE, "args", [ts.Suite("lint", "ruff .", paths=["."], kind=ts.LINT)]) assert ts.covers_whole(lint_only, ["."]) is False + + +# ---------- #1630: 範囲テストのテンプレートの試走(probe_paths・template_verdict) ---------- + + +def _probe_strategy(*suites): + return ts.Strategy(ts.LOCAL_FULL, "test.strategy", list(suites)) + + +def test_probe_paths_takes_two_scope_files_and_skips_existing_failures(): + """I3: `--scope` の候補から既存失敗のファイルを除き、並べ替えた先頭の 2 本を選ぶ。""" + unit = ts.Suite("unit", "u", "u {paths}", paths=["tests"]) + pick = ts.probe_paths( + _probe_strategy(unit), unit, ["tests/test_c.py", "tests/test_a.py", "tests/test_b.py"], [], [], ["tests/test_a.py"] + ) + assert (pick.paths, pick.source, pick.owned) == (["tests/test_b.py", "tests/test_c.py"], ts.PROBE_FROM_SCOPE, True) + + +def test_probe_paths_falls_back_to_repo_files_the_suite_owns(): + """I3: `--scope` にその suite のテストが無ければ、`suite_for` がその suite を返したものだけを選ぶ(最初の suite へ寄せない)。""" + unit = ts.Suite("unit", "u", "u {paths}", paths=["tests"]) + js = ts.Suite("js", "j", "j {paths}", paths=["web"]) + strategy = _probe_strategy(unit, js) + pick = ts.probe_paths(strategy, unit, [], ["e2e/login.spec.ts", "tests/test_x.py", "web/a.test.ts"], [], []) + assert (pick.paths, pick.source, pick.owned) == (["tests/test_x.py"], ts.PROBE_FROM_REPO, True) + + +def test_probe_paths_from_repo_is_not_owned_when_the_suite_declares_dot(): + """I3: `paths` が `.` の suite には全ファイルが当たるため、`repo` の候補は受け持ちが未確定になる。""" + unit = ts.Suite("unit", "u", "u {paths}", paths=["."]) + pick = ts.probe_paths(_probe_strategy(unit), unit, [], ["e2e/login.spec.ts", "tests/test_x.py"], [], []) + assert (pick.paths, pick.source, pick.owned) == (["e2e/login.spec.ts", "tests/test_x.py"], ts.PROBE_FROM_REPO, False) + + +def test_probe_paths_uses_the_declared_paths_when_no_name_matches(): + """I3: 名前の形に当たらないテストは、suite の `paths` の配下から選ぶ(受け持ちは未確定)。`.` と glob は根にしない。""" + php = ts.Suite("php", "p", "p {paths}", paths=["tests", ".", "src/**/*Test.php"]) + assert ts.probe_roots(php) == ["tests"] + pick = ts.probe_paths( + _probe_strategy(php), php, [], [], ["tests/TestCase.php", "tests/FooTest.php", "tests/CreatesApplication.php"], [] + ) + assert (pick.paths, pick.source, pick.owned) == (["tests/CreatesApplication.php", "tests/FooTest.php"], ts.PROBE_FROM_PATHS, False) + + +def test_probe_paths_is_empty_when_nothing_is_found(): + unit = ts.Suite("unit", "u", "u {paths}", paths=["."]) + pick = ts.probe_paths(_probe_strategy(unit), unit, [], [], [], []) + assert (pick.paths, pick.source, pick.owned) == ([], None, False) + + +@pytest.mark.parametrize( + "result, reference, known, owned, expected", + [ + (None, "green", True, True, ts.PROBE_UNDECIDED), + (ts.outcome(None, True), "green", True, True, ts.PROBE_UNDECIDED), + (ts.outcome(0, False), "none", False, False, ts.PROBE_OK), + (ts.outcome(1, False), "green", True, False, ts.PROBE_UNDECIDED), + (ts.outcome(1, False), "green", False, True, ts.PROBE_DEFECT), + (ts.outcome(5, False), "red", True, True, ts.PROBE_DEFECT), + (ts.outcome(1, False), "none", True, True, ts.PROBE_DEFECT), + (ts.outcome(1, False), "red", False, True, ts.PROBE_UNDECIDED), + (ts.outcome(1, False), "none", False, True, ts.PROBE_UNDECIDED), + ], +) +def test_template_verdict_follows_the_table(result, reference, known, owned, expected): + """I4: 判定の表の行どおりに返し、`ok` 以外は止める。""" + verdict = ts.template_verdict(result, reference, known, owned) + assert verdict.status == expected + assert verdict.stops is (expected != ts.PROBE_OK) + assert verdict.reason diff --git a/plugins/ndf/scripts/tests/test_project_mvv_normal.py b/plugins/ndf/scripts/tests/test_project_mvv_normal.py index e15a1662f..0a189be65 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv_normal.py +++ b/plugins/ndf/scripts/tests/test_project_mvv_normal.py @@ -11,6 +11,7 @@ from __future__ import annotations import json +import re from pathlib import Path from types import SimpleNamespace @@ -319,7 +320,11 @@ def test_normal_sprint_with_a_state_puts_the_advice_before_both_gates(tmp_path): manifest, out = new_normal_sprint(tmp_path, "--state", str(state)) assert (manifest["進め方"], manifest["状態"], manifest["ブランチ"]) == ("normal", str(state.resolve()), "sprint/m26") waves = {w["name"]: w for w in manifest["ステージ"]} - assert "--what <要約> --by user --pr <設計 PR>" in waves["関門 1"]["gate"] and "--by user" in waves["配布"]["note"] + assert "--what <要約> --by user --pr <設計 PR>" in waves["関門 1"]["gate"] and "--by user" in waves["関門 2"]["gate"] + assert "note" not in waves["配布"] and "'関門 2' --what" in waves["関門 2"]["gate"] + assert waves["関門 2"]["gate"].index("release-steps.py approve --approval") < waves["関門 2"]["gate"].index("「本番」のステージ") + # 承認資料は本番の release と同じく元のリポジトリの下の絶対パスで渡す(作業場所の git の根に依らない) + assert re.search(r"approve --approval /\S+/issues/approval-\S+\.md ", waves["関門 2"]["gate"]) assert "sprint-state.py gate" in out["next"] design = load(waves["設計"]["plans"][0]) ds = steps_of(design) @@ -342,6 +347,7 @@ def test_normal_sprint_with_a_state_puts_the_advice_before_both_gates(tmp_path): assert plan["スプリント状態"] == str(state.resolve()) assert_transitions_exist(plan) assert sum(1 for w in manifest["ステージ"] for p in w.get("plans", []) for s in load(p)["steps"] if s["id"] == "mvv") == 2 + assert not {"mvv", "note", "handoff"} & set(steps_of(load(waves["本番"]["plans"][0]))) # 本番は利用者が答える(#1914 の I4) def test_normal_sprint_without_a_state_is_unchanged(tmp_path): diff --git a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py index 75db457a1..5c8db5b4e 100644 --- a/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py +++ b/plugins/ndf/scripts/tests/test_sprint_close_merge_green.py @@ -935,3 +935,75 @@ def test_merge_gate_stops_on_unchecked_before_merge_rows(repo, gh, body, code): if code == 10: assert out["metrics"]["gate"] == "manual-check" and "実機で承認資料の欄を見る" in out["summary"] assert "--from merge-gate" in out["next"] + + +# --- #1529: fast / auto の昇格は、導入の確認で確かめたコミットのときだけマージする ---------------------- + +X, Y = "a" * 40, "b" * 40 + + +def verified_material(tmp_path, *lines): + """導入の確認の承認資料(verify-facts が書く形)。lines を省けば確かめたコミットを X にする。""" + sys.path.insert(0, str(SCRIPTS)) + from release_lib.deploy import verify_markdown + + p = tmp_path / "approval-verify.md" + p.write_text("\n".join(lines) if lines else verify_markdown("develop", X, "true", 0), encoding="utf-8") + return p + + +def promote_mvv(repo, gh, material): + args = ["promote", "--head", "develop", "--base", "main", "--interval", "0", "--recheck", "0"] + return call("merged-steps.py", [*args, "--expect-head-from", str(material), "--gate-approved", "mvv"], gh.env, repo) + + +def test_promote_merges_the_verified_commit(repo, gh, tmp_path): + """AC1・I1: 先端が確かめたコミットのまま CI が通れば、そのコミットに固定してマージする。""" + declare(repo, *TRYGROUP) + gh.set(open_prs=[{"number": 9}], pr_seq={"9": [dict(to("main", X), headRefName="develop")] * 3}) + code, out, err = promote_mvv(repo, gh, verified_material(tmp_path)) + assert code == 0, (out, err) + assert merges(gh) == [["pr", "merge", "9", "--admin", "--merge", "--match-head-commit", X]] + assert next(i for i in out["items"] if i.get("result") == "merged")["head"] == X + + +@pytest.mark.parametrize( + "seq", + [ + [dict(to("main", Y), headRefName="develop")] * 2, # 確認の後、最初の読みまでに進んだ + [dict(to("main", X), headRefName="develop"), dict(to("main", Y), headRefName="develop")], # CI を待つ間に進んだ + [{"state": "MERGED", "headRefOid": Y, "mergeCommit": {"oid": "c"}, "baseRefName": "main"}], # 別の先端でマージ済み + ], +) +def test_promote_stops_when_the_head_moved_from_the_verified_commit(repo, gh, tmp_path, seq): + """AC2・AC3・I1: 先端が確かめたコミットと違えば gh pr merge を呼ばず、head_moved と両方の SHA を出して 1。""" + declare(repo, *TRYGROUP) + gh.set(open_prs=[{"number": 9}], pr_seq={"9": seq}) + code, out, err = promote_mvv(repo, gh, verified_material(tmp_path)) + assert code == 1 and out["status"] == "stopped", (out, err) + assert {"kind": "pr", "name": "#9", "result": "head_moved", "head": Y, "expected": X} in out["items"] + assert merges(gh) == [] + + +@pytest.mark.parametrize( + "lines", + [ + None, # 資料が無い + ("# 導入の確認の承認資料", "", "- コマンド: `true`"), # 行が無い + (f"- ベースブランチ develop の先頭: `{X}`。導入の確認はこのコミットで走らせた",) * 2, # 行が 2 つ + (f"- ベースブランチ develop の先頭: `{X[:39]}`。導入の確認はこのコミットで走らせた",), # 39 桁 + ], +) +def test_promote_does_not_merge_without_a_readable_verified_commit(repo, gh, tmp_path, lines): + """AC5・I2: 確かめたコミットを読めなければ、gh を 1 度も呼ばずに 2 で止まる(照合を飛ばさない)。""" + declare(repo, *TRYGROUP) + material = tmp_path / "missing.md" if lines is None else verified_material(tmp_path, *lines) + code, out, err = promote_mvv(repo, gh, material) + assert code == 2 and out["status"] == "stopped", (out, err) + assert gh.get().get("calls", []) == [] + + +def test_promote_help_shows_the_verified_commit_argument(gh, repo): + """AC9: promote の --help に照合の引数が出る。""" + p = subprocess.run([PY, str(SCRIPTS / "merged-steps.py"), "promote", "--help"], capture_output=True, text=True, env=gh.env) + assert p.returncode == 0 and "--expect-head-from" in p.stdout diff --git a/plugins/ndf/scripts/tests/test_supervise.py b/plugins/ndf/scripts/tests/test_supervise.py index 6fce3e650..cce9a68b7 100644 --- a/plugins/ndf/scripts/tests/test_supervise.py +++ b/plugins/ndf/scripts/tests/test_supervise.py @@ -1487,7 +1487,17 @@ def test_new_sprint_writes_waves_in_order(tmp_path): assert res["status"] == "ok" manifest = json.loads((out / "sprint.json").read_text()) assert manifest["ブランチ"] == "sprint/v10-18" - assert [w["name"] for w in manifest["ステージ"]] == ["設計", "関門 1", "設計の結果", "スプリントブランチ", "実装", "検査", "配布"] + assert [w["name"] for w in manifest["ステージ"]] == [ + "設計", + "関門 1", + "設計の結果", + "スプリントブランチ", + "実装", + "検査", + "配布", + "関門 2", + "本番", + ] waves = {w["name"]: w for w in manifest["ステージ"]} assert "plans" not in waves["関門 1"] and waves["関門 1"]["gate"] assert len(waves["実装"]["plans"]) == 2 and waves["実装"]["command"].endswith("--max 3") @@ -1563,7 +1573,7 @@ def test_new_sprint_without_design_skips_gate(tmp_path): p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) assert p.returncode == 0, p.stderr names = [w["name"] for w in json.loads((out / "sprint.json").read_text())["ステージ"]] - assert names == ["スプリントブランチ", "実装", "検査", "配布"] + assert names == ["スプリントブランチ", "実装", "検査", "配布", "関門 2", "本番"] @pytest.mark.parametrize("args", [["--issue", "1"], ["--name", "a b", "--issue", "1"], ["--name", "m"], ["--name", "m", "--issue", "1"]]) @@ -2449,6 +2459,48 @@ def test_new_sprint_check_and_release_run_without_a_whole_skill(tmp_path): assert "command" not in waves["配布"] and waves["配布"]["then_of"] == "検査" +def test_normal_sprint_puts_gate_2_and_prod_after_the_dev_release(tmp_path): + """normal の雛形の経路は配布の後に関門 2 と本番を置き、本番は承認の後に単独で流す(#1914)。""" + out = tmp_path / "m" + p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + assert p.returncode == 0, p.stderr + index = json.loads((out / "sprint.json").read_text())["ステージ"] + assert [w["name"] for w in index][-3:] == ["配布", "関門 2", "本番"] + waves = {w["name"]: w for w in index} + gate, prod = waves["関門 2"], waves["本番"] + assert "plans" not in gate and "approval-" in gate["gate"] and "sprint-state.py" not in gate["gate"] + assert "then_of" not in prod and prod["plans"][0].endswith("-release-prod.json") + assert prod["command"].endswith(f"queue {prod['plans'][0]} --max 3") + assert prod["plans"][0] not in waves["検査"]["command"] and "note" not in waves["配布"] + plan = json.loads(Path(prod["plans"][0]).read_text()) + steps = {s["id"]: s for s in plan["steps"]} + assert "--channel prod" in steps["release"]["cmd"] and "--version 10.18.0 " in steps["release"]["cmd"] + assert "--prs {queue_pr:check}" in steps["changelog"]["cmd"] and plan["課題"] == [1] + assert not {"mvv", "note", "handoff"} & set(steps) + + +def test_prod_plan_queued_alone_takes_the_check_pr(tmp_path): + """関門 2 の後に本番のプランだけを流すと、同じディレクトリの検査の報告から PR が埋まる(#1914 の AC3)。""" + from supervise_lib import paths, queue + + out = tmp_path / "m" + p = cli("new", "sprint", "--name", "m", "--worktree", str(tmp_path), "--issue", "1", "--version", "10.18.0-dev.1", "--out", str(out)) + assert p.returncode == 0, p.stderr + waves = {w["name"]: w for w in json.loads((out / "sprint.json").read_text())["ステージ"]} + check, prod = waves["検査"]["plans"][0], waves["本番"]["plans"][0] + alone = tmp_path / "alone" + alone.mkdir() + for f in (check, prod): + (alone / Path(f).name).write_text(Path(f).read_text()) + d = paths.state_dir_of(str(alone / Path(check).name)) + d.mkdir(parents=True, exist_ok=True) + (d / "report.md").write_text("## フェーズの報告\n\n- 結果: 完了\n- Pull Request: 4242\n") + target = alone / Path(prod).name + assert queue.fill_queue_pr(str(target), []) is None + text = target.read_text() + assert "{queue_pr:check}" not in text and "--prs 4242" in text + + def test_new_impl_with_several_issues_refers_to_all_of_them(tmp_path): out = tmp_path / "p.json" p = cli("new", "impl", "--issue", "898", "913", "922", "--worktree", "/w", "--tests", "t.py", "--title", "T", "--out", str(out)) diff --git a/plugins/ndf/scripts/tests/test_supervise_new.py b/plugins/ndf/scripts/tests/test_supervise_new.py index 84d53e63d..db1633103 100644 --- a/plugins/ndf/scripts/tests/test_supervise_new.py +++ b/plugins/ndf/scripts/tests/test_supervise_new.py @@ -92,6 +92,18 @@ def test_new_sprint_with_package_plugin_keeps_release_stage(tmp_path): assert p.returncode == 0, p.stderr names = [w["name"] for w in json.loads((out / "sprint.json").read_text())["ステージ"]] assert names[-1] == "配布" and "/ndf:release" not in json.loads(p.stdout)["next"] + assert "production_branch" in json.loads(p.stdout)["next"] # 本番のブランチが無いので本番を置かない(#1914 の決定 3) + + +def test_normal_sprint_without_a_production_branch_keeps_gate_2_on_the_release(tmp_path): + root = plain_repo(tmp_path, {"form": "package-plugin", "plugin": "foo", "runtimes": ["claude"]}) + state = tmp_path / "state.json" + state.write_text(json.dumps({"name": "m", "pace": "normal", "gates": []})) + out = tmp_path / "m" + p = new_sprint(root, out, "--state", str(state)) + assert p.returncode == 0, p.stdout + p.stderr + last = json.loads((out / "sprint.json").read_text())["ステージ"][-1] + assert last["name"] == "配布" and "--by user" in last["note"] def close_cli(root, out, *extra): diff --git a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py index 5231a0174..a8164ed53 100644 --- a/plugins/ndf/scripts/tests/test_supervise_pace_verify.py +++ b/plugins/ndf/scripts/tests/test_supervise_pace_verify.py @@ -111,6 +111,58 @@ def test_a_failed_verify_stops_before_the_promote_pr(tmp_path): assert ran == [] and res["items"][0]["result"] != "完了", res +@pytest.mark.parametrize("pace", ["auto", "fast"]) +def test_the_promote_merges_only_the_commit_the_mvv_judge_read(tmp_path, pace): + """#1529 の AC4・AC6・I4: fast / auto の promote は mvv の材料と同じ導入の確認の承認資料で照合し、落ちたら handoff へ進む。""" + plan = load(wave(new_sprint(tmp_path, make_repo(tmp_path, [STG, PRD]), pace), "本番")["plans"][0]) + s = steps_of(plan) + material = "{state_dir}/work/approval-verify.md" + assert material in s["verify"]["cmd"] and material in s["mvv"]["cmd"] + assert f"--expect-head-from {material} " in s["promote"]["cmd"] and s["promote"]["on_fail"] == "handoff" + assert "--expect-head-from" not in s["promote-approved"]["cmd"] + + +def test_the_promote_plan_uses_the_declared_branch_names(tmp_path): + """#1529 の AC7・AC8・I5: 照合の引数は fast / auto の promote にだけ付き、ブランチ名は引数のものだけが現れる。""" + from supervise_lib.delivery_templates import plan_promote + + a = type("A", (), {"base": "trunk", "production_branch": "stable", "issue": "11", "mode": "standard"})() + normal = steps_of(plan_promote(a, str(tmp_path), 600, verify="true")) + fast = steps_of(plan_promote(a, str(tmp_path), 600, mvv=str(tmp_path / "state.json"), verify="true")) + base = f"{SCRIPTS / 'merged-steps.py'} promote --head trunk --base stable --timeout 600" + assert normal["promote"]["cmd"].endswith(base) and normal["promote-approved"]["cmd"].endswith(base + " --gate-approved user") + assert fast["promote-approved"]["cmd"] == normal["promote-approved"]["cmd"] + want = base + " --expect-head-from {state_dir}/work/approval-verify.md --gate-approved mvv" + assert fast["promote"]["cmd"].endswith(want) + for s in (*normal.values(), *fast.values()): + assert "develop" not in str(s.get("cmd")) and " main" not in str(s.get("cmd")) + + +def test_a_promote_without_the_verified_commit_falls_back_to_gate_2(tmp_path): + """#1529 の AC4・AC5: 導入の確認の承認資料が無ければ、plan の promote はマージせずに落ち、handoff が + 承認ゲート 2 の by: mvv の記録を外して承認ゲートで終わる。""" + repo = make_repo(tmp_path, [STG, PRD]) + state = sprint_state(tmp_path) + plan = load(wave(new_sprint(tmp_path, repo), "本番")["plans"][0]) + log = tmp_path / "mvv-gate.jsonl" + stub = { + "verify": "true", # 承認資料を書かない + "prepare": "true", + "mvv": f"{PY} {SCRIPTS / 'sprint-state.py'} gate {state} '関門 2' --what x --by mvv --verdict follow --reasons '[]' --log {log}", + "note": "true", + "record": f"touch {tmp_path / 'recorded'}", + } + for s in plan["steps"]: + if s["id"] in stub: + s["cmd"] = stub[s["id"]] + path = tmp_path / "promote.json" + path.write_text(json.dumps(plan, ensure_ascii=False)) + res = queue.cmd_queue([str(path)], 1, poll=0.05) + assert res["status"] == "gate" and res["items"][0]["result"] == "関門", res + assert not (tmp_path / "recorded").exists() + assert not [g for g in load(state)["gates"] if g["name"] == "関門 2"] + + def test_a_passed_verify_keeps_the_order(tmp_path): """AC4: 確認が 0 なら今と同じ順で続き、マージの後にリリース記録を書く(#1870)。""" res, ran = run_promote(tmp_path, "true") diff --git a/plugins/ndf/scripts/tests/test_supervise_stop_exit.py b/plugins/ndf/scripts/tests/test_supervise_stop_exit.py new file mode 100644 index 000000000..3f3a2ed89 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_supervise_stop_exit.py @@ -0,0 +1,57 @@ +"""drive のステップが駆動の stopped で失敗したとき、止まった理由に駆動の summary と元の終了コードが載る(#1593 I5)。""" + +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)) +sys.path.insert(0, str(SCRIPTS / "lib")) +from supervise_lib import engine # noqa: E402 +from test_supervise_limits import reason_of # noqa: E402 + +PY = sys.executable +WHY = "review: 最終スイープの指示を組めない: .ndf/project.json の test が不正: test.suites[0].kind は test か lint で書く(今: unit)" + + +def said(status, metrics, code): + obj = {"tool": "t", "status": status, "summary": WHY, "items": [], "metrics": metrics} + return f"{PY} -c 'print({json.dumps(json.dumps(obj, ensure_ascii=False))}); raise SystemExit({code})'" + + +def run(tmp_path, steps): + return engine.Engine({"フェーズ": "検査", "作業場所": str(tmp_path), "steps": steps}, tmp_path / "state").run(None) + + +@pytest.mark.parametrize("kind", ["drive", "run"]) +def test_the_stop_reason_carries_the_original_exit(tmp_path, kind): + text = run(tmp_path, [{"id": "review", "type": kind, "cmd": said("stopped", {"exit": 2}, 1), "next": "end"}]) + reason = reason_of(text) + assert "ステップ review が失敗した" in reason and f"{WHY}(元の終了コード 2)" in reason, text + + +def test_the_abort_route_carries_the_original_exit_of_the_review(tmp_path): + review = {"id": "review", "type": "drive", "cmd": said("stopped", {"exit": 2}, 1), "next": "end", "on_fail": "abort"} + abort = {"id": "abort", "type": "run", "cmd": "false", "next": "end"} + reason = reason_of(run(tmp_path, [review, abort])) + assert "ステップ abort が失敗した" in reason and f"review: {WHY}(元の終了コード 2)" in reason, reason + + +@pytest.mark.parametrize( + "status, metrics", + [("stopped", {}), ("stopped", {"exit": "2"}), ("ok", {"exit": 2})], +) +def test_no_exit_is_added_without_a_stopped_integer_exit(tmp_path, status, metrics): + reason = reason_of(run(tmp_path, [{"id": "review", "type": "run", "cmd": said(status, metrics, 1), "next": "end"}])) + assert WHY in reason and "元の終了コード" not in reason, reason + + +def test_the_counts_line_carries_the_unverified_ci(tmp_path): + """#1593 I3・受け入れ条件 3 — 駆動の件数の `ci`・`ci_reason` が報告の件数の行に載り、`null` のキーは出ない。""" + metrics = {"rounds": 3, "review_status": "approved", "ci": "unverified", "ci_reason": "チェックジョブ 0 件", "unresolved": None} + text = run(tmp_path, [{"id": "review", "type": "drive", "cmd": said("ok", metrics, 0), "next": "end"}]) + assert "ci unverified / ci_reason チェックジョブ 0 件" in text and "unresolved" not in text, text diff --git a/plugins/ndf/scripts/tests/test_usage_capture.py b/plugins/ndf/scripts/tests/test_usage_capture.py index e2445b953..7d8226f42 100644 --- a/plugins/ndf/scripts/tests/test_usage_capture.py +++ b/plugins/ndf/scripts/tests/test_usage_capture.py @@ -94,6 +94,8 @@ def fake_clis(tmp_path, monkeypatch): monkeypatch.setenv("HOME", str(home)) monkeypatch.setenv("CODEX_HOME", str(home / ".codex")) monkeypatch.setenv("SECRET_TOKEN", SECRET) + for name in ("NDF_USAGE_SOURCE", "NDF_USAGE_KIND", "NDF_USAGE_PLAN", "NDF_USAGE_STEP"): + monkeypatch.delenv(name, raising=False) # supervise の中で走らせても名乗りを持ち込まない return tmp_path diff --git a/plugins/ndf/skills/cross-refactoring/SKILL.md b/plugins/ndf/skills/cross-refactoring/SKILL.md index 025e1862b..bab60f56f 100644 --- a/plugins/ndf/skills/cross-refactoring/SKILL.md +++ b/plugins/ndf/skills/cross-refactoring/SKILL.md @@ -255,7 +255,7 @@ bash "$LIB/bg-wait.sh" wait "$RC" # 1 回 540 秒以内。124 = まだ終わ | --- | --- | --- | | 0 | finalize まで終わった | `items[0].report`(`refactor.py report` の出力)と `metrics` を「完了報告」へ写す | | 23(`cross-review`) | 単独起動の最終ゲート | `prompt_file` を読み、`items[0].cwd`(対象のリポジトリの根)で `items[0].command`(cross-review の駆動)を回す。最終ステータスは cross-review の駆動が `result_file` へ書き、自分では書かない。同じコマンドを打ち直し、結果 JSON の `metrics.review_status` が `approved` のときだけ Draft を解除する。それ以外は Draft のまま最終ステータスを報告して止まる | -| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・着手前のテストが長く手順の枠が想定最大時間に収まらない(要る `--budget-minutes` の下限を出す。下限以上で `init` を打ち直すと着手前のテストを走らせずに続く)・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・着手前のテストの打ち切り・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | +| 1 | 中断(`metrics.exit` に元の終了コード。4 = 対象の Pull Request が閉じている・マージ済み・状態を判定できない(作業ディレクトリを作らず、担当の CLI を起動しない)・予算の指定の誤り・着手前のテストが長く手順の枠が想定最大時間に収まらない(要る `--budget-minutes` の下限を出す。下限以上で `init` を打ち直すと着手前のテストを走らせずに続く)・使える時間が 0 分以下で候補がすべて時間で見送られた(計画を置かず、打ち直しで止まっていた時間を除いて作り直す。#1944)・着手前のテストの打ち切り・範囲テストのテンプレートが `{paths}` に入るテストのファイルを受けない(着手前のテストの後に試走で見分けた。受けるかを確かめられないときも止め、直す箇所と記録 `template-check.json` を出す)・`--round-test` が要る・旧い状態ファイル・取り消しの失敗(項目に属さないコミットの積み直しか公開済みの revert が衝突した)・打ち切りの後の取り消しで起点へ戻した後でも最終ゲートが落ちた・push の直前に残すコミットでないもの(見放した担当の残留コミットなど)がある・範囲を確定できない など。最終ゲートを経ずに終わった実行もここで、`metrics.adopted` は 0・`metrics.unconfirmed` に残った項目の数) | `summary` を報告して止まる。**握り潰さない**(検証を通っていない変更が残る) | 再開は同じコマンドを打ち直すだけである。進みは耐久の記録(既定の置き場は `~/.local/state/ndf/dbos/refactor-<鍵>.sqlite` )が持ち、 記録のある手順は流し直さない。`metrics` は状態ファイルから数えた件数(`items` / `adopted` / `reverted` / diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py index d1f8e425a..d32a51b07 100644 --- a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/setup.py @@ -12,7 +12,7 @@ import subprocess import sys import time -from dataclasses import dataclass +from dataclasses import dataclass, field from typing import Any, Iterable, Optional import assignment @@ -29,10 +29,7 @@ from .. import ABORT, die, forecast, info, init_test, pause from .. import baseline as baseline_lib -from .. import ci_coverage -from .. import pr_gate -from .. import runtime_decl -from .. import timeline +from .. import ci_coverage, pr_gate, runtime_decl, template_check, timeline from ..paths import ( github_repo_from_origin, default_worktree_base, @@ -462,6 +459,7 @@ class _InitPreparation: strategy: ts.Strategy decl: dict[str, Any] ci_coverage: dict[str, Any] + template_checks: list[dict[str, Any]] = field(default_factory=list) def _resolve_init_inputs(args: argparse.Namespace) -> Optional[_InitInputs]: @@ -601,6 +599,7 @@ def _verify_init( init_test.append_init_test(prep, int(args.budget_minutes), args.pr, baseline) if baseline.get("timed_out"): init_test.abort_timed_out(prep.strategy, int(args.budget_minutes), baseline, timeout) + prep.template_checks = template_check.enforce(args, prep, baseline, timeout) # 不備なら止める(#1630) return participants, baseline, baseline_lib.round_record(prep.strategy, baseline) @@ -669,6 +668,7 @@ def _save_initial_state( # **実行時の値を書き出す**(決定 24)。改修計画の後の値は `merge-plan` が足す。 state["limits"] = timeline.of_state(state, forecast.after_plan(state)) state["ci_coverage"] = prep.ci_coverage + state["template_checks"] = list(prep.template_checks) # 範囲テストのテンプレートの試走の記録(#1630 の決定 7) info(f" 実装担当: {context.implementer}({context.implementer_reason})") # GitHub は自分の Pull Request への `APPROVE` と `REQUEST_CHANGES` を `HTTP 422` で拒む。判定はそのまま結果ファイルへ # 残し、**投稿の event だけ**を倒す。収束判定は結果ファイルの判定を見るので、倒しても進行は変わらない。 diff --git a/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py new file mode 100644 index 000000000..bce475fa2 --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py @@ -0,0 +1,228 @@ +"""範囲テストのテンプレートの試走(#1630)。`commands/setup.py` の `init` が着手前のテストの後に呼ぶ。 + +改修の範囲テストは計画の `test_targets`(テストのファイル)をテンプレートの `{paths}` へ入れる。ファイルを受けない +テンプレート(unittest の `discover -s {paths}` など)だと、コードと関係なく毎回落ちて修正へ回る。そこで改修の前に、 +`scope_command` を持つテストの suite ごとにテストのファイルを最大 2 本入れて 1 回走らせ、受けるかを見分ける。 +**判定が `ok` の suite だけが続く。** 不備(`defect`)か判定できない(`undecided`)なら、直す箇所を出して止める。 + +試走のコマンドは改修の範囲テストと同じ `test_strategy.fill` で組む(コマンドの語を見ない)。ファイルの選び方と判定は +`test_strategy.probe_paths` / `template_verdict` が持ち、ここは候補を集めて走らせ、記録を組むだけである。 +記録を組んで `/template-check.json` へ書くのはこのモジュールだけで、`init` は返った記録を状態ファイルの +`template_checks` へそのまま置く。着手前のテストの記録(`baseline_test`)とテストの戦略は書き換えない。 +""" + +from __future__ import annotations + +import json +import pathlib +import time +from dataclasses import dataclass, field +from typing import Any, Optional + +import statefile +import test_strategy as ts +import test_triage + +from . import die, info, launch +from .gitfacts import run_with_timeout +from .scope import test_files_under, test_locations, tracked_files_under + +RECORD_NAME = "template-check.json" +NO_TIME = "着手前のテストの上限の残りが無い" +NO_FILES = "候補のファイルが無い" + + +@dataclass +class Report: + """試走の結果。`stopped` が真なら `message` を出して止める。`suites` は状態ファイルの `template_checks`。""" + + stopped: bool + suites: list[dict[str, Any]] = field(default_factory=list) + message: str = "" + path: Optional[pathlib.Path] = None + + +@dataclass(frozen=True) +class _ProbeContext: + """`check_templates` が suite ごとの試走(`_check_one`)へ渡す、suite によらない入力。""" + + baseline: dict[str, Any] + work: pathlib.Path + scope: list[str] + decl: dict[str, Any] + flag: Optional[str] + tmp_dir: pathlib.Path + + +def targets_of(strategy: ts.Strategy) -> list[ts.Suite]: + """試走の対象の suite。`round-only` は改修の範囲テストを使わないため、静的解析は `{paths}` に変更したファイルが入るため対象にしない。""" + if strategy.name == ts.ROUND_ONLY: + return [] + return strategy.scoped_suites(ts.TEST) + + +def _decl_index(decl: dict[str, Any], suite: ts.Suite) -> Optional[int]: + test = (decl or {}).get("test") + rows = test.get("suites") if isinstance(test, dict) else None + for i, row in enumerate(rows if isinstance(rows, list) else []): + if isinstance(row, dict) and str(row.get("name") or "suite") == suite.name and row.get("scope_command") == suite.scope_command: + return i + return None + + +def _fix_at(strategy: ts.Strategy, decl: dict[str, Any], suite: ts.Suite, flag: Optional[str]) -> tuple[Optional[int], str]: + """直す箇所。宣言の suite は `test.suites[<番号>].scope_command`、引数の雛形は引数の名前。""" + if strategy.source == "args" and suite.name == "args": + return None, f"{flag or '--round-test'} の値" + index = _decl_index(decl, suite) + if index is None: + return None, f".ndf/project.json の test.suites(name: {suite.name})の scope_command" + return index, f".ndf/project.json の test.suites[{index}].scope_command" + + +def _probe_candidates( + strategy: ts.Strategy, suite: ts.Suite, scope: list[str], work: pathlib.Path +) -> tuple[list[str], list[str], list[str]]: + """`--scope` のテストの置き場所の配下・リポジトリ全体・suite の `paths` の配下の候補(選ぶのは `probe_paths`)。""" + w = str(work) + in_scope = [f for loc in test_locations(scope, w) for f in test_files_under(loc, w)] + repo = test_files_under(".", w) + under = [f for root in ts.probe_roots(suite) for f in tracked_files_under(root, w)] + return in_scope, repo, under + + +def _reference(baseline: dict[str, Any], suite: ts.Suite, source: Optional[str]) -> str: + """基準。`--scope` の候補はその suite の着手前のテストの成否、ほかは全体(`command`)で通ったときだけ `green`。""" + status = (baseline.get("suites") or {}).get(suite.name) + if source == ts.PROBE_FROM_SCOPE: + return status if status in (ts.REFERENCE_GREEN, ts.REFERENCE_RED) else ts.REFERENCE_NONE + whole = baseline.get("mode") in ("whole", "round") and bool(suite.command) + return ts.REFERENCE_GREEN if whole and status == ts.REFERENCE_GREEN else ts.REFERENCE_NONE + + +def _run_probe( + command: Optional[str], work: pathlib.Path, log: pathlib.Path, budget: tuple[float, float] +) -> tuple[Optional[ts.Outcome], str]: + """試走を 1 回走らせ、(結果, 走らせなかった理由) を返す。command が無ければ NO_FILES、上限の残りが 1 秒未満なら NO_TIME。""" + if command is None: + return None, NO_FILES + left, started = budget + if left - (time.monotonic() - started) < 1: + return None, NO_TIME + code, timed_out = test_triage.run_within(left, started, lambda limit: run_with_timeout(command, str(work), limit, output=log)) + result = ts.outcome(code, timed_out) + if result.launch_failed: + launch.stop(None, {}, "baseline", command, result, log) + return result, NO_FILES + + +def _check_one( + strategy: ts.Strategy, + suite: ts.Suite, + ctx: _ProbeContext, + budget: tuple[float, float], + i: int, +) -> dict[str, Any]: + baseline, work = ctx.baseline, ctx.work + failures = baseline.get("existing_failures") + failing = list(test_triage.by_file(list(failures or []))) + pick = ts.probe_paths(strategy, suite, *_probe_candidates(strategy, suite, ctx.scope, work), failing) + index, fix_at = _fix_at(strategy, ctx.decl, suite, ctx.flag) + reference = _reference(baseline, suite, pick.source) + command = ts.fill(str(suite.scope_command), pick.paths) if pick.paths else None + log = ctx.tmp_dir / f"template-check-{i}.log" + result, not_run = _run_probe(command, work, log, budget) + known = pick.source == ts.PROBE_FROM_SCOPE and failures is not None + verdict = ts.template_verdict(result, reference, known, pick.owned, not_run) + return { + "suite": suite.name, + "index": index, + "fix_at": fix_at, + "scope_command": suite.scope_command, + "paths": list(pick.paths), + "source": pick.source, + "owned": pick.owned, + "command": command, + "code": result.code if result else None, + "outcome": result.status if result else "not_run", + "reference": reference, + "verdict": verdict.status, + "reason": verdict.reason, + "log": str(log) if result else None, + } + + +def stop_text(row: dict[str, Any]) -> str: + """止めるときの文(suite ごとに 1 段)。`undecided` でも直す箇所と `--round-test` の直し方を必ず出す。""" + head = "受けません" if row["verdict"] == ts.PROBE_DEFECT else "受けるかを確かめられません" + if row["code"] is not None: + ran = f"{row['command']} → 終了コード {row['code']}({row['reason']}。着手前のテスト: {row['reference']})" + else: + ran = f"走らせていない({row['reason']})" + ways = ["ファイルを受けるコマンドへ直す", "--round-test に {paths} を含まない全体テストを渡す"] + if not row["paths"]: + ways.append("その suite の paths にテストの置き場所を宣言する") + lines = [ + f"⛔ 範囲テストのテンプレートが {{paths}} に入るテストのファイルを{head}(suite: {row['suite']})", + f" 今のテンプレート: {row['scope_command']}", + f" 入れたパス: {' '.join(row['paths']) or '(なし)'}", + f" 試走: {ran}", + f" 直す箇所: {row['fix_at']}({{paths}} にはテストのファイルのパスが入る。{'か、'.join(ways)})", + ] + if row.get("log"): + lines.append(f" ログ: {row['log']}") + return "\n".join(lines) + + +def check_templates( + strategy: ts.Strategy, + baseline: dict[str, Any], + scope: list[str], + work: pathlib.Path, + tmp_dir: pathlib.Path, + timeout: int, + *, + decl: Optional[dict[str, Any]] = None, + flag: Optional[str] = None, +) -> Report: + """試走の対象の suite ごとに 1 回ずつ走らせ、記録を `/template-check.json` へ書いて返す。 + + 上限は着手前のテストと同じ `timeout` から、着手前のテストの所要(`baseline["seconds"]`)を引いた残りを + suite 群で分け合う。`flag` は雛形を渡した引数の名前(`--round-test` / `--baseline-test`)。""" + suites = targets_of(strategy) + if not suites: + return Report(False) + left = float(timeout) - float(baseline.get("seconds") or 0.0) + budget = (left, time.monotonic()) + ctx = _ProbeContext(baseline, work, list(scope), decl or {}, flag, tmp_dir) + rows = [_check_one(strategy, suite, ctx, budget, i) for i, suite in enumerate(suites)] + # 試走が書いた JUnit を残さない(後の手順が前の実行の結果を読まないため) + test_triage.clear_junit(str(work), strategy) + stopped = any(r["verdict"] != ts.PROBE_OK for r in rows) + path = tmp_dir / RECORD_NAME + path.write_text( + json.dumps({"checked_at": statefile.now(), "stopped": stopped, "suites": rows}, ensure_ascii=False, indent=2) + "\n", + encoding="utf-8", + ) + message = "\n".join(stop_text(r) for r in rows if r["verdict"] != ts.PROBE_OK) + return Report(stopped, rows, message, path) + + +def report_summary(report: Report) -> str: + """続けるときの 1 行。""" + shown = "、".join(f"{r['suite']}({' '.join(r['paths'])})" for r in report.suites) + return f"✅ 範囲テストのテンプレートを試走: {shown}" + + +def enforce(args: Any, prep: Any, baseline: dict[str, Any], timeout: int) -> list[dict[str, Any]]: + """`init` の入口。試走して、`ok` でない suite があれば状態ファイルを作らずに止める(終了コード 4。#1630 の I5)。 + 続けるときは状態ファイルの `template_checks` に置く記録を返す。""" + round_test = getattr(args, "round_test", None) + flag = "--round-test" if ts.has_paths(round_test) else "--baseline-test" + report = check_templates(prep.strategy, baseline, list(args.scope), prep.work, prep.tmp_dir, timeout, decl=prep.decl, flag=flag) + if report.stopped: + info(report.message) + die(f"範囲テストのテンプレートを直してから打ち直してください(記録: {report.path})") + if report.suites: + info(report_summary(report)) + return report.suites diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_init.py b/plugins/ndf/skills/cross-refactoring/tests/test_init.py index a111fd1f8..dcf286e37 100644 --- a/plugins/ndf/skills/cross-refactoring/tests/test_init.py +++ b/plugins/ndf/skills/cross-refactoring/tests/test_init.py @@ -89,6 +89,9 @@ def run_init(refactor_lib, paths, patch_lib, refactor, origin_repo, monkeypatch) refactor_lib = sys.modules["refactor_lib"] probed: list[list[str]] = [] probed_models: list[dict] = [] + # 範囲テストのテンプレートの試走(#1630)はこのファイルの主題ではないため、対象なしとして通す。 + # 試走そのものは `test_template_check.py` で見る。 + _no_template_check(monkeypatch) def _run(args, viewer="someone-else", probe=None, real_probe=False, pr_status=None): """`probe` を渡すと確認を差し替える。`{ランタイム: 理由}` の者だけが通らない。 @@ -164,6 +167,11 @@ def fake_probe(runtimes, *, info, env=None, models=None, level="model"): return _run +def _no_template_check(monkeypatch): + template_check = sys.modules["refactor_lib.template_check"] + monkeypatch.setattr(template_check, "check_templates", lambda *a, **k: template_check.Report(False)) + + def refactor_abort(): """中断の終了コード。`refactor` フィクスチャを取らない関数からも使う。""" return 4 @@ -2019,6 +2027,7 @@ def fake_run(strategy, work, timeout, scope, tmp_dir): monkeypatch.setattr(setup, "resolve_participants", lambda *a, **k: {"available": ["claude"]}) monkeypatch.setattr(setup.baseline_lib, "run_baseline", fake_run) + _no_template_check(monkeypatch) args = types.SimpleNamespace(budget_minutes=30, scope=["tests"], pr=1554, require_all=None) inputs = types.SimpleNamespace(host="claude", include=[], exclude=[], model_spec={}) diff --git a/plugins/ndf/skills/cross-refactoring/tests/test_template_check.py b/plugins/ndf/skills/cross-refactoring/tests/test_template_check.py new file mode 100644 index 000000000..1dd46e0dc --- /dev/null +++ b/plugins/ndf/skills/cross-refactoring/tests/test_template_check.py @@ -0,0 +1,180 @@ +"""#1630: `init` が着手前のテストの後に範囲テストのテンプレートを試走し、`{paths}` に入るテストのファイルを受けなければ止まる。 + +再現のリポジトリ(`scripts/storylib/{__init__,x}.py` と `tests/{__init__,test_x,test_y}.py`)を git で作り、宣言から +解いた戦略で `_verify_init` を走らせる。着手前のテストも試走も実際にシェルで走らせる。 +""" + +from __future__ import annotations + +import json +import shlex +import subprocess +import sys +import types + +import pytest + +PY = shlex.quote(sys.executable) +DISCOVER = f"{PY} -m unittest discover -s {{paths}}" +WHOLE = f"{PY} -m unittest discover -s tests" + + +def _git(*args, cwd): + subprocess.run(["git", *args], cwd=cwd, check=True, capture_output=True) + + +@pytest.fixture +def repo(tmp_path): + work = tmp_path / "work" + for rel, body in { + "scripts/storylib/__init__.py": "", + "scripts/storylib/x.py": "X = 1\n", + # 環境の PYTHONPATH に別の `tests` パッケージがあっても、この `tests` を読むように通常のパッケージにする + "tests/__init__.py": "", + "tests/test_x.py": "import unittest\n\n\nclass T(unittest.TestCase):\n def test_x(self):\n pass\n", + "tests/test_y.py": "import unittest\n\n\nclass T(unittest.TestCase):\n def test_y(self):\n pass\n", + }.items(): + path = work / rel + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(body, encoding="utf-8") + _git("init", "-q", cwd=work) + _git("add", "-A", cwd=work) + _git("-c", "user.email=t@e.st", "-c", "user.name=t", "commit", "-qm", "init", cwd=work) + return work + + +@pytest.fixture +def verify(refactor, monkeypatch, tmp_path, repo): + """宣言を受けて `_verify_init` を呼ぶ。止まれば `(終了コード, None)`、続けば `(None, prep)`。""" + setup = sys.modules["refactor_lib.commands.setup"] + ts = sys.modules["test_strategy"] + monkeypatch.setenv("NDF_METRICS_DIR", str(tmp_path / "metrics")) + monkeypatch.setattr(setup, "resolve_participants", lambda *a, **k: {"available": ["claude"]}) + tmp_dir = tmp_path / "tmp" + tmp_dir.mkdir() + + def call(suite, strategy=None, scope=("scripts/storylib",), round_test=None, extra=()): + decl = {"version": 1, "test": {"suites": [suite, *extra]}} + if strategy: + decl["test"]["strategy"] = strategy + resolved = ts.resolve(decl, round_test=round_test, scope_paths=list(scope)) + prep = types.SimpleNamespace(strategy=resolved, work=repo, tmp_dir=tmp_dir, decl=decl, repo="acme/demo", template_checks=[]) + args = types.SimpleNamespace(budget_minutes=30, scope=list(scope), pr=1630, require_all=None, round_test=round_test) + inputs = types.SimpleNamespace(host="claude", include=[], exclude=[], model_spec={}) + try: + _, baseline, _ = setup._verify_init(args, inputs, prep) + except SystemExit as e: + return e.code, None, None + return None, prep, baseline + + def record(): + return json.loads((tmp_dir / "template-check.json").read_text(encoding="utf-8")) + + return types.SimpleNamespace(call=call, record=record, setup=setup, ts=ts) + + +def _suite(scope_command=DISCOVER, paths=None, **over): + suite = {"name": "unit", "runner": "unittest", "command": WHOLE, "scope_command": scope_command} + if paths is not None: + suite["paths"] = paths + suite.update(over) + return suite + + +def test_a_template_that_rejects_files_stops_with_where_to_fix(verify, capsys): + """受け入れ条件 1・2・3: 全体を走らせる戦略で suite が paths を宣言していれば `defect` で止まり、直す箇所が出る。""" + code, prep, _ = verify.call(_suite(paths=["tests"])) + assert code == 4 and prep is None, "状態ファイルを作る手前で止まる(改修の範囲テストへ進まない)" + err = capsys.readouterr().err + assert "suite: unit" in err and DISCOVER in err + assert "tests/test_x.py tests/test_y.py" in err + assert ".ndf/project.json の test.suites[0].scope_command" in err + row = verify.record()["suites"][0] + assert verify.record()["stopped"] is True + assert (row["verdict"], row["source"], row["reference"], row["index"]) == ("defect", "repo", "green", 0) + assert row["paths"] == ["tests/test_x.py", "tests/test_y.py"] + assert row["command"] == verify.ts.fill(DISCOVER, row["paths"]), "I1: 改修の範囲テストと同じ組み立て" + assert row["code"] == 1 + + +def test_a_suite_that_declares_only_dot_stops_as_undecided(verify, capsys): + """受け入れ条件 1・2: `paths` が `.` なら受け持ちが未確定のため `undecided` で止まり、直す箇所と paths の宣言を出す。""" + code, _, _ = verify.call(_suite()) + assert code == 4 + err = capsys.readouterr().err + assert "受けるかを確かめられません" in err and ".ndf/project.json の test.suites[0].scope_command" in err + assert "--round-test" in err + assert verify.record()["suites"][0]["verdict"] == "undecided" + + +def test_the_ci_strategy_stops_as_undecided_without_junit(verify, capsys): + """受け入れ条件 1: CI に任せる戦略では基準が `none` で、unittest は JUnit を書かないため `undecided` で止まる。""" + code, _, _ = verify.call(_suite(paths=["tests"]), strategy="local-scoped-ci-whole") + assert code == 4 + row = verify.record()["suites"][0] + assert (row["verdict"], row["reference"], row["fix_at"]) == ("undecided", "none", ".ndf/project.json の test.suites[0].scope_command") + + +def test_a_template_that_takes_files_continues_and_keeps_the_record(verify, capsys): + """受け入れ条件 6・I6・決定 7: ファイルを受けるテンプレートは `ok` で続き、記録はそのまま `template_checks` へ置かれる。""" + code, prep, baseline = verify.call(_suite(scope_command=f"{PY} -m unittest {{paths}}", paths=["tests"])) + assert code is None + assert prep.template_checks == verify.record()["suites"] + assert [r["verdict"] for r in prep.template_checks] == ["ok"] + assert "template_checks" not in baseline and baseline["status"] == "green", "着手前のテストの記録は書き換えない" + assert "✅ 範囲テストのテンプレートを試走: unit" in capsys.readouterr().err + + +def test_a_pytest_template_continues(verify): + """受け入れ条件 6: このリポジトリと同じ pytest の雛形は試走が通る。""" + pytest_suite = _suite( + scope_command=f"{PY} -m pytest -q -p no:cacheprovider {{paths}}", command=f"{PY} -m pytest -q -p no:cacheprovider tests" + ) + code, prep, _ = verify.call(pytest_suite, scope=("scripts/storylib", "tests")) + assert code is None + row = prep.template_checks[0] + assert (row["verdict"], row["source"], row["paths"]) == ("ok", "scope", ["tests/test_x.py", "tests/test_y.py"]) + + +def test_lint_and_unscoped_suites_are_not_probed(verify, monkeypatch): + """受け入れ条件 7・I7: 静的解析の suite と scope_command の無い suite は試走しない。試走は suite ごとに 1 回(I2)。""" + template_check = sys.modules["refactor_lib.template_check"] + seen = [] + real = template_check.run_with_timeout + + def counting(command, cwd, timeout, output=None): + seen.append(command) + return real(command, cwd, timeout, output=output) + + monkeypatch.setattr(template_check, "run_with_timeout", counting) + lint = {"name": "lint", "kind": "lint", "command": "true", "scope_command": "false {paths}", "paths": ["."]} + plain = {"name": "plain", "command": "true"} + code, prep, _ = verify.call(_suite(scope_command=f"{PY} -m unittest {{paths}}", paths=["tests"]), extra=(lint, plain)) + assert code is None + assert [r["suite"] for r in prep.template_checks] == ["unit"] + assert seen == [f"{PY} -m unittest tests/test_x.py tests/test_y.py"] + + +def test_no_time_left_stops_without_running(verify, monkeypatch): + """I2: 着手前のテストで上限を使い切ったら走らせずに `undecided` で止まる。""" + setup = verify.setup + real = setup.baseline_lib.run_baseline + monkeypatch.setattr( + setup.baseline_lib, + "run_baseline", + lambda strategy, work, timeout, *a: {**real(strategy, work, timeout, *a), "seconds": float(timeout)}, + ) + code, _, _ = verify.call(_suite(scope_command=f"{PY} -m unittest {{paths}}", paths=["tests"])) + assert code == 4 + row = verify.record()["suites"][0] + assert (row["verdict"], row["outcome"], row["code"]) == ("undecided", "not_run", None) + assert "上限の残りが無い" in row["reason"] + + +def test_an_argument_template_names_the_argument(verify, capsys): + """受け入れ条件 2: `--round-test` の雛形で止まったときは、直す箇所を引数の名前で出す。""" + code, _, _ = verify.call(_suite(paths=["tests"]), round_test=DISCOVER) + assert code == 4 + row = verify.record()["suites"][0] + assert (row["suite"], row["index"], row["fix_at"]) == ("args", None, "--round-test の値") + assert "--round-test の値" in capsys.readouterr().err diff --git a/plugins/ndf/skills/cross-review/SKILL.md b/plugins/ndf/skills/cross-review/SKILL.md index b16ba87db..55dd7c167 100644 --- a/plugins/ndf/skills/cross-review/SKILL.md +++ b/plugins/ndf/skills/cross-review/SKILL.md @@ -167,7 +167,9 @@ JSON の形と終了コードの表は共通ライブラリの `scripts/lib/driv 再開は同じコマンドを打ち直すだけである。進みは耐久の記録(`~/.local/state/ndf/dbos/review-<鍵>.sqlite`)と state.json にあり、pause の 結果ファイルがあればその続きから進む。`metrics` は state.json から数えた件数(`rounds` / `prs` / `findings` / -`fixed` / `deferred` / `rejected` / `unresolved` / `final` / `review_status`)である。 +`fixed` / `deferred` / `rejected` / `unresolved` / `final` / `review_status` / `ci` / `ci_reason`)である。`ci` は最後のラウンドの +継続的統合の判定(`ci.verdict`。無ければ `null`)、`ci_reason` はその判定が `unverified`・`meta_only`・`pending` のときの理由 +(`unverified` の理由・`meta_only` の注記・未完了のチェックジョブの名前)で、ほかは `null` である。 **終わったレビューにもう一度回すときも、同じコマンドを打ち直すだけである。** state.json を開かない・`mv` しない・ 書き換えない。終わりの時点の PR の head と今の head が違えば(差分を足した)、`state.py init` が `final` を外して diff --git a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md index 1da272afb..65a6bc557 100644 --- a/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md +++ b/plugins/ndf/skills/cross-review/docs/02-fix-and-rotation.md @@ -293,8 +293,8 @@ while ループ脱出後にメインが以下のプロンプトでサブエー > 対象リポジトリを見て決める。**コマンドを推測して組み立てない。** > > 対象リポジトリに `.ndf/project.json` の `test` があれば、駆動(`drive.py`)がプロンプトに宣言の全体テスト -> (テストと静的解析の両方の suite の `command`)を並べる。そのときは並んだコマンドを順に実行し、下の探し方は使わない。 -> 起動の引数 `--verify-command`(実行検証 Step 2.5 の許可の一覧)は、ここでは使わない。宣言が無いときだけ次の手順で探す。 +> (テストと静的解析の両方の suite の `command`)を並べる。そのときは並んだコマンドを順に実行し、下の探し方は使わない。起動の引数 `--verify-command`(実行検証 Step 2.5 の許可の一覧)は、ここでは使わない。 +> 宣言が無いときと、テストの無い宣言(`strategy` を書かず、どの suite も `command` と `scope_command` を持たない。`{"suites": []}` を含む)のときだけ次の手順で探す。 > > 1. 実行手段を探す。`Makefile` の `test` / `lint` / `check` ターゲット、`package.json` の > `scripts`、`pyproject.toml` の `[tool.pytest.ini_options]`、`composer.json` の diff --git a/plugins/ndf/skills/cross-review/scripts/drive.py b/plugins/ndf/skills/cross-review/scripts/drive.py index e54a8d9c5..ae4b1b969 100755 --- a/plugins/ndf/skills/cross-review/scripts/drive.py +++ b/plugins/ndf/skills/cross-review/scripts/drive.py @@ -12,9 +12,7 @@ 立てて続きを待ち、打ち直しが続き(`resume`)を送る。結果ファイルが無ければ同じ止まりを番号を増やして返す。 途中のスクリプトが失敗したときも stopped を返して同じように待ち、打ち直しで同じ段階をやり直す。 -止まるときの JSON の形と終了コードの表は `scripts/lib/drive_pause.py` にある。 - -件数(metrics)は状態ファイルから数える: rounds / prs / findings / fixed / deferred / rejected / unresolved / final / review_status。 +止まるときの JSON の形と終了コードの表は `scripts/lib/drive_pause.py`、件数(metrics)のキーの一覧は SKILL.md の「実行」の節にある。 """ from __future__ import annotations @@ -57,10 +55,8 @@ def review_paths(): def sweep_verify_lines(worktree) -> str: """最終スイープの検証のコマンドの段落(#1483 I13)。宣言の `test` があれば全体テスト(両方の種別)を名指しする。 - - 宣言が無い・読めなければ空で、プロンプトは今の探し方(Step 7.5)のまま。`test` があって解けない(不正な - `kind` など)ときは、どのキーが不正かを添えて `Stop` で止める。`--verify-command` は読まない。 - """ + 宣言が無い・読めない・テストの無い宣言(#1593)なら空で、プロンプトは今の探し方(Step 7.5)のまま。`test` があって + 解けない(不正な `kind` など)ときは、どのキーが不正かを添えて `Stop` で止める。`--verify-command` は読まない。""" if not worktree: return "" import project_decl @@ -73,7 +69,7 @@ def sweep_verify_lines(worktree) -> str: try: commands = ts.verify_commands(decl) except ts.StrategyError as e: - raise Stop(f".ndf/project.json の test が不正: {e}", 2) from e + raise Stop(f"最終スイープの指示を組めない: .ndf/project.json の test が不正: {e}", 2) from e if not commands: return "" listed = "\n".join(f" - {c}" for c in commands) @@ -142,6 +138,9 @@ def counts(self) -> dict: `findings` は修正担当が扱った指摘(各ラウンドの直した・見送った・却下したの和)で、`findings >= fixed` が 数え方で成り立つ。レビュー担当が出したコメントの数は `comments` で別に出す(1 つのコメントに複数の指摘が 入り、2 者が同じ所を指せば 2 件になるため、指摘の数と比べない)。収束の判定はこの値を読まない。""" + review_paths() # 継続的統合の判定の写し(ci_counts)は review_lib にある + from review_lib.ci import ci_counts + s = self.state() rounds = s.get("rounds") or [] comments = fixed = deferred = rejected = 0 @@ -164,6 +163,7 @@ def counts(self) -> dict: "unresolved": sweep.get("remaining_open"), "final": s.get("final"), "review_status": review_status(s), + **ci_counts(rounds), } # --- 止まる --- diff --git a/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py b/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py index 3e6eb9cd4..006247c58 100644 --- a/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py +++ b/plugins/ndf/skills/cross-review/scripts/review_lib/ci.py @@ -141,3 +141,11 @@ def _round_ci(st: dict[str, Any], last: dict[str, Any], pr: int) -> dict[str, An if c.pending: return {"verdict": "pending", "sha": sha, "pending": c.pending} return {"verdict": "success", "sha": sha} + + +def ci_counts(rounds: list) -> dict: + """最後のラウンドの継続的統合の判定(`ci`)と理由(`ci_reason`)。判定が無ければ両方 `None`(#1593 I3)。""" + ci = (rounds[-1].get("ci") if rounds else None) or {} + verdict = ci.get("verdict") + reason = {"unverified": ci.get("reason"), "meta_only": ci.get("note"), "pending": " ".join(ci.get("pending") or []) or None} + return {"ci": verdict, "ci_reason": reason.get(verdict)} diff --git a/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py b/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py index 8ee48433b..dd4197a8d 100644 --- a/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py +++ b/plugins/ndf/skills/cross-review/tests/test_drive_sweep_verify.py @@ -1,4 +1,5 @@ -"""drive.py の最終スイープの検証コマンド: 不正な `test` 宣言は既定の探し方へ流さず止める(#1483 I13)。""" +"""drive.py の最終スイープの検証コマンド: 不正な `test` 宣言は既定の探し方へ流さず止める(#1483 I13)。 +テストの無い宣言は止めずに今の探し方へ進み、件数に最後のラウンドの継続的統合の判定が載る(#1593)。""" from __future__ import annotations @@ -24,6 +25,7 @@ def _load(): cr = _load() +from review_lib.ci import ci_counts # noqa: E402 conftest が scripts を読めるようにする def _decl(root: Path, decl: dict) -> Path: @@ -43,6 +45,50 @@ def test_a_valid_declaration_names_the_commands(tmp_path): def test_an_invalid_declaration_stops_with_the_key(tmp_path): root = _decl(tmp_path, {"test": {"suites": [{"name": "py", "kind": "bogus", "command": "pytest ."}]}}) - with pytest.raises(cr.Stop, match=r"test\.suites\[0\]\.kind") as e: + with pytest.raises(cr.Stop, match=r"^最終スイープの指示を組めない: .*test\.suites\[0\]\.kind") as e: cr.sweep_verify_lines(root) assert e.value.code == 2 + + +def test_a_declaration_without_tests_keeps_the_default_search(tmp_path): + """#1593 I1 — 解析が「テストが無い」と測った宣言では止めず、今の探し方の段落(空)を返す。""" + assert cr.sweep_verify_lines(_decl(tmp_path, {"test": {"suites": []}})) == "" + + +def test_a_wrong_kind_without_command_still_stops(tmp_path): + """#1593 I2 — command の無い suite でも、種別の誤りは終了コード 2 で止める。""" + with pytest.raises(cr.Stop, match=r"test\.suites\[0\]\.kind") as e: + cr.sweep_verify_lines(_decl(tmp_path, {"test": {"suites": [{"name": "py", "kind": "unit"}]}})) + assert e.value.code == 2 + + +UNVERIFIED = {"verdict": "unverified", "reason": "チェックジョブを照会できない(未 push・権限・チェックジョブ 0 件のいずれか)"} + + +@pytest.mark.parametrize( + "ci, expected", + [ + (UNVERIFIED, ("unverified", UNVERIFIED["reason"])), + ({"verdict": "meta_only", "note": "メタチェックのみ失敗", "pending": []}, ("meta_only", "メタチェックのみ失敗")), + ({"verdict": "pending", "pending": ["build", "lint"]}, ("pending", "build lint")), + ({"verdict": "success", "sha": "abc"}, ("success", None)), + (None, (None, None)), + ], +) +def test_counts_carry_the_last_round_ci(ci, expected): + """#1593 I3 — 件数の `ci`・`ci_reason` は最後のラウンドの判定から写す(最初のラウンドは読まない)。""" + rounds = [{"ci": {"verdict": "code_failure", "failed": ["x"]}}, {"ci": ci} if ci else {}] + got = ci_counts(rounds) + assert (got["ci"], got["ci_reason"]) == expected + assert ci_counts([]) == {"ci": None, "ci_reason": None} + + +def test_counts_keep_the_existing_keys(monkeypatch): + """#1593 I4 — 足すのは `ci`・`ci_reason` だけで、`review_status` ほかの値は変えない。""" + state = {"final": "approved", "sweep": None, "rounds": [{"fix": {"fixed": 1}, "ci": UNVERIFIED}]} + d = cr.Drive(1, "light", []) + monkeypatch.setattr(d, "state", lambda: state) + counts = d.counts() + assert counts["review_status"] == cr.review_status(state) == "unverified" + assert (counts["final"], counts["fixed"], counts["rounds"], counts["ci"]) == ("approved", 1, 1, "unverified") + assert counts["ci_reason"] == UNVERIFIED["reason"] diff --git a/plugins/ndf/skills/development-workflow/references/agent-layers.md b/plugins/ndf/skills/development-workflow/references/agent-layers.md index 5732af53d..156452ceb 100644 --- a/plugins/ndf/skills/development-workflow/references/agent-layers.md +++ b/plugins/ndf/skills/development-workflow/references/agent-layers.md @@ -82,8 +82,8 @@ context window を埋めるときは、**読解だけを worker へ出す**(co | 実装 | `new sprint` の実装のプラン(課題ごと。スプリントブランチ宛て)。スプリントブランチはその前のステージのプランが切る。単発は `new impl` | スプリントブランチ → 実装 | — | | 検査 | `new sprint` の検査のプラン(スプリントの Pull Request を出し、構造改善・コードレビュー・完了判定を通してマージする)。単発は `new check`、`pace: fast` は `new check --since-last` | 検査 | — | | 取り込み | スプリントの Pull Request のマージは検査のプランが、検証リリースは開発版のリリースプランが持つ(後片付けは本番のリリースプランの最後のステップ) | 配布(検査のキューが `--then` で流す) | 確定仕様化(`plan-to-spec`)。worker へ委ねるときは全課題を 1 件の worker に委ね、起動指示の「手順」へ `plan-to-spec` の抜粋(`references/excerpt.md`)をコピーする。`normal` のプランはこの工程を持たない(`pace: fast` は `new close` が持つ) | -| 仕上げ | 本番のリリースプラン `new release --channel prod`(本番へのリリース・リリース後テスト・後片付け) | 目録(`/sprint.json`)のステージの外。ゲート 2 の承認の後に流す | 振り返り(`retrospective`)と棚卸し(`backlog-refinement`)。同じ supervisor が振り返りに続けて棚卸しを通す。`normal` のプランはこの 2 つの工程を持たない(`pace: fast` は `new close` が持つ) | -| リリース | `new release --channel dev` / `--channel prod` | 配布 | —(プランだけが通す) | +| 仕上げ | `new sprint` の本番のリリースプラン(本番へのリリース・リリース後テスト・後片付け)。単発は `new release --channel prod` | `関門 2` → `本番`(ゲート 2 の承認の後に、`本番` のステージの `command` を単独で流す) | 振り返り(`retrospective`)と棚卸し(`backlog-refinement`)。同じ supervisor が振り返りに続けて棚卸しを通す。`normal` のプランはこの 2 つの工程を持たない(`pace: fast` は `new close` が持つ) | +| リリース | `new sprint` の開発版と本番のリリースプラン。単発は `new release --channel dev` / `--channel prod` | 配布・本番 | —(プランだけが通す) | **supervisor へ落とすのは、次の 2 つのときだけである。** diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index aa0e937cc..048d8ae6d 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -1,7 +1,7 @@ # 開発ループ(`--loop`) スプリントを 1 つ終えるたびに、棚卸しの順位から次のスプリントを機械で選び、`pace: auto` で周回を続ける進め方の手順。 -設計の正本は `issues/issue-2089-design.md`(確定仕様化の後は `docs/`)にある。 +決定の理由・契約・テスト観点は、ai-plugins の確定仕様 `docs/specifications/ndf-workflow-development-loop.md` にある。 ## 具体例: ループ `l20261025` の 1 周 diff --git a/plugins/ndf/skills/development-workflow/references/pace.md b/plugins/ndf/skills/development-workflow/references/pace.md index 8061eb748..806394f96 100644 --- a/plugins/ndf/skills/development-workflow/references/pace.md +++ b/plugins/ndf/skills/development-workflow/references/pace.md @@ -228,7 +228,11 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの 「本番」(か「導入の確認」)と「リリース」が両方あれば、この順に置く。昇格のプランは `normal` では `promote`(承認の引数無し。 承認ゲート 2 で終える)→ 承認の後に `run <プラン> --from promote-approved`、`fast` / `auto` では `verify`(導入の確認)→ `prepare`(Pull Request と承認資料)→ `mvv`(承認ゲート 2 の MVV 判定)→ `note` → `promote`(`--gate-approved mvv`)で、 -`note` か `promote` が落ちたら `handoff` が承認ゲートへ落とす。`fast` / `auto` の `merge` の経路では開発版のステージを置かない +`note` か `promote` が落ちたら `handoff` が承認ゲートへ落とす。`fast` / `auto` の `promote` は、`mvv` が読んだのと同じ +`approval-verify.md` を `--expect-head-from` に受け、そこに書かれた確かめたコミット(導入の確認を走らせたベースブランチの先頭)が +昇格の Pull Request の先端のときだけマージする。確認の後にベースブランチが進んで先端が違えば(CI を待つ間に進んだときも) +`gh pr merge` を呼ばずに `head_moved` で 1、資料から確かめたコミットを読めなければマージせずに 2 で止まり、どちらも `handoff` が +承認ゲート 2 の `by: mvv` の記録を外して承認ゲートへ落とす。`fast` / `auto` の `merge` の経路では開発版のステージを置かない (ベースブランチへのマージが検証への反映である)。 **`fast` / `auto` の `merge` の経路では、導入の確認(`<節>.verify`)を 1 つのスプリントの中で 1 度だけ走らせる。** 昇格のプランが @@ -260,7 +264,7 @@ glob の `**` は区切りをまたぎ、`*` と `?` はまたがない。どの | 答え | 打つもの | | --- | --- | | 承認(ゲート 1) | `sprint-state.py gate <状態> "関門 1" --what <要約> --by user --outcome approved` → `supervise.py run <設計のプラン> --from approve` → マニフェストのスプリントブランチのステージの `resume` | -| 承認(ゲート 2) | `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved` → `supervise.py run <本番のプラン> --from bump`(昇格のプランは `--from promote-approved`、マージの `merge-gate` で止まったプランは `--from merge-approved`。`gate-2` のプランは続きを流さず、手で行う「本番」のステージへ進む) | +| 承認(ゲート 2) | `sprint-state.py gate <状態> "関門 2" --what <要約> --by user --outcome approved` → `supervise.py run <本番のプラン> --from bump`(昇格のプランは `--from promote-approved`。ただし `promote` が照合で止まった(`head_moved`・確かめたコミットを読めない)ときは `--from verify` から流し直す。`promote-approved` は照合を持たず、導入の確認も MVV 判定もしていない今の先端をマージする。マージの `merge-gate` で止まったプランは `--from merge-approved`。`gate-2` のプランは続きを流さず、手で行う「本番」のステージへ進む) | | 差し戻し | `sprint-state.py gate <状態> "関門 N" --what <要約> --by user --outcome rejected`。続きは流さない。差し戻しは状態ファイルでなく差し戻しの記録(`gate-rejections.jsonl`)へ 1 行残る。状態ファイルの無い進め方の打ち方は [approval-request.md](approval-request.md) の「答えを記録する」 | **判定と食い違う答えは覆しとして残る。** 「従う」で自動に通った後の差し戻しは `override_reject`、「従わない」「判定できない」で diff --git a/plugins/ndf/skills/development-workflow/references/project-analysis.md b/plugins/ndf/skills/development-workflow/references/project-analysis.md index 172210b08..471d99454 100644 --- a/plugins/ndf/skills/development-workflow/references/project-analysis.md +++ b/plugins/ndf/skills/development-workflow/references/project-analysis.md @@ -68,7 +68,7 @@ CI の壁時計(`ci.workflows[].wall_seconds`)とテストの所要の `ci-s | 項目 | 答えの `value` の形 | 決め方 | | --- | --- | --- | -| `test`(P2) | `{"strategy"?, "ci"?: {"check"?, "junit_artifacts"?}, "suites": [{"name", "runner", "command", "scope_command"?, "junit"?, "container"?: {"service", "compose_files"}, "needs", "paths", "kind"?}]}` | 指示書・README の走らせ方の行を正とする。コンテナ越しなら `container` を書き、`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)にシェルの引用で守った範囲のパスが入る。`command` と `scope_command` はシェルで走らせる。`kind` は suite の種別で、`test`(既定)か `lint`(静的解析。整形の検査を含む)。静的解析の suite は変更したファイルのうち `paths` に当たるものにかかり、`paths` の要素には接頭辞のほかに glob(`*.sh` など)を書ける。`junit` はコマンドが JUnit XML を書く相対パス(pytest は `-o junit_family=xunit1 --junitxml=<パス>` をコマンドに書く。無ければ落ちたテストの見分けが全体の走らせ直しへ落ちる)。`strategy` は `local-full`(全体テストを手元で走らせる)/ `local-scoped-ci-whole`(範囲は手元、全体は CI に任せる。`ci.check` に待つチェック、`ci.junit_artifacts` に JUnit の成果物の名前の glob)/ `round-only`(パスでテストを選べない。suite の `command` をそのまま走らせる)。空なら所要と suite から導く(600 秒を超え CI が読めれば `local-scoped-ci-whole`。`scope_command` が無ければ `round-only`。ほかは `local-full`)ので、その既定を答えとして示し、手元で全体テストを回さない方針があるときだけ書き換える | +| `test`(P2) | `{"strategy"?, "ci"?: {"check"?, "junit_artifacts"?}, "suites": [{"name", "runner", "command", "scope_command"?, "junit"?, "container"?: {"service", "compose_files"}, "needs", "paths", "kind"?}]}` | 指示書・README の走らせ方の行を正とする。コンテナ越しなら `container` を書き、`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)にシェルの引用で守った範囲のパスの並びが入る。入るパスの種類は場所で違い、cross-refactoring の改修の範囲テストではテストのファイルのパス(計画の `test_targets` の `::` より前)、全体テストを CI に任せる戦略の着手前のテストでは `--scope` のテストの置き場所(ディレクトリかファイル)、静的解析の suite では変更したファイルのパスが入る。テストの `scope_command` はテストのファイルのパスを受けるコマンドで書く。ディレクトリしか受けないランナーは、範囲テストをファイルで受ける形と全体テストをディレクトリで走らせる形に分ける(unittest なら `scope_command` を `python3 -m unittest {paths}`、`command` を `python3 -m unittest discover -s <テストのディレクトリ>` にする。`python3 -m unittest tests/test_x.py tests/test_y.py` と `discover -s tests` はどちらも終了コード 0、`discover -s tests/test_x.py` は 1、テストの無いディレクトリを渡した `discover -s scripts/storylib` は 5 で終わる)。ファイルを受けない `scope_command` は、cross-refactoring の `init` がテストのファイルを入れた試走で見分けて改修の前に止める。`command` と `scope_command` はシェルで走らせる。`kind` は suite の種別で、`test`(既定)か `lint`(静的解析。整形の検査を含む)。静的解析の suite は変更したファイルのうち `paths` に当たるものにかかり、`paths` の要素には接頭辞のほかに glob(`*.sh` など)を書ける。`junit` はコマンドが JUnit XML を書く相対パス(pytest は `-o junit_family=xunit1 --junitxml=<パス>` をコマンドに書く。無ければ落ちたテストの見分けが全体の走らせ直しへ落ちる)。`strategy` は `local-full`(全体テストを手元で走らせる)/ `local-scoped-ci-whole`(範囲は手元、全体は CI に任せる。`ci.check` に待つチェック、`ci.junit_artifacts` に JUnit の成果物の名前の glob)/ `round-only`(パスでテストを選べない。suite の `command` をそのまま走らせる)。空なら所要と suite から導く(600 秒を超え CI が読めれば `local-scoped-ci-whole`。`scope_command` が無ければ `round-only`。ほかは `local-full`)ので、その既定を答えとして示し、手元で全体テストを回さない方針があるときだけ書き換える | | `branches`(P6) | `{"base": "<起点>", "production": "<本番>"}` | origin の HEAD・直近 100 件のマージ先・最後のコミットの日時を見る。長くコミットの無いブランチ(`develop` など)は採らない | | `delivery`(P7) | `[{"target", "kind": "auto" / "manual", "trigger", "branch"?, "versioned", "production"?}]` | 配布の設定ファイル・ワークフローの `deploy` の行・版数の置き場とタグから決める。`production`(本番系へ届くか。[pace.md](pace.md) の「手動反映の本番系の形」)は解析では書かず、利用者が書く | | `issues`(P8) | `{"primary", "others"}`。名前は `github`・`markdown`・`redmine`・`external` など | 名前だけを書き、URL や識別子を書かない | diff --git a/plugins/ndf/skills/development-workflow/references/relay.md b/plugins/ndf/skills/development-workflow/references/relay.md index 1cb9931fb..80e4a1ded 100644 --- a/plugins/ndf/skills/development-workflow/references/relay.md +++ b/plugins/ndf/skills/development-workflow/references/relay.md @@ -356,14 +356,14 @@ export NDF_SUPERVISE_CLAUDE_FALLBACK='CLAUDE_CODE_USE_BEDROCK=1 AWS_PROFILE=<名 - `sprint-state.py next --doc <引継ぎ文書> --replace`(「次に実行するコマンド」の節を置き換え、同じ `ndf-next` のブロックを最後の応答に出す) - `handoff.py check <名> --trim`(「前の会話の進み」を履歴へ移し、節の形と 300 行の上限を確かめる。1 か 5 のときの扱いは [handoff.md](handoff.md) の「作る・更新する」) -**本番へのリリースの後は、カットポイントの呼び出しを本番のキューと同じバックグラウンドの Bash で続けて流す。** 流す前に conductor が `handoff.py init` を打ち、本番の後の「現在地」と「次にやること」を文書へ書く(パイプラインの中では書き直せない)。`check` が 1 か 5 ならパイプラインはブロックを出さずに止まり、conductor が完了の通知で終了コードを受けて扱ってからブロックを出す。ゲート 2 の承認から次のセッションの起動までに、conductor が組み立てる文は無くなる。conductor は完了の通知を受けたら `relay.py notice` のアナウンスと、出力の `ndf-next` のブロックをそのまま出す。ラッパーがプラグインを本番の版へ更新し、次のセッションを起動する。 +**本番へのリリースの後は、カットポイントの呼び出しを本番のキューと同じバックグラウンドの Bash で続けて流す。** 流す前に conductor が `handoff.py init` を打ち、本番の後の「現在地」と「次にやること」を文書へ書く(パイプラインの中では書き直せない)。`check` が 1 か 5 ならパイプラインはブロックを出さずに止まり、conductor が完了の通知で終了コードを受けて扱ってからブロックを出す。ゲート 2 の承認から次のセッションの起動までに、conductor が組み立てる文は無くなる。conductor は完了の通知を受けたら `relay.py notice` のアナウンスと、出力の `ndf-next` のブロックをそのまま出す。ラッパーがプラグインを本番の版へ更新し、次のセッションを起動する。本番のキューは、目録(`$O/sprint.json`)の `本番` のステージの `command` に `--done` を足したものである(例の `9` はステージの番号)。 ```bash O=~/.local/state/ndf/sv/r7; M=plugins/ndf/scripts/sprint-state.py; H=plugins/ndf/scripts/handoff.py; N=sprint-<スプリント名> DOC=$(python3 $H path $N | python3 -c 'import json, sys; print(json.load(sys.stdin)["items"][0]["path"])') python3 $H init $N --title "<表示名>" >/dev/null && -python3 plugins/ndf/scripts/supervise.py queue $O/plan-release-prod.json --done $O/done-release-prod.json >/dev/null && -python3 plugins/ndf/scripts/supervise.py wait $O/done-release-prod.json --timeout 3600 >/dev/null && +python3 plugins/ndf/scripts/supervise.py queue $O/9-release-prod.json --max 3 --done $O/done-9.json >/dev/null && +python3 plugins/ndf/scripts/supervise.py wait $O/done-9.json --timeout 3600 >/dev/null && python3 $M update $O/sprint-state.json >/dev/null && python3 $M render $O/sprint-state.json $DOC --demote 前の会話の進み --heading "今の会話の進み($(date -u +%Y-%m-%d\ %H:%M) UTC まで)" >/dev/null && python3 $M next $O/sprint-state.json --doc $DOC --replace >/dev/null && diff --git a/plugins/ndf/skills/development-workflow/references/waiting.md b/plugins/ndf/skills/development-workflow/references/waiting.md index fadbf2a4d..f85904084 100644 --- a/plugins/ndf/skills/development-workflow/references/waiting.md +++ b/plugins/ndf/skills/development-workflow/references/waiting.md @@ -164,9 +164,10 @@ conductor が起きるのは、承認ゲート・`attention`・キューの終 `sv wait $O/done-6.json`。検査のプランがスプリントの Pull Request をベースブランチへマージし、開発版のリリースプランが続けて流れる。 スプリントの Pull Request の本文に「手動確認」のマージ前の行があれば、印が付くまで `merge-gate` が承認ゲートで止まる (利用者が印を付けたら `sv run $O/6-check.json --from merge-gate`) -8. 承認ゲート 2: キューの結果が `gate`(開発版の facts のステップ)なら、承認資料を添えて本番の承認を取る -9. 本番: `sv new release --version 10.18.0 --prs <スプリントの PR 番号> --channel prod --worktree <リポジトリの根>/.worktrees/release/v10.18.0 --out $O/8-release-prod.json`、 - 続けて `sv queue $O/8-release-prod.json --done $O/done-8.json` と `sv wait $O/done-8.json`。最後のステップが後片付けを行う +8. 承認ゲート 2: キューの結果が `gate`(開発版の facts のステップ)なら、`sprint.json` の `関門 2` のステージの `gate` に沿って + 承認資料を添えて本番の承認を取る。差し戻されたら 9 を打たない +9. 本番: `sprint.json` の `本番` のステージの `command` に `--done` を足して打つ(`sv queue $O/9-release-prod.json --max 3 --done $O/done-9.json`)と + `sv wait $O/done-9.json`。出す PR は検査の報告から埋まる。最後のステップが後片付けを行う - ステージの番号とプランのファイル名は `new sprint` の出力(`sprint.json` の `ステージ`)が正である。書き出した `command` に `--done` を足して打つ - 確定仕様化・振り返り・棚卸しは `normal` のプランが持たないため、supervisor で回す([agent-layers.md](agent-layers.md) の表の取り込み・仕上げの行) diff --git a/plugins/ndf/skills/development-workflow/schemas/project.schema.json b/plugins/ndf/skills/development-workflow/schemas/project.schema.json index 5aa74ab62..a59d9ac91 100644 --- a/plugins/ndf/skills/development-workflow/schemas/project.schema.json +++ b/plugins/ndf/skills/development-workflow/schemas/project.schema.json @@ -415,7 +415,7 @@ }, "Suite": { "additionalProperties": false, - "description": "テストの 1 まとまり。`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)に、シェルの引用で守った範囲の\nパスが入る。`command` と `scope_command` はシェルで走らせる。\n`junit` はコマンドが JUnit XML を書く作業ディレクトリからの相対パス(無ければ落ちたテストの見分けは走らせ直しへ落ちる)。\n`kind` は種別で、`test`(テスト)か `lint`(静的解析。整形の検査を含む)。書かなければ `test`。静的解析の suite の範囲は\n変更したファイルのうち `paths` に当たるもの。`paths` の要素は接頭辞(ディレクトリ)か glob(`*.sh` など)で、空ならすべて。\n`ci_jobs` はこの suite が同じ検査を手元で走らせる継続的統合のジョブの識別子で、cross-refactoring の `init` が\n宣言に無いジョブを知らせるときに見る。", + "description": "テストの 1 まとまり。`scope_command` の `{paths}`(引用の外の、空白で区切った 1 語)に、シェルの引用で守った範囲の\nパスの並びが入る。入るパスの種類は場所で違う: cross-refactoring の改修の範囲テストではテストのファイルのパス\n(計画の `test_targets` の `::` より前)、全体テストを CI に任せる戦略の着手前のテストでは `--scope` のテストの置き場所\n(ディレクトリかファイル)、静的解析(`kind: lint`)では変更したファイルのパス。テストの `scope_command` はテストの\nファイルのパスを受けるコマンドで書く(unittest なら `discover -s {paths}` ではなく `python3 -m unittest {paths}`)。\nファイルを受けない `scope_command` は、cross-refactoring の `init` がテストのファイルを入れた試走で見分け、改修の前に\n止める。`command` と `scope_command` はシェルで走らせる。\n`junit` はコマンドが JUnit XML を書く作業ディレクトリからの相対パス(無ければ落ちたテストの見分けは走らせ直しへ落ちる)。\n`kind` は種別で、`test`(テスト)か `lint`(静的解析。整形の検査を含む)。書かなければ `test`。静的解析の suite の範囲は\n変更したファイルのうち `paths` に当たるもの。`paths` の要素は接頭辞(ディレクトリ)か glob(`*.sh` など)で、空ならすべて。\n`ci_jobs` はこの suite が同じ検査を手元で走らせる継続的統合のジョブの識別子で、cross-refactoring の `init` が\n宣言に無いジョブを知らせるときに見る。", "properties": { "name": { "title": "Name",