diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 7cc7875d6..fc996833c 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -9,7 +9,7 @@ { "name": "ndf", "source": "./plugins/ndf", - "description": "Claude Code plugin (v10.27.16): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.27.18): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/.ndf/pace.json b/.ndf/pace.json index 8751d905f..36b673daa 100644 --- a/.ndf/pace.json +++ b/.ndf/pace.json @@ -26,6 +26,6 @@ "triggers": {"score": 10, "common_weight": 2, "lines": 3000, "escapes": 1, "hours": 24}, "loop": { "enabled": true, - "budget": {"laps": 8, "usd": 200, "hours": 72} + "budget": {"laps": 1000, "usd": 200, "hours": 10000} } } diff --git a/CHANGELOG.md b/CHANGELOG.md index 7639ea194..e73334e89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,12 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.18] - 2026-11-05 + +- Docs: 並列のレーンの画面の見方と、入力欄のサジェストを判断に使わないことを書く(#2502) +- Update: ループの予算の周回と時間を実質無制限にする(#2505 までのつなぎ)(#2506) +- 無し(テストだけの変更)(#2508) + ## [ndf 10.27.16] - 2026-11-04 - Docs: #2436 の並列のオーガナイザーを確定仕様にする(#2471) diff --git a/README.md b/README.md index ceceef30f..ce730a17e 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.16 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.18 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | | **playwright-kit** | 4.0.0 | Playwright による E2E テストの計画・スクリプト作成・証跡のレポート | [README](./plugins/playwright-kit/README.md) | 外部サービスへつなぐ MCP サーバーの設定のプラグインが 10 個あります(Claude Code / Codex 向け)。 @@ -69,7 +69,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は ### NDF プラグインの Skill の一覧 -**NDFプラグイン v10.27.16** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.18** に入る Skill の数は、AI ツールごとに違います。 Claude Code向け core 49個、Codex向け core 45個です。 元Skills(49個)の内訳は次のとおりです。 - Pull Request とレビュー (7): pr, pr-tests, fix, pr-review, cherry-pick-pr, deploy, merged diff --git a/docs/glossary.md b/docs/glossary.md index a3ddcf79f..cddb8f0fc 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -179,6 +179,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | チェック | — | 機械が合否を返すもの。CI のジョブと、mvv-gate.py・doc-lint.py などのスクリプト | — | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | 完了判定 | — | コマンドの証跡で完了を判定する工程。スクラムの完了の定義に当たる | — | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | 全体テスト | — | リポジトリ全体を範囲にするテストか静的解析。テストは宣言の suites[].command、無ければ範囲テストのテンプレートの {paths} を . にしたもの(その旨を注記に残す)。静的解析は宣言の command、無ければテンプレートの {paths} を範囲のパスで埋めたもの(範囲のパスが無ければ組まず、その旨を注記に残す) | 全体のテスト | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | +| 現状固定テスト | — | 構造を変える前の出力をそのまま期待値にしたテスト。構造を変える前後で同じ出力が出ることを縛る(英語の characterization test) | — | — | `plugins/ndf/skills/refactoring/references/characterization-tests.md` | | コメントのスナップショット | — | cross-review が取る既存コメントの一覧。2 ラウンド目以降は取り直す | 既存コメントのスナップショット | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | doc-lint | — | 追加した Markdown の行に、検討の痕跡・課題番号の由来・比較の語が無いかを見るチェック | — | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | 引継ぎ文書 | `handoff` | 会話を切って新しいセッションで続けるための文書。メインディレクトリの .ndf/handoff/<名>.md に置き、コミットしない。1 本が 1 つの引継ぎの対象を受け持つ | — | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | @@ -311,6 +312,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 全体テストの実測 | — | 同じリポジトリで先に走った全体テストの壁時計の秒。所要の記録とは別に残し、後のプランと test-run.py が予算なしのテストの上限を決めるときに読む(#1631) | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | | テストの上限 | `limits` | test_strategy.limits が返す init_test_timeout・test_timeout・whole_timeout(秒)。ステップの timeout はこれに with_margin の余裕を足した値で、テストの上限には余裕を足さない | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | | 全体テストの鍵 | `whole_key` | 戦略の全体テストのコマンドの集合から作る識別子(whole_key)。全体テストの実測をコマンドごとに分け、コマンドの違う実測を混ぜない(#1631) | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | +| 予算の反映 | — | 開発ループの記録の予算を、渡した単位の値で上書きすること(`sprint-state.py loop budget`)。変わったときだけ予算の履歴に 1 行足し、止まった記録の解除や周回の締めを伴わない | — | — | — | +| 予算の履歴 | — | 開発ループの記録が持つ、予算の上書きの記録(`budget_history`)。予算が変わったときだけ、時刻・前の値・後の値の 1 行を足す | — | — | — | +| 人の判断待ちの行 | — | 開発ループの記録が持つ、人の答え(承認・差し戻し・直して出し直す)を待つ課題の行(`waiting`)。`sprint-state.py loop answer` で答えを書く。着手の前の課題の状態を指す「人の判断待ち」とは別の語 | — | — | — | | 問い合わせの区分 | `ask_category` | AskUserQuestion の metadata.source に AI が付ける値。ndf-gate:<番号>(承認ゲート)・ndf-ask:redline:<番号>(レッドライン。宣言で外せない)・ndf-ask:<名前>(宣言が許す区分)の 3 つの形。hook は質問の中身を読まず、この値が許された区分かだけを見る(#2206) | — | — | `docs/specifications/ndf-ask-guard.md` | | 問い合わせの宣言 | `ask_declaration` | プロジェクトの .ndf/ask.json。人へ回してよい問い合わせの区分と hook の働き方(enforce / advise / off)を書く(#2206) | — | — | `docs/specifications/ndf-ask-guard.md` | | 代わりの判断 | `proxy_decision` | hook が問い合わせを止めた後か、手順が「AI が決める」とした場面で AI が自分で下した判断。問い・選んだ答え・理由を問い合わせの記録へ書く(#2206) | — | — | `docs/specifications/ndf-ask-guard.md` | @@ -337,6 +341,12 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 実行の文脈 | `run_context` | 実行の核が本体へ渡す 1 つの値。解決した入力・実行の識別子・期限・記録の書き口・再試行・型の読み書きを持つ(#2335) | — | — | — | | 実行の記録 | `run_log` | 1 回の起動(子の実行を含む)の始まりから終わりまでを、実行の識別子つきで書く JSON Lines のファイル(#2335) | — | — | — | | 上限の宣言 | `limits_declaration` | 上限の秒・再試行の回数・同じ原因の上限を、方針の名前ごとに持つ 1 つのファイル(plugins/ndf/contracts/limits.json。#2335) | — | — | — | +| 方針の名前 | — | 上限の宣言の 1 行を指す名前。入口の契約と部品の呼び出しが、値でなくこの名前で上限を引く(#2343) | — | — | — | +| 原因の鍵 | — | 失敗が前回と同じ原因かを見る値。例外の型と、数字・時刻・パスを伏せた失敗の理由の文から作る(#2343) | — | — | — | +| 再試行 | — | 失敗した同じ呼び出しを、同じプロセスの中で間隔を置いて打ち直すこと。条件が揃うまで問い合わせる待ちとは別である(#2343) | — | — | — | +| ステップの打ち直し | — | プランの判定役(judge)が、失敗したステップを同じステップのまま流し直すと返すこと(#2343) | — | — | — | +| 眠りのループ | — | 本体(入れ子の関数を除く)で sleep を呼ぶ for / while のループ。再試行と待ちの両方が当たり、lib/waits.py の外では構造チェックが落とす(#2343) | — | — | — | +| 最後の照会 | — | 待ちの次の眠りが上限を超えるとき、残りの秒だけ眠った後に上限の時刻に打つ 1 回の照会(#2343) | — | — | — | | 名前の対応表 | `names` | 同じ意味の値の正しい名前と、読むだけ受ける旧い名前の対応(plugins/ndf/contracts/names.json。#2335) | — | — | — | | 受け渡しのファイル | `handoff_file` | あるエントリポイントが書き、別のエントリポイントか Skill の手順が読むファイル(状態・記録・結果)。1 つのエントリポイントの中で作って消す一時ファイルは含まない | — | — | `docs/specifications/ndf-entrypoint-contracts.md` | | 例外リスト | `allow_list` | 構造チェックが違反として数えない今の外れを 1 項目 1 ファイルで持つ置き場。構造チェックごとに置く(scripts/script-structure-allow/・scripts/contract-allow/)。違反に当たらなくなった項目は落ち、減る方向にだけ変える | 許可の一覧 | — | `docs/specifications/ndf-entrypoint-contracts.md` | @@ -383,6 +393,8 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 作業の識別子 | — | 複数のセッションを 1 つの作業として結ぶ鍵。起動の引数かスプリントに含まれる課題番号の組(#829 #830) | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 費用の見込み | — | 判断の帯で、続けた場合と再起動した場合の次の工程までの費用を、記録から較正した中央値で比べた値 | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | `docs/specifications/ndf-context-restart-policy.md` | +| 呼び手 | — | 関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える | — | — | — | +| 残す関数の一覧 | — | 関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの | — | — | — | ## NDF の Slack 通知(`ndf-notification`) @@ -733,10 +745,12 @@ worktree の設定・セッション開始 hook・テスト環境の割り当て | 状態の持ち主 | — | 残る状態を作った実行。持ち主が生きている間は、別の実行がその状態を消さない(#2229) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | | 状態の寿命 | — | 残る状態を片付けてよくなる条件(持ち主のプロセスの終わり・Pull Request が閉じたこと・送り終えたこと など)。状態の種類ごとに 1 つ決める(#2229) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | | 以前の実行 | — | 今の実行より前に同じ Pull Request・同じリポジトリで回った、終わった実行。supervise.py の run --from の「前の実行」とは別(#2229) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | -| 持ち主の記録 | — | 残る状態ごとに、作った実行が書く記録。実行の識別子・開始・最後に動いた時刻・終わった時刻、またはプロセス(pid と開始時刻)を持つ。片付けが持ち主の生存を判定するために読む(#2229) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | -| 寿命の窓 | — | 持ち主の記録が終わっていなくても、最後に動いた時刻からこの時間を過ぎた実行を終わったとみなす長さ。レビュー worktree は 24 時間(#2229)、見直しの持ち主は既定 15 分(#2315) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | +| 持ち主の記録 | — | 持ち主の鍵ごとに 1 つ、状態の置き場の leases/<リポジトリ>/ に置く記録。実行の識別子・入口・開始・最後に動いた時刻・終わった時刻・持ち主のプロセス(pid・開始時刻・ホスト名)を持つ。片付けが持ち主の生存を判定するときに読む(#2229・#2342) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | +| 寿命の窓 | — | 持ち主の記録が終わっていなくても、最後に動いた時刻からこの長さを過ぎた持ち主を死んでいるとみなす長さ。持ち主の部品(leases/)の記録では別のホストの記録にだけ使い、同じホストの生死は pid と開始時刻で決める。値は持ち主の契約の window_seconds に置く。レビュー worktree は 24 時間(#2229・#2342)、見直しの持ち主は既定 15 分(#2315) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | | 片付け | — | 状態の一覧に従い、持ち主の終わった残る状態を外し、残したものと理由を返す処理(run_state.sweep_states)。git が拒めば止まる操作だけで行う(#2229) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | | 持ち主の鍵 | `lease_key` | 持ち主の記録を取り合う単位。入口の契約が、解決した入力から作る形を宣言する(例 cross-refactoring:pr=2301。#2335) | — | — | — | +| 持ち主の契約 | — | 入口の契約の行が持つ lease の宣言。副命令ごとに鍵の形・持ち主の単位・寿命の窓・返す副命令かを決める。見直しの持ち主が実行時に取る記録(持ち主の宣言)とは別の物である(#2342) | — | — | — | +| 持ち主の単位 | — | 持ち主の記録を誰が持つか。run は入口の 1 回の実行で、実行の終わりで返す。session はランタイムのセッション(Claude Code・Codex のプロセス)で、返す入口が呼ばれるかセッションが終わるまで持つ(#2342) | — | — | — | ## mcp-serena(`mcp-serena`) @@ -881,6 +895,7 @@ NDF を Web UI から操作できるサービスとして提供するときの | GitHub App の導入 | — | 利用者が自分のリポジトリへサービスの GitHub App を入れたもの。導入の識別子で指し、clone と push の導入の token をここから作る | — | — | `docs/specifications/ndf-factory-runs-and-runner.md` | | キーの持ち込み | — | 実行の形態 `byok` で、利用者の Anthropic API キーか、引き受ける AWS のロールの ARN をサービスへ預けること | — | — | `docs/specifications/ndf-factory-runs-and-runner.md` | | 再開の要求 | — | 承認の依頼に答えが保存された後、承認と通知が実行の制御へ出す、`resume` の実行ジョブを起こす要求 | — | — | `docs/specifications/ndf-factory-runs-and-runner.md` | +| 再開のジョブ | — | `START=resume` の実行ジョブ。承認の答えで起こすものと、利用上限のリセット時刻の後に起こすものがある。保存した状態を復元して前回の続きから進める | — | — | — | | 打ち切りの時間 | — | 1 つの実行ジョブが動いてよい時間の上限。超えたら実行の制御が止める | — | — | `docs/specifications/ndf-factory-runs-and-runner.md` | | 課題の候補 | — | スプリントを始める画面に、AI が棚卸しの順位から理由つきで出す課題 | — | — | `docs/specifications/ndf-factory-runs-and-runner.md` | | 実行の枠 | — | アカウントとリポジトリごとに、終わりの状態でないスプリントの実行を数えた値。開始で増え、終わりの状態への移りで減る | — | — | `docs/specifications/ndf-factory-runs-and-runner.md` | @@ -888,10 +903,10 @@ NDF を Web UI から操作できるサービスとして提供するときの | 候補の証明 | — | GitHub で確かめたリポジトリの登録の候補を、取り込みが署名して 10 分だけ有効にした値。登録の要求はこれを持って来る | — | — | `docs/specifications/ndf-factory-runs-and-runner.md` | | 同じ役割の写し | — | リファクタリングの対象と同じ処理・同じ判断を、別の場所に書き写したもの。対象と同じ変更で 1 か所へ寄せる(#2199) | — | — | — | | 保存の契約 | — | アカウントの保存(AccountStore)の interface と、その引数・失敗の型。DynamoDB とメモリの 2 つの実装が満たし、AWS SDK を読まない(accounts/port.ts) | — | — | — | -| スプリントの一覧 | — | アカウントのスプリントの実行を新しい順に並べ、それぞれの今のフェーズと状態を出す画面(#2172) | — | — | — | -| スプリントの詳細 | — | 1 つのスプリントの実行の、課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態を出す画面(#2172) | — | — | — | -| 詳細パネル | — | スプリントの詳細で工程の行を押すと右側に出る、その工程の中身(触ったファイル・決めたこと・終了コード・秒・費用・作業の報告)の欄(#2172) | — | — | — | -| バックログの画面 | — | 取り込んだリポジトリの順位の表と棚卸しの記録を、スプリントの外で読む画面(#2172) | — | — | — | +| スプリントの一覧 | — | アカウントのスプリントの実行を新しい順に並べ、それぞれの今のフェーズと状態を出す画面(#2172) | — | — | `docs/specifications/ndf-factory-sprint-views.md` | +| スプリントの詳細 | — | 1 つのスプリントの実行の、課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態を出す画面(#2172) | — | — | `docs/specifications/ndf-factory-sprint-views.md` | +| 詳細パネル | — | スプリントの詳細で工程の行を押すと右側に出る、その工程の中身(触ったファイル・決めたこと・終了コード・秒・費用・作業の報告)の欄(#2172) | — | — | `docs/specifications/ndf-factory-sprint-views.md` | +| バックログの画面 | — | 取り込んだリポジトリの順位の表と棚卸しの記録を、スプリントの外で読む画面(#2172) | — | — | `docs/specifications/ndf-factory-sprint-views.md` | | フェーズの名前 | — | phase.changed と step.recorded の phase が運ぶ、設計・実装・検査・取り込み・仕上げの 5 つ。factory/contracts/src/phases.ts の列挙が正。ほかの名前も捨てずに残し、画面は「その他(<名前>)」と出す(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | | 工程の記録 | — | スプリントの詳細に並ぶ step.recorded の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | | 工程の中身 | — | 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す(#2172) | — | — | `docs/specifications/ndf-factory-service-boundary.md` | @@ -902,13 +917,24 @@ NDF を Web UI から操作できるサービスとして提供するときの | マイルストーンの持ち分 | — | 終わりの状態でない開発ループが、リポジトリのマイルストーン 1 つを選定の対象として占めていることを表す実行の枠の行。同じリポジトリのループどうしで重ならない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 課題の持ち分 | — | 終わりの状態でない手動のスプリントの実行が、リポジトリの課題 1 つを持っていることを表す実行の枠の行。同じ課題を 2 つの実行に入れない | — | — | `docs/specifications/ndf-factory-dev-loops.md` | | 手元のマシンの枠 | — | 1 つの手元のマシンが running で持つ local の実行ジョブの数を数えた実行の枠の行。同時のジョブの数を超えて受け取らせない | 手元のランナーの枠 | — | `docs/specifications/ndf-factory-dev-loops.md` | -| 手元のマシン | — | 利用者の環境に登録し、同時のジョブの数まで実行ジョブを受け取るマシン。受け取った実行ジョブごとに別のランナーが動く | 手元のランナー | — | — | -| Factory のオーガナイザー | — | 同じリポジトリで並行する Factory の実行をまとめる役割。1 台の中のオーガナイザーの仕事すべて(配布を 1 本にまとめる・問いを集める・答えの後の作業を持ち主へ返す・共有の作業を一時のレーンへ渡す)に、Factory 固有の仕事(実行ジョブの起動・停止・答えでの再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場)を足したもの。API の handler と見回りの中で動き、常駐しない。1 台の中のオーガナイザーと同じ層の別の実装で、互いに排他 | — | — | — | -| 配布の実行 | — | Factory のオーガナイザーが、まとめた配布 1 つのために起こすスプリントの実行。範囲のどの実行でもなく、承認資料を作り、本番の配布を流し、結果を返して終わる。同じリポジトリで同時に 1 本まで | — | — | — | +| 手元のマシン | — | 利用者の環境に登録し、同時のジョブの数まで実行ジョブを受け取るマシン。受け取った実行ジョブごとに別のランナーが動く | 手元のランナー | — | `docs/specifications/ndf-factory-organizer.md` | +| Factory のオーガナイザー | — | 同じリポジトリで並行する Factory の実行をまとめる役割。1 台の中のオーガナイザーの仕事すべて(配布を 1 本にまとめる・問いを集める・答えの後の作業を持ち主へ返す・共有の作業を一時のレーンへ渡す)に、Factory 固有の仕事(実行ジョブの起動・停止・答えでの再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場)を足したもの。API の handler と見回りの中で動き、常駐しない。1 台の中のオーガナイザーと同じ層の別の実装で、互いに排他 | — | — | `docs/specifications/ndf-factory-organizer.md` | +| 配布の実行 | — | Factory のオーガナイザーが、まとめた配布 1 つのために起こすスプリントの実行。範囲のどの実行でもなく、承認資料を作り、本番の配布を流し、結果を返して終わる。同じリポジトリで同時に 1 本まで | — | — | `docs/specifications/ndf-factory-organizer.md` | | 画面の地図 | — | Factory の全画面の経路・役割ごとの主な動線・確定仕様の機能との対応を 1 つにまとめた表 | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| 予定の画面 | — | 画面の地図で状態が「予定」の行。経路がまだ無く、載る機能だけが決まっている画面 | — | — | — | +| 作る課題 | — | 予定の画面を作り、画面の地図のその行を「あり」へ変える GitHub の課題。#2382 は「持ち主の課題」と書く | — | — | — | | デザインのトークン | — | 色・文字・余白の値に名前を付け、画面の部品がその名前で参照する値の集合 | — | — | `docs/specifications/ndf-factory-ui-components.md` | | ダミーの API クライアント | — | 画面が呼ぶ API クライアントのインタフェースを満たし、決まったデータを返す実装。本物の API クライアントと差し替えられる | — | — | `docs/specifications/ndf-factory-ui-components.md` | | 翻訳のファイル | — | 言語ごとに、画面の文言のキーと文言を持つファイル | — | — | `docs/specifications/ndf-factory-ui-components.md` | | 見本の画面 | — | 土台の部品だけで組み、ダミーデータで動かす画面の 1 組(スプリントの一覧と詳細)。後続の課題が中身を置き換える出発点 | — | — | `docs/specifications/ndf-factory-ui-components.md` | | つながりの状態 | — | 画面が今サーバーへつながっているか・最後に受け取った時刻・つながってから確定する操作の数の組 | — | — | `docs/specifications/ndf-factory-ui-components.md` | | 言語と地域の設定 | — | 画面の文言の言語・日時と数の書式の地域・表示の時刻帯の組。今はブラウザの設定から決める | — | — | `docs/specifications/ndf-factory-ui-components.md` | +| 委任の資格情報 | — | 人が画面で発行し、エージェントかクライアントに渡す、期限と範囲つきの資格情報。だれの委任か(利用者)と、どのクライアントかを持ち、人が取り消せる(#2384) | — | — | — | +| 人だけが確定できる操作 | — | 承認ゲートの承認・判断の依頼への答えと、共通原則の C1〜C8・プロジェクト MVV の P に当たる Factory の操作。契約に印を持ち、委任の資格情報からは断る(#2384) | — | — | — | +| 操作の記録 | — | Factory の利用者向けの操作 1 回ごとに 1 件追記する記録。だれが・いつ・どの呼び出し元から・何を行ったかを持ち、書き換えと削除の経路を持たない(#2384) | — | — | — | +| 呼び出し元の種類 | — | 操作の記録に残す、操作がどこから来たかの区分。画面・API・MCP の 3 つ(#2384) | — | — | — | +| 機能から起こした契約 | — | 経路と入出力を、画面の 1 枚でなくエンティティと操作から決めた API の契約。取得・作成・更新・削除とも配列で受け渡す(#2384) | — | — | — | +| 操作の扱い | — | Factory の利用者向けの操作が要る範囲。読み取り(read)・書き込み(write)・人だけ(human)の 3 つで、契約の各操作に x-ndf-access として持つ(#2384) | — | — | — | +| 操作の表 | — | Factory の契約から生成する、利用者向けの操作の一覧(operationId・メソッド・経路・操作の扱い・入力の定義)。画面・API・MCP がここから要求を組み、経路を登録する(#2384) | — | — | — | +| 要素の結果 | — | Factory の配列で受け渡す操作の応答の 1 要素。入力と同じ順に並び、成功なら値を、失敗なら状態と理由(code)を持つ(#2384) | — | — | — | +| 作成の冪等の鍵 | — | Factory で新しい識別子を作る作成の要素に、呼ぶ側が付ける鍵。同じ利用者・同じ操作・同じ鍵の作成を 1 件にする。進捗の事象の冪等の鍵とは別(#2384) | — | — | — | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 59f37c2d1..bdd8b8390 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -2017,6 +2017,12 @@ "全体のテスト" ] }, + { + "term": "現状固定テスト", + "context": "ndf-workflow", + "meaning": "構造を変える前の出力をそのまま期待値にしたテスト。構造を変える前後で同じ出力が出ることを縛る(英語の characterization test)", + "source": "plugins/ndf/skills/refactoring/references/characterization-tests.md" + }, { "term": "コメントのスナップショット", "context": "ndf-workflow", @@ -4547,6 +4553,30 @@ "meaning": "承認の依頼に答えが保存された後、承認と通知が実行の制御へ出す、`resume` の実行ジョブを起こす要求", "source": "docs/specifications/ndf-factory-runs-and-runner.md" }, + { + "term": "再開のジョブ", + "context": "ndf-factory", + "meaning": "`START=resume` の実行ジョブ。承認の答えで起こすものと、利用上限のリセット時刻の後に起こすものがある。保存した状態を復元して前回の続きから進める", + "pending_source": "issues/issue-2405-requirements.md" + }, + { + "term": "予算の反映", + "context": "ndf-workflow", + "meaning": "開発ループの記録の予算を、渡した単位の値で上書きすること(`sprint-state.py loop budget`)。変わったときだけ予算の履歴に 1 行足し、止まった記録の解除や周回の締めを伴わない", + "pending_source": "issues/issue-2405-design.md" + }, + { + "term": "予算の履歴", + "context": "ndf-workflow", + "meaning": "開発ループの記録が持つ、予算の上書きの記録(`budget_history`)。予算が変わったときだけ、時刻・前の値・後の値の 1 行を足す", + "pending_source": "issues/issue-2405-design.md" + }, + { + "term": "人の判断待ちの行", + "context": "ndf-workflow", + "meaning": "開発ループの記録が持つ、人の答え(承認・差し戻し・直して出し直す)を待つ課題の行(`waiting`)。`sprint-state.py loop answer` で答えを書く。着手の前の課題の状態を指す「人の判断待ち」とは別の語", + "pending_source": "issues/issue-2405-design.md" + }, { "term": "打ち切りの時間", "context": "ndf-factory", @@ -4688,13 +4718,13 @@ { "term": "持ち主の記録", "context": "ndf-worktree", - "meaning": "残る状態ごとに、作った実行が書く記録。実行の識別子・開始・最後に動いた時刻・終わった時刻、またはプロセス(pid と開始時刻)を持つ。片付けが持ち主の生存を判定するために読む(#2229)", + "meaning": "持ち主の鍵ごとに 1 つ、状態の置き場の leases/<リポジトリ>/ に置く記録。実行の識別子・入口・開始・最後に動いた時刻・終わった時刻・持ち主のプロセス(pid・開始時刻・ホスト名)を持つ。片付けが持ち主の生存を判定するときに読む(#2229・#2342)", "source": "docs/specifications/ndf-run-state-owner-and-sweep.md" }, { "term": "寿命の窓", "context": "ndf-worktree", - "meaning": "持ち主の記録が終わっていなくても、最後に動いた時刻からこの時間を過ぎた実行を終わったとみなす長さ。レビュー worktree は 24 時間(#2229)、見直しの持ち主は既定 15 分(#2315)", + "meaning": "持ち主の記録が終わっていなくても、最後に動いた時刻からこの長さを過ぎた持ち主を死んでいるとみなす長さ。持ち主の部品(leases/)の記録では別のホストの記録にだけ使い、同じホストの生死は pid と開始時刻で決める。値は持ち主の契約の window_seconds に置く。レビュー worktree は 24 時間(#2229・#2342)、見直しの持ち主は既定 15 分(#2315)", "source": "docs/specifications/ndf-run-state-owner-and-sweep.md" }, { @@ -4707,29 +4737,25 @@ "term": "スプリントの一覧", "context": "ndf-factory", "meaning": "アカウントのスプリントの実行を新しい順に並べ、それぞれの今のフェーズと状態を出す画面(#2172)", - "source": "", - "pending_source": "issues/issue-2172-requirements.md" + "source": "docs/specifications/ndf-factory-sprint-views.md" }, { "term": "スプリントの詳細", "context": "ndf-factory", "meaning": "1 つのスプリントの実行の、課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態を出す画面(#2172)", - "source": "", - "pending_source": "issues/issue-2172-requirements.md" + "source": "docs/specifications/ndf-factory-sprint-views.md" }, { "term": "詳細パネル", "context": "ndf-factory", "meaning": "スプリントの詳細で工程の行を押すと右側に出る、その工程の中身(触ったファイル・決めたこと・終了コード・秒・費用・作業の報告)の欄(#2172)", - "source": "", - "pending_source": "issues/issue-2172-requirements.md" + "source": "docs/specifications/ndf-factory-sprint-views.md" }, { "term": "バックログの画面", "context": "ndf-factory", "meaning": "取り込んだリポジトリの順位の表と棚卸しの記録を、スプリントの外で読む画面(#2172)", - "source": "", - "pending_source": "issues/issue-2172-requirements.md" + "source": "docs/specifications/ndf-factory-sprint-views.md" }, { "term": "フェーズの名前", @@ -4877,6 +4903,48 @@ "pending_source": "issues/issue-2335-design.md", "code": "limits_declaration" }, + { + "term": "方針の名前", + "context": "ndf-workflow", + "meaning": "上限の宣言の 1 行を指す名前。入口の契約と部品の呼び出しが、値でなくこの名前で上限を引く(#2343)", + "source": "", + "pending_source": "issues/issue-2343-requirements.md" + }, + { + "term": "原因の鍵", + "context": "ndf-workflow", + "meaning": "失敗が前回と同じ原因かを見る値。例外の型と、数字・時刻・パスを伏せた失敗の理由の文から作る(#2343)", + "source": "", + "pending_source": "issues/issue-2343-requirements.md" + }, + { + "term": "再試行", + "context": "ndf-workflow", + "meaning": "失敗した同じ呼び出しを、同じプロセスの中で間隔を置いて打ち直すこと。条件が揃うまで問い合わせる待ちとは別である(#2343)", + "source": "", + "pending_source": "issues/issue-2343-requirements.md" + }, + { + "term": "ステップの打ち直し", + "context": "ndf-workflow", + "meaning": "プランの判定役(judge)が、失敗したステップを同じステップのまま流し直すと返すこと(#2343)", + "source": "", + "pending_source": "issues/issue-2343-requirements.md" + }, + { + "term": "眠りのループ", + "context": "ndf-workflow", + "meaning": "本体(入れ子の関数を除く)で sleep を呼ぶ for / while のループ。再試行と待ちの両方が当たり、lib/waits.py の外では構造チェックが落とす(#2343)", + "source": "", + "pending_source": "issues/issue-2343-design.md" + }, + { + "term": "最後の照会", + "context": "ndf-workflow", + "meaning": "待ちの次の眠りが上限を超えるとき、残りの秒だけ眠った後に上限の時刻に打つ 1 回の照会(#2343)", + "source": "", + "pending_source": "issues/issue-2343-design.md" + }, { "term": "名前の対応表", "context": "ndf-workflow", @@ -4995,22 +5063,19 @@ "deprecated": [ "手元のランナー" ], - "source": "", - "pending_source": "issues/issue-2439-design.md" + "source": "docs/specifications/ndf-factory-organizer.md" }, { "term": "Factory のオーガナイザー", "context": "ndf-factory", "meaning": "同じリポジトリで並行する Factory の実行をまとめる役割。1 台の中のオーガナイザーの仕事すべて(配布を 1 本にまとめる・問いを集める・答えの後の作業を持ち主へ返す・共有の作業を一時のレーンへ渡す)に、Factory 固有の仕事(実行ジョブの起動・停止・答えでの再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場)を足したもの。API の handler と見回りの中で動き、常駐しない。1 台の中のオーガナイザーと同じ層の別の実装で、互いに排他", - "source": "", - "pending_source": "issues/issue-2439-design.md" + "source": "docs/specifications/ndf-factory-organizer.md" }, { "term": "配布の実行", "context": "ndf-factory", "meaning": "Factory のオーガナイザーが、まとめた配布 1 つのために起こすスプリントの実行。範囲のどの実行でもなく、承認資料を作り、本番の配布を流し、結果を返して終わる。同じリポジトリで同時に 1 本まで", - "source": "", - "pending_source": "issues/issue-2439-design.md" + "source": "docs/specifications/ndf-factory-organizer.md" }, { "term": "画面の地図", @@ -5018,6 +5083,18 @@ "meaning": "Factory の全画面の経路・役割ごとの主な動線・確定仕様の機能との対応を 1 つにまとめた表", "source": "docs/specifications/ndf-factory-ui-components.md" }, + { + "term": "予定の画面", + "context": "ndf-factory", + "meaning": "画面の地図で状態が「予定」の行。経路がまだ無く、載る機能だけが決まっている画面", + "pending_source": "issues/issue-2431-requirements.md" + }, + { + "term": "作る課題", + "context": "ndf-factory", + "meaning": "予定の画面を作り、画面の地図のその行を「あり」へ変える GitHub の課題。#2382 は「持ち主の課題」と書く", + "pending_source": "issues/issue-2431-requirements.md" + }, { "term": "デザインのトークン", "context": "ndf-factory", @@ -5054,6 +5131,60 @@ "meaning": "画面の文言の言語・日時と数の書式の地域・表示の時刻帯の組。今はブラウザの設定から決める", "source": "docs/specifications/ndf-factory-ui-components.md" }, + { + "term": "委任の資格情報", + "context": "ndf-factory", + "meaning": "人が画面で発行し、エージェントかクライアントに渡す、期限と範囲つきの資格情報。だれの委任か(利用者)と、どのクライアントかを持ち、人が取り消せる(#2384)", + "pending_source": "issues/issue-2384-requirements.md" + }, + { + "term": "人だけが確定できる操作", + "context": "ndf-factory", + "meaning": "承認ゲートの承認・判断の依頼への答えと、共通原則の C1〜C8・プロジェクト MVV の P に当たる Factory の操作。契約に印を持ち、委任の資格情報からは断る(#2384)", + "pending_source": "issues/issue-2384-requirements.md" + }, + { + "term": "操作の記録", + "context": "ndf-factory", + "meaning": "Factory の利用者向けの操作 1 回ごとに 1 件追記する記録。だれが・いつ・どの呼び出し元から・何を行ったかを持ち、書き換えと削除の経路を持たない(#2384)", + "pending_source": "issues/issue-2384-requirements.md" + }, + { + "term": "呼び出し元の種類", + "context": "ndf-factory", + "meaning": "操作の記録に残す、操作がどこから来たかの区分。画面・API・MCP の 3 つ(#2384)", + "pending_source": "issues/issue-2384-requirements.md" + }, + { + "term": "機能から起こした契約", + "context": "ndf-factory", + "meaning": "経路と入出力を、画面の 1 枚でなくエンティティと操作から決めた API の契約。取得・作成・更新・削除とも配列で受け渡す(#2384)", + "pending_source": "issues/issue-2384-requirements.md" + }, + { + "term": "操作の扱い", + "context": "ndf-factory", + "meaning": "Factory の利用者向けの操作が要る範囲。読み取り(read)・書き込み(write)・人だけ(human)の 3 つで、契約の各操作に x-ndf-access として持つ(#2384)", + "pending_source": "issues/issue-2384-design.md" + }, + { + "term": "操作の表", + "context": "ndf-factory", + "meaning": "Factory の契約から生成する、利用者向けの操作の一覧(operationId・メソッド・経路・操作の扱い・入力の定義)。画面・API・MCP がここから要求を組み、経路を登録する(#2384)", + "pending_source": "issues/issue-2384-design.md" + }, + { + "term": "要素の結果", + "context": "ndf-factory", + "meaning": "Factory の配列で受け渡す操作の応答の 1 要素。入力と同じ順に並び、成功なら値を、失敗なら状態と理由(code)を持つ(#2384)", + "pending_source": "issues/issue-2384-design.md" + }, + { + "term": "作成の冪等の鍵", + "context": "ndf-factory", + "meaning": "Factory で新しい識別子を作る作成の要素に、呼ぶ側が付ける鍵。同じ利用者・同じ操作・同じ鍵の作成を 1 件にする。進捗の事象の冪等の鍵とは別(#2384)", + "pending_source": "issues/issue-2384-design.md" + }, { "term": "差分の棚卸し", "context": "ndf-workflow", @@ -5299,6 +5430,32 @@ "context": "ndf-workflow", "meaning": "conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで", "source": "docs/specifications/ndf-context-restart-policy.md" + }, + { + "term": "持ち主の契約", + "context": "ndf-worktree", + "meaning": "入口の契約の行が持つ lease の宣言。副命令ごとに鍵の形・持ち主の単位・寿命の窓・返す副命令かを決める。見直しの持ち主が実行時に取る記録(持ち主の宣言)とは別の物である(#2342)", + "source": "", + "pending_source": "issues/issue-2342-design.md" + }, + { + "term": "持ち主の単位", + "context": "ndf-worktree", + "meaning": "持ち主の記録を誰が持つか。run は入口の 1 回の実行で、実行の終わりで返す。session はランタイムのセッション(Claude Code・Codex のプロセス)で、返す入口が呼ばれるかセッションが終わるまで持つ(#2342)", + "source": "", + "pending_source": "issues/issue-2342-design.md" + }, + { + "term": "呼び手", + "context": "ndf-workflow", + "meaning": "関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える", + "pending_source": "issues/issue-2369-requirements.md" + }, + { + "term": "残す関数の一覧", + "context": "ndf-workflow", + "meaning": "関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの", + "pending_source": "issues/issue-2369-design.md" } ] } diff --git a/docs/metrics/ndf-token-usage/2026-11-05.json b/docs/metrics/ndf-token-usage/2026-11-05.json new file mode 100644 index 000000000..fa2cb5d33 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-05.json @@ -0,0 +1,1049 @@ +{ + "per_pr": [ + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 20, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 3, + "conductor_cost": 802454.7666666666, + "supervisor_cost": 634856.4166666666, + "worker_cost": 0.0, + "context": 13893558.333333334, + "out": 47368.333333333336, + "minutes": 51.90099444521798, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 25, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 648905.75, + "supervisor_cost": 0.0, + "worker_cost": 0.0, + "context": 5325894.0, + "out": 23884.0, + "minutes": 10.57671666542689, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 3, + "conductor_cost": 2448057.05, + "supervisor_cost": 904120.7666666666, + "worker_cost": 132714.11666666667, + "context": 38971428.333333336, + "out": 95174.66666666667, + "minutes": 170.40631111065548, + "codex_input": 3794037.0, + "codex_out": 25157.0, + "kiro_credit": 0.0, + "claude_seat_cost": 1065309.2333333332 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 31, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "sessions": 1, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 28, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 20, + "cost": 674537.1700000002, + "context": 4977035.25, + "out": 27067.55, + "minutes": 18.688582499424616, + "p": 60783.4, + "k": 36.45, + "w5": 0.0, + "w1h": 148859.55, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 248519, + "rewrite_tokens_after_5m": 248519, + "read_tokens_after_5m": 1436991, + "rewrite_gap_median": 93.8481916685899 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 2, + "cost": 26853.5, + "context": 12709.0, + "out": 287.5, + "minutes": 0.095, + "k": 1.0, + "w5": 0.0, + "w1h": 12707.0 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 31303.225, + "context": 14633.0, + "out": 2149.0, + "minutes": 0.35083333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 10166.5 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "その他", + "count": 1, + "cost": 19460.15, + "context": 12955.0, + "out": 1441.0, + "minutes": 0.1465333342552185, + "p": 3426.0, + "k": 2.0, + "w5": 9527.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 12, + "cost": 552397.85, + "context": 4826722.416666667, + "out": 25591.5, + "minutes": 5.110416666666666, + "k": 43.25, + "w5": 0.0, + "w1h": 93862.41666666667 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 2407364.3, + "context": 22167274.0, + "out": 58600.0, + "minutes": 155.70298333565395, + "p": 44902.0, + "k": 119.0, + "w5": 0.0, + "w1h": 515774.0, + "rewrites": 3, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 342605, + "rewrite_tokens_after_5m": 295416, + "read_tokens_after_5m": 811441, + "rewrite_gap_median": 263.9299999992053 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.317", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 2, + "cost": 952284.6249999999, + "context": 9756700.5, + "out": 41752.5, + "minutes": 12.307008334000905, + "p": 39178.0, + "k": 72.0, + "w5": 212957.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 25, + "cost": 356384.89, + "context": 2206706.64, + "out": 14023.28, + "minutes": 5.807699333031972, + "p": 58815.12, + "k": 21.0, + "w5": 0.0, + "w1h": 90201.32, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 315665, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 2, + "cost": 27939.0, + "context": 13383.0, + "out": 235.0, + "minutes": 0.12666666666666665, + "k": 1.0, + "w5": 0.0, + "w1h": 13381.0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 4, + "cost": 62007.275, + "context": 27125.5, + "out": 1663.0, + "minutes": 0.31708333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 26838.0 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 7, + "cost": 19917.321428571428, + "context": 13575.57142857143, + "out": 1041.0, + "minutes": 0.23547619047619048, + "k": 1.0, + "w5": 0.0, + "w1h": 7195.714285714285 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 2, + "cost": 102377.875, + "context": 104781.5, + "out": 4193.0, + "minutes": 0.7202416678269704, + "p": 36575.5, + "k": 2.0, + "w5": 63475.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 5, + "cost": 73552.94, + "context": 68403.6, + "out": 4303.2, + "minutes": 0.748746665318807, + "p": 24921.2, + "k": 2.0, + "w5": 40510.8, + "w1h": 0.0, + "rewrites": 2, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 56092, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.5356166660785675 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 16, + "cost": 437842.20625000005, + "context": 3942818.0625, + "out": 19452.75, + "minutes": 4.175833333333333, + "k": 28.125, + "w5": 0.0, + "w1h": 73532.8125 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 7344171.149999999, + "context": 99071892.0, + "out": 176956.0, + "minutes": 511.2189333319664, + "p": 45175.0, + "k": 327.0, + "w5": 0.0, + "w1h": 771799.0, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 335959, + "rewrite_tokens_after_5m": 335959, + "read_tokens_after_5m": 5102252, + "rewrite_gap_median": 346.74322499831516 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "Explore", + "count": 1, + "cost": 653471.6000000001, + "context": 6666806.0, + "out": 25828.0, + "minutes": 8.993599998950959, + "p": 21921.0, + "k": 58.0, + "w5": 159066.0, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:supervisor-waits", + "count": 2, + "cost": 235793.0, + "context": 1476042.0, + "out": 5404.0, + "minutes": 13.845449999968212, + "p": 46957.0, + "k": 21.0, + "w5": 0.0, + "w1h": 69191.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 70286, + "rewrite_gap_median": null + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 15, + "cost": 105820.31333333332, + "context": 432512.4, + "out": 3754.4, + "minutes": 2.7827022218704225, + "p": 38411.8, + "k": 8.2, + "w5": 54505.4, + "w1h": 0.0, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 109482, + "rewrite_tokens_after_5m": 109482, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 13.753474998474122 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "worker", + "role": "その他", + "agent_type": "ndf:worker", + "count": 3, + "cost": 132714.11666666667, + "context": 578605.6666666666, + "out": 5205.333333333333, + "minutes": 1.3990166677369011, + "p": 38413.666666666664, + "k": 9.666666666666666, + "w5": 64782.333333333336, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 31, + "cost": 451688.9112903226, + "context": 3034750.1612903224, + "out": 18385.290322580644, + "minutes": 22.554559140307926, + "p": 60330.12903225807, + "k": 25.903225806451612, + "w5": 0.0, + "w1h": 106652.90322580645, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 91350, + "rewrite_tokens_after_5m": 91350, + "read_tokens_after_5m": 2471876, + "rewrite_gap_median": 72.19301666418711 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 5, + "cost": 27704.6, + "context": 12992.8, + "out": 344.2, + "minutes": 0.11099999999999999, + "k": 1.0, + "w5": 0.0, + "w1h": 12990.8 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 46033.55, + "context": 18852.0, + "out": 1889.0, + "minutes": 0.3433333333333334, + "k": 1.0, + "w5": 0.0, + "w1h": 18279.0 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 12, + "cost": 20500.2625, + "context": 14004.416666666666, + "out": 1109.8333333333333, + "minutes": 0.2026388888888889, + "k": 1.0, + "w5": 0.0, + "w1h": 7307.166666666667 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 3, + "cost": 230859.68333333335, + "context": 1373556.3333333333, + "out": 14592.333333333334, + "minutes": 2.448522220717536, + "p": 25547.666666666668, + "k": 22.0, + "w5": 74315.33333333333, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "その他", + "count": 2, + "cost": 93526.025, + "context": 118118.0, + "out": 6817.5, + "minutes": 1.0378249982992809, + "p": 3427.5, + "k": 4.0, + "w5": 41407.5, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 29, + "cost": 296489.5879310345, + "context": 2288290.206896552, + "out": 13913.379310344828, + "minutes": 8.352528735632184, + "k": 22.413793103448278, + "w5": 0.0, + "w1h": 57676.31034482759 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 886845.25, + "context": 9165440.0, + "out": 26793.0, + "minutes": 188.82836666504542, + "p": 45248.0, + "k": 62.0, + "w5": 0.0, + "w1h": 151011.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 799216, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 28, + "cost": 339263.97500000003, + "context": 1942528.3214285714, + "out": 12992.5, + "minutes": 5.885591666329475, + "p": 63552.357142857145, + "k": 17.892857142857142, + "w5": 0.0, + "w1h": 90841.53571428571, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 244914, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 3, + "cost": 29080.666666666668, + "context": 13791.333333333334, + "out": 300.0, + "minutes": 0.09277777777777778, + "k": 1.0, + "w5": 0.0, + "w1h": 13789.333333333334 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 5, + "cost": 56032.130000000005, + "context": 25107.6, + "out": 1297.4, + "minutes": 0.2506666666666667, + "k": 1.0, + "w5": 0.0, + "w1h": 24763.0 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 7, + "cost": 17429.985714285714, + "context": 13629.857142857143, + "out": 1017.2857142857143, + "minutes": 0.2045238095238095, + "k": 1.0, + "w5": 0.0, + "w1h": 5979.571428571428 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 4, + "cost": 86984.95, + "context": 94007.0, + "out": 3228.5, + "minutes": 0.5861708323160807, + "p": 36525.25, + "k": 2.0, + "w5": 55115.25, + "w1h": 0.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 18, + "cost": 319765.81944444444, + "context": 2716543.5, + "out": 15901.222222222223, + "minutes": 3.028508334027396, + "p": 24915.38888888889, + "k": 27.333333333333332, + "w5": 86983.83333333333, + "w1h": 0.0, + "rewrites": 5, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 143388, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.6457666635513306 + }, + { + "version": "10.27.16", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 13, + "cost": 173273.80384615387, + "context": 916692.2307692308, + "out": 8087.384615384615, + "minutes": 6.446538461538462, + "k": 20.23076923076923, + "w5": 0.0, + "w1h": 44599.38461538462 + } + ], + "external": [ + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "runtime": "claude", + "skill": "cross-refactoring", + "cli_model": "claude-opus-5-5", + "count": 11, + "context": 405299.9090909091, + "out": 4969.727272727273, + "cost": 122104.31818181813, + "minutes": 0.8947151505585873, + "p": 26883.81818181818, + "k": 9.090909090909092, + "w5": 0.0, + "w1h": 39473.545454545456, + "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": 11 + }, + { + "version": "10.27.15", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "runtime": "codex", + "skill": "cross-refactoring", + "cli_model": "gpt-6.1-sol", + "count": 1, + "input": 2153346.0, + "cached": 1998464.0, + "out": 10087.0, + "minutes": 4.944216664632162, + "p": 27887.0, + "k": 19.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 1 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 24, + "context": 344675.125, + "out": 5442.5, + "cost": 133163.65416666665, + "minutes": 1.060519444445769, + "p": 28692.666666666668, + "k": 7.416666666666667, + "w5": 0.0, + "w1h": 45488.875, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 37815, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.5753833333651225, + "call_seats": 24 + }, + { + "version": "10.27.15", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 35, + "input": 325203.17142857146, + "cached": 278440.22857142857, + "out": 2156.3142857142857, + "minutes": 1.2748423809096927, + "p": 25587.628571428573, + "k": 6.571428571428571, + "rewrites": 3, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 72691, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.09981666405995687, + "call_seats": 35 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "runtime": "claude", + "skill": "cross-review", + "cli_model": "claude-opus-5-5", + "count": 4, + "context": 253228.25, + "out": 5068.75, + "cost": 115206.8125, + "minutes": 4.319233335057894, + "p": 24890.0, + "k": 6.25, + "w5": 0.0, + "w1h": 39584.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 28121, + "rewrite_gap_median": null, + "call_seats": 4 + }, + { + "version": "10.27.16", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "runtime": "codex", + "skill": "cross-review", + "cli_model": "gpt-6.1-sol", + "count": 3, + "input": 271961.0, + "cached": 228352.0, + "out": 2139.6666666666665, + "minutes": 2.0000500003496806, + "p": 25574.0, + "k": 6.0, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 3 + } + ], + "meta": { + "by": [ + "version", + "mode", + "model", + "cc", + "reviewers" + ], + "min_version": "10.27.15", + "until": "2026-11-05T01:04:00Z", + "sessions": 107, + "sessions_with_pr": 3, + "unlinked_external": 3146, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 896, + "版を判定できない": 21 + }, + "released": "10.27.18", + "previous": "2026-11-04.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-11-05.md b/docs/metrics/ndf-token-usage/2026-11-05.md new file mode 100644 index 000000000..3f1774531 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-05.md @@ -0,0 +1,131 @@ +# ndf の版ごとのトークン消費と所要時間(2026-11-05 集計・10.27.18 の配布) + +**10.27.18 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-11-05T01:04:00Z`。表の最初の行は 10.27.15(前の記録: 2026-11-04.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.18 --until 2026-11-05T01:04:00Z --min-version 10.27.15 +python3 scripts/token-usage.py --min-version 10.27.15 --until 2026-11-05T01:04:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 無し。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.16, 10.27.18。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.27.15, 10.27.16。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(PR 1 本あたり) + +換算の合計は conductor・supervisor・worker の換算の和。前の版との差は、表の 1 つ上の行との比である。±30% を超えた版を下の「読み取り」で扱う。 + +| ndf の版 | 会話 | PR | 換算の合計 | 前の版との差 | conductor | supervisor | worker | 入力 | 所要 | codex 入力 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.15 | 3 | 7 | 2.20M | - | 1.49M | 0.66M | 0.06M | 23.42M | 96.8 | 1.63M | + +## 版と層ごとの呼び出しとキャッシュ(1 起動あたり) + +P と k と書き込みは 1 起動あたり、書き直しとうち 5 分超は合計の回数である。 + +| ndf の版 | 層 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | うち 5 分超 | 1 起動あたりの書き直し | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.15 | conductor | 47 | 59.1k | 36.2 | 0.00M | 0.14M | 7 | 6 | 0.15 | +| 10.27.15 | supervisor | 45 | 34.7k | 8.9 | 0.04M | 0.01M | 4 | 2 | 0.14 | +| 10.27.15 | worker | 31 | 38.4k | 32.2 | 0.01M | 0.07M | 0 | 0 | 0.00 | +| 10.27.16 | conductor | 60 | 61.6k | 22.8 | 0.00M | 0.10M | 1 | 1 | 0.02 | +| 10.27.16 | supervisor | 60 | 25.1k | 10.1 | 0.03M | 0.01M | 5 | 0 | 0.19 | +| 10.27.16 | worker | 42 | - | 21.7 | 0.00M | 0.05M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 107 件 / PR を作った会話: 3 件 / 寄せ先の無い外部 CLI: 3146 件 / 寄せ先の無い帳簿の行: 0 件 + +換算は input を 1 とした費用(cache read はモデル別: claude-opus-5-5 0.05 / claude-fable-5-1 0.025 / 他 0.1。cache write 5 分 1.25・1 時間 2 / output 5)。入力は input + cache read + cache write。所要は分。 + +### PR 1 本あたり + +| 動いた版 | 会話 | PR | conductor 換算 | supervisor 換算 | worker 換算 | 入力 | 出力 | 所要 | codex 入力 | codex 出力 | kiro credit | claude 席 換算 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.15 | 3 | 7 | 1.49M | 0.66M | 0.06M | 23.42M | 64.5k | 96.8 | 1.63M | 10.8k | 0.00 | 0.46M | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.15 | conductor | - | - | 47 | 0.68M | 5.87M | 24.0k | 25.2 | +| 10.27.15 | supervisor | judge | claude -p | 4 | 0.03M | 0.01M | 0.3k | 0.1 | +| 10.27.15 | supervisor | mvv | claude -p | 4 | 0.06M | 0.03M | 1.7k | 0.3 | +| 10.27.15 | supervisor | pr | claude -p | 9 | 0.02M | 0.01M | 1.3k | 0.3 | +| 10.27.15 | supervisor | その他 | Explore | 1 | 0.65M | 6.67M | 25.8k | 9.0 | +| 10.27.15 | supervisor | その他 | general-purpose | 2 | 0.10M | 0.10M | 4.2k | 0.7 | +| 10.27.15 | supervisor | その他 | ndf:supervisor-waits | 2 | 0.24M | 1.48M | 5.4k | 13.8 | +| 10.27.15 | supervisor | その他 | ndf:worker | 22 | 0.18M | 1.20M | 7.3k | 3.2 | +| 10.27.15 | supervisor | その他 | その他 | 1 | 0.02M | 0.01M | 1.4k | 0.1 | +| 10.27.15 | worker | work | claude -p | 28 | 0.49M | 4.32M | 22.1k | 4.6 | +| 10.27.15 | worker | その他 | ndf:worker | 3 | 0.13M | 0.58M | 5.2k | 1.4 | +| 10.27.16 | conductor | - | - | 60 | 0.41M | 2.63M | 16.0k | 17.5 | +| 10.27.16 | supervisor | judge | claude -p | 8 | 0.03M | 0.01M | 0.3k | 0.1 | +| 10.27.16 | supervisor | mvv | claude -p | 6 | 0.05M | 0.02M | 1.4k | 0.3 | +| 10.27.16 | supervisor | pr | claude -p | 19 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.16 | supervisor | その他 | general-purpose | 4 | 0.09M | 0.09M | 3.2k | 0.6 | +| 10.27.16 | supervisor | その他 | ndf:worker | 21 | 0.31M | 2.52M | 15.7k | 2.9 | +| 10.27.16 | supervisor | その他 | その他 | 2 | 0.09M | 0.12M | 6.8k | 1.0 | +| 10.27.16 | worker | work | claude -p | 42 | 0.26M | 1.86M | 12.1k | 7.8 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.15 | conductor | - | - | 47 | 59.1k | 36.2 | 0.00M | 0.14M | 7 | 6 | 0.88M | 7.67M | 263.9 | +| 10.27.15 | supervisor | judge | claude -p | 4 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.15 | supervisor | mvv | claude -p | 4 | - | 1.0 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.15 | supervisor | pr | claude -p | 9 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.15 | supervisor | その他 | Explore | 1 | 21.9k | 58.0 | 0.16M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | supervisor | その他 | general-purpose | 2 | 36.6k | 2.0 | 0.06M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | supervisor | その他 | ndf:supervisor-waits | 2 | 47.0k | 21.0 | 0.00M | 0.07M | 0 | 0 | 0.00M | 0.07M | - | +| 10.27.15 | supervisor | その他 | ndf:worker | 22 | 35.4k | 12.6 | 0.07M | 0.00M | 4 | 2 | 0.11M | 0.00M | 6.9 | +| 10.27.15 | supervisor | その他 | その他 | 1 | 3.4k | 2.0 | 0.01M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | worker | work | claude -p | 28 | - | 34.6 | 0.00M | 0.08M | - | - | - | - | - | +| 10.27.15 | worker | その他 | ndf:worker | 3 | 38.4k | 9.7 | 0.06M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.16 | conductor | - | - | 60 | 61.6k | 22.8 | 0.00M | 0.10M | 1 | 1 | 0.09M | 3.52M | 72.2 | +| 10.27.16 | supervisor | judge | claude -p | 8 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.16 | supervisor | mvv | claude -p | 6 | - | 1.0 | 0.00M | 0.02M | - | - | - | - | - | +| 10.27.16 | supervisor | pr | claude -p | 19 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.16 | supervisor | その他 | general-purpose | 4 | 36.5k | 2.0 | 0.06M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.16 | supervisor | その他 | ndf:worker | 21 | 25.0k | 26.6 | 0.09M | 0.00M | 5 | 0 | 0.00M | 0.00M | 0.6 | +| 10.27.16 | supervisor | その他 | その他 | 2 | 3.4k | 4.0 | 0.04M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.16 | worker | work | claude -p | 42 | - | 21.7 | 0.00M | 0.05M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.15 | claude | cross-review | claude-opus-5-5 | 24 | 0.34M | 0.00M | 5.4k | 0.00 | 1.1 | +| 10.27.15 | claude | cross-refactoring | claude-opus-5-5 | 11 | 0.41M | 0.00M | 5.0k | 0.00 | 0.9 | +| 10.27.15 | codex | cross-review | gpt-6.1-sol | 35 | 0.33M | 0.28M | 2.2k | 0.00 | 1.3 | +| 10.27.15 | codex | cross-refactoring | gpt-6.1-sol | 1 | 2.15M | 2.00M | 10.1k | 0.00 | 4.9 | +| 10.27.16 | claude | cross-review | claude-opus-5-5 | 4 | 0.25M | 0.00M | 5.1k | 0.00 | 4.3 | +| 10.27.16 | codex | cross-review | gpt-6.1-sol | 3 | 0.27M | 0.23M | 2.1k | 0.00 | 2.0 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.15 | claude | cross-review | claude-opus-5-5 | 24 | 28.7k | 7.4 | 1 | 0 | 0.00M | 0.00M | 0.6 | +| 10.27.15 | claude | cross-refactoring | claude-opus-5-5 | 11 | 26.9k | 9.1 | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.15 | codex | cross-review | gpt-6.1-sol | 35 | 25.6k | 6.6 | 3 | 0 | 0.00M | 0.00M | 0.1 | +| 10.27.15 | codex | cross-refactoring | gpt-6.1-sol | 1 | 27.9k | 19.0 | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.16 | claude | cross-review | claude-opus-5-5 | 4 | 24.9k | 6.2 | 0 | 0 | 0.00M | 0.03M | - | +| 10.27.16 | codex | cross-review | gpt-6.1-sol | 3 | 25.6k | 6.0 | 0 | 0 | 0.00M | 0.00M | - | diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 7dc9e7fa2..cc1b95016 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -126,6 +126,9 @@ | [ndf-factory-dev-loops.md](ndf-factory-dev-loops.md) | Neural Development Factory: 開発ループ(集約・状態の写し方・周回の開始)・同じリポジトリで並行する実行の取り合い(課題・マイルストーンの持ち分・NDF の版・同時の数)・移行の埋め戻し・ランナーの区間と ndf-next・形態 local の利用上限・手元のマシンの同時のジョブの数・loops.yaml の経路。#2347 | | [ndf-factory-dev-loops-data-and-contracts.md](ndf-factory-dev-loops-data-and-contracts.md) | Neural Development Factory: 開発ループの実行の表の行・移行の済みの行と埋め戻し・環境変数と、実行ジョブの指示・進捗の事象・ndf-next の受け渡しの形・手元のマシンの申告・loops.yaml の経路の契約(ndf-factory-dev-loops.md から分けた)。#2347 | | [ndf-factory-ui-components.md](ndf-factory-ui-components.md) | Neural Development Factory: Web の画面の部品の一覧と使い分け・デザインのトークン・端末の幅・文言と書式(国際化)・API クライアントとダミー・つながりの状態・常に成り立つ条件・検査(check:source・check:bundle・E2E)。#2382 | +| [ndf-factory-organizer-data-and-contracts.md](ndf-factory-organizer-data-and-contracts.md) | Neural Development Factory: Factory のオーガナイザーの実行の表の行(根・受けた依頼・まとめた配布・記録)と足した列、画面向けとランナー向けの経路・release.* の進捗の事象と冪等の鍵・ジョブの指示・NDF の置き場の契約(ndf-factory-organizer.md から分けた)。#2439 | | [ndf-parallel-organizer.md](ndf-parallel-organizer.md) | 並列の計画(plan.json)を 1 度書き、--parallel のオーガナイザーが耐久ワークフローでレーンを起動・観測し、本番の配布を develop のコミットの範囲で 1 本にまとめ、区分つきの問いを集める設計判断・常に成り立つ条件・メッセージとイベントの形・置き場。手順は development-workflow の references/parallel.md と issue-plan-strategy の references/parallel-plan.md が正 | +| [ndf-factory-organizer.md](ndf-factory-organizer.md) | Neural Development Factory: Factory のオーガナイザー(同じリポジトリで並行する実行の本番の配布を配布の実行 1 本にまとめる・待ちの判定・止まった配布と再開・承認と問いのリポジトリごとの一覧・NDF の切り替え口・手元のマシンと料金プランの語)。#2439 | +| [ndf-factory-sprint-views.md](ndf-factory-sprint-views.md) | Neural Development Factory: スプリントの一覧と詳細・工程の中身・バックログの読み出し(事象から導く派生・フェーズの名前の対応づけ・ランナーが送る工程の中身と伏せ字・経路の形・読み出しの口・画面と読み直し・常に成り立つ条件)。#2172 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-factory-organizer-data-and-contracts.md b/docs/specifications/ndf-factory-organizer-data-and-contracts.md new file mode 100644 index 000000000..d39e7c545 --- /dev/null +++ b/docs/specifications/ndf-factory-organizer-data-and-contracts.md @@ -0,0 +1,192 @@ +# Neural Development Factory: Factory のオーガナイザーのデータと契約 + +[Factory のオーガナイザー](ndf-factory-organizer.md) が実行の表に持つ行と、画面向けの経路・ランナー向けの経路・進捗の事象・ジョブの指示・NDF の置き場の契約を扱う。不変条件の番号(I56〜I68)はその文書のものを指す。 + +## データ・設定 + +### 保存する形 + +オーガナイザーの進行の行は、今の実行の表(`pk=ACCOUNT#`)に置く。まとめた配布と配布の実行を 1 回のトランザクションで書く(I57)ためである。 + +```mermaid +erDiagram + ORG ||--o{ ORGREQ : "受けた" + ORG ||--o{ ORGBATCH : "組んだ" + ORG ||--o{ ORGLOG : "積む" + ORGBATCH ||--|| SPRINT_RUN : "配布の実行" + ORGREQ }o--|| SPRINT_RUN : "依頼した実行" + ORG { + string sk PK "ORG#repositoryId" + number version + number currentSeq + number nextSeq + string waitReason + } + ORGREQ { + string sk PK "ORGREQ#repositoryId#requestId" + string sprintRunId + string lane + object answer + } + ORGBATCH { + string sk PK "ORGBATCH#repositoryId#seq6桁" + string state + string releaseRunId + list presentations + } +``` + +**根(`sk=ORG#`)**: 1 リポジトリに 1 行。最初に書き込む `tick` が作る。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `repositoryId` | 数値 | 許さない | リポジトリの登録の識別子 | +| `version` | 数値 | 許さない | 楽観的な排他の版(I67)。書くたびに 1 増やす | +| `currentSeq` | 数値 | 許す | 流れているまとめた配布の連番。空は「流れていない」(I56) | +| `nextSeq` | 数値 | 許さない | 次に組むまとめた配布の連番(1 から) | +| `waitReason` | 文字列 | 許す | `external_release_open` / `legacy_run`。空は「待っていない」 | +| `waitDetail` | 文字列 | 許す | 待ちの相手(外の Pull Request の URL・依頼を出していない古い版の実行の数) | +| `gsi1pk`・`gsi1sk` | 文字列 | 許す | 既存の GSI `gsi1` の鍵 `ORGANIZER_ACTIVE` と `#`。`currentSeq` があるときだけ持つ | + +**受けた依頼(`sk=ORGREQ##`)**: 依頼 1 件に 1 行(I59)。依頼の中身は書き換えない。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `requestId` | 文字列 | 許さない | NDF が作る依頼の id | +| `sprintRunId` | 文字列 | 許さない | 依頼を出した実行 | +| `loopId` | 文字列 | 許す | 依頼を出した実行のループ。空は手動の実行 | +| `lane` | 文字列 | 許さない | `NDF_ORGANIZER_LANE` の値 | +| `prs`・`issues` | 数値の配列 | 許さない(空の配列は可) | 依頼の Pull Request と課題 | +| `ndfVersion` | 文字列 | 許さない | 依頼した実行の NDF の版 | +| `requestedAt` | 文字列 | 許さない | 依頼のファイルの時刻 | +| `answer` | オブジェクト | 許す | `{seq, result, version, prodPrUrl, reason, at}`。`result` は `released` / `rejected`、差し戻しの `reason` は `release_rejected`。空は「未回答」。1 回だけ書く(I58) | +| `deliveredAt` | 文字列 | 許す | 答えを依頼元へ届け終えた時刻。空は「届け終えていない」 | +| `gsi1pk`・`gsi1sk` | 文字列 | 許す | `gsi1` の鍵 `ORGANIZER_ACTIVE`。`deliveredAt` が空の間だけ持ち、`deliveredAt` を書く書き込みで外す | + +**まとめた配布(`sk=ORGBATCH##`)** + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `seq` | 数値 | 許さない | 連番 | +| `state` | 文字列 | 許さない | `composed` / `presented` / `represented` / `confirmed` / `stalled` / `released` / `rejected` | +| `releaseRunId` | 文字列 | 許さない | 配布の実行(I57) | +| `lanes` | 配列 | 許さない | 組んだ時点のレーン `{lane, sprintRunId, loopId, requestIds, issues}`。依頼の無いレーンは `requestIds` が空 | +| `presentations` | 配列 | 許さない(空の配列は可) | 提示 `{k, sha, version, prs, lanes:[{lane, requested}], addedPrs, at, approvalRequestId}`。2 つまで(I60)で、上書きせず足す。`approvalRequestId` はその提示へ答えた承認の依頼で、古い承認の再送を次の提示の承認と取り違えないために残す | +| `confirmedK` | 数値 | 許す | 確定した提示の番号。空は「確定していない」 | +| `mainMerged` | 真偽 | 許さない | `main` へのマージを記録したか(I63) | +| `result` | オブジェクト | 許す | `{version, prodPrUrl, sha, prs}`。空は「配布済みでない」 | +| `stallReason` | 文字列 | 許す | `stalled` の理由。`stalled` 以外は空 | +| `stalledFrom` | 文字列 | 許す | 止まる前の状態(`composed` / `presented` / `represented` / `confirmed`)。「続きから流す」でこの状態へ戻す。`stalled` 以外は空 | + +**記録(`sk=ORGLOG###<種類>#<乱数 8 桁>`)**: 追記だけで書き換えない。末尾の乱数は、同じ時刻に同じ種類の記録を 2 件書いても上書きしないためである。種類は `waited` / `composed` / `presented` / `represented` / `confirmed` / `stalled` / `resolved` / `released` / `rejected`。 + +**時系列の扱いは事象の記録と追記だけの提示の列である。** まとめた配布の `state` は上書きするが、遷移は記録の行に積み、提示は配列に足すだけにする。承認する人が何のコミットを見て答えたかを、出し直しの後も辿れるようにするためである。 + +**スプリントの実行・ジョブ・リポジトリの枠に足した列**(既存の列は変えない。空は今の意味として読む): + +| 行 | 列 | 型 | 意味 | +| --- | --- | --- | --- | +| リポジトリの枠 | `legacySeq` | 数値 | 古い版の実行を `legacyCount` へ数えに入れた回数。`legacyCount` を増やす書き込み(開始・移行の埋め戻し)で同じトランザクションで 1 増やし、減らす書き込みでは変えない。組む書き込みの条件に使う(I62) | +| 実行 | `purpose` | 文字列 | `release`。空はスプリントの実行 | +| 実行 | `releaseSeq` | 数値 | 配布の実行のまとめた配布 | +| 実行 | `awaitingReason` | 文字列 | `awaiting_approval` の理由 `release_requested` / `release_stalled`。空は承認の依頼の待ち | +| 実行 | `releaseRequestId` | 文字列 | 手動の実行が待つ依頼の id。理由が `release_requested` のときだけ | +| ジョブ | `start` | 文字列 | 今の値に `release` を足す | +| ジョブ | `releaseStep` | 文字列 | `present` / `represent` / `confirm:1` / `confirm:2` / `continue`。配布の実行のジョブだけ | +| ジョブ | `releaseRequestId` | 文字列 | 手動の実行の再開で、答えの出た依頼の id | + +既存のデータの移行は無い。 + +## 外部連携 + +### 画面向けの経路(`factory/contracts/openapi/organizer.yaml`) + +| 経路 | 入力 | 成功 | 失敗の形 | +| --- | --- | --- | --- | +| `GET /accounts/{accountId}/repositories/{repositoryId}/approval-requests` | — | 200 `{organizer: {current_seq, wait_reason, wait_detail}, items}`。`items` の 1 件は `{kind, sprint_run_id?, loop_id?, pr_url?, number?, reason?, created_at, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のものだけが持つ `{seq, k, sha, lanes:[{lane, requested, prs}], added_prs, choices}` | 401・403・404 | +| `GET /accounts/{accountId}/repositories/{repositoryId}/releases` | `?limit`(1〜100、既定 20) | 200 `{items: [{seq, state, release_run_id, presentations, confirmed_k, result, log}]}`(新しい順) | 400・401・403・404 | +| `POST /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve` | `{choice: "continue" \| "reject", body?}` | 202 `{seq, state}`。`state` は戻した状態(`composed` / `presented` / `represented` / `confirmed`)か `rejected` | 400・401・403・404・409(`not_stalled` / `release_after_main`) | + +一覧と履歴の経路は読むだけで、承認を確定する経路はこのファイルに無い(I66)。 + +### ランナー向けの経路(`factory/contracts/openapi/runs.yaml`) + +| 経路 | 入力 | 成功 | 失敗の形 | +| --- | --- | --- | --- | +| `GET /run-jobs/organizer/answers` | 実行ジョブの token | 200 `{answers: [{request_id, result, version, release, reason, at}], ranges: [{seq, sha, version, prs, lanes}]}`。手動の実行はその実行の、ループの実行はそのループのすべての実行の依頼の答えと、リポジトリの配布済みの範囲(新しい方から 100 件のまとめた配布のうち `released` のもの) | 401・403 | +| `GET /run-jobs/organizer/batch` | 実行ジョブの token(配布の実行のジョブだけ) | 200 `{seq, step, lanes:[{lane, requests:[{id, prs}], issues}], presentations, choices}` | 401・403・404 | + +答えの形は並列のオーガナイザーの `parallel-request.json` の答えと範囲にそろえる。受けた依頼の行の `prodPrUrl` は答えの `release` に写す(本番の Pull Request の URL)。レーンの中身はジョブの指示に載せず、ランナーがこの経路で読む。RunTask の `overrides` の上限(8,192 字)を避けるためである。 + +### 進捗の事象(`factory/contracts/schemas/progress-event.schema.json`) + +| 種類 | 出所 | payload | 冪等の鍵 | +| --- | --- | --- | --- | +| `release.requested` | `runner` | `{request_id, lane, prs, issues, ndf_version, requested_at}` | `` | +| `release.presented` | `runner` | `{seq, k, sha, version, prs, lanes:[{lane, requested}], added_prs, auto}`。`auto` は MVV 判定が通したとき真 | `:presented:` | +| `release.progress` | `runner` | `{seq, stage}`。`stage` は `release_branch` / `develop_merged` / `prod_pr_opened` / `main_merged` / `tagged` | `:progress:` | +| `release.settled` | `runner` | `{seq, result, version?, prod_pr_url?, sha?, prs, lanes, reason?}`。`result` は `released` / `failed` | `:settled:` | +| `release.answered` | `control` | `{seq, request_id?, result, version?, prod_pr_url?}`。範囲の実行の進捗の記録へ書く | `:` | + +冪等の鍵は実行の識別子を頭にする。冪等の鍵の表はサービス全体で 1 つのため、リポジトリごとの連番だけを鍵にしない。結果の鍵を種類ごとに分けるのは、同じ実行が `failed` を送った後に再開して `released` を送れるようにするためである。式は `factory/contracts/src/index.ts` の `releasePresentedKey`・`releaseProgressKey`・`releaseSettledKey` と `factory/api/src/progress/authorize.ts` だけが持ち、ランナーの送信と受け口の照合が同じ式を使う。 + +`gate.reached` の payload に任意の `choices`(文字列の配列)がある。配布の実行が `main` へのマージの後に判断で止まったときは `["continue"]` だけを載せる(I63)。 + +### ジョブの指示(`factory/contracts/schemas/run-job-instruction.schema.json`) + +| 項目 | 規則 | +| --- | --- | +| `START` | `new` / `resume` / `loop` / `release` | +| `RELEASE_SEQ` | 1 以上の整数の文字列。`START` が `release` のときは必須、ほかでは持たない | +| `RELEASE_STEP` | `present` / `represent` / `confirm:1` / `confirm:2` / `continue`。`START` が `release` のときは必須、ほかでは持たない | +| `RELEASE_REQUEST_ID` | 手動の実行の再開で、答えの出た配布の依頼の id | + +### 実行の制御の関数 + +| 関数 | 振る舞い | +| --- | --- | +| `buildReleaseRun(base, seq, ndfVersion)` | 範囲の実行を元に、目的 `release` の配布の実行とジョブ(`START=release`・`releaseStep=present`)を組む。枠の行は書かない(I64) | +| `resumeForRelease(sprintRunId, requestId)` | 手動の実行が理由 `release_requested` でこの依頼を待つときだけ、`addResumeJob` の条件にその照らしを入れて再開のジョブを作る。`resumed` / `busy` / `not_waiting` を返す | +| `resumeRelease(run, step, organizer?)` | 理由 `release_stalled` の配布の実行を `step` で再開する。`organizer` を渡すと、まとめた配布の状態の戻しを同じトランザクションで書く | +| 停止の後の処理(`finalize`) | `release.requested` を受けた依頼の行として同じトランザクションへ入れ、後で `tick` を呼ぶ。手動の実行が依頼を出して `gate`(理由 `release_requested`)で終わると、理由と依頼の id を持つ `awaiting_approval` へ移す。この理由はジョブの終わりの理由(`finishReason`)には写さない | +| 見回り(`sweep`) | 最後に `organizer.sweep()` を呼ぶ。`gsi1` の `ORGANIZER_ACTIVE` を引き、リポジトリごとに `tick` を呼ぶ | + +## NDF の置き場の契約(`plugins/ndf/contracts/forms/parallel-external.json`) + +```text +/ +├── requests/<依頼の id>.json # NDF が書く(レーン) +├── answers/<依頼の id>.json # 外のオーガナイザーが書く +├── ranges/<連番>.json # 外のオーガナイザーが書く(配布済みの範囲) +├── batch.json # 外のオーガナイザーが書く(配布の実行だけ) +├── presented/<連番>-<提示>.json # NDF が書く(配布の実行) +├── progress/<連番>.json # NDF が書く(配布の実行) +└── results/<連番>.json # NDF が書く(配布の実行) +``` + +| ファイル | 形 | +| --- | --- | +| `requests/` | 並列のオーガナイザーの配布の依頼(`parallel-request.json`)に `ndf_version`(空でない文字列)を足した形。ファイル名は id | +| `answers/`・`ranges/` | 答え `{result, version, release, reason, at}`(`result` は `released` / `rejected`)と範囲 `{sha, version, prs, lanes}` | +| `batch.json` | `{seq, step, lanes:[{lane, requests:[{id, prs}], issues}], presentations, choices}` | +| `presented/` | `{seq, k, sha, version, prs, lanes:[{lane, requested}], added_prs, auto}`。`k` は 1 か 2 | +| `progress/` | `{seq, stages}`。届いた段の並び | +| `results/` | `{seq, result, version, prod_pr_url, sha, prs, lanes, reason}`。`result` は `released` / `failed` | + +- どちらの書き手も一時ファイルへ書いて名前を付け替える。読み手は名前の付いたファイルだけを読み、書きかけのファイルを読まない +- 契約は例の組を持ち、NDF の構造チェックとランナーのテストが同じ例を読む +- 環境変数の宣言は `plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json`・`NDF_ORGANIZER_LANE.json`・`NDF_FACTORY_RUN.json` にある + +## テスト観点 + +- 経路・事象・ジョブの指示の追加は既存の項目の意味を変えず、`npm run check` の契約の検査が通ること +- 同じ時刻に同じ種類の記録を 2 件書いても、どちらも残ること +- `START` が `release` でないジョブの指示が `RELEASE_SEQ`・`RELEASE_STEP` を持つと、定義の検査で落ちること +- ランナーの翻訳が、置き場の契約の例の組を進捗の事象へ写し、答えと範囲を同じ形のファイルへ書くこと + +## 関連リンク + +- [Factory のオーガナイザー](ndf-factory-organizer.md) +- [並列のオーガナイザー(1 台の中)](ndf-parallel-organizer.md) +- [開発ループのデータと契約](ndf-factory-dev-loops-data-and-contracts.md) +- [契約の定義と型・進捗の受け口](ndf-factory-contracts-and-progress.md) diff --git a/docs/specifications/ndf-factory-organizer.md b/docs/specifications/ndf-factory-organizer.md new file mode 100644 index 000000000..8645ca43b --- /dev/null +++ b/docs/specifications/ndf-factory-organizer.md @@ -0,0 +1,327 @@ +# Neural Development Factory: Factory のオーガナイザー(本番の配布を 1 本にまとめ、承認と問いをリポジトリごとに 1 か所へ集める) + +同じリポジトリで 2 本以上の開発ループかスプリントの実行を Factory で回すと、本番の配布は Factory のオーガナイザーが 1 本にまとめる。流すのは専用の配布の実行 1 本で、承認ゲート 2 は 1 回で済む。承認と問いはリポジトリごとの 1 つの一覧に並ぶ。NDF の側は、外のオーガナイザーと置き場のファイルで依頼と答えをやり取りする切り替え口を持ち、Factory を名指ししない。 + +## 概要 + +- **配布を流すのは、同じリポジトリで配布の実行 1 本だけである。** 実行ごとに本番の配布を流すと、各実行が `develop` の先頭を出すため、先に出した実行が他の実行の変更もまとめて出してしまう。配布の実行は、承認したコミットまでの範囲だけを出す。範囲に Pull Request が入った実行すべてへ、同じ版と同じ本番の Pull Request を返す。範囲の外の変更は次のまとめた配布で出る +- **常駐のプロセスとインフラの部品を足さない。** 状態はすべて実行の表(DynamoDB)の行で、既存の API の handler と 5 分ごとの見回りの中で進む。見回りが引く索引は、既存の GSI `gsi1` に鍵 `ORGANIZER_ACTIVE` の行を載せて使う +- **1 台の中のオーガナイザー([並列のオーガナイザー](ndf-parallel-organizer.md))とは同じ層の別の実装で、互いに排他である。** 共有するのは依頼と答えの形と配布の規則だけである。Factory の実行ジョブでは 1 台の中のオーガナイザーを起動しない +- 承認ゲート 1 と承認ゲートの外の問いは依頼にせず、今の承認の流れのまま、一覧への並べ方だけをまとめる + +## 用語 + +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`・`ndf-workflow`)。この文書で使う語は「Factory のオーガナイザー」「配布の実行」「手元のマシン」「料金プラン」「レーン」「承認ゲート 2」「MVV 判定」「見回り」である。 + +次の 4 つは、この文書の中だけで使う構成要素の名前である。 + +| 名前 | 意味 | +| --- | --- | +| 配布のまとめ | Factory のオーガナイザーのうち、この仕組みが足した部分(`OrganizerControl`) | +| 受けた依頼 | 実行が出した配布の依頼 1 件の行 | +| まとめた配布 | 1 回の本番の配布の単位。範囲・提示・結果を持つ | +| 提示 | 承認ゲート 2 で人へ示した、出すコミットと範囲。1 つのまとめた配布に 2 つまで | + +## コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| Neural Development Factory(`ndf-factory`) | Factory のオーガナイザー・配布の実行・受けた依頼・まとめた配布・提示・待ちの理由・手元のマシン・料金プラン | +| NDF の開発ワークフロー(`ndf-workflow`) | オーガナイザー・レーン・配布の依頼・承認ゲート 2・承認したコミット | + +`ndf-workflow` は依頼と答えの形を公開された言語として持つ(`plugins/ndf/contracts/forms/parallel-request.json` と `parallel-external.json`)。`ndf-factory` はランナーの翻訳(`factory/runner/src/organizer.ts`)を腐敗防止層に置き、置き場のファイルを進捗の事象へ写す。API は NDF のファイルを読まない。 + +Factory のオーガナイザーは、既存の実行の制御・ループの制御と、配布のまとめでできる役割である。 + +| 1 台の中のオーガナイザーの仕事 | Factory の担い手 | +| --- | --- | +| レーンを起動・監視・停止する | 実行の制御・ループの制御(周回の開始・停止の後の処理・見回り・取り消し) | +| 本番の配布を 1 本にまとめる | 配布のまとめ | +| 問いを集める | 配布のまとめの一覧(リポジトリごと) | +| 答えの後の作業を持ち主のレーンへ返す | 配布のまとめの届け・`resumeForRelease`・ランナー向けの答えの経路 | +| 共有の作業を一時レーンへ渡す | 配布の実行 | +| (Factory 固有)実行ジョブの起動・停止・再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場 | 実行の制御・ランナー向けの経路 | + +## 構成要素 + +| 要素 | 場所 | 責務 | +| --- | --- | --- | +| 配布のまとめ | `factory/api/src/organizer/control.ts`(`OrganizerControl`) | `newRequestRows`(受けた依頼の行)・`tick`(届け直し・流れている配布の 1 手・待ちの判定と組む)・`decide`(承認の答え)・`resolve`(止まった配布を解く)・`list`(一覧)・`history`(履歴)・`answersFor`・`batchFor`(ランナー向け)・`sweep`(見回り) | +| 型と純粋な関数 | `factory/api/src/organizer/model.ts` | 根・受けた依頼・まとめた配布・記録の行の型。答える依頼の決め方(`requestsToAnswer`)・止まった配布の選択肢(`choicesOf`)・再開の手(`stepFrom`)・配布の実行の NDF の版(`newestVersion`) | +| 実行の表 | `factory/api/src/runs/table.ts`・`dynamo-table.ts`・`memory-table.ts` | オーガナイザーの行の読み書き。まとめた配布と配布の実行を 1 回で書くトランザクション(`composeRelease`)。古い版の実行を数えるたびに `legacySeq` を 1 増やす。`addResumeJob` の待ちの照らし | +| 実行の制御 | `factory/api/src/runs/control.ts`(`RunControl`) | 停止の後の処理で受けた依頼の行を同じトランザクションへ入れ、後で `tick` を呼ぶ。配布の実行の再開を `decide` へ回す。配布の実行を枠の外で作る(`buildReleaseRun`)。`resumeForRelease`・`resumeRelease`。見回りで `organizer.sweep` を呼ぶ | +| GitHub の口 | `factory/api/src/github/port.ts`・`app.ts` | `develop` の先頭・`main` 宛の開いている Pull Request の head・head を指定した Pull Request・枝の有無 | +| 経路 | `factory/api/src/routes/factory.ts`・`runs/runner-api.ts` | 画面向けの 3 経路とランナー向けの 2 経路 | +| 出所の許可 | `factory/api/src/progress/authorize.ts` | 出所 `runner` が `release.*` の 4 種類を、出所 `control` が `release.answered` を書ける | +| 置き場の翻訳 | `factory/runner/src/organizer.ts` | 依頼・提示・届いた段・結果のファイル → 進捗の事象。答え・範囲・まとめた配布の中身 → ファイル。未回答の依頼の再送 | +| ランナーの手順 | `factory/runner/src/main.ts`・`runtime.ts` | `START=release` の手順と、NDF へ渡す環境変数 | +| 契約 | `factory/contracts/openapi/organizer.yaml`・`runs.yaml`・`schemas/progress-event.schema.json`・`schemas/run-job-instruction.schema.json`・`src/index.ts` | 経路・事象・ジョブの指示の形と、レーンの名前と冪等の鍵の式([データと契約](ndf-factory-organizer-data-and-contracts.md)) | +| NDF の切り替え口 | `plugins/ndf/scripts/lib/parallel_external.py`・`parallel_external_cli.py`・`parallel_lane.py`・`parallel.py`・`plugins/ndf/contracts/forms/parallel-external.json`・`plugins/ndf/contracts/env/NDF_ORGANIZER_{DIR,LANE}.json`・`NDF_FACTORY_RUN.json` | 置き場の契約と、依頼の書き出し・答えの読み込み・配布の実行の手順(「NDF の切り替え口」) | + +```mermaid +graph TD + subgraph sg1["API(factory/api)"] + RT[画面向けの経路] + RAPI[ランナー向けの経路] + RC[実行の制御] + OC[配布のまとめ] + TBL[実行の表] + GH[GitHub の口] + end + subgraph sg2["実行ジョブ(ECS か手元のマシン)"] + TR[置き場の翻訳] + MAIN[ランナーの手順] + end + NDF["NDF(切り替え口)"] + ING[進捗の受け口] + RT --> OC + RAPI --> OC + RC -->|停止の後の処理・見回り・再開| OC + OC -->|composeRelease・行| TBL + OC -->|起動| RC + OC --> GH + MAIN --> TR + TR -->|置き場のファイル| NDF + TR -->|release.*| ING + MAIN --> RAPI +``` + +## 仕様 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| オーガナイザーの進行 | 配布のまとめ | リポジトリのオーガナイザー(`repositoryId`) | 受けた依頼・まとめた配布 | 待ちの理由・提示・答え・配布の結果 | +| スプリントの実行 | 実行の制御 | スプリントの実行 | 実行ジョブ | 目的(`release`)・属するまとめた配布・待っている依頼 | +| 開発ループ | ループの制御 | ループ | — | 人の判断待ち・答え | + +- オーガナイザーの進行は、ほかの集約を識別子でだけ参照する +- 1 つの処理が書き換える集約は 1 つで、例外は 2 つである。まとめた配布を組む処理は、オーガナイザーの進行と配布の実行(実行とジョブ)を 1 つのトランザクションで書く。停止の後の処理は、ジョブが送った `release.requested` を受けた依頼の行として、実行の移りと同じトランザクションへ入れる。どちらも、間で止まったときに見回りが 2 本目を起こすか依頼を取りこぼすためである +- 一覧は承認の依頼とループの人の判断待ちを読んで並べるだけで、書き換えない。承認の依頼の持ち主は 1 つに保ち、答えたかどうかを 2 か所に分けない + +### 常に成り立つ条件 + +番号は Factory の確定仕様の続きである。 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| I56 | 同じリポジトリで、終わりの状態でないまとめた配布は 1 つまで | 組む書き込みは、根の `currentSeq` が空で `version` が読んだ値のときだけ通す。落ちたら読み直し、組まない | +| I57 | まとめた配布 1 つに配布の実行は 1 本で、まとめた配布と同じトランザクションで作る | 片方だけを書く経路を持たない。トランザクションが落ちればどちらも無い | +| I58 | 受けた依頼 1 件に答えは 1 回だけ書く | 答えの書き込みは答えが無いことを条件にし、落ちたら書かない | +| I59 | 同じ依頼の id は 1 行(ランナーが送り直しても増えない) | 停止の後の処理は在る行を読んでから無い行だけを書く。`release.requested` は冪等の鍵が依頼の id で 1 件になる | +| I60 | 提示は 1 つのまとめた配布に 2 つまで。`develop` の先頭を比べるのは提示 1 への承認のときだけ | 提示 2 への答えでは GitHub を読まない。3 つ目の提示は記録しない | +| I61 | 答える依頼はまとめた配布の終わり方で決まる。配布済みなら結果の Pull Request の範囲に入る依頼へ `released`。差し戻しなら最後の提示の範囲に入る依頼へ `rejected`。提示の前の差し戻しなら、まとめた配布が受けた依頼すべてへ `rejected`。Pull Request を持たない依頼は、まとめた配布が受けた依頼であれば範囲に入る。どれにも入らない依頼は次のまとめた配布へ残る | 照らせない依頼は答えずに残す | +| I62 | Factory の外の本番の Pull Request が開いている間、またはリポジトリに依頼を出していない古い版の実行がある間は、まとめた配布を組まない。逆に、まとめた配布が終わりの状態でない間は古い版の実行を始めない | 「待ちの判定」のとおり | +| I63 | `main` へのマージを記録したまとめた配布は差し戻せない | 止まった配布の選択肢を「続きから流す」だけにし、差し戻しを 409 `release_after_main` で断る。ジョブの無い配布の実行の取り消しも同じ 409 で断る | +| I64 | 配布の実行は実行の枠(アカウント・リポジトリ)を持たず、使える量の判定にかけない。トークンと費用は登録したアカウントの利用量へ数える | 配布の実行の作成は枠の行を書かない | +| I65 | 配布の依頼を出したループの実行は `done` で終わる。手動の実行は理由 `release_requested` の `awaiting_approval` で枠を持ったまま答えを待つ。配布の実行はジョブが失敗しても、差し戻しか配布済みまで終わりの状態にならない(理由 `release_stalled` の `awaiting_approval`) | 停止の後の処理が実行の目的と `job.finished` の結果から決める | +| I66 | 保存した承認の依頼へ答えを書けるのは人だけ。一覧と履歴の経路は読むだけで、答えを書く経路を持たない。MVV 判定が承認ゲート 2 を通すときは承認の依頼を作らない | 一覧と履歴の経路は GET だけを持つ。止まった配布を解く操作は画面の経路(Cognito の利用者の token)でだけ受ける | +| I67 | 根の状態を変える書き込みは `version` を条件にする(API の handler と見回りが同時に進めても 1 回だけ通る) | 落ちたら 3 回まで読み直す。状態が変わっていれば何もしない | +| I68 | Factory の実行ジョブでは 1 台の中のオーガナイザーを起動せず、本番の配布は依頼として出す | ランナーが `NDF_FACTORY_RUN=1`・`NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE` を必ず渡す。`parallel.py init`・`start`・`run` は `NDF_FACTORY_RUN` があれば 1 で断る | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 実行が配布の依頼を出した(`release.requested`) | ランナー(依頼のファイルの翻訳) | 停止の後の処理(受けた依頼の行)→ 配布のまとめ | +| E2 | まとめた配布を組んだ | 配布のまとめ(`tick`) | 実行の制御(配布の実行を起こす) | +| E3 | 承認資料をそろえ、承認ゲート 2 で止まった(`release.presented`・`gate.reached`) | ランナー(配布の実行) | 配布のまとめ(提示の記録)・承認と通知 | +| E4 | 人が承認ゲート 2 に答えた | 承認と通知(再開の要求) | 配布のまとめ(`decide`) | +| E5 | 答えの時点の `develop` が進んでいたので出し直した | 配布のまとめ(`decide`) | 配布の実行(`represent` で再開) | +| E7 | 本番の Pull Request を作ってマージし、版を出した(`release.progress`・`release.settled`) | ランナー(配布の実行) | 配布のまとめ(`tick`) | +| E8 | 範囲の実行へ配布の結果を返した(`release.answered`) | 配布のまとめ | 実行の進捗の記録・次のジョブ(答えの経路)・手動の実行(`resumeForRelease`) | +| E11 | 外の本番の Pull Request か古い版の実行があったので待った | 配布のまとめ(`tick`) | 一覧(待ちの理由)・記録の行 | +| E12 | MVV 判定が承認ゲート 2 を通した(`release.presented` の `auto` が真) | ランナー(配布の実行) | 配布のまとめ(提示 1 を記録して確定する。`decide` を通さない) | + +承認ゲートの外の問い(判断)は、今の経路(`gate.reached` → 承認の依頼 → 再開の要求、ループは人の判断待ち)のまま持ち主の実行へ返る。 + +### まとめた配布の状態 + +```mermaid +stateDiagram-v2 + [*] --> composed: 組んだ(配布の実行を起こした) + composed --> presented: 承認ゲート 2 で止まった + composed --> confirmed: MVV 判定が通した + presented --> confirmed: 承認(develop が同じ / 提示 2 への承認) + presented --> represented: 提示 1 への承認で develop が進んでいた + represented --> presented: 提示 2 で止まった + presented --> rejected: 差し戻し + confirmed --> released: release.settled(released) + composed --> stalled: 配布の実行のジョブが失敗した + presented --> stalled: 配布の実行のジョブが失敗した + represented --> stalled: 配布の実行のジョブが失敗した + confirmed --> stalled: 配布の実行のジョブが失敗した + stalled --> composed: 続きから流す + stalled --> presented: 続きから流す + stalled --> represented: 続きから流す + stalled --> confirmed: 続きから流す + stalled --> rejected: 差し戻す(main のマージの前だけ) + released --> [*] + rejected --> [*] +``` + +`presented` から止まるのは、提示を送った後、承認ゲート 2 に届く前にジョブが落ちたときである。配布の実行が取り消しなどで終わりの状態になり、`main` へのマージが無ければ、まとめた配布は `rejected` になる。 + +### 依頼から結果まで + +```mermaid +sequenceDiagram + participant L as レーンの実行ジョブ + participant C as 実行の制御 + participant O as 配布のまとめ + participant R as 配布の実行ジョブ + participant A as 承認と通知 + L->>C: release.requested・job.finished + C->>O: 停止の後の処理で依頼の行 → tick + O->>O: 外の Pull Request と古い版を照らす + O->>C: composeRelease → 起動(START=release) + R->>O: release.presented・gate.reached(gate2) + A->>C: 再開の要求(人の答え) + C->>O: decide(develop の先頭を比べる) + O->>C: 再開(confirm:k か represent) + R->>O: release.settled → tick + O->>L: release.answered・答えと範囲(次のジョブ) +``` + +1. **依頼**: NDF は本番のステージの代わりに依頼のファイルを置き場へ書く。ランナーはそれを `release.requested` として送る。ループの実行は周回を配布待ちで締めて `done` で、手動の実行は `job.finished`(`gate`・理由 `release_requested`)で終わる +2. **受ける**: 停止の後の処理が `release.requested` を読み、受けた依頼の行を実行の移りと同じトランザクションで書く。通った後に `tick` を呼ぶ。`tick` が落ちても見回りが続ける +3. **組む**: `tick` は根を読み、まとめた配布が流れておらず未回答の依頼があれば、待ちの判定をしてから組む。依頼が 1 件でも、他の依頼を待たずに組む。組む書き込み(`composeRelease`)は、根・まとめた配布(`composed`)・配布の実行・ジョブ(`START=release`、`RELEASE_STEP=present`)・記録の行を 1 回で書く。続けて起動する +4. **提示**: 配布の実行は、ランナー向けの経路でまとめた配布の中身を読み、NDF の配布の手順を流す。MVV 判定が通せば `release.presented`(`auto` が真)を送って本番の配布まで続け、承認の依頼は作らない。止まれば `release.presented` と `gate.reached`(`gate2`)を送って終わり、配布の実行は `awaiting_approval` になる +5. **答え**: 再開の要求が配布の実行を `decide` へ回す。提示 1 への承認では、GitHub の口で `develop` の先頭を読み、提示したコミットと比べる。同じなら `confirm:1`、進んでいれば `represent` で再開する。提示 2 への承認は比べずに `confirm:2` で再開する。`confirm:` で出すのは提示 k のコミットで、本番のプランはそのコミットから `release/v<版>` を切る。答えの後に `develop` が進んでも出すコミットは変わらない。差し戻しはジョブを起こさず、まとめた配布を `rejected`、配布の実行を `stopped` にし、範囲の依頼へ `rejected`(理由 `release_rejected`)を返す +6. **結果**: 配布の実行が `release.settled`(`released`)を送ると、`tick` が根を空け、まとめた配布を `released` にし、範囲の依頼へ答えを書く +7. **届ける**: `tick` は先頭で、答えがあって届け終えていない依頼を届ける。依頼元の実行の進捗の記録へ `release.answered` を追記し、手動の実行には `resumeForRelease` で再開のジョブを作る。どちらも通った後に届け終えた時刻を書き、見回りの索引から外す。続けて残った依頼で次のまとめた配布を組む +8. **次のジョブ**: レーンの次のジョブ(ループの次の周回か手動の実行の再開)の開始で、ランナーは答えと範囲を読んで置き場へ書き、答えの無い依頼のファイルを送り直す。NDF は周回の境で答えを読み、`released` なら残りの工程を流す + +### 待ちの判定 + +`tick` は組む前に次の順で照らし、当たれば根に待ちの理由と相手を書き、記録の行(`waited`)を積んで終える。次の `tick` で照らし直す。 + +| 順 | 理由 | 当たる条件 | +| --- | --- | --- | +| 1 | `external_release_open` | `main` 宛の開いている Pull Request のうち、head が `release/v` で始まるか `develop` のものがある。新しい方から 20 件のまとめた配布のうち、差し戻したものが提示した版の `release/v<版>` は外のものに数えない(差し戻しの後に開いたまま残った自分の本番の Pull Request で、次の配布が待ち続けないため) | +| 2 | `legacy_run` | リポジトリの枠の `legacyCount` から、未回答の依頼を出して理由 `release_requested` で待つ古い版の実行の数を引いた値が 1 以上(依頼を出した実行は自分で配布しないため数えない) | + +組む書き込みは、リポジトリの枠の `legacySeq`(古い版の実行を数えに入れるたびに 1 増える値)が照らしたときに読んだ値と同じことを条件にする。照らした後に古い版の実行が 1 本でも数えに入れば、`legacyCount` が同じ値へ戻っていても落ちる。古い版の実行の開始は、オーガナイザーの根の `currentSeq` が空であることを条件にし、落ちれば 409 `parallel_unsupported_version` で断る(ループは待つ)。古い版の見分け方は [開発ループの確定仕様](ndf-factory-dev-loops.md) の「NDF の版の照合」にある。 + +**待つ相手は GitHub の上の状態と古い版の実行である。** 2 つのオーガナイザーが互いの記録を読めなくても、両方が読めるのは GitHub だけだからである。head が `develop` の Pull Request も待つのは、並列のオーガナイザーの配布の規則より前の NDF の版がその形の本番の Pull Request を作るためである。 + +まとめた配布のレーンは、依頼のあるレーンと、同じリポジトリで動いている依頼の無いレーン(配布の実行と終わった実行を除く)の両方である。配布の実行の NDF の版は、依頼の NDF の版のうち最も新しいものである。 + +### 止まったときと再開 + +| 場面 | 扱い | +| --- | --- | +| `tick` が組む前・組んだ後に止まった | トランザクションの単位で残るか消えるかが決まる。次の `tick` が根から続ける | +| 組んだ後、起動の前に止まった | 見回りは、配布の実行が `starting` で、ジョブが `pending`・形態 `ecs`・起動の記録が無く、作ってから 60 秒を過ぎていれば起動し直す | +| 配布の実行のジョブが失敗した・打ち切られた | 停止の後の処理は配布の実行を理由 `release_stalled` の `awaiting_approval` へ移す。続く `tick` がまとめた配布を `stalled` にし、止まる前の状態(`stalledFrom`)と理由を書く。`release.progress` に `main_merged` があるか、GitHub で head が `release/v<提示の版>` の `main` 宛の Pull Request がマージ済みなら、`main` へのマージを記録する | +| 止まった配布を「続きから流す」 | `resolve` が同じ配布の実行を再開する。配布の実行を作り直さない。まとめた配布の状態の戻しと再開のジョブの作成は 1 つのトランザクションで書く。再開の手は下の表 | +| 止まった配布を「差し戻す」 | `resolve` が差し戻しの答えと同じ処理をする(I61)。`main` へのマージの後は 409 `release_after_main` | +| 配布の実行の中の NDF が判断を求めた | 今の経路(`gate.reached` の判断 → 承認の依頼 → 再開の要求)。`decide` は、まとめた配布の今の状態から下の表と同じ規則で再開の手を決める。`main` へのマージの後は、ランナーが `gate.reached` の `choices` を `["continue"]` だけにする | +| 同じ承認の答えが再び届いた | 提示に答えた承認の依頼の識別子を記録しておき、今待っている提示への答えでなければ 409 `not_presented` で断る | +| 答えを書いた後、届け終える前に止まった | 届け終えていない依頼は見回りの索引に残り、見回りが届け直す(`release.answered` は冪等の鍵で 1 件) | +| 手動の実行の再開が `busy` | 届け終えたことにせず残し、次の `tick` で呼び直す | +| 手動の実行がもうこの依頼を待っていない | 再開のジョブを作らず、届け終えた時刻を書く。「待っている」は、実行が `awaiting_approval` で、理由が `release_requested`、待つ依頼の id がこの依頼と一致することを指す。この照らしは再開のジョブを作る条件つきの書き込み(`addResumeJob`)の条件に入れる。別の承認ゲートの待ちを、その承認の答え無しに再開させないためである | +| 配布の実行を取り消した | ジョブが動いていれば止め、`main` へのマージの後なら終わりの状態にせず、理由 `release_stalled` の `awaiting_approval` へ移す(続きから流して配布を仕上げる) | + +| 止まる前の状態 | 再開の手(`RELEASE_STEP`) | +| --- | --- | +| `composed` | `present`(提示 1 のプランから作り直す) | +| `presented` | 最後の提示が 1 なら `present`、2 なら `represent` | +| `represented` | `represent`(提示 2 のプランから作り直す) | +| `confirmed` | 本番のプランに手を付けた跡があれば `continue`(保存したプランから続ける)、無ければ `confirm:<確定した提示>`(初めから流す) | + +本番のプランに手を付けた跡とは、`main` へのマージの記録、配布の実行の `release.progress` が 1 件以上、または GitHub に `release/v<提示の版>` の枝か、その枝を head とする `main` 宛の Pull Request があることである。差し戻したまとめた配布が残した同じ版の枝は跡に数えない。ジョブは段の事象を送る前に落ちうるため、GitHub の状態でも照らす。 + +### 配布の実行と枠 + +配布の実行は範囲の実行がすでに入った枠の続きの仕事なので、実行の枠を持たず、使える量の判定にかけない(I64)。枠を持たせると、ループが枠を埋めたリポジトリで配布が組めず、`develop` の変更が `main` へ届かなくなる。同時の数はリポジトリに 1 本まで(I56・I57)で、枠が無くても増えない。費用は `usage.recorded` で配布の実行の識別子に付き、アカウントの利用量と形態 `managed` の月のトークンに数える。 + +依頼の後のループの実行を `done` にし、手動の実行を理由つきの `awaiting_approval` にするのは、今の待ち方と状態の遷移を変えないためである(I65)。ループは周回を配布待ちで締めると次の周回を起こせる。手動の実行は今も承認ゲート 2 の待ちで枠を持ったまま `awaiting_approval` になっている。状態と遷移の表は [スプリントの実行の状態とデータ](ndf-factory-runs-state-and-data.md) にある。 + +### 一覧と止まった配布の操作 + +リポジトリごとの一覧は、同じリポジトリの `awaiting_approval` の実行と、ループの人の判断待ちを、作った時刻の古い順に 1 つの並びで返す。 + +| 種類(`kind`) | 何が並ぶか | +| --- | --- | +| `gate1` / `gate2` / `decision` | 承認ゲートか判断で止まった実行の最後の `gate.reached`。配布の実行のものは、まとめた配布の要約(連番・提示・レーンごとの依頼の有無と Pull Request・加わった Pull Request・選択肢)を添える | +| `release_stalled` | 止まった配布。要約と選択肢(`continue` / `reject`、`main` へのマージの後は `continue` だけ)を添える | +| `loop_pending` | ループの人の判断待ち | + +理由 `release_requested` で配布の答えを待つ手動の実行は並べない(人の答えを待っていないため)。承認の依頼の中身と答えの経路は承認と通知が持ち、一覧は識別子と要約だけを返す。経路の形は [データと契約](ndf-factory-organizer-data-and-contracts.md) にある。 + +### NDF の切り替え口 + +NDF は環境変数 `NDF_ORGANIZER_DIR` があるときだけ、本番の配布を自分で組まずに、外のオーガナイザーへ置き場のファイルで依頼を出す。切り替え口は Factory を名指しせず、手元の別のまとめ役からも同じ形で使える(`plugins/` は `factory/` を参照しない)。手順の正本は `plugins/ndf/skills/development-workflow/references/parallel.md` の「外のオーガナイザーのレーン」、副命令の終了コードは `plugins/ndf/scripts/lib/parallel_external_cli.py` の冒頭の表、置き場の形は [データと契約](ndf-factory-organizer-data-and-contracts.md) にある。 + +例: Factory のループ `l20261102` の周回の実行ジョブで、ランナーが `NDF_ORGANIZER_LANE=loop-l20261102` を渡す。NDF は本番のステージを組む時点で、依頼を `requests/<依頼の id>.json` へ書いて周回を配布待ちで締める。ランナーはそれを `release.requested` として送る。次の周回のジョブの開始で、ランナーは `answers/<依頼の id>.json`(`released`・版)を書き、NDF は周回の境でそれを読んで残りの工程を流す。 + +| 点 | 決まり | +| --- | --- | +| レーンの判定 | `NDF_ORGANIZER_DIR` があれば、外のオーガナイザーのレーン(名前は `NDF_ORGANIZER_LANE`)として判定し、計画名の代わりに `--external` を付ける | +| 依頼 | `parallel.py request --external` は依頼を書いて 10 で終わる。自分の Pull Request がすべてすでに `main` にあれば、書かずに 0 で終わる | +| 答え | `parallel.py await --external` は答えのファイルを読み、`released` で 0、`rejected` で 10、まだ無ければ 3 で終わる | +| 問い | 設計の承認と承認ゲートの外の問いは依頼にせず、今どおり止まって人へ戻す | +| 配布の実行 | `/goal /ndf:development-workflow --parallel-helper --external` で始め、`batch.json` の手(`step`)ごとに 1 手を打つ。`present` / `represent` は提示のプランと本番のプランを流し、承認ゲート 2 で止まれば提示を書いて「判断が要る」で終える。`confirm:` は確定した提示 k の本番のプランだけを流す。`continue` は止まったプランを続ける。終わりに `parallel.py settle --external` が届いた段と結果を書く | + +置き場は取り込んだリポジトリの `.ndf/organizer` で、ランナーが保存する NDF の状態に入り、周回と再開をまたいで残る。ランナーは NDF へ `NDF_FACTORY_RUN=1`・`NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE`・`NDF_VERSION`(入れた NDF の版)を渡す。 + +レーンの名前は、ループの実行が `loop-<ループの名前を小文字にした先頭 27 字>`、手動の実行が `run-<実行の識別子を小文字にした先頭 16 字>` である。式は契約の `organizerLaneName`(`factory/contracts/src/index.ts`)だけが持ち、ランナーと配布のまとめが同じ式で名付ける。 + +### 語: 手元のマシンと料金プラン + +利用者が自分の環境に登録して実行ジョブを受け取るマシンは「手元のマシン」、アカウントの契約の種類(Free・個人 Pro・個人 Max・Team)は「料金プラン」と書く。NDF の「プラン」(`plan.json`)と語を分け、マシンそのものとして読めるようにするためである。改めたのは説明文・用語集・画面の文言(`factory/web/src/i18n/ja.json`)で、コードの識別子(`runnerId`・`plan` など)・API とデータの項目名・翻訳のキーは変えない。識別子を変えると API の互換と保存済みのデータの移行が要るためである。用語集は `手元のランナー` を廃止した語として持つ。 + +## エラー処理 + +| 場面 | 応答 | +| --- | --- | +| `resolve` の `choice` が `continue` / `reject` でない | 400 | +| 登録の無いリポジトリ・無いまとめた配布 | 404(`repository_not_found` / `release_not_found`) | +| 止まっていない配布を解く | 409 `not_stalled` | +| `main` へのマージの後の差し戻し・取り消し | 409 `release_after_main` | +| 今待っている提示への答えでない承認 | 409 `not_presented` | +| 承認の答えが読めない・`develop` の先頭が読めない・3 回読み直しても書けない | 503(`approval_answer_unavailable` / `develop_unreadable` / `organizer_busy`) | +| まとめた配布が流れている間の古い版の実行の開始 | 409 `parallel_unsupported_version` | +| 見回りの `tick` の失敗 | 1 行の記録(`organizer_tick_failed`)を出して次のリポジトリへ進む | + +## セキュリティ + +- 承認の確定は人だけが行う(I66)。止まった配布を解く操作は承認ゲートではなく失敗の扱いで、画面の経路だけで受ける +- 配布の実行のジョブの token と導入の token は、ほかの実行と同じく実行のリポジトリ 1 つに絞る +- どの経路も `resolveFromRequest` でアカウントを解決し、他人のアカウントは 403 + +## 運用 + +- 範囲・出し直し・待ちの理由・結果は、まとめた配布の行(提示を上書きせずに 2 つまで持つ)と記録の行で後から辿れる。履歴の経路で読める +- 依頼から配布の実行の起動までは、見回り 1 回分(5 分)以内である。承認資料を作る時間は含めない +- 依頼を出さない NDF の版の実行は、今どおり自分で本番の配布を流す。並行を許す版の下限(`NDF_PARALLEL_MIN_VERSION`)は、切り替え口の入った NDF の版以上にする。下限より低いと、切り替え口の無い版の実行が古い版として数えられずに自分で配布し、まとめた配布とぶつかる + +## テスト観点 + +検査は `cd factory && npm run check`(型検査と vitest)。配布のまとめの規則はメモリの表(`MemoryRunsTable`)で確かめる(`factory/api/src/organizer/control.test.ts`)。NDF の側は `plugins/ndf/scripts/tests/test_parallel_external.py`。 + +- 同じリポジトリの 2 本の実行が依頼を出すと、まとめた配布 1 つ・配布の実行 1 本になり、結果の後に 2 本とも同じ版と本番の Pull Request の `release.answered` を持つこと +- 依頼 1 件でも他を待たずに組むこと +- 提示 1 への承認で `develop` が進んでいれば `represent`、同じなら `confirm:1` になり、提示 2 への答えでは GitHub を読まないこと +- 流れている間に届いた依頼は組まずに残り、結果の直後の `tick` で次が組まれること。同時の 2 つの `tick` で組まれるのは 1 つであること +- 片方の書き込みを失敗させると、まとめた配布と配布の実行のどちらも無いこと +- 手動の実行が理由 `release_requested` の `awaiting_approval` で枠を持つこと +- 外の本番の Pull Request か依頼を出していない古い版の実行がある間は組まずに待ちの理由を書き、閉じた後の `tick` で組むこと。照らした後に `legacyCount` が戻っても、`legacySeq` の条件で組む書き込みが落ちること。まとめた配布が流れている間は古い版の実行の開始が 409 になること +- 一覧が同じリポジトリの承認の依頼とループの人の判断待ちを 1 つの並びで返し、別のリポジトリのものを含まないこと +- `tick` を途中で止めて呼び直しても、答えは 1 回、依頼の行は 1 行、配布の実行は 1 本であること。届け終えていない依頼は見回りが届け直し、再開した手動の実行が別の承認ゲートで止まっていれば再開のジョブを作らないこと +- `main` へのマージを記録した止まった配布の選択肢は `continue` だけで、差し戻しは 409 になること。`confirmed` から止まった配布は、本番のプランに手を付けた跡があれば `continue`、無ければ `confirm:` で再開すること +- 配布の実行の作成が枠の行を増やさないこと +- NDF の側で、`NDF_ORGANIZER_DIR` があると本番のステージが依頼のプランになり、依頼のファイルが契約の例と同じ形で書かれ、打ち直しても同じ id になること。`confirm:1` が提示 1 の本番のプランだけを流すこと + +テストが無い振る舞い: ループの実行が依頼の後に `done` で終わること(I65)は、ランナー(`factory/runner/src/main.ts`)がループの実行で依頼を `gate` にせず通常の終わりを送ることで成り立つ。API の側のテストはこれを確かめていない。 + +## 関連リンク + +- [Factory のオーガナイザーのデータと契約](ndf-factory-organizer-data-and-contracts.md) +- [並列のオーガナイザー(1 台の中)](ndf-parallel-organizer.md) +- [開発ループと並行する実行の調整](ndf-factory-dev-loops.md) +- [スプリントの実行の状態とデータ](ndf-factory-runs-state-and-data.md) +- [契約の定義と型・進捗の受け口](ndf-factory-contracts-and-progress.md) +- [issue #2439](https://github.com/devbasex/ai-plugins/issues/2439) diff --git a/docs/specifications/ndf-factory-sprint-views.md b/docs/specifications/ndf-factory-sprint-views.md new file mode 100644 index 000000000..3de09dbc1 --- /dev/null +++ b/docs/specifications/ndf-factory-sprint-views.md @@ -0,0 +1,226 @@ +# Neural Development Factory: スプリントの一覧と詳細・工程の中身・バックログの読み出し + +1 つのアカウントで並列に流れる複数のスプリントの実行について、一覧で各実行の今のフェーズと状態を、詳細で課題ごとの工程・Pull Request・CI・承認ゲートを、工程の詳細パネルで触ったファイル・決めたこと・費用・作業の報告を読める。バックログの画面では、取り込んだリポジトリの順位の表と棚卸しの記録をスプリントの外で読める。どれも読むだけで、書き込む操作を持たない。 + +## 概要 + +- **一覧・詳細・工程の中身・バックログは、進捗の事象の列とスプリントの実行の行から、読み出しのたびに導く派生である。** 派生の表は持たない。派生の関数は 2 つの集約のどちらも書き換えない +- **NDF の語はランナーの翻訳の中だけで読む。** ランナーはプランの `"フェーズ"` の値を 5 つのフェーズの名前へ写し、工程の中身を `step.recorded` に任意の項目として添えて送る。API と画面は NDF のファイルを読まない +- **フェーズの名前の集合は `factory/contracts/src/phases.ts` の 1 か所にある。** ランナー・派生・画面が同じ列挙を読む +- 派生の値の導き方と `step.recorded` の工程の中身の項目は、[契約の定義と型・進捗の受け口](ndf-factory-contracts-and-progress.md) の「進捗の事象の定義」と「スプリントの一覧と詳細の読み出し」が正である。この文書は、経路の形・読み出しの口・画面・常に成り立つ条件と、そう決めた理由を持つ + +## 用語 + +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`)。この文書で使う語は「フェーズの名前」「工程の記録」「工程の中身」「スプリント全体の欄」「スプリントの一覧」「スプリントの詳細」「詳細パネル」「バックログの画面」である。 + +## コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| Neural Development Factory(`ndf-factory`) | スプリントの実行・進捗の事象・フェーズの名前・工程の記録・スプリントの一覧と詳細 | +| NDF の開発ワークフロー(`ndf-workflow`) | プラン・ステップ・プランの `"フェーズ"`・`progress.jsonl`・作業の報告 | + +`ndf-factory` が下流で、ランナーの翻訳を腐敗防止層に置く。 + +## 構成要素 + +| 要素 | 場所 | 責務 | +| --- | --- | --- | +| フェーズの名前の列挙 | `factory/contracts/src/phases.ts` | `PHASE_NAMES`(5 つ)・`isPhaseName`・仕上げに常に置く工程 `CLOSING_STEPS`(振り返り `retro`・棚卸し `refine`)。依存を持たず、画面は `@ndf-factory/contracts/phases` から読む | +| ランナーの翻訳 | `factory/runner/src/translate.ts` | プランの `"フェーズ"` を 5 つの名前へ写す。`step.recorded` を作り、事象 1 件を 256 KiB に収める(`fitStep`) | +| 工程の中身の収集 | `factory/runner/src/step-details.ts` | プランの JSON・作業場所の git・判断の台帳・ステップの出力から工程の中身を集める。外のファイルとコマンドに触るのはここだけ | +| 伏せ字 | `factory/runner/src/redact.ts` | NDF の `secret_redact.py` の 4 つの形の規則と、ランナーが持つ秘密の値を `***` にする | +| ランナーの見回り | `factory/runner/src/runtime.ts` | ステップの行ごとに工程の中身の収集を呼ぶ。同じ行から作る `usage.recorded` の `at` を `step.recorded` の `at` にそろえる | +| 進捗の読み出し | `factory/api/src/progress/read.ts` | 読むだけの 4 つの口(「読み出しの口」) | +| 派生 | `factory/api/src/views/derive.ts` | 事象の列と実行の行から一覧の行・詳細・工程の中身・棚卸しの記録を作る純粋な関数。時刻も外の世界も読まない | +| 読み出しの経路 | `factory/api/src/routes/sprint-views.ts`(一覧は `routes/factory.ts`) | アカウントの解決と 403 の本文の統一、読み出しの口と派生の呼び出し | +| 経路の定義 | `factory/contracts/openapi/runs.yaml` | 一覧の項目の追加と 3 つの経路 | +| 画面 | `factory/web/src/pages/SprintsPage.tsx`・`SprintDetailPage.tsx`・`BacklogPage.tsx`・`sprintParts.tsx` | 3 つの画面と導線 | +| 読み直し | `factory/web/src/poll.ts`・`config.ts`・`vite.config.ts` | 画面を開いている間、設定の間隔で読み直し、失敗しても前の表示を残す | + +```mermaid +graph TD + subgraph 実行ジョブ + R1[ランナーの見回り] --> R2[工程の中身の収集] + R2 --> R3[伏せ字] + R1 --> R4[ランナーの翻訳] + end + subgraph API + A1[進捗の受け口] + A2[読み出しの経路] --> A3[派生] + A2 --> A4[進捗の読み出し] + A2 --> A5[実行の表] + A2 --> A6[GitHub の口] + end + W1[3 つの画面と読み直し] + K1[フェーズの名前の列挙] + R4 --> K1 + R4 -->|step.recorded / phase.changed| A1 + A3 --> K1 + W1 --> K1 + W1 -->|一覧・詳細・工程の中身・バックログ| A2 +``` + +## 仕様 + +### 常に成り立つ条件 + +| # | 条件 | +| --- | --- | +| I1 | `step.recorded` の工程の中身の項目はどれも任意で、項目の無い事象(`schema_version` 1 の今の形)も受け口が 202 で受ける | +| I2 | 事象 1 件は 256 KiB 以下。超える `step.recorded` は `output.text` → `files` → `decisions` の順に削り、削ったことを `output.truncated`・`files_omitted` に残す。削っても超えれば工程の中身をすべて外す | +| I3 | `output.text`・`decisions` の文・`files` のパスは、送る前に伏せ字の規則に通す | +| I4 | 一覧の今のフェーズは、その実行の最後の `phase.changed`(`order_key` の順)の `phase`。0 件なら `null` | +| I5 | 5 つの名前に無い `phase` は捨てずに名前のまま返し、読み出しは 200 を返す | +| I6 | 工程は `issues` の課題ごとの欄に 1 回ずつ並び、`issues` が無いか空なら「スプリント全体」の欄に並ぶ。欄の中は `order_key` の順 | +| I7 | 工程のフェーズは `step.recorded` の `phase`、無ければ同じ実行ジョブの直前の `phase.changed` の `phase` | +| I8 | 工程の費用は、同じ `job_id`・同じ `step`・同じ `at` の `usage.recorded` の `cost_usd` の和で、1 件も無ければ `null` | +| I9 | 承認の欄はゲート 1・ゲート 2 の 2 つと判断の 1 つ。どの欄も、その種類の最後の `gate.reached` と、それより後の同じ種類の `gate.answered` から状態を決める | +| I10 | 仕上げの欄は振り返り(`retro`)と棚卸し(`refine`)の行を常に持ち、記録が無ければ `record` が `null` | +| I11 | 詳細の件数の上限は `step.recorded` だけに効き、新しい方から最大 2,000 件読む。超えたら古い `step.recorded` から省き、省いた数を `omitted_steps` に返す。ほかの種類の事象は件数によらずすべて読み、I4・I7〜I9 と使用量の和から欠かさない | +| I12 | 4 つの読み出しは、`resolveAccount` で解決したアカウントの実行とリポジトリだけを返す。他人のアカウント・無いアカウント・アカウントに属さない実行・無い実行・無い工程・登録の無いリポジトリは、どれも同じ本文の 403 `forbidden` | +| I13 | 一覧・詳細・工程の中身の読み出しは GitHub の口を呼ばない。バックログは順位の表の読み出し(`findRankingTable`)だけを呼ぶ | +| I14 | 読み出しは事象の表と実行の表へ書かない | + +### ランナーが送る工程の中身 + +**フェーズの名前の対応づけはランナーの翻訳だけが持つ。** プランの JSON の `"フェーズ"` は NDF が持つフェーズの値で、`設計`・`実装`・`検査`・`配布(開発版)`・`配布(本番)`・`まとめ` の 6 つが現れる。`配布(開発版)` は `取り込み`、`配布(本番)` と `まとめ` は `仕上げ` へ写す。`まとめ` は進め方 `fast` / `auto` のプランで、振り返りと棚卸しを持つため仕上げに当たる。状態のディレクトリの名前の断片(`impl`・`release-prod`)から写さないのは、断片がプランのテンプレートの数だけ増え、フェーズの名前と別に増えるためである。 + +**工程にフェーズと課題を添えるのは、事象の並びに頼らないためである。** 1 つの実行ジョブの中で課題ごとの実装のプランが並列に流れ、事象が交互に届く。課題の番号はプランの名前の番号から取る(`5-impl-2171` なら 2171)。検査・リリースのプランの `"課題"` はスプリントのすべての課題を持つため、使うとスプリント全体の工程がすべての欄に重なる。 + +| 項目 | 取り方 | +| --- | --- | +| 触ったファイル | プランの JSON の `"作業場所"` で、工程の時間の窓(`at − seconds` 〜 `at`)のコミットを `git log --no-merges --numstat` で読む。最大 100 件で、外した数を `files_omitted`。コミットが無ければ空の配列、作業場所が無い・git が 0 以外で終わるときは項目ごと省く | +| コミットの範囲 | 窓の最初のコミットの親と最後のコミット | +| 決めたこと | NDF の判断の台帳(`ask.jsonl`)の `decision` の行のうち、`cwd` が工程の作業場所の下で `at` が窓の中のもの(`by: ai`)。最大 20 件・1 件 1,000 字 | +| 出力 | ステップの出力(`.out`)。`work` のステップは作業の報告(`report`)、`run` のステップはログ(`log`)。UTF-8 で 32 KiB まで | + +収集の失敗は工程の送信を止めない。読めない項目だけを省き、今の形の `step.recorded` に戻る。出力を 32 KiB で切るのは、過去の 11 スプリントの実測で出力の p99 が 19,323 バイトで 99% 以上がそのまま届き、最大(57,843 バイト)でも事象 1 件の上限に収まるためである。 + +**伏せ字の規則は NDF の 4 つの形(URL の資格・GitHub の token の形・`x-access-token:` の値・認証ヘッダー)を写し、ランナーが持つ秘密の値(実行ジョブの token・導入の token・持ち込んだキー・引き受けたロールの資格)を文字列のまま置き換える。** `factory/` から `plugins/` を参照しない境界のため、NDF の Python を呼ばずに TypeScript へ写した。写した規則の出所は `redact.ts` の先頭に書いてある。 + +**工程の費用を結ぶため、同じステップの行から作る `step.recorded` と `usage.recorded` は同じ `at` で送る。** 同じ名前のステップ(`pr`・`merge`)はプランごとに現れ、`step` だけでは別の工程の費用まで足すためである。費用の正は `usage.recorded` で、`step.recorded` に費用を持たせない。conductor の `usage.recorded` はどの工程にも結ばず、使用量の和にだけ入る。 + +**受け口は `phase` を列挙で縛らない。** NDF の版がフェーズを足しても、その版の事象を 400 にせず残すためである。 + +### 経路(`factory/contracts/openapi/runs.yaml`) + +| 経路 | 応答 | +| --- | --- | +| `GET /accounts/{accountId}/sprint-runs` | 200 `SprintRunList`。各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない / 403 | +| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}` | 200 `SprintRunDetail` / 403 / 503 | +| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}` | 200 `StepDetail`。`stepKey` は事象の `idempotency_key` を URL で符号化したもの / 403 / 503 | +| `GET /accounts/{accountId}/repositories/{repositoryId}/backlog` | 200 `Backlog` / 403 / 503(保存か GitHub の口の失敗) | + +403 はどれも `{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}` である。無い実行と他人の実行で、同じ回数の読み出しの後に返す。登録の無いリポジトリも 404 にしない。404 を返すと、アカウントがそのリポジトリを取り込んでいるかが分かるためである。保存の失敗は 503 で、応答に中身を出さない。 + +`SprintRunDetail`: + +| 項目 | 意味 | +| --- | --- | +| `sprint_run_id`・`repo`・`repository_id`・`issues`・`state`・`failure_reason`・`created_at` | 一覧と同じ | +| `ndf_version`・`execution_mode`・`job_count` | 概要の札(実行の行から) | +| `current_phase` | 一覧と同じ | +| `phases` | `{name, step_keys[], required[]}`。5 つの名前の順に 5 つ、その後に現れた順に 5 つに無い名前。`required` は仕上げだけが持つ `[{label, step, record}]`(振り返り・棚卸し)。`record` は最後の記録の `{key, exit_code, seconds}` か `null` | +| `columns` | `{issue, step_keys[], pull_requests[]}`。実行の `issues` の順に課題の欄、最後にスプリント全体の欄(`issue` が `null`) | +| `columns[].pull_requests` | `{repo, number, url, state, checks[]}`。`state` は最後の `pr.updated` か `unknown`、`checks` はチェックの名前ごとの最後の `{name, conclusion}`(`null` は実行中) | +| `steps` | 工程の行 `{key, at, job_id, step, phase, issues, exit_code, seconds, cost_usd, summary, pull_request, has_details}`(`order_key` の順)。`has_details` は工程の中身を 1 つでも持つか | +| `gates` | `{gate1, gate2, decision}`。`gate1`・`gate2` の `state` は `not_reached` / `waiting` / `approved` / `rejected`、`decision` の `state` は `none` / `waiting` / `answered` | +| `delegated_decisions` | `decision.delegated` の件数 | +| `usage` | `{input_tokens, output_tokens, cost_usd}`。`usage.recorded` の和 | +| `omitted_steps` | 上限で省いた工程の数 | + +`StepDetail` は工程の行に、`files`(各行に `url`)・`files_omitted`・`decisions`・`output`・`diff_url` を足したものである。工程の中身を持たない工程ではそれぞれ `null`。リンクは GitHub を呼ばずに文字列で作る。 + +| リンク | 作り方 | +| --- | --- | +| Pull Request | `https://github.com/{repo}/pull/{number}` | +| 差分(`diff_url`) | `pull_request` があれば `…/pull/{number}/files`、無く `commit_range` があれば `…/compare/{from}...{to}`、どちらも無ければ `null` | +| ファイル | `commit_range` があれば `https://github.com/{repo}/blob/{to}/{path}`、無ければ `null` | + +`Backlog` は `repository_id`・`repo`・`ranking`(`{milestone, rows: [{rank, number, title}]}` か `null`)・`refinements`(そのリポジトリの実行の新しい方から 50 件の、`step` が `refine` の `step.recorded` を `at` の新しい順に。`{sprint_run_id, key, at, exit_code, summary, pull_request}`)である。 + +**詳細の応答は工程の中身の本文を持たない。** 詳細は読み直しのたびに返すため、本文を含めると使わない本文を毎回返す。画面は工程を押したときに工程の中身の経路で 1 件を読む。鍵の `idempotency_key` から実行の ID を引けるため、別の実行の鍵を渡されても 403 にできる。 + +### 承認の欄の状態 + +```mermaid +stateDiagram-v2 + [*] --> not_reached + not_reached --> waiting: gate.reached + waiting --> approved: gate.answered(approved) + waiting --> rejected: gate.answered(rejected) + rejected --> waiting: 次の gate.reached + approved --> waiting: 次の gate.reached +``` + +判断の欄は `waiting` / `answered` の 2 つに読み替える。`gate.reached` の前に届いた答えは状態を変えない。承認の依頼の集約は読まず、事象だけから導く。 + +### 読み出しの口(`ProgressReader`) + +| 名前 | 入力 | 出力 | +| --- | --- | --- | +| `listRunEvents` | 実行の ID・`step.recorded` の上限 | `order_key` の順の事象の列(`step.recorded` 以外はすべて、`step.recorded` は新しい方から上限まで、本文の `files`・`decisions`・`output` は読まない)と、上限で読まなかった `step.recorded` の数 | +| `latestOfRun` | 実行の ID | 最後の `phase.changed`・最後の `step.recorded`・最後の事象(無ければ `null`) | +| `getStep` | 実行の ID・`idempotency_key` | その実行の `step.recorded` の全項目か `null`(別の実行の鍵も `null`) | +| `listStepsNamed` | 実行の ID・`step` の値 | その値の `step.recorded`(本文を読まない) | + +`listRunEvents` は DynamoDB の事象の表を `sprint_run_id` で `order_key` の昇順に Query し、全ページ読んでから、手元で古い `step.recorded` を上限の外として捨てる。表に `type` の索引が無く、`Limit` と `FilterExpression` では「`step.recorded` だけを新しい方から上限まで」を作れないためである。新しい方から `Limit` で切る読み方は、古い `gate.reached`・`usage.recorded` を落とすため採らない。上限が抑えるのは応答の大きさだけで、読み出しの量は実行の事象の総数に比例する。 + +一覧は各行について `latestOfRun` を並列 10 本までで呼ぶ。バックログは登録を確かめ、`findRankingTable` と実行ごとの `listStepsNamed(…, "refine")` を並列 10 本までで呼ぶ。 + +**派生の表を持たないのは、今の件数で要らず、書き手が 2 つになるためである。** 過去の 11 スプリントの実測で 1 スプリントの事象は最大 233 件だった。派生の表を持つと、受け口が事象の表と別の表を同じ追記で書くことになる。上限の 2,000 件は実測の最大の約 8.6 倍である。 + +### 画面 + +| 画面 | 経路 | 出す項目 | +| --- | --- | --- | +| スプリントの一覧 | `/accounts/:accountId/sprints` | 札 2 つ(動いている数、対応待ちの数)。タブ(すべて・動いている・対応待ち・終わった。画面の中で絞る)。表の列はスプリント(課題の番号の並びとリポジトリ)・課題・フェーズ(5 つの棒と今の工程。「始まる前」「その他(<名前>)」は文字で)・状態・始めた日時・最後の動き | +| スプリントの詳細 | `/accounts/:accountId/sprints/:sprintRunId` | 見出し(課題の番号・状態・リポジトリ)と「GitHub で開く」。概要の札(NDF の版・実行の形態・始めた日時・実行ジョブの数)。フェーズの欄(仕上げに振り返りと棚卸し)。左に工程の記録(新しいものが上、課題の欄ごとのタブで絞る)。右の既定は承認の欄・課題の欄ごとの Pull Request と CI・AI が代わりに判断した件数・使用量。工程を押すと右が詳細パネル(ステップ・終了コード・秒・費用・要約・触ったファイルと増減の行数・決めたこと・差分のリンク・作業の報告かログ・概要に戻る) | +| バックログの画面 | `/accounts/:accountId/repositories/:repositoryId/backlog` | リポジトリの名前、札 2 つ(最後の棚卸しの日時・順位の表のマイルストーン)。着手の順位の表(順位・課題の番号と題)。棚卸しの記録(日時・要約・Pull Request・終了コード) | + +画面の地図と状態ごとの表示は [画面の地図](ndf-factory-screen-map.md) にある。バックログの画面は読むだけで、課題・マイルストーン・ボードへ書き込む操作を持たない。一覧と詳細は画面を開いている間、画面の設定 `pollIntervalMs`(環境変数 `FACTORY_POLL_INTERVAL_MS`、既定 30,000 ミリ秒)の間隔で読み直す。読み直しは前の読み直しが終わる前に次を打たず、失敗しても前の表示を残して失敗の時刻を出す。押し出しの通知は使わない。401 は token を消してログインへ移る。 + +## セキュリティ + +- 他人の実行の有無を、応答の本文・状態のコード・読み出しの回数で区別できない(I12) +- 作業の報告とログには、コマンドの失敗の出力に混ざった token が入りうるため、ランナーが送る前に伏せる(I3) +- 読み出しは書き込まず(I14)、一覧・詳細・工程の中身は GitHub を呼ばない(I13) + +## 運用 + +- 「触ったファイル」は作業場所のコミットの時刻(秒の精度)で窓に入るものを取る。同じ作業場所で並列のステップがコミットすると、隣の工程のコミットが混ざりうる。NDF のプランは課題ごとに作業場所を分ける前提で、実物では確かめていない +- `commit_range` の `compare` のリンクは、push 前のコミットでは 404 になる +- 一覧と詳細の応答時間の目安は p95 2 秒で、DynamoDB の実環境でしか測れない(未計測)。超えたら派生の表を課題にする +- 判断の「誰が」は、ランナーが読める判断の台帳の行がどれも AI の代わりの判断のため `ai` になる。利用者の答えは承認の欄に出る + +## テスト観点 + +検査は `cd factory && npm run check`(型検査と vitest)と `python3 scripts/check-service-boundary.py`。派生は純粋な関数の単体テスト(`factory/api/src/views/derive.test.ts`)、経路は偽の保存(`factory/api/src/routes/sprint-views.test.ts`)、画面は `factory/web/src/pages/sprints.test.tsx`、ランナーは `factory/runner/src/step-details.test.ts` と翻訳のテストが縛る。 + +- 最後の `phase.changed` が `設計`・`検査`・`仕上げ` の 3 つの実行の一覧の行に、それぞれその名前が出ること。0 件の実行は `null`(「始まる前」) +- 5 つに無い名前(例 `準備`)でも一覧が 200 を返し、名前のまま出ること +- 翻訳がプランの `"フェーズ"` の 6 つの値を写し、プランの JSON が読めないときはプランの名前を送ること +- 課題 #A・#B・番号なしの工程がそれぞれの欄に `order_key` の順で並び、`issues: [A, B]` の工程が 2 つの欄に 1 回ずつ並ぶこと +- 欄の Pull Request の状態が最後の `pr.updated`、チェックが名前ごとの最後の結論で、`pr.updated` が無ければ `unknown` であること +- 工程の費用が同じ `job_id`・`step`・`at` の和で、別の `at` の `usage.recorded` を足さないこと +- 承認の欄が 3 つで、判断の `gate.reached` がゲート 1 を変えないこと +- 仕上げの 2 行が記録の前は `record: null` であること +- 偽の時計で間隔ごとに読み直し、読み直しの失敗で前の表示が残ること +- 4 つの経路で、他人・無いアカウント・属さない実行・無い実行(バックログは登録の無いリポジトリも)の 403 の本文が同じであること +- 3 つの画面で 401 を受けると token が消えてログインへ移ること +- 一覧・詳細・工程の中身で偽の GitHub の口の呼び出しが 0 回、バックログは `findRankingTable` だけであること。読み出しの後に保存の行が変わらないこと +- 工程の中身を持たない `step.recorded` が 202 で受かり、詳細に出ること +- バックログの棚卸しの記録が新しい順で、順位の表が無いときに `ranking: null` であること +- 300 KiB になる `step.recorded` が 256 KiB 以下になり、`output.truncated` が真になること +- ランナーが持つ秘密の値と 4 つの形を含む報告・判断・パスが、送る本文に残らないこと +- `phase` の無い工程が同じ実行ジョブの直前の `phase.changed` のフェーズに入ること +- `step.recorded` 2,001 件と、その前の `gate.reached`・`usage.recorded` を持つ列で `omitted_steps` が 1 になり、省くのが最も古い工程で、承認の欄と使用量の和が全件から導いた値と一致すること + +## 関連リンク + +- [契約の定義と型・進捗の受け口](ndf-factory-contracts-and-progress.md) +- [サービスの境界とサービス全体の契約](ndf-factory-service-boundary.md) +- [画面の地図](ndf-factory-screen-map.md) +- [リポジトリの登録・スプリントの実行の制御・ランナー](ndf-factory-runs-and-runner.md) +- [issue #2172](https://github.com/devbasex/ai-plugins/issues/2172) diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index 12e7c48ac..bb1f17aa0 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.16` | 利用者が常用してよい | +| 正式版 | `10.27.18` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.16` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.18` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` diff --git a/issues/issue-2172-design-decisions.md b/issues/issue-2172-design-decisions.md deleted file mode 100644 index ccb4c0c37..000000000 --- a/issues/issue-2172-design-decisions.md +++ /dev/null @@ -1,117 +0,0 @@ -# #2172 の設計の決定 - -設計文書の本体は `issues/issue-2172-design.md` にある。I の番号はその「不変条件」、F の番号は「機能一覧」を指す。 - -## 決定の記録 - -### 決定 1: フェーズの名前を 1 つの意味にするため、ランナーがプランの `"フェーズ"` の値を 5 つの名前へ写し、`まとめ` も `仕上げ` へ写す - -今のランナーは状態のディレクトリの名前(`5-impl-2171-state`)の断片を `phase` に送り、`impl`・`release-prod`・`sprint-branch` のようにプランの数だけ名前が増える。プランの JSON の `"フェーズ"` は NDF が持つフェーズの値で、実測で 6 つしか現れない。写しの表は `設計` → `設計`、`実装` → `実装`、`検査` → `検査`、`配布(開発版)` → `取り込み`、`配布(本番)` → `仕上げ`、`まとめ` → `仕上げ` とする。`まとめ` は `pace: fast` / `auto` のプラン(`new close`)で、振り返りと棚卸しを持つため仕上げに当たる(`agent-layers.md` の仕上げの行)。写しはランナーの翻訳だけが持ち、NDF の語を外へ出さない。プランの JSON が読めない・表に無い値は、今と同じくプランの名前を送り、画面は「その他(<名前>)」と出す(I5)。 - -状態のディレクトリの名前の断片から写す案は採らない。断片の語は NDF のプランのテンプレートの名前で、フェーズの名前と別に増える。 - -根拠: Value 6 / Value 8(MVV 版 3) - -### 決定 2: 工程のフェーズを事象の並びに頼らず決めるため、`step.recorded` に `phase` を足す - -1 つの実行ジョブの中で課題ごとの実装のプランが並列に流れ、ランナーはファイルが替わるたびに `phase.changed` を送る。工程のフェーズを「直前の `phase.changed`」で決めると、`order_key`(`at` のミリ秒と冪等の鍵の文字列)が同じミリ秒で `:10` を `:9` より前に並べたとき、隣のプランのフェーズに入る。ランナーは工程を送る時点でプランのフェーズを知っているため、工程に添えれば並びに頼らない。`phase` の無い事象(足す前の形)だけ、同じ実行ジョブの直前の `phase.changed` で読む(I7)。 - -`order_key` の作り方を変える案は採らない。事象の表の鍵で、既に残った事象の並びが変わる。 - -根拠: Value 6(MVV 版 3) - -### 決定 3: 課題ごとの欄を課題を単位とするプランだけに絞るため、`issues` はプランの名前の番号から取る - -NDF のプランの名前は、課題を単位とするもの(`1-design-2206`・`5-impl-2171`)だけが番号を持ち、スプリントで 1 つのもの(`4-sprint-branch`・`6-check`・`7-release`)は持たない。今の `phaseOf` が既にこの番号を取り、`phase.changed` の `issues` に入れている。工程にも同じ番号を添えれば、スプリントで 1 つの工程は番号を持たず「スプリント全体」の欄に入る(前提 5)。 - -プランの JSON の `"課題"` を使う案は採らない。検査・リリースのプランはスプリントのすべての課題を `"課題"` に持ち、スプリント全体の工程がすべての課題の欄に重なって出る。 - -根拠: Value 6(MVV 版 3) - -### 決定 4: NDF を変えずに触ったファイルを取るため、作業場所の git の、工程の時間の窓のコミットから取る - -NDF のステップの行は触ったファイルを持たない。プランの JSON の `"作業場所"` で `git log --no-merges --since= --until= --numstat` を打てば、その工程が作ったコミットのファイルと増減の行数が取れる。窓の最初のコミットの親と最後のコミットを `commit_range` に残し、差分とファイルのリンクを作る。コミットの無い工程は空の配列、作業場所が無い(消された)・git が 0 以外で終わるときは項目ごと省く。上限は 100 件で、超えた数を `files_omitted` に残す。 - -NDF のステップの行へファイルを足す案は採らない。`plugins/` を変えることになり、この課題の対象範囲の外である。工程の前後の `HEAD` を見回りが覚える案も採らない。見回り(2 秒ごと)の 1 回に複数の行が届くと、前後の `HEAD` が工程ごとに分かれない。 - -根拠: Value 5 / Value 6(MVV 版 3) - -### 決定 5: 決めたことを工程へ結び付けるため、判断の台帳の `decision` の行を作業場所と時刻の窓で引く - -ランナーは既に NDF の判断の台帳(`ask.jsonl`)の `decision` の行を `decision.delegated` として送っている。行は `cwd`(実際の作業のディレクトリ)と `at` を持つため、`cwd` が工程の作業場所の下にあり、`at` が工程の時間の窓(決定 4 と同じ)に入る行を、その工程の `decisions`(`by: ai`)にする。`decision.delegated` は今のまま送り、承認の欄の「AI が代わりに判断した件数」はその件数で数える。 - -派生が `decision.delegated` を時刻で工程へ結ぶ案は採らない。`decision.delegated` は作業場所を持たず、並列のプランのどれの判断かが分からない。 - -根拠: Value 6(MVV 版 3) - -### 決定 6: 作業の報告とログを 1 つの項目 `output` にし、種類をステップの `type` から決め、32 KiB で切る - -NDF はステップの出力を状態のディレクトリの `.out` に 1 つだけ書き(ステップの行の `out`)、`work` のステップでは作業の報告、`run` のステップではコマンドの出力(ログ)になる。項目を 2 つに分けても、どちらか一方しか埋まらない。`kind` を `report` / `log` にし、画面は「作業の報告を開く」か「ログを開く」の一方を出す。実測で `.out` の p99 は 19,323 バイトで、32 KiB で切れば 99% 以上がそのまま届き、最大(57,843 バイト)の報告でも事象 1 件の上限(256 KiB)に収まる。切ったときは `truncated` を真にする。 - -`.out` を事象に入れず、画面が別の置き場から読む案は採らない。ランナーの外に置き場が要り、事象の列だけから導くという前提 1 を外れる。 - -根拠: Value 3 / Value 10(MVV 版 3) - -### 決定 7: 秘密を外へ出さないため、NDF の伏せ字の 4 つの規則を TypeScript へ写し、ランナーが持つ秘密の値をそのまま置き換える - -作業の報告とログには、コマンドの失敗の出力に混ざった token が入りうる(未決 3)。NDF の `secret_redact.py` の 4 つの規則(URL の資格・GitHub の token の形・`x-access-token:` の値・認証ヘッダー)を `factory/runner/src/redact.ts` に写し、`output.text`・`decisions` の文・`files` のパスに当てる。加えて、ランナーが自分で持つ秘密の値(実行ジョブの token・導入の token・持ち込んだキーの値・引き受けたロールの資格)は形に頼らず文字列のまま `***` に置き換える。行と字の数の切り詰め(`redact_output` の 5 行・500 字)は写さない。外へ出す本文は 32 KiB の上限(決定 6)が縛る。 - -`plugins/` の Python を呼ぶ案は採らない。`factory/` から `plugins/` を参照しない境界(`check-service-boundary.py`)に反する。規則が 2 か所になるため、写した規則の出所(`secret_redact.py`)を `redact.ts` の先頭に書き、伏せ字のテストに NDF の規則の 4 つの形を入れる。 - -根拠: C1 / Value 1 / Value 6(MVV 版 3) - -### 決定 8: 工程の費用を 1 つに結ぶため、ステップの行から作る `usage.recorded` の `at` を、同じ行の `step.recorded` の `at` に揃える - -前提 6 の「`payload.step` が等しい `usage.recorded` の和」は、同じ名前のステップ(`pr`・`merge`)がプランごとに現れるため、そのままでは別の工程の費用まで足す。ランナーは 1 つのステップの行から `step.recorded` と `usage.recorded`(モデルごと)を作るため、同じ `at` を付ければ `job_id`・`step`・`at` の 3 つで一意に結べる(I8)。conductor の `usage.recorded`(`step: "conductor"`)はどの工程にも結ばず、使用量の和にだけ入る。揃える前の事象は結べず「—」になる。 - -`step.recorded` に費用を足す案は採らない。費用の正は `usage.recorded`(確定仕様の「`usage.recorded` は 3 つの形態とも出す」)で、2 つの項目に同じ値を持つと食い違ったときにどちらを読むかが決まらない。 - -根拠: Value 3 / Value 6(MVV 版 3) - -### 決定 9: 派生の表を持たず、読み出しのたびに事象から導く。詳細の件数の上限は 2,000 件、応答の時間の上限は p95 2 秒とする - -実測で 1 スプリントの事象は最大 233 件、32 KiB で切った報告の和は最大 375,257 バイトで、1 つの実行の事象は DynamoDB の Query の 1 ページ(1 MB)に収まる。一覧の 50 件は実行ごとに最後の事象から逆に読み、並列 10 本までで済む。派生の表を持つと、受け口が事象の表と別の表を同じ追記で書くことになり、書き手が 2 つになる(集約の持ち主は 1 つ)。件数の上限は実測の最大の約 8.6 倍の 2,000 件とし、超えたら古い工程から省く(I11)。表に `type` の索引が無いため、詳細は件数によらず実行の事象を `LastEvaluatedKey` が無くなるまで全ページ読み、古い `step.recorded` は手元で捨てる。上限が抑えるのは応答の大きさだけで、読み出しの量(RCU・ページの数)は抑えない。「Query の 1 ページに収まる」は実測の範囲(233 件)の見込みで、上限を超える実行は複数ページになる。`ScanIndexForward=false` と `Limit` で切ると古い `gate.reached`・`usage.recorded` を落とすため、その読み方はしない。時間の上限は実環境でしか測れないため、リリース後テストで測り、超えたら派生の表を課題にする(未確認 1)。 - -派生の表を持つ案(未決 4 のもう一方)は採らない。今の件数で要らず、受け口の書き込みの経路を増やす。 - -根拠: Value 3 / Value 10(MVV 版 3) - -### 決定 10: 読み直しを軽く保つため、詳細の応答から工程の中身の本文を外し、工程の中身を 1 件ずつ読む経路を分ける - -詳細は 30 秒ごとに読み直す(前提 7)。本文を含めると、実測の最大で 1 回 375 KB を超える応答を読み直すたびに返す。詳細の応答は工程の行(`StepRow`)と `has_details` だけを持ち、詳細パネルを開いたときに `GET …/steps/{stepKey}` で 1 件を読む。鍵は事象の `idempotency_key` で、冪等の鍵の表から `order_key` と実行の ID を引けるため、別の実行の鍵を渡されても 403 にできる(I12)。 - -詳細の応答に本文をすべて含める案は採らない。読み直しのたびに使わない本文を返す。 - -根拠: Value 10(MVV 版 3) - -### 決定 11: NDF の版がフェーズを足しても記録を失わないため、受け口は `phase` を列挙で縛らず、フェーズの名前の列挙を依存の無い入口に置く - -`phase.changed` と `step.recorded` の `phase` は定義の上では文字列のままにし、5 つの名前は `factory/contracts/src/phases.ts` の定数(`PHASE_NAMES`・`isPhaseName`)に置く(前提 2)。画面も同じ定数を読むため、`@ndf-factory/contracts/phases` の入口を `package.json` の `exports` に足し、ajv を読む今の入口(`src/index.ts`)を画面へ入れない。 - -定義の `enum` にする案は採らない。NDF の版がフェーズを足すと、その版の事象が 400 になり残らない。 - -根拠: Value 1 / Value 6(MVV 版 3) - -### 決定 12: 読み直しの間隔を 1 か所で変えるため、画面の設定 `pollIntervalMs` に置き、環境変数 `FACTORY_POLL_INTERVAL_MS`(既定 30,000)から埋め込む - -画面の設定は既に `vite.config.ts` が環境変数から `FactoryConfig` へ埋め込む形を持つ。同じ形に 1 項目を足し、一覧と詳細の読み直し(`poll.ts`)がそこから読む。読み直しは画面が見えている間だけ動き、前の読み直しが終わる前に次を打たない。 - -API が間隔を返す案は採らない。読み直しの前に 1 回の読み出しが増え、設定の置き場も 2 つになる。 - -根拠: Value 5(MVV 版 3) - -### 決定 13: 事象の形の追加を承認ゲート 1 で見るため、この設計 Pull Request を「先に出す Pull Request」とし、確定仕様の本文は実装の中で直す - -要求は、事象の形の追加を確定仕様を直す Pull Request で先に出し、承認ゲート 1 で見ることを求める。足す形(「データ構造」の表)と翻訳の規則(決定 1〜8)はこの設計文書が持ち、承認ゲート 1 で人が見る。確定仕様 2 本(`ndf-factory-service-boundary.md` の「進捗の事象の種類と受け口」と「受け持つ課題」、`ndf-factory-contracts-and-progress.md` の「進捗の事象の定義」)は、実装の Pull Request の中で定義と同時に直し、定義と仕様が別々の差分で食い違う時間を作らない。 - -確定仕様だけを直す Pull Request を設計の前に別に出す案は採らない。承認の工程が 1 つ増える(承認ゲートは設計と本番への配布の 2 回)。 - -根拠: Value 2 / Value 7(MVV 版 3) - -### 決定 14: リポジトリの有無を明かさないため、登録の無いリポジトリのバックログも同じ 403 にする - -バックログの経路は `repositoryId` を受ける。登録が無いときに 404 を返すと、他人のアカウントの 403 と区別でき、アカウントがそのリポジトリを取り込んでいるかが分かる。一覧と詳細の I12 と同じ本文の 403 に揃える。 - -404 を返す案は採らない。前提 9 の「有無を明かさない」に反する。 - -根拠: C2 / Value 6(MVV 版 3) diff --git a/issues/issue-2172-design.md b/issues/issue-2172-design.md deleted file mode 100644 index 69747463c..000000000 --- a/issues/issue-2172-design.md +++ /dev/null @@ -1,482 +0,0 @@ -# NDF Factory: 並列に動くスプリントの今のフェーズと、課題ごとの工程・Pull Request・CI・承認ゲートを画面で追える(#2172) - -## 目的 - -- **何が壊れているか**: Factory の画面には、スプリントの実行の状態(`running` など)しか出ない。今どのフェーズにいるか、課題ごとにどの工程まで進んだか、Pull Request と CI と承認ゲートがどうなっているかは、進捗の事象として残っているのに読み出す経路が無い。`phase.changed` の `phase` はプランの名前の断片(`impl`・`release-prod`)で、フェーズの名前になっていない -- **誰が困るか**: 1 つのアカウントで複数のスプリントを並列に流す利用者。どれが承認を待ち、どれが止まったかを GitHub と端末を見比べて探すことになる -- **直すと何が成り立つか**: スプリントの一覧で各実行の今のフェーズと状態を、スプリントの詳細で課題ごとの工程・Pull Request・CI・承認ゲートを、工程の詳細パネルで触ったファイル・決めたこと・費用・作業の報告を読める。バックログの画面で順位の表と棚卸しの記録をスプリントの外で読める - -## 適用範囲 - -- **働く範囲**: `factory/` の中(契約・ランナー・API・画面)と、確定仕様 2 本。`plugins/` とプラグインの利用者の振る舞いは変えない -- **プロジェクトごとに違うもの**: 取り込んだリポジトリごとの違い(作業場所・テストのコマンド)はランナーが NDF のプランの JSON から読む。画面の読み直しの間隔は画面の設定(環境変数 `FACTORY_POLL_INTERVAL_MS`)で受ける -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| 依頼(2026-10-30)「sprint一覧でそれぞれがどのフェーズを進めているかが可視化できる」「sprint詳細で進捗状況が可視化できる」 | 利用者の指示の原文 | 一覧はフェーズ、詳細は進捗を出す | -| モック https://claude.ai/artifact/MYL6CPBTsm6JFTNgtiwY2J(版 1793412669-5df4。要求の「モックの中身」に写した) | 利用者の指示 | 3 画面の配置と項目、詳細パネルの項目 | -| 過去の 11 スプリントの `progress.jsonl`(`/work/ai-plugins/.ndf/loop`・`.ndf/tmp`、94 ファイル、2026-10-31 に測った) | 実測 | 1 スプリントの記録は最大 399 行、事象にして最大 233 件。ステップの行は最大 525 バイト。作業の報告(`.out`)は 1,114 件で p50 353 バイト・p99 19,323 バイト・最大 57,843 バイト。32 KiB で切った報告の和は 1 スプリントで最大 375,257 バイト | -| 同じ記録のプランの JSON の `"フェーズ"` の値 | 実測 | `設計`・`実装`・`検査`・`配布(開発版)`・`配布(本番)`・`まとめ` の 6 つが現れる。振り返りと棚卸しのステップの識別子は `retro`・`refine`(`supervise_lib/sprint_close_steps.py`) | -| NDF の `agent-layers.md` の「フェーズ」の表 | 一次情報(このリポジトリの規約) | 取り込みのフェーズが検証リリースまで、仕上げが本番へのリリース・振り返り・棚卸しを通す | - -要求と受け入れ条件は #2172 の本文にある(コピーは `issues/issue-2172-requirements.md`)。この文書は「どう作るか」だけを扱う。決定の記録は `issues/issue-2172-design-decisions.md` に分けた。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| Neural Development Factory(`ndf-factory`) | スプリントの実行・進捗の事象・フェーズの名前・工程の記録・スプリントの一覧と詳細 | -| NDF の開発ワークフロー(`ndf-workflow`) | プラン・ステップ・プランの `"フェーズ"`・`progress.jsonl`・作業の報告 | - -**`ndf-factory` が下流で、ランナーの翻訳を腐敗防止層に置く(腐敗防止層)。** NDF の語(`配布(本番)`・`retro`・`.out`)はランナーの翻訳の中だけで読み、進捗の事象の形へ移してから外へ出す。#2169 の確定仕様の決定 6 のとおりで、この変更は翻訳の中身を足すだけである。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 進捗の記録(変える) | 進捗の受け口(`factory/api/src/progress/`)。追記だけ | スプリントの実行ごとの事象の列 | 進捗の事象 | 工程の中身(フェーズ・課題・触ったファイル・コミットの範囲・決めたこと・出力) | -| スプリントの実行(読むだけ) | 実行の制御(#2186、`factory/api/src/runs/`) | スプリントの実行 | 実行ジョブ | 状態・課題 | - -**一覧・詳細・バックログは集約でなく、2 つの集約から導く読み出しの形(派生)である。** 派生は表に保存せず、読み出しのたびに導く(決定 9)。派生の関数は 2 つの集約のどちらも書き換えない。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 進捗の記録 | `step.recorded` の足した項目(`phase`・`issues`・`files`・`files_omitted`・`commit_range`・`decisions`・`output`)はどれも任意で、無い事象(`schema_version` 1 の今の形)も受け口が 202 で受ける | 受け口が 400 を返したら定義の誤り。既存の受け口のテストが落ちる | -| I2 | 進捗の記録 | 事象 1 件は 256 KiB 以下。ランナーは超える `step.recorded` を `output.text` → `files` → `decisions` の順に削って収め、削ったことを `output.truncated`・`files_omitted` に残す | 削っても超えるなら足した項目をすべて外して送る(今の形に戻る) | -| I3 | 進捗の記録 | ランナーは `output.text`・`decisions` の文・`files` のパスを、送る前に伏せ字の規則に通す | 規則を通らない送信の経路があれば、伏せ字のテストが落ちる | -| I4 | 派生 | 一覧の今のフェーズは、その実行の最後の `phase.changed`(`order_key` の順)の `phase` である。0 件なら `null`(画面は「始まる前」) | — | -| I5 | 派生 | 5 つの名前に無い `phase` は捨てずに名前のまま返す(画面は「その他(<名前>)」)。読み出しは 200 を返す | — | -| I6 | 派生 | 工程は `payload.issues` の課題ごとの欄に 1 回ずつ並び、`issues` が無いか空なら「スプリント全体」の欄に並ぶ。欄の中は `order_key` の順 | — | -| I7 | 派生 | 工程のフェーズは `payload.phase`、無ければ同じ実行ジョブの直前の `phase.changed` の `phase` である | — | -| I8 | 派生 | 工程の費用は、同じ `job_id`・同じ `payload.step`・同じ `at` の `usage.recorded` の `cost_usd` の和で、1 件も無ければ `null`(画面は「—」) | — | -| I9 | 派生 | 承認の欄はゲート 1・ゲート 2 の 2 つだけで、判断の欄は 1 つ。どの欄も、その種類の最後の `gate.reached` と、それより後の同じ種類の `gate.answered` から状態を決める | — | -| I10 | 派生 | 仕上げの欄は、振り返り(`retro`)と棚卸し(`refine`)の行を常に持つ。記録が無ければ `record` が `null`(画面は「まだ」) | — | -| I11 | 派生 | 詳細の上限は `step.recorded` だけに効き、新しい方から最大 2,000 件読む。超えたら古い `step.recorded` から省き、省いた件数を `omitted_steps` に返す。`step.recorded` 以外の事象(`phase.changed`・`gate.reached`・`gate.answered`・`usage.recorded`・`job.*` など)は件数によらずすべて読み、I4・I7〜I9 の状態と `usage` の和から欠かさない | — | -| I12 | 読み出しの経路 | 一覧・詳細・工程の中身・バックログは、`resolveAccount` で解決したアカウントの実行とリポジトリだけを返す。他人のアカウント・無いアカウント・アカウントに属さない実行・無い実行・登録の無いリポジトリは、どれも同じ 403 `forbidden` の本文 | 本文か状態のコードが分かれたら、認可のテストが落ちる | -| I13 | 読み出しの経路 | 一覧・詳細・工程の中身の読み出しは GitHub の口を呼ばない。バックログは順位の表の読み出し(`findRankingTable`)だけを呼び、書き込む口を呼ばない | 偽の GitHub の口の呼び出しの数で落ちる | -| I14 | 読み出しの経路 | 読み出しは事象の表と実行の表へ書かない | 偽の保存の行が変わればテストが落ちる | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | ランナーがフェーズの移りを `phase.changed` で送った(`phase` は 5 つの名前に写した値) | ランナーの翻訳 | 進捗の受け口 → 一覧・詳細の派生 | -| E2 | ランナーが工程の終わりを `step.recorded` で送った(足した項目つき) | ランナーの翻訳 | 進捗の受け口 → 詳細の派生・工程の中身・バックログ | -| E3 | GitHub の Webhook から `pr.updated`・`ci.updated` が残った | 進捗の受け口(GitHub の翻訳) | 詳細の派生(課題の欄の Pull Request と CI) | -| E4 | ランナーが承認ゲートに達し `gate.reached` と `job.finished`(`gate`)を送った | ランナー | 詳細の派生(承認の欄)・実行の制御(`awaiting_approval`) | -| E5 | 利用者が答え、`gate.answered` が残った | #2173 の API | 詳細の派生(承認の欄) | -| E6 | 利用者がスプリントの一覧を開いた | 画面 | 一覧の経路 | -| E7 | 利用者がスプリントの詳細を開いた | 画面 | 詳細の経路 | -| E8 | 利用者が工程の行を押し、詳細パネルに切り替わった | 画面 | 工程の中身の経路 | -| E9 | 画面が読み直しの間隔ごとに読み直した | 画面の読み直し | 一覧・詳細の経路 | -| E10 | 利用者がバックログの画面を開いた | 画面 | バックログの経路 → GitHub の口(順位の表) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| フェーズの名前 | `phase.changed` と `step.recorded` の `phase` が運ぶ、`設計`・`実装`・`検査`・`取り込み`・`仕上げ` の 5 つ。`factory/contracts/src/phases.ts` の列挙が正 | 追加(`ndf-factory`) | -| 工程の記録 | スプリントの詳細に並ぶ `step.recorded` の 1 件ずつの行。NDF のプランのステップ 1 つが 1 行になる | 追加(`ndf-factory`) | -| 工程の中身 | 1 件の工程の記録の、触ったファイル・コミットの範囲・決めたこと・出力(作業の報告かログ)。詳細パネルが出す | 追加(`ndf-factory`) | -| スプリント全体の欄 | スプリントの詳細で、課題の番号を持たない工程の記録を並べる欄 | 追加(`ndf-factory`) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | スプリントの一覧で、実行ごとのリポジトリ・課題・状態・今のフェーズ・今の工程・始めた日時・最後の動きを見て、タブで絞る | アカウントの持ち主と席を持つ人 | -| F2 | スプリントの詳細で、フェーズの欄・課題ごとの工程の記録と Pull Request と CI・承認の欄・使用量を見る | 同上 | -| F3 | 工程の記録を押して詳細パネルで工程の中身を見て、概要へ戻る | 同上 | -| F4 | バックログの画面で、リポジトリの順位の表と棚卸しの記録を見る | 同上 | -| F5 | 画面を開いたまま、新しい進捗の事象を読み直しで出す | 同上 | -| F6 | ランナーが、フェーズの名前を 5 つへ写し、工程の中身を `step.recorded` に添えて送る | ランナー(実行ジョブ) | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| フェーズの名前の列挙(`factory/contracts/src/phases.ts`、新設) | 5 つの名前・`isPhaseName`。依存を持たず、`@ndf-factory/contracts/phases` で画面からも読める | -| 進捗の事象の定義(`factory/contracts/schemas/progress-event.schema.json`) | `StepRecordedPayload` に任意の 7 項目を足す(「データ構造」)。型を作り直す | -| ランナーの翻訳(`factory/runner/src/translate.ts`) | プランの `"フェーズ"` の値を 5 つの名前へ写す。ステップの行と工程の中身から `step.recorded` を作り、256 KiB に収める | -| 工程の中身の収集(`factory/runner/src/step-details.ts`、新設) | プランの JSON・作業場所の git・判断の台帳・`.out` から工程の中身を集める。外のファイルとコマンドに触るのはここだけ | -| 伏せ字(`factory/runner/src/redact.ts`、新設) | NDF の `secret_redact.py` の 4 つの規則と、ランナーが持つ秘密の値(実行ジョブの token・導入の token・持ち込んだキー)を `***` にする | -| ランナーの見回り(`factory/runner/src/runtime.ts`) | ステップの行ごとに工程の中身の収集を呼ぶ。ステップの行から作る `usage.recorded` の `at` を、同じ行の `step.recorded` の `at` に揃える | -| 進捗の読み出し(`factory/api/src/progress/read.ts`) | 読むだけの 4 つの操作を足す(「入出力の契約」の読み出しの口) | -| 派生(`factory/api/src/views/derive.ts`、新設) | 事象の列と実行の行から、一覧の行・詳細・工程の中身・棚卸しの記録を作る純粋な関数。時刻も外の世界も読まない | -| 読み出しの経路(`factory/api/src/routes/sprint-views.ts`、新設。既存の一覧の経路は `routes/factory.ts`) | アカウントの解決と 403 の揃え、読み出しの口と派生の呼び出し、応答の本文の組み立て | -| 経路の定義(`factory/contracts/openapi/runs.yaml`) | 一覧の項目の追加と、3 つの経路の追加 | -| 画面(`factory/web/src/pages/SprintsPage.tsx`・`SprintDetailPage.tsx`・`BacklogPage.tsx`、新設。`api.ts`・`App.tsx`・`AccountPage.tsx`・`TeamPage.tsx`・`RepositoriesPage.tsx`) | 3 画面と導線。401 で token を消してログインへ移る(既存の `api.ts` の扱い) | -| 読み直し(`factory/web/src/poll.ts`、新設。`config.ts`・`vite.config.ts`) | 画面を開いている間、設定の間隔で読み直し、失敗しても前の表示を残す | -| 確定仕様(`docs/specifications/ndf-factory-service-boundary.md`・`ndf-factory-contracts-and-progress.md`) | 事象の種類の表・事象の定義・フェーズの名前の集合を、この設計の形へ直す(決定 13) | - -```mermaid -graph TD - subgraph 実行ジョブ - R1[ランナーの見回り] --> R2[工程の中身の収集] - R2 --> R3[伏せ字] - R1 --> R4[ランナーの翻訳] - end - subgraph 契約 - K1[フェーズの名前の列挙] - K2[進捗の事象の定義] - K3[経路の定義] - end - subgraph API - A1[進捗の受け口(既存)] - A2[読み出しの経路] --> A3[派生] - A2 --> A4[進捗の読み出し] - A2 --> A5[実行の表(既存)] - A2 --> A6[GitHub の口(既存)] - end - subgraph 画面 - W1[3 画面と導線] --> W2[読み直し] - end - R4 --> K1 - R4 -->|step.recorded / phase.changed| A1 - A1 --> K2 - A3 --> K1 - W2 -->|一覧・詳細・工程の中身・バックログ| A2 - W1 --> K1 - A2 --> K3 - D[確定仕様] -.写す.- K2 -``` - -**配置は変わらない。** ランナーは実行ジョブのコンテナか手元、API は今の Lambda の中で動く。境界をまたぐのは今と同じ 2 本(ランナー → 受け口の事象、画面 → API の読み出し)で、事象の本文が増えるだけである。新しく境界を越えるのはバックログの経路から GitHub の順位の表の読み出しで、#2186 の候補の経路と同じ口を使う。 - -置き場(変える範囲): - -```text -factory/ -├── contracts/ -│ ├── package.json # exports に ./phases を足す -│ ├── schemas/progress-event.schema.json -│ ├── openapi/runs.yaml -│ └── src/{phases.ts(新設), generated/progress-event.ts} -├── runner/src/{translate.ts, runtime.ts, step-details.ts(新設), redact.ts(新設)} -├── api/src/ -│ ├── progress/read.ts -│ ├── views/derive.ts(新設) -│ └── routes/{factory.ts, sprint-views.ts(新設)} -└── web/ - ├── package.json # @ndf-factory/contracts を足す - ├── vite.config.ts - └── src/{api.ts, App.tsx, config.ts, poll.ts(新設), pages/} -``` - -## 構造 - -```mermaid -classDiagram - class StepRecordedPayload { - +step - +exit_code - +seconds - +summary - +pull_request? - +phase? - +issues? - +files? - +files_omitted? - +commit_range? - +decisions? - +output? - } - class SprintRunDetail { - +phases - +columns - +steps - +gates - +usage - +omitted_steps - } - class StepDetail { - +files - +decisions - +output - +diff_url - } - SprintRunDetail "1" --> "*" StepRow : steps - SprintRunDetail "1" --> "*" IssueColumn : columns - IssueColumn "1" --> "*" PullRequestView - StepDetail ..> StepRecordedPayload : 導く - SprintRunDetail ..> SprintRun : 読む -``` - -`SprintRun` は #2186 の既存の型で、変えない。`StepRow` は工程の記録の 1 行(本文を持たない)、`IssueColumn` は課題 1 つかスプリント全体の欄、`PullRequestView` は Pull Request 1 つの状態とチェックである。項目は「入出力の契約」の表が持つ。 - -## データ構造 - -**表は増やさず、事象の表の `payload` の中身が増えるだけである**(事象の記録の手法のまま。過去の事象は書き換えない)。`schema_version` は 1 のまま上げない(I1)。足す項目はどれも任意で、既に残った事象と今のランナーの事象はそのまま読める。 - -`StepRecordedPayload` に足す項目: - -| 項目 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `phase` | 文字列(1 字以上) | 項目ごと省ける | その工程が属するフェーズの名前。省いたときは「直前の `phase.changed` から導く」で、「フェーズが無い」ではない(I7) | -| `issues` | 整数(1 以上)の配列、最大 50 | 省ける。空の配列も許す | その工程が受け持つ課題。省いたときも空のときも「スプリント全体」(I6) | -| `files` | `{path, added, deleted}` の配列、最大 100。`path` は 1〜512 字、`added`・`deleted` は 0 以上の整数か `null` | 省ける | その工程の時間の窓のコミットが触ったファイル。`added`・`deleted` の `null` はバイナリ(行数が無い)。省いたときは「記録なし」で、「触っていない」ではない。触らなかったと分かったときは空の配列 | -| `files_omitted` | 0 以上の整数 | 省ける | 上限で `files` から外した数 | -| `commit_range` | `{from, to}`(どちらも 40 桁の 16 進) | 省ける | 時間の窓のコミットの最初の親と最後のコミット。差分のリンクとファイルのリンクを作る | -| `decisions` | `{question, answer, by}` の配列、最大 20。`question`・`answer` は 1〜1,000 字、`by` は `ai` / `user` | 省ける | その工程の間に決めたこと。省いたときは「記録なし」、空の配列は「決めたことが無かった」 | -| `output` | `{kind, text, truncated}`。`kind` は `report` / `log`、`text` は UTF-8 で 32,768 バイト以下、`truncated` は真偽 | 省ける | 作業の報告(`work` のステップ)かログ(`run` のステップ)。`truncated` は 32 KiB か 256 KiB の上限で切ったこと | - -```mermaid -erDiagram - SPRINT_RUN ||--o{ PROGRESS_EVENT : "sprint_run_id" - PROGRESS_EVENT { - string sprint_run_id PK - string order_key PK - string type - string job_id - string at - json payload "step.recorded は足した 7 項目を任意で持つ" - } - SPRINT_RUN { - string sprintRunId PK - string accountId - number repositoryId - } -``` - -| 機能 | 進捗の事象 | スプリントの実行 | リポジトリの登録 | 順位の表(GitHub) | -| --- | --- | --- | --- | --- | -| F1 一覧 | R | R | — | — | -| F2 詳細 | R | R | — | — | -| F3 工程の中身 | R | R | — | — | -| F4 バックログ | R | R | R | R | -| F5 読み直し | R | R | — | — | -| F6 ランナーの送信 | C(受け口が追記) | — | — | — | - -**検査の手段**: 定義から型を作り直し(`npm run generate -w contracts`。名前は `factory/contracts/package.json` に合わせる)、`factory/contracts/tests/` の定義のテストと `npm run check` で、今の形と足した形の両方が通ることを確かめる。 - -## 入出力の契約 - -### 経路(`factory/contracts/openapi/runs.yaml`) - -| 経路 | 変わる約束 | 応答 | -| --- | --- | --- | -| `GET /accounts/{accountId}/sprint-runs` | 各行に `repo`・`current_phase`・`current_step`・`last_event_at` を足す。既存の 6 項目の名前と型は変えない | 200 `SprintRunList` / 403 | -| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}` | 追加。スプリントの詳細 | 200 `SprintRunDetail` / 403 | -| `GET /accounts/{accountId}/sprint-runs/{sprintRunId}/steps/{stepKey}` | 追加。工程の中身。`stepKey` は事象の `idempotency_key`(URL の符号化をする) | 200 `StepDetail` / 403 | -| `GET /accounts/{accountId}/repositories/{repositoryId}/backlog` | 追加。順位の表と棚卸しの記録 | 200 `Backlog` / 403 / 503(GitHub の口の失敗) | - -403 はどれも既存の `ForbiddenError` の本文(`{"error": {"code": "forbidden", "message": "このアカウントを見る権限がありません"}}`)で、無い実行・無い工程・登録の無いリポジトリも同じ本文にする(I12)。401 は既存の認証の扱いのまま。 - -一覧の行に足す項目: - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `repo` | 文字列 | `owner/name`(実行の行の `repo`) | -| `current_phase` | 文字列か `null` | 最後の `phase.changed` の `phase`。`null` は 0 件(I4)。5 つの名前に無い値もそのまま(I5) | -| `current_step` | 文字列か `null` | 最後の `step.recorded` の `step` | -| `last_event_at` | 日時か `null` | 最後の事象の `at` | - -`SprintRunDetail`: - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `sprint_run_id`・`repo`・`repository_id`・`issues`・`state`・`failure_reason`・`created_at` | 一覧と同じ | 見出しと概要の札 | -| `ndf_version`・`execution_mode`・`job_count` | 文字列・文字列・整数 | 概要の札(実行の行から) | -| `current_phase` | 文字列か `null` | 一覧と同じ | -| `phases` | `{name, step_keys[], required[]}` の配列 | 5 つの名前の順に 5 つ、その後に現れた順に「その他」の名前。`required` は仕上げだけが持つ `[{label: "振り返り", step: "retro", record}, {label: "棚卸し", step: "refine", record}]`。`record` は最後の記録の `{key, exit_code, seconds}` か `null`(I10) | -| `columns` | `{issue, step_keys[], pull_requests[]}` の配列 | `issue` は課題の番号、`null` はスプリント全体の欄。実行の `issues` の順に課題の欄、最後にスプリント全体の欄(I6) | -| `columns[].pull_requests` | `{repo, number, url, state, checks[]}` | 欄の工程の `pull_request` を重複なく。`state` は最後の `pr.updated` の値か `unknown`。`checks` はチェックの名前ごとの最後の `ci.updated` の `{name, conclusion}`(`null` は実行中) | -| `steps` | `StepRow` の配列(`order_key` の順) | `{key, at, job_id, step, phase, issues, exit_code, seconds, cost_usd, summary, pull_request, has_details}`。`has_details` は足した項目を 1 つでも持つか | -| `gates` | `{gate1, gate2, decision}` | `gate1`・`gate2` は `{state, at}`、`state` は `not_reached` / `waiting` / `approved` / `rejected`。`decision` は `{state, at}`、`state` は `none` / `waiting` / `answered`(I9) | -| `delegated_decisions` | 整数 | `decision.delegated` の件数 | -| `usage` | `{input_tokens, output_tokens, cost_usd}` | `usage.recorded` の和 | -| `omitted_steps` | 整数 | 上限で省いた工程の数(I11) | - -`StepDetail` は `StepRow` の項目に、`files`(各行に `url` を足す)・`files_omitted`・`decisions`・`output`・`diff_url` を足したもの。足した項目を持たない工程では、それぞれ `null`(画面は「記録なし」)。リンクは GitHub を呼ばずに文字列で作る(I13)。 - -| リンク | 作り方 | -| --- | --- | -| Pull Request | `https://github.com/{repo}/pull/{number}` | -| 差分(`diff_url`) | `pull_request` があれば `…/pull/{number}/files`、無く `commit_range` があれば `…/compare/{from}...{to}`、どちらも無ければ `null` | -| ファイル | `commit_range` があれば `https://github.com/{repo}/blob/{to}/{path}`、無ければ `null` | - -`Backlog`: - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `repository_id`・`repo` | 整数・文字列 | 登録の値 | -| `ranking` | `{milestone, rows: [{rank, number, title}]}` か `null` | `findRankingTable` の結果。`rank` は表の行の順(1 から)。`null` は「順位の表がありません」 | -| `refinements` | `{sprint_run_id, key, at, exit_code, summary, pull_request}` の配列 | そのリポジトリの実行(新しい順に 50 件まで)の `step` が `refine` の `step.recorded` を、`at` の新しい順に | - -### 読み出しの口(`ProgressReader` に足す) - -| 名前 | 入力 | 出力 | 失敗の形 | -| --- | --- | --- | --- | -| `listRunEvents` | 実行の ID・`step.recorded` の上限 | `order_key` の順の事象の列(`step.recorded` 以外はすべて、`step.recorded` は新しい方から上限まで。`step.recorded` の `files`・`decisions`・`output` を読まない)と、上限で読まなかった `step.recorded` の件数。DynamoDB では事象の表を `sprint_run_id` で `order_key` の昇順に Query し、`LastEvaluatedKey` が無くなるまで全ページ読んでから、手元で古い `step.recorded` を上限の外として捨てる(表のキーは `sprint_run_id`・`order_key` だけで `type` の索引が無く、`Limit`・`FilterExpression` では「`step.recorded` だけを新しい方から上限まで」を作れない)。`ScanIndexForward=false` と `Limit` で件数を切らない(古い `gate.reached`・`usage.recorded` を落とす)。上限が抑えるのは応答の大きさだけで、読み出しの量(RCU・ページの数)は実行の事象の総数に比例する | 保存の失敗は投げる(経路が 503) | -| `latestOfRun` | 実行の ID | 最後の `phase.changed`・最後の `step.recorded`・最後の事象(どれも無ければ `null`) | 同上 | -| `getStep` | 実行の ID・`idempotency_key` | その実行の `step.recorded` の全項目か `null`(別の実行の鍵も `null`) | 同上 | -| `listStepsNamed` | 実行の ID・`step` の値 | その値の `step.recorded`(本文を読まない) | 同上 | - -### 画面 - -| 画面 | 経路 | 何をする場所か | -| --- | --- | --- | -| スプリントの一覧 | `/accounts/:accountId/sprints` | 実行を新しい順に並べ、今のフェーズと状態を見て、詳細へ移る | -| スプリントの詳細 | `/accounts/:accountId/sprints/:sprintRunId` | フェーズの欄・工程の記録・課題の欄・承認の欄・使用量を見る。工程を押すと詳細パネル | -| バックログの画面 | `/accounts/:accountId/repositories/:repositoryId/backlog` | 順位の表と棚卸しの記録を見る | - -```mermaid -graph LR - A[アカウント・チームの画面] -->|スプリント| L[スプリントの一覧] - P[リポジトリの画面] -->|スプリント| L - P -->|バックログ| B[バックログの画面] - L -->|行を押す| D[スプリントの詳細] - D -->|工程を押す| D2[詳細パネル] - D2 -->|概要に戻る| D - L & D & B -->|401| G[ログイン] -``` - -| 画面 | 出す項目 | 出さないもの(前提の課題が入るまで) | -| --- | --- | --- | -| スプリントの一覧 | 札 2 つ(動いている数=`starting`・`running`、対応待ちの数=`awaiting_approval`)。タブ(すべて・動いている・対応待ち・終わった。画面の中で絞る)。表の列: スプリント(課題の番号の並びとリポジトリ)・課題・フェーズ(5 つの棒。今より前は濃く、今は薄く、「始まる前」「その他(<名前>)」は文字で)と今の工程・状態(`starting`・`running` は実行中、`awaiting_approval` は承認待ち、`done` は終わった、`stopped` は止まった、`failed` は失敗、`cancelled` は取り消し)・始めた日時・最後の動き | token と上限の札(#2174)、承認する・答えるのリンク(#2173) | -| スプリントの詳細 | 見出し(課題の番号の並び・状態・リポジトリ)と「GitHub で開く」(リポジトリ)。概要の札(NDF の版・実行の形態・始めた日時・実行ジョブの数)。フェーズの欄(`phases`。仕上げに振り返りと棚卸しの行)。左に工程の記録(新しいものが上、課題の欄ごとのタブで絞る)。右の既定は承認の欄(ゲート 1・ゲート 2・判断)・課題の欄ごとの Pull Request と CI・AI が代わりに判断した件数・使用量。工程を押すと右が詳細パネル(ステップ・終了コード・秒・費用・要約・触ったファイルと増減の行数・決めたこと(問い → 答え、誰が決めたか)・差分を GitHub で見る・作業の報告かログを開く・概要に戻る) | 「止める」(#2186 の取り消しの画面のまま)、モードと進め方の札(未確認 3) | -| バックログの画面 | リポジトリの名前、札 2 つ(最後の棚卸しの日時・順位の表のマイルストーン)。着手の順位の表(順位・課題の番号と題)。棚卸しの記録(日時・要約・Pull Request・終了コード) | 「今すぐ棚卸しする」(未決 2)、open な課題の数・順位の値・前の棚卸しからの動き(どれも GitHub の別の読み出しが要る) | - -| 状態 | 一覧 | 詳細 | バックログ | -| --- | --- | --- | --- | -| 読み込み中 | 「読み込んでいます」 | 同じ | 同じ | -| 空 | 「スプリントの実行はまだありません」 | 工程が 0 件なら「工程の記録はまだありません」、`current_phase` が `null` なら「始まる前」 | 順位の表が `null` なら「順位の表がありません」、記録が 0 件なら「棚卸しの記録はまだありません」 | -| 読み直しの失敗 | 前の表示を残し、上に「読み直せませんでした(<時刻>)」 | 同じ | (読み直さない) | -| 401 | token を消してログインへ | 同じ | 同じ | -| 403 | 「このアカウントを見る権限がありません」(#2170 の画面と同じ) | 同じ | 同じ | -| 5xx | 「読み込めませんでした」と再読み込み | 同じ | 同じ | - -入力を持つ画面は無い(タブと工程の行を押すだけ)ため、項目定義と入力規則の表は作らない。見せる範囲は I12 のとおりで、権限で隠す要素は無い。 - -## 処理の流れ - -ランナーが工程の中身を送る(F6): - -```mermaid -sequenceDiagram - participant M as ランナーの見回り - participant C as 工程の中身の収集 - participant T as ランナーの翻訳 - participant I as 進捗の受け口 - M->>M: progress.jsonl の伸びた行を読む - M->>C: プランのディレクトリ・ステップの行 - C->>C: プランの JSON の フェーズ・作業場所を読む(読めなければ null) - C->>C: git log(at − seconds 〜 at、merge を除く)--numstat - C->>C: 判断の台帳の decision の行(cwd が作業場所の下、at が窓の中) - C->>C: .out を読み 32 KiB で切る - C->>C: 伏せ字 - C-->>M: 工程の中身(集められなかった項目は省く) - M->>T: プランの名前・ステップの行・工程の中身 - T->>T: フェーズの名前へ写す・256 KiB に収める - alt プランが変わった - M->>I: phase.changed(写した名前・課題) - end - M->>I: step.recorded(足した項目つき) - M->>I: usage.recorded(step.recorded と同じ at) -``` - -**収集の失敗は工程の送信を止めない。** どの項目も、読めない・コマンドが 0 以外で終わるときはその項目だけを省き、今の形の `step.recorded` に戻る。 - -詳細を読む(F2・F5): - -```mermaid -sequenceDiagram - participant W as 画面(読み直し) - participant R as 読み出しの経路 - participant T as 実行の表 - participant P as 進捗の読み出し - participant D as 派生 - W->>R: GET sprint-runs/{id} - R->>R: resolveFromRequest(失敗 → 401 / 403) - R->>T: getRun - alt 実行が無い・別のアカウント - R-->>W: 403 forbidden - end - R->>P: listRunEvents(step.recorded の上限 2,000、本文なし) - P-->>R: 事象の列・読まなかった step.recorded の件数 - R->>D: 実行の行・事象の列 - D-->>R: SprintRunDetail - R-->>W: 200 - alt 読み直しの失敗 - W->>W: 前の表示を残し、失敗の時刻を出す - end -``` - -一覧(F1)は `listSprintRuns` の結果の各行について `latestOfRun` を並列 10 本までで呼び、派生が行へ 4 項目を足す。工程の中身(F3)は `getRun` → `getStep` → 派生。バックログ(F4)は登録を確かめ(無ければ 403)、`findRankingTable` と、そのリポジトリの実行ごとの `listStepsNamed(…, "refine")` を並列に呼ぶ。 - -承認の欄の状態(ゲート 1・ゲート 2 は同じ形。判断は `waiting` / `answered` の 2 つに読み替える): - -```mermaid -stateDiagram-v2 - [*] --> not_reached - not_reached --> waiting: gate.reached - waiting --> approved: gate.answered(approved) - waiting --> rejected: gate.answered(rejected) - rejected --> waiting: 次の gate.reached - approved --> waiting: 次の gate.reached -``` - -`approved` の後の `gate.reached` は、NDF が同じゲートをもう一度求めたときに起こる(差し戻しの後の再提出と同じ扱い)。`gate.answered` が先に来て `gate.reached` が無い列は状態を変えない。 - -## 非機能の実現方式 - -| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 一覧(50 件)と詳細の読み出しの応答の時間の上限、詳細で読む事象の件数の上限は、設計で過去のスプリントの `progress.jsonl` を実測して決める(前提 10) | 件数の上限は `step.recorded` に 2,000 件(実測の事象の最大 233 件の約 8.6 倍)で、それ以外の事象は上限を掛けずにすべて読む(I11)。詳細は本文(`files`・`decisions`・`output`)を射影で外して読み、本文は工程の中身の経路で 1 件ずつ読む。一覧は実行ごとに最後の事象から逆に読み、並列 10 本まで。応答の時間の上限は一覧・詳細とも p95 2 秒とする(決定 9) | 件数: `step.recorded` 2,001 件の偽の列で `omitted_steps` が 1 になり、最も古い `gate.reached`・`usage.recorded` が残るテスト。時間: リリース後テストで実環境の 1 本を開いて測る(未確認 1) | -| 運用・保守性 | フェーズの名前の集合は `factory/contracts/` の 1 か所にあり、画面・API・ランナーはそこから型を得る | `factory/contracts/src/phases.ts` に置き、ランナー・派生・画面が読む。`@ndf-factory/contracts/phases` の入口は依存を持たない(画面へ ajv を入れない) | 型検査(`npm run check`)。列挙を書き換えると 3 か所の型が追う | -| 移行性 | `step.recorded` の追加は任意の項目で、`schema_version` を上げない。今のランナーの事象と既に残った事象はそのまま読める | 足す項目をすべて任意にし、派生は無い項目を「記録なし」「直前の `phase.changed`」「スプリント全体」で読む | 受け口の既存のテストを変えずに通す。足す前の形の事象の列で詳細を導くテスト | -| セキュリティ | 他人の実行の有無を応答の本文・状態のコード・時間で区別できない(403 の本文を揃える)。作業の報告とログの本文に秘密が入りうるため、ランナーが送る前に NDF の既存の伏せ字の規則を通す範囲は設計で決める(未決 3) | 403 は 1 つの例外の型で返し、無い実行と他人の実行で同じ回数の読み出し(`getRun` 1 回)の後に返す。伏せ字は `output.text`・`decisions`・`files` のパスに当て、NDF の 4 つの規則にランナーが持つ秘密の値の置き換えを足す(決定 7)。本文の上限は `output.text` 32 KiB、事象 1 件 256 KiB(I2) | 4 つの場合の 403 の本文と状態のコードが一致するテスト。秘密の値と 4 つの形を含む報告を送って、送られた本文に残らないテスト | - -## 決定の記録 - -決定は `issues/issue-2172-design-decisions.md` に分けた。 - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 条件 1・I4 | 最後の `phase.changed` が `設計`・`検査`・`仕上げ` の 3 つの実行の一覧の行 | 最初の `phase.changed` を読むように変える | -| 条件 2 | 一覧の行の項目と、7 つの状態から 6 つの表示への写し、`phase.changed` 0 件の「始まる前」 | `starting` を写し漏らす。0 件で空文字を返す | -| 条件 3・I5 | `準備` が最後の実行で一覧が 200 と `current_phase: "準備"`、画面に「その他(準備)」 | 列挙に無い名前を捨てる・400 にする | -| 条件 4 | 翻訳がプランの `"フェーズ"` の 6 つの値を写す(`配布(開発版)` → `取り込み`、`配布(本番)`・`まとめ` → `仕上げ`、5 つはそのまま)。プランの JSON が読めないときはプランの名前を送る | 写しの表から 1 つ消す | -| 条件 5・I6 | #A・#B・番号なしの工程が、それぞれの欄に `order_key` の順で並ぶ。`issues: [A, B]` の工程は 2 つの欄に 1 回ずつ | 番号なしを最初の課題の欄へ入れる | -| 条件 6 | 欄の Pull Request の最後の `pr.updated` の状態、チェックの名前ごとの最後の結論、`pr.updated` が無いときの `unknown` | 最初の `pr.updated` を使う。`conclusion: null` を落とす | -| 条件 7・I8 | 工程の中身の経路の項目とリンク、費用の和(同じ `job_id`・`step`・`at`)、足した項目の無い工程の `null`。画面で工程を押すと右が詳細パネルになり、「概要に戻る」で承認の欄へ戻る | 別の `at` の `usage.recorded` を足す。`null` を空の配列にする | -| 条件 8・I9 | ゲート 1 の `gate.reached` だけ → `waiting`、`approved` → `approved`、`rejected` → `rejected`、判断の `waiting` / `answered`。欄が 3 つ | 判断の `gate.reached` でゲート 1 を変える。欄を 4 つ作る | -| 条件 9・I10 | 仕上げの欄の 2 行が記録の前は `record: null`、`retro` の記録の後は終了コードと秒 | 記録が無いと行を作らない | -| 条件 10 | 画面: 偽の時計で間隔ごとに読み直し、新しい行が 2 間隔以内に出る。読み直しの失敗で前の表示が残る | 失敗で表示を消す。間隔を設定から読まない | -| 条件 11・I12 | 4 つの経路で、他人・無いアカウント・属さない実行・無い実行(バックログは登録の無いリポジトリも)の 403 の本文が同じ | 無い実行を 404 にする | -| 条件 12 | 3 画面で API の 401 → token が消え、ログインへ移る | 401 で画面に止まる | -| 条件 13・I13 | 一覧・詳細・工程の中身で偽の GitHub の口の呼び出しが 0 回。バックログは `findRankingTable` だけ | 派生で Pull Request の状態を GitHub から引く | -| 条件 14・I1 | 足した項目の無い `step.recorded` が 202 で受かり、詳細に出る。受け口の既存のテストを変えずに通す | 足した項目を必須にする | -| 条件 15 | バックログの順位の表と、棚卸しの記録の新しい順、表が無いときの `ranking: null` と画面の文 | 古い順に並べる | -| 条件 16・I14 | バックログの経路と画面が書き込みの口を持たない(偽の GitHub の口の書き込みの呼び出しが 0 回、画面にボタンが無い)。読み出しの後に偽の保存の行が変わらない | 読み出しで実行の行を更新する | -| 条件 17 | `npm run check` と `python3 scripts/check-service-boundary.py` が通る | — | -| 条件 18 | 一覧の既存の 6 項目の名前と型が変わらない(応答を OpenAPI の定義で確かめる) | `state` を表示の語へ変える | -| I2 | 300 KiB になる中身の `step.recorded` が 256 KiB 以下になり、`output.truncated` が真 | 切らずに送る | -| I3 | ランナーが持つ秘密の値と 4 つの形(URL の資格・GitHub の token・`x-access-token:`・認証ヘッダー)を含む報告・判断・パスが、送る本文に残らない | 伏せ字を `output` だけに当てる | -| I7 | `phase` の無い工程が同じ実行ジョブの直前の `phase.changed` のフェーズに入る | 別の実行ジョブの `phase.changed` を使う | -| I11 | `step.recorded` 2,001 件と、その前の `gate.reached`・`usage.recorded` を持つ列で `omitted_steps: 1`、省くのは最も古い工程で、ゲートの状態と `usage` の和は全件から導いた値と一致する | 新しい方から省く。事象の総数で上限を掛けて古い `gate.reached`・`usage.recorded` を落とす | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2172 | 実装する | — | `factory/contracts/`、`factory/runner/src/`、`factory/api/src/progress/read.ts`、`factory/api/src/views/`、`factory/api/src/routes/`、`factory/web/`、`docs/specifications/ndf-factory-service-boundary.md`、`docs/specifications/ndf-factory-contracts-and-progress.md` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| 1. 応答の時間 | 一覧と詳細の p95 2 秒は DynamoDB の実環境でしか測れない。リリース後テストで 1 本の実行を開いて測り、超えたら派生の表を持つ案(決定 9 で採らなかった案)を課題にする | -| 2. 時間の窓のコミット | 触ったファイルは、作業場所のコミットの時刻(秒の精度)が `at − seconds` 〜 `at` に入るものから取る。同じ作業場所で並列のステップがコミットすると、隣の工程のコミットが混ざりうる。NDF のプランは課題ごとに作業場所を分けるため起こらないと読んだが、実物で確かめていない。`commit_range` の `compare` のリンクは push 前のコミットでは 404 になる | -| 3. モードと進め方の札 | モックの概要の札にある「モードと進め方」は、今の事象にも実行の行にも無い。この課題では出さない。出すなら `job.started` に足す課題を起こす | -| 4. 未決 2(今すぐ棚卸し) | 要求の S7 のとおり含めない形で設計した。承認ゲート 1 で利用者が含めると決めたら、実行の制御(#2186 の集約)の `START` を変える別の設計になり、この文書の 1,000 行の内に収まらないため、別の課題にする | -| 5. 判断の誰が | ランナーが今読める判断は NDF の判断の台帳(`ask.jsonl`)の `decision` の行で、どれも AI の代わりの判断(`by: ai`)である。`by: user` は定義だけ置き、利用者の答えは承認の欄に出す | diff --git a/issues/issue-2172-requirements.md b/issues/issue-2172-requirements.md deleted file mode 100644 index 68c7c72a5..000000000 --- a/issues/issue-2172-requirements.md +++ /dev/null @@ -1,202 +0,0 @@ -# #2172: NDF Factory: スプリントの一覧(フェーズ)と詳細(工程・PR・CI・承認ゲート) - -正は課題の本文(#2172)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -親: #2168(Neural Development Factory)。前提: #2169(確定仕様)・#2170(v10.26.0 で配布)・#2171(contracts と進捗の受け口)・#2186(実行の制御とランナー)。 - -形と契約は [サービス全体の確定仕様](https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-factory-service-boundary.md) の「サービス全体の契約(子課題が実装する)」と、[契約の定義と型・進捗の受け口・実行ジョブの token](https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-factory-contracts-and-progress.md) にある。この課題は「一覧と詳細」(進捗の事象から一覧と詳細の読み出しの形を導く派生)の持ち主で、`phase.changed` が運ぶフェーズの名前の集合を決める(確定仕様の「受け持つ課題」の #2172 の行)。 - -## 依頼(原文) - -> * /ndf:development-workflow のsprintを並列で進めることが可能。 -> * sprint一覧でそれぞれがどのフェーズを進めているかが化しかできる -> * sprint詳細で進捗状況が可視化できる。 - -(書き手: 利用者、日付: 2026-10-30。#2168 の構想を伝えた会話の原文のうち、この課題が受け持つ行。全文は #2169 の「依頼(原文)」にある。「化しか」は「可視化」の誤記と読む) - -> スプリントの一覧と詳細の画面を作る。 - -(書き手: 起票者、日付: 2026-10-30。この課題の旧本文の「何をするか」) - -> - 工程の記録は 1 行ずつ詳細へ辿れる。押すと右側が詳細パネルに切り替わり、触ったファイル(GitHub へのリンク)・その工程で決めたこと・終了コード・秒・費用・ログと作業の報告を出す(モック https://claude.ai/artifact/MYL6CPBTsm6JFTNgtiwY2J の「スプリント詳細」) -> - 仕上げのフェーズに振り返りと棚卸し(必須)を出す(NDF の定義どおり。スプリントは棚卸しの報告で終わる)。棚卸しはスプリントの外でも実施できるよう、バックログの画面(順位・棚卸しの記録・今すぐ棚卸し)を持つ - -(書き手: 起票者、日付: 2026-10-31。旧本文の「画面の方針(2026-10-31 の利用者の指摘、モックで確かめる)」。利用者の指摘の原文は確かめられないため、利用者の文としては扱わず、指摘の中身(詳細パネル・仕上げの 2 工程・バックログの画面)を利用者の方針として読む。モックは要求を書いた時点で読めなかった(未決 1)) - -## 起票者の推定 - -題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 一覧のフェーズは 設計・実装・検査・取り込み・仕上げ の 5 つ | #2168 の方針の表(「スプリントの一覧」の行)と NDF の `agent-layers.md` の「フェーズ」 | 受け入れ条件 1〜3・前提 2 | -| S2 | 詳細に出すのは課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態 | #2168 の受け入れ条件 6 | 受け入れ条件 5〜9 | -| S3 | 一覧と詳細は進捗の事象の列から導く派生で、課題の本文の `## 進行` とボードは読まない | 確定仕様の「進捗の正本」(ゲート 1 で利用者が承認済み) | 受け入れ条件 13・前提 1 | -| S4 | 見せてよいのは `resolveAccount` で解決できたアカウントのスプリントの実行だけで、他人のものは同じ 403 | #2170 の確定仕様の I18 | 受け入れ条件 11・12 | -| S5 | 詳細パネルの「触ったファイル・決めたこと・ログと作業の報告」を出すため、`step.recorded` の形を足し、ランナーの翻訳もこの課題が直す | 今の `step.recorded` の `payload` は `step`・`exit_code`・`seconds`・`summary`・`pull_request` だけで、出す項目が届かない。翻訳はランナーだけが持つ(確定仕様の決定 6)ため、形と翻訳を同じ課題で直さないと片方だけが変わる(Value 6) | 前提 4・受け入れ条件 7。確定仕様を直す Pull Request を先に出す | -| S6 | 工程を課題へ結び付けるため、`step.recorded` に課題の番号を足す | 1 つの実行ジョブの中で課題ごとの実装のプランが並列に流れ、事象が交互に届くため、直前の `phase.changed` の課題で引くと誤る | 前提 5・受け入れ条件 5 | -| S7 | バックログの画面の「今すぐ棚卸し」は、スプリントの外で実行ジョブを起こす新しい種類の実行が要り、この課題に含めない | 実行の種類と起動は実行の制御(#2186 の集約)が持ち、`START` は `new` / `resume` だけ。設計文書 1 本 1,000 行以下(#2168)と Value 10 | 含まない・未決 2 | -| S8 | 画面を開いている間は一定の間隔で読み直し、押し出しの通知(WebSocket など)は作らない | 承認の通知は #2173 が持つ。押し出しの経路は API の入口のインフラ(private リポジトリ)を足す | 前提 7・受け入れ条件 10 | - -## 目的 - -- 1 つのアカウントで並列に流れる複数のスプリントの実行が、それぞれ今どのフェーズにいて、承認待ちか止まったかを、1 つの画面で見分けられる状態にする(#2168 の受け入れ条件 5 の後半) -- 1 つのスプリントの実行について、課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態を 1 つの画面で追え、工程を 1 行ずつ辿ってその工程の中身(触ったファイル・決めたこと・終了コード・秒・費用・ログ)まで確かめられる状態にする(#2168 の受け入れ条件 6) -- スプリントの外でも、取り込んだリポジトリの順位と棚卸しの記録を読める状態にする - -## 前提 - -- 前提 1: 一覧と詳細は、進捗の記録(#2171)の事象の列と、スプリントの実行(#2186)の状態だけから導く。課題の本文・ボード・GitHub の API は読まない。例外はバックログの画面で、順位の表は #2186 の GitHub の口(`factory/api/src/github/port.ts` の棚卸しの順位の表の読み出し)を使う -- 前提 2: フェーズの名前の集合は `設計`・`実装`・`検査`・`取り込み`・`仕上げ` の 5 つとし、`factory/contracts/` に列挙として置く。NDF のプランの `"フェーズ"` の値 `配布(開発版)` は `取り込み`、`配布(本番)` は `仕上げ` へ、ランナーの翻訳が写す(NDF の `agent-layers.md` の「フェーズ」の表で、検証リリースは取り込み、本番へのリリースは仕上げの工程)。受け口は今どおり文字列で受け、集合に無い名前の `phase.changed` は 400 にせず残し、画面は「その他(<名前>)」と出す(NDF の版がフェーズを足しても記録が失われない) -- 前提 3: 一覧の「今のフェーズ」は、そのスプリントの実行の最後の `phase.changed` の `phase`(`order_key` の順)とする。並列の実装で複数の課題が同じフェーズにいるときも 1 つの名前になる。`phase.changed` が 1 件も無い実行は「始まる前」と出す -- 前提 4: 詳細パネルの「触ったファイル」「その工程で決めたこと」「ログと作業の報告」は、今の進捗の事象の形では届かない。この課題は `step.recorded` の `payload` に任意の項目(触ったファイルのパスの並び・決めたことの文の並び・作業の報告の本文)を足し、`schema_version` を上げない互換の追加とする(今のランナーの事象も通る)。足す形は設計で決め、確定仕様(`ndf-factory-service-boundary.md` の「進捗の事象の種類と受け口」・`ndf-factory-contracts-and-progress.md` の「進捗の事象の定義」)を直す Pull Request を先に出す。ランナーの翻訳(`factory/runner/`)もこの課題で直す -- 前提 5: 工程を課題へ結び付けるため、`step.recorded` の `payload` に課題の番号の並び(任意)を足す(前提 4 と同じ互換の追加)。番号を持たない `step.recorded`(スプリントで 1 つの検査・取り込み・仕上げの工程や、足す前のランナーの事象)は、課題ごとの欄でなく「スプリント全体」の欄に出す -- 前提 6: 工程の費用は、同じスプリントの実行の `usage.recorded` のうち `payload.step` がその工程と等しいものの `cost` の和とする。等しいものが無ければ「—」と出す。形態 ①・② の費用も同じく出す(確定仕様の「`usage.recorded` は 3 つの形態とも出す」) -- 前提 7: 一覧と詳細は、画面を開いている間 30 秒ごとに API を読み直して更新する。押し出しの通知は作らない。読み直しの間隔は設定の 1 か所で変えられる -- 前提 8: 承認ゲートの状態は、承認の依頼の集約(#2173。まだ実装されていない)を読まず、進捗の事象 `gate.reached`・`gate.answered`・`job.finished`(`gate`)と、スプリントの実行の状態 `awaiting_approval` から導く。状態は「承認待ち」「承認された」「差し戻された」「判断を待っている」「判断に答えた」の 5 つとする。承認するボタンは #2173 が置き、この課題は置かない -- 前提 9: 見せる範囲は、`resolveAccount` で解決できたアカウントのスプリントの実行だけ。チームのアカウントでは席を持つ人なら誰でも一覧と詳細を読める(スプリントの一覧と同じ。#2186 の確定仕様の「持ち主だけの経路」)。他人のアカウントのもの、アカウントに属さない `sprint_run_id` はどれも 403 `forbidden` と同じ本文で返し、有無を明かさない -- 前提 10: 詳細の読み出しは、1 つのスプリントの実行の事象を全件読んで導く。件数の上限は設計で過去のスプリントの `progress.jsonl` の行数を実測して決め、超えたら古い工程から省いて省いた件数を出す -- 前提 11: 時刻は ISO 8601 の UTC で受け、画面はブラウザの地域の時刻で出す -- 前提 12: 「仕上げのフェーズに振り返りと棚卸し(必須)を出す」は、仕上げのフェーズの欄に工程 `振り返り`・`棚卸し` の行を常に置き、`step.recorded` がまだ無ければ「まだ」と出すことを指す。工程の名前の照合は、ランナーが `step.recorded` の `step` に写す NDF のステップの識別子のうち、どれを振り返り・棚卸しと見なすかを設計で決める -- 前提 13: バックログの画面は、取り込んだリポジトリごとに、順位の表(#2186 の候補が読むものと同じ)と、そのリポジトリのスプリントの実行のうち仕上げのフェーズで記録された棚卸しの工程の一覧(日時・要約・Pull Request)を出す。読むだけで、課題やマイルストーンを書き換えない - -## 対象範囲 - -含む: -- 画面(`factory/web/`): スプリントの一覧・スプリントの詳細(工程の詳細パネルを含む)・バックログの画面と、そこへの導線(アカウントとリポジトリの画面から) -- API(`factory/api/`): 一覧の派生を足した読み出し・詳細の読み出し・バックログの読み出しの経路と、その OpenAPI(`factory/contracts/openapi/`) -- 契約(`factory/contracts/`): フェーズの名前の列挙と、`step.recorded` の任意の項目の追加(前提 4・5)。確定仕様を直す Pull Request を先に出す -- ランナー(`factory/runner/`): フェーズの名前の写し(前提 2)と、`step.recorded` の足した項目の翻訳(前提 4・5) - -含まない: -- 承認と差し戻しの操作・承認の依頼の保存・Web と Slack の通知(#2173) -- 「今すぐ棚卸し」の操作(スプリントの外で棚卸しの実行ジョブを起こす。未決 2) -- スプリントの開始と取り消しの操作(#2186 が置いた画面のまま) -- 料金と請求の画面(#2174) -- 押し出しの通知(WebSocket・Server-Sent Events) -- DynamoDB の表と GSI・API の入口などインフラの定義(private リポジトリ) -- 課題の本文の `## 進行` とボードを読むこと - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | ランナーがフェーズの移りを `phase.changed` で送った | NDF の `sprint.json` の計画と `progress.jsonl` の `step` の行が伸びた | 受け口が 4xx を返す → 事象は残らず、一覧のフェーズが古いまま(受け口の契約どおり) | スプリントの実行が始まっている(`job.started`) | -| E2 | ランナーが工程の終わりを `step.recorded` で送った | NDF の `progress.jsonl` の `step` の行 | E1 と同じ | E1(無ければ「始まる前」のまま工程が並ぶ) | -| E3 | GitHub が Pull Request と CI の結果を Webhook で送り、`pr.updated`・`ci.updated` が残った | GitHub の Webhook | 宛先が引けない → 残らない。詳細は `step.recorded` の Pull Request の参照から最初の状態を出す | E2 の `pull_request`(`pr_ref`) | -| E4 | ランナーが承認ゲートに達し `gate.reached` と `job.finished`(`gate`)を送った | 作業の報告の `判断が要る` | 送れない → 実行の制御が `job.finished`(`failed`)を書く | E1 | -| E5 | 利用者が答え、`gate.answered` が残った | #2173 の API | 未保存の答え → 受け口が 403(残らない) | E4 | -| E6 | 利用者がスプリントの一覧を開いた | 画面の操作 | 401 → token を消してログインへ。403 → #2170 の画面と同じ表示。5xx → 読み込めない旨と再読み込み | ログイン済み | -| E7 | 利用者がスプリントの詳細を開いた | 一覧の行を押した・URL を開いた | E6 と同じ。他人の実行 → 403 | E6 か URL | -| E8 | 利用者が工程の行を押し、詳細パネルに切り替わった | 画面の操作 | 足した項目が無い工程 → 該当の欄を「記録なし」と出す | E7 | -| E9 | 画面が一定の間隔で読み直した | 時間の経過(前提 7) | 読み直しの失敗 → 前の表示を残し、失敗した旨を出す | E6 か E7 | -| E10 | 利用者がバックログの画面を開いた | 画面の操作 | 順位の表が無い → 「順位の表がありません」。GitHub の口の失敗 → 5xx の表示 | リポジトリの登録 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| スプリントの一覧 | アカウントのスプリントの実行を新しい順に並べ、それぞれの今のフェーズと状態を出す画面 | -| スプリントの詳細 | 1 つのスプリントの実行の、課題ごとの工程・Pull Request・CI の結果・承認ゲートの状態を出す画面 | -| 詳細パネル | スプリントの詳細で工程の行を押すと右側に出る、その工程の中身の欄 | -| バックログの画面 | 取り込んだリポジトリの順位と棚卸しの記録を、スプリントの外で読む画面 | - -## 受け入れ条件 - -一覧: - -- [ ] 1. アカウントに、フェーズの異なる 3 つのスプリントの実行(最後の `phase.changed` が `設計`・`検査`・`仕上げ`)があるとき、一覧の 3 行にそれぞれ `設計`・`検査`・`仕上げ` が出る -- [ ] 2. 一覧の各行に、リポジトリ・課題の番号・スプリントの実行の状態(実行中・承認待ち・終わった・止まった・失敗・取り消し)・今のフェーズ・始めた日時が出る。`phase.changed` が 0 件の実行は今のフェーズに「始まる前」が出る -- [ ] 3. `phase` が 5 つの名前に無い `phase.changed`(例 `準備`)が最後の実行は、今のフェーズに「その他(準備)」が出て、一覧の読み出しは 200 を返す -- [ ] 4. ランナーが NDF のプランの `"フェーズ"` の値 `配布(開発版)` を `取り込み`、`配布(本番)` を `仕上げ` として `phase.changed` に写す。5 つの名前はそのまま写す - -詳細: - -- [ ] 5. 課題 2 つ(#A・#B)を持つ実行で、`payload` に課題 #A を持つ `step.recorded` は #A の欄に、#B を持つものは #B の欄に、課題を持たないものは「スプリント全体」の欄に、`order_key` の順で並ぶ -- [ ] 6. 各課題の欄に、その課題の工程を作った・更新した Pull Request(番号と GitHub へのリンク)と、最後の `pr.updated` の状態(open / closed / merged)と、その Pull Request の `ci.updated` のチェックの名前ごとの最後の結論(未完了は「実行中」)が出る。`pr.updated` が無い Pull Request は状態に「不明」が出る -- [ ] 7. 工程の行を押すと、右側が詳細パネルに切り替わり、ステップ・終了コード・秒・費用(前提 6)・要約・触ったファイル(各パスが GitHub のそのリポジトリのファイルへのリンクで、足した行数と消した行数を添える)・決めたこと(問い・答え・誰が決めたか)・作業の報告とログを開く操作・差分を GitHub で見るリンクが出る。「概要に戻る」で右側が承認ゲート・Pull Request と CI・使用量の欄に戻る。足した項目を持たない工程では、その欄に「記録なし」が出る -- [ ] 8. 承認ゲートの状態が、`gate.reached`(ゲート 1)だけの実行では「ゲート 1: 承認待ち」、続いて承認の `gate.answered` があれば「ゲート 1: 承認された」、差し戻しなら「ゲート 1: 差し戻された」と出る。判断の `gate.reached` は「判断を待っている」、答えがあれば「判断に答えた」と出る。承認ゲート 1・2 と判断の 3 つの欄を持ち、承認の欄は 2 つだけである -- [ ] 9. 仕上げのフェーズの欄に、工程 `振り返り` と `棚卸し` の行が常にあり、`step.recorded` が届く前は「まだ」、届いた後はその工程の終了コードと秒が出る -- [ ] 10. 一覧と詳細を開いたまま、新しい進捗の事象が追記されると、読み直しの間隔(既定 30 秒)の 2 倍以内に画面へ出る。読み直しが失敗しても前の表示は消えない - -認可と読み方: - -- [ ] 11. 一覧・詳細・バックログの読み出しは、他人のアカウント・存在しないアカウント・そのアカウントに属さない `sprint_run_id`・存在しない `sprint_run_id` のどれでも、403 `forbidden` を同じ本文で返す -- [ ] 12. API が 401 を返すと、画面は token を消してログインの画面へ移る -- [ ] 13. 一覧と詳細の読み出しは、課題の本文・ボード・GitHub の API を呼ばない(偽の GitHub の口の呼び出しが 0 回) -- [ ] 14. `step.recorded` の足した項目(前提 4・5)を持たない事象(今のランナーの形、`schema_version` 1)を受け口が 202 で受け、詳細に出る。受け口の既存のテストが変えずに通る - -バックログ: - -- [ ] 15. バックログの画面に、リポジトリの順位の表(順位・課題の番号・題)と、そのリポジトリのスプリントの実行の棚卸しの工程の一覧(日時・要約・Pull Request)が新しい順に出る。順位の表が無いリポジトリでは「順位の表がありません」が出る -- [ ] 16. バックログの画面は、課題・マイルストーン・ボードへ書き込む操作を持たない - -退行: - -- [ ] 17. `factory/` の `npm run check`(CI の `factory-check`)と境界の検査(`python3 scripts/check-service-boundary.py`)が通る -- [ ] 18. スプリントの一覧の既存の経路(`GET /accounts/{accountId}/sprint-runs`)の既存の項目の名前と型は変わらない(足すだけ) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 一覧(50 件)と詳細の読み出しの応答の時間の上限、詳細で読む事象の件数の上限は、設計で過去のスプリントの `progress.jsonl` を実測して決める(前提 10) | -| 運用・保守性 | フェーズの名前の集合は `factory/contracts/` の 1 か所にあり、画面・API・ランナーはそこから型を得る | -| 移行性 | `step.recorded` の追加は任意の項目で、`schema_version` を上げない。今のランナーの事象と既に残った事象はそのまま読める | -| セキュリティ | 他人の実行の有無を応答の本文・状態のコード・時間で区別できない(403 の本文を揃える)。作業の報告とログの本文に秘密が入りうるため、ランナーが送る前に NDF の既存の伏せ字の規則を通す範囲は設計で決める(未決 3) | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わる。API の経路を足し、`step.recorded` に任意の項目を足す(互換あり)。フェーズの名前の列挙を `factory/contracts/` に足す | -| データ | 事象の表の形は変わらない(`payload` の中身が増えるだけ)。一覧のための派生の表を持つかは設計で決める | -| 既存の振る舞い | プラグイン(`plugins/`)と CLI の利用者の振る舞いは変わらない。リポジトリの画面に一覧と詳細への導線が増える | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| 起動 | `factory/` で `npm run dev -w web`(名前は設計で `factory/web/package.json` に合わせる) | -| テスト | `factory/` で `npm run check`(vitest を含む) | -| 静的解析・型検査 | `factory/` で `npm run check`。境界は `python3 scripts/check-service-boundary.py` | -| 手動確認 | リリース後テストで、実環境の 1 本のスプリントの実行を一覧と詳細で開き、フェーズ・工程・Pull Request・CI・承認ゲートが実際の進み方と合うかを人が見る。詳細パネルの見た目がモックに沿うかを人が見る | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | `factory/` の中だけ。`plugins/` と `.claude-plugin/marketplace.json` から `factory/` を参照しない(AGENTS.md・境界の検査)。画面は `factory/web/`、API は `factory/api/`、契約は `factory/contracts/`、ランナーは `factory/runner/` | -| コーディング規約 | TypeScript。npm の workspaces、ロックファイルは `factory/package-lock.json` の 1 つ(`factory/README.md` の「パッケージの管理」)。エラーの本文は `{"error": {"code", "message"}}`、経路は OpenAPI を正にする | -| テスト戦略 | 派生(事象の列 → 一覧・詳細の形)は純粋な関数の単体テストで条件 1〜9 を縛る。API は偽の保存(`memory-store.ts`)で認可と経路(条件 11・13・18)を縛る。画面は既存の `App.test.tsx` の形で表示と 401(条件 12)を縛る。ランナーの写し(条件 4)は翻訳の単体テスト。`.md` の文言のテストは書かない | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | `npm run check` と境界の検査、`factory/contracts/` からの型の生成、確定仕様と OpenAPI の同時の更新 | -| 確認してから行う | 進捗の事象の形の追加(確定仕様を直す Pull Request を先に出し、承認ゲート 1 で見る)・API の経路の追加 | -| 行わない | 課題・マイルストーン・ボードへの書き込み、承認の操作、インフラの定義、秘密の値のコミット、`plugins/` の変更 | - -## モックの中身 - -モック https://claude.ai/artifact/MYL6CPBTsm6JFTNgtiwY2J の 3 画面(2026-10-31 に conductor が読んだ版 1793412669-5df4)。値は例示で、配置と項目だけを要求として読む。 - -| 画面 | 配置と項目 | -| --- | --- | -| スプリント一覧(`Main`) | 上に件数の札 3 つ(同時に動いている数と上限・対応待ち・今月の token)。絞り込みのタブ(すべて・動いている・対応待ち・終わった)。表の列はスプリント(名前とリポジトリ)・課題・フェーズ(5 つの棒。通ったものは濃く、今のものは薄く)と今の工程・状態(承認待ちなら「承認する」、判断待ちなら「答える」へのリンク)・最後の動き | -| スプリント詳細(`SprintDetail`) | 見出し(名前・状態・リポジトリと課題)と「GitHub で開く」「止める」。概要の札(モードと進め方・NDF の版・実行の形態・始めた日時・実行ジョブの数)。フェーズの欄(5 つのフェーズごとに中の工程を並べ、仕上げに振り返りと棚卸し(必須)を置く)。左に工程の記録(新しいものが上、押すと右に詳細)。右は既定で承認ゲート(1・2)・Pull Request と CI・AI が代わりに判断した問い合わせの件数・使用量。工程を押すと右が詳細パネル(終了コード・秒・費用・要約・触ったファイルと増減の行数・この工程で決めたこと(問い → 答え、誰が決めたか)・差分を GitHub で見る・ログを開く・作業の報告を開く・概要に戻る)に切り替わる | -| バックログ(`Backlog`) | リポジトリの選択と「今すぐ棚卸しする」。札 3 つ(最後の棚卸し・open な課題の数・次に棚卸しが動く所)。着手の順位の表(順位・課題・順位の値・前の棚卸しからの動き・状態)。棚卸しの記録(日時・きっかけ・結果・報告へのリンク) | - -モックにあってこの課題の受け入れ条件に入れないもの: 件数の札の token・上限(#2174)、「止める」(#2186)、承認する・答えるの操作(#2173)、「今すぐ棚卸しする」(未決 2)。表示の場所は残し、操作は前提の課題が入るまで出さない。 - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 1. (解決、2026-10-31)モック https://claude.ai/artifact/MYL6CPBTsm6JFTNgtiwY2J を conductor が読み、中身を「モックの中身」の節へ写した。受け入れ条件 7 を補った | — | — | -| 2. 「今すぐ棚卸し」(スプリントの外で棚卸しの実行ジョブを起こす)を別の課題にするか、この課題に含めるか。含めるなら実行ジョブの指示の `START` に新しい値を足し、実行の制御(#2186 の集約)を変える | 利用者 | 承認ゲート 1 | -| 3. `step.recorded` に足す作業の報告とログの本文に秘密が入りうる場合の伏せ方と、本文の大きさの上限(事象 1 件の上限の内) | 設計 | 設計 Pull Request | -| 4. 一覧のための派生の表を持つか、読み出しのたびに事象から導くか | 設計(実測で決める) | 設計 Pull Request | - -🤖 Generated with [Claude Code](https://claude.com/claude-code) diff --git a/issues/issue-2342-design-contracts.md b/issues/issue-2342-design-contracts.md new file mode 100644 index 000000000..53ef87d16 --- /dev/null +++ b/issues/issue-2342-design-contracts.md @@ -0,0 +1,101 @@ +# #2342 の設計: データ構造と入出力の契約 + +設計文書の本体は [issue-2342-design.md](issue-2342-design.md) にある。 +決定の記録は [issue-2342-design-decisions.md](issue-2342-design-decisions.md) にある。 + +## データ構造 + +### 持ち主の記録(`leases/<リポジトリ>/<鍵>.json`) + +| 鍵 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `version` | int | 許さない | 記録の形の版(1) | +| `key` | str | 許さない | 持ち主の鍵(例 `cross-refactoring:pr=2318`) | +| `entry` | str | 許さない | 取った入口(契約の一覧の入口のパスと副命令) | +| `run` | str | 許さない | 取った実行の識別子。`inherited` で使う子の実行は書き換えない。生死と返す権限の判定には使わず、断りの結果に出すためだけに持つ | +| `started_at` / `seen_at` | str | 許さない | 取った時刻 / 最後に動いた時刻(UTC の ISO 8601) | +| `finished_at` | str | 許す | 返した時刻。空は「返していない」 | +| `pid` / `pid_started` | int / float | 許さない | 持ち主のプロセス(単位 `run` は入口のプロセス、`session` はランタイムのセッションのプロセス)と、その開始時刻(epoch 秒) | +| `host` | str | 許さない | 持ち主のプロセスが動くホスト名 | + +**返しても記録を消さない。** 片付けがレビュー worktree の持ち主が終わったかを読むためである。 +終わった時刻から 30 日を過ぎた記録(`.json`)は、持ち主の部品の `lease.prune` が外す(`durable.KEEP_DAYS` と同じ値)。錠のファイル(`.lock`)は消さずに残す(`locks.py` の規則。消すと別のプロセスが別の inode で同じ鍵の排他を取れてしまう)。片付けは `lease.prune` を呼ぶだけで、記録を自分で書き換えない。 + +### 入口の契約に足すキー + +入口の行(`contracts/entrypoints/<パス>.json`)に任意のキー `lease` を足す。無い入口は持ち主を取らない(受け入れ条件 10)。 +`lease` は、副命令の名前(副命令の無い入口は `""`)から下の宣言への対応である。 + +| 鍵 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `key` | str | 許さない | 鍵の形。`{<入力の名前>}` を解決した入力で埋める(例 `cross-refactoring:pr={pr}`) | +| `unit` | str | 許さない | 持ち主の単位(`run` / `session`) | +| `window_seconds` | int | 許さない | 寿命の窓の秒。別のホストの記録の生死の判定にだけ使う(I4) | +| `release` | bool | 許す | 真なら、この副命令の終わりで単位 `session` の記録を返す | + +生きた持ち主がいるときの扱いは宣言しない。どの入口も止まる(決定 9)。 + +### 持ち主の鍵を宣言する入口(要求の前提 3) + +| 入口 | 鍵の形 | 単位 | 寿命の窓 | +| --- | --- | --- | --- | +| cross-refactoring の `drive.py` と `refactor.py init` | `cross-refactoring:pr={pr}` | `run` | 86400(24 時間) | +| cross-review の `drive.py` と `state.py init` | `cross-review:pr={pr}` | `run` | 86400(24 時間) | +| `supervise.py queue`(`--resume` を含む) | `supervise.queue:done={done}` | `run` | 86400(24 時間) | +| `supervise.py loop` の副命令のうち、利用者が答える副命令(`answer`・`approve-mvv`)と読むだけの副命令(`status`・`list`)を除くすべて(`pending` を含む) | `supervise.loop:name={name}` | `session` | 86400(24 時間) | + +`{done}` はリポジトリの根からの相対パスに正規化する。`loop stop` と `loop finish` は `release: true` を持つ。 +利用者が答える副命令(`loop answer`・`loop approve-mvv`)と読むだけの副命令(`loop status`・`loop list`)は `lease` に載せず、持ち主の段を通らない。 +利用者が答える副命令は、人の判断待ちの課題への承認を利用者が自分の端末や別のセッションから記録する手段であり、鍵を取らせると `no_session` や `refused` で止まって承認を記録できなくなる。記録の書き換えどうしの排他は、開発ループの記録を開く排他(`open_record`)が守る。 +`loop pending` は、差し戻し・失敗の答えで周回を締め直し、再開時に周回の結果を記録するため、読むだけの副命令に含めず鍵を取る。呼ぶのは conductor のセッションの中の一時レーンなので、同じランタイムのセッションとして持ち主の確認を通る。 +`release: true` の副命令は、ランタイムのセッションが見つからなくても(`no_session`)本体を走らせる(記録は書かない。断るときの案内を参照)。 + +### 状態の一覧に足す行 + +| 種類 | 置き場 | 寿命 | 片付ける時点 | +| --- | --- | --- | --- | +| `review-worktree`(既存) | 既存のまま。持ち主の記録は `leases/` の鍵 `cross-review:pr=`・`cross-refactoring:pr=` へ移る | 既存のまま(持ち主が終わり、PR が open でなく、作業が無い) | 既存のまま | +| `lease-record`(新設) | `leases/<リポジトリ>/*.json`(錠の `.lock` は対象外で、消さずに残す) | 終わった時刻から 30 日 | 片付けが `lease.prune` を呼ぶ。`lease.prune` は `.json` だけを外す | + +### 移行 + +| 変えるもの | 旧い形の扱い | 外す時点 | +| --- | --- | --- | +| 持ち主の記録の置き場(状態ディレクトリの横 → `leases/`) | 読まない(決定 3)。旧い記録は今の片付けがレビュー worktree と一緒に消す | この柱の実装の Pull Request | +| `refactor.py init` と `state.py init` の `--run` | 受けて捨て、標準エラーに「実行の識別子は核が振る」と出す(内部の入口で `public: false`) | 同じ Pull Request で呼ぶ側(drive)から外す | + +## 入出力の契約 + +### 結果 JSON に足す鍵 + +持ち主の鍵を宣言した入口の結果 JSON は、任意の鍵 `lease` を持つ(鍵は足すだけ。#2335 の互換性の約束)。 + +| 鍵 | 型 | 意味 | +| --- | --- | --- | +| `lease.key` | str | 持ち主の鍵 | +| `lease.outcome` | str | `acquired`(いなかった・終わっていた)/ `reclaimed`(死んだ持ち主を引き取った)/ `inherited`(祖先のプロセスが持っていた)/ `refused`(生きた持ち主がいて止まった)/ `no_session`(単位 `session` でランタイムのセッションが見つからず、記録を読み書きしなかった。鍵を取る副命令は止まり、返す副命令は本体を走らせた) | +| `lease.holder_run` | str | 生きた持ち主・引き取った持ち主の実行の識別子。いなければ省く | +| `lease.holder_pid` | int | 同上の pid。いなければ省く | +| `lease.holder_seen_at` | str | 同上の最後に動いた時刻。いなければ省く | + +### 断るときの案内 + +`refused` は `status: stopped`・終了コード 3 で、`summary` に持ち主の実行の識別子・pid・最後に動いた時刻を書く。値は `acquire` が返す `LeaseOutcome` から核が写す。 +`no_session` は、鍵を取る副命令(`release` が偽)では `status: stopped`・終了コード 1 で、`summary` に「ランタイムのセッション(Claude Code・Codex)の外では、セッション単位の鍵を取れない」と書く。記録は書かず、本体を走らせない。 +`release: true` の副命令(`loop stop`・`loop finish`)では止めずに本体を走らせ、記録は書かない。`status` と終了コードは本体の結果のままで、`lease.outcome` に `no_session` を出す。利用者が Claude Code・Codex の外の端末からループを止められるようにするためである。 +案内(`next`)は打てる手段だけを書く: 持ち主が終わるのを待ってから同じコマンドを打ち直す(#2321 の受け入れ条件 3)。 +`--run` を渡す・`finish` の後に開始する、という今の案内は消す(どちらもプランから打てない)。 +持ち主のプロセスは別の実行か利用者のランタイムのセッション(Claude Code・Codex)なので、案内に `kill` を書かない。待てないときは、conductor は止まった理由と持ち主の pid を添えて人へ戻す。持ち主を止めるかどうかは人が決める(共通原則の C4)。 + +### キューの `--from` つきの再開 + +```text +supervise.py queue --resume --from <プラン>=<ステップ> [--from ...] +``` + +| 項目 | 約束 | +| --- | --- | +| 入力 | 今のまま | +| 振る舞い | `--from` があれば、前の起動の途中の耐久ワークフローを子ごと止め、新しい実行の回で指定のステップから流す。`--from` も `--only` も無ければ前の起動を続ける | +| 失敗の形 | 途中を止められない → `stopped`・1。生きた持ち主がいる → `stopped`・3 | +| 互換性 | 引数は変えない。`--from` を渡したときの振る舞いだけが変わる(前の途中を続けなくなる) | diff --git a/issues/issue-2342-design-decisions.md b/issues/issue-2342-design-decisions.md new file mode 100644 index 000000000..f816ff5bc --- /dev/null +++ b/issues/issue-2342-design-decisions.md @@ -0,0 +1,117 @@ +# #2342 の設計: 決定の記録 + +設計文書の本体は [issue-2342-design.md](issue-2342-design.md) にある。要求の未決は、次の決定と本文が受ける。 + +| 要求の未決 | 受ける決定・節 | +| --- | --- | +| 開発ループとキューの持ち主の鍵の形と持ち主の単位 | 決定 1・2 | +| 柱 P1(#2337)の実行の核の形が違って決まった場合の読み直し | 本文の「あるべき姿の根拠」の後の段落と「未確認のまま残ること」の 1 行目 | + +## 決定の記録 + +### 決定 1: 2 つ目の conductor を開発ループの入口で止めるため、持ち主の単位を実行とセッションの 2 つにし、開発ループはセッションで持つ + +開発ループは、1 つの conductor が別々のプロセスで副命令を打って進む(`loop start`・`loop select`・`loop sprint` など)。 +持ち主の単位を入口の 1 回の実行にすると、`loop start` が終わった時点で記録が返る。 +そのため、2 つ目の conductor の `loop start` を止められない(#2316)。 +単位 `session` は、持ち主のプロセスをランタイムのセッションのプロセスにする(hook が今も使う祖先の探し方)。 +記録は `loop stop`・`loop finish` かセッションの終わりまで持つ。同じセッションの副命令は、持ち主のプロセスが自分の祖先なので `inherited` で通る。`loop finish` は `loop start` と別の実行だが、持ち主のプロセスが同じランタイムのセッションなので返せる。 + +開発ループの記録(`.ndf/loop/<名>/loop.json`)に conductor のセッションを書き足す形は採らない。記録の書き手が開発ループの部品と +持ち主の部品の 2 つになる。生死の判定と排他も、開発ループの部品の中にもう 1 つできる。 + +根拠: Value 7(MVV 版 5) + +### 決定 2: 並行の衝突が起きた単位で取り合うため、キューは done のパス、開発ループは名前を持ち主の鍵にする + +#2316 で衝突したのは、同じ done を再開した 2 つのキューと、同じ名前の開発ループの同じ周回である。 +鍵を done ごとにすれば、`queue <プラン>... --done X` と `queue --resume X` が同じ鍵を取り合う。別の done のキューは並べて流せる。開発ループを周回ごとにすると、 +周回の切れ目で 2 つ目の conductor が入れる。パスはリポジトリの根からの相対へ正規化し、worktree の違いで鍵が割れないようにする。 + +キューをプランごとの鍵にする形は採らない。1 つのキューが流す複数のプランの鍵を、起動時にまとめて取ることになる。 +取れた分だけで流すか全部を待つかの規則が、もう 1 つ要る。 +`supervise.py run` には鍵を宣言しない。#2316 の衝突はキューの再開で起きており、`run` を直に 2 本並べた記録は無い。 + +根拠: Value 2 / Value 6(MVV 版 5) + +### 決定 3: 旧い置き場の記録は読まない + +前の版の NDF が書いた `*.owner.json` は、新しい版の取る側も片付けも読まない。 +旧い記録の残りは、今の片付けがレビュー worktree を外すときに一緒に消す(`run_state.py:157`・`run_state.py:167`)。 + +旧い置き場を読む互換を持つ形は採らない。承認ゲート 1 の答えが範囲から外した。 +読む処理を持つと、取る側と片付けの両方に旧い規則(寿命の窓だけで判定する規則)の分岐が残り、次の正式版で消す作業が要る。 +外したことで残る並走の危険(旧い版と新しい版が同じ鍵を同時に取る)は、本文の「未確認のまま残ること」に書く。 + +根拠: Value 2 / Value 3(MVV 版 5) + +### 決定 4: drive が起こす init が自分の持ち主に断られないよう、持ち主のプロセスが祖先である実行は持ち主の鍵を取り直さずに使う + +cross-refactoring の `drive.py` は `refactor.py init` を子のプロセスとして起こす。 +どちらも同じ鍵 `cross-refactoring:pr={pr}` を宣言する(`init` は利用者が直に打つこともあるため)。記録の持ち主のプロセス(pid と開始時刻)が自分の祖先のプロセスなら `inherited` で通る。返すのは記録の持ち主のプロセスと同じ実行だけで、`inherited` で使った子の終わりでは返さない。 +祖先が持っていない鍵を子が取ったとき(プランから起こした `drive.py` など)は、その子が持ち主で、子の終わりで返す。 + +実行の識別子の一致(記録の `run`)で通す形は採らない。識別子は、子へ渡す経路ごとに受け渡しの漏れがある。#2321 の案内が示した `--run` も、プランから呼ぶ `drive.py` には渡す手段が無い。 +プロセスの祖先は受け渡しを要らない。 + +根拠: Value 5(MVV 版 5) + +### 決定 5: キューの再開で `--from` が勝つよう、`--from` があれば途中を子ごと止めてから耐久の記録を開く + +#2325 の原因は、前の起動が途中で残っていると、`--from` があっても `durable.resolve` が `continue` を返すことである。 +そのため前の入力で回復した。 +`launch_queue` は、今もプランの並びが変わったとき(`fresh`)に途中の耐久ワークフローを子ごと止める(`keep=()`)。 +この条件を「プランの並びが変わった、または `--from` がある」に広げる。`--from` も `--only` も無ければ今のまま前の起動を続ける。 +`supervise.py run --from` は変えない。開始のステップを耐久ワークフローの接頭辞に含めており、すでに `--from` が勝つためである。 + +耐久の部品に再開の位置を決める関数を新しく置く形は採らない。食い違いはキューの入口の 1 か所の条件にあり、既存の `keep` で直せる。 +`--from` が前の起動と変わったときだけ新しい実行の回にする形も採らない。前の `--from` を覚えておく置き場がもう 1 つ要り、判定の漏れが位置の食い違いになる。 + +根拠: Value 7 / Value 2(MVV 版 5) + +### 決定 6: 引き取りは記録の書き直しだけにし、状態を持ち主の記録に結び付けない + +死んだ持ち主を `reclaimed` で引き取るとき、持ち主の部品は自分の鍵の記録を書き直すだけにする。 +死んだ持ち主が残したレビュー worktree は、入口が今と同じく作り直すか使い直す。 +レビュー worktree の片付けは、今の時点と条件のまま、生死の判定だけを持ち主の部品の記録に置き換える。 + +状態の一覧の種類に結び付く鍵の列を足し、返した後と引き取りの中で結び付いた状態を片付ける形は採らない。 +1 つのレビュー worktree の種類には、cross-review と cross-refactoring の 2 つの鍵が結び付く。そのため、複数の鍵の排他を順に取る規則が要る。 +#2321 は記録の終わった時刻が書かれれば解消し、結び付きを要らない。 + +根拠: Value 2 / Value 3(MVV 版 5) + +### 決定 7: 再開の入口は作らず、旧い再開の形を今のまま残す + +#2335 の決定 15 は再開の入口(`supervise.py resume`)を置き、旧い再開の形をその別名にする。 +承認ゲート 1 の答えは旧い形の別名を範囲から外した。 +別名にしないまま再開の入口を足すと、再開の手段が 1 つ増えて 2 通りになる。 +そのため、この柱では再開の入口を作らず、#2325 の食い違いを決定 5 のとおりキューの入口で直す。 +再開の入口を求める子の課題(#1592・#1635)は残す。 + +再開の入口だけを足し、旧い形は後で別名にする形は採らない。再開の手段が 2 通りある期間ができ、どちらを案内するかを入口ごとに決め直すことになる。 + +根拠: Value 2 / Value 3(MVV 版 5) + +### 決定 8: backlog-refinement の見直しの持ち主は `run_owner.py` のまま残す + +見直しの持ち主(`.ndf/backlog/owner.json`)は、同じホストでも最後に動いた時刻から `depths.owner.ttl` を過ぎた宣言を別のセッションが引き取れる。 +持ち主の部品へ移すと、同じホストの生死を pid と開始時刻で決める規則(I4)に入る。その結果、見直しの持ち主の振る舞いが変わる。 +変える理由になる不具合は 3 件に無い。 +`run_owner.py` からはレビュー worktree の持ち主の関数だけを消し、見直しの持ち主が使う関数を残す。 + +見直しの持ち主も同じ柱で移す形は採らない。振る舞いの変更と設定(`depths.owner.ttl`)の扱いの決定が加わり、承認ゲート 1 の答えの範囲を超える。 +同じ役割の部品が `lease.py` と `run_owner.py` に分かれた状態は残る(Value 7)。移すときは、見直しの持ち主の生死の規則を決め直す変更として扱う。 + +根拠: Value 2 / Value 3(MVV 版 5) + +### 決定 9: 持ち主の鍵を宣言する入口はすべて止まり、待つ形は作らない + +持ち主の鍵を宣言する入口の持ち主は、どれも数分から数時間動く(収束ループ・リファクタリング・キュー・開発ループ)。 +待つにすると、後から来た conductor が進まないまま上限まで止まる。持ち主の実行の識別子を知るのも上限の後になる。 +止まって結果に持ち主の実行の識別子を出せば、conductor はその場で待つか別の仕事へ移るかを決められる。 + +部品と契約に待つ形だけを用意しておく形は採らない。承認ゲート 1 の答えが範囲から外した。使う入口が無い形は、テストと契約の検査だけを増やす。 +同じ PR の cross-review を待つにする形も採らない。同じ PR の収束ループが 2 本並ぶのは conductor の取り違えで、待った後に流すと同じ指摘を二重に直す。 + +根拠: Value 3 / Value 2(MVV 版 5) diff --git a/issues/issue-2342-design.md b/issues/issue-2342-design.md new file mode 100644 index 000000000..3b1570da6 --- /dev/null +++ b/issues/issue-2342-design.md @@ -0,0 +1,471 @@ +# 実行の持ち主: どの終わり方の後も次の実行が止まらず、並行する conductor が互いを知り、キューの再開で `--from` が勝つ(#2342) + +## 目的 + +- **今どうなっているか**: 着手前のテストの打ち切りで終わった実行が持ち主の記録に終わった時刻を残さず、次の実行を 24 時間断る(#2321)。2 つの conductor が同じ開発ループとキューを互いに知らずに流す(#2316)。強制終了の後の `queue --resume --from` が前の途中の位置に負ける(#2325) +- **誰に効くか**: NDF でスプリントを回す conductor と利用者。打ち切り・強制終了・並行のたびに、持ち主の記録を手で書き換えるか耐久の記録を手で取り消している +- **変えると何が成り立つか**: 実行の核が持ち主の記録を取り・返し・死んだ持ち主を引き取る。後から来た実行は止まり、持ち主を結果で知る。キューの再開は `--from` があれば前の途中を止めて指定のステップから流す + +## 適用範囲 + +- **働く範囲**: NDF の配布物(`plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts`)。配布先のリポジトリでも同じ部品が動く。hook(`plugins/ndf/hooks/` と hook から呼ぶ `*-guard.sh`)・`factory/` は含まない +- **プロジェクトごとに違うもの**: 持ち主の鍵の値(PR・done のパス・ループの名前)は入口の入力から作り、置き場はリポジトリの slug で分ける。寿命の窓は契約の一覧の値で、プロジェクトの `.ndf/` を読まない +- **当たるモード**: `standard` +- **この柱で直す子の課題**: #2321・#2316・#2325 の 3 件だけである(承認ゲート 1 の答え。MVV 版 5 の Value 2・Value 3)。ほかの子の課題は「子の課題の扱い」に残す理由を書く + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 利用者の指摘(2026-11-01)「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」 | 利用者の指示の原文(#2335 の本文から引用) | 持ち主の扱いを、入口ごとではなく核の 1 か所で持つこと | +| #2321 の記録: プロセスが残っていないのに `rf2318.owner.json` の `finished_at` が `null` のまま、次の init が 4 秒で断られた | 実測(課題の本文) | 終わった時刻を書く経路が入口ごとにあり、打ち切りの経路が漏れていること | +| #2321 の断りの案内は `--run` を渡す・`finish` の後に開始すると示すが、プランから呼ぶ `drive.py` は `pr` しか受けず、`refactor.py` に `finish` は無い | 実測(課題の本文) | 案内が打てない手段を示していること | +| #2316 の記録: 後から来たセッションの `loop start` が `resumed` を返し、同じ done の `queue --resume` を 2 つのプロセスが流した | 実測(課題の本文) | 開発ループとキューに、後から来た conductor を止める仕組みが無いこと | +| `supervise_lib/flow.py` の `launch_queue` は、途中の耐久ワークフローを止める(`keep=()`)のが `fresh`(プランの並びが変わった)のときだけである。`launch_run` は開始のステップを接頭辞に含め(`plan_prefix(engine, start)`)、接頭辞の違う途中を止める(`keep=prefix`) | コードの読み取り(2026-11-04、`supervise_lib/flow.py:104-127`) | `--from` が負けるのはキューの再開だけで、`run --from` はすでに勝つこと | +| 契約の一覧の入口の行のキー(`scripts/contracts_lib/model.py:_check_entry` の必須 11 個と任意の `callers`・`external`)に持ち主の鍵が無い | コードの読み取り | 持ち主の宣言を契約の一覧へ足す必要があること | + +要求と受け入れ条件は #2342 の本文にある(コピーは [issue-2342-requirements.md](issue-2342-requirements.md) )。この文書は「どう作るか」だけを扱う。 +上位の設計は #2335 の [issue-2335-design.md](issue-2335-design.md) と [issue-2335-design-decisions.md](issue-2335-design-decisions.md) である。 +この文書はその決定 14・15 の持ち主の部品と置き場を正として、柱 P3 のうち 3 件に要る部分だけを詳しくする。決定 15 の再開の入口は作らない(決定 7)。 +決定の記録は [issue-2342-design-decisions.md](issue-2342-design-decisions.md) にある。 +データ構造と入出力の契約は [issue-2342-design-contracts.md](issue-2342-design-contracts.md) にある。 + +**実装は、柱 P1(#2337 結果の契約。すべての入口を実行の核 `run_core.main` へ移す)のマージの後に始める。** 持ち主の段を足す先が実行の核だからである。 +#2337 は 2026-11-04 の時点でマージされていない。この文書は #2335 での実行の核の形(`RunContext` が解決済みの入力を持つ・SIGTERM の受け手・結果の JSON)を前提に書く。 +#2337 の設計がそれと違う形で決まったときは、実装の開始時に読み直す(要求の未決の 2 行目)。柱 P2(#2338)・P8(#2341)のマージは待たない。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の worktree(`ndf-worktree`) | 持ち主の記録・持ち主の鍵・持ち主の契約・持ち主の単位・寿命の窓・状態の一覧・片付け | +| NDF の開発ワークフロー(`ndf-workflow`) | 実行の核・実行の識別子・耐久の記録 | + +2 つは #2335 と同じく**共有カーネル**の関係にある。持ち主の部品(`lib/lease.py`)を、実行の核と片付けの両方が使う。 +共有部分を変えるのはこの柱の Pull Request だけで、読み手と書き手を同じ差分で直す。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 持ち主の記録 | 持ち主の部品(`lib/lease.py`)だけ。30 日を過ぎた記録の削除も持ち主の部品の `lease.prune` が行い、片付けはそれを呼ぶだけにする | 持ち主の鍵 | — | 持ち主のプロセス(pid・開始時刻・ホスト名)・時刻(開始・最後に動いた・終わった) | +| 持ち主の契約 | この柱と入口を変える Pull Request(実行時は読むだけ) | 入口の契約の行の `lease` | — | 鍵の形・持ち主の単位・寿命の窓・返す副命令か | +| 耐久の記録 | 耐久の部品(`lib/durable.py`) | 起動の種類と鍵 | 耐久ワークフロー | 実行の回 | + +**集約の間は識別子で参照する。** 持ち主の記録は実行を実行の識別子で指す。1 回の取る・返すが書き換える集約は持ち主の記録 1 つである。 +片付けは持ち主の記録を読むだけで書き換えない(記録の削除は `lease.prune` を呼ぶ)。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 持ち主の記録 | 1 つの持ち主の鍵を、生きた持ち主が 2 つ同時に持たない(#2335 の I4) | 後から来た実行は `stopped`・終了コード 3(理由に持ち主の実行の識別子) | +| I2 | 持ち主の記録 | 記録を読んでから書くまでを、鍵ごとの排他の中で行う | 排他を取れなければ記録を書かない | +| I3 | 持ち主の記録 | 持ち主の単位が `run` の鍵は、正常・例外・SIGTERM・時間切れのどの終わり方でも、終わった時刻が書かれる(#2335 の I5) | SIGKILL・電源断の後は、次の実行が I4 で死を判定して引き取る | +| I4 | 持ち主の記録 | 持ち主が生きているのは、記録の pid が同じホストで動いていて、そのプロセスの開始時刻が記録と 1 秒未満で一致するときだけ。別のホストの記録は最後に動いた時刻から寿命の窓の中を生きているとみなす | pid が一致し開始時刻が違えば、pid の再利用として死んだ持ち主に扱う | +| I5 | 持ち主の記録 | 記録が持つのは版・鍵・入口・実行の識別子・3 つの時刻・pid・開始時刻・ホスト名だけ | 書く前に形を型で確かめ、余分な鍵があれば書かずに `stopped`・1 | +| I6 | 持ち主の記録 | 記録の持ち主のプロセス(pid と開始時刻)が自分の祖先のプロセスである実行は、持ち主の鍵を取り直さずに使う(`inherited`)。実行の識別子の一致では判定しない | 祖先が持っていない鍵は I1 のとおり取り合い、取った実行がその鍵の持ち主になって自分の終わりで返す | +| I7 | 耐久の記録 | キューの再開で `--from` があれば、前の途中の耐久ワークフローを子ごと止めてから新しい実行の回で始める | 止められなければ流さずに `stopped`・1 | + +### ドメインイベント + +要求の E1〜E8 を引き継ぐ。右端の列は #2335 のイベントの番号である。 + +| # | イベント | 発生元 | 受け手 | #2335 | +| --- | --- | --- | --- | --- | +| E1 | 入力を解決し、持ち主の鍵を作った | 実行の核 | 持ち主の部品 | E2 | +| E2 | 持ち主の記録を読んだ | 持ち主の部品 | 持ち主の部品(E3〜E5) | E4 | +| E3 | 死んだ持ち主の記録を引き取った | 持ち主の部品 | 結果 JSON | E4 | +| E4 | 生きた持ち主がいるため断った | 持ち主の部品 | 実行の核(`stopped`・3)・呼ぶ側 | E4 | +| E5 | 持ち主の記録を取った | 持ち主の部品 | 実行の核(本体を呼ぶ) | E4 | +| E6 | ステップの中身を実行した | 本体 | 実行の核 | E7 | +| E7 | 持ち主の記録に終わった時刻を書いた | 実行の核 | 次の実行(E2)・片付け | E11 | +| E8 | キューを `--from` つきで再開した | キューの入口(`supervise.py queue`) | 耐久の部品 | E13 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 持ち主の単位 | 持ち主の記録を誰が持つか。`run` は入口の 1 回の実行で、実行の終わりで返す。`session` はランタイムのセッション(Claude Code・Codex のプロセス)で、返す副命令が呼ばれるかセッションが終わるまで持つ。どちらも、返せるのは記録の持ち主のプロセスが自分(`session` では自分のランタイムのセッション)と同じ実行だけである | 追加(`ndf-worktree`) | +| 持ち主の契約 | 入口の契約の行が持つ `lease` の宣言。副命令ごとに鍵の形・持ち主の単位・寿命の窓・返す副命令かを決める。見直しの持ち主が実行時に取る記録(既存の語「持ち主の宣言」)とは別の物である | 追加(`ndf-worktree`) | +| 寿命の窓 | 持ち主の記録が終わっていなくても、最後に動いた時刻からこの長さを過ぎた持ち主を死んでいるとみなす長さ。持ち主の部品の記録では別のホストの記録にだけ使い、同じホストは pid と開始時刻で決める(I4) | 意味の変更(`ndf-worktree`。持ち主の部品の記録では別のホストにだけ使う) | +| 持ち主の記録 | 持ち主の鍵ごとに 1 つ、状態の置き場の `leases/<リポジトリ>/` に置く記録。実行の識別子・入口・開始・最後に動いた時刻・終わった時刻・持ち主のプロセス(pid・開始時刻・ホスト名)を持つ | 意味の変更(`ndf-worktree`。残る状態ごとの記録から、鍵ごとの記録へ) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 持ち主の鍵で持ち主の記録を排他の中で取り、どの終わり方でも返す | 実行の核(持ち主の鍵を宣言した入口すべて) | +| F2 | 死んだ持ち主(pid の再利用を含む)を判定し、引き取ってから取る | 持ち主の部品 | +| F3 | 生きた持ち主がいるとき止まり、結果 JSON に持ち主の実行の識別子・pid・最後に動いた時刻と、打てる手段だけの案内を出す | 呼ぶ側(conductor・プラン・利用者) | +| F4 | 開発ループをセッションで、キューを done ごとに持ち、2 つ目の conductor を止める | conductor | +| F5 | キューの再開で `--from` があれば、前の途中を子ごと止めて新しい実行の回で流す | conductor・利用者 | +| F6 | レビュー worktree の片付けが、持ち主の部品の記録で生死を判定し、古い記録を消す | 片付け | + +## 構成要素 + +| 要素 | 責務 | 新設 / 変更 | +| --- | --- | --- | +| 持ち主の部品(`lib/lease.py`) | `acquire` / `touch` / `release` / `read` / `prune`(30 日を過ぎた記録の `.json` の削除。錠の `.lock` は残す)。鍵ごとの排他(`locks.exclusive`)の中で記録を読み書きし、生死を判定する。外部コマンドを起こさない | 新設(`run_owner.py` のうちレビュー worktree の記録の読み書きと、`run_state._process_alive` の判定を移す) | +| 持ち主の記録の旧い読み手(`lib/run_owner.py`) | レビュー worktree の持ち主の関数(`adopt_owner`・`mark_owner`・`run_args`・`claim_owner`・`touch_owner`・`finish_owner`)を消す。backlog-refinement の見直しの持ち主が使う関数(`read_owner`・`write_owner`・`alive` など)は残す(決定 8) | 変更 | +| プロセスの部品(`lib/procs.py`) | `runtime_session()`(祖先のランタイムのプロセスの pid と開始時刻。環境変数 `NDF_RUNTIME_SESSION` があればそれを先に返す)と `ancestors()`(自分の祖先の pid と開始時刻の並び)を足す | 変更(`hook_lib/worktree.session_owner` の探し方を移す) | +| 実行の核の持ち主の段(`lib/run_core.py`) | E1 の後に `lease.acquire`、終わり(`finally`・SIGTERM の受け手)に `lease.release`。結果 JSON に `lease` を足す | 変更(柱 P1 #2337 の実行の核に段を足す) | +| 契約の読み手(`scripts/contracts_lib/model.py`) | 入口の行の任意のキー `lease` の形を確かめる | 変更 | +| 契約の一覧(`plugins/ndf/contracts/entrypoints/`) | 持ち主の鍵を宣言する 4 つの入口の行に `lease` を書く | 変更 | +| 状態の一覧(`lib/run_state_kinds.py`) | 種類 `lease-record`(`leases/` の記録と錠)の行を足す。`review-worktree` の行の記録の置き場を `leases/` に直す | 変更 | +| 片付け(`lib/run_state.py`・`merged-steps.py`) | レビュー worktree(`pr`・`rf`)の持ち主を、鍵 `cross-review:pr=`・`cross-refactoring:pr=` の `lease.read` で判定する。種類 `lease-record` は `lease.prune` を呼ぶ | 変更 | +| cross-refactoring と cross-review の入口(`drive.py`・`refactor.py init`・`state.py init`) | 自前の持ち主の扱い(`adopt_owner`・`mark_owner`・`--run` の受け渡し)を消し、長く動く間は `ctx.touch()` で最後に動いた時刻を書く | 変更 | +| 開発ループ(`lib/sprint_loop.py`) | `loop` の副命令は、利用者が答えるもの(`answer`・`approve-mvv`)と読むだけのもの(`status`・`list`)を除き、持ち主の単位 `session` の鍵 `supervise.loop:name={name}` を宣言する。`stop`・`finish` が返し、ランタイムのセッションの外でも本体を走らせる | 変更 | +| キューの耐久の記録の開き方(`supervise_lib/flow.py` の `launch_queue`・`supervise_lib/queue.py`) | `--from` があれば `fresh` と同じく途中の耐久ワークフローを子ごと止めてから開く | 変更 | + +```mermaid +graph TD + subgraph 呼ぶ側 + C[conductor・プラン・利用者] + end + subgraph 入口 + EN[持ち主の鍵を宣言した入口] + Q[キューの入口] + end + subgraph 実行の核 + K[持ち主の段] + LS[持ち主の部品] + PR[プロセスの部品] + end + subgraph 状態 + SW[片付け] + DB[耐久の部品] + end + CT[契約の一覧] + C --> EN + C --> Q + Q --> EN + EN --> K + K --> CT + K --> LS + LS --> PR + SW --> LS + Q --> DB +``` + +キューの入口は持ち主の鍵を宣言した入口の 1 つで、耐久の記録の開き方だけを別に描く。 +処理の流れの図に描かない要素は次の 4 つである。 + +| 描かない要素 | 理由 | +| --- | --- | +| 契約の読み手 | 核が契約の一覧を読むときに通る | +| 状態の一覧 | 片付けが読む宣言である | +| 持ち主の記録の旧い読み手 | 見直しの持ち主だけが使う | +| 入口ごとの変更 | 持ち主の鍵を宣言した入口の一部である | + +### 配置 + +```mermaid +graph TD + subgraph 同じホスト + A[入口のプロセス] -->|排他で読み書き| LE[leases/リポジトリ/鍵.json] + A -->|子として起こす| B[子の実行] + B -->|持ち主が祖先のプロセスなら同じ鍵を使う| LE + S[ランタイムのセッション] -.->|session の鍵の持ち主| LE + A -->|キューのときだけ| DB[dbos/queue-鍵.sqlite] + W[片付け] -->|読むだけ| LE + end +``` + +置き場は `${NDF_STATE_DIR:-~/.local/state/ndf}/leases/--/<鍵をファイル名にしたもの>.json` である。 +排他の錠は、同じ名前に `.lock` を付けたファイルである。錠のファイルは `lease.prune` でも消さない(`locks.py` の規則。消すと別のプロセスが別の inode で同じ鍵の排他を取れてしまう)。 +`--` は origin の URL から作り、同じリポジトリの別の clone と worktree の実行どうしが取り合う(#2335 の決定 15)。 +鍵のうち `/` と `:` と `=` は `%2F`・`%3A`・`%3D` に置き換えてファイル名にする。 + +### 置き場所 + +```text +plugins/ndf/ +├── contracts/ +│ └── entrypoints/*.json # 変更: lease を足す +└── scripts/ + ├── lib/ + │ ├── lease.py # 新設 + │ ├── run_owner.py # 変更: レビュー worktree の持ち主の関数を消す + │ ├── procs.py # 変更 + │ ├── run_core.py # 変更(柱 P1 が新設した核に足す) + │ ├── run_state.py # 変更 + │ ├── run_state_kinds.py # 変更 + │ └── sprint_loop.py # 変更 + ├── merged-steps.py # 変更 + └── supervise_lib/ # 変更: flow・queue +scripts/ +└── contracts_lib/model.py # 変更 +``` + +## 構造 + +```mermaid +classDiagram + class LeaseDecl { + +key_form + +unit + +window_seconds + +release + } + class LeaseRecord { + +version + +key + +entry + +run + +started_at + +seen_at + +finished_at + +pid + +pid_started + +host + } + class LeaseOutcome { + +key + +outcome + +holder_run + +holder_pid + +holder_seen_at + } + LeaseDecl ..> LeaseRecord : acquire が書く + LeaseRecord ..> LeaseOutcome : 核が結果へ写す +``` + +- **部品の形**: 次の 5 つの関数を持つ。生きた持ち主がいても例外を投げず、`acquire` は `outcome` が `refused` の `LeaseOutcome` を返す。 + `refused` のときも持ち主の実行の識別子・pid・最後に動いた時刻を `holder_run`・`holder_pid`・`holder_seen_at` に入れる。 + 核は `refused` を `stopped`・3 に変え、`LeaseOutcome` の 5 項目を結果 JSON の `lease` へ写す(写すのは核だけ) + + ```python + lease.acquire(decl, inputs, run) -> LeaseOutcome + lease.release(key, holder) -> None + lease.touch(key, holder) -> None + lease.read(key) -> LeaseRecord | None + lease.prune(days) -> list[str] + ``` + + `holder` は持ち主のプロセス(pid と開始時刻)で、単位 `run` では自分の入口のプロセス、単位 `session` では自分のランタイムのセッション(`procs.runtime_session()`)である。 + 単位 `session` で `runtime_session()` が `None`(祖先に Claude Code・Codex のプロセスが無い。ランタイムの外のシェル・CI・テストの起動)のときは、入口のプロセスへ退避せず、記録を読み書きせずに `outcome` が `no_session` の `LeaseOutcome` を返す。 + 核が `no_session` をどう扱うかは、副命令の宣言で決まる。 + + | 副命令の宣言 | 例 | `no_session` のときの核 | + | --- | --- | --- | + | 鍵を取る(`release` が偽) | `loop start`・`loop sprint`・`loop lap`・`loop pending` | `stopped`・1 に変え、`summary` に「ランタイムのセッションの外では、セッション単位の鍵を取れない」と書く。鍵を取らずに本体を走らせない | + | 返す(`release: true`) | `loop stop`・`loop finish` | 記録を読み書きせずに本体を走らせる。結果 JSON の `lease.outcome` は `no_session` のまま出す。利用者が自分の端末からループを止める手段を残すためである | + | 宣言しない(利用者が答える) | `loop answer`・`loop approve-mvv` | 持ち主の段を通らない(`acquire` を呼ばない)。利用者が端末や別のセッションから承認を記録する手段を残すためである。記録の書き換えどうしは開発ループの記録を開く排他(`open_record`)が守る | + | 宣言しない(読むだけ) | `loop status`・`loop list` | 持ち主の段を通らない(`acquire` を呼ばない) | + + `release` と `touch` は `holder` が `None` なら何も書かない(`acquire` が記録を書いていないため)。 + ランタイムのセッションは、環境変数 `NDF_RUNTIME_SESSION`(`:<開始時刻>`)があれば `runtime_session()` がそれを祖先の探索より先に返す。 + テストはこの環境変数と `NDF_STATE_DIR` を、全テストに効く自動の fixture で与える。置き場はプラグインの根の `plugins/ndf/conftest.py` の `_isolated_usage_dirs`(既存のテストの隔離。プラグインのキャッシュでも効く)で、`plugins/ndf/scripts/tests` と `plugins/ndf/skills/*/tests`(cross-review・cross-refactoring の `drive.py`・`state.py init` のテストなど)のどちらにも届く。値は pytest のプロセスの pid と開始時刻、置き場はテストの一時ディレクトリである。 + サブプロセスで入口を起動するテスト(`test_sprint_loop.py` の CLI のテストなど)にも環境変数で届くため、ランタイムのいない CI でも `no_session` にならず、本物の `~/.local/state/ndf/leases/` に記録を書かない。 + 関数の中だけのテストは `procs.runtime_session` の差し替えでもよい。 + `release` と `touch` は、記録の `pid` と `pid_started` が `holder` と一致するときだけ書き、一致しなければ何も書かない。 + 実行の識別子(`run`)は照合に使わない + +- **核の形**: 本体は持ち主の記録を触らない。長く動く本体は `ctx.touch()` で最後に動いた時刻を書く(drive の `mark_owner(..., "touch")` を置き換える) + +## データ構造 + +[issue-2342-design-contracts.md の「データ構造」](issue-2342-design-contracts.md#データ構造) に次のものがある。 + +- 持ち主の記録の形 +- 入口の契約に足すキー +- 持ち主の鍵を宣言する入口 +- 状態の一覧に足す行 +- 移行 + +## 入出力の契約 + +[issue-2342-design-contracts.md の「入出力の契約」](issue-2342-design-contracts.md#入出力の契約) に次のものがある。 + +- 結果 JSON に足す鍵 +- 断るときの案内 +- キューの `--from` つきの再開 + +## 処理の流れ + +### 持ち主の記録を取る(E1〜E5) + +```mermaid +sequenceDiagram + participant K as 実行の核 + participant L as 持ち主の部品 + participant F as 持ち主の記録 + K->>L: acquire(鍵・単位・実行) + alt 単位 session でランタイムのセッションが無い + L-->>K: no_session(記録を読み書きしない。鍵を取る副命令なら核が stopped・1 に変え、返す副命令なら本体を走らせる) + end + L->>F: 鍵の排他を取り、記録を読む(E2) + alt 記録が壊れている + L-->>K: 読めない(核が stopped・1 に変え、理由に記録のパス) + end + L->>L: 生死の判定(下の表) + alt 無い・終わっている + L->>F: 記録を書く(E5) + L-->>K: acquired + else 死んでいる + L->>F: 記録を書き直す(E3・E5) + L-->>K: reclaimed + else 持ち主のプロセスが自分の祖先 + L->>F: 最後に動いた時刻を書く + L-->>K: inherited + else 生きている + L-->>K: refused(E4。持ち主の 3 項目を添えて返す) + end + L->>F: 排他を外す +``` + +| 記録の状態 | 判定 | 結果 | +| --- | --- | --- | +| 記録が無い | いない | `acquired` | +| `finished_at` がある | 終わっている | `acquired` | +| 記録の pid と開始時刻が、自分の祖先のプロセス(単位 `session` では自分のランタイムのセッションを含む)と一致する | 同じ実行の子・同じ conductor | `inherited`(書き換えるのは `seen_at` だけ) | +| 同じホストで、pid が無い・pid が別のプロセスで開始時刻が違う | 死んでいる(I4) | `reclaimed` | +| 同じホストで、pid と開始時刻が一致する | 生きている | `refused` | +| 別のホストで、最後に動いた時刻が寿命の窓(`window_seconds`)の外 | 死んでいる | `reclaimed` | +| 別のホストで、最後に動いた時刻が寿命の窓の中 | 生きている | `refused` | + +**記録の `run` の一致では `inherited` にしない。** 祖先のプロセスかどうかは `psutil` で自分の親をたどって確かめる(決定 4)。 +**`acquire` は記録の有無によらず、毎回 `procs`(`psutil`)を読む。** `acquired`・`reclaimed` で記録へ自分の開始時刻(`pid_started`)を書き、`inherited` の判定で祖先をたどるためである。読み込みを遅らせてよいのは、別のホストの記録の判定だけである(`psutil` を使わない)。 + +**引き取りは記録を書き直すだけにする。** 死んだ持ち主が残したレビュー worktree は、入口が今と同じく作り直すか使い直す。引き取りの中で片付けを呼ばない(決定 6)。 + +### 返す(E6・E7) + +```mermaid +sequenceDiagram + participant K as 実行の核 + participant B as 本体 + participant L as 持ち主の部品 + K->>B: 本体を呼ぶ + B-->>K: 返った・例外・SIGTERM・時間切れ + K->>L: release(単位 run、または release を持つ副命令) + L-->>K: 書いた / 持ち主が違うため書かない + K-->>K: 結果 JSON の lease に書く +``` + +- **返すのは `finally` と SIGTERM の受け手の両方から 1 回だけ**(2 回目は何もしない)。時間切れは、本体の中の打ち切りが例外か終了コードで戻る経路で、`finally` を通る。#2321 の着手前のテストの打ち切りは、`refactor.py init` が終了コード 4 で戻る経路である +- `release` は、記録の持ち主のプロセス(pid と開始時刻)が自分の持ち主のプロセスと同じときだけ `finished_at` を書く。 + `inherited` で使った実行は返さない(祖先の持ち主が返す)。祖先が持っていない鍵を子の実行が取ったときは、その子が持ち主で、子の終わりで返す(I6) +- **単位 `session` の記録は、`release: true` の副命令でだけ返す。** ほかの副命令の終わりでは `seen_at` だけを書く。`loop start` と `loop finish` は別の実行だが、持ち主のプロセスが同じランタイムのセッションなので `loop finish` が返せる。セッションが終われば、次の取る側が I4 で死を判定する + +### 片付ける(F6) + +片付け(`run_state` の `review-worktree`・`merged-steps.py`)は、今 `//{pr,rf}.owner.json` を読んで判定している。 +この判定を、鍵 `cross-review:pr=`・`cross-refactoring:pr=` の `lease.read` と I4 の判定に置き換える。 +判定の結果の扱い(生きていれば `kept`、終わっていれば今の条件で外す)は変えない。種類 `lease-record` は、片付けが `lease.prune(30)` を呼んで外す。 + +### キューを `--from` つきで再開する(E8) + +```mermaid +sequenceDiagram + participant C as 呼ぶ側 + participant Q as キューの入口 + participant D as 耐久の部品 + C->>Q: queue --resume done --from プラン=ステップ + Q->>Q: 持ち主の記録を取る(E1〜E5) + alt from がある + Q->>D: launch(keep は空。途中を子ごと止める) + D->>D: 新しい実行の回 + else from も only も無い + Q->>D: launch(今のまま) + D->>D: resolve のとおり前の起動を続ける + end + Q-->>C: 結果 JSON +``` + +- `launch_queue` の `fresh` を「プランの並びが変わった、または `--from` がある」に広げる(決定 5)。`--only` の扱いは変えない +- `supervise.py run --from` は変えない。開始のステップを耐久ワークフローの接頭辞に含めており、すでに `--from` が勝つ(あるべき姿の根拠の 5 行目) + +### 持ち主の記録の状態 + +```mermaid +stateDiagram-v2 + [*] --> 持っている: acquire + 持っている --> 持っている: touch・inherited + 持っている --> 終わった: release + 持っている --> 死んだ: SIGKILL・セッションの終わり + 死んだ --> 持っている: 次の acquire(reclaimed) + 終わった --> 持っている: 次の acquire + 終わった --> [*]: 30 日を過ぎて lease.prune +``` + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 持ち主の段の増分が #2335 の上限 150 ms の内側 | 取る・返すは `filelock` と JSON の 1 ファイルの読み書きで、外部コマンドを起こさない。`psutil`(`procs`)は `acquire` のたびに読む(自分の開始時刻を書き、祖先をたどるため) | 柱の Pull Request で、持ち主の鍵を宣言した入口(`refactor.py init --help` ではなく、PR を渡して鍵を取る起動)を前後で 10 回ずつ測り、差の中央値を本文に書く | +| 可用性 | 生きた持ち主がいれば待たずに返る | `refused` で `stopped`・3 | 2 つのプロセスで同じ鍵を取り合うテスト | +| 運用・保守性 | 取った・引き取った・断った・返したを結果に出す | 結果 JSON の `lease`(入出力の契約) | 終わり方ごとのテストで `lease.outcome` を見る | +| 移行性 | 置き場を移す。revert で戻る | 移行の表のとおり。持ち主の段の Pull Request と入口を移す Pull Request を分け、段は契約の行に `lease` が無ければ何もしない | それぞれの Pull Request の revert の後に全体テストが通る | +| セキュリティ | 記録に秘密が無い | 記録の型が I5 の鍵だけを許す。鍵の形に `secret: true` の入力を使う宣言を契約の検査が落とす | 秘密を入れた環境変数で起動し、`leases/` の下に値が無いことを見る | +| システム環境 | extras が無ければ 69 | 持ち主の部品は `acquire` の先頭で `deps.require("locks")` と `procs` の読み込みを行う。`psutil`(`procs`)が無い環境は、記録を読む前に、どの入口でも同じ時点で `stopped`・69 と足りないグループ名を返す | extras を外した環境で鍵を宣言した入口を起動するテスト | + +## 決定の記録 + +決定の記録は [issue-2342-design-decisions.md](issue-2342-design-decisions.md) にある。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 1・I3 | 正常・例外・SIGTERM・時間切れで終えた後、記録に `finished_at` があり、同じ鍵の次の実行が `acquired` で始まる。#2321 の再現の手順(着手前のテストを上限で打ち切った後に `--from` で流し直す)を通す | `finally` から `release` を外す/SIGTERM の受け手で返さない | +| 2・I4 | SIGKILL の後の次の実行が `reclaimed` で取る。pid が一致し開始時刻が違う記録を `reclaimed` と読む | pid の生存だけを見る | +| 3 | 断りの結果に持ち主の実行の識別子・pid・最後に動いた時刻があり、`next` に `--run`・`finish`・`kill` が無い | 案内に今の文を戻す | +| 4 | 2 つのプロセスで同じ鍵を起動し、後の側が `refused`・3 になる | 結果に `lease` を書かない | +| 5・I1・I2 | 多数のプロセスが同時に同じ鍵を取り、生きた持ち主が常に 1 つ | 排他の外で読んで書く | +| 6 | 2 つのセッションから同じ開発ループの `loop start` を打つと、後の側が `resumed` を返さず `refused`・3。同じ done の `queue --resume` も同じ。#2316 の再現の手順を通す | ループの鍵の単位を `run` にする/`queue` に鍵を宣言しない | +| 7・I7 | 途中のまま残った後の `queue --resume --from <プラン>=<ステップ>` が指定のステップから新しい実行の回で流れ、前の途中の耐久ワークフローが子ごと止まる。`--from` も `--only` も無ければ前の起動を続ける。#2325 の再現の手順を通す | `fresh` を並びの変化だけに戻す | +| 8 | 終わった記録のレビュー worktree が今の条件で外れ、生きた記録のものは `kept`。30 日を過ぎた記録が消える | 片付けが旧い置き場を読む | +| 9 | (手動)子の課題 3 件の再現の手順を本番へ出した版で通し、課題の本文の表を埋める | — | +| 10 | `lease` の無い入口の振る舞いが変わらず、全体テストが前後で通る | 鍵の無い入口でも記録を書く | +| 6・I1 | ランタイムのセッションが見つからない(`procs.runtime_session` が `None` を返す)ときの `loop start` が、記録を書かずに `no_session`・`stopped`・1 で止まり、本体を走らせない | 入口のプロセスを持ち主にする/鍵を取らずに本体を走らせる | +| 6 | ランタイムのセッションが見つからないときも、`loop stop` と `loop finish` は本体を走らせ(記録は書かない)、`loop status`・`loop list`・`loop answer`・`loop approve-mvv` は持ち主の段を通らずに本体を走らせる。別のセッションが生きた持ち主のときも、この 4 つは `refused` で止まらない | `no_session` を副命令の宣言によらず `stopped`・1 にする/読むだけの副命令・利用者が答える副命令に鍵を宣言する | +| 6・I1 | 別のセッションが生きた持ち主のとき、`loop pending` が `refused`・3 で止まり、周回の記録を書き換えない | `loop pending` を読むだけの副命令として持ち主の段から外す | +| 6・10 | ランタイムのいない CI で、自動の fixture が与えた `NDF_RUNTIME_SESSION` によりサブプロセスで起動した `loop start` が `no_session` にならず、記録が `NDF_STATE_DIR` の一時ディレクトリにだけ書かれる | `runtime_session()` が環境変数を読まない/置き場を `NDF_STATE_DIR` で変えられない | +| 4 | 生きた持ち主がいるとき、`acquire` が例外でなく `refused` の `LeaseOutcome` を返し、結果 JSON の `lease` に持ち主の 3 項目が入る | `refused` を例外で投げ、持ち主の項目を結果へ写さない | +| 8・I1 | `lease.prune` が 30 日を過ぎた `.json` だけを消し、`.lock` が残る | `.lock` も消す | +| 11・I5 | 秘密を入れた環境変数で起動しても記録に値が無い。余分な鍵を持つ記録を書こうとすると `stopped`・1 | 記録に引数の並びを書く | +| I6 | 祖先が持つ鍵を子の実行が `inherited` で使い、子の終わりで記録が返らない。祖先が持たない鍵を子が取ったときは子の終わりで返る | 子の終わりで `release` する/`run` の一致で `inherited` にする | + +## 子の課題の扱い + +受け入れ条件 9 で、柱の仕組みを本番へ出した後に 3 件の再現の手順を 1 回ずつ通す。この表は設計の時点の判定で、結論は再現の結果で決める。 + +| 課題 | 判定 | 理由 | +| --- | --- | --- | +| #2321 | 解消の見込み | 打ち切りも核の `finally` を通って返し、断りの案内は打てる手段だけを示す | +| #2316 | 一部の解消の見込み。課題は残す | 開発ループを単位 `session` で、キューを done ごとに持つ。本文の残りの対象(Issue・触るファイル・版数・マシンをまたぐロック)は範囲外 | +| #2325 | 解消の見込み | `--from` があれば途中を子ごと止めて新しい実行の回で流す | +| #2285 | 残す | 子のプロセスの停止を範囲から外した | +| #2313 | 残す | 3 件の外。#2321 の解消で流せる見込みだが、確かめる作業を範囲に入れない | +| #1592・#1635 | 残す | 再開の入口の新設と旧い再開の形の別名を範囲から外した(決定 7) | +| #2009 | 残す | 承認資料の後片付けを範囲から外した | +| #2243 | 残す | 一時の置き場の一覧の整理を範囲から外した | +| #1886 | 残す | リポジトリの根の解決(柱 P8 #2341)の問題で、持ち主の記録では変わらない | +| #2198 | 残す | ステップの冪等性の問題で、再開の手段では変わらない | +| #1967 | 残す | テストの間の状態の共有で、持ち主の記録の外 | +| #1640 | 残す | 受け渡しの契約の問題で、状態の寿命の外 | +| #2273 | 残す | プランの分岐の問題 | +| #1811・#2037・#2165 | 残す | キューの重なりの判定の粒度(対象範囲の「含まない」) | +| #2229 | 閉じている | 置き場を移しても #2229 の受け入れ条件が保たれることを全体テストで確かめる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2342 | 実装する(#2337 のマージ後に始める) | — | `plugins/ndf/scripts/lib/`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/merged-steps.py`、`plugins/ndf/scripts/hook_lib/worktree.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/cross-review/scripts/`、`plugins/ndf/skills/cross-refactoring/scripts/`、`plugins/ndf/contracts/`、`scripts/contracts_lib/model.py`、`scripts/tests/`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +子の課題は表に載せない。表の行はスプリントの Pull Request の `Closes` になるが、子の課題は本番へ出した版で再現の手順を通してから閉じるためである(受け入れ条件 9)。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 前提の柱の形 | 実行の核の形は #2335 の設計から置いた。#2337 が違う形で決まれば、持ち主の段の呼び出しの位置と、鍵を埋める入力の受け取り方を読み直す | +| 中継の切り替えとセッションの持ち主 | 中継の再起動で新しいセッションが前のセッションの終わりより先に `loop` を打つと、前のセッションが生きている間は `refused` になる。切り替えの順を再現の手順で確かめる | +| 旧い版との並走 | 新しい版は旧い置き場の記録を読まない(決定 3)。旧い版と新しい版が同じ鍵を同時に取ると排他が効かない。利用者が両方を同時に動かす運用は確認していない | +| 旧い置き場の残り | 旧い置き場の `*.owner.json` は、今の片付けがレビュー worktree を外すときに一緒に消す(`run_state.py:157`・`run_state.py:167`)。worktree が残る間は記録も残るが、新しい版は読まない | +| 増分の実測 | 持ち主の段の増分は柱の Pull Request で測る。`psutil` は `acquire` のたびに読む。150 ms に収まらなければ、内訳(`psutil` の読み込み・排他・読み書き)を測って本文に書き、設計へ戻す | diff --git a/issues/issue-2342-requirements.md b/issues/issue-2342-requirements.md new file mode 100644 index 000000000..b329b2484 --- /dev/null +++ b/issues/issue-2342-requirements.md @@ -0,0 +1,214 @@ +# #2342: 実行の持ち主と後片付けを核に持たせ、残骸と並行の衝突で止まらないようにする + +正は課題の本文(#2342)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +大方針 #2335(異常系・ログ・回復とデータ・入出力の形を 1 つの実行の核へ集める)の柱の 1 本(柱の記号 P3「持ち主・後片付け・並行」)。この柱で解消を確かめる子の課題は #2321・#2316・#2325 の 3 件に絞る(MVV 版 5 の Value 2「人が判断できる量に収める」・Value 3「やめる・縮めるを既定にする」。承認ゲート 1 の答え「直して出し直す」による)。ほかの子の課題は、残す理由を「子の課題」の表に書く。 + +## 依頼(原文) + +この課題の題と本文は、起票者(課題を書いた AI)が 2026-11-01 に大方針 #2335 の組み直しの記録として書いた文で、利用者の指示の原文ではない。この柱の元になった利用者の指示は、#2335 の「依頼(原文)」が引用した次の文である。 + +> 異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない + +> 内部データ構造と出力のフォーマットの不統一の影響も大きそう + +> NDF は Skill の Script 化から発展してきたため、内部データ構造や入出力契約がほぼ整理されていない + +> 出力だけでなく入力もそろえるべき + +(書き手: 利用者、日付: 2026-11-01。#2335 の本文 https://github.com/devbasex/ai-plugins/issues/2335 から引用した) + +起票時の本文(書き手: 起票者、日付: 2026-11-01): + +> 実行の核が実行の持ち主(lease)を取り、どの終わり方(正常・例外・シグナル・時間切れ)でも終了時刻と片付けを書く。ロック・持ち主の記録・worktree・状態ファイル・一時ファイルの寿命をこの lease に結び付ける。並行するスプリント・ループ・プランの排他も同じ lease で取る。再開は耐久の記録(`durable`)を唯一の手段にする。 + +> 1. どの終わり方でも持ち主の記録に終了時刻が書かれ、次の実行が残骸で止まらない(#2321 の再現の手順で確かめる) +> 2. 強制終了の後の再開で `--from` が効き、再開の手段は耐久の記録 1 つになる(#2325 の再現の手順で確かめる) +> 3. 並行する実行が同じ対象を触るとき、lease で排他され、待つか止まるかが結果に出る +> 4. 子の課題それぞれを、閉じた(解消を確かめた)か残した(理由つき)かに分けて本文に記録する + +> 子の課題は 1 件ずつ直さない。柱の仕組みを入れた後に、子の課題の再現の手順を 1 回ずつ通し、解消したものを理由を添えて閉じる。設計は #2335 の設計(マイルストーン「28 根本解決 1: 設計と契約の棚卸し」)の後に、この柱ごとに行う。 + +この要求の整理の指示(書き手: AI の起動指示、日付: 2026-11-04): + +> 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +承認ゲート 1 の答え(書き手: 利用者、日付: 2026-11-04。下の節「人の判断待ち」の「直して出し直す指摘」): + +> 範囲を #2321(強制終了の後に残る持ち主の記録)・#2316(並行する conductor が互いを知らない)・#2325(再開の --from が前の位置に負ける)の 3 件に絞る(MVV 版 5 の量の上限・縮めるを既定)。 +> 外すもの: 旧い置き場の記録を読む互換(決定 10)・旧い再開の 3 つの形の別名・「待つ」の形だけの用意(決定 2)・配布の承認資料の後片付け(決定 11、#2009)。外した子の課題は理由を付けて残す。 + +**解釈**: 利用者が求めているのは、中断・強制終了・並行の後に残った記録が次の実行を止める型の不具合(大方針の原因の型 C 回復・再開と F 並行の衝突)を、仕組みで無くすことである。この柱では、その型のうち #2321・#2316・#2325 の 3 件が示す経路だけを直す。手段は、実行の核が持ち主の記録を取り・返し・死んだ持ち主を片付けることと、キューの再開で `--from` を耐久の記録の位置より優先することである。この文書では、起票時の本文の「lease」を #2335 の設計に合わせて **持ち主の記録**(と、それを取り合う排他)と呼ぶ。 + +## 起票者の推定 + +題・本文のうち、上の利用者の原文に無く、起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 実行の核が持ち主の記録を取り、正常・例外・シグナル・時間切れのどの終わり方でも終わった時刻を書く | 起票時の本文「何をするか」・#2335 の受け入れ条件 4 | 受け入れ条件 1・2 | +| S2 | ロック・worktree・状態ファイル・一時ファイルの寿命を持ち主の記録に結び付ける | 起票時の本文「何をするか」 | 縮めた。レビュー worktree の片付けが新しい置き場の持ち主の記録を読むこと(受け入れ条件 8)だけを含む。一時の置き場の一覧(#2243)と承認資料(#2009)は外した | +| S3 | 並行するスプリント・ループ・プランの排他も同じ持ち主の記録で取る | 起票時の本文「何をするか」・#2335 の起票者の推定 S4 | 開発ループとキューだけ(受け入れ条件 4〜6) | +| S4 | 待つか止まるかが結果に出る | 起票時の本文の受け入れ条件 3 | 止まるだけにした(受け入れ条件 4)。待つは外した | +| S5 | 再開は耐久の記録を唯一の手段にし、強制終了の後の再開で `--from` が効く | 起票時の本文・#2335 の起票者の推定 S7 | `--from` が効くこと(受け入れ条件 7)だけを含む。再開の入口を 1 つにすることと旧い形の別名は外した | +| S6 | SIGKILL の後は、次の実行が持ち主の記録(pid と開始時刻)から持ち主の死を判定して片付ける | #2335 の前提 6・受け入れ条件 4a | 受け入れ条件 2 | +| S7 | 子の課題は 1 件ずつ直さず、柱の仕組みを入れた後に再現の手順を通して閉じるか残すかに分ける | 起票時の本文「進め方」・#2335 の起票者の推定 S8 | 受け入れ条件 9・境界の「行わない」 | +| S8 | この柱の設計は #2335 の設計の後に行う | 起票時の本文「進め方」 | 前提 1 | + +## 目的 + +- 打ち切り・例外・シグナル・強制終了で終わった実行の記録が、次の実行を「使用中」で止めない状態にする(今は #2321 のように、終わった時刻の無い持ち主の記録が寿命の窓の 24 時間だけ次の実行を断る) +- 2 つの conductor が同じ開発ループ・同じキューを同時に流さず、後から来た側が止まって持ち主を結果で知る状態にする(今は #2316 のように、互いに気づく手段が無い) +- キューの再開で、`--from` の指定が前の起動の途中の位置より常に優先される状態にする(今は #2325 のように、前の起動が途中で残っていると `--from` が無視される) + +## 前提 + +- 前提 1: この柱の設計と実装は、#2335 の設計(`issues/issue-2335-design.md`・`issues/issue-2335-design-decisions.md`・`issues/issue-2335-design-pillars.md`)を正とする。持ち主の部品(`lib/lease.py`)・持ち主の記録の置き場(`${NDF_STATE_DIR:-~/.local/state/ndf}/leases/<リポジトリ>/<持ち主の鍵>.json`)は、その決定 14・15 に従う。決定 15 の再開の入口(`supervise.py resume`)は、この柱では作らない(範囲を絞ったため。理由は設計の決定の記録) +- 前提 2: **この柱の実装は、柱 P1(#2337 結果の契約。すべての入口を実行の核 `run_core.main` へ移す)のマージの後に始める。** 持ち主の段を足す先が実行の核だからである。2026-11-04 の時点で #2337 は未完了で、`plugins/ndf/scripts/lib/` に `run_core.py` と `lease.py` は無い(`ls plugins/ndf/scripts/lib/` で確かめた)。設計は #2337 の設計を正として先に書いてよい。柱 P2(#2338)・P8(#2341)のマージは待たない +- 前提 3: 持ち主の記録を取る入口は、契約の一覧(`plugins/ndf/contracts/`)の入口の行に持ち主の鍵の形が宣言されたものに限る。宣言の無い入口は持ち主の記録を取らない(#2335 の設計の移行性)。この柱で鍵を宣言するのは、cross-refactoring(`drive.py`・`refactor.py init`)・cross-review(`drive.py`・`state.py init`)・キュー(`supervise.py queue`)・開発ループ(`supervise.py loop`)である。backlog-refinement の見直しの持ち主は含まない +- 前提 4: 後から来た実行は、生きた持ち主がいれば止まる(`stopped`・終了コード 3)。待つ形は作らない +- 前提 5: 持ち主が生きているかは、持ち主の記録の pid とプロセスの開始時刻の両方が今のプロセスと一致するかで判定する。pid だけが一致し開始時刻が違うときは、pid が再利用されたとみなして死んだ持ち主として扱う。別のマシンで動く実行(pid を確かめられない)は寿命の窓で判定する +- 前提 6: 前の版の NDF が状態ディレクトリの横に書いた持ち主の記録(`*.owner.json`)は、新しい版は読まない。旧い再開の形(`supervise.py queue --resume ... --from` など)は今の形のまま残し、別名にしない + +## 対象範囲 + +含む: +- 持ち主の部品(取る・返す・死んだ持ち主を片付ける・断る)と、実行の核の持ち主の段(E1〜E7) +- 前提 3 の入口を持ち主の部品へ移すこと(cross-refactoring と cross-review に残る自前の持ち主の扱い `run_owner.adopt_owner`・`mark_owner`・`run_args` を消す) +- 断るときの案内を、プランから実際に打てる手段だけにすること(#2321 の受け入れ条件 3) +- レビュー worktree の片付けが、新しい置き場の持ち主の記録で持ち主の生死を判定すること +- キューの再開で `--from` を優先すること(#2325) +- 子の課題 3 件(#2321・#2316・#2325)の再現の手順を通し、閉じるか残すかを本文に記録すること + +含まない: +- 子の課題を 1 件ずつ直す Pull Request(即時修正の 4 条件を満たすものと本番を止めているものは除く) +- 3 件以外の子の課題の解消(「子の課題」の表に残す理由を書く) +- 旧い置き場の記録(`*.owner.json`・`.ndf/backlog/owner.json`)を読む互換 +- 再開の入口(`supervise.py resume`)の新設と、旧い再開の形(`supervise.py run --from`・`supervise.py queue --resume`・cross-review の `drive.py --reopen`)をその別名にすること +- 生きた持ち主を待つ形(`wait`) +- 配布の承認資料の後片付け(#2009)と、一時の置き場の種類を片付けの状態の一覧へ載せること(#2243) +- 打ち切りで子のプロセスをまとめて止めること(#2285 の 2 つ目) +- backlog-refinement の見直しの持ち主を持ち主の部品へ移すこと +- hook(`plugins/ndf/hooks/` と hook から呼ぶ `*-guard.sh`)に持ち主の記録を持たせること(#2335 の「含まない」) +- キューの重なりの判定(触るファイルの単位・宣言の有無)を変えること +- 上限と再試行の値を 1 つの宣言へ集めること(柱 P4 #2343)と、プランの参照を実行の直前に解決すること(柱 P5 #2344) +- `factory/` の並行の扱い(Factory のオーガナイザー)と、マシンをまたいで共有するロック(#2316 の後半) + +## ドメインイベント + +1 回の入口の実行のうち、持ち主の記録と再開に関わる部分を時間の順に並べる。右端の列は #2335 のドメインイベントの番号である。 + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | #2335 | +| --- | --- | --- | --- | --- | --- | +| E1 | 入力を解決し、持ち主の鍵を作った | 入口の起動 | 鍵を作る値(PR・done・ループの名前)が無い → `stopped`(入力違い、終了コード 2) | — | E2 | +| E2 | 持ち主の記録を読んだ | E1 | 記録が壊れている → `stopped`(理由に記録のパス) | E1 | E4 | +| E3 | 死んだ持ち主の記録を引き取った | E2 で、記録に終わった時刻が無く、pid と開始時刻が一致しない | 書けない → `stopped` | E2 | E4 | +| E4 | 生きた持ち主がいるため断った | E2 で生きた持ち主がいる | — | E2 | E4 | +| E5 | 持ち主の記録を取った | E2・E3 で持ち主がいない・終わっている・引き取った | 書けない → `stopped` | 排他の中で E2 から続けて行う | E4 | +| E6 | ステップの中身を実行した | E5 | — | E5 | E7 | +| E7 | 持ち主の記録に終わった時刻を書いた | 正常・例外・SIGTERM・時間切れの終わり | SIGKILL・電源断で書けない → 次の実行の E3 が引き取る | 結果を返す前 | E11 | +| E8 | キューを `--from` つきで再開した | `supervise.py queue --resume --from <プラン>=<ステップ>` | 途中の耐久ワークフローを止められない → `stopped` | 前の起動の耐久の記録がある | E13 | + +洗い出しで移したもの: E3 の判定の条件(pid の再利用・別のマシン)は前提 5 に置いた。E8 で `--from` が無いときの振る舞いは受け入れ条件 7 に置いた。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 持ち主の記録 | 持ち主の鍵ごとに 1 つ、状態の置き場の `leases/<リポジトリ>/` に置く記録。実行の識別子・入口・開始・最後に動いた時刻・終わった時刻・持ち主のプロセス(pid と開始時刻・ホスト名)を持つ。起票時の本文の「lease」はこの記録と、それを取り合う排他を指す | +| 持ち主の鍵 | 持ち主の記録を取り合う単位。入口の契約が、解決した入力から作る形を宣言する(例 `cross-refactoring:pr=2301`) | +| 寿命の窓 | 別のホストの持ち主の記録について、最後に動いた時刻からこの時間を過ぎた持ち主を死んでいるとみなす長さ | +| 耐久の記録 | 1 回の起動が持つ DBOS の SQLite のファイル(`~/.local/state/ndf/dbos/<種類>-<鍵>.sqlite`) | +| 実行の識別子 | 1 回の入口の実行に実行の核が振る識別子 | +| 実行の核 | 入口・ステップが必ず通る共通の実行の仕組み(#2337) | + +## 受け入れ条件 + +- [ ] 1. 持ち主の鍵を宣言した入口が、正常・例外・SIGTERM・時間切れのどれで終わっても、持ち主の記録に終わった時刻が書かれる。同じ鍵で続けて起動した実行は「使用中」で止まらずに始まる。終わり方ごとのテストと、#2321 の再現の手順(cross-refactoring の着手前のテストを上限で打ち切った後に、同じプランのステップを `--from` で流し直す)で確かめる +- [ ] 2. SIGKILL で終わった実行の持ち主の記録を、同じ鍵の次の実行が死んだ持ち主として引き取り、止まらない。pid が別のプロセスに再利用されていても(pid が一致し開始時刻が違う)死んだ持ち主として扱う +- [ ] 3. 生きた持ち主がいるときの断りは `stopped`(終了コード 3)で、結果の JSON に持ち主の実行の識別子・pid・最後に動いた時刻が出る。案内は、プランから実際に打てる手段(持ち主が終わってから同じコマンドを打ち直す)だけを示し、打てない引数(`--run`)や無い副命令(`finish`)を示さない +- [ ] 4. 同じ持ち主の鍵で 2 つの実行を同時に起動すると、後から来た実行は受け入れ条件 3 の形で止まる。2 つのプロセスを並べて起動するテストで確かめる +- [ ] 5. 生きた持ち主がいる鍵を、2 つの実行が同時に取れない(持ち主の記録を読んでから書くまでが排他の中にある) +- [ ] 6. 2 つの conductor(別々のランタイムのセッション)が同じ開発ループ、または同じキューの記録(done のファイル)を流そうとすると、後から来た側の `supervise.py loop start`・`supervise.py queue` は、前の側が生きている間、続きとして流さず(`resumed` を返さず)、受け入れ条件 3 の形で止まる。#2316 の再現の手順で確かめる +- [ ] 7. `supervise.py queue --resume --from <プラン>=<ステップ>` は、前の起動が強制終了で途中のまま残っていても、指定のステップから新しい実行の回で流し、前の途中の耐久ワークフローを子ごと止める。`--from` も `--only` も無い打ち直しは、今までどおり前の起動を続ける。#2325 の再現の手順で確かめる +- [ ] 8. レビュー worktree の片付けは、新しい置き場の持ち主の記録で持ち主の生死を判定する。終わった時刻から 30 日を過ぎた持ち主の記録は片付けで消える +- [ ] 9. 子の課題 3 件(#2321・#2316・#2325)それぞれについて、再現の手順を 1 回通した結果(コマンドと出力の要点)と、閉じた(解消を確かめた)か残した(理由つき)かを、この課題の本文の表に記録する。閉じた課題には、確かめた結果へのリンクを添える +- [ ] 10. 持ち主の鍵を宣言していない入口の振る舞いが変わらない。全体テストが柱のマージの前後で通る +- [ ] 11. 持ち主の記録に、秘密(トークン・鍵・認証情報)の値が書かれない。持ち主の記録が持つのは実行の識別子・入口・鍵・時刻・pid と開始時刻・ホスト名だけである + +## 子の課題 + +受け入れ条件 9 で、柱の仕組みを入れた後に 3 件の再現の手順を通す。ほかの課題は、この柱では解消を確かめずに残す。「見込み」は 2026-11-04 の見立てで、結論ではない。#2229 は 2026-11-04 の時点で閉じている。 + +| 課題 | 何が起きるか(要約) | この柱での扱い | +| --- | --- | --- | +| #2321 | 着手前テストの打ち切りで持ち主の記録が終わらず、24 時間断る | 対象(受け入れ条件 1〜3)。解消の見込み | +| #2316 | conductor どうしの排他が無い | 対象(受け入れ条件 4〜6)。同じ開発ループ・同じ done の二重の流しは解消の見込み。本文の残りの対象(Issue・触るファイル・版数・マシンをまたぐロック)は範囲外のため、課題は残して解消した範囲を本文に書く | +| #2325 | 強制終了の後の `queue --resume --from` が無視される | 対象(受け入れ条件 7)。解消の見込み | +| #2285 | #2321 と同じ型の記録の残りと、打ち切ったテストの子のプロセスが残る | 残す。1 つ目は #2321 と同じ仕組みで変わる見込みだが、2 つ目(子のプロセスの停止)を範囲から外したため | +| #2313 | #2285 のためリファクタリングを PR 2312 に通せていない | 残す。3 件の外。#2321 の解消で流せる見込みだが、確かめる作業を範囲に入れない | +| #1592 | 同じ並びのまま頭から流し直す手段が無い | 残す。再開の入口の新設を外したため | +| #1635 | `drive.py --reopen` が止まった耐久ワークフローを新しい回へ移さない | 残す。cross-review の再開の形を変えないため | +| #2198 | タグの push の後に Release の作成だけが失敗すると打ち直せない | 残す。ステップの冪等性の問題で、再開の手段では変わらない | +| #1886 | 後片付けで消えた cwd により `--then` の計画で落ちる | 残す。リポジトリの根の解決(柱 P8 #2341)の問題で、持ち主の記録では変わらない | +| #2243 | #2229 の対象外の一時の置き場に持ち主と寿命が無い | 残す。一時の置き場の一覧の整理を範囲から外したため | +| #2009 | 配布の後片付けが承認資料を消さない | 残す。承認資料の後片付けを範囲から外したため | +| #1967 | 並列の全体テストでだけ中継のテストが落ちる | 残す。テストの間の状態の共有で、持ち主の記録の外 | +| #1640 | 持ち越した候補が次の棚卸しへ渡らない | 残す。受け渡しの契約の問題 | +| #2273 | 設計 PR のマージが衝突で取り込みへ戻らない | 残す。プランの分岐の問題 | +| #1811 | テストのディレクトリが重なりの判定に当たり直列になる | 残す。重なりの判定の粒度(対象範囲の「含まない」) | +| #2037 | 重なりの判定がファイル単位で直列になる | 残す(同上) | +| #2165 | 触るファイルを宣言しないプランが重なると止まる | 残す(同上) | +| #2229 | 実行をまたいで残る状態に寿命と持ち主を持たせる | 閉じている。持ち主の記録の置き場を移しても、#2229 の受け入れ条件が保たれることを全体テストで確かめる | + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 持ち主の記録を取って返す段が、1 入口の起動に足す時間は、#2335 の上限 150 ms(核全体)の内側に収まる。柱の Pull Request で `--help` ではなく持ち主の鍵を宣言した入口の起動を前後で 10 回ずつ測り、差の中央値を本文に書く | +| 可用性 | 生きた持ち主がいる実行は待たずに `stopped` で返る(受け入れ条件 3) | +| 運用・保守性 | 取った・引き取った・断った・返したを、結果の JSON の `lease` に出す | +| 移行性 | 移す対象: 持ち主の記録の置き場(状態ディレクトリの横 → `leases/`)。旧い置き場の記録は読まない(前提 6)。切り戻し: 持ち主の段の Pull Request と入口を移す Pull Request を、それぞれ revert すれば前の形へ戻る | +| セキュリティ | 受け入れ条件 11。認証・認可(C2)と秘密の扱い(C1)は変えない | +| システム環境 | 排他は既存の optional extras の `filelock`(`lib/locks.py`)を使う。extras が無い環境では、核が `stopped`(終了コード 69)と足りない extras の名前を返す(#2335 の受け入れ条件 15) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。持ち主がいるときの断りは `stopped`・終了コード 3 になる。`queue --resume --from` は前の途中を止めて指定のステップから流す。新しい入口は増えない | +| データ | 変わる。持ち主の記録の置き場が `${NDF_STATE_DIR:-~/.local/state/ndf}/leases/` へ移る。旧い置き場の記録は読まない。利用者の `.ndf/` は変えない | +| 既存の振る舞い | 変わる。打ち切りの後の流し直しが止まらなくなる。並行する conductor の後から来た側が、続きとして流さずに止まる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`(全体テスト)。終わり方(正常・例外・SIGTERM・SIGKILL・時間切れ)ごとの持ち主の記録のテスト、2 つのプロセスで同じ鍵を取り合うテスト、pid の再利用のテスト、キューの `--from` つきの再開のテスト | +| 静的解析・構造の検査 | `python3 scripts/check-script-structure.py` | +| 配布物の検証 | `claude plugin validate .` | +| 手動確認(マージ前) | #2321・#2325・#2316 の再現の手順を、直した版のスクリプトで 1 回ずつ通し、結果を Pull Request に書く(#2335 の検証手段「該当する柱のマージの前に通す」) | +| 手動確認 | 子の課題 3 件の再現の手順を、本番へ出した版で通し、受け入れ条件 9 の表を埋める(リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 持ち主の部品は `plugins/ndf/scripts/lib/` に置く。既定で働くため `experimental/` に置かない(`AGENTS.md` の「安定版と実験版」)。`factory/` を参照しない | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」と、外部コマンド(`ps`・シグナル)の挙動を書く前に実行して確かめる決まり | +| テスト戦略 | 持ち主の部品は単体テストで終わり方と取り合いを担保し、入口は契約のテストで担保する。子の課題は再現の手順で確かめる。`.md` の文言を照合するテストは書かない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | #2335 の設計を正として変更する(前提 1 の再開の入口を除く)。全体テストと構造の検査を通す。持ち主の部品へ移した入口から自前の持ち主の扱いを消す | +| 確認してから行う(承認ゲート 1・2 で人が見る) | 持ち主の記録の置き場の移動。`queue --resume --from` の振る舞いの変更(公開インタフェース) | +| 行わない | 子の課題を 1 件ずつ直す Pull Request(「含まない」の例外を除く)。対象範囲の「含まない」に挙げたもの。利用者の `.ndf/` を書き換える。`-D` / `--force` が要る片付け(止めて人へ戻す) | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 開発ループ(`loop`)とキューの持ち主の鍵の形と持ち主の単位 | この柱の設計 | 設計 Pull Request | +| 柱 P1(#2337)の実行の核の形が、この柱の設計の前提(解決済みの入力・SIGTERM の受け手・結果の JSON)と違って決まった場合の読み直し | この柱の実装の担当(前提 2) | 実装の開始時(#2337 のマージ後) | diff --git a/issues/issue-2343-design-decisions.md b/issues/issue-2343-design-decisions.md new file mode 100644 index 000000000..90d1ff228 --- /dev/null +++ b/issues/issue-2343-design-decisions.md @@ -0,0 +1,153 @@ +# #2343 の設計: 決定の記録 + +設計文書の本体は [issue-2343-design.md](issue-2343-design.md) にある。 + +## 要求の未決への答え + +| 要求の未決 | 答え | +| --- | --- | +| 1. 同じ原因の上限の既定の回数と、伏せる部分の規則 | ステップの打ち直しは 2 回、呼び出しの再試行は方針ごとに今届く回数(決定 7)。伏せる規則は日時・URL・パス・16 進の語・数字の 5 つ(決定 6) | +| 2. シェルのスクリプトの数字を検査に入れるか | 入れない(決定 10) | +| 3. 構造チェックが再試行のループと待ちを見分ける規則 | 見分けない。眠りのループを `lib/waits.py` の外ですべて落とし、待ちのループは理由つきの例外リストの行で残す(決定 5) | +| 4. ステップの打ち直しの原因の鍵をどこで渡すか | 判定役へは渡さない。判定の後にプランの実行が数えて止める(決定 8) | +| 5. #2335 の本文の `timeout=<数字>` 195 か所と打ち直した値の食い違い | 2026-11-04 に `grep -rnE "timeout=[0-9]" plugins/ndf --include=*.py` をテストと実験版を含めて打つと 199 か所、除くと 20 か所だった。本文の 195 はテストを含めた数に近い。2026-11-01 の数え方の記録は無いため、出所は確定できない(未確認)。受け入れ条件は数でなく検査で判定するため、この食い違いは判定に影響しない | + +## 決定の記録 + +### 決定 1: 部品が値でなく名前で上限を引けるよう、上限の宣言を方針の名前 → 値の平らな表にする + +方針の 1 行は `summary` と、上限の秒・間隔・倍率・間隔の上限・1 回の呼び出しの待ち・打つ回数・同じ原因の上限のうち使うものだけを持つ。 +監視の工程 × 担当の表は、`monitor.phase.<工程>` と `monitor.stall.<担当>` の名前で同じ表に置く。`limits.py` が接頭辞で数え上げる。 +1 つの部品が組で使う値(設計の結果の読み取りの間隔・1 回の待ち・合計)は 1 つの方針にまとめ、組の値を別々の名前に分けない。 + +工程と担当の 2 段の入れ子を宣言の形に持つ案は採らない。入れ子を持つのは監視の表だけで、ほかの 100 か所の上限に使われない形を読み手が受け持つことになる。 + +根拠: Value 6「どのプロジェクトでも使える形を保つ」 / Value 8「判断の理由を残し、語をそろえる」(MVV 版 5) + +### 決定 2: hook の経路でも起動を遅くしないよう、読み手は標準ライブラリの `json` だけで 1 プロセスに 1 回読み、置き場をプラグインからの相対に固定する + +hook は SessionStart が用意した仮想環境の python を直に起動する。hook からは `deps.require()` を呼べない(構造チェックの `hook-deps`)。 +読み手が pydantic を要すると、hook が宣言を読めない。形の確かめ(I1)は読み手の数十行の判定と、CI の契約の検査の 2 か所で行う。置き場の上書きの環境変数は作らない。 +テストは読み手の読み込み先を関数で差し替える。 + +宣言の置き場を環境変数で差し替えられるようにする案は採らない。上書きの経路を新しく作ることになり、要求の対象範囲(含まない)に当たる。 + +根拠: Value 6「どのプロジェクトでも使える形を保つ」 / Value 5「決まった手順はスクリプトに、判断と中身は AI に」(MVV 版 5) + +### 決定 3: 宣言の誤りで実際の上限が宣言と食い違わないよう、読めないときは組み込みの値へ戻らず `LimitsError` で止める + +組み込みの値へ戻ると、宣言を直したつもりの人の値が使われず、それが結果に出ない。`LimitsError` は理由に宣言のパスと方針の名前を持ち、 +エントリポイントは終了コード 1 で止まる。宣言は配布物の一部で、利用者が書く設定ではないため、壊れるのは配布物の不具合である。CI の契約の検査が先に落とす。 + +今の `safe_int_env`(環境変数が数字でなければ警告して既定値を使う)は利用者の入力への扱いで、宣言の誤りとは別のため変えない。 + +根拠: Value 1「小さく出して使って確かめる」 / Value 7「根本原因の場所で直す」(MVV 版 5) + +### 決定 4: 正規表現で見落とす上限の数字を落とせるよう、`limit-literal` を構文木で判定し、名前の規則とキーワードの表の 2 つで当てる + +`timeout=[0-9]` の正規表現が当たるのは 20 か所である(0 と docstring の中を含む)。構文木で数えた 110 か所のうち、定数・関数の既定値・`.get` の既定値の 91 か所を +この正規表現では落とせない。構文木で、名前の規則に当たる定数・12 個のキーワード引数・同じ名前の引数の既定値・`.get("<キー>", <数字>)` の 4 つを見る。0 は「待たない・打ち直さない」の意味で上限の値でないため外す。 +終了コードの定数(`EXIT_` で始まる・`_CODE` で終わる名前)は名前の規則で外す。割合と見直しの周期の 3 か所は例外リストに理由つきで載せる。 + +名前の規則を持たずに、数字のリテラルをすべて落とす案は採らない。行数・文字数・終了コードなど上限でない数字が数百か所当たり、例外リストが検査の意味を失う。 + +根拠: Value 6「どのプロジェクトでも使える形を保つ」 / Value 7「根本原因の場所で直す」(MVV 版 5) + +### 決定 5: 再試行のループを書き戻せないよう、再試行と待ちを見分けずに、`lib/waits.py` の外の眠りのループをすべて落とす + +構文木から「失敗した同じ呼び出しの打ち直し」と「条件が揃うまでの問い合わせ」を見分ける規則は、呼ぶ関数の意味に頼るため外れる。 +眠りのループは両方を含み、構文で決まる。今ある 25 か所のうち、再試行の 1 か所(`supervise_lib/paths.py`)は `waits.retry_call` へ移す。 +待ちの 24 か所は、例外リストに理由(何を待つか)つきで載せる。新しい眠りのループは、`lib/waits.py` を通すか、例外リストの行を差分に載せて +レビューを通すかのどちらかになる。行の増加を機械で落とす検査(`allow-grow`)は #2335 の P0 の受け持ちで、入れば同じ行に効く。 + +名前(`retry` を含む関数)で再試行を見分ける案は採らない。要求の前提 6 のとおり、名前に `retry` を含む関数 2 つは打ち直しのループを持たない。 +名前で見分けると、`_add_worktree_with_retry` のほかに名前で当たらない打ち直しが残る。 + +根拠: Value 5「決まった手順はスクリプトに、判断と中身は AI に」 / Value 7「根本原因の場所で直す」(MVV 版 5) + +### 決定 6: 同じ失敗を番号や時刻の違いで別の原因と数えないよう、原因の鍵は種類と伏せた理由の文から作り、鍵ごとに数える + +#1983 の失敗の理由は「PR #1976 の CI が失敗: runtime-smoke (agy)(run 38353378585)」である。run の番号が変わっても同じ原因である。 +日時・URL・パス・16 進の語・数字をこの順で伏せ、空白をまとめて先頭 200 字を残す。種類(例外の型か `exit=<終了コード>`)を前に付け、 +同じ文でも終了コードが違えば別の原因にする。回数は鍵ごとに数え、続いたかどうかは見ない。A と B の原因が交互に起きても、 +どちらかが上限に届けば止まり、合計は打つ回数と待ちの合計の上限が抑える。 + +続けて起きた回数だけを数える案は採らない。交互に起きる 2 つの原因で、上限に届かないまま待ちの合計まで回る。理由の文の数字を伏せない案は採らない。 +番号つきの失敗が毎回別の原因になり、#1983 が再現する。 + +根拠: Value 7「根本原因の場所で直す」 / Value 3「やめる・縮めるを既定にする」(MVV 版 5) + +### 決定 7: 新しく止める所だけ振る舞いを変えるため、ステップの打ち直しの同じ原因の上限は 2、呼び出しの再試行は方針ごとに今届く回数にする + +ステップの打ち直しは、今は同じ原因で止まらない。#1983 の受け入れ条件 1 は「同じ失敗の要約で 2 回落ちたステップは 3 回目を流さない」を求める。 +呼び出しの再試行は一時的な失敗(上限・lock・502)を待つためのもので、同じ原因が続くのが通常である。そこへ小さい上限を当てると、 +今は回復している失敗で止まる。再試行の方針の `same_cause` は、今の間隔と合計から届く打つ回数と同じ値にし、移す前後で振る舞いを変えない(要求の「移行性」)。 + +全方針に共通の既定(例: 3)を読み手に持たせる案は採らない。読み手の中に上限の数字が残り、I3 と食い違う。 + +根拠: Value 3「やめる・縮めるを既定にする」 / Value 1「小さく出して使って確かめる」(MVV 版 5) + +### 決定 8: 判定役の答えに頼らず決まった回数で止まるよう、ステップの打ち直しはプランの実行が判定の後に数えて止める + +#1983 では、判定役は自分で「再び同じ所で落ちたら止める」と書いた。しかし前の打ち直しの結果を読まずに、7 回とも同じ答えを返した。 +判定は言語モデルの答えで、同じ入力でも揃わない。プランの記録(`RunState.record`)が、ステップの失敗ごとに原因の鍵を数える。 +判定役が失敗したステップを返したときは、プランの実行(`Engine._next_after_judge`)が回数を見て止める。判定役の入力は変えない。 + +判定役の入力に前の失敗と回数を足して判断させる案は採らない。止まるかが答えに左右され、受け入れ条件 5 の「宣言の回数で止まる」をテストで縛れない。 + +根拠: Value 5「決まった手順はスクリプトに、判断と中身は AI に」(MVV 版 5) + +### 決定 9: 宣言した上限を使い切ってから止まるよう、待ちと再試行は次の眠りを残りの秒に縮め、上限の時刻に最後の 1 回を打つ + +今の `_stop_by_idle` は「眠りの合計 + 次の眠り > 上限」で止まる。間隔が 120 秒まで伸びた待ちでは、最大 119 秒を残す(#2224)。 +次の眠りを `min(間隔, 残りの秒)` にし、残りが 0 になった後の 1 回で止まる。同じ規則を `retry_call` にも当て、止まる規則を 1 つにする。 +上限がちょうど間隔の倍数の方針(設計の結果の読み取りの 15 秒 × 2 = 30 秒など)は、今と同じ回数で止まる。 + +`wait_until` だけを直す案は採らない。2 つの関数で止まる規則が分かれ、同じ方針の値を渡しても打つ回数が関数で変わる。 + +根拠: Value 7「根本原因の場所で直す」(MVV 版 5) + +### 決定 10: 表示のたびに動くシェルを遅くしないよう、シェルのスクリプトの数字は検査に入れない + +`statusline.sh` は表示のたびに動き、宣言を読むには Python を起動することになる。ほかの 5 か所は、`${<環境変数>:-<数字>}` の既定値と `timeout 1` である。 +これらも、シェルのエントリポイントが実行の核の上で動く(#2337 の `run-core.sh`)までは、Python の宣言を読む経路が無い。核の上で動いた後は、 +核が宣言から引いた値を環境変数で渡せるため、そのときにシェルの数字を移す。 + +シェルから `limits.py` を毎回起動して読む案は採らない。表示と錠の待ちのたびに Python の起動(21 ms)が足される。 + +根拠: Value 6「どのプロジェクトでも使える形を保つ」 / Value 1「小さく出して使って確かめる」(MVV 版 5) + +### 決定 11: 前提の柱を待たずに値の置き場と止め方をそろえるため、この柱は実行の核に依存しない形で作る + +要求の前提 1 は、核のスケルトン(#2337)と持ち主・後片付け(#2342)の後にこの柱を作るとする。この設計の部品(宣言・読み手・原因の鍵・待ちの部品・ +プランの実行)は、核の段を通らずに今のエントリポイントから呼ばれる。核が入った後に核がするのは、`LimitsError` を `stopped` に変えることと、実行の記録へ理由を書くことである。 +どちらもこの設計の形を変えない。期限で打ち切った実行の持ち主の記録(#2335 の柱の順序で P4 が P3 を前提にした理由)は、要求の対象範囲(含まない)のとおり #2342 が受け持つ。 + +核が入るまで設計を待つ案は採らない。#1983 と #2224 の不具合は核と無関係に起きており、待つ間も同じ止まり方が続く。 + +根拠: Value 1「小さく出して使って確かめる」 / Value 4「外の利用で測って決める」(MVV 版 5) + +### 決定 12: 1 回の承認で読み切れる差分に収めるため、実装を 3 本の Pull Request に分け、例外リストで 1 本目を通す + +| Pull Request | 入れるもの | +| --- | --- | +| 1 本目 | 宣言・読み手・原因の鍵・待ちの部品・プランの実行・worktree の作成・構造チェックと契約の検査。まだ移していない上限の数字は、`limit-literal` の例外リストの行として載せる | +| 2 本目 | `scripts/lib/`・`scripts/supervise_lib/`・エントリポイントのスクリプトの数字を移す | +| 3 本目 | 残り(`relay_lib`・`hook_lib`・Skill の下・ほかの `*_lib`)を移す | + +移した Pull Request が、同じ差分でその行を消す。3 本目の後に残る `limit-literal` の行は、上限でない 3 か所だけになる。子の課題の再現の手順は 3 本目の後に通す。 + +1 本の Pull Request で 104 か所を移す案は採らない。69 ファイルの差分になり、#2335 の決定 11 の「部品の Pull Request は 3 本まで」の単位を超える。 + +根拠: Value 2「人が判断できる量に収める」 / Value 1「小さく出して使って確かめる」(MVV 版 5) + +### 決定 13: エントリポイントから上限をたどれるよう、方針の名前と宣言の対応の検査と、方針ごとに引く関数の表を契約の検査に置く + +契約の検査(`check-ndf-contracts.py`)は、契約の一覧の形と逆引きの表(`plugins/ndf/contracts/index.md`)を持つ。方針の名前を引く呼び出し +(`limits.policy("<名前>")`・`limits.seconds("<名前>")`・`limits.value("<名前>", …)`)を、構文木で集める。 +宣言に無い名前と、どこからも引かれない方針を落とす。方針ごとの値と引く関数は、「上限の宣言」の表として描く。名前の照合ができるよう、`lib/limits.py` の外では方針の名前を文字列のリテラルで書く。 +`lib/limits.py` が接頭辞で数え上げる方針(`monitor.` で始まる名前)は、引かれたものとして数える。 + +構造チェックに名前の対応を足す案は採らない。構造チェックはコードの形を見る検査で、契約の一覧の逆引きの表を描く役割を持たない。 + +根拠: Value 8「判断の理由を残し、語をそろえる」 / Value 6「どのプロジェクトでも使える形を保つ」(MVV 版 5) diff --git a/issues/issue-2343-design.md b/issues/issue-2343-design.md new file mode 100644 index 000000000..0a7b7f6c2 --- /dev/null +++ b/issues/issue-2343-design.md @@ -0,0 +1,452 @@ +# NDF のスクリプト: 上限と再試行を 1 つの宣言から引き、同じ原因の打ち直しを決まった回数で止める(#2343) + +## 目的 + +- **今どうなっているか**: 上限の秒・再試行の回数・待ちの間隔が、テストと実験版を除く 69 ファイルの 110 か所に数字で書かれている。待ちの部品は上限の最大 2 分前に止まり(#2224)、プランの判定役(judge)は同じ CI の失敗に 7 回打ち直しを返した(#1983) +- **誰に効くか**: NDF でスプリントを回す利用者と conductor。上限で止まった実行を追い直す手間と、通るはずの検査が「判断が要る」で止まる手当てが減る +- **変えると何が成り立つか**: 上限の値は上限の宣言 1 か所にあり、数字を書き戻すと構造チェックが落とす。同じ原因の失敗は宣言の回数で止まり、待ちは上限の時刻に最後の照会を打ってから止まる。止まった理由(方針の名前・原因・回数・待った秒)が結果に出る + +## 適用範囲 + +- **働く範囲**: NDF の配布物(`plugins/ndf/` の Python のコード。テストと `plugins/ndf/scripts/experimental/` を除く)。hook も含む。配布先のリポジトリでも同じ宣言が使われる +- **プロジェクトごとに違うもの**: 上限の値の上書きは、今ある環境変数と引数(`MONITOR_TIMEOUT` など)のまま受ける。新しい上書きの経路は #2341(入力の契約)が作る。宣言が持つのは既定値だけである +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 利用者の指摘(2026-11-01)「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」 | 利用者の指示の原文 | 回復の手段(再試行と待ち)を 1 つの部品と 1 つの宣言にそろえること | +| 構文木で数えた上限の数字のリテラル 110 か所(定数 68・呼び出しのキーワード引数 19・関数の既定値 18・`.get("timeout", <数字>)` 5)。うち上限でないもの 6 か所(終了コード 3・割合 2・見直しの周期 1) | 実測(2026-11-04、下の「数え方」) | 移す対象が 104 か所で、正規表現の `timeout=[0-9]`(20 か所。0 と docstring の中を含む)は呼び出しのキーワード引数しか見ず、定数・既定値・`.get` の 91 か所を見落とすこと | +| 眠り(`sleep`)を含むループが `lib/waits.py` の外に 25 か所ある。うち再試行(失敗した同じ呼び出しの打ち直し)は `supervise_lib/paths.py` の 1 か所で、残り 24 か所は条件が揃うまでの待ち | 実測(2026-11-04、構文木) | 再試行のループを `lib/waits.py` へ寄せる変更が 1 か所で済み、待ちのループは例外リストの行で数を固定できること | +| #2224 の記録: 上限 1154 秒のうち 112 秒を残して「結論を得られない」で止まり、同じコミットのチェックはすべて成功していた | 実測(PR #2209 の検査のフェーズの記録) | 待ちは上限の時刻に照会を打つ必要があること | +| #1983 の記録: `release` のステップが同じ run の同じ失敗で 7 回流され、ステップの上限(366 秒)で止まった | 実測(スプリント m1953 の本番の配布の記録) | 同じ原因の打ち直しを回数で止める仕組みが、呼び出しの再試行だけでなくステップの打ち直しにも要ること | +| 上限の宣言の読み込みの費用: 120 の方針を持つ 18.9 KB の JSON を `json.load` で読む起動と読まない起動の中央値(10 回)は、どちらも 21.5 ms で、差は 1 ms 未満。`supervise.py --help` の中央値は 262 ms | 実測(2026-11-04、`/work/ai-plugins/.venv/bin/python -I`) | 宣言の読み込みが #2335 の起動の増分の上限 150 ms の内訳で 1 ms 未満に収まること | + +数え方(構文木で数えた): + +| 項目 | 中身 | +| --- | --- | +| 対象のファイル | `git ls-files 'plugins/ndf/*.py'` から、テストと実験版を除いたもの | +| (1) 定数 | 名前が大文字で `TIMEOUT`・`RETRY`・`RETRIES`・`WAIT`・`INTERVAL`・`BUDGET`・`DEADLINE`・`POLL`・`STALL`・`MARGIN`・`GRACE`・`ATTEMPTS` のどれかを含む定数への、0 でない数字の代入 | +| (2) キーワード引数 | 呼び出しのキーワード引数 | +| (3) 既定値 | 関数の引数の既定値 | +| (4) `.get` | `.get("<キー>", <数字>)` の 2 つ目の引数 | +| (2)〜(4) のキー | 「構造チェックの新しい種類」の表にある 12 個 | + +要求と受け入れ条件は #2343 の本文にある(コピーは [issue-2343-requirements.md](issue-2343-requirements.md) )。 +この文書は「どう作るか」だけを扱う。決定の記録は [issue-2343-design-decisions.md](issue-2343-design-decisions.md) にある。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | 上限の宣言・方針の名前・原因の鍵・再試行・ステップの打ち直し・眠りのループ | + +1 つのコンテキストに収まる。上限の宣言は #2335 の設計が決めた共有カーネル(契約の一覧)の一部で、プランの実行・収束ループ・リリースなどの各部品は方針の名前で値を引く。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 上限の宣言 | 宣言を変える Pull Request(実行時には誰も書かない。上限の部品 `lib/limits.py` は読むだけ) | `plugins/ndf/contracts/limits.json` | 方針(方針の名前で引く行) | 上限の秒・間隔・倍率・間隔の上限・1 回の呼び出しの待ち・打つ回数・同じ原因の上限 | +| 1 回の再試行 | 待ちの部品(`lib/waits.py`)。1 回の `retry_call` / `wait_until` の呼び出しの間だけ生きる | 呼び出し | — | 原因の鍵・原因ごとの回数・待った秒・止まった理由(`Waited`) | +| プランの実行 | プランの実行の部品(`supervise_lib/engine.py` と記録の `supervise_lib/state.py`) | 状態の置き場 | ステップの結果 | 原因の鍵・ステップと原因ごとの回数 | + +**集約の間は名前で参照する。** 部品は方針を方針の名前で引き、値をコピーして持たない。1 回の再試行とプランの実行は、 +上限の宣言を読むだけで書き換えない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 上限の宣言 | 方針の名前は一意で、各方針は `summary` と値を 1 つ以上持つ。秒・間隔は 0 以上の数、倍率は 1 以上の数、打つ回数と同じ原因の上限は 1 以上の整数で、知らないキーを持たない | 読み手が `LimitsError`(理由に宣言のパスと方針の名前)。CI では契約の検査(`check-ndf-contracts.py`)が落とす | +| I2 | 上限の宣言 | 宣言が無い・壊れている・引いた方針の名前が無い・引いた値が方針に無いとき、部品は組み込みの値へ戻らない | `LimitsError`。エントリポイントは終了コード 1 と理由で止まる | +| I3 | 上限の宣言 | テストと実験版を除く `plugins/ndf/` の Python のコードに、上限の数字のリテラルが無い(#2335 の I12) | 構造チェックの `limit-literal` が落とす | +| I4 | 上限の宣言 | コードが引く方針の名前はすべて宣言にあり、宣言の方針はすべてどこかのコードが引く(`lib/limits.py` が接頭辞で数え上げる `monitor.` の方針は引かれたものとする) | 契約の検査が落とす | +| I5 | 上限の宣言 | 監視の工程と担当のすべての組で、無進捗の許容 < 監視の上限 < CLI の上限 | `limits.py check` が組を出して終了コード 1 | +| I6 | 1 回の再試行 | 眠りを含むループは `lib/waits.py` と、例外リストに理由つきで載った待ちのループだけにある | 構造チェックの `sleep-loop` が落とす。例外リストの行を足すには、その行を差分に載せてレビューを通す | +| I7 | 1 回の再試行 | `tenacity` を import するのは `lib/waits.py` だけ | 構造チェックの `wrapped` が落とす | +| I8 | 1 回の再試行 | 同じ原因の鍵の失敗の回数が方針の `same_cause` に達したら、待ちの合計の上限が残っていても打ち直さない。原因の鍵が違う失敗は別に数える(#2335 の I13) | 打ち直さずに `Waited`(`stop` が `same-cause`)を返す | +| I9 | 1 回の再試行 | 期限は呼び出しの開始の単調時計(`time.monotonic`)の値に方針の `seconds` を足した時刻で、残りの秒は期限から今の単調時計の値を引いたもの(眠りと、照会・呼び出しにかかった時間の両方を含む)。どの照会の開始も期限を超えない。次の眠りが残りの秒を超えるときは残りの秒だけ眠り、期限の時刻に最後の 1 回を打ってから止まる。最後の 1 回の後は眠らない | 打ち切った `Waited`(`stop` が `deadline`)を返す | +| I10 | 1 回の再試行 | 止まった `Waited` は理由の文を持ち、そこに方針の名前と、上限なら待った秒・照会の回数・上限の秒が、同じ原因なら原因の要旨と回数が入る | — (作る関数が 1 つで、空の理由を作る経路が無い) | +| I11 | プランの実行 | 判定役が失敗したステップの打ち直しを返したとき、そのステップの同じ原因の鍵の失敗が方針 `supervise.step-rerun` の `same_cause` に達していれば、ステップを流さずに止まる | 結果「止まった」、理由に原因の要旨と回数 | +| I12 | プランの実行 | ステップと原因ごとの回数は、落ちた後の続き(耐久の記録からの再開)でも数え直さずに引き継ぐ | 耐久ステップの出力のコピー(`snapshot`)が持ち、`replay` が戻す | + +### ドメインイベント + +番号は要求のドメインイベントを引き継ぐ。 + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| P4-E1 | 上限の宣言を読んだ | 上限の部品(`limits.policy`) | 待ちの部品・各部品(値を使う) | +| P4-E2 | 呼び出しが失敗した | 待ちの部品が呼んだ関数 | 待ちの部品 | +| P4-E3 | 失敗から原因の鍵を作った | 原因の鍵の部品(`lib/cause_key.py`) | 待ちの部品・プランの実行 | +| P4-E4 | 同じ原因の失敗を数えた | 待ちの部品 / プランの記録(`RunState.record`) | 待ちの部品 / プランの実行 | +| P4-E5 | 間隔を置いて打ち直した | 待ちの部品 | 呼び出し元(`on_wait` のアナウンス) | +| P4-E6 | 打ち直しを止めた | 待ちの部品 | 呼び出し元 | +| P4-E7 | 止めた理由を結果に書いた | 呼び出し元(`Waited.reason` を結果の `summary` へ) | 呼ぶ側・プランの進捗の記録 | +| P4-E8 | 判定役がステップの打ち直しを返した | 判定役(`JudgeStep`) | プランの実行(`Engine._next_after_judge`) | +| P4-E9 | ステップの打ち直しを同じ原因の上限で止めた | プランの実行 | プランの結果・進捗の記録(`kind: same-cause` の行) | +| P4-E10 | 待ちが上限の時刻に最後の照会を打った | 待ちの部品(`wait_until`) | 呼び出し元 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 眠りのループ | 本体(入れ子の関数を除く)で `sleep` を呼ぶ `for` / `while` のループ。再試行と待ちの両方が当たり、`lib/waits.py` の外では構造チェックが落とす | 追加(`ndf-workflow`) | +| 最後の照会 | 待ちの次の眠りが残りの秒(期限までの単調時計の秒)を超えるとき、残りの秒だけ眠った後に期限の時刻に打つ 1 回の照会 | 追加(`ndf-workflow`) | + +要求で足した語(上限の宣言・方針の名前・原因の鍵・再試行・ステップの打ち直し)は意味を変えずに使う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 上限の値を方針の名前で宣言から引く。宣言が読めなければ組み込みの値へ戻らずに止まる | NDF のすべての部品(hook を含む) | +| F2 | 上限の数字のリテラル・`lib/waits.py` の外の眠りのループ・`tenacity` の import を構造チェックで落とす | CI・Pull Request の作者 | +| F3 | 方針の名前と宣言の食い違いを契約の検査で落とし、方針ごとに引く関数を契約の一覧に描く | CI・上限を変えたい人 | +| F4 | 呼び出しの再試行を、同じ原因の回数・打つ回数・待ちの合計のどれかで止め、止めた理由を返す | 再試行を使う部品(worktree の作成・投稿キュー・設計の結果の読み取りなど) | +| F5 | 判定役が返したステップの打ち直しを、同じ原因の回数で止める | プランの実行(conductor・利用者) | +| F6 | 待ちが上限の時刻に最後の照会を打ってから止まり、待った秒・照会の回数・上限の秒を返す | CI の待ち・チェックの待ち(`wait_until` の呼び出し元) | +| F7 | 子の課題 9 件の再現の手順を通し、閉じるか理由つきで残すかを記録する | 利用者・backlog | + +## 構成要素 + +| 要素 | 責務 | 新設 / 変更 | +| --- | --- | --- | +| 上限の宣言(`plugins/ndf/contracts/limits.json`) | 方針の名前ごとに上限の値を持つ。今の `limits.py` の表と、各部品の上限の既定値を移す | 新設 | +| 上限の部品(`lib/limits.py`) | 宣言を標準ライブラリの `json` だけで 1 プロセスに 1 回読み、I1 を確かめ、`policy(名前)` と `seconds(名前)` を返す。監視の工程と担当の引き方(`monitor_timeout` など)と `check` は宣言を読む形へ変える。CLI の引数と出力は変えない | 変更 | +| 原因の鍵の部品(`lib/cause_key.py`) | 種類(例外の型か `exit=<終了コード>`)と理由の文から原因の鍵を作り、要旨(60 字まで)を返す。外の世界を触らない純粋な処理 | 新設 | +| 待ちの部品(`lib/waits.py`) | `retry_call` / `wait_until` が方針を引数に取り、同じ原因の上限・打つ回数・最後の照会・止まった理由を受け持つ。数字の既定値を持たない | 変更 | +| プランの実行(`supervise_lib/engine.py`・`supervise_lib/state.py`) | ステップの失敗ごとに原因の鍵を作って数え、判定役の打ち直しを `supervise.step-rerun` の回数で止める | 変更 | +| worktree の作成(`supervise_lib/paths.py`) | 自前の打ち直しのループを `waits.retry_call` の呼び出しへ置き換える | 変更 | +| 構造チェック(`scripts/check-script-structure.py` と、新設の `scripts/structure_checks/limits_rules.py`) | 検査の種類 `limit-literal` と `sleep-loop` を足し、`wrapped` の表に `tenacity` を足す。規則の本体は新設のモジュールに置き、今 895 行のチェックの本体を伸ばさない | 変更・新設 | +| 契約の検査(`scripts/check-ndf-contracts.py`・`scripts/contracts_lib/`) | 宣言の形(I1)と方針の名前の対応(I4)を確かめ、契約の一覧(`plugins/ndf/contracts/index.md`)に「上限の宣言」の表を描く | 変更 | +| 上限の値を持つ各部品(69 ファイル) | 数字のリテラルを `limits.seconds("<方針の名前>")` などの呼び出しへ置き換える | 変更 | +| 例外リスト(`scripts/script-structure-allow/`) | 待ちのループ 24 か所と、上限でない数字 3 か所(割合 2・見直しの周期 1)の行を持つ | 変更 | + +### 要素の間の関係 + +```mermaid +graph TD + subgraph 宣言 + D[(上限の宣言
limits.json)] + end + subgraph 読み手 + L[上限の部品
limits.py] + K[原因の鍵の部品
cause_key.py] + W[待ちの部品
waits.py] + end + subgraph 使う側 + E[プランの実行
engine・state] + P[worktree の作成
paths.py] + O[上限の値を持つ
各部品] + end + subgraph 検査 + S[構造チェック
limits_rules] + C[契約の検査
contracts_lib] + end + L -->|読む| D + W -->|方針| L + W -->|鍵| K + E -->|方針| L + E -->|鍵| K + P -->|再試行| W + O -->|値| L + O -->|再試行・待ち| W + S -.->|リテラルと眠りのループ| O + C -.->|形と名前の対応| D +``` + +### パッケージ構成 + +```text +plugins/ndf/ +├── contracts/ +│ ├── limits.json # 新設: 上限の宣言 +│ └── index.md # 変更: 「上限の宣言」の表を描く(生成物) +└── scripts/ + ├── lib/ + │ ├── limits.py # 変更: 宣言の読み手 + │ ├── cause_key.py # 新設: 原因の鍵 + │ └── waits.py # 変更: 方針・同じ原因・最後の照会 + └── supervise_lib/ + ├── engine.py # 変更: ステップの打ち直しの上限 + ├── state.py # 変更: ステップと原因ごとの回数 + └── paths.py # 変更: 再試行を waits へ +scripts/ +├── check-script-structure.py # 変更: 種類の追加 +├── structure_checks/ +│ └── limits_rules.py # 新設: limit-literal・sleep-loop の規則 +├── check-ndf-contracts.py # 変更 +├── contracts_lib/ # 変更: 宣言の検査と描画 +└── script-structure-allow/ # 変更: 待ちのループ・上限でない数字の行 +``` + +## 構造 + +```mermaid +classDiagram + class Policy { + +str name + +float seconds + +float interval + +float factor + +float max_interval + +float per_call + +int attempts + +int same_cause + } + class Waited { + +value + +int attempts + +float waited + +bool done + +error + +str stop + +str cause + +int same + +str reason + } + class limits { + +policy(name) Policy + +seconds(name) float + +monitor_timeout(phase, agent) int + +check() list + } + class cause_key { + +make(kind, text) str + +brief(key) str + } + class waits { + +retry_call(fn, should_retry, policy) Waited + +wait_until(fn, done, policy) Waited + } + class RunState { + +dict fail_counts + +dict cause_counts + +record() + +snapshot() dict + +replay(out) + } + limits ..> Policy : 返す + waits ..> Policy : 読む + waits ..> Waited : 返す + waits ..> cause_key : 鍵を作る + RunState ..> cause_key : 鍵を作る +``` + +### 値の扱い + +- `Policy` は値オブジェクト(`NamedTuple`)で、方針に無い値は `None`。計算で上限を縮める呼び出し元(設計の結果の読み取りの残りの予算など)は `policy._replace(seconds=<計算した値>)` で渡す。リテラルを渡す経路は I3 が落とす +- `Waited` の今の 5 つの値(`value`・`attempts`・`waited`・`done`・`error`)は、位置も意味も変えない。後ろに次の 4 つの値を既定値つきで足す。今の呼び出し元は書き換えずに動く + - `stop`: 止まり方(`done` / `deadline` / `same-cause` / `attempts`) + - `cause`: 最後の失敗の原因の鍵 + - `same`: その鍵の回数 + - `reason`: I10 の理由の文 +- `RunState.cause_counts` は `{ステップの id: {原因の鍵: 回数}}` で、`fail_counts` と同じく `snapshot` と `replay` が運ぶ。`state.json` には書かない(`durable-boundary` の検査が `state.json` の鍵を `log`・`llm`・`project_mvv` に限るため) + +## データ構造 + +上限の宣言は 1 つの JSON のファイルで、実行時には読むだけである。 + +```json +{ + "version": 1, + "policies": { + "monitor.phase.review": {"summary": "レビューの工程の監視の上限", "seconds": 1200}, + "monitor.stall.claude": {"summary": "claude の無進捗の許容。--output-format json は完了まで出さない", "seconds": 900}, + "monitor.cli-margin": {"summary": "CLI の上限 = 監視の上限 + この秒数", "seconds": 120}, + "supervise.design-stage-read": {"summary": "設計の結果の gh / git の一時的な失敗の打ち直し", "seconds": 120, "per_call": 30, "interval": 15, "same_cause": 3}, + "supervise.worktree-lock": {"summary": ".git/config の lock で落ちた worktree の作成の打ち直し", "seconds": 5, "attempts": 6, "interval": 1, "same_cause": 6}, + "supervise.step-rerun": {"summary": "判定役が返すステップの打ち直しの同じ原因の上限", "same_cause": 2} + } +} +``` + +| キー | 型 | 空の扱い | 意味 | +| --- | --- | --- | --- | +| `version` | int | 許さない | 宣言の形の版。今は 1 | +| `policies` | object | 許さない | 方針の名前 → 方針 | +| 方針の名前 | str | — | `<領域>.<用途>`。領域は Skill の名前(`skills/<名>/`)、`*_lib` の名前から `_lib` を除いたもの、`lib/` の部品はその部品の名前(例 `post-queue`)、エントリポイントのスクリプトはその名前。用途は小文字のケバブケース | +| `summary` | str | 許さない | 何の上限か。理由の文と契約の一覧に出る | +| `seconds` | number | 省略可 | 上限の秒。待ちと再試行では開始から期限までの単調時計の秒(眠りと照会・呼び出しの時間を含む)、呼び出しでは `timeout` | +| `interval` | number | 省略可 | 待ちの最初の間隔(秒)。再試行では毎回の間隔 | +| `factor` | number | 省略可 | 待ちの間隔の倍率(値が変わらない間に掛ける) | +| `max_interval` | number | 省略可 | 待ちの間隔の上限(秒) | +| `per_call` | number | 省略可 | 1 回の呼び出しの打ち直しの待ちの上限(秒)。1 回の実行の合計は `seconds` | +| `attempts` | int | 省略可 | 打つ回数の上限(最初の 1 回を含む) | +| `same_cause` | int | 省略可 | 同じ原因の鍵の失敗の上限の回数 | + +**値は今の既定値のまま移す**(要求の「移行性」)。表の値は、移す前のコードの値と一致することをテストで見る。 +監視の工程と担当の表は、`monitor.phase.<工程>`・`monitor.stall.<担当>`・`monitor.stall-default`・`monitor.cli-margin` の名前で持つ。 +`limits.py` は `monitor.phase.` と `monitor.stall.` の接頭辞で工程と担当を数え上げる(表に無い担当の値は接頭辞の外に置き、担当として数えない)。 + +## 入出力の契約 + +### 上限の部品(`lib/limits.py`) + +| 呼び出し | 入力 | 出力 | 失敗の形 | +| --- | --- | --- | --- | +| `policy(name)` | 方針の名前 | `Policy` | 宣言が無い・壊れている・名前が無い → `LimitsError`(`str()` が「上限の宣言 <パス> に方針 <名前> が無い」などの 1 文) | +| `seconds(name)` / `value(name, key)` | 方針の名前(とキー) | 数 | 上に加え、方針にその値が無い → `LimitsError` | +| `monitor_timeout(phase, agent, explicit=None)` ほか今の関数 | 今と同じ | 今と同じ値 | 表に無い工程 → 今と同じ `KeyError`。宣言の誤り → `LimitsError` | +| `phases()` / `stalls()` / `cli_margin()` | — | 工程 → 秒、担当 → 秒、秒 | 宣言の誤り → `LimitsError` | +| CLI `cli-timeout` / `monitor-timeout` / `check` | 今と同じ | 今と同じ | 今の失敗に加え、宣言の誤りは理由を標準エラーへ 1 行出して終了コード 1 | + +`PHASE_TIMEOUT`・`AGENT_STALL`・`DEFAULT_STALL`・`CLI_MARGIN` の定数は消す。読む側は `lib/monitor.py` と `skills/external-ai/scripts/external-ai.py` である。 +どちらも同じ Pull Request で `phases()`・`stalls()`・`cli_margin()` を呼ぶ。`DEFAULT_PHASE`(工程の名前)は数字でないため残す。 + +**`LimitsError` はエントリポイントで終了コード 1 になる。** 実行の核(#2337)が入る前は、各エントリポイントの今の例外の扱いのままにする +(捕まえないエントリポイントは Python の既定で終了コード 1)。`LimitsError` を捕まえて組み込みの値へ戻す処理は書かない。核が入った後は、核が `stopped` と理由に変える。 + +### 待ちの部品(`lib/waits.py`) + +| 呼び出し | 方針から読む値 | 止まる条件 | +| --- | --- | --- | +| `retry_call(fn, should_retry, *, policy, retry_on=(), reason=None, sleep, clock, on_wait)` | `seconds`(必須)・`interval`(必須)・`attempts`・`same_cause` | 当たらない結果(`done`)、同じ原因の回数が `same_cause`(`same-cause`)、打った回数が `attempts`(`attempts`)、期限を過ぎた(`deadline`) | +| `wait_until(fn, done, *, policy, sleep, clock, same, on_wait)` | `seconds`(必須)・`interval`(必須)・`factor`・`max_interval` | `done(値)`(`done`)、最後の照会の後(`deadline`) | + +- `clock` は単調時計の値を返す関数で、既定は `time.monotonic`。期限と残りの秒(I9)はこの値から計算し、テストは `sleep` と組で差し替えて照会の所要時間を再現する。理由の文の「待った秒」は開始から止めた時点までのこの値の差である +- `reason(値)` は失敗の理由の文を返す関数で、省くと結果の値の `str()` を使う。例外は型の名前と `str(例外)` から鍵を作る +- **`interval` が 0 の方針は打ち直さない**(今の `interval <= 0` の振る舞いを宣言の値で表す) +- 方針に必須の値が無いときは `LimitsError` + +理由の文の形(I10): + +```text +上限で止めた(方針 test-triage.check-wait): 待った 1154 秒・照会 15 回・上限 1154 秒 +同じ原因(exit=1: PR #<数> の CI が失敗: runtime-smoke (agy)(run <数>))で 2 回失敗した(方針 supervise.step-rerun) +``` + +### 原因の鍵(`lib/cause_key.py`) + +`make(kind, text)` は理由の文に次の置き換えをこの順で当て、空白を 1 つにまとめて先頭 200 字を残し、`"<種類>: <伏せた文>"` を返す。伏せた文が空なら `None`(P4-E3 の失敗。呼ぶ側は「原因を見分けられない」として、鍵を持たない失敗どうしを同じ原因として数える)。 + +| 順 | 伏せる部分 | 置き換え | +| --- | --- | --- | +| 1 | ISO 8601 の日時(日付と時刻、秒・小数・時差は任意) | `<時刻>` | +| 2 | URL(`http://` / `https://` から空白まで) | `` | +| 3 | パス(`/`・`./`・`../`・`~/` で始まる語と、`/` を 1 つ以上含む語) | `<パス>` | +| 4 | 英字を 1 つ以上含む 7〜64 字の 16 進の語(コミットの SHA など) | `` | +| 5 | 数字の並び(小数を含む) | `<数>` | + +`brief(key)` は鍵の先頭 60 字を返し、理由の文と進捗の記録に使う。 + +### 構造チェックの新しい種類 + +| 種類 | 落とすもの | 落とさないもの | 例外リストの `name` | +| --- | --- | --- | --- | +| `limit-literal` | (1) 名前の規則(「数え方」の 12 語のどれかを含む大文字の名前。`EXIT_` で始まる名前と `_CODE` で終わる名前を除く)に当たる定数への 0 でない数字の代入、(2) 呼び出しのキーワード引数 `timeout`・`max_wait`・`interval`・`max_interval`・`wait`・`deadline`・`retries`・`attempts`・`grace`・`per_call`・`poll`・`stall_timeout` への 0 でない数字、(3) 同じ名前の引数の既定値の 0 でない数字、(4) `.get("<同じキー>", <0 でない数字>)` | 0(待たない・打ち直さないの意味)、`lib/limits.py` の中、テストと実験版 | 定数の名前・引数の名前 | +| `sleep-loop` | `lib/waits.py` の外で、本体(入れ子の関数・クラス・`lambda` を除く)が名前の末尾を `sleep` とする関数を呼ぶ `for` / `while` のループ | テストと実験版 | ループを持つ関数の名前(最上位はモジュールの名前) | + +`wrapped` は持ち主の表に `tenacity`(持ち主 `lib/waits.py`)を足すだけで、規則は変えない。 + +## 処理の流れ + +### 呼び出しの再試行(`retry_call`) + +```mermaid +stateDiagram-v2 + [*] --> 打つ + 打つ --> 終わり: 当たらない結果 + 打つ --> 数える: 失敗 + 数える --> 止める: 同じ原因が same_cause + 数える --> 止める: 打った回数が attempts + 数える --> 眠る: 残りの秒がある + 数える --> 止める: 残りの秒が無い + 眠る --> 打つ: min(間隔, 残りの秒) + 止める --> [*]: 理由を付けて返す + 終わり --> [*] +``` + +構造チェック・契約の検査・例外リストは CI で動く構成要素で、実行時の処理の流れの図には含めない。 + +「数える」は、失敗から原因の鍵を作り(P4-E3)、鍵ごとの回数に 1 を足す(P4-E4)。`wait_until` も同じ形で、 +「失敗」を「条件が揃わない値」に、「数える」を「残りの秒を見る」だけに読み替える(同じ原因と打つ回数では止めない)。 +残りの秒は、期限(開始の単調時計の値 + `seconds`)から今の単調時計の値を引いて、打つたびに計算し直す。照会にかかった時間も残りの秒から引かれるため、上限 30 秒・間隔 10 秒・照会 2 秒の待ちは 0・12・24・30 秒の時点で照会し、最後の照会は期限の時刻に打たれる(P4-E10)。 + +### ステップの打ち直し + +```mermaid +sequenceDiagram + participant St as ステップ + participant Rs as プランの記録 + participant Jd as 判定役 + participant En as プランの実行 + St->>Rs: 失敗(exit と出力) + Rs->>Rs: 原因の鍵を作り、ステップと鍵ごとに数える + En->>Jd: on_fail で判定を求める + Jd-->>En: 同じステップをもう一度 + En->>Rs: そのステップの最後の鍵の回数を読む + alt 回数が same_cause に達した + En-->>En: 止まった(理由に原因と回数) + else 達していない + En->>St: もう一度流す + end +``` + +原因の鍵の理由の文は、ステップの出力の最後の結果 JSON の `summary`(`_result_summary` と同じ読み方)で、無ければ出力の最後の空でない行である。 +種類は `exit=<終了コード>` とする。判定役が失敗していないステップ・別のステップを返したときは数えない。 +止めたときは進捗の記録へ `{"kind": "same-cause", "step", "cause", "count", "policy"}` の 1 行を書く。 + +## 非機能の実現方式 + +| 大項目 | 条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 宣言の読み込みでエントリポイントの起動が遅くならない | 読み手は標準ライブラリの `json` だけを使い、1 プロセスに 1 回読む。pydantic で確かめない(hook の経路でも読むため) | 実装の Pull Request で `python3 <エントリポイント> --help` を変更の前後で 10 回ずつ測り、中央値の差を本文に書く。設計の時点の実測は「あるべき姿の根拠」の 1 ms 未満 | +| 運用・保守性 | 上限で止まった理由が結果と実行の記録の両方に残る | 理由の文を `Waited.reason` 1 か所で作り、呼び出し元が結果の `summary` へ書く。プランの実行は進捗の記録(`progress.jsonl`)に `same-cause` の行を書く。実行の記録(#2338)が入った後は、核の記録の書き口へも同じ値を書く | 単体テストで理由の文の中身(方針の名前・回数・秒)を見る | +| 移行性 | 移す値は今の既定値と同じ | 移す Pull Request ごとに、移す前の値の表をテストに持ち、宣言から引いた値と比べる | 値の比較のテスト | + +## 決定の記録 + +決定は [issue-2343-design-decisions.md](issue-2343-design-decisions.md) にある。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I1 | 宣言を差し替えると、同じ方針を引く部品(`retry_call` の打つ回数・`monitor_timeout` の値)が変わる。形の誤り(負の秒・知らないキー・整数でない回数)を `LimitsError` で拒む | 読み手が値をモジュールの中にコピーして持つ。形を確かめずに受け取る | +| 受け入れ条件 2・I3 | `timeout=600` を足したファイル・`FOO_TIMEOUT = 5` を足したファイル・`def f(timeout=5)` を足したファイルで `limit-literal` が終了コード 1。`timeout=0` と `EXIT_WAIT = 75` は通る | 名前の規則・キーワードの表・既定値の検査のどれかを外す | +| 受け入れ条件 3・I6 | `lib/waits.py` の外に `while True: ...; time.sleep(1)` を足すと `sleep-loop` が落ちる。入れ子の関数の中の `sleep` だけなら落とさない | ループの本体だけでなく入れ子の関数まで見る、または `lib/waits.py` を外し忘れる | +| 受け入れ条件 3・I7 | `lib/waits.py` の外で `import tenacity` すると `wrapped` が落ちる | 持ち主の表に足し忘れる | +| 受け入れ条件 4・I8 | 同じ理由の失敗を続けると `same_cause` 回で止まり、待ちの合計まで眠らない。数字だけが違う理由(run の番号・秒)は同じ原因として数える。理由の文が違えば別に数える | 伏せる規則を外す。連続した回数だけを数える。鍵を見ずに回数で止める | +| 受け入れ条件 5・I10・I11 | 同じ CI の失敗を返す `release` のステップと、毎回同じステップを返す判定役のプランが、2 回目の失敗の後の打ち直しで「止まった」になり、理由に原因の要旨と回数が出る。別の失敗に変わったら流し続ける | 判定役の後で数を見ない。原因の鍵ではなくステップの失敗の回数だけで止める | +| 受け入れ条件 6・I9 | 条件が揃わない値を返し続ける `wait_until` の最後の照会が期限の時刻に打たれる。照会に 2 秒かかる差し替えの時計(上限 30 秒・間隔 10 秒)で、照会の時刻が 0・12・24・30 秒になり、30 秒を超えない。上限で止まった理由に待った秒・照会の回数・上限の秒が出る。#2224 の再現の手順(上限 1154 秒・間隔 120 秒まで伸びる待ち)で最後の照会が打たれる | 次の眠りが上限を超えた時点で打たずに止める(今の `_stop_by_idle`) | +| 受け入れ条件 7・I2 | 宣言が無い・壊れた JSON・名前が無い・値が無いの 4 通りで、`limits.py` の CLI が終了コード 1 と、パスと名前を含む理由を出す | 例外を捕まえて組み込みの値を返す | +| 受け入れ条件 8 | 子の課題 9 件の本文に、閉じた・残したと理由の行がある(下の「子の課題の扱い」) | — (記録の作業で、テストで縛らない) | +| 受け入れ条件 9・I5 | 既存の全体テストが通る。`limits.py check` が宣言の値で今と同じ組を判定し、無進捗の許容を監視の上限より長くした宣言で組を出して終了コード 1 | `check` が宣言でなく別の表を見る | +| I4 | コードに宣言に無い方針の名前を書く・宣言にどこからも引かれない方針を足すと、契約の検査が落ちる | 名前の対応を片方向だけ見る | +| I12 | 同じ原因で 1 回落ちた後に落ちて再開した実行が、再開の後の 1 回の失敗で止まる | 回数を `snapshot` に入れ忘れる | +| 移行性 | 移す前の値の表と、宣言から引いた値が一致する | 値を移すときに数字を誤る | + +## 子の課題の扱い + +受け入れ条件 8 の記録は、実装がすべてマージされた後に 1 件ずつ再現の手順を通して決める。設計の時点の見込みは次のとおりで、判定の基準ではない。 + +| 課題 | この柱の仕組みとの関係 | 見込み | +| --- | --- | --- | +| #1983 | ステップの打ち直しの同じ原因の上限(F5)が直接当たる | 再現の手順で止まれば閉じる。本文の受け入れ条件 2(判定役が前の判断を読む)は、判定役の後でプランの実行が止める形で置き換えたことを閉じる理由に書く | +| #2224 | 最後の照会(F6)が直接当たる | 再現の手順で上限まで照会すれば閉じる | +| #2202・#2203 | 2026-11-03 に閉じた(not planned) | 再現の手順を通さず、閉じた状態を記録する | +| #1286・#1734・#1735・#2093・#2158 | 待ちの止め方と上限の置き場に関わるが、原因が宣言の値か部品の振る舞いかは再現するまで分からない | 再現の手順を通し、残ったものは理由(どの仕組みで解消しないか)を本文に書いて残す | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2343 | 実装する | — | `plugins/ndf/contracts/limits.json`、`plugins/ndf/contracts/index.md`、`plugins/ndf/scripts/lib/limits.py`、`plugins/ndf/scripts/lib/cause_key.py`、`plugins/ndf/scripts/lib/waits.py`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/lib/`、`plugins/ndf/scripts/relay_lib/`、`plugins/ndf/scripts/hook_lib/`、`plugins/ndf/scripts/release_lib/`、`plugins/ndf/scripts/merged_lib/`、`plugins/ndf/scripts/project_lib/`、`plugins/ndf/scripts/instructions_lib/`、`plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/release-verification-steps.py`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/sprint-state.py`、`plugins/ndf/scripts/wait-notify.py`、`plugins/ndf/skills/`、`plugins/ndf/scripts/tests/`、`scripts/check-script-structure.py`、`scripts/structure_checks/`、`scripts/check-ndf-contracts.py`、`scripts/contracts_lib/`、`scripts/script-structure-allow/`、`scripts/tests/` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 前提の柱が入っていない | 要求の前提 1 の #2337(結果の契約と核のスケルトン)と #2342(持ち主・後片付け・並行)は 2026-11-04 の時点で開いている。この設計は核に依存しない形で作る(決定 11)。核が入ったときに `LimitsError` を `stopped`・終了コード 1 に変えるかは、#2337 の設計が契約の誤りに終了コード 2 を当てる場合に食い違う。#2337 の実装の時点で照らす | +| 境界の置き場を触る | 移す対象に `plugins/ndf/skills/google-workspace/`(3 か所)と `plugins/ndf/scripts/lib/auth.py`(1 か所)がある。どちらも `.ndf/pace.json` の境界の置き場で、値の置き場を変えるだけでも、その Pull Request は MVV の判定で承認ゲート 2 を通らず人が見る | +| 眠りのループの検査の取りこぼし | `sleep-loop` はループの本体で直に `sleep` を呼ぶものだけを見る。眠りを別の関数に包んで呼ぶ打ち直し(例: `supervise_lib/claude.py` の利用上限の待ちを呼ぶループ)は落とさない。今あるものは利用上限の回復の時刻までの待ちで、要求の前提 6 で再試行に当たらない | +| シェルのスクリプトの数字 | `statusline.sh`・`progress-record.sh`・`workflow-common.sh`・`lock-common.sh`・`worktree-registry.sh`・`pr-body-decisions.sh` の上限の数字(2026-11-04 に 6 か所。要求の前提 8 の 4 か所より多い)は検査の外に残す(決定 10) | +| 例外リストの行の増加 | 行の増加をベースブランチと比べて落とす検査(`allow-grow`。#2335 の設計で P0 の受け持ち)は、2026-11-04 の時点でリポジトリに無い(`grep -rln "allow-grow\|allow_grow"` を `.py`・`.yml`・`.sh` に打って 0 件)。それまでは、`sleep-loop` と `limit-literal` の行を足す差分をレビューが見る | +| 実行の記録への書き込み | 非機能の「実行の記録の両方に残る」は、実行の記録(#2338)が入るまで、プランの進捗の記録と結果の `summary` だけで満たす | diff --git a/issues/issue-2343-requirements.md b/issues/issue-2343-requirements.md new file mode 100644 index 000000000..8cba69195 --- /dev/null +++ b/issues/issue-2343-requirements.md @@ -0,0 +1,193 @@ +# #2343: 上限と再試行の方針を 1 つの宣言に集め、同じ失敗の打ち直しを止める + +正は課題の本文(#2343)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +大方針 #2335(異常系・ログ・回復とデータ・入出力の形を 1 つの実行の核へ集める)の柱の 1 本。子の課題は、この柱の仕組みがそろえば解消する可能性があるものである。 + +## 具体例 + +2026-10-31 の本番の配布のプランで、`release` のステップが同じ CI の失敗(run 38353378585 の `runtime-smoke (agy)`)で落ち続けた。プランの判定役(judge)は 7 回とも「外部の待ちの揺れなので同じステップをもう一度」と返し、ステップの上限まで 366 秒、同じ失敗した run を読み直した(#1983)。打ち直しの回数は数えていたが、**原因が前回と同じかは誰も見ていなかった。** + +2026-10-30 には、cross-refactoring の最終ゲートの CI の待ちが、上限 1154 秒のうち 112 秒を残して「結論を得られない」で終わった。同じコミットのチェックはすべて成功していた(#2224)。待ちの部品が、上限の時刻に最後の照会を打たずに止まるためである。 + +どちらも、上限と再試行の値と止め方が、それぞれの部品の中に書かれていることから起きる。この課題が終わると、上限と再試行の方針は 1 つの宣言から読まれ、同じ原因の打ち直しは決まった回数で止まり、止まった理由が結果に出る。 + +## 依頼(原文) + +この課題の題と本文(「何をするか」「受け入れ条件」「進め方」)は、起票者(課題を書いた AI)が 2026-11-01 に #2335 の組み直しの記録として書いた文で、利用者の指示の原文として扱わない。利用者の指示の原文は、#2335 が引用した次の指摘である。 + +> 異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない + +> 内部データ構造と出力のフォーマットの不統一の影響も大きそう + +> NDF は Skill の Script 化から発展してきたため、内部データ構造や入出力契約がほぼ整理されていない + +> 出力だけでなく入力もそろえるべき + +(書き手: 利用者、日付: 2026-11-01。#2335 の「調べ方」の節が引用した指摘) + +**解釈**: 利用者が求めているのは、異常時の回復処理を NDF 全体でそろえ、同じ型の不具合を 1 件ずつではなく型ごとに無くすことである。この課題はそのうち「待ちと上限」の型(#2335 の型 D)を受け持つ。上限と再試行の値の置き場を 1 つにし、回復の手段である再試行を 1 つの部品にそろえる。 + +## 起票者の推定 + +題・本文のうち、上の利用者の原文に無く、起票者と要求の工程が推して足した条件。S1〜S4 は課題の本文の起票者、S5〜S10 は要求の工程(2026-11-04)が人へ問わずに最も筋の通る案として選んだ。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 上限(時間・予算・待ち)と再試行の方針を 1 つの宣言に置き、実行の核が当てる。コードに `timeout=<数字>` の直書きを残さない(テストを除く) | 課題の本文「何をするか」・受け入れ条件 1。#2335 の起票者の推定 S5 | 受け入れ条件 1・2 | +| S2 | 再試行の実装を `lib/waits.py` の 1 つにし、ほかの再試行の関数を残さない | 課題の本文の受け入れ条件 2 | 受け入れ条件 3 | +| S3 | 同じ原因の失敗の打ち直しは決まった回数で止まり、理由が結果に出る | 課題の本文の受け入れ条件 3。#2335 の受け入れ条件 5a | 受け入れ条件 4・5 | +| S4 | 子の課題は 1 件ずつ直さず、柱の仕組みを入れた後に再現の手順を 1 回ずつ通し、閉じるか理由つきで残すかに分ける | 課題の本文「進め方」・受け入れ条件 4 | 受け入れ条件 8・境界の「行わない」 | +| S5 | 直書きを禁じる対象を `timeout=<数字>` だけでなく、上限・再試行の回数・待ちの間隔の定数(例 `DEFAULT_TIMEOUT = 1800`)にも広げ、構造チェックで判定する | #2335 の受け入れ条件 5 の書き換え(定数を通すと直書きを言い換えただけになる)。#2335 の設計の I12 | 受け入れ条件 1・2 | +| S6 | 実験版(`plugins/ndf/scripts/experimental/`)は直書きの検査の対象から外す | 実験版は課題を起こさずにその場で試す経路で、本体へ移すときに安定版の検査を通る(AGENTS.md の「安定版と実験版」) | 前提 4 | +| S7 | 同じ原因かどうかは、例外の型と、数字・時刻・パスを伏せた失敗の理由の文から作る「原因の鍵」で見る | #2335 の設計の決定 13 | 受け入れ条件 4 | +| S8 | 同じ原因の打ち直しの上限は、関数の呼び出しの再試行と、プランの判定役が返すステップの打ち直しの両方に当てる | #1983 はステップの打ち直しで起きた。呼び出しの再試行だけに当てると #1983 が残る | 受け入れ条件 4・5 | +| S9 | 待ちは上限の時刻に最後の照会を 1 回打ってから止まる | #2224 の受け入れ条件。止まる時刻を上限より前に置くと、上限の値を宣言に置いても実際の上限が宣言と食い違う | 受け入れ条件 6 | +| S10 | この柱の子の課題は、#2335 が型 D(待ち・上限)に分けた 13 件のうち、マイルストーン「30 根本解決 3」にある 9 件とする | #2335 の要求の「対象の課題」と、マイルストーン 30 の一覧(2026-11-04 に `gh issue view` で確かめた) | 前提 5・受け入れ条件 8 | + +## 目的 + +- NDF のスクリプトが、上限の値と再試行の回数・間隔をそれぞれ自分で持たずに済む状態にする。値を変えるときに直す場所が 1 つになり、入口の契約から上限をたどれる +- 同じ原因の失敗を上限まで打ち直さない。打ち直しが結果を変えないと分かった時点で止まり、止まった理由(原因と回数)が結果に残るので、人へ戻ったときに追い直さずに済む +- 待ちが宣言した上限を使い切ってから止まる。上限より前に「結論を得られない」で止まらない + +## 前提 + +- 前提 1: この柱は、#2335 の柱 P1(#2337 結果の契約と核のスケルトン)と P3(#2342 持ち主・後片付け・並行)が入った後に設計と実装を行う(#2335 の設計の「柱の順序」)。2026-11-04 の時点で #2336(P0)は閉じ、#2337・#2342 は開いている。リポジトリに実行の核(`run_core`)はまだ無い(`ls plugins/ndf/scripts/lib` に無い) +- 前提 2: 上限の宣言の置き場は `plugins/ndf/contracts/limits.json`、読み手は `plugins/ndf/scripts/lib/limits.py` とする(#2335 の設計の決定 13)。`limits.py` が今持つ工程 × 担当の表と順序のチェック(無進捗の許容 < 監視の上限 < CLI の上限)は、宣言を読んで同じ判定をする +- 前提 3: 宣言の値を利用者が上書きする経路は、#2335 の柱 P8(#2341 入力の契約)の解決の順(引数 → 環境変数 → 設定 → 既定値)に従う。宣言が持つのは既定値で、上書きの経路はこの柱で新しく作らない +- 前提 4: 直書きの検査の対象は、テスト(`tests/` と `test_*.py`)と実験版(`plugins/ndf/scripts/experimental/`)を除く `plugins/ndf/` の Python のコードとする。hook(`plugins/ndf/scripts/hook_lib/` など)も対象に入る。hook は核を通らない(#2335 の対象範囲の「含まない」)が、値は核を通さずに `limits.py` から読む +- 前提 5: 子の課題は次の 9 件とする。型 D の残り 4 件(#1872・#1919・#2154・#2251)は別のマイルストーン(31・32・29)にあり、この柱では扱わない + + | 課題 | 題の要旨 | 2026-11-04 の状態 | + | --- | --- | --- | + | #1286 | relay が `ndf-next` と同じ応答の質問を待たずに会話を切り替える | 開いている | + | #1734 | cross-review の修正担当がテストを背景で起動し、通知を待ったまま結果ファイルを書かずに終わる | 開いている | + | #1735 | `test_external_ai_run` の codex の成功ケースが CI でだけまれに落ちる | 開いている | + | #1983 | 本番の配布のプランで、判定役が同じ CI の失敗に打ち直しを 7 回返す | 開いている | + | #2093 | 負荷が高いときだけ `test_runtime_specific_usage_shapes` が落ちる | 開いている | + | #2158 | 着手前のテストが上限の近くで通ると、範囲テストのテンプレートの試走の残りが数秒になり止まる | 開いている | + | #2202 | cross-refactoring の既定の予算をテストの所要とコード量から決める | 2026-11-03 に閉じた(not planned) | + | #2203 | cross-refactoring の最終ゲートの CI の待ちを想定最大時間の勘定から外す | 2026-11-03 に閉じた(not planned) | + | #2224 | 待ちの部品 `wait_until` が上限の時刻に最後の照会を打たず、最大 2 分前に打ち切る | 開いている | + +- 前提 6: 「再試行の関数」は、失敗した同じ呼び出しを、同じプロセスの中で間隔を置いて打ち直す関数を指す。次は再試行の関数に当たらない + - 名前に retry を含むが打ち直しのループを持たない関数: cross-review の `_retry_unmatched_scope`(次のラウンドへ進める出口)、cross-refactoring の `push_with_retry_marker`(次の起動で push をやり直すための印)。2026-11-04 に本文を読んで確かめた + - 条件が揃うまで問い合わせる待ち(プロセスの終わり・ロックの取得・CI の結論・GitHub の上限の回復の時刻)。待ちは受け入れ条件 6 が扱う +- 前提 7: 2026-11-04 に同じ数え方(`grep -rnE "timeout=[0-9]"`、テストと実験版を除く、`plugins/ndf/scripts` と `plugins/ndf/skills`)で打ち直すと 20 か所あった。上限・再試行・待ちの間隔の名前を持つ定数に数字を入れた行(`grep -rnE "^[A-Z_]*(TIMEOUT|RETRIES|RETRY|MAX_WAIT|INTERVAL|BUDGET)[A-Z_]* *(:[^=]*)?= *[0-9]"`)は 40 か所あった。数は受け入れ条件の判定に使わない。判定は構造チェックが落とすかで行う +- 前提 8: シェルのスクリプトの上限の直書き(2026-11-04 に 4 か所。`statusline.sh` の `timeout 1`、`progress-record.sh` と `workflow-common.sh` の `${…:-<数字>}`、`wait-review.sh` の説明)を検査に入れるかは設計で決める(未決 2)。`statusline.sh` は表示のたびに動くため、Python を起動して宣言を読むと表示が遅れる + +## 対象範囲 + +含む: + +- 上限の宣言(`plugins/ndf/contracts/limits.json`)を作り、`limits.py` の表と、各部品の上限・再試行の回数・待ちの間隔の既定値をそこへ移す +- 構造チェック(`scripts/check-script-structure.py`)に、上限・再試行の数字のリテラルの検査と、`lib/waits.py` の外の再試行のループの検査を足す +- 再試行のループを持つ関数(2026-11-04 の時点で `supervise_lib/paths.py` の `_add_worktree_with_retry`、`lib/post_queue.py` の `retry` など)を `lib/waits.py` の呼び出しへ置き換える +- 原因の鍵を作り、同じ原因の打ち直しを宣言の回数で止める仕組み。呼び出しの再試行(`lib/waits.py`)と、プランの判定役が返すステップの打ち直しの両方に当てる +- 待ちが上限の時刻に最後の照会を打ってから止まり、止まった理由を結果に出すこと +- 子の課題 9 件の再現の手順を通し、閉じるか理由つきで残すかを本文に記録すること + +含まない: + +- 上限の値そのものの見直し(例: cross-refactoring の予算の配分を変える #2457)。この柱は値の置き場と当て方をそろえ、値は今の既定値のまま移す +- 上書きの経路(引数・環境変数・設定)を新しく作ること。#2341(P8)が受け持つ +- 期限で打ち切った実行の持ち主の記録と後片付け。#2342(P3)が受け持つ +- 子の課題を 1 件ずつ直すこと。仕組みを入れた後に再現の手順を通して残ったものは、理由を書いて残す +- 型 D のうちマイルストーン 30 の外にある 4 件(#1872・#1919・#2154・#2251) + +## ドメインイベント + +番号は #2335 の要求のドメインイベント E7・E8 を細かくしたもの。 + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| P4-E1 | 上限の宣言を読んだ | 実行の核の起動・`limits.py` の呼び出し | 宣言が無い・壊れている・方針の名前が無い → 核が `stopped`(理由に宣言のパスと方針の名前) | — | +| P4-E2 | 呼び出しが失敗した | 部品の呼び出しの例外か、再試行の条件に当たる結果 | — | P4-E1 | +| P4-E3 | 失敗から原因の鍵を作った | P4-E2 | 鍵を作れない(理由の文が空など) → 回数だけで数え、理由に「原因を見分けられない」を書く | P4-E2 | +| P4-E4 | 同じ原因の失敗を数えた | P4-E3 | — | P4-E3 | +| P4-E5 | 間隔を置いて打ち直した | P4-E4 の回数が宣言の上限より小さい | 打ち直しの合計の待ちが上限を超える → P4-E6 | P4-E4 | +| P4-E6 | 打ち直しを止めた | 同じ原因の回数が上限に達した、または待ちの合計が上限に達した | — | P4-E4 | +| P4-E7 | 止めた理由を結果に書いた | P4-E6 | — | P4-E6 | +| P4-E8 | 判定役がステップの打ち直しを返した | プランのステップの失敗 | — | — | +| P4-E9 | ステップの打ち直しを同じ原因の上限で止めた | P4-E8 の失敗の原因の鍵が前回と同じで、回数が上限に達した | — | P4-E8 | +| P4-E10 | 待ちが上限の時刻に最後の照会を打った | 待ちの次の眠りが上限を超える | 照会が失敗する → 理由に照会の失敗を書いて止まる | P4-E1 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 上限の宣言 | 上限の秒・再試行の回数・待ちの間隔・同じ原因の上限を、方針の名前ごとに持つ 1 つのファイル(`plugins/ndf/contracts/limits.json`) | +| 方針の名前 | 上限の宣言の 1 行を指す名前。入口の契約と部品の呼び出しが、値でなくこの名前で上限を引く | +| 原因の鍵 | 失敗が前回と同じ原因かを見る値。例外の型と、数字・時刻・パスを伏せた失敗の理由の文から作る | +| 再試行 | 失敗した同じ呼び出しを、同じプロセスの中で間隔を置いて打ち直すこと | +| ステップの打ち直し | プランの判定役が、失敗したステップを同じステップのまま流し直すと返すこと | + +## 受け入れ条件 + +- [ ] 1. 上限(時間・予算・待ち)・再試行の回数・待ちの間隔の既定値は上限の宣言(`plugins/ndf/contracts/limits.json`)1 か所にあり、`limits.py` と各部品はそこから値を読む。宣言の値を 1 つ変えると、その方針の名前を使う部品の振る舞いが変わる(テストで、宣言を差し替えて部品の上限が変わることを見る) +- [ ] 2. テストと実験版を除く `plugins/ndf/` の Python のコードに、`timeout=<数字>` の直書きと、上限・再試行の回数・待ちの間隔の定数への数字のリテラルがあると、構造チェック(`python3 scripts/check-script-structure.py`)が終了コード 1 で落とす。変更の後の develop では 0 で通る +- [ ] 3. 再試行のループは `lib/waits.py` だけにある。`lib/waits.py` の外で再試行のループを書くと構造チェックが落とす。`tenacity` を import するのは `lib/waits.py` だけである +- [ ] 4. 同じ原因の鍵の失敗が上限の宣言の回数に達すると、呼び出しの再試行は止まり、待ちの合計の上限まで回らない。原因の鍵が変わった失敗は別に数える(テストで、同じ理由の失敗を続けると宣言の回数で止まり、理由の文の数字だけが違う失敗も同じ原因として数えることを見る) +- [ ] 5. 止まったときの結果は `stopped` で、理由に原因の鍵の要旨と回数が出る(例: 「同じ原因(`CI の失敗: runtime-smoke`)で 3 回失敗した」)。ステップの打ち直しにも同じ上限が効き、#1983 の再現の手順(同じ CI の失敗が続く `release` のステップ)では、宣言の回数で止まって人へ戻る +- [ ] 6. 待ちは、次の眠りが上限を超えるとき、上限の時刻に最後の照会を 1 回打ってから止まる。上限で止まったときの結果は `stopped` で、理由に待った秒・照会の回数・上限の秒が出る(#2224 の再現の手順で確かめる) +- [ ] 7. 宣言が無い・壊れている・方針の名前が宣言に無いとき、部品は組み込みの値へ黙って戻らず、終了コード 1 と理由(宣言のパスと方針の名前)で止まる +- [ ] 8. 子の課題 9 件(前提 5)のそれぞれを、閉じた(再現の手順を通して解消を確かめた)か残した(理由つき)かに分けて、この課題の本文に記録する。閉じた課題には、通した手順と結果をコメントで残す +- [ ] 9. 退行しない: 既存の全体テスト(`uv run --frozen --project . --all-extras pytest . -q -n 4`)が通る。`limits.py check` の順序のチェック(無進捗の許容 < 監視の上限 < CLI の上限)は、宣言を読んで同じ組を判定する + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 宣言の読み込みで、入口の起動(`python3 <入口> --help`)が遅くならない。変更の前後で 10 回ずつ測った中央値の差を設計 Pull Request に書き、#2335 の上限 150 ms の内訳に収まることを示す | +| 運用・保守性 | 上限で止まった理由(方針の名前・原因の鍵・回数・待った秒)が、結果と実行の記録の両方に残り、後から 1 回の実行を追える | +| 移行性 | 移す値は今の既定値と同じにする。値の移し替えの前後で、各部品の上限の秒・回数が変わらないことをテストで見る | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | `limits.py` のコマンド(`cli-timeout`・`monitor-timeout`・`check`)の引数と出力は変えない。利用者が上書きに使う環境変数(`MONITOR_TIMEOUT` など)は変えない | +| データ | 上限の宣言のファイルを追加する。保存済みのデータの移行は無い | +| 既存の振る舞い | 同じ原因の打ち直しが、今より早く止まる(今はステップの上限か待ちの合計まで回る)。待ちが上限の時刻まで照会を続ける(今は最大 2 分前に止まる) | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`(受け入れ条件 1・4〜7・9) | +| 静的解析・構造の検査 | `python3 scripts/check-script-structure.py`(受け入れ条件 2・3。CI のジョブ `script-structure`)、`python3 plugins/ndf/scripts/lib/limits.py check` | +| 再現の手順 | 子の課題 9 件の本文の再現の手順を 1 回ずつ通す(受け入れ条件 5・6・8) | +| 手動確認 | リリース後テストで、上限で止まった実行の結果に方針の名前と理由が出ることを 1 件見る | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 宣言は `plugins/ndf/contracts/` に置く(#2336 が作った契約の一覧と同じ置き場)。`plugins/` から `factory/` を参照しない(AGENTS.md) | +| コーディング規約 | 再試行と待ちは `lib/waits.py`(tenacity を呼ぶ唯一のモジュール)を通す。新しい依存は足さない(#2335 の前提 5) | +| テスト戦略 | 部品の単体テストで、宣言を差し替えて上限と回数が変わることと、同じ原因の数え方を見る。`.md` の文言を照合するテストは書かない(AGENTS.md) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストと構造チェックを通す。値は今の既定値のまま移す | +| 確認してから行う | 上限の既定値そのものを変えること(承認ゲート 1・2 で人が見る) | +| 行わない | 子の課題を 1 件ずつ直すこと。上書きの経路を新しく作ること。hook を核へ移すこと | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 同じ原因の上限の既定の回数と、原因の鍵を作るときに伏せる部分の規則 | この柱の設計 | 設計 Pull Request | +| 2. シェルのスクリプトの上限の直書き(前提 8 の 4 か所)を検査の対象に入れるか | この柱の設計 | 設計 Pull Request | +| 3. 構造チェックが「再試行のループ」と「待ち」を見分ける規則(受け入れ条件 3) | この柱の設計 | 設計 Pull Request | +| 4. プランの判定役のステップの打ち直しに、原因の鍵をどこで渡すか(判定役への入力か、判定の後の核か) | この柱の設計 | 設計 Pull Request | +| 5. #2335 の本文の `timeout=<数字>` 195 か所と、打ち直した値(2026-11-01 に 17、2026-11-04 に 20)の食い違いの出所。#2335 の未決をこの柱が受け持つ | この柱の設計(契約の一覧の数え方で確かめる) | 設計 Pull Request | + +## 進め方 + +子の課題は 1 件ずつ直さない。柱の仕組みを入れた後に、子の課題の再現の手順を 1 回ずつ通し、解消したものを理由を添えて閉じる。設計は #2335 の設計(マイルストーン「28 根本解決 1: 設計と契約の棚卸し」)の後に、この柱ごとに行う。 + +(柱の記号: P4。2026-11-01 の組み直しで作った) diff --git a/issues/issue-2369-design.md b/issues/issue-2369-design.md new file mode 100644 index 000000000..0ef34b734 --- /dev/null +++ b/issues/issue-2369-design.md @@ -0,0 +1,338 @@ +# NDF のスクリプト: 呼び手の無い関数を片付け、子の課題 17 件の解消を確かめて閉じる(#2369) + +## 目的 + +- **今どうなっているか**: NDF のスクリプトには、テストの外から呼ばれない関数とメソッドが 70 件の候補として残っている(2026-11-04 の棚卸しのコマンドの結果)。柱 P0(#2336)の子の課題 17 件は、解消したかを 1 件ずつ確かめないまま開いている +- **誰に効くか**: 柱 P1〜P8(マイルストーン 29・30)で実行の核へ移す作業の担当と、課題の一覧から次に着手する課題を選ぶ利用者 +- **変えると何が成り立つか**: 移す対象が実際に呼ばれる関数だけになり、残した関数には残す理由が 1 行ずつ付く。子の課題は、解消を確かめたものが閉じ、残したものに理由と根拠が付く + +## 適用範囲 + +- **働く範囲**: このリポジトリの `plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts` の下の `.py`(テストと実験版を除く)と、子の課題 17 件。配布先のリポジトリの振る舞いは変わらない +- **プロジェクトごとに違うもの**: 無し(このリポジトリの中の片付けで、設定も引数も足さない) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| `uvx vulture plugins/ndf/scripts plugins/ndf/skills/*/scripts --exclude '*/tests/*,*/experimental/*' --min-confidence 60`(vulture 2.16、2026-11-04、develop 729dcbe40)で `unused function` 51 件・`unused method` 19 件 | 実測 | 呼び手の無い関数の候補は 70 件ある。どれを消すかは候補ごとに呼び手を確かめて決める | +| 同じ候補の名前でリポジトリを検索した結果(下の「候補の分類」): 23 件は `getattr`・`globals()`・名前の辞書で呼ばれ、4 件は枠組み(pydantic・urllib)が呼ぶ | 実測 | vulture は名前で呼ぶ形を見られない。消す前に呼ばれ方を確かめる手順と、残す理由の置き場が要る | +| 確定仕様 `docs/specifications/ndf-entrypoint-contracts.md` の「呼び手の無いもの」(310〜315 行目): 関数の棚卸しは `uvx vulture` で 1 回だけ行い、CI の常設の検査にしない | 既存の決定 | 残す関数の一覧は CI が読まない。次に棚卸しをする人が同じコマンドで同じ結果を得られれば足りる | +| #1601 の本文「直し方」: `metrics.aggregate` と、それだけが使う補助の関数と、そのテストを消す | 既存の課題 | 子の課題のうち少なくとも 1 件は、この片付けそのもので解消する | + +要求と受け入れ条件は #2369 の本文にある(コピーは `issues/issue-2369-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +この文書での呼び方は次のとおりにする。 + +| 呼び方 | 意味 | +| --- | --- | +| 棚卸しのコマンド | 要求の前提 2 の `uvx vulture <対象> --exclude '*/tests/*,*/experimental/*' --min-confidence 60`。残す関数の一覧を渡すときは対象の後ろにそのパスを足す | +| 候補 | 棚卸しのコマンドが `unused function` か `unused method` として出した 1 行 | +| 呼び手 | その関数を呼ぶ、テストの外のファイル(要求の用語と同じ) | +| 残す関数の一覧 | 呼び手が vulture に見えない関数を、理由つきで並べた vulture の許可リスト(要求の用語と同じ) | +| 子の課題 | 要求の受け入れ条件 6 に並ぶ 17 件 | +| 判定の節 | #2369 の本文の「子の課題の判定」の節 | +| 構造チェック | 要求の用語と同じく `scripts/check-ndf-contracts.py` を指す。`scripts/check-script-structure.py` はこの文書では名前で書く | + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | エントリポイント・契約の一覧・構造チェック・呼び手・残す関数の一覧 | +| NDF の課題の棚卸し(`ndf-issue-upkeep`) | 子の課題・判定・再現の手順 | + +開発ワークフローが供給者、課題の棚卸しが顧客の関係(顧客 / 供給者)である。課題の棚卸しは、開発ワークフローの片付けを終えた worktree で再現の手順を通し、その結果だけを受け取る。契約の一覧の形と構造チェックの規則は変えない(要求の対象範囲の「含まない」)。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 残す関数の一覧 | 棚卸しを行うその時点の担当(1 回の棚卸しを 1 つの変更として書き換える)。この課題の初版は E3・E4 の実装の担当が作る | 一覧のファイル(`scripts/ndf-vulture-keep.txt`) | 行(残す 1 つの名前) | 名前・定義のあるファイル・理由の種類・理由の本文 | +| 子の課題の判定 | この課題の実装だけ(#2369 の本文の判定の節だけを書き換える)。リリース後テストは判定の節を書き換えない | 判定の節 | 行(子の課題 1 件) | 再現の手順の結果・判定(閉じた / 残した)・理由の種類・根拠 | +| 契約の一覧 | #2336 の構造チェックの運用。この変更は、消したエントリポイントの項目の削除だけ書き換える | `plugins/ndf/contracts/` | エントリポイントの項目 | `callers`・`external` | + +この変更が書き換えるのは、上の 2 つの集約と、呼び手の無い関数の定義だけである。契約の一覧は、呼び手の無いエントリポイントが見つかったときだけその項目を消す(2026-11-04 の構造チェックでは 0 件)。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 残す関数の一覧 | 棚卸しのコマンドへ一覧を渡すと、`unused function` と `unused method` が 0 件である(受け入れ条件 2) | 完了判定で止まる。出た候補を消すか、理由つきで一覧へ足す | +| I2 | 残す関数の一覧 | 名前の行はどれも、`#` の後ろに理由の種類(下の「理由の種類」の 6 つのどれか)と理由の本文を持つ(受け入れ条件 3) | 完了判定で止まる。理由を書くか、行を消してその関数を消す | +| I3 | 残す関数の一覧 | 名前の行はどれも、定義のあるファイルのパスを持ち、そのファイルにその名前の `def` がある | 完了判定で止まる。古い行を消す。古い行を残すと、同じ名前の呼び手の無い関数を棚卸しから隠す | +| I4 | 契約の一覧 | 構造チェックが終了コード 0 で `violations: 0` を返す(受け入れ条件 1) | CI のジョブ `ndf-contracts-check` が落ちる | +| I5 | 子の課題の判定 | 判定の節の行は 17 行で、子の課題 1 件に 1 行である(受け入れ条件 6) | 完了判定で止まる | +| I6 | 子の課題の判定 | 判定が「残した」の行は、理由の種類が「再現した」「判断待ち」「手順を起こせない」のどれかで、根拠(打ったコマンドと結果、または待っている判断)を持つ。その課題は本番の配布の後に開いている。#1709 のように既に閉じている課題は、E7 で開き直す(受け入れ条件 8) | 理由と根拠は完了判定で止まる。開いているかはリリース後テストで止まる。開き直す操作が失敗した課題は、判定の節を書き換えず、E7 の担当が失敗の内容を #2369 のコメントへ記録し、開き直す操作を 1 回だけやり直す。やり直しも失敗したら、受け入れ条件 8 をリリース後テストの記録で不合格にし、人へ戻す | +| I7 | 子の課題の判定 | 判定が「閉じた」の行の課題は、本番の配布の後に GitHub 上で閉じていて、判定の理由と通した手順の結果を書いたコメントを持つ(受け入れ条件 7) | リリース後テストで止まる。閉じる操作が失敗した課題は、判定の節を書き換えず、E7 の担当が失敗の内容を #2369 のコメントへ記録し、閉じる操作を 1 回だけやり直す。やり直しも失敗したら、受け入れ条件 7 をリリース後テストの記録で不合格にし、人へ戻す | +| I8 | 契約の一覧 | 契約の一覧の項目の差分は、消したエントリポイントの項目の削除だけである(受け入れ条件 5) | 完了判定で止まる。`git diff origin/develop -- plugins/ndf/contracts/entrypoints/` に削除以外の差分があれば、その差分を戻す | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 関数の棚卸しを行った | 実装の担当(棚卸しのコマンド) | E2 の手順 | +| E2 | 候補ごとに呼び手を確かめた | 実装の担当(候補の名前の検索) | E3・E4 | +| E3 | 呼び手の無い関数を消し、参照を直した | 実装の担当 | 全体テスト・参照切れのチェック・構造チェック | +| E4 | 残す関数を残す関数の一覧へ理由つきで載せた | 実装の担当 | 棚卸しのコマンド(I1) | +| E5 | 子の課題の再現の手順を通した | 実装の担当(E3・E4 の後の worktree) | E6 | +| E6 | 子の課題を閉じたか残したかを記録した | 実装の担当(判定の節) | 承認ゲート 2 の承認資料 | +| E7 | 判定が「閉じた」の子の課題を閉じ、「残した」の閉じている課題を開き直した | リリース後テストの担当(本番の配布の後) | リリース後テスト(I6・I7 の確認)。閉じる操作・開き直す操作の失敗は #2369 のコメントへ記録する | + +E1〜E6 は要求のドメインイベントを引き継ぐ。E7 は、閉じる時期を本番の配布の後に置く決定 3 で足したイベントである。要求の E6 の「失敗したとき」の列(判定の行を「残した」にして理由に失敗を書く)は、この設計では使わない。閉じる操作の失敗の扱いは E7 へ移り、I6・I7 が持つ(置き換える理由は決定 3)。判定の節は実装だけが書き、E7 は判定の節を書き換えない。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 呼び手 | その関数かエントリポイントを呼ぶ、テストの外のファイル | 無し(要求の工程で追加済み) | +| 残す関数の一覧 | 呼び手が vulture に見えない関数を、理由つきで残す一覧。置き場は `scripts/ndf-vulture-keep.txt` | 意味の変更(置き場を書き足す) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 棚卸しのコマンドに残す関数の一覧を渡すと、呼び手の無い関数が 0 件になる | 次に棚卸しをする開発者 | +| F2 | 残した関数ごとに、残す理由と定義のあるファイルを一覧の 1 行で読める | 柱 P1〜P8 の担当・次に棚卸しをする開発者 | +| F3 | 子の課題 17 件の再現の結果・判定・理由を、#2369 の本文の 1 か所で読める | 承認ゲート 2 で承認する人・課題の棚卸しをする利用者 | +| F4 | 解消を確かめた子の課題が閉じ、閉じた理由をその課題のコメントで読める | 課題の一覧を読む利用者 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 残す関数の一覧(`scripts/ndf-vulture-keep.txt`、新規) | 呼び手が vulture に見えない関数の名前を 1 行に 1 つ持ち、行ごとに理由の種類・理由・定義のあるファイルを持つ | +| 呼び手の無い関数の定義(`plugins/ndf/scripts/**`・`plugins/ndf/skills/*/scripts/**` の該当の `.py`) | 消す。消した関数だけを使う定数と補助の関数、再エクスポートの `import`(例: `plugins/ndf/scripts/lib/monitor.py` の 137・161 行目)も一緒に消す | +| 消した関数だけを確かめるテスト(`plugins/ndf/scripts/tests/`・`plugins/ndf/skills/*/tests/` の該当の関数・ファイル) | 消す。ほかの関数も確かめるテストは、消した関数を確かめる部分だけを消す | +| 消した関数を名前で指す文書(`docs/specifications/*.md`・`plugins/ndf/scripts/lib/README.md`・Skill の本文・モジュールの docstring) | 消した名前の記述を消すか、残る関数の名前へ直す。`issues/old/`・`CHANGELOG.md`・`docs/archive/` は書いた時点の記録なので直さない | +| 確定仕様の「呼び手の無いもの」(`docs/specifications/ndf-entrypoint-contracts.md`) | 棚卸しのコマンド・残す関数の一覧の置き場・理由の種類・I3 の確かめ方を書き足す(非機能の条件「運用・保守性」) | +| 判定の節(#2369 の本文) | 子の課題 1 件に 1 行で、再現の手順の結果・判定・理由を持つ。`gh issue edit --body` で書き、`spec-copy.py write` で仕様のコピーを作り直す | +| 子の課題 17 件(GitHub の課題) | 判定が「閉じた」の課題は、本番の配布の後に閉じた理由のコメントを付けて閉じる。#1709(既に閉じている)はコメントだけを足し、再現したら開き直す | + +構成要素図(参照と書き込みの関係。CI と承認ゲートは図に含めない): + +```mermaid +graph TD + subgraph repo["リポジトリの変更"] + keep["残す関数の一覧"] + defs["呼び手の無い関数の定義"] + tests["消した関数のテスト"] + docs["関数名を指す文書"] + spec["確定仕様の呼び手の無いもの"] + end + subgraph gh["GitHub の課題"] + body["判定の節"] + child["子の課題 17 件"] + end + vulture["棚卸しのコマンド"] -->|読む| keep + vulture -->|読む| defs + spec -->|指す| keep + tests -->|呼ぶ| defs + docs -->|名前で指す| defs + body -->|1 行ずつ指す| child +``` + +## データ構造 + +### 残す関数の一覧の形 + +vulture の許可リストの形(`--make-whitelist` が出す形)で書く。vulture は渡されたファイルを拡張子に関わらず Python として読むため、`.txt` で置く。`.py` にすると、lint(`scripts/check-lint.sh`)が名前の行を未定義の名前(F821)として落とす。lint は git が追跡するすべての `.py` を ruff で検査する(2026-11-04、`uvx ruff check` で 2 行とも F821 を確かめた)。 + +```text +# 残す関数の一覧(vulture の許可リスト)。棚卸しのコマンドの対象の後ろに渡す。 +# 行の形: <名前の式> # <理由の種類>: <理由の本文>(<定義のあるファイル>) +# 理由の種類: 名前で呼ばれる / 枠組みが呼ぶ / 範囲の外から呼ばれる / テストの基盤が呼ぶ / 利用者が手で打つ / 呼び手を確かめられない +_.redirect_request # 枠組みが呼ぶ: urllib の HTTPRedirectHandler が 3xx の応答で呼ぶ(plugins/ndf/scripts/lib/claude_usage.py) +cmd_present # 名前で呼ばれる: main が globals()["cmd_" + 副命令] で引く(plugins/ndf/scripts/parallel.py) +``` + +| 項目 | 決まり | +| --- | --- | +| 名前の式 | 関数は名前だけ、メソッドは `_.<名前>`(`--make-whitelist` の形) | +| 理由の種類 | 上の 6 つのどれか | +| 定義のあるファイル | リポジトリの根からのパス。行番号は書かない(関数を足し引きするたびに古くなる) | +| 同じ名前が 2 つのファイルにある | ファイルごとに 1 行ずつ書く(例: `parallel_plan.py` の `_name` は同じファイルに 2 件あるので 1 行) | +| 先頭の `#` の行 | 一覧の説明。名前の行として数えない | + +この形の行は、`.txt` と `.py` のどちらで渡しても効く。vulture 2.16 で `redirect_request` が候補から消えることを確かめた(2026-11-04、`uvx vulture plugins/ndf/scripts/lib/claude_usage.py <一覧> --min-confidence 60`)。 + +### 判定の節の形 + +#2369 の本文の既存の見出しと列をそのまま使う。 + +| 列 | 書くこと | +| --- | --- | +| 課題 | `#<番号>` と題の要約 | +| 再現の手順の結果 | 打ったコマンドと、その結果の要点(終了コード・出力の該当の行・件数) | +| 判定 | `閉じた` / `残した` | +| 理由 | 閉じた: 解消を確かめた根拠(どの仕組みで、どの出力から)。残した: 理由の種類(再現した / 判断待ち / 手順を起こせない)と根拠 | + +1 行に収まらない出力は、行には要点だけを書き、全文は閉じた理由のコメント(閉じた課題)か、#2369 のコメント(残した課題)に置いてその URL を行に添える。 + +## 処理の流れ + +```mermaid +graph TD + a["E1 棚卸しのコマンドを打つ"] --> b["E2 候補ごとに名前で検索"] + b -->|呼び手が無い・テストだけ| c["E3 関数とテストと参照を消す"] + b -->|呼び手がある| d["E4 一覧へ理由つきで足す"] + c --> e["一覧を渡して棚卸しを打ち直す"] + d --> e + e -->|候補が残る| b + e -->|0 件| s["確定仕様の呼び手の無いものを書き足す"] + s --> f["全体テストと参照切れのチェック"] + f --> g["E5 子の課題の再現の手順を通す"] + g --> h["E6 判定の節へ 1 行ずつ書く"] + h --> i["承認ゲート 2 と本番の配布"] + i --> j["E7 閉じた判定の課題を閉じる"] +``` + +### E2 の手順(候補 1 件ごと) + +1. テストを除いてリポジトリ全体を名前で検索する(`git grep -nw <名前> -- . ':!*/tests/*' ':!tests/*'`)。定義のあるファイルの中の出現も見る +2. 当たりを読み、呼び出しの形か(`getattr` の文字列・名前の辞書の値・`globals()` の組み立て・`import` してからの呼び出し・枠組みの上書き)を決める。docstring・コメント・`issues/old/`・`CHANGELOG.md` の語の出現は呼び手に数えない +3. 呼び出しの形が見つかったら、理由の種類を決めて一覧へ足す(E4) +4. 見つからなければ消す(E3)。テストだけが呼ぶ関数も消す(要求の前提 3) +5. 名前を組み立てて呼ぶ形(`"cmd_" + …` のような前置きの連結)は名前の全体では当たらない。定義のあるファイルの `getattr` と `globals()` を先に見る + +### 候補の分類(試行) + +2026-11-04 の 70 件の候補を、名前の検索(テストを除くリポジトリ全体の `git grep -lw <名前>` と、定義のあるファイルの中の名前の出現)で分けた結果である。件数は参考値で、実装で同じ手順を打ち直して決める。 + +| 理由の種類(残すとき) | 呼ばれ方 | 件数 | 例 | +| --- | --- | --- | --- | +| 名前で呼ばれる | `getattr`・`globals()`・名前の辞書で関数を引く | 23 | `plugins/ndf/scripts/hook_lib/write_target.py` の `_stmt_*` 8 件と `andor`(`STMT_HANDLERS` を `getattr` で引く)、`plugins/ndf/scripts/parallel.py` の `cmd_*` 9 件(`globals()["cmd_" + …]`)、`plugins/ndf/skills/cross-review/scripts/drive.py` の 5 件(`act(d, "review_round")` などの `getattr`) | +| 枠組みが呼ぶ | ライブラリが上書きのメソッドや検証の関数を呼ぶ | 4 | `plugins/ndf/scripts/lib/claude_usage.py` の `redirect_request`(urllib の `HTTPRedirectHandler`)、`plugins/ndf/scripts/lib/parallel_plan.py` の `_name` 2 件と `_handoff`(pydantic の `field_validator`) | +| 範囲の外から呼ばれる | 棚卸しの対象の外(リポジトリ直下の `scripts/`)が `import` して呼ぶ | 1 | `plugins/ndf/scripts/lib/yamlio.py` の `front_matter_text`(`scripts/check-skill-frontmatter.py` の 287・329 行目) | +| テストの基盤が呼ぶ | `conftest.py` が全テストの後始末で呼ぶ | 1 | `plugins/ndf/scripts/lib/usage_ledger.py` の `marked_rows`(`plugins/ndf/conftest.py` の 51 行目) | +| (消す) | 呼び手が無いか、テストだけが呼ぶ | 41 | `plugins/ndf/scripts/lib/metrics.py` の `aggregate`・`format_report`(#1601)、`plugins/ndf/scripts/lib/md.py` の `fences`・`prose_lines` | + +`md.fences` は `scripts/check-markdown-links.py` に語として出る。出現は docstring の英文(9・134 行目)で、`md.fences` の呼び出しは無い。名前の検索の当たりは、呼び出しの形かを読んで決める。 + +残す理由の種類は、上の 4 つに「利用者が手で打つ」(試行では 0 件)と「呼び手を確かめられない」(要求の E2 の失敗の扱い)を足した 6 つにする。 + +### E3 の手順(消す関数 1 件ごと) + +1. 関数の定義を消す。消した関数だけが使っていた定数・補助の関数・`import` が新しく棚卸しに出たら、同じ手順で扱う +2. 再エクスポートの `import` と `__all__` の名前を消す +3. その関数だけを確かめるテストの関数を消す。ファイルの全部がその関数のテストなら、ファイルを消す +4. 文書の名前の参照を直す(構成要素の表の「消した関数を名前で指す文書」) +5. 例外リスト(`scripts/script-structure-allow/`・`scripts/contract-allow/`)がその名前を持っていれば項目を消す。2026-11-04 の候補 70 件の名前では、次の検索の当たりは 0 件だった: `grep -rlw <名前> scripts/script-structure-allow scripts/contract-allow plugins/ndf/contracts` + +### E5 の手順(子の課題 1 件ごと) + +1. 課題の本文を読み、再現の手順を取り出す。本文に手順が無ければ、本文の「何を見つけたか」の現象を起こすコマンドを組む(要求の前提 5) +2. E3・E4 を終えた実装の worktree で 1 回打つ +3. 現象が起きなければ「閉じた」、起きれば「残した(再現した)」。コマンドを組めなければ「残した(手順を起こせない)」と、組めない理由を書く +4. #1512・#1968 は、手順の結果だけを書いて「残した(判断待ち)」にし、待っている判断を理由に書く(要求の前提 6) +5. #1709 は既に閉じている。手順を通して現象が起きなければ判定を「閉じた」にし、E7 でコメントだけを足す。起きれば「残した(再現した)」にし、E7 で開き直す(要求の前提 7) + +子の課題の 2026-11-04 の状態(`gh issue view <番号> --json state,title,body`): + +| 課題 | 状態 | 本文に再現の語 | 題の要点 | +| --- | --- | --- | --- | +| #715 | 開 | あり | 実行する版を解決する層を 1 つにする | +| #1142 | 開 | 無し | スクリプト全体をモデル・クラスの設計からやり直す | +| #1512 | 開(`needs-decision`) | 無し | `.ndf/` の定義ファイルに作成・更新の定義が無い | +| #1601 | 開 | 無し | `metrics.aggregate` が本番から呼ばれない | +| #1694 | 開 | 無し | `runtime-plugin-validate.yml` の 9 ジョブがテストの宣言に無い | +| #1709 | 閉 | 無し | cross-refactoring が戦略の宣言をメインディレクトリから読む | +| #1756 | 開 | 無し | release がプラグインの置き場を名前で決め打つ | +| #1774 | 開 | 無し | CI の失敗の集合が 2 つのモジュールで別々にある | +| #1787 | 開 | 無し | 基盤待ち(終了コード 75)と merge-when-green の引数が正本に無い | +| #1968 | 開 | 無し | 宣言の無いリポジトリで指示書の既定が食い違う | +| #2010 | 開 | 無し | `.ndf/project.json` の計測値が実行のたびに書き戻される | +| #2011 | 開 | 無し | テストのコマンドを 2 か所が持つ | +| #2054 | 開 | あり | サブモジュールのリポジトリでメインディレクトリを取り違える | +| #2060 | 開 | 無し | release-steps.md に範囲の PR と承認の材料の打ち方が無い | +| #2097 | 開 | あり | release のテンプレートが本番のブランチを既定ブランチへ後退させない | +| #2235 | 開 | 無し | require-groups の例外リストに残した 11 本 | +| #2317 | 開 | 無し | 全体テストの CI 待ちが外したチェックを待ち続ける | + +#1601 は、本文の「直し方」が E3 で消す関数(`metrics.aggregate`・`format_report`)と一致する。E3 の後の worktree では関数が無いため、本文の確かめ方(`import metrics` の検索が 0 件であること)は成り立たなくなる。そのため手順は「`metrics.aggregate` の定義とテストが無いこと」を確かめる形で組む。 + +## 非機能の実現方式 + +| 大項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 運用・保守性 | 残す関数の一覧と棚卸しのコマンドを、確定仕様の「呼び手の無いもの」から辿れる。次に棚卸しをする人が、同じコマンドで同じ結果を得られる | 確定仕様の「呼び手の無いもの」に、一覧を渡した棚卸しのコマンドの全文・一覧の置き場(`scripts/ndf-vulture-keep.txt`)・理由の種類の 6 つ・I3 の確かめ方(行のパスのファイルに名前の `def` があるか)を書く。vulture の版で結果が変わりうるため、確かめた版(2.16)も書く | + +## 決定の記録 + +### 決定 1: 配布物に開発用のツールを混ぜないため、残す関数の一覧をリポジトリ直下の `scripts/ndf-vulture-keep.txt` に置く + +残す関数の一覧は、このリポジトリで棚卸しをする開発者だけが使い、CI も配布先のリポジトリも読まない(確定仕様の「呼び手の無いもの」)。`plugins/ndf/contracts/` は配布物に含まれ、エントリポイント・受け渡しのファイル・設定・環境変数の項目だけを持つ形として構造チェックが読む。関数の項目を足すと、契約の一覧の形と配布物の両方が変わる。リポジトリ直下の `scripts/` には、構造チェックと `check-script-structure.py` の例外リストが既にある(`scripts/contract-allow/`・`scripts/script-structure-allow/`)。開発用のツールの置き場として揃う。 + +`plugins/ndf/contracts/` の下に置く形は、上の理由で採らない。拡張子は `.txt` にする。`.py` は lint(`scripts/check-lint.sh`)が名前の行を未定義の名前(F821)として落とすため採らない。 + +根拠: Value 6 / Value 8(MVV 版 5) + +### 決定 2: 一覧の古い行が呼び手の無い関数を隠さないよう、行ごとに定義のあるファイルを書き、そのファイルに名前の `def` があることを確かめる + +vulture の許可リストは名前だけで照らすため、行は同じ名前のすべての関数を候補から外す。`_name` のように短い名前の行が残ると、別のファイルに増えた同じ名前の呼び手の無い関数が棚卸しに出なくなる。行にファイルを書けば、完了判定で 1 行ずつ `def` の有無を確かめられ、関数を消したときに行も消す手掛かりになる。 + +確かめ方はスクリプトにせず、完了判定と確定仕様に 1 行のコマンドとして書く。棚卸しは 1 回だけ行う決まり(確定仕様)で、常設の検査を足すと決まりと食い違う。行番号は関数の足し引きで古くなるため書かない。 + +根拠: Value 3 / Value 5(MVV 版 5) + +### 決定 3: 承認する人が判定を見てから課題が閉じるよう、判定は実装で書き、閉じる操作は本番の配布の後のリリース後テストで行う + +要求の境界は、子の課題を閉じることを「確認してから行う(承認ゲート 2 で判定の節を見る)」に置いている。実装で閉じると、承認する人が判定を見る前に課題が閉じる。#1601 は、この変更で関数を消して初めて解消するため、本番へ届く前に閉じると、正式版にまだ残っている不具合が課題の一覧から消える。 + +判定の節の「判定」の列は、実装の時点で決めた結論(閉じた / 残した)を書く。承認ゲート 2 から閉じる操作までの間は、「閉じた」の行の課題が開いている。受け入れ条件 7 はリリース後テストで確かめる。 + +この決定で、要求の E6 の「失敗したとき」の列(閉じる操作が失敗した課題は、判定の行を「残した」にして理由に失敗を書く)を置き換える。閉じる操作は本番の配布の後に行うため、その時点の判定の節は承認ゲート 2 で承認された内容である。リリース後テストが判定の節を書き換えると、承認されていない判定が本文に残り、判定の節の持ち主も実装とリリース後テストの 2 者になる。閉じる操作・開き直す操作の失敗は #2369 のコメントへ記録し、やり直しは 1 回までとする(I6・I7)。要求の写し(`issues/issue-2369-requirements.md`)は課題の本文から作り直すものなので、この設計では直さない。 + +実装の Pull Request のマージの前に閉じる形は、上の 2 つの理由で採らない。 + +根拠: Value 1 / Value 2(MVV 版 5) + +### 決定 4: 呼ばれ方の判断を候補ごとに 1 回で済ませるため、残す理由を 6 つの種類に固定する + +理由を自由な文にすると、受け入れ条件 3(理由の無い行が 0 件)を機械で確かめられず、似た理由が別の言い方で並ぶ。試行で見つかった呼ばれ方は 4 つある(名前で呼ばれる・枠組みが呼ぶ・範囲の外から呼ばれる・テストの基盤が呼ぶ)。これに、要求が挙げる「利用者が手で打つ」と、要求の E2 の失敗の扱いの「呼び手を確かめられない」を足して 6 つにする。種類の後ろに、呼び出しの場所を示す本文を添える。 + +当たらない呼ばれ方が見つかったら、種類を足して確定仕様の一覧を直す。 + +根拠: Value 8(MVV 版 5) + +### 決定 5: 名前を組み立てて呼ぶ関数を消さないよう、名前で呼ぶ関数の群は群ごとに残す + +`parallel.py` の `cmd_*` と `write_target.py` の `_stmt_*` は、副命令と構文の種類の名前から関数名を組み立てて引く。群のうち 1 つでも呼ばれない関数を消すと、その副命令を打ったときに初めて失敗する。群の 1 件ごとに、組み立ての元(副命令の一覧・`STMT_HANDLERS` の値)にその名前があるかを確かめ、元にある関数は残し、元に無い関数だけを消す。 + +群をまとめて許可リストのワイルドカードで外す形は採らない。元から外れた関数も一緒に隠れるためである。 + +根拠: Value 7(MVV 版 5) + +## テスト設計 + +この変更は新しい振る舞いを足さない。受け入れ条件は、既存のテストとチェックの終了コードと、1 回のコマンドの結果で縛る。消した関数だけを確かめるテストは一緒に消す(要求の前提 3)。 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I4 | `python3 scripts/check-ndf-contracts.py` が終了コード 0 で `violations: 0` を返す | 消した文書の記述がエントリポイントの呼び手の記述を含んでいて、項目の `callers` のファイルがその名前を含まなくなると `caller-stale` で落ちる | +| 受け入れ条件 2・I1 | 一覧を渡した棚卸しのコマンドの出力に `unused function`・`unused method` が 0 行 | 一覧から 1 行消すと、その名前が候補として出る | +| 受け入れ条件 3・I2 | 一覧の `#` で始まらない行のうち、`# <6 つの種類のどれか>: ` を持たない行が 0 件 | 行から理由を消すと 1 件になる | +| I3 | 一覧の名前の行ごとに、括弧のパスのファイルに `def <名前>` がある | 消した関数の行を一覧に残すと、`def` の無い行として出る | +| 受け入れ条件 4 | 全体テスト(`uv run --frozen --project . --all-extras pytest . -q -n 4`)・`python3 scripts/check-markdown-links.py`・`python3 scripts/check-cross-skill-refs.py` が終了コード 0 | 名前で呼ばれる関数を消すと、その副命令のテストが落ちる。消した関数を指す文書のリンクの見出しを消すと、参照切れのチェックが落ちる | +| 受け入れ条件 5・I8 | `git diff --stat origin/develop -- plugins/ndf/contracts/entrypoints/` が空か、消したエントリポイントの項目の削除だけ。全体テストが develop の CI のジョブ `Python tests` と同じく通る | エントリポイントの引数・出力を変えると、構造チェックか、そのエントリポイントのテストが落ちる | +| 受け入れ条件 6・I5 | 判定の節の表の行が 17 行で、課題の列の番号の集合が子の課題 17 件と一致する | 1 件を書き忘れると 16 行になる | +| 受け入れ条件 7・I7 | 「閉じた」の行の課題ごとに `gh issue view <番号> --json state,comments` が `CLOSED` で、判定の理由を書いたコメントを持つ(リリース後テスト) | 閉じ忘れると `OPEN` が出る | +| 受け入れ条件 8・I6 | 理由の列が「再現した」「判断待ち」「手順を起こせない」のどれかで始まる(完了判定)。「残した」の行の課題が本番の配布の後に `OPEN` である(リリース後テスト) | 理由の種類を書き忘れると、どれでも始まらない行として出る | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2369 | 実装する | — | `scripts/ndf-vulture-keep.txt`、`plugins/ndf/scripts/`、`plugins/ndf/skills/`、`docs/specifications/ndf-entrypoint-contracts.md`、`docs/specifications/` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 候補の分類の件数 | 「候補の分類(試行)」の件数は名前の検索の当たりから分けた試行の値で、41 件の消す候補の中に、呼び出しの形を読み落とした関数が残っている可能性がある。実装の E2 で 1 件ずつ読み直して決める。消した後の全体テストが落ちれば、その関数を一覧へ戻す | +| 消す候補の中の公開の API | `plugins/ndf/scripts/lib/schema.py` の `lenient_choice` のように、モジュールの docstring が書き方の例として示す関数がある。テストの外の呼び手が無ければ要求の前提 3 に従って消し、docstring の例も消す。後続の柱で要るときは git の履歴から戻す | +| `durable.step` の関数 | `plugins/ndf/scripts/lib/parallel_run.py` の `step_render` は `@durable.step(name="parallel.render")` で登録される。登録の名前で呼ばれる経路があるか(再開のときに名前で引くか)は未確認。E2 で `durable.py` の引き方を読んで決める | +| 子の課題の再現の手順 | 17 件のうち本文に再現の語を持つのは 3 件(#715・#2054・#2097)。残りの 14 件は本文の現象から手順を組む。組めない課題が何件になるかは実装で決まる | +| 判定から閉じるまでの間 | 決定 3 により、承認ゲート 2 から本番の配布の後のリリース後テストまで、「閉じた」の行の課題が開いたままになる。この間に課題の棚卸しが走ると、閉じる予定の課題が着手の候補に並ぶ | diff --git a/issues/issue-2369-requirements.md b/issues/issue-2369-requirements.md new file mode 100644 index 000000000..de6d9e926 --- /dev/null +++ b/issues/issue-2369-requirements.md @@ -0,0 +1,166 @@ +# #2369: 契約の棚卸しの残り: 呼び手の無い関数の片付けと子の課題 17 件の判定(#2336 の受け入れ条件 8・10) + +正は課題の本文(#2369)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 経緯 + +開発ループ lroot の周回 2(スプリント lroot-2、#2335 #2336)で ndf v10.27.7 を本番へ出した。リリース後テスト(記録: https://github.com/devbasex/ai-plugins/pull/2366#issuecomment-6561546564 )で、#2336 の受け入れ条件のうち 2 件が不合格だった。どちらも Pull Request #2362 が「この変更の範囲では行っていない」と書いていた残りで、配布した 10.27.7 の不具合ではない。 + +#2336 はスプリントのまとめで閉じるため、残りをこの課題へ移す(開発ループの規則「上限で打ち切った品質の工程の残りは Issue へ起こす」)。 + +## 依頼(原文) + +> 開発ループ lroot の周回 2(スプリント lroot-2、#2335 #2336)で ndf v10.27.7 を本番へ出した。リリース後テスト(記録: https://github.com/devbasex/ai-plugins/pull/2366#issuecomment-6561546564 )で、#2336 の受け入れ条件のうち 2 件が不合格だった。どちらも Pull Request #2362 が「この変更の範囲では行っていない」と書いていた残りで、配布した 10.27.7 の不具合ではない。 +> +> #2336 はスプリントのまとめで閉じるため、残りをこの課題へ移す(開発ループの規則「上限で打ち切った品質の工程の残りは Issue へ起こす」)。 +> +> 1. (#2336 の条件 8)棚卸しで呼び手が 0 と分かったエントリポイントと最上位の関数が消えているか、残す理由(外部から呼ばれる・利用者が手で打つ など)とともに契約の一覧に載る。消した後に全体テストと参照切れのチェック(`scripts/check-markdown-links.py`・`scripts/check-cross-skill-refs.py`)が通る +> 2. (#2336 の条件 10)子の課題 17 件(#715 #1142 #1512 #1601 #1694 #1709 #1756 #1774 #1787 #1968 #2010 #2011 #2054 #2060 #2097 #2235 #2317)それぞれについて、再現の手順を通した結果と、閉じた(解消を確かめた)か残した(理由つき)かが、この課題の本文の「子の課題の判定」の節に 1 行ずつある。閉じた課題には閉じた理由のコメントがある +> 3. 片付けの外で、エントリポイントの外から見える振る舞い(引数・出力・終了コード・書くファイル)が変わらない。全体テストが変更の前と同じく通る + +(書き手: 起票者、日付: 2026-11-02。リリース後テストの残りを移すために AI が書いた本文で、利用者の指示の原文としては扱わない) + +利用者の指示の原文は、#2335 の「調べ方」に引用された次の 4 文である。この課題はその柱 P0(#2336)の残りに当たる。 + +> 「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」「内部データ構造と出力のフォーマットの不統一の影響も大きそう」「NDF は Skill の Script 化から発展してきたため、内部データ構造や入出力契約がほぼ整理されていない」「出力だけでなく入力もそろえるべき」 + +(書き手: 利用者、日付: 2026-11-01。#2335 の本文が引用したもの) + +> 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-04。この要求の工程の進め方の指示で、要求の中身ではない) + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文(上の 4 文)に無く、起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 呼び手が 0 のエントリポイントと最上位の関数を消すか、残す理由を記録する | #2336 の起票者の推定 S8(#2336 の本文「呼ばれない死んだコードもここで片付ける」) | 受け入れ条件 1〜4 | +| S2 | 関数を残す理由の置き場は「契約の一覧」 | この課題の受け入れ条件 1 の原文 | 採らない。契約の一覧(`plugins/ndf/contracts/`)はエントリポイント・受け渡しのファイル・設定・環境変数の項目だけを持ち、関数の項目を持たない(確定仕様 `docs/specifications/ndf-entrypoint-contracts.md` の 158 行目の項目の形)。エントリポイントは契約の一覧の `callers` / `external` で記録し、関数は前提 3 の置き場で記録する(受け入れ条件 2・3) | +| S3 | 子の課題 17 件は 1 件ずつ直さず、再現の手順を 1 回ずつ通して閉じるか残すかを決める | #2336 の起票者の推定 S9・#2335 の進め方 4 | 受け入れ条件 6〜8・含まない | +| S4 | 閉じた課題に閉じた理由のコメントを付ける | この課題の受け入れ条件 2 の原文 | 受け入れ条件 7 | +| S5 | 片付けの外で外から見える振る舞いを変えない | #2336 の起票者の推定 S3 | 受け入れ条件 5 | +| S6 | 関数の棚卸しは `uvx vulture` で 1 回だけ行い、CI の常設の検査にしない | 確定仕様 `docs/specifications/ndf-entrypoint-contracts.md` の「呼び手の無いもの」(314〜315 行目) | 前提 2・含まない | + +## 目的 + +- 呼ばれない関数とエントリポイントを NDF のスクリプトから消し、柱 P1〜P8(マイルストーン 29・30)が実行の核へ移す対象を、実際に使われるものだけにする +- #2336 の柱 P0 の仕組み(契約の一覧と構造チェック)で解消したかを、子の課題 17 件それぞれについて確かめ、解消したものを閉じて課題の一覧を実態に合わせる + +## 前提 + +- 前提 1: エントリポイントの呼び手は、#2336 で入れた構造チェック(`python3 scripts/check-ndf-contracts.py`)が `callers` / `external` で既に確かめている(2026-11-04 の実行で `violations: 0`、エントリポイント 79 本、`external` を持つ項目 1 件)。この課題は、構造チェックが違反 0 を返し続けることで受け入れ条件 1 のエントリポイントの部分を満たす +- 前提 2: 「最上位の関数」は、`plugins/ndf/scripts` と `plugins/ndf/skills/*/scripts` の下の `.py` のうち、テスト(`tests/`)と実験版(`experimental/`)を除くファイルで、モジュールの最上位に定義された関数とクラスのメソッドを指す。棚卸しは `uvx vulture <対象> --exclude '*/tests/*,*/experimental/*' --min-confidence 60` で行う(確定仕様の「呼び手の無いもの」)。2026-11-04 の試行で `unused function` は 51 件(例: `plugins/ndf/scripts/lib/md.py:81` の `fences`)。数は参考値で、受け入れ条件は棚卸しのコマンドの結果で判定する +- 前提 3: 呼び手はテストを除いて数える(確定仕様の「呼び手の無いもの」と同じ)。テストだけが呼ぶ関数は呼び手 0 とし、消すときはその関数だけを確かめるテストも消す。残す関数は、理由を 1 行ずつ持つ vulture の残す関数の一覧(`--make-whitelist` の形)に載せる。置き場は設計が決める +- 前提 4: vulture が拾わない呼ばれ方(`getattr`・副命令の辞書引き・hook の JSON からの文字列の指定・別のプラグインからの import)は、候補ごとに名前でリポジトリを検索して確かめ、呼び手が見つかった候補は残す関数の一覧へ理由つきで載せる +- 前提 5: 子の課題の「再現の手順」は、各課題の本文にある手順を使う。本文に手順が無い課題は、本文の現象を起こすコマンドを起こしてから通す。起こせない課題は、理由を添えて残す +- 前提 6: 判断待ちの課題(#1512・#1968)は、再現の手順を通した結果だけを書き、判断を待つ理由で残す。この課題で判断を代わりに下さない +- 前提 7: 既に閉じている子の課題(#1709。2026-11-04 に閉じ、コメント 0 件)も判定の行を書き、閉じた理由のコメントが無ければ足す。判定で再現した課題は開き直す +- 前提 8: 「全体テストが変更の前と同じく通る」の変更の前は、作業の起点の develop の CI のジョブ `Python tests` の結果を指す(2026-11-04 14:21 UTC の develop 62553bdf4 で success) + +## 対象範囲 + +含む: +- 前提 2 の棚卸しのコマンドが拾う関数を、消すか、理由とともに残す関数の一覧へ載せること +- 呼び手が 0 のエントリポイントがあれば消すこと(2026-11-04 時点の構造チェックでは 0 件) +- 消した関数・エントリポイントを指す Skill の本文・README・確定仕様の参照を直すこと +- 子の課題 17 件の再現の手順を 1 回ずつ通し、この本文の「子の課題の判定」の節に 1 行ずつ書くこと +- 解消を確かめた子の課題を閉じ、閉じた理由のコメントを付けること + +含まない: +- 子の課題を 1 件ずつ直す変更(再現したものは理由を添えて残す) +- vulture を CI の常設の検査にすること(確定仕様の「呼び手の無いもの」が採らないと決めている) +- `plugins/ndf` の外(`playwright-kit`・`mcp-*`・`factory/`・リポジトリ直下の `scripts/`)の関数の棚卸し +- 呼び手のある関数の統合・名前の変更・移動(柱 P1〜P8 の範囲) +- 構造チェックの例外リスト 183 件を減らすこと(柱 P1〜P8 の範囲) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 関数の棚卸しを行った | 実装者が前提 2 の棚卸しのコマンドを打つ | vulture が構文の誤りで読めないファイルは、棚卸しの結果に読めなかったファイルとして残り、受け入れ条件 2 で落ちる | — | +| E2 | 候補ごとに呼び手を確かめた | E1 の結果 | 呼び手を決められない候補は、残す関数の一覧へ「呼び手を確かめられない」の理由で載せて残す | E1 | +| E3 | 呼び手の無い関数を消し、参照を直した | E2 で呼び手 0 と決まった | 消した後に呼び手が見つかれば、全体テストか参照切れのチェックが落ちる(受け入れ条件 4) | E2 | +| E4 | 残す関数を残す関数の一覧へ理由つきで載せた | E2 で呼び手が見つかったか、外から呼ばれると決まった | 理由の無い行は受け入れ条件 3 で落ちる | E2 | +| E5 | 子の課題の再現の手順を通した | E3・E4 の変更の木で、実装者が課題ごとに手順を打つ | 手順を起こせない課題は理由を添えて残す(前提 5) | E3・E4 | +| E6 | 子の課題を閉じたか残したかを記録した | E5 の結果 | 閉じる操作が失敗した課題は、判定の行を「残した」にして理由に失敗を書く | E5 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| エントリポイント | 直に起動されるスクリプト。数え上げの規則は確定仕様 `docs/specifications/ndf-entrypoint-contracts.md` にある | +| 契約の一覧 | `plugins/ndf/contracts/` にある、エントリポイントごとの入力・出力・書く状態・終了コードと、受け渡しのファイル・設定・環境変数の宣言 | +| 構造チェック | `scripts/check-ndf-contracts.py`。契約の一覧の外を落とす | +| 呼び手 | その関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト) | +| 残す関数の一覧 | 呼び手が vulture に見えない関数を、理由つきで残す一覧(前提 3) | + +## 受け入れ条件 + +- [ ] 1. `python3 scripts/check-ndf-contracts.py` が終了コード 0 で `violations: 0` を返す。呼び手の無いエントリポイント(`no-caller`)と、呼び手のファイルが名前を含まない項目(`caller-stale`)が 0 件である +- [ ] 2. 前提 2 の棚卸しのコマンドに残す関数の一覧を渡して打つと、`unused function` と `unused method` が 0 件になる +- [ ] 3. 残す関数の一覧の各行に、残す理由(外部から呼ばれる・利用者が手で打つ・vulture に見えない呼ばれ方の種類など)がある。理由の無い行が 0 件である +- [ ] 4. 消した後に、全体テスト(`uv run --frozen --project . --all-extras pytest . -q -n 4`)と参照切れのチェック(`python3 scripts/check-markdown-links.py`・`python3 scripts/check-cross-skill-refs.py`)が終了コード 0 で通る +- [ ] 5. 片付けの外で、エントリポイントの外から見える振る舞い(引数・出力・終了コード・書くファイル)が変わらない。契約の一覧の項目(`plugins/ndf/contracts/entrypoints/`)の差分が、消したエントリポイントの項目の削除だけである(退行しない) + 前提の確認: `python3 scripts/check-ndf-contracts.py` → `violations: 0`・エントリポイント 79 本(2026-11-04、develop 729dcbe40) +- [ ] 6. 子の課題 17 件(#715 #1142 #1512 #1601 #1694 #1709 #1756 #1774 #1787 #1968 #2010 #2011 #2054 #2060 #2097 #2235 #2317)それぞれについて、この本文の「子の課題の判定」の節に、再現の手順を通した結果・判定(閉じた / 残した)・理由が 1 行ずつある。行の数が 17 である +- [ ] 7. 判定が「閉じた」の課題は GitHub 上で閉じていて、判定の理由と、通した手順の結果を書いたコメントを持つ +- [ ] 8. 判定が「残した」の課題は開いたままで、理由に「再現した」「判断待ち」「手順を起こせない」のどれかと、その根拠(打ったコマンドと結果、または待っている判断)がある + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 残す関数の一覧と棚卸しのコマンドを、確定仕様 `docs/specifications/ndf-entrypoint-contracts.md` の「呼び手の無いもの」から辿れる。次に棚卸しをする人が、同じコマンドで同じ結果を得られる | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。消すのは呼び手の無い関数(と、あれば呼び手の無いエントリポイント)だけで、エントリポイントのパス・副命令・引数・出力は変わらない | +| データ | 無し。`.ndf/` の設定と受け渡しのファイルの形は変わらない | +| 既存の振る舞い | 変わらない(受け入れ条件 5)。消した関数だけを確かめていたテストが減る | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析・構造 | `python3 scripts/check-ndf-contracts.py`・`python3 scripts/check-script-structure.py`・`python3 scripts/check-markdown-links.py`・`python3 scripts/check-cross-skill-refs.py` | +| 関数の棚卸し | 前提 2 の `uvx vulture` に残す関数の一覧を渡して打ち、`unused function`・`unused method` の件数を数える | +| 子の課題の判定 | 各課題の再現の手順を打ち、結果を「子の課題の判定」の節へ書く。`gh issue view <番号> --json state,comments` で閉じた課題の状態とコメントを確かめる | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。対象は `plugins/ndf/` の中だけ | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。消すときに周りの書き換えを広げない | +| テスト戦略 | 関数の削除は全体テストで退行を見る。消した関数だけを確かめるテストは一緒に消す(前提 3)。`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 全体テストと参照切れのチェックの実行、消した名前のリポジトリ全体の検索 | +| 確認してから行う | 子の課題を閉じること(承認ゲート 2 で判定の節を見る) | +| 行わない | 子の課題の修正、呼び手のある関数の統合と改名、例外リストの削減、`plugins/ndf` の外の棚卸し | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 残す関数の一覧の置き場(`plugins/ndf/contracts/` の下か、リポジトリ直下の `scripts/` の下か) | 設計 | 設計の承認(承認ゲート 1) | +| 子の課題を閉じる時期(実装の Pull Request のマージの前か、本番の配布の後か) | 設計 | 設計の承認(承認ゲート 1) | + +## 子の課題の判定 + +| 課題 | 再現の手順の結果 | 判定 | 理由 | +| --- | --- | --- | --- | + +## 参照 + +- 元の課題: #2336(契約の一覧と構造チェック)、大方針 #2335 +- リリース後テストの記録: https://github.com/devbasex/ai-plugins/pull/2366#issuecomment-6561546564 +- 確定仕様: `docs/specifications/ndf-entrypoint-contracts.md`(呼び手の扱いは「呼び手の無いもの」) diff --git a/issues/issue-2384-design-contracts.md b/issues/issue-2384-design-contracts.md new file mode 100644 index 000000000..88efeb8e3 --- /dev/null +++ b/issues/issue-2384-design-contracts.md @@ -0,0 +1,197 @@ +# #2384 の設計: 機能から起こした契約の操作の一覧と形 + +[設計文書の本体](issue-2384-design.md) の「入出力の契約」の詳細である。正は `factory/contracts/openapi/` の定義で、この文書は実装の前に形を決めるための一覧である。 + +## 操作の種類と形 + +利用者向けの操作は、次の 6 つの種類のどれかに当たる。種類は経路の形とメソッドから決まり、操作の表の生成が `kind` として書く。 + +| 種類 | メソッド | 入力 | 成功の応答 | 要素の数の検査 | +| --- | --- | --- | --- | --- | +| 一覧(`list`) | `GET` | 条件の query(0 個以上) | 200 と値の配列 | しない | +| 指定の取得(`select`) | `GET` | 鍵の query を繰り返す(`?sprint_run_id=a&sprint_run_id=b`) | 200 と要素の結果の配列 | 鍵の値の数 | +| 作成(`create`) | `POST` | 本文の配列 | 200 と要素の結果の配列 | 本文の要素の数 | +| 更新(`update`) | `PATCH` / `PUT` | 本文の配列 | 200 と要素の結果の配列 | 本文の要素の数 | +| 削除(`delete`) | `DELETE` | 鍵の query を繰り返す | 200 と要素の結果の配列 | 鍵の値の数 | +| 操作(`action`) | `POST`(経路の末尾が動詞) | 本文の配列 | 200 と要素の結果の配列 | 本文の要素の数 | + +- **経路の変数はアカウントの ID(`{accountId}`)だけ**である。アカウントに属するものは `/accounts/{accountId}/<もの>` に置く。アカウントそのものと利用者に属するもの(`/me`・`/accounts`・`/teams`・`/delegations`)は頭に置く +- **鍵の query の名前は、要素の鍵の項目の名前と同じ**にする。本文の要素と query の鍵が同じ語で読める +- **鍵を持たない操作**(アカウントにつき 1 つしか無いもの。キーの持ち込み・取り込みの候補)は、要素が必ず 1 個である。2 個以上は同じ鍵の重複として 400 になる +- **一覧の応答を要素の結果で包まない。** 条件で集めた値は、すべて読める値だけで組むためである。条件が指すアカウントを解決できなければ、要求全体が 403 になる +- **成功の状態は、要素がすべて失敗しても 200 にする。** 要求全体を断るのは下の「要求全体の誤り」だけである + +契約の各操作は、OpenAPI の拡張に次の 3 つを持つ。 + +| 拡張 | 持つ操作 | 値 | +| --- | --- | --- | +| `x-ndf-access` | 利用者向けのすべての操作 | `read` / `write` / `human`(操作の扱い)。ランナー向けの経路と受け口は持たない。持つかどうかで利用者向けかを判定する | +| `x-ndf-keys` | 要素を持つ種類(一覧を除く 5 つ) | 要素の鍵の項目の名前の配列。鍵を持たない操作は空の配列 | +| `x-ndf-id` | 新しい識別子を作る作成 | 成功の値の中の、作った識別子の項目の名前 | + +## 要素の結果 + +```json +[ + { "ok": true, "value": { "sprint_run_id": "sr_1", "state": "running" } }, + { "ok": false, "status": 403, "error": { "code": "forbidden", "message": "このアカウントを見る権限がありません" } }, + { "ok": true, "value": { "sprint_run_id": "sr_3", "state": "running" } } +] +``` + +- 応答の配列は、入力(鍵の query の並び・本文の要素の並び)と同じ順・同じ数である +- 失敗の要素は、今の誤りの状態(400・403・404・409・422・503)と本文(`code`・`message`・`details`)をそのまま持つ。画面は状態で文言を分けているため、状態を要素の結果に残す +- 認可の無い要素は `forbidden` だけを持ち、その要素のデータを含めない。無い実行と他人の実行を同じ本文の 403 にする今の規則は、要素の単位でも変えない。この規則は、スプリントの画面の確定仕様( [ndf-factory-sprint-views.md](../docs/specifications/ndf-factory-sprint-views.md) )の I12 である +- 要素の形の誤り(席の種類が知らない値など)は、その要素だけを 400 の結果にする + +## 要求全体の誤り + +| 状態 | `code` | いつ | +| --- | --- | --- | +| 400 | `invalid_request` | 本文が JSON の配列でない | +| 400 | `empty_items` | 要素が 0 個 | +| 400 | `too_many_items` | 要素が 101 個以上。`details.limit` に 100 を入れる | +| 400 | `duplicate_key` | 同じ鍵の要素が 2 つある。`details.key` に鍵の項目の名前を入れる | +| 401 | `unauthorized` | 資格情報が無い・無効・期限切れ・取り消し済み | +| 403 | `forbidden` | 経路のアカウントを解決できない(今の 403 のまま) | +| 403 | `human_only` | 委任の資格情報で、扱い `human` の操作を呼んだ | +| 403 | `scope_insufficient` | 範囲が読み取りだけの委任の資格情報で、扱い `write` の操作を呼んだ | +| 503 | `audit_unavailable` | 操作の記録を追記できなかった | + +検査の順は、資格情報 → 操作の扱い(`human_only`・`scope_insufficient`)→ 要素の数と重複 → 経路のアカウントの解決である(本体の「処理の流れ」)。 + +## 操作の一覧 + +鍵の列の `+` は複合の鍵、`—` は鍵を持たない操作を表す。画面の列は `factory/web/src/api.ts` の `Api` のメソッドで、`—` は画面に無い操作である。 + +### 扱いごとの数 + +| 扱い | 操作 | 数 | +| --- | --- | --- | +| read | 1・3・8・9・11・15・20・21・22・23・26・27・32・33・35 | 15 | +| write | 4・10・12・13・14・18・19・24・25・28・29・30 | 12 | +| human | 2・5・6・7・16・17・31・34・36・37 | 10 | + +画面の `Api` のメソッドは 34(今の 31 と委任の 3)で、画面に無い操作は 32〜34 の 3 つである。 + +### アカウント(`accounts.yaml`) + +| # | operationId | 種類 | メソッドと経路 | 要素か条件 | 鍵 | 扱い | 画面 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 1 | `getMe` | 一覧 | `GET /me` | — | — | read | `me` | +| 2 | `createTeam` | 作成 | `POST /teams` | `{name, seat_type, idempotency_key}` | `idempotency_key` | human | `createTeam` | +| 3 | `getAccount` | 指定の取得 | `GET /accounts` | `?account_id=` | `account_id` | read | `account` | +| 4 | `setExecutionMode` | 更新 | `PATCH /accounts` | `{account_id, execution_mode}` | `account_id` | write | `setExecutionMode` | +| 5 | `addSeat` | 作成 | `POST /accounts/{accountId}/seats` | `{user_id, seat_type}` | `user_id` | human | `addSeat` | +| 6 | `changeSeat` | 更新 | `PATCH /accounts/{accountId}/seats` | `{user_id, seat_type}` | `user_id` | human | `changeSeat` | +| 7 | `removeSeat` | 削除 | `DELETE /accounts/{accountId}/seats` | `?user_id=` | `user_id` | human | `removeSeat` | + +`getAccount` と `setExecutionMode` は、要素ごとにアカウントを解決する(経路にアカウントを持たないため)。解決できない要素は `forbidden` の結果になる。`createTeam` の成功の値は今のアカウントの詳細で、`x-ndf-id` は `account_id` である。 + +### 取り込み・キー・スプリント(`runs.yaml`) + +| # | operationId | 種類 | メソッドと経路 | 要素か条件 | 鍵 | 扱い | 画面 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 8 | `getInstallUrl` | 一覧 | `GET /accounts/{accountId}/github/install-url` | — | — | read | `installUrl` | +| 9 | `getAuthorizeUrl` | 一覧 | `GET /accounts/{accountId}/github/authorize-url` | — | — | read | `authorizeUrl` | +| 10 | `postRepositoryCandidates` | 操作 | `POST /accounts/{accountId}/github/candidates` | `{code, state}` | — | write | `repositoryCandidates` | +| 11 | `listRepositories` | 一覧 | `GET /accounts/{accountId}/repositories` | — | — | read | `repositories` | +| 12 | `registerRepository` | 作成 | `POST /accounts/{accountId}/repositories` | `{proof, repository_id}` | `repository_id` | write | `registerRepository` | +| 13 | `updateRepository` | 更新 | `PATCH /accounts/{accountId}/repositories` | `{repository_id, max_concurrent_runs}` | `repository_id` | write | `updateRepository` | +| 14 | `removeRepository` | 削除 | `DELETE /accounts/{accountId}/repositories` | `?repository_id=` | `repository_id` | write | `removeRepository` | +| 15 | `getModelCredential` | 一覧 | `GET /accounts/{accountId}/model-credential` | — | — | read | `modelCredential` | +| 16 | `putModelCredential` | 更新 | `PUT /accounts/{accountId}/model-credential` | `{kind, api_key}` か `{kind, role_arn, region}` | — | human | `saveModelCredential` | +| 17 | `deleteModelCredential` | 削除 | `DELETE /accounts/{accountId}/model-credential` | — | — | human | `deleteModelCredential` | +| 18 | `postIssueCandidates` | 操作 | `POST /accounts/{accountId}/issue-candidates` | `{repository_id, exclude}` | `repository_id` | write | `issueCandidates` | +| 19 | `startSprintRun` | 作成 | `POST /accounts/{accountId}/sprint-runs` | `{repository_id, issues, idempotency_key}` | `idempotency_key` | write | `startSprintRun` | +| 20 | `listSprintRuns` | 一覧 | `GET /accounts/{accountId}/sprint-runs` | `?repository_id=`(省くとアカウントの新しい順の 50 件) | — | read | `sprintRuns` | +| 21 | `getSprintRunDetail` | 指定の取得 | `GET /accounts/{accountId}/sprint-runs/detail` | `?sprint_run_id=` | `sprint_run_id` | read | `sprintRunDetails` | +| 22 | `getStepDetail` | 指定の取得 | `GET /accounts/{accountId}/sprint-runs/steps` | `?sprint_run_id=`(1 つ)と `?step_key=` | `step_key` | read | `stepDetails` | +| 23 | `getBacklog` | 指定の取得 | `GET /accounts/{accountId}/backlogs` | `?repository_id=` | `repository_id` | read | `backlogs` | +| 24 | `cancelSprintRun` | 操作 | `POST /accounts/{accountId}/sprint-runs/cancel` | `{sprint_run_id}` | `sprint_run_id` | write | `cancelSprintRun` | + +- 一覧の 4 つ(8・9・11・15)の応答は、今の本文を要素 1 個の配列で包む(`[{url}]`・`[{registered, kind, …}]`)。`listRepositories` は今の `repositories` の配列をそのまま返す +- `getStepDetail` は 1 つの実行の中の工程の鍵を要素にする。実行の ID は条件で、他人の実行なら要求全体が 403 になる +- `postIssueCandidates` は言語モデルを呼び、費用が掛かるため、読むだけでも扱いを `write` にする +- `registerRepository` は、同じリポジトリの 2 回目の登録が今と同じ結果(登録済みの値)になるため、冪等の鍵を持たない +- `startSprintRun` の `x-ndf-id` は `sprint_run_id` である + +### 開発ループ(`loops.yaml`) + +| # | operationId | 種類 | メソッドと経路 | 要素か条件 | 鍵 | 扱い | 画面 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 25 | `startLoop` | 作成 | `POST /accounts/{accountId}/loops` | `{repository_id, name, milestones, budget?, idempotency_key}` | `idempotency_key` | write | `startLoop` | +| 26 | `listLoops` | 一覧 | `GET /accounts/{accountId}/loops` | `?repository_id=` | — | read | `loops` | +| 27 | `getLoop` | 指定の取得 | `GET /accounts/{accountId}/loops/detail` | `?loop_id=` | `loop_id` | read | `loopDetails` | +| 28 | `updateLoopBudget` | 更新 | `PATCH /accounts/{accountId}/loops` | `{loop_id, budget}` | `loop_id` | write | `updateLoopBudget` | +| 29 | `cancelLoop` | 操作 | `POST /accounts/{accountId}/loops/cancel` | `{loop_id}` | `loop_id` | write | `cancelLoop` | +| 30 | `startNextLap` | 操作 | `POST /accounts/{accountId}/loops/next-lap` | `{loop_id}` | `loop_id` | write | `requestNextLap` | +| 31 | `answerLoopPending` | 操作 | `POST /accounts/{accountId}/loops/answers` | `{loop_id, issue, decision, pr?}` | `loop_id` + `issue` | human | `answerLoop` | + +`getLoop` の無いループは、今の 404 のまま要素の結果(`loop_not_found`)になり、画面はその要素を捨てる(今の `loopDetails` の振る舞い)。`answerLoopPending` の成功の値は `{loop_id, pending}` である。`startLoop` の `x-ndf-id` は `loop_id` である。 + +### オーガナイザー(`organizer.yaml`) + +| # | operationId | 種類 | メソッドと経路 | 要素か条件 | 鍵 | 扱い | 画面 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 32 | `listRepositoryApprovalRequests` | 指定の取得 | `GET /accounts/{accountId}/approval-requests` | `?repository_id=` | `repository_id` | read | — | +| 33 | `listRepositoryReleases` | 一覧 | `GET /accounts/{accountId}/releases` | `?repository_id=`(1 つ)と `?limit=` | — | read | — | +| 34 | `resolveStalledRelease` | 操作 | `POST /accounts/{accountId}/releases/resolve` | `{repository_id, seq, choice, body?}` | `repository_id` + `seq` | human | — | + +`listRepositoryApprovalRequests` の成功の値は `{repository_id, organizer, items}` である。止まった配布を解く操作は、オーガナイザーの確定仕様(I66)で画面の経路だけで受けると決まっているため、扱いを `human` にする。 + +### 委任(`delegations.yaml`、新設) + +| # | operationId | 種類 | メソッドと経路 | 要素か条件 | 鍵 | 扱い | 画面 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| 35 | `listDelegations` | 一覧 | `GET /delegations` | — | — | read | `delegations` | +| 36 | `createDelegation` | 作成 | `POST /delegations` | `{client_name, scope, expires_in_seconds, idempotency_key}` | `idempotency_key` | human | `createDelegation` | +| 37 | `revokeDelegation` | 削除 | `DELETE /delegations` | `?credential_id=` | `credential_id` | human | `revokeDelegation` | + +- `listDelegations` の値は `{credential_id, client_name, scope, created_at, expires_at, revoked_at, state}` の配列である。自分が発行したものだけを、新しい順で 100 件まで返す。`state` は `active` / `expired` / `revoked` である +- `createDelegation` の成功の値は `{delegation, credential}` で、`credential` は値(`ndfd_<資格情報の ID>_<秘密>`)である。同じ冪等の鍵の 2 回目は `credential` を `null` にする。`x-ndf-id` は `delegation.credential_id` である +- `revokeDelegation` は、期限切れと取り消し済みの資格情報でも何も変えずに成功を返す。他人の資格情報は `forbidden` の結果になる + +## 操作の記録へ写す鍵 + +記録の `target_ids` に写してよい項目の名前は、次の一覧だけである。一覧に無い項目は、要求と応答のどこにあっても記録に写さない(I9)。 + +| 鍵の名前 | 何の識別子か | +| --- | --- | +| `account_id` | アカウント(経路の `{accountId}` を含む) | +| `user_id` | 席の利用者 | +| `repository_id` | リポジトリ | +| `sprint_run_id` | スプリントの実行 | +| `step_key` | 工程 | +| `loop_id` | 開発ループ | +| `issue` | 課題の番号 | +| `seq` | まとめた配布の連番 | +| `credential_id` | 委任の資格情報 | +| `idempotency_key` | 作成の冪等の鍵 | + +写す値は、要素ごとの `x-ndf-keys` の項目の値と、作成の成功の値の `x-ndf-id` の項目の値である。対応の検査は、すべての操作の `x-ndf-keys` と `x-ndf-id` がこの一覧に入っていることを確かめる。この一覧に、チームの名前・クライアントの名前・課題の題と本文・モデルの認証情報・GitHub の `code`・`state`・`proof` は入らない。 + +## MCP のツール + +| 項目 | 規則 | +| --- | --- | +| 名前 | `operationId` のまま | +| 説明 | 契約の `summary` と `description`。扱い `human` の操作は、頭に「人だけが確定できる。MCP からは常に断られる。画面で行う」を足す | +| 入力の定義 | オブジェクト。経路に `{accountId}` があれば `account_id`(必須)。一覧と指定の取得と削除は query の項目(鍵は配列)。作成・更新・操作は `items`(要素の定義の配列。1〜100 個) | +| 呼び出し | 入力から経路・query・本文を組み、`Authorization` をそのまま付けて同じアプリへ内部の要求として送る | +| 結果 | `structuredContent` に `{status, body}`(応答の状態と本文)。`content` に同じ値の JSON の文字列を 1 つ。`isError` は状態が 400 以上のとき真 | +| 対象 | 扱いによらず 37 の操作すべて。扱い `human` のツールは常に 403(`human_only`)を返す。登録しておくのは、エージェントが断られた理由と、人が画面で行うことを読めるようにするためである | + +ツールの定義は起動のたびに操作の表から作る。手で書いたツールの定義を持たない。 + +## 画面の API クライアントの対応 + +`Api` のメソッドの名前と引数と戻り値は変えない。画面のコードを変えずに、契約の形を切り替えるためである。 + +| メソッドの形 | 要求の組み方 | 応答のほどき方 | +| --- | --- | --- | +| 1 件を作る・変える・消す・操作する(`startSprintRun`・`cancelLoop` など) | 要素 1 個の配列で送る。冪等の鍵が要る作成は `crypto.randomUUID()` で付ける | 要素の結果が成功なら値を返し、失敗なら要素の状態と本文で `ApiFailure` を投げる | +| 配列で受ける取得(`sprintRunDetails`・`stepDetails`・`backlogs`・`loopDetails`) | 鍵の query を繰り返して 1 回で送る | `sprintRunDetails`・`stepDetails`・`backlogs` は最初の失敗の要素で `ApiFailure` を投げる。`loopDetails` は `loop_not_found` の要素を捨てる(今の振る舞いのまま) | +| 一覧(`sprintRuns`・`loops`・`repositories` など) | 条件の query で送る | 今の戻り値の形へ包み直す(`{sprint_runs: [...]}` など) | + +要求は操作の表の `operationId` から組み、経路の文字列をソースに直に書かない。ソースの検査(`check-source.ts`)に、経路の文字列を直に組んだ要求を見つける規則を足す(受け入れ条件 1)。 diff --git a/issues/issue-2384-design-decisions.md b/issues/issue-2384-design-decisions.md new file mode 100644 index 000000000..b5fda84d1 --- /dev/null +++ b/issues/issue-2384-design-decisions.md @@ -0,0 +1,131 @@ +# #2384 の設計: 決定の記録 + +[設計文書の本体](issue-2384-design.md) の決定の記録である。根拠の行の MVV は、プロジェクト MVV 版 5 と、スプリント MVV である。スプリント MVV は、マイルストーン「25 Neural Development Factory」のもの(sha256 の先頭 8 文字 44cd096d)を指す。 + +## 決定の記録 + +### 決定 1: すべての操作を配列で受け渡すため、取得と削除は鍵の query の繰り返し、作成・更新・操作は本文の配列にする + +取得を `GET` のままにするのは、読み取りと書き込みをメソッドで分け、オフラインの見せ方(最後に受け取った一覧と詳細を読む)とブラウザの開発ツールで要求を読みやすく保つためである。削除も鍵の query にし、`DELETE` の本文を使わない。HTTP の仕様(RFC 9110 の DELETE の節)は `DELETE` の本文に一般的な意味を定めておらず、実装によっては要求を断ると書いている。一覧は条件で集めた値だけを返し、要素の結果で包まない。 + +すべての操作を `POST` にして本文で鍵を渡す形は採らない。読み取りまで書き込みと同じメソッドになり、操作の扱い(読み取り・書き込み)をメソッドから読めなくなる。 + +根拠: Value 6 / スプリント Value 2(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 2: 認可の単位を今と変えないため、経路の変数はアカウントの ID だけにし、要素ごとに持ち主を照らす + +アカウントを経路に置く今の決め方(`account_id` を token に載せず経路から解決する。認証の確定仕様の判断)を保つ。経路のアカウントを解決できなければ、今と同じく要求全体を 403 にする(受け入れ条件 26)。要素の識別子(実行・ループ・リポジトリ)は、そのアカウントに属するかを要素ごとに照らし、属さない要素だけを `forbidden` にする(受け入れ条件 7・15)。 + +要素ごとに `account_id` を持たせ、経路からアカウントを外す形は採らない。画面のリンクからどのアカウントの画面かを読めなくなり、1 回の要求の中でアカウントの解決が要素の数だけ走る。アカウントそのものの操作(`getAccount`・`setExecutionMode`)だけは、要素がアカウントであるため要素ごとに解決する。 + +根拠: Value 7 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 3: 一部の失敗を読めるようにするため、成功の状態は 200 にし、要素ごとに状態と本文を持つ結果を入力と同じ順で返す + +要素の結果に今の誤りの状態(403・404・409・422)を残すのは、画面が状態で文言を分けている(`factory/web/src/context.tsx`)ためである。画面の分岐を変えずに済む。要求全体を断るのは、資格情報・操作の扱い・要素の数と重複・経路のアカウントの 4 つと、操作の記録の追記の失敗(503)だけにする。 + +207(Multi-Status)は採らない。WebDAV の XML の本文を前提にした状態で、JSON の API で使うと、呼ぶ側が 200 と 207 の両方を扱うことになる。 + +根拠: スプリント Value 1(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 4: 送り直しで 2 重に作らないため、新しい識別子を作る 4 つの作成だけに冪等の鍵を求め、鍵の行を作るものと同じ表へ同じトランザクションで書く + +冪等の鍵が要るのは、送り直したときに別の識別子のレコードがもう 1 つできる作成だけである(スプリントの実行・開発ループ・チーム・委任の資格情報)。席とリポジトリの登録は鍵が自然に決まり(アカウントと利用者、アカウントとリポジトリ)、2 回目は今と同じ結果になる。更新・削除・取り消しは同じ値の書き込みで、送り直しても結果が変わらない。 + +鍵の行を別の表に置き、作成の前に鍵を取り、作成の後に結果を書く形は採らない。作成と鍵の書き込みの間で落ちると、鍵だけが残って結果が無い状態ができる。同じ表の 1 つのトランザクションなら、両方が書かれるか、どちらも書かれないかになる。 + +根拠: Value 7(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 5: 画面・API・MCP・認可の対応を 1 か所から導くため、契約に操作の扱いと鍵を拡張として持ち、操作の表を生成する + +操作の扱い(`x-ndf-access`)・要素の鍵(`x-ndf-keys`)・作った識別子(`x-ndf-id`)を契約に持ち、操作の表をそこから生成する。API の経路の登録・範囲の照合・記録の鍵・MCP のツール・画面の要求の組み立ては、同じ表を読む。そのため、契約に操作を足すと 4 か所へ同時に届き、片方だけの操作ができない(受け入れ条件 1・2)。生成物はコミットし、今の型の生成と同じく `--check` で食い違いを落とす。 + +扱いを API のコードの表に持つ形は採らない。契約を読む MCP と画面と検査が、もう 1 つの表を読むことになり、正が 2 つになる。 + +根拠: Value 5 / Value 8 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 6: MCP のツールが別の保存の経路を持たないため、MCP の受け口はツールの呼び出しを同じアプリの経路へ内部の要求として送る + +MCP の受け口が受け取る依存は、アプリの要求の関数(Hono の `app.request`)1 つだけにする。認証・範囲の照合・要素の検査・認可・記録が、画面と API と同じ処理をそのまま通る(受け入れ条件 3)。内部の要求は資格情報の検証をもう 1 度行い、表の読み出しが 1 回増えるが、読み出し 1 回は単価 100 万回で $0.125 の水準である。 + +MCP の受け口がユースケースの関数を直に呼ぶ形は採らない。認可と記録を MCP の側にもう 1 つ書くことになり、片方だけを直す誤りが起きる。 + +根拠: Value 7 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 7: 新しい認可サーバーを足さずに出すため、MCP の委任の認証は Factory が発行した資格情報を `Authorization` で直に受け、MCP の認可の仕様(OAuth 2.1)には今は沿わせない(未決 5) + +MCP の仕様は認可を任意とし、独自の認証の方式を認めている(本体の「あるべき姿の根拠」)。直に受ける形なら、人が画面で発行して値を MCP のクライアントの設定へ写すだけで使え、要求の前提 6(発行と取り消しは人が画面で行う)にそのまま合う。仕様に沿わせるには、Factory に認可サーバー(認可と token の経路・PKCE・クライアントの登録・保護されたリソースのメタデータ)を作る必要がある。Cognito はクライアントの動的な登録を持たず、取り消しの直後から断る条件(受け入れ条件 12)も Cognito の token の検証だけでは満たせない。 + +この形では、1 時間を超えて動くエージェントのために、人が 1 時間ごとに発行し直すことになる。更新の token で解く場合は MCP の認可の仕様へ移り、委任の資格情報の表と範囲と人だけの操作の判定はそのまま使える。この形で出すかを承認ゲート 1 で利用者が見る(本体の「未確認のまま残ること」)。 + +根拠: Value 1 / Value 3 / スプリント Value 6(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 8: 取り消しを次の要求から効かせるため、委任の資格情報は不透明な値にし、要求のたびに表を強い整合で読む + +値は `ndfd_<資格情報の ID>_<秘密>` で、ID で行を引き、秘密の SHA-256 を一定時間の比較で照らす。頭の `ndfd_` で Cognito の token と分ける。署名した token(JWT)は、取り消しを効かせるために結局は表を読む必要があり、署名の鍵の管理が増えるだけになるため採らない。照合の結果をためる(キャッシュする)形も、取り消しの直後の要求を通してしまうため採らない。 + +根拠: Value 7 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 9: 人が選びやすくするため、範囲は読み取りだけと読み書きの 2 つ、期限は 5 分〜1 時間にし、アカウントで絞る範囲は持たない + +受け入れ条件 13 が求める区別は読み取りと書き込みの 2 つで、操作の扱い(`read` / `write`)と 1 対 1 に対応する。画面の期限の選択肢は 15 分・30 分・1 時間で、API は 300〜3,600 秒を受ける。アカウントや操作ごとに範囲を絞る形は、発行の画面の入力が増え、委任の側に認可の分岐を持つことになる(I6)。今の要求に絞る条件が無いため持たない。 + +根拠: スプリント Value 1 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 10: 人の承認を委任で確定させないため、扱い `human` の操作を 10 に定める + +扱い `human` の操作は、次の 10 である。 + +| 何に当たるか | 操作 | +| --- | --- | +| 承認と判断の答え | `answerLoopPending`・`resolveStalledRelease` | +| 共通原則の C1 に当たる、モデルの認証情報の保存と削除 | `putModelCredential`・`deleteModelCredential` | +| C2 に当たる、席の追加・変更・削除とチームの作成 | `addSeat`・`changeSeat`・`removeSeat`・`createTeam` | +| 委任の資格情報の発行と取り消し(要求の前提 6) | `createDelegation`・`revokeDelegation` | + +#2173 が足す承認の操作も扱い `human` で足す。 + +リポジトリの登録と外す操作、実行の形態の変更は `write` にする。登録は GitHub の画面で人が認可した結果(`proof`)が無いと通らず、外す操作と実行の形態の変更は、人の承認が要る操作の一覧に当たらない。 + +根拠: C1 / C2 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 11: 記録の欠けを残さないため、記録の追記に失敗したら操作を 503 で返し、記録の全体を構造化ログへ出して補えるようにする(未決 1) + +スプリント Value 4 は、読み取りを含むすべての操作を記録に残すことを求める。操作を通して後から補う形は、補う仕組み(再送のキューと、その失敗の扱い)が新しく要り、補えなかった記録を見つける手段も要る。503 にすると、読み取りは応答を返さず、書き込みは既に行われたことがあるが、送り直しは冪等の鍵と同じ値の書き込みで 2 重にならない(決定 4)。追記の失敗の 1 行は構造化ログに残り、記録の表へ入れ直せる。 + +この決定は承認ゲート 1 で利用者が決める項目で、可用性を優先するなら、503 にせず構造化ログの 1 行だけで補う形へ切り替えられる。切り替えても、記録の書き手の外の形は変わらない。 + +根拠: スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 12: 秘密と個人情報を記録に書かないため、記録の項目を許可した鍵の一覧だけで組む + +記録は要求の本文を写さず、許可した 10 の鍵の値と固定の列だけを持つ。書かない値を並べる禁止の一覧は、新しい操作が新しい秘密の項目を足したときに漏れる。許可した鍵の一覧なら、足した項目は記録に入らない。検査は、すべての操作の鍵がその一覧に入っていることを確かめる。 + +根拠: Value 1 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 13: 呼び出し元の種類を偽れないようにするため、種類は資格情報の種類と MCP の受け口の内部の印だけで決める + +Cognito の token は `screen`、委任の資格情報の API への直の要求は `api`、MCP の受け口が送る内部の要求は `mcp` にする。内部の印は Hono の要求の環境(`app.request` の第 3 引数)に置き、外から届くヘッダーでは決めない。Cognito の token を画面の外から使う要求も `screen` になるが、人の直の操作であることに変わりはない。 + +根拠: スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 14: 新しい実行基盤を足さないため、MCP の SDK は `@modelcontextprotocol/server` 2.4 を使い、状態を持たない JSON の 1 応答で既存の API の Lambda に置く + +このパッケージは MCP の 2026-07-28 版に対応し、Web 標準の要求と応答で動く受け口を持つため、Hono の経路から `c.req.raw` を渡して使える。依存は `zod` と同じ系列の `core` だけである。旧来の `@modelcontextprotocol/sdk`(1.32)は Express と関連の依存を持ち込み、対応する版も 2025-11-25 までである。JSON-RPC と版の取り決めを自前で書く形は、仕様の版が変わるたびに直す範囲が増えるため採らない。 + +根拠: Value 4 / スプリント Value 6(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 15: 1 回の承認で読み切れる量に収めるため、設計は 1 本にし、実装は 4 本の Pull Request に分けて順に入れる(未決 4) + +設計を 1 本にするのは、4 つの部分が操作の表と操作の登録の層を共有し、分けると同じ前提を 4 回承認することになるためである。実装は次の順に分け、どの Pull Request も画面の Vitest と Playwright が通る状態で入れる。 + +| 順 | 中身 | 前提 | +| --- | --- | --- | +| 1 | 契約の配列化・操作の表と生成・操作の登録の層・要素ごとの実行・作成の冪等の鍵・画面とダミーの API クライアント・対応の検査(MCP を除く) | — | +| 2 | 委任の資格情報(検証・管理・画面)と、扱い `human` と範囲の照合 | 1 | +| 3 | 操作の記録 | 1 | +| 4 | MCP の受け口と、MCP のツールの対応の検査 | 2 | + +承認ゲート 2 は 4 本がそろった後の 1 回にする。4 つの課題へ分けるかは承認ゲート 1 で利用者が決める。 + +根拠: Value 2(MVV 版 5・スプリント MVV 44cd096d) diff --git a/issues/issue-2384-design.md b/issues/issue-2384-design.md new file mode 100644 index 000000000..6538d7a4e --- /dev/null +++ b/issues/issue-2384-design.md @@ -0,0 +1,493 @@ +# NDF Factory: 画面で行える操作を、委任を受けたエージェントが API と MCP から同じ認可と記録のもとで行える(#2384) + +## 目的 + +- **今どうなっているか**: Factory には MCP(Model Context Protocol)の受け口が無く、API を呼べるのは人のログインの token だけである。契約の 34 の利用者向けの操作は、画面の 1 枚に合わせた単一のレコードの入出力が混ざっている。画面はスプリントの詳細・工程の中身・バックログ・ループの詳細を、要素の数だけ別々に取っている。だれが・どの経路で何をしたかの記録も無い +- **誰に効くか**: Factory を使う人(承認と判断に集中できる)と、その人が任せる AI のエージェント・連携するシステム +- **変えると何が成り立つか**: 人が画面で発行した委任の資格情報で、エージェントが画面と同じ操作を API と MCP から行える。認可は人の操作と同じで、人だけが確定できる 10 の操作は委任から断る。すべての利用者向けの操作が配列で受け渡され、1 回ごとに操作の記録が 1 件残る + +## 適用範囲 + +- **働く範囲**: このリポジトリの `factory/`(契約・API・画面)だけ。NDF のプラグインと配布先のリポジトリには届かない +- **プロジェクトごとに違うもの**: 無し(Factory は 1 つのサービスである。表の名前は環境変数で受ける) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| #2168 の「方針(利用者の決定)」の表の 2 行(「AI からも人と同じく使える」「画面先行」、2026-11-02)と、スプリント MVV の Value 2・Value 4 の文 | 利用者の指示の原文 | 全操作を API と MCP から行えること、入出力を機能から起こして常に配列で受け渡すこと、承認は人だけが確定すること | +| MCP の仕様 2026-07-28 版の認可のページ( https://modelcontextprotocol.io/specification/2026-07-28/basic/authorization.md ): 「Authorization is OPTIONAL for MCP implementations」。基本のページ( https://modelcontextprotocol.io/specification/2026-07-28/basic.md ): 「clients and servers MAY negotiate their own custom authentication and authorization strategies」 | 外部の一次情報 | Factory が発行した資格情報を直に受ける形は仕様の範囲にある(決定 7) | +| 同じ版の Streamable HTTP のページ( https://modelcontextprotocol.io/specification/2026-07-28/basic/transports/streamable-http.md ): プロトコルの層のセッションと GET のストリームを除き、要求に対して `application/json` の 1 応答で返してよい | 外部の一次情報 | MCP の受け口を、状態を持たない Lambda の 1 経路に置ける(決定 14) | +| `npm view @modelcontextprotocol/server` の版 2.4.0(2026-10-23 更新)。パッケージの中に 2026-07-28 版の対応と、Web 標準の要求と応答を受ける受け口(`WebStandardStreamableHTTPServerTransport`)がある。依存は `zod` と同じ系列の `core` だけ(2026-11-04 に取得して確かめた) | 実測 | Hono の上で、新しい実行基盤を足さずに MCP を受けられる | +| `factory/web/src/api.ts` の `sprintRunDetails`・`stepDetails`・`backlogs`・`loopDetails` が `Promise.all` で要素ごとに経路を呼んでいる(2026-11-04) | 実測 | 配列で受ける契約にすると、画面の要求の回数が要素の数から 1 回へ減る | + +要求と受け入れ条件は #2384 の本文にある(コピーは `issues/issue-2384-requirements.md`)。この文書は「どう作るか」だけを扱う。 +次の 2 つは分けた文書にある。 + +| 中身 | 文書 | +| --- | --- | +| 契約の操作ごとの経路・要素・応答・扱い | [issue-2384-design-contracts.md](issue-2384-design-contracts.md) | +| 決定の記録 | [issue-2384-design-decisions.md](issue-2384-design-decisions.md) | + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| Neural Development Factory(`ndf-factory`) | 利用者・アカウント・委任の資格情報・人だけが確定できる操作・操作の記録・呼び出し元の種類・機能から起こした契約 | + +変更はこのコンテキストの中で閉じる。外のエージェントとクライアントは、文書化した契約(OpenAPI)と、そこから作った MCP のツールの定義だけでやり取りする(公開された言語)。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 委任の資格情報(新設) | 委任の管理(`factory/api/src/delegations/`) | 委任の資格情報 | — | 範囲・期限・秘密のハッシュ・クライアントの名前・作成の冪等の鍵 | +| 操作の記録(新設) | 操作の記録の書き手(`factory/api/src/audit/`) | 記録 1 件 | — | 呼び出し元の種類・結果・対象の ID の配列 | +| スプリントの実行・開発ループ(既存) | 実行の制御・ループの制御(`factory/api/src/runs/`) | 実行・ループ | — | 作成の冪等の鍵(追加) | +| アカウント(既存) | 認証と料金プラン(`factory/api/src/accounts/`) | アカウント | 席 | 作成の冪等の鍵(追加) | + +委任の資格情報は、利用者を ID で参照する。人の操作と同じ認可を掛けるため、アカウントへの権限は持たず、要求のたびに利用者から解決する。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 委任の資格情報 | 期限は発行から 300〜3,600 秒の間にある | 発行の要素を 400 で断り、保存しない | +| I2 | 委任の資格情報 | 期限を過ぎた・取り消した資格情報は認証に通らない。取り消しは、その後の最初の要求から効く | 401 で、操作を行わない | +| I3 | 委任の資格情報 | 秘密は SHA-256 のハッシュだけを保存し、平文を持たない。値は発行の応答で 1 度だけ返す | 保存の関数の引数に平文を取らない | +| I4 | 委任の資格情報 | 委任の資格情報では、人だけが確定できる操作を呼べない。委任の資格情報の発行と取り消しも含む | 範囲によらず 403(`human_only`)で、答えも状態も保存しない | +| I5 | 委任の資格情報 | 範囲が読み取りだけの資格情報では、書き込みの操作を呼べない | 403(`scope_insufficient`)で、操作を行わない | +| I6 | 委任の資格情報 | 委任の資格情報での認可の判定は、発行した人の直の操作の判定と同じになる | 利用者とアカウントの解決を人と同じ関数で行い、委任の側に認可の分岐を持たない | +| I7 | 操作の記録 | 利用者向けの操作 1 回に記録が 1 件増える。成功・一部の失敗・400・401・403 のどれでも増える | 追記に失敗したら操作を 503(`audit_unavailable`)で返す(決定 11) | +| I8 | 操作の記録 | 記録を書き換える・消す関数を持たない | 保存の契約は追記の関数 1 つだけを持つ | +| I9 | 操作の記録 | 記録に秘密と個人情報の値が入らない | 記録へ写す項目は許可した鍵の一覧からだけ取る(決定 12) | +| I10 | 機能から起こした契約 | 利用者向けの操作は、成功の応答の本文が配列で、要求の本文を持つなら配列である。経路の変数はアカウントの ID だけである | 検査が落ちる | +| I11 | 機能から起こした契約 | 利用者向けの操作と MCP のツールが 1 対 1 で対応し、画面の API クライアントが呼ぶ操作はすべて契約にある | 検査が落ちる | +| I12 | 機能から起こした契約 | 一覧を除く操作(指定の取得・作成・更新・削除・操作)では、1 回の要求の要素は 1〜100 個で、同じ鍵の要素を 2 つ含まない。一覧は要素を持たず、この検査を掛けない(条件の query が 0 個でも通る) | 400(`empty_items` / `too_many_items` / `duplicate_key`)で、どの要素も処理しない | +| I13 | 機能から起こした契約 | 指定の操作の応答は、入力と同じ順・同じ数の結果を持ち、認可は要素ごとに掛かる | 認可の無い要素だけを `forbidden` の結果にし、その要素のデータを応答に含めない | +| I14 | スプリントの実行・開発ループ・アカウント・委任の資格情報 | 同じ利用者が同じ作成の操作へ同じ冪等の鍵で 24 時間以内に送り直した作成は、レコードを 1 件だけ作る。操作が違うか、24 時間を過ぎた同じ鍵は新しい作成として扱う | 2 回目は 1 回目の結果(同じ識別子)を返す。委任の資格情報は秘密を 2 回目に返さない | +| I15 | 機能から起こした契約 | MCP のツールは、操作の表を通して API と同じ経路を呼び、保存の関数を直に持たない | MCP の受け口に渡す依存をアプリの呼び出しの関数 1 つに限る | +| I16 | 操作の記録 | 呼び出し元の種類は、要求のヘッダーで変えられない | 種類は資格情報の種類と、MCP の受け口が内部の呼び出しに付ける印だけで決める(決定 13) | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 人が委任の資格情報を発行した | 委任の管理 | 操作の記録の書き手 | +| E2 | エージェントが委任の資格情報で API か MCP を呼んだ | 資格情報の検証 | 委任の管理(照合)・操作の記録の書き手 | +| E3 | 委任の資格情報の範囲と、操作の要る範囲を照らした | 操作の登録の層 | 操作の記録の書き手 | +| E4 | 人と同じ認可を要素ごとに掛けた | 要素ごとの実行 | 操作の記録の書き手 | +| E5 | 人だけが確定できる操作を断った | 操作の登録の層 | 操作の記録の書き手 | +| E6 | 配列の要素ごとに操作を行い、要素ごとの結果を返した | 要素ごとの実行 | 操作の記録の書き手 | +| E7 | 同じ冪等の鍵の作成を 2 回受け、2 回目は 1 回目の結果を返した | 実行の制御・ループの制御・認証と料金プラン・委任の管理 | 操作の記録の書き手 | +| E8 | 操作の記録を 1 件追記した | 操作の記録の書き手 | 運用の担当(後から記録を読む人) | +| E9 | 人が委任の資格情報を取り消した | 委任の管理 | 資格情報の検証・操作の記録の書き手 | +| E10 | 取り消した委任の資格情報での要求を断った | 資格情報の検証 | 操作の記録の書き手 | + +イベントは同じ要求の中の関数の呼び出しとして受け渡す。キューは使わない。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 委任の資格情報 | 要求の定義のまま。値の形は `ndfd_<資格情報の ID>_<秘密>` | 既存(要求で追加済み) | +| 人だけが確定できる操作 | 要求の定義のまま。契約の印は操作の扱い `human` | 既存(要求で追加済み) | +| 操作の記録 | 要求の定義のまま | 既存(要求で追加済み) | +| 呼び出し元の種類 | 要求の定義のまま | 既存(要求で追加済み) | +| 機能から起こした契約 | 要求の定義のまま | 既存(要求で追加済み) | +| 操作の扱い | 利用者向けの操作が要る範囲。読み取り(`read`)・書き込み(`write`)・人だけ(`human`)の 3 つ。契約の各操作に `x-ndf-access` として持つ | 追加 | +| 操作の表 | 契約から生成する、利用者向けの操作の一覧(`operationId`・メソッド・経路・操作の扱い・入力の定義)。画面・API・MCP がここから要求を組み、経路を登録する | 追加 | +| 要素の結果 | 配列で受け渡す操作の応答の 1 要素。入力と同じ順に並び、成功なら値を、失敗なら状態と理由(`code`)を持つ | 追加 | +| 作成の冪等の鍵 | 新しい識別子を作る作成の要素に、呼ぶ側が付ける鍵。同じ利用者・同じ操作・同じ鍵の作成を 1 件にする。進捗の事象の冪等の鍵とは別の鍵である | 追加 | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 機能から起こした契約で、取得・作成・更新・削除・操作を配列で行う | 画面・委任を受けたエージェント・連携するシステム | +| F2 | 委任の資格情報を発行し、一覧で見て、取り消す | 人(画面) | +| F3 | 委任の資格情報で API を呼ぶ | 委任を受けたエージェント・連携するシステム | +| F4 | MCP のツールとして Factory の操作を呼ぶ | 委任を受けたエージェント | +| F5 | 人だけが確定できる操作を委任から断る | Factory(すべての経路) | +| F6 | 利用者向けの操作を 1 回ごとに記録する | Factory(すべての経路)。読むのは運用の担当 | +| F7 | 画面・契約・MCP の対応と配列の形を検査する | 開発者・継続的統合 | + +## 構成要素 + +| 要素 | 責務 | 置き場 | +| --- | --- | --- | +| 契約 | 利用者向けの 37 の操作(既存 34・委任 3)を配列の形で定義し、各操作に操作の扱いを持つ。ランナー向けの経路と GitHub の Webhook は変えない | `factory/contracts/openapi/*.yaml`(`delegations.yaml` を新設) | +| 操作の表の生成 | 契約から操作の表を作る。`--check` で、コミットした表との食い違いを 1 で返す | `factory/contracts/scripts/generate.ts`・`src/generated/operations.ts` | +| 対応の検査 | 受け入れ条件 1・2・4・5・16 の対応と形を確かめる | `factory/contracts/tests/operations.test.ts`・`factory/web/scripts/check-source.ts` | +| 資格情報の検証 | `Authorization` の値を、委任の資格情報(頭が `ndfd_`)か Cognito の access token として検証し、利用者・委任の有無・呼び出し元の種類を文脈に置く | `factory/api/src/auth/verify.ts`・`resolve.ts` | +| 委任の管理 | 委任の資格情報の発行・一覧・取り消しと、値の照合 | `factory/api/src/delegations/`(新設) | +| 操作の登録の層 | 経路を操作の表から登録し、範囲の照合(I4・I5)・要素の数と重複の検査(I12)・操作の記録への受け渡しを、すべての利用者向けの操作に同じく掛ける | `factory/api/src/routes/operation.ts`(新設) | +| 要素ごとの実行 | 要素を並べる数 10 で順に既存のユースケースへ渡し、失敗を要素の結果に包む | `factory/api/src/routes/elements.ts`(新設) | +| 既存の経路 | 利用者向けの 34 の操作を操作の登録の層で登録し直し、要素の形へ合わせる。ユースケースの関数は変えない | `factory/api/src/routes/*.ts` | +| 作成の冪等の鍵 | 新しい識別子を作る 4 つの作成で、鍵の行を作るものと同じトランザクションで書く | `factory/api/src/runs/table.ts`・`dynamo-table.ts`・`memory-table.ts`・`accounts/store.ts`・`memory-store.ts` | +| 操作の記録の書き手 | 許可した鍵だけで記録を組み、追記する。保存の契約は追記の関数だけを持つ | `factory/api/src/audit/`(新設) | +| MCP の受け口 | `POST /mcp` で MCP の要求を受け、操作の表から作ったツールの呼び出しを同じアプリの経路へ内部で送る | `factory/api/src/mcp/`(新設) | +| 画面の API クライアント | 要求を操作の表から組み、配列の応答をほどいて今の `Api` の形で返す。委任の 3 操作を足す | `factory/web/src/api.ts` | +| ダミーの API クライアント | 同じ `Api` の形で委任の 3 操作を足す | `factory/web/src/dummy/` | +| 委任の画面 | 委任の資格情報を発行・一覧・取り消す | `factory/web/src/pages/DelegationsPage.tsx`・`screens.ts`・`i18n/ja.json` | + +### 構成要素の関係 + +```mermaid +graph TD + subgraph 呼ぶ側 + W[画面の API
クライアント] + X[エージェント・
連携するシステム] + end + subgraph 契約 + C[契約] --> T[操作の表] + end + subgraph API + M[MCP の受け口] + V[資格情報の検証] + R[操作の登録の層] + E[要素ごとの実行] + U[既存の経路と
ユースケース] + D[委任の管理] + A[操作の記録の書き手] + K[作成の冪等の鍵] + end + T --> W + T --> R + T --> M + W --> V + X --> V + X --> M + M --> V + V --> D + V --> R + R --> E --> U --> K + R --> A +``` + +対応の検査は図に含めない。継続的統合の中で契約・操作の表・画面のソースを読むだけで、実行時の線を持たない。委任の画面とダミーの API クライアントは、画面の API クライアントの一部として描いている(ダミーは同じ形の差し替えである)。 + +### 配置 + +```mermaid +graph TD + subgraph 利用者の端末 + B[ブラウザの画面] + CC[Claude Code など
MCP のクライアント] + end + subgraph AWS + G[API Gateway
HTTP API] + L[API の Lambda
Node 24・Hono] + T1[(委任の資格情報の表)] + T2[(操作の記録の表)] + T3[(既存の表)] + end + B -->|Cognito の token| G + CC -->|委任の資格情報
POST /mcp| G + G --> L + L --> T1 + L --> T2 + L --> T3 +``` + +MCP の受け口は既存の API の Lambda の 1 経路に置き、新しい実行基盤を足さない。インフラ(private リポジトリ)へ渡すものは「データ構造」の「インフラへ渡すもの」にある。 + +### 置き場所 + +```text +factory/ +├── contracts/ +│ ├── openapi/delegations.yaml (新設) +│ ├── openapi/{accounts,runs,loops,organizer}.yaml (配列の形へ書き換え) +│ ├── scripts/generate.ts (操作の表の生成を足す) +│ ├── src/generated/operations.ts (新設・生成物) +│ └── tests/operations.test.ts (新設) +├── api/src/ +│ ├── audit/{log,dynamo-log,memory-log}.ts (新設) +│ ├── delegations/{service,store,memory-store}.ts (新設) +│ ├── mcp/server.ts (新設) +│ └── routes/{operation,elements}.ts (新設) +└── web/src/pages/DelegationsPage.tsx (新設) +``` + +## 構造 + +```mermaid +classDiagram + class Caller { + +userId + +idp + +delegation: DelegationRef | null + +via: screen | api | mcp + } + class DelegationRef { + +credentialId + +scope: read | read_write + } + class Operation { + +operationId + +method + +path + +access: read | write | human + +kind: list | select | create | update | delete | action + } + class AuditRecord { + +recordId + +at + +userId + +credentialId + +via + +operationId + +targetIds + +outcome + } + class AuditLog { + <> + +append(record) + } + Caller "1" --> "0..1" DelegationRef + AuditLog ..> AuditRecord : 追記する +``` + +`Caller` は既存の型に 2 項目を足す。`AccountContext` は変えない(I6 の認可は今の解決の関数のまま)。 +`Operation` の `kind` は、契約の経路の形から生成の時点で決める。要素の検査と応答の形は `kind` で選ぶ([契約の表](issue-2384-design-contracts.md#操作の種類と形))。 + +## データ構造 + +### 委任の資格情報の表 + +区分の鍵は `credential_id` だけ。利用者の一覧のため、GSI(`user_id`・`created_at`)を置く。読み出しは強い整合で行う(I2)。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `credential_id` | 文字列 | 許さない | 主キー。ランダムな 16 バイトの base32 | +| `user_id` | 文字列 | 許さない | 委任した利用者(Cognito の `sub`) | +| `idp` | 文字列 | 許さない | 委任した利用者の IdP。人の解決と同じ `Caller` を組むために写す | +| `client_name` | 文字列 | 許さない | 人が付けたクライアントの名前(1〜64 字)。操作の記録には写さない | +| `scope` | 文字列 | 許さない | `read` / `read_write` | +| `secret_hash` | 文字列 | 許さない | 秘密(32 バイト)の SHA-256 の 16 進 | +| `created_at` | 文字列 | 許さない | 発行の日時(UTC、ISO 8601) | +| `expires_at` | 文字列 | 許さない | 期限(UTC) | +| `revoked_at` | 文字列 | 許す | 空は「取り消していない」。取り消しで 1 度だけ書く | + +`revoked_at` の 1 回の書き込み以外は行を書き換えない。期限を過ぎた行も消さない。操作の記録が資格情報の ID だけを持つため、後からクライアントの名前をたどれるようにする。 + +### 操作の記録の表 + +区分の鍵は `record_id` だけ。書き込みは `attribute_not_exists(record_id)` の条件つきの追加だけで、更新と削除を行わない(I8)。検索の索引は持たない(記録の検索は要求の範囲の外)。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `record_id` | 文字列 | 許さない | 主キー。要求の識別子(API Gateway の要求の ID、無ければ UUID)。構造化ログの同じ要求の行とたどれる | +| `at` | 文字列 | 許さない | 受けた日時(UTC、ミリ秒まで) | +| `user_id` | 文字列 | 許す | 操作した利用者。空は「401 で利用者を決められなかった」 | +| `credential_id` | 文字列 | 許す | 委任の資格情報の ID。空は「人の直の操作」か「401 で決められなかった」 | +| `via` | 文字列 | 許さない | 呼び出し元の種類。`screen` / `api` / `mcp` | +| `operation_id` | 文字列 | 許す | 契約の `operationId`。空は「契約に無い経路」(404 で終わった要求) | +| `account_id` | 文字列 | 許す | 経路のアカウントの ID。空は「アカウントを持たない操作」 | +| `target_ids` | 文字列の配列 | 許さない | 要素の鍵([許可した鍵](issue-2384-design-contracts.md#操作の記録へ写す鍵))を `<鍵の名前>=<値>` で並べる。0 件は空の配列 | +| `outcome` | 文字列 | 許さない | `ok`(すべての要素が成功)/ `partial`(一部の要素が失敗)/ `rejected`(要求全体を断った) | +| `status` | 数 | 許さない | 応答の HTTP の状態 | +| `reason` | 文字列 | 許す | `rejected` の誤りの `code`。`partial` は失敗した要素の `code` を重ねずに `,` で並べる。`ok` は空 | + +この表は事象の記録(発生した操作を積み上げる)で、状態を持たない。集計の単位(利用者・アカウント・日時・呼び出し元・操作)を列として取り出せる。 + +### 作成の冪等の鍵の行 + +新しい識別子を作る 4 つの作成(スプリントの実行・開発ループ・チーム・委任の資格情報)で、作るものと同じ表へ同じトランザクションで書く(決定 4)。 + +| 置く表 | 鍵 | 値 | +| --- | --- | --- | +| 実行の表(スプリントの実行・開発ループ) | `IDEMP###<鍵>` | 作った識別子・作った日時 | +| アカウントの表(チーム) | 同じ形 | 作った識別子・作った日時 | +| 委任の資格情報の表 | 区分の鍵に `IDEMP#…` を入れた行 | 作った資格情報の ID。秘密は 2 回目に返さない(`credential` を `null` にする) | + +鍵の行の属性 `ttl` に、作った日時の 24 時間後を置く。24 時間を過ぎた同じ鍵は新しい作成として扱う。鍵は 1〜64 字の英数字とハイフンである。 + +期限は TTL の削除に頼らず、書き込みと読み出しのたびに今の時刻と比べて守る。DynamoDB の TTL は期限を過ぎた行をすぐには消さず、消えるまでの間も読めるためである。手元の実装の表(`memory-table.ts`・`memory-store.ts`)は TTL で行を消さない。 + +- **書き込み**: 鍵の行を、条件 `attribute_not_exists(pk) OR ttl < :now` で書く。期限を過ぎた行は上書きし、新しい作成として扱う +- **読み出し**: 鍵の行の `ttl` が今の時刻より前なら、その行は無いものとして扱う + +### CRUD + +| 機能 | 委任の資格情報 | 操作の記録 | 冪等の鍵の行 | 既存の表 | +| --- | --- | --- | --- | --- | +| F1 配列で操作する | — | C | C・R(作成だけ) | C・R・U・D(今のユースケースのまま) | +| F2 発行・一覧・取り消し | C・R・U(`revoked_at`) | C | C・R | — | +| F3 委任で API を呼ぶ | R | C | — | 操作による | +| F4 MCP から呼ぶ | R | C | — | 操作による | +| F5 人だけの操作を断る | R | C | — | — | +| F6 記録する | — | C | — | — | + +### インフラへ渡すもの + +| 項目 | 内容 | +| --- | --- | +| 表 | 委任の資格情報の表(GSI `user_id`・`created_at`、TTL の属性 `ttl`)と、操作の記録の表。どちらもオンデマンド | +| 権限 | API の Lambda に、委任の資格情報の表の `GetItem`・`PutItem`・`UpdateItem`・`Query`(GSI)、操作の記録の表の `PutItem` だけ。操作の記録の表へ `UpdateItem`・`DeleteItem` を与えない | +| 既存の表 | 実行の表とアカウントの表で、TTL の属性 `ttl` を有効にする(作成の冪等の鍵の行だけが持つ) | +| 環境変数 | `DELEGATIONS_TABLE_NAME`・`AUDIT_TABLE_NAME` | +| 経路 | HTTP API に `POST /mcp` を足し、既存の API の Lambda へ通す。`GET` と `DELETE` の `/mcp` は Lambda が 405 を返す | + +## 入出力の契約 + +| 項目 | 内容 | +| --- | --- | +| 置き場所 | `factory/contracts/openapi/accounts.yaml`・`runs.yaml`・`loops.yaml`・`organizer.yaml`(書き換え)と `delegations.yaml`(新設) | +| 変わる約束 | 利用者向けの 34 の操作を配列の形へ変え、委任の 3 操作を足す。経路・要素・応答・操作の扱いの一覧は [契約の表](issue-2384-design-contracts.md#操作の一覧) | +| 互換性の扱い | 互換なし。旧い形の経路を残さない(要求の前提 7)。ランナー向けの経路(`/run-jobs/*`)と受け口(`/ingest/*`)は変えない | +| 検査の手段 | `cd factory && npm run check`(Spectral・操作の表の `--check`・対応の検査・型検査・Vitest) | + +MCP のツールの定義(名前・説明・入力の定義)は操作の表から作る。対応の規則は [契約の表の「MCP のツール」](issue-2384-design-contracts.md#mcp-のツール) にある。 + +### 画面 + +| 画面 | 何をする場所か | +| --- | --- | +| エージェントへの委任(`/delegations`、新設) | 委任の資格情報を発行し、値を 1 度だけコピーし、一覧で期限と状態を見て、取り消す | +| 自分のアカウント(既存) | 遷移の元。「エージェントへの委任」へのリンクを足す | + +```mermaid +graph TD + A[自分のアカウント] --> D[エージェントへの委任] + D -->|発行する| S[値を 1 度だけ表示] + S -->|閉じる| D + D -->|取り消す| D +``` + +| 画面 | 項目 | 型 | 必須 | 上限 | 満たさないときの表示 | +| --- | --- | --- | --- | --- | --- | +| エージェントへの委任 | クライアントの名前 | 文字列 | 必須 | 64 字 | 「名前を入力してください」 | +| エージェントへの委任 | 範囲 | 読み取りだけ / 読み書き | 必須 | — | 既定は読み取りだけ | +| エージェントへの委任 | 期限 | 15 分 / 30 分 / 1 時間 | 必須 | 1 時間 | 既定は 1 時間 | + +| 状態 | 表示 | +| --- | --- | +| 読み込み中・エラー・空 | 既存の部品(`LoadingState`・`ErrorState`・`EmptyState`)。空は「まだ委任していません」 | +| 発行の直後 | 値と、MCP のクライアントへの登録の例(URL と `Authorization` のヘッダー)。閉じると値は二度と出ない | +| 一覧の行 | クライアントの名前・範囲・発行と期限の日時(利用者の時刻帯)・状態(有効 / 期限切れ / 取り消し済み)・取り消すボタン(有効な行だけ) | +| オフライン | 発行と取り消しのボタンを無効にする(取り消せない操作をオフラインで確定させない。スプリント Value 3) | + +権限: ログインした人なら役割によらず使える。委任は利用者に属し、アカウントに属さない。ナビゲーションには出さず、自分のアカウントからたどる。画面の地図(`docs/specifications/ndf-factory-screen-map.md`)に 1 行を足す。 + +## 処理の流れ + +```mermaid +graph TD + Q[要求を受ける] --> V{資格情報の検証} + V -->|無効・期限切れ・取り消し済み| R401[401] + V -->|有効| O{操作の表に
ある経路か} + O -->|無い| R404[404] + O -->|ある| H{委任で
人だけの操作か} + H -->|はい| R403H[403 human_only] + H -->|いいえ| S{読み取りだけの範囲で
書き込みか} + S -->|はい| R403S[403 scope_insufficient] + S -->|いいえ| N{要素は 1〜100 個
で重複なしか} + N -->|いいえ| R400[400] + N -->|はい| P{経路のアカウントを
解決できるか} + P -->|いいえ| R403[403 forbidden] + P -->|はい| E[要素ごとに認可して
ユースケースを呼ぶ] + E --> RES[200 と結果の配列] + R401 --> AU[操作の記録を追記] + R404 --> AU + R403H --> AU + R403S --> AU + R400 --> AU + R403 --> AU + RES --> AU + AU -->|失敗| R503[503 audit_unavailable] + AU -->|成功| OUT[応答を返す] +``` + +- **MCP からの呼び出し**: MCP の受け口が `tools/call` を受け、操作の表から経路・query・本文を組み、同じアプリへ内部の要求として送る。内部の要求は上の流れをそのまま通り、呼び出し元の種類だけが `mcp` になる。結果は MCP のツールの結果(`structuredContent` に応答の本文、`isError` は状態が 400 以上のとき真)へ写す。`tools/list` と、MCP の受け口そのものへの要求は操作ではないため記録しない +- **MCP の受け口の資格情報**: 委任の資格情報だけを受ける。Cognito の token とほかの値は 401 にする。`Origin` のヘッダーがあり画面のオリジンと違えば 403 にする(MCP の仕様の Streamable HTTP の要求) +- **記録の追記の失敗**: 構造化ログ(今の `jsonLogger` の出力先)へ、記録の全体を `audit_fallback` の印つきで 1 行出してから 503 を返す。書き込みの操作は既に行われていることがあるため、呼ぶ側は同じ要求を送り直す。作成は冪等の鍵で、更新・削除・取り消しは同じ値の書き込みとして、送り直しで 2 重にならない + +### 委任の資格情報の状態 + +```mermaid +stateDiagram-v2 + [*] --> 有効: 人が画面で発行する + 有効 --> 期限切れ: 期限の日時を過ぎる + 有効 --> 取り消し済み: 人が画面で取り消す + 期限切れ --> [*] + 取り消し済み --> [*] +``` + +期限切れの行は取り消せない(取り消しは何も変えずに成功を返す。E9)。期限切れと取り消し済みから有効へ戻る遷移は無く、延長もしない。延ばしたいときは新しく発行する。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 配列の要素は 1 回の要求で 100 個まで。超えたら 400 と上限値を返す | 操作の登録の層が、query の繰り返しと本文の配列の数を、ユースケースを呼ぶ前に数える。要素は並べる数 10 で処理する(今の一覧の読み出しと同じ値) | 101 個で 400 と `details.limit` 100、0 個で 400 を Vitest で見る | +| 性能・拡張性 | 画面は今と同じ回数以下の要求で同じ画面を描ける | 詳細・工程の中身・バックログ・ループの詳細を、要素の数によらず 1 回の要求で取る | 画面のテストで、詳細 3 件を開いたときの要求の数が 1 であることを数える | +| 運用・保守性 | 1 つの操作を、操作の記録の 1 件と要求の識別子でたどれる。秘密と個人情報の値は記録に書かない | 記録の主キーを要求の識別子にし、構造化ログの行にも同じ値を出す。記録の項目は許可した鍵からだけ組む | 受け入れ条件 19・22・23 のテスト | +| 移行性 | 旧い形の経路との並走は持たない。画面と API は同じ Pull Request の系列で同時に切り替える | 契約の書き換え・経路の登録し直し・画面の API クライアントを、実装の 1 本目の Pull Request に入れる(決定 15) | 画面の Vitest と Playwright が 1 本目で通る | +| セキュリティ | 委任の資格情報は期限 1 時間以内で、取り消しの後の最初の要求から断る。値は保存の時点でハッシュにし、平文で持たない。人だけが確定できる操作は委任から断る | 不透明な値を要求のたびに強い整合で引き、`secret_hash` を一定時間の比較で照らす。人だけの操作は操作の表の扱い `human` で、ユースケースを呼ぶ前に断る | 受け入れ条件 11〜14・17 のテスト | +| システム環境 | MCP のサーバーは `factory/api/` と同じ Node 24 の上で動き、新しい実行基盤を足さない | 既存の API の Lambda に `POST /mcp` を足す。依存は `@modelcontextprotocol/server`(と `zod`)と、操作の表の生成に使う `js-yaml`(既に Spectral の依存として入っている)だけ | `npm run check` と、手元で MCP のクライアントから `tools/list` を打つ | + +### 記録の件数と費用の見積り(未決 3) + +画面は見えている間、30 秒ごとに読み直す(`factory/web/src/poll.ts` の `DEFAULT_POLL_INTERVAL_MS`)。開いた画面 1 つで 1 時間に 120 件の記録になる。 + +| 仮定 | 値 | +| --- | --- | +| 1 人が画面を開いている時間 | 1 日 2 時間・月 22 日(仮定。実測ではない) | +| 1 人の月の記録 | 2 × 22 × 120 = 5,280 件 | +| 記録 1 件の大きさ | 0.5 KB 前後(列の数からの見積り。書き込み 1 回は 1 KB まで 1 単位) | +| 単価(米国東部、オンデマンド) | 書き込み 100 万単位で $0.625、保存 1 GB・月で $0.25( https://aws.amazon.com/dynamodb/pricing/on-demand/ の計算例、2026-11-04 に取得) | + +利用者 1,000 人で月 528 万件、書き込みは月 $3.3 である。保存は月 2.6 GB ずつ増え、12 か月で約 32 GB・月 $7.9 になる。無料枠は数えていない。保存期間を決めると(未決 2)保存の費用は頭打ちになる。 + +## 決定の記録 + +決定の記録は [issue-2384-design-decisions.md](issue-2384-design-decisions.md) にある。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 1・I11 | 画面の API クライアントが組む要求の `operationId` がすべて操作の表にある。ソースに経路の文字列を直に書いた要求が無い | クライアントに表に無い経路の要求を 1 つ足す | +| 2・I11 | 操作の表の利用者向けの操作と、MCP の受け口が `tools/list` で返すツールの名前の集合が一致する | 契約に操作を 1 つ足し、ツールを作らない(またはその逆) | +| 3・I15 | MCP のツールの呼び出しが、API の経路と同じユースケースを通り、同じ結果を返す。MCP の受け口はアプリの呼び出しの関数しか受け取らない | MCP の受け口から保存の関数を直に呼ぶ | +| 4・5・I10 | 利用者向けの操作の成功の応答と要求の本文が配列で、経路の変数が `accountId` だけである | 1 件の取得の経路 `/sprint-runs/{sprintRunId}` を契約へ戻す | +| 6・8・I12 | 101 個で 400 と上限値 100、0 個で 400、同じ鍵 2 つで 400。どれもユースケースが呼ばれない | 数の検査を外す | +| 7・15・I13 | 3 要素のうち他人の 1 要素だけが `forbidden` で、順と数が入力と同じ。他人のデータが応答に無い | 1 要素の 403 で要求全体を 403 にする / 結果を並べ替える | +| 9・I14 | 同じ鍵の作成 2 回でレコードが 1 件、2 回目が同じ識別子。鍵の行と作るものが同じトランザクションにある。違う作成の操作へ同じ鍵を送るとレコードが 2 件になる。鍵の行の `ttl` を過ぎた時刻(行は残したまま)に同じ鍵を送ると、新しい識別子のレコードを作る。委任の資格情報の 2 回目の作成は同じ ID を返し、`credential` が `null` である | 鍵の行を作成の後に別に書く / 鍵に `operationId` を含めない / 書き込みと読み出しで `ttl` を今の時刻と比べない / 2 回目に秘密を返す | +| 10・I6 | 人の token と、同じ人の委任の資格情報で同じ操作を呼び、許可と拒否が一致する | 委任の側にだけアカウントの照合を足す(または外す) | +| 11・12・I2 | 期限の後と取り消しの後の最初の要求が 401 で、ユースケースが呼ばれない | 資格情報の照合を 1 分ためる(キャッシュする) | +| 13・I5 | 読み取りだけの範囲で書き込みの操作が 403 | 範囲の照合を外す | +| 14・16・17・I4 | 扱い `human` の 10 の操作すべてを読み書きの範囲で呼び、403 で何も保存されない。`answerLoopPending` に扱い `human` がある | 操作の扱いを `write` に変える | +| 18 | 委任の資格情報でスプリントの詳細と承認待ちの一覧が読める | 読み取りの操作を `human` にする | +| 19・20・I7 | 成功・一部の失敗・400・401・403 のどれでも記録が 1 件増え、要求の 7 項目がそろう | 断った要求で記録を書かない | +| 21・I8 | 保存の契約の関数の一覧が追記 1 つだけ | 保存の契約に更新の関数を足す | +| 22・23・I9 | モデルの認証情報・ログインの token・委任の資格情報の値・GitHub の `code` と `proof`・チームの名前・クライアントの名前を含む要求を送り、記録のどの値にもそれらが無い。記録の列は許可した鍵と固定の列だけで、メールアドレス・氏名・課題の題と本文を写す列が無い | 許可した鍵の一覧に `name` を足す | +| 24 | 画面の既存の Vitest と Playwright が通る | 画面の API クライアントの応答のほどき方を誤る | +| 25 | ランナー向けの経路と受け口の既存のテストが変えずに通る | ランナー向けの経路を操作の登録の層へ入れる | +| 26 | 委任を使わない人の操作で、token の検証の失敗が 401、アカウントの解決の失敗が 403 | 資格情報の検証の分岐で Cognito の失敗を 403 にする | +| I1 | 期限 299 秒と 3,601 秒の発行が 400 で保存されない | 期限の検査を外す | +| I3 | 保存した行に秘密の平文が無く、ハッシュが値と一致する | 平文を保存する | +| I16 | 外から `x-ndf-via: mcp` などのヘッダーを付けた API の要求が `api` として記録される | ヘッダーから呼び出し元を読む | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2384 | 実装する | — | `factory/contracts/`、`factory/api/src/`、`factory/web/src/`、`docs/specifications/ndf-factory-screen-map.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 要求の URL の長さ | 100 個の識別子を query で繰り返すと 5 KB 前後になる。API Gateway の HTTP API の上限は未確認。実装の 1 本目で、100 個の要求を本物の経路へ送って確かめる。収まらなければ要求の上限の値(前提 3)を下げる | +| 委任の 1 時間の期限と長く動くエージェント | 1 時間を超えて見守る使い方(具体例の「承認待ちになったら教えて」)は、人が 1 時間ごとに発行し直すことになる。MCP の認可の仕様(OAuth 2.1 と更新の token)に沿わせると解ける。承認ゲート 1 で、この形で出すかを見る(決定 7) | +| 記録の大きさと件数 | 1 件 0.5 KB と、1 日 2 時間の利用は仮定である。実装の後に本物の記録の大きさを測る | +| 配置の地域 | 単価は米国東部の値で、Factory の配置の地域はこのリポジトリから確かめられない | +| 記録の保存期間と削除の求め | 未決 2 のまま。外部の利用者へ公開する前に利用者が決める | +| オーガナイザーの画面 | 承認待ちの一覧・配布の履歴・止まった配布を解く 3 操作は契約にあり画面に無い。この課題は配列の形と MCP のツールを足し、画面は #2439 が作る | +| 規模(未決 4) | 設計は 1 本、実装は 4 本の Pull Request に分ける案を決定 15 に置いた。承認ゲート 1 で利用者が決める | diff --git a/issues/issue-2384-requirements.md b/issues/issue-2384-requirements.md new file mode 100644 index 000000000..dff6d9e5b --- /dev/null +++ b/issues/issue-2384-requirements.md @@ -0,0 +1,209 @@ +# #2384: NDF Factory: 画面で行える操作を API と MCP からも同じく行える(機能から起こした配列の入出力・委任の認証・追記だけの操作の記録) + +正は課題の本文(#2384)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +親: #2168(Neural Development Factory を Web UI で操作できるサービスにする)。方針: マイルストーン「25 Neural Development Factory」のスプリント MVV の Value 2(画面先行)・Value 4(AI からも人と同じく使える)。 + +## 具体例 + +利用者が、自分の手元の Claude Code から「Factory で #2172 のスプリントを始めて、承認待ちになったら教えて」と頼む。今の Factory には MCP(Model Context Protocol)の受け口が無く、API は画面の都合で 1 件ずつ受け渡す形が混ざっている。この課題の後は、エージェントが利用者の委任で MCP からスプリントを始め、一覧を読み、承認の依頼を読める。ただし承認の確定は人だけが行う。だれの委任で、どのエージェントが、いつ何をしたかは、追記だけの記録に残る。 + +## 依頼(原文) + +> | AI からも人と同じく使える | 画面で行えるすべての操作を API と MCP からも行える。画面も同じ API を通る。委任を明示する認証・人と同じ認可・追記だけの操作の記録を持つ。承認ゲートとレッドラインの承認は人だけが行う | +> +> | 画面先行 | UX の土台(画面の地図・部品・端末・オフラインの見せ方・国際化)→ 機能の画面をダミーデータで作り切る → API の中身をつなぐ、の順で作る。API の入出力は画面の 1 枚に合わせず機能から起こし、常に配列で受け渡す | + +(書き手: 利用者、日付: 2026-11-02。#2168 の「方針(利用者の決定)」の表の 2 行。表は起票者が書き起こしたもので、利用者の決定として承認された行である) + +> 画面で行えるすべての操作を API と MCP からも行えるようにする。画面も同じ API を通して動かし、画面にしか無い操作も、API や MCP にしか無い抜け道も作らない。ただし、承認ゲートの承認と、共通原則の C1〜C8・プロジェクト MVV の P に当たる操作の承認は人だけが行う。API と MCP からは承認の依頼と材料を読めるが、承認を確定できない(AI のエージェントの委任でも同じ)。API と MCP を使う者は、だれの委任で、どのエージェント・クライアントが動くかを確かめ、認可は人の操作と同じく掛ける。読み取りを含むすべての操作を、だれが・いつ・どの経路で・何を行ったかの追記だけの記録に残す。秘密と個人情報の値は記録に書かない。 + +(書き手: 利用者の承認を受けたスプリント MVV の Value 4、日付: 2026-11-02。文面は起票者が利用者の方針から起こし、利用者がスプリント MVV として承認した) + +課題の題と、この後の「何をするか」の表・受け入れ条件の案(起票時の本文)は起票者が書いた。原文に無い条件は「起票者の推定」に分けた。 + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 配列の受け渡しで、一部だけ失敗したときの返し方・冪等性・配列の上限を決める | 起票時の本文の「入出力の形」の行 | 受け入れ条件 6〜9、前提 3 | +| S2 | 委任の権限は短い期限で、範囲を絞り、取り消せる | 起票時の本文の「委任の認証」の行 | 受け入れ条件 11〜14、前提 4 | +| S3 | 認可は要素ごとに掛ける | 起票時の本文の「認可」の行 | 受け入れ条件 15 | +| S4 | 操作の記録の項目は、操作した人・委任を受けたエージェントかクライアント・日時(UTC)・呼び出し元の種類(画面・API・MCP)・操作・対象の ID | 起票時の本文の「操作の記録」の行。原文は「だれが・いつ・どの経路で・何を」 | 受け入れ条件 19 | +| S5 | 画面の操作と契約の操作と MCP のツールの対応を、検査で確かめる | 起票時の受け入れ条件の案 1 | 受け入れ条件 1〜3 | +| S6 | C1(秘密に触れる)・C2(認証・認可を変える)に当たる Factory の操作(モデルの認証情報の保存と削除、席の追加・変更・削除、チームの作成)は、委任の資格情報からは確定させず、人が画面で行う | 起票者の推定(この要求で足した)。原文の「C1〜C8 に当たる操作の承認は人だけが行う」を、Factory には操作ごとの承認の仕組みが無いため「委任では確定させない」と読んだ | 受け入れ条件 17、前提 5 | +| S7 | ランナー向けの経路(実行ジョブの経路・進捗の受け口)と GitHub の Webhook は、配列の規則と MCP の対象から外す | 起票者の推定(この要求で足した)。画面で行える操作ではなく、GitHub の Webhook の形は Factory が決められないため | 前提 2、含まない | + +## 目的 + +- 人が任せた AI のエージェントや連携するシステムが、画面と同じ操作を、画面と同じ認可と記録のもとで行える状態にする +- API の入出力を画面の 1 枚から切り離し、機能(エンティティと操作)単位の配列の形へそろえる。画面・API・MCP が同じユースケースを通る +- 承認ゲートの承認と、人の承認が要る操作の確定を、委任の経路から行えないことを仕組みで保証する +- だれの委任で、どのエージェントが、いつ何をしたかを後から確かめられる記録を残す + +## 前提 + +- 前提 1: 「画面で行える操作」は、画面の API クライアント(`factory/web/src/api.ts` の `Api`)が呼ぶ操作の全体と、承認と通知(#2173)が足す Web からの承認の操作を指す。2026-11-04 の時点で `Api` は 31 の操作を持つ(`request(` の呼び出し 31 件) +- 前提 2: 配列の規則と MCP のツールの対象は、利用者(人か、人の委任を受けたエージェント)が呼ぶ操作である。ランナー向けの経路(`/run-jobs/*`・`/ingest/events`・`/ingest/api/events`)と GitHub の Webhook(`/ingest/github`)は対象に含めない(S7) +- 前提 3: 1 回の要求で受け渡す配列の要素は 100 個までとする。値は設計で実測を見て変えてよいが、上限を超えた要求は処理せずに 400 と上限値を返す、という振る舞いは変えない +- 前提 4: 委任の資格情報の期限は 1 時間以内とする。範囲は「読み取りだけ」と「読み書き」の少なくとも 2 つを選べる。値と範囲の分け方は設計で決める +- 前提 5: GitHub App の導入とリポジトリへの認可は、人がブラウザで GitHub の画面を操作する流れである。MCP からは導入と認可の URL を返すところまでを行い、GitHub の画面の操作は人が行う +- 前提 6: 委任の資格情報の発行と取り消しは、人が画面で行う。委任の資格情報で委任の資格情報を発行できない +- 前提 7: 既存の契約(`factory/contracts/openapi/` の 5 ファイル)の経路を配列の形へ変える。画面と API は同じリポジトリで同時に変わり、外部の利用者はまだいないため、旧い形との互換の経路は持たない +- 前提 8: 操作の記録を置く表の定義は、インフラ(private リポジトリ)が作る。このリポジトリは保存の契約(interface)と、メモリの実装・本物の実装を持つ + +## 対象範囲 + +含む: + +- 契約(`factory/contracts/openapi/`)の利用者向けの操作を、機能(エンティティと操作)単位の配列の入出力へ起こし直すこと +- 画面の API クライアントと、ダミーの API クライアントを新しい契約の形へ合わせること +- MCP のサーバー(API と同じユースケースを呼ぶ)と、契約の操作と MCP のツールの対応表 +- 委任の資格情報の発行・検証・取り消しと、それを発行・取り消す画面 +- 人だけが確定できる操作の印と、その印のある操作を委任の資格情報から断る共通の処理 +- 操作の記録(追記だけ)の保存の契約・実装と、すべての利用者向けの操作からの書き込み +- 画面の操作・契約・MCP の対応と、単一のレコードの受け渡しが無いことを確かめる検査 + +含まない: + +- ランナー向けの経路と GitHub の Webhook の形(前提 2) +- Web からの承認の操作そのもの(#2173 が作る)。この課題は、その操作に人だけが確定できる印が付き、委任の資格情報から断られる仕組みを用意する +- 操作の記録を読む画面と、記録の検索の機能 +- 操作の記録の保存期間と、国ごとの法令(GDPR など)に応じた削除の手続き(未決 2) +- 外部の利用者向けの公開の手続き(MCP のサーバーの一覧への掲載など) +- インフラの定義(表・Lambda・API Gateway) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 人が委任の資格情報を発行した | 人が画面で、委任するクライアントの名前・範囲・期限を選んで発行する | 範囲か期限が許す値の外なら 400 で、発行しない | 人がログインしている | +| E2 | エージェントが委任の資格情報で API か MCP を呼んだ | エージェントが操作を送る | 資格情報が無効・期限切れ・取り消し済みなら 401 で、操作を行わない | E1 | +| E3 | 委任の資格情報の範囲と、操作の要る範囲を照らした | E2 | 範囲の外なら 403 で、操作を行わない | E2 | +| E4 | 人と同じ認可を要素ごとに掛けた | E3 | 認可の無い要素は、その要素だけを 403 相当の結果にする | E3 | +| E5 | 人だけが確定できる操作を断った | E3 で、操作に人だけが確定できる印がある | — (断ること自体が結果) | E3 | +| E6 | 配列の要素ごとに操作を行い、要素ごとの結果を返した | E4 | 一部の要素だけが失敗したら、成功した要素はそのまま残し、失敗した要素に理由を付けて返す | E4 | +| E7 | 同じ冪等の鍵の作成を 2 回受け、2 回目は 1 回目の結果を返した | E6 で、作成の要素に既に使われた冪等の鍵がある | — | E6 の 1 回目 | +| E8 | 操作の記録を 1 件追記した | E2〜E6 のすべての結果(成功・一部失敗・401・403・400) | 追記に失敗したら操作を失敗として返す(前提として未決 1) | E2 | +| E9 | 人が委任の資格情報を取り消した | 人が画面で取り消す | 既に取り消し済みなら何も変えずに成功を返す | E1 | +| E10 | 取り消した委任の資格情報での要求を断った | E9 の後の E2 | — | E9 | + +E8 の「追記に失敗したとき操作も失敗にするか」は、記録の欠けを許すかと可用性の取り合いである。未決 1 に置いた。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 委任の資格情報 | 人が画面で発行し、エージェントかクライアントに渡す、期限と範囲つきの資格情報。だれの委任か(利用者)と、どのクライアントか(人が付けた名前と識別子)を持つ。人が取り消せる | +| 人だけが確定できる操作 | 承認ゲートの承認・判断の依頼への答えと、共通原則の C1〜C8・プロジェクト MVV の P に当たる Factory の操作。契約に印を持ち、委任の資格情報からは断る | +| 操作の記録 | 利用者向けの操作 1 回ごとに 1 件追記する記録。書き換えと削除の経路を持たない | +| 呼び出し元の種類 | 操作の記録に残す、操作がどこから来たかの区分。画面・API・MCP の 3 つ | +| 機能から起こした契約 | 経路と入出力を、画面の 1 枚でなくエンティティと操作から決めた契約。取得・作成・更新・削除とも配列で受け渡す | + +## 受け入れ条件 + +対応と形: + +- [ ] 1. 画面の API クライアントが呼ぶ操作がすべて契約(`factory/contracts/openapi/`)の `operationId` に対応することを、検査(`npm run check` の一部)が確かめる。対応しない操作が 1 つでもあると検査が落ちる +- [ ] 2. 契約の利用者向けの操作がすべて MCP のツールに対応し、MCP のツールがすべて契約の操作に対応することを、検査が確かめる。片方にしか無い操作かツールが 1 つでもあると検査が落ちる +- [ ] 3. MCP のツールは、API の経路と同じユースケースの関数を呼ぶ。MCP のツールから、契約に無い保存の操作を呼べないことをテストで確かめる +- [ ] 4. 契約の利用者向けの操作で、要求の本文か応答の本文が配列でない操作が無いことを、検査が確かめる(1 件の取得・作成・更新・削除も要素 1 個の配列で受け渡す) +- [ ] 5. 経路の変数で単一のレコードを指す取得(`/sprint-runs/{sprintRunId}` の形)が、利用者向けの操作の契約に無いことを、検査が確かめる + +配列の受け渡し: + +- [ ] 6. 101 個の要素を送ると、どの要素も処理されずに 400 が返り、応答に上限の値 100 が入る +- [ ] 7. 3 個の要素のうち 1 個だけに認可が無いとき、応答は入力と同じ順に 3 個の結果を返し、認可の無い 1 個だけに理由(`forbidden`)が付き、残り 2 個は処理される +- [ ] 8. 0 個の要素を送ると、何も処理されずに 400 が返る +- [ ] 9. 作成の要素に同じ冪等の鍵を付けて 2 回送ると、レコードは 1 件だけでき、2 回目の応答は 1 回目と同じ識別子を返す + +委任の認証: + +- [ ] 10. 人のログインの token で呼んだ操作と、同じ人が発行した委任の資格情報で呼んだ同じ操作は、同じ認可の判定になる(人に許された操作は委任でも許され、人に断られる操作は委任でも断られる) +- [ ] 11. 期限を過ぎた委任の資格情報で呼ぶと 401 になり、操作は行われない +- [ ] 12. 取り消した委任の資格情報で、取り消しの後に呼んだ最初の要求が 401 になる +- [ ] 13. 「読み取りだけ」の範囲の委任の資格情報で作成・更新・削除の操作を呼ぶと 403 になり、操作は行われない +- [ ] 14. 委任の資格情報で委任の資格情報の発行を呼ぶと 403 になる(前提 6) +- [ ] 15. 認可は要素ごとに掛かる。他人のアカウントの要素を混ぜた配列で、他人の要素だけが断られ、応答に他人のデータが含まれない + +人だけが確定できる操作: + +- [ ] 16. 契約で、人だけが確定できる操作に印が付いている。ループの判断待ちへの答え(`answerLoopPending`)には印がある +- [ ] 17. 印のある操作を委任の資格情報で呼ぶと、範囲が「読み書き」でも 403 になり、答えも状態も保存されない。印のある操作をすべて委任の資格情報で呼ぶテストがある(S6 の操作を含む) +- [ ] 18. 委任の資格情報で、承認の依頼とその材料(スプリントの詳細・承認待ちの一覧)は読める + +操作の記録: + +- [ ] 19. 利用者向けの操作 1 回ごとに、操作の記録が 1 件増える。記録は、操作した利用者の ID・委任の資格情報の識別子(人の直の操作では空)・日時(UTC)・呼び出し元の種類(画面・API・MCP)・`operationId`・対象の ID の配列・結果(成功・一部失敗・断った理由)を持つ +- [ ] 20. 読み取りの操作と、401・403・400 で断った操作も記録に残る +- [ ] 21. 操作の記録の保存の契約に、書き換えと削除の関数が無い。テストが契約の関数の一覧を確かめる +- [ ] 22. モデルの認証情報(API キー・ロールの ARN)・ログインの token・委任の資格情報の値・GitHub の token を含む要求を送っても、記録にそれらの値が入らないことをテストで確かめる +- [ ] 23. 記録に、メールアドレス・氏名・チームの名前・課題の題と本文が入らない + +退行しないこと: + +- [ ] 24. 契約の形を変えた後も、画面の既存の画面テスト(`factory/web` の Vitest と Playwright)が通る +- [ ] 25. ランナー向けの経路(`/run-jobs/*`・`/ingest/*`)の入出力が変わらない(前提 2) +- [ ] 26. 委任の資格情報を使わない人の操作で、今と同じく 401(token の検証の失敗)と 403(アカウントの解決の失敗)が返る + 前提の確認: `factory/api/src/auth/verify.test.ts` と `factory/api/src/handlers/api.test.ts` が今の 401・403 を確かめている(実装の前に `npm run check` で通ることを確かめる) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 配列の要素は 1 回の要求で 100 個まで。超えたら 400 と上限値を返す(前提 3)。画面は今と同じ回数以下の要求で同じ画面を描ける(今は詳細を要素の数だけ別々に取っている。`factory/web/src/api.ts` の `sprintRunDetails`・`stepDetails`・`backlogs`) | +| 運用・保守性 | 1 つの操作を、操作の記録の 1 件と、その操作の要求の識別子でたどれる。秘密と個人情報の値は記録に書かない(受け入れ条件 22・23) | +| 移行性 | 旧い形の経路との並走は持たない(前提 7)。画面と API は同じ Pull Request の系列で同時に切り替える | +| セキュリティ | 委任の資格情報は期限 1 時間以内で、取り消しの後の最初の要求から断る。委任の資格情報の値は保存の時点でハッシュにし、平文で持たない。人だけが確定できる操作は委任から断る | +| システム環境 | MCP のサーバーは `factory/api/` と同じ Node 24 の上で動き、新しい実行基盤を足さない。足す場合は理由と代わりの案を設計に書く(スプリント Value 6) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。`factory/contracts/openapi/` の利用者向けの経路の入出力が配列の形になる。`互換なし`(前提 7。Factory はまだ外部の利用者に公開していない) | +| データ | 操作の記録の表と、委任の資格情報の表が増える(定義はインフラが作る)。既存の表の形は変わらない | +| 既存の振る舞い | 画面の API クライアントとダミーの API クライアントが新しい形を呼ぶ。利用者から見た画面の振る舞いは変わらない | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| 起動 | `cd factory && npm run dev:dummy -w web`(ダミーの API クライアントで画面を起動する。手順は `docs/specifications/ndf-factory-screen-map.md` の「ダミーで起動する」) | +| テスト | `cd factory && npm run check`(契約の検査・各 workspace の型検査と Vitest)と `npm run e2e -w web`(Playwright) | +| 静的解析・型検査 | `npm run check` に含まれる `tsc --noEmit` と OpenAPI の lint(Spectral) | +| 手動確認 | リリース後に、人が手元の Claude Code に Factory の MCP のサーバーを登録し、委任の資格情報でスプリントの一覧を読めることと、承認の依頼に答えようとすると断られることを見る | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 変更は `factory/` の中に置く。`plugins/` と `.claude-plugin/marketplace.json` は `factory/` を参照しない(`python3 scripts/check-service-boundary.py`)。インフラの定義と秘密は private リポジトリに置く(`AGENTS.md` の「マーケットプレイスの構造」) | +| コーディング規約 | `factory/` の TypeScript の規約と `npm run check`(`docs/specifications/ndf-factory-accounts-and-plans.md` の「パッケージの管理」)。契約の正は OpenAPI で、型は生成する(`docs/specifications/ndf-factory-contracts-and-progress.md`) | +| テスト戦略 | 契約と対応の検査は `factory/contracts/tests/`、委任・認可・記録の振る舞いは `factory/api/` の Hono の `app.request()` を使うテスト、画面は既存の Vitest と Playwright | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | `npm run check` の実行、契約からの型の生成、変更する経路のテストの追加 | +| 確認してから行う | 依存パッケージの追加(MCP の SDK など)、契約の経路の名前の変更、記録の項目の追加。いずれも承認ゲート 1・2 で人が見る | +| 行わない | ランナー向けの経路の形の変更、インフラの定義の追加、`plugins/` から `factory/` への参照、操作の記録への秘密と個人情報の値の書き込み | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 操作の記録の追記に失敗したとき、操作も失敗にするか(記録の欠けを許さない側)、操作を通して後から補うか(可用性の側) | 設計で案を出し、承認ゲート 1 で利用者が決める | 設計 PR のマージ | +| 2. 操作の記録の保存期間と、個人の削除の求め(GDPR など)への応じ方。記録が持つ利用者の ID を、削除の求めのときにどう扱うか | 利用者 | 外部の利用者へ公開する前 | +| 3. 読み取りを含むすべての操作を記録する費用。画面の再読み込み(`factory/web/src/poll.ts`)の回数を含めた月の件数と保存の費用の見積り | 設計で実測から見積り、承認ゲート 1 で利用者が見る | 設計 PR のマージ | +| 4. 規模(Size 13)が 1 回の承認で読み切れる量に収まるか。収まらないなら、契約の配列化・委任の認証と人だけが確定できる操作・操作の記録・MCP のサーバーの 4 つへ分けるか | 設計で分け方の案を出し、承認ゲート 1 で利用者が決める | 設計 PR のマージ | +| 5. MCP の委任の認証を、MCP の認可の仕様(OAuth 2.1)に沿わせるか、Factory が発行する資格情報を直に渡す形にするか | 設計 | 設計 PR のマージ | + +## 関連 + +- 前提にする契約: `docs/specifications/ndf-factory-accounts-and-plans.md`(認証)・`docs/specifications/ndf-factory-contracts-and-progress.md`(契約) +- #2382(UX の土台)のダミーの API クライアントは、この契約の形へ合わせる +- #2173(承認ゲートの通知と Web からの承認)が足す承認の操作は、人だけが確定できる操作の印を持つ diff --git a/issues/issue-2388-design.md b/issues/issue-2388-design.md new file mode 100644 index 000000000..d565fb08f --- /dev/null +++ b/issues/issue-2388-design.md @@ -0,0 +1,295 @@ +# 設計のプラン: 上限で終わった cross-review は、未解決の指摘を解決してから MVV 判定へ進める(#2388) + +## 目的 + +- **今どうなっているか**: 設計のプランの cross-review が上限の 3 ラウンドに達して終わると、駆動の失敗と同じ経路(`ready` → 承認ゲート 1 の判断)へ進む。未解決の指摘が 0 件でも同じである。`pace: fast` / `pace: auto` と開発ループでは MVV 判定が一度も流れず、承認ゲート 1 が「判定できない」として人の判断待ちになる +- **誰に効くか**: 設計のプランを流すすべての利用者。`pace: fast` / `pace: auto` と開発ループでは、1 回あたり約 $1.5・27 分の周回の無駄が無くなる +- **変えると何が成り立つか**: 上限に達して未解決 0 件で終わったレビューは、収束したときと同じ経路(直しの取り込み → 仕様のコピーの照合 → 用語チェック → MVV 判定)を通る。未解決の指摘が残ったら、承認ゲート 1 へ進む前に解決のフロー(直し → 再レビュー)で解決する。どうしても解決できないと判定したときと、駆動の失敗だけが承認ゲート 1 の判断へ回る + +## 適用範囲 + +- **働く範囲**: NDF の `supervise.py` が組み立てる設計のプラン(スプリントの設計のステージ)。配布先のリポジトリでも同じように働く +- **プロジェクトごとに違うもの**: 無し(ラウンドの上限・ペースは既存の設定のまま読む。判定の条件の値はプランの組み立てが引数で渡し、この変更で足す設定は無い) +- **当たるモード**: 設計のプランを流すすべてのモード。ペースは `normal` / `fast` / `auto` のすべて + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 開発ループ lfactory2 の周回 2 の記録(設計 PR https://github.com/devbasex/ai-plugins/pull/2387 ): 上限 3 ラウンド・指摘 3 件・修正 3 件・未解決 0 件・最終状態 `oscillation`・終了コード 6 で、`mvv` が流れずに承認ゲート 1 が人の判断待ちになった | 実測 | 上限到達・未解決 0 件の終わりが、今の経路では MVV 判定へ届かない | +| `pace: fast` / `auto` の設計のプランの規則の文(`plugins/ndf/scripts/supervise_lib/sprint_waves.py` の `plan_mvv_design`): 設計の cross-review は上限 3 ラウンドで承認ゲート 1 の判定(`mvv` のステップ)へ渡し、収束を待たない。駆動そのものが失敗したら `gate` | 既存の規則 | 上限で止めたレビューは MVV 判定へ渡すことが、プランの意図としてすでに書かれている | +| `grep -n "unresolved\|review\|oscillation" plugins/ndf/scripts/mvv-gate.py` の当たり 0 件(2026-11-04) | 実測 | MVV 判定は未解決の件数を読まない。未解決が残ったまま渡すと人が見ないまま通りうるため、件数はプランの側で分ける | +| 承認ゲート 1 の答え「直して出し直す」の指摘 2・3(2026-11-04、#2388 の本文の「依頼(原文)」) | 利用者の決定 | 未解決の指摘は承認ゲート 1 の前に解決する。分け方は全ペースの共有の定義に置く | + +要求と受け入れ条件は #2388 の本文にある(コピーは `issues/issue-2388-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +この文書での呼び方は、要求の前提 1 に合わせて次のとおりにする。 + +| 呼び方 | 意味 | +| --- | --- | +| 上限到達 | cross-review の駆動が終了コード 6 で終わったこと(最終状態が `max_rounds` か `oscillation`)。レビューは成立している | +| 駆動の失敗 | cross-review の駆動が 0・6 以外で終わったこと(実行器は 1 として記録する。最終状態 `error` と、CLI が起動しないなど) | +| 未解決の件数 | cross-review の結果の件数(`metrics.unresolved`。最終の見直しの後に GitHub 側で数え直した、解決していない指摘の数) | +| 解決のフロー | 上限到達で未解決の指摘が残ったときに、直しを PR のブランチへ送り、cross-review を打ち直してラウンドを足すまでの 1 回の繰り返し | +| 解決できない | 下の「解決できないの判定」の条件のどれかが成り立ったこと | + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | プラン・ステップ・MVV 判定・承認ゲート・欠け・解決のフロー | +| NDF の cross-review(`ndf-cross-review`) | 収束・上限到達・未解決の件数 | + +cross-review が供給者、開発ワークフローが順応者の関係である。開発ワークフローは、cross-review の駆動の終了コードと結果の件数(`metrics.unresolved`)をそのまま読む。cross-review の契約は変えない(要求の対象範囲の「含まない」)。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 設計のプラン | プランの組み立て(`supervise_lib/sprint_waves.py`) | プラン | ステップ | 行き先(`next` / `on_fail` / `on_exit`)・規則の文・判定の条件の値 | +| プランの実行の記録 | 実行器(`supervise_lib/state.py` の `RunState`) | 実行の記録(`state.json`) | ステップの結果(`log` の 1 件) | 終了コード・件数(`counts`)・秒・費用(`llm.cost`)・欠け | + +この変更が書き換えるのは設計のプランだけである。実行の記録は、新しいステップが読むだけで書き換えない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 設計のプラン | レビューのステップが上限到達で終わり、未解決の件数が 0 なら、`sync-review` → `requirements-recheck` → 用語チェック → `push-glossary` を通る。その後は、`fast` / `auto` は `mvv` へ進む。`--state` 付きの `normal` は `ready` → 助言の `mvv` → `mvv-note` → `gate` へ進む。`normal` で `--state` が無いときは `ready` → `gate` へ進み、`mvv` を流さない | テストが落ちる | +| I2 | 設計のプラン | レビューのステップが上限到達で終わり、未解決の件数が 1 以上で、解決できないの判定がどれも成り立たないときは、解決のフロー(`resolve-sync` → `resolve` → `push-resolve` → `review`)へ進む。`gate` と `mvv` を流さない | テストが落ちる | +| I3 | 設計のプラン | 解決できないと判定したとき(読めないときを含む)は、`ready` → 承認ゲート 1 の判断(`gate`)へ進み、`fast` / `auto` では `mvv` を流さない | テストが落ちる | +| I4 | 設計のプラン | 駆動の失敗は `ready` → 承認ゲート 1 の判断(`gate`)へ進む(`fast` / `auto` で `mvv` を流さない)。解決のフローの中の再レビューでも同じである | テストが落ちる | +| I5 | 設計のプラン | 収束(終了コード 0)の経路は変わらない | 既存のテストが落ちる | +| I6 | 設計のプラン | 経路の分け方はプランの JSON(`on_exit` とステップ)に現れ、実行器(`supervise_lib/engine.py`)に設計のプラン専用の分岐を足さない | レビューで差し戻す(要求の非機能の条件「運用・保守性」) | +| I7 | 設計のプラン | 分け方は全ペースの設計のプランが共有するレビューのまとまり(`_review_steps`)の 1 か所に置き、ペースごとの組み立てに写さない | レビューで差し戻す(利用者の決定 S4) | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 設計 PR を作り、本文の「決めたこと」をそろえた | `pr`・`sync-body` | `review` | +| E2 | cross-review の駆動が終わった | `review` | 収束: `sync-review`。上限到達: `review-settled`。駆動の失敗: `ready` | +| E9 | 上限到達のレビューの未解決の件数と、解決できないの判定を確かめた | `review-settled`(新設) | 0 件: `sync-review`。1 件以上で解決を続ける: `resolve-sync`。解決できない: `ready` | +| E8 | 上限到達で残った未解決の指摘を、解決のフローで解決しようとした | `resolve-sync`・`resolve`・`push-resolve`(新設) | `review`(打ち直してラウンドを足す)。直しか送りが失敗: `ready` | +| E3 | レビューの直しを PR のブランチから取り込んだ | `sync-review` | `requirements-recheck` | +| E4 | 仕様のコピーと課題の本文の一致を確かめた | `requirements-recheck` | `glossary-check` | +| E5 | 用語チェックを通し、PR のブランチへ送った | `push-glossary` | `fast` / `auto`: `mvv`。`normal`: `ready` | +| E6 | MVV 判定が承認ゲート 1 を判定した | `mvv` | `fast` / `auto`: `approve`(従う)・プランの終わり(従わない・判定できない)。`--state` 付きの `normal` の助言の `mvv`: 判定の結果によらず `mvv-note` → `gate`(承認は利用者が行う) | +| E7 | 承認ゲート 1 の判断(`gate`)へ回した | `ready` | 利用者(`gate` の判断のステップ) | + +E1〜E8 は要求の番号を引き継ぐ。E9 はこの設計で足す。 + +### 用語 + +用語集へ足す語・意味を変える語は無い。上の「この文書での呼び方」は要求の前提 1 の呼び方と、この課題の中だけで使う語である。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 設計のプランのレビューのステップが、終了コード 6(上限到達)を未解決の件数の確かめへ回す | `supervise.py` の実行器(設計のプランを流すとき) | +| F2 | 実行の記録から未解決の件数と解決のフローの回数・費用・秒を読み、「0 件」「解決を続ける」「解決できない」を終了コードで返す副命令 | 設計のプランの `review-settled` のステップ | +| F3 | 未解決の指摘を直して PR のブランチへ送り、cross-review を打ち直す解決のフロー | 設計のプランの `resolve-sync`・`resolve`・`push-resolve` のステップ | +| F4 | 設計のプランの規則の文を、新しい経路に合わせる | 設計のプランの判断のステップ(`gate`)。規則の文は判断のたびに渡る | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| レビューのまとまり(`sprint_waves.py` の `_review_steps`。変更) | `review` に `"on_exit": {"6": "review-settled"}` を足す。`review` の直後に `review-settled`・`resolve-sync`・`resolve`・`push-resolve` の 4 つのステップを置く(下の表)。送りのコマンドを `push-glossary` と同じにするため、引数に `push` を足す。全ペースの設計のプランがこの定義を共有する(要求の前提 3・I7) | +| 未解決の件数の確かめ(`supervise.py review-settled`。新設) | 実行の記録(`<状態ディレクトリ>/state.json`)の `log` を読み、下の「解決できないの判定」の順に照らして終了コード 0・1・2 を返す。結果 JSON を 1 行出す。LLM を呼ばない | +| ステップの上限の数え方(`supervise_lib/limits.py` の修正のループ。変更) | ループの戻りの道に `on_exit` の遷移を通し、同じステップの集まりのループを、修正のループと戻りの輪の全体で 1 つとして数える(「処理の流れ」の末尾) | +| 規則の文(`sprint_waves.py` の `RULE_DESIGN` と `plan_mvv_design` の `規則`。変更) | 「上限到達で未解決 0 件なら通常の経路。未解決が残れば解決のフローを回し、解決できないか駆動が失敗したら承認ゲート 1 の判断」に書き直す | +| 契約テストの見本のプラン(`plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/1-design-16.json`。変更) | 書き出しのプランの変化(`review` の `on_exit`・新しい 4 つのステップ・規則の文・ステップの上限)を見本へ反映する。見本は `new sprint` の出力と突き合わせる比較元である | + +足すステップ: + +| id | 型 | 中身 | 成功の次 | 失敗の次 | +| --- | --- | --- | --- | --- | +| `review-settled` | run | `supervise.py review-settled --state {state_dir}/state.json --step review --fix-step resolve --max-attempts 2 --max-cost 1.5 --max-seconds 3600` | `sync-review`(終了コード 0) | `on_exit` の `"1"` は `resolve-sync`、`"2"` は `ready`。それ以外は `on_fail` の `ready` | +| `resolve-sync` | run | `sync-review` と同じ(`git fetch -q && git merge -q --ff-only '@{u}'`)。cross-review の直しに追いついてから直す | `resolve` | `ready` | +| `resolve` | work(`kind` は「修正」) | `review` の報告(結果の `items[0].report`)が示す未解決の指摘を直してコミットする。直せない指摘は理由を PR のスレッドへ返信する | `push-resolve` | `ready` | +| `push-resolve` | run | `push-glossary` と同じ送りのコマンド | `review` | `ready` | + +工程は 4 つとも「ドキュメントレビュー」とする。 + +```mermaid +graph TD + W[プランの組み立て
sprint_waves.py] -->|プランの JSON| E[実行器
engine.py] + E -->|終了コード 6| S[review-settled
supervise.py] + S -->|読む| R[(実行の記録
state.json)] + E -->|書く| R +``` + +確定仕様 `docs/specifications/ndf-ubiquitous-language.md` の「設計のフェーズのステップ」の表は、`plan-to-spec` で新しい経路へ書き直す。この実装の Pull Request では触らない。 + +## 入出力の契約 + +`supervise.py` に副命令を 1 つ足す。既存の副命令と引数は変えない。 + +```text +supervise.py review-settled --state --step <ステップの id> --fix-step <ステップの id> + --max-attempts <回数> --max-cost <ドル> --max-seconds <秒> +``` + +| 引数 | 意味 | +| --- | --- | +| `--state` | 実行の記録のファイル。設計のプランは `{state_dir}/state.json` を渡す(`{state_dir}` は実行器が run のステップの `cmd` で置き換える) | +| `--step` | 未解決の件数を読むステップの id。設計のプランは `review` を渡す | +| `--fix-step` | 解決のフローの直しのステップの id。回数と費用はこのステップの結果から数える。設計のプランは `resolve` を渡す | +| `--max-attempts` / `--max-cost` / `--max-seconds` | 解決できないの判定の上限(下の表の U2・U4・U5) | + +### 解決できないの判定 + +上から順に照らし、最初に当たった行の終了コードを返す。 + +| # | 条件 | 終了コード | 結果 JSON の `status` | +| --- | --- | --- | --- | +| U1 | 記録が読めない・`--step` の結果が無い・最後の結果の `exit` が 6 でない・`counts.unresolved` が整数でない | 2 | `stopped` | +| — | 最後の `--step` の結果の `counts.unresolved` が 0 | 0 | `ok` | +| U2 | 回数: `--fix-step` の成功した結果が `--max-attempts` 件(2)に達した | 2 | `stopped` | +| U3 | 同じ指摘の繰り返し: `--fix-step` の結果が 1 件以上あり、最後の `--step` の未解決の件数が、その前の `--step` の結果の件数から減っていない | 2 | `stopped` | +| U4 | 直しの費用: `--fix-step` の結果の `llm.cost` の合計が `--max-cost`($1.5)を超えた。費用が不明の結果(`llm.cost_unknown`)が 1 件でもあれば、この条件は成り立ったとみなす。`--step` の結果の費用は足さない(実行の記録に残らない。決定 3) | 2 | `stopped` | +| U5 | 時間: 最初の `--fix-step` の結果から後の、`--fix-step` と `--step` の結果の秒の合計が `--max-seconds`(3600 秒)を超えた。次の直しを始めずに止める | 2 | `stopped` | +| — | 上のどれにも当たらない(未解決が 1 件以上で、解決を続けられる) | 1 | `stopped` | + +結果 JSON は共通の形(`step_result.result`)で、次の値を持つ。 + +| 項目 | 値 | +| --- | --- | +| `tool` | `review-settled` | +| `summary` | 読んだ件数と、当たった条件(U1〜U5)か「解決を続ける」 | +| `metrics.unresolved` | 読んだ件数(読めなければ `null`) | +| `metrics.attempts` / `metrics.cost` / `metrics.seconds` | 解決のフローの回数・費用の合計・秒の合計 | +| `metrics.reason` | 当たった条件の番号(`U1`〜`U5`)。当たらなければ `null` | + +## 処理の流れ + +`review` が終わった後の行き先。`fast` / `auto` の設計のプランの流れで書く(`normal` は下の表の差分だけが違う)。 + +```mermaid +graph TD + RV[review] -->|0 収束| SY[sync-review] + RV -->|6 上限到達| ST[review-settled] + RV -->|1 駆動の失敗| RD[ready] + ST -->|0 未解決 0 件| SY + ST -->|1 解決を続ける| RS[resolve-sync] + RS --> FX[resolve] + FX --> PR[push-resolve] + PR -->|打ち直す| RV + ST -->|2 解決できない| RD + SY --> PG[requirements-recheck
用語チェック
push-glossary] + PG --> MV[mvv] + RD --> GT[gate
承認ゲート 1 の判断] +``` + +`resolve-sync`・`resolve`・`push-resolve` が失敗したときの `ready` への矢印は、図を読みやすくするため省いた(上の「足すステップ」の表のとおり)。 + +| ペース | `push-glossary` の次 | `ready` の次 | 上限到達で未解決が残ったときに流れるもの | +| --- | --- | --- | --- | +| `fast` / `auto`(`plan_mvv_design`) | `mvv` | `gate` | 解決のフロー。解決すれば用語チェックの後に `mvv`(従えば `approve` → マージ) | +| `--state` 付きの `normal`(`plan_advise_design`) | `ready` | `mvv`(助言)→ `gate` | 解決のフロー。解決すれば用語チェックと助言の `mvv` の後に `gate` で止まる | +| `normal`(`--state` 無し。`plan_sprint_design`) | `ready` | `gate` | 解決のフロー。解決すれば用語チェックの後に `gate` で止まる | + +`review-settled` は実行の記録を読む。実行器はステップを終えるたびに `state.json` を書き直す。その後で次のステップを流す(`engine.py` の `run` が `RunState.record` を呼んでから次へ進む)。そのため、`review-settled` が流れる時点で、直前の `review` と解決のフローの結果は記録にある。 + +再レビューは同じ `review` のステップを打ち直す。`push-resolve` で PR の head が変わっている。そのため cross-review は前のラウンドを残したまま次のラウンドから続ける(要求の前提 5)。直しで何もコミットしなかったときは head が変わらず、打ち直しはラウンドを足して同じ指摘を数える。未解決の件数が減らないため U3 で止まる。 + +ステップの上限(プランの `上限`)は、書き出しが遷移から求める(`supervise_lib/limits.py`)。いまの数え方では、修正のループは「検査のステップの失敗の遷移 1 本から、成功の遷移だけで検査のステップへ戻るループ」である。`on_exit` の 0 以外の行き先は失敗の遷移として扱う。解決のフローのループ(`review` → `review-settled` → `resolve-sync` → `resolve` → `push-resolve` → `review`)は `on_exit` の遷移を 2 本含む。そのため、このままでは修正のループに数えられず、上限はループを通らない最長の経路のままになる。見本のプランに 4 つのステップを足して求めると、上限は 17 で、解決のフローを 1 回回すとステップの数が上限を超えて止まる。 + +そこで `limits.py` の修正のループの数え方を次のように広げる。 + +- ループの戻りの道は、成功の遷移に加えて `on_exit` の遷移も通れる。ループの始まりは、いまと同じく失敗の遷移 1 本である +- 同じステップの集まりから成るループは 1 つとして数える。重複を除く範囲は、修正のループと戻りの輪(`kind` が「戻り」のステップを含む輪)をまとめた全体である。解決のフローのループは、`review` の終了コード 6 と `review-settled` の終了コード 1 の両方から始まる。戻りの道はループの数え方と戻りの輪の数え方で共有されるため、戻りの輪も増える。実装のプランでは、`design-tests→design-specs` と `design-specs→design-match` の失敗の遷移から `merge` の `on_exit` を通って `base-return` → `base-sync` へ戻る長さ 12 の輪が新しく数えられる。どちらも既存の `merge→base-return` の輪と同じステップの集まりである + +解決のフローのループは 5 ステップで、上限に `FIX_ROUNDS`(3)× 5 = 15 が足される(17 → 32)。U2 の上限 2 回で流れるのは 2 × 5 = 10 ステップで、この内側に収まる。契約テストの見本のプラン(`plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/` の下の全件)に広げた数え方を当てると、どのプランも上限が変わらない(いまの数え方と同じ結果になる)ことを確かめた。重複を修正のループの中だけで除くと、実装のプラン(`impl.json`・`fix.json`・`sprint/6-impl-16.json`・`sprint/6-impl-17.json`)の上限が 3 × 12 × 2 = 72 増える(`impl.json` は 122 → 194)。 + +## 非機能の実現方式 + +| 条件 | 実現方式 | +| --- | --- | +| 経路の分け方はプランの JSON に現れ、実行器に設計のプラン専用の分岐を足さない | 実行器がすでに持つ `on_exit`(run と drive の終了コード → 行き先)と `on_fail` だけで分ける。`engine.py` は変えない | +| 作成済みのプランの JSON を書き換えない | 経路はプランの組み立てで決まり、書き出した JSON を読み直して直す処理を足さない。版を入れた後に作り直したプランから効く | + +## 決定の記録 + +### 決定 1: 実行器と cross-review の契約を変えずに分けるため、上限到達の未解決の件数を後続の run のステップで確かめる + +`review` の `on_exit` で終了コード 6 を `review-settled` へ回す。このステップは実行の記録から未解決の件数を読み、行き先を終了コードで返す。実行器の既存の行き先(`on_exit` と `on_fail`)だけで終わりを分けられる。そのため、`engine.py` にも cross-review の終了コードにも手を入れない。 + +cross-review の駆動が未解決 0 件の上限到達を別の終了コードで返す形は採らない。終了コードの契約は実装のプランのレビュー(#2143)とリファクタリングの欠け(#2047)も読んでおり、要求で「確認してから行う」に置いた変更である。実行器に「結果の件数で行き先を分ける」鍵を足す形も採らない。1 つの経路のために実行器の契約が増え、既存の `on_exit` と run のステップで足りる。 + +根拠: Value 5(決まった手順はスクリプトに、判断と中身は AI に)/ Value 7(根本原因の場所で直す)(MVV 版 5) + +### 決定 2: 未解決の指摘は、承認ゲート 1 へ進む前に解決のフロー(直し → 再レビュー)で解決する + +上限到達で未解決の指摘が残ったら、`resolve-sync` → `resolve` → `push-resolve` で直しを PR のブランチへ送る。その後 `review` を打ち直してラウンドを足す。再レビューの結果を `review-settled` がもう一度確かめ、0 件になれば通常の経路へ、解決できなければ承認ゲート 1 の判断へ回す。 + +これは承認ゲート 1 の答え「直して出し直す」の指摘 2 で利用者が決めた(2026-11-04。要求の S3)。未解決の指摘を承認ゲート 1 の前に解決すれば、人が承認で読むのは解決できなかった指摘だけになる。再レビューに cross-review の打ち直しを使うのは、直しが新しい指摘を生んでいないかを、上限到達までと同じ担当と基準で確かめるためである。 + +根拠: Value 2(人が判断できる量に収める)/ Vision(人が手を止めるのは判断が要る所だけ)/ 共通原則の優先順位(利用者の承認はプロジェクトの MVV より上) + +### 決定 3: 解決のフローは、回数・同じ指摘の繰り返し・費用・時間のどれかで止め、読めないときも止める + +「解決できない」の判定は、上の表の U1〜U5 で決める。 + +- **回数(U2)の上限は 2 回とする。** cross-review はすでに 3 ラウンドで直しを試みており、その後の 2 回で減らない指摘は、直す側でなく人の判断が要る指摘である見込みが高い。2 回は初期値で、リリース後テストで `review-settled` の `metrics.attempts` を見て見直す +- **同じ指摘の繰り返し(U3)は、未解決の件数が減らないことで判定する。** cross-review の結果は未解決の指摘を件数だけで返し、指摘ごとの識別子を返さない(要求の前提 5)。指摘の同一性を比べるには cross-review の結果の契約を変えることになるため、この課題では件数で判定する。件数が減らないとき、直しは残りの指摘を 1 件も消せていない +- **直しの費用(U4)の上限は $1.5 とする。** 解決できずに人へ戻したときの無駄は 1 周回(lfactory2 の周回 2 の実測で $1.5・27 分)である。それを超えて直しに使うなら、人へ戻す方が安い。実行の記録に残る費用は作業(`resolve`)の分だけである。cross-review の駆動の費用は残らない(費用を記録へ足すのは `state.py` の `RunState.add_usage` で、呼ぶのは LLM を呼ぶ `claude.py` だけ)。そのため U4 は再レビューの費用を数えない +- **再レビューの費用は、回数(U2)で抑える。** 再レビューは 1 回の直しにつき 1 回で、U2 で 2 回までになる。1 回は 1 周回と同じ程度($1.5)と見積もる。解決のフロー全体の費用は、直しの $1.5 と再レビュー 2 回の約 $3 を足した、約 $4.5 までになる。この費用は、承認ゲート 1 の前に解決のフローを回すと利用者が決めたこと(決定 2)で受け入れる費用である。再レビューの実際の費用はリリース後テストで確かめる(「未確認のまま残ること」) +- **時間(U5)の上限は 3600 秒とし、次の直しを始めるかの判定に使う。** U5 は `review-settled` が流れる時点で、それまでの秒を照らす。走っている直しや再レビューを途中で止めるものではない。1 周(直しと再レビュー)は、打ち切りの延長が無ければ最大 1800 + 3600 = 5400 秒かかる(work のステップの既定の打ち切り 1800 秒と、`review` の打ち切り 3600 秒)。1 周目が 3600 秒を超えたら、U5 で 2 周目を始めずに止まる。1 周目が 3600 秒以内なら 2 周目を流し、その後は U2 で止まる。そのため解決のフロー全体の時間は、最大で 3600 + 5400 = 9000 秒(2 時間 30 分)である。再レビューが実測の 27 分で終わる周では U5 は当たらず、U2 が先に当たる。U5 が先に当たるのは、再レビューの cross-review がラウンドを重ねて長引いたときである +- **読めないとき(U1)は解決できないと同じに扱う。** 件数が読めないまま 0 件として進めると、残った指摘を人が見ないまま `fast` / `auto` で設計 PR がマージされうる。件数が分からないまま直しを回しても、何を直すかが決まらない。取り違えたときに戻すのは、承認ゲート 1 で人が止める側より難しい(マージ済みの設計を差し戻すことになる) + +値(2 回・$1.5・3600 秒)はプランの組み立てが `review-settled` の引数で渡す。プロジェクトの設定には足さない(Value 3 の「足すより減らす」。実測で見直すときは組み立ての定数を変える)。 + +根拠: 共通原則の上位の原則(人を守る)/ Value 3(上限に当たったらやめるか縮める)/ Value 4(外の利用で測って決める)(MVV 版 5) + +### 決定 4: ペースごとに経路を分けないため、レビューのまとまりの共有の定義だけを直す + +`_review_steps` は `normal` / `fast` / `auto` の設計のプランが共有している(要求の前提 3)。ここだけを直せば、3 つのプランの組み立て(`plan_sprint_design`・`plan_advise_design`・`plan_mvv_design`)は変えずに新しい経路を持つ。`plan_advise_design` と `plan_mvv_design` は `plan_sprint_design` の出力を元に組み立てる。そのため、`_review_steps` を呼ぶのは `plan_sprint_design` の 1 か所である。`normal` で変わるのは、上限到達のときに解決のフローと、直しの取り込みから用語チェックまでが流れることだけである。最後は承認ゲート 1 の判断で止まる(要求の前提 4)。 + +これは承認ゲート 1 の答えの指摘 3 で利用者が決めた(2026-11-04。要求の S4)。`fast` / `auto` のプランにだけ `on_exit` を足す形は採らない。同じ終わりがペースによって別の経路を通り、規則の文と経路の対応がペースごとに分かれる。 + +根拠: Value 7(根本原因の場所で直す。同じ役割の定義を分けない)/ 共通原則の優先順位(利用者の承認)(MVV 版 5) + +## テスト設計 + +経路のテストは、ステップの結果を偽装してプランを実行器へ流す(`tests/test_supervise_limits.py` の `drive` と同じ流し方)。そのうえで、通ったステップの並びを確かめる。判定のテストは、`review-settled` に記録のファイルを渡して終了コードと `metrics.reason` を確かめる。 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I1 | `fast` / `auto` の設計のプランで `review` が 6・`review-settled` が 0 のとき、`review` → `review-settled` → `sync-review` → `requirements-recheck` → `glossary-check` → `push-glossary` → `mvv` の順に通り、`gate` を通らない | `review` の `on_exit` を消す、`review-settled` の `next` を `ready` にする | +| 受け入れ条件 2・I1 | `review-settled` を `exit` 6・`counts.unresolved` 0 の記録で打つと、最終状態が `max_rounds` でも `oscillation` でも終了コード 0 を返す(最終状態を見ない) | 判定に最終状態の値を加える | +| 受け入れ条件 3・I2 | 全ペースの設計のプランで `review` が 6・`review-settled` が 1 → 再レビューの `review` が 6・`review-settled` が 0 のとき、`resolve-sync` → `resolve` → `push-resolve` → `review` を通ってから `sync-review` 以降へ進み、`fast` / `auto` は `mvv` を流す | `on_exit` の `"1"` を `ready` にする、`push-resolve` の `next` を `sync-review` にする | +| 受け入れ条件 3・I3 | 全ペースの設計のプランで `review-settled` が 2 のとき、`ready` → `gate` へ進み `mvv` を通らない | `on_exit` の `"2"` を `sync-review` にする | +| 受け入れ条件 3・I4 | 解決のフローの再レビューの `review` が 1 のとき、`ready` → `gate` へ進む | 再レビューの失敗を `review-settled` へ回す | +| 受け入れ条件 4・I4 | `fast` / `auto` の設計のプランで `review` が 1 のとき、`review-settled` を通らずに `ready` → `gate` へ進み `mvv` を通らない | `on_exit` に `"1"` を足して `review-settled` へ回す | +| 受け入れ条件 5・I7 | `--state` 付きの `normal` の設計のプランで `review` が 6・`review-settled` が 0 のとき、`sync-review` 以降を通って助言の `mvv` を流し、`gate` で止まる | 共有の定義でなく `plan_mvv_design` だけに `on_exit` を足す | +| 受け入れ条件 6・I5 | 既存の経路のテスト(`DESIGN_ROUTE`)が、`review` が 0 のとき `review-settled` を通らずに通る | `review` の `next` を `review-settled` にする | +| 受け入れ条件 8(U1) | `counts.unresolved` が無い・`null`・該当のステップの結果が無い・`exit` が 6 でない・ファイルが読めない記録で、終了コード 2 と `U1` を返す | 件数が無いときに 0 か 1 を返す | +| 受け入れ条件 8(U2) | 未解決 1 件・`resolve` の成功が 2 件で、ほかの条件が成り立たない記録で、終了コード 2 と `U2` を返す。1 件なら終了コード 1 | 回数を数えない、失敗した `resolve` も数える | +| 受け入れ条件 8(U3) | `review` の未解決が 2 → 2(`resolve` 1 件)の記録で終了コード 2 と `U3`、2 → 1 なら終了コード 1 | 件数の比べ方を「増えたとき」だけにする | +| 受け入れ条件 8(U4) | `resolve` の `llm.cost` の合計が 1.6 の記録で終了コード 2 と `U4`。`cost_unknown` が真の結果を含む記録でも `U4`。`resolve` の合計が 1.4 で `review` の結果に `llm.cost` 1.0 を持つ記録では終了コード 1 | 費用を足さない、不明の費用を 0 として扱う、`review` の費用を足す | +| 受け入れ条件 8(U5) | `resolve` の成功が 1 件(U2 に当たらない)で、最初の `resolve` から後の秒の合計が 3601 の記録で終了コード 2 と `U5`。3600 なら終了コード 1 | 秒を数えない、U5 を U2 の後ろでしか効かない形にする | +| 受け入れ条件 3(ステップの上限) | 書き出した設計のプランの `上限` が、ループを通らない最長の経路に 3 × 5(解決のフローのループ)を足した値になる。全ペースの設計のプランで `review` が 6・`review-settled` が 1 を 2 回流してから `review-settled` が 2 のとき、ステップの上限で止まらずに `ready` → `gate` へ進む。`limits.py` の単体のテストで、`on_exit` を含むループを数え、同じステップの集まりのループを 1 回だけ数える | 戻りの道に `on_exit` を通さない、同じループを始まりごとに数える | +| 受け入れ条件 3(実装のプランの上限) | 契約テストの見本のプランの全件で、広げた数え方の `上限` がいまの数え方と同じになる(`impl.json` は 122) | 重複を修正のループの中だけで除き、戻りの輪を始まりごとに数える | +| 受け入れ条件 7 | 全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` が終了コード 0。契約テストの見本のプランを書き出しの出力に合わせる | 見本を更新しない | +| I6 | `engine.py` に差分が無い(実装の Pull Request の差分で確かめる。テストは書かない) | — | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2388 | 実装する | — | `plugins/ndf/scripts/supervise_lib/sprint_waves.py`、`plugins/ndf/scripts/supervise_lib/limits.py`、`plugins/ndf/scripts/supervise_lib/commands.py`、`plugins/ndf/scripts/supervise.py`、`plugins/ndf/scripts/tests/` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| `run --from review-settled` での再開 | 途中から再開した実行で、前の実行の `review` と解決のフローの結果が `state.json` の `log` に残るかを確かめていない。残らなければ `review-settled` は U1 で終了コード 2 を返し、承認ゲート 1 の判断へ回る(決定 3 の安全側)。実装で 1 度流して確かめる | +| `metrics.unresolved` が `null` になる頻度 | cross-review が最終の見直し(`sweep`)を書かずに上限到達で終わる場合があるかを確かめていない。あれば上限到達でも U1 で承認ゲート 1 の判断へ回る。リリース後テストのフェーズレポートで `review-settled` の `summary` を見る | +| 判定の条件の値 | 2 回・$1.5・3600 秒は lfactory2 の周回 2 の 1 件の実測と `review` の打ち切りから置いた初期値で、解決のフローを流した実測はまだ無い。リリース後テストと開発ループの記録で `metrics.attempts`・`metrics.reason` を見て見直す | +| 打ち直しの cross-review のラウンドの上限 | 再レビューで cross-review が数え直す上限(設計の分類の既定 3 ラウンド)が、時間の上限(3600 秒)の内に収まるかを確かめていない。1 周目で収まらなければ、U5 で 2 周目を始めずに止まる | +| 再レビューの費用 | 再レビューの cross-review の費用は実行の記録に残らず、1 回 $1.5 は周回の実測からの見積である。リリース後テストで cross-review の状態ファイルと報告から再レビューの費用を拾い、解決のフロー全体が約 $4.5 に収まるかを見る | diff --git a/issues/issue-2388-requirements.md b/issues/issue-2388-requirements.md new file mode 100644 index 000000000..750cef22a --- /dev/null +++ b/issues/issue-2388-requirements.md @@ -0,0 +1,157 @@ +# #2388: 設計のプランで cross-review が上限到達(未解決 0 件)を返すと MVV 判定を飛ばし、承認ゲート 1 が「判定できない」になる + +正は課題の本文(#2388)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 具体例 + +開発ループ lfactory2 の周回 2(スプリント lfactory2-2、#2382 UX の土台)で、設計のプラン `1-design-2382.json` を流した(2026-11-02 11:15〜11:45 JST)。 + +- 設計の cross-review(ステップ `review`)は上限の 3 ラウンドに達し、指摘 3 件・修正 3 件・**未解決 0 件**、最終状態 `oscillation` で `exit=6` を返した +- プランは `review` の失敗の経路(`ready` → 判断のステップ `gate`)へ進み、判断が `gate` を選んでプランが「承認ゲート」で終わった +- **MVV 判定のステップ `mvv`(`mvv-gate.py check --gate design`)は一度も流れなかった** +- `sprint-state.py loop gate lfactory2` は承認ゲート 1 の判定を `unknown`(根拠なし)として読み、#2382 を人の判断待ちへ移して周回を「人へ戻した」で締めた + +プランの「規則」は「設計の cross-review は上限 3 ラウンドで承認ゲート 1 の判定(mvv のステップ)へ渡す(収束を待たない)。駆動そのものが失敗したら gate」と書いている。上限に達しただけの `exit=6` は駆動の失敗ではないのに、駆動の失敗と同じ経路を通った。 + +記録: `/work/ai-plugins/.ndf/loop/lfactory2/lap-2/sprint/1-design-2382-state/report.md`(ローカル)、設計 PR https://github.com/devbasex/ai-plugins/pull/2387 + +## 何が困るか + +`pace: auto` と開発ループでは、承認ゲート 1 を MVV 判定だけで通す。判定が流れないと、設計の中身に問題が無くても課題が人の判断待ちへ回り、周回が 1 つ無駄になる(今回 $1.5・27 分)。 + +## 直し方の候補 + +- 設計のプランの `review` のステップで、上限到達(`exit=6`・未解決 0 件)を `mvv` へ進む経路にする(`sync-review` 以降の通常の経路) +- 駆動の失敗(CLI が起動しない等)だけを `ready` → `gate` へ流す + +## 受け入れ条件(起票時の初版) + +1. cross-review が上限で `oscillation`・未解決 0 件を返したとき、設計のプランが `mvv` のステップを流す(テストで確かめる) +2. 駆動そのものの失敗は従来どおり `gate` へ進む + +(この節までは起票時の本文。以下の節が要求と受け入れ条件の確定版で、受け入れ条件は「受け入れ条件」の節を正とする) + +## 依頼(原文) + +> 設計のプランで cross-review が上限到達(未解決 0 件)を返すと MVV 判定を飛ばし、承認ゲート 1 が「判定できない」になる + +(課題の題。書き手: 起票者、日付: 2026-11-02。題と上の「具体例」〜「受け入れ条件(起票時の初版)」は、起票者(課題を書いた AI)が開発ループ lfactory2 の周回 2 の記録から書いた文で、利用者の指示の原文として扱わない。利用者の指示の原文は、下の承認ゲート 1 の答えだけである) + +> #2388。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-04。この要求を書く作業の起動指示) + +> 2. S3 は利用者の指示で次の形に変える: 上限到達で未解決の指摘が残ったときは、承認ゲートへ進む前に未解決の指摘をすべて解決するフロー(直し → 再レビュー)を入れる。どうしても解決できない場合に限り、人の設計の承認(承認ゲート 1 の判断)へ回す。「どうしても解決できない」の判定の条件(回数・費用・同じ指摘の繰り返しなど)を設計に書く。課題本文の要求にも反映し、写しを作り直す。 +> 3. S4 は採る: 同じ分け方を pace: normal の設計のプランにも当てる(共有の定義を 1 つに保つ)。 + +(書き手: 利用者、日付: 2026-11-04。承認ゲート 1 の答え「直して出し直す」の指摘 2・3。指摘の全文は節「人の判断待ち(開発ループ lflow2)」にある) + +**解釈**: 設計のプランで cross-review が「上限のラウンドに達して終わった(収束はしていないが、レビューは成立した)」ときは、駆動が失敗したときと分ける。未解決の指摘が 0 件なら、レビューの後の通常の経路(直しの取り込み → 仕様のコピーの照合 → 用語チェック → MVV 判定)へ進める。未解決の指摘が残ったら、承認ゲート 1 へ進む前に解決のフロー(直し → 再レビュー)で未解決の指摘をすべて解決し、解決できたら通常の経路へ進める。どうしても解決できないと判定したときと、駆動そのものが失敗したときだけ、承認ゲート 1 の判断(`gate`)へ回す。この分け方は、全ペースの設計のプランが共有する 1 つの定義に置く。 + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く、起票者が推して足した条件。S3・S4 は承認ゲート 1(2026-11-04)で利用者が決めた。S1・S2 は、承認ゲート 1 で利用者が確かめるまで利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 上限到達(終了コード 6・未解決 0 件)は、駆動の失敗ではなくレビューの後の通常の経路(`sync-review` 以降)へ進める | 本文「直し方の候補」1 行目・プランの「規則」 | 受け入れ条件 1・2 | +| S2 | 駆動の失敗(CLI が起動しない等)だけを `ready` → `gate` へ流す | 本文「直し方の候補」2 行目・受け入れ条件の初版 2 | 受け入れ条件 4 | +| S3 | ~~上限到達で未解決の指摘が 1 件以上残ったときは、MVV 判定へ渡さず承認ゲート 1 の判断(`gate`)へ回す~~ → 上限到達で未解決の指摘が残ったときは、承認ゲート 1 へ進む前に解決のフロー(直し → 再レビュー)で未解決の指摘をすべて解決する。どうしても解決できないと判定したときだけ、承認ゲート 1 の判断(`gate`)へ回す(2026-11-04、利用者の指示) | 利用者の決定(承認ゲート 1 の答えの指摘 2)。起票時は、この要求を書いた AI の推定だった | 受け入れ条件 3・8 | +| S4 | 同じ経路の分け方を、`pace: normal` の設計のプラン(助言の MVV 判定)にも当て、全ペースが共有する定義を 1 つに保つ | 利用者の決定(2026-11-04、承認ゲート 1 の答えの指摘 3)。起票時は、この要求を書いた AI の推定だった | 受け入れ条件 3・5 | + +## 目的 + +- `pace: fast` / `pace: auto` と開発ループで、設計の cross-review が上限のラウンドに達して終わっても、承認ゲート 1 を MVV 判定で判定できるようにする。設計の中身に問題が無いのに課題が人の判断待ちへ回り、周回が 1 つ無駄になる(1 回あたり約 $1.5・27 分)ことを無くす + +## 前提 + +- 前提 1: cross-review の駆動は、最終状態が上限到達(`max_rounds`)か行き来(`oscillation`)のとき終了コード 6 と欠け(`converge`)を返し、レビューが成立していない終わり(`error`)と駆動の失敗は終了コード 1 を返す(`plugins/ndf/scripts/lib/drive_pause.py` の `unconverged`、`plugins/ndf/scripts/supervise_lib/worker_steps.py` の `execute`)。この要求では、前者(終了コード 6)を「上限到達」、0・6 以外で終わったものを「駆動の失敗」と呼ぶ。上限到達ではレビューは成立している。この課題はこの終了コードの契約を変えない +- 前提 2: MVV 判定(`mvv-gate.py check --gate design`)はレビューの件数(未解決の指摘の数)を読まない(`grep -n "unresolved\|review\|oscillation" plugins/ndf/scripts/mvv-gate.py` の当たり 0 件、2026-11-04)。未解決の指摘が残ったまま MVV 判定へ渡すと、人が見ないまま設計 PR がマージされうる。そのため未解決の指摘が残ったまま MVV 判定へ渡さない +- 前提 3: レビューのステップの定義は、`pace: normal`(助言の MVV 判定)と `pace: fast` / `pace: auto`(MVV 判定で承認ゲート 1 を通す)の設計のプランが共有している(`plugins/ndf/scripts/supervise_lib/sprint_waves.py` の `_review_steps`)。直す場所はこの共有の定義とし、ペースごとに分けない +- 前提 4: `pace: normal` の設計のプランは、どの終わりでも最後は承認ゲート 1 の判断(`gate`)で止まり、承認は利用者が行う。この課題で変わるのは、上限到達のときに解決のフローと途中の用語チェックが流れることだけである +- 前提 5: 解決のフローの再レビューは、同じ cross-review の駆動を打ち直してラウンドを足す。直しで PR の head が変われば、打ち直すだけで前のラウンドを残したまま次のラウンドから続く(`plugins/ndf/skills/cross-review/SKILL.md` の「終わったレビューにもう一度回すとき」)。cross-review の結果は未解決の指摘を件数(`metrics.unresolved`)で返し、指摘ごとの識別子を返さない(`plugins/ndf/skills/cross-review/scripts/drive.py` の結果の組み立て) + +## 対象範囲 + +含む: +- 設計のプランのレビューのステップ(cross-review)が終わったときの行き先を、「収束」「上限到達・未解決 0 件」「上限到達・未解決 1 件以上」「駆動の失敗」の 4 つで分ける +- 上限到達・未解決 1 件以上のときの解決のフロー(直し → 再レビュー)と、「どうしても解決できない」の判定 +- 上の分け方・解決のフロー・判定を確かめる自動テスト + +含まない: +- cross-review の駆動の終了コードと最終状態の契約(前提 1) +- MVV 判定の判定の中身と、MVV 判定がレビューの件数を読むようにすること +- 実装のプランのレビューのステップ(収束しなかった終わりで後続へ進めない決まり。#2143) +- 承認ゲート 1 の判定を読む `sprint-state.py loop gate` の読み方 +- lfactory2 の周回 2 で人の判断待ちへ回った #2382 の扱い(その周回の判断で決める) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 設計 PR を作り、本文の「決めたこと」をそろえた | 設計のプランの `pr`・`sync-body` | プランが止まる(この課題では変えない) | — | +| E2 | cross-review の駆動が終わった | `review` のステップ | 駆動が失敗した(終了コード 1)→ E7 | E1 | +| E3 | レビューの直しを PR のブランチから取り込んだ | E2 が収束か上限到達・未解決 0 件で終わった、または E8 で未解決が 0 件になった | プランが止まる(この課題では変えない) | E2 | +| E4 | 仕様のコピーと課題の本文の一致を確かめた | E3 | プランが止まる(この課題では変えない) | E3 | +| E5 | 用語チェックを通し、PR のブランチへ送った | E4 | 当たりが残ったら E7(`pace: fast` / `auto`) | E4 | +| E6 | MVV 判定が承認ゲート 1 を判定した | E5 | 判定に従わない・判定できないときは承認ゲートで止まる(この課題では変えない) | E5 | +| E7 | 承認ゲート 1 の判断(`gate`)へ回した | E2 が駆動の失敗で終わった、E8 がどうしても解決できないと判定した、または E5 の当たりが残った | — | E2 | +| E8 | 上限到達で残った未解決の指摘を、解決のフロー(直し → 再レビュー)で解決しようとした | E2 が上限到達・未解決 1 件以上で終わった | どうしても解決できないと判定した → E7 | E2 | + +## 受け入れ条件 + +- [ ] 1. `pace: fast` / `pace: auto` の設計のプランで、cross-review が上限到達(最終状態 `oscillation`)・未解決 0 件で終わったとき、プランは `sync-review` → `requirements-recheck` → `glossary-check` → `push-glossary` を経て `mvv` のステップを流し、`gate` の判断のステップを流さない(自動テストで、通ったステップの並びを確かめる) +- [ ] 2. 1 と同じ条件で最終状態が `max_rounds` のときも、プランは `mvv` のステップを流す(自動テスト) +- ~~3. `pace: fast` / `pace: auto` の設計のプランで、cross-review が上限到達・未解決 1 件以上で終わったとき、プランは `mvv` のステップを流さずに `gate` の判断のステップへ進む(自動テスト)~~ → 下の 3 へ変更(2026-11-04、利用者の指示) +- [ ] 3. 全ペース(`normal` / `fast` / `auto`)の設計のプランで、cross-review が上限到達・未解決 1 件以上で終わったとき、プランは承認ゲート 1 の判断(`gate`)へ進む前に解決のフロー(直し → 再レビュー)を流す。再レビューで未解決が 0 件になれば、1・5 と同じ `sync-review` 以降の経路へ進む(`fast` / `auto` は `mvv` を流す)。どうしても解決できないと判定したときだけ、`mvv` を流さずに `gate` へ進む(自動テストで、通ったステップの並びを確かめる) +- [ ] 4. `pace: fast` / `pace: auto` の設計のプランで、cross-review の駆動が失敗した(終了コード 1)とき、プランは `ready` → `gate` へ進み、`mvv` のステップを流さない(自動テスト。退行しないこと) +- [ ] 5. `pace: normal` の設計のプランで、cross-review が上限到達・未解決 0 件で終わったとき、プランは `sync-review` 以降の経路を経て助言の MVV 判定(`mvv`)を流し、`gate` の判断のステップで止まる(自動テスト) +- [ ] 6. cross-review が収束して終わった(終了コード 0)ときの経路は変わらない(既存のテストが通る) +- [ ] 7. 全体テストが通る(`uv run --frozen --project . --all-extras pytest . -q -n 4` の終了コード 0) +- [ ] 8. 「どうしても解決できない」の判定の条件が、少なくとも解決のフローの回数の上限・費用の上限・同じ指摘の繰り返しの 3 つを含んで設計文書に書かれ、条件ごとに 1 つ以上の自動テストが、その条件だけが成り立つ記録で `gate` へ回ることを確かめる + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 経路の分け方は設計のプランの定義(プランの JSON)に現れ、`supervise.py` の実行器に設計のプラン専用の分岐を足さない | +| 移行性 | 作成済みのプランの JSON(`.ndf/loop/` 配下)は書き換えない。直した経路は、版を入れた後に作り直したプランから効く | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない(Skill の呼び方・`supervise.py` の引数・cross-review の結果の形は同じ) | +| データ | 変わらない | +| 既存の振る舞い | 設計のプランで cross-review が上限到達で終わったときの行き先が変わる。未解決が残れば解決のフローが流れ、その分の時間と費用が承認ゲート 1 の前にかかる。`pace: fast` / `auto` では未解決が 0 件になれば MVV 判定まで進み、承認ゲート 1 が MVV 判定で通りうる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(設計のプランの経路のテスト)と、全体の `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析・型検査 | リポジトリの CI の既存のジョブ | +| 手動確認 | リリース後テストで、開発ループの設計のプランが上限到達・未解決 0 件で終わったときに、フェーズレポートの「通ったステップ」に `mvv` が載ることを確かめる(その条件の周回が起きたときに見る) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。プランの組み立ては `plugins/ndf/scripts/supervise_lib/`、テストは `plugins/ndf/scripts/tests/` に置く | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない | +| テスト戦略 | プランを組み立てる関数の出力と、実行器にプランを流したときの通ったステップの並びを、単体・結合のテストで確かめる(`tdd-cycle`) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存のテストの実行、変更範囲の命名の統一 | +| 確認してから行う | cross-review の終了コードの契約を変えること(承認ゲート 1 で人が見る) | +| 行わない | 実装のプランのレビューの経路の変更、MVV 判定の中身の変更、作成済みのプランの JSON の書き換え | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 上限到達で未解決 1 件以上を、未解決 0 件と分ける手段(駆動の終了コードを分ける / レビューの後に件数を見るステップを置く など) | `design`(設計の工程) | 承認ゲート 1 | +| 「どうしても解決できない」の判定の条件の値(回数・費用の上限) | `design`(設計の工程)。値は実測で見直す | 承認ゲート 1 | diff --git a/issues/issue-2405-design.md b/issues/issue-2405-design.md new file mode 100644 index 000000000..6d531c2d7 --- /dev/null +++ b/issues/issue-2405-design.md @@ -0,0 +1,276 @@ +# Factory の開発ループ: 承認待ち・利用上限待ちの間に変えた予算で、再開した後の止まる判定を行う(#2405) + +## 目的 + +- **今どうなっているか**: Factory の画面でループの予算を変えても、承認の答えや利用上限のリセット時刻の後に作られる再開のジョブ(`START=resume`)は予算を NDF へ渡さない。NDF は古い予算のまま周回を締め、止まるかを判定する +- **誰に効くか**: 承認待ち・利用上限待ちで止まっている開発ループの予算を、画面で変える Factory の利用者 +- **変えると何が成り立つか**: 再開のジョブで、conductor(ジョブの中で NDF の工程を進める Claude のセッション)を起こす前に、NDF のループの記録の予算が画面の予算と同じ値になる。画面の「予算の残り」と NDF の止まる判定が同じ予算を使う + +## 適用範囲 + +- **働く範囲**: Factory のサービス(`factory/api`)とランナー(`factory/runner`)、NDF の `sprint-state.py loop` の副命令。NDF の副命令は配布先のリポジトリでも使えるが、呼ぶのは Factory のランナーだけである +- **プロジェクトごとに違うもの**: 無し(予算の値は実行ジョブの指示の `LOOP_BUDGET` から引数で受ける) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| #2405 の受け入れ条件「承認待ちの間に予算を変えたループを再開すると、NDF の止まる条件の判定が新しい予算で行われる」 | 利用者の指示の原文(起票者の文。承認ゲート 1 で確かめる) | 変えた予算が再開の後の判定に効くこと | +| `factory/api/src/runs/control.ts` の実行ジョブの指示の組み立てが、`LOOP_BUDGET` を `START=loop` のときだけ載せている | 実測(コードの読み取り) | 再開のジョブに予算が載らないこと | +| `plugins/ndf/scripts/lib/sprint_loop.py` の `cmd_start` が、既にあるループで予算の上書きの後に止まった記録を解く手続き(`resume_stopped`)を続けて行う | 実測(コードの読み取り) | 既存の `loop start` を打ち直すと、予算のほかの処理も起きること | + +要求と受け入れ条件は #2405 の本文にある(コピーは [issue-2405-requirements.md](issue-2405-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| Neural Development Factory(`ndf-factory`) | 開発ループの行・実行・再開のジョブ・実行ジョブの指示 | +| NDF の開発ワークフロー(`ndf-workflow`) | ループの記録・ループの予算・予算の履歴・止まる条件 | + +**関係は腐敗防止層である。** Factory(下流)は NDF のループの記録を直接書かず、ランナーが NDF の副命令を打って変える。読むときもランナーの翻訳(`factory/runner/src/loop-file.ts`)を通す。この変更もこの関係のまま、予算の書き込みを NDF の副命令 1 つに寄せる。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 開発ループ(Factory) | サービスのループの制御(`updateBudget`。この変更では変えない) | ループの行 | — | ループの予算(周回の数・費用・時間。単位ごとに空を許す) | +| 実行ジョブ(Factory) | サービスの実行の制御(`control.ts`) | ジョブ | — | ループの実行の入力(名前・マイルストーン・予算・除く課題・答え) | +| ループの記録(NDF) | NDF の `sprint-state.py loop` の副命令(`sprint_loop.py`) | ループの記録 | 周回・人の判断待ちの行 | ループの予算・予算の履歴の行(時刻・前・後) | + +ランナーはループの記録を書き換える持ち主ではない。NDF の副命令を打つ呼び出し側である。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1a | 実行ジョブ | ループに属する再開のジョブの指示は、宣言した単位があればジョブを作った時点のループの予算を `LOOP_BUDGET` に持つ | 契約の検査(`validateRunJobInstruction`)と単体テストで落とす | +| I1b | 実行ジョブ | ループに属さない実行のジョブの指示は `LOOP_BUDGET` を持たない | 単体テストで落とす | +| I2 | ループの記録 | 予算の反映は、予算と予算の履歴のほかを変えない(止まった記録・周回・人の判断待ちの行は前と同じ) | NDF の単体テストで落とす | +| I3 | ループの記録 | 予算の履歴の行は、予算が変わったときだけ 1 行足す。同じ値の反映では足さない | NDF の単体テストで落とす | +| I4a | 実行ジョブ | `LOOP_BUDGET` を持つ再開のジョブでは、予算の反映が成功するまで conductor を起こさない(I4c の場合を除く) | ランナーの単体テストで落とす | +| I4b | 実行ジョブ | 予算の反映が失敗したら、ジョブを失敗で終え、理由をジョブの出力に残す | ランナーの単体テストで落とす | +| I4c | 実行ジョブ | 導入した NDF に副命令が無い(終了コード 2)ときは、反映せずに今の振る舞いで続ける(決定 6) | ランナーの単体テストで落とす | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 利用者がループの予算を変えた | サービスのループの制御(`updateBudget`。既存) | 次に作る再開のジョブ・周回のジョブ(E2) | +| E2 | サービスが再開のジョブを作った | サービスの実行の制御(`requestResume`・`resumeAfterReset`) | ランナー(実行ジョブの指示として) | +| E3 | ランナーが保存した状態を復元した | ランナー | ランナー自身(E4 へ進む) | +| E4 | ランナーがループの記録の予算を新しい値にした | NDF の `loop budget`(ランナーが打つ) | conductor(E5 へ進む) | +| E5 | conductor が前回の続きから進めた | conductor | NDF(`loop start` の再開・周回の締め) | +| E6 | NDF が周回を締め、止まる条件を新しい予算で判定した | NDF の `loop lap` / `loop gate` | ランナー(`loop.stopped` へ翻訳してサービスへ送る。既存) | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 再開のジョブ | `START=resume` の実行ジョブ。承認の答えで起こすものと、利用上限のリセット時刻の後に起こすものがある。保存した状態を復元して前回の続きから進める | 意味の変更なし(要求で追加済み) | +| 予算の反映 | ループの記録の予算を、渡した単位の値で上書きすること。止まった記録の解除・周回の締めを伴わない | 追加(`ndf-workflow`) | +| 予算の履歴 | ループの記録が持つ、予算の上書きの記録。予算が変わったときだけ、時刻・前の値・後の値の 1 行を足す | 追加(`ndf-workflow`) | +| 人の判断待ちの行 | ループの記録が持つ、人の答え(承認・差し戻し・直して出し直す)を待つ課題の行。`loop answer` で答えを書く。着手の前の課題の状態を指す「人の判断待ち」(`ndf-issue-upkeep`)とは別の語にする | 追加(`ndf-workflow`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 再開のジョブの指示に、そのときのループの予算を載せる | サービス(承認の答え・リセット時刻の後の再開) | +| F2 | 予算だけをループの記録へ反映する副命令(`sprint-state.py loop budget`) | ランナー(手で打つ利用者も使える) | +| F3 | 再開のジョブで、conductor を起こす前に予算を反映する | ランナー | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| サービスの実行ジョブの指示の組み立て(`factory/api/src/runs/control.ts` の `buildInstruction`) | ループの実行の入力があれば、`START=loop` と `START=resume` の両方で宣言した予算を `LOOP_BUDGET` に載せる。マイルストーンは `START=loop` だけのまま | +| ランナーの手順(`factory/runner/src/main.ts` の `runJob`) | `START=resume` で `LOOP_NAME` と `LOOP_BUDGET` があれば、状態の復元と承認の答えの記録の後、人の判断待ちの行への答えの前に、予算の反映を呼ぶ。失敗したら conductor を起こさない | +| ランナーの実行の部品(`factory/runner/src/runtime.ts` の `JobRuntime` の実装) | 新しいメソッド `applyLoopBudget(name, budget)` で `sprint-state.py loop budget <名> --budget-<単位> <値>…` を打つ。終了コードが 0 なら `"applied"`、2 なら `"unsupported"` を返し、ほかは例外を投げる。引数の並べ方は `loopPrompt` と同じ関数で作る | +| NDF の副命令の引数(`plugins/ndf/scripts/lib/sprint_loop_cli.py`) | `loop budget <名> [--budget-laps N] [--budget-usd X] [--budget-hours H] [--root]` を足し、`cmd_budget` へ振り分ける | +| NDF の予算の反映(`plugins/ndf/scripts/lib/sprint_loop.py` の `cmd_budget`) | 排他の中で記録を読み、既存の `apply_budget` で上書きし、記録を書く。止まった記録・周回・人の判断待ちの行に触れない | +| 確定仕様(`docs/specifications/ndf-factory-dev-loops.md`・`docs/specifications/ndf-workflow-development-loop.md`)と NDF の手順(`plugins/ndf/skills/development-workflow/references/loop.md`) | 再開のジョブにも予算が届くこと、ランナーの手順、`loop budget` の副命令の契約を書く | + +```mermaid +graph TB + subgraph Factory + S["サービス: 指示の組み立て"] + R["ランナー: runJob"] + RT["ランナー: applyLoopBudget"] + end + subgraph NDF + C["副命令の引数: loop budget"] + B["予算の反映: cmd_budget / apply_budget"] + L[(ループの記録 loop.json)] + end + S -- "LOOP_BUDGET を載せた再開の指示" --> R + R --> RT + RT -- "sprint-state.py loop budget" --> C + C --> B + B --> L +``` + +図には文書(確定仕様・NDF の手順・用語集)を含めない。 + +## 構造 + +変える型は `JobRuntime` の 1 つである。`RunJobInstruction` は形を変えない。図には、`JobRuntime` が予算を読む相手として載せる。 + +```mermaid +classDiagram + class JobRuntime { + +readLoop(name) LoopFile + +answerLoop(name, answer) + +applyLoopBudget(name, budget) 追加 + +runConductor(...) + } + class RunJobInstruction { + +START + +LOOP_NAME + +LOOP_BUDGET + } + JobRuntime ..> RunJobInstruction : 予算を読む +``` + +- `JobRuntime.applyLoopBudget(name: string, budget: string): Promise<"applied" | "unsupported">`。`budget` は指示の `LOOP_BUDGET` の文字列(`laps=3,usd=60` の形)をそのまま渡す。テストの偽物はこのメソッドを差し替える +- `RunJobInstruction` の形は変えない(要求の前提 4: 契約は再開のジョブの `LOOP_BUDGET` を既に許している)。`START=resume` で `LOOP_BUDGET` が載るのは値の出所の変更だけである + +## 入出力の契約 + +### `sprint-state.py loop budget`(追加) + +| 項目 | 書くこと | +| --- | --- | +| 名前 | `python3 plugins/ndf/scripts/sprint-state.py loop budget <ループ名> [--budget-laps N] [--budget-usd X] [--budget-hours H] [--root <根>]` | +| 入力 | ループ名(必須)。`--budget-*` は数(`float`)で、1 つ以上が必須。`--root` は既存の副命令と同じ | +| 出力(成功) | 終了コード 0。結果 JSON(`status: ok`)。`metrics.status` は `changed`(予算の履歴へ 1 行足した)か `unchanged`(同じ値だった)。`metrics.budget` に反映の後の予算、`items` に足した予算の履歴の行(`unchanged` なら空) | +| 失敗の形 | 終了コード 1。`--budget-*` が 1 つも無い、ループの記録が無いか読めない(既存の `read_loop_record` の `LoopError`)、書けない(`OSError`)。記録は変えない。終了コード 2 は返さない(2 は引数の解析の誤りだけに残し、ランナーが「副命令が無い」と読む。決定 6) | +| 互換性 | 副命令の追加だけである。`loop start` の `--budget-*` は変えない | + +`loop start` と違い、使ってよい条件(`.ndf/pace.json` の `loop` 節・プロジェクト MVV)を照らさない。予算の反映は記録の 1 項目の上書きで、ループを始めも続けもしないためである。止まった記録(`status: stopped`)にも反映し、止まった記録は解かない。 + +### 実行ジョブの指示(値の出所の変更) + +| 項目 | 内容 | +| --- | --- | +| 置き場所 | `factory/contracts/schemas/run-job-instruction.schema.json`(変えない) | +| 変わる約束 | `START=resume` でループの実行の入力があれば、宣言した単位を `LOOP_BUDGET` に載せる(`START=loop` と同じ並べ方) | +| 互換性の扱い | 契約は既に許している。古い版のランナーは `START=resume` の `LOOP_BUDGET` を読まずに無視する(今の振る舞いのまま) | +| 検査の手段 | サービスの単体テストで、組み立てた指示を `validateRunJobInstruction` に通す | + +## 処理の流れ + +再開のジョブのランナーの手順は次の順になる。太字の手順 3 が足す手順である。 + +| 手順 | 中身 | 失敗したとき | +| --- | --- | --- | +| 1. 状態の復元 | `POST /run-jobs/state`(`get`)で保存した状態を戻す | ジョブを失敗で終える(既存) | +| 2. 承認の答えの記録 | `APPROVAL_REQUEST_ID` があれば `sprint-state.py gate` で書く | 同上(既存) | +| **3. 予算の反映** | `LOOP_NAME` と `LOOP_BUDGET` があれば `sprint-state.py loop budget <名> --budget-*` を打つ | 終了コード 2(導入した NDF に副命令が無い)は、標準エラーへ 1 行残して手順 4 へ進む。ほかの 0 以外は conductor を起こさずジョブを失敗で終え、「ループの記録へ予算を反映できない(終了コード N)」を出力の要約へ残す | +| 4. 人の判断待ちの行への答え | `LOOP_ANSWERS` のうち答えの無い番号へ `loop answer` を打つ | 同上(既存) | +| 5. conductor | 「前回の続きから進めてください」で起こす。conductor が `loop start <名>`(予算なし)で止まった記録を解き、続きを進める | 既存 | + +```mermaid +sequenceDiagram + participant S as サービス + participant R as ランナー + participant N as NDF(sprint-state.py) + participant C as conductor + S->>R: 再開のジョブの指示(LOOP_NAME・LOOP_BUDGET) + R->>R: 状態の復元・承認の答えの記録 + R->>N: loop budget <名> --budget-* + alt 終了コードが 0 と 2 のどちらでもない + N-->>R: 1 + R->>S: job.finished(failed・理由) + else 0(反映した)か 2(副命令が無い) + N-->>R: 0 / 2 + R->>N: loop answer(答えの無い番号だけ) + R->>C: 前回の続きから進める + C->>N: loop start <名>・loop lap(新しい予算で判定) + end +``` + +予算の反映が失敗したジョブは状態を保存しない(手順の後の `saveState` まで進まない)。次の再開のジョブは、反映の前に保存した状態から復元し、もう一度反映する。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | 予算の上書きの規則(単位ごとの上書きと予算の履歴)は NDF の 1 か所に置き、ランナーとサービスに同じ規則を写さない | `cmd_budget` は `loop start` と同じ `apply_budget` を呼ぶ。ランナーは値の文字列を引数へ並べるだけで、比べも履歴も持たない | NDF の単体テストで、`loop budget` と `loop start --budget-*` が同じ予算の履歴の行を作ることを確かめる | +| 移行性 | 既に動いているループと保存済みの状態は、そのまま次の再開のジョブで新しい規則に乗る。データの移行は要らない | 記録の形を変えない。保存済みの状態を復元した後に副命令を打つだけである | NDF の単体テストで、予算の履歴を持たない記録(古い記録)へ反映できることを確かめる(`apply_budget` の `setdefault`) | + +## 決定の記録 + +### 決定 1: 予算のほかの処理を起こさないため、予算だけを反映する副命令 `loop budget` を NDF に足し、ランナーが打つ + +既存の `loop start <名> --budget-*` は、既にあるループで予算を上書きした後、止まった記録を解く手続きを続けて行う。その手続きは、承認ゲート 2 の答えの処理・周回の締め・予算超過の判定し直しである。ランナーがこれを打つと、その手続きが conductor より先に起き、続けて conductor が打つ `loop start` は解けた後の記録を見る。受け入れ条件 7 の「conductor が前回の続きから進めるときに既存のとおり 1 回だけ起きる」が成り立たない。予算だけを変える副命令にすると、止まった記録の扱いは conductor の `loop start` に残る。 + +再開の指示の文へ予算を足して conductor に `--budget-*` を打たせる形は採らない。決まった手順を AI に任せることになり、conductor が打ち忘れても検出できない。ランナーが打てば、終了コードで成否が決まる。 + +根拠: Value 5 / Value 7(MVV 版 5) + +### 決定 2: 予算の上書きの規則を 1 か所に置くため、新しい副命令は既存の予算の上書きの関数を呼ぶ + +単位ごとの上書きと、変わったときだけ予算の履歴へ 1 行足す規則は、既存の予算の上書きの関数(`apply_budget`)が既に持つ。新しい副命令はその関数を排他の中で呼び、記録を書くだけにする。ランナーとサービスは値を比べず、履歴も持たない。 + +根拠: Value 7 / Value 8(MVV 版 5) + +### 決定 3: 書き込みの対象が重ならないため、予算の反映は承認の答えの記録の後、人の判断待ちの行への答えの前に置く + +予算の反映が変えるのは記録の予算と予算の履歴だけで、人の判断待ちの行への答え(`loop answer`)が変えるのは人の判断待ちの行と周回のスプリント状態である。どちらも同じ排他の中で記録を読み直して書くため、どちらを先にしても記録は同じになる。そのうえで反映を先に置くのは、反映が失敗したときにループの記録へ何も書かずにジョブを終えられるためである。承認の答えの記録(`sprint-state.py gate`)は周回のスプリント状態を書き、ループの記録には触れない。 + +根拠: Value 5(MVV 版 5) + +### 決定 4: 重複を避けるため、予算の反映は `START=resume` だけで行い、`START=loop` は既存の `--budget-*` のまま渡す + +周回のジョブ(`START=loop`)は conductor の起動の指示に `--budget-*` を既に並べ、`loop start` が新しいループの記録を作るときにも上書きする。最初の周回ではループの記録がまだ無いため、副命令を先に打つと記録が無いことで失敗する。再開のジョブだけが予算を渡す手段を持たないため、そこだけに足す。 + +根拠: Value 3(MVV 版 5) + +### 決定 5: 指示の値の出所を 1 つにするため、サービスは再開のジョブにも周回のジョブと同じ並べ方で `LOOP_BUDGET` を載せる + +`buildInstruction` の予算の並べ方を `START=loop` の条件の外へ出し、ループの実行の入力があれば両方で使う。入力(`resumeLoopInput`)はジョブを作る時点のループの行から読むため、その時点の予算が載る。マイルストーンは `START=loop` だけのままにする(再開では選定をやり直さない)。 + +根拠: Value 7(MVV 版 5) + +### 決定 6: 版を固定した既存の実行の再開を止めないため、副命令が無い NDF(終了コード 2)では反映せずに続ける + +Factory の実行は作った時点の NDF の版を固定し、再開のジョブも同じ版を導入する。この変更より前の版で作り、承認待ち・利用上限待ちのまま残る実行を再開すると、その NDF には `loop budget` が無い。そのため引数の解析が終了コード 2 で終わる(今の `sprint-state.py loop budget x --budget-usd 3` で確かめた)。これを失敗にすると、今は動いている再開が止まる。終了コード 2 のときは反映せずに今の振る舞いで続け、次の周回のジョブ(新しい版で作る)から予算が届く。 + +版の下限を設定に置いてサービスが `LOOP_BUDGET` を載せるかを決める形は採らない。下限の版はリリースで初めて決まり、配布先の設定を足す手間が増える。新しい `loop budget` は終了コード 2 を返さないため、2 は副命令が無いことだけを表す。 + +根拠: 共通原則の上位の原則(人を守る)/ Value 1(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I4a | ランナー: 承認の依頼の識別子のある再開のジョブで、偽の実行の部品の `applyLoopBudget` が書いた予算を、`runConductor` の偽物が起動の時点で読むと新しい値である。呼ぶ順は状態の復元 → 承認の答えの記録 → 予算の反映 → 人の判断待ちの行への答え → conductor | 反映の呼び出しを conductor の後へ移す、または消すと落ちる | +| 受け入れ条件 2 | ランナー: 承認の依頼の識別子の無い再開のジョブでも、受け入れ条件 1 と同じ順で反映を呼ぶ | 反映を `APPROVAL_REQUEST_ID` の有無の条件の中へ入れると落ちる | +| 受け入れ条件 3 | NDF: 費用の予算 60 のループへ `loop budget --budget-usd 40` を打ち、消費の和 50 で周回を締めると `budget_usd` で止まる | `cmd_budget` が記録を書かない、または別の鍵へ書くと落ちる | +| 受け入れ条件 4 | NDF: 費用の予算 40 のループへ `loop budget --budget-usd 100` を打ち、消費の和 50 で周回を締めると止まる条件を書かない | 同上 | +| 受け入れ条件 5・I1a | サービス: 予算を変えた後の `requestResume` と `resumeAfterReset` の指示に、変えた予算の `LOOP_BUDGET` が載り、`validateRunJobInstruction` を通る | 予算の並べ方を `START=loop` の条件の中へ戻すと落ちる | +| 受け入れ条件 6・I3 | NDF: 同じ値の `loop budget` では予算の履歴の行数が変わらず `unchanged`、違う値では 1 行増えて `changed` | 比べずに毎回足すと落ちる | +| 受け入れ条件 7・I2 | NDF: `gate2_returned` で止まった記録へ `loop budget` を打つと、予算と予算の履歴のほか(`status`・`stop`・`stops`・周回・人の判断待ちの行)が前と同じである。その後の `loop start <名>` で止まった記録の解除が 1 回起きる | `cmd_budget` から `resume_stopped` を呼ぶと落ちる | +| 受け入れ条件 8 | ランナー: `LOOP_BUDGET` の無い再開のジョブでは `applyLoopBudget` を呼ばない | 条件を `LOOP_NAME` だけにすると落ちる | +| 受け入れ条件 9・I1b | サービス: ループに属さない実行の再開のジョブの指示に `LOOP_BUDGET` が無い。ランナー: `LOOP_NAME` の無いジョブでは反映を呼ばない | ループの入力の有無を見ずに載せると落ちる | +| 受け入れ条件 10・I4b | ランナー: `applyLoopBudget` が例外を投げると `runConductor` を呼ばず、`job.finished` が失敗で要約に理由を持つ。NDF: 記録の無いループ名と `--budget-*` の無い呼び出しで終了コード 1 | 例外を握りつぶして進めると落ちる | +| I4c(決定 6) | ランナー: `applyLoopBudget` が `"unsupported"` を返すと conductor を起こし、ジョブは今の振る舞いで終わる。実行の部品: 終了コード 2 を `"unsupported"`、1 を例外へ写す | 2 を例外にする、または 1 を `"unsupported"` にすると落ちる | +| 引数の並べ方 | ランナー: `laps=3,usd=60` から `--budget-laps 3 --budget-usd 60` を作る関数を、`loopPrompt` と `applyLoopBudget` が共有する | 片方だけ並べ方を変えると、既存の `loopPrompt` のテストか新しいテストが落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2405 | 実装する | — | `factory/api/src/runs/control.ts`、`factory/api/src/runs/loops.test.ts`、`factory/runner/src/main.ts`、`factory/runner/src/runtime.ts`、`factory/runner/src/runner.test.ts`、`factory/runner/src/loop.test.ts`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_cli.py`、`plugins/ndf/scripts/tests/test_sprint_loop.py`、`plugins/ndf/skills/development-workflow/references/loop.md`、`docs/specifications/ndf-factory-dev-loops.md`、`docs/specifications/ndf-workflow-development-loop.md`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 実物のプロセスでの通し | ランナーのテストは実行の部品を偽物に替える。実物の `sprint-state.py loop budget` をランナーから打つ通しは、リリース後テスト(要求の「検証手段」の手動確認)で確かめる | +| 指示を作る間の予算の変更 | `requestResume` がループの入力を読んでからジョブを書くまでの間に予算が変わると、そのジョブは変わる前の予算を持つ。変えた予算は次のジョブで届く。要求の「含まない」(動いているジョブへの割り込み)と同じ扱いにする | +| 古い版の NDF で作った実行 | 決定 6 のとおり、その実行の再開では予算が届かず、今の振る舞いのまま残る。リリースの時点で承認待ち・利用上限待ちに残る実行の数は未確認。次の周回のジョブからは届く | diff --git a/issues/issue-2405-requirements.md b/issues/issue-2405-requirements.md new file mode 100644 index 000000000..4743cf7b3 --- /dev/null +++ b/issues/issue-2405-requirements.md @@ -0,0 +1,140 @@ +# #2405: Factory の開発ループ: 再開したジョブへ変えた予算を届ける + +正は課題の本文(#2405)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> ## 何をするか +> Factory の開発ループで、承認待ち・利用上限待ちの間に画面で予算を変えても、再開したジョブ(START=resume)は古い予算のまま進む。再開したジョブへ変えた予算を届ける。 +> +> ## 経緯 +> - スプリント lfactory2-3 の検査で、クロスレビューが上限(4 ラウンド)に達して後へ回した指摘(https://github.com/devbasex/ai-plugins/pull/2397 、`factory/api/src/runs/control.ts:377`、major) +> - 今の NDF には再開したループへ予算を渡す口が無く、上書きできるのはループを始めるときの `--budget-*` だけ(#2347 の設計の決定 14) +> +> ## 決めること +> - ランナーが再開の前に NDF の `loop start --budget-*` を打ち直すか、再開の指示へ予算を足すか +> +> ## 受け入れ条件 +> - 承認待ちの間に予算を変えたループを再開すると、NDF の止まる条件の判定が新しい予算で行われる(テストで確かめる) + +(書き手: 起票者、日付: 2026-11-02。スプリント lfactory2-3 の検査で後へ回した指摘を、AI が起票の記録として書いた本文である。利用者の指示の原文は含まない) + +## 起票者の推定 + +依頼の全文が起票者の文であるため、題と本文の条件をすべてここに並べる。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 再開したジョブ(`START=resume`)へ、画面で変えた予算を届ける | PR 2397 のクロスレビューの指摘( https://github.com/devbasex/ai-plugins/pull/2397 )。`factory/api/src/runs/control.ts` の実行ジョブの指示の組み立てが、予算を `START=loop` のときだけ載せている | 受け入れ条件 1・2 | +| S2 | 対象の場面は「承認待ち」と「利用上限待ち」の間の変更 | 起票者の判断。再開のジョブを作る経路が、承認の答えによる再開とリセット時刻の後の再開の 2 つであること | 受け入れ条件 1・2。周回の間の変更は既に届くため含まない(前提 1) | +| S3 | 今の NDF には再開したループへ予算を渡す口が無く、上書きできるのはループを始めるときの `--budget-*` だけ | #2347 の設計の決定 14 | 前提 2 で事実を補う(既にあるループへの `loop start --budget-*` も予算を上書きするが、ほかの処理を伴う) | +| S4 | 届け方は「ランナーが再開の前に `loop start --budget-*` を打ち直す」か「再開の指示へ予算を足す」かの 2 つから選ぶ | 起票者の判断 | 未決 1(設計で決める。選択肢を 2 つに限らない) | +| S5 | 新しい予算で止まる条件の判定が行われることを、テストで確かめる | 起票者の判断 | 受け入れ条件 3・4 | + +## 目的 + +- Factory の利用者が、承認待ちや利用上限待ちで止まっている開発ループの予算を画面で変えたとき、ループが再開した後に NDF がその予算で止まるかを判定する状態にする +- 画面に出る「予算の残り」と、NDF が止まる判定に使う予算を食い違わせない + +## 前提 + +- 前提 1: 周回の間(`between_laps`)に変えた予算は、次の周回のジョブ(`START=loop`)の指示の `LOOP_BUDGET` から `--budget-*` として NDF へ渡っており、この課題では変えない。根拠: `factory/runner/src/runtime.ts` の `loopPrompt` が `LOOP_BUDGET` を `--budget-<単位> <値>` へ並べ、`factory/runner/src/loop.test.ts` の「`--budget-laps 3 --budget-usd 60`」の例がそれを確かめている +- 前提 2: NDF のループの記録(`.ndf/loop/<名>/loop.json`)は既にあるループにも `loop start <名> --budget-*` で予算を上書きでき、変わったときだけ予算の履歴(`budget_history`)に 1 行足す(`plugins/ndf/scripts/lib/sprint_loop.py` の `apply_budget`)。ただし既にあるループへの `loop start` は、止まった記録を解く手続き(承認ゲート 2 の答えの処理・周回の締め・予算超過の判定し直し)も同時に行う(同じファイルの `cmd_start`・`resume_stopped`) +- 前提 3: NDF の止まる条件の判定は、周回を締めるときにループの記録の予算と締めた周回の消費の和を比べる(`plugins/ndf/scripts/lib/sprint_loop_record.py` の予算の判定)。したがって conductor を起こす前にループの記録の予算が新しい値になっていれば、その後の判定は新しい予算で行われる +- 前提 4: 実行ジョブの指示の契約(`factory/contracts/schemas/run-job-instruction.schema.json`)は、ループの実行の `resume` に `LOOP_BUDGET` を載せることを既に許している。契約の形は変えない +- 前提 5: 予算をループの記録へ反映できなかったときは、conductor を起こさずにジョブを失敗で終える。古い予算のまま進めると、この課題が直す食い違いをそのまま残すためである。人の判断待ちへの答えを書けなかったとき(`factory/runner/src/runtime.ts` の `answerLoop`)と同じ扱いにする + +## 対象範囲 + +含む: +- 承認の答えによる再開(`APPROVAL_REQUEST_ID` のある `START=resume`)で、Factory のループの今の予算を NDF のループの記録へ反映すること +- 利用上限の後の再開(`APPROVAL_REQUEST_ID` の無い `START=resume`)で、同じく反映すること +- 反映を確かめる自動テスト(Factory のサービスとランナー、必要なら NDF のスクリプト) +- 確定仕様(`docs/specifications/ndf-factory-dev-loops.md` ほか)の予算の変更の記述を、再開のジョブにも届くことに合わせる + +含まない: +- 動いているジョブ(`running`)の途中で予算を届けること。変えた予算は次に作るジョブ(再開か次の周回)で届く +- 画面で予算の単位を空にした(制限を外した)ときに、NDF の予算を既定へ戻すこと。今の NDF の `--budget-*` は値の上書きだけを持ち、外す口が無い。周回のジョブでも同じ制約があり、別の課題で扱う +- 止まった(`stopped`)・取り消した(`cancelled`)ループの再開。決定 14 のとおり終わりの状態であり、続きは新しいループで始める +- 予算の変更の画面と API(`updateBudget`)の形の変更 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 利用者がループの予算を変えた | 画面か API の予算の変更(`updateBudget`) | 終わりの状態のループは `already_finished` で断る(既存) | ループが `running`・`between_laps`・`awaiting_approval` のいずれか | +| E2 | サービスが再開のジョブを作った | 承認の答え(`requestResume`)か、リセット時刻の経過(`resumeAfterReset`) | 既存の断りの理由(`sprint_run_busy` など)で作らない | E1 の後に作られたジョブに、E1 の予算が載る | +| E3 | ランナーが保存した状態を復元した | 再開のジョブの起動 | 既存のとおりジョブを失敗にする | E2 | +| E4 | ランナーがループの記録の予算を新しい値にした | E3 の完了 | conductor を起こさずジョブを失敗で終える(前提 5) | E3 の後、conductor の起動の前 | +| E5 | conductor が前回の続きから進めた | E4 の完了(予算を宣言していないループでは E3 の完了) | 既存のとおり | E4 | +| E6 | NDF が周回を締め、止まる条件を新しい予算で判定した | conductor が周回を締めるコマンドを打つ | 既存のとおり | E4 | + +E4 と人の判断待ちへの答えの書き込み(既存)の順序は、どちらが先でも結果が同じになることを設計で確かめる(未決 2)。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| ループの予算 | 開発ループが使ってよい周回の数・費用(USD)・時間の上限。Factory のループの行と NDF のループの記録の両方が持つ | +| 再開のジョブ | `START=resume` の実行ジョブ。承認の答えによるものと、利用上限の後のものがある | +| ループの記録 | NDF が開発ループごとに持つ `.ndf/loop/<名>/loop.json`。予算・予算の履歴・周回・止まった理由を持つ | + +## 受け入れ条件 + +- [ ] 1. 承認待ち(`awaiting_approval`)のループの予算を変えた後に、承認の答えで作られた再開のジョブでは、conductor を起こす前に NDF のループの記録の予算が変えた値になっている(ランナーのテストで、記録の予算を読んで確かめる) +- [ ] 2. 利用上限待ち(`awaiting_reset`)の実行のループの予算を変えた後に、リセット時刻の後に作られた再開のジョブでも、受け入れ条件 1 と同じになる +- [ ] 3. 予算を下げて再開した場合、締めた周回の消費の和が新しい予算以上になった周回の締めで、NDF が `budget_<単位>` の止まる条件を記録する(例: 費用の予算を 60 から 40 へ下げ、消費の和 50 で締めると `budget_usd` で止まる) +- [ ] 4. 予算を上げて再開した場合、古い予算なら止まっていた消費の和でも NDF は止まる条件を記録しない(例: 費用の予算を 40 から 100 へ上げ、消費の和 50 で締めると止まらない) +- [ ] 5. 再開のジョブの指示に載る予算は、そのジョブを作った時点の Factory のループの予算である(サービスのテストで、`requestResume` と `resumeAfterReset` の両方の指示に `LOOP_BUDGET` が載ることを確かめる) +- [ ] 6. 予算が変わっていないときは、予算の履歴に行を足さない。変わったときは 1 行だけ足す +- [ ] 7. 予算を反映する操作は、ループの記録の予算と予算の履歴のほかを変えない。止まった記録の解除・周回の締め・承認ゲート 2 の答えの処理は、conductor が前回の続きから進めるときに既存のとおり 1 回だけ起きる +- [ ] 8. 予算をどの単位も宣言していないループの再開では、ループの記録の予算を変えない +- [ ] 9. ループに属さない実行の再開のジョブは、指示も振る舞いも変わらない(`LOOP_BUDGET` を載せない) +- [ ] 10. ループの記録へ予算を反映できなかったとき、ランナーは conductor を起こさずにジョブを失敗で終え、理由をジョブの出力に残す + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 予算の上書きの規則(単位ごとの上書きと予算の履歴)は NDF の 1 か所に置き、ランナーとサービスに同じ規則を写さない | +| 移行性 | 既に動いているループと保存済みの状態は、そのまま次の再開のジョブで新しい規則に乗る。データの移行は要らない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。実行ジョブの指示の契約は再開のジョブの `LOOP_BUDGET` を既に許している(前提 4)。NDF に予算だけを反映するコマンドを足すかは未決 1 | +| データ | スキーマの変更は無い。NDF のループの記録の予算の履歴に、再開のときの変更が 1 行ずつ足される | +| 既存の振る舞い | 再開のジョブで、conductor の起動の前に予算の反映が入る。周回のジョブとループに属さない実行は変わらない | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト(Factory) | `factory/` の既存のテストのコマンド(サービスは `factory/api`、ランナーは `factory/runner` のテスト) | +| テスト(NDF) | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(NDF のスクリプトを変えるとき) | +| 静的解析・型検査 | `factory/` の既存の型検査とリンタ | +| 手動確認 | リリース後テストで、Factory のループを承認待ちで止めて予算を変え、答えで再開した後のループの記録の予算と予算の履歴を見る | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。`plugins/` と `.claude-plugin/marketplace.json` から `factory/` を参照しない(`scripts/check-service-boundary.py`)。ランナーは NDF のスクリプトを、導入した版の取得元の写しから呼ぶ(既存の `answerLoop` と同じ) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストを書かない | +| テスト戦略 | 指示の組み立てはサービスの単体テスト、反映の順序と失敗の扱いはランナーの単体テスト、予算の判定は NDF の既存のテスト(`plugins/ndf/scripts/tests/test_sprint_loop.py`)で担保する | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存のテストの実行、確定仕様の予算の変更の記述を実装に合わせる | +| 確認してから行う | NDF のスクリプトへ新しい副命令を足すこと(公開インタフェースの追加。承認ゲート 1 で見る) | +| 行わない | 予算の単位を外す口の追加、動いているジョブへの予算の割り込み、止まったループの再開 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 予算をループの記録へ反映する手段。候補は (a) ランナーが既存の `loop start <名> --budget-*` を打つ、(b) 再開の指示の文へ予算を足して conductor に打たせる、(c) NDF に予算だけを反映する副命令を足してランナーが打つ。(a) は止まった記録を解く手続きを conductor より先に起こすため、受け入れ条件 7 を満たせるかを確かめる。(b) は決まった手順を AI に任せる形になる | 設計(承認ゲート 1 で利用者が見る) | 設計 PR | +| 2. 予算の反映と、人の判断待ちへの答えの書き込み(既存)の順序 | 設計 | 設計 PR | diff --git a/issues/issue-2422-design.md b/issues/issue-2422-design.md new file mode 100644 index 000000000..1f18ca31a --- /dev/null +++ b/issues/issue-2422-design.md @@ -0,0 +1,349 @@ +# NDF のスクリプト: 見送ったリファクタリングの提案 24 件を判定し、長い関数と重複を減らす(#2422) + +## 目的 + +- **今どうなっているか**: スプリント lflow-1 の cross-refactoring(PR #2414)が、提案 24 件を時間の予算(`budget`)で見送った。対象は文脈の量の判定(`context-policy.py` と `lib/context_decisions.py`)・課題の棚卸し(`upkeep_*.py`)・Skill の利用統計(`skill-stats.py`)などである。これらに長い関数と同じ処理の重複が残っている +- **誰に効くか**: これらのファイルを次に直す実装者と、そのスプリントの cross-refactoring。重複が残ると同じ修正を 2 か所へ入れ、同じ提案が毎回出て時間で見送られる +- **変えると何が成り立つか**: 24 行のそれぞれが「改修した」か「採らない(理由つき)」に決まる。改修した行は、同じ処理の本体が `plugins/` の中で 1 か所になり、長い関数は短く浅くなる。コマンドの引数・終了コード・出力・記録のファイルの形は変わらない + +## 適用範囲 + +- **働く範囲**: このリポジトリの次の 3 か所。配布物の中の関数の分け方は変わるが、配布先のリポジトリから見える振る舞いは変わらない + - `plugins/ndf/scripts/` + - `plugins/ndf/skills/{skill-stats,backlog-refinement}/scripts/` + - リポジトリだけで使う `scripts/token-usage.py` +- **プロジェクトごとに違うもの**: 無し(内部の関数の置き場と分け方だけを変える) +- **当たるモード**: `standard`(要求の前提 5。7 つのファイル群の構造を変える) + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| PR #2414 のコメント `cross-refactoring plan rf2414` の「見送った提案」の表(24 行が `budget`) | 実測(記録の読み取り) | 各行の兆候と手法。この設計の判定表の出所 | +| 2026-11-04 の `develop`(`729dcbe40`)で、各行の定義を Python の構文木(`ast`)で読み、行範囲と入れ子の深さを測った値(下の判定表) | 実測 | 改修の前の行数と深さ。受け入れ条件 4 の「前」の値 | +| 同じ日の `grep -rn "show-toplevel" plugins/ndf/scripts` で、`hook_lib/token_guard.py:456` と `lib/context_decisions.py:75` が同じ引数・同じ時間の上限(5 秒)・同じ失敗の扱いで git を呼ぶ | 実測 | 行 10 の重複の相手 | +| 同じ日の `lib/context_policy.py:21・34・35` に `TRIAL`・`DENY`・`REASON_CAPACITY`・`REASON_COST` があり、`token_guard.py:373・375` は使い、`token_guard.py:333・336・338` は生の文字列で比べる | 実測 | 行 5 は新しい定数を作らず、既存の定数へ寄せれば足りる | +| 契約のチェック(`scripts/contracts_lib/extract_py.py:246-292`)は、書き込みの式に書かれた f 文字列からファイル名を拾う。`append_decision` の書き込み先をパスを作る関数へ置き換えた一時コピーで `check-ndf-contracts.py --draft` を流すと、`writes` から `*.decisions.jsonl` が消えた | 実測(`git archive` で `/tmp` へ展開したコピー) | 行 22・24 の「重複」は契約を抽出させるための形で、まとめると契約が変わる | + +要求と受け入れ条件は #2422 の本文にある(コピーは [issue-2422-requirements.md](issue-2422-requirements.md))。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の cross-refactoring(`ndf-cross-refactoring`) | 見送った提案・兆候・手法(24 行の出所) | +| NDF の開発ワークフロー(`ndf-workflow`) | 判定の記録・文脈の量の方針・全体テスト・スプリントの周回(直すコードの多くが属する) | +| NDF の課題の棚卸し(`ndf-issue-upkeep`) | 調査・見直し・大整理(`upkeep_*.py` が属する) | + +関係は**順応者**(Conformist)である。この変更は、`ndf-cross-refactoring` が残した見送った提案を、その語と兆候の分類(duplication など)のまま受け取る。そのうえで、残り 2 つのコンテキストのコードの構造を直す。`ndf-cross-refactoring` の側は何も変えない。 + +### 集約 + +**集約は変えない。** この変更が書き換えるのは、関数の置き場と分け方だけである。新しく持つ状態は、実装の Pull Request の本文に載せる判定表だけである。判定表は記録として 1 回書くもので、後から書き換える集約ではない。 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 判定表 | 実装の Pull Request の本文(実装の担当だけが書く) | 判定表 | 行(見送った提案 1 件。ファイル・シンボル・兆候で識別する) | 判定(改修した / 採らない)・採らない理由・改修の前後の行数と深さ | + +### 不変条件 + +I1〜I3 は既存の振る舞いを保つ条件、I4〜I7 はこの変更で成り立たせる条件である。 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | — | コマンドの引数・終了コード・標準出力の結果 JSON・hook の出力の文面が、改修の前と同じ | その行の改修を取り消し、判定を「採らない(振る舞いが変わる)」にする | +| I2 | — | `plugins/ndf/contracts/` に差分が無い(契約の抽出が拾う書き込み先・環境変数が変わらない) | その行の改修を取り消す | +| I3 | — | 記録のファイル(判定の記録・適用値・調査の記録・周回の状態・数え上げ)の行のキーと値が、改修の前と同じ | その行の改修を取り消す | +| I4 | 判定表 | 24 行すべてに判定があり、「採らない」の行には理由が 1 つある | 実装の Pull Request を出さない | +| I5 | — | 重複の行を「改修した」なら、同じ処理の本体が `plugins/` の中で 1 か所にある | 残った重複を寄せるまで、その行を「改修した」にしない | +| I6 | — | 長い関数・深い入れ子の行を「改修した」なら、対象の関数の行数か深さが改修の前より小さい | その行を「採らない」にする | +| I7 | — | `plugins/` の Python が `scripts/` を import しない | その import を取り除く | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 各行を今のコードと突き合わせ、改修するか採らないかを決めた | この設計(下の判定表) | 実装の担当(E2 の対象を決める) | +| E2 | 改修する行の関数を通る現状固定テストを足した | 実装の担当(`tdd-cycle`) | 実装の担当(E3 の前提) | +| E3 | 1 行(または同じ重複を解く複数行)ごとに改修を適用した | 実装の担当(`refactoring`) | 全体テスト | +| E4 | 全体テストと静的解析が通った | 全体テストと静的解析 | 実装の担当(E5 の前提) | +| E5 | 24 行の判定と理由を Pull Request の本文に記録した | 実装の担当 | 承認ゲート 2 で見る人 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 見送った提案 | 計画に入らなかった提案と見送った改善項目を合わせたもの。この文書では #2422 の表の 24 行 | 無し(既存の語。`ndf-cross-refactoring`) | +| 現状固定テスト | 改修の前の出力をそのまま期待値にして、構造を変える前後で同じ出力が出ることを縛るテスト | 追加(`ndf-workflow`) | + +この文書で「判定表」と書くのは、見送った提案の行ごとに判定(改修した / 採らない)・理由・改修の前後の行数と深さを並べた表である。実装の Pull Request の本文に置く。この課題だけで使う語のため、用語集には足さない。 + +## 機能一覧 + +利用者から見た機能は変えない。この変更が成り立たせるのは、保守の作業から見た次の 3 つである。 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 見送った提案の行ごとに、改修したか、採らない理由を 1 か所で読める | 承認ゲート 2 で見る人、次の周回の棚卸し | +| F2 | 同じ処理(使用量の行の組み立て・時刻の読み取り・git の根の取得・再起動の行の組み立て・正の整数の判定・「実行した」行の判定)を 1 か所で直せる | これらのファイルを次に直す実装者 | +| F3 | 長い関数を、名前の付いた手順の並びとして読める | 同上 | + +## 24 行の判定表 + +**判定の数**: 改修する 17 行(1・2・3・4・5・6・7・10・11・12・14・15・16・17・18・19・21)、採らない 7 行(8・9・13・20・22・23・24)。重大度 major の 10 行(1・2・3・4・5・6・12・13・14・15)のうち、改修するのは 13 を除く 9 行である。 + +**行番号は #2422 の表の行の順である。** 行数は `ast` の定義の行から本体の最後の行まで(docstring を含む)とする。深さは関数の本体から数えた `if`/`for`/`while`/`with`/`try` の段数で、`elif` は `if` と同じ段に数える。実装の担当は改修の後に同じ測り方で測り直し、Pull Request の本文の表へ前後の値を書く。 + +| 行 | ファイル#シンボル | 兆候 | 判定 | 前の行数 / 深さ | 改修の中身、または採らない理由 | +| --- | --- | --- | --- | --- | --- | +| 1 | `context-policy.py#ledger_calls` | duplication | 改修する | 20 / 1 | 使用量の行の組み立てを `context_decisions.usage_call` へ寄せる(行 15 と同じ重複。決定 1) | +| 2 | `context-policy.py#report_work` | long_method | 改修する | 59 / 3 | 区間の作成・部分の合算・再起動の数え上げ・やり直しの数え上げ・丸めを、同じファイルの関数へ抜き出す | +| 3 | `context-policy.py#session_parts` | long_method | 改修する | 55 / 3 | 印の境目の取得・サブエージェントの加算・帳簿の加算を、同じファイルの関数へ抜き出す | +| 4 | `skill-stats.py#aggregate_by_project` | deep_nesting | 改修する | 38 / 5 | 利用者の発言 1 件を数える部分を同じファイルの関数へ抜き出す(深さ 3 の見込み) | +| 5 | `token_guard.py#_trial_cause` | magic_value | 改修する | 11 / 1 | `"trial"`・`"容量"`・`"費用"` を既存の `context_policy.TRIAL`・`context_policy.REASON_CAPACITY`・`context_policy.REASON_COST` に置き換える | +| 6 | `context_decisions.py#parse_ts` | duplication | 改修する | 7 / 1 | 本体を `clock` の時刻の読み取りへ寄せ、同じ本体の `upkeep_research.py#_iso_epoch` も寄せる(決定 2) | +| 7 | `upkeep_research.py#run_research` | long_method | 改修する | 84 / 4 | 空きを待って止まる理由を返す部分と、経過を書く内側の関数を、同じファイルの関数へ抜き出す | +| 8 | `context-policy.py#IDLE_CAP` | duplication | 採らない | 1 / — | `plugins/` の中に重複の相手が無い。相手は配布の外の `scripts/token-usage.py:60` だけ(要求の前提 3) | +| 9 | `upkeep_research.py#research_one` | deep_nesting | 採らない | 25 / 1 | 入れ子は 1 段で、深い入れ子の兆候に当たらない | +| 10 | `token_guard.py#plan_hint` | duplication | 改修する | 23 / 1 | git の根の取得を `context_decisions.toplevel` の呼び出しに置き換える(`context_decisions` は関数の中で import する) | +| 11 | `upkeep_depth.py#diffs_since` | duplication | 改修する | 4 / 0 | 「その深さを実行した」行の判定と最後の位置を、同じファイルの関数 2 つへ寄せ、`_ran`・`_due_overhaul` からも使う | +| 12 | `scripts/token-usage.py#read_codex` | long_method | 改修する | 58 / 4 | Codex の記録を 1 行ずつ読む部分と、使用量を組む部分を、同じファイルの関数へ抜き出す | +| 13 | `upkeep_research.py#run_research` | long_parameter_list | 採らない | 引数 11 個 | 11 個は待つ条件・記録の持ち主・試験用の差し替えに分かれ、1 つの組として渡っていない。まとめると呼び出し元 13 か所(CLI 1・試験 12)が変わり、得るものが無い | +| 14 | `skill-stats.py#emit_json` | data_clump | 改修する | 53 / 1 | 一緒に渡る 6 つの値(エージェント・集計・合計の行・合計・使用量・除外の数)を 1 つの値の型にまとめ、`emit_markdown` も同じ型を受ける | +| 15 | `context_decisions.py#read_calls` | duplication | 改修する | 59 / 2 | 行 1 と同じ。`usage_call` を新しく作り、両方から使う | +| 16 | `context_decisions.py#read_calls` | long_method | 改修する | 59 / 2 | 自動の要約の検出・最初の発言の取得・呼び出しの登録を、同じファイルの関数へ抜き出す(行 15 の後に行う) | +| 17 | `context_policy.py#_positive_int` | duplication | 改修する | 2 / 0 | 公開の名前 `positive_int` にし、`context_decisions.py#read_window` の同じ判定を置き換える | +| 18 | `sprint_loop_select.py#run_selection` | long_method | 改修する | 70 / 2 | 選べなかったときの止まり方と、周回の行を作って記録する部分を、同じファイルの関数へ抜き出す | +| 19 | `upkeep_overhaul.py#count_types` | divergent_change | 改修する | 37 / 2 | 型ごとの件数・本文からの部品の拾い出し・月ごとの数え上げを、同じファイルの関数 3 つへ分ける。戻す辞書のキーと順序は保つ | +| 20 | `terminal.py#Terminal` | hidden_dependency | 採らない | 199 / — | 環境変数で待ち時間を調整するのは `relay_lib` 全体の約束で、同じ読み方が他に 12 か所ある。1 クラスだけ引数にすると揃いが崩れ、終了の待ちを生成の時点で読むと読む時刻が変わる | +| 21 | `context-policy.py#cmd_record` | duplication | 改修する | 17 / 1 | 再起動の行の組み立て(セッションの読み取り・境目の行・`cut` と再起動の付与)を `context_decisions.py` の関数へ寄せ、`record_restart` と共有する。`context-policy.py#cli_row` は消す | +| 22 | `context_decisions.py#append_decision` | duplication | 採らない | 9 / 2 | 書き込みの式に書いたパスは、契約のチェックが書き込み先を拾うための形である。パスを作る関数へ寄せると契約が変わる(あるべき姿の根拠の 5 行目) | +| 23 | `context_decisions.py#attach_marks` | duplication | 採らない | 10 / 2 | 本体に重複の相手が無い。似ているのは呼ぶ側の区間の作り方で、`report_work` は帳簿にあるセッションを除くなど意図して違う | +| 24 | `context_decisions.py#write_applied` | duplication | 採らない | 18 / 3 | 行 22 と同じ理由(`*.applied.json` も書き込みの式から拾われる) | + +## 構成要素 + +**新しいモジュールは作らない。** 寄せる先は、重複の片方がすでにある既存のモジュールにする。 + +| 要素 | 責務 | 判定表の行 | +| --- | --- | --- | +| 判定の記録の読み書き(`lib/context_decisions.py`) | 使用量の行の組み立て(`usage_call`)と、再起動の行の組み立てを持つ。`read_calls` を短くする | 1・6・15・16・17・21 | +| 文脈の量のコマンド(`context-policy.py`) | 帳簿の読み取りと作業の報告を、抜き出した関数の並びにする | 1・2・3・21 | +| 文脈の量の方針(`lib/context_policy.py`) | 正の整数の判定を公開する | 17 | +| 時刻(`lib/clock.py`) | 時刻の文字列を秒へ変える関数を持つ(`parse` の上に置く) | 6 | +| トークンの監視の hook(`hook_lib/token_guard.py`) | 試行方式の拒否の句と、計画の案内を、既存の定数と関数で組む | 5・10 | +| スプリントの周回の選定(`lib/sprint_loop_select.py`) | 選定の本体を、止まり方と周回の開始の 2 つの関数へ分ける | 18 | +| Skill の利用統計(`skill-stats/scripts/skill-stats.py`) | 発言 1 件の数え上げを抜き出し、エージェントの報告を 1 つの値の型で渡す | 4・14 | +| 課題の棚卸しの調査(`upkeep_research.py`) | 調査の本体を短くし、時刻の読み取りを `clock` へ寄せる | 6・7 | +| 課題の棚卸しの深さ(`upkeep_depth.py`) | 「実行した」行の判定を 1 か所にする | 11 | +| 課題の棚卸しの大整理(`upkeep_overhaul.py`) | 数え上げを変わる理由ごとの関数に分ける | 19 | +| トークン使用量の集計(`scripts/token-usage.py`、配布の外) | Codex の記録の読み取りを短くする | 12 | +| 現状固定テスト | 改修する関数の今の出力を固定する。既存のテストが値を縛っていない関数にだけ足す | 2・3・4・14・18 | + +```mermaid +graph TD + subgraph 配布物の共通部品 + CL[時刻] + CP[文脈の量の方針] + CD[判定の記録の読み書き] + end + subgraph 配布物のエントリポイント + CMD[文脈の量のコマンド] + TG[トークンの監視の hook] + SEL[スプリントの周回の選定] + SS[Skill の利用統計] + UR[棚卸しの調査] + UD[棚卸しの深さ] + UO[棚卸しの大整理] + end + subgraph 配布の外 + TU[トークン使用量の集計] + end + CD -->|正の整数の判定| CP + CD -->|時刻の読み取り| CL + UR -->|時刻の読み取り| CL + CMD -->|使用量の行・再起動の行| CD + TG -->|git の根| CD + UO --> UD +``` + +図に載せていない要素: 現状固定テスト(どの要素からも呼ばれない)。スプリントの周回の選定・Skill の利用統計・トークン使用量の集計は、ファイルの中だけで閉じる改修なので、他の要素への線を持たない。 + +### パッケージ・モジュール構成 + +変えるファイルだけを書く。新設は無い(テストの関数の追加は既存のテストファイルへ入れる)。 + +```text +plugins/ndf/ +├── scripts/ +│ ├── context-policy.py # 行 1・2・3・21(cli_row を消す) +│ ├── hook_lib/token_guard.py # 行 5・10 +│ └── lib/ +│ ├── clock.py # 行 6(秒へ変える関数を足す) +│ ├── context_decisions.py # 行 1・6・15・16・17・21(usage_call・再起動の行を足す) +│ ├── context_policy.py # 行 17(positive_int) +│ └── sprint_loop_select.py # 行 18 +└── skills/ + ├── skill-stats/scripts/skill-stats.py # 行 4・14 + └── backlog-refinement/scripts/ + ├── upkeep_research.py # 行 6・7 + ├── upkeep_depth.py # 行 11 + └── upkeep_overhaul.py # 行 19 +scripts/token-usage.py # 行 12(配布の外) +``` + +## 構造 + +触る関数と、寄せた後の呼び出しの向きだけを描く。同じファイルの中へ抜き出す関数は描かない。 + +```mermaid +classDiagram + class 判定の記録の読み書き { + +usage_call(usage, ts, model) + +restart_row(runtime, source, work, reason) + +toplevel(cwd) + +parse_ts(value) + } + class 文脈の量の方針 { + +positive_int(v) + } + class 時刻 { + +parse(value) + +epoch(value) + } + 判定の記録の読み書き ..> 文脈の量の方針: 使う + 判定の記録の読み書き ..> 時刻: 使う +``` + +- `usage_call` は 1 回の呼び出しの使用量の行(入力・出力・読み込み・キャッシュの書き込み 2 種・文脈の量・時刻・モデル)を返す。帳簿の行だけが持つ秒は、呼ぶ側の `ledger_calls` が足す。キーの順は今の 2 か所と同じにする +- `restart_row` の名前と引数は実装で決める。満たす条件は、`cmd_record` と `record_restart` の両方が、セッションの読み取りと `cut`・再起動の付与をこの関数に任せ、記録の行と標準出力が変わらないことである +- `epoch` の名前は実装で決めてよい。`parse_ts` は呼び出し元が多い(`context_decisions.py` と `context-policy.py`)ため、名前を残して `epoch` を呼ぶ + +## 処理の流れ + +行ごとの改修は、次の順で進める。この順は構成要素の表のどの要素にも同じように当たり、要素ごとの違いは判定表と下の順序の表が持つ。止まったときは、その行だけを取り消して判定を「採らない」にし、次の行へ進む。 + +```mermaid +graph TD + A[判定表の改修する行を 1 つ取る] --> B{関数を通るテストが値を縛っているか} + B -->|縛っている| D[改修の前に通ることを確かめる] + B -->|縛っていない| C[現状固定テストを足す] + C --> D + D -->|落ちる| X[行を採らないにする] + D -->|通る| E[改修する] + E --> F{範囲テストが通るか} + F -->|通る| G[行数と深さを測り直す] + F -->|落ちる| Y[改修を取り消して採らないにする] + G --> H{残りの行があるか} + X --> H + Y --> H + H -->|ある| A + H -->|無い| I[全体テストと静的解析] + I -->|落ちる| Z[原因の改修を取り消して採らないにする] + Z --> I + I -->|通る| J[判定表を Pull Request の本文へ書く] +``` + +**同じファイルの行どうしの順序**は次のとおりにする。後の行が前の行の結果を前提にするためである。 + +| 先に行う | 後に行う | 理由 | +| --- | --- | --- | +| 行 15(`usage_call` を作る) | 行 1・行 16 | 行 1 は `usage_call` を呼び、行 16 は `usage_call` を除いた残りを抜き出す | +| 行 17(`positive_int`) | — | 他の行と当たらない | +| 行 21(再起動の行) | 行 2・3 | 同じ `context-policy.py` の差分を小さく保つ | +| 行 6(`clock` の関数) | 行 7 | 行 7 は同じ `upkeep_research.py` を直す | + +ファイル群(文脈の量・hook・周回・統計・棚卸し・集計)の間には順序の依存が無い。 + +## 非機能の実現方式 + +| 大項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 運用・保守性 | 対象のスクリプトのコマンドの引数・終了コード・出力は変わらない。利用者の手元での操作は増えない | 改修はモジュールの中の関数の分け方と、`plugins/` の中の置き場だけに限る。`plugins/ndf/contracts/` の差分が無いことを契約のチェックで確かめる(I2)。hook の応答時間は既存の締切のテスト(`tests/test_hook_deadline.py`)で見る | + +## 決定の記録 + +### 決定 1: 使用量の行の組み立てを 1 か所にするため、判定の記録の読み書きのモジュールに置く + +使用量の行は、セッションの記録の読み取り(`read_calls`)と帳簿の読み取り(`ledger_calls`)の 2 か所で同じキーと同じ計算で作られている。`context-policy.py` は `context_decisions.py` を import しており、逆向きの import は無い。`context_decisions.py` に置けば向きは変わらず、hook からも使える。 + +使用量の帳簿のモジュール(`usage_ledger`)へ置く形は採らない。帳簿は書く側の形を持つモジュールで、セッションの記録の読み取りの都合を持ち込むことになる。 + +根拠: Value 7(MVV 版 5) + +### 決定 2: 時刻の読み取りの本体を 1 か所にするため、`clock` の `parse` の上に秒へ変える関数を置く + +`parse_ts` と `upkeep_research.py#_iso_epoch` は本体が同じで、`clock.parse` は同じ読み取りを一般化した関数である。秒へ変える関数を `clock` に 1 つ置き、2 つの関数はそれを呼ぶ。`clock` は標準ライブラリだけを import するため、hook が読むモジュールにも循環は生じない。 + +`clock.parse` は前後の空白と小文字の `z` も読む。この 2 つを含む時刻は NDF の記録に現れない(記録の時刻は `clock.now_iso` が書く)。そのため、改修の前に `None` を返していた入力が読めるようになっても、記録を読む結果は変わらない。この差は現状固定テストでは縛らない。 + +`#_iso_epoch` は判定表に無い関数である。重複の行は重複の相手を寄せなければ I5 を満たせないため、重複の行の改修は重複の相手を含めて扱う。形の違う時刻の読み取り(`slow_step.py:346` は `"null"` も読む、`check-trigger.py:248` は UTC へ変える など)は同じ処理ではないため寄せない。 + +根拠: Value 7 / Value 3(MVV 版 5) + +### 決定 3: 契約の抽出を保つため、書き込み先のパスの重複(行 22・24)は採らない + +契約のチェックは、書き込みの式に書かれた f 文字列から書き込み先のファイル名を拾う。パスを作る関数へ寄せると、契約から書き込み先が消え、受け入れ条件の「`plugins/ndf/contracts/` に差分が無い」を破る。契約の抽出の方を直すのはこの課題の範囲の外である(要求の対象範囲は表の関数に限る)。 + +根拠: Value 3 / Value 7(MVV 版 5) + +### 決定 4: 1 クラスだけ揃いを崩さないため、ラッパーの端末の環境変数の読み方(行 20)は採らない + +`relay_lib` は、待ち時間を環境変数で調整する約束を 12 か所で同じ書き方で持つ。端末のクラスだけを引数で受ける形にすると、同じパッケージに 2 つの書き方が残る。揃えるならパッケージ全体を直す変更になり、この課題の範囲を超える。 + +根拠: Value 7 / Value 3(MVV 版 5) + +### 決定 5: 呼び出し元を変えずに済む範囲に留めるため、調査の本体の引数(行 13)は採らない + +`run_research` の 11 個の引数は、待つ条件・記録の持ち主・試験用の差し替えに分かれ、どれも 1 つの組として渡っていない。値の型にまとめると、CLI の 1 か所と試験の 12 か所が変わり、関数が受ける情報は変わらない。 + +一緒に渡る組は内側の `log_call` と `research_one` の `run`・`loop`・`lap` にある。これは判定表に無い関数の兆候で、この課題では扱わない。 + +根拠: Value 3(MVV 版 5) + +### 決定 6: 既存のテストを振る舞いの基準にし、値を縛るテストが無い関数にだけ現状固定テストを足す + +既存のテストが出力の値を縛っている関数(行 1・5・6・7・10・11・12・15・16・17・19・21)は、そのテストを改修の前後で通す。値を縛っていない関数にだけ、今の出力を期待値にするテストを足す。対象は、行 2・3 の作業の報告の秒・費用・再起動の数、行 4 のトリガーとヒットの数、行 14 のエージェントの報告の JSON、行 18 の直しの周回の印である。`.md` の文言を照合するテストは書かない。 + +すべての行に新しく現状固定テストを足す形は採らない。同じ振る舞いを縛るテストが 2 つになり、片方だけが直される。 + +根拠: Value 1 / Value 5(MVV 版 5) + +### 決定 7: 承認ゲート 1 で範囲を縮められるよう、行をファイル群の単位で独立に改修できる順にする + +要求の未決 1(24 行すべてか、major の 10 行に絞るか)は承認ゲート 1 で決まる。ファイル群の間には順序の依存を持たせない(処理の流れの表)。そのため、絞ると決まった場合は外れた行を「採らない(承認ゲート 1 で範囲を絞った)」として判定表に残し、残りの行だけを改修する。設計を作り直さない。 + +根拠: Value 2 / Value 3(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I4(24 行の判定と理由) | Pull Request の本文の判定表を、承認ゲート 2 で人が読む(テストにしない) | — | +| 受け入れ条件 2(改修した行の関数を通るテストが前後で通る) | 判定表の「改修する」17 行それぞれで、関数を通るテストを改修の前のコミットと後のコミットで流す | 抜き出した関数の中で値を 1 つ変える(例: 使用量の行のキャッシュの書き込みを 1 種落とす)と落ちる | +| 受け入れ条件 3・I5(重複の本体が 1 か所) | 行 1・6・10・11・15・17・21 の処理を `grep` で探し、定義が `plugins/` の中で 1 件になる | 重複を 1 つ戻すと 2 件になる | +| 受け入れ条件 4・I6(行数か深さが小さい) | 行 2・3・4・7・12・16・18 の関数を `ast` で測り、前後の値を判定表へ書く | 抜き出した関数を元へ戻すと前の値に戻る | +| 受け入れ条件 5(名前の無い値) | 行 5 の比較が `context_policy` の定数を通る | 生の文字列へ戻すと `grep` で見つかる | +| 受け入れ条件 6・I1・I3(全体テスト) | `uv run --frozen --project . --all-extras pytest . -q -n 4` | 作業の報告の秒・再起動の数、エージェントの報告の JSON のキーと順序、数え上げの辞書のキーの順を変えると、足した現状固定テストか既存のテストが落ちる | +| 受け入れ条件 7(静的解析) | `python3 scripts/check-script-structure.py`・`python3 scripts/check-ndf-contracts.py`・`bash scripts/check-lint.sh` が 0 | — | +| 受け入れ条件 8・I2(契約の差分が無い) | `git diff --stat develop -- plugins/ndf/contracts/` が空 | 書き込みの式のパスを関数へ置き換えると、契約のチェックが差分を出す | +| 受け入れ条件 9・I7(`scripts/` を import しない) | `git diff develop -- plugins/` の追加の行に `scripts/` のモジュールの import が無い | `token-usage.py` の関数を import すると見つかる | +| 受け入れ条件 10(版が変わらない) | `git diff develop -- plugins/ndf/.claude-plugin/plugin.json` が空 | — | +| 行 10 の hook の応答時間 | 既存の `tests/test_hook_deadline.py` | `plan_hint` で判定の記録のモジュールを最上位で import して重くすると、締切を超えれば落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2422 | 実装する | — | `plugins/ndf/scripts/context-policy.py`、`plugins/ndf/scripts/hook_lib/token_guard.py`、`plugins/ndf/scripts/lib/clock.py`、`plugins/ndf/scripts/lib/context_decisions.py`、`plugins/ndf/scripts/lib/context_policy.py`、`plugins/ndf/scripts/lib/sprint_loop_select.py`、`plugins/ndf/skills/skill-stats/`、`plugins/ndf/skills/backlog-refinement/`、`plugins/ndf/scripts/tests/`、`scripts/token-usage.py`、`scripts/tests/`、`docs/glossary/` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 行 10 の hook の読み込みの時間 | `plan_hint` が判定の記録のモジュールを読むと、エージェントの起動の hook で読み込みが増える。手元の `python3 -X importtime` で累計 約 43 ms と測ったが、締切への余裕は未確認。実装で締切のテストを流して確かめる | +| 判定表の前の値の再現 | 表の値は判定表の冒頭の測り方を 1 本の `ast` のスクリプトで `729dcbe40` に当てた値である。スクリプトはリポジトリに置かないため、実装の担当は同じ測り方で前(`develop`)と後を測り直し、本文の表に書く | +| 範囲の外で見つけた重複 | `token_guard.py:398` の `"拒否"`(`_judge` の中)、`plan_hint` と `guard_context` のサブエージェントの除外、`run_research` の内側の `run`・`loop`・`lap` の組、`research_one` の同じ条件の 2 回の評価。どれも判定表に無い関数で、利用者へ届く不具合ではないため起票しない(Value 3)。次の周回の cross-refactoring が提案すれば、そのとき判定する | +| 要求の未決 1・2 | 24 行すべてか major の 10 行に絞るか、見送った提案を起票する手順と Value 3 の食い違いの扱いは、承認ゲート 1 で決まる(決定 7) | diff --git a/issues/issue-2422-requirements.md b/issues/issue-2422-requirements.md new file mode 100644 index 000000000..e8c518f69 --- /dev/null +++ b/issues/issue-2422-requirements.md @@ -0,0 +1,192 @@ +# #2422: クロスリファクタリングが時間の予算で見送った 24 件(スプリント lflow-1) + +正は課題の本文(#2422)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何を見つけたか + +スプリント lflow-1 の検査で、クロスリファクタリングは改善の候補を 30 件出した。そのうち 24 件は時間の予算に入らず見送った(理由 `budget`。残り 2 件は同じ変更の重複)。見送った 24 件は次のとおり。 + +| ファイル | シンボル | 兆候 | 手法 | 重大度 | +| --- | --- | --- | --- | --- | +| `plugins/ndf/scripts/context-policy.py` | `ledger_calls` | duplication | consolidate_duplication | major | +| `plugins/ndf/scripts/context-policy.py` | `report_work` | long_method | extract_method | major | +| `plugins/ndf/scripts/context-policy.py` | `session_parts` | long_method | extract_method | major | +| `plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | `aggregate_by_project` | deep_nesting | extract_method | major | +| `plugins/ndf/scripts/hook_lib/token_guard.py` | `_trial_cause` | magic_value | introduce_named_constant | major | +| `plugins/ndf/scripts/lib/context_decisions.py` | `parse_ts` | duplication | consolidate_duplication | major | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py` | `run_research` | long_method | extract_method | minor | +| `plugins/ndf/scripts/context-policy.py` | `IDLE_CAP` | duplication | centralize_configuration | minor | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py` | `research_one` | deep_nesting | flatten_conditional | minor | +| `plugins/ndf/scripts/hook_lib/token_guard.py` | `plan_hint` | duplication | consolidate_duplication | minor | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py` | `diffs_since` | duplication | consolidate_duplication | minor | +| `scripts/token-usage.py` | `read_codex` | long_method | extract_method | major | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py` | `run_research` | long_parameter_list | introduce_parameter_object | major | +| `plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | `emit_json` | data_clump | introduce_parameter_object | major | +| `plugins/ndf/scripts/lib/context_decisions.py` | `read_calls` | duplication | consolidate_duplication | major | +| `plugins/ndf/scripts/lib/context_decisions.py` | `read_calls` | long_method | extract_method | minor | +| `plugins/ndf/scripts/lib/context_policy.py` | `_positive_int` | duplication | extract_method | minor | +| `plugins/ndf/scripts/lib/sprint_loop_select.py` | `run_selection` | long_method | extract_method | minor | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_overhaul.py` | `count_types` | divergent_change | extract_method | minor | +| `plugins/ndf/scripts/relay_lib/terminal.py` | `Terminal` | hidden_dependency | change_signature | minor | +| `plugins/ndf/scripts/context-policy.py` | `cmd_record` | duplication | consolidate_duplication | minor | +| `plugins/ndf/scripts/lib/context_decisions.py` | `append_decision` | duplication | consolidate_duplication | minor | +| `plugins/ndf/scripts/lib/context_decisions.py` | `attach_marks` | duplication | consolidate_duplication | minor | +| `plugins/ndf/scripts/lib/context_decisions.py` | `write_applied` | duplication | consolidate_duplication | minor | + +## どこで見つけたか + +スプリントの Pull Request https://github.com/devbasex/ai-plugins/pull/2414 のリファクタリング計画(コメント `cross-refactoring plan rf2414`)。 + +## なぜこの変更の範囲外なのか + +開発ループは、品質を上げる工程を上限で打ち切り、残りを次の周回の棚卸しへ回す(`development-workflow` の `references/loop.md`)。この周回の受け入れ条件は、#2346・#2315・#2262 の機能そのものである。根拠: Value 10(MVV 版はプロジェクトの MVV)。 + +## 直さないと何が起きるか + +#2262(context-policy.py・context_decisions.py)、#2315(upkeep_depth.py)、skill-stats の新しいコードに、長いメソッドと重複が残る。次に触るときに読みにくく、同じ修正を 2 か所へ入れることになる。 + +## 起票者の推定 +- 無し + +## 由来 + +PR #2414 + +## 依頼(原文) + +> クロスリファクタリングが時間の予算で見送った 24 件(スプリント lflow-1) + +> スプリント lflow-1 の検査で、クロスリファクタリングは改善の候補を 30 件出した。そのうち 24 件は時間の予算に入らず見送った(理由 `budget`。残り 2 件は同じ変更の重複)。 + +> #2262(context-policy.py・context_decisions.py)、#2315(upkeep_depth.py)、skill-stats の新しいコードに、長いメソッドと重複が残る。次に触るときに読みにくく、同じ修正を 2 か所へ入れることになる。 + +(書き手: 起票者(開発ループが `out-of-scope` で起こした本文)、日付: 2026-11-02。利用者の指示の原文は無い) + +> 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-04) + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 見送った 24 件を、次の周回で改修する対象として扱う | 開発ループの手順(`development-workflow` の `references/loop.md` の「品質を上げる工程は今の上限で打ち切る」) | 未決 1(進めるかどうかと量は承認ゲート 1 で決める) | +| S2 | 24 件を直さないと、次に触るときに同じ修正を 2 か所へ入れることになる | 本文の「直さないと何が起きるか」 | 目的 | + +## 目的 + +- 見送った提案 24 件(関数・定数 22 個。同じ関数に別の兆候が 2 行ある関数が 2 個)について、振る舞いを変えずに長い関数と重複を減らす。次にこれらのファイルを直すとき、1 か所の修正で済む状態にする +- 改修しない行は、理由を残して終える。理由の無い見送りを次の周回へ持ち越さない + +## 前提 + +- 前提 1: この課題はリファクタリングである。公開インタフェース(コマンドの引数・終了コード・標準出力の結果 JSON・`plugins/ndf/contracts/` が定めるファイルと環境変数の形)は変えない +- 前提 2: 24 行は今の `develop`(2026-11-05 の `729dcbe40`)にすべて残っている。22 個の関数・定数の定義をファイルごとに検索し、すべて見つかった(検索の結果は下の「確認した事実」) +- 前提 3: 配布物(`plugins/`)は、リポジトリだけの道具(`scripts/`)を import しない。`parse_ts` と `IDLE_CAP` は `scripts/token-usage.py` にも同じものがある。この 2 行の重複は `plugins/` の中で 1 か所にまとめる範囲で解き、`scripts/` 側との共有は扱わない +- 前提 4: 改修の前後で振る舞いが変わらないことは、改修の前の出力をそのまま期待値にしたテスト(現状固定テスト。`refactoring` の Skill の語)で確かめる。改修すると公開インタフェースが変わる行、または現状固定テストを書けない行は、改修せずに「採らない」と理由を記録する +- 前提 5: モードは `standard` とする(複数のファイルの構造を変えるため) + +## 確認した事実 + +各行のファイルで `def <名前>` / `class <名前>` / `<名前> =` を検索した結果(2026-11-04、`729dcbe40`)。22 個すべてが見つかった。 + +| ファイル | 見つかった定義(行) | +| --- | --- | +| `plugins/ndf/scripts/context-policy.py` | `IDLE_CAP`(39)・`ledger_calls`(59)・`session_parts`(134)・`report_work`(191)・`cmd_record`(286) | +| `plugins/ndf/scripts/lib/context_decisions.py` | `parse_ts`(97)・`append_decision`(157)・`write_applied`(247)・`read_calls`(270)・`attach_marks`(348) | +| `plugins/ndf/scripts/lib/context_policy.py` | `_positive_int`(133) | +| `plugins/ndf/scripts/hook_lib/token_guard.py` | `_trial_cause`(331)・`plan_hint`(448) | +| `plugins/ndf/scripts/lib/sprint_loop_select.py` | `run_selection`(247) | +| `plugins/ndf/scripts/relay_lib/terminal.py` | `Terminal`(49) | +| `plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | `aggregate_by_project`(378)・`emit_json`(750) | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py` | `research_one`(294)・`run_research`(321) | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py` | `diffs_since`(109) | +| `plugins/ndf/skills/backlog-refinement/scripts/upkeep_overhaul.py` | `count_types`(65) | +| `scripts/token-usage.py` | `read_codex`(472)・`parse_ts`(91)・`IDLE_CAP`(60) | + +## 対象範囲 + +含む: +- 「何を見つけたか」の表の 24 行それぞれの、改修するか採らないかの判定と、改修する行の改修 +- 改修する行で、対象の関数を通るテストが無い場合の現状固定テストの追加 + +含まない: +- PR #2414 のリファクタリング計画で「未確認」になった 4 件(`calibrate` など。最終ゲートを経ずに残った改修)の確かめ直し +- 表に無い関数・ファイルのリファクタリング +- `scripts/token-usage.py` と `plugins/` の間で関数・定数を共有する仕組みの導入(前提 3) +- 開発ループが見送った提案を Issue に起こす手順そのものの見直し + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 各行を今のコードと突き合わせ、改修するか採らないかを決めた | 承認ゲート 1 を通った | 判定できない行は「採らない」と理由を書く(前提 4) | — | +| E2 | 改修する行の関数を通る現状固定テストを足した | E1 で改修すると決めた行に、通るテストが無い | テストを書けない行は E1 へ戻して「採らない」にする | E1 | +| E3 | 1 行(または同じ重複を解く複数行)ごとに改修を適用した | E2 のテストが改修前に通った | テストが落ちたら、その改修だけを取り消し、行を「採らない」にする | E2 | +| E4 | 全体テストと静的解析が通った | すべての行の E3 が終わった | 落ちた原因の改修を取り消し、E3 へ戻す | E3 | +| E5 | 24 行の判定と理由を Pull Request の本文に記録した | E4 が通った | — | E4 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 見送った提案 | クロスリファクタリングが計画に入れなかった提案と、見送った改善項目。この課題では「何を見つけたか」の表の 24 行で、理由はどれも時間の予算(`budget`) | +| 全体テスト | リポジトリのすべてのテスト(`uv run --frozen --project . --all-extras pytest . -q -n 4`) | + +## 受け入れ条件 + +- [ ] Pull Request の本文に 24 行の表があり、各行に「改修した」か「採らない」と、採らない行には理由が 1 つ書いてある +- [ ] 「改修した」の行の関数には、その関数を通るテストがあり、改修の前のコミットと後のコミットの両方で通る +- [ ] 重複(兆候 duplication)を「改修した」の行では、同じ処理の本体が `plugins/` の中で 1 か所だけに定義されている(関数名か処理の検索で 1 件) +- [ ] 長い関数(兆候 long_method)と深い入れ子(兆候 deep_nesting)を「改修した」の行では、対象の関数の行数か入れ子の深さが改修の前より小さい(数値を本文の表に書く) +- [ ] 名前の無い値(兆候 magic_value)を「改修した」の行では、その値が名前の付いた定数を通して参照されている +- [ ] `uv run --frozen --project . --all-extras pytest . -q -n 4` が通る +- [ ] `python3 scripts/check-script-structure.py`・`python3 scripts/check-ndf-contracts.py`・`bash scripts/check-lint.sh` が 0 で終わる +- [ ] `plugins/ndf/contracts/` に差分が無い(公開インタフェースが変わっていない。退行しない) +- [ ] `plugins/` の Python が `scripts/` のモジュールを新たに import していない(`git diff` に該当の import の行が無い) +- [ ] `plugins/ndf/.claude-plugin/plugin.json` の `version` が変わっていない(版はスプリントの後に上げる) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 対象のスクリプトのコマンドの引数・終了コード・出力は変わらない。利用者の手元での操作は増えない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない | +| データ | 変わらない(記録のファイルの形を変えない) | +| 既存の振る舞い | 変わらない。内部の関数の分け方と定義の場所だけが変わる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析・型検査 | `python3 scripts/check-script-structure.py` / `python3 scripts/check-ndf-contracts.py` / `bash scripts/check-lint.sh` | +| 手動確認 | 無し(振る舞いを変えないため、リリース後テストは自動テストの結果で足りる) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。配布物(`plugins/`)から `scripts/` と `factory/` を参照しない | +| コーディング規約 | `plugins/ndf/skills/refactoring/SKILL.md`(テストで守ってから構造を変える)。確認は上の静的解析 | +| テスト戦略 | 既存の単体テストを現状固定テストとして使い、関数を通るテストが無い行だけ現状固定テストを足す。`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 改修の前に対象の関数を通るテストを確かめる。全体テストと静的解析を流す | +| 確認してから行う | 公開インタフェースに触れる変更(行わずに「採らない」とし、承認ゲート 2 で人が見る) | +| 行わない | 表に無い関数の改修、`scripts/` と `plugins/` の共有の仕組みの導入、版数の変更 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 未決 1: この課題を進めるか、進めるなら 24 行すべてか重大度 major の 10 行に絞るか。Value 2(人が 1 回の承認で読み切れる量)と Value 3(上限に当たったら縮めるを既定にする)に照らし、分量で分けるかもここで決める | 利用者(承認ゲート 1 の先頭の要否) | 承認ゲート 1 | +| 未決 2: Value 3 は Issue に起こすものを利用者へ届いた不具合などに限るが、開発ループの手順(`loop.md`)は見送った提案を Issue に起こす。この食い違いの扱い(この課題の範囲外。前提とせず記録だけする) | 利用者 | 承認ゲート 1 | diff --git a/issues/issue-2431-design.md b/issues/issue-2431-design.md new file mode 100644 index 000000000..b2537c328 --- /dev/null +++ b/issues/issue-2431-design.md @@ -0,0 +1,196 @@ +# 画面の地図: 予定の画面ごとに、その画面を作る課題が分かる(#2431) + +## 目的 + +- **今どうなっているか**: 画面の地図(`docs/specifications/ndf-factory-screen-map.md`)で状態が「予定」の 3 行(承認・請求と使用量・手元のマシン)は、載る機能だけを書いている。どの課題がその画面を作るかは書いていない +- **誰に効くか**: Factory の画面を足す課題の設計者と実装者 +- **変えると何が成り立つか**: 予定の行を読むだけで、その画面を自分の課題が作るかを判断できる。#2382(UX の土台)の受け入れ条件 2 の残り(まだ画面が無い機能の、作る課題)を、#2382 が閉じた後も画面の地図が持ち続ける + +## 適用範囲 + +- **働く範囲**: このリポジトリの Factory の確定仕様 1 ファイルだけ。配布物(`plugins/`)と Factory のコード(`factory/`)は変えない +- **プロジェクトごとに違うもの**: 無し(Factory 固有の文書の変更で、設定と引数を持たない) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| #2382 の受け入れ条件 2「まだ画面が無い機能は、載せる予定の画面と持ち主の課題」 | 利用者が承認ゲート 1 で確かめた条件の原文 | 予定の画面に作る課題を示すこと自体が、承認済みの要求である | +| 画面の地図の表の見出し「載る機能(確定仕様の節)」と、状態が「あり」の 13 行のうち 10 行(30〜39 行)が末尾の括弧に出所(確定仕様の節)を置く形 | 既存の文書の実測。残りの 3 行(ループ・ループの詳細・バックログ)は括弧を持たない | 予定の行の出所は、確定仕様の節がまだ無いため作る課題になる。同じ括弧の位置へ置けば、表の形を変えずに済む | +| `factory/web/src/docs.test.ts` の `tableRows` が行を ` \| ` で割り、1 列目(経路)と 4 列目(状態)だけを読む | 既存のテストのコードの実測 | 5 列目の末尾へ書き足しても、テストの読み方は変わらない | +| Factory の確定仕様は課題を裸の `#<番号>` で指す(`ndf-factory-*.md` で裸の `#2173` が 20 か所、括弧つきの `(#2173)` が 6 か所) | 既存の文書の実測(`grep -rhoE` の集計) | 課題の書き方を既存の確定仕様にそろえる | + +要求と受け入れ条件は #2431 の本文にある(コピーは `issues/issue-2431-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| Neural Development Factory(`ndf-factory`) | 画面の地図・予定の画面・作る課題 | + +変更は 1 つのコンテキストに収まる。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 画面の地図 | 画面の地図の文書(`docs/specifications/ndf-factory-screen-map.md`)。行ごとに、次の規則に当たる課題の変更だけが書き換えてよい(下の「行を書き換えてよい課題」) | 画面の地図の表 | 画面の行(経路・画面・役割・状態・載る機能) | 状態(あり / 予定)・作る課題(課題の番号と題) | + +#### 行を書き換えてよい課題 + +| 行の状態 | 書き換えてよい課題 | 書き換えてよい内容 | +| --- | --- | --- | +| 予定で、作る課題が書かれていない | 予定の行に作る課題を割り当てる課題(3 行の初期値はこの課題 #2431 が書く。E1) | 作る課題の括弧を足す | +| 予定で、作る課題が書かれている | その行の作る課題 | 状態を「あり」へ変える(E3)。その画面を作らないと決まったときは、同じ変更で行ごと消す(作る課題を持たない予定の行を残さない。I1) | +| 予定で、作る課題が書かれている | その行を作ると申し出る別の課題(E2 の失敗) | 作る課題の括弧を自分の番号と題へ書き換える | +| あり | その行の画面を変える課題 | 既存の手順のとおり | + +経路の定義(`factory/web/src/screens.ts`)は別の正本で、この集約に入らない。両者は状態が「あり」の行の経路の集合でだけ照合する(既存のテスト)。作る課題は経路を作らないため、`screens.ts` へ書き込まない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 画面の地図 | 状態が「予定」の行は、どれも作る課題を 1 つ、`(作る課題: #<番号> <題>)` の形で「載る機能」の列の末尾に持つ | 実装の完了判定とリリース後テストで、予定の行を読んで落とす | +| I2 | 画面の地図 | 状態が「あり」の行は作る課題を持たない | 予定の行を「あり」へ変える課題の変更で、作る課題の括弧を確定仕様の節へ置き換える。破れ(括弧を残したまま「あり」へ変える)は、その課題の完了判定で行を読んで落とす | +| I3 | 画面の地図 | 状態が「あり」の行の経路の集合は、`screens.ts` の画面が使える経路の集合と一致する | 既存のテスト(`docs.test.ts`)が落とす | + +作る課題が GitHub で open であることは、書く時点の条件として扱い、不変条件に入れない。行を書く課題の変更が、書く前に `gh issue view <番号> --json state` で `OPEN` を確かめ、`OPEN` でなければ書かない(受け入れ条件 2)。書いた後に作る課題が閉じたことを見つける検査は、この課題では作らない(「未確認のまま残ること」)。 + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 予定の画面に、作る課題を書いた | この課題の実装 | 画面を足す課題の設計者(E2 の読み手) | +| E2 | 画面を足す課題が、予定の行を自分の持ち物と判断した | 画面を足す課題の設計か実装 | 同じ課題の実装(E3) | +| E3 | 予定の画面を作り、行を「あり」へ変えた | 作る課題の実装 | 既存のテスト(`docs.test.ts`。I3 を照合する) | + +E2 の失敗(行に自分の番号が無い)は、その行を作ると申し出る課題が、自分の変更で行の作る課題を自分の番号と題へ書き換えて扱う(「行を書き換えてよい課題」の 3 行目)。この課題は書き換えの仕組みを作らない。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 予定の画面 | 画面の地図で状態が「予定」の行。経路がまだ無く、載る機能だけが決まっている画面 | 追加(`ndf-factory`。要求の変更で反映済み) | +| 作る課題 | 予定の画面を作り、画面の地図のその行を「あり」へ変える GitHub の課題。#2382 は「持ち主の課題」と書く | 追加(`ndf-factory`。要求の変更で反映済み) | + +「持ち主」は用語集で状態の持ち主・見直しの持ち主など別の意味に使われているため、文書では「作る課題」と書く。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 予定の画面の行を読み、その画面を作る課題を知る | 画面を足す課題の設計者と実装者 | +| F2 | 自分の課題が作る予定の行を、「あり」の行へ変える手順を知る | 予定の画面を作る課題の実装者 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 画面の地図の表の「予定」の 3 行(`docs/specifications/ndf-factory-screen-map.md` の 43〜45 行) | 載る機能の後に、作る課題を `(作る課題: #<番号> <題>)` で示す(F1) | +| 画面の地図の冒頭の段落(同じファイルの 3 行) | 画面を足す課題の手順に、作る課題として書かれた予定の行を作るときの扱いを足す(F2) | +| 既存のテスト(`factory/web/src/docs.test.ts`) | 変えない。状態が「あり」の行の経路の集合と `screens.ts` の一致を照合し続ける(I3) | + +```mermaid +graph TB + subgraph 文書["画面の地図の文書"] + Lead["冒頭の段落
画面を足す手順"] + Planned["予定の 3 行
作る課題を持つ"] + Available["あり の 13 行
作る課題を持たない"] + end + Issues["作る課題
#2173・#2174・#2189"] + Screens["screens.ts
経路の定義の正本"] + Test["docs.test.ts"] + Lead -->|手順を示す| Planned + Planned -->|番号と題で指す| Issues + Planned -->|作られたら変わる| Available + Test -->|経路の集合を照合する| Available + Test -->|経路の集合を照合する| Screens +``` + +### 予定の 3 行の変更後の形 + +経路・画面・役割・状態の 4 列は変えない。5 列目の載る機能の後に括弧を足す。題は各課題の題から「NDF Factory: 」を除いたものである(2026-11-04 に `gh issue view` で確かめた)。 + +| 画面 | 5 列目(載る機能)の変更後 | +| --- | --- | +| 承認 | 承認の依頼・Web からの承認・通知(作る課題: #2173 承認ゲートの通知(Slack を含む)と Web からの承認) | +| 請求と使用量 | 料金プランの料金・使用量の台帳・決済(作る課題: #2174 サブスクリプションと Bedrock の token 課金) | +| 手元のマシン | 手元のマシンの登録・心拍(作る課題: #2189 手元のマシン(利用者の環境で、利用者の claude ログインで実行する)) | + +### 冒頭の段落の変更後の形 + +今の段落の「画面を足す課題は、この表に 1 行を足し、…」の後に、次の 1 文を足す。 + +> 予定の行に作る課題として書かれた課題は、行を足さずにその行の状態を「あり」へ変え、経路を書き、作る課題の括弧を確定仕様の節へ置き換える(節が無ければ括弧を消す)。 + +## 処理の流れ + +予定の行は、作る課題の変更で 1 度だけ「あり」へ変わる。逆向きの遷移は無い。 + +```mermaid +stateDiagram-v2 + [*] --> 予定_作る課題なし + 予定_作る課題なし --> 予定_作る課題あり: この課題の実装(E1) + 予定_作る課題あり --> 予定_作る課題あり: 行を作ると申し出る課題が作る課題を書き換える(E2 の失敗) + 予定_作る課題あり --> [*]: 作る課題が、その画面を作らないと決めて行ごと消す + 予定_作る課題あり --> あり: 作る課題の実装(E3) +``` + +## 非機能の実現方式 + +| 大項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 運用・保守性 | 予定の行を読むだけで、その行を自分が作るかを判断できる | 作る課題を番号だけでなく題も添えて行の中に書き、課題の一覧を開かずに判断できるようにする(決定 2) | + +## 決定の記録 + +### 決定 1: 表の形と状態が「あり」の行を変えずに済むよう、作る課題を「載る機能」の列の末尾の括弧に書く + +状態が「あり」の 13 行のうち 10 行は、載る機能の末尾の括弧に出所(確定仕様の節)を置く。予定の行には確定仕様の節がまだ無く、その出所に当たるのが作る課題である。同じ位置に置けば、行の読み方が「あり」と「予定」で 1 通りになり、「あり」へ変えるときも括弧の中身を置き換えるだけで済む。 + +列を足す形は採らない。13 行すべての行が変わり、受け入れ条件 4(差分を予定の 3 行と冒頭の文に限る)を満たせない。状態の列に書く形も採らない。状態の列は「あり / 予定」の 2 値で、既存のテストがその先頭の文字を読む。 + +根拠: Value 2 / Value 8 / スプリント Value 2(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 2: 課題の一覧を開かずに判断できるよう、作る課題の番号に題を添える + +番号だけでは、画面を足す課題の担当が番号の指す課題を開いて確かめる手間が残る。題を添えれば、行を読むだけで自分の課題かが分かる(非機能の条件)。題は課題の題から、どの行にも共通の「NDF Factory: 」を除いて書く。 + +課題へのリンクの記法は使わない。Factory の確定仕様は課題を裸の `#<番号>` で指しており、書き方をそろえる。 + +根拠: Value 8 / スプリント Value 1(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 3: 状態が「あり」の行に作った課題が残らないよう、「あり」へ変えるときに作る課題の括弧を確定仕様の節へ置き換える + +状態が「あり」の行は作った課題を持たない(要求の前提 5)。作る課題の括弧を残したまま「あり」へ変えると、「あり」の行だけが別の形になる。確定仕様の節がまだ無い画面では括弧を消す(ループ・ループの詳細・バックログの行と同じ形になる)。冒頭の段落に置き換えの手順を書き、作る課題の変更が手順として読めるようにする。 + +根拠: Value 8(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・I1 | 画面の地図の予定の 3 行が、それぞれ #2173・#2174・#2189 を作る課題として示す(完了判定とリリース後テストで、行を読んで確かめる。文言を照合するテストは書かない) | 予定の行の 1 つから作る課題の括弧を消すと、完了判定で落ちる | +| 受け入れ条件 2(書く時点の条件) | `gh issue view <番号> --json state,title` が 3 件とも `OPEN` を返し、題がその画面を作る課題である(手動確認) | 閉じた課題の番号を書くと、手動確認で落ちる | +| 受け入れ条件 3・I2 | 冒頭の段落に、予定の行を作るときは状態を「あり」へ変え、経路を書き、作る課題の括弧を確定仕様の節へ置き換える(節が無ければ消す)手順がある(完了判定で読む) | 置き換えの手順を書かずに出すと、完了判定で落ちる | +| 受け入れ条件 4 | `git diff develop -- docs/specifications/ndf-factory-screen-map.md` の変更が 3 行目と 43〜45 行目だけで、表の行の数(あり 13 行・予定 3 行)が変わらない | 「あり」の行や見出しの行を書き換えると、差分の行で落ちる | +| 受け入れ条件 5・I3 | `factory/` で `npm test -w web` が通る(`docs.test.ts` の画面の地図の経路の集合の照合を含む) | 予定の行の状態の列を「あり」で始まる値へ変えると、テストが落ちる | +| 受け入れ条件 6 | `document-style` のセルフチェックの grep で、足した文に内部の識別子の裸の使用と、多義語の新しい当たりが無い | 足した文に `screens.ts` の識別子を裸で書くと、識別子の grep に当たる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2431 | 実装する | — | `docs/specifications/ndf-factory-screen-map.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| #2174 が請求と使用量の画面を作るか | #2174 の本文は画面をまだ受け入れ条件に書いていない(要求の前提 2)。画面を作るかは #2174 の要求で決まる。作らないと決まったときは、#2174 の変更で請求と使用量の行ごと消す(「行を書き換えてよい課題」の 2 行目。作る課題を持たない予定の行は I1 に反するため残さない) | +| 書いた後に作る課題が閉じたこと | 作る課題が「あり」へ変えないまま閉じても、どの検査も落とさない。open であることは書く時点でだけ確かめる。見つけたときは、その行を作ると申し出る課題が作る課題を書き換えるか、作る課題を割り当て直す課題を起こす | +| `npm test -w web` の実行 | 設計の時点では、この worktree に Factory の依存が入っておらず未実行。実装で依存を入れて実行する。テストの読み方(1 列目と 4 列目だけを読む)はコードを読んで確かめた | diff --git a/issues/issue-2431-requirements.md b/issues/issue-2431-requirements.md new file mode 100644 index 000000000..5eb147da5 --- /dev/null +++ b/issues/issue-2431-requirements.md @@ -0,0 +1,175 @@ +# #2431: NDF Factory: 画面の地図の「予定」の画面に、作る課題(持ち主)が書かれていない(#2382 の受け入れ条件 2 の残り) + +正は課題の本文(#2431)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何を見つけたか + +ndf v10.27.14(タグ `ndf--v10.27.14`、commit 84381ee8)のリリース後テストで、#2382 の受け入れ条件 2「まだ画面が無い機能は、載せる予定の画面と持ち主の課題が分かる」のうち、**持ち主の課題**が画面の地図に無い。 + +`docs/specifications/ndf-factory-screen-map.md` の「画面の地図」で状態が「予定」の 3 行(承認・請求と使用量・手元のマシン)は、載る機能だけを書き、どの課題がその画面を作るかを書いていない。 + +```text +| 未定 | 承認 | 個人・管理者・席 | 予定 | 承認の依頼・Web からの承認・通知 | +| 未定 | 請求と使用量 | 個人・管理者 | 予定 | 料金プランの料金・使用量の台帳・決済 | +| 未定 | 手元のマシン | 個人・管理者 | 予定 | 手元のマシンの登録・心拍 | +``` + +タグの時点(2026-11-02 21:50 JST に確かめた)と、確定仕様化の後の `develop` の両方で同じである。 + +## どこで見つけたか + +- `docs/specifications/ndf-factory-screen-map.md` の「画面の地図」43〜45 行 +- リリース後テストの記録: Pull Request #2426 のコメント + +## なぜこの変更の範囲外なのか + +リリース後テストは不合格を即時修正しない(`release-verification` の「合否を判定する」)。リリース後の修正は新しい変更として、モードの判定から始める。根拠: プロジェクト Value 7(判断の理由を残す・正本を 1 か所に)・スプリント Value 2(画面先行で地図を土台にする)(MVV 版 4)。 + +## 直さないと何が起きるか + +画面を足す課題が、地図の「予定」の行を自分の持ち物か判断できない。#2382 の非機能の条件(運用・保守性)「画面を足す課題は、地図の行と翻訳のキーを足すだけで済む」の前提になる、行と課題の対応が無いままになる。#2382 が閉じられると、この条件の残りを持つ課題が無くなる。 + +## 起票者の推定 + +- 承認の画面の持ち主は #2173(承認ゲートの通知と Web からの承認)である(推した出所: #2173 の題) +- 残りの 2 行(請求と使用量・手元のマシン)の持ち主は起票者には決められず、直す変更で課題を探して書く(推した出所: 起票者の判断) + +## 由来 + +PR #2426 + + +--- + +以下は要求と受け入れ条件である(2026-11-04、`requirements-design`)。上の節は起票の時点の本文で、書き換えていない。 + +## 依頼(原文) + +> NDF Factory: 画面の地図の「予定」の画面に、作る課題(持ち主)が書かれていない(#2382 の受け入れ条件 2 の残り) + +(書き手: 起票者、日付: 2026-11-02。課題の題。リリース後テストの記録 https://github.com/devbasex/ai-plugins/pull/2426 から起票した AI の文で、利用者の指示の原文ではない) + +> 2. 確定仕様 `docs/specifications/ndf-factory-*.md` の機能のうち利用者が操作するものが、地図のどの画面に載るか(まだ画面が無い機能は、載せる予定の画面と持ち主の課題)が分かる + +(書き手: 起票者、日付: 不明。#2382(UX の土台)の受け入れ条件 2。#2382 は承認ゲート 1 を通っており、この条件は利用者が承認で確かめた条件として扱う) + +> 画面を足す課題は、地図の行と翻訳のキーを足すだけで、部品・整形・API クライアントの仕組みに手を入れずに済む + +(書き手: 起票者、日付: 不明。#2382 の非機能の条件の「運用・保守性」の行) + +この課題の要求の出所は #2382 の受け入れ条件 2 の後半(「持ち主の課題」)だけである。利用者が会話で書いた文は無い。 + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 承認の画面を作る課題は #2173(承認ゲートの通知と Web からの承認)である | 起票の本文の「起票者の推定」。#2173 の題 | 前提 1・受け入れ条件 1 | +| S2 | 請求と使用量・手元のマシンの画面を作る課題は、直す変更で探して書く | 起票の本文の「起票者の推定」 | 前提 2・前提 3・受け入れ条件 1 | +| S3 | 地図の「予定」の行ごとに、作る課題の番号を書けば足りる(地図の表の形を変えずに済む) | この要求を書いた AI の推定。起票の本文は「どの課題がその画面を作るかを書いていない」とだけ書く | 前提 4・未決 1 | + +## 目的 + +- 画面を足す課題が、画面の地図の「予定」の行を見て、その行が自分の課題の持ち物か(自分が作る画面か)を 1 回の読みで判断できる状態にする +- #2382 の受け入れ条件 2 の残り(まだ画面が無い機能の、作る課題)を満たし、#2382 が閉じた後もこの条件を持つ文書が残る状態にする + +## 前提 + +- 前提 1: 承認の画面(承認の依頼・Web からの承認・通知)を作る課題は #2173 である。根拠: #2173 の受け入れ条件 5〜7 が「承認の画面」「判断の依頼の画面」を定め、受け入れ条件 4 が「承認の画面の URL」を Slack の通知へ入れる +- 前提 2: 請求と使用量の画面(料金プランの料金・使用量の台帳・決済)を作る課題は #2174(サブスクリプションと Bedrock の token 課金)である。根拠: #2174 の本文が「この課題の使用量の台帳」「決済の手段(Stripe など)」を持つ。#2174 の本文は画面をまだ受け入れ条件に書いていないため、画面を作るかどうかは #2174 の要求で決まる。この課題は地図に作る課題を書くだけで、#2174 の範囲を変えない +- 前提 3: 手元のマシンの画面(手元のマシンの登録・心拍)を作る課題は #2189(手元のマシン)である。根拠: #2189 の受け入れ条件 17 が「手元のマシンの一覧」の画面を定め、起票者の推定 S9 が「手元のマシンの一覧と登録の token の管理の画面を作る」とする +- 前提 4: 作る課題は、画面の地図の表の「予定」の行に、GitHub の課題の番号(`#<番号>`)で書く。列を足すか既存の列に書くかは設計で決める(未決 1) +- 前提 5: 状態が「あり」の行(すでに画面がある行)には、作った課題を書き足さない。#2382 の受け入れ条件 2 が課題を求めるのは「まだ画面が無い機能」だけである +- 前提 6: `factory/web/src/screens.ts` の予定の画面の定義(`approvals`・`billing`・`runners`)は、作る課題を持たない。画面の地図の文書が人の読む対応の正本で、`screens.ts` は経路の定義の正本である(文書の「概要」)。作る課題は経路を作らないため、`screens.ts` へ写さない + +## 対象範囲 + +含む: +- 画面の地図(`docs/specifications/ndf-factory-screen-map.md`)の「予定」の 3 行に、作る課題を書く +- 画面の地図の冒頭の、画面を足す課題が行う手順の文に、「予定」の行を自分の課題が作るときの扱い(行を「あり」へ変える)を書く + +含まない: +- 予定の画面そのものを作ること(#2173・#2174・#2189 が持つ) +- #2173・#2174・#2189 の本文と受け入れ条件を書き換えること +- `factory/web/src/screens.ts` と翻訳のファイルの変更(前提 6) +- 状態が「あり」の行へ、作った課題を書き足すこと(前提 5) +- 画面の地図のほかの節(ナビゲーション・動線・見本の画面)の変更 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 予定の画面に、作る課題を書いた | この課題の実装 | 書いた番号が閉じた課題か、その画面を作らない課題を指す → 受け入れ条件 2 が落とす | — | +| E2 | 画面を足す課題が、予定の行を自分の持ち物と判断した | その課題の設計か実装 | 行に自分の番号が無い → その課題の要求で、地図の行の作る課題を書き換える(この課題の範囲外) | E1 | +| E3 | 予定の画面を作り、行を「あり」へ変えた | 作る課題の実装 | 経路の集合が `screens.ts` と食い違う → 既存のテスト(`factory/web/src/docs.test.ts`)が落とす | E2 | + +E2 の失敗の経路(作る課題が後から変わる)は、この課題では作る課題を書き換える仕組みを作らず、画面を足す課題が自分の要求で直すものとして扱う(含まない)。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 画面の地図 | Factory の Web の画面の全体を 1 つの表にまとめた確定仕様(`docs/specifications/ndf-factory-screen-map.md`)。用語集の文脈 `ndf-factory` の語 | +| 予定の画面 | 画面の地図の状態が「予定」の行。経路がまだ無く、載る機能だけが決まっている画面 | +| 作る課題 | 予定の画面を作り、その行を「あり」へ変える GitHub の課題。起票の本文と #2382 は「持ち主の課題」と書く | + +「持ち主」は用語集で状態の持ち主・見直しの持ち主など別の意味に使われているため、この要求では「作る課題」と書く。 + +## 受け入れ条件 + +- [ ] 1. 画面の地図の状態が「予定」の 3 行が、それぞれ作る課題を `#<番号>` で示す。承認は #2173、請求と使用量は #2174、手元のマシンは #2189 である +- [ ] 2. 受け入れ条件 1 で書いた課題は、どれも GitHub で open である(`gh issue view <番号> --json state` が `OPEN` を返す) +- [ ] 3. 画面の地図の冒頭の、画面を足す課題が行う手順の文に、作る課題として書かれた「予定」の行を作るときは、その行の状態を「あり」へ変え経路を書くことが書かれている +- [ ] 4. 状態が「あり」の行の内容と行の数(13 行)は変わらない(変更の差分が「予定」の 3 行と冒頭の手順の文だけである) +- [ ] 5. 既存のテスト(`factory/web/src/docs.test.ts` の、地図の「あり」の行の経路の集合と `screens.ts` の一致)が通る +- [ ] 6. 文書の書式の検査(`document-style` のセルフチェック)で、追記した行に内部の識別子の裸の使用と多義語の新たな当たりが無い + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 画面を足す課題は、地図の「予定」の行を読むだけで、その行を自分が作るかを判断できる(地図のほかの文書や課題の一覧を探しに行かない) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない(文書だけの変更。配布物の `plugins/` と Factory の API・画面の振る舞いは変わらない) | +| データ | 変わらない | +| 既存の振る舞い | 変わらない。画面の地図の文書の「予定」の 3 行と冒頭の手順の文だけが変わる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `factory/` で `npm test -w web`(`docs.test.ts` を含む)。コマンドは実装の前に打って確かめる | +| 静的解析・型検査 | 無し(コードを変えない) | +| 手動確認 | 受け入れ条件 2: 書いた番号ごとに `gh issue view <番号> --json state,title` を打ち、`OPEN` とその画面を作る題であることを確かめる。リリース後テストで、`develop` の画面の地図を読んで受け入れ条件 1・3 を確かめる | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 確定仕様は `docs/specifications/` に置く。`factory/` は変えない。`plugins/` と `marketplace.json` は `factory/` を参照しない(`AGENTS.md` の「マーケットプレイスの構造」) | +| コーディング規約 | 文書は `document-style` に従う。`.md` の文言を照合するテストを書かない(`AGENTS.md` の DON'T) | +| テスト戦略 | 既存の `docs.test.ts` が経路の集合を見る。作る課題の番号を照合するテストは足さない(文言の照合に当たるため)。番号の正しさは受け入れ条件 2 の手動確認で見る | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 画面の地図の「予定」の行と冒頭の手順の文の変更、既存テストの実行 | +| 確認してから行う | 画面の地図の表に列を足すこと(表の形が変わるため。承認ゲート 1 で人が見る) | +| 行わない | #2173・#2174・#2189 の本文の書き換え、`screens.ts` と翻訳のファイルの変更、「あり」の行の変更 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 作る課題を、表に列(例: 「作る課題」)を足して書くか、「載る機能」の列の末尾に書くか | 設計(`design` の工程)。列を足すときは承認ゲート 1 で人が確かめる | 設計 PR を出すまで | + +## 根拠にした MVV の項目 + +- プロジェクト Value 8(判断の理由を残し、語をそろえる。正本を 1 か所に置き、番号には題か中身を添える): 作る課題を画面の地図の 1 か所に書き、`screens.ts` へ写さない(前提 6)。「持ち主」を多義を避けて「作る課題」と書く +- スプリント Value 2(画面先行。画面の地図を UX の土台にする): 予定の画面と作る課題の対応を地図に持たせる +- プロジェクト Value 2(人が判断できる量に収める): 変更を「予定」の 3 行と冒頭の 1 文に限る +- 共通原則の C1〜C8・プロジェクトの P1 に当たる操作は無い(文書だけの変更) diff --git a/issues/issue-2439-design-data.md b/issues/issue-2439-design-data.md deleted file mode 100644 index edb9e15ac..000000000 --- a/issues/issue-2439-design-data.md +++ /dev/null @@ -1,199 +0,0 @@ -# Factory のオーガナイザーの設計: データ構造と入出力の契約(#2439) - -設計文書 [issue-2439-design.md](issue-2439-design.md) から分けたファイルである。DynamoDB の行と、API・進捗の事象・ランナーの指示の契約を扱う。不変条件の番号(I56〜I68)は設計文書のものを指す。 - -## データ構造 - -### 保存する形 - -オーガナイザーの進行の行は、今の実行の表(`pk=ACCOUNT#`)に置く。まとめた配布と配布の実行を 1 回のトランザクションで書く(I57)ために、同じ表に置く。 - -```mermaid -erDiagram - ORG ||--o{ ORGREQ : "受けた" - ORG ||--o{ ORGBATCH : "組んだ" - ORG ||--o{ ORGLOG : "積む" - ORGBATCH ||--|| SPRINT_RUN : "配布の実行" - ORGREQ }o--|| SPRINT_RUN : "依頼した実行" - ORGREQ }o--o| ORGBATCH : "答えた配布" - ORG { - string sk PK "ORG#repositoryId" - number version - number currentSeq - number nextSeq - string waitReason - } - ORGREQ { - string sk PK "ORGREQ#repositoryId#requestId" - string sprintRunId - string lane - object answer - } - ORGBATCH { - string sk PK "ORGBATCH#repositoryId#seq6桁" - string state - string releaseRunId - list presentations - } -``` - -### テーブル定義 - -**根(`sk=ORG#`)**: 1 リポジトリに 1 行。最初の依頼を受けた `tick` が作る。 - -| 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `repositoryId` | 文字列 | 許さない | リポジトリの登録の識別子 | -| `version` | 数値 | 許さない | 楽観的な排他の版(I67)。書くたびに 1 増やす | -| `currentSeq` | 数値 | 許す | 流れているまとめた配布の連番。空は「流れていない」(I56) | -| `nextSeq` | 数値 | 許さない | 次に組むまとめた配布の連番(1 から) | -| `waitReason` | 文字列 | 許す | `external_release_open` / `legacy_run`。空は「待っていない」 | -| `waitDetail` | 文字列 | 許す | 待ちの相手(外の Pull Request の URL・古い版の数)。`waitReason` が空なら空 | -| `gsi1pk`・`gsi1sk` | 文字列 | 許す | 実行の表の既存の GSI `gsi1` の鍵 `ORGANIZER_ACTIVE` / 行の `sk`。`currentSeq` があるときだけ持つ(見回りが流れている根を引く) | - -**受けた依頼(`sk=ORGREQ##`)**: 依頼 1 件に 1 行(I59)。依頼の中身は書き換えない。 - -| 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `requestId` | 文字列 | 許さない | NDF が作る依頼の id(#2436 の `<レーン名>-r<乱数 12 桁>`) | -| `sprintRunId` | 文字列 | 許さない | 依頼を出した実行 | -| `loopId` | 文字列 | 許す | 依頼を出した実行のループ。空は手動の実行 | -| `lane` | 文字列 | 許さない | `NDF_ORGANIZER_LANE` の値(`loop-` / `run-`) | -| `prs`・`issues` | 数値の配列 | 許さない(空の配列は可) | 依頼の Pull Request と課題 | -| `ndfVersion` | 文字列 | 許さない | 依頼した実行の NDF の版 | -| `requestedAt` | 文字列 | 許さない | 依頼のファイルの時刻 | -| `answer` | オブジェクト | 許す | `{seq, result, version, prodPrUrl, reason, at}`。空は「未回答」。1 回だけ書く(I58) | -| `deliveredAt` | 文字列 | 許す | 答えを依頼元へ届け終えた時刻。依頼元の実行の進捗の記録へ `release.answered` を追記し、手動の実行なら `requestResume` が通った(か、実行がもうこの依頼を待っていない)後に書く。「この依頼を待っている」は、実行が `awaiting_approval` で `awaitingReason` が `release_requested`、`releaseRequestId` がこの依頼の id と一致することを指し、それ以外(取り消し・終わり・別の理由や別の依頼での `awaiting_approval`)は待っていないとする。空は「届け終えていない」 | -| `gsi1pk`・`gsi1sk` | 文字列 | 許す | 実行の表の既存の GSI `gsi1` の鍵 `ORGANIZER_ACTIVE` / 行の `sk`。`deliveredAt` が空の間だけ持つ(見回りが未回答の依頼と、答えを届け終えていない依頼を引く)。`deliveredAt` を書く書き込みで外す | - -**まとめた配布(`sk=ORGBATCH##`)** - -| 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | -| `seq` | 数値 | 許さない | 連番 | -| `state` | 文字列 | 許さない | `composed` / `presented` / `represented` / `confirmed` / `stalled` / `released` / `rejected` | -| `releaseRunId` | 文字列 | 許さない | 配布の実行(I57) | -| `lanes` | 配列 | 許さない | 組んだ時点のレーンの一覧 `{lane, sprintRunId, loopId?, requestIds, issues}`。依頼の無いレーンは `requestIds` が空 | -| `presentations` | 配列 | 許さない(空の配列は可) | 提示 `{k, sha, version, prs, lanes:[{lane, requested}], addedPrs, at}`。2 つまで(I60)。上書きせず足す | -| `confirmedK` | 数値 | 許す | 確定した提示の番号。空は「確定していない」 | -| `mainMerged` | 真偽 | 許さない | `main` へのマージを記録したか(I63)。`release.progress` の `main_merged` か、GitHub で head が `release/v<提示の版>` の `main` 宛の Pull Request がマージ済みなら真。既定は偽 | -| `result` | オブジェクト | 許す | `{version, prodPrUrl, sha, prs}`。空は「配布済みでない」 | -| `stallReason` | 文字列 | 許す | `stalled` の理由(ジョブの失敗の理由)。`stalled` 以外は空 | -| `stalledFrom` | 文字列 | 許す | 止まる前の状態(`composed` / `represented` / `confirmed`)。「続きから流す」でこの状態へ戻し、再開の手(ジョブの `releaseStep`)をこの値で決める。`stalled` 以外は空 | - -**記録(`sk=ORGLOG###<種類>#<乱数 8 桁>`)**: 追記だけで書き換えない。末尾の乱数は、同じ時刻に同じ種類の記録を 2 件書いても上書きしないために付け、書き込みは `attribute_not_exists(sk)` を条件にする。並びは時刻で決まる。種類は `waited` / `composed` / `presented` / `represented` / `confirmed` / `stalled` / `resolved` / `released` / `rejected`。中身は種類ごとの値(待ちの相手・提示のコミット・確定の番号・結果)。 - -**スプリントの実行・ジョブ・リポジトリの枠(変更)**: 列を足すだけで既存の列は変えない。 - -| 行 | 列 | 型 | 空を許すか | 意味 | -| --- | --- | --- | --- | --- | -| リポジトリの枠 | `legacySeq` | 数値 | 許す | 古い版の実行を `legacyCount` へ数えに入れた回数。`legacyCount` を増やす書き込み(開始・移行の埋め戻し)で同じトランザクションで 1 増やし、減らす書き込みでは変えない。まとめた配布を組む書き込みの条件に使う(I62)。空は「まだ数えに入れていない」 | -| 実行 | `purpose` | 文字列 | 許す | `release`。空は今のスプリントの実行(`sprint`) | -| 実行 | `releaseSeq` | 数値 | 許す | 配布の実行のまとめた配布。`purpose` が空なら空 | -| 実行 | `awaitingReason` | 文字列 | 許す | `awaiting_approval` の理由 `release_requested` / `release_stalled`。空は今の承認の依頼の待ち | -| 実行 | `releaseRequestId` | 文字列 | 許す | 手動の実行が待つ依頼の id。`awaitingReason` が `release_requested` のときだけ | -| ジョブ | `start` | 文字列 | 許さない | 今の値に `release` を足す | -| ジョブ | `releaseStep` | 文字列 | 許す | `present` / `represent` / `confirm:1` / `confirm:2` / `continue`。配布の実行のジョブだけ。「続きから流す」では `stalledFrom` が `composed` なら `present`、`represented` なら `represent`、`confirmed` なら `release.progress` があるか、`mainMerged` が真か、GitHub に `release/v<提示の版>` の枝か本番の Pull Request があれば `continue`・どれも無ければ `confirm:`。判断の答えの再開は `continue` | - -### 時系列の扱い - -**事象の記録と、追記だけの提示の列を採る。** まとめた配布の `state` は上書きするが、遷移は記録の行に積み、提示は配列に足すだけにする。承認する人が何のコミットを見て答えたかを、出し直しの後も辿れる必要があるためである(非機能の「運用・保守性」)。根の `waitReason` は上書きで、待ちの履歴は記録の行が持つ。 - -### 機能 × データ - -| 機能 | 根 | 受けた依頼 | まとめた配布 | 記録 | 実行・ジョブ | -| --- | --- | --- | --- | --- | --- | -| F1 依頼を出す | — | C(`finalize`) | — | — | U | -| F2 組む | C・U | R | C | C | C(配布の実行) | -| F3 提示する | — | — | U | C | U | -| F4 答えと出し直し | R | — | U | C | U(再開) | -| F5 結果を返す | U | U(答え) | U | C | U(手動の実行の再開) | -| F6 一覧 | R | R | R | R | R | -| F7 止まった配布を解く | U | U(差し戻しの答え) | U | C | U | -| F8 見回り | R | R・U(届け終わり) | R | — | R・U(手動の実行の再開) | - -### 移行 - -既存のデータの移行は無い。根は最初の依頼で作り、実行とジョブの足した列は空を今の意味として読む。 - -### 検査の手段 - -`cd factory && npm ci && npm run check`(型検査と vitest。`MemoryRunsTable` に同じ行と条件つきの書き込みを足し、配布のまとめの規則はメモリの表で確かめる)。 - -## 入出力の契約 - -### 画面向けの経路(`factory/contracts/openapi/organizer.yaml`。新規) - -| 経路 | 入力 | 成功 | 失敗の形 | -| --- | --- | --- | --- | -| `GET /accounts/{accountId}/repositories/{repositoryId}/approval-requests` | — | 200 `{organizer: {currentSeq, waitReason, waitDetail}, items: [...]}`。`items` の 1 件は `{kind, sprintRunId?, loopId?, approvalRequestId?, prUrl?, createdAt, release?}`。`kind` は `gate1` / `gate2` / `decision` / `loop_pending` / `release_stalled`。`release` は配布の実行のゲート 2 と止まった配布だけが持ち `{seq, k, sha, lanes:[{lane, requested, prs}], addedPrs, choices}` | 401・403(アカウントのリポジトリでない)・404 | -| `GET /accounts/{accountId}/repositories/{repositoryId}/releases` | `?limit`(既定 20) | 200 `{items: [{seq, state, releaseRunId, presentations, confirmedK, result, log}]}`(新しい順) | 401・403・404 | -| `POST /accounts/{accountId}/repositories/{repositoryId}/releases/{seq}/resolve` | `{choice: "continue" \| "reject", body?}`。Web のセッションの認証だけ | 202 `{seq, state}` | 401・403(セッションでない呼び出し。I66)・404・409 `not_stalled`・409 `release_after_main`(I63) | - -- 一覧の並びは作った時刻の古い順である。承認の依頼の中身(承認資料・答え)は #2173 の経路が持ち、一覧は識別子と要約だけを返す -- 一覧の承認の依頼は「同じリポジトリの `awaiting_approval` の実行」から #2173 の読み出し(未確認のまま残ること)で引く。ループの人の判断待ちはループの行から引く -- 一覧の経路と履歴の経路は読むだけで、答えを確定する経路はこのファイルに無い(I66)。MCP は同じ GET を呼ぶ - -### ランナー向けの経路(`factory/contracts/openapi/runs.yaml`。足す) - -| 経路 | 入力 | 成功 | 失敗の形 | -| --- | --- | --- | --- | -| `GET /run-jobs/organizer/answers` | 実行ジョブの token | 200 `{answers: [{request_id, result, version, release, reason, at}], ranges: [{seq, sha, version, prs, lanes}]}`。そのジョブの実行のレーン(ループならループのすべての実行)の依頼の答えと、リポジトリの配布済みの範囲 | 401・403 | -| `GET /run-jobs/organizer/batch` | 実行ジョブの token(配布の実行のジョブだけ) | 200 `{seq, step, lanes, presentations, choices}` | 401・403(配布の実行でない)・404 | - -答えの形は #2436 の `parallel-request.json` の答えと範囲にそろえる。 - -| 形 | #2436 のファイル | 項目 | -| --- | --- | --- | -| 答え | `answer-<依頼>` | `{result, version, release, reason, at}` | -| 範囲 | `range-<連番>` | `{sha, version, prs, lanes}` | - -答えの項目は、受けた依頼の行の `answer` から次のように写す。#2436 の答えの `release` は、この設計では本番の Pull Request の URL を入れる。 - -| 受けた依頼の行(`answer`) | ランナーへ返す答え・置き場の `answers/` | -| --- | --- | -| `result`・`version`・`reason`・`at` | 同じ名前 | -| `prodPrUrl` | `release` | -| `seq` | 返さない(範囲の `ranges` が連番を持つ) | -| 行の `requestId` | `request_id`(経路の応答だけ。ファイルでは名前が依頼の id) | - -ランナーはこれを置き場のファイルへそのまま書く([issue-2439-design-ndf.md](issue-2439-design-ndf.md))。 - -### 進捗の事象(`factory/contracts/schemas/progress-event.schema.json`。種類を足す) - -| 種類 | 出所 | payload | 冪等の鍵 | -| --- | --- | --- | --- | -| `release.requested` | `runner` | `{request_id, lane, prs, issues, ndf_version, requested_at}` | `` | -| `release.presented` | `runner` | `{seq, k, sha, version, prs, lanes:[{lane, requested}], added_prs, auto}`。`auto` は MVV 判定が通したとき真 | `:presented:` | -| `release.progress` | `runner` | `{seq, stage}`。`stage` は `release_branch` / `develop_merged` / `prod_pr_opened` / `main_merged` / `tagged` | `:progress:` | -| `release.settled` | `runner` | `{seq, result, version?, prod_pr_url?, sha?, prs, lanes, reason?}`。`result` は `released` / `failed` | `:settled` | -| `release.answered` | `control` | `{seq, request_id?, result, version?, prod_pr_url?}`。範囲の実行の進捗の記録へ書く(AC 1) | `:` | - -`gate.reached` の payload に任意の `choices`(文字列の配列)を足す。配布の実行が `main` のマージの後に判断で止まったときは `["continue"]` だけを載せる(I63)。既存の項目と既存の種類は変えない。 - -### ジョブの指示(`factory/contracts/schemas/run-job-instruction.schema.json`。足す) - -| 項目 | 型 | 意味 | -| --- | --- | --- | -| `START` | 文字列 | 今の値に `release` を足す | -| `RELEASE_SEQ` | 数値 | 配布の実行のまとめた配布の連番 | -| `RELEASE_STEP` | 文字列 | `present` / `represent` / `confirm:1` / `confirm:2` / `continue` | -| `RELEASE_REQUEST_ID` | 文字列 | 手動の実行の再開で、答えの出た依頼の id | - -レーンの中身(レーンの一覧と依頼)は指示に載せず、ランナーが `GET /run-jobs/organizer/batch` で読む。RunTask の `overrides` の上限(8192 字)を避けるためである。 - -### 実行の制御の関数(変更) - -| 関数 | 変えること | -| --- | --- | -| `requestResume({sprintRunId, approvalRequestId?, releaseRequestId?})` | 配布の実行なら `organizer.decide` が `RELEASE_STEP` を決めてから今の書き込みで再開する。`releaseRequestId` は手動の実行の配布の答えの再開に使い、承認の依頼の答えを読まない。`releaseRequestId` を渡したときは、再開のジョブを作る条件つきの書き込み(`addResumeJob`)の条件に「状態が `awaiting_approval`・`awaitingReason` が `release_requested`・実行の `releaseRequestId` が渡した値と一致する」を足す。条件に当たらなければ再開せず、実行がこの依頼を待っていないことを返す(見回りは `deliveredAt` を書く)。別の承認ゲートの待ちを、その承認の依頼の答え無しに再開させないためである | -| `finalize` | 上の「止まったときと再開」と I65 の表のとおり。`release.requested` を受けた依頼の行に、`release.settled` を `settle` へ渡す | -| `sweep` | 最後に `organizer.sweep()` を呼ぶ | - -### 互換性 - -- 足すのは経路・事象の種類・payload の任意の項目・指示の項目・行の列だけで、既存の項目の意味は変えない -- 依頼を出さない NDF の版の実行は、今どおり自分で本番の配布を流す。Factory はその実行を、並行を許す版の下限(設定 `NDF_PARALLEL_MIN_VERSION`)より古い版の実行(`legacy` が真)として見分ける。下限が切り替え口(`parallel.py request --external`)の入った版より低いと、下限以上で依頼を出さない版の実行が I62 の待ちをすり抜けて自分で配布し、まとめた配布とぶつかる。そのため実装の配布の手順で、Factory の本番へこの課題を出す前に、下限を切り替え口の入った NDF の版以上へ上げる。古い版かどうか(`legacy`)は実行の開始で決まり、下限を変えても既に始まった実行の値は変わらない。古い下限以上として始まった切り替え口の無い版の実行は `legacy` が偽のまま自分で配布するため、下限を上げた後、上げる前に始まって終わりの状態でない実行が無くなるまで待ってから、この課題を本番へ出す -- 古い版の実行はリポジトリで唯一になり([ndf-factory-dev-loops.md](../docs/specifications/ndf-factory-dev-loops.md) の I48)、依頼を出していない古い版の実行がある間は配布のまとめは組まない(I62 の `legacy_run`)。まとめた配布が流れている間は、古い版の実行を始めない(I62)。下限が無い間はすべての版が古い版として数えられるが、依頼を出して待つ実行は I62 で数えないため、まとめた配布は組まれる -- `requestResume` を呼ぶ側(#2173)の引数は変わらない。足した `releaseRequestId` は配布のまとめだけが渡す -- 検査は 2 つである。`npm run check` の契約の lint(`contracts`)と、構造チェック(`scripts/contracts_lib/model.py`)が、ランナーの翻訳と NDF の契約の例の組を照らす diff --git a/issues/issue-2439-design-decisions.md b/issues/issue-2439-design-decisions.md deleted file mode 100644 index 3c240c62f..000000000 --- a/issues/issue-2439-design-decisions.md +++ /dev/null @@ -1,91 +0,0 @@ -# Factory のオーガナイザーの設計: 決定の記録(#2439) - -設計文書 [issue-2439-design.md](issue-2439-design.md) の決定を分けたファイル。要求の「起票者の推定」(S1〜S11)と「未決」(1〜4)への答えを含む。 - -## 決定の記録 - -### 決定 1: 実行基盤の違う 2 つの実装を互いに独立して変えられるよう、Factory のオーガナイザーを `factory/` に置き、#2436 の契約だけに依る - -1 台の中のオーガナイザーは SQLite(DBOS)の耐久ワークフローで、実行ジョブの間をまたげない。Factory の実行はジョブごとに別のコンテナと clone を持ち、共有できるのは DynamoDB と GitHub だけである。そのため同じ層の別の実装として `factory/` に置く。共有するのは、依頼と答えの形(#2436 の `parallel-request.json`)と配布の規則(#2436 の決定 13〜15)だけである。2 つは排他で、Factory の実行ジョブの中では 1 台の中のオーガナイザーを起動しない(I68。#2436 が `NDF_FACTORY_RUN` で断る)。 - -Factory のオーガナイザーの仕事は、1 台の中のオーガナイザーの仕事すべてに Factory 固有の仕事(ランナーの制御・枠・状態の置き場)を足したものである。固有の仕事とレーンの起動・停止は、既存の実行の制御とループの制御がすでに持つ。そのため作り直さずに役割の一部として数える。足すのは、足りない「配布を 1 本にまとめる」「問いを集める」「共有の作業を渡す」だけで、配布のまとめと配布の実行として足す。 - -#2436 の設計へ組み込む案は採らない。`plugins/` から `factory/` を参照できない(`scripts/check-service-boundary.py`)。#2436 の設計はすでに 6 ファイル 1,445 行ある。 - -根拠: Value 5 / Value 6(MVV 版 4) - -### 決定 2: 新しい常駐とインフラを足さずに済ませるため、オーガナイザーの進行を実行の表の DynamoDB の行で持ち、API の handler と見回りが進める - -要求の未決 3 への答えである。配布のまとめの 1 手は「根を読み、条件つきで書き、必要なら実行を起こす」で、待ちは人の答えか次の事象である。今の実行の制御(`startNextLap`・`finalize`)が同じ形で動いている。同じ表に置けば、まとめた配布と配布の実行を 1 回のトランザクションで書ける(I57)。見回りが引く索引も、実行の表の既存の GSI `gsi1` に鍵 `ORGANIZER_ACTIVE` の行を載せて使い、インフラの定義を変えない。費用は行の読み書きに比例し、依頼の無いリポジトリには掛からない。 - -| 案 | 採否 | 理由 | -| --- | --- | --- | -| DynamoDB の行(実行の表)と既存の handler・見回り | **採る** | 部品が既にあり、実行の行と同じトランザクションに入る。常駐が無い | -| AWS Step Functions の Standard ワークフロー(人の答えをタスクの token で待つ) | 採らない | ステートマシン・IAM・起動の経路を private リポジトリに足す。実行の行と同じトランザクションに入らず、ワークフローと表の二重の記録の整合が要る | -| 配布のまとめ用の別の表 | 採らない | 配布の実行との原子的な作成ができない | - -根拠: Value 4 / Value 10 / Mission(MVV 版 4) - -### 決定 3: 配布の実行を 2 本作らないため、配布の実行をまとめた配布と同じトランザクションで作り、範囲のどの実行でもない専用の実行にする - -要求の S5 への答えである。範囲の実行のどれかに配布を流させると、その実行のループの周回と配布が同じジョブに混ざる。ループの予算と打ち切りの時間が配布に掛かる。配布の実行を専用にすれば、範囲の実行は答えを待たずに次の周回へ進める(AC 7)。作成をまとめた配布と別のトランザクションにすると、間で止まったときに見回りが 2 本目を起こす。依頼が 1 件でも同じ経路を通す(S8)。1 本のときだけ今の経路に残すと、1 本から 2 本へ増えた瞬間の扱いが要るためである。 - -根拠: Value 6 / Value 2(MVV 版 4) - -### 決定 4: 承認済みの開発の配布を枠で止めないため、配布の実行は実行の枠を持たず使える量の判定にかけず、費用は登録したアカウントの利用量へ数える - -要求の未決 1 への答えで、S11 を採る。配布の実行は範囲の実行がすでに入った枠の続きの仕事である。枠を持たせると、ループが枠を埋めたリポジトリで配布が組めず、`develop` の変更が `main` へ届かない。同時の数はリポジトリに 1 本まで(I56・I57)で、枠が無くても増えない。トークンと費用は今の `usage.recorded` で配布の実行の識別子に付く。アカウントの利用量と `managed` の月のトークンに数える。範囲の実行の数で割る案は、割り方と表示の規則が増えるため採らない。 - -承認ゲート 1 でこの数え方を利用者が確かめる(要求の未決 1 の「誰が決めるか」)。 - -根拠: Value 2 / Value 10(MVV 版 4) - -### 決定 5: NDF を Factory に依らずに外のオーガナイザーへつなぐため、切り替え口をこの課題で作り、置き場のファイルの契約にする - -要求の未決 2 への答えで、S7 を採る。#2436 は 1 台の中に閉じ、依頼をメッセージで送る。Factory のランナーはメッセージを受けられず、NDF の書くファイルを読んで翻訳する形(腐敗防止層)がすでにある。そのため、環境変数 `NDF_ORGANIZER_DIR` があるときだけ、依頼と答えを置き場のファイルで受け渡す。切り替え口の唯一の使い手は Factory なので、この課題で作る。#2436 の契約がマージされてから実装する(前提 1)。 - -環境変数の名前を `NDF_FACTORY_RUN` 1 つにまとめる案は採らない。`plugins/` の切り替え口に Factory を名指しせず、手元の別のまとめ役からも同じ切り替え口を使えるようにするためである。 - -根拠: Value 5(MVV 版 4) - -### 決定 6: Factory の今の承認の流れを保つため、置き場で依頼にするのは本番の配布だけにし、承認ゲート 1 と問いは一覧への並べ方だけをまとめる - -要求の S9 を採る。Factory の実行は承認の依頼でジョブを終えて費用を止め、答えで再開する。承認ゲート 1 と問いを依頼にしてレーンを進め続けると(#2436 の I17)、待つ間もジョブが動く。今の形と違う費用の掛かり方になる。設計 Pull Request は互いに独立で、まとめて 1 つの承認にできる単位も無い。そのため 3 種類の承認の依頼とループの人の判断待ちを、リポジトリごとの 1 つの一覧に並べるだけにする(AC 9)。 - -根拠: Value 2(MVV 版 4) - -### 決定 7: 承認したコミット以外を出さないため、答えの時点の `develop` の比べを API の側で GitHub の口から 1 回だけ行う - -要求の S4 を採る(#2436 の決定 14 と同じ規則)。比べる時点は答えが保存された直後で、配布の実行のジョブはまだ起きていない。API で比べれば、出し直し(`represent`)か確定(`confirm:1`)かを再開の指示に載せられる。ジョブを 1 回起こして比べる費用が要らない。出し直しへの答えでは比べないため、出し直しは連鎖しない(I60)。 - -根拠: Value 4 / Value 10(MVV 版 4) - -### 決定 8: 2 つのオーガナイザーが互いの記録を読めなくても衝突させないため、GitHub の上の開いている本番の Pull Request と古い版の実行で待つ - -要求の S6 を採る。両方が読めるのは GitHub の状態だけである。待つ相手は `main` 宛の Pull Request で、head が `release/v` で始まるものと、head が `develop` のものの両方である。#2436 の配布の規則より前の NDF の版は head が `develop` の Pull Request を作るためである。同じ理由で、リポジトリに依頼を出していない古い版の実行がある間も組まず、まとめた配布が流れている間は古い版の実行を始めない(I62)。その実行は自分で本番を配布する。依頼を出して待つ実行は自分で配布しないため、古い版として数えない。禁止の案は、利用者の手元の作業を Factory が止めるため採らない。 - -根拠: Value 6 / C4(MVV 版 4) - -### 決定 9: ループの周回と手動の実行の今の待ち方を変えないため、依頼の後のループの実行は `done`、手動の実行は理由つきの `awaiting_approval` にする - -ループは周回を配布待ちで締めると次の周回を起こせる(#2436 のレーンのループ)。手動の実行は今もゲート 2 の待ちで枠を持ったまま `awaiting_approval` になっている。同じ状態に理由(`release_requested`)を足せば、状態の遷移を増やさずに済む。答えは `requestResume` で届け、NDF は残りの工程を流す。新しい状態 `awaiting_release` を足す案は、状態の遷移の表・一覧の絞り込み・ループの写し方の 3 つを変えるため採らない。 - -根拠: Value 2 / Value 6(MVV 版 4) - -### 決定 10: 承認の依頼の持ち主を 1 つに保つため、一覧は既存の承認の依頼と人の判断待ちを読んで並べ、新しい保存を作らない - -承認の依頼の保存と答えの経路は #2173 が持つ(前提 3)。配布のまとめがコピーを持つと、答えたかどうかが 2 か所に分かれる。一覧は、同じリポジトリの `awaiting_approval` の実行から #2173 の読み出しで承認の依頼を引き、ループの行から人の判断待ちを引いて並べる。答えは今の経路(#2173 の答え → `requestResume`、ループの答えの経路)で持ち主へ届く。 - -根拠: Value 7 / Value 6(MVV 版 4) - -### 決定 11: `main` へのマージの後の配布を差し戻さないため、配布の実行は失敗しても終わりの状態にせず、止まった配布を「続きから流す」か「差し戻す」で解く - -配布の実行を `failed` で終えると、続きを流すには別の実行を作るしかない。1 つのまとめた配布に 1 本の守り(I57)が崩れる。理由 `release_stalled` の `awaiting_approval` で止めれば、今の再開の書き込みで続けられる。`main` のマージの後は選択肢を「続きから流す」だけにし(AC 13)、マージを `release.progress` と GitHub の両方から照らす。ジョブが段を書く前に落ちても、GitHub でマージ済みと分かるためである。解く操作は承認ゲートではなく失敗の扱いで、承認の工程を足さない。 - -根拠: Value 1 / C4(MVV 版 4) - -### 決定 12: 語の衝突を読み手の側で解くため、「手元のマシン」と「料金プラン」へ改め、識別子は変えない - -要求の未決 4 への答えで、S10 を採る。マシン単位の受け手は、利用者が自分の環境に登録するマシンそのものとして読めればよく、要求と #2189 がすでに「手元のマシン」と書いている。「手元の受け手」は業界で流通していない語である。「エージェント」「ホスト」はこのリポジトリの別の意味(AI のエージェント、Claude Code などのホスト)と重なる。アカウントの契約の種類は、料金の契約であることを語に含めて `ndf-workflow` の「プラン」(`plan.json`)と分ける。識別子(`runnerId`・`plan`)を変えると、API の互換と保存済みのデータの移行が要る。衝突しているのは説明文の語だけである。 - -根拠: Value 8(MVV 版 4) diff --git a/issues/issue-2439-design-ndf.md b/issues/issue-2439-design-ndf.md deleted file mode 100644 index d2637a3b3..000000000 --- a/issues/issue-2439-design-ndf.md +++ /dev/null @@ -1,115 +0,0 @@ -# Factory のオーガナイザーの設計: NDF の側の切り替え口と語の改名(#2439) - -設計文書 [issue-2439-design.md](issue-2439-design.md) から分けたファイルである。`plugins/ndf/` に置く切り替え口と、語の改名(F9)を扱う。どちらも Factory を名指ししない(`plugins/` は `factory/` を参照しない。`scripts/check-service-boundary.py`)。 - -## 切り替え口: 外のオーガナイザーとファイルでやり取りする - -### 具体例 - -Factory のループ `l20261102` の 3 周目と 4 周目の実行ジョブで、次の順に進む。 - -1. ランナーは `NDF_ORGANIZER_DIR=<状態の置き場>/organizer`・`NDF_ORGANIZER_LANE=loop-l20261102` を渡す -2. NDF は本番のステージを組む時点で、外のオーガナイザーのレーンと判定する。`requests/loop-l20261102-r3f9a1c2e4b7d.json` を書いて周回を配布待ちで締める -3. ランナーはそれを `release.requested` へ写す -4. 4 周目のジョブの開始で、ランナーは `answers/loop-l20261102-r3f9a1c2e4b7d.json`(`released`・`10.28.0`)を書く -5. NDF は周回の境でそれを読んで残りの工程を流す - -### 置き場の契約(`plugins/ndf/contracts/forms/parallel-external.json`。新規) - -```text -/ -├── requests/<依頼の id>.json # NDF が書く(レーン) -├── answers/<依頼の id>.json # 外のオーガナイザーが書く -├── ranges/<連番>.json # 外のオーガナイザーが書く(配布済みの範囲) -├── batch.json # 外のオーガナイザーが書く(配布の実行だけ) -├── presented/<連番>-<提示>.json # NDF が書く(配布の実行) -├── progress/<連番>.json # NDF が書く(配布の実行) -└── results/<連番>.json # NDF が書く(配布の実行) -``` - -| ファイル | 形 | -| --- | --- | -| `requests/` | #2436 の `parallel-request.json` の依頼(`kind: release`)。`{kind, id, lane, prs, issues, sprint_state, at}` に `ndf_version` を足す | -| `answers/`・`ranges/` | #2436 の `parallel-request.json` の答え `{result, version, release, reason, at}` と範囲 `{sha, version, prs, lanes}` | -| `batch.json` | `{seq, step, lanes:[{lane, requests:[{id, prs}], issues}], presentations, choices}`。`step` は `present` / `represent` / `confirm:1` / `confirm:2` / `continue` | -| `presented/` | `{seq, k, sha, version, prs, lanes:[{lane, requested}], added_prs, auto}` | -| `progress/` | `{seq, stages}`。届いた段(`release_branch` / `develop_merged` / `prod_pr_opened` / `main_merged` / `tagged`) | -| `results/` | `{seq, result, version, prod_pr_url, sha, prs, lanes, reason}`。`result` は `released` / `failed` | - -- **どちらの書き手も一時ファイルへ書いて名前を付け替える。** 読み手は名前の付いたファイルだけを読む。書きかけのファイルを読まないためである -- 契約の例の組(依頼・答え・範囲・`batch.json`・提示・結果の 1 組ずつ)を置く。構造チェック(`scripts/contracts_lib/model.py`)とランナーのテストが同じ例を読む(`ndf-next-block.json` と同じ扱い) -- 環境変数の宣言 `plugins/ndf/contracts/env/NDF_ORGANIZER_DIR.json`・`NDF_ORGANIZER_LANE.json` を足す。`NDF_FACTORY_RUN` の宣言は #2436 が足す - -### レーンの側 - -| 点 | 決まり | -| --- | --- | -| 判定 | `parallel_lane.lane_of()` は、`NDF_ORGANIZER_DIR` があれば外のオーガナイザーのレーン(名前は `NDF_ORGANIZER_LANE`)を返す。無ければ #2436 の判定のまま | -| 依頼 | `parallel.py request --external` は `requests/.json` を書いて 10 で終わる。自分の Pull Request がすべて `origin/main` にあれば、`ranges/` から版を読んで書かずに 0 で終わる(#2436 の I15)。依頼の `id` の決め方は #2436 のまま | -| 答え | `parallel.py await --external --timeout 0` は `answers/.json` を読む。無ければ 3 | -| 問い | 外のオーガナイザーのレーンは、設計の承認と承認ゲートの外の問いを依頼にしない(#2436 の I17 を当てない)。今どおり止まって人へ戻す。問いの hook は `NDF_ORGANIZER_DIR` があれば送らない | -| ループ | 周回の結果「配布待ち」と周回の境の読み方は #2436 のレーンのループのまま。答えを運ぶのは外のオーガナイザー(ランナー)である | - -### 配布の実行の側 - -`/goal /ndf:development-workflow --parallel-helper --external` で始める。conductor は `batch.json` の `step` ごとに次の 1 手を打つ。 - -| `step` | すること | 終わり方 | -| --- | --- | --- | -| `present` / `represent` | `parallel.py compose --external` が提示の番号 `k`(`present` は 1、`represent` は 2)の提示のプランと本番のプランを `<置き場>/releases/<連番>//` に作る(#2436 の「配布を組む」と同じ関数。版は `origin/develop` の `plugin.json` の開発版から接尾辞を外す)。`supervise.py queue present.json --then release.json` を流す | MVV 判定が通せば本番まで流して `settle`。承認ゲート 2 で止まれば `parallel.py present --external` が `presented/` を書き、「判断が要る」で終える | -| `confirm:` | 確定した提示 `k` の本番のプランだけを流す(#2436 の確定の後と同じ)。本番のプランは `batch.json` の `presentations` の k 番目の `sha` から `release/v<版>` を切り、その後に進んだ `develop` を出さない | `settle` | -| `continue` | 止まったプランを今の再開の手順で続ける | `settle` か、判断で止まる | - -`parallel.py settle --external <連番> --done ` は、本番のプランの done とリリース記録を読む。届いた段を `progress/` に、結果を `results/` に書く。配布の実行の失敗の扱い(CI の失敗・版数の衝突の直し)は #2436 の一時レーンと同じで、直せなければ問いとして止まる。 - -`<置き場>` は、ランナーが S3 へ保存する NDF の状態の中に置く。配布の実行の再開(`confirm:k`・`continue`)は、保存した提示のプランと本番のプランから続ける。 - -### 構成要素と触るファイル - -| ファイル | 変えること | -| --- | --- | -| `plugins/ndf/scripts/lib/parallel_external.py` | 置き場の読み書き(一時ファイルと付け替え)・`batch.json` の読み・`compose`・`present`・`settle` の外の版 | -| `plugins/ndf/scripts/lib/parallel_lane.py` | `lane_of()` の外のオーガナイザーの判定、`request`・`await` の外の版 | -| `plugins/ndf/scripts/parallel.py` | 副命令に `--external` を足す(`request`・`await`・`compose`・`present`・`settle`) | -| 問いの hook(`plugins/ndf/scripts/hook_lib/ask_guard.py`) | `NDF_ORGANIZER_DIR` があれば依頼を送らない | -| `plugins/ndf/contracts/forms/parallel-external.json`・`plugins/ndf/contracts/env/NDF_ORGANIZER_{DIR,LANE}.json` | 置き場の契約と例の組、環境変数の宣言 | -| `plugins/ndf/skills/development-workflow/references/parallel.md` | `--parallel-helper --external` の手順の節 | -| `plugins/ndf/scripts/tests/test_parallel_external.py` | 下のテスト設計 | - -### テスト設計 - -| 条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 判定 | `NDF_ORGANIZER_DIR` があると本番のステージが配布の依頼のプランになり、無いと #2436 の判定のまま | 環境変数を読まないと落ちる | -| 依頼と答え | 依頼のファイルが契約の例と同じ形で書かれ、答えのファイルを `await --timeout 0` が読む。打ち直しても同じ `id` | 形を変える・`id` を採番し直すと落ちる | -| 問い | 外のレーンの設計の承認と問いは依頼にならず、今どおり止まる | 問いの hook が送ると落ちる | -| 提示と確定 | `represent` で提示 2 のプランを作り、`confirm:1` は提示 1 の本番のプランだけを流す | 確定しない提示の本番のプランを流すと落ちる | -| 結果 | `settle` が `main` のマージの後に止まった本番のプランで `main_merged` を `progress/` に書く | 段を書かないと落ちる | - -## 語の改名(F9) - -### 用語 - -| 用語 | 改める前 | 対象 | -| --- | --- | --- | -| 手元のマシン | `手元のランナー` | 用語集の `ndf-factory`・Factory の確定仕様・`factory/` の説明文・`ja.json` | -| 手元のマシンの枠 | `手元のランナーの枠` | 同じ | -| 料金プラン | `ndf-factory` の文脈の裸の「プラン」 | 同じ。`ndf-workflow` の「プラン」(`plan.json`)と、Factory の文書の中の NDF のプラン(本番のプランなど)は変えない | - -### 対象と件数(2026-11-02 の検索) - -| 対象 | 改める前の語の件数 | -| --- | --- | -| Factory の確定仕様 `docs/specifications/ndf-factory-*.md` | `手元のランナー`38 件(11 ファイル中 6 ファイル)。「プラン」57 件(7 ファイル。NDF のプランを指す行を除いて改める) | -| `factory/README.md` | 「プラン」2 件 | -| `factory/web/src/i18n/ja.json` | `screens.runners``手元のランナー`・`mode.local``local(手元のランナー)`・`account.plan`「プラン: {{plan}}」・`repositories.maxConcurrent`「同時の実行の数(空ならプランの値)」の 4 件 | -| `factory/` のコメントとテストの期待値 | `手元のランナー`16 ファイル・「プラン」18 ファイル(`ja.json` を含む)。文言を照合するテスト(`App.test.tsx` など)は期待値を改めた文言へ合わせる | -| 用語集 | `手元のランナーの枠`の語、「同時のジョブの数」「実行の形態」などの意味の中の語、`ndf-factory` の「プラン」 | - -件数は実装の時点で同じ検索をし直して照らす。対象の外は、発表資料(`docs/presentations/`)・記事(`docs/articles/`)・#2436 の設計文書・`issues/` の過去の文書である。 - -### 守ること - -- コードの識別子(`LocalRunner`・`runnerId`・`Plan`・`plan`)・API の項目名・データの項目名・i18n のキーは変えない(AC 16) -- 用語集は、新しい語に `pending_source`(この設計文書)を置き、`plan-to-spec` が確定仕様を書いたときに `source` へ移す。`手元のランナー`は廃止した語として残し、`glossary.py check` が新しく足した行で拾う -- `ndf-factory` の「プラン」は廃止した語にしない。同じ語が `ndf-workflow` で生きており、用語チェックは生きた語と同じ廃止した語を照合から外すためである。Factory の文書に裸の「プラン」が残らないことは、実装の検索で確かめる diff --git a/issues/issue-2439-design.md b/issues/issue-2439-design.md deleted file mode 100644 index 59765dc7a..000000000 --- a/issues/issue-2439-design.md +++ /dev/null @@ -1,364 +0,0 @@ -# Factory のオーガナイザー: 同じリポジトリで並行する実行の本番の配布を 1 本にまとめ、承認と問いをリポジトリごとに 1 か所へ集める(#2439) - -## 目的 - -- **今どうなっているか**: Neural Development Factory の実行は、承認ゲート 2 と本番の配布を実行ごとに出す。2026-11-02 に 1 台の tmux で衝突が起きた。ndf v10.27.14 が他の 2 本の変更もまとめて出し、Pull Request https://github.com/devbasex/ai-plugins/pull/2420 を閉じた。同じ衝突が、同じリポジトリの実行ジョブの間でも起きる。#2436 のオーガナイザーは 1 台の SQLite に閉じ、実行ジョブの間をまたげない -- **誰に効くか**: 同じリポジトリで 2 本以上の開発ループかスプリントを Factory で回す利用者 -- **変えると何が成り立つか**: 本番の配布は Factory のオーガナイザーが 1 本にまとめ、配布専用の実行ジョブが流す。承認ゲート 2 は 1 回で済み、承認と問いはリポジトリごとの 1 つの一覧に並ぶ。語の衝突 2 つ(手元の受け手・アカウントの契約の種類)が解ける -- **なぜ衝突が無くなるか**: 衝突の原因は、実行ごとの本番の配布が `develop` の先頭を出すため、先に出した実行が他の実行の変更も出すことにある。この設計では、同じリポジトリで本番の配布を流すのは配布の実行 1 本だけになる(I56・I57)。出すのは承認したコミットまでの範囲で、範囲に Pull Request が入った実行すべてへ同じ版と本番の Pull Request を返す(I61)。範囲の外の変更は次のまとめた配布で出る。この仕組みの外で本番を出しうるもの(Factory の外の本番の Pull Request と、依頼を出さない古い NDF の版の実行)がある間は組まない(I62) - -## 適用範囲 - -- **働く範囲**: `factory/`(API・ランナー・契約・画面の文言)と、NDF の側の切り替え口(`plugins/ndf/`。Factory を名指ししない)。NDF の切り替え口は、外のオーガナイザーとファイルで依頼と答えをやり取りしたいどのリポジトリでも使える -- **プロジェクトごとに違うもの**: 本番の配布の手段は各リポジトリのリリースのテンプレート(`.ndf/release.json`)のまま。承認ゲート 2 を MVV 判定に任せるかは各リポジトリの `.ndf/pace.json` のまま -- **当たるモード**: `standard`。オーガナイザーはモードを持たない。実行の中のスプリントは今どおりモードを判定する - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| #2439 の「依頼(原文)」(2026-11-02) | 利用者の指示の原文 | Factory にもまとめ役が要る。「上位のオーガナイザー」ではなく、同じ役割を Factory の側で担う実装として定義する | -| Pull Request 2420 を閉じた件 | 実測 | 本番の配布が `develop` の先頭を出すため、先に出した実行が他の実行の変更も出す | -| #2436 の設計(確定仕様 `docs/specifications/ndf-parallel-organizer.md`) | 設計の正本 | 配布の中身を `develop` のコミットの範囲で決める。本番の Pull Request の head は `release/v<版>`。依頼と答えの形は NDF の契約 | -| Factory の確定仕様 [ndf-factory-dev-loops.md](../docs/specifications/ndf-factory-dev-loops.md) の概要 | 設計の正本 | 常駐のプロセスを持たない。実行をまたぐ制御は API の handler と 5 分ごとの見回りに置く | -| `factory/` の調査(2026-11-02) | 実測 | 承認の依頼の保存と一覧(#2173)、手元の受け手の登録の経路(#2189)はまだ無い。本番の配布はランナーの中の NDF が流し、Factory は関わらない | - -要求と受け入れ条件は #2439 の本文にある(コピーは [issue-2439-requirements.md](issue-2439-requirements.md))。この文書は「どう作るか」だけを扱う。部分の設計は次の 3 つに分けた。 - -| 文書 | 扱う部分 | -| --- | --- | -| [issue-2439-design-data.md](issue-2439-design-data.md) | データ構造と入出力の契約(DynamoDB の行・API・進捗の事象・ランナーの指示) | -| [issue-2439-design-ndf.md](issue-2439-design-ndf.md) | NDF の側の切り替え口(外のオーガナイザーとの置き場の契約)と、語の改名 | -| [issue-2439-design-decisions.md](issue-2439-design-decisions.md) | 決定の記録 | - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| Neural Development Factory(`ndf-factory`) | Factory のオーガナイザー・配布の実行・受けた依頼・まとめた配布・提示・待ちの理由・手元のマシン・料金プラン | -| NDF の開発ワークフロー(`ndf-workflow`) | オーガナイザー・レーン・配布の依頼・承認ゲート 2・承認したコミット | - -**Factory のオーガナイザーは、既存の実行の制御・ループの制御と、この課題で足す配布のまとめでできる役割である。** 1 台の中のオーガナイザーの仕事と Factory の担い手は次のとおり対応する。 - -| 1 台の中のオーガナイザーの仕事(#2436) | Factory の担い手 | この課題で | -| --- | --- | --- | -| レーンを起動・監視・停止する | 実行の制御・ループの制御(周回の開始・停止の後の処理・見回り・取り消し) | 変えない | -| 本番の配布を 1 本にまとめる | 配布のまとめ(`OrganizerControl`) | 新規 | -| 問いを集める | 配布のまとめの一覧(リポジトリごと) | 新規 | -| 答えの後の作業を持ち主のレーンへ返す | `requestResume`・ループの答え・ランナー向けの答えの経路 | 変更 | -| 共有の作業を一時レーンへ渡す | 配布の実行 | 新規 | -| (Factory 固有)実行ジョブの起動・停止・答えでの再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場 | 実行の制御・ランナー向けの経路 | 変えない | - -`ndf-workflow` は依頼と答えの形を**公開された言語**として持つ。形のファイルは 2 つある。#2436 の `plugins/ndf/contracts/forms/parallel-request.json` と、この課題が足す置き場の契約 `parallel-external.json` である。`ndf-factory` はランナーの翻訳(`factory/runner/src/organizer.ts`)を**腐敗防止層**として置き、ファイルの形を進捗の事象へ写す。API は NDF のファイルを読まない。今の `ndf-next` のブロックと `loop.json` の扱いと同じである。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| オーガナイザーの進行 | 配布のまとめ(`OrganizerControl`) | リポジトリのオーガナイザー(`repositoryId`) | 受けた依頼・まとめた配布 | 待ちの理由・提示(コミット・範囲の実行・加わった範囲)・答え・配布の結果・本番の Pull Request | -| スプリントの実行(変更) | 実行の制御(`RunControl`) | スプリントの実行 | 実行ジョブ | 目的(`sprint` / `release`)・属するまとめた配布・待っている依頼 | -| 開発ループ | ループの制御(変えない) | ループ | — | 人の判断待ち・答え | -| 承認の依頼 | 承認と通知(#2173。変えない) | 承認の依頼 | — | 種類・答え | - -- オーガナイザーの進行は、ほかの集約を識別子でだけ参照する(実行は `sprintRunId`、ループは `loopId`、承認の依頼は `approvalRequestId`) -- **1 つの処理が書き換える集約は 1 つ**で、例外は 2 つである。まとめた配布を組む処理は、オーガナイザーの進行と配布の実行(実行とジョブ)を 1 つのトランザクションで書く(I57)。停止の後の処理は、ジョブの `release.requested` を受けた依頼の行として同じトランザクションへ入れる(I59)。どちらも、間で止まったときに見回りが 2 本目を起こすか依頼を取りこぼすためである。扱いは既存の 3 集約のトランザクションと同じにする([ndf-factory-dev-loops.md](../docs/specifications/ndf-factory-dev-loops.md) の「集約」) -- 承認の依頼と開発ループの人の判断待ちは書き換えない。一覧(F6)はそれらを読んで並べるだけである - -### 不変条件 - -番号は Factory の確定仕様の続き(I55 の次)から振る。 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I56 | オーガナイザーの進行 | 同じリポジトリで終わりの状態でないまとめた配布は 1 つまで | 組む書き込みは根の `currentSeq` が空で `version` が読んだ値のときだけ通す。落ちたら読み直し、組まない | -| I57 | オーガナイザーの進行 | まとめた配布 1 つに配布の実行は 1 本で、まとめた配布と同じトランザクションで作る | 片方だけを書く経路を持たない。トランザクションが落ちればどちらも無い | -| I58 | オーガナイザーの進行 | 受けた依頼 1 件に答えは 1 回だけ書く | 答えの書き込みは `attribute_not_exists(answer)` を条件にし、落ちたら書かない | -| I59 | オーガナイザーの進行 | 同じ依頼の id は 1 行(ランナーが再送しても増えない) | 停止の後の処理は在る行を読んでから無い行だけを書く。進捗の事象は冪等の鍵 `<依頼の id>` で 1 件になる | -| I60 | オーガナイザーの進行 | 提示は 1 つのまとめた配布に 2 つまで。`develop` の先頭を比べるのは提示 1 への承認のときだけ | 提示 2 への答えでは GitHub を読まない。3 つ目の提示を組む経路を持たない | -| I61 | オーガナイザーの進行 | 答えを返す依頼は、まとめた配布の終わり方で決まる。配布済みでは、確定した範囲に Pull Request が入る依頼へ `released` を返す。差し戻しでは、最後の提示の範囲に Pull Request が入る依頼へ `rejected` を返す。提示の前に止まった配布の差し戻しでは、まとめた配布が受けた依頼すべてへ `rejected` を返す。どの範囲にも入らない依頼は次のまとめた配布へ残る | 配布済みの範囲は、配布の実行が返す `release.settled` の `prs` で照らす。差し戻しはジョブを起こさず `release.settled` も来ないため、まとめた配布に記録した最後の提示の `prs` で照らす。照らせない依頼は答えずに残す | -| I62 | オーガナイザーの進行 | Factory の外の本番の Pull Request(宛先 `main`、head が `release/v` で始まるか `develop`)が開いている間、またはリポジトリに依頼を出していない古い NDF の版の実行がある間は、まとめた配布を組まない。依頼を出していない古い版の実行の数は、リポジトリの枠の `legacyCount` から、受けた依頼を待つ `awaiting_approval`(`release_requested`)の実行のうち `legacy` が真のものの数を引いた値である(依頼を出した実行は自分で配布しないため数えない)。逆に、まとめた配布が終わりの状態でない間は、古い版の実行を始めない | 組まずに待ちの理由(`external_release_open` / `legacy_run`)を根と記録の行に書く。次の見回りで照らし直す。組む書き込み(I57)は、リポジトリの枠の `legacySeq`(古い版の実行を数えに入れるたびに 1 増える版)が照らした時に読んだ値と同じであることを条件に入れる(読んだ時に無ければ無いことを条件にする)。照らした後に古い版の実行が 1 本でも数えに入れば、`legacyCount` が同じ値へ戻っていても落ち、次の見回りで照らし直す。古い版の実行の開始([ndf-factory-dev-loops.md](../docs/specifications/ndf-factory-dev-loops.md) の I48 が求める、リポジトリの枠の `count` が 0 の条件の書き込み)は、オーガナイザーの根の `currentSeq` が空であることを条件に入れ、落ちれば 409 `parallel_unsupported_version`(ループは待つ)。どちらも相手の集約を条件として読むだけで書き換えない。古い版の見分け方は [issue-2439-design-data.md](issue-2439-design-data.md) の「互換性」にある | -| I63 | オーガナイザーの進行 | `main` へのマージを記録したまとめた配布は差し戻せない | 止まった配布の選択肢は「続きから流す」だけを返し、差し戻しの操作と答えを 409 `release_after_main` で断る | -| I64 | スプリントの実行 | 配布の実行は実行の枠(アカウント・リポジトリ)を持たず、使える量の判定にかけない。トークンと費用は登録したアカウントの利用量へ数える | 配布の実行の作成は枠の行を書かない。`judgeLimits` を呼ばない | -| I65 | スプリントの実行 | 配布の依頼を出したループの実行は `done` で終わる(周回は配布待ちで締まる)。手動の実行は `awaiting_approval`(理由 `release_requested`)で枠を持ったまま答えを待つ。配布の実行は、ジョブが失敗しても差し戻しか配布済みまで終わりの状態にならない(理由 `release_stalled` の `awaiting_approval`) | 停止の後の処理が実行の目的と `job.finished` の結果と理由から決める。表に無い組は今の扱いのまま | -| I66 | 承認の依頼 | 保存した承認の依頼へ答えを書けるのは人だけ。一覧の API と MCP は読むだけで、答えを書く経路を持たない。MVV 判定が承認ゲート 2 を通すときは承認の依頼を作らないため、この条件の対象にならない(E12) | 一覧の経路は GET だけを持つ。止まった配布を解く操作は Web のセッションの認証でだけ受ける | -| I67 | オーガナイザーの進行 | 根の状態を変える書き込みは `version` を条件にする(API の handler と見回りが同時に進めても 1 回だけ通る) | 落ちたら 3 回まで読み直す。状態が変わっていれば何もしない | -| I68 | スプリントの実行 | Factory の実行ジョブでは 1 台の中のオーガナイザーを起動せず、本番の配布は依頼として出す | ランナーが `NDF_FACTORY_RUN=1`・`NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE` を必ず渡す。#2436 の `parallel.py init`・`run` は `NDF_FACTORY_RUN` で断る | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 実行が配布の依頼を出した(`release.requested`) | ランナー(NDF の依頼のファイルの翻訳) | 停止の後の処理(受けた依頼の行)→ 配布のまとめ | -| E2 | 配布の範囲を決めるまとめた配布を組んだ | 配布のまとめ(`tick`) | 実行の制御(配布の実行を起こす) | -| E3 | 承認資料をそろえ、承認ゲート 2 で止まった(`release.presented`・`gate.reached`) | ランナー(配布の実行) | 配布のまとめ(提示の記録)・承認と通知(承認の依頼) | -| E4 | 人が承認ゲート 2 の承認の依頼に答えた | 承認と通知(`requestResume`) | 配布のまとめ(`decide`) | -| E5 | 答えの時点の `develop` が進んでいたので出し直した | 配布のまとめ(`decide`) | 配布の実行(`represent` で再開) | -| E6 | 配布の実行を起こした | 配布のまとめ(`tick`・`decide`) | 実行の制御(`launch`) | -| E7 | 本番の Pull Request を 1 本作ってマージし、版を出した(`release.progress`・`release.settled`) | ランナー(配布の実行) | 配布のまとめ(`settle`) | -| E8 | 範囲のすべての実行へ配布の結果を返した | 配布のまとめ(`settle`) | 実行の進捗の記録(`release.answered`)・次のジョブ(答えの経路)・手動の実行(`requestResume`) | -| E9 | 実行が問いの依頼を出した(承認ゲートの外) | ランナー(今の `gate.reached` の判断・ループの `pending`) | 承認と通知・ループの制御(今のまま) | -| E10 | 問いの答えを持ち主の実行へ返した | 承認と通知(`requestResume`)・ループの制御(答えの保存) | 持ち主の実行か、ループの次の周回(今のまま) | -| E11 | Factory の外の本番の Pull Request が開いていたので待った | 配布のまとめ(`tick`) | 一覧(待ちの理由)・記録の行 | -| E12 | MVV 判定が承認ゲート 2 を通した(`release.presented` の `auto` が真。承認の依頼を作らない) | ランナー(配布の実行) | 配布のまとめ(提示 1 の記録と確定。`decide` を通さない) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| Factory のオーガナイザー | 同じリポジトリで並行する Factory の実行をまとめる役割。1 台の中のオーガナイザーの仕事すべて(配布を 1 本にまとめる・問いを集める・答えの後の作業を持ち主へ返す・共有の作業を一時のレーンへ渡す)に、Factory 固有の仕事(実行ジョブの起動・停止・答えでの再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場)を足したもの。API の handler と見回りの中で動き、常駐しない。1 台の中のオーガナイザーと同じ層の別の実装で、互いに排他 | 追加(`ndf-factory`) | -| 配布の実行 | Factory のオーガナイザーが、まとめた配布 1 つのために起こすスプリントの実行。範囲のどの実行でもなく、承認資料を作り、本番の配布を流し、結果を返して終わる。同じリポジトリで同時に 1 本まで | 追加(`ndf-factory`) | -| 手元のマシン | 利用者の環境に登録し、同時のジョブの数まで実行ジョブを受け取るマシン。受け取った実行ジョブごとに別のランナーが動く | 追加(`ndf-factory`。廃止した語`手元のランナー`) | -| 手元のマシンの枠 | 1 つの手元のマシンが `running` で持つ `local` の実行ジョブの数を数えた実行の枠の行 | 意味の変更(語を改める。廃止した語`手元のランナーの枠`) | -| 料金プラン | アカウントの契約の種類。Free・個人 Pro・個人 Max・Team の 4 つ | 意味の変更(`ndf-factory` の「プラン」を改める。`ndf-workflow` の「プラン」は変えない) | - -配布のまとめ・受けた依頼・まとめた配布・提示・待ちの理由は、この設計の中だけで使う構成要素の名前で、用語集へは足さない。 - -### 既存の語と記号 - -この設計が定義し直さずに使う既存の語と記号は、次の正本の意味で読む。 - -| 語・記号 | 意味 | 正本 | -| --- | --- | --- | -| 切り替え口 | NDF が本番の配布を自分で組まずに、外のオーガナイザーへ配布の依頼をファイルで出す経路。環境変数 `NDF_ORGANIZER_DIR` があるときだけ働く | この設計([issue-2439-design-ndf.md](issue-2439-design-ndf.md))。要求の「前提 2」 | -| レーン | 並列の計画の下で 1 本のセッションが進める開発の流れ。Factory では、ループ 1 つか手動の実行 1 つが 1 つのレーンになる(名前は `loop-` / `run-`) | #2436 の設計の「用語」 | -| 一時レーン | #2436 で、持ち主のいない共有の作業(まとめた配布のプランの実行と、その失敗の対応)を受け持つ短命のレーン。この設計では配布の実行がその役を受け持つ | #2436 の確定仕様 `docs/specifications/ndf-parallel-organizer.md` | -| 1 台の中のオーガナイザー | #2436 の、1 台のマシンの中でレーンを起動・監視し、配布の依頼をまとめる conductor の役割 | #2436 の設計の「用語」(語は「オーガナイザー」) | -| 承認ゲート 1・承認ゲート 2 | 人の承認を求める 2 つの点。1 は設計 Pull Request のマージ、2 は本番系へ届く操作(本番の配布) | 用語集 `ndf-workflow` の「承認ゲート」「ゲート 1」「ゲート 2」 | -| MVV 判定 | 承認ゲートの資料がプロジェクトの MVV(Mission / Vision / Value)に従うかの判定。`.ndf/pace.json` が許すときは、従うと判定した承認ゲートを人の答えなしで通す | 用語集 `ndf-workflow` の「MVV 判定」 | -| conductor | 人と対話しているセッション。承認ゲートで人へ問えるのはこの層だけ | 用語集 `ndf-workflow` の「conductor」 | -| `/goal` | Claude Code の、目標の条件を満たすまでセッションを止めずに進めさせるコマンド。NDF は工程 Skill をこの条件の先頭に置いて始める | `development-workflow` の `references/relay.md` | -| 見回り | 実行の制御が 5 分ごとに動かす処理 | 用語集 `ndf-factory` の「見回り」 | -| 古い版の実行・`legacy`・`legacyCount`・`NDF_PARALLEL_MIN_VERSION` | 並行を許す NDF の版の下限(設定 `NDF_PARALLEL_MIN_VERSION`)より古い版で始まった実行を古い版の実行と呼び、実行の行の `legacy` を真にする。リポジトリの枠の `legacyCount` は、終わりの状態でない古い版の実行の数 | [ndf-factory-dev-loops.md](../docs/specifications/ndf-factory-dev-loops.md) の I48 と「並行の開始」、[ndf-factory-dev-loops-data-and-contracts.md](../docs/specifications/ndf-factory-dev-loops-data-and-contracts.md) | -| I1〜I55 | Factory の確定仕様の不変条件。I48 は [ndf-factory-dev-loops.md](../docs/specifications/ndf-factory-dev-loops.md) の「古い版の実行がリポジトリにあれば、その実行は唯一である」を指す(`ndf-factory-issue-requests.md` の同じ番号とは別) | 各確定仕様の「不変条件」 | -| S1〜S11・前提 1〜5・未決 1〜4・AC と受け入れ条件の番号 | 要求の「起票者の推定」・「前提」・「未決」・「受け入れ条件」の番号。AC は受け入れ条件(acceptance criteria) | [issue-2439-requirements.md](issue-2439-requirements.md) | -| Value 1〜10・Mission・C4(MVV 版 4) | 決定の根拠に挙げた、プロジェクト MVV の第 4 版の項目。C4 は NDF の共通原則の「必ず承認が要る操作」の 4 番目(戻せない操作) | `.ndf/mvv.md`、`issues/ndf-common-principles.md` | -| `pending_source` | 用語集の語が、まだ確定仕様になっていない設計文書を出典とすることを示す項目。確定仕様化で `source` へ移す | `plugins/ndf/scripts/glossary.py`、[ndf-glossary-merge.md](../docs/specifications/ndf-glossary-merge.md) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 実行が本番の配布の代わりに配布の依頼を出し、答えを待たずに周回を締める | 実行の中の NDF・ランナー | -| F2 | 配布の依頼を受け、流れている配布が無ければ直ちにまとめた配布を組み、配布の実行を起こす | 配布のまとめ | -| F3 | 承認資料を作って承認ゲート 2 で止まるか、MVV 判定が通せば続けて本番の配布を流す | 配布の実行 | -| F4 | 承認の答えの時点で `develop` が進んでいれば 1 回だけ出し直し、確定した提示の本番の配布を流させる | 配布のまとめ | -| F5 | 配布の結果を範囲の実行へ返し、範囲に入らない依頼で次のまとめた配布を組む | 配布のまとめ | -| F6 | リポジトリごとの承認と問いの一覧と、まとめた配布の履歴を読む | 利用者(Web・API・MCP) | -| F7 | 止まった配布を「続きから流す」か「差し戻す」で解く(`main` へのマージの後は前者だけ) | 利用者(Web) | -| F8 | 途中で止まった配布のまとめの処理を、見回りが記録から続ける | 実行の制御(見回り) | -| F9 | 手元のマシンと料金プランの語へ説明文・用語集・画面の文言を改める | 利用者(読み手) | - -## 構成要素 - -| 要素 | 責務 | 新規 / 変更 | -| --- | --- | --- | -| `factory/api/src/organizer/control.ts`(配布のまとめ。`OrganizerControl`) | `accept`(受けた依頼)・`tick`(待ちの判定・組む・`settle` の続き)・`decide`(承認の答えと出し直し)・`settle`(結果と答え)・`resolve`(止まった配布)・`list`(一覧)・`sweep` | 新規 | -| `factory/api/src/organizer/model.ts` | 根・受けた依頼・まとめた配布・記録の行の型と、状態の遷移の純粋な関数 | 新規 | -| `factory/api/src/runs/table.ts`・`dynamo-table.ts`・`memory-table.ts` | オーガナイザーの進行の行の読み書きと、まとめた配布と配布の実行を 1 回で書くトランザクション(`composeRelease`。リポジトリの枠の `legacySeq` を条件にする)。古い版の実行の開始で `legacyCount` と同じトランザクションで `legacySeq` を 1 増やす。`addResumeJob` に、待っている理由と依頼の id を照らす条件を足す | 変更 | -| `factory/api/src/runs/backfill.ts` | 実行を古い版として `legacyCount` へ写す書き込みで `legacySeq` を 1 増やす | 変更 | -| `factory/api/src/runs/control.ts`(`RunControl`) | `finalize` が `release.*` を読んで受けた依頼の行を同じトランザクションへ入れ、後で `organizer.tick` を呼ぶ。`requestResume` が配布の実行を `organizer.decide` へ回す。`sweep` が `organizer.sweep` を呼ぶ。配布の実行を枠の外で作る | 変更 | -| `factory/api/src/runs/model.ts` | 実行の `purpose`・`releaseSeq`・`releaseRequestId`、ジョブの `start: release`・`releaseStep` | 変更 | -| `factory/api/src/github/port.ts`・`app.ts` | `branchHead(repo, "develop")`・宛先 `main` の開いている Pull Request の head の一覧・head を指定した Pull Request(開いている・マージ済み)・`release/v<版>` の枝の有無 | 変更 | -| `factory/api/src/routes/factory.ts` | 一覧・まとめた配布の履歴・止まった配布を解く操作の 3 経路 | 変更 | -| `factory/api/src/runs/runner-api.ts` | ランナー向けの 2 経路(答えと範囲・まとめた配布の中身) | 変更 | -| `factory/api/src/progress/authorize.ts` | 出所 `runner` が `release.*` の 4 種類を書け、出所 `control` が `release.answered` を書ける | 変更 | -| `factory/runner/src/organizer.ts` | 置き場の翻訳: 依頼・提示・進み・結果のファイル → 進捗の事象。答え・範囲・まとめた配布 → ファイル。未回答の依頼の再送 | 新規 | -| `factory/runner/src/main.ts`・`runtime.ts`・`translate.ts` | `START=release` の手順。環境変数 `NDF_FACTORY_RUN`・`NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE`。配布の依頼で締めた手動の実行の終わり方 | 変更 | -| `factory/contracts/`(`schemas/progress-event.schema.json`・`schemas/run-job-instruction.schema.json`・`openapi/organizer.yaml`・`openapi/runs.yaml`) | 契約([issue-2439-design-data.md](issue-2439-design-data.md)) | 変更・新規 | -| NDF の切り替え口(`plugins/ndf/`) | 外のオーガナイザーとの置き場の契約と、依頼の書き出し・答えの読み込み・配布の実行の手順([issue-2439-design-ndf.md](issue-2439-design-ndf.md)) | 変更・新規 | -| 語の改名(用語集・Factory の確定仕様・`factory/` の説明文・`factory/web/src/i18n/ja.json`) | F9([issue-2439-design-ndf.md](issue-2439-design-ndf.md) の「語の改名」) | 変更 | - -```mermaid -graph TD - subgraph sg1["API(factory/api)"] - RT[画面向けの経路] - RAPI[ランナー向けの経路] - RC[実行の制御] - OC[配布のまとめ] - TBL[実行の表] - GH[GitHub の口] - end - subgraph sg2["実行ジョブ(ECS か手元のマシン)"] - TR[置き場の翻訳] - MAIN[ランナーの手順] - end - NDF["NDF(切り替え口)"] - AP["承認と通知(#2173)"] - ING[進捗の受け口] - RT --> OC - RAPI --> OC - RC -->|finalize・sweep・requestResume| OC - OC -->|composeRelease・行| TBL - OC -->|launch| RC - OC --> GH - OC -->|開いている承認の依頼| AP - AP -->|requestResume| RC - MAIN --> TR - TR -->|置き場のファイル| NDF - TR -->|release.*| ING - MAIN --> RAPI -``` - -画面(`factory/web`)は文言の改名だけで、一覧の画面は #2430 が持つため図に含めない。型の定義(2 つの `model.ts`)・出所の許可(`authorize.ts`)・契約のファイル・語の改名は、上の要素が使う定義と文書で、図に含めない。 - -### 置き場(パッケージ構成) - -```text -factory/ -├── api/src/ -│ ├── organizer/{control,model}.ts # 新規 -│ ├── organizer/control.test.ts # 新規(メモリの表) -│ ├── runs/{backfill,control,model,table,dynamo-table,memory-table,runner-api}.ts # 変更 -│ ├── github/{port,app}.ts # 変更 -│ ├── progress/authorize.ts # 変更 -│ └── routes/factory.ts # 変更 -├── runner/src/ -│ ├── organizer.ts # 新規 -│ └── {main,runtime,translate}.ts # 変更 -├── contracts/ -│ ├── openapi/{organizer.yaml,runs.yaml} # 新規・変更 -│ └── schemas/{progress-event,run-job-instruction}.schema.json # 変更 -└── web/src/i18n/ja.json # 変更(文言) -``` - -### システムの文脈と配置 - -```mermaid -graph TD - U[利用者の Web] --> API[API の handler] - EB[EventBridge 5 分] --> SW[見回りの Lambda] - API --> DB[(DynamoDB の実行の表)] - SW --> DB - API --> GHB[GitHub] - SW --> GHB - SW -->|RunTask| L1[実行ジョブ: レーン] - SW -->|RunTask| R1[実行ジョブ: 配布の実行] - L1 -->|release.requested| ING[進捗の受け口] - R1 -->|release.presented 等| ING - R1 -->|本番の Pull Request| GHB -``` - -配布のまとめは新しいプロセスもインフラの部品も持たない。既存の API の Lambda と見回りの Lambda の中で、実行の制御から呼ばれる。見回りが引く索引は、実行の表の既存の GSI `gsi1`(待っているループ `WAITING_LOOPS` などを引く疎な索引)に鍵 `ORGANIZER_ACTIVE` の行を載せて使い、インフラの定義は変えない。 - -## 処理の流れ - -### まとめた配布の状態 - -```mermaid -stateDiagram-v2 - [*] --> composed: tick が組んだ(配布の実行を起こした) - composed --> presented: 承認ゲート 2 で止まった - composed --> confirmed: MVV 判定が通した - presented --> confirmed: 答え(develop が同じ / 提示 2 への答え) - presented --> represented: 提示 1 への承認で develop が進んでいた - represented --> presented: 提示 2 の承認資料で止まった - presented --> rejected: 差し戻し - confirmed --> released: release.settled(released) - composed --> stalled: 配布の実行のジョブが失敗した - represented --> stalled: 配布の実行のジョブが失敗した - confirmed --> stalled: 配布の実行のジョブが失敗した - stalled --> composed: 続きから流す(提示の前に止まった) - stalled --> represented: 続きから流す(出し直しの途中で止まった) - stalled --> confirmed: 続きから流す(確定の後に止まった) - stalled --> rejected: 差し戻す(main のマージの前だけ) - released --> [*] - rejected --> [*] -``` - -### 依頼から結果まで - -```mermaid -sequenceDiagram - participant L as レーンの実行ジョブ - participant C as 実行の制御 - participant O as 配布のまとめ - participant R as 配布の実行ジョブ - participant A as 承認と通知 - L->>C: release.requested・job.finished - C->>O: finalize で依頼の行 → tick - O->>O: 外の Pull Request と古い版を照らす - O->>C: composeRelease → launch(START=release) - R->>O: release.presented・gate.reached(gate2) - A->>C: requestResume(人の答え) - C->>O: decide(develop の先頭を比べる) - O->>C: 再開(confirm:k か represent) - R->>O: release.settled(finalize → settle) - O->>L: 答えと範囲(次のジョブ)・release.answered -``` - -1. **依頼(F1)**: NDF は本番のステージの代わりに依頼のファイルを置き場へ書く([issue-2439-design-ndf.md](issue-2439-design-ndf.md))。ランナーは 2 秒ごとの読みで `release.requested` を送る。ループの実行は周回を配布待ちで締めて `done` で、手動の実行は `job.finished`(`gate`・理由 `release_requested`)で終わる(I65) -2. **受ける**: `finalize` は `release.requested` をジョブの冪等の鍵の範囲で読む。受けた依頼の行は、実行の移りと同じトランザクションで書く(I59)。通った後に `organizer.tick(repositoryId)` を呼ぶ(落ちてよい。見回りが続ける) -3. **組む(F2)**: `tick` は根を読み、まとめた配布が流れていなければ、未回答の依頼があるときだけ組む。先に I62 を照らし、当たれば待ちの理由を書いて終える。当たらなければ `composeRelease` で 5 つの行を 1 回で書き、`launch` する。5 つは根(`currentSeq`・`nextSeq`・`version`)・まとめた配布(`composed`)・配布の実行・ジョブ(`START=release`、`releaseStep=present`)・記録の行である。依頼が 1 件でも同じである(S8) -4. **提示(F3)**: 配布の実行は、ランナー向けの経路でまとめた配布の中身(レーンの一覧と依頼)を読み、NDF の配布の手順を流す。MVV 判定が通せば、`release.presented`(`auto` が真)を送って本番の配布まで続ける。このとき承認の依頼は作らず、配布のまとめは `decide` を通さずにまとめた配布を `confirmed`(`confirmedK=1`)にする(E12)。止まれば `release.presented`(提示の番号・コミット・範囲・加わった範囲)と `gate.reached`(`gate2`)を送って終わる。配布の実行は `awaiting_approval` になる。承認の依頼は #2173 が今の経路で作る -5. **答え(F4)**: #2173 が答えを保存して `requestResume` を呼ぶと、実行の制御は配布の実行を `organizer.decide` へ回す。提示 1 への承認では GitHub の口で `develop` の先頭を読み、提示したコミットと比べる。同じなら `confirm:1`、進んでいれば `represent` で再開する。提示 2 への答えは比べずに `confirm:2`(新しい範囲)か `confirm:1`(最初の範囲)で再開する(I60)。`confirm:` で出すのは提示 k のコミット(まとめた配布の `presentations` の k 番目の `sha`)で、本番のプランはそのコミットから `release/v<版>` を切る。答えの後に `develop` が進んでも、出すコミットは変わらない(#2436 の設計の「まとめた配布」(確定仕様 `docs/specifications/ndf-parallel-organizer.md`))。差し戻しはジョブを起こさない。まとめた配布を `rejected`、配布の実行を `stopped`(`release_rejected`)にし、最後の提示の範囲の依頼へ `rejected` を返す(I61)。範囲の実行ごとに `release.answered` を書き、手動の実行には `requestResume`(依頼の識別子つき)を呼ぶ。届け方は 6 の配布済みと同じである。続けて `tick` を呼び、範囲に入らない依頼で次のまとめた配布を組む -6. **結果(F5)**: 配布の実行の `finalize` が `release.settled` を読み、`settle` へ渡す。`settle` は根を空け、まとめた配布を `released` にし、範囲に Pull Request が入る依頼へ答えを書く(I58・I61)。範囲の実行ごとに `appendControlEvent` で `release.answered`(冪等の鍵 `:`)を書く。手動の実行には `requestResume`(依頼の識別子つき)を呼ぶ。依頼ごとに、この 2 つが通った後に `deliveredAt` を書いて見回りの索引から外す(答えを書いただけでは外さない。途中で止まっても見回りが届け直す)。続けて `tick` を呼び、残った依頼で次のまとめた配布を組む -7. **答えを届ける**: レーンの次のジョブは、ループの次の周回か手動の実行の再開である。その開始で、ランナーは答えと範囲を読んで置き場へ書き、答えの無い依頼のファイルを再送する(I59)。NDF は周回の境で答えを読み、`released` なら残りの工程を流す(#2436 のレーンのループ) - -### 止まったときと再開(F7・F8) - -| 場面 | 扱い | -| --- | --- | -| `tick` が組む前・組んだ後に止まった | トランザクションの単位で残るか消えるかが決まる。次の `tick`(見回りか次の `finalize`)が根から続ける | -| `composed` のまま `launch` の前に止まった | 見回りは、`composed` のまとめた配布の配布の実行のジョブが `pending` のまま起動の記録が無ければ `launch` し直す(ジョブの冪等は今の `startedBy`) | -| 配布の実行のジョブが失敗した・打ち切られた | `finalize` は配布の実行を終わりの状態にせず `awaiting_approval`(理由 `release_stalled`)へ移す。続く `tick` がまとめた配布を `stalled` にし、`release.progress` に `main_merged` があるか、GitHub で head が `release/v<提示の版>` の `main` 宛の Pull Request がマージ済みなら選択肢を「続きから流す」だけにする(I63)。一覧に止まった配布として並ぶ | -| 止まった配布を「続きから流す」 | `resolve` が同じ配布の実行を `requestResume` と同じ書き込みで再開する。配布の実行を作り直さない(I57)。再開の手(`releaseStep`)は `stalledFrom` で決める。`composed` は `present`(提示 1 のプランから作り直す)、`represented` は `represent`(提示 2 のプランから作り直す)、`confirmed` は、配布の実行の進捗の記録に `release.progress` が 1 件でもあるか、`mainMerged` が真か、GitHub の口で読んで `release/v<提示の版>` の枝か head がその枝の `main` 宛の Pull Request があれば `continue`(NDF が保存した本番のプランの状態から続ける)、どれも無ければ `confirm:`(本番のプランを初めから流す)である。ジョブは段の事象を送る前に落ちうるため、GitHub の状態でも照らす(決定 11)。まとめた配布の `state` は `stalledFrom` の値へ戻す | -| 止まった配布を「差し戻す」 | `resolve` が、答え(5)の差し戻しと同じ処理をする。ジョブを起こさず、範囲の依頼へ `rejected` を返す(I61)。提示の前に止まった配布では、まとめた配布が受けた依頼すべてが範囲になる | -| 配布の実行の中の NDF が判断を求めた | 今の経路(`gate.reached` の判断 → 承認の依頼 → `requestResume`)。`decide` は `continue` で再開する。`main` のマージの後は `gate.reached` の `choices` を `continue` だけにする | -| `settle` の後、答えを書き終える前に止まった | 見回りが `released` のまとめた配布で、範囲の依頼のうち答えの無いものを書き直す(I58 で 2 回目は書かれない) | -| 答えを書いた後、届け終える前に止まった | 答えがあり `deliveredAt` が空の依頼は見回りの索引に残る。見回りが `release.answered` を追記し直し(冪等の鍵で 1 件)、手動の実行には `requestResume`(`releaseRequestId` つき)を呼び直してから `deliveredAt` を書く。呼び直しが再開するのは、実行が `awaiting_approval` で、`awaitingReason` が `release_requested`、`releaseRequestId` がこの依頼の id と一致するときだけである。この照らしは再開のジョブを作る条件つきの書き込み(`addResumeJob`)の条件に入れ、読んでから書くまでの間に実行の待ちが変わっても通さない。`settle` の `requestResume` が通った後で止まり、再開した実行が別の判断・承認ゲートで `awaiting_approval` に戻っていれば、条件に当たらず再開しない。このときは「実行がもうこの依頼を待っていない」として `deliveredAt` を書く | -| 手動の実行の `requestResume` が `sprint_run_busy` | `deliveredAt` を書かずに残す。見回りが、答えのある依頼を待つ実行(`awaiting_approval`・`awaitingReason` が `release_requested`・`releaseRequestId` がこの依頼の id)へ、上の行と同じ条件つきの書き込みで呼び直す。実行がもうこの依頼を待っていなければ(取り消し・終わり・別の理由や別の依頼での `awaiting_approval`)呼ばずに `deliveredAt` を書く | - -見回りは実行の表の既存の GSI `gsi1` を鍵 `ORGANIZER_ACTIVE` で引き(流れているまとめた配布を持つ根と、答えを届け終えていない依頼の行だけが載る)、リポジトリごとに `tick` を呼ぶ。`tick` は根の続きに加えて、答えがあって届け終えていない依頼へ届け直す。 - -## 非機能の実現方式 - -| 大項目 | 条件 | 実現方式 | -| --- | --- | --- | -| 可用性 | 常駐のプロセスを持たない。止まっても次の見回りで続きから再開する | 状態はすべて DynamoDB の行。`tick` は根の状態だけで次の 1 手が決まり、何度呼んでも同じ結果になる(I56・I58・I67) | -| 性能・拡張性 | 依頼から配布の実行の起動まで見回り 1 回分(5 分)以内。承認資料を作る時間は含めない(2026-11-03 の承認ゲート 1 で利用者が決めた読み方) | `finalize` の直後に `tick` を呼ぶ。落ちても見回りが 5 分以内に呼ぶ。見回りが引く索引は既存の GSI `gsi1` で、インフラを足さずに働く。承認の依頼は配布の実行が承認資料を作り終えた時点で作られ、その所要は今の本番のプランの承認資料の生成と同じ | -| 運用・保守性 | 範囲・出し直し・待ちの理由・配布の実行の結果が後から辿れる | まとめた配布の行が提示を 2 つまで上書きせずに持ち、記録の行(`ORGLOG`)が待ち・組む・出し直し・確定・結果を積む。まとめた配布の履歴の経路で読める | -| セキュリティ | 承認の確定は人だけ。配布の実行は範囲の実行と同じリポジトリの権限だけを持つ | I66。配布の実行のジョブの token と導入の token は、今の実行と同じく実行のリポジトリ 1 つに絞る | - -## 決定の記録 - -[issue-2439-design-decisions.md](issue-2439-design-decisions.md) に分けた。 - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 1・I61 | 同じリポジトリの 2 本の実行が依頼を出すと、まとめた配布 1 つ・配布の実行 1 本になり、`settle` の後に 2 本とも同じ版と本番の Pull Request の `release.answered` を持つ | 依頼ごとに組む・範囲に入らない依頼へ答えると落ちる | -| 2 | 依頼 1 件で、他を待たずに `tick` が組む。1 本だけの実行でも承認の依頼 1 件・配布の実行 1 本 | 依頼の数か待ち時間を条件にすると落ちる | -| 3 | まとめた配布の中身の経路が、依頼のあるレーンと無いレーンを印つきで返す。`release.presented` のコミットが提示の行に残る | 依頼の集合で範囲を決めると落ちる | -| 4・I60 | 提示 1 への承認で `develop` が進んでいれば `represent`、同じなら `confirm:1`。提示 2 への答えでは GitHub を読まない | 提示 2 でも比べる・3 つ目の提示を組むと落ちる | -| 5・I56 | 流れている間に届いた依頼は組まずに残り、`settle` の直後の `tick` で次が組まれる。同時の 2 つの `tick` で組まれるのは 1 つ | 根の条件を外すと 2 つ組まれて落ちる | -| 6・I57 | 配布の実行は範囲のどの実行とも別の実行で、まとめた配布と同じトランザクションで作られる。片方の書き込みを失敗させると両方が無い | 2 回の書き込みに分けると落ちる | -| 7・I65 | ループの実行は依頼の後に `done` で終わり、ループは次の周回へ進む。手動の実行は `awaiting_approval`(`release_requested`)で枠を持つ | ループの実行を承認待ちにすると落ちる | -| 8・I62 | 外の `release/v*` か `develop` の head の `main` 宛の Pull Request が開いている間、または依頼を出していない古い版の実行がある間は組まず、待ちの理由を書く。閉じた後の最初の `tick` で組む。下限の設定が無く、依頼を出して `release_requested` で待つ手動の実行 1 本だけが `legacy` 真のときは組む。まとめた配布が流れている間(枠の `count` が 0 でも)は古い版の実行の開始が 409 `parallel_unsupported_version` になる。照らした後に、依頼を待つ古い版の実行の取り消しと別の古い版の実行の開始で `legacyCount` が 1→0→1 と戻ると、組む書き込みが `legacySeq` の条件で落ちる | 照らしを外す・依頼を出した実行を数える・開始の条件から根を外すと落ちる | -| 9 | 一覧が、同じリポジトリの承認待ちの実行の承認の依頼(ゲート 1・判断・配布の実行のゲート 2)と、ループの人の判断待ちを 1 つの並びで返す。別のリポジトリのものを含まない | 実行やループごとの一覧に戻すと落ちる | -| 10・I68 | ランナーの環境に 3 つの環境変数があり、依頼のファイルが `release.requested` へ写る。契約の例の組をランナーのテストと NDF の構造チェックが読む | 環境変数を落とす・形を変えると落ちる | -| 11・I66 | 一覧とまとめた配布の履歴の経路は GET だけで、答えを書く経路が増えない。止まった配布を解く操作は Web のセッションでない呼び出しを 403 で断る | 書く経路を足すと落ちる | -| 12・I58・I59・I67 | `tick`・`settle` を途中で止めて呼び直しても、答えは 1 回、依頼の行は 1 行、本番の Pull Request を作る配布の実行は 1 本。答えを書いた後に止めた依頼と、`requestResume` が `sprint_run_busy` だった依頼は、見回りが `gsi1` の `ORGANIZER_ACTIVE` から引いて届け直し、`release.answered` は 1 件、届け終えた後に索引から外れる。`settle` の `requestResume` が通った後・`deliveredAt` を書く前に止め、再開した手動の実行を別の承認ゲートで `awaiting_approval`(理由が空)に戻してから見回りを流すと、再開のジョブは作られず、その承認の依頼は人の答えを待ったまま残り、依頼の `deliveredAt` が書かれる | 冪等の条件を外す・答えを書いた時点で索引から外す・`addResumeJob` の条件から理由と依頼の id の照らしを外すと落ちる | -| 13・I63 | `main_merged` を記録した止まった配布の選択肢は `continue` だけで、差し戻しは 409 `release_after_main`。`confirmed` から止まった配布は、`release.progress` が無くても、`mainMerged` が真か、GitHub に `release/v<提示の版>` の枝か本番の Pull Request があれば `continue` で再開し、どれも無いときだけ `confirm:` で再開する | 選択肢を固定にすると落ちる | -| 14 | 用語集に「手元のマシン」があり、廃止した語`手元のランナー`を `glossary.py check` が拾う。Factory の確定仕様・`factory/` の説明文・`ja.json` に残らない(検索 0 件) | 廃止した語の登録を外すと落ちる | -| 15 | 用語集の `ndf-factory` に「料金プラン」があり、`ndf-workflow` の「プラン」と並ぶ。Factory の確定仕様・`factory/` の説明文・`ja.json` の「プラン」の検索の当たりが、NDF のプランを指す行だけになる | 契約の種類を指す行を 1 つ残すと、検索の当たりを NDF のプランの行と照らす確認で落ちる | -| 16 | 改名の差分にコードの識別子・API の項目名・データの項目名が無く、`npm run check` が変更前と同じく通る | 識別子を変えると型検査かテストが落ちる | -| I64 | 配布の実行の作成は枠の行を増やさず、`judgeLimits` を呼ばない。`usage.recorded` はアカウントの利用量に数えられる | 枠を書くと、枠が埋まったリポジトリで配布が組めず落ちる | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2439 | 実装する | — | `factory/api/src/organizer/`、`factory/api/src/runs/`、`factory/api/src/github/`、`factory/api/src/progress/authorize.ts`、`factory/api/src/routes/factory.ts`、`factory/runner/src/`、`factory/contracts/`、`factory/web/src/i18n/ja.json`、`factory/README.md`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/scripts/lib/parallel_external.py`、`plugins/ndf/scripts/parallel.py`、`plugins/ndf/contracts/forms/parallel-external.json`、`plugins/ndf/contracts/env/`、`plugins/ndf/skills/development-workflow/references/parallel.md`、`plugins/ndf/scripts/tests/`、`docs/specifications/`、`docs/glossary/glossary.json`、`docs/glossary.md` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| #2173 の承認の依頼の読み方 | 一覧は「実行の識別子から開いている承認の依頼を読む」関数を #2173 に求める。#2173 の保存はまだ無く、関数の形は #2173 の設計で決まる | -| `gate.reached` の `choices` | 承認の依頼が選択肢を持ち、選択肢の外の答えを断るのは #2173 の実装に依る(I63 の二重の守り)。配布のまとめの `decide` と `resolve` は単独でも断る | -| #2436 の側の待ち | 1 台の中のオーガナイザーが、Factory の本番の Pull Request が開いている間に待つかは #2436 の設計に無い。S6 の両側の排他は片側だけになる。範囲外の課題 https://github.com/devbasex/ai-plugins/issues/2441 で扱う | -| レーンの名前の長さ | `NDF_ORGANIZER_LANE` に `loop-` / `run-` を渡す。識別子の長さが #2436 の名前の形に収まるかは、実装で識別子の生成を読んで確かめる | -| 配布の実行の NDF の版 | 範囲の依頼の NDF の版のうち最も新しいものを入れ、下限(`NDF_PARALLEL_MIN_VERSION`)とは比べない。依頼を出せるのは切り替え口の入った版だけで、下限が無い間に `legacy` 真と数えられた実行の依頼でも、版は切り替え口を持つ。実装で依頼の `ndf_version` を読んで確かめる | diff --git a/issues/issue-2439-requirements.md b/issues/issue-2439-requirements.md deleted file mode 100644 index b158d8de7..000000000 --- a/issues/issue-2439-requirements.md +++ /dev/null @@ -1,195 +0,0 @@ -# #2439: NDF Factory: オーガナイザーを Factory の側で実装し、同じリポジトリで並行する実行の本番の配布を 1 本にまとめ、承認と問いを 1 か所に集める - -正は課題の本文(#2439)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 具体例 - -2026-11-02、3 本の開発ループ(lroot2・lflow・lfactory2)が 1 台のコンテナの tmux で動き、本番の配布がぶつかった。lflow が ndf v10.27.14 に他の 2 本の変更もまとめて出し、lroot2 の本番の Pull Request https://github.com/devbasex/ai-plugins/pull/2420 を閉じた。#2436(tmux の並列開発を計画から始める)は、これを 1 台の中のオーガナイザーで防ぐ。 - -Neural Development Factory で同じ 3 本を Web から始めると、周回ごとに別の実行ジョブで動く。ジョブはそれぞれ別のコンテナと別の clone を持つ(ECS の `managed`・`byok`、または利用者の手元のマシン #2189)。#2347(開発ループと並行する実行の調整)は、実行を起こす時点で課題・マイルストーン・NDF の版・同時の数の取り合いを守る。一方、承認ゲート 2 と本番の配布は実行ごとに出る。そのため 11-02 と同じ衝突が、**同じリポジトリの実行ジョブの間で**起きる。#2436 のオーガナイザーは 1 台の SQLite(DBOS)に閉じるので、この衝突を防げない。 - -Factory では、実行ジョブ 1 本が #2436 のレーン 1 本に当たる(ジョブの中で動くのはスプリント 1 本の conductor で、tmux のレーンは並ばない)。したがって Factory に要るのは #2436 の上位のまとめ役ではない。**#2436 と同じオーガナイザーの役割を、レーン = 実行ジョブとして Factory の側で担う実装**である(2026-11-02 の利用者の指摘で定義を改めた)。 - -この課題が終わると、同じリポジトリで並行する Factory の実行の本番の配布は、Factory のオーガナイザーが 1 本にまとめる。承認ゲート 2 は 1 回で済む。承認ゲートの外の問いも、リポジトリごとに 1 か所に並ぶ。 - -## 依頼(原文) - -> 今回設計したオーガナイザーの挙動は同一コンテナ内の並列についての挙動ですが、Neural Development Factoryではさらにその上位にランナー間単位のオーガナイザーが必要になりそうです。検討して今の設計に組み込むか、別の設計PRを起こしてください - -(書き手: 利用者、日付: 2026-11-02。#2436 の設計 PR https://github.com/devbasex/ai-plugins/pull/2437 の承認ゲート 1 の前) - -> 用語が混乱してきた。1コンテナ=1ランナーっていう考え方でいいんだっけ? -> それとも複数コンテナ起動できるマシン単位で1ランナー? - -> 多分、今回のオーガナイザーの方が範囲が広くなっちゃってるね。「上位のオーガナイザー」の定義を変えないとダメかも - -> というより、全体のヒエラルキーを定義しないとダメかも。 - -> Factory のオーガナイザーと 1 台の中のオーガナイザー は、対等という以上に排他ですね。 -> Factoryで動くときには1台の中のオーガナイザーは動かない。 - -> Factoryのオーガナイザーは「1台の中のオーガナイザーの仕事全て+ランナー制御等固有の仕事」がその役割になるはず - -(書き手: 利用者、日付: 2026-11-02。続けて受けた 2 つの指摘) - -## 起票者の推定 - -利用者の原文に無く、起票者(conductor)と要求の工程が推して足した条件。S1〜S5 は起票者の判断の記録 09a63a07a4a5(`ask.py decide`)による。S6〜S11 は要求の工程が、人へ問わずに最も筋の通る案として選んだ。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | #2436 へ組み込まず、別の課題と別の設計 PR にする | `plugins/` から `factory/` を参照できない(`scripts/check-service-boundary.py`)。実行の基盤(DynamoDB・見回り)が違う。#2436 の設計は 6 本で 1,445 行ある | 含む(この課題の範囲そのもの) | -| S2 | Factory のオーガナイザーは常駐のプロセスにせず、Factory の API と 5 分ごとの見回りの中に置く | [docs/specifications/ndf-factory-dev-loops.md](https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-factory-dev-loops.md) の概要「常駐のプロセスを持たない」 | 非機能の条件(可用性・運用) | -| S3 | レーンからオーガナイザーへの依頼と答えの形(配布の依頼・承認ゲート 1/2 の依頼・問いの依頼)は #2436 が NDF の契約として公開し、この課題はランナーの翻訳で同じ形を受ける | #2436 の決定 21。下流の腐敗防止層の形(`ndf-factory-dev-loops.md` のコンテキスト) | 前提 1・受け入れ条件 10 | -| S4 | 本番の配布の中身は #2436 と同じく、承認資料を作る時点の `develop` のコミットの範囲で決める。本番の Pull Request の規則(head が `release/v<版>`、`develop` に含まれるコミットだけ)を共有する | #2436 の決定 13〜15 | 受け入れ条件 1・3・4 | -| S5 | 本番の配布は、#2436 の一時レーンに当たる専用の実行ジョブ(配布の実行ジョブ)で流す | #2436 の決定 19。Factory では実行ジョブがレーンの単位になる | 受け入れ条件 6 | -| S6 | Factory の実行と Factory の外の 1 台の並列(#2436)が同じリポジトリを同時に回すときは、禁止せず、GitHub の上の本番の Pull Request で排他にする。どちらかの本番の Pull Request(head が `release/v` で始まり、宛先が `main`)が開いている間は、もう一方は配布を組まずに待つ | 2 つのオーガナイザーは互いの記録を読めない(DBOS と DynamoDB)。両方が読めるのは GitHub の状態だけである。禁止すると、利用者の手元の作業を Factory が止めることになる | 受け入れ条件 8 | -| S7 | Factory の実行の中では、NDF は本番の配布を自分で組まず、配布の依頼を出して周回を締める。切り替えはランナーが NDF へ渡す設定で行う | #2436 の決定 11 は「ラッパーの外では今の振る舞いのまま」とする。このままでは Factory の実行の中の NDF が自分で配布する | 前提 2・受け入れ条件 10 | -| S8 | 同じリポジトリの実行が 1 本だけでも、配布は Factory のオーガナイザーを通す(1 本のときだけ今の経路に残さない) | 経路を 2 つ持つと、1 本から 2 本へ増えた瞬間の扱いが要る。1 本でも結果は今と同じく 1 回の承認と 1 本の本番の Pull Request になる | 受け入れ条件 2 | -| S9 | 承認ゲート 1 の依頼は設計 PR ごとに別の承認の依頼のままにし、まとめるのは一覧への並べ方だけにする | #2436 の決定 16。設計 PR は互いに独立で、コミットの範囲との結び付きが無い | 受け入れ条件 9 | -| S10 | `手元のランナー` の改名とアカウントの「プラン」の改名は、説明文・用語集・画面の文言を対象にし、コードの識別子・API の項目名・データの項目名は変えない | 識別子を変えると、API の互換と保存済みのデータの移行が要る。衝突は読み手の語の問題で、識別子は衝突していない(`runnerId`・`plan`) | 前提 4・受け入れ条件 14〜16 | -| S11 | 配布の実行ジョブの費用は、リポジトリを登録したアカウントの利用量に数える | 同じリポジトリの実行は同じアカウントに属する。範囲に入った実行の数で割ると、計算と表示の規則が増える | 前提 5・未決 1 | - -## 目的 - -同じリポジトリで並行する Factory の実行どうしで、本番の配布をぶつけずに 1 本へまとめる。承認と問いを、リポジトリごとに 1 か所で答えられるようにする。あわせて、#2436 の階層の定義(確定仕様 https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-parallel-organizer.md )が Factory の側へ割り当てた語の衝突 2 つを解く。 - -## 前提 - -- 前提 1: レーンとオーガナイザーの間の依頼と答えの形は、#2436(tmux の並列開発を計画から始める)が NDF の契約 `plugins/ndf/contracts/forms/parallel-request.json` として公開し、ndf v10.27.15 で配布した(`develop` に `plugins/ndf/scripts/parallel.py` と契約がある) -- 前提 2: NDF の側の切り替え口(本番の配布を自分で組まずに配布の依頼を出す)は、この課題が `plugins/ndf/` に作る。`NDF_ORGANIZER_DIR`・`NDF_ORGANIZER_LANE` で外のオーガナイザーのレーンと判定する(設計 `issues/issue-2439-design-ndf.md`) -- 前提 3: 承認の依頼の保存・通知・答えの経路は #2173 のものを使う。この課題は、まとめた配布を 1 件の承認の依頼(種類 ゲート 2)として載せる -- 前提 4: 衝突を解く改名は、`ndf-factory` の文脈の語だけを対象にする。`ndf-workflow` の「プラン」(`plan.json`)は変えない -- 前提 5: 配布の実行ジョブは、リポジトリを登録したアカウントの実行として数える(S11)。利用量の表示と料金の上限での扱いは未決 1 で決める -- 前提 6: 同じリポジトリで同時に動く実行の数は、今の上限(`concurrent_repo_runs`)のまま変えない -- 前提 7: 受け入れ条件 10 の「`parallel.py init`・`run` が `NDF_FACTORY_RUN` を見て断る」処理と、`NDF_FACTORY_RUN` の契約の宣言(`plugins/ndf/contracts/env/`)は、#2436 の配布(v10.27.15)に入っていない。確定仕様 ndf-parallel-organizer.md の「対象範囲」は「今の `parallel.py` は Factory の実行ジョブ(`NDF_FACTORY_RUN`)を見分けず、排他の検査は持たない」と書く。設計 `issues/issue-2439-design-ndf.md` は「`NDF_FACTORY_RUN` の宣言は #2436 が足す」としており、今の実装と食い違う。受け入れ条件 10 を満たすには、この 2 つを足す変更が要る(未決 5) - -## 対象範囲 - -含む: -- Factory のオーガナイザー: 同じリポジトリの実行が出す配布の依頼・承認ゲート 1/2 の依頼・問いの依頼を集め、答えを持ち主の実行へ返す -- ランナーの翻訳: #2436 の契約の依頼を、Factory の進捗の事象へ写す -- 配布の実行ジョブ: まとめた本番の配布を流し、結果を返して終わる専用の実行ジョブ -- 承認の依頼の一覧への載せ方(データと API。リポジトリごとに 1 つ) -- 語の衝突のうち Factory の側の 2 つの改名: マシン単位の `手元のランナー` と、アカウントの契約の種類の「プラン」 - -含まない: -- 1 台の中の並列のオーガナイザー(#2436) -- 実行を起こす時点の課題・マイルストーンの取り合い(#2347) -- 承認の依頼の保存と通知の経路(#2173) -- 複数のループの承認の依頼の一覧の画面(#2430) -- 手元のマシンの登録(#2189) -- 発表資料(`docs/presentations/`)と記事(`docs/articles/`)の語。どちらも書いた日時点の記録である -- ホットフィックス(並列の外の配布の経路) - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 実行が配布の依頼を出した | 実行の中の conductor が承認ゲート 2 の手前に達した | ランナーが依頼を送れずにジョブが終わった → 実行は終わりの理由つきで終わり、見回りが依頼の欠けを検出する(受け入れ条件 12) | 実行の変更が `develop` へマージされている | -| E2 | オーガナイザーが配布の範囲を決めた | E1 が届き、流れている配布が無い | `develop` の先頭を読めない → 次の見回りで読み直す | E1 | -| E3 | 承認資料をそろえた承認の依頼(ゲート 2)を作った | E2 | 資料の生成が失敗した → 問いの依頼にして人へ戻す | E2 | -| E4 | 人(または `pace: auto` の MVV 判定)が承認の依頼に答えた | E3 | 答えが届かない → 承認待ちのまま、実行は次の周回へ進む | E3 | -| E5 | 答えの時点の `develop` の先頭を比べ、進んでいたので承認資料を作り直して出し直した | E4 で、提示したコミットから `develop` が進んでいた | 出し直しへの答えでは比べない(1 回だけ) | E4 | -| E6 | 配布の実行ジョブを起こした | E4 または E5 の答えが「出す」 | ジョブが起動できない → 見回りが起こし直す | E4・E5 | -| E7 | 本番の Pull Request を 1 本作り、`main` へマージし、版を出した | E6 | 途中で止まった → `main` へのマージの前なら差し戻せる。後なら「続きから流す」だけを出す | E6 | -| E8 | 範囲に入ったすべての実行へ配布の結果を返した | E7 | 返し損ねた → 見回りが記録から返し直す。1 件の依頼に答えは 1 回 | E7 | -| E9 | 実行が問いの依頼を出した(承認ゲートの外) | conductor が人の判断を求めた | E1 と同じ | — | -| E10 | 問いの答えを持ち主の実行へ返した | 人が答えた | 持ち主の実行が終わっていた → 持ち主のループの次の周回へ渡す | E9 | -| E11 | Factory の外の本番の Pull Request が開いていたので、配布を組まずに待った | E2 の時点で、他のオーガナイザーの本番の Pull Request が開いている | 開いたまま動かない → 待ちの理由を一覧へ出す | E1 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 実行ジョブ | 実行基盤が 1 回起動するコンテナ 1 つ分の実行(用語集の `ndf-factory`)。Factory では実行ジョブ 1 本がレーン 1 本に当たる | -| ランナー | 1 つの実行ジョブの中で動くプログラム。1 コンテナ = 1 実行ジョブ = 1 ランナー(用語集の `ndf-factory`) | -| 承認の依頼 | 実行ジョブが人の答えを待って終わったときに作る依頼。種類はゲート 1・ゲート 2・判断の 3 つ(用語集の `ndf-factory`) | -| 見回り | 実行の制御が 5 分ごとに動かす処理(用語集の `ndf-factory`) | - -**オーガナイザー**は役割の名前で、並行するレーンの本番の配布を 1 本にまとめ、問いを集め、答えの後の作業を持ち主のレーンへ返す。実装は 1 台の中(#2436。レーン = tmux のセッション)と Factory(この課題。レーン = 同じリポジトリの実行ジョブ)の 2 つで、上下ではなく**排他**である。Factory の実行ジョブの中では 1 台の中のオーガナイザーは動かない(2026-11-02 の利用者の指摘)。Factory のオーガナイザーの仕事は、**1 台の中のオーガナイザーの仕事すべて**(配布を 1 本にまとめる・問いを集める・答えの後の作業を持ち主へ返す・共有の作業を一時レーンへ渡す)に、**Factory 固有の仕事**(ランナーの制御=実行ジョブの起動・停止・答えでの再開、実行の枠と手元のマシンの枠、ジョブをまたぐ状態の置き場)を足したものである(同日の利用者の指摘)。語の定義は #2436 の変更が用語集へ足す。「上位のオーガナイザー」という呼び方は使わず、「Factory のオーガナイザー」と呼ぶ。 - -この課題で新しく決める語(Factory のオーガナイザーの部品の名前、マシン単位の手元の受け手の新しい名前、アカウントの契約の種類の新しい名前)は設計で決め、同じ変更で用語集へ足す。 - -## 受け入れ条件 - -配布のまとめ: - -- [ ] 1. 同じリポジトリで並行する 2 本以上の実行が承認ゲート 2 に達したとき、本番の Pull Request は 1 本だけ作られる。範囲に入った実行は、どれも同じ本番の Pull Request と版を配布の結果として記録に持つ -- [ ] 2. 配布の依頼が届いた時点で流れている配布が無ければ、他の実行の依頼を待たずに配布の範囲を決め、承認の依頼を作る。同じリポジトリの実行が 1 本だけのときも同じ経路を通り、承認の依頼 1 件と本番の Pull Request 1 本になる -- [ ] 3. 配布の範囲は、承認資料を作る時点の `develop` の先頭のコミット(`main..`)で決まる。範囲に入った実行は、依頼を出したかどうかの印つきで承認資料に並ぶ -- [ ] 4. 承認の答えの時点で `develop` が提示したコミットから進んでいれば、承認資料を作り直し、加わった範囲を添えて承認の依頼を 1 回だけ出し直す。出し直しへの答えでは比べず、出し直しで承認したコミットを出す -- [ ] 5. 配布が流れている間に届いた依頼と `develop` へ入った変更は、今の配布の後に組む次の配布になる。同じリポジトリで同時に流れる配布は 1 本までである -- [ ] 6. 本番の配布は、範囲に入った実行のどれでもない、配布専用の実行ジョブで流れる。配布専用の実行ジョブは結果を返して終わり、同じリポジトリで同時に 1 本までである -- [ ] 7. 承認待ちの間も、範囲に入った実行は答えを待たずに次の周回へ進める(ジョブを終えて費用を止める今の形のまま) -- [ ] 8. 同じリポジトリに、Factory の外で作られた本番の Pull Request(head が `release/v` で始まり、宛先が `main`)が開いている間は、Factory のオーガナイザーは配布を組まずに待ち、待ちの理由を承認の依頼の一覧の記録に残す。閉じた後の最初の見回りで配布を組む - -承認と問いの集約: - -- [ ] 9. 承認ゲート 1 の依頼と承認ゲートの外の問い(種類 判断)は、リポジトリごとに 1 つの一覧として API から取れる。答えは持ち主の実行(持ち主の実行が終わっていれば、そのループの次の周回)へ届く -- [ ] 10. Factory の実行の中では #2436 のオーガナイザーを起動しない。ランナーは実行ジョブの環境に `NDF_FACTORY_RUN=1` を立て、`parallel.py init`・`run` はそれを見て断る(#2436 の設計の階層の文書)。実行の中の NDF は本番の配布を自分で組まず、配布の依頼を #2436 の契約の形で出し、ランナーがその依頼を進捗の事象へ写す -- [ ] 11. 承認ゲートの承認は、今と同じく人だけが確定できる。API と MCP からは、まとめた配布の承認の依頼と材料を読めるが、承認を確定できない(退行しない) - -障害と再開: - -- [ ] 12. Factory のオーガナイザーの処理が途中で止まっても、次の見回りが記録から続きを再開する。1 件の依頼に答えは 1 回だけ返り、同じ範囲で本番の Pull Request が 2 本作られることは無い -- [ ] 13. `main` へのマージの後に止まった配布は、差し戻しを選べず「続きから流す」だけを出す - -語の衝突: - -- [ ] 14. 用語集の `ndf-factory` の文脈で、マシン単位で複数の実行ジョブを受け取る手元の受け手に、「ランナー」と別の語が付く。もとの `手元のランナー` は廃止した語として用語集に残り、`glossary.py check` が拾う。Factory の確定仕様・`factory/` の説明文・画面の文言(`factory/web/src/i18n/`)に、廃止した語が残らない -- [ ] 15. 用語集の `ndf-factory` の文脈で、アカウントの契約の種類が「料金プラン」になる。`ndf-workflow` の「プラン」(`plan.json`)とは別の語として並ぶ。Factory の確定仕様・`factory/` の説明文・画面の文言に、契約の種類を指す裸の「プラン」が残らない -- [ ] 16. 改名で、コードの識別子・API の項目名・保存済みのデータの項目名は変わらない(`factory/` の型検査とテストが変更前と同じく通る) - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 可用性 | 常駐のプロセスを持たない。オーガナイザーの処理は API の handler と 5 分ごとの見回りの中で動き、止まっても次の見回りで続きから再開する(受け入れ条件 12) | -| 性能・拡張性 | 配布の依頼が届いてから配布の実行が起動されるまで、見回り 1 回分(5 分)以内。承認資料を作る時間は含めない(2026-11-03 の承認ゲート 1 で利用者が決めた読み方) | -| 運用・保守性 | 配布の範囲・出し直し・待ちの理由・配布専用の実行ジョブの結果が、リポジトリごとの記録に残り、後から辿れる | -| セキュリティ | 承認の確定は人だけ(受け入れ条件 11)。配布専用の実行ジョブは、範囲に入った実行と同じリポジトリの権限だけを持つ | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わる。承認の依頼の一覧の API が、リポジトリごとの一覧を返す。進捗の事象に、配布の依頼と結果の種類が加わる(加えるだけで既存の項目は変えない)。画面の文言の 2 語が変わる | -| データ | 加わる。リポジトリごとのオーガナイザーの進み具合(受けた依頼・配布の範囲・答え)を持つ行。既存のデータの移行は無い | -| 既存の振る舞い | 変わる。Factory の実行は本番の配布を自分で流さず、配布専用の実行ジョブが流す。実行が 1 本だけのときも、承認の依頼 1 件と本番の Pull Request 1 本の結果は今と同じ | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テストと型検査 | `cd factory && npm ci && npm run check`(契約の lint と、`contracts`・`web`・`api`・`runner` の型検査と vitest。オーガナイザーの集約・出し直し・待ち・再開は `api` の単体テストでメモリの表を使って確かめる) | -| 契約 | 構造チェック(リポジトリの根の `scripts/contracts_lib/model.py`)が、ランナーの翻訳と #2436 の契約の例の組を照らす | -| 境界 | `python3 scripts/check-service-boundary.py` が通る | -| 語 | `python3 plugins/ndf/scripts/glossary.py check` が当たり 0 件 | -| 手動確認 | 同じリポジトリで 2 本の開発ループを Factory で回し、承認ゲート 2 が 1 件だけ一覧に出て、本番の Pull Request が 1 本だけ作られることを、利用者がリリース後テストで見る | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | Factory のオーガナイザーとランナーの翻訳は `factory/` に置く。`plugins/` と `.claude-plugin/marketplace.json` は `factory/` を参照しない(`AGENTS.md` の「マーケットプレイスの構造」)。NDF の側の切り替え口は `plugins/ndf/` に置き、Factory を名指ししない | -| コーディング規約 | `factory/` の TypeScript の既存の書き方と、確定仕様 [ndf-factory-dev-loops.md](https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-factory-dev-loops.md) の集約・トランザクションの規則(1 つの処理が書き換える集約は 1 つ) | -| テスト戦略 | オーガナイザーの規則は API の単体テスト(メモリの表)で、ランナーの翻訳は契約の例の組で、2 本の実行の衝突はリリース後テストで確かめる | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | `factory/` のテストと型検査の実行、境界のチェック、用語集のチェック | -| 確認してから行う | 公開インタフェース(API・進捗の事象・画面の文言)の変更。承認ゲート 1・2 で人が見る | -| 行わない | #2436 の 1 台の中のオーガナイザーの変更、`main` への出し方の規則(#2436 の決定 15)の変更、コードの識別子の改名、発表資料と記事の書き換え | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 1. 配布専用の実行ジョブの費用を、利用量の表示と料金の上限(同時の数を含む)でどう数えるか。前提 5 はアカウントの利用量に数えるとだけ置いた | 利用者(承認ゲート 1) | 設計の承認まで | -| 2. NDF の側の切り替え口(配布を自分で組まずに依頼を出す)は、この課題で作ると設計で決めた(`issues/issue-2439-design-ndf.md`) | #2436 と #2439 の設計 | 決定済み | -| 3. オーガナイザーの進行の状態を DynamoDB の行で持つか、AWS の耐久ワークフローの部品(Step Functions など)を使うか。#2436 と同じく既存の部品を先に比べ、費用が増えるなら理由と代わりの案を示す | 設計 | 設計の承認まで | -| 4. マシン単位の手元の受け手の新しい名前 | 設計(用語集の語の選び方に従う) | 設計の承認まで | -| 5. `parallel.py init`・`run` の `NDF_FACTORY_RUN` の断りと `NDF_FACTORY_RUN` の契約の宣言(前提 7)を、この課題の NDF の側の変更に含めるか。「境界」の「#2436 の 1 台の中のオーガナイザーの変更は行わない」と合わせて決める | conductor(範囲の判断) | 実装の着手まで | diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 4ab7a0419..ec3b6ab16 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.16", - "description": "Claude Code plugin (v10.27.16): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "version": "10.27.18", + "description": "Claude Code plugin (v10.27.18): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "author": { "name": "takemi-ohama", "url": "https://github.com/takemi-ohama" diff --git a/plugins/ndf/.codex-plugin/plugin.json b/plugins/ndf/.codex-plugin/plugin.json index 7dcf58b5e..6d706bf3c 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.16", - "description": "Codex plugin (v10.27.16): 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.27.18", + "description": "Codex plugin (v10.27.18): 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", "skills": [ "./skills/cherry-pick-pr", "./skills/cross-refactoring", diff --git a/plugins/ndf/README.md b/plugins/ndf/README.md index 38f75b682..8849dee8c 100644 --- a/plugins/ndf/README.md +++ b/plugins/ndf/README.md @@ -350,7 +350,7 @@ agy models # 認証確認 ```text # 動く: 実体パスを示して読ませる -~/.codex/plugins/cache/ai-plugins/ndf/10.27.16/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.18/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 # 動かない: 明示起動 ($ は展開されない) $deploy qa/staging @@ -372,14 +372,14 @@ marketplace 経由でインストールした場合、Skill の実体は **ワ ```text $CODEX_HOME/plugins/cache////skills//SKILL.md # 既定 ($CODEX_HOME=~/.codex) の例: -# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.16/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.18/skills/deploy/SKILL.md ``` そのため「`deploy` の SKILL.md を探して読んで」のような曖昧な依頼は、Codex のファイル探索がワークスペース内に限られる状況では失敗しえます。**抑止した Skill は `$` が展開されない**ので、`codex plugin list` で実体パスを確認し、絶対パスを渡してください。 ```bash codex plugin list | grep 'ndf@ai-plugins' -# => ndf@ai-plugins installed, enabled 10.27.16 +# => ndf@ai-plugins installed, enabled 10.27.18 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/scripts/context-policy.py b/plugins/ndf/scripts/context-policy.py index 134e815bb..215b1a37b 100755 --- a/plugins/ndf/scripts/context-policy.py +++ b/plugins/ndf/scripts/context-policy.py @@ -31,6 +31,7 @@ from typing import Iterable, Mapping sys.path.insert(0, str(Path(__file__).resolve().parent / "lib")) +import context_calibrate # noqa: E402 import context_decisions as cd # noqa: E402 import context_policy as cp # noqa: E402 import step_result as sr # noqa: E402 @@ -58,24 +59,11 @@ def busy_seconds(times: Iterable[float | None]) -> float: def ledger_calls(row: Mapping) -> dict | None: """使用量の帳簿の 1 行を 1 つの呼び出しの形にする。usage が取れなければ None。""" - from usage_ledger import cache_writes - u = row.get("usage") if not isinstance(u, dict): return None - w5, w1h = cache_writes(u) - inp, read = int(u.get("input_tokens") or 0), int(u.get("cache_read_input_tokens") or 0) - return { - "ts": cd.parse_ts(row.get("at")), - "model": row.get("model") or None, - "inp": inp, - "out": int(u.get("output_tokens") or 0), - "read": read, - "w5": w5, - "w1h": w1h, - "context": inp + read + w5 + w1h, - "seconds": row.get("seconds") if isinstance(row.get("seconds"), (int, float)) else None, - } + seconds = row.get("seconds") if isinstance(row.get("seconds"), (int, float)) else None + return cd.usage_call(u, cd.parse_ts(row.get("at")), row.get("model") or None) | {"seconds": seconds} def _bucket_of(ts: float | None, restore_end: float | None, handoff_from: float | None) -> str: @@ -131,6 +119,43 @@ def _within(ts: float | None, windows: list[tuple[float, float]]) -> bool: return ts is not None and any(a <= ts < b for a, b in windows) +def _mark_bounds(marks: list[dict], by_next: bool) -> tuple[float | None, float | None]: + """(復元の区間の終わり, 引継ぎの区間の始まり)。復元は ndf-next で始まったセッションの最初の「復元完了」、引継ぎは最後の「引継ぎ開始」。""" + restored = [cd.parse_ts(m.get("at")) for m in marks if m.get("event") == cd.MARK_RESTORED] + handoffs = [cd.parse_ts(m.get("at")) for m in marks if m.get("event") == cd.MARK_HANDOFF] + restore_end = min((t for t in restored if t is not None), default=None) if by_next else None + handoff_from = max((t for t in handoffs if t is not None), default=None) + return restore_end, handoff_from + + +def _add_subagents(ndf: dict, transcript: str, excluded: set, seen: set, windows: list[tuple[float, float]]) -> None: + """会話の記録の隣の `subagents/` の呼び出しと秒を、NDF が起動した分へ足す。""" + sub = Path(str(transcript).removesuffix(".jsonl")) / "subagents" + for f in sorted(sub.glob("*.jsonl")) if sub.is_dir() else []: + if f.stem.removeprefix("agent-") in excluded: + continue # SubagentStop が帳簿へ書いたサブエージェント(session_id = agent_id)は帳簿の行で数える + sub_calls, _, _ = cd.read_calls(f, seen) + sub_calls = [c for c in sub_calls if _within(c["ts"], windows)] + for c in sub_calls: + _add_call(ndf, c) + ndf["seconds"] += busy_seconds(c["ts"] for c in sub_calls) + + +def _add_ledger(ndf: dict, sid: str, span: tuple, ledger_rows: list[dict], seen: set, windows: list[tuple[float, float]]) -> None: + """このセッションが親か、親の無い行で時刻がセッションの範囲に入る帳簿の行を、NDF が起動した分へ 1 回だけ足す。""" + for row in ledger_rows: + at = cd.parse_ts(row.get("at")) + mine = row.get("parent_session_id") == sid or ( + not row.get("parent_session_id") and span[0] is not None and at is not None and span[0] <= at <= span[1] + ) + key = ("ledger", row.get("session_id"), row.get("at"), row.get("step")) + c = ledger_calls(row) if mine and key not in seen and _within(at, windows) else None + if c is not None: + seen.add(key) + _add_call(ndf, c) + ndf["seconds"] += c["seconds"] or 0.0 + + def session_parts( sid: str, transcript: str | None, @@ -144,10 +169,7 @@ def session_parts( windows = ALL_TIME if windows is None else windows calls, compacts, by_next = cd.read_calls(transcript, seen) if transcript and sid not in excluded else ([], None, False) calls = [c for c in calls if _within(c["ts"], windows)] - restored = [cd.parse_ts(m.get("at")) for m in marks if m.get("event") == cd.MARK_RESTORED] - handoffs = [cd.parse_ts(m.get("at")) for m in marks if m.get("event") == cd.MARK_HANDOFF] - restore_end = min((t for t in restored if t is not None), default=None) if by_next else None - handoff_from = max((t for t in handoffs if t is not None), default=None) + restore_end, handoff_from = _mark_bounds(marks, by_next) parts = {b: _empty_totals() for b in BUCKETS} times: dict[str, list] = defaultdict(list) for c in calls: @@ -155,27 +177,9 @@ def session_parts( _add_call(parts[b], c) times[b].append(c["ts"]) if transcript: - sub = Path(str(transcript).removesuffix(".jsonl")) / "subagents" - for f in sorted(sub.glob("*.jsonl")) if sub.is_dir() else []: - if f.stem.removeprefix("agent-") in excluded: - continue # SubagentStop が帳簿へ書いたサブエージェント(session_id = agent_id)は下の帳簿の行で数える - sub_calls, _, _ = cd.read_calls(f, seen) - sub_calls = [c for c in sub_calls if _within(c["ts"], windows)] - for c in sub_calls: - _add_call(parts["ndf"], c) - parts["ndf"]["seconds"] += busy_seconds(c["ts"] for c in sub_calls) + _add_subagents(parts["ndf"], transcript, excluded, seen, windows) span = (min((c["ts"] for c in calls if c["ts"]), default=None), max((c["ts"] for c in calls if c["ts"]), default=None)) - for row in ledger_rows: - at = cd.parse_ts(row.get("at")) - mine = row.get("parent_session_id") == sid or ( - not row.get("parent_session_id") and span[0] is not None and at is not None and span[0] <= at <= span[1] - ) - key = ("ledger", row.get("session_id"), row.get("at"), row.get("step")) - c = ledger_calls(row) if mine and key not in seen and _within(at, windows) else None - if c is not None: - seen.add(key) - _add_call(parts["ndf"], c) - parts["ndf"]["seconds"] += c["seconds"] or 0.0 + _add_ledger(parts["ndf"], sid, span, ledger_rows, seen, windows) for b, ts in times.items(): parts[b]["seconds"] += busy_seconds(ts) return { @@ -188,27 +192,24 @@ def session_parts( } -def report_work(work: str, rows: list[dict], ledger_rows: list[dict] | None = None) -> dict: - """作業の識別子 1 つの集計(受け入れ条件 12・13、I12〜I14)。""" - ledger_rows = ledger_rows or [] - excluded = {r.get("session_id") for r in ledger_rows if r.get("session_id")} - filled = cd.fill_work(rows) - windows = work_windows(filled, work) - rows = [r for r in filled if (r.get("work") or "") == work] - sessions = cd.sessions_of(rows) - seen: set = set() +def _session_spans(sessions: dict, excluded: set, windows: dict) -> dict[str, tuple[float, float]]: + """セッションごとの (最初, 最後) の時刻。作業の区間の呼び出しと判定の記録の行から取る(印の割り当てに使う)。""" spans = {} - marks = [r for r in rows if r.get("event") in cd.MARKS] for sid, s in sessions.items(): calls, _, _ = cd.read_calls(s["transcript"], set()) if s["transcript"] and sid not in excluded else ([], None, False) calls = [c for c in calls if _within(c["ts"], windows.get(sid, ALL_TIME))] ts = [c["ts"] for c in calls if c["ts"]] + [cd.parse_ts(r.get("at")) for r in s["rows"] if cd.parse_ts(r.get("at"))] if ts: spans[sid] = (min(ts), max(ts)) - by_session = cd.attach_marks(marks, spans) + return spans + + +def _sum_sessions(sessions: dict, by_session: dict, ledger_rows: list[dict], excluded: set, windows: dict) -> dict: + """セッションごとの区分を合算し、自動圧縮の数と印の無い区間の数を数える。""" totals = {b: _empty_totals() for b in BUCKETS} compacts: int | None = 0 no_restore_mark = no_handoff_mark = 0 + seen: set = set() for sid, s in sessions.items(): summary = session_parts(sid, s["transcript"], by_session.get(sid, []), ledger_rows, excluded, seen, windows.get(sid)) for b in BUCKETS: @@ -218,33 +219,50 @@ def report_work(work: str, rows: list[dict], ledger_rows: list[dict] | None = No no_restore_mark += 0 if summary["restore_known"] else 1 restarted = any(r.get("verdict") == cp.RESTART for r in s["rows"]) no_handoff_mark += 1 if restarted and not summary["handoff_known"] else 0 + for b in BUCKETS: + totals[b]["cost"] = round(totals[b]["cost"]) + totals[b]["seconds"] = round(totals[b]["seconds"]) + return {"parts": totals, "compacts": compacts, "unknown_parts": {"restore": no_restore_mark, "handoff": no_handoff_mark}} + + +def _restarts_by_reason(rows: list[dict]) -> dict[str, int]: restarts: dict[str, int] = defaultdict(int) for r in rows: if r.get("verdict") == cp.RESTART: restarts[r.get("reason") or cd.UNKNOWN] += 1 - keep = defaultdict(int) + return dict(restarts) + + +def _rework(rows: list[dict]) -> int | str: + """「継続」の判定が 2 回以上ある工程の数(やり直し)。「継続」の判定が無ければ「不明」。""" + keep: dict[str, int] = defaultdict(int) for r in rows: if r.get("event", cd.EVENT_DECISION) == cd.EVENT_DECISION and r.get("verdict") == cp.CONTINUE and r.get("stage"): keep[r["stage"]] += 1 - for b in BUCKETS: - totals[b]["cost"] = round(totals[b]["cost"]) - totals[b]["seconds"] = round(totals[b]["seconds"]) + return sum(1 for n in keep.values() if n >= 2) if keep else cd.UNKNOWN + + +def report_work(work: str, rows: list[dict], ledger_rows: list[dict] | None = None) -> dict: + """作業の識別子 1 つの集計(受け入れ条件 12・13、I12〜I14)。""" + ledger_rows = ledger_rows or [] + excluded = {r.get("session_id") for r in ledger_rows if r.get("session_id")} + filled = cd.fill_work(rows) + windows = work_windows(filled, work) + rows = [r for r in filled if (r.get("work") or "") == work] + sessions = cd.sessions_of(rows) + by_session = cd.attach_marks([r for r in rows if r.get("event") in cd.MARKS], _session_spans(sessions, excluded, windows)) + summed = _sum_sessions(sessions, by_session, ledger_rows, excluded, windows) + restarts = _restarts_by_reason(rows) + compacts = summed["compacts"] return { "work": work, "sessions": len(sessions), - "parts": totals, - "unknown_parts": { - "restore": no_restore_mark, - "handoff": no_handoff_mark, - }, - "restarts": dict(restarts), + "parts": summed["parts"], + "unknown_parts": summed["unknown_parts"], + "restarts": restarts, "restart_count": sum(restarts.values()), "auto_compacts": cd.UNKNOWN if compacts is None or not sessions else compacts, - "quality": { - "rework": sum(1 for n in keep.values() if n >= 2) if keep else cd.UNKNOWN, - "review_fixes": cd.UNKNOWN, - "missed_requirements": cd.UNKNOWN, - }, + "quality": {"rework": _rework(rows), "review_fixes": cd.UNKNOWN, "missed_requirements": cd.UNKNOWN}, "no_policy_rows": sum(1 for r in rows if "policy" not in r), } @@ -291,24 +309,14 @@ def cmd_record(a) -> None: if a.verdict is not None and (a.verdict != cp.RESTART or a.reason not in cp.REASONS): sr.emit(sr.result(TOOL, "stopped", f"--verdict 再起動 と、理由({' / '.join(cp.REASONS)})を渡す"), sr.EXIT_UNREADABLE) root = cd.toplevel(None) - sid = os.environ.get("CLAUDE_CODE_SESSION_ID") if a.runtime == "claude" else None - row = cli_row(a.runtime, "record", sid or None, a.work, root, a.context) - if a.event: - row.update(event=a.event) - else: - row.update(stage="cut", verdict=cp.RESTART, reason=a.reason) + row = cd.record_row(a.runtime, "record", a.work, cp.resolve_policy(root), a.context, a.event, a.reason) if not cd.append_decision(row, cd.decisions_repo(root)): sr.emit(sr.result(TOOL, "stopped", "判定の記録へ書けない", metrics={"dir": str(cd.context_state_dir())}), sr.EXIT_VIOLATION) sr.emit(sr.result(TOOL, "ok", f"{a.event or a.verdict} を記録した({a.work})", [row])) -def cli_row(runtime: str, source: str, sid: str | None, work: str, root: str | None, context: int | None) -> dict: - """CLI と sprint-state.py が書く行(判定をしない。`band` は null)。""" - return cd.boundary_row(runtime=runtime, source=source, session_id=sid, work=work, context=context, applied=cp.resolve_policy(root)) - - def cmd_calibrate(a) -> None: - body, ok = cd.calibrate_repo(cd.toplevel(None), a.days) + body, ok = context_calibrate.calibrate_repo(cd.toplevel(None), a.days) if not ok: sr.emit(sr.result(TOOL, "stopped", "見込みのファイルへ書けない"), sr.EXIT_VIOLATION) sr.emit(sr.result(TOOL, "ok", f"見込みを較正した(標本 {body['samples']})", [body], {"samples": body["samples"]})) diff --git a/plugins/ndf/scripts/hook_lib/token_guard.py b/plugins/ndf/scripts/hook_lib/token_guard.py index 6e3e8d3ec..8507bf310 100644 --- a/plugins/ndf/scripts/hook_lib/token_guard.py +++ b/plugins/ndf/scripts/hook_lib/token_guard.py @@ -22,7 +22,6 @@ import json import os import re -import subprocess import time from collections import deque from contextlib import contextmanager @@ -330,12 +329,14 @@ def _context_deny(total: int, limit: int, nxt: str, notice: str | None, cause: s def _trial_cause(applied, verdict) -> str | None: """試行方式の拒否の句。現行方式は None(文面を変えない。I1)。""" - if applied.policy != "trial": + import context_policy as cp + + if applied.policy != cp.TRIAL: return None tag = "再起動の方針: 試行方式" - if verdict.reason == "容量": + if verdict.reason == cp.REASON_CAPACITY: return f"容量(窓 {verdict.effective_limit + applied.margin} − 余白 {applied.margin} = {verdict.effective_limit})を超えた({tag}、理由: 容量)" - if verdict.reason == "費用": + if verdict.reason == cp.REASON_COST: e = verdict.estimate.to_dict() return f"判断開始の値 {applied.decide_from} を超え、再起動のほうが安い({tag}、理由: 費用(続ける {e['continue']} / 再起動 {e['restart']}、入力の単価に換算))" return f"上限 {applied.limit}({tag}、理由: 運用上限)を超えた" @@ -451,14 +452,10 @@ def plan_hint(raw: dict) -> dict | None: return None if _field(raw, "tool_input", "subagent_type") not in ("ndf:supervisor", "ndf:supervisor-waits"): return None - try: - p = subprocess.run( - ["git", "-C", _field(raw, "cwd") or ".", "rev-parse", "--show-toplevel"], capture_output=True, text=True, timeout=5 - ) - except (OSError, subprocess.SubprocessError): - return None - root = p.stdout.strip() - if p.returncode != 0 or not root: + import context_decisions as cd + + root = cd.toplevel(_field(raw, "cwd") or None) + if not root: return None if not (Path(root, ".ndf", "supervise.json").is_file() or Path(root, ".ndf", "worktree.json").is_file()): return None diff --git a/plugins/ndf/scripts/lib/clock.py b/plugins/ndf/scripts/lib/clock.py index e20067bb4..ef3e8938b 100644 --- a/plugins/ndf/scripts/lib/clock.py +++ b/plugins/ndf/scripts/lib/clock.py @@ -73,6 +73,12 @@ def parse(value: Any, naive: str = "local") -> Optional[_dt.datetime]: return None +def epoch_seconds(value: Any, naive: str = "local") -> Optional[float]: + """`parse` で読んだ時刻の UNIX 時刻(秒)。読めなければ `None`。""" + parsed = parse(value, naive) + return None if parsed is None else parsed.timestamp() + + def seconds_between(start: Any, end: Any, naive: str = "local") -> Optional[float]: """`end − start` の秒。どちらかが読めなければ `None`。""" s, e = parse(start, naive), parse(end, naive) diff --git a/plugins/ndf/scripts/lib/context_calibrate.py b/plugins/ndf/scripts/lib/context_calibrate.py new file mode 100644 index 000000000..a22947637 --- /dev/null +++ b/plugins/ndf/scripts/lib/context_calibrate.py @@ -0,0 +1,109 @@ +"""費用の見込みの較正(`calibrate`。#2262 の決定 5・6)。 + +判定の記録と会話の記録の全体を読む重い側で、hook は使わない(hook が使うのは `context_decisions` の記録の追記と +小さな JSON の読み書きだけ。決定 6)。 +""" + +from __future__ import annotations + +import statistics +import time +from collections import defaultdict + +import context_decisions as cd +import context_policy as cp +from usage_ledger import now_utc + +CACHE_1H = 3600 # 呼び出しの間隔がこれを超えたら、1 時間のキャッシュが切れたとみなす + + +def _median_of(values: list[float]) -> float | None: + return float(statistics.median(values)) if values else None + + +def _mark_times(marks: list[dict], event: str) -> list[float]: + return sorted(t for m in marks if m.get("event") == event and (t := cd.parse_ts(m.get("at")))) + + +def _restore_sample(calls: list[dict], marks: list[dict], by_next: bool) -> tuple[int, int] | None: + """ndf-next で始まり復元の印があるセッションの、最初の復元までの (直前のコンテキスト量, 呼び出し数)。""" + restored = _mark_times(marks, cd.MARK_RESTORED) + if not (by_next and restored): + return None + before = [c for c in calls if c["ts"] is not None and c["ts"] <= restored[0]] + return (before[-1]["context"], len(before)) if before else None + + +def _handoff_sample(calls: list[dict], marks: list[dict]) -> tuple[int, int] | None: + """最後の引継ぎの印から後の (呼び出し数, 出力 token の合計)。印が無ければ None。""" + handoffs = _mark_times(marks, cd.MARK_HANDOFF) + if not handoffs: + return None + after = [c for c in calls if c["ts"] is not None and c["ts"] >= handoffs[-1]] + return len(after), sum(c["out"] for c in after) + + +def _stage_segments(calls: list[dict], rows: list[dict]) -> list[tuple[str, int, int]]: + """「継続」の判定で区切った工程ごとの (工程, 呼び出し数, キャッシュ切れの数)。最後の区間と空の区間は除く。""" + launches = sorted( + ((t, r["stage"]) for r in rows if r.get("stage") and r.get("verdict") == cp.CONTINUE and (t := cd.parse_ts(r.get("at")))), + ) + out = [] + for (start, stage), nxt in zip(launches, [t for t, _ in launches[1:]] + [None]): + seg = [c for c in calls if c["ts"] is not None and c["ts"] >= start and (nxt is None or c["ts"] < nxt)] + if not seg or nxt is None: + continue + gaps = sum(1 for a, b in zip(seg, seg[1:]) if b["ts"] - a["ts"] > CACHE_1H) + out.append((stage, len(seg), gaps)) + return out + + +def calibrate(rows: list[dict], since: float | None = None) -> dict: + """判定の記録と会話の記録から費用の見込みを作る(決定 5・6)。標本が 3 未満の値は None。""" + rows = [r for r in cd.fill_work(rows) if since is None or (cd.parse_ts(r.get("at")) or 0) >= since] + sessions = cd.sessions_of(rows) + restore_q, restore_n, handoff_h, handoff_out = [], [], [], [] + stage_k: dict[str, list] = defaultdict(list) + stage_m: dict[str, list] = defaultdict(list) + spans = {} + cache: dict[str, tuple] = {} + for sid, s in sessions.items(): + cache[sid] = cd.read_calls(s["transcript"], set()) if s["transcript"] else ([], None, False) + ts = [c["ts"] for c in cache[sid][0] if c["ts"]] + if ts: + spans[sid] = (min(ts), max(ts)) + by_session = cd.attach_marks([r for r in rows if r.get("event") in cd.MARKS], spans) + for sid, s in sessions.items(): + calls, _, by_next = cache[sid] + marks = by_session.get(sid, []) + if (restore := _restore_sample(calls, marks, by_next)) is not None: + restore_q.append(restore[0]) + restore_n.append(restore[1]) + if (handoff := _handoff_sample(calls, marks)) is not None: + handoff_h.append(handoff[0]) + handoff_out.append(handoff[1]) + for stage, n, gaps in _stage_segments(calls, s["rows"]): + for key in (stage, "*"): + stage_k[key].append(n) + stage_m[key].append(gaps) + + def med(values): + return _median_of(values) if len(values) >= cp.MIN_SAMPLES else None + + return { + "restore_context": med(restore_q), + "restore_calls": med(restore_n), + "handoff_calls": med(handoff_h), + "handoff_output": med(handoff_out), + "stage_calls": {k: _median_of(v) for k, v in stage_k.items()}, + "stage_expiries": {k: _median_of(v) for k, v in stage_m.items()}, + "samples": {"restore": len(restore_q), "handoff": len(handoff_h)} | {f"stage:{k}": len(v) for k, v in stage_k.items()}, + "at": now_utc(), + } + + +def calibrate_repo(root: str | None, days: int = 30) -> tuple[dict, bool]: + """そのリポジトリの直近 `days` 日の記録から見込みを較正して書く。(見込み, 書けたか)。""" + repo = cd.decisions_repo(root) + body = calibrate(cd.read_decisions(repo), since=time.time() - days * 86400) + return body, cd.write_cost_estimates(body, repo) diff --git a/plugins/ndf/scripts/lib/context_decisions.py b/plugins/ndf/scripts/lib/context_decisions.py index ad0da5352..8cd3670fd 100644 --- a/plugins/ndf/scripts/lib/context_decisions.py +++ b/plugins/ndf/scripts/lib/context_decisions.py @@ -19,23 +19,21 @@ import json import os import re -import statistics import time from collections import defaultdict import subprocess -from datetime import datetime from pathlib import Path -from typing import Mapping +from typing import Iterator, Mapping +import clock import context_policy as cp import jsonio from context_policy import REASONS # noqa: F401 sprint-state.py の --reason の選択肢 -from usage_ledger import now_utc +from usage_ledger import cache_writes, now_utc SCHEMA = 1 SESSION_DAYS = 7 LOCAL = "_local" -CACHE_1H = 3600 # 呼び出しの間隔がこれを超えたら、1 時間のキャッシュが切れたとみなす MARK_HANDOFF, MARK_RESTORED = "引継ぎ開始", "復元完了" MARKS = (MARK_HANDOFF, MARK_RESTORED) EVENT_DECISION = "判定" @@ -94,13 +92,7 @@ def stage_of(key: str) -> str: return "plan" -def parse_ts(value) -> float | None: - if not isinstance(value, str) or not value: - return None - try: - return datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() - except ValueError: - return None +parse_ts = clock.epoch_seconds # 記録の時刻(ISO 8601)を UNIX 時刻(秒)にする。読めなければ None # --- 判定の記録 ---------------------------------------------------------------------------------------- @@ -237,7 +229,7 @@ def session_file(sid: str, kind: str, env: Mapping | None = None) -> Path: def read_window(sid: str, env: Mapping | None = None) -> int | None: """statusline が書いた窓の大きさ。無い・形が違えば None(推測で埋めない。I7)。""" w = jsonio.read(session_file(sid, "window", env), missing={}, broken={}, want=dict).get("window") - return w if isinstance(w, int) and not isinstance(w, bool) and w > 0 else None + return w if cp.is_positive_int(w) else None def read_applied(sid: str, env: Mapping | None = None) -> dict: @@ -267,39 +259,60 @@ def write_applied(sid: str, applied, env: Mapping | None = None) -> bool: # --- 会話の記録 --------------------------------------------------------------------------------------- -def read_calls(path, seen: set | None = None) -> tuple[list[dict], int | None, bool]: - """会話の記録の API 呼び出し(同じ `message.id` は 1 回。I13)と、自動圧縮の回数と、ndf-next で始まったか。 +def usage_call(usage: Mapping, ts: float | None, model) -> dict: + """API 呼び出し 1 回の usage を {ts, model, inp, out, read, w5, w1h, context} にする(会話の記録と使用量の帳簿で共通)。""" + w5, w1h = cache_writes(usage) + inp, out, read = (int(usage.get(k) or 0) for k in ("input_tokens", "output_tokens", "cache_read_input_tokens")) + return {"ts": ts, "model": model, "inp": inp, "out": out, "read": read, "w5": w5, "w1h": w1h, "context": inp + read + w5 + w1h} - 同じ `message.id` の行は最後の usage を採る(ストリーミングで後の行ほど output_tokens が増える。scripts/token-usage.py と同じ)。 - 時刻は最初に現れた行のもの。`seen` に前のファイルで数えた id があれば、このファイルでは数えない。 - 呼び出しは {ts, model, inp, out, read, w5, w1h, context}。読めなければ ([], None, False)。""" - from usage_ledger import cache_writes +def _is_auto_compact(e: dict) -> bool: + meta = e.get("compactMetadata") + return e.get("type") == "system" and e.get("subtype") == "compact_boundary" and isinstance(meta, dict) and meta.get("trigger") == "auto" - seen = set() if seen is None else seen - calls: list[dict] = [] - here: dict = {} # このファイルで数えた message.id → calls の位置 - compacts = 0 - first_user: str | None = None + +def _user_text(e: dict) -> str | None: + """利用者の発言(メタの行を除く)の本文。文字列でなければ JSON にする。発言でなければ None。""" + if e.get("type") != "user" or e.get("isMeta"): + return None + content = (e.get("message") if isinstance(e.get("message"), dict) else {}).get("content") + return content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) + + +def _json_lines(path) -> Iterator[dict] | None: + """JSONL の辞書の行を 1 行ずつ返す(全行を辞書のまま保持しない)。読めなければ None。壊れた行と辞書でない行は飛ばす。""" try: lines = Path(path).read_text(encoding="utf-8", errors="replace").splitlines() except OSError: + return None + return (e for e in map(_loads_or_none, lines) if isinstance(e, dict)) + + +def _loads_or_none(line: str): + try: + return json.loads(line) + except ValueError: + return None + + +def read_calls(path, seen: set | None = None) -> tuple[list[dict], int | None, bool]: + """会話の記録の API 呼び出し(同じ `message.id` は 1 回。I13)と、自動圧縮の回数と、ndf-next で始まったか。 + 同じ `message.id` の行は最後の usage を採る(ストリーミングで後の行ほど output_tokens が増える。scripts/token-usage.py と同じ)。 + 時刻は最初に現れた行のもの。`seen` に前のファイルで数えた id があれば、このファイルでは数えない。 + 呼び出しは {ts, model, inp, out, read, w5, w1h, context}。読めなければ ([], None, False)。""" + seen = set() if seen is None else seen + entries = _json_lines(path) + if entries is None: return [], None, False - for line in lines: - try: - e = json.loads(line) - except ValueError: - continue - if not isinstance(e, dict): - continue - if e.get("type") == "system" and e.get("subtype") == "compact_boundary": - meta = e.get("compactMetadata") - compacts += 1 if isinstance(meta, dict) and meta.get("trigger") == "auto" else 0 + calls, here = [], {} # here はこのファイルで数えた message.id → calls の位置 + compacts, first_user = 0, None + for e in entries: # 呼び出し・最初の利用者発言・自動圧縮を 1 回の走査で集める + if _is_auto_compact(e): + compacts += 1 continue + if first_user is None: + first_user = _user_text(e) msg = e.get("message") if isinstance(e.get("message"), dict) else {} - if e.get("type") == "user" and first_user is None and not e.get("isMeta"): - content = msg.get("content") - first_user = content if isinstance(content, str) else json.dumps(content, ensure_ascii=False) usage = msg.get("usage") if e.get("type") != "assistant" or not isinstance(usage, dict): continue @@ -307,18 +320,7 @@ def read_calls(path, seen: set | None = None) -> tuple[list[dict], int | None, b if mid in seen and mid not in here: continue seen.add(mid) - w5, w1h = cache_writes(usage) - inp, read = int(usage.get("input_tokens") or 0), int(usage.get("cache_read_input_tokens") or 0) - call = { - "ts": parse_ts(e.get("timestamp")), - "model": msg.get("model"), - "inp": inp, - "out": int(usage.get("output_tokens") or 0), - "read": read, - "w5": w5, - "w1h": w1h, - "context": inp + read + w5 + w1h, - } + call = usage_call(usage, parse_ts(e.get("timestamp")), msg.get("model")) if mid in here: call["ts"] = calls[here[mid]]["ts"] or call["ts"] calls[here[mid]] = call @@ -357,99 +359,11 @@ def attach_marks(marks: list[dict], spans: dict[str, tuple[float, float]]) -> di return out -# --- 較正 --------------------------------------------------------------------------------------------- - - -def _median_of(values: list[float]) -> float | None: - return float(statistics.median(values)) if values else None - - -def _mark_times(marks: list[dict], event: str) -> list[float]: - return sorted(t for m in marks if m.get("event") == event and (t := parse_ts(m.get("at")))) - - -def _restore_sample(calls: list[dict], marks: list[dict], by_next: bool) -> tuple[int, int] | None: - """ndf-next で始まり復元の印があるセッションの、最初の復元までの (直前のコンテキスト量, 呼び出し数)。""" - restored = _mark_times(marks, MARK_RESTORED) - if not (by_next and restored): - return None - before = [c for c in calls if c["ts"] is not None and c["ts"] <= restored[0]] - return (before[-1]["context"], len(before)) if before else None - - -def _handoff_sample(calls: list[dict], marks: list[dict]) -> tuple[int, int] | None: - """最後の引継ぎの印から後の (呼び出し数, 出力 token の合計)。印が無ければ None。""" - handoffs = _mark_times(marks, MARK_HANDOFF) - if not handoffs: - return None - after = [c for c in calls if c["ts"] is not None and c["ts"] >= handoffs[-1]] - return len(after), sum(c["out"] for c in after) - - -def _stage_segments(calls: list[dict], rows: list[dict]) -> list[tuple[str, int, int]]: - """「継続」の判定で区切った工程ごとの (工程, 呼び出し数, キャッシュ切れの数)。最後の区間と空の区間は除く。""" - launches = sorted( - ((t, r["stage"]) for r in rows if r.get("stage") and r.get("verdict") == cp.CONTINUE and (t := parse_ts(r.get("at")))), - ) - out = [] - for (start, stage), nxt in zip(launches, [t for t, _ in launches[1:]] + [None]): - seg = [c for c in calls if c["ts"] is not None and c["ts"] >= start and (nxt is None or c["ts"] < nxt)] - if not seg or nxt is None: - continue - gaps = sum(1 for a, b in zip(seg, seg[1:]) if b["ts"] - a["ts"] > CACHE_1H) - out.append((stage, len(seg), gaps)) - return out - - -def calibrate(rows: list[dict], since: float | None = None) -> dict: - """判定の記録と会話の記録から費用の見込みを作る(決定 5・6)。標本が 3 未満の値は None。""" - rows = [r for r in fill_work(rows) if since is None or (parse_ts(r.get("at")) or 0) >= since] - sessions = sessions_of(rows) - restore_q, restore_n, handoff_h, handoff_out = [], [], [], [] - stage_k: dict[str, list] = defaultdict(list) - stage_m: dict[str, list] = defaultdict(list) - spans = {} - cache: dict[str, tuple] = {} - for sid, s in sessions.items(): - cache[sid] = read_calls(s["transcript"], set()) if s["transcript"] else ([], None, False) - ts = [c["ts"] for c in cache[sid][0] if c["ts"]] - if ts: - spans[sid] = (min(ts), max(ts)) - by_session = attach_marks([r for r in rows if r.get("event") in MARKS], spans) - for sid, s in sessions.items(): - calls, _, by_next = cache[sid] - marks = by_session.get(sid, []) - if (restore := _restore_sample(calls, marks, by_next)) is not None: - restore_q.append(restore[0]) - restore_n.append(restore[1]) - if (handoff := _handoff_sample(calls, marks)) is not None: - handoff_h.append(handoff[0]) - handoff_out.append(handoff[1]) - for stage, n, gaps in _stage_segments(calls, s["rows"]): - for key in (stage, "*"): - stage_k[key].append(n) - stage_m[key].append(gaps) - - def med(values): - return _median_of(values) if len(values) >= cp.MIN_SAMPLES else None - - return { - "restore_context": med(restore_q), - "restore_calls": med(restore_n), - "handoff_calls": med(handoff_h), - "handoff_output": med(handoff_out), - "stage_calls": {k: _median_of(v) for k, v in stage_k.items()}, - "stage_expiries": {k: _median_of(v) for k, v in stage_m.items()}, - "samples": {"restore": len(restore_q), "handoff": len(handoff_h)} | {f"stage:{k}": len(v) for k, v in stage_k.items()}, - "at": now_utc(), - } - - -def calibrate_repo(root: str | None, days: int = 30) -> tuple[dict, bool]: - """そのリポジトリの直近 `days` 日の記録から見込みを較正して書く。(見込み, 書けたか)。""" - repo = decisions_repo(root) - body = calibrate(read_decisions(repo), since=time.time() - days * 86400) - return body, write_cost_estimates(body, repo) +def record_row(runtime: str, source: str, work: str, applied, context: int | None = None, event: str | None = None, reason=None) -> dict: + """CLI と sprint-state.py が書く行(判定をしない。`band` は null)。`event` を渡せば印の行、渡さなければ再起動の行。""" + sid = os.environ.get("CLAUDE_CODE_SESSION_ID") if runtime == "claude" else None + row = boundary_row(runtime=runtime, source=source, session_id=sid or None, work=work, context=context, applied=applied) + return row | ({"event": event} if event else {"stage": "cut", "verdict": cp.RESTART, "reason": reason}) def record_restart(work: str, reason: str | None = None, cwd: str | None = None, runtime: str = "claude") -> bool: @@ -464,11 +378,10 @@ def record_restart(work: str, reason: str | None = None, cwd: str | None = None, if reason is None: prev = last_denial(read_decisions(repo), work) reason = (prev or {}).get("reason") or (cp.REASON_CUTPOINT if applied.policy == cp.CURRENT else cp.REASON_MANUAL) - sid = os.environ.get("CLAUDE_CODE_SESSION_ID") if runtime == "claude" else None - row = boundary_row(source="sprint-state", runtime=runtime, session_id=sid or None, work=work, applied=applied) - row.update(stage="cut", verdict=cp.RESTART, reason=reason) - ok = append_decision(row, repo) - calibrate_repo(root) + ok = append_decision(record_row(runtime, "sprint-state", work, applied, reason=reason), repo) + import context_calibrate # 較正は会話の記録の全体を読む重い側。循環を避けて関数の中で読む + + context_calibrate.calibrate_repo(root) return ok except Exception: # noqa: BLE001 — 記録の失敗で ndf-next を止めない return False diff --git a/plugins/ndf/scripts/lib/context_policy.py b/plugins/ndf/scripts/lib/context_policy.py index 88ffd7701..29529a5ae 100644 --- a/plugins/ndf/scripts/lib/context_policy.py +++ b/plugins/ndf/scripts/lib/context_policy.py @@ -130,7 +130,7 @@ def to_dict(self) -> dict: # --- 適用値の解決 ------------------------------------------------------------------------------------ -def _positive_int(v) -> bool: +def is_positive_int(v) -> bool: return isinstance(v, int) and not isinstance(v, bool) and v > 0 @@ -147,7 +147,7 @@ def parse_context_setting(data: dict) -> tuple[dict, str | None]: if not isinstance(trial, dict): return {}, f".ndf/{SETTING}: trial がオブジェクトでない" decide_from, limit = trial.get("decide_from", TRIAL_DECIDE_FROM), trial.get("limit", TRIAL_LIMIT) - if not (_positive_int(decide_from) and _positive_int(limit)): + if not (is_positive_int(decide_from) and is_positive_int(limit)): return {}, f".ndf/{SETTING}: trial.decide_from / trial.limit が正の整数でない" if decide_from > limit: return {}, f".ndf/{SETTING}: trial.decide_from({decide_from})が trial.limit({limit})より大きい" @@ -170,27 +170,24 @@ def read_context_setting(root) -> tuple[dict, str | None]: return (({}, why) if why else parse_context_setting(data)) if data or why else ({}, None) -def resolve_policy(root=None, env: Mapping | None = None, cli: Mapping | None = None) -> AppliedPolicy: - """適用値を解決する(I2・I3)。cli は {"policy": ..., "limit": ...}(値が None のキーは無視)。 - - `NDF_CONTEXT_LIMIT` は適用中の方針の運用上限を上書きし、判断開始の値がそれを上回れば同じ値へ下げる(決定 2)。 - モデル名と窓の大きさは方針を選ばない(I15)。""" - if env is None: # 読む変数を名前で書く(契約の検査が読みを拾う) - env = {"NDF_CONTEXT_POLICY": os.environ.get("NDF_CONTEXT_POLICY"), "NDF_CONTEXT_LIMIT": os.environ.get("NDF_CONTEXT_LIMIT")} - cli = cli or {} - setting, error = read_context_setting(root) - sources = {"policy": "default", "decide_from": "default", "limit": "default", "margin": "default"} - policy = CURRENT +def _resolve_policy_name(setting: dict, env: Mapping, cli: Mapping) -> tuple[str, str, str | None]: + """方針名を 既定 < config < env < CLI で重ね、(方針名, 出所, 無視した環境変数の記述) を返す。""" + policy, source, ignored = CURRENT, "default", None if setting: - policy = setting["policy"] - sources["policy"] = "config" + policy, source = setting["policy"], "config" env_policy = (env.get("NDF_CONTEXT_POLICY") or "").strip() if env_policy in POLICIES: - policy, sources["policy"] = env_policy, "env" + policy, source = env_policy, "env" elif env_policy: - sources["ignored"] = f"NDF_CONTEXT_POLICY={env_policy}" + ignored = f"NDF_CONTEXT_POLICY={env_policy}" if cli.get("policy") in POLICIES: - policy, sources["policy"] = cli["policy"], "cli" + policy, source = cli["policy"], "cli" + return policy, source, ignored + + +def _resolve_thresholds(policy: str, setting: dict, env: Mapping, cli: Mapping) -> tuple[int, int, int, dict]: + """方針に応じた判断開始・運用上限・余白を重ね、(decide_from, limit, margin, 出所) を返す。""" + sources = {"decide_from": "default", "limit": "default", "margin": "default"} margin = setting.get("margin", MARGIN) if "margin" in setting and policy == TRIAL: sources["margin"] = "config" @@ -203,11 +200,28 @@ def resolve_policy(root=None, env: Mapping | None = None, cli: Mapping | None = env_limit = env.get("NDF_CONTEXT_LIMIT") or "" if env_limit.isdigit() and int(env_limit) > 0: limit, sources["limit"] = int(env_limit), "env" - if _positive_int(cli.get("limit")): + if is_positive_int(cli.get("limit")): limit, sources["limit"] = cli["limit"], "cli" if policy == CURRENT or decide_from > limit: decide_from = limit sources["decide_from"] = sources["limit"] + return decide_from, limit, margin, sources + + +def resolve_policy(root=None, env: Mapping | None = None, cli: Mapping | None = None) -> AppliedPolicy: + """適用値を解決する(I2・I3)。cli は {"policy": ..., "limit": ...}(値が None のキーは無視)。 + + `NDF_CONTEXT_LIMIT` は適用中の方針の運用上限を上書きし、判断開始の値がそれを上回れば同じ値へ下げる(決定 2)。 + モデル名と窓の大きさは方針を選ばない(I15)。""" + if env is None: # 読む変数を名前で書く(契約の検査が読みを拾う) + env = {"NDF_CONTEXT_POLICY": os.environ.get("NDF_CONTEXT_POLICY"), "NDF_CONTEXT_LIMIT": os.environ.get("NDF_CONTEXT_LIMIT")} + cli = cli or {} + setting, error = read_context_setting(root) + policy, policy_source, ignored = _resolve_policy_name(setting, env, cli) + decide_from, limit, margin, threshold_sources = _resolve_thresholds(policy, setting, env, cli) + sources = {"policy": policy_source, **threshold_sources} + if ignored: + sources["ignored"] = ignored return AppliedPolicy(policy, decide_from, limit, margin, sources, error) diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index fb9521815..1ba067b1e 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -244,42 +244,37 @@ def add_env_exclusions(rec: dict, n: int) -> None: rec.setdefault("excluded", []).append({"number": num, "reason": EXCLUDE_REASON, "lap": n}) -def run_selection(env: Env, rec: dict, before: dict, n: int, lap: dict | None = None) -> dict: - add_env_exclusions(rec, n) - code, rank = env.rank(env.main_dir()) - if code not in (0, PAUSE_RANK): - return refusal_order(f"upkeep.py rank が {code} を返した。選定を推さない: {rank.get('summary', '')}") - try: - descriptions = env.milestones(rec.get("repo") or "") - except OSError as e: - return refusal_order(str(e)) - got = choose_issues(env, rec, rank, n, descriptions, set((lap or {}).get("created") or [])) - if got["milestone"] is None: - if lap is not None: - rec["laps"].remove(lap) # スプリントを組む前の周回は取り下げ、除外の一覧(excluded の lap)を次の選定に残す - if has_held_work(rec): # 選べる課題が無く答えを待つ作業だけが残る: no_issue で止まらず、見直しも挟まない(異常系 6) - return stop_and_report( - env, rec, before, waiting_only(rec, "no_selectable"), "手元の作業が待ちだけになった", {"selection": got["selection"]} - ) - cond = "no_issue" if got["candidates"] == 0 else "outside_mvv" - if cond == "no_issue" and rec.get("review_first") != len(rec["laps"]): - return review_first(env, rec, before, got) - reasons: dict = {} - for r in got["selection"]: - reasons[r["reason"]] = reasons.get(r["reason"], 0) + 1 - values = ( - {"milestones": rec.get("milestones") or (rank.get("metrics") or {}).get("order", [])} - if cond == "no_issue" - else {"reasons": reasons} - ) +def _stop_without_issue(env: Env, rec: dict, before: dict, got: dict, rank: dict, lap: dict | None) -> dict: + """選べる課題が無いときの止まり方(待ちだけ・見直しを先に・no_issue・outside_mvv)。""" + if lap is not None: + rec["laps"].remove(lap) # スプリントを組む前の周回は取り下げ、除外の一覧(excluded の lap)を次の選定に残す + if has_held_work(rec): # 選べる課題が無く答えを待つ作業だけが残る: no_issue で止まらず、見直しも挟まない(異常系 6) return stop_and_report( - env, - rec, - before, - {"condition": cond, "values": values}, - "着手できる課題が無い" if cond == "no_issue" else "候補がすべて MVV の外・人の判断待ちとして外れた", - {"selection": got["selection"]}, + env, rec, before, waiting_only(rec, "no_selectable"), "手元の作業が待ちだけになった", {"selection": got["selection"]} ) + cond = "no_issue" if got["candidates"] == 0 else "outside_mvv" + if cond == "no_issue" and rec.get("review_first") != len(rec["laps"]): + return review_first(env, rec, before, got) + reasons: dict = {} + for r in got["selection"]: + reasons[r["reason"]] = reasons.get(r["reason"], 0) + 1 + values = ( + {"milestones": rec.get("milestones") or (rank.get("metrics") or {}).get("order", [])} + if cond == "no_issue" + else {"reasons": reasons} + ) + return stop_and_report( + env, + rec, + before, + {"condition": cond, "values": values}, + "着手できる課題が無い" if cond == "no_issue" else "候補がすべて MVV の外・人の判断待ちとして外れた", + {"selection": got["selection"]}, + ) + + +def _open_lap(env: Env, rec: dict, before: dict, got: dict, rank: dict, n: int, lap: dict | None) -> dict: + """周回 n の行を作って(または取り直した周回を書き換えて)記録する。選んだ課題の直しを済みにし、スプリントの MVV を置く。""" d = loop_dir(env, rec["name"]) / f"lap-{n}" new = lap or {"n": n, "outcome": IN_PROGRESS, "started_at": env.now(), "created": [], "gates": [], "deferred_issues": []} new.update( @@ -300,6 +295,22 @@ def run_selection(env: Env, rec: dict, before: dict, n: int, lap: dict | None = d.mkdir(parents=True, exist_ok=True) (d / "mvv.md").write_text(got["sprint_mvv"], encoding="utf-8") write_record(env, rec, before) + return new + + +def run_selection(env: Env, rec: dict, before: dict, n: int, lap: dict | None = None) -> dict: + add_env_exclusions(rec, n) + code, rank = env.rank(env.main_dir()) + if code not in (0, PAUSE_RANK): + return refusal_order(f"upkeep.py rank が {code} を返した。選定を推さない: {rank.get('summary', '')}") + try: + descriptions = env.milestones(rec.get("repo") or "") + except OSError as e: + return refusal_order(str(e)) + got = choose_issues(env, rec, rank, n, descriptions, set((lap or {}).get("created") or [])) + if got["milestone"] is None: + return _stop_without_issue(env, rec, before, got, rank, lap) + new = _open_lap(env, rec, before, got, rank, n, lap) chosen = selected_issues(new) nxt = f"モードと版を決め、対象外は loop exclude で外し、python3 {shlex.quote(str(SPRINT_STATE))} loop sprint {rec['name']} --mode <モード> --dev <開発版> を打つ" update_handoff(env, rec, f"周回 {n} の課題を選んだ: {' '.join(f'#{x}' for x in chosen)}", resume_command(rec), nxt) diff --git a/plugins/ndf/scripts/tests/test_context_policy_hook.py b/plugins/ndf/scripts/tests/test_context_policy_hook.py index 6becaf95b..4cf4fba54 100644 --- a/plugins/ndf/scripts/tests/test_context_policy_hook.py +++ b/plugins/ndf/scripts/tests/test_context_policy_hook.py @@ -15,6 +15,7 @@ SCRIPTS = Path(__file__).resolve().parents[1] sys.path.insert(0, str(SCRIPTS / "lib")) +import context_calibrate # noqa: E402 import context_decisions as cd # noqa: E402 CLI = SCRIPTS / "context-policy.py" @@ -203,6 +204,45 @@ def test_record_marks_and_restart(tmp_path, ctx): assert (restart["verdict"], restart["reason"]) == ("再起動", "独立性") +def test_record_row_columns_and_stdout(tmp_path, ctx): + # CLI の記録の行の列(現状固定。#2422)。標準出力の行と記録の行は同じ + out = cli( + "record", + "--verdict", + "再起動", + "--reason", + "独立性", + "--work", + "#7", + "--context", + "1234", + ctx=ctx, + cwd=str(tmp_path), + CLAUDE_CODE_SESSION_ID="s9", + ) + (row,) = rows(ctx) + assert json.loads(out.stdout)["items"] == [row] + picked = { + k: row[k] for k in ("runtime", "source", "session_id", "work", "context", "stage", "verdict", "reason", "band", "event", "policy") + } + assert picked == { + "runtime": "claude", + "source": "record", + "session_id": "s9", + "work": "#7", + "context": 1234, + "stage": "cut", + "verdict": "再起動", + "reason": "独立性", + "band": None, + "event": "判定", + "policy": "current", + } + cli("record", "--event", "復元完了", "--work", "#7", "--runtime", "codex", ctx=ctx, cwd=str(tmp_path), CLAUDE_CODE_SESSION_ID="s9") + mark = rows(ctx)[-1] + assert (mark["runtime"], mark["session_id"], mark["event"], mark["stage"], mark["reason"]) == ("codex", None, "復元完了", None, None) + + def call(ts: str, mid: str, out: int = 10, read: int = 1_000, model: str = "claude-opus-5-5") -> dict: usage = {"input_tokens": 100, "output_tokens": out, "cache_read_input_tokens": read, "cache_creation_input_tokens": 0} return {"type": "assistant", "timestamp": ts, "message": {"id": mid, "model": model, "usage": usage, "content": []}} @@ -333,10 +373,10 @@ def test_calibrate_needs_three_samples(tmp_path): mark("復元完了", f"2026-11-0{i + 1}T00:00:30Z", f"s{i}"), mark("引継ぎ開始", f"2026-11-0{i + 1}T00:02:30Z", f"s{i}"), ] - body = cd.calibrate(recs) + body = context_calibrate.calibrate(recs) assert body["samples"]["restore"] == 3 and body["restore_calls"] == 1.0 assert body["handoff_calls"] == 1.0 and body["handoff_output"] == 50.0 - assert cd.calibrate(recs[:8])["restore_context"] is None + assert context_calibrate.calibrate(recs[:8])["restore_context"] is None def test_sprint_state_next_records_restart(tmp_path, ctx): @@ -356,7 +396,7 @@ def test_sprint_state_next_records_restart(tmp_path, ctx): def _calibrated(recs, since=None) -> dict: - body = cd.calibrate(recs, since=since) + body = context_calibrate.calibrate(recs, since=since) assert isinstance(body.pop("at"), str) return body @@ -430,3 +470,105 @@ def test_calibrate_characterizes_since_and_unmatched_sessions(tmp_path): } since = cd.parse_ts("2026-11-02T00:00:00Z") assert _calibrated(recs, since=since)["samples"] == {"restore": 0, "handoff": 0, "stage:plan": 2, "stage:*": 2} + + +def _cached(ts: str, mid: str, **over) -> dict: + """キャッシュの書き込みを 5 分と 1 時間に分けた usage を持つ呼び出し。""" + c = call(ts, mid) + c["message"]["usage"].update( + {"cache_creation_input_tokens": 70, "cache_creation": {"ephemeral_5m_input_tokens": 30, "ephemeral_1h_input_tokens": 40}}, **over + ) + return c + + +def test_transcript_and_ledger_build_the_same_call(tmp_path): + # 会話の記録と使用量の帳簿は、同じ usage から同じキーと値の呼び出しを作る(現状固定。#2422) + m = load_cli() + a = _cached("2026-11-01T00:00:00Z", "m1") + calls, compacts, by_next = cd.read_calls(write_transcript(tmp_path / "s1.jsonl", [a])) + want = { + "ts": cd.parse_ts("2026-11-01T00:00:00Z"), + "model": "claude-opus-5-5", + "inp": 100, + "out": 10, + "read": 1000, + "w5": 30, + "w1h": 40, + "context": 1170, + } + assert (calls, compacts, by_next) == ([want], 0, False) + row = {"at": "2026-11-01T00:00:00Z", "model": "claude-opus-5-5", "usage": a["message"]["usage"], "seconds": 4} + assert m.ledger_calls(row) == want | {"seconds": 4} + assert m.ledger_calls({**row, "model": "", "seconds": "x"}) == want | {"model": None, "seconds": None} + + +def test_transcript_counts_auto_compacts_and_the_handoff_start(tmp_path): + lines = [ + {"type": "user", "isMeta": True, "message": {"content": "meta"}}, + {"type": "user", "message": {"content": [{"type": "text", "text": "cat .ndf/handoff/x.md"}]}}, + {"type": "system", "subtype": "compact_boundary", "compactMetadata": {"trigger": "auto"}}, + {"type": "system", "subtype": "compact_boundary", "compactMetadata": {"trigger": "manual"}}, + _cached("2026-11-01T00:00:00Z", "m1"), + ] + calls, compacts, by_next = cd.read_calls(write_transcript(tmp_path / "s1.jsonl", lines)) + assert (len(calls), compacts, by_next) == (1, 1, True) + assert cd.read_calls(tmp_path / "missing.jsonl") == ([], None, False) + + +def test_work_report_pins_every_part(tmp_path): + # 作業の報告の区分ごとの token・秒・費用・再起動・やり直しの数(現状固定。#2422) + m = load_cli() + first = {"type": "user", "message": {"content": "続き .ndf/handoff/a.md"}} + tp = write_transcript( + tmp_path / "s1.jsonl", + [ + first, + _cached("2026-11-01T00:00:00Z", "m1"), + call("2026-11-01T00:10:00Z", "m2"), + call("2026-11-01T00:20:00Z", "m3"), + call("2026-11-01T01:30:00Z", "m4"), + ], + ) + write_transcript( + tmp_path / "s1" / "subagents" / "agent-x.jsonl", [call("2026-11-01T00:11:00Z", "m5"), call("2026-11-01T00:12:00Z", "m6")] + ) + ledger = [ + { + "session_id": "ag9", + "parent_session_id": "s1", + "at": "2026-11-01T00:15:00Z", + "usage": call("t", "m7")["message"]["usage"], + "seconds": 12, + } + ] + recs = [ + hook_row("s1", tp, "2026-11-01T00:00:00Z", verdict="継続", stage="skill:a"), + mark("復元完了", "2026-11-01T00:05:00Z"), + hook_row("s1", tp, "2026-11-01T00:06:00Z", verdict="継続", stage="skill:a"), + mark("引継ぎ開始", "2026-11-01T01:00:00Z", sid="s1"), + hook_row("s1", tp, "2026-11-01T01:40:00Z", verdict="再起動", reason="容量", stage="cut"), + hook_row("s2", None, "2026-11-01T02:00:00Z", verdict="再起動"), + ] + out = m.report_work("#7", recs, ledger) + parts = {b: (t["calls"], t["inp"], t["out"], t["read"], t["w5"], t["w1h"], t["seconds"], t["cost"]) for b, t in out["parts"].items()} + assert parts == PINNED_PARTS + rest = {k: v for k, v in out.items() if k != "parts"} + assert rest == PINNED_REST + + +PINNED_PARTS = { + "conductor": (2, 200, 20, 2000, 0, 0, 600, 400), + "ndf": (3, 300, 30, 3000, 0, 0, 72, 650), + "handoff": (1, 100, 10, 1000, 0, 0, 0, 200), + "restore": (1, 100, 10, 1000, 30, 40, 0, 318), +} +PINNED_REST = { + "work": "#7", + "sessions": 2, + "unknown_parts": {"restore": 0, "handoff": 1}, + "restarts": {"容量": 1, "不明": 1}, + "restart_count": 2, + "auto_compacts": "不明", + "quality": {"rework": 1, "review_fixes": "不明", "missed_requirements": "不明"}, + "no_policy_rows": 0, +} diff --git a/plugins/ndf/scripts/tests/test_lib_clock.py b/plugins/ndf/scripts/tests/test_lib_clock.py index 5c225e6e2..2b6bc5500 100644 --- a/plugins/ndf/scripts/tests/test_lib_clock.py +++ b/plugins/ndf/scripts/tests/test_lib_clock.py @@ -76,3 +76,13 @@ def test_parse_accepts_datetimes(): def test_seconds_between(): assert clock.seconds_between("2026-09-26T07:00:00Z", "2026-09-26T16:01:00+09:00") == 60 assert clock.seconds_between("x", "2026-09-26T07:00:00Z") is None + + +def test_epoch_seconds_reads_the_same_instant_as_parse(): + assert clock.epoch_seconds("2026-09-26T07:00:00Z") == T.replace(microsecond=0).timestamp() + assert clock.epoch_seconds("2026-09-26T16:00:00+09:00") == clock.epoch_seconds("2026-09-26T07:00:00+00:00") + + +@pytest.mark.parametrize("value", [None, "", "not a time", 12]) +def test_epoch_seconds_returns_none_for_unreadable(value): + assert clock.epoch_seconds(value) is None diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index bde04a128..a0c12237f 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -55,7 +55,9 @@ def ms(title, nums, boundary=None, sizes=None, **extra): @pytest.fixture -def env(tmp_path, mvv): +def env(tmp_path, mvv, monkeypatch): + # 中継の下で起動したシェルの値を受け継ぐと、周回がその中継の記録を指し、状態の表示が変わる + monkeypatch.delenv("NDF_RELAY_DIR", raising=False) (tmp_path / ".ndf").mkdir() (tmp_path / ".ndf" / "pace.json").write_text(json.dumps(DECL)) e = sl.Env(tmp_path, main=tmp_path, now=Clock(), upkeep_dir=lambda repo: tmp_path / "upkeep") @@ -1546,3 +1548,16 @@ def test_gate_1_of_a_lane_sends_the_request_to_the_organizer(env, monkeypatch): out = sl_gate.cmd_gate(env, "l1", write_done(env, [item])) w = rec(env)["waiting"][0] assert out["metrics"]["waiting"] == [11] and sent == [11] and w["request"] == "lroot-g501-x" and w["plan"] == "p1" + + +def test_selecting_a_returned_issue_marks_its_fix(env): + # 直しの周回: 差し戻された周回の課題を選ぶと、その直しを済みにする。選ばなかった直しと済みの直しは変えない(現状固定。#2422) + start(env) + with sl_record.open_record(env, "l1") as (r, before): + r["fixes"] = [{"lap": 0, "issues": [12]}, {"lap": 0, "issues": [99]}, {"lap": 0, "issues": [11], "fixed_by": 7}] + sl_record.write_record(env, r, before) + got = sl_select.cmd_select(env, "l1") + lap = rec(env)["laps"][-1] + assert got["metrics"]["selected"] == [12, 11] + assert (lap["sprint"], lap["milestone"], lap["rank_digest"], lap["state_path"]) == (f"l1-{lap['n']}", "01 近い", "dg", None) + assert [f.get("fixed_by") for f in rec(env)["fixes"]] == [lap["n"], None, 7] diff --git a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py index 61e1f5f7c..077b2cf0d 100644 --- a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py +++ b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py @@ -102,14 +102,23 @@ def append_depth_row(path: Path, row: dict) -> None: locks.append_locked(path, json.dumps(row, ensure_ascii=False)) +def _is_ran(row: dict, depth: str) -> bool: + """`depth` を実行した(`event` が `ran` の)行か。""" + return row.get("depth") == depth and row.get("event") == "ran" + + +def _last_ran(rows: list[dict], depth: str) -> int: + """最後に `depth` を実行した行の位置。無ければ -1。""" + return max((i for i, r in enumerate(rows) if _is_ran(r, depth)), default=-1) + + def _ran(rows: list[dict], depth: str) -> list[dict]: - return [r for r in rows if r.get("depth") == depth and r.get("event") == "ran"] + return [r for r in rows if _is_ran(r, depth)] def diffs_since(rows: list[dict], depth: str) -> int: """最後の `depth` の `ran` の行より後の差分の棚卸しの `ran` の行の数(前提 1 の数え方)。""" - last = max((i for i, r in enumerate(rows) if r.get("depth") == depth and r.get("event") == "ran"), default=-1) - return sum(1 for r in rows[last + 1 :] if r.get("depth") == "diff" and r.get("event") == "ran") + return len(_ran(rows[_last_ran(rows, depth) + 1 :], "diff")) def cause_share(causes: list[dict], types: list[dict], open_numbers: set | None) -> tuple[str | None, float]: @@ -156,8 +165,7 @@ def _due_overhaul(rows: list[dict], cfg: dict, causes, types, open_numbers) -> t values["count"] = diffs_since(rows, "overhaul") if values["count"] >= c["every"]: trig.append("count") - last_ov = max((i for i, r in enumerate(rows) if r.get("depth") == "overhaul" and r.get("event") == "ran"), default=-1) - reviews = _ran(rows[last_ov + 1 :], "review")[-c["streak"] :] + reviews = _ran(rows[_last_ran(rows, "overhaul") + 1 :], "review")[-c["streak"] :] if len(reviews) >= c["streak"]: if all((r.get("created") or 0) > (r.get("closed") or 0) for r in reviews): trig.append("created_over_closed") diff --git a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_overhaul.py b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_overhaul.py index a71168cf0..0bc0f844a 100644 --- a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_overhaul.py +++ b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_overhaul.py @@ -62,42 +62,57 @@ def _read_input(path, what: str): return got +def _issue_types(issues: list[dict], by_num: dict, ids: list[str]) -> dict[int, str]: + """課題の番号 → 型。分類が無いか型の一覧に無ければ `I`(無ければ最後の型)。""" + other = "I" if "I" in ids else ids[-1] + kind = {} + for i in issues: + t = (by_num.get(i["number"]) or {}).get("type") + kind[i["number"]] = t if t in ids else other + return kind + + +def _components(issues: list[dict]) -> Counter: + """題と本文に現れる部品の名前ごとの、課題の数(1 つの課題で 1 回)。""" + comps = Counter() + for i in issues: + comps.update(set(_COMPONENT.findall(f"{i.get('title')}\n{i.get('body')}"))) + return comps + + +def _months(issues: list[dict], closed: list[dict]) -> dict: + """月(`YYYY-MM`)→ {起票の数, 完了の数}。月の順。""" + months: dict = {} + for i in list(issues) + list(closed): + if i.get("created_at"): + months.setdefault(i["created_at"][:7], {"created": 0, "closed": 0})["created"] += 1 + for c in closed: + if c.get("closed_at"): + months.setdefault(c["closed_at"][:7], {"created": 0, "closed": 0})["closed"] += 1 + return dict(sorted(months.items())) + + def count_types(snap: dict, classes: list[dict], types: list[dict]) -> dict: """型ごとの件数・部品・マイルストーンごとの割合・月ごとの起票と完了。""" ids = [t["id"] for t in types] - other = "I" if "I" in ids else ids[-1] by_num = {c["number"]: c for c in classes if isinstance(c, dict) and isinstance(c.get("number"), int)} issues = snap.get("open") or [] - kind = { - i["number"]: (by_num.get(i["number"]) or {}).get("type") if (by_num.get(i["number"]) or {}).get("type") in ids else other - for i in issues - } + kind = _issue_types(issues, by_num, ids) by_type = {t: sum(1 for v in kind.values() if v == t) for t in ids} defect = {t["id"] for t in types if t.get("defect", True)} n_defect = sum(by_type[t] for t in defect) - comps = Counter() - for i in issues: - comps.update(set(_COMPONENT.findall(f"{i.get('title')}\n{i.get('body')}"))) per_ms: dict = {} for i in issues: - row = per_ms.setdefault(i.get("milestone") or "(なし)", Counter()) - row[kind[i["number"]]] += 1 - months: dict = {} - for i in list(issues) + list(snap.get("closed") or []): - if i.get("created_at"): - months.setdefault(i["created_at"][:7], {"created": 0, "closed": 0})["created"] += 1 - for c in snap.get("closed") or []: - if c.get("closed_at"): - months.setdefault(c["closed_at"][:7], {"created": 0, "closed": 0})["closed"] += 1 + per_ms.setdefault(i.get("milestone") or "(なし)", Counter())[kind[i["number"]]] += 1 return { "issues": len(issues), "unclassified": sum(1 for i in issues if i["number"] not in by_num), "by_type": by_type, "defects": n_defect, "defect_share": {t: round(by_type[t] / n_defect, 4) for t in ids if t in defect and n_defect}, - "components": comps.most_common(10), + "components": _components(issues).most_common(10), "milestones": {m: dict(c) for m, c in sorted(per_ms.items())}, - "months": dict(sorted(months.items())), + "months": _months(issues, snap.get("closed") or []), } diff --git a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py index 02ddbe773..14a757910 100644 --- a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py +++ b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_research.py @@ -15,7 +15,6 @@ from __future__ import annotations -import datetime as _dt import hashlib import json import os @@ -101,25 +100,37 @@ def read_research_item(root, item_id: str) -> dict | None: return rec if rec is not None and rec.get("id", item_id) == item_id else None +def _usage_rows(needs: dict) -> tuple[list[dict], list[dict]]: + """needs.usage の行を (使える行, 飛ばした側面) に分ける。集計だけを返す行に限って読む。""" + usage, skipped = [], [] + for row in needs.get("usage") or []: + scope = row.get("scope") or {} + if scope.get("aggregate_only") is True and row.get("command"): + usage.append(row) + else: + skipped.append( + {"aspect": "needs.usage", "reason": f"{row.get('name')}: aggregate_only が true でない行は読まない(個票を読まない)"} + ) + return usage, skipped + + +def _undeclared(items: list[dict], skipped: list[dict]) -> list[dict]: + """項目にも飛ばした側面にも現れない側面を、宣言の無い側面として返す。""" + have = {i["aspect"] for i in items} | {s["aspect"] for s in skipped} + return [{"aspect": a, "reason": UNDECLARED} for a in ASPECTS if a not in have] + + def research_items(mat: dict | None) -> tuple[list[dict], list[dict]]: """(調査の項目, 飛ばした側面)。項目は {id, aspect, target, decl}。""" if mat is None: return [], [{"aspect": a, "reason": UNDECLARED} for a in ASPECTS] - items, skipped = [], [] + items = [] needs, seeds = mat.get("needs") or {}, mat.get("seeds") or {} def add(aspect, target, decl): items.append({"id": f"{aspect}:{target}", "aspect": aspect, "target": target, "decl": decl}) - usage = [] - for row in needs.get("usage") or []: - scope = row.get("scope") or {} - if scope.get("aggregate_only") is True and row.get("command"): - usage.append(row) - else: - skipped.append( - {"aspect": "needs.usage", "reason": f"{row.get('name')}: aggregate_only が true でない行は読まない(個票を読まない)"} - ) + usage, skipped = _usage_rows(needs) for row in usage: add("needs.usage", row.get("name") or row["command"], row) for row in needs.get("market") or []: @@ -133,29 +144,17 @@ def add(aspect, target, decl): add("method.unused", "usage", {"usage": [u.get("name") for u in usage]}) add("method.hotspot", "git", {}) add("method.failures", "loop", {"failures": mat.get("failures")}) - have = {i["aspect"] for i in items} | {s["aspect"] for s in skipped} - for a in ASPECTS: - if a not in have: - skipped.append({"aspect": a, "reason": UNDECLARED}) + skipped.extend(_undeclared(items, skipped)) return items, skipped -def _iso_epoch(value) -> float | None: - if not isinstance(value, str) or not value: - return None - try: - return _dt.datetime.fromisoformat(value.replace("Z", "+00:00")).timestamp() - except ValueError: - return None - - def plan(root, since: str | None) -> dict: items, skipped = research_items(D.materials(root)) - cut = _iso_epoch(since) + cut = clock.epoch_seconds(since) todo, fresh = [], [] for it in items: rec = read_research_item(root, it["id"]) - checked = _iso_epoch((rec or {}).get("checked_at")) + checked = clock.epoch_seconds((rec or {}).get("checked_at")) if checked is None or (cut is not None and checked < cut): todo.append(it["id"]) else: @@ -298,9 +297,9 @@ def research_one(root, it: dict, cfg: dict, product, run: str, loop, lap, tick) extra = "# 変更の多いファイル(回数・パス)\n" + hotspots(root) if it["aspect"] == "method.hotspot" else extra got = call_llm(root, prompt_of(it, product, extra), int(lim["item_seconds"]), tick, tools_of(it["aspect"])) usage = {"cost": got["cost"], "seconds": got["seconds"]} - ok = got["ok"] and got["cost"] <= lim["item_usd"] and got["seconds"] <= lim["item_seconds"] and valid_record(got["data"]) - log_call(root, it["id"], ok, usage, run, loop, lap) - if not got["ok"] or got["cost"] > lim["item_usd"] or got["seconds"] > lim["item_seconds"] or not valid_record(got["data"]): + accepted = got["ok"] and got["cost"] <= lim["item_usd"] and got["seconds"] <= lim["item_seconds"] and valid_record(got["data"]) + log_call(root, it["id"], accepted, usage, run, loop, lap) + if not accepted: return None, usage rec = { "id": it["id"], @@ -318,6 +317,35 @@ def research_one(root, it: dict, cfg: dict, product, run: str, loop, lap, tick) return rec, usage +def _ticker(root, run_id, until: str | None, cfg: dict): + """呼び出しの間に呼ぶ関数。持ち主の印を `owner.beat` 秒ごとに書き、待っていたプランの完了から `research.grace` 秒を過ぎたら GraceOver を投げる。""" + beat = {"at": time.time(), "until_seen": None} + + def tick(*_): + if time.time() - beat["at"] >= cfg["owner"]["beat"]: + O.touch(root, run_id) + beat["at"] = time.time() + # 調査中も待っていたプランの完了を見て、research.grace 秒を過ぎた呼び出しを止める(持ち主を手放し、後の見直しへ渡す) + if until and Path(until).exists(): + seen = beat["until_seen"] = beat["until_seen"] or time.time() + if time.time() - seen >= float(cfg["research"].get("grace", 300)): + raise GraceOver + + return tick + + +def _wait_turn(root, run_id, item_id: str, until: str | None, listing: str | None, max_: int, tick, sleep) -> str | None: + """並列の空きを待ち、次の項目を始めてよければ None、止めるなら止まる理由を返す。""" + while not (until and Path(until).exists()) and not free_slot(listing, max_): + tick() + sleep(POLL) + if until and Path(until).exists(): # 待っていたプランが終わったら新しい項目を始めない(I13) + return "待っていたプランが終わった" + if O.touch(root, run_id, item_id): + return "持ち主でなくなった" + return None + + def run_research( root, until: str | None, @@ -345,31 +373,14 @@ def run_research( pl = plan(root, since) mat = D.materials(root) or {} done, cost, secs, fails, why = [], 0.0, 0.0, 0, "項目が無い" - beat = {"at": time.time(), "until_seen": None} - - def tick(*_): - if time.time() - beat["at"] >= cfg["owner"]["beat"]: - O.touch(root, run_id) - beat["at"] = time.time() - # 調査中も待っていたプランの完了を見て、research.grace 秒を過ぎた呼び出しを止める(持ち主を手放し、後の見直しへ渡す) - if until and Path(until).exists(): - seen = beat["until_seen"] = beat["until_seen"] or time.time() - if time.time() - seen >= float(cfg["research"].get("grace", 300)): - raise GraceOver - + tick = _ticker(root, run_id, until, cfg) try: for item_id in pl["todo"]: if len(done) >= max_items: why = f"1 回の上限 {max_items} 項目に当たった" break - while not (until and Path(until).exists()) and not free_slot(listing, max_): - tick() - sleep(POLL) - if until and Path(until).exists(): # 待っていたプランが終わったら新しい項目を始めない(I13) - why = "待っていたプランが終わった" - break - if O.touch(root, run_id, item_id): - why = "持ち主でなくなった" + if stop := _wait_turn(root, run_id, item_id, until, listing, max_, tick, sleep): + why = stop break try: rec, usage = research_one(root, pl["by_id"][item_id], cfg, mat.get("product"), run_id, loop, lap, tick) diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 27d3c7d5a..67201f004 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -166,6 +166,16 @@ CI の失敗・衝突・版数の衝突は一時レーンと同じく配布の 全レーンが止まっても、答えていない依頼か問いが残る間は終えない。 +### レーンの画面を見るとき + +レーンは既定の tmux とは別のサーバーで動く。`tmux ls` には出ず、`tmux -L ndf-relay ls` と +`tmux -L ndf-relay capture-pane -p -t <レーン>` で見る(人が入るのは `relay.py attach <レーン>`)。 + +**画面の入力欄(`❯` の行)の文字は、判断の材料にしない。** Claude Code やエディタのサジェストが薄い色で出ており、 +`capture-pane` は色を落とすため、人が打った文字と区別できない。サジェストを「送信されていない人の指示」と読むと、 +誰も出していない指示で起こし直しを止める(2026-11-04、計画 p3 の lflow で起きた)。レーンを起こし直すかは、 +`[ndf-loop] stopped` の行と `relay.py ls` の状態だけで決める。 + ## 数え方 オーガナイザーのセッションは `relay.py role organizer --plan p1` の印を持ち、同時に動かせる数(`NDF_RELAY_MAX_NAMED`)に数えない。 diff --git a/plugins/ndf/skills/skill-stats/scripts/skill-stats.py b/plugins/ndf/skills/skill-stats/scripts/skill-stats.py index 8ab030628..fd8b5c282 100755 --- a/plugins/ndf/skills/skill-stats/scripts/skill-stats.py +++ b/plugins/ndf/skills/skill-stats/scripts/skill-stats.py @@ -27,7 +27,7 @@ import sys from collections import Counter, defaultdict from datetime import datetime, timedelta -from typing import Iterable +from typing import Iterable, NamedTuple # 会話の記録を層の単位で読む部品は共通層にある(#550 の決定 13)。Kiro CLI が Skill を # symlink にするため、`.resolve()` を通してからプラグインルートへ登る(`scripts/lib/README.md`)。 @@ -375,6 +375,16 @@ def _lookahead_hit(tl: list, i: int, qualified: str, cap: int) -> bool: return False +def _count_user_text(tl: list, i: int, text, skill_triggers: list, trig_h: Counter, hits: Counter, cap: int) -> None: + """利用者の発言 1 件(tl の i 番目)に現れたトリガーを Skill ごとに数え、続く呼び出しで当たったらヒットに数える。""" + text_l = str(text).lower() + for qualified, trs in skill_triggers: + if trs and any(t in text_l for t in trs): + trig_h[qualified] += 1 + if _lookahead_hit(tl, i, qualified, cap): + hits[qualified] += 1 + + def aggregate_by_project( transcripts: list[pathlib.Path], skills: list[dict], @@ -398,20 +408,10 @@ def aggregate_by_project( for i, (kind, data) in enumerate(tl): if kind == "skill": auto[data] += 1 - continue - if kind == "slash": + elif kind == "slash": explicit[data] += 1 - continue - if kind != "user": - continue - text_l = str(data).lower() - for qualified, trs in skill_triggers: - if not trs: - continue - if any(t in text_l for t in trs): - trig_h[qualified] += 1 - if _lookahead_hit(tl, i, qualified, lookahead_cap): - hits[qualified] += 1 + elif kind == "user": + _count_user_text(tl, i, data, skill_triggers, trig_h, hits, lookahead_cap) return result @@ -747,6 +747,32 @@ def filter_projects( return {k: v for k, v in per_project.items() if needle in k.lower()} +class AgentReport(NamedTuple): + """`--agents` の報告(記録の行・要約・層ごとの合計・総計・役割ごとの使用量・要約から除いた数・割る候補の上限と出所)。""" + + agents: list + summary: list[dict] + totals_rows: list[dict] + totals: dict + usage: list[dict] + excluded: int + limit_meta: dict + + +def agent_report(args: argparse.Namespace) -> AgentReport: + """`--agents` のときだけ記録を読んで報告を作る。割る候補の上限と出所は `limit_meta` に返す。""" + if not args.agents: + return AgentReport([], [], [], {}, [], 0, {}) + agents, skipped = collect_agents(args.session, args.layer) + if skipped: + print(f"[skill-stats] 読めない行を飛ばした: {skipped} 件", file=sys.stderr) + limit_of, limit_meta = context_decisions.record_limits([r.session for r in agents], args.window_limit) + summary, excluded = summarize_agents(agents, limit_of) + totals_rows, totals = layer_totals(agents) + usage = role_usage(agents) if args.session else [] + return AgentReport(agents, summary, totals_rows, totals, usage, excluded, limit_meta) + + def emit_json( skills: list[dict], per_project: dict[str, tuple[Counter, Counter, Counter, Counter]], @@ -754,12 +780,7 @@ def emit_json( effective_days: int | None, plugin_root: pathlib.Path, transcripts: list[pathlib.Path], - agents: list, - agent_summary: list[dict], - totals_rows: list[dict], - totals: dict, - usage: list[dict], - excluded: int, + report: AgentReport, ) -> None: projects_json = [] for project, (auto, explicit, trig, hits) in sorted(per_project.items()): @@ -787,16 +808,16 @@ def emit_json( "projects": projects_json, } if args.agents: - out["meta"].update(args.limit_meta) # 割る候補の上限と出所(main が記録から決めた値) + out["meta"].update(report.limit_meta) # 割る候補の上限と出所(agent_report が記録から決めた値) out.update( { - "agents": [r.as_row() for r in agents], - "agent_summary": agent_summary, - "layer_totals": totals_rows, - "totals": totals, - "role_usage": usage, - "excluded": excluded, - "unphased_supervisors": transcript_agents.unphased_supervisors(agents), + "agents": [r.as_row() for r in report.agents], + "agent_summary": report.summary, + "layer_totals": report.totals_rows, + "totals": report.totals, + "role_usage": report.usage, + "excluded": report.excluded, + "unphased_supervisors": transcript_agents.unphased_supervisors(report.agents), } ) print(json.dumps(out, ensure_ascii=False, indent=2)) @@ -806,12 +827,7 @@ def emit_markdown( skills: list[dict], per_project: dict[str, tuple[Counter, Counter, Counter, Counter]], args: argparse.Namespace, - agents: list, - agent_summary: list[dict], - totals_rows: list[dict], - totals: dict, - usage: list[dict], - excluded: int, + report: AgentReport, ) -> None: if args.by_project: for project, (auto, explicit, trig, hits) in sorted(per_project.items()): @@ -838,12 +854,12 @@ def emit_markdown( print() print( format_agents_markdown( - agents, - agent_summary, - excluded, - totals_rows, - totals, - usage, + report.agents, + report.summary, + report.excluded, + report.totals_rows, + report.totals, + report.usage, with_session=bool(args.session), ) ) @@ -895,20 +911,7 @@ def main() -> int: transcripts = select_transcripts(args, date_from, date_to, effective_days) - agents: list = [] - agent_summary: list[dict] = [] - totals_rows: list[dict] = [] - totals: dict = {} - usage: list[dict] = [] - excluded = 0 - if args.agents: - agents, skipped = collect_agents(args.session, args.layer) - if skipped: - print(f"[skill-stats] 読めない行を飛ばした: {skipped} 件", file=sys.stderr) - limit_of, args.limit_meta = context_decisions.record_limits([r.session for r in agents], args.window_limit) - agent_summary, excluded = summarize_agents(agents, limit_of) - totals_rows, totals = layer_totals(agents) - usage = role_usage(agents) if args.session else [] + report = agent_report(args) # Header summary window = [] @@ -931,32 +934,9 @@ def main() -> int: return 0 if args.format == "json": - emit_json( - skills, - per_project, - args, - effective_days, - plugin_root, - transcripts, - agents, - agent_summary, - totals_rows, - totals, - usage, - excluded, - ) + emit_json(skills, per_project, args, effective_days, plugin_root, transcripts, report) else: - emit_markdown( - skills, - per_project, - args, - agents, - agent_summary, - totals_rows, - totals, - usage, - excluded, - ) + emit_markdown(skills, per_project, args, report) return 0 diff --git a/plugins/ndf/skills/skill-stats/tests/test_agents_report.py b/plugins/ndf/skills/skill-stats/tests/test_agents_report.py index f008ea937..14f7135eb 100644 --- a/plugins/ndf/skills/skill-stats/tests/test_agents_report.py +++ b/plugins/ndf/skills/skill-stats/tests/test_agents_report.py @@ -279,3 +279,21 @@ def test_a_session_narrows_the_skill_statistics_to_the_conductor() -> None: assert p.returncode == 0, p.stderr out = json.loads(p.stdout) assert out["meta"]["transcripts"] == 1 + + +def test_the_json_keys_keep_their_order(report) -> None: + # 結果 JSON のキーの順(現状固定。#2422)。エージェントの報告をまとめて渡しても順を変えない + assert list(report) == [ + "meta", + "total", + "grand_skills", + "projects", + "agents", + "agent_summary", + "layer_totals", + "totals", + "role_usage", + "excluded", + "unphased_supervisors", + ] + assert list(report["meta"])[-3:] == ["window_limit", "window_limit_source", "records_without_decisions"] diff --git a/plugins/ndf/skills/skill-stats/tests/test_skill_stats_wrappers.py b/plugins/ndf/skills/skill-stats/tests/test_skill_stats_wrappers.py index 8bc02822c..4a7f4a54a 100644 --- a/plugins/ndf/skills/skill-stats/tests/test_skill_stats_wrappers.py +++ b/plugins/ndf/skills/skill-stats/tests/test_skill_stats_wrappers.py @@ -82,3 +82,31 @@ def test_the_skill_table_uses_spaced_rules_and_an_empty_total_cell(ss): "| ndf:fix | explicit | 11 | 3 | 8 | 8 | 3 | 37.5% |", "| **合計** | | **23** | **5** | **18** | **8** | **3** | **37.5%** |", ] + + +def test_aggregate_counts_auto_explicit_triggers_and_hits(ss, monkeypatch): + """発言 1 件ごとのトリガーとヒットの数(現状固定。#2422)。ヒットは次の発言・スラッシュより前に同じ Skill が呼ばれたとき。""" + tl = [ + ("user", "Please REVIEW this"), + ("skill", "ndf:review"), + ("user", "review again and plan"), + ("slash", "ndf:review"), + ("skill", "ndf:review"), + ("user", "nothing here"), + ("skill", "ndf:plan"), + ("user", "plan it"), + ("assistant", "x"), + ("skill", "ndf:plan"), + ] + monkeypatch.setattr(ss, "build_timeline", lambda path, names: (tl, "proj")) + skills = [ + {"name": "review", "qualified": "ndf:review", "triggers": ["review", ""]}, + {"name": "plan", "qualified": "ndf:plan", "triggers": ["plan"]}, + {"name": "quiet", "qualified": "ndf:quiet", "triggers": []}, + ] + got = ss.aggregate_by_project([pathlib.Path("a.jsonl"), pathlib.Path("b.jsonl")], skills) + auto, explicit, trig, hits = got["proj"] + assert (dict(auto), dict(explicit)) == ({"ndf:review": 4, "ndf:plan": 4}, {"ndf:review": 2}) + assert (dict(trig), dict(hits)) == ({"ndf:review": 4, "ndf:plan": 4}, {"ndf:review": 2, "ndf:plan": 2}) + capped = ss.aggregate_by_project([pathlib.Path("a.jsonl")], skills, lookahead_cap=1)["proj"] + assert dict(capped[3]) == {"ndf:review": 1} diff --git a/scripts/token-usage.py b/scripts/token-usage.py index 931d36b58..54d307420 100755 --- a/scripts/token-usage.py +++ b/scripts/token-usage.py @@ -469,47 +469,60 @@ class External: usage: Usage | None = None # 呼び出しの並びが分かる席だけ(claude・codex)。P・k・書き直しに使う +def _codex_lines(path: Path, until: float | None) -> tuple[str, str | None, dict | None, list[float], list[tuple]]: + """codex の記録 1 本の (作業ディレクトリ, モデル, 最後の累計, 時刻の並び, 呼び出しごとの (時刻, 入力, キャッシュ, 出力))。 + + `until` より後の行は読まない。同じ累計の token_count は同じ呼び出しの再掲として数えない。""" + cwd, model, total, times, calls = "", None, None, [], [] + with open(path, encoding="utf-8", errors="replace") as fh: + lines = fh.readlines() + for line in lines: + try: + d = json.loads(line) + except ValueError: + continue + if not isinstance(d, dict): + continue + t = parse_ts(d.get("timestamp")) + if until is not None and t is not None and t > until: + continue + if t is not None: + times.append(t) + p = d.get("payload") or {} + if d.get("type") == "session_meta": + cwd = p.get("cwd") or "" + elif d.get("type") == "turn_context" and not model: + model = p.get("model") + elif p.get("type") == "token_count" and p.get("info"): + new_total = p["info"].get("total_token_usage") or total + last = p["info"].get("last_token_usage") or {} + if last and new_total != total: # 同じ累計の token_count は同じ呼び出しの再掲 + inp, cached = last.get("input_tokens", 0), last.get("cached_input_tokens", 0) + calls.append((t, inp, cached, last.get("output_tokens", 0))) + total = new_total + return cwd, model, total, times, calls + + +def _codex_usage(calls: list[tuple]) -> Usage | None: + """呼び出しごとの値から使用量を組む。codex の input_tokens は cached を含むため、キャッシュに当たらなかった分を書き込みとみなす。 + + 呼び出しごとの値(last_token_usage)が無い記録は、P・k を取れないものとして None を返す。""" + if not calls: + return None + usage = Usage() + for _, inp, cached, out_t in calls: + usage.add(Usage(1, inp - cached, cached, 0, 0, out_t)) + usage.record_calls([(t, inp, inp - cached, cached) for t, inp, cached, _ in calls]) + return usage + + def read_codex(root: Path, idle_cap: int, until: float | None = None) -> list[External]: out = [] for path in sorted(root.glob("**/rollout-*.jsonl")): - cwd, model, total, times, calls = "", None, None, [], [] - with open(path, encoding="utf-8", errors="replace") as fh: - lines = fh.readlines() - for line in lines: - try: - d = json.loads(line) - except ValueError: - continue - if not isinstance(d, dict): - continue - t = parse_ts(d.get("timestamp")) - if until is not None and t is not None and t > until: - continue - if t is not None: - times.append(t) - p = d.get("payload") or {} - if d.get("type") == "session_meta": - cwd = p.get("cwd") or "" - elif d.get("type") == "turn_context" and not model: - model = p.get("model") - elif p.get("type") == "token_count" and p.get("info"): - new_total = p["info"].get("total_token_usage") or total - last = p["info"].get("last_token_usage") or {} - if last and new_total != total: # 同じ累計の token_count は同じ呼び出しの再掲 - inp, cached = last.get("input_tokens", 0), last.get("cached_input_tokens", 0) - calls.append((t, inp, cached, last.get("output_tokens", 0))) - total = new_total + cwd, model, total, times, calls = _codex_lines(path, until) m = WT_RE.search(cwd + "/") if not m or not times or total is None: # token_count の無い記録(起動に失敗した席)は数えない continue - # codex の input_tokens は cached を含む。キャッシュに当たらなかった分を書き込みとみなす。 - # 呼び出しごとの値(last_token_usage)が無い記録は、P・k を取れないものとして usage を持たない - usage = None - if calls: - usage = Usage() - for _, inp, cached, out_t in calls: - usage.add(Usage(1, inp - cached, cached, 0, 0, out_t)) - usage.record_calls([(t, inp, inp - cached, cached) for t, inp, cached, _ in calls]) out.append( External( "codex", @@ -523,7 +536,7 @@ def read_codex(root: Path, idle_cap: int, until: float | None = None) -> list[Ex "cached": total.get("cached_input_tokens", 0), "out": total.get("output_tokens", 0), }, - usage=usage, + usage=_codex_usage(calls), ) ) return out