diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 8ad70eea6..0c5cbe5f0 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.27): 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.31): 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/mvv.json b/.ndf/mvv.json index 9570521d6..302829abd 100644 --- a/.ndf/mvv.json +++ b/.ndf/mvv.json @@ -165,6 +165,25 @@ } ], "body": "# ai-plugins のプロジェクト MVV\n\nこのリポジトリ(ai-plugins。主要プラグインは NDF。NDF を Web から使うサービス Factory も置く)の Mission(何のためにやるか)・Vision(目指す姿)・Value(判断の基準)。AI と人がこのプロジェクトで判断するときの基準にする。判断の記録から候補を抽出した根拠は `issues/mvv-candidates-ai-plugins.md` にある。`.ndf/` へ置く形は #1366 の設計が決める。\n\n## NDF の共通原則との関係\n\nこの MVV は、NDF の共通原則(`issues/ndf-common-principles.md`。上位の原則「人を守り、人の発展を支える」、優先順位、AI が自分で取れる行動は「進める / 止めて人へ戻す」の 2 つだけ、人と AI の対話、必ず承認が要る操作 C1〜C8)の範囲で定める。ai-plugins は NDF を作るプロジェクトであると同時に、NDF を使うプロジェクトの 1 つでもある。**共通原則の優先順位と「必ず承認が要る操作」C1〜C8 は、この MVV のどの項目よりも先に効く。** 以下の Vision と Value は、その範囲でだけ読む。\n\n## Mission\n\nClaude Code / Codex を使うチームに、プラグインとして、また Web から操作するサービス(Factory)として、要求から本番の配布までの開発工程を「人の判断が要る所だけで止まる」形で届け、開発者が価値を利用者へ届けるまでの時間と費用を減らす。良くなったかは、NDF の外のプロジェクトで実際に使われた結果で確かめる。Kiro と agy へは配らず、レビューと提案の相手役として起動するだけにする。\n\n## Vision\n\n開発者が課題を 1 つ指せば、設計・実装・レビュー・検査・配布までを NDF が回す。人が手を止めるのは、設計の承認と本番への配布の承認の 2 回と、必ず承認が要る操作に限られる。設計の承認では、設計の中身より先に、それを作る要否を数行で決める。1 回のスプリントで作る量(課題の数と承認資料の分量)は、人が 1 回の承認で読み切れる量に収まる。2 回の承認を MVV 判定に任せるかは、利用者が進め方ごとに事前に選ぶ(`.ndf/pace.json` の許可と MVV の承認。利用者の承認による委任であり、MVV が利用者の承認に代わるのではない)。共通原則の C1〜C8 と固有の P1 に当たる操作は任せず、配布が他のリポジトリへの公開(C5)に当たるときも任せない。承認には、決めたこと・未検証のまま残ること・MVV に照らした判定が、番号でなく中身の分かる語でそろって届く。出した版は実際に使って確かめ、確かめた結果が次に何を作り何をやめるかを決める。ai-plugins の中より、NDF の外のリポジトリでの利用の手がかりを先に拾う。言語・テスト・ブランチ運用・配布の手段が違うどのリポジトリでも、プロジェクトの設定(`.ndf/`)を 1 つ置けば同じ工程が通る。Factory でも同じ基準で判断する。\n\n## Value\n\n1. **小さく出して使って確かめる**: 完璧なものより、小さく「たぶん動く」ものを早く出す。開発版のチャネル・導入の確認・自動テストで守る。出した後は実際に使って受け入れ条件を確かめ、確かめていないものを済んだと記録しない。見つけた不具合は 4 条件を満たせばその場で直す。人を害する・秘密・利用者のデータ・戻せない操作に関わる指摘は、効率に寄与しなくても直す。\n2. **人が判断できる量に収める**: 一度に作る量は、スプリントの課題の数と承認資料の分量で測り、その上限を人が 1 回の承認で読み切れる量で決める(数値は実測から決めて見直す)。上限に収まらないものは、始める前に分けるか縮める。承認は「設計」と「本番への配布」の 2 回に寄せ、設計の承認の先頭で作る要否を数行で問う。再起動・告知・確認で操作を求めない。AI が決められないときは、理由と根拠を添えて人へ戻す。共通原則の C1〜C8 と固有の P1 に当たる操作の承認は、この 2 回とは別に必ず残す。承認を求めるときは判断の材料を先に集め、番号と内部の呼び名だけで書かない。\n3. **やめる・縮めるを既定にする**: 上限(費用・秒・ラウンド・量)に当たったら、続けずにやめるか縮める。それを既定にし、残りを新しい Issue へ送ることを既定にしない。起票するのは、利用者へ届いた不具合か、人の安全・秘密・利用者のデータ・戻せない操作・承認資料の正しさに関わるものに限る。役に立たない繰り返し(同じ指摘の出直し、指摘 0 件でのラウンドの追加)は始めない。\n4. **外の利用で測って決める**: 効果は実測(費用・秒・件数)で示し、測らずに良くなったとは言わない。何を作るかの順位は、NDF の外のプロジェクトでの利用と、そこで見つかった不具合を先に置く。手順やコードに書くコマンドは、書く前に実行して確かめる。\n5. **決まった手順はスクリプトに、判断と中身は AI に**: 判断の要らない手順(待ち・集計・検査・記録・整形・起動の組み立て)はスクリプトが進め、AI は判断と中身だけを担う。同じ手当てが 2 度目になったら、まずその手順自体を消せないかを問う。消せないときだけ雛形へ入れる。決まりを足すより、要らない決まりを減らす方を選ぶ。\n6. **どのプロジェクトでも使える形を保つ**: スクリプトにするのは手順の骨組みだけにする。プロジェクトごとに違うもの(テストのコマンド・起点のブランチ・配布の手段・言語・ランタイム・課題の置き場)は、設定か引数で受ける。ai-plugins の形を既定に埋め込まない。\n7. **根本原因の場所で直す**: 現象ごとに直さない。直す場所が現象の場所と違うときは、共通の層へ寄せてから設計する。同じ役割の定数・関数を Skill ごとに分けない。\n8. **判断の理由を残し、語をそろえる**: 変更点の列挙とは別に、「何を決め、なぜそう決めたか」を、版・設計文書・振り返りに残す。正本は 1 か所に置き、写さずに指す。語は業界に流通している語を用語集で 1 語 1 意味にそろえ、番号には題か中身を添える。\n9. **試すものと本体を分ける**: 呼んだときだけ働く仕組みは実験版として、課題を起こさずその場で試し、台帳に使った結果と行き先を残す。実際に使って効いたと示せたものだけを、本体へ移す。\n\n## 必ず人の承認が要る操作\n\n| # | 操作 | 理由 |\n| --- | --- | --- |\n| P1 | 配布物の取得元・版の規則を変える(マーケットプレイスの登録の置き換え、既定ブランチ・版の規則の変更) | 利用者全員に効き、戻すには利用者側の操作が要る |\n\n## 改訂\n\nMVV は進化する。改訂は利用者の承認で成立し、版・理由・前の版との差分を記録に残す。方向の誤りは、人が AI の判断を覆した回数・AI が決められなかった回数・検査をすり抜けて利用者へ届いた不具合の件数で検出し、閾値を超えたら改訂を提案する。手順の正本は `development-workflow` の `references/project-mvv.md` の「改訂」にある。\n" + }, + { + "version": 6, + "sha256": "6713bbec4faa5259baf44c1f15a8e3a443443ca601fbe6d26b5dd682ed05c287", + "approved_at": "2026-11-06T03:35:59+00:00", + "approved_by": "takemi.ohama", + "reason": "覆し 11 件の根本原因の切り分け(issues/mvv-v6-revision.md)を受け、利用者が案 A で承認(依頼 lroot2-q4c5098517cd2、2026-11-06)。Value 3 と「改訂」の節を改める", + "vet": { + "verdict": "follow", + "at": "2026-11-06T03:35:49+00:00", + "accepted_unknown": false + }, + "changes": [ + { + "item": "Value 3", + "kind": "changed" + } + ], + "body": "# ai-plugins のプロジェクト MVV\n\nこのリポジトリ(ai-plugins。主要プラグインは NDF。NDF を Web から使うサービス Factory も置く)の Mission(何のためにやるか)・Vision(目指す姿)・Value(判断の基準)。AI と人がこのプロジェクトで判断するときの基準にする。判断の記録から候補を抽出した根拠は `issues/mvv-candidates-ai-plugins.md` にある。`.ndf/` へ置く形は #1366 の設計が決める。\n\n## NDF の共通原則との関係\n\nこの MVV は、NDF の共通原則(`issues/ndf-common-principles.md`。上位の原則「人を守り、人の発展を支える」、優先順位、AI が自分で取れる行動は「進める / 止めて人へ戻す」の 2 つだけ、人と AI の対話、必ず承認が要る操作 C1〜C8)の範囲で定める。ai-plugins は NDF を作るプロジェクトであると同時に、NDF を使うプロジェクトの 1 つでもある。**共通原則の優先順位と「必ず承認が要る操作」C1〜C8 は、この MVV のどの項目よりも先に効く。** 以下の Vision と Value は、その範囲でだけ読む。\n\n## Mission\n\nClaude Code / Codex を使うチームに、プラグインとして、また Web から操作するサービス(Factory)として、要求から本番の配布までの開発工程を「人の判断が要る所だけで止まる」形で届け、開発者が価値を利用者へ届けるまでの時間と費用を減らす。良くなったかは、NDF の外のプロジェクトで実際に使われた結果で確かめる。Kiro と agy へは配らず、レビューと提案の相手役として起動するだけにする。\n\n## Vision\n\n開発者が課題を 1 つ指せば、設計・実装・レビュー・検査・配布までを NDF が回す。人が手を止めるのは、設計の承認と本番への配布の承認の 2 回と、必ず承認が要る操作に限られる。設計の承認では、設計の中身より先に、それを作る要否を数行で決める。1 回のスプリントで作る量(課題の数と承認資料の分量)は、人が 1 回の承認で読み切れる量に収まる。2 回の承認を MVV 判定に任せるかは、利用者が進め方ごとに事前に選ぶ(`.ndf/pace.json` の許可と MVV の承認。利用者の承認による委任であり、MVV が利用者の承認に代わるのではない)。共通原則の C1〜C8 と固有の P1 に当たる操作は任せず、配布が他のリポジトリへの公開(C5)に当たるときも任せない。承認には、決めたこと・未検証のまま残ること・MVV に照らした判定が、番号でなく中身の分かる語でそろって届く。出した版は実際に使って確かめ、確かめた結果が次に何を作り何をやめるかを決める。ai-plugins の中より、NDF の外のリポジトリでの利用の手がかりを先に拾う。言語・テスト・ブランチ運用・配布の手段が違うどのリポジトリでも、プロジェクトの設定(`.ndf/`)を 1 つ置けば同じ工程が通る。Factory でも同じ基準で判断する。\n\n## Value\n\n1. **小さく出して使って確かめる**: 完璧なものより、小さく「たぶん動く」ものを早く出す。開発版のチャネル・導入の確認・自動テストで守る。出した後は実際に使って受け入れ条件を確かめ、確かめていないものを済んだと記録しない。見つけた不具合は 4 条件を満たせばその場で直す。人を害する・秘密・利用者のデータ・戻せない操作に関わる指摘は、効率に寄与しなくても直す。\n2. **人が判断できる量に収める**: 一度に作る量は、スプリントの課題の数と承認資料の分量で測り、その上限を人が 1 回の承認で読み切れる量で決める(数値は実測から決めて見直す)。上限に収まらないものは、始める前に分けるか縮める。承認は「設計」と「本番への配布」の 2 回に寄せ、設計の承認の先頭で作る要否を数行で問う。再起動・告知・確認で操作を求めない。AI が決められないときは、理由と根拠を添えて人へ戻す。共通原則の C1〜C8 と固有の P1 に当たる操作の承認は、この 2 回とは別に必ず残す。承認を求めるときは判断の材料を先に集め、番号と内部の呼び名だけで書かない。\n3. **やめる・縮めるを既定にする**: 上限(費用・秒・ラウンド・量)に当たったら、続けずにやめるか縮める。その場で手を広げて続けることを既定にしない。上限で残した指摘や提案は Issue に起こしてよい。ただし、起こしたことを理由に新しい作業を既定で始めず、棚卸しの順位で選ばれたものだけを進める。それ以外に新しく起票するのは、利用者へ届いた不具合か、人の安全・秘密・利用者のデータ・戻せない操作・承認資料の正しさに関わるものに限る。役に立たない繰り返し(同じ指摘の出直し、指摘 0 件でのラウンドの追加)は始めない。\n4. **外の利用で測って決める**: 効果は実測(費用・秒・件数)で示し、測らずに良くなったとは言わない。何を作るかの順位は、NDF の外のプロジェクトでの利用と、そこで見つかった不具合を先に置く。手順やコードに書くコマンドは、書く前に実行して確かめる。\n5. **決まった手順はスクリプトに、判断と中身は AI に**: 判断の要らない手順(待ち・集計・検査・記録・整形・起動の組み立て)はスクリプトが進め、AI は判断と中身だけを担う。同じ手当てが 2 度目になったら、まずその手順自体を消せないかを問う。消せないときだけ雛形へ入れる。決まりを足すより、要らない決まりを減らす方を選ぶ。\n6. **どのプロジェクトでも使える形を保つ**: スクリプトにするのは手順の骨組みだけにする。プロジェクトごとに違うもの(テストのコマンド・起点のブランチ・配布の手段・言語・ランタイム・課題の置き場)は、設定か引数で受ける。ai-plugins の形を既定に埋め込まない。\n7. **根本原因の場所で直す**: 現象ごとに直さない。直す場所が現象の場所と違うときは、共通の層へ寄せてから設計する。同じ役割の定数・関数を Skill ごとに分けない。\n8. **判断の理由を残し、語をそろえる**: 変更点の列挙とは別に、「何を決め、なぜそう決めたか」を、版・設計文書・振り返りに残す。正本は 1 か所に置き、写さずに指す。語は業界に流通している語を用語集で 1 語 1 意味にそろえ、番号には題か中身を添える。\n9. **試すものと本体を分ける**: 呼んだときだけ働く仕組みは実験版として、課題を起こさずその場で試し、台帳に使った結果と行き先を残す。実際に使って効いたと示せたものだけを、本体へ移す。\n\n## 必ず人の承認が要る操作\n\n| # | 操作 | 理由 |\n| --- | --- | --- |\n| P1 | 配布物の取得元・版の規則を変える(マーケットプレイスの登録の置き換え、既定ブランチ・版の規則の変更) | 利用者全員に効き、戻すには利用者側の操作が要る |\n\n## 改訂\n\nMVV は進化する。改訂は利用者の承認で成立し、版・理由・前の版との差分を記録に残す。方向の誤りは、人が AI の判断を覆した回数・AI が決められなかった回数・検査をすり抜けて利用者へ届いた不具合の件数で検出する。覆しは数える前に 1 件ずつ原因を切り分ける。判定へ渡した材料や判定の仕組みの不具合によるもの(後の検査で解消した止まりを材料が失敗として載せた、など)は方向の誤りに数えず、判定側の不具合として起票して直す。原因が MVV の文言にある覆しが閾値を超えたら改訂を提案する。手順の正本は `development-workflow` の `references/project-mvv.md` の「改訂」にある。\n" } ] } \ No newline at end of file diff --git a/.ndf/mvv.md b/.ndf/mvv.md index 66272b0c7..877bf59db 100644 --- a/.ndf/mvv.md +++ b/.ndf/mvv.md @@ -18,7 +18,7 @@ Claude Code / Codex を使うチームに、プラグインとして、また We 1. **小さく出して使って確かめる**: 完璧なものより、小さく「たぶん動く」ものを早く出す。開発版のチャネル・導入の確認・自動テストで守る。出した後は実際に使って受け入れ条件を確かめ、確かめていないものを済んだと記録しない。見つけた不具合は 4 条件を満たせばその場で直す。人を害する・秘密・利用者のデータ・戻せない操作に関わる指摘は、効率に寄与しなくても直す。 2. **人が判断できる量に収める**: 一度に作る量は、スプリントの課題の数と承認資料の分量で測り、その上限を人が 1 回の承認で読み切れる量で決める(数値は実測から決めて見直す)。上限に収まらないものは、始める前に分けるか縮める。承認は「設計」と「本番への配布」の 2 回に寄せ、設計の承認の先頭で作る要否を数行で問う。再起動・告知・確認で操作を求めない。AI が決められないときは、理由と根拠を添えて人へ戻す。共通原則の C1〜C8 と固有の P1 に当たる操作の承認は、この 2 回とは別に必ず残す。承認を求めるときは判断の材料を先に集め、番号と内部の呼び名だけで書かない。 -3. **やめる・縮めるを既定にする**: 上限(費用・秒・ラウンド・量)に当たったら、続けずにやめるか縮める。それを既定にし、残りを新しい Issue へ送ることを既定にしない。起票するのは、利用者へ届いた不具合か、人の安全・秘密・利用者のデータ・戻せない操作・承認資料の正しさに関わるものに限る。役に立たない繰り返し(同じ指摘の出直し、指摘 0 件でのラウンドの追加)は始めない。 +3. **やめる・縮めるを既定にする**: 上限(費用・秒・ラウンド・量)に当たったら、続けずにやめるか縮める。その場で手を広げて続けることを既定にしない。上限で残した指摘や提案は Issue に起こしてよい。ただし、起こしたことを理由に新しい作業を既定で始めず、棚卸しの順位で選ばれたものだけを進める。それ以外に新しく起票するのは、利用者へ届いた不具合か、人の安全・秘密・利用者のデータ・戻せない操作・承認資料の正しさに関わるものに限る。役に立たない繰り返し(同じ指摘の出直し、指摘 0 件でのラウンドの追加)は始めない。 4. **外の利用で測って決める**: 効果は実測(費用・秒・件数)で示し、測らずに良くなったとは言わない。何を作るかの順位は、NDF の外のプロジェクトでの利用と、そこで見つかった不具合を先に置く。手順やコードに書くコマンドは、書く前に実行して確かめる。 5. **決まった手順はスクリプトに、判断と中身は AI に**: 判断の要らない手順(待ち・集計・検査・記録・整形・起動の組み立て)はスクリプトが進め、AI は判断と中身だけを担う。同じ手当てが 2 度目になったら、まずその手順自体を消せないかを問う。消せないときだけ雛形へ入れる。決まりを足すより、要らない決まりを減らす方を選ぶ。 6. **どのプロジェクトでも使える形を保つ**: スクリプトにするのは手順の骨組みだけにする。プロジェクトごとに違うもの(テストのコマンド・起点のブランチ・配布の手段・言語・ランタイム・課題の置き場)は、設定か引数で受ける。ai-plugins の形を既定に埋め込まない。 @@ -34,4 +34,4 @@ Claude Code / Codex を使うチームに、プラグインとして、また We ## 改訂 -MVV は進化する。改訂は利用者の承認で成立し、版・理由・前の版との差分を記録に残す。方向の誤りは、人が AI の判断を覆した回数・AI が決められなかった回数・検査をすり抜けて利用者へ届いた不具合の件数で検出し、閾値を超えたら改訂を提案する。手順の正本は `development-workflow` の `references/project-mvv.md` の「改訂」にある。 +MVV は進化する。改訂は利用者の承認で成立し、版・理由・前の版との差分を記録に残す。方向の誤りは、人が AI の判断を覆した回数・AI が決められなかった回数・検査をすり抜けて利用者へ届いた不具合の件数で検出する。覆しは数える前に 1 件ずつ原因を切り分ける。判定へ渡した材料や判定の仕組みの不具合によるもの(後の検査で解消した止まりを材料が失敗として載せた、など)は方向の誤りに数えず、判定側の不具合として起票して直す。原因が MVV の文言にある覆しが閾値を超えたら改訂を提案する。手順の正本は `development-workflow` の `references/project-mvv.md` の「改訂」にある。 diff --git a/CHANGELOG.md b/CHANGELOG.md index bd35dd1c0..213a7cf89 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,18 @@ **開発版(接尾辞の付いた版)は載せない。** `9.8.0` は `9.8.0-dev.1` までしか出ておらず、 その内容は `10.0.0` で届いている。 +## [ndf 10.27.31] - 2026-11-06 + +- Fix: 共有の環境の UV_PROJECT_ENVIRONMENT を子へ漏らさず、状態の一覧の失敗で並列の計画を終えない(#2509 の即時修正)(#2651) +- ndf: リリース記録の版が `v` で始まる形で書かれていても、先頭の `v` を落とした版として読む(#2650)(#2652) +- ndf: リリース後に実行するテストは、先頭の `v` を落とした版どうしを比べて選ぶ(#2650)(#2652) +- Update: プロジェクト MVV を版 6 にする(Value 3 と「改訂」の節)(#2657) +- Fix: MVV の連続の提案のテストが秒の境で落ちる(#2661) +- Fix: 並列の計画の状態が消える 4 か所を止血する(#2647 の即時修正)(#2668) +- `ndf` プラグインの並列実行で状態の一覧の失敗が続くと、利用者へ問いが出る(#2671)(#2673) +- Docs: #2567 の設計へ承認ゲート 1 の答えを反映し、ボリュームを既定で残す(#2674) +- `loop exclude` で選定を取り直しても、承認とマージが済んだ設計の課題は順位によらず先頭に選ばれる(#2678) + ## [ndf 10.27.27] - 2026-11-05 - ndf の開発ループは、前提の課題が閉じていない課題を選定の候補に入れない。前提の課題が閉じると、その課題は選定の候補に戻る(#2622)(#2625) diff --git a/README.md b/README.md index 4de9545c8..f17fa39d3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.27 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.31 | 開発の作業手順(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.27** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.31** に入る 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 ec627b3a6..b217f2401 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -214,9 +214,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | スプリント状態ファイル | `sprint_state` | スプリントのプラン・done・承認ゲートの記録・MVV・版を持つファイル(パスは呼ぶ側が決め、手順書の例は `sprint-state.json`。目録 `sprint.json` とは別のファイル) | ミッション状態ファイル、ミッションの状態 | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | | 開発ループ | — | スプリントを 1 つ終えるたびに、棚卸しの結果から次のスプリントを選んで続けて始める進め方。引数で切り替え、止まる条件に当たるまで繰り返す | — | — | `docs/specifications/ndf-workflow-development-loop.md` | | 周回 | — | 開発ループの 1 回分。次のスプリントの選定から、そのスプリントの棚卸しと止まる条件の判定まで | — | — | `docs/specifications/ndf-workflow-development-loop.md` | -| 選定 | — | 開発ループの周回で、実装する課題を棚卸しの順位と記録から選ぶ処理。選ばなかった課題には落選の理由を残す | — | — | — | -| 前提の課題 | — | ある課題が依存する、別の open の課題。棚卸しが記録した依存と並列の組の依存から順位の算出が合成した辺の先にある。閉じるまで、依存する課題は選定で選ばれない | — | — | — | -| ループの予算 | — | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数え、超えたら止まる | — | — | `docs/specifications/ndf-workflow-development-loop.md` | +| 選定 | — | 開発ループの周回で、実装する課題を棚卸しの順位と記録から選ぶ処理。選ばなかった課題には落選の理由を残す | — | — | `docs/specifications/ndf-loop-select-prerequisites.md` | +| 前提の課題 | — | ある課題が依存する、別の open の課題。棚卸しが記録した依存と並列の組の依存から順位の算出が合成した辺の先にある。閉じるまで、依存する課題は選定で選ばれない | — | — | `docs/specifications/ndf-loop-select-prerequisites.md` | +| ループの予算 | — | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える。周回の数と経過時間は宣言に数を書いたときだけ効き、書かない・null は無制限である | — | — | `docs/specifications/ndf-workflow-development-loop.md` | | 止まる条件 | — | 開発ループを止める、機械で判定できる条件。予算を超えた・同じ失敗が続いた・着手できる課題が無い・MVV の外の課題しか残らない・利用者が止めた | — | — | `docs/specifications/ndf-workflow-development-loop.md` | | ループの記録 | — | 開発ループの周回ごとの選定・承認ゲートの判定・版・費用と秒・止まる条件の判定を持つ記録。メインディレクトリの .ndf/loop/<名>/loop.json が正本で、laps.md はそこから描画する | — | — | `docs/specifications/ndf-workflow-development-loop.md` | | スプリントブランチ | — | 課題の Pull Request を集め、ベースブランチへの Pull Request をスプリントで 1 本にするブランチ(`sprint/<名前>`) | ミッションブランチ、ミッションのブランチ | — | `plugins/ndf/skills/development-workflow/references/glossary.md` | @@ -253,7 +253,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 決定を分けたファイル | — | 設計文書から決定の記録の節だけを別に置いたファイル。名前は `issue-<番号>-design-decisions.md` | — | — | `docs/specifications/ndf-workflow-unit-and-gates.md` | | 決定を読めない文書 | — | 設計 PR で変更した .md のうち、`## 決定の記録` で始まる H2 を持つか決定を分けたファイルであるのに、決定の見出しが 0 件のもの。文書自身の名前が `-design-decisions.md` で終わらないときに限り、決定を分けたファイルの名前だけを示す節は除く。pr-body-decisions.sh の check / sync が 1 で止まる | — | — | `docs/specifications/ndf-workflow-unit-and-gates.md` | | hook の上限 | — | ランタイムの hook の定義(hooks/*.json・dev.agy/hooks.json)の timeout。過ぎるとランタイムが hook を打ち切る | — | — | `docs/specifications/cross-review-reopen-and-hook-deadline.md` | -| 実装の上限 | `impl_timeout` | プランの impl のステップ(worker の実装)の timeout(秒)。.ndf/supervise.json の impl_timeout か supervise.py new の --impl-timeout で決め、無ければ雛形の既定の値 | — | — | `docs/specifications/ndf-supervise-step-budgets.md` | +| 実装の上限 | `impl_timeout` | プランの impl のステップ(worker の実装)の経過時間の上限(秒)。.ndf/supervise.json の impl_timeout か supervise.py new の --impl-timeout に書いたときだけ効き、書かなければ無い | — | — | `docs/specifications/ndf-supervise-step-budgets.md` | | hook の締め切り | — | NDF の hook が自分で決める 1 回の実行の時間の上限(3.5 秒)。hook の上限より短く、過ぎたら残りの判定を飛ばして通し、標準エラーへ 1 行残す | — | — | `docs/specifications/cross-review-reopen-and-hook-deadline.md` | | 適用範囲 | — | 設計した変更が働く範囲。このリポジトリだけで働くのか配布先のリポジトリでも働くのか、プロジェクトごとに違うものを設定か引数のどちらで受けるのか。設計文書の節と承認資料の行に書く | — | — | `docs/specifications/ndf-design-phase.md` | | あるべき姿の根拠 | — | 変更後の形が適切だと言える根拠。外部の一次情報・実測・利用者の指示の原文のどれか。設計文書の節と承認資料の行に書き、無ければ「無し(理由)」。MVV の根拠の項目とは別のもの | — | — | `docs/specifications/ndf-design-phase.md` | @@ -382,6 +382,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | レーン | — | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ。最初の入力は開発ループかスプリントの 1 つ | — | — | `plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md` | | オーガナイザー | — | --parallel <計画名> で起動した conductor の役割。レーンを起動・監視し、レーンの承認待ちを依頼として受けて問うか自分で決め(設計の承認では設計 Pull Request をマージする)、配布の依頼をまとめて本番の配布を 1 本にする。統括役とは別 | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | | 配布の依頼 | — | レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request) | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | +| まとめた配布 | — | オーガナイザーが複数のレーンの Pull Request を 1 つの版として出す配布。提示のプランと本番のプランからなる | — | — | — | +| 配布の組み方 | — | 配布の依頼を受けたときに、まとめた配布をどう扱うか。組む・組まない・組めないの 3 つがあり、宣言から決まる | — | — | — | +| 組み直しの依頼 | — | 組めなかった配布の依頼を、人が「直してから組み直す」と答えた後に耐久ワークフローが受けている依頼へ入れ直したもの。元の依頼の答えは変えない | — | — | — | | 一時レーン | — | 並列の計画のオーガナイザーが、持ち主のいない共有の作業(まとめた配布のプランの実行・その失敗の対応・止まったレーンの保留の周回の残り)のためだけに起こすラッパー(<計画名>-rel<連番>、役割 helper)。同時に 1 本までで、作業を終えると自分で止まる | — | — | `plugins/ndf/skills/development-workflow/references/parallel.md` | | 保留の周回 | — | 並列の計画のループのレーンで、答えを待たずに締めた周回(配布待ち・質問待ち)。課題は締め直すまで選定から外し、答えの後に残りの工程を流して 1 回だけ締め直す | — | — | `plugins/ndf/skills/development-workflow/references/loop.md` | | 配布待ち | — | 配布の依頼を送り、答えを待たずに締めた周回の結果。まとめはまだ流していない | — | — | `plugins/ndf/skills/development-workflow/references/loop.md` | @@ -437,6 +440,22 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 共有の環境 | — | NDF のスクリプトとラッパーが使う Python の環境。置き場は ~/.cache/ndf/venv/<版>(NDF_DEPS_VENV で変えられる)。プラグインの版ごとに 1 つで、同じ版のスクリプトとラッパーが共に使う | — | — | — | | 一覧の失敗の問い | — | 状態の一覧の失敗が停滞の区切りより長く続いたときに、並列の計画のオーガナイザーの耐久ワークフローが自分で立てる問い(kind は observe)。一覧の終了コードと標準エラーの末尾 1 行を持ち、一覧が成功に戻れば外れる | — | — | — | | 状態の一覧 | — | ラッパーの relay.py ls --json が返す、名前つきのラッパーごとの状態(running・awaiting_approval・stopped など)と最後に動いた時刻の一覧。オーガナイザーはこれでレーンの状態を観測する | — | — | — | +| 設計承認ゲート | — | 設計 Pull Request のマージの前に人の承認を求める承認ゲート。documentation モードでは企画承認に当たる | — | — | — | +| リリース承認ゲート | — | 本番系へ届く操作(本番へのリリース・operation の実行・自動反映の本番チャネルへのマージ)の前に人の承認を求める承認ゲート。documentation モードでは制作物承認に当たる | — | — | — | +| 人へ出す文 | — | Skill・references・エージェントの定義の本文と、スクリプト・hook が人へ見せる文(問い・承認資料・報告・案内・エラーの文)。機械が読む識別子と対になる | — | — | — | +| 機械が読む識別子 | — | スクリプトが値の一致で読む文字列(問いの区分・契約の種類の値・記録の名前・ラベルなど)。人へ出す文と対になる | — | — | — | +| 書いた日の記録 | — | 書いた日時点の内容を残し、以後の構成変更に追随しない文書。発表資料・記事・アーカイブ・開発履歴・変更履歴の出した版の項・issues/ の作業記録 | — | — | — | +| リリース待ち | — | リリースの依頼を送り、答えを待たずに締めた周回の結果 | — | — | — | +| リリースの依頼 | — | レーンがリリース承認の手前で、リリースをオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request) | — | — | — | +| まとめたリリース | — | オーガナイザーが複数のレーンのリリースの依頼をまとめた 1 回のリリース | — | — | — | +| 利用先のリポジトリ | — | NDF を導入して使っているリポジトリ | — | — | — | +| 旧い値の読み替え | — | 版を上げる前に書かれた記録の値を、読む時点で新しい値へ変換すること | — | — | — | +| 禁止語 | — | 用語集の設定が、検査する範囲で新しく書くことを禁じた語 | — | — | — | +| 無進捗の打ち切り | — | 実装のステップで、進みの観測が無進捗の許容時間を越えて途絶えたときだけ打ち切ること | — | — | — | +| 無進捗の許容時間 | `impl_stall_seconds` | 実装のステップで、最後の進みの観測から無進捗の打ち切りまでに待つ秒。.ndf/supervise.json の impl_stall_seconds か supervise.py new の --impl-stall で決め、無ければ 1800 秒 | — | — | — | +| 進みの観測 | — | 実装のステップの間に、会話の記録の伸び・進捗ログの行・コミットの増加のどれかを見つけること | — | — | — | +| 周回の費用の内訳 | — | 周回の費用を、プランの claude -p・同じプランの中の別の起動・USD に換算できない起動に分けた記録。周回の費用の和は、使用量の帳簿の和とスプリントの報告の和の大きい方とする | — | — | — | +| 実行環境の調査 | — | 動く場所・CPU のコア数・メモリ・同時に動くレーンとプランの数を調べ、1 周回で選ぶ課題の数の候補を出す工程 | — | — | — | ## NDF の Slack 通知(`ndf-notification`) @@ -674,6 +693,7 @@ release が走らせるリリースの種別・リリースコマンド・公開 | リリース種別 | `release_type` | リリースの種類。production(本番リリース)/ verification(検証リリース) | — | — | `docs/specifications/ndf-release-steps-and-token-usage-snapshot.md` | | 公開操作 | — | release の手順 4 で行う操作。レジストリへの公開・配備先への反映・署名した配布物の設置・ストアへの提出 | 公開の操作 | — | `plugins/ndf/skills/release/references/completion-check.md` | | リリースの経路 | `release_route` | 変更が本番系へ届く道筋の種類。template(release.form の雛形で組む)/ merge(マージで反映)/ manual(手で反映)/ none(届けない)。release.form があればそれ、無ければ宣言の delivery から決まる。リリースの形とは別の軸 | — | — | — | +| CD | — | 継続的デリバリー(Continuous Delivery)。ブランチへのマージを起点に、環境への反映をリポジトリの外の仕組みが行うこと。delivery の kind: auto で宣言する | — | — | — | | 昇格の Pull Request | `promotion_pr` | ベースブランチから本番チャネルへ変更を入れる Pull Request | — | — | — | | 昇格のプラン | — | リリースの経路 promote のための「本番」のステージのプラン(plan_promote)。昇格の Pull Request を作り、承認ゲート 2 の後にマージする | — | — | — | | 昇格の承認資料 | — | 昇格の承認ゲート 2 で利用者か MVV 判定が見る承認資料({state_dir}/work/approval-promote.md)。承認したコミットの欄を持ち、normal では promote の承認ゲート 2 の停止が、fast / auto では prepare が書く。promote-approved はこの欄の SHA が昇格の Pull Request の先端のときだけマージする | — | — | `docs/specifications/ndf-release-approved-commit.md` | @@ -688,10 +708,12 @@ release が走らせるリリースの種別・リリースコマンド・公開 | 承認の記録 | — | 承認資料の欄の 1 つ。ゲート 2 を承認したときの承認したコミットの SHA と、承認した者・時刻を持つ。承認資料を書き直すと消え、承認し直すまで本番の配布は通らない | — | — | `docs/specifications/ndf-release-approved-commit.md` | | 更新情報の節 | — | plugin の README の `## 更新情報` の節。版を名乗らず、CHANGELOG.md の版の節を参照する固定の案内だけを持つ。配布の手順(bump・changelog・notes)は書かない | — | — | `docs/specifications/ndf-release-readme-update-info.md` | | 版の節 | — | CHANGELOG.md の `## [ <基底の版>]` の見出しから次の `## ` までの節。その版の利用者向けの変化と移行の手順の全件の正本 | — | — | `docs/specifications/ndf-release-readme-update-info.md` | +| 範囲の終点 | — | 版の節と承認資料の「含む PR」に並べる PR の範囲の、新しい側の端のコミット。出す版のコミット(リリースのブランチの先頭か、承認に出すコミット)を指す | — | — | — | +| リリースタグ | — | 正式版を出したときに本番チャネルのコミットへ打つタグ `<プラグイン>--v<版>`。リリースタグのある版の節は工程から書き換えない | — | — | — | | 子の箇条 | — | PR 本文の箇条書きで、前の項目より字下げした箇条。親の項目の一部として読む | — | — | `docs/specifications/ndf-release-readme-update-info.md` | | リリース後テストの記録 | — | release-verification が Pull Request へ残す、見出し `## リリース後テスト` から `合否:` までのブロック。`対象の版:` の行と課題ごとの表を持つ | — | — | `docs/specifications/ndf-production-release-record.md` | | 版数を上げない配布 | — | 版数を変えず、既定ブランチへのマージかコミットの反映で本番へ届ける配布 | — | — | `docs/specifications/ndf-production-release-record.md` | -| 範囲の PR | — | 直前の正式版のタグから終点(承認したコミットか、ベースブランチの先端)までに、ベースブランチへ merge commit か squash merge で入った PR と、スプリント経由の PR の和。版上げの PR と、PR でない番号(squash の件名の `(#N)` が課題を指すもの)は含まない | — | — | `docs/specifications/ndf-release-approval-included-prs.md` | +| 範囲の PR | — | 直前の正式版のタグから範囲の終点までに、ベースブランチへ merge commit か squash merge で入った PR と、スプリント経由の PR の和。`--prs` で渡した番号は、マージコミットが範囲の終点の祖先であるものだけを含む。版上げの PR と、PR でない番号(squash の件名の `(#N)` が課題を指すもの)は含まない | — | — | `docs/specifications/ndf-release-approval-included-prs.md` | | 版上げの PR | — | head のブランチが `release/` で始まる PR。版数と CHANGELOG だけを変え、中身の変更を持たない | — | — | `docs/specifications/ndf-release-approval-included-prs.md` | | 合格の行 | — | リリース後テストの表で、`結果` の列が `合格` で始まる行 | — | — | `docs/specifications/ndf-sprint-close-acceptance-criteria.md` | | 手で足した節 | — | 承認資料の `## ` の節のうち、配布のステップ(approval-facts・notes --approval・changed-plugins --approval)が書く見出しに当たらないもの。作り直しの後も見出しと中身が残る | — | — | `plugins/ndf/skills/release/references/release-steps.md` | @@ -707,6 +729,13 @@ release が走らせるリリースの種別・リリースコマンド・公開 | 対象の版 | — | リリース後テストの記録の `対象の版:` の行に書く版。全角の括弧から後ろは注記として読まない。先頭の小文字の `v` の有無は同じ版として扱う | — | — | — | | 本番の版 | — | リリース記録の `版:` の行の `→` の右に書く、本番へ出した版。全角の括弧から後ろは注記として読まない。読み取った値は先頭の `v` を付けない形にそろえる | — | — | — | | 版の表記の揺れ | — | 同じ版を指す書き方の違いのうち、リリース記録とリリース後テストの記録の読み取りが吸収するもの。先頭の小文字の `v` の有無だけを指す | — | — | — | +| デプロイ | — | 開発・本番を問わず、環境に変更を届けること。本番へのデプロイだけをリリースと呼ぶ | — | — | — | +| 開発版のデプロイ | — | 開発版のチャネルへ版を出すことと、検証の環境への反映。フェーズの名前でもある | — | — | — | +| デプロイなし | — | リリース記録の段階の値。どの環境へも届けずに工程を終えたこと | — | — | — | +| リリース対象 | — | リリースで利用者へ届くファイルの集まり。ai-plugins では plugins/ と .claude-plugin/ | — | — | — | +| アーティファクト | — | ビルドや署名で作る、公開操作で届ける生成物(パッケージ・署名したバイナリなど) | — | — | — | +| 導入先のランタイム | — | マーケットプレイスのプラグインを導入できる形で届けるランタイム(Claude Code と Codex) | — | — | — | +| 利用者向けの変化なし | — | Pull Request の本文の `## 利用者向けの変化` が「無し」(「なし」も同じ)だけを持つこと | — | — | — | ## NDF の指示書チェック(`ndf-instructions`) @@ -789,6 +818,8 @@ worktree の設定・セッション開始 hook・テスト環境の割り当て | 依存物 | — | パッケージマネージャが入れる、追跡されないディレクトリ(`vendor/`・`node_modules/`・`.venv` など)。worktree には最初から無い | — | — | — | | 依存の用意 | — | worktree を作った直後、または未用意の既存 worktree を使う前(使い回し・手で打つやり直し)に、宣言に従って依存物を worktree で使える状態にすること。手段(コマンド・参照・複製)は問わない | — | — | — | | 用意の印 | — | 依存の用意が済んだことを示す、worktree ごとの git ディレクトリの `ndf-deps` ファイル。使い回すときはこれを見てやり直しを省く | — | — | — | +| テスト環境 | — | 1 つの worktree に対応して立ち上げる、テスト実行に必要なサービスの一式。番号で識別され、他の worktree とポート・データ保存先・ネットワークを共有しない | — | — | — | +| テスト環境の用意 | — | 到達の確認が通らないときに、テスト環境を起こして到達を確かめ直すこと | — | — | — | | 到達の確認 | — | コンテナの中から worktree の探りの印を読み、テストのコンテナが worktree を見ているかを確かめること | — | — | — | | ツールのパス | `tool_paths` | CLI が起動したツール(MCP サーバーなど)が、利用者の操作なしに worktree の中で書き換える既知のパス。既定(`.serena/project.yml` と `.serena/serena_config.yml`)と `.ndf/worktree.json` の `tool_paths`(`add` / `remove`)で決まる。検査とコミットの対象から外す | — | — | — | | 残る状態 | — | NDF の実行が作り、その実行が終わった後もディスクに残るもの(レビュー worktree・状態ファイル・一時ファイル・投稿キューの項目)(#2229) | — | — | `docs/specifications/ndf-run-state-owner-and-sweep.md` | @@ -991,3 +1022,7 @@ NDF を Web UI から操作できるサービスとして提供するときの | 作成の冪等の鍵 | — | Factory で新しい識別子を作る作成の要素に、呼ぶ側が付ける鍵。同じ利用者・同じ操作・同じ鍵の作成を 1 件にする。進捗の事象の冪等の鍵とは別(#2384) | — | — | `docs/specifications/ndf-factory-agent-access.md` | | 承認待ちの一覧 | — | アカウントのスプリントの実行のうち、答えの無い承認の依頼(ゲート 1・ゲート 2・判断)を、ループの名前と周回の番号とともに 1 つに並べた、読むだけの画面と API の読み出し | — | — | `docs/specifications/ndf-factory-sprint-views.md` | | 取り込みの衝突 | — | ベースブランチの取り込みで解けずにスプリントの実行が止まったこと。進捗の事象では attention の理由 base_conflict で届く | — | — | `docs/specifications/ndf-factory-sprint-views.md` | +| 秘密のキャッシュ | — | Secrets Manager から読んだ値を Lambda の実行環境の中で保持する `factory/api/` の仕組み。成功した値は以後も同じ値を返し、失敗した読み込みは捨てて次の要求で読み直す | — | — | — | +| 端末の控え | — | 画面が最後に受け取った読み出しの応答を、暗号化して端末に残したもの。受け取った時刻と保存の期限を持つ | — | — | — | +| 確定待ちの操作 | — | オフラインの間に押した取り消せない操作で、つながったときに今の状態と照らしてから確定するもの | — | — | — | +| リリースの実行 | — | Factory のオーガナイザーが、まとめたリリース 1 つのために起こすスプリントの実行 | — | — | — | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index ef14ec0cf..fb5e0909b 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -2440,6 +2440,20 @@ "source": "", "pending_source": "issues/old/issue-1337-design.md" }, + { + "term": "テスト環境", + "context": "ndf-worktree", + "meaning": "1 つの worktree に対応して立ち上げる、テスト実行に必要なサービスの一式。番号で識別され、他の worktree とポート・データ保存先・ネットワークを共有しない", + "source": "", + "pending_source": "issues/issue-2567-requirements.md" + }, + { + "term": "テスト環境の用意", + "context": "ndf-worktree", + "meaning": "到達の確認が通らないときに、テスト環境を起こして到達を確かめ直すこと", + "source": "", + "pending_source": "issues/issue-2567-requirements.md" + }, { "term": "到達の確認", "context": "ndf-worktree", @@ -2575,20 +2589,18 @@ "term": "選定", "context": "ndf-workflow", "meaning": "開発ループの周回で、実装する課題を棚卸しの順位と記録から選ぶ処理。選ばなかった課題には落選の理由を残す", - "source": "", - "pending_source": "issues/issue-2604-requirements.md" + "source": "docs/specifications/ndf-loop-select-prerequisites.md" }, { "term": "前提の課題", "context": "ndf-workflow", "meaning": "ある課題が依存する、別の open の課題。棚卸しが記録した依存と並列の組の依存から順位の算出が合成した辺の先にある。閉じるまで、依存する課題は選定で選ばれない", - "source": "", - "pending_source": "issues/issue-2604-requirements.md" + "source": "docs/specifications/ndf-loop-select-prerequisites.md" }, { "term": "ループの予算", "context": "ndf-workflow", - "meaning": "開発ループが使ってよい量の上限。周回の数・費用・経過時間で数え、超えたら止まる", + "meaning": "開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える。周回の数と経過時間は宣言に数を書いたときだけ効き、書かない・null は無制限である", "source": "docs/specifications/ndf-workflow-development-loop.md" }, { @@ -2676,6 +2688,12 @@ "code": "release_route", "pending_source": "issues/old/issue-1336-design.md" }, + { + "term": "CD", + "context": "ndf-release", + "meaning": "継続的デリバリー(Continuous Delivery)。ブランチへのマージを起点に、環境への反映をリポジトリの外の仕組みが行うこと。delivery の kind: auto で宣言する", + "pending_source": "issues/issue-2616-requirements.md" + }, { "term": "昇格の Pull Request", "context": "ndf-release", @@ -3242,7 +3260,7 @@ { "term": "実装の上限", "context": "ndf-workflow", - "meaning": "プランの impl のステップ(worker の実装)の timeout(秒)。.ndf/supervise.json の impl_timeout か supervise.py new の --impl-timeout で決め、無ければ雛形の既定の値", + "meaning": "プランの impl のステップ(worker の実装)の経過時間の上限(秒)。.ndf/supervise.json の impl_timeout か supervise.py new の --impl-timeout に書いたときだけ効き、書かなければ無い", "code": "impl_timeout", "source": "docs/specifications/ndf-supervise-step-budgets.md" }, @@ -3597,6 +3615,18 @@ "meaning": "CHANGELOG.md の `## [ <基底の版>]` の見出しから次の `## ` までの節。その版の利用者向けの変化と移行の手順の全件の正本", "source": "docs/specifications/ndf-release-readme-update-info.md" }, + { + "term": "範囲の終点", + "context": "ndf-release", + "meaning": "版の節と承認資料の「含む PR」に並べる PR の範囲の、新しい側の端のコミット。出す版のコミット(リリースのブランチの先頭か、承認に出すコミット)を指す", + "source": "" + }, + { + "term": "リリースタグ", + "context": "ndf-release", + "meaning": "正式版を出したときに本番チャネルのコミットへ打つタグ `<プラグイン>--v<版>`。リリースタグのある版の節は工程から書き換えない", + "source": "" + }, { "term": "子の箇条", "context": "ndf-release", @@ -3921,7 +3951,7 @@ { "term": "範囲の PR", "context": "ndf-release", - "meaning": "直前の正式版のタグから終点(承認したコミットか、ベースブランチの先端)までに、ベースブランチへ merge commit か squash merge で入った PR と、スプリント経由の PR の和。版上げの PR と、PR でない番号(squash の件名の `(#N)` が課題を指すもの)は含まない", + "meaning": "直前の正式版のタグから範囲の終点までに、ベースブランチへ merge commit か squash merge で入った PR と、スプリント経由の PR の和。`--prs` で渡した番号は、マージコミットが範囲の終点の祖先であるものだけを含む。版上げの PR と、PR でない番号(squash の件名の `(#N)` が課題を指すもの)は含まない", "source": "docs/specifications/ndf-release-approval-included-prs.md" }, { @@ -5374,6 +5404,24 @@ "meaning": "レーンが承認ゲート 2 の手前で、本番の配布をオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request)", "source": "plugins/ndf/skills/development-workflow/references/parallel.md" }, + { + "term": "まとめた配布", + "context": "ndf-workflow", + "meaning": "オーガナイザーが複数のレーンの Pull Request を 1 つの版として出す配布。提示のプランと本番のプランからなる", + "pending_source": "issues/issue-2616-requirements.md" + }, + { + "term": "配布の組み方", + "context": "ndf-workflow", + "meaning": "配布の依頼を受けたときに、まとめた配布をどう扱うか。組む・組まない・組めないの 3 つがあり、宣言から決まる", + "pending_source": "issues/issue-2616-design.md" + }, + { + "term": "組み直しの依頼", + "context": "ndf-workflow", + "meaning": "組めなかった配布の依頼を、人が「直してから組み直す」と答えた後に耐久ワークフローが受けている依頼へ入れ直したもの。元の依頼の答えは変えない", + "pending_source": "issues/issue-2616-design.md" + }, { "term": "一時レーン", "context": "ndf-workflow", @@ -5785,6 +5833,190 @@ "meaning": "同じ版を指す書き方の違いのうち、リリース記録とリリース後テストの記録の読み取りが吸収するもの。先頭の小文字の `v` の有無だけを指す", "source": "", "pending_source": "issues/issue-2225-requirements.md" + }, + { + "term": "秘密のキャッシュ", + "context": "ndf-factory", + "meaning": "Secrets Manager から読んだ値を Lambda の実行環境の中で保持する `factory/api/` の仕組み。成功した値は以後も同じ値を返し、失敗した読み込みは捨てて次の要求で読み直す", + "source": "", + "pending_source": "issues/issue-2276-requirements.md" + }, + { + "term": "端末の控え", + "context": "ndf-factory", + "meaning": "画面が最後に受け取った読み出しの応答を、暗号化して端末に残したもの。受け取った時刻と保存の期限を持つ", + "pending_source": "issues/issue-2383-requirements.md" + }, + { + "term": "確定待ちの操作", + "context": "ndf-factory", + "meaning": "オフラインの間に押した取り消せない操作で、つながったときに今の状態と照らしてから確定するもの", + "pending_source": "issues/issue-2383-requirements.md" + }, + { + "term": "設計承認ゲート", + "context": "ndf-workflow", + "meaning": "設計 Pull Request のマージの前に人の承認を求める承認ゲート。documentation モードでは企画承認に当たる", + "pending_source": "issues/issue-2619-requirements.md" + }, + { + "term": "リリース承認ゲート", + "context": "ndf-workflow", + "meaning": "本番系へ届く操作(本番へのリリース・operation の実行・自動反映の本番チャネルへのマージ)の前に人の承認を求める承認ゲート。documentation モードでは制作物承認に当たる", + "pending_source": "issues/issue-2619-requirements.md" + }, + { + "term": "人へ出す文", + "context": "ndf-workflow", + "meaning": "Skill・references・エージェントの定義の本文と、スクリプト・hook が人へ見せる文(問い・承認資料・報告・案内・エラーの文)。機械が読む識別子と対になる", + "pending_source": "issues/issue-2619-requirements.md" + }, + { + "term": "機械が読む識別子", + "context": "ndf-workflow", + "meaning": "スクリプトが値の一致で読む文字列(問いの区分・契約の種類の値・記録の名前・ラベルなど)。人へ出す文と対になる", + "pending_source": "issues/issue-2619-requirements.md" + }, + { + "term": "デプロイ", + "context": "ndf-release", + "meaning": "開発・本番を問わず、環境に変更を届けること。本番へのデプロイだけをリリースと呼ぶ", + "source": "", + "pending_source": "issues/issue-2620-requirements.md" + }, + { + "term": "書いた日の記録", + "context": "ndf-workflow", + "meaning": "書いた日時点の内容を残し、以後の構成変更に追随しない文書。発表資料・記事・アーカイブ・開発履歴・変更履歴の出した版の項・issues/ の作業記録", + "source": "", + "pending_source": "issues/issue-2620-requirements.md" + }, + { + "term": "開発版のデプロイ", + "context": "ndf-release", + "meaning": "開発版のチャネルへ版を出すことと、検証の環境への反映。フェーズの名前でもある", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "デプロイなし", + "context": "ndf-release", + "meaning": "リリース記録の段階の値。どの環境へも届けずに工程を終えたこと", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "リリース待ち", + "context": "ndf-workflow", + "meaning": "リリースの依頼を送り、答えを待たずに締めた周回の結果", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "リリースの依頼", + "context": "ndf-workflow", + "meaning": "レーンがリリース承認の手前で、リリースをオーガナイザーへ頼むために送る 1 件のメッセージ(parallel.py request)", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "リリースの実行", + "context": "ndf-factory", + "meaning": "Factory のオーガナイザーが、まとめたリリース 1 つのために起こすスプリントの実行", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "まとめたリリース", + "context": "ndf-workflow", + "meaning": "オーガナイザーが複数のレーンのリリースの依頼をまとめた 1 回のリリース", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "リリース対象", + "context": "ndf-release", + "meaning": "リリースで利用者へ届くファイルの集まり。ai-plugins では plugins/ と .claude-plugin/", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "アーティファクト", + "context": "ndf-release", + "meaning": "ビルドや署名で作る、公開操作で届ける生成物(パッケージ・署名したバイナリなど)", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "導入先のランタイム", + "context": "ndf-release", + "meaning": "マーケットプレイスのプラグインを導入できる形で届けるランタイム(Claude Code と Codex)", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "利用先のリポジトリ", + "context": "ndf-workflow", + "meaning": "NDF を導入して使っているリポジトリ", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "利用者向けの変化なし", + "context": "ndf-release", + "meaning": "Pull Request の本文の `## 利用者向けの変化` が「無し」(「なし」も同じ)だけを持つこと", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "旧い値の読み替え", + "context": "ndf-workflow", + "meaning": "版を上げる前に書かれた記録の値を、読む時点で新しい値へ変換すること", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "禁止語", + "context": "ndf-workflow", + "meaning": "用語集の設定が、検査する範囲で新しく書くことを禁じた語", + "source": "", + "pending_source": "issues/issue-2620-design.md" + }, + { + "term": "無進捗の打ち切り", + "context": "ndf-workflow", + "meaning": "実装のステップで、進みの観測が無進捗の許容時間を越えて途絶えたときだけ打ち切ること", + "source": "", + "pending_source": "issues/issue-2505-requirements.md" + }, + { + "term": "無進捗の許容時間", + "context": "ndf-workflow", + "meaning": "実装のステップで、最後の進みの観測から無進捗の打ち切りまでに待つ秒。.ndf/supervise.json の impl_stall_seconds か supervise.py new の --impl-stall で決め、無ければ 1800 秒", + "code": "impl_stall_seconds", + "source": "", + "pending_source": "issues/issue-2505-design.md" + }, + { + "term": "進みの観測", + "context": "ndf-workflow", + "meaning": "実装のステップの間に、会話の記録の伸び・進捗ログの行・コミットの増加のどれかを見つけること", + "source": "", + "pending_source": "issues/issue-2505-design.md" + }, + { + "term": "周回の費用の内訳", + "context": "ndf-workflow", + "meaning": "周回の費用を、プランの claude -p・同じプランの中の別の起動・USD に換算できない起動に分けた記録。周回の費用の和は、使用量の帳簿の和とスプリントの報告の和の大きい方とする", + "source": "", + "pending_source": "issues/issue-2505-design.md" + }, + { + "term": "実行環境の調査", + "context": "ndf-workflow", + "meaning": "動く場所・CPU のコア数・メモリ・同時に動くレーンとプランの数を調べ、1 周回で選ぶ課題の数の候補を出す工程", + "source": "", + "pending_source": "issues/issue-2505-requirements.md" } ] } diff --git a/docs/metrics/ndf-token-usage/2026-11-06.json b/docs/metrics/ndf-token-usage/2026-11-06.json new file mode 100644 index 000000000..7116976f2 --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-06.json @@ -0,0 +1,1165 @@ +{ + "per_pr": [ + { + "version": "10.27.26", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 21, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.26", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 10, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 18, + "sessions_with_pr": 2, + "prs": 3, + "conductor_cost": 881316.15, + "supervisor_cost": 538667.2, + "worker_cost": 540072.3666666667, + "context": 10090612.0, + "out": 85824.33333333333, + "minutes": 83.19420555565092, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.27.27", + "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.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "sessions": 8, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 1006640.6500000001, + "supervisor_cost": 0.0, + "worker_cost": 0.0, + "context": 8004587.0, + "out": 44383.0, + "minutes": 96.87911666631699, + "codex_input": 0.0, + "codex_out": 0.0, + "kiro_credit": 0.0, + "claude_seat_cost": 0.0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "sessions": 20, + "sessions_with_pr": 0, + "prs": 0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "sessions": 1, + "sessions_with_pr": 1, + "prs": 1, + "conductor_cost": 1597741.9, + "supervisor_cost": 1144169.9, + "worker_cost": 0.0, + "context": 17968320.0, + "out": 120495.0, + "minutes": 197.639566663901, + "codex_input": 955936.0, + "codex_out": 6049.0, + "kiro_credit": 0.0, + "claude_seat_cost": 725168.3 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "sessions": 7, + "sessions_with_pr": 0, + "prs": 0 + } + ], + "per_role": [ + { + "version": "10.27.26", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 21, + "cost": 628338.6071428572, + "context": 4223153.80952381, + "out": 31700.095238095237, + "minutes": 6.824388889660911, + "p": 66703.57142857143, + "k": 30.857142857142858, + "w5": 0.0, + "w1h": 132626.52380952382, + "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.26", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 1, + "cost": 33622.0, + "context": 13767.0, + "out": 1218.0, + "minutes": 0.23166666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 13765.0 + }, + { + "version": "10.27.26", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 1, + "cost": 402782.00000000006, + "context": 3303568.0, + "out": 20889.0, + "minutes": 3.8621166666348774, + "p": 31332.0, + "k": 42.0, + "w5": 110899.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.26", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "その他", + "count": 3, + "cost": 74021.06666666667, + "context": 70038.0, + "out": 4166.0, + "minutes": 0.4469111111429003, + "p": 3427.3333333333335, + "k": 2.6666666666666665, + "w5": 40158.666666666664, + "w1h": 0.0, + "rewrites": 3, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 92507, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.10888333320617676 + }, + { + "version": "10.27.26", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 10, + "cost": 325735.46499999997, + "context": 1872965.9, + "out": 12757.3, + "minutes": 4.565730000336965, + "p": 62867.6, + "k": 17.9, + "w5": 0.0, + "w1h": 86290.4, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 89499, + "rewrite_gap_median": null + }, + { + "version": "10.27.26", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 38984.55, + "context": 16425.0, + "out": 1450.0, + "minutes": 0.295, + "k": 1.0, + "w5": 0.0, + "w1h": 15852.0 + }, + { + "version": "10.27.26", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 3, + "cost": 106322.41666666667, + "context": 114382.33333333333, + "out": 5292.333333333333, + "minutes": 0.9524499985906814, + "p": 30383.666666666668, + "k": 2.3333333333333335, + "w5": 61781.0, + "w1h": 0.0, + "rewrites": 2, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 70557, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.7374749998251597 + }, + { + "version": "10.27.26", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 5, + "cost": 73952.01999999999, + "context": 67854.8, + "out": 4235.0, + "minutes": 0.8320466653505961, + "p": 25217.6, + "k": 2.0, + "w5": 41150.4, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 25788, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.7210666696230571 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 18, + "cost": 889721.4222222223, + "context": 4364178.0, + "out": 48060.555555555555, + "minutes": 40.33063240713543, + "p": 58655.055555555555, + "k": 26.77777777777778, + "w5": 0.0, + "w1h": 221105.0, + "rewrites": 4, + "rewrites_after_5m": 4, + "rewrites_untimed": 0, + "rewrite_tokens": 474655, + "rewrite_tokens_after_5m": 474655, + "read_tokens_after_5m": 4139382, + "rewrite_gap_median": 108.12083333333334 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 2, + "cost": 22682.0, + "context": 10734.5, + "out": 243.0, + "minutes": 0.10833333333333334, + "k": 1.0, + "w5": 0.0, + "w1h": 10732.5 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 2, + "cost": 28115.725, + "context": 14418.0, + "out": 1597.5, + "minutes": 0.3108333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 9951.5 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 10, + "cost": 12317.3, + "context": 4855.4, + "out": 521.7, + "minutes": 0.22333333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 4853.4 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:code-reviewer", + "count": 1, + "cost": 284586.94999999995, + "context": 1279247.0, + "out": 10978.0, + "minutes": 5.554433333873749, + "p": 45444.0, + "k": 17.0, + "w5": 88471.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.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 1, + "cost": 1118025.2, + "context": 4198506.0, + "out": 57610.0, + "minutes": 99.70819999774297, + "p": 36874.0, + "k": 30.0, + "w5": 516656.0, + "w1h": 0.0, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 332553, + "rewrite_tokens_after_5m": 332553, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 40.173266667127606 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "その他", + "count": 2, + "cost": 141792.8, + "context": 185844.5, + "out": 7551.0, + "minutes": 0.7695416649182637, + "p": 3625.0, + "k": 4.0, + "w5": 74301.0, + "w1h": 0.0, + "rewrites": 2, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 99768, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.27115833163261416 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 6, + "cost": 270036.18333333335, + "context": 961338.0, + "out": 17167.0, + "minutes": 18.630833333333335, + "k": 20.666666666666668, + "w5": 0.0, + "w1h": 69798.0 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1712924.55, + "context": 8041972.0, + "out": 50324.0, + "minutes": 218.25076666673024, + "p": 31718.0, + "k": 50.0, + "w5": 0.0, + "w1h": 543127.0, + "rewrites": 3, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 388662, + "rewrite_tokens_after_5m": 355200, + "read_tokens_after_5m": 1125387, + "rewrite_gap_median": 189.33313333193462 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 2, + "cost": 334061.5, + "context": 1633335.5, + "out": 24240.0, + "minutes": 9.681833332777023, + "p": 33590.5, + "k": 16.0, + "w5": 109304.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.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 8, + "cost": 715236.60625, + "context": 3368711.5, + "out": 34924.125, + "minutes": 41.40215208331744, + "p": 51274.125, + "k": 22.75, + "w5": 0.0, + "w1h": 190837.875, + "rewrites": 2, + "rewrites_after_5m": 2, + "rewrites_untimed": 0, + "rewrite_tokens": 171520, + "rewrite_tokens_after_5m": 171520, + "read_tokens_after_5m": 1287143, + "rewrite_gap_median": 128.14184999863306 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 4, + "cost": 24430.75, + "context": 11480.75, + "out": 294.25, + "minutes": 0.125, + "k": 1.0, + "w5": 0.0, + "w1h": 11478.75 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 76177.55, + "context": 32174.0, + "out": 2589.0, + "minutes": 0.47833333333333333, + "k": 1.0, + "w5": 0.0, + "w1h": 31601.0 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 1, + "cost": 10956.0, + "context": 4729.0, + "out": 300.0, + "minutes": 0.2, + "k": 1.0, + "w5": 0.0, + "w1h": 4727.0 + }, + { + "version": "10.27.27", + "mode": "standard", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 12, + "cost": 181458.0458333333, + "context": 602043.0833333334, + "out": 11713.666666666666, + "minutes": 20.85013888888889, + "k": 11.083333333333334, + "w5": 0.0, + "w1h": 47575.0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 20, + "cost": 442692.2075, + "context": 1774596.6, + "out": 22254.65, + "minutes": 10.026649166742962, + "p": 61804.95, + "k": 14.9, + "w5": 0.0, + "w1h": 124441.25, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 467483, + "rewrite_gap_median": null + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 95215.55, + "context": 42193.0, + "out": 2389.0, + "minutes": 0.445, + "k": 1.0, + "w5": 0.0, + "w1h": 41620.0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 6, + "cost": 17643.058333333334, + "context": 13759.0, + "out": 926.1666666666666, + "minutes": 0.1913888888888889, + "k": 1.0, + "w5": 0.0, + "w1h": 6319.166666666667 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "Explore", + "count": 1, + "cost": 172675.95, + "context": 84403.0, + "out": 18748.0, + "minutes": 3.419583332538605, + "p": 22141.0, + "k": 2.0, + "w5": 62260.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 40121, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 2.891433334350586 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 2, + "cost": 117055.425, + "context": 85118.5, + "out": 9611.5, + "minutes": 1.8054666658242544, + "p": 31168.0, + "k": 2.0, + "w5": 53948.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.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 4, + "cost": 92040.1, + "context": 65753.0, + "out": 7376.75, + "minutes": 1.3079250007867813, + "p": 22530.25, + "k": 2.0, + "w5": 43220.75, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 31281, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 1.7574833353360495 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 5, + "cost": 221427.15, + "context": 719887.4, + "out": 11574.6, + "minutes": 2.0076666666666667, + "k": 17.4, + "w5": 0.0, + "w1h": 65402.8 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 1, + "cost": 1597741.9, + "context": 13639732.0, + "out": 44858.0, + "minutes": 197.639566663901, + "p": 42542.0, + "k": 76.0, + "w5": 0.0, + "w1h": 354506.0, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 147356, + "rewrite_tokens_after_5m": 147356, + "read_tokens_after_5m": 1718803, + "rewrite_gap_median": 119.94325000047684 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 3, + "cost": 249314.7666666667, + "context": 956800.0, + "out": 14656.666666666666, + "minutes": 3.2535666677686903, + "p": 43610.666666666664, + "k": 11.666666666666666, + "w5": 106806.66666666667, + "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.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.318", + "reviewers": "claude+codex", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 1, + "cost": 396225.6, + "context": 1458188.0, + "out": 31667.0, + "minutes": 11.826783335208892, + "p": 36793.0, + "k": 14.0, + "w5": 137459.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.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "conductor", + "role": "-", + "agent_type": "-", + "count": 7, + "cost": 455365.47857142857, + "context": 1895101.5714285714, + "out": 24824.0, + "minutes": 17.316069047223955, + "p": 52596.57142857143, + "k": 15.857142857142858, + "w5": 0.0, + "w1h": 121260.28571428571, + "rewrites": 1, + "rewrites_after_5m": 1, + "rewrites_untimed": 0, + "rewrite_tokens": 67418, + "rewrite_tokens_after_5m": 67418, + "read_tokens_after_5m": 157818, + "rewrite_gap_median": 179.4806333343188 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "judge", + "agent_type": "claude -p", + "count": 1, + "cost": 29441.0, + "context": 13579.0, + "out": 457.0, + "minutes": 0.26666666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 13577.0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "mvv", + "agent_type": "claude -p", + "count": 1, + "cost": 57580.55, + "context": 25728.0, + "out": 1448.0, + "minutes": 0.26, + "k": 1.0, + "w5": 0.0, + "w1h": 25155.0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "pr", + "agent_type": "claude -p", + "count": 5, + "cost": 22030.69, + "context": 13361.8, + "out": 1149.8, + "minutes": 0.23166666666666666, + "k": 1.0, + "w5": 0.0, + "w1h": 8006.0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "slow", + "agent_type": "claude -p", + "count": 5, + "cost": 11513.0, + "context": 4828.0, + "out": 371.8, + "minutes": 0.233, + "k": 1.0, + "w5": 0.0, + "w1h": 4826.0 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "general-purpose", + "count": 1, + "cost": 167733.75, + "context": 86303.0, + "out": 18456.0, + "minutes": 3.4434166669845583, + "p": 27022.0, + "k": 2.0, + "w5": 59279.0, + "w1h": 0.0, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 32259, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 2.913700000445048 + }, + { + "version": "10.27.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "supervisor", + "role": "その他", + "agent_type": "ndf:worker", + "count": 2, + "cost": 93826.375, + "context": 57375.5, + "out": 9605.0, + "minutes": 1.691566667954127, + "p": 21599.5, + "k": 2.0, + "w5": 35774.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.27", + "mode": "不明", + "model": "claude-opus-5-5", + "cc": "2.1.319", + "reviewers": "-", + "layer": "worker", + "role": "work", + "agent_type": "claude -p", + "count": 13, + "cost": 224279.5076923077, + "context": 1213443.5384615385, + "out": 12040.0, + "minutes": 21.659871794871794, + "k": 14.615384615384615, + "w5": 0.0, + "w1h": 53018.307692307695 + } + ], + "external": [ + { + "version": "10.27.27", + "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": 309664.25, + "out": 15280.0, + "cost": 202226.5625, + "minutes": 2.6759458313385647, + "p": 16640.5, + "k": 6.5, + "w5": 0.0, + "w1h": 56580.0, + "rewrites": 2, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 58692, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.9456749975681304, + "call_seats": 4 + }, + { + "version": "10.27.27", + "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": 5, + "input": 301659.8, + "cached": 250086.4, + "out": 2067.6, + "minutes": 1.1632699998219809, + "p": 22444.8, + "k": 6.2, + "rewrites": 1, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 25554, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": 0.07016666730244954, + "call_seats": 5 + }, + { + "version": "10.27.27", + "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": 4, + "context": 386965.5, + "out": 9436.25, + "cost": 181292.075, + "minutes": 1.7362000008424123, + "p": 25846.5, + "k": 6.25, + "w5": 0.0, + "w1h": 58846.5, + "rewrites": 0, + "rewrites_after_5m": 0, + "rewrites_untimed": 0, + "rewrite_tokens": 0, + "rewrite_tokens_after_5m": 0, + "read_tokens_after_5m": 0, + "rewrite_gap_median": null, + "call_seats": 4 + }, + { + "version": "10.27.27", + "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": 3, + "input": 318645.3333333333, + "cached": 275498.6666666667, + "out": 2016.3333333333333, + "minutes": 1.1058055559794109, + "p": 26320.333333333332, + "k": 6.666666666666667, + "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.26", + "until": "2026-11-06T13:08:00Z", + "sessions": 86, + "sessions_with_pr": 4, + "unlinked_external": 3307, + "unlinked_ledger": 0, + "skipped": { + "ndf の Skill が無い": 1037, + "版を判定できない": 31 + }, + "released": "10.27.31", + "previous": "2026-11-05-7.json" + } +} diff --git a/docs/metrics/ndf-token-usage/2026-11-06.md b/docs/metrics/ndf-token-usage/2026-11-06.md new file mode 100644 index 000000000..8634d243d --- /dev/null +++ b/docs/metrics/ndf-token-usage/2026-11-06.md @@ -0,0 +1,118 @@ +# ndf の版ごとのトークン消費と所要時間(2026-11-06 集計・10.27.31 の配布) + +**10.27.31 を正式版として出したときの記録である。** 打ち切りの時刻は `2026-11-06T13:08:00Z`。表の最初の行は 10.27.26(前の記録: 2026-11-05-7.md)。既定では前の記録の最後の版で、記録どうしが 1 行ずつ重なる。生の記録・会話の本文・リポジトリ名・会話の ID は残していない。集計した日時点の記録で、以後の記録の増減には追随しない。 + +## 作り方 + +同じ打ち切りの時刻で走らせ直すと、記録が残っている間は同じ `.json` ができる。ただし kiro の記録はターンに時刻を持たないため、打ち切りをまたいで進行中だった席は credit が変わりうる。 + +```bash +python3 scripts/token-usage-snapshot.py --released 10.27.31 --until 2026-11-06T13:08:00Z --min-version 10.27.26 +python3 scripts/token-usage.py --min-version 10.27.26 --until 2026-11-06T13:08:00Z --by version # 末尾の「集計の出力」 +``` + +## 比べるときの注意 + +- **PR を作った会話が 2 件以下の版:** 無し。作業の中身の違いが版の違いより大きい +- **CHANGELOG.md にあって表に出ない版:** 10.27.26, 10.27.31。その版で始めた PR つきの会話が無い(会話の版は最初に読んだ版で決まる) +- **打ち切りの 30 分前以降にも行がある会話を含む版:** 10.27.27。進行中だった会話は、値が打ち切りの時刻までの分である + +## 版ごとの差(PR 1 本あたり) + +換算の合計は conductor・supervisor・worker の換算の和。前の版との差は、表の 1 つ上の行との比である。±30% を超えた版を下の「読み取り」で扱う。 + +| ndf の版 | 会話 | PR | 換算の合計 | 前の版との差 | conductor | supervisor | worker | 入力 | 所要 | codex 入力 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.27 | 4 | 5 | 1.93M | - | 1.05M | 0.55M | 0.32M | 11.25M | 108.8 | 0.19M | + +## 版と層ごとの呼び出しとキャッシュ(1 起動あたり) + +P と k と書き込みは 1 起動あたり、書き直しとうち 5 分超は合計の回数である。 + +| ndf の版 | 層 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | うち 5 分超 | 1 起動あたりの書き直し | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.26 | conductor | 31 | 65.5k | 26.7 | 0.00M | 0.12M | 0 | 0 | 0.00 | +| 10.27.26 | supervisor | 14 | 21.6k | 4.9 | 0.04M | 0.00M | 6 | 0 | 0.50 | +| 10.27.27 | conductor | 55 | 57.2k | 21.8 | 0.00M | 0.18M | 11 | 10 | 0.20 | +| 10.27.27 | supervisor | 59 | 28.5k | 3.3 | 0.03M | 0.01M | 7 | 2 | 0.35 | +| 10.27.27 | worker | 36 | - | 14.8 | 0.00M | 0.06M | - | - | - | + +## 読み取り + +差の大きい版は無い(すべて ±30% の内)。読み取りは要らない。 + +## 集計の出力 + +対象の会話: 86 件 / PR を作った会話: 4 件 / 寄せ先の無い外部 CLI: 3307 件 / 寄せ先の無い帳簿の行: 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.27 | 4 | 5 | 1.05M | 0.55M | 0.32M | 11.25M | 84.5k | 108.8 | 0.19M | 1.2k | 0.00 | 0.15M | + +### フェーズごと(1 起動あたり) + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | 換算 | 入力 | 出力 | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.26 | conductor | - | - | 31 | 0.53M | 3.47M | 25.6k | 6.1 | +| 10.27.26 | supervisor | mvv | claude -p | 1 | 0.04M | 0.02M | 1.4k | 0.3 | +| 10.27.26 | supervisor | pr | claude -p | 1 | 0.03M | 0.01M | 1.2k | 0.2 | +| 10.27.26 | supervisor | その他 | general-purpose | 4 | 0.18M | 0.91M | 9.2k | 1.7 | +| 10.27.26 | supervisor | その他 | ndf:worker | 5 | 0.07M | 0.07M | 4.2k | 0.8 | +| 10.27.26 | supervisor | その他 | その他 | 3 | 0.07M | 0.07M | 4.2k | 0.4 | +| 10.27.27 | conductor | - | - | 55 | 0.67M | 3.20M | 33.8k | 32.6 | +| 10.27.27 | supervisor | judge | claude -p | 7 | 0.02M | 0.01M | 0.3k | 0.1 | +| 10.27.27 | supervisor | mvv | claude -p | 3 | 0.08M | 0.03M | 2.1k | 0.4 | +| 10.27.27 | supervisor | pr | claude -p | 13 | 0.02M | 0.01M | 1.1k | 0.2 | +| 10.27.27 | supervisor | slow | claude -p | 16 | 0.01M | 0.00M | 0.5k | 0.2 | +| 10.27.27 | supervisor | その他 | Explore | 1 | 0.17M | 0.08M | 18.7k | 3.4 | +| 10.27.27 | supervisor | その他 | general-purpose | 8 | 0.23M | 0.80M | 16.3k | 4.5 | +| 10.27.27 | supervisor | その他 | ndf:code-reviewer | 1 | 0.28M | 1.28M | 11.0k | 5.6 | +| 10.27.27 | supervisor | その他 | ndf:worker | 8 | 0.26M | 0.75M | 17.2k | 15.0 | +| 10.27.27 | supervisor | その他 | その他 | 2 | 0.14M | 0.19M | 7.6k | 0.8 | +| 10.27.27 | worker | work | claude -p | 36 | 0.22M | 0.90M | 12.7k | 18.2 | + +### フェーズごとの呼び出しとキャッシュ + +P は起動ごとの固定費(最初の呼び出しの文脈)、k は呼び出し回数(どちらも 1 起動あたり)。書き直しは 2 回目以降の呼び出しのうち、書き込みが文脈の 50% を超え、かつ 20k を超えたものの回数(合計)。5 分超はそのうち直前の呼び出しとの間隔が 5 分を超えた回数、間隔は直前の間隔の中央値(分)。5 分超の書き直し・読み込みは、直前の間隔が 5 分を超えた呼び出しの書き直しの量と、書き直しでない呼び出しの読み込みの量(どちらも合計)。定義はサブエージェントの定義の名前。 + +| 動いた版 | 層 | フェーズ | 定義 | 起動 | P | k | 書き込み 5 分 | 書き込み 1 時間 | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.26 | conductor | - | - | 31 | 65.5k | 26.7 | 0.00M | 0.12M | 0 | 0 | 0.00M | 0.09M | - | +| 10.27.26 | supervisor | mvv | claude -p | 1 | - | 1.0 | 0.00M | 0.02M | - | - | - | - | - | +| 10.27.26 | supervisor | pr | claude -p | 1 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.26 | supervisor | その他 | general-purpose | 4 | 30.6k | 12.2 | 0.07M | 0.00M | 2 | 0 | 0.00M | 0.00M | 0.7 | +| 10.27.26 | supervisor | その他 | ndf:worker | 5 | 25.2k | 2.0 | 0.04M | 0.00M | 1 | 0 | 0.00M | 0.00M | 0.7 | +| 10.27.26 | supervisor | その他 | その他 | 3 | 3.4k | 2.7 | 0.04M | 0.00M | 3 | 0 | 0.00M | 0.00M | 0.1 | +| 10.27.27 | conductor | - | - | 55 | 57.2k | 21.8 | 0.00M | 0.18M | 11 | 10 | 1.22M | 8.90M | 120.0 | +| 10.27.27 | supervisor | judge | claude -p | 7 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.27 | supervisor | mvv | claude -p | 3 | - | 1.0 | 0.00M | 0.03M | - | - | - | - | - | +| 10.27.27 | supervisor | pr | claude -p | 13 | - | 1.0 | 0.00M | 0.01M | - | - | - | - | - | +| 10.27.27 | supervisor | slow | claude -p | 16 | - | 1.0 | 0.00M | 0.00M | - | - | - | - | - | +| 10.27.27 | supervisor | その他 | Explore | 1 | 22.1k | 2.0 | 0.06M | 0.00M | 1 | 0 | 0.00M | 0.00M | 2.9 | +| 10.27.27 | supervisor | その他 | general-purpose | 8 | 35.9k | 9.1 | 0.09M | 0.00M | 1 | 0 | 0.00M | 0.00M | 2.9 | +| 10.27.27 | supervisor | その他 | ndf:code-reviewer | 1 | 45.4k | 17.0 | 0.09M | 0.00M | 0 | 0 | 0.00M | 0.00M | - | +| 10.27.27 | supervisor | その他 | ndf:worker | 8 | 25.9k | 7.0 | 0.11M | 0.00M | 3 | 2 | 0.33M | 0.00M | 37.4 | +| 10.27.27 | supervisor | その他 | その他 | 2 | 3.6k | 4.0 | 0.07M | 0.00M | 2 | 0 | 0.00M | 0.00M | 0.3 | +| 10.27.27 | worker | work | claude -p | 36 | - | 14.8 | 0.00M | 0.06M | - | - | - | - | - | + +### 外部 CLI(1 起動あたり) + +kiro はトークン数を記録しない(値が 0)ため credit だけを載せる。agy は読まない。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | 入力 | cache | 出力 | credit | 所要 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.27 | claude | cross-review | claude-opus-5-5 | 8 | 0.35M | 0.00M | 12.4k | 0.00 | 2.2 | +| 10.27.27 | codex | cross-review | gpt-6.1-sol | 8 | 0.31M | 0.26M | 2.0k | 0.00 | 1.1 | + +### 外部 CLI の呼び出しとキャッシュ(1 起動あたり) + +codex は input のうち cached に当たらなかった分を書き込みとみなし、呼び出しごとの値(last_token_usage)を持つ席だけで数える。kiro は呼び出し回数を記録しない(利用者のターン数は JSON の turns)。取れない値は -。 + +| 動いた版 | ランタイム | Skill | モデル | 起動 | P | k | 書き直し | 5 分超 | 5 分超の書き直し | 5 分超の読み込み | 間隔 | +| --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | --- | +| 10.27.27 | claude | cross-review | claude-opus-5-5 | 8 | 21.2k | 6.4 | 2 | 0 | 0.00M | 0.00M | 0.9 | +| 10.27.27 | codex | cross-review | gpt-6.1-sol | 8 | 23.9k | 6.4 | 1 | 0 | 0.00M | 0.00M | 0.1 | diff --git a/docs/specifications/README.md b/docs/specifications/README.md index 67243653d..1341a52bd 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -135,5 +135,7 @@ | [ndf-factory-shared-code-placement.md](ndf-factory-shared-code-placement.md) | Neural Development Factory: API と画面で同じ役割の処理を 1 か所に置く規則と置き場の一覧(handlers/ を handler の配線だけにする・保存の契約の port.ts・版の競合の再試行・ログインの確認と失敗の表示)。#2199 | | [ndf-supervise-production-default-branch.md](ndf-supervise-production-default-branch.md) | supervise.py new が本番チャネルを --production-branch → production_branch → 既定ブランチの順で決め、既定ブランチを宣言を探す木の最初に存在する git の木から解くため、まだ作っていない worktree でも本番のプランを組む理由と境界 | | [ndf-design-external-survey.md](ndf-design-external-survey.md) | design の外部の調査(標準・主要な製品・ライブラリとサービス)と、自前で作る決定に比べた候補の検査(design-survey.py)・レビューの観点(pr-review と cross-review)を置いた決定と理由。手順は design の references/external-survey.md が正 | +| [ndf-loop-select-prerequisites.md](ndf-loop-select-prerequisites.md) | 開発ループの選定が閉じていない前提の課題を持つ課題を外す仕組み。順位の算出の出力 `prerequisites`・常に成り立つ条件・決定の理由・既知の限界・テスト観点。選定の規則の全体は開発ループの確定仕様が正 | +| [cross-refactoring-test-file-detection.md](cross-refactoring-test-file-detection.md) | cross-refactoring のテストのファイルの見分け方(Python は名前の形に加えてテストの関数の定義の行で決めること、試走の候補・テストの置き場所・行数・危険フラグ・コミットの事実を同じ判定にそろえること、コミットの事実だけ前後の版を読むこと、判定の規則と既知の限界) | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/cross-refactoring-lint-suite-and-ci-coverage.md b/docs/specifications/cross-refactoring-lint-suite-and-ci-coverage.md index 6f30e8954..852c7f89d 100644 --- a/docs/specifications/cross-refactoring-lint-suite-and-ci-coverage.md +++ b/docs/specifications/cross-refactoring-lint-suite-and-ci-coverage.md @@ -140,7 +140,9 @@ cross-refactoring は範囲テストを作業ディレクトリの根で走ら | suite | `kind` | `command` / `scope_command` | `paths` | `ci_jobs` | | --- | --- | --- | --- | --- | | `pytest` | `test` | `uv run … pytest . …` / `… pytest {paths} …` | `["."]` | `.github/workflows/pytest.yml` | +| `factory` | `test` | `npm --prefix factory ci … && npm --prefix factory run check` / `… vitest run --root factory {paths}` | `["factory"]` | `.github/workflows/factory-check.yml` | | `script-structure` | `lint` | `python3 scripts/check-script-structure.py` / `… {paths}` | `plugins/ndf` ほか | `.github/workflows/script-structure.yml` | +| `ndf-contracts` | `lint` | `python3 scripts/check-ndf-contracts.py` / `… {paths}` | `plugins/ndf` ほか | `.github/workflows/script-structure.yml` | | `lint` | `lint` | `bash scripts/check-lint.sh` / `bash scripts/check-lint.sh -- {paths}` | `["."]` | `.github/workflows/lint.yml` | | `runtime-plugin-build-check` ほか 9 本 | `lint` | ワークフローの `run` の行と同じ文 / 持たない | 持たない | `.github/workflows/runtime-plugin-validate.yml#<ジョブ>`(1 本に 1 ジョブ) | @@ -148,10 +150,49 @@ cross-refactoring は範囲テストを作業ディレクトリの根で走ら `skill-repo-assumptions-check`・`skill-shell-vars-check`・`service-boundary-check`・`runtime-plugin-validate`・ `markdown-link-check`・`doc-line-limit-check`・`instruction-files-check` を 1 つずつ受け持つ。どれも検査のスクリプトが ファイルのパスを受けないため `scope_command` と `paths` を持たず、範囲テストでは走らず、最終ゲートで 1 本ずつ判定される。 +`name` は継続的統合のジョブ id と同じにし、最終ゲートの記録と継続的統合のチェックの名前を同じ語で読めるようにする。`runner` は呼ぶスクリプトの +名前(拡張子を除く)で、判定には使わない。`ci_jobs` の識別子は、同じファイルのジョブの扱いが分かれるときにジョブの単位で書く。 `test.ci_exempt` は、手元の検証で結果が決まらないか走らせられない 7 件(`glossary.yml`・`pr-base-guard.yml`・ `pr-body-decisions.yml`・`runtime-plugin-authenticated-smoke.yml`・`runtime-plugin-smoke.yml`・ `runtime-plugin-smoke-latest.yml`・`runtime-plugin-validate.yml#required-checks-check`)を理由とともに持つ。 +`runtime-plugin-smoke-latest.yml` はジョブが 1 件だけなのでファイルの単位で書き、`required-checks-check` は同じファイルに suite にするジョブが +あるのでジョブの単位で書く。 + +**suite にするジョブの条件。** 次の 3 つをすべて満たすジョブを suite にし、満たさないジョブを除外する。 + +- 秘密・ネットワーク・Pull Request の文脈(宛先のブランチ・本文)を使わずに、手元で判定が決まる +- 継続的統合のジョブと同じスクリプトを同じ引数で走らせる(継続的統合だけで要る準備の手順は除く) +- 1 回の実行が 60 秒以内に終わる + +**ジョブ 1 件に suite を 1 本置く。** 最終ゲートは静的解析の suite を 1 本ずつ走らせ、着手前の suite ごとの成否で既存失敗と変更起因を分ける +(`gate_lint.py`・`lint_verdict`)。1 本にまとめると、どれか 1 つが着手前から落ちていれば suite 全体が既存失敗として判定から外れ、ほかの 8 件を +項目が壊しても止まらないためである。`scope_command` を持たないこれらの suite が通る判定は次の 3 行である。 + +| 着手前の成否 | 判定 | +| --- | --- | +| 通っていた | 変更起因(最終ゲートが止まる) | +| 落ちていた | 既存失敗(判定から外す) | +| 読めない | 変更起因 | + +**`scope_command` と `paths` を書かない。** 静的解析の suite の `paths` は `scope_command` を持つ suite の範囲を決めるときだけ読まれ、読まれない値は +検査の範囲が変わっても古くなったことに気づけないためである。`{paths}` を受け取って捨てる `scope_command` は書かない。継続的統合と同じ引数で呼ぶ +約束から外れ、項目ごとに全体の検査が走るためである。 + +**`runtime-plugin-validate` は `claude` の CLI が無いホストでも suite として走る。** `scripts/validate-runtime-plugins.sh` は CLI が無いと +`claude plugin validate` だけを飛ばして終了コード 0 で終わり、残りの検査を走らせる。`check-doc-staleness.py` と `check-cross-skill-refs.py` は +継続的統合ではこのジョブでしか走らないため、除外すると項目が壊しても手元で気づけない。CLI が無いホストでは、`claude plugin validate` の分だけ +継続的統合より検査が少ない。このスクリプトは別の suite と同じ検査(生成物の同期・リンク・文書の行数)を内部でもう一度行う。 + +**除外の 2 件の理由。** `required-checks-check` は Pull Request の宛先のブランチと GitHub の API(トークン)を使い、`runtime-plugin-smoke-latest.yml` は +定期と手動でだけ起動してネットワークから最新の CLI を入れる。どちらも上の条件の 1 つ目を満たさない。 + +宣言と処理はスキーマの `test.suites[].ci_jobs`・`test.ci_exempt` を介してだけやり取りする。突き合わせの処理とワークフローはこの値を知らない。 + +足した suite 9 本の所要の合計は、2026-11-05 の実測で 19.6 秒、2026-11-06 の実測で 35.4 秒だった。着手前のテストと最終ゲートは、それぞれこの分だけ +長くなる。いずれも 1 回の実測で、2 回の差の原因は切り分けていない。`claude plugin validate` が通信をするかは未確認である。 + +継続的統合にジョブを足すと、`init` はまた宣言に無いジョブとして知らせる。これは突き合わせの目的どおりの振る舞いで、宣言へ足すまで続く。 `analysis.written.test` の指紋は宣言の値と一致しない(suite を手で足したため)。解析の書き出し(`merge.merge_item`)は 一致しない項目を書き換えずに残すため、手で足した値は解析で消えない。 @@ -175,6 +216,10 @@ cross-refactoring は範囲テストを作業ディレクトリの根で走ら (`plugins/ndf/scripts/tests/test_project_decl_ci_jobs.py`) - `measure_ci.workflow_jobs` の数が共通の部品へ移す前と同じであること(`plugins/ndf/scripts/tests/test_lib_ci_workflows.py`) - `Strategy.as_state` → `from_state` で `ci_jobs` が残り、`ci_jobs` を持たない旧い状態ファイルも読めること +- このリポジトリの宣言と作業ディレクトリで `compare_jobs` を打つと、`status` が `compared` で `undeclared` が空になること(テストのコードは持たず、 + 宣言を変えたときにコマンドで確かめる。2026-11-06 の実測: ジョブ 24 件、`undeclared` 0 件) +- `runtime-plugin-validate.yml` の 9 本の suite の `command` が根で終了コード 0 で終わり、ジョブの `run` の行と同じ文であること +- `claude` を `PATH` から外しても `runtime-plugin-validate` の `command` が終了コード 0 で終わること ## 関連リンク diff --git a/docs/specifications/cross-refactoring-scope-template-probe.md b/docs/specifications/cross-refactoring-scope-template-probe.md index 2658542bf..702284461 100644 --- a/docs/specifications/cross-refactoring-scope-template-probe.md +++ b/docs/specifications/cross-refactoring-scope-template-probe.md @@ -268,4 +268,5 @@ suite ごとに 1 段で、「受けません」(`defect`)か「受けるか - [`lib/test_strategy.py`](../../plugins/ndf/scripts/lib/test_strategy.py)・[`refactor_lib/template_check.py`](../../plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/template_check.py)・[`project_lib/model.py`](../../plugins/ndf/scripts/project_lib/model.py) - [cross-refactoring-init-test-and-phase-windows.md](cross-refactoring-init-test-and-phase-windows.md)(着手前のテストの上限。試走はその残りの中で走る) - [cross-refactoring-lint-suite-and-ci-coverage.md](cross-refactoring-lint-suite-and-ci-coverage.md)(静的解析の suite の範囲テスト) +- [cross-refactoring-test-file-detection.md](cross-refactoring-test-file-detection.md)(テストのファイルの見分け方。テストの関数を持たない `.py` を候補から外す規則) - 課題: #1630(由来 takemi-ohama/novel issue #32 / PR #58)。残した主題は #2095(修正のラウンドの止め方)・#2096(着手前のテストへ入れるパスの種類) diff --git a/docs/specifications/cross-refactoring-test-file-detection.md b/docs/specifications/cross-refactoring-test-file-detection.md new file mode 100644 index 000000000..51bb1cf89 --- /dev/null +++ b/docs/specifications/cross-refactoring-test-file-detection.md @@ -0,0 +1,183 @@ +# cross-refactoring: テストのファイルの見分け方(Python は名前の形とテストの関数の定義で決める) + +## 概要 + +**cross-refactoring は、Python のファイルを、名前の形に加えてテストの関数の定義があるときだけテストのファイルとして扱う。** 名前が +`test_` で始まっていても、テストの関数を持たない `.py` はプロダクションコードである。試走の候補・テストの置き場所・プロダクションコードの +行数・危険フラグ・コミットの事実は、同じ見分け方で同じ答えを出す。 + +例: `plugins/ndf/scripts/lib/test_strategy.py` はテストの関数を持たないプロダクションコードである。このファイルを変える Pull Request で +`refactor-scope.py` の出力を `--scope` に渡しても、`init` はこのファイルを範囲テストのテンプレートの試走に入れない。pytest がテストを 1 件も +集めず終了コード 5 で終わって、正しいテンプレート(`scope_command`)を直す箇所として示す、という止まり方をしない。 + +中身で見分けるのは拡張子 `.py` のファイルだけで、ほかの言語は名前の形とディレクトリの名前で見分ける。設定も引数も足さない。配布先の +リポジトリでも同じに働く。 + +## 用語 + +定義は [用語集](../glossary.md) が正である(`.ndf/glossary.json` の `document`)。この文書で使う語は「テストの関数」「プロダクションコード」 +「テンプレートの試走」「範囲テストのテンプレート」である。 + +テストかどうかの判定は cross-refactoring のコンテキスト(`ndf-cross-refactoring`)の中で閉じる。テストの戦略と試走の判定の表 +(`plugins/ndf/scripts/lib/test_strategy.py`)はこの見分け方に関わらない。 + +## 対象範囲 + +| 扱う | 扱わない | +| --- | --- | +| 試走の候補・`--scope` のテストの置き場所の判定 | Python 以外の言語の見分け方 | +| プロダクションコードの行数と危険フラグでのテストのファイルの見分け方 | プロダクションコードのファイルの改名(`test_strategy.py` を別名へ移すこと) | +| コミットの事実(テストを触ったか・テストの期待値の変更) | 試走の判定の表(`template_verdict`)。終了コード 5 の読み替え | +| | コードの計測の表示(名前の形のまま。#2557) | + +## 構成要素と責務 + +| 要素 | 責務 | +| --- | --- | +| パスの判定(`refactor_lib/pathkinds.py`) | 見分け方の持ち主。`python_text_lacks_tests`・`lacks_python_tests`・`is_test_path`・`is_test_path_in_versions` を持つ | +| `--scope` とテストの置き場所の検査(`refactor_lib/scope.py`) | `test_files_under`・`_matches_by_name` が `lacks_python_tests` を呼ぶ | +| 危険フラグ(`refactor_lib/danger.py`) | D3 と D4 でテストのファイルを外すときに `is_test_path` へ作業ディレクトリを渡す | +| 行数の判定(`pathkinds.production_code_changes`) | `is_test_path` へ作業ディレクトリを渡す | +| コミットの事実(`refactor_lib/gitfacts.py`) | `commit_test_changes` が前後の版を読み `is_test_path_in_versions` で判定する。`commit_touches_tests` はその結果が空でなければ真を返し、git を起動しない | +| テストの追加のコミットの検査(`commands/implement.py` の `_test_commit_problem`) | `is_test_path` へ作業ディレクトリを渡す | +| 試走・着手前のテスト・対象の組み立て(`template_check.py`・`baseline.py`・`targets.py`) | `test_files_under` と `test_locations` の答えをそのまま使う | +| コードの計測(`codemetrics.path_role`・`codemetrics_read._clone`) | 作業ディレクトリを受けないため、名前の形で判定する | + +## 仕様 + +### 常に成り立つ条件 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| I1 | Python のファイルの中身でテストかどうかを決めるのは `python_text_lacks_tests` の 1 関数だけである。試走の候補・テストの置き場所・行数・危険フラグ・コミットの事実はこの関数を通る | テストで落とす | +| I2 | 名前がテストの形の `.py` で、テスト用ディレクトリの配下になく、読んだ中身がテストの関数の定義を 1 つも持たないものは、テストのファイルでない。テストの置き場所の根拠にならず、試走の候補に入らず、プロダクションコードとして数える。読む中身は作業ディレクトリのファイルで、コミットの事実だけはそのコミットの前後の版である | テストで落とす | +| I3 | `.py` 以外のファイルと、中身を読めない `.py` は、名前の形で判定する。読めないとは、作業ディレクトリでは削除済み・ディレクトリ・権限の不足・作業ディレクトリが渡されないことを指し、コミットの事実では前後の版のどちらにも無いことを指す | テストで落とす | +| I4 | ディレクトリの名前(`tests/` など)によるテストの置き場所の判定は中身を見ない。その配下の `.py` はテストの関数を持たなくても `is_test_path` が真を返す。ただし試走の候補には入らない | テストで落とす | +| I5 | 中身を読むのは、名前がテストの形の `.py` だけである | テストで落とす | + +### 判定の規則 + +テストの関数の定義は、行頭の空白に続く `def test` か `async def test` の行である(`^[ \t]*(?:async[ \t]+)?def[ \t]+test`)。 + +| 入力 | `lacks_python_tests(path, work)` | +| --- | --- | +| 拡張子が `.py` でない | 偽 | +| `work` が無い・`/` が読めない | 偽 | +| 中身にテストの関数の定義の行がある | 偽 | +| 上のどれにも当たらない | 真 | + +`lacks_python_tests` は名前の形を見ない。呼ぶ側が名前の形で当たったファイルにだけ呼ぶ(I5)。 + +| 判定 | 答え | +| --- | --- | +| `is_test_path(path)` | ディレクトリの印かファイル名の印があれば真 | +| `is_test_path(path, work)` | ディレクトリの印があれば真。ファイル名の印があり、`lacks_python_tests` が偽なら真 | +| `is_test_path_in_versions(path, before, after)` | ディレクトリの印があれば真。ファイル名の印がある `.py` は、読めた版(`None` でない `before` か `after`)のどちらかがテストの関数を持てば真。どちらの版も読めなければ真。`.py` 以外はファイル名の印があれば真 | +| `test_files_under(path, work)` | 名前の形で当たる追跡ファイルのうち、`lacks_python_tests` が偽のもの | +| `_matches_by_name(path, work)` | ディレクトリの名前で当たれば真。ファイル名の形で当たり、`lacks_python_tests` が偽なら真 | + +名前の形の並びは 2 つあり、当たる名前が違う。 + +| 判定 | 名前の形の並び | 当たり方 | +| --- | --- | --- | +| 置き場所の検査 | `TEST_NAME_PATTERNS` | `fnmatch` の形(`test_*` など) | +| パスの判定 | `TEST_NAME_MARKERS` | 部分一致の印(`test_`・`spec_` など) | + +### テストの置き場所から試走の候補まで + +```mermaid +graph TD + S[--scope の 1 件] --> D{ディレクトリの
名前で当たる} + D -->|はい| L[テストの置き場所] + D -->|いいえ| F{ファイル名の
形で当たる} + F -->|はい| P{テストの関数を
持たない .py} + P -->|いいえ| L + P -->|はい| C + F -->|いいえ| C{配下に tests などの
ディレクトリ} + C -->|ある| L + C -->|無い| U{配下に
テストのファイル} + U -->|ある| L + U -->|無い| N[置き場所でない] + L --> Q[配下のテストの
ファイルを候補へ] + Q --> R[probe_paths が
最大 2 本を選ぶ] +``` + +ファイル名の形で当たるがテストの関数を持たない `.py` は、次の判定へ進む。ファイルには配下が無いため、結果は「置き場所でない」になる。 +「配下にテストのファイル」と「候補へ」は `test_files_under` の答えで、テストの関数を持たない `.py` を含まない。 + +リファクタリングの要否と危険フラグは、差分やコミットのファイルごとに `is_test_path(path, work)` を 1 回呼ぶ。 + +### 決定と理由 + +**決定 1: テストの関数の有無を行の正規表現で見分ける。** 名前がテストの形の `.py` 497 本で、構文解析(`ast`)と同じ 3 本を見分け、所要は 0.03 秒 +だった。構文解析は 1.44 秒で、`init` の候補選びの延びを 1 秒以内に収める条件を超える。文字列や docstring の中の `def test` で「持つ」と誤る +場合は、名前の形でテストとする側へ倒れるため、止まる方向の誤りは増えない。pytest の収集(`--collect-only`)は使わない。候補ごとにランナーを +起動し、ランナーの語と終了コードを読むことになり、試走がコマンドの語を見ない約束から外れるためである。 + +**決定 2: 中身の判定をパスの判定(`pathkinds`)の 1 関数に置き、置き場所の検査と `is_test_path` の両方から呼ぶ。** 同じファイルを一方でテスト、 +もう一方でプロダクションコードと数えないためである。置き場所の検査(`scope.py`)に置かないのは、行数と危険フラグの判定が `--scope` の検査に +依存する向きになるためである。 + +**決定 3: `is_test_path` は作業ディレクトリを任意の引数で受け、渡されなければ名前の形で判定する。** 中身を読む手段を持つ呼び出し元だけが中身で +見分けるためである。必須の引数にすると、コードの計測の読み取りの関数(`parse_ruff`・`parse_lizard`・`file_metrics`・`parse_symilar`・ +`parse_jscpd`)へ作業ディレクトリを通すことになる。 + +コミットの事実だけは作業ディレクトリでなく、そのコミットの前後の版(`git show ^:` と `:`)で見分け、どちらかにテストの +関数があればテストとする。作業ディレクトリの中身で見分けると、本体と同じ場所の `lib/test_z.py` からテストの関数をすべて消すコミットが +プロダクションコードと読まれ、`test_changes` から外れて、`verify.verify_test_changes` によるテストの期待値の変更の取り消しを通り抜けるためで +ある。前後の版を読むのは `commit_test_changes` の 1 か所で、ファイルごとに 1 回ずつにする。読み直すと、1 コミットのテストの名前の `.py` 1 本に +つき git の起動が 2 本から 6 本へ増えるためである。 + +**決定 4: 終了コード 5 を `undecided` に読み替えない。** 終了コード 5(テストが集まらない)は pytest の約束で、ほかのランナーでは別の意味を持つ。 +読み替えると試走がランナーの終了コードの意味を読むことになる。テストの関数を持たないファイルは候補に入らないため、この経路で終了コード 5 は +起きない。 + +**決定 5: `tests/` の配下でもテストの関数を持たない `.py` は候補から外し、置き場所の判定は変えない。** `tests/test_helpers.py` のような補助の +ファイルを候補に入れると、同じく終了コード 5 で止まるためである。`is_test_path` がディレクトリの印でテスト側とする判定は変えない。補助の +ファイルを変える差分を、行数と危険フラグでプロダクションコードと数えないためである。 + +**決定 6: 2 つの名前の形の並びはそろえない。** そろえると `spec_helper.rb` などの判定が変わり、Python 以外の言語の見分け方が変わるためである。 +部分一致の印が `latest_version.py` を名前でテストと読むことは、#2557 が扱う。 + +## 運用 + +**このリポジトリの実測**(2026-11-06): 追跡ファイルで名前の形に当たる 554 本のうち、`test_files_under(".", ".")` が返すのは 551 本である。外れる +3 本は `plugins/ndf/scripts/lib/test_strategy.py`・`plugins/ndf/scripts/lib/test_triage.py`・`plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/init_test.py` である。所要は中身を +見る形で 0.106 秒、名前の形だけで 0.096 秒だった。プロセスの最初の実行は 0.902 秒で、3 回目の 0.106 秒との差の原因は切り分けていない。 +いずれも 1 回の実測である。 + +### 既知の限界 + +| 項目 | 内容 | +| --- | --- | +| pytest の設定で収集の形を変えたリポジトリ | `python_functions` を `check_*` などへ変えたリポジトリでは、テストのファイルを「テストの関数を持たない」と読み、候補から外す。外れた分は候補 0 本の `undecided` か別の候補で試走する | +| 定義の行を持たないテスト | 代入や import でテストの関数を作るファイル(`test_x = make_case()`)は「持たない」と読む。このリポジトリの `.py` 497 本には無かった | +| コードの計測の表示 | テストの関数を持たない `test_*.py` は、計測の表示ではテスト側に並ぶ(#2557) | + +## テスト観点 + +`plugins/ndf/skills/cross-refactoring/tests/test_python_test_detection.py` と `test_template_check.py` が持つ。 + +- テストの関数を持たない `lib/test_x.py` は、`--scope` の置き場所の配下とリポジトリ全体のどちらから集めても試走の候補に入らないこと +- `--scope` に `lib/test_x.py` か、配下がそのファイルだけの `lib` を渡すと、テストの置き場所とみなされないこと +- プロダクションコードのモジュールが `test_` の名前を持つ構成で `init` の検証(`_verify_init`)を走らせると、試走で止まらないこと +- テストの関数を持つファイルは、`tests/` の配下でも本体と同じ場所でも候補と置き場所に入ること。`async def test` だけのファイルと + メソッドだけのファイルも入ること +- テストの関数を持たない `lib/test_x.py` の変更が、リファクタリングの要否でプロダクションコードに数えられ、危険フラグの D3・D4 の対象に入ること +- `*.spec.ts`・`*_test.go`・`tests/` 配下の判定と、作業ディレクトリを渡さない `is_test_path` の答えが変わらないこと(`test_scope_gate.py`・ + `test_danger.py`・`test_assess.py`) +- 同じファイルについて `test_files_under` と `is_test_path(path, work)` の答えが一致すること(I1) +- 作業ディレクトリに無い `.py` は名前の形のままテストとみなされること(I3) +- `tests/test_helpers.py` は候補に入らず、`tests` は置き場所のままで、`is_test_path` は真を返すこと(I4) +- 本体と同じ場所の `test_z.py` からテストの関数をすべて消すコミットが、`touches_tests` で真になり `test_changes` に入ること。プロダクションコードの + `lib/test_x.py` を変えるコミットはテストの変更にならないこと(決定 3) +- テストの名前の `.py` 1 本を変えるコミットで、`^:` と `:` を読む `git show` がそれぞれ 1 回だけ起動されること(決定 3) +- 名前がテストの形でない `.py` とテストの形の `.ts` ではファイルを読まないこと(I5) + +## 関連リンク + +- [`refactor_lib/pathkinds.py`](../../plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/pathkinds.py) +- [cross-refactoring-scope-template-probe.md](cross-refactoring-scope-template-probe.md)(テンプレートの試走と候補の選び方) +- [cross-refactoring-round-tests-and-assess.md](cross-refactoring-round-tests-and-assess.md)(リファクタリングの要否とプロダクションコードの数え方) +- [cross-refactoring-verify-and-final-gate.md](cross-refactoring-verify-and-final-gate.md)(危険フラグ) +- 課題: #2516(関連 #2557) diff --git a/docs/specifications/ndf-loop-select-prerequisites.md b/docs/specifications/ndf-loop-select-prerequisites.md new file mode 100644 index 000000000..0f09dae8e --- /dev/null +++ b/docs/specifications/ndf-loop-select-prerequisites.md @@ -0,0 +1,216 @@ +# 開発ループの選定: 閉じていない前提の課題を持つ課題を外し、理由を選定の行に残す + +## 概要 + +**開発ループの選定(`sprint-state.py loop select` / `loop exclude`)は、閉じていない前提の課題を持つ課題を選ばない。** +落選の理由に `前提の課題の実装待ち(#番号 …)` を残し、外した課題は選定の件数の枠(`max_issues`)を使わない。 +前提の関係は、棚卸しの順位の算出(`upkeep.py rank`)が行ごとの項目 `prerequisites` として選定へ渡す。 + +この文書は、順位の算出の出力の契約・常に成り立つ条件・決定の理由・既知の限界・テスト観点を持つ。 +**選定の規則の全体(候補・外す理由の順・枠の割り当て・止まる条件)の正は +[開発ループの確定仕様の「選定」](ndf-workflow-development-loop.md#選定select--exclude) にある。** 手順は +`development-workflow` の [references/loop.md](../../plugins/ndf/skills/development-workflow/references/loop.md) が正である。 + +## 用語 + +「前提の課題」「選定」の定義は用語集([docs/glossary.md](../glossary.md))が正である。 + +## コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 課題の棚卸し(`ndf-issue-upkeep`) | 見積もりの記録・依存の辺・順位・切り出しの境界 | +| 開発の工程(`ndf-workflow`) | 開発ループ・周回・選定・前提の課題・落選の理由 | + +棚卸しが供給者、開発ループの選定が顧客の関係(顧客 / 供給者)である。選定は順位の算出の出力(JSON)だけを読み、 +依存の辺を自分で合成しない。供給者の側が、選定の要る「前提の課題」を行ごとの項目として出す。 + +## 背景 + +選定が順位だけで課題を選ぶと、前提の課題の実装がまだ develop に無い課題を選び、実装のステップで失敗する。 +開発ループ lroot3 の周回 13 は #2344 を選び、前提の #2337・#2339・#2341・#2342 の実装が無いため失敗した。 +スプリントの側は依存を読まず、同じ周回に入った課題の実装の順序を依存で決めない。そのため、前提の判定は選定で行う。 + +課題管理の標準と主要な製品は、待つ関係(GitHub の課題の依存の blocked by / blocking、Jira の課題リンクの +is blocked by / blocks)を親子の関係と分けて持つ。選定もこれに合わせ、待つ関係だけを外す理由にし、 +親子(サブイシュー)の関係は外す理由にしない。 + +## 対象範囲 + +含む: + +- 順位の算出の出力の行に、閉じていない前提の課題の番号(`prerequisites`)を載せること +- 選定が `prerequisites` を持つ行を外し、理由を選定の行に残すこと +- 境界が無いマイルストーンで、外した行が `max_issues` の枠を使わないこと + +含まない: + +- 課題の本文の文章から依存の番号を拾うこと。拾って見積もりの記録へ書くのは棚卸し(`backlog-refinement`)の担当である +- 見積もりの記録・ループの記録の書き換え +- 順位の並び・切り出しの境界・品質の枠・大きい課題の枠の判定 +- develop へマージ済みで、まだ閉じていない前提の課題を「満たした」と見なすこと + +## 仕様 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 順位の算出の結果 | 順位の算出(`upkeep_rank.py` / `upkeep_rank_cmd.py`) | 算出の結果 | マイルストーンの順位の行 | 前提の課題の番号の並び | +| ループの記録 | 開発ループ(`sprint_loop_*.py`) | ループの記録 | 周回 | 選定の行(番号・採否・理由) | + +ループの記録は順位の算出の結果を課題の番号だけで参照する。選定は順位の算出の結果を書き換えない。 + +### 構成要素と責務 + +| 要素 | 責務 | +| --- | --- | +| 辺の合成([`upkeep_rank_cmd.py`](../../plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank_cmd.py) の `build_edges`) | サブイシュー・見積もりの記録の `depends_on`・マイルストーンの説明の `### 並列の組(見込み)` の辺を `merge_edges` で合成し、合成後に残った辺のうち見積もりの記録と並列の組が宣言した辺を前提の辺として選り分ける | +| 算出の入力([`upkeep_rank.py`](../../plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank.py) の `Board.prerequisites`) | 前提の辺の集合を持つ。依存の辺(`edges`)の部分集合である | +| 行の出力(`upkeep_rank.py` の `_milestone_out`) | 行の課題から出る前提の辺の先を昇順で `prerequisites` に載せる。空なら項目を出さない | +| 選定の候補([`sprint_loop_select.py`](../../plugins/ndf/scripts/lib/sprint_loop_select.py) の `candidates_of`) | 境界が無いとき、`prerequisites` を持つ行を `max_issues` の枠から除く | +| 落選の理由(`sprint_loop_select.py` の `exclusion_reason` / `prerequisite_reason`) | `prerequisites` を持つ行に `前提の課題の実装待ち(#番号 …)` を返す | + +```mermaid +graph TD + subgraph 棚卸し + EST[見積もりの記録の depends_on] + PAR[マイルストーンの説明の並列の組] + SUB[サブイシュー] + MERGE[辺の合成 build_edges] + OUT[行の出力 _milestone_out] + end + subgraph 開発ループ + CAND[選定の候補 candidates_of] + WHY[落選の理由 exclusion_reason] + REC[ループの記録の選定の行] + end + EST --> MERGE + PAR --> MERGE + SUB --> MERGE + MERGE -->|前提の辺| OUT + OUT -->|rows の prerequisites| CAND + CAND --> WHY + WHY --> REC +``` + +### 処理の流れ + +```mermaid +sequenceDiagram + participant L as loop select + participant R as upkeep.py rank + participant S as 選定 choose_issues + L->>R: 順位の算出を打つ + R->>R: 辺を合成し、open の課題どうしの辺だけを残す + R->>R: 見積もりの記録と並列の組の辺を前提の辺として取り出す + R-->>L: rows に prerequisites を載せて返す + L->>S: 順位の算出の出力を渡す + S->>S: candidates_of: 境界が無ければ prerequisites のある行を枠から除く + S->>S: exclusion_reason: prerequisites があれば前提の課題の実装待ち + S-->>L: 選定の行(落選の理由つき) +``` + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 棚卸しが課題の依存を見積もりの記録へ書いた | 棚卸しの手順 2A・2B | 順位の算出 | +| E2 | 順位の算出が依存の辺を合成し、行ごとの前提の課題を出した | `upkeep.py rank` | 開発ループの選定 | +| E3 | 選定が前提の課題の閉じていない課題を外した | `loop select` / `loop exclude` | ループの記録(選定の行) | +| E4 | 前提の課題が閉じた | スプリントの締め(`sprint-close.py`) | 次の順位の算出(閉じた課題への辺が消える) | +| E5 | 次の選定が、外していた課題を選べるようにした | 次の `loop select` | ループの記録 | + +### 前提が解ける時点 + +前提の課題が閉じたかは、選定のたびに打つ順位の算出の時点の GitHub の状態で決める。`merge_edges` は open の課題どうしの +辺だけを残すため、前提の課題が閉じると辺が消え、次の選定で依存する課題を選べるようになる。前提の課題は別の +マイルストーンにあっても、マイルストーン未設定でも、open であれば前提に数える。 + +### 落選の理由を判定する順 + +選定は次の順に外す理由を見て、最初に当たった理由を残す。前提の課題の実装待ちは 5 番目である。 + +1. 人の判断待ち(棚卸し) +2. 保留の周回 +3. 人の判断待ち・差し戻し・設計 PR がマージ待ち +4. この周回の手の除外(`loop exclude`) +5. **前提の課題の実装待ち(#番号 …)** +6. 周回の中で起票された +7. 容量(尺度を超える) + +先頭に選ぶ課題(設計を直して出し直す課題・配布の直し・承認とマージが済んだ設計の課題)も、前提の課題が open なら外れる。 +前提の課題が同じ周回に選ばれていても、依存する課題は外す。 + +### 常に成り立つ条件 + +| # | 対象 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 順位の算出の結果 | 行の `prerequisites` は、合成後の依存の辺のうち、見積もりの記録か並列の組が宣言した辺の先にある open の課題だけである。サブイシューの辺だけの先は入れない | テストが落ちる | +| I2 | 順位の算出の結果 | 前提の課題が無い行は `prerequisites` の項目を持たない(依存の記録が無い算出の出力と `digest` に、この項目は現れない) | テストが落ちる | +| I3 | ループの記録 | `prerequisites` を持つ行は、どの選定でも採られない。落選の理由は、それより先に判定する理由が無いときに限り `前提の課題の実装待ち(#番号 …)` で、open の前提の課題の番号をすべて昇順で載せる。先に判定する理由があれば、その理由を残す | テストが落ちる | +| I4 | ループの記録 | 境界が無いマイルストーンで、`prerequisites` を持つ行は `max_issues` の枠を使わない | テストが落ちる | + +## データ・設定 + +### 順位の算出の出力(`upkeep.py rank` の `metrics.milestones[].rows[]`) + +| 項目 | 内容 | +| --- | --- | +| 入力 | 見積もりの記録・マイルストーンの説明・サブイシューの関係 | +| 前提の項目 | `prerequisites`: 整数の配列。昇順・重複なし。その行の課題が依存する open の前提の課題の番号。前提の課題が無い行は項目を持たない | +| 失敗の形 | 循環があれば `stopped` と終了コード 2 で止まる | +| 他の項目との関係 | `digest` は行の内容から計算するため、`prerequisites` を含む。表の描画(`render_rank_table`)は列を名前で読み、この項目を使わない | + +### 選定の行(`loop select` / `loop exclude` の結果) + +| 項目 | 内容 | +| --- | --- | +| 落選の理由 | `前提の課題の実装待ち(#B)`。複数なら `前提の課題の実装待ち(#B #C)`(番号の昇順、半角スペース区切り) | +| 行の形 | 番号・マイルストーン・順位・採否・理由 | +| 止まったときの件数 | 候補がすべて外れたときの `outside_mvv` の `values.reasons` は、理由の文字列ごとの件数として数える | + +## 決定の理由 + +- **前提の課題を順位の算出の行ごとの項目として出す。** 選定は行を見るだけで外す理由と枠の扱いを決められ、 + 辺の一覧を行へ引き直す処理を持たなくて済む。辺の合成を選定の側へ写すと、同じ役割の処理が 2 か所に分かれる。 + 依存の取り元は Python 標準の `graphlib` も GitHub の課題の依存の API も使わない。要るのは「辺の先が open か」の + 1 段の判定で、並べ替えは順位の算出が既に持つ。このリポジトリの依存は GitHub の API に登録されていない +- **前提の辺は、合成後に残った辺のうち見積もりの記録と並列の組が宣言した辺に限る。** 合成はサブイシューの辺を + 最も優先し、逆向きの下位の辺を捨てる。合成の結果との共通部分を取ると、並び順と前提の判定が同じ合成の結果に揃い、 + 親子の関係で親も子も選べなくなることが無い。見積もりの記録が同じ向きの辺を明示したときは、サブイシューの辺と + 重なっても前提に数える +- **前提の課題が同じ周回に選ばれていても、依存する課題を外す。** スプリントの側は依存で実装の順序を決めないため、 + 同じ周回に入れると前提が develop に入る前に依存する課題の実装が始まりうる +- **前提の課題がどのループからも選ばれない場所にあっても、人へ別に知らせない。** 外す理由に前提の課題の番号が載るため、 + 選定の行を読めばどこで止まっているかが分かる。候補がすべて外れれば `outside_mvv` で止まり、理由ごとの件数が出る。 + 棚卸しがマイルストーンを付ければ解けるため、専用の止まる条件は足さない +- **前提の課題の実装待ちは、手の除外より後、周回の中の起票と容量より前に判定する。** 人か記録が明示した理由を + 優先して残し、周回の中の起票と容量より長く続く理由を先に置いて、理由の行が周回ごとに変わらないようにする + +## 既知の限界 + +| 項目 | 内容 | +| --- | --- | +| 境界があるマイルストーンでの枠 | 切り出しの境界は順位の算出が決め、前提では変わらない。境界の内の課題が前提で外れても、境界の外の課題は候補に上がらない | +| 計画の `digest` の食い違い | 前提の課題を持つ行が現れると `digest` の値が変わり、それより前の `digest` を持つ計画(`plan.rank`)は古いと判定される。打ち直しで揃う見込みで、実測していない | +| 記録に無い依存 | 選定は棚卸しが見積もりの記録へ書いた依存と並列の組だけを読む。記録に依存が無い課題は前提では外れない。#2344 の見積もりの記録は 2026-11-05 時点で依存が空であり、書き足した後に外れることは未確認である | +| 依存の連なりの進み方 | 依存が連なる課題は、前提の課題が閉じる(本番への配布の後)まで 1 段ずつしか進まない。周回の数への影響は測っていない | + +## テスト観点 + +| 観点 | テスト | +| --- | --- | +| 1 位の行が open の前提の課題を持つと落選し、理由が `前提の課題の実装待ち(#B)` になること。前提が 2 件なら昇順で 2 件載ること | `plugins/ndf/scripts/tests/test_sprint_loop.py` | +| 境界が無く `max_issues` が 1 のとき、1 位が前提で外れると 2 位が選ばれること | 同上 | +| 先頭に選ぶ課題(`revise`・承認とマージが済んだ設計)も前提で外れ、手の除外・差し戻しの行は先に判定する理由が残ること | 同上 | +| 候補がすべて前提で外れると `outside_mvv` で止まり、`values.reasons` に理由ごとの件数が載ること | 同上 | +| 見積もりの記録と並列の組の依存から、閉じた課題を除いた前提の課題が行に載ること。別のマイルストーン・マイルストーン未設定の課題も載ること | `plugins/ndf/skills/backlog-refinement/tests/test_upkeep_rank_cli.py` | +| サブイシューの辺だけの関係は `prerequisites` を出さないこと | 同上 | +| 依存の記録が無い算出の行は `prerequisites` を持たないこと | 同上 | + +## 関連リンク + +- [ndf-workflow-development-loop.md](ndf-workflow-development-loop.md) — 開発ループの選定の規則の全体 +- [ndf-backlog-refinement-depths.md](ndf-backlog-refinement-depths.md) — 棚卸しの深さと順位の算出 +- [GitHub: Creating issue dependencies](https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-issue-dependencies) diff --git a/docs/specifications/ndf-parallel-organizer.md b/docs/specifications/ndf-parallel-organizer.md index f181f385a..24086ed0c 100644 --- a/docs/specifications/ndf-parallel-organizer.md +++ b/docs/specifications/ndf-parallel-organizer.md @@ -101,7 +101,7 @@ lroot2 の変更もまとめて出た。そのため lroot2 が出していた | I8 | 依存のあるレーンは、依存先のレーンがすべて止まってから起動する。問いを保留して止まったレーン(答えていない問いを持つ・答えをまだ当てていない・`waiting_only` で止まったループ)は、止まったと数えない | 依存先が止まるまで起動の対象から外す | | I9 | 流れている本番の配布は、1 つの計画につき 1 本まで。依頼が届いた時点で流れている配布が無ければ、直ちに配布を組む。中身は提示したコミットの範囲で決まる | 流れている間に届いた依頼は、範囲に入ればその配布の答えを受け、入らなければ `pending` に残って次の配布になる | | I10 | 依頼 1 件への最終の答え(`released` / `rejected` / `failed`)は、イベント `answer-<依頼>` に 1 回だけ立つ。次の配布へ回した知らせは `defer-<依頼>` で、答えではない | 答えのイベントは耐久ワークフローの中でだけ立てる。`parallel.py answer` もメッセージ(`kind: answer`)を送るだけで、イベントを直接立てない | -| I11 | 耐久ワークフローが終わるのは、全レーンが止まり、流れている配布も、答えていない依頼と問いも無いときか、停止の指示を受けたときだけである | 終わる前に、答えを当てていない持ち主のレーンがあれば起こす 1 行を添え続ける。止まったループのレーンに保留の周回が残れば、一時レーンを起こして流させる | +| I11 | 耐久ワークフローが終わるのは、最後の観測で状態の一覧(`relay.py ls`)が成功し、全レーンが止まり、流れている配布も、答えていない依頼と問いも無いときか、停止の指示を受けたときだけである | 終わる前に、答えを当てていない持ち主のレーンがあれば起こす 1 行を添え続ける。止まったループのレーンに保留の周回が残れば、一時レーンを起こして流させる | | I12 | 動いている並列の進行(耐久ワークフローが `PENDING` か `ENQUEUED`)に属さないレーンは、依頼を送らず今の本番のプランを流す | `lane_of` が None を返し、今の本番のプランを組む | | I13 | 同時に動かせる数から外すのは、オーガナイザーの印を持つ動いているラッパー 1 本だけ。役割 `helper` のラッパーは 1 本だけ、上限を 1 本超えて許す | 2 本目以降のオーガナイザーの印は数に入る。2 本目の `helper` は統括役が断る | | I14 | 出すのは確定したコミットと版上げのコミットだけ。提示は 1 つのまとめた配布につき 2 つまで | 出し直しの提示への `confirm` は比べずに確定し、提示 3 を組まない | @@ -171,7 +171,7 @@ graph TD 4. 流れている配布が無く `pending` があれば、出せた配布の範囲で答えられる依頼に答える。残りがあれば、まとめた配布を組む(下の「まとめた配布」) 5. I11 を満たせば、役割の印を消して `finished` で終わる 6. `inbox` を最長 60 秒(`NDF_PARALLEL_POLL`。配布の途中は 30 秒)待つ。配布の途中なら `resume` も読む -7. 耐久ステップ「観測」で `relay.py ls --json` を読む。状態が `awaiting_approval` へ移ったレーンがあれば `notice` を立てる。動かないレーンと、止まった一時レーンには問いを立てる。2 へ戻る +7. 耐久ステップ「観測」で `relay.py ls --json` を読む。状態が `awaiting_approval` へ移ったレーンがあれば `notice` を立てる。動かないレーンと、止まった一時レーンには問いを立てる。一覧が 0 以外で終わった回は、失敗の続いた長さの追跡のほかは何も変えず、次の観測を待つ。2 へ戻る ### まとめた配布 @@ -217,7 +217,7 @@ stateDiagram-v2 | `gate1`(設計の承認) | ループのレーン(`sprint_loop_gate.py`) | `<レーン名>-g<設計 PR>-<先頭の SHA の 12 桁>` | 設計 PR の先頭の SHA | | `ask`(承認ゲートの外の問い。1 スプリントのレーンの承認ゲート 1 も含む) | 問いの hook(`parallel.py request-ask`) | `<送り手>-q<判定の記録の id>` | 作業の場所の `HEAD` | | `gate2`(承認ゲート 2) | 一時レーン(`parallel.py request-gate2`) | `<計画名>-p<連番>-<提示>` | 承認資料の承認したコミット | -| `stall`・`helper`・`spawn` | 耐久ワークフロー自身 | `<レーン>-s<時刻>`・`<一時レーン>-h<起動の連番>`・`<計画名>-spawn-<レーン>-<回数>` | — | +| `stall`・`helper`・`spawn`・`observe` | 耐久ワークフロー自身 | `<レーン>-s<時刻>`・`<一時レーン>-h<起動の連番>`・`<計画名>-spawn-<レーン>-<回数>`・`<計画名>-observe-<観測の時刻>`(`observe` はレーンに紐づかず、`lane` と `attach` を持たない) | — | - レーンは、答えを使う前に今の印と答えの `stamp` を比べる。違えば答えを使わずに問い直す(ループのレーンの設計の承認で実装済み) - 同じ原因の問いは 1 つにまとめる。対象は `ndf-ask:human-action` の問いで、区分・選択肢・文面が同じものである。文面はレーン名・パス・数字を伏せ、空白をまとめてから比べる。 @@ -264,7 +264,7 @@ stateDiagram-v2 | --- | --- | --- | | `NDF_RELAY_MAX_NAMED` | 3 | レーンの数の上限(統括役の上限と同じ値)。正の整数でなければ既定を使い、検査の結果に書く | | `NDF_PARALLEL_MAX_PLANS` | 6 | レーンのプランの本数の合計の上限。扱いは同上 | -| `NDF_PARALLEL_STALL_MINUTES` | 60 | 承認待ちでも止まってもいないレーンを「動かない」として問うまでの分 | +| `NDF_PARALLEL_STALL_MINUTES` | 60 | 承認待ちでも止まってもいないレーンを「動かない」として問うまでの分。状態の一覧の失敗が続くことを問うまでの分と、次に問うまでの間隔にも使う | | `NDF_PARALLEL_POLL` | 60 | `inbox` と `resume` を待つ秒(テストで縮める) | | `NDF_PARALLEL_RELAY` | — | 統括役の CLI の打ち方の差し替え(テスト用) | | `NDF_RELAY_NAME`・`NDF_RELAY_DIR` | — | レーンの判定と役割のファイルの置き場(ラッパーが立てる) | @@ -297,6 +297,7 @@ stateDiagram-v2 | 動かないレーン | 観測で、`relay.py ls` の `at` が `NDF_PARALLEL_STALL_MINUTES` より前で、承認待ちでも止まってもいない | `kind: stall` の問い(起こし直す / 止める / 待つ)。「待つ」の後は次の区切りまで問わない | | 配布の途中で一時レーンが止まる | 観測で一時レーンが `stopped`、`settle` も `gate2` の依頼も無い | `kind: helper` の問い(続きから起こし直す / 止める)。「止める」は配布を `failed` で締める | | レーンの起動が断られ続ける | `relay.py spawn` の断りが 3 回続く | `kind: spawn` の問い。「止める」は止まったレーンとして数える | +| 状態の一覧が失敗する | 観測で `relay.py ls --json` が 0 以外で終わる | その回の観測を捨て、レーンの状態・最後に観測した時刻・起こし直しの待ちを変えず、`finished` でも終えない。失敗が `NDF_PARALLEL_STALL_MINUTES` より長く続くと、区切りごとに `kind: observe` の問い(ラッパーの環境を作り直す / 待つ)を立てる。本文に一覧の終了コードと標準エラーの末尾 1 行が入る。一覧が成功に戻れば、答えていない問いを外す。問いの無い版の記録を再生する間は問わない(記録の呼び出しの順を保つ) | | 同じ原因の問いが複数のレーンから届く | 「依頼と答え」の正規化した鍵が同じ | 1 つにまとめ、答えを全レーンへ配る | | 手元が待ちだけになったループのレーン | 周回の境で、選べる課題が無く保留の周回か答えの無い設計の承認がある | ループは `waiting_only` で止まり、統括役の枠を空ける。答えの後、オーガナイザーが起こす(loop.md) | | 止まったレーンの保留の周回が残る | I11 を満たした時点で、`released` を受けたのにまとめを流していない保留の周回がある | `action: held` で一時レーン(`--parallel-helper <計画名> pending <ループ名>`)を 1 本ずつ起こし、流し終えてから終える | @@ -323,6 +324,7 @@ stateDiagram-v2 | 答えの時点で先頭が同じなら確定、進んでいれば提示 2 を組む。提示 2 への答えでは比べず、提示 3 を組まない。確定まで本番のプランを流さない(I14) | 同上 | | 範囲に入ったレーンは後で依頼に来ても送らずに `released` で進み、2 本目の本番のプランが組まれない(I15、Pull Request 2420 の件) | `test_parallel_run.py`・`test_parallel_lane.py` | | 答えは `answer-<依頼>` に 1 回だけ立ち、`defer-<依頼>` では `await` が返らない(I10)。全レーンが止まり答えていない依頼が無いときだけ `finished`(I11) | `test_parallel_run.py` | +| 状態の一覧が失敗した回はレーンの状態を変えず `finished` で終えない。失敗が続くと区切りごとに問いが 1 件立ち、成功に戻ると問いが外れる | `test_parallel_run.py` | | 動いている計画に属さないレーンでは依頼を送らない(I12)。送り手の資格を `lane_of` だけで見ない(一時レーン) | `test_parallel_lane.py` | | 依頼の `id` は受け取りの前後の打ち直しで変わらず、別の設計 PR では別になる(I20) | `test_parallel_lane.py` | | 問いの依頼をまとめて 1 回で返し、答えの間に届いた依頼だけを続けて返す。同じ原因の問いを 1 つにまとめて全レーンへ答える。止まった持ち主を起こす 1 行を返す | `test_parallel_run.py` | diff --git a/docs/specifications/ndf-production-release-record.md b/docs/specifications/ndf-production-release-record.md index 71e2de101..d2b40276a 100644 --- a/docs/specifications/ndf-production-release-record.md +++ b/docs/specifications/ndf-production-release-record.md @@ -159,12 +159,13 @@ python3 release-steps.py record --version <版> --prs ... [--plugin < | 最後のリリース記録の `段階:` | 使うブロック | | --- | --- | -| `本番` | `対象の版:` の `(` の前が `版:` の右と完全一致する最後のブロック。無ければ、最後のリリース記録より後で、`対象の版:` が版数(`v?<数>.<数>.<数>` で始まる)でない最後のブロック。それも無ければ使わない | +| `本番` | `対象の版:` の `(` の前が `版:` の右と完全一致する最後のブロック。両側とも、数字の前の先頭の小文字の `v` を落としてから比べる(`v10.26.1` と `10.26.1` は同じ版。大文字の `V` と `main c427284` のような版数でない表記は変えない)。無ければ、最後のリリース記録より後で、`対象の版:` が版数(`v?<数>.<数>.<数>` で始まる)でない最後のブロック。それも無ければ使わない | | `配布なし` | 最後のリリース記録より後の最後のブロック | | それ以外(`検証` など) | 使わない | 例: devbasex/devbase PR #432 の `版: 4.0.0 → 4.0.0` の記録の後に置いた `対象の版: main c427284` のブロックは 2 段目で選ばれる。 本番 `10.17.68` の記録の後に置いた `対象の版: 10.17.69-dev.1` のブロックは版数なので選ばれない。 +本番 `10.26.1` の記録の後に置いた `対象の版: v10.26.1(…)` のブロックは 1 段目で選ばれ、読み取った本番の版は `v` を付けない `10.26.1` になる(課題 #2225)。 ### まとめのプランの記録の PR と `release-verify` @@ -357,7 +358,7 @@ sequenceDiagram | 投稿の失敗(I4) | `gh pr comment` が非 0 なら 1・`stopped` で終わること | | 報告の Pull Request(I5) | `pr_from` の run のステップが 0 で終わると報告の `Pull Request` が `metrics` の URL になり、落ちたとき・鍵が無いときは `無し` のままであること | | まとめが閉じる(条件 1) | `record` が書いた記録を持つ PR を `--record-pr` に渡し、`--with-verification` なしの `sprint-close.py --dry-run` で `--issues` の OPEN の課題が `would_close` になること | -| 版の食い違い(I8) | PR #432 の形の組で `verify_block` が後のブロックになること。リリース記録より前にだけブロックがある PR・版数の違うブロックだけが後にある PR では None。版の一致するブロックとその後ろの別の版のブロックがある PR では一致する方を選び、`版: 10.17.67 → 10.17.68` の組で結果が直す前と同じであること | +| 版の食い違い(I8) | PR #432 の形の組で `verify_block` が後のブロックになること。リリース記録より前にだけブロックがある PR・版数の違うブロックだけが後にある PR では None。版の一致するブロックとその後ろの別の版のブロックがある PR では一致する方を選び、`版: 10.17.67 → 10.17.68` の組で結果が直す前と同じであること。先頭の `v` の有無だけが違う組(`版:` と `対象の版:` のどちらに付いても)ではそのブロックを選び、`version` が `v` なしになること。`V10.26.1` と `main c427284` は読み替えないこと | | 昇格の記録(I7) | `record --promote` がマージ済みの PR があればその PR へ書き、無ければ書かずに 3 で終わること。書いた記録を `parse_record` が `本番` で読むこと。本文に `段階: 検証(… 承認待ちのため未実施)` を置いた PR に記録をコメントで足すと `本番` として読むこと | | 昇格のプラン | normal と fast / auto の両方で、マージの後に `record`(`pr_from: release_pr_url`・`on_fail: judge-record`)があり、`judge-record` の `choices` が `record` と `stop` だけであること | | まとめのプラン(I9) | 雛形と昇格の経路で、マージ → `release-verify` → `close`(`--with-verification`)がこの順で並び、昇格の `close` が `--record-pr {queue_pr:promote}` であること。none だけの経路は `0`、merge・manual・検証の環境だけの経路は `unknown` で、どちらも `release-verify` が無いこと | diff --git a/docs/specifications/ndf-relay-multi-session-supervisor.md b/docs/specifications/ndf-relay-multi-session-supervisor.md index 0e79155a7..f5456fff7 100644 --- a/docs/specifications/ndf-relay-multi-session-supervisor.md +++ b/docs/specifications/ndf-relay-multi-session-supervisor.md @@ -313,6 +313,7 @@ tmux のソケットの置き場は tmux の規則(`TMUX_TMPDIR`)のまま | 統括役が `NDF_RELAY_HUB_WAIT` 秒答えない | 頼む側は依頼を消して戻る。端末の 2 本目は 1 行出して前景の起動、`spawn`・`restart` は `no-hub`・1。依頼を消せなければ統括役が受け取った後なので、もう 1 度同じ秒数だけ結果を待つ | | `spawn`・`restart` で統括役が居ない | 依頼を置かずに `no-hub`・1(端末で `claude` を起動すると統括役になる、と出す)。起動のブロックは統括役が居なくても依頼を置き、次の統括役が処理する | | `tmux new-session` の失敗 | tmux の出力(無ければ終了コード)を `summary` にして `tmux-failed`・1。登録簿は変えない | +| 作ったセッションが `NDF_RELAY_LAUNCH_CHECK` 秒(既定 2)後に無い | `tmux-failed`・1。`summary` に起動のコマンドを載せ、登録簿は変えない。tmux に聞けなければ起動した扱いのまま進める | | 統括役の thread の例外 | 記録に `failed` と例外の型を残して thread を終え、`hub.lock` を放す。前景のラッパーは動き続け、次に打った `claude` が統括役になる | | 受付箱・依頼の権限が所有者だけでない | 処理せずに記録へ `refused` を残す。依頼のファイルは消す | | Stop hook で依頼を置けない | 例外を握り、Stop を止めない | diff --git a/docs/versioning-and-distribution.md b/docs/versioning-and-distribution.md index ce465be28..9f311638d 100644 --- a/docs/versioning-and-distribution.md +++ b/docs/versioning-and-distribution.md @@ -57,11 +57,11 @@ semver の順序で除外されるのは、プラグイン間の依存解決(` | 版 | 形 | 意味 | | --- | --- | --- | -| 正式版 | `10.27.27` | 利用者が常用してよい | +| 正式版 | `10.27.31` | 利用者が常用してよい | | 開発版 | `10.28.0-dev.1` | 検証中。入れたくない利用者は取得を控えられる | | 公開前の確認版 | `10.28.0-rc.1` | 正式版の候補。残るのは確認だけ | -- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.27` の次を開発するなら `10.28.0-dev.1` +- 接尾辞は**次に出す正式版の版数へ付ける**。`10.27.31` の次を開発するなら `10.28.0-dev.1` - 連番は開発版を出すたびに増やす。**同じ版数で中身を差し替えない**。差し替えると、利用者の 手元にある版と `main` の版が同じ番号で別物になり、何を確かめたのかが分からなくなる - **正式版を出すときは接尾辞を外す。** `10.28.0-dev.3` の次は `10.28.0` diff --git a/issues/issue-1694-design.md b/issues/issue-1694-design.md deleted file mode 100644 index 72bb86762..000000000 --- a/issues/issue-1694-design.md +++ /dev/null @@ -1,249 +0,0 @@ -# このリポジトリのテストの宣言: 継続的統合のジョブ 11 件を、手元で走らせる静的解析か理由つきの除外として載せる(#1694) - -## 目的 - -- **今どうなっているか**: このリポジトリの継続的統合のジョブ 24 件のうち 11 件が、テストの宣言(`.ndf/project.json` の `test`)のどこにも載っていない。11 件は生成物の同期・Skill の frontmatter・リンク・文書の行数・指示書・`claude plugin validate` などである。cross-refactoring の改善項目がこれらの検査を壊しても、着手前のテストと最終ゲートは走らせず、Pull Request を出した後の継続的統合で初めて落ちる。`init` は毎回「宣言に無いもの 11 件」を知らせる -- **誰に効くか**: このリポジトリで cross-refactoring・cross-review・テストの実行(`test-run.py`)を回す開発者と AI -- **変えると何が成り立つか**: 手元で判定が決まる 9 件は静的解析の suite として最終ゲートで走り、改善項目が壊せば変更起因として止まる。手元で判定が決まらない 2 件は理由つきの除外になり、`init` は「宣言に無いジョブ」を知らせなくなる - -## 適用範囲 - -- **働く範囲**: このリポジトリだけ。変えるのは宣言の `test` の要素だけで、配布物(`plugins/`)・継続的統合のワークフロー・突き合わせの処理(`ci_coverage.py`)・宣言のスキーマは変えない -- **プロジェクトごとに違うもの**: どのジョブを suite にするか・除外にするかは、各リポジトリの宣言(設定)で受ける。この変更はこのリポジトリの設定の値だけを決める -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| 9 本のコマンドを作業ディレクトリの根で走らせると、すべて終了コード 0、所要の合計 19.6 秒(2026-11-05、このブランチの worktree、1 回の実測)。走らせた後の `git status --porcelain` は空 | 実測 | 9 件は手元で判定が決まり、作業ディレクトリを書き換えないこと | -| `claude` を `PATH` から外して `bash scripts/validate-runtime-plugins.sh` を走らせると、`==> claude CLI not found; skipped claude plugin validate` を出して終了コード 0、8.5 秒(2026-11-05) | 実測 | `claude` の CLI が無いホストでも、このジョブの残りの検査は走り、判定が決まること | -| `scripts/validate-runtime-plugins.sh` の `if command -v claude` の分岐 | 実測(コードの読み取り) | CLI の有無で落ちないことが、スクリプト自身の約束であること | -| `plugins/ndf/scripts/lib/test_triage.py` の `lint_verdict`、`plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gate_lint.py` の `lint_gate` | 実測(コードの読み取り) | 最終ゲートは静的解析の suite を 1 本ずつ走らせ、着手前の suite ごとの成否で既存失敗と変更起因を分けること | -| `plugins/ndf/scripts/project_lib/merge.py` の不変条件(置き換えてよいのは、キーが無い項目と、前の解析の値のままの項目だけ)と、宣言の `analysis.written` に `test` の指紋が無いこと | 実測(コードと宣言の読み取り) | 宣言の再解析が、手で足した `test` の要素を置き換えないこと | - -要求と受け入れ条件は #1694 の本文にある(コピーは [issue-1694-requirements.md](issue-1694-requirements.md) )。この文書は「どう作るか」だけを扱う。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| このリポジトリの開発の検査(`repo-dev-checks`) | 継続的統合のジョブ・ジョブの識別子・除外したジョブ | -| cross-refactoring(`ndf-cross-refactoring`) | 宣言に無いジョブ・着手前のテスト・最終ゲート・既存失敗・変更起因 | - -**関係は公開された言語である。** 2 つのコンテキストは、宣言のスキーマの `test.suites[].ci_jobs`・`test.ci_exempt` を介してだけやり取りする。スキーマは `plugins/ndf/skills/development-workflow/schemas/project.schema.json` にある。この変更はスキーマを変えず、このリポジトリの側の値だけを足す。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| テストの宣言(`.ndf/project.json` の `test`) | このリポジトリの開発者(手で書く。宣言の再解析は指紋の無い `test` を置き換えない) | `test` | suite(`name` で識別)・除外したジョブ(`job` で識別) | ジョブの識別子・コマンド・除外の理由 | - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | テストの宣言 | 継続的統合のジョブはどれも、suite の `ci_jobs` か除外したジョブのどちらか一方だけに当たる | 両方に当たる・どちらにも当たらないジョブがあれば、実装の検証で止め、宣言を直す | -| I2 | テストの宣言 | 足した除外したジョブの理由は空でなく、手元で判定が決まらない要因を書く | 理由が空ならスキーマの検査(`project-decl.py check`)が 3 を返す。要因でない理由は実装の検証で直す | -| I3 | テストの宣言 | 足した suite の `command` は、`ci_jobs` に書いたジョブの `run` の行と同じスクリプトを同じ引数で呼ぶ(継続的統合だけで要る準備の手順は除く) | 実装の検証で止め、コマンドを直す | -| I4 | テストの宣言 | 足した suite は、秘密とネットワークを使わずに根で走り、終了コード 0 で終わる。所要の合計は 30 秒以内 | 0 で終わらない suite は、宣言へ載せる前に原因を調べて人へ戻す | -| I5 | テストの宣言 | 既存の suite 5 本と既存の除外 5 件は、名前・コマンド・`ci_jobs`・理由が変わらない | 差分に既存の要素の変更があれば戻す | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 宣言に suite と除外を足した | 実装の変更 | cross-refactoring の `init`・着手前のテスト・最終ゲート、cross-review、`test-run.py` | -| E2 | `init` が継続的統合のジョブと宣言を突き合わせた | cross-refactoring の `init`(`compare_jobs`) | `init` の出力(宣言に無いジョブが 0 件なら行を出さない) | -| E3 | 着手前のテストで、足した静的解析の suite を全体で走らせた | cross-refactoring の着手前のテスト(`baseline.py`) | 最終ゲート(suite ごとの成否を既存失敗の判定に使う) | -| E4 | 最終ゲートで、足した静的解析の suite を全体で走らせた | cross-refactoring の最終ゲート(`gate_lint.py`) | 最終ゲートの判定(変更起因なら止まる) | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| ジョブの識別子 | 継続的統合のジョブを指す `<ワークフローのファイルのパス>#`。`#` を省くとそのファイルのジョブすべてを指す | 無し(要求の用語を引き継ぐ。突き合わせの処理と宣言のスキーマの説明が持つ) | -| 除外したジョブ | 手元の検証で走らせないと宣言したジョブ。宣言の `test.ci_exempt` に理由と組で書く | 無し(同上) | -| 宣言に無いジョブ | 継続的統合のジョブのうち、どの suite の `ci_jobs` にも除外したジョブにも当たらないもの | 無し(同上) | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 改善項目が継続的統合の検査 9 件を壊したとき、cross-refactoring の最終ゲートが変更起因として止める | cross-refactoring を回す開発者と AI | -| F2 | cross-review とテストの実行(`test-run.py whole`)で、検査 9 件を全体テストの一部として走らせる | cross-review を回す AI・手元でテストを走らせる開発者 | -| F3 | 手元で判定が決まらない 2 件を、理由つきで手元の検証から外す | 宣言を読む開発者と AI | -| F4 | `init` が「宣言に無いジョブ」を知らせない | cross-refactoring を回す開発者と AI | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| テストの宣言(`.ndf/project.json` の `test`)※変える | suite 9 本と除外したジョブ 2 件を足す。ほかの要素は変えない | -| 突き合わせの処理(`refactor_lib/ci_coverage.py`)※変えない | 宣言とワークフローのジョブを突き合わせ、宣言に無いジョブを挙げる | -| 着手前のテスト(`refactor_lib/baseline.py`)※変えない | 静的解析の suite を全体で走らせ、suite ごとの成否を残す | -| 最終ゲートの静的解析(`refactor_lib/gate_lint.py`)※変えない | 静的解析の suite を 1 本ずつ走らせ、落ちたものを既存失敗か変更起因に分ける | -| テストの実行(`plugins/ndf/scripts/test-run.py`)※変えない | `whole` で宣言の suite の `command` を走らせる | -| 検査スクリプト 9 本(`scripts/` と `plugins/ndf/scripts/instructions-check.py`)※変えない | 継続的統合と同じ検査を、手元で同じ引数で行う | - -```mermaid -graph TD - D[テストの宣言] --> C[突き合わせの処理] - W[継続的統合の
ワークフロー] --> C - D --> B[着手前のテスト] - D --> G[最終ゲートの
静的解析] - D --> T[テストの実行] - B --> S[検査スクリプト 9 本] - G --> S - T --> S -``` - -## データ構造 - -宣言のスキーマは変えない。足す値は次のとおりである。 - -### 足す suite - -9 本とも `kind: lint`・`needs: []` で、`scope_command`・`junit`・`paths` を書かない(決定 3)。`ci_jobs` はジョブの単位の識別子 1 つである。パスは `.github/workflows/runtime-plugin-validate.yml` で、以下の表では `#` 以降だけを書く。並びは既存の suite 5 本の後ろに、ワークフローのジョブの順で置く。 - -| `name` | `runner` | `command` | `ci_jobs` | 手元の所要 | -| --- | --- | --- | --- | --- | -| `runtime-plugin-build-check` | `build-runtime-plugins` | `bash scripts/build-runtime-plugins.sh --check` | `#runtime-plugin-build-check` | 0.2 秒 | -| `skill-frontmatter-check` | `check-skill-frontmatter` | `python3 scripts/check-skill-frontmatter.py` | `#skill-frontmatter-check` | 0.1 秒 | -| `skill-repo-assumptions-check` | `check-skill-repo-assumptions` | `python3 scripts/check-skill-repo-assumptions.py` | `#skill-repo-assumptions-check` | 0.2 秒 | -| `skill-shell-vars-check` | `check-skill-shell-vars` | `python3 scripts/check-skill-shell-vars.py` | `#skill-shell-vars-check` | 0.3 秒 | -| `service-boundary-check` | `check-service-boundary` | `python3 scripts/check-service-boundary.py` | `#service-boundary-check` | 1.3 秒 | -| `runtime-plugin-validate` | `validate-runtime-plugins` | `bash scripts/validate-runtime-plugins.sh` | `#runtime-plugin-validate` | 11.0 秒 | -| `markdown-link-check` | `check-markdown-links` | `python3 scripts/check-markdown-links.py --root .` | `#markdown-link-check` | 6.2 秒 | -| `doc-line-limit-check` | `check-doc-line-limit` | `python3 scripts/check-doc-line-limit.py --root .` | `#doc-line-limit-check` | 0.1 秒 | -| `instruction-files-check` | `check-instruction-files` | `python3 plugins/ndf/scripts/instructions-check.py --root .` | `#instruction-files-check` | 0.1 秒 | - -所要は 2026-11-05 のこのブランチの worktree での 1 回の実測である。合計は 19.6 秒で、非機能の条件の 30 秒以内に収まる(丸める前の値の和。表の値の和は 19.5 秒)。`runtime-plugin-validate` は `claude` の CLI があるときの値で、無いときは 8.5 秒。`name` は継続的統合のジョブ id と同じにし、最終ゲートの記録と継続的統合のチェックの名前を同じ語で読めるようにする。`runner` は呼ぶスクリプトの名前(拡張子を除く)で、判定には使われない。 - -1 本を宣言に書いた形の例: - -```json -{ - "name": "runtime-plugin-build-check", - "runner": "build-runtime-plugins", - "kind": "lint", - "command": "bash scripts/build-runtime-plugins.sh --check", - "needs": [], - "ci_jobs": [ - ".github/workflows/runtime-plugin-validate.yml#runtime-plugin-build-check" - ] -} -``` - -### 足す除外したジョブ - -既存の除外 5 件の後ろに、パスの順で置く。 - -| `job` | `reason` | -| --- | --- | -| `.github/workflows/runtime-plugin-smoke-latest.yml` | 定期と手動でだけ起動し、ネットワークから最新の CLI の版を解決して入れる。項目のコミットだけでは判定が決まらない | -| `.github/workflows/runtime-plugin-validate.yml#required-checks-check` | Pull Request の宛先のブランチと GitHub の API(トークン)を使う。項目の変更で結果が変わらない | - -`runtime-plugin-smoke-latest.yml` はジョブが 1 件だけで扱いが分かれないため、ファイルの単位で書く。`required-checks-check` は同じファイルに suite にするジョブがあるため、ジョブの単位で書く(要求の前提 2)。 - -## 処理の流れ - -宣言を読む処理はどれも変えない。足した値が通る経路は次のとおりである。 - -```mermaid -sequenceDiagram - participant I as init - participant B as 着手前のテスト - participant G as 最終ゲート - participant S as 検査スクリプト - I->>I: compare_jobs(宣言に無いジョブ 0 件、行を出さない) - B->>S: 足した suite を 1 本ずつ全体で走らせる - S-->>B: suite ごとの成否(green / red) - G->>S: 足した suite を 1 本ずつ全体で走らせる - S-->>G: 終了コード - G->>G: 落ちた suite を lint_verdict で判定 -``` - -最終ゲートで落ちた suite の判定は、既存の `lint_verdict` の表のとおりである。足した suite は `scope_command` を持たないため、次の 2 行だけを通る。 - -| 着手前の成否 | 判定 | -| --- | --- | -| 通っていた | 変更起因(最終ゲートが止まる) | -| 落ちていた | 既存失敗(判定から外す) | -| 読めない | 変更起因 | - -`scope_command` を持たない suite は、改善項目ごとの検証(範囲テスト)では走らない。`targets.py` の `lint_runs_for` が、`scope_command` を持つ suite だけを使うためである。改善項目が壊した場合に気づくのは最終ゲートである。 - -## 非機能の実現方式 - -| 大項目 | 実現方式 | -| --- | --- | -| 性能・拡張性 | 足す suite の所要の合計は 19.6 秒(実測)。着手前のテストと最終ゲートは、それぞれこの分だけ長くなる。`runtime-plugin-validate` は別の suite と同じ検査(生成物の同期・リンク・文書の行数)を内部でもう一度行い、重なりは実測で約 6.5 秒(決定 2) | -| システム環境 | 足す suite はリポジトリの中のファイルだけを読む。`scripts/check-markdown-links.py` が使う `urllib` は `urllib.parse`(文字列の分解)だけで、通信をしない。`claude plugin validate` が通信をするかは確かめられていない(「未確認のまま残ること」) | - -## 決定の記録 - -### 決定 1: 落ちた検査を 1 つずつ判定できるよう、ジョブ 1 件に suite を 1 本置く - -最終ゲートは静的解析の suite を 1 本ずつ走らせ、着手前の suite ごとの成否で既存失敗と変更起因を分ける(`gate_lint.py`・`lint_verdict`)。9 件を 1 本の suite にまとめると、どれか 1 つが着手前から落ちていれば suite 全体が既存失敗として判定から外れる。そのとき、ほかの 8 件を改善項目が壊しても止まらない。`&&` でつないだコマンドは最初の失敗で止まり、後ろの検査が走らない。 - -9 本の `command` を 1 本の suite にまとめる形は採らない。suite を増やす費用は宣言の行数だけで、走らせる時間は変わらないためである。 - -根拠: Value 7(根本原因の場所で直す)/ Value 2(人が判断できる量に収める)(MVV 版 5) - -### 決定 2: `claude` の CLI が無いホストでも残りの検査を走らせるため、`runtime-plugin-validate` を suite にする - -このジョブのスクリプトは、CLI が無いと `claude plugin validate` だけを飛ばす。残りの検査(定義ファイルの形・マーケットプレイスの登録・説明文書の数と版・Skill の境界をまたぐ参照)は走らせ、0 で終わる(実測)。後ろの 2 つの検査(`check-doc-staleness.py`・`check-cross-skill-refs.py`)は、継続的統合ではこのジョブでしか走らない。`.github/` と `scripts/*.sh` を検索すると、呼ぶのは `scripts/validate-runtime-plugins.sh` だけである。除外にすると改善項目が壊しても手元で気づけない。継続的統合で先に CLI を入れる手順(`npm install -g`)は準備の手順で、受け入れ条件 4 が除く。Codex のホストで CLI が無いときは、`claude plugin validate` の分だけ継続的統合より検査が少なくなる。 - -除外にする形は採らない。CLI の有無で変わるのは検査の 1 つだけで、残りの検査は手元で判定が決まるためである。スクリプトの中で別の suite と重なる検査(約 6.5 秒)は、重ならないように別の suite を外す形も採らない。外すとその suite の `command` の引数が継続的統合のジョブと変わり(スクリプトの中では `--root` に絶対パスを渡す)、決定 1 の 1 件ずつの判定も失うためである。 - -根拠: Value 6(どのプロジェクトでも使える形を保つ)/ Value 1(小さく出して使って確かめる)(MVV 版 5) - -### 決定 3: 読まれない値を宣言に残さないため、足す suite に `scope_command` と `paths` を書かない - -9 本の検査スクリプトはどれも対象のファイルのパスを引数に受けず、受ける形にする変更はこの課題の範囲の外である(要求の対象範囲)。静的解析の suite の `paths` は、`scope_command` を持つ suite の範囲を決めるときだけ読まれ、`scope_command` の無い suite では読まれない。読むのは `lint_verdict` と、`targets.py` の `suite_lint_files`・`lint_runs_for` である。読まれない値を書くと、検査スクリプトの読む範囲が変わっても誰も気づかず古くなる。 - -`{paths}` を受け取って捨てる `scope_command`(例: 末尾に `# {paths}` を付ける)は採らない。継続的統合と同じ引数で呼ぶ約束(受け入れ条件 4)から外れ、改善項目ごとに全体の検査を走らせて時間が増えるためである。 - -根拠: Value 3(やめる・縮めるを既定にする)/ Value 5(決まった手順はスクリプトに、判断と中身は AI に)(MVV 版 5) - -### 決定 4: 手元で判定が決まらない 2 件を、理由つきの除外にする - -`required-checks-check` は Pull Request の宛先のブランチと GitHub の API を使う。`runtime-plugin-smoke-latest.yml` はネットワークから最新の CLI を入れる。どちらも要求の前提 3 を満たさない。前提 3 は、秘密・ネットワーク・Pull Request の文脈を使わずに手元で判定が決まることである。理由には、手元で判定が決まらない要因を書く(受け入れ条件 2)。 - -`check-required-checks.py` を宛先を固定して手元で走らせる形は採らない。GitHub の API とトークンが要り、継続的統合と同じ引数でも呼べないためである。 - -根拠: 共通原則 C1(秘密に触れる操作を手元の検証に入れない)/ Value 6(MVV 版 5) - -## テスト設計 - -要求の取り決めのとおり、振る舞いを変えないためテストのコードは足さない。受け入れ条件と不変条件は、実装の検証でコマンドを走らせて確かめる。 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1・3 / I1 | 根の宣言と作業ディレクトリで `compare_jobs` を打つと、`status` が `compared`、`undeclared` が空 | 足した suite か除外を 1 つ消すと、`undeclared` にそのジョブが出る | -| I1(一方だけ) | 宣言の `ci_jobs` と除外の `job` を並べ、同じ識別子・同じファイルを指すファイルの単位の識別子が両方に無い | `required-checks-check` を suite と除外の両方に書くと、重なりとして見つかる | -| 受け入れ条件 2 / I2 | 足した除外 2 件の `reason` が空でなく、要因(ネットワーク・Pull Request の宛先と GitHub の API)を書いている | `reason` を空にすると `project-decl.py check` が 3 を返す | -| 受け入れ条件 4 / I3 / I4 | 足した suite の `command` を根で 1 本ずつ走らせると 0 で終わり、文字列がワークフローの `run` の行と一致する | 引数を変える(`--root .` を外すなど)と `run` の行との照合で食い違う | -| I4(CLI が無いホスト) | `claude` を `PATH` から外して `runtime-plugin-validate` の `command` を走らせても 0 で終わる | スクリプトが CLI の有無で落ちる形になると 0 以外で終わる | -| 受け入れ条件 5 | `project-decl.py check --root .` が 3 を返さない(今は入力が古いため 2) | スキーマに無いキーを足すと 3 を返す | -| 受け入れ条件 6 / I5 | `git diff` で、既存の suite 5 本と除外 5 件の行が変わっていない | 既存の要素を書き換えると差分に現れる | -| 非機能の条件(性能) | 足した suite の所要の合計が 30 秒以内 | — | -| 全体 | `uv run --frozen --project . --all-extras pytest . -q -n 4` が通る | — | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #1694 | 実装する | — | `.ndf/project.json` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| `claude plugin validate` の通信 | この環境ではネットワークを切る操作(`unshare -n`)が権限で拒否され、通信の有無を確かめられなかった。通信するなら非機能の条件(システム環境)に触れる。実装の検証で、通信できない状態で走らせられる環境があれば確かめる | -| 所要のばらつき | 所要は 1 回の実測で、ばらつきは測っていない。ファイルが増えるとリンクの検査(6.2 秒)と `runtime-plugin-validate`(11.0 秒)が伸びる | -| ジョブが増えたとき | 継続的統合にジョブを足すと、`init` はまた「宣言に無いジョブ」を知らせる。これは突き合わせの処理の目的どおりの振る舞いで、この課題では自動の検査を足さない | -| 宣言の再解析 | `project-decl.py check` は今「古い」(入力の `.github/workflows/pytest.yml` が変わった)を返す。再解析はこの課題の範囲の外で、再解析は指紋の無い `test` を置き換えない(`merge.py` の不変条件) | diff --git a/issues/issue-1694-requirements.md b/issues/issue-1694-requirements.md deleted file mode 100644 index 4e6d8d26d..000000000 --- a/issues/issue-1694-requirements.md +++ /dev/null @@ -1,166 +0,0 @@ -# #1694: このリポジトリの runtime-plugin-validate.yml の 9 ジョブがテストの宣言に無く、cross-refactoring の項目が壊しても手元で拾えない - -正は課題の本文(#1694)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 依頼(原文) - -> **このリポジトリの継続的統合の `runtime-plugin-validate.yml` の 9 ジョブ(生成物の同期・frontmatter・リポジトリの前提・手順書の変数・ -> リンク・文書の行数・指示書・必須チェックの設定・`claude plugin validate`)は、テストの宣言(`.ndf/project.json` の `test.suites`)に無い。** -> cross-refactoring の項目がこれらを壊しても、項目の検証と手元の最終ゲートは拾わない。 -> -> #464 の実装(PR #1697)の後の今の宣言で `refactor_lib.ci_coverage.compare_jobs` を打つと、ジョブ 24 件のうち宣言に無いものは次の 11 件である(2026-11-04、develop 252ddf82b)。 -> -> - `runtime-plugin-validate.yml` の 10 件: `doc-line-limit-check`・`instruction-files-check`・`markdown-link-check`・ -> `required-checks-check`・`runtime-plugin-build-check`・`runtime-plugin-validate`・`service-boundary-check`・`skill-frontmatter-check`・ -> `skill-repo-assumptions-check`・`skill-shell-vars-check` -> - `runtime-plugin-smoke-latest.yml` の `runtime-smoke-latest` -> -> 宣言の `ci_jobs`・`ci_exempt` はワークフローのファイル(`.github/workflows/<名前>.yml`)の単位で書く。 -> -> ## 受け入れ条件(案) -> -> - [ ] 上の 11 件のそれぞれが、宣言の suite の `ci_jobs` か `test.ci_exempt`(理由つき)のどちらかに載る -> - [ ] cross-refactoring の `init` がこのリポジトリで宣言に無いジョブを知らせない - -(書き手: 起票者、日付: 2026-10-03 起票・2026-11-04 更新。#464 の設計の途中で範囲外として起票した記録で、利用者の指示の原文は無い) - -> 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 - -(書き手: AI の起動指示、日付: 2026-11-05) - -## 起票者の推定 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 宣言に無いジョブ 11 件のそれぞれを、suite の `ci_jobs` か `test.ci_exempt`(理由つき)に載せる | 起票時の受け入れ条件(案)1 | 受け入れ条件 1・2 | -| S2 | cross-refactoring の `init` がこのリポジトリで宣言に無いジョブを知らせない | 起票時の受け入れ条件(案)2 | 受け入れ条件 3 | -| S3 | 宣言の `ci_jobs`・`ci_exempt` はワークフローのファイルの単位で書く | 本文「宣言の `ci_jobs`・`ci_exempt` はワークフローのファイルの単位で書く」 | 採らない。突き合わせ(`refactor_lib/ci_coverage.py`)は `<パス>#<ジョブ id>` の形も受ける。`runtime-plugin-validate.yml` の中には手元で走らせるジョブと走らせないジョブが混ざるため、ジョブの単位で書く(前提 2) | -| S4 | 対象は「9 ジョブ」 | 題と本文の 1 段落目 | 採らない。2026-11-05 の再測定で、`runtime-plugin-validate.yml` のジョブは 10 件(`service-boundary-check` を含む)、ほかに `runtime-plugin-smoke-latest.yml` の 1 件がある。対象は 11 件とする | -| S5 | 各ジョブを suite にするか除外するかは、範囲テストの形(`{paths}` を受けられるか)を確かめて決める | 本文「なぜ範囲外か」 | 採らない。静的解析の suite(`kind: lint`)は `scope_command` を持たなくても全体の実行で判定できる(`plugins/ndf/scripts/lib/test_triage.py` の `lint_verdict`)。suite にする条件は前提 3 で決める | - -## 目的 - -cross-refactoring の項目が、このリポジトリの継続的統合で検査している内容(生成物の同期・Skill の frontmatter・リンク・文書の行数・指示書など)を壊したとき、Pull Request を出す前の手元の検証で気づけるようにする。手元で走らせられない検査は、走らせない理由を宣言に残し、`init` が毎回「宣言に無いジョブ」を知らせる状態を無くす。 - -## 前提 - -- 前提 1: 変更の対象は、このリポジトリのプロジェクトの宣言(`.ndf/project.json` の `test`)だけである。突き合わせの処理(`ci_coverage.py`)・宣言のスキーマ・ワークフローは変えない -- 前提 2: ジョブの識別子は、1 つのファイルのジョブがすべて同じ扱いになるときはファイルの単位(`<パス>`)、扱いが分かれるときはジョブの単位(`<パス>#<ジョブ id>`)で書く -- 前提 3: ジョブを suite にする条件は、次の 3 つをすべて満たすことである。満たさないジョブは除外にする - - 秘密・ネットワーク・Pull Request の文脈(宛先のブランチ・本文)を使わずに、手元で判定が決まる - - 継続的統合のジョブと同じスクリプトを同じ引数で走らせる - - 1 回の実行が 60 秒以内に終わる(2026-11-05 の手元の実測で最長は `validate-runtime-plugins.sh` の 12.2 秒) -- 前提 4: 前提 3 の判定の材料は下の「現状の実測」の表である。どのジョブをどちらに載せるか・suite の分け方(ジョブごとか、まとめるか)・`paths` は設計で決める -- 前提 5: `claude plugin validate` を走らせるジョブ(`runtime-plugin-validate`)は、手元に `claude` の CLI があることを要る。cross-refactoring のホストは Claude Code か Codex であり、Codex のホストでは CLI が無いことがある。suite にするか除外するかは設計で決め、決めた理由を宣言か設計に残す - -### 現状の実測 - -2026-11-05、develop d51159151、このリポジトリの worktree で各ジョブの `run` の行を走らせた結果。 - -| ジョブ | 継続的統合で走らせるもの | 手元の終了コード | 手元の所要 | 手元で判定が決まらない要因 | -| --- | --- | --- | --- | --- | -| `runtime-plugin-build-check` | `bash scripts/build-runtime-plugins.sh --check` | 0 | 0.2 秒 | 無し | -| `skill-frontmatter-check` | `python3 scripts/check-skill-frontmatter.py` | 0 | 0.2 秒 | 無し | -| `skill-repo-assumptions-check` | `python3 scripts/check-skill-repo-assumptions.py` | 0 | 0.2 秒 | 無し | -| `skill-shell-vars-check` | `python3 scripts/check-skill-shell-vars.py` | 0 | 0.4 秒 | 無し | -| `service-boundary-check` | `python3 scripts/check-service-boundary.py` | 0 | 1.6 秒 | 無し | -| `runtime-plugin-validate` | `bash scripts/validate-runtime-plugins.sh`(継続的統合では先に `claude` の CLI を入れる) | 0 | 12.2 秒 | `claude` の CLI が要る(前提 5) | -| `markdown-link-check` | `python3 scripts/check-markdown-links.py --root .` | 0 | 7.3 秒 | 無し | -| `doc-line-limit-check` | `python3 scripts/check-doc-line-limit.py --root .` | 0 | 0.1 秒 | 無し | -| `instruction-files-check` | `python3 plugins/ndf/scripts/instructions-check.py --root .` | 0 | 0.2 秒 | 無し | -| `required-checks-check` | `python3 scripts/check-required-checks.py --repo … --branch <宛先>` | 未実行 | — | Pull Request の宛先のブランチと GitHub の API を使う | -| `runtime-smoke-latest` | 最新の CLI の版を解決して入れ、スモークを走らせる(定期と手動の起動だけ) | 未実行 | — | ネットワークから最新の CLI を入れる | - -手元の所要は 1 回の実測で、ばらつきは測っていない。上の 9 本の検査スクリプトは、どれも対象のファイルのパスを引数に受けない(各スクリプトの `argparse` の定義を確かめた)。 - -## 対象範囲 - -含む: - -- 宣言に無いジョブ 11 件を、suite の `ci_jobs` か `test.ci_exempt` に載せる -- suite にするジョブのために、静的解析の suite(`kind: lint`)を宣言へ足す -- 除外にするジョブの理由を書く - -含まない: - -- 検査スクリプトに、対象のファイルのパスを受ける引数(範囲テストの形)を足すこと -- 突き合わせの処理・宣言のスキーマ・ワークフローの変更 -- このリポジトリ以外のプロジェクトの宣言 -- 宣言の再解析(`project-decl.py check` が古いと判定する件。宣言の `analysis.inputs` の更新を含む) - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 宣言に suite と除外を足した | 実装の変更 | 宣言が壊れると、cross-refactoring・cross-review・テストの実行(`test-run.py`)が宣言を読めず止まる(受け入れ条件 5) | — | -| E2 | `init` が継続的統合のジョブと宣言を突き合わせた | cross-refactoring の `init` | 突き合わせられないと「突き合わせられませんでした」の 1 行が出る | E1 | -| E3 | 着手前のテストで、足した静的解析の suite を全体で走らせた | cross-refactoring の着手前のテスト | 着手前から落ちる suite は、最終ゲートで既存失敗として判定から外れる | E1 | -| E4 | 最終ゲートで、足した静的解析の suite を全体で走らせた | cross-refactoring の最終ゲート | 項目が壊していれば変更起因として止まる(目的の振る舞い) | E3 | - -E3・E4 の振る舞いは既存の処理(`lint_verdict`)が持ち、この課題では変えない。 - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| プロジェクトの宣言 | この文書では、このリポジトリの `.ndf/project.json` を指す。本文では「宣言」と略す | -| ジョブの識別子 | 継続的統合のジョブを指す `<ワークフローのファイルのパス>#`。`#` を省くとそのファイルのジョブすべてを指す | -| 除外したジョブ | 手元の検証で走らせないと宣言したジョブ。宣言の `test.ci_exempt` に理由と組で書く | -| 宣言に無いジョブ | 継続的統合のジョブのうち、どの suite の `ci_jobs` にも除外したジョブにも当たらないもの | - -## 受け入れ条件 - -- [ ] 1. 宣言に無いジョブ 11 件(上の「現状の実測」の表の行)のそれぞれが、suite の `ci_jobs` か `test.ci_exempt` の `job` のどちらか一方だけに、ファイルの単位かジョブの単位の識別子で当たる -- [ ] 2. `test.ci_exempt` に足した行は、`reason` が空でなく、手元で判定が決まらない要因(「現状の実測」の表の最後の列に当たるもの)か、走らせない理由を書いている -- [ ] 3. このリポジトリの宣言と作業ディレクトリで `compare_jobs` を打つと、`status` が `compared` で `undeclared` が 0 件になる(`init` が「宣言に無いもの」の行を出さない) -- [ ] 4. 足した suite のそれぞれは、`command` を作業ディレクトリの根で走らせると終了コード 0 で終わり、そのコマンドは `ci_jobs` に書いたジョブの `run` の行と同じスクリプトを同じ引数で呼ぶ(継続的統合だけで要る準備の手順は除く) -- [ ] 5. `project-decl.py check --root .` が 3(壊れている)を返さない -- [ ] 6. 既存の suite(`pytest`・`factory`・`script-structure`・`ndf-contracts`・`lint`)と既存の除外 5 件は、名前・コマンド・`ci_jobs`・理由が変わらない - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 足した suite の `command` の所要の合計は、手元で 30 秒以内(2026-11-05 の実測では、`claude` の CLI を使うジョブを含めて 22.4 秒、含めないで 10.2 秒)。cross-refactoring の着手前のテストと最終ゲートは、この合計ずつ長くなる | -| システム環境 | 足した suite は、秘密とネットワークを使わずに走る | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わらない(配布物を変えない) | -| データ | このリポジトリの宣言(`.ndf/project.json`)の `test` に suite と除外が増える。移行は無い | -| 既存の振る舞い | このリポジトリで cross-refactoring を走らせると、着手前のテストと最終ゲートで足した suite が走る。宣言の suite を読む cross-review とテストの実行(`test-run.py`)でも、足した suite が走る対象に入る | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| 突き合わせ(受け入れ条件 1・3) | `cd plugins/ndf/skills/cross-refactoring/scripts && python3 -c "import json,pathlib; from refactor_lib.ci_coverage import compare_jobs; r=compare_jobs(json.load(open('<根>/.ndf/project.json')), pathlib.Path('<根>')); print(r.as_state())"` | -| suite のコマンド(受け入れ条件 4) | 足した suite の `command` を根で 1 本ずつ走らせ、終了コードを見る | -| 宣言の形(受け入れ条件 5) | `python3 plugins/ndf/scripts/project-decl.py check --root .` の終了コード | -| 既存の宣言の不変(受け入れ条件 2・6) | `git diff` で `.ndf/project.json` の既存の要素が変わっていないことと、足した除外の理由を見る | -| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 変更は `.ndf/project.json` だけに置く。プラグイン(`plugins/`)とワークフロー(`.github/workflows/`)は変えない | -| コーディング規約 | 宣言の形は `plugins/ndf/skills/development-workflow/schemas/project.schema.json` に従う | -| テスト戦略 | 振る舞いの変更が無いためテストを足さない。受け入れ条件は検証手段のコマンドの実行結果で確かめる | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 足した suite のコマンドを手元で走らせる。突き合わせの結果を確かめる | -| 確認してから行う | 宣言(`.ndf/project.json`)の書き換え。共通原則の C7 に当たり、承認ゲート 2 で人が見る | -| 行わない | 検査スクリプト・ワークフロー・突き合わせの処理の変更。宣言の再解析 | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 11 件のそれぞれを suite と除外のどちらに載せるか(前提 3・前提 5) | 設計 | 承認ゲート 1 | -| suite の分け方(ジョブごとに 1 つか、まとめて 1 つか)と各 suite の `paths` | 設計 | 承認ゲート 1 | diff --git a/issues/issue-2276-design.md b/issues/issue-2276-design.md new file mode 100644 index 000000000..3ed14eabd --- /dev/null +++ b/issues/issue-2276-design.md @@ -0,0 +1,311 @@ +# factory/api: 秘密のキャッシュを 1 つの共通の仕組みにまとめる(#2276) + +## 目的 + +- **今どうなっているか**: Secrets Manager の秘密を「1 回だけ読み、読めなければ次の要求で読み直す」処理が、3 か所に別々に書かれている。実行の制御の依存・進捗の受け口・GitHub の仲立ちである。読み直しの規則を変えるときは 3 か所を直す必要がある。実行の制御の依存のコピーには、古い失敗の後始末が新しい読み込みの保持を消しうる順序の穴がある(「決定 3」) +- **誰に効くか**: Factory の API(`factory/api`)を直す開発者と、秘密を読む Lambda を足す子課題(#2173 の通知・#2174 の決済) +- **変えると何が成り立つか**: 保持と読み直しの規則が 1 か所にだけあり、3 か所と今後の Lambda はそれを呼ぶだけで同じ規則が効く + +## 適用範囲 + +- **働く範囲**: このリポジトリの `factory/api/src` だけ。NDF の配布物(`plugins/`)は参照しない +- **プロジェクトごとに違うもの**: 無し(Factory のサービスの内部の部品で、利用者のプロジェクトへ配らない) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| LIB2(p-memoize の README) | 外部の一次情報 | 非同期の結果を保持する部品は「拒否された Promise を保持しない」「同時の呼び出しは 1 回の実行を共有する」の 2 つを規則として持つ。この設計の不変条件 I2・I3 と同じ形である | +| `factory/api/src/runs/deps.ts:26`・`src/handlers/ingest.ts:14`・`src/handlers/github-oidc.ts:20` | 実測(コードの読み取り、2026-11-05 の `design/issue-2276`) | 3 か所が同じ規則を別々の書き方で持つ。うち 2 か所は環境変数が無いときに拒否でなく同期の例外を投げる | + +要求と受け入れ条件は #2276 の本文にある(コピーは [issues/issue-2276-requirements.md](issue-2276-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| Neural Development Factory(`ndf-factory`) | 秘密のキャッシュ・GitHub の仲立ち・進捗の受け口・実行の制御 | + +1 つのコンテキストの中の変更で、コンテキストマップの関係は宣言しない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 秘密のキャッシュ | 共通の仕組み(秘密のキャッシュのモジュール)だけ。呼ぶ側は値を求めるだけで、保持を書き換えない | 保持している読み込み(読み込み中か、成功した読み込みの 1 つ。無いこともある) | — | 秘密の文字列・署名の鍵の組(どちらも読んだ後は変えない) | + +1 つの秘密(または鍵の組)ごとに 1 つのキャッシュを作る。キャッシュどうしは互いを参照しない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 秘密のキャッシュ | 読み込みが成功した後は、何回求めても読み込みの関数は 1 回だけ呼ばれ、同じ値が返る | テストで落とす(受け入れ条件 6) | +| I2 | 秘密のキャッシュ | 読み込み中に重ねて求めたとき、新しい読み込みを始めず、同じ結果を待つ | テストで落とす(受け入れ条件 7) | +| I3 | 秘密のキャッシュ | 失敗した読み込みは保持に残らず、次の要求で読み直す。環境変数が無い・値が空・読み込みの関数が同期で例外を投げる場合も同じ | テストで落とす(受け入れ条件 8) | +| I4 | 秘密のキャッシュ | 失敗した読み込みの後始末は、その読み込み自身が保持されているときだけ保持を捨てる。後から始まった読み込みの保持を消さない | テストで落とす(「決定 3」) | +| I5 | 秘密のキャッシュ | 失敗の例外の文言は、環境変数の名前か読む対象の名前だけを含み、秘密の値を含まない | テストで落とす(受け入れ条件 9) | +| I6 | 秘密のキャッシュ | 呼ぶ側が拒否を受け取って処理した後、共通の仕組みの内部に処理されない拒否を残さない | テストで落とす(受け入れ条件 10) | +| I7 | 秘密のキャッシュ | ARN の環境変数は、最初に秘密を求めた時点で読む。キャッシュを作る時点では読まない | テストで落とす(受け入れ条件 11) | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 秘密の読み込みを求めた | 呼ぶ側(実行の制御・進捗の受け口・GitHub の仲立ち) | 秘密のキャッシュ | +| E2 | Secrets Manager から秘密を読んだ | 秘密のキャッシュ(渡された読み込みの関数) | 秘密のキャッシュ | +| E3 | 読んだ値を保持し、以後の要求へ同じ値を返した | 秘密のキャッシュ | 呼ぶ側 | +| E4 | 失敗した読み込みの保持を捨てた | 秘密のキャッシュ | 秘密のキャッシュ(次の E1 で E5 へ進む)と、例外を受け取る呼ぶ側 | +| E5 | 次の要求で読み直した | 秘密のキャッシュ | Secrets Manager(渡された読み込みの関数) | + +いずれも 1 つの Lambda の実行環境の中の関数呼び出しで、永続化も配信もしない。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 秘密のキャッシュ | Secrets Manager から読んだ値を Lambda の実行環境の中で保持する `factory/api/` の仕組み。成功した値は以後も同じ値を返し、失敗した読み込みは捨てて次の要求で読み直す | 追加(要求の変更で反映済み。この設計で意味を変えない) | + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べない | Lambda の実行環境の中の値の保持に、公開の仕様・規格は無い。Secrets Manager の呼び出しの形は既存のコードが使う AWS SDK の API のまま変えない | +| 主要な製品 | 調べる | AWS が Lambda で秘密を保持する手段として何を出しているかを比べる | +| ライブラリとサービス | 調べる | 自前で書く前に、保持と読み直しを持つ既存のライブラリと AWS の提供物を比べる | + +### 主要な製品 + +| # | 製品 | どう解いているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| PRD1 | AWS Parameters and Secrets Lambda Extension | Lambda のレイヤーとして足す拡張が秘密を保持し、関数は SDK を使わずに実行環境の中のローカルの HTTP の経路から読む。保持の期間と時間切れは拡張の環境変数で設定する | 採らない。レイヤーの追加と環境変数の追加がインフラの定義の変更に当たり、要求の「含まない」(インフラの定義の変更)に入る(「決定 1」) | https://docs.aws.amazon.com/secretsmanager/latest/userguide/retrieving-secrets_lambda.html (2026-11-05、HTTP 200) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | `@aws-lambda-powertools/parameters`(Secrets Manager の取得を含む) | 2.36.0 | MIT-0 | 2026-10-06 | 無し | 一部。保持は時間で切れる(既定 5 秒、`maxAge` で変える)。成功の後は 1 回だけ読む規則(受け入れ条件 6)にするには時間切れを止める指定が要り、失敗時に保持を捨てるか・同時の要求を 1 回にまとめるかは確かめられなかった | https://registry.npmjs.org/@aws-lambda-powertools/parameters ・ https://docs.aws.amazon.com/powertools/typescript/latest/features/parameters/ (2026-11-05) | +| LIB2 | `p-memoize` | 9.0.0 | MIT | 2026-10-15 | 無し | 満たす。拒否された Promise を保持せず、同時の呼び出しは 1 回の実行を共有する。ただし引数ごとに保持を分ける部品で、引数の無い 1 つの値には仕組みが余る。Node.js 22 以上を求める | https://registry.npmjs.org/p-memoize (2026-11-05) | +| LIB3 | `@aws-sdk/client-secrets-manager`(既存の依存) | `^3.1160.0`(`factory/api/package.json`) | Apache-2.0 | 確かめていない | 確かめていない | 秘密を読む呼び出しだけを持ち、保持はしない。読み込みの関数の中でそのまま使う | `factory/api/package.json` (2026-11-05) | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| LIB1 が失敗した取得の保持を捨てるか・同時の取得を 1 回にまとめるか | https://docs.aws.amazon.com/powertools/typescript/latest/features/parameters/ の本文の検索(`maxAge`・`cache`) | 本文に該当の記述を見つけられなかった。ソースは読んでいない | +| LIB3 の最後のリリースの日付・ライセンス | 試していない | 既存の依存で、使い方を変えないため取得しなかった | + +調べた量: 候補 4 件・取得の試み 7 回。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 任意の読み込みの関数の結果を、成功したら保持し、失敗したら捨てて読み直す | 秘密や鍵を読む `factory/api` のコード(GitHub の仲立ちの署名の鍵の組・今後の Lambda) | +| F2 | ARN の環境変数の名前を受け、Secrets Manager の秘密の文字列を F1 の規則で読む | 実行の制御の依存・進捗の受け口・今後の Lambda | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 秘密のキャッシュの汎用の層(新設) | 読み込みの関数を受け、保持・同時の要求の共有・失敗時の破棄(I1〜I4・I6)を引き受ける。値の型を問わない | +| 秘密の文字列の層(新設) | ARN の環境変数の名前と Secrets Manager のクライアントを受け、最初の要求の時点で環境変数を読み、秘密の文字列を返す読み込みの関数を汎用の層へ渡す。環境変数が無い・値が空のときの例外の文言(I5・I7)を持つ | +| 実行の制御の依存(変える) | GitHub App のクライアントの秘密・秘密鍵・実行ジョブの token の発行の鍵の 3 件を、秘密の文字列の層から得る。自前の保持の関数を消す | +| 進捗の受け口の handler(変える) | GitHub App の Webhook の秘密を、依存を作る時点で秘密の文字列の層から得る。モジュールの変数と読み込みの関数を消す | +| GitHub の仲立ちの handler(変える) | 署名の鍵の組を、既存の鍵の読み込み(変えない)を汎用の層で包んで得る。モジュールの変数を消す | + +```mermaid +graph TD + subgraph 呼ぶ側 + A[実行の制御の依存] + B[進捗の受け口の handler] + C[GitHub の仲立ちの handler] + end + subgraph 共通の仕組み + S[秘密の文字列の層] + G[汎用の層] + end + K[署名の鍵の読み込み(変えない)] + SM[(Secrets Manager)] + A --> S + B --> S + C --> G + C -.読み込みの関数として渡す.-> K + S --> G + G -.読み込みの関数を呼ぶ.-> SM + K --> SM +``` + +### ファイルの置き場 + +配置は変わらない。3 つの呼ぶ側は今と同じ Lambda で動き、共通の仕組みは各 Lambda のバンドルへ一緒に入る。 + +```text +factory/api/src/ +├── secret-cache.ts # 新設: 汎用の層と秘密の文字列の層 +├── secret-cache.test.ts # 新設: 不変条件 I1〜I7 の単体テスト +├── runs/deps.ts # 変える: 自前の保持の関数を消し、秘密の文字列の層を呼ぶ +└── handlers/ + ├── ingest.ts # 変える: モジュールの変数と読み込みの関数を消す + └── github-oidc.ts # 変える: 署名の鍵の組の保持の変数を消す +``` + +## 構造 + +```mermaid +classDiagram + class 汎用の層 { + +cacheUntilFailure~T~(load) 値を求める関数 + } + class 秘密の文字列の層 { + +secretStringFromEnv(arnEnv, client) 値を求める関数 + } + class SecretsManagerClient + 秘密の文字列の層 ..> 汎用の層: 読み込みの関数を渡す + 秘密の文字列の層 ..> SecretsManagerClient: 1 回の読み込みで 1 回呼ぶ +``` + +| 型・関数 | 形 | 責務 | +| --- | --- | --- | +| `cacheUntilFailure(load: () => Promise): () => Promise` | 新設 | 返す関数を呼ぶたびに、保持している読み込みがあればそれを返し、無ければ `load` を呼んで保持する。`load` が同期で投げた例外も、返す Promise の拒否にする | +| `secretStringFromEnv(arnEnv: string, client?: SecretsManagerClient): () => Promise` | 新設 | `cacheUntilFailure` に「`requireEnv(arnEnv)` で ARN を読み、`GetSecretValueCommand` を送り、`SecretString` が空なら `${arnEnv} の秘密が読めません` で投げる」読み込みの関数を渡す。`client` を省くと 1 つ作る | +| `secretOnce`(`runs/deps.ts`) | 削除 | `secretStringFromEnv` に置き換わる | +| `webhookSecretFromEnv` とモジュールの変数(`handlers/ingest.ts`) | 削除 | 依存の `webhookSecret` に `secretStringFromEnv("GITHUB_APP_WEBHOOK_SECRET_ARN")` を渡す | +| 署名の鍵の組のモジュールの変数(`handlers/github-oidc.ts`) | 削除 | 依存の `keys` に `cacheUntilFailure(() => loadSigningKeys(requireEnv("GITHUB_OIDC_SIGNING_KEY_SECRET_ARN")))` を渡す | + +呼ぶ側の次の型は、どれも `() => Promise<…>` のままで変わらない。 + +- GitHub App の設定の `clientSecret`・`privateKeyPem` +- 進捗の受け口の依存の `webhookSecret` +- GitHub の仲立ちの依存の `keys` + +## 処理の流れ + +```mermaid +sequenceDiagram + participant 呼ぶ側 + participant キャッシュ as 秘密のキャッシュ + participant 読込 as 読み込みの関数 + 呼ぶ側->>キャッシュ: 値を求める(E1) + alt 保持している読み込みがある + キャッシュ-->>呼ぶ側: 保持している Promise(I1・I2) + else 保持が無い + キャッシュ->>読込: 呼ぶ(E2。環境変数はここで読む。I7) + キャッシュ->>キャッシュ: この読み込みを保持し、失敗時の後始末を 1 つだけ付ける + キャッシュ-->>呼ぶ側: 保持した Promise + alt 成功 + 読込-->>キャッシュ: 値(E3。保持を残す) + else 失敗(環境変数が無い・値が空・読めない) + 読込-->>キャッシュ: 拒否 + キャッシュ->>キャッシュ: 保持がこの読み込みのときだけ捨てる(E4。I3・I4) + キャッシュ-->>呼ぶ側: 拒否(文言は環境変数か対象の名前だけ。I5) + end + end +``` + +保持の状態は次の 3 つで、描いていない遷移は起こらない。 + +```mermaid +stateDiagram-v2 + [*] --> 空 + 空 --> 読み込み中: 値を求めた + 読み込み中 --> 読み込み中: 重ねて求めた(同じ結果を待つ) + 読み込み中 --> 保持: 読み込みが成功した + 読み込み中 --> 空: 読み込みが失敗した + 保持 --> 保持: 値を求めた(読み込みの関数を呼ばない) +``` + +「保持」から「空」への遷移は無い。成功した値は Lambda の実行環境が終わるまで保持する(今の振る舞いのまま。秘密の更新への追随は要求の範囲外)。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | Lambda の 1 つの実行環境あたりの Secrets Manager の呼び出しの回数は、成功した後は秘密ごとに 1 回のまま変わらない(受け入れ条件 6・7) | 秘密ごとに 1 つのキャッシュを作り、成功した読み込みと読み込み中の Promise を保持して返す。時間で切れる保持にしない | 受け入れ条件 6・7 の単体テスト | +| 運用・保守性 | 秘密を読む Lambda を足すとき、共通の仕組みを呼ぶだけで同じ規則が効く(受け入れ条件 1・4) | 文字列の秘密は `secretStringFromEnv` の 1 行、文字列以外は `cacheUntilFailure` に読み込みの関数を渡す 1 行で済む形にする | 受け入れ条件 1〜3 の検索と差分の確認 | +| セキュリティ | 秘密の値をログ・例外の文言・応答に出さない(受け入れ条件 9)。認証と認可の振る舞いを変えない(前提 4) | 共通の仕組みはログを書かない。自分で作る例外の文言は環境変数の名前だけを入れる。署名の鍵の読み込みと Webhook の署名の検証のコードは変えない | 受け入れ条件 9 の単体テストと、既存のテスト(受け入れ条件 12) | + +## 決定の記録 + +### 決定 1: 依存とインフラを増やさないため、秘密のキャッシュを `factory/api` の中の小さな関数として自前で作る + +求める規則は「成功を保持する」「同時の要求を 1 回にまとめる」「失敗を捨てる」の 3 つで、関数 1 つの十数行で書ける。LIB2 は規則を満たすが、引数ごとに保持を分ける仕組みと依存の追加が、引数の無い 1 つの値には余る。 + +LIB1 は時間で切れる保持が既定で、成功の後 1 回だけ読む今の規則に合わせる指定が要り、失敗時の振る舞いを確かめられなかった。PRD1 はレイヤーの追加がインフラの定義の変更に当たり、要求の範囲外である。いずれも採らない。 + +- 作り方: 自前で作る +- 比べた候補: PRD1 / LIB1 / LIB2 / LIB3 + +根拠: Value 7 / スプリント Value 6(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 2: 呼ぶ側が値の型に応じて選べるよう、汎用の層と秘密の文字列の層の 2 つを 1 つのモジュールに置く + +GitHub の仲立ちは文字列でなく鍵の組を保持するため、型を問わない汎用の層が要る。実行の制御の依存・進捗の受け口・今後の Lambda は ARN の環境変数から文字列を読むだけで、その形を毎回組まないよう秘密の文字列の層を足す。置き場は `factory/api/src/secret-cache.ts` とし、環境変数を読む `src/env.ts` と並べる。handler の配線だけを持つ `handlers/` の外に置く(#2199 の R02 の決定)。 + +2 つの層を別のファイルに分けることは採らない。秘密の文字列の層は汎用の層を呼ぶ数行で、分けると読み手が規則を追うファイルが増える。 + +根拠: Value 7(MVV 版 5) + +### 決定 3: 後から始まった読み込みの保持を消さないため、失敗の後始末は自分の読み込みが保持されているときだけ保持を捨てる + +実行の制御の依存の今のコピーは、呼ぶたびに失敗時の後始末を足し、後始末は無条件に保持を捨てる。読み込み中に 2 回求めた後でその読み込みが失敗すると、1 つ目の後始末の後に呼ぶ側が読み直しを始め、続いて 2 つ目の後始末がその新しい読み込みの保持を捨てる。読み直しの読み込みが 2 回始まり、同時の要求を 1 回にまとめる規則(I2)が読み直しの時点で崩れる。共通の仕組みでは、後始末を読み込みを始めたときに 1 つだけ付け、保持が同じ読み込みのときだけ捨てる(I4)。 + +この順序の穴はコードの読み取りから推したもので、実際に起きたかは確かめていない(「未確認のまま残ること」)。 + +根拠: Value 7(MVV 版 5) + +### 決定 4: 環境変数が無いときも同じ規則で読み直すため、読み込みの関数が同期で投げた例外を拒否として返す + +今の 3 か所は、ARN の環境変数が無いと `requireEnv` が同期で例外を投げ、Promise を返さない。呼ぶ側はどれも `await` の中で呼ぶため応答は変わらないが、規則の上では「失敗は拒否として返り、次の要求で読み直す」に 1 つの例外ができる。共通の仕組みは同期の例外も拒否にし、受け入れ条件 8 を 1 つの経路で満たす。 + +根拠: Value 7(MVV 版 5) + +### 決定 5: コピーごとに違う文言をやめるため、秘密の文字列が空のときの例外の文言を `<環境変数の名前> の秘密が読めません` にそろえる + +進捗の受け口の今の文言「Webhook の秘密が読めません」は `GITHUB_APP_WEBHOOK_SECRET_ARN の秘密が読めません` に変わる。受け口はこの例外を 503 のエラーに置き換え、文言を応答に出さない(`src/progress/app.ts:136`、要求の前提 3)。 + +文言を検査する既存のテストは無い。`grep -rn "秘密が読めません" factory/api/src` の結果は 2 件(`runs/deps.ts:30`・`handlers/ingest.ts:18`)である。どちらもこの変更で書き換える箇所で、テストのファイルには現れない。 + +根拠: Value 7(MVV 版 5) + +### 決定 6: 認証の経路の変更を保持の置き場だけに留めるため、署名の鍵の読み込みは変えずに汎用の層で包む + +署名の鍵の読み込み(`src/github-oidc/keys.ts` の `loadSigningKeys`)は、秘密の文字列を読んで鍵の組へ変える処理と、その文言「署名の鍵が読めません」を持つ。これを秘密の文字列の層へ書き換えると、要求の前提 4(鍵の中身・検証の規則を変えない)の外に出る範囲が増える。保持だけを汎用の層へ移す。 + +秘密の文字列が空かを確かめる 1 行は、署名の鍵の読み込みと実行ジョブの token の公開鍵の読み込み(要求の「含まない」)にも残る。どちらも保持を持たない 1 回の読み込みで、この課題の対象の「保持して読み直す処理」ではないため寄せない。 + +根拠: Value 7 / Value 3(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1 | 保持と読み直しの定義が `src/secret-cache.ts` にだけあり、3 か所がそれを呼ぶ | 3 か所のどれかに自前の保持を残すと、差分の確認と受け入れ条件 3 の検索で見つかる | +| 受け入れ条件 2 | `grep -n GetSecretValueCommand factory/api/src/handlers/*.ts` が 0 件 | 進捗の受け口に Secrets Manager の呼び出しを残すと 1 件以上になる | +| 受け入れ条件 3 | 2 つの handler に、秘密か鍵の読み込みの結果を保持するモジュールの変数が無い(差分を読む) | 保持の変数を残すと差分の確認で見つかる | +| 受け入れ条件 4・5 | 汎用の層は文字列でない値(オブジェクト)も同じ規則で保持する。GitHub の仲立ちの依存の `keys` は汎用の層を通る | 汎用の層を文字列だけの型にすると型検査で落ちる | +| 受け入れ条件 6・I1 | 成功の後に何回求めても、読み込みの関数の呼び出しは 1 回で、同じ値が返る | 求めるたびに読み込みの関数を呼ぶように壊すと落ちる | +| 受け入れ条件 7・I2 | 1 回目の読み込みが終わる前に 2 回目を求めても、読み込みの関数は 1 回で、両方が同じ結果を受け取る | 読み込み中の Promise を保持せず、成功した値だけを保持するように壊すと落ちる | +| 受け入れ条件 8・I3 | 失敗の要求には拒否が返り、次の要求で読み込みの関数がもう一度呼ばれ、成功すればその値が返る。環境変数が無いとき・`SecretString` が空のとき・読み込みの関数が同期で投げたときも同じ | 拒否した Promise を保持に残すと落ちる。同期の例外を拒否にしないと、呼び出しが Promise を返さずに落ちる | +| I4 | 読み込み中に 2 回求め、その読み込みが失敗した直後に読み直したとき、読み直しの読み込みは 1 回だけ呼ばれる | 後始末を呼ぶたびに足し、無条件に保持を捨てるように壊すと、読み込みの関数が余分に呼ばれて落ちる | +| 受け入れ条件 9・I5 | 環境変数が無いとき・値が空のときの例外の文言は環境変数の名前を含み、秘密の値を含まない | 文言に読んだ値を入れるように壊すと落ちる | +| 受け入れ条件 10・I6 | 呼ぶ側が拒否を受け取った後、`unhandledRejection` が起きない | 後始末を付けずに保持だけ捨てる形、または後始末の Promise に拒否を残す形に壊すと落ちる | +| 受け入れ条件 11・I7 | 秘密の文字列の層を作った後に環境変数を設定しても、最初の要求でその値を読む | 作る時点で環境変数を読むように壊すと落ちる | +| 受け入れ条件 12 | `cd factory && npm ci && npm run check -w api` が終了コード 0 | 呼ぶ側の型を変えると型検査で、既存の振る舞いを変えると既存のテストで落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2276 | 実装する | — | `factory/api/src/secret-cache.ts`、`factory/api/src/secret-cache.test.ts`、`factory/api/src/runs/deps.ts`、`factory/api/src/handlers/ingest.ts`、`factory/api/src/handlers/github-oidc.ts` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 「決定 3」の順序の穴が実際に起きたか | コードの読み取りから推した。実装の I4 のテストで、今のコピーの形では読み込みの関数が余分に呼ばれることを先に確かめる(`tdd-cycle`) | +| AWS 上の Lambda で秘密を読めるか | 単体テストは読み込みの関数を差し替える。実際の Secrets Manager からの読み込みは、Factory の配布の後の確認で見る(要求の「検証手段」) | +| LIB1 の失敗時の振る舞い | 確かめていない。採らない理由は時間切れの既定とインフラ以外の点(依存の追加)でも成り立つ | diff --git a/issues/issue-2276-requirements.md b/issues/issue-2276-requirements.md new file mode 100644 index 000000000..f22b51fc9 --- /dev/null +++ b/issues/issue-2276-requirements.md @@ -0,0 +1,150 @@ +# #2276: factory/api: 秘密を 1 回だけ読むキャッシュが 3 か所に別々にある + +正は課題の本文(#2276)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> ## 何を見つけたか +> +> Secrets Manager の秘密を「1 回だけ読み、読めなければ次の呼び出しで読み直す」キャッシュが、3 か所に別々に書かれている(2026-10-31 の `develop`、ee32e92f)。 +> +> | 場所 | 形 | +> | --- | --- | +> | `factory/api/src/runs/deps.ts` の `secretOnce` | 汎用の関数(ARN の環境変数の名前を受ける) | +> | `factory/api/src/handlers/ingest.ts` の `webhookSecretFromEnv` | 同じ処理を Webhook の秘密だけのために書いている | +> | `factory/api/src/handlers/github-oidc.ts` の `keys` | 署名の鍵の読み込みに、失敗したら捨てて読み直す同じ形のキャッシュ | +> +> ## どこで見つけたか +> +> #2199 の設計で、R20(`requireEnv` の重複)と同じ役割の写しを探したとき。`requireEnv` を使う 4 つのファイルのうち 3 つが、このキャッシュも持っていた。 +> +> ## なぜこの変更の範囲外なのか +> +> #2199 の対象範囲は見送った提案 R01〜R20 と、その写しに限る。R20 は環境変数を読む関数の重複で、秘密のキャッシュとは役割が違う。新しく見つかった提案は別の課題へ起こすと #2199 の「含まない」が定める。根拠: Value 6・Value 10(MVV 版 3) +> +> ## 直さないと何が起きるか +> +> 秘密を読む Lambda を足す子課題(#2173 の通知・#2174 の決済)が 4 つ目のキャッシュを書く。読み直しの規則(失敗したら捨てる・値をログに出さない)を変えるとき、写しごとに直す必要がある。 +> +> ## 起票者の推定 +> +> - `github-oidc.ts` の鍵の読み込みも同じ役割に数える(推した出所: 起票者の推定。読む値が文字列でなく鍵の組である点が違う) +> +> ## 由来 +> +> issue #2199 + +(書き手: 起票者(#2199 の設計の中で範囲外の指摘を起こした AI)、日付: 2026-10-31。利用者の指示の原文は無い。題・本文のどの条件も、承認ゲート 1 で利用者が確かめるまで利用者の決定として扱わない) + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | GitHub の仲立ちの署名の鍵の読み込み(`handlers/github-oidc.ts`)も同じ役割に数え、共通の仕組みへ寄せる | 本文の「起票者の推定」の節。読む値が文字列でなく鍵の組である点が違う | 受け入れ条件 1・5 | +| S2 | 3 か所を 1 つの共通の仕組みにまとめることが直し方である(本文は重複を指摘しているだけで、まとめ方は書いていない) | 題と「直さないと何が起きるか」の節から推した | 受け入れ条件 1〜3 | +| S3 | 共有する規則は「失敗したら捨てる」「値をログに出さない」の 2 つである | 「直さないと何が起きるか」の節。既存のコードの注釈(`runs/deps.ts`・`handlers/ingest.ts`)と一致する | 受け入れ条件 6〜9 | +| S4 | 秘密を読む Lambda を足す #2173(通知)・#2174(決済)が、同じキャッシュを新たに書く見込みがある | 「直さないと何が起きるか」の節 | 含まない(この課題では共通の仕組みを用意するだけで、#2173・#2174 の実装は扱わない) | + +## 目的 + +- Factory の API(`factory/api`)で、Secrets Manager から秘密を読んで保持する規則を 1 か所で持つ。規則を変えるときに直す場所を 1 つにし、秘密を読む Lambda を足す変更が同じ処理をもう一度書かずに済む状態にする + +## 前提 + +- 前提 1: 共通の仕組みは、読み込みの関数を受けてその結果を保持する汎用の形(値の型を問わない)と、ARN の環境変数の名前から秘密の文字列を読む形の 2 層で足りる。署名の鍵の組は前者で扱う +- 前提 2: 共通の仕組みは `factory/api/src` の中に置く。`factory/runner` は別のパッケージで、起動時に 1 回読むだけで保持の規則を持たないため、共有の対象にしない +- 前提 3: 読めなかったときの例外の文言は、呼ぶ側の応答へ出ない(受け口は 503 の誤りに置き換え、仲立ちは鍵を読めないときに例外を返す)。文言を共通の形にそろえても、HTTP の応答の状態コードと本文は変わらない +- 前提 4: 秘密の値・鍵の中身・検証の規則は変えない。変えるのは、読んだ値を保持して読み直すコードの置き場だけである + +## 対象範囲 + +含む: +- 実行の制御の依存(`runs/deps.ts`)の秘密の読み込み 3 件(GitHub App のクライアントの秘密・秘密鍵・実行ジョブの token の発行の鍵) +- 進捗の受け口(`handlers/ingest.ts`)の GitHub App の Webhook の秘密の読み込み +- GitHub の仲立ち(`handlers/github-oidc.ts`)の署名の鍵の組の読み込み +- 共通の仕組みの単体テスト + +含まない: +- 実行ジョブの token の公開鍵の集合を時間ごとに読み直す検証(`progress/run-job-token.ts` の `createCachedRunJobTokenVerifier`)。失敗で捨てるのでなく一定時間ごとに読み直す規則で、役割が違う +- `factory/runner` の秘密の読み込み +- #2173(通知)・#2174(決済)の Lambda の実装 +- 秘密の値・ARN・環境変数の名前・インフラの定義の変更 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 秘密の読み込みを求めた | Lambda の処理が秘密か鍵を初めて要る時点(起動時ではない) | — | — | +| E2 | Secrets Manager から秘密を読んだ | E1 で保持している値が無い | 環境変数が無い・読めない・値が空のとき、例外を呼ぶ側へ返し、保持を捨てる(E4) | E1 | +| E3 | 読んだ値を保持し、以後の要求へ同じ値を返した | E2 の成功 | — | E2 | +| E4 | 失敗した読み込みの保持を捨てた | E2 の失敗 | — | E2 | +| E5 | 次の要求で読み直した | E4 の後の E1 | E2 と同じ | E4 | + +E2 が終わる前に E1 が重なったときは、同じ読み込みの結果を待つ(Secrets Manager を 2 回呼ばない)。受け入れ条件 7 に移した。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 秘密のキャッシュ | Secrets Manager から読んだ値を Lambda の実行環境の中で保持し、成功した値は以後も同じ値を返し、失敗した読み込みは捨てて次の要求で読み直す仕組み | +| GitHub の仲立ち | GitHub の OAuth を Cognito が受け取れる OIDC の形へ変える `factory/api/` のプログラム(`/oidc/github`)。署名の鍵の組を Secrets Manager から読む | + +## 受け入れ条件 + +- [ ] 1. 秘密のキャッシュ(成功した値を保持し、失敗したら捨てて読み直す処理)の定義が `factory/api/src` のテスト以外のコードに 1 か所だけある。実行の制御の依存・進捗の受け口・GitHub の仲立ちの 3 か所はそれを呼ぶ +- [ ] 2. `factory/api/src/handlers/` の `.ts` に `GetSecretValueCommand` が現れない(`grep -n GetSecretValueCommand factory/api/src/handlers/*.ts` が 0 件) +- [ ] 3. `factory/api/src/handlers/ingest.ts` と `factory/api/src/handlers/github-oidc.ts` に、秘密か鍵の読み込みの結果を保持するモジュールの変数が無い +- [ ] 4. 共通の仕組みは、文字列の秘密と、文字列以外の値(署名の鍵の組など)の両方を同じ規則で保持できる +- [ ] 5. GitHub の仲立ちの署名の鍵の組は、共通の仕組みを通して読まれる +- [ ] 6. 読み込みが成功した後に何回求めても、Secrets Manager(または渡した読み込みの関数)は 1 回だけ呼ばれ、同じ値が返る(単体テスト) +- [ ] 7. 1 回目の読み込みが終わる前に 2 回目を求めたとき、読み込みの関数は 1 回だけ呼ばれ、両方が同じ結果を受け取る(単体テスト) +- [ ] 8. 読み込みが失敗したとき、その要求には例外が返り、次の要求で読み込みの関数がもう一度呼ばれる。2 回目が成功すればその値が返る(単体テスト)。環境変数が無いとき・`SecretString` が空のときも同じく読み直す +- [ ] 9. 失敗したときの例外の文言に、秘密の値が含まれない。文言には環境変数の名前か読む対象の名前だけが入る(単体テスト) +- [ ] 10. 失敗した読み込みを呼ぶ側が受け取った後、処理されない Promise の拒否が残らない(単体テストで `unhandledRejection` が起きない) +- [ ] 11. 環境変数の値(ARN)は、最初に秘密を求めた時点で読む。依存を組み立てる時点では読まない(今の振る舞いのまま) +- [ ] 12. 退行しない: `cd factory && npm ci && npm run check -w api` が終了コード 0 で終わり、既存のテストがすべて通る + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | Lambda の 1 つの実行環境あたりの Secrets Manager の呼び出しの回数は、成功した後は秘密ごとに 1 回のまま変わらない(受け入れ条件 6・7) | +| 運用・保守性 | 秘密を読む Lambda を足すとき、共通の仕組みを呼ぶだけで同じ規則が効く(受け入れ条件 1・4) | +| セキュリティ | 秘密の値をログ・例外の文言・応答に出さない(受け入れ条件 9)。認証と認可の振る舞いを変えない(前提 4) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。HTTP の API・受け口・仲立ちの経路と応答は同じ | +| データ | 無し | +| 既存の振る舞い | 読めなかったときの内部の例外の文言がそろう場合がある(前提 3。応答には出ない)。それ以外は変わらない | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `cd factory && npm ci && npm run check -w api`(型検査と vitest。2026-11-05 の `develop` 相当で 27 ファイル・480 件が通ることを確かめた) | +| 静的解析・型検査 | 同上(`npm run typecheck` を含む) | +| 受け入れ条件 2・3 | `grep -n GetSecretValueCommand factory/api/src/handlers/*.ts` と、2 つの handler の差分を読んで確かめる | +| 手動確認 | 無し(AWS 上の Lambda で秘密を読む確認は、Factory の配布の後の確認に含まれる) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `factory/` は `plugins/` から参照されない(`AGENTS.md` の「マーケットプレイスの構造」、`python3 scripts/check-service-boundary.py`)。`handlers/` は handler の配線だけを持つ(#2199 の R02 の決定)。共通の仕組みは `handlers/` の外に置く | +| コーディング規約 | 既存の `factory/api` の書き方(TypeScript・ES モジュール・注釈は日本語)。確認は `npm run typecheck` | +| テスト戦略 | 共通の仕組みは単体テストで規則(受け入れ条件 6〜10)を固定する。3 か所の呼び出しは既存のテストと型検査で退行を見る | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | `npm run check -w api` の実行、変更範囲内の命名の統一 | +| 確認してから行う(承認ゲート 1・2 で人が見る) | GitHub の仲立ち(認証の経路)のコードの変更。秘密を読むコードの置き場の変更 | +| 行わない | 秘密の値・鍵の中身・検証の規則の変更、時間ごとに読み直す検証の変更、`factory/runner` の変更、インフラの定義の変更 | + +## 未決 + +無し(決められない点は前提 1〜4 に書いた) diff --git a/issues/issue-2383-design-decisions.md b/issues/issue-2383-design-decisions.md new file mode 100644 index 000000000..34761258e --- /dev/null +++ b/issues/issue-2383-design-decisions.md @@ -0,0 +1,118 @@ +# #2383 の設計: 決定の記録 + +設計の本体は [issue-2383-design.md](issue-2383-design.md) にある。番号(`STD1`・`LIB1` など)は本体の「外部の調査」の表を指す。 + +## 決定の記録 + +### 決定 1: 鍵と暗号文を同じ所に置けるよう、端末の控えの置き場を IndexedDB にし、ラッパーに idb を使う + +鍵(CryptoKey)を保存できるのは、直列化できる値を保存する仕組みだけである(STD1・STD2)。`localStorage` と `sessionStorage` は文字列だけを持ち、鍵を書き出さずに置けない。IndexedDB の要求を Promise にする部分は idb(LIB3)に任せ、自分でラッパーを書かない。idb は画面の依存に足す唯一の実行時のパッケージで、運用の費用は増えない。 + +dexie(LIB4)は問い合わせとスキーマの版の管理を持ち、3 つのストアの読み書きと消去には余る。idb-keyval(LIB5)は 1 つのストアだけを持ち、3 つのストアを 1 つのトランザクションで消せない。依存を足さずに IndexedDB を直に使う形は採らない。要求と事象のラッパーを自分で書くことになり、idb が持つものの複製になる。 + +- 作り方: ライブラリやサービスを使う・標準に倣う +- 比べた候補: LIB3 / LIB4 / LIB5 / STD2 + +根拠: Value 5 / スプリント Value 6(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 2: 応答の中身も ID も平文で残さないよう、中身を AES-GCM で暗号化し、レコードの名前を HMAC で作る + +受け入れ条件 9 は、書いたバイト列に実行の ID とリポジトリ名が現れないことを求める。中身だけを暗号化しても、読み出しの指し方(実行の ID など)をレコードの名前に使うと平文で残る。名前は書き出せない HMAC の鍵で作り、指し方そのものは暗号文の中に持つ。暗号はブラウザの標準の Web Crypto の AES-GCM 256 を使う。書くたびに 12 バイトの初期化ベクトルを乱数で作る(STD1)。 + +暗号のライブラリは足さない。標準の API なら、鍵を書き出せない形にできる(受け入れ条件 10)。ライブラリの鍵は JavaScript の値として持つため、書き出せてしまう。 + +- 作り方: 標準に倣う +- 比べた候補: STD1 / STD2 + +根拠: C1 / スプリント Value 3(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 3: token の無いタブでもオフラインで読めるよう、鍵を端末に置き、token から導かない + +起票者の推定 S9 は、通知から開いた token の無いタブで、期限の前なら控えを読めることを求める。鍵を token から導くと、token の無いタブは控えを復号できない。鍵は控えの持ち主の記録に書き出せない形で置き、利用者の ID と期限を平文で添える(前提 4)。 + +この形では、端末で同じブラウザを開ける人は期限までは控えを読める(未確認のまま残ること 7)。暗号化が守るのは、保存のファイル・バックアップ・ほかのアプリからの読み取りである。期限を短く保つため、ログインの延長は作らない(要求の対象範囲)。 + +根拠: スプリント Value 3(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 4: 読み出しを 1 行で足せるよう、端末の控えを画面の API を包む層と登録の表で作る + +前提 2 は、#2173 が読み出しの操作を 1 行の登録で控えに足せる形を求める。控えに入れる読み出しを表に並べ、画面の API(`Api`)を包む層が表に従ってネットワークと控えを切り替える。画面のコードは、読み出しが控えから来たかを意識せずに今の API を呼ぶ。表に無い読み出しは控えの保管を呼ばない(I6)。 + +Service Worker の実行時のキャッシュで API の応答を控える形は採らない。応答が平文のまま Cache Storage に残り、前提 8 に反する(STD3・LIB1)。 + +- 作り方: 自前で作る +- 比べた候補: STD3 / LIB1 + +根拠: Value 7 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 5: 送る前に今の状態と照らせるよう、確定待ちの操作は画面を開いている間に自前の列で確定し、Background Sync を使わない + +workbox-background-sync(LIB2)は失敗した要求を積み、Service Worker の起動時に送り直す。送る前に対象の今の状態を読んで照らす段を持たず、画面を閉じている間にも送る。前提 6 は画面を開いている間だけ確定するとし、前提 7 は送る前に今の状態と照らすとする。列は IndexedDB に暗号化して置き、照合の規則は登録の表に操作ごとに 1 行で持つ。 + +- 作り方: 自前で作る +- 比べた候補: LIB2 / STD3 + +根拠: スプリント Value 3 / スプリント Value 4(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 6: 同じ操作を 2 度送らないよう、送る権利を列からの原子的な取り出しで 1 つの処理に限る + +同じ利用者が画面を 2 つのタブで開いていると、つながりが戻ったときに 2 つのタブが同じ確定待ちの操作を取り出す。送る権利は、ストア `pending` への 1 つの読み書きのトランザクションでレコードを読んで消す取り出しに成功した 1 つの処理だけが得る。IndexedDB は範囲の重なる読み書きのトランザクションを 1 つずつ動かすため、同時に取り出しても取れるのは 1 つである。照合を重ねて行わないよう、同期は Web Locks API の名前つきのロックの中でも行う。ロックは API の呼び出しを減らすためのもので、二重の送信を防ぐのは取り出しである。送る前に列から外すため、送った要求の結果が分からないまま失敗しても送り直さない(I9)。送り直しよりも、利用者が結果を見て押し直す方を選ぶ。取り消せない操作は 2 度の確定の方が害が大きいためである。 + +Web Locks API の無いブラウザでは、ロックなしで同期する。2 つのタブが同じ操作を照合することはあるが、送るのは取り出せた 1 つだけである(未確認のまま残ること 6)。 + +根拠: スプリント Value 3(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 7: 画面を開き直せるよう、画面の資産を vite-plugin-pwa の generateSW で事前にキャッシュする + +画面の資産(HTML・JavaScript・CSS・翻訳のファイル)は、ビルドの成果物の一覧を Service Worker が事前にキャッシュする。経路の遷移には `index.html` を返す。vite-plugin-pwa(LIB1)は vite 8 を peerDependencies に持ち、ビルドの成果物から Service Worker を生成する。開発時の依存だけに入り、運用の費用は増えない。API の応答の実行時のキャッシュは設定しない(決定 4)。新しい版の Service Worker の更新を知らせる画面は作らない。開いているタブをすべて閉じた後に効く、既定の振る舞いのままにする。 + +Service Worker を手で書く形は採らない。ビルドの成果物のファイル名がハッシュつきで変わるため、一覧を作る処理を自分で持つことになる。 + +- 作り方: ライブラリやサービスを使う +- 比べた候補: LIB1 / STD3 + +根拠: Value 5 / スプリント Value 3 / スプリント Value 6(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 8: 画面のコードを読み出しの出所に依らせないよう、控えから出したことを読み直しの部品が受け取った時刻として受け取る + +API の戻り値の型を「値と出所」の組へ変えると、画面のすべての読み出しの型が変わる。読み直しの部品(`usePolled`)は、読み出しを呼ぶ間だけ、控えつきの API から出所を集める。控えから出たときは、受け取った時刻を `receivedAt` として返す。画面は `receivedAt` があれば注記を出す。集めるのは読み出しを呼んだ直後の同期の部分だけである。そのため、読み出しの関数は最初の `await` より前に API を呼ぶ(今の 5 画面の読み出しはこの形である)。 + +控えが変わった(取り直し・消去)ときは、控えから出した画面だけを読み直す。 + +根拠: Value 7(MVV 版 5) + +### 決定 9: ループの画面も同じ注記と読み直しを持つよう、ループの一覧と詳細の読み出しを読み直しの部品へ移す + +ループの一覧と詳細は、今は画面ごとの読み込みの関数で読む。読み直しの部品へ移し、間隔での読み直しはしない(`poll: false`。今と同じく、開いたときと操作の後だけ読む)。受け入れ条件 17 は読み直しの部品の間隔を変えないことを求め、この移し替えはループの画面の読む回数を変えない。 + +根拠: Value 7 / スプリント Value 1(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 10: 電波が届かないのにブラウザがオンラインと言う場面を拾うよう、要求のネットワークの失敗でもオフラインにし、次の成功で戻す + +ブラウザの `navigator.onLine` は、端末がネットワークにつながっていればサーバーに届かなくても真である。要求が応答を受け取れずに失敗したら(`fetch` の `TypeError`)、つながりをオフラインにする。次に要求が応答を受け取ったら、オンラインに戻す。戻ったときを E6 とし、同期を始める。オフラインの間も読み直しの間隔で要求を試すため、戻ったことに気づける。 + +根拠: スプリント Value 3(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 11: サーバーに届いたかもしれない操作を 2 度送らないよう、確定待ちにするのはオフラインで押したときだけにする + +オンラインで送った取り消せない操作がネットワークで失敗したとき、サーバーが受け取って処理したかは分からない。確定待ちにすると、処理済みの操作を照合の後にもう 1 度送ることがある。この場合は今と同じく失敗を画面に出し、利用者が画面を読み直して決める。 + +根拠: スプリント Value 3(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 12: token の無いタブで書く操作をさせないよう、token の無いタブで描くのはオフラインで有効な控えがあるときだけにする + +ログインの枠は、token が無くても、オフラインで控えの持ち主が期限の前なら画面を描く。オンラインなら今と同じくログインへ移す。token の無いタブで押した取り消せない操作は確定待ちになる。同期には token が要るため、確定するのはログインした後のタブである。 + +根拠: C2 / スプリント Value 3(MVV 版 5・スプリント MVV 44cd096d) + +### 決定 13: 通信を切って開き直す確かめ方が成り立つよう、オフラインの E2E はダミーのビルドを配って行う + +vite-plugin-pwa は開発のサーバーではエントリポイントだけをキャッシュし、モジュールをキャッシュしない(LIB1 の出典)。今の E2E はダミーの開発のサーバーで動くため、通信を切って開き直すと画面の資産が読めない。オフラインの E2E は、ダミーのモードでビルドした成果物(`dist-dummy/`)を `vite preview` で配るプロジェクトで行う。本番のビルド(`dist/`)とその検査(`check:bundle`)は変えない。ダミーの API は通信が切れているとネットワークの失敗と同じ誤りを投げ、本物の控えとつながりを使う。 + +根拠: Value 1 / Value 4(MVV 版 5) + +### 決定 14: 端末の控えをサーバーの最後の応答のコピーに留めるよう、控えた応答は最後の応答で上書きし、履歴を持たない + +端末の控えは、画面が最後に受け取った応答をオフラインで読むためのものである。過去の状態はサーバーの事象の記録が持ち、端末で履歴を持つと、利用者の端末に残るデータが増えるだけになる。同じ読み出しの指し方の応答は同じレコードへ上書きし、取り直しの応答に無い要素は消す(前提 9)。 + +根拠: スプリント Value 3 / スプリント Value 6(MVV 版 5・スプリント MVV 44cd096d) diff --git a/issues/issue-2383-design.md b/issues/issue-2383-design.md new file mode 100644 index 000000000..b902c818b --- /dev/null +++ b/issues/issue-2383-design.md @@ -0,0 +1,507 @@ +# Factory の画面: オフラインでも最後に受け取った内容を読め、取り消せない操作はつながってから照らして確定する(#2383) + +## 目的 + +- **今どうなっているか**: Factory の画面は、API が応答を返さないと一覧も詳細も表示しない。取り消せない操作(ループの判断待ちへの答え・ループの開始・止める)のボタンはオフラインでは押せず、つながりの状態の表示は「最後に受け取った時刻」と「確定待ちの数」を実際の値で持たない +- **誰に効くか**: 移動中にスマートフォンやタブレットで承認の通知を受け、電波の弱い場所で開く Factory の利用者 +- **変えると何が成り立つか**: 最後に受け取ったスプリントの一覧・詳細・承認を待つ依頼の一覧・ループの一覧と詳細を、暗号化して端末に残した端末の控えから読める。オフラインの間に押した取り消せない操作は、つながってからサーバーの今の状態と照らし、同じときだけ確定する。端末の控えはログアウト・401・期限・別の利用者のログインで消える + +## 適用範囲 + +- **働く範囲**: Factory の画面(`factory/web`)だけ。API(`factory/api`)と契約(`factory/contracts`)、NDF のプラグイン(`plugins/`)は変えない +- **プロジェクトごとに違うもの**: 無し(Factory の画面はこのリポジトリにだけある。端末の控えに入れる読み出しは画面のコードの登録の表が持つ) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| スプリント MVV の Value 3「つながらないときも、最後に受け取ったスプリントの一覧・詳細・承認資料を読め…端末に残すデータは暗号化して保存する…取り消せない操作は、オフラインでは確定させず、つながってからサーバーの今の状態と照らして確定する」 | 利用者の指示の原文 | 読めるもの・暗号化・確定の規則 | +| STD1(Web Crypto API)は、`[[extractable]]` が偽の鍵の書き出しで `InvalidAccessError` を投げると定め、鍵を IndexedDB へ保存する使い方を想定している | 外部の一次情報 | 書き出せない鍵を端末に残せること(受け入れ条件 10) | +| Node 24.22.1 で、書き出せない AES-GCM の鍵を `structuredClone` で複製でき、複製の `exportKey` が `InvalidAccessError` になった | 実測(`node -e` で 2026-11-05 に実行) | 単体テストの側でも、鍵の複製と書き出しの拒否を確かめられること | +| `factory/web/src/auth.ts` は token を `sessionStorage` に置く。新しいタブは token を持たない | 実測(コードの読み取り) | 通知から開いた新しいタブで読むには、token に依らずに端末の控えを読める必要があること(起票者の推定 S9) | +| `grep -rn cancelSprintRun factory/web/src` の結果は `src/api.ts` の 2 行と `src/dummy/api.ts` の 1 行だけで、画面(`src/pages/`)からの呼び出しは 0 件 | 実測(2026-11-05 に実行) | スプリントの実行を止める操作は、今の画面にボタンが無いこと(未確認のまま残ること 4) | + +要求と受け入れ条件は #2383 の本文にある(コピーは [issue-2383-requirements.md](issue-2383-requirements.md) )。この文書は「どう作るか」だけを扱う。決定の記録は [issue-2383-design-decisions.md](issue-2383-design-decisions.md) に分けた。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| Neural Development Factory(`ndf-factory`) | 端末の控え・確定待ちの操作・スプリントの実行・ループ・承認を待つ依頼 | + +変更は 1 つのコンテキストに収まる。サーバーのスプリントの実行とループは読み出しの応答として受け取るだけで、端末の側でモデルを持ち直さない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 端末の控え | 控えの保管(`src/offline/vault.ts`) | 控えの持ち主の記録(利用者の ID・保存の期限・鍵) | 控えた応答(1 つの読み出しの 1 つの応答。要素ごとに読む読み出しでは要素 1 個) | 受け取った時刻・保存の期限・読み出しの指し方(読み出しの名前・アカウント・引数) | +| 確定待ちの操作 | 確定待ちの列(`src/offline/pending.ts`) | 確定待ちの操作 | — | 操作の種類・送る中身・押したときに見ていた状態・照合の結果 | + +- 控えの保管と確定待ちの列は、同じ IndexedDB のデータベースに別のオブジェクトストアとして置く。暗号の鍵は控えの持ち主の記録だけが持ち、確定待ちの列は控えの保管の暗号化と復号の操作を呼ぶ +- 確定待ちの操作は、操作の対象(ループ・スプリントの実行)を ID で指す。対象の今の状態は照合のときにサーバーから読み、端末の控えを照合の根拠にしない + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 端末の控え | 端末に書くバイト列に、応答の平文と token の値が現れない。控えた応答は AES-GCM で暗号化し、レコードの名前は HMAC で作る | 暗号化に失敗したら書かない(応答は画面へそのまま返す) | +| I2 | 端末の控え | 暗号と名前の鍵は書き出せない(`extractable: false`) | 鍵を作れなければ控えを使わない | +| I3 | 端末の控え | 控えの持ち主は 1 人である。持ち主と別の利用者の token で読み書きする前に、控えと確定待ちの操作をすべて消す | 消せなければ控えを返さない | +| I4 | 端末の控え | 保存の期限(控えを作ったログインの token の `exp`)を過ぎた控えは返さず、消す | 消せなければ控えを返さない | +| I5 | 端末の控え | 控えの中身を端末で書き換えない。置き換えるのはサーバーの応答だけである | — (書き換える操作を持たない) | +| I6 | 端末の控え | 登録の表に無い読み出しの応答は控えに入らない | — (登録に無い読み出しは控えの保管を呼ばない) | +| I7 | 端末の控え | オンラインのときは先にネットワークを読み、控えへの書き込みの完了を待たずに応答を返す | 書き込みの失敗は捨て、前の控えを残す | +| I8 | 確定待ちの操作 | 照合で「押したときと同じ」と判定されるまで API へ送らない | 照合の読み出しが失敗したら確定待ちのまま残す | +| I9 | 確定待ちの操作 | 1 つの確定待ちの操作を送るのは、全タブを通じて多くとも 1 回である。送る権利は、列からの原子的な取り出し(1 つの読み書きのトランザクションでレコードを読んで消す)に成功した 1 つの処理だけが得る。Web Locks API の有無によらない | 取り出したレコードが無かった処理は送らない。送る要求が失敗したら理由を示して列へ戻さない | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 読み出しの応答を受け取り、端末の控えへ暗号化して残した | 控えつきの API | 控えの保管・つながり(最後に受け取った時刻) | +| E2 | つながりが切れた | つながり(ブラウザの `offline` の事象、または API への要求のネットワークの失敗) | 控えつきの API・画面の表示 | +| E3 | 端末の控えから画面を表示した | 控えつきの API | 読み直し(受け取った時刻を画面へ渡す) | +| E4 | 取り消せない操作を確定待ちとして残した | 控えつきの API | 確定待ちの列・つながり(確定待ちの数) | +| E5 | 確定待ちの操作を取り消した | 確定待ちの一覧(利用者の操作) | 確定待ちの列・つながり | +| E6 | つながりが戻った | つながり(`online` の事象、またはネットワークの失敗の後の最初の成功) | 同期 | +| E7 | 端末の控えを取り直した | 同期 | 控えの保管・読み直し(控えから出した画面を読み直す) | +| E8 | 確定待ちの操作を今の状態と照らした | 同期 | 確定待ちの列 | +| E9 | 確定待ちの操作を確定した、または食い違いの理由を示して確定しなかった | 同期 | 確定待ちの一覧(結果の表示)・つながり | +| E10 | 端末の控えと確定待ちの操作を消した | 控えの保管(ログアウト・API の 401・期限・別の利用者) | ログインの枠(token の無いタブはログインへ移る)・読み直し | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 端末の控え | 画面が最後に受け取った読み出しの応答を、暗号化して端末に残したもの。受け取った時刻と保存の期限を持つ | 追加(要求の変更で反映済み) | +| 確定待ちの操作 | オフラインの間に押した取り消せない操作で、つながったときに今の状態と照らしてから確定するもの | 追加(要求の変更で反映済み) | + +画面の資産(HTML・JavaScript・CSS)を端末に置く仕組みは、業界の語のまま「Service Worker のキャッシュ」と書き、端末の控えと呼ばない。 + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べる | 端末の保存・暗号・鍵の保存・オフラインの画面の資産に、W3C と WHATWG の仕様がある | +| 主要な製品 | 調べない | 見せ方は UX の土台(#2382)が決めており、取り消せない操作の扱いは利用者の指示の原文(スプリント Value 3)が決めている。製品の解き方で変わる判断が残っていない | +| ライブラリとサービス | 調べる | IndexedDB のラッパー・Service Worker の生成・送り直しの列に既存のライブラリがある | + +### 標準・仕様 + +| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| STD1 | Web Cryptography API(W3C) | AES-GCM の暗号化と復号、HMAC。鍵の `[[extractable]]` が偽なら `exportKey` は `InvalidAccessError` を投げる。CryptoKey は直列化でき、IndexedDB への保存を想定する | 暗号は AES-GCM 256、レコードの名前は HMAC-SHA-256。鍵は書き出せない形で作り、IndexedDB に置く(決定 2) | https://www.w3.org/TR/WebCryptoAPI/ (2026-11-05) | +| STD2 | Indexed Database API(W3C) | レコードの値に直列化できる任意の値(Blob・ArrayBuffer を含む)を保存する | 控えと確定待ちの操作を IndexedDB に置き、鍵(CryptoKey)も同じデータベースに置く(決定 1) | https://www.w3.org/TR/IndexedDB/ (2026-11-05) | +| STD3 | Service Workers(W3C) | fetch の事象と、要求と応答のストア。オフラインで動く画面を作るための仕組み。境界はオリジンである | 画面の資産を Service Worker のキャッシュに置き、API の応答は置かない(決定 7・前提 8) | https://www.w3.org/TR/service-workers/ (2026-11-05) | +| STD4 | Storage Standard(WHATWG) | ローカルのストレージのバケットは既定で `best-effort` で、ブラウザが消すことがある | 控えは消えうる前提で作り、消えたときは「まだ受け取っていません」を出す(受け入れ条件 4) | https://storage.spec.whatwg.org/ (2026-11-05) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | vite-plugin-pwa(依存に workbox-build・workbox-window 7.4.1) | 2.0.0 | MIT | 2026-10-03 | 無し | 満たす。peerDependencies に `vite ^8.0.0` を含む。`generateSW` でビルドの成果物を事前にキャッシュする Service Worker を作る。開発のサーバーではエントリポイントだけをキャッシュする | `npm view vite-plugin-pwa version license peerDependencies dependencies time` / https://vite-pwa-org.netlify.app/guide/development.md (2026-11-05) | +| LIB2 | workbox-background-sync | 7.4.1(workbox の版) | MIT | 2026-05-04(workbox-precaching の版の日付) | 無し | 満たさない。失敗した要求を列に積み、Service Worker の起動時に送り直す。送る前にサーバーの今の状態と照らす段を持たず、画面を閉じている間にも送る(前提 6 に反する) | https://developer.chrome.com/docs/workbox/modules/workbox-background-sync (2026-11-05) | +| LIB3 | idb | 8.0.4 | ISC | 2026-10-06 | 無し | 満たす。IndexedDB の要求を Promise にする薄いラッパー | `npm view idb version license time`(2026-11-05) | +| LIB4 | dexie | 4.4.7 | Apache-2.0 | 2026-10-19 | 無し | 満たすが、問い合わせとスキーマの版の管理を持つ。この変更が使うのは 3 つのストアの読み書きと消去だけである | `npm view dexie version license time`(2026-11-05) | +| LIB5 | idb-keyval | 6.3.0 | Apache-2.0 | 2026-07-08 | 無し | 一部を満たす。1 つのストアの鍵と値だけを持ち、3 つのストアを 1 つのトランザクションで消せない | `npm view idb-keyval version license time`(2026-11-05) | +| LIB6 | fake-indexeddb(テストだけ) | 6.2.7 | Apache-2.0 | 2026-10-30(登録簿の更新の日付) | 無し | 満たす。jsdom に無い IndexedDB を単体テストで使う | `npm view fake-indexeddb version license time`(2026-11-05) | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| Web Locks API の仕様と、iOS の Safari での対応 | 試していない | 上限(取得の試み 30 回)。決定 6 のタブの排他ロックはこの API に依る。無いブラウザでも、二重の送信は確定待ちの列の原子的な取り出し(I9)で防ぐ | +| Playwright の `setOffline` で `navigator.onLine` と `offline` の事象が変わるか | https://playwright.dev/docs/api/class-browsercontext (2026-11-05 に取得) | 文書は「通常のネットワークの経路を通る要求(ページの遷移・fetch・XMLHttpRequest・WebSocket)にだけ効く」と書き、`navigator.onLine` には触れていない。実測していない(未確認のまま残ること 2) | +| localforage 1.10.0(最後のリリース 2021-08-18)・@tanstack/query-persist-client-core 5.106.1 | `npm view`(2026-11-05) | 版とライセンスは取得したが、候補の上限(6 件)に当たったため比べていない | + +調べた量: 候補 10 件・取得の試み 32 回。上限の 30 回を 2 回超えた(`npm view` を 1 つの候補につき 2 回打った分)。超えた後の取得はしていない。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | オフラインで、最後に受け取ったスプリントの一覧・詳細・承認を待つ依頼の一覧を読む | Factory の利用者 | +| F2 | オフラインで、最後に受け取ったループの一覧と詳細を読む | Factory の利用者 | +| F3 | 端末の控えから出した画面で、受け取った時刻と、つながりの状態の「最後に受け取った時刻」を見る | Factory の利用者 | +| F4 | オフラインで取り消せない操作を押し、確定待ちとして残す。確定待ちの一覧で取り消す | Factory の利用者 | +| F5 | つながりが戻ったとき、確定待ちの操作が照合の後に確定する、または食い違いの理由が出る | Factory の利用者 | +| F6 | つながりが戻ったとき、端末の控えがサーバーの応答にそろう | Factory の利用者 | +| F7 | ログアウト・401・期限・別の利用者のログインで、端末の控えと確定待ちの操作が消える | Factory の利用者(端末を手にした他人から守られる) | +| F8 | 通信を切った状態で画面を開き直しても、画面の資産が読み込まれる | Factory の利用者 | +| F9 | 読み出しの操作を 1 行の登録で端末の控えへ足す | Factory の開発者(#2173 の実装) | + +## 構成要素 + +| 要素 | 責務 | 置き場 | +| --- | --- | --- | +| 控えの保管 | IndexedDB の開閉。暗号化と復号、レコードの名前の HMAC。持ち主・期限の判定と消去。変更の通知 | `src/offline/vault.ts`(新規) | +| 確定待ちの列 | 確定待ちの操作の追加・一覧・取り消し・取り出し。暗号化は控えの保管を呼ぶ | `src/offline/pending.ts`(新規) | +| 登録の表 | 控えに入れる読み出しの登録(読み出しの名前と、一覧か要素ごとか)と、取り消せない操作の登録(押したときに見ていた状態の取り方・照合・送り方) | `src/offline/registry.ts`(新規) | +| 控えつきの API | 画面の API(`Api`)を包む。登録した読み出しをネットワークから先に読んで控えに書き、ネットワークで失敗したら控えを返す。オフラインで押した取り消せない操作を確定待ちにする | `src/offline/copied-api.ts`(新規) | +| 同期 | つながりが戻ったときに、確定待ちの操作の照合と確定、続けて控えの取り直しを行う。Web Locks API があれば 1 つのタブだけが行う。無くても、送るのは列から取り出せた処理だけである(I9) | `src/offline/sync.ts`(新規) | +| つながり | オンラインの判定(ブラウザの事象と要求のネットワークの失敗)・最後に受け取った時刻・確定待ちの数を実際の値で持つ | `src/connection.ts`(変更) | +| 読み直し | 読み出しが控えから出たときの受け取った時刻を画面へ返す。控えが変わったら、控えから出した画面を読み直す | `src/poll.ts`(変更) | +| ログイン | token の利用者の ID と有効期限の時刻を読む(`session()`)。401 で控えを消す | `src/auth.ts`・`src/api.ts`(変更) | +| ログインの枠 | token が無くても、オフラインで控えの持ち主が有効なら画面を描く | `src/context.tsx`(変更) | +| 画面の部品 | 控えから出した画面の注記(受け取った時刻)・確定待ちの一覧(取り消しと結果) | `src/ui/CopyNotice.tsx`・`src/ui/PendingOperations.tsx`(新規)、`src/ui/AppShell.tsx`・`src/ui/ApprovalActions.tsx`(変更) | +| 画面 | ループの一覧と詳細を読み直しの部品へ移す。取り消せない操作のボタンをオフラインでも押せるようにする。ログアウトで控えを消す | `src/pages/LoopsPage.tsx`・`LoopDetailPage.tsx`・`SprintsPage.tsx`・`SprintDetailPage.tsx`・`ApprovalsPage.tsx`・`AccountPage.tsx`・`CallbackPage.tsx`(変更) | +| 画面の資産のキャッシュ | ビルドの成果物を事前にキャッシュする Service Worker を生成し、画面から登録する | `vite.config.ts`・`src/main.tsx`(変更) | +| ダミー | ダミーの API がオフラインでネットワークの失敗と同じ誤りを投げる。本物の控えとつながりを使う。token の無いタブを作れる | `src/dummy/index.ts`・`src/dummy/api.ts`(変更) | +| E2E | ダミーのビルドを配り、通信を切って開き直す | `e2e/offline.spec.ts`(新規)、`playwright.config.ts`(変更) | + +### 要素どうしの呼び出し + +```mermaid +graph TD + subgraph 画面 + P[各画面と
確定待ちの一覧] + R[読み直し] + end + subgraph 端末の控えの層 + C[控えつきの API] + S[同期] + G[登録の表] + end + subgraph 端末の保存 + V[控えの保管] + Q[確定待ちの列] + end + P --> R --> C + C --> G + C --> V + C --> Q + S --> Q + S --> V + S --> C + N[つながり] --> S + C --> N + Q --> V + K[ログインの枠] --> V + K --> AU[ログイン] + C --> AU +``` + +図に含めない要素: 画面の資産のキャッシュ(下の配置の図)・ダミー・E2E(確かめるための要素で、上の要素を呼ぶだけである)。 + +### 配置 + +```mermaid +graph TD + subgraph 利用者の端末のブラウザ + W[画面] + SW[Service Worker] + DB[(IndexedDB)] + end + subgraph サーバー + API[Factory の API] + H[画面の資産の配信] + end + W -- 読み出しと操作 --> API + W -- 暗号文と鍵 --> DB + SW -- 画面の資産 --> H + W -- 画面の資産 --> SW +``` + +境界をまたぐもの: 画面と API の間は今と同じ token つきの要求である。IndexedDB へ書くのは暗号文・書き出せない鍵・利用者の ID・時刻だけで、token の値は書かない。Service Worker は画面の資産だけを扱い、API の要求を扱わない。 + +## データ構造 + +**IndexedDB のデータベース 1 つに、3 つのオブジェクトストアを置く。** データベースの名前は `ndf-factory-copies`、版は 1 である。 + +```mermaid +erDiagram + 控えの持ち主 ||--o{ 控えた応答 : "鍵で暗号化する" + 控えの持ち主 ||--o{ 確定待ちの操作 : "鍵で暗号化する" + 控えの持ち主 { + string レコードの名前 PK + string 利用者のID + string 保存の期限 + } + 控えた応答 { + string レコードの名前 PK + bytes 暗号文 + } + 確定待ちの操作 { + string 操作のID PK + number 押した順 + bytes 暗号文 + } +``` + +### 控えの持ち主(ストア `owner`。レコードは常に 0 件か 1 件) + +| 項目 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| レコードの名前 | 文字列 | 許さない | 常に `owner` | +| 利用者の ID | 文字列 | 許さない | token の `sub`。API の `Me.user_id` と同じ値。秘密ではない(前提 4) | +| 保存の期限 | 文字列(UTC の ISO 8601) | 許さない | 控えを作ったログインの token の `exp`。同じ利用者がログインし直したら新しい `exp` に置き換える | +| 最後に受け取った時刻 | 文字列(UTC の ISO 8601) | 許す | 空は「まだ 1 度も受け取っていない」 | +| 暗号の鍵 | CryptoKey(AES-GCM 256・書き出せない) | 許さない | 控えた応答と確定待ちの操作の暗号化 | +| 名前の鍵 | CryptoKey(HMAC-SHA-256・書き出せない) | 許さない | 控えた応答のレコードの名前を作る | + +### 控えた応答(ストア `copies`) + +**時系列は持たない。** 控えた応答は同じレコードの名前へ最後の応答を上書きする(理由は決定 14)。 + +| 項目 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| レコードの名前 | 文字列(16 進) | 許さない | 読み出しの指し方(読み出しの名前・アカウントの ID・引数、要素ごとの読み出しでは要素の ID)を名前の鍵で HMAC にしたもの。平文の ID を名前に出さない | +| 初期化ベクトル | ArrayBuffer(12 バイト) | 許さない | 書くたびに乱数で作る | +| 暗号文 | ArrayBuffer | 許さない | 次の JSON を暗号化したもの: 読み出しの指し方・応答の値・受け取った時刻 | + +### 確定待ちの操作(ストア `pending`) + +| 項目 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| 操作の ID | 文字列(UUID) | 許さない | 押したときに乱数で作る。応答の中身を含まない | +| 押した順 | 数 | 許さない | 照合と確定の順序。押した時刻のミリ秒 | +| 初期化ベクトル・暗号文 | ArrayBuffer | 許さない | 次の JSON を暗号化したもの: 操作の種類・アカウントの ID・送る中身・押したときに見ていた状態・押した時刻 | + +### 機能ごとの読み書き + +| 機能 | 控えの持ち主 | 控えた応答 | 確定待ちの操作 | +| --- | --- | --- | --- | +| F1・F2 オフラインで読む | R | R | — | +| F3 受け取った時刻を見る | R | R | — | +| F4 確定待ちにする・取り消す | R | R | C・D | +| F5 照合と確定 | R | R | R・D | +| F6 取り直す | U(最後に受け取った時刻) | R・U・D | — | +| F7 消す | D | D | D | +| (オンラインの読み出し) | C・U | C・U | — | + +### 移行 + +新しいデータベースで、既存のデータは無い。版を上げるときは、古い版のデータベースを消して作り直す(控えはサーバーから取り直せる)。 + +## 入出力の契約 + +**API と契約は変えない。** 変えるのは画面の内側の約束(登録の表の形と、画面の状態)である。 + +### 登録の表 + +前提 2 の「1 行の登録」は次の形にする。#2173 は自分の読み出しと操作をこの表へ 1 行ずつ足す。 + +```ts +// 控えに入れる読み出し。list は引数ごとに 1 つ、byIds は要素ごとに 1 つ控える +export const COPIED_READS = { + sprintRuns: list(), + approvalRequests: list(), + loops: list(), + sprintRunDetails: byIds((d: SprintRunDetail) => d.sprint_run_id), + loopDetails: byIds((d: LoopDetail) => d.loop.loop_id), +} satisfies CopiedReads; + +// 取り消せない操作。seen は押したときに見ていた状態を控えから取り、check は今の状態と照らす +export const DEFERRED_WRITES = { + answerLoop: deferred({ seen: seenLoopPending, check: loopPendingStillOpen }), + startLoop: deferred({ seen: noSeen, check: loopNameFree }), + cancelLoop: deferred({ seen: seenLoopState, check: loopNotFinished }), + cancelSprintRun: deferred({ seen: seenRunState, check: runNotFinished }), +} satisfies DeferredWrites; +``` + +| 操作 | 照合で読むもの | 同じと判定する条件 | 食い違いの理由 | +| --- | --- | --- | --- | +| ループの判断待ちへの答え | ループの詳細(`loopDetails([loopId])`) | ループが終わっておらず、同じ課題の判断待ちが同じ周回で答えを持たない | すでに答えられている / 周回が変わった / ループが終わった / ループが無い | +| ループの開始 | ループの一覧(`loops(repositoryId)`) | 同じ名前のループが動いていない(`stopped`・`cancelled` 以外の状態に無い) | 同じ名前のループが動いている | +| ループを止める | ループの詳細 | ループの状態が `stopped`・`cancelled` でない | すでに止まっている / ループが無い | +| スプリントの実行を止める | スプリントの詳細(`sprintRunDetails([id])`) | 実行の状態が終わりの状態(`done`・`stopped`・`failed`・`cancelled`)でない | すでに終わっている | + +- 照合の読み出しが 401 以外の理由で失敗したら(ネットワーク・5xx)、確定待ちのまま残す(受け入れ条件 14)。401 は E10 で消す +- 送った要求が失敗したら(409・422 など)、サーバーの文を理由として出し、列へ戻さない(I9) + +### 控えつきの API の振る舞い + +| 場面 | 登録した読み出し | 取り消せない操作 | 登録の無い操作 | +| --- | --- | --- | --- | +| オンライン | ネットワークを読む。成功したら控えへ書き(完了を待たない)、応答を返す | 今と同じくその場で送る | 今と同じ | +| オンラインでネットワークが失敗した | つながりをオフラインにし、控えを返す。ブラウザがオンラインと言う間は、次の読み出しでもネットワークを先に試す(決定 10) | 失敗をそのまま返す(結果が分からないため確定待ちにしない。決定 11) | 失敗をそのまま返す | +| オフライン(ブラウザの `navigator.onLine` が偽、または前の要求がネットワークで失敗した) | ブラウザが偽と言う間はネットワークを読まずに控えを返す。控えが無い・要素が 1 つでも欠ける・期限切れなら「まだ受け取っていません」の誤り(`NoCopyError`) | 押したときに見ていた状態を控えから取り、確定待ちの列へ足し、「つながってから確定します」の誤り(`DeferredError`)を返す | 今と同じ(画面がボタンを押せないようにしている) | +| サーバーの 401 | 控えと確定待ちの操作を消す | 同じ | 同じ | + +### 画面 + +| 画面 | 何をする場所か | 変わること | +| --- | --- | --- | +| スプリントの一覧・詳細・承認を待つ依頼の一覧 | 実行と依頼を読む | 控えから出したとき、見出しの下に受け取った時刻の注記を出す | +| ループの一覧・詳細 | ループを読み、開始・止める・判断待ちに答える | 読み出しを読み直しの部品へ移し(間隔の読み直しはしない)、控えの注記を出す。開始・止める・答えのボタンをオフラインでも押せる | +| 枠(すべてのログインの後の画面) | 見出しの帯とつながりの状態 | つながりの状態が実際の値を出す。確定待ちが 1 件以上か、照合の結果があるとき、確定待ちの一覧を出す | +| アカウント | ログアウト | ログアウトで控えと確定待ちの操作を消してから、今のログアウトへ送る | + +| 状態 | 控えを使う画面に出すもの | +| --- | --- | +| 読み込み中 | 今と同じ | +| ネットワークから表示 | 今と同じ(注記を出さない) | +| 控えから表示 | 中身と「端末の控えを表示しています(受け取った時刻: 利用者の時刻帯の日時)」 | +| 控えが無い | 「まだ受け取っていません」。空の一覧として「0 件」を出さない | +| 読み直しの失敗(控えも無い) | 今と同じ誤りの文と再試行 | + +| 確定待ちの一覧の行 | 出すもの | 操作 | +| --- | --- | --- | +| 確定待ち | 操作の説明(例: 「ループ lfactory の #123 を承認する」)・押した時刻 | 取り消す | +| 確定した | 操作の説明と「確定しました」 | 閉じる | +| 確定しなかった | 操作の説明と食い違いの理由 | 閉じる | +| 取り消せなかった(同期が先に取り出した) | 操作の説明と「すでに照合して取り出したため取り消せません」。この画面の同期が照合した場合は、結果が出たら確定した・確定しなかったの行に変わる。別のタブの同期が照合した場合は結果が届かないため、閉じるまでこの行のまま残る | 閉じる | + +文言はすべて翻訳のファイル(`src/i18n/ja.json`)に置く。 + +## 処理の流れ + +### オンラインの読み出しと保存 + +```mermaid +sequenceDiagram + participant 画面 as 画面と読み直し + participant C as 控えつきの API + participant A as Factory の API + participant V as 控えの保管 + 画面->>C: 登録した読み出し + C->>A: 要求(token つき) + alt 応答が届いた + A-->>C: 応答 + C-->>画面: 応答(控えの完了を待たない) + C->>V: 持ち主を確かめ、暗号化して書く + V-->>C: 失敗しても前の控えを残す + else ネットワークの失敗 + C->>V: 控えを読む(期限と持ち主を確かめる) + V-->>C: 控えか NoCopyError + C-->>画面: 控えと受け取った時刻 + end +``` + +### 通信を切って開き直す + +```mermaid +sequenceDiagram + participant SW as Service Worker + participant M as 画面の起動 + participant L as ログインの枠 + participant V as 控えの保管 + M->>SW: 画面の資産 + SW-->>M: キャッシュから返す + M->>V: 開く(期限切れ・消去の途中なら消す) + V-->>M: 持ち主(利用者の ID・期限) + M->>L: token と持ち主 + L-->>M: token か有効な持ち主があれば描く +``` + +### つながりが戻ったとき + +```mermaid +sequenceDiagram + participant S as 同期 + participant Q as 確定待ちの列 + participant A as Factory の API + participant V as 控えの保管 + S->>S: タブの排他ロックを取る(API が無ければ飛ばす) + loop 確定待ちの操作を押した順に + S->>Q: 1 件を読む(列からは外さない) + S->>A: 対象の今の状態を読む + alt 読めなかった + S->>Q: 列に残す + else 食い違った + S->>Q: 原子的に取り出し、取れたら理由を結果へ + else 同じだった + S->>Q: 原子的に取り出す(読んで消す) + alt 取り出せた + S->>A: 操作を送る + A-->>S: 成功か断り(理由を結果へ) + else 無かった(別のタブか取り消しが先に取り出した) + S->>S: 送らずに次へ + end + end + end + S->>A: 控えにある読み出しを取り直す + S->>V: 応答で置き換え、無い要素を消す + V-->>S: 変更を通知する +``` + +取り直しは、一覧の読み出しは控えにある引数ごとに 1 回、要素ごとの読み出しはアカウントごとに控えにある ID をまとめて 1 回(要素の数が上限 `MAX_ITEMS` を超えるときだけ分ける)送る。応答に無い要素(無くなったループ)の控えは消す。 + +### 確定待ちの操作の状態 + +```mermaid +stateDiagram-v2 + [*] --> 確定待ち: オフラインで押した + 確定待ち --> [*]: 利用者が取り消した + 確定待ち --> 照合中: つながりが戻った + 照合中 --> [*]: 利用者が取り消した(同期より先に取り出せた) + 照合中 --> 確定待ち: 今の状態を読めなかった + 照合中 --> 確定しなかった: 食い違った + 照合中 --> 送信中: 同じだった + 送信中 --> 確定した: 成功した + 送信中 --> 確定しなかった: 断られた・通信が失敗した + 確定した --> [*]: 閉じた・画面を閉じた + 確定しなかった --> [*]: 閉じた・画面を閉じた +``` + +取り出しは、ストア `pending` への 1 つの読み書きのトランザクションで、操作の ID のレコードを読み、あれば消す。IndexedDB は範囲の重なる読み書きのトランザクションを、作られた順に 1 つずつ動かす(STD2、Indexed Database API の「Transaction scheduling」)。このため、2 つのタブが同じ操作を照合しても、レコードを読めて消せるのは先に動いた 1 つだけで、送る権利はその処理だけが得る。利用者の取り消しも同じ取り出しで行う。取り消しが先に取り出せば同期は送らず、同期が先に取り出せば取り消しは効かずに「すでに照合して取り出したため取り消せない」と示す(照合が食い違って送らない場合も同じ)。このため、照合中も取り消しのボタンを止めない。照合の結果は照合した画面の記憶にだけ持ち、端末に書かず、タブの間でも知らせない。別のタブの同期が取り出したときは、取り消したタブには結果が届かないため、取り消せなかったの行は利用者が閉じるまで残る。 + +### 端末の控えの状態 + +持ち主の記録が無い状態から、ログインした利用者の最初の保存で有効になる。有効な控えは、ログアウト・サーバーの 401・期限の時刻・別の利用者の token のいずれかで消える。消すときは、先に消去中のフラグ(`localStorage` の `ndf-factory.copies.revoked`)を書く。次に、持ち主の記録・控えた応答・確定待ちの操作を 1 つのトランザクションで消し、フラグを外す。フラグが残っている間は、開いたときに消し直し、それまで控えを返さない(E10 の失敗の扱い)。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| セキュリティ | 端末の控えは暗号化して保存し、鍵は書き出せない形で持つ(受け入れ条件 9・10) | AES-GCM 256 で暗号化し、レコードの名前を HMAC で作る。鍵は `extractable: false` で作って IndexedDB に置く | 単体テストで、書いたレコードを直列化したバイト列に控えた応答の文字列と token が無いこと、鍵の `exportKey` が `InvalidAccessError` で失敗することを確かめる | +| セキュリティ | token・キー・資格情報の値は控えに入れない(受け入れ条件 9・18) | 登録の表に無い読み出しは控えの保管を呼ばない。token は暗号文の中にも書かない | 前提 3 の読み出しを呼んだ後に控えのレコードが 0 件であることを単体テストで確かめる | +| セキュリティ | 控えはログアウト・401・期限・別の利用者で消す(受け入れ条件 6〜8) | 控えの保管の消去を、ログアウト・API の 401・開いたときと読み書きのたびの期限の判定・期限の時刻のタイマー・持ち主の不一致から呼ぶ | 単体テストで 4 つの契機ごとにストアが空になることを確かめる | +| 性能・拡張性 | 取り直しは画面ごとに 1 回の配列の要求にまとめる(受け入れ条件 5) | 要素ごとの読み出しを、アカウントごとに控えにある ID をまとめて 1 回で送る | 単体テストで要求の回数と ID の並びを数える | +| 性能・拡張性 | オンラインのときの表示の速さは今と変わらない | ネットワークを先に読み、控えへの書き込みを待たずに応答を返す | 単体テストで、控えの書き込みが終わらないままでも読み出しが値を返すことを確かめる | +| システム環境 | 対象のブラウザは、e2e が使う Chromium と、360・768 px の幅の表示 | E2E の 3 つの幅のプロジェクトで、ダミーのビルドを配って通信を切る | `npm run e2e -w web` | + +## 決定の記録 + +決定の記録は [issue-2383-design-decisions.md](issue-2383-design-decisions.md) に置く。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 1 | E2E: スプリントの一覧・詳細・承認を待つ依頼の一覧を開いた後、通信を切ってページを読み込み直すと、同じ中身が出る | Service Worker の登録を外す / 控えに書かないようにする | +| 2 | E2E: ループの一覧と詳細で 1 と同じ | ループの読み出しを登録の表から外す | +| 3 | E2E と単体: 控えから出した画面に受け取った時刻が利用者の時刻帯で出て、つながりの状態が最後に受け取った時刻を出す | 受け取った時刻を読み直しへ渡さない / つながりの最後に受け取った時刻を null のままにする | +| 4 | E2E: 開いていない画面を通信を切って開くと「まだ受け取っていません」が出て「0 件」が出ない | 控えが無いときに空の配列を返す | +| 5 | 単体: つながりが戻ると、控えにある詳細の ID をまとめた 1 回の要求が出て、控えと画面がサーバーの応答にそろう | ID ごとに要求を送る / 取り直しを呼ばない | +| 6・I4 | 単体: token の無い状態でオフラインなら期限の前は控えを読め、期限の時刻を過ぎると読めずストアが空になる | 期限を判定しない / token の有無で読めるかを決める | +| 7 | 単体: ログアウトでストアが 3 つとも空になる | ログアウトから消去を呼ばない | +| 8・I3 | 単体: サーバーの 401 と、持ち主と別の利用者の token での書き込みで、ストアが空になる | 401 で token だけを消す / 持ち主を比べない | +| 9・I1 | 単体: 書いたレコードを直列化したバイト列に、控えた応答の実行の ID・リポジトリ名と token が現れない | 暗号化を外す / レコードの名前に読み出しの指し方の平文を使う | +| 10・I2 | 単体: 保存した鍵の `exportKey` が `InvalidAccessError` で失敗する | 鍵を `extractable: true` で作る | +| 11 | 単体と E2E: オフラインで 4 つの操作を押すと API を呼ばず、「つながってから確定します」と確定待ちの数が出る | オフラインでもその場で送る / ボタンを押せないままにする | +| 12 | 単体: 確定待ちの操作を取り消すと、つながった後に送られない | 取り消しが列から外さない | +| 12・I9 | 単体: 照合中に取り消すと、先に取り出した側だけが効く。取り消しが勝てば送らず、負ければ取り消せなかったの行を出し、操作は 1 回だけ送られる。取り消せなかったの行は閉じると消える | 取り消しが取り出さずにレコードを消す / 負けた取り消しを取り消し済みと示す | +| 13・I8 | 単体: 照合で同じなら送り、食い違えば送らずに理由を結果に出す(4 つの操作ごと) | 照合をせずに送る / 食い違いの理由を捨てる | +| 14 | 単体: 照合の読み出しがネットワークか 5xx で失敗すると送らず、列に残る | 読み出しの失敗を「同じ」と扱う / 列から外す | +| 15 | 単体: オンラインで 4 つの操作を押すと、今と同じくその場で 1 回送る | オンラインでも確定待ちにする | +| 16・I7 | 単体: 控えの書き込みが失敗しても、読み出しは応答を返す | 書き込みを待って失敗を投げる | +| 17 | 単体: オンラインの間、読み直しの間隔と回数が今と同じで、控えを先に読まない | 控えを先に返してからネットワークを読む | +| 18・I6 | 単体: 前提 3 の読み出し(アカウント・席・モデルの資格情報・委任・リポジトリの候補・工程の中身)を呼んだ後、控えのレコードが 0 件 | 登録の表に無い読み出しも控える | +| I5 | 単体: 控えの保管に、応答で置き換える操作と消す操作のほかに書き換える操作が無い。取り直しの応答で控えが置き換わる | 確定待ちの操作の結果で控えを書き換える | +| I9 | 単体: Web Locks API の無い条件で、2 つの同期を同時に走らせても、1 つの確定待ちの操作は 1 回だけ送られる。送る要求が失敗しても列に戻らない | 送った後に列から外す / 読む処理と消す処理を別のトランザクションに分ける / 排他ロックだけに頼る | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2383 | 実装する | — | `factory/web/src/offline/`、`factory/web/src/connection.ts`、`factory/web/src/poll.ts`、`factory/web/src/auth.ts`、`factory/web/src/api.ts`、`factory/web/src/context.tsx`、`factory/web/src/main.tsx`、`factory/web/src/ui/`、`factory/web/src/pages/`、`factory/web/src/dummy/`、`factory/web/src/i18n/ja.json`、`factory/web/vite.config.ts`、`factory/web/playwright.config.ts`、`factory/web/e2e/`、`factory/web/package.json`、`factory/package-lock.json`、`factory/web/.gitignore` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 1. 期限が token の有効期限(Cognito の既定で 1 時間)で足りるか | 要求の未決 1。利用者が承認ゲート 1 で決める。足りなければログインの延長(C2)を別の課題にする | +| 2. Playwright の通信の切断で `navigator.onLine` が偽になるか | 未確認。偽にならなければ、ダミーの API はネットワークの失敗と同じ誤りを投げ、つながりは要求の失敗からオフラインになる(決定 10)。E2E の確かめ方は `tdd-cycle` で走らせて決める | +| 3. 照らして同じなら人の再確認なしで確定してよいか | 要求の未決 2。この設計は前提 6・7 のとおり再確認なしで確定する。利用者が承認ゲート 1 で決める | +| 4. スプリントの実行を止めるボタンが画面に無い | 前提 5 は「今の画面にある」とするが、呼び出し元は 0 件(あるべき姿の根拠の 5 行目)。この設計は登録の表に載せて控えつきの API で扱い、ボタンは足さない | +| 5. #2173 の受け入れ条件 18(オフラインでは押せない)との食い違い | 要求の未決 3。利用者が #2173 の設計の前に決める | +| 6. Web Locks API の iOS の Safari での対応 | 未確認(外部の調査の上限)。無いブラウザでは 2 つのタブが同時に照合しうる。二重の送信は I9 の原子的な取り出しで防ぐため、残るのは照合の読み出しが重なる分の API の呼び出しだけである | +| 7. 端末で同じブラウザを開ける他人は、期限までは控えを読める | 鍵を端末に置くため(決定 3)。暗号化が守るのは、保存のファイル・バックアップ・ほかのアプリからの読み取りである | +| 8. 画面の資産の配信が Service Worker の更新を遅らせないか | 配信の設定は private のリポジトリにあり、この変更では見ていない。新しい版の Service Worker は、開いているタブをすべて閉じた後に効く(決定 7) | +| 9. iOS の Safari と Android の Chrome の実機での確認 | リリース後テストで、一覧と詳細を開いた後に機内モードにし、開き直して読めること・受け取った時刻が出ることを確かめる | diff --git a/issues/issue-2383-requirements.md b/issues/issue-2383-requirements.md new file mode 100644 index 000000000..12a8ee57e --- /dev/null +++ b/issues/issue-2383-requirements.md @@ -0,0 +1,193 @@ +# #2383: NDF Factory: つながらないときも、最後に受け取ったスプリントの一覧・詳細・承認資料を端末で読める + +正は課題の本文(#2383)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +親: #2168(Neural Development Factory)。方針: マイルストーン「25 Neural Development Factory」のスプリント MVV の Value 3(2026-11-02)。前提: #2382(UX の土台。閉じた)。この課題の仕組みを使う課題: #2173(承認ゲートの通知と Web からの承認。前提 10 で「端末への保存と同期の中身は #2383 が持つ」と決めている)。 + +## 具体例 + +利用者が移動中にスマートフォンで承認の通知(#2173)を受け、電波の届かない場所で開く。今の画面は API が返さなければ何も表示しない。この課題の後は、最後に受け取ったスプリントの詳細と承認資料を読める。承認のボタンは「つながってから確定する」状態を示し、つながったときにサーバーの今の状態(承認ゲートがまだ待っているか)と照らしてから確定する。 + +## 依頼(原文) + +> 3. **どこでも、つながらなくても使える**: スマートフォンとタブレットで操作できる。つながらないときも、最後に受け取ったスプリントの一覧・詳細・承認資料を読め、つながったときに食い違いなくそろう。端末に残すデータは暗号化して保存する。承認ゲートの承認のように取り消せない操作は、オフラインでは確定させず、つながってからサーバーの今の状態と照らして確定する。 + +(書き手: 起票者、日付: 2026-11-02。マイルストーン「25 Neural Development Factory」のスプリント MVV の Value 3。スプリント MVV は利用者が承認済みのため、利用者の決定として扱う) + +> | 観点 | 中身 | +> | --- | --- | +> | 読めるもの | スプリントの一覧・詳細・承認資料・通知(最後に受け取った時点のもの。受け取った時刻を表示する) | +> | 端末の保存 | 暗号化して保存する。ログアウトと期限切れで消す。秘密(キー・token の値)は端末に置かない | +> | 書く操作 | 取り消せない操作(承認ゲートの承認・ループの開始・止める)はオフラインで確定しない。つながってから今の状態と照らして確定し、食い違えば理由を示して確定しない | +> | 同期 | つながったときに差分を取り直し、食い違いなくそろえる。API の入出力が配列である前提で、まとめて取り直す | +> +> 1. 通信を切った状態で、最後に受け取った一覧・詳細・承認資料が表示される(E2E) +> 2. 端末に保存したデータが暗号化されていることを確かめるテストがある +> 3. オフラインで押した承認は確定せず、つながった後にサーバーの状態と照らして確定する。承認ゲートがすでに閉じていれば確定しない +> 4. ログアウトで端末の保存が消える + +(書き手: 起票者、日付: 2026-11-02。この課題の旧本文の「何をするか」と「受け入れ条件(案)」。Value 3 から起票者が書き起こした) + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文(Value 3)に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 端末で読めるものに「通知」を含める | 旧本文の「何をするか」。通知は #2173 が作る | 前提 2(通知は #2173 が同じ仕組みへ載せる) | +| S2 | 端末の保存をログアウトと期限切れで消す | 旧本文の「何をするか」 | 受け入れ条件 7・8 | +| S3 | 秘密(キー・token の値)を端末の保存に置かない | 旧本文の「何をするか」。共通原則の C1 | 受け入れ条件 9 | +| S4 | 取り消せない操作に「ループの開始」と「止める」を含める | 旧本文の「何をするか」 | 前提 5・受け入れ条件 11〜14 | +| S5 | 照らした結果が食い違えば、理由を示して確定しない | 旧本文の「何をするか」 | 受け入れ条件 13 | +| S6 | 取り直しは配列の入出力でまとめて行う | 旧本文の「何をするか」とスプリント Value 2 | 受け入れ条件 5 | +| S7 | 画面ごとに受け取った時刻を表示する | 旧本文の「何をするか」 | 受け入れ条件 3 | +| S8 | オフラインの間に押した操作は、端末に残して、つながったときに確定する(押せないようにするのではない) | 旧本文の受け入れ条件(案)3 の「オフラインで押した承認」と、つながりの状態の「確定待ちの数」(#2382 が置いた表示) | 前提 6・受け入れ条件 11 | +| S9 | 端末の保存は、ログインの期限までなら、token の無いタブ(通知から開いた新しいタブ)でもオフラインのときに読める | この要求を書いた AI の推定。具体例の「通知を受けて電波の届かない場所で開く」を満たすため | 前提 4・受け入れ条件 6 | + +## 目的 + +- 利用者が電波の届かない場所でも、最後に受け取ったスプリントの一覧・詳細・承認を待つ依頼を読み、承認の判断の材料をそろえられる状態にする +- オフラインの間に押した取り消せない操作が、サーバーの今の状態と食い違ったまま確定しない状態にする +- 端末に残したデータを、端末を手にした他人が読めず、ログアウトと期限切れで残らない状態にする + +## 前提 + +- 前提 1: 端末の控え(下の「用語」)に入れるのは、この課題を実装する時点で画面が読んでいる次の応答である。スプリントの一覧(`listSprintRuns`)・スプリントの詳細(`getSprintRunDetail`)・承認を待つ依頼の一覧(`listApprovalRequests`)・ループの一覧と詳細(`listLoops`・`getLoopDetail`)。承認資料のうち今ある部分は、スプリントの詳細の承認ゲートの状態・工程・Pull Request と、承認を待つ依頼の一覧である +- 前提 2: #2173 が作る承認資料(設計文書の節・ゲート 2 の中身の行)と通知の一覧は、この課題の仕組みへ読み出しの操作を登録することで端末の控えに入る。登録とそのテストは #2173 が持つ。この課題は、読み出しの操作を 1 行の登録で控えに足せる形を用意する +- 前提 3: 端末の控えに入れない応答: アカウント・席・モデルの資格情報・委任の資格情報・リポジトリの候補・工程の中身(`getStepDetail`)。前の 4 つは秘密か個人情報に近く、工程の中身は 1 件が大きく、承認の判断に要る材料はスプリントの詳細で足りるため +- 前提 4: 端末の控えの保存の期限は、控えを作ったときのログインの token の有効期限の時刻(token の `exp`)である。期限の時刻と利用者の ID は秘密ではないため、控えとともに残す。期限の前なら、token の無いタブでもオフラインのときに控えを読める。期限を過ぎた控えは表示せずに消す。ログインの延長(refresh token)は作らない +- 前提 5: 「取り消せない操作」は、今の画面にある次の 4 つである。ループの人の判断待ちへの答え(`answerLoop`。承認と差し戻しのボタン `ApprovalActions` を使う)・ループの開始(`startLoop`)・ループを止める(`cancelLoop`)・スプリントの実行を止める(`cancelSprintRun`)。#2173 の承認ゲートの答えは、#2173 がこの仕組みへ載せる +- 前提 6: オフラインの間に取り消せない操作のボタンを押すと、操作は確定待ちの操作として端末の控えに残り、画面は「つながってから確定します」と確定待ちの数を出す。確定させるのは、画面を開いていて、つながりが戻ったときだけである。画面を閉じている間に確定することはない(Service Worker の背景の同期は使わない) +- 前提 7: つながりが戻ったとき、確定待ちの操作ごとに、操作の対象の今の状態をサーバーから読み、押したときに画面が見ていた状態と同じときだけ確定する。比べる状態は操作ごとに決め、少なくとも次を含む。判断待ちへの答えは「同じ周回の同じ課題が、まだ答えを待っている」。止める操作は「対象がまだ終わっていない」。ループの開始は「同じ名前のループが動いていない」 +- 前提 8: オフラインで画面を開き直しても読めるように、画面の資産(HTML・JavaScript・CSS・翻訳のファイル)を Service Worker で端末に置く。API の応答は Service Worker の控えに置かず、暗号化した端末の控えにだけ置く +- 前提 9: 取り直しで食い違ったときは、サーバーの応答で控えを置き換える。端末の側の変更は確定待ちの操作だけで、控えの読み出しの結果を端末で書き換えることはない + +## 対象範囲 + +含む: + +- 読み出しの応答を、暗号化して端末に残す仕組み(端末の控え)と、そこへ読み出しの操作を登録する形 +- オフラインの間に、前提 1 の画面を端末の控えから表示し、受け取った時刻を出すこと +- つながりの状態の「最後に受け取った時刻」と「確定待ちの数」を、実際の値で出すこと(`factory/web/src/connection.ts` の `browserConnection` は今 online だけを持つ) +- つながりが戻ったときの、控えの取り直しと、確定待ちの操作の照合と確定 +- ログアウト・ログインの期限切れ・別の利用者のログインで、端末の控えと確定待ちの操作を消すこと +- 画面の資産をオフラインで読み込めるようにすること(前提 8) +- 前提 5 の 4 つの操作を、確定待ちの操作として扱うこと + +含まない: + +- #2173 の承認資料・通知・承認ゲートの答えを控えへ登録すること(前提 2・前提 5。#2173 が持つ) +- 前提 3 の応答を控えに入れること +- 取り消せる操作(リポジトリの登録・予算の変更など)のオフラインでの受け付け。今のとおり、オフラインでは押せない +- 画面を閉じている間の確定(前提 6)と、ブラウザの押し出しの通知 +- ログインの延長(refresh token)と、ログインの仕組みの変更 +- API の変更(読み出しは今の配列の入出力をそのまま使う) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 読み出しの応答を受け取り、端末の控えへ暗号化して残した | 登録した読み出しの操作の成功 | 暗号化か保存に失敗したら、画面は応答を表示し、控えは前のまま残す(受け入れ条件 16) | ログインしている | +| E2 | つながりが切れた | ブラウザの offline の事象、または API への要求のネットワークの失敗 | — | — | +| E3 | 端末の控えから画面を表示した | オフラインで前提 1 の画面を開いた | 控えが無い・期限切れなら「まだ受け取っていません」を出す(受け入れ条件 4) | E1 | +| E4 | 取り消せない操作を確定待ちとして残した | オフラインで前提 5 のボタンを押した | 残せなければ押せなかったことを出す | E3 | +| E5 | 確定待ちの操作を取り消した | 利用者が確定待ちの操作を取り消した | — | E4 | +| E6 | つながりが戻った | ブラウザの online の事象 | — | E2 | +| E7 | 端末の控えを取り直した | E6 | 取り直しが失敗したら控えを残し、受け取った時刻を変えない | E6 | +| E8 | 確定待ちの操作を今の状態と照らした | E6 | 読み出しが失敗したら確定せず、確定待ちのまま残す(受け入れ条件 14) | E4・E6 | +| E9 | 確定待ちの操作を確定した、または食い違いの理由を示して確定しなかった | E8 の結果 | 確定の要求が失敗したら、失敗の理由を示して確定待ちから外す | E8 | +| E10 | 端末の控えと確定待ちの操作を消した | ログアウト・API の 401・期限の時刻を過ぎた・別の利用者のログイン | 消せなかったら、控えを表示しない | — | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 端末の控え | 画面が最後に受け取った読み出しの応答を、暗号化して端末に残したもの。受け取った時刻と保存の期限を持つ | +| 確定待ちの操作 | オフラインの間に押した取り消せない操作で、つながったときに今の状態と照らしてから確定するもの | + +## 受け入れ条件 + +オフラインで読む: + +- [ ] 1. スプリントの一覧・詳細・承認を待つ依頼の一覧を一度開いた後、通信を切って画面を開き直すと、最後に受け取った中身が表示される(E2E。Playwright で通信を切り、ページを読み込み直す) +- [ ] 2. ループの一覧と詳細も、1 と同じく通信を切った後に表示される(E2E) +- [ ] 3. 端末の控えから表示した画面には、その応答を受け取った時刻が利用者の時刻帯で表示され、つながりの状態の表示が「最後に受け取った時刻」を実際の値で出す +- [ ] 4. 一度も開いていない画面を通信を切って開くと、「まだ受け取っていません」の文が出て、空の一覧を「0 件」として出さない +- [ ] 5. つながりが戻ると、端末の控えにある応答を取り直し、画面とその表示がサーバーの応答と同じになる。スプリントの詳細とループの詳細は、控えにある ID をまとめた 1 回の要求で取り直す + +端末の控えの守り: + +- [ ] 6. 期限の時刻の前なら、token の無いタブでも、オフラインのときに同じ利用者の控えを読める。期限の時刻を過ぎた控えは表示されず、消える +- [ ] 7. ログアウトすると、端末の控えと確定待ちの操作がすべて消える +- [ ] 8. API が 401 を返したとき、または控えと別の利用者がログインしたとき、端末の控えと確定待ちの操作がすべて消える +- [ ] 9. 端末に書いたバイト列の中に、応答の平文(実行の ID・リポジトリ名など、控えた応答に含まれる文字列)と、token の値が現れないことを確かめるテストがある +- [ ] 10. 暗号化の鍵は端末から書き出せない形で持ち、書き出しを試みると失敗することを確かめるテストがある + +取り消せない操作: + +- [ ] 11. オフラインで前提 5 の操作のボタンを押すと、操作は確定せず(API へ要求を送らず)、画面に「つながってから確定します」と確定待ちの数が出る +- [ ] 12. 確定待ちの操作は、つながる前なら利用者が取り消せ、取り消したものは確定しない +- [ ] 13. つながりが戻ると、確定待ちの操作ごとに対象の今の状態を読み、押したときと同じなら確定する。食い違えば(例: 判断待ちがすでに答えられている・ループがすでに止まっている)確定せず、食い違いの理由を画面に出す +- [ ] 14. 今の状態の読み出しが失敗したときは確定せず、確定待ちのまま残す +- [ ] 15. オンラインのときの前提 5 の操作は、今と同じくその場で確定する(退行しない) + +退行しない: + +- [ ] 16. 端末の控えへの保存が失敗しても、オンラインの画面は今と同じく応答を表示する +- [ ] 17. オンラインの間、画面の表示と読み直しの間隔(`usePolled`)は今と変わらない +- [ ] 18. 前提 3 の応答(委任の資格情報の値・モデルの資格情報など)は端末の控えに入らないことを確かめるテストがある + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| セキュリティ | 端末の控えは暗号化して保存し、鍵は書き出せない形で持つ(受け入れ条件 9・10)。token・キー・資格情報の値は控えに入れない(受け入れ条件 9・18)。控えはログアウト・401・期限・別の利用者で消す(受け入れ条件 6〜8) | +| 性能・拡張性 | 取り直しは画面ごとに 1 回の配列の要求にまとめる(受け入れ条件 5)。オンラインのときの表示の速さは今と変わらない | +| システム環境 | 対象のブラウザは、e2e が使う Chromium と、スマートフォン・タブレットの幅(360・768 px)の表示。iOS の Safari の実機での確認はリリース後テストで行う | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。API と契約(`factory/contracts/`)は変えない | +| データ | 端末(ブラウザ)に新しい保存を足す。サーバーのデータは変わらない | +| 既存の振る舞い | オフラインのとき、画面が空ではなく端末の控えを表示する。前提 5 の操作がオフラインで押せるようになり、確定待ちになる。#2173 の受け入れ条件 18(オフラインでは押せない)は、この課題の後は確定待ちの振る舞いに合わせる必要がある(未決 3) | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| 起動 | `npm run dev:dummy -w web`(`factory/` で打つ) | +| テスト | `npm run check -w web`(型検査・単体テスト・直に書いた文言の検査・ビルド) | +| E2E | `npm run e2e -w web`(Playwright の Chromium。通信を切る操作は Playwright のブラウザの文脈の offline で行う) | +| 手動確認 | リリース後テストへ回す: スマートフォン(iOS の Safari と Android の Chrome)で一覧と詳細を開いた後に機内モードにし、画面を開き直して読めること、受け取った時刻が出ること | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 変更は `factory/web/` に置く。`plugins/` と `factory/api/`・`factory/contracts/` は変えない(`factory/README.md`・`scripts/check-service-boundary.py`) | +| コーディング規約 | 画面の文言は翻訳のファイルに置く(`npm run check:source`)。ダミーは本番のビルドに入れない(`npm run check:bundle`) | +| テスト戦略 | 暗号化・期限・消去・照合の規則は単体テスト(vitest)、オフラインでの表示は E2E(Playwright)で確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | `npm run check -w web` と `npm run e2e -w web` の実行 | +| 確認してから行う | ログアウトの処理(`factory/web/src/auth.ts`)の変更。`factory/` の認証と token はレッドラインに当たるため、承認ゲート 2 で人が見る。依存パッケージの追加(Service Worker の生成など)は理由と代わりの案を設計に書く(スプリント Value 6) | +| 行わない | API・契約の変更、ログインの仕組みの変更、#2173 の画面の実装 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 前提 4 の期限が token の有効期限(Cognito の既定で 1 時間)のとき、具体例の「移動中に読む」に足りるか。足りなければ、ログインの延長(C2 に当たる)を別の課題にするか | 利用者 | 承認ゲート 1 | +| 2. 前提 6 で、オフラインで押した操作を、つながったときに人の再確認なしで確定してよいか(照らして同じなら確定する) | 利用者 | 承認ゲート 1 | +| 3. #2173 の受け入れ条件 18(オフラインでは押せない)を、この課題の確定待ちの振る舞いへ合わせるか | 利用者(#2173 の設計の前に) | 承認ゲート 1 | +| 4. 端末の控えの置き場(IndexedDB など)・暗号の方式・Service Worker の作り方 | 設計(`design`) | 設計 PR | + +## 関連 + +- 前提: #2382(UX の土台。見せ方。閉じている)。つながりの状態の表示は `factory/web/src/ui/ConnectionStatus.tsx` と `factory/web/src/connection.ts` にあり、ブラウザの実装は online だけを持つ。最後に受け取った時刻と確定待ちの数を作るのはこの課題 +- #2173(承認と通知) diff --git a/issues/issue-2505-design-decisions.md b/issues/issue-2505-design-decisions.md new file mode 100644 index 000000000..1a0dbaf48 --- /dev/null +++ b/issues/issue-2505-design-decisions.md @@ -0,0 +1,196 @@ +# 開発ループの上限の撤廃の決定の記録(#2505) + +[issue-2505-design.md](issue-2505-design.md) から分けた節である。決定 11〜15 は承認ゲート 1 で利用者が決めた項目で、ここには答えの後の形を書く。答えは設計文書の「利用者が決めたこと」にある。 + +## 決定の記録 + +### 決定 1: 暴走止めを再起動ループの検出 1 つにするため、1 日の起動回数の数えと、数えを守る排他を消す + +ラッパーの次の区間の可否は、停止シグナルファイルと再起動ループの検出だけで決める。起動回数の数え(`count_today`)を消す。数えてから `start` を書くまでの排他(`count.lock`)も、数えのためだけにあるので一緒に消す。判定の型の名前(`StartLimit`)は中身に合わせ `SpinGuard` に改める。`NDF_RELAY_MAX_STARTS` は読まない。設定した環境でも誤りにしない。 + +値を増やす形は採らない。利用者の環境の暫定の 100000 と同じく、上限の意味を失った数が残るためである。 + +根拠: Vision / Value 2 / Value 5(MVV 版 5) + +### 決定 2: 周回の数と時間は「書いたときだけ効く」に揃えるため、無制限を `null` で表す + +宣言の既定を周回の数 `null`・経過時間 `null` にする。宣言に書かない場合と `null` を書いた場合は同じ意味で、ループの記録にも `null` が入る。判定は 1 つの関数(`over_budget(used, limit)`。`limit` が `null` なら偽)だけに置き、境の判定と再開の判定の両方がこれを呼ぶ。 + +| 採らない形 | 理由 | +| --- | --- | +| 無制限を大きな数で表す | このリポジトリの 1000 周・10000 時間は無制限を書けないための代用で、値から意図が読めない | +| `--budget-*` へ `none` のような語を足す | `default` で宣言へ戻せば足り、Factory の受け渡しの形も変えずに済む | + +根拠: Value 3 / Value 5(MVV 版 5) + +### 決定 3: 待ちを抱えたまま進むため、保留の周回の数を判定から消し、選べる課題が尽きたときの待ちだけで止める + +周回の境から保留の周回の数の判定を消す。保留の周回の課題は今も選定から外れる。保留が増えると選べる課題が減り、尽きれば今の `waiting_only`(`no_selectable`)で止まる。止まる理由に `release_pending_max` が出る経路は無くなる。宣言の `loop.release_pending_max` は読まない。不正な値が書かれていても断らない(要求の前提 6)。 + +根拠: Vision / Value 2(MVV 版 5) + +### 決定 4: 実装のステップの進みを手元で確かめられる信号で見るため、会話の記録の伸び・進捗ログの行・コミットの増加の 3 つを見る + +進みとして数えるのは、次のどれかである。 + +| 信号 | 読み方 | 実測 | +| --- | --- | --- | +| 会話の記録の伸び | 実装のステップの `claude -p` に `--session-id` を渡して会話を残し、その記録の大きさを見る | 2026-11-06 の実測で、起動から 6 秒以内に作られ、メッセージのたびに伸び、20 秒の無音のコマンドの間は伸びなかった。`--no-session-persistence` を付けると作られなかった | +| 進捗ログの行 | worker が進捗ログへ書く行 | プランの実行 37 本で、行の間隔の最大は中央値 954 秒・最大 6479 秒。単独では許容時間に使えない | +| コミットの増加 | 作業場所の HEAD までのコミットの数 | 遅れの見張りが同じ値を使っている | + +今の実装のステップは `--no-session-persistence` で起動するため、会話の記録が無い。実装のステップに限ってこの引数を外し、`full` のステップと同じく会話の id を決める。 + +| 採らない信号 | 理由 | +| --- | --- | +| 標準出力 | `--output-format json` は完了まで何も出さない。`stream-json` へ切り替えると 1 行ずつの読み取りが要り、待ちの部品(`run_ticking`)の書き換えが要る。得られる粒度はメッセージ単位で、会話の記録と同じである | +| worktree の変更の監視 | 編集のたびに会話の記録が伸びるため、足しても新しい情報が無い | +| 子プロセスの CPU 時間(psutil で読める) | テストの待ち合わせと固まった状態を区別できず、無音のコマンドの長さは許容時間で受けられる(決定 7) | + +- 作り方: 自前で作る +- 比べた候補: PRD2 / PRD3 / PRD6 / LIB1 + +根拠: Value 4 / Value 5 / Value 6(MVV 版 5) + +### 決定 5: 打ち切りの経路を 1 つにするため、既存の締め切りの延長で無進捗の締め切りを動かし、無進捗の打ち切りのステップでは遅れの見張りが打ち切らない + +待ちの部品の `extend`(#2047 で駆動の締め切りを延ばすために入れた)を使い、締め切りを「最後の進みの観測 + 許容時間」へ動かす。打ち切りは今の時間切れと同じ経路(`subprocess.TimeoutExpired` → `ClaudeCall` の失敗 → `on_fail` と判定役)を通り、打ち切りの理由だけを記録で分ける。 + +遅れの見張りは、確認の回数が上限に達すると判定役へ回す。判定役は進んでいるステップにも `retry` / `stop` を返せる。これは「進み続けている間は打ち切らない」に反する。そのため `stall` を持つステップでは見張りを始めない形にし(`off` で始める)、所要の履歴だけを残す。 + +根拠: Value 5 / Value 7(MVV 版 5) + +### 決定 6: 既定で打ち切られないようにするため、実装の上限を「書いたときだけ効く」にし、実績からの見込みを消す + +`--impl-timeout` と `.ndf/supervise.json` の `impl_timeout` は書いたときだけ経過時間の上限として効く。書かなければ経過時間の上限は無い。ループの予算と同じ規則であり、書いたプロジェクトが選んだ止まり方を奪わない。 + +実績からの見込み(`impl_timeout_estimate` の履歴の経路と `estimate_predict.impl_limit`)は消す。打ち切りを決めなくなると読む者が無いためである。プランの `estimate` の欄と、`new` の結果の `impl_timeout_source`・「未校正」の文も一緒に消す。遅れの見張りの履歴は別のもので残す。 + +根拠: Value 3 / Value 5(MVV 版 5) + +### 決定 7: 誤って打ち切らず、固まった実装を半時間で止めるため、無進捗の許容時間の既定を 1800 秒にする + +`full` のステップの会話の記録 313 本で、連続する記録の間隔の最大は中央値 57 秒・99 パーセンタイル 590 秒・最大 706 秒だった。900 秒以上は 0 本である。Bash の前景の実行の上限 600 秒に近い値で頭打ちになっている。 + +誤った打ち切りは数時間分の実装を捨てる。待ちすぎの費用は LLM を使わない 15 分である。そのため実測の最大 706 秒を十分に上回る 1800 秒を既定にする。プロジェクトは `impl_stall_seconds` か `--impl-stall` で変える。 + +会話の記録が読めないとき(起動から 60 秒たっても記録が見つからないとき)は、進捗ログの行とコミットだけを見て、許容時間を 7200 秒にする。実測の行の間隔の最大 6479 秒を上回る値である。読めない場合に打ち切らない形は、固まった worker が無限に残るため採らない。 + +根拠: Value 4(MVV 版 5) + +### 決定 8: Codex などのプロジェクトでも同じ規則で動くため、外部のランタイムは監視の無進捗の許容へ渡し、監視の上限を「無し」にできるようにする + +外部のランタイムの実装のステップは、`external-ai.py run` に `--stall-timeout <許容時間>` を渡す。`--timeout 0` で経過時間の上限を外す。監視は既にログの大きさで無進捗を見ている。監視の `--timeout 0` は「経過時間の上限が無い」の意味とし、外側の待ちも `timeout` を渡さない。 + +`external-ai.py` は無進捗の打ち切りを結果の `items[0].result` の `stalled` で返す。`call_worker` は今 `status` だけを読むので時間切れと区別できない。この値が `stalled` のとき、Claude のステップと同じ `stalled` の行とステップの結果の `stalled` を書く。行の `last_signal` は `monitor_log`、`last_at` は打ち切りの時刻から許容時間を引いた時刻とする。監視の結果の形は変えない。 + +agy の `--print-timeout` は CLI の必須の上限で、外せない。工程 `implement` の値のまま残し、残リスクとして書く。 + +- 作り方: 自前で作る +- 比べた候補: PRD2 / PRD6 + +根拠: Value 6(MVV 版 5) + +### 決定 9: 開発ループを止めずに値を見直せるため、実行環境の調査は読むだけの部品にし、ループの開始と並列の計画の開始で必ず通す + +部品(`lib/run_environment.py`)は動く場所・使える CPU・メモリ・レーンの数と動いているプランの数を読み、候補を返す。ファイルへ書かない。`sprint-state.py loop environment` が単独で出力し、`loop start` と `parallel.py start` が同じ部品を呼んで結果に載せる。調査は 1 秒未満で LLM を使わないため、時点を絞る理由が無い。毎回通せば「実行環境が変わったとき」も同時に満たす。指紋を保存して変化を検知する形は採らない。 + +候補と今の値が違うとき、結果の 1 行とループの記録の `environments` に残して進む。宣言へ書くのは人で、変更は通常の Pull Request として承認ゲートを通る(C7)。読めなければ `unreadable` を載せて進む。 + +| 値 | 読み方 | +| --- | --- | +| CPU | `sched_getaffinity` と cgroup の `cpu.max` の小さい方 | +| メモリ | `/proc/meminfo` の合計と cgroup の `memory.max` の小さい方 | +| レーンの数(`parallel.py start`) | 開始する計画の `plan.json` の `lanes` の数。この時点ではレーンがまだ動いていないため、計画の値を引数で部品へ渡す | +| レーンの数(`loop start`・`loop environment`) | `.ndf/parallel/` の計画のうち、耐久ワークフローの状態(`parallel_lane.run_status`)が動いているものの `lanes` の数の和。無ければ 1 | + +レーンの数の読み先を計画にするのは、ラッパーの一覧には計画と関係のないラッパーと一時レーンが混ざり、Factory の実行ジョブではハブが動かないためである。 + +`os.cpu_count()` はマシン全体の数を返し、コンテナの制限を見ない。NDF のスクリプトは Python 3.10 で動くため `os.process_cpu_count()`(3.13 以降)は使わない。 + +- 作り方: 自前で作る・ライブラリやサービスを使う +- 比べた候補: STD1 / STD2 / LIB1 + +根拠: Value 4 / Value 6 / C7(MVV 版 5) + +### 決定 10: 実測のある 1 点から比で伸縮できるよう、候補の数を 8 コア・30 GB でプラン 6 本の比から導く + +| 候補 | 式 | +| --- | --- | +| プランの本数の合計 | `max(1, min(floor(コア × 6 / 8), floor(メモリ GiB × 6 / 30)))` | +| 同時に動かすラッパーの数 | `max(1, floor(プランの本数の合計 / 2))` | +| 1 周回で選ぶ課題の数 | `max(1, floor(プランの本数の合計 / max(1, レーンの数)))` | + +実測の 8 コア・30 GB でプラン 6 本(2026-09-25)を起点にする。ラッパーの数は今の既定の比(3 / 6)を保つ。課題の数は、1 周回が課題ごとに 1 本のプランを同時に流す(周回 19 の 3 課題で設計のプランが 3 本並んだ)ため、レーンの数で割る。レーンの数の読み先は決定 9 の表にある。3 レーンの計画の `parallel.py start` では 2(6 / 3)が候補になる。式は候補を出すだけで、人の読める量(Value 2)はこの式に入らない。候補の理由の行に「承認で読める量は別に見る」と添える。 + +根拠: Value 2 / Value 4(MVV 版 5) + +### 決定 11: 周回と時間と同じ規則にそろえるため、費用の予算を書いたときだけ効く形にし、周回の費用を使用量の帳簿から導く + +費用の予算の既定は上限なしにする(利用者の答え、2026-11-06。案は「既定 60 ドルを残す」だった)。値を書いたときの判定は変えず、`null` も受ける。歯止めは同じ失敗の回数と利用プランの上限である。このリポジトリの `.ndf/pace.json` の 200 ドルは宣言なのでそのまま効く。周回を締めるときの費用は、使用量の帳簿の和とスプリントの報告の和の大きい方にし、内訳の `source` に採った方を残す。帳簿の和は Claude の行を周回のプランのパスで絞ったもので、cross-review・MVV 判定を含む。Codex と Kiro は USD を持たないため和に足さず、周回の費用の内訳へ呼び出しの数とトークンを残す。帳簿を読めないときは報告の和を使う。 + +帳簿の和だけにしないのは、Factory の実行ジョブの再開で帳簿の行が欠けるためである。再開は新しいコンテナで動き、帳簿はホームの下にあって保存と復元の対象(`factory/runner/src/state.ts` の `STATE_PATHS`)に入らない。スプリントの報告は `.ndf/` の下にあり復元される。帳簿を読めても行が欠けた小さい和になるので、大きい方を採って今の費用の数えより小さくならないようにする。 + +conductor の会話は和に入れない。周回を締める時点で conductor の会話を指す手段が無いためである。ラッパーの記録の `from_session` は区間が終わった後に書かれ、動いている区間の会話は載らない。 + +[費用の実測](#費用の実測) に 1 周回の内訳がある。 + +根拠: Value 3 / Value 4(MVV 版 5) + +### 決定 12: 同じ失敗の費用を止めるため、同じ失敗の繰り返しの既定 2 回を残す + +周回の数と違い、レーンを増やしても当たりやすくならない。ループの止まり 37 回に `same_failure` は 0 回である(要求の「目的」の表)。止まらない場合に回り続けるのは失敗の費用だけである。 + +根拠: Value 3(MVV 版 5) + +### 決定 13: 上限を外しても別の数で止まるため、merge から取り込みへ戻る回数と配布の入れ直しの回数を残す + +merge から取り込みへ戻る回数は、プランのステップの上限(戻りの輪を 3 回と数える)の中にある。これを外しても同じ回数でプランの上限に当たるため、外すにはステップの上限の式も変えることになる。記録で戻りの上限に当たった実行は 0 件、戻りを通った実行は 1 件である。`/work/ai-plugins/.ndf/loop` の `state.json` を検索し、`"result": "limit"` は 0 件だった。 + +配布の入れ直しは、回数が尽きると承認ゲートで人へ戻す形で、失敗で止まらない。先端が 4 回動く配布を自動で出さないことは、本番の配布の承認の範囲に沿う。 + +根拠: Value 3 / Value 4(MVV 版 5) + +### 決定 14: `factory/` は volareinc/nd-factory へ移すため、区間の上限はこの設計の範囲から外す + +Factory の区間の上限 20 と再起動ループの検出は、移した先の課題(nd-factory の #12)で直す(利用者の答え、2026-11-06。#2646)。この設計は `factory/` を変えない。案の内容は移した先の課題へ写してある。 + +根拠: Value 6(MVV 版 5) + +### 決定 15: 動いている経路を壊さないため、耐久ワークフローの止まりの待ち 30 日を残す + +`durable.pause()` は cross-review と cross-refactoring の駆動が呼ぶ(要求の前提 2)。`DBOS.recv` の `timeout_seconds` は数を取り、上限の無い待ちの指定は文書に無い(LIB3)。止まりの待ちが上限を過ぎた記録は 0 件である。`/work/ai-plugins/.ndf/loop` と `~/.local/state/ndf/sv` を全文検索し、当たりはスクリプトのコピーだけだった。 + +根拠: Value 3 / Value 4(MVV 版 5) + +### 決定 16: 消した上限の説明を残さないため、`loop status` の `relay_max_starts` を消す + +ラッパーの起動回数の止まりは起きなくなる。入れ替え前の古いラッパーが止まる場合は、ラッパー自身の `ndf-relay:` の 1 行とラッパーの記録に残る(要求の前提 4)。入れ替えの期間だけのために判定を残さない。 + +根拠: Value 5(MVV 版 5) + +### 決定 17: 既存の上書きの形に揃えるため、起動の断りを問う回数を環境変数で変える + +`NDF_PARALLEL_SPAWN_REFUSALS` を足す。同じ部品の `NDF_PARALLEL_STALL_MINUTES` と同じ読み方で、不正な値なら既定の 3 を使う。#2343 が上書きの経路を作った後は、そちらへ移す。 + +根拠: Value 6(MVV 版 5) + +## 費用の実測 + +開発ループ lroot3 の周回 19(2026-11-05 22:24〜11-06 01:43 UTC)の内訳である。USD は定価の見積もりで請求額ではない(帳簿の行の `costBasis` は `list`、PRD6)。 + +| 範囲 | USD | 今の周回の費用に入るか | +| --- | ---: | --- | +| スプリントの報告に載った `claude -p`(プラン 4 本) | 23.93 | 入る | +| 帳簿にあり報告に載らない Claude の起動(cross-review 1.84・MVV 判定 0.40・検査のプラン 0.33・接続の確認 0.03) | 2.59 | 入らない | +| conductor の会話 2 本(`lroot3-19` を含む会話の、この時間帯のメッセージを `token_rates.py` の単価で換算) | 20.98 | 入らない | +| Codex の起動 9 回(155 万トークン) | USD なし | 入らない | +| Kiro の起動 11 回(使用量の記録なし) | 不明 | 入らない | +| USD の分かる範囲の合計 | 47.50 | 記録はその 50% | + +```bash +# 帳簿: ~/.local/state/ndf/usage//devbasex__ai-plugins.jsonl の plan に lroot3/lap-19 を含む行の cost_usd の和 +# 記録: /work/ai-plugins/.ndf/loop/lroot3/loop.json の laps[18].cost(23.934) +``` + +周回の費用の記録は 58 周で中央値 9.25 ドル・90 パーセンタイル 26.32 ドル・最大 71.74 ドルである。採る案は帳簿の範囲(この周回で 26.52 ドル)までを数えるので、記録は約 1.1 倍になる。この比は、予算の値を書いたプロジェクトで止まる時点が早まる量の目安になる。実測は 1 周だけで、比は周回で変わる。ループ 10 本のうち 3 本(lroot3・lroot4・lfactory3)は `--budget-usd` で 100 万ドルを渡し、費用の予算を実質的に外している。 diff --git a/issues/issue-2505-design-limits.md b/issues/issue-2505-design-limits.md new file mode 100644 index 000000000..6a2973930 --- /dev/null +++ b/issues/issue-2505-design-limits.md @@ -0,0 +1,158 @@ +# 開発ループの上限の全体の表(#2505) + +[issue-2505-design.md](issue-2505-design.md) から分けた節である。開発ループの中で働く上限を、種類と扱いで分けて 1 か所に並べる。 + +## 区分ごとの行数 + +| 区分 | 行数 | +| --- | ---: | +| 種類 A | 15 | +| 暴走止め | 4 | +| 種類 B | 1 | +| 配布の重複止め | 1 | +| 種類 C | 11 | +| 種類 D | 9 | +| 種類 E | 7 | +| 外 | 12 | +| 合計 | 60 | + +## 種類と扱いの語 + +種類の分け方は要求の「残すもの」の表に揃える。 + +| 種類 | 意味 | +| --- | --- | +| A | 回数・時間の上限で開発ループを止める | +| 暴走止め | 回数を数える上限ではなく、壊れて回り続ける状態を止める | +| B | 費用 | +| C | 資源と安全。マシンとアカウントの限界 | +| D | 品質の収束。当たっても残りを記録して先へ進む | +| E | 状態の変化。上限ではなく人の判断が要る状態 | +| 外 | 打ち直せばよい、または開発ループを止めない | + +扱いの語は次のとおりである。 + +| 扱い | 意味 | +| --- | --- | +| 無くす | この変更で上限を消す | +| 書いたときだけ | 既定を無制限にし、宣言に数を書いたときだけ効く | +| 置き換え | 別の判定へ置き換える | +| 足す | 上書きの手段を足す | +| 残す | 値も振る舞いも変えない | +| 利用者が決めた | 承認ゲート 1 で決めた。[設計文書の「利用者が決めたこと」](issue-2505-design.md#利用者が決めたこと)に対応する行がある | + +## 種類 A: 回数・時間の上限で開発ループを止めるもの + +| # | 上限 | 値 | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L1 | ラッパーの 1 日の起動回数 | 20 | `relay_lib/record.py:132` | 無くす | +| L2 | ループの予算: 周回の数 | 3 | `lib/pace.py:93` | 書いたときだけ | +| L3 | ループの予算: 経過時間 | 24 時間 | `lib/pace.py:93` | 書いたときだけ | +| L4 | 予算の値の検証(正の数だけを受ける) | — | `lib/pace.py:110-112` | `null` を受ける | +| L5 | 保留の周回の上限 | 2 | `lib/sprint_loop_record.py:63`・`lib/sprint_loop_select.py:368` | 無くす | +| L6 | 実装のステップの打ち切り | 3600 秒 | `supervise_lib/verify_steps.py:52` | 置き換え(無進捗の打ち切り) | +| L7 | 実績からの実装の上限の見込み | 同じ構成の直近の最大(下限 3600 秒) | `supervise_lib/verify_steps.py:55-79`・`lib/estimate_predict.py:202` | 無くす | +| L8 | レーンの起動の断りを問う回数 | 3 回 | `lib/parallel_run.py:139` | 足す | +| L9 | 同じ失敗の繰り返し | 2 回 | `lib/pace.py:94` | 残す(利用者の答え) | +| L10 | merge から取り込みへ戻る回数 | 3 回 | `merged_lib/base_sync.py:23` | 残す(利用者の答え) | +| L11 | 配布で先端が動いたときの入れ直し | 3 回(2 か所で使う) | `release-steps.py:126` | 残す(利用者の答え) | +| L12 | 耐久ワークフローの止まりの待ち | 30 日 | `lib/durable.py:49` | 残す(利用者の答え) | +| L13 | Factory の区間の上限 | 20 | `factory/runner/src/sections.ts:6` | 範囲の外。volareinc/nd-factory の #12 で直す | +| L14 | 1 周回で選ぶ課題の数 | 3 | `lib/pace.py:95` | 残す。実行環境の調査が候補を出す | +| L15 | 動かないレーンを問うまで | 60 分 | `lib/parallel_run.py:138` | 残す。止めずに問うだけ(#2477 の範囲) | + +## 暴走止め + +| # | 上限 | 値 | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L16 | 再起動ループの検出 | 3 区間続けて 120 秒未満 | `relay_lib/record.py:133,186` | 残す | +| L17 | プランのステップの上限(修正のループと戻りの輪の想定回数・手書きのプランの既定) | 3 回・30 | `supervise_lib/limits.py:20-21` | 残す | +| L18 | 駆動の段階の数 | 1000 | `skills/cross-review/scripts/drive.py:413` | 残す | +| L19 | 検証と修正の繰り返しの上限 | 100 | `skills/cross-refactoring/scripts/assignee_launch.py:16` | 残す | + +## 種類 B: 費用 + +| # | 上限 | 値 | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L20 | ループの予算: 費用 | 60 ドル(このリポジトリは 200 ドル) | `lib/pace.py:93`・`.ndf/pace.json` | 既定を上限なしにする。書いたときだけ効く。数え方を直す | + +## 配布の重複止め + +| # | 上限 | 値 | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L21 | 一時レーンの同時の数 | 1 本 | `relay_lib/hub.py:425` | 残す(利用者の指示) | + +## 種類 C: 資源と安全 + +| # | 上限 | 値 | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L22 | 同時に動かすラッパーの数 | 3 | `relay_lib/hub.py:85`・`lib/parallel_plan.py:29` | 残す。実行環境の調査が候補を出す(利用者の答え) | +| L23 | プランの本数の合計 | 6 | `lib/parallel_plan.py:28` | 残す。実行環境の調査が候補を出す(利用者の答え) | +| L24 | 並列の計画の外の `queue --max` の既定 | 3 | `lib/parallel_lane.py:116` | 残す | +| L25 | 文脈の量の運用上限 | 200,000 | `lib/context_policy.py:24` | 残す | +| L26 | 文脈の量の試行の運用上限 | 400,000 | `lib/context_policy.py:26` | 残す | +| L27 | 利用上限の待ちの最大 | 10800 秒 | `supervise_lib/claude.py:72` | 残す | +| L28 | 利用上限のリセット時刻が読めないときの待ち | 900 秒 | `supervise_lib/claude.py:71` | 残す | +| L29 | GitHub のレート上限の待ちの最大 | 3600 秒 | `lib/gh_quota.py:21` | 残す | +| L30 | 判定で倍々にする GitHub の待ちの上限 | 3600 秒 | `supervise_lib/engine.py:122` | 残す | +| L31 | 担当 1 本の見込み・予備のメモリ | 1536 MiB・1024 MiB | `parallel-measure.py` の既定値 | 残す | +| L32 | 基盤待ちの再実行の回数 | 3 回 | `lib/design_pr_merge.py:30` | 残す | + +## 種類 D: 品質の収束 + +| # | 上限 | 値 | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L33 | cross-review のラウンドの上限 | 設計 3・コード 12 | `skills/cross-review/scripts/classifications.py:28` | 残す | +| L34 | cross-review が読む設計文書の行数 | 1000 行 | `skills/cross-review/scripts/classifications.py:30` | 残す | +| L35 | 振動の検知 | 検知で止まる | `lib/drive_pause.py:86` | 残す | +| L36 | cross-refactoring の時間予算の既定 | 30 分 | `skills/cross-refactoring/scripts/refactor_lib/vocabulary.py:107` | 残す | +| L37 | 監視の上限(工程ごと) | 1200 秒・3600 秒 | `lib/limits.py:34-46` | 残す。実装のステップを外部のランタイムで動かすときだけ渡さない(決定 8) | +| L38 | 無進捗の許容(担当ごと) | 180〜900 秒 | `lib/limits.py:49-54` | 残す。実装のステップは引数で渡す(決定 8) | +| L39 | 駆動が pause で止まってよい回数 | 12 回 | `supervise_lib/worker_steps.py:23` | 残す | +| L40 | 遅れの見張りの判定の回数・待ち直し・打ち直し | 2 回・3 回・1 回 | `lib/slow_step.py:39-41` | 残す。無進捗の打ち切りのステップでは見張りが打ち切らない(決定 5) | +| L41 | 報告が無い Skill のステップの起こし直し | 3 回 | `supervise_lib/worker_steps.py:154` | 残す | + +## 種類 E: 状態の変化 + +| # | 止まる条件 | 何が起きたか | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L42 | `mvv_changed` | MVV が改訂された | `lib/sprint_loop_record.py:65` | 残す | +| L43 | `outside_mvv` | MVV の外の判定 | `lib/sprint_loop*.py` | 残す | +| L44 | `gate2_returned` | 本番の配布の承認が差し戻された | `lib/sprint_loop*.py` | 残す | +| L45 | `waiting_only` | 選べる課題が無く、答えを待つ作業だけが残る | `lib/sprint_loop_record.py:351`・`lib/sprint_loop_select.py` | 残す(`release_pending_max` の理由だけが無くなる) | +| L46 | `user_stop` | 利用者が止めた | `lib/sprint_loop_record.py:65` | 残す | +| L47 | `refused` | 使ってよい条件に当たらない | `lib/sprint_loop.py` | 残す | +| L48 | 同じ分類の検査の失敗が続く周回 | 3 周で人へ戻す | `lib/sprint_loop_select.py:165` | 残す | + +## 外: 打ち直せばよい、または開発ループを止めないもの + +| # | 上限 | 値 | 定義の場所 | 扱い | +| --- | --- | --- | --- | --- | +| L49 | `parallel.py wait` | 3600 秒 | `parallel.py:437` | 残す(要求の対象外) | +| L50 | `supervise.py wait` | 10800 秒 | `supervise.py:173` | 残す(要求の対象外) | +| L51 | 耐久の記録を消すまでの日数 | 30 日 | `lib/durable.py:48` | 残す(要求の対象外) | +| L52 | 実装以外の worker の 1 回の上限 | 1800 秒 | `supervise_lib/worker_steps.py:22` | 残す(要求の前提 7) | +| L53 | Skill のステップの上限 | 3600 秒 | `supervise_lib/templates.py:270`・`supervise_lib/sprint_waves.py:61` ほか | 残す(要求の前提 7) | +| L54 | 見直し・大整理の work のステップ | 5400 秒 | `supervise_lib/sprint_close_steps.py:106` | 残す(要求の前提 7) | +| L55 | テストの上限の既定(範囲・全体・CI の待ち) | 900 秒・1800 秒・3600 秒 | `lib/test_strategy.py:52` | 残す(実績から決め直す) | +| L56 | 走っているプランが「まだ動いている」を書く間隔 | 600 秒 | `supervise_lib/state.py:28` | 残す | +| L57 | ラッパーの入れ替えの用意の待ち | 600 秒 | `relay_lib/handover.py:55` | 残す | +| L58 | worktree の lock のやり直し | 5 回 | `supervise_lib/paths.py:62` | 残す | +| L59 | 設計の結果の取得のやり直し | 合計 120 秒 | `supervise_lib/design_stage.py:33` | 残す | +| L60 | hook の締め切り | 3.5 秒 | `hook_lib/deadline.py:16` | 残す | + +## 作り方 + +要求の「調べた範囲」が指す全体の表は、課題の本文とコメントに残っていない。`gh issue view 2505 --json body,comments` で確かめた範囲は、本文の種類 A の表と残すものの表だけである。そのため 6c78fe695 の `plugins/ndf/` と `factory/` を次の形で読み直して作った。 + +```bash +rg -n '^[A-Z_]+ = [0-9]' plugins/ndf/scripts/relay_lib plugins/ndf/scripts/supervise_lib plugins/ndf/scripts/hook_lib +rg -n '^[A-Z_]+ = [0-9]' plugins/ndf/scripts/lib/parallel*.py plugins/ndf/scripts/lib/sprint_loop*.py plugins/ndf/scripts/lib/design_pr_merge.py plugins/ndf/scripts/lib/context_policy.py plugins/ndf/scripts/lib/gh_quota.py +rg -n 'env_num\(' plugins/ndf/scripts/relay_lib +rg -n '^[A-Z_]+ = [0-9]|DEFAULT_MAX_ROUNDS|STEP_LIMIT' plugins/ndf/skills/cross-review/scripts plugins/ndf/skills/cross-refactoring/scripts +``` + +## 未確認・残リスク + +- 数え方は上の検索の範囲に限る。#2343 の設計は上限の数字のリテラルを 104 か所と数えており、この表はそのうち開発ループを止めるか、止めない理由を示す必要があるものだけを載せた。104 か所との突き合わせは行っていない +- `outside_mvv`・`gate2_returned`・`refused` の定義の場所はファイルまでで、行まで確かめていない diff --git a/issues/issue-2505-design.md b/issues/issue-2505-design.md new file mode 100644 index 000000000..7dfdea84c --- /dev/null +++ b/issues/issue-2505-design.md @@ -0,0 +1,479 @@ +# 開発ループ: レーンを増やしても回数と時間の固定の上限で止まらなくなる(#2505) + +## 目的 + +- **今どうなっているか**: 並列の計画でレーンが増えると、1 日の起動回数(全ラッパーの合計 20 回)と周回の予算が先に尽きる。2026-11-04 にラッパーの上限で 4 回止まり、周回の予算でループが 1 回止まった。実装のステップは 3600 秒の一律の打ち切りで 3 回切られている +- **誰に効くか**: 開発ループと並列の計画を回す利用者とオーガナイザー。手で起こし直す作業と、上限の代わりに大きな数を書く手当てが消える +- **変えると何が成り立つか**: 開発ループが止まる理由は、人の判断が要る状態と、費用・資源の限界と、壊れて回り続ける状態に絞られる。実装のステップは進んでいる間は打ち切られない。1 周回で選ぶ課題の数は、実行環境の調査が出す候補を見て人が決める + +## 適用範囲 + +- **働く範囲**: NDF の配布物(ラッパー・開発ループ・並列の計画・supervise)。配布先のリポジトリでも同じに働く +- **プロジェクトごとに違うもの**: 周回の数・時間・費用の上限は `.ndf/pace.json` の `loop.budget` で受ける。実装の上限と無進捗の許容時間は `.ndf/supervise.json` か `supervise.py new` の引数で受ける。起動の断りを問う回数は環境変数で受ける。ai-plugins の値を既定へ埋め込まない +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「開発ループを阻害する制限をリストアップし、原則すべて撤廃する」(2026-11-05) | 利用者の指示の原文 | 回数と時間の上限を原則として外すこと | +| 「実装のステップは進み続けている間は打ち切らない」(2026-11-05) | 利用者の指示の原文 | 打ち切りの条件を経過時間から進みへ移すこと | +| 「1 周回で選ぶ課題の数は撤廃しない。環境に合わせて決める」「一時レーンの同時の数(1 本)は撤廃しない」(2026-11-05) | 利用者の指示の原文 | 残すものと、環境で決める値 | +| ラッパーの記録の `stop` は `max-starts` が 5 件・`stop-file` が 1 件。1 日の起動は 2026-11-04 に 30 回、11-05 に 37 回(`~/.local/state/ndf/relay/*/log.jsonl`、2026-11-06 に集計) | 実測 | 固定の 20 回は今の運用の 2 分の 1 に近いこと | +| 実装のステップの呼び出し 77 回のうち 3 回が 3600 秒で打ち切り、4 回が 3000 秒以上(使用量の帳簿の `step` が `impl` の行、2026-11-06 に集計) | 実測 | 長い実装が実際に切られていること | +| `full` のステップの会話の記録 313 本で、記録の間隔の最大は 706 秒、900 秒以上は 0 本(2026-11-06 に集計) | 実測 | 進みが止まったと判定できる許容時間が決められること | +| PRD1(Kubernetes)/ PRD2(Temporal)/ PRD3(AWS Step Functions) | 外部の一次情報 | 長い処理の打ち切りを経過時間でなく進みの途絶で判定する形が、主要な製品の標準の形であること | +| PRD4(systemd) | 外部の一次情報 | 再起動の暴走は 1 日の数でなく「短い時間に何回」で止める形が標準であること | + +要求と受け入れ条件は #2505 の本文にある(コピーは [issue-2505-requirements.md](issue-2505-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +分けた文書は次の 2 つである。 + +| 文書 | 内容 | +| --- | --- | +| [issue-2505-design-limits.md](issue-2505-design-limits.md) | 上限の全体の表 60 項目と、種類・扱い | +| [issue-2505-design-decisions.md](issue-2505-design-decisions.md) | 決定の記録 17 件と費用の実測 | + +承認ゲート 1 で決めた項目は[「利用者が決めたこと」](#利用者が決めたこと)にある。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 開発の工程(`ndf-workflow`) | 周回・ループの予算・保留の周回・実装の上限・無進捗の打ち切り・進みの観測・実行環境の調査 | +| 中継(`ndf-relay`) | ラッパー・区間・再起動ループ | + +Factory が顧客、開発の工程が供給者の関係(顧客 / 供給者)。Factory は `--budget-<単位>` の引数でループの予算を渡し、NDF の既定の変更を受け入れる。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| ループの記録 | 開発ループの部品(`sprint_loop` と `sprint_loop_record`) | ループ | 周回 | 予算・消費・止まる条件・周回の費用の内訳・実行環境の報告 | +| プランの実行 | supervise のエンジンとステップのハンドラー | 実行の状態 | ステップの結果 | 進みの観測・打ち切りの理由 | +| ラッパーの記録 | ラッパー(`relay_lib`) | ラッパー | 区間 | 再起動ループの判定 | +| 進め方の宣言 | 人。部品は読むだけ | 宣言 | — | ループの節 | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 進め方の宣言 | ループの予算の値は `null` か正の数である | `PaceError` でループを断る | +| I2 | ループの記録 | `null` の単位では予算の止まる条件が立たない。数を書いた単位は消費が値以上で立つ | 境の判定と再開の判定は同じ関数で読むので、片方だけ違う結果にならない | +| I3 | ループの記録 | 保留の周回の数は止まる条件にならない。選べる課題があれば選ぶ | 選べなければ `waiting_only`(`no_selectable`)で止まる | +| I4 | ラッパーの記録 | 次の区間の可否は停止シグナルファイルと再起動ループの判定だけで決まる | 再起動ループなら `spin` で止まる | +| I5 | プランの実行 | 実装のステップは、実装の上限を書いていなければ、最後の進みの観測から許容時間を越えるまで打ち切られない | 許容時間を越えたら無進捗の打ち切りになる | +| I6 | プランの実行 | 無進捗の打ち切りは、進捗記録で時間切れと区別できる | `stalled` の行とステップの結果の `stalled` が残る | +| I7 | 進め方の宣言 | 実行環境の調査は宣言を書き換えない | 書く経路を持たない | +| I8 | ループの記録 | 実行環境の調査の失敗で開発ループが止まらない | `unreadable` を記録して進む | +| I9 | ループの記録 | 周回の費用は締めた時点の帳簿の Claude の行の和とスプリントの報告の和の大きい方で、USD の無い起動を足さない | 帳簿を読めなければ報告の和を使う。どちらを採ったかを内訳の `source` に残す | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | ラッパーが次の区間を起動してよいと判定した | ラッパー | ラッパーの記録(`start`) | +| E2 | 開発ループが周回の境で止まる条件を判定した | 開発ループの部品 | ループの記録・conductor | +| E3 | 開発ループが保留の周回を抱えたまま次の周回を選んだ | 開発ループの部品 | conductor | +| E4 | 実装のステップの進みを観測した | 進みの観測 | 待ちの締め切り | +| E5 | 実装のステップを無進捗で打ち切った | 進みの観測 | プランの実行の進捗記録・`on_fail` と判定役 | +| E6 | 実行環境を調べた | 実行環境の調査 | ループの記録・conductor・オーガナイザー | +| E7 | 課題の数の候補を人へ示した | `loop start`・`parallel.py start` の結果 | conductor の報告を読む人 | +| E8 | レーンの起動の断りが規定の回数続いて問うた | 並列の計画の実行 | 人 | + +要求で未決の E4 と E6 の引き金は次のとおりである。E4 は実装のステップの待ちの中で 60 秒ごと、E6 は `loop start` と `parallel.py start` のたびである。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| ループの予算 | 開発ループが使ってよい量の上限。周回の数と経過時間は宣言に数を書いたときだけ効く | 意味の変更 | +| 実装の上限 | 実装のステップの経過時間の上限。書いたときだけ効く | 意味の変更 | +| 無進捗の打ち切り | 進みの観測が無進捗の許容時間を越えて途絶えたときだけ打ち切ること | 意味の変更 | +| 無進捗の許容時間 | 最後の進みの観測から無進捗の打ち切りまでに待つ秒 | 追加 | +| 進みの観測 | 会話の記録の伸び・進捗ログの行・コミットの増加のどれかを見つけること | 追加 | +| 周回の費用の内訳 | 周回の費用を、プランの `claude -p`・同じプランの別の起動・USD に換算できない起動に分けた記録 | 追加 | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | ラッパーが起動回数を数えずに次の区間を起動する | 開発ループを回す利用者 | +| F2 | 周回の数と時間を書かなければ無制限でループが進み、書けばその値で止まる | プロジェクトの管理者 | +| F3 | 保留の周回を抱えたまま次の周回へ進む | オーガナイザー | +| F4 | 実装のステップが無進捗のときだけ打ち切られる | プランを流す conductor | +| F5 | レーンの起動の断りを問う回数を変える | オーガナイザー | +| F6 | 実行環境を調べ、課題の数とプラン・ラッパーの本数の候補を見る | プロジェクトの管理者 | +| F7 | 周回の費用が使用量の帳簿の範囲まで数えた値になる | 予算を決める利用者 | + +## 利用者が決めたこと + +要求の未決 1〜8 である。2026-11-06 の承認ゲート 1 で利用者が答えた(正本は #2505 の節「設計の承認ゲート 1 での利用者の答え(2026-11-06)」)。設計の案から変わったのは 3 と 7 で、この文書は答えの後の形を書く。理由は決定の記録にある。 + +| # | 何を決めるか | 決めた形 | 理由 | 答え | +| --- | --- | --- | --- | --- | +| 1 | 同じ失敗の繰り返し(2 回)の既定を外すか | 残す(決定 12) | レーンの数で増えず、止まりの記録は 37 回中 0 回。外すと失敗の費用だけが増える | 残す | +| 2 | merge から取り込みへ戻る回数と配布の入れ直しの回数を無くすか | どちらも残す(決定 13) | 戻りはプランのステップの上限の中にあり、当たった記録は 0 件。配布は回数が尽きると人へ戻すだけで失敗しない | どちらも残す | +| 3 | 費用の予算を残すか。残すなら数え方を直すか | 既定を上限なしにし、書いたときだけ効く。書いたときの数え方は帳簿へ直す。`null` を受ける(決定 11) | 周回の数・時間と同じ規則にそろえる。歯止めは同じ失敗の回数と利用プランの上限である。[費用の実測](issue-2505-design-decisions.md#費用の実測) のとおり、今の記録は分かる支出の半分である | 既定を上限なしにする(案の「残す・既定 60 ドル」から変更) | +| 4 | 「進んでいる」の判定・無進捗の許容時間・観測できないときの扱い・`impl_timeout` の扱い | 3 つの信号。許容時間 1800 秒。観測できなければ行とコミットだけで 7200 秒。`impl_timeout` は書いたときだけ効く(決定 4・6・7) | 記録の間隔の実測の最大は 706 秒。行の間隔の最大は 6479 秒 | 案のまま | +| 5 | プランの本数の合計 6 と同時に動かすラッパーの数 3 も実行環境の調査で決めるか | 候補を同じ出力に載せる。既定は変えない(決定 9・10) | 同じ値から導け、足す手間が無い。反映は環境変数で人が行う | 案のまま | +| 6 | 実行環境の調査の時点と、候補を宣言へ反映する経路 | `loop start` と `parallel.py start` のたび。反映は人が Pull Request で行う(決定 9) | 1 秒未満で LLM を使わず、毎回通せば環境の変化も拾える。自動で書かない(C7) | 案のまま | +| 7 | Factory の区間の上限 20 を同じ扱いにするか | この設計の範囲から外す(決定 14) | `factory/` は volareinc/nd-factory へ移す(#2646) | 移した先で直す(nd-factory の #12) | +| 8 | 耐久ワークフローの止まりの待ち 30 日 | 関数と 30 日を残す(決定 15) | 関数は cross-review と cross-refactoring の駆動が呼ぶ。待ちが上限を過ぎた記録は 0 件 | 案のまま | + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べる | 実行環境の調査が読むコンテナの制限の値に公開の仕様がある | +| 主要な製品 | 調べる | 長い処理の打ち切りと再起動の暴走の止め方は、ジョブ実行の基盤に確立した形がある | +| ライブラリとサービス | 調べる | リトライ・プロセスの情報を既存の依存で賄えるかを確かめる | + +### 標準・仕様 + +| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| STD1 | Linux cgroup v2 | `cpu.max`(`$MAX $PERIOD`。`max` は無制限)で CPU の帯域、`memory.max` でメモリの上限を表す | 実行環境の調査でコンテナの制限を読む(決定 9) | https://docs.kernel.org/admin-guide/cgroup-v2.html (2026-11-06) | +| STD2 | Python `os.cpu_count` / `os.process_cpu_count` | `cpu_count` はシステム全体の論理 CPU の数、`process_cpu_count` は呼び出したスレッドが使える数を返す。後者は 3.13 で追加された | 3.10 で動くよう `sched_getaffinity` を使う(決定 9) | https://docs.python.org/3/library/os.html (2026-11-06) | + +### 主要な製品 + +| # | 製品 | どう解いているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| PRD1 | Kubernetes | Deployment の `progressDeadlineSeconds` は進みの途絶を判定する。Job の `activeDeadlineSeconds` は経過時間の上限で、書いたときだけ効く | 無進捗の判定を既定にし、経過時間の上限は書いたときだけにする(決定 6) | https://kubernetes.io/docs/concepts/workloads/controllers/deployment/ ・ https://kubernetes.io/docs/concepts/workloads/controllers/job/ (2026-11-06) | +| PRD2 | Temporal | 長い Activity は Heartbeat と Heartbeat Timeout で検知する。Heartbeat は進みの情報を持てる | 進みを信号で読み、許容時間を越えたら打ち切る形に倣う(決定 4) | https://docs.temporal.io/encyclopedia/detecting-activity-failures (2026-11-06) | +| PRD3 | AWS Step Functions | `HeartbeatSeconds` を越えて信号が途絶えると `States.Timeout` で失敗する。`TimeoutSeconds` は別にある | 同上。打ち切りの理由を時間切れと分ける点だけ、この変更が記録で足す(決定 5) | https://docs.aws.amazon.com/step-functions/latest/dg/state-task.html (2026-11-06) | +| PRD4 | systemd | `StartLimitIntervalSec` と `StartLimitBurst` は「区間の中の起動の回数」で止める。`WatchdogSec` は keep-alive の途絶で止める | 暴走止めを短い区間の検出に任せ、1 日の数を持たない(決定 1) | https://www.freedesktop.org/software/systemd/man/latest/systemd.unit.html ・ https://www.freedesktop.org/software/systemd/man/latest/systemd.service.html (2026-11-06) | +| PRD5 | GitHub Actions | ジョブの `timeout-minutes` の既定は 360 分で、経過時間の上限を既定で持つ | 経過時間の上限を既定にする形。CI の 1 回に向く形で、長さの読めない実装には採らない | https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax (2026-11-06) | +| PRD6 | Claude Code の CLI | `--session-id` で会話の id を決め、`--no-session-persistence` で保存を止める。`--max-budget-usd` は手元の見積もりで止め、請求とは異なることがある | 実装のステップで会話を残して進みを読む(決定 4)。帳簿の USD は見積もりとして扱う(決定 11) | https://code.claude.com/docs/en/cli-reference (2026-11-06) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | psutil(既存の依存) | 7.2.2 | BSD-3-Clause | 2026-01-28 | 無し | メモリと cgroup の読み取りに使う(`procs` の部品)。子プロセスの CPU 時間も読めるが、進みの判定には使わない(決定 4) | https://pypi.org/pypi/psutil/json (2026-11-06) | +| LIB2 | tenacity | 9.2.1 | Apache-2.0 | 2026-10-07 | 無し | 再試行の部品で、進みの観測と締め切りの移動を持たない。要件を満たさない | https://pypi.org/pypi/tenacity/json (2026-11-06) | +| LIB3 | DBOS(既存の依存。固定の版 3.1.0、最新 3.4.0) | 3.4.0 | MIT | 2026-10-28 | 無し | `DBOS.recv` の `timeout_seconds` は数で、既定 60。上限の無い待ちは文書に無い(決定 15) | https://docs.dbos.dev/python/reference/contexts ・ https://pypi.org/pypi/dbos/json (2026-11-06) | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| 会話の記録が CLI の版をまたいで同じ場所・同じ伸び方を保つこと | https://code.claude.com/docs/en/cli-reference と https://code.claude.com/docs/en/headless の本文を読んだ | 記録の場所と書き込みの時点は公開の文書で確かめられなかった。手元の CLI で実測した。読めないときの扱いを決定 7 に置く | +| Factory の実行ジョブのコンテナの CPU とメモリの制限の実際の値 | 試していない | インフラの定義はこのリポジトリの外にある。部品は cgroup の値を読むだけなので、値が分からなくても設計は変わらない | + +調べた量: 候補 11 件・取得の試み 15 回。 + +## 構成要素 + +| 要素 | 責務 | 変更 | +| --- | --- | --- | +| 起動の判定(`relay_lib/record.py`) | 再起動ループだけで次の区間の可否を返す | 変える | +| ラッパー(`relay_lib/run.py`・`handover.py`) | 判定を呼び、`count.lock` を持たない | 変える | +| 進め方の宣言の読み取り(`lib/pace.py`) | 予算の `null` を受け、`release_pending_max` を読まない | 変える | +| 開発ループの部品(`lib/sprint_loop*.py`) | 予算の判定を 1 つの関数へ集め、保留の周回の数の判定と `relay_max_starts` を消す | 変える | +| 周回の費用(`lib/lap_cost.py`) | 帳簿の和と報告の和を比べて周回の費用を決め、内訳を導く | 新設 | +| 実行環境の調査(`lib/run_environment.py`) | 環境を読み、候補を返す。書き込まない | 新設 | +| 進みの観測(`supervise_lib/progress_watch.py`) | 信号を 60 秒ごとに読み、無進捗の締め切りを返す | 新設 | +| 待ちの部品と起動(`supervise_lib/claude.py`) | `extend` を `call_claude` へ通す。会話を残す起動を許す | 変える | +| worker のステップ(`supervise_lib/worker_steps.py`) | `stall` のステップへ進みの観測を付け、打ち切りの理由を残す。外部のランタイムの結果の `stalled` を読む | 変える | +| 遅れの見張り(`supervise_lib/slow.py`) | `stall` のステップで打ち切らない | 変える | +| プランのテンプレートと宣言(`supervise_lib/templates.py`・`verify_steps.py`・`decl.py`・`new_args.py`・`plan.py`) | 実装のステップに `stall` を書き、実装の上限は書いたときだけ `timeout` にする | 変える | +| 実績の見込み(`lib/estimate_predict.py`) | 実装の上限の見込みを消す | 変える | +| 監視(`lib/monitor.py`・`monitor_loop.py`)と外部 AI の起動 | `--timeout 0` を「経過時間の上限が無い」として受ける | 変える | +| 並列の計画の実行(`lib/parallel_run.py`・`parallel.py`) | 断りの回数の上書きを読む。開始で計画のレーンの数を渡して実行環境の調査を載せる | 変える | +| 確定仕様・手順書 | 変更後の値へ揃える(下の「文書の対象」) | 変える | + +### 構成要素の関係 + +```mermaid +graph TD + subgraph 開発ループ + A[進め方の宣言の読み取り] --> B[開発ループの部品] + F[実行環境の調査] --> B + G[周回の費用] --> B + end + subgraph supervise + H[プランのテンプレートと宣言] --> I[worker のステップ] + I --> J[進みの観測] + I --> K[待ちの部品と起動] + J --> K + L[遅れの見張り] --> I + I --> Q[監視と外部 AI の起動] + end + subgraph 中継 + M[ラッパー] --> N[起動の判定] + end + P[並列の計画の実行] --> F +``` + +実績の見込み・確定仕様と手順書は、ほかの要素を呼ばず、呼ばれもしない変更なので図に含めない。 + +### システムの文脈と配置 + +システムの文脈は次のとおりである。Claude Code の CLI の会話の記録と使用量の帳簿を読むだけで、書き手は変わらない。 + +```mermaid +graph TD + 人[利用者とオーガナイザー] --> 本体[NDF の配布物] + Fac[Factory のランナー] --> 本体 + 本体 --> CLI[claude と外部の CLI] + CLI --> 記録[(会話の記録と
使用量の帳簿)] + 記録 --> 本体 +``` + +動く場所は変わらない。 + +```mermaid +graph TD + subgraph 開発コンテナか Factory の実行ジョブ + W[ラッパー] --> C[conductor] + C --> S[supervise のプラン] + S --> K[worker の claude -p] + end +``` + +### 置き場 + +```text +plugins/ndf/scripts/ +├── lib/ +│ ├── lap_cost.py # 新設: 周回の費用 +│ ├── run_environment.py # 新設: 実行環境の調査 +│ ├── pace.py +│ ├── sprint_loop*.py +│ ├── estimate_predict.py +│ ├── monitor.py / monitor_loop.py +│ └── parallel_run.py +├── supervise_lib/ +│ ├── progress_watch.py # 新設: 進みの観測 +│ ├── claude.py / worker_steps.py / slow.py +│ └── templates.py / verify_steps.py / decl.py / new_args.py / plan.py +├── relay_lib/ +│ └── record.py / run.py / handover.py / common.py +└── parallel.py +``` + +### 文書の対象 + +要求の受け入れ条件の文書の行のとおり、次を変更後の値に揃える。 + +| 種類 | ファイル | +| --- | --- | +| 確定仕様 | `docs/specifications/` の `ndf-workflow-development-loop.md`・`ndf-relay-segment-restart.md`・`ndf-relay-multi-session-supervisor.md`・`ndf-parallel-organizer.md`・`ndf-supervise-step-budgets.md`・`ndf-estimate.md`・`ndf-factory-dev-loops.md`・`ndf-factory-dev-loops-data-and-contracts.md`・`ndf-factory-runs-and-runner.md` | +| 手順書 | `development-workflow` の `references/loop.md`・`relay.md`・`parallel.md`・`pace.md`、`issue-plan-strategy` の `parallel-plan.md`、`estimate` の `rules.md`、`restart` の `SKILL.md` | + +## 構造 + +```mermaid +classDiagram + class ProgressWatch { + +stall + +hard_deadline + +last_at + +last_signal + +observable + +deadline(開始) + } + class WorkStep { + +call_worker() + } + class SpinGuard { + +refusal(書いた時刻, 始めた時刻) + } + class 待ちの部品と起動 { + +call_claude(extend) + } + WorkStep "1" --> "0..1" ProgressWatch : 実装のステップで作る + WorkStep ..> 待ちの部品と起動 : 締め切りの関数を渡す + ラッパー ..> SpinGuard : 区間の終わりで問う +``` + +`SpinGuard` は `StartLimit` の改名である(決定 1)。 + +## データ構造 + +JSON のファイルだけで、移行は無い。読む側が新しい値と古い値の両方を受ける。 + +| ファイル | 項目 | 型 | 空の値 | 変更 | +| --- | --- | --- | --- | --- | +| `.ndf/pace.json` | `loop.budget.laps` / `hours` | 正の数か `null` | `null` と省略は無制限 | 既定が `null` になる | +| `.ndf/pace.json` | `loop.budget.usd` | 正の数か `null` | `null` と省略は無制限 | 省略の既定が無制限になる。`null` を受ける | +| `.ndf/pace.json` | `loop.release_pending_max` | — | — | 読まない。値が何でも断らない | +| `.ndf/supervise.json` | `impl_stall_seconds` | 1 以上の整数か `null` | `null` と省略は 1800。無進捗の打ち切りは外せない | 新設 | +| `.ndf/supervise.json` | `impl_timeout` | 1 以上の整数か `null` | 無ければ経過時間の上限が無い | 意味の変更 | +| ループの記録(`loop.json`) | `budget.<単位>` | 数か `null` | `null` は無制限 | `null` が入る | +| ループの記録 | `release_pending_max` | — | — | 新しい記録に書かない。古い記録の値は読まない | +| ループの記録 | `environments[]` | 実行環境の報告の配列(`at` を持つ) | 無ければ空 | 新設。追記だけで、過去の行を書き換えない | +| ループの記録の周回 | `cost` | 数 | — | 意味の変更(帳簿の和と報告の和の大きい方)。締めた周回の値は変えない | +| ループの記録の周回 | `cost_parts` | `{source, plans, others, unpriced}` | 古い周回には無い | 新設 | +| プランのステップ | `stall` | 秒 | 無ければ今の振る舞い | 新設。動いているプランの古いステップはそのまま動く | +| プランのステップ | `timeout` | 秒 | 実装のステップは実装の上限を書かなければ置かない | 意味の変更 | +| プランのステップ | `estimate` | — | — | 消す | +| 進捗ログ | `{"kind": "stalled", "step", "stall", "last_at", "last_signal"}` | 行 | — | 新設 | +| ステップの結果 | `stalled` | `{seconds, last_signal}` | 時間切れと分けるため無進捗のときだけ | 新設 | + +周回の費用の内訳の形は次のとおりである。 + +| 項目 | 意味 | +| --- | --- | +| `source` | `ledger`(帳簿の和を採った)か `reports`(報告の和が大きい、または帳簿を読めず、スプリントの報告の和を採った) | +| `plans` | `supervise` の起動の和(USD) | +| `others` | cross-review・MVV 判定ほかの Claude の起動の和(USD) | +| `unpriced` | `{"codex": {"calls", "tokens"}, "kiro": {"calls"}}`。USD を持たないため和に足さない | + +`plans` と `others` は帳簿の値である。`source` が `reports` のとき、2 つの和は `cost` と一致しない。 + +## 入出力の契約 + +| 名前 | 入力 | 出力 | 失敗の形 | 互換性 | +| --- | --- | --- | --- | --- | +| `sprint-state.py loop start` | 今のまま | 結果の `metrics.environment` に実行環境の報告。候補と今の値が違えば `lines` に 1 行 | 調査の失敗では止まらない | 足すだけ | +| `sprint-state.py loop environment [--root] [--json]` | なし | 実行環境の報告 | 読めない項目は `unreadable`。終了コードは 0 | 新設 | +| `parallel.py start` | 今のまま | 結果に実行環境の報告 | 同上 | 足すだけ | +| `sprint-state.py loop start --budget-<単位> default` | 今のまま | 宣言の値へ戻す。宣言が無ければ `null` | 今のまま | 既定の値が変わる | +| `sprint-state.py loop status` | 今のまま | `metrics.relay_max_starts` を出さない | 今のまま | 文書の範囲の項目を消す。読み手は確定仕様のほかに無い(`rg relay_max_starts plugins docs factory` の当たりは出す側と確定仕様の 2 行) | +| `supervise.py new impl` / `new sprint` | `--impl-stall SECONDS` を足す。`--impl-timeout` は書いたときだけ効く | 実装のステップの `stall` と任意の `timeout` | 1 未満は引数の誤り | 足すだけ | +| `external-ai.py run` / `monitor.py` | `--timeout 0` を受ける | 経過時間の上限が無い | 今のまま | 足すだけ | +| 環境変数 `NDF_PARALLEL_SPAWN_REFUSALS` | 1 以上の整数 | 起動の断りを問う回数 | 不正なら既定の 3 | 新設 | +| 環境変数 `NDF_RELAY_MAX_STARTS` | — | 読まない | 設定しても誤りにしない | 効かなくなる | + +実行環境の報告の形は次のとおりである。 + +```json +{ + "at": "2026-11-06T04:00:00Z", + "place": "container", + "cpus": 8, + "memory_gib": 30, + "lanes": 1, + "plans": 0, + "candidates": {"max_issues": 6, "max_plans": 6, "max_named": 3}, + "current": {"max_issues": 3, "max_plans": 6, "max_named": 3}, + "reason": "8 コア・30 GiB からプラン 6 本。レーン 1 本で割って課題 6。承認で読める量は別に見る", + "unreadable": [] +} +``` + +`place` は `factory`(`NDF_FACTORY_RUN` がある)・`container`(`/.dockerenv` がある、または cgroup の階層がコンテナの形)・`host` のどれかである。`lanes` の読み先は決定 9 にあり、上の例は並列の計画が動いていない `loop start` の値である。 + +## 処理の流れ + +実行環境の調査は `loop start` と `parallel.py start` の中で 1 回通り、失敗しても次へ進む。周回の費用は周回を締めるときに 1 回だけ読む。 + +### 実装のステップの打ち切り + +経過時間の上限を書いていないときは、締め切りは最後の進みに許容時間を足した時刻だけで決まる。 + +```mermaid +sequenceDiagram + participant W as worker のステップ + participant P as 進みの観測 + participant R as 待ちの部品 + participant C as worker の claude -p + W->>R: 起動(会話の id・締め切りの関数) + R->>C: 起動 + loop 待ちの周期ごと + R->>P: 締め切りを問う + P->>P: 60 秒ごとに 3 つの信号を読む + P-->>R: 最後の進み + 許容時間 + end + alt 終わった + C-->>R: 結果 + R-->>W: 結果 + else 締め切りを過ぎた + R->>C: プロセスグループを止める + R-->>W: 打ち切り + W->>W: stalled の行を書き、失敗として返す + end +``` + +### 周回の境 + +保留の周回の数の判定が無くなり、予算の判定が `null` を読む。 + +```mermaid +graph TD + A[すぐ止まる条件] --> B[届いた答えと
保留の周回のまとめ] + B --> C[承認済みの設計をマージ] + C --> D{同じ失敗か
数を書いた予算に当たる} + D -->|当たる| E{保留の周回がある} + E -->|ある| F[waiting_only] + E -->|無い| G[止まる条件で止まる] + D -->|当たらない| H[選定] + H -->|選べない| I[waiting_only
no_selectable] + H -->|選べた| J[次の周回] +``` + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | 開発ループの止まりは、今と同じく `[ndf-loop] stopped` の 1 行と記録に残る。無進捗の打ち切りは、進捗記録で今の時間切れと区別できる | 止まりの経路を変えない。無進捗の打ち切りは `kind: stalled` の行とステップの結果の `stalled` を書く | 短い許容時間の結合テストで行を読む | +| 移行性 | 既存の宣言(`.ndf/pace.json`)とループの記録は書き換えない。切り戻しは版を戻すことで行う | 読む側が古い値を受ける。古いプランのステップは `stall` が無く今のまま動く。`null` が入った記録を古い版が読むと予算の比較で例外になるため、切り戻した後は新しいループを始める | 古い記録のフィクスチャを読むテスト | +| システム環境 | 無進捗の打ち切りと実行環境の調査は、Claude Code・Codex のどちらのプロジェクトでも、NDF の外のリポジトリでも動く。ai-plugins の値を既定へ埋め込まない | Claude は会話の記録、外部のランタイムは監視の無進捗の判定を使う。調査は `procs` と標準ライブラリだけを使い、cgroup が無ければ `/proc` の値を使う | ランタイムごとのテスト。cgroup の無い環境の値を与えるテスト | + +## 決定の記録 + +決定の記録 17 件は [issue-2505-design-decisions.md](issue-2505-design-decisions.md) にある。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 同じ日の起動の記録が 20 件以上でも次の区間を起動し、`max-starts` を書かない | 判定に 20 件の記録を与えて起動を許す | 数えの判定を戻すと落ちる | +| `NDF_RELAY_MAX_STARTS=1` でも可否が変わらない | 変数を設定して同じ結果になる | 変数を読むと落ちる | +| 区間が 3 つ続けて 120 秒未満なら `spin` で止まる(I4) | 再起動ループで起動しない | 判定を消すと落ちる | +| 周回の数と時間を書かない宣言で 4 周・25 時間を越えても止まらない | 消費を与えて境の判定が止まらない | 既定を数へ戻すと落ちる | +| `null` の宣言で開始・境・再開・状態の表示・`laps.md` が例外なく動く(I2) | 5 つの経路を通す | どれか 1 つが `null` を比べると落ちる | +| 周回の数 2 で 2 周の境で `budget_laps` で止まる | 数を書いた予算で止まる | 判定を常に偽にすると落ちる | +| 無制限のループで `--budget-laps 5` は 5 に、`default` は宣言の値へ戻り、履歴に 1 行ずつ残る | 2 回の上書きと履歴 | 履歴を書かないと落ちる | +| 0・負の数・文字列・真偽値の予算を断る(I1) | `PaceError` になる | `null` 以外も通すと落ちる | +| 答えの無い保留の周回が 3 つでも次の周回を選ぶ(I3) | 選定へ進み `release_pending_max` が出ない | 数の判定を戻すと落ちる | +| 選べる課題が無ければ `waiting_only`(`no_selectable`)で止まる | 止まる理由 | 判定を消すと落ちる | +| `loop.release_pending_max` を書いた宣言でも開始できる | 不正な値でも断らない | 検証を戻すと落ちる | +| 3600 秒を越えても進みがあれば打ち切られない(I5) | 短い許容時間で、許容時間の数倍の間信号を出し続けるステップが完走する | 締め切りの移動を消すと落ちる | +| 進みが途絶えると打ち切られ、理由が進捗記録に残る(I6) | 短い許容時間で `stalled` の行を読む | 行を書かないと落ちる | +| 実績の見込みが打ち切りの秒を決めない | 実績があっても実装のステップに `timeout` が付かない | 見込みの経路を戻すと落ちる | +| 実装の上限を書くと進んでいても経過時間で打ち切る | 上限と無進捗の締め切りの早い方で止まる | 上限を無視すると落ちる | +| 会話の記録を読めないときは行とコミットで見て長い許容時間を使う | 記録の無い偽の起動 | 通常の許容時間を使うと落ちる | +| 遅れの見張りが進んでいるステップを打ち切らない | `stall` のステップで判定役を呼ばない | 見張りを動かすと落ちる | +| 外部のランタイムへ許容時間が渡り、上限を渡さない | 起動の引数と `--timeout 0` の監視 | 監視が 0 を即時の打ち切りと読むと落ちる | +| 外部のランタイムの無進捗の打ち切りが時間切れと区別できる(I6) | `items[0].result` が `stalled` の結果を与え、`stalled` の行とステップの結果を読む。`timeout` の結果では書かない | `status` だけを読むと落ちる | +| `impl_stall_seconds` の `null` は省略と同じ 1800 秒になる | `null` の宣言から作ったステップの `stall` | `null` を打ち切り無しと読むと落ちる | +| cross-review と cross-refactoring の駆動が止まりから再開へ進む | 既存のテスト | `pause` を消すと落ちる | +| `KEEP_DAYS` が 30 のまま | 既存のテスト | 値を変えると落ちる | +| 上書きに 5 を渡すと 5 回で問い、渡さなければ 3 回で問う | 断りの回数と問い | 環境変数を読まないと落ちる | +| `loop.max_issues` を書かなければ 3 | 既定の読み取り | 既定を変えると落ちる | +| 調査が 4 項目と候補を理由つきで出す | 与えた CPU・メモリ・cgroup の値から候補を出す | 式を変えると落ちる。`cpu.max` を無視すると落ちる | +| `parallel.py start` の課題の数の候補は計画のレーンの数で割る | プラン 6 本・3 レーンの計画で候補が 2 になる。動いている計画が無い `loop start` では 6 になる | 動いているレーンの数で割ると落ちる | +| 調査の前後で `.ndf/pace.json` の sha256 が同じ(I7) | 開始の経路を通して比べる | 書く経路を足すと落ちる | +| 調査が読めなくても開始が進む(I8) | 読み取りを失敗させる | 例外を伝えると落ちる | +| ラッパーの数 3・一時レーン 1 本・プランの本数 6 が変わらない | 既存のテスト | 既定を変えると落ちる | +| 周回の費用が帳簿の和になり、USD の無い起動を足さない(I9) | 帳簿のフィクスチャで周回を締める | 報告の和のままだと落ちる。Codex を足すと落ちる | +| 帳簿を読めなければ報告の和と `source: reports` | 帳簿の無い環境 | 周回の締めが止まると落ちる | +| 再開をまたぐ周回は報告の和を採る(I9) | 帳簿に再開の後の行だけがあり、報告の和の方が大きいフィクスチャで周回を締め、`source: reports` を読む | 帳簿を読めたら帳簿を採る形だと落ちる | +| 利用者の決定の項目が設計文書と承認資料に載る | この節と[費用の実測](issue-2505-design-decisions.md#費用の実測) を承認資料で見る(文言のテストは書かない) | — | +| 確定仕様・手順書の値が一致し、検索の当たりが撤廃の説明だけ | `rg 'NDF_RELAY_MAX_STARTS\|release_pending_max\|max-starts' plugins/ndf/skills docs/specifications` | 古い値が残ると当たる | +| 全体テストが通る | `uv run --frozen --project . --all-extras pytest . -q -n 4` | — | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2505 | 実装する | — | `plugins/ndf/scripts/relay_lib/`、`plugins/ndf/scripts/lib/`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/parallel.py`、`plugins/ndf/scripts/sprint-state.py`、`plugins/ndf/skills/external-ai/scripts/external-ai.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/`、`plugins/ndf/skills/issue-plan-strategy/`、`plugins/ndf/skills/estimate/references/rules.md`、`plugins/ndf/skills/restart/SKILL.md`、`docs/specifications/` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 再起動ループの検出を抜ける暴走 | 区間が 120 秒以上かかる空回りは検出を抜ける。起動回数の上限が無くなると、ループの外のラッパーには止める仕組みが無い。`NDF_RELAY_SPIN=0` の環境も同じである(要求の前提 3)。実際の発生は確かめていない | +| 保留の周回が増えたときの人の読む量 | 保留の周回の上限が無くなると、答えを待つ配布と質問が溜まりうる。課題が尽きるまで増える。人が読める量(Value 2)との関係は、レーンのあるプロジェクトで保留の周回の数を見て確かめる | +| 許容時間の実測の範囲 | 313 本は `full` のステップの値で、実装のステップは会話を残していないため実測が無い。リリース後に実装のステップの記録で確かめる | +| 費用の実測の標本 | 1 周だけで、conductor の 2 本の会話は同じ時間帯の別の作業を含むかもしれない | +| agy の実装のステップ | CLI の `--print-timeout` が残り、工程 `implement` の上限で止まる | +| 古いラッパー | 入れ替えが済むまで起動回数の上限が残る(要求の前提 4) | +| #2343 との重なり | 上限の宣言へ集める変更と同じ定数を触る。この変更は今の定義の場所で直す。後からマージする側が取り込みで揃える | +| Factory の画面 | 空の予算の表示は「リポジトリの既定」のままで、宣言の無いリポジトリでは無制限を指す。文言は変えない。Factory の予算の入力の受け付け(`parseBudget`)は変わらない | diff --git a/issues/issue-2505-requirements.md b/issues/issue-2505-requirements.md new file mode 100644 index 000000000..f1b5c0994 --- /dev/null +++ b/issues/issue-2505-requirements.md @@ -0,0 +1,362 @@ +# #2505: 開発ループ: レーンを増やすと当たる回数・時間の上限を撤廃する(relay の 1 日の起動回数・周回の予算など) + +正は課題の本文(#2505)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何が困っているか + +並列の計画でレーンが加わり、開発ループを回す本数と時間が増えた。そのため、回数・時間の上限が現実に合わなくなった。利用者の指示(2026-11-05)は「開発ループを阻害する制限をリストアップし、原則すべて撤廃する」である。 + +## 具体例: 2026-11-04 に実際に止めたもの + +- relay の 1 日の起動回数の上限(20 回)に 4 回当たった(15:18〜21:09Z、`~/.local/state/ndf/relay/*/log.jsonl` の `stop` の理由 `max-starts`)。この上限は全ラッパーを合わせた 1 日の数なので、レーンが 3 本あれば 3 本で 20 回を分け合う +- ループ lroot3 が周回の予算 8/8 で止まった(`budget_laps`)。8 は `.ndf/pace.json` の値で、プラグインの既定は 3 である + +## 撤廃する対象(種類 A: 回数・時間の上限で開発ループを止めるもの) + +| 名前 | 今の値 | 定義の場所(10.27.16) | 撤廃の形 | +| --- | --- | --- | --- | +| relay の 1 日の起動回数 | 20(`NDF_RELAY_MAX_STARTS`) | `relay_lib/record.py:132,157-184` | 上限を無くす。暴走は再起動ループの検出(下の「残すもの」)が受ける | +| ループの予算: 周回の数 | 既定 3、このリポジトリ 1000(無制限を書けないため大きな値で代えている。PR #2506) | `lib/pace.py:91-97`・`lib/sprint_loop_record.py:338-342` | 既定を無制限にする。宣言で数を書いたときだけ効く | +| ループの予算: 時間 | 既定 24 時間、このリポジトリ 10000 時間(同上) | 同上 | 同上 | +| 予算の値の検証 | 正の数を強制し、無制限を書けない | `lib/pace.py:110-112` | 書かない・`null` を無制限として受ける | +| 同じ失敗の繰り返し | 2 回 | `lib/pace.py:94` | 既定を外すか検討(同じ失敗を回し続けると費用だけが出る。設計で決める) | +| 保留の周回の上限(配布待ちと質問待ちの合計) | 2 | `lib/sprint_loop_record.py:62`・`lib/sprint_loop_select.py:216` | 上限を無くす。待ちを抱えたまま次の周回へ進む | +| 1 周回で選ぶ課題の数 | 3 | `lib/pace.py:95` | **撤廃しない。環境に合わせて決める**(利用者の指示、2026-11-05)。適した数は環境で変わる(コンテナだけか Factory の上か、マシンのスペック)。初期値は 3 のままとし、適切な時点で環境を調べて数を設定する工程を入れる(下の「環境に合わせて決める値」) | +| 動かないレーンを問うまで | 60 分 | `lib/parallel_run.py:131` | 残す(止めずに問うだけ)。ただし #2477 の通り今は効いていない | +| レーンの起動を断られて問う回数 | 3 回・固定 | `lib/parallel_run.py:132,645` | 上書きの手段を足す | +| 耐久ワークフローの止まりの待ち | 30 日(`PAUSE_SECONDS`) | `lib/durable.py:49,333` | **関数ごと消す**(2026-11-05 の利用者との確認)。`durable.pause()` はテストのほかに呼び手が無く、今は開発ループを止めていない。並列の計画の止まりは、イベントを立てて `parallel.py wait` で読む別の経路で、待ちの上限を持たない。上限が無いほうが開発ループを阻害しないので、残す理由が無い。呼び手の無い関数の片付け(#2369)と重なるなら、そちらへ寄せてよい。使われていない記録を消すまでの日数(`KEEP_DAYS` 30 日)は別のもので、この行の対象ではない | +| 実装のステップの打ち切り | 3600 秒 | `supervise_lib/verify_steps.py:52` | **進み続けている間は打ち切らない**(利用者の指示、2026-11-05)。3600 秒の一律の打ち切りをやめ、進みが止まったとき(無進捗の時間が続いたとき)だけ打ち切る。「進んでいる」の判定(出力・コミット・ツールの呼び出しのどれを見るか)と無進捗の許容時間は設計で決める(実際に 2 回打ち切った: l20261031 lap-5 #2291、lflow2 lap-3 #2388) | +| merge から取り込みへ戻る回数 | 3 | `merged_lib/base_sync.py:23` | 上限を無くすか設計で決める | +| 配布で先端が動いたときの入れ直し | 3 回・固定 | `release-steps.py:126` | 同上 | +| `parallel.py wait` 3600 秒・`supervise.py wait` 10800 秒 | — | — | 対象外(打ち直せばよく、ループを止めない) | + +## 環境に合わせて決める値 + +1 周回で選ぶ課題の数(`loop.max_issues`)は、上限として撤廃するのではなく、環境に合わせて設定する(利用者の指示、2026-11-05)。 + +- **初期値**: 3(今の既定のまま) +- **環境を調べる工程を入れる**: 調べるのは、動く場所(コンテナだけか、Factory の実行ジョブの中か)・CPU のコア数・メモリ・同時に動くレーンとプランの数。その結果から、`loop.max_issues` を決めて宣言(`.ndf/pace.json`)に書く +- **調べる時点**: 設計で決める。候補はループの開始(`loop start`)・並列の計画の開始(`parallel.py start`)・環境が変わったとき(Factory へ移した、マシンを替えた) +- **同じ考え方の値**: プランの本数の合計 6(8 コア・30 GB での実測)と、同時に動かすラッパーの数 3 も環境で決まる値である。同じ工程で一緒に決めるかを、設計で決める + +## 残すもの(理由つき) + +| 種類 | 例 | 残す理由 | +| --- | --- | --- | +| B: 費用 | ループの予算の費用(既定 60 ドル、このリポジトリ 200 ドル) | お金が出ていく。費用の判断は `ndf-ask:cost` の区分が持つ。撤廃するかは設計で利用者に問う | +| 配布の重複止め | 一時レーンの同時の数(1 本・固定、`relay_lib/hub.py:425`) | **撤廃しない**(利用者の指示、2026-11-05)。一時レーンは本番の配布を流す。1 本に限るのは、リリースの工程が重なって走るのを防ぐためで、開発ループの回数の制限ではない | +| C: 資源と安全 | 同時に動かすラッパーの数 3・プランの本数 6(8 コア・30 GB の実測)・文脈の量の guard・利用上限の待ち・GitHub のレート上限 | マシンとアカウントの限界で、外すと OOM や利用停止になる。開発ループの回数の制限ではない | +| D: 品質の収束 | cross-review のラウンドの上限・振動の検知・cross-refactoring の時間予算 | 当たっても止まらず、残った指摘を記録して先へ進む | +| E: 状態の変化 | MVV の改訂・MVV の外・待ちだけ・停止の指示 | 上限ではなく、人の判断が要る状態である | +| A のうちの暴走止め | 再起動ループの検出(3 区間続けて 120 秒未満) | 回数を数える上限ではなく、壊れて回り続ける状態を止める。起動回数の上限を外した後は、これが唯一の暴走止めになる | + +## 費用の予算が実際の支出より少なく数えている疑い(PR #2506 のレビューで見つけた。確かめていない) + +周回と時間の上限を外すと、費用の予算(B)が残る歯止めになる。ところが周回の費用に入るのは、`supervise.py` が `report.md` に書く `claude -p` の分だけに見える(`total_cost_usd` → `report.md` → `fill_row` → `close_lap` → `consumed()`)。conductor 自身のセッションの費用と、drive・work の中の codex・kiro などの CLI の費用は、合計に入っていないように読める。設計で実際の支出と突き合わせ、費用の予算を残すなら数え方も直す。 + +## 記録から見た効果の見込み + +ループの止まり 28 回の内訳(`/work/ai-plugins/.ndf/loop/*/loop.json`)は次のとおり。回数の上限(A)を外しても、止まる回数そのものはあまり減らない見込みである。それでも、レーンを増やしたときに当たり始める上限(起動回数・周回の数)は先に外しておく。 + +| 条件 | 回数 | +| --- | ---: | +| waiting_only | 9 | +| outside_mvv | 7 | +| gate2_returned | 4 | +| mvv_changed | 4 | +| refused | 2 | +| budget_laps | 1 | +| user_stop | 1 | + +## 調べた範囲 + +NDF 10.27.16 の `scripts/` と `skills/` を読み、ループ・中継・並列・supervise・cross 系・hook の上限を拾った。全体の表(約 60 項目、種類 A〜E の分類と定義の場所)は、この課題の設計で写す。 + + +## carul から届いた課題の束 E(2026-11-06) + +carul の課題を根本原因で束ねた結果、束 E(進行を止める上限)はこの課題の範囲と重なる。利用者の見立て「阻害要因になっている上限の撤廃」。束 A〜C は #2647・#2648・#2649。 + +### 束 E(軸 1): 進行を止める上限 + +**根本原因。** レーンの数に比例して増える起動・周回・時間を、プロセスに付かない固定の数で数え、当たると止める。 + +**場所。** `relay_lib/record.py:132,157-184`(1 日の起動回数)・`lib/pace.py:91-112`(周回・時間の予算)・`lib/sprint_loop_record.py:62`(保留の周回 2)・`relay_lib/hub.py:84-85`(`NDF_RELAY_MAX_NAMED` 既定 3)。一覧は #2505 の表が持つ。 + +**束に入る課題。** carul: #2637(症状は上限 3 で断られる。ただし原因は印が付かないことで、束 A で消える)。起票の無い当たり: 起動回数の上限(carul の引継ぎ文書の現在地「20:16:25 に再び stopped(waiting_only・起動回数の上限の表示)」と 30 行目)・周回と費用の予算(現在地の表)。どちらも #2505 の対象。 + +**直せば消える理由。** #2505 の受け入れ条件どおりに上限を外せば、起動回数と周回の数では止まらない。`MAX_NAMED` は #2505 で「資源の上限として残す」としているので、#2637 は束 A の印の付け直しで消す。 + +**既存の柱との重なり。** #2505(マイルストーン 30)・#2343(上限と再試行を 1 つの宣言に)。 + +## 依頼(原文) + +この本文の「何が困っているか」から「carul から届いた課題の束 E」までの節は、起票時の本文をそのまま残している(書き手: 起票者。2026-11-05 に起票し、2026-11-06 に束 E の節を足した)。利用者の指示を引用した部分だけを、次の 2 表に写す。ほかの文は利用者の指示の原文として扱わない。 + +利用者の指示(書き手: 利用者。起票者の本文に引用された形で残っている): + +| 引用 | 日付 | +| --- | --- | +| 開発ループを阻害する制限をリストアップし、原則すべて撤廃する | 2026-11-05 | +| 1 周回で選ぶ課題の数は撤廃しない。環境に合わせて決める | 2026-11-05 | +| 実装のステップは進み続けている間は打ち切らない | 2026-11-05 | +| 一時レーンの同時の数(1 本)は撤廃しない | 2026-11-05 | +| 阻害要因になっている上限の撤廃 | 2026-11-06 | + +利用者との確認(書き手: 起票者が「2026-11-05 の利用者との確認」と記録した文): + +> 耐久ワークフローの止まりの待ち(`PAUSE_SECONDS`)は関数ごと消す + +起票時の受け入れ条件(書き手: 起票者、2026-11-05。この本文の「受け入れ条件」の節は、これを観測できる形へ書き直したものである): + +> 1. relay は 1 日の起動回数で次の区間の起動を断らない(`max-starts` の止まりが無くなる)。再起動ループの検出は残り、3 区間続けて 120 秒未満なら止まる +> 2. ループの予算は、宣言に周回の数・時間を書かなければ無制限になる。書いたときはその値で止まる +> 3. 保留の周回の数でループが止まらない +> 4. 実装のステップは、進み続けている間は打ち切られない。進みが止まって無進捗の許容時間を越えたときだけ打ち切られる +> 5. 1 周回で選ぶ課題の数は初期値 3 のまま残し、環境を調べて数を設定する工程が入る。調べる時点と調べる項目は設計に書く +> 6. 上の表の「設計で決める」の項目は、承認ゲート 1 で利用者が採否を決める +> 7. 費用の予算(B)の扱いも、承認ゲート 1 で利用者が決める +> 8. 確定仕様(`docs/specifications/ndf-workflow-development-loop.md` ほか)と手順書(`references/loop.md`・`relay.md`・`parallel.md`)の上限の記述が、変更後の値と一致する + +この工程の起動指示(書き手: AI の起動指示、2026-11-06): + +> #2505。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +## 起票者の推定 + +利用者の指示の原文に無く、起票者が推して足した条件である。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 1 日の起動回数の上限は値を増やすのではなく無くす。暴走は再起動ループの検出が受ける | 起票時の表、運用の記録(2026-11-04) | 受け入れ条件 1〜3 | +| S2 | 周回の数と時間の既定を無制限にする。宣言に数を書いたときだけ効く | 起票時の表、PR #2506 | 受け入れ条件 4〜7 | +| S3 | 書かない・`null` の両方を無制限として受ける | 起票時の表 | 受け入れ条件 4・5 | +| S4 | 保留の周回の上限を無くし、待ちを抱えたまま次の周回へ進む | 起票時の表 | 受け入れ条件 9 | +| S5 | レーンの起動を断られて問う回数(3 回)に上書きの手段を足す | 起票時の表 | 受け入れ条件 17 | +| S6 | 動かないレーンを問うまでの 60 分は残す | 起票時の表、#2477 | 含まない | +| S7 | 同じ失敗の繰り返し(2 回)の既定を外すかを設計で決める | 起票時の表 | 未決 1 | +| S8 | merge から取り込みへ戻る回数(3 回)と配布の入れ直し(3 回)を、上限を無くすか設計で決める | 起票時の表 | 未決 2 | +| S9 | `parallel.py wait` 3600 秒・`supervise.py wait` 10800 秒は対象外とする | 起票時の表。打ち直せばループを止めないため | 含まない | +| S10 | 費用の予算は残す候補とし、撤廃するかを設計で利用者へ問う | 起票時の「残すもの」の表 | 未決 3、受け入れ条件 23 | +| S11 | 費用の予算が実際の支出より少なく数えている疑いがある | PR #2506 のレビュー。起票時点で未確認 | 受け入れ条件 23 | +| S12 | 同時に動かすラッパーの数 3・プランの本数 6・文脈の量の guard・利用上限の待ち・GitHub のレート上限を残す | 起票時の「残すもの」の表 | 受け入れ条件 21、含まない | +| S13 | cross-review のラウンドの上限・振動の検知・cross-refactoring の時間予算を残す | 起票時の「残すもの」の表 | 含まない | +| S14 | 調べる項目は動く場所・CPU のコア数・メモリ・同時に動くレーンとプランの数とする | 起票時の「環境に合わせて決める値」 | 受け入れ条件 19 | +| S15 | 実行環境を調べた結果を宣言(`.ndf/pace.json`)へ書く | 起票時の「環境に合わせて決める値」 | 受け入れ条件 20 で「人の承認なしには書かない」へ絞る。理由: `.ndf/` の書き換えは共通原則の C7 に当たる | +| S16 | プランの本数の合計 6 と同時に動かすラッパーの数 3 も、同じ工程で決めるかを設計で決める | 起票時の「環境に合わせて決める値」 | 未決 5 | +| S18 | `durable.pause()` はテストのほかに呼び手が無く、関数ごと消してよい | 起票時の表(2026-11-05 の利用者との確認) | 採らない。前提 2 のとおり稼働中の呼び手がある。未決 8 | +| S17 | 確定仕様の対象は `ndf-workflow-development-loop.md` ほか、手順書の対象は `loop.md`・`relay.md`・`parallel.md` とする | 起票時の受け入れ条件 8 | 受け入れ条件 24 で手順書と確定仕様を足す(検索で上限の記述が当たったため) | + +## 目的 + +レーンを増やしても、回数と時間の固定の上限で開発ループが止まらない状態にする。止まる理由を、人の判断が要る状態と資源・費用の限界に絞る。 + +効果の見込みは次の実測のとおりで、止まる回数そのものは大きくは減らない。レーンを増やしたときに当たり始める上限を、当たる前に外すことが目的である。 + +メインディレクトリのループの記録(`/work/ai-plugins/.ndf/loop/*/loop.json` の `stops`)の集計、2026-11-06 時点、37 回: + +| 条件 | 回数 | この変更で消えるか | +| --- | ---: | --- | +| `waiting_only`(理由 `no_selectable`) | 16 | 消えない | +| `waiting_only`(理由 `release_pending_max`) | 2 | 消える | +| `outside_mvv` | 10 | 消えない | +| `gate2_returned` | 4 | 消えない | +| `refused` | 3 | 消えない | +| `budget_laps` | 1 | 宣言に数を書かなければ消える | +| `user_stop` | 1 | 消えない | + +ラッパー側の止まり(`max-starts`)はこの集計の外にあり、2026-11-04 に ai-plugins で 4 回、2026-11-05 に carul で少なくとも 2 回起きた(起票の本文とコメントの記録)。 + +## 前提 + +- 前提 1: 種類 A の表の数値は 10.27.16 の値で、6c78fe695(10.27.27)でも値は同じである。定義の行は `lib/sprint_loop_record.py:63`(保留の周回)・`lib/sprint_loop_select.py:368-369`(保留の周回の判定)・`lib/parallel_run.py:138-139`(問うまでの分と回数)へ動いている。ほかの行は変わっていない +- 前提 2: 起票の本文の「`durable.pause()` はテストのほかに呼び手が無い」は 6c78fe695 の事実と異なる。`rg -n 'durable\.pause' plugins/ndf` はテスト 3 行のほかに `plugins/ndf/skills/cross-review/scripts/drive.py:484` と `plugins/ndf/skills/cross-refactoring/scripts/drive.py:499` に当たる。どちらも収束ループの止まりで呼ぶ稼働中の経路で、関数を消すと cross-review と cross-refactoring が動かなくなる。利用者との確認(関数ごと消す)はこの前提の上の判断だったため、受け入れ条件にせず未決 8 で確かめ直す +- 前提 3: 再起動ループの検出は、`NDF_RELAY_SPIN=0` を設定すると働かない(`s < self.spin` が常に偽になる。`relay_lib/record.py:200`)。carul の開発コンテナは暫定でこの値を設定している。起動回数の上限を外した後、この値を設定した環境には暴走止めが無くなる。この変更は `NDF_RELAY_SPIN` の扱いを変えず、残リスクとして残す +- 前提 4: 上限を外した版が届いた後も、既に動いているラッパーは起動の時の版を持つ。ラッパーの入れ替えの後に効く +- 前提 5: ループの記録は開始時に予算を写している。既存のループの `budget` の数(このリポジトリの 1000・10000 など)は書き換えない +- 前提 6: `loop.release_pending_max` を書いた宣言は、書いてあっても断らず、値を使わない。外のプロジェクトの宣言で開発ループが断られると、上限を外す目的に反するためである +- 前提 7: 無進捗の打ち切りを当てるのは実装のステップだけである。ほかのステップの `timeout` は変えない +- 前提 8: 実行環境の調査に失敗したとき(値が読めないとき)は、`loop.max_issues` の今の値で進む。開発ループを止めない +- 前提 9: `--impl-timeout` と `.ndf/supervise.json` の `impl_timeout` の扱いは未決 4 で決める。決まるまでの受け入れ条件 12〜14 は、どちらも渡していないときの振る舞いを指す +- 前提 10: 1 つの課題のまま要求を書く。範囲は上限 10 件ほどで、実装の Pull Request をいくつに分けるかは `implementation-plan` が決める + +MVV との突き合わせ: 反する疑いは無いと判定した。周回と時間の既定を外しても、上限に当たったら止まる仕組み(Value 3)として、費用の予算・同じ失敗の繰り返し・再起動ループの検出が残る。費用の予算まで撤廃すると判定が変わるため、未決 3 に分けて承認ゲート 1 で決める。根拠: Vision / Value 2 / Value 3 / Value 6(MVV 版 5) + +## 対象範囲 + +含む: + +- ラッパーの 1 日の起動回数の上限(`NDF_RELAY_MAX_STARTS`)の撤廃 +- ループの予算の周回の数・時間の既定を無制限にすること。`null` を無制限として受けること +- 保留の周回の上限(`loop.release_pending_max`)の撤廃 +- 実装のステップの 3600 秒の打ち切りを、無進捗の打ち切りへ置き換えること +- レーンの起動を断られて問う回数の上書きの手段 +- 実行環境の調査の工程の追加 +- 未決 1〜5 のうち、承認ゲート 1 で採ると決まった項目 +- 変更後の値に合わせた確定仕様・手順書の更新 +- 起票の本文の「全体の表(約 60 項目)」を設計文書へ写すこと + +含まない: + +- `loop.max_issues` の撤廃(初期値 3 のまま残す) +- 一時レーンの同時の数 1 本の変更 +- 同時に動かすラッパーの数 3・プランの本数 6 の既定の変更(未決 5 が決めるのは、同じ工程で決めるかどうかである) +- 動かないレーンを問うまでの 60 分の変更。問いが今は出ない不具合は #2477 の範囲である +- `parallel.py wait`・`supervise.py wait` の待ちの上限 +- 文脈の量の guard・利用上限の待ち・GitHub のレート上限 +- cross-review のラウンドの上限・振動の検知・cross-refactoring の時間予算 +- 上限の値と再試行を 1 つの宣言へ集めること(#2343 の範囲)。`plugins/ndf/contracts/limits.json` は 6c78fe695 に無く(`ls plugins/ndf/contracts` の結果)、この変更は今の定義の場所で直す +- 同時に動かす数の上限で断られる #2637 の解消(束 A の #2647 の範囲) +- 耐久の記録を消すまでの日数 `KEEP_DAYS` 30 日 +- `durable.pause()` の削除(前提 2。待ちの上限の扱いは未決 8) +- 既存のループの記録の予算の書き換え + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | ラッパーが次の区間を起動してよいと判定した | セッションが `ndf-next` を出して終わった | 再起動ループに当たれば `spin` で止まる。`count.lock` が取れなければ今の扱いのまま | — | +| E2 | 開発ループが周回の境で止まる条件を判定した | 周回を締めた | 宣言の予算の形が誤りなら `PaceError` で断る | — | +| E3 | 開発ループが保留の周回を抱えたまま次の周回を選んだ | E2 が止まらなかった | 選べる課題が無ければ `waiting_only`(`no_selectable`)で止まる | E2 | +| E4 | 実装のステップの進みを観測した | 実装のステップが動いている間の一定の間隔 | 観測の手段が読めないときは未決 4 で決める | — | +| E5 | 実装のステップを無進捗で打ち切った | E4 で無進捗の時間が許容時間を越えた | 打ち切りの理由が記録に残り、今の打ち切りと同じ経路で次へ進む | E4 | +| E6 | 実行環境を調べた | 未決 6 で決める時点 | 値が読めなければ今の `loop.max_issues` で進む(前提 8) | — | +| E7 | 課題の数の候補を人へ示した | E6 が値を返した | 示せないときは記録に残すだけで進む | E6 | +| E8 | レーンの起動の断りが規定の回数続いて問うた | `relay.py spawn` が断った | 問いが届かなければ今の扱いのまま | — | + +引き金や失敗の経路が決まっていない E4・E6 は未決へ移した。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 開発ループ | スプリントを 1 つ終えるたびに次を選んで続ける進め方 | +| 周回 | 開発ループの 1 回分 | +| ループの予算 | 開発ループが使ってよい量の上限。周回の数・費用・経過時間で数える | +| 保留の周回 | 答えを待たずに締めた周回(配布待ち・質問待ち) | +| レーン | 並列の計画の下で、名前つきのラッパー 1 本が進める開発の流れ | +| 一時レーン | オーガナイザーが共有の作業のために起動するレーン | +| ラッパー | 利用者の claude を包んで常駐し、次のセッションを起動する常駐プロセス | +| 再起動ループ | セッションが短い時間で続き、進まずに起動だけが重なる状態 | +| 実装の上限 | プランの実装のステップの `timeout`(秒) | +| 無進捗の打ち切り | 実装のステップで、進みの観測が許容時間を越えて途絶えたときだけ打ち切ること | +| 実行環境の調査 | 動く場所・CPU のコア数・メモリ・同時に動くレーンとプランの数を調べ、1 周回で選ぶ課題の数の候補を出す工程 | + +## 受け入れ条件 + +ラッパーの起動回数(起票時の条件 1): + +- [ ] 同じ日の起動の記録が 20 件以上ある状態で、ラッパーが次の区間を起動する。記録の `stop` に理由 `max-starts` の行が書かれない +- [ ] `NDF_RELAY_MAX_STARTS=1` を設定しても、起動の可否が変わらない +- [ ] 区間が 3 つ続けて 120 秒未満なら、ラッパーは次の区間を起動せず、理由 `spin` で止まる(退行しない) + 前提の確認: `pytest plugins/ndf/scripts/tests/test_relay.py -k "spin or max_starts or start_limit or refusal"` → 4 件成功(6c78fe695) + +ループの予算(起票時の条件 2): + +- [ ] `loop.budget` に `laps`・`hours` を書かない宣言で始めたループは、4 周を終えても、動いていた時間が 25 時間を越えても、`budget_laps` / `budget_hours` で止まらない +- [ ] `loop.budget.laps` と `hours` に `null` を書いた宣言で、`loop start`・周回の境の判定・止まった後の再開・`loop status`・`laps.md` の書き出しが例外なく動き、書かないときと同じく止まらない +- [ ] `loop.budget.laps` に 2 を書いた宣言で始めたループは、2 周を終えた境で `budget_laps` で止まる(退行しない) + 前提の確認: `pytest plugins/ndf/scripts/tests/test_sprint_loop*.py plugins/ndf/scripts/tests/test_relay_hub.py plugins/ndf/scripts/tests/test_parallel_plan.py -k "budget or pending or no_selectable or waiting_only or helper or max_named or max_plans or max_issues"` → 36 件成功(6c78fe695) +- [ ] 無制限のループで `--budget-laps 5` を渡すと 5 になり、`--budget-laps default` を渡すと宣言の値(宣言が無ければ無制限)へ戻る。どちらも予算の履歴に 1 行ずつ残る +- [ ] `loop.budget.laps` に 0・負の数・文字列・真偽値を書いた宣言は、今と同じく `PaceError` で断られる(退行しない) + +保留の周回(起票時の条件 3): + +- [ ] 答えの無い保留の周回が 3 つある状態で、次の周回の課題が選ばれる。止まる理由に `release_pending_max` が現れない +- [ ] 選べる課題が無いときは、今と同じく `waiting_only`(`no_selectable`)で止まる(退行しない) +- [ ] `loop.release_pending_max` を書いた宣言でも、`loop start` が断らない + +実装のステップ(起票時の条件 4): + +- [ ] 実装のステップが 3600 秒を越えて動いても、進みが観測され続ける間は打ち切られない +- [ ] 進みの観測が許容時間を越えて途絶えると、実装のステップが打ち切られ、理由が無進捗であることが進捗記録に残る +- [ ] 実績からの見込みを含め、実装の打ち切りの秒に 3600 秒の一律の下限が効かない(`impl_timeout_estimate` の結果がステップの打ち切りを決めない) + +耐久ワークフロー: + +- [ ] cross-review と cross-refactoring の駆動が、止まりから再開へ今と同じく進む(退行しない) + 前提の確認: `pytest plugins/ndf/scripts/tests/test_durable.py plugins/ndf/scripts/tests/test_drive_review.py` → 34 件成功(6c78fe695) +- [ ] `KEEP_DAYS` の値が 30 のまま残る(退行しない) + +レーンの起動の断り: + +- [ ] 上書きの手段に 5 を渡すと、起動の断りが 5 回続いたときに問い、渡さなければ今と同じ 3 回で問う + +1 周回で選ぶ課題の数と実行環境の調査(起票時の条件 5): + +- [ ] `loop.max_issues` を書かない宣言では、1 周回で選ぶ課題の数は 3 である(退行しない) +- [ ] 実行環境の調査が、動く場所(コンテナだけか、Factory の実行ジョブの中か)・CPU のコア数・メモリ・同時に動くレーンとプランの数を出力し、`loop.max_issues` の候補を理由つきで出す +- [ ] 実行環境の調査は、人の承認なしに `.ndf/pace.json` を書き換えない(共通原則の C7)。調査の前後で `.ndf/pace.json` の sha256 が同じである + +残すもの(退行しない): + +- [ ] 同時に動かすラッパーの数の既定 3・一時レーンの同時の数 1 本・プランの本数の合計の既定 6 が変わらない + 前提の確認: 上の 36 件に `test_helper_passes_once_over_cap` などを含む(6c78fe695) + +利用者の決定(起票時の条件 6・7): + +- [ ] 設計文書が、「未決」の 1〜8 について、それぞれ採る案・理由・利用者が決める点を載せている。承認ゲート 1 の承認資料に同じ項目が決定待ちとして載る +- [ ] 設計文書が、費用の予算を残すか撤廃するかの選択肢を載せ、残す場合はループの費用に入る範囲(conductor のセッション・`supervise.py` の `claude -p`・drive と work の中の codex と kiro)を、1 つの周回の実測と比べた結果を載せている + +文書(起票時の条件 8): + +- [ ] `docs/specifications/ndf-workflow-development-loop.md`・`ndf-relay-segment-restart.md`・`ndf-relay-multi-session-supervisor.md`・`ndf-parallel-organizer.md`・`ndf-supervise-step-budgets.md`・`ndf-estimate.md`、手順書 `references/loop.md`・`relay.md`・`parallel.md`・`pace.md`・`issue-plan-strategy` の `parallel-plan.md`・`estimate` の `rules.md` の上限の記述が、変更後の値と一致する。`rg 'NDF_RELAY_MAX_STARTS|release_pending_max|max-starts' plugins/ndf/skills docs/specifications` の当たりが、撤廃の説明の行だけである +- [ ] 全体テストが通る + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 開発ループの止まりは、今と同じく `[ndf-loop] stopped` の 1 行と記録に残る。無進捗の打ち切りは、進捗記録で今の時間切れと区別できる | +| 移行性 | 既存の宣言(`.ndf/pace.json`)とループの記録は書き換えない。切り戻しは版を戻すことで行う | +| システム環境 | 無進捗の打ち切りと実行環境の調査は、Claude Code・Codex のどちらのプロジェクトでも、NDF の外のリポジトリでも動く。ai-plugins の値を既定へ埋め込まない(Value 6) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。`NDF_RELAY_MAX_STARTS` と `loop.release_pending_max` が効かなくなる(渡してもエラーにしない)。`loop status` の `relay_max_starts` の扱いは設計で決める。`loop.budget` の既定が無制限になり、`null` を受ける | +| データ | スキーマの移行は無い。`budget` の値に `null` が入りうる | +| 既存の振る舞い | 周回の数・時間を宣言していないプロジェクトのループが、費用の予算(既定 60 ドル)まで止まらなくなる。Factory の予算の `null` は「リポジトリの既定へ戻す」の意味である(`factory/api/src/runs/loops.ts:441-442`)。NDF の既定が無制限になると、宣言の無いリポジトリでは空の予算が無制限を意味する。画面の表示は設計で確かめる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析・型検査 | `bash scripts/check-lint.sh`・`bash scripts/validate-runtime-plugins.sh` | +| 文書の整合 | `python3 plugins/ndf/scripts/instructions-check.py --root .`・`python3 plugins/ndf/scripts/glossary.py check` | +| 手動確認 | リリース後に、レーンのあるプロジェクトで 21 回目の起動が通ったことと、1 時間を越える実装のステップが打ち切られなかったことを記録で確かめる | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md`。ラッパーの変更は `relay_lib/`、ループの変更は `lib/` に置く。実験版の置き場へ置かない(既定で働く変更のため) | +| コーディング規約 | `AGENTS.md` の最小限のコード実装。`.md` の文言を照合するテストを書かない | +| テスト戦略 | 判定はユニットテスト、無進捗の打ち切りは短い許容時間の結合テストで確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、変更に合わせた確定仕様と手順書の更新 | +| 確認してから行う | 公開インタフェースの変更、費用の予算の扱い、`.ndf/` の書き換え(承認ゲート 1・2 で人が見る) | +| 行わない | `.ndf/pace.json` の自動の書き換え、残すものの値の変更、既存のループの記録の書き換え | + +## 未決 + +いずれも承認ゲート 1 で利用者が決める。下の「候補」は設計の起点で、決定ではない。 + +| # | 項目 | 候補 | 誰が決めるか | 期限 | +| --- | --- | --- | --- | --- | +| 1 | 同じ失敗の繰り返し(2 回)の既定を外すか | 残す。同じ失敗を回し続けると費用だけが出るため(Value 3) | 利用者 | 承認ゲート 1 | +| 2 | merge から取り込みへ戻る回数 3 と、配布の入れ直し 3 回を無くすか | 設計で失敗の形を確かめて決める | 利用者 | 承認ゲート 1 | +| 3 | 費用の予算を残すか撤廃するか。残すなら数え方を直すか | 残す。周回と時間を外した後の歯止めになるため。ただし数え方の範囲を実測してから決める | 利用者(費用の判断) | 承認ゲート 1 | +| 4 | 「進んでいる」の判定(出力・コミット・ツールの呼び出しのどれを見るか)・無進捗の許容時間・観測できないときの扱い・`impl_timeout` の宣言の扱い | 設計で決める | 利用者 | 承認ゲート 1 | +| 5 | プランの本数の合計 6 と同時に動かすラッパーの数 3 も、実行環境の調査で決めるか | 設計で決める | 利用者 | 承認ゲート 1 | +| 6 | 実行環境の調査の時点(`loop start`・`parallel.py start`・実行環境が変わったとき)と、候補の数を宣言へ反映する経路 | 設計で決める。自動では書かない(受け入れ条件のとおり) | 利用者 | 承認ゲート 1 | +| 7 | Factory の区間の上限(`factory/runner/src/sections.ts:6`。既定 20、`RUNNER_SECTION_LIMIT` で変える)を同じ扱いにするか。起票の表に無いが、同じく回数の固定の上限である | 設計の全体の表で分類し、種類 A なら同じ扱いを提案する | 利用者 | 承認ゲート 1 | +| 8 | 耐久ワークフローの止まりの待ち 30 日(`PAUSE_SECONDS`)をどうするか。関数は消せない(前提 2) | 関数と 30 日を残す。30 日の待ちに当たった記録の有無は未確認で、設計で確かめる | 利用者(2026-11-05 の確認の取り直し) | 承認ゲート 1 | + +--- diff --git a/issues/issue-2509-design.md b/issues/issue-2509-design.md index 91a52bad6..fd1e79e78 100644 --- a/issues/issue-2509-design.md +++ b/issues/issue-2509-design.md @@ -95,9 +95,10 @@ | `relay_lib/runtime.py` の `enter` | 副命令の振り分けの前に、`UV_PROJECT_ENVIRONMENT` が共有の環境を指していれば外す(古い版の NDF のスクリプトから継いだ値を含む) | 変える | | `lib/parallel_run.py` の `relay` | 統括役の CLI の起動を `relay_proc`(新設。終了コード・標準出力・標準エラーを持つ結果を返す)へ寄せ、戻り値の形 `(終了コード, 統一 JSON レポート)` は保つ | 変える | | `lib/parallel_run.py` の `step_observe` | 観測の結果に、一覧の終了コード(`code`)と標準エラーの末尾 1 行(`err`)を足す | 変える | -| `lib/parallel_run.py` の `_observe_run` | 終了コードによらず先に `_track_ls_failure` を呼ぶ。0 以外ならそこで戻り、0 なら既存の観測を通す | 変える | +| `lib/parallel_run.py` の `_observe_run` | 終了コードを `ls_ok` に控え、終了コードによらず先に `_track_ls_failure` を呼ぶ。0 以外ならそこで戻り、0 なら既存の観測を通す | 変える | +| `lib/parallel_run.py` の `_run_finished` | `ls_ok` が偽の間は偽を返す(I2) | 変える | | `lib/parallel_run.py` の `_track_ls_failure`(新設) | 失敗の回は、一覧の失敗が始まった時刻を持ち、停滞の区切りを超えたら区切りごとに 1 回、一覧の失敗の問いを立てる。成功に戻ったら答えていない一覧の失敗の問いを外す | 足す | -| `lib/parallel_run.py` の `_Run` | 一覧の失敗の追跡の値(`ls_failed_since`・`ls_ask_until`)を持つ。`ask` は `lane` を持たない問い(`attach` を添えない)を受ける | 変える | +| `lib/parallel_run.py` の `_Run` | 最後の観測で一覧が成功したか(`ls_ok`)と、一覧の失敗の追跡の値(`ls_failed_since`・`ls_ask_until`)を持つ。`ask` は `lane` を持たない問い(`attach` を添えない)を受ける | 変える | | `lib/parallel_answer.py` の `owner_restart` | 自分で立てた問いの種類に `observe` を足し、選んだ答えの `commands` を返す | 変える | | `skills/development-workflow/references/parallel.md` | 耐久ワークフローが自分で立てる問いの種類の一覧に `observe` を足す | 変える | @@ -149,9 +150,8 @@ plugins/ndf/ │ ├── relay_lib/ │ │ └── runtime.py # enter │ └── tests/ -│ ├── test_deps.py -│ ├── test_deps_child_env.py # 受け入れ条件 5・6・8・10(新設) -│ ├── test_relay_child_env.py # 受け入れ条件 7・8(新設) +│ ├── test_deps.py # 受け入れ条件 5・6・8・10 +│ ├── test_relay.py # 受け入れ条件 7・8 │ └── test_parallel_run.py # 受け入れ条件 1〜4 └── skills/development-workflow/references/parallel.md ``` @@ -164,6 +164,7 @@ classDiagram +dict lanes +list questions +float seen_at + +bool ls_ok +float ls_failed_since +float ls_ask_until +ask(kind, rid, lane, question, options) @@ -173,11 +174,12 @@ classDiagram +dict rows +int code +str err + +bool can_ask } _Run ..> ObserveResult : _observe_run が読む ``` -観測の結果は耐久の記録に残るステップの戻り値で、`code` と `err` を足す。足す前の版で記録した戻り値は `code` を持たないため、無ければ 0(成功)として読む(決定 5)。`ls_failed_since` と `ls_ask_until` は `_Run` の値で、打ち直すと記録からの再生で同じ値に戻る(ほかの値と同じ)。 +観測の結果は耐久の記録に残るステップの戻り値で、`code` と `err` と `can_ask` を足す。`code` を足す前の版で記録した戻り値は `code` を持たないため、無ければ 0(成功)として読む。`can_ask` が無い記録では一覧の失敗を問わない(どちらも決定 5)。`ls_ok`・`ls_failed_since`・`ls_ask_until` は `_Run` の値で、打ち直すと記録からの再生で同じ値に戻る(ほかの値と同じ)。 ## 入出力の契約 @@ -251,7 +253,7 @@ stateDiagram-v2 問い済み --> 正常: 一覧が 0(答えていない問いを外す) ``` -答えていない一覧の失敗の問いがある間は `run.questions` が空でないため、`_run_finished` は偽を返し、計画は終わらない(既存の条件)。 +答えていない一覧の失敗の問いがある間は `run.questions` が空でないため、`_run_finished` は偽を返し、計画は終わらない(既存の条件)。問いを答えた後でも、最後の観測が失敗なら `ls_ok` が偽のため終わらない(I2)。 ## 非機能の実現方式 @@ -298,11 +300,13 @@ stateDiagram-v2 観測の結果は耐久の記録に残るステップの戻り値で、打ち直すと記録から再生される。この変更の前に記録した戻り値は `code` を持たない。無いときは 0 として読み、記録の時点の振る舞い(成功として読む)を保つ。記録のテーブルと形は変えない。 +一覧の失敗の問いも同じ考えで、記録の時点の振る舞いを保つ。問いは `DBOS.set_event` を呼ぶため、問いの無い版の記録を再生する途中で立てると、記録済みの呼び出しの順と衝突して計画を再開できない。そこで観測の結果に `can_ask` を足し、これが無い記録の再生では問わない。失敗の始まりの追跡は続けるので、再生の後の最初の観測で失敗が続いていれば問う。形式の版(`durable.FORMAT`)は上げない。上げると全種類の記録に効くうえ、並列の計画は形式の版で続けるかを選ばないため、更新前の計画が回復されず待ち続ける。 + 根拠: Value 6(MVV 版 5) ### 決定 6: 問いが計画の終わりを止め続けないよう、一覧が成功に戻ったら答えていない一覧の失敗の問いを外す -答えていない問いがあると `_run_finished` が偽になり、計画は終われない。一覧が成功に戻れば問いの前提が無くなるため、外して `questions` のイベントを出し直す。外した後に届いた答えは、既存の `reply` が一覧に無い id として捨てる。 +答えていない問いがあると `_run_finished` が偽になり、計画は終われない。一覧が成功に戻れば問いの前提が無くなるため、外して `questions` のイベントを出し直す。外した後に届いた答えは、既存の `reply` が一覧に無い id として保留し、警告を残す。`id` は観測の時刻を含み、同じ id の問いは立たないため、保留した答えはどの問いにも当たらず、計画の終わりも止めない。 問いを残して利用者の答えを待つ形は採らない。一覧が戻った後の答えは何も変えず、待つ間は計画が終われない。 @@ -329,6 +333,7 @@ stateDiagram-v2 | 受け入れ条件 3 | 一覧が 0 で終わり、起動済みのレーンの行が無いとき、そのレーンを `stopped` と読み、全レーンがそうなら `finished` で終える | 成功した回の行の無いレーンも状態を変えないよう壊すと落ちる | | 受け入れ条件 4・I3 | 失敗が停滞の区切り(テストでは短い値)より長く続くと問いが 1 件立ち、本文に終了コードと標準エラーの末尾 1 行が入る。同じ区切りの間の次の失敗では増えない | 区切りの判定を外す・問いの本文から終了コードを落とす・毎回問うよう壊すと落ちる | | I4 | 失敗で問いが立った後、一覧が 0 で終わると問いが一覧から外れる | 成功の回で問いを外さないよう壊すと落ちる | +| 決定 5 | `can_ask` の無い失敗の記録では、区切りを超えても問いも `set_event` も足さず、印のある観測で問う | `can_ask` を見ないよう壊すと落ちる | | 受け入れ条件 5・I5・I6 | 共有の環境へ起動し直したスクリプト(`require` を呼ぶ小さなスクリプト)の子プロセスの `env` の出力に `UV_PROJECT_ENVIRONMENT` が無い | `release_project_env` を呼ばない・退避の値が無いときに外さないよう壊すと落ちる | | 受け入れ条件 6 | 起動し直したスクリプトの子プロセスとして、`requires-python` が合わない別のプロジェクトで `uv run --project <別のプロジェクト> python -c pass` を打った後も、共有の環境の `pyvenv.cfg` の中身と更新時刻が変わらず、`import psutil` が通る(本物の uv で、一時ディレクトリの共有の環境を使う。用意の仕方は表の下) | 受け入れ条件 5 と同じ壊し方で落ちる | | 受け入れ条件 7・I6 | 共有の環境を指す `UV_PROJECT_ENVIRONMENT` を親に持たせてラッパーを通しても、子の claude(差し替えの claude が環境を書き出す)の環境に無い | `enter` で外さないよう壊すと落ちる | diff --git a/issues/issue-2516-design.md b/issues/issue-2516-design.md deleted file mode 100644 index 81e8cae1c..000000000 --- a/issues/issue-2516-design.md +++ /dev/null @@ -1,275 +0,0 @@ -# cross-refactoring: テストの関数を持たない `test_*.py` をプロダクションコードとして扱い、`init` の試走が誤って止まらないようにする(#2516) - -## 目的 - -- **今どうなっているか**: cross-refactoring は、テストのファイルかどうかを名前の形だけで決める。名前が `test_` で始まるプロダクションコードがある(`plugins/ndf/scripts/lib/test_strategy.py` など)。それを変える Pull Request では、`init` がそのファイルを範囲テストのテンプレートの試走に入れる。pytest がテストを 1 件も集めずに終了コード 5 で終わり、正しいテンプレート(`scope_command`)を直す箇所として示して止まる -- **誰に効くか**: 名前が `test_` で始まるプロダクションコードを持つリポジトリで cross-refactoring を使う開発者。このリポジトリでは #2369 の Pull Request https://github.com/devbasex/ai-plugins/pull/2515 が検査のプランを通れない -- **変えると何が成り立つか**: Python のファイルは、名前の形に加えてテストの関数の定義があるときだけテストのファイルになる。試走の候補・テストの置き場所の判定・プロダクションコードの行数と危険フラグの判定が、同じ見分け方で同じ答えを出す - -## 適用範囲 - -- **働く範囲**: cross-refactoring を使うすべてのリポジトリ(配布先を含む)。中身で見分けるのは拡張子 `.py` のファイルだけで、ほかの言語は名前の形とディレクトリの名前で見分ける -- **プロジェクトごとに違うもの**: 無し(設定も引数も足さない。pytest の既定の収集の形だけを見る) -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| PR 2515 の試走の記録(`template-check.json`。suite `pytest` の `paths` が `lib/test_strategy.py`・`lib/test_triage.py`、`source: scope`、終了コード 5、判定 `defect`) | 実測 | `--scope` に並んだプロダクションコードのファイルが、名前の形だけでテストの置き場所とみなされ、候補になった | -| PR 2515 の変更ファイルの一覧(`gh pr view 2515 --json files`。`plugins/ndf/scripts/lib/test_strategy.py`・`test_triage.py` を含む) | 実測 | `refactor-scope.py` は変更したファイルそのものを `--scope` に入れる。止まった経路はファイルの名前の判定である | -| このリポジトリの追跡ファイルで名前がテストの形の 547 本(うち `.py` 497 本)を、行の正規表現(`^[ \t]*(?:async[ \t]+)?def[ \t]+test`)で調べた結果。テストの関数の定義が無いのは `lib/test_strategy.py`・`lib/test_triage.py`・`refactor_lib/init_test.py` の 3 本で、構文解析(`ast`)で調べた結果と一致した。所要は正規表現 0.03 秒、構文解析 1.44 秒 | 実測 | 正規表現で構文解析と同じ答えが出て、非機能の条件(1 秒以内の延び)に収まる | -| pytest 9.0.3 の既定の収集の形(このリポジトリの `.venv` の `_pytest/python.py` の `pytest_addoption`。`python_files` が `test_*.py`・`*_test.py`、`python_classes` が `Test`、`python_functions` が `test`。`pyproject.toml` はどれも上書きしていない) | 外部の一次情報(pytest の実装) | テストの関数とは、名前が `test` で始まる関数かメソッドの定義である | - -要求と受け入れ条件は #2516 の本文にある(コピーは `issues/issue-2516-requirements.md`)。この文書は「どう作るか」だけを扱う。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| cross-refactoring(`ndf-cross-refactoring`) | テストのファイル・テストの置き場所・プロダクションコード・試走の候補・危険フラグ | - -1 つのコンテキストに収まる。テストの戦略と試走の判定の表(`plugins/ndf/scripts/lib/test_strategy.py`)は変えない。 - -### 集約 - -見分け方そのものは、状態を持たない判定(ドメインサービス)である。持ち主は、プロダクションコードとテストのパスの判定(`refactor_lib/pathkinds.py`)である。答えが変わる集約は次の 3 つで、持ち主は変わらない。 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 試走の記録 | 試走(`template_check.check_templates`) | 試走の記録(`template-check.json`) | suite ごとの行 | 候補の選択(`ProbePick`)・判定(`ProbeVerdict`) | -| 項目の危険フラグ | 収束(`commands/converge.py` が `danger.item_flags` を呼ぶ) | 計画の項目 | — | 危険フラグの並び(`D1`〜`D5`)・D3 で当たった語 | -| リファクタリングの要否 | 要否の判定(`commands/assess.py`) | 判定の結果 | — | プロダクションコードの変更の並び(パスと変更行) | - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 3 つすべて | Python のファイルの中身でテストかどうかを決めるのは、プロダクションコードとテストのパスの判定にある 1 つの関数(中身の文字列を受ける `python_text_lacks_tests`)だけである。試走の候補・テストの置き場所・行数・危険フラグ・コミットの事実はその関数を呼ぶ | テストで落とす | -| I2 | 3 つすべて | 名前がテストの形の `.py` で、テスト用ディレクトリ(`tests/` など、`TEST_PATH_MARKERS` と `TEST_PATH_SEGMENTS` の印)の配下になく、読んだ中身がテストの関数の定義を 1 つも持たないものは、テストのファイルでない(テストの置き場所の根拠にならず、試走の候補に入らず、プロダクションコードとして数える)。読む中身は作業ディレクトリのファイルで、コミットの事実だけはそのコミットの前後の版(どちらかにテストの関数があればテスト)である。テスト用ディレクトリの配下のファイルは I4 に従う | テストで落とす | -| I3 | 3 つすべて | `.py` 以外のファイルと、中身を読めない `.py`(作業ディレクトリでは削除済み・作業ディレクトリが渡されない。コミットの事実では前後の版のどちらにも無い)は、名前の形の判定のまま扱う | テストで落とす | -| I4 | 試走の記録 | ディレクトリの名前(`tests/` など)によるテストの置き場所の判定は変わらない。その配下の `.py` は、テストの関数を持たなくてもテスト側に数える(`is_test_path` は真)。ただし試走の候補には入らない | テストで落とす | -| I5 | 試走の記録 | 中身を読むのは、名前がテストの形の `.py` だけである | テストで落とす | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E2 | `--scope` のうちテストの置き場所が決まった | テストの置き場所の判定(`scope.test_locations`) | 着手前のテスト(`baseline`)・試走(`template_check`)・対象の組み立て(`targets`)・危険フラグ(`danger`) | -| E4 | 試走の候補のテストのファイルが選ばれた | 試走の候補集め(`template_check._probe_candidates`) | 候補の選択(`test_strategy.probe_paths`) | -| E7 | 差分のプロダクションコードの変更が数えられた | 行数の判定(`pathkinds.production_code_changes`) | リファクタリングの要否(`commands/assess.py`) | -| E8 | 項目の危険フラグが決まった | 危険フラグの判定(`danger.item_flags`) | 収束(`commands/converge.py`) | - -E1・E3・E5・E6 は要求のとおりで、振る舞いを変えない。E7・E8 は要求の対象範囲の「行数と危険度の判定」をイベントとして足したものである。 - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| テストの関数 | Python のファイルで、名前が `test` で始まる関数かメソッドの定義(`async def` を含む)。pytest が既定で集める形 | 追加 | -| プロダクションコード | コードの拡張子を持ち、テストの置き場所でないファイル。名前がテストの形でもテストの関数を持たない `.py` は、テスト用ディレクトリ(`tests/` など)の配下になければ含む | 意味の変更 | - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | Python のファイルがテストの関数を持たないかを、中身の文字列で見分ける(作業ディレクトリのファイルか、コミットの前後の版) | F2〜F4 | -| F2 | `--scope` の 1 件がテストの置き場所かを決める(ファイル名で当たる場合・配下のファイルで当たる場合に F1 を使う) | `init`(着手前のテスト・試走)・対象の組み立て・危険フラグ | -| F3 | 試走の候補のテストのファイルを集める(F1 で外す) | `init` の試走 | -| F4 | パスがテスト側かプロダクションコードかを決める(作業ディレクトリを受けたときと、コミットの事実で前後の版を読んだときだけ F1 を使う) | リファクタリングの要否・危険フラグ・コミットの事実・テストの追加のコミットの検査 | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| プロダクションコードとテストのパスの判定(`refactor_lib/pathkinds.py`) | **変える。** F1 の中身の判定 `python_text_lacks_tests(text)` と、作業ディレクトリのファイルを読んでそれを呼ぶ `lacks_python_tests(path, work)` を足す。`is_test_path(path, work=None)` は `work` を受けたとき、ディレクトリの印が無く名前の形で当たった `.py` に F1 を当てる。コミットの前後の版の中身(読んだ文字列。読めなければ `None`)を受けて見分ける `is_test_path_in_versions(path, before, after)` を足す。この関数は git を起動しない | -| `--scope` とテストの置き場所の検査(`refactor_lib/scope.py`) | **変える。** `test_files_under` は名前の形で当たったファイルから F1 で当たるものを外す。`_matches_by_name` のファイル名の形の判定は、作業ディレクトリで F1 に当たるファイルを置き場所とみなさない。ディレクトリの名前の判定(`TEST_PATH_SEGMENTS`)は変えない | -| 危険フラグ(`refactor_lib/danger.py`) | **変える。** D3 と D4 でテストのファイルを外す箇所で `is_test_path` に `work` を渡す | -| 行数の判定(`pathkinds.production_code_changes`) | **変える。** `is_test_path` に `work` を渡す | -| コミットの事実(`refactor_lib/gitfacts.py` の `commit_touches_tests`・`commit_test_changes`) | **変える。** `commit_test_changes` は、名前の形で当たったファイルの前後の版を今と同じく 1 回ずつ読み(`git show ^:` と `:`)、その文字列を `is_test_path_in_versions` に渡して判定し、テストと判定したものだけを前後の行として返す。`commit_touches_tests` は git を起動せず、`commit_test_changes` の結果を受けて、空でなければ真を返す(引数をその結果に変える)。1 コミットのテストの名前の `.py` 1 本につき、git の起動は今と同じ 2 本のままである。作業ディレクトリの中身は読まない(決定 3) | -| テストの追加のコミットの検査(`commands/implement.py` の `_test_commit_problem`) | **変える。** `is_test_path` に作業ディレクトリ(`work_dir(state)`)を渡す | -| 試走(`refactor_lib/template_check.py`) | 変えない。`test_files_under` と `test_locations` の答えが変わることで候補が変わる | -| 着手前のテスト・対象の組み立て(`baseline.py`・`targets.py`) | 変えない。`test_locations` の答えが変わることで、置き場所の並びが変わる | -| コードの計測(`codemetrics.path_role`・`codemetrics_read._clone`) | 変えない。作業ディレクトリを受けないため名前の形の判定のまま(決定 3) | - -```mermaid -graph TD - PK[パスの判定
pathkinds] - SC[置き場所の検査
scope] - TC[試走
template_check] - BL[着手前のテスト・対象
baseline・targets] - DG[危険フラグ
danger] - AS[リファクタリングの要否
assess] - GF[コミットの事実
gitfacts] - IM[テストの追加の検査
implement] - SC -->|lacks_python_tests| PK - TC -->|test_files_under・test_locations| SC - BL -->|test_locations| SC - DG -->|test_locations| SC - DG -->|is_test_path work| PK - AS -->|production_code_changes| PK - GF -->|is_test_path_in_versions| PK - IM -->|is_test_path work| PK -``` - -図には変える要素と、答えが変わる呼び出し元だけを置く。変えないコードの計測は置かない。 - -システムの文脈と配置は変わらない。`refactor.py` は利用者の作業ディレクトリ(cross-refactoring の worktree)で動く。読むのはその中の追跡ファイルだけである。 - -```mermaid -graph TD - DR[drive.py] --> RF[refactor.py init・assess・converge] - RF -->|git ls-files・ファイルの読み取り| WT[作業ディレクトリ] - RF -->|試走| RN[テストのランナー] -``` - -## 構造 - -型は変えないため、クラス図は置かない。関数の配置は次のとおりで、ファイルは足さない。 - -```text -plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/ -├── pathkinds.py # python_text_lacks_tests・lacks_python_tests・is_test_path_in_versions を足す。is_test_path(path, work=None) -├── scope.py # _matches_by_name・test_files_under が lacks_python_tests を呼ぶ -├── danger.py # is_test_path に work を渡す -├── gitfacts.py # commit_test_changes が前後の版を 1 回読み is_test_path_in_versions を呼ぶ。commit_touches_tests はその結果から出す -└── commands/ - └── implement.py # is_test_path に作業ディレクトリを渡す -``` - -足す 3 つの関数は、分けた先のモジュールごとの定義の表の `pathkinds` の行へ足す。表は `tests/test_module_owners.py` の `OWNERS` である。 - -### 判定の規則 - -| 入力 | `lacks_python_tests(path, work)` | -| --- | --- | -| 拡張子が `.py` でない | 偽 | -| `work` が無い・`/` が読めない(無い・ディレクトリ・権限) | 偽 | -| 中身に、行頭の空白に続く `def test` か `async def test` の行がある | 偽 | -| 上のどれにも当たらない | 真 | - -名前の形は見ない。呼ぶ側が名前の形で当たったファイルにだけ呼ぶ(I5)。3 行目の正規表現の判定は `python_text_lacks_tests(text)` が持ち、`lacks_python_tests` は読めた中身をそれへ渡す。コミットの前後の版の中身も、`commit_test_changes` が読んだ文字列のまま同じ関数へ渡す(I1)。 - -| 判定 | 今 | 変えた後 | -| --- | --- | --- | -| `is_test_path(path)` | ディレクトリの印かファイル名の印があれば真 | 同じ(`work` を渡さない呼び出し元) | -| `is_test_path(path, work)` | — | ディレクトリの印があれば真。ファイル名の印があり、`lacks_python_tests` が偽なら真 | -| `is_test_path_in_versions(path, before, after)` | — | ディレクトリの印があれば真。ファイル名の印がある `.py` は、読めた版(`None` でない `before` か `after`)のどちらかで `python_text_lacks_tests` が偽なら真。どちらの版も読めなければ名前の形のまま真。`.py` 以外は名前の形のまま。git は起動せず、前後の版は `commit_test_changes` が 1 回ずつ読んで渡す | -| `commit_touches_tests(test_changes)` | `is_test_path` が真のファイルを触れば真(git でファイルの並びを読む) | `commit_test_changes` の結果が空でなければ真。git を起動しない | -| `test_files_under(path, work)` | 名前の形で当たる追跡ファイル | 名前の形で当たり、`lacks_python_tests` が偽のもの | -| `_matches_by_name(path, work)` | ディレクトリの名前かファイル名の形で当たれば真 | ディレクトリの名前で当たれば真。ファイル名の形で当たり、`lacks_python_tests` が偽なら真 | - -## 処理の流れ - -`init` の試走までの流れで、変わるのは「変更」と書いた 3 つの判定である。 - -```mermaid -graph TD - S[--scope の 1 件] --> D{ディレクトリの
名前で当たる} - D -->|はい| L[テストの置き場所] - D -->|いいえ| F{ファイル名の
形で当たる} - F -->|はい| P{変更: テストの関数を
持たない .py} - P -->|いいえ| L - P -->|はい| C - F -->|いいえ| C{配下に tests などの
ディレクトリ} - C -->|ある| L - C -->|無い| U{変更: 配下に
テストのファイル} - U -->|ある| L - U -->|無い| N[置き場所でない] - L --> Q[変更: 配下の
テストのファイルを候補へ] - Q --> R[probe_paths が
最大 2 本を選ぶ] -``` - -「ファイル名の形で当たるが、テストの関数を持たない `.py`」は、置き場所とみなさずに次の判定(配下のディレクトリ・配下のファイル)へ進む。ファイルには配下が無いため、結果は「置き場所でない」になる。 - -リファクタリングの要否と危険フラグは、差分やコミットのファイルごとに `is_test_path(path, work)` を 1 回呼ぶだけで、順序は変わらない。 - -## 非機能の実現方式 - -| 大項目 | 条件 | 実現方式 | 確かめ方 | -| --- | --- | --- | --- | -| 性能・拡張性 | 中身を読むのは名前がテストの形の `.py` だけ。`init` の候補選びの時間が変更前から 1 秒を超えて延びない | 名前の形で当たった `.py` にだけ `lacks_python_tests` を呼ぶ。中身は構文解析せず、行の正規表現で 1 回探す(決定 1)。このリポジトリの `.py` 497 本を読んで 0.03 秒(あるべき姿の根拠) | 実装の後、このリポジトリで `test_files_under(".", work)` の所要を変更の前後で測り、Pull Request の本文に添える | - -## 決定の記録 - -### 決定 1: 非機能の条件に収め、テストのランナーを起動しないため、テストの関数の有無を行の正規表現で見分ける - -行頭の空白に続く `def test` か `async def test` の行があれば、テストの関数を持つとみなす。このリポジトリの 497 本で構文解析(`ast`)と同じ 3 本を見分け、所要は 0.03 秒だった。構文解析は 1.44 秒で、非機能の条件(延びが 1 秒以内)を超える。文字列や docstring の中の `def test` で「持つ」と誤る場合は、今の振る舞い(名前の形でテスト)に倒れる。そのため、止まる方向の誤りは増えない。 - -pytest の収集(`--collect-only`)は採らない。採ると、候補ごとにランナーを起動し(所要は測っていない)、ランナーの語と終了コードを読むことになる。これは試走のコマンドの語を見ない約束(#1334 の I1)から外れる。 - -根拠: Value 4 / Value 6(MVV 版 5) - -### 決定 2: 同じファイルを一方でテスト、もう一方でプロダクションコードと数えないよう、中身の判定をパスの判定の 1 関数に置き、置き場所の検査と `is_test_path` の両方から呼ぶ - -テストかどうかの判定はすでに `pathkinds` が持ち主である(`tests/test_module_owners.py`)。中身の判定をそこへ置き、`scope.py` はそれを呼ぶ。`scope.py` に置いて `pathkinds` から呼ぶ形は採らない。`scope.py` は `--scope` の検査である。そこに置くと、行数と危険フラグの判定が検査に依存する向きになる。 - -根拠: Value 7(MVV 版 5) - -### 決定 3: 中身を読む手段を持つ呼び出し元だけが中身で見分けるよう、`is_test_path` は作業ディレクトリを任意の引数で受け、渡されなければ名前の形のまま判定する - -作業ディレクトリを持つ呼び出し元(行数の判定・危険フラグ・テストの追加のコミットの検査)は渡す。コミットの事実(`commit_touches_tests`・`commit_test_changes`)は作業ディレクトリの中身を読まず、そのコミットの前後の版(`git show ^:` と `:`)を読み、どちらかにテストの関数があればテストとする(`is_test_path_in_versions`)。前後の版を読むのは `commit_test_changes` の 1 か所で、ファイルごとに 1 回ずつである。判定はその文字列を受け、`commit_touches_tests` は `commit_test_changes` の結果から出す。判定と前後の行の取得で同じ版を読み直すと、1 コミットのテストの名前の `.py` 1 本につき git の起動が今の 2 本から 6 本へ増える。作業ディレクトリの中身で見分けると、本体と同じ場所の `lib/test_z.py` からテストの関数をすべて消すコミットが、その後の作業ディレクトリではプロダクションコードと読まれる。するとそのコミットは `test_changes` から外れ、`verify.verify_test_changes` によるテストの期待値の変更の取り消しを通り抜ける。テストの関数が版の間で消える場面は、この検査が捕まえる場面そのものである。中身の判定は `python_text_lacks_tests` を共有し、I1 を保つ。コードの計測(`codemetrics`)は、計測ツールの出力を読む関数の並びで、作業ディレクトリを受けない。そのため名前の形のまま残す。計測の表示でテスト側に並ぶ誤りは、範囲外の課題 #2557 に置く。 - -`is_test_path` を必須の引数にして全呼び出し元を変える形は採らない。必須にすると、コードの計測の読み取りの関数へ作業ディレクトリを通す変更が要る。当たる関数は `parse_ruff`・`parse_lizard`・`file_metrics`・`parse_symilar`・`parse_jscpd` である。その変更は、この課題の受け入れ条件(行数と危険度)の外へ広がる。 - -根拠: Value 3 / Value 7(MVV 版 5) - -### 決定 4: 試走の判定の表を変えないため、終了コード 5 を `undecided` に読み替えない - -終了コード 5(テストが集まらない)は pytest の約束で、ほかのランナーでは別の意味を持つ。読み替えると、試走がランナーの終了コードの意味を読むことになる。これは #1334 の I1 から外れる。この課題の直し方では、テストの関数を持たないファイルが候補に入らないため、再現の手順の条件で終了コード 5 は起きない。候補にテストが残っていても集まらない場合(pytest の設定で収集の形を変えたリポジトリ)は「未確認のまま残ること」に置く。 - -根拠: Value 6 / Value 3(MVV 版 5) - -### 決定 5: 試走が走らせて意味のあるファイルだけを入れるため、`tests/` の配下でもテストの関数を持たない `.py` は候補から外し、置き場所の判定は変えない - -`tests/test_helpers.py` のような補助のファイルを候補に入れると、同じく終了コード 5 で止まる。外すのは候補(`test_files_under`)だけである。`tests/` をテストの置き場所とみなす判定と、`is_test_path` がディレクトリの印でテスト側とする判定は変えない。補助のファイルを変える差分を、行数と危険フラグでプロダクションコードと数えないためである。 - -根拠: Value 7(MVV 版 5) - -### 決定 6: Python 以外の言語の判定を変えないため、2 つの名前の形の並び(`TEST_NAME_PATTERNS` と `TEST_NAME_MARKERS`)はそろえない - -2 つの並びは、当たる名前が違う。 - -| 判定 | 名前の形の並び | 当たり方 | -| --- | --- | --- | -| 置き場所の検査 | `TEST_NAME_PATTERNS` | `fnmatch` の形(`test_*` など) | -| パスの判定 | `TEST_NAME_MARKERS` | 部分一致の印(`test_`・`spec_` など) | - -そろえると `spec_helper.rb` などの判定が変わり、受け入れ条件 6 を満たさない。部分一致の印が `latest_version.py` を名前でテストと読む誤りは、範囲外の課題 #2557 に置く。 - -根拠: Value 3(MVV 版 5) - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1 / I2 | テストの関数を持たない `lib/test_x.py` は、`--scope` の置き場所の配下とリポジトリ全体のどちらから集めても試走の候補に入らない | `test_files_under` から `lacks_python_tests` の呼び出しを外すと落ちる | -| 受け入れ条件 2 / I2 | `--scope` に `lib/test_x.py`(ファイル)か `lib`(配下がそのファイルだけ)を渡すと、テストの置き場所とみなされない | `_matches_by_name` のファイル名の判定か `test_files_under` の中身の判定を外すと落ちる | -| 受け入れ条件 3 | 一時の git リポジトリで `lib/test_x.py`(テストの関数なし)と `tests/test_y.py`(テストあり)を置き、`refactor-scope.py` と同じ形の `--scope`(変えたファイルと近くの `tests`)で `init` を打つと、試走の判定が `ok` で止まらない | 置き場所の判定を名前の形だけに戻すと、`defect` で止まって落ちる | -| 受け入れ条件 4 | テストの関数を持つ `tests/test_y.py` と本体と同じ場所の `lib/test_z.py` は、今までどおり候補と置き場所に入る。`async def test` だけを持つファイルとメソッドだけを持つファイルも入る | 判定の正規表現を関数の定義の行だけ・行頭だけに狭めると落ちる | -| 受け入れ条件 5 / I1 | テストの関数を持たない `lib/test_x.py` の変更は、リファクタリングの要否でプロダクションコードの変更として数えられ、危険フラグの D3・D4 の対象に入る | `production_code_changes` か `danger` で `work` を渡さないと落ちる | -| 受け入れ条件 6 / I3 | `*.spec.ts`・`*_test.go`・`tests/` 配下の判定と、作業ディレクトリを渡さない `is_test_path` の答えは変わらない | 既存のテスト(`test_scope_gate.py`・`test_danger.py`・`test_assess.py`・`test_template_check.py`)が変更なしで通る。`.py` 以外にも中身を読むように壊すと落ちる | -| 受け入れ条件 7 | 全体テストが通る | — | -| I1 | 同じファイルについて `test_files_under` と `is_test_path(path, work)` の答えが一致する(置き場所の配下でない場合) | どちらかが中身の判定を独自に持つと、一方だけを壊したときに落ちる | -| I3 | 作業ディレクトリに無い `.py`(削除済み)は名前の形のままテストとみなされる | 読めないファイルを「テストの関数なし」と読むと落ちる | -| I4 | `tests/test_helpers.py`(テストの関数なし)は試走の候補に入らないが、`tests` は置き場所のままで、`is_test_path` は真を返す。その変更はリファクタリングの要否でプロダクションコードの変更に数えられず、危険フラグの D3・D4 の対象に入らない | ディレクトリの印より先に中身を見るように壊すと落ちる | -| 決定 3 / I2 | 本体と同じ場所の `lib/test_z.py` から全テストの関数を消す実装のコミットが、コミットの事実の `touches_tests` で真になり、`test_changes` に入って、テストの期待値の変更として検出される | `commit_test_changes` か `commit_touches_tests` が作業ディレクトリの中身で見分けるように戻すと落ちる | -| 決定 3 | テストの名前の `.py` 1 本を変えるコミットで `collect_commit_facts` を打つと、`^:` と `:` を読む `git show` はそれぞれ 1 回だけ起動される | 判定と前後の行の取得で版を読み直すか、`commit_touches_tests` が自分で版を読むように壊すと落ちる | -| I5 | 名前がテストの形でない `.py` とテストの形の `.ts` では、ファイルを読まない | 名前の判定の前に中身を読むように壊すと落ちる | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2516 | 実装する | — | `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/pathkinds.py`、`plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/scope.py`、`plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/danger.py`、`plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/gitfacts.py`、`plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/commands/implement.py`、`plugins/ndf/skills/cross-refactoring/tests/` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| pytest の設定で収集の形を変えたリポジトリ | `python_functions` を `check_*` などへ変えたリポジトリでは、テストの関数を持たないと誤って読み、テストのファイルを候補から外す。外れた分は候補 0 本の `undecided` か別の候補で試走する。設定を読む形は、該当するリポジトリが見つかるまで作らない | -| 定義の行を持たないテスト | 代入や import でテストの関数を作るファイル(`test_x = make_case()`)は「持たない」と読む。このリポジトリの 497 本には無かった(あるべき姿の根拠の実測) | -| コードの計測の表示 | テストの関数を持たない `test_*.py` は、計測の表示ではテスト側に並ぶ(決定 3)。範囲外の課題 #2557 に置く | -| 手元の非機能の実測 | 0.03 秒は設計の時点でこのリポジトリを調べたスクリプトの値で、実装した関数で `init` を通した所要はまだ測っていない | diff --git a/issues/issue-2516-requirements.md b/issues/issue-2516-requirements.md deleted file mode 100644 index 0280c48f3..000000000 --- a/issues/issue-2516-requirements.md +++ /dev/null @@ -1,165 +0,0 @@ -# #2516: cross-refactoring: 名前が test_ で始まる本番のモジュール(lib/test_strategy.py)をテストとみなし、範囲テストのテンプレートの試走が終了コード 5 で止まる - -正は課題の本文(#2516)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 依頼(原文) - -> ## 現象 -> -> 開発ループ lroot3 の周回 10(スプリント lroot3-10、#2369、Pull Request https://github.com/devbasex/ai-plugins/pull/2515 )の検査のプランで、cross-refactoring の `init` が範囲テストのテンプレートの試走で止まった(`refactor.py init` が終了コード 4)。 -> -> ```text -> ⛔ 範囲テストのテンプレートが {paths} に入るテストのファイルを受けません(suite: pytest) -> 入れたパス: plugins/ndf/scripts/lib/test_strategy.py plugins/ndf/scripts/lib/test_triage.py -> 試走: uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/lib/test_strategy.py plugins/ndf/scripts/lib/test_triage.py -q -n 4 ... → 終了コード 5 -> ``` -> -> 記録: `/tmp/ndf-worktrees/devbasex--ai-plugins/rf2515/work/.cross_refactoring/template-check.json`、プランの報告 `/work/ai-plugins/.ndf/loop/lroot3/lap-10/sprint/4-check-state/report.md`(手元の記録) -> -> ## 原因 -> -> `plugins/ndf/scripts/lib/test_strategy.py` と `test_triage.py` は本番のモジュールで、テスト関数を持たない。テストかどうかを名前だけで決めているため、テストのファイルとして扱われる。 -> -> - `refactor_lib/scope.py` の `test_files_under` は名前の形(`TEST_NAME_PATTERNS`)だけで拾う。`test_locations` は「配下にテストの名前のファイルがある」ことで `plugins/ndf/scripts/lib` をテストの置き場所とみなす(決定 12 の本体と同じ場所に置く構成) -> - `template_check._probe_candidates` がその 2 本を試走の候補にし、pytest がテストを 1 件も集められず終了コード 5 で終わる。着手前のテストは通っているので `defect` と判定され、テンプレートの不備として止まる -> - テンプレート(`.ndf/project.json` の `test.suites[0].scope_command`)は正しい。直す箇所の案内が誤っている -> -> `plugins/ndf/scripts/lib` を触る Pull Request なら、#2369 に限らず同じように止まる(`refactor-scope.py` が変更のディレクトリを `--scope` に入れるため)。`pathkinds.is_test_path`(`test_` を名前に含めばテスト)も同じ基準で、危険度や本番コードの行数の数え方にも効いている。 -> -> ## 再現の手順 -> -> 1. `plugins/ndf/scripts/lib/` の本番のファイルを 1 行変える Pull Request を作る -> 2. `python3 plugins/ndf/scripts/refactor-scope.py --pr <番号>` の出力を `--scope` に渡して `drive.py <番号> --workflow-step` を打つ -> 3. `init` が上の ⛔ で止まる -> -> ## 受け入れ条件 -> -> 1. テスト関数を持たない `test_*.py`(本番のモジュール)が、範囲テストのテンプレートの試走の候補とテストの置き場所の判定に入らない。上の再現の手順で `init` が止まらない -> 2. 直す箇所の案内が、テンプレートが正しいのに `scope_command` を指さない -> 3. 全体テストが通る -> -> ## 補足 -> -> - 即時修正の 4 条件のうち「既存の方針の範囲に収まる」を満たすか判断がつかない(テストの見分け方を名前から中身へ広げると、他言語の構成と決定 12 の扱いが変わる)ため、その場で直さずに起票した -> - この不具合が解けるまで、#2369 の Pull Request 2515 は検査のプランを通れない - -(書き手: 起票者(開発ループ lroot3 の conductor)、日付: 2026-11-05。利用者の指示の原文は含まない) - -> #2516。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 - -(書き手: AI の起動指示、日付: 2026-11-05) - -## 起票者の推定 - -課題の全文が起票者(AI)の記録で、利用者の指示の原文は無い。承認ゲート 1 で利用者が確かめるまで、次の条件を利用者の決定として扱わない。S4・S5 は要求を書いた AI が足した判断である。 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 名前が `test_` で始まりテスト関数を持たない Python のファイルは、テストのファイルとして扱わない | 本文の「原因」「受け入れ条件 1」 | 受け入れ条件 1・2 | -| S2 | 案内がテンプレートを直す箇所として指すのは、テンプレートに不備があるときだけにする | 本文の「受け入れ条件 2」 | 受け入れ条件 3 | -| S3 | プロダクションコードの行数と危険度の判定(`pathkinds.is_test_path`)も同じ誤りを持つ | 本文の「原因」の最後の段落 | 受け入れ条件 5(S4 のとおり含める) | -| S4 | テストかどうかの見分け方は、試走の候補・置き場所の判定・行数と危険度の判定で 1 つにそろえる。片方だけ直すと、同じファイルを一方はテスト、もう一方はプロダクションコードと数える | プロジェクト MVV の Value 7(同じ役割の関数を分けない、根本原因の場所で直す) | 受け入れ条件 5・前提 2 | -| S5 | Python 以外の言語のテストの見分け方(名前の形とディレクトリの名前)は変えない | 本文の「補足」(他言語の構成と決定 12 の扱いが変わる懸念) | 受け入れ条件 6・前提 1・含まない | - -## 目的 - -- 名前が `test_` で始まるプロダクションコードのファイルを持つリポジトリでも、cross-refactoring の着手(`init`)がテンプレートの試走で誤って止まらないようにする -- 止まったときの案内が、正しいテンプレートを直す箇所として示さないようにする -- #2369 の Pull Request https://github.com/devbasex/ai-plugins/pull/2515 が検査のプランを通れるようにする - -## 前提 - -- 前提 1: 中身でテストかどうかを見分ける対象は Python のファイル(拡張子 `.py`)に限る。Python の場合、テストの関数とは pytest が既定で集める形(名前が `test` で始まる関数、名前が `Test` で始まるクラスの中の `test` で始まるメソッド)を指す。ほかの言語のファイルは今の名前の形の判定のまま扱う -- 前提 2: 行数と危険度の判定(`is_test_path`)は今はパスの文字列だけで決めている。見分け方をそろえるために作業ディレクトリの中身を読む形へ変えてよい。読めないファイル(削除済み・リポジトリの外)は今の名前の判定で扱う -- 前提 3: ディレクトリの名前でテストの置き場所とみなす判定(`tests/` `spec/` など)は変えない。`tests/` の配下にあるテスト関数を持たない補助のファイル(`tests/test_helpers.py` など)も、試走の候補からは外す(受け入れ条件 1 の対象に含む) -- 前提 4: プロダクションコードのファイルの名前を変える(`test_strategy.py` を別名へ移す)ことはこの課題の直し方にしない。ほかのリポジトリにも同じ構成がありうるため、見分け方の側で直す(Value 6) - -## 対象範囲 - -含む: -- cross-refactoring のテンプレートの試走の候補選び -- `--scope` のテストの置き場所の判定(本体と同じ場所にテストを置く構成の判定) -- プロダクションコードの行数と危険度の判定でのテストのファイルの見分け方(S4) -- 上の 3 つを確かめるテスト - -含まない: -- Python 以外の言語のテストの見分け方の変更(S5) -- `plugins/ndf/scripts/lib/test_strategy.py`・`test_triage.py` の名前の変更(前提 4) -- 試走の判定の表(`template_verdict`)の作り直し。終了コード 5 の扱いは「未決」に置く -- cross-refactoring 以外の Skill のテストの見分け方 - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | `init` が `--scope` を受け取った | `drive.py <番号> --workflow-step` | `--scope` が無ければ既存の検査で止まる | — | -| E2 | `--scope` のうちテストの置き場所が決まった | E1 | 置き場所が 0 件なら既存の検査(`scope_problem`)で止まる | E1 | -| E3 | 着手前のテストが走り、suite ごとの成否が記録された | E2 | 既存の振る舞い(既存失敗として記録) | E2 | -| E4 | 試走の候補のテストのファイルが選ばれた | E3 | 候補が 0 本なら `undecided` で止まる(既存) | E2・E3 | -| E5 | テンプレートの試走が走った | E4 | 上限で打ち切り・起動の失敗は既存の判定 | E4 | -| E6 | 試走の判定(`ok` / `defect` / `undecided`)が決まり、`ok` 以外なら直す箇所の案内を出して止まった | E5 | — | E3・E5 | - -E2 と E4 がこの課題の対象である。E2 でテスト関数を持たないファイルを根拠に置き場所とみなすと、E4 でそのファイルが候補になり、E5 がテストを 1 件も集めずに落ちる。 - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 範囲テストのテンプレート | `.ndf/project.json` の `test.suites[].scope_command`。`{paths}` にテストのファイルのパスを入れて走らせる | -| テンプレートの試走 | `init` が着手前のテストの後に、範囲テストのテンプレートへテストのファイルを最大 2 本入れて 1 回走らせ、`{paths}` に入るパスを受けるかを見分けること | -| プロダクションコード | コードの拡張子を持ち、テストの置き場所でないファイル。この課題の後は、名前が `test_` で始まりテストの関数を持たない `.py` もここに入る | - -## 受け入れ条件 - -- [ ] 1. Python のファイルで、名前が `test_` で始まり、テストの関数(前提 1)を 1 つも持たないものは、試走の候補に入らない(`--scope` の配下・リポジトリ全体のどちらから選ぶ場合も) -- [ ] 2. `--scope` の 1 件の配下に、テストの関数を持たない `test_*.py` しか無いとき、その 1 件はテストの置き場所とみなされない(ディレクトリの名前で当たる場合を除く。前提 3) -- [ ] 3. 本文の「再現の手順」と同じ条件(`plugins/ndf/scripts/lib/` のプロダクションコードのファイルだけを変え、`refactor-scope.py` の出力を `--scope` に渡す)で、`init` がテンプレートの試走で止まらず、`scope_command` を直す箇所として示す案内も出ない -- [ ] 4. テストの関数を持つ `test_*.py`(`tests/` の配下・本体と同じ場所のどちらでも)は、今までどおり試走の候補とテストの置き場所の判定に入る -- [ ] 5. プロダクションコードの行数と危険度の判定で、テストの関数を持たない `test_*.py` はプロダクションコードとして数えられる(受け入れ条件 1・2 と同じ見分け方になる) -- [ ] 6. Python 以外の言語の名前の形(`*.spec.ts`・`*_test.go` など)とディレクトリの名前による判定の結果は変わらない(既存のテストが変更なしで通る) -- [ ] 7. 全体テストが通る - -## 非機能の条件 - -| 大項目 | 条件 | -| --- | --- | -| 性能・拡張性 | 中身を読むのは名前がテストの形の `.py` のファイルだけにする。このリポジトリで `init` の候補選びにかかる時間が、変更前の実測から 1 秒を超えて延びない | - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わらない(`refactor.py` と `drive.py` の引数・結果の JSON の形は同じ) | -| データ | 無し | -| 既存の振る舞い | テスト関数を持たない `test_*.py` が、試走の候補・テストの置き場所・行数と危険度の判定でテストとして扱われなくなる | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/skills/cross-refactoring -q -n 4` と全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` | -| 静的解析・型検査 | CI の既存のジョブ(ruff) | -| 手動確認 | 配布の後、#2369 の Pull Request https://github.com/devbasex/ai-plugins/pull/2515 の検査のプランで `init` が試走で止まらないことを、`template-check.json` の判定が `ok` であることで確かめる(リリース後テスト) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | cross-refactoring のスクリプトは `plugins/ndf/skills/cross-refactoring/scripts/refactor_lib/`、テストは `plugins/ndf/skills/cross-refactoring/tests/` に置く。テストの見分け方は 1 か所に置き、呼び出し元で写さない(Value 7) | -| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストを書かない | -| テスト戦略 | 判定の関数は単体テストで、`init` の通しの振る舞いは `test_init.py` の形の結合テストで確かめる。受け入れ条件 3 は一時の git リポジトリに `lib/test_x.py`(テスト関数なし)を置いて再現する | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存テストの実行、変更範囲内の命名の統一 | -| 確認してから行う | テストの見分け方を変えることで変わる既存のテストの期待値(承認ゲート 2 で人が見る) | -| 行わない | プロダクションコードのファイルの改名、Python 以外の言語の判定の変更、試走の判定の表の作り直し | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 候補にテストが残っていても pytest が終了コード 5(テストが集まらない)で終わったとき、`defect` でなく `undecided` と判定するか。suite の実行器を読まない現行の方針(#1334 I1)との関係を含めて決める | 設計(`design`) | 設計 PR の承認まで | -| 中身の判定を、ファイルの構文解析で行うか、pytest の収集(`--collect-only`)で行うか | 設計(`design`) | 設計 PR の承認まで | diff --git a/issues/issue-2555-design.md b/issues/issue-2555-design.md new file mode 100644 index 000000000..9fba8a299 --- /dev/null +++ b/issues/issue-2555-design.md @@ -0,0 +1,312 @@ +# リリースの工程: 版の節と GitHub Release に、その版のリリースタグに入った PR だけを載せる(#2555) + +## 目的 + +- **今どうなっているか**: 正式版 10.27.21 の版の節と GitHub Release の本文が、タグに入っていない #2542・#2543 の 4 行を載せている。版の節に並べる PR の範囲の終点がベースブランチの最新(`origin/develop`)であり、承認したコミットより後にマージされた PR まで拾うためである。あわせて、リリースタグのある版の節(10.27.2)へ後の開発版が行を足し、正式版を出さなかった版の節(10.27.22・10.27.23)が残っている +- **誰に効くか**: 正式版の利用者(変更点の記載と中身が一致する)と、承認ゲート 2 で版の中身を確かめる人 +- **変えると何が成り立つか**: 版の節・承認資料の「含む PR」・GitHub Release の本文が、出す版のコミットに入った PR だけを載せる。リリースタグのある版の節は工程から書き換わらず、正式版を出した時点でタグの無い途中の版の節が残らない + +## 適用範囲 + +- **働く範囲**: NDF を使うすべてのリポジトリのうち、配布の形が `package-plugin` のもの。変わるのは `release-steps.py` の `changelog`・`notes`・`approval-facts` である。既存の食い違いの修正(`CHANGELOG.md` と GitHub Release `ndf--v10.27.21` の本文)は ai-plugins だけ +- **プロジェクトごとに違うもの**: ベースブランチ・本番チャネル・配るプラグインの名前。いずれも今と同じく `.ndf/` の宣言と引数で受け、この変更で増やさない +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| `git merge-base --is-ancestor d90cf16ca ndf--v10.27.21` が失敗し、`git show ndf--v10.27.21:CHANGELOG.md` の 10.27.21 の節に #2543 の 4 行がある | 実測(課題 #2555 の本文、2026-11-05) | 範囲の終点を出す版のコミットにしないと、タグの外の PR が版の節に載る | +| PRD1・PRD2・PRD3(git-cliff・release-please・semantic-release) | 外部の一次情報 | リリースノートの生成は「前のリリースタグから、リリースするコミットまで」を範囲にする形が標準である | +| STD1(Keep a Changelog 1.1.0) | 外部の一次情報 | まだ出していない変更は 1 つの未リリースの節に集め、リリース時に版の節へ移す。途中の版の節を残す形は採らない | +| 版 10.27.2-dev.1 の版の上げ(`55f9015cf`、2026-10-31 11:30 UTC)が、リリースタグ `ndf--v10.27.2`(同日 10:56 UTC)の後に 10.27.2 の節へ 5 行を足した | 実測(`git log -1 --format=%ci`、2026-11-05) | リリースタグのある版の節へ書く呼び出しを止めないと、タグの中身と節が食い違う | + +要求と受け入れ条件は #2555 の本文にある(コピーは `issues/issue-2555-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| リリース(`ndf-release`) | 版の節・範囲の PR・範囲の終点・リリースタグのある版 | +| 開発の工程(`ndf-workflow`) | 正式版・開発版・承認資料・承認したコミット・リリース差分 | + +開発の工程が供給者、リリースが顧客の関係(顧客 / 供給者)。リリースは開発の工程が決めた承認したコミットと、そこから作ったリリースのブランチを受け取り、範囲の終点として使う。承認したコミットを自分で決め直さない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 変更履歴 | `release-steps.py` の `changelog`・`notes` | `CHANGELOG.md` | 版の節 | 箇条・見出しの版 | +| 範囲の PR | `release_lib/included.py` | 範囲(前のリリースタグ・範囲の終点) | 範囲の PR(`IncludedPr`) | PR の番号・スプリント経由の印 | +| 承認資料 | `release-steps.py` の `approval-facts`・`notes --approval` | 承認資料のファイル | 「含む PR」の行 | 承認したコミット | + +変更履歴と承認資料は、範囲の PR を `included.section_range` の戻り値から受け取り、`--prs` の番号の終点の確かめは `included.drop_outside` だけが行う。範囲を自分で求め直さない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 範囲の PR | 範囲の終点は出す版のコミットである。`changelog`・`notes` では worktree の先頭(リリースのブランチの先頭)、`approval-facts` では承認に出すコミット。範囲の PR は前のリリースタグからその終点までの first-parent のマージと、そこから辿れるスプリント経由の PR に限る。`--prs` で渡した番号もこの条件に従う。範囲の PR に無い番号は、マージコミットが終点の祖先であるときだけ足し、祖先でなければ外して、結果の `items` に出す | テストが落ちる(受け入れ条件 5・9) | +| I2 | 承認資料 | 「含む PR」と「承認したコミット」は、同じ 1 つのコミットから求める | テストが落ちる(受け入れ条件 5) | +| I3 | 変更履歴 | リリースタグ `<プラグイン>--v<版>` のある版の節を、`changelog` と `notes` は書き換えない。書く呼び出しは書き込む前に止まり、理由を結果に出す | 終了コード 3(前提の不足)で止まる。テストが落ちる(受け入れ条件 6) | +| I4 | 変更履歴 | 正式版の節を書いた後、前のリリースタグの版より新しく出す版より古い版の節は、すべてリリースタグを持つ | テストが落ちる(受け入れ条件 7) | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | PR がベースブランチへマージされた | 実装の PR のマージ | 次の範囲の計算 | +| E2 | 開発版の版の節を書いた | 開発版のプランの `changelog`・`notes` | 開発版のリリースの PR | +| E3 | 正式版を出すコミットが承認された | 承認ゲート 2(`approval-facts` が出した承認したコミット) | 本番のプラン | +| E4 | リリースのブランチを承認したコミットから作った | 本番のプランの worktree の用意(計画の「起点のコミット」) | 本番のプランの `changelog`・`notes` | +| E5 | 正式版の版の節を書いた | 本番のプランの `changelog`・`notes`。範囲の終点は E4 のブランチの先頭 | `release` | +| E6 | リリースのブランチを本番チャネルへ入れ、リリースタグを打った | `release` | GitHub Release の作成 | +| E7 | GitHub Release を版の節から作った | `release`(E6 の続き) | 利用者 | +| E8 | 次の開発版の版の節を書いた | 次の開発版の `changelog`・`notes` | E2 と同じ。リリースタグのある版の節には書かない(I3) | + +E5 と E1 の順序は保証されない。E4 の後もベースブランチへのマージは続くため、範囲の終点はベースブランチではなく E4 のブランチの先頭から取る。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 版の節 | `CHANGELOG.md` の `## [ <基底の版>]` の見出しから次の `## ` までの節 | 変更なし(用語集の `ndf-release` の語をそのまま使う) | +| 範囲の PR | 前のリリースタグから範囲の終点までにベースブランチへ入った PR と、スプリント経由の PR の和。`--prs` で渡した番号は、終点に入ったものだけを含む | 意味を変える(`ndf-release`)。終点を「承認したコミットか、ベースブランチの先端」から「範囲の終点」へ変える。出典の仕様 `docs/specifications/ndf-release-approval-included-prs.md` の終点の表(`changelog`・`notes` が `origin/`)は、今の実装の振る舞いを書く文書のため、実装の PR で `section_range` の変更と同じコミットで直す | +| 範囲の終点 | 版の節と承認資料の「含む PR」に並べる PR の範囲の、新しい側の端のコミット。出す版のコミットを指す | 追加(`ndf-release`) | +| リリースタグ | 正式版を出したときに本番チャネルのコミットへ打つタグ `<プラグイン>--v<版>`。リリースタグのある版の節は工程から書き換えない | 追加(`ndf-release`) | + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べる | 変更履歴の書き方(未リリースの変更の扱い・リリース済みの節の扱い)に事実上の標準がある | +| 主要な製品 | 調べる | リリースノートを生成する主要な製品が、範囲の終点をどこに置くかを比べる | +| ライブラリとサービス | 調べる | 範囲の計算を既存のツールへ置き換えられるかと、GitHub Release の本文だけを書き換える手段を決める | + +### 標準・仕様 + +| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| STD1 | Keep a Changelog 1.1.0 | 先頭に `Unreleased` の節を置いて出す前の変更を集め、リリース時にその中身を新しい版の節へ移す。誤りを見つけたら変更履歴を直してよい | 正式版を出すとき、タグの無い途中の版の節を消して正式版の節へ集める(決定 3)。既存の食い違いは develop の `CHANGELOG.md` を直す | https://keepachangelog.com/en/1.1.0/ (2026-11-05) | +| STD2 | GitHub REST API の Update a release(`PATCH /repos/{owner}/{repo}/releases/{release_id}`) | `body` だけを送って本文を書き換えられる。`tag_name` は必須でない | タグに触れずに `ndf--v10.27.21` の本文だけを直す(決定 4) | https://docs.github.com/en/rest/releases/releases#update-a-release (2026-11-05) | + +### 主要な製品 + +| # | 製品 | どう解いているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| PRD1 | git-cliff | `--unreleased` はタグに属さないコミット、`--latest` は最新のタグからのコミットを扱う。範囲は `v0.1.0..HEAD` のように git の範囲で渡す | 範囲を「前のリリースタグ..出す版のコミット」の git の範囲で求める(決定 1) | https://git-cliff.org/docs/usage/args (2026-11-05) | +| PRD2 | release-please | 最後のリリースタグ以降のコミットを見てリリースの PR を作り、そのコミットへタグを打ってから GitHub Release を作る | タグを打つコミットと、ノートを組む範囲の終点を同じにする(決定 1) | https://github.com/googleapis/release-please (2026-11-05) | +| PRD3 | semantic-release | 最後のリリースのコミットをタグから求め、リリースするブランチで CI が動いた時点のコミットまでを解析してノートを作る | 同上(決定 1) | https://semantic-release.gitbook.io/semantic-release/ (2026-11-05) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | git-cliff | 2.14.0 | Apache-2.0 / MIT | 確かめられなかった | 無し | 満たさない。コミットの題から節を組み、PR の本文の「利用者向けの変化」とスプリント経由の PR の扱い(`included.py`)を持たない。範囲の計算だけを置き換えても、PR の読み出しと箇条の組み立ては残る | https://git-cliff.org/docs/usage/args 、 https://github.com/orhun/git-cliff (2026-11-05) | +| LIB2 | `gh release edit --notes-file` | 手元の `gh`(このリポジトリの工程が既に依存) | MIT | 確かめていない | 無し | 満たす。タグを指定してノートだけをファイルから書き換える | https://cli.github.com/manual/gh_release_edit (2026-11-05) | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| git-cliff の最後のリリースの日付 | https://github.com/orhun/git-cliff の取得 | リリースの一覧が取得したページに無く、日付を読めなかった | +| semantic-release の現行の文書 | https://semantic-release.gitbook.io/semantic-release/ の取得 | 取得したページは保守を終えた旧サイトで、移った先(semantic-release.org)は試していない | + +調べた量: 候補 7 件・取得の試み 7 回。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 版の節と承認資料の「含む PR」に、出す版のコミットまでに入った PR だけを並べる | 配布のプラン(開発版・本番)と承認ゲート 2 で読む人 | +| F2 | リリースタグのある版の節へ書く呼び出しを、書き込む前に止める | 開発版のプラン | +| F3 | 正式版の節を書くとき、前のリリースタグより新しくタグの無い版の節を消す | 本番のプラン | +| F4 | develop の `CHANGELOG.md` の既存の食い違い(10.27.2・10.27.21・10.27.22・10.27.23 の節)を直す | 実装の PR(1 回だけ) | +| F5 | GitHub Release `ndf--v10.27.21` の本文から #2542・#2543 の 4 行を外す | 本番の配布の後の操作(1 回だけ) | + +## 構成要素 + +| 要素 | 責務 | 変える内容 | +| --- | --- | --- | +| `release_lib/included.py` の `section_range` | 前のリリースタグと範囲の終点から範囲の PR を求め、`--prs` との和と、範囲の PR に無い `--prs` の番号(以下「範囲の外の `--prs`」)を返す。範囲を求めるのはここだけ | 終点を引数 `to`(コミット)で受ける。中で `origin/<ベースブランチ>` を読まず、`fetch` もしない。戻り値を「和」と「範囲の外の `--prs`」の 2 つにする(`with_extra` で和にした後は、範囲の PR と `--prs` を区別できないため)。範囲を求める今の読み出し(`release_range` → `merged_range.with_heads` が squash merge の head のブランチ名を GitHub から補い、`release/` の PR と PR でない番号を除く)はそのまま残す。この関数で GitHub の読み出しを足さない | +| `release_lib/included.py` に足す `drop_outside` | 範囲の外の `--prs` のうち、マージコミットが範囲の終点の祖先でない番号を和から外す | 新設。引数は和・PR の読み出し結果(`pr_many` の戻り値)・終点・範囲の外の `--prs`・呼び手が渡す列 `outside`。GitHub を読まず、マージコミットは読み出し結果の `mergeCommit` を使う(`pr_fields` の 1 回のまとめ読みが既に `mergeCommit { oid }` を含む)。`git merge-base --is-ancestor <マージコミット> <終点>` を打つのは範囲の外の `--prs` の番号だけで、範囲の PR に入っている番号には打たない。失敗した番号を外して `outside` へ足す(`titles_of` の `skipped` と同じ渡し方)。マージされていない番号(`mergeCommit` が無い)は外さず、今のとおり `titles_of`・`notes_of` が `skipped` で外す | +| `release-steps.py` の `pr_titles`・`pr_notes` | 範囲の PR を読み、版の節の箇条と配る中身を組む | 引数に終点・範囲の外の `--prs`・列 `outside` を足す(既定は終点なし)。終点を受けたときだけ、`pr_many` の直後、`titles_of`・`notes_of` の前に `drop_outside` を呼ぶ。`pr_many` に渡す欄へ `mergeCommit` を足す(まとめ読みが失敗して PR ごとの `gh pr view` へ倒れたときも、マージコミットを同じ 1 回の読みで得るため) | +| `release-steps.py` の `section_prs` | `changelog`・`notes`(`--approval` なし)の範囲の PR・前のリリースタグ・終点・範囲の外の `--prs` を決める | タグを `fetch` してから前のリリースタグを決め、終点に worktree の先頭(`git rev-parse HEAD`)を渡す。終点と範囲の外の `--prs` も返す。`drop_outside` は呼ばない | +| `release-steps.py` の `cmd_changelog`・`cmd_notes` | `changelog`・`notes` を打つ | `drop_outside` を呼ぶかを決める呼び手である。`cmd_changelog` と `--approval` なしの `cmd_notes` は、`section_prs` の終点・範囲の外の `--prs`・列 `outside` を `pr_titles`・`pr_notes` へ渡す。`--approval` の `cmd_notes` は終点を渡さず、`drop_outside` を呼ばない(読む「含む PR」は `approval-facts` が承認に出すコミットを終点にして絞り込み済みで、HEAD で確かめ直すと承認したコミットと違う終点で判定するため)。外した番号は `outside_items` の行として `items` に、`outside` として `metrics` に、`outside_note` の文言として `summary` に出す(下の行) | +| `release-steps.py` に足す `outside_items`・`outside_note` | 外した番号の出し方を 1 か所に置く | 新設。`unmerged_items`・`unmerged_note` と同じ形で、マージされていない番号とは混ぜない。`items` の行は `{"kind": "pr", "name": "#<番号>", "result": "skipped", "reason": "範囲の終点に入っていない"}`。`metrics` は `unmerged` と別の鍵 `outside`(外した番号の列。無ければ空の列)。`summary` の末尾に「(範囲の終点に入っていない #<番号> は載せない)」を足す(無ければ足さない) | +| `release-steps.py` の `cmd_approval_facts` | 承認に出すコミットを決め、承認資料を書く | 範囲の PR を `section_range` で求め、終点に承認に出すコミット(今のコードの変数 `dev`)を渡す。今の `pr_many`(`mergeCommit` を既に読む)の直後に、同じ終点と範囲の外の `--prs` で `drop_outside` を呼ぶ。外した番号は `outside_items`・`outside_note` で `changelog` と同じ形で出す。差分の行数も `origin/<本番チャネル>...<承認に出すコミット>` から数える | +| `release_lib/names.py` に足す `require_untagged` | 版の節を書く前に、その基底の版のリリースタグが無いことを確かめる | 新設。タグがあれば `StepError`(3)を投げる | +| `release_lib/names.py` に足す `drop_untagged_sections` | 前のリリースタグの版と出す正式版の間にある、タグの無い版の節を行の列から消し、消した見出しを返す | 新設 | +| `release-steps.py` の `_update_changelog`・`write_notes` | 版の節を足す・挿す・差し替える | 書く前に `require_untagged` を呼ぶ。`_update_changelog` は正式版(接尾辞の無い版)のときだけ `drop_untagged_sections` を呼ぶ | +| `CHANGELOG.md`(develop) | 変更履歴 | 10.27.2 の節から #2279・#2283・#2290 の 5 行、10.27.21 の節から #2542・#2543 の 4 行を外し、10.27.22・10.27.23 の節を消す | +| GitHub Release `ndf--v10.27.21` | 正式版 10.27.21 の利用者向けの本文 | 本文から #2542・#2543 の 4 行を外す(`gh release edit`) | + +```mermaid +graph TD + subgraph steps[release-steps.py] + CL[changelog] + NT[notes] + AF[approval-facts] + SP[section_prs] + PT[pr_titles・pr_notes] + end + subgraph lib[release_lib] + SR[included.section_range] + DO[included.drop_outside] + RU[names.require_untagged] + DU[names.drop_untagged_sections] + end + CL --> SP + NT --> SP + SP --> SR + AF --> SR + CL --> PT + NT -->|"--approval なしのときだけ終点を渡す"| PT + PT --> DO + AF --> DO + CL --> RU + NT --> RU + CL --> DU +``` + +システム構成(外部との関係と配置)は変わらない。git と GitHub への読み書きは今と同じく、配布のプランの worktree で動く `release-steps.py` から行う。 + +## 構造 + +型を足さず、変えない。範囲の PR の型(`IncludedPr`)は今のままで、結果 JSON は `metrics` に鍵 `outside` が増えるだけである。変わるのは関数の引数と戻り値(`section_range` の終点と範囲の外の `--prs`、`pr_titles`・`pr_notes` の終点)と、`release_lib/included.py` に足す `drop_outside` と、`release-steps.py` に足す `outside_items`・`outside_note` と、`release_lib/names.py` に足す 2 つの関数だけである。 + +```text +plugins/ndf/scripts/ +├── release-steps.py # section_prs・pr_titles・pr_notes・cmd_changelog・cmd_notes・cmd_approval_facts・_update_changelog・write_notes を変え、outside_items・outside_note を足す +└── release_lib/ + ├── included.py # section_range の終点を引数にし、drop_outside を足す + └── names.py # require_untagged・drop_untagged_sections を足す +``` + +## 入出力の契約 + +引数は変わらず、結果 JSON は `metrics.outside` が増えるだけである。振る舞いが変わるのは次の 3 つである。 + +| コマンド | 条件 | 結果 | +| --- | --- | --- | +| `changelog`・`notes`(`--approval` なし) | `refs/tags/<プラグイン>--v<基底の版>` がある | 書き込まずに終了コード 3。`summary` は「<版> はリリースタグ <タグ> があるため版の節へ書かない」。`CHANGELOG.md` は変わらない | +| `changelog`・`notes`(`--approval` なし)・`approval-facts` | 範囲の PR に無い `--prs` の番号のマージコミットが範囲の終点の祖先でない | その番号を版の節と「含む PR」に載せない。`items` に `{"kind": "pr", "name": "#<番号>", "result": "skipped", "reason": "範囲の終点に入っていない"}` を外した番号ごとに足し、`metrics.outside` に番号を並べる(`metrics.unmerged` には混ぜない)。`summary` の末尾に「(範囲の終点に入っていない #<番号> は載せない)」を足す。終了コードは 0 のまま | +| `changelog`(正式版) | 前のリリースタグの版より新しく、出す版より古いタグの無い版の節がある | その節を消す。`items` に `{"kind": "section", "name": "CHANGELOG.md", "result": "removed", "heading": "<見出し>"}` を消した節ごとに足す | + +`notes --approval` は `CHANGELOG.md` を書かないため、タグの確かめを通らない。範囲の終点の確かめ(`drop_outside`)も通らない。読む「含む PR」は、`approval-facts` が承認に出すコミットを終点にして絞り込んだものである。 + +## 処理の流れ + +正式版の本番のプラン(承認ゲート 2 の後)を示す。開発版のプランも `changelog`・`notes` の流れは同じで、worktree の起点がその時点のベースブランチの先頭であることだけが違う。 + +```mermaid +sequenceDiagram + participant P as 本番のプラン + participant R as release-steps.py + participant I as included.py + participant G as git と CHANGELOG + participant H as GitHub + P->>G: 承認したコミットから release/v<版> の worktree を作る + P->>R: changelog --version <版> --prs + R->>G: fetch --tags・前のリリースタグを決める + R->>G: タグ <プラグイン>--v<版> があるか + alt タグがある + R-->>P: 終了コード 3(書かない) + else タグが無い + R->>I: section_range(前のタグ, HEAD, --prs) + I->>G: git log --first-parent 前のタグ..HEAD + I->>H: squash merge の head のブランチ名を補う(今のまま) + I-->>R: 範囲の PR と --prs の和、範囲の外の --prs + R->>H: PR をまとめて 1 回読む(mergeCommit を含む) + R->>I: drop_outside(和, 読み出し結果, HEAD, 範囲の外の --prs) + I->>G: 範囲の外の --prs の番号ごとに merge-base --is-ancestor マージコミット HEAD + I-->>R: 終点に入った PR と外した番号 + R->>G: 版の節を足す・挿す。タグの無い途中の版の節を消す + end + P->>R: notes --version <版> --prs + R->>I: section_range(前のタグ, HEAD, --prs) + R->>H: PR をまとめて 1 回読む(mergeCommit を含む) + R->>I: drop_outside(和, 読み出し結果, HEAD, 範囲の外の --prs) + R->>G: 版の節を差し替える + P->>R: release(本番チャネルへ入れ、タグと GitHub Release を作る) +``` + +図に含めない要素は、`approval-facts`(次の段落)と、1 回だけ行う `CHANGELOG.md` の修正と GitHub Release の本文の書き換え(決定 4)である。 + +`approval-facts` は開発版のプランの最後で、`origin/<ベースブランチ>` を 1 回だけ読んで承認に出すコミットを決める。そのコミットを、`section_range` の終点と「承認したコミット」の両方に使う。 + +## 決定の記録 + +### 決定 1: 版の節がタグの中身と一致するよう、範囲の終点を出す版のコミットにして 1 つの関数へ渡す + +範囲の終点は、`changelog`・`notes` では worktree の先頭、`approval-facts` では承認に出すコミットとし、どちらも `section_range` に引数で渡す。本番のプランの worktree は承認したコミットから作られ(計画の「起点のコミット」)、版の上げのコミットだけが載る。そのため worktree の先頭までの範囲は、リリースタグを打つコミットに入った PR と一致する。主要な製品も、前のリリースタグからリリースするコミットまでを範囲にしている。承認したコミットとベースブランチの先頭が一致するときは、範囲は今と同じである(受け入れ条件 9)。 + +範囲の終点を承認資料の「承認したコミット」から読む形は採らない。開発版のプランには承認資料が無く、終点の決め方が 2 通りに分かれる。範囲の計算を git-cliff へ置き換える形も採らない。PR の本文の「利用者向けの変化」とスプリント経由の PR を扱う処理(`included.py`)は残り、置き換わるのは `git log` の 1 行だけである。 + +- 作り方: 自前で作る・標準に倣う +- 比べた候補: PRD1 / PRD2 / PRD3 / LIB1 + +根拠: Value 7 / Value 8(MVV 版 5) + +### 決定 2: タグの中身と版の節の食い違いを作らないため、リリースタグのある版の節へ書く呼び出しは書き飛ばさずに止める + +`changelog` と `notes` は、基底の版のリリースタグがあれば書き込む前に終了コード 3 で止まり、理由を結果に出す。リリースタグのある版を基底にした開発版は、版数の付け方(次に出す正式版の版数へ接尾辞を付ける)から外れており、版の上げの誤りである。書き飛ばして続けると、開発版は出るが誤った版数のまま配られ、誤りが残る。止めれば、配布のプランの judge のステップが、版数を直すか止めるかを決める。 + +書き込みを飛ばして開発版のリリースを続ける形は採らない。版の節の無い開発版のリリースの PR が作られ、利用者向けの変化の記載が無いまま開発版が配られる。 + +根拠: Value 2 / Value 7(MVV 版 5) + +### 決定 3: 途中の版の節が残らないよう、正式版の `changelog` が前のリリースタグより新しくタグの無い版の節を消す + +正式版の節を書く `changelog` が、前のリリースタグの版より新しく出す版より古い、タグの無い版の節を消す。その節の箇条は、前のリリースタグから出す版のコミットまでの範囲に必ず含まれ、同じ実行で正式版の節に並ぶ(要求の前提 4)。Keep a Changelog が未リリースの節をリリース時に版の節へ移すのと同じ結果になる。消すのは `changelog` の 1 か所に置き、`notes` は節の差し替えだけを持つ。 + +開発版の節を未リリースの 1 つの節(`Unreleased`)にまとめる形は採らない。開発版の版数と版の節の見出しの対応(`changelog_span`・リリースの PR の本文・CI の文書の検査)が変わり、この課題の範囲を超える。 + +- 作り方: 自前で作る・標準に倣う +- 比べた候補: STD1 + +根拠: Value 3 / Value 8(MVV 版 5) + +### 決定 4: 正式版 10.27.21 の利用者へ届く本文を直すため、GitHub Release の本文だけを `gh release edit` で書き換え、タグには触れない + +本文は、`git show ndf--v10.27.21:CHANGELOG.md` の 10.27.21 の節から #2542・#2543 を含む 4 行を外したものとする。書き換えは `gh release edit ndf--v10.27.21 --notes-file <ファイル>` で行う。書き換える前の本文は `gh release view ndf--v10.27.21 --json body` で控え、戻すときは控えを同じコマンドで書き戻す。打つのは承認ゲート 2 の後の本番の配布の後で、手順と控えの置き場は実装の Pull Request の本文の「手動確認」に書く。タグとタグが指すコミットの `CHANGELOG.md` は書き換えない(要求の前提 1)。 + +GitHub の REST API を直接呼ぶ形は採らない。このリポジトリの工程は既に `gh` に依存しており、同じことを `gh release edit` が 1 行で行う。 + +- 作り方: ライブラリやサービスを使う +- 比べた候補: LIB2 / STD2 + +根拠: Value 1 / C4(MVV 版 5) + +## テスト設計 + +受け入れ条件 5〜7・9 と不変条件は、一時の git リポジトリ(origin・ベースブランチ・本番チャネル・リリースタグを持つ)で `release-steps.py` を起動して確かめる。 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 5・I1 | 承認したコミットからリリースのブランチを作り、その後にベースブランチへ PR をマージする。`changelog`・`notes` の版の節に後の PR が無い | `section_prs` の終点を `origin/<ベースブランチ>` へ戻すと落ちる | +| I1(`--prs`) | 範囲の終点より後にマージした PR を `--prs` で渡すと、版の節と「含む PR」に無く、`items` に `result` が `skipped`・`reason` が「範囲の終点に入っていない」の行があり、`metrics.outside` にその番号があって `metrics.unmerged` には無い。終点より前にマージした PR を `--prs` で渡すと載る。範囲の PR に入っている番号を `--prs` で渡しても `git merge-base` を打たない | `drop_outside` を呼ばずに和をそのまま使う、外した番号を `unmerged` に混ぜる、または範囲の PR の番号にも祖先の確かめを打つと落ちる | +| `notes --approval` | 承認資料の「含む PR」にある番号は、HEAD がその PR のマージより前でも外さない | `--approval` のときも `drop_outside` を呼ぶと落ちる | +| 受け入れ条件 5・I2 | 承認に出すコミットより後にマージされた PR が、`approval-facts` の「含む PR」に無く、「承認したコミット」が範囲の終点と同じ SHA である | `approval-facts` で終点と承認したコミットを別々に `origin/<ベースブランチ>` から読み、その間にマージが入ると落ちる | +| 受け入れ条件 6・I3 | タグ `<プラグイン>--v<版>` がある版を基底にした開発版で `changelog` と `notes` を打つと、終了コード 3 で止まり、`summary` にタグの名前があり、`CHANGELOG.md` の中身が打つ前と同じである | タグの確かめを外す、または書き込みの後に確かめると落ちる | +| 受け入れ条件 7・I4 | 前のリリースタグ 1.0.0、タグの無い 1.0.1・1.0.2 の節があるとき、正式版 1.0.3 の `changelog` の後に 1.0.1・1.0.2 の節が無く、その節にあった PR が 1.0.3 の節にある。`items` に `removed` が 2 件ある | 消す条件の下限を外して前のリリースタグの節まで消す、またはタグのある節を消すと落ちる | +| 受け入れ条件 7(開発版) | 開発版の `changelog` は途中の版の節を消さない | 消す処理を接尾辞の有無で分けないと落ちる | +| 受け入れ条件 9 | 承認したコミットとベースブランチの先頭が一致するとき、版の節に並ぶ PR が終点を `origin/<ベースブランチ>` にしたときと同じである | 範囲の始点か first-parent の扱いを変えると落ちる | +| 受け入れ条件 1〜3・8 | 実装の PR で、develop の `CHANGELOG.md` に対し課題の本文「調べたこと」の手段(節ごとの PR 番号とマージコミットの `git merge-base --is-ancestor`)を打ち、タグの祖先でない組が 0 件。10.27.22・10.27.23 の節が無く、2 つの節の箇条がすべて 10.27.24 の節にある | 手で直すときに節を消し残す、または 10.27.3 の節の #2279 の行まで消すと 0 件にならない | +| 受け入れ条件 4 | リリース後テストで `gh release view ndf--v10.27.21 --json body` の本文に #2542・#2543 が無い | 書き換えを打たないと残る | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2555 | 実装する | — | `plugins/ndf/scripts/release-steps.py`、`plugins/ndf/scripts/release_lib/included.py`、`plugins/ndf/scripts/release_lib/names.py`、`plugins/ndf/scripts/tests/`、`CHANGELOG.md`、`docs/specifications/ndf-release-approval-included-prs.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 並べた配布(`parallel_release`)の本番 | worktree の起点は「起点のコミット」に承認したコミットが入る経路を読んで確かめた。並べた配布の実物で `changelog` を打った結果は確かめていない。リリース後テストで、次の正式版の版の節の PR がタグの祖先にあるかを確かめる | +| マージコミットを辿れない 232 組 | 要求の「未確認・残リスク」のとおり、この変更でも確かめない | +| 正式版の利用者の手元 | タグ `ndf--v10.27.21` の `CHANGELOG.md` は 4 行を載せたままで、次の正式版を入れるまで残る | diff --git a/issues/issue-2555-requirements.md b/issues/issue-2555-requirements.md new file mode 100644 index 000000000..bbbd9e8ce --- /dev/null +++ b/issues/issue-2555-requirements.md @@ -0,0 +1,185 @@ +# #2555: 正式版 v10.27.21 の CHANGELOG に、タグに入っていない変更(#2542・#2543)が載っている + +正は課題の本文(#2555)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何が起きたか + +正式版のタグ `ndf--v10.27.21` の `CHANGELOG.md` の「[ndf 10.27.21] - 2026-11-05」の節(12〜17 行)は、次の 4 行を載せている。 + +- 設計の承認に「承認」と答えると、その設計の PR がマージされる(#2542)(#2543) +- 開発ループの周回の境で、承認済みの設計の PR がマージし直される(#2542)(#2543) +- 承認に答えた後、その時点の整理結果が一覧で表示される(#2542)(#2543) +- 承認の答えの `--by` 引数が契約に含まれ、検査の対象になる(#2542)(#2543) + +しかし実装の PR https://github.com/devbasex/ai-plugins/pull/2543 のマージコミット `d90cf16ca` はタグ `ndf--v10.27.21` の祖先ではなく、実装のファイル `plugins/ndf/scripts/lib/design_pr_merge.py` もタグに無い(`git ls-tree -r --name-only ndf--v10.27.21 | grep -c design_pr_merge` が 0)。develop には入っている。 + +正式版の利用者には、変更点の記載と中身が食い違って届いている。 + +## 確かめ方 + +```bash +git merge-base --is-ancestor d90cf16ca737140067530d507307dcacdf0acdbd ndf--v10.27.21 || echo not-in-tag +git show ndf--v10.27.21:CHANGELOG.md | sed -n 12,18p +``` + +## 直す方向(案) + +- 10.27.21 の節から 4 行を外す。実装は正式版 10.27.24 に入っており(`git merge-base --is-ancestor d90cf16ca ndf--v10.27.24` が成功)、develop の `CHANGELOG.md` の 10.27.24 の節(14〜17 行)に同じ 4 行が載っている +- 正式版のタグの無い 10.27.23・10.27.22 の節(38〜41 行・61〜64 行)にも同じ 4 行が重ねて載っている。版の節に載る行の決め方を合わせて確かめる +- 本番の配布の工程で、CHANGELOG の節に載る PR がタグの祖先にあるかを確かめる検査を足すかを検討する + +## 見つけた経緯 + +開発ループ lfactory3 の周回 6(#2199、版 10.27.21)のまとめの棚卸し(差分)で worker が見つけた。記録 `/work/ai-plugins/.ndf/loop/lfactory3/lap-6/close/1-close-state/01-refine.out` + +## 依頼(原文) + +依頼の原文は、この本文の「何が起きたか」「確かめ方」「直す方向(案)」「見つけた経緯」の 4 節である(書き手: 起票者。開発ループ lfactory3 の周回 6 のまとめの棚卸しで worker が見つけて起票した。日付: 2026-11-05)。利用者の指示の原文は含まない。 + +> #2555。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-05) + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 10.27.21 の節から #2542・#2543 の 4 行を外す | 本文「直す方向(案)」1 行目 | 受け入れ条件 1 | +| S2 | 正式版のタグの無い 10.27.22・10.27.23 の節に重なった 4 行も扱い、版の節に載る行の決め方を合わせて確かめる | 本文「直す方向(案)」2 行目 | 受け入れ条件 3・6・7 | +| S3 | 本番のリリースの工程で、版の節に載る PR がタグの祖先にあるかを確かめる検査を足すかを検討する | 本文「直す方向(案)」3 行目 | 採らない(独立した検査は足さない。理由は「前提」の前提 3) | + +## 目的 + +- 正式版の変更履歴(`CHANGELOG.md` の版の節)と GitHub Release の本文が、その版のリリースタグに入っている変更だけを載せる状態にする +- 版の節を組むリリースの工程が、出す版に入っていない PR を載せない状態にする(同じ食い違いを再び作らない) + +## 調べたこと + +食い違いは 3 つの仕組みから生じている。 + +| # | 事象 | 確かめた手段と結果 | +| --- | --- | --- | +| 1 | 版の節に並べる PR の範囲の終点が、出す版のコミットではなくベースブランチの最新(`origin/develop`)になっている | `plugins/ndf/scripts/release_lib/included.py:173` の `section_range` は `release_range(root, prev, f"origin/{base}")` を返す。承認資料の「含む PR」(`release-steps.py:844`)も `origin/{base}` を終点にする。10.27.21 のリリースの PR #2550 のブランチは `33dc59709`(#2543 のマージより前のコミット)から作られた一方、`git show 528365748 -- CHANGELOG.md` で 10.27.21 の節へ #2542・#2543 の 4 行が足されている | +| 2 | 正式版を出さなかった版(10.27.22・10.27.23)の節が残る | 2 つの節の箇条 12 行・20 行は、すべて 10.27.24 の節に同じ文で載っている(行の突き合わせで、10.27.24 に無い行は 0 件)。`git tag -l 'ndf--v10.27.2[23]*'` は 0 件 | +| 3 | リリースタグのある版の節へ、後から行が足される | develop の 10.27.2 の節に #2279・#2283・#2290 の 5 行があるが、`git show ndf--v10.27.2:CHANGELOG.md` には 0 件。3 件のマージコミットは `ndf--v10.27.2` の祖先でない | + +develop の `CHANGELOG.md` の全 130 節・710 組(版と PR 番号の組)を、マージコミットとリリースタグの祖先関係で調べた結果、タグの祖先でない組は次の 5 件だった。 + +| 版の節 | PR | +| --- | --- | +| 10.27.2 | #2279・#2283・#2290 | +| 10.27.21 | #2542・#2543 | + +GitHub Release の本文は、`ndf--v10.27.21` が #2542・#2543 の 4 行を載せている。`ndf--v10.27.2` の本文は #2279・#2283・#2290 を載せていない(`gh release view ndf--v10.27.2 --json body` の該当件数 0)。 + +## 前提 + +- 前提 1: リリースタグとタグが指す中身は書き換えない(履歴の書き換えは戻せない操作に当たる)。直すのは develop の `CHANGELOG.md` と GitHub Release の本文で、正式版の利用者には次の正式版の配布で届く +- 前提 2: 版の節に並べる PR の範囲の終点は、出す版のコミット(リリースのブランチの先頭)とする。承認された版のコミットとリリースのブランチの先頭が一致することは、既存のリリースの工程が保つ +- 前提 3: 版の節を組む経路は `release-steps.py` の `changelog`・`notes`・`approval-facts` だけである。範囲の終点を正せば、この経路で作る節にタグの外の PR は載らない。版の節とタグを突き合わせる独立した検査は、同じ判定を 2 か所に持つことになるため足さない +- 前提 4: 正式版を出さなかった版の節の箇条は、次の正式版の範囲(前のリリースタグから出す版のコミットまで)に必ず含まれる。そのため、正式版を出したときに、前のリリースタグより新しくタグの無い版の節を消しても、変更の記載は失われない + +## 対象範囲 + +含む: +- 版の節と承認資料の「含む PR」に並べる PR の範囲の終点を、出す版のコミットにする +- リリースタグのある版の節へ、別の版の工程が行を足さないようにする +- 正式版を出したとき、前のリリースタグより新しくタグの無い版の節を消す +- develop の `CHANGELOG.md` の既存の食い違い(10.27.2・10.27.21 の節の 5 組と、10.27.22・10.27.23 の節)を直す +- GitHub Release `ndf--v10.27.21` の本文から #2542・#2543 の 4 行を外す + +含まない: +- リリースタグの付け替えと、タグが指すコミットの `CHANGELOG.md` の書き換え +- マージコミットから辿れない 232 組(10.9.1 以前の版など。下の「未確認・残リスク」)の検証 +- タグの無い古い版の節(10.27.0・10.27.4・10.27.12・10.27.13)の整理。前のリリースタグより古いため、受け入れ条件 7 の対象にならない +- 版の節とリリースタグを突き合わせる独立した検査の追加(前提 3) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | PR がベースブランチへマージされた | 実装の PR のマージ | — | — | +| E2 | 開発版の版の節を書いた | 開発版のリリース(`changelog`・`notes`) | 節を書けなければリリースが止まる | E1 | +| E3 | 正式版を出すコミットが承認された | 承認ゲート 2 | 差し戻し | E2 | +| E4 | リリースのブランチを承認されたコミットから作った | 正式版のリリース | ブランチが作れなければ止まる | E3 | +| E5 | 正式版の版の節を書いた | `notes`(承認資料つき) | 範囲の PR を読めなければ止まる | E4。範囲の終点は E4 のブランチの先頭 | +| E6 | リリースのブランチを本番チャネルへ入れ、リリースタグを打った | `promote` | 衝突で止まる | E5 | +| E7 | GitHub Release を版の節から作った | E6 の続き | 作れなければ止まる | E6 | +| E8 | 次の開発版の版の節を書いた | 次の開発版のリリース | E2 と同じ | E6。リリースタグのある版の節には書かない | + +E5 と E1 の順序は保証されない(E4 の後にもベースブランチへのマージは続く)。調べたことの事象 1 は、E5 が E4 ではなく E1 の最新を終点にしたことで起きた。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 正式版 | 本番チャネルへ出す、接尾辞の無い版。出したらリリースタグを打つ | +| 開発版 | ベースブランチ(develop)に載るチャネルと、そこへ出す接尾辞付きの版 | +| ベースブランチ | worktree の分岐元と Pull Request の宛先 | +| 本番チャネル | リリースした版が常用する利用者へ届くブランチ | +| リリース差分 | 版と版の間(タグからタグまで)の変更 | + +この文書の「版の節」は、`CHANGELOG.md` の `## [ndf <版>]` の見出しから次の見出しまでを指す。 + +## 受け入れ条件 + +- [ ] 1. develop の `CHANGELOG.md` の 10.27.21 の節に、#2542・#2543 を含む行が無い +- [ ] 2. develop の `CHANGELOG.md` の 10.27.2 の節に、#2279・#2283・#2290 を含む行が無い(3 件は 10.27.3 の節に残る) +- [ ] 3. develop の `CHANGELOG.md` に 10.27.22・10.27.23 の節が無く、2 つの節にあった箇条はすべて 10.27.24 の節に残る +- [ ] 4. GitHub Release `ndf--v10.27.21` の本文に、#2542・#2543 を含む行が無い +- [ ] 5. 前提: 承認されたコミットより後に、ベースブランチへ PR がマージされている + 操作: その承認されたコミットから正式版の版の節を組む(`changelog`・`notes`・`approval-facts`) + 結果: 後からマージされた PR は版の節にも承認資料の「含む PR」にも載らない +- [ ] 6. 前提: リリースタグ `ndf--v<版>` があり、`CHANGELOG.md` にその版の節がある + 操作: 基底の版が同じ開発版の版の節を書く + 結果: 書き込まずに止まり、理由(その版はリリースタグがあること)を結果に出す。タグのある節の行数は変わらない +- [ ] 7. 前提: 前のリリースタグより新しく、出す正式版より古い版の節が `CHANGELOG.md` にあり、その版のリリースタグが無い + 操作: 正式版の版の節を書く + 結果: タグの無い版の節が消え、その箇条は出す正式版の節に載っている +- [ ] 8. develop の `CHANGELOG.md` で、リリースタグのある版の節に載る PR のうち、マージコミットを辿れるものはすべて、そのタグの祖先にある(確かめたときの 710 組の調べ方で、祖先でない組が 0 件) +- [ ] 9. 退行しない: 承認されたコミットとベースブランチの先頭が一致するとき、版の節に並ぶ PR は今と同じである + 前提の確認: 10.27.26 の節の PR 2 件(#2610・#2612)は、`git merge-base --is-ancestor` でどちらも `ndf--v10.27.26` の祖先にあった(2026-11-05) + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。`release-steps.py` の `changelog`・`notes` は、リリースタグのある版の節へ書く呼び出しで止まる(受け入れ条件 6)。引数と結果 JSON の形は変わらない | +| データ | develop の `CHANGELOG.md` の 4 つの節(10.27.2・10.27.21・10.27.22・10.27.23)と、GitHub Release `ndf--v10.27.21` の本文 | +| 既存の振る舞い | 版の節の PR の範囲の終点が、ベースブランチの最新から出す版のコミットに変わる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(受け入れ条件 5〜7・9 は、一時リポジトリでベースブランチとリリースのブランチを分けた形で確かめる) | +| 静的解析 | `python3 scripts/check-skill-frontmatter.py`・既存の CI のジョブ | +| データの確認 | 受け入れ条件 1〜3・8 は、調べたことの表の手段(`git merge-base --is-ancestor` と節の行の突き合わせ)を develop の `CHANGELOG.md` に対して打つ | +| 手動確認 | 受け入れ条件 4 は、`gh release view ndf--v10.27.21 --json body` の本文に #2542・#2543 が無いことを見る(リリース後テストで確かめる) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md`。リリースの工程のコードは `plugins/ndf/scripts/` と `plugins/ndf/scripts/release_lib/` に置く。同じ役割の関数を工程ごとに分けない | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない | +| テスト戦略 | `release_lib` の範囲の計算と節の書き込みを、一時の git リポジトリを使う単体テストで確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存のテストの実行。範囲の計算の終点を変えた箇所すべて(`changelog`・`notes`・`approval-facts`)を同じ関数へ寄せる | +| 確認してから行う | GitHub Release `ndf--v10.27.21` の本文の書き換え(承認ゲート 2 で人が見る) | +| 行わない | リリースタグの付け替え・削除、タグの指すコミットの書き換え、タグの無い古い版の節(10.27.0・10.27.4・10.27.12・10.27.13)の整理 | + +## 未確認・残リスク + +- マージコミットを `Merge pull request #<番号>` で辿れない 232 組(10.9.1 以前の版のほか、10.27.5・10.27.7・10.27.16・10.27.18 の数組)は、リリースタグの祖先にあるかを確かめていない。squash でマージされた PR と、別のリポジトリの PR を指す番号が含まれると見ている(未確認) +- 正式版の利用者の手元の `CHANGELOG.md`(タグ `ndf--v10.27.21` の中身)は、次の正式版を入れるまで 4 行を載せたままである + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 受け入れ条件 6 で止めるか、書き込みを飛ばして続けるか(開発版のリリースを止めない方を採るか) | 設計(`design`)で決め、承認ゲート 1 で人が見る | 設計 PR の作成まで | diff --git a/issues/issue-2567-design.md b/issues/issue-2567-design.md new file mode 100644 index 000000000..d6ecd91e4 --- /dev/null +++ b/issues/issue-2567-design.md @@ -0,0 +1,671 @@ +# テストのステップ: テスト環境を自動で起こし、止まったときは原因が読める結果を返す(#2567) + +## 目的 + +- **今どうなっているか**: テストのステップはテスト環境が止まっていると「`worktree-testenv.sh up` で起こす」と返して止まり、人が起こしてプランを流し直すまで開発ループが進まない。全体テストの失敗は落ちたコマンドが分からず、導入の確認は走っている間の出力が見えない +- **誰に効くか**: コンテナでテストを走らせるプロジェクトで開発ループを回す利用者と、止まった結果を読む判定の worker +- **変えると何が成り立つか**: テスト環境の用意のために人を待たず、止まったときは結果の JSON とログから原因を読める + +## 適用範囲 + +- **働く範囲**: 配布先のリポジトリでも働く +- **プロジェクトごとに違うもの**: コンテナのサービスは `.ndf/project.json` の `test.suites[].container` で受ける。起動の定義は `.ndf/worktree.json` の `testenv` と `localenv.compose_files` で受ける。必須の設定は足さない。足すのは任意の `testenv.discard_volumes` の 1 項目だけで、無ければボリュームを消さない(決定 7) +- **当たるモード**: すべてのモード(テストと後片付けを通るとき) + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「ひとつづつ直すのではなく根本原因を見つけて対処してください」(2026-11-06) | 利用者の指示の原文 | 止まり方ごとに手当てせず、同じ層で直す | +| carul の 3 回の判定の stop と、人が起こして流し直すと通った記録(#2567) | 実測 | 起こせば通るため、自動で起こす価値がある | +| carul のテスト環境 22 個で、最初のコンテナの作成から api の起動まで 16〜49 秒(2026-11-06、`docker inspect`。作り直しの跡がある 2 個を除く) | 実測 | 起動の待ちは、人の待ちと判定の費用より小さい | +| 導入の確認の打ち切り 4 回が、すべて develop へのマージの 12〜32 秒後に始まった実行である(要求の「調べて分かったこと」) | 実測 | 導入の確認の遅れは、テスト環境の停止とは別の原因である見込みが高い | +| STD1 | 外部の一次情報 | `docker compose up` は既存のコンテナを保ったまま足りないものを起こすため、確かめた後に 1 度打ってよい | +| PRD2 | 外部の一次情報 | テスト用のサービスは使う仕事の範囲で用意し、終わりに破棄する形が定着している | +| 「自動では `down`(`--volumes` 無し)までにして、ボリュームは残す。残ったボリュームは宣言があるときだけ消す形を実装の前に設計へ反映する。戻せない削除を既定にしない」(2026-11-06、[#2567 の節「設計の承認ゲート 1 での利用者の答え」](https://github.com/devbasex/ai-plugins/issues/2567)) | 利用者の答え | 既定の自動の破棄はコンテナまでで、ボリュームの削除は宣言で選ぶ(決定 7) | +| carul のテスト環境のボリューム 99 個で計 18.3 GB、データベース 1 個あたり平均 203 MB。ホストのディスクの空きは 11.9 GB(使用率 96%)。worktree レジストリの 49 の環境名のうち 2 つは解放の後に割り当て直された(2026-11-06、`du`・`df`・`.git/ndf/worktree-registry.json`) | 実測 | 残るボリュームはディスクを圧迫し、同じ環境名の再利用は実際に起きる(決定 7) | + +要求と受け入れ条件は #2567 の本文にある(コピーは [issue-2567-requirements.md](issue-2567-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の worktree(`ndf-worktree`) | テスト環境・テスト環境の用意・到達の確認 | +| NDF の開発ワークフロー(`ndf-workflow`) | 全体テスト・導入の確認・ステップ | + +ワークフローが顧客、worktree が供給者の関係(顧客 / 供給者)。ワークフローは到達の確認の関数を呼び、テスト環境の割り当てへ直接触らない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| テスト環境の割り当て | `worktree-testenv.sh` | 割り当て(worktree のパスで引く) | — | 環境名・スロット・ポート | +| 到達の結果 | 到達の確認(`lib/container_reach.py`)。1 プロセスの中だけで持つ | 作業ディレクトリごとの結果 | — | 足す環境変数・起こした記録 | +| 導入の確認のログ | `release_lib/deploy.py` | ログのファイル | — | — | + +Python の側は割り当てを書き換えない。`worktree-testenv.sh` の `env`・`up`・`down` を呼ぶだけである。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | テスト環境の割り当て | 同じ worktree の環境名とスロットは、HEAD がどのコミットへ動いても変わらない | `env` が同じ値を返さなければ、テストで落とす | +| I2 | テスト環境の割り当て | 同じコミットを開いた別の worktree には、別の環境名と別のスロットが割り当たる | 同上 | +| I3 | 到達の結果 | 到達が確かめられていない worktree では、テストのコマンドを走らせない | `Unreachable` を送り、走らせずに止まる | +| I4 | 到達の結果 | 1 プロセス・1 作業ディレクトリで、起動は 1 度までである | 2 度目は打たず、1 度目の失敗を送り直す | +| I5 | 到達の結果 | 既に届く worktree と、`container` の宣言が無いリポジトリでは、起動しない | 起動のコマンドを打たずに走らせる | +| I6 | テスト環境の割り当て | worktree を消す前に開いた公開を閉じる。閉じた後に破棄を試み、破棄できなければ環境名を結果に残す | 閉じられなければ、破棄せず worktree を残す。破棄の失敗では worktree の削除を続ける | +| I7 | 導入の確認のログ | 確認のコマンドの出力は所有者だけが読めるログへだけ書く。進みの知らせに出力の中身を載せない | ログを書けなければ `StepError` で止まる | +| I8 | テスト環境の割り当て | 自動の破棄がボリュームを消すのは、メインディレクトリの宣言の `testenv.discard_volumes` が真偽値の `true` のときだけである | テストで落とす | +| I9 | テスト環境の割り当て | 行に `golden_tag` が無い割り当てへタグを書くのは、同じプロジェクトのボリュームが無いと確かめられたときだけである。破棄に失敗した行は `golden_tag` を消し、この条件の対象へ戻す | 残っているか確かめられなければ書かずに起こし、`expose` が拒否する | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | テストのステップが始まった | `test-run.py`・cross-refactoring の実行 | 到達の確認 | +| E2 | テスト環境へ届かないと分かった | 到達の確認 | テスト環境の用意 | +| E3 | テスト環境の割り当てを取った | `worktree-testenv.sh env` | テスト環境の用意 | +| E4 | テスト環境を起こした | `worktree-testenv.sh up` | 到達の確認 | +| E5 | 到達を確かめ直した | 到達の確認 | テストの実行 | +| E6 | テストを走らせた | `test-run.py`・cross-refactoring の実行 | 結果の JSON | +| E7 | 導入の確認の出力をログへ書いた | 確認のコマンド | 導入の確認の進みの知らせ → 遅れの見張り | +| E8 | テスト環境を破棄した | `/ndf:merged` の後片付け | worktree レジストリ(解放の時刻) | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| テスト環境 | 1 つの worktree に対応して立ち上げる、テスト実行に必要なサービスの一式 | 変更なし | +| テスト環境の用意 | 到達の確認が通らないときに、テスト環境を起こして到達を確かめ直すこと | 変更なし | +| 到達の確認 | コンテナの中から worktree の探りの印を読み、テストのコンテナが worktree を見ているかを確かめること | 変更なし | +| 導入の確認 | `.ndf/pace.json` の `<節>.verify` に書かれたコマンドを、ベースブランチの先頭で走らせること | 変更なし | +| 全体テスト | リポジトリ全体を範囲にするテストか静的解析 | 変更なし | + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べる | 起動・破棄は Docker Compose の振る舞いに、detached HEAD は git の仕様に、出力の記録はプロセスの標準の手段に従う | +| 主要な製品 | 調べる | テスト用のサービスをいつ起こし、いつ破棄するかと、走っている間の出力をどう残すかの解き方を比べる | +| ライブラリとサービス | 調べる | 起動の管理と、ログの伸びの監視を自前で持つかを決める | + +### 標準・仕様 + +| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| STD1 | Docker Compose の `up` | 既存のコンテナは設定やイメージが変わったときだけ作り直し、ボリュームを保つ。`--wait` はサービスが動くか健全になるまで待ち、`--wait-timeout` で上限を置く。サービスを指定すると、そのサービスと依存だけを待つ(実測は決定 2) | 確かめた後に `up -d` を 1 度打ち、宣言のサービスだけを `--wait` で待つ(決定 2) | https://docs.docker.com/reference/cli/docker/compose/up/ (2026-11-06)、`docker compose up --help`(v5.6.1) | +| STD2 | Docker Compose の `down` | `down` はコンテナとネットワークを消す。`--volumes` は定義の名前付きボリュームとコンテナに付いた匿名ボリュームも消す。定義外の削除は `--remove-orphans` のときだけ行う(実測は決定 7) | 既定の破棄は `down` を使い、宣言があるときだけ `down --volumes` を使う。定義外を消す指定は付けない | `docker compose down --help`(v5.6.1、2026-11-06) | +| STD3 | git worktree | `--detach` でブランチを持たない worktree を作れる。`git worktree remove` を使わず消した worktree は管理のファイルが残る | ブランチに頼らず、worktree のパスで環境を識別する(決定 4) | https://git-scm.com/docs/git-worktree (2026-11-06) | +| STD4 | POSIX の `tee` | 標準入力を標準出力へ複製し、指定のファイルへも書く | 出力が標準出力へも出るため採らない(決定 6) | https://pubs.opengroup.org/onlinepubs/9799919799/utilities/tee.html (2026-11-06) | +| STD5 | Python の `subprocess` | `stdout` にファイルを渡すと子がそのファイルへ直接書く。`stderr=STDOUT` で 1 本にまとまる。`wait(timeout)` は打ち切り後に再度呼べる | 子の出力をログへ直接渡し、親は `wait(timeout)` のループでログの大きさを見る(決定 6) | https://docs.python.org/3/library/subprocess.html (2026-11-06) | + +### 主要な製品 + +| # | 製品 | どう解いているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| PRD1 | Testcontainers の Docker Compose モジュール | テストのコードが compose の定義からサービスを起こす。待ち方と起動の上限を指定でき、手元の `docker compose` を使う形も持つ | 起動の上限を置く形と、手元の `docker compose` を使う形を採る。テストのコードへ組み込む形は言語ごとに分かれるため採らない(決定 1) | https://java.testcontainers.org/modules/docker_compose/ (2026-11-06) | +| PRD2 | GitHub Actions のサービスコンテナ | 仕事ごとに新しいコンテナを作り、仕事が終わると破棄する | 使う単位(worktree)に合わせて起こし、削除に合わせてコンテナを破棄する。ボリュームは手元のホストに残るデータで、利用者の答えにより既定で残す(決定 7) | https://docs.github.com/en/actions/tutorials/use-containerized-services/use-docker-service-containers (2026-11-06) | +| PRD3 | Dev Containers | 起動ごとに走るコマンド(`postStartCommand`)と、接続の前に待つコマンド(`waitFor`)を宣言で持つ | 起動の宣言は既存の `testenv` を使い、起動のための新しい宣言を足さない | https://containers.dev/implementors/json_reference/ (2026-11-06) | +| PRD4 | Supervisor | 常駐プロセスが子を管理し、`stdout_logfile` と `redirect_stderr` で出力を 1 本のログへ書く | ログを 1 本にまとめる形を採る。1 回限りの確認のコマンドに常駐プロセスは要らないため、製品は採らない(決定 6) | https://supervisord.org/configuration.html (2026-11-06) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | testcontainers(Python) | 4.15.0 | Apache-2.0 | 2026-07-24 | 無し(PyPI の取り下げ(yanked)の値は false。リポジトリのアーカイブの有無は未確認) | 満たさない。割り当て・ポートの帯・worktree レジストリは `worktree-testenv.sh` が持ち、compose の起動はそれを通す必要がある | https://pypi.org/pypi/testcontainers/json (2026-11-06) | +| LIB2 | pytest-docker | 3.2.6 | MIT | 2026-10-16 | 無し(同上) | 満たさない。pytest の中で働き、vitest やシェルのコマンドの suite に使えない | https://pypi.org/pypi/pytest-docker/json (2026-11-06) | +| LIB3 | python-on-whales | 0.81.0 | MIT | 2026-03-09 | 無し(同上) | 満たさない。`docker` の CLI の包みで、`WT_DOCKER_COMMAND` の差し替えと既存のシェルの経路を置き換える利点が無い | https://pypi.org/pypi/python-on-whales/json (2026-11-06) | +| LIB4 | watchdog | 7.1.0 | Apache-2.0 | 2026-11-05 | 無し(同上) | 満たさない。ファイルの変更をイベントで受けられるが、親は子の終了を待つループを持ち、ログの大きさは標準ライブラリで読める | https://pypi.org/pypi/watchdog/json (2026-11-06) | +| LIB5 | psutil | 7.2.2 | BSD-3-Clause | 2026-01-28 | 無し(同上) | 満たさない。プロセスの情報を読むライブラリで、見るのはログの大きさである | https://pypi.org/pypi/psutil/json (2026-11-06) | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| testcontainers の Python 版の Compose の文書 | https://testcontainers-python.readthedocs.io/en/latest/modules/compose/README.html | HTTP 404。Java 版の文書(PRD1)で振る舞いを見た | +| LIB1〜LIB5 のリポジトリのアーカイブの有無 | 試していない | PyPI の情報だけを読んだ。5 つとも採らないため、ここで止めた | + +調べた量: 候補 14 件・取得の試み 19 回。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | テストの前にテスト環境を自動で起こす | 開発ループ・cross-refactoring | +| F2 | 起こせなかった理由を結果に載せて止まる | 判定の worker・利用者 | +| F3 | detached HEAD の worktree でテスト環境を使う | cross-refactoring | +| F4 | 導入の確認の出力を走っている間にログへ書き、進みを知らせる | 遅れの見張り・利用者 | +| F5 | 全体テストの落ちたコマンドを報告する | 判定の worker・利用者 | +| F6 | worktree の削除に合わせてテスト環境を破棄する | `/ndf:merged` の利用者 | + +## 構成要素 + +### 変える要素と責務 + +| 要素 | 責務 | +| --- | --- | +| 到達の確認(`lib/container_reach.py`) | 届かなければ 1 度だけテスト環境を起こして確かめ直す。起こした記録を持つ。公開を閉じて破棄する関数を持ち、worktree を外してよいかを返す | +| テスト環境の管理(`worktree-testenv.sh`、`lib/worktree-registry.sh`) | detached HEAD の worktree にも環境名とスロットを割り当てる。`up` で宣言のサービスの準備完了を待つ。`down` が `testenv.discard_volumes` を読む。行に `golden_tag` が無い割り当ての `up` は、残ったボリュームがあればタグを書かない。`down` は破棄に失敗すると行の `golden_tag` を消す(決定 7)。割り当ての持ち主であることは変えない | +| 作業用 worktree の置き場(`lib/worktree_base.py`、`lib/worktree-branch.sh`) | コンテナの宣言があるリポジトリの cross-refactoring と cross-review の worktree を、実行系から見える場所へ置く。置き場を git の無視へ登録する(決定 9) | +| 置き場を求める呼び出し(7 箇所) | リポジトリのルートを渡す。`cross-review` の `review_lib/commands/init.py` の 2 箇所・`drive.py`、`cross-refactoring` の `refactor_lib/paths.py`・`refactor_lib/commands/setup.py`、`merged-steps.py` の `cleanup`、`lib/run_state.py` の `review_base`(決定 9) | +| worktree を作る経路(`cross-review` の `review_lib/workspace.py`、`cross-refactoring` の `refactor_lib/commands/setup.py`) | `git worktree add` の前に置き場の登録を呼ぶ(決定 9) | +| テストのエントリポイント(`test-run.py`) | 時間の予算を数え始める前にテスト環境を用意する。起こした記録と落ちたコマンドを結果へ載せる | +| ステップの実行(`supervise_lib/steps.py`) | コンテナの宣言があるとき、テストのステップの `timeout` へ起動の上限を足す | +| 導入の確認(`release_lib/deploy.py`) | 出力をログへ直接書き、伸びたことだけを標準エラーへ知らせる | +| 後片付け(`merged-steps.py`、`lib/run_state.py`) | worktree を消す直前に公開を閉じてテスト環境を破棄する。ボリュームを消すかは `worktree-testenv.sh down` が宣言で決める。閉じられなければ worktree を残し、破棄できなければ環境名を結果に残す | +| 手順書(`worktree/references/test-execution.md`、`worktree/references/declaration.md`、`worktree/SKILL.md`、`merged/SKILL.md`、`cross-review` と `cross-refactoring` の `docs/`、`lib/README.md`) | 自動で起こすこと・破棄の担い手・detached HEAD の環境名・置き場の解決の順を書く。`--volumes` を既定の破棄として書いた 4 か所(`worktree/SKILL.md` のコマンド例と「worktree を消す前に `down --volumes` を実行する」の文、`test-execution.md` の表の「破棄する」の行と同じ文)を書き換える。既定の破棄の例は `down "$WT"` にし、ボリュームを消すのは宣言か明示の指定のときだけと書く。あわせて自動の破棄・残るボリューム・`discard_volumes`・手で消す手順を書く。`test-execution.md` の `expose` の拒否の条件 2 に、残ったボリュームの上の `up` はタグを書かないことと、`down "$WT" --volumes` の後に起こし直す手順を足す | +| 宣言の説明(`worktree/schemas/registry.schema.json`、`worktree/schemas/worktree.schema.json`、`project_lib/model.py`) | 環境名の形と `container` の説明を合わせる。`testenv.discard_volumes` を足す | + +cross-refactoring の実行は変えない。`run_with_timeout` が到達の確認の `env_for` を呼んでいるため、用意が働く。cross-refactoring と cross-review で変えるのは、置き場を求める呼び出しへリポジトリのルートを渡すことと、worktree を作る前に置き場を git の無視へ登録することだけである(決定 9)。`worktree-testenv.sh test` も変えない。この経路は `probe` の CLI を呼ぶため、今のまま起こさずに止まる。 + +### システムの文脈 + +```mermaid +graph LR + L[開発ループ] --> N[NDF のスクリプト] + N --> D[コンテナ実行系] + N --> R[worktree レジストリ] +``` + +### 構成要素の関係 + +```mermaid +graph TD + subgraph エントリポイント + S[ステップの実行] + T[テストのエントリポイント] + C[cross-refactoring の実行] + M[後片付け] + V[導入の確認] + end + subgraph 用意 + A[到達の確認] + E[テスト環境の管理] + end + S --> T + T --> A + C --> A + M --> A + A --> E + E --> D[コンテナ実行系] + A --> D + V --> L[所有者だけが読むログ] +``` + +図には文書(手順書・宣言の説明)を含めない。 + +### 配置 + +どの要素も開発ループと同じコンテナかホストのプロセスで動き、コンテナの起動は `WT_DOCKER_COMMAND`(既定は `docker`)へ渡す。コマンドの経路は、ホストの Docker を共有するコンテナの中(DooD: Docker outside of Docker)でも、ホストの直下でも同じである。 + +違うのはバインドのパスの解かれ方で、DooD ではコンテナの中のパスではなくデーモンから見たパスで解かれる。テスト環境が worktree を見るには次の 2 つが要る。 + +| 条件 | 持ち主 | +| --- | --- | +| worktree がデーモンから見える場所にある | NDF(決定 9) | +| compose の定義のバインド元が、デーモンから見たパスになる | プロジェクト(carul は `deps.run` の `scripts/worktree-env.sh` が worktree の `.env` へ書く) | + +どちらかが欠けると起動はできても探りの印が読めず、「起こしても届かない」で止まる。 + +```mermaid +graph TD + subgraph 開発コンテナかホスト + P[テストのエントリポイントのプロセス] + B[worktree-testenv.sh] + end + subgraph Docker のデーモン + S[テスト環境のコンテナ] + end + P --> B + B --> S + P --> S +``` + +### パッケージ構成 + +新しいファイルは足さない。 + +```text +plugins/ndf/ +├── scripts/ +│ ├── test-run.py +│ ├── merged-steps.py +│ ├── worktree-testenv.sh +│ ├── lib/container_reach.py +│ ├── lib/run_state.py +│ ├── lib/worktree-registry.sh +│ ├── lib/worktree-branch.sh +│ ├── lib/worktree_base.py +│ ├── release_lib/deploy.py +│ └── supervise_lib/steps.py +└── skills/worktree/ + ├── SKILL.md + ├── references/declaration.md + ├── references/test-execution.md + ├── schemas/registry.schema.json + └── schemas/worktree.schema.json +``` + +## 構造 + +`Unreachable` に「起こせば直りうるか」を足す。コンテナが動いていない・別のディレクトリを見ている・答えないは起こす対象で、実行系が無い・テスト環境の値を読めない・探りの印を置けないは起こしても直らない。 + +```mermaid +classDiagram + class Unreachable { + +reason: str + +code: int + +wakeable: bool + } + class container_reach { + +UP_TIMEOUT: int + +env_for(cwd) dict + +events() list + +discard(worktree) tuple + -wake(cwd, why) None + -leftover_volumes(environment) list + } + container_reach ..> Unreachable : 送る +``` + +- `UP_TIMEOUT` は 300 秒 +- `events()` は起こした・起こせなかった記録の一覧を返す +- `leftover_volumes()` は起こす前に `docker volume ls -q` とプロジェクトのラベルでボリュームを数える。読めなければ空を返し、止めない(決定 7) +- `discard()` は開いた公開を閉じてから破棄し、`(worktree を外してよいか, 理由)` を返す。閉じられなければ破棄せず `(False, 理由)`、破棄できなければ `(True, 理由)`、どちらも済んだか、割り当ても公開も無ければ `(True, None)` を返す +- 「`worktree-testenv.sh up` で起こす」の案内は `probe` の CLI の経路だけへ移す。`env_for` の経路の理由に載せない(受け入れ条件 6) + +## データ構造 + +worktree レジストリの形は変えない。detached HEAD の worktree の行だけ、値が次のようになる。 + +| 列 | 値 | 例 | +| --- | --- | --- | +| `branch` | 空の文字列 | `""` | +| `environment` | `<リポジトリ>-wt-detached--` を、ハッシュ値の手前を切って 40 文字に収めたもの | `carul-wt-detached-work-1a2b3c` | + +`.branch` を読むコードはシェルの側に無い(`worktree-*.sh`・`lib/worktree-*.sh` を `\.branch\b` で検索して 0 件、2026-11-06)。Python の側は `lib/run_state_kinds.py` がファイルのパスを一覧に持つだけで、`branch` の列を読まない。`worktree-registry` で検索して、当たるのはこの 1 ファイルである(2026-11-06)。 + +| 機能 | worktree レジストリ | +| --- | --- | +| F1 | 作る(`env` が割り当てを取る)・更新する(`up` が時刻を書く) | +| F3 | 作る | +| F6 | 更新する(解放の時刻を書く)。行は消さない | + +## 入出力の契約 + +### テスト環境の管理のコマンド(`worktree-testenv.sh`) + +| コマンド | detached HEAD の worktree | リポジトリの外 | +| --- | --- | --- | +| `env` | 終了コード 0。JSON の `branch` は `""` | 今のまま終了コード 1 と、ブランチを取れない旨の断り | +| `up` / `compose-env` / `down` | 終了コード 0 | 今のまま | + +`up` に `--wait-service <サービス>`(複数可)を足す。`up -d` の後に `docker compose up -d --wait <サービス>…` を打ち、0 以外なら `up` の終了コードとして返す。指定が無ければ今のままである。 + +`up` は `golden_tag` を次のとおり扱う。`compose up` の前に判定する(決定 7)。判定するのは書くタグ(`TAG`)が空でないときだけである。`golden_tag_paths` の宣言が無くタグが空なら、今のまま何も書かず、`volume ls` を打たず、案内も出さない。 + +| 割り当ての行の `golden_tag` | `docker volume ls -q --filter label=com.docker.compose.project=<環境名>` | `up` | +| --- | --- | --- | +| ある | 打たない | 今のまま、新しいタグで上書きする | +| 無い | 終了コード 0 で、名前の出力が空 | タグを書く | +| 無い | 名前が 1 つ以上出る、または 0 以外で終わる | タグを書かずに起こす。理由を標準エラーへ出す。`testenv.expose.enabled` が `true` のときだけ `down --volumes` の案内を足す。終了コードは変えない | + +`-q` は省かない。`-q` が無いと、該当が 0 件でも見出しの行が出て終了コードは 0 なので、新しい環境でも「残っている」と読む(決定 7 の実測)。案内を公開が有効なときに限るのは、公開を使わない場所で戻せない削除を勧めないためである。 + +`expose` は変えない。`golden_tag` が無ければ今の照合が `public_tag` と一致せず、終了コード 1 で拒否する。 + +`down` はメインディレクトリの宣言を読み、ボリュームを次のとおり扱う。読むのは `worktree-testenv.sh` の 1 か所で、`discard()` は引数を足さずに `down ` を打つ。 + +| `testenv.discard_volumes` | `down ` | `down --volumes` | +| --- | --- | --- | +| 無し・`false`・真偽値以外(文字列の `"true"` を含む) | `compose down`。ボリュームは残る | `compose down --volumes` | +| `true` | `compose down --volumes` | `compose down --volumes` | + +破棄に失敗した `down` は、今のとおりスロットを解放せず終了コード 1 で返す。その前に行の `golden_tag` を消す(`_wt_slot_set_field` で `del(.golden_tag)`)。消せなければその旨を標準エラーへ足し、終了コードは 1 のままである。 + +### 宣言(`.ndf/worktree.json`) + +| 項目 | 型 | 既定 | 意味 | +| --- | --- | --- | --- | +| `testenv.discard_volumes` | 真偽値 | 無し(消さない) | `true` のとき、`worktree-testenv.sh down` がテスト環境のボリュームも消す。消えたデータは戻せない | + +### テストの結果(`test-run.py`) + +`items` に 2 種類の行を足す。既存の行は変えない。終了コードの意味も変えない。 + +```json +{"testenv": [{"environment": "carul-wt-feat-x-1a2b3c", "result": "woken", "seconds": 31.2}]} +``` + +```json +{"failed_commands": [{"command": "uv run pytest", "code": 1, "log": ".ndf/tmp/test-run/whole-2.log", "tail": "(ログの末尾)"}]} +``` + +| 項目 | 値 | +| --- | --- | +| `testenv[].result` | `woken`(起こして届いた)/ `failed`(起こせなかった、または起こしても届かない) | +| `testenv[].reason` | `failed` のときの理由 | +| `testenv[].reused_volumes` | 起こす前から同じプロジェクト名で残っていたボリュームの名前の一覧。無ければ項目を載せない(決定 7) | +| `tail` | ログの末尾を `secret_redact.redact_output` で通した文字列 | + +`testenv` の行は、起こそうとした実行のすべての止まり方に載る。全体テストが落ちたときの `summary` は `全体テスト 4 本のうち 1 本が落ちた(74.6 秒 / 戦略 local-full)` の形にする。全部通ったときの `summary` は変えない。 + +### テストが止まるときの `summary` + +形は `コンテナで走る suite へ worktree が届かない: <理由>` で、終了コードは 2 である。 + +| 場合 | 理由 | +| --- | --- | +| `testenv` の宣言が無い | `.ndf/worktree.json` に `testenv` の宣言が無いため、サービス <名前> のテスト環境を起こせない | +| `localenv.compose_files` が無い | `.ndf/worktree.json` の `localenv.compose_files` が無いため、テスト環境を起こせない | +| 空きスロットなし | サービス <名前> のテスト環境を起こせない: `worktree-testenv.sh` の標準エラー(空きスロットがありません…) | +| 起動の失敗(準備完了の待ちの失敗を含む) | サービス名・終了コード・`up` の標準エラーの末尾 | +| 上限の超過 | サービス <名前> のテスト環境の起動が 300 秒で終わらなかった | +| 起こしても届かない | 2 度目の到達の確認の理由 | + +<名前> は 1 回目の確認で届かなかったサービスで、複数なら `、` で並べる。 + +### 導入の確認の出力(`release-steps.py verify-facts` / `deploy-facts`) + +| 項目 | 契約 | +| --- | --- | +| ログ | `.verify.log`(0600)。確認のコマンドの標準出力と標準エラーを 1 本にまとめ、出た順に書く | +| 標準エラー | ログが伸びたとき、最短 30 秒の間隔で `導入の確認の出力: <バイト数> バイト(<秒> 秒)` を書く。出力の中身は載せない | +| 終了コード・承認資料 | 変えない | + +### 後片付けの結果(`/ndf:merged`) + +| 場合 | `items[].result` | `items[].reason` | +| --- | --- | --- | +| 破棄した | 今のまま | 今のまま。残るボリュームは載せない(決定 7) | +| 破棄できない | 今のまま(worktree は外れる) | `テスト環境を破棄できない: <環境名>(<理由>)` | +| 公開を閉じられない | `kept` | `テスト環境の公開を閉じられない: <環境名> (<理由>)。close_command を直して worktree-testenv.sh unexpose を打つ` | + +公開を閉じられないときは破棄へ進まないため、2 つの理由は同時に出ない。残した worktree は、次の後片付けでも同じ順に試す。 + +## 処理の流れ + +### テストの用意 + +```mermaid +sequenceDiagram + participant T as テストのエントリポイント + participant A as 到達の確認 + participant E as テスト環境の管理 + participant D as コンテナ実行系 + T->>A: env_for + A->>D: compose exec で印を読む + alt 届いた + A-->>T: 足す環境変数 + else 起こす対象の理由で届かない + A->>E: env + E-->>A: 割り当て(宣言が無ければ空) + A->>D: 残っているボリュームを読む(volume ls) + A->>E: up(上限 300 秒) + E->>D: compose up -d + E->>D: compose up -d --wait 宣言のサービス + A->>D: compose exec で印を読む + A-->>T: 足す環境変数か Unreachable + else 起こしても直らない理由 + A-->>T: Unreachable + end + T->>T: 時間の予算を数え始めてテストを走らせる +``` + +`env_for` は宣言のすべてのサービスを確かめ、1 つでも届かなければ 1 度だけ `up` を打って全部を確かめ直す。`up` の前に標準エラーへ 1 行書き、遅れの見張りに進みを見せる。 + +`test-run.py` は、手元で走らせるコマンドがあるときだけ `env_for` を呼ぶ。呼ぶ時点は、`scope` では走らせるものを決めた後、`whole` では `budget_started` を取る前である。CI に任せて手元で走らせるものが無いときは起こさない。`test_triage.run_command` の中の呼び出しは結果を使い回す。 + +### worktree の削除 + +```mermaid +graph TD + A[追跡ファイルの変更を確かめる] -->|ある| K[外さず kept] + A -->|無い| P[開いた公開を閉じる] + P -->|閉じられない| K + P -->|閉じた・公開が無い| B[テスト環境を破棄する] + B --> C[worktree を外す] + C --> D[結果を返す] +``` + +最初に `unexpose` を打つ。開いた公開の記録があれば閉じる手段を実行し、記録が無ければ何もせず 0 で終わる(実行系を呼ばない)。公開の記録は割り当てを解放した後にも残るため、割り当ての有無によらず打つ。続けて `compose-env` で割り当ての有無を見て、あれば `down ` を打つ。ボリュームを消すかは `down` が宣言で決める。割り当てが無ければコンテナ実行系を呼ばない。閉じられなければ `down` へ進まず、worktree も外さない。外す直前に同じ `discard` を呼ぶ箇所は 2 つある。`merged-steps.py` の `remove_worktree` は、最初の `git worktree remove` より前に追跡ファイルの変更を確かめてから呼ぶ。`lib/run_state.py` の `remove_review_worktree` は、状態のディレクトリを消す前に呼ぶ。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 可用性 | 起動は上限つきの 1 回で終わる。上限を超えたら受け入れ条件 5 で止まり、待ち続けない | `up` を 300 秒の上限で打ち、超えたらプロセスグループごと止める(`test_triage.stop_group`)。2 度目を打たない | フェイクの実行系で `up` を止まらせ、上限で止まり起動が 1 回であることを見る | +| 性能・拡張性 | 既に届く worktree では、追加の処理は今の到達の確認 1 回だけである | 1 回目の確認が通れば `env` も `up` も呼ばない | フェイクの実行系の呼び出しの記録を見る | +| 運用・保守性 | 起こしたこと・起こせなかった理由が結果の JSON に残り、判定の worker と人が同じ材料を読める | `testenv` の行を結果へ載せる | 結果の JSON を読むテスト | +| 移行性 | 導入先の宣言の変更は要らない。`testenv` と `container` を宣言済みのリポジトリは、版を上げるだけで自動で起きるようになる | 必須の設定を足さない。`testenv.discard_volumes` は任意で、無ければ今までの宣言のまま動く | 宣言を変えない fixture で受け入れ条件 1 を通す | +| セキュリティ | 確認のコマンドの出力は今と同じく所有者だけが読める権限で書く(出力に秘密が含まれうる)。結果の JSON とログの末尾へ載せるのは全体テストの出力だけで、導入の確認の出力は載せない | ログを 0600 で開いて子へ直接渡す。進みの知らせはバイト数と秒だけにする | ログの権限と、標準エラーに出力の文字列が無いことを見る | +| システム環境 | ホストの Docker を共有するコンテナの中と、ホストの直下の両方で動く。`WT_DOCKER_COMMAND` の差し替えを守る | コンテナの操作は `worktree-testenv.sh` と既存の `docker_command()` だけを通す。作業用 worktree はデーモンから見える場所へ置く(決定 9) | フェイクの実行系のテストと、実物の Docker でのリリース後テスト | + +## 決定の記録 + +### 決定 1: 経路ごとに複製しないため、テスト環境の用意を到達の確認の `env_for` の中に置く + +範囲テスト・全体テスト・cross-refactoring はどれも `env_for` を通るため、ここに置けば 1 か所で全部に効く。`worktree-testenv.sh test` は `probe` の CLI を呼ぶ経路なので、要求の「含まない」のとおり影響を受けない。 + +プランへ起動のステップを足す形は、テンプレートごとに足す必要があり、cross-refactoring の実行に効かないため採らない。テストのコードへ組み込むライブラリは、言語とテストランナーごとに分かれ、割り当ての持ち主を 2 つにするため採らない。 + +- 作り方: 自前で作る +- 比べた候補: PRD1 / LIB1 / LIB2 / LIB3 + +根拠: Value 7 / Value 6(MVV 版 6) + +### 決定 2: 依存の準備を待ちつつ無関係なサービスの不調で止まらないよう、`up -d` の後に宣言のサービスだけを `--wait` で待つ + +テストに要るのは、宣言のサービスが worktree を見ていて、依存のサービスの準備ができていることである。到達の確認だけでは依存を見ないため、起動直後にテストが走って接続で落ちうる。そのため `worktree-testenv.sh up` が宣言のサービスを `--wait-service` で受け、`up -d` の後に `docker compose up -d --wait <サービス>…` を打つ。上限は決定 3 の 300 秒の中に収める。 + +`up -d` はサービスを絞らずに打つ。人が打って通った形と同じである。待つ対象を絞るのは、全体の `--wait` がテストに関係しないサービスで止まるためである。carul では `web` が unhealthy のままテストが通っている環境がある(`docker ps`、2026-11-06)。 + +Docker Compose v5.6.1 の実測(2026-11-06、検証用の定義): + +| 操作 | 結果 | +| --- | --- | +| `web` が unhealthy の定義で、全体を `--wait` | 終了コード 1 | +| 同じ定義で `api` を指定して `--wait` | 終了コード 0。`web` は unhealthy のまま | +| `api` の依存 `db` が `condition: service_started` で healthcheck を持つ。`api` を指定して `--wait` | `db` が healthy になる 9 秒後に終了コード 0 | +| 依存が healthcheck を持たない。指定して `--wait` | 動いた時点の 2 秒で終了コード 0 | +| 起動後に終了コード 0 で終わるサービスを指定して `--wait` | 終了コード 1 | + +このため、準備完了を担保できるのは依存が healthcheck を持つプロジェクトである。持たないプロジェクトの扱いは「未確認のまま残ること」に置く。宣言のサービスは動き続けるサービスである必要があるが、到達の確認の `compose exec` も同じ前提である。 + +- 作り方: 標準に倣う +- 比べた候補: STD1 + +根拠: Value 3(MVV 版 6) + +### 決定 3: テストの上限を削らないため、起動の上限を 300 秒の定数にし、テストの予算の外に置く + +carul の実測 22 個の最長は 49 秒で、定数 300 秒はその 6 倍である。遅れの見張りの既定の下限(300 秒)と同じ値のため、1 回目の確認の時点で起動が終わっている。上限で止まっても人が `up` を打てば先へ進めるため、設定にしない。 + +起動の時間をテストの予算へ数えると、テストの上限を削り、全体テストの実測も起動の分だけ伸びる。そのため `test-run.py` は用意の後に予算を数え始める。プランのステップの `timeout` は `fill_test_limit` でコンテナの宣言があるときだけ 300 秒足す。`with_margin` の余裕には重ねない。 + +根拠: Value 3 / Value 4(MVV 版 6) + +### 決定 4: HEAD が動いても同じ環境を指すため、detached HEAD の環境名を worktree のパスから作る + +ハッシュ値は worktree の絶対パスから作り、名前の語にディレクトリ名を使う。パスは worktree ごとに一意で、HEAD の移動で変わらない。`wt_env_name` にハッシュ値の元を渡す 3 つ目の引数を足し、ブランチの worktree の名前は変えない。 + +コミットから作る形は、HEAD の移動で名前が変わり、同じコミットの worktree で衝突するため採らない。 + +- 作り方: 自前で作る +- 比べた候補: STD3 + +根拠: Value 6(MVV 版 6) + +### 決定 5: 効かない手当てを足さないため、導入の確認の前にテスト環境を用意しない + +carul の確認のコマンドは `docker compose run` で自分のコンテナを作り、動いている api を使わない。打ち切りの記録は、検証環境のパイプラインの待ちを示す。テスト環境を用意しても遅れは直らず、メインディレクトリに解放されない割り当てが増え、確認のコマンドの向き先が既定のプロジェクトから変わる。 + +確認のコマンドが要るコンテナは、コマンド自身が起こす。 + +根拠: Value 3 / Value 7(MVV 版 6) + +### 決定 6: 秘密を見せずに進みを見張れるよう、出力をログへ直接書き、伸びたことだけを標準エラーへ知らせる + +確認のコマンドの標準出力と標準エラーをログのファイルへ直接渡す。子が直接書くため、遅れの見張りがプロセスグループごと止めても書いた分が残る。親は 5 秒ごとに `wait` しながらログの大きさを見て、伸びていれば最短 30 秒の間隔で標準エラーへ 1 行書く。遅れの見張りの `probe_output` は標準エラーの伸びを見ているため、見張りの側は変えない。 + +出力そのものを標準エラーへ流す形は、`run-stderr.log` と判定の LLM へ秘密が渡るため採らない。見張りの調査でログを読む形は、プランごとに `probe` のコマンドが要り、前回の大きさを持つ状態も増えるため採らない。`tee` は出力を標準出力へも複製するため、秘密の扱いが同じ理由で合わない。常駐プロセスで子を管理する製品は、1 回限りのコマンドには要らない。ログの伸びは標準ライブラリで読めるため、ファイル監視とプロセス監視のライブラリは足さない。 + +- 作り方: 自前で作る・標準に倣う +- 比べた候補: STD4 / STD5 / PRD4 / LIB4 / LIB5 + +根拠: Value 5 / C1(MVV 版 6) + +### 決定 7: 戻せない削除を既定にしないため、自動の破棄はコンテナまでにし、ボリュームは宣言があるときだけ消す + +**利用者の答えで案を変えた。** 承認ゲート 1 に出した案は `down --volumes` で、ボリュームも消す形だった。利用者の答えは「コンテナだけ消す。自動では `down`(`--volumes` 無し)までにして、ボリュームは残す。残ったボリュームは宣言があるときだけ消す形を実装の前に設計へ反映する。戻せない削除を既定にしない」である([#2567 の節](https://github.com/devbasex/ai-plugins/issues/2567)。2026-11-06)。テスト環境のデータが作り直せるかはプロジェクトごとに違い、NDF は確かめられない。受け入れ条件 14・前提 4・S7 は、この答えへ更新済みである。 + +NDF が起こすようになると、NDF が片付ける必要がある。破棄の持ち主を 1 つにするため、worktree を外す 2 か所の直前に置く。`down` は compose の定義を worktree から読むため、外した後では打てない。追跡ファイルに変更があって外さない worktree では破棄しない。破棄に失敗しても worktree の削除は続ける。後片付けを止める方が失う物が大きい。このとき行は解放されないため、`down` が行の `golden_tag` を消す(下の「残ったボリュームの上では公開を通さない」)。 + +破棄の前に開いた公開を `unexpose` で閉じる。閉じる手段は worktree へ `cd` して動くため、worktree を外した後では閉じられない。閉じられないときは `down` も worktree の削除もせず `kept` で残し、環境名・URL・`unexpose` の打ち直しを `items[].reason` に載せる。 + +破棄の失敗と扱いを分けるのは、失うものが違うためである。スロットを解放すると同じ番号が次の worktree へ渡り(`wt_slot_acquire`)、開いたままの `wt<スロット>.<ドメイン>` が別のテスト環境を指しうる。これは公開の基準タグの確かめを通らない。worktree を消すと `unexpose` が `cd` で止まって公開の記録を閉じられず、以後の `expose` は別の worktree の開いた記録で拒まれ続ける(`expose_record_assignment`)。受け入れ条件 14 の「削除を続ける」は破棄の失敗についての条件で、公開を閉じられない場合はこの決定で定める。 + +**宣言は `.ndf/worktree.json` の `testenv.discard_volumes`(真偽値、既定は無し=消さない)にする。** テスト環境の定義が既にここにあり、新しい設定ファイルが要らない。`testenv` の schema は未知の項目を許すため、古い版の NDF は読み飛ばし、消さない側へ倒れる。 + +読むのは `worktree-testenv.sh` の `do_down` だけにする。宣言は他の値と同じく `wt_declaration` でメインディレクトリから読むため、作業中のブランチの変更では有効にならない。個人設定(`.ndf/worktree.local.json`)の `testenv` も今の規則のとおり重なる。消えるのはその人の機械のボリュームだけで、`expose` のように外へ影響しないため、例外にしない。`discard()` が宣言を読んで `--volumes` を渡す形は、割り当ての持ち主の判断を Python へ分けるため採らない。比べる式は `.testenv.discard_volumes == true` で、文字列の `"true"`・`1`・`null` はどれも偽になる(`jq`、2026-11-06)。戻せない操作なので、真偽値の `true` だけで動かす。人が打つ `--volumes` は今のまま働く。 + +Docker Compose v5.6.1 の実測(2026-11-06、検証用の定義): + +| 操作 | 結果 | +| --- | --- | +| `down` の後に同じプロジェクト名で `up` | 名前付きボリュームの中身が残っていて読める | +| 同じ操作の匿名ボリューム | 古いものは残り、`up` は新しいものを作る。古いものは読まれない | +| `down` の後に `down --volumes` | 名前付きは消える。匿名は消えない(コンテナが無く結び付きが切れている) | +| `down --volumes` と `external: true` のボリューム | 消えない | +| `docker volume ls --filter label=com.docker.compose.project=<プロジェクト名>` | 名前付きが出る。`down` の後も出る。匿名は出ない(ラベルは `com.docker.volume.anonymous` だけ) | +| 該当が 0 件の同じコマンド(Docker 29.9.2) | `-q` が無いと見出しの行 `DRIVER VOLUME NAME` を出して終了コード 0、`-q` があると何も出さず終了コード 0 | +| 何も無いプロジェクトへの `down --volumes` | 警告を出して終了コード 0 | + +**残るボリュームの影響は次のとおりに扱う。** + +| 影響 | 確かめたこと | 扱い | +| --- | --- | --- | +| ディスク | carul のボリュームは 99 個で計 18.3 GB、ホストの空きは 11.9 GB。1 環境は `db_data` と `web_node_modules` で約 390 MB で、空きは約 30 環境分である。**この課題で増える。** 今は人が `up` を打った worktree にだけボリュームができるが、これからはテストを走らせる worktree のすべてに自動ででき、detached HEAD の `rf<番号>/work` も加わる。後片付けはコンテナまでなので、できた分は消えない。着手 1 件あたり feature の worktree で約 390 MB、cross-refactoring を通すと `rf<番号>/work` の分を足して約 780 MB である。cross-review の `pr<番号>` はテストを走らせないため増えない。ホストの空きは他の用途がなければ約 15〜30 着手で尽き、尽きると `up` かテストの書き込みが落ちる。コンテナは消えるようになる | `discard_volumes` を宣言して自動で消すか、手で消す。定期的に消す処理は足さない(要求の「含まない」)。空きの知らせと容量の閾値もこの設計では足さず、要否を利用者の判断として「未確認のまま残ること」に残す。手で消す手順は `test-execution.md` に書く(下) | +| 古いデータの読み込み | Compose のプロジェクト名は環境名で(`compose -p`)、環境名はブランチ名か(detached HEAD は)worktree のパスから作る。**スロット番号は環境名に入らないため、スロットの再利用だけでは別の環境のボリュームを読まない。同じブランチ名か同じパスの worktree を作り直すと、同じプロジェクト名になり、古いボリュームを読む。** carul の worktree レジストリの 49 の環境名のうち 2 つは、同じ worktree のパスで解放の後に割り当て直されている。carul の `db` は初期化の定義を `docker-entrypoint-initdb.d` で渡しており、保存先が空でなければ公式イメージはこれを走らせないため、古いデータのままテストが走って偽の失敗になりうる | 消さずに知らせる。起こす前に残っていたボリュームを `testenv[].reused_volumes` へ載せ、判定の worker が原因の候補として読めるようにする。止めない。自分の worktree で `stop` の後に起こしたときも載るが、走りは変わらない。公開は別に塞ぐ(下の「残ったボリュームの上では公開を通さない」) | +| 匿名ボリューム | `down` は匿名を残し、次の `up` は読まず、後の `down --volumes` でも消えない。プロジェクトのラベルも持たない | `discard_volumes: true` なら `down --volumes` が破棄の時点で消す。宣言が無ければ残る。消す手段は `docker volume ls --filter dangling=true` からの手作業で、どの環境の物かは見分けられない。carul の定義は名前付きだけを宣言している(イメージが宣言する分は未確認) | + +**残ったボリュームの上では公開を通さない。** 公開してよいのはマスク済みデータで焼いた基準を載せたテスト環境だけで(`test-execution.md`)、`expose` はこれを `golden_tag` と `public_tag` の照合で確かめる。ところが `up` は宣言のファイルから求めたタグを、載っているデータを見ずに書く(`do_up`)。同じ環境名で worktree を作り直すと、古いデータの上に新しいタグが書かれ、照合が通る。既定でボリュームを残すと、この経路が普通の操作で起きる。 + +そこで、行に `golden_tag` が無い割り当ての `up` は、同じプロジェクトのボリュームが残っていればタグを書かない(I9)。新しい行は `golden_tag` を持たないため(`wt_slot_acquire`)、解放してから作り直した worktree はこの条件に当たる。条件を「割り当てを新しく取った `up`」にしないのは、`worktree/SKILL.md` の手順では `env` が割り当てを取り、後の `up` は取らないためである。`volume ls` が失敗したときも書かない。公開の関門は確かめられないときに閉じる側へ倒す。テストは止めない。`reused_volumes` と同じく古いデータでの走りは原因の候補にとどまり、止める理由にならない。 + +破棄に失敗した worktree はこの条件から漏れる。`do_down` は失敗すると行を解放しないため、worktree を外した後に同じパスで作り直すと、`wt_slot_acquire` は `golden_tag` の付いた古い行をそのまま返し、`up` は `volume ls` を打たずに新しいタグで上書きする。古いデータのまま照合が通る。そこで、破棄に失敗した `do_down` が行の `golden_tag` を消す。消す場所を `do_down` にするのは、`discard()` の 2 か所の呼び出しが同じ 1 か所を通り、Python へ台帳の更新を増やさないためである。worktree を残したまま `down` だけが失敗したときも消える。破棄の途中で落ちた環境の中身は確かめられないため、閉じる側へ倒す。`expose` は拒否し、次の段落の `down --volumes` で戻せる。 + +公開したいときは `down --volumes` で消してから `up` を打つ。消せばボリュームが無くなり、次の `up` がタグを書く。消す判断はこの操作を打つ人が持つため、戻せない削除を既定にしないという答えは変わらない。 + +後片付けの結果へは残るボリュームを載せない。すべての worktree の削除に出る行は、読む側が見分けるための情報にならないためである。`reused_volumes` は影響が出る時点で知らせる。 + +手で消す手順は次のとおりで、`down` は割り当てが無いと何もしないため、worktree を消した後も打てる形にする。 + +```bash +docker volume ls -q --filter label=com.docker.compose.project=<環境名> # 残っているものを見る +docker volume ls -q --filter label=com.docker.compose.project=<環境名> | xargs -r docker volume rm +``` + +動いているコンテナが使うボリュームは `docker volume rm` が断り(`volume is in use`、`xargs` の終了コードは 123)、コンテナを消した後は消せる(実測、2026-11-06)。 + +定期的に止める処理は、要求の「含まない」のとおり足さない。 + +- 作り方: 標準に倣う +- 比べた候補: STD2 / PRD2 + +根拠: 利用者の答え(#2567 の節)/ C3 / C4 / Value 1 / Value 3 / Value 2(MVV 版 6) + +### 決定 8: 判定の worker が別の項目を突き合わせずに読めるよう、落ちたコマンドを `summary` と結果の両方に載せる + +`summary` は落ちた本数と走らせた本数を出し、`items` はコマンド・終了コード・ログ・末尾を持つ。ログは流し直しで上書きされるため、末尾を結果へ写す。末尾は既存の `secret_redact.redact_output` を通す。範囲テストの報告は受け入れ条件に無いため変えない。 + +根拠: Value 4 / Value 8(MVV 版 6) + +### 決定 9: ホストの Docker を共有する環境でも worktree が届くよう、コンテナの宣言があるリポジトリの作業用 worktree をメインディレクトリの下に置く + +cross-refactoring の既定の置き場 `/tmp` はデーモンから見えない。この開発コンテナ(DooD、Docker 29.9.2)で確かめると、`/tmp` の下のディレクトリのバインドは空で、`/work/.ndf-worktrees/volareinc--carul/rf182/work` のデーモンから見たパスのバインドでは `compose.yaml` が見えた(2026-11-06)。起動できても探りの印が読めず、受け入れ条件 4 を満たせない。 + +`worktree_base.worktree_parent` にリポジトリのルートを渡す引数を足し、解決の順を次にする。引数は既定値を持たない必須の引数にする。省略を許すと、片付けの経路だけが一時ディレクトリを見続けても動いて見えるためである。 + +1. `NDF_WORKTREE_BASE`(今のまま) +2. コンテナの宣言があれば `<メインディレクトリ>/.worktrees/.ndf` +3. `<システムの一時ディレクトリ>/ndf-worktrees`(今のまま) + +渡されたルートが worktree やサブディレクトリでも、メインディレクトリへ解いて `container_reach.container_suites` で宣言を読む(解き方は `wt_main_dir` と同じ)。作る経路と片付ける経路で読む宣言を同じにするためである。git のリポジトリへ解けないルートは 3 へ進む。 + +呼び出しは 7 箇所ある。`merged-steps.py` の `cleanup` はメインディレクトリを、`lib/run_state.py` の `review_base` は受け取った `root` を渡す。`cross-review` の `init.py` の 2 箇所・`drive.py` と、`cross-refactoring` の `paths.py`・`setup.py` は、リポジトリ名を求めた今のディレクトリを渡す。cross-review も同じ規則を通し、`/ndf:merged` と片付けが見る置き場を 1 つのリポジトリで 1 つに保つ。 + +compose の定義を置くメインディレクトリは、テスト環境を動かすリポジトリなら実行系から見えている。`.` で始まる語はブランチ名に使えないため、開発 worktree と衝突しない。`wt_dev_worktrees` はこの下を一覧から外す。`NDF_WORKTREE_BASE` を与えている carul の置き場は変わらない。 + +`.worktrees/` を git の無視へ登録するのは `/ndf:worktree` の経路で、cross-review と cross-refactoring はそこを通らない。未登録のまま作ると、メインディレクトリの `git status` に `?? .worktrees/` が出て、`git add -A` が worktree を拾う(一時のリポジトリで確かめた。2026-11-06)。そのため `worktree_base` に登録の関数を置き、2 の置き場を使うときだけ `git worktree add` の前に呼ぶ。`git check-ignore` で登録が無ければ、共通の git ディレクトリの `info/exclude` へ `.worktrees/` を足す。追跡している `.gitignore` は変えない。足した後は `/ndf:worktree` の確かめも通る。登録できなければ worktree を作らずに止まる。`info/exclude` へ足す処理は到達の確認の `_exclude_evidence` と同じ役割のため、`worktree_base` の 1 つの関数へ寄せる。置き場を読むだけの片付けの経路では登録しない。 + +`NDF_WORKTREE_BASE` を利用者に与えさせる形は、carul の引き継ぎ文書が queue のコマンドごとに付ける手順として残しており、人を待たない目的に合わないため採らない。全リポジトリの既定を変える形は、コンテナを使わないリポジトリから、コンテナの作り直しで自動で消える利点を除くため採らない。 + +バインド元のパスをデーモンから見たパスへ直すことは、プロジェクトの compose の定義の持ち物のまま変えない(「配置」の表)。 + +根拠: Value 6 / Value 7(MVV 版 6) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1 | 届かない worktree の `test-run.py scope` が `up` を打ち、テストを走らせ `status: ok`。`items` に `testenv` の `woken` | `up` を呼ばないと落ちる。`testenv` の行を載せないと落ちる | +| 受け入れ条件 2 | 同じ前提の `whole` が同じ結果になり、全体テストの実測に起動の秒が入らない | 用意を予算の後へ動かすと落ちる | +| 受け入れ条件 3、I5 | 既に届く worktree で `up` が 0 回 | 確かめる前に `up` を打つと落ちる | +| 依存の準備完了(決定 2) | `up -d` の後に宣言のサービスだけへ `--wait` が打たれる。`--wait` が 0 以外なら、テストが 0 回で受け入れ条件 5 の止まり方になる | サービスを絞らずに待つと落ちる。`--wait` の失敗を無視すると落ちる | +| 受け入れ条件 4 | detached HEAD の作業ディレクトリで `run_with_timeout` が起こして走らせる | `reach=True` の経路の `env_for` を外すと落ちる | +| 受け入れ条件 4(決定 9) | コンテナの宣言があると `worktree_parent` が `<メインディレクトリ>/.worktrees/.ndf` を返し、`NDF_WORKTREE_BASE` があればそれを返し、宣言が無ければ一時ディレクトリを返す。worktree やサブディレクトリを渡しても同じ値を返す。`wt_dev_worktrees` にこの下が出ない。テストの共通の設定が与える `NDF_WORKTREE_BASE` を消して走らせる | 宣言を見ずに既定を返すと落ちる。渡されたルートの宣言だけを読むと落ちる | +| 置き場の片付け(決定 9) | `NDF_WORKTREE_BASE` を消し、宣言のある fixture の新しい置き場へ detached の `pr<番号>` と `rf<番号>/work` を作る。`merged-steps.py` の `cleanup` と `run_state.sweep_states` がそれぞれ見つけ、`unexpose` と `down` を外す前に打ち、割り当てを解放する | どちらかの呼び出しへルートを渡さないと落ちる | +| 置き場の登録(決定 9) | `.worktrees/` を無視していない fixture で cross-review と cross-refactoring の経路が worktree を作ると、`info/exclude` に登録が入り、`git status --porcelain` に `.worktrees/` が出ない。`.gitignore` は変わらない。登録済みなら足さない。登録できなければ worktree を作らない | 登録を呼ばないと落ちる。登録の失敗を無視すると落ちる | +| 受け入れ条件 4(実物の Docker) | リリース後テストで、`NDF_WORKTREE_BASE` を与えない DooD の開発コンテナから carul の cross-refactoring の着手前のテストを走らせ、テスト環境が起きてテストが走る | 置き場が `/tmp` のままだと「起こしても届かない」で止まる | +| 受け入れ条件 5、I3 | 起動の失敗・上限の超過・空きなし・起動後も届かないの 4 通りで、テストが 0 回、終了コード 2、`summary` にサービス名と理由 | 失敗を無視して走らせると落ちる | +| 受け入れ条件 5、I4 | 1 プロセスで `env_for` を 2 度呼んでも `up` は 1 回 | 失敗を覚えないと落ちる | +| 受け入れ条件 6 | `testenv` の宣言が無いと `up` を打たず、`summary` が宣言を求め、`up` の案内を含まない | 案内の文を残すと落ちる | +| 受け入れ条件 7 | 実行系が無いと終了コード 2 で、`env` も `up` も呼ばない | `wakeable` を実行系の無い場合にも立てると落ちる | +| 受け入れ条件 8、I5 | `container` の宣言が無いと実行系を呼ばず、足す環境変数が空 | 宣言を見ずに確かめると落ちる | +| 受け入れ条件 9、I1 | 一時のリポジトリの detached worktree で `env`・`up`・`compose-env`・`down` が 0。HEAD を動かしても同じ値 | コミットから名前を作ると落ちる | +| 受け入れ条件 10、I2 | 同じコミットの 2 つの worktree の環境名とスロットが別 | ディレクトリ名だけからハッシュ値を作ると落ちる | +| 受け入れ条件 11、I7 | 確認のコマンドの出力が走っている間にログへ出て、打ち切っても残る。標準エラーにバイト数の行が出て、出力の文字列が無い。ログは 0600 | 終わってからまとめて書くと落ちる。出力を標準エラーへ流すと落ちる | +| 受け入れ条件 12 | 4 本のうち 1 本が落ち JUnit に失敗が無いとき、`summary` が「4 本のうち 1 本」、`failed_commands` が 1 件 | 走らせた本数だけを出すと落ちる | +| 受け入れ条件 13 | 全部通るときの `summary` が今と同じ文字列 | 文の形を共通にして変えると落ちる | +| 受け入れ条件 14(決定 7 の答えの形)、I6 | `/ndf:merged` の後片付けで宣言の無い fixture の `down` が `--volumes` 無しで外す前に打たれ、解放の時刻が入る。破棄の失敗でも worktree が外れ、`reason` に環境名。`rf<番号>` の worktree も同じ | `--volumes` を付けると落ちる。外した後に打つと落ちる。失敗で止めると落ちる | +| I8(決定 7) | `testenv.discard_volumes` が `true` の `worktree-testenv.sh down` だけが `compose down --volumes` を打つ。無し・`false`・文字列の `"true"` は `compose down`。人が打つ `--volumes` はどの値でも働く。作業ブランチの宣言だけを `true` にしても消さない | 値の有無だけで判定すると落ちる。worktree の宣言を読むと落ちる | +| 残ったボリュームの知らせ(決定 7) | 残っているボリュームを偽の実行系が返すと、`woken` の行に `reused_volumes` が載り、テストは走る。`volume ls` が失敗しても止まらず項目を載せない。既に届く worktree では `volume ls` が 0 回 | 古いボリュームを見つけて止めると落ちる。届く worktree で実行系を余分に呼ぶと落ちる | +| 残ったボリュームと公開の関門(I9、決定 7) | 行に `golden_tag` が無く、偽の実行系の `volume ls` がボリュームを返すと、`up` はコンテナを起こして `golden_tag` を書かず、`expose` が終了コード 1 で拒否する。`env` の後に打つ `up` と、`up` が割り当てを取る場合の両方で同じになる。`volume ls` が失敗しても書かない。`volume ls -q` が終了コード 0 で名前を出さなければ書き、`public_tag` と一致すれば `expose` が通る。`golden_tag` のある行の `up` は `volume ls` を打たずに上書きする。タグが空なら `volume ls` を打たない。公開が無効なら `down --volumes` の案内を出さない。破棄に失敗した worktree を同じパスで作り直すと `expose` が拒否する | 残っていてもタグを書くと落ちる。割り当てを取った `up` だけを見ると落ちる。`volume ls` の失敗を「無い」と読むと落ちる。`-q` を付けずに打つと落ちる(偽の実行系は `-q` が無ければ見出しの行を返す)。タグが空のプロジェクトで削除を案内すると落ちる。失敗した `down` の後に `golden_tag` が残ると落ちる | +| 実物の Docker の振る舞い(決定 7) | リリース後テストで、テスト環境の `down` の後にボリュームが残り、同じ環境名で起こすと `reused_volumes` に載ることを見る | 宣言が無いのにボリュームが消えると落ちる | +| I6(公開) | 公開中の worktree の後片付けで `unexpose` が `down` より前に打たれ、公開の記録が閉じる。閉じる手段が失敗すると `down` を打たず、スロットを解放せず、worktree が残り、`result` が `kept` で `reason` に環境名・URL・`unexpose` の打ち直し。閉じる手段を直した次の後片付けで外れる。`rf<番号>` の worktree も同じ。公開の無い worktree では閉じる手段を打たない | 破棄の後に閉じると落ちる。閉じられないまま `down` か削除へ進むと落ちる | +| 受け入れ条件 15 | 割り当ての無い worktree の削除で実行系を呼ばない | 割り当てを見ずに `down` を打つと落ちる | +| ステップの上限(決定 3) | コンテナの宣言があるときだけ `timeout` が 300 秒増え、`--limit` は変わらない | 宣言を見ずに足すと落ちる | + +既存のテストのうち、`env_for` が届かないときに必ず `Unreachable` を送ることを固定した行は、起動をフェイクにして新しい振る舞いへ合わせる。CI は DooD で動かないため、実物の Docker での確認はリリース後テストで行う。 + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2567 | 実装する | — | `plugins/ndf/scripts/lib/container_reach.py`、`plugins/ndf/scripts/test-run.py`、`plugins/ndf/scripts/worktree-testenv.sh`、`plugins/ndf/scripts/lib/worktree-registry.sh`、`plugins/ndf/scripts/lib/worktree-branch.sh`、`plugins/ndf/scripts/lib/worktree_base.py`、`plugins/ndf/scripts/lib/README.md`、`plugins/ndf/scripts/release_lib/deploy.py`、`plugins/ndf/scripts/merged-steps.py`、`plugins/ndf/scripts/lib/run_state.py`、`plugins/ndf/scripts/supervise_lib/steps.py`、`plugins/ndf/scripts/project_lib/model.py`、`plugins/ndf/contracts/`、`plugins/ndf/skills/worktree/`、`plugins/ndf/skills/merged/SKILL.md`、`plugins/ndf/skills/cross-refactoring/scripts/`、`plugins/ndf/skills/cross-refactoring/docs/`、`plugins/ndf/skills/cross-review/scripts/`、`plugins/ndf/skills/cross-review/docs/`、`plugins/ndf/skills/cross-refactoring/tests/`、`plugins/ndf/scripts/tests/` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 自動で起こす判断と後片付けの範囲 | 確定した(承認ゲート 1、2026-11-06)。S6 は自動で起こす。S7 はコンテナまでを自動で破棄し、ボリュームは宣言があるときだけ消す(決定 7)。正本は [#2567 の節「設計の承認ゲート 1 での利用者の答え」](https://github.com/devbasex/ai-plugins/issues/2567) | +| 残るボリュームの量 | 自動で消さないため、宣言の無いプロジェクトでは増え続ける。carul の実測は 18.3 GB、ホストの空きは 11.9 GB で、着手 1 件あたり約 390〜780 MB なので、空きは約 15〜30 着手分である(決定 7)。着手の頻度と全プロジェクトの量は測っていない | +| 空きへの手当ての要否(利用者の判断) | 既定でボリュームを残す答えは変えない。そのうえで、ホストの空きが尽きる前に、carul へ `discard_volumes` を宣言するか(carul の側で決める。要求の「含まない」)と、空きが減ったことを NDF が知らせる形を足すかが決まっていない。この設計は知らせを足さない | +| 同じ割り当ての `up` のタグ | 同じ割り当ての 2 度目以降の `up` は、今のまま新しいタグで上書きする。宣言のファイルが変わるとタグだけが変わり、データは前のままになる。この課題で増える経路ではないため変えない(決定 7) | +| 匿名ボリュームを持つプロジェクト | 宣言の無い `down` は匿名を残し、後から消す手がかりも残らない(決定 7)。carul のイメージが宣言する分と、他のプロジェクトの実例は未確認 | +| 導入の確認の遅れの原因 | 打ち切られた実行のログが残っておらず、パイプラインの待ちは記録からの見込みである。受け入れ条件 11 の後、次に遅れたときにログで確定する | +| 全体テストの偽の失敗の原因 | 未確認。受け入れ条件 12 の後、次に起きたときに読める。テスト環境を起こしても再発しうる | +| 起動の上限の妥当性 | 実測 22 個はイメージが作成済みの環境で、ビルドを含む初回の起動は測っていない | +| 実物の Docker での通し | 設計の工程で確かめたのは、バインドの見え方(決定 9)と `--wait` の振る舞い(決定 2)までである。自動で起こす経路の通しは、carul の開発ループを 1 周流して確かめる | +| 依存の準備完了の前提 | 依存が healthcheck を持たないプロジェクトでは、`--wait` は動いた時点で通るため、準備の前にテストが走って落ちることがある。carul は `db` と `cache` が healthcheck を持ち、`condition: service_healthy` で待つ。他のプロジェクトは未確認で、healthcheck の宣言を前提とする | +| compose のバインド元の前提 | DooD でバインド元をデーモンから見たパスへ直さないプロジェクトは、起こしても届かないで止まる。carul 以外の DooD のプロジェクトの実例は未確認 | +| 置き場の移行 | 実行中の cross-refactoring・cross-review の worktree が一時ディレクトリに残っているときに版を上げると、新しい置き場の片付けの対象から外れる。コンテナの作り直しで消えるため手当てはしない。宣言を足した・外したリポジトリの実行中の worktree も同じである。実例は未確認 | +| メインディレクトリで走るテスト | 宣言があるリポジトリでメインディレクトリのテストが届かないとテスト環境が起き、後片付けの対象にならないため 1 スロットが残る。プランのテストは worktree で走る前提で、実例は未確認 | +| CI に任せる全体テストのステップ | `--limit` の印が無いため `timeout` へ起動の上限を足さない。手元の静的解析の前に起こしたときは、ステップの `timeout` で打ち切られることがある。実例は未確認 | +| cross-refactoring の時間の見積り | 起動の秒は想定最大時間の中で消費する。着手前のテストの最初の 1 回で起きるため、見積りの式は変えない | +| 消えた worktree の割り当て | carul で未解放の 40 行のうち 30 行は worktree が無い。#2660 に起票した。これらの行は `down` を通らないため `golden_tag` が残り、同じパスで作り直すと古いデータのまま照合が通る経路が残る(決定 7 が塞ぐのは破棄に失敗した経路だけ) | diff --git a/issues/issue-2567-requirements.md b/issues/issue-2567-requirements.md new file mode 100644 index 000000000..77acde69f --- /dev/null +++ b/issues/issue-2567-requirements.md @@ -0,0 +1,222 @@ +# #2567: 開発ループのテストのステップが、作業場所のテスト用コンテナが止まっているのを確かめずに走り、偽の失敗か出力なしの遅れで止まる + +正は課題の本文(#2567)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何を見つけたか + +DooD(ホストの Docker を共有するコンテナ)で動かすプロジェクトでは、実装・検査・昇格のプランが作る作業場所のテスト用コンテナ(compose の api)が起動していないか、途中で止まる。テストのステップはこれを確かめずに走り、次の形で止まる。 + +- 全体テストが「4 本落ちた」と報告するが、JUnit に落ちたテストが無く、判定の worker が原因を見分けられずに stop する +- 開発のビルドの確認(`auto.verify`)が出力を一度も出さないまま履歴の最長の 7 倍を超え、終了コード 125 で止まる +- リファクタリング(cross-refactoring)の作業場所は detached HEAD のため、`worktree-testenv.sh up` が「ブランチを取れません」で断る + +いずれも人が `worktree-testenv.sh up` を打ち、プランを `--from` で流し直すと通った。 + +## どこで見つけたか + +開発対象リポジトリ(volareinc/carul)の開発ループ l20261104-ux の周回 2(スプリント l20261104-ux-2・l20261104-ux-3 の課題)。 + +| プラン | ステップ | 時刻(UTC) | 結果 | +| --- | --- | --- | --- | +| 検査(4-check) | test-all | 2026-11-05 04:35 | 判定が stop(落ちたテストが JUnit に無い) | +| 昇格(5-promote) | verify | 2026-11-05 | 終了コード 125(出力なしの遅れ) | + +同じ事象は 2026-11-04 の開発ループ l20261103 の周回 3・4 でも 3 回起きている(判定の stop)。 + +## なぜこの変更の範囲外なのか + +スプリントの課題(検証環境の認証・導入の確認・権限のモデル)の受け入れ条件に、プランのテスト環境の起動は含まれない。プランの手順は NDF が持ち、開発対象リポジトリでは直せない。根拠: Value 5(MVV 版 8) + +## 直さないと何が起きるか + +DooD のプロジェクトで開発ループを流すたびに、テストのステップが止まり、人がテスト環境を起こして流し直すまで周回が進まない。偽の失敗を判定の worker が読むため、そのつど判定の費用もかかる。 + +## 由来 + +PR #143 + + +## 根本原因の束(2026-11-06) + +carul から届いた課題を根本原因で束ねた(利用者の指示「ひとつづつ直すのではなく根本原因を見つけて対処してください」)。この課題は #2624(束 D) に入る。1 件ずつ直さず、束の根本原因を直した後に、この課題の再現の手順を 1 回通して閉じるか残すかを決める。 + +## 依頼(原文) + +> ひとつづつ直すのではなく根本原因を見つけて対処してください + +(書き手: 利用者、日付: 2026-11-06。上の「根本原因の束」の節の引用。carul から届いた課題全体への指示) + +> DooD(ホストの Docker を共有するコンテナ)で動かすプロジェクトでは、実装・検査・昇格のプランが作る作業場所のテスト用コンテナ(compose の api)が起動していないか、途中で止まる。テストのステップはこれを確かめずに走り、次の形で止まる。 + +(書き手: 起票者、日付: 2026-11-05。開発ループの記録から書いた本文で、利用者の指示の原文ではない) + +## 調べて分かったこと + +carul の周回 2 の記録(`/work/carul/.ndf/loop/l20261104-ux/lap-2/sprint/`)と、このリポジトリのコードを突き合わせた。本文の 3 つの止まり方は、原因が同じではない。 + +| 止まり方 | 記録にあるもの | 分かったこと | +| --- | --- | --- | +| 範囲の静的解析・範囲テスト | `3-impl-114-state/03-lint-limited.out` の「サービス api のコンテナが動いていない(プロジェクト 既定)。worktree-testenv.sh up … で起こす」 | 到達の確認(`lib/container_reach.py:92-143`)がコンテナの停止を見つけ、テストを走らせずに止まっている。**確かめてはいるが、起こさない。** #1337 の設計で「起動に数分かかることがある」を理由に自動で起こさないと決めた形である(`plugins/ndf/skills/worktree/references/test-execution.md` の「テスト環境は自動で起こさない」) | +| 全体テスト | `4-check-state/02-test-all.out` の「全体テスト 4 本(74.6 秒)が落ちた … JUnit に落ちたテストが無い」。流し直しの `01-test-all.out` は 74.3 秒で通った | **コンテナの停止が原因とは確かめられていない。** 到達の確認は通っている(通らなければ「届かない」の文面になる)。所要も通った実行とほぼ同じである。「コンテナが止まった」は判定の worker の見込み(`03-judge.out` の「可能性もある」)にとどまる。「4 本」は走らせた本数で、落ちた本数ではない(`test-run.py:379`)。どのコマンドが落ちたかは結果に載らず、ログも流し直しで上書きされるため、原因は未確認である | +| 導入の確認(`auto.verify`) | `5-promote-state/01-verify.out` の「履歴の最長 83 秒の 7 倍を超えて出力が一度も出ていない」。終了コード 125 | **テスト環境の停止が原因である根拠は見つからず、別の原因を示す記録がある。** `scripts/verify-dev.sh` は `docker compose run` で新しいコンテナを作って走り、動いている api を使わない(既定のプロジェクトに api のコンテナは無い: `docker ps -a --filter label=com.docker.compose.project=carul`、2026-11-06)。打ち切りは 4 回で、すべて確認のスクリプトが検証環境のパイプラインの完了を待つようになった 2026-11-05 02:01 UTC(carul の e76f9c8)より後に起き、すべて develop へのマージの 12〜32 秒後に始まった実行である(04:59:49 / 06:32 / 06:49:22 / 12:15 UTC。マージは 25043c3・ec33cbc・914c4fa・ea31ebb)。12:24 に打ち切られた実行を 12:28 に流し直すと 93 秒で通った。確認のスクリプトの検証環境の照合は既定で 3,600 秒まで待つ。`release_lib/deploy.py:150` はコマンドの出力を終わるまで受け取って保持するため、この待ちの出力が見えず、打ち切られるとログも書かれない。打ち切られた実行のログが無いため、原因の確定は未確認である | +| リファクタリングの作業場所 | 周回の記録には見つからなかった(`lap-*/sprint/*.log` と `*-state/*.out` を「ブランチを取れません」で検索して 0 件)。手元で再現した | detached HEAD の worktree で `worktree-testenv.sh env` と `up` が終了コード 1 で断る(2026-11-06、一時のリポジトリで実行)。原因は環境名をブランチ名から作ること(`worktree-testenv.sh:166-168`) | + +NDF の中でテスト環境を止める・破棄する処理の呼び出し元は無い。`plugins/ndf/scripts/merged_lib/`・`merged-steps.py`・`skills/merged/SKILL.md` を `testenv` で検索して 0 件、`worktree-testenv.sh` の `reap`・`down` を呼ぶスクリプトも 0 件だった。手順書(`skills/worktree/SKILL.md`)が人に打たせる形である。carul の worktree レジストリには割り当てが 44 行ある。 + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件と、要求を書くときに選んだ条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | テストのステップ(test-limited・test-all・refactor の init)の前に、作業場所のテスト環境が動いているかを確かめ、止まっていれば起こす。起こす手段をステップとして置くかは決めない | 本文の表の止まり方と、人が打って通った手当て | 受け入れ条件 1〜4 | +| S2 | detached HEAD の作業場所でもテスト環境を起こせるようにする | リファクタリングの作業場所での断り | 受け入れ条件 9・10 | +| S3 | 題の「確かめずに走り」 | 題 | 採らない。到達の確認は走っている。「確かめて止まり、起こさない」へ読み替える(「調べて分かったこと」) | +| S4 | 全体テストの偽の失敗の原因はコンテナの停止である | 本文の冒頭と判定の worker の見込み | 前提 2 | +| S5 | 導入の確認の出力なしの遅れの原因はコンテナの停止である | 本文の冒頭と遅れの調査の見込み | 採らない。導入の確認の前にテスト環境を用意しない(前提 3) | +| S6 | テスト環境を自動で起こす形へ改める(#1337 で見送った判断を変える)。人が打つ手間と判定の費用が、起動の待ちより大きいため | 本文の「直さないと何が起きるか」と #2624 の「テストのステップの前に宣言のコンテナを起こす」 | 受け入れ条件 1〜5 | +| S7 | NDF が起こすようになると持ち主が要るため、worktree の削除でテスト環境も破棄する。自動で消すのはコンテナだけで、ボリュームは残す(2026-11-06 の利用者の答え。前提 4) | 「調べて分かったこと」の呼び出し元 0 件。本文には無く、要求を書くときに足した | 受け入れ条件 14 | +| S8 | 全体テストの失敗の報告を、落ちたコマンドが分かる形へ直す。コンテナを起こすだけでは「4 本落ちた」の形が残るため | 「調べて分かったこと」の全体テストの行 | 受け入れ条件 12 | + +## 目的 + +宣言でコンテナを使うプロジェクトの開発ループが、テスト環境の用意のために人を待たず、テストのステップが止まったときは、原因が読める結果を返す状態にする。 + +## 前提 + +- 前提 1: 自動で起こす対象は、`.ndf/project.json` の `test.suites[].container` を宣言したサービスだけである。コマンドの語からは判定しない(#1337 と同じ) +- 前提 2: 全体テストの「JUnit に落ちたテストが無い」の原因は未確認である。この課題は原因を決めずに、次に起きたときに原因が読める結果を返す(受け入れ条件 12) +- 前提 3: 導入の確認の遅れは、テスト環境の停止ではなく、確認のコマンドの中の長い待ちの出力が見えないことで起きた見込みが高い(「調べて分かったこと」の導入の確認の行)。この課題は出力を走っている間に見えるようにする(受け入れ条件 11)。導入の確認の前にテスト環境を用意することは行わない。確認のコマンドが要るコンテナは、コマンド自身が起こす +- 前提 4: テスト環境のボリュームは作り直せるとは確かめられていないため、戻せない削除を既定にしない。消すのは `.ndf/worktree.json` の `testenv.discard_volumes` が `true` のときだけである(2026-11-06 の利用者の答え) + +## 対象範囲 + +含む: +- 範囲テスト・範囲の静的解析・全体テスト(`test-run.py`)の前のテスト環境の用意 +- cross-refactoring の着手前のテストと以後のテストの前のテスト環境の用意 +- 導入の確認(`verify-facts`・`deploy-facts`)の、走っている間の出力のログへの書き出し +- detached HEAD の worktree でのテスト環境の採番と起動 +- 全体テストの失敗の報告(落ちたコマンドが分かる形) +- worktree の削除に合わせたテスト環境の破棄 +- テスト環境を自動で起こさないと書いた手順書の更新 + +含まない: +- 導入の確認の前のテスト環境の用意(前提 3) +- 使われていないテスト環境を定期的に止める処理(`reap` の呼び出し)。削除までの間は動いたままにする +- 同時に動かせるテスト環境の数の制御。worktree レジストリの 0〜63 の上限のまま +- `worktree-testenv.sh test` の経路。人が打つコマンドで、届かなければ今のまま止まる +- 着手前の HEAD の一時の worktree。今のまま見分けられないとして扱う +- carul の `scripts/verify-dev.sh` と宣言の変更 +- `testenv` を宣言していないリポジトリ向けに NDF が compose を直接起動すること +- #2624 の残りの項目 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | テストのステップが始まった | プランの run のステップ・cross-refactoring の着手前のテスト | — | — | +| E2 | テスト環境へ届かないと分かった | 到達の確認 | 実行系が無ければ終了コード 2 で止まる(受け入れ条件 7) | E1 | +| E3 | テスト環境の割り当てを取った | E2 で宣言の `testenv` がある | スロットが無ければ止まる(受け入れ条件 5)。宣言が無ければ起こさず止まる(受け入れ条件 6) | E2 | +| E4 | テスト環境を起こした | E3 | 起動の失敗か上限で止まる(受け入れ条件 5) | E3 | +| E5 | 到達を確かめ直した | E4 | 届かなければテストを走らせず止まる(受け入れ条件 5) | E4 | +| E6 | テストを走らせた | E2 で届いた、か E5 | 全体テストは落ちたコマンドを報告する(受け入れ条件 12) | E1 | +| E7 | 導入の確認の出力をログへ書いた | 確認のコマンドが出力した | 打ち切られても書いた分が残る(受け入れ条件 11) | — | +| E8 | テスト環境を破棄した | worktree の削除 | 破棄が失敗しても削除は続け、残った環境名を報告する(受け入れ条件 14) | E4 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| テスト環境 | 1 つの worktree に対応して立ち上げる、テスト実行に必要なサービスの一式。番号で識別され、他の worktree とポート・データ保存先・ネットワークを共有しない | +| テスト環境の用意 | 到達の確認が通らないときに、テスト環境を起こして到達を確かめ直すこと | +| 到達の確認 | コンテナの中から worktree の探りの印を読み、テストのコンテナが worktree を見ているかを確かめること | +| 導入の確認 | `.ndf/pace.json` の `<節>.verify` に書かれたコマンドを、ベースブランチの先頭で走らせること | +| 全体テスト | リポジトリ全体を範囲にするテストか静的解析 | + +## 受け入れ条件 + +テストの前の用意: + +- [ ] 1. 前提: `test.suites[].container` と `testenv` を宣言し、割り当ても動いているコンテナも無い worktree がある + 操作: `test-run.py scope` を打つ + 結果: テスト環境が起動し、到達の確認が通り、宣言のテストが走って `status: ok` で終わる。結果の JSON にテスト環境を起こしたことが載る +- [ ] 2. 同じ前提で `test-run.py whole` を打つと、受け入れ条件 1 と同じ結果になる +- [ ] 3. 既に届く worktree でテストを打っても、起動のコマンド(`docker compose up`)は 1 回も打たれない +- [ ] 4. 同じ前提で cross-refactoring の着手前のテストを走らせると、テスト環境が起動して走り、止まらない +- [ ] 5. 起動の失敗・起動の上限の超過・空きスロットなし・起動後も worktree へ届かない(メインディレクトリを見ているなど)のどれでも、テストを走らせず、終了コード 2 で止まり、`summary` にサービス名と理由が載る。1 回の起動で起こすのは 1 度だけである +- [ ] 6. `container` はあるが `testenv` の宣言が無いリポジトリでは、起こさず終了コード 2 で止まり、`summary` が `.ndf/worktree.json` の `testenv` の宣言を求める(人に `up` を打たせる案内を出さない。この宣言では `up` は何もしない) +- [ ] 7. コンテナの実行系が無いときは、今と同じく終了コード 2 で止まる(退行しない) + 前提の確認: `plugins/ndf/scripts/test-run.py:426`・`lib/container_reach.py:100` の読み取り。実行では未確認で、実装の前にテストで固定する +- [ ] 8. `container` の宣言が無いリポジトリでは、コンテナの実行系を 1 回も呼ばず、足す環境も空のままである(退行しない) + 前提の確認: このリポジトリで `container_reach.container_suites('.')` → `[]`、`env_for('.')` → `{}`(2026-11-06) + +detached HEAD: + +- [ ] 9. detached HEAD の worktree で `worktree-testenv.sh env`・`up`・`compose-env`・`down` が終了コード 0 で終わる。その worktree の HEAD を別のコミットへ動かしても、同じ環境名とスロットが返る + 前提の確認: 変更前は `env`・`up` が終了コード 1 と「ブランチを取れません」で始まる断り(2026-11-06、一時のリポジトリで実行) +- [ ] 10. 同じコミットを detached で開いた 2 つの worktree には、別の環境名と別のスロットが割り当たる + +導入の確認: + +- [ ] 11. 確認のコマンドが走っている間、出力が所有者だけが読めるログへ順に書かれる。遅れの監視が打ち切っても、それまでの出力が残り、遅れの調査は出力の有無を判定できる + +全体テストの報告: + +- [ ] 12. 4 本のコマンドのうち 1 本だけが 0 以外で終わり JUnit に落ちたテストが無いとき、`test-run.py whole` の結果に、落ちた本数(1)・走らせた本数(4)・落ちたコマンド・終了コード・ログの末尾が載る。`summary` は 4 本が落ちたと読める形にならない +- [ ] 13. 全部のコマンドが通るときの `summary`(`全体テスト 4 本(… 秒 / 戦略 …)`)は変わらない(退行しない) + 前提の確認: `4-check-state/01-test-all.out` の出力(carul、10.27.18) + +後片付け: + +- [ ] 14. テスト環境の割り当てがある worktree を `/ndf:merged` の後片付けで消すと、その worktree のコンテナが残らず、割り当てに解放の時刻が入る。ボリュームは既定で残し、`testenv.discard_volumes` が `true` のときだけ消す。破棄が失敗したときは worktree の削除を続け、残った環境名を結果に載せる +- [ ] 15. 割り当ての無い worktree の削除では、コンテナの実行系を呼ばない(退行しない) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 可用性 | 起動は上限つきの 1 回で終わる。上限を超えたら受け入れ条件 5 で止まり、待ち続けない | +| 性能・拡張性 | 既に届く worktree では、追加の処理は今の到達の確認 1 回だけである | +| 運用・保守性 | 起こしたこと・起こせなかった理由が結果の JSON に残り、判定の worker と人が同じ材料を読める | +| 移行性 | 導入先の宣言の変更は要らない。`testenv` と `container` を宣言済みのリポジトリは、版を上げるだけで自動で起きるようになる | +| セキュリティ | 確認のコマンドの出力は今と同じく所有者だけが読める権限で書く(出力に秘密が含まれうる)。結果の JSON とログの末尾へ載せるのは全体テストの出力だけで、導入の確認の出力は載せない | +| システム環境 | ホストの Docker を共有するコンテナの中と、ホストの直下の両方で動く。`WT_DOCKER_COMMAND` の差し替えを守る | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | `worktree-testenv.sh` は detached HEAD で 1 ではなく 0 を返す。`test-run.py` の結果 JSON は項目が増える。どちらも互換あり | +| データ | worktree レジストリの形は変えない。detached HEAD の worktree の行は `branch` が空になる | +| 既存の振る舞い | テスト環境を自動で起こす。動くコンテナの数が増え、worktree の削除まで残る | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf -q -n 4`。コンテナの実行系は `WT_DOCKER_COMMAND` の差し替えで偽物を使う | +| 静的解析・型検査 | リポジトリの CI の既定のジョブ(`python3 scripts/check-skill-frontmatter.py` と `claude plugin validate .`) | +| 手動確認 | carul の開発ループを 1 周流し、人が `worktree-testenv.sh up` を打たずに実装・検査・昇格のプランが通ることを見る。リリース後テストで確かめ、通ったらこの課題を閉じるか残すかを決める | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | テスト環境の割り当ての持ち主は `worktree-testenv.sh`。起動の判断は到達の確認の層へ寄せ、経路ごとに複製しない(MVV Value 7) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストを書かない | +| テスト戦略 | 到達の確認と用意は偽の実行系で単体テスト、`worktree-testenv.sh` は一時のリポジトリの detached worktree で実行。実物の Docker での確認はリリース後テスト | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、手順書の「自動で起こさない」の更新 | +| 確認してから行う | 公開インタフェースの変更(承認ゲート 1・2 で見る) | +| 行わない | 定義に無いコンテナを削除する指定(`--remove-orphans`)。導入先のリポジトリの変更。メインディレクトリのローカル環境の停止 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 全体テストの偽の失敗の原因(前提 2) | 受け入れ条件 12 の後、次に起きたときに起票する | リリース後 | +| S6・S7(自動で起こす・ボリュームも破棄する)を採るか | 利用者 | 決まった(2026-11-06): 自動で起こす。自動で消すのはコンテナだけで、ボリュームは宣言があるときだけ消す | diff --git a/issues/issue-2604-design.md b/issues/issue-2604-design.md deleted file mode 100644 index b700b8d1d..000000000 --- a/issues/issue-2604-design.md +++ /dev/null @@ -1,262 +0,0 @@ -# 開発ループの選定: 閉じていない前提の課題を持つ課題を外し、理由を選定の行に残す(#2604) - -## 目的 - -- **今どうなっているか**: 開発ループの選定は順位だけで課題を選ぶ。前提の課題がまだ実装されていない課題を選んでしまい、実装のステップで失敗する。lroot3 の周回 13 では #2344 を選び、前提の #2337・#2339・#2341・#2342 が develop に無いため失敗した。その後は人が手で `loop exclude` している -- **誰に効くか**: 開発ループを回す利用者と、ループの周回を進める AI -- **変えると何が成り立つか**: 前提の課題が閉じるまで、依存する課題は選ばれない。選ばない理由(`前提の課題の実装待ち(#番号)`)が選定の行に残り、人が手で外さなくて済む - -## 適用範囲 - -- **働く範囲**: NDF を使うすべてのリポジトリの開発ループ(`sprint-state.py loop select` / `loop exclude`)と、棚卸しの順位の算出(`upkeep.py rank`) -- **プロジェクトごとに違うもの**: 無し(依存の取り元は棚卸しの見積もりの記録と、マイルストーンの説明の並列の組で、どのリポジトリでも同じ形である) -- **当たるモード**: `standard` - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| lroot3 の周回 13 で #2344 を選び、前提の課題の実装が無いため失敗した | 実測(課題 #2604 の本文) | 順位だけで選ぶと、前提の無い実装が起きる | -| STD1(GitHub の課題の依存) | 外部の一次情報 | 依存は「ほかの作業の完了を待つ」状態を表し、ブロックされた課題を一覧で区別して示す。待つ課題を着手の候補から区別する形が標準である | -| `grep -rln "depends_on\|cross_milestone_deps" plugins/ndf/scripts/lib plugins/ndf/scripts/*.py` が 0 件 | 実測(2026-11-05) | スプリントの側は依存を読まず、同じ周回に入った課題の実装の順序を依存で決めない。前提の課題と同じ周回に選んでも、前提が先に入る保証が無い | - -要求と受け入れ条件は #2604 の本文にある(コピーは `issues/issue-2604-requirements.md`)。この文書は「どう作るか」だけを扱う。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| 課題の棚卸し(`ndf-issue-upkeep`) | 見積もりの記録・依存の辺・順位・切り出しの境界 | -| 開発の工程(`ndf-workflow`) | 開発ループ・周回・選定・前提の課題・落選の理由 | - -棚卸しが供給者、開発ループの選定が顧客の関係(顧客 / 供給者)。選定は順位の算出の出力(JSON)だけを読み、依存の辺を自分で合成しない。供給者の側が、選定の要求する「前提の課題」を行ごとの項目として出す。 - -### 集約 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 順位の算出の結果 | 順位の算出(`upkeep_rank.py` / `upkeep_rank_cmd.py`) | 算出の結果 | マイルストーンの順位の行 | 前提の課題の番号の並び | -| ループの記録 | 開発ループ(`sprint_loop_*.py`) | ループの記録 | 周回 | 選定の行(番号・採否・理由) | - -ループの記録は順位の算出の結果を課題の番号だけで参照する。選定は順位の算出の結果を書き換えない。 - -### 不変条件 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | 順位の算出の結果 | 行の前提の課題は、合成後の依存の辺のうち、見積もりの記録か並列の組が宣言した辺の先にある open の課題だけである。サブイシューの辺だけの先は入れない | テストが落ちる(受け入れ条件 8) | -| I2 | 順位の算出の結果 | 前提の課題が無い行は、前提の課題の項目を持たない(出力と digest が今と同じ) | テストが落ちる(受け入れ条件 9) | -| I3 | ループの記録 | 前提の課題を持つ行は、どの選定でも採られない。落選の理由は、それより先に判定する理由(人の判断待ち・保留の周回・設計の答え待ち・差し戻し・マージ待ち・手の除外。決定 5 の順)が無いときに限り `前提の課題の実装待ち(#番号 …)` で、open の前提の課題の番号をすべて昇順で載せる。先に判定する理由があれば、その理由を残す | テストが落ちる(受け入れ条件 1・2・4・5・7、テスト設計の「I3(先に判定する理由を残す)」の行) | -| I4 | ループの記録 | 境界が無いマイルストーンで、前提の課題を持つ行は `max_issues` の枠を使わない | テストが落ちる(受け入れ条件 6) | - -### ドメインイベント - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 棚卸しが課題の依存を見積もりの記録へ書いた | 棚卸しの手順 2A・2B | 順位の算出 | -| E2 | 順位の算出が依存の辺を合成し、行ごとの前提の課題を出した | `upkeep.py rank` | 開発ループの選定 | -| E3 | 選定が前提の課題の閉じていない課題を外した | `loop select` / `loop exclude` | ループの記録(選定の行) | -| E4 | 前提の課題が閉じた | スプリントの締め(`sprint-close.py`) | 次の順位の算出(閉じた課題への辺が消える) | -| E5 | 次の選定が、外していた課題を選べるようにした | 次の `loop select` | ループの記録 | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 前提の課題 | ある課題が依存する、別の open の課題。棚卸しが記録した依存と並列の組の依存から順位の算出が合成した辺の先にある。閉じるまで、依存する課題は選定で選ばれない | 追加(`ndf-workflow`。要求の工程で追加済み) | -| 選定 | 開発ループの周回で、実装する課題を棚卸しの順位と記録から選ぶ処理 | 追加(`ndf-workflow`。要求の工程で追加済み) | - -## 外部の調査 - -| 種類 | 調べるか | 理由 | -| --- | --- | --- | -| 標準・仕様 | 調べる | 課題管理での「待つ」依存の表し方と、ブロックが解ける時点の扱いに事実上の形がある | -| 主要な製品 | 調べる | 課題管理の製品が、ブロックの関係と親子の関係を分けているかを比べる | -| ライブラリとサービス | 調べる | 依存の判定に、Python の標準ライブラリ(`graphlib`)か GitHub の課題の依存の API を使えるかを決める | - -### 標準・仕様 - -| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | -| --- | --- | --- | --- | --- | -| STD1 | GitHub の課題の依存(blocked by / blocking) | ほかの作業の完了を待つ課題を「blocked by」で示し、一覧とボードでブロックされた課題に印を付ける。ブロックしている課題が閉じたときの扱いの記述は無い | 待つ課題を候補から区別する。解ける時点は「前提の課題が閉じたとき」とする(要求の前提 2) | https://docs.github.com/en/issues/tracking-your-work-with-issues/using-issues/creating-issue-dependencies (2026-11-05) | - -### 主要な製品 - -| # | 製品 | どう解いているか | 設計への反映 | 出典 | -| --- | --- | --- | --- | --- | -| PRD1 | Jira の課題リンク | リンクの種類にブロックの関係(is blocked by / blocks)と関連(relates to)などを分けて持つ。親子は課題リンクの種類に含まれない | ブロックの関係と親子(サブイシュー)を分け、親子の辺は選定から外す理由にしない(要求の前提 4) | https://support.atlassian.com/jira-software-cloud/docs/link-issues/ (2026-11-05) | - -### ライブラリとサービス - -| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| LIB1 | Python 標準ライブラリ `graphlib` | Python 3.13.17 に同梱 | PSF | Python の版と同じ | 無し | 満たさない。並べ替えはできるが、要るのは「辺の先が open か」の 1 段の判定で、並べ替えは順位の算出が既に持つ | `python3 -c "import graphlib,sys;print(sys.version)"`(2026-11-05) | -| LIB2 | GitHub の課題の依存の REST API(`issues/{番号}/dependencies/blocked_by`) | REST API(2026-11-05 時点) | GitHub の利用規約 | — | 無し | 満たさない。#2344 で `[]` を返し、このリポジトリの依存は API に登録されていない。取り元を足すと順位の並びも変わり、要求の範囲(選定以外の判定を変えない)を超える | `gh api repos/devbasex/ai-plugins/issues/2344/dependencies/blocked_by`(2026-11-05) | -| LIB3 | 既存の `merge_edges`(`upkeep_rank.py`) | このリポジトリの現行 | このリポジトリ | — | 無し | 満たす。open の課題どうしの辺だけを残すため、前提の課題が閉じると辺が消える | `plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank.py`(2026-11-05) | - -### 確かめられなかったこと - -| 対象 | 試した手段 | 結果と理由 | -| --- | --- | --- | -| Jira の「is blocked by」の意味の定義 | PRD1 の URL | 取得できたが、意味を定義した文が無かった。設計はリンクの種類が分かれていることだけを使う | - -調べた量: 候補 5 件・取得の試み 4 回。 - -## 機能一覧 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 順位の算出が、行ごとに open の前提の課題を出す | 開発ループの選定 | -| F2 | 選定が、前提の課題を持つ課題を外し、理由を選定の行に残す | 開発ループを回す利用者と AI | -| F3 | 境界が無いマイルストーンで、前提で外れた課題が件数の枠を使わない | 開発ループを回す利用者と AI | - -## 構成要素 - -| 要素 | 責務 | -| --- | --- | -| 辺の合成(`upkeep_rank_cmd.py` の `_build_board`) | 今の合成に加えて、見積もりの記録と並列の組が宣言した辺のうち、合成後に残った辺の集合(前提の辺)を算出の入力(`upkeep_rank.py` の `Board` に足す項目 `prerequisites`)へ渡す | -| 行の出力(`upkeep_rank.py` の `_milestone_out`) | 行の課題から出る前提の辺の先を昇順で `prerequisites` に載せる。空なら項目を出さない | -| 選定の候補(`sprint_loop_select.py` の `candidates_of`) | 境界が無いとき、`prerequisites` を持つ行を `max_issues` の枠から除く | -| 落選の理由(`sprint_loop_select.py` の `exclusion_reason`) | `prerequisites` を持つ行に `前提の課題の実装待ち(#番号 …)` を返す | - -```mermaid -graph TD - subgraph 棚卸し - EST[見積もりの記録の depends_on] - PAR[マイルストーンの説明の並列の組] - SUB[サブイシュー] - MERGE[辺の合成 _build_board] - OUT[行の出力 _milestone_out] - end - subgraph 開発ループ - CAND[選定の候補 candidates_of] - WHY[落選の理由 exclusion_reason] - REC[ループの記録の選定の行] - end - EST --> MERGE - PAR --> MERGE - SUB --> MERGE - MERGE -->|前提の辺| OUT - OUT -->|rows の prerequisites| CAND - CAND --> WHY - WHY --> REC -``` - -## 入出力の契約 - -| 項目 | 内容 | -| --- | --- | -| 名前 | `upkeep.py rank` の出力(`metrics.milestones[].rows[]`) | -| 入力 | 変わらない | -| 出力 | 行に `prerequisites`(整数の配列、昇順、重複なし)を足す。その行の課題が依存する open の前提の課題の番号。前提の課題が無い行は項目を持たない | -| 失敗の形 | 変わらない(循環は今と同じく `stopped` と終了コード 2 で止まり、選定を推さない) | -| 互換性 | 既存の項目は変えない。前提の課題を持つ行がある算出だけ `digest` の値が変わる。表の描画(`render_rank_table`)は列を名前で読むため影響しない | - -| 項目 | 内容 | -| --- | --- | -| 名前 | `sprint-state.py loop select` / `loop exclude` の結果の選定の行 | -| 出力 | 落選の理由に `前提の課題の実装待ち(#B)`、複数なら `前提の課題の実装待ち(#B #C)`(番号の昇順、半角スペース区切り)が加わる | -| 互換性 | 選定の行の形(番号・マイルストーン・順位・採否・理由)は変えない。候補がすべて外れたときの `outside_mvv` の `values.reasons` は、理由の文字列ごとの件数として今と同じ形で数える | - -## 処理の流れ - -```mermaid -sequenceDiagram - participant L as loop select - participant R as upkeep.py rank - participant S as 選定 choose_issues - L->>R: 順位の算出を打つ - R->>R: 辺を合成し、open の課題どうしの辺だけを残す - R->>R: 見積もりの記録と並列の組の辺を前提の辺として取り出す - R-->>L: rows に prerequisites を載せて返す - L->>S: 順位の算出の出力を渡す - S->>S: candidates_of: 境界が無ければ prerequisites のある行を枠から除く - S->>S: exclusion_reason: prerequisites があれば前提の課題の実装待ち - S-->>L: 選定の行(落選の理由つき) -``` - -落選の理由(`exclusion_reason`)は、次の順に判定する。 - -1. 人の判断待ち(棚卸し) -2. 保留の周回 -3. 設計の答え待ち・差し戻し・マージ待ち -4. 手の除外(`loop exclude`) -5. **前提の課題の実装待ち** -6. 周回の中で起票 -7. 容量(尺度を超える) - -## 決定の記録 - -### 決定 1: 選定が依存の合成を写さないよう、前提の課題を順位の算出の行ごとの項目として出す - -選定は順位の算出の出力だけを読む(顧客 / 供給者)。行ごとに番号の並びを持たせると、選定は行を見るだけで外す理由と枠の扱いを決められ、辺の一覧を引き直す処理を持たなくて済む。前提の課題が無い行は項目を出さないため、依存の記録が無いリポジトリの出力と digest は変わらない。 - -マイルストーンの外に辺の一覧を足す形は採らない。選定の側で辺を行へ引き直す処理が要り、同じ役割の処理が 2 か所に分かれる。 - -- 作り方: 自前で作る -- 比べた候補: LIB1 / LIB2 / LIB3 / STD1 - -根拠: Value 7 / Value 5(MVV 版 5) - -### 決定 2: 親子の関係で親も子も選べなくならないよう、前提の辺は合成後に残った辺のうち見積もりの記録と並列の組が宣言した辺に限る - -合成(`merge_edges`)はサブイシューの辺を最も優先し、逆向きの下位の辺を捨てる。合成の結果の集合と、見積もりの記録と並列の組の辺の和集合との共通部分を前提の辺にすると、並び順と前提の判定が同じ合成の結果に揃い、サブイシューの辺だけの関係は前提にならない。見積もりの記録が同じ向きの辺を明示したときは、サブイシューの辺と重なっても前提に数える。 - -合成の前に取り元ごとの辺を別に持つ形は採らない。合成で捨てられた逆向きの辺が前提に残り、並び順と食い違う。 - -根拠: Value 7(MVV 版 5) - -### 決定 3: 同じ周回でも前提の無い実装をしないよう、前提の課題が同じ周回に選ばれていても依存する課題を外す - -スプリントの側は依存で実装の順序を決めない(あるべき姿の根拠の 3 行目)。前提の課題と同じ周回に入れると、前提が develop に入る前に依存する課題の実装が始まりうる。依存が連なる課題は 1 周回に 1 段ずつ進むが、選ばない期間が延びるだけで、前提の無い実装は起きない。 - -前提の課題が同じ周回に選ばれたら外さない形は採らない。周回の中の実装の順序を保証する仕組みが要り、要求の範囲を超える。 - -根拠: Value 2 / Value 3(MVV 版 5) - -### 決定 4: 新しい止まり方を足さないよう、前提の課題がどのループからも選ばれない場所にあっても人へ別に知らせない - -前提の課題がマイルストーン未設定などで選ばれないとき、依存する課題は外れ続ける。外す理由に前提の課題の番号が載るため、選定の行を読めばどこで止まっているかが分かる。候補がすべて外れれば、今と同じ止まり方(`outside_mvv`)で、理由ごとの件数(`values.reasons`)が出る。知らせる仕組みを足すと、止まる条件と記録の形が増える。 - -前提の課題がマイルストーン未設定のときに専用の止まる条件を足す形は採らない。棚卸しがマイルストーンを付ければ解け、ループが止まる理由にはならない。 - -根拠: Value 3 / Value 5(MVV 版 5) - -### 決定 5: 人の明示した理由を残すよう、前提の課題の実装待ちは手の除外より後、周回の中の起票と容量より前に判定する - -人の判断待ち・保留・設計の答え待ち・手の除外は、人か記録が明示した理由で、外す理由として優先して残す。前提の課題の実装待ちは、周回の中の起票と容量より長く続く理由のため、その前に置いて理由の行が周回ごとに変わらないようにする。この順は前提の課題を持つ行にだけ効き、依存の記録が無い行の理由は今と同じである。 - -根拠: Value 8(MVV 版 5) - -## テスト設計 - -| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | -| --- | --- | --- | -| 受け入れ条件 1・I3 | 1 位の A が `prerequisites: [B]` を持つ順位の出力で選定すると、A が落選し理由が `前提の課題の実装待ち(#B)` | 理由の判定を消す・番号を載せない | -| 受け入れ条件 2・I3 | 前提の課題が 2 件 open なら理由に 2 件が昇順で載る | 先頭の 1 件だけを載せる | -| 受け入れ条件 3 | 見積もりの記録が B を指していても、B が閉じていれば A の行に `prerequisites` が無く、選定で順位どおりに選ばれる | 閉じた課題への辺を残す | -| 受け入れ条件 4・I1 | 並列の組の依存だけで B に依存する A の行に `prerequisites: [B]` が出る | 前提の辺から並列の組を落とす | -| 受け入れ条件 5 | B が別のマイルストーンかマイルストーン未設定でも、A の行に `prerequisites: [B]` が出る | 同じマイルストーンの辺だけに絞る | -| 受け入れ条件 6・I4 | 境界が無く `max_issues` が 1 で、1 位が前提で外れると 2 位が選ばれる | 枠の計算から前提の行を除かない | -| 受け入れ条件 7・I3 | 承認とマージが済んだ設計の課題・設計を直して出し直す課題(人の答えが `revise`。差し戻しの `rejected` とは別で、先に判定する理由を持たない)でも、前提があれば落選し、理由が `前提の課題の実装待ち(#番号)` になる | 先頭に選ぶ課題を理由の判定より先に採る | -| I3(先に判定する理由を残す) | 手の除外(`loop exclude`)をした行と差し戻し(人の答えが `rejected`)の行が `prerequisites` を持つとき、落選の理由が手の除外の理由・`差し戻し` のまま残る | 前提の判定を手の除外・差し戻しより先に置く | -| 受け入れ条件 8・I1 | サブイシューの辺だけで親に依存する子の行に `prerequisites` が出ない | サブイシューの辺を前提の辺に含める | -| 受け入れ条件 9・I2 | 依存の記録が無い順位の出力の行・digest と、選定の結果が今と同じ | 空の `prerequisites` を出す・判定の順を変える | -| 受け入れ条件 10 | 候補がすべて前提で外れると `outside_mvv` で止まり、`values.reasons` に理由ごとの件数が載る | 前提の落選を候補の件数から除いて `no_issue` にする | - -## 設計の結果 - -| 課題 | 扱い | 取り込み先 | 触るファイル | -| --- | --- | --- | --- | -| #2604 | 実装する | — | `plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank.py`、`plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank_cmd.py`、`plugins/ndf/scripts/lib/sprint_loop_select.py`、`plugins/ndf/scripts/tests/test_sprint_loop.py`、`plugins/ndf/skills/backlog-refinement/tests/` | - -## 未確認のまま残ること - -| 項目 | 内容 | -| --- | --- | -| 境界があるマイルストーンでの枠 | 切り出しの境界は順位の算出が決め、この変更は変えない(要求の対象範囲)。境界の内の課題が前提で外れても、境界の外の課題は候補に上がらない。前提の課題を持つ課題を境界から除くかは、境界の判定を変える別の変更になる | -| 計画の digest の食い違い | 前提の課題を持つ行が初めて出る算出で `digest` が変わり、前の digest を持つ計画(`plan.rank`)は古いと判定される。1 回の打ち直しで揃う見込みで、実測していない | -| #2344 の解消 | #2344 の見積もりの記録は依存が空で、この変更だけでは外れない(要求の前提 3)。次の棚卸しで記録に依存を書き足した後、リリース後テストで確かめる | -| 依存の連なりの進み方 | 決定 3 により、依存が連なる課題は 1 周回に 1 段ずつ、しかも前提の課題が閉じる(本番への配布の後)まで進まない。周回の数への影響は測っていない | diff --git a/issues/issue-2604-requirements.md b/issues/issue-2604-requirements.md deleted file mode 100644 index 152b76a9a..000000000 --- a/issues/issue-2604-requirements.md +++ /dev/null @@ -1,132 +0,0 @@ -# #2604: 開発ループの選定が、前提の課題の実装が develop に無い課題を選び、実装のステップで失敗する - -正は課題の本文(#2604)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 - -## 依頼(原文) - -> ## 何が起きたか -> -> #2344(プランは参照を持ち実行の直前に解決する)の設計 PR https://github.com/devbasex/ai-plugins/pull/2482 で、承認ゲート 1 の MVV 判定は「前提の柱 P6(#2339)・P8(#2341)の設計が無く、P3(#2342)も未確定」を理由の 1 つに「判定できない」を返した。利用者が通した後、開発ループ lroot3 の周回 13 で #2344 を実装に選び、前提の柱(#2337・#2339・#2341・#2342)の実装が develop に無いため失敗した。以後の周回は #2344 を手で `loop exclude` している。 -> -> ## 原因 -> -> `loop select` の選定は、課題の前提(依存する課題の実装が develop にあるか)を見ずに順位で選ぶ。 -> -> ## 直す向き -> -> 課題の本文か棚卸しの記録にある依存を読み、前提の課題が閉じるまで選定から外す(理由「前提の課題の実装待ち」)。 -> -> ## 由来 -> -> MVV 版 5 の覆し 11 件の根本原因の切り分け(2026-11-06)。 - -(書き手: 起票者、日付: 2026-11-06 JST(課題の作成は 2026-11-05T19:41:39Z)。MVV の覆しの根本原因を切り分けた AI が起票の記録として書いた本文で、利用者の指示の原文ではない。この課題を進める指示は「人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く」(書き手: AI の起動指示、日付: 2026-11-05)である) - -**解釈**: 開発ループの選定(`loop select`)が、まだ閉じていない別の課題を前提にする課題を選ばないようにする。前提の関係は、棚卸しが見積もりの記録に書いた依存(`depends_on`)と、マイルストーンの説明の並列の組の依存から取る。今の順位の算出(`upkeep.py rank`)はこの依存を並び順にだけ使い、選定は見ていない。 - -## 起票者の推定 - -| # | 条件 | 推した出所 | この要求での扱い | -| --- | --- | --- | --- | -| S1 | 前提が満たされたと見なす時点は「前提の課題が閉じたとき」とする(題と原因は「実装が develop にあるか」と書く) | 本文の「直す向き」 | 受け入れ条件 3・前提 2。課題が閉じるのは本番への配布の後で、develop へのマージより遅い。遅い側で判定すると選ばない期間が延びるだけで、前提の無い実装は起きない(Value 2・Value 3) | -| S2 | 依存は「課題の本文か棚卸しの記録」から読む | 本文の「直す向き」 | 受け入れ条件 1・含まない。本文の文章から番号を拾うのは棚卸しの手順 2A(AI)の担当で、選定のスクリプトは棚卸しが記録した依存だけを読む(Value 5)。本文の自由な文章をスクリプトで解析しない | -| S3 | 外す理由の語は「前提の課題の実装待ち」とする | 本文の「直す向き」 | 受け入れ条件 2 | -| S4 | 手で `loop exclude` している #2344 の除外は、この変更の後は要らなくなる | 本文の「何が起きたか」 | 前提 3。#2344 の見積もりの記録は依存が空(`depends_on: []`)のため、記録を直さないとこの変更だけでは外れない | - -## 目的 - -- 開発ループが、前提の課題の実装がまだ届いていない課題を実装の周回に選ばず、実装のステップで失敗しないようにする -- 前提の関係で外した課題を、人が手で除外(`loop exclude`)しなくても済むようにする - -## 前提 - -- 前提 1: 前提の関係の正本は、順位の算出が合成する依存の辺(見積もりの記録の `depends_on` と、マイルストーンの説明の `### 並列の組(見込み)` の依存)である。順位の算出は open の課題どうしの辺だけを残す(`upkeep_rank.py` の `merge_edges`)ため、前提の課題が閉じると辺は消える -- 前提 2: 前提の課題が閉じたかは、選定のたびに打つ順位の算出の時点の GitHub の状態で決める。閉じたのが前の選定の後なら、次の選定で選べるようになる -- 前提 3: #2344 の見積もりの記録(2026-11-05 の `/tmp/ndf/backlog-refinement/devbasex--ai-plugins/estimates.json`)は `depends_on` が空である。本文の前提 2 が書く #2339・#2341・#2342 は、次の棚卸しで記録へ書き足す。この変更は記録を書き換えない -- 前提 4: サブイシューの辺(子は親に依存する)は、選定から外す理由にしない。親が子の完了を待って閉じる使い方では、子を外すと親も子も選べなくなる。サブイシューの辺は今と同じく並び順にだけ効く - -## 対象範囲 - -含む: -- 開発ループの選定が、閉じていない前提の課題を持つ課題を外し、理由を選定の行に残すこと -- 外した課題が、選定の件数の枠(`max_issues`)を使わないこと -- 順位の算出の出力が、選定の判定に要る前提の関係を選定へ渡すこと - -含まない: -- 課題の本文の文章から依存の番号をスクリプトで拾うこと(棚卸しの手順 2A の担当のまま) -- #2344 の見積もりの記録の書き足し(次の棚卸しで行う。前提 3) -- 前提の課題が develop にマージ済みで、まだ閉じていない場合に前提を満たしたと見なすこと(S1) -- 選定以外の工程(順位の並び・切り出しの境界・品質の枠・大きい課題の枠)の判定を変えること - -## ドメインイベント - -| # | イベント | 引き金 | 失敗したとき | 順序の前提 | -| --- | --- | --- | --- | --- | -| E1 | 棚卸しが課題の依存を見積もりの記録へ書いた | 棚卸しの手順 2A・2B | 依存が抜ける(前提 3)。選定は抜けた依存を見ない | — | -| E2 | 順位の算出が依存の辺を合成した | `loop select` が打つ `upkeep.py rank` | 循環があれば今と同じく算出が止まり、選定を推さない | E1 | -| E3 | 選定が前提の課題の閉じていない課題を外した | `loop select` / `loop exclude` の選定 | — | E2 | -| E4 | 前提の課題が閉じた | スプリントの締め(`sprint-close.py`) | 閉じなければ、後続の課題は選ばれないまま残る | E3 | -| E5 | 次の選定が、外していた課題を選べるようにした | 次の `loop select` | — | E4 | - -## 用語 - -| 用語 | 意味 | -| --- | --- | -| 前提の課題 | ある課題が依存する、別の課題。順位の算出が合成した依存の辺の先にある open の課題 | -| 選定 | 開発ループの周回で、実装する課題を選ぶ処理(`loop select`・`loop exclude` が打つ) | - -## 受け入れ条件 - -- [ ] 1. 前提: マイルストーンの順位の 1 位の課題 A の見積もりの記録に `depends_on: [B]` があり、B が open - 操作: `loop select` を打つ - 結果: A は選ばれず(`decision` が落選)、選定の行の理由が `前提の課題の実装待ち(#B)` になる -- [ ] 2. 前提の課題が複数で open のものが 2 件あるとき、理由に open の 2 件の番号がすべて載る -- [ ] 3. 前提: 条件 1 の B が閉じている - 操作: `loop select` を打つ - 結果: A は前提を理由に外れず、順位どおりに選ばれる -- [ ] 4. マイルストーンの説明の並列の組の依存で B に依存する A も、条件 1 と同じ理由で外れる -- [ ] 5. 前提の課題が別のマイルストーン(選定の対象外のマイルストーンを含む)にあっても、条件 1 と同じ理由で外れる -- [ ] 6. 境界が無いマイルストーンで、前提で外れた課題は `max_issues` の枠を使わず、その後ろの課題が候補に上がって選ばれる -- [ ] 7. 先頭に選ぶ課題(直して出し直す・承認とマージが済んだ設計)でも、閉じていない前提の課題があれば外れる -- [ ] 8. サブイシューの辺だけで親に依存する子の課題は、前提を理由に外れない(退行しない。前提 4) -- [ ] 9. 依存の記録が無い課題の選定の結果(選ぶ課題・落選の理由・順序)は今と同じである(退行しない) -- [ ] 10. 候補がすべて前提で外れたとき、選定は今と同じく `outside_mvv` で止まり、`values.reasons` に `前提の課題の実装待ち(#B)` の件数が載る - -## 影響 - -| 対象 | 影響 | -| --- | --- | -| 公開インタフェース | 変わる。`upkeep.py rank` の出力に前提の関係を足す(足すだけで既存の項目は変えない)。`loop select` の結果に落選の理由が 1 種類増える | -| データ | 変わらない。見積もりの記録とループの記録の形は変えない | -| 既存の振る舞い | 前提の課題が open の課題は、順位が上でも選ばれなくなる | - -## 検証手段 - -| 項目 | 手段 | -| --- | --- | -| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_sprint_loop.py plugins/ndf/skills/backlog-refinement/tests -q -n 4` | -| 全体テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | -| 手動確認 | 次の棚卸しで #2344 の記録に依存を書き足した後、開発ループの選定で #2344 が `前提の課題の実装待ち` で外れ、手の除外が要らないことを見る(リリース後テスト) | - -## 前提とする取り決め - -| 項目 | 参照先 / 決めたこと | -| --- | --- | -| プロジェクト構造 | 選定の判定は開発ループの選定(`plugins/ndf/scripts/lib/sprint_loop_select.py`)に、依存の合成は順位の算出(`plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank.py`)に置く。依存の合成を選定の側へ写さない(Value 7) | -| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない | -| テスト戦略 | 選定は `test_sprint_loop.py` の単体のテストで、順位の算出の出力は `test_upkeep_rank*.py` で担保する | - -## 境界 - -| 区分 | 内容 | -| --- | --- | -| 常に行う | 既存のテストの実行、選定の落選の理由の記録 | -| 確認してから行う | 順位の算出の出力の項目の追加(承認ゲート 1・2 で人が見る) | -| 行わない | 見積もりの記録・ループの記録の書き換え、本文の文章の解析、順位・境界・枠の判定の変更 | - -## 未決 - -| 項目 | 誰が決めるか | 期限 | -| --- | --- | --- | -| 前提の関係を順位の算出の出力のどこに載せるか(行ごとの項目か、辺の一覧か) | 設計(`design`) | 設計 PR | -| 外した課題の前提の課題が、ループのどこからも選ばれない場所にあるとき(マイルストーン未設定など)に、人へ知らせるか | 設計(`design`) | 設計 PR | diff --git a/issues/issue-2616-design.md b/issues/issue-2616-design.md new file mode 100644 index 000000000..c6ad317f5 --- /dev/null +++ b/issues/issue-2616-design.md @@ -0,0 +1,475 @@ +# 並列の計画: 配る形の違うリポジトリでも、配布の依頼で計画全体を止めない(#2616) + +## 目的 + +- **今どうなっているか**: 並列の計画の耐久ワークフローは、配布の依頼を受けるとプラグインの版を読んでまとめた配布を組む。プラグインでないリポジトリでは版を読めず、誤りを受けて計画全体を止める(carul の計画 l20261104 で発生) +- **誰に効くか**: マージで反映する CD を持つリポジトリと、手で配布するリポジトリで、並列の計画を流す利用者 +- **変えると何が成り立つか**: リリースの経路を宣言から読み、組む必要が無ければ組まずに答える。組めなければ、その依頼へ失敗と理由を答え、計画を止めるかは人が決める + +## 適用範囲 + +- **働く範囲**: 配布先のリポジトリでも働く。ai-plugins の形(`release.form: package-plugin`)の動きは変えない +- **プロジェクトごとに違うもの**: 引数を足さず、設定で受ける。`.ndf/project.json` の `delivery` と `.ndf/supervise.json` の `release.form` を読む +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「ひとつづつ直すのではなく根本原因を見つけて対処してください」(2026-11-06) | 利用者の指示の原文 | 例外を足さず、宣言を読まない処理そのものを直す | +| 「本来デプロイ・配布・リリースなどと呼ばれるものはCDで解決すべき課題です」(#2623、2026-11-06) | 利用者の指示の原文 | マージで反映するリポジトリで、オーガナイザーが配布を組まない | +| リポジトリの形ごとの経路の判定の結果(要求の「調べたこと」の 2) | 実測 | 判定に必要な宣言と関数は揃っており、並列の計画だけが読んでいない | +| DBOS 3.1.0 で、記録のある途中の実行へ耐久ステップを足したコードを当てると `DBOSUnexpectedStepError` で落ち、`DBOS.patch` で分けると古い処理のまま続いた(2026-11-06。詳細は決定 2) | 実測 | 途中の記録を新しい版で続ける形は、`DBOS.patch` で成り立つ | +| STD1 | 外部の一次情報 | Continuous Delivery は本番へいつでも出せる状態を指し、変更が本番へ届いたことを意味しない。「配布済み」の答えを本番への到達と読まない根拠になる | + +要求と受け入れ条件は #2616 の本文にある(コピーは [issue-2616-requirements.md](issue-2616-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 開発ワークフロー(`ndf-workflow`) | まとめた配布・配布の依頼・配布の組み方・組み直しの依頼 | +| リリース(`ndf-release`) | リリースの経路・CD・配布の宣言 | + +開発ワークフローが顧客、リリースが供給者の関係(顧客 / 供給者)である。開発ワークフローは経路を自分で判定せず、リリースが返す配布の組み方だけを受ける。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 配布の宣言 | 利用者(`.ndf/` のファイル)。NDF は読むだけ | 宣言 | `delivery` の行 | リリースの経路・配布の組み方 | +| 並列の進行 | 耐久ワークフローの `_Run` | 計画の実行 | 配布の記録・受けている依頼・問い | 依頼への答え | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 配布の宣言 | 依頼へ「配布済み」と組まずに答えるのは、宣言を読めて、すべての経路がマージで届くか配布しない形のときだけである | テストで落とす。判定が決まらないときは「組めない」へ倒す | +| I2 | 配布の宣言 | 配布の組み方を決める処理は `lib/delivery.py` の 1 か所にある | レビューで落とす | +| I3 | 並列の進行 | 組まないと決めた依頼に対して、提示のプラン・本番のプラン・一時レーン・本番チャネルへのマージ・承認の記録を 1 つも作らない | テストで落とす | +| I4 | 並列の進行 | 範囲が決まる前に組まない・組めないと決めたときは、その時点で受けている依頼のすべてへそのループの 1 回で 1 回答え、受けている依頼に残さない。提示 2 を組めないときは範囲の依頼だけへ `failed` を答え、範囲に入らない依頼はその配布の答えを受けずに次の配布で扱う(組み立ての失敗が続けば、次の配布の連番で `failed` を受ける) | テストで落とす。範囲が決まる前の依頼を残すと、次の回で組み直しを繰り返す | +| I5 | 並列の進行 | 配布を組めないことでは計画を止めない。止まるのは人が問いへ「止める」と答えたときである | テストで落とす | +| I6 | 並列の進行 | 答えていない「配布を組めない」の問いのうち、同じ理由のものは 1 件である | テストで落とす | +| I7 | 並列の進行 | 記録のある途中の実行を新しい版で続けても、耐久ステップの並びの食い違いで落ちない | テストで落とす | +| I8 | 並列の進行 | `release.form` のテンプレートで組む配布の手順と答えは今と同じである | 既存のテストで落とす | +| I9 | 配布の宣言 | 起動の前の検査は、配布を組めないことを警告にとどめ、起動を断らない | テストで落とす | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | レーンが配布の依頼を送った | レーン | 耐久ワークフロー | +| E2 | 耐久ワークフローが依頼を受け付けた | 耐久ワークフロー | 耐久ワークフロー(配布のループ) | +| E3 | 出した範囲で答えられる依頼に答えた | 耐久ワークフロー | レーン | +| E4 | リリースの経路を読んだ | 耐久ステップ `parallel.route` | 耐久ワークフロー | +| E5 | 配布を組まずに `released` と答えた | 耐久ワークフロー | レーン | +| E6 | まとめた配布を組んだ | 耐久ステップ `parallel.build` | オーガナイザー・一時レーン | +| E7 | 配布を失敗として締め、依頼へ `failed` と理由を答えた | 耐久ワークフロー | レーン | +| E8 | オーガナイザーへ問いを立てた | 耐久ワークフロー | オーガナイザー | +| E9 | オーガナイザーが問いへ答えた | オーガナイザー | 耐久ワークフロー | +| E10 | レーンが答えを読み、まとめへ進むか周回を締めた | レーン | 周回の記録 | +| E11 | 起動の前に検査が配布を組めないことを知らせた | `parallel.py check` | オーガナイザー | +| E12 | 組み直しの依頼を入れ直した | 耐久ワークフロー | 耐久ワークフロー(配布のループ) | + +E12 は設計で足した。要求の未決 2(「直してから組み直す」の後に Pull Request をどう届けるか)の答えである(決定 5)。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 配布の組み方 | 配布の依頼を受けたときに、まとめた配布をどう扱うか。組む・組まない・組めないの 3 つがある。宣言から決まる | 追加(`ndf-workflow`) | +| 組み直しの依頼 | 組めなかった配布の依頼を、人が「直してから組み直す」と答えた後に耐久ワークフローが受けている依頼へ入れ直したもの。元の依頼の答えは変えない | 追加(`ndf-workflow`) | + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べる | 「配布済み」の答えが何を意味するかを、CD の定着した定義に合わせる | +| 主要な製品 | 調べる | 動いているワークフローの定義を変える方法と、1 つの失敗を全体へ波及させない宣言の形を比べる | +| ライブラリとサービス | 調べる | 耐久ワークフローの基盤(DBOS)が、途中の記録の続きに使える仕組みを持つかを確かめる | + +### 標準・仕様 + +| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| STD1 | Continuous Delivery と Continuous Deployment の定義 | Continuous Delivery は「software can be released to production at any time」で、出さないことも選べる。Continuous Deployment は変更がすべて自動で本番へ出る | `released` の答えは本番への到達を意味しない。到達はリリース後テストが確かめる(要求の前提 2) | https://martinfowler.com/bliki/ContinuousDelivery.html (2026-11-06) | + +### 主要な製品 + +| # | 製品 | どう解いているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| PRD1 | Temporal | `workflow.patched()` が履歴へマーカーを入れ、新旧の処理を分ける。古い実行が無くなったら `deprecate_patch` に置き換え、最後に消す。定義を別名で複製する方法は、動いている実行を救えない | 同じ 3 段階の形を DBOS で使う。別名の耐久ワークフローへ切り替える形を採らない | https://docs.temporal.io/develop/python/versioning (2026-11-06) | +| PRD2 | GitHub Actions | `continue-on-error` は 1 つのジョブの失敗で実行全体を失敗にしない。既定の `fail-fast` は行列の他のジョブを取り消す | 1 つの配布の失敗を計画全体へ波及させない形の例として参照する | https://docs.github.com/en/actions/reference/workflows-and-actions/workflow-syntax (2026-11-06。要約を経由して取得した) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | DBOS の `DBOS.patch`(`enable_patching` で有効にする) | 3.1.0(`uv.lock` で固定) | MIT | 3.1.0 は 2026-09-24、最新の 3.4.0 は 2026-10-28 | 無し(調べたページの範囲。下の「確かめられなかったこと」) | 満たす。古い記録は `False`、新しい記録と記録の先は `True` を返す(実測) | https://docs.dbos.dev/python/tutorials/upgrading-workflows ・ https://pypi.org/pypi/dbos/3.1.0/json ・ `.venv/lib/python3.13/site-packages/dbos/_dbos.py` の 3649 行(2026-11-06) | +| LIB2 | DBOS の版による隔離(`application_version`。NDF の `durable.FORMAT`) | 3.1.0 | MIT | LIB1 と同じ | 無し(LIB1 と同じ範囲) | 満たさない。版が違う実行は回復されず、受け付け済みの依頼を持つ実行が続かない | https://docs.dbos.dev/python/tutorials/upgrading-workflows ・ `plugins/ndf/scripts/lib/durable.py` の `resolve`(2026-11-06) | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| Argo Workflows の `continueOn` の動き | https://argo-workflows.readthedocs.io/en/latest/walk-through/continue-on/ | HTTP 404。PRD2 だけで比べた | +| GitHub Actions の該当の節の原文 | PRD2 の URL | ページが長く、要約を経由して読んだ。原文の照合は未確認 | +| `enable_patching` のドキュメントの記述 | LIB1 のドキュメントの URL | ページに語が無い。導入済みのソースで動きを確かめた | +| DBOS の `patch` の終了のアナウンス | LIB1 のドキュメントのページと PyPI の情報を読んだ。変更履歴の全体は試していない | 読んだ範囲に記述は無い。範囲の外は未確認 | + +調べた量: 候補 5 件・取得の試み 8 回。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 配布の依頼を受けたときに、宣言から配布の組み方を決める | 耐久ワークフロー | +| F2 | 組む必要が無ければ、組まずに配布済みと答えて計画を続ける | CD のリポジトリのレーン | +| F3 | 組めなければ、依頼へ失敗と理由を答えて計画を続ける | レーン | +| F4 | 組めなかったことを問いにし、「直してから組み直す」「止める」を選べる | オーガナイザーと利用者 | +| F5 | 起動の前の検査で、配布を組めないことを知らせる | オーガナイザー | +| F6 | 途中の計画を新しい版で続ける | 計画を流している利用者 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 配布の組み方の判定(`lib/delivery.py` の `release_way`)【追加】 | 宣言とリリースの経路から、組む・組まない・組めないと理由を返す。ファイルを読まない純粋な処理 | +| 計画の読み出し(`lib/parallel_route.py`)【新設】 | 宣言を読んで配布の組み方を返す。起動の前の検査の警告を作る。`parallel_run.py` から版の読み出し(`base_tracking_ref`・`release_plugin`・`dev_version`)を移して持つ | +| 一時レーンの組み立て(`lib/parallel_helper.py`)【新設】 | `helper_spawn`・`present_approval`・`helper_task` を `parallel_run.py` から移して持つ。振る舞いは変えない | +| 耐久の記録の包み(`lib/durable.py`)【変更】 | `enable_patching` を有効にし、`patched(名前)` を足す。DBOS を読むのはこのモジュールだけのままである | +| 耐久ワークフロー(`lib/parallel_run.py`)【変更】 | 配布の組み方で分岐する。組まない・組めないの答え、組めないときの問い、組み直しの依頼を持つ | +| 配布の検査(`parallel.py check`)【変更】 | 計画の検査に、配布を組めない警告を足す | +| 計画の描画(`lib/parallel_plan.py` の `render_md`)【変更】 | 配布の表の連番の欄を配布の記録のキー `n` から読む。今は無いキー `seq` を読み、`None` と描く | +| 手順書(`references/parallel.md`・`parallel-plan.md`)【変更】 | 経路ごとの動き、問いの種類、検査の警告を書く | + +### 呼び出しの関係 + +図には呼び出しの関係だけを描き、関係の変わらない要素(一時レーンの組み立て・耐久の記録の包み・計画の描画・手順書)は載せない。 + +```mermaid +graph TB + subgraph レーン + L[配布の依頼を送る] + A[答えを読む] + end + subgraph 耐久ワークフロー + R[_Run の配布のループ] + S1[parallel.route] + S2[parallel.build] + end + subgraph 読み出しと判定 + P[parallel_route] + D[delivery.release_way] + end + subgraph 利用者の宣言 + W[.ndf の宣言] + end + O[オーガナイザー] + C[parallel.py check] + L --> R + R --> S1 + R --> S2 + S1 --> P + C --> P + P --> D + P --> W + R --> A + R --> O + O --> R +``` + +### 文脈と配置 + +変更はオーガナイザーのマシンのプロセスの中で閉じ、配置は変わらない。外部のシステム(CD の仕組み・本番チャネル)へ何も送らない。 + +```mermaid +graph TB + U[利用者] --> O[オーガナイザーと耐久ワークフロー] + O --> G[GitHub の読み取りだけ] + CD[リポジトリの CD] --> E[検証と本番の環境] +``` + +### パッケージ・モジュール構成 + +```text +plugins/ndf/scripts/ +├── parallel.py # check に警告を足す +└── lib/ + ├── delivery.py # release_way を足す + ├── durable.py # patched を足す + ├── parallel_helper.py # 新設(parallel_run.py から移す) + ├── parallel_route.py # 新設(配布の組み方の読み出しと版の読み出し) + ├── parallel_plan.py # 配布の表の連番の欄を直す + ├── parallel_external_cli.py # dev_version の読み先を parallel_route へ + └── parallel_run.py # 配布のループの分岐 +``` + +## 構造 + +```mermaid +classDiagram + class _Run { + +open_release() + +open_recorded() + +skip_release(n, reason) + +refuse_release(n, reason) + +ask_rebuild(n, reason, requests) + +settle(rel, msg) list + +wake_answered(answered) + +apply_own(q, choice) + } + class ReleaseWay { + +way: str + +reason: str + } + class Route { + +route: str + +stage: str + +note: str + } + class DeliveryDecl { + +rows: list + +release: dict + +problems: list + } + _Run ..> ReleaseWay : parallel.route の結果 + ReleaseWay ..> Route : routes から導く + Route ..> DeliveryDecl : 宣言から導く +``` + +`open_recorded` は今の `open_release` の本体で、古い記録を打ち直すときだけ通る(決定 2)。`ReleaseWay` は `delivery.py` の `dataclass` で、耐久ステップの結果へは `{"way", "reason"}` の dict で載せる。 + +## 入出力の契約 + +### `delivery.release_way(decl, forms) -> ReleaseWay` + +上から順に最初に当たった行で決まる。 + +| 条件 | `way` | `reason` | +| --- | --- | --- | +| `needs_version(routes(...))` が真(`release.form` にテンプレートがある) | `build` | 空 | +| `decl.problems` がある | `refuse` | 宣言を読めない(内容) | +| `stage` が `manual` の経路が 1 つでもある(行が無い・不明、手動の行、版数を持つ自動の行、宛先の分からない自動の行、テンプレートの無い `release.form`) | `refuse` | その経路の `note` を並べ、`.ndf/project.json` の `delivery` か `.ndf/supervise.json` の `release.form` を直すよう添える | +| 残り(`merged-by-check`・`promote`・`none` だけ) | `skip` | 経路の対象と届き方を並べる(例: `web-app はマージで反映する`) | + +### 依頼への答え(`answer-<依頼>`) + +`result` の値は増えない。`version` の `null` は `parallel.py request` の「すでに本番チャネルにある」分岐で既に取っている形である。 + +| 配布の組み方 | `result` | `version` | `release` | `reason` | +| --- | --- | --- | --- | --- | +| 組む | 今のまま | 今のまま | 連番 | 今のまま | +| 組まない | `released` | `null` | 連番 | 組まなかった理由 | +| 組めない | `failed` | `null` | 連番 | 組めない理由 | + +`parallel.py await` の終了コードは変わらない(`released` は 0、`failed` は 1)。 + +### 配布の記録(耐久ワークフローの結果の `releases`) + +| 配布の組み方 | キー | +| --- | --- | +| 組まない | `n`・`state: released`・`version: null`・`prs`・`requests`・`reason`・`skipped: true` | +| 組めない | `n`・`state: failed`・`prs`・`requests`・`reason` | + +`prs` は受けている依頼の Pull Request の和である。組まない配布に置き場(`releases/<連番>/`)とイベント `range-<連番>` は作らない。`parallel_lane.released_version` は置き場のある連番だけを読むため、版は `null` のまま返る。 + +### 組めないときの問い(`questions` の 1 件) + +| キー | 値 | +| --- | --- | +| `kind` | `build` | +| `id` | `<計画名>-build-<連番>` | +| `lane` | 無し。依存の判定(`_asking_lanes`)で依頼元のレーンの後続を止めないためである | +| `question` | 配布 `<連番>` を組めない: `<理由>` | +| `options` | 「直してから組み直す」「止める」 | +| `reason` | 組めない理由。同じ理由の問いをまとめるキーである | +| `requests` | 組めなかった依頼の並び(組み直しの依頼の元) | + +答えは `parallel.py answer <計画名> --choice <選んだ答え>` で送る。`attach` は付けない。 + +### `parallel.py check` の警告 + +`items` へ次の形を足し、`metrics.warnings` へ件数を足す。終了コードは 0 のままである。`init` の検査には足さない。 + +| 条件 | `rule` | `at` | `message` | +| --- | --- | --- | --- | +| 配布の組み方が組めない | `release` | `delivery` | `reason` | +| 配布の組み方が組むで、出す版を読めない | `release` | `release.plugin` | 出す版を決められない理由 | + +### `durable.patched(name) -> bool` + +耐久ワークフローの中で呼ぶ。この位置に別の記録があれば `False`(古い実行の打ち直し)、それ以外はマーカーを記録して `True` を返す。 + +## 処理の流れ + +### 配布のループ + +配布のループで、出した範囲で答えられない依頼が残ったときの流れである。 + +```mermaid +graph TB + A[依頼が残っている] --> B{記録の位置に
古い build がある} + B -- ある --> C[open_recorded で
今の手順のまま] + B -- 無い --> D[parallel.route] + D --> E{配布の組み方} + E -- 組まない --> F[記録を released にし
全依頼へ released] + E -- 組めない --> G[記録を failed にし
全依頼へ failed] + E -- 組む --> H[parallel.build] + H -- 誤り --> G + H -- 成功 --> I[提示を出す
今の手順] + G --> J[問いを立てる] + F --> K[止まった宛先を起こす] + J --> K + K --> L[観測を続ける] +``` + +承認の答えの時点でベースブランチが進むと、提示 2 を組む(`confirm`)。ここで `parallel.build` が誤りを返したら、`settle` で範囲の依頼へ `failed` を答えて配布を締め、同じ問いを立てる。範囲に入らない依頼は今の `settle` のとおり次の配布へ回す。 + +### 問いへの答えの処理(`apply_own`) + +```mermaid +graph TB + A[問い build への答え] --> B{選んだ答え} + B -- 止める --> C[stop を立てる
流れている配布の後に止まる] + B -- 直してから組み直す --> D[組み直しの依頼を入れ直す] + D --> E[次の回で parallel.route から読み直す] +``` + +### 配布の記録の状態 + +```mermaid +stateDiagram-v2 + [*] --> released: 組まない + [*] --> failed: 組めない + [*] --> presented: 組む + presented --> confirmed: 承認 + presented --> failed: 提示 2 を組めない + presented --> failed: 人が止めると答えた + presented --> rejected: 差し戻し + confirmed --> released: 流れ切った + confirmed --> failed: 流れなかった + released --> [*] + failed --> [*] + rejected --> [*] +``` + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 運用・保守性 | 配布を組まなかった理由と組めなかった理由が配布の記録に 1 件ごとに残り、理由だけでどの宣言を直せばよいかが分かる | `reason` に経路の `note` と宣言のファイル名を載せ、配布の記録と答えの両方へ書く | テストが `reason` に宣言の名前が入ることを確かめる | +| 移行性 | 記録のある途中の耐久ワークフローを新しい版で続けても、耐久ステップの並びの食い違いで落ちない | `durable.patched` で新旧の処理を分ける(決定 2)。形式の版 `FORMAT` は上げない | 古い手順で記録を作り、新しいコードで続けるテスト | + +## 決定の記録 + +### 決定 1: 配布の組み方を 1 か所で決めるため、`lib/delivery.py` が 3 値を返す形にする + +配布の組み方の判定はリリースの経路の判定と同じ宣言を読むため、並列の計画の側に書くと同じ役割が 2 か所に分かれる。経路の列挙だけでは「組まない」「組めない」を呼び手ごとに解釈することになるので、解釈まで `delivery.py` に置く。 + +`release.plugin` の有無で判定する形は採らない。`.ndf/supervise.json` を持つのは ai-plugins だけで、手で反映するリポジトリも同じ条件に当たり、届いていない変更へ配布済みと答える。carul が手元で足したステップ(`parallel.has-release-plugin`)を本体へ入れない理由も同じである。 + +根拠: Value 6 / Value 7(MVV 版 6) + +### 決定 2: 途中の計画を新しい版で続けるため、配布のループに `DBOS.patch` の分岐を置く + +依頼を受けるたびに宣言を読む耐久ステップ `parallel.route` を足す。読むのを耐久ステップにするのは、耐久ワークフローの分岐を記録された値だけで決めるためである。足すと並びが変わるため、`durable.patched("parallel-route")` で新旧を分ける。`False`(古い実行の打ち直し)は今の手順 `open_recorded` をそのまま通す。古い手順の誤りで止まる途中だった記録が、打ち直しで別の処理を通って落ちないためである。 + +2026-11-06 に DBOS 3.1.0 で、受信 → 耐久ステップの順に動くワークフローの途中でプロセスを落とし、次の 3 つを確かめた。 + +| 確かめたこと | 結果 | +| --- | --- | +| 記録のある実行へ、耐久ステップを前に足したコードを当てた | `DBOSUnexpectedStepError`(`p.build was recorded when p.route was expected`) | +| 記録のある実行へ、`DBOS.patch` で分けたコードを当てた | 古い処理のまま続き、後の受信も読めた | +| 新しい記録で `patch` を 3 回通し、2 回目の後に落として打ち直した | 3 回とも `True` で続いた。実行の版は `application_version` の値のままだった | + +次の形は採らない。 + +| 形 | 採らない理由 | +| --- | --- | +| 形式の版 `FORMAT` を上げる | `run_plan` は `resolve` を通らず、版の違う実行は回復されないまま待ち続ける。全種類の記録に効き、受け付け済みの依頼も失う | +| 計画の開始時に一度だけ読む(`parallel.init` の結果へ載せる) | 並びは変わらないが、宣言を直して組み直しても読み直されず、受け入れ条件 15(「直してから組み直す」の後に `parallel.build` が再び呼ばれる)を満たさない | +| レーンが依頼のメッセージへ配布の組み方を載せる | 並びは変わらないが、判定がオーガナイザーからレーンの側へ移り、版の違うレーンの依頼が混ざる | +| `parallel.build` の中で分岐する | 受け入れ条件 1 の「`parallel.build` は呼ばれない」に合わない | + +- 作り方: ライブラリやサービスを使う +- 比べた候補: LIB1 / LIB2 / PRD1 + +根拠: Value 7(MVV 版 6) + +### 決定 3: 配布の失敗を計画の失敗にしないため、範囲が決まる前に組めないときは受けている依頼のすべてへその場で `failed` を答える + +範囲が決まる前に組めないのだから、依頼を範囲で分けられない。受けている依頼に残すと、次の回でまた組もうとして問いと記録が増え続ける。提示 2 だけは範囲が決まっているので、今の `settle` で締める(範囲の依頼だけ答え、残りは次の配布へ回す)。 + +`release.form` が無く手動の経路があるリポジトリも同じ扱いにする。手動の経路を自動にしない方針で、配布済みと答えると届いていない変更でレーンの周回が進むためである。 + +組めない場合の 4 つ(版の読み取り・連番の上限・組み立ての例外・提示 2)は `parallel.build` の結果の `error` に集める。`step_build` の中で例外を受け、耐久ステップから例外を上げない。 + +根拠: Value 3 / Value 7(MVV 版 6) + +### 決定 4: 計画を止めずに問うため、組めないときの問いにレーンを結び付けない + +既存の問いは `lane` を持ち、答えるまでそのレーンを保留中として依存の判定から外す。組めないのは配布の問題で、レーンの作業は止まっていない。`lane` を置くと後続のレーンが起動しなくなる。 + +同じ理由の問いがすでに開いていれば、新しい問いを立てず、開いている問いの `requests` へ足す(不変条件 I6)。問いの同一性は `reason` の文字列の一致で決める。既存の `cause_of` はレーンから届く問いのまとめ方で、番号を伏せて比べる。ここでは自分で作った文字列なので、そのまま比べる。 + +根拠: Value 2(MVV 版 6) + +### 決定 5: 直した変更が届かないまま計画が終わらないよう、「直してから組み直す」で耐久ワークフローが組み直しの依頼を入れ直す + +要求の未決 2 の答えである。元の依頼はすでに `failed` と答えており、答えは 1 回だけである。レーンの周回も失敗で締まるので、レーンに送り直す手段が無い。依頼が他に来なければ次の配布が起きず、直しても Pull Request が届かないまま計画が終わる。 + +そこで、問いの `requests` の各依頼を新しい ID(`<元の ID>-again<連番>`)とフラグ `again: true` で受けている依頼へ入れ直す。配布の中身はコミットの範囲で決まるため、依頼が残れば次の配布の範囲に入る。この依頼の答えのイベントを読むレーンはいない。止まったレーンを起こす処理は `again` の依頼を除く。組み直しの結果は配布の記録に残り、計画が終わる前に流れ切る。 + +元の依頼の答えを書き換える形は採らない。答えを 1 回だけとする約束が崩れ、周回の記録との整合が取れない。 + +根拠: Value 2 / Value 3(MVV 版 6) + +### 決定 6: 構造の上限を超えないため、`parallel_run.py` の読み出しと一時レーンの組み立てを別のモジュールへ移す + +`parallel_run.py` は 776 行で、例外リストの上限 778 行に近い。例外リストの理由欄が分け方を挙げているので、それに従う。移した分だけ例外リストの行数を下げ、上げない。`parallel_run` の名前で読むテストのため、`dev_version`・`git_head`・`helper_task`・`helper_spawn` は `parallel_run` からも読める形を保つ。`parallel_external_cli.py` の `dev_version` の読み先は `parallel_route` へ変え、外のオーガナイザーの処理が `parallel_run` を読まなくする。 + +根拠: Value 5(MVV 版 6) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 1・I1・I3・9 | 全行 `auto` の宣言でレーンが依頼を送ると、`released`・`version: null` の答えが 1 回立ち、`parallel.build` の記録が無く、提示の置き場も `release`・`confirmed` の通知も無い | 判定を外して常に組むように壊すと落ちる。答えを 2 回立てると落ちる | +| 2 | 配布の記録に `state: released`・`prs`・`reason` があり、`plan.md` の版の欄が `-` で連番の欄が番号である | 理由を書かないと落ちる。連番の読み先を戻すと落ちる | +| 3 | 答えの後も計画が動き、他のレーンの観測を続け、全レーンが止まると `finished` で終わる | 組まないときに `stop` を立てると落ちる | +| 4 | 開発ループのレーンが `version` の無い `released` を読むと、まとめの pipeline が返り、周回の記録に本番の版が無い | `settle_release` が版を必須にすると落ちる | +| 5 | `delivery: []` で 1 と同じ答えになる | 空の行を読めないと扱うと落ちる | +| 6・I1 | `manual` の行が 1 行でもあれば `failed` と手動の経路の理由を答える。自動との混在でも同じである | 自動の行だけを見て組まないと判定すると落ちる | +| 7・I1 | `delivery` が無い・不明・壊れた宣言で `failed` と宣言を読めない理由を答える | 読めないときに `skip` へ倒すと落ちる | +| 8・I8 | `release.form: package-plugin` で `parallel.build` が呼ばれ、提示のプランが書かれる。既存の 62 件が通る | 判定を `build` の前で止めると落ちる | +| 10・I4・I5 | 版を読めないと依頼へ `failed` と理由を答え、計画は動き続け、記録が `failed` になり、受けている依頼が空である | 今の `stop` を残すと落ちる。依頼を残すと落ちる | +| 11 | `build_plans` が例外を上げても耐久ワークフローは動き続け、10 と同じ答えになる | `step_build` の例外の受け取りを外すと落ちる | +| 12 | 連番が上限を超えると 10 と同じ答えになる | 誤りの判定を外すと落ちる | +| 13・I4 | 配布 n の提示 2 を組めないと `KeyError` で落ちず、範囲の依頼へ配布 n の `failed` を答え、問いが立つ。範囲に入らない依頼は配布 n の答えを受けず、次の配布で扱われる。組み立ての失敗が続くときは配布 n+1 の `failed` を受け、理由が同じなら同じ問いの `requests` へ足される | `error` を見ずに `dir` を読むと落ちる。範囲に入らない依頼へ配布 n の答えを返すと落ちる | +| 14・I6 | 組めないと問いが 1 件立つ。同じ理由が続いても 1 件で、`requests` が増える。問いがあっても依存先のレーンが起動する | 問いへ `lane` を持たせると落ちる。まとめないと落ちる | +| 15 | 「止める」で計画が止まる。「直してから組み直す」で計画が続き、組み直しの依頼で `parallel.route` と `parallel.build` が再び呼ばれ、元の依頼の答えは変わらない | 組み直しの依頼を入れないと落ちる。宣言を直した後も古い判定を使うと落ちる | +| 16・I9 | 6・7 の形と版を読めない形で、警告が 1 件増え、終了コードが 0 である | 誤りとして 1 で返すと落ちる | +| 17 | 1 の形と版を読める `package-plugin` の形で、警告が増えない | 組むかどうかを見ずに版を読むと落ちる | +| 18 | 1 と 10 のテストが直す前のコードで失敗する | テストを実装より先に書いて失敗を確かめる(`tdd-cycle`) | +| 19 | `test_char_build_error_fails_release_and_stops` が 10 の振る舞いを確かめる形である | 今の期待値のまま残すと落ちる | +| I2 | `delivery.release_way` の表の各行を単体で確かめ、並列の計画の側のテストは結果の分岐だけを見る | 表の行の順を変えると落ちる | +| I7 | 古い手順で `parallel.build` まで記録した実行を、新しいコードで続けても落ちず、次の依頼は新しい処理を通る。新しい記録は打ち直しで同じ値に戻る | `patched` を外すと `DBOSUnexpectedStepError` で落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2616 | 実装する | — | `plugins/ndf/scripts/lib/delivery.py`、`plugins/ndf/scripts/lib/durable.py`、`plugins/ndf/scripts/lib/parallel_run.py`、`plugins/ndf/scripts/lib/parallel_route.py`、`plugins/ndf/scripts/lib/parallel_helper.py`、`plugins/ndf/scripts/lib/parallel_plan.py`、`plugins/ndf/scripts/lib/parallel_external_cli.py`、`plugins/ndf/scripts/lib/README.md`、`plugins/ndf/scripts/parallel.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/parallel.md`、`plugins/ndf/skills/issue-plan-strategy/references/parallel-plan.md`、`scripts/script-structure-allow/plugins__ndf__scripts__lib__parallel_run.py--lines.json` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 要求の未決 1(承認ゲート 1 で利用者が決める) | CD のリポジトリのレーンは今のコードでは配布の依頼を送らず、昇格を直接流す。この設計はその流れを変えない。carul の再現は手で置き換えたプランで起きた形で、配布の依頼へ置き換えて `released` を返すだけだと昇格を行う者がいなくなる。#2623 の受け入れ条件 4 で決めることを勧める | +| 要求の未決 3(承認ゲート 2 で利用者が決める) | この課題を閉じる条件。リリース後テストの手動確認を通した時点で閉じるかを決める | +| リリースの経路のステージが `promote` のときの答え | `released` は「オーガナイザーが組む配布が要らない」ことを指し、本番への到達は含まない(要求の前提 2)。依頼を受けた時点で昇格が済んでいるかは読まない | +| carul のほかの実績 | `delivery` が `auto` の行だけを持つリポジトリで並列の計画を流した実績は未確認である | +| 分岐を外す時期 | 記録は使われなくなって 30 日(`durable.KEEP_DAYS`)で消える。古い処理を外す手順は `deprecate_patch` を入れてから消す 2 段階である(LIB1)。外す時期は未定で、実装の範囲に含めない | diff --git a/issues/issue-2616-requirements.md b/issues/issue-2616-requirements.md new file mode 100644 index 000000000..68546bb87 --- /dev/null +++ b/issues/issue-2616-requirements.md @@ -0,0 +1,287 @@ +# #2616: 並列の計画: plugin.json の無いリポジトリでは配布の版を決められず、配布の依頼 1 つで計画全体が止まる + +正は課題の本文(#2616)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何が起きたか + +プラグインではないアプリのリポジトリ(volareinc/carul。`.ndf/supervise.json` を持たず、`plugin.json` も無い)で並列の計画 l20261104 を流した。レーン l20261104-ux が配布の依頼(`l20261104-ux-r807469755886`、PR #182)を送ると、耐久ワークフローの `parallel.build` が次の誤りを返した(10.27.21、2026-11-05 21:29 UTC)。 + +```text +{"error": "出す版を決められない(ベースブランチと .ndf/supervise.json の release.plugin の plugin.json の版を読めない)"} +``` + +`open_release` はこの誤りを受けて `run.stop = True` にする。このため、配布を 1 本も組まないまま `parallel.stop-lanes`(ux・api)と `parallel.finish` まで進み、計画全体が止まった。同じ時点で届いていた api のレーンの問い(`l20261104-api-q9d246b7f36fa`)にも答えられなくなった。 + +並列の計画を使わない 1 スプリントや開発ループでは、carul は同じ develop → main の昇格を流せている。前の周回(ux の周回 5)は、プランの `verify` → `prepare` → `mvv` → `promote` → `record` で main へ届いた。並列の計画の下でだけ配布が組めない。 + +## 原因 + +- `lib/parallel_run.py` の `step_build` が、出す版を `dev_version` だけから決めている +- `dev_version` は `.ndf/supervise.json` の `release.plugin` と、ベースブランチの `plugin.json` の版を読む。プラグインでないリポジトリには、このどちらも無い。このため常に空文字を返し、`step_build` が誤りになる +- 版を決められないことを「配布の失敗」ではなく「計画を止める」理由として扱っている(`open_release` の `self.stop = True`) + +## 直し方の案 + +1. 配るものがプラグインでないリポジトリ(`release.plugin` が無い)では、まとめた配布を組まず、届いた配布の依頼へその場で `released` と答える(下の「CD があるときは配布の工程が要らない」) +2. 版を決められないなどで配布を組めないときは、計画を止めず、その配布の依頼に `failed` と理由を答える。あわせてオーガナイザーへ問いを立てる(「直してから組み直す」「止める」) +3. `parallel.py check`(計画の検査)が、配布の版を決められないリポジトリを起動の前に見つけて知らせる + +## CD があるときは配布の工程が要らない + +carul の配布は CI/CD が決めている。develop へのマージで検証環境へ、main へのマージで本番へ、環境ごとの CodePipeline が反映する(利用者の指摘、2026-11-05)。このようにマージで反映する CD を持つリポジトリでは、マージそのものが配布になる。オーガナイザーがまとめた配布(提示のプラン・本番のプラン・版上げ・タグ)を組む工程は要らない。 + +| リポジトリ | 配布の工程 | 届いた配布の依頼への答え | +| --- | --- | --- | +| プラグイン(`release.plugin` があり、版とタグで出す) | まとめた配布を組む(今の動き) | 配布の結果(`released`・`rejected`・`failed`) | +| CD がマージで反映する(`.ndf/project.json` の `delivery` が `kind: auto`) | 組まない | その場で `released` | + +本番チャネルへの昇格のマージ(develop → main)は承認ゲート 2 のままで、この変更は承認の要否を変えない。 + +## 起票時の受け入れ条件(案) + +1. `plugin.json` を持たず CD が反映するリポジトリの並列の計画で、レーンの配布の依頼に配布済みの答えが返り、計画が止まらずにレーンがまとめへ進む +2. 配布を組めないときに、耐久ワークフローが計画全体を止めず、依頼へ理由つきで答える +3. 1 と 2 をそれぞれ縛るテストがある + +## 再現の材料 + +- 耐久の記録: `~/.local/state/ndf/dbos/parallel-6bcdad30d9e4.sqlite` の `operation_outputs` の 491 番目(`parallel.build`)〜 496 番目(`parallel.finish`) +- 計画: carul の `.ndf/parallel/l20261104/plan.json`。依頼: `.ndf/parallel/l20261104/releases/requests.json` + + + +## 根本原因の束(2026-11-06) + +carul から届いた課題を根本原因で束ねた(利用者の指示「ひとつづつ直すのではなく根本原因を見つけて対処してください」)。この課題は #2624(束 D) に入る。1 件ずつ直さず、束の根本原因を直した後に、この課題の再現の手順を 1 回通して閉じるか残すかを決める。 + +## 依頼(原文) + +> このため、オーガナイザーがまとめた配布を組むこと自体が要らない。 + +(書き手: 起票者が利用者の指摘を書き留めた文、日付: 2026-11-05。課題のコメント。利用者の原文は取得できなかったため、利用者の指示の原文としては扱わない) + +> ひとつづつ直すのではなく根本原因を見つけて対処してください + +(書き手: 利用者、日付: 2026-11-06。上の節「根本原因の束」の引用) + +> そもそもai-pluginにCDの仕掛けが無いことが根本課題で、本来デプロイ・配布・リリースなどと呼ばれるものはCDで解決すべき課題です。 + +(書き手: 利用者、日付: 2026-11-06。#2623 の引用) + +> carmo-cdkはインフラに関わるため、あえてCDを置いていないプロジェクトですね。参考としてはあまり適切ではないです(そういった例の対処も必要ですが) + +(書き手: 利用者、日付: 2026-11-06。#2623 の引用) + +> 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-06) + +この課題の「何が起きたか」から「再現の材料」までの節の書き手は起票者(2026-11-05)である。起票時の受け入れ条件は、節の名前を「起票時の受け入れ条件(案)」に変えて残した。確定した条件は下の「受け入れ条件」にある。 + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 配るプラグインが無いリポジトリ(`release.plugin` が無い)では、その場で `released` と答える | 「直し方の案」の 1 と課題のコメント | 採らない。判定は `delivery` の宣言で行う(受け入れ条件 1・6・7)。`release.plugin` で判定すると、手動で反映するリポジトリにも `released` と答える。理由は「調べたこと」の 3 | +| S2 | `delivery` が `kind: auto` のリポジトリは、まとめた配布を組まず `released` と答える | 節「CD があるときは配布の工程が要らない」の表 | 受け入れ条件 1 | +| S3 | 配布を組めないときは `failed` と理由を答え、オーガナイザーへ問いを立てる。選択肢は「直してから組み直す」「止める」 | 「直し方の案」の 2 | 受け入れ条件 10・13・14 | +| S4 | `parallel.py check` が、配布の版を決められないリポジトリを起動の前に知らせる | 「直し方の案」の 3 | 受け入れ条件 16。知らせは警告にし、起動を断らない(レーンが配布の依頼を送らない計画もあるため) | +| S5 | 本番チャネルへの昇格は承認ゲート 2 のままである | 節「CD があるときは配布の工程が要らない」 | 受け入れ条件 9 | +| S6 | CD が反映するリポジトリでも、レーンは配布の依頼を送る | 起票時の受け入れ条件 1 | 前提 3 と未決 1。今のコードでは、この形のレーンは配布の依頼を送らない(「調べたこと」の 1) | +| S7 | 手元の手当て(`parallel.has-release-plugin` のステップ)を本体へ入れる | 課題のコメント | 採らない。理由は S1 と同じ | +| S8 | 1 件ずつ直さず、束の根本原因を直した後にこの課題の再現の手順を通して閉じるか残すかを決める | 節「根本原因の束」 | 前提 4 と未決 3 | + +## 目的 + +並列の計画が、配る形の違うリポジトリでも止まらずに進む状態にする。 + +- リポジトリの配り方を、`.ndf/project.json` の `delivery` と `.ndf/supervise.json` の `release.form` の宣言から読む。プラグインの形を前提にしない +- 配布を組めないことを、計画全体を止める理由にしない。その配布の失敗として依頼へ答え、計画を止めるかは人が決める + +## 調べたこと + +調べた時点は develop の e7500d193 である。 + +### 1. CD のリポジトリのレーンは、今のコードでは配布の依頼を送らない + +本番のステージを配布の依頼に置き換える `prod_plans` の呼び出しは `supervise_lib/sprint.py` の 4 か所(124・164・219・318 行)で、どれも `has_release_template` が真のときだけ通る。テンプレートで組まない経路は `route_waves` へ進み、昇格のプランをレーンが直接流す。 + +carul の実物も同じである。周回 6 の本番のステージ `7-promote.json` は、組まれた時点では「配布(昇格)」のプランだった(`7-promote.json.direct-promote.bak`)。オーガナイザーの作業の中で配布の依頼のプランへ手で置き換えられ、その依頼が `parallel.build` の誤りを起こした。 + +つまり、この課題の再現は手で置き換えたプランで起きた。**配布の依頼を受けたときの直しだけでは、CD のリポジトリのレーンが直接昇格する流れは変わらない**(未決 1)。 + +### 2. リポジトリの形ごとの経路 + +次の形をリリースの経路の判定(`lib/delivery.py` の `routes`)へ渡した結果である。 + +| 形 | 宣言 | `routes` の結果(経路, ステージ) | +| --- | --- | --- | +| carul | `delivery` の 2 行がどちらも `auto` | `merge, merged-by-check`・`merge, promote` | +| ai-plugins | `release.form: package-plugin` | `template, template` | +| 手動で反映 | `delivery` が `manual` だけ | `manual, manual` | +| 自動と手動の混在 | `auto` と `manual` | `merge, merged-by-check`・`manual, manual` | +| 配布しない | `delivery: []` | `none, none` | +| 宣言が無い | `delivery` が無い | `manual, manual` | +| 不明 | `delivery: {"unknown": ...}` | `manual, manual` | + +判定に必要な宣言と関数は揃っており、並列の計画(`lib/parallel_run.py`)だけがこれを読んでいない。 + +### 3. `release.plugin` では判定できない + +`.ndf/supervise.json` を持つのは ai-plugins だけである(#2624 の「根にある形」の 3。/work の下の 11 リポジトリで確認)。このため `release.plugin` が無いことは、CD があることを意味しない。手動で反映するリポジトリも同じ条件に当たり、届いていない変更へ `released` と答えることになる。 + +### 4. 配布を組めない経路は、版の読み取りのほかにもある + +- 配布の連番が上限を超えたとき(`step_build` の 225 行) +- `build_plans` が例外を上げたとき(`parallel_run.py` の `step_build` は例外を受けない) +- 承認の答えの時点でベースブランチが進み、提示 2 を組むとき。`confirm`(`parallel_run.py` の 475〜476 行)は `step_build` の `error` を見ずに `built["dir"]` を読むため、誤りが返ると `KeyError` になる + +### 5. レーンは `version` の無い `released` を受けられる + +`settle_release` は `version` が無ければ版を書かずに進む(`sprint_loop_gate2.py` の 176〜178 行)。carul の周回 6 は、手元の手当てで届いた `version` の無い `released` を受けてまとめを流した(`.ndf/handoff/loop-l20261104-ux.md`)。`test_sprint_loop.py` で `released` の答えを渡す 3 件の `version` はどれも文字列で、`version` の無い答えを読むテストは無い(同じファイルを `"version": None` で検索して 0 件)。 + +### 6. 計画を止める今の振る舞いはテストが固定している + +`test_parallel_run.py` の `test_char_build_error_fails_release_and_stops` が、誤りで計画が止まることを確かめている。この課題で意図して書き換える。 + +## 前提 + +- 前提 1: リリースの経路は、メインディレクトリの `.ndf/project.json` の `delivery` と `.ndf/supervise.json` の `release.form` から決まる。読み方は `lib/delivery.py` の `load_delivery` と `routes` に従う +- 前提 2: `released` の答えは「本番系へ届いた」を意味しない。CD の経路では「オーガナイザーが組む配布が要らない」ことを意味し、レーンはまとめへ進む。本番系へ届いたことはリリース後テストが確かめる +- 前提 3: 配布の依頼が届く Pull Request は、ベースブランチへマージ済みである。依頼を送るのは検査のステージの後である。答えを返す時点で読み直さない +- 前提 4: この課題は #2624 の違反 1(宣言を読まない経路)を、宣言を読む形で直す。その場の例外を足さないため、#2624 の根本原因を直す作業の一部として扱う +- 前提 5: 手動で反映する経路を持つリポジトリの配布は、この課題では自動にしない。`failed` と理由を答え、オーガナイザーが手で進める + +## 対象範囲 + +含む: + +- 耐久ワークフローが配布の依頼を受けたときに、リリースの経路を読んで配布を組むかを決めること +- 配布を組めないときの、依頼への答えとオーガナイザーへの問い(版の読み取り・連番の上限・組み立ての例外・提示 2) +- `parallel.py check` の警告 +- `development-workflow` の `references/parallel.md` の「まとめた配布」の節への、経路ごとの動きの記述 +- `version` の無い `released` をレーンが読むテスト + +含まない: + +- レーンの昇格のプランの組み立て(`route_waves`)を変えること(未決 1) +- 外のオーガナイザーの経路(`--external`)。`compose_external` は誤りを `stopped` で返し、計画を止めない +- 版数の繰り上げと承認資料の比べる元(#2554) +- 工程の定義を CD 前提へ改めること、ai-plugins の CD(#2623) +- 「配布」の語の置き換え(#2620)・承認ゲートの呼び名(#2619) +- 耐久ワークフローを作り直したときの保留の扱い(#2617) +- 昇格だけの周回の `{queue_prs}` の不具合(#2568) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | レーンが配布の依頼を送った | スプリントの本番のステージ | 計画が動いていなければ `parallel.py request` が 1 で止まる | — | +| E2 | 耐久ワークフローが依頼を受け付けた | `inbox` のメッセージ | 同じ依頼は 1 回だけ数える | E1 | +| E3 | 出した範囲で答えられる依頼に答えた | 流れている配布が無く、依頼が残っている | 答えられなければ依頼は残る | E2 | +| E4 | リリースの経路を読んだ | E3 の後に依頼が残っている | 読めなければ E7 | E3 | +| E5 | 配布を組まずに `released` と答えた | 経路が自動の反映か「配布しない」だけである | — | E4 | +| E6 | まとめた配布を組んだ | 経路がテンプレートである | 組めなければ E7 | E4 | +| E7 | 配布を失敗として締め、依頼へ `failed` と理由を答えた | E4 か E6 の失敗。手動の経路がある | — | E4 | +| E8 | オーガナイザーへ問いを立てた | E7 | 答えが無い間も計画は進む | E7 | +| E9 | オーガナイザーが問いへ答えた | 人の判断 | 「止める」なら計画が止まる | E8 | +| E10 | レーンが答えを読み、まとめへ進むか周回を締めた | 周回の境か `parallel.py await` | `failed` は周回を失敗で締める | E5 か E7 | +| E11 | 起動の前に検査が配布を組めないことを知らせた | `parallel.py check` | 宣言を読めなければ警告にする | — | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| まとめた配布 | オーガナイザーが複数のレーンの Pull Request を 1 つの版として出す配布。提示のプランと本番のプランからなる | +| 配布の依頼 | レーンが本番の配布をオーガナイザーへ頼む 1 件のメッセージ | +| リリースの経路 | 変更が本番系へ届く道筋。`lib/delivery.py` の `routes` が返す | +| CD | 継続的デリバリー(Continuous Delivery)。ブランチへのマージを起点に、環境への反映をリポジトリの外の仕組みが行うこと。`delivery` の `kind: auto` で宣言する | + +## 受け入れ条件 + +依頼を受けたときの判定: + +- [ ] 1. 前提: `release.form` が無く、`delivery` の行がすべて `kind: auto`(carul の形)。操作: レーンが配布の依頼を送る。結果: `answer-<依頼>` が `result: released`・`version: null` で 1 回立ち、ステップ `parallel.build` は呼ばれない +- [ ] 2. 1 の形で、配布の記録に `state: released`・依頼の Pull Request の番号・組まなかった理由が残り、`plan.md` の版の欄が `-` になる +- [ ] 3. 1 の形で、答えの後も計画は止まらず、他のレーンの観測を続け、全レーンが止まると `finished` で終わる +- [ ] 4. 開発ループのレーンが `version` の無い `released` を読むと、周回の締め直しのまとめの pipeline が返り、周回の記録に本番の版が書かれない +- [ ] 5. `delivery: []` のリポジトリでは、1 と同じく `released` が返り、`parallel.build` は呼ばれない +- [ ] 6. `kind: manual` の行が 1 行でもあり `release.form` が無いリポジトリでは、`released` と答えず、`failed` と手動の経路があるという理由を答える +- [ ] 7. `delivery` が無いか不明か読めないリポジトリでは、`released` と答えず、`failed` と宣言を読めないという理由を答える +- [ ] 8. `release.form: package-plugin` のリポジトリでは、今と同じく `parallel.build` が呼ばれ、提示のプランが書かれる(退行しない) + 前提の確認: `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_parallel_run.py plugins/ndf/scripts/tests/test_parallel_answer.py -q -n 4` → 62 件が通る(e7500d193) +- [ ] 9. 1 の形の答えの間に、本番チャネルへのマージ・Pull Request の作成・承認の記録が 1 つも行われない(承認ゲート 2 の要否を変えない) + +配布を組めないとき: + +- [ ] 10. 前提: `release.form` があり `plugin.json` の版を読めない。操作: レーンが配布の依頼を送る。結果: 依頼へ `failed` と理由が答えられ、計画は止まらず、配布の記録が `failed` になる +- [ ] 11. `build_plans` が例外を上げても、耐久ワークフローは落ちず、10 と同じ答えになる +- [ ] 12. 配布の連番が上限を超えたときも、10 と同じ答えになる +- [ ] 13. 提示 2 を組めないとき、耐久ワークフローは `KeyError` で落ちず、その配布を `failed` で締めて依頼へ理由を答える +- [ ] 14. 組めないときに、オーガナイザーへ選択肢「直してから組み直す」「止める」の問いが 1 件立つ。同じ理由が続いても問いは 1 件にまとまる +- [ ] 15. 問いへ「止める」と答えると計画が止まる。「直してから組み直す」と答えると計画は続き、次の配布の依頼で `parallel.build` が再び呼ばれる + +起動の前の検査: + +- [ ] 16. 6・7 の形か、`release.form` があって版を読めないリポジトリで `parallel.py check` を打つと、理由つきの警告が 1 件出て `metrics.warnings` が増え、終了コードは 0 のままである +- [ ] 17. 1 の形と `release.form: package-plugin` で版を読めるリポジトリでは、`parallel.py check` の警告が増えない + +テスト: + +- [ ] 18. 1 と 10 を縛るテストがあり、直す前のコードでは失敗する +- [ ] 19. `test_char_build_error_fails_release_and_stops` が 10 の振る舞いを確かめる形に書き換わっている + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 配布を組まなかった理由と組めなかった理由が、配布の記録(耐久ワークフローの結果の `releases`)に 1 件ごとに残る。理由だけで、どの宣言を直せばよいかが分かる | +| 移行性 | 記録のある途中の耐久ワークフローを新しい版で続けても、耐久ステップの並びの食い違いで落ちない。続けられない形なら、頭から流すときに受け付け済みの依頼を失わない手順を設計で決める | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる。`parallel.py check` の警告が増える。`answer-<依頼>` の `version` が `null` を取る(契約 `parallel-request` の `result` の値は変わらない)。互換性は保つ | +| データ | スキーマの変更と移行は無い。配布の記録の行に理由が載る | +| 既存の振る舞い | 配布を組めないときに計画が止まらなくなる。CD のリポジトリで依頼を受けても `parallel.build` を呼ばなくなる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| 起動 | `python3 plugins/ndf/scripts/parallel.py check <計画名>` | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_parallel_run.py plugins/ndf/scripts/tests/test_parallel_answer.py plugins/ndf/scripts/tests/test_sprint_loop.py -q -n 4` | +| 静的解析・型検査 | `ruff`(`pyproject.toml` の `[tool.ruff]`) | +| 手動確認 | carul の並列の計画で、配布の依頼を 1 回通して、依頼に配布済みの答えが返り計画が止まらずにレーンがまとめへ進むことを、リリース後テストで確かめる | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 判定は `plugins/ndf/scripts/lib/delivery.py` の既存の関数を使い、同じ役割の判定を並列の計画の側に足さない。実験版の置き場を参照しない(`AGENTS.md`) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`ruff` で確かめる | +| テスト戦略 | 耐久ワークフローの判定は `test_parallel_run.py`、レーンの読み取りは `test_sprint_loop.py`、検査は `parallel.py check` の統合テストで確かめる。`.md` の文言を照合するテストは書かない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、`ruff` の適用、宣言の読み取りを `lib/delivery.py` に寄せること | +| 確認してから行う | 公開インタフェースの変更、耐久ステップの並びの変更。どちらも承認ゲート 1 の設計で人が見る | +| 行わない | `main` へのマージを伴う処理の追加、`release.plugin` を判定に使うこと、手動の経路を自動にすること | + +## 未確認・残リスク + +- 記録のある耐久ワークフローの途中に新しい耐久ステップが入ったときの振る舞いは未確認である(設計で確かめる) +- carul のほかに、`delivery` が `auto` の行だけを持つリポジトリで並列の計画を流した実績は未確認である + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. CD のリポジトリのレーンが、昇格を直接流すか(今のコード)、配布の依頼へ置き換えるか。今のコードのままでは、レーンは他のレーンの変更を含めて `develop` を `main` へ昇格する。配布の依頼へ置き換えて `released` を返すだけなら、昇格を行う者がいなくなる。この課題の範囲外に置き、#2623 の受け入れ条件 4 で決めることを勧める | 利用者 | 承認ゲート 1 | +| 2. 「直してから組み直す」と答えたときに、`failed` と答えた依頼の Pull Request をどう届けるか(次の配布の範囲に入れるか、レーンが依頼を送り直すか) | 設計 | 設計 PR | +| 3. この課題を閉じる条件。節「根本原因の束」には #2624 の根本原因を直した後に再現の手順を通すとあるが、この課題の直しがその根本原因の一部に当たる。リリース後テストの手動確認を通した時点で閉じるかを決める | 利用者 | 承認ゲート 2 | diff --git a/issues/issue-2619-design.md b/issues/issue-2619-design.md new file mode 100644 index 000000000..c6c919822 --- /dev/null +++ b/issues/issue-2619-design.md @@ -0,0 +1,321 @@ +# 承認ゲートの呼び名: 2 つの承認ゲートを「設計承認ゲート」「リリース承認ゲート」と中身の分かる名前で呼ぶ(#2619) + +## 目的 + +- **今どうなっているか**: NDF が人の承認を求める 2 つの点を、番号つきの 3 つの語(「承認ゲート 1・2」「ゲート 1・2」`関門 1・2`)で呼んでいる。承認を求められた人は、番号から設計の承認か本番へのリリースの承認かを思い出す必要がある +- **誰に効くか**: 承認ゲートで問いと承認資料を受け取る利用者、Skill と承認資料を読む AI、Factory の画面で承認の依頼に答える利用者 +- **変えると何が成り立つか**: 名前だけでどちらの承認かが分かり、同じ点を指す語が 1 つにそろう。旧い版が書いた記録は、移行の操作なしで新しい版でも同じ意味に読める + +## 適用範囲 + +- **働く範囲**: NDF を入れたすべてのリポジトリ(人へ出す文と、承認ゲートの記録の読み書き)と、このリポジトリの Factory +- **プロジェクトごとに違うもの**: 無し(呼び名はプロジェクトの設定で変えない。用語集はリポジトリごとに持つが、NDF が出す文の語は NDF が決める) +- **当たるモード**: すべてのモード(`light` / `operation` / `legacy-refactor` / `standard` / `documentation`)。documentation モードの「企画承認」「制作物承認」は呼び名を変えない + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「承認ゲート1を「設計承認ゲート」、承認ゲート2を「リリース承認ゲート」と名称を変更します」(2026-11-06、課題 #2619 の依頼の原文) | 利用者の指示の原文 | 新しい 2 つの名前 | +| `git grep -F` の件数(要求の「今の使われ方」。`承認ゲート 2` 397 行・`ゲート 2` 472 行・`関門 2` 169 行) | 実測 | 同じ点を 3 つの語で呼んでいる | +| メインディレクトリの `.ndf/` にある承認ゲートの記録の名前(`"name": "関門 1"` 244 件・`"name": "関門 2"` 99 件、要求の「今の使われ方」) | 実測 | 旧名の記録が残り続けるため、読む側の互換が要る | +| 工程の単位の改名(#1407)の旧名の対応表 `plugins/ndf/scripts/lib/legacy_names.py` | 既存の形 | 旧名は 1 つの表にだけ書き、読むときに新しい名前へ寄せ、書くのは常に新しい名前、という形がこのリポジトリにある | + +要求と受け入れ条件は課題 #2619 の本文にある(コピーは [issues/issue-2619-requirements.md](issue-2619-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の工程(`ndf-workflow`) | 承認ゲート・設計承認ゲート・リリース承認ゲート・承認ゲートの記録・人へ出す文・機械が読む識別子 | +| Factory(`factory`) | 承認の依頼とその種類(`gate1` / `gate2` / `decision`)・画面の表示 | + +**Factory は NDF の工程に対して腐敗防止層を置く。** Factory のランナー(`factory/runner/src/translate.ts`)は、Factory の承認の依頼の種類を NDF の承認ゲートの記録の名前へ翻訳する。翻訳した名前で `sprint-state.py gate` を呼ぶ。Factory の種類の値は NDF の呼び名の変更に引きずられず、翻訳の層だけが NDF の名前を知る。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| スプリント状態 | `sprint-state.py gate`(`mvv-gate.py`・開発ループ・並列の計画・Factory のランナーは、このコマンドを呼んで書く) | スプリント状態ファイル | 承認ゲートの記録(`gates[]`)・取り消しの記録(`withdrawals[]`) | 承認ゲートの名前 | +| 差し戻しの記録 | `sprint-state.py gate --outcome rejected` と `gate-rejections.py record`(どちらも `lib/gate_rejections.py` の書き込みを使う) | `gate-rejections.jsonl` の 1 行 | — | 承認ゲートの名前 | +| スプリントの目録 | `supervise.py new sprint`(`supervise_lib/sprint.py`) | 目録の `sprint.json` | ステージ(`ステージ[]`) | ステージの名前(承認ゲートのステージは承認ゲートの名前を使う) | + +**承認ゲートの名前は値オブジェクトとして扱う。** 正の名前は「設計承認ゲート」「リリース承認ゲート」の 2 つと、MVV の承認の記録の `MVV` である。旧名(`関門 1` / `関門 2`)は読むときに正の名前へ寄せ、正の名前どうしで比べる。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | スプリント状態・差し戻しの記録・スプリントの目録 | 新しい版は承認ゲートの旧名(`関門 1` / `関門 2`)を書かない。旧名で呼ばれたら、正の名前(「設計承認ゲート」「リリース承認ゲート」)へ寄せて書く。旧名でも正の名前でもない名前(「MVV」を含む)は、今と同じく受けてそのまま書く | テストが落ちる(書いた JSON に旧名が残る、または旧名でない名前が書き換わる) | +| I2 | スプリント状態・差し戻しの記録・スプリントの目録 | 旧名(`関門 1` / `関門 2`)の記録は、正の名前の記録と同じ承認ゲートとして読まれる。承認済みの判定・差し戻しの件数・改訂の兆候の件数・取り消しの判定・続きの queue の組み立てが、名前を正の名前に書き換えた記録と同じ結果になる | 旧い記録で再開したループが承認済みの承認ゲートを未承認と判定して止まる。テストで落とす | +| I3 | スプリント状態 | 同じ承認ゲート・同じ Pull Request の利用者の承認の記録は 1 件に置き換わる。旧名の記録も置き換えの対象になる | 旧名と正の名前の記録が並び、新旧の記録の数が食い違う。テストで落とす | +| I4 | (定数) | NDF のスクリプトの中では、承認ゲートの正の名前は `lib/gate_answer.py` の 1 か所、旧名との対応は `lib/legacy_names.py` の 1 か所にだけ書く。Factory のランナーは NDF を読み込めないため、対応を `factory/runner/src/translate.ts` の 1 か所に持つ(X2) | 別のファイルに同じ文字列の定数があると、片方だけが変わる。テストで落とす | +| I5 | (識別子) | 機械が読む識別子のうち、問いの区分 `ndf-gate:1` / `ndf-gate:2`、Factory の種類 `gate1` / `gate2`、承認ラベル `design-approved`、報告の結果の値 `関門` は値を変えない | hook が問いを拒む・Factory の依頼が表示されない・再開が止まる。既存のテストが落ちる | +| I6 | (識別子) | Factory の承認の依頼は、API・ランナー・画面で同じ種類の値(`gate1` / `gate2` / `decision`)を使う | 依頼が表示されないか、答えが写らない。既存の契約のテストが落ちる | + +### ドメインイベント + +要求の「ドメインイベント」の番号を引き継ぐ。 + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 新しい版の NDF が利用者の環境へ入った | 利用者のプラグインの更新 | 以後のすべての読み書き(I1 の書き込みが始まる) | +| E2 | 実行中のループ・並列の計画が、新しい版で再開した | `supervise.py queue --resume`・relay | 承認済みの判定と続きの queue の組み立て(I2) | +| E3 | スクリプトが承認ゲートで止まり、人へ問いと承認資料を出した | 承認ゲートに当たるステップ | conductor の `AskUserQuestion` と hook の区分の検査(I5) | +| E4 | 人が承認ゲートに答え、承認ゲートの記録が書かれた | `sprint-state.py gate` | 承認済みの判定・差し戻しの集計・改訂の兆候の集計(I1・I3) | +| E5 | Factory が承認の依頼を表示し、答えをランナーが NDF の記録へ写した | Factory の API とランナー | `sprint-state.py gate`(I1・I6) | +| E6 | 新旧の版が混ざった環境で、同じ記録を両方の版が読み書きした | 一部のセッションだけ更新済み | 受け手を設けない。旧い版が新しい名前を読むことは互換の対象外とする(決定 4) | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 設計承認ゲート | 設計 Pull Request のマージの前に人の承認を求める承認ゲート。documentation モードでは企画承認に当たる | 追加(`ndf-workflow`。廃止した語に「承認ゲート 1」「ゲート 1」`関門 1`) | +| リリース承認ゲート | 本番系へ届く操作(本番へのリリース・operation の実行・自動反映の本番チャネルへのマージ)の前に人の承認を求める承認ゲート。documentation モードでは制作物承認に当たる | 追加(`ndf-workflow`。廃止した語に「承認ゲート 2」「ゲート 2」`関門 2`) | +| 承認ゲート | 人手の承認を求める点。設計承認ゲートとリリース承認ゲートの 2 つだけ | 意味の変更(意味の文の「ゲート 1」「ゲート 2」を新しい 2 語へ) | +| ゲート 1 | — | 廃止(設計承認ゲートの廃止した語へ移す) | +| ゲート 2 | — | 廃止(リリース承認ゲートの廃止した語へ移す) | +| 人へ出す文 | Skill・references・エージェントの定義の本文と、スクリプト・hook が人へ見せる文 | 追加(要求の段階で用語集に載せた) | +| 機械が読む識別子 | スクリプトが値の一致で読む文字列 | 追加(要求の段階で用語集に載せた) | + +**新しい 2 語は要求の段階で用語集に載っている。廃止した語の追加・「承認ゲート」の意味の変更・「ゲート 1」「ゲート 2」の削除は実装の中で行う。** 設計のブランチの CI(`.github/workflows/glossary.yml`)は追加した行の廃止した語を検査する。今の段階で廃止した語を足すと、要求のコピーの 12 件が当たって落ちる。要求のコピーは `spec-copy.py write` で作るため、手で直さない。落ちることは、廃止した語を足した用語集で `glossary.py check --diff origin/develop --rules all` を実行して確かめた(2026-11-05)。実装のブランチの CI は用語集の形と文書の一致だけを見る。 + +## 外部の調査 + +省く: プロジェクトの中で使う呼び名の変更と、このリポジトリの記録の互換の形で決まり、外部の標準・製品・ライブラリの判断が要らない。旧名の読み方は既存の `lib/legacy_names.py`(#1407)の形に揃える。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 承認ゲートの問い・承認資料・報告・案内が、2 つの承認ゲートを新しい名前で呼ぶ | 承認ゲートで答える利用者 | +| F2 | Skill・references・エージェントの定義・確定仕様・リポジトリ知識の文書が、新しい名前で書かれる | NDF を読む AI と人 | +| F3 | 用語集が新しい 2 語を持ち、旧い呼び名を廃止した語として引ける | 文書を書く人と AI、用語チェック | +| F4 | 旧い版が書いた承認ゲートの記録を、新しい版が同じ意味に読む | 実行中のループ・並列の計画を再開する conductor と supervisor | +| F5 | 旧名で `sprint-state.py gate` を呼んでも、新しい名前で記録される | 旧い手順書を読んだ AI、Factory のランナー | +| F6 | Factory の画面が 2 つの承認ゲートを新しい名前で表示する | Factory の利用者 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 承認ゲートの名前(`plugins/ndf/scripts/lib/gate_answer.py`) | 正の名前 2 つ(定数 `DESIGN_GATE` / `RELEASE_GATE`)と、名前を正の名前へ寄せる関数 `canonical()` を持つ。`approved()` と `replaces()` は `canonical()` で比べる。今の定数 `GATE_1` / `GATE_2` を置き換える | +| 旧名の対応表(`plugins/ndf/scripts/lib/legacy_names.py`) | 承認ゲートの名前の旧名の表(正の名前 → 旧名の並び)を足す。旧名はこの表にだけ書く。読み取りの種類として扱い、`MODE` に関わらず読む。`sprint-state.py gate` と `gate-rejections.py record` が旧名を受け付けるのも `MODE` に関わらず続け、呼び出しの種類の止め方(`_tell()`。`MODE="refuse"` で終了コード 2)を使わない。Factory のランナーが旧名を渡し続けるため(決定 5)である。案内の文は `rename_notice()` を使わず、期限(`WHEN`)を含まない承認ゲート用の文を置く | +| ループの記録(`lib/sprint_loop_record.py`) | 自分で持つ定数 `GATE_1` / `GATE_2` を消し、`gate_answer` から読む | +| 改訂の兆候(`lib/project_mvv_signals.py`) | `GATE_KEYS` を正の名前の定数から作り、引くときは `canonical()` を通す | +| 差し戻しの集計(`lib/gate_rejections.py`・`gate-rejections.py`) | 記録の有無と件数を `canonical()` で仕分ける。`record --gate` は旧名を受けて正の名前で書く | +| 取り消し(`lib/sprint_mvv.py`) | `withdraw()` と `withdrawn()` を `canonical()` で比べる | +| 開発ループの承認ゲート(`lib/sprint_loop_gate.py`・`lib/sprint_loop_gate2.py`) | 目録のステージの名前・承認の記録・差し戻しの記録を `canonical()` で比べる | +| 並列の計画(`lib/parallel_external.py`・`lib/parallel_run.py`・`lib/parallel_release.py`) | 打つコマンドの承認ゲートの名前を定数から作り、記録を `canonical()` で比べる | +| 記録を書くコマンド(`sprint-state.py gate`) | 受けた名前を `canonical()` で寄せてから書く。旧名で呼ばれたら案内を標準エラーへ 1 行出す | +| 人へ出す文を持つスクリプト(`mvv-gate.py`・`supervise.py`・`supervise_lib/`・`release_lib/`・`relay_lib/`・`lib/ask_policy.py` ほか) | 問い・承認資料・報告・案内・エラーの文と、それを作る定数を新しい名前へ | +| Skill・references・エージェントの定義(`plugins/ndf/skills/`・`plugins/ndf/agents/director.md`) | 本文の呼び名を新しい名前へ。`sprint-state.py gate` のコマンドの例も正の名前で書く | +| 用語集(`docs/glossary/glossary.json`・`docs/glossary.md`・`plugins/ndf/skills/development-workflow/references/glossary.md`) | 用語の表のとおりに直し、`glossary.py render` で作り直す | +| 確定仕様とリポジトリ知識の文書(`docs/specifications/`・`docs/ndf-experiments.md`・`docs/versioning-and-distribution.md`・`AGENTS.md`) | 呼び名を新しい名前へ。判断の中身は変えない | +| Factory のランナー(`factory/runner/src/translate.ts`・`runtime.ts`・`main.ts`) | 種類から `sprint-state.py gate` に渡す名前を作る関数は旧名を渡し続ける(決定 5)。`runtime.ts` の `recordAnswer` は、今 1 つの変数 `gate` を `sprint-state.py gate` の引数と `rejectionDirective()` の両方へ渡している。これを記録用の名前(`answer.gate ?? gateRecordName(answer.kind)`。旧名のまま、X2)と表示用の名前に分ける。表示用の名前は、`translate.ts` に置く関数 `gateDisplayName()` が記録用の名前を正の名前へ寄せて作る(`answer.gate` が来た経路でも記録と表示が食い違わない)。旧名と正の名前の対応は、`gateRecordName()` と同じ種類と旧名の対から作り、`translate.ts` の中の 1 か所にだけ置く。インストールした NDF の `canonical()` は呼ばない(決定 5 のとおり、この変更より前の版の NDF には `canonical()` が無い)。`rejectionDirective()` は表示用の名前を受け、文中で承認ゲートを旧名で呼んでいる箇所も「承認ゲートを通さず」へ直す | +| Factory の画面と契約の説明(`factory/web/src/i18n/ja.json`・`factory/web/src/pages/`・`factory/contracts/openapi/*.yaml`・`factory/contracts/schemas/progress-event.schema.json`・`factory/api/src/organizer/`) | 表示と説明文を新しい名前へ。種類の値は変えない。`factory/contracts/src/generated/` は手で直さず、契約から作り直す | + +```mermaid +graph TD + subgraph 書く側 + SS[sprint-state.py gate] + GR[gate-rejections.py record] + RN[Factory のランナー] + CALL[mvv-gate.py・開発ループ
並列の計画] + end + subgraph 名前 + GA[gate_answer
正の名前と canonical] + LN[legacy_names
旧名の表] + end + subgraph 読む側 + RD[承認済みの判定・取り消し
差し戻しの集計・改訂の兆候
続きの queue] + end + CALL --> SS + RN -->|旧名を渡す| SS + SS --> GA + GR --> GA + GA --> LN + RD --> GA +``` + +## データ構造 + +**スキーマは変えない。値の集合だけを変える。** 既存の記録は書き換えない(要求の「境界」)。 + +| 記録 | 欄 | 新しい版が書く値 | 新しい版が読む値 | +| --- | --- | --- | --- | +| スプリント状態ファイルの `gates[]` | `name` | `設計承認ゲート` / `リリース承認ゲート` / `MVV` | 書く値に加えて `関門 1` / `関門 2` | +| スプリント状態ファイルの `withdrawals[]` | `name` | 同上 | 同上 | +| `gate-rejections.jsonl` | `gate` | `設計承認ゲート` / `リリース承認ゲート`(ほかの名前は今と同じく受けて `other` に数える) | 書く値に加えて `関門 1` / `関門 2` | +| スプリントの目録 `sprint.json` の `ステージ[]` | `name`(承認ゲートのステージ) | `設計承認ゲート` / `リリース承認ゲート` | 書く値に加えて `関門 1` / `関門 2` | +| `mvv-gate.jsonl` | `gate` | `design` / `release`(変えない) | 同左 | + +**旧名として読むのは `関門 1` / `関門 2` だけである。** 既存の記録には `"name": "承認ゲート 2"`(4 件)と `"name": "ゲート 2"`(2 件)もある。これらは今の版でも承認ゲートの記録として読まれていない。`gate_answer.approved()` は `関門 2` との一致だけを見るためである。旧名に加えると判定と件数が変わり、受け入れ条件 4 の「変更の前と同じ判定・件数」が破れる。 + +## 入出力の契約 + +| 項目 | `sprint-state.py gate <状態> <名前>` | +| --- | --- | +| 入力 | `<名前>` に `設計承認ゲート` / `リリース承認ゲート` / `MVV` / 旧名 `関門 1` / `関門 2`。ほかの名前は今と同じく受けてそのまま書く | +| 出力 | 結果 JSON の形は変えない。`summary` の名前は正の名前で出す | +| 失敗の形 | 変えない | +| 互換性 | 旧名で呼んだ既存の呼び出し側(旧い手順書を読んだ AI・Factory のランナー)は壊れない。旧名で呼ばれたら、期限を含まない案内(例: `関門 1 は 設計承認ゲート へ改名した。設計承認ゲート で呼ぶ`)を標準エラーへ 1 行出し、正の名前で書く。旧名の受け付けは `legacy_names.MODE` に関わらず続け、終了コード 2 で止めない(決定 2・決定 5) | + +`gate-rejections.py record --gate <名前>` と `count` の出力も同じ扱いにする(旧名を受け、正の名前で書き、件数の仕分けは `canonical()` で行う)。 + +**Factory の契約は、種類の値と形を変えない。** 契約は `factory/contracts/openapi/runs.yaml`・`organizer.yaml`・`progress-event.schema.json` である。 変えるのは説明文の呼び名だけである。`ApprovalAnswer` の説明の `関門 1 / 関門 2 へ写す` は「NDF の承認ゲートの記録の名前へ写す」に直す。契約の検査は既存の `factory/contracts/tests/` が見る。 + +**画面**(`factory/web/src/i18n/ja.json`)は次の 3 つの表示を直す。キーは変えない。 + +| キー | 今の表示 | 新しい表示 | +| --- | --- | --- | +| `sprintDetail.gate1` | ゲート 1 | 設計承認ゲート | +| `sprintDetail.gate2` | ゲート 2 | リリース承認ゲート | +| `loopStop.gate2_returned` | 承認ゲート 2 で差し戻された | リリース承認ゲートで差し戻された | + +## 処理の流れ + +承認ゲートの記録を書くときと読むときの流れ。どちらも `canonical()` を 1 回通す。 + +```mermaid +sequenceDiagram + participant C as 呼ぶ側 + participant S as sprint-state.py gate + participant G as gate_answer + participant R as 読む側 + C->>S: 名前(旧名でもよい) + S->>G: canonical(名前) + G-->>S: 正の名前 + S->>S: 同じ承認ゲートと PR の記録を置き換えて書く + R->>G: canonical(記録の name) + G-->>R: 正の名前 + R->>R: 正の名前どうしで比べる +``` + +**置き換え(I3)は、既存の記録の名前も `canonical()` で寄せてから比べる。** 旧い版が `関門 1` で書いた記録に、新しい版が同じ Pull Request の `設計承認ゲート` の承認を書くと、旧い記録は置き換わる。 + +人へ出す文の置き換えは実行時の流れを持たない。実装の順序は次のとおりにする。 + +1. `gate_answer` と `legacy_names` に名前と旧名の表を置き、読む側をすべて `canonical()` へ寄せる(記録の互換を先に固める) +2. スクリプトの定数と人へ出す文を新しい名前へ +3. Skill・references・エージェントの定義・確定仕様・リポジトリ知識の文書を新しい名前へ +4. 用語集を直して `render` する +5. Factory の表示と説明文を直し、生成物を作り直す + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 移行性 | 旧い版が書いた記録(承認ゲートの記録の名前・問いの区分・並列の依頼の種類)を、新しい版が移行の操作なしで読める。利用者に記録の書き換えを求めない | 記録の名前は読むときに `canonical()` で正の名前へ寄せる。問いの区分と並列の依頼の種類は値を変えない(決定 2・3) | 旧名と正の名前の両方を入力にしたテスト(テスト設計の I2・I3)。メインディレクトリの `.ndf/loop` と `.ndf/` の既存の記録を、変更の前と後の版の集計に通して件数を比べる | +| 運用・保守性 | 承認ゲートの名前を表す文字列は、スクリプトの中で 1 か所の定数から読む | 正の名前は `gate_answer.py`、旧名は `legacy_names.py` にだけ置く。`sprint_loop_record.py` と `project_mvv_signals.py` の定数は `gate_answer` から作る | テスト設計の I4 | + +## 決定の記録 + +### 決定 1: 名前だけでどちらの承認か分かるよう、承認ゲートの記録の名前を新しい名前へ変える + +承認ゲートの記録の名前(`関門 1` / `関門 2`)は機械が読む識別子だが、`sprint-state.py gate <状態> "関門 1"` のように手順書のコマンドに現れ、人と AI が打つ値でもある。名前を残すと、Skill と references のコマンドの例に旧い語が残り続け、受け入れ条件 2 の例外が手順書の全体へ広がる。新しい版は正の名前で書き、旧名は読むときだけ受け付ける。 + +記録の名前を残して表示だけを変える形は採らない。人が打つコマンドと人が読む文の語が食い違い、1 語 1 意味が崩れる。 + +根拠: Value 8 / Vision(MVV 版 5) + +### 決定 2: 再開が承認済みの承認ゲートで止まらないよう、旧名の記録は読むときに正の名前へ寄せ、書き換えない + +旧名と正の名前の対応は `legacy_names.py` の 1 つの表に置き、読む側はすべて `gate_answer.canonical()` を通して比べる。#1407 の工程の単位の改名が同じ形(旧名はこの表にだけ書き、読むのは `MODE` に関わらず、書くのは新しい名前)で運用されており、同じ規則に揃える。既存の記録(`.ndf/` の JSON・JSONL)は書き換えない。書き換えは利用者のデータの変更(C3)に当たり、移行の操作を利用者に求めることにもなる。 + +旧名の表を消す時期は、旧名の記録を読む必要が無くなったときとする。既存の記録はリポジトリの `.ndf/` に残り続けるため、この設計では期限を置かない。読み取りの表の 1 行は、残しても既定の振る舞いを変えない。 + +- 作り方: 自前で作る +- 比べた候補: 無し(外部の調査を省いた) + +根拠: Value 7 / C3(MVV 版 5) + +### 決定 3: hook と Factory の契約を壊さないよう、ASCII の識別子(`ndf-gate:1/2`・`gate1/gate2`・`design-approved`)は値を変えない + +問いの区分は `AskUserQuestion` の `metadata.source` に入り、hook(`hook_lib/ask_guard.py`)が値の一致で許す。Factory の種類は API・ランナー・画面と進行のイベントの契約(`progress-event.schema.json`)が共有する。どちらも人へ出す文ではなく、値を変えると旧い区分と旧い種類を読む経路を hook・API・画面の 3 か所に持つことになる。人へ出す説明(`ask_policy.py` の区分の説明、hook の案内の文)だけを新しい名前へ直す。 + +承認ラベル `design-approved` は既に設計の承認を表す語で、番号を持たない。変える理由が無い。 + +根拠: Value 3 / Value 6(MVV 版 5) + +### 決定 4: 承認ゲートの変更を名前に限るため、報告の結果の値 `関門` は変えず、例外の一覧に載せる + +フェーズレポートの `結果: 関門` と、queue の記録の `result: "関門"` は、承認ゲートで止まったことを表す機械が読む識別子である。この値は、`supervise_lib/` の queue・再開・流し直しの判定(`FINISHED`・`RESUMABLE`)と、開発ループと並列の計画が、合わせて 17 ファイルで値の一致で読む。実行中の queue の記録にも保存されている。値を変えると、旧い記録を読む経路をそのすべてに足すことになり、要求が求める範囲(承認ゲートの 2 つの呼び名)を超える。値は残し、値を説明する文(`関門を返した` など)だけを「承認ゲート」へ直す。 + +E6(旧い版が新しい版の書いた記録を読む)も互換の対象に含めない。旧い版の読む側を新しい版から直す手段が無く、一部のセッションだけ更新した状態は再起動で解ける。`CHANGELOG.md` の新しい版の項に、すべてのセッションを更新してから再開するよう書く。 + +根拠: Value 3(MVV 版 5) + +### 決定 5: Factory がどの版の NDF を入れても承認を記録できるよう、ランナーは `sprint-state.py gate` へ旧名を渡し続ける + +Factory のランナーは、ジョブごとに指定された版の NDF を入れる。入れる箇所は `factory/runner/src/main.ts` の `installNdf(instruction.NDF_VERSION)` である。新しい名前を渡すと、この変更より前の版の NDF は承認を読めず、承認済みの承認ゲートで止まり続ける。旧名なら、前の版はそのまま読み、新しい版は `canonical()` で正の名前へ寄せて書く。ランナーの差し戻しの指示の文は人へ出す文である。今の `runtime.ts` は同じ変数を記録と指示の文の両方へ渡しているため、記録用の名前(旧名のまま)と表示用の名前(記録用の名前を正の名前へ寄せたもの)の 2 つの変数に分ける。表示用の名前は種類からではなく記録用の名前から作り、`answer.gate` が来た経路でも記録と同じ承認ゲートを指すようにする。表示用の名前への対応は `translate.ts` の `gateDisplayName()` が持ち、インストールした NDF の `canonical()` は呼ばない。ランナーが入れる NDF の版は Factory の利用者が選ぶため、前の版を入れたジョブでは `canonical()` を呼べない。 + +旧名を渡すのをやめるのは、Factory がこの変更より前の版の NDF をジョブで入れなくなったときとする。 + +根拠: Value 6 / Value 1(MVV 版 5) + +### 決定 6: 確定仕様を読む人が今の語で読めるよう、`docs/specifications/` の呼び名も新しい名前へ直す + +確定仕様は主題ごとの今の仕様で、リリースのたびに段落が移されて書き足される。書いた日の記録(発表資料・記事・アーカイブ・開発履歴)とは扱いが違う。用語チェックも `docs/specifications/*.md` を語の出所として読む(`.ndf/glossary.json` の `source_paths`)。直すのは呼び名だけで、判断の中身・版・課題の番号は変えない。 + +根拠: Value 8(MVV 版 5) + +### 決定 7: 残した旧い呼び名を確かめられるよう、受け入れ条件 1・2 の例外を次の一覧に限る + +`git grep -nE '(承認ゲート|ゲート|関門) ?[12]'` と `git grep -n '関門'` を対象範囲に当てたとき、ヒットしてよいのは次の行だけである。ほかのヒットは直す。 + +| # | 残す箇所 | 理由 | +| --- | --- | --- | +| X1 | `plugins/ndf/scripts/lib/legacy_names.py` の旧名の表 | 旧い記録を読むため(決定 2) | +| X2 | `factory/runner/src/translate.ts` の `sprint-state.py gate` へ渡す名前、`gateDisplayName()` の旧名と正の名前の対応、それらの期待値を持つ `factory/runner/src/runner.test.ts` | どの版の NDF でも承認を記録するため(決定 5) | +| X3 | 報告の結果の値 `関門` を値として書く・比べるコード(`lib/durable.py`・`lib/parallel_external.py`・`lib/sprint_loop.py`・`lib/sprint_loop_gate.py`・`lib/sprint_loop_gate2.py`・`lib/sprint_loop_record.py`・`parallel.py`・`supervise_lib/actuals.py`・`commands.py`・`engine.py`・`flow.py`・`plan.py`・`queue.py`・`queue_fill.py`・`queue_resume.py`・`release_templates.py`・`state.py`)と、値をコードの記法(`` `結果: 関門` ``)で示す Skill と references の行 | 機械が読む識別子のため(決定 4) | +| X4 | 旧名を入力にした互換のテスト(`plugins/ndf/scripts/tests/` の I2・I3 のテスト) | 受け入れ条件 4 | +| X5 | 用語集の廃止した語(`docs/glossary/glossary.json` の `deprecated` と、それを描いた `docs/glossary.md` の行) | 旧い呼び名を引けるようにするため(受け入れ条件 3) | +| X6 | `.ndf/mvv.json` の版の `reason`(MVV の過去の版を承認した理由の記録) | 承認した時点の記録で、書き換えると承認の経緯が変わる | +| X7 | `CHANGELOG.md` のこの変更の版の項で、旧い呼び名を挙げて改名を知らせる行 | 改名のアナウンスに旧い語が要る | + +`plugins/ndf/scripts/lib/lock-common.sh` の `関門` はロックの排他の意味で、承認ゲートではない。例外にせず「排他の判定」に言い換え、`関門` の当たりを承認ゲートの例外だけにする。`lib/step_result.py` の `EXIT_GATE` の注釈の `関門` は「承認ゲート」に直す。 + +根拠: Value 8 / Value 4(MVV 版 5) + +## テスト設計 + +`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T)。文書の呼び名は受け入れ条件 1・2 の `git grep` で確かめる。 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・2 | 対象範囲の `git grep` のヒットが、決定 7 の X1〜X7 に当たる行だけになる(実装の最後に手で当て、Pull Request の本文に結果を載せる) | 人へ出す文に旧い呼び名を 1 つ残すと、例外に当たらないヒットが出る | +| 受け入れ条件 3 | `glossary.py render` の後の `docs/glossary.md` と `development-workflow/references/glossary.md` の表が、用語の表と同じ語と廃止した語を持つ。`glossary.py check` が用語集の検査で 0 を返す | 「ゲート 1」の独立した語を残すと、廃止した語との重なりで検査が落ちる | +| 受け入れ条件 4・I2 | 旧名の記録を入れたスプリント状態ファイルで、承認済みの判定(`gate_answer.approved()`)・差し戻しの件数(`gate_rejections`)・改訂の兆候の件数(`project_mvv_signals`)・取り消しの判定(`sprint_mvv.withdrawn()`)・続きの queue の組み立て(`sprint_loop_gate.continuation()`)・開発ループのリリース承認ゲートの答え(`sprint_loop_gate2`)・並列の計画の MVV 判定の通過(`parallel_external`)が、名前を正の名前に書き換えた記録と同じ結果を返す | 読む側の 1 つが `canonical()` を通さずに比べると、旧名の側だけ未承認・0 件になる | +| I1 | `sprint-state.py gate` と `gate-rejections.py record` に旧名を渡すと、書いた JSON の名前が正の名前になり、標準エラーに案内が 1 行出る | 受けた名前をそのまま書くと、旧名が記録に残る | +| I3 | 旧名で書かれた同じ PR の承認の記録がある状態で正の名前の承認を書くと、承認の記録は 1 件になる | `replaces()` が既存の名前を寄せずに比べると 2 件になる | +| I4 | スクリプトの中で、承認ゲートの正の名前の文字列が `gate_answer.py` の外に定数として現れず、旧名の文字列が `legacy_names.py` と X2・X3 の外に現れない(`plugins/ndf/scripts/` を走査するテスト) | `sprint_loop_record.py` に定数を残すと落ちる | +| 受け入れ条件 5・I5 | 既存の hook のテスト(`tests/test_ask_guard.py`)が `ndf-gate:1` / `ndf-gate:2` の問いを通す | 区分の値を変えると落ちる | +| 受け入れ条件 6・I6 | 既存の Factory の契約と画面のテストが種類 `gate1` / `gate2` で通り、画面のテストの表示の期待値が新しい名前になる | 種類の値を変えるか、表示のキーを変えると落ちる | +| 受け入れ条件 7 | 全体テスト・Factory のテスト・CI が通る | — | +| 受け入れ条件 8 | 変更した文書に `glossary.py check --file` を当て、廃止した語の当たりが決定 7 の例外だけになる | 本文に「ゲート 2」を残すと当たる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2619 | 実装する | — | `plugins/ndf/scripts/`、`plugins/ndf/skills/`、`plugins/ndf/agents/director.md`、`docs/specifications/`、`docs/glossary/glossary.json`、`docs/glossary.md`、`docs/ndf-experiments.md`、`docs/versioning-and-distribution.md`、`AGENTS.md`、`factory/runner/src/`、`factory/web/src/`、`factory/api/src/organizer/`、`factory/contracts/` | + +## 設計承認での利用者の答え(2026-11-06) + +答えの正本は #2619 の本文の節「設計の承認ゲート 1 での利用者の答え(2026-11-06)」にある。この文書へは次の 2 点を反映した。 + +| 問い | 答え | 反映した箇所 | +| --- | --- | --- | +| #2620(「配布」を「デプロイ」「リリース」に分ける)との順序 | 1 つのスプリントにまとめ、#2620 と同じスプリントで実装する。重なった行(「本番への配布の承認」など)は、後に入る方が先の差分へ合わせ直す | — | +| `factory/` の 42 行・17 ファイルと決定 5 | 範囲に残す。nd-factory へ移す(#2646)前に ai-plugins で置き換え、そのまま移す。このスプリントの `factory/` の変更は #2646 より先にマージする | 決定 5、「設計の結果」の触るファイル | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| Factory のジョブが入れる NDF の版 | ランナーが旧名を渡すのをやめる時点(決定 5)は、Factory がジョブで入れる NDF の版の下限を決めたときに決まる。下限の決め方はこの設計では確かめていない | +| 置き換えの規模 | 2026-11-05 の `git grep` で、対象範囲の旧い呼び名は約 1,240 行・約 235 ファイル(`plugins/ndf/scripts/` 680 行・113 ファイル、うちテスト 46 ファイル。`plugins/ndf/skills/` 200 行・48 ファイル、`docs/specifications/` 265 行・49 ファイル、`factory/` 42 行・17 ファイル)。行の機械的な置き換えでは文が崩れる箇所(`関門で止まる` など、番号を持たない言い方)は 1 行ずつ読んで直す | +| 実行中の queue の記録 | 実行中の queue に書かれたプランの `question` などの文は旧い呼び名のまま残る。再開の判定は名前ではなく結果の値で行うため振る舞いは変わらないが、再開した後に人へ出る文に旧い呼び名が混ざることがある | diff --git a/issues/issue-2619-requirements.md b/issues/issue-2619-requirements.md new file mode 100644 index 000000000..77caa5b56 --- /dev/null +++ b/issues/issue-2619-requirements.md @@ -0,0 +1,165 @@ +# #2619: 2 つの承認ゲートの呼び名を「設計承認ゲート」「リリース承認ゲート」へ変える(`承認ゲート 1・2` / `ゲート 1・2` / `関門 1・2` を置き換える) + +正は課題の本文(#2619)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> 承認ゲート1を「設計承認ゲート」、承認ゲート2を「リリース承認ゲート」と名称を変更します。 +> issueを起票して若干早めで着手してください。 + +(書き手: 利用者、日付: 2026-11-06。並列の計画 p3 のオーガナイザーの会話で受けた) + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。設計承認ゲート(今の承認ゲート 1)で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 「ゲート 1・2」`関門 1・2`も同じものを指す呼び名として置き換える | `git grep` の結果(下の「今の使われ方」)。同じ 2 つの点を 3 つの語で呼んでいる | 受け入れ条件 1 | +| S2 | 用語集に新しい 2 語を載せ、旧い呼び名を廃止した語として引けるようにする | 用語集の規則(1 語 1 意味。`requirements-design` の手順 3b) | 受け入れ条件 2 | +| S3 | 名前の変更で振る舞いを変えず、既存の記録が新しい版でも読める | 記録に`関門 1``関門 2`が値として保存されている(下の「今の使われ方」) | 受け入れ条件 3・4 | +| S4 | 「配布」を「デプロイ」「リリース」に分ける #2620 と組にして進める | 触るファイルが重なる | 前提 3 | +| S5 | 書いた日の記録(発表資料・記事・過去の課題・アーカイブ)は直さない | `AGENTS.md` の「ドキュメント」の表(発表資料と記事は「書いた日時点の記録で、以後の構成変更には追随しない」) | 含まない | + +## 目的 + +NDF が人の承認を求める 2 つの点を、番号でなく中身の分かる名前で呼ぶ。承認を求められた人が、どちらの承認か(設計を承認するのか、本番へのリリースを承認するのか)を名前だけで分かる状態にする。あわせて、同じ点を「承認ゲート」「ゲート」`関門`の 3 つの語で呼んでいる状態を、1 語 1 意味にそろえる(プロジェクト MVV の Value 8「語をそろえる」・Vision「番号でなく中身の分かる語」)。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 設計承認ゲート | 設計 Pull Request のマージの前に人の承認を求める点。documentation モードでは企画承認に当たる。今の `承認ゲート 1`・`ゲート 1`・`関門 1` | +| リリース承認ゲート | 本番系へ届く操作(本番へのリリース・`operation` の実行・自動反映の本番チャネルへのマージ)の前に人の承認を求める点。documentation モードでは制作物承認に当たる。今の `承認ゲート 2`・`ゲート 2`・`関門 2` | +| 承認ゲート | 上の 2 つの総称 | +| 人へ出す文 | Skill・references・エージェントの定義の本文と、スクリプト・hook が人へ見せる文(問い・承認資料・報告・案内・エラーの文) | +| 機械が読む識別子 | スクリプトが値の一致で読む文字列。問いの区分 `ndf-gate:1` / `ndf-gate:2`、並列の依頼と Factory の契約の種類 `gate1` / `gate2`、スプリント状態ファイルとループの記録に書く承認ゲートの記録の名前(`関門 1` / `関門 2`)、承認ラベル `design-approved` | + +## 今の使われ方 + +2026-11-05、develop 75fc92737 の `git grep -F`。対象は `plugins` `docs` `AGENTS.md` `CLAUDE.md` `README.md` `factory` `.ndf`。 + +| 書き方 | 行 | ファイル | +| --- | ---: | ---: | +| `承認ゲート 1` | 175 | 73 | +| `承認ゲート 2` | 397 | 111 | +| `ゲート 1` | 261 | 102 | +| `ゲート 2` | 472 | 135 | +| `関門 1` | 159 | 44 | +| `関門 2` | 169 | 56 | +| `関門`(数字の無いものを含む) | 629 | 128 | +| `ndf-gate:1`(問いの区分) | 38 | 19 | +| `ndf-gate:2`(問いの区分) | 36 | 27 | +| `gate1` | 149 | 39 | +| `gate2` | 185 | 50 | +| `設計承認ゲート` | 0 | 0 | +| `リリース承認ゲート` | 0 | 0 | + +**記録に値として保存されている呼び名がある。** スプリント状態ファイルの承認ゲートの記録の名前は `関門 1` / `関門 2` で、`plugins/ndf/scripts/lib/gate_answer.py:17`・`lib/sprint_loop_record.py:68`・`lib/project_mvv_signals.py:122` が名前の一致で読み、Factory のランナー(`factory/runner/src/translate.ts:159`)が `gate1` / `gate2` を `関門 1` / `関門 2` へ写して書く。メインディレクトリの `.ndf/` の既存の記録には、この値が次の数だけある(`grep -rhoE '"(gate|name)": ?"(関門|承認ゲート|ゲート) ?[12]"' .ndf`)。 + +| 値 | 件数 | +| --- | ---: | +| `"name": "関門 1"` | 244 | +| `"name": "関門 2"` | 99 | +| `"gate": "関門 1"` | 10 | +| `"gate": "関門 2"` | 15 | +| `"name": "承認ゲート 2"` | 4 | +| `"name": "ゲート 2"` | 2 | + +用語集(`docs/glossary/glossary.json`)は「承認ゲート」(廃止した語に `関門`)・「ゲート 1」・「ゲート 2」を別の語として持つ。Factory の画面の表示(`factory/web/src/i18n/ja.json:209`)は「ゲート 1」「ゲート 2」である。 + +## 前提 + +- 前提 1: 新しい呼び名は「設計承認ゲート」「リリース承認ゲート」の 2 つで、利用者が決めた。総称は「承認ゲート」とし、`関門`は語として使わない(用語集が既に`関門`を「承認ゲート」の廃止した語に置いているため) +- 前提 2: 「リリース承認ゲート」の「リリース」は、#2620 が定める「リリース」(本番へのデプロイ)を指す。承認ゲートが掛かる操作は今のゲート 2 から変えない +- 前提 3: #2620(「配布」を「デプロイ」「リリース」に分ける)と同じ開発ループ lflow2(マイルストーン 35)で、同じ周回か続く周回で進める。2 つの課題が同じ行を書き換えるときの順序は、実装計画で決める +- 前提 4: 承認ゲートの数・置き場所・掛かる操作・承認の判定は変えない。変えるのは呼び名だけである + +## 対象範囲 + +含む: +- `plugins/` の Skill・references・エージェントの定義・README・スクリプトの人へ出す文 +- `docs/` のうち、リポジトリ知識の文書(`docs/ndf-plugin-reference.md` など)と用語集(`docs/glossary/glossary.json`・`docs/glossary.md`)と `plugins/ndf/skills/development-workflow/references/glossary.md` +- `AGENTS.md`・`CLAUDE.md`・`README.md`・`.ndf/` の設定の人へ出す文 +- `factory/` の画面の表示と契約の説明文 +- 機械が読む識別子を変えるかの決定と、変えるときの旧い値の読み方(未決 1) +- テストのうち、人へ出す文の期待値を持つもの + +含まない: +- `docs/presentations/`・`docs/articles/`・`docs/archive/`・`docs/development-history/`・`issues/old/`(書いた日の記録。`AGENTS.md` の「ドキュメント」の表) +- `CHANGELOG.md` の既存の版の記述(リリースした版の記録) +- 承認ゲートの振る舞い(数・掛かる操作・判定・承認の証跡の条件)の変更 +- 「配布」の言い換え(#2620) +- 「最終ゲート」(cross-refactoring の最後の手順)・「品質ゲート」など、承認ゲートでない「ゲート」の語 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 新しい版の NDF が利用者の環境へ入った | 利用者のプラグインの更新 | 古い版のまま動く。古い版は旧い呼び名で記録を書き続ける | — | +| E2 | 実行中のループ・並列の計画が、新しい版で再開した | 再開の操作(supervise の queue・relay) | 旧い版が書いた承認ゲートの記録を新しい版が読めないと、承認済みの承認ゲートを未承認と判定して止まる(受け入れ条件 4) | E1 | +| E3 | スクリプトが承認ゲートで止まり、人へ問いと承認資料を出した | 承認ゲートに当たるステップ | 問いの区分が hook の許す値に無いと、問いが拒否される(受け入れ条件 4) | E1 | +| E4 | 人が承認ゲートに答え、承認ゲートの記録が書かれた | 人の答え・MVV 判定 | 書いた名前を読む側が一致で判定できないと、承認の証跡が無いと判定される(受け入れ条件 4) | E3 | +| E5 | Factory が承認の依頼を表示し、答えをランナーが NDF の記録へ写した | 実行ジョブの承認待ち | 契約の種類の値が API・ランナー・画面で食い違うと、依頼が表示されないか答えが写らない(受け入れ条件 4) | E3 | +| E6 | 新旧の版が混ざった環境で、同じ記録を両方の版が読み書きした | 一部のセッションだけ更新済み | 新しい版が書いた名前を古い版が読めない。古い版は互換の対象外とし、前提で扱う(未決 2) | E1 | + +## 受け入れ条件 + +- [ ] 1. 対象範囲の人へ出す文で、2 つの承認ゲートが「設計承認ゲート」「リリース承認ゲート」と呼ばれる。対象範囲の `git grep -nE '(承認ゲート|ゲート|関門) ?[12]'` のヒットは、未決 1 で残すと決めた機械が読む識別子・互換のために旧い値を読むコード・そのテストだけになり、残した箇所とその理由が設計文書に一覧で載る +- [ ] 2. 対象範囲の人へ出す文で、`関門`が承認ゲートの意味で使われない(`git grep -n '関門'` のヒットは受け入れ条件 1 と同じ例外だけ) +- [ ] 3. 用語集(`docs/glossary/glossary.json`)に「設計承認ゲート」「リリース承認ゲート」の 2 語が載り、それぞれの廃止した語に「承認ゲート 1」「ゲート 1」`関門 1`/「承認ゲート 2」「ゲート 2」`関門 2`が載る。「ゲート 1」「ゲート 2」の独立した語は用語集から消える。`glossary.py render` で作り直した `docs/glossary.md` と、`development-workflow/references/glossary.md` の表が同じ内容を持つ +- [ ] 4. 名前の変更で、旧い版が書いた記録が新しい版で同じ意味に読める。具体的には、承認ゲートの記録の名前が `関門 1` / `関門 2` の既存のスプリント状態ファイルとループの記録を新しい版の読む側(承認済みの判定・ループの記録の集計・MVV の改訂の兆候の集計)に通したとき、変更の前と同じ判定・件数になる。機械が読む識別子を変えた場合は、旧い値と新しい値の両方を入力にしたテストがある +- [ ] 5. 承認ゲートで出す問いが、hook(問い合わせの区分の検査)に拒否されない。問いの区分を変えた場合は、旧い区分の問いも拒否されない +- [ ] 6. Factory の承認の依頼が、API・ランナー・画面で同じ種類の値を使う。画面の表示は「設計承認ゲート」「リリース承認ゲート」になる +- [ ] 7. 全体テスト(`uv run --frozen --project . --all-extras pytest . -q -n 4`)と Factory のテストと CI が通る +- [ ] 8. 用語チェック(`python3 plugins/ndf/scripts/glossary.py check --file <変更した文書>`)が、変更した文書で廃止した語の当たりを 0 件にする(受け入れ条件 1 の例外を除く) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 移行性 | 旧い版が書いた記録(承認ゲートの記録の名前・問いの区分・並列の依頼の種類)を、新しい版が移行の操作なしで読める。利用者に記録の書き換えを求めない | +| 運用・保守性 | 承認ゲートの名前を表す文字列は、スクリプトの中で 1 か所の定数から読む(今は `gate_answer.py` と `sprint_loop_record.py` が同じ定数を別々に持つ。Value 7 の「同じ役割の定数・関数を分けない」) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 人へ出す文が変わる。機械が読む識別子は未決 1 で決める。変える場合は旧い値も読む互換の経路を持つ | +| データ | 既存の記録は書き換えない。新しい版が書く記録の名前を変えるかは未決 1 で決める | +| 既存の振る舞い | 変わらない(前提 4) | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`、Factory のパッケージのテスト | +| 静的解析 | `python3 scripts/check-skill-frontmatter.py`、`python3 plugins/ndf/scripts/instructions-check.py --root .`、`claude plugin validate .` | +| 呼び名の残り | 受け入れ条件 1・2 の `git grep` を対象範囲に当て、ヒットが設計文書の例外の一覧と一致することを見る | +| 記録の互換 | 受け入れ条件 4 のテスト。加えて、メインディレクトリの `.ndf/loop` の既存の記録を新旧の版の集計に通し、結果を比べる | +| 手動確認 | リリースの後、承認ゲートで止まったときの問いと承認資料に新しい呼び名が出ることを、利用者が実際の周回で見る(リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。`plugins/` は `factory/` を参照しない | +| コーディング規約 | `plugins/ndf/skills/AUTHORING.md`、`document-style`、用語集 | +| テスト戦略 | `.md` の文言を照合するテストを書かない(`AGENTS.md` の DON'T)。テストは記録の互換(旧い値を読む)と、スクリプトの出す文の期待値の更新に限る | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 全体テストの実行、用語集の更新と `render`、変更範囲内の呼び名の統一 | +| 確認してから行う | 機械が読む識別子の変更(設計承認ゲートで人が見る) | +| 行わない | 既存の記録(`.ndf/` の JSON・JSONL)の書き換え、書いた日の記録の書き換え、承認ゲートの振る舞いの変更、範囲外の言い換え | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 機械が読む識別子(問いの区分・並列の依頼と Factory の契約の種類・承認ゲートの記録の名前・承認ラベル)を変えるか。変えるなら旧い値を読む期間と、その期間を終える条件 | 設計(設計承認ゲートで利用者が確かめる) | 設計 PR | +| 2. 旧い版と新しい版が同じ記録を読み書きする状態(E6)を互換の対象に含めるか | 設計 | 設計 PR | +| 3. 確定仕様(`docs/specifications/`)を新しい呼び名へ直すか(確定仕様はリリース済み版の判断を持つが、書いた日の記録の扱いを受けていない) | 設計 | 設計 PR | +| 4. #2620 と同じ行を書き換えるときの順序 | 実装計画 | 実装計画 | diff --git a/issues/issue-2620-design.md b/issues/issue-2620-design.md new file mode 100644 index 000000000..18fe05711 --- /dev/null +++ b/issues/issue-2620-design.md @@ -0,0 +1,445 @@ +# 語の置き換え: 環境へ届ける操作を「デプロイ」、本番へのデプロイを「リリース」と呼び分ける(#2620) + +## 目的 + +- **今どうなっているか**: 環境へ変更を届ける操作を `配布` と呼び、同じ意味に「検証リリース」「本番の配布」「本番へのリリース」も当たっている。受け入れ条件 1 の検索で 2,000 行(`75fc92737`)が当たり、スクリプトが記録に書く値(工程の名前・周回の結果・フェーズの名前・リリース記録の段階)にも `配布` が入っている +- **誰に効くか**: 承認を求められる利用者と、課題の本文の「進行」・報告・リリース記録を読む利用者と AI +- **変えると何が成り立つか**: 本番へ届く操作だけが「リリース」と書かれ、承認が要るのはどちらかを語から読める。版を上げる前に作られた記録も同じ意味で読み続けられ、`配布` を書き足した変更は継続的統合で落ちる + +## 適用範囲 + +- **働く範囲**: このリポジトリの文書・Skill・スクリプト・Factory と、NDF を使うすべてのリポジトリでスクリプトが書く記録の値(工程の名前・リリース記録・周回の結果・フェーズの名前)。再流入の検査は、用語集の設定に禁止語を書いたリポジトリだけで働く +- **プロジェクトごとに違うもの**: 禁止語と、検査する範囲・外す範囲。用語集の設定(`.ndf/glossary.json`)で受ける +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「デプロイは開発・本番を問わず環境に変更を届けることを指し、リリースは本番へのデプロイのみをさします」 | 利用者の指示の原文(#2620) | 語の定義そのもの | +| STD1・PRD1 | 外部の一次情報 | 業界ではデプロイを「環境へ入れること」、リリースを「利用者が使える状態にすること」と分けて呼ぶ。利用者の定義はこの区別と矛盾しない(NDF の本番チャネルは利用者が使うチャネルである) | +| 受け入れ条件 1 の検索が 2,000 行・`docs/metrics/` を除いて 1,906 行 | 実測(2026-11-06、`75fc92737`) | 置き換えは文の書き換えが大半で、記録に書く値は 7 つの値の集合に限られる(「データ構造」) | +| 用語集の検査(`glossary.py check --diff`)が、継続的統合のジョブ `glossary-check` で Pull Request ごとに足した行を見ている | 実測(`.github/workflows/glossary.yml`) | 再流入の検査を足す場所が既にある | + +要求と受け入れ条件は #2620 の本文にある(コピーは `issues/issue-2620-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF のリリース(`ndf-release`) | デプロイ・リリース・開発版のデプロイ・リリース記録の段階・リリース対象・アーティファクト・導入先のランタイム | +| NDF の開発ワークフロー(`ndf-workflow`) | 工程の名前・フェーズの名前・周回の結果・リリースの依頼・旧い値の読み替え | +| NDF の Factory(`ndf-factory`) | リリースの実行・まとめたリリース | + +`ndf-release` と `ndf-workflow` は共有カーネルの関係にある。同じ用語集(`docs/glossary/glossary.json`)と旧い値の読み替えの表を共同で持ち、同じ変更で直す。Factory は NDF の出す値に対して腐敗防止層を置く(`factory/runner/src/translate.ts` が NDF のフェーズの名前を Factory の段の名前へ変換する)。Factory はサービスの境界の規則によって NDF の読み替えの表を参照しないため、自分の層に旧い値を持つ。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 用語集 | `glossary.py`(`render` が文書を作る。語の追加・廃止は用語集の JSON を直す変更) | 用語集 | 語 | 意味・廃止した語・コンテキスト | +| 旧い値の読み替えの表 | NDF のスクリプト(`plugins/ndf/scripts/data/legacy-values.json`) | 表 | 値の集合 | 旧い値 → 新しい値の組 | +| 禁止語の宣言 | 利用者の設定(`.ndf/glossary.json` の `check.banned`) | 宣言 | — | 禁止語・検査する範囲・外す範囲 | +| Factory のフェーズの変換 | Factory のランナー(`factory/runner/src/translate.ts` と、旧い値を持つ `legacy-phases.ts`) | 変換の表 | — | NDF のフェーズの名前 → Factory の段 | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 旧い値の読み替えの表 | スクリプトが記録・報告・課題の本文の「進行」へ書く値は、どの値の集合でも新しい値だけである | 全体テストが落ちる(書く側の値を集めて旧い値と照らす) | +| I2 | 旧い値の読み替えの表 | 旧い値を読んだ処理は、対応する新しい値を読んだときと同じ結果を返す | 単体テストが落ちる | +| I3 | 旧い値の読み替えの表 | 1 つの値の集合の中で、旧い値は新しい値の 1 つだけへ変換される。新しい値は旧い値の欄に現れない | 表を読む時点で例外を上げ、読み手は終了コード 2 で止まる | +| I4 | 禁止語の宣言 | 検査する範囲に足した行に禁止語があれば、検査は失敗し、ファイルと行を出す。外す範囲の行では失敗しない | 継続的統合のジョブ `glossary-check` が落ちる | +| I5 | 用語集 | 新しく決めた語は、それぞれ 1 つの意味で用語集に載る。旧い語は廃止した語として、置き換え先の語を引ける | `glossary.py check` の構造の検査が落ちる | +| I6 | — | Skill の名前・スラッシュコマンド・スクリプトの副命令の名前が変わらない | `plugins/ndf/manifests/` と契約の差分の確認で止まる | +| I7 | — | 書いた日の記録に差分が無い | 禁止語の検査の外す範囲と、テスト設計の差分の確認で止まる | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 利用者が「デプロイ」「リリース」の定義を決めた | 利用者 | 用語集 | +| E2 | 用語集にデプロイ・リリースの定義と、旧い語の別名が載った | 用語集の JSON の変更 | `glossary.py render`・`glossary.py check` | +| E3 | 対象範囲の文書・Skill・Factory の文を置き換えた | 実装の変更 | 禁止語の検査 | +| E4 | スクリプトが記録に書く値を新しい語にした | NDF のスクリプト | 記録を読むスクリプト・Factory のランナー | +| E5 | 実行中のループ・並列の計画が、旧い値を持つ既存の記録を読んだ | 版を上げた後の最初の周回 | 旧い値の読み替えの表 | +| E6 | 禁止語を書き足した変更が継続的統合で落ちた | 以後の Pull Request | Pull Request の作成者 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| デプロイ | 開発・本番を問わず、環境に変更を届けること。本番へのデプロイだけをリリースと呼ぶ。工程の名前でもある | 追加(`ndf-release`。設計の Pull Request は工程の名前を除いた意味で足し、実装計画の最初の Pull Request が工程の名前を足す) | +| リリース | 本番へのデプロイ。フェーズの名前でもある | 意味の変更(`ndf-workflow`。工程の名前ではなくなる) | +| 開発版のデプロイ | 開発版のチャネルへ版を出すことと、検証の環境への反映。フェーズの名前でもある | 追加(`ndf-release`。「検証リリース」を廃止した語にする) | +| デプロイなし | リリース記録の段階の値。どの環境へも届けずに工程を終えたこと | 追加(`ndf-release`) | +| リリース待ち | リリースの依頼を送り、答えを待たずに締めた周回の結果 | 追加(`ndf-workflow`。旧い語を廃止した語にする) | +| リリースの依頼 | レーンがリリース承認の手前で、リリースをオーガナイザーへ頼むために送る 1 件のメッセージ | 追加(`ndf-workflow`。旧い語を廃止した語にする) | +| リリースの実行 | Factory のオーガナイザーが、まとめたリリース 1 つのために起こすスプリントの実行 | 追加(`ndf-factory`。旧い語を廃止した語にする) | +| まとめたリリース | オーガナイザーが複数のレーンのリリースの依頼をまとめた 1 回のリリース | 追加(`ndf-workflow`) | +| リリース対象 | リリースで利用者へ届くファイルの集まり。ai-plugins では `plugins/` と `.claude-plugin/` | 追加(`ndf-release`) | +| アーティファクト | ビルドや署名で作る、公開操作で届ける生成物(パッケージ・署名したバイナリなど) | 追加(`ndf-release`) | +| 導入先のランタイム | マーケットプレイスのプラグインを導入できる形で届けるランタイム(Claude Code と Codex) | 追加(`ndf-release`。旧い語を廃止した語にする) | +| 利用先のリポジトリ | NDF を導入して使っているリポジトリ | 追加(`ndf-workflow`) | +| 利用者向けの変化なし | Pull Request の本文の `## 利用者向けの変化` が「無し」(「なし」も同じ)だけを持つこと | 追加(`ndf-release`。旧い語を廃止した語にする) | +| 旧い値の読み替え | 版を上げる前に書かれた記録の値を、読む時点で新しい値へ変換すること | 追加(`ndf-workflow`) | +| 禁止語 | 用語集の設定が、検査する範囲で新しく書くことを禁じた語 | 追加(`ndf-workflow`) | + +「用語集への反映」の列は、実装を終えた時点の用語集を示す。設計の Pull Request が用語集へ入れるのは「追加」の語だけで、意味の変更(「リリース」)と、「デプロイ」への工程の名前の追記と、旧い語を廃止した語にする対応(「検証リリース」「配布」など)は入れない。実装計画の最初の Pull Request(用語集と読み替えの表と検査)が入れる。設計の Pull Request で旧い語を廃止した語にすると、用語チェック(`glossary.py check --diff`)が、この設計文書の足した行にある旧い語を廃止した語として指摘し、継続的統合が落ちるためである。 + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べる | デプロイとリリースの区別に業界で流通した定義があり、利用者の定義と食い違わないかを確かめる | +| 主要な製品 | 調べる | デプロイの製品が、環境と版とリリースをどう呼び分けているかを見る | +| ライブラリとサービス | 調べる | 禁止語の検査を既存の文書の検査ツールで済ませられるかを決める | + +### 標準・仕様 + +| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| STD1 | Thoughtworks Technology Radar「Decoupling deployment from release」 | デプロイを「アプリケーションの部品か基盤へ変更を入れる行為」、リリースを「機能の変更が業務上の影響を持って利用者へ届くこと」に限って使うよう勧める(2016 年 4 月の版。最新の版には載っていない) | 開発版のチャネル・検証の環境へ入れることはデプロイ、本番チャネルで利用者へ届くことはリリースと呼ぶ(決定 1) | https://www.thoughtworks.com/en-au/radar/techniques/decoupling-deployment-from-release (2026-11-06) | + +### 主要な製品 + +| # | 製品 | どう解いているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| PRD1 | Octopus Deploy | デプロイは「ソフトウェアの版を環境へ入れること」、リリースは「利用者が使える状態にすること」と書く。版を「リリースする」と「リリースをリリースする」の言い方を避けるため、版は「ソフトウェアの版」と呼ぶ | 版は「版」のまま呼び、「リリース」を名詞の版の意味に使わない(「リリース済み版」は既存の語のまま) | https://octopus.com/devops/continuous-delivery/deployments-vs-releases/ (2026-11-06) | +| PRD2 | Microsoft Learn「Understand releases and deployments」 | 新しいソフトウェアをデプロイすると同時に利用者へ機能が届く状態を、分けるべき問題として扱う | 承認が要るのは本番へ届く操作だけだと語で分かるよう、工程の名前と段階の値を分けて持つ(決定 2・3) | https://learn.microsoft.com/en-us/training/modules/introduction-to-continuous-delivery/5-understand-releases-deployments (2026-11-06、検索の結果の要約で確かめた) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | Vale(substitution の検査) | 未確認 | MIT | 未確認 | 無し | 一部。`swap` で語と置き換え先を持てるが、Pull Request で足した行だけを見る形と、コードのコメント・文字列の中を見る形は持たない。Go のバイナリを継続的統合へ足す必要がある | https://docs.vale.sh/checks/substitution (2026-11-06) | +| LIB2 | textlint-rule-prh | 6.1.1 | MIT | 2026-10-16(npm の更新日) | 無し | 一部。`expected` と `pattern` の辞書で置き換え先を出せる。Markdown が対象で、Python・TypeScript・シェルのコメントと文字列は見ない。Node の依存と辞書を、用語集と別に持つ | https://github.com/textlint-rule/textlint-rule-prh ・ `npm view textlint-rule-prh version time.modified license`(2026-11-06) | +| LIB3 | `glossary.py check --diff`(このリポジトリが既に持つ) | NDF 10.27.26 | MIT | — | 無し | 一部。足した行だけを見て、廃止した語から置き換え先の語を出す。検査する範囲が用語集の設定の `check.paths`(`issues/` の要求と設計)に限られ、コードはコメントを外して英語の識別子だけを見る | `plugins/ndf/scripts/glossary.py`(2026-11-06) | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| Vale の最新の版と日付 | `curl -s https://api.github.com/repos/errata-ai/vale/releases/latest` | 応答に `tag_name` が無かった(リポジトリの移転か API の制限。理由は確かめていない) | +| Vale の検査がコードのコメントと文字列を見るか | https://docs.vale.sh/checks/substitution | 頁に記述が無い。採らない理由は足した行だけを見る形が無いことで足りるため、追わない | + +調べた量: 候補 6 件・取得の試み 9 回。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 文書・Skill・Factory の文が、本番へ届く操作をリリース、それ以外の環境へ届く操作をデプロイと書く | 利用者・AI | +| F2 | スクリプトが記録・報告・「進行」へ書く値が新しい語になる | 利用者・AI・記録を読むスクリプト | +| F3 | 版を上げる前の記録を、新しい値と同じに読む | 実行中のループ・並列の計画・閉じる前の確認・見積もり | +| F4 | 用語集が新しい語の定義と、旧い語から置き換え先を引く対応を持つ | 利用者・AI・`glossary.py check` | +| F5 | 禁止語を書き足した Pull Request を継続的統合が落とす | Pull Request の作成者 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 用語集(`docs/glossary/glossary.json` と `docs/glossary.md`、`development-workflow` の `references/glossary.md`) | 決定 1・4 の語を載せ、旧い語を廃止した語として持つ。`配布` そのものを「デプロイ」「リリース」の廃止した語にする | +| 旧い値の読み替えの表(`plugins/ndf/scripts/data/legacy-values.json`) | 値の集合ごとに旧い値 → 新しい値を持つ。旧い値を持つ NDF のファイルはこの表だけである | +| 読み替えのモジュール(`plugins/ndf/scripts/lib/legacy_values.py`) | 表を読み、I3 を確かめ、`normalize(<値の集合>, <値>)` を返す。Python の読み手はここだけを呼ぶ | +| シェルの読み手(`development-workflow` の `scripts/lib/workflow-common.sh`・`progress-record.sh`・`projects-sync.sh`) | 工程の名前を受け取った時点と通過記録を読む時点で、表を `jq` で引いて新しい値へ変換する | +| 書き手(`supervise_lib/release_templates.py`・`delivery_templates.py`・`sprint.py`・`parallel_release.py`・`sprint_loop_names.py`・`sprint_loop_gate2.py`・`estimate_records.py`・`sprint-state.py`・`release-steps.py`・`sprint-close.py`・`sprint_close_lib/final_stage.py`・`transcript_agents.py`) | 新しい値だけを書く(I1)。読む箇所は読み替えのモジュールを通す | +| リリース記録の読み手(`lib/dist_record.py`) | 見出し `## リリース記録` と旧い見出しの両方を記録として読み、段階の値を変換する | +| ボードの書き手(`projects-sync.sh`) | 工程のフィールドへ新しい名前の選択肢を書く。新しい選択肢が無く旧い選択肢があれば旧い選択肢へ書き、選択肢の名前を変える案内を `NOTE:` で返す(決定 7) | +| 禁止語の検査(`glossary.py check` の新しい規則 `banned`) | 用語集の設定の `check.banned` を読み、`--diff` で足した行のうち検査する範囲のものを、ファイルの種類を問わず照らす(決定 8) | +| 禁止語の宣言(`.ndf/glossary.json`) | 禁止語 `配布` と、検査する範囲・外す範囲を書く(決定 9) | +| Factory(`factory/api/src/`・`factory/runner/src/`・`factory/contracts/`) | 人へ出す文と OpenAPI・スキーマの説明を置き換える。`translate.ts` は新しいフェーズの名前を持ち、旧い名前は `legacy-phases.ts` に置く | +| 文書(Skill の本文・references・`AGENTS.md`・`CLAUDE.md`・`README.md`・`KIRO.md`・確定仕様・現行の文書・契約の説明) | 決定 1・4 の対応で文を置き換える。見出しを変えたら、その見出しを指すリンクを同じ変更で直す | + +```mermaid +flowchart TB + subgraph decl["設定と用語"] + G["用語集"] + B["禁止語の宣言"] + end + subgraph ndf["NDF のスクリプト"] + W["書き手"] + T["旧い値の読み替えの表"] + M["読み替えのモジュール"] + SH["シェルの読み手"] + DR["リリース記録の読み手"] + PS["ボードの書き手"] + CK["禁止語の検査"] + end + subgraph fac["Factory"] + TR["フェーズの変換"] + end + W -->|新しい値だけを書く| REC[("記録・課題の本文・PR の本文")] + REC --> M + REC --> SH + REC --> DR + T --> M + T --> SH + M --> DR + SH --> PS + W -->|進行の記録| SH + REC --> TR + G --> CK + B --> CK +``` + +文書は図に含めない(どの要素からも読まれず、禁止語の検査だけが見る)。 + +## データ構造 + +### 値の集合と新しい値 + +旧い値の読み替えの表は、次の 7 つの値の集合を持つ。左の 3 列(値の集合・旧い値・新しい値)が表の中身で、書き手はこの表の新しい値だけを書く。 + +| 値の集合(表の鍵) | 旧い値 | 新しい値 | 書き手 | 読み手 | 記録の寿命 | +| --- | --- | --- | --- | --- | --- | +| 工程の名前(`stage`) | `配布` | `デプロイ` | プランのステップの `stage`・`progress-record.sh`・`sprint-close.py --final-stage` | `workflow-common.sh`・`progress-record.sh`・`stage_skills.py`・`context-policy.py`・`final_stage.py` | 長い(通過記録・判断の記録・課題の本文) | +| リリース記録の見出し(`record_heading`) | `## 配布の記録` | `## リリース記録` | `release-steps.py record`・`release` の手順 | `dist_record.py` | 長い(Pull Request の本文とコメント) | +| リリース記録の段階(`record_stage`) | `配布なし` で始まる値 | `デプロイなし` で始まる値 | `sprint-close.py`・`release` の手順 | `dist_record.py`・`sprint-close.py` | 長い | +| フェーズの名前(`phase`) | `配布(開発版)`・`配布(本番)`・`配布の依頼` | `開発版のデプロイ`・`リリース`・`リリースの依頼` | `release_templates.py`・`parallel_release.py` | `sprint-state.py`・`estimate_records.py`・`sprint_loop_gate.py`・`transcript_agents.py`・Factory の `translate.ts` | 長い(見積もりの記録) | +| 周回の結果(`lap_outcome`) | `配布待ち` | `リリース待ち` | `sprint_loop_names.py` | `sprint_loop_gate.py`・`sprint_loop_gate2.py` | 短い(実行中のループの状態) | +| 失敗の種類(`failure_kind`) | `配布`・`配布の失敗` | `リリース`・`リリースの失敗` | `sprint_loop_gate2.py` | ループの選定と報告 | 短い | +| パイプラインのステージの名前(`pipeline_stage`) | `配布` | `デプロイ` | `supervise_lib/sprint.py` | キューの再開 | 短い(キューの状態) | + +リリース記録の段階の残りの値(`検証`・`本番`)は変えない。`検証` は開発版のデプロイまでを終えた記録の値として読む。 + +### 表の形 + +```json +{ + "version": 1, + "sets": { + "stage": {"配布": "デプロイ"}, + "record_heading": {"## 配布の記録": "## リリース記録"}, + "record_stage_prefix": {"配布なし": "デプロイなし"}, + "phase": {"配布(開発版)": "開発版のデプロイ", "配布(本番)": "リリース", "配布の依頼": "リリースの依頼"}, + "lap_outcome": {"配布待ち": "リリース待ち"}, + "failure_kind": {"配布": "リリース", "配布の失敗": "リリースの失敗"}, + "pipeline_stage": {"配布": "デプロイ"} + } +} +``` + +`record_stage_prefix` だけは前方一致で変換する(段階の値は `配布なし(<理由>)` の形で理由を後ろに持つ)。ほかの集合は値全体の一致で変換する。表に無い値はそのまま返す。 + +### 禁止語の宣言の形 + +```json +{ + "check": { + "banned": { + "words": ["配布"], + "paths": ["plugins/**", "factory/**", "docs/**", ".github/**", "AGENTS.md", "CLAUDE.md", "README.md", "KIRO.md", ".ndf/**"], + "exclude": [ + "docs/presentations/**", "docs/articles/**", "docs/archive/**", "docs/development-history/**", "docs/changelog/**", + "docs/metrics/**", ".ndf/mvv.md", ".ndf/mvv.json", + "docs/glossary/glossary.json", "docs/glossary.md", + "plugins/ndf/scripts/data/legacy-values.json", "plugins/ndf/scripts/tests/test_legacy_values.py", + "factory/runner/src/legacy-phases.ts", "factory/runner/src/legacy-phases.test.ts" + ] + } + } +} +``` + +`docs/glossary/glossary.json` と `docs/glossary.md` を外すのは、廃止した語の欄が `配布` を持つためである。語の意味の欄への再流入は、`glossary.py check` の構造の検査が、禁止語を含む `meaning` を指摘して止める(廃止した語の欄だけを例外にする)。 + +## 入出力の契約 + +### `glossary.py check` の規則 `banned` + +| 項目 | 内容 | +| --- | --- | +| 起動 | 既存の `glossary.py check --diff <基点>`(継続的統合のジョブ `glossary-check`)と `--file <パス>`。新しい引数は足さない | +| 対象 | `check.banned.paths` に一致し `exclude` に一致しないファイルの、足した行(`--file` ではすべての行)。ファイルの種類を問わず、コードのコメント・文字列・Markdown のコードブロックとインラインのコードも照らす | +| 一致 | 語の部分一致(`配布物`・`配布先` も当たる) | +| 出力 | 既存の形のまま。標準エラーに `ERROR: <パス>:<行>: banned: <語>`、結果 JSON の `items` に `{"rule": "banned", "path", "line", "term", "detail"}`。`detail` は用語集の廃止した語の対応から置き換え先を出す(`配布` なら「デプロイ / リリース」) | +| 終了コード | 既存の規則と同じ(指摘があれば違反の終了コード) | +| 宣言が無いとき | 規則 `banned` を走らせない。既存の規則の振る舞いは変わらない | +| 宣言の形の誤り | `words` が文字列の配列でない・`paths` が無いときは、宣言を読めない誤りとして既存の形で止まる | + +`check.paths`(要求と設計の文書で用語の表を見る範囲)と `check.banned.paths` は別に持つ。`check.paths` の文書へは既存の規則(廃止した語・登録の無い語)が今までどおり当たる。 + +### スクリプトの引数で受ける値 + +| 引数 | 新しい値 | 旧い値を渡したとき | +| --- | --- | --- | +| `progress-record.sh <課題> <工程名>`・`projects-sync.sh <課題> stage <工程名>` | `デプロイ` | 新しい値として記録する。知らない工程名にしない | +| `sprint-close.py --final-stage` | `デプロイ`・`リリース後テスト`・`振り返り` | `配布` を `デプロイ` として受ける | + +前の版の NDF が組んだプランは、ステップの `stage` に旧い値を持ったまま流れる。キューが流すときに上の引数へ旧い値が渡るため、引数の側で受ける。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant Q as キュー(前の版のプラン) + participant P as progress-record.sh + participant T as 読み替えの表 + participant R as 課題の本文と通過記録 + participant B as ボード + Q->>P: 工程名「配布」で記録を頼む + P->>T: 工程の名前の集合で引く + T-->>P: デプロイ + P->>R: 「デプロイ」として書く + P->>B: 選択肢「デプロイ」を書く + alt 選択肢「デプロイ」が無く、旧い選択肢がある + P->>B: 旧い選択肢へ書く + P-->>Q: NOTE: 選択肢の名前を変える案内 + end +``` + +ループの周回の選定は、状態を読んだ直後に周回の結果と失敗の種類を変換する。旧い値の周回は、新しい値の周回と同じ分岐(答えを待つ保留の周回)へ入る。リリース記録の読み取りは、見出しを旧い形と新しい形のどちらでも記録の始まりとして扱い、最後の記録 1 つを選ぶ規則は変えない。 + +## 非機能の実現方式 + +| 大項目 | 実現方式 | +| --- | --- | +| 移行性 | 旧い記録は書き換えない。読む時点で旧い値の読み替えの表を通す(決定 5)。表は外さずに持ち続ける(決定 6) | +| 運用・保守性 | 再流入は禁止語の検査が継続的統合で止める(決定 8)。旧い値を持つファイルは表・そのテスト・Factory の 1 ファイルと、そのテストに限り、検査の外す範囲と一致させる | + +## 決定の記録 + +### 決定 1: 承認が要るのはどちらかを語で読めるよう、届け先で語を選ぶ規則を 3 つに決める + +本番チャネル・本番環境へ届く操作だけを「リリース」と書く。開発版のチャネル・検証の環境だけへ届く操作は「開発版のデプロイ」と書く。開発版と本番の両方にまたがる操作と、届け先を問わない操作は「デプロイ」と書く。利用者の定義ではリリースはデプロイの一部であり、両方にまたがるものをリリースと書くと開発版だけで終わった場合も本番へ届いたと読める。 + +「検証リリース」は廃止した語にし、置き換え先は「開発版のデプロイ」とする。版は「版」と呼び、名詞の「リリース」を版の意味に使わない(PRD1)。 + +根拠: Value 8(MVV 版 5) + +### 決定 2: 開発版だけで終わった時点を本番へ届いたと読ませないよう、工程の名前を「デプロイ」にする + +工程(`release` を起動する行)は開発版のデプロイと本番へのリリースの両方を含み、進め方によって開発版のデプロイまでで記録が付く。工程の名前を「リリース」にすると、本番へ届いていない時点で「進行」に「リリース」の印が付く。名前を「デプロイ」にすれば、決定 1 の規則のまま両方を表せる。フェーズの名前は届け先が 1 つに決まるため、`開発版のデプロイ` と `リリース` に分ける。 + +工程を 2 つに分ける形は採らない。工程表と承認ゲートの置き場所が変わり、要求の「含まない」(工程を変えない)に当たる。 + +根拠: Value 8 / Value 2(MVV 版 5) + +### 決定 3: 記録の形を保つよう、リリース記録は見出しだけを用語集の語へ合わせ、段階の値は `配布なし` だけを変える + +見出しを用語集に既にある語「リリース記録」へ合わせる。記録を書くのは `release` であり、段階 `検証` の記録も同じブロックで持つ。段階の値 `検証`・`本番` は `配布` を含まず、閉じる前の確認が読む値なので変えない。`配布なし` は、どの環境へも届けなかったことを指すため `デプロイなし` にする。 + +記録の名前を「デプロイの記録」に変える形は採らない。`配布` を含まない既存の語「リリース記録」の言い換えになり、置き換えの範囲が受け入れ条件 1 の外へ広がる。 + +根拠: Value 8 / Value 3(MVV 版 5) + +### 決定 4: デプロイにもリリースにも当たらない意味を 1 語 1 意味にするため、言い換えの語を次のとおり決める + +| 今の語 | 意味 | 言い換え | +| --- | --- | --- | +| `配布物`(`plugins/` と `.claude-plugin/`) | リリースで利用者へ届くファイル | リリース対象 | +| `配布物`(パッケージ・署名したバイナリ) | 公開操作で届ける生成物 | アーティファクト | +| `配布物の変化なし` | 本文の利用者向けの変化が「無し」 | 利用者向けの変化なし | +| `配布先のランタイム`・`配布先`(ランタイムを指すとき) | プラグインを導入できる形で届けるランタイム | 導入先のランタイム | +| `配布先のリポジトリ` | NDF を導入して使うリポジトリ | 利用先のリポジトリ | +| `配布元`・`配布元のリポジトリ` | プラグインの取得元 / 上流 | 取得元(マーケットプレイスの登録を指すとき)/ 上流リポジトリ(既存の語) | +| `配布の宣言`・`配布の形`・`配布の経路`・`配布の手順` | `.ndf/` のリリースの設定とその中身 | リリースの設定・リリースの形・リリースの経路(既存の語)・リリースの手順 | +| `配布の依頼`・`配布の実行`・`まとめた配布`・`配布待ち` | 本番へのリリースを頼む・行う・まとめる・待つ | リリースの依頼・リリースの実行・まとめたリリース・リリース待ち | +| `版と配布`(`docs/versioning-and-distribution.md` の題・「版と配布の方針」) | 版の付け方とチャネル | 版とリリース | + +表に無い出現は決定 1 の規則で選ぶ。`配る` は禁止語にせず、置き換えも求めない(要求は `配布` の語だけを対象にする)。Skill の説明文の起動の語(`release` の「配布する」)も「デプロイする」に置き換える。 + +根拠: Value 8(MVV 版 5) + +### 決定 5: 旧い値を 1 か所で持つよう、読み替えの表を NDF のデータとして置き、読む境界だけで変換する + +7 つの値の集合の旧い値を `plugins/ndf/scripts/data/legacy-values.json` に置く。Python は `lib/legacy_values.py` で、シェルは `jq` で同じ表を引く。読み手は記録を読んだ直後に 1 回変換し、後の処理は新しい値だけを扱う。Factory はサービスの境界の規則によって `plugins/` を読まないため、フェーズの名前の旧い値だけを `factory/runner/src/legacy-phases.ts` に持つ。 + +既存の記録を書き換える形は採らない。Pull Request と課題の本文、利用者の手元の見積もりの記録と通過記録は利用者のデータで、すべてを書き換える手段も戻す手段も無い(C3)。読み手ごとに旧い値を足す形も採らない。旧い値が 15 か所以上に散り、禁止語の検査の外す範囲を絞れない。 + +- 作り方: 自前で作る +- 比べた候補: LIB3 + +根拠: Value 7 / C3(MVV 版 5) + +### 決定 6: 外す時点の判断を残さないよう、旧い値の読み替えを外さずに持ち続ける + +リリース記録と課題の本文の「進行」・見積もりの記録・判断の記録は消えずに残り、後から読まれる。外す時期を決めると、その版の後に古い記録を読んだ処理が退行する。持ち続ける費用は 1 つの表と 1 つのテストで、検査の外す範囲もその 2 ファイルに限られる。 + +版を決めて外す形は採らない。外したかどうかを確かめる作業が新しく生まれ、長く残る記録では退行を防げない。 + +根拠: Value 5 / Value 3(MVV 版 5) + +### 決定 7: 利用者のボードを黙って書き換えないよう、工程の選択肢は旧い名前へ書き続けて案内だけを返す + +ボードの単一選択のフィールドの選択肢は利用者が作った設定である。新しい名前の選択肢が無いときは、旧い選択肢があればそこへ書き、選択肢の名前を変えるコマンドを `NOTE:` で返す。両方とも無ければ今までどおり「選択肢が無い」と返す。ボードの作り方の手順(`progress-tracking` の `references/board.md`)は新しい名前で書く。 + +スクリプトが選択肢の名前を自動で変える形は採らない。他の利用者が使うボードの設定を、案内なしに変えることになる。 + +根拠: Value 6 / 上位の原則(MVV 版 5) + +### 決定 8: 再流入の検査を足す場所を増やさないよう、用語集の検査に禁止語の規則を足す + +`glossary.py check` は、Pull Request で足した行を見る仕組みと、廃止した語から置き換え先を出す仕組みを既に持つ。継続的統合のジョブ `glossary-check` がこの検査を走らせている。禁止語の規則はこの 2 つを使い、検査する範囲と照らす場所(コードのコメントと文字列も含める)だけを足す。宣言は用語集の設定に置くため、NDF を使うほかのリポジトリでも同じ形で使える。 + +Vale(LIB1)と textlint-rule-prh(LIB2)は採らない。どちらも足した行だけを見る形を持たず、Python・TypeScript・シェルのコメントと文字列を見ない。継続的統合に新しいランタイム(Go のバイナリか Node の依存)と、用語集とは別の辞書が要る。`scripts/` に専用の検査を新しく作る形も採らない。足した行を集める処理と置き換え先を出す処理が `glossary.py` と重なる。 + +- 作り方: 自前で作る +- 比べた候補: LIB1 / LIB2 / LIB3 + +根拠: Value 7 / Value 6(MVV 版 5) + +### 決定 9: 承認済みの MVV と書いた日の記録を変えないよう、MVV の 2 ファイルと計測の記録を置き換えと検査から外す + +`.ndf/mvv.md` は利用者が承認した本文で、書き換えるとハッシュが合わなくなり、MVV 判定が承認ゲートを省かなくなる。`.ndf/mvv.json` は承認した版の本文の履歴である。MVV の本文の `配布`(5 行)は、利用者が次に MVV を改訂するときに `project-mvv.md` の「改訂」の手順で直す。`docs/metrics/ndf-token-usage/` の 94 ファイルは版ごとの計測の記録で、集計した日の時点の見出しを持つ書いた日の記録である。新しく書く記録の見出しには `配布` を出さない(書き手の文言を置き換える)。 + +この 2 つは受け入れ条件 1 の検索に当たり続けるため、受け入れ条件 1 の例外に足す(設計承認の答え、2026-11-06)。MVV の語は、次に MVV を改訂するときに直す。 + +根拠: Value 8 / C7 / P1(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1 | 受け入れ条件 1 の `git grep` の結果が、外す範囲のファイルだけになる(完了判定で打つ) | 置き換え漏れの行が 1 行でも残る | +| 受け入れ条件 2・I4 | 禁止語の宣言を置いた一時のリポジトリで、検査する範囲のファイル(Markdown・Python のコメント・TypeScript の文字列)へ禁止語の行を足すと、規則 `banned` がファイルと行を出して失敗する。外す範囲のファイルへ足した行では失敗しない。宣言が無いリポジトリでは走らない | 外す範囲を読まないように壊すと、外す範囲の行で失敗する。コードのコメントを外して照らすように壊すと、コメントの行が通る | +| 受け入れ条件 3・I5 | `glossary.py check --file` が、`配布`・`検証リリース`・`本番の配布`・`開発版の配布` を含む文書で廃止した語を指摘し、置き換え先の語を出す。禁止語を含む `meaning` を構造の検査が指摘する | 廃止した語の対応を外すと、置き換え先が「用語集の語」になる | +| 受け入れ条件 4 | 受け入れ条件 1 の検索と、`git grep -nF 検証リリース` が外す範囲だけになる(完了判定で打つ) | 置き換え漏れが残る | +| 受け入れ条件 5 | 決定 4 の語が用語集に 1 つずつ載り、`glossary.py check` の構造の検査が通る | 同じ語を 2 つのコンテキストに意味を変えて載せると落ちる | +| 受け入れ条件 6・I1 | 書き手(プランのテンプレート・周回の締め・リリース記録・見積もりの行・パイプラインの組み立て)が出す値を集めると、読み替えの表の旧い値を 1 つも含まない | テンプレートの 1 か所を旧い値に戻すと落ちる | +| 受け入れ条件 7・I2 | 値の集合ごとに、旧い値の記録と新しい値の記録を同じ読み手へ渡すと同じ結果になる。旧い値の周回(`配布待ち`)を持つループの状態で次の周回を選ぶと、保留の周回として扱い、選び直さない。旧い見出しのリリース記録を `sprint-close.py` が読み、同じ判定を出す | 読み替えを通さないように壊すと、旧い値の周回が退行する | +| I3 | 表の 1 つの集合で旧い値を 2 つの新しい値へ変換する形、新しい値を旧い値の欄に置く形を読むと、例外で止まる | 確かめを外すと、表の誤りが黙って通る | +| ボード(決定 7) | 新しい選択肢が無く旧い選択肢があるボードへ工程を書くと、旧い選択肢へ書き、`NOTE:` に案内が出る | 旧い選択肢へ書かずに失敗すると落ちる | +| 受け入れ条件 8・I6 | `plugins/ndf/manifests/` と `plugins/ndf/contracts/entrypoints/` の副命令の名前が変更の前後で同じ(差分の確認で打つ) | 副命令の名前を変えると差分が出る | +| 受け入れ条件 9 | `uv run --frozen --project . --all-extras pytest . -q -n 4` が通る | — | +| 受け入れ条件 10・I7 | `git diff --stat origin/develop -- docs/presentations docs/articles docs/archive docs/development-history docs/changelog issues/old docs/metrics .ndf/mvv.md .ndf/mvv.json` が空(差分の確認で打つ) | 書いた日の記録を 1 行でも直すと出る | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2620 | 実装する | — | `plugins/ndf/`、`plugins/playwright-kit/README.md`、`factory/`、`.github/`、`docs/specifications/`、`docs/versioning-and-distribution.md`、`docs/glossary/glossary.json`、`docs/glossary.md`、`AGENTS.md`、`CLAUDE.md`、`README.md`、`KIRO.md`、`.ndf/glossary.json`、`.ndf/pace.json` | + +受け入れ条件 1 の検索(`75fc92737`)が当たる 2,000 行の内訳は次のとおりである。 + +| 置き場 | 行数 | +| --- | ---: | +| スクリプト(うちテスト 257 行) | 611 | +| Skill | 289 | +| 確定仕様 | 655 | +| Factory | 206 | +| 用語集 | 70 | +| その他 | 75 | +| 計測の記録(決定 9 で外す) | 94 | + +実装計画は、用語集と読み替えの表と検査 → スクリプト → Skill と現行の文書 → 確定仕様 → Factory の順に Pull Request を分ける。用語集と読み替えの表が先に要る(E2 が E3・E4 の前提)。最初の Pull Request は、用語の表の意味の変更と旧い語の廃止を用語集へ入れる(既存の「リリース」の意味から工程の名前を外し、設計の Pull Request で足した「デプロイ」の意味へ工程の名前を足し、「検証リリース」「配布」などを廃止した語として置き換え先を引ける形にする)。設計の Pull Request の用語集の差分は、追加の語だけである。 + +## 設計承認での利用者の答え(2026-11-06) + +答えの正本は #2620 の本文の節「設計の承認ゲート 1 での利用者の答え(2026-11-06)」にある。この文書へは次の 3 点を反映した。 + +| 問い | 答え | 反映した箇所 | +| --- | --- | --- | +| 受け入れ条件 1 の例外(`.ndf/mvv.md`・`.ndf/mvv.json`・`docs/metrics/`) | 例外に足す。MVV の語は次に MVV を改訂するときに直す | 決定 9 | +| `.github/` の 8 行(継続的統合の定義と Pull Request・課題のテンプレート) | 範囲に入れる。置き換え、禁止語の検査の対象にもする | 「禁止語の宣言の形」の `paths`、「設計の結果」の触るファイル | +| #2619(承認ゲートの呼び名)との順序 | 1 つのスプリントにまとめ、#2619 と同じスプリントで実装する。重なった行は、後に入る方が先の差分へ合わせ直す | — | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 起動の語の効き | `release` の説明文から「配布する」を外すと、利用者が「配布して」と書いたときに Skill が起動しにくくなるおそれがある。リリース後テストで起動を 1 回確かめる | +| 利用者のボード | 工程の選択肢の名前を変えるまで、ボードは旧い名前を表示し続ける。変えるのは利用者で、案内は `NOTE:` と `board.md` に置く | diff --git a/issues/issue-2620-requirements.md b/issues/issue-2620-requirements.md new file mode 100644 index 000000000..203b02e10 --- /dev/null +++ b/issues/issue-2620-requirements.md @@ -0,0 +1,151 @@ +# #2620: 「配布」を使わず「デプロイ」(環境へ届ける)と「リリース」(本番へのデプロイ)に分ける + +正は課題の本文(#2620)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> あと、「配布」という用語は「リリース」と「デプロイ」に分け、配布という言葉は利用しないようにします。 +> デプロイは開発・本番を問わず環境に変更を届けることを指し、リリースは本番へのデプロイのみをさします。 + +(書き手: 利用者、日付: 2026-11-06。#2619(承認ゲートの呼び名の変更)を起票した直後に受けた) + +## 語の定義(利用者の決定) + +| 語 | 意味 | +| --- | --- | +| **デプロイ** | 開発・本番を問わず、環境に変更を届けること | +| **リリース** | 本番へのデプロイだけを指す | +| 配布 | **使わない** | + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く、起票者と要求の作成者(AI)が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 開発版のチャネル(`develop`)へ版を出すのは「デプロイ」、正式版のチャネル(`main`)へ出してタグを打つのは「リリース」 | 起票時の本文の例。AGENTS.md の「版と配布の方針」で `main` を正式版、`develop` を開発版としている | 受け入れ条件 3・4 | +| S2 | 今「検証リリース」と呼んでいるもの(開発版と分かる版数での公開・検証の環境への反映)は「デプロイ」になる | 起票時の本文の受け入れ条件の案 2。用語集の「検証リリース」の意味 | 受け入れ条件 4 | +| S3 | 語を置き換える範囲は、利用者・AI が今読む文書とコード(`plugins/`・`factory/`・`docs/` の確定仕様と現行の文書・用語集・`AGENTS.md`・`CLAUDE.md`・`README.md`・`.ndf/`)とする | 起票時の本文の調査の対象。AGENTS.md の「発表日時点の記録で、以後の構成変更には追随しない」 | 受け入れ条件 1・前提 1 | +| S4 | 書いた日の記録(`docs/presentations/`・`docs/articles/`・`docs/archive/`・`docs/development-history/`・`CHANGELOG.md` と `docs/changelog/` の出した版の項・`issues/`)は直さない | 起票時の本文の未決 4。AGENTS.md の同じ記述 | 含まない・前提 1 | +| S5 | 確定仕様(`docs/specifications/`)は直す | 確定仕様は現行の版の判断を持つ文書(CLAUDE.md の「版ごとの判断の記録」)で、書いた日の記録ではない | 受け入れ条件 1 | +| S6 | Skill の名前(`release`・`release-verification`・`deploy`)とコマンド名は変えない。`release` が開発版へのデプロイも担う点は、Skill の説明文で「デプロイとリリース」と書いて表す | 起票時の本文の未決 3。名前の変更は利用者の呼び出しを壊し、版の上げ幅が MAJOR の候補になる(Value 2・Value 3) | 含まない・前提 2 | +| S7 | 機械が読む値(工程の名前・周回の結果・フェーズの名前・失敗の種類など、`配布` を含む値)は新しい語へ変え、旧い値を持つ既存の記録は新しい値と同じに読む | 起票時の本文の未決 2 と受け入れ条件の案 4。値は課題の本文の「進行」と報告で人の目に触れる | 受け入れ条件 6・7 | +| S8 | `配布` を新しく書き足した変更は継続的統合が落とす | Value 5(決まりはスクリプトで固定する)。置き換えだけでは、次の変更で語が戻る | 受け入れ条件 2 | +| S9 | #2619(承認ゲートの呼び名)と同じレーンで、同じ周回か続く周回で進める | 起票時の本文の「進め方」。触るファイルが重なる | 前提 4 | + +## 目的 + +- 利用者と AI が、環境へ届ける操作(デプロイ)と本番へ届ける操作(リリース)を同じ語で呼び分け、承認が要るのはどちらかを語から読み取れる状態にする +- 1 つの意味に複数の語(配布・検証リリース・本番の配布・本番へのリリース)が当たっている状態をやめ、1 語 1 意味にそろえる(Value 8) + +## 前提 + +- 前提 1: 置き換えの対象は S3 の範囲で、S4 の書いた日の記録は対象外とする。`CHANGELOG.md` はこれから書く項だけを新しい語で書く +- 前提 2: Skill の名前・スラッシュコマンド・スクリプトのファイル名と副命令の名前(`release-steps.py`・`parallel.py request` など、英語の識別子)は変えない。対象は日本語の語だけである +- 前提 3: `配布` を含む語のうち、デプロイにもリリースにも当たらないもの(Skill をどのランタイムへ届けるか・利用者へ届く `plugins/` の中身を指す「配布物」など)は、設計で `配布` を含まない語を 1 つずつ決める。決めた語は用語集に載せる +- 前提 4: #2619 と触るファイルが重なるため、別のレーンで並行して触らない +- 前提 5: 旧い値を読む互換の経路を外す時期は設計で決める。外すまでの間、旧い値を読むコードとそのテストには `配布` が残ってよい + +## 対象範囲 + +含む: +- Skill の本文・references・エージェントの定義・`AGENTS.md`・`CLAUDE.md`・`README.md`・`.ndf/` の文 +- スクリプトが人へ出す文(報告・エラー・課題とプルリクエストへ書く本文)と、記録に書く値 +- 用語集(`docs/glossary/glossary.json` と生成される文書、`development-workflow` の用語集) +- 確定仕様(`docs/specifications/`)と現行の文書(`docs/versioning-and-distribution.md` など) +- Factory(`factory/`)の画面と API が人へ出す文 +- `配布` の再流入を落とす検査 + +含まない: +- S4 の書いた日の記録 +- Skill の名前・コマンド名・ファイル名・英語の識別子(前提 2) +- 文書のファイル名とアンカー(`versioning-and-distribution.md` など。英語の名前で、`配布` を含まない) +- 版の付け方・チャネル・承認ゲートの数と置き場所(語だけを変え、工程は変えない) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 利用者が「デプロイ」「リリース」の定義を決めた | 2026-11-06 の利用者の指示 | — | — | +| E2 | 用語集にデプロイ・リリースの定義と、旧い語の別名が載った | 実装の最初の変更 | 用語集の検査(`glossary.py`)が落ちる | E1 | +| E3 | 対象範囲の文書・Skill・Factory の文の `配布` を置き換えた | 実装 | 受け入れ条件 1 の検索で行が残る | E2(置き換え先の語が用語集で決まっている) | +| E4 | スクリプトが記録に書く値を新しい語にした | 実装 | 全体テストが落ちる | E2 | +| E5 | 実行中のループ・並列の計画が、旧い値を持つ既存の記録を読んだ | 版を上げた後の最初の周回 | 周回の結果や工程を読み違え、課題を選び直すか止まる | E4 | +| E6 | `配布` を書き足した変更が継続的統合で落ちた | 以後のプルリクエスト | 検査が通ると語が戻る | E3 | + +E5 の失敗の経路は受け入れ条件 7 へ、E6 は受け入れ条件 2 へ移した。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| デプロイ | 開発・本番を問わず、環境に変更を届けること | +| リリース | 本番へのデプロイだけを指す | +| 書いた日の記録 | 書いた日時点の内容を残し、以後の構成変更に追随しない文書(S4 の一覧) | + +## 受け入れ条件 + +- [ ] 1. 次の検索の結果が、受け入れ条件 6・7 の互換のコードとそのテスト、用語集の別名の欄だけになる: `git grep -nF 配布 -- plugins factory docs AGENTS.md CLAUDE.md README.md .ndf ':!docs/presentations' ':!docs/articles' ':!docs/archive' ':!docs/development-history' ':!docs/changelog'` +- [ ] 2. 対象範囲のファイルへ `配布` を含む行を足したプルリクエストは、継続的統合の検査が失敗し、失敗の出力にファイルと行が出る。1 の例外の場所へ足した行では失敗しない +- [ ] 3. 用語集に「デプロイ」(開発・本番を問わず環境に変更を届けること)と「リリース」(本番へのデプロイだけ)が載り、`glossary.py check` で「配布」「検証リリース」「本番の配布」「開発版の配布」を含む文書を調べると、廃止した語として当たり、置き換え先の語が出る +- [ ] 4. 開発版のチャネルへ版を出す操作と検証の環境への反映を指す文は「デプロイ」、正式版のチャネルへ出してタグを打つ操作を指す文は「リリース」で書かれている。「検証リリース」の語が受け入れ条件 1 の範囲に残らない +- [ ] 5. 前提 3 で決めた語(デプロイにもリリースにも当たらない意味の言い換え)が、それぞれ 1 つの意味で用語集に載っている +- [ ] 6. スクリプトが記録・報告・課題の本文の「進行」へ新しく書く値に `配布` が含まれない(例: 工程の名前・周回の結果・フェーズの名前・失敗の種類) +- [ ] 7. 旧い値(`配布`・`配布待ち`・`配布(開発版)`・`配布(本番)`・`配布なし` など)を持つ既存の記録を読むと、新しい値と同じに扱われる。旧い値を持つ実行中のループの記録で次の周回を選ぶと、旧い値の周回を新しい値の周回と同じに扱う(退行しない) +- [ ] 8. Skill の名前・スラッシュコマンド・スクリプトの副命令の名前が変わっていない(`plugins/ndf/manifests/` の行が変更の前後で同じ) +- [ ] 9. 全体テストが通る +- [ ] 10. S4 の書いた日の記録のファイルに差分が無い + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 移行性 | 版を上げた後も、上げる前に作られた実行中のループ・並列の計画の記録を読んで続けられる(受け入れ条件 7)。互換の経路を外す時期は設計で決める | +| 運用・保守性 | 語の再流入は継続的統合の検査で止まる(受け入れ条件 2) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | Skill の名前とコマンドは変わらない。記録に書く値は変わる。旧い値を読む互換の経路を持つ | +| データ | 既存の記録は書き換えない。読むときに旧い値を新しい値へ読み替える | +| 既存の振る舞い | 人へ出す文と記録の値の語が変わる。工程・承認ゲート・版の付け方は変わらない | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析・型検査 | `python3 scripts/check-skill-frontmatter.py`、`python3 plugins/ndf/scripts/instructions-check.py --root .`、`claude plugin validate .` | +| 語の検索 | 受け入れ条件 1 の `git grep` | +| 用語集 | `python3 plugins/ndf/scripts/glossary.py check --file <文書>`、`python3 plugins/ndf/scripts/glossary.py render` の後に差分が無いこと | +| 手動確認 | リリースした版で、開発ループを 1 周回し、課題の本文の「進行」と報告に `配布` が出ないことを見る | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。`plugins/` は `factory/` を参照しない | +| コーディング規約 | `AGENTS.md` の「ベストプラクティス」。`.md` の文言を照合するテストは書かない(受け入れ条件 1・2 は検査のスクリプトで見る) | +| テスト戦略 | 受け入れ条件 6・7 は単体テストで、旧い値を持つ記録の読み取りを確かめる。受け入れ条件 2 は検査のスクリプトのテストで確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 置き換えた語を用語集の語にそろえる。全体テストを流す | +| 確認してから行う | 記録に書く値の変更(承認ゲート 1 で設計を見る) | +| 行わない | 書いた日の記録の書き換え。Skill・コマンド・ファイルの改名。工程・承認ゲートの変更 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 前提 3 の語(「配布物」「配布先のランタイム」などの言い換え) | 設計(承認ゲート 1 で利用者が確かめる) | 設計 PR | +| 旧い値を読む互換の経路を外す時期 | 設計 | 設計 PR | +| 受け入れ条件 2 の検査の置き場(既存の検査へ足すか、用語集の廃止した語を使うか) | 設計 | 設計 PR | +| 量が人が 1 回の承認で読み切れる量に収まるか(2026-11-06 時点で `配布` は 2,129 行・467 ファイル。範囲外を除いても 2,000 行(受け入れ条件 1 の検索、75fc92737))。収まらなければ、プルリクエストを領域ごとに分ける | 実装計画 | 実装計画 | + +## 関連 + +- #2619(承認ゲートの呼び名を「設計承認ゲート」「リリース承認ゲート」へ変える) diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index 01f636e7e..b64dee7f4 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.27", - "description": "Claude Code plugin (v10.27.27): 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.31", + "description": "Claude Code plugin (v10.27.31): 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 b3996420f..2bedddee5 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.27", - "description": "Codex plugin (v10.27.27): 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.31", + "description": "Codex plugin (v10.27.31): 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 acb6ff7c9..0838b83cf 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.27/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.31/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.27/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.31/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.27 +# => ndf@ai-plugins installed, enabled 10.27.31 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__acceptance-check.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__acceptance-check.py.json index 5844161ba..f5d8643df 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__acceptance-check.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__acceptance-check.py.json @@ -31,7 +31,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_LOCK_DIR", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__ask.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__ask.py.json index 9acba0fe1..e9b2fa626 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__ask.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__ask.py.json @@ -28,7 +28,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_MVV_STATE_DIR", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__check-trigger.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__check-trigger.py.json index c2853e462..2bc042be7 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__check-trigger.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__check-trigger.py.json @@ -11,7 +11,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_PRESENTATION_DIR", - "TMPDIR" + "TMPDIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__context-policy.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__context-policy.py.json index 70be9e5ef..5b467cdc9 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__context-policy.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__context-policy.py.json @@ -50,6 +50,7 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-match.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-match.py.json index 10d18e340..30a3b9b79 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-match.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-match.py.json @@ -25,7 +25,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-survey.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-survey.py.json index 700300518..c9dbd56f1 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-survey.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-survey.py.json @@ -18,7 +18,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__doc-lint.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__doc-lint.py.json index 7f8ed37a7..8da990e49 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__doc-lint.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__doc-lint.py.json @@ -14,7 +14,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__estimate.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__estimate.py.json index 8f8426b2a..bb1ef2bf4 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__estimate.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__estimate.py.json @@ -56,6 +56,7 @@ "NDF_PRESENTATION_DIR", "NDF_SV_STATE_DIR", "NDF_WHOLE_RUNS_DIR", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [ diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__gate-rejections.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__gate-rejections.py.json index 71c706193..ccf677638 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__gate-rejections.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__gate-rejections.py.json @@ -21,7 +21,8 @@ "NDF_SUPERVISE_CLAUDE", "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_SUPERVISE_LIMIT_SLEEP", - "NDF_SUPERVISE_MODEL" + "NDF_SUPERVISE_MODEL", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__glossary.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__glossary.py.json index 9d5ca0b3e..b077ea21d 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__glossary.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__glossary.py.json @@ -8,7 +8,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__handoff.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__handoff.py.json index 9878519b5..5bdd72d88 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__handoff.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__handoff.py.json @@ -54,7 +54,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook-env.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook-env.py.json index bf372f1a1..8869e5000 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook-env.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook-env.py.json @@ -9,7 +9,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_HOOK_PYTHON", - "NDF_HOOK_REEXEC" + "NDF_HOOK_REEXEC", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json index 55f7a9630..e15bbe1dc 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__hook.py.json @@ -47,6 +47,7 @@ "SLACK_BOT_TOKEN", "SLACK_CHANNEL_ID", "TMPDIR", + "UV_PROJECT_ENVIRONMENT", "XDG_DATA_HOME", "XDG_STATE_HOME", "ZDOTDIR" diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__instructions-check.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__instructions-check.py.json index 896173c26..8f64ae9bc 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__instructions-check.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__instructions-check.py.json @@ -15,7 +15,8 @@ }, "env": [ "NDF_DEPS_REEXEC", - "NDF_DEPS_VENV" + "NDF_DEPS_VENV", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__issue-file.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__issue-file.py.json index c635e3f64..b7efc7620 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__issue-file.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__issue-file.py.json @@ -53,7 +53,8 @@ "NDF_DEPS_VENV", "NDF_ISSUE_REQUEST_FILE", "NDF_PRESENTATION_DIR", - "NDF_SKILL_REPO" + "NDF_SKILL_REPO", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__merged-steps.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__merged-steps.py.json index aaf244063..9de6bc4ea 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__merged-steps.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__merged-steps.py.json @@ -13,7 +13,8 @@ "NDF_DEPS_VENV", "NDF_LOCK_DIR", "NDF_PRESENTATION_DIR", - "NDF_WORKTREE_BASE" + "NDF_WORKTREE_BASE", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__mvv-gate.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__mvv-gate.py.json index 67e42ea64..14c8b18fb 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__mvv-gate.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__mvv-gate.py.json @@ -42,7 +42,8 @@ "NDF_SUPERVISE_CLAUDE", "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_SUPERVISE_LIMIT_SLEEP", - "NDF_SUPERVISE_MODEL" + "NDF_SUPERVISE_MODEL", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel-measure.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel-measure.py.json index 301352e99..4cbe1dfcc 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel-measure.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel-measure.py.json @@ -48,7 +48,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_LOCK_DIR", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json index 1369d99b1..fd2b73500 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -183,6 +183,7 @@ "NDF_SV_STATE_DIR", "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [ diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__plan-to-spec-steps.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__plan-to-spec-steps.py.json index be6d3a0a1..a8be0cf4f 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__plan-to-spec-steps.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__plan-to-spec-steps.py.json @@ -21,7 +21,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__pr-steps.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__pr-steps.py.json index 4978179f7..c6d7ad93a 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__pr-steps.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__pr-steps.py.json @@ -12,7 +12,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_LOCK_DIR", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-decl.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-decl.py.json index 5ba905d55..fa36d1223 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-decl.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-decl.py.json @@ -42,7 +42,8 @@ "MONITOR_NO_EARLY_ERROR", "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-mvv.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-mvv.py.json index 8b84d52ed..32b1e7315 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-mvv.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__project-mvv.py.json @@ -21,7 +21,8 @@ "NDF_SUPERVISE_CLAUDE", "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_SUPERVISE_LIMIT_SLEEP", - "NDF_SUPERVISE_MODEL" + "NDF_SUPERVISE_MODEL", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json index 087a4e2f4..ac600d509 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__relay.py.json @@ -29,6 +29,7 @@ "NDF_SUPERVISE_CLAUDE_FALLBACK", "PATH", "SHELL", + "UV_PROJECT_ENVIRONMENT", "XDG_DATA_HOME", "XDG_STATE_HOME", "ZDOTDIR" diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-steps.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-steps.py.json index 255421ac4..254c8a913 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-steps.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-steps.py.json @@ -129,6 +129,7 @@ "NDF_LOCK_DIR", "NDF_PRESENTATION_DIR", "NDF_WHOLE_RUNS_DIR", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [ diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-verification-steps.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-verification-steps.py.json index 954c868ce..59ca2935b 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-verification-steps.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__release-verification-steps.py.json @@ -24,7 +24,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_PRESENTATION_DIR", - "PATH" + "PATH", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__spec-copy.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__spec-copy.py.json index 5c2f0438d..7bbc44bf5 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__spec-copy.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__spec-copy.py.json @@ -12,7 +12,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_LOCK_DIR", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-close.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-close.py.json index 8c1757c35..9cb9ee0cd 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-close.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-close.py.json @@ -23,7 +23,8 @@ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "NDF_LOCK_DIR", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json index d3019b025..916a7f904 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__sprint-state.py.json @@ -41,6 +41,7 @@ "NDF_UPKEEP_STATE_DIR", "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [ diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__stage-skills.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__stage-skills.py.json index 9d8883614..633cb16eb 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__stage-skills.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__stage-skills.py.json @@ -17,7 +17,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json index 4d9ef14ef..f8c0d5229 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__supervise.py.json @@ -34,6 +34,7 @@ "NDF_SV_STATE_DIR", "NDF_VERSION", "NDF_WHOLE_RUNS_DIR", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [ diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__test-run.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__test-run.py.json index b01b0d0d9..e116cdadf 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__test-run.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__test-run.py.json @@ -13,6 +13,7 @@ "NDF_ESTIMATE_DIR", "NDF_PRESENTATION_DIR", "NDF_WHOLE_RUNS_DIR", + "UV_PROJECT_ENVIRONMENT", "WT_DOCKER_COMMAND", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__training-optout.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__training-optout.py.json index cd9c82a35..5febc85f5 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__training-optout.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__training-optout.py.json @@ -31,7 +31,8 @@ "NDF_DEPS_VENV", "NDF_PRESENTATION_DIR", "NDF_SHARED_CONFIG_DIR", - "NDF_SUPERVISE_CLAUDE_FALLBACK" + "NDF_SUPERVISE_CLAUDE_FALLBACK", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "hook-json", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__usage-report.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__usage-report.py.json index 75bb8ed71..07c4cce0d 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__usage-report.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__usage-report.py.json @@ -20,7 +20,8 @@ "CLAUDE_CONFIG_DIR", "CODEX_HOME", "NDF_DEPS_REEXEC", - "NDF_DEPS_VENV" + "NDF_DEPS_VENV", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__wait-notify.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__wait-notify.py.json index 732be7f6c..7253f1fb3 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__wait-notify.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__wait-notify.py.json @@ -21,6 +21,7 @@ "SLACK_BOT_TOKEN", "SLACK_CHANNEL_ID", "SLACK_USER_MENTION", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__backlog-refinement__scripts__upkeep.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__backlog-refinement__scripts__upkeep.py.json index 584a9516a..f5bf3542a 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__backlog-refinement__scripts__upkeep.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__backlog-refinement__scripts__upkeep.py.json @@ -26,6 +26,7 @@ "NDF_SUPERVISE_MODEL", "NDF_UPKEEP_NO_SLEEP", "NDF_UPKEEP_STATE_DIR", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [ diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__drive.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__drive.py.json index e184d47b6..5f13dbba8 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__drive.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__drive.py.json @@ -32,6 +32,7 @@ "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_WHOLE_RUNS_DIR", "NDF_WORKTREE_BASE", + "UV_PROJECT_ENVIRONMENT", "WT_DOCKER_COMMAND", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__refactor.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__refactor.py.json index d4cd70791..77ccc51e6 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__refactor.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-refactoring__scripts__refactor.py.json @@ -33,6 +33,7 @@ "NDF_SUPERVISE_MODEL", "NDF_WHOLE_RUNS_DIR", "NDF_WORKTREE_BASE", + "UV_PROJECT_ENVIRONMENT", "WT_DOCKER_COMMAND", "XDG_STATE_HOME" ], diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__drive.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__drive.py.json index 84d48f32f..e5b718917 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__drive.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__drive.py.json @@ -32,6 +32,7 @@ "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_WHOLE_RUNS_DIR", "NDF_WORKTREE_BASE", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [ diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__state.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__state.py.json index 0eb5612a8..3d2455e8f 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__state.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__cross-review__scripts__state.py.json @@ -147,7 +147,8 @@ "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_SUPERVISE_LIMIT_SLEEP", "NDF_SUPERVISE_MODEL", - "NDF_WORKTREE_BASE" + "NDF_WORKTREE_BASE", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__external-ai__scripts__external-ai.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__external-ai__scripts__external-ai.py.json index ef289d96e..6b7c5f489 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__external-ai__scripts__external-ai.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__external-ai__scripts__external-ai.py.json @@ -47,7 +47,8 @@ "NDF_SKIP_AUTH_CHECK", "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_USAGE_KIND", - "NDF_USAGE_SOURCE" + "NDF_USAGE_SOURCE", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__fix__scripts__fix-steps.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__fix__scripts__fix-steps.py.json index 8e2729470..9d2b95bf2 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__fix__scripts__fix-steps.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__fix__scripts__fix-steps.py.json @@ -59,7 +59,8 @@ "NDF_SUPERVISE_CLAUDE", "NDF_SUPERVISE_CLAUDE_FALLBACK", "NDF_SUPERVISE_LIMIT_SLEEP", - "NDF_SUPERVISE_MODEL" + "NDF_SUPERVISE_MODEL", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__gws-check.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__gws-check.py.json index 3254dd662..0b963169a 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__gws-check.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__gws-check.py.json @@ -11,7 +11,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__slides-thumbnails.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__slides-thumbnails.py.json index 4ba895397..8055e08ac 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__slides-thumbnails.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__google-workspace__scripts__slides-thumbnails.py.json @@ -14,7 +14,8 @@ "env": [ "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [], "stdin": "none", diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__pr-review__scripts__pr-review-steps.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__pr-review__scripts__pr-review-steps.py.json index ab899ac4d..5819b42e4 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__pr-review__scripts__pr-review-steps.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__pr-review__scripts__pr-review-steps.py.json @@ -10,7 +10,8 @@ "GITHUB_TOKEN", "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", - "NDF_PRESENTATION_DIR" + "NDF_PRESENTATION_DIR", + "UV_PROJECT_ENVIRONMENT" ], "settings": [ { diff --git a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__skill-stats__scripts__skill-stats.py.json b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__skill-stats__scripts__skill-stats.py.json index 4fb0fcb19..d484e13c7 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__skill-stats__scripts__skill-stats.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__skills__skill-stats__scripts__skill-stats.py.json @@ -31,6 +31,7 @@ "NDF_CONTEXT_POLICY", "NDF_DEPS_REEXEC", "NDF_DEPS_VENV", + "UV_PROJECT_ENVIRONMENT", "XDG_STATE_HOME" ], "settings": [], diff --git a/plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json b/plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json index db5b03b76..1fe137de1 100644 --- a/plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json +++ b/plugins/ndf/contracts/env/NDF_PARALLEL_STALL_MINUTES.json @@ -1,5 +1,5 @@ { "name": "NDF_PARALLEL_STALL_MINUTES", "set_by": "user", - "summary": "並列の計画の run が動かないレーンを問うまでの分(既定 60。plugins/ndf/scripts/lib/parallel_run.py)" + "summary": "並列の計画の run が動かないレーンと、続く状態の一覧の失敗を問うまでの分(既定 60。plugins/ndf/scripts/lib/parallel_run.py)" } diff --git a/plugins/ndf/contracts/env/UV_PROJECT_ENVIRONMENT.json b/plugins/ndf/contracts/env/UV_PROJECT_ENVIRONMENT.json new file mode 100644 index 000000000..d81c03a02 --- /dev/null +++ b/plugins/ndf/contracts/env/UV_PROJECT_ENVIRONMENT.json @@ -0,0 +1,5 @@ +{ + "name": "UV_PROJECT_ENVIRONMENT", + "set_by": "user", + "summary": "uv のプロジェクトの環境の置き場。lib/deps.py の require が起動し直すときに共有の環境へ向け、起動し直した後に利用者の値へ戻す(無ければ外す)。共有の環境を指す値は子プロセスへ継がせない(#2509)" +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index 605fd74fd..aba6c392b 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -166,6 +166,7 @@ | `SR_GAPS` | runtime | `plugins/ndf/scripts/progress-record.sh`
`plugins/ndf/scripts/projects-sync.sh` | | `SR_GH_ERR` | runtime | `plugins/ndf/scripts/progress-record.sh`
`plugins/ndf/scripts/projects-sync.sh` | | `TMPDIR` | runtime | `plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/worktree-deps.sh`
`plugins/ndf/scripts/worktree-session.sh` | +| `UV_PROJECT_ENVIRONMENT` | user | `plugins/ndf/scripts/acceptance-check.py`
`plugins/ndf/scripts/ask.py`
`plugins/ndf/scripts/check-trigger.py`
`plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/design-match.py`
`plugins/ndf/scripts/design-survey.py`
`plugins/ndf/scripts/doc-lint.py`
`plugins/ndf/scripts/estimate.py`
`plugins/ndf/scripts/gate-rejections.py`
`plugins/ndf/scripts/glossary.py`
`plugins/ndf/scripts/handoff.py`
`plugins/ndf/scripts/hook-env.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/instructions-check.py`
`plugins/ndf/scripts/issue-file.py`
`plugins/ndf/scripts/merged-steps.py`
`plugins/ndf/scripts/mvv-gate.py`
`plugins/ndf/scripts/parallel-measure.py`
`plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/plan-to-spec-steps.py`
`plugins/ndf/scripts/pr-steps.py`
`plugins/ndf/scripts/project-decl.py`
`plugins/ndf/scripts/project-mvv.py`
`plugins/ndf/scripts/relay.py`
`plugins/ndf/scripts/release-steps.py`
`plugins/ndf/scripts/release-verification-steps.py`
`plugins/ndf/scripts/spec-copy.py`
`plugins/ndf/scripts/sprint-close.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/stage-skills.py`
`plugins/ndf/scripts/supervise.py`
`plugins/ndf/scripts/test-run.py`
`plugins/ndf/scripts/training-optout.py`
`plugins/ndf/scripts/usage-report.py`
`plugins/ndf/scripts/wait-notify.py`
`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`
`plugins/ndf/skills/cross-refactoring/scripts/drive.py`
`plugins/ndf/skills/cross-refactoring/scripts/refactor.py`
`plugins/ndf/skills/cross-review/scripts/drive.py`
`plugins/ndf/skills/cross-review/scripts/state.py`
`plugins/ndf/skills/external-ai/scripts/external-ai.py`
`plugins/ndf/skills/fix/scripts/fix-steps.py`
`plugins/ndf/skills/google-workspace/scripts/gws-check.py`
`plugins/ndf/skills/google-workspace/scripts/slides-thumbnails.py`
`plugins/ndf/skills/pr-review/scripts/pr-review-steps.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | | `WF_MODES` | runtime | `plugins/ndf/scripts/progress-record.sh` | | `WF_STAGE_MATRIX` | runtime | `plugins/ndf/scripts/progress-record.sh` | | `WT_DECLARATION_FILE` | runtime | `plugins/ndf/scripts/worktree-deps.sh`
`plugins/ndf/scripts/worktree-setup.sh` | diff --git a/plugins/ndf/scripts/lib/deps.py b/plugins/ndf/scripts/lib/deps.py index afa64ec2d..d5ad06528 100644 --- a/plugins/ndf/scripts/lib/deps.py +++ b/plugins/ndf/scripts/lib/deps.py @@ -14,7 +14,9 @@ `require()` は次の順に動く(決定 17 の表)。 1. 渡したグループのパッケージがすべて import できる → `NDF_DEPS_REEXEC` を環境から外して戻る(uv の環境の中で - 起動されたとき)。外すのは、子のプロセスが別のグループを要るときに起動し直せるようにするため + 起動されたとき)。外すのは、子のプロセスが別のグループを要るときに起動し直せるようにするため。 + `UV_PROJECT_ENVIRONMENT` も `release_project_env` で起動し直す前の利用者の値へ戻し、共有の環境を指す値は外す + (子の `uv run --project <別のプロジェクト>` が共有の環境を作り直さないため。#2509) 2. 環境変数 `NDF_DEPS_REEXEC` が自分のスクリプトのパス → 起動し直したのに import できない。理由を出して終了コード 69。 印の値は起動し直したスクリプトのパスで、別のパス(印を外す前の版の親から継いだ `1` など)なら 3 へ進む 3. uv が見つかる(`PATH`・`~/.local/bin`・`~/.cargo/bin`)→ `uv run --frozen --project <プラグインの根> --extra <グループ> ... @@ -65,6 +67,9 @@ } EXIT_DEPS_MISSING = 69 # 依存の欠け(sysexits.h の EX_UNAVAILABLE)。共通の契約の 0〜3・10〜29 と重ねない REEXEC_ENV = "NDF_DEPS_REEXEC" +PROJECT_ENV = "UV_PROJECT_ENVIRONMENT" +# 起動し直す前の利用者の UV_PROJECT_ENVIRONMENT。無かったときは置かない(#2509 の決定 2) +SAVED_ENV = "NDF_DEPS_SAVED_UV_PROJECT_ENVIRONMENT" PLUGIN_ROOT = Path(__file__).resolve().parents[2] INSTALL_HINT = f"curl -LsSf https://astral.sh/uv/{UV_VERSION}/install.sh | sh" @@ -128,6 +133,33 @@ def venv_dir(root: Path = PLUGIN_ROOT) -> str: return os.environ.get("NDF_DEPS_VENV") or str(Path.home() / ".cache" / "ndf" / "venv" / venv_version(root)) +def _real(path: str | Path) -> str: + return os.path.realpath(os.path.expanduser(str(path))) + + +def is_shared_venv(path: str | None) -> bool: + """`path` が共有の環境の置き場か(`NDF_DEPS_VENV` と同じか、親が `~/.cache/ndf/venv`)。版の名前には依らない(#2509 の決定 3)。""" + if not path: + return False + p, forced = _real(path), os.environ.get("NDF_DEPS_VENV") + return (bool(forced) and p == _real(forced)) or os.path.dirname(p) == _real(Path.home() / ".cache" / "ndf" / "venv") + + +def release_project_env(script: str | None = None) -> None: + """NDF が起動し直すために設定した `UV_PROJECT_ENVIRONMENT` を子プロセスへ継がせない(#2509 の I5〜I7)。 + + 自分を起動し直した回(`NDF_DEPS_REEXEC` が `script`)なら退避した利用者の値を戻し、無ければ外す。続けて、値が共有の + 環境を指していれば外す(古い版の親から継いだ値も含む)。退避の変数も外す。""" + saved = os.environ.pop(SAVED_ENV, None) + if script is not None and os.environ.get(REEXEC_ENV) == script: + if saved is None: + os.environ.pop(PROJECT_ENV, None) + else: + os.environ[PROJECT_ENV] = saved + if is_shared_venv(os.environ.get(PROJECT_ENV)): + os.environ.pop(PROJECT_ENV, None) + + def reexec_argv(uv: str, groups: str | Sequence[str], script: str, args: list[str], root: Path = PLUGIN_ROOT) -> list[str]: extras = [x for g in ([groups] if isinstance(groups, str) else groups) for x in ("--extra", g)] return [uv, "run", "--quiet", "--frozen", "--project", str(root), *extras, "python", script, *args] @@ -159,11 +191,12 @@ def require(group: str, *more: str, project: Path | None = None) -> None: root = PLUGIN_ROOT if project is None else Path(project).resolve() groups = _normalize_groups(group, more) missing = [g for g in groups if not importable(g)] + script = str(Path(sys.argv[0]).resolve()) if not missing: + release_project_env(script) # 印を外す前に呼ぶ(起動し直した回かを印で決める) # 起動し直した印は子のプロセス(別のグループを要るエントリポイント)へ継がせない。継ぐと子は起動し直さずに止まる os.environ.pop(REEXEC_ENV, None) return - script = str(Path(sys.argv[0]).resolve()) if os.environ.get(REEXEC_ENV) == script: mods = ", ".join(m for g in missing for m in GROUPS[g]) _stop( @@ -174,5 +207,8 @@ def require(group: str, *more: str, project: Path | None = None) -> None: _stop(f"外部パッケージの宣言が無い: {root}/pyproject.toml と uv.lock") uv = _resolve_uv_or_stop() venv = venv_dir() if root == PLUGIN_ROOT else str(root / ".venv") - env = dict(os.environ, **{REEXEC_ENV: script, "UV_PROJECT_ENVIRONMENT": venv}) + env = {k: v for k, v in os.environ.items() if k != SAVED_ENV} + if PROJECT_ENV in os.environ: # 利用者の値は起動し直した後に戻す(release_project_env) + env[SAVED_ENV] = os.environ[PROJECT_ENV] + env.update({REEXEC_ENV: script, PROJECT_ENV: venv}) os.execve(uv, reexec_argv(uv, groups, script, sys.argv[1:], root), env) diff --git a/plugins/ndf/scripts/lib/dist_record.py b/plugins/ndf/scripts/lib/dist_record.py index d6fd156cb..9a29f1612 100644 --- a/plugins/ndf/scripts/lib/dist_record.py +++ b/plugins/ndf/scripts/lib/dist_record.py @@ -45,8 +45,10 @@ def released(stage) -> bool: def _strip_note(text): - """版の文字列から全角の括弧の注記を落とす。""" - return re.sub(r"\s*(.*$", "", text).strip() + """版の文字列から全角の括弧の注記と、数字の前の先頭の小文字の `v` を落とす(`v10.26.1` と `10.26.1` を同じ版に読む。#2225)。 + + 本番の版(`版:`)と対象の版(`対象の版:`)の両方がここを通る。大文字の `V` と版数でない表記(`main c427284`)は変えない。""" + return re.sub(r"^v(?=\d)", "", re.sub(r"\s*(.*$", "", text).strip()) def _read_dist_block(block): diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py index 295f8d55d..df03ccc52 100644 --- a/plugins/ndf/scripts/lib/parallel_answer.py +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -23,6 +23,13 @@ def questions(main: Path, name: str) -> tuple[int, list[dict]]: MVV_VERDICTS = ("unknown", "unreadable") # レーンの判定が決まらなかった(判定できない・判定を読めない) +def not_listed(rid: str, items: list[dict]) -> dict: + """今の問いの一覧に無い依頼への答えなら、結果へ足す `warning` を返す(#2617)。""" + if any(q.get("id") == rid for q in items): + return {} + return {"warning": f"依頼 {rid} は今の問いの一覧に無い。答えは保留され、その依頼が届いたら当たる(答え済みなら何もしない)"} + + def mvv_mark(q: dict) -> dict: """MVV 判定でオーガナイザーが自分で答えてよい依頼か(#2507 の決定 8)。設計の承認で、レーンの判定が決まらず、 レッドラインが空で、利用者へ戻した依頼(印 `returned`。#2507 の決定 9)でなく、判定を打ち直す材料(レーンの周回のスプリント状態)が残るときだけ true。""" @@ -85,14 +92,14 @@ def drop(q: dict) -> str | None: def owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None, items: list[dict] | None = None) -> str | None: - """答えの後の 1 行。自分で立てた問い(stall・helper・spawn)は選んだ答えの commands、ほかは答えを待って止まった持ち主の + """答えの後の 1 行。自分で立てた問い(stall・helper・spawn・observe)は選んだ答えの commands、ほかは答えを待って止まった持ち主の restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。同じ原因としてまとめた問い(`merged`)は、 代表だけでなく全持ち主をそれぞれの依頼の id で調べ、起こす行を `; ` でつなぐ(異常系 5)。""" import parallel_run as pr_run items = questions(main, name)[1] if items is None else items q = next((q for q in items if q.get("id") == rid), {}) - if q.get("kind") in ("stall", "helper", "spawn") or not q.get("lane"): + if q.get("kind") in ("stall", "helper", "spawn", "observe") or not q.get("lane"): return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) ls = lambda: pr_run.relay(main, "ls", "--json") # noqa: E731 owners = [(q["lane"], rid), *((m.get("lane"), m.get("id")) for m in q.get("merged") or [] if m.get("lane"))] diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index f1162c4ef..afe698379 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -44,6 +44,16 @@ def record_exists(main: Path, name: str) -> bool: return record_path(KIND, record_identity(main, name)).exists() +def live_run_pid(main: Path, name: str) -> Optional[int]: + """`run.pid` の run が動いていればその pid。""" + try: + pid = int((pp.plan_dir(main, name) / "run.pid").read_text().strip()) + os.kill(pid, 0) + except (OSError, ValueError): + return None + return pid + + def run_status(main: Path, name: str) -> Optional[str]: """計画の耐久ワークフローの状態(記録が無ければ None)。""" if not record_exists(main, name): diff --git a/plugins/ndf/scripts/lib/parallel_repo.py b/plugins/ndf/scripts/lib/parallel_repo.py new file mode 100644 index 000000000..f855f4e6f --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_repo.py @@ -0,0 +1,49 @@ +"""並列の計画が読む配るリポジトリの状態(ベースブランチの先頭・配るプラグイン・開発版の版)。`parallel_run` が使う。""" + +from __future__ import annotations + +import json +import subprocess +from pathlib import Path + + +def base_tracking_ref(main: Path) -> str: + """ベースブランチ(`.ndf/worktree.json` の `base_branch` → 既定ブランチ)の `origin/<名>`。決まらなければ空。""" + import repo + + b = repo.base_branch(main) + return f"origin/{b}" if b else "" + + +def release_plugin(main: Path) -> str: + """配るプラグイン(`.ndf/supervise.json` の `release.plugin`)。無ければ空。""" + from supervise_lib.decl import read_decl + + return str((read_decl([Path(main)], "supervise.json").get("release") or {}).get("plugin") or "") + + +def git_head(main: Path) -> str: + """ベースブランチの先頭のコミット。ベースブランチが決まらなければ空。""" + ref = base_tracking_ref(main) + if not ref: + return "" + subprocess.run(["git", "fetch", "-q", "origin"], cwd=main, capture_output=True) + got = subprocess.run(["git", "rev-parse", ref], cwd=main, capture_output=True, text=True) + return (got.stdout or "").strip() + + +def dev_version(main: Path) -> str: + """ベースブランチの配るプラグインの `plugin.json` の開発版の版数から接尾辞を外した版(出す版)。決まらなければ空。""" + import re + + from release_version import plugin_json_rel + + ref, plugin = base_tracking_ref(main), release_plugin(main) + rel = plugin_json_rel(main, ref, plugin) if ref and plugin else None + if not rel: + return "" + got = subprocess.run(["git", "show", f"{ref}:{rel}"], cwd=main, capture_output=True, text=True) + try: + return re.sub(r"-.*$", "", json.loads(got.stdout).get("version") or "") if got.returncode == 0 else "" + except ValueError: + return "" diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 9a4e43ca4..b9da850a5 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -26,6 +26,7 @@ import parallel_lane as pl import parallel_plan as pp import parallel_release as pr +from parallel_repo import dev_version, git_head from parallel_question import cause_of from sprint_loop_names import LOOP_DIR, PENDING @@ -48,56 +49,20 @@ def relay_argv() -> list[str]: return alt.split() if alt else [sys.executable, str(RELAY)] -def relay(main: Path, *args: str) -> tuple[int, dict]: - """統括役の CLI を打ち、(終了コード, 統一 JSON レポート) を返す。読めなければ空の dict。""" +def relay_proc(main: Path, *args: str) -> tuple[subprocess.CompletedProcess, dict]: + """統括役の CLI を打ち、(終わったプロセス, 統一 JSON レポート) を返す。読めなければ空の dict。""" got = subprocess.run([*relay_argv(), *args], cwd=main, capture_output=True, text=True) lines = [ln for ln in (got.stdout or "").splitlines() if ln.strip().startswith("{")] try: - return got.returncode, json.loads(lines[-1]) if lines else {} + return got, json.loads(lines[-1]) if lines else {} except ValueError: - return got.returncode, {} - - -def base_tracking_ref(main: Path) -> str: - """ベースブランチ(`.ndf/worktree.json` の `base_branch` → 既定ブランチ)の `origin/<名>`。決まらなければ空。""" - import repo - - b = repo.base_branch(main) - return f"origin/{b}" if b else "" - - -def release_plugin(main: Path) -> str: - """配るプラグイン(`.ndf/supervise.json` の `release.plugin`)。無ければ空。""" - from supervise_lib.decl import read_decl - - return str((read_decl([Path(main)], "supervise.json").get("release") or {}).get("plugin") or "") - - -def git_head(main: Path) -> str: - """ベースブランチの先頭のコミット。ベースブランチが決まらなければ空。""" - ref = base_tracking_ref(main) - if not ref: - return "" - subprocess.run(["git", "fetch", "-q", "origin"], cwd=main, capture_output=True) - got = subprocess.run(["git", "rev-parse", ref], cwd=main, capture_output=True, text=True) - return (got.stdout or "").strip() - - -def dev_version(main: Path) -> str: - """ベースブランチの配るプラグインの `plugin.json` の開発版の版数から接尾辞を外した版(出す版)。決まらなければ空。""" - import re + return got, {} - from release_version import plugin_json_rel - ref, plugin = base_tracking_ref(main), release_plugin(main) - rel = plugin_json_rel(main, ref, plugin) if ref and plugin else None - if not rel: - return "" - got = subprocess.run(["git", "show", f"{ref}:{rel}"], cwd=main, capture_output=True, text=True) - try: - return re.sub(r"-.*$", "", json.loads(got.stdout).get("version") or "") if got.returncode == 0 else "" - except ValueError: - return "" +def relay(main: Path, *args: str) -> tuple[int, dict]: + """統括役の CLI を打ち、(終了コード, 統一 JSON レポート) を返す。読めなければ空の dict。""" + got, res = relay_proc(main, *args) + return got.returncode, res # ---- 耐久ステップ ---- @@ -143,14 +108,16 @@ def stall_seconds() -> float: @durable.step(name="parallel.observe") def step_observe(main: str) -> dict: - """`relay.py ls --json` の行(名前 → {state, at, role})と、観た時刻(エポック秒)。""" - _, res = relay(Path(main), "ls", "--json") + """`relay.py ls --json` の行(名前 → {state, at, role})・観た時刻(エポック秒)・終了コード・標準エラーの末尾 1 行。 + `can_ask` は一覧の失敗を問える版の記録の印で、無い記録の再生では問わない(問いの DBOS の呼び出しが記録の順を崩す。決定 5)。""" + got, res = relay_proc(Path(main), "ls", "--json") rows = { it["name"]: {"state": it.get("state"), "at": it.get("at"), "role": it.get("role")} for it in res.get("items") or [] if isinstance(it, dict) and it.get("name") } - return {"now": time.time(), "rows": rows} + err = (got.stderr or "").strip().splitlines()[-1:] + return {"now": time.time(), "rows": rows, "code": got.returncode, "err": err[0] if err else "", "can_ask": True} @durable.step(name="parallel.held-loops") @@ -290,6 +257,10 @@ def __init__(self, main: str, name: str, lanes: list[dict]): self.revive: list[str] = [] # 問いへ答えた後、まだ動き出していない止まった 1 スプリントのレーン(6f) self.revive_at: dict[str, float] = {} # revive に入れたときの最後の観測の時刻(エポック秒)。最後に動いた時刻と比べる self.seen_at = 0.0 # 最後に観測した時刻(エポック秒) + self.ls_ok = True # 最後の観測で一覧が成功したか。失敗した回の後は全レーンが止まったと読まない(#2509 の I2) + self.ls_failed_since: Optional[float] = None # 一覧の失敗が始まった時刻(エポック秒)。成功に戻ると None + self.ls_ask_until = 0.0 # 一覧の失敗を次に問うてよい時刻(エポック秒) + self.held: dict[str, dict] = {} # 一覧に無い依頼への答え。その依頼の問いが届いたら当てる(#2617) def bump(self) -> int: self.seq += 1 @@ -347,21 +318,34 @@ def receive_question(self, msg: dict) -> None: same["also"] = [*same.get("also", []), msg.get("id")] same["merged"] = [*same.get("merged", []), {"id": msg.get("id"), "lane": msg.get("lane"), "question": msg.get("question")}] self.publish_questions() + self.apply_held(msg.get("id"), same.get("id")) return if msg.get("source") == "ndf-ask:human-action": # 代われない操作: レーンへ入る 1 行を添える(6d) msg = {**msg, "attach": f"python3 {RELAY} attach {msg.get('lane')}"} self.questions.append({**msg, "cause": key} if key else msg) self.publish_questions() + self.apply_held(msg.get("id")) + + def apply_held(self, rid, target=None) -> None: + """保留した依頼 `rid` への答えを、届いた問い(まとめた先があれば `target`)へ当てる。""" + if (msg := self.held.pop(rid, None)) is not None: + self.reply({**msg, "id": target or rid}) def publish_questions(self) -> None: durable.set_event("questions", {"seq": self.bump(), "items": self.questions}) def reply(self, msg: dict) -> None: - """オーガナイザーの答えを `answer-<依頼>` に 1 回だけ立て、問いの一覧から外す。答えの後の処理は持ち主のレーンが行う(6f)。""" + """オーガナイザーの答えを `answer-<依頼>` に 1 回だけ立て、問いの一覧から外す。答えの後の処理は持ち主のレーンが行う(6f)。 + 一覧に無い依頼への答えは捨てずに保留し、run.log へ警告を残す(#2617)。""" rid = msg.get("id") - if rid in self.answered or all(q.get("id") != rid for q in self.questions): + if rid in self.answered: + return + q = next((q for q in self.questions if q.get("id") == rid), None) + if q is None: + if rid and rid not in self.held: + self.held[rid] = msg + print(f"parallel: 一覧に無い依頼 {rid} への答えを保留した(届いたら当てる)", file=sys.stderr, flush=True) return - q = next(q for q in self.questions if q.get("id") == rid) for each in [rid, *q.get("also", [])]: self.answered.add(each) durable.set_event(f"answer-{each}", {k: msg.get(k) for k in ("result", "note", "choice", "stamp", "merge", "by", "at")}) @@ -388,13 +372,16 @@ def apply_own(self, q: dict, choice: list[str]) -> None: rel["prs"] = step_presented(rel["dirs"][-1]).get("prs") or sorted(own) self.settle(rel, {"result": "failed", "reason": "配布の途中で止まった一時レーンを、人が止めると答えた"}) - def ask(self, kind: str, rid: str, lane: str, question: str, options: list[str], **extra) -> None: - """耐久ワークフローが自分で立てる問い(動かないレーン・一時レーンの停止・起動の断り)。同じ id は 1 回だけ。""" + def ask(self, kind: str, rid: str, lane: Optional[str], question: str, options: list[str], **extra) -> None: + """耐久ワークフローが自分で立てる問い(動かないレーン・一時レーンの停止・起動の断り・一覧の失敗)。同じ id は 1 回だけ。 + レーンに紐づかない問い(`lane` が None)は `lane` と `attach` を持たない。""" if rid in self.answered or any(q.get("id") == rid for q in self.questions): return - item = {"kind": kind, "id": rid, "lane": lane, "source": "ndf-ask:human-action", "question": question, "options": options} - self.questions.append({**item, "attach": f"python3 {RELAY} attach {lane}", **extra}) + item = {"kind": kind, "id": rid, "source": "ndf-ask:human-action", "question": question, "options": options} + where = {"lane": lane, "attach": f"python3 {RELAY} attach {lane}"} if lane else {} + self.questions.append({**item, **where, **extra}) self.publish_questions() + self.apply_held(rid) def answer(self, rid: str, result: str, version: Optional[str], n: Optional[int], reason: Optional[str] = None) -> None: self.answered.add(rid) @@ -661,12 +648,16 @@ def _spawn_ready(run: _Run, main: str) -> None: def _run_finished(run: _Run) -> bool: """配布の途中でも残りでもなく、答えていない問いも無く、全レーンが止まっている(I11)。""" - return run.current is None and not run.pending and not run.questions and len(run.stopped()) == len(run.order) + return run.ls_ok and run.current is None and not run.pending and not run.questions and len(run.stopped()) == len(run.order) def _observe_run(run: _Run, main: str) -> None: - """レーンの状態を観測して反映し、承認待ちへ移ったレーンを知らせる。""" + """レーンの状態を観測して反映し、承認待ちへ移ったレーンを知らせる。一覧が失敗した回は、一覧の失敗の追跡のほかは何も変えない(#2509 の I1)。""" seen = step_observe(main) + run.ls_ok = int(seen.get("code") or 0) == 0 # code の無い古い記録は成功として読む(決定 5) + _track_ls_failure(run, seen) + if not run.ls_ok: + return rows = seen.get("rows") or {} states = {n: r.get("state") for n, r in rows.items()} run.seen_at = float(seen.get("now") or 0) @@ -689,6 +680,27 @@ def _observe_run(run: _Run, main: str) -> None: run.pause("notice", lanes=noticed, attach=[f"python3 {RELAY} attach {n}" for n in noticed]) +def _track_ls_failure(run: _Run, seen: dict) -> None: + """一覧の失敗が停滞の区切りより長く続いたら、区切りごとに 1 回問う(#2509 の I3)。成功に戻ったら追跡を解き、答えていない + 一覧の失敗の問いを外す(I4。残すと答えを待って計画が終われない)。""" + if run.ls_ok: + run.ls_failed_since, run.ls_ask_until = None, 0.0 + if any(q.get("kind") == "observe" for q in run.questions): + run.questions = [q for q in run.questions if q.get("kind") != "observe"] + run.publish_questions() + return + now, limit = float(seen.get("now") or 0), stall_seconds() + if run.ls_failed_since is None: + run.ls_failed_since = now + if now - run.ls_failed_since < limit or now < run.ls_ask_until or not seen.get("can_ask"): + return + run.ls_ask_until = now + limit + mins = int((now - run.ls_failed_since) // 60) + rebuild, rid = "ラッパーの環境を作り直す", f"{run.name}-observe-{int(now)}" + text = f"レーンの状態の一覧(relay.py ls)が {mins} 分続けて失敗している(終了コード {seen.get('code')}: {seen.get('err') or ''})" + run.ask("observe", rid, None, text, [rebuild, "待つ"], commands={rebuild: f"python3 {RELAY} status"}) + + def _clear_revived(run: _Run, rows: dict) -> None: """起こしたレーンの最後に動いた時刻(`relay.py ls` の `at`)が、答えたときの観測の時刻より後へ進んだら `revive` から外す。 答えを当ててすぐ止まるレーンは観測の間隔の中で stopped → running → stopped と動き、状態の変化として見えないためである。""" diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index a7c8cef7e..d16ae4544 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -95,26 +95,27 @@ def candidates_of(ms: dict, max_issues: int, held: set[int] = frozenset()) -> li PRODUCTION_REASON = "設計 PR の宛先が本番(承認ゲート 2 で人がマージする)" -def approved_designs(rec: dict) -> set[int]: - """承認し、設計 PR のマージが済み、その後の周回でまだ選ばれていない課題(#2507 の I7)。""" +def approved_designs(rec: dict, reselecting: int | None = None) -> set[int]: + """承認し、設計 PR のマージが済み、その後の周回でまだ選ばれていない課題(#2507 の I7)。 + reselecting は取り直している周回の番号で、その周回の前の選定は「選ばれた」に数えない。""" out = set() for w in rec.get("waiting", []): if w.get("answer") != "approved" or not w.get("merged_at"): continue n = w["number"] - later = [lap for lap in rec.get("laps", []) if lap.get("n", 0) > (w.get("lap") or 0)] + later = [lap for lap in rec.get("laps", []) if lap.get("n", 0) > (w.get("lap") or 0) and lap.get("n") != reselecting] if not any(r.get("number") == n and r.get("decision") == SELECTED for lap in later for r in lap.get("selection") or []): out.add(n) return out -def first_issues(rec: dict) -> set[int]: +def first_issues(rec: dict, reselecting: int | None = None) -> set[int]: """順位によらず先頭に選ぶ課題(#2436 の 6e・#2507): 設計を直して出し直す課題・配布を差し戻された周回の直し・ 承認とマージが済んだ設計の課題。""" last = {w["number"]: w for w in rec.get("waiting", [])} revise = {n for n, w in last.items() if w.get("answer") == "revise"} fixes = {n for f in rec.get("fixes", []) if not f.get("fixed_by") for n in f.get("issues") or []} - return revise | fixes | approved_designs(rec) + return revise | fixes | approved_designs(rec, reselecting) def prerequisite_reason(row: dict) -> str: @@ -321,8 +322,8 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic order = [t for t in m.get("order", []) if t in by_title and (not rec.get("milestones") or t in rec["milestones"])] approvals = {(a["milestone"], a["sha256"]) for a in rec.get("sprint_mvv_approvals", [])} rows_out, total = [], 0 - first = first_issues(rec) - approved = approved_designs(rec) + first = first_issues(rec, lap_n) + approved = approved_designs(rec, lap_n) for title in order: ms = by_title[title] cands = milestone_candidates(ms, rec, first, lap_n) diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 91f71cee2..31a36fd5e 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -5,7 +5,7 @@ | --- | --- | | `init <計画名>` | 標準入力の下書きを検査し、`plan.json`・`plan.md`・レーンごとの引継ぎ文書を書く(在る文書は上書きせず、何も書かない) | | `check <計画名>` | 並列の計画を検査する(誤りは 1、警告だけなら 0) | -| `start <計画名>` | `run` をセッションから切り離して起動する(動いていれば今の pid を返す) | +| `start <計画名>` | `run` をセッションから切り離して起動する(動いていれば打ったセッションへオーガナイザーの印を付け直し、今の pid を返す。`metrics.organizer` が付けられたか) | | `run <計画名>` | 耐久ワークフローを始めるか続け、終わるまで動き続ける。ラッパーの外・tmux が無ければ手で起動する 1 行を返す(3) | | `wait <計画名> [--after ] [--timeout 秒]` | 状態の変化(notice / release / confirmed / questions)か終わりまで待つ。上限なら 3 | | `present <計画名> <連番> <提示> --approval <承認資料>` | 範囲の Pull Request をレーンへ対応づけ、提示の置き場(`releases/<連番>/<提示>/`)へ書く | @@ -16,7 +16,7 @@ | `await <計画名> --from <パス> [--timeout 秒]` | 依頼の答えを読む(released 0・rejected 10・failed 1・まだ無い 3) | | `request-ask <計画名> --lane <名>` | 標準入力の問い(`tool_input`・`cwd`・`ledger_id`)をオーガナイザーへ送る(送り手でなければ 4) | | `request-gate2 <計画名> <連番> <提示> --report <承認資料>` | 一時レーンが承認ゲート 2 の依頼を送る | -| `answer <計画名> <依頼> (--approved [--by mvv] \\| --revise --note <パス> \\| --rejected \\| --choice <答え>...)` | 問いの答えを送る(持ち主のレーンが止まっていれば `metrics.next` に起こす 1 行)。設計の承認では設計 PR をマージしてから送り、結果を `metrics.merge` に、答えの後に残る依頼を `metrics.after` に載せる | +| `answer <計画名> <依頼> (--approved [--by mvv] \\| --revise --note <パス> \\| --rejected \\| --choice <答え>...)` | 問いの答えを送る(持ち主のレーンが止まっていれば `metrics.next` に起こす 1 行。今の問いの一覧に無い依頼なら `metrics.warning`)。設計の承認では設計 PR をマージしてから送り、結果を `metrics.merge` に、答えの後に残る依頼を `metrics.after` に載せる | | `helper <計画名> <連番> <提示>` | 一時レーンが流すもの(`phase`・`pipeline`・`settle`・`gate2`)を返す | 外のオーガナイザーとの `--external` の副命令(#2439)は `lib/parallel_external_cli.py` にある。 @@ -173,13 +173,12 @@ def cmd_start(a) -> None: plan, items, metrics = checked_plan(main, a.name) if plan is None: reply("stopped", f"並列の計画 {a.name} の検査に誤りがあるため、レーンを 1 本も起動しない", items, 1, **metrics) - pidfile = pp.plan_dir(main, a.name) / "run.pid" - try: - pid = int(pidfile.read_text().strip()) - os.kill(pid, 0) - reply("ok", f"run は動いている(pid {pid})", pid=pid, next=_wait_line(a.name)) - except (OSError, ValueError): - pass + pid = pl.live_run_pid(main, a.name) + if pid is not None: + import parallel_run as pr_run + + marked = pr_run.relay(main, "role", "organizer", "--plan", a.name)[0] == 0 # 打ったセッションへ付け直す(#2637) + reply("ok", f"run は動いている(pid {pid})", pid=pid, organizer=marked, next=_wait_line(a.name)) if pl.run_status(main, a.name) is None: _manual_or_none(main, plan) log = pp.plan_dir(main, a.name) / "run.log" @@ -401,7 +400,7 @@ def cmd_answer(a) -> None: q = next((q for q in items if q.get("id") == a.id), {"id": a.id}) nxt = pa.owner_restart(main, a.name, a.id, a.choice, items) tail, extra = pa.answer_with_merge(main, a.name, q, result, items=items, note=note, choice=a.choice, by=a.by) - extra.update(id=a.id, result=result, next=nxt, seq=seq) + extra.update(id=a.id, result=result, next=nxt, seq=seq, **pa.not_listed(a.id, items)) reply("ok", f"依頼 {a.id} へ答え({result})を送った{tail}", **extra) diff --git a/plugins/ndf/scripts/relay_lib/handover.py b/plugins/ndf/scripts/relay_lib/handover.py index add0d29b8..f8819daef 100644 --- a/plugins/ndf/scripts/relay_lib/handover.py +++ b/plugins/ndf/scripts/relay_lib/handover.py @@ -334,7 +334,7 @@ def from_handover(cls, path: str, serve) -> int: # 新しい方へ印を置いてから古い方の印を消す(I11・決定 7。pid は exec で変わらない) inuse = version_dir.claim_inuse() if h["from_dir"] != os.path.basename(PKG_ROOT): - version_dir.release_inuse(os.path.join(copy_base(), h["from_dir"], f"inuse-{os.getpid()}")) + version_dir.release_claims(os.path.join(copy_base(), h["from_dir"])) written = h["written_at"] relay.log( event="reexec", diff --git a/plugins/ndf/scripts/relay_lib/hub.py b/plugins/ndf/scripts/relay_lib/hub.py index 2988e10fc..06e4890eb 100644 --- a/plugins/ndf/scripts/relay_lib/hub.py +++ b/plugins/ndf/scripts/relay_lib/hub.py @@ -85,6 +85,11 @@ def max_named() -> int: return max(1, int(env_num("NDF_RELAY_MAX_NAMED", 3))) +def launch_check_seconds() -> float: + """起動の後、セッションが残っているかを確かめるまで待つ秒数(`NDF_RELAY_LAUNCH_CHECK`。既定 2)。""" + return env_num("NDF_RELAY_LAUNCH_CHECK", 2) + + def wait_seconds() -> float: """頼む側が結果を待つ秒数(`NDF_RELAY_HUB_WAIT`。既定 10。決定 14)。""" return env_num("NDF_RELAY_HUB_WAIT", 10) @@ -441,6 +446,10 @@ def launch( why = tmux.new_session(name, cwd, argv, env) if why: return self.refuse(kind, name, Refusal(TMUX_FAILED, why, 1)) + time.sleep(launch_check_seconds()) + if tmux.session_gone(name): + why = f"{name} のセッションが起動の直後に消えた(起動のコマンド: {argv[0]})" + return self.refuse(kind, name, Refusal(TMUX_FAILED, why, 1)) registry[name] = {"session": name, "cwd": cwd, "args": args, "created_at": stamp(), "by": by} if role: registry[name].update(role=role, plan=plan) diff --git a/plugins/ndf/scripts/relay_lib/run.py b/plugins/ndf/scripts/relay_lib/run.py index 003a3e84c..c084f5967 100644 --- a/plugins/ndf/scripts/relay_lib/run.py +++ b/plugins/ndf/scripts/relay_lib/run.py @@ -155,6 +155,7 @@ def read_mark(self): return self.record.read_mark() def tick(self): + version_dir.refresh_inuse() if self.halted: return None try: diff --git a/plugins/ndf/scripts/relay_lib/runtime.py b/plugins/ndf/scripts/relay_lib/runtime.py index d86eea89a..e3a1abeb0 100644 --- a/plugins/ndf/scripts/relay_lib/runtime.py +++ b/plugins/ndf/scripts/relay_lib/runtime.py @@ -121,7 +121,11 @@ def _reexec(python: str, launcher: str, argv: list[str]) -> None: def enter(argv: list[str], launcher: str, root: str = PKG_ROOT) -> int | None: """副命令を動かせる python で動いていれば None(続ける)。起動し直すなら戻らない。 - 環境が無くて副命令を動かさないときは、終了コードを返す(`run` は素通しして戻らない)。""" + 環境が無くて副命令を動かさないときは、終了コードを返す(`run` は素通しして戻らない)。 + + 共有の環境を指す `UV_PROJECT_ENVIRONMENT`(古い版の NDF のスクリプトから継いだ値)は、子の claude と hook へ継がせない(#2509 の I6)。""" + if deps.is_shared_venv(os.environ.get("UV_PROJECT_ENVIRONMENT")): + os.environ.pop("UV_PROJECT_ENVIRONMENT", None) if ready(): os.environ.pop(REEXEC_ENV, None) # 子の claude と hook へ継がせない return None diff --git a/plugins/ndf/scripts/relay_lib/tmux.py b/plugins/ndf/scripts/relay_lib/tmux.py index f68d5404a..7b0a029eb 100644 --- a/plugins/ndf/scripts/relay_lib/tmux.py +++ b/plugins/ndf/scripts/relay_lib/tmux.py @@ -58,6 +58,12 @@ def has_session(name: str) -> bool: return p is not None and p.returncode == 0 +def session_gone(name: str) -> bool: + """セッション `name` が無いと tmux が答えた(サーバごと無いときも)。tmux に聞けなければ偽。""" + p = _tmux("has-session", "-t", f"={name}") + return p is not None and p.returncode != 0 + + def sessions() -> dict[str, dict]: """在るセッションの {名前: {attached, activity, pid}}。サーバが無ければ空。""" p = _tmux("list-sessions", "-F", "#{session_name}\t#{session_attached}\t#{window_activity}\t#{pane_pid}") diff --git a/plugins/ndf/scripts/relay_lib/version_dir.py b/plugins/ndf/scripts/relay_lib/version_dir.py index 2aa5e04c7..99477ded8 100644 --- a/plugins/ndf/scripts/relay_lib/version_dir.py +++ b/plugins/ndf/scripts/relay_lib/version_dir.py @@ -5,10 +5,15 @@ relay.py ランチャー(プラグインの scripts/relay.py と同じバイト列) relay.version 複製の版 relay.current 使うバージョンディレクトリの名前 1 行。原子的に書き換える - relay-<版>-/ relay_lib/・lib/(LIB_FILES)・pyproject.toml・uv.lock・MANIFEST・.venv/・inuse- + relay-<版>-/ relay_lib/・lib/(LIB_FILES)・pyproject.toml・uv.lock・MANIFEST・.venv/・inuse-<持ち主>- digest は MANIFEST(ファイルごとの sha256 と相対パス)の sha256 である。バージョンディレクトリは書き終えてから -`relay.current` を替え(I9)、動いている run が使うもの(生きている pid の `inuse-`)は消さない。 +`relay.current` を替え(I9)、動いている run が使うもの(使用中の印 `inuse-<持ち主>-`)は消さない。 + +置き場は別のコンテナと共有の volume にあり、pid は作ったコンテナの中でしか読めない(#2583)。持ち主はホスト名 +(コンテナの識別)で、同じ持ち主の印は pid の生死で、別の持ち主の印は寿命の窓(`FOREIGN_WINDOW`)で決める +(`lib/run_owner.py` の `alive` と同じ方針)。動いているラッパーは印の時刻を進める(`refresh_inuse`)。 +旧形式の `inuse-` は持ち主が分からず、pid の生死で読む。 `.venv/` はラッパーを動かす環境で、書きかけのディレクトリの中でプラグインと同じ `pyproject.toml` と `uv.lock` から `uv sync --frozen` で作る(決定 20。`runtime.sync`)。作れなければバージョンディレクトリを置かない。 @@ -20,6 +25,8 @@ import os import re import shutil +import socket +import time from . import runtime from .common import ( @@ -62,7 +69,12 @@ PROJECT_FILES = ("pyproject.toml", "uv.lock") DIR_RE = re.compile(r"^relay-(.+)-[0-9a-f]{8}$") TMP_RE = re.compile(r"^relay-.+-[0-9a-f]{8}\.tmp-(\d+)$") +INUSE_RE = re.compile(r"^inuse-(?:(?P.+)-)?(?P\d+)$") KEEP = 2 +# 別のコンテナの印の寿命の窓。`lib/run_owner.py` の `WINDOW` と同じ値(run_owner は複製に入らないため import しない) +FOREIGN_WINDOW = 24 * 3600 +REFRESH = 3600 # 動いているラッパーが使用中の印の時刻を進める間隔の秒(窓より十分短く) +_refreshed_at = 0.0 def version_of(name: str | None) -> str | None: @@ -110,11 +122,20 @@ def _alive(pid: int) -> bool: return procs.pid_alive(pid) +def inuse_owner() -> str: + """使用中の印の持ち主(このホストまたはコンテナの識別)。""" + return re.sub(r"[^0-9A-Za-z._-]", "_", socket.gethostname()) or "unknown" + + +def inuse_name(pid: int) -> str: + return f"inuse-{inuse_owner()}-{pid}" + + def claim_inuse(root: str = PKG_ROOT, pid: int | None = None) -> str | None: - """`root` がバージョンディレクトリなら `inuse-` を置いてそのパスを返す。プラグインのキャッシュなら None。""" + """`root` がバージョンディレクトリなら使用中の印を置いてそのパスを返す。プラグインのキャッシュなら None。""" if not os.path.isfile(os.path.join(root, MANIFEST)): return None - path = os.path.join(root, f"inuse-{os.getpid() if pid is None else pid}") + path = os.path.join(root, inuse_name(os.getpid() if pid is None else pid)) try: os.close(os.open(path, os.O_WRONLY | os.O_CREAT, 0o600)) except OSError: @@ -130,6 +151,37 @@ def release_inuse(path: str | None) -> None: pass +def release_claims(root: str) -> None: + """`root` に置いたこのプロセスの印を消す。入れ替えの前の版が置いた旧形式の印も消す(pid は exec で変わらない)。""" + for name in (inuse_name(os.getpid()), f"inuse-{os.getpid()}"): + release_inuse(os.path.join(root, name)) + + +def refresh_inuse(root: str = PKG_ROOT) -> None: + """使用中の印の時刻を `REFRESH` 秒ごとに進める。別のコンテナの片付けが寿命の窓で読む。消えていれば置き直す。""" + global _refreshed_at + now = time.time() + if now - _refreshed_at < REFRESH: + return + _refreshed_at = now + path = claim_inuse(root) + if path: + try: + os.utime(path) + except OSError: + pass + + +def _mark_live(mark: str, owner: str | None, pid: int) -> bool: + """旧形式(持ち主なし)と自分のコンテナの印は pid の生死、別のコンテナの印は最終更新が寿命の窓の中か。""" + if owner in (None, inuse_owner()): + return _alive(pid) + try: + return time.time() - os.stat(mark).st_mtime < FOREIGN_WINDOW + except OSError: + return False + + class VersionDir: """`base` の下のバージョンディレクトリと、使うものを指す `relay.current`。中身の元は `source`。""" @@ -204,7 +256,7 @@ def _write(self, target: str, manifest: str) -> None: def prune(self) -> list[str]: """指されず、生きている run も使っていないバージョンディレクトリを、新しい 2 つを残して消す。 - 落ちた書きかけ(死んだ pid の `.tmp-`)と、死んだ pid の `inuse-` も消す。消したパスを返す。""" + 落ちた書きかけ(死んだ pid の `.tmp-`)と、使用中でなくなった印も消す。消したパスを返す。""" try: names = os.listdir(self.base) except OSError: @@ -227,18 +279,17 @@ def prune(self) -> list[str]: @staticmethod def _in_use(path: str) -> bool: + """印が 1 つでも使用中ならディレクトリは使用中。使用中でなくなった印は消す。""" used = False for n in os.listdir(path): - if not n.startswith("inuse-"): - continue - try: - pid = int(n[len("inuse-") :]) - except ValueError: + m = INUSE_RE.match(n) + if not m: continue - if _alive(pid): + mark = os.path.join(path, n) + if _mark_live(mark, m.group("owner"), int(m.group("pid"))): used = True else: - release_inuse(os.path.join(path, n)) + release_inuse(mark) return used def remove_all(self) -> list[str]: diff --git a/plugins/ndf/scripts/tests/test_deps.py b/plugins/ndf/scripts/tests/test_deps.py index 13fcb4744..d246c7605 100644 --- a/plugins/ndf/scripts/tests/test_deps.py +++ b/plugins/ndf/scripts/tests/test_deps.py @@ -8,6 +8,7 @@ import json import os import re +import shutil import subprocess import sys from pathlib import Path @@ -276,3 +277,125 @@ def groups(path: Path) -> dict[str, str]: for pins in root.values(): for name, ver in re.findall(r'"([\w-]+)==([\w.]+)"', pins): assert f'name = "{name}"\nversion = "{ver}"' in lock + + +# ---------------------------------------------------------------- 子プロセスへ UV_PROJECT_ENVIRONMENT を漏らさない(#2509 の I5〜I7) + +REEXEC_UV = """#!/bin/sh +while [ "$1" != python ]; do shift; done +shift +PYTHONPATH="$FAKE_SITE" exec "$FAKE_PY" "$@" +""" + + +def run_reexec_child(tmp_path: Path, **env: str) -> dict[str, str]: + """起動し直した先で import が通る偽の uv(`PYTHONPATH` で印のパッケージを足して本物の python を起動する)で + require を通し、スクリプト自身と子プロセスから見た `UV_PROJECT_ENVIRONMENT` を返す。""" + bindir, site = tmp_path / "bin", tmp_path / "site" + bindir.mkdir(exist_ok=True) + site.mkdir(exist_ok=True) + (site / "ndf_reexec_mark.py").write_text("") + (bindir / "uv").write_text(REEXEC_UV) + (bindir / "uv").chmod(0o755) + script = tmp_path / "entry.py" + script.write_text( + f"import os, subprocess, sys\nsys.path.insert(0, {str(LIB)!r})\nimport deps\n" + "deps.GROUPS['t'] = ['ndf_reexec_mark']\ndeps.require('t')\n" + "print('SELF=' + repr(os.environ.get('UV_PROJECT_ENVIRONMENT')))\n" + "print('SAVED=' + repr(os.environ.get('NDF_DEPS_SAVED_UV_PROJECT_ENVIRONMENT')))\n" + "c = [sys.executable, '-c', \"import os; print('CHILD=' + repr(os.environ.get('UV_PROJECT_ENVIRONMENT')))\"]\n" + "sys.stdout.write(subprocess.run(c, capture_output=True, text=True).stdout)\n" + ) + base = {k: v for k, v in os.environ.items() if k not in ("NDF_DEPS_REEXEC", "UV_PROJECT_ENVIRONMENT", "PYTHONPATH")} + base.update( + PATH=str(bindir), HOME=str(tmp_path / "home"), NDF_DEPS_VENV=str(tmp_path / "venv"), + FAKE_SITE=str(site), FAKE_PY=sys.executable, **env, + ) # fmt: skip + p = subprocess.run([sys.executable, str(script)], capture_output=True, text=True, env=base) + assert p.returncode == 0, p.stderr + return dict(ln.split("=", 1) for ln in p.stdout.splitlines() if "=" in ln) + + +def test_a_reexeced_script_does_not_pass_the_shared_venv_to_children(tmp_path): + """受け入れ条件 2・I5・I6: 起動し直したスクリプトの子に、NDF が設定した共有の環境が渡らない。""" + got = run_reexec_child(tmp_path) + assert got == {"SELF": "None", "SAVED": "None", "CHILD": "None"} + + +def test_a_reexeced_script_gives_back_the_users_own_value(tmp_path): + """I7: 利用者が自分で設定した値は、起動し直した後に戻って子へ渡る。""" + mine = str(tmp_path / "my-project-venv") + got = run_reexec_child(tmp_path, UV_PROJECT_ENVIRONMENT=mine) + assert got == {"SELF": repr(mine), "SAVED": "None", "CHILD": repr(mine)} + + +def test_a_shared_venv_inherited_from_an_old_parent_is_dropped(tmp_path): + """I6・決定 3: 古い版の親から共有の環境を指す値を継いでも、起動し直した後の子へ渡さない。""" + got = run_reexec_child(tmp_path, UV_PROJECT_ENVIRONMENT=str(tmp_path / "venv")) + assert got["CHILD"] == "None" + + +def test_an_importable_require_drops_an_inherited_shared_venv(monkeypatch, tmp_path): + """I6: 起動し直さずに戻る回も、継いだ共有の環境(既定の置き場の下)を外し、利用者の値は残す。""" + monkeypatch.setitem(deps.GROUPS, "t", ["json"]) + monkeypatch.delenv("NDF_DEPS_VENV", raising=False) + monkeypatch.setenv("HOME", str(tmp_path)) + monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", str(tmp_path / ".cache" / "ndf" / "venv" / "9.9.9")) + deps.require("t") + assert "UV_PROJECT_ENVIRONMENT" not in os.environ + monkeypatch.setenv("UV_PROJECT_ENVIRONMENT", str(tmp_path / "mine")) + deps.require("t") + assert os.environ["UV_PROJECT_ENVIRONMENT"] == str(tmp_path / "mine") + + +def minor_of(python: str) -> tuple[int, int] | None: + """`python` のマイナー版。起動できなければ None。""" + p = subprocess.run([python, "-c", "import sys; print(*sys.version_info[:2])"], capture_output=True, text=True) + return tuple(int(x) for x in p.stdout.split()) if p.returncode == 0 else None # type: ignore[return-value] + + +def test_a_child_uv_run_of_another_project_leaves_the_shared_venv_alone(tmp_path): + """受け入れ条件 6: 本物の uv で、起動し直したスクリプトの子が打つ別のプロジェクトの `uv run` が共有の環境を作り直さない。 + + ネットワークを使わないよう、Python は手元の 2 つを使い、psutil は uv のキャッシュから入れる。そろわなければ skip する。""" + uv = shutil.which("uv") + first, second = getattr(sys, "_base_executable", sys.executable), "/usr/bin/python3" + if uv is None or not Path(second).exists(): + pytest.skip("uv か /usr/bin/python3 が無い") + a, b = minor_of(first), minor_of(second) + if a is None or b is None or a == b: + pytest.skip(f"マイナー版の違う Python が 2 つそろわない({a} と {b})") + drop = ("NDF_DEPS_REEXEC", "NDF_DEPS_VENV", "UV_PROJECT_ENVIRONMENT", "VIRTUAL_ENV", "PYTHONPATH") + base = {k: v for k, v in os.environ.items() if k not in drop} + base.update(UV_PYTHON_DOWNLOADS="never", UV_OFFLINE="1") + shared, bare = tmp_path / "shared", tmp_path / "bare" + for venv, python in ((shared, first), (bare, first)): + if ( + p := subprocess.run([uv, "venv", "--quiet", "--python", python, str(venv)], capture_output=True, text=True, env=base) + ).returncode: + pytest.skip(f"環境を作れない: {p.stderr.strip()}") + sync = [uv, "sync", "--quiet", "--frozen", "--offline", "--project", str(PLUGIN), "--extra", "procs"] + p = subprocess.run(sync, capture_output=True, text=True, env={**base, "UV_PROJECT_ENVIRONMENT": str(shared)}) + if p.returncode: + pytest.skip(f"uv のキャッシュから psutil を入れられない: {p.stderr.strip()}") + other = tmp_path / "other" + other.mkdir() + (other / "pyproject.toml").write_text( + f'[project]\nname = "other"\nversion = "0"\nrequires-python = ">={b[0]}.{b[1]},<{b[0]}.{b[1] + 1}"\n' + ) + script = tmp_path / "entry.py" + script.write_text( + f"import subprocess, sys\nsys.path.insert(0, {str(LIB)!r})\nimport deps\ndeps.require('procs')\n" + f"c = [{uv!r}, 'run', '--quiet', '--project', {str(other)!r}, '--python', {second!r}, 'python', '-c', 'pass']\n" + "sys.exit(subprocess.run(c, cwd=sys.argv[1]).returncode)\n" + ) + cfg = shared / "pyvenv.cfg" + before = (cfg.read_text(), cfg.stat().st_mtime_ns) + # psutil の無い環境の python で打ち、require に共有の環境へ起動し直させる + p = subprocess.run( + [str(bare / "bin" / "python"), str(script), str(other)], capture_output=True, text=True, env={**base, "NDF_DEPS_VENV": str(shared)} + ) + assert p.returncode == 0, p.stderr + assert (cfg.read_text(), cfg.stat().st_mtime_ns) == before + assert (other / ".venv").is_dir() # 別のプロジェクトは自分の環境で動く + assert subprocess.run([str(shared / "bin" / "python"), "-c", "import psutil"], capture_output=True).returncode == 0 diff --git a/plugins/ndf/scripts/tests/test_parallel_answer.py b/plugins/ndf/scripts/tests/test_parallel_answer.py index 37c7ef513..8257eb4ce 100644 --- a/plugins/ndf/scripts/tests/test_parallel_answer.py +++ b/plugins/ndf/scripts/tests/test_parallel_answer.py @@ -130,3 +130,15 @@ def test_answers_in_a_row_leave_the_earlier_one_out_of_after(cli, capsys): cli["items"] = [gate1("lc-g701-c", lane="lc", pr=701)] # 受け手が a・b を処理した _, out = answer(cli, capsys, "lc-g701-c", "--rejected") assert pa.sent_answers(cli["mod"].main_of(), "p1", cli["items"]) == [{"id": "lc-g701-c", "kind": "gate1", "lane": "lc", "pr": 701}] + + +def test_an_answer_to_an_id_outside_the_list_warns(cli, capsys): + """一覧に無い依頼への答えは送って保留されるが、`parallel.py answer` が警告を返す(#2617 の受け入れ条件 2)。""" + code, res = answer(cli, capsys, "la-q-unknown", "--choice", "はい") + assert code == 0 and res["metrics"]["warning"] and "la-q-unknown" in res["metrics"]["warning"] + assert [s[0] for s in cli["sent"]] == ["la-q-unknown"] + + +def test_an_answer_to_an_id_in_the_list_has_no_warning(cli, capsys): + code, res = answer(cli, capsys, "la-g501-a", "--choice", "はい") + assert code == 0 and "warning" not in res["metrics"] diff --git a/plugins/ndf/scripts/tests/test_parallel_plan.py b/plugins/ndf/scripts/tests/test_parallel_plan.py index 8db0f98e1..7ae7454bb 100644 --- a/plugins/ndf/scripts/tests/test_parallel_plan.py +++ b/plugins/ndf/scripts/tests/test_parallel_plan.py @@ -4,6 +4,7 @@ import copy import json +import os import subprocess import sys from pathlib import Path @@ -208,3 +209,37 @@ def test_handoff_name_follows_handoff_py(name, ok): plan, _, metrics = pp.check_data(variant(x=lambda d: d["lanes"][0].__setitem__("handoff", name))) assert (plan is not None) is ok assert (metrics["errors"] == 0) is ok + + +def _running_plan(tmp_path: Path) -> tuple[Path, dict]: + """run が動いている(`run.pid` の pid が生きている)計画と、ラッパーの中のセッションの環境。""" + (tmp_path / "repo").mkdir() + main = _git_main(tmp_path / "repo") + assert _cli(main, "init", "p1", stdin=json.dumps(BASE))[0] == 0 + (pp.plan_dir(main, "p1") / "run.pid").write_text(f"{os.getpid()}\n") + return main, {k: v for k, v in os.environ.items() if k != "NDF_PARALLEL_RELAY"} + + +def test_start_on_a_running_plan_marks_the_session_as_organizer(tmp_path): + """動いている run でも、`start` を打ったセッションのラッパーへオーガナイザーの印を付ける(#2637)。""" + main, env = _running_plan(tmp_path) + relay_dir = tmp_path / "wrapper" + relay_dir.mkdir() + got = subprocess.run( + [sys.executable, str(SCRIPTS / "parallel.py"), "start", "p1"], + cwd=main, capture_output=True, text=True, env={**env, "NDF_RELAY_DIR": str(relay_dir)}, + ) # fmt: skip + res = json.loads(got.stdout.strip().splitlines()[-1]) + assert got.returncode == 0 and res["metrics"]["pid"] == os.getpid() and res["metrics"]["organizer"] is True + role = json.loads((relay_dir / "role.json").read_text()) + assert (role["role"], role["plan"]) == ("organizer", "p1") + + +def test_start_on_a_running_plan_outside_a_wrapper_still_answers(tmp_path): + """ラッパーの外では印を付けられないが、`start` の答えは今のまま(次の 1 手を返す)で、run を増やさない。""" + main, env = _running_plan(tmp_path) + env.pop("NDF_RELAY_DIR", None) + got = subprocess.run([sys.executable, str(SCRIPTS / "parallel.py"), "start", "p1"], cwd=main, capture_output=True, text=True, env=env) + res = json.loads(got.stdout.strip().splitlines()[-1]) + assert got.returncode == 0 and res["metrics"]["organizer"] is False and res["metrics"]["pid"] == os.getpid() + assert (pp.plan_dir(main, "p1") / "run.pid").read_text().strip() == str(os.getpid()) diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index d60741ff6..01e693ef7 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -44,6 +44,9 @@ with open(log, "a") as f: f.write(json.dumps(sys.argv[1:]) + "\\n") sub = sys.argv[1] + if sub == "ls" and os.path.exists(os.environ.get("FAKE_LS_FAIL", "")): + print("ls: hub に届かない", file=sys.stderr) + sys.exit(3) if sub == "ls": st = json.load(open(states)) if os.path.exists(states) else {} rows = [{"name": n, **(s if isinstance(s, dict) else {"state": s})} for n, s in st.items()] @@ -70,6 +73,7 @@ def env(tmp_path, monkeypatch): monkeypatch.setenv("NDF_PARALLEL_RELAY", f"{sys.executable} {fake}") monkeypatch.setenv("FAKE_LOG", str(log)) monkeypatch.setenv("FAKE_STATES", str(states)) + monkeypatch.setenv("FAKE_LS_FAIL", str(tmp_path / "ls.fail")) monkeypatch.setenv("NDF_PARALLEL_POLL", "0.2") heads = {"v": "sha1"} monkeypatch.setattr(prun, "git_head", lambda _m: heads["v"]) @@ -779,6 +783,60 @@ def test_a_revived_lane_that_stops_between_observations_leaves_revive(monkeypatc assert run.revive == [] +def _answers(monkeypatch): + """立った答えのイベントを {キー: 値} で集める。""" + box = {} + monkeypatch.setattr(durable, "set_event", lambda key, value: box.update({key: value})) + return box + + +def test_an_answer_to_an_unknown_id_is_held_until_the_question_arrives(monkeypatch, capsys): + """一覧に無い id への答えは捨てずに保留して警告を残し、その id の問いが届いたら当てる(#2617)。""" + run = _bare_run(monkeypatch, [{"name": "la"}]) + box = _answers(monkeypatch) + run.take({"kind": "answer", "id": "q1", "result": "answered", "note": "n", "by": "user"}) + assert "answer-q1" not in box and "q1" not in run.answered + assert "q1" in capsys.readouterr().err + run.take({"kind": "ask", "id": "q1", "lane": "la", "question": "?"}) + assert box["answer-q1"]["result"] == "answered" and box["answer-q1"]["note"] == "n" + assert run.questions == [] and "q1" in run.answered and run.held == {} + + +def test_a_held_answer_reaches_a_question_merged_by_cause(monkeypatch): + """保留した答えの問いが同じ原因の問いへまとまったら、まとめた問いごと答える。""" + run = _bare_run(monkeypatch, [{"name": "la"}, {"name": "lb"}]) + box = _answers(monkeypatch) + run.questions = [{"id": "q0", "kind": "ask", "lane": "la", "cause": "c"}] + run.take({"kind": "answer", "id": "q1", "result": "answered"}) + monkeypatch.setattr(prun, "cause_of", lambda _m: "c") + run.take({"kind": "ask", "id": "q1", "lane": "lb"}) + assert box["answer-q0"]["result"] == box["answer-q1"]["result"] == "answered" and run.questions == [] + + +def test_a_held_answer_reaches_a_question_the_workflow_asks_itself(monkeypatch): + """耐久ワークフローが自分で立てる問いにも、保留した答えを当てる。""" + run = _bare_run(monkeypatch, [{"name": "la"}]) + box = _answers(monkeypatch) + run.take({"kind": "answer", "id": "s1", "result": "answered", "choice": ["止める"]}) + run.ask("spawn", "s1", "la", "起動できない", ["止める"]) + assert box["answer-s1"]["choice"] == ["止める"] and run.lanes["la"].get("given_up") is True + + +def test_a_repeated_answer_stands_once_and_warns_once(monkeypatch, capsys): + """既に答えた id の重複は今のまま 1 回だけで、保留もしない。保留中の id の重複は最初の答えを保つ。""" + run = _bare_run(monkeypatch, [{"name": "la"}]) + box = _answers(monkeypatch) + run.questions = [{"id": "q1", "kind": "ask", "lane": "la"}] + run.reply({"id": "q1", "result": "answered"}) + box.clear() + run.reply({"id": "q1", "result": "rejected"}) + assert box == {} and run.held == {} + capsys.readouterr() + run.reply({"id": "q2", "result": "first"}) + run.reply({"id": "q2", "result": "second"}) + assert run.held["q2"]["result"] == "first" and capsys.readouterr().err.count("q2") == 1 + + def test_a_helper_that_stopped_after_asking_is_not_asked_about(monkeypatch): """一時レーンが ask を送って自分のラッパーを止めたときは、「配布の途中で止まった」を問わない(6g)。""" run = _bare_run(monkeypatch, [{"name": "la"}]) @@ -823,3 +881,160 @@ def test_a_late_request_is_answered_by_the_release_that_completed_it(monkeypatch run.pending = [{"id": "r1", "lane": "la", "prs": [10, 20]}, {"id": "r0", "lane": "la", "prs": [10]}] run.answer_from_ranges() assert sorted(got) == [("r0", "1.2.3", 1), ("r1", "1.2.4", 2)] and run.pending == [] + + +# ---------------------------------------------------------------- 状態の一覧の失敗(#2509 の I1・I2・決定 5) + + +def test_a_failed_ls_keeps_lane_states_and_the_last_observation(monkeypatch): + """受け入れ条件 1・I1: 一覧が 0 以外で終わった回は、レーンの状態・最後に観測した時刻・起こし直しの待ち・問いを変えない。""" + run = _bare_run(monkeypatch, [{"name": "la", "input": {"kind": "sprint"}}, {"name": "lb"}]) + seen = {"now": 1_000_000.0, "rows": {"la": {"state": "running", "at": "1970-01-01T00:00:00Z"}}, "code": 0} + monkeypatch.setattr(prun, "step_observe", lambda _m: seen) + run.lanes["la"].update(started=True) + run.lanes["lb"].update(started=True) + prun._observe_run(run, "/m") + run.questions, run.stall_until = [], {} + run.revive, run.revive_at = ["lb"], {"lb": 1_000_000.0} + before = (json.dumps(run.lanes, sort_keys=True), run.seen_at, list(run.revive), dict(run.revive_at)) + seen.update(now=1_000_060.0, rows={}, code=3, err="ls: hub に届かない") + prun._observe_run(run, "/m") + assert (json.dumps(run.lanes, sort_keys=True), run.seen_at, list(run.revive), dict(run.revive_at)) == before + assert run.questions == [] and run.stall_until == {} + + +def test_after_a_failed_ls_the_plan_is_not_finished(monkeypatch): + """I2: 最後の観測が失敗した回の後は、全レーンが止まって見えていても終えない。成功に戻れば終えられる。""" + run = _bare_run(monkeypatch, [{"name": "la"}]) + seen = {"now": 1.0, "rows": {}, "code": 0} + monkeypatch.setattr(prun, "step_observe", lambda _m: seen) + run.lanes["la"].update(started=True) + prun._observe_run(run, "/m") + assert prun._run_finished(run) + seen["code"] = 3 + prun._observe_run(run, "/m") + assert not prun._run_finished(run) + seen["code"] = 0 + prun._observe_run(run, "/m") + assert prun._run_finished(run) + + +def test_an_observation_recorded_without_code_reads_as_success(monkeypatch): + """決定 5: 足す前の版で記録した観測(`code` が無い)は成功として読み、行の無い起動済みのレーンを止まったと読む。""" + run = _bare_run(monkeypatch, [{"name": "la"}]) + monkeypatch.setattr(prun, "step_observe", lambda _m: {"now": 1.0, "rows": {}}) + run.lanes["la"].update(started=True) + prun._observe_run(run, "/m") + assert run.lanes["la"]["state"] == "stopped" and prun._run_finished(run) + + +def test_step_observe_carries_the_exit_code_and_the_last_stderr_line(env, tmp_path): + (tmp_path / "ls.fail").write_text("") + got = prun.step_observe(str(env["main"])) + assert got["code"] == 3 and got["err"] == "ls: hub に届かない" and got["rows"] == {} and got["can_ask"] is True + + +def test_the_plan_does_not_finish_while_ls_keeps_failing(env, tmp_path): + """受け入れ条件 1・2: 一覧が失敗し続ける間は、起動済みのレーンを止まったと読まず、計画を finished で終えない。""" + main, log = env["main"], env["log"] + fail = tmp_path / "ls.fail" + fail.write_text("") + start(main) + until(lambda: len(calls(log, "ls")) >= 5) + assert spawned(log) == ["la"] # la を止まったと読まないため、lb を起動しない + assert durable.status(pl.workflow_id("p1")) in pl.RUNNING + fail.unlink() # 成功に戻ると、行の無い起動済みのレーンを止まったと読んで終える(受け入れ条件 3 の既存の振る舞い) + out = until(lambda: durable.status(pl.workflow_id("p1")) not in pl.RUNNING and durable.output_of(pl.workflow_id("p1"))) + assert out["status"] == "finished" and spawned(log) == ["la", "lb"] + + +# ---------------------------------------------------------------- 一覧の失敗の問い(#2509 の I3・I4) + + +def _failing_run(monkeypatch): + """起動済みのレーン 1 本の計画と、書き換えられる観測の結果。停滞の区切りは 1 分。""" + monkeypatch.setenv("NDF_PARALLEL_STALL_MINUTES", "1") + run = _bare_run(monkeypatch, [{"name": "la"}]) + run.lanes["la"].update(started=True, state="running") + seen = {"now": 1000.0, "rows": {}, "code": 3, "err": "ls: hub に届かない", "can_ask": True} + monkeypatch.setattr(prun, "step_observe", lambda _m: seen) + return run, seen + + +def _observe_at(run, seen, now): + seen["now"] = now + prun._observe_run(run, "/m") + return [q for q in run.questions if q["kind"] == "observe"] + + +def test_a_long_ls_failure_raises_one_question_per_limit(monkeypatch): + """受け入れ条件 4・I3: 失敗が区切りより長く続くと問いが 1 件立ち、本文に終了コードと標準エラーの末尾 1 行が入る。同じ区切りの間は増えない。""" + run, seen = _failing_run(monkeypatch) + assert _observe_at(run, seen, 1000.0) == [] and _observe_at(run, seen, 1059.0) == [] + got = _observe_at(run, seen, 1060.0) + assert len(got) == 1 + q = got[0] + assert q["id"] == "p1-observe-1060" and q["source"] == "ndf-ask:human-action" + assert "終了コード 3" in q["question"] and "ls: hub に届かない" in q["question"] and "1 分" in q["question"] + assert "lane" not in q and "attach" not in q + assert q["options"] == ["ラッパーの環境を作り直す", "待つ"] + assert q["commands"] == {"ラッパーの環境を作り直す": f"python3 {prun.RELAY} status"} + assert len(_observe_at(run, seen, 1119.0)) == 1 # 同じ区切りの間は重ねない + assert len(_observe_at(run, seen, 1120.0)) == 2 # 次の区切りで次の 1 件 + + +def test_replaying_a_record_from_before_the_question_keeps_the_recorded_calls(monkeypatch): + """決定 5: 問いを持たない版の失敗の記録の再生では問いも DBOS の呼び出しも足さない。続きの観測は続いた時間で問う。""" + run, seen = _failing_run(monkeypatch) + del seen["can_ask"] + calls = [] + monkeypatch.setattr(durable, "set_event", lambda *a, **_k: calls.append(a)) + assert _observe_at(run, seen, 1000.0) == [] and _observe_at(run, seen, 1060.0) == [] + assert _observe_at(run, seen, 1200.0) == [] and calls == [] + seen["can_ask"] = True + assert len(_observe_at(run, seen, 1201.0)) == 1 and len(calls) == 1 + + +def test_a_waited_ls_question_is_not_asked_again_before_the_next_limit(monkeypatch): + run, seen = _failing_run(monkeypatch) + _observe_at(run, seen, 1000.0) + rid = _observe_at(run, seen, 1060.0)[0]["id"] + run.take({"kind": "answer", "id": rid, "result": "answered", "choice": ["待つ"]}) + assert run.questions == [] and run.lanes["la"]["state"] == "running" + assert _observe_at(run, seen, 1119.0) == [] + assert len(_observe_at(run, seen, 1120.0)) == 1 + + +def test_a_recovered_ls_drops_the_question_and_restarts_the_tracking(monkeypatch): + """I4: 成功に戻ると答えていない一覧の失敗の問いが外れ、失敗の始まりも数え直す。ほかの問いは残す。""" + run, seen = _failing_run(monkeypatch) + run.questions.append({"id": "q1", "kind": "ask", "lane": "la"}) + _observe_at(run, seen, 1000.0) + assert len(_observe_at(run, seen, 1060.0)) == 1 + seen["code"] = 0 + assert _observe_at(run, seen, 1061.0) == [] and [q["id"] for q in run.questions] == ["q1"] + seen["code"] = 3 + assert _observe_at(run, seen, 1062.0) == [] + assert _observe_at(run, seen, 1121.0) == [] # 新しい始まり(1062)から区切りまでは問わない + assert len(_observe_at(run, seen, 1122.0)) == 1 + + +def test_a_recovered_ls_lets_the_plan_finish_with_the_question_gone(env, tmp_path, monkeypatch): + """I3・I4: 耐久ワークフローを通しても、失敗が続けば問いが届き、成功に戻れば問いが外れて計画を終える。""" + main = env["main"] + monkeypatch.setattr(prun, "stall_seconds", lambda: 0.3) + fail = tmp_path / "ls.fail" + fail.write_text("") + start(main) + item = until(lambda: [i for i in questions(main)["items"] if i["kind"] == "observe"])[0] + assert "終了コード 3" in item["question"] and "lane" not in item + assert durable.status(pl.workflow_id("p1")) in pl.RUNNING + fail.unlink() + out = until(lambda: durable.status(pl.workflow_id("p1")) not in pl.RUNNING and durable.output_of(pl.workflow_id("p1"))) + assert out["status"] == "finished" and not [i for i in questions(main)["items"] if i["kind"] == "observe"] + + +def test_answering_rebuild_returns_the_status_command(tmp_path): + item = {"id": "p1-observe-1", "kind": "observe", "commands": {"ラッパーの環境を作り直す": "python3 relay.py status"}} + assert pa.owner_restart(tmp_path, "p1", item["id"], ["ラッパーの環境を作り直す"], [item]) == "python3 relay.py status" + assert pa.owner_restart(tmp_path, "p1", item["id"], ["待つ"], [item]) is None diff --git a/plugins/ndf/scripts/tests/test_project_mvv.py b/plugins/ndf/scripts/tests/test_project_mvv.py index b25c1c97e..7d1d23b6d 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv.py +++ b/plugins/ndf/scripts/tests/test_project_mvv.py @@ -789,13 +789,18 @@ def test_mvv_gate_suggests_revision_after_the_unknown_streak(env): assert approve(env) == 0 state = gate_state(env, env["tmp"], with_sprint=False) answer(env, '{"verdict": "unknown", "reasons": ["決められない"], "boundary": []}') + # 判定できなかった依頼の数で数える(#2507): 同じスプリント状態の打ち直しは 1 件、別のスプリント状態なら別の件 - assert not any(i.get("kind") == "revise" for i in [gate_check(env, state)[1] for _ in range(3)][-1]["items"]) + # 累計(`unknowns`)の閾値でも同じ kind の提案が出る。承認と同じ秒の行は累計に入らず、秒をまたぐと結果が変わるため、連続の提案だけを見る + def streak_revise(out: dict) -> bool: + return any(i.get("kind") == "revise" and "回続いた" in i.get("reason", "") for i in out["items"]) + + assert not streak_revise([gate_check(env, state)[1] for _ in range(3)][-1]) states = [state] + [state.with_name(f"m{k}.json") for k in (2, 3)] for s in states[1:]: s.write_text(state.read_text()) outs = [gate_check(env, s)[1] for s in states] - assert [any(i.get("kind") == "revise" for i in o["items"]) for o in outs] == [False, False, True] + assert [streak_revise(o) for o in outs] == [False, False, True] assert gate_check(env, state)[2][-1]["basis"] == [pm.NO_BASIS] diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py index 99efda16f..8227a00aa 100644 --- a/plugins/ndf/scripts/tests/test_relay.py +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -1406,7 +1406,14 @@ def test_run_goal_unmet_cancelled(term, case): t.type(f"tr {json.dumps(row, ensure_ascii=False)}\r") elif case == "question": t.type("q open\r") - t.wait(lambda: (d / "question").exists(), what="質問の合図") + + def question_opened(): + # 負荷が高いと hook の起動が静止の待ち(0.5 秒)より遅れ、質問の前に /exit が書かれる。 + # 端末の入力で静止の待ちを延ばし、合図が立ってから判定させる + t.type("\r") + return (d / "question").exists() + + t.wait(question_opened, what="質問の合図") t.type("q close\r") else: row = { @@ -2703,6 +2710,107 @@ def test_version_dir_prune_keeps_two_and_in_use(tmp_path): assert (base / names[0] / f"inuse-{os.getpid()}").exists() +def _idle_dir(tmp_path): + d = tmp_path / "relay-10.17.0-00000000" + d.mkdir() + return d + + +def test_version_dir_foreign_mark_counts_as_in_use(tmp_path): + """別のコンテナの印は、こちらで pid が死んで見えても使用中で、消さない(#2583)。""" + d = _idle_dir(tmp_path) + mark = d / f"inuse-another-container-{dead_pid()}" + mark.touch() + assert relay_version_dir.VersionDir._in_use(str(d)) + assert mark.exists() + + +def test_version_dir_prune_keeps_a_dir_marked_by_another_container(tmp_path): + base = tmp_path / "ndf" + base.mkdir() + names = [f"relay-10.17.{i}-0000000{i}" for i in range(5)] + for i, n in enumerate(names): + (base / n).mkdir() + os.utime(base / n, (1000 + i, 1000 + i)) + (base / names[0] / f"inuse-another-container-{dead_pid()}").touch() + relay_version_dir.VersionDir(str(base)).prune() + assert version_dirs(base) == sorted([names[0], names[3], names[4]]) + + +def test_version_dir_old_foreign_mark_expires_after_the_owner_window(tmp_path): + """別のコンテナの印でも、更新が寿命の窓(持ち主の記録と同じ 24 時間)より古ければ消す。""" + d = _idle_dir(tmp_path) + mark = d / f"inuse-another-container-{dead_pid()}" + mark.touch() + old = time.time() - relay_version_dir.FOREIGN_WINDOW - 60 + os.utime(mark, (old, old)) + assert not relay_version_dir.VersionDir._in_use(str(d)) + assert not mark.exists() + + +def test_version_dir_foreign_window_is_the_owner_record_window(): + """寿命の窓は持ち主の記録(`lib/run_owner.py`)の窓と同じ値に保つ。""" + import run_owner + + assert relay_version_dir.FOREIGN_WINDOW == run_owner.WINDOW + + +def test_version_dir_own_container_mark_follows_the_pid(tmp_path): + """同じコンテナの印は pid の生死で決まる。""" + d = _idle_dir(tmp_path) + live, dead = relay_version_dir.inuse_name(os.getpid()), relay_version_dir.inuse_name(dead_pid()) + (d / live).touch() + (d / dead).touch() + assert relay_version_dir.VersionDir._in_use(str(d)) + assert (d / live).exists() and not (d / dead).exists() + + +def test_version_dir_legacy_mark_is_read_as_before(tmp_path): + """旧形式の `inuse-` は持ち主が分からず、今までどおり pid の生死で読む。""" + d = _idle_dir(tmp_path) + (d / f"inuse-{os.getpid()}").touch() + assert relay_version_dir.VersionDir._in_use(str(d)) + os.unlink(d / f"inuse-{os.getpid()}") + (d / f"inuse-{dead_pid()}").touch() + assert not relay_version_dir.VersionDir._in_use(str(d)) + assert not [n for n in os.listdir(d) if n.startswith("inuse-")] + + +def test_version_dir_refresh_keeps_a_long_run_within_the_window(tmp_path, monkeypatch): + """動いているラッパーは印の時刻を進める。別のコンテナの窓を過ぎても消されず、消えた印は置き直す。""" + d = _idle_dir(tmp_path) + (d / relay_version_dir.MANIFEST).touch() + mark = pathlib.Path(relay_version_dir.claim_inuse(str(d))) + old = time.time() - relay_version_dir.FOREIGN_WINDOW - 60 + os.utime(mark, (old, old)) + monkeypatch.setattr(relay_version_dir, "_refreshed_at", 0.0) + relay_version_dir.refresh_inuse(str(d)) + assert time.time() - mark.stat().st_mtime < 60 + mark.unlink() + relay_version_dir.refresh_inuse(str(d)) # 間隔の中は何もしない + assert not mark.exists() + monkeypatch.setattr(relay_version_dir, "_refreshed_at", 0.0) + relay_version_dir.refresh_inuse(str(d)) + assert mark.exists() + + +def test_relay_tick_refreshes_the_mark_even_when_halted(monkeypatch): + """動いている間の tick が印の時刻を進める。止まったラッパーも、プロセスが居る間は使用中である。""" + seen = [] + monkeypatch.setattr(relay_run.version_dir, "refresh_inuse", lambda: seen.append(1)) + relay_run.Relay.tick(types.SimpleNamespace(halted=True)) + assert seen == [1] + + +def test_version_dir_release_claims_removes_both_forms(tmp_path): + """入れ替えの前の版が旧形式で置いた印も、新しい形の印も外す。""" + d = _idle_dir(tmp_path) + (d / f"inuse-{os.getpid()}").touch() + (d / relay_version_dir.inuse_name(os.getpid())).touch() + relay_version_dir.release_claims(str(d)) + assert os.listdir(d) == [] + + def test_version_dir_half_written_is_not_pointed(tmp_path, monkeypatch): base = tmp_path / "ndf" base.mkdir() @@ -2731,7 +2839,7 @@ def test_version_dir_claim_marks_only_version_dirs(tmp_path): base.mkdir() name = relay_version_dir.VersionDir(str(base)).ensure("10.18.0") got = relay_version_dir.claim_inuse(str(base / name)) - assert got == str(base / name / f"inuse-{os.getpid()}") and os.path.exists(got) + assert got == str(base / name / relay_version_dir.inuse_name(os.getpid())) and os.path.exists(got) relay_version_dir.release_inuse(got) assert not os.path.exists(got) assert relay_version_dir.claim_inuse(str(ROOT / "scripts")) is None # プラグインのキャッシュには置かない @@ -2840,6 +2948,18 @@ def test_run_without_env_says_then_passthrough(tmp_path, home): assert p.stderr.startswith("ndf-relay: ラッパーを始めない(ラッパーの環境("), p.stderr +@pytest.mark.parametrize("value, kept", [("shared", False), ("mine", True)]) +def test_enter_drops_a_shared_project_env_before_starting_claude(tmp_path, home, value, kept): + """#2509 の受け入れ条件 2・I6・I7: 共有の環境を指す UV_PROJECT_ENVIRONMENT は子の claude へ渡さず、利用者の値は渡す。""" + claude = tmp_path / "bin" / "claude" + claude.parent.mkdir() + claude.write_text('#!/bin/sh\necho "uvenv:${UV_PROJECT_ENVIRONMENT-unset}"\n') + claude.chmod(0o755) + path = str(home / ".cache" / "ndf" / "venv" / "9.9.9") if value == "shared" else str(tmp_path / "mine") + p = bare(tmp_path, "run -c", NDF_RELAY_CLAUDE=claude, UV_PROJECT_ENVIRONMENT=path) + assert p.stdout == (f"uvenv:{path}\n" if kept else "uvenv:unset\n"), p.stderr + + def test_install_prepares_env_then_copy_runs_in_it(tmp_path, home): """導入は、プラグインの環境を同じ lock から用意して起動し直し、複製のバージョンディレクトリにも環境を作る。 複製のランチャーは、素の python から起動されてもバージョンディレクトリの環境の python で動く。""" @@ -4178,8 +4298,8 @@ def test_swap_runs_next_session_in_new_version_dir(tmp_path, term): assert t.starts()[1]["relay_dir"] == str(d) assert not (d / "handover.json").exists() # 使用中の印は入れ替え後のバージョンディレクトリにだけある - assert not (sw.base / sw.old_name / f"inuse-{t.proc.pid}").exists() - assert (sw.base / new_name / f"inuse-{t.proc.pid}").exists() + assert not (sw.base / sw.old_name / relay_version_dir.inuse_name(t.proc.pid)).exists() + assert (sw.base / new_name / relay_version_dir.inuse_name(t.proc.pid)).exists() assert "── ndf-relay: 区間 2 ──" in t.text and "入れ替えずに" not in t.text assert termios.tcgetattr(t.slave) != t.before # raw のまま中継する # 入れ替えた後も停止シグナルファイルの扱いが続く(起動は 2 回のまま) @@ -4254,7 +4374,7 @@ def test_swap_failure_keeps_current_version(tmp_path, term, case): assert sk["section"] == 1 and sk["from"] == sw.old_name and isinstance(sk["seconds"], float) assert rows[3]["relay_version_dir"] == sw.old_name and rows[3]["section"] == 2 assert t.text.count("入れ替えずに今の版で続ける") == 1 - assert (sw.base / sw.old_name / f"inuse-{t.proc.pid}").exists() + assert (sw.base / sw.old_name / relay_version_dir.inuse_name(t.proc.pid)).exists() (d,) = t.relay_dirs() assert not (d / "handover.json").exists() t.type("quit 0\r") diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index f1c5fce99..9ef78e2de 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -37,6 +37,7 @@ def state(tmp_path, monkeypatch): monkeypatch.setenv("XDG_STATE_HOME", str(tmp_path / "state")) monkeypatch.setenv("NDF_RELAY_TMUX", "ndf-no-tmux") monkeypatch.delenv("NDF_RELAY_MAX_NAMED", raising=False) + monkeypatch.setenv("NDF_RELAY_LAUNCH_CHECK", "0") monkeypatch.setenv("NDF_ACCOUNTS_DIR", str(tmp_path / "accounts")) # 利用者の置き場(`account use` の指定)を読まない root = tmp_path / "state" / "ndf" / "relay" root.mkdir(parents=True) @@ -370,7 +371,13 @@ def hubenv(tmp_path): sock = tempfile.mkdtemp(prefix="ndf-tests-tmux-", dir="/tmp") made = [] # CI には TERM が無く、tmux の attach が端末の種類を決められずに失敗する。擬似端末に実際の端末と同じく TERM を与える - env = {"NDF_RELAY_TMUX": "tmux", "TMUX_TMPDIR": sock, "NDF_RELAY_HUB_WAIT": "15", "TERM": "xterm-256color"} + env = { + "NDF_RELAY_TMUX": "tmux", + "TMUX_TMPDIR": sock, + "NDF_RELAY_HUB_WAIT": "15", + "TERM": "xterm-256color", + "NDF_RELAY_LAUNCH_CHECK": "0.2", + } def start(**kw): t = Term(tmp_path, env={**env, **kw.pop("env", {})}, **kw) @@ -482,6 +489,32 @@ def test_killed_named_wrapper_shows_stopped(self, hubenv): assert ls_rows(hub)["t1"]["state"] == "running" +@pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux が無い") +def test_session_gone_with_a_real_tmux(tmp_path, monkeypatch): + """実物の tmux: 消えた置き場の python を渡しても `new-session` は成功し、セッションは残らない(#2583 の再現)。 + サーバごと居ないときも消えたと読み、動いているセッションは消えたと読まない。""" + sock = tempfile.mkdtemp(prefix="ndf-tests-tmux-", dir="/tmp") + monkeypatch.setenv("TMUX_TMPDIR", sock) + monkeypatch.setenv("NDF_RELAY_TMUX", "tmux") + try: + assert tx.new_session("gone", str(tmp_path), ["/無い/.venv/bin/python", "relay.py"], {}) is None + end = time.monotonic() + 5 + while time.monotonic() < end and not tx.session_gone("gone"): + time.sleep(0.05) + assert tx.session_gone("gone") + assert tx.new_session("stays", str(tmp_path), ["sleep", "30"], {}) is None + assert not tx.session_gone("stays") + finally: + subprocess.run(["tmux", "-L", tx.SOCKET, "kill-server"], env={**os.environ, "TMUX_TMPDIR": sock}, capture_output=True) + shutil.rmtree(sock, ignore_errors=True) + + +def test_session_gone_is_false_when_tmux_cannot_be_asked(monkeypatch): + """tmux を呼べないときは消えたと決めない(動いているセッションを失敗と誤らない)。""" + monkeypatch.setattr(hb.tmux, "_tmux", lambda *a: None) + assert not tx.session_gone("a") + + def test_tmux_class_has_no_skip_of_its_own(): """tmux の有無は根の前提のコマンドの一覧だけが決め、4 件は自分で読み飛ばさない(#2346)。""" holders = [sys.modules[__name__], TestWithTmux, *(v for k, v in vars(TestWithTmux).items() if k.startswith("test_"))] @@ -609,6 +642,41 @@ def test_process_tmux_failure_is_refused_and_registry_untouched(launched, monkey assert not os.path.exists(hb.hub_path(hb.REGISTRY)) +def test_process_session_gone_after_launch_is_refused_and_not_registered(launched, monkeypatch): + """作ったセッションがすぐ消えたら「起動した」と返さず、登録もしない(#2563・#2583 の受け入れ条件 2)。""" + monkeypatch.setattr(hb.tmux, "session_gone", lambda name: True) + res = hb.Hub(None).process({"kind": "spawn", "name": "a", "cwd": "/", "args": [], "by": "spawn"}) + assert res["status"] == "stopped" and res["items"][0]["result"] == hb.TMUX_FAILED + assert res["metrics"]["exit_code"] == 1 and "a" in res["summary"] + assert not os.path.exists(hb.hub_path(hb.REGISTRY)) + + +def test_process_restart_gone_keeps_the_registry_row(launched, tmp_path, monkeypatch): + """起こし直しで消えたら、前の登録の行はそのまま残す(次の起こし直しの入力になる)。""" + row = {"session": "a", "cwd": str(tmp_path), "args": [], "created_at": "x", "by": "spawn"} + _write_registry({"a": row}) + monkeypatch.setattr(hb.tmux, "session_gone", lambda name: True) + res = hb.Hub(None).process({"kind": "restart", "name": "a", "by": "restart"}) + assert res["metrics"]["exit_code"] == 1 and hb.read_registry()["a"] == row + + +def test_launch_waits_the_check_seconds_before_looking(launched, monkeypatch): + """セッションの確かめの前に `NDF_RELAY_LAUNCH_CHECK` 秒待つ。tmux に聞けなければ起動した扱いのまま。""" + monkeypatch.setenv("NDF_RELAY_LAUNCH_CHECK", "1.5") + order = [] + monkeypatch.setattr(hb.time, "sleep", lambda s: order.append(("sleep", s))) + monkeypatch.setattr(hb.tmux, "session_gone", lambda name: order.append(("look", name)) or False) + res = hb.Hub(None).process({"kind": "spawn", "name": "a", "cwd": "/", "args": [], "by": "spawn"}) + assert order == [("sleep", 1.5), ("look", "a")] and res["status"] == "ok" + + +def test_launch_check_seconds_default_and_broken_value(monkeypatch): + monkeypatch.delenv("NDF_RELAY_LAUNCH_CHECK", raising=False) + assert hb.launch_check_seconds() == 2 + monkeypatch.setenv("NDF_RELAY_LAUNCH_CHECK", "x") + assert hb.launch_check_seconds() == 2 + + def test_process_restart_uses_registry_row(launched, tmp_path): row = {"session": "a", "cwd": str(tmp_path), "args": ["--", "最初"], "created_at": "x", "by": "spawn"} _write_registry({"a": row}) diff --git a/plugins/ndf/scripts/tests/test_release_record.py b/plugins/ndf/scripts/tests/test_release_record.py index 7f10a668c..4cc349f4a 100644 --- a/plugins/ndf/scripts/tests/test_release_record.py +++ b/plugins/ndf/scripts/tests/test_release_record.py @@ -276,6 +276,38 @@ def test_a_release_test_of_another_version_is_not_used_for_production(): assert dist_record.parse_record(text)["verify_block"] is None +def _verify(ver: str, label: str) -> str: + return f"\n## リリース後テスト\n\n対象の版: {ver}\n合否: 合格({label})\n" + + +@pytest.mark.parametrize( + ("dist_ver", "verify_ver", "want_ver"), + [ + ("10.26.1", "v10.26.1(2026-10-31 08:52 JST に配布)", "10.26.1"), + ("v10.26.1", "10.26.1(2026-10-31)", "10.26.1"), + ("10.17.69-dev.1", "v10.17.69-dev.1(2026-10-02)", "10.17.69-dev.1"), + ], +) +def test_a_leading_v_on_either_version_still_matches(dist_ver, verify_ver, want_ver): + """#2225 AC1〜3: 先頭の `v` の有無だけが違う本番の版と対象の版は同じ版として読み、本番の版は `v` なしで返す。""" + text = f"## 配布の記録\n\n段階: 本番(承認の後)\n版: 10.26.0 → {dist_ver}(タグ)\nスプリント: PR #1\n" + text += _verify(verify_ver, "一致") + got = dist_record.parse_record(text) + assert got["version"] == want_ver and got["verify_block"].endswith("合否: 合格(一致)") + + +def test_a_leading_v_does_not_let_another_version_be_used_for_production(): + """#2225 AC4: `v` を落としても、版数の違うテスト(v10.17.69-dev.1)は本番 10.17.68 の検証に使わない。""" + text = dist_record.format_record("10.17.67", "10.17.68", [1], stage_note="承認の後", version_note="タグ") + assert dist_record.parse_record(text + _verify("v10.17.69-dev.1(2026-10-02)", "開発版"))["verify_block"] is None + + +@pytest.mark.parametrize("ver", ["V10.26.1", "main c427284", "vnext"]) +def test_only_a_lowercase_v_before_a_digit_is_dropped(ver): + """#2225 前提 1: 大文字の `V` と、`v` の後が数字でない表記は読み替えない。""" + assert dist_record._strip_note(f"{ver}(注記)") == ver + + def test_a_release_test_before_the_record_is_not_used(): """AC1: 記録より前のリリース後テストは、版が違えば選ばない。""" text = "## リリース後テスト\n\n対象の版: main abc1234\n合否: 合格\n\n" + UNVERSIONED.split("## リリース後テスト")[0] diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 03127df4e..bda9c545a 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1740,6 +1740,19 @@ def test_loop_answer_merges_the_approved_design_and_the_next_lap_selects_it_firs assert 11 not in sl_select.approved_designs(rec(env)) +def test_loop_exclude_keeps_the_approved_design_first_on_every_reselection(env): + """同じ周回の取り直し(loop exclude)は、取り直す周回自身の選定を「選ばれた後の周回」に数えない。""" + gated_both(env) + sl.cmd_answer(env, "l1", 11, True, None) + sl.cmd_answer(env, "l1", 12, False, None) + env.rank_box["out"] = rank_out(ms("01 近い", [13, 14, 15, 16, 17, 11])) + assert sl_select.cmd_select(env, "l1")["metrics"]["selected"][0] == 11 + for excluded in (13, 14): # 1 回ごとに先頭から落ちて戻るのではなく、どの取り直しでも先頭に残る + got = sl_select.cmd_exclude(env, "l1", excluded, "外す") + assert got["metrics"]["selected"][0] == 11 + assert next(x for x in got["items"] if x["number"] == 11)["reason"] == sl_select.APPROVED_REASON + + def test_revise_and_rejected_answers_do_not_merge(env, tmp_path): gated_both(env) note = tmp_path / "n.md" diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 470b4279a..27e2bb741 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -39,14 +39,14 @@ 2. 依頼 1 件を問い 1 つにし、5 件以上の区分は届いた順に 4 件ずつ続けて呼ぶ。各問いにはレーン名・課題・設計 PR の URL か作業の場所を添える 3. `ndf-ask:human-action` の依頼は `attach`(`relay.py attach <レーン>`)の 1 行を添えて「済んだ」「まだ」で問う。 `merged` があれば、同じ原因でまとめた各レーンの元の文面を並べる(答えは全レーンへ届く) -4. 耐久ワークフローが自分で立てる問い(`kind` が `stall`・`helper`・`spawn`)は `options` で問う。選んだ答えの `commands` の 1 行は、答えを送った `answer` が `metrics.next` に返す +4. 耐久ワークフローが自分で立てる問い(`kind` が `stall`・`helper`・`spawn`・`observe`)は `options` で問う。選んだ答えの `commands` の 1 行は、答えを送った `answer` が `metrics.next` に返す 5. 答えを送り、下の「答えの後の整理」を通す | 依頼の `kind` | 問いの答え | 送るコマンド | | --- | --- | --- | | `gate1`(設計の承認) | 承認 / 直して出し直す(指摘を書く) / やらない | `parallel.py answer p1 --approved` / `--revise --note <指摘ファイル>` / `--rejected`。`reasons` にマージできなかった理由があれば添えて問う(利用者へ戻した依頼) | | `gate2`(承認ゲート 2) | 下の「承認ゲート 2」 | `confirm` / `settle --rejected` | -| `ask`・`stall`・`helper`・`spawn` | 依頼の選択肢 | `parallel.py answer p1 --choice <選んだ答え>` | +| `ask`・`stall`・`helper`・`spawn`・`observe` | 依頼の選択肢 | `parallel.py answer p1 --choice <選んだ答え>` | `gate1` の `--approved` では、`answer` が答えを送る前に設計 PR を宛先へマージしてみる([loop.md](loop.md) の「承認した設計のマージ」)。 結果は `metrics.merge` に載り、答えにも載ってレーンへ届く。マージできなくても答えは送られ、終了コードは 0 である。`answer` はほかに、 @@ -182,6 +182,7 @@ CI の失敗・衝突・版数の衝突は一時レーンと同じく配布の | レーンが承認待ちでも止まってもいないのに、最後に動いた時刻から 60 分(`NDF_PARALLEL_STALL_MINUTES`)を越えた | 起こし直す / 止める / 待つ(次の区切りまで問わない) | | 配布の途中で一時レーンが止まり、`settle` も承認ゲート 2 の依頼も届いていない | 続きから起こし直す / 止める | | レーンの起動が 3 回続けて断られた | 起こし直す / 止める | +| レーンの状態の一覧(`relay.py ls`)の失敗が、同じ区切りより長く続いた(失敗した回の観測は捨て、レーンの状態を変えない) | ラッパーの環境を作り直す / 待つ(次の区切りまで問わない)。一覧が成功に戻れば問いは外れる | 全レーンが止まっても、答えていない依頼か問いが残る間は終えない。 diff --git a/plugins/ndf/skills/development-workflow/references/relay.md b/plugins/ndf/skills/development-workflow/references/relay.md index aac5370e2..0955b5e1c 100644 --- a/plugins/ndf/skills/development-workflow/references/relay.md +++ b/plugins/ndf/skills/development-workflow/references/relay.md @@ -125,6 +125,8 @@ (`^[a-z0-9][a-z0-9_-]{0,31}$`)が違うときも 3 - **統括役が落ちても**名前つきのラッパーは止まらない(tmux のサーバが親)。次に端末で打った `claude` が統括役になる。 統括役が `NDF_RELAY_HUB_WAIT`(既定 10 秒)答えなければ、2 本目は 1 行出して今の前景の起動になる +- **起動の確かめ**: 統括役は `spawn`・`restart` でセッションを作った後、`NDF_RELAY_LAUNCH_CHECK`(既定 2 秒)待ってセッションが残っているかを見る。 + 消えていれば登録せず `tmux-failed`・1 を返す - **tmux が PATH に無い**(`NDF_RELAY_TMUX` で実行ファイルを替えられる)ときは、2 本目は 1 行出して今の前景の起動になる - **承認待ちの知らせ**は各ラッパーの待ち通知が出し、名前つきのラッパーなら `ラッパー: <名前>(relay.py attach <名前>)` の 1 行が付く - tmux は専用のソケット `-L ndf-relay` だけを使い、利用者の tmux サーバに触れない。統括役の置き場は