From 10c38018397eea8f452a40d75c9fc53e9f07d430 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 06:50:30 +0000 Subject: [PATCH 001/127] =?UTF-8?q?Docs:=20#2399=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=AE=E5=86=99=E3=81=97=E3=82=92=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent の ndf:supervisor が ndf:worker の起動指示に「読む Skill」を置かない不具合の要求。 受け入れ条件 7 件・前提 4 件・未決 2 件(経路の置き場と機械の検査の要否は設計で決める)。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2399-requirements.md | 157 ++++++++++++++++++++++++++++++ 1 file changed, 157 insertions(+) create mode 100644 issues/issue-2399-requirements.md diff --git a/issues/issue-2399-requirements.md b/issues/issue-2399-requirements.md new file mode 100644 index 000000000..6a07f7571 --- /dev/null +++ b/issues/issue-2399-requirements.md @@ -0,0 +1,157 @@ +# #2399: Agent の ndf:supervisor が worker の起動指示に「読む Skill」を置かず、設計の工程の ndf:worker に document-style が届かない + +正は課題の本文(#2399)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何を見つけたか + +ndf v10.27.10 を導入した状態(`claude -p --plugin-dir <導入先の 10.27.10>`)で、conductor の役から Agent の `ndf:supervisor` を設計のフェーズ(通す工程は「設計」だけ)で起動し、設計文書 1 つを `ndf:worker` に書かせた。2 回起動し、2 回とも次のとおりだった。 + +| 起動 | 起動指示の形 | supervisor が `stage-skills.py lookup 設計` を打ったか | worker の起動指示の「読む Skill」の欄 | worker が `document-style` の `SKILL.md` を読んだか | +| --- | --- | --- | --- | --- | +| 1 回目(2026-11-02 15:33 JST) | 依頼の文だけ(10 項目の形でない) | 打っていない | 無い | 読んでいない | +| 2 回目(2026-11-02 15:35 JST) | `agent-layers.md` の「conductor → supervisor」の 10 項目と守る規則 14 個、`$SCRIPTS` の絶対パス | 打っていない | 無い | 読んでいない | + +supervisor は `agent-layers.md` を読まずに worker の起動指示を自分で組んだ。worker の起動指示は「作業・置き場所・形の制約・報告」だけで、6 項目の形(作業・入力・手順・読む Skill・返す形・置き場所)になっていなかった。 + +一方、プランの worker(`supervise.py run`)は同じ版で、工程の対応表どおりの Skill を受け取った(5 ステップを実起動し、`progress.jsonl` の `kind: skills` の行と答えが一致した)。 + +## どこで見つけたか + +- #2376 のリリース後テスト(配布の記録は Pull Request #2395、スプリントの Pull Request は #2392) +- 手順の正本: `plugins/ndf/skills/development-workflow/references/agent-layers.md` の「supervisor → worker」の「読む Skill」の行 +- supervisor の定義: `plugins/ndf/agents/supervisor.md`(「規約の正本は `references/agent-layers.md` の『conductor → supervisor』にある」とだけ書き、supervisor → worker の形を読む指示が無い)。conductor の起動指示の守る規則 14 個にも、worker の起動指示の形と `stage-skills.py` は入っていない + +## なぜこの変更の範囲外なのか + +#2376 はリリース済みで、リリース後の不合格は新しい変更として通常の工程で直す(`release-verification` の「不合格を即時修正しない」)。原因は supervisor が手順の正本を読む経路が無いことで、直す場所(supervisor の定義か conductor の起動指示の規則)と直し方は設計を要し、即時修正の 4 条件のうち「原因が分かっている」以外を確かめていない。根拠: Value 1・Value 6(MVV 版 4) + +## 直さないと何が起きるか + +Agent の 3 層で回すフェーズ(プランの雛形が無いフェーズ)では、設計・Pull Request・振り返りなど文書を書く工程の `ndf:worker` に `document-style` の規則が届かない。#2376 で `worker.md` と `agent-layers.md` から `document-style` を名指す文を外したため、#2372 の止血の前と同じ状態に戻っている(#2376 の受け入れ条件 13 の「退行しない」に反する)。プランの worker は影響を受けない。 + +## 由来 + +issue #2376 + +## 依頼(原文) + +> supervisor は `agent-layers.md` を読まずに worker の起動指示を自分で組んだ。worker の起動指示は「作業・置き場所・形の制約・報告」だけで、6 項目の形(作業・入力・手順・読む Skill・返す形・置き場所)になっていなかった。 + +> Agent の 3 層で回すフェーズ(プランの雛形が無いフェーズ)では、設計・Pull Request・振り返りなど文書を書く工程の `ndf:worker` に `document-style` の規則が届かない。#2376 で `worker.md` と `agent-layers.md` から `document-style` を名指す文を外したため、#2372 の止血の前と同じ状態に戻っている(#2376 の受け入れ条件 13 の「退行しない」に反する)。プランの worker は影響を受けない。 + +(書き手: 起票者(#2376 のリリース後テストを行った AI)、日付: 2026-11-02。上の「何を見つけたか」〜「由来」の節が原文である(原文の「起票者の推定」の 2 行は、下の表の S1・S2 に写した)。利用者の指示の原文は含まない) + +> 人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-02) + +## 起票者の推定 + +承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 原因は、supervisor が `agent-layers.md` の「supervisor → worker」の節を読む経路が無いことである | 2 回の実起動の記録で、supervisor の Tool の呼び出しに `agent-layers.md` の Read と `stage-skills.py` が無い。`plugins/ndf/agents/supervisor.md` の本文 | 前提 1。受け入れ条件は原因を問わず、起動指示と worker の振る舞いで判定する(受け入れ条件 1〜4) | +| S2 | 直す場所は、supervisor の定義か conductor の起動指示の規則のどちらかである | S1 の推定 | 採らない(場所を 2 つに絞らない)。直す場所は設計で決める(未決 1) | +| S3 | 2 回の実起動の結果から、起動指示の形(依頼の文だけ / 10 項目の形)に依らず再現する | 本文「何を見つけたか」の表 | 受け入れ条件 1(両方の形で確かめる) | + +## 目的 + +- Agent の 3 層で回すフェーズで、supervisor が起動する `ndf:worker` に、いま通している工程の規則の Skill(Skill の対応表が挙げるもの)が届く状態に戻す。conductor が起動指示をどの形で書いても届く +- #2376 の受け入れ条件 13(設計・ドキュメント再構成・Pull Request・振り返りの工程の worker に `document-style` の規則が届く)を、Agent の 3 層でも満たす + +## 前提 + +- 前提 1: 原因は S1 のとおり、supervisor が worker の起動指示の形(6 項目と「読む Skill」の決め方)を受け取る経路を持たないことである。設計の段階で実起動の記録を読み直し、この前提が外れたら設計 PR で示す +- 前提 2: プランの worker(`supervise.py` が起動する worker)への届け方と、Skill の対応表(`plugins/ndf/scripts/lib/stage-skills.json`)の中身はこの課題で変えない +- 前提 3: 「読む Skill」の欄の決め方(`stage-skills.py lookup <工程>` を打ち、出力の名前とパスを並べる)と、worker の規則 6(挙がった `SKILL.md` だけを作業の前に読む)は、`agent-layers.md` の今の規約のまま使う。変えるのは、supervisor がその規約に従う経路である +- 前提 4: 実起動での確かめは、2 回の再現と同じ条件(`claude -p --plugin-dir <導入先>` で conductor の役を起動し、設計のフェーズの supervisor に設計文書 1 つを worker へ書かせる)で行う。言語モデルの振る舞いは起動ごとに揺れるため、起動指示の 2 つの形でそれぞれ 2 回、計 4 回を確かめの回数とする + +## 対象範囲 + +含む: +- supervisor が `ndf:worker` の起動指示を組むときに、6 項目の形と「読む Skill」の決め方を受け取る経路(supervisor の定義・conductor の起動指示の規則・機械の検査のどれに置くかは設計で決める) +- `ndf:supervisor` と `ndf:supervisor-waits` の両方の定義(本文は同じに保つ) +- 起動指示の「読む Skill」の欄が無い `ndf:worker` の起動を、機械で検出して止めるかどうかの判断(未決 2) + +含まない: +- Skill の対応表の行の追加・削除(工程と Skill の組の変更) +- プランの worker・PR 本文の書き手への届け方 +- `ndf:worker` の定義の変更(規則 6 と「読む Skill」だけを読む振る舞いは #2376 のまま) +- supervisor の起動指示のほかの項目(10 項目・守る規則 14 個)の見直し。6 項目の形のうち「読む Skill」以外の欄の欠け(本文「何を見つけたか」の「入力」「手順」の欠け)は、同じ経路で直るかを設計で確かめ、直らなければ別の課題へ起こす + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | conductor が supervisor を起動した | フェーズの開始 | 起動指示が 10 項目の形でない(依頼の文だけ)。この場合も E3 以降が成り立つこと(受け入れ条件 1) | — | +| E2 | supervisor が worker の起動指示の形を得た | supervisor の起動(定義・起動指示・検査のどれで得るかは設計) | 形を得られないまま worker を起動する。これが今の不具合である | E1 | +| E3 | supervisor が `stage-skills.py lookup <工程>` を打った | 工程の中で worker を起動する直前 | 終了コードが 0 以外なら、欄を空にせず `結果: 判断が要る` で返す(今の規約) | E2。工程の名前は起動指示の「フェーズ」から取る | +| E4 | supervisor が「読む Skill」の欄つきで `ndf:worker` を起動した | E3 の出力 | 欄の無い起動指示で起動した。機械で検出して止めるかは未決 2 | E3 | +| E5 | worker が挙がった `SKILL.md` を読んだ | 作業の開始 | パスのファイルが無いときは `結果: 判断が要る` で返す(今の規約) | E4 | +| E6 | worker が規則に従って文書を書き、作業の報告を返した | E5 | — | E5 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 読む Skill | supervisor が `ndf:worker` の起動指示に置く欄。作業の前に読む規則の Skill の名前と `SKILL.md` の絶対パスを並べる | +| Skill の対応表 | 工程の名前から、その工程の worker に届ける規則の Skill の一覧を引く表 | + +## 受け入れ条件 + +- [ ] 1. conductor の役が設計のフェーズ(通す工程は「設計」だけ)の `ndf:supervisor` を起動し、設計文書 1 つを `ndf:worker` に書かせたとき、`ndf:worker` を起動する Agent の呼び出しの `prompt` に「読む Skill」の欄があり、`document-style` の名前と、`stage-skills.py lookup 設計` が返すのと同じ `SKILL.md` の絶対パスが並ぶ。conductor の起動指示が依頼の文だけのときと、`agent-layers.md` の 10 項目の形のときの両方で成り立つ +- [ ] 2. 受け入れ条件 1 の起動で、`ndf:worker` が作業の前に、欄に挙がった `document-style` の `SKILL.md` を Read する(実起動の記録の Tool の呼び出しで確かめる) +- [ ] 3. 受け入れ条件 1 の起動で、supervisor の Tool の呼び出しに `stage-skills.py lookup 設計` の実行がある。「読む Skill」の名前とパスを、対応表を引かずに書いていない +- [ ] 4. Skill の対応表が空を返す工程(例: 作業場所の用意)と、工程に入る前の作業の `ndf:worker` の起動指示では、「読む Skill」の欄が「無し」である(欄そのものは省かれない) +- [ ] 5. `ndf:supervisor` と `ndf:supervisor-waits` の定義の本文は、frontmatter を除いて同じである(退行しない) +- [ ] 6. 工程と Skill の組(`document-style` などの規則の Skill の名前と工程の名前の組)は、Skill の対応表のほかに増えない。`grep -rn "document-style" plugins/ndf/agents plugins/ndf/skills/development-workflow/references/agent-layers.md` の当たりが 0 件のままである(2026-11-02 の `develop` で 0 件) +- [ ] 7. プランの worker への届け方は変わらない。`plugins/ndf/scripts/tests` の既存テストがすべて通る(退行しない) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | supervisor の起動 1 回あたりに増える費用と秒を、設計 PR で実測値として示す。上限は Skill の対応表の増分の上限(起動 1 回あたり費用 $0.10・秒 +2 秒。`docs/specifications/ndf-worker-stage-skills.md`)と同じ値を置き、超えるときは設計で示して承認ゲート 1 で決める | +| 運用・保守性 | 「読む Skill」の決め方の正本は `agent-layers.md` の「supervisor → worker」の 1 か所のままにし、supervisor の定義や conductor の規則へ同じ規約を写さない(Value 7「正本は 1 か所に置き、写さずに指す」)。写さずに経路を作れないときは、設計で理由を示す | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない(Agent の定義名・Skill の対応表・`stage-skills.py` の入出力は今のまま) | +| データ | 無し | +| 既存の振る舞い | Agent の 3 層の supervisor が `ndf:worker` を起動するときの起動指示の中身が変わる。機械の検査を足す場合(未決 2)は、「読む Skill」の欄の無い `ndf:worker` の起動が止まる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`。機械の検査を足す場合は、その検査の単体テストを足す | +| 静的解析 | `claude plugin validate .`、`python3 scripts/check-skill-frontmatter.py`、`python3 plugins/ndf/scripts/instructions-check.py --root .` | +| 受け入れ条件 5・6 | `diff <(awk 'f>=2{print} /^---$/{f++}' plugins/ndf/agents/supervisor.md) <(awk 'f>=2{print} /^---$/{f++}' plugins/ndf/agents/supervisor-waits.md)` の終了コードが 0。受け入れ条件 6 の `grep` が 0 件(2026-11-02 の `develop` で 0 件) | +| 手動確認 | 受け入れ条件 1〜4 は、前提 4 の条件で `claude -p` の実起動を起動指示の 2 つの形で 2 回ずつ行い、実起動の記録(stream-json)の Agent の呼び出しの `prompt` と Tool の呼び出しで判定する。リリース後テストで確かめる | +| 手動確認(マージ前) | 受け入れ条件 1・2 を、実装の worktree のプラグインのディレクトリ(`--plugin-dir`)で 1 回ずつ確かめる(リリース前に退行の有無を知るため) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md` の「マーケットプレイスの構造」。Agent の定義は `plugins/ndf/agents/`、3 層の規約は `development-workflow` の `references/agent-layers.md` | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない | +| テスト戦略 | 振る舞いは実起動の記録で確かめる(手動確認)。機械の検査を足す場合は、その判定を単体テストで確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、`supervisor.md` と `supervisor-waits.md` の本文を同じに保つ | +| 確認してから行う | hook の判定を足す・変える(承認ゲート 1・2 で人が見る) | +| 行わない | Skill の対応表の行の変更、プランの worker への届け方の変更、`ndf:worker` の定義の変更 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. supervisor が worker の起動指示の形を得る経路をどこに置くか(supervisor の定義から正本の節を読ませる・conductor の起動指示の規則に足す・起動時に正本の節を渡す など) | 設計(`design`)。承認ゲート 1 で利用者が確かめる | 設計 PR | +| 2. 「読む Skill」の欄の無い `ndf:worker` の起動を、hook などの機械の検査で止めるか(Value 4「決まった手順はスクリプトに」と、検査が誤って止める危険の比較) | 設計(`design`)。承認ゲート 1 で利用者が確かめる | 設計 PR | From 1bd293a162c9f978dcbeff4ed9c0978ff6899749 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 06:51:48 +0000 Subject: [PATCH 002/127] =?UTF-8?q?Docs:=20#2401=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88=E3=83=AA=E3=83=AA?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E5=BE=8C=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7?= =?UTF-8?q?=E5=88=A9=E7=94=A8=E8=80=85=E3=81=AE=E8=A8=AD=E5=AE=9A=E3=83=87?= =?UTF-8?q?=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E3=81=B8=E5=B0=8E?= =?UTF-8?q?=E5=85=A5=E3=81=97=E3=81=AA=E3=81=84=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 2 + docs/glossary/glossary.json | 12 +++ issues/issue-2401-requirements.md | 166 ++++++++++++++++++++++++++++++ 3 files changed, 180 insertions(+) create mode 100644 issues/issue-2401-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 303a0773f..2307722fb 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -638,6 +638,8 @@ release が走らせるリリースの種別・リリースコマンド・公開 | 配布物の変化なし | — | PR の本文の `## 利用者向けの変化` が「無し」(「なし」も同じ)だけを持つこと。承認資料の「含む PR」の行に印を付け、CHANGELOG の版の節と「配る中身」には載せない。節の無い本文(題名で代える)とは別に扱う | — | — | `docs/specifications/ndf-release-approval-included-prs.md` | | マニフェスト | — | plugins/ndf/manifests/<ランタイム>-skills.txt。ランタイムごとに配る Skill の一覧で、配布の唯一の基準(#2231) | — | — | `docs/specifications/ndf-distribution-runtimes.md` | | 配布先のランタイム | — | マーケットプレイスのプラグインを導入できる形で届けるランタイム。#2231 の後は Claude Code と codex で、相手役のランタイムとは別に決まる(#2231) | — | — | `docs/specifications/ndf-distribution-runtimes.md` | +| 利用者の設定ディレクトリ | — | 利用者の Claude Code の設定ディレクトリ(`CLAUDE_CONFIG_DIR`、無ければ `~/.claude`)と Codex の設定ディレクトリ(`CODEX_HOME`、無ければ `~/.codex`)。リリース後テストはこの中の導入済みのプラグインと取得元の登録を変えない | — | — | — | +| 導入の操作 | — | プラグインの導入・更新と、取得元の登録・更新(`claude plugin install` / `update` / `marketplace add` / `marketplace update`、`codex plugin add` / `marketplace add`)。リリース後テストの worker は利用者の設定ディレクトリでこれを打たない | — | — | — | ## NDF の指示書チェック(`ndf-instructions`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 830e193df..b62b7dda3 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5241,6 +5241,18 @@ "context": "ndf-workflow", "meaning": "conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで", "pending_source": "issues/issue-2262-design.md" + }, + { + "term": "利用者の設定ディレクトリ", + "context": "ndf-release", + "meaning": "利用者の Claude Code の設定ディレクトリ(`CLAUDE_CONFIG_DIR`、無ければ `~/.claude`)と Codex の設定ディレクトリ(`CODEX_HOME`、無ければ `~/.codex`)。リリース後テストはこの中の導入済みのプラグインと取得元の登録を変えない", + "pending_source": "issues/issue-2401-requirements.md" + }, + { + "term": "導入の操作", + "context": "ndf-release", + "meaning": "プラグインの導入・更新と、取得元の登録・更新(`claude plugin install` / `update` / `marketplace add` / `marketplace update`、`codex plugin add` / `marketplace add`)。リリース後テストの worker は利用者の設定ディレクトリでこれを打たない", + "pending_source": "issues/issue-2401-requirements.md" } ] } diff --git a/issues/issue-2401-requirements.md b/issues/issue-2401-requirements.md new file mode 100644 index 000000000..55110a91f --- /dev/null +++ b/issues/issue-2401-requirements.md @@ -0,0 +1,166 @@ +# #2401: リリース後テストの worker が手で導入を試し、利用者の設定の ndf の版を黙って上げる + +正は課題の本文(#2401)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> ## 何を見つけたか +> +> リリース後テストの worker が、導入の確認のスクリプト(`release-verification-steps.py verify-install`)とは別に、導入を手で試した。隔離のつもりで付けた環境変数が効かず、利用者の Claude Code の設定(`~/.claude/ndf/accounts/team`)の ndf が 10.27.7 から 10.27.10 へ更新された(2026-11-02 15:30 JST)。 +> +> - 記録: https://github.com/devbasex/ai-plugins/pull/2395#issuecomment-6570809748 の「補足」 +> - 同じマシンで別の開発ループ 2 本(lflow・lfactory2)が動いていた。どちらも版のディレクトリ(`.../ndf/10.27.7/scripts`)を名指しで使い、そのキャッシュが残っていたため止まらなかった。キャッシュが消えていれば止まっていた +> - `verify-install` は `env -i` で動くため、この影響を受けない。手で試す必要は無かった +> +> ## どこで見つけたか +> +> スプリント lroot2-2(#2376)の振り返り。本番の PR https://github.com/devbasex/ai-plugins/pull/2396 +> +> ## なぜこの変更の範囲外なのか +> +> このスプリントは worker へ工程の Skill を届ける仕組み(#2376)を作った。リリース後テストの手順(`release-verification`)は変えていない +> +> ## 直さないと何が起きるか +> +> リリース後テストのたびに、利用者の導入済みの版が黙って変わり得る。戻すには前の版を入れ直す操作が要る。共通原則の「人を守る」と、利用者の環境を黙って書き換えない考え(C6 の趣旨。今回は `~/.claude/` の中なので C6 そのものには当たらない)に反する +> +> ## 受け入れ条件 +> +> - リリース後テストの導入の確認は `verify-install`(隔離した環境)だけで行い、worker が利用者の設定へ導入する操作をしない。手順書か worker への指示でそれを定める +> - 利用者の設定へ導入する必要があるときは、実行の前に止めて人へ戻す +> +> ## 起票者の推定 +> +> - 受け入れ条件は起票者(振り返りの AI)の推定で、利用者の指示ではない +> - 根拠: 上位の原則(人を守る)・Value 2(利用者の手を止めない) +> +> ## 由来 +> +> PR #2396(スプリント lroot2-2 の本番の配布)の振り返り + +(書き手: 起票者、日付: 2026-11-02。スプリント lroot2-2 の振り返りの AI が起票した本文。利用者の指示の原文は課題に無いため、利用者の文としては扱わない) + +> #2401。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。本文を書いたら `spec-copy.py write 2401 issues/issue-2401-requirements.md` で写しを作り、コミットする(push しない)。 + +(書き手: AI の起動指示、日付: 2026-11-02) + +## 起票者の推定 + +題・本文のうち、利用者の指示の原文に無く起票者が推して足した条件。課題の本文に利用者の原文は無いため、旧本文の条件はすべてこの節に当たる。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | リリース後テストの導入の確かめは `verify-install`(隔離した環境)だけで行い、worker は利用者の設定ディレクトリへ導入する操作をしない | 旧本文の受け入れ条件 1。根拠は上位の原則(人を守る)と Value 2 | 受け入れ条件 1・2 | +| S2 | 禁止は手順書か worker への指示で定める | 旧本文の受け入れ条件 1 | 受け入れ条件 1・2(手順書と指示の両方に置く) | +| S3 | 利用者の設定ディレクトリへ導入する必要があるときは、実行の前に止めて人へ戻す | 旧本文の受け入れ条件 2 | 受け入れ条件 3 | +| S4 | 手順書と指示だけでなく、利用者の設定ディレクトリが変わったことを機械で検出し、人へ戻す | 要求の工程(この文書を書いた AI)が足した。手順書の禁止は worker が読み落とすと効かない。決まった検査はスクリプトに任せる(プロジェクト Value 4)。上位の原則(人を守る)は効率より先に効く | 受け入れ条件 4・5・前提 3 | + +## 目的 + +- リリース後テストを何度回しても、利用者の Claude Code と Codex に導入済みのプラグインの版と取得元の登録が、利用者の操作なしに変わらない状態にする(上位の原則「人を守る」、C6 の趣旨) +- 万一変わったときは、その場で人が知り、戻すかどうかを決められる状態にする + +## 前提 + +- 前提 1: 「利用者の設定ディレクトリ」は、Claude Code の設定ディレクトリ(環境変数 `CLAUDE_CONFIG_DIR`、無ければ `~/.claude`)と Codex の設定ディレクトリ(`CODEX_HOME`、無ければ `~/.codex`)を指す。この 2 つの中の導入済みのプラグインの記録と取得元の登録を守る対象にする +- 前提 2: 2026-11-02 の事象で環境変数が効かなかった理由は未確認である。記録( https://github.com/devbasex/ai-plugins/pull/2395#issuecomment-6570809748 )には worker が付けた変数の名前が無い。この環境では `CLAUDE_CONFIG_DIR=/home/ubuntu/.claude/ndf/accounts/team` が設定されており(2026-11-02 に `echo $CLAUDE_CONFIG_DIR` で確かめた)、`HOME` だけを差し替えても Claude Code の設定ディレクトリは変わらない、という推定に留まる。この要求は理由に依らず、手で導入しないこと(受け入れ条件 1〜3)と、変わったら検出すること(受け入れ条件 4・5)で守る +- 前提 3: 変わったことの検出は、導入した後に知らせる仕組みであり、導入を防ぐ仕組みではない。防ぐのは手順書と起動指示(受け入れ条件 1・2)である +- 前提 4: `verify-install` が隔離した設定ディレクトリで導入する振る舞い(`isolated_env` と `env -i`)は今のまま正しい。この要求では変えない + +## 対象範囲 + +含む: +- リリース後テストの手順書(`release-verification` の Skill)で、導入の確かめを `verify-install` に限り、利用者の設定ディレクトリでの導入の操作を禁じること +- まとめのプランのリリース後テストのステップ(`release-verify`)の、worker への起動指示に同じことを書くこと +- 利用者の設定ディレクトリへの導入が要る受け入れ条件に当たったときの、worker の振る舞い(導入せずに保留とし、人へ戻す) +- リリース後テストのステップの前後で、利用者の設定ディレクトリの導入済みのプラグインと取得元の登録を比べ、変わっていたら人へ戻す検査 +- `verify-install` が比べる「利用者の環境」に、利用者の設定ディレクトリの導入済みのプラグインと取得元の登録を加えること + +含まない: +- 2026-11-02 に 10.27.10 へ上がった、利用者の設定ディレクトリの ndf を 10.27.7 へ戻すこと(リリースした版と同じであり、戻すかは利用者が決める) +- 別の開発ループが版のディレクトリを名指しで使い、そのディレクトリが消えると止まる問題(課題の本文の「何を見つけたか」の 2 点目。別の課題で扱う) +- `release` の手順書にある、隔離した設定ディレクトリで取得の経路を確かめる手順(`release/references/form-package-plugin.md` の「隔離した設定ディレクトリで取得の経路を確かめる」)の見直し。`CLAUDE_CONFIG_DIR` と `CODEX_HOME` を差し替えており、リリース後テストの工程ではない +- リリース後テスト以外の工程(実装・完了判定など)での導入の操作の禁止 +- 利用者の設定ディレクトリを守るための権限の仕組み(hook で `claude plugin install` を拒否するなど) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | リリース後テストのステップが利用者の設定ディレクトリの状態を控えた | 本番のリリースが終わり、まとめのプランが `release-verify` のステップを始めた | 設定ディレクトリが読めなければ、控えが取れなかったことを報告に載せ、人へ戻す(受け入れ条件 4) | 本番のリリースが終わった | +| E2 | worker が `verify-install` で導入を確かめた | E1 の後の worker の手順 1 | 終了コード 1 なら不合格として記録する(今の手順のまま) | E1 | +| E3 | `verify-install` が利用者の設定ディレクトリの前後を比べた | E2 の導入の前と後 | 変わっていれば `status` が `ok` にならない(受け入れ条件 5) | E2 | +| E4 | worker が受け入れ条件ごとに確かめた | E2 が合格 | 利用者の設定ディレクトリへの導入が要る条件は E5 へ | E2 | +| E5 | worker が利用者の設定ディレクトリへの導入が要る条件を保留にした | E4 で、利用者の設定ディレクトリに導入しないと確かめられない条件に当たった | — (導入せず、保留の行と「判断が要る」の報告で人へ戻す。受け入れ条件 3) | E4 | +| E6 | worker がリリース後テストの記録を配布の記録の PR へ書いた | E4・E5 が終わった | 書けなければ今の手順のまま報告する | E4 | +| E7 | ステップが利用者の設定ディレクトリの今の状態を控えと比べた | worker が終わった | 変わっていれば、ステップを先へ進めず、変わった項目と前後の値を添えて人へ戻す(受け入れ条件 4) | E1・E6 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 利用者の設定ディレクトリ | 利用者の Claude Code の設定ディレクトリ(`CLAUDE_CONFIG_DIR`、無ければ `~/.claude`)と Codex の設定ディレクトリ(`CODEX_HOME`、無ければ `~/.codex`)。前提 1 | +| 導入の操作 | プラグインの導入・更新と、取得元の登録・更新。Claude Code の `claude plugin install` / `claude plugin update` / `claude plugin marketplace add` / `claude plugin marketplace update` と、Codex の `codex plugin add` / `codex plugin marketplace add` | + +## 受け入れ条件 + +手順書と起動指示: +- [ ] 1. `release-verification` の Skill の手順に、導入の確かめは `verify-install` だけで行い、利用者の設定ディレクトリで導入の操作を打たないことが書かれている。隔離のつもりで環境変数を付けて手で導入することも、この禁止に含まれる +- [ ] 2. まとめのプランの `release-verify` のステップを生成すると、worker への起動指示に、受け入れ条件 1 と同じ禁止と、`release-verification` の Skill の該当の節への参照が入る +- [ ] 3. 受け入れ条件の確認に利用者の設定ディレクトリへの導入が要るとき、worker は導入の操作を打たず、記録の表のその行を「保留(理由: 利用者の設定ディレクトリへの導入が要る)」とし、作業の報告を「判断が要る」で返す。このことが手順書と起動指示に書かれている + +検出: +- [ ] 4. `release-verify` のステップの前後で、利用者の設定ディレクトリの導入済みのプラグインの記録と取得元の登録(Claude Code と Codex の両方)を比べる。変わっていなければステップは今と同じく次(振り返り)へ進む。変わっていれば次へ進まず、変わった項目(プラグインの名前か取得元の名前)と前後の値を結果に載せて人へ戻す。控えが取れなかったときも人へ戻す(テストで、一時ディレクトリの設定ディレクトリを書き換えて確かめる) +- [ ] 5. `verify-install` の結果の「利用者の環境が変わっていない」(`metrics.user_env_unchanged`)が、利用者の設定ディレクトリの導入済みのプラグインの記録と取得元の登録も比べる。導入の途中でそれが変わると `user_env_unchanged` が false になり、`status` は `ok` にならず、変わった項目が `items` に `kind: user_env` で載る(テストで確かめる) + +退行しない: +- [ ] 6. 利用者の設定ディレクトリが変わらない場合、`verify-install` の結果の形・終了コード・`status` は今と同じである。既存のテスト(`test_step_scripts.py` などの `verify-install` を扱うテスト)が書き換えずに通る +- [ ] 7. NDF のテスト(`uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`)とチェック(`python3 scripts/check-skill-frontmatter.py`・`claude plugin validate .`)が通る + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| セキュリティ | 利用者の設定ディレクトリは読むだけで、書かない。比べるのはプラグインの名前・版・取得元の名前と場所だけで、認証情報(Codex の `auth.json` など)を読まず、結果へも載せない | +| 運用・保守性 | 利用者の設定ディレクトリが変わったときの報告から、人が戻す操作(どのプラグインを何の版へ入れ直すか)を決められる(受け入れ条件 4 の前後の値) | +| システム環境 | 利用者の設定ディレクトリの場所は環境変数(`CLAUDE_CONFIG_DIR`・`CODEX_HOME`)と既定の場所から決め、ai-plugins の開発環境の形を既定に埋め込まない(プロジェクト Value 5) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる(互換あり)。`verify-install` の結果に、利用者の設定ディレクトリの項目が `kind: user_env` として加わる。利用者の設定ディレクトリが変わらなければ結果の形は今と同じ | +| データ | 無し | +| 既存の振る舞い | `release-verify` のステップが、利用者の設定ディレクトリが変わったときに振り返りへ進まず止まる。worker が利用者の設定ディレクトリへの導入が要る条件を保留にする | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(受け入れ条件 2・4・5・6 は単体テスト。設定ディレクトリは一時ディレクトリで作る) | +| 静的解析・型検査 | `python3 scripts/check-skill-frontmatter.py`・`claude plugin validate .`・`python3 plugins/ndf/scripts/instructions-check.py --root .` | +| 手動確認 | 次のスプリントの本番のリリース後テストで、`release-verify` のステップの前後に利用者の設定ディレクトリの導入済みの版と取得元の登録が変わらず、ステップの結果に比べた記録が残ることを、人が配布の記録の PR とステップの結果で見る(受け入れ条件 1・3・4。リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 変更は `plugins/ndf/skills/release-verification/` と `plugins/ndf/scripts/`(まとめのプランの雛形・`release-verification-steps.py`・テスト)に置く。既定で動くものから実験版(`plugins/ndf/scripts/experimental/`)を参照しない(`AGENTS.md` の「安定版と実験版」) | +| コーディング規約 | `plugins/ndf/skills/AUTHORING.md`(Skill の書き方)、結果 JSON の形は `plugins/ndf/scripts/lib/README.md` の「手順のスクリプトの結果」 | +| テスト戦略 | 検出(受け入れ条件 4・5)とステップの生成(受け入れ条件 2)は単体テストで確かめる。`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T)。手順書の効き目(受け入れ条件 1・3)は実際のリリース後テストで確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | NDF のテストとチェックの実行、利用者の設定ディレクトリを読むだけで書かないこと | +| 確認してから行う(承認ゲート 1・2 で人が見る) | `verify-install` の結果の項目を加えること、`release-verify` のステップが止まる条件を加えること | +| 行わない | 利用者の設定ディレクトリへの導入の操作(開発の作業でも打たない)、利用者の設定ディレクトリの ndf の版を戻すこと、`release` の手順書の隔離の手順の変更、hook による導入の操作の拒否 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 受け入れ条件 4 の比較をどこで行うか(まとめのプランのステップの前後を挟む仕組みか、`release-verify` のステップを前後の `run` のステップで挟むか、`release-verification-steps.py` に控えと比較のサブコマンドを足すか) | 設計の工程(AI) | 設計 PR | +| 2. 同じマシンの別のセッション(Claude Code の自動の更新を含む)が、ステップの間に利用者の設定ディレクトリを変えたときの誤検出の扱い。起きる頻度は未確認。誤検出でも人へ戻す(人を守る側に倒す)ことを既定とし、区別の手段があるかを設計で調べる | 設計の工程(AI) | 設計 PR | +| 3. Claude Code と Codex の、導入済みのプラグインの記録と取得元の登録のファイルの場所と形。Claude Code は `$CLAUDE_CONFIG_DIR/plugins/installed_plugins.json` と `known_marketplaces.json` にあることを 2026-11-02 に `ls` で確かめた。Codex の側は未確認 | 設計の工程(AI) | 設計 PR | From 0acd66b4d1e97b8e1bc2987f4dce5fcff39033a4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 06:56:23 +0000 Subject: [PATCH 003/127] =?UTF-8?q?Docs:=20#2399=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Agent の supervisor が起動する ndf:worker に「読む Skill」が届かない不具合の設計。 supervisor の定義に正本の節の在りかを指す 1 段落を足し、欄の無い起動と対応表を引かずに Skill を挙げた起動を PreToolUse の hook(読む Skill の guard)で止め、止めた理由に正本の節を その場で読み出して添える。用語集に「読む Skill の guard」を足す。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 1 + docs/glossary/glossary.json | 6 + issues/issue-2399-design.md | 313 ++++++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+) create mode 100644 issues/issue-2399-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 303a0773f..43defe096 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -345,6 +345,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | Skill の対応表 | — | 工程(ステップの stage)から、そのステップの worker へ届ける Skill の一覧を引く表。NDF の scripts/lib/stage-skills.json の 1 か所に置き、プランの worker の起動と ndf:worker の起動指示の両方が読む(#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | | Skill の渡し方 | — | Skill を worker へ届ける形。プランの worker には本文を system プロンプトへ入れ(本文)、ndf:worker には SKILL.md の絶対パスを起動指示の「読む Skill」に書く(パス。#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | | 読む Skill | — | supervisor が ndf:worker の起動指示に書く項目。その作業で読む規則の Skill の名前と SKILL.md の絶対パスを並べ、worker はここに挙がった SKILL.md だけを読む(#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | +| 読む Skill の guard | — | supervisor が ndf:worker を起動する Agent の呼び出しを PreToolUse の hook で見て、「読む Skill」の欄が無い起動と、Skill の対応表を引かずに Skill を挙げた起動を止める判定。止めた理由に正本の「supervisor → worker」の節を添える(#2399) | — | — | — | | 規則の Skill | — | 手順を持たず、書き方・記録の仕方のような規則だけを持つ Skill(document-style・logging-guidelines など)。Skill の対応表に置けるのはこれだけで、工程 Skill は置かない(#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | | 差分の棚卸し | — | 毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題への見積の段階の付与だけを行う(#2315) | — | — | — | | 見直し | — | 数スプリントに 1 回行う棚卸しの深さ。後回しを除く open の課題の枝刈り・順位付け・マイルストーンの再編と、ニーズ・シーズの調査からの起票を行う(#2315) | — | — | — | diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 830e193df..12c89864b 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4946,6 +4946,12 @@ "meaning": "supervisor が ndf:worker の起動指示に書く項目。その作業で読む規則の Skill の名前と SKILL.md の絶対パスを並べ、worker はここに挙がった SKILL.md だけを読む(#2376)", "source": "docs/specifications/ndf-worker-stage-skills.md" }, + { + "term": "読む Skill の guard", + "context": "ndf-workflow", + "meaning": "supervisor が ndf:worker を起動する Agent の呼び出しを PreToolUse の hook で見て、「読む Skill」の欄が無い起動と、Skill の対応表を引かずに Skill を挙げた起動を止める判定。止めた理由に正本の「supervisor → worker」の節を添える(#2399)", + "pending_source": "issues/issue-2399-design.md" + }, { "term": "規則の Skill", "context": "ndf-workflow", diff --git a/issues/issue-2399-design.md b/issues/issue-2399-design.md new file mode 100644 index 000000000..e4c7bb2d1 --- /dev/null +++ b/issues/issue-2399-design.md @@ -0,0 +1,313 @@ +# Agent の supervisor: 起動する ndf:worker に、いま通している工程の規則の Skill が届く(#2399) + +## 目的 + +- **今どうなっているか**: Agent の `ndf:supervisor` が `ndf:worker` を起動するとき、起動指示に「読む Skill」の欄が無い。v10.27.10 で 2 回起動し、2 回とも supervisor は正本の節(`agent-layers.md` の「supervisor → worker」)を読まず、Skill の対応表も引かなかった。設計の工程の worker に `document-style` が届いていない +- **誰に効くか**: プランのテンプレートが無いフェーズを Agent の 3 層で回す利用者(設計・確定仕様化・振り返りなど、文書を書く工程の worker の成果物を受け取る人) +- **変えると何が成り立つか**: conductor が起動指示をどの形で書いても、supervisor が起動する `ndf:worker` の起動指示に「読む Skill」の欄が入り、対応表が挙げる Skill を worker が作業の前に読む + +## 適用範囲 + +- **働く範囲**: このリポジトリと、NDF を Claude Code へ導入したすべてのリポジトリ。Codex には Agent の supervisor が無いため働かない。プランの worker(`supervise.py` が起動する `claude -p`)は対象外で、振る舞いは変わらない +- **プロジェクトごとに違うもの**: 無し(工程と Skill の組は NDF の対応表が持ち、プロジェクトの設定を読まない)。止める判定は環境変数 `NDF_WORKER_SKILLS_GUARD=0` で外せる +- **当たるモード**: supervisor でフェーズを回すすべてのモード(`standard` / `documentation` / `legacy-refactor` / `light` / `operation`) + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 再現の 2 回の実起動の記録(2026-11-02 15:33・15:35 JST)で、supervisor の Tool の呼び出しに `agent-layers.md` の Read と `stage-skills.py` の実行が 1 件も無い | 実測 | 定義に「規約の正本は〜にある」と書くだけでは、supervisor は正本を読まない。言語モデルへの指示だけに頼らない経路が要る | +| 同じ記録の 2 回目で、supervisor は worker への指示をファイルへ書き、Agent の `prompt` には「このファイルを読め」の 1 行だけを渡した | 実測 | 検査は Agent の `prompt` の本文を見る。欄を `prompt` の本文に置くことを、止めた理由の文で求める | +| 今の hook(Agent の呼び出し 1 回)の所要時間は 44〜52 ms。312 KB の supervisor の会話の記録から `stage-skills.py` の実行を探す走査は、Python の起動 14 ms を除いて 1 ms 程度 | 実測 | 判定を hook に足しても、秒の上限(起動 1 回あたり +2 秒)に収まる | +| 「supervisor → worker」の節(`agent-layers.md` の 184〜215 行)は 4,965 バイト(2,405 文字)。`agent-layers.md` の全体は 41,294 バイト | 実測 | supervisor に正本のファイル全体を読ませるより、止めたときに節だけを返す方が、読み込む量が 8 分の 1 程度で済む | + +要求と受け入れ条件は #2399 の本文にある(コピーは `issues/issue-2399-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の工程(`ndf-workflow`) | 3 層(conductor・supervisor・worker)・起動指示・読む Skill・Skill の対応表・工程 | + +1 つのコンテキストに収まる。hook は同じコンテキストの規約(正本の節)をそのまま読み、語を翻訳しない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| worker の起動指示 | supervisor(Agent の `ndf:supervisor` と `ndf:supervisor-waits`) | 起動指示(Agent の呼び出し 1 回) | — | 読む Skill の欄(Skill の名前と `SKILL.md` の絶対パスの並び、または「無し」)・作業・入力・手順・返す形・置き場所 | +| Skill の対応表 | この課題では変えない(`stage-skills.json`) | 対応表 | 工程の行 | Skill の名前とパスの組 | + +読む Skill の guard は起動指示を書き換えない。止めるか通すかだけを決め、直すのは持ち主の supervisor である。起動指示は対応表を Skill の名前とパスで参照し、対応表の行を持たない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | worker の起動指示 | supervisor が起動する `ndf:worker` の起動指示の `prompt` は、「読む Skill」の欄を持つ(値は Skill の並びか「無し」) | 読む Skill の guard が起動を止め、正本の「supervisor → worker」の節を止めた理由に添えて返す | +| I2 | worker の起動指示 | 欄に Skill を挙げた起動の前に、その supervisor が `stage-skills.py lookup` を打っている | 読む Skill の guard が起動を止め、対応表の引き方(`stage-skills.py` の絶対パスつき)を返す | +| I3 | Skill の対応表 | 工程と Skill の組は対応表の 1 か所にだけある。supervisor の定義・正本の節・読む Skill の guard のコードは Skill の名前を書かない | 受け入れ条件 6 の `grep` が当たる。テストが落ちる | +| I4 | worker の起動指示 | `ndf:supervisor` と `ndf:supervisor-waits` の定義の本文は、frontmatter を除いて同じである | 受け入れ条件 5 の `diff` が 0 以外を返す | +| I5 | worker の起動指示 | supervisor 以外(conductor・人・ほかのエージェント)が起動する `ndf:worker` と、`ndf:worker` 以外の起動は止めない | 読む Skill の guard が判定をせずに通す | +| I6 | worker の起動指示 | 読む Skill の guard が呼び手の会話の記録を読めないときと、判定の途中で失敗したときは、起動を止めない | 判定をせずに通す(hook の既存の原則「どの失敗も終了コード 0 で通す」) | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | conductor が supervisor を起動した | conductor | supervisor | +| E2 | supervisor が worker の起動指示の形を得た | supervisor の定義(指す 1 文)、または E7 の止めた理由 | supervisor | +| E3 | supervisor が `stage-skills.py lookup <工程>` を打った | supervisor | 読む Skill の guard(E4 の判定で会話の記録から読む) | +| E4 | supervisor が「読む Skill」の欄つきで `ndf:worker` を起動した | supervisor | 読む Skill の guard、worker | +| E5 | worker が挙がった `SKILL.md` を読んだ | worker | — (作業の前提。E6 の成果物に規則が効く) | +| E6 | worker が規則に従って文書を書き、作業の報告を返した | worker | supervisor | +| E7 | 読む Skill の guard が起動を止めた | 読む Skill の guard | supervisor(止めた理由を読み、E3 からやり直す) | + +E1〜E6 は要求の番号を引き継ぐ。E7 はこの設計で足す。 + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 読む Skill の guard | supervisor が `ndf:worker` を起動する Agent の呼び出しを PreToolUse の hook で見て、「読む Skill」の欄が無い起動と、対応表を引かずに Skill を挙げた起動を止める判定。止めた理由に正本の「supervisor → worker」の節を添える | 追加 | + +「読む Skill」「Skill の対応表」は用語集の意味のまま使う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | supervisor が、定義から worker の起動指示の形(正本の節)の在りかを知る | supervisor | +| F2 | 「読む Skill」の欄が無い `ndf:worker` の起動を止め、正本の節を返す | supervisor(止められた側) | +| F3 | 対応表を引かずに Skill を挙げた `ndf:worker` の起動を止め、引き方を返す | supervisor(止められた側) | +| F4 | 読む Skill の guard を環境変数で外す | 利用者 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| supervisor の定義(`plugins/ndf/agents/supervisor.md`・`supervisor-waits.md`) | worker の起動指示は正本の「supervisor → worker」の 6 項目で組むこと、欠けた起動は hook が止めて節を返すことを、1 段落で指す。規約を写さない。2 つの本文は同じに保つ | +| 読む Skill の guard(新設 `plugins/ndf/scripts/hook_lib/worker_skills.py`) | Agent(旧名 Task)の呼び出しのうち、呼び手が supervisor で相手が `ndf:worker` のものだけを判定する。欄の有無と値(「無し」か Skill か)を `prompt` から読み、Skill を挙げたときは呼び手の会話の記録に `stage-skills.py lookup` の実行があるかを見る。止めるときは正本の節をその場でファイルから読み出して理由に添える | +| hook のエントリポイント(`plugins/ndf/scripts/hook.py`) | Claude Code の PreToolUse で Agent・Task のときに読む Skill の guard を締め切りの中で打ち、拒否をほかの拒否と同じ規則で出す。docstring の振り分けの表に 1 行足す | +| 正本の節(`plugins/ndf/skills/development-workflow/references/agent-layers.md` の「supervisor → worker」) | 「読む Skill」の行に、欄の無い起動と対応表を引かずに Skill を挙げた起動を hook が止めることを 1 文で足す。決め方そのものは変えない | +| テスト(新設 `plugins/ndf/scripts/tests/test_worker_skills_guard.py`) | 不変条件 I1・I2・I5・I6 と、止めた理由に節が入ることを縛る | +| 用語集(`docs/glossary/glossary.json` と生成物 `docs/glossary.md`) | 「読む Skill の guard」を足す | + +hook の登録(`plugins/ndf/hooks/claude.json`)は変えない。PreToolUse の matcher は既に `Agent|Task` を含む。 + +### システムの文脈 + +```mermaid +graph LR + 人[利用者] --> CC[Claude Code の実行環境] + CC --> NDF[NDF の配布物
Agent の定義・hook・正本の節・対応表] +``` + +外部の系は Claude Code の実行環境だけである。PreToolUse の hook の入力(呼び手の `agent_id`・`agent_type`・`transcript_path`・`tool_input`)と、拒否の返し方(`permissionDecision: deny`)は Claude Code が決め、この変更は使う側に留まる。 + +### 構成要素の関係 + +```mermaid +graph TD + subgraph 定義 + SV[supervisor の定義] + end + subgraph hook + EP[hook のエントリポイント] + G[読む Skill の guard] + end + subgraph 規約と表 + SEC[正本の節] + TBL[Skill の対応表] + end + SV -->|在りかを指す| SEC + EP --> G + G -->|止めるとき読み出す| SEC + G -->|会話の記録から実行を探す| LK[stage-skills.py lookup] + LK --> TBL +``` + +テストと用語集は図に含めない(どちらも実行の経路に現れない)。 + +### 配置 + +配置は変わらない。読む Skill の guard は、既存の hook と同じ 1 プロセス(SessionStart が用意した環境の Python)の中で動く。境界をまたいで新しく流れるものは無く、hook は呼び手の会話の記録(利用者のホームの下のファイル)を読むだけで書かない。 + +### 置き場所 + +```text +plugins/ndf/ +├── agents/ +│ ├── supervisor.md # 変更(1 段落を足す) +│ └── supervisor-waits.md # 変更(同じ段落) +├── scripts/ +│ ├── hook.py # 変更(振り分けと docstring) +│ ├── hook_lib/ +│ │ └── worker_skills.py # 新設 +│ └── tests/ +│ └── test_worker_skills_guard.py # 新設 +└── skills/development-workflow/references/ + └── agent-layers.md # 変更(「読む Skill」の行に 1 文) +docs/glossary/glossary.json # 変更(語を 1 つ足す) +docs/glossary.md # 生成(glossary.py render) +``` + +## 構造 + +読む Skill の guard は型を持たず、関数だけでできる。既存の token の guard の部品(拒否の形・環境変数の判定・入力の取り出し)を使い、重ねて定義しない。 + +```mermaid +classDiagram + class hook { + <> + _guards(command, runtime, ev, outs) + } + class worker_skills { + <> + decision(ev) dict|None + has_field(prompt) bool + lists_skills(prompt) bool + looked_up(transcript) bool + section(path) str + } + class token_guard { + <> + deny(reason) dict + _off(name) bool + _field(raw, path) str + } + hook --> worker_skills : Agent・Task のとき + worker_skills --> token_guard : 拒否の形と入力の取り出し +``` + +関数の名前と分け方は実装(`tdd-cycle`)で決めてよい。縛るのは、判定のエントリポイントが 1 つで、`hook.py` の振り分けからだけ呼ばれることである。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant C as conductor + participant S as supervisor + participant H as 読む Skill の guard + participant W as worker + C->>S: 起動(依頼の文だけ、または 10 項目) + S->>S: stage-skills.py lookup <工程> + S->>H: Agent(ndf:worker) の呼び出し + alt 欄が無い + H-->>S: 止める(理由に正本の節) + S->>S: 6 項目で組み直す + S->>H: Agent(ndf:worker) をもう一度 + else Skill を挙げたが lookup の実行が無い + H-->>S: 止める(理由に引き方) + S->>S: stage-skills.py lookup <工程> + S->>H: Agent(ndf:worker) をもう一度 + end + H-->>W: 通す + W->>W: 挙がった SKILL.md を Read + W-->>S: 作業の報告 +``` + +読む Skill の guard の判定は、上から順に次のとおりである。最初に当たった行で決まる。 + +| # | 条件 | 結果 | +| --- | --- | --- | +| 1 | `NDF_WORKER_SKILLS_GUARD=0` | 通す | +| 2 | Tool が Agent・Task でない、または `subagent_type` が `ndf:worker` でない | 通す | +| 3 | 呼び手の `agent_type` が `ndf:supervisor`・`ndf:supervisor-waits` のどちらでもない | 通す | +| 4 | `prompt` に「読む Skill」の欄が無い | 止める。理由は「欄が無い」と、正本の節の本文と、`$SCRIPTS` の絶対パス | +| 5 | 欄の値が「無し」 | 通す | +| 6 | 呼び手の会話の記録が読めない | 通す | +| 7 | 呼び手の会話の記録に `stage-skills.py lookup` の実行が無い | 止める。理由は「対応表を引いていない」と、`stage-skills.py` の絶対パスを入れた引き方と、正本の節の在りか | +| 8 | 上のどれにも当たらない | 通す | + +欄は、行の先頭(箇条書きの記号・表の区切り・見出しの記号を除いたところ)に「読む Skill」の語があり、区切り(`:`・`:`・`|`)か行末が続く行とする。値は同じ行の区切りの後、空なら次の空でない行の先頭である。欄の見分け方の細部(記号の組み合わせ)は実装で走らせて決める。 + +正本の節が読めない(ファイルが無い・見出しが見つからない)ときも 4 は止め、理由には節の本文の代わりに正本のファイルのパスと見出しの名前を書く。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | supervisor の起動 1 回あたりに増える費用と秒を、設計 PR で実測値として示す。上限は起動 1 回あたり費用 $0.10・秒 +2 秒。超えるときは設計で示して承認ゲート 1 で決める | 判定は既存の hook のプロセスの中で打ち、プロセスを増やさない。会話の記録の走査は Agent(`ndf:worker`)の呼び出しのときだけ行う。supervisor に正本のファイル全体(41,294 バイト)を読ませず、止めたときだけ節(4,965 バイト)を返す。定義に足すのは 1 段落 | 秒: 実測は下の表。費用: マージ前の手動確認(前提 4 の条件で 1 回ずつ)で、supervisor の起動 1 回の費用を変更の前後で比べる | +| 運用・保守性 | 「読む Skill」の決め方の正本は `agent-layers.md` の「supervisor → worker」の 1 か所のままにし、supervisor の定義や conductor の規則へ同じ規約を写さない | 定義は在りかを指す 1 段落だけを持つ。hook は止めたときに正本の節をファイルから読み出して返し、コードに規約の文を持たない。工程と Skill の名前は対応表にだけ置く | 受け入れ条件 6 の `grep` が 0 件。テストで、止めた理由の節が正本のファイルの中身から作られることを縛る | + +秒の実測(2026-11-02、この worktree): + +| 測ったもの | 値 | 上限との関係 | +| --- | --- | --- | +| 今の hook の 1 回(Agent・`ndf:worker`・supervisor から) | 44 / 46 / 52 ms(3 回) | 読む Skill の guard はこのプロセスの中で走る | +| 会話の記録の走査(312 KB、再現の 2 回目の supervisor の記録) | 15 ms(Python の起動 14 ms を含む) | 足し分は 1 ms 程度 | +| `stage-skills.py lookup 設計` の 1 回 | 43 ms | supervisor が打つ。今の規約がすでに求めている呼び出しで、この設計が足すものではない | + +費用は、止めずに通った起動では定義に足す 1 段落の分だけ増え、止めた起動では止められた Agent の呼び出し 1 回の出力と、返した節の入力が増える。どちらも未実測で、マージ前の手動確認で測る(「未確認のまま残ること」)。 + +## 決定の記録 + +### 決定 1: conductor の起動指示の形に依らずに届けるため、経路を supervisor の定義と hook に置き、conductor の規則には足さない + +受け入れ条件 1 は、conductor の起動指示が依頼の文だけのときにも成り立つことを求める。conductor の起動指示の規則は 10 項目の形で書いたときにしか supervisor へ届かず、依頼の文だけの起動では届かない。supervisor の定義は起動の形に依らず読み込まれ、hook は supervisor の Tool の呼び出しそのものを見るため、どちらも起動の形に左右されない。 + +conductor の守る規則 14 個に worker の起動指示の形を足す案は採らない。依頼の文だけの起動を救えず、規則を conductor の起動指示へ写すことにもなる。 + +根拠: Value 6 / Value 7(MVV 版 4) + +### 決定 2: 言語モデルへの指示だけに頼らないため、「読む Skill」の欄の無い起動を hook で止める + +再現の 2 回は、定義に正本の在りかが書かれていても supervisor が読まなかったことを示す。指示の文を増やすだけでは、同じ読み飛ばしが起きうる。欄の有無は機械で判定でき、判定の要らない確かめは hook が持つ。 + +誤って止める危険は 3 つの条件で抑える。対象を supervisor が起動する `ndf:worker` に限る(conductor の読解や単発の起動は止めない)。工程に入る前の作業は欄の値を「無し」と書けば通る。`NDF_WORKER_SKILLS_GUARD=0` で外せる。止めるのではなく案内(`additionalContext`)を足す形は採らない。案内は呼び出しを止めないため、欄の無い起動指示のまま worker が動く。 + +根拠: Value 4 / Value 6(MVV 版 4) + +### 決定 3: 正本を写さずに形を届けるため、止めた理由に正本の節をその場で読み出して添える + +hook は止めるときに `agent-layers.md` から「supervisor → worker」の節を読み出し、理由の本文に入れる。正本はファイルの 1 か所のままで、節を直せば次の拒否からその文が届く。supervisor の定義は在りかを指す 1 段落だけを持ち、「読め」とは書かない。読ませると supervisor は 41,294 バイトのファイル全体を読み、起動のたびに費用が増える。 + +定義へ節を写す案は、正本が 2 つになるため採らない。supervisor の起動の時点で節を注入する案(SubagentStart の hook)は採らない。worker を起動しないフェーズでも毎回費用が掛かり、このリポジトリの hook でまだ使っていない事象で、動きを確かめていない。 + +根拠: Value 7 / Value 10(MVV 版 4) + +### 決定 4: 誤って止めないため、欄の値の検査は「無し」かどうかと、対応表を引いた記録の有無までにする + +欄に Skill を挙げた起動では、呼び手の会話の記録に `stage-skills.py lookup` の実行があるかだけを見る。挙げた名前とパスが lookup の出力と一致するかは見ない。起動指示は自由な文で、欄の範囲を切り出す規則が要り、規則が外れると正しい起動を止める。一致は受け入れ条件 1 の実起動で確かめる。 + +「無し」と書いて検査を抜ける起動は止められない。これは残るリスクとして扱う(「未確認のまま残ること」)。 + +根拠: Value 1 / Value 4(MVV 版 4) + +### 決定 5: token の guard の責務を広げないため、読む Skill の guard を別のモジュールに置く + +token の guard は文脈の大きさと待ち方を見る判定の集まりで、起動指示の中身は見ていない。読む Skill の guard は起動指示の形を見るため、`hook_lib/` に新しいモジュールを置き、拒否の形と入力の取り出しは token の guard の部品をそのまま使う。 + +根拠: Value 6(MVV 版 4) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・2・3 | 実起動(前提 4 の条件、起動指示の 2 つの形で 2 回ずつ)の記録で、worker を起動する `prompt` に `document-style` の名前と lookup と同じパス、supervisor の Bash に `stage-skills.py lookup 設計`、worker の Read に `SKILL.md` がある。マージ前は実装の worktree で 1 回ずつ | 定義の段落を外し hook を止めた状態(`NDF_WORKER_SKILLS_GUARD=0`)で、欄の無い起動が再現する | +| 受け入れ条件 4 | 単体テスト: 欄の値が「無し」の起動は、会話の記録に lookup の実行が無くても通る。実起動の記録で、工程に入る前の作業の起動指示に「無し」の欄がある | 「無し」でも lookup の記録を求めるように壊すと落ちる | +| 受け入れ条件 5・I4 | `diff`(受け入れ条件 5 のコマンド)が 0 を返す | 片方の定義だけに段落を足すと 0 以外になる | +| 受け入れ条件 6・I3 | `grep -rn "document-style" plugins/ndf/agents plugins/ndf/skills/development-workflow/references/agent-layers.md` が 0 件。読む Skill の guard のコードに Skill の名前の文字列が無い | 定義・正本の節・guard のどれかに Skill の名前を書くと当たる | +| 受け入れ条件 7 | `plugins/ndf/scripts/tests` の既存テストがすべて通る | プランの worker の Skill の渡し方(`supervise_lib`)に触れると既存テストが落ちる | +| I1 | 単体テスト: supervisor から `ndf:worker` を起動し、`prompt` に欄が無い入力で拒否が返り、理由に正本の節の見出しの下の本文が入る | 欄の判定を常に「ある」と返すように壊すと落ちる。理由を固定の文にして節を読み出さないように壊すと落ちる(テストは一時ファイルの正本で節の中身を差し替えて確かめる) | +| I2 | 単体テスト: 欄に Skill を挙げ、会話の記録に lookup の実行が無いと拒否、有ると通る | 記録を見ずに通すように壊すと、無い方の入力で落ちる | +| I5 | 単体テスト: 呼び手が supervisor でない(`agent_type` が無い・別の名前)、または `subagent_type` が `ndf:worker` でない入力は、欄が無くても通る | 呼び手の判定を外すと、conductor からの起動の入力で落ちる | +| I6 | 単体テスト: 会話の記録のパスが読めない入力で、欄に Skill を挙げた起動が通る。環境変数で外したときも通る。正本の節が見つからないときは止め、理由に正本のパスが入る | 記録が読めないときに止めるように壊すと落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2399 | 実装する | — | `plugins/ndf/agents/supervisor.md`、`plugins/ndf/agents/supervisor-waits.md`、`plugins/ndf/scripts/hook.py`、`plugins/ndf/scripts/hook_lib/worker_skills.py`、`plugins/ndf/scripts/tests/test_worker_skills_guard.py`、`plugins/ndf/skills/development-workflow/references/agent-layers.md`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 費用の実測 | 止めずに通った起動と、1 回止められた起動の費用の増分は未実測。マージ前の手動確認で、supervisor の起動 1 回の費用を変更の前後で比べる。上限 $0.10 を超えたら承認ゲート 2 の資料に載せる | +| 「無し」による抜け | 設計の工程でも欄に「無し」と書けば読む Skill の guard を通る。実起動(受け入れ条件 1)で起きないことを確かめる。起きたら、直前の lookup の出力が空でないのに「無し」と書いた起動を止める判定を別の課題で検討する | +| 「入力」「手順」の欄の欠け | 定義の段落は 6 項目すべてを指し、止めた理由の節も 6 項目を含む。ただし読む Skill の guard が見るのは「読む Skill」だけで、欄があれば「入力」「手順」が欠けても通る。マージ前の手動確認の記録で 2 つの欄の有無を見て、欠けていたら別の課題へ起こす | +| hook の入力の `agent_type` | supervisor の中の Tool の呼び出しで `agent_type` が `ndf:supervisor` になることは、既存の判定(収束ループの前で寿命 5 分の supervisor を止める判定)が使っている。`ndf:supervisor-waits` の値は、マージ前の手動確認で記録を見て確かめる | From b38ac551b27d2ef5cf1684378e68225e66dd7b04 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 06:58:50 +0000 Subject: [PATCH 004/127] =?UTF-8?q?Docs:=20#2401=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8F=EF=BC=88=E3=83=AA=E3=83=AA?= =?UTF-8?q?=E3=83=BC=E3=82=B9=E5=BE=8C=E3=83=86=E3=82=B9=E3=83=88=E3=81=A7?= =?UTF-8?q?=E5=88=A9=E7=94=A8=E8=80=85=E3=81=AE=E8=A8=AD=E5=AE=9A=E3=83=87?= =?UTF-8?q?=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA=E3=82=92=E5=A4=89?= =?UTF-8?q?=E3=81=88=E3=81=9A=E3=80=81=E5=A4=89=E3=82=8F=E3=81=A3=E3=81=9F?= =?UTF-8?q?=E3=82=89=E6=AD=A2=E3=81=BE=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - まとめのプランのリリース後テストのステップを、控えを取る run のステップと比べる run のステップで挟む - worker が失敗したときも比べ、必ず止まる - verify-install の利用者の環境の比較に、Claude Code と Codex の導入済みのプラグインと取得元の登録を加える - 手順書の節を禁止の正本とし、起動指示は要点と節の名前を 1 つの定数で持つ - 用語集に「利用者の設定の控え」を足す Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 1 + docs/glossary/glossary.json | 6 + issues/issue-2401-design.md | 341 ++++++++++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 issues/issue-2401-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 2307722fb..28a34160b 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -640,6 +640,7 @@ release が走らせるリリースの種別・リリースコマンド・公開 | 配布先のランタイム | — | マーケットプレイスのプラグインを導入できる形で届けるランタイム。#2231 の後は Claude Code と codex で、相手役のランタイムとは別に決まる(#2231) | — | — | `docs/specifications/ndf-distribution-runtimes.md` | | 利用者の設定ディレクトリ | — | 利用者の Claude Code の設定ディレクトリ(`CLAUDE_CONFIG_DIR`、無ければ `~/.claude`)と Codex の設定ディレクトリ(`CODEX_HOME`、無ければ `~/.codex`)。リリース後テストはこの中の導入済みのプラグインと取得元の登録を変えない | — | — | — | | 導入の操作 | — | プラグインの導入・更新と、取得元の登録・更新(`claude plugin install` / `update` / `marketplace add` / `marketplace update`、`codex plugin add` / `marketplace add`)。リリース後テストの worker は利用者の設定ディレクトリでこれを打たない | — | — | — | +| 利用者の設定の控え | — | 利用者の設定ディレクトリの、導入済みのプラグインと取得元の登録を鍵と値の組で書き出したもの。リリース後テストの前に取り、後で今の状態と比べる | — | — | — | ## NDF の指示書チェック(`ndf-instructions`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index b62b7dda3..54130ea35 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5253,6 +5253,12 @@ "context": "ndf-release", "meaning": "プラグインの導入・更新と、取得元の登録・更新(`claude plugin install` / `update` / `marketplace add` / `marketplace update`、`codex plugin add` / `marketplace add`)。リリース後テストの worker は利用者の設定ディレクトリでこれを打たない", "pending_source": "issues/issue-2401-requirements.md" + }, + { + "term": "利用者の設定の控え", + "context": "ndf-release", + "meaning": "利用者の設定ディレクトリの、導入済みのプラグインと取得元の登録を鍵と値の組で書き出したもの。リリース後テストの前に取り、後で今の状態と比べる", + "pending_source": "issues/issue-2401-design.md" } ] } diff --git a/issues/issue-2401-design.md b/issues/issue-2401-design.md new file mode 100644 index 000000000..29f94ef60 --- /dev/null +++ b/issues/issue-2401-design.md @@ -0,0 +1,341 @@ +# リリース後テスト: 利用者の設定ディレクトリのプラグインを変えずに確かめ、変わったら止まる(#2401) + +## 目的 + +- **今どうなっているか**: リリース後テストの worker が導入の確認のスクリプトとは別に手で導入を試し、利用者の Claude Code に入っていた ndf が 10.27.7 から 10.27.10 へ黙って上がった(2026-11-02 15:30 JST)。手順書にも起動指示にも禁止が無く、変わったことを機械で知る仕組みも無い +- **誰に効くか**: NDF のスプリントを回す利用者(同じマシンで別の開発ループを動かしている利用者を含む) +- **変えると何が成り立つか**: worker は利用者の設定ディレクトリで導入の操作を打たない。打たれて変わったら、まとめのプランが振り返りへ進まずに止まり、変わったプラグインと前後の版を人へ示す + +## 適用範囲 + +- **働く範囲**: NDF を使うどのリポジトリでも働く。リリース後テストの手順書(`release-verification` の Skill)、まとめのプランのリリース後テストのステップ、導入の確認のスクリプト(`release-verification-steps.py`)を変える +- **プロジェクトごとに違うもの**: 無し。利用者の設定ディレクトリの場所は環境変数(`CLAUDE_CONFIG_DIR`・`CODEX_HOME`)と既定の場所(`~/.claude`・`~/.codex`)から決め、設定も引数も足さない +- **当たるモード**: リリース後テストを通るすべてのモード(まとめのプランの前後の検査は、まとめのプランがリリース後テストのステップを組むときだけ) + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| https://github.com/devbasex/ai-plugins/pull/2395#issuecomment-6570809748 の「補足」 | 実測 | 手で導入を試したことで利用者の設定ディレクトリの ndf の版が変わった。導入の確認のスクリプト(`env -i` で隔離して動く)だけなら変わらなかった | +| `$CLAUDE_CONFIG_DIR/plugins/installed_plugins.json` と `known_marketplaces.json` の中身(2026-11-02 に読んだ) | 実測 | Claude Code はプラグインごとに範囲・版・コミットを、取得元ごとに取得元の場所を、この 2 つのファイルに持つ。時刻(`lastUpdated`)は自動の更新で変わる | +| 一時ディレクトリを `CODEX_HOME` にして `codex plugin marketplace add devbasex/ai-plugins --ref develop` と `codex plugin add ndf@ai-plugins` を打った結果(codex-cli 0.166.0、2026-11-02) | 実測 | Codex は取得元を `config.toml` の `[marketplaces.<名前>]` に、導入を `[plugins."<プラグイン>@<取得元>"]` に書き、版は `plugins/cache/<取得元>/<プラグイン>/<版>/` のディレクトリで持つ | + +要求と受け入れ条件は #2401 の本文にある(コピーは [issues/issue-2401-requirements.md](issue-2401-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| リリース(`ndf-release`) | 利用者の設定ディレクトリ・導入の操作・利用者の設定の控え・リリース後テスト | +| 開発の工程(`ndf-workflow`) | まとめのプラン・ステップ・止まる(人へ戻す) | + +関係は公開された言語。まとめのプランは導入の確認のスクリプトを run のステップとして打ち、結果 JSON(`plugins/ndf/scripts/lib/README.md` の「手順のスクリプトの結果」)の終了コードと `summary` だけを読む。控えの中身は読まない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 利用者の設定ディレクトリ | 利用者だけ。NDF は読むだけで書かない | 設定ディレクトリ(Claude Code・Codex の 2 つ) | 導入済みのプラグイン(名前 `<プラグイン>@<取得元>` で追う)・取得元の登録(取得元の名前で追う) | 版・コミット・範囲・取得元の場所 | +| 利用者の設定の控え | `release-verification-steps.py`(`user-config-save` が書き、`user-config-check` が読む) | 控え(1 回のまとめのプランの実行に 1 つ) | — | 控えの項目(鍵と値の組。鍵の形は「入出力の契約」) | +| まとめのプラン | `supervise_lib`(`close_plan` が組む) | まとめのプラン | ステップ | 遷移(`next`・`on_fail`・`on_exit`) | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 利用者の設定ディレクトリ | リリース後テストのステップの前後で、導入済みのプラグインの版・コミット・範囲と、取得元の登録の場所が変わらない | まとめのプランが振り返りへ進まずに止まり、変わった項目と前後の値を理由に載せる | +| I2 | 利用者の設定の控え | 控えと結果 JSON は、プラグインの名前・版・コミット・範囲と取得元の名前・場所だけを持つ。認証情報のファイル(Codex の `auth.json` など)を読まず、取得元の URL の利用者の情報(`user:pass@`)は伏せる | 実装のテストで落とす | +| I3 | 利用者の設定の控え | 控えが取れない・控えが読めない・設定のファイルが読めないとき、`user-config-save` と `user-config-check` は `ok` を返さない | 終了コード 2 で止まり、まとめのプランが止まる | +| I4 | まとめのプラン | リリース後テストのステップを持つまとめのプランは、その直前に控えを取るステップを、直後に比べるステップを持つ。リリース後テストのステップが失敗しても比べる | まとめのプランを組むテストで落とす | +| I5 | まとめのプラン | 比べるステップが `ok` でなければ、振り返りのステップへ進まない | まとめのプランを組むテストで落とす | +| I6 | 利用者の設定の控え | 導入の確認(`verify-install`)の間に利用者の設定ディレクトリのプラグインか取得元の登録が変わると、`metrics.user_env_unchanged` は false、`status` は `ok` でない | 終了コード 1。今の判定(利用者の rc ファイル)と同じ扱い | +| I7 | 利用者の設定の控え | 利用者の設定ディレクトリが変わらなければ、`verify-install` の結果の形・終了コード・`status` は今と同じ | 既存のテストで落とす | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | リリース後テストのステップが利用者の設定ディレクトリの状態を控えた | 控えを取るステップ(`user-config-save`) | 比べるステップ(控えのファイルを読む) | +| E2 | worker が `verify-install` で導入を確かめた | リリース後テストの worker | worker の記録の表(導入の確認の行) | +| E3 | `verify-install` が利用者の設定ディレクトリの前後を比べた | `verify-install` | worker(`status` と `items` の `kind: user_env`) | +| E4 | worker が受け入れ条件ごとに確かめた | リリース後テストの worker | worker の記録の表 | +| E5 | worker が利用者の設定ディレクトリへの導入が要る条件を保留にした | リリース後テストの worker | 記録の表の保留の行と、作業の報告の「判断が要る」(まとめのプランが失敗として受け、比べるステップへ回す) | +| E6 | worker がリリース後テストの記録を配布の記録の PR へ書いた | リリース後テストの worker | まとめのプランの close のステップ(今と同じ) | +| E7 | ステップが利用者の設定ディレクトリの今の状態を控えと比べた | 比べるステップ(`user-config-check`) | まとめのプランの遷移(`ok` なら振り返りへ、ほかは止まる) | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 利用者の設定ディレクトリ | 利用者の Claude Code の設定ディレクトリ(`CLAUDE_CONFIG_DIR`、無ければ `~/.claude`)と Codex の設定ディレクトリ(`CODEX_HOME`、無ければ `~/.codex`) | 追加(`ndf-release`。要求の工程で足した行のまま) | +| 導入の操作 | プラグインの導入・更新と、取得元の登録・更新 | 追加(`ndf-release`。要求の工程で足した行のまま) | +| 利用者の設定の控え | 利用者の設定ディレクトリの、導入済みのプラグインと取得元の登録を鍵と値の組で書き出したもの。リリース後テストの前に取り、後で今の状態と比べる | 追加(`ndf-release`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 手順書と起動指示が、導入の確かめを `verify-install` に限り、利用者の設定ディレクトリでの導入の操作を禁じる | リリース後テストの worker | +| F2 | 利用者の設定ディレクトリへの導入が要る受け入れ条件を、導入せずに保留にして人へ戻す | リリース後テストの worker | +| F3 | リリース後テストのステップの前に利用者の設定の控えを取り、後で比べ、変わっていたら止まる | まとめのプラン(結果を読むのは利用者) | +| F4 | `verify-install` が、導入の前後で利用者の設定ディレクトリのプラグインと取得元の登録も比べる | リリース後テストの worker と、リリースのプランの導入の確かめのステップ | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| `release-verification` の Skill(`SKILL.md`) | 手順 1 に節「利用者の設定ディレクトリで導入しない」を置き、禁止・保留の書き方・「判断が要る」での返し方の正本になる。プランの外で使うときに控えを取って比べる手順も持つ | +| `release-verification-steps.py` の利用者の設定の読み取り(`user_config_snapshot`) | 利用者の設定ディレクトリを読むだけで、控えの項目(鍵と値の組)と読めなかったファイルの一覧を返す | +| `release-verification-steps.py` の `user_env_snapshot` | 今の rc ファイルとリンクの項目に、`user_config_snapshot` の項目を足して返す | +| `release-verification-steps.py` の `_env_items` | 前後の鍵の和集合で差分を作る(片方にしか無い鍵も `changed` にする) | +| `release-verification-steps.py` の副命令 `user-config-save` | 控えを `--out` のファイルへ書き、結果 JSON を出す | +| `release-verification-steps.py` の副命令 `user-config-check` | `--before` の控えと今の状態を比べ、結果 JSON を出す | +| `supervise_lib/release_templates.py` の `release_verify_step` | 起動指示に、禁止・保留の要点と Skill の節への参照(定数 `NO_USER_INSTALL`)を入れる | +| `supervise_lib/release_templates.py` の `user_config_steps` | 控えを取るステップ(`config-save`)・比べるステップ(`config-check`)・失敗のときに比べるステップ(`config-check-on-fail`)を組む | +| `supervise_lib/sprint.py` の `close_plan` | リリース後テストのステップを組むとき、マージの後を `config-save` へ、リリース後テストの後を `config-check` へつなぐ | + +```mermaid +graph TB + subgraph plan["まとめのプラン(supervise_lib)"] + CP["close_plan"] + UCS["user_config_steps"] + RVS["release_verify_step"] + end + subgraph script["release-verification-steps.py"] + SAVE["user-config-save"] + CHECK["user-config-check"] + VI["verify-install"] + SNAP["user_config_snapshot"] + end + SKILL["release-verification の Skill"] + DIR[("利用者の設定ディレクトリ")] + CP --> UCS + CP --> RVS + UCS -->|打つ| SAVE + UCS -->|打つ| CHECK + RVS -->|起動指示が参照| SKILL + SKILL -->|手順 1 で打つ| VI + SAVE --> SNAP + CHECK --> SNAP + VI --> SNAP + SNAP -->|読むだけ| DIR +``` + +## 構造 + +型は変えない(`VerifyContext` はそのまま)。変える関数と、それを持つモジュールだけを描く。 + +```mermaid +classDiagram + class release_verification_steps { + <> + +user_config_snapshot() Snapshot + +user_env_snapshot() dict + -_env_items(before, after) list + +cmd_user_config_save(a) + +cmd_user_config_check(a) + +cmd_verify_install(a) + } + class Snapshot { + <> + items: dict + unreadable: list + } + class release_templates { + <> + +NO_USER_INSTALL: str + +release_verify_step(sprint, refs, record_pr) dict + +user_config_steps() list + } + class sprint { + <> + +close_plan(a, repo) dict + } + release_verification_steps ..> Snapshot + sprint ..> release_templates +``` + +`Snapshot` は `user_config_snapshot` の戻り値の組(`NamedTuple`)である。`user_env_snapshot` は引数を持たず `dict` を返す今の形を保つ(既存のテストがこの形を差し替えて使う。I7)。 + +## 入出力の契約 + +### 控えの項目の鍵と値 + +| 鍵 | 値 | 読む元 | +| --- | --- | --- | +| `claude:plugin:<プラグイン>@<取得元>` | 導入の行ごとの `<範囲>:<版>@<コミットの先頭 8 文字>` を並べて空白でつないだもの。範囲が `user` 以外なら範囲の後に `()` を付ける。コミットが無ければ `@…` を省く | Claude Code の設定ディレクトリの `plugins/installed_plugins.json` の `plugins` | +| `claude:marketplace:<取得元>` | `source` の値を鍵の順に並べた JSON(URL の利用者の情報を伏せる) | 同じく `plugins/known_marketplaces.json` | +| `codex:plugin:<プラグイン>@<取得元>` | `enabled=` と、版のディレクトリの名前を並べた `versions=<版>,<版>` | Codex の設定ディレクトリの `config.toml` の `[plugins."…"]` と、`plugins/cache/<取得元>/<プラグイン>/` のディレクトリ。片方にしか無いプラグインも鍵にする | +| `codex:marketplace:<取得元>` | `source_type`・`source`・`ref` を鍵の順に並べた JSON(URL の利用者の情報を伏せる) | 同じく `config.toml` の `[marketplaces.<取得元>]` | + +- 時刻(`installedAt`・`lastUpdated`)・置き場所(`installPath`・`installLocation`)・自動の更新の設定(`autoUpdate`)は値に入れない +- 設定ディレクトリかファイルが無いときは、その鍵を作らない(導入していない状態であり、読めないこととは分ける) +- ファイルはあるが読めない・形が壊れているときは、`unreadable` に `<ファイルのパス>: <理由>` を 1 件足す。Python 3.10(`tomllib` が無い)で Codex の `config.toml` があるときも読めないとする +- `config.toml` は `marketplaces` と `plugins` の表だけを値に使い、ほかの表を控えへ写さない + +### 副命令 + +```text +release-verification-steps.py user-config-save --out <控えのファイル> +release-verification-steps.py user-config-check --before <控えのファイル> +``` + +| 副命令 | 結果 | 終了コード | `items` | `metrics` | +| --- | --- | --- | --- | --- | +| `user-config-save` | 控えを書けた | 0(`ok`) | 無し | `keys`(項目の数)・`claude_config_dir`・`codex_home`・`out` | +| `user-config-save` | 設定のファイルが読めない・控えを書けない | 2(`stopped`) | `kind: unreadable` の項目(`name` はファイルのパス、`reason`) | 同上 | +| `user-config-check` | 変わっていない | 0(`ok`) | 無し | `unchanged: true`・`changed: 0`・`keys`・両方のディレクトリ | +| `user-config-check` | 変わった | 1(`stopped`) | 変わった鍵ごとに `kind: user_env`・`name`(鍵)・`result: changed`・`before`・`after`(無い側は `null`) | `unchanged: false`・`changed`(件数) | +| `user-config-check` | 控えが無い・読めない、または今の設定のファイルが読めない | 2(`stopped`) | `kind: unreadable` の項目 | `unchanged` を置かない | + +`summary` は、変わったときに `利用者の設定ディレクトリが変わった(<件数> 件: <鍵>: <前> → <後>, …)` の形で、先頭の 3 件までを載せる。プランが止まった理由にこの `summary` が写る(I1)。`tool` は `release-verification` のまま。 + +### `verify-install` の結果 + +`items` に `kind: user_env` の項目として、上の鍵が加わる(今の `.bashrc`・`.zshrc`・`.local/bin/claude` と並ぶ)。`metrics.user_env_unchanged` はこれらも含めた前後の一致になる。利用者の設定ディレクトリが変わらなければ、結果の形は今と同じ(I7)。読めないファイルは鍵 `:unreadable` の値として前後で比べ、`verify-install` 自身は止めない(決定 6)。 + +### まとめのプランのステップ + +| id | type | コマンド・指示 | 遷移 | +| --- | --- | --- | --- | +| `config-save` | run | `{VERIFY_PY} user-config-save --out {state_dir}/user-config-before.json` | 0 → `release-verify`。ほか → 止まる(`on_fail` を持たない) | +| `release-verify` | work | 今の起動指示に `NO_USER_INSTALL` を足したもの | 完了 → `config-check`。失敗(「判断が要る」「できなかった」を含む)→ `on_fail: config-check-on-fail` | +| `config-check` | run | `{VERIFY_PY} user-config-check --before {state_dir}/user-config-before.json` | 0 → `retro`。ほか → 止まる | +| `config-check-on-fail` | run | `config-check` と同じ | `on_exit: {"0": "stop"}`(変わっていなくても止まる)。ほか → 止まる | + +3 つの run のステップは `stage` を持たない(「リリース後テスト」と記録するのは今どおり `release-verify` だけ)。 + +### 起動指示に足す文(`NO_USER_INSTALL`) + +> 導入の確かめは `verify-install` だけで行い、利用者の設定ディレクトリ(`CLAUDE_CONFIG_DIR` か `~/.claude`、`CODEX_HOME` か `~/.codex`)で導入の操作(`claude plugin install` / `update` / `marketplace add` / `marketplace update`、`codex plugin add` / `marketplace add`)を打たない。隔離のつもりで環境変数を付けて手で打つことも含む。確かめるのに利用者の設定ディレクトリへの導入が要る条件は、導入せずに記録の表のその行を「保留(理由: 利用者の設定ディレクトリへの導入が要る)」とし、作業の報告を「判断が要る」で返す(`/ndf:release-verification` の「利用者の設定ディレクトリで導入しない」)。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant P as まとめのプラン + participant S as 控えの副命令 + participant W as worker + participant V as verify-install + P->>S: user-config-save で控えを取る(E1) + S-->>P: 0 / 2(2 なら止まる) + P->>W: 起動指示(禁止と Skill の節の参照) + W->>V: 隔離した環境で導入(E2) + V-->>W: 前後の比較を含む結果(E3) + W->>W: 受け入れ条件ごとに確かめる(E4) + alt 利用者の設定ディレクトリへの導入が要る + W->>W: 保留の行を書き、判断が要るで返す(E5) + end + W-->>P: 記録を PR へ書いて報告(E6) + alt worker が完了 + P->>S: config-check で比べる(E7) + S-->>P: 0 なら振り返りへ、ほかは止まる + else worker が失敗 + P->>S: config-check-on-fail で比べる(E7) + S-->>P: どの終了コードでも止まる + end +``` + +この図に含めない構成要素: `user_config_snapshot`・`user_env_snapshot`・`_env_items`(控えの副命令と `verify-install` の中で呼ばれる)、`release_verify_step`・`user_config_steps`・`close_plan`(まとめのプランを組む側で、実行の前に働く)。`release-verification` の Skill は worker が読む手順として「worker」に含める。 + +## 非機能の実現方式 + +| 大項目 | 実現方式 | +| --- | --- | +| セキュリティ | `user_config_snapshot` が開くのは Claude Code の 2 つの JSON と Codex の `config.toml`、`plugins/cache/` のディレクトリの名前だけ。`config.toml` は 2 つの表だけを値に使う。取得元の URL は `scheme://user:pass@host` の利用者の情報を `***` に置き換えてから控えと結果へ入れる(I2)。控えのファイルはまとめのプランの状態ディレクトリに置き、リポジトリへコミットしない | +| 運用・保守性 | 変わったときの `items` に、鍵(プラグインか取得元の名前)と前後の値(範囲・版・コミット)が載り、`summary` がプランの止まった理由になる。人は前の版と取得元を読んで入れ直すかを決められる | +| システム環境 | 設定ディレクトリは `CLAUDE_CONFIG_DIR` → `~/.claude`、`CODEX_HOME` → `~/.codex` の順で決める。ai-plugins の開発環境の場所(`/home/ubuntu/.claude/ndf/accounts/team` など)を既定に書かない。Python 3.10 では `config.toml` を読めないと報告して止まる(黙って比べずに通さない) | + +## 決定の記録 + +### 決定 1: 比べる処理をスクリプトに任せるため、まとめのプランのリリース後テストのステップを前後の run のステップで挟む + +控えと比較は判断の要らない手順であり、worker に任せると手順書を読み落としたときに効かない。まとめのプランの run のステップなら、worker が何をしても前後で必ず打たれる。挟むステップは今のステップの種類(run・`on_fail`・`on_exit`)だけで組め、実行エンジンを変えない。 + +実行エンジンに「work のステップの前後で打つコマンド」の欄を足す形は採らない。仕組みを使うのはこのステップだけで、エンジンの変更がまとめのプランの組み方の変更より広くなる。worker に控えと比較を打たせる形も採らない。手順書の禁止と同じく、読み落とすと効かない。 + +根拠: Value 4 / Value 6(MVV 版 4) + +### 決定 2: worker が止まったときも比べるため、リリース後テストのステップの失敗を、必ず止まる比べるステップへ回す + +worker が手で導入したうえで「判断が要る」か「できなかった」で返すと、今は比べずにプランが止まり、変わったことが報告に出ない。失敗の行き先 `config-check-on-fail` は `on_exit: {"0": "stop"}` を持ち、変わっていなくても止まる(リリース後テストの失敗はそのまま人へ戻る)。変わっていれば終了コード 1 で、変わった項目が理由に載る。 + +失敗の行き先を `config-check` にする形は採らない。変わっていなければ振り返りへ進み、リリース後テストの失敗が人へ戻らなくなる。 + +根拠: 上位の原則(人を守る)/ Value 2(MVV 版 4) + +### 決定 3: 自動の更新による誤検出を人へ戻す側に倒すため、版・コミット・範囲・取得元の場所が変われば止める + +利用者の設定ディレクトリは、同じマシンの別のセッションや Claude Code の自動の更新(`known_marketplaces.json` の `autoUpdate: true`)でも書き換わる。worker の操作か自動の更新かを区別する手段は見つかっていない(記録の行に書き手は残らない)。区別できない変化は、利用者の導入済みの版が変わったという事実として人へ戻す。時刻・置き場所・`autoUpdate` は版が変わらなくても動くため値に入れず、版の変わらない取得元の更新では止まらない。 + +誤検出を減らすために、変化を無視する時間帯や許す版の一覧を置く形は採らない。区別の根拠が無いまま許すと、手で導入した場合も通る。 + +根拠: 上位の原則(人を守る)/ Value 3(MVV 版 4) + +### 決定 4: 1 か所で直せるようにするため、利用者の設定の読み取りを 1 つの関数にし、`verify-install` と 2 つの副命令で使う + +`verify-install` の前後の比較(受け入れ条件 5)と、まとめのプランの前後の比較(受け入れ条件 4)は、同じファイルを同じ鍵で読む。`user_config_snapshot` を 1 つ置き、`user_env_snapshot` はそれを足すだけにする。Claude Code か Codex の記録の形が変わったときの直し先が 1 か所になる。 + +副命令を別のスクリプトに分ける形は採らない。読み取りの関数を 2 つのスクリプトで共有するために `lib/` へ移す必要が生まれ、今の 1 本のスクリプトで足りる範囲を超える。 + +根拠: Value 6(MVV 版 4) + +### 決定 5: 新しく入ったプラグインを見落とさないため、前後の差分を鍵の和集合で取る + +今の `_env_items` は前の鍵だけを回るため、導入の後にだけ現れた鍵(新しく入ったプラグイン・新しく登録した取得元)を差分に出さない。今の 3 つの鍵は常に前後の両方にあったため表に出なかった。和集合で回り、無い側は `null` にする。今の鍵だけなら結果は変わらない(I7)。 + +根拠: 上位の原則(人を守る)/ Value 6(MVV 版 4) + +### 決定 6: 導入の確かめの判定を今と同じに保つため、`verify-install` は読めない設定のファイルで止まらず、控えの 2 つの副命令は止まる + +`verify-install` の目的は導入した版と中身を確かめることで、利用者の設定のファイルが読めないことはその合否と別である。読めないことは値として前後で比べ、途中で読めるようになったか壊れたかだけを差分に出す。控えの 2 つの副命令は比べることだけが目的なので、読めなければ「変わっていない」と言えず、終了コード 2 で止まる(受け入れ条件 4 の「控えが取れなかったときも人へ戻す」)。 + +根拠: Value 3 / Value 6(MVV 版 4) + +### 決定 7: 禁止の正本を 1 つにするため、Skill の節を正本とし、起動指示は要点と節の名前を 1 つの定数で持つ + +worker は Skill の本文を読んで動くが、起動指示は Skill の本文より先に読まれる。起動指示に要点(禁止・保留の書き方・「判断が要る」)を置き、詳しい理由と手順は Skill の節を指す。起動指示の文は `release_templates.py` の定数 `NO_USER_INSTALL` 1 つに置き、ほかのプランの組み立てへ写さない。 + +根拠: Value 7 / Value 4(MVV 版 4) + +### 決定 8: プランの外で Skill を使うときも比べられるため、手順書に控えと比較の 2 行を置く + +`/ndf:release-verification` を利用者が直接呼ぶときは、まとめのプランの前後のステップが無い。手順書の手順 1 の前に `user-config-save`、記録の後に `user-config-check` を打つ手順を置き、まとめのプランの中では前後のステップが打つため打たないと書く。 + +根拠: Value 5(MVV 版 4) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1・3 | 手順書の効き目は、次のスプリントの本番のリリース後テストで、利用者の設定ディレクトリが変わらず `config-check` が `ok` になることを人が見る(`.md` の文言のテストは書かない) | — (リリース後テストの手動確認) | +| 受け入れ条件 2 | まとめのプランを組むと、`release-verify` の起動指示が `NO_USER_INSTALL` を含む | 起動指示から定数を外すと落ちる | +| 受け入れ条件 4・I4・I5 | 配布の記録のあるまとめのプランの `next` のつながりが `merge → config-save → release-verify → config-check → retro → close → refine` で、`release-verify` の `on_fail` が `config-check-on-fail`、その `on_exit` が `{"0": "stop"}`。配布の記録の無いまとめのプランは 3 つのステップを持たない | 挟むステップを外す・`on_fail` を外す・`config-check` に `on_fail` を付けて先へ進めると落ちる | +| 受け入れ条件 4・I1 | 一時ディレクトリを `CLAUDE_CONFIG_DIR`・`CODEX_HOME` にして `user-config-save` → 設定のファイルの版を書き換え → `user-config-check` が終了コード 1、変わった鍵と前後の値を `items` に、件数を `summary` に載せる。書き換えなければ 0 | 比較を常に一致と返す・新しく足したプラグインの鍵を見落とすと落ちる | +| 受け入れ条件 4・I3 | 控えのファイルが無い・壊れている、設定のファイルが壊れているとき、`user-config-save` と `user-config-check` は終了コード 2 | 読めないことを 0 件と読んで 0 を返すと落ちる | +| 決定 3 | `lastUpdated`・`installedAt`・`installLocation`・`autoUpdate` だけを書き換えると `user-config-check` は 0 | 時刻を値に入れると落ちる | +| 受け入れ条件 5・I6 | `verify-install` の導入の途中で一時ディレクトリの設定ディレクトリの版を書き換えると、`user_env_unchanged` が false、`status` が `stopped`、`items` に `kind: user_env` の鍵が載る | `user_env_snapshot` が設定ディレクトリを読まない・差分を前の鍵だけで取ると落ちる | +| 受け入れ条件 6・I7 | `test_step_scripts.py` の `verify-install` の既存のテストが書き換えずに通る | `user_env_snapshot` の引数か戻り値の形を変えると落ちる | +| I2 | 取得元の URL に `user:pass@` を含む設定と、Codex の `auth.json` と `config.toml` のほかの表に置いた目印の文字列が、控えのファイルにも結果 JSON にも出ない | 伏せずに写す・ほかの表を写すと落ちる | +| 受け入れ条件 7 | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`・`python3 scripts/check-skill-frontmatter.py`・`claude plugin validate .` が通る | — | + +既存の `test_supervise_pace_verify.py` の `test_close_comes_after_the_retro_and_before_the_refine` は、つながりの期待値に 2 つのステップが入る(つながりを変えることが受け入れ条件 4 の振る舞いである)。 + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2401 | 実装する | — | `plugins/ndf/scripts/release-verification-steps.py`、`plugins/ndf/scripts/supervise_lib/release_templates.py`、`plugins/ndf/scripts/supervise_lib/sprint.py`、`plugins/ndf/skills/release-verification/SKILL.md`、`plugins/ndf/scripts/tests/test_step_scripts.py`、`plugins/ndf/scripts/tests/test_supervise_pace_verify.py` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 2026-11-02 に環境変数が効かなかった理由 | 未確認(要求の前提 2)。この設計は理由に依らず、禁止と前後の比較で守る | +| 自動の更新による誤検出の頻度 | 未確認。リリース後テストの worker は利用者の設定ディレクトリで `claude -p` を起動するため、起動時の自動の更新で版が変わると `config-check` が止まる。次のスプリントから、止まった回数と理由をまとめのプランの結果で数える | +| Codex が更新のときに古い版のディレクトリを消すか | 未確認。消しても残しても版のディレクトリの並びが変わるため、どちらでも変化として出る | +| Claude Code・Codex の記録の形の安定性 | Claude Code の `installed_plugins.json` は `version: 2` の形、Codex は codex-cli 0.166.0 の形で確かめた。形が変わると読めないとして止まる(I3)。黙って通らない | +| Claude Code の `settings.json` の有効化の設定(`enabledPlugins`) | 比べない。要求の守る対象(導入済みのプラグインの記録と取得元の登録)の外 | +| Python 3.10 の利用者の環境 | Codex の `config.toml` があると、控えの副命令が毎回止まる。NDF の動作の下限は 3.10 だが、3.10 で Codex を使う利用者がいるかは未確認 | From 13883446dbf9aa4ab76cb14adb09761fd26b96b2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 06:58:48 +0000 Subject: [PATCH 005/127] =?UTF-8?q?Docs:=20#2399=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=81=AE=E9=95=B7=E3=81=84=E6=96=87?= =?UTF-8?q?=E3=82=92=E5=88=86=E3=81=91=E3=82=8B=EF=BC=88document-rebuild?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 章立ては設計テンプレートの順のまま保ち、内容は足していない。 最長文 135 字 → 98 字、平均文長 38.3 字 → 36.8 字。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2399-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/issues/issue-2399-design.md b/issues/issue-2399-design.md index e4c7bb2d1..9de6e4659 100644 --- a/issues/issue-2399-design.md +++ b/issues/issue-2399-design.md @@ -2,9 +2,9 @@ ## 目的 -- **今どうなっているか**: Agent の `ndf:supervisor` が `ndf:worker` を起動するとき、起動指示に「読む Skill」の欄が無い。v10.27.10 で 2 回起動し、2 回とも supervisor は正本の節(`agent-layers.md` の「supervisor → worker」)を読まず、Skill の対応表も引かなかった。設計の工程の worker に `document-style` が届いていない +- **今どうなっているか**: Agent の `ndf:supervisor` が `ndf:worker` を起動するとき、起動指示に「読む Skill」の欄が無い。v10.27.10 で 2 回起動した。2 回とも supervisor は正本の節(`agent-layers.md` の「supervisor → worker」)を読まず、Skill の対応表も引かなかった。設計の工程の worker に `document-style` が届いていない - **誰に効くか**: プランのテンプレートが無いフェーズを Agent の 3 層で回す利用者(設計・確定仕様化・振り返りなど、文書を書く工程の worker の成果物を受け取る人) -- **変えると何が成り立つか**: conductor が起動指示をどの形で書いても、supervisor が起動する `ndf:worker` の起動指示に「読む Skill」の欄が入り、対応表が挙げる Skill を worker が作業の前に読む +- **変えると何が成り立つか**: conductor が起動指示をどの形で書いても、supervisor が起動する `ndf:worker` の起動指示に「読む Skill」の欄が入る。worker は対応表が挙げる Skill を作業の前に読む ## 適用範囲 @@ -105,7 +105,7 @@ graph LR CC --> NDF[NDF の配布物
Agent の定義・hook・正本の節・対応表] ``` -外部の系は Claude Code の実行環境だけである。PreToolUse の hook の入力(呼び手の `agent_id`・`agent_type`・`transcript_path`・`tool_input`)と、拒否の返し方(`permissionDecision: deny`)は Claude Code が決め、この変更は使う側に留まる。 +外部の系は Claude Code の実行環境だけである。PreToolUse の hook の入力(呼び手の `agent_id`・`agent_type`・`transcript_path`・`tool_input`)は Claude Code が決める。拒否の返し方(`permissionDecision: deny`)も Claude Code が決める。この変更はどちらも使う側に留まる。 ### 構成要素の関係 @@ -279,7 +279,7 @@ hook は止めるときに `agent-layers.md` から「supervisor → worker」 ### 決定 5: token の guard の責務を広げないため、読む Skill の guard を別のモジュールに置く -token の guard は文脈の大きさと待ち方を見る判定の集まりで、起動指示の中身は見ていない。読む Skill の guard は起動指示の形を見るため、`hook_lib/` に新しいモジュールを置き、拒否の形と入力の取り出しは token の guard の部品をそのまま使う。 +token の guard は文脈の大きさと待ち方を見る判定の集まりで、起動指示の中身は見ていない。読む Skill の guard は起動指示の形を見るため、`hook_lib/` に新しいモジュールを置く。拒否の形と入力の取り出しは、token の guard の部品をそのまま使う。 根拠: Value 6(MVV 版 4) From de63375c7c4f465b68d6fd8143b09e51503355a8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:01:56 +0000 Subject: [PATCH 006/127] =?UTF-8?q?Docs:=20#2401=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E9=95=B7=E3=81=84=E6=96=87=E3=82=92=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=80=81=E5=9B=B3=E3=81=AB=E5=90=AB=E3=82=81=E3=81=AA?= =?UTF-8?q?=E3=81=84=E6=A7=8B=E6=88=90=E8=A6=81=E7=B4=A0=E3=82=92=E8=A1=A8?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2401-design.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/issues/issue-2401-design.md b/issues/issue-2401-design.md index 29f94ef60..f18bc6693 100644 --- a/issues/issue-2401-design.md +++ b/issues/issue-2401-design.md @@ -2,13 +2,13 @@ ## 目的 -- **今どうなっているか**: リリース後テストの worker が導入の確認のスクリプトとは別に手で導入を試し、利用者の Claude Code に入っていた ndf が 10.27.7 から 10.27.10 へ黙って上がった(2026-11-02 15:30 JST)。手順書にも起動指示にも禁止が無く、変わったことを機械で知る仕組みも無い +- **今どうなっているか**: リリース後テストの worker が、導入の確認のスクリプトとは別に手で導入を試した。その操作で、利用者の Claude Code に入っていた ndf が 10.27.7 から 10.27.10 へ黙って上がった(2026-11-02 15:30 JST)。手順書にも起動指示にも禁止が無く、変わったことを機械で知る仕組みも無い - **誰に効くか**: NDF のスプリントを回す利用者(同じマシンで別の開発ループを動かしている利用者を含む) - **変えると何が成り立つか**: worker は利用者の設定ディレクトリで導入の操作を打たない。打たれて変わったら、まとめのプランが振り返りへ進まずに止まり、変わったプラグインと前後の版を人へ示す ## 適用範囲 -- **働く範囲**: NDF を使うどのリポジトリでも働く。リリース後テストの手順書(`release-verification` の Skill)、まとめのプランのリリース後テストのステップ、導入の確認のスクリプト(`release-verification-steps.py`)を変える +- **働く範囲**: NDF を使うどのリポジトリでも働く。変えるのは 3 つである。リリース後テストの手順書(`release-verification` の Skill)・まとめのプランのリリース後テストのステップ・導入の確認のスクリプト(`release-verification-steps.py`) - **プロジェクトごとに違うもの**: 無し。利用者の設定ディレクトリの場所は環境変数(`CLAUDE_CONFIG_DIR`・`CODEX_HOME`)と既定の場所(`~/.claude`・`~/.codex`)から決め、設定も引数も足さない - **当たるモード**: リリース後テストを通るすべてのモード(まとめのプランの前後の検査は、まとめのプランがリリース後テストのステップを組むときだけ) @@ -31,7 +31,7 @@ | リリース(`ndf-release`) | 利用者の設定ディレクトリ・導入の操作・利用者の設定の控え・リリース後テスト | | 開発の工程(`ndf-workflow`) | まとめのプラン・ステップ・止まる(人へ戻す) | -関係は公開された言語。まとめのプランは導入の確認のスクリプトを run のステップとして打ち、結果 JSON(`plugins/ndf/scripts/lib/README.md` の「手順のスクリプトの結果」)の終了コードと `summary` だけを読む。控えの中身は読まない。 +関係は公開された言語。まとめのプランは導入の確認のスクリプトを run のステップとして打つ。読むのは結果 JSON(`plugins/ndf/scripts/lib/README.md` の「手順のスクリプトの結果」)の終了コードと `summary` だけで、控えの中身は読まない。 ### 集約 @@ -88,7 +88,7 @@ | --- | --- | | `release-verification` の Skill(`SKILL.md`) | 手順 1 に節「利用者の設定ディレクトリで導入しない」を置き、禁止・保留の書き方・「判断が要る」での返し方の正本になる。プランの外で使うときに控えを取って比べる手順も持つ | | `release-verification-steps.py` の利用者の設定の読み取り(`user_config_snapshot`) | 利用者の設定ディレクトリを読むだけで、控えの項目(鍵と値の組)と読めなかったファイルの一覧を返す | -| `release-verification-steps.py` の `user_env_snapshot` | 今の rc ファイルとリンクの項目に、`user_config_snapshot` の項目を足して返す | +| `release-verification-steps.py` の `user_env_snapshot` | 今の rc ファイルとリンクの項目(`.bashrc`・`.zshrc`・`.local/bin/claude`)に、`user_config_snapshot` の項目を足して返す | | `release-verification-steps.py` の `_env_items` | 前後の鍵の和集合で差分を作る(片方にしか無い鍵も `changed` にする) | | `release-verification-steps.py` の副命令 `user-config-save` | 控えを `--out` のファイルへ書き、結果 JSON を出す | | `release-verification-steps.py` の副命令 `user-config-check` | `--before` の控えと今の状態を比べ、結果 JSON を出す | @@ -238,7 +238,13 @@ sequenceDiagram end ``` -この図に含めない構成要素: `user_config_snapshot`・`user_env_snapshot`・`_env_items`(控えの副命令と `verify-install` の中で呼ばれる)、`release_verify_step`・`user_config_steps`・`close_plan`(まとめのプランを組む側で、実行の前に働く)。`release-verification` の Skill は worker が読む手順として「worker」に含める。 +この図に含めない構成要素は次のとおりである。 + +| 構成要素 | 図に含めない理由 | +| --- | --- | +| `user_config_snapshot`・`user_env_snapshot`・`_env_items` | 控えの副命令と `verify-install` の中で呼ばれる | +| `release_verify_step`・`user_config_steps`・`close_plan` | まとめのプランを組む側で、実行の前に働く | +| `release-verification` の Skill | worker が読む手順として「worker」に含める | ## 非機能の実現方式 @@ -260,7 +266,7 @@ sequenceDiagram ### 決定 2: worker が止まったときも比べるため、リリース後テストのステップの失敗を、必ず止まる比べるステップへ回す -worker が手で導入したうえで「判断が要る」か「できなかった」で返すと、今は比べずにプランが止まり、変わったことが報告に出ない。失敗の行き先 `config-check-on-fail` は `on_exit: {"0": "stop"}` を持ち、変わっていなくても止まる(リリース後テストの失敗はそのまま人へ戻る)。変わっていれば終了コード 1 で、変わった項目が理由に載る。 +worker が手で導入したうえで「判断が要る」か「できなかった」で返すと、今は比べずにプランが止まり、変わったことが報告に出ない。失敗の行き先 `config-check-on-fail` は `on_exit: {"0": "stop"}` を持つ。変わっていなくても止まり、リリース後テストの失敗はそのまま人へ戻る。変わっていれば終了コード 1 で、変わった項目が理由に載る。 失敗の行き先を `config-check` にする形は採らない。変わっていなければ振り返りへ進み、リリース後テストの失敗が人へ戻らなくなる。 @@ -290,7 +296,7 @@ worker が手で導入したうえで「判断が要る」か「できなかっ ### 決定 6: 導入の確かめの判定を今と同じに保つため、`verify-install` は読めない設定のファイルで止まらず、控えの 2 つの副命令は止まる -`verify-install` の目的は導入した版と中身を確かめることで、利用者の設定のファイルが読めないことはその合否と別である。読めないことは値として前後で比べ、途中で読めるようになったか壊れたかだけを差分に出す。控えの 2 つの副命令は比べることだけが目的なので、読めなければ「変わっていない」と言えず、終了コード 2 で止まる(受け入れ条件 4 の「控えが取れなかったときも人へ戻す」)。 +`verify-install` の目的は導入した版と中身を確かめることで、利用者の設定のファイルが読めないことはその合否と別である。読めないことは値として前後で比べ、途中で読めるようになったか壊れたかだけを差分に出す。控えの 2 つの副命令は比べることだけが目的である。読めなければ「変わっていない」と言えないため、終了コード 2 で止まる(受け入れ条件 4 の「控えが取れなかったときも人へ戻す」)。 根拠: Value 3 / Value 6(MVV 版 4) @@ -302,7 +308,7 @@ worker は Skill の本文を読んで動くが、起動指示は Skill の本 ### 決定 8: プランの外で Skill を使うときも比べられるため、手順書に控えと比較の 2 行を置く -`/ndf:release-verification` を利用者が直接呼ぶときは、まとめのプランの前後のステップが無い。手順書の手順 1 の前に `user-config-save`、記録の後に `user-config-check` を打つ手順を置き、まとめのプランの中では前後のステップが打つため打たないと書く。 +`/ndf:release-verification` を利用者が直接呼ぶときは、まとめのプランの前後のステップが無い。手順書の手順 1 の前に `user-config-save`、記録の後に `user-config-check` を打つ手順を置く。まとめのプランの中では前後のステップが打つため、打たないと書く。 根拠: Value 5(MVV 版 4) @@ -321,7 +327,7 @@ worker は Skill の本文を読んで動くが、起動指示は Skill の本 | I2 | 取得元の URL に `user:pass@` を含む設定と、Codex の `auth.json` と `config.toml` のほかの表に置いた目印の文字列が、控えのファイルにも結果 JSON にも出ない | 伏せずに写す・ほかの表を写すと落ちる | | 受け入れ条件 7 | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`・`python3 scripts/check-skill-frontmatter.py`・`claude plugin validate .` が通る | — | -既存の `test_supervise_pace_verify.py` の `test_close_comes_after_the_retro_and_before_the_refine` は、つながりの期待値に 2 つのステップが入る(つながりを変えることが受け入れ条件 4 の振る舞いである)。 +既存の `test_supervise_pace_verify.py` の `test_close_comes_after_the_retro_and_before_the_refine` は、つながりの期待値に 2 つのステップが入る。つながりを変えることが、受け入れ条件 4 の振る舞いである。 ## 設計の結果 From 13cce442c4c62a0144bb416f609996e7bd4d3978 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:03:24 +0000 Subject: [PATCH 007/127] =?UTF-8?q?docs(design):=20=E4=B8=8D=E5=A4=89?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=20I6=20=E3=81=8B=E3=82=89=E6=AC=84=E3=81=8C?= =?UTF-8?q?=E7=84=A1=E3=81=84=E8=B5=B7=E5=8B=95=E3=81=A7=E3=81=AE=E6=AD=A3?= =?UTF-8?q?=E6=9C=AC=E3=81=AE=E7=AF=80=E3=81=AE=E8=AA=AD=E3=81=BF=E5=87=BA?= =?UTF-8?q?=E3=81=97=E5=A4=B1=E6=95=97=E3=82=92=E9=99=A4=E3=81=8F=20(#2399?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 正本の節が読めないとき、I6 は通す・処理の流れの判定 4 は止める、と読めて 実装が分かれるため、判定 4 の既定どおり止めることを I6 に書く。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2399-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-2399-design.md b/issues/issue-2399-design.md index 9de6e4659..1b123a820 100644 --- a/issues/issue-2399-design.md +++ b/issues/issue-2399-design.md @@ -51,7 +51,7 @@ | I3 | Skill の対応表 | 工程と Skill の組は対応表の 1 か所にだけある。supervisor の定義・正本の節・読む Skill の guard のコードは Skill の名前を書かない | 受け入れ条件 6 の `grep` が当たる。テストが落ちる | | I4 | worker の起動指示 | `ndf:supervisor` と `ndf:supervisor-waits` の定義の本文は、frontmatter を除いて同じである | 受け入れ条件 5 の `diff` が 0 以外を返す | | I5 | worker の起動指示 | supervisor 以外(conductor・人・ほかのエージェント)が起動する `ndf:worker` と、`ndf:worker` 以外の起動は止めない | 読む Skill の guard が判定をせずに通す | -| I6 | worker の起動指示 | 読む Skill の guard が呼び手の会話の記録を読めないときと、判定の途中で失敗したときは、起動を止めない | 判定をせずに通す(hook の既存の原則「どの失敗も終了コード 0 で通す」) | +| I6 | worker の起動指示 | 読む Skill の guard が呼び手の会話の記録を読めないときと、判定の途中で失敗したときは、起動を止めない | 判定をせずに通す(hook の既存の原則「どの失敗も終了コード 0 で通す」)。ただし欄が無い起動(「処理の流れ」の判定 4)で正本の節が読めないときは、この失敗に含めずに止め、理由に正本のファイルのパスと見出しの名前を書く | ### ドメインイベント From dda02cd716285c37f4d444834c007082cd879fca Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:11:55 +0000 Subject: [PATCH 008/127] =?UTF-8?q?Docs:=20#2401=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=20E7=20=E3=81=AE=E5=8F=97=E3=81=91=E6=89=8B?= =?UTF-8?q?=E3=82=92=20worker=20=E3=81=AE=E5=AE=8C=E4=BA=86=E6=99=82?= =?UTF-8?q?=E3=81=A8=E5=A4=B1=E6=95=97=E3=83=BB=E4=BF=9D=E7=95=99=E6=99=82?= =?UTF-8?q?=E3=81=AB=E5=88=86=E3=81=91=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2401-design.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/issues/issue-2401-design.md b/issues/issue-2401-design.md index f18bc6693..bc0866ad8 100644 --- a/issues/issue-2401-design.md +++ b/issues/issue-2401-design.md @@ -63,7 +63,7 @@ | E4 | worker が受け入れ条件ごとに確かめた | リリース後テストの worker | worker の記録の表 | | E5 | worker が利用者の設定ディレクトリへの導入が要る条件を保留にした | リリース後テストの worker | 記録の表の保留の行と、作業の報告の「判断が要る」(まとめのプランが失敗として受け、比べるステップへ回す) | | E6 | worker がリリース後テストの記録を配布の記録の PR へ書いた | リリース後テストの worker | まとめのプランの close のステップ(今と同じ) | -| E7 | ステップが利用者の設定ディレクトリの今の状態を控えと比べた | 比べるステップ(`user-config-check`) | まとめのプランの遷移(`ok` なら振り返りへ、ほかは止まる) | +| E7 | ステップが利用者の設定ディレクトリの今の状態を控えと比べた | 比べるステップ(`user-config-check`) | まとめのプランの遷移(worker が完了し、比べた結果も `ok` のときだけ振り返りへ進む。worker が失敗・保留で返したとき(`config-check-on-fail`)は、比べた結果が `ok` でも止まる。ほかは止まる) | ### 用語 From 136caca5b20cedb5d1480a18ed0d236faffdea19 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:39:51 +0000 Subject: [PATCH 009/127] =?UTF-8?q?Docs:=20#2337=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=81=AE=E5=86=99=E3=81=97=E3=82=92=E7=BD=AE=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2337-requirements.md | 218 ++++++++++++++++++++++++++++++ 1 file changed, 218 insertions(+) create mode 100644 issues/issue-2337-requirements.md diff --git a/issues/issue-2337-requirements.md b/issues/issue-2337-requirements.md new file mode 100644 index 000000000..281669619 --- /dev/null +++ b/issues/issue-2337-requirements.md @@ -0,0 +1,218 @@ +# #2337: 結果の契約をそろえ、失敗・未収束・判定できないを ok で返す経路を無くす + +正は課題の本文(#2337)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +大方針 #2335(異常系・ログ・回復とデータ・入出力の形を 1 つの実行の核へ集める)の柱 P1。実行の核のスケルトンを作り、すべてのエントリポイントの結果を `ok` / `gate` / `stopped` と決まった終了コードにそろえる。 + +## 依頼(原文) + +> 異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない + +> 出力だけでなく入力もそろえるべき + +(書き手: 利用者、日付: 2026-11-01。親の課題 #2335 の「調べ方」の節が引用した利用者の指摘のうち、この柱に当たるもの) + +> 全ての工程で統一ルールに基づくレポートを JSON で出し、過程・判断・結果が分かるようにする + +(書き手: 起票者、日付: 2026-10-31。利用者の方針を AI が要約して #2227 に記録した文で、利用者の発言の原文ではない) + +この課題の初版の本文(「何をするか」「受け入れ条件 1〜4」「進め方」)は、起票者(AI)が 2026-11-01 の組み直しで書いた文で、利用者の指示の原文として扱わない。初版の内容は下の「起票者の推定」と受け入れ条件へ移した。 + +**解釈**: 利用者が求めているのは、異常が起きたときの扱いを NDF 全体で 1 つにし、同じ型の不具合を 1 件ずつでなく仕組みで無くすことである。この柱はそのうち「失敗の判定」(原因の型 A)を受け持つ。手段(実行の核・`run_core.main`・柱の分け方)は #2335 の設計(`issues/issue-2335-design.md`)が決めており、この要求はその範囲で書く。 + +## 起票者の推定 + +題・本文のうち、利用者の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。#2335 の推定 S2・S8 は #2335 の承認ゲート 1 を通っている(#2335 の設計文書が develop にある)。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | ステップの出口は `ok` / `gate` / `stopped` の 3 つと決まった終了コードで、例外・中断・時間切れ・判定できないは核が `stopped` と理由へ変える | 初版の本文「何をするか」、#2335 の推定 S2 | 受け入れ条件 1・2 | +| S2 | `ok` は欠けが無いときだけ返す | 初版の本文「何をするか」、#2335 の不変条件 I2 | 受け入れ条件 3・4 | +| S3 | worker の報告も同じ形で返し、自由文から完了を読まない。外部 CLI の報告も同じ | 初版の本文「何をするか」・初版の受け入れ条件 3 | 受け入れ条件 9〜11 | +| S4 | 課題を閉じる処理は、確かめが済むまで閉じない | 初版の本文「何をするか」(#2228 の主題) | 受け入れ条件 13・14 | +| S5 | 子の課題は 1 件ずつ直さず、柱の仕組みを入れた後に再現の手順を通して閉じるか残すかに分ける | 初版の本文「進め方」、#2335 の推定 S8 | 受け入れ条件 15・境界の「行わない」 | +| S6 | P1 がすべてのエントリポイントを核へ移し、P2 以降は核の段を足すだけにする | #2335 の設計の決定 11 | 受け入れ条件 6・前提 2 | +| S7 | 「すべての入口を回すテスト」で `ok` の経路が無いことを確かめる | 初版の受け入れ条件 2、#2335 の受け入れ条件 2a | 受け入れ条件 5 | +| S8 | 子の課題は、GitHub のサブ課題 20 件と、コメントで結び付けた #2361 の 21 件である | この課題のサブ課題の一覧と 2026-11-01 のコメント | 受け入れ条件 15・「子の課題」の表 | + +## 目的 + +- エントリポイントとステップが、終わり方(正常・例外・SIGTERM・時間切れ)にかかわらず、`ok` / `gate` / `stopped` と決まった終了コードで返る状態にする。欠け・例外・判定できない状態が `ok` で返る経路を、核と検査で塞ぐ +- worker・外部 CLI・課題を閉じる処理の結果を、自由文ではなく構造化した結果で受け取り、読み違いで先へ進む不具合(原因の型 A)を型ごとに無くす +- 達成したい状態: 新しいエントリポイントを足しても、核を通らなければ構造チェックが落とすため、同じ型の不具合が生まれない + +## 前提 + +- 前提 1: 柱 P0(#2336)は閉じており、契約の一覧(`plugins/ndf/contracts/entrypoints/` の 77 項目。Python 50・シェル 27、2026-11-02 に数えた)を正として読む +- 前提 2: 核を通ったかの検査(構造チェックの種類 `core-entry`)は P0 では入っていない。`grep -nE "core-entry|core_entry|run_core" scripts/check-script-structure.py scripts/check-ndf-contracts.py scripts/contracts_lib/*.py` は 0 件(2026-11-02)。この柱が入れる +- 前提 3: 1 スプリントは「核の段の Pull Request」と「部品ごとにエントリポイントを移す Pull Request 3 本まで」で作る(#2335 の設計)。全エントリポイントが 1 スプリントで移らなければ、残りは同じ柱の次のスプリントへ送り、この課題は開いたまま残る +- 前提 4: hook と、ランタイム(Claude Code・Codex)が直接起動して標準出力をそのまま使うエントリポイント(ステータスライン など)は核の対象から外す(#2335 の対象範囲)。外す一覧は設計が契約の一覧に印として決める +- 前提 5: 標準出力を結果 JSON 以外(文・JSON の別の形・KEY=VALUE)で返すエントリポイント 46 本の標準出力の形は、この柱では変えない。形をそろえるのは柱 P7(#2340)である。この柱は終了コードと結果の状態だけをそろえる +- 前提 6: フェーズレポート(Markdown)・課題と PR の本文から値を読む箇所は柱 P7 が受け持つ。この柱が受け持つのは、worker・外部 CLI の報告とフェーズの結果から「完了・止まり」を判定する箇所である +- 前提 7: 外部 CLI の標準エラーの文から失敗の理由(利用上限など)を分類することは残してよい。外部 CLI は NDF の外にあり、構造化した理由を返さないためである。完了の判定にだけ使わない +- 前提 8: SIGKILL など後片付けのコードが動けない終わり方では、結果 JSON は出ない。呼ぶ側は結果 JSON が無いことを `stopped` と読む + +## 対象範囲 + +含む: +- 実行の核のスケルトン(`run_core.main(<エントリポイントの id>, <本体>)` と、シェルの `rc_enter`)。終わり方ごとの結果への変換・欠けの確認・extras が無いときの扱い +- 契約の一覧のエントリポイント(前提 4 を除く)を核へ移すこと +- 構造チェックの種類 `core-entry` と、その例外リスト +- worker の報告・外部 CLI の報告・フェーズの結果から完了と止まりを判定する箇所 +- 課題を閉じる処理(`sprint-close.py`)の結果の状態 +- 子の課題 21 件の、閉じる・残すの仕分け + +含まない: +- 実行の記録(JSON Lines)と実行の識別子の記録(柱 P2 #2338)。結果 JSON に任意の鍵 `run_id` を足すことは含む +- 持ち主の記録と後片付け・並行の排他(柱 P3 #2342) +- 上限と再試行の宣言(柱 P4 #2343) +- データの型と版(柱 P6 #2339) +- 標準出力の形と、Markdown・本文から値を読む箇所(柱 P7 #2340) +- 引数・環境変数・設定の読み込み部品(柱 P8 #2341) +- hook と、ランタイムが直接起動するエントリポイント(前提 4) +- 子の課題を 1 件ずつ直す Pull Request + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | エントリポイントが核を呼んだ | Skill の本文・プラン・conductor・利用者のコマンド | 必要な extras が無い → `stopped`・69 と足りないグループ名 | — | +| E2 | 核が本体を呼んだ | E1 | — | E1 | +| E3 | 本体が結果を返した、または例外・SIGTERM・時間切れで中断した | E2 | 中断 → `stopped`・1 と理由(`interrupted` / `timeout`) | E2 | +| E4 | 核が結果の形・欠け・状態と終了コードの一致を確かめた | E3 で結果が返った | 形が違う・判定できない → `stopped`・1。欠けがあるのに `ok` → `stopped`・6 | E3 | +| E5 | 核が結果 JSON と終了コードを返した | E3 か E4 | SIGKILL では返らない → 呼ぶ側が `stopped` と読む(前提 8) | E3 | +| E6 | worker が構造化した結果を付けて報告を終えた | supervise が worker を起動した | 構造化した結果が無い・読めない → `stopped` | — | +| E7 | supervise が worker・外部 CLI・フェーズの結果の状態から次の手を決めた | E6、外部 CLI の終了 | 状態が読めない → `stopped` | E5 か E6 | +| E8 | 課題を閉じる処理が、閉じる対象の課題ごとに閉じる条件を判定した | 本番の配布の後のプラン | 条件を満たさない課題が残る → `ok` にしない | 配布の記録 | +| E9 | 子の課題の再現の手順を通し、閉じる・残すを本文へ記録した | 柱の仕組みが develop へ入った | 再現した → 理由つきで残す | E1〜E8 の仕組みのマージ | + +洗い出しで移したもの: E5 の SIGKILL は前提 8 に、E6 の構造化した結果が無い場合は受け入れ条件 9 に、E8 の「振り返りを待つ」扱いは未決に置いた。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 実行の核 | エントリポイントとステップが必ず通る共通の実行の仕組み。この柱では、本体の結果か中断を結果 JSON と終了コードへ変える部分(スケルトン)を作る | +| 結果 JSON | `lib/step_result.py` の形の 1 行の JSON。`status` は `ok` / `gate` / `stopped` | +| 欠け | 結果 JSON の `gaps`。満たせなかったこと・確かめられなかったことの一覧 | +| 例外リスト | 構造チェックが許す今の外れの一覧(`scripts/contract-allow/`)。行を増やす変更は落ちる | + +## 受け入れ条件 + +### 核の結果の契約 + +- [ ] 1. 本体が結果を返す・例外を投げる・SIGTERM を受ける・時間切れになる、の 4 つの終わり方のそれぞれで、核の結果 JSON の `status` は `ok` / `gate` / `stopped` のどれかで、終了コードは `step_result.code_matches` を満たす +- [ ] 2. 例外・SIGTERM・時間切れで終わったとき、結果は `stopped` で、`summary` に理由があり、`gaps` に `interrupted`(SIGTERM)か `timeout`(時間切れ)が入る +- [ ] 3. 本体が `gaps` の空でない結果を `ok` で返すと、核は `stopped`・終了コード 6 を返す +- [ ] 4. 本体が結果の形でないもの(`None`・型の違う値)や、`status` と終了コードが食い違う結果を返すと、核は `stopped`・終了コード 1 を返す +- [ ] 5. 契約の一覧のすべてのエントリポイント(前提 4 を除く)を、欠けのある結果を返す本体と例外を投げる本体に差し替えて回すテストで、`ok` が 1 度も出ない +- [ ] 6. 前提 4 を除くすべての Python のエントリポイントの `if __name__ == "__main__":` の中身が `run_core.main(...)` の 1 文で、シェルのエントリポイントが先頭で `rc_enter` を呼ぶ。外れたエントリポイントを構造チェック(`core-entry`)が名前を出して落とす +- [ ] 7. `core-entry` の例外リストに行を足す変更を構造チェックが落とす。この課題を閉じる時点で `core-entry` の例外リストの行は 0 である +- [ ] 8. optional extras(pydantic・tenacity・dbos)が入っていない環境でエントリポイントを起動すると、例外の跡(traceback)を出さず、`stopped`・終了コード 69 と足りないグループの名前が返る + +### 報告と結果の読み方 + +- [ ] 9. supervise は worker の完了を、worker が返す構造化した結果の `status` だけから決める。報告の文に「結果: 完了」「判断が要る」があるかで成否を決める箇所が無い。構造化した結果が無いか読めない worker は `stopped` になる +- [ ] 10. `supervise.py run` の終了コードは、フェーズの結果の `status` から決まる(今は報告の文に決まった語があるかで決める。`plugins/ndf/scripts/supervise_lib/commands.py:308`) +- [ ] 11. 外部 CLI(codex・agy・kiro・claude)の完了は、終了コードと結果ファイルの状態から決まる。結果ファイルが無い・読めないときは `ok` にならない。進捗ログの語(「止まった」など)から止まりを判定する箇所が無い(`supervise_lib/state.py:30` の `PROGRESS_STOP`) +- [ ] 12. ほかのエントリポイントの結果 JSON を読んで次の手を決める箇所は、共通の読み手(`lib/step_read.py`)を通し、`status` が `ok` でない結果を成功と読まない +- [ ] 13. 契約の一覧の各エントリポイントの終了コードの表(`exit`)が `step_result` の終了コードの表と同じ意味で書かれ、違う意味の番号(#2241 の 4・5 など)を持つエントリポイントを検査が落とす + +### 課題を閉じる処理 + +- [ ] 14. 課題を閉じる処理は、閉じる対象として集めた課題のうち閉じなかった課題(`kept_open`・`closed_unverified`・`failed`)が 1 件でもあれば `ok` を返さず、課題ごとの理由を結果に載せる。参照しているだけの課題(閉じる対象でないもの)は状態に影響しない +- [ ] 15. 課題を閉じる処理は、今と同じく、閉じる条件 2(リリース後テストの行)と条件 3(振り返りの記録)を満たさない課題を閉じない(退行しない) + 前提の確認: `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_sprint_close_final_stage.py plugins/ndf/scripts/tests/test_sprint_close_acceptance.py -q` → 38 passed(2026-11-02、v10.27.10)。今は条件 3 を満たさない課題が `kept_open`・終了コード 0 で返る(`test_an_issue_without_the_retro_record_is_kept_open`)。受け入れ条件 14 はこの終了コードを変える + +### 子の課題 + +- [ ] 16. 下の「子の課題」の 21 件それぞれについて、柱の仕組みのマージの後に再現の手順を 1 回通し、閉じた(解消を確かめた)か残した(理由つき)かをこの本文に記録する + +### 起きてはいけないこと + +- [ ] 17. 結果 JSON の既存の鍵(`tool`・`status`・`summary`・`items`・`metrics` ほか)を削らない。足すのは任意の鍵 `run_id` だけである +- [ ] 18. `step_result` の終了コードの表(0・1・2・3・4・6・10〜19・20〜29・69・75)の意味を変えない +- [ ] 19. Skill の本文・プランの雛形が書くエントリポイントの呼び方(コマンドと引数)が変わらない +- [ ] 20. 全体テスト `uv run --frozen --project . --all-extras pytest . -q -n 4` が、この柱の各 Pull Request のマージの前後で通る + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 核へ移したエントリポイントの `python3 <エントリポイント> --help` を変更の前後で 10 回ずつ測り、中央値の差が 150 ms 以下である(#2335 の決定 9)。差を各 Pull Request の本文に書く | +| 運用・保守性 | `stopped` の結果は、どの終わり方でも `summary` に理由を持ち、呼ぶ側が結果 JSON だけで止まった理由を読める | +| 移行性 | 移す対象はエントリポイントの末尾と結果の返し方。順序は部品ごと(supervise → cross-refactoring → 課題を閉じる処理 → リリース → 外部 CLI の監視 → 使用量の帳簿 → 残り)。並走: 移していないエントリポイントは例外リストに載ったまま今の形で動く。切り戻し: 部品ごとの Pull Request を 1 回 revert すれば前の形へ戻る | +| システム環境 | Claude Code と Codex の両方の配布で同じ核が動く。シェルのエントリポイントは bash で動く。新しい依存を足さない(既存の optional extras の範囲) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | エントリポイントの呼び方は変わらない(受け入れ条件 19)。結果 JSON に任意の鍵 `run_id` が増える(互換あり)。今 `ok`・終了コード 0 で返している欠けのある結果と、課題を閉じる処理の `kept_open` が、`stopped` か `gate` と 0 以外の終了コードになる | +| データ | 変わらない。状態ファイルとプランの形は柱 P6 が扱う | +| 既存の振る舞い | worker の報告の形が変わる(構造化した結果を付ける)。worker を起動する指示と worker のエージェント定義が変わる。欠けのある結果で先へ進んでいたループ・プランが、承認ゲートか `stopped` で止まる回数が一時的に増えうる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`。核の終わり方ごとの単体テストと、契約の一覧の全エントリポイントを回すテスト | +| 静的解析・構造の検査 | `python3 scripts/check-script-structure.py`、`python3 scripts/check-ndf-contracts.py` | +| 配布物の検証 | `claude plugin validate .` | +| 性能 | 移したエントリポイントの `--help` を前後で 10 回ずつ測る(非機能の条件) | +| 手動確認 | 受け入れ条件 16: 子の課題ごとに本文の再現の手順を通し、結果を本文に書く(リリース後テストで行う) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 核は `plugins/ndf/scripts/lib/` に置き、実験版(`experimental/`)に置かない。`factory/` を参照しない(`AGENTS.md` の「マーケットプレイスの構造」)。核の形は #2335 の設計の決定 1・2 | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」と、外部コマンドを書く前に実行して確かめる決まり | +| テスト戦略 | 核は終わり方ごとの単体テスト、エントリポイントは契約の一覧から作るテスト、子の課題は再現の手順で確かめる。`.md` の文言を照合するテストは書かない(`AGENTS.md` の DON'T) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 契約の一覧を正として変更する。核へ移したエントリポイントを例外リストから消す。Pull Request ごとに全体テストと構造チェックを通す | +| 確認してから行う(承認ゲート 1・2 で人が見る) | worker の報告の形とエージェント定義の変更。終了コードが 0 から変わるエントリポイントの一覧。秘密を扱う部品(`lib/auth.py`・`lib/git-credential.sh`・`skills/google-workspace/`)を核へ移す変更(C1。`.ndf/pace.json` の `boundary_paths`) | +| 行わない | 子の課題を 1 件ずつ直す Pull Request。hook を核へ移す。例外リストに行を足す。標準出力の形を変える(柱 P7) | + +## 子の課題 + +見込みは、どの受け入れ条件の仕組みで解消しうるかの推定で、閉じるかどうかは受け入れ条件 16 の再現で決める。 + +| 課題 | 何が起きるか | 見込み | +| --- | --- | --- | +| #620 | cross-refactoring の add-tests が、pytest が収集しないファイルへテストを足して検証を通る | 核の外の可能性(検証の中身) | +| #1411 | cross-review の監視が、既存コメントの「quota exceeded」を利用上限と誤判定して中断する | 11(前提 7 の範囲なら残す) | +| #1502 | mvv-gate が、設計の前に打った requirements-check の exit 2 を材料に読む | 12 | +| #1657 | cross-refactoring の最終ゲートで、CI で落ちた範囲外のテストを再現できない | 核の外の可能性 | +| #1682 | 実装のプランが手動確認(マージ前)を残したまま完了で返る | 3・9 | +| #1699 | 受け入れ条件を担うステップがプランに無く、worker が残したまま完了へ進む | 3・9 | +| #1715 | spec-finalize が残りのファイルをコミットに入れないまま ok で終わる | 3 | +| #1984 | supervise が worker の進捗ログの「止まった」の語に反応する | 11 | +| #2067 | gws-check.py がトークンの無い状態を認証済みと判定して exit 0 を返す | 3(C1 の部品。承認ゲートで見る) | +| #2118 | design-tests が、改名を決めたテストを止まりと判定しない | 3 | +| #2166 | リリース後テストの起動指示が、配布の記録の経路を確かめない | 核の外の可能性 | +| #2227 | 全工程の結果を統一した JSON レポートにする(親) | 1〜13 | +| #2228 | 課題を閉じる時点を 1 つにし、最終の工程を通ってから閉じる(親) | 14・15 | +| #2237 | 承認ゲート 1 の MVV 判定が、要求の前の exit 2 を材料の欠けと読む | 12 | +| #2241 | handoff.py が終了コード 4・5 を共通の表と違う意味で返す | 13 | +| #2274 | 取り込みの後、取り込まれた課題の受け入れ条件の一部が落ちたまま完了になる | 核の外の可能性 | +| #2289 | 結果を読む共通の関数を製品のコードが使わない | 12 | +| #2327 | 別のリポジトリの課題が振り返りの記録を満たせず開いたまま残る | 14 | +| #2330 | 保留にした条件を次のスプリントで拾い直す工程が無い | 14 | +| #2334 | スクリプトの結果の JSON・ログ出力・異常系の処理を 1 つの形へそろえる | 1〜8(ログは柱 P2) | +| #2361 | 設計の結果の食い違いのメッセージが、違う項目を出さない | 核の外の可能性(メッセージの中身) | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| worker の構造化した結果の置き場(報告の末尾の機械用のブロックか、結果のファイルか)と、エージェント定義への書き方 | この課題の設計 | 設計の Pull Request | +| 標準出力が結果 JSON でないエントリポイント 46 本(文 34・JSON の別の形 5・KEY=VALUE 2・出力なし 5)で、柱 P7 までの間に核が結果の状態をどこへ返すか | この課題の設計 | 設計の Pull Request | +| 核の対象から外すエントリポイント(前提 4)の一覧と、契約の一覧での印の付け方 | この課題の設計 | 設計の Pull Request | +| 課題を閉じる処理で、振り返りを待って閉じない課題を `gate` と `stopped` のどちらにするか。閉じる処理と振り返りのプランの順をどう変えるか(受け入れ条件 14) | この課題の設計 | 設計の Pull Request | +| 全エントリポイントを何スプリントで移すか(前提 3) | この課題の実装計画 | 最初のスプリントの計画 | +| 耐久の記録(DBOS)を使うエントリポイントで、時間切れの仕組み(`signal.setitimer`)と DBOS の内部のスレッドが干渉しないか(#2335 の設計の未確認) | この課題の設計(耐久のエントリポイント 1 本で確かめる) | 設計の Pull Request | From 57b0fc93602160e9c4ce304107008612953fff4b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:46:30 +0000 Subject: [PATCH 010/127] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AE=E9=A3=9F=E3=81=84=E9=81=95=E3=81=84?= =?UTF-8?q?=E3=81=AE=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8=E3=81=AE?= =?UTF-8?q?=E8=A6=81=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=EF=BC=88#2361=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 課題の本文の写しを issues/ に置き、設計の結果の列の語(扱い・取り込み先・触るファイル)を用語集へ足す。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 + docs/glossary/glossary.json | 18 ++++ issues/issue-2361-requirements.md | 142 ++++++++++++++++++++++++++++++ 3 files changed, 163 insertions(+) create mode 100644 issues/issue-2361-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index 303a0773f..13f36e5cd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -375,6 +375,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 作業の識別子 | — | 複数のセッションを 1 つの作業として結ぶ鍵。起動の引数かスプリントに含まれる課題番号の組(#829 #830) | — | — | — | | 費用の見込み | — | 判断の帯で、続けた場合と再起動した場合の次の工程までの費用を、記録から較正した中央値で比べた値 | — | — | — | | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | — | +| 扱い | — | 設計の結果の列。課題ごとに `実装する` / `取り込む` / `閉じる` のどれかを書く | — | — | — | +| 取り込み先 | — | 設計の結果の列。扱いが `取り込む` の課題を、どの課題の実装へ含めるかを課題の番号で書く | — | — | — | +| 触るファイル | — | 設計の結果の列。扱いが `実装する` の課題が変えるファイルの一覧。実装のプランの触る範囲になる | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 830e193df..8247140a0 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5241,6 +5241,24 @@ "context": "ndf-workflow", "meaning": "conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで", "pending_source": "issues/issue-2262-design.md" + }, + { + "term": "扱い", + "context": "ndf-workflow", + "meaning": "設計の結果の列。課題ごとに `実装する` / `取り込む` / `閉じる` のどれかを書く", + "pending_source": "issues/issue-2361-requirements.md" + }, + { + "term": "取り込み先", + "context": "ndf-workflow", + "meaning": "設計の結果の列。扱いが `取り込む` の課題を、どの課題の実装へ含めるかを課題の番号で書く", + "pending_source": "issues/issue-2361-requirements.md" + }, + { + "term": "触るファイル", + "context": "ndf-workflow", + "meaning": "設計の結果の列。扱いが `実装する` の課題が変えるファイルの一覧。実装のプランの触る範囲になる", + "pending_source": "issues/issue-2361-requirements.md" } ] } diff --git a/issues/issue-2361-requirements.md b/issues/issue-2361-requirements.md new file mode 100644 index 000000000..eee12aa5f --- /dev/null +++ b/issues/issue-2361-requirements.md @@ -0,0 +1,142 @@ +# #2361: 設計の結果の食い違いのメッセージが、違う項目(触るファイル・取り込み先)を出さない + +正は課題の本文(#2361)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> 開発ループ lroot の周回 2 で、`supervise.py design-results` が次のメッセージで止まった。 +> +> ``` +> 設計の結果を書けない: #2336 に違う扱いが書かれている(実装する と 実装する)。人へ戻す +> ``` +> +> 扱いは同じで、実際に違っていたのは「触るファイル」だった(`issues/issue-2335-design.md` は 5 個、`issues/issue-2336-design.md` は 18 個)。メッセージからは何が違うのか読めず、原因を探すのにコードを読む必要があった。 +> +> ## 原因 +> +> `plugins/ndf/scripts/lib/design_results.py` の `DesignResults.add` は (扱い, 取り込み先, 触るファイル) の組で比べるのに、メッセージには扱い(`kind`)しか出さない。 +> +> ## 直し方の案 +> +> 違った項目の名前と両方の値、2 本の設計文書のパス(`Row.doc`)をメッセージに載せる。 +> +> ## 見つけた経緯 +> +> PR https://github.com/devbasex/ai-plugins/pull/2360 (2 本の設計文書の行をそろえた)の前に見つけた。 + +(書き手: 起票者、日付: 2026-11-01。開発ループの振り返りとして AI が書いた本文で、利用者の指示の原文ではない) + +> 大方針 #2335 の柱 #2337(結果の契約)の子として扱います(スプリント lroot-2 の棚卸し)。 + +(書き手: 起票者、日付: 2026-11-01。棚卸しの記録として AI が書いたコメント) + +> #2361。人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く。 + +(書き手: AI の起動指示、日付: 2026-11-02) + +この課題には利用者が書いた指示の原文が無い。以下の条件はすべて起票者の本文から導いたもので、承認ゲート 1 で利用者が確かめる。 + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 食い違いのメッセージに、違った項目の名前を載せる | 本文「直し方の案」 | 受け入れ条件 1・2・3 | +| S2 | 違った項目の両方の値を載せる | 本文「直し方の案」 | 受け入れ条件 1・2。触るファイルは両方の一覧の全体ではなく、片方にしか無いファイルと個数を載せる(前提 2) | +| S3 | 2 本の設計文書のパスを載せる | 本文「直し方の案」 | 受け入れ条件 4 | +| S4 | 大方針 #2335 の柱 #2337(結果の契約)の子として扱う | 棚卸しのコメント | 含まない(結果の JSON の項目を足すことは柱 #2337 が決める) | + +## 目的 + +設計の結果を合わせる工程(`supervise.py design-results`)が、同じ課題について 2 本の設計文書が違う設計の結果を書いていたために止まったとき、止まった理由のメッセージだけを読んで、どの設計文書のどの項目をそろえればよいかが分かる状態にする。コードを読んで原因を探す手間を無くす。 + +## 前提 + +- 前提 1: 比べる項目は今と同じ 3 つ(扱い・取り込み先・触るファイル)で、比べ方(触るファイルは順序を問わない)も変えない。止まるかどうかの判定は変わらず、変わるのはメッセージの中身だけである +- 前提 2: 触るファイルが違うときは、両方の一覧の全体ではなく、各設計文書にしか無いファイルと、各設計文書のファイルの個数を載せる。一覧の全体(実例では 18 個)を載せると、どこが違うかを読み手が自分で突き合わせることになるため +- 前提 3: 2 つ以上の項目が同時に違うときは、違うものをすべて載せる(扱い・取り込み先・触るファイルの順)。片方だけを載せると、直した後にもう一度止まる +- 前提 4: 設計の結果を合わせる工程は、どの行にも読んだ設計文書のパスを持たせている(`read_results` が `parse_results` へパスを渡す)。パスを持たない行(テストなどで直接組んだ行)では、パスの代わりに「パスの記録なし」と分かる表示を出す。表示の文言は設計が決める +- 前提 5: 止まったときの結果の形(状態 `stopped`・終了コード 2・プランと `design-results.json` を書かない)は変えない + +## 対象範囲 + +含む: +- 同じ課題に違う設計の結果が書かれていたときのメッセージ(`DesignResultsError` の文)に、違う項目・値・設計文書のパスを載せる +- 上の振る舞いを確かめる自動テスト + +含まない: +- 結果の JSON(`items` / `metrics`)へ食い違いの項目を構造化して足すこと。結果の契約は柱 #2337 が決める +- 取り込み先に流れる実装のプランが無いときのメッセージ(別の食い違いで、すでに課題と取り込み先の番号を出している) +- 食い違いを自動でそろえること。食い違いは今と同じく人へ戻す + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 設計の課題ごとに、マージ済みの設計 PR の設計文書から設計の結果の行を読んだ | `supervise.py design-results` の実行 | 読めない一時的な失敗は打ち直し、残れば止まる(今の振る舞い。変えない) | — | +| E2 | すでに合わせた行の中に、同じ課題の行が見つかった | E1 で読んだ行を合わせるとき | — | E1 | +| E3 | 2 つの行の項目を比べ、違う項目を見つけた | E2 | 項目がすべて同じなら何もせず次の行へ進む(今の振る舞い) | E2 | +| E4 | 違う項目・両方の値・両方の設計文書のパスを載せたメッセージで止まった | E3 で違う項目が 1 つ以上あった | — | E3。プランと `design-results.json` を書く前 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 設計の結果 | 設計文書の「設計の結果」の節の表。課題ごとに扱い・取り込み先・触るファイルを持つ | +| 扱い | 設計の結果の列。`実装する` / `取り込む` / `閉じる` のどれか | +| 取り込み先 | 扱いが `取り込む` の課題を、どの課題の実装へ入れるか | +| 触るファイル | 扱いが `実装する` の課題が変えるファイルの一覧 | + +## 受け入れ条件 + +- [ ] 1. 同じ課題について、扱いが違う 2 本の設計文書を合わせると、メッセージに課題の番号・項目の名前「扱い」・両方の値(例: `実装する` と `取り込む`)が載る +- [ ] 2. 扱いが同じ `取り込む` で取り込み先が違う 2 本を合わせると、メッセージに項目の名前「取り込み先」と両方の課題の番号が載り、項目の名前「扱い」は載らない +- [ ] 3. 扱いが同じ `実装する` で触るファイルが違う 2 本(実例: 片方 5 個・片方 18 個)を合わせると、メッセージに項目の名前「触るファイル」・各設計文書のファイルの個数・各設計文書にしか無いファイルが載り、項目の名前「扱い」は載らない +- [ ] 4. 1〜3 のどのメッセージにも、先に読んだ設計文書のパスと後に読んだ設計文書のパスが、それぞれの値と対応の分かる形で載る +- [ ] 5. 2 つ以上の項目が違うときは、違う項目がすべて載る +- [ ] 6. 触るファイルの順序だけが違う 2 本、およびすべての項目が同じ 2 本を合わせても止まらない(今の振る舞いを保つ) +- [ ] 7. 食い違いで止まったときの `supervise.py design-results` の結果は、状態 `stopped`・終了コード 2 で、メッセージは「設計の結果を書けない: 」で始まり「。人へ戻す」で終わる。実装のプランと `design-results.json` は書かれない(今の振る舞いを保つ) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 止まった理由がメッセージだけで分かり、原因を探すためにコードを読まなくてよい(受け入れ条件 1〜5) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。結果の状態・終了コード・JSON の項目は今のまま。メッセージの文だけが変わる | +| データ | 変わらない | +| 既存の振る舞い | 食い違いで止まったときのメッセージの文 | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_design_results_rewrite.py plugins/ndf/scripts/tests/test_sprint_procedures.py -q`(2026-11-02 に実行し 32 件が通ることを確かめた) | +| 全体テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4` | +| 静的解析・型検査 | 継続的統合の既存のジョブ(変更したファイルの整形と静的解析) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | 比べてメッセージを組むのは設計の結果の値オブジェクト(`plugins/ndf/scripts/lib/design_results.py`)に置く。`supervise_lib` 側のメッセージの前後の文(「設計の結果を書けない: 」「。人へ戻す」)は変えない | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」 | +| テスト戦略 | 値オブジェクトの単体テストで受け入れ条件 1〜6 を、`cmd_design_results` を通すテストで受け入れ条件 7 を確かめる。`.md` の文言を照合するテストは書かない(`AGENTS.md`) | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存のテストの実行、変更したファイルの整形 | +| 確認してから行う | 結果の JSON の項目を足すこと(柱 #2337 の範囲。承認ゲートで人が見る) | +| 行わない | 比べる項目と比べ方の変更、食い違いの自動のそろえ、範囲外のリファクタリング | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| パスを持たない行のときの表示の文言(前提 4) | 設計 | 設計 PR | +| メッセージの並べ方(1 行か、項目ごとに区切るか) | 設計 | 設計 PR | From bdcf56c00d309d652554359fd3716e02422d22fb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:49:56 +0000 Subject: [PATCH 011/127] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AE=E9=A3=9F=E3=81=84=E9=81=95=E3=81=84?= =?UTF-8?q?=E3=81=AE=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8=E3=81=AE?= =?UTF-8?q?=E8=A8=AD=E8=A8=88=EF=BC=88#2361=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 食い違いのメッセージに、違う項目(扱い・取り込み先・触るファイル)と先・後の値、2 本の設計文書のパスを 1 行で載せる形を決める。触るファイルは差だけを重なりを数えて出す。用語集へ「食い違い」を足す。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 1 + docs/glossary/glossary.json | 6 + issues/issue-2361-design.md | 272 ++++++++++++++++++++++++++++++++++++ 3 files changed, 279 insertions(+) create mode 100644 issues/issue-2361-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 13f36e5cd..7d96adfcd 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -378,6 +378,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 扱い | — | 設計の結果の列。課題ごとに `実装する` / `取り込む` / `閉じる` のどれかを書く | — | — | — | | 取り込み先 | — | 設計の結果の列。扱いが `取り込む` の課題を、どの課題の実装へ含めるかを課題の番号で書く | — | — | — | | 触るファイル | — | 設計の結果の列。扱いが `実装する` の課題が変えるファイルの一覧。実装のプランの触る範囲になる | — | — | — | +| 食い違い | — | 同じ課題について、2 本の設計文書が違う設計の結果(扱い・取り込み先・触るファイルのどれか)を書いていること。設計の結果のステージは食い違いを見つけると止まり、人へ戻す | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 8247140a0..1b719da97 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5259,6 +5259,12 @@ "context": "ndf-workflow", "meaning": "設計の結果の列。扱いが `実装する` の課題が変えるファイルの一覧。実装のプランの触る範囲になる", "pending_source": "issues/issue-2361-requirements.md" + }, + { + "term": "食い違い", + "context": "ndf-workflow", + "meaning": "同じ課題について、2 本の設計文書が違う設計の結果(扱い・取り込み先・触るファイルのどれか)を書いていること。設計の結果のステージは食い違いを見つけると止まり、人へ戻す", + "pending_source": "issues/issue-2361-design.md" } ] } diff --git a/issues/issue-2361-design.md b/issues/issue-2361-design.md new file mode 100644 index 000000000..488231c06 --- /dev/null +++ b/issues/issue-2361-design.md @@ -0,0 +1,272 @@ +# 設計の結果の食い違い: 止まった理由のメッセージだけで、そろえる設計文書と項目が分かる(#2361) + +## 目的 + +- **今どうなっているか**: 同じ課題に 2 本の設計文書が違う設計の結果を書いていると、`supervise.py design-results` は `#2336 に違う扱いが書かれている(実装する と 実装する)` で止まる。比べるのは扱い・取り込み先・触るファイルの 3 つなのに、メッセージには扱いしか出ない。実例では違っていたのは触るファイル(5 個と 18 個)で、原因を探すのにコードを読んだ +- **誰に効くか**: 開発ループで止まった工程を人へ戻す conductor と、戻されて設計文書を直す人 +- **変えると何が成り立つか**: メッセージに違う項目・両方の値・2 本の設計文書のパスが載り、どの設計文書のどの項目をそろえればよいかがメッセージだけで分かる。止まるかどうかの判定と結果の形は変わらない + +## 適用範囲 + +- **働く範囲**: NDF のスプリントの「設計の結果」のステージ(`supervise.py design-results`)。配布先のリポジトリでも同じスクリプトが動くため、そこでも働く +- **プロジェクトごとに違うもの**: 無し(メッセージの文だけを変え、設定も引数も足さない) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| #2361 の本文「依頼(原文)」のメッセージ `設計の結果を書けない: #2336 に違う扱いが書かれている(実装する と 実装する)。人へ戻す` と、実際の違いが触るファイル(5 個と 18 個)だったという記録 | 実測(開発ループ lroot の周回 2 で出たメッセージ) | 扱いだけを出すメッセージでは、違いが扱い以外のとき何も読めない | +| `plugins/ndf/scripts/lib/design_results.py:64` の比較 `(old.kind, old.host, sorted(old.files)) != (r.kind, r.host, sorted(r.files))` と、`:65` のメッセージ `f"#{r.issue} に違う扱いが書かれている({old.kind} と {r.kind})"` | 一次情報(コード) | 比べる項目は 3 つ、メッセージに出すのは 1 つ | + +要求と受け入れ条件は #2361 の本文にある(コピーは `issues/issue-2361-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発の工程(`ndf-workflow`) | 設計の結果・扱い・取り込み先・触るファイル・食い違い | + +1 つのコンテキストに収まる。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 設計の結果 | 設計の結果の値オブジェクトのモジュール(`plugins/ndf/scripts/lib/design_results.py` の `DesignResults.add`) | 設計の結果(`DesignResults`) | 行(`Row`。課題の番号で識別する) | 扱い・取り込み先・触るファイル・設計文書のパス | + +設計の結果のステージ(`supervise_lib/design_stage.py`)は集約を読むだけで、行を足すのは `DesignResults.add` だけである。この変更は集約の境界を変えない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 設計の結果 | 1 つの課題の行は 1 つだけである。同じ課題の行の扱い・取り込み先・触るファイルのどれかが違えば、行を足さずに `DesignResultsError` で止まる | 今の振る舞い。この変更でも保つ | +| I2 | 設計の結果 | 触るファイルは順序を問わずに比べる。並びだけが違う 2 行は食い違いにしない | 今の振る舞い。この変更でも保つ | +| I3 | 設計の結果 | 食い違いのメッセージは、違う項目をすべて、扱い・取り込み先・触るファイルの順で載せ、同じ値の項目は載せない | テストが落ちる | +| I4 | 設計の結果 | 食い違いのメッセージの値は、先に読んだ設計文書の値、後に読んだ設計文書の値の順で並び、2 本のパスも同じ順で載る | テストが落ちる | +| I5 | 設計の結果 | 食い違いで止まったとき、実装のプランと `design-results.json` を書かない | 今の振る舞い。この変更でも保つ | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 設計の課題ごとに、マージ済みの設計 PR の設計文書から設計の結果の行を読んだ | 設計の結果のステージ(`read_results`) | 設計の結果(`DesignResults.add`) | +| E2 | すでに合わせた行の中に、同じ課題の行が見つかった | 設計の結果(`DesignResults.add`) | 同じ集約の中の比較 | +| E3 | 2 つの行の項目を比べ、違う項目を見つけた | 設計の結果(`DesignResults.add`) | 食い違いのメッセージを組む関数 | +| E4 | 違う項目・両方の値・両方の設計文書のパスを載せたメッセージで止まった | 設計の結果(`DesignResultsError`) | 設計の結果のステージ(`cmd_design_results`)。結果を `stopped`・終了コード 2 で返し、conductor が人へ戻す | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 食い違い | 同じ課題について、2 本の設計文書が違う設計の結果(扱い・取り込み先・触るファイルのどれか)を書いていること。設計の結果のステージは食い違いを見つけると止まり、人へ戻す | 追加(`ndf-workflow`) | + +扱い・取り込み先・触るファイル・設計の結果は、要求の工程で用語集へ足した意味のまま使う。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 食い違いで止まったとき、違う項目・両方の値・2 本の設計文書のパスをメッセージで示す | conductor(結果の `summary` を人へ渡す)と、設計文書を直す人 | + +## 構成要素 + +| 要素 | 責務 | 変更 | +| --- | --- | --- | +| `DesignResults.add`(`plugins/ndf/scripts/lib/design_results.py`) | 行を足し、同じ課題の行が違えば `DesignResultsError` を投げる | 投げるときのメッセージを、下の食い違いのメッセージを組む関数で作る。比べる条件は変えない | +| 食い違いのメッセージを組む関数(同じファイルに新設。モジュールの外へ公開しない) | 先の行と後の行を受け、違う項目ごとの文を「入出力の契約」の形で組んで返す。比べるのは扱い・取り込み先・触るファイルの 3 つ | 新設 | +| `cmd_design_results`(`plugins/ndf/scripts/supervise_lib/design_stage.py`) | `DesignResultsError` を受け、`設計の結果を書けない: <メッセージ>。人へ戻す` の `stopped`・終了コード 2 で返す | 変えない | +| テスト(`plugins/ndf/scripts/tests/test_design_results_rewrite.py`) | 受け入れ条件 1〜7 と不変条件 I1〜I5 を縛る | 足す | + +```mermaid +graph TD + CMD[cmd_design_results
設計の結果のステージ] --> READ[read_results
設計文書を読む] + READ --> ADD[DesignResults.add
行を足す] + ADD --> DIFF[食い違いの
メッセージを組む関数] + ADD -.DesignResultsError.-> CMD +``` + +### システム構成 + +動く場所と外部の系は変わらない。設計の結果のステージは conductor が打つ `supervise.py design-results` の中で動き、設計文書を origin の基点のブランチから `git` で、設計 PR を `gh` で読む。 + +```mermaid +graph TD + C[conductor] -->|supervise.py design-results| S[設計の結果のステージ] + S -->|gh / git| O[GitHub の origin] + S -->|stopped と summary| C +``` + +### パッケージ構成 + +``` +plugins/ndf/scripts/ +├── lib/ +│ └── design_results.py # 変える(DesignResults.add と、食い違いのメッセージを組む関数) +├── supervise_lib/ +│ └── design_stage.py # 変えない +└── tests/ + └── test_design_results_rewrite.py # テストを足す +``` + +## 構造 + +型は変えない。`Row` の項目(`issue`・`kind`・`host`・`files`・`doc`)も `DesignResultsError` の型もそのままで、`DesignResults.add` が投げるメッセージの中身だけが変わる。 + +```mermaid +classDiagram + class DesignResults { + rows: list~Row~ + add(rows) + } + class Row { + issue: int + kind: str + host: int | None + files: list~str~ + doc: str + } + class DesignResultsError + DesignResults "1" o-- "*" Row + DesignResults ..> DesignResultsError : 食い違いで投げる +``` + +## 入出力の契約 + +**結果の JSON の形・状態・終了コードは変えない。** 変わるのは、食い違いで止まったときの `summary` の文だけである。`summary` は今と同じく `設計の結果を書けない: ` で始まり `。人へ戻す` で終わる(前後は `cmd_design_results` が付け、変えない)。 + +### 食い違いのメッセージ(`DesignResultsError` の文) + +1 行で組む。 + +```text +#<課題> の設計の結果が 2 本の設計文書で違う(先: <先のパス> / 後: <後のパス> )。<項目の文>。<項目の文>… +``` + +| 部分 | 中身 | +| --- | --- | +| `<課題>` | 食い違った課題の番号 | +| `先` / `後` | `DesignResults.add` で、すでに合わせてあった行が先、新しく足そうとした行が後 | +| `<先のパス>` / `<後のパス>` | 行の設計文書のパス(`Row.doc`)。空なら `パスの記録なし` | +| `<項目の文>` | 違う項目ごとに 1 つ。扱い → 取り込み先 → 触るファイルの順。同じ値の項目は出さない | + +項目の文は、値を `先 / 後` の順で並べる。 + +| 項目 | 形 | 値の書き方 | +| --- | --- | --- | +| 扱い | `扱い: <先> / <後>` | `実装する` / `取り込む` / `閉じる` | +| 取り込み先 | `取り込み先: <先> / <後>` | `#<番号>`。取り込み先を持たない行は `—`(設計文書の表と同じ記号) | +| 触るファイル | `触るファイル: <先の個数> 個 / <後の個数> 個(先にだけある: <ファイル>, … / 後にだけある: <ファイル>, … )` | 個数は行の触るファイルの数。括弧の中は、片方にしか無いファイルを、その設計文書に書かれた順で `, ` 区切りに並べる。片方にしか無いファイルが無い側は、その部分(`先にだけある: …` か `後にだけある: …`)を出さない | + +パスとファイルの直後には半角スペースか半角の記号を置き、全角の文字を密着させない。メッセージを開いた画面でパスをそのまま開けるようにするためである。 + +### 例 + +扱いが同じ `実装する` で、触るファイルが違う(受け入れ条件 3): + +```text +設計の結果を書けない: #2336 の設計の結果が 2 本の設計文書で違う(先: issues/issue-2335-design.md / 後: issues/issue-2336-design.md )。触るファイル: 2 個 / 4 個(後にだけある: pkg/c.py, pkg/d.py )。人へ戻す +``` + +扱いが同じ `取り込む` で、取り込み先が違う(受け入れ条件 2): + +```text +設計の結果を書けない: #13 の設計の結果が 2 本の設計文書で違う(先: issues/issue-11-design.md / 後: issues/issue-12-design.md )。取り込み先: #11 / #12。人へ戻す +``` + +扱いが `実装する` と `取り込む` で違う(受け入れ条件 1・5。3 つの項目がすべて違う): + +```text +設計の結果を書けない: #12 の設計の結果が 2 本の設計文書で違う(先: issues/issue-11-design.md / 後: パスの記録なし )。扱い: 実装する / 取り込む。取り込み先: — / #11。触るファイル: 1 個 / 0 個(先にだけある: pkg/a.py )。人へ戻す +``` + +## 処理の流れ + +```mermaid +sequenceDiagram + participant CMD as cmd_design_results + participant READ as read_results + participant RES as DesignResults + CMD->>READ: 設計の課題を読む + READ->>RES: add(行) + alt 同じ課題の行が無い + RES-->>READ: 行を足す + else 同じ課題の行が同じ(触るファイルは順序を問わない) + RES-->>READ: 何もしない + else 同じ課題の行が違う + RES->>RES: 違う項目ごとの文を組む + RES-->>CMD: DesignResultsError(食い違いのメッセージ) + CMD-->>CMD: stopped・終了コード 2。プランと design-results.json を書かない + end +``` + +## 非機能の実現方式 + +| 大項目 | 条件 | 実現方式 | +| --- | --- | --- | +| 運用・保守性 | 止まった理由がメッセージだけで分かり、原因を探すためにコードを読まなくてよい | 違う項目・両方の値・2 本のパスを、値とパスが同じ `先 / 後` の順で対応する 1 行のメッセージにする(「入出力の契約」)。触るファイルは差だけを出し、読み手が一覧を突き合わせなくてよいようにする | + +## 決定の記録 + +### 決定 1: 結果の要約を他の止まり方とそろえるため、食い違いのメッセージを 1 行で組む + +設計の結果のステージが止まる他の場合(打ち直しても読めない・取り込み先に流れるプランが無い)の `summary` は、どれも 1 行の文である。食い違いだけを複数行にすると、同じステージの要約の中で形が分かれる。項目は `。` で区切り、値は `先 / 後` の順で並べることで、1 行でも項目と値の対応が読める。 + +項目ごとに改行する形は採らない。どの読み手が改行をそのまま見せるかを確かめておらず、1 行の他の要約と形が分かれるためである。 + +根拠: Value 2・Value 6(MVV 版 4) + +### 決定 2: 重なりの数だけの違いも読めるよう、触るファイルの差は重なりを数えて取る + +触るファイルの比較は、並べ替えた一覧どうしで行う(`sorted(files)`)。同じファイルが片方に 2 回書かれていても違いになる。差を集合で取ると、この場合に「先にだけある」も「後にだけある」も空になり、メッセージから違いが読めない。差は重なりを数えて取り(片方で多く書かれた分を、その側にだけあるとして出す)、比較と同じ基準で違いを示す。個数も行の一覧の長さ(重なりを含む)で数える。 + +集合の差は採らない。比較の基準と表示の基準が分かれ、違うと判定したのに差が空のメッセージが出るためである。 + +根拠: Value 6(MVV 版 4) + +### 決定 3: 読み手がパスと取り違えないよう、パスを持たない行は `パスの記録なし` と出す + +行の設計文書のパスが空になるのは、テストなどで行を直接組んだときだけである(設計の結果のステージは読んだ文書のパスを必ず渡す)。空文字のまま出すと `先: / 後: …` となり、どちらがどの値か読めない。`パスの記録なし` はパスの形を持たないため、ファイルの名前と取り違えない。 + +`—` は採らない。取り込み先を持たないことを表す記号として同じメッセージの中で使い、1 つの記号が 2 つの意味になるためである。 + +根拠: Value 8(MVV 版 4) + +### 決定 4: 直す対象をメッセージだけで洗い出せるよう、片方にしか無いファイルは数を切らずにすべて出す + +食い違いを直す人は、片方にしか無いファイルを 1 つずつ見て、どちらの設計文書に合わせるかを決める。一部だけを出して「ほか N 個」と省くと、残りを知るために設計文書を開いて突き合わせることになり、メッセージだけで分かるという目的に届かない。実例は 5 個と 18 個で、片方にしか無いファイルは多くても 23 個に収まる。 + +出す数に上限を設ける形は採らない。上限を超えた分は結局設計文書を読むことになるためである。 + +根拠: Value 2(MVV 版 4) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1 | 扱いが `実装する` と `取り込む` の 2 本を `DesignResults.add` で合わせると、メッセージに `#<課題>`・`扱い: 実装する / 取り込む` が載る | 扱いの文を出さないように壊す・値の順を入れ替えると落ちる | +| 受け入れ条件 2 | 扱いが同じ `取り込む` で取り込み先が違う 2 本では、`取り込み先: #11 / #12` が載り、`扱い:` は載らない | 同じ値の項目も出すように壊すと落ちる | +| 受け入れ条件 3 | 扱いが同じ `実装する` で触るファイルが違う 2 本では、`触るファイル: <先の個数> 個 / <後の個数> 個` と、各設計文書にしか無いファイルが載り、`扱い:` は載らない | 一覧の全体を出す・差の側を取り違えるように壊すと落ちる | +| 受け入れ条件 4 | 1〜3 のメッセージに `先: <先のパス> / 後: <後のパス>` が載る。パスが空の行では `パスの記録なし` が載る | 先と後のパスを入れ替える・空のパスをそのまま出すように壊すと落ちる | +| 受け入れ条件 5・I3 | 3 つの項目がすべて違う 2 本では、3 つの項目の文が扱い → 取り込み先 → 触るファイルの順で載る | 最初に見つけた 1 項目だけを出す・順を変えるように壊すと落ちる | +| 受け入れ条件 6・I2 | 触るファイルの順序だけが違う 2 本と、すべてが同じ 2 本は止まらず、行は 1 つのまま | 並べ替えずに比べるように壊すと落ちる | +| 受け入れ条件 7・I5 | 食い違いを返す読み出しを渡して `cmd_design_results` を通すと、`stopped`・終了コード 2・`summary` が `設計の結果を書けない: ` で始まり `。人へ戻す` で終わり、実装のプランと `design-results.json` が書き換わらない | 食い違いでもプランを書くように壊すと落ちる | +| I1 | 同じ課題の行が違うとき、`DesignResults.add` は行を足さずに `DesignResultsError` を投げる | 後の行で上書きするように壊すと落ちる | +| I4 | 先と後で値もパスも違う 2 本では、メッセージの値とパスがどちらも `先 / 後` の順で並ぶ | 値だけ・パスだけの順を入れ替えるように壊すと落ちる | +| 決定 2 | 同じファイルを片方で 2 回書いた 2 本では、そのファイルが多く書いた側の「にだけある」に載る | 集合の差で取るように壊すと落ちる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2361 | 実装する | — | `plugins/ndf/scripts/lib/design_results.py`、`plugins/ndf/scripts/tests/test_design_results_rewrite.py` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 実例の差の中身 | 本文の実例(`issues/issue-2335-design.md` は 5 個、`issues/issue-2336-design.md` は 18 個)のファイルの一覧は、PR https://github.com/devbasex/ai-plugins/pull/2360 でそろえた後のため、そろえる前の差を再現していない。メッセージの形はテストの入力で確かめる | +| 要約を見せる画面の折り返し | 片方にしか無いファイルが多いとき、1 行の `summary` を conductor の報告や進捗記録がどう折り返すかは確かめていない。決定 4 のとおり数を切らない | From 3df033947f9eb5511e25e138e0a76eacc0cdc06a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:52:08 +0000 Subject: [PATCH 012/127] =?UTF-8?q?Docs:=20=E8=A8=AD=E8=A8=88=E3=81=AE?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AE=E9=A3=9F=E3=81=84=E9=81=95=E3=81=84?= =?UTF-8?q?=E3=81=AE=E3=83=A1=E3=83=83=E3=82=BB=E3=83=BC=E3=82=B8=E3=81=AE?= =?UTF-8?q?=E8=A8=AD=E8=A8=88=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4=E3=81=99?= =?UTF-8?q?=EF=BC=88#2361=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内容を足さずに、目安の 100 字を超える 3 文を分け、文脈を持たない読み手が 迷った語(開発ループの呼び名・「合わせた行」・上限に読める実例の数)を言い換えた。 章の並びは設計文書のテンプレートどおりのため動かしていない。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2361-design.md | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/issues/issue-2361-design.md b/issues/issue-2361-design.md index 488231c06..97fdf65e5 100644 --- a/issues/issue-2361-design.md +++ b/issues/issue-2361-design.md @@ -2,7 +2,7 @@ ## 目的 -- **今どうなっているか**: 同じ課題に 2 本の設計文書が違う設計の結果を書いていると、`supervise.py design-results` は `#2336 に違う扱いが書かれている(実装する と 実装する)` で止まる。比べるのは扱い・取り込み先・触るファイルの 3 つなのに、メッセージには扱いしか出ない。実例では違っていたのは触るファイル(5 個と 18 個)で、原因を探すのにコードを読んだ +- **今どうなっているか**: 同じ課題に 2 本の設計文書が違う設計の結果を書いていると、`supervise.py design-results` が止まる。そのときのメッセージは `#2336 に違う扱いが書かれている(実装する と 実装する)` である。比べるのは扱い・取り込み先・触るファイルの 3 つなのに、メッセージには扱いしか出ない。実例では違っていたのは触るファイル(5 個と 18 個)で、原因を探すのにコードを読んだ - **誰に効くか**: 開発ループで止まった工程を人へ戻す conductor と、戻されて設計文書を直す人 - **変えると何が成り立つか**: メッセージに違う項目・両方の値・2 本の設計文書のパスが載り、どの設計文書のどの項目をそろえればよいかがメッセージだけで分かる。止まるかどうかの判定と結果の形は変わらない @@ -16,7 +16,7 @@ | 根拠 | 種類 | 何を示すか | | --- | --- | --- | -| #2361 の本文「依頼(原文)」のメッセージ `設計の結果を書けない: #2336 に違う扱いが書かれている(実装する と 実装する)。人へ戻す` と、実際の違いが触るファイル(5 個と 18 個)だったという記録 | 実測(開発ループ lroot の周回 2 で出たメッセージ) | 扱いだけを出すメッセージでは、違いが扱い以外のとき何も読めない | +| #2361 の本文「依頼(原文)」のメッセージ `設計の結果を書けない: #2336 に違う扱いが書かれている(実装する と 実装する)。人へ戻す` と、実際の違いが触るファイル(5 個と 18 個)だったという記録 | 実測(開発ループの周回 2 で出たメッセージ) | 扱いだけを出すメッセージでは、違いが扱い以外のとき何も読めない | | `plugins/ndf/scripts/lib/design_results.py:64` の比較 `(old.kind, old.host, sorted(old.files)) != (r.kind, r.host, sorted(r.files))` と、`:65` のメッセージ `f"#{r.issue} に違う扱いが書かれている({old.kind} と {r.kind})"` | 一次情報(コード) | 比べる項目は 3 つ、メッセージに出すのは 1 つ | 要求と受け入れ条件は #2361 の本文にある(コピーは `issues/issue-2361-requirements.md`)。この文書は「どう作るか」だけを扱う。 @@ -54,7 +54,7 @@ | # | イベント | 発生元 | 受け手 | | --- | --- | --- | --- | | E1 | 設計の課題ごとに、マージ済みの設計 PR の設計文書から設計の結果の行を読んだ | 設計の結果のステージ(`read_results`) | 設計の結果(`DesignResults.add`) | -| E2 | すでに合わせた行の中に、同じ課題の行が見つかった | 設計の結果(`DesignResults.add`) | 同じ集約の中の比較 | +| E2 | すでに設計の結果に足した行の中に、同じ課題の行が見つかった | 設計の結果(`DesignResults.add`) | 同じ集約の中の比較 | | E3 | 2 つの行の項目を比べ、違う項目を見つけた | 設計の結果(`DesignResults.add`) | 食い違いのメッセージを組む関数 | | E4 | 違う項目・両方の値・両方の設計文書のパスを載せたメッセージで止まった | 設計の結果(`DesignResultsError`) | 設計の結果のステージ(`cmd_design_results`)。結果を `stopped`・終了コード 2 で返し、conductor が人へ戻す | @@ -91,7 +91,7 @@ graph TD ### システム構成 -動く場所と外部の系は変わらない。設計の結果のステージは conductor が打つ `supervise.py design-results` の中で動き、設計文書を origin の基点のブランチから `git` で、設計 PR を `gh` で読む。 +動く場所と外部の系は変わらない。設計の結果のステージは conductor が打つ `supervise.py design-results` の中で動く。設計文書は origin の基点のブランチから `git` で、設計 PR は `gh` で読む。 ```mermaid graph TD @@ -114,7 +114,7 @@ plugins/ndf/scripts/ ## 構造 -型は変えない。`Row` の項目(`issue`・`kind`・`host`・`files`・`doc`)も `DesignResultsError` の型もそのままで、`DesignResults.add` が投げるメッセージの中身だけが変わる。 +型は変えない。`Row` の項目(`issue`・`kind`・`host`・`files`・`doc`)も `DesignResultsError` の型もそのままである。変わるのは、`DesignResults.add` が投げるメッセージの中身だけである。 ```mermaid classDiagram @@ -149,7 +149,7 @@ classDiagram | 部分 | 中身 | | --- | --- | | `<課題>` | 食い違った課題の番号 | -| `先` / `後` | `DesignResults.add` で、すでに合わせてあった行が先、新しく足そうとした行が後 | +| `先` / `後` | `DesignResults.add` で、すでに設計の結果に足してあった行が先、新しく足そうとした行が後 | | `<先のパス>` / `<後のパス>` | 行の設計文書のパス(`Row.doc`)。空なら `パスの記録なし` | | `<項目の文>` | 違う項目ごとに 1 つ。扱い → 取り込み先 → 触るファイルの順。同じ値の項目は出さない | @@ -237,7 +237,7 @@ sequenceDiagram ### 決定 4: 直す対象をメッセージだけで洗い出せるよう、片方にしか無いファイルは数を切らずにすべて出す -食い違いを直す人は、片方にしか無いファイルを 1 つずつ見て、どちらの設計文書に合わせるかを決める。一部だけを出して「ほか N 個」と省くと、残りを知るために設計文書を開いて突き合わせることになり、メッセージだけで分かるという目的に届かない。実例は 5 個と 18 個で、片方にしか無いファイルは多くても 23 個に収まる。 +食い違いを直す人は、片方にしか無いファイルを 1 つずつ見て、どちらの設計文書に合わせるかを決める。一部だけを出して「ほか N 個」と省くと、残りを知るために設計文書を開いて突き合わせることになり、メッセージだけで分かるという目的に届かない。実例は 5 個と 18 個で、この実例では片方にしか無いファイルは多くても 23 個である。 出す数に上限を設ける形は採らない。上限を超えた分は結局設計文書を読むことになるためである。 From a9214b73d7c89bcbb214248bfacc084a7571455d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:52:31 +0000 Subject: [PATCH 013/127] =?UTF-8?q?Docs:=20#2337=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=EF=BC=88=E5=AE=9F=E8=A1=8C=E3=81=AE=E6=A0=B8=E3=81=A8?= =?UTF-8?q?=E7=B5=90=E6=9E=9C=E3=81=AE=E5=A5=91=E7=B4=84=EF=BC=89=E3=82=92?= =?UTF-8?q?=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit エントリポイントがどの終わり方でも ok / gate / stopped と決まった終了コードで 返り、worker・外部 CLI・フェーズ・課題を閉じる処理の成否を結果 JSON と 結果ファイルだけから読む設計。決定の記録は別のファイルに分けた。 用語集に 4 語を足し、2 語の意味を変えた。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 8 +- docs/glossary/glossary.json | 38 ++- issues/issue-2337-design-decisions.md | 109 +++++++ issues/issue-2337-design.md | 414 ++++++++++++++++++++++++++ 4 files changed, 563 insertions(+), 6 deletions(-) create mode 100644 issues/issue-2337-design-decisions.md create mode 100644 issues/issue-2337-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 303a0773f..a44643c29 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -305,8 +305,8 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 依存グループ | — | lib/deps.py の GROUPS と pyproject.toml の extra に並ぶ、外部パッケージの用途ごとのまとまり(procs・locks など)。deps.require へ名前で渡す | — | — | `docs/specifications/ndf-deps-require-groups-check.md` | | 未宣言のグループ | — | モジュールから import をたどって届く外部パッケージの依存グループのうち、そのモジュールの中で覆う deps.require が無いもの。そのモジュールを読む側が引き受ける | — | — | `docs/specifications/ndf-deps-require-groups-check.md` | | 任意の依存 | — | 無ければ代わりの手段へ落ちる import。except ImportError / ModuleNotFoundError で受けるか、同じ関数の中で find_spec / deps.importable で確かめ、確かめが成り立ったときだけ通る枝で読む | — | — | `docs/specifications/ndf-deps-require-groups-check.md` | -| 作業の報告 | `work_report` | worker が応答の最後に書く「## 作業の報告」の節。「結果:」は 完了 / 判断が要る / できなかった のどれかで、supervise はこれでステップの成否を決める | — | — | `docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md` | -| 起こし直し | `resume_prompt` | supervise の full のステップで、作業の報告が無いまま応答が終わったとき、同じ会話(--resume)へ続けるよう送ること。3 回まで | — | — | `docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md` | +| 作業の報告 | `work_report` | worker が応答の最後に書く「## 作業の報告」の節。人と conductor が読む。成否は作業の結果ファイルで決め、この節の文からは決めない(#2337) | — | — | `docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md` | +| 起こし直し | `resume_prompt` | supervise の full のステップで、作業の結果ファイルが無いまま応答が終わったとき、同じ会話(--resume)へ続けるよう送ること。3 回まで(#2337) | — | — | `docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md` | | 所要の記録 | — | `.ndf/project.json` の `test_duration.measured` にある全体テストの所要(秒。w)。出所は ndf-record → ci-junit → ci-steps の順に採る。宣言の解析が書き、プランの作成と test-run.py が読む | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | | 全体テストの実測 | — | 同じリポジトリで先に走った全体テストの壁時計の秒。所要の記録とは別に残し、後のプランと test-run.py が予算なしのテストの上限を決めるときに読む(#1631) | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | | テストの上限 | `limits` | test_strategy.limits が返す init_test_timeout・test_timeout・whole_timeout(秒)。ステップの timeout はこれに with_margin の余裕を足した値で、テストの上限には余裕を足さない | — | — | `docs/specifications/ndf-supervise-test-limits-from-whole-runs.md` | @@ -375,6 +375,10 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 作業の識別子 | — | 複数のセッションを 1 つの作業として結ぶ鍵。起動の引数かスプリントに含まれる課題番号の組(#829 #830) | — | — | — | | 費用の見込み | — | 判断の帯で、続けた場合と再起動した場合の次の工程までの費用を、記録から較正した中央値で比べた値 | — | — | — | | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | — | +| 本体 | — | エントリポイントの仕事の中身を持つ関数。実行の核が呼び、結果 JSON か終了コードを返す(#2337) | — | — | — | +| 核の対象外 | — | 実行の核を通さないエントリポイントの印。hook の定義から起動されるか、ランタイムが直接起動して標準出力をそのまま使うもの。契約の一覧の core に理由と根拠を書く(#2337) | — | — | — | +| 作業の結果ファイル | — | worker が報告を終える前に書く、結果 JSON の形の 1 行のファイル。supervise と supervisor はこのファイルの status だけで作業の成否を決める(#2337) | — | — | — | +| フェーズの結果 | — | supervise の 1 つのプランの実行の終わり方を結果 JSON の形で書いたファイル(状態ディレクトリの phase-result.json)。supervise.py run の終了コードはこの status から決まる(#2337) | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 830e193df..974e90dd7 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -4462,16 +4462,18 @@ { "term": "作業の報告", "context": "ndf-workflow", - "meaning": "worker が応答の最後に書く「## 作業の報告」の節。「結果:」は 完了 / 判断が要る / できなかった のどれかで、supervise はこれでステップの成否を決める", + "meaning": "worker が応答の最後に書く「## 作業の報告」の節。人と conductor が読む。成否は作業の結果ファイルで決め、この節の文からは決めない(#2337)", "code": "work_report", - "source": "docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md" + "source": "docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md", + "pending_source": "issues/issue-2337-design.md" }, { "term": "起こし直し", "context": "ndf-workflow", - "meaning": "supervise の full のステップで、作業の報告が無いまま応答が終わったとき、同じ会話(--resume)へ続けるよう送ること。3 回まで", + "meaning": "supervise の full のステップで、作業の結果ファイルが無いまま応答が終わったとき、同じ会話(--resume)へ続けるよう送ること。3 回まで(#2337)", "code": "resume_prompt", - "source": "docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md" + "source": "docs/specifications/ndf-supervise-work-step-web-tools-and-report-stop.md", + "pending_source": "issues/issue-2337-design.md" }, { "term": "所要の記録", @@ -5241,6 +5243,34 @@ "context": "ndf-workflow", "meaning": "conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで", "pending_source": "issues/issue-2262-design.md" + }, + { + "term": "本体", + "context": "ndf-workflow", + "meaning": "エントリポイントの仕事の中身を持つ関数。実行の核が呼び、結果 JSON か終了コードを返す(#2337)", + "source": "", + "pending_source": "issues/issue-2337-design.md" + }, + { + "term": "核の対象外", + "context": "ndf-workflow", + "meaning": "実行の核を通さないエントリポイントの印。hook の定義から起動されるか、ランタイムが直接起動して標準出力をそのまま使うもの。契約の一覧の core に理由と根拠を書く(#2337)", + "source": "", + "pending_source": "issues/issue-2337-design.md" + }, + { + "term": "作業の結果ファイル", + "context": "ndf-workflow", + "meaning": "worker が報告を終える前に書く、結果 JSON の形の 1 行のファイル。supervise と supervisor はこのファイルの status だけで作業の成否を決める(#2337)", + "source": "", + "pending_source": "issues/issue-2337-design.md" + }, + { + "term": "フェーズの結果", + "context": "ndf-workflow", + "meaning": "supervise の 1 つのプランの実行の終わり方を結果 JSON の形で書いたファイル(状態ディレクトリの phase-result.json)。supervise.py run の終了コードはこの status から決まる(#2337)", + "source": "", + "pending_source": "issues/issue-2337-design.md" } ] } diff --git a/issues/issue-2337-design-decisions.md b/issues/issue-2337-design-decisions.md new file mode 100644 index 000000000..4a9851eb2 --- /dev/null +++ b/issues/issue-2337-design-decisions.md @@ -0,0 +1,109 @@ +# #2337 の設計の決定の記録 + +設計文書の本体は [issue-2337-design.md](issue-2337-design.md) にある。決定はどれも、#2335 の設計(`issues/issue-2335-design.md` と `issues/issue-2335-design-decisions.md`)の決定の範囲で、柱 P1 の形を決める。 + +## 決定の記録 + +### 決定 1: 64 項目の本体とテストを柱ごとに書き直さないよう、柱 P1 では既存の `main(argv)` をそのまま本体として核へ渡す + +#2335 の設計は本体を `body(ctx) -> Outcome` と書く。この形へ移すには、引数の解決(`argparse`)を本体から外す必要があり、それは柱 P8(入力の部品)の仕事である。P1 で `ctx` の形へ書き換えると、P8 でもう一度、64 項目の本体と、`main([...])` を呼ぶ既存のテストを書き換えることになる。P1 は `run_core.main("<契約の path>", main)` で既存の `main` を渡し、実行の文脈(実行の識別子・期限・契約)は `run_core.context()` で引けるようにする。核を通ったかの検査(`__main__` の中身が `run_core.main` の 1 文)は #2335 の決定 1 のまま成り立つ。 + +本体を `body(ctx)` にそろえてから移す形は採らない。P8 の前に引数の解決の置き場を決めることになり、柱の順序を崩す。 + +根拠: Value 6 / Value 10(MVV 版 4) + +### 決定 2: 46 項目の標準出力を柱 P7 の前に変えないよう、標準出力が結果 JSON でないエントリポイントの結果は `NDF_RESULT_FILE` が指すファイルへ書く + +契約の `stdout` が `step-result` でない 46 項目(`text` 34・`json` 5・`none` 5・`kv` 2)の標準出力は、呼ぶ側の Skill の本文・プラン・利用者の端末がそのまま読む。核が標準出力へ結果 JSON を足すと、`| tail -1` や `KEY=VALUE` の読み手が壊れる。核は、呼ぶ側が環境変数 `NDF_RESULT_FILE` でファイルを渡したときだけ、最終の結果 JSON をそこへ 1 行で書く。結果を読みたい呼ぶ側(supervise の run のステップ・テスト)は、ファイルを渡して読む。 + +標準エラーへ結果 JSON を出す形は採らない。`2>&1` で受けている呼ぶ側の出力が変わり、読み手が最後の行を結果と読み違える。終了コードだけを返す形も採らない。`summary` と `gaps` が届かず、運用・保守性の条件(止まった理由を結果 JSON だけで読める)を満たさない。 + +**核は本体を呼ぶ前に `NDF_RESULT_FILE` を自分の環境から外す。** 継ぐと、本体が起こした子のエントリポイントが親の結果ファイルを上書きする。 + +根拠: Value 2 / Value 6(MVV 版 4) + +### 決定 3: 本体の中の `step_result.emit` を書き換えずに核へ結果を渡すため、核が動いている間の `emit` は `Emitted` を投げる + +契約の `stdout` が `step-result` の Python のエントリポイントは、結果を `step_result.emit` で出し、`SystemExit` で終わる。核がこの出力を確かめる(`ok` に欠けが無いか・契約に無い終了コードでないか)には、出す前に受け取る必要がある。`emit` は、核が動いているときだけ標準出力へ書かずに `Emitted`(`SystemExit` の子クラス。結果と終了コードを持つ)を投げ、核が確かめてから出す。核が動いていないとき(テストが関数を直に呼ぶとき)は今と同じに出す。 + +標準出力を受け取って最後の行を読み直す形は、Python のエントリポイントでは採らない。本体が流す途中の出力と結果の行の区別が、本体ごとの書き方に依存する。シェルのエントリポイントは子プロセスであるため、この形しか取れない(設計文書の「シェルのエントリポイント」)。 + +`Emitted` を `SystemExit` の子クラスにするのは、本体の `except Exception` が結果を握りつぶさないためである。 + +根拠: Value 6(MVV 版 4) + +### 決定 4: 終了コードの意味を検査で確かめられるよう、終了コードの表に見出しを持たせ、契約の `exit` はその見出しで始め、エントリポイントは独自の `EXIT_` 定数を持たない + +`handoff.py` は 4 を「一致する本体が複数」、5 を「行数の上限を超える」の意味で返す(#2241)。表では 4 は「ベースブランチと衝突」、5 は表に無い。契約の `exit` は自由文のため、食い違いを機械で見つけられない。`step_result` に番号ごとの見出し(`EXIT_LABELS`)を持たせ、検査(`exit-meaning`)は 2 つを落とす。契約の `exit` の値が見出しで始まらない項目と、エントリポイントとその部品が `EXIT_` で始まる整数の定数を自分で持つことである。実行時には、核が契約の `exit` の鍵に無い終了コードを `stopped`・1 に変える(I3)。 + +意味ごとに新しい番号を表へ足す形は採らない。受け入れ条件 18 が表の意味を変えることを許さず、呼ぶ側の読み方(`code_matches`)を広げることになる。`handoff.py` の 2 つは、利用者へ聞く場面を承認ゲートの 10、上限を超える場面を違反の 1 へ寄せる。 + +根拠: Value 6 / Value 8(MVV 版 4) + +### 決定 5: 核の対象外を印で決めて検査できるよう、契約の一覧の `core` に `hook` か `runtime` と根拠を書き、エントリポイント単位で外す + +前提 4 の対象外は、hook の定義から起動されるものと、ランタイムが直接起動して標準出力をそのまま使うものである。今の 77 項目のうち 13 項目が当たる(設計文書の「核の対象外」)。`hook` は根拠をファイルから確かめられる(hook の定義にパスが現れる・`callers` に `hook` の印を持つ項目がある)ため、検査(`core-exempt`)が根拠の無い印を落とす。`runtime` は根拠をファイルから確かめられないため、印の数がベースブランチより増えたら落とし、増やす変更を承認ゲートで人が見る。 + +hook からも利用者からも呼ばれる 3 項目(`training-optout.py`・`statusline-switch.sh`・`relay.py`)は、副命令ごとには分けず、エントリポイント単位で外す。副命令ごとに外すと、`__main__` の 1 文の検査が副命令の分岐まで見ることになり、#2335 の決定 1(構文木で 1 文を確かめる)の範囲を超える。利用者向けの副命令の結果の形は柱 P7 がそろえる。 + +根拠: Value 4 / Value 6(MVV 版 4) + +### 決定 6: 契約と例外リストを 1 か所で扱うため、検査の種類 `core-entry`・`core-exempt`・`exit-meaning`・`result-read` を `check-ndf-contracts.py` に足す + +要求の前提 2 は、`core-entry` を構造チェックの種類と書く。#2335 の構成要素の表は置き場を `check-script-structure.py` としたが、柱 P0 は契約の一覧の検査と例外リスト(`scripts/contract-allow/`)と増加の判定を `check-ndf-contracts.py` に置いた。核の対象外の印・契約の `exit`・エントリポイントの一覧はどれも契約の一覧が持つため、同じ検査に足せば読み込みと例外リストと増加の判定を共有できる。 + +増加の判定(`contracts_lib/ratchet.py`)は今、ベースブランチに無い行をすべて増加として数える。新しい種類を入れる Pull Request はその種類の行を初めて置くため、ベースブランチの検査(`check-ndf-contracts.py`)が知らない種類の行を数えないようにする(#2335 の I11 の「ベースブランチにあった検査の種類について」)。 + +`check-script-structure.py` に置く形は採らない。契約の一覧の読み手と例外リストが 2 つの検査に分かれ、同じ役割の部品を 2 つ持つことになる。 + +根拠: Value 6(MVV 版 4) + +### 決定 7: worker の成否を文の読み違いなく決めるため、worker は作業の結果ファイルを結果 JSON の形で書き、supervise はそのファイルだけを読む + +今は supervise が報告の文の「結果: 完了」を正規表現で照らす(`REPORT_DONE`・`REPORT_NOT_DONE`)。文の形はどこでも確かめられず、欠けを持つ置き場も無い。要求の子の課題の表は、#1682・#1699(残した作業があっても完了へ進む)を受け入れ条件 9 で解消しうると見込む。worker は報告を終える前に `step_result.py emit --out <置き場>` でファイルを書く。CLI が形を確かめ、違えば終了コード 2 で拒むため、worker は形の違う結果を残せない。supervise は `step_read.read_result_file` で読み、`ok` のときだけ成功にする。外部 CLI を worker に使うステップも、`ndf:worker` のエージェントも、同じファイルを書く。 + +報告の末尾に機械用のブロック(`` の JSON)を置く形は採らない。応答の文から取り出す規則が要り、文を読む箇所が残る。#2335 の I15 は機械用のブロックを課題と PR の本文の値に使い、その検出の規則は柱 P7 が決める。worker の結果をそれに乗せると、P1 が P7 の規則を先に決めることになる。claude -p の構造化出力の機能に頼る形も採らない。外部 CLI(codex・agy・kiro)の worker には同じ機能が無く、ランタイムごとに読み方が分かれる。 + +人が読む「## 作業の報告」の節は残す。conductor と人が経緯を読む置き場であり、成否の判定には使わない。 + +根拠: Value 4 / Value 5(MVV 版 4) + +### 決定 8: 課題を閉じる処理の後の棚卸しを止めずに人へ知らせるため、閉じなかった課題があれば `gate`・10 を返し、失敗があるときだけ `stopped`・1 を返す + +閉じなかった理由は、条件 2(リリース後テストの行)や条件 3(振り返りの記録)を満たさないことで、処理の失敗ではない。満たすかどうかは工程を通すか人が確かめて手で閉じるかで決まり、人の判断が要る。プランの規則では 10〜19 は失敗にならず `next`(棚卸し)へ進み、フェーズの結果が承認ゲートになる。conductor と人は承認ゲートとして閉じなかった課題と理由を読む。`failed`(状態を読めない・閉じても CLOSED にならない)は処理の失敗のため、今と同じ `stopped`・1 で止める。 + +閉じなかった課題を `stopped`・6 にする形は採らない。プランが棚卸しの前で止まり、別のリポジトリの課題(#2327)のように工程を通しても満たせない課題があるたびに、まとめのプランが進まなくなる。プランの順を「振り返り → 課題を閉じる」から変える必要は無い。今のテンプレート(`supervise_lib/sprint_close_steps.py`)は既に振り返りの後に閉じる。 + +根拠: Value 2 / Value 7(MVV 版 4) + +### 決定 9: 時間切れと SIGTERM を耐久のエントリポイントでも同じ形で扱うため、核は主スレッドで受けて結果を出し、耐久の記録を開いていれば DBOS を閉じずに抜ける + +DBOS 3.1.0 の耐久ワークフローを動かしたまま、主スレッドで `signal.setitimer` の時間切れと SIGTERM を受けた(2026-11-02 の実測。設計文書の「あるべき姿の根拠」)。どちらも主スレッドの待ちが 0.3 秒以内に中断し、DBOS の内部のスレッドとの干渉は無かった。中断の後に DBOS を閉じる(`durable.close`)と、動いていたステップのスレッドが閉じたデータベースへ書こうとして例外の跡を 2 件出した。閉じずに `durable.exit_leaving_pending` で抜けると 0 件で、ワークフローは途中のまま残り、打ち直しで続く。核は結果を出した後、`durable` が読まれて耐久の記録が開いているときだけこの出口を使う。 + +時間切れを別スレッドの監視で切る形は採らない。Python は主スレッド以外から主スレッドの処理を中断できず、本体の外から切れない。 + +根拠: Value 3(MVV 版 4) + +### 決定 10: 柱 P4 の前に時間切れを確かめられるよう、核は時間切れの秒を環境変数 `NDF_RUN_TIMEOUT` から受け、子へ継がない + +受け入れ条件 1 は時間切れの終わり方を求めるが、上限の宣言(`contracts/limits.json`)は柱 P4 が作る。P1 の核は、呼ぶ側が `NDF_RUN_TIMEOUT` を渡したときだけ期限を置く。supervise の run のステップはステップの `timeout` をこの値で渡す。核は本体を呼ぶ前にこの値を外し、子のエントリポイントが親と同じ秒を自分の期限にしない。P4 は値の出所を上限の宣言へ移し、この環境変数を上書きの経路として残すかを決める。 + +P1 で上限の宣言を先に作る形は採らない。P4 の範囲(方針の名前・再試行の回数・同じ原因の上限)を P1 が部分的に決めることになる。 + +根拠: Value 6 / Value 10(MVV 版 4) + +### 決定 11: 依存の欠けで止まるときも結果 JSON を返すため、`deps` の止まりは核の早い出口(`run_core.exit_early`)を通す + +`deps.require` はモジュールの最上位で呼ばれ、`run_core.main` に届く前に止まる。今は標準エラーへ 1 行を出して 69 で終わり、結果 JSON を出さない。`deps` の止まりを `run_core.exit_early` に通し、契約の `stdout` が `step-result` なら標準出力へ、`NDF_RESULT_FILE` があればそのファイルへ、`stopped`・69 と `gaps`(`kind` は `deps`、`name` はグループ)を書く。核は標準ライブラリだけで書くため、extras が無い環境でも読める。 + +`deps` に結果の組み立てを持たせる形は採らない。結果の出し方(標準出力か結果ファイルか)を決める規則が核と `deps` の 2 か所に分かれる。 + +根拠: Value 6(MVV 版 4) + +### 決定 12: 結果の読み方を共通の読み手へ寄せたことを検査できるよう、最後の行を JSON として読む書き方を共通の読み手の外で禁じる + +ほかのエントリポイントの結果 JSON を読む箇所は、子プロセスの標準出力の最後の行を `json.loads` で読む(`supervise_lib/steps.py` の `last_json`・`release-steps.py`・`lib/sprint_loop_record.py`)。GitHub の CLI の出力を読む箇所は、出力の全体を読む。検査(`result-read`)は、`lib/step_read.py` の外で、名前に `last_json` を含む関数の定義と、`json.loads(<式>.splitlines()[-1])` の形を落とす。今の箇所は例外リストに置き、移す Pull Request が `step_read.read_completed` へ置き換えて行を消す。 + +呼び出し先のパスから NDF のエントリポイントを呼ぶ箇所を見分ける形は採らない。パスは `paths` の定数・`HERE /` の式・文字列の組み立てで作られ、構文木から決まらない箇所が残る。 + +根拠: Value 6(MVV 版 4) diff --git a/issues/issue-2337-design.md b/issues/issue-2337-design.md new file mode 100644 index 000000000..c17f28133 --- /dev/null +++ b/issues/issue-2337-design.md @@ -0,0 +1,414 @@ +# NDF のエントリポイント: どの終わり方でも ok / gate / stopped と決まった終了コードで返り、欠けと失敗を ok と読まない(#2337) + +## 目的 + +- **今どうなっているか**: エントリポイントは終わり方ごとの扱いをそれぞれ書く。例外・SIGTERM・時間切れでは結果 JSON が出ない。supervise は worker の完了を報告の文の「結果: 完了」の有無で、フェーズの成否を報告の文で、worker の止まりを進捗の文の語で決める。課題を閉じる処理は閉じられなかった課題を残したまま `ok`・終了コード 0 を返す +- **誰に効くか**: NDF でスプリントを回す利用者と conductor。要求の「子の課題」の表は、21 件のうち 16 件をこの柱の受け入れ条件で解消しうると見込む(残り 5 件は核の外の可能性) +- **変えると何が成り立つか**: エントリポイントは実行の核を通り、核が終わり方を結果と終了コードへ変える。成否は結果 JSON と結果ファイルの `status` だけから読む。核を通らないエントリポイントと、表と違う意味の終了コードを検査が落とすため、新しいエントリポイントでも同じ型の不具合が生まれない + +## 適用範囲 + +- **働く範囲**: NDF の配布物のエントリポイント(契約の一覧の 77 項目から、hook とランタイムが直接起動する 13 項目を除いた 64 項目)と、supervise・外部 CLI の起動・課題を閉じる処理の読み手。配布先のリポジトリでも同じ核が動く。hook・`factory/`・ほかのプラグインは含まない +- **プロジェクトごとに違うもの**: 無い。核はプロジェクトの設定を読まない。時間切れの秒は呼ぶ側が環境変数 `NDF_RUN_TIMEOUT` で渡す(柱 P4 が上限の宣言へ移す) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」(利用者、2026-11-01) | 利用者の指示の原文 | 終わり方の扱いを 1 か所へ集め、型ごとに無くすこと | +| #2335 の設計(`issues/issue-2335-design.md`)の決定 1・2・11 と不変条件 I1・I2・I16 | 承認ゲート 1 を通った親の設計 | 核の形(`run_core.main` の 1 文・シェルは核の子として起こし直す)と、P1 がすべてのエントリポイントを移すこと | +| DBOS 3.1.0 の耐久ワークフローを動かしたまま、主スレッドで `signal.setitimer` の時間切れと SIGTERM を受けた実測(2026-11-02、`/work/ai-plugins/.venv/bin/python`、Python 3.13.17)。どちらも 2.15〜2.28 秒で主スレッドの待ちが中断した。続けて DBOS を閉じると、動いていたステップのスレッドの例外の跡が標準エラーへ 2 件出た。閉じずに `durable.exit_leaving_pending` で抜けると 0 件だった | 実測 | 耐久のエントリポイントでも時間切れの仕組みは使え、中断した後は DBOS を閉じずに抜けること(決定 9) | +| 契約の一覧の `stdout` の内訳: `step-result` 31・`text` 34・`json` 5・`none` 5・`kv` 2(2026-11-02 に `plugins/ndf/contracts/entrypoints/*.json` を数えた) | 実測 | 46 項目の標準出力を変えずに結果を返す経路が要ること(決定 2) | + +要求と受け入れ条件は #2337 の本文にある(コピーは [issue-2337-requirements.md](issue-2337-requirements.md) )。この文書は「どう作るか」だけを扱う。決定の記録は [issue-2337-design-decisions.md](issue-2337-design-decisions.md) にある。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | 実行の核・本体・結果 JSON・欠け・核の対象外・作業の結果ファイル・フェーズの結果・作業の報告 | +| NDF の外部 CLI 委譲(`ndf-agent-cli`) | 結果ファイル・起動結果(語の意味は変えない) | + +2 つの間は **公開された言語** でやり取りする。外部 CLI の起動(`external-ai.py run`)は結果 JSON の形(`lib/step_result.py`)で起動の終わり方を返し、supervise は共通の読み手(`lib/step_read.py`)だけでそれを読む。外部 CLI の結果ファイルの中身(自由文)は、成否の判定に使わない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 実行 | 実行の核(`lib/run_core.py`) | 実行の識別子(`run_id`) | — | 結果 JSON・終了コード・終わり方 | +| 契約の一覧 | 柱と各エントリポイントを変える Pull Request(核と検査は読むだけ) | `plugins/ndf/contracts/` | エントリポイントの契約 | 終了コードの表・核の対象外の印 | +| 作業の結果 | worker(書く)。supervise と supervisor は読むだけ | 作業の結果ファイル | — | 結果 JSON | +| フェーズの結果 | supervise のフェーズの実行(`supervise_lib/engine.py`) | フェーズの状態ディレクトリ | ステップの記録 | 結果 JSON | +| 閉じる判定 | 課題を閉じる処理(`sprint-close.py`) | 1 回の呼び出し | 課題ごとの項目 | 閉じた・閉じなかった理由 | + +集約の間は識別子とファイルの置き場で参照する。実行は契約をエントリポイントの id(契約の `path`)で引く。フェーズの結果はステップごとの作業の結果をファイルの置き場で読む。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 実行 | 結果の `status` は `ok` / `gate` / `stopped` のどれかで、終了コードは `step_result.code_matches` を満たす。例外・SIGTERM・時間切れは `stopped`・1 で、`summary` に理由、`gaps` に `interrupted`(SIGTERM)か `timeout`(時間切れ)か `exception`(例外)を持つ | 核が本体の結果を捨て、`stopped` を返す | +| I2 | 実行 | `ok` を返すのは `gaps` が空のときだけ | `stopped`・6。本体の `gaps` はそのまま残す | +| I3 | 実行 | 本体の結果は結果 JSON の形で、`status` と終了コードが食い違わず、終了コードは契約の `exit` の鍵に含まれる | `stopped`・1。`summary` に違いの中身 | +| I4 | 契約の一覧 | 核の対象外の印を持たないエントリポイントは、Python なら `if __name__ == "__main__":` の中身が `run_core.main("<契約の path>", <本体>)` の 1 文、シェルなら先頭の実行文が `rc_enter "<契約の path>" "$@"` | 構造チェック(`core-entry`)が名前を出して落とす | +| I5 | 契約の一覧 | 核の対象外の印は、hook の定義か、ランタイムが直接起動する根拠を持つエントリポイントにだけ付く | 構造チェック(`core-exempt`)が落とす | +| I6 | 契約の一覧 | 構造チェックの例外リストの `core-entry`・`exit-meaning`・`result-read` の行は、ベースブランチより増えない | 構造チェック(`allow-added`)が落とす | +| I7 | 契約の一覧 | 契約の `exit` の各値は、`step_result` の終了コードの表のその番号の見出しで始まる。エントリポイントとその部品は `EXIT_` で始まる整数の定数を自分で持たない | 構造チェック(`exit-meaning`)が落とす | +| I8 | 実行 | optional extras が無い環境では、例外の跡を出さず `stopped`・69 と足りないグループの名前を返す | `deps` の止まりが核の早い出口を通る | +| I9 | 実行 | 核は、本体が結果を出さずに正常に終えたとき標準出力へ何も足さない。結果ファイルの環境変数(`NDF_RESULT_FILE`)を子プロセスへ継がない | 契約の標準出力の形を照らすテストが落とす | +| I10 | 作業の結果 | work のステップを成功と読むのは、作業の結果ファイルが結果 JSON の形で `status` が `ok` のときだけ。無い・読めない・形が違えば `stopped` | ステップを失敗にする(`on_fail` か止まる) | +| I11 | フェーズの結果 | `supervise.py run` の終了コードはフェーズの結果の `status` から決まる(`ok` 0・`gate` 10・`stopped` 1) | テストが落とす | +| I12 | 実行 | 外部 CLI の起動を `ok` と読むのは、監視が終わりを記録し、結果ファイルが空でなく読めるときだけ。進捗の文の語で止まりを決めない | `stopped` と理由 | +| I13 | 実行 | ほかのエントリポイントの結果 JSON を読んで次の手を決める箇所は共通の読み手を通し、`ok` でない結果を成功と読まない | 構造チェック(`result-read`)が落とす | +| I14 | 閉じる判定 | 閉じる対象のうち閉じなかった課題(`kept_open`・`closed_unverified`・`failed`)が 1 件でもあれば `ok` を返さない。参照しているだけの課題は状態に影響しない | `failed` があれば `stopped`・1、無ければ `gate`・10 | + +### ドメインイベント + +要求の E1〜E9 を引き継ぐ。 + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | エントリポイントが核を呼んだ | 呼ぶ側(Skill の本文・プラン・conductor・利用者) | 実行の核 | +| E2 | 核が本体を呼んだ | 実行の核 | 本体 | +| E3 | 本体が結果を返した、または例外・SIGTERM・時間切れで中断した | 本体・シグナル | 実行の核 | +| E4 | 核が結果の形・欠け・状態と終了コードの一致を確かめた | 実行の核 | 実行の核(E5) | +| E5 | 核が結果 JSON と終了コードを返した | 実行の核 | 呼ぶ側 | +| E6 | worker が作業の結果ファイルを書いて報告を終えた | worker | supervise・supervisor | +| E7 | supervise が作業の結果・外部 CLI の起動結果・フェーズの結果の状態から次の手を決めた | supervise | プランの次のステップ・conductor | +| E8 | 課題を閉じる処理が、閉じる対象の課題ごとに閉じる条件を判定した | 課題を閉じる処理 | プランの次のステップ(棚卸し)・conductor | +| E9 | 子の課題の再現の手順を通し、閉じる・残すを本文へ記録した | リリース後テスト | 子の課題の本文 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 本体 | エントリポイントの仕事の中身を持つ関数。実行の核が呼び、結果 JSON か終了コードを返す | 追加(`ndf-workflow`) | +| 核の対象外 | 実行の核を通さないエントリポイントの印。hook の定義から起動されるか、ランタイムが直接起動して標準出力をそのまま使うもの。契約の一覧の `core` に理由と根拠を書く | 追加(`ndf-workflow`) | +| 作業の結果ファイル | worker が報告を終える前に書く、結果 JSON の形の 1 行のファイル。supervise と supervisor はこのファイルの `status` だけで作業の成否を決める | 追加(`ndf-workflow`) | +| フェーズの結果 | supervise の 1 つのプランの実行の終わり方を結果 JSON の形で書いたファイル(状態ディレクトリの `phase-result.json`) | 追加(`ndf-workflow`) | +| 作業の報告 | worker が応答の最後に書く「## 作業の報告」の節。人と conductor が読む。成否は作業の結果ファイルで決め、この節の文からは決めない | 意味の変更(`ndf-workflow`) | +| 起こし直し | supervise の full のステップで、作業の結果ファイルが無いまま応答が終わったとき、同じ会話(`--resume`)へ続けるよう送ること。3 回まで | 意味の変更(`ndf-workflow`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | エントリポイントを核の上で動かし、どの終わり方でも 3 つの状態と決まった終了コードで返す | すべての呼ぶ側 | +| F2 | 標準出力が結果 JSON でないエントリポイントの結果を、標準出力を変えずに結果ファイルへ返す(柱 P7 まで) | supervise・テスト・conductor | +| F3 | 核を通らないエントリポイント・表と違う意味の終了コード・共通の読み手を通らない結果の読みを検査で落とす | CI・柱の Pull Request | +| F4 | worker の作業の成否を作業の結果ファイルから決める | supervise・supervisor | +| F5 | プランの実行の成否をフェーズの結果から決め、`supervise.py run` の終了コードにする | conductor・キュー | +| F6 | 外部 CLI の完了を監視ログと結果ファイルから決め、進捗の文の語を読まない | supervise・cross-review・cross-refactoring | +| F7 | 課題を閉じる処理が、閉じなかった課題を理由つきで承認ゲートとして返す | スプリントのまとめのプラン・conductor | +| F8 | optional extras が無い環境で、依存の欠けを結果として返す | すべての呼ぶ側 | + +## 構成要素 + +| 要素 | 責務 | 新設 / 変更 | 機能 | +| --- | --- | --- | --- | +| 実行の核(`plugins/ndf/scripts/lib/run_core.py`) | `main(entry_id, body)` で契約を引き、シグナルと時間切れの受け手を置いて本体を呼び、結果か中断を結果 JSON と終了コードへ変える。`exec` 副命令でシェルの本体を子として動かす。`exit_early` で核に入る前の止まりを結果にする。標準ライブラリだけで書く | 新設 | F1・F2・F8 | +| シェルの起こし直し(`plugins/ndf/scripts/lib/run-core.sh`) | `rc_enter "" "$@"`。`NDF_RUN_ENTRY` が自分の id でなければ `exec python3 run_core.py exec -- "$0" "$@"` で起こし直す | 新設 | F1 | +| 結果の形(`lib/step_result.py`) | 核が動いている間の `emit` を、出力せずに核へ渡す。終了コードの表の見出し(`EXIT_LABELS`)を持つ。任意の鍵 `run_id` を足す。`emit` の CLI に `--out <パス>`(同じ 1 行をファイルへも書く)を足す | 変更 | F1・F3・F4 | +| 共通の読み手(`lib/step_read.py`) | `read_result_file(path)`(作業の結果ファイルと結果ファイルの読み)と `read_completed(completed)`(子プロセスの結果)を足す。無い・読めない・形が違うを `stopped` の理由として返す | 変更 | F4・F5・F6 | +| 依存の確かめ(`lib/deps.py`) | 止まるときは `run_core.exit_early` を通し、`stopped`・69 と足りないグループを返す | 変更 | F8 | +| 契約の一覧(`plugins/ndf/contracts/entrypoints/*.json`) | 13 項目に `core`(核の対象外の印)を足す。`exit` の値を表の見出しで始める。核へ移した項目の `env` に核が読む環境変数(`NDF_RUN_ENTRY`・`NDF_RUN_TIMEOUT`・`NDF_RESULT_FILE`)を足す | 変更 | F3 | +| 契約の型(`scripts/contracts_lib/model.py`) | `core` の鍵(`exempt`: `hook` / `runtime`、`evidence`: 文)を読み、型を確かめる | 変更 | F3 | +| 契約の検査(`scripts/check-ndf-contracts.py`) | 検査の種類 `core-entry`・`core-exempt`・`exit-meaning`・`result-read` を足す | 変更 | F3 | +| 増加の判定(`scripts/contracts_lib/ratchet.py`) | ベースブランチの検査が知らない種類の行を、増加として数えない | 変更 | F3 | +| 例外リスト(`scripts/contract-allow/`) | 核へ移していない項目の行を持ち、移した Pull Request が消す | 変更 | F3 | +| 起動指示(`supervise_lib/prompts.py`) | worker へ作業の結果ファイルの置き場と書き方を渡す。進捗の行に `state` を書かせる。報告の文を照らす正規表現を消す | 変更 | F4・F6 | +| work のステップ(`supervise_lib/worker_steps.py`) | 作業の結果ファイルを消してから起動し、読んで成否を決める。無ければ起こし直す | 変更 | F4 | +| 進捗の分類(`supervise_lib/state.py`) | worker の進捗の行を `state` の値と同じ文の繰り返しで分け、文の語を読まない | 変更 | F6 | +| フェーズの実行(`supervise_lib/engine.py`・`commands.py`・`steps.py`) | 終わり方をフェーズの結果へ書き、`supervise.py run` の終了コードにする。`steps.last_json` を共通の読み手へ寄せる | 変更 | F5 | +| エージェントの定義(`plugins/ndf/agents/worker.md`・`supervisor.md`・`supervisor-waits.md`) | worker は渡された置き場へ作業の結果ファイルを書く。supervisor は置き場を渡し、成否をそのファイルで決める | 変更 | F4 | +| 外部 CLI の起動(`skills/external-ai/scripts/external-ai.py`) | `ok` の条件を「監視が終わりを記録し、結果ファイルが空でなく読める」にそろえる | 変更 | F6 | +| 課題を閉じる処理(`plugins/ndf/scripts/sprint-close.py`) | 閉じなかった課題があれば `gate`・10(失敗があれば `stopped`・1)。呼び出しの誤りを 2 にする | 変更 | F7 | +| 64 のエントリポイント | 末尾を核の 1 文にする。独自の `EXIT_` 定数を表の定数へ置き換える(`handoff.py` の 4・5 ほか) | 変更 | F1・F3 | +| 文書(`skills/progress-tracking/SKILL.md`・`development-workflow/references/`・`plugins/ndf/scripts/lib/README.md`・用語集) | 終了コードが変わる箇所と、作業の結果ファイルの書き方を直す | 変更 | F4・F5・F7 | + +```mermaid +graph TD + subgraph 呼ぶ側 + C[Skill の本文・プラン・conductor] + SV[supervise] + end + subgraph 実行の核 + K[run_core.py] + SH[run-core.sh] + SR[step_result.py] + DP[deps.py] + end + subgraph エントリポイント + PY[Python の本体] + SB[シェルの本体] + end + subgraph 読み手 + RD[step_read.py] + WR[作業の結果ファイル] + PR[フェーズの結果] + end + subgraph 検査 + CK[check-ndf-contracts.py] + CT[契約の一覧] + end + C --> PY + C --> SB + SB --> SH + SH --> K + PY --> K + DP --> K + K --> SR + K --> CT + SV --> RD + RD --> WR + RD --> PR + CK --> CT + CK --> PY + CK --> SB +``` + +構成要素の図に描かない要素: 起動指示・work のステップ・進捗の分類・フェーズの実行(supervise の中)、エージェントの定義(supervise の外で supervisor が読む)、外部 CLI の起動と課題を閉じる処理(Python の本体の 1 つ)、契約の型・増加の判定・例外リスト(検査の部品)、文書。 + +### 文脈と配置 + +```mermaid +graph TD + U[利用者・conductor] --> N[NDF の配布物] + N --> X[外部 CLI: codex・agy・kiro・claude] + N --> G[GitHub: 課題・Pull Request] + R[ランタイムの hook と statusLine] --> E[核の対象外のエントリポイント] +``` + +配置は #2335 の設計の「配置」と同じで、核は利用者の端末か CI の `python3` の中で、エントリポイントと同じプロセスで動く。シェルのエントリポイントだけは核のプロセスの子になる。作業の結果ファイルとフェーズの結果は、プランの状態ディレクトリ(`<プラン>-state/`)に置く。 + +### 核の対象外 + +| エントリポイント | 印 | 根拠 | +| --- | --- | --- | +| `scripts/hook.py`・`scripts/ensure-retention.sh`・`scripts/worktree-session.sh`・`scripts/usage-subagent.py` | `hook` | `plugins/ndf/hooks/claude.json`・`codex.json` が起動する | +| `scripts/hook-env.py`・`scripts/wait-notify.py` | `hook` | hook のエントリポイント(`worktree-session.sh`・`hook_lib/wait_notify.py`)が起動する | +| `scripts/token-guard.sh`・`scripts/worktree-guard.sh` | `hook` | Kiro CLI・agy の hook の定義が直に結ぶ古いエントリポイント(各ファイルの先頭の説明) | +| `skills/development-workflow/scripts/workflow-guard.sh` | `hook` | `development-workflow` の frontmatter が hook として登録する | +| `scripts/statusline.sh` | `runtime` | ランタイムの statusLine が直接起動し、標準出力を表示に使う | +| `scripts/statusline-switch.sh`・`scripts/training-optout.py` | `hook` | `hooks/claude.json`(`statusline-switch.sh ensure`)・`hooks/*.json`(`training-optout.py session-start`)が起動する | +| `scripts/relay.py` | `hook` | `hooks/claude.json` が起動する(`mark`・`notice` ほか)。ランタイムの起動を包むラッパーでもあり、子の端末の入出力をそのまま流す | + +`hook` の印は、hook の定義のファイル(`plugins/ndf/hooks/*.json`・Skill の frontmatter の `hooks`・`dev.kiro/`・`dev.agy/` の hook の定義)にパスが現れるか、`callers` に `hook` の印を持つエントリポイントがあるときだけ付けられる。`runtime` の印は `evidence` に理由を書き、印の数がベースブランチより増えたら検査が落とす(I5)。 + +## 構造 + +```mermaid +classDiagram + class run_core { + +main(entry_id, body) + +exec(entry_id, argv) + +exit_early(summary, code, gaps) + +context() + } + class RunContext { + +entry_id + +run_id + +argv + +deadline + +stdout_kind + +exit_codes + } + class Emitted { + +result + +code + } + run_core --> "1" RunContext : 作る + run_core ..> Emitted : 受け取る + run_core ..> 契約の一覧 : 引く + run_core ..> step_result : 確かめる +``` + +- **本体の形**: `body(argv: list[str]) -> int | None`。既存の `main(argv=None)` をそのまま渡す。本体は今と同じく `step_result.emit` で結果を出してよい。核が動いている間の `emit` は標準出力へ書かずに `Emitted`(`SystemExit` の子クラス)を投げ、核が受け取って確かめてから出す。実行の文脈は `run_core.context()` で引く(決定 1) +- **エントリポイントの形**: Python は `if __name__ == "__main__": run_core.main("<契約の path>", main)` の 1 文。副命令は本体の中の `argparse` が今のまま分ける。シェルは先頭で `. "$(dirname "$0")//run-core.sh"; rc_enter "<契約の path>" "$@"` +- **核が受け取る値の読み替え**: `Emitted` は本体の結果 JSON と終了コード。`SystemExit`(`argparse` の `--help` と引数の誤りを含む)はその `code` を終了コードにし、`None` は 0 とする。戻り値は、契約の `stdout` が `step-result` でなければ `None` を 0、整数をその終了コードとする。`step-result` の本体が結果 JSON を出さずに返ったとき(戻り値が `None` か整数)と、戻り値が整数と `None` 以外のときは、I3 で `stopped`・1 にする +- **終了コードから状態を決める規則**: 結果 JSON が無い終わり方では、0 を `ok`、10〜29 を `gate`、表の `stopped` の値(1・2・3・4・6・69・75)を `stopped` とし、それ以外は I3 で `stopped`・1 にする + +## 入出力の契約 + +**エントリポイントごとの約束の正本は契約の一覧である。** この節は、核がすべてのエントリポイントに課す共通の約束だけを書く。 + +### 核の出力 + +| 契約の `stdout` | 本体が結果 JSON を出して終えた | 本体が結果 JSON を出さずに終えた | 例外・SIGTERM・時間切れ・形の違い | +| --- | --- | --- | --- | +| `step-result` | 確かめた結果を標準出力へ 1 行(`run_id` を足す)。違反なら置き換えた `stopped` の結果 | 標準出力へ何も足さない | 核の `stopped` の結果を標準出力へ 1 行 | +| それ以外(`text`・`json`・`kv`・`none`) | (出さない形) | 標準出力へ何も足さない | 標準出力へ何も足さず、理由を標準エラーへ 1 行 | + +**どの場合も、`NDF_RESULT_FILE` が渡されていれば、核は最終の結果 JSON をそのファイルへ 1 行で書く。** 核は本体を呼ぶ前に `NDF_RESULT_FILE` と `NDF_RUN_TIMEOUT` を自分のプロセスの環境変数から外し、子プロセスへ継がない(I9)。 + +### 核が返す止まり + +| 終わり方 | `status` | 終了コード | `gaps` の `kind` | `summary` | +| --- | --- | --- | --- | --- | +| 例外 | `stopped` | 1 | `exception` | 例外の型と文(秘密を伏せる `secret_redact` を通す)。例外の跡は標準エラーへ出す | +| SIGTERM | `stopped` | 1 | `interrupted` | `SIGTERM で中断した` | +| 時間切れ(`NDF_RUN_TIMEOUT` 秒) | `stopped` | 1 | `timeout` | `<秒> 秒の上限で中断した` | +| `ok` に `gaps` | `stopped` | 6 | 本体の `gaps` のまま | 本体の `summary` | +| 形の違い・状態と終了コードの食い違い・契約に無い終了コード・`step-result` の本体が結果 JSON を出さずに返った | `stopped` | 1 | `result-shape` | 違いの中身 | +| 契約に無いエントリポイント | `stopped` | 2 | `contract` | `契約の一覧に無い: ` | +| optional extras が無い | `stopped` | 69 | `deps`(`name` にグループ) | `deps` の止まりの文 | + +**耐久の記録を開いたエントリポイントが中断したら、核は結果を出した後に DBOS を閉じず `durable.exit_leaving_pending` で抜ける**(決定 9)。耐久のワークフローは途中のまま残り、`--from` か同じコマンドの打ち直しで続く。核は `durable` を import せず、`sys.modules` に読まれているときだけ `launched()` を見る(起動の増分を増やさない)。 + +### シェルのエントリポイント + +`run_core.py exec -- <スクリプト> <引数>` は、スクリプトを `NDF_RUN_ENTRY=` を付けた子として起動する。契約の `stdout` が `step-result` なら標準出力を受け取り、最後の行以外はそのまま流し、最後の行を本体の結果として確かめる。それ以外なら標準出力を受け取らずに流す。SIGTERM と時間切れは子のプロセスグループへ SIGTERM を送り、5 秒待って SIGKILL を送る。 + +### 作業の結果ファイル + +| 項目 | 約束 | +| --- | --- | +| 置き場 | supervise は `<プランの状態ディレクトリ>/<ステップの id>-result.json` を起動指示の「作業の結果ファイル」の行で渡し、起動の前に消す。supervisor は `ndf:worker` へ同じ行で置き場を渡す | +| 書き方 | worker は報告を終える前に `python3 /step_result.py emit --tool worker --status <状態> --summary <要約> [--gap <種類> <名前> <理由>] [--next <理由>] --out <置き場>` を打つ。形が違えば CLI が終了コード 2 で拒み、worker は直して打ち直す | +| 状態 | 完了は `ok`、判断が要るは `gate`(`--next` に理由)、できなかったは `stopped`(`--gap` に理由)。人が読む「## 作業の報告」の節は今のまま残す | +| 読み方 | supervise は `step_read.read_result_file` で読み、`ok` のときだけステップを成功にする。無い・読めない・形が違えば `stopped`(I10)。外部 CLI を worker に使うステップも同じで、`external-ai.py run` の結果が `ok` で、かつ作業の結果ファイルが `ok` のときだけ成功 | + +### 進捗の行 + +worker が進捗のファイルへ追記する行に `state`(`working` / `failing` / `stopped` / `gate`)を足す。supervise は `state` の値と、同じ文の 3 回の繰り返しだけで conductor へ知らせるかを決め、`text` の語を照らさない。`state` が無い行は `working` として読む(古い起動指示の worker の行で止まらない)。 + +### フェーズの結果と `supervise.py run` + +フェーズの実行は、報告(`report.md`)と同じ時点で `phase-result.json` を書く。`status` は報告の結果が完了なら `ok`、承認ゲートなら `gate`(`next` に承認ゲートの理由)、止まったなら `stopped`(`gaps` に止まった理由)。`supervise.py run` の標準出力は今と同じ報告の文で、終了コードはフェーズの結果の `status` から決まる(`ok` 0・`gate` 10・`stopped` 1)。 + +### 課題を閉じる処理の結果 + +| 閉じる対象の課題の内訳 | `status` | 終了コード | `next` | +| --- | --- | --- | --- | +| すべて `closed` か `already_closed`(`--dry-run` では `would_close`) | `ok` | 0 | — | +| `failed` が 1 件以上 | `stopped` | 1 | 今と同じ(失敗した課題の `cmd` でやり直す) | +| `failed` が無く、`kept_open` か `closed_unverified` が 1 件以上 | `gate` | 10 | 閉じなかった課題と理由。条件を満たす工程を通してから打ち直すか、確かめて手で閉じる | +| `--record-pr unknown` | `gate` | 10 | 今と同じ(記録を確かめて手で閉じる) | + +閉じなかった課題は、`gaps` に 1 件ずつ(`kind` は `issue`、`name` は `<リポジトリ>#<番号>`、`reason` は課題の項目の理由)載せる。参照しているだけの課題(`kept_open` のうちスプリントの PR が番号で参照しただけのもの)は `items` に残し、`gaps` と状態に入れない。呼び出しの誤りの終了コードは 3 から 2 にする(表の「呼び出しの誤り」)。プランの並び(振り返り → 課題を閉じる → 棚卸し)は変えない。承認ゲートの 10 はプランの規則どおり失敗にならず、棚卸しへ進んでフェーズの結果が承認ゲートになる。 + +### 終了コードが変わるエントリポイント + +| エントリポイント | 場面 | 今 | 変更後 | +| --- | --- | --- | --- | +| `sprint-close.py` | 閉じなかった課題がある・記録の置き場が分からない | 0 | 10 | +| `sprint-close.py` | 呼び出しの誤り | 3 | 2 | +| `supervise.py run` | フェーズが承認ゲートで終わった | 0 | 10 | +| `supervise.py run` | フェーズが止まった | 3 | 1 | +| `handoff.py` | 一致する本体が 2 本以上 | 4 | 10(承認ゲート。利用者へ聞く) | +| `handoff.py` | 本体が上限の行数を超える | 5 | 1 | +| 64 のエントリポイントすべて | 例外・SIGTERM・時間切れ | Python の既定の 1・-15 など | 1 | + +ほかに `exit-meaning` の検査が見つけた食い違いは、移す Pull Request ごとに本文へこの表の形で書き、承認ゲート 2 で人が見る。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant C as 呼ぶ側 + participant K as 実行の核 + participant B as 本体 + participant F as 結果ファイル + C->>K: エントリポイントを起動(E1) + K->>K: 契約を引く・受け手と期限を置く + alt 契約に無い + K-->>C: stopped・2 + end + K->>B: 本体を呼ぶ(E2) + alt 返った・emit した + B-->>K: 終了コードか Emitted(E3) + K->>K: 形・欠け・状態と終了コード・契約の exit を確かめる(E4) + else 例外・SIGTERM・時間切れ + B-->>K: 中断(E3) + K->>K: stopped・1 と理由を組む + end + K->>F: NDF_RESULT_FILE があれば 1 行書く(E5) + K-->>C: 標準出力(契約の stdout の規則)と終了コード(E5) + opt 耐久の記録を開いていて中断した + K->>K: DBOS を閉じずに抜ける + end +``` + +処理の流れの図に描かない要素: シェルの起こし直し(`rc_enter` が `exec` で起こし直した後は同じ流れ)、依存の確かめ(本体より前に止まるときは `exit_early` が E5 だけを行う)、結果の形(核の確かめの中)、契約の一覧・契約の型・契約の検査・増加の判定・例外リスト(CI で動く)、64 のエントリポイント(本体として描く)。 + +```mermaid +sequenceDiagram + participant S as supervise + participant W as worker + participant R as 作業の結果ファイル + S->>R: 前の試行のファイルを消す + S->>W: 起動指示(置き場を渡す) + W->>R: step_result.py emit --out(E6) + W-->>S: 応答(作業の報告の節) + S->>R: step_read.read_result_file + alt ファイルが無い(full のステップ) + S->>W: 起こし直し(3 回まで) + end + alt status が ok + S->>S: ステップを成功にして next へ(E7) + else gate・stopped・無い・読めない + S->>S: ステップを失敗にして on_fail か止まる(E7) + end +``` + +2 つ目の図に描かない要素: 起動指示(supervise が組む起動指示の文)、共通の読み手(supervise の読みの中)、進捗の分類(worker の進捗の行を読む。成否を決めない)、フェーズの実行(E7 の後に終わり方を書く。「フェーズの結果と `supervise.py run`」)、エージェントの定義(supervisor が `ndf:worker` を起動するときの同じ流れ)、外部 CLI の起動(worker を外部 CLI で動かすときの起動の中身)、課題を閉じる処理(プランの run のステップの 1 つ)、文書。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 移したエントリポイントの `--help` の中央値の差が 150 ms 以下 | 核は標準ライブラリと `step_result` だけを読む(#2335 の実測で `step_result` の import は 35 ms)。契約は自分の 1 ファイルだけを `json` で読む。`durable` は読まない | 移す Pull Request ごとに、移したエントリポイントの `--help` を前後で 10 回ずつ測り、中央値の差を本文に書く | +| 運用・保守性 | `stopped` の結果は、どの終わり方でも `summary` に理由を持つ | 「核が返す止まり」の表の `summary` と `gaps` を核が必ず埋める | 終わり方ごとのテストで `summary` が空でないことを見る | +| 移行性 | 部品ごとに移す。移していないものは例外リストに載ったまま動く。部品ごとの Pull Request を 1 回 revert すれば戻る | 核と検査を入れる Pull Request で、64 項目すべての `core-entry` の行を例外リストに置く。移す Pull Request は、エントリポイントの末尾・独自の終了コード・例外リストの行・呼ぶ側の文書を同じ差分で変える | 各 Pull Request で全体テストと構造チェックを前後で通す | +| システム環境 | Claude Code と Codex の両方で同じ核。シェルは bash。新しい依存を足さない | 核と `run-core.sh` は配布物の `plugins/ndf/scripts/lib/` に置く。核は標準ライブラリだけで書く | optional extras を外した環境でエントリポイントを起動するテスト(受け入れ条件 8) | + +## 決定の記録 + +[issue-2337-design-decisions.md](issue-2337-design-decisions.md) に置く。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 1・I1 | 本体が返す・例外を投げる・SIGTERM を受ける・時間切れになる、の 4 つで、結果の `status` が 3 つのどれかで終了コードが `code_matches` を満たす。Python とシェルの両方で見る | SIGTERM の受け手を外す/例外を握りつぶして 0 で返す/シェルの子へ SIGTERM を送らない | +| 2・I1 | 例外・SIGTERM・時間切れのとき `stopped`、`summary` が空でなく、`gaps` に `exception`・`interrupted`・`timeout` | `gaps` を付けずに返す/時間切れを SIGTERM と同じ `kind` にする | +| 3・I2 | 本体が `gaps` のある `ok` を出すと `stopped`・6 | 欠けの確かめを外す | +| 4・I3 | `step-result` の本体が結果 JSON を出さずに `None` を返す・形の違う結果を出す・整数と `None` 以外を返す・`status` と終了コードが食い違う・契約に無い終了コードを返すと `stopped`・1。`--help` は今と同じ 0 で終わる | 形の確かめを `emit` の 2 のまま通す/契約の `exit` を見ない/`SystemExit(0)` を結果の欠けと読む | +| 5 | 契約の一覧の核の対象外を除くすべてのエントリポイントの id で、欠けのある結果を出す本体と例外を投げる本体を核に渡して回し、`ok` が 1 度も出ない(シェルは同じ 2 つの振る舞いをするスクリプトを `exec` で回す) | 契約の `stdout` が `text` のときに `ok` を返す | +| 6・I4 | `__main__` の中身が `run_core.main` の 1 文でない Python、先頭で `rc_enter` を呼ばないシェル、契約の `path` と違う id を、構造チェックが名前を出して落とす | 1 本の末尾を `sys.exit(main())` に戻す/id の文字列を別のパスにする | +| 7・I6 | `core-entry` の例外リストに 1 行足した差分を落とす。ベースブランチの検査が知らない種類の行は数えない。課題を閉じる時点で `core-entry` の行が 0 | 増加の判定を種類で分けない | +| 8・I8 | extras を入れない環境で、extras を要るエントリポイントを起動すると、標準エラーに例外の跡が無く、`stopped`・69 で `gaps` にグループの名前(契約の `stdout` が `step-result` なら標準出力の結果 JSON、それ以外は結果ファイル) | `deps` の止まりを `exit_early` を通さず `sys.exit` に戻す/核の最上位で pydantic を import する | +| 9・I10 | 作業の結果ファイルが `ok` のときだけステップが成功する。報告の文に「結果: 完了」があってもファイルが無ければ失敗、文が無くてもファイルが `ok` なら成功。full のステップでファイルが無ければ起こし直す | 報告の文を照らす判定を残す/前の試行のファイルを消さずに読む | +| 10・I11 | フェーズが完了・承認ゲート・止まったで、`supervise.py run` の終了コードが 0・10・1 になり、`phase-result.json` の `status` と一致する | 報告の文で終了コードを決める | +| 11・I12 | 監視が終わりを記録しても結果ファイルが無い・空・読めなければ `ok` にならない。進捗の行の `text` に「止まった」を書いても `state` が `working` なら知らせない | `PROGRESS_STOP` の照合を残す/結果ファイルの有無を見ない | +| 12・I13 | 子のエントリポイントが `gate` か `stopped` を返すと読み手は成功と読まない。最後の行を JSON として読む箇所と `last_json` の定義が共通の読み手の外にあると構造チェック(`result-read`)が落とす | 読み手の外に `json.loads(out.splitlines()[-1])` を書く | +| 13・I7 | 契約の `exit` の値が表の見出しで始まらない項目、エントリポイントとその部品が持つ `EXIT_` の整数の定数を、構造チェック(`exit-meaning`)が落とす | `handoff.py` に `EXIT_FOUND_MANY = 4` を戻す | +| 14・I14 | 閉じる対象に `kept_open` か `closed_unverified` があれば `gate`・10、`failed` があれば `stopped`・1、参照だけの課題は状態を変えない。`gaps` に課題ごとの理由 | 参照だけの課題を数える/`kept_open` を `ok` で返す | +| 15 | 閉じる条件 2・3 を満たさない課題を閉じない(`test_sprint_close_final_stage.py`・`test_sprint_close_acceptance.py` の 38 件が通り、終了コードの期待だけを 14 に合わせる) | 条件 3 の判定を外す | +| 16 | (手動・リリース後テスト)子の課題 21 件ごとに再現の手順を通し、閉じた・残した(理由)を #2337 の本文に書く | — | +| 17 | 核が出す結果の鍵は、既存の鍵と任意の `run_id` だけ | 既存の鍵を消す | +| 18 | `step_result` の終了コードの値と `code_matches` が今と同じ | 表の値を変える | +| 19・I9 | 契約の `stdout` が `step-result` でないエントリポイントで、本体が正常に終えたとき標準出力が核を通す前と同じバイト列。子プロセスに `NDF_RESULT_FILE` が見えない | 核が `text` のエントリポイントの標準出力へ結果 JSON を足す/環境変数を継ぐ | +| 20 | 各 Pull Request のマージの前後で `uv run --frozen --project . --all-extras pytest . -q -n 4` が通る | — | +| I5 | `hook` の根拠の無い項目に `core.exempt: hook` を付けると落ちる。`runtime` の印をベースブランチより増やすと落ちる | 印の根拠を見ない | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2337 | 実装する | — | `plugins/ndf/scripts/lib/`、`plugins/ndf/scripts/supervise_lib/`、`plugins/ndf/scripts/sprint-close.py`、`plugins/ndf/scripts/sprint_close_lib/`、`plugins/ndf/scripts/handoff.py`、`plugins/ndf/scripts/`、`plugins/ndf/skills/`、`plugins/ndf/agents/`、`plugins/ndf/contracts/`、`plugins/ndf/scripts/tests/`、`scripts/check-ndf-contracts.py`、`scripts/contracts_lib/`、`scripts/contract-allow/`、`scripts/tests/`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +子の課題 21 件は表に載せない。受け入れ条件 16 の再現の手順で閉じるか残すかを決めるためである。 + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 移すスプリントの数 | 1 スプリントは核の Pull Request と、部品ごとに移す Pull Request 3 本までで作る。64 項目が何スプリントで移るかは実装計画が決める(要求の未決のまま) | +| 増分 150 ms の実物 | 核の import は部品の実測から見積もった。移した後の `--help` の差は移す Pull Request ごとに測る | +| 子プロセスの後片付け | Python の本体が `subprocess` で起こした子は、SIGTERM と時間切れのときに核が止めない(シェルの子は止める)。孫のプロセスが残るかは柱 P3(持ち主の記録と後片付け)が扱う | +| 監視ログの終了コード | 外部 CLI の監視ログ(`-monitor.json`)が CLI の終了コードを持つかは確かめていない。持たなければ、`ok` の条件は監視の状態と結果ファイルの 2 つで判定する | +| `exit-meaning` が見つける食い違いの数 | 契約の `exit` の値を表の見出しへ書き直したときに、意味の食い違う項目がいくつ出るかは数えていない。移す Pull Request が見つけ、「終了コードが変わるエントリポイント」の形で本文へ書く | From 921063b43bee719d7ae1d09c2856b4e1823a0408 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 07:57:16 +0000 Subject: [PATCH 014/127] =?UTF-8?q?Docs:=20#2337=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E8=AA=AD=E3=81=BF=E6=89=8B?= =?UTF-8?q?=E3=81=AE=E5=95=8F=E3=81=86=E9=A0=86=E3=81=AB=E7=B5=84=E3=81=BF?= =?UTF-8?q?=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内容は足さず、次を行った。 - 決定の記録の各決定で、結論の文を段落の先頭へ移し、長い文を分けた - 図に描かない要素の列挙を表にし、処理の流れと構成要素の図に小見出しを付けた - シェルのエントリポイントを核の子として起こす語を「再実行」にし、worker の「起こし直し」と分けた Co-Authored-By: Claude Opus 5.5 --- issues/issue-2337-design-decisions.md | 50 +++++++++++++++++------- issues/issue-2337-design.md | 56 ++++++++++++++++++++++----- 2 files changed, 83 insertions(+), 23 deletions(-) diff --git a/issues/issue-2337-design-decisions.md b/issues/issue-2337-design-decisions.md index 4a9851eb2..896a85bee 100644 --- a/issues/issue-2337-design-decisions.md +++ b/issues/issue-2337-design-decisions.md @@ -1,12 +1,14 @@ # #2337 の設計の決定の記録 -設計文書の本体は [issue-2337-design.md](issue-2337-design.md) にある。決定はどれも、#2335 の設計(`issues/issue-2335-design.md` と `issues/issue-2335-design-decisions.md`)の決定の範囲で、柱 P1 の形を決める。 +設計文書の本体は [issue-2337-design.md](issue-2337-design.md) にある。決定はどれも柱 P1 の形を決める。どれも #2335 の設計(`issues/issue-2335-design.md` と `issues/issue-2335-design-decisions.md`)の決定の範囲にある。 ## 決定の記録 ### 決定 1: 64 項目の本体とテストを柱ごとに書き直さないよう、柱 P1 では既存の `main(argv)` をそのまま本体として核へ渡す -#2335 の設計は本体を `body(ctx) -> Outcome` と書く。この形へ移すには、引数の解決(`argparse`)を本体から外す必要があり、それは柱 P8(入力の部品)の仕事である。P1 で `ctx` の形へ書き換えると、P8 でもう一度、64 項目の本体と、`main([...])` を呼ぶ既存のテストを書き換えることになる。P1 は `run_core.main("<契約の path>", main)` で既存の `main` を渡し、実行の文脈(実行の識別子・期限・契約)は `run_core.context()` で引けるようにする。核を通ったかの検査(`__main__` の中身が `run_core.main` の 1 文)は #2335 の決定 1 のまま成り立つ。 +P1 は `run_core.main("<契約の path>", main)` で既存の `main` を渡す。実行の文脈(実行の識別子・期限・契約)は `run_core.context()` で引けるようにする。核を通ったかの検査(`__main__` の中身が `run_core.main` の 1 文)は #2335 の決定 1 のまま成り立つ。 + +#2335 の設計は本体を `body(ctx) -> Outcome` と書く。この形へ移すには、引数の解決(`argparse`)を本体から外す必要があり、それは柱 P8(入力の部品)の仕事である。P1 で `ctx` の形へ書き換えると、P8 でもう一度書き換えることになる。書き換える対象は、64 項目の本体と、`main([...])` を呼ぶ既存のテストである。 本体を `body(ctx)` にそろえてから移す形は採らない。P8 の前に引数の解決の置き場を決めることになり、柱の順序を崩す。 @@ -14,7 +16,9 @@ ### 決定 2: 46 項目の標準出力を柱 P7 の前に変えないよう、標準出力が結果 JSON でないエントリポイントの結果は `NDF_RESULT_FILE` が指すファイルへ書く -契約の `stdout` が `step-result` でない 46 項目(`text` 34・`json` 5・`none` 5・`kv` 2)の標準出力は、呼ぶ側の Skill の本文・プラン・利用者の端末がそのまま読む。核が標準出力へ結果 JSON を足すと、`| tail -1` や `KEY=VALUE` の読み手が壊れる。核は、呼ぶ側が環境変数 `NDF_RESULT_FILE` でファイルを渡したときだけ、最終の結果 JSON をそこへ 1 行で書く。結果を読みたい呼ぶ側(supervise の run のステップ・テスト)は、ファイルを渡して読む。 +核は、呼ぶ側が環境変数 `NDF_RESULT_FILE` でファイルを渡したときだけ、最終の結果 JSON をそこへ 1 行で書く。結果を読みたい呼ぶ側(supervise の run のステップ・テスト)は、ファイルを渡して読む。 + +契約の `stdout` が `step-result` でない項目は 46 ある(`text` 34・`json` 5・`none` 5・`kv` 2)。その標準出力は、呼ぶ側の Skill の本文・プラン・利用者の端末がそのまま読む。核が標準出力へ結果 JSON を足すと、`| tail -1` や `KEY=VALUE` の読み手が壊れる。 標準エラーへ結果 JSON を出す形は採らない。`2>&1` で受けている呼ぶ側の出力が変わり、読み手が最後の行を結果と読み違える。終了コードだけを返す形も採らない。`summary` と `gaps` が届かず、運用・保守性の条件(止まった理由を結果 JSON だけで読める)を満たさない。 @@ -24,7 +28,9 @@ ### 決定 3: 本体の中の `step_result.emit` を書き換えずに核へ結果を渡すため、核が動いている間の `emit` は `Emitted` を投げる -契約の `stdout` が `step-result` の Python のエントリポイントは、結果を `step_result.emit` で出し、`SystemExit` で終わる。核がこの出力を確かめる(`ok` に欠けが無いか・契約に無い終了コードでないか)には、出す前に受け取る必要がある。`emit` は、核が動いているときだけ標準出力へ書かずに `Emitted`(`SystemExit` の子クラス。結果と終了コードを持つ)を投げ、核が確かめてから出す。核が動いていないとき(テストが関数を直に呼ぶとき)は今と同じに出す。 +`emit` は、核が動いているときだけ標準出力へ書かずに `Emitted`(`SystemExit` の子クラス。結果と終了コードを持つ)を投げ、核が確かめてから出す。核が動いていないとき(テストが関数を直に呼ぶとき)は今と同じに出す。 + +契約の `stdout` が `step-result` の Python のエントリポイントは、結果を `step_result.emit` で出し、`SystemExit` で終わる。核がこの出力を確かめる(`ok` に欠けが無いか・契約に無い終了コードでないか)には、出す前に受け取る必要がある。 標準出力を受け取って最後の行を読み直す形は、Python のエントリポイントでは採らない。本体が流す途中の出力と結果の行の区別が、本体ごとの書き方に依存する。シェルのエントリポイントは子プロセスであるため、この形しか取れない(設計文書の「シェルのエントリポイント」)。 @@ -34,7 +40,9 @@ ### 決定 4: 終了コードの意味を検査で確かめられるよう、終了コードの表に見出しを持たせ、契約の `exit` はその見出しで始め、エントリポイントは独自の `EXIT_` 定数を持たない -`handoff.py` は 4 を「一致する本体が複数」、5 を「行数の上限を超える」の意味で返す(#2241)。表では 4 は「ベースブランチと衝突」、5 は表に無い。契約の `exit` は自由文のため、食い違いを機械で見つけられない。`step_result` に番号ごとの見出し(`EXIT_LABELS`)を持たせ、検査(`exit-meaning`)は 2 つを落とす。契約の `exit` の値が見出しで始まらない項目と、エントリポイントとその部品が `EXIT_` で始まる整数の定数を自分で持つことである。実行時には、核が契約の `exit` の鍵に無い終了コードを `stopped`・1 に変える(I3)。 +`step_result` に番号ごとの見出し(`EXIT_LABELS`)を持たせ、検査(`exit-meaning`)は 2 つを落とす。契約の `exit` の値が見出しで始まらない項目と、エントリポイントとその部品が `EXIT_` で始まる整数の定数を自分で持つことである。実行時には、核が契約の `exit` の鍵に無い終了コードを `stopped`・1 に変える(I3)。 + +`handoff.py` は 4 を「一致する本体が複数」、5 を「行数の上限を超える」の意味で返す(#2241)。表では 4 は「ベースブランチと衝突」、5 は表に無い。契約の `exit` は自由文のため、食い違いを機械で見つけられない。 意味ごとに新しい番号を表へ足す形は採らない。受け入れ条件 18 が表の意味を変えることを許さず、呼ぶ側の読み方(`code_matches`)を広げることになる。`handoff.py` の 2 つは、利用者へ聞く場面を承認ゲートの 10、上限を超える場面を違反の 1 へ寄せる。 @@ -42,7 +50,9 @@ ### 決定 5: 核の対象外を印で決めて検査できるよう、契約の一覧の `core` に `hook` か `runtime` と根拠を書き、エントリポイント単位で外す -前提 4 の対象外は、hook の定義から起動されるものと、ランタイムが直接起動して標準出力をそのまま使うものである。今の 77 項目のうち 13 項目が当たる(設計文書の「核の対象外」)。`hook` は根拠をファイルから確かめられる(hook の定義にパスが現れる・`callers` に `hook` の印を持つ項目がある)ため、検査(`core-exempt`)が根拠の無い印を落とす。`runtime` は根拠をファイルから確かめられないため、印の数がベースブランチより増えたら落とし、増やす変更を承認ゲートで人が見る。 +`hook` は根拠をファイルから確かめられる(hook の定義にパスが現れる・`callers` に `hook` の印を持つ項目がある)。そのため検査(`core-exempt`)が根拠の無い印を落とす。`runtime` は根拠をファイルから確かめられないため、印の数がベースブランチより増えたら落とし、増やす変更を承認ゲートで人が見る。 + +前提 4 の対象外は、hook の定義から起動されるものと、ランタイムが直接起動して標準出力をそのまま使うものである。今の 77 項目のうち 13 項目が当たる(設計文書の「核の対象外」)。 hook からも利用者からも呼ばれる 3 項目(`training-optout.py`・`statusline-switch.sh`・`relay.py`)は、副命令ごとには分けず、エントリポイント単位で外す。副命令ごとに外すと、`__main__` の 1 文の検査が副命令の分岐まで見ることになり、#2335 の決定 1(構文木で 1 文を確かめる)の範囲を超える。利用者向けの副命令の結果の形は柱 P7 がそろえる。 @@ -50,9 +60,11 @@ hook からも利用者からも呼ばれる 3 項目(`training-optout.py`・` ### 決定 6: 契約と例外リストを 1 か所で扱うため、検査の種類 `core-entry`・`core-exempt`・`exit-meaning`・`result-read` を `check-ndf-contracts.py` に足す -要求の前提 2 は、`core-entry` を構造チェックの種類と書く。#2335 の構成要素の表は置き場を `check-script-structure.py` としたが、柱 P0 は契約の一覧の検査と例外リスト(`scripts/contract-allow/`)と増加の判定を `check-ndf-contracts.py` に置いた。核の対象外の印・契約の `exit`・エントリポイントの一覧はどれも契約の一覧が持つため、同じ検査に足せば読み込みと例外リストと増加の判定を共有できる。 +核の対象外の印・契約の `exit`・エントリポイントの一覧はどれも契約の一覧が持つ。同じ検査に足せば、読み込みと例外リストと増加の判定を共有できる。 + +要求の前提 2 は、`core-entry` を構造チェックの種類と書く。#2335 の構成要素の表は置き場を `check-script-structure.py` とした。柱 P0 は、契約の一覧の検査と例外リスト(`scripts/contract-allow/`)と増加の判定を `check-ndf-contracts.py` に置いた。 -増加の判定(`contracts_lib/ratchet.py`)は今、ベースブランチに無い行をすべて増加として数える。新しい種類を入れる Pull Request はその種類の行を初めて置くため、ベースブランチの検査(`check-ndf-contracts.py`)が知らない種類の行を数えないようにする(#2335 の I11 の「ベースブランチにあった検査の種類について」)。 +増加の判定(`contracts_lib/ratchet.py`)は、ベースブランチの検査(`check-ndf-contracts.py`)が知らない種類の行を数えないようにする。今はベースブランチに無い行をすべて増加として数える。新しい種類を入れる Pull Request は、その種類の行を初めて置く(#2335 の I11 の「ベースブランチにあった検査の種類について」)。 `check-script-structure.py` に置く形は採らない。契約の一覧の読み手と例外リストが 2 つの検査に分かれ、同じ役割の部品を 2 つ持つことになる。 @@ -60,7 +72,9 @@ hook からも利用者からも呼ばれる 3 項目(`training-optout.py`・` ### 決定 7: worker の成否を文の読み違いなく決めるため、worker は作業の結果ファイルを結果 JSON の形で書き、supervise はそのファイルだけを読む -今は supervise が報告の文の「結果: 完了」を正規表現で照らす(`REPORT_DONE`・`REPORT_NOT_DONE`)。文の形はどこでも確かめられず、欠けを持つ置き場も無い。要求の子の課題の表は、#1682・#1699(残した作業があっても完了へ進む)を受け入れ条件 9 で解消しうると見込む。worker は報告を終える前に `step_result.py emit --out <置き場>` でファイルを書く。CLI が形を確かめ、違えば終了コード 2 で拒むため、worker は形の違う結果を残せない。supervise は `step_read.read_result_file` で読み、`ok` のときだけ成功にする。外部 CLI を worker に使うステップも、`ndf:worker` のエージェントも、同じファイルを書く。 +worker は報告を終える前に `step_result.py emit --out <置き場>` でファイルを書く。CLI が形を確かめ、違えば終了コード 2 で拒むため、worker は形の違う結果を残せない。supervise は `step_read.read_result_file` で読み、`ok` のときだけ成功にする。外部 CLI を worker に使うステップも、`ndf:worker` のエージェントも、同じファイルを書く。 + +今は supervise が報告の文の「結果: 完了」を正規表現で照らす(`REPORT_DONE`・`REPORT_NOT_DONE`)。文の形はどこでも確かめられず、欠けを持つ置き場も無い。要求の子の課題の表は、#1682・#1699(残した作業があっても完了へ進む)を受け入れ条件 9 で解消しうると見込む。 報告の末尾に機械用のブロック(`` の JSON)を置く形は採らない。応答の文から取り出す規則が要り、文を読む箇所が残る。#2335 の I15 は機械用のブロックを課題と PR の本文の値に使い、その検出の規則は柱 P7 が決める。worker の結果をそれに乗せると、P1 が P7 の規則を先に決めることになる。claude -p の構造化出力の機能に頼る形も採らない。外部 CLI(codex・agy・kiro)の worker には同じ機能が無く、ランタイムごとに読み方が分かれる。 @@ -72,13 +86,15 @@ hook からも利用者からも呼ばれる 3 項目(`training-optout.py`・` 閉じなかった理由は、条件 2(リリース後テストの行)や条件 3(振り返りの記録)を満たさないことで、処理の失敗ではない。満たすかどうかは工程を通すか人が確かめて手で閉じるかで決まり、人の判断が要る。プランの規則では 10〜19 は失敗にならず `next`(棚卸し)へ進み、フェーズの結果が承認ゲートになる。conductor と人は承認ゲートとして閉じなかった課題と理由を読む。`failed`(状態を読めない・閉じても CLOSED にならない)は処理の失敗のため、今と同じ `stopped`・1 で止める。 -閉じなかった課題を `stopped`・6 にする形は採らない。プランが棚卸しの前で止まり、別のリポジトリの課題(#2327)のように工程を通しても満たせない課題があるたびに、まとめのプランが進まなくなる。プランの順を「振り返り → 課題を閉じる」から変える必要は無い。今のテンプレート(`supervise_lib/sprint_close_steps.py`)は既に振り返りの後に閉じる。 +閉じなかった課題を `stopped`・6 にする形は採らない。プランが棚卸しの前で止まる。別のリポジトリの課題(#2327)のように工程を通しても満たせない課題があるたびに、まとめのプランが進まなくなる。プランの順を「振り返り → 課題を閉じる」から変える必要は無い。今のテンプレート(`supervise_lib/sprint_close_steps.py`)は既に振り返りの後に閉じる。 根拠: Value 2 / Value 7(MVV 版 4) ### 決定 9: 時間切れと SIGTERM を耐久のエントリポイントでも同じ形で扱うため、核は主スレッドで受けて結果を出し、耐久の記録を開いていれば DBOS を閉じずに抜ける -DBOS 3.1.0 の耐久ワークフローを動かしたまま、主スレッドで `signal.setitimer` の時間切れと SIGTERM を受けた(2026-11-02 の実測。設計文書の「あるべき姿の根拠」)。どちらも主スレッドの待ちが 0.3 秒以内に中断し、DBOS の内部のスレッドとの干渉は無かった。中断の後に DBOS を閉じる(`durable.close`)と、動いていたステップのスレッドが閉じたデータベースへ書こうとして例外の跡を 2 件出した。閉じずに `durable.exit_leaving_pending` で抜けると 0 件で、ワークフローは途中のまま残り、打ち直しで続く。核は結果を出した後、`durable` が読まれて耐久の記録が開いているときだけこの出口を使う。 +核は結果を出した後、`durable` が読まれて耐久の記録が開いているときだけ `durable.exit_leaving_pending` の出口を使う。 + +DBOS 3.1.0 の耐久ワークフローを動かしたまま、主スレッドで `signal.setitimer` の時間切れと SIGTERM を受けた(2026-11-02 の実測。設計文書の「あるべき姿の根拠」)。どちらも主スレッドの待ちが 0.3 秒以内に中断し、DBOS の内部のスレッドとの干渉は無かった。中断の後に DBOS を閉じる(`durable.close`)と、動いていたステップのスレッドが閉じたデータベースへ書こうとして例外の跡を 2 件出した。閉じずに `durable.exit_leaving_pending` で抜けると 0 件で、ワークフローは途中のまま残り、打ち直しで続く。 時間切れを別スレッドの監視で切る形は採らない。Python は主スレッド以外から主スレッドの処理を中断できず、本体の外から切れない。 @@ -86,7 +102,9 @@ DBOS 3.1.0 の耐久ワークフローを動かしたまま、主スレッドで ### 決定 10: 柱 P4 の前に時間切れを確かめられるよう、核は時間切れの秒を環境変数 `NDF_RUN_TIMEOUT` から受け、子へ継がない -受け入れ条件 1 は時間切れの終わり方を求めるが、上限の宣言(`contracts/limits.json`)は柱 P4 が作る。P1 の核は、呼ぶ側が `NDF_RUN_TIMEOUT` を渡したときだけ期限を置く。supervise の run のステップはステップの `timeout` をこの値で渡す。核は本体を呼ぶ前にこの値を外し、子のエントリポイントが親と同じ秒を自分の期限にしない。P4 は値の出所を上限の宣言へ移し、この環境変数を上書きの経路として残すかを決める。 +P1 の核は、呼ぶ側が `NDF_RUN_TIMEOUT` を渡したときだけ期限を置く。supervise の run のステップはステップの `timeout` をこの値で渡す。核は本体を呼ぶ前にこの値を外し、子のエントリポイントが親と同じ秒を自分の期限にしない。P4 は値の出所を上限の宣言へ移し、この環境変数を上書きの経路として残すかを決める。 + +受け入れ条件 1 は時間切れの終わり方を求めるが、上限の宣言(`contracts/limits.json`)は柱 P4 が作る。 P1 で上限の宣言を先に作る形は採らない。P4 の範囲(方針の名前・再試行の回数・同じ原因の上限)を P1 が部分的に決めることになる。 @@ -94,7 +112,9 @@ P1 で上限の宣言を先に作る形は採らない。P4 の範囲(方針 ### 決定 11: 依存の欠けで止まるときも結果 JSON を返すため、`deps` の止まりは核の早い出口(`run_core.exit_early`)を通す -`deps.require` はモジュールの最上位で呼ばれ、`run_core.main` に届く前に止まる。今は標準エラーへ 1 行を出して 69 で終わり、結果 JSON を出さない。`deps` の止まりを `run_core.exit_early` に通し、契約の `stdout` が `step-result` なら標準出力へ、`NDF_RESULT_FILE` があればそのファイルへ、`stopped`・69 と `gaps`(`kind` は `deps`、`name` はグループ)を書く。核は標準ライブラリだけで書くため、extras が無い環境でも読める。 +`deps` の止まりを `run_core.exit_early` に通す。書くのは `stopped`・69 と `gaps`(`kind` は `deps`、`name` はグループ)である。書き先は、契約の `stdout` が `step-result` なら標準出力、`NDF_RESULT_FILE` があればそのファイルである。核は標準ライブラリだけで書くため、extras が無い環境でも読める。 + +`deps.require` はモジュールの最上位で呼ばれ、`run_core.main` に届く前に止まる。今は標準エラーへ 1 行を出して 69 で終わり、結果 JSON を出さない。 `deps` に結果の組み立てを持たせる形は採らない。結果の出し方(標準出力か結果ファイルか)を決める規則が核と `deps` の 2 か所に分かれる。 @@ -102,7 +122,9 @@ P1 で上限の宣言を先に作る形は採らない。P4 の範囲(方針 ### 決定 12: 結果の読み方を共通の読み手へ寄せたことを検査できるよう、最後の行を JSON として読む書き方を共通の読み手の外で禁じる -ほかのエントリポイントの結果 JSON を読む箇所は、子プロセスの標準出力の最後の行を `json.loads` で読む(`supervise_lib/steps.py` の `last_json`・`release-steps.py`・`lib/sprint_loop_record.py`)。GitHub の CLI の出力を読む箇所は、出力の全体を読む。検査(`result-read`)は、`lib/step_read.py` の外で、名前に `last_json` を含む関数の定義と、`json.loads(<式>.splitlines()[-1])` の形を落とす。今の箇所は例外リストに置き、移す Pull Request が `step_read.read_completed` へ置き換えて行を消す。 +検査(`result-read`)は、`lib/step_read.py` の外で、名前に `last_json` を含む関数の定義と、`json.loads(<式>.splitlines()[-1])` の形を落とす。今の箇所は例外リストに置き、移す Pull Request が `step_read.read_completed` へ置き換えて行を消す。 + +ほかのエントリポイントの結果 JSON を読む箇所は、子プロセスの標準出力の最後の行を `json.loads` で読む。該当するのは `supervise_lib/steps.py` の `last_json`・`release-steps.py`・`lib/sprint_loop_record.py` である。GitHub の CLI の出力を読む箇所は、出力の全体を読む。 呼び出し先のパスから NDF のエントリポイントを呼ぶ箇所を見分ける形は採らない。パスは `paths` の定数・`HERE /` の式・文字列の組み立てで作られ、構文木から決まらない箇所が残る。 diff --git a/issues/issue-2337-design.md b/issues/issue-2337-design.md index c17f28133..cecfca02d 100644 --- a/issues/issue-2337-design.md +++ b/issues/issue-2337-design.md @@ -8,7 +8,7 @@ ## 適用範囲 -- **働く範囲**: NDF の配布物のエントリポイント(契約の一覧の 77 項目から、hook とランタイムが直接起動する 13 項目を除いた 64 項目)と、supervise・外部 CLI の起動・課題を閉じる処理の読み手。配布先のリポジトリでも同じ核が動く。hook・`factory/`・ほかのプラグインは含まない +- **働く範囲**: NDF の配布物のエントリポイント 64 項目と、supervise・外部 CLI の起動・課題を閉じる処理の読み手。64 項目は、契約の一覧の 77 項目から hook とランタイムが直接起動する 13 項目を除いたもの。配布先のリポジトリでも同じ核が動く。hook・`factory/`・ほかのプラグインは含まない - **プロジェクトごとに違うもの**: 無い。核はプロジェクトの設定を読まない。時間切れの秒は呼ぶ側が環境変数 `NDF_RUN_TIMEOUT` で渡す(柱 P4 が上限の宣言へ移す) - **当たるモード**: `standard` @@ -17,7 +17,7 @@ | 根拠 | 種類 | 何を示すか | | --- | --- | --- | | 「異常系処理とログ出力、異常時の回復処理が統一されていないから起こる不具合・課題が多い。1 件ずつ対処してもきりがない」(利用者、2026-11-01) | 利用者の指示の原文 | 終わり方の扱いを 1 か所へ集め、型ごとに無くすこと | -| #2335 の設計(`issues/issue-2335-design.md`)の決定 1・2・11 と不変条件 I1・I2・I16 | 承認ゲート 1 を通った親の設計 | 核の形(`run_core.main` の 1 文・シェルは核の子として起こし直す)と、P1 がすべてのエントリポイントを移すこと | +| #2335 の設計(`issues/issue-2335-design.md`)の決定 1・2・11 と不変条件 I1・I2・I16 | 承認ゲート 1 を通った親の設計 | 核の形(`run_core.main` の 1 文・シェルは核の子として再実行する)と、P1 がすべてのエントリポイントを移すこと | | DBOS 3.1.0 の耐久ワークフローを動かしたまま、主スレッドで `signal.setitimer` の時間切れと SIGTERM を受けた実測(2026-11-02、`/work/ai-plugins/.venv/bin/python`、Python 3.13.17)。どちらも 2.15〜2.28 秒で主スレッドの待ちが中断した。続けて DBOS を閉じると、動いていたステップのスレッドの例外の跡が標準エラーへ 2 件出た。閉じずに `durable.exit_leaving_pending` で抜けると 0 件だった | 実測 | 耐久のエントリポイントでも時間切れの仕組みは使え、中断した後は DBOS を閉じずに抜けること(決定 9) | | 契約の一覧の `stdout` の内訳: `step-result` 31・`text` 34・`json` 5・`none` 5・`kv` 2(2026-11-02 に `plugins/ndf/contracts/entrypoints/*.json` を数えた) | 実測 | 46 項目の標準出力を変えずに結果を返す経路が要ること(決定 2) | @@ -32,7 +32,7 @@ | NDF の開発ワークフロー(`ndf-workflow`) | 実行の核・本体・結果 JSON・欠け・核の対象外・作業の結果ファイル・フェーズの結果・作業の報告 | | NDF の外部 CLI 委譲(`ndf-agent-cli`) | 結果ファイル・起動結果(語の意味は変えない) | -2 つの間は **公開された言語** でやり取りする。外部 CLI の起動(`external-ai.py run`)は結果 JSON の形(`lib/step_result.py`)で起動の終わり方を返し、supervise は共通の読み手(`lib/step_read.py`)だけでそれを読む。外部 CLI の結果ファイルの中身(自由文)は、成否の判定に使わない。 +2 つの間は **公開された言語** でやり取りする。外部 CLI の起動(`external-ai.py run`)は、起動の終わり方を結果 JSON の形(`lib/step_result.py`)で返す。supervise はそれを共通の読み手(`lib/step_read.py`)だけで読む。外部 CLI の結果ファイルの中身(自由文)は、成否の判定に使わない。 ### 集約 @@ -110,7 +110,7 @@ | 要素 | 責務 | 新設 / 変更 | 機能 | | --- | --- | --- | --- | | 実行の核(`plugins/ndf/scripts/lib/run_core.py`) | `main(entry_id, body)` で契約を引き、シグナルと時間切れの受け手を置いて本体を呼び、結果か中断を結果 JSON と終了コードへ変える。`exec` 副命令でシェルの本体を子として動かす。`exit_early` で核に入る前の止まりを結果にする。標準ライブラリだけで書く | 新設 | F1・F2・F8 | -| シェルの起こし直し(`plugins/ndf/scripts/lib/run-core.sh`) | `rc_enter "" "$@"`。`NDF_RUN_ENTRY` が自分の id でなければ `exec python3 run_core.py exec -- "$0" "$@"` で起こし直す | 新設 | F1 | +| シェルの再実行(`plugins/ndf/scripts/lib/run-core.sh`) | `rc_enter "" "$@"`。`NDF_RUN_ENTRY` が自分の id でなければ `exec python3 run_core.py exec -- "$0" "$@"` で再実行する | 新設 | F1 | | 結果の形(`lib/step_result.py`) | 核が動いている間の `emit` を、出力せずに核へ渡す。終了コードの表の見出し(`EXIT_LABELS`)を持つ。任意の鍵 `run_id` を足す。`emit` の CLI に `--out <パス>`(同じ 1 行をファイルへも書く)を足す | 変更 | F1・F3・F4 | | 共通の読み手(`lib/step_read.py`) | `read_result_file(path)`(作業の結果ファイルと結果ファイルの読み)と `read_completed(completed)`(子プロセスの結果)を足す。無い・読めない・形が違うを `stopped` の理由として返す | 変更 | F4・F5・F6 | | 依存の確かめ(`lib/deps.py`) | 止まるときは `run_core.exit_early` を通し、`stopped`・69 と足りないグループを返す | 変更 | F8 | @@ -129,6 +129,8 @@ | 64 のエントリポイント | 末尾を核の 1 文にする。独自の `EXIT_` 定数を表の定数へ置き換える(`handoff.py` の 4・5 ほか) | 変更 | F1・F3 | | 文書(`skills/progress-tracking/SKILL.md`・`development-workflow/references/`・`plugins/ndf/scripts/lib/README.md`・用語集) | 終了コードが変わる箇所と、作業の結果ファイルの書き方を直す | 変更 | F4・F5・F7 | +### 構成要素の関係 + ```mermaid graph TD subgraph 呼ぶ側 @@ -170,7 +172,15 @@ graph TD CK --> SB ``` -構成要素の図に描かない要素: 起動指示・work のステップ・進捗の分類・フェーズの実行(supervise の中)、エージェントの定義(supervise の外で supervisor が読む)、外部 CLI の起動と課題を閉じる処理(Python の本体の 1 つ)、契約の型・増加の判定・例外リスト(検査の部品)、文書。 +構成要素の図に描かない要素: + +| 要素 | 描かない理由 | +| --- | --- | +| 起動指示・work のステップ・進捗の分類・フェーズの実行 | supervise の中 | +| エージェントの定義 | supervise の外で supervisor が読む | +| 外部 CLI の起動・課題を閉じる処理 | Python の本体の 1 つ | +| 契約の型・増加の判定・例外リスト | 検査の部品 | +| 文書 | — | ### 文脈と配置 @@ -196,7 +206,12 @@ graph TD | `scripts/statusline-switch.sh`・`scripts/training-optout.py` | `hook` | `hooks/claude.json`(`statusline-switch.sh ensure`)・`hooks/*.json`(`training-optout.py session-start`)が起動する | | `scripts/relay.py` | `hook` | `hooks/claude.json` が起動する(`mark`・`notice` ほか)。ランタイムの起動を包むラッパーでもあり、子の端末の入出力をそのまま流す | -`hook` の印は、hook の定義のファイル(`plugins/ndf/hooks/*.json`・Skill の frontmatter の `hooks`・`dev.kiro/`・`dev.agy/` の hook の定義)にパスが現れるか、`callers` に `hook` の印を持つエントリポイントがあるときだけ付けられる。`runtime` の印は `evidence` に理由を書き、印の数がベースブランチより増えたら検査が落とす(I5)。 +`hook` の印を付けられるのは、次のどちらかに当たるときだけである。 + +- hook の定義のファイル(`plugins/ndf/hooks/*.json`・Skill の frontmatter の `hooks`・`dev.kiro/`・`dev.agy/` の hook の定義)にパスが現れる +- `callers` に `hook` の印を持つエントリポイントがある + +`runtime` の印は `evidence` に理由を書く。印の数がベースブランチより増えたら検査が落とす(I5)。 ## 構造 @@ -229,7 +244,7 @@ classDiagram - **本体の形**: `body(argv: list[str]) -> int | None`。既存の `main(argv=None)` をそのまま渡す。本体は今と同じく `step_result.emit` で結果を出してよい。核が動いている間の `emit` は標準出力へ書かずに `Emitted`(`SystemExit` の子クラス)を投げ、核が受け取って確かめてから出す。実行の文脈は `run_core.context()` で引く(決定 1) - **エントリポイントの形**: Python は `if __name__ == "__main__": run_core.main("<契約の path>", main)` の 1 文。副命令は本体の中の `argparse` が今のまま分ける。シェルは先頭で `. "$(dirname "$0")//run-core.sh"; rc_enter "<契約の path>" "$@"` - **核が受け取る値の読み替え**: `Emitted` は本体の結果 JSON と終了コード。`SystemExit`(`argparse` の `--help` と引数の誤りを含む)はその `code` を終了コードにし、`None` は 0 とする。戻り値は、契約の `stdout` が `step-result` でなければ `None` を 0、整数をその終了コードとする。`step-result` の本体が結果 JSON を出さずに返ったとき(戻り値が `None` か整数)と、戻り値が整数と `None` 以外のときは、I3 で `stopped`・1 にする -- **終了コードから状態を決める規則**: 結果 JSON が無い終わり方では、0 を `ok`、10〜29 を `gate`、表の `stopped` の値(1・2・3・4・6・69・75)を `stopped` とし、それ以外は I3 で `stopped`・1 にする +- **終了コードから状態を決める規則**: 結果 JSON が無い終わり方に当てる。0 は `ok`、10〜29 は `gate`、表の `stopped` の値(1・2・3・4・6・69・75)は `stopped` とする。それ以外は I3 で `stopped`・1 にする ## 入出力の契約 @@ -306,6 +321,8 @@ worker が進捗のファイルへ追記する行に `state`(`working` / `fail ## 処理の流れ +### エントリポイントの実行 + ```mermaid sequenceDiagram participant C as 呼ぶ側 @@ -332,7 +349,17 @@ sequenceDiagram end ``` -処理の流れの図に描かない要素: シェルの起こし直し(`rc_enter` が `exec` で起こし直した後は同じ流れ)、依存の確かめ(本体より前に止まるときは `exit_early` が E5 だけを行う)、結果の形(核の確かめの中)、契約の一覧・契約の型・契約の検査・増加の判定・例外リスト(CI で動く)、64 のエントリポイント(本体として描く)。 +この図に描かない要素: + +| 要素 | 描かない理由 | +| --- | --- | +| シェルの再実行 | `rc_enter` が `exec` で再実行した後は同じ流れ | +| 依存の確かめ | 本体より前に止まるときは `exit_early` が E5 だけを行う | +| 結果の形 | 核の確かめの中 | +| 契約の一覧・契約の型・契約の検査・増加の判定・例外リスト | CI で動く | +| 64 のエントリポイント | 本体として描く | + +### worker の作業の成否 ```mermaid sequenceDiagram @@ -354,7 +381,18 @@ sequenceDiagram end ``` -2 つ目の図に描かない要素: 起動指示(supervise が組む起動指示の文)、共通の読み手(supervise の読みの中)、進捗の分類(worker の進捗の行を読む。成否を決めない)、フェーズの実行(E7 の後に終わり方を書く。「フェーズの結果と `supervise.py run`」)、エージェントの定義(supervisor が `ndf:worker` を起動するときの同じ流れ)、外部 CLI の起動(worker を外部 CLI で動かすときの起動の中身)、課題を閉じる処理(プランの run のステップの 1 つ)、文書。 +この図に描かない要素: + +| 要素 | 描かない理由 | +| --- | --- | +| 起動指示 | supervise が組む起動指示の文 | +| 共通の読み手 | supervise の読みの中 | +| 進捗の分類 | worker の進捗の行を読む。成否を決めない | +| フェーズの実行 | E7 の後に終わり方を書く(「フェーズの結果と `supervise.py run`」) | +| エージェントの定義 | supervisor が `ndf:worker` を起動するときの同じ流れ | +| 外部 CLI の起動 | worker を外部 CLI で動かすときの起動の中身 | +| 課題を閉じる処理 | プランの run のステップの 1 つ | +| 文書 | — | ## 非機能の実現方式 From d61feac57b6bc383af72feafb5c69a935ed2a958 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 08:04:36 +0000 Subject: [PATCH 015/127] =?UTF-8?q?Docs:=20#2337=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=AE=E4=B8=8D=E5=A4=89=E6=9D=A1=E4=BB=B6=20I3?= =?UTF-8?q?=E3=83=BBI12=20=E3=81=AE=E6=9D=A1=E4=BB=B6=E3=82=92=E5=AE=9F?= =?UTF-8?q?=E8=A3=85=E3=81=A8=E9=A3=9F=E3=81=84=E9=81=95=E3=82=8F=E3=81=AA?= =?UTF-8?q?=E3=81=84=E5=BD=A2=E3=81=AB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit I3 は核が読み替えた後の結果に当て、結果 JSON を求めるのは step-result の契約だけにする。 I12 の ok の条件に、監視の状態・理由が失敗を示さないことを足す。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2337-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/issues/issue-2337-design.md b/issues/issue-2337-design.md index cecfca02d..2936ca173 100644 --- a/issues/issue-2337-design.md +++ b/issues/issue-2337-design.md @@ -52,7 +52,7 @@ | --- | --- | --- | --- | | I1 | 実行 | 結果の `status` は `ok` / `gate` / `stopped` のどれかで、終了コードは `step_result.code_matches` を満たす。例外・SIGTERM・時間切れは `stopped`・1 で、`summary` に理由、`gaps` に `interrupted`(SIGTERM)か `timeout`(時間切れ)か `exception`(例外)を持つ | 核が本体の結果を捨て、`stopped` を返す | | I2 | 実行 | `ok` を返すのは `gaps` が空のときだけ | `stopped`・6。本体の `gaps` はそのまま残す | -| I3 | 実行 | 本体の結果は結果 JSON の形で、`status` と終了コードが食い違わず、終了コードは契約の `exit` の鍵に含まれる | `stopped`・1。`summary` に違いの中身 | +| I3 | 実行 | 核が本体の終わり方を読み替えた後の結果(「核が受け取る値の読み替え」と「終了コードから状態を決める規則」で決めたもの)は、`status` と終了コードが食い違わず、終了コードは契約の `exit` の鍵に含まれる。本体に結果 JSON を求めるのは契約の `stdout` が `step-result` のときだけで、それ以外の契約の本体が `None`・整数を返すことと、`SystemExit`(`argparse` の `--help` を含む)で終えることは破れに当たらない | `stopped`・1。`summary` に違いの中身 | | I4 | 契約の一覧 | 核の対象外の印を持たないエントリポイントは、Python なら `if __name__ == "__main__":` の中身が `run_core.main("<契約の path>", <本体>)` の 1 文、シェルなら先頭の実行文が `rc_enter "<契約の path>" "$@"` | 構造チェック(`core-entry`)が名前を出して落とす | | I5 | 契約の一覧 | 核の対象外の印は、hook の定義か、ランタイムが直接起動する根拠を持つエントリポイントにだけ付く | 構造チェック(`core-exempt`)が落とす | | I6 | 契約の一覧 | 構造チェックの例外リストの `core-entry`・`exit-meaning`・`result-read` の行は、ベースブランチより増えない | 構造チェック(`allow-added`)が落とす | @@ -61,7 +61,7 @@ | I9 | 実行 | 核は、本体が結果を出さずに正常に終えたとき標準出力へ何も足さない。結果ファイルの環境変数(`NDF_RESULT_FILE`)を子プロセスへ継がない | 契約の標準出力の形を照らすテストが落とす | | I10 | 作業の結果 | work のステップを成功と読むのは、作業の結果ファイルが結果 JSON の形で `status` が `ok` のときだけ。無い・読めない・形が違えば `stopped` | ステップを失敗にする(`on_fail` か止まる) | | I11 | フェーズの結果 | `supervise.py run` の終了コードはフェーズの結果の `status` から決まる(`ok` 0・`gate` 10・`stopped` 1) | テストが落とす | -| I12 | 実行 | 外部 CLI の起動を `ok` と読むのは、監視が終わりを記録し、結果ファイルが空でなく読めるときだけ。進捗の文の語で止まりを決めない | `stopped` と理由 | +| I12 | 実行 | 外部 CLI の起動を `ok` と読むのは、監視が正常な終わり(監視ログの状態が `OK` か `NO_RESULT`)を記録し、監視の理由が失敗(時間切れ・利用上限・認証切れ・起動の失敗)を示さず、結果ファイルが空でなく読めるときだけ。結果ファイルがあっても、監視の状態か理由が失敗を示せば `ok` にしない。進捗の文の語で止まりを決めない | `stopped` と理由 | | I13 | 実行 | ほかのエントリポイントの結果 JSON を読んで次の手を決める箇所は共通の読み手を通し、`ok` でない結果を成功と読まない | 構造チェック(`result-read`)が落とす | | I14 | 閉じる判定 | 閉じる対象のうち閉じなかった課題(`kept_open`・`closed_unverified`・`failed`)が 1 件でもあれば `ok` を返さない。参照しているだけの課題は状態に影響しない | `failed` があれば `stopped`・1、無ければ `gate`・10 | @@ -124,7 +124,7 @@ | 進捗の分類(`supervise_lib/state.py`) | worker の進捗の行を `state` の値と同じ文の繰り返しで分け、文の語を読まない | 変更 | F6 | | フェーズの実行(`supervise_lib/engine.py`・`commands.py`・`steps.py`) | 終わり方をフェーズの結果へ書き、`supervise.py run` の終了コードにする。`steps.last_json` を共通の読み手へ寄せる | 変更 | F5 | | エージェントの定義(`plugins/ndf/agents/worker.md`・`supervisor.md`・`supervisor-waits.md`) | worker は渡された置き場へ作業の結果ファイルを書く。supervisor は置き場を渡し、成否をそのファイルで決める | 変更 | F4 | -| 外部 CLI の起動(`skills/external-ai/scripts/external-ai.py`) | `ok` の条件を「監視が終わりを記録し、結果ファイルが空でなく読める」にそろえる | 変更 | F6 | +| 外部 CLI の起動(`skills/external-ai/scripts/external-ai.py`) | `ok` の条件を「監視が正常な終わりを記録し、監視の状態・理由が失敗を示さず、結果ファイルが空でなく読める」(I12)にそろえる | 変更 | F6 | | 課題を閉じる処理(`plugins/ndf/scripts/sprint-close.py`) | 閉じなかった課題があれば `gate`・10(失敗があれば `stopped`・1)。呼び出しの誤りを 2 にする | 変更 | F7 | | 64 のエントリポイント | 末尾を核の 1 文にする。独自の `EXIT_` 定数を表の定数へ置き換える(`handoff.py` の 4・5 ほか) | 変更 | F1・F3 | | 文書(`skills/progress-tracking/SKILL.md`・`development-workflow/references/`・`plugins/ndf/scripts/lib/README.md`・用語集) | 終了コードが変わる箇所と、作業の結果ファイルの書き方を直す | 変更 | F4・F5・F7 | @@ -421,7 +421,7 @@ sequenceDiagram | 8・I8 | extras を入れない環境で、extras を要るエントリポイントを起動すると、標準エラーに例外の跡が無く、`stopped`・69 で `gaps` にグループの名前(契約の `stdout` が `step-result` なら標準出力の結果 JSON、それ以外は結果ファイル) | `deps` の止まりを `exit_early` を通さず `sys.exit` に戻す/核の最上位で pydantic を import する | | 9・I10 | 作業の結果ファイルが `ok` のときだけステップが成功する。報告の文に「結果: 完了」があってもファイルが無ければ失敗、文が無くてもファイルが `ok` なら成功。full のステップでファイルが無ければ起こし直す | 報告の文を照らす判定を残す/前の試行のファイルを消さずに読む | | 10・I11 | フェーズが完了・承認ゲート・止まったで、`supervise.py run` の終了コードが 0・10・1 になり、`phase-result.json` の `status` と一致する | 報告の文で終了コードを決める | -| 11・I12 | 監視が終わりを記録しても結果ファイルが無い・空・読めなければ `ok` にならない。進捗の行の `text` に「止まった」を書いても `state` が `working` なら知らせない | `PROGRESS_STOP` の照合を残す/結果ファイルの有無を見ない | +| 11・I12 | 監視が終わりを記録しても結果ファイルが無い・空・読めなければ `ok` にならない。結果ファイルを書いた後に監視が時間切れ・利用上限を記録した起動も `ok` にならない。進捗の行の `text` に「止まった」を書いても `state` が `working` なら知らせない | `PROGRESS_STOP` の照合を残す/結果ファイルの有無を見ない/監視の状態・理由を見ずに結果ファイルだけで `ok` にする | | 12・I13 | 子のエントリポイントが `gate` か `stopped` を返すと読み手は成功と読まない。最後の行を JSON として読む箇所と `last_json` の定義が共通の読み手の外にあると構造チェック(`result-read`)が落とす | 読み手の外に `json.loads(out.splitlines()[-1])` を書く | | 13・I7 | 契約の `exit` の値が表の見出しで始まらない項目、エントリポイントとその部品が持つ `EXIT_` の整数の定数を、構造チェック(`exit-meaning`)が落とす | `handoff.py` に `EXIT_FOUND_MANY = 4` を戻す | | 14・I14 | 閉じる対象に `kept_open` か `closed_unverified` があれば `gate`・10、`failed` があれば `stopped`・1、参照だけの課題は状態を変えない。`gaps` に課題ごとの理由 | 参照だけの課題を数える/`kept_open` を `ok` で返す | @@ -448,5 +448,5 @@ sequenceDiagram | 移すスプリントの数 | 1 スプリントは核の Pull Request と、部品ごとに移す Pull Request 3 本までで作る。64 項目が何スプリントで移るかは実装計画が決める(要求の未決のまま) | | 増分 150 ms の実物 | 核の import は部品の実測から見積もった。移した後の `--help` の差は移す Pull Request ごとに測る | | 子プロセスの後片付け | Python の本体が `subprocess` で起こした子は、SIGTERM と時間切れのときに核が止めない(シェルの子は止める)。孫のプロセスが残るかは柱 P3(持ち主の記録と後片付け)が扱う | -| 監視ログの終了コード | 外部 CLI の監視ログ(`-monitor.json`)が CLI の終了コードを持つかは確かめていない。持たなければ、`ok` の条件は監視の状態と結果ファイルの 2 つで判定する | +| 監視ログの終了コード | 外部 CLI の監視ログ(`-monitor.json`)が CLI の終了コードを持つかは確かめていない。持たなければ、`ok` の条件は監視の状態・理由と結果ファイルで判定する | | `exit-meaning` が見つける食い違いの数 | 契約の `exit` の値を表の見出しへ書き直したときに、意味の食い違う項目がいくつ出るかは数えていない。移す Pull Request が見つけ、「終了コードが変わるエントリポイント」の形で本文へ書く | From 530c0fb963be903d88025338c54cd9d7860f07d8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Mon, 2 Nov 2026 08:10:48 +0000 Subject: [PATCH 016/127] =?UTF-8?q?Docs:=20#2337=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=AE=E4=B8=8D=E5=A4=89=E6=9D=A1=E4=BB=B6=20I3?= =?UTF-8?q?=E3=83=BBI12=20=E3=81=AE=E6=9D=A1=E4=BB=B6=E3=82=92=E4=BE=8B?= =?UTF-8?q?=E5=A4=96=E3=81=94=E3=81=A8=E3=83=BB=E7=9B=A3=E8=A6=96=E3=83=AD?= =?UTF-8?q?=E3=82=B0=E3=81=AE=E5=80=A4=E3=81=A7=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2337-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/issues/issue-2337-design.md b/issues/issue-2337-design.md index 2936ca173..81c8f1b31 100644 --- a/issues/issue-2337-design.md +++ b/issues/issue-2337-design.md @@ -52,7 +52,7 @@ | --- | --- | --- | --- | | I1 | 実行 | 結果の `status` は `ok` / `gate` / `stopped` のどれかで、終了コードは `step_result.code_matches` を満たす。例外・SIGTERM・時間切れは `stopped`・1 で、`summary` に理由、`gaps` に `interrupted`(SIGTERM)か `timeout`(時間切れ)か `exception`(例外)を持つ | 核が本体の結果を捨て、`stopped` を返す | | I2 | 実行 | `ok` を返すのは `gaps` が空のときだけ | `stopped`・6。本体の `gaps` はそのまま残す | -| I3 | 実行 | 核が本体の終わり方を読み替えた後の結果(「核が受け取る値の読み替え」と「終了コードから状態を決める規則」で決めたもの)は、`status` と終了コードが食い違わず、終了コードは契約の `exit` の鍵に含まれる。本体に結果 JSON を求めるのは契約の `stdout` が `step-result` のときだけで、それ以外の契約の本体が `None`・整数を返すことと、`SystemExit`(`argparse` の `--help` を含む)で終えることは破れに当たらない | `stopped`・1。`summary` に違いの中身 | +| I3 | 実行 | 核が本体の終わり方を読み替えた後の結果(「核が受け取る値の読み替え」と「終了コードから状態を決める規則」で決めたもの)は、`status` と終了コードが食い違わず、終了コードは契約の `exit` の鍵に含まれる。本体が `SystemExit`(`argparse` の `--help` を含む)で終えることは、契約の `stdout` によらず破れに当たらない。本体に結果 JSON を求めるのは契約の `stdout` が `step-result` のときだけで、破れになるのは本体が結果 JSON を出さずに返ったときである。`step-result` 以外の契約の本体が `None`・整数を返すことは破れに当たらない | `stopped`・1。`summary` に違いの中身 | | I4 | 契約の一覧 | 核の対象外の印を持たないエントリポイントは、Python なら `if __name__ == "__main__":` の中身が `run_core.main("<契約の path>", <本体>)` の 1 文、シェルなら先頭の実行文が `rc_enter "<契約の path>" "$@"` | 構造チェック(`core-entry`)が名前を出して落とす | | I5 | 契約の一覧 | 核の対象外の印は、hook の定義か、ランタイムが直接起動する根拠を持つエントリポイントにだけ付く | 構造チェック(`core-exempt`)が落とす | | I6 | 契約の一覧 | 構造チェックの例外リストの `core-entry`・`exit-meaning`・`result-read` の行は、ベースブランチより増えない | 構造チェック(`allow-added`)が落とす | @@ -61,7 +61,7 @@ | I9 | 実行 | 核は、本体が結果を出さずに正常に終えたとき標準出力へ何も足さない。結果ファイルの環境変数(`NDF_RESULT_FILE`)を子プロセスへ継がない | 契約の標準出力の形を照らすテストが落とす | | I10 | 作業の結果 | work のステップを成功と読むのは、作業の結果ファイルが結果 JSON の形で `status` が `ok` のときだけ。無い・読めない・形が違えば `stopped` | ステップを失敗にする(`on_fail` か止まる) | | I11 | フェーズの結果 | `supervise.py run` の終了コードはフェーズの結果の `status` から決まる(`ok` 0・`gate` 10・`stopped` 1) | テストが落とす | -| I12 | 実行 | 外部 CLI の起動を `ok` と読むのは、監視が正常な終わり(監視ログの状態が `OK` か `NO_RESULT`)を記録し、監視の理由が失敗(時間切れ・利用上限・認証切れ・起動の失敗)を示さず、結果ファイルが空でなく読めるときだけ。結果ファイルがあっても、監視の状態か理由が失敗を示せば `ok` にしない。進捗の文の語で止まりを決めない | `stopped` と理由 | +| I12 | 実行 | 外部 CLI の起動を `ok` と読むのは、監視ログの状態(`status`)が `OK` か `NO_RESULT` で、理由(`reason`)が `ok` か `missing` で、結果ファイルが空でなく読めるときだけ。理由がそれ以外(`cli_timeout`・`usage_limit`・`auth_expired`・`model_unavailable` など、`monitor_outcome.REASONS` の残りの値)なら、状態が `OK` か `NO_RESULT` でも失敗と読む。結果ファイルがあっても、監視の状態か理由が失敗を示せば `ok` にしない。進捗の文の語で止まりを決めない | `stopped` と理由 | | I13 | 実行 | ほかのエントリポイントの結果 JSON を読んで次の手を決める箇所は共通の読み手を通し、`ok` でない結果を成功と読まない | 構造チェック(`result-read`)が落とす | | I14 | 閉じる判定 | 閉じる対象のうち閉じなかった課題(`kept_open`・`closed_unverified`・`failed`)が 1 件でもあれば `ok` を返さない。参照しているだけの課題は状態に影響しない | `failed` があれば `stopped`・1、無ければ `gate`・10 | @@ -124,7 +124,7 @@ | 進捗の分類(`supervise_lib/state.py`) | worker の進捗の行を `state` の値と同じ文の繰り返しで分け、文の語を読まない | 変更 | F6 | | フェーズの実行(`supervise_lib/engine.py`・`commands.py`・`steps.py`) | 終わり方をフェーズの結果へ書き、`supervise.py run` の終了コードにする。`steps.last_json` を共通の読み手へ寄せる | 変更 | F5 | | エージェントの定義(`plugins/ndf/agents/worker.md`・`supervisor.md`・`supervisor-waits.md`) | worker は渡された置き場へ作業の結果ファイルを書く。supervisor は置き場を渡し、成否をそのファイルで決める | 変更 | F4 | -| 外部 CLI の起動(`skills/external-ai/scripts/external-ai.py`) | `ok` の条件を「監視が正常な終わりを記録し、監視の状態・理由が失敗を示さず、結果ファイルが空でなく読める」(I12)にそろえる | 変更 | F6 | +| 外部 CLI の起動(`skills/external-ai/scripts/external-ai.py`) | `ok` の条件を「監視ログの状態が `OK` か `NO_RESULT`、理由が `ok` か `missing` で、結果ファイルが空でなく読める」(I12)にそろえる | 変更 | F6 | | 課題を閉じる処理(`plugins/ndf/scripts/sprint-close.py`) | 閉じなかった課題があれば `gate`・10(失敗があれば `stopped`・1)。呼び出しの誤りを 2 にする | 変更 | F7 | | 64 のエントリポイント | 末尾を核の 1 文にする。独自の `EXIT_` 定数を表の定数へ置き換える(`handoff.py` の 4・5 ほか) | 変更 | F1・F3 | | 文書(`skills/progress-tracking/SKILL.md`・`development-workflow/references/`・`plugins/ndf/scripts/lib/README.md`・用語集) | 終了コードが変わる箇所と、作業の結果ファイルの書き方を直す | 変更 | F4・F5・F7 | From b73468b18458d3237c85aa3878776e5ff14c45b1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 03:33:49 +0000 Subject: [PATCH 017/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=A8=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直して出し直すの指摘が次の周回の設計に届くための受け入れ条件 9 件と、 用語集に 5 語(出し直しの指摘・出し直しの指摘の履歴・指摘への対応の表・ 設計のプラン・答えの種類)を足した。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 5 + docs/glossary/glossary.json | 30 +++++ issues/issue-2510-requirements.md | 209 ++++++++++++++++++++++++++++++ 3 files changed, 244 insertions(+) create mode 100644 issues/issue-2510-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index ad29be3f0..75f66e7b5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -401,6 +401,11 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 呼び手 | — | 関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える | — | — | — | | 残す関数の一覧 | — | 関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの | — | — | — | | プランの進行の記録 | — | supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録(`<状態のディレクトリ>/progress.jsonl`)。開発ループのまとめのプランでは `.ndf/loop/<名>/lap-<番号>/close*/` の下にある(#2315) | — | — | — | +| 出し直しの指摘 | — | 承認ゲート 1 で人が「直して出し直す」と答えたときに `--note` に渡したファイルの全文。レビューの担当が書く指摘ファイルとは別のもの | — | — | — | +| 出し直しの指摘の履歴 | — | 課題の本文に置く、出し直しの指摘を回ごとに追記する節。人の判断待ちの節を書き直しても消えない | — | — | — | +| 指摘への対応の表 | — | 出し直しの設計 PR の本文に置く表。出し直しの指摘の項目ごとに、直した / 直せない、直した箇所か直せない理由を 1 行で持つ | — | — | — | +| 設計のプラン | — | 開発ループの周回が課題ごとに組む、設計のフェーズのプラン(`sprint/<番号>-design-<課題>.json`)。要求・設計・レビュー・承認ゲート 1 のステップを持つ | — | — | — | +| 答えの種類 | — | 人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 9c6f16ced..080de224c 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5505,6 +5505,36 @@ "context": "repo-dev-checks", "meaning": "根の conftest.py が持つ、テストの置き場所(パス、またはパスに :: でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる", "pending_source": "issues/issue-2346-design.md" + }, + { + "term": "出し直しの指摘", + "context": "ndf-workflow", + "meaning": "承認ゲート 1 で人が「直して出し直す」と答えたときに `--note` に渡したファイルの全文。レビューの担当が書く指摘ファイルとは別のもの", + "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "出し直しの指摘の履歴", + "context": "ndf-workflow", + "meaning": "課題の本文に置く、出し直しの指摘を回ごとに追記する節。人の判断待ちの節を書き直しても消えない", + "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "指摘への対応の表", + "context": "ndf-workflow", + "meaning": "出し直しの設計 PR の本文に置く表。出し直しの指摘の項目ごとに、直した / 直せない、直した箇所か直せない理由を 1 行で持つ", + "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "設計のプラン", + "context": "ndf-workflow", + "meaning": "開発ループの周回が課題ごとに組む、設計のフェーズのプラン(`sprint/<番号>-design-<課題>.json`)。要求・設計・レビュー・承認ゲート 1 のステップを持つ", + "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "答えの種類", + "context": "ndf-workflow", + "meaning": "人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ", + "pending_source": "issues/issue-2510-requirements.md" } ] } diff --git a/issues/issue-2510-requirements.md b/issues/issue-2510-requirements.md new file mode 100644 index 000000000..6ab50c6c9 --- /dev/null +++ b/issues/issue-2510-requirements.md @@ -0,0 +1,209 @@ +# #2510: 並列の計画: オーケストレーターからレーンへの指示が伝わらない(直して出し直すの指摘が設計に渡らない、ほか) + +正は課題の本文(#2510)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 起票者の推定 + +この課題の本文(下の「依頼(原文)」)は、開発ループの記録と設計 PR を調べた AI が書いた。利用者の指示の原文は本文に引用されていない(「利用者の指示で検討」「利用者の判断で差し戻し」の記述だけがある)。そのため、本文の条件はすべて起票者の推定として扱い、承認ゲート 1 で利用者が確かめる。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 出し直しの指摘の全文を、次の周回の設計のプランの入力に入れる | 元の本文の受け入れ条件 1・直し方の案 1 | 受け入れ条件 1 | +| S2 | 課題の本文の指摘の履歴は、次の周回で人の判断待ちへ移しても消えない | 元の本文の受け入れ条件 2・直し方の案 2 | 受け入れ条件 3 | +| S3 | 出し直しの設計 PR の本文に、指摘ごとの対応の表を置く | 元の本文の受け入れ条件 3・直し方の案 3 | 受け入れ条件 4 | +| S4 | オーガナイザーは人に問う前に指摘と表を突き合わせ、届いていなければ人に問わずに直して出し直すへ戻す | 元の本文の受け入れ条件 4・直し方の案 B | 受け入れ条件 5(戻す上限は前提 4) | +| S5 | 答えの種類ごとに読んで動く手順を 1 つ決め、`references/parallel.md`・`loop.md` に書く | 元の本文の受け入れ条件 5・直し方の案 A | 受け入れ条件 7(予算と止めるは前提 6) | +| S6 | 出し直しの指摘を持つ課題では、要求の写しが一致しても要求のステップを流し、起動指示に指摘を入れる | 元の本文の受け入れ条件 6。#2494 の例 | 受け入れ条件 1・2 | +| S7 | MVV 判定の材料に指摘を入れ、指摘と食い違う設計を「従う」にしない | 元の本文の受け入れ条件 7。#2494 の例 | 受け入れ条件 6 | +| S8 | 指示の通り道を 1 本にまとめる作り直し(案 C)は、A・B の効き目を確かめてから要否を決める | 元の本文の直し方の案 C | 含まない | +| S9 | 推奨は A と B を両方入れること | 元の本文の直し方の案の末尾 | 受け入れ条件 1〜7 の範囲として採る | +| S10 | #2338(PR #2497)を差し戻し、この課題を直した後に選び直す。#2344(PR #2482)も次に出し直されたら差し戻す | 元の本文の「今の回避」の 2026-11-05 の結果(「利用者の判断で」と書かれている) | 含まない(ループの運用であり、この課題の変更ではない) | + +## 依頼(原文) + +書き手: 起票者(開発ループの記録と設計 PR を調べた AI)、日付: 2026-11-05。下は要求を書く前の課題の本文の全文である。 + +> ## 何が起きたか +> +> 開発ループで、承認ゲート 1 の答え「直して出し直す」(`loop answer --revise --note <指摘ファイル>`)の指摘が、出し直しの設計に反映されない。同じ指摘を 2 回送っても反映されなかった。 +> +> | 課題 | 設計 PR | 指摘を送った時刻 | 出し直しの中身 | +> | --- | --- | --- | --- | +> | #2344 | #2482 | 14:50Z、21:09Z(同じ指摘を、直す箇所を名指しして再送) | どちらの回も、指摘の後のコミットはレビューの指摘への手当てだけ。要求・受け入れ条件 1・決定 4・設計の I3・I7 は元のまま | +> | #2338 | #2497 | 21:09Z | 指摘の後の 3 コミット(22:17〜22:28Z)は、ドキュメント再構成とレビュー 4 ラウンド目への対応だけ。範囲(60 ファイル・PR 7 本・3 スプリント)は元のまま | +> +> ## 原因 +> +> 1. **指摘が設計のプランに入らない。** 周回 9 の設計のプラン(`/work/ai-plugins/.ndf/loop/lroot3/lap-9/sprint/1-design-2338.json`)に、指摘の中身(#2059・#2300 に絞る など)が無い。設計の手順は指摘を読まずに、前の設計 PR をレビューの指摘だけで直した +> 2. **指摘を書いた節が上書きで消える。** `loop answer --revise` は指摘を `loop.json` の人の判断待ちの行(`note`)と、課題の本文の節「人の判断待ち(開発ループ <名>)」に書く(`lib/sprint_loop.py` の `apply_answer` → `mark_issue`)。次の周回で同じ課題を再び人の判断待ちへ移すと、同じ節が「答え: まだ」で上書きされ、指摘が本文から消える。今の #2338 の本文には、指摘がどこにも残っていない +> +> ## 影響 +> +> 直して出し直すたびに 1 周回を使い、設計も人の承認も同じことを繰り返す。lroot3 の周回 2〜4・7〜9 の多くがこの形で使われた(#2507 の周回の表)。 +> +> ## 直し方の案 +> +> 1. 出し直しの設計のプランに、指摘の全文を入力として渡す(設計の worker の起動指示に「この指摘を満たすように直す。満たせない項目は理由を書く」として載せる) +> 2. 指摘を課題の本文の残る節(「直して出し直す」の履歴の節。回ごとに追記し、上書きしない)に書く。設計の手順はその節を読む +> 3. 出し直しの設計 PR の本文に「指摘への対応」の表(指摘ごとに直した・直せない理由)を置く。オーガナイザーが承認を問うときに、そのまま材料にする +> +> ## 受け入れ条件 +> +> 1. `loop answer --revise --note` の指摘の全文が、次の周回の設計のプランの入力に入る +> 2. 課題の本文の指摘の履歴は、次の周回で人の判断待ちへ移しても消えない +> 3. 出し直しの設計 PR の本文に、指摘ごとの対応の表がある +> +> ## 広げた範囲: オーケストレーターからレーンへの指示が伝わらない(利用者の指示で検討、2026-11-05) +> +> 「直して出し直す」の指摘は 1 つの例である。2026-11-04〜05 の計画 p3 では、次の形が起きた。 +> +> | 送ったもの | 届いたか | 落ちた場所 | +> | --- | --- | --- | +> | 設計の承認(approved) | 記録には届いた | 承認の後のマージに持ち主がいない(#2507) | +> | 直して出し直すの指摘(note) | 記録には届いた | 設計のプランが読まない。本文の節は次の周回で上書きされる(この課題) | +> | 同じ問いへの答え | 行き違いで再送が来た | 答えが届く前に、レーンが同じ問いを送った | +> | 引継ぎ文書の書き換え(ループ名・予算) | 届いた | レーンが起こし直しのときに読むので伝わった | +> +> **共通の原因**: 答えは記録(`loop.json`・耐久のイベント)には届く。しかし、その値を読んで動く側が決まっていない値がある。レーンが指示どおりに動いたかを確かめる手順も無く、人が設計を読んで初めて気づいた。 +> +> ### 直し方の案 +> +> - **A. 読む側を決める(元を断つ)**: 答えの種類ごとに、読んで動く手順を 1 つ決める。承認はオーガナイザーが設計 PR をマージする(#2507)。直して出し直すは、指摘の全文を設計のプランの入力に入れる(上の 1・2)。予算は `loop start --budget-*` で上書きする +> - **B. 届いたかを確かめる(漏れを拾う)**: 出し直しの設計 PR の本文に「指摘への対応」の表を置く(上の 3)。オーガナイザーは人に問う前に、指摘と表を突き合わせる。足りなければ人に聞かず、直して出し直すへ自分で戻す(同じ指摘のまま、届かなかった項目を名指しする) +> - **C. 指示の通り道を 1 本にまとめる**: 承認・指摘・止める・予算などを、種類を持つ 1 つのメッセージにする。レーンは周回の境で読んで「当てた」を記録し、当てていない指示はオーガナイザーの `wait` に出す。A・B の上位の形だが大きな作り直しになるので、A・B で効き目を確かめてから要否を決める +> +> 推奨は A と B を両方入れること。 +> +> ### 受け入れ条件(足すもの) +> +> 4. オーガナイザーは、出し直しの設計の承認を人に問う前に、指摘と「指摘への対応」の表を突き合わせる。届いていない項目があれば、人に問わずに直して出し直すへ戻す +> 5. 答えの種類(承認・直して出し直す・やらない・予算・止める)ごとに、読んで動く手順が 1 つ決まっていて、手順書(`references/parallel.md`・`loop.md`)に書かれている +> 6. 直して出し直す指摘を持つ課題の設計のプランでは、要求の写しが一致して `requirements-check` が 0 を返しても要求のステップが流れ、起動指示に指摘の本文が入る(テストで確かめる) +> 7. 直して出し直す指摘を持つ課題の MVV 判定(`mvv-gate.py check --gate design`)の材料に指摘の本文が入り、指摘と食い違う設計は「従う」にならない(テストで確かめる) +> +> ### 同じ根を持つ課題 +> +> - #2494: 開発ループ lflow2 の周回 3 で、#2388 の出し直しの設計が要求のステップを飛ばし、指摘を反映しないまま MVV 判定「従う」で承認ゲート 1 を通った(設計 PR https://github.com/devbasex/ai-plugins/pull/2479 )。受け入れ条件 6・7 はこの例から来る +> - #2489: 開発ループ lroot3 の周回 2 で、#2342 の出し直しの設計の指示に指摘が入らなかった。同じ課題の、作業ブランチが設計 PR の最新から始まらない点は #2489 が持つ +> +> ### 今の回避(2026-11-05) +> +> #2338 と #2344 の本文に、節「設計の承認ゲート 1 での利用者の指摘(直して出し直す)」として指摘を写した。この節は上書きされない。設計の手順は課題の本文を読むので、これで届く見込みである。 +> - 2026-11-05 の結果: 本文の節に書き込んでも、#2338 の 3 回目の出し直し(lroot3 周回 11)は範囲が元のままだった。設計の手順は課題の本文の節も読んでいない。利用者の判断で #2338(PR #2497)は差し戻し、この課題を直した後に選び直す。#2344(PR #2482)も次に出し直されたら差し戻す + +## 目的 + +- 人が承認ゲート 1 で「直して出し直す」と答えて送った指摘を、次の周回の設計が必ず読み、指摘ごとに直したか・直せない理由を示した状態で人へ戻す +- 指摘が届いていない出し直しの設計を、人が読む前に機械と AI が見つけて戻す。人が同じ指摘を 2 回送ることを無くす +- 人の答え(承認・直して出し直す・やらない・予算・止める)ごとに、その答えを読んで動く手順を 1 つに決める + +## 前提 + +- 前提 1: 設計のプランに渡す指摘は、その課題について送られた全回の出し直しの指摘とする(最新の 1 回だけにしない)。前の回の指摘を満たした状態を、次の回の設計で崩さないためである +- 前提 2: 指摘の項目は、`--note` に渡したファイルの箇条書き(`-` / `*`)と番号付きの行を 1 項目として数える。どちらも無ければ全文を 1 項目とする。「指摘への対応」の表はこの項目ごとに 1 行を持つ +- 前提 3: 出し直しの指摘と「指摘への対応」の表の突き合わせ(受け入れ条件 5)は、人の判断待ちの行へ移す共通の経路で行う。並列の計画のオーガナイザーが問う場合と、並列でない開発ループの conductor が問う場合の両方に効く(Value 7。直す場所を経路ごとに分けない) +- 前提 4: 人へ問わずに直して出し直すへ自分で戻すのは、同じ課題・同じ指摘の回につき 1 回までとする。2 回目も届いていない項目が残れば、届いていない項目を添えて人へ問う(Value 3。役に立たない繰り返しを始めない) +- 前提 5: 課題の本文の「出し直しの指摘の履歴」の節を書けなかった(GitHub の書き込みの失敗)ときも、ループの記録の人の判断待ちの行に指摘が残り、設計のプランは記録から指摘を読む。本文の節は人が読むための写しで、設計のプランの入力の正本はループの記録とする +- 前提 6: 予算の答えは、既にある `loop start --budget-*` / `sprint-state.py loop budget`(予算の反映)が読んで動く手順とし、この課題で新しい経路を作らない。止めるの答えは、既にある `loop stop` が置く止める指示を周回の境で読む手順とする + +## 対象範囲 + +含む: +- 出し直しの指摘を、次の周回の設計のプランの要求のステップと設計のステップの起動指示へ入れる +- 出し直しの指摘を持つ課題では、要求の写しが一致しても要求のステップを流す +- 課題の本文に、回ごとに追記して上書きしない「出し直しの指摘の履歴」の節を置く +- 出し直しの設計 PR の本文に「指摘への対応」の表を置く +- 人の判断待ちの行へ移す前に、出し直しの指摘と「指摘への対応」の表を突き合わせ、届いていない項目があれば直して出し直すへ戻す +- 承認ゲート 1 の MVV 判定(`mvv-gate.py check --gate design`)の材料へ出し直しの指摘を入れる +- 答えの種類ごとの、読んで動く手順の表を `development-workflow` の `references/parallel.md`・`references/loop.md` に書く + +含まない: +- 承認・指摘・止める・予算を種類つきの 1 つのメッセージにまとめる作り直し(元の本文の直し方の案 C)。この課題の効き目を確かめてから要否を決める +- 承認の後に設計 PR をマージする持ち主を決めること(#2507 が持つ) +- 出し直しの作業ブランチが設計 PR の最新から始まらない点(#2489 が持つ) +- #2338(PR #2497)・#2344(PR #2482)の選び直しと差し戻し。この課題の配布の後に、利用者が選び直す +- 答えが届く前にレーンが同じ問いを送る行き違い(元の本文の表の 3 行目)。答えの読み手を決める範囲の外である + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 人が直して出し直すの答えを送った | `sprint-state.py loop answer --revise --note <ファイル>` か `parallel.py answer <計画名> <依頼> --revise --note <ファイル>` | ファイルを読めなければ答えを拒み、記録を書かない(今の振る舞い) | 課題が人の判断待ちの行にある | +| E2 | 答えを人の判断待ちの行へ当てた(指摘の全文と回数を書いた) | 並列でない開発ループでは E1 の場で、並列の計画ではレーンが周回の境で | 記録を書けなければ答えを当てない。次の周回の境で当て直す | E1 | +| E3 | 課題の本文の「出し直しの指摘の履歴」の節へ、その回の指摘を追記した | E2 | 書けなければ理由を結果の `note` に残す。設計のプランは記録から読むので止めない(前提 5) | E2 | +| E4 | 次の周回で課題を先頭に選んだ | 周回の選定(直して出し直す課題を先頭に選ぶ今の振る舞い) | — | E2 | +| E5 | 設計のプランを組み、出し直しの指摘の全回分を要求と設計のステップの起動指示へ入れた | E4 | 記録から指摘を読めなければプランを組まずに止める | E4 | +| E6 | 要求のステップが、要求の写しの一致によらず流れ、指摘に合わせて要求を直した | E5 | ステップが失敗すればプランの失敗として止まる(今の振る舞い) | E5 | +| E7 | 設計のステップが設計を直し、設計 PR の本文に「指摘への対応」の表を書いた | E6 | 表が無いまま設計 PR を出したときは E9 で戻す | E6 | +| E8 | MVV 判定が、出し直しの指摘を材料に含めて判定した | 承認ゲート 1 の手前の MVV 判定 | 判定できなければ人へ問う(今の振る舞い) | E7 | +| E9 | 出し直しの指摘と「指摘への対応」の表を突き合わせた | 人の判断待ちの行へ移す前 | 届いていない項目があれば、その項目を名指しして直して出し直すへ戻す(前提 4 の上限まで) | E7・E8 | +| E10 | 課題を人の判断待ちの行へ移し、判断待ちの節を書き直した(履歴の節は残る) | E9 で項目がそろった、か前提 4 の上限に達した | 本文を書けなければ理由を結果に残す(今の振る舞い) | E9 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 出し直しの指摘 | 承認ゲート 1 で人が「直して出し直す」と答えたときに `--note` に渡したファイルの全文。レビューの担当が書く指摘ファイルとは別のもの | +| 出し直しの指摘の履歴 | 課題の本文に置く、出し直しの指摘を回ごとに追記する節。人の判断待ちの節を書き直しても消えない | +| 指摘への対応の表 | 出し直しの設計 PR の本文に置く表。出し直しの指摘の項目ごとに、直した / 直せない、直した箇所か直せない理由を 1 行で持つ | +| 設計のプラン | 開発ループの周回が課題ごとに組む、設計のフェーズのプラン(`sprint/<番号>-design-<課題>.json`)。要求・設計・レビュー・承認ゲート 1 のステップを持つ | +| 答えの種類 | 人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ | + +## 受け入れ条件 + +- [ ] 1. 課題に出し直しの指摘が 1 回以上ある状態で次の周回の設計のプランを組むと、要求のステップと設計のステップの起動指示に、その課題の全回の出し直しの指摘の全文が入る。起動指示は「この指摘を満たすように直す。満たせない項目は理由を書く」の旨を含む(テストで確かめる) +- [ ] 2. 出し直しの指摘を持つ課題の設計のプランでは、要求の写しが課題の本文と一致して `requirements-check` が 0 を返しても、要求のステップが流れる(テストで確かめる) +- [ ] 3. 直して出し直すの答えを当てるたびに、課題の本文の「出し直しの指摘の履歴」の節へ、回数・日付・設計 PR・指摘の全文が 1 回分ずつ追記される。その課題を次の周回で人の判断待ちへ移して判断待ちの節を書き直した後も、前の回の指摘が本文に残る(テストで確かめる) +- [ ] 4. 出し直しの設計 PR の本文に「指摘への対応」の表があり、出し直しの指摘の項目(前提 2)ごとに 1 行を持つ +- [ ] 5. 出し直しの設計を人の判断待ちの行へ移す前に、出し直しの指摘の項目と「指摘への対応」の表を突き合わせる。表が無い・行の無い項目がある・対応の欄が空の項目があるときは、人に問わずに、届いていない項目を名指しした直して出し直すへ戻す。戻すのは前提 4 の上限までで、上限に達したら届いていない項目を添えて人へ問う(テストで確かめる) +- [ ] 6. 出し直しの指摘を持つ課題の承認ゲート 1 の MVV 判定(`mvv-gate.py check --gate design`)の材料に、出し直しの指摘の全文が入る。判定の問いは、指摘の項目を満たさない設計を「従う」にしないことを含む(テストで確かめる) +- [ ] 7. `references/parallel.md` と `references/loop.md` に、答えの種類(承認・直して出し直す・やらない・予算・止める)ごとに、その答えを読んで動く手順を 1 つずつ示す表がある。両方の文書で同じ表を写さず、片方が正本で、もう片方は正本を指す +- [ ] 8. 出し直しの指摘を持たない課題の設計のプランは変わらない。要求の写しが一致すれば要求のステップを飛ばし、起動指示に指摘の欄が入らない(テストで確かめる) +- [ ] 9. 直して出し直す以外の答え(承認・やらない)を当てたときの、承認の記録・ラベル・人の判断待ちの節の振る舞いは変わらない(既存のテストが通る) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 出し直しの指摘を読む処理と、指摘の項目を数える処理は、並列でない開発ループと並列の計画のレーンで 1 つの関数を共有する(Value 7) | +| 移行性 | 既にある課題の本文の「人の判断待ち(開発ループ <名>)」の節に残る指摘は移さない。この変更の後に当てた答えから履歴の節へ書く。ループの記録の既存の `note` は、設計のプランの入力として読める | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。`loop answer`・`parallel.py answer` の引数は今のまま。ループの記録の人の判断待ちの行は欄を足すだけで、既存の欄の意味を変えない | +| データ | ループの記録(`.ndf/loop/<名>/loop.json`)の人の判断待ちの行に、自分で戻した回数などの欄が足りうる(形は設計で決める)。課題の本文に履歴の節が増える | +| 既存の振る舞い | 出し直しの指摘を持つ課題の設計のプランの要求のステップ・起動指示・MVV 判定の材料・人の判断待ちへ移す経路が変わる | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4` | +| 静的解析 | CI のジョブ(`ruff` ほか、リポジトリの既存の設定) | +| 手動確認 | 配布の後、開発ループで出し直しの指摘を 1 回送り、次の周回の設計 PR に「指摘への対応」の表があり、指摘の項目が設計に反映されたことを人が読んで確かめる(リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | スクリプトは `plugins/ndf/scripts/`(ライブラリは `lib/`・`supervise_lib/`)、手順書は `plugins/ndf/skills/development-workflow/references/`。実験版の置き場は使わない(既定で働く経路を直すため、`AGENTS.md` の「安定版と実験版」の安定版) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない(受け入れ条件 7 は文書のレビューで確かめる) | +| テスト戦略 | 受け入れ条件 1〜3・5・6・8 はスクリプトの単体テスト(`plugins/ndf/scripts/tests/`)で、外部の `gh` と CLI は差し替えて確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存のテストの実行、変更範囲内の語の統一(用語集の語に合わせる) | +| 確認してから行う | ループの記録の欄を足すこと(承認ゲート 1 の設計で人が見る) | +| 行わない | 指示の通り道を 1 本にまとめる作り直し、#2507・#2489 が持つ範囲の変更、既存の課題の本文の書き換え | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 「指摘への対応」の表を書く担い手(設計のステップの worker が設計 PR の本文へ書くか、設計文書に書いて PR の本文へ写すか) | 設計(`design`) | 承認ゲート 1 | +| 突き合わせ(受け入れ条件 5)のうち、表の行がそろっていても中身が指摘を満たさない場合の扱い。受け入れ条件 6 の MVV 判定に任せるか、別の判定を足すか | 設計(`design`) | 承認ゲート 1 | +| 前提 4 の自分で戻す上限(1 回)の妥当性 | 利用者(承認ゲート 1) | 承認ゲート 1 | From 289a7f4b28bcd6c9cf4564e3e0f39f13e5b9fe95 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 03:40:32 +0000 Subject: [PATCH 018/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 直して出し直すの指摘を、ループの記録から周回ごとに書き出す入力ファイル経由で 設計のプランの要求・設計の起動指示と MVV 判定へ渡す。課題の本文には追記だけの 履歴の節を置き、設計文書の「出し直しの指摘への対応」の表を設計 PR の本文へ写して 突き合わせ、欠けがあれば人へ問わずに 1 回だけ自動で出し直す。答えの種類ごとの 手順の表は loop.md を正本にする。用語集に 2 語(自動の出し直し・出し直しの指摘の 入力)を足した。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 2 + docs/glossary/glossary.json | 12 + issues/issue-2510-design.md | 424 ++++++++++++++++++++++++++++++++++++ 3 files changed, 438 insertions(+) create mode 100644 issues/issue-2510-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 75f66e7b5..027721ae7 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -406,6 +406,8 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 指摘への対応の表 | — | 出し直しの設計 PR の本文に置く表。出し直しの指摘の項目ごとに、直した / 直せない、直した箇所か直せない理由を 1 行で持つ | — | — | — | | 設計のプラン | — | 開発ループの周回が課題ごとに組む、設計のフェーズのプラン(`sprint/<番号>-design-<課題>.json`)。要求・設計・レビュー・承認ゲート 1 のステップを持つ | — | — | — | | 答えの種類 | — | 人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ | — | — | — | +| 自動の出し直し | — | 出し直しの設計が出し直しの指摘の項目を指摘への対応の表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | — | — | — | +| 出し直しの指摘の入力 | — | 開発ループが周回ごとに、課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 080de224c..44680468c 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5535,6 +5535,18 @@ "context": "ndf-workflow", "meaning": "人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ", "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "自動の出し直し", + "context": "ndf-workflow", + "meaning": "出し直しの設計が出し直しの指摘の項目を指摘への対応の表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで", + "pending_source": "issues/issue-2510-design.md" + }, + { + "term": "出し直しの指摘の入力", + "context": "ndf-workflow", + "meaning": "開発ループが周回ごとに、課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む", + "pending_source": "issues/issue-2510-design.md" } ] } diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md new file mode 100644 index 000000000..869fcecbe --- /dev/null +++ b/issues/issue-2510-design.md @@ -0,0 +1,424 @@ +# 開発ループ: 直して出し直すの指摘を次の周回の設計が読み、届いたかを人へ問う前に確かめる(#2510) + +## 目的 + +- **今どうなっているか**: 承認ゲート 1 で人が「直して出し直す」と答えて送った指摘が、次の周回の設計のプランの入力に入らない。課題の本文に書いた指摘も、次に人の判断待ちへ移すときの書き直しで消える。#2344・#2338 では同じ指摘を 2 回送っても設計が変わらなかった +- **誰に効くか**: 開発ループと並列の計画で承認ゲート 1 に答える利用者 +- **変えると何が成り立つか**: 次の周回の設計が全回の指摘を読み、指摘の項目ごとに直したか・直せない理由を設計 PR の本文に示す。示していない出し直しは、人へ問う前に機械が見つけて 1 回だけ自動で戻す。答えの種類ごとに、読んで動く手順が 1 つに決まる + +## 適用範囲 + +- **働く範囲**: 配布先のリポジトリでも働く。開発ループ(`--loop`)と並列の計画(`--parallel`)のレーンの両方で同じ経路を通る +- **プロジェクトごとに違うもの**: 無し。指摘の項目の数え方と自動で戻す上限(1 回)は固定で、設定を足さない +- **当たるモード**: 開発ループが組む設計のプラン(`pace: auto`)を通るすべてのモード。単発の `/ndf:design` は出し直しの指摘を持たないため、振る舞いは変わらない + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 周回 9 の設計のプラン(`.ndf/loop/lroot3/lap-9/sprint/1-design-2338.json`)に、指摘の中身(#2059・#2300 に絞る など)が無い | 実測(課題の本文の「原因」) | 指摘は記録に届くが、設計のプランの入力に入っていない | +| #2338 の本文に節として指摘を写しても、3 回目の出し直し(lroot3 周回 11)の範囲が元のままだった | 実測(課題の本文の「今の回避」) | 本文に書くだけでは設計の手順に届かない。プランの起動指示へ入れる必要がある | +| #2494: 出し直しの設計が要求のステップを飛ばし、指摘を反映しないまま MVV 判定「従う」で承認ゲート 1 を通った( https://github.com/devbasex/ai-plugins/pull/2479 ) | 実測(同じ根を持つ課題) | 要求の写しの一致で要求のステップを飛ばす分岐と、MVV 判定の材料に指摘が無いことが、指摘の取りこぼしを人まで届けない | + +要求と受け入れ条件は #2510 の本文にある(コピーは [issues/issue-2510-requirements.md](issue-2510-requirements.md) )。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の開発ワークフロー(`ndf-workflow`) | 出し直しの指摘・出し直しの指摘の履歴・指摘への対応の表・設計のプラン・答えの種類・自動の出し直し | + +1 つのコンテキストに収まる。開発ループと並列の計画のレーンは同じループの記録を同じ関数で書くため、コンテキストを分けない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| ループの記録 | 開発ループの集約(`lib/sprint_loop.py`・`lib/sprint_loop_gate.py`) | ループ | 人の判断待ちの行 | 出し直しの指摘・届いていない項目の一覧 | +| 出し直しの指摘の入力 | 開発ループの集約が周回ごとに書き出す。読むのは設計のプランの組み立て・MVV 判定・表の突き合わせ | 課題ごとの入力 | 回 | 指摘の項目(識別子 `<回>-<番号>` と本文) | +| 出し直しの指摘の履歴 | 開発ループの集約(答えを当てる経路だけが追記する) | 課題の本文の節 | 回の記載 | — | +| 指摘への対応の表 | 設計のステップの worker が設計文書へ書く。設計 PR の本文へは表の突き合わせのコマンドが写す | 設計文書の節 | 表の行 | 対応(直した / 直せない)・直した箇所か直せない理由 | + +出し直しの指摘の入力は、ループの記録から作る読み取り専用の写しである。正本はループの記録の人の判断待ちの行で、課題の本文の履歴は人が読むための写しとする(要求の前提 5)。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 出し直しの指摘の入力 | 課題の入力の回は、ループの記録でその課題の答えが「直して出し直す」の行を、記録の順にすべて持つ(人の回と自動の回の両方) | 入力を書けなければ設計のプランを組まずに止める(周回を `refused` で止める) | +| I2 | 出し直しの指摘の入力 | 指摘の項目は人の回の指摘からだけ数える。箇条書き(`-` / `*`)と番号付きの行を 1 項目とし、どちらも無ければ全文を 1 項目とする。自動の回は項目を足さず、届いていない項目の識別子だけを持つ | — (数え方の関数が 1 つで、ほかの経路が数えない) | +| I3 | 出し直しの指摘の履歴 | 履歴の節は追記だけで、既にある回の記載を消さない・書き換えない。同じ回を 2 度書かない | 本文を書けなければ理由を結果に残し、記録の書き込みは止めない(要求の前提 5) | +| I4 | 設計のプラン | 出し直しの指摘を持つ課題では、要求のステップを写しの一致によらず流し、要求と設計のステップの起動指示に全回の指摘を入れる。持たない課題のプランは今と同じ | — (組み立ての分岐で守る) | +| I5 | 指摘への対応の表 | 設計 PR の本文の表は、設計文書の節を写したものだけである。本文の側で書き足さない | 写しが食い違えば、表の突き合わせのコマンドが設計文書の側で本文を書き直す | +| I6 | ループの記録 | 出し直しの指摘を持つ課題を人の判断待ち(答えがまだの行)へ移すのは、すべての項目に表の行があり、対応と箇所・理由の欄が空でないときか、自動の出し直しの上限に達したときだけである | 項目が欠ければ、人へ問わずに自動の出し直しの行にする | +| I7 | ループの記録 | 自動の出し直しは、人の出し直しの 1 回につき 1 回まで(直前の出し直しの行が自動なら、次は人へ問う) | 上限に達したら、届いていない項目を理由に添えて人へ問う | +| I8 | ループの記録 | 承認とやらないの答えを当てたときの、承認の記録・ラベル・人の判断待ちの節の振る舞いは今と同じ | — (既存のテストで守る) | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 人が直して出し直すの答えを送った | `loop answer --revise --note` / `parallel.py answer --revise --note` | 開発ループの集約(並列でなければその場、レーンなら周回の境) | +| E2 | 答えを人の判断待ちの行へ当てた | 開発ループの集約の答えを当てる関数 | 出し直しの指摘の履歴の追記 | +| E3 | 履歴の節へその回の指摘を追記した | 開発ループの集約 | 課題の本文を読む人 | +| E4 | 次の周回で課題を先頭に選んだ | 周回の選定 | 周回のスプリントの組み立て | +| E5 | 設計のプランへ全回の指摘を入れた | 周回のスプリントの組み立て(出し直しの指摘の入力を書き、プランの組み立てへ渡す) | 要求・設計のステップの worker | +| E6 | 要求のステップが写しの一致によらず流れた | 設計のプラン | 設計のステップ | +| E7 | 設計を直し、設計文書に指摘への対応の表を書いた | 設計のステップの worker | 表の突き合わせのステップ | +| E8 | MVV 判定が出し直しの指摘を材料に含めて判定した | MVV 判定のステップ | 承認ゲート 1 | +| E9 | 指摘と表を突き合わせた | 表の突き合わせのステップ(プランの中)と、人の判断待ちへ移す関数(周回の締め) | 自動の出し直し、または人の判断待ちへ移す処理 | +| E10 | 課題を人の判断待ちへ移し、判断待ちの節を書き直した(履歴の節は残る) | 人の判断待ちへ移す関数 | 承認ゲート 1 に答える利用者・オーガナイザー | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 自動の出し直し | 出し直しの設計が指摘の項目を表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | 追加 | +| 出し直しの指摘の入力 | 開発ループが周回ごとに、課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・表の突き合わせが読む | 追加 | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 出し直しの指摘を持つ課題の設計のプランに、全回の指摘を入れ、要求のステップを必ず流す | 開発ループ(要求と設計のステップの worker が読む) | +| F2 | 直して出し直すの答えを当てるたびに、課題の本文の履歴の節へ 1 回分を追記する | 承認ゲート 1 に答える利用者 | +| F3 | 設計文書の指摘への対応の表を、設計 PR の本文へ写して突き合わせる | 承認ゲート 1 に答える利用者・オーガナイザー | +| F4 | 表に欠けがあれば、人へ問わずに自動の出し直しへ戻す(1 回まで) | 開発ループ | +| F5 | MVV 判定の材料に出し直しの指摘を入れ、指摘を満たさない設計を「従う」にしない | 開発ループ(承認ゲート 1 の判定) | +| F6 | 答えの種類ごとに、読んで動く手順を手順書の表で示す | 利用者・オーガナイザー・conductor | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 出し直しの指摘の部品(新規 `lib/revise_note.py`) | 指摘の読み取りと項目の数え方の唯一の実装。課題の回の取り出し・項目の分割・入力ファイルの書き出しと読み込み・起動指示の文面・表の解析と欠けの判定・自動の出し直しの上限の判定・履歴の 1 回分の文面を持つ | +| 表の突き合わせのコマンド(新規 `revise-note.py check`) | 設計文書の「出し直しの指摘への対応」の節を設計 PR の本文へ写し、入力の項目と突き合わせる。そろえば 0、欠ければ 10(承認ゲートで止める)、読めなければ 2 | +| 開発ループの答えを当てる関数(`lib/sprint_loop.py` の `apply_answer`) | 直して出し直すの答えで、記録の行を書いた後に履歴の節へ追記する | +| 周回のスプリントの組み立て(`lib/sprint_loop.py` の `cmd_sprint`・`new_sprint_argv`) | 設計する課題のうち出し直しの指摘を持つものの入力ファイルを周回のディレクトリへ書き、`supervise.py new sprint` へ `--revise-note <課題>=<パス>` で渡す | +| 人の判断待ちへ移す関数(`lib/sprint_loop_gate.py` の `move_to_waiting`) | 出し直しの指摘を持つ課題で、設計 PR の本文の表を突き合わせる。欠けがあり上限内なら自動の出し直しの行にし、オーガナイザーへ設計の承認の依頼を送らない | +| 判断待ちの節の文面(`lib/sprint_loop_gate.py` の `waiting_section`) | 指摘の全文を載せる代わりに、履歴の節の何回目かを指す 1 行を載せる。自動の出し直しなら届いていない項目を載せる | +| 設計のプランの組み立て(`supervise_lib/sprint_waves.py` の `plan_sprint_design`・`plan_mvv_design`) | 出し直しの指摘を持つ課題で、要求の写しのチェックを外して要求のステップへ直行させ、要求と設計の起動指示へ指摘を入れ、表の突き合わせのステップを足し、MVV 判定へ入力を渡す | +| `supervise.py new sprint` の引数(`supervise_lib/new_args.py`) | `--revise-note <課題>=<パス>`(繰り返し可)を受ける | +| MVV 判定(`mvv-gate.py`) | `--revise-note <パス>` を受け、指摘を材料の 1 件として足し、承認ゲートの問いへ「指摘の項目を満たさない設計は従うにしない」を足す | +| 手順書(`development-workflow` の `references/loop.md`・`references/parallel.md`) | 答えの種類ごとの読んで動く手順の表を `loop.md` に置き、`parallel.md` の問い方の節から指す | +| `/ndf:pr` の「設計 Pull Request の本文」(`skills/pr/SKILL.md`) | 節「出し直しの指摘への対応」を、手で書かない節として表に足す | + +```mermaid +graph TD + subgraph 開発ループ + A[答えを当てる] + B[スプリントの組み立て] + C[人の判断待ちへ移す] + end + subgraph 部品 + R[出し直しの指摘の部品] + end + subgraph 設計のプラン + P[プランの組み立て] + K[表の突き合わせ] + M[MVV 判定] + end + A --> R + B --> R + C --> R + B -->|入力ファイル| P + P --> K + P --> M + K --> R + M --> R +``` + +図には処理の流れに現れない要素(手順書・`/ndf:pr` の Skill・`supervise.py new sprint` の引数の定義)を含めない。判断待ちの節の文面は「人の判断待ちへ移す」と「答えを当てる」の中で使われる。 + +システムの文脈と配置は次のとおりである。外部の系は GitHub(課題の本文・設計 PR の本文)だけで、すべての処理は利用者の手元のループの実行場所で動く。 + +```mermaid +graph TD + U[利用者] -->|答え| L[開発ループ] + L -->|記録| J[ループの記録] + L -->|入力ファイル| D[周回のディレクトリ] + L -->|履歴の追記| G[GitHub の課題] + D --> S[設計のプラン] + S -->|表の写し| H[GitHub の設計 PR] +``` + +パッケージの置き場は次のとおりである。 + +```text +plugins/ndf/scripts/ +├── revise-note.py # 新規。表の突き合わせのコマンド +├── mvv-gate.py # --revise-note を足す +├── lib/ +│ ├── revise_note.py # 新規。出し直しの指摘の部品 +│ ├── sprint_loop.py # 答えを当てる・スプリントの組み立て +│ └── sprint_loop_gate.py # 人の判断待ちへ移す・判断待ちの節 +└── supervise_lib/ + ├── new_args.py # --revise-note を足す + └── sprint_waves.py # 設計のプランの分岐 +``` + +## 構造 + +コードは関数と辞書で書かれ、型の定義は無い。変える形は、ループの記録の行と出し直しの指摘の入力の 2 つの辞書である。 + +```mermaid +classDiagram + class 人の判断待ちの行 { + number + answer + note + revise_count + by + missing + } + class 出し直しの指摘の入力 { + issue + rounds + } + class 回 { + round + at + pr + by + note + items + missing + } + class 項目 { + id + text + } + 出し直しの指摘の入力 "1" --> "1..*" 回 + 回 "1" --> "0..*" 項目 + 人の判断待ちの行 ..> 回 : 写す +``` + +## データ構造 + +### 人の判断待ちの行に足す欄 + +既存の欄(`note`・`revise_count` ほか)の意味は変えない。足すのは自動の出し直しの 2 欄だけである。 + +| 欄 | 型 | 空の扱い | 意味 | +| --- | --- | --- | --- | +| `by` | 文字列 | 無ければ人の答え | `auto` のとき自動の出し直し | +| `missing` | 文字列の配列 | 無ければ欠けなし | 自動の出し直しで名指しした、届いていない項目の識別子(`1-2` など) | + +自動の出し直しの行は `answer` を `revise`、`note` を空にする。項目は人の回の指摘からだけ数えるため(I2)、自動の回は指摘の本文を持たない。 + +### 出し直しの指摘の入力(`<周回のディレクトリ>/revise-<課題>.json`) + +周回のスプリントを組むたびに、ループの記録から作り直す。手で直さない。 + +```json +{ + "issue": 2338, + "rounds": [ + {"round": 1, "at": "2026-11-04T21:09:00Z", "pr": 2497, "by": "user", + "note": "- #2059 と #2300 に絞る\n- PR は 2 本にする", + "items": [{"id": "1-1", "text": "#2059 と #2300 に絞る"}, {"id": "1-2", "text": "PR は 2 本にする"}], + "missing": []}, + {"round": 2, "at": "2026-11-05T10:00:00Z", "pr": 2497, "by": "auto", "note": "", "items": [], "missing": ["1-2"]} + ] +} +``` + +### 課題の本文の履歴の節 + +見出しは `## 出し直しの指摘の履歴` で、ループの名前を入れない(ループを替えても 1 つの節に続けて書く)。1 回分は次の形で末尾へ足す。 + +```markdown +### 第 2 回(2026-11-05・設計 PR #2497・開発ループ lroot3・自動) + +届いていない項目: 1-2 +``` + +人の回は「自動」の代わりに「人」と書き、見出しの下に指摘の全文を置く。同じ「第 N 回」の見出しが既にあれば足さない(I3)。 + +### 指摘への対応の表(設計文書と設計 PR の本文の `## 出し直しの指摘への対応`) + +| 列 | 書くこと | +| --- | --- | +| 項目 | 入力の項目の識別子(`1-1`) | +| 指摘 | 項目の本文の要約 | +| 対応 | `直した` か `直せない` | +| 直した箇所・直せない理由 | 直した節の名前か、直せない理由 | + +### CRUD 図 + +| 機能 | ループの記録の行 | 入力ファイル | 履歴の節 | 設計文書の表 | 設計 PR の本文の表 | +| --- | --- | --- | --- | --- | --- | +| F1 | 読む | 作る | — | — | — | +| F2 | 更新 | — | 作る・追記 | — | — | +| F3 | — | 読む | — | 読む | 作る・更新 | +| F4 | 作る(自動の出し直し) | — | 追記 | — | 読む | +| F5 | — | 読む | — | — | 読む | + +## 入出力の契約 + +| コマンド | 足す引数 | 振る舞い | +| --- | --- | --- | +| `supervise.py new sprint` | `--revise-note <課題>=<パス>`(繰り返し可) | `--design` に含まれる課題の設計のプランだけを変える。ファイルを読めなければ組み立てを断る(終了コード 2) | +| `mvv-gate.py check --gate design` | `--revise-note <パス>` | 材料の先頭へ「出し直しの指摘」の節を足し、承認ゲートの問いへ判定の条件を足す。読めなければ承認ゲートへ戻す(今の材料を取れないときと同じ) | +| `revise-note.py check --pr <番号> --doc <設計文書> --notes <パス>` | 新規 | 終了コード 0(項目がそろった)/ 10(表が無い・行の無い項目・対応か箇所の欄が空の項目がある。届いていない項目を `items` に出す)/ 2(PR・設計文書・入力を読めない) | + +`loop answer`・`parallel.py answer` の引数は変えない。 + +## 処理の流れ + +### 答えを当ててから次の設計のプランを組むまで + +```mermaid +sequenceDiagram + participant 利用者 + participant ループ as 開発ループ + participant GH as GitHub の課題 + participant 計画 as 設計のプラン + 利用者->>ループ: 直して出し直す(指摘ファイル) + ループ->>ループ: 行に指摘と回数を書く + ループ->>GH: 履歴の節へ 1 回分を追記 + ループ->>GH: 判断待ちの節を書き直す + Note over ループ: 次の周回で先頭に選ぶ + ループ->>ループ: 入力ファイルを書く + ループ->>計画: --revise-note で組む +``` + +並列の計画のレーンでは、1 行目の答えがオーガナイザーの `parallel.py answer` から耐久のイベントとして届き、レーンが周回の境(`apply_gate1_answers`)で同じ関数へ渡す。以降は同じである。 + +### 出し直しの設計のプランの中 + +```mermaid +graph TD + G[用語集の入口の検査] --> Q[要求のステップ
指摘つき] + Q --> D[設計のステップ
指摘つき・表を書く] + D --> X[組み直し・PR・レビュー・用語] + X --> K{表の突き合わせ} + K -->|0| M[MVV 判定
指摘を材料に] + K -->|10| E[承認ゲートで終える] + M -->|従う| A[承認とマージ] + M -->|従わない| E +``` + +出し直しの指摘を持たない課題では、`G` の次が今どおり要求の写しのチェックで、`K` は無い(I4)。`K` は用語の送り(`push-glossary`)の直後に入り、送りの元の行き先へ進む。基本形の行き先は Draft を外すステップ、MVV 判定を置く形では MVV 判定である。用語チェックの当たりが残って承認ゲートの判断(`gate`)へ回る経路は `K` を通らないが、周回の締めで同じ突き合わせを通る。 + +### 周回の締めで人の判断待ちへ移すとき + +```mermaid +graph TD + S[承認ゲート 1 で
止まった課題] --> H{出し直しの
指摘がある} + H -->|無い| W[今どおり判断待ち] + H -->|ある| T{表がそろう} + T -->|そろう| W + T -->|欠ける| L{直前の出し直しが
自動か} + L -->|人| R[自動の出し直し
依頼を送らない] + L -->|自動| W2[判断待ち
欠けを理由に添える] +``` + +`T` は設計 PR の本文を読む。読めなければ自動の出し直しにせず、「表を読めなかった」を理由に添えて判断待ちへ移す。自動の出し直しの行は、人の答えと同じく次の周回で先頭に選ばれる(今の選定の振る舞い)。 + +## 非機能の実現方式 + +| 大項目 | 実現方式 | +| --- | --- | +| 運用・保守性 | 指摘の読み取り・項目の数え方・表の解析は出し直しの指摘の部品の関数だけが持つ。開発ループ・並列の計画のレーン・設計のプランの組み立て・MVV 判定・表の突き合わせのコマンドは、すべてこの部品を呼ぶ | +| 移行性 | 既存の課題の本文の判断待ちの節に残る指摘は移さない。ループの記録の既存の `note` はそのまま入力の回として読める(`by` の無い行は人の回として扱う) | + +## 決定の記録 + +### 決定 1: 設計のプランの入力の正本を 1 つにするため、ループの記録から周回ごとに入力ファイルを書き出して渡す + +設計のプランは `supervise.py new sprint` が組み、ループの記録を読まない。記録から書き出したファイルのパスを引数で渡せば、プランの組み立ては記録の形を知らずに済み、MVV 判定と表の突き合わせも同じファイルを読める。課題の本文の履歴の節は GitHub の書き込みに失敗しうるため、入力にしない(要求の前提 5)。 + +プランの組み立てが課題の本文を読む形は採らない。本文の節は書き込みの失敗と人の編集で揃わなくなり、#2338 の回避で本文の節が読まれなかった経路と同じ弱さを持つ。 + +根拠: Value 7 / Value 5(MVV 版 5) + +### 決定 2: 表の書き手を 1 つにするため、指摘への対応の表は設計のステップの worker が設計文書へ書き、突き合わせのコマンドが設計 PR の本文へ写す + +pr のステップは既にある設計 PR の本文を作り直すため、worker が本文へ直接書いた表は次の pr のステップで消える。設計文書に置けば、レビューの直しでも同じ差分で直り、MVV 判定も設計文書の材料として読む。本文への写しは、最後の送りの後の突き合わせのステップが行う。 + +pr のステップの材料に表を足す形は採らない。pr のステップは設計 PR を出す時点で 1 回しか本文を作らず、レビューの直しで変わった表を拾えない。 + +根拠: Value 5 / Value 8(MVV 版 5) + +### 決定 3: 機械が確かめられる範囲に絞るため、表の突き合わせは項目ごとの行と欄の有無だけを見て、中身が指摘を満たすかは MVV 判定に任せる + +行と欄の有無は決まった手順で判定でき、スクリプトが持てる。中身の充足は判断であり、既に承認ゲート 1 の手前にある MVV 判定に指摘の全文と問いを足せば、新しい判定の工程を作らずに済む。MVV 判定が「従わない」を返せば人へ問われる。 + +中身を判定する AI のステップを別に足す形は採らない。MVV 判定と同じ材料で同じ問いを 2 回問うことになる。 + +根拠: Value 5 / Value 3(MVV 版 5) + +### 決定 4: 表の欠けが MVV 判定で通らないようにするため、プランの中の突き合わせが欠けを見つけたら MVV 判定へ進まず承認ゲートで終える + +MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断待ちへ移す関数を通らない。#2494 はこの経路で指摘を反映しないまま通った。プランの中で欠けを見つけて承認ゲートで終えれば、周回の締めの共通の経路(人の判断待ちへ移す関数)が自動の出し直しを決める。自動で戻すかの判断は締めの 1 か所だけに置く。 + +プランの中で自動の出し直しまで決める形は採らない。上限の判定にループの記録が要り、プランはそれを読まない。 + +根拠: Value 7(MVV 版 5) + +### 決定 5: 指摘を満たす設計を 1 回で揃えやすくするため、要求と設計の起動指示へ全回の指摘の全文を埋め込む + +起動指示にファイルのパスだけを書くと、worker が読まない経路が残る(#2338 の回避で本文の節が読まれなかった)。全文と項目の識別子を起動指示に置き、「この指摘を満たすように直す。満たせない項目は理由を書く」と、設計文書へ表を置く指示を添える。 + +最新の 1 回だけを渡す形は採らない。前の回で満たした項目を次の回で崩さないため(要求の前提 1)。 + +根拠: Value 2 / Value 7(MVV 版 5) + +### 決定 6: 課題の本文に指摘の全文を 2 か所持たないため、判断待ちの節は履歴の節の回を指す 1 行だけを載せる + +判断待ちの節は次に人の判断待ちへ移すときに書き直される。全文を履歴の節へ移し、判断待ちの節は「指摘: 節『出し直しの指摘の履歴』の第 N 回」と書けば、正本が本文の中でも 1 か所になる。 + +根拠: Value 8(MVV 版 5) + +### 決定 7: 答えの種類ごとの手順の正本を 1 つにするため、表を `loop.md` に置き、`parallel.md` から指す + +答えを読んで動くのはどちらの場合もループ(レーンもループである)で、`parallel.md` が持つのは答えを送る側の手順である。正本を読む側に置けば、手順の変更で直す文書が 1 つになる。 + +根拠: Value 8(MVV 版 5) + +## 答えの種類ごとの手順(手順書に書く表の中身) + +`loop.md` の「承認ゲート」の節に置く表の中身を決める。 + +| 答えの種類 | 送るコマンド | 読んで動く手順 | +| --- | --- | --- | +| 承認 | `loop answer --approved` / `parallel.py answer --approved` | 並列でなければ答えの場で承認の記録を書き、次の周回のスプリントで設計済みの課題として扱う。レーンは周回の境で設計 PR の先端を比べ、ラベル `design-approved` と承認の記録を書く。承認の後のマージの持ち主は #2507 が決める | +| 直して出し直す | `loop answer --revise --note` / `parallel.py answer --revise --note` | 答えを当てる関数が記録の行と履歴の節を書き、次の周回で先頭に選ぶ。スプリントの組み立てが入力ファイルを書き、設計のプランが読む(この設計) | +| やらない | `loop answer --rejected` / `parallel.py answer --rejected` | 答えを当てる関数が差し戻しの記録を書き、以後の選定から外す | +| 予算 | `loop start <名>` に `--budget-laps` / `--budget-usd` / `--budget-hours` | ループの開始と再開の起動が予算を上書きし、変更を記録の予算の履歴へ残す。止まる条件の判定は周回の境で新しい予算を読む。レーンでは起こし直しの起動の行に付ける | +| 止める | `loop stop <名>` / `parallel.py stop <計画名>` | 止める指示のファイルを置き、周回の境で止まる条件 `user_stop` として読む。並列の計画は全レーンへ止める指示を打つ | + +要求の前提 6 にある `sprint-state.py loop budget` は、今の CLI に無い(`lib/sprint_loop_cli.py` の予算の引数は `loop start` の `--budget-laps`・`--budget-usd`・`--budget-hours` の 3 つだけ)。予算の手順は `loop start --budget-*` だけを書く。 + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 1 / I1 / I4 | 出し直しの行を 2 回持つ課題でスプリントを組むと、要求と設計のステップの起動指示に 2 回分の全文と「満たすように直す。満たせない項目は理由を書く」の旨が入る | 最新の 1 回だけを入れる・自動の回を落とす・起動指示へ入れずにパスだけを書く | +| 受け入れ条件 2 / I4 | 出し直しの指摘を持つ課題のプランでは、要求の写しのチェックが一致で 0 を返しても要求のステップが流れる | 写しのチェックのステップを残して `next` を設計にする | +| 受け入れ条件 3 / I3 | 直して出し直すを 2 回当て、間に人の判断待ちへ移して判断待ちの節を書き直しても、本文に第 1 回と第 2 回の記載が残る。同じ回を当て直しても記載が 1 つ | 判断待ちの節の中へ履歴を書く・追記でなく置き換える・回の重複を見ない | +| 受け入れ条件 4 / I5 | 突き合わせのコマンドが、設計文書の表を設計 PR の本文の節へ写す。本文に別の表があれば設計文書の側で書き直す | 本文の表を正として設計文書を無視する | +| 受け入れ条件 5 / I6 | 表が無い・行の無い項目がある・対応か箇所の欄が空のとき、突き合わせのコマンドは 10、人の判断待ちへ移す関数は自動の出し直しの行(届いていない項目つき)を作り、オーガナイザーへ依頼を送らない | 欄の空を見ない・自動の出し直しでも依頼を送る | +| 受け入れ条件 5 / I7 | 直前の出し直しが自動なら、欠けがあっても答えのまだの判断待ちの行にし、理由に届いていない項目を載せる | 上限を数えずに自動の出し直しを続ける | +| 受け入れ条件 6 | 出し直しの指摘を持つ課題の MVV 判定の材料と問いに、指摘の全文と「満たさない設計は従うにしない」が入る。判定器を差し替えて材料を見る | `--revise-note` を受けても材料へ足さない | +| 受け入れ条件 8 / I4 | 出し直しの指摘を持たない課題のプランが今と同じ(写しのチェックがあり、起動指示に指摘の欄が無く、突き合わせのステップが無い) | 指摘の有無によらず分岐する | +| 受け入れ条件 9 / I8 | 承認・やらないを当てたときの承認の記録・ラベル・判断待ちの節が今と同じ(既存のテストが通る) | 答えを当てる関数の承認とやらないの経路で履歴を書く | +| I2 | 箇条書きと番号付きの行を 1 項目ずつ数え、どちらも無ければ全文を 1 項目にする。コードブロックの中の行は数えない。自動の回は項目を持たない | 段落を項目に数える・自動の回の文面を項目に数える | +| 受け入れ条件 7 | `.md` の文言は照合しない。文書のレビューで、表が `loop.md` にあり `parallel.md` が指していることを見る | — | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2510 | 実装する | — | `plugins/ndf/scripts/lib/revise_note.py`、`plugins/ndf/scripts/revise-note.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/supervise_lib/sprint_waves.py`、`plugins/ndf/scripts/supervise_lib/new_args.py`、`plugins/ndf/scripts/mvv-gate.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/loop.md`、`plugins/ndf/skills/development-workflow/references/parallel.md`、`plugins/ndf/skills/pr/SKILL.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 自動の出し直しの上限(1 回)の妥当性 | 要求の未決。承認ゲート 1 で利用者が決める。変えるときは出し直しの指摘の部品の上限の判定だけを直す | +| `new_args.py` の引数の受け方 | `--revise-note <課題>=<パス>` を既存の引数の定義に合わせて足せるかは、実装(`tdd-cycle`)で走らせて確かめる | +| worker が表の識別子を入力どおりに書くか | 起動指示に識別子つきの項目を並べて書かせる。書き違えは突き合わせのコマンドが欠けとして拾い、自動の出し直しへ戻す。配布の後のリリース後テストで、実際の出し直しで確かめる | From c8d3696242e299264eb2b0ca38bdb82b77790c1d Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 03:43:09 +0000 Subject: [PATCH 019/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E9=95=B7=E3=81=84=E6=96=87=E3=82=92=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=80=81=E6=A7=8B=E6=88=90=E8=A6=81=E7=B4=A0=E3=81=AE?= =?UTF-8?q?=E7=AB=A0=E3=81=AB=E5=B0=8F=E8=A6=8B=E5=87=BA=E3=81=97=E3=82=92?= =?UTF-8?q?=E7=AB=8B=E3=81=A6=E3=80=81=E4=BB=95=E6=A7=98=E3=81=AE=E5=86=99?= =?UTF-8?q?=E3=81=97=E3=82=92=E3=82=B3=E3=83=94=E3=83=BC=E3=81=B8=E3=81=9D?= =?UTF-8?q?=E3=82=8D=E3=81=88=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2510-design.md | 42 +++++++++++++++++++++---------------- 1 file changed, 24 insertions(+), 18 deletions(-) diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md index 869fcecbe..e2c947cbe 100644 --- a/issues/issue-2510-design.md +++ b/issues/issue-2510-design.md @@ -4,7 +4,7 @@ - **今どうなっているか**: 承認ゲート 1 で人が「直して出し直す」と答えて送った指摘が、次の周回の設計のプランの入力に入らない。課題の本文に書いた指摘も、次に人の判断待ちへ移すときの書き直しで消える。#2344・#2338 では同じ指摘を 2 回送っても設計が変わらなかった - **誰に効くか**: 開発ループと並列の計画で承認ゲート 1 に答える利用者 -- **変えると何が成り立つか**: 次の周回の設計が全回の指摘を読み、指摘の項目ごとに直したか・直せない理由を設計 PR の本文に示す。示していない出し直しは、人へ問う前に機械が見つけて 1 回だけ自動で戻す。答えの種類ごとに、読んで動く手順が 1 つに決まる +- **変えると何が成り立つか**: 次の周回の設計がすべての回の指摘を読み、指摘の項目ごとに直したか・直せない理由を設計 PR の本文に示す。示していない出し直しは、人へ問う前に機械が見つけて 1 回だけ自動で戻す。答えの種類ごとに、読んで動く手順が 1 つに決まる ## 適用範囲 @@ -18,7 +18,7 @@ | --- | --- | --- | | 周回 9 の設計のプラン(`.ndf/loop/lroot3/lap-9/sprint/1-design-2338.json`)に、指摘の中身(#2059・#2300 に絞る など)が無い | 実測(課題の本文の「原因」) | 指摘は記録に届くが、設計のプランの入力に入っていない | | #2338 の本文に節として指摘を写しても、3 回目の出し直し(lroot3 周回 11)の範囲が元のままだった | 実測(課題の本文の「今の回避」) | 本文に書くだけでは設計の手順に届かない。プランの起動指示へ入れる必要がある | -| #2494: 出し直しの設計が要求のステップを飛ばし、指摘を反映しないまま MVV 判定「従う」で承認ゲート 1 を通った( https://github.com/devbasex/ai-plugins/pull/2479 ) | 実測(同じ根を持つ課題) | 要求の写しの一致で要求のステップを飛ばす分岐と、MVV 判定の材料に指摘が無いことが、指摘の取りこぼしを人まで届けない | +| #2494: 出し直しの設計が要求のステップを飛ばし、指摘を反映しないまま MVV 判定「従う」で承認ゲート 1 を通った( https://github.com/devbasex/ai-plugins/pull/2479 ) | 実測(同じ根を持つ課題) | 仕様のコピーの一致で要求のステップを飛ばす分岐と、MVV 判定の材料に指摘が無いことが、指摘の取りこぼしを人まで届けない | 要求と受け入れ条件は #2510 の本文にある(コピーは [issues/issue-2510-requirements.md](issue-2510-requirements.md) )。この文書は「どう作るか」だけを扱う。 @@ -41,7 +41,7 @@ | 出し直しの指摘の履歴 | 開発ループの集約(答えを当てる経路だけが追記する) | 課題の本文の節 | 回の記載 | — | | 指摘への対応の表 | 設計のステップの worker が設計文書へ書く。設計 PR の本文へは表の突き合わせのコマンドが写す | 設計文書の節 | 表の行 | 対応(直した / 直せない)・直した箇所か直せない理由 | -出し直しの指摘の入力は、ループの記録から作る読み取り専用の写しである。正本はループの記録の人の判断待ちの行で、課題の本文の履歴は人が読むための写しとする(要求の前提 5)。 +出し直しの指摘の入力は、ループの記録から作る読み取り専用のコピーである。正本はループの記録の人の判断待ちの行で、課題の本文の履歴は人が読むためのコピーとする(要求の前提 5)。 ### 不変条件 @@ -50,8 +50,8 @@ | I1 | 出し直しの指摘の入力 | 課題の入力の回は、ループの記録でその課題の答えが「直して出し直す」の行を、記録の順にすべて持つ(人の回と自動の回の両方) | 入力を書けなければ設計のプランを組まずに止める(周回を `refused` で止める) | | I2 | 出し直しの指摘の入力 | 指摘の項目は人の回の指摘からだけ数える。箇条書き(`-` / `*`)と番号付きの行を 1 項目とし、どちらも無ければ全文を 1 項目とする。自動の回は項目を足さず、届いていない項目の識別子だけを持つ | — (数え方の関数が 1 つで、ほかの経路が数えない) | | I3 | 出し直しの指摘の履歴 | 履歴の節は追記だけで、既にある回の記載を消さない・書き換えない。同じ回を 2 度書かない | 本文を書けなければ理由を結果に残し、記録の書き込みは止めない(要求の前提 5) | -| I4 | 設計のプラン | 出し直しの指摘を持つ課題では、要求のステップを写しの一致によらず流し、要求と設計のステップの起動指示に全回の指摘を入れる。持たない課題のプランは今と同じ | — (組み立ての分岐で守る) | -| I5 | 指摘への対応の表 | 設計 PR の本文の表は、設計文書の節を写したものだけである。本文の側で書き足さない | 写しが食い違えば、表の突き合わせのコマンドが設計文書の側で本文を書き直す | +| I4 | 設計のプラン | 出し直しの指摘を持つ課題では、要求のステップを仕様のコピーの一致によらず流し、要求と設計のステップの起動指示にすべての回の指摘を入れる。持たない課題のプランは今と同じ | — (組み立ての分岐で守る) | +| I5 | 指摘への対応の表 | 設計 PR の本文の表は、設計文書の節を写したものだけである。本文の側で書き足さない | コピーが食い違えば、表の突き合わせのコマンドが設計文書の側で本文を書き直す | | I6 | ループの記録 | 出し直しの指摘を持つ課題を人の判断待ち(答えがまだの行)へ移すのは、すべての項目に表の行があり、対応と箇所・理由の欄が空でないときか、自動の出し直しの上限に達したときだけである | 項目が欠ければ、人へ問わずに自動の出し直しの行にする | | I7 | ループの記録 | 自動の出し直しは、人の出し直しの 1 回につき 1 回まで(直前の出し直しの行が自動なら、次は人へ問う) | 上限に達したら、届いていない項目を理由に添えて人へ問う | | I8 | ループの記録 | 承認とやらないの答えを当てたときの、承認の記録・ラベル・人の判断待ちの節の振る舞いは今と同じ | — (既存のテストで守る) | @@ -64,8 +64,8 @@ | E2 | 答えを人の判断待ちの行へ当てた | 開発ループの集約の答えを当てる関数 | 出し直しの指摘の履歴の追記 | | E3 | 履歴の節へその回の指摘を追記した | 開発ループの集約 | 課題の本文を読む人 | | E4 | 次の周回で課題を先頭に選んだ | 周回の選定 | 周回のスプリントの組み立て | -| E5 | 設計のプランへ全回の指摘を入れた | 周回のスプリントの組み立て(出し直しの指摘の入力を書き、プランの組み立てへ渡す) | 要求・設計のステップの worker | -| E6 | 要求のステップが写しの一致によらず流れた | 設計のプラン | 設計のステップ | +| E5 | 設計のプランへすべての回の指摘を入れた | 周回のスプリントの組み立て(出し直しの指摘の入力を書き、プランの組み立てへ渡す) | 要求・設計のステップの worker | +| E6 | 要求のステップが仕様のコピーの一致によらず流れた | 設計のプラン | 設計のステップ | | E7 | 設計を直し、設計文書に指摘への対応の表を書いた | 設計のステップの worker | 表の突き合わせのステップ | | E8 | MVV 判定が出し直しの指摘を材料に含めて判定した | MVV 判定のステップ | 承認ゲート 1 | | E9 | 指摘と表を突き合わせた | 表の突き合わせのステップ(プランの中)と、人の判断待ちへ移す関数(周回の締め) | 自動の出し直し、または人の判断待ちへ移す処理 | @@ -76,13 +76,13 @@ | 用語 | 意味 | 用語集への反映 | | --- | --- | --- | | 自動の出し直し | 出し直しの設計が指摘の項目を表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | 追加 | -| 出し直しの指摘の入力 | 開発ループが周回ごとに、課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・表の突き合わせが読む | 追加 | +| 出し直しの指摘の入力 | 開発ループが周回ごとに、課題のすべての回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・表の突き合わせが読む | 追加 | ## 機能一覧 | # | 機能 | 誰が使うか | | --- | --- | --- | -| F1 | 出し直しの指摘を持つ課題の設計のプランに、全回の指摘を入れ、要求のステップを必ず流す | 開発ループ(要求と設計のステップの worker が読む) | +| F1 | 出し直しの指摘を持つ課題の設計のプランに、すべての回の指摘を入れ、要求のステップを必ず流す | 開発ループ(要求と設計のステップの worker が読む) | | F2 | 直して出し直すの答えを当てるたびに、課題の本文の履歴の節へ 1 回分を追記する | 承認ゲート 1 に答える利用者 | | F3 | 設計文書の指摘への対応の表を、設計 PR の本文へ写して突き合わせる | 承認ゲート 1 に答える利用者・オーガナイザー | | F4 | 表に欠けがあれば、人へ問わずに自動の出し直しへ戻す(1 回まで) | 開発ループ | @@ -91,6 +91,8 @@ ## 構成要素 +### 要素ごとの責務 + | 要素 | 責務 | | --- | --- | | 出し直しの指摘の部品(新規 `lib/revise_note.py`) | 指摘の読み取りと項目の数え方の唯一の実装。課題の回の取り出し・項目の分割・入力ファイルの書き出しと読み込み・起動指示の文面・表の解析と欠けの判定・自動の出し直しの上限の判定・履歴の 1 回分の文面を持つ | @@ -99,12 +101,14 @@ | 周回のスプリントの組み立て(`lib/sprint_loop.py` の `cmd_sprint`・`new_sprint_argv`) | 設計する課題のうち出し直しの指摘を持つものの入力ファイルを周回のディレクトリへ書き、`supervise.py new sprint` へ `--revise-note <課題>=<パス>` で渡す | | 人の判断待ちへ移す関数(`lib/sprint_loop_gate.py` の `move_to_waiting`) | 出し直しの指摘を持つ課題で、設計 PR の本文の表を突き合わせる。欠けがあり上限内なら自動の出し直しの行にし、オーガナイザーへ設計の承認の依頼を送らない | | 判断待ちの節の文面(`lib/sprint_loop_gate.py` の `waiting_section`) | 指摘の全文を載せる代わりに、履歴の節の何回目かを指す 1 行を載せる。自動の出し直しなら届いていない項目を載せる | -| 設計のプランの組み立て(`supervise_lib/sprint_waves.py` の `plan_sprint_design`・`plan_mvv_design`) | 出し直しの指摘を持つ課題で、要求の写しのチェックを外して要求のステップへ直行させ、要求と設計の起動指示へ指摘を入れ、表の突き合わせのステップを足し、MVV 判定へ入力を渡す | +| 設計のプランの組み立て(`supervise_lib/sprint_waves.py` の `plan_sprint_design`・`plan_mvv_design`) | 出し直しの指摘を持つ課題で、仕様のコピーのチェックを外して要求のステップへ直行させ、要求と設計の起動指示へ指摘を入れ、表の突き合わせのステップを足し、MVV 判定へ入力を渡す | | `supervise.py new sprint` の引数(`supervise_lib/new_args.py`) | `--revise-note <課題>=<パス>`(繰り返し可)を受ける | | MVV 判定(`mvv-gate.py`) | `--revise-note <パス>` を受け、指摘を材料の 1 件として足し、承認ゲートの問いへ「指摘の項目を満たさない設計は従うにしない」を足す | | 手順書(`development-workflow` の `references/loop.md`・`references/parallel.md`) | 答えの種類ごとの読んで動く手順の表を `loop.md` に置き、`parallel.md` の問い方の節から指す | | `/ndf:pr` の「設計 Pull Request の本文」(`skills/pr/SKILL.md`) | 節「出し直しの指摘への対応」を、手で書かない節として表に足す | +### 要素どうしの呼び出し + ```mermaid graph TD subgraph 開発ループ @@ -132,6 +136,8 @@ graph TD 図には処理の流れに現れない要素(手順書・`/ndf:pr` の Skill・`supervise.py new sprint` の引数の定義)を含めない。判断待ちの節の文面は「人の判断待ちへ移す」と「答えを当てる」の中で使われる。 +### システムの文脈と配置 + システムの文脈と配置は次のとおりである。外部の系は GitHub(課題の本文・設計 PR の本文)だけで、すべての処理は利用者の手元のループの実行場所で動く。 ```mermaid @@ -141,7 +147,7 @@ graph TD L -->|入力ファイル| D[周回のディレクトリ] L -->|履歴の追記| G[GitHub の課題] D --> S[設計のプラン] - S -->|表の写し| H[GitHub の設計 PR] + S -->|表のコピー| H[GitHub の設計 PR] ``` パッケージの置き場は次のとおりである。 @@ -285,7 +291,7 @@ sequenceDiagram ループ->>計画: --revise-note で組む ``` -並列の計画のレーンでは、1 行目の答えがオーガナイザーの `parallel.py answer` から耐久のイベントとして届き、レーンが周回の境(`apply_gate1_answers`)で同じ関数へ渡す。以降は同じである。 +並列の計画のレーンでは、1 行目の答えがオーガナイザーの `parallel.py answer` から耐久のイベントとして届く。レーンはそれを周回の境(`apply_gate1_answers`)で同じ関数へ渡し、以降は同じである。 ### 出し直しの設計のプランの中 @@ -301,7 +307,7 @@ graph TD M -->|従わない| E ``` -出し直しの指摘を持たない課題では、`G` の次が今どおり要求の写しのチェックで、`K` は無い(I4)。`K` は用語の送り(`push-glossary`)の直後に入り、送りの元の行き先へ進む。基本形の行き先は Draft を外すステップ、MVV 判定を置く形では MVV 判定である。用語チェックの当たりが残って承認ゲートの判断(`gate`)へ回る経路は `K` を通らないが、周回の締めで同じ突き合わせを通る。 +出し直しの指摘を持たない課題では、`G` の次が今どおり仕様のコピーのチェックで、`K` は無い(I4)。`K` は用語の送り(`push-glossary`)の直後に入り、送りの元の行き先へ進む。基本形の行き先は Draft を外すステップ、MVV 判定を置く形では MVV 判定である。用語チェックの当たりが残って承認ゲートの判断(`gate`)へ回る経路は `K` を通らないが、周回の締めで同じ突き合わせを通る。 ### 周回の締めで人の判断待ちへ移すとき @@ -337,7 +343,7 @@ graph TD ### 決定 2: 表の書き手を 1 つにするため、指摘への対応の表は設計のステップの worker が設計文書へ書き、突き合わせのコマンドが設計 PR の本文へ写す -pr のステップは既にある設計 PR の本文を作り直すため、worker が本文へ直接書いた表は次の pr のステップで消える。設計文書に置けば、レビューの直しでも同じ差分で直り、MVV 判定も設計文書の材料として読む。本文への写しは、最後の送りの後の突き合わせのステップが行う。 +pr のステップは既にある設計 PR の本文を作り直すため、worker が本文へ直接書いた表は次の pr のステップで消える。設計文書に置けば、レビューの直しでも同じ差分で直り、MVV 判定も設計文書の材料として読む。本文へのコピーは、最後の送りの後の突き合わせのステップが行う。 pr のステップの材料に表を足す形は採らない。pr のステップは設計 PR を出す時点で 1 回しか本文を作らず、レビューの直しで変わった表を拾えない。 @@ -359,7 +365,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 根拠: Value 7(MVV 版 5) -### 決定 5: 指摘を満たす設計を 1 回で揃えやすくするため、要求と設計の起動指示へ全回の指摘の全文を埋め込む +### 決定 5: 指摘を満たす設計を 1 回で揃えやすくするため、要求と設計の起動指示へすべての回の指摘の全文を埋め込む 起動指示にファイルのパスだけを書くと、worker が読まない経路が残る(#2338 の回避で本文の節が読まれなかった)。全文と項目の識別子を起動指示に置き、「この指摘を満たすように直す。満たせない項目は理由を書く」と、設計文書へ表を置く指示を添える。 @@ -391,20 +397,20 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 | 予算 | `loop start <名>` に `--budget-laps` / `--budget-usd` / `--budget-hours` | ループの開始と再開の起動が予算を上書きし、変更を記録の予算の履歴へ残す。止まる条件の判定は周回の境で新しい予算を読む。レーンでは起こし直しの起動の行に付ける | | 止める | `loop stop <名>` / `parallel.py stop <計画名>` | 止める指示のファイルを置き、周回の境で止まる条件 `user_stop` として読む。並列の計画は全レーンへ止める指示を打つ | -要求の前提 6 にある `sprint-state.py loop budget` は、今の CLI に無い(`lib/sprint_loop_cli.py` の予算の引数は `loop start` の `--budget-laps`・`--budget-usd`・`--budget-hours` の 3 つだけ)。予算の手順は `loop start --budget-*` だけを書く。 +要求の前提 6 にある `sprint-state.py loop budget` は、今の CLI に無い。`lib/sprint_loop_cli.py` が持つ予算の引数は、`loop start` の `--budget-laps`・`--budget-usd`・`--budget-hours` の 3 つだけである。予算の手順は `loop start --budget-*` だけを書く。 ## テスト設計 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | | 受け入れ条件 1 / I1 / I4 | 出し直しの行を 2 回持つ課題でスプリントを組むと、要求と設計のステップの起動指示に 2 回分の全文と「満たすように直す。満たせない項目は理由を書く」の旨が入る | 最新の 1 回だけを入れる・自動の回を落とす・起動指示へ入れずにパスだけを書く | -| 受け入れ条件 2 / I4 | 出し直しの指摘を持つ課題のプランでは、要求の写しのチェックが一致で 0 を返しても要求のステップが流れる | 写しのチェックのステップを残して `next` を設計にする | +| 受け入れ条件 2 / I4 | 出し直しの指摘を持つ課題のプランでは、仕様のコピーのチェックが一致で 0 を返しても要求のステップが流れる | 仕様のコピーのチェックのステップを残して `next` を設計にする | | 受け入れ条件 3 / I3 | 直して出し直すを 2 回当て、間に人の判断待ちへ移して判断待ちの節を書き直しても、本文に第 1 回と第 2 回の記載が残る。同じ回を当て直しても記載が 1 つ | 判断待ちの節の中へ履歴を書く・追記でなく置き換える・回の重複を見ない | | 受け入れ条件 4 / I5 | 突き合わせのコマンドが、設計文書の表を設計 PR の本文の節へ写す。本文に別の表があれば設計文書の側で書き直す | 本文の表を正として設計文書を無視する | | 受け入れ条件 5 / I6 | 表が無い・行の無い項目がある・対応か箇所の欄が空のとき、突き合わせのコマンドは 10、人の判断待ちへ移す関数は自動の出し直しの行(届いていない項目つき)を作り、オーガナイザーへ依頼を送らない | 欄の空を見ない・自動の出し直しでも依頼を送る | | 受け入れ条件 5 / I7 | 直前の出し直しが自動なら、欠けがあっても答えのまだの判断待ちの行にし、理由に届いていない項目を載せる | 上限を数えずに自動の出し直しを続ける | | 受け入れ条件 6 | 出し直しの指摘を持つ課題の MVV 判定の材料と問いに、指摘の全文と「満たさない設計は従うにしない」が入る。判定器を差し替えて材料を見る | `--revise-note` を受けても材料へ足さない | -| 受け入れ条件 8 / I4 | 出し直しの指摘を持たない課題のプランが今と同じ(写しのチェックがあり、起動指示に指摘の欄が無く、突き合わせのステップが無い) | 指摘の有無によらず分岐する | +| 受け入れ条件 8 / I4 | 出し直しの指摘を持たない課題のプランが今と同じ(仕様のコピーのチェックがあり、起動指示に指摘の欄が無く、突き合わせのステップが無い) | 指摘の有無によらず分岐する | | 受け入れ条件 9 / I8 | 承認・やらないを当てたときの承認の記録・ラベル・判断待ちの節が今と同じ(既存のテストが通る) | 答えを当てる関数の承認とやらないの経路で履歴を書く | | I2 | 箇条書きと番号付きの行を 1 項目ずつ数え、どちらも無ければ全文を 1 項目にする。コードブロックの中の行は数えない。自動の回は項目を持たない | 段落を項目に数える・自動の回の文面を項目に数える | | 受け入れ条件 7 | `.md` の文言は照合しない。文書のレビューで、表が `loop.md` にあり `parallel.md` が指していることを見る | — | From 1c0f79840f0059ce50738d9db2cec4691dfb939f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 03:51:12 +0000 Subject: [PATCH 020/127] =?UTF-8?q?docs(#2510):=20=E5=87=BA=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=81=AE=E6=8C=87=E6=91=98=E3=81=AE=E5=B1=A5?= =?UTF-8?q?=E6=AD=B4=E3=81=AE=E5=9B=9E=E3=82=92=E3=83=AB=E3=83=BC=E3=83=97?= =?UTF-8?q?=E3=81=AE=E5=90=8D=E5=89=8D=E3=81=A8=E5=9B=9E=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E7=B5=84=E3=81=A7=E8=AD=98=E5=88=A5=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2510-design.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md index e2c947cbe..5e1197389 100644 --- a/issues/issue-2510-design.md +++ b/issues/issue-2510-design.md @@ -49,7 +49,7 @@ | --- | --- | --- | --- | | I1 | 出し直しの指摘の入力 | 課題の入力の回は、ループの記録でその課題の答えが「直して出し直す」の行を、記録の順にすべて持つ(人の回と自動の回の両方) | 入力を書けなければ設計のプランを組まずに止める(周回を `refused` で止める) | | I2 | 出し直しの指摘の入力 | 指摘の項目は人の回の指摘からだけ数える。箇条書き(`-` / `*`)と番号付きの行を 1 項目とし、どちらも無ければ全文を 1 項目とする。自動の回は項目を足さず、届いていない項目の識別子だけを持つ | — (数え方の関数が 1 つで、ほかの経路が数えない) | -| I3 | 出し直しの指摘の履歴 | 履歴の節は追記だけで、既にある回の記載を消さない・書き換えない。同じ回を 2 度書かない | 本文を書けなければ理由を結果に残し、記録の書き込みは止めない(要求の前提 5) | +| I3 | 出し直しの指摘の履歴 | 履歴の節は追記だけで、既にある回の記載を消さない・書き換えない。同じ回を 2 度書かない。回はループの名前と回数の組で識別する(回数はループごとに 1 から数えるため、回数だけでは別のループの回と区別できない) | 本文を書けなければ理由を結果に残し、記録の書き込みは止めない(要求の前提 5) | | I4 | 設計のプラン | 出し直しの指摘を持つ課題では、要求のステップを仕様のコピーの一致によらず流し、要求と設計のステップの起動指示にすべての回の指摘を入れる。持たない課題のプランは今と同じ | — (組み立ての分岐で守る) | | I5 | 指摘への対応の表 | 設計 PR の本文の表は、設計文書の節を写したものだけである。本文の側で書き足さない | コピーが食い違えば、表の突き合わせのコマンドが設計文書の側で本文を書き直す | | I6 | ループの記録 | 出し直しの指摘を持つ課題を人の判断待ち(答えがまだの行)へ移すのは、すべての項目に表の行があり、対応と箇所・理由の欄が空でないときか、自動の出し直しの上限に達したときだけである | 項目が欠ければ、人へ問わずに自動の出し直しの行にする | @@ -241,7 +241,7 @@ classDiagram 届いていない項目: 1-2 ``` -人の回は「自動」の代わりに「人」と書き、見出しの下に指摘の全文を置く。同じ「第 N 回」の見出しが既にあれば足さない(I3)。 +人の回は「自動」の代わりに「人」と書き、見出しの下に指摘の全文を置く。見出しのループの名前と回数の組(例: `開発ループ lroot3` の `第 2 回`)が同じ見出しが既にあれば足さない(I3)。回数が同じでもループの名前が違えば別の回として足す。 ### 指摘への対応の表(設計文書と設計 PR の本文の `## 出し直しの指摘への対応`) @@ -375,7 +375,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 ### 決定 6: 課題の本文に指摘の全文を 2 か所持たないため、判断待ちの節は履歴の節の回を指す 1 行だけを載せる -判断待ちの節は次に人の判断待ちへ移すときに書き直される。全文を履歴の節へ移し、判断待ちの節は「指摘: 節『出し直しの指摘の履歴』の第 N 回」と書けば、正本が本文の中でも 1 か所になる。 +判断待ちの節は次に人の判断待ちへ移すときに書き直される。全文を履歴の節へ移し、判断待ちの節は「指摘: 節『出し直しの指摘の履歴』の開発ループ <名前> の第 N 回」と書けば、正本が本文の中でも 1 か所になる。 根拠: Value 8(MVV 版 5) @@ -405,7 +405,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 | --- | --- | --- | | 受け入れ条件 1 / I1 / I4 | 出し直しの行を 2 回持つ課題でスプリントを組むと、要求と設計のステップの起動指示に 2 回分の全文と「満たすように直す。満たせない項目は理由を書く」の旨が入る | 最新の 1 回だけを入れる・自動の回を落とす・起動指示へ入れずにパスだけを書く | | 受け入れ条件 2 / I4 | 出し直しの指摘を持つ課題のプランでは、仕様のコピーのチェックが一致で 0 を返しても要求のステップが流れる | 仕様のコピーのチェックのステップを残して `next` を設計にする | -| 受け入れ条件 3 / I3 | 直して出し直すを 2 回当て、間に人の判断待ちへ移して判断待ちの節を書き直しても、本文に第 1 回と第 2 回の記載が残る。同じ回を当て直しても記載が 1 つ | 判断待ちの節の中へ履歴を書く・追記でなく置き換える・回の重複を見ない | +| 受け入れ条件 3 / I3 | 直して出し直すを 2 回当て、間に人の判断待ちへ移して判断待ちの節を書き直しても、本文に第 1 回と第 2 回の記載が残る。同じ回を当て直しても記載が 1 つ。別のループで同じ課題の第 1 回を当てると、記載が別に足される | 判断待ちの節の中へ履歴を書く・追記でなく置き換える・回の重複を見ない・回数だけで重複を見る | | 受け入れ条件 4 / I5 | 突き合わせのコマンドが、設計文書の表を設計 PR の本文の節へ写す。本文に別の表があれば設計文書の側で書き直す | 本文の表を正として設計文書を無視する | | 受け入れ条件 5 / I6 | 表が無い・行の無い項目がある・対応か箇所の欄が空のとき、突き合わせのコマンドは 10、人の判断待ちへ移す関数は自動の出し直しの行(届いていない項目つき)を作り、オーガナイザーへ依頼を送らない | 欄の空を見ない・自動の出し直しでも依頼を送る | | 受け入れ条件 5 / I7 | 直前の出し直しが自動なら、欠けがあっても答えのまだの判断待ちの行にし、理由に届いていない項目を載せる | 上限を数えずに自動の出し直しを続ける | From b33cb578fb0c6ba90af3418c11af6d41c1b41086 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 04:00:44 +0000 Subject: [PATCH 021/127] =?UTF-8?q?docs(#2510):=20=E5=87=BA=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E3=81=97=E3=81=AE=E6=8C=87=E6=91=98=E3=81=AE=E5=85=A5?= =?UTF-8?q?=E5=8A=9B=E3=81=8C=E6=8C=81=E3=81=A4=E5=9B=9E=E3=82=92=E3=80=81?= =?UTF-8?q?=E3=81=84=E3=81=BE=E5=9B=9E=E3=81=A3=E3=81=A6=E3=81=84=E3=82=8B?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E5=9B=9E=E3=81=AB=E9=99=90?= =?UTF-8?q?=E3=82=8B=E3=81=A8=E5=AE=9A=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 回数と項目の識別子がループごとに 1 から数えられるため、入力・表の突き合わせ・MVV 判定が 別のループの回と取り違えないよう、入力の範囲を不変条件 I1 と用語集で明記する。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 2 +- docs/glossary/glossary.json | 2 +- issues/issue-2510-design.md | 8 ++++---- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index 027721ae7..e5a7e0f97 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -407,7 +407,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 設計のプラン | — | 開発ループの周回が課題ごとに組む、設計のフェーズのプラン(`sprint/<番号>-design-<課題>.json`)。要求・設計・レビュー・承認ゲート 1 のステップを持つ | — | — | — | | 答えの種類 | — | 人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ | — | — | — | | 自動の出し直し | — | 出し直しの設計が出し直しの指摘の項目を指摘への対応の表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | — | — | — | -| 出し直しの指摘の入力 | — | 開発ループが周回ごとに、課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む | — | — | — | +| 出し直しの指摘の入力 | — | 開発ループが周回ごとに、そのループでの課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 44680468c..84e6a2282 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5545,7 +5545,7 @@ { "term": "出し直しの指摘の入力", "context": "ndf-workflow", - "meaning": "開発ループが周回ごとに、課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む", + "meaning": "開発ループが周回ごとに、そのループでの課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む", "pending_source": "issues/issue-2510-design.md" } ] diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md index 5e1197389..7935e0e77 100644 --- a/issues/issue-2510-design.md +++ b/issues/issue-2510-design.md @@ -41,13 +41,13 @@ | 出し直しの指摘の履歴 | 開発ループの集約(答えを当てる経路だけが追記する) | 課題の本文の節 | 回の記載 | — | | 指摘への対応の表 | 設計のステップの worker が設計文書へ書く。設計 PR の本文へは表の突き合わせのコマンドが写す | 設計文書の節 | 表の行 | 対応(直した / 直せない)・直した箇所か直せない理由 | -出し直しの指摘の入力は、ループの記録から作る読み取り専用のコピーである。正本はループの記録の人の判断待ちの行で、課題の本文の履歴は人が読むためのコピーとする(要求の前提 5)。 +出し直しの指摘の入力は、ループの記録から作る読み取り専用のコピーである。正本はループの記録の人の判断待ちの行で、課題の本文の履歴は人が読むためのコピーとする(要求の前提 5)。入力が持つのは、いま回っているループの回だけである。同じ課題を前に別のループで出し直した回は、課題の本文の履歴の節で人が読める形に残るが、入力・表の突き合わせ・MVV 判定には入らない(ループの記録はループごとに別のファイルで、回数もループごとに 1 から数えるため)。 ### 不変条件 | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | -| I1 | 出し直しの指摘の入力 | 課題の入力の回は、ループの記録でその課題の答えが「直して出し直す」の行を、記録の順にすべて持つ(人の回と自動の回の両方) | 入力を書けなければ設計のプランを組まずに止める(周回を `refused` で止める) | +| I1 | 出し直しの指摘の入力 | 課題の入力の回は、いま回っているループの記録(`.ndf/loop/<名>/loop.json`)でその課題の答えが「直して出し直す」の行を、記録の順にすべて持つ(人の回と自動の回の両方)。ほかのループの記録の回は入れない。回数と項目の識別子 `<回>-<番号>` は 1 つのループの中で一意で、入力の中で衝突しない | 入力を書けなければ設計のプランを組まずに止める(周回を `refused` で止める) | | I2 | 出し直しの指摘の入力 | 指摘の項目は人の回の指摘からだけ数える。箇条書き(`-` / `*`)と番号付きの行を 1 項目とし、どちらも無ければ全文を 1 項目とする。自動の回は項目を足さず、届いていない項目の識別子だけを持つ | — (数え方の関数が 1 つで、ほかの経路が数えない) | | I3 | 出し直しの指摘の履歴 | 履歴の節は追記だけで、既にある回の記載を消さない・書き換えない。同じ回を 2 度書かない。回はループの名前と回数の組で識別する(回数はループごとに 1 から数えるため、回数だけでは別のループの回と区別できない) | 本文を書けなければ理由を結果に残し、記録の書き込みは止めない(要求の前提 5) | | I4 | 設計のプラン | 出し直しの指摘を持つ課題では、要求のステップを仕様のコピーの一致によらず流し、要求と設計のステップの起動指示にすべての回の指摘を入れる。持たない課題のプランは今と同じ | — (組み立ての分岐で守る) | @@ -76,7 +76,7 @@ | 用語 | 意味 | 用語集への反映 | | --- | --- | --- | | 自動の出し直し | 出し直しの設計が指摘の項目を表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | 追加 | -| 出し直しの指摘の入力 | 開発ループが周回ごとに、課題のすべての回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・表の突き合わせが読む | 追加 | +| 出し直しの指摘の入力 | 開発ループが周回ごとに、そのループでの課題のすべての回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・表の突き合わせが読む | 追加 | ## 機能一覧 @@ -369,7 +369,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 起動指示にファイルのパスだけを書くと、worker が読まない経路が残る(#2338 の回避で本文の節が読まれなかった)。全文と項目の識別子を起動指示に置き、「この指摘を満たすように直す。満たせない項目は理由を書く」と、設計文書へ表を置く指示を添える。 -最新の 1 回だけを渡す形は採らない。前の回で満たした項目を次の回で崩さないため(要求の前提 1)。 +最新の 1 回だけを渡す形は採らない。前の回で満たした項目を次の回で崩さないため(要求の前提 1)。「すべての回」は、いま回っているループの回を指す(I1)。 根拠: Value 2 / Value 7(MVV 版 5) From c4d5f6b3600c81a4988db01873cdab4bd55e9efd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 04:47:21 +0000 Subject: [PATCH 022/127] =?UTF-8?q?Feat:=20=E8=A8=AD=E8=A8=88=E3=81=AE?= =?UTF-8?q?=E6=89=BF=E8=AA=8D=E3=81=AE=E7=AD=94=E3=81=88=E3=81=A7=E6=89=BF?= =?UTF-8?q?=E8=AA=8D=E3=81=97=E3=81=9F=E8=A8=AD=E8=A8=88=20PR=20=E3=82=92?= =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=B8=E3=81=97=E3=80=81=E5=91=A8=E5=9B=9E?= =?UTF-8?q?=E3=81=AE=E5=A2=83=E3=81=A7=E3=83=9E=E3=83=BC=E3=82=B8=E3=81=97?= =?UTF-8?q?=E7=9B=B4=E3=81=99=EF=BC=88#2507=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 承認した設計のマージ(lib/design_pr_merge.py)を新設し、parallel.py answer --approved・ loop answer --approved・周回の境の 3 か所から呼ぶ。承認した先端のままで必須の検査が緑のときだけ、 merge-when-green --expect-head --no-cleanup で待たずにマージする。マージ済みの承認した課題は 次の周回で先頭に選ぶ。answer の結果に答えの後に残る依頼(metrics.after)を載せ、 unknown_streak は同じ依頼の打ち直しを 1 件と数える。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/design_pr_merge.py | 146 ++++++++++++++++++ plugins/ndf/scripts/lib/parallel_answer.py | 52 ++++++- plugins/ndf/scripts/lib/parallel_lane.py | 19 ++- plugins/ndf/scripts/lib/parallel_run.py | 2 +- .../ndf/scripts/lib/project_mvv_signals.py | 14 +- plugins/ndf/scripts/lib/sprint_loop.py | 38 ++++- plugins/ndf/scripts/lib/sprint_loop_gate.py | 79 +++++++++- plugins/ndf/scripts/lib/sprint_loop_select.py | 140 ++++++++++++++++- plugins/ndf/scripts/parallel.py | 22 ++- plugins/ndf/scripts/tests/test_project_mvv.py | 7 +- plugins/ndf/scripts/tests/test_sprint_loop.py | 23 ++- 11 files changed, 507 insertions(+), 35 deletions(-) create mode 100644 plugins/ndf/scripts/lib/design_pr_merge.py diff --git a/plugins/ndf/scripts/lib/design_pr_merge.py b/plugins/ndf/scripts/lib/design_pr_merge.py new file mode 100644 index 000000000..5ec7db715 --- /dev/null +++ b/plugins/ndf/scripts/lib/design_pr_merge.py @@ -0,0 +1,146 @@ +"""承認した設計のマージ(#2507)。設計の承認の答えと周回の境が、承認した設計 PR を宛先へ待たずに 1 回だけマージしてみる。 + +呼ぶのは `parallel.py answer --approved`(並列の計画)・`sprint-state.py loop answer --approved`(1 本のループ)・周回の境 +(`sprint_loop_select.settle_approved_designs`)の 3 か所で、どれもこの関数だけを使う(設計 #2507 の決定 2)。 + +- 承認した先端(問いの時点の設計 PR の先頭のコミット)のままのときだけマージする(`merge-when-green --expect-head`) +- 宛先が本番チャネルに当たるか決められないときはマージしない(承認ゲート 2 を承認ゲート 1 の答えで通さない。決定 3) +- 必須の検査が緑のときだけマージする。検査の完了は待たない(`probe` で分類し、`passed` と `infra` のときだけ呼ぶ。決定 1) +- 後片付けは持ち主の周回の境が打つ(`--no-cleanup`。決定 5) +- 例外を投げず、終了コードを持たない。結果は「マージの結果」の 1 つ(`RESULTS`)にして返す +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path +from typing import Callable + +import ci_workflows +import clock +import delivery + +MERGED_STEPS = Path(__file__).resolve().parents[1] / "merged-steps.py" +RESULTS = ("merged", "already_merged", "head_moved", "no_stamp", "not_green", "conflict", "production", "failed") +# 答えの待ちの上限(非機能の実現方式)。緑なら 1 回目の読みから 5 秒後の確かめ直しでマージし、10 秒を過ぎた最初の読みで切れる +WAIT_ARGS = ["--timeout", "10", "--interval", "5", "--recheck", "5", "--no-checks-after", "5"] +UNREGISTERED = "unregistered" # 必須の検査がまだ検査の一覧に載っていない・検査が無いと確かめられない + +Run = Callable[..., tuple] + + +def _run(cmd: list[str], cwd: str | None = None, stdin: str | None = None) -> tuple[int, str, str]: + p = subprocess.run(cmd, cwd=cwd, input=stdin, capture_output=True, text=True) + return p.returncode, p.stdout, p.stderr + + +def _last_json(text: str) -> dict: + for line in reversed((text or "").strip().splitlines()): + try: + got = json.loads(line) + except ValueError: + continue + if isinstance(got, dict): + return got + return {} + + +def _out(result: str, pr: int, head: str = "", reason: str = "", cls: str | None = None, **extra) -> dict: + return {"result": result, "class": cls, "reason": reason, "pr": pr, "head": head, "at": clock.now_iso("utc"), **extra} + + +def _check_names(rollup: list) -> set[str]: + return {str(c.get("name") or c.get("context") or "") for c in rollup or [] if isinstance(c, dict)} - {""} + + +def _registered(root: str, base: str, stamp: str, rollup: list, run: Run) -> str | None: + """必須の検査が検査の一覧に載っているか(検査が無いなら、無いこと)を確かめる。確かめられなければ理由を返す(手順 3)。""" + code, so, se = run(["gh", "api", f"repos/{{owner}}/{{repo}}/rules/branches/{base}"], cwd=root) + try: + required = ci_workflows.required_contexts(json.loads(so)) if code == 0 else None + except ValueError: + code = 1 + if code != 0: + return f"宛先 {base} の必須の検査の規則を読めない: {(se or so).strip()[:200]}" + missing = sorted(set(required or []) - _check_names(rollup)) + if missing: + return f"必須の検査がまだ検査の一覧に無い: {', '.join(missing)}" + if rollup: + return None + if required is not None: + return "検査の一覧が空(必須の検査がある)" + code, so, se = run(["gh", "api", f"repos/{{owner}}/{{repo}}/contents/{ci_workflows.WORKFLOW_DIR}?ref={stamp}"], cwd=root) + if code == 0: + return "検査の一覧が空(ワークフローがある)" + if "404" in (se or so) or "Not Found" in (se or so): + return None # 必須の検査もワークフローのディレクトリも無い: 検査が無いと確かめた + return f"ワークフローの有無を読めない: {(se or so).strip()[:200]}" + + +def merge_approved(root, pr: int, stamp: str | None, run: Run | None = None) -> dict: + """設計 PR `pr` を、承認した先端 `stamp` のままで検査が緑なら宛先へマージする。結果は `RESULTS` の値の 1 つ。 + + 出力: {result, class, reason, pr, head, at, branch, infra_rerun}。`infra_rerun` は基盤待ちのジョブの再実行を頼んだか + (`merge-when-green --infra-gap 0` を呼んだか)。周回の境が同じ承認した先端での累計に足す。""" + run = run or _run + root = str(root) + try: + return _merge(root, int(pr), (stamp or "").strip(), run) + except Exception as e: # noqa: BLE001 — 例外を投げない(I4)。読めない出力も failed に写す + return _out("failed", int(pr), reason=f"マージを試せない: {e}"[:300]) + + +def _merge(root: str, pr: int, stamp: str, run: Run) -> dict: + code, so, se = run(["gh", "pr", "view", str(pr), "--json", "state,headRefOid,baseRefName,headRefName,statusCheckRollup"], cwd=root) + try: + info = json.loads(so) if code == 0 else None + except ValueError: + info = None + if not isinstance(info, dict) or not info.get("state"): + return _out("failed", pr, reason=f"設計 PR を読めない: {(se or so).strip()[:200]}") + head, base, branch = info.get("headRefOid") or "", info.get("baseRefName") or "", info.get("headRefName") or "" + ctx = {"branch": branch, "infra_rerun": False} + if info["state"] == "MERGED": + return _out("already_merged", pr, head, **ctx) + if info["state"] != "OPEN": + return _out("failed", pr, head, f"設計 PR が開いていない({info['state']})", **ctx) + if not stamp.strip("0"): + return _out("no_stamp", pr, head, "承認した先端が記録に無い", **ctx) + if head != stamp: + return _out("head_moved", pr, head, f"承認の後に設計 PR の先端が動いた({stamp[:12]} → {head[:12]})", **ctx) + verdict = delivery.judge_target(delivery.load_delivery(root), base) + if verdict.stops: + return _out("production", pr, head, f"設計 PR の宛先 {base} が本番チャネルに当たるか決められない({verdict.reason})", **ctx) + if why := _registered(root, base, stamp, info.get("statusCheckRollup") or [], run): + return _out("not_green", pr, head, why, UNREGISTERED, **ctx) + code, so, se = run([sys.executable, str(MERGED_STEPS), "probe", "--pr", str(pr), "--act"], cwd=root) + cls = (_last_json(so).get("metrics") or {}).get("class") + if code != 0 or not cls: + return _out("failed", pr, head, f"検査を分類できない(probe {code}): {(se or so).strip()[:200]}", **ctx) + if cls not in ("passed", "infra"): + return _out("not_green", pr, head, f"検査が緑でない({cls})", cls, **ctx) + argv = [sys.executable, str(MERGED_STEPS), "merge-when-green", str(pr), "--expect-head", stamp, "--no-cleanup", *WAIT_ARGS] + if cls == "infra": + argv += ["--infra-gap", "0"] + ctx["infra_rerun"] = True + code, so, se = run(argv, cwd=root) + return _from_merge(pr, head, cls, code, _last_json(so), se, ctx) + + +def _from_merge(pr: int, head: str, cls: str, code: int, got: dict, se: str, ctx: dict) -> dict: + """merge-when-green の終了コードと出力を、マージの結果へ写す。""" + summary = str(got.get("summary") or se or "").strip()[:200] + items = got.get("items") or [] + if code == 0: + return _out("merged", pr, head, cls=cls, **ctx) + if any(isinstance(i, dict) and i.get("result") == "head_moved" for i in items): + return _out("head_moved", pr, head, f"マージの直前に先端が動いた: {summary}", cls, **ctx) + if code == 4: + return _out("conflict", pr, head, f"宛先と衝突している: {summary}", cls, **ctx) + if code == 10: + return _out("production", pr, head, f"宛先が本番チャネルに当たる(承認ゲート 2 で止まった): {summary}", cls, **ctx) + if code == 75 or (code == 1 and not any(isinstance(i, dict) and i.get("result") == "stopped" for i in items)): + return _out("not_green", pr, head, f"検査が待ちの上限までに緑にならない({code}): {summary}", cls, **ctx) + return _out("failed", pr, head, f"マージできない({code}): {summary}", cls, **ctx) diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py index bdc1eb10f..dba988400 100644 --- a/plugins/ndf/scripts/lib/parallel_answer.py +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -1,4 +1,5 @@ -"""並列の計画の問いへ答えた後に、だれを起こすか(#2436 の 6c・6f)。`parallel.py answer` だけが使う。 +"""並列の計画の問いへ答えた後に、だれを起こすか(#2436 の 6c・6f)と、答えの後に残る依頼の一覧(#2507 の答えの後の整理)。 +`parallel.py answer` だけが使う。 `parallel_run`(耐久ワークフローの本体)を読むため、レーンの側の `parallel_lane` から分けて置く(レーンの側の入口が `parallel_run` の環境変数を読まないように)。 @@ -11,14 +12,57 @@ import parallel_lane as pl -def owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = None) -> str | None: +def questions(main: Path, name: str) -> tuple[int, list[dict]]: + """耐久ワークフローの今の依頼の一覧 (seq, items)。読めなければ (0, [])。""" + import durable + + got = durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {} + return int(got.get("seq") or 0), list(got.get("items") or []) + + +MVV_VERDICTS = ("unknown", "unreadable") # レーンの判定が決まらなかった(判定できない・判定を読めない) + + +def mvv_mark(q: dict) -> dict: + """MVV 判定でオーガナイザーが自分で答えてよい依頼か(#2507 の決定 8)。設計の承認で、レーンの判定が決まらず、 + レッドラインが空で、判定を打ち直す材料(レーンの周回のスプリント状態)が残るときだけ true。""" + if q.get("kind") != "gate1": + return {"eligible": False, "why": "設計の承認の依頼でない"} + if q.get("boundary"): + return {"eligible": False, "why": "レッドラインに当たる"} + if q.get("verdict") not in MVV_VERDICTS: + return {"eligible": False, "why": f"レーンの判定が決まっている({q.get('verdict')})"} + if not q.get("sprint_state") or not Path(q["sprint_state"]).is_file(): + return {"eligible": False, "why": "判定を打ち直す材料(スプリント状態)が無い"} + return {"eligible": True, "why": f"レーンの判定が決まらなかった({q.get('verdict')})"} + + +def after_items(items: list[dict], answered: dict) -> list[dict]: + """答えの後に残る依頼の一覧(`metrics.after`)。今答えた依頼を除き、機械で決まる除外の印(drop)と MVV 判定の印(mvv)を付ける。 + drop: settled(今送った答えと同じ設計 PR の依頼)/ resend(同じレーンの同じ設計 PR に、より新しい先端の依頼がある)/ None。""" + rid = answered.get("id") + rest = [q for q in items if q.get("id") != rid and rid not in (q.get("also") or [])] + key = lambda q: (q.get("lane"), q.get("pr")) # noqa: E731 + + def drop(q: dict) -> str | None: + if q.get("kind") != "gate1": + return None + if answered.get("kind") == "gate1" and key(q) == key(answered): + return "settled" + if any(o is not q and o.get("kind") == "gate1" and key(o) == key(q) and str(o.get("at") or "") > str(q.get("at") or "") for o in rest): + return "resend" + return None + + return [{**q, "drop": drop(q), "mvv": mvv_mark(q)} for q in rest] + + +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、ほかは答えを待って止まった持ち主の restart。起こす要否はレーンの側の待ちの記録で決める(`parallel_lane.lane_waits`)。同じ原因としてまとめた問い(`merged`)は、 代表だけでなく全持ち主をそれぞれの依頼の id で調べ、起こす行を `; ` でつなぐ(異常系 5)。""" - import durable import parallel_run as pr_run - items = (durable.event_from(pl.KIND, pl.record_identity(main, name), pl.workflow_id(name), "questions", 0) or {}).get("items") or [] + 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"): return next((q["commands"][c] for c in choice or [] if (q.get("commands") or {}).get(c)), None) diff --git a/plugins/ndf/scripts/lib/parallel_lane.py b/plugins/ndf/scripts/lib/parallel_lane.py index f8f9a9da0..f1162c4ef 100644 --- a/plugins/ndf/scripts/lib/parallel_lane.py +++ b/plugins/ndf/scripts/lib/parallel_lane.py @@ -329,9 +329,12 @@ def request_ask(main: Path, name: str, lane: str, tool_input: dict, cwd: str, le return rid -def request_gate1(main: Path, name: str, lane: str, issue: int, pr: int, report: str, state: str, stamp: str, **extra) -> str: - """設計の承認の依頼(`kind: gate1`)。id は `<レーン>-g<設計 PR>-<先頭の SHA の 12 桁>`(同じ先頭の打ち直しは同じ id)。""" - rid = f"{lane}-g{pr}-{stamp[:12]}" +def request_gate1( + main: Path, name: str, lane: str, issue: int, pr: int, report: str, state: str, stamp: str, suffix: str = "", **extra +) -> str: + """設計の承認の依頼(`kind: gate1`)。id は `<レーン>-g<設計 PR>-<先頭の SHA の 12 桁>`(同じ先頭の打ち直しは同じ id)。 + 承認した設計をマージできずに利用者へ戻すとき(#2507)は、同じ先端でも別の依頼にするため `suffix` を足す。""" + rid = f"{lane}-g{pr}-{stamp[:12]}{suffix}" msg = {"kind": "gate1", "id": rid, "lane": lane, "issue": issue, "pr": pr, "report": report, "sprint_state": state, "stamp": stamp} _send_inbox(main, name, {**msg, **extra, "at": pe.utc_stamp()}, rid) return rid @@ -362,9 +365,15 @@ def send_answer( note: Optional[str] = None, choice: Optional[list[str]] = None, stamp: Optional[str] = None, + merge: Optional[dict] = None, + by: str = "user", ) -> None: - """オーガナイザーの答えを送る。耐久ワークフローが `answer-<依頼>` を 1 回だけ立て、問いの一覧から外す。""" - msg = {"kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, "at": pe.utc_stamp()} + """オーガナイザーの答えを送る。耐久ワークフローが `answer-<依頼>` を 1 回だけ立て、問いの一覧から外す。 + 設計の承認では、答えの前に試みた承認した設計のマージの結果(`merge`)と、だれが承認したか(`by`: user / mvv)を載せる(#2507)。""" + msg = { + "kind": "answer", "id": rid, "result": result, "note": note, "choice": list(choice or []), "stamp": stamp, + "merge": merge, "by": by, "at": pe.utc_stamp(), + } # fmt: skip _send_inbox(main, name, msg, f"answer:{rid}") diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 65e696a59..b7fdd92aa 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -359,7 +359,7 @@ def reply(self, msg: dict) -> None: 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", "at")}) + durable.set_event(f"answer-{each}", {k: msg.get(k) for k in ("result", "note", "choice", "stamp", "merge", "by", "at")}) self.questions = [q for q in self.questions if q.get("id") != rid] self.publish_questions() owners = {q.get("lane"), *(m.get("lane") for m in q.get("merged", []))} & self.sprint & self.stopped() diff --git a/plugins/ndf/scripts/lib/project_mvv_signals.py b/plugins/ndf/scripts/lib/project_mvv_signals.py index bf67b51ad..a29ad1086 100644 --- a/plugins/ndf/scripts/lib/project_mvv_signals.py +++ b/plugins/ndf/scripts/lib/project_mvv_signals.py @@ -39,9 +39,11 @@ def _after(row: dict, since: datetime.datetime | None) -> bool: def unknown_streak(gate_rows: list[dict], sha: str | None) -> int: - """同じプロジェクト MVV の sha256 のもとで、末尾から続いた「判定できない」の回数(I13)。""" - n = 0 - for r in reversed(gate_rows): + """同じプロジェクト MVV の sha256 のもとで、末尾から続いた「判定できない」の回数(I13)。 + スプリント状態・承認ゲート・PR の組ごとに 1 件と数える(#2507)。オーガナイザーが同じ依頼の判定を打ち直した行は、 + 隣り合っていなくても同じ組として 1 件にまとめる(閾値は判定できなかった依頼の数で届く)。""" + seen: set[tuple] = set() + for i, r in enumerate(reversed(gate_rows)): ref = r.get("project_mvv") or {} if ref.get("sha256") != sha: break @@ -49,8 +51,10 @@ def unknown_streak(gate_rows: list[dict], sha: str | None) -> int: continue # 機械の検査で戻したものは判定ではない if r.get("verdict") != "unknown": break - n += 1 - return n + pr = r.get("pr") + # スプリント状態の無い行は組を決められないため、1 行を 1 件と数える + seen.add((r.get("sprint"), r.get("gate"), tuple(pr) if isinstance(pr, list) else pr) if r.get("sprint") else ("row", i)) + return len(seen) def _collect_signals(key, since, sha, approved, log) -> list[dict]: diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 26af0b194..f14bf7af8 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -12,6 +12,7 @@ from pathlib import Path from typing import Callable +import design_pr_merge import jsonio import project_mvv as pm import repo as repo_lib @@ -371,15 +372,45 @@ def cmd_answer(env: Env, name: str, number: int, approved: bool, pr: int | None, text = Path(note or "").read_text(encoding="utf-8").strip() except OSError as e: return refusal_order(f"指摘ファイルを読めない: {e}") + if pr: + w["pr"] = pr + if approved and w.get("pr") and (moved := regated_answer(env, w)): + write_record(env, rec, before) + return loop_ok(moved, rec, [w], status="regated") notes = apply_answer(env, rec, w, "revise" if revise else ("approved" if approved else "rejected"), pr, text if revise else None) + merge = None + if approved and w.get("pr"): # 承認した設計のマージ(#2507)。成否によらず答えは書き、0 で返る(I4) + out = design_pr_merge.merge_approved(env.main_dir(), int(w["pr"]), w.get("stamp"), run=env.run) + merge = sprint_loop_gate.note_merge(w, out, w.get("lap") or len(rec["laps"]), env.now()) write_record(env, rec, before) if revise: after = "次の周回で先頭に選び、指摘を読んで同じ設計 Pull Request を直す" + elif not approved: + after = "選定の対象に戻さない(差し戻し)" + elif merge and merge["result"] in sprint_loop_gate.MERGED_RESULTS: + after = "設計 PR をマージした。次の周回で先頭に選ぶ" + elif merge and merge["result"] == "production": + after = f"設計 PR をマージしない({merge['reason']})。承認ゲート 2 で人がマージするまで実装に選ばない" + elif merge: + after = f"設計 PR をマージできない({merge['result']}: {merge['reason']})。次の周回の境でマージし直す" else: - after = "設計 PR のマージの後の周回で選定の対象に戻る" if approved else "選定の対象に戻さない(差し戻し)" + after = "設計 PR のマージの後の周回で選定の対象に戻る" return loop_ok(f"#{number} の答え({w['answer']})を書いた。{after}", rec, [w, *({"note": n} for n in notes)], status="answered") +def regated_answer(env: Env, w: dict) -> str | None: + """1 本のループの承認の答えの前に、承認した先端と今の先端を比べる(#2507)。違えば(または行に承認した先端が無ければ)、 + 答えを当てずに行の承認した先端を今の先端へ書き換え、答え直しを促す 1 行を返す。読めなければ比べない(マージの部品が読み直す)。""" + head = sprint_loop_gate.design_head(env, w) + stamp = w.get("stamp") or "" + if not head or head == stamp: + return None + w.update(stamp=head, restamped=stamp) + if not stamp.strip("0"): + return f"#{w['number']} の設計 PR #{w['pr']} の承認した先端が記録に無い。今の先端 {head[:12]} を記録した。この先端を見てから答え直す" + return f"#{w['number']} の設計 PR #{w['pr']} の先端が問いの後に動いた({stamp[:12]} → {head[:12]})。新しい先端を見てから答え直す" + + def apply_answer(env: Env, rec: dict, w: dict, answer: str, pr: int | None, note: str | None = None) -> list[str]: """人の判断待ちの行へ答えを書き、スプリント状態の承認の記録と課題の本文の節を更新する。書けなかった理由を返す。 直して出し直す(revise)は答えの確定でないため承認の記録を書かず、指摘と回数を行と本文に書く(#2436 の 6e)。""" @@ -395,7 +426,7 @@ def apply_answer(env: Env, rec: dict, w: dict, answer: str, pr: int | None, note return notes lap = rec["laps"][w["lap"] - 1] state = lap.get("state_path") - if state: + if state and w.get("by") != "mvv": # MVV 判定で承認した答え(#2507)は mvv-gate.py が承認の記録を書いた。書き直さない cmd = [ sys.executable, str(SPRINT_STATE), @@ -430,8 +461,11 @@ def apply_gate1_answers(env: Env, rec: dict, answers: dict) -> None: code, so, se = env.run(["gh", "pr", "edit", str(w["pr"]), "--add-label", "design-approved"], cwd=str(env.main_dir())) if code != 0: w["label_error"] = (se or so).strip()[:200] + w["by"] = "mvv" if a.get("by") == "mvv" else "user" if result in ("approved", "rejected", "revise"): apply_answer(env, rec, w, result, w.get("pr"), a.get("note")) + if result == "approved" and isinstance(a.get("merge"), dict): # オーガナイザーが答えの前に試みたマージの結果を写す(#2507) + sprint_loop_gate.note_merge(w, a["merge"], w.get("lap") or len(rec["laps"]), env.now()) def cmd_approve_mvv(env: Env, name: str, milestone: str) -> dict: diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index d9a84b2ab..c95842bb4 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -211,6 +211,8 @@ def waiting_section(rec: dict, w: dict) -> str: f"- 根拠の項目: {', '.join(w.get('basis') or []) or '—'}", f"- 設計 Pull Request: {pr}", f"- 答え: {w.get('answer') or 'まだ'}", + *([f"- 設計 PR のマージ: {w['merge'].get('result')}({w['merge'].get('reason') or '—'})"] if w.get("merge") else []), + *([f"- 利用者へ戻した理由: {w['returned']}"] if w.get("returned") and not w.get("answer") else []), f"- 答え方: `{how}`", ] if w.get("note"): # 直して出し直す(#2436 の 6e): 次の周回の設計がこの指摘を読む @@ -355,8 +357,11 @@ def move_to_waiting( reasons = row.get("reasons") or [] w = { "number": int(n), "reason": "/".join(reasons) or row.get("verdict", "判定を読めない"), "verdict": row.get("verdict", "unknown"), - "basis": row.get("basis") or [], "gate": 1, "pr": pr, "lap": lap["n"], "at": env.now(), + "basis": row.get("basis") or [], "boundary": row.get("boundary") or [], "gate": 1, "pr": pr, "lap": lap["n"], + "at": env.now(), "report": i.get("report") or "", "state": lap["state_path"], } # fmt: skip + if pr is not None: # 承認した先端(#2507): 並列の計画の外でも書き、答えの時点の先端と比べる + w["stamp"] = design_head(env, w) if pr is not None and (sent := lane_gate1(env, w, i.get("report") or "", lap["state_path"])): w.update(sent) # 並列の計画のレーン: 設計の承認をオーガナイザーへ依頼し、答えは周回の境で読む(#2436 の 6c) rec["waiting"].append(w) @@ -369,19 +374,22 @@ def move_to_waiting( return moved, kept, notes -def lane_gate1(env: Env, w: dict, report: str, state: str) -> dict | None: +def lane_gate1(env: Env, w: dict, report: str, state: str, suffix: str = "", stamp: str | None = None) -> dict | None: """並列の計画のレーンなら、設計の承認の依頼(`kind: gate1`)をオーガナイザーへ送り、{request, plan, stamp, report, state} - を返す。レーンでない・送れなければ None(今どおり人の判断待ちとして残す)。印は設計 PR の先頭の SHA(問いの時点の印)。""" + を返す。レーンでない・送れなければ None(今どおり人の判断待ちとして残す)。印は設計 PR の先頭の SHA(問いの時点の印)。 + 同じ承認した先端で利用者へ戻すとき(#2507)は、`stamp` に承認した先端を、`suffix` に依頼の id の接尾辞を渡す + (同じ id の依頼は答え済みとして受け取られないため)。依頼の理由にはマージの結果の理由(`w.returned`)も載せる。""" try: import parallel_lane got = parallel_lane.lane_of(env.main_dir()) if not got: return None - stamp = design_head(env, w) + stamp = stamp or design_head(env, w) + reasons = [x for x in (w.get("reason"), w.get("returned")) if x] rid = parallel_lane.request_gate1( env.main_dir(), got[0], got[1].name, w["number"], int(w["pr"]), report, state, stamp or "0" * 12, - verdict=w.get("verdict"), reasons=[w.get("reason")] if w.get("reason") else [], + verdict=w.get("verdict"), reasons=reasons, boundary=list(w.get("boundary") or []), suffix=suffix, ) # fmt: skip except Exception: # noqa: BLE001 — 送れなければ今どおり人の判断待ち(loop answer で答える) return None @@ -407,6 +415,67 @@ def regate1(env: Env, w: dict) -> bool: return True +MERGED_RESULTS = ("merged", "already_merged") + + +def note_merge(w: dict, out: dict, lap: int, now: str) -> dict: + """承認した設計のマージの結果(`design_pr_merge.merge_approved` の出力)を人の判断待ちの行の `merge` へ書く(#2507)。 + 同じ承認した先端で同じ result と class が続いた周回の数(streak)と、基盤待ちのための再実行の累計(infra_reruns)を数える。 + infra_reruns は分類が変わっても 0 に戻さない(基盤待ちと実行中が交互に現れても再実行を上限なく増やさない)。""" + prev = w.get("merge") or {} + stamp = w.get("stamp") or "" + same = prev.get("stamp") == stamp + streak = prev.get("streak", 0) + 1 if same and (prev.get("result"), prev.get("class")) == (out.get("result"), out.get("class")) else 1 + reruns = (prev.get("infra_reruns", 0) if same else 0) + (1 if out.get("infra_rerun") else 0) + w["merge"] = {**out, "lap": lap, "streak": streak, "infra_reruns": reruns, "stamp": stamp} + if out.get("result") in MERGED_RESULTS and not w.get("merged_at"): + w["merged_at"] = now + return w["merge"] + + +def _reopen(w: dict) -> None: + for k in ("answer", "answered_at", "merge", "merged_at"): + w.pop(k, None) + + +def reask_design(env: Env, rec: dict, w: dict, head: str) -> bool: + """承認した設計 PR の先端が承認の後に動いた(または承認した先端が記録に無い): 新しい先端で問い直す(#2507)。 + レーンは新しい先端で依頼を送り直し、送れなければ何も変えずに次の周回の境で打ち直す(古い答えを当てないため)。 + 1 本のループは行の承認した先端を書き換え、答えを外して人の判断待ちへ戻す。問い直したら True。""" + if not head: + return False + old = w.get("stamp") or "" + if w.get("request"): + sent = lane_gate1(env, w, w.get("report") or "", w.get("state") or "") + if not sent: + return False + _reopen(w) + w.update(sent, restamped=old) + else: + _reopen(w) + w.update(stamp=head, restamped=old) + mark_issue(env, rec, w) + return True + + +def giveback_design(env: Env, rec: dict, w: dict, reason: str, lap: int) -> bool: + """時間で解けないマージの結果を、承認した先端のまま利用者へ戻す(#2507 の決定 9)。依頼にマージの結果の理由を載せる。 + 利用者は「直して出し直す」「やらない」か、手を打ってから「承認」を選ぶ。戻した行のマージの結果(streak・infra_reruns)は消す。 + レーンは同じ先端でも別の依頼にするため id に `-m<周回>` を足す。送れなければ何も変えずに次の周回の境で打ち直す。""" + w["returned"] = reason + if w.get("request"): + sent = lane_gate1(env, w, w.get("report") or "", w.get("state") or "", suffix=f"-m{lap}", stamp=w.get("stamp") or "") + if not sent: + w.pop("returned", None) + return False + _reopen(w) + w.update(sent) + else: + _reopen(w) + mark_issue(env, rec, w) + return True + + def drop_issues(plan: str, moved: list[int]) -> None: """残りの課題でも流す共通の計画(検査など)から、人の判断待ちへ移した課題を外して書き戻す。 計画の `課題` と、pr のステップの材料の `closes`(スプリント PR の Closes の行)の両方から外す。""" diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index 1ba067b1e..f65bbdd7f 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -5,7 +5,9 @@ import os import re import shlex +import sys +import design_pr_merge import project_mvv as pm import sprint_mvv @@ -38,7 +40,12 @@ ) from sprint_loop_gate import ( + MERGED_RESULTS, + design_head, + giveback_design, loop_ok, + note_merge, + reask_design, refusal_order, select_cmd, ) @@ -83,11 +90,30 @@ def candidates_of(ms: dict, max_issues: int, held: set[int] = frozenset()) -> li PENDING_REASON = "保留の周回の課題" +APPROVED_REASON = "承認した設計" +PRODUCTION_REASON = "設計 PR の宛先が本番(承認ゲート 2 で人がマージする)" + + +def approved_designs(rec: dict) -> set[int]: + """承認し、設計 PR のマージが済み、その後の周回でまだ選ばれていない課題(#2507 の I7)。""" + 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)] + 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]: - """順位によらず先頭に選ぶ課題(#2436 の 6e): 設計を直して出し直す課題と、配布を差し戻された周回の直し。""" + """順位によらず先頭に選ぶ課題(#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"} - return revise | {n for f in rec.get("fixes", []) if not f.get("fixed_by") for n in f.get("issues") or []} + 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) def exclusion_reason(env: Env, rec: dict, row: dict, lap_n: int, lap_created: set) -> str | None: @@ -107,7 +133,7 @@ def exclusion_reason(env: Env, rec: dict, row: dict, lap_n: int, lap_created: se if w.get("pr") and env.pr_state(int(w["pr"])) == "MERGED": w["merged_at"] = env.now() else: - return "設計 PR がマージ待ち" + return merge_wait_reason(w) for e in rec.get("excluded", []): if e["number"] == n and e.get("lap") == lap_n: return e["reason"] @@ -118,8 +144,104 @@ def exclusion_reason(env: Env, rec: dict, row: dict, lap_n: int, lap_created: se return None +def merge_wait_reason(w: dict) -> str: + """承認済みで未マージの課題を外す理由。マージの結果の理由を添える(#2507)。""" + m = w.get("merge") or {} + if m.get("result") == "production": + return PRODUCTION_REASON + return f"設計 PR がマージ待ち({m['reason']})" if m.get("reason") else "設計 PR がマージ待ち" + + +# ---------------------------------------------------------------- 承認した設計のマージし直し(#2507 の I6) + +STREAK_LIMIT = 3 # 同じ分類の not_green がこの周回の数だけ続いたら利用者へ戻す(既定。測っていない) +INFRA_RERUNS = 3 # 同じ承認した先端で基盤待ちのために再実行する回数の上限(merge-when-green の --infra-reruns の既定と同じ) +UNSOLVABLE = ("stale_again",) # 時間で解けない not_green の分類 + + +def settle_approved_designs(env: Env, rec: dict) -> list[str]: + """周回の境(選定の前)で、承認済みで未マージの設計 PR ごとに、前のマージの結果に応じた手を 1 回だけ打つ。 + マージ済みになった設計 PR は後片付けを打つ。失敗はその課題を外す理由にとどめ、周回は止めない。打った手の 1 行を返す。""" + lap_n = len(rec.get("laps", [])) + 1 + notes = [] + for w in rec.get("waiting", []): + if w.get("answer") != "approved" or not w.get("pr"): + continue + if not w.get("merged_at"): + if line := _settle_one(env, rec, w, lap_n): + notes.append(line) + if w.get("answer") == "approved" and w.get("merged_at") and not w.get("cleaned"): + code, so, se = env.run([sys.executable, str(design_pr_merge.MERGED_STEPS), "cleanup", str(w["pr"])], cwd=str(env.main_dir())) + w["cleaned"] = env.now() if code == 0 else None + if code != 0: + w["cleanup_error"] = (se or so).strip()[:200] + return notes + + +def _settle_one(env: Env, rec: dict, w: dict, lap_n: int) -> str | None: + """「周回の境が結果ごとに打つ手」の表(設計 #2507)の 1 行を打つ。""" + m = w.get("merge") or {} + res, cls, pr = m.get("result"), m.get("class"), int(w["pr"]) + if res in MERGED_RESULTS: + w["merged_at"] = env.now() + return None + if res in ("head_moved", "no_stamp"): + return f"#{w['number']} の設計 PR #{pr} を新しい先端で問い直した" if reask_design(env, rec, w, design_head(env, w)) else None + if res == "production": # 宛先は周回を重ねても変わらない。人が承認ゲート 2 でマージしたかだけを読む + if env.pr_state(pr) == "MERGED": + w["merged_at"] = env.now() + return None + if res == "failed": + return _giveback(env, rec, w, m.get("reason") or "マージできない", lap_n) + if res == "conflict": + return _settle_conflict(env, rec, w, m, lap_n) + if res == "not_green" and (cls in UNSOLVABLE or m.get("streak", 0) >= STREAK_LIMIT): + return _giveback(env, rec, w, m.get("reason") or "検査が緑にならない", lap_n) + if res == "not_green" and cls == "infra" and m.get("infra_reruns", 0) >= INFRA_RERUNS: + return _giveback(env, rec, w, f"CI の基盤待ちが再実行 {INFRA_RERUNS} 回で解けない", lap_n) + out = design_pr_merge.merge_approved(env.main_dir(), pr, w.get("stamp"), run=env.run) + got = note_merge(w, out, lap_n, env.now()) + return f"#{w['number']} の設計 PR #{pr} をマージし直した({got['result']})" + + +def _giveback(env: Env, rec: dict, w: dict, reason: str, lap_n: int) -> str | None: + if giveback_design(env, rec, w, reason, lap_n): + return f"#{w['number']} の設計 PR #{w['pr']} をマージできず、利用者へ戻した({reason})" + return None + + +def design_worktree(env: Env, branch: str) -> str | None: + """設計のブランチを持つ worktree のパス。無ければ None。""" + if not branch: + return None + code, so, _ = env.run(["git", "worktree", "list", "--porcelain"], cwd=str(env.main_dir())) + path = None + for line in (so or "").splitlines() if code == 0 else []: + if line.startswith("worktree "): + path = line.split(" ", 1)[1] + elif line == f"branch refs/heads/{branch}": + return path + return None + + +def _settle_conflict(env: Env, rec: dict, w: dict, m: dict, lap_n: int) -> str | None: + """宛先と衝突した設計 PR: 設計の worktree でベースブランチを取り込んで push する。先端が進めば新しい先端で問い直し、 + 衝突が残れば取り込みを閉じて利用者へ戻す。""" + wt = design_worktree(env, m.get("branch") or "") + if not wt: + return _giveback(env, rec, w, f"宛先と衝突している(設計の worktree が無い): {m.get('reason') or ''}", lap_n) + code, so, se = env.run([sys.executable, str(design_pr_merge.MERGED_STEPS), "base-sync", "--pr", str(w["pr"]), "--push"], cwd=wt) + if code == 0: + head = design_head(env, w) + if head and head != (w.get("stamp") or "") and reask_design(env, rec, w, head): + return f"#{w['number']} の設計 PR #{w['pr']} にベースブランチを取り込み、新しい先端で問い直した" + if code == 1: + env.run(["git", "merge", "--abort"], cwd=wt) + return _giveback(env, rec, w, f"宛先と衝突し、ベースブランチを取り込めない({code}): {(se or so).strip()[:160]}", lap_n) + + def _select_candidate_rows( - cands: list[dict], title: str, max_issues: int, reason_for, first: set[int] = frozenset() + cands: list[dict], title: str, max_issues: int, reason_for, first: set[int] = frozenset(), approved: set[int] = frozenset() ) -> tuple[list[int], list[dict]]: """1 マイルストーンの候補の採否。選んだ番号と選定の行を返す。reason_for は外す理由(外さなければ None)を返す。 max_issues の枠は、先頭に選ぶ課題(first。直して出し直す・配布の直し)→ 品質の枠の課題 → 順位の順に割り当てる @@ -136,7 +258,12 @@ def _select_candidate_rows( why = whys[i] if whys[i] is not None or i in take else "容量" if why is None: picked.append(r["number"]) - reason = "直しの先頭" if r["number"] in first else "品質の枠" if r.get("quality_slot") else "順位" + reason = ( + APPROVED_REASON if r["number"] in approved + else "直しの先頭" if r["number"] in first + else "品質の枠" if r.get("quality_slot") + else "順位" + ) # fmt: skip here.append({"number": r["number"], "milestone": title, "rank": r["rank"], "decision": SELECTED, "reason": reason}) else: here.append({"number": r["number"], "milestone": title, "rank": r["rank"], "decision": DROPPED, "reason": why}) @@ -173,6 +300,7 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic 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) for title in order: ms = by_title[title] cands = milestone_candidates(ms, rec, first) @@ -185,6 +313,7 @@ def choose_issues(env: Env, rec: dict, rank: dict, lap_n: int, descriptions: dic rec["max_issues"], (lambda r: "スプリント MVV が未承認") if unapproved else (lambda r: exclusion_reason(env, rec, r, lap_n, lap_created)), first, + approved, ) rows_out += here if picked: @@ -213,6 +342,7 @@ def boundary_then_select(env: Env, rec: dict, before: dict) -> dict: got = pending_step(env, rec, before, sprint_loop.apply_gate1_answers) if got is not None: return got + settle_approved_designs(env, rec) # 選定の前に、承認済みで未マージの設計 PR をマージし直す(#2507) if len(pending_laps(rec)) >= rec.get("release_pending_max", RELEASE_PENDING_MAX): return stop_and_report(env, rec, before, waiting_only(rec, "release_pending_max"), "答えの無い保留の周回が上限に達した(待ちだけ)") stop = held_stop(rec, boundary_stop(env, rec)) diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 1952d1e44..0474ef24d 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -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 \\| --revise --note <パス> \\| --rejected \\| --choice <答え>...)` | 問いの答えを送る(持ち主のレーンが止まっていれば `metrics.next` に起こす 1 行) | +| `answer <計画名> <依頼> (--approved [--by mvv] \\| --revise --note <パス> \\| --rejected \\| --choice <答え>...)` | 問いの答えを送る(持ち主のレーンが止まっていれば `metrics.next` に起こす 1 行)。設計の承認では設計 PR をマージしてから送り、結果を `metrics.merge` に、答えの後に残る依頼を `metrics.after` に載せる | | `helper <計画名> <連番> <提示>` | 一時レーンが流すもの(`phase`・`pipeline`・`settle`・`gate2`)を返す | 外のオーガナイザーとの `--external` の副命令(#2439)は `lib/parallel_external_cli.py` にある。 @@ -396,10 +396,23 @@ def cmd_answer(a) -> None: note = Path(a.note).read_text(encoding="utf-8").strip() except OSError as exc: reply("stopped", f"指摘ファイルを読めない: {exc}", code=2) + if a.by == "mvv" and not a.approved: + reply("stopped", "--by mvv は --approved とだけ組む", code=1) result = "approved" if a.approved else "revise" if a.revise else "rejected" if a.rejected else "answered" - nxt = pa.owner_restart(main, a.name, a.id, a.choice) - pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice) - reply("ok", f"依頼 {a.id} へ答え({result})を送った", id=a.id, result=result, next=nxt) + seq, items = pa.questions(main, a.name) + q = next((q for q in items if q.get("id") == a.id), {}) + nxt = pa.owner_restart(main, a.name, a.id, a.choice, items) + merge = None + if a.approved and q.get("kind") == "gate1" and q.get("pr"): # 承認した設計のマージ(#2507)。成否によらず答えを送り 0 で返る + import design_pr_merge + + merge = design_pr_merge.merge_approved(main, int(q["pr"]), q.get("stamp")) + pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice, merge=merge, by=a.by) + summary = f"依頼 {a.id} へ答え({result})を送った" + if merge: + summary += "。設計 PR をマージした" if merge["result"] in ("merged", "already_merged") else f"。設計 PR をマージできない({merge['result']}: {merge['reason']})" + extra = {"merge": merge} if merge is not None else {} + reply("ok", summary, id=a.id, result=result, next=nxt, seq=seq, after=pa.after_items(items, q or {"id": a.id}), **extra) def cmd_helper(a) -> None: @@ -480,6 +493,7 @@ def build_parser() -> argparse.ArgumentParser: ag.add_argument("--rejected", action="store_true") ag.add_argument("--choice", nargs="+") an.add_argument("--note") + an.add_argument("--by", choices=("user", "mvv"), default="user", help="--approved の担い手(MVV 判定で自分で答えたら mvv)") hp = sub.add_parser("helper") hp.add_argument("name") hp.add_argument("n", type=int) diff --git a/plugins/ndf/scripts/tests/test_project_mvv.py b/plugins/ndf/scripts/tests/test_project_mvv.py index 07e2d1a6e..554d18d47 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv.py +++ b/plugins/ndf/scripts/tests/test_project_mvv.py @@ -789,7 +789,12 @@ 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": []}') - outs = [gate_check(env, state)[1] for _ in range(3)] + # 判定できなかった依頼の数で数える(#2507): 同じスプリント状態の打ち直しは 1 件、別のスプリント状態なら別の件 + assert not any(i.get("kind") == "revise" for i in [gate_check(env, state)[1] for _ in range(3)][-1]["items"]) + 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 gate_check(env, state)[2][-1]["basis"] == [pm.NO_BASIS] diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index a0c12237f..759439a98 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -71,15 +71,28 @@ def env(tmp_path, mvv, monkeypatch): e.issue_body = lambda n: e.bodies.get(n, "本文\n") e.edit_issue = lambda n, b: e.bodies.__setitem__(n, b) e.calls = [] + e.heads = {} # 設計 PR の先端(gh pr view --json headRefOid)。無ければ HEAD e.run = fake_run(e) + e.merges = [] # 承認した設計のマージの呼び出し (pr, stamp)。結果は merge_box(#2507) + e.merge_box = {"result": "merged", "class": "passed", "reason": ""} + monkeypatch.setattr( + sl.design_pr_merge, + "merge_approved", + lambda root, pr, stamp, run=None: e.merges.append((pr, stamp)) or {"pr": pr, "head": stamp, "at": "t", **e.merge_box}, + ) return e +HEAD = "h" * 40 + + def fake_run(e): """sprint-state.py init・supervise.py new sprint / new close の差し替え。--out にマニフェストを書く。""" def run(cmd, cwd=None, stdin=None): e.calls.append(cmd) + if cmd[:3] == ["gh", "pr", "view"] and "headRefOid" in cmd: + return 0, e.heads.get(int(cmd[3]), HEAD), "" if "init" in cmd: Path(cmd[cmd.index("init") + 1]).write_text( json.dumps({"plans": [], "done": [], "gates": [], "goal_template": "", "versions": {}}) @@ -604,11 +617,14 @@ def test_answers_control_reselection(env): sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.1", None, []) lap = sl.current_lap(rec(env)) sl_gate.cmd_gate(env, "l1", write_done(env, [{"plan": str(Path(lap["out"]) / f"design-{n}.json"), "result": "関門"} for n in (11, 12)])) - assert sl.cmd_answer(env, "l1", 11, True, 501)["status"] == "ok" + env.merge_box = {"result": "not_green", "class": "running", "reason": "検査が緑でない(running)"} + # 行に承認した先端が無い(設計 PR を答えで渡した): 答えを当てずに今の先端を記録し、答え直しを求める(#2507) + assert sl.cmd_answer(env, "l1", 11, True, 501)["metrics"]["status"] == "regated" and rec(env)["waiting"][0]["stamp"] == HEAD + assert sl.cmd_answer(env, "l1", 11, True, None)["status"] == "ok" and env.merges == [(501, HEAD)] assert sl.cmd_answer(env, "l1", 12, False, None)["status"] == "ok" assert sl.cmd_answer(env, "l1", 12, True, None)["exit"] == 1 # もう待っていない r = rec(env) - assert sl_select.exclusion_reason(env, r, {"number": 11}, 2, set()) == "設計 PR がマージ待ち" + assert sl_select.exclusion_reason(env, r, {"number": 11}, 2, set()) == "設計 PR がマージ待ち(検査が緑でない(running))" assert sl_select.exclusion_reason(env, r, {"number": 12}, 2, set()) == "差し戻し" env.prs[501] = "MERGED" assert sl_select.exclusion_reason(env, r, {"number": 11}, 2, set()) is None and r["waiting"][0]["merged_at"] @@ -874,10 +890,11 @@ def test_gate_1_takes_the_verdict_of_the_plan_design_pr_and_saves_the_pr_as_a_nu w = rec(env)["waiting"][0] assert (w["number"], w["pr"], w["reason"], w["verdict"]) == (11, 501, "11 の理由", "従わない") env.calls.clear() + env.merge_box = {"result": "conflict", "class": "passed", "reason": "宛先と衝突している"} sl.cmd_answer(env, "l1", 11, True, None) gate = next(c for c in env.calls if "gate" in c) assert gate[gate.index("--pr") + 1] == "501" - assert sl_select.exclusion_reason(env, rec(env), {"number": 11}, 2, set()) == "設計 PR がマージ待ち" + assert sl_select.exclusion_reason(env, rec(env), {"number": 11}, 2, set()) == "設計 PR がマージ待ち(宛先と衝突している)" def approve_gate_2(lap): From ae96a00c3ebabaee2d3ef809ca6937aba3201149 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 04:57:51 +0000 Subject: [PATCH 023/127] =?UTF-8?q?Test:=20=E6=89=BF=E8=AA=8D=E3=81=97?= =?UTF-8?q?=E3=81=9F=E8=A8=AD=E8=A8=88=E3=81=AE=E3=83=9E=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=83=BB=E5=91=A8=E5=9B=9E=E3=81=AE=E5=A2=83=E3=81=AE=E3=83=9E?= =?UTF-8?q?=E3=83=BC=E3=82=B8=E3=81=97=E7=9B=B4=E3=81=97=E3=83=BB=E7=AD=94?= =?UTF-8?q?=E3=81=88=E3=81=AE=E5=BE=8C=E3=81=AE=E4=B8=80=E8=A6=A7=E3=82=92?= =?UTF-8?q?=E7=B8=9B=E3=82=8B=EF=BC=88#2507=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_answer.py | 4 +- plugins/ndf/scripts/lib/sprint_loop.py | 4 +- plugins/ndf/scripts/parallel.py | 6 +- .../ndf/scripts/tests/test_design_pr_merge.py | 151 +++++++++++ .../ndf/scripts/tests/test_parallel_answer.py | 114 +++++++++ .../ndf/scripts/tests/test_parallel_run.py | 14 ++ plugins/ndf/scripts/tests/test_project_mvv.py | 10 + plugins/ndf/scripts/tests/test_sprint_loop.py | 234 ++++++++++++++++++ 8 files changed, 534 insertions(+), 3 deletions(-) create mode 100644 plugins/ndf/scripts/tests/test_design_pr_merge.py create mode 100644 plugins/ndf/scripts/tests/test_parallel_answer.py diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py index dba988400..18d7c9d6e 100644 --- a/plugins/ndf/scripts/lib/parallel_answer.py +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -49,7 +49,9 @@ def drop(q: dict) -> str | None: return None if answered.get("kind") == "gate1" and key(q) == key(answered): return "settled" - if any(o is not q and o.get("kind") == "gate1" and key(o) == key(q) and str(o.get("at") or "") > str(q.get("at") or "") for o in rest): + if any( + o is not q and o.get("kind") == "gate1" and key(o) == key(q) and str(o.get("at") or "") > str(q.get("at") or "") for o in rest + ): return "resend" return None diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index f14bf7af8..9ae018191 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -407,7 +407,9 @@ def regated_answer(env: Env, w: dict) -> str | None: return None w.update(stamp=head, restamped=stamp) if not stamp.strip("0"): - return f"#{w['number']} の設計 PR #{w['pr']} の承認した先端が記録に無い。今の先端 {head[:12]} を記録した。この先端を見てから答え直す" + return ( + f"#{w['number']} の設計 PR #{w['pr']} の承認した先端が記録に無い。今の先端 {head[:12]} を記録した。この先端を見てから答え直す" + ) return f"#{w['number']} の設計 PR #{w['pr']} の先端が問いの後に動いた({stamp[:12]} → {head[:12]})。新しい先端を見てから答え直す" diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 0474ef24d..d6cd1725f 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -410,7 +410,11 @@ def cmd_answer(a) -> None: pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice, merge=merge, by=a.by) summary = f"依頼 {a.id} へ答え({result})を送った" if merge: - summary += "。設計 PR をマージした" if merge["result"] in ("merged", "already_merged") else f"。設計 PR をマージできない({merge['result']}: {merge['reason']})" + summary += ( + "。設計 PR をマージした" + if merge["result"] in ("merged", "already_merged") + else f"。設計 PR をマージできない({merge['result']}: {merge['reason']})" + ) extra = {"merge": merge} if merge is not None else {} reply("ok", summary, id=a.id, result=result, next=nxt, seq=seq, after=pa.after_items(items, q or {"id": a.id}), **extra) diff --git a/plugins/ndf/scripts/tests/test_design_pr_merge.py b/plugins/ndf/scripts/tests/test_design_pr_merge.py new file mode 100644 index 000000000..f623e5d95 --- /dev/null +++ b/plugins/ndf/scripts/tests/test_design_pr_merge.py @@ -0,0 +1,151 @@ +"""承認した設計のマージ(lib/design_pr_merge.py。#2507)。gh と merged-steps.py は偽のコマンドで置き換える。""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS / "lib")) +import delivery # noqa: E402 +import design_pr_merge as dm # noqa: E402 + +STAMP = "a" * 40 +GREEN = [{"name": "ci", "status": "COMPLETED", "conclusion": "SUCCESS"}] +RULES = [{"type": "required_status_checks", "parameters": {"required_status_checks": [{"context": "ci"}]}}] + + +class Gh: + """偽の gh と merged-steps.py。呼ばれたコマンドを calls に残す。""" + + def __init__(self, **kw): + self.view = {"state": "OPEN", "headRefOid": STAMP, "baseRefName": "develop", "headRefName": "design/x", "statusCheckRollup": GREEN} + self.view.update(kw.pop("view", {})) + self.rules = kw.pop("rules", (0, json.dumps(RULES), "")) + self.contents = kw.pop("contents", (1, "", "HTTP 404: Not Found")) + self.probe = kw.pop("probe", "passed") + self.mwg = kw.pop("mwg", (0, "{}", "")) + self.calls: list[list[str]] = [] + + def __call__(self, cmd, cwd=None, stdin=None): + self.calls.append(cmd) + if cmd[:3] == ["gh", "pr", "view"]: + return 0, json.dumps(self.view), "" + if cmd[:2] == ["gh", "api"] and "/rules/branches/" in cmd[2]: + return self.rules + if cmd[:2] == ["gh", "api"] and "/contents/" in cmd[2]: + return self.contents + if "probe" in cmd: + return 0, json.dumps({"status": "ok", "metrics": {"class": self.probe}}), "" + if "merge-when-green" in cmd: + return self.mwg + raise AssertionError(f"思わぬ呼び出し: {cmd}") + + def called(self, word: str) -> list[list[str]]: + return [c for c in self.calls if word in c] + + +@pytest.fixture(autouse=True) +def not_production(monkeypatch): + box = {"value": delivery.NOT_PRODUCTION} + monkeypatch.setattr(dm.delivery, "load_delivery", lambda root: None) + monkeypatch.setattr(dm.delivery, "judge_target", lambda decl, target: delivery.Verdict(target, box["value"], "宣言による")) + return box + + +def merge(gh, stamp=STAMP): + return dm.merge_approved("/r", 501, stamp, run=gh) + + +def test_green_design_pr_at_the_approved_head_is_merged_without_cleanup_and_with_the_wait_limit(): + gh = Gh() + out = merge(gh) + argv = gh.called("merge-when-green")[0] + assert out["result"] == "merged" and out["class"] == "passed" and out["branch"] == "design/x" + assert argv[argv.index("--expect-head") + 1] == STAMP and "--no-cleanup" in argv and "--gate-approved" not in argv + assert argv[argv.index("--timeout") :][:8] == dm.WAIT_ARGS and "--infra-gap" not in argv + assert gh.called("probe")[0][-1] == "--act" + + +def test_moved_head_and_missing_stamp_do_not_merge(): + gh = Gh(view={"headRefOid": "b" * 40}) + assert merge(gh)["result"] == "head_moved" and not gh.called("probe") + gh = Gh() + assert merge(gh, "0" * 12)["result"] == "no_stamp" and merge(gh, "")["result"] == "no_stamp" + assert not gh.called("merge-when-green") + + +def test_merged_and_closed_design_prs(): + assert merge(Gh(view={"state": "MERGED"}))["result"] == "already_merged" + out = merge(Gh(view={"state": "CLOSED"})) + assert out["result"] == "failed" and "CLOSED" in out["reason"] + + +@pytest.mark.parametrize("value", [delivery.PRODUCTION, delivery.UNDETERMINED]) +def test_production_target_is_not_merged_and_no_check_is_read(not_production, value): + not_production["value"] = value + gh = Gh() + assert merge(gh)["result"] == "production" + assert gh.calls == [gh.calls[0]] # gh pr view の 1 回だけ(probe・merge-when-green・承認資料を書かない) + + +@pytest.mark.parametrize("cls", ["running", "queued", "failed", "stale", "stale_again", "settled"]) +def test_checks_that_are_not_green_do_not_call_merge_when_green(cls): + gh = Gh(probe=cls) + out = merge(gh) + assert (out["result"], out["class"]) == ("not_green", cls) and not gh.called("merge-when-green") + + +def test_infra_wait_reruns_with_no_gap_and_marks_the_rerun(): + gh = Gh(probe="infra") + out = merge(gh) + argv = gh.called("merge-when-green")[0] + assert out["result"] == "merged" and out["infra_rerun"] is True and argv[argv.index("--infra-gap") + 1] == "0" + + +def test_unregistered_required_check_does_not_merge(): + gh = Gh(view={"statusCheckRollup": [{"name": "lint", "status": "COMPLETED", "conclusion": "SUCCESS"}]}) + out = merge(gh) + assert (out["result"], out["class"]) == ("not_green", dm.UNREGISTERED) and "ci" in out["reason"] and not gh.called("probe") + + +def test_empty_checks_merge_only_when_there_are_no_rules_and_no_workflows(): + gh = Gh(view={"statusCheckRollup": []}, rules=(0, "[]", "")) + assert merge(gh)["result"] == "merged" + for gh in ( + Gh(view={"statusCheckRollup": []}, rules=(0, "[]", ""), contents=(0, "[]", "")), # ワークフローがある + Gh(view={"statusCheckRollup": []}, rules=(0, json.dumps(RULES), "")), # 必須の検査がある + Gh(view={"statusCheckRollup": []}, rules=(1, "", "HTTP 403")), # 規則を読めない + Gh(view={"statusCheckRollup": []}, rules=(0, "[]", ""), contents=(1, "", "HTTP 500")), # ワークフローの有無を読めない + ): + out = merge(gh) + assert (out["result"], out["class"]) == ("not_green", dm.UNREGISTERED) and not gh.called("merge-when-green") + + +@pytest.mark.parametrize( + "mwg, want", + [ + ((4, json.dumps({"summary": "衝突"}), ""), "conflict"), + ((10, json.dumps({"summary": "承認ゲート 2"}), ""), "production"), + ((75, json.dumps({"summary": "基盤待ち"}), ""), "not_green"), + ((1, json.dumps({"summary": "60 秒で終わらない", "items": [{"kind": "check", "result": "pending"}]}), ""), "not_green"), + ((1, json.dumps({"summary": "権限が無い", "items": [{"kind": "pr", "result": "stopped"}]}), ""), "failed"), + ((1, json.dumps({"items": [{"kind": "pr", "result": "head_moved"}]}), ""), "head_moved"), + ((2, "", "読めない"), "failed"), + ], +) +def test_merge_when_green_exit_codes_map_to_results(mwg, want): + out = merge(Gh(mwg=mwg)) + assert out["result"] == want and out["reason"] + + +def test_unreadable_output_never_raises(): + def broken(cmd, cwd=None, stdin=None): + raise OSError("gh が無い") + + out = dm.merge_approved("/r", 501, STAMP, run=broken) + assert out["result"] == "failed" and "gh が無い" in out["reason"] + assert dm.merge_approved("/r", 501, STAMP, run=lambda cmd, cwd=None, stdin=None: (0, "壊れた", ""))["result"] == "failed" diff --git a/plugins/ndf/scripts/tests/test_parallel_answer.py b/plugins/ndf/scripts/tests/test_parallel_answer.py new file mode 100644 index 000000000..37d78e9ee --- /dev/null +++ b/plugins/ndf/scripts/tests/test_parallel_answer.py @@ -0,0 +1,114 @@ +"""`parallel.py answer` の承認した設計のマージと、答えの後に残る依頼の一覧(#2507)。耐久の記録と gh は差し替える。""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +SCRIPTS = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(SCRIPTS / "lib")) +sys.path.insert(0, str(SCRIPTS)) +import design_pr_merge # noqa: E402 +import parallel_answer as pa # noqa: E402 +import parallel_lane as pl # noqa: E402 + + +def gate1(rid, lane="la", pr=501, stamp="a" * 40, at="2026-11-05T00:00:00Z", verdict="unknown", **kw): + return {"kind": "gate1", "id": rid, "lane": lane, "pr": pr, "stamp": stamp, "at": at, "verdict": verdict, "boundary": [], **kw} + + +def test_after_items_drop_the_answered_request_and_mark_resends_and_settled(tmp_path): + state = tmp_path / "s.json" + state.write_text("{}") + answered = gate1("la-g501-a", sprint_state=str(state)) + items = [ + answered, + gate1("la-g501-old", stamp="0" * 40, at="2026-11-04T00:00:00Z"), # 同じ設計 PR の依頼: 答えで要らなくなった + gate1("lb-g601-old", lane="lb", pr=601, at="2026-11-04T00:00:00Z", sprint_state=str(state)), + gate1("lb-g601-new", lane="lb", pr=601, at="2026-11-05T01:00:00Z", sprint_state=str(state)), + {"kind": "ask", "id": "lc-q1", "lane": "lc"}, + ] + after = {q["id"]: q for q in pa.after_items(items, answered)} + assert "la-g501-a" not in after + assert [after[i]["drop"] for i in ("la-g501-old", "lb-g601-old", "lb-g601-new", "lc-q1")] == ["settled", "resend", None, None] + assert after["lb-g601-new"]["mvv"]["eligible"] is True and after["lc-q1"]["mvv"]["eligible"] is False + + +@pytest.mark.parametrize( + "q, eligible", + [ + (dict(verdict="unknown"), True), + (dict(verdict="unreadable"), True), + (dict(verdict="not_follow"), False), + (dict(verdict="machine"), False), + (dict(verdict="unknown", boundary=["C1 に当たる"]), False), + (dict(verdict="unknown", sprint_state="/無い/s.json"), False), + (dict(verdict="unknown", kind="gate2"), False), + ], +) +def test_mvv_mark_only_for_undecided_design_approvals_without_redlines(tmp_path, q, eligible): + state = tmp_path / "s.json" + state.write_text("{}") + item = {**gate1("x", sprint_state=str(state)), **q} + assert pa.mvv_mark(item)["eligible"] is eligible + + +@pytest.fixture +def cli(monkeypatch, tmp_path): + spec = importlib.util.spec_from_file_location("parallel_cli_answer", SCRIPTS / "parallel.py") + mod = importlib.util.module_from_spec(spec) + spec.loader.exec_module(mod) + box = {"items": [gate1("la-g501-a")], "sent": [], "merges": [], "merge": {"result": "merged", "reason": ""}} + monkeypatch.setattr(mod, "main_of", lambda: tmp_path) + monkeypatch.setattr(pl, "run_status", lambda main, name: next(iter(pl.RUNNING))) + monkeypatch.setattr(pa, "questions", lambda main, name: (7, box["items"])) + monkeypatch.setattr(pa, "owner_restart", lambda *a, **k: None) + monkeypatch.setattr(pl, "send_answer", lambda main, name, rid, result, **kw: box["sent"].append((rid, result, kw))) + monkeypatch.setattr( + design_pr_merge, "merge_approved", lambda root, pr, stamp, run=None: box["merges"].append((pr, stamp)) or {**box["merge"], "pr": pr} + ) + box["mod"] = mod + return box + + +def answer(cli, capsys, *argv): + with pytest.raises(SystemExit) as e: + cli["mod"].main(["answer", "p1", *argv]) + return e.value.code or 0, json.loads(capsys.readouterr().out.strip().splitlines()[-1]) + + +def test_approved_design_is_merged_before_the_answer_is_sent(cli, capsys): + code, out = answer(cli, capsys, "la-g501-a", "--approved") + rid, result, kw = cli["sent"][0] + assert code == 0 and cli["merges"] == [(501, "a" * 40)] and out["metrics"]["merge"]["result"] == "merged" + assert (rid, result, kw["merge"]["result"], kw["by"]) == ("la-g501-a", "approved", "merged", "user") + assert out["metrics"]["seq"] == 7 and out["metrics"]["after"] == [] + + +def test_a_failed_merge_still_sends_the_answer_and_exits_0(cli, capsys): + cli["merge"] = {"result": "conflict", "reason": "宛先と衝突している"} + code, out = answer(cli, capsys, "la-g501-a", "--approved", "--by", "mvv") + assert code == 0 and cli["sent"][0][2]["merge"]["result"] == "conflict" and cli["sent"][0][2]["by"] == "mvv" + assert "宛先と衝突している" in out["summary"] + + +@pytest.mark.parametrize("argv", [["--rejected"], ["--choice", "A"]]) +def test_other_answers_do_not_merge(cli, capsys, argv): + code, out = answer(cli, capsys, "la-g501-a", *argv) + assert code == 0 and cli["merges"] == [] and "merge" not in out["metrics"] and cli["sent"][0][2]["merge"] is None + + +def test_revise_does_not_merge(cli, capsys, tmp_path): + note = tmp_path / "n.md" + note.write_text("直す") + answer(cli, capsys, "la-g501-a", "--revise", "--note", str(note)) + assert cli["merges"] == [] + + +def test_by_mvv_needs_approved(cli, capsys): + code, _ = answer(cli, capsys, "la-g501-a", "--rejected", "--by", "mvv") + assert code == 1 and cli["sent"] == [] diff --git a/plugins/ndf/scripts/tests/test_parallel_run.py b/plugins/ndf/scripts/tests/test_parallel_run.py index 782b7aa00..d60741ff6 100644 --- a/plugins/ndf/scripts/tests/test_parallel_run.py +++ b/plugins/ndf/scripts/tests/test_parallel_run.py @@ -402,6 +402,20 @@ def test_ask_request_raises_questions_and_the_answer_reaches_the_lane(env, tmp_p until(lambda: not [i for i in questions(main)["items"] if i["id"] == rid]) # 答えた問いはもう返さない +def test_gate1_answer_event_carries_the_merge_result_and_who_approved(env, tmp_path): + """設計の承認の答えのイベントに、答えの前に試みたマージの結果と担い手が載る(#2507)。同じ先端で戻した依頼は別の id。""" + main = env["main"] + start(main) + rid = pl.request_gate1(main, "p1", "la", 11, 501, "r.md", "s.json", "a" * 40, verdict="unknown") + again = pl.request_gate1(main, "p1", "la", 11, 501, "r.md", "s.json", "a" * 40, suffix="-m3") + assert again == f"{rid}-m3" + until(lambda: [i for i in questions(main)["items"] if i["id"] == rid]) + pl.send_answer(main, "p1", rid, "approved", merge={"result": "merged"}, by="mvv") + ans = until(lambda: pl.answers_of(main, "p1", [rid])[rid]["answer"]) + assert ans["merge"] == {"result": "merged"} and ans["by"] == "mvv" + until(lambda: [i["id"] for i in questions(main)["items"]] == [again]) + + def test_the_same_ask_id_is_counted_once(env, tmp_path): main = env["main"] start(main) diff --git a/plugins/ndf/scripts/tests/test_project_mvv.py b/plugins/ndf/scripts/tests/test_project_mvv.py index 554d18d47..b25c1c97e 100644 --- a/plugins/ndf/scripts/tests/test_project_mvv.py +++ b/plugins/ndf/scripts/tests/test_project_mvv.py @@ -885,3 +885,13 @@ def test_judge_gets_the_block_and_records_the_basis(env, monkeypatch): assert state["project_mvv"] == {"status": "approved", "version": 1, "sha256": sha(BODY)} assert state["log"][-1]["basis"] == ["Value 2"] assert (env["tmp"] / "judge-prompt.txt").read_text().startswith(pm.principles()) + + +def test_unknown_streak_counts_each_sprint_gate_and_pr_once(): + # オーガナイザーの打ち直しの行(#2507)は、隣り合っていなくても同じ組を 1 件と数える + ref = {"project_mvv": {"sha256": "s"}, "verdict": "unknown", "gate": "design"} + a, b = {**ref, "sprint": "/a.json", "pr": [5]}, {**ref, "sprint": "/b.json", "pr": [6]} + assert pms.unknown_streak([a, a], "s") == 1 + assert pms.unknown_streak([a, b, a], "s") == 2 + assert pms.unknown_streak([a, {**b, "verdict": "follow"}, b, a], "s") == 2 + assert pms.unknown_streak([{**ref}, {**ref}], "s") == 2 # スプリント状態の無い行は 1 行を 1 件と数える diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 759439a98..afc5dc753 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1578,3 +1578,237 @@ def test_selecting_a_returned_issue_marks_its_fix(env): assert got["metrics"]["selected"] == [12, 11] assert (lap["sprint"], lap["milestone"], lap["rank_digest"], lap["state_path"]) == (f"l1-{lap['n']}", "01 近い", "dg", None) assert [f.get("fixed_by") for f in rec(env)["fixes"]] == [lap["n"], None, 7] + + +# ---------------------------------------------------------------- 承認した設計のマージ(#2507) + + +def gated_both(env): + """#11(設計 PR 501)と #12(設計 PR 502)を承認ゲート 1 で人の判断待ちへ移し、周回を締めた記録。""" + lap = built_lap(env) + items = [ + { + "plan": str(Path(lap["out"]) / f"design-{n}.json"), + "result": "関門", + "report": design_report(env, f"d{n}", f"https://github.com/o/r/pull/{pr}"), + } + for n, pr in ((11, 501), (12, 502)) + ] + sl_gate.cmd_gate(env, "l1", write_done(env, items)) + assert sl.current_lap(rec(env)) is None + return rec(env) + + +def row(env, n): + return next(w for w in reversed(rec(env)["waiting"]) if w["number"] == n) + + +def test_loop_answer_merges_the_approved_design_and_the_next_lap_selects_it_first(env): + r = gated_both(env) + assert [w["stamp"] for w in r["waiting"]] == [HEAD, HEAD] # 並列の計画の外でも承認した先端を書く + out = sl.cmd_answer(env, "l1", 11, True, None) + w = row(env, 11) + assert out["status"] == "ok" and env.merges == [(501, HEAD)] and w["merge"]["result"] == "merged" and w["merged_at"] + assert out["items"][0]["merge"]["result"] == "merged" + sl.cmd_answer(env, "l1", 12, False, None) + env.rank_box["out"] = rank_out(ms("01 近い", [13, 14, 11])) + got = sl_select.cmd_select(env, "l1") + assert got["metrics"]["selected"][0] == 11 + first = next(x for x in got["items"] if x["number"] == 11) + assert first["reason"] == sl_select.APPROVED_REASON + assert any(c[-2:] == ["cleanup", "501"] for c in env.calls) # 後片付けは周回の境が打つ + # 選ばれた後の周回では先頭に選ばない + assert 11 not in sl_select.approved_designs(rec(env)) + + +def test_revise_and_rejected_answers_do_not_merge(env, tmp_path): + gated_both(env) + note = tmp_path / "n.md" + note.write_text("直す") + sl.cmd_answer(env, "l1", 11, False, None, revise=True, note=str(note)) + sl.cmd_answer(env, "l1", 12, False, None) + assert env.merges == [] + + +def test_loop_answer_for_a_moved_design_pr_is_regated_without_merging(env): + gated_both(env) + env.heads[501] = "b" * 40 + out = sl.cmd_answer(env, "l1", 11, True, None) + w = row(env, 11) + assert out["status"] == "ok" and out["metrics"]["status"] == "regated" and not w.get("answer") and env.merges == [] + assert w["stamp"] == "b" * 40 and w["restamped"] == HEAD + assert sl.cmd_answer(env, "l1", 11, True, None)["metrics"]["status"] == "answered" and env.merges == [(501, "b" * 40)] + + +def test_a_failed_merge_still_answers_and_is_retried_at_the_next_boundary(env): + gated_both(env) + env.merge_box = {"result": "not_green", "class": "running", "reason": "検査が緑でない(running)"} + out = sl.cmd_answer(env, "l1", 11, True, None) + assert out["status"] == "ok" and row(env, 11)["answer"] == "approved" and "マージできない" in out["summary"] + sl.cmd_answer(env, "l1", 12, False, None) + env.rank_box["out"] = rank_out(ms("01 近い", [11, 13, 14])) + env.merge_box = {"result": "merged", "class": "passed", "reason": ""} + got = sl_select.cmd_select(env, "l1") # 境で 1 回マージし直し、同じ周回の選定に含める(受け入れ条件 7) + assert len(env.merges) == 2 and got["metrics"]["selected"][0] == 11 and row(env, 11)["merge"]["lap"] == 2 + + +def test_a_merge_that_fails_again_only_drops_that_issue(env): + gated_both(env) + env.merge_box = {"result": "not_green", "class": "queued", "reason": "検査が緑でない(queued)"} + sl.cmd_answer(env, "l1", 11, True, None) + sl.cmd_answer(env, "l1", 12, False, None) + env.rank_box["out"] = rank_out(ms("01 近い", [11, 13, 14])) + got = sl_select.cmd_select(env, "l1") + drop = next(x for x in got["items"] if x["number"] == 11) + assert len(env.merges) == 2 and got["metrics"]["selected"] == [13] + assert drop["reason"] == "設計 PR がマージ待ち(検査が緑でない(queued))" and row(env, 11)["merge"]["streak"] == 2 + + +def boundary(env, n=1): + """周回の境のマージし直しだけを n 回打つ(選定は流さない)。""" + for _ in range(n): + with sl_record.open_record(env, "l1") as (r, before): + sl_select.settle_approved_designs(env, r) + sl_record.write_record(env, r, before) + + +def test_the_same_not_green_for_three_laps_is_given_back_to_the_user(env): + gated_both(env) + env.merge_box = {"result": "not_green", "class": "running", "reason": "走っている"} + sl.cmd_answer(env, "l1", 11, True, None) + boundary(env, 2) + assert len(env.merges) == 3 and row(env, 11)["merge"]["streak"] == 3 + boundary(env) + w = row(env, 11) + assert len(env.merges) == 3 and not w.get("answer") and w["returned"] == "走っている" and "merge" not in w + assert "利用者へ戻した理由: 走っている" in env.bodies[11] + + +@pytest.mark.parametrize("res, cls", [("failed", None), ("not_green", "stale_again")]) +def test_results_that_time_does_not_solve_are_given_back_at_once(env, res, cls): + gated_both(env) + env.merge_box = {"result": res, "class": cls, "reason": "権限が無い"} + sl.cmd_answer(env, "l1", 11, True, None) + boundary(env) + w = row(env, 11) + assert len(env.merges) == 1 and not w.get("answer") and w["returned"] == "権限が無い" + + +def test_infra_reruns_are_counted_across_classes_and_capped(env): + gated_both(env) + env.merge_box = {"result": "not_green", "class": "infra", "reason": "基盤待ち", "infra_rerun": True} + sl.cmd_answer(env, "l1", 11, True, None) + env.merge_box = {"result": "not_green", "class": "running", "reason": "走っている"} + boundary(env) # 実行中に変わっても累計は 0 に戻さない + env.merge_box = {"result": "not_green", "class": "infra", "reason": "基盤待ち", "infra_rerun": True} + boundary(env, 2) + assert row(env, 11)["merge"]["infra_reruns"] == 3 and len(env.merges) == 4 + boundary(env) + w = row(env, 11) + assert len(env.merges) == 4 and not w.get("answer") and "基盤待ち" in w["returned"] + + +def test_production_target_is_not_retried_and_waits_for_gate_2(env): + gated_both(env) + env.merge_box = {"result": "production", "class": None, "reason": "宛先 main が本番"} + sl.cmd_answer(env, "l1", 11, True, None) + boundary(env) + assert len(env.merges) == 1 and not row(env, 11).get("merged_at") + assert sl_select.exclusion_reason(env, rec(env), {"number": 11}, 2, set()) == sl_select.PRODUCTION_REASON + env.prs[501] = "MERGED" + boundary(env) + assert len(env.merges) == 1 and row(env, 11)["merged_at"] + + +def test_head_moved_at_the_boundary_asks_again_with_the_new_head(env): + gated_both(env) + env.merge_box = {"result": "head_moved", "class": None, "reason": "動いた"} + sl.cmd_answer(env, "l1", 11, True, None) + env.heads[501] = "c" * 40 + boundary(env) + w = row(env, 11) + assert not w.get("answer") and w["stamp"] == "c" * 40 and w["restamped"] == HEAD and len(env.merges) == 1 + + +def test_conflict_syncs_the_base_in_the_design_worktree_and_asks_again(env): + gated_both(env) + env.merge_box = {"result": "conflict", "class": "passed", "reason": "衝突", "branch": "design/11"} + sl.cmd_answer(env, "l1", 11, True, None) + base = env.run + + def run(cmd, cwd=None, stdin=None): + if cmd[:3] == ["git", "worktree", "list"]: + return 0, "worktree /m\nbranch refs/heads/develop\n\nworktree /wt/d11\nbranch refs/heads/design/11\n", "" + if "base-sync" in cmd: + env.calls.append([*cmd, cwd]) + env.heads[501] = "d" * 40 + return env.sync_code, "{}", "" + return base(cmd, cwd=cwd, stdin=stdin) + + env.run, env.sync_code = run, 0 + boundary(env) + w = row(env, 11) + assert any("base-sync" in c and c[-1] == "/wt/d11" for c in env.calls) + assert not w.get("answer") and w["stamp"] == "d" * 40 + + +def test_conflict_that_base_sync_cannot_solve_aborts_and_gives_back(env): + gated_both(env) + env.merge_box = {"result": "conflict", "class": "passed", "reason": "衝突", "branch": "design/11"} + sl.cmd_answer(env, "l1", 11, True, None) + base = env.run + + def run(cmd, cwd=None, stdin=None): + if cmd[:3] == ["git", "worktree", "list"]: + return 0, "worktree /wt/d11\nbranch refs/heads/design/11\n", "" + if "base-sync" in cmd: + return 1, "{}", "衝突が残る" + return base(cmd, cwd=cwd, stdin=stdin) + + env.run = run + boundary(env) + w = row(env, 11) + assert ["git", "merge", "--abort"] in env.calls and not w.get("answer") and "衝突が残る" in w["returned"] + + +def lane_env(env, monkeypatch): + env.answer_box = {} + env.answers = lambda plan, ids: {i: env.answer_box.get(i, {"answer": None, "defer": None}) for i in ids} + sent = [] + + def lane_gate1(e, w, report, state, suffix="", stamp=None): + stamp = stamp or sl_gate.design_head(e, w) + sent.append({"suffix": suffix, "stamp": stamp, "returned": w.get("returned")}) + return {"request": f"la-g{w['pr']}-{stamp[:12]}{suffix}", "plan": "p1", "stamp": stamp, "report": report, "state": state} + + monkeypatch.setattr(sl_gate, "lane_gate1", lane_gate1) + return sent + + +def test_lane_copies_the_organizer_merge_and_does_not_rewrite_an_mvv_approval(env, monkeypatch): + sent = lane_env(env, monkeypatch) + r = gated_both(env) + rid = r["waiting"][0]["request"] + assert rid == f"la-g501-{HEAD[:12]}" and sent[0]["suffix"] == "" + merge = {"result": "merged", "class": "passed", "reason": "", "pr": 501, "head": HEAD, "at": "t", "infra_rerun": False} + env.answer_box[rid] = {"answer": {"result": "approved", "merge": merge, "by": "mvv"}, "defer": None} + env.answer_box[r["waiting"][1]["request"]] = {"answer": {"result": "rejected"}, "defer": None} + env.calls.clear() + env.rank_box["out"] = rank_out(ms("01 近い", [13, 11])) + got = sl_select.cmd_select(env, "l1") + w = row(env, 11) + assert w["answer"] == "approved" and w["by"] == "mvv" and w["merged_at"] and w["merge"]["result"] == "merged" + assert not any("gate" in c and c[-2:] == ["--pr", "501"] for c in env.calls) # 承認の記録は mvv-gate.py が書いた + assert env.merges == [] and got["metrics"]["selected"][0] == 11 + + +def test_lane_gives_back_with_a_new_request_id_for_the_same_head(env, monkeypatch): + sent = lane_env(env, monkeypatch) + r = gated_both(env) + rid = r["waiting"][0]["request"] + failed = {"result": "failed", "class": None, "reason": "権限が無い", "pr": 501, "head": HEAD, "at": "t"} + env.answer_box[rid] = {"answer": {"result": "approved", "merge": failed}, "defer": None} + env.rank_box["out"] = rank_out(ms("01 近い", [13, 14])) + sl_select.cmd_select(env, "l1") + w = row(env, 11) + assert not w.get("answer") and w["request"] == f"la-g501-{HEAD[:12]}-m2" and sent[-1]["returned"] == "権限が無い" From bdc947428d1b00272e913e1d98088f746e715683 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 04:59:04 +0000 Subject: [PATCH 024/127] =?UTF-8?q?Docs:=20=E6=89=BF=E8=AA=8D=E3=81=AE?= =?UTF-8?q?=E7=AD=94=E3=81=88=E3=81=8C=E8=A8=AD=E8=A8=88=20PR=20=E3=82=92?= =?UTF-8?q?=E3=83=9E=E3=83=BC=E3=82=B8=E3=81=99=E3=82=8B=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=81=A8=E3=80=81=E7=AD=94=E3=81=88=E3=81=AE=E5=BE=8C=E3=81=AE?= =?UTF-8?q?=E6=95=B4=E7=90=86=E3=82=92=E6=89=8B=E9=A0=86=E6=9B=B8=E3=81=A8?= =?UTF-8?q?=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB=E6=9B=B8=E3=81=8F?= =?UTF-8?q?=EF=BC=88#2507=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit loop.md に承認した設計のマージと周回の境が結果ごとに打つ手を、parallel.md に答えの後の整理 (drop の除外・MVV 判定で自分で答える依頼・まとめて問い直す)を書く。確定仕様の I18 を 「設計の承認で設計 PR をマージするほかは作業を持たない」へ書き換える。 Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-parallel-organizer.md | 6 ++-- .../development-workflow/references/loop.md | 33 +++++++++++++++++-- .../references/parallel.md | 25 ++++++++++++-- 3 files changed, 56 insertions(+), 8 deletions(-) diff --git a/docs/specifications/ndf-parallel-organizer.md b/docs/specifications/ndf-parallel-organizer.md index d3d37b282..b4336fddd 100644 --- a/docs/specifications/ndf-parallel-organizer.md +++ b/docs/specifications/ndf-parallel-organizer.md @@ -108,7 +108,7 @@ lroot2 の変更もまとめて出た。そのため lroot2 が出していた | I15 | 範囲に入ったレーンは、依頼していなくても配布の結果を受ける。自分の Pull Request のマージのコミットがすべて `origin/<本番チャネル>` にあれば、依頼を送らずに `released` で進む | 出せた配布ごとに範囲のイベント `range-<連番>` を立てる。`request` は送る前に本番チャネルを照らし、版は `range-<連番>` から読む | | I16 | 本番チャネルへ出すコミットは、すべて `develop` に含まれる | `scripts/check-pr-base.sh`(継続的統合の `pr-base-guard`)が落とす | | I17 | 並列の計画の下で区分つきの問いを出すのはオーガナイザーだけである | レーンと一時レーンの問いは依頼(`kind: gate1` / `gate2` / `ask`)としてオーガナイザーへ届く | -| I18 | オーガナイザーは作業を持たない。一時レーンは同時に 1 本まで | `answer` は答えのメッセージを送るだけで、ラベル・記録・直しは持ち主のレーンが周回の境で行う | +| I18 | オーガナイザーは、設計の承認で設計 PR をマージするほかは作業を持たない。一時レーンは同時に 1 本まで | `answer --approved` は設計の承認の依頼なら、承認した先端のままで必須の検査が緑の設計 PR を待たずにマージしてから答えのメッセージを送り、ほかの答えはメッセージを送るだけである。ラベル・記録・直し・マージし直し・後片付けは持ち主のレーンが周回の境で行う | | I20 | 依頼の `id` は送る側が依頼の中身から決め、受け取った数から採番しない | 打ち直しで同じ `id` になり、冪等の鍵(`:inbox:<依頼>`)が 2 度目を数えない。受け取りの前後で打ち直しても `id` が変わらない | ### 構成要素 @@ -124,6 +124,8 @@ lroot2 の変更もまとめて出た。そのため lroot2 が出していた | `plugins/ndf/scripts/lib/durable.py` | 外のプロセスから記録を開く関数(`open_client`・`send_to`・`event_from`・`status_from`・`output_from`・`wait_from`)と、耐久ワークフローの中の `recv`・`set_event`。DBOS を import するのはこのファイルだけである | | `plugins/ndf/scripts/relay_lib/hub.py`・`hub_client.py` | 役割のファイルの読み書き(`relay.py role`・`spawn --role helper --plan`)と数え方(I13)。`ls --json` の `metrics.hub`・`metrics.tmux` | | `plugins/ndf/scripts/lib/sprint_loop_gate.py` | ループのレーンが設計の承認を「人の判断待ち」へ移すときに、`kind: gate1` の依頼を送る。答えを当てる前に設計 PR の先頭を比べる | +| `plugins/ndf/scripts/lib/design_pr_merge.py` | 承認した設計のマージ(`merge_approved`)。`parallel.py answer --approved`・`loop answer --approved`・周回の境が呼ぶ | +| `plugins/ndf/scripts/lib/parallel_answer.py` | 答えの後に起こす 1 行(`owner_restart`)と、答えの後に残る依頼の一覧と除外の印(`after_items`・`mvv_mark`) | | `plugins/ndf/scripts/hook_lib/ask_guard.py` | 判定が通す問いを、レーンか一時レーンなら `parallel.py request-ask` で送って止める | ```mermaid @@ -250,7 +252,7 @@ stateDiagram-v2 | メッセージ | 話題 `resume` | `confirm`・`settle` | 配布の途中の耐久ワークフロー | `{n, phase: confirm, take}` / `{n, phase: settle, result, version, reason}` | | イベント | `pause` | 耐久ワークフロー | `wait` | `{seq, action, …}`。`action` は `running` / `notice` / `release` / `confirmed` / `held`。`running` では `wait` は返らない。起こす 1 行(`wake`)を添える | | イベント | `questions` | 耐久ワークフロー | `wait` | `{seq, items}`。答えていない問いの依頼の全件 | -| イベント | `answer-<依頼>` | 耐久ワークフロー | `await`・周回の境(`answers_of`) | 配布: `{result, version, release, reason, at}`。問い: `{result, note, choice, stamp, at}` | +| イベント | `answer-<依頼>` | 耐久ワークフロー | `await`・周回の境(`answers_of`) | 配布: `{result, version, release, reason, at}`。問い: `{result, note, choice, stamp, merge, by, at}`(`merge` は設計の承認で試みたマージの結果、`by` は `user` か `mvv`) | | イベント | `defer-<依頼>` | 耐久ワークフロー | `await` | `{seq, reason, at}`。知らせのたびに上書きしてよい | | イベント | `range-<連番>` | 耐久ワークフロー | `request`(`released_version`) | 出せた配布の `{sha, version, prs, lanes}`。差し戻し・失敗の配布の連番は欠ける | | イベント | `lane-<レーン名>` | 耐久ワークフロー | 状態の表示 | `{received}`。依頼の `id` の採番には使わない | diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index 1810dc7e7..5ee17e40b 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -69,11 +69,37 @@ | 配布の依頼(並列の計画のレーン。フェーズ「配布の依頼」のプラン) | 承認ゲート 2 として止まらず、`loop lap` が周回を「配布待ち」で締めて次の周回へ進む(下の「並列の計画のレーン」) | **人の判断待ちの課題には利用者が答える。** `$SS loop answer <名> <課題> --approved [--pr <設計 PR>]` は承認の記録を書き、 -設計 PR がマージされた後の周回で選定の対象に戻す(その課題は設計のステージを置かない)。`--rejected` は差し戻しの記録を書き、 -以後選ばない。答えるまで、その課題は選ばない。 +設計 PR を宛先へマージする(下の「承認した設計のマージ」)。マージが済んだ課題は、次の周回で順位によらず先頭に選ぶ(選定の理由は +「承認した設計」。その課題は設計のステージを置かない)。`--rejected` は差し戻しの記録を書き、以後選ばない。答えるまで、その課題は選ばない。 + +`--approved` は、答えの前に、問いの時点の設計 PR の先頭のコミット(承認した先端。人の判断待ちの行の `stamp`)と今の先端を比べる。 +違うとき、または行に承認した先端が無いときは、答えを当てずに今の先端を記録し、`status: regated` で返る。新しい先端を見てから答え直す。 `--revise --note <指摘ファイル>` は「直して出し直す」で、指摘を課題の本文の節に書き、次の周回で先頭に選ぶ。 +### 承認した設計のマージ + +承認の答え(`loop answer --approved`、並列の計画では `parallel.py answer --approved`)は、設計 PR を待たずに 1 回だけマージしてみる。 +マージするのは、今の先端が承認した先端と同じで、必須の検査がすべて緑のときだけである(検査の完了は待たない)。宛先が本番チャネルに +当たるか決められない設計 PR はマージしない(承認ゲート 2 で人がマージする)。後片付け(`merged-steps.py cleanup`)は周回の境が打つ。 +マージの成否によらず答えは書かれ、終了コードは 0 である。結果は人の判断待ちの行の `merge`(`result` と `reason`)に残る。 + +マージできなかった設計 PR は、周回の境(選定の前)で、前の結果に応じた手を 1 回だけ打つ。失敗はその課題を外す理由 +(`設計 PR がマージ待ち(<理由>)`)にとどめ、ほかの課題で周回を進める。 + +| 前の結果(`merge.result`) | 周回の境で打つ手 | +| --- | --- | +| まだ試みていない・`not_green`(走っている・待っている・未登録・取り残し) | マージし直す。取り残しのジョブは中の `probe --act` が再実行する | +| `not_green`(基盤待ち `infra`) | マージし直す(`--infra-gap 0` で取り消されたジョブを再実行する)。同じ承認した先端で 3 回再実行したら利用者へ戻す | +| `not_green` が同じ分類で 3 周回続いた・`not_green`(再び取り残し `stale_again`)・`failed` | 利用者へ戻す | +| `conflict` | 設計の worktree で `merged-steps.py base-sync --pr <設計 PR> --push` を打つ。先端が進めば新しい先端で問い直し、衝突が残れば `git merge --abort` で閉じて利用者へ戻す | +| `production` | マージし直さない。人が承認ゲート 2 でマージしたら(`state` が `MERGED`)、マージ済みとして扱う | +| `head_moved`・`no_stamp` | 新しい先端で問い直す | + +- 問い直すは、行の承認した先端を新しい先端へ書き換え、答えを外して人の判断待ちへ戻す(並列の計画のレーンは新しい先端で依頼を送り直す) +- 利用者へ戻すは、承認した先端を変えずに答えを外し、マージの結果の理由を課題の本文の節(「利用者へ戻した理由」)と依頼に載せる。 + 利用者は「直して出し直す」「やらない」か、手を打ってから「承認」を選ぶ + **スプリント MVV を持つマイルストーンは、利用者が承認したときだけ選ぶ。** 承認は `$SS loop approve-mvv <名> --milestone <題>` で、説明の Mission / Vision / Value の sha256 を記録する。説明が変われば承認し直す。 @@ -109,7 +135,8 @@ - 保留の周回の課題は、締め直すまで選定から外す(「保留の周回の課題」)。保留の周回は予算の周回の数に入り、同じ失敗には数えない - 周回の境の `loop select` は、答えを古い順に読む。オーガナイザーへ送った設計の承認の答えも読み、承認ならラベル `design-approved` と - 承認の記録、直して出し直すなら指摘、やらないなら差し戻しを、レーンが自分で書く + 承認の記録、直して出し直すなら指摘、やらないなら差し戻しを、レーンが自分で書く。承認の答えに載ったマージの結果(`merge`)は + 人の判断待ちの行へ写す。MVV 判定でオーガナイザーが承認した答え(`by: mvv`)では、承認の記録を `mvv-gate.py` が書いたため書き直さない - 止まったレーンの答えを受けた保留の周回は、オーガナイザーが起こす一時レーンが `$SS loop pending <名>` の `pipeline` で流す ## 止まったとき diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 67201f004..943e17a41 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -40,18 +40,37 @@ 3. `ndf-ask:human-action` の依頼は `attach`(`relay.py attach <レーン>`)の 1 行を添えて「済んだ」「まだ」で問う。 `merged` があれば、同じ原因でまとめた各レーンの元の文面を並べる(答えは全レーンへ届く) 4. 耐久ワークフローが自分で立てる問い(`kind` が `stall`・`helper`・`spawn`)は `options` で問う。選んだ答えの `commands` の 1 行は、答えを送った `answer` が `metrics.next` に返す -5. 答えを送り、`wait` を打ち直す。答えの間に新しい依頼が届いていれば `questions` が続けて返る +5. 答えを送り、下の「答えの後の整理」を通す | 依頼の `kind` | 問いの答え | 送るコマンド | | --- | --- | --- | -| `gate1`(設計の承認) | 承認 / 直して出し直す(指摘を書く) / やらない | `parallel.py answer p1 --approved` / `--revise --note <指摘ファイル>` / `--rejected` | +| `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 <選んだ答え>` | -`answer` は答えのイベントを立てるだけで、ラベル・記録・直しは持ち主のレーンが周回の境で行う。持ち主のレーンが止まっていれば +`gate1` の `--approved` では、`answer` が答えを送る前に設計 PR を宛先へマージしてみる([loop.md](loop.md) の「承認した設計のマージ」)。 +結果は `metrics.merge` に載り、答えにも載ってレーンへ届く。マージできなくても答えは送られ、終了コードは 0 である。`answer` はほかに、 +答えのイベントを立てるだけで、ラベル・記録・直し・マージし直し・後片付けは持ち主のレーンが周回の境で行う。持ち主のレーンが止まっていれば `metrics.next` に `relay.py restart <レーン>` の 1 行が返るので、それを打つ(耐久ワークフローが自分で立てた問いでは、選んだ答えの `commands` の 1 行が返る)。 +### 答えの後の整理 + +出した `AskUserQuestion` は取り下げられず、答えを待つ間に届いた依頼は答えの後まで表に出ない。答えを受けたら、溜まった依頼を +整理してまとめて問い直す。 + +1. 依頼ごとに答えを送る(`parallel.py answer`)。`metrics.merge.result` が `merged`・`already_merged` 以外なら、理由(`reason`)を + 1 行で利用者へ伝える(問いにしない。次の周回の境でレーンが結果に応じた手を打つ)。`production` なら、承認ゲート 2 で人が + マージするまで実装に選ばれないことを添える +2. 最後の `answer` の `metrics.after`(答えの後に残る依頼)から、`drop` が付いた依頼を除く。`settled` は今送った答えと同じ設計 PR の依頼、 + `resend` は同じレーンの同じ設計 PR に、より新しい先端の依頼がある古い依頼である +3. `mvv.eligible` が `true` の依頼(設計の承認で、レーンの判定が「判定できない」「判定を読めない」で、レッドラインが空の依頼)ごとに、 + `mvv-gate.py check --sprint <依頼の sprint_state> --gate design --pr <依頼の pr>` を 1 回だけ打つ。「従う」(終了コード 0)なら + `parallel.py answer p1 --approved --by mvv` を送る(判定の記録は `mvv-gate.py` が書く)。ほかの結果なら、打ち直さずに残す。 + 「従わない」・レッドライン・承認ゲート 2・区分つきの問いは自分で答えない +4. 残りがあれば、上の「問い方」の区分の順と 4 件ずつで、まとめて `AskUserQuestion` を出す。残りが無ければ + `wait p1 --after ` を背景で打ち直す + 承認ゲート 2 は、提示 1 なら「承認」「差し戻す」で問い、`parallel.py confirm p1 <連番>` か `parallel.py settle p1 <連番> --rejected` を打つ。出し直しの提示(`represent: true`)なら加わった範囲を添えて 「新しい範囲で出す」「最初の範囲だけ出す」「差し戻す」で問い、`confirm --take new` / `--take first` / `settle --rejected` を打つ。 From 151d694fac1bc955ef0c067aa40487c1c4586c74 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 05:07:00 +0000 Subject: [PATCH 025/127] =?UTF-8?q?Fix:=20=E6=A7=8B=E9=80=A0=E3=81=A8?= =?UTF-8?q?=E5=A5=91=E7=B4=84=E3=81=AE=E6=A4=9C=E6=9F=BB=E3=82=92=E9=80=9A?= =?UTF-8?q?=E3=81=99=EF=BC=88=E8=A3=9C=E5=8A=A9=E9=96=A2=E6=95=B0=E3=81=AE?= =?UTF-8?q?=E6=94=B9=E5=90=8D=E3=83=BB=E8=B5=B7=E5=8B=95=E3=81=AE=E6=9C=AC?= =?UTF-8?q?=E4=BD=93=E3=82=92=201=20=E3=81=A4=E3=81=AB=E3=83=BBparallel.py?= =?UTF-8?q?=20=E3=82=92=20500=20=E8=A1=8C=E3=81=AB=E3=83=BBanswer=20?= =?UTF-8?q?=E3=81=AE=20--by=20=E3=82=92=E5=A5=91=E7=B4=84=E3=81=B8?= =?UTF-8?q?=EF=BC=89=EF=BC=88#2507=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../plugins__ndf__scripts__parallel.py.json | 1 + plugins/ndf/scripts/lib/design_pr_merge.py | 47 ++++++++++--------- plugins/ndf/scripts/lib/parallel_answer.py | 35 ++++++++++++++ plugins/ndf/scripts/lib/sprint_loop_gate.py | 10 ++-- plugins/ndf/scripts/lib/sprint_loop_record.py | 6 +-- plugins/ndf/scripts/parallel.py | 26 +++------- ...__scripts__lib__sprint_loop.py--lines.json | 2 +- ...ipts__lib__sprint_loop_gate.py--lines.json | 2 +- 8 files changed, 75 insertions(+), 54 deletions(-) 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 5458e1dd6..e77b70365 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -9,6 +9,7 @@ "answer": { "options": [ "--approved", + "--by", "--choice", "--note", "--rejected", diff --git a/plugins/ndf/scripts/lib/design_pr_merge.py b/plugins/ndf/scripts/lib/design_pr_merge.py index 5ec7db715..b751a33e2 100644 --- a/plugins/ndf/scripts/lib/design_pr_merge.py +++ b/plugins/ndf/scripts/lib/design_pr_merge.py @@ -31,7 +31,8 @@ Run = Callable[..., tuple] -def _run(cmd: list[str], cwd: str | None = None, stdin: str | None = None) -> tuple[int, str, str]: +def run_cmd(cmd: list[str], cwd: str | None = None, stdin: str | None = None) -> tuple[int, str, str]: + """コマンドを起動して (終了コード, 標準出力, 標準エラー) を返す。周回のコマンド(`sprint_loop_record`)もこれを使う。""" p = subprocess.run(cmd, cwd=cwd, input=stdin, capture_output=True, text=True) return p.returncode, p.stdout, p.stderr @@ -47,7 +48,7 @@ def _last_json(text: str) -> dict: return {} -def _out(result: str, pr: int, head: str = "", reason: str = "", cls: str | None = None, **extra) -> dict: +def _res(result: str, pr: int, head: str = "", reason: str = "", cls: str | None = None, **extra) -> dict: return {"result": result, "class": cls, "reason": reason, "pr": pr, "head": head, "at": clock.now_iso("utc"), **extra} @@ -55,7 +56,7 @@ def _check_names(rollup: list) -> set[str]: return {str(c.get("name") or c.get("context") or "") for c in rollup or [] if isinstance(c, dict)} - {""} -def _registered(root: str, base: str, stamp: str, rollup: list, run: Run) -> str | None: +def _checks_registered(root: str, base: str, stamp: str, rollup: list, run: Run) -> str | None: """必須の検査が検査の一覧に載っているか(検査が無いなら、無いこと)を確かめる。確かめられなければ理由を返す(手順 3)。""" code, so, se = run(["gh", "api", f"repos/{{owner}}/{{repo}}/rules/branches/{base}"], cwd=root) try: @@ -84,43 +85,43 @@ def merge_approved(root, pr: int, stamp: str | None, run: Run | None = None) -> 出力: {result, class, reason, pr, head, at, branch, infra_rerun}。`infra_rerun` は基盤待ちのジョブの再実行を頼んだか (`merge-when-green --infra-gap 0` を呼んだか)。周回の境が同じ承認した先端での累計に足す。""" - run = run or _run + run = run or run_cmd root = str(root) try: - return _merge(root, int(pr), (stamp or "").strip(), run) + return _try_merge(root, int(pr), (stamp or "").strip(), run) except Exception as e: # noqa: BLE001 — 例外を投げない(I4)。読めない出力も failed に写す - return _out("failed", int(pr), reason=f"マージを試せない: {e}"[:300]) + return _res("failed", int(pr), reason=f"マージを試せない: {e}"[:300]) -def _merge(root: str, pr: int, stamp: str, run: Run) -> dict: +def _try_merge(root: str, pr: int, stamp: str, run: Run) -> dict: code, so, se = run(["gh", "pr", "view", str(pr), "--json", "state,headRefOid,baseRefName,headRefName,statusCheckRollup"], cwd=root) try: info = json.loads(so) if code == 0 else None except ValueError: info = None if not isinstance(info, dict) or not info.get("state"): - return _out("failed", pr, reason=f"設計 PR を読めない: {(se or so).strip()[:200]}") + return _res("failed", pr, reason=f"設計 PR を読めない: {(se or so).strip()[:200]}") head, base, branch = info.get("headRefOid") or "", info.get("baseRefName") or "", info.get("headRefName") or "" ctx = {"branch": branch, "infra_rerun": False} if info["state"] == "MERGED": - return _out("already_merged", pr, head, **ctx) + return _res("already_merged", pr, head, **ctx) if info["state"] != "OPEN": - return _out("failed", pr, head, f"設計 PR が開いていない({info['state']})", **ctx) + return _res("failed", pr, head, f"設計 PR が開いていない({info['state']})", **ctx) if not stamp.strip("0"): - return _out("no_stamp", pr, head, "承認した先端が記録に無い", **ctx) + return _res("no_stamp", pr, head, "承認した先端が記録に無い", **ctx) if head != stamp: - return _out("head_moved", pr, head, f"承認の後に設計 PR の先端が動いた({stamp[:12]} → {head[:12]})", **ctx) + return _res("head_moved", pr, head, f"承認の後に設計 PR の先端が動いた({stamp[:12]} → {head[:12]})", **ctx) verdict = delivery.judge_target(delivery.load_delivery(root), base) if verdict.stops: - return _out("production", pr, head, f"設計 PR の宛先 {base} が本番チャネルに当たるか決められない({verdict.reason})", **ctx) - if why := _registered(root, base, stamp, info.get("statusCheckRollup") or [], run): - return _out("not_green", pr, head, why, UNREGISTERED, **ctx) + return _res("production", pr, head, f"設計 PR の宛先 {base} が本番チャネルに当たるか決められない({verdict.reason})", **ctx) + if why := _checks_registered(root, base, stamp, info.get("statusCheckRollup") or [], run): + return _res("not_green", pr, head, why, UNREGISTERED, **ctx) code, so, se = run([sys.executable, str(MERGED_STEPS), "probe", "--pr", str(pr), "--act"], cwd=root) cls = (_last_json(so).get("metrics") or {}).get("class") if code != 0 or not cls: - return _out("failed", pr, head, f"検査を分類できない(probe {code}): {(se or so).strip()[:200]}", **ctx) + return _res("failed", pr, head, f"検査を分類できない(probe {code}): {(se or so).strip()[:200]}", **ctx) if cls not in ("passed", "infra"): - return _out("not_green", pr, head, f"検査が緑でない({cls})", cls, **ctx) + return _res("not_green", pr, head, f"検査が緑でない({cls})", cls, **ctx) argv = [sys.executable, str(MERGED_STEPS), "merge-when-green", str(pr), "--expect-head", stamp, "--no-cleanup", *WAIT_ARGS] if cls == "infra": argv += ["--infra-gap", "0"] @@ -134,13 +135,13 @@ def _from_merge(pr: int, head: str, cls: str, code: int, got: dict, se: str, ctx summary = str(got.get("summary") or se or "").strip()[:200] items = got.get("items") or [] if code == 0: - return _out("merged", pr, head, cls=cls, **ctx) + return _res("merged", pr, head, cls=cls, **ctx) if any(isinstance(i, dict) and i.get("result") == "head_moved" for i in items): - return _out("head_moved", pr, head, f"マージの直前に先端が動いた: {summary}", cls, **ctx) + return _res("head_moved", pr, head, f"マージの直前に先端が動いた: {summary}", cls, **ctx) if code == 4: - return _out("conflict", pr, head, f"宛先と衝突している: {summary}", cls, **ctx) + return _res("conflict", pr, head, f"宛先と衝突している: {summary}", cls, **ctx) if code == 10: - return _out("production", pr, head, f"宛先が本番チャネルに当たる(承認ゲート 2 で止まった): {summary}", cls, **ctx) + return _res("production", pr, head, f"宛先が本番チャネルに当たる(承認ゲート 2 で止まった): {summary}", cls, **ctx) if code == 75 or (code == 1 and not any(isinstance(i, dict) and i.get("result") == "stopped" for i in items)): - return _out("not_green", pr, head, f"検査が待ちの上限までに緑にならない({code}): {summary}", cls, **ctx) - return _out("failed", pr, head, f"マージできない({code}): {summary}", cls, **ctx) + return _res("not_green", pr, head, f"検査が待ちの上限までに緑にならない({code}): {summary}", cls, **ctx) + return _res("failed", pr, head, f"マージできない({code}): {summary}", cls, **ctx) diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py index 18d7c9d6e..7aea6eb36 100644 --- a/plugins/ndf/scripts/lib/parallel_answer.py +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -73,3 +73,38 @@ def owner_restart(main: Path, name: str, rid: str, choice: list[str] | None = No lanes = [lane for lane, each in owners if pl.lane_waits(main, name, lane, str(each or rid), ls)] lanes = list(dict.fromkeys(lanes)) return "; ".join(f"python3 {pr_run.RELAY} restart {lane}" for lane in lanes) or None + + +def answer_arg_error(revise: bool, note: str | None, approved: bool, by: str) -> str | None: + """`parallel.py answer` の引数の組み合わせの誤り。誤りが無ければ None。""" + if revise and not note: + return "--revise には --note <指摘ファイル> が要る" + if by == "mvv" and not approved: + return "--by mvv は --approved とだけ組む" + return None + + +def merge_design(main: Path, q: dict) -> dict | None: + """設計の承認(gate1)の問いなら、承認した設計 PR をマージしてみる(#2507)。それ以外の問いは None。""" + if q.get("kind") != "gate1" or not q.get("pr"): + return None + import design_pr_merge + + return design_pr_merge.merge_approved(main, int(q["pr"]), q.get("stamp")) + + +def answer_with_merge(main: Path, name: str, q: dict, result: str, **kw) -> tuple[str, dict]: + """依頼 `q` へ答えを送る。設計の承認なら、承認した設計 PR をマージしてから送る(成否によらず送る。#2507)。 + 返すのは `merge_note` の (要約へ足す 1 文, 出力へ足す `merge`)。""" + merge = merge_design(main, q) if result == "approved" else None + pl.send_answer(main, name, q["id"], result, merge=merge, **kw) + return merge_note(merge) + + +def merge_note(merge: dict | None) -> tuple[str, dict]: + """答えの要約へ足すマージの結果の 1 文と、出力へ足す `merge`。マージを試していなければ ("", {})。""" + if not merge: + return "", {} + if merge["result"] in ("merged", "already_merged"): + return "。設計 PR をマージした", {"merge": merge} + return f"。設計 PR をマージできない({merge['result']}: {merge['reason']})", {"merge": merge} diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index c95842bb4..777fd8271 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -433,7 +433,7 @@ def note_merge(w: dict, out: dict, lap: int, now: str) -> dict: return w["merge"] -def _reopen(w: dict) -> None: +def _reopen_waiting(w: dict) -> None: for k in ("answer", "answered_at", "merge", "merged_at"): w.pop(k, None) @@ -449,10 +449,10 @@ def reask_design(env: Env, rec: dict, w: dict, head: str) -> bool: sent = lane_gate1(env, w, w.get("report") or "", w.get("state") or "") if not sent: return False - _reopen(w) + _reopen_waiting(w) w.update(sent, restamped=old) else: - _reopen(w) + _reopen_waiting(w) w.update(stamp=head, restamped=old) mark_issue(env, rec, w) return True @@ -468,10 +468,10 @@ def giveback_design(env: Env, rec: dict, w: dict, reason: str, lap: int) -> bool if not sent: w.pop("returned", None) return False - _reopen(w) + _reopen_waiting(w) w.update(sent) else: - _reopen(w) + _reopen_waiting(w) mark_issue(env, rec, w) return True diff --git a/plugins/ndf/scripts/lib/sprint_loop_record.py b/plugins/ndf/scripts/lib/sprint_loop_record.py index 6cf3b2482..14a913692 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_record.py +++ b/plugins/ndf/scripts/lib/sprint_loop_record.py @@ -22,7 +22,6 @@ import json import os import re -import subprocess import sys import tempfile from contextlib import contextmanager @@ -31,6 +30,7 @@ from typing import Callable import clock +import design_pr_merge import handoff_doc import jsonio import locks @@ -83,9 +83,7 @@ class LoopError(Exception): # ---------------------------------------------------------------- 外の世界 -def _run_loop_cmd(cmd: list[str], cwd: str | None = None, stdin: str | None = None) -> tuple[int, str, str]: - p = subprocess.run(cmd, cwd=cwd, input=stdin, capture_output=True, text=True) - return p.returncode, p.stdout, p.stderr +_run_loop_cmd = design_pr_merge.run_cmd # 起動の本体は 1 つ(#2507) def _upkeep_rank(root: Path) -> tuple[int, dict]: diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index d6cd1725f..424ee3f57 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -389,34 +389,20 @@ def cmd_answer(a) -> None: if pl.run_status(main, a.name) not in pl.RUNNING: reply("stopped", f"並列の計画 {a.name} が動いていない", code=1) note = None + if err := pa.answer_arg_error(a.revise, a.note, a.approved, a.by): + reply("stopped", err, code=1) if a.revise: - if not a.note: - reply("stopped", "--revise には --note <指摘ファイル> が要る", code=1) try: note = Path(a.note).read_text(encoding="utf-8").strip() except OSError as exc: reply("stopped", f"指摘ファイルを読めない: {exc}", code=2) - if a.by == "mvv" and not a.approved: - reply("stopped", "--by mvv は --approved とだけ組む", code=1) result = "approved" if a.approved else "revise" if a.revise else "rejected" if a.rejected else "answered" seq, items = pa.questions(main, a.name) - q = next((q for q in items if q.get("id") == a.id), {}) + 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) - merge = None - if a.approved and q.get("kind") == "gate1" and q.get("pr"): # 承認した設計のマージ(#2507)。成否によらず答えを送り 0 で返る - import design_pr_merge - - merge = design_pr_merge.merge_approved(main, int(q["pr"]), q.get("stamp")) - pl.send_answer(main, a.name, a.id, result, note=note, choice=a.choice, merge=merge, by=a.by) - summary = f"依頼 {a.id} へ答え({result})を送った" - if merge: - summary += ( - "。設計 PR をマージした" - if merge["result"] in ("merged", "already_merged") - else f"。設計 PR をマージできない({merge['result']}: {merge['reason']})" - ) - extra = {"merge": merge} if merge is not None else {} - reply("ok", summary, id=a.id, result=result, next=nxt, seq=seq, after=pa.after_items(items, q or {"id": a.id}), **extra) + tail, extra = pa.answer_with_merge(main, a.name, q, result, note=note, choice=a.choice, by=a.by) + extra.update(id=a.id, result=result, next=nxt, seq=seq, after=pa.after_items(items, q)) + reply("ok", f"依頼 {a.id} へ答え({result})を送った{tail}", **extra) def cmd_helper(a) -> None: diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json index bcb3a8a78..a4c9a4bdc 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/sprint_loop.py", "name": "", "kind": "lines", "reason": "492 行だったところへ #2436(レーンの配布待ち・質問待ち・保留の周回の CLI)で増えて 587 行。500 行以下にしたら消す", "lines": 587} +{"path": "plugins/ndf/scripts/lib/sprint_loop.py", "name": "", "kind": "lines", "reason": "492 行だったところへ #2436(レーンの配布待ち・質問待ち・保留の周回の CLI)で増えて 587 行、#2507(承認した設計のマージの記録と周回の境のマージし直しの CLI)で 623 行。500 行以下にしたら消す", "lines": 623} diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json index 9948b0fd2..52a9965aa 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 585 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 585} +{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 585 行、#2507(人の判断待ちの行へマージの結果を書く・答えの後の整理)で 654 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 654} From 6bc2ce852e91611779a5ab7904868f9b76db4550 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 05:23:19 +0000 Subject: [PATCH 026/127] =?UTF-8?q?Docs:=20=E6=89=BF=E8=AA=8D=E3=81=97?= =?UTF-8?q?=E3=81=9F=E8=A8=AD=E8=A8=88=E3=81=AE=E3=83=9E=E3=83=BC=E3=82=B8?= =?UTF-8?q?=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=80=81=E9=96=8B=E7=99=BA?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=81=AE=E7=A2=BA=E5=AE=9A=E4=BB=95?= =?UTF-8?q?=E6=A7=98=E3=83=BB=E3=82=AA=E3=83=BC=E3=82=AC=E3=83=8A=E3=82=A4?= =?UTF-8?q?=E3=82=B6=E3=83=BC=E3=81=AE=E5=BD=B9=E5=89=B2=E3=83=BB=E5=88=A4?= =?UTF-8?q?=E5=AE=9A=E3=81=A7=E3=81=8D=E3=81=AA=E3=81=84=E3=81=AE=E6=95=B0?= =?UTF-8?q?=E3=81=88=E6=96=B9=E3=83=BB=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=206=20=E3=81=AE=E5=86=99=E3=81=97=E3=82=92?= =?UTF-8?q?=E7=9B=B4=E3=81=99=EF=BC=88#2507=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 開発ループの確定仕様: loop answer --approved の先端の照合と regated、承認した設計のマージ、周回の境のマージし直しと後片付け、 選定の先頭に選ぶ課題と外す理由、人の判断待ちの行の項目を、変更後の振る舞いに合わせる - オーガナイザーは設計の承認の答えで設計 PR をマージするほかは作業を持たない、と書き直す(手順書と確定仕様) - 改訂の兆候の「判定できない」を、スプリント状態・承認ゲート・PR の組ごとに 1 件と数えると書く - 課題の本文の受け入れ条件 6 を設計の決定 9 の形に直し、写しを作り直す Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-parallel-organizer.md | 2 +- .../ndf-workflow-development-loop.md | 29 ++++++++++++++----- issues/issue-2507-requirements.md | 4 ++- .../references/parallel.md | 2 +- .../references/project-mvv.md | 2 +- 5 files changed, 27 insertions(+), 12 deletions(-) diff --git a/docs/specifications/ndf-parallel-organizer.md b/docs/specifications/ndf-parallel-organizer.md index b4336fddd..f181f385a 100644 --- a/docs/specifications/ndf-parallel-organizer.md +++ b/docs/specifications/ndf-parallel-organizer.md @@ -81,7 +81,7 @@ lroot2 の変更もまとめて出た。そのため lroot2 が出していた | 承認ゲート 2 は提示のプランが持ち、本番のプランは確定の後にだけ流す | 本番のプランは流し始めるときに worktree と `release/v<版>` を作る。答えの前に作ると、提示 2 の本番のプランが同じ名前のブランチを作り直せない | | 本番チャネルへの Pull Request の head を、承認したコミットから切った `release/v<版>` にする | `develop` を止めずに、承認したコミットと版上げだけを `main` へ出せる。`main` へ出るコミットはすべて `develop` に含まれる。並列の計画の外の配布にも同じ規則が効く | | 並列の計画の下では、区分つきの問いはオーガナイザーだけが出す。レーンの問いは問いの hook が送る | レーンは問いで止まらず別の作業へ進める。conductor の手順だけで送ると、手順に書いていない場所の問いが漏れる。送れなければ今どおり通すので、DBOS が読めないときも今の振る舞いに戻る | -| オーガナイザーは作業を持たない。答えの後の処理は持ち主のレーンが、持ち主のいない共有の作業は一時レーン(同時に 1 本)が行う | オーガナイザーに作業を積むと、その間は問いと配布の取りまとめが止まる。空いているレーンへ移す形は、空きの判定と他のレーンの周回への割り込みが要る | +| オーガナイザーは、設計の承認の答えで設計 PR をマージするほかは作業を持たない。答えの後の処理は持ち主のレーンが、持ち主のいない共有の作業は一時レーン(同時に 1 本)が行う | オーガナイザーに作業を積むと、その間は問いと配布の取りまとめが止まる。空いているレーンへ移す形は、空きの判定と他のレーンの周回への割り込みが要る | | 計画名での停止は `relay.py` ではなく `parallel.py stop` に置く | 統括役はリポジトリをまたぐ層で、並列の計画を知らない。統括役が知る語を役割の印だけに留める | | レーンの数の上限は `NDF_RELAY_MAX_NAMED` から読み、依存のあるレーンも数える | 統括役が実際に断る値と同じにする。同時に動く本数を依存から求めると、起動の前に確かめられない | | プランの本数の合計の上限は `NDF_PARALLEL_MAX_PLANS`(既定 6) | 2026-09-25 の実測(8 コア・30 GB で 6 本)。マシンごとに違うため環境変数で変える(2026-11-03 の承認ゲート 1 で利用者が決めた) | diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 1de7b05e5..22bd9b3bc 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -174,11 +174,12 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 2. 説明にスプリント MVV(`## Mission` / `## Vision` / `## Value`)があり、その sha256 が `sprint_mvv_approvals` に無い マイルストーンは、候補をすべて「スプリント MVV が未承認」で外す 3. 候補ごとに外す理由を順に見る: 「人の判断待ち(棚卸し)」(`waiting_decision`)→「人の判断待ち」(答えの無い行)→ - 「差し戻し」(`rejected`)→「設計 PR がマージ待ち」(`approved` で `merged_at` が無く、`gh pr view` が `MERGED` でない。 - `MERGED` なら `merged_at` を足して以後打たない)→ この周回の `excluded` の理由 → + 「差し戻し」(`rejected`)→「設計 PR がマージ待ち(<マージの結果の理由>)」(`approved` で `merged_at` が無く、`gh pr view` が `MERGED` でない。 + `MERGED` なら `merged_at` を足して以後打たない。宛先が本番の設計 PR は「設計 PR の宛先が本番(承認ゲート 2 で人がマージする)」)→ この周回の `excluded` の理由 → 「周回の中で起票された」(周回の `created`)→「容量(尺度を超える)」 -4. 外れなかった候補に `max_issues` 件の枠を、品質の枠の課題から先に、残りを順位の順に割り当てる。選んだ行の理由は - 「品質の枠」か「順位」、枠からあふれた行は「容量」で外す +4. 外れなかった候補に `max_issues` 件の枠を、先頭に選ぶ課題(直して出し直す課題・配布の直し・承認とマージが済み、その後の周回でまだ + 選ばれていない設計の課題)、品質の枠の課題、順位の順に割り当てる。選んだ行の理由は「承認した設計」「直しの先頭」「品質の枠」「順位」の + どれか、枠からあふれた行は「容量」で外す 5. 選べたら、そのマイルストーンの候補でない行を「順位(切り出しの境界の外)」で外した行として足す。外した行も `selection` に載せ、`rank` の `metrics.digest` を `rank_digest` に書く。スプリント MVV があれば周回のディレクトリの `mvv.md` に写し、`loop sprint` が `sprint-state.py init --mvv` へ渡して承認ゲート `MVV` の記録を状態へ写す @@ -219,7 +220,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | 止まった所 | 扱い | | --- | --- | | どこでも、プロジェクト MVV が開始時と違う・未承認 | `mvv_changed` で止まる | -| 設計のプラン(承認ゲート 1)。判定が「従う」以外・`boundary` が空でない・機械のチェック・判定を読めない | 計画の課題を `waiting` へ移し(`{number, reason, verdict, basis, gate: 1, pr, lap, at}`。`pr` は報告の設計 PR、判定は `mvv-gate.jsonl` のその PR の最後の行)、課題の本文の節 `## 人の判断待ち(開発ループ <名>)` を書き直す。設計が完了も関門もしなかった課題は続きから外す。残った課題(`issues_kept`)で、関門 1 の後のステージから外した課題だけの計画を除き、共通の計画の `課題` と `closes` からも外し、まとめを組み直して最後の `--then` につなぐ | +| 設計のプラン(承認ゲート 1)。判定が「従う」以外・`boundary` が空でない・機械のチェック・判定を読めない | 計画の課題を `waiting` へ移し(`{number, reason, verdict, basis, boundary, gate: 1, pr, lap, at, report, state, stamp}`。`pr` は報告の設計 PR、判定は `mvv-gate.jsonl` のその PR の最後の行、`stamp` は問いの時点の設計 PR の先頭のコミット(承認した先端))、課題の本文の節 `## 人の判断待ち(開発ループ <名>)` を書き直す。設計が完了も関門もしなかった課題は続きから外す。残った課題(`issues_kept`)で、関門 1 の後のステージから外した課題だけの計画を除き、共通の計画の `課題` と `closes` からも外し、まとめを組み直して最後の `--then` につなぐ | | 同上で残りが無い | 周回を `人へ戻した`(設計が完了しなかった課題があれば `失敗`)で締め、境の判定へ進む | | 同上で続きを組めない | `refused`(`step: 続きの queue`)で止まる | | 承認ゲート 1 以外(承認ゲート 2) | 判定の写しを周回へ足し、止まった計画を `gate2_plan` に控え、`gate2_returned`(`values: {pr, verdict, reasons}`)で止まる。本番は出さない | @@ -228,8 +229,13 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 承認ゲート 2 の答えは、止まった時刻より後の、周回の状態の `関門 2` の利用者の記録(`by` が `mvv` でない)と `gate-rejections.jsonl` の差し戻しのうち最新のもので決める。 -`loop answer --approved` は `sprint-state.py gate <その周回の状態> 関門 1 --by user --outcome approved [--pr N]` を内で打つ。 -`--rejected` は `--outcome rejected` で差し戻しの記録を残す。どちらも課題の本文の節を書き直す。 +`loop answer --approved` は、まず行の承認した先端(`stamp`)と設計 PR の今の先端を比べる。違う・行に承認した先端が無いときは、答えを当てずに +`stamp` を今の先端へ書き換え、`status: regated` で返す(新しい先端を見てから答え直す)。同じなら +`sprint-state.py gate <その周回の状態> 関門 1 --by user --outcome approved [--pr N]` を内で打ち、続けて承認した設計のマージ +(`design_pr_merge.merge_approved`。承認した先端のままで必須の検査が緑の設計 PR を、検査の完了を待たずに `--no-cleanup` で宛先へマージする。 +宛先が本番チャネルに当たるか決められない設計 PR はマージしない)を 1 回試み、結果を行の `merge` に、マージできたら `merged_at` を書く。 +マージの成否によらず終了コードは 0 で、`summary` に結果を添える。`--rejected` は `--outcome rejected` で差し戻しの記録を残し、マージを呼ばない。 +どちらも課題の本文の節を書き直す。 ### 止まる条件と再開 @@ -247,6 +253,11 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 やらないなら差し戻し)→ `配布待ち` の `rejected`(`人へ戻した` で締め、`fixes` に課題と理由を足し、次の周回で先頭に選ぶ)・`failed` (`失敗` で締め、鍵は `{issues, kind: 配布, step: 配布の失敗}`)→ 最も古い `released` の `配布待ち` か答えの届いた `質問待ち` (`status: finish` と残りの `pipeline` を返し、新しい周回を始めない)。次の配布へ回した知らせ(`defer-<依頼>`)だけなら `配布待ち` のまま。 +続けて、承認済みで未マージの設計 PR ごとに、前のマージの結果に応じた手を 1 回だけ打つ(未試行・検査が緑でない・取り残し・基盤待ちは承認した設計のマージを呼び直す。 +先端が動いた・承認した先端が無いは新しい先端で問い直す。衝突は設計の worktree で `merged-steps.py base-sync --pr --push` を打ち、 +先端が進めば問い直す。権限の不足など時間で解けない結果・同じ分類の未緑が 3 周回続いた・基盤待ちの再実行が同じ承認した先端で 3 回に達した +ときは、承認した先端のまま理由を添えて利用者へ問い直す。宛先が本番の設計 PR はマージせず、`gh pr view` の状態だけを読む)。 +マージ済みになった設計 PR には `merged-steps.py cleanup ` を打つ。この手は保留の周回が無いときも選定の前に打つ(`settle_approved_designs`)。 その後、答えの無い保留の周回が `release_pending_max` 以上あれば `waiting_only`、3・4 に当たって答えの無い保留の周回があれば 先に `waiting_only` で止まる。選べる課題が無く、保留の周回かオーガナイザーへ送った答えの無い設計の承認があれば、`no_issue` / `outside_mvv` と見直しの代わりに `waiting_only` で止まる(`values: {why, pending, waiting}`)。 @@ -297,7 +308,9 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | `same_failure` / `max_issues` / `release_pending_max` / `milestones` | 開始時に設定と引数から写した値 | | `status` / `stop` / `stops` | `running` / `stopped`・今の止まった理由・過去の止まった理由(追記だけ) | | `laps[]` | 周回(追記だけ。I1) | -| `waiting[]` | 人の判断待ち。答えは行を消さずに `answer`(`approved` / `rejected` / `revise`)・`answered_at` を足す。`approved` の行はマージを確かめた時刻 `merged_at` を足す。並列の計画のレーンは、オーガナイザーへ送った設計の承認の依頼の `request` と計画名 `plan` を持つ。`revise` の行は指摘 `note` と回数 `revise_count` を持つ | +| `waiting[]` | 人の判断待ち。答えは行を消さずに `answer`(`approved` / `rejected` / `revise`)・`answered_at` を足す。`approved` の行はマージを確かめた時刻 `merged_at`、だれが承認したか `by`(`user` / `mvv`)、最後の承認した設計のマージの結果 `merge` +(`{result, class, reason, pr, head, at, branch, infra_rerun, lap, streak, infra_reruns, stamp}`)、後片付けの時刻 `cleaned` を足す。 +利用者へ問い直した行は理由 `returned` を持つ。並列の計画のレーンは、オーガナイザーへ送った設計の承認の依頼の `request` と計画名 `plan` を持つ。`revise` の行は指摘 `note` と回数 `revise_count` を持つ | | `fixes[]` | 配布を差し戻された周回の直し `{lap, issues, reason, at}`。直しの周回で課題を選ぶと `fixed_by` に周回の番号を書く | | `excluded[]` | `{number, reason, lap}`(`loop exclude`) | | `sprint_mvv_approvals[]` | `{milestone, sha256, at, by}` | diff --git a/issues/issue-2507-requirements.md b/issues/issue-2507-requirements.md index 54d9d2681..74b0f41fc 100644 --- a/issues/issue-2507-requirements.md +++ b/issues/issue-2507-requirements.md @@ -148,7 +148,8 @@ - [ ] 3. 1 本の開発ループで `loop answer <名> <課題> --approved --pr <設計 PR>` を打つと、1・2 と同じ条件で設計 PR が `develop` へマージされる - [ ] 4. マージできないとき(衝突・必須の検査が緑でない・権限が無い)、`answer` は終了コード 0 で返り、答えは送られ、理由がループの記録の人の判断待ちの行に残る。`answer` は検査の完了を待たない - [ ] 5. 承認とマージが済んだ課題は、次の周回の選定で、順位によらず先頭に選ばれる(選定の行に外す理由「設計 PR がマージ待ち」が出ない) -- [ ] 6. 4 で未マージのまま残った承認済みの設計 PR は、次の周回の選定の前にマージし直される。またマージできなければ、その課題だけを外して他の課題で周回が進み(周回は止まらない)、その次の周回でもう一度マージし直される +- ~~6. 4 で未マージのまま残った承認済みの設計 PR は、次の周回の選定の前にマージし直される。またマージできなければ、その課題だけを外して他の課題で周回が進み(周回は止まらない)、その次の周回でもう一度マージし直される~~ → 時間で解けない結果は繰り返さず利用者へ戻す形へ変更(2026-11-05、承認ゲート 1 で承認された設計 issues/issue-2507-design.md の決定 9) +- [ ] 6. 4 で未マージのまま残った承認済みの設計 PR は、次の周回の選定の前に、前のマージの結果に応じた手が 1 回打たれる(検査が緑でない・取り残し・基盤待ちはマージし直す。先端が動いたら新しい先端で問い直す。衝突はベースブランチを取り込んで問い直す)。マージできなければ、その課題だけを外して他の課題で周回が進む(周回は止まらない)。権限の不足など時間で解けない結果・同じ分類の未緑が 3 周回続いた・基盤待ちの再実行が同じ承認した先端で 3 回に達したときは、繰り返さずに理由を添えて利用者へ問い直す。宛先が本番の設計 PR はマージし直さず、承認ゲート 2 で人がマージしたかだけを読む - [ ] 7. 承認済みの設計 PR がある状態で、ループが他の課題の設計だけで周回を使うのは、その設計 PR がマージできなかった周回に限られる(テストで、承認済み・マージ可能の設計 PR があるときの次の周回の選定に、その課題が含まれることを確かめる) 答えの後の依頼の整理: @@ -160,6 +161,7 @@ - [ ] 10. `--revise` と `--rejected` の答えでは設計 PR をマージしない - [ ] 11. 承認ゲート 2 の答え(`confirm` / `settle`)の振る舞いは変わらない + ## 非機能の条件 diff --git a/plugins/ndf/skills/development-workflow/references/parallel.md b/plugins/ndf/skills/development-workflow/references/parallel.md index 943e17a41..470b4279a 100644 --- a/plugins/ndf/skills/development-workflow/references/parallel.md +++ b/plugins/ndf/skills/development-workflow/references/parallel.md @@ -7,7 +7,7 @@ /goal /ndf:development-workflow --parallel p1 -- 終わり: [ndf-parallel] finished p1 の行を出したとき ``` -このセッションの conductor が**オーガナイザー**になる。オーガナイザーは作業を持たない。レーンの起動・観測・配布のまとめは +このセッションの conductor が**オーガナイザー**になる。オーガナイザーは、設計の承認の答えで設計 PR をマージするほかは作業を持たない。レーンの起動・観測・配布のまとめは 耐久ワークフロー(`parallel.py run`)が、配布のプランの実行は一時レーンが、答えの後の処理は持ち主のレーンが行う。 オーガナイザーが打つのは、問いと答え・一時レーンの起動・終わったレーンを起こす 1 行だけである。再開のコマンドも同じ 1 行である。 diff --git a/plugins/ndf/skills/development-workflow/references/project-mvv.md b/plugins/ndf/skills/development-workflow/references/project-mvv.md index 30f3a35c2..43cfa03ad 100644 --- a/plugins/ndf/skills/development-workflow/references/project-mvv.md +++ b/plugins/ndf/skills/development-workflow/references/project-mvv.md @@ -77,7 +77,7 @@ README・指示書(`AGENTS.md` / `CLAUDE.md` など)・依頼文(`--reques | 契機 | 出どころ | | --- | --- | | 振り返りの問い「MVV を見直すか」 | `retrospective` が `project-mvv.py signals` の集計を示して問う | -| 改訂の兆候が閾値を超えた | `mvv-gate.py check` の結果の `items` の `kind: revise`(同じ版のもとで「判定できない」が `settings.unknown_streak` 回続いた、または `signals` の閾値を超えた) | +| 改訂の兆候が閾値を超えた | `mvv-gate.py check` の結果の `items` の `kind: revise`(同じ版のもとで「判定できない」が `settings.unknown_streak` 件続いた。スプリント状態・承認ゲート・PR の組ごとに 1 件と数え、同じ依頼の判定の打ち直しは 1 件にまとめる。または `signals` の閾値を超えた) | | 利用者の指示 | — | | 順 | 打つもの・すること | From 00115d159eae4ec12ddad12c22b5697534103b78 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 05:38:11 +0000 Subject: [PATCH 027/127] =?UTF-8?q?docs:=20=E6=A4=9C=E6=9F=BB=E3=81=AE?= =?UTF-8?q?=E8=AA=9E=E3=81=AB=E5=BD=93=E3=81=9F=E3=82=8B=203=20=E8=A1=8C?= =?UTF-8?q?=E3=82=92=E4=BB=8A=E3=81=AE=E6=B1=BA=E3=81=BE=E3=82=8A=E3=81=A0?= =?UTF-8?q?=E3=81=91=E3=82=92=E6=9B=B8=E3=81=8F=E5=BD=A2=E3=81=B8=E7=9B=B4?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-workflow-development-loop.md | 2 +- plugins/ndf/skills/development-workflow/references/loop.md | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 22bd9b3bc..d823814ee 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -234,7 +234,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 `sprint-state.py gate <その周回の状態> 関門 1 --by user --outcome approved [--pr N]` を内で打ち、続けて承認した設計のマージ (`design_pr_merge.merge_approved`。承認した先端のままで必須の検査が緑の設計 PR を、検査の完了を待たずに `--no-cleanup` で宛先へマージする。 宛先が本番チャネルに当たるか決められない設計 PR はマージしない)を 1 回試み、結果を行の `merge` に、マージできたら `merged_at` を書く。 -マージの成否によらず終了コードは 0 で、`summary` に結果を添える。`--rejected` は `--outcome rejected` で差し戻しの記録を残し、マージを呼ばない。 +終了コードは、マージできたときもできなかったときも 0 で、`summary` に結果を添える。`--rejected` は `--outcome rejected` で差し戻しの記録を残し、マージを呼ばない。 どちらも課題の本文の節を書き直す。 ### 止まる条件と再開 diff --git a/plugins/ndf/skills/development-workflow/references/loop.md b/plugins/ndf/skills/development-workflow/references/loop.md index 5ee17e40b..b1bc95bc4 100644 --- a/plugins/ndf/skills/development-workflow/references/loop.md +++ b/plugins/ndf/skills/development-workflow/references/loop.md @@ -69,7 +69,7 @@ | 配布の依頼(並列の計画のレーン。フェーズ「配布の依頼」のプラン) | 承認ゲート 2 として止まらず、`loop lap` が周回を「配布待ち」で締めて次の周回へ進む(下の「並列の計画のレーン」) | **人の判断待ちの課題には利用者が答える。** `$SS loop answer <名> <課題> --approved [--pr <設計 PR>]` は承認の記録を書き、 -設計 PR を宛先へマージする(下の「承認した設計のマージ」)。マージが済んだ課題は、次の周回で順位によらず先頭に選ぶ(選定の理由は +設計 PR を宛先へマージする(下の「承認した設計のマージ」)。マージが済んだ課題は、次の周回で順位より先に、先頭に選ぶ(選定の理由は 「承認した設計」。その課題は設計のステージを置かない)。`--rejected` は差し戻しの記録を書き、以後選ばない。答えるまで、その課題は選ばない。 `--approved` は、答えの前に、問いの時点の設計 PR の先頭のコミット(承認した先端。人の判断待ちの行の `stamp`)と今の先端を比べる。 @@ -82,7 +82,7 @@ 承認の答え(`loop answer --approved`、並列の計画では `parallel.py answer --approved`)は、設計 PR を待たずに 1 回だけマージしてみる。 マージするのは、今の先端が承認した先端と同じで、必須の検査がすべて緑のときだけである(検査の完了は待たない)。宛先が本番チャネルに 当たるか決められない設計 PR はマージしない(承認ゲート 2 で人がマージする)。後片付け(`merged-steps.py cleanup`)は周回の境が打つ。 -マージの成否によらず答えは書かれ、終了コードは 0 である。結果は人の判断待ちの行の `merge`(`result` と `reason`)に残る。 +答えは、マージできたときもできなかったときも書かれ、終了コードは 0 である。結果は人の判断待ちの行の `merge`(`result` と `reason`)に残る。 マージできなかった設計 PR は、周回の境(選定の前)で、前の結果に応じた手を 1 回だけ打つ。失敗はその課題を外す理由 (`設計 PR がマージ待ち(<理由>)`)にとどめ、ほかの課題で周回を進める。 From 371c61aade5e591b357c787bc50f5bb5589085ad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 06:13:29 +0000 Subject: [PATCH 028/127] =?UTF-8?q?Test:=20=E7=8F=BE=E7=8A=B6=E5=9B=BA?= =?UTF-8?q?=E5=AE=9A=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E8=B6=B3=E3=81=99?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop.py#cmd=5Fa?= =?UTF-8?q?nswer?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/tests/test_sprint_loop.py | 99 +++++++++++++++++++ 1 file changed, 99 insertions(+) diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index afc5dc753..7eae8a5f5 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1812,3 +1812,102 @@ def test_lane_gives_back_with_a_new_request_id_for_the_same_head(env, monkeypatc sl_select.cmd_select(env, "l1") w = row(env, 11) assert not w.get("answer") and w["request"] == f"la-g501-{HEAD[:12]}-m2" and sent[-1]["returned"] == "権限が無い" + + +# ---------------------------------------------------------------- 現状固定: cmd_answer の分岐ごとの出力 + + +@pytest.mark.parametrize( + "box, after, merged", + [ + ({"result": "merged", "class": "passed", "reason": ""}, "設計 PR をマージした。次の周回で先頭に選ぶ", True), + ({"result": "already_merged", "class": "passed", "reason": ""}, "設計 PR をマージした。次の周回で先頭に選ぶ", True), + ( + {"result": "production", "class": None, "reason": "宛先 main が本番"}, + "設計 PR をマージしない(宛先 main が本番)。承認ゲート 2 で人がマージするまで実装に選ばない", + False, + ), + ( + {"result": "not_green", "class": "running", "reason": "走っている"}, + "設計 PR をマージできない(not_green: 走っている)。次の周回の境でマージし直す", + False, + ), + ], +) +def test_characterize_answer_approved_with_a_design_pr_by_merge_result(env, box, after, merged): + gated_both(env) + env.merge_box = box + out = sl.cmd_answer(env, "l1", 11, True, None) + w = row(env, 11) + assert out["status"] == "ok" and out["metrics"]["status"] == "answered" and "exit" not in out + assert out["summary"] == f"#11 の答え(approved)を書いた。{after}" + assert out["items"] == [w] and w["answer"] == "approved" and env.merges == [(501, HEAD)] + assert {k: w["merge"][k] for k in ("result", "class", "reason", "lap", "streak", "infra_reruns", "stamp")} == { + **{k: box[k] for k in ("result", "class", "reason")}, + "lap": 1, + "streak": 1, + "infra_reruns": 0, + "stamp": HEAD, + } + assert bool(w.get("merged_at")) is merged + assert any(c[2:4] == ["gate", w["state"]] and c[-2:] == ["--pr", "501"] and "approved" in c for c in env.calls) + + +def test_characterize_answer_approved_without_a_design_pr_does_not_merge(env): + lap = built_lap(env) + sl_gate.cmd_gate(env, "l1", write_done(env, [{"plan": str(Path(lap["out"]) / f"design-{n}.json"), "result": "関門"} for n in (11, 12)])) + out = sl.cmd_answer(env, "l1", 11, True, None) + w = row(env, 11) + assert out["status"] == "ok" and out["metrics"]["status"] == "answered" + assert out["summary"] == "#11 の答え(approved)を書いた。設計 PR のマージの後の周回で選定の対象に戻る" + assert out["items"] == [w] and w["answer"] == "approved" and w["pr"] is None and "merge" not in w and env.merges == [] + gate = [c for c in env.calls if c[2:3] == ["gate"]] + assert len(gate) == 1 and "--pr" not in gate[0] and gate[0][gate[0].index("--outcome") + 1] == "approved" + + +def test_characterize_answer_rejected_and_revise_summaries(env, tmp_path): + gated_both(env) + out = sl.cmd_answer(env, "l1", 12, False, None) + assert out["status"] == "ok" and out["metrics"]["status"] == "answered" + assert out["summary"] == "#12 の答え(rejected)を書いた。選定の対象に戻さない(差し戻し)" + assert out["items"] == [row(env, 12)] and row(env, 12)["answer"] == "rejected" + note = tmp_path / "n.md" + note.write_text(" 直す\n") + out = sl.cmd_answer(env, "l1", 11, False, None, revise=True, note=str(note)) + w = row(env, 11) + assert out["summary"] == "#11 の答え(revise)を書いた。次の周回で先頭に選び、指摘を読んで同じ設計 Pull Request を直す" + assert out["items"] == [w] and w["note"] == "直す" and w["revise_count"] == 1 and env.merges == [] + + +def test_characterize_answer_refusals_write_nothing(env, tmp_path): + gated_both(env) + before = rec(env) + out = sl.cmd_answer(env, "l1", 99, True, None) + assert out["status"] == "stopped" and out["exit"] == 1 and out["metrics"] == {"status": "refused"} and out["items"] == [] + assert out["summary"] == "#99 は人の判断待ちでない" + missing = tmp_path / "none.md" + out = sl.cmd_answer(env, "l1", 11, False, 777, revise=True, note=str(missing)) + assert out["status"] == "stopped" and out["exit"] == 1 and out["metrics"] == {"status": "refused"} + assert out["summary"].startswith("指摘ファイルを読めない: ") and str(missing) in out["summary"] + assert rec(env) == before and env.merges == [] + + +def test_characterize_answer_pr_argument_is_written_to_the_row(env): + gated_both(env) + out = sl.cmd_answer(env, "l1", 12, False, 777) + assert out["status"] == "ok" and row(env, 12)["pr"] == 777 and env.merges == [] + + +def test_characterize_answer_appends_notes_after_the_row(env): + gated_both(env) + base = env.run + + def run(cmd, cwd=None, stdin=None): + if cmd[2:3] == ["gate"]: + env.calls.append(cmd) + return 2, "", "書けない理由\n" + return base(cmd, cwd=cwd, stdin=stdin) + + env.run = run + out = sl.cmd_answer(env, "l1", 12, False, None) + assert out["status"] == "ok" and out["items"] == [row(env, 12), {"note": "承認の記録を書けない(2): 書けない理由"}] From 5f1869d98fa0ece2a5c7e2d856e83b2b28e4dada Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 06:15:41 +0000 Subject: [PATCH 029/127] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop.py#cmd=5Fansw?= =?UTF-8?q?er?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 答えを書いた後の人向けの文面を決める if/elif の 6 分岐を、副作用の無い answer_after(approved, revise, merge) へ抽出した。write_record と loop_ok の 実行位置は変えていない。抽出で 4 行増えたため、行数の例外リストを 627 行へ更新した。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop.py | 28 +++++++++++-------- ...__scripts__lib__sprint_loop.py--lines.json | 2 +- 2 files changed, 17 insertions(+), 13 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop.py b/plugins/ndf/scripts/lib/sprint_loop.py index 9ae018191..5c911edf5 100644 --- a/plugins/ndf/scripts/lib/sprint_loop.py +++ b/plugins/ndf/scripts/lib/sprint_loop.py @@ -383,21 +383,25 @@ def cmd_answer(env: Env, name: str, number: int, approved: bool, pr: int | None, out = design_pr_merge.merge_approved(env.main_dir(), int(w["pr"]), w.get("stamp"), run=env.run) merge = sprint_loop_gate.note_merge(w, out, w.get("lap") or len(rec["laps"]), env.now()) write_record(env, rec, before) - if revise: - after = "次の周回で先頭に選び、指摘を読んで同じ設計 Pull Request を直す" - elif not approved: - after = "選定の対象に戻さない(差し戻し)" - elif merge and merge["result"] in sprint_loop_gate.MERGED_RESULTS: - after = "設計 PR をマージした。次の周回で先頭に選ぶ" - elif merge and merge["result"] == "production": - after = f"設計 PR をマージしない({merge['reason']})。承認ゲート 2 で人がマージするまで実装に選ばない" - elif merge: - after = f"設計 PR をマージできない({merge['result']}: {merge['reason']})。次の周回の境でマージし直す" - else: - after = "設計 PR のマージの後の周回で選定の対象に戻る" + after = answer_after(approved, revise, merge) return loop_ok(f"#{number} の答え({w['answer']})を書いた。{after}", rec, [w, *({"note": n} for n in notes)], status="answered") +def answer_after(approved: bool, revise: bool, merge: dict | None) -> str: + """答えを書いた後に人へ示す、この後の扱いの文面を返す(答えの種別と設計 PR のマージの結果から決める)。""" + if revise: + return "次の周回で先頭に選び、指摘を読んで同じ設計 Pull Request を直す" + if not approved: + return "選定の対象に戻さない(差し戻し)" + if merge and merge["result"] in sprint_loop_gate.MERGED_RESULTS: + return "設計 PR をマージした。次の周回で先頭に選ぶ" + if merge and merge["result"] == "production": + return f"設計 PR をマージしない({merge['reason']})。承認ゲート 2 で人がマージするまで実装に選ばない" + if merge: + return f"設計 PR をマージできない({merge['result']}: {merge['reason']})。次の周回の境でマージし直す" + return "設計 PR のマージの後の周回で選定の対象に戻る" + + def regated_answer(env: Env, w: dict) -> str | None: """1 本のループの承認の答えの前に、承認した先端と今の先端を比べる(#2507)。違えば(または行に承認した先端が無ければ)、 答えを当てずに行の承認した先端を今の先端へ書き換え、答え直しを促す 1 行を返す。読めなければ比べない(マージの部品が読み直す)。""" diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json index a4c9a4bdc..52e2cc8d5 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/sprint_loop.py", "name": "", "kind": "lines", "reason": "492 行だったところへ #2436(レーンの配布待ち・質問待ち・保留の周回の CLI)で増えて 587 行、#2507(承認した設計のマージの記録と周回の境のマージし直しの CLI)で 623 行。500 行以下にしたら消す", "lines": 623} +{"path": "plugins/ndf/scripts/lib/sprint_loop.py", "name": "", "kind": "lines", "reason": "492 行だったところへ #2436(レーンの配布待ち・質問待ち・保留の周回の CLI)で増えて 587 行、#2507(承認した設計のマージの記録と周回の境のマージし直しの CLI)で 623 行、cmd_answer の文面の決定を answer_after へ抽出して 627 行。500 行以下にしたら消す", "lines": 627} From a3f90cc1f1a792da491fe486c1ddac0d120560ec Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 06:17:17 +0000 Subject: [PATCH 030/127] =?UTF-8?q?Refactor:=20centralize=5Fconfiguration?= =?UTF-8?q?=20=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop=5Fgate.py#?= =?UTF-8?q?MERGED=5FRESULTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit マージ済みに当たる結果の組を結果の値の正本 design_pr_merge.RESULTS の隣へ移し、 sprint_loop_gate は再エクスポート、parallel_answer.merge_note はリテラルをやめて参照する。 sprint_loop_gate の行数を例外リストの上限内に保つため、再エクスポートは from import で行う。 Item-Id: I-002 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/design_pr_merge.py | 1 + plugins/ndf/scripts/lib/parallel_answer.py | 4 +++- plugins/ndf/scripts/lib/sprint_loop_gate.py | 4 +--- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/plugins/ndf/scripts/lib/design_pr_merge.py b/plugins/ndf/scripts/lib/design_pr_merge.py index b751a33e2..3bd6bf816 100644 --- a/plugins/ndf/scripts/lib/design_pr_merge.py +++ b/plugins/ndf/scripts/lib/design_pr_merge.py @@ -24,6 +24,7 @@ MERGED_STEPS = Path(__file__).resolve().parents[1] / "merged-steps.py" RESULTS = ("merged", "already_merged", "head_moved", "no_stamp", "not_green", "conflict", "production", "failed") +MERGED_RESULTS = ("merged", "already_merged") # マージ済みに当たる結果 # 答えの待ちの上限(非機能の実現方式)。緑なら 1 回目の読みから 5 秒後の確かめ直しでマージし、10 秒を過ぎた最初の読みで切れる WAIT_ARGS = ["--timeout", "10", "--interval", "5", "--recheck", "5", "--no-checks-after", "5"] UNREGISTERED = "unregistered" # 必須の検査がまだ検査の一覧に載っていない・検査が無いと確かめられない diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py index 7aea6eb36..7f6c608d6 100644 --- a/plugins/ndf/scripts/lib/parallel_answer.py +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -105,6 +105,8 @@ def merge_note(merge: dict | None) -> tuple[str, dict]: """答えの要約へ足すマージの結果の 1 文と、出力へ足す `merge`。マージを試していなければ ("", {})。""" if not merge: return "", {} - if merge["result"] in ("merged", "already_merged"): + import design_pr_merge + + if merge["result"] in design_pr_merge.MERGED_RESULTS: return "。設計 PR をマージした", {"merge": merge} return f"。設計 PR をマージできない({merge['result']}: {merge['reason']})", {"merge": merge} diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 777fd8271..fb7f4891f 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -15,6 +15,7 @@ import jsonio import project_mvv as pm import step_result +from design_pr_merge import MERGED_RESULTS # 正本は design_pr_merge。sprint_loop・sprint_loop_select はここから読む from sprint_loop_record import ( TOOL, @@ -415,9 +416,6 @@ def regate1(env: Env, w: dict) -> bool: return True -MERGED_RESULTS = ("merged", "already_merged") - - def note_merge(w: dict, out: dict, lap: int, now: str) -> dict: """承認した設計のマージの結果(`design_pr_merge.merge_approved` の出力)を人の判断待ちの行の `merge` へ書く(#2507)。 同じ承認した先端で同じ result と class が続いた周回の数(streak)と、基盤待ちのための再実行の累計(infra_reruns)を数える。 From 8e05a973803f621eb8d4cdd5ae4382f9b51196fd Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 06:19:01 +0000 Subject: [PATCH 031/127] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/parallel=5Frun.py#=5FRun.ta?= =?UTF-8?q?ke?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit take の gate1 / gate2 / ask の枝(打ち直しの除外・同じ原因のまとめ・human-action の attach 付与・ 一覧への追加)を _Run.receive_question へそのまま移し、take は kind の振り分けだけにした。 questions と answered は引き続き _Run が持ち、publish_questions の回数と順序は変えていない。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_run.py | 30 ++++++++++++++----------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index b7fdd92aa..531681cb3 100644 --- a/plugins/ndf/scripts/lib/parallel_run.py +++ b/plugins/ndf/scripts/lib/parallel_run.py @@ -332,22 +332,26 @@ def take(self, msg) -> None: self.received[msg.get("lane", "")] = self.received.get(msg.get("lane", ""), 0) + 1 durable.set_event(f"lane-{msg.get('lane')}", {"received": self.received[msg.get("lane", "")]}) elif kind in ("gate1", "gate2", "ask"): - if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: - return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) - key = cause_of(msg) - same = next((q for q in self.questions if key and q.get("cause") == key), None) - if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) - 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() - 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.receive_question(msg) elif kind == "answer": self.reply(msg) + def receive_question(self, msg: dict) -> None: + """レーンからの問い(gate1 / gate2 / ask)を一覧へ受け付ける。打ち直しは数えず、同じ原因の問いは 1 つにまとめる。""" + if any(q.get("id") == msg.get("id") for q in self.questions) or msg.get("id") in self.answered: + return # 同じ依頼の打ち直しは数えない(id は送る側が依頼の中身から決める) + key = cause_of(msg) + same = next((q for q in self.questions if key and q.get("cause") == key), None) + if same is not None: # 同じ原因の問いは 1 つにまとめ、答えを依頼してきた全レーンへ配る(異常系 5) + 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() + 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() + def publish_questions(self) -> None: durable.set_event("questions", {"seq": self.bump(), "items": self.questions}) From d3dd017debd709cd7a5b1ae9f7d1a011db04b7c7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 06:21:36 +0000 Subject: [PATCH 032/127] =?UTF-8?q?Fix:=20=E6=A4=9C=E8=A8=BC=E3=81=AE?= =?UTF-8?q?=E5=A4=B1=E6=95=97=E3=82=92=E7=9B=B4=E3=81=99=20=E2=80=94=20plu?= =?UTF-8?q?gins/ndf/scripts/lib/parallel=5Frun.py#=5FRun.take?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit receive_question の抽出で parallel_run.py が 782 行になり、例外リストの 778 行を超えた。 同じ原因の問いの鍵を作る cause_of を新しいモジュール parallel_question.py へそのまま移し、 parallel_run から読み込む(parallel_run.cause_of の名前は残る)。本体は変えていない。 Item-Id: I-003 Impl-Runtime: claude Impl-Model: default --- plugins/ndf/scripts/lib/parallel_question.py | 23 ++++++++++++++++++++ plugins/ndf/scripts/lib/parallel_run.py | 19 +--------------- 2 files changed, 24 insertions(+), 18 deletions(-) create mode 100644 plugins/ndf/scripts/lib/parallel_question.py diff --git a/plugins/ndf/scripts/lib/parallel_question.py b/plugins/ndf/scripts/lib/parallel_question.py new file mode 100644 index 000000000..d9747e40a --- /dev/null +++ b/plugins/ndf/scripts/lib/parallel_question.py @@ -0,0 +1,23 @@ +"""並列の計画でレーンから届いた問いのまとめ方(#2436 の異常系 5)。`parallel_run` の問いの受け付けが使う。""" + +from __future__ import annotations + +import json + + +def cause_of(msg: dict) -> str: + """同じ原因の問いの鍵(異常系 5): 区分と選択肢と、空白をまとめてレーン名・パス・番号を伏せた問いの文面。 + + まとめるのは人にしかできない操作(`ndf-ask:human-action`。認証切れ・ディスク不足・ネットワークなど環境の問題)だけで、 + 取り消せない操作・レッドライン・費用の問いは番号の違いで別の対象になり得るため、まとめない(空の鍵を返す)。 + まとめた問いには各レーンの元の文面(`merged`)を残し、オーガナイザーが問いに並べる。""" + import re + + if msg.get("kind") != "ask" or msg.get("source") != "ndf-ask:human-action": + return "" + text = str(msg.get("question") or "") + if msg.get("lane"): + text = text.replace(str(msg["lane"]), "") + text = re.sub(r"(/[^\s()()]+)+", "", text) + text = re.sub(r"\d+", "", text) + return f"{msg.get('source')}|{json.dumps(msg.get('options') or [], ensure_ascii=False)}|{' '.join(text.split())}" diff --git a/plugins/ndf/scripts/lib/parallel_run.py b/plugins/ndf/scripts/lib/parallel_run.py index 531681cb3..9a4e43ca4 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_question import cause_of from sprint_loop_names import LOOP_DIR, PENDING HERE = Path(__file__).resolve().parent.parent @@ -737,24 +738,6 @@ def _ask_helper(run: _Run, rows: dict) -> None: ) # fmt: skip -def cause_of(msg: dict) -> str: - """同じ原因の問いの鍵(異常系 5): 区分と選択肢と、空白をまとめてレーン名・パス・番号を伏せた問いの文面。 - - まとめるのは人にしかできない操作(`ndf-ask:human-action`。認証切れ・ディスク不足・ネットワークなど環境の問題)だけで、 - 取り消せない操作・レッドライン・費用の問いは番号の違いで別の対象になり得るため、まとめない(空の鍵を返す)。 - まとめた問いには各レーンの元の文面(`merged`)を残し、オーガナイザーが問いに並べる。""" - import re - - if msg.get("kind") != "ask" or msg.get("source") != "ndf-ask:human-action": - return "" - text = str(msg.get("question") or "") - if msg.get("lane"): - text = text.replace(str(msg["lane"]), "") - text = re.sub(r"(/[^\s()()]+)+", "", text) - text = re.sub(r"\d+", "", text) - return f"{msg.get('source')}|{json.dumps(msg.get('options') or [], ensure_ascii=False)}|{' '.join(text.split())}" - - def _plan_view(run: _Run) -> pp.ParallelPlan: """起動の判定に使う計画の形(耐久ワークフローの入力から作る。ファイルを読まない)。""" lanes = [ From e6649275222a223a1238974272c6134b193932b6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 07:03:51 +0000 Subject: [PATCH 033/127] =?UTF-8?q?Fix:=20=E6=89=BF=E8=AA=8D=E3=81=97?= =?UTF-8?q?=E3=81=9F=E8=A8=AD=E8=A8=88=E3=81=AE=E5=95=8F=E3=81=84=E7=9B=B4?= =?UTF-8?q?=E3=81=97=E3=81=AE=20id=E3=83=BB=E5=9F=BA=E7=9B=A4=E5=BE=85?= =?UTF-8?q?=E3=81=A1=E3=81=AE=E5=86=8D=E5=AE=9F=E8=A1=8C=E3=81=AE=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E3=83=BB=E5=89=8D=E3=81=AE=E5=9B=9E=E7=AD=94=E8=80=85?= =?UTF-8?q?=E3=81=AE=E6=89=B1=E3=81=84=E3=82=92=E7=9B=B4=E3=81=99=EF=BC=88?= =?UTF-8?q?#2507=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 利用者へ戻すときの依頼の id を `-m<周回>-<戻した回数>` にし、同じ周回で戻し直しても重複として捨てられないようにする - 基盤待ちの再実行の残りを merge-when-green の --infra-reruns へ渡し、実際に打った回数で累計する。残りが 0 なら呼ばない - 人の判断待ちへ戻すときに前の回答者 by も外し、次の利用者の答えの承認の記録を省かない Co-Authored-By: Claude Opus 5.5 --- .../ndf-workflow-development-loop.md | 2 +- plugins/ndf/scripts/lib/design_pr_merge.py | 24 ++++++++----- plugins/ndf/scripts/lib/sprint_loop_gate.py | 16 +++++---- plugins/ndf/scripts/lib/sprint_loop_select.py | 9 ++--- .../ndf/scripts/tests/test_design_pr_merge.py | 16 ++++++--- plugins/ndf/scripts/tests/test_sprint_loop.py | 34 +++++++++++++++++-- 6 files changed, 74 insertions(+), 27 deletions(-) diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index d823814ee..6e59b6fe6 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -310,7 +310,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | `laps[]` | 周回(追記だけ。I1) | | `waiting[]` | 人の判断待ち。答えは行を消さずに `answer`(`approved` / `rejected` / `revise`)・`answered_at` を足す。`approved` の行はマージを確かめた時刻 `merged_at`、だれが承認したか `by`(`user` / `mvv`)、最後の承認した設計のマージの結果 `merge` (`{result, class, reason, pr, head, at, branch, infra_rerun, lap, streak, infra_reruns, stamp}`)、後片付けの時刻 `cleaned` を足す。 -利用者へ問い直した行は理由 `returned` を持つ。並列の計画のレーンは、オーガナイザーへ送った設計の承認の依頼の `request` と計画名 `plan` を持つ。`revise` の行は指摘 `note` と回数 `revise_count` を持つ | +利用者へ問い直した行は理由 `returned` を持つ(問い直すときは `answer`・`answered_at`・`by`・`merge`・`merged_at` を外す。`infra_rerun` は 1 回のマージで実際に打った再実行の回数、`infra_reruns` はその累計)。マージできずに利用者へ戻した回数 `givebacks` は消さず、レーンの依頼の id の接尾辞 `-m<周回>-<回数>` に使う。並列の計画のレーンは、オーガナイザーへ送った設計の承認の依頼の `request` と計画名 `plan` を持つ。`revise` の行は指摘 `note` と回数 `revise_count` を持つ | | `fixes[]` | 配布を差し戻された周回の直し `{lap, issues, reason, at}`。直しの周回で課題を選ぶと `fixed_by` に周回の番号を書く | | `excluded[]` | `{number, reason, lap}`(`loop exclude`) | | `sprint_mvv_approvals[]` | `{milestone, sha256, at, by}` | diff --git a/plugins/ndf/scripts/lib/design_pr_merge.py b/plugins/ndf/scripts/lib/design_pr_merge.py index 3bd6bf816..152eb87a0 100644 --- a/plugins/ndf/scripts/lib/design_pr_merge.py +++ b/plugins/ndf/scripts/lib/design_pr_merge.py @@ -27,6 +27,7 @@ MERGED_RESULTS = ("merged", "already_merged") # マージ済みに当たる結果 # 答えの待ちの上限(非機能の実現方式)。緑なら 1 回目の読みから 5 秒後の確かめ直しでマージし、10 秒を過ぎた最初の読みで切れる WAIT_ARGS = ["--timeout", "10", "--interval", "5", "--recheck", "5", "--no-checks-after", "5"] +INFRA_RERUNS = 3 # 同じ承認した先端で基盤待ちのために再実行する回数の上限(merge-when-green の --infra-reruns の既定と同じ) UNREGISTERED = "unregistered" # 必須の検査がまだ検査の一覧に載っていない・検査が無いと確かめられない Run = Callable[..., tuple] @@ -81,20 +82,22 @@ def _checks_registered(root: str, base: str, stamp: str, rollup: list, run: Run) return f"ワークフローの有無を読めない: {(se or so).strip()[:200]}" -def merge_approved(root, pr: int, stamp: str | None, run: Run | None = None) -> dict: +def merge_approved(root, pr: int, stamp: str | None, run: Run | None = None, infra_budget: int = INFRA_RERUNS) -> dict: """設計 PR `pr` を、承認した先端 `stamp` のままで検査が緑なら宛先へマージする。結果は `RESULTS` の値の 1 つ。 - 出力: {result, class, reason, pr, head, at, branch, infra_rerun}。`infra_rerun` は基盤待ちのジョブの再実行を頼んだか - (`merge-when-green --infra-gap 0` を呼んだか)。周回の境が同じ承認した先端での累計に足す。""" + 出力: {result, class, reason, pr, head, at, branch, infra_rerun}。`infra_rerun` は基盤待ちのジョブを実際に再実行した + 回数(merge-when-green の items の `infra_rerun` の数)。周回の境が同じ承認した先端での累計に足す。 + `infra_budget` は再実行の残りの回数で、merge-when-green の `--infra-reruns` に渡す。0 以下で基盤待ちなら + merge-when-green を呼ばずに not_green(infra)を返す。""" run = run or run_cmd root = str(root) try: - return _try_merge(root, int(pr), (stamp or "").strip(), run) + return _try_merge(root, int(pr), (stamp or "").strip(), run, int(infra_budget)) except Exception as e: # noqa: BLE001 — 例外を投げない(I4)。読めない出力も failed に写す return _res("failed", int(pr), reason=f"マージを試せない: {e}"[:300]) -def _try_merge(root: str, pr: int, stamp: str, run: Run) -> dict: +def _try_merge(root: str, pr: int, stamp: str, run: Run, budget: int) -> dict: code, so, se = run(["gh", "pr", "view", str(pr), "--json", "state,headRefOid,baseRefName,headRefName,statusCheckRollup"], cwd=root) try: info = json.loads(so) if code == 0 else None @@ -103,7 +106,7 @@ def _try_merge(root: str, pr: int, stamp: str, run: Run) -> dict: if not isinstance(info, dict) or not info.get("state"): return _res("failed", pr, reason=f"設計 PR を読めない: {(se or so).strip()[:200]}") head, base, branch = info.get("headRefOid") or "", info.get("baseRefName") or "", info.get("headRefName") or "" - ctx = {"branch": branch, "infra_rerun": False} + ctx = {"branch": branch, "infra_rerun": 0} if info["state"] == "MERGED": return _res("already_merged", pr, head, **ctx) if info["state"] != "OPEN": @@ -125,10 +128,13 @@ def _try_merge(root: str, pr: int, stamp: str, run: Run) -> dict: return _res("not_green", pr, head, f"検査が緑でない({cls})", cls, **ctx) argv = [sys.executable, str(MERGED_STEPS), "merge-when-green", str(pr), "--expect-head", stamp, "--no-cleanup", *WAIT_ARGS] if cls == "infra": - argv += ["--infra-gap", "0"] - ctx["infra_rerun"] = True + if budget <= 0: + return _res("not_green", pr, head, f"CI の基盤待ちが再実行 {INFRA_RERUNS} 回で解けない", cls, **ctx) + argv += ["--infra-gap", "0", "--infra-reruns", str(budget)] code, so, se = run(argv, cwd=root) - return _from_merge(pr, head, cls, code, _last_json(so), se, ctx) + got = _last_json(so) + ctx["infra_rerun"] = sum(1 for i in got.get("items") or [] if isinstance(i, dict) and i.get("result") == "infra_rerun") + return _from_merge(pr, head, cls, code, got, se, ctx) def _from_merge(pr: int, head: str, cls: str, code: int, got: dict, se: str, ctx: dict) -> dict: diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index fb7f4891f..ce4902eab 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -419,20 +419,21 @@ def regate1(env: Env, w: dict) -> bool: def note_merge(w: dict, out: dict, lap: int, now: str) -> dict: """承認した設計のマージの結果(`design_pr_merge.merge_approved` の出力)を人の判断待ちの行の `merge` へ書く(#2507)。 同じ承認した先端で同じ result と class が続いた周回の数(streak)と、基盤待ちのための再実行の累計(infra_reruns)を数える。 - infra_reruns は分類が変わっても 0 に戻さない(基盤待ちと実行中が交互に現れても再実行を上限なく増やさない)。""" + infra_reruns は分類が変わっても 0 に戻さない(基盤待ちと実行中が交互に現れても再実行を上限なく増やさない)。 + 足すのは実際に打った再実行の回数(`infra_rerun`。1 回の merge-when-green の中の複数回も数える)。""" prev = w.get("merge") or {} stamp = w.get("stamp") or "" same = prev.get("stamp") == stamp streak = prev.get("streak", 0) + 1 if same and (prev.get("result"), prev.get("class")) == (out.get("result"), out.get("class")) else 1 - reruns = (prev.get("infra_reruns", 0) if same else 0) + (1 if out.get("infra_rerun") else 0) + reruns = (prev.get("infra_reruns", 0) if same else 0) + int(out.get("infra_rerun") or 0) w["merge"] = {**out, "lap": lap, "streak": streak, "infra_reruns": reruns, "stamp": stamp} if out.get("result") in MERGED_RESULTS and not w.get("merged_at"): w["merged_at"] = now return w["merge"] -def _reopen_waiting(w: dict) -> None: - for k in ("answer", "answered_at", "merge", "merged_at"): +def _reopen_waiting(w: dict) -> None: # 前の回答者 by も外す(残すと次の答えを MVV の承認と読み違える) + for k in ("answer", "answered_at", "by", "merge", "merged_at"): w.pop(k, None) @@ -459,15 +460,16 @@ def reask_design(env: Env, rec: dict, w: dict, head: str) -> bool: def giveback_design(env: Env, rec: dict, w: dict, reason: str, lap: int) -> bool: """時間で解けないマージの結果を、承認した先端のまま利用者へ戻す(#2507 の決定 9)。依頼にマージの結果の理由を載せる。 利用者は「直して出し直す」「やらない」か、手を打ってから「承認」を選ぶ。戻した行のマージの結果(streak・infra_reruns)は消す。 - レーンは同じ先端でも別の依頼にするため id に `-m<周回>` を足す。送れなければ何も変えずに次の周回の境で打ち直す。""" + レーンは同じ先端でも別の依頼にするため id に `-m<周回>-<戻した回数 givebacks。消さない>` を足す。送れなければ何も変えずに次の周回の境で打ち直す。""" w["returned"] = reason if w.get("request"): - sent = lane_gate1(env, w, w.get("report") or "", w.get("state") or "", suffix=f"-m{lap}", stamp=w.get("stamp") or "") + n = w.get("givebacks", 0) + 1 + sent = lane_gate1(env, w, w.get("report") or "", w.get("state") or "", suffix=f"-m{lap}-{n}", stamp=w.get("stamp") or "") if not sent: w.pop("returned", None) return False _reopen_waiting(w) - w.update(sent) + w.update(sent, givebacks=n) else: _reopen_waiting(w) mark_issue(env, rec, w) diff --git a/plugins/ndf/scripts/lib/sprint_loop_select.py b/plugins/ndf/scripts/lib/sprint_loop_select.py index f65bbdd7f..c0167d4db 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_select.py +++ b/plugins/ndf/scripts/lib/sprint_loop_select.py @@ -155,7 +155,6 @@ def merge_wait_reason(w: dict) -> str: # ---------------------------------------------------------------- 承認した設計のマージし直し(#2507 の I6) STREAK_LIMIT = 3 # 同じ分類の not_green がこの周回の数だけ続いたら利用者へ戻す(既定。測っていない) -INFRA_RERUNS = 3 # 同じ承認した先端で基盤待ちのために再実行する回数の上限(merge-when-green の --infra-reruns の既定と同じ) UNSOLVABLE = ("stale_again",) # 時間で解けない not_green の分類 @@ -197,9 +196,11 @@ def _settle_one(env: Env, rec: dict, w: dict, lap_n: int) -> str | None: return _settle_conflict(env, rec, w, m, lap_n) if res == "not_green" and (cls in UNSOLVABLE or m.get("streak", 0) >= STREAK_LIMIT): return _giveback(env, rec, w, m.get("reason") or "検査が緑にならない", lap_n) - if res == "not_green" and cls == "infra" and m.get("infra_reruns", 0) >= INFRA_RERUNS: - return _giveback(env, rec, w, f"CI の基盤待ちが再実行 {INFRA_RERUNS} 回で解けない", lap_n) - out = design_pr_merge.merge_approved(env.main_dir(), pr, w.get("stamp"), run=env.run) + used = m.get("infra_reruns", 0) if m.get("stamp") == (w.get("stamp") or "") else 0 + if res == "not_green" and cls == "infra" and used >= design_pr_merge.INFRA_RERUNS: + return _giveback(env, rec, w, f"CI の基盤待ちが再実行 {design_pr_merge.INFRA_RERUNS} 回で解けない", lap_n) + budget = design_pr_merge.INFRA_RERUNS - used # 残りを再実行する層へ渡す(前の分類が基盤待ちでなくても累計で上限を守る) + out = design_pr_merge.merge_approved(env.main_dir(), pr, w.get("stamp"), run=env.run, infra_budget=budget) got = note_merge(w, out, lap_n, env.now()) return f"#{w['number']} の設計 PR #{pr} をマージし直した({got['result']})" diff --git a/plugins/ndf/scripts/tests/test_design_pr_merge.py b/plugins/ndf/scripts/tests/test_design_pr_merge.py index f623e5d95..33659b587 100644 --- a/plugins/ndf/scripts/tests/test_design_pr_merge.py +++ b/plugins/ndf/scripts/tests/test_design_pr_merge.py @@ -99,11 +99,19 @@ def test_checks_that_are_not_green_do_not_call_merge_when_green(cls): assert (out["result"], out["class"]) == ("not_green", cls) and not gh.called("merge-when-green") -def test_infra_wait_reruns_with_no_gap_and_marks_the_rerun(): - gh = Gh(probe="infra") - out = merge(gh) +def test_infra_wait_reruns_with_no_gap_and_counts_the_reruns_it_made(): + rerun = {"kind": "check", "name": "ci", "result": "infra_rerun", "run": "1"} + gh = Gh(probe="infra", mwg=(0, json.dumps({"status": "ok", "items": [rerun, {**rerun, "run": "2"}]}), "")) + out = dm.merge_approved("/r", 501, STAMP, run=gh, infra_budget=2) argv = gh.called("merge-when-green")[0] - assert out["result"] == "merged" and out["infra_rerun"] is True and argv[argv.index("--infra-gap") + 1] == "0" + assert out["result"] == "merged" and out["infra_rerun"] == 2 + assert argv[argv.index("--infra-gap") + 1] == "0" and argv[argv.index("--infra-reruns") + 1] == "2" + + +def test_infra_wait_without_budget_does_not_call_merge_when_green(): + gh = Gh(probe="infra") + out = dm.merge_approved("/r", 501, STAMP, run=gh, infra_budget=0) + assert (out["result"], out["class"], out["infra_rerun"]) == ("not_green", "infra", 0) and not gh.called("merge-when-green") def test_unregistered_required_check_does_not_merge(): diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 7eae8a5f5..9b9835f32 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -74,11 +74,14 @@ def env(tmp_path, mvv, monkeypatch): e.heads = {} # 設計 PR の先端(gh pr view --json headRefOid)。無ければ HEAD e.run = fake_run(e) e.merges = [] # 承認した設計のマージの呼び出し (pr, stamp)。結果は merge_box(#2507) + e.budgets = [] # マージの呼び出しへ渡した基盤待ちの再実行の残り e.merge_box = {"result": "merged", "class": "passed", "reason": ""} monkeypatch.setattr( sl.design_pr_merge, "merge_approved", - lambda root, pr, stamp, run=None: e.merges.append((pr, stamp)) or {"pr": pr, "head": stamp, "at": "t", **e.merge_box}, + lambda root, pr, stamp, run=None, infra_budget=3: ( + e.merges.append((pr, stamp)) or e.budgets.append(infra_budget) or {"pr": pr, "head": stamp, "at": "t", **e.merge_box} + ), ) return e @@ -1708,6 +1711,22 @@ def test_infra_reruns_are_counted_across_classes_and_capped(env): assert len(env.merges) == 4 and not w.get("answer") and "基盤待ち" in w["returned"] +def test_the_rerun_budget_is_passed_down_even_after_a_running_lap(env): + gated_both(env) + env.merge_box = {"result": "not_green", "class": "running", "reason": "走っている", "infra_rerun": 3} # 1 回で 3 回打った + sl.cmd_answer(env, "l1", 11, True, None) + env.merge_box = {"result": "not_green", "class": "running", "reason": "走っている"} + boundary(env, 2) # 前の分類が実行中でも、残りの 0 を再実行する層へ渡す + assert row(env, 11)["merge"]["infra_reruns"] == 3 and env.budgets[1:] == [0, 0] + + +def test_reopening_drops_the_previous_answerer(env): + gated_both(env) + w = {"answer": "approved", "answered_at": "t", "by": "mvv", "merge": {}, "merged_at": "t"} + sl_gate._reopen_waiting(w) + assert w == {} + + def test_production_target_is_not_retried_and_waits_for_gate_2(env): gated_both(env) env.merge_box = {"result": "production", "class": None, "reason": "宛先 main が本番"} @@ -1811,7 +1830,18 @@ def test_lane_gives_back_with_a_new_request_id_for_the_same_head(env, monkeypatc env.rank_box["out"] = rank_out(ms("01 近い", [13, 14])) sl_select.cmd_select(env, "l1") w = row(env, 11) - assert not w.get("answer") and w["request"] == f"la-g501-{HEAD[:12]}-m2" and sent[-1]["returned"] == "権限が無い" + assert not w.get("answer") and w["request"] == f"la-g501-{HEAD[:12]}-m2-1" and sent[-1]["returned"] == "権限が無い" + + +def test_lane_gives_back_twice_in_the_same_lap_with_different_request_ids(env, monkeypatch): + lane_env(env, monkeypatch) + w = {"number": 11, "pr": 501, "request": "x", "stamp": HEAD, "answer": "approved", "by": "mvv"} + monkeypatch.setattr(sl_gate, "mark_issue", lambda e, rec, w: None) + assert sl_gate.giveback_design(env, {}, w, "権限が無い", 2) + first = w["request"] + w.update(answer="approved", by="user") + assert sl_gate.giveback_design(env, {}, w, "権限が無い", 2) + assert (first, w["request"]) == (f"la-g501-{HEAD[:12]}-m2-1", f"la-g501-{HEAD[:12]}-m2-2") and "by" not in w # ---------------------------------------------------------------- 現状固定: cmd_answer の分岐ごとの出力 From f99add2782e33e54e62acea42cc8ea9bc1bf10c3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 07:10:29 +0000 Subject: [PATCH 034/127] =?UTF-8?q?fix(sprint-loop):=20=E5=88=A9=E7=94=A8?= =?UTF-8?q?=E8=80=85=E3=81=B8=E6=88=BB=E3=81=97=E3=81=9F=E8=A8=AD=E8=A8=88?= =?UTF-8?q?=E3=81=AE=E6=89=BF=E8=AA=8D=E3=81=AE=E4=BE=9D=E9=A0=BC=E3=82=92?= =?UTF-8?q?=20MVV=20=E5=88=A4=E5=AE=9A=E3=81=A7=E8=87=AA=E5=88=86=E3=81=A7?= =?UTF-8?q?=E6=89=BF=E8=AA=8D=E3=81=97=E3=81=AA=E3=81=84=20(#2543)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lane_gate1 が利用者へ戻した依頼(w.returned)に印 returned を載せ、 mvv_mark はその印があれば eligible=false を返す(#2507 の決定 9)。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/parallel_answer.py | 4 +++- plugins/ndf/scripts/lib/sprint_loop_gate.py | 4 +++- plugins/ndf/scripts/tests/test_parallel_answer.py | 1 + plugins/ndf/scripts/tests/test_sprint_loop.py | 12 ++++++++++++ 4 files changed, 19 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py index 7f6c608d6..a3332a2cd 100644 --- a/plugins/ndf/scripts/lib/parallel_answer.py +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -25,11 +25,13 @@ def questions(main: Path, name: str) -> tuple[int, list[dict]]: def mvv_mark(q: dict) -> dict: """MVV 判定でオーガナイザーが自分で答えてよい依頼か(#2507 の決定 8)。設計の承認で、レーンの判定が決まらず、 - レッドラインが空で、判定を打ち直す材料(レーンの周回のスプリント状態)が残るときだけ true。""" + レッドラインが空で、利用者へ戻した依頼(印 `returned`。#2507 の決定 9)でなく、判定を打ち直す材料(レーンの周回のスプリント状態)が残るときだけ true。""" if q.get("kind") != "gate1": return {"eligible": False, "why": "設計の承認の依頼でない"} if q.get("boundary"): return {"eligible": False, "why": "レッドラインに当たる"} + if q.get("returned"): + return {"eligible": False, "why": "マージできずに利用者へ戻した依頼(直す・やめるは利用者が選ぶ)"} if q.get("verdict") not in MVV_VERDICTS: return {"eligible": False, "why": f"レーンの判定が決まっている({q.get('verdict')})"} if not q.get("sprint_state") or not Path(q["sprint_state"]).is_file(): diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index ce4902eab..1d2960e47 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -379,7 +379,8 @@ def lane_gate1(env: Env, w: dict, report: str, state: str, suffix: str = "", sta """並列の計画のレーンなら、設計の承認の依頼(`kind: gate1`)をオーガナイザーへ送り、{request, plan, stamp, report, state} を返す。レーンでない・送れなければ None(今どおり人の判断待ちとして残す)。印は設計 PR の先頭の SHA(問いの時点の印)。 同じ承認した先端で利用者へ戻すとき(#2507)は、`stamp` に承認した先端を、`suffix` に依頼の id の接尾辞を渡す - (同じ id の依頼は答え済みとして受け取られないため)。依頼の理由にはマージの結果の理由(`w.returned`)も載せる。""" + (同じ id の依頼は答え済みとして受け取られないため)。依頼の理由にはマージの結果の理由(`w.returned`)も載せ、 + 利用者へ戻した依頼には印 `returned` を付ける(MVV 判定でオーガナイザーが自分で承認しないため。#2507 の決定 9)。""" try: import parallel_lane @@ -391,6 +392,7 @@ def lane_gate1(env: Env, w: dict, report: str, state: str, suffix: str = "", sta rid = parallel_lane.request_gate1( env.main_dir(), got[0], got[1].name, w["number"], int(w["pr"]), report, state, stamp or "0" * 12, verdict=w.get("verdict"), reasons=reasons, boundary=list(w.get("boundary") or []), suffix=suffix, + **({"returned": True} if w.get("returned") else {}), ) # fmt: skip except Exception: # noqa: BLE001 — 送れなければ今どおり人の判断待ち(loop answer で答える) return None diff --git a/plugins/ndf/scripts/tests/test_parallel_answer.py b/plugins/ndf/scripts/tests/test_parallel_answer.py index 37d78e9ee..fb7e37df7 100644 --- a/plugins/ndf/scripts/tests/test_parallel_answer.py +++ b/plugins/ndf/scripts/tests/test_parallel_answer.py @@ -48,6 +48,7 @@ def test_after_items_drop_the_answered_request_and_mark_resends_and_settled(tmp_ (dict(verdict="unknown", boundary=["C1 に当たる"]), False), (dict(verdict="unknown", sprint_state="/無い/s.json"), False), (dict(verdict="unknown", kind="gate2"), False), + (dict(verdict="unknown", returned=True), False), ], ) def test_mvv_mark_only_for_undecided_design_approvals_without_redlines(tmp_path, q, eligible): diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 9b9835f32..7ce7c5af9 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1844,6 +1844,18 @@ def test_lane_gives_back_twice_in_the_same_lap_with_different_request_ids(env, m assert (first, w["request"]) == (f"la-g501-{HEAD[:12]}-m2-1", f"la-g501-{HEAD[:12]}-m2-2") and "by" not in w +@pytest.mark.parametrize("returned, mark", [("権限が無い", True), (None, None)]) +def test_lane_gate1_marks_a_given_back_request_so_the_organizer_does_not_self_approve(env, monkeypatch, returned, mark): + import parallel_lane + + got = [] + monkeypatch.setattr(parallel_lane, "lane_of", lambda main: ("p1", Path("la"))) + monkeypatch.setattr(parallel_lane, "request_gate1", lambda *a, **kw: got.append(kw) or "rid") + w = {"number": 11, "pr": 501, "verdict": "unknown", **({"returned": returned} if returned else {})} + assert sl_gate.lane_gate1(env, w, "r.md", "s.json", stamp=HEAD)["request"] == "rid" + assert got[0].get("returned") is mark + + # ---------------------------------------------------------------- 現状固定: cmd_answer の分岐ごとの出力 From 686633c32f2bb15b1034cf74e72b913f4a1ebbf8 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 07:17:24 +0000 Subject: [PATCH 035/127] =?UTF-8?q?Fix:=20=E5=85=88=E7=AB=AF=E3=81=AE?= =?UTF-8?q?=E7=A7=BB=E5=8B=95=E3=81=A7=E5=95=8F=E3=81=84=E7=9B=B4=E3=81=99?= =?UTF-8?q?=E3=81=A8=E3=81=8D=E3=81=AB=E5=88=A9=E7=94=A8=E8=80=85=E3=81=B8?= =?UTF-8?q?=E6=88=BB=E3=81=97=E3=81=9F=E5=8D=B0=20returned=20=E3=82=92?= =?UTF-8?q?=E5=A4=96=E3=81=99=E3=83=BBsprint=5Floop=5Fgate.py=20=E3=81=AE?= =?UTF-8?q?=E8=A1=8C=E6=95=B0=E3=81=AE=E4=BE=8B=E5=A4=96=E3=82=92=20658=20?= =?UTF-8?q?=E8=A1=8C=E3=81=B8=EF=BC=88#2507=20=E3=81=AE=20cross-review=20r?= =?UTF-8?q?ound=203=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop_gate.py | 10 ++++++---- plugins/ndf/scripts/tests/test_sprint_loop.py | 16 ++++++++++++++++ ...scripts__lib__sprint_loop_gate.py--lines.json | 2 +- 3 files changed, 23 insertions(+), 5 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 1d2960e47..344db3a29 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -413,7 +413,8 @@ def regate1(env: Env, w: dict) -> bool: head = design_head(env, w) if head == stamp: return False - if head and (sent := lane_gate1(env, w, w.get("report") or "", w.get("state") or "")): + if head and (sent := lane_gate1(env, dict(w, returned=None), w.get("report") or "", w.get("state") or "")): + w.pop("returned", None) # 前に利用者へ戻した印と理由は新しい先端の問いに当てはまらない w.update(sent, restamped=stamp) return True @@ -434,8 +435,8 @@ def note_merge(w: dict, out: dict, lap: int, now: str) -> dict: return w["merge"] -def _reopen_waiting(w: dict) -> None: # 前の回答者 by も外す(残すと次の答えを MVV の承認と読み違える) - for k in ("answer", "answered_at", "by", "merge", "merged_at"): +def _reopen_waiting(w: dict) -> None: # 前の回答者 by と利用者へ戻した印 returned も外す(残すと次の問いを読み違える) + for k in ("answer", "answered_at", "by", "merge", "merged_at", "returned"): w.pop(k, None) @@ -447,7 +448,7 @@ def reask_design(env: Env, rec: dict, w: dict, head: str) -> bool: return False old = w.get("stamp") or "" if w.get("request"): - sent = lane_gate1(env, w, w.get("report") or "", w.get("state") or "") + sent = lane_gate1(env, dict(w, returned=None), w.get("report") or "", w.get("state") or "") if not sent: return False _reopen_waiting(w) @@ -474,6 +475,7 @@ def giveback_design(env: Env, rec: dict, w: dict, reason: str, lap: int) -> bool w.update(sent, givebacks=n) else: _reopen_waiting(w) + w["returned"] = reason # _reopen_waiting が外すため書き直す mark_issue(env, rec, w) return True diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 7ce7c5af9..0ff61defa 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -1844,6 +1844,22 @@ def test_lane_gives_back_twice_in_the_same_lap_with_different_request_ids(env, m assert (first, w["request"]) == (f"la-g501-{HEAD[:12]}-m2-1", f"la-g501-{HEAD[:12]}-m2-2") and "by" not in w +@pytest.mark.parametrize("rid", ["x", None]) +def test_reasking_on_a_moved_head_drops_the_given_back_mark(env, monkeypatch, rid): + sent = lane_env(env, monkeypatch) + w = {"number": 11, "pr": 501, "request": rid, "stamp": "old", "returned": "権限が無い", "answer": "approved"} + monkeypatch.setattr(sl_gate, "mark_issue", lambda e, rec, w: None) + assert sl_gate.reask_design(env, {}, w, HEAD) + assert "returned" not in w and all(s["returned"] is None for s in sent) + + +def test_regating_on_a_moved_head_drops_the_given_back_mark(env, monkeypatch): + sent = lane_env(env, monkeypatch) + w = {"number": 11, "pr": 501, "request": "x", "stamp": "0ld" * 4, "returned": "権限が無い"} + assert sl_gate.regate1(env, w) + assert "returned" not in w and sent[-1]["returned"] is None and w["restamped"] == "0ld" * 4 + + @pytest.mark.parametrize("returned, mark", [("権限が無い", True), (None, None)]) def test_lane_gate1_marks_a_given_back_request_so_the_organizer_does_not_self_approve(env, monkeypatch, returned, mark): import parallel_lane diff --git a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json index 52a9965aa..ccc153387 100644 --- a/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json +++ b/scripts/script-structure-allow/plugins__ndf__scripts__lib__sprint_loop_gate.py--lines.json @@ -1 +1 @@ -{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 585 行、#2507(人の判断待ちの行へマージの結果を書く・答えの後の整理)で 654 行。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 654} +{"path": "plugins/ndf/scripts/lib/sprint_loop_gate.py", "name": "", "kind": "lines", "reason": "498 行だったところへ #2436(承認ゲートの問いをオーガナイザーへ送る経路・レーンの配布待ち)で増えて 585 行、#2507(人の判断待ちの行へマージの結果を書く・答えの後の整理)で 658 行(問い直しで利用者へ戻した印を外す分を含む)。500 行以下にしたら消す。保留の周回は sprint_loop_gate2.py へ分けてある", "lines": 658} From 1faa4cb484af2c0ddc4d012304af6f4c1486e8f3 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 07:26:36 +0000 Subject: [PATCH 036/127] =?UTF-8?q?fix(parallel):=20=E7=B6=9A=E3=81=91?= =?UTF-8?q?=E3=81=A6=E7=AD=94=E3=81=88=E3=81=9F=E3=81=A8=E3=81=8D=E3=80=81?= =?UTF-8?q?=E5=8F=97=E3=81=91=E6=89=8B=E3=81=8C=E6=9C=AA=E5=87=A6=E7=90=86?= =?UTF-8?q?=E3=81=AE=E7=AD=94=E3=81=88=E6=B8=88=E3=81=BF=E3=81=AE=E4=BE=9D?= =?UTF-8?q?=E9=A0=BC=E3=82=92=20metrics.after=20=E3=81=8B=E3=82=89?= =?UTF-8?q?=E9=99=A4=E3=81=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit send_answer は受け手の処理を待たないため、A・B へ続けて答えると B の after に A が残り、 答えの後の整理で答え済みの依頼を利用者へ問い直していた。送った答えを計画のディレクトリの answers-sent.json に残し、今の一覧に残るものを after から除く(同じ設計 PR の依頼は settled)。 Co-Authored-By: Claude Opus 5.5 --- .../plugins__ndf__scripts__parallel.py.json | 1 + .../contracts/files/answers-sent-json.json | 7 +++ plugins/ndf/contracts/index.md | 1 + plugins/ndf/scripts/lib/parallel_answer.py | 46 +++++++++++++++---- plugins/ndf/scripts/parallel.py | 4 +- .../ndf/scripts/tests/test_parallel_answer.py | 17 +++++++ 6 files changed, 65 insertions(+), 11 deletions(-) create mode 100644 plugins/ndf/contracts/files/answers-sent-json.json 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 e77b70365..1369d99b1 100644 --- a/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json +++ b/plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__parallel.py.json @@ -217,6 +217,7 @@ "stdout": "step-result", "writes": [ "answer-json", + "answers-sent-json", "approved-txt", "breakdown-md", "plan-json", diff --git a/plugins/ndf/contracts/files/answers-sent-json.json b/plugins/ndf/contracts/files/answers-sent-json.json new file mode 100644 index 000000000..8bcca361e --- /dev/null +++ b/plugins/ndf/contracts/files/answers-sent-json.json @@ -0,0 +1,7 @@ +{ + "name": "answers-sent-json", + "pattern": "answers-sent.json", + "kind": "state", + "place": "main", + "skill_readers": [] +} diff --git a/plugins/ndf/contracts/index.md b/plugins/ndf/contracts/index.md index f2b79e74f..f3f21246e 100644 --- a/plugins/ndf/contracts/index.md +++ b/plugins/ndf/contracts/index.md @@ -32,6 +32,7 @@ | --- | --- | --- | --- | --- | --- | | `actuals-run-json` | `actuals-run.json` | result | state-dir | `plugins/ndf/scripts/supervise.py` | — | | `answer-json` | `answer.json` | state | main | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | +| `answers-sent-json` | `answers-sent.json` | state | main | `plugins/ndf/scripts/parallel.py` | — | | `applied-json` | `*.applied.json` | state | state-dir | `plugins/ndf/scripts/context-policy.py`
`plugins/ndf/scripts/hook.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/skills/skill-stats/scripts/skill-stats.py` | — | | `apply-json` | `apply.json` | result | tmp | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` | — | | `approved-txt` | `approved.txt` | result | state-dir | `plugins/ndf/scripts/parallel.py`
`plugins/ndf/scripts/sprint-state.py`
`plugins/ndf/scripts/supervise.py` | — | diff --git a/plugins/ndf/scripts/lib/parallel_answer.py b/plugins/ndf/scripts/lib/parallel_answer.py index a3332a2cd..295f8d55d 100644 --- a/plugins/ndf/scripts/lib/parallel_answer.py +++ b/plugins/ndf/scripts/lib/parallel_answer.py @@ -39,17 +39,41 @@ def mvv_mark(q: dict) -> dict: return {"eligible": True, "why": f"レーンの判定が決まらなかった({q.get('verdict')})"} -def after_items(items: list[dict], answered: dict) -> list[dict]: - """答えの後に残る依頼の一覧(`metrics.after`)。今答えた依頼を除き、機械で決まる除外の印(drop)と MVV 判定の印(mvv)を付ける。 - drop: settled(今送った答えと同じ設計 PR の依頼)/ resend(同じレーンの同じ設計 PR に、より新しい先端の依頼がある)/ None。""" - rid = answered.get("id") - rest = [q for q in items if q.get("id") != rid and rid not in (q.get("also") or [])] +SENT = "answers-sent.json" # 送ったが耐久ワークフローがまだ一覧から外していない答え(計画のディレクトリに置く) + + +def sent_answers(main: Path, name: str, items: list[dict]) -> list[dict]: + """前に送った答えのうち、今の一覧にまだ残る依頼への答え。`send_answer` は受け手の処理を待たないため、続けて答えると + 一覧に答え済みの依頼が残る。一覧から消えた依頼(受け手が処理した)の答えは落とす。""" + import jsonio + + live = {i for q in items for i in (q.get("id"), *(q.get("also") or []))} + got = jsonio.read(pl.pp.plan_dir(main, name) / SENT, missing=[], broken=[], want=list) + return [s for s in got if isinstance(s, dict) and s.get("id") in live] + + +def remember_answers(main: Path, name: str, sent: list[dict]) -> None: + """送った答えの依頼(id・kind・lane・pr)を書き残す。次の `answer` の `metrics.after` から除くため。""" + import jsonio + + keep = [{k: s.get(k) for k in ("id", "kind", "lane", "pr")} for s in sent] + jsonio.write_atomic(pl.pp.plan_dir(main, name) / SENT, keep) + + +def after_items(items: list[dict], answered: dict, earlier: list[dict] | None = None) -> list[dict]: + """答えの後に残る依頼の一覧(`metrics.after`)。今答えた依頼と、前に送ったが受け手がまだ外していない答えの依頼(`earlier`)を除き、 + 機械で決まる除外の印(drop)と MVV 判定の印(mvv)を付ける。 + drop: settled(送った答えと同じ設計 PR の依頼)/ resend(同じレーンの同じ設計 PR に、より新しい先端の依頼がある)/ None。""" + done = [answered, *(earlier or [])] + ids = {d.get("id") for d in done} + rest = [q for q in items if q.get("id") not in ids and not ids & set(q.get("also") or [])] key = lambda q: (q.get("lane"), q.get("pr")) # noqa: E731 + settled = {key(d) for d in done if d.get("kind") == "gate1"} def drop(q: dict) -> str | None: if q.get("kind") != "gate1": return None - if answered.get("kind") == "gate1" and key(q) == key(answered): + if key(q) in settled: return "settled" if any( o is not q and o.get("kind") == "gate1" and key(o) == key(q) and str(o.get("at") or "") > str(q.get("at") or "") for o in rest @@ -95,12 +119,16 @@ def merge_design(main: Path, q: dict) -> dict | None: return design_pr_merge.merge_approved(main, int(q["pr"]), q.get("stamp")) -def answer_with_merge(main: Path, name: str, q: dict, result: str, **kw) -> tuple[str, dict]: +def answer_with_merge(main: Path, name: str, q: dict, result: str, items: list[dict], **kw) -> tuple[str, dict]: """依頼 `q` へ答えを送る。設計の承認なら、承認した設計 PR をマージしてから送る(成否によらず送る。#2507)。 - 返すのは `merge_note` の (要約へ足す 1 文, 出力へ足す `merge`)。""" + 返すのは (要約へ足す 1 文, 出力へ足す `merge`(`merge_note`)と `after`)。`after` は今の一覧 `items` から、今の答えと、 + 前に送ったが受け手がまだ外していない答えの依頼を除いたもの(`after_items`)。""" + earlier = sent_answers(main, name, items) merge = merge_design(main, q) if result == "approved" else None pl.send_answer(main, name, q["id"], result, merge=merge, **kw) - return merge_note(merge) + remember_answers(main, name, [*earlier, q]) + tail, extra = merge_note(merge) + return tail, {**extra, "after": after_items(items, q, earlier)} def merge_note(merge: dict | None) -> tuple[str, dict]: diff --git a/plugins/ndf/scripts/parallel.py b/plugins/ndf/scripts/parallel.py index 424ee3f57..91f71cee2 100644 --- a/plugins/ndf/scripts/parallel.py +++ b/plugins/ndf/scripts/parallel.py @@ -400,8 +400,8 @@ def cmd_answer(a) -> None: seq, items = pa.questions(main, a.name) 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, note=note, choice=a.choice, by=a.by) - extra.update(id=a.id, result=result, next=nxt, seq=seq, after=pa.after_items(items, q)) + 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) reply("ok", f"依頼 {a.id} へ答え({result})を送った{tail}", **extra) diff --git a/plugins/ndf/scripts/tests/test_parallel_answer.py b/plugins/ndf/scripts/tests/test_parallel_answer.py index fb7e37df7..37c7ef513 100644 --- a/plugins/ndf/scripts/tests/test_parallel_answer.py +++ b/plugins/ndf/scripts/tests/test_parallel_answer.py @@ -113,3 +113,20 @@ def test_revise_does_not_merge(cli, capsys, tmp_path): def test_by_mvv_needs_approved(cli, capsys): code, _ = answer(cli, capsys, "la-g501-a", "--rejected", "--by", "mvv") assert code == 1 and cli["sent"] == [] + + +def test_after_items_also_drop_answers_sent_before_the_workflow_took_them(): + a, b = gate1("la-g501-a"), gate1("lb-g601-b", lane="lb", pr=601) + items = [a, b, gate1("la-g501-old", at="2026-11-04T00:00:00Z"), {"kind": "ask", "id": "lc-q1", "lane": "lc"}] + after = {q["id"]: q for q in pa.after_items(items, b, earlier=[a])} + assert set(after) == {"la-g501-old", "lc-q1"} and after["la-g501-old"]["drop"] == "settled" + + +def test_answers_in_a_row_leave_the_earlier_one_out_of_after(cli, capsys): + cli["items"] = [gate1("la-g501-a"), gate1("lb-g601-b", lane="lb", pr=601)] + answer(cli, capsys, "la-g501-a", "--rejected") + _, out = answer(cli, capsys, "lb-g601-b", "--rejected") # 受け手はまだ a を一覧から外していない + assert out["metrics"]["after"] == [] + 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}] From 5af677d9b40f9f2d5526cb6c5ad4c39161c6e518 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 07:44:42 +0000 Subject: [PATCH 037/127] Release: ndf v10.27.21-dev.2 --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 4 ++++ README.md | 4 ++-- plugins/ndf/.claude-plugin/plugin.json | 4 ++-- plugins/ndf/.codex-plugin/plugin.json | 4 ++-- plugins/ndf/README.md | 6 +++--- 6 files changed, 14 insertions(+), 10 deletions(-) diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 42c6af24a..d5635c1bb 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.21-dev.1): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "description": "Claude Code plugin (v10.27.21-dev.2): 8 specialized agents (plus 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", "policy": { "installation": "AVAILABLE", "authentication": "ON_INSTALL" diff --git a/CHANGELOG.md b/CHANGELOG.md index 62a730a35..e2a91fea3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ ## [ndf 10.27.21] - 2026-11-05 +- 設計の承認に「承認」と答えると、その設計の PR がマージされる(#2542)(#2543) +- 開発ループの周回の境で、承認済みの設計の PR がマージし直される(#2542)(#2543) +- 承認に答えた後、その時点の整理結果が一覧で表示される(#2542)(#2543) +- 承認の答えの `--by` 引数が契約に含まれ、検査の対象になる(#2542)(#2543) ## [ndf 10.27.19] - 2026-11-05 diff --git a/README.md b/README.md index 95d207929..b2e6b06d1 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ Claude Code では `/ndf:` と打って呼びます。**Skill** は | プラグイン名 | バージョン | できること | 詳細 | | --- | --- | --- | --- | -| **ndf** | 10.27.21-dev.1 | 開発の作業手順(Skill)と専門のサブエージェント。Claude Code と Codex へ同じ名前で入る | [README](./plugins/ndf/README.md) | +| **ndf** | 10.27.21-dev.2 | 開発の作業手順(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.21-dev.1** に入る Skill の数は、AI ツールごとに違います。 +**NDFプラグイン v10.27.21-dev.2** に入る Skill の数は、AI ツールごとに違います。 Claude Code向け core 49個、Codex向け core 45個です。 元Skills(49個)の内訳は次のとおりです。 - Pull Request とレビュー (7): pr, pr-tests, fix, pr-review, cherry-pick-pr, deploy, merged diff --git a/plugins/ndf/.claude-plugin/plugin.json b/plugins/ndf/.claude-plugin/plugin.json index d83787f66..82984fa22 100644 --- a/plugins/ndf/.claude-plugin/plugin.json +++ b/plugins/ndf/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.21-dev.1", - "description": "Claude Code plugin (v10.27.21-dev.1): 8 specialized agents (plus 2 supervisor agents and 1 worker agent for the 3-layer workflow) and 49 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, statusline, external AI delegation (Codex/agy), transcript retention guard, and optional Slack notifications.", + "version": "10.27.21-dev.2", + "description": "Claude Code plugin (v10.27.21-dev.2): 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 6fe01e211..2acc54867 100644 --- a/plugins/ndf/.codex-plugin/plugin.json +++ b/plugins/ndf/.codex-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "ndf", - "version": "10.27.21-dev.1", - "description": "Codex plugin (v10.27.21-dev.1): 45 focused NDF skills for PR/review workflows, cross-review, implementation planning, plan-to-spec, Docker container access, external AI delegation (Codex/agy), and optional Slack completion notifications.", + "version": "10.27.21-dev.2", + "description": "Codex plugin (v10.27.21-dev.2): 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 d560d99e4..b433e1b5e 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.21-dev.1/skills/deploy/SKILL.md を読んで、その手順どおりに qa/staging へ deploy PR を作成してください。 +~/.codex/plugins/cache/ai-plugins/ndf/10.27.21-dev.2/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.21-dev.1/skills/deploy/SKILL.md +# ~/.codex/plugins/cache/ai-plugins/ndf/10.27.21-dev.2/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.21-dev.1 +# => ndf@ai-plugins installed, enabled 10.27.21-dev.2 ``` 抑止していない Skill(`document-style` など)はキャッシュ配下でも `$` で解決するため、そちらは `$` 起動が使えます。 From cf94aa6cee6705254bab49fd911d75edc8ae9ff9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 07:54:33 +0000 Subject: [PATCH 038/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=AB=E3=80=81=E6=89=BF=E8=AA=8D=E3=81=AE=E4=B8=8D?= =?UTF-8?q?=E7=9D=80=E3=83=BB=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=A7?= =?UTF-8?q?=E3=81=AE=E5=86=8D=E9=80=81=E3=83=BB=E5=89=8D=E3=81=AE=E3=83=AB?= =?UTF-8?q?=E3=83=BC=E3=83=97=E3=81=AE=E6=89=BF=E8=AA=8D=E5=BE=85=E3=81=A1?= =?UTF-8?q?=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 利用者の出し直しの指摘(2026-11-05)に合わせて、受け入れ条件 10〜13 と前提 7〜9 を足す。 - 承認がレーンのループの記録に当たらないときは、答えを捨てずにオーガナイザーへ知らせる - 取り込みのコミットだけで設計 PR の先頭が動いたときは、承認の依頼を送り直さない - 前のループの答えがまだの判断待ちを、計画の開始と周回の境で問いの一覧へ出す 自動の出し直しの上限(人の出し直し 1 回につき 1 回)は利用者の決定として前提 4 へ移し、未決から外す。 前提 1 に別のループで送った回も含めると明記し、設計の I1 との食い違いを未決に置く。 課題の本文は要求を書く前の版に戻っていたため、要求の全文を書き直した。ループが書く判断待ちの節は `## 進行` の後ろへ移し、写しの外に置いた。用語集に 3 語を足した。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 ++ docs/glossary/glossary.json | 18 +++++++ issues/issue-2510-requirements.md | 82 +++++++++++++++++++++++-------- 3 files changed, 83 insertions(+), 20 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index e5a7e0f97..e8acdf6d8 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -408,6 +408,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 答えの種類 | — | 人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ | — | — | — | | 自動の出し直し | — | 出し直しの設計が出し直しの指摘の項目を指摘への対応の表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | — | — | — | | 出し直しの指摘の入力 | — | 開発ループが周回ごとに、そのループでの課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む | — | — | — | +| 設計の承認の依頼 | — | 並列の計画のレーンが、承認ゲート 1 の判断をオーガナイザーへ求めて送る依頼。設計 PR の先頭の SHA を持つ | — | — | — | +| 取り込みのコミット | — | 起点ブランチを設計 PR のブランチへ取り込むマージコミット。これだけで先頭が動いた設計 PR は、承認の依頼を送り直さない | — | — | — | +| 前のループ | — | 同じリポジトリの開発ループのうち、並列の計画のレーンでないもの。その答えがまだの人の判断待ちは、オーガナイザーの問いの一覧に出す | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 84e6a2282..92aedc528 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5547,6 +5547,24 @@ "context": "ndf-workflow", "meaning": "開発ループが周回ごとに、そのループでの課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む", "pending_source": "issues/issue-2510-design.md" + }, + { + "term": "設計の承認の依頼", + "context": "ndf-workflow", + "meaning": "並列の計画のレーンが、承認ゲート 1 の判断をオーガナイザーへ求めて送る依頼。設計 PR の先頭の SHA を持つ", + "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "取り込みのコミット", + "context": "ndf-workflow", + "meaning": "起点ブランチを設計 PR のブランチへ取り込むマージコミット。これだけで先頭が動いた設計 PR は、承認の依頼を送り直さない", + "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "前のループ", + "context": "ndf-workflow", + "meaning": "同じリポジトリの開発ループのうち、並列の計画のレーンでないもの。その答えがまだの人の判断待ちは、オーガナイザーの問いの一覧に出す", + "pending_source": "issues/issue-2510-requirements.md" } ] } diff --git a/issues/issue-2510-requirements.md b/issues/issue-2510-requirements.md index 6ab50c6c9..59c15f861 100644 --- a/issues/issue-2510-requirements.md +++ b/issues/issue-2510-requirements.md @@ -4,24 +4,26 @@ ## 起票者の推定 -この課題の本文(下の「依頼(原文)」)は、開発ループの記録と設計 PR を調べた AI が書いた。利用者の指示の原文は本文に引用されていない(「利用者の指示で検討」「利用者の判断で差し戻し」の記述だけがある)。そのため、本文の条件はすべて起票者の推定として扱い、承認ゲート 1 で利用者が確かめる。 +この課題の本文(下の「依頼(原文)」)は、開発ループの記録と設計 PR を調べた AI が書いた。利用者の指示の原文は、本文の節「設計の承認ゲート 1 での利用者の指摘(直して出し直す)」(2026-11-05)だけである。そのため、その節に無い本文の条件は起票者の推定として扱い、承認ゲート 1 で利用者が確かめる。その節にある条件(「2026-11-05 の追加の形」の 3 つを要求に足すこと、自動の出し直しの上限)は利用者の指示として扱い、この表に載せない。 | # | 条件 | 推した出所 | この要求での扱い | | --- | --- | --- | --- | | S1 | 出し直しの指摘の全文を、次の周回の設計のプランの入力に入れる | 元の本文の受け入れ条件 1・直し方の案 1 | 受け入れ条件 1 | | S2 | 課題の本文の指摘の履歴は、次の周回で人の判断待ちへ移しても消えない | 元の本文の受け入れ条件 2・直し方の案 2 | 受け入れ条件 3 | | S3 | 出し直しの設計 PR の本文に、指摘ごとの対応の表を置く | 元の本文の受け入れ条件 3・直し方の案 3 | 受け入れ条件 4 | -| S4 | オーガナイザーは人に問う前に指摘と表を突き合わせ、届いていなければ人に問わずに直して出し直すへ戻す | 元の本文の受け入れ条件 4・直し方の案 B | 受け入れ条件 5(戻す上限は前提 4) | +| S4 | オーガナイザーは人に問う前に指摘と表を突き合わせ、届いていなければ人に問わずに直して出し直すへ戻す | 元の本文の受け入れ条件 4・直し方の案 B | 受け入れ条件 5(戻す上限は利用者の決定。前提 4) | | S5 | 答えの種類ごとに読んで動く手順を 1 つ決め、`references/parallel.md`・`loop.md` に書く | 元の本文の受け入れ条件 5・直し方の案 A | 受け入れ条件 7(予算と止めるは前提 6) | -| S6 | 出し直しの指摘を持つ課題では、要求の写しが一致しても要求のステップを流し、起動指示に指摘を入れる | 元の本文の受け入れ条件 6。#2494 の例 | 受け入れ条件 1・2 | -| S7 | MVV 判定の材料に指摘を入れ、指摘と食い違う設計を「従う」にしない | 元の本文の受け入れ条件 7。#2494 の例 | 受け入れ条件 6 | +| S6 | 出し直しの指摘を持つ課題では、要求の写しが一致しても要求のステップを流し、起動指示に指摘を入れる | 要求を書く前の本文の受け入れ条件 6。#2494 の例 | 受け入れ条件 1・2 | +| S7 | MVV 判定の材料に指摘を入れ、指摘と食い違う設計を「従う」にしない | 要求を書く前の本文の受け入れ条件 7。#2494 の例 | 受け入れ条件 6 | | S8 | 指示の通り道を 1 本にまとめる作り直し(案 C)は、A・B の効き目を確かめてから要否を決める | 元の本文の直し方の案 C | 含まない | -| S9 | 推奨は A と B を両方入れること | 元の本文の直し方の案の末尾 | 受け入れ条件 1〜7 の範囲として採る | +| S9 | 推奨は A と B を両方入れること | 元の本文の直し方の案の末尾。利用者の指摘(2026-11-05)が「A・B の部分はこのままでよい」と答えた | 受け入れ条件 1〜7 の範囲として採る | | S10 | #2338(PR #2497)を差し戻し、この課題を直した後に選び直す。#2344(PR #2482)も次に出し直されたら差し戻す | 元の本文の「今の回避」の 2026-11-05 の結果(「利用者の判断で」と書かれている) | 含まない(ループの運用であり、この課題の変更ではない) | +| S11 | 取り込みのコミットだけの違いなら、承認を引き継ぐ | 元の本文の追加の形(#2526)。利用者の指摘の (b) が同じ条件を指示した | 受け入れ条件 11。「取り込みのコミットだけ」の判定は前提 7 | +| S12 | 前のループの承認待ちは、計画を始めるときと周回の境でオーガナイザーへ送る | 利用者の指摘の (c)。「前のループ」の範囲は起票者の推定 | 受け入れ条件 12。範囲は前提 8 | ## 依頼(原文) -書き手: 起票者(開発ループの記録と設計 PR を調べた AI)、日付: 2026-11-05。下は要求を書く前の課題の本文の全文である。 +書き手: 起票者(開発ループの記録と設計 PR を調べた AI)、日付: 2026-11-05。下は要求を書く前の課題の本文の全文である。利用者の指示の原文(書き手: 利用者、オーガナイザーが写した、2026-11-05)は、この本文の末尾の節「設計の承認ゲート 1 での利用者の指摘(直して出し直す)」にあり、ここへは写さない。 > ## 何が起きたか > @@ -90,21 +92,30 @@ > > #2338 と #2344 の本文に、節「設計の承認ゲート 1 での利用者の指摘(直して出し直す)」として指摘を写した。この節は上書きされない。設計の手順は課題の本文を読むので、これで届く見込みである。 > - 2026-11-05 の結果: 本文の節に書き込んでも、#2338 の 3 回目の出し直し(lroot3 周回 11)は範囲が元のままだった。設計の手順は課題の本文の節も読んでいない。利用者の判断で #2338(PR #2497)は差し戻し、この課題を直した後に選び直す。#2344(PR #2482)も次に出し直されたら差し戻す +> - 2026-11-05 の追加の形: **承認がレーンの記録に当たらない。** #2474 の承認(`parallel.py answer --approved`、01:3xZ)が lroot3 の `loop.json` に入らず、「人の判断待ち・答え無し」のまま lroot3 は `waiting_only` で止まった。オーガナイザーが `loop answer lroot3 2474 --approved --pr 2501` を直接打って回避した +> - 2026-11-05 の追加の形: **マージで先頭が変わると承認の依頼が再送される。** オーガナイザーが develop を取り込んでマージした設計 PR(#2526)について、レーンは先頭の SHA の違いを新しい設計と見て、承認の依頼を再送した。取り込みのコミットだけの違いなら、承認を引き継ぐ形がよい +> - 2026-11-05 の追加の形: **計画の前のループの承認待ちがオーガナイザーに届かない。** ループ lroot2(計画 p3 の前)で承認待ちになった設計 PR 4 本(#2412・#2404・#2403・#2410、11/02 から)は、計画 p3 の `questions` に一度も出なかった。lroot3 はそれを「判断待ち」として除外し続け、柱の土台 #2337 が止まったまま後続の 4 本も実装で止まった。オーガナイザーが記録を読んで見つけ、利用者に問うた ## 目的 - 人が承認ゲート 1 で「直して出し直す」と答えて送った指摘を、次の周回の設計が必ず読み、指摘ごとに直したか・直せない理由を示した状態で人へ戻す - 指摘が届いていない出し直しの設計を、人が読む前に機械と AI が見つけて戻す。人が同じ指摘を 2 回送ることを無くす - 人の答え(承認・直して出し直す・やらない・予算・止める)ごとに、その答えを読んで動く手順を 1 つに決める +- 並列の計画で、オーガナイザーが送った承認がレーンのループの記録に必ず当たり、当たらなかったときはオーガナイザーが知る +- 取り込みのコミットだけで設計 PR の先頭が動いても、同じ設計の承認を人へ問い直さない +- 計画を始める前のループで承認待ちになった設計を、オーガナイザーの問いの一覧に出し、人の判断が要る設計を埋もれさせない ## 前提 -- 前提 1: 設計のプランに渡す指摘は、その課題について送られた全回の出し直しの指摘とする(最新の 1 回だけにしない)。前の回の指摘を満たした状態を、次の回の設計で崩さないためである +- 前提 1: 設計のプランに渡す指摘は、その課題について送られた全回の出し直しの指摘とする(最新の 1 回だけにしない)。前の回の指摘を満たした状態を、次の回の設計で崩さないためである。別の開発ループで送った回も含める。ループが替わると前のループの指示が届かない形は、この課題の受け入れ条件 12 と同じ根を持つためである - 前提 2: 指摘の項目は、`--note` に渡したファイルの箇条書き(`-` / `*`)と番号付きの行を 1 項目として数える。どちらも無ければ全文を 1 項目とする。「指摘への対応」の表はこの項目ごとに 1 行を持つ - 前提 3: 出し直しの指摘と「指摘への対応」の表の突き合わせ(受け入れ条件 5)は、人の判断待ちの行へ移す共通の経路で行う。並列の計画のオーガナイザーが問う場合と、並列でない開発ループの conductor が問う場合の両方に効く(Value 7。直す場所を経路ごとに分けない) -- 前提 4: 人へ問わずに直して出し直すへ自分で戻すのは、同じ課題・同じ指摘の回につき 1 回までとする。2 回目も届いていない項目が残れば、届いていない項目を添えて人へ問う(Value 3。役に立たない繰り返しを始めない) +- 前提 4(利用者の決定、2026-11-05): 人へ問わずに直して出し直すへ自分で戻すのは、人の出し直し 1 回につき 1 回までとする。2 回目も届いていない項目が残れば、届いていない項目を添えて人へ問う - 前提 5: 課題の本文の「出し直しの指摘の履歴」の節を書けなかった(GitHub の書き込みの失敗)ときも、ループの記録の人の判断待ちの行に指摘が残り、設計のプランは記録から指摘を読む。本文の節は人が読むための写しで、設計のプランの入力の正本はループの記録とする - 前提 6: 予算の答えは、既にある `loop start --budget-*` / `sprint-state.py loop budget`(予算の反映)が読んで動く手順とし、この課題で新しい経路を作らない。止めるの答えは、既にある `loop stop` が置く止める指示を周回の境で読む手順とする +- 前提 7: 「取り込みのコミットだけの違い」は、問いの時点の先頭から今の先頭までに足されたコミットがすべて起点ブランチ(`.ndf/worktree.json` の `base_branch`)を取り込むマージコミットで、かつ設計 PR が変える設計文書と要求の写し(`issues/issue-<番号>-*.md`)の中身が問いの時点の先頭と同じであることで判定する。判定できない(照会の失敗・履歴を辿れない)ときは、今どおり問い直す +- 前提 8: 「前のループ」は、同じリポジトリの開発ループの記録(`.ndf/loop/<名>/loop.json`)のうち、計画のレーンでないループすべてとする。名前の付け方や順序で前後を決めない。レーンのループが「人の判断待ち」を理由に選定から外す課題の待ちは、どのループの記録にあっても対象に入る +- 前提 9: #2474 の承認がレーンの記録に当たらなかった原因は、この要求の時点で特定していない。依頼の id の打ち直し(設計 PR の先頭が動いたときに依頼を送り直すと、行の依頼の id が新しい id に置き換わり、古い id への答えが行に当たらない)が候補の 1 つである。受け入れ条件 10 は原因によらず観測できる形で書き、原因の特定は設計で行う ## 対象範囲 @@ -116,6 +127,9 @@ - 人の判断待ちの行へ移す前に、出し直しの指摘と「指摘への対応」の表を突き合わせ、届いていない項目があれば直して出し直すへ戻す - 承認ゲート 1 の MVV 判定(`mvv-gate.py check --gate design`)の材料へ出し直しの指摘を入れる - 答えの種類ごとの、読んで動く手順の表を `development-workflow` の `references/parallel.md`・`references/loop.md` に書く +- オーガナイザーの答え(`parallel.py answer`)がレーンのループの記録に当たらないときに、黙って捨てずにオーガナイザーへ知らせる +- 取り込みのコミットだけで設計 PR の先頭が動いたときに、設計の承認の依頼を送り直さず、問いの時点の依頼への答えを当てる +- 計画を始めるときと周回の境で、前のループの答えがまだの人の判断待ちを、オーガナイザーの問いの一覧へ送る 含まない: - 承認・指摘・止める・予算を種類つきの 1 つのメッセージにまとめる作り直し(元の本文の直し方の案 C)。この課題の効き目を確かめてから要否を決める @@ -123,13 +137,15 @@ - 出し直しの作業ブランチが設計 PR の最新から始まらない点(#2489 が持つ) - #2338(PR #2497)・#2344(PR #2482)の選び直しと差し戻し。この課題の配布の後に、利用者が選び直す - 答えが届く前にレーンが同じ問いを送る行き違い(元の本文の表の 3 行目)。答えの読み手を決める範囲の外である +- 前のループの記録を、計画のレーンへ移す・合わせること。問いの一覧へ出すだけで、答えは今どおりその記録のループへ当てる +- 設計 PR の本文のテストの欄の書き直し(利用者の指摘の 3)。要求ではなく、設計 PR を出し直す手順で行う ## ドメインイベント | # | イベント | 引き金 | 失敗したとき | 順序の前提 | | --- | --- | --- | --- | --- | | E1 | 人が直して出し直すの答えを送った | `sprint-state.py loop answer --revise --note <ファイル>` か `parallel.py answer <計画名> <依頼> --revise --note <ファイル>` | ファイルを読めなければ答えを拒み、記録を書かない(今の振る舞い) | 課題が人の判断待ちの行にある | -| E2 | 答えを人の判断待ちの行へ当てた(指摘の全文と回数を書いた) | 並列でない開発ループでは E1 の場で、並列の計画ではレーンが周回の境で | 記録を書けなければ答えを当てない。次の周回の境で当て直す | E1 | +| E2 | 答えを人の判断待ちの行へ当てた(指摘の全文と回数を書いた) | 並列でない開発ループでは E1 の場で、並列の計画ではレーンが周回の境で | 記録を書けなければ答えを当てない。次の周回の境で当て直す。並列の計画で当てる行が見つからなければ E12 | E1 | | E3 | 課題の本文の「出し直しの指摘の履歴」の節へ、その回の指摘を追記した | E2 | 書けなければ理由を結果の `note` に残す。設計のプランは記録から読むので止めない(前提 5) | E2 | | E4 | 次の周回で課題を先頭に選んだ | 周回の選定(直して出し直す課題を先頭に選ぶ今の振る舞い) | — | E2 | | E5 | 設計のプランを組み、出し直しの指摘の全回分を要求と設計のステップの起動指示へ入れた | E4 | 記録から指摘を読めなければプランを組まずに止める | E4 | @@ -138,6 +154,11 @@ | E8 | MVV 判定が、出し直しの指摘を材料に含めて判定した | 承認ゲート 1 の手前の MVV 判定 | 判定できなければ人へ問う(今の振る舞い) | E7 | | E9 | 出し直しの指摘と「指摘への対応」の表を突き合わせた | 人の判断待ちの行へ移す前 | 届いていない項目があれば、その項目を名指しして直して出し直すへ戻す(前提 4 の上限まで) | E7・E8 | | E10 | 課題を人の判断待ちの行へ移し、判断待ちの節を書き直した(履歴の節は残る) | E9 で項目がそろった、か前提 4 の上限に達した | 本文を書けなければ理由を結果に残す(今の振る舞い) | E9 | +| E11 | レーンが設計の承認の依頼をオーガナイザーへ送った | E10(並列の計画のレーンのとき) | 送れなければ今どおり人の判断待ちのまま(`loop answer` で答える) | E10 | +| E12 | オーガナイザーの答えを、レーンのループの記録の行へ当てた | レーンの周回の境、か `waiting_only` で止まったレーンを答えで起こしたとき | 当てる行が見つからない・当てられないときは、依頼と理由をオーガナイザーの `wait` に出す。答えは捨てずに残し、次の境で当て直す | E11 | +| E13 | 設計 PR の先頭が問いの時点から動いたのを見つけた | E12 の手前(承認の答えを当てる前) | 先頭を読めなければ、次の境で読み直す(今の振る舞い) | E11 | +| E14 | 動いた分が取り込みのコミットだけかを判定した | E13 | 判定できなければ問い直す(前提 7) | E13 | +| E15 | 前のループの答えがまだの人の判断待ちを、オーガナイザーの問いの一覧へ送った | 計画を始めたとき・レーンの周回の境 | 記録を読めないループは飛ばし、飛ばしたループの名前と理由をオーガナイザーの `wait` に出す | 計画のレーンが 1 つ以上ある | ## 用語 @@ -148,6 +169,9 @@ | 指摘への対応の表 | 出し直しの設計 PR の本文に置く表。出し直しの指摘の項目ごとに、直した / 直せない、直した箇所か直せない理由を 1 行で持つ | | 設計のプラン | 開発ループの周回が課題ごとに組む、設計のフェーズのプラン(`sprint/<番号>-design-<課題>.json`)。要求・設計・レビュー・承認ゲート 1 のステップを持つ | | 答えの種類 | 人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ | +| 設計の承認の依頼 | 並列の計画のレーンが、承認ゲート 1 の判断をオーガナイザーへ求めて送る依頼。設計 PR の先頭の SHA を持つ | +| 取り込みのコミット | 起点ブランチを設計 PR のブランチへ取り込むマージコミット。判定は前提 7 | +| 前のループ | 同じリポジトリの開発ループのうち、計画のレーンでないもの(前提 8) | ## 受け入れ条件 @@ -155,26 +179,30 @@ - [ ] 2. 出し直しの指摘を持つ課題の設計のプランでは、要求の写しが課題の本文と一致して `requirements-check` が 0 を返しても、要求のステップが流れる(テストで確かめる) - [ ] 3. 直して出し直すの答えを当てるたびに、課題の本文の「出し直しの指摘の履歴」の節へ、回数・日付・設計 PR・指摘の全文が 1 回分ずつ追記される。その課題を次の周回で人の判断待ちへ移して判断待ちの節を書き直した後も、前の回の指摘が本文に残る(テストで確かめる) - [ ] 4. 出し直しの設計 PR の本文に「指摘への対応」の表があり、出し直しの指摘の項目(前提 2)ごとに 1 行を持つ -- [ ] 5. 出し直しの設計を人の判断待ちの行へ移す前に、出し直しの指摘の項目と「指摘への対応」の表を突き合わせる。表が無い・行の無い項目がある・対応の欄が空の項目があるときは、人に問わずに、届いていない項目を名指しした直して出し直すへ戻す。戻すのは前提 4 の上限までで、上限に達したら届いていない項目を添えて人へ問う(テストで確かめる) +- [ ] 5. 出し直しの設計を人の判断待ちの行へ移す前に、出し直しの指摘の項目と「指摘への対応」の表を突き合わせる。表が無い・行の無い項目がある・対応の欄が空の項目があるときは、人に問わずに、届いていない項目を名指しした直して出し直すへ戻す。戻すのは人の出し直し 1 回につき 1 回まで(前提 4)で、上限に達したら届いていない項目を添えて人へ問う(テストで確かめる) - [ ] 6. 出し直しの指摘を持つ課題の承認ゲート 1 の MVV 判定(`mvv-gate.py check --gate design`)の材料に、出し直しの指摘の全文が入る。判定の問いは、指摘の項目を満たさない設計を「従う」にしないことを含む(テストで確かめる) - [ ] 7. `references/parallel.md` と `references/loop.md` に、答えの種類(承認・直して出し直す・やらない・予算・止める)ごとに、その答えを読んで動く手順を 1 つずつ示す表がある。両方の文書で同じ表を写さず、片方が正本で、もう片方は正本を指す - [ ] 8. 出し直しの指摘を持たない課題の設計のプランは変わらない。要求の写しが一致すれば要求のステップを飛ばし、起動指示に指摘の欄が入らない(テストで確かめる) - [ ] 9. 直して出し直す以外の答え(承認・やらない)を当てたときの、承認の記録・ラベル・人の判断待ちの節の振る舞いは変わらない(既存のテストが通る) +- [ ] 10. `parallel.py answer <計画名> <依頼> --approved` を送ると、持ち主のレーンの次の周回の境で、そのレーンのループの記録の該当する人の判断待ちの行に承認が当たる。レーンの記録が `waiting_only` で止まっていても当たる。設計 PR の先頭が動いて依頼を送り直した後に、送り直す前の依頼へ答えた場合も、取り込みのコミットだけの違い(受け入れ条件 11)なら当たる。当てる行が見つからないときは、答えを捨てずに、依頼の id と理由がオーガナイザーの `parallel.py wait` の出力に出る(テストで確かめる) +- [ ] 11. 設計の承認の依頼を送った後に設計 PR の先頭が動いても、動いた分が取り込みのコミットだけ(前提 7)なら、レーンは依頼を送り直さず、問いの時点の依頼への答えを当てる。設計文書か要求の写しを変えるコミットが含まれるときと、判定できないときは、今どおり新しい先頭で問い直す(テストで確かめる) +- [ ] 12. 計画を始めるときとレーンの周回の境で、前のループ(前提 8)の記録にある答えがまだの人の判断待ちの行が、オーガナイザーの問いの一覧(`parallel.py wait` の `questions`)に、ループの名前・課題・設計 PR を添えて出る。同じ行を 2 回出さない。その問いへの答えは、その行を持つループの記録へ当たる(テストで確かめる) +- [ ] 13. レーンのループが「人の判断待ち」を理由に選定から外した課題は、その待ちを持つ行がオーガナイザーの問いの一覧に出ている(受け入れ条件 12 の行と同じものか、レーン自身の依頼)。問いの一覧に出ていない待ちを理由に外し続けることが無い(テストで確かめる) ## 非機能の条件 | 大項目 | 条件 | | --- | --- | -| 運用・保守性 | 出し直しの指摘を読む処理と、指摘の項目を数える処理は、並列でない開発ループと並列の計画のレーンで 1 つの関数を共有する(Value 7) | -| 移行性 | 既にある課題の本文の「人の判断待ち(開発ループ <名>)」の節に残る指摘は移さない。この変更の後に当てた答えから履歴の節へ書く。ループの記録の既存の `note` は、設計のプランの入力として読める | +| 運用・保守性 | 出し直しの指摘を読む処理と、指摘の項目を数える処理は、並列でない開発ループと並列の計画のレーンで 1 つの関数を共有する(Value 7)。取り込みのコミットだけかの判定は 1 つの関数に置き、依頼を送り直すかの判断と答えを当てるかの判断の両方がそれを使う | +| 移行性 | 既にある課題の本文の「人の判断待ち(開発ループ <名>)」の節に残る指摘は移さない。この変更の後に当てた答えから履歴の節へ書く。ループの記録の既存の `note` は、設計のプランの入力として読める。既にある前のループの答えがまだの行は、この変更の後に計画を始めたときか次の周回の境で、問いの一覧に出る | ## 影響 | 対象 | 影響 | | --- | --- | -| 公開インタフェース | 変わらない。`loop answer`・`parallel.py answer` の引数は今のまま。ループの記録の人の判断待ちの行は欄を足すだけで、既存の欄の意味を変えない | -| データ | ループの記録(`.ndf/loop/<名>/loop.json`)の人の判断待ちの行に、自分で戻した回数などの欄が足りうる(形は設計で決める)。課題の本文に履歴の節が増える | -| 既存の振る舞い | 出し直しの指摘を持つ課題の設計のプランの要求のステップ・起動指示・MVV 判定の材料・人の判断待ちへ移す経路が変わる | +| 公開インタフェース | 変わらない。`loop answer`・`parallel.py answer` の引数は今のまま。ループの記録の人の判断待ちの行は欄を足すだけで、既存の欄の意味を変えない。`parallel.py wait` の出力は、当たらなかった答えと前のループの問いの分だけ項目が増える | +| データ | ループの記録(`.ndf/loop/<名>/loop.json`)の人の判断待ちの行に、自分で戻した回数・前の依頼の id などの欄が足りうる(形は設計で決める)。課題の本文に履歴の節が増える | +| 既存の振る舞い | 出し直しの指摘を持つ課題の設計のプランの要求のステップ・起動指示・MVV 判定の材料・人の判断待ちへ移す経路が変わる。設計 PR の先頭が動いたときの依頼の送り直しの条件が変わる。オーガナイザーの問いの一覧に、前のループの待ちが加わる | ## 検証手段 @@ -182,7 +210,7 @@ | --- | --- | | テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4` | | 静的解析 | CI のジョブ(`ruff` ほか、リポジトリの既存の設定) | -| 手動確認 | 配布の後、開発ループで出し直しの指摘を 1 回送り、次の周回の設計 PR に「指摘への対応」の表があり、指摘の項目が設計に反映されたことを人が読んで確かめる(リリース後テスト) | +| 手動確認 | 配布の後、開発ループで出し直しの指摘を 1 回送り、次の周回の設計 PR に「指摘への対応」の表があり、指摘の項目が設計に反映されたことを人が読んで確かめる。並列の計画で、承認の答えがレーンの記録に当たること、起点ブランチを取り込んだ設計 PR で承認の依頼が送り直されないこと、前のループの待ちが `parallel.py wait` に出ることを確かめる(リリース後テスト) | ## 前提とする取り決め @@ -190,15 +218,15 @@ | --- | --- | | プロジェクト構造 | スクリプトは `plugins/ndf/scripts/`(ライブラリは `lib/`・`supervise_lib/`)、手順書は `plugins/ndf/skills/development-workflow/references/`。実験版の置き場は使わない(既定で働く経路を直すため、`AGENTS.md` の「安定版と実験版」の安定版) | | コーディング規約 | `AGENTS.md` の「最小限のコード実装」。`.md` の文言を照合するテストは書かない(受け入れ条件 7 は文書のレビューで確かめる) | -| テスト戦略 | 受け入れ条件 1〜3・5・6・8 はスクリプトの単体テスト(`plugins/ndf/scripts/tests/`)で、外部の `gh` と CLI は差し替えて確かめる | +| テスト戦略 | 受け入れ条件 1〜3・5・6・8・10〜13 はスクリプトの単体テスト(`plugins/ndf/scripts/tests/`)で、外部の `gh`・`git` と CLI は差し替えて確かめる | ## 境界 | 区分 | 内容 | | --- | --- | | 常に行う | 既存のテストの実行、変更範囲内の語の統一(用語集の語に合わせる) | -| 確認してから行う | ループの記録の欄を足すこと(承認ゲート 1 の設計で人が見る) | -| 行わない | 指示の通り道を 1 本にまとめる作り直し、#2507・#2489 が持つ範囲の変更、既存の課題の本文の書き換え | +| 確認してから行う | ループの記録の欄を足すこと、`parallel.py wait` の出力に項目を足すこと(承認ゲート 1 の設計で人が見る) | +| 行わない | 指示の通り道を 1 本にまとめる作り直し、#2507・#2489 が持つ範囲の変更、既存の課題の本文の書き換え、前のループの記録の書き換え(答えを当てる以外) | ## 未決 @@ -206,4 +234,18 @@ | --- | --- | --- | | 「指摘への対応」の表を書く担い手(設計のステップの worker が設計 PR の本文へ書くか、設計文書に書いて PR の本文へ写すか) | 設計(`design`) | 承認ゲート 1 | | 突き合わせ(受け入れ条件 5)のうち、表の行がそろっていても中身が指摘を満たさない場合の扱い。受け入れ条件 6 の MVV 判定に任せるか、別の判定を足すか | 設計(`design`) | 承認ゲート 1 | -| 前提 4 の自分で戻す上限(1 回)の妥当性 | 利用者(承認ゲート 1) | 承認ゲート 1 | +| #2474 の承認がレーンの記録に当たらなかった原因(前提 9)。記録と依頼の id を読んで特定し、受け入れ条件 10 の直し方を決める | 設計(`design`) | 承認ゲート 1 | +| 前提 1 の「別の開発ループで送った回も含める」に合わせて、出し直しの指摘の入力の回の取り方と回の識別子(ループの名前と回数の組)を決め直す。設計の不変条件 I1 は、いま回っているループの回に限っている | 設計(`design`) | 承認ゲート 1 | +| 前のループの待ちを問いの一覧へ送る担い手(オーガナイザーが計画を始めるときに読むか、各レーンが周回の境で読むか)と、同じ行を 2 回出さない方法 | 設計(`design`) | 承認ゲート 1 | + +## 設計の承認ゲート 1 での利用者の指摘(直して出し直す) + +(書き手: 利用者の答えをオーガナイザーが写した、日付: 2026-11-05。出し直しの設計はこの節を満たすように直す。この節は上書きしない) + +利用者の判断(2026-11-05): 直して出し直す。A・B の部分はこのままでよい。 +1. 課題 #2510 の本文の「2026-11-05 の追加の形」の 3 つを、要求と設計に足す: + (a) 承認がレーンの記録に当たらない(#2474 の承認が lroot3 の loop.json に入らず waiting_only で止まった。オーガナイザーが loop answer を直接打って回避) + (b) 取り込みのコミットだけで設計 PR の先頭が変わると、承認の依頼が再送される(#2526)。取り込みのコミットだけの違いなら承認を引き継ぐ + (c) 計画の前のループ(lroot2)の承認待ちが、計画 p3 の questions に出ない(#2412・#2404・#2403・#2410 が 11/02 から埋もれ、柱の土台 #2337 が止まった)。計画を始めるときと周回の境で、レーンの前のループの承認待ちをオーガナイザーへ送る +2. 自動で出し直す上限は、人の出し直し 1 回につき 1 回で決める(利用者の決定)。「利用者が決めること」から外し、決定の記録へ移す +3. PR 本文のテストの欄(requirements-check)を、再確認の結果(ok)に直す From b783c915dca941c7d74c07b1873574796b0d80c9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 08:03:03 +0000 Subject: [PATCH 039/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=AB=E3=80=81=E6=89=BF=E8=AA=8D=E3=81=AE=E4=B8=8D?= =?UTF-8?q?=E7=9D=80=E3=83=BB=E5=8F=96=E3=82=8A=E8=BE=BC=E3=81=BF=E3=81=A7?= =?UTF-8?q?=E3=81=AE=E5=86=8D=E9=80=81=E3=83=BB=E5=89=8D=E3=81=AE=E3=83=AB?= =?UTF-8?q?=E3=83=BC=E3=83=97=E3=81=AE=E6=89=BF=E8=AA=8D=E5=BE=85=E3=81=A1?= =?UTF-8?q?=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 要求に足した受け入れ条件 10〜13 と前提 1・4・7〜9 に合わせて設計を直す。 - #2474 の承認が当たらなかった原因を記録から確かめた。答えは耐久の記録に届いていたが、 waiting_only で止まったループのレーンが答えの後に一度も起こされなかった。依頼の id の打ち直しではない - 止まったループのレーンも起こす対象に入れ、当てられない答えを区分 unmatched の問いにする(決定 9) - 取り込みのコミットだけかの判定を承認の依頼の部品の 1 関数に置き、問い直しと答えの当て方が共有する(決定 10) - 前のループの待ちを耐久ワークフローが開始時に、レーンが周回の境に送り、答えはそのループの loop answer で当てる(決定 11) - 出し直しの指摘の入力を、すべてのループの記録から集める形へ改める(決定 8。前提 1) 自動の出し直しの上限は利用者の決定になったため、未確認の表から外した。用語集に 1 語を足し、1 語の意味を改めた。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 +- docs/glossary/glossary.json | 8 +- issues/issue-2510-design.md | 348 ++++++++++++++++++++++++++++++------ 3 files changed, 305 insertions(+), 54 deletions(-) diff --git a/docs/glossary.md b/docs/glossary.md index e8acdf6d8..e29bb96e5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -407,10 +407,11 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 設計のプラン | — | 開発ループの周回が課題ごとに組む、設計のフェーズのプラン(`sprint/<番号>-design-<課題>.json`)。要求・設計・レビュー・承認ゲート 1 のステップを持つ | — | — | — | | 答えの種類 | — | 人が承認ゲートや問いに返す答えの区分。承認・直して出し直す・やらない・予算・止めるの 5 つ | — | — | — | | 自動の出し直し | — | 出し直しの設計が出し直しの指摘の項目を指摘への対応の表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | — | — | — | -| 出し直しの指摘の入力 | — | 開発ループが周回ごとに、そのループでの課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む | — | — | — | +| 出し直しの指摘の入力 | — | 開発ループが周回ごとに、同じリポジトリのすべてのループの記録から、課題の全回の出し直しの指摘を書き出したファイル。回はループの名前と回数の組で識別する。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む | — | — | — | | 設計の承認の依頼 | — | 並列の計画のレーンが、承認ゲート 1 の判断をオーガナイザーへ求めて送る依頼。設計 PR の先頭の SHA を持つ | — | — | — | | 取り込みのコミット | — | 起点ブランチを設計 PR のブランチへ取り込むマージコミット。これだけで先頭が動いた設計 PR は、承認の依頼を送り直さない | — | — | — | | 前のループ | — | 同じリポジトリの開発ループのうち、並列の計画のレーンでないもの。その答えがまだの人の判断待ちは、オーガナイザーの問いの一覧に出す | — | — | — | +| 当たらなかった答え | — | オーガナイザーの答えのうち、持ち主のループの記録の行へ当てられなかったもの。答えは耐久の記録に残し、区分 unmatched の問いとしてオーガナイザーの問いの一覧に出す | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 92aedc528..bdee1e4d7 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5545,7 +5545,7 @@ { "term": "出し直しの指摘の入力", "context": "ndf-workflow", - "meaning": "開発ループが周回ごとに、そのループでの課題の全回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む", + "meaning": "開発ループが周回ごとに、同じリポジトリのすべてのループの記録から、課題の全回の出し直しの指摘を書き出したファイル。回はループの名前と回数の組で識別する。設計のプラン・MVV 判定・指摘への対応の表の突き合わせが読む", "pending_source": "issues/issue-2510-design.md" }, { @@ -5565,6 +5565,12 @@ "context": "ndf-workflow", "meaning": "同じリポジトリの開発ループのうち、並列の計画のレーンでないもの。その答えがまだの人の判断待ちは、オーガナイザーの問いの一覧に出す", "pending_source": "issues/issue-2510-requirements.md" + }, + { + "term": "当たらなかった答え", + "context": "ndf-workflow", + "meaning": "オーガナイザーの答えのうち、持ち主のループの記録の行へ当てられなかったもの。答えは耐久の記録に残し、区分 unmatched の問いとしてオーガナイザーの問いの一覧に出す", + "pending_source": "issues/issue-2510-design.md" } ] } diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md index 7935e0e77..aed2c31ae 100644 --- a/issues/issue-2510-design.md +++ b/issues/issue-2510-design.md @@ -1,15 +1,23 @@ -# 開発ループ: 直して出し直すの指摘を次の周回の設計が読み、届いたかを人へ問う前に確かめる(#2510) +# 開発ループと並列の計画: 人の答え(出し直しの指摘・承認・前のループの待ち)を、読んで動く側へ確実に届ける(#2510) ## 目的 -- **今どうなっているか**: 承認ゲート 1 で人が「直して出し直す」と答えて送った指摘が、次の周回の設計のプランの入力に入らない。課題の本文に書いた指摘も、次に人の判断待ちへ移すときの書き直しで消える。#2344・#2338 では同じ指摘を 2 回送っても設計が変わらなかった -- **誰に効くか**: 開発ループと並列の計画で承認ゲート 1 に答える利用者 -- **変えると何が成り立つか**: 次の周回の設計がすべての回の指摘を読み、指摘の項目ごとに直したか・直せない理由を設計 PR の本文に示す。示していない出し直しは、人へ問う前に機械が見つけて 1 回だけ自動で戻す。答えの種類ごとに、読んで動く手順が 1 つに決まる +- **今どうなっているか**: 承認ゲート 1 で人が「直して出し直す」と答えて送った指摘が、次の周回の設計のプランの入力に入らない。課題の本文に書いた指摘も、次に人の判断待ちへ移すときの書き直しで消える。#2344・#2338 では同じ指摘を 2 回送っても設計が変わらなかった。並列の計画では、ほかに 3 つの形で答えや待ちが落ちた + - オーガナイザーが送った承認が、止まっていたレーンのループの記録に当たらなかった(#2474) + - 起点ブランチを取り込んだだけの設計 PR について、承認の依頼が送り直された(#2526) + - 計画を始める前のループで承認待ちになった設計 PR 4 本が、問いの一覧に一度も出なかった(ループ lroot2) +- **誰に効くか**: 開発ループと並列の計画で承認ゲート 1 に答える利用者と、並列の計画を回すオーガナイザー +- **変えると何が成り立つか** + - 次の周回の設計が、すべての回の指摘を読む。指摘の項目ごとに、直したか・直せない理由を設計 PR の本文に示す + - 示していない出し直しは、人へ問う前に機械が見つけて 1 回だけ自動で戻す + - 答えの種類ごとに、読んで動く手順が 1 つに決まる + - オーガナイザーの答えは捨てられず、当たらなければオーガナイザーが知る + - 取り込みのコミットだけの違いでは問い直さない。前のループの待ちも問いの一覧に出る ## 適用範囲 -- **働く範囲**: 配布先のリポジトリでも働く。開発ループ(`--loop`)と並列の計画(`--parallel`)のレーンの両方で同じ経路を通る -- **プロジェクトごとに違うもの**: 無し。指摘の項目の数え方と自動で戻す上限(1 回)は固定で、設定を足さない +- **働く範囲**: 配布先のリポジトリでも働く。開発ループ(`--loop`)と並列の計画(`--parallel`)のレーンの両方で同じ経路を通る。承認の届け方・前のループの待ちは、並列の計画を回すときだけ働く +- **プロジェクトごとに違うもの**: 起点ブランチだけで、`.ndf/worktree.json` の `base_branch` から読む(取り込みのコミットの判定)。指摘の項目の数え方と、自動で戻す上限(1 回)は固定で、設定を足さない - **当たるモード**: 開発ループが組む設計のプラン(`pace: auto`)を通るすべてのモード。単発の `/ndf:design` は出し直しの指摘を持たないため、振る舞いは変わらない ## あるべき姿の根拠 @@ -19,42 +27,68 @@ | 周回 9 の設計のプラン(`.ndf/loop/lroot3/lap-9/sprint/1-design-2338.json`)に、指摘の中身(#2059・#2300 に絞る など)が無い | 実測(課題の本文の「原因」) | 指摘は記録に届くが、設計のプランの入力に入っていない | | #2338 の本文に節として指摘を写しても、3 回目の出し直し(lroot3 周回 11)の範囲が元のままだった | 実測(課題の本文の「今の回避」) | 本文に書くだけでは設計の手順に届かない。プランの起動指示へ入れる必要がある | | #2494: 出し直しの設計が要求のステップを飛ばし、指摘を反映しないまま MVV 判定「従う」で承認ゲート 1 を通った( https://github.com/devbasex/ai-plugins/pull/2479 ) | 実測(同じ根を持つ課題) | 仕様のコピーの一致で要求のステップを飛ばす分岐と、MVV 判定の材料に指摘が無いことが、指摘の取りこぼしを人まで届けない | +| #2474 の承認の答えは耐久の記録に届いていた。レーン lroot2(ループ lroot3)は答えの前に `waiting_only` で止まり、答えの後に一度も動かなかった | 実測(下の「#2474 の承認が当たらなかった原因」) | 答えを当てるのはレーンの周回の境だけで、止まったループのレーンを答えの後に起こす仕組みが無い | +| ループ lroot2 の記録に、#2337 の設計 PR #2412 の待ちが 11/02 から依頼の id を持たずに残っていた | 実測(`.ndf/loop/lroot2/loop.json`) | 計画のレーンでないループの待ちは、どの経路でもオーガナイザーへ送られない | 要求と受け入れ条件は #2510 の本文にある(コピーは [issues/issue-2510-requirements.md](issue-2510-requirements.md) )。この文書は「どう作るか」だけを扱う。 +### #2474 の承認が当たらなかった原因 + +要求の前提 9 が設計に委ねた原因を、記録から確かめた。 + +| 確かめたこと | 結果 | +| --- | --- | +| ループ lroot3 の記録の #2474 の行(周回 12) | 依頼の id は `lroot2-g2501-8c5982fe95ca`、印は `8c5982fe95ca…`、`restamped` は無い。答えは `approved`・答えた時刻 03:11:08Z(オーガナイザーが `loop answer` を直接打った時刻) | +| 計画 p3 の耐久の記録のイベント `answer-lroot2-g2501-8c5982fe95ca` | `{"result": "approved", "at": "2026-11-05T02:09:28Z"}` がある。答えは耐久の記録に届いていた | +| 設計 PR #2501 の先頭 | `8c5982fe95ca…` のまま(02:09:47Z にマージ)。先頭は問いの時点から動いていない | +| ループ lroot3 の止まった記録 | 01:54:52Z に `waiting_only` で止まり、再開の時刻が無い。答えの後に周回の境を一度も通っていない | + +**原因**: 答えを行へ当てるのは、持ち主のレーンの周回の境(`loop select` の `apply_gate1_answers`)だけである。止まったレーンを起こすのは、`parallel.py answer` が `metrics.next` に返す 1 行をオーガナイザーが打つことだけに頼っている。耐久ワークフローが起こす対象(`revive`・`wake`)は 1 スプリントのレーンに限られ、ループのレーンを含まない。オーガナイザーが `metrics.next` の 1 行を打たなかったため、答えは当たらないまま残った。 + +**候補だった依頼の id の打ち直しは、この件の原因ではない。** 行の依頼の id は答えた id と同じで、`restamped` も無い。ただし、打ち直しで古い id への答えが行に当たらなくなる経路は、コードに残っている(`regate1` が行の `request` を新しい id で置き換える)。受け入れ条件 10 はこの経路も含めて直す。 + +`parallel.py answer` の出力に `metrics.next` があったかは、出力が記録に残っていないため未確認である。 + ## ドメインモデル ### コンテキスト | コンテキスト | 何の語が 1 つの意味に決まるか | | --- | --- | -| NDF の開発ワークフロー(`ndf-workflow`) | 出し直しの指摘・出し直しの指摘の履歴・指摘への対応の表・設計のプラン・答えの種類・自動の出し直し | +| NDF の開発ワークフロー(`ndf-workflow`) | 出し直しの指摘・出し直しの指摘の履歴・指摘への対応の表・設計のプラン・答えの種類・自動の出し直し・設計の承認の依頼・取り込みのコミット・前のループ・当たらなかった答え | -1 つのコンテキストに収まる。開発ループと並列の計画のレーンは同じループの記録を同じ関数で書くため、コンテキストを分けない。 +1 つのコンテキストに収まる。開発ループと並列の計画のレーンは同じループの記録を同じ関数で書く。並列の計画の耐久ワークフローも、同じ記録の行へ答えを届ける側としてこのコンテキストの語を使う。 ### 集約 | 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | | --- | --- | --- | --- | --- | -| ループの記録 | 開発ループの集約(`lib/sprint_loop.py`・`lib/sprint_loop_gate.py`) | ループ | 人の判断待ちの行 | 出し直しの指摘・届いていない項目の一覧 | -| 出し直しの指摘の入力 | 開発ループの集約が周回ごとに書き出す。読むのは設計のプランの組み立て・MVV 判定・表の突き合わせ | 課題ごとの入力 | 回 | 指摘の項目(識別子 `<回>-<番号>` と本文) | +| ループの記録 | 開発ループの集約(`lib/sprint_loop.py`・`lib/sprint_loop_gate.py`) | ループ | 人の判断待ちの行 | 出し直しの指摘・届いていない項目の一覧・前の依頼の一覧 | +| 出し直しの指摘の入力 | 開発ループの集約が周回ごとに書き出す。読むのは設計のプランの組み立て・MVV 判定・表の突き合わせ | 課題ごとの入力 | 回 | 指摘の項目(識別子 `<ループ>:<回>-<番号>` と本文) | | 出し直しの指摘の履歴 | 開発ループの集約(答えを当てる経路だけが追記する) | 課題の本文の節 | 回の記載 | — | | 指摘への対応の表 | 設計のステップの worker が設計文書へ書く。設計 PR の本文へは表の突き合わせのコマンドが写す | 設計文書の節 | 表の行 | 対応(直した / 直せない)・直した箇所か直せない理由 | +| 問いの一覧 | 並列の計画の耐久ワークフロー(`lib/parallel_run.py` の `_Run`) | 計画 | 問い(設計の承認の依頼・前のループの待ち・当たらなかった答え ほか) | 置き換えた依頼の id の一覧 | + +出し直しの指摘の入力は、ループの記録から作る読み取り専用のコピーである。正本はループの記録の人の判断待ちの行で、課題の本文の履歴は人が読むためのコピーとする(要求の前提 5)。入力は、同じリポジトリのすべてのループの記録から、その課題の回を集める(要求の前提 1)。 -出し直しの指摘の入力は、ループの記録から作る読み取り専用のコピーである。正本はループの記録の人の判断待ちの行で、課題の本文の履歴は人が読むためのコピーとする(要求の前提 5)。入力が持つのは、いま回っているループの回だけである。同じ課題を前に別のループで出し直した回は、課題の本文の履歴の節で人が読める形に残るが、入力・表の突き合わせ・MVV 判定には入らない(ループの記録はループごとに別のファイルで、回数もループごとに 1 から数えるため)。 +**前のループの記録へ書くのは、答えを当てる `loop answer` だけである。** 前のループの記録は、そのループの集約が持ち主のままである。耐久ワークフローは記録を直接書かず、そのループの `loop answer` を呼ぶ。 ### 不変条件 | # | 集約 | 条件 | 破れたときの扱い | | --- | --- | --- | --- | -| I1 | 出し直しの指摘の入力 | 課題の入力の回は、いま回っているループの記録(`.ndf/loop/<名>/loop.json`)でその課題の答えが「直して出し直す」の行を、記録の順にすべて持つ(人の回と自動の回の両方)。ほかのループの記録の回は入れない。回数と項目の識別子 `<回>-<番号>` は 1 つのループの中で一意で、入力の中で衝突しない | 入力を書けなければ設計のプランを組まずに止める(周回を `refused` で止める) | +| I1 | 出し直しの指摘の入力 | 課題の入力の回は、同じリポジトリのすべてのループの記録(`.ndf/loop/*/loop.json`)で、その課題の答えが「直して出し直す」の行をすべて持つ(人の回と自動の回の両方)。並びは答えた時刻の順である。回はループの名前と、そのループの中の回数の組で識別する。項目の識別子 `<ループ>:<回>-<番号>` は入力の中で衝突しない | いま回っているループの記録を読めなければ、設計のプランを組まずに止める(周回を `refused` で止める)。ほかのループの記録を読めなければ、その記録を飛ばし、飛ばした名前を周回の結果の `items` に出す | | I2 | 出し直しの指摘の入力 | 指摘の項目は人の回の指摘からだけ数える。箇条書き(`-` / `*`)と番号付きの行を 1 項目とし、どちらも無ければ全文を 1 項目とする。自動の回は項目を足さず、届いていない項目の識別子だけを持つ | — (数え方の関数が 1 つで、ほかの経路が数えない) | -| I3 | 出し直しの指摘の履歴 | 履歴の節は追記だけで、既にある回の記載を消さない・書き換えない。同じ回を 2 度書かない。回はループの名前と回数の組で識別する(回数はループごとに 1 から数えるため、回数だけでは別のループの回と区別できない) | 本文を書けなければ理由を結果に残し、記録の書き込みは止めない(要求の前提 5) | +| I3 | 出し直しの指摘の履歴 | 履歴の節は追記だけで、既にある回の記載を消さない・書き換えない。同じ回を 2 度書かない。回はループの名前と回数の組で識別する(回数はループごとに 1 から数える) | 本文を書けなければ理由を結果に残し、記録の書き込みは止めない(要求の前提 5) | | I4 | 設計のプラン | 出し直しの指摘を持つ課題では、要求のステップを仕様のコピーの一致によらず流し、要求と設計のステップの起動指示にすべての回の指摘を入れる。持たない課題のプランは今と同じ | — (組み立ての分岐で守る) | | I5 | 指摘への対応の表 | 設計 PR の本文の表は、設計文書の節を写したものだけである。本文の側で書き足さない | コピーが食い違えば、表の突き合わせのコマンドが設計文書の側で本文を書き直す | | I6 | ループの記録 | 出し直しの指摘を持つ課題を人の判断待ち(答えがまだの行)へ移すのは、すべての項目に表の行があり、対応と箇所・理由の欄が空でないときか、自動の出し直しの上限に達したときだけである | 項目が欠ければ、人へ問わずに自動の出し直しの行にする | -| I7 | ループの記録 | 自動の出し直しは、人の出し直しの 1 回につき 1 回まで(直前の出し直しの行が自動なら、次は人へ問う) | 上限に達したら、届いていない項目を理由に添えて人へ問う | +| I7 | ループの記録 | 自動の出し直しは、人の出し直しの 1 回につき 1 回まで(いま回っているループの記録で、その課題の直前の出し直しの行が自動なら、次は人へ問う) | 上限に達したら、届いていない項目を理由に添えて人へ問う | | I8 | ループの記録 | 承認とやらないの答えを当てたときの、承認の記録・ラベル・人の判断待ちの節の振る舞いは今と同じ | — (既存のテストで守る) | +| I9 | 問いの一覧 | オーガナイザーの答えは捨てない。耐久ワークフローは、答えた依頼の id がどうであれ `answer-<依頼>` を 1 回だけ立てる。行へ当てられない答えは、区分 `unmatched` の問いとして問いの一覧に出す | — (受け取りの分岐で守る) | +| I10 | ループの記録 | 承認の依頼の後に設計 PR の先頭が動いたとき、動いた分が取り込みのコミットだけなら、依頼を送り直さず、元の依頼への答えを当てる。それ以外と判定できないときは、新しい先頭で問い直す | 判定の関数が判定できなければ、問い直す(要求の前提 7) | +| I11 | 問いの一覧 | 前のループの答えがまだの行 1 つにつき、問いは 1 つだけである。依頼の id はループの名前・課題・行の周回から決まり、同じ行を何度送っても同じ id になる。先頭が動いて問い直すときは、古い問いを新しい問いで置き換える | — (id の決め方と受け取りの分岐で守る) | +| I12 | 問いの一覧 | 前のループの待ちの問いは、計画を終えることを止めない。終えたときの要約に、答えのまだの問いを並べる | — | ### ドメインイベント @@ -70,13 +104,19 @@ | E8 | MVV 判定が出し直しの指摘を材料に含めて判定した | MVV 判定のステップ | 承認ゲート 1 | | E9 | 指摘と表を突き合わせた | 表の突き合わせのステップ(プランの中)と、人の判断待ちへ移す関数(周回の締め) | 自動の出し直し、または人の判断待ちへ移す処理 | | E10 | 課題を人の判断待ちへ移し、判断待ちの節を書き直した(履歴の節は残る) | 人の判断待ちへ移す関数 | 承認ゲート 1 に答える利用者・オーガナイザー | +| E11 | レーンが設計の承認の依頼を送った | 人の判断待ちへ移す関数(レーンのとき)・周回の境(依頼の無い行の送り直し) | 耐久ワークフローの問いの一覧 | +| E12 | オーガナイザーの答えを受けた | 耐久ワークフローの受け取り | 持ち主のレーン(起こす対象へ入れる)・前のループ(`loop answer`)・問いの一覧(当たらなかった答え) | +| E13 | 設計 PR の先頭が問いの時点から動いたのを見つけた | 答えを当てる前の比べ(レーン・耐久ワークフロー) | 取り込みのコミットの判定 | +| E14 | 動いた分が取り込みのコミットだけかを判定した | 承認の依頼の部品の判定の関数 | 答えを当てる処理か、問い直す処理 | +| E15 | 前のループの待ちを問いの一覧へ送った | 耐久ワークフローの開始・レーンの周回の境 | 耐久ワークフローの問いの一覧 | ### 用語 | 用語 | 意味 | 用語集への反映 | | --- | --- | --- | -| 自動の出し直し | 出し直しの設計が指摘の項目を表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | 追加 | -| 出し直しの指摘の入力 | 開発ループが周回ごとに、そのループでの課題のすべての回の出し直しの指摘をループの記録から書き出したファイル。設計のプラン・MVV 判定・表の突き合わせが読む | 追加 | +| 自動の出し直し | 出し直しの設計が指摘の項目を表に示していないとき、人へ問わずに開発ループが自分で付ける「直して出し直す」の答え。届いていない項目を名指しする。人の出し直しの 1 回につき 1 回まで | 既にある | +| 出し直しの指摘の入力 | 開発ループが周回ごとに、同じリポジトリのすべてのループの記録から、課題のすべての回の出し直しの指摘を書き出したファイル。設計のプラン・MVV 判定・表の突き合わせが読む | 意味を変える | +| 当たらなかった答え | オーガナイザーの答えのうち、持ち主のループの記録の行へ当てられなかったもの。答えは耐久の記録に残し、区分 `unmatched` の問いとしてオーガナイザーの問いの一覧に出す | 追加 | ## 機能一覧 @@ -88,14 +128,19 @@ | F4 | 表に欠けがあれば、人へ問わずに自動の出し直しへ戻す(1 回まで) | 開発ループ | | F5 | MVV 判定の材料に出し直しの指摘を入れ、指摘を満たさない設計を「従う」にしない | 開発ループ(承認ゲート 1 の判定) | | F6 | 答えの種類ごとに、読んで動く手順を手順書の表で示す | 利用者・オーガナイザー・conductor | +| F7 | 答えを受けたら、`waiting_only` で止まったループのレーンも起こす対象に入れる。当てられない答えは問いの一覧に出す | オーガナイザー | +| F8 | 取り込みのコミットだけで設計 PR の先頭が動いたときは、問い直さずに元の依頼への答えを当てる | レーン・オーガナイザー | +| F9 | 前のループの答えがまだの待ちを問いの一覧へ送り、答えをそのループの記録へ当てる | オーガナイザー | ## 構成要素 ### 要素ごとの責務 +**出し直しの指摘(F1〜F6)** + | 要素 | 責務 | | --- | --- | -| 出し直しの指摘の部品(新規 `lib/revise_note.py`) | 指摘の読み取りと項目の数え方の唯一の実装。課題の回の取り出し・項目の分割・入力ファイルの書き出しと読み込み・起動指示の文面・表の解析と欠けの判定・自動の出し直しの上限の判定・履歴の 1 回分の文面を持つ | +| 出し直しの指摘の部品(新規 `lib/revise_note.py`) | 指摘の読み取りと項目の数え方の唯一の実装。すべてのループの記録から課題の回を集める・項目の分割・入力ファイルの書き出しと読み込み・起動指示の文面・表の解析と欠けの判定・自動の出し直しの上限の判定・履歴の 1 回分の文面を持つ | | 表の突き合わせのコマンド(新規 `revise-note.py check`) | 設計文書の「出し直しの指摘への対応」の節を設計 PR の本文へ写し、入力の項目と突き合わせる。そろえば 0、欠ければ 10(承認ゲートで止める)、読めなければ 2 | | 開発ループの答えを当てる関数(`lib/sprint_loop.py` の `apply_answer`) | 直して出し直すの答えで、記録の行を書いた後に履歴の節へ追記する | | 周回のスプリントの組み立て(`lib/sprint_loop.py` の `cmd_sprint`・`new_sprint_argv`) | 設計する課題のうち出し直しの指摘を持つものの入力ファイルを周回のディレクトリへ書き、`supervise.py new sprint` へ `--revise-note <課題>=<パス>` で渡す | @@ -104,11 +149,26 @@ | 設計のプランの組み立て(`supervise_lib/sprint_waves.py` の `plan_sprint_design`・`plan_mvv_design`) | 出し直しの指摘を持つ課題で、仕様のコピーのチェックを外して要求のステップへ直行させ、要求と設計の起動指示へ指摘を入れ、表の突き合わせのステップを足し、MVV 判定へ入力を渡す | | `supervise.py new sprint` の引数(`supervise_lib/new_args.py`) | `--revise-note <課題>=<パス>`(繰り返し可)を受ける | | MVV 判定(`mvv-gate.py`) | `--revise-note <パス>` を受け、指摘を材料の 1 件として足し、承認ゲートの問いへ「指摘の項目を満たさない設計は従うにしない」を足す | -| 手順書(`development-workflow` の `references/loop.md`・`references/parallel.md`) | 答えの種類ごとの読んで動く手順の表を `loop.md` に置き、`parallel.md` の問い方の節から指す | +| 手順書(`development-workflow` の `references/loop.md`・`references/parallel.md`) | 答えの種類ごとの読んで動く手順の表を `loop.md` に置き、`parallel.md` の問い方の節から指す。`parallel.md` に区分 `unmatched` の問いと前のループの待ちの問い方を足す | | `/ndf:pr` の「設計 Pull Request の本文」(`skills/pr/SKILL.md`) | 節「出し直しの指摘への対応」を、手で書かない節として表に足す | +**承認と前のループの待ち(F7〜F9)** + +| 要素 | 責務 | +| --- | --- | +| 承認の依頼の部品(新規 `lib/gate1_requests.py`) | 設計 PR の先頭の読み取り(`sprint_loop_gate.design_head` を移す)・取り込みのコミットだけかの判定・前のループの待ちの読み出しと依頼の形・依頼の id の決め方を持つ唯一の実装 | +| 問い直しの判断(`lib/sprint_loop_gate.py` の `regate1`) | 先頭が動いたら判定の関数を呼ぶ。取り込みのコミットだけなら送り直さない。送り直すときは、前の依頼の id と印を行の `requests_before` へ移す | +| 答えを読む依頼の一覧(`lib/sprint_loop_gate2.py` の `pending_requests`) | 行の `request` に加えて `requests_before` の id も読む | +| 設計の承認の答えを当てる関数(`lib/sprint_loop.py` の `apply_gate1_answers`) | 前の依頼の id への答えは、その印から今の先頭までが取り込みのコミットだけなら当てる。違えば当てずに、当たらなかった答えとしてオーガナイザーへ送る | +| 周回の境(`lib/sprint_loop_select.py` の `boundary_then_select`) | レーンなら、答えを当てた後・選定の前に、前のループの待ちを送り、自分の行で依頼の id を持たないもの(送れなかった依頼)を送り直す | +| 選定の外す理由(`lib/sprint_loop_select.py` の `exclusion_reason`) | 前のループに答えのまだの行がある課題を、「人の判断待ち(開発ループ <名>)」で外す。棚卸しの判断待ちより先に見る | +| レーンの送り手(`lib/parallel_lane.py`) | 前のループの待ちの依頼(`kind: gate1` と `loop`)と、当たらなかった答えの知らせ(`kind: unmatched`)を `inbox` へ送る関数を足す | +| 耐久ワークフロー(`lib/parallel_run.py`) | 開始時に前のループの待ちを問いの一覧へ入れる。同じ行の古い問いを置き換える。答えを受けたら、`waiting_only` で止まったループのレーンも起こす対象に入れ、前のループの問いへの答えをそのループの `loop answer` で当て、当てられない答えを区分 `unmatched` の問いにする。前のループの問いは終わりを止めない | + ### 要素どうしの呼び出し +出し直しの指摘の経路は次のとおりである。 + ```mermaid graph TD subgraph 開発ループ @@ -134,20 +194,43 @@ graph TD M --> R ``` -図には処理の流れに現れない要素(手順書・`/ndf:pr` の Skill・`supervise.py new sprint` の引数の定義)を含めない。判断待ちの節の文面は「人の判断待ちへ移す」と「答えを当てる」の中で使われる。 +承認と前のループの待ちの経路は次のとおりである。 + +```mermaid +graph TD + subgraph レーン + S[周回の境] + G[答えを当てる] + Q[問い直しの判断] + end + subgraph オーガナイザー + W[耐久ワークフロー] + end + H[承認の依頼の部品] + S --> G + G --> Q + Q --> H + S --> H + S -->|依頼・知らせ| W + W --> H + W -->|loop answer| L[前のループの記録] +``` + +図には処理の流れに現れない要素(手順書・`/ndf:pr` の Skill・`supervise.py new sprint` の引数の定義)を含めない。判断待ちの節の文面は「人の判断待ちへ移す」と「答えを当てる」の中で使われる。答えを読む依頼の一覧は「答えを当てる」の中で、外す理由は「周回の境」の中で、レーンの送り手は「依頼・知らせ」の辺の中で使われる。 ### システムの文脈と配置 -システムの文脈と配置は次のとおりである。外部の系は GitHub(課題の本文・設計 PR の本文)だけで、すべての処理は利用者の手元のループの実行場所で動く。 +外部の系は GitHub(課題の本文・設計 PR の本文と先頭)だけで、すべての処理は利用者の手元のループと耐久ワークフローの実行場所で動く。 ```mermaid graph TD - U[利用者] -->|答え| L[開発ループ] + U[利用者] -->|答え| O[オーガナイザー] + O -->|answer| W[耐久ワークフロー] + W -->|答えのイベント| L[開発ループ] L -->|記録| J[ループの記録] - L -->|入力ファイル| D[周回のディレクトリ] - L -->|履歴の追記| G[GitHub の課題] - D --> S[設計のプラン] - S -->|表のコピー| H[GitHub の設計 PR] + L -->|入力ファイル| S[設計のプラン] + L -->|履歴の追記| G[GitHub] + S -->|表のコピー| G ``` パッケージの置き場は次のとおりである。 @@ -158,8 +241,13 @@ plugins/ndf/scripts/ ├── mvv-gate.py # --revise-note を足す ├── lib/ │ ├── revise_note.py # 新規。出し直しの指摘の部品 +│ ├── gate1_requests.py # 新規。承認の依頼の部品 │ ├── sprint_loop.py # 答えを当てる・スプリントの組み立て -│ └── sprint_loop_gate.py # 人の判断待ちへ移す・判断待ちの節 +│ ├── sprint_loop_gate.py # 人の判断待ちへ移す・問い直しの判断 +│ ├── sprint_loop_gate2.py # 答えを読む依頼の一覧 +│ ├── sprint_loop_select.py # 周回の境・外す理由 +│ ├── parallel_lane.py # 依頼と知らせを送る +│ └── parallel_run.py # 耐久ワークフロー └── supervise_lib/ ├── new_args.py # --revise-note を足す └── sprint_waves.py # 設計のプランの分岐 @@ -167,7 +255,7 @@ plugins/ndf/scripts/ ## 構造 -コードは関数と辞書で書かれ、型の定義は無い。変える形は、ループの記録の行と出し直しの指摘の入力の 2 つの辞書である。 +コードは関数と辞書で書かれ、型の定義は無い。変える形は、ループの記録の行・出し直しの指摘の入力・問いの一覧の問いの 3 つの辞書である。 ```mermaid classDiagram @@ -176,14 +264,18 @@ classDiagram answer note revise_count + request + stamp by missing + requests_before } class 出し直しの指摘の入力 { issue rounds } class 回 { + loop round at pr @@ -196,37 +288,52 @@ classDiagram id text } + class 問い { + kind + id + lane + loop + issue + pr + stamp + replaced + } 出し直しの指摘の入力 "1" --> "1..*" 回 回 "1" --> "0..*" 項目 人の判断待ちの行 ..> 回 : 写す + 人の判断待ちの行 ..> 問い : 依頼 ``` ## データ構造 ### 人の判断待ちの行に足す欄 -既存の欄(`note`・`revise_count` ほか)の意味は変えない。足すのは自動の出し直しの 2 欄だけである。 +既存の欄(`note`・`revise_count`・`request`・`stamp` ほか)の意味は変えない。 | 欄 | 型 | 空の扱い | 意味 | | --- | --- | --- | --- | | `by` | 文字列 | 無ければ人の答え | `auto` のとき自動の出し直し | -| `missing` | 文字列の配列 | 無ければ欠けなし | 自動の出し直しで名指しした、届いていない項目の識別子(`1-2` など) | +| `missing` | 文字列の配列 | 無ければ欠けなし | 自動の出し直しで名指しした、届いていない項目の識別子(`lroot3:1-2` など) | +| `requests_before` | `{id, stamp}` の配列 | 無ければ送り直していない | 問い直すときに置き換えた前の依頼の id と、その依頼の印(設計 PR の先頭) | -自動の出し直しの行は `answer` を `revise`、`note` を空にする。項目は人の回の指摘からだけ数えるため(I2)、自動の回は指摘の本文を持たない。 +自動の出し直しの行は `answer` を `revise`、`note` を空にする。項目は人の回の指摘からだけ数えるため(I2)、自動の回は指摘の本文を持たない。`restamped`(今の欄)は残し、`requests_before` の最後の印と同じ値を持つ。 ### 出し直しの指摘の入力(`<周回のディレクトリ>/revise-<課題>.json`) -周回のスプリントを組むたびに、ループの記録から作り直す。手で直さない。 +周回のスプリントを組むたびに、すべてのループの記録から作り直す。手で直さない。 ```json { "issue": 2338, "rounds": [ - {"round": 1, "at": "2026-11-04T21:09:00Z", "pr": 2497, "by": "user", + {"loop": "lroot2", "round": 1, "at": "2026-11-03T10:00:00Z", "pr": 2497, "by": "user", + "note": "- #2059 に絞る", "items": [{"id": "lroot2:1-1", "text": "#2059 に絞る"}], "missing": []}, + {"loop": "lroot3", "round": 1, "at": "2026-11-04T21:09:00Z", "pr": 2497, "by": "user", "note": "- #2059 と #2300 に絞る\n- PR は 2 本にする", - "items": [{"id": "1-1", "text": "#2059 と #2300 に絞る"}, {"id": "1-2", "text": "PR は 2 本にする"}], + "items": [{"id": "lroot3:1-1", "text": "#2059 と #2300 に絞る"}, {"id": "lroot3:1-2", "text": "PR は 2 本にする"}], "missing": []}, - {"round": 2, "at": "2026-11-05T10:00:00Z", "pr": 2497, "by": "auto", "note": "", "items": [], "missing": ["1-2"]} + {"loop": "lroot3", "round": 2, "at": "2026-11-05T10:00:00Z", "pr": 2497, "by": "auto", "note": "", "items": [], + "missing": ["lroot3:1-2"]} ] } ``` @@ -238,7 +345,7 @@ classDiagram ```markdown ### 第 2 回(2026-11-05・設計 PR #2497・開発ループ lroot3・自動) -届いていない項目: 1-2 +届いていない項目: lroot3:1-2 ``` 人の回は「自動」の代わりに「人」と書き、見出しの下に指摘の全文を置く。見出しのループの名前と回数の組(例: `開発ループ lroot3` の `第 2 回`)が同じ見出しが既にあれば足さない(I3)。回数が同じでもループの名前が違えば別の回として足す。 @@ -247,20 +354,36 @@ classDiagram | 列 | 書くこと | | --- | --- | -| 項目 | 入力の項目の識別子(`1-1`) | +| 項目 | 入力の項目の識別子(`lroot3:1-1`) | | 指摘 | 項目の本文の要約 | | 対応 | `直した` か `直せない` | | 直した箇所・直せない理由 | 直した節の名前か、直せない理由 | +### 問いの一覧に足す問い + +既にある設計の承認の依頼(`kind: gate1`、`lane` を持つ)の形は変えない。足すのは次の 2 つである。 + +| 問い | 欄 | id の決め方 | +| --- | --- | --- | +| 前のループの待ち | `kind: gate1`・`loop`(行を持つループの名前)・`issue`・`pr`・`lap`・`stamp`(送った時点の設計 PR の先頭)・`question`。`lane` を持たない | `<ループ>-w<課題>-l<行の周回>`。問い直すときは末尾に `-<新しい先頭の SHA の 12 桁>` を足す | +| 当たらなかった答え | `kind: unmatched`・`answered`(答えた依頼の id)・`lane`(レーンが知らせたとき)・`reason`・`question`・`options: ["確かめた"]` | `<答えた依頼の id>-unmatched` | + +同じ行の古い問い(`lane` か `loop` と `pr` が同じで id が違う)は、新しい問いを受けたときに一覧から外し、新しい問いの `replaced` に古い id を入れる(I11)。この置き換えは、レーンの設計の承認の依頼にも同じく当てる。 + +計画を終えたときの要約(`parallel.py wait` の終わりの出力)に、答えのまだの前のループの問いを `prior_waits` として並べる(I12)。 + ### CRUD 図 -| 機能 | ループの記録の行 | 入力ファイル | 履歴の節 | 設計文書の表 | 設計 PR の本文の表 | -| --- | --- | --- | --- | --- | --- | -| F1 | 読む | 作る | — | — | — | -| F2 | 更新 | — | 作る・追記 | — | — | -| F3 | — | 読む | — | 読む | 作る・更新 | -| F4 | 作る(自動の出し直し) | — | 追記 | — | 読む | -| F5 | — | 読む | — | — | 読む | +| 機能 | ループの記録の行 | 前のループの記録の行 | 入力ファイル | 履歴の節 | 設計文書の表 | 設計 PR の本文の表 | 問いの一覧 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| F1 | 読む | 読む | 作る | — | — | — | — | +| F2 | 更新 | — | — | 作る・追記 | — | — | — | +| F3 | — | — | 読む | — | 読む | 作る・更新 | — | +| F4 | 作る(自動の出し直し) | — | — | 追記 | — | 読む | — | +| F5 | — | — | 読む | — | — | 読む | — | +| F7 | 更新(答え) | — | — | — | — | — | 作る(unmatched)・削除 | +| F8 | 更新(`requests_before`) | — | — | — | — | — | 作る・置き換え | +| F9 | 読む | 読む・更新(`loop answer` だけ) | — | — | — | — | 作る・置き換え・削除 | ## 入出力の契約 @@ -269,9 +392,20 @@ classDiagram | `supervise.py new sprint` | `--revise-note <課題>=<パス>`(繰り返し可) | `--design` に含まれる課題の設計のプランだけを変える。ファイルを読めなければ組み立てを断る(終了コード 2) | | `mvv-gate.py check --gate design` | `--revise-note <パス>` | 材料の先頭へ「出し直しの指摘」の節を足し、承認ゲートの問いへ判定の条件を足す。読めなければ承認ゲートへ戻す(今の材料を取れないときと同じ) | | `revise-note.py check --pr <番号> --doc <設計文書> --notes <パス>` | 新規 | 終了コード 0(項目がそろった)/ 10(表が無い・行の無い項目・対応か箇所の欄が空の項目がある。届いていない項目を `items` に出す)/ 2(PR・設計文書・入力を読めない) | +| `parallel.py wait` | 無し | `questions` に区分 `unmatched` と前のループの待ちの問いが出る。終わりの出力に `prior_waits` が増える | +| `parallel.py answer` | 無し | 区分 `unmatched` の問いには `--choice 確かめた` で答える。前のループの待ちの問いには設計の承認と同じ `--approved` / `--revise --note` / `--rejected` で答える | `loop answer`・`parallel.py answer` の引数は変えない。 +### 取り込みのコミットだけかの判定(`gate1_requests.merge_only`) + +入力は設計 PR の番号・課題の番号・問いの時点の先頭・今の先頭、出力は 3 値(`True` = 取り込みだけ、`False` = 違う、`None` = 判定できない)である。 + +1. 起点ブランチを `.ndf/worktree.json` の `base_branch` から読む(`repo.base_branch`)。読めなければ `None` +2. 起点ブランチと設計 PR の先頭を `git fetch` する。失敗すれば `None` +3. 問いの時点の先頭から今の先頭までの、設計 PR のブランチの上のコミット(`git rev-list --first-parent --parents <前>..<今>`)が、すべて親を 2 つ以上持ち、1 つ目以外の親がすべて `origin/<起点>` の祖先であることを確かめる。外れれば `False` +4. `git diff --quiet <前> <今> -- 'issues/issue-<課題>-*.md'` が 0 なら `True`、1 なら `False`、ほかは `None` + ## 処理の流れ ### 答えを当ててから次の設計のプランを組むまで @@ -297,7 +431,7 @@ sequenceDiagram ```mermaid graph TD - G[用語集の入口の検査] --> Q[要求のステップ
指摘つき] + G[用語集の検査
gate] --> Q[要求のステップ
指摘つき] Q --> D[設計のステップ
指摘つき・表を書く] D --> X[組み直し・PR・レビュー・用語] X --> K{表の突き合わせ} @@ -324,12 +458,72 @@ graph TD `T` は設計 PR の本文を読む。読めなければ自動の出し直しにせず、「表を読めなかった」を理由に添えて判断待ちへ移す。自動の出し直しの行は、人の答えと同じく次の周回で先頭に選ばれる(今の選定の振る舞い)。 +### オーガナイザーが答えを送ってから行へ当たるまで + +```mermaid +sequenceDiagram + participant O as オーガナイザー + participant W as 耐久ワークフロー + participant L as レーン + O->>W: answer(依頼の id) + W->>W: answer- を立てる + alt 一覧にある問い + W->>W: 止まった持ち主を起こす対象へ + W-->>O: 知らせに起こす 1 行 + else 一覧に無い・置き換えた id + W-->>O: 区分 unmatched の問い + end + O->>L: 起こす 1 行を打つ + L->>L: 周回の境で答えを読む + L->>L: 先頭を比べて当てる +``` + +起こす対象は、問いの持ち主のレーンのうち止まっているもので、1 スプリントのレーンに加えて、記録が `waiting_only` で答えのまだの行を持つループのレーンを含める(今の `step_waiting_lanes` と同じ判定)。起こす 1 行は、そのレーンが動き出すまで、以後の知らせ(`pause` の `wake`)に添え続ける。 + +レーンが答えを読んで当てるときの判断は次のとおりである。 + +```mermaid +graph TD + A[承認の答え] --> I{答えた id} + I -->|今の依頼| H{先頭が動いた} + I -->|前の依頼| P{前の印から今まで
取り込みだけ} + H -->|動かない| Y[当てる] + H -->|動いた| M{取り込みだけ} + M -->|True| Y + M -->|False か None| R[新しい先頭で問い直す] + P -->|True| Y + P -->|False か None| U[当たらなかった答え
として知らせる] +``` + +先頭を読めないときは、今どおり当てずに次の境で読み直す。 + +### 前のループの待ちを問いの一覧へ出す + +```mermaid +sequenceDiagram + participant W as 耐久ワークフロー + participant L as レーン + participant P as 前のループの記録 + participant O as オーガナイザー + W->>P: 開始時に読む + W->>W: 行ごとの問いを一覧へ + L->>P: 周回の境で読む + L->>W: 同じ id の依頼を送る + W->>W: 同じ id は数えない + O->>W: answer + W->>P: 先頭を比べて loop answer +``` + +読む対象は、`.ndf/loop/*/loop.json` のうち、その計画のループのレーンでないものすべてである。その中の行のうち、答えがまだで設計 PR を持つものを送る。ただし、動いている別の計画の依頼の id を持つ行は除く(その計画の問いの一覧に出ているため)。記録を読めないループは飛ばし、飛ばしたループの名前と理由を、耐久ワークフローでは区分 `unmatched` と同じ形の知らせに、レーンでは周回の結果の `items` に出す。 + +承認の答えを当てる前に、問いの `stamp` と今の先頭を判定の関数で比べる。取り込みのコミットだけなら `loop answer <ループ> <課題> --approved --pr <設計 PR>` を打つ。違えば当てずに新しい先頭で問いを置き換える。直して出し直すは、指摘を計画の置き場(`.ndf/parallel/<計画>/prior/.md`)へ書いて `--revise --note` で渡し、やらないは `--rejected` で渡す。`loop answer` が 0 以外で終われば、理由を添えて区分 `unmatched` の問いにする。 + ## 非機能の実現方式 | 大項目 | 実現方式 | | --- | --- | -| 運用・保守性 | 指摘の読み取り・項目の数え方・表の解析は出し直しの指摘の部品の関数だけが持つ。開発ループ・並列の計画のレーン・設計のプランの組み立て・MVV 判定・表の突き合わせのコマンドは、すべてこの部品を呼ぶ | -| 移行性 | 既存の課題の本文の判断待ちの節に残る指摘は移さない。ループの記録の既存の `note` はそのまま入力の回として読める(`by` の無い行は人の回として扱う) | +| 運用・保守性 | 指摘の読み取り・項目の数え方・表の解析は出し直しの指摘の部品の関数だけが持つ。開発ループ・並列の計画のレーン・設計のプランの組み立て・MVV 判定・表の突き合わせのコマンドは、すべてこの部品を呼ぶ。取り込みのコミットの判定と前のループの待ちの読み出しは承認の依頼の部品だけが持ち、レーンの問い直し・レーンの答えの当て方・耐久ワークフローの前のループへの当て方が同じ関数を呼ぶ | +| 移行性 | 既存の課題の本文の判断待ちの節に残る指摘は移さない。ループの記録の既存の `note` はそのまま入力の回として読める(`by` の無い行は人の回として扱う)。`requests_before` の無い行は、送り直していない行として扱う。既にある前のループの答えがまだの行は、この変更の後に計画を始めたときか、次のレーンの周回の境で問いの一覧に出る | ## 決定の記録 @@ -369,7 +563,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 起動指示にファイルのパスだけを書くと、worker が読まない経路が残る(#2338 の回避で本文の節が読まれなかった)。全文と項目の識別子を起動指示に置き、「この指摘を満たすように直す。満たせない項目は理由を書く」と、設計文書へ表を置く指示を添える。 -最新の 1 回だけを渡す形は採らない。前の回で満たした項目を次の回で崩さないため(要求の前提 1)。「すべての回」は、いま回っているループの回を指す(I1)。 +最新の 1 回だけを渡す形は採らない。前の回で満たした項目を次の回で崩さないため(要求の前提 1)。 根拠: Value 2 / Value 7(MVV 版 5) @@ -385,14 +579,54 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 根拠: Value 8(MVV 版 5) +### 決定 8: 別のループで送った指摘も満たすため、出し直しの指摘の入力はすべてのループの記録から回を集め、回をループの名前と回数の組で識別する + +要求の前提 1 は、別の開発ループで送った回も入力に含めると決めた。ループが替わると前のループの指示が届かない形は、受け入れ条件 12 と同じ根を持つ。回数はループごとに 1 から数えるため、項目の識別子にループの名前を入れて衝突を避ける。自動の出し直しの上限は、いま回っているループの記録の直前の行で数える。上限は人の出し直しの 1 回ごとに効くもので、前のループの回まで遡る必要が無いためである。 + +いま回っているループの回だけを集める形は採らない。前提 1 に合わず、ループを替えた直後の設計が前の指摘を読まない。 + +根拠: Value 7 / Value 2(MVV 版 5) + +### 決定 9: 答えを捨てないため、止まったループのレーンも起こす対象に入れ、当てられない答えは区分 `unmatched` の問いにする + +#2474 の原因は、止まったループのレーンを起こすことが、オーガナイザーが `metrics.next` の 1 行を打つことだけに頼っていた点にある(「#2474 の承認が当たらなかった原因」)。1 スプリントのレーンには、耐久ワークフローが起こす 1 行を以後の知らせに添え続ける仕組み(`revive`・`wake`)が既にある。ループのレーンもこの対象に入れれば、新しい経路を作らずに済む。 + +当てられない答え(一覧に無い id・置き換えた id・前の印から取り込みだけでない・前のループへ当てられない)は、今は耐久ワークフローの受け取りか、レーンの読み取りで黙って捨てられる。区分 `unmatched` の問いにすれば、オーガナイザーが `parallel.py wait` の `questions` で知り、答えるまで計画は終わらない。答えのイベント自体は立てたまま残すため、後から同じ id の依頼が届いても当たる。 + +耐久ワークフローが自分でレーンを起こし直す形は採らない。今の決まりは、レーンを起こす操作をオーガナイザーの 1 行に置いている(`parallel.md` の「オーガナイザーが打つのは…終わったレーンを起こす 1 行だけ」)。この課題では起こす 1 行の届け方だけを直し、担い手は変えない。 + +根拠: Value 7 / Vision(MVV 版 5) + +### 決定 10: 問い直しの判断と答えの当て方をそろえるため、取り込みのコミットだけかの判定を承認の依頼の部品の 1 つの関数に置く + +要求の非機能の条件は、依頼を送り直すかの判断と答えを当てるかの判断が同じ関数を使うことを求める。レーンの問い直し・レーンの前の依頼への答え・耐久ワークフローの前のループへの答えの 3 か所が、この関数だけを呼ぶ。判定は git の履歴と差分で決まり、AI の判断を要らない。 + +判定は、設計 PR のブランチの上のコミット(`--first-parent`)だけを見る。取り込んだ起点ブランチのコミットは、親を辿ると範囲に入るため、ブランチの上だけに絞る。そのうえで、設計文書と要求のコピー(`issues/issue-<課題>-*.md`)の中身が変わらないことを確かめる。 + +コミットの題(`Merge branch 'develop'` など)で判定する形は採らない。題は書き換えられ、要求の前提 7 の「中身が同じ」を確かめられない。 + +根拠: Value 5 / Value 7(MVV 版 5) + +### 決定 11: 前のループの待ちを埋もれさせず、前のループの記録の持ち主を変えないため、耐久ワークフローが開始時に、レーンが周回の境に同じ関数で送り、答えは耐久ワークフローがそのループの `loop answer` で当てる + +要求の未決は、送る担い手と、同じ行を 2 回出さない方法を設計に委ねた。開始時は、まだレーンが周回の境を通っていないため、耐久ワークフローが送る。周回の境はレーンにしか無いため、レーンが送る。どちらも承認の依頼の部品の同じ関数で依頼を作る。依頼の id を行(ループの名前・課題・周回)から決めるため、両方から送っても、耐久ワークフローの受け取りが同じ id を 1 つにまとめる(I11)。 + +答えを当てるのは耐久ワークフローである。前のループにはレーンが無く、周回の境で答えを読む持ち主がいない。`loop answer` を呼べば、記録の書き込みはそのループの集約の関数のまま、要求の「答えを当てる以外は前のループの記録を書き換えない」に収まる。 + +前のループの待ちの問いは、計画を終えることを止めない(I12)。前のループの判断は計画の仕事の外にあり、止めると計画の終わりが計画の外の判断に縛られる。終えたときの要約に並べて、埋もれないようにする。 + +レーンが前のループの行を自分の記録へ写す形は採らない。要求の対象範囲が「前のループの記録を計画のレーンへ移す・合わせること」を含まないとしている。 + +根拠: Value 2 / Value 7(MVV 版 5) + ## 答えの種類ごとの手順(手順書に書く表の中身) `loop.md` の「承認ゲート」の節に置く表の中身を決める。 | 答えの種類 | 送るコマンド | 読んで動く手順 | | --- | --- | --- | -| 承認 | `loop answer --approved` / `parallel.py answer --approved` | 並列でなければ答えの場で承認の記録を書き、次の周回のスプリントで設計済みの課題として扱う。レーンは周回の境で設計 PR の先端を比べ、ラベル `design-approved` と承認の記録を書く。承認の後のマージの持ち主は #2507 が決める | -| 直して出し直す | `loop answer --revise --note` / `parallel.py answer --revise --note` | 答えを当てる関数が記録の行と履歴の節を書き、次の周回で先頭に選ぶ。スプリントの組み立てが入力ファイルを書き、設計のプランが読む(この設計) | +| 承認 | `loop answer --approved` / `parallel.py answer --approved` | 並列でなければ答えの場で承認の記録を書き、次の周回のスプリントで設計済みの課題として扱う。レーンは周回の境で設計 PR の先頭を比べ(取り込みのコミットだけの違いなら同じとみなす)、ラベル `design-approved` と承認の記録を書く。止まっているレーンは、起こす 1 行で起こす。前のループの待ちへの承認は、耐久ワークフローがそのループの `loop answer` で当てる。承認の後のマージの持ち主は #2507 が決める | +| 直して出し直す | `loop answer --revise --note` / `parallel.py answer --revise --note` | 答えを当てる関数が記録の行と履歴の節を書き、次の周回で先頭に選ぶ。スプリントの組み立てが入力ファイルを書き、設計のプランが読む | | やらない | `loop answer --rejected` / `parallel.py answer --rejected` | 答えを当てる関数が差し戻しの記録を書き、以後の選定から外す | | 予算 | `loop start <名>` に `--budget-laps` / `--budget-usd` / `--budget-hours` | ループの開始と再開の起動が予算を上書きし、変更を記録の予算の履歴へ残す。止まる条件の判定は周回の境で新しい予算を読む。レーンでは起こし直しの起動の行に付ける | | 止める | `loop stop <名>` / `parallel.py stop <計画名>` | 止める指示のファイルを置き、周回の境で止まる条件 `user_stop` として読む。並列の計画は全レーンへ止める指示を打つ | @@ -403,7 +637,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | -| 受け入れ条件 1 / I1 / I4 | 出し直しの行を 2 回持つ課題でスプリントを組むと、要求と設計のステップの起動指示に 2 回分の全文と「満たすように直す。満たせない項目は理由を書く」の旨が入る | 最新の 1 回だけを入れる・自動の回を落とす・起動指示へ入れずにパスだけを書く | +| 受け入れ条件 1 / I1 / I4 | 出し直しの行を 2 回持つ課題でスプリントを組むと、要求と設計のステップの起動指示に 2 回分の全文と「満たすように直す。満たせない項目は理由を書く」の旨が入る。別のループの記録に同じ課題の回があれば、その回も別の識別子で入る | 最新の 1 回だけを入れる・自動の回を落とす・起動指示へ入れずにパスだけを書く・ほかのループの回を落とす・識別子を回数だけで作る | | 受け入れ条件 2 / I4 | 出し直しの指摘を持つ課題のプランでは、仕様のコピーのチェックが一致で 0 を返しても要求のステップが流れる | 仕様のコピーのチェックのステップを残して `next` を設計にする | | 受け入れ条件 3 / I3 | 直して出し直すを 2 回当て、間に人の判断待ちへ移して判断待ちの節を書き直しても、本文に第 1 回と第 2 回の記載が残る。同じ回を当て直しても記載が 1 つ。別のループで同じ課題の第 1 回を当てると、記載が別に足される | 判断待ちの節の中へ履歴を書く・追記でなく置き換える・回の重複を見ない・回数だけで重複を見る | | 受け入れ条件 4 / I5 | 突き合わせのコマンドが、設計文書の表を設計 PR の本文の節へ写す。本文に別の表があれば設計文書の側で書き直す | 本文の表を正として設計文書を無視する | @@ -412,6 +646,13 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 | 受け入れ条件 6 | 出し直しの指摘を持つ課題の MVV 判定の材料と問いに、指摘の全文と「満たさない設計は従うにしない」が入る。判定器を差し替えて材料を見る | `--revise-note` を受けても材料へ足さない | | 受け入れ条件 8 / I4 | 出し直しの指摘を持たない課題のプランが今と同じ(仕様のコピーのチェックがあり、起動指示に指摘の欄が無く、突き合わせのステップが無い) | 指摘の有無によらず分岐する | | 受け入れ条件 9 / I8 | 承認・やらないを当てたときの承認の記録・ラベル・判断待ちの節が今と同じ(既存のテストが通る) | 答えを当てる関数の承認とやらないの経路で履歴を書く | +| 受け入れ条件 10 / I9 | 記録が `waiting_only` で止まったループのレーンが持つ問いへ答えると、そのレーンが起こす対象に入り、以後の知らせに起こす 1 行が付く。起こした後の周回の境で承認が行に当たる | 起こす対象を 1 スプリントのレーンに限る | +| 受け入れ条件 10 / I9 | 一覧に無い id・置き換えた id への答えでも `answer-` が立ち、区分 `unmatched` の問いが一覧に出る | 一覧に無い id を黙って捨てる・置き換えた id の答えを立てない | +| 受け入れ条件 10 / I10 | 問い直した行で、前の依頼の id へ答えが届く。前の印から今の先頭まで取り込みのコミットだけなら当たる。そうでなければ当てずに、区分 `unmatched` の知らせが届く | 前の依頼の id を読まない・取り込みでない差分でも当てる | +| 受け入れ条件 11 / I10 | 先頭が起点ブランチの取り込みのマージコミットだけで動いたとき、承認が当たり、依頼を送り直さない。設計文書か要求のコピーを変えるコミットがあるとき・取り込みでないコミットがあるとき・判定できないときは、新しい先頭で送り直し、前の id を `requests_before` に残す | 先頭が違えば常に送り直す・`--first-parent` で絞らずに起点のコミットを数える・判定できないときに当てる | +| 受け入れ条件 12 / I11 | 計画を始めると、前のループの答えがまだの行が、ループの名前・課題・設計 PR つきで問いの一覧に出る。レーンの周回の境で同じ行を送っても問いは 1 つのまま。答えるとそのループの `loop answer` が打たれる。先頭が取り込みでなく動いていれば、古い問いが置き換わる | 開始時だけ・境だけで送る・id を送った時刻や先頭から作る・答えをレーンの記録へ当てる | +| 受け入れ条件 12 / I12 | 前のループの待ちの問いだけが残る状態で全レーンが止まると、計画は終わり、要約の `prior_waits` に並ぶ | 前のループの問いで終わりを止める | +| 受け入れ条件 13 | 前のループに答えのまだの行がある課題は、レーンの選定で「人の判断待ち(開発ループ <名>)」として外れ、同じ境の後に問いの一覧にその行の問いがある。自分の行で依頼の id を持たないものは、境で送り直される | 前のループの行を見ずに棚卸しの判断待ちで外す・送れなかった依頼を送り直さない | | I2 | 箇条書きと番号付きの行を 1 項目ずつ数え、どちらも無ければ全文を 1 項目にする。コードブロックの中の行は数えない。自動の回は項目を持たない | 段落を項目に数える・自動の回の文面を項目に数える | | 受け入れ条件 7 | `.md` の文言は照合しない。文書のレビューで、表が `loop.md` にあり `parallel.md` が指していることを見る | — | @@ -419,12 +660,15 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 | 課題 | 扱い | 取り込み先 | 触るファイル | | --- | --- | --- | --- | -| #2510 | 実装する | — | `plugins/ndf/scripts/lib/revise_note.py`、`plugins/ndf/scripts/revise-note.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/supervise_lib/sprint_waves.py`、`plugins/ndf/scripts/supervise_lib/new_args.py`、`plugins/ndf/scripts/mvv-gate.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/loop.md`、`plugins/ndf/skills/development-workflow/references/parallel.md`、`plugins/ndf/skills/pr/SKILL.md` | +| #2510 | 実装する | — | `plugins/ndf/scripts/lib/revise_note.py`、`plugins/ndf/scripts/lib/gate1_requests.py`、`plugins/ndf/scripts/revise-note.py`、`plugins/ndf/scripts/lib/sprint_loop.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/lib/sprint_loop_gate2.py`、`plugins/ndf/scripts/lib/sprint_loop_select.py`、`plugins/ndf/scripts/lib/parallel_lane.py`、`plugins/ndf/scripts/lib/parallel_run.py`、`plugins/ndf/scripts/supervise_lib/sprint_waves.py`、`plugins/ndf/scripts/supervise_lib/new_args.py`、`plugins/ndf/scripts/mvv-gate.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/development-workflow/references/loop.md`、`plugins/ndf/skills/development-workflow/references/parallel.md`、`plugins/ndf/skills/pr/SKILL.md` | ## 未確認のまま残ること | 項目 | 内容 | | --- | --- | -| 自動の出し直しの上限(1 回)の妥当性 | 要求の未決。承認ゲート 1 で利用者が決める。変えるときは出し直しの指摘の部品の上限の判定だけを直す | | `new_args.py` の引数の受け方 | `--revise-note <課題>=<パス>` を既存の引数の定義に合わせて足せるかは、実装(`tdd-cycle`)で走らせて確かめる | | worker が表の識別子を入力どおりに書くか | 起動指示に識別子つきの項目を並べて書かせる。書き違えは突き合わせのコマンドが欠けとして拾い、自動の出し直しへ戻す。配布の後のリリース後テストで、実際の出し直しで確かめる | +| #2474 で `parallel.py answer` が起こす 1 行を返したか | 出力が記録に残っていない。決定 9 は、返したかによらず起こす 1 行を以後の知らせに添え続ける | +| 取り込みの判定の `git fetch` の費用 | 判定は先頭が動いたときだけ呼ぶ。1 回の fetch の秒は実装で測る | +| 棚卸しの判断待ち(`needs-decision`)のうち、どのループの記録にも行を持たないもの | 待ちを持つ行が無いため、受け入れ条件 13 の対象外とし、今の外し方のままにする。オーガナイザーへ届かない待ちとして残る | +| 前のループの待ちへの「直して出し直す」の後 | 答えはそのループの記録に当たるが、出し直しの設計は、そのループを再開するまで流れない。前のループの記録をレーンへ移すことは要求の対象範囲の外である | From 4ad1182eff6b5b04596ad6a9e9ea0c9ef662bee7 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 08:07:23 +0000 Subject: [PATCH 040/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E6=96=87=E6=9B=B8=E3=82=92=E7=B5=84=E3=81=BF=E7=9B=B4?= =?UTF-8?q?=E3=81=99=EF=BC=88=E3=83=87=E3=83=BC=E3=82=BF=E6=A7=8B=E9=80=A0?= =?UTF-8?q?=E3=81=A8=E5=85=A5=E5=87=BA=E5=8A=9B=E3=81=AE=E5=A5=91=E7=B4=84?= =?UTF-8?q?=E3=83=BB=E6=B1=BA=E5=AE=9A=E3=81=AE=E8=A8=98=E9=8C=B2=E3=82=92?= =?UTF-8?q?=E5=88=A5=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=81=B8=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=80=81=E7=AD=94=E3=81=88=E3=81=AE=E7=A8=AE=E9=A1=9E?= =?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AE=E6=89=8B=E9=A0=86=E3=82=92=E5=87=A6?= =?UTF-8?q?=E7=90=86=E3=81=AE=E6=B5=81=E3=82=8C=E3=81=AE=E5=BE=8C=E3=81=B8?= =?UTF-8?q?=E7=A7=BB=E3=81=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 本体が 674 行で 500 行を超えていたため、データ構造と入出力の契約を issue-2510-design-contracts.md へ、決定の記録を issue-2510-design-decisions.md へ分けた - 答えの種類ごとの手順の章を、決定の記録の後から処理の流れの後へ移した - 100 字を超える 2 文を分け、決定 11 の見出しを縮めた。内容は足していない Co-Authored-By: Claude Opus 5.5 --- issues/issue-2510-design-contracts.md | 105 +++++++++++++ issues/issue-2510-design-decisions.md | 95 ++++++++++++ issues/issue-2510-design.md | 213 ++------------------------ 3 files changed, 215 insertions(+), 198 deletions(-) create mode 100644 issues/issue-2510-design-contracts.md create mode 100644 issues/issue-2510-design-decisions.md diff --git a/issues/issue-2510-design-contracts.md b/issues/issue-2510-design-contracts.md new file mode 100644 index 000000000..579b64055 --- /dev/null +++ b/issues/issue-2510-design-contracts.md @@ -0,0 +1,105 @@ +# #2510 の設計: データ構造と入出力の契約 + +設計文書の本体は [issue-2510-design.md](issue-2510-design.md) にある。 + +## データ構造 + +### 人の判断待ちの行に足す欄 + +既存の欄(`note`・`revise_count`・`request`・`stamp` ほか)の意味は変えない。 + +| 欄 | 型 | 空の扱い | 意味 | +| --- | --- | --- | --- | +| `by` | 文字列 | 無ければ人の答え | `auto` のとき自動の出し直し | +| `missing` | 文字列の配列 | 無ければ欠けなし | 自動の出し直しで名指しした、届いていない項目の識別子(`lroot3:1-2` など) | +| `requests_before` | `{id, stamp}` の配列 | 無ければ送り直していない | 問い直すときに置き換えた前の依頼の id と、その依頼の印(設計 PR の先頭) | + +自動の出し直しの行は `answer` を `revise`、`note` を空にする。項目は人の回の指摘からだけ数えるため(I2)、自動の回は指摘の本文を持たない。`restamped`(今の欄)は残し、`requests_before` の最後の印と同じ値を持つ。 + +### 出し直しの指摘の入力(`<周回のディレクトリ>/revise-<課題>.json`) + +周回のスプリントを組むたびに、すべてのループの記録から作り直す。手で直さない。 + +```json +{ + "issue": 2338, + "rounds": [ + {"loop": "lroot2", "round": 1, "at": "2026-11-03T10:00:00Z", "pr": 2497, "by": "user", + "note": "- #2059 に絞る", "items": [{"id": "lroot2:1-1", "text": "#2059 に絞る"}], "missing": []}, + {"loop": "lroot3", "round": 1, "at": "2026-11-04T21:09:00Z", "pr": 2497, "by": "user", + "note": "- #2059 と #2300 に絞る\n- PR は 2 本にする", + "items": [{"id": "lroot3:1-1", "text": "#2059 と #2300 に絞る"}, {"id": "lroot3:1-2", "text": "PR は 2 本にする"}], + "missing": []}, + {"loop": "lroot3", "round": 2, "at": "2026-11-05T10:00:00Z", "pr": 2497, "by": "auto", "note": "", "items": [], + "missing": ["lroot3:1-2"]} + ] +} +``` + +### 課題の本文の履歴の節 + +見出しは `## 出し直しの指摘の履歴` で、ループの名前を入れない(ループを替えても 1 つの節に続けて書く)。1 回分は次の形で末尾へ足す。 + +```markdown +### 第 2 回(2026-11-05・設計 PR #2497・開発ループ lroot3・自動) + +届いていない項目: lroot3:1-2 +``` + +人の回は「自動」の代わりに「人」と書き、見出しの下に指摘の全文を置く。見出しのループの名前と回数の組(例: `開発ループ lroot3` の `第 2 回`)が同じ見出しが既にあれば足さない(I3)。回数が同じでもループの名前が違えば別の回として足す。 + +### 指摘への対応の表(設計文書と設計 PR の本文の `## 出し直しの指摘への対応`) + +| 列 | 書くこと | +| --- | --- | +| 項目 | 入力の項目の識別子(`lroot3:1-1`) | +| 指摘 | 項目の本文の要約 | +| 対応 | `直した` か `直せない` | +| 直した箇所・直せない理由 | 直した節の名前か、直せない理由 | + +### 問いの一覧に足す問い + +既にある設計の承認の依頼(`kind: gate1`、`lane` を持つ)の形は変えない。足すのは次の 2 つである。 + +| 問い | 欄 | id の決め方 | +| --- | --- | --- | +| 前のループの待ち | `kind: gate1`・`loop`(行を持つループの名前)・`issue`・`pr`・`lap`・`stamp`(送った時点の設計 PR の先頭)・`question`。`lane` を持たない | `<ループ>-w<課題>-l<行の周回>`。問い直すときは末尾に `-<新しい先頭の SHA の 12 桁>` を足す | +| 当たらなかった答え | `kind: unmatched`・`answered`(答えた依頼の id)・`lane`(レーンが知らせたとき)・`reason`・`question`・`options: ["確かめた"]` | `<答えた依頼の id>-unmatched` | + +同じ行の古い問い(`lane` か `loop` と `pr` が同じで id が違う)は、新しい問いを受けたときに一覧から外し、新しい問いの `replaced` に古い id を入れる(I11)。この置き換えは、レーンの設計の承認の依頼にも同じく当てる。 + +計画を終えたときの要約(`parallel.py wait` の終わりの出力)に、答えのまだの前のループの問いを `prior_waits` として並べる(I12)。 + +### CRUD 図 + +| 機能 | ループの記録の行 | 前のループの記録の行 | 入力ファイル | 履歴の節 | 設計文書の表 | 設計 PR の本文の表 | 問いの一覧 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| F1 | 読む | 読む | 作る | — | — | — | — | +| F2 | 更新 | — | — | 作る・追記 | — | — | — | +| F3 | — | — | 読む | — | 読む | 作る・更新 | — | +| F4 | 作る(自動の出し直し) | — | — | 追記 | — | 読む | — | +| F5 | — | — | 読む | — | — | 読む | — | +| F7 | 更新(答え) | — | — | — | — | — | 作る(unmatched)・削除 | +| F8 | 更新(`requests_before`) | — | — | — | — | — | 作る・置き換え | +| F9 | 読む | 読む・更新(`loop answer` だけ) | — | — | — | — | 作る・置き換え・削除 | + +## 入出力の契約 + +| コマンド | 足す引数 | 振る舞い | +| --- | --- | --- | +| `supervise.py new sprint` | `--revise-note <課題>=<パス>`(繰り返し可) | `--design` に含まれる課題の設計のプランだけを変える。ファイルを読めなければ組み立てを断る(終了コード 2) | +| `mvv-gate.py check --gate design` | `--revise-note <パス>` | 材料の先頭へ「出し直しの指摘」の節を足し、承認ゲートの問いへ判定の条件を足す。読めなければ承認ゲートへ戻す(今の材料を取れないときと同じ) | +| `revise-note.py check --pr <番号> --doc <設計文書> --notes <パス>` | 新規 | 終了コード 0(項目がそろった)/ 10(表が無い・行の無い項目・対応か箇所の欄が空の項目がある。届いていない項目を `items` に出す)/ 2(PR・設計文書・入力を読めない) | +| `parallel.py wait` | 無し | `questions` に区分 `unmatched` と前のループの待ちの問いが出る。終わりの出力に `prior_waits` が増える | +| `parallel.py answer` | 無し | 区分 `unmatched` の問いには `--choice 確かめた` で答える。前のループの待ちの問いには設計の承認と同じ `--approved` / `--revise --note` / `--rejected` で答える | + +`loop answer`・`parallel.py answer` の引数は変えない。 + +### 取り込みのコミットだけかの判定(`gate1_requests.merge_only`) + +入力は設計 PR の番号・課題の番号・問いの時点の先頭・今の先頭、出力は 3 値(`True` = 取り込みだけ、`False` = 違う、`None` = 判定できない)である。 + +1. 起点ブランチを `.ndf/worktree.json` の `base_branch` から読む(`repo.base_branch`)。読めなければ `None` +2. 起点ブランチと設計 PR の先頭を `git fetch` する。失敗すれば `None` +3. 問いの時点の先頭から今の先頭までの、設計 PR のブランチの上のコミットを並べる(`git rev-list --first-parent --parents <前>..<今>`)。それらがすべて親を 2 つ以上持ち、1 つ目以外の親がすべて `origin/<起点>` の祖先であることを確かめる。外れれば `False` +4. `git diff --quiet <前> <今> -- 'issues/issue-<課題>-*.md'` が 0 なら `True`、1 なら `False`、ほかは `None` diff --git a/issues/issue-2510-design-decisions.md b/issues/issue-2510-design-decisions.md new file mode 100644 index 000000000..5f1dbc519 --- /dev/null +++ b/issues/issue-2510-design-decisions.md @@ -0,0 +1,95 @@ +# #2510 の設計: 決定の記録 + +設計文書の本体は [issue-2510-design.md](issue-2510-design.md) にある。 + +## 決定 1: 設計のプランの入力の正本を 1 つにするため、ループの記録から周回ごとに入力ファイルを書き出して渡す + +設計のプランは `supervise.py new sprint` が組み、ループの記録を読まない。記録から書き出したファイルのパスを引数で渡せば、プランの組み立ては記録の形を知らずに済み、MVV 判定と表の突き合わせも同じファイルを読める。課題の本文の履歴の節は GitHub の書き込みに失敗しうるため、入力にしない(要求の前提 5)。 + +プランの組み立てが課題の本文を読む形は採らない。本文の節は書き込みの失敗と人の編集で揃わなくなり、#2338 の回避で本文の節が読まれなかった経路と同じ弱さを持つ。 + +根拠: Value 7 / Value 5(MVV 版 5) + +## 決定 2: 表の書き手を 1 つにするため、指摘への対応の表は設計のステップの worker が設計文書へ書き、突き合わせのコマンドが設計 PR の本文へ写す + +pr のステップは既にある設計 PR の本文を作り直すため、worker が本文へ直接書いた表は次の pr のステップで消える。設計文書に置けば、レビューの直しでも同じ差分で直り、MVV 判定も設計文書の材料として読む。本文へのコピーは、最後の送りの後の突き合わせのステップが行う。 + +pr のステップの材料に表を足す形は採らない。pr のステップは設計 PR を出す時点で 1 回しか本文を作らず、レビューの直しで変わった表を拾えない。 + +根拠: Value 5 / Value 8(MVV 版 5) + +## 決定 3: 機械が確かめられる範囲に絞るため、表の突き合わせは項目ごとの行と欄の有無だけを見て、中身が指摘を満たすかは MVV 判定に任せる + +行と欄の有無は決まった手順で判定でき、スクリプトが持てる。中身の充足は判断であり、既に承認ゲート 1 の手前にある MVV 判定に指摘の全文と問いを足せば、新しい判定の工程を作らずに済む。MVV 判定が「従わない」を返せば人へ問われる。 + +中身を判定する AI のステップを別に足す形は採らない。MVV 判定と同じ材料で同じ問いを 2 回問うことになる。 + +根拠: Value 5 / Value 3(MVV 版 5) + +## 決定 4: 表の欠けが MVV 判定で通らないようにするため、プランの中の突き合わせが欠けを見つけたら MVV 判定へ進まず承認ゲートで終える + +MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断待ちへ移す関数を通らない。#2494 はこの経路で指摘を反映しないまま通った。プランの中で欠けを見つけて承認ゲートで終えれば、周回の締めの共通の経路(人の判断待ちへ移す関数)が自動の出し直しを決める。自動で戻すかの判断は締めの 1 か所だけに置く。 + +プランの中で自動の出し直しまで決める形は採らない。上限の判定にループの記録が要り、プランはそれを読まない。 + +根拠: Value 7(MVV 版 5) + +## 決定 5: 指摘を満たす設計を 1 回で揃えやすくするため、要求と設計の起動指示へすべての回の指摘の全文を埋め込む + +起動指示にファイルのパスだけを書くと、worker が読まない経路が残る(#2338 の回避で本文の節が読まれなかった)。全文と項目の識別子を起動指示に置き、「この指摘を満たすように直す。満たせない項目は理由を書く」と、設計文書へ表を置く指示を添える。 + +最新の 1 回だけを渡す形は採らない。前の回で満たした項目を次の回で崩さないため(要求の前提 1)。 + +根拠: Value 2 / Value 7(MVV 版 5) + +## 決定 6: 課題の本文に指摘の全文を 2 か所持たないため、判断待ちの節は履歴の節の回を指す 1 行だけを載せる + +判断待ちの節は次に人の判断待ちへ移すときに書き直される。全文を履歴の節へ移し、判断待ちの節は「指摘: 節『出し直しの指摘の履歴』の開発ループ <名前> の第 N 回」と書けば、正本が本文の中でも 1 か所になる。 + +根拠: Value 8(MVV 版 5) + +## 決定 7: 答えの種類ごとの手順の正本を 1 つにするため、表を `loop.md` に置き、`parallel.md` から指す + +答えを読んで動くのはどちらの場合もループ(レーンもループである)で、`parallel.md` が持つのは答えを送る側の手順である。正本を読む側に置けば、手順の変更で直す文書が 1 つになる。 + +根拠: Value 8(MVV 版 5) + +## 決定 8: 別のループで送った指摘も満たすため、出し直しの指摘の入力はすべてのループの記録から回を集め、回をループの名前と回数の組で識別する + +要求の前提 1 は、別の開発ループで送った回も入力に含めると決めた。ループが替わると前のループの指示が届かない形は、受け入れ条件 12 と同じ根を持つ。回数はループごとに 1 から数えるため、項目の識別子にループの名前を入れて衝突を避ける。自動の出し直しの上限は、いま回っているループの記録の直前の行で数える。上限は人の出し直しの 1 回ごとに効くもので、前のループの回まで遡る必要が無いためである。 + +いま回っているループの回だけを集める形は採らない。前提 1 に合わず、ループを替えた直後の設計が前の指摘を読まない。 + +根拠: Value 7 / Value 2(MVV 版 5) + +## 決定 9: 答えを捨てないため、止まったループのレーンも起こす対象に入れ、当てられない答えは区分 `unmatched` の問いにする + +#2474 の原因は、止まったループのレーンを起こすことが、オーガナイザーが `metrics.next` の 1 行を打つことだけに頼っていた点にある([「#2474 の承認が当たらなかった原因」](issue-2510-design.md#2474-の承認が当たらなかった原因))。1 スプリントのレーンには、耐久ワークフローが起こす 1 行を以後の知らせに添え続ける仕組み(`revive`・`wake`)が既にある。ループのレーンもこの対象に入れれば、新しい経路を作らずに済む。 + +当てられない答え(一覧に無い id・置き換えた id・前の印から取り込みだけでない・前のループへ当てられない)は、今は耐久ワークフローの受け取りか、レーンの読み取りで黙って捨てられる。区分 `unmatched` の問いにすれば、オーガナイザーが `parallel.py wait` の `questions` で知り、答えるまで計画は終わらない。答えのイベント自体は立てたまま残すため、後から同じ id の依頼が届いても当たる。 + +耐久ワークフローが自分でレーンを起こし直す形は採らない。今の決まりは、レーンを起こす操作をオーガナイザーの 1 行に置いている(`parallel.md` の「オーガナイザーが打つのは…終わったレーンを起こす 1 行だけ」)。この課題では起こす 1 行の届け方だけを直し、担い手は変えない。 + +根拠: Value 7 / Vision(MVV 版 5) + +## 決定 10: 問い直しの判断と答えの当て方をそろえるため、取り込みのコミットだけかの判定を承認の依頼の部品の 1 つの関数に置く + +要求の非機能の条件は、依頼を送り直すかの判断と答えを当てるかの判断が同じ関数を使うことを求める。レーンの問い直し・レーンの前の依頼への答え・耐久ワークフローの前のループへの答えの 3 か所が、この関数だけを呼ぶ。判定は git の履歴と差分で決まり、AI の判断を要らない。 + +判定は、設計 PR のブランチの上のコミット(`--first-parent`)だけを見る。取り込んだ起点ブランチのコミットは、親を辿ると範囲に入るため、ブランチの上だけに絞る。そのうえで、設計文書と要求のコピー(`issues/issue-<課題>-*.md`)の中身が変わらないことを確かめる。 + +コミットの題(`Merge branch 'develop'` など)で判定する形は採らない。題は書き換えられ、要求の前提 7 の「中身が同じ」を確かめられない。 + +根拠: Value 5 / Value 7(MVV 版 5) + +## 決定 11: 前のループの待ちを埋もれさせず、その記録の持ち主を変えないため、耐久ワークフローとレーンが同じ関数で送り、答えは `loop answer` で当てる + +要求の未決は、送る担い手と、同じ行を 2 回出さない方法を設計に委ねた。開始時は、まだレーンが周回の境を通っていないため、耐久ワークフローが送る。周回の境はレーンにしか無いため、レーンが送る。どちらも承認の依頼の部品の同じ関数で依頼を作る。依頼の id を行(ループの名前・課題・周回)から決めるため、両方から送っても、耐久ワークフローの受け取りが同じ id を 1 つにまとめる(I11)。 + +答えを当てるのは耐久ワークフローである。前のループにはレーンが無く、周回の境で答えを読む持ち主がいない。`loop answer` を呼べば、記録の書き込みはそのループの集約の関数のまま、要求の「答えを当てる以外は前のループの記録を書き換えない」に収まる。 + +前のループの待ちの問いは、計画を終えることを止めない(I12)。前のループの判断は計画の仕事の外にあり、止めると計画の終わりが計画の外の判断に縛られる。終えたときの要約に並べて、埋もれないようにする。 + +レーンが前のループの行を自分の記録へ写す形は採らない。要求の対象範囲が「前のループの記録を計画のレーンへ移す・合わせること」を含まないとしている。 + +根拠: Value 2 / Value 7(MVV 版 5) diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md index aed2c31ae..6a760c355 100644 --- a/issues/issue-2510-design.md +++ b/issues/issue-2510-design.md @@ -31,6 +31,7 @@ | ループ lroot2 の記録に、#2337 の設計 PR #2412 の待ちが 11/02 から依頼の id を持たずに残っていた | 実測(`.ndf/loop/lroot2/loop.json`) | 計画のレーンでないループの待ちは、どの経路でもオーガナイザーへ送られない | 要求と受け入れ条件は #2510 の本文にある(コピーは [issues/issue-2510-requirements.md](issue-2510-requirements.md) )。この文書は「どう作るか」だけを扱う。 +データ構造と入出力の契約は [issue-2510-design-contracts.md](issue-2510-design-contracts.md) に、決定の記録は [issue-2510-design-decisions.md](issue-2510-design-decisions.md) にある。 ### #2474 の承認が当たらなかった原因 @@ -306,105 +307,11 @@ classDiagram ## データ構造 -### 人の判断待ちの行に足す欄 - -既存の欄(`note`・`revise_count`・`request`・`stamp` ほか)の意味は変えない。 - -| 欄 | 型 | 空の扱い | 意味 | -| --- | --- | --- | --- | -| `by` | 文字列 | 無ければ人の答え | `auto` のとき自動の出し直し | -| `missing` | 文字列の配列 | 無ければ欠けなし | 自動の出し直しで名指しした、届いていない項目の識別子(`lroot3:1-2` など) | -| `requests_before` | `{id, stamp}` の配列 | 無ければ送り直していない | 問い直すときに置き換えた前の依頼の id と、その依頼の印(設計 PR の先頭) | - -自動の出し直しの行は `answer` を `revise`、`note` を空にする。項目は人の回の指摘からだけ数えるため(I2)、自動の回は指摘の本文を持たない。`restamped`(今の欄)は残し、`requests_before` の最後の印と同じ値を持つ。 - -### 出し直しの指摘の入力(`<周回のディレクトリ>/revise-<課題>.json`) - -周回のスプリントを組むたびに、すべてのループの記録から作り直す。手で直さない。 - -```json -{ - "issue": 2338, - "rounds": [ - {"loop": "lroot2", "round": 1, "at": "2026-11-03T10:00:00Z", "pr": 2497, "by": "user", - "note": "- #2059 に絞る", "items": [{"id": "lroot2:1-1", "text": "#2059 に絞る"}], "missing": []}, - {"loop": "lroot3", "round": 1, "at": "2026-11-04T21:09:00Z", "pr": 2497, "by": "user", - "note": "- #2059 と #2300 に絞る\n- PR は 2 本にする", - "items": [{"id": "lroot3:1-1", "text": "#2059 と #2300 に絞る"}, {"id": "lroot3:1-2", "text": "PR は 2 本にする"}], - "missing": []}, - {"loop": "lroot3", "round": 2, "at": "2026-11-05T10:00:00Z", "pr": 2497, "by": "auto", "note": "", "items": [], - "missing": ["lroot3:1-2"]} - ] -} -``` - -### 課題の本文の履歴の節 - -見出しは `## 出し直しの指摘の履歴` で、ループの名前を入れない(ループを替えても 1 つの節に続けて書く)。1 回分は次の形で末尾へ足す。 - -```markdown -### 第 2 回(2026-11-05・設計 PR #2497・開発ループ lroot3・自動) - -届いていない項目: lroot3:1-2 -``` - -人の回は「自動」の代わりに「人」と書き、見出しの下に指摘の全文を置く。見出しのループの名前と回数の組(例: `開発ループ lroot3` の `第 2 回`)が同じ見出しが既にあれば足さない(I3)。回数が同じでもループの名前が違えば別の回として足す。 - -### 指摘への対応の表(設計文書と設計 PR の本文の `## 出し直しの指摘への対応`) - -| 列 | 書くこと | -| --- | --- | -| 項目 | 入力の項目の識別子(`lroot3:1-1`) | -| 指摘 | 項目の本文の要約 | -| 対応 | `直した` か `直せない` | -| 直した箇所・直せない理由 | 直した節の名前か、直せない理由 | - -### 問いの一覧に足す問い - -既にある設計の承認の依頼(`kind: gate1`、`lane` を持つ)の形は変えない。足すのは次の 2 つである。 - -| 問い | 欄 | id の決め方 | -| --- | --- | --- | -| 前のループの待ち | `kind: gate1`・`loop`(行を持つループの名前)・`issue`・`pr`・`lap`・`stamp`(送った時点の設計 PR の先頭)・`question`。`lane` を持たない | `<ループ>-w<課題>-l<行の周回>`。問い直すときは末尾に `-<新しい先頭の SHA の 12 桁>` を足す | -| 当たらなかった答え | `kind: unmatched`・`answered`(答えた依頼の id)・`lane`(レーンが知らせたとき)・`reason`・`question`・`options: ["確かめた"]` | `<答えた依頼の id>-unmatched` | - -同じ行の古い問い(`lane` か `loop` と `pr` が同じで id が違う)は、新しい問いを受けたときに一覧から外し、新しい問いの `replaced` に古い id を入れる(I11)。この置き換えは、レーンの設計の承認の依頼にも同じく当てる。 - -計画を終えたときの要約(`parallel.py wait` の終わりの出力)に、答えのまだの前のループの問いを `prior_waits` として並べる(I12)。 - -### CRUD 図 - -| 機能 | ループの記録の行 | 前のループの記録の行 | 入力ファイル | 履歴の節 | 設計文書の表 | 設計 PR の本文の表 | 問いの一覧 | -| --- | --- | --- | --- | --- | --- | --- | --- | -| F1 | 読む | 読む | 作る | — | — | — | — | -| F2 | 更新 | — | — | 作る・追記 | — | — | — | -| F3 | — | — | 読む | — | 読む | 作る・更新 | — | -| F4 | 作る(自動の出し直し) | — | — | 追記 | — | 読む | — | -| F5 | — | — | 読む | — | — | 読む | — | -| F7 | 更新(答え) | — | — | — | — | — | 作る(unmatched)・削除 | -| F8 | 更新(`requests_before`) | — | — | — | — | — | 作る・置き換え | -| F9 | 読む | 読む・更新(`loop answer` だけ) | — | — | — | — | 作る・置き換え・削除 | +データ構造は [issue-2510-design-contracts.md の「データ構造」](issue-2510-design-contracts.md#データ構造) にある。 ## 入出力の契約 -| コマンド | 足す引数 | 振る舞い | -| --- | --- | --- | -| `supervise.py new sprint` | `--revise-note <課題>=<パス>`(繰り返し可) | `--design` に含まれる課題の設計のプランだけを変える。ファイルを読めなければ組み立てを断る(終了コード 2) | -| `mvv-gate.py check --gate design` | `--revise-note <パス>` | 材料の先頭へ「出し直しの指摘」の節を足し、承認ゲートの問いへ判定の条件を足す。読めなければ承認ゲートへ戻す(今の材料を取れないときと同じ) | -| `revise-note.py check --pr <番号> --doc <設計文書> --notes <パス>` | 新規 | 終了コード 0(項目がそろった)/ 10(表が無い・行の無い項目・対応か箇所の欄が空の項目がある。届いていない項目を `items` に出す)/ 2(PR・設計文書・入力を読めない) | -| `parallel.py wait` | 無し | `questions` に区分 `unmatched` と前のループの待ちの問いが出る。終わりの出力に `prior_waits` が増える | -| `parallel.py answer` | 無し | 区分 `unmatched` の問いには `--choice 確かめた` で答える。前のループの待ちの問いには設計の承認と同じ `--approved` / `--revise --note` / `--rejected` で答える | - -`loop answer`・`parallel.py answer` の引数は変えない。 - -### 取り込みのコミットだけかの判定(`gate1_requests.merge_only`) - -入力は設計 PR の番号・課題の番号・問いの時点の先頭・今の先頭、出力は 3 値(`True` = 取り込みだけ、`False` = 違う、`None` = 判定できない)である。 - -1. 起点ブランチを `.ndf/worktree.json` の `base_branch` から読む(`repo.base_branch`)。読めなければ `None` -2. 起点ブランチと設計 PR の先頭を `git fetch` する。失敗すれば `None` -3. 問いの時点の先頭から今の先頭までの、設計 PR のブランチの上のコミット(`git rev-list --first-parent --parents <前>..<今>`)が、すべて親を 2 つ以上持ち、1 つ目以外の親がすべて `origin/<起点>` の祖先であることを確かめる。外れれば `False` -4. `git diff --quiet <前> <今> -- 'issues/issue-<課題>-*.md'` が 0 なら `True`、1 なら `False`、ほかは `None` +入出力の契約は [issue-2510-design-contracts.md の「入出力の契約」](issue-2510-design-contracts.md#入出力の契約) にある。 ## 処理の流れ @@ -478,7 +385,7 @@ sequenceDiagram L->>L: 先頭を比べて当てる ``` -起こす対象は、問いの持ち主のレーンのうち止まっているもので、1 スプリントのレーンに加えて、記録が `waiting_only` で答えのまだの行を持つループのレーンを含める(今の `step_waiting_lanes` と同じ判定)。起こす 1 行は、そのレーンが動き出すまで、以後の知らせ(`pause` の `wake`)に添え続ける。 +起こす対象は、問いの持ち主のレーンのうち止まっているものである。1 スプリントのレーンに加えて、記録が `waiting_only` で答えのまだの行を持つループのレーンを含める(今の `step_waiting_lanes` と同じ判定)。起こす 1 行は、そのレーンが動き出すまで、以後の知らせ(`pause` の `wake`)に添え続ける。 レーンが答えを読んで当てるときの判断は次のとおりである。 @@ -518,107 +425,6 @@ sequenceDiagram 承認の答えを当てる前に、問いの `stamp` と今の先頭を判定の関数で比べる。取り込みのコミットだけなら `loop answer <ループ> <課題> --approved --pr <設計 PR>` を打つ。違えば当てずに新しい先頭で問いを置き換える。直して出し直すは、指摘を計画の置き場(`.ndf/parallel/<計画>/prior/.md`)へ書いて `--revise --note` で渡し、やらないは `--rejected` で渡す。`loop answer` が 0 以外で終われば、理由を添えて区分 `unmatched` の問いにする。 -## 非機能の実現方式 - -| 大項目 | 実現方式 | -| --- | --- | -| 運用・保守性 | 指摘の読み取り・項目の数え方・表の解析は出し直しの指摘の部品の関数だけが持つ。開発ループ・並列の計画のレーン・設計のプランの組み立て・MVV 判定・表の突き合わせのコマンドは、すべてこの部品を呼ぶ。取り込みのコミットの判定と前のループの待ちの読み出しは承認の依頼の部品だけが持ち、レーンの問い直し・レーンの答えの当て方・耐久ワークフローの前のループへの当て方が同じ関数を呼ぶ | -| 移行性 | 既存の課題の本文の判断待ちの節に残る指摘は移さない。ループの記録の既存の `note` はそのまま入力の回として読める(`by` の無い行は人の回として扱う)。`requests_before` の無い行は、送り直していない行として扱う。既にある前のループの答えがまだの行は、この変更の後に計画を始めたときか、次のレーンの周回の境で問いの一覧に出る | - -## 決定の記録 - -### 決定 1: 設計のプランの入力の正本を 1 つにするため、ループの記録から周回ごとに入力ファイルを書き出して渡す - -設計のプランは `supervise.py new sprint` が組み、ループの記録を読まない。記録から書き出したファイルのパスを引数で渡せば、プランの組み立ては記録の形を知らずに済み、MVV 判定と表の突き合わせも同じファイルを読める。課題の本文の履歴の節は GitHub の書き込みに失敗しうるため、入力にしない(要求の前提 5)。 - -プランの組み立てが課題の本文を読む形は採らない。本文の節は書き込みの失敗と人の編集で揃わなくなり、#2338 の回避で本文の節が読まれなかった経路と同じ弱さを持つ。 - -根拠: Value 7 / Value 5(MVV 版 5) - -### 決定 2: 表の書き手を 1 つにするため、指摘への対応の表は設計のステップの worker が設計文書へ書き、突き合わせのコマンドが設計 PR の本文へ写す - -pr のステップは既にある設計 PR の本文を作り直すため、worker が本文へ直接書いた表は次の pr のステップで消える。設計文書に置けば、レビューの直しでも同じ差分で直り、MVV 判定も設計文書の材料として読む。本文へのコピーは、最後の送りの後の突き合わせのステップが行う。 - -pr のステップの材料に表を足す形は採らない。pr のステップは設計 PR を出す時点で 1 回しか本文を作らず、レビューの直しで変わった表を拾えない。 - -根拠: Value 5 / Value 8(MVV 版 5) - -### 決定 3: 機械が確かめられる範囲に絞るため、表の突き合わせは項目ごとの行と欄の有無だけを見て、中身が指摘を満たすかは MVV 判定に任せる - -行と欄の有無は決まった手順で判定でき、スクリプトが持てる。中身の充足は判断であり、既に承認ゲート 1 の手前にある MVV 判定に指摘の全文と問いを足せば、新しい判定の工程を作らずに済む。MVV 判定が「従わない」を返せば人へ問われる。 - -中身を判定する AI のステップを別に足す形は採らない。MVV 判定と同じ材料で同じ問いを 2 回問うことになる。 - -根拠: Value 5 / Value 3(MVV 版 5) - -### 決定 4: 表の欠けが MVV 判定で通らないようにするため、プランの中の突き合わせが欠けを見つけたら MVV 判定へ進まず承認ゲートで終える - -MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断待ちへ移す関数を通らない。#2494 はこの経路で指摘を反映しないまま通った。プランの中で欠けを見つけて承認ゲートで終えれば、周回の締めの共通の経路(人の判断待ちへ移す関数)が自動の出し直しを決める。自動で戻すかの判断は締めの 1 か所だけに置く。 - -プランの中で自動の出し直しまで決める形は採らない。上限の判定にループの記録が要り、プランはそれを読まない。 - -根拠: Value 7(MVV 版 5) - -### 決定 5: 指摘を満たす設計を 1 回で揃えやすくするため、要求と設計の起動指示へすべての回の指摘の全文を埋め込む - -起動指示にファイルのパスだけを書くと、worker が読まない経路が残る(#2338 の回避で本文の節が読まれなかった)。全文と項目の識別子を起動指示に置き、「この指摘を満たすように直す。満たせない項目は理由を書く」と、設計文書へ表を置く指示を添える。 - -最新の 1 回だけを渡す形は採らない。前の回で満たした項目を次の回で崩さないため(要求の前提 1)。 - -根拠: Value 2 / Value 7(MVV 版 5) - -### 決定 6: 課題の本文に指摘の全文を 2 か所持たないため、判断待ちの節は履歴の節の回を指す 1 行だけを載せる - -判断待ちの節は次に人の判断待ちへ移すときに書き直される。全文を履歴の節へ移し、判断待ちの節は「指摘: 節『出し直しの指摘の履歴』の開発ループ <名前> の第 N 回」と書けば、正本が本文の中でも 1 か所になる。 - -根拠: Value 8(MVV 版 5) - -### 決定 7: 答えの種類ごとの手順の正本を 1 つにするため、表を `loop.md` に置き、`parallel.md` から指す - -答えを読んで動くのはどちらの場合もループ(レーンもループである)で、`parallel.md` が持つのは答えを送る側の手順である。正本を読む側に置けば、手順の変更で直す文書が 1 つになる。 - -根拠: Value 8(MVV 版 5) - -### 決定 8: 別のループで送った指摘も満たすため、出し直しの指摘の入力はすべてのループの記録から回を集め、回をループの名前と回数の組で識別する - -要求の前提 1 は、別の開発ループで送った回も入力に含めると決めた。ループが替わると前のループの指示が届かない形は、受け入れ条件 12 と同じ根を持つ。回数はループごとに 1 から数えるため、項目の識別子にループの名前を入れて衝突を避ける。自動の出し直しの上限は、いま回っているループの記録の直前の行で数える。上限は人の出し直しの 1 回ごとに効くもので、前のループの回まで遡る必要が無いためである。 - -いま回っているループの回だけを集める形は採らない。前提 1 に合わず、ループを替えた直後の設計が前の指摘を読まない。 - -根拠: Value 7 / Value 2(MVV 版 5) - -### 決定 9: 答えを捨てないため、止まったループのレーンも起こす対象に入れ、当てられない答えは区分 `unmatched` の問いにする - -#2474 の原因は、止まったループのレーンを起こすことが、オーガナイザーが `metrics.next` の 1 行を打つことだけに頼っていた点にある(「#2474 の承認が当たらなかった原因」)。1 スプリントのレーンには、耐久ワークフローが起こす 1 行を以後の知らせに添え続ける仕組み(`revive`・`wake`)が既にある。ループのレーンもこの対象に入れれば、新しい経路を作らずに済む。 - -当てられない答え(一覧に無い id・置き換えた id・前の印から取り込みだけでない・前のループへ当てられない)は、今は耐久ワークフローの受け取りか、レーンの読み取りで黙って捨てられる。区分 `unmatched` の問いにすれば、オーガナイザーが `parallel.py wait` の `questions` で知り、答えるまで計画は終わらない。答えのイベント自体は立てたまま残すため、後から同じ id の依頼が届いても当たる。 - -耐久ワークフローが自分でレーンを起こし直す形は採らない。今の決まりは、レーンを起こす操作をオーガナイザーの 1 行に置いている(`parallel.md` の「オーガナイザーが打つのは…終わったレーンを起こす 1 行だけ」)。この課題では起こす 1 行の届け方だけを直し、担い手は変えない。 - -根拠: Value 7 / Vision(MVV 版 5) - -### 決定 10: 問い直しの判断と答えの当て方をそろえるため、取り込みのコミットだけかの判定を承認の依頼の部品の 1 つの関数に置く - -要求の非機能の条件は、依頼を送り直すかの判断と答えを当てるかの判断が同じ関数を使うことを求める。レーンの問い直し・レーンの前の依頼への答え・耐久ワークフローの前のループへの答えの 3 か所が、この関数だけを呼ぶ。判定は git の履歴と差分で決まり、AI の判断を要らない。 - -判定は、設計 PR のブランチの上のコミット(`--first-parent`)だけを見る。取り込んだ起点ブランチのコミットは、親を辿ると範囲に入るため、ブランチの上だけに絞る。そのうえで、設計文書と要求のコピー(`issues/issue-<課題>-*.md`)の中身が変わらないことを確かめる。 - -コミットの題(`Merge branch 'develop'` など)で判定する形は採らない。題は書き換えられ、要求の前提 7 の「中身が同じ」を確かめられない。 - -根拠: Value 5 / Value 7(MVV 版 5) - -### 決定 11: 前のループの待ちを埋もれさせず、前のループの記録の持ち主を変えないため、耐久ワークフローが開始時に、レーンが周回の境に同じ関数で送り、答えは耐久ワークフローがそのループの `loop answer` で当てる - -要求の未決は、送る担い手と、同じ行を 2 回出さない方法を設計に委ねた。開始時は、まだレーンが周回の境を通っていないため、耐久ワークフローが送る。周回の境はレーンにしか無いため、レーンが送る。どちらも承認の依頼の部品の同じ関数で依頼を作る。依頼の id を行(ループの名前・課題・周回)から決めるため、両方から送っても、耐久ワークフローの受け取りが同じ id を 1 つにまとめる(I11)。 - -答えを当てるのは耐久ワークフローである。前のループにはレーンが無く、周回の境で答えを読む持ち主がいない。`loop answer` を呼べば、記録の書き込みはそのループの集約の関数のまま、要求の「答えを当てる以外は前のループの記録を書き換えない」に収まる。 - -前のループの待ちの問いは、計画を終えることを止めない(I12)。前のループの判断は計画の仕事の外にあり、止めると計画の終わりが計画の外の判断に縛られる。終えたときの要約に並べて、埋もれないようにする。 - -レーンが前のループの行を自分の記録へ写す形は採らない。要求の対象範囲が「前のループの記録を計画のレーンへ移す・合わせること」を含まないとしている。 - -根拠: Value 2 / Value 7(MVV 版 5) - ## 答えの種類ごとの手順(手順書に書く表の中身) `loop.md` の「承認ゲート」の節に置く表の中身を決める。 @@ -633,6 +439,17 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 要求の前提 6 にある `sprint-state.py loop budget` は、今の CLI に無い。`lib/sprint_loop_cli.py` が持つ予算の引数は、`loop start` の `--budget-laps`・`--budget-usd`・`--budget-hours` の 3 つだけである。予算の手順は `loop start --budget-*` だけを書く。 +## 非機能の実現方式 + +| 大項目 | 実現方式 | +| --- | --- | +| 運用・保守性 | 指摘の読み取り・項目の数え方・表の解析は出し直しの指摘の部品の関数だけが持つ。開発ループ・並列の計画のレーン・設計のプランの組み立て・MVV 判定・表の突き合わせのコマンドは、すべてこの部品を呼ぶ。取り込みのコミットの判定と前のループの待ちの読み出しは承認の依頼の部品だけが持ち、レーンの問い直し・レーンの答えの当て方・耐久ワークフローの前のループへの当て方が同じ関数を呼ぶ | +| 移行性 | 既存の課題の本文の判断待ちの節に残る指摘は移さない。ループの記録の既存の `note` はそのまま入力の回として読める(`by` の無い行は人の回として扱う)。`requests_before` の無い行は、送り直していない行として扱う。既にある前のループの答えがまだの行は、この変更の後に計画を始めたときか、次のレーンの周回の境で問いの一覧に出る | + +## 決定の記録 + +決定 1〜11 は [issue-2510-design-decisions.md](issue-2510-design-decisions.md) にある。 + ## テスト設計 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | From e7b62a377eb336e73a444469d7eedd76cc692165 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 08:13:55 +0000 Subject: [PATCH 041/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=E3=80=81=E7=B7=A0=E3=82=81=E3=81=AE=E7=AA=81?= =?UTF-8?q?=E3=81=8D=E5=90=88=E3=82=8F=E3=81=9B=E3=81=8C=E8=A1=A8=E3=82=92?= =?UTF-8?q?=E6=9C=AC=E6=96=87=E3=81=B8=E5=86=99=E3=81=99=E3=81=93=E3=81=A8?= =?UTF-8?q?=E3=83=BB=E5=89=8D=E3=81=AE=E4=BE=9D=E9=A0=BC=E3=81=AE=20id=20?= =?UTF-8?q?=E3=82=92=E5=88=A4=E5=AE=9A=E6=B8=88=E3=81=BF=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=E3=81=93=E3=81=A8=E3=83=BB=E7=BD=AE=E3=81=8D=E6=8F=9B?= =?UTF-8?q?=E3=81=88=E3=81=9F=20id=20=E3=81=AE=E7=AD=94=E3=81=88=E3=81=A7?= =?UTF-8?q?=E6=8C=81=E3=81=A1=E4=B8=BB=E3=82=92=E8=B5=B7=E3=81=93=E3=81=99?= =?UTF-8?q?=E3=81=93=E3=81=A8=E3=82=92=E5=AE=9A=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2510-design-contracts.md | 18 +++++++++++++++--- issues/issue-2510-design-decisions.md | 2 +- issues/issue-2510-design.md | 25 +++++++++++++------------ 3 files changed, 29 insertions(+), 16 deletions(-) diff --git a/issues/issue-2510-design-contracts.md b/issues/issue-2510-design-contracts.md index 579b64055..2205d323c 100644 --- a/issues/issue-2510-design-contracts.md +++ b/issues/issue-2510-design-contracts.md @@ -12,7 +12,12 @@ | --- | --- | --- | --- | | `by` | 文字列 | 無ければ人の答え | `auto` のとき自動の出し直し | | `missing` | 文字列の配列 | 無ければ欠けなし | 自動の出し直しで名指しした、届いていない項目の識別子(`lroot3:1-2` など) | -| `requests_before` | `{id, stamp}` の配列 | 無ければ送り直していない | 問い直すときに置き換えた前の依頼の id と、その依頼の印(設計 PR の先頭) | +| `requests_before` | `{id, stamp, settled}` の配列 | 無ければ送り直していない。`settled` が無ければ `false` | 問い直すときに置き換えた前の依頼の id と、その依頼の印(設計 PR の先頭)。`settled` が `true` の要素は、その id への答えを判定し終えたもので、答えを読む依頼の一覧に入れない | + +`requests_before` の要素は消さず、判定し終えたら `settled` を `true` にする(`restamped` との対応と、置き換えた id の持ち主を引く手がかりを残すため)。`true` にするのは次の 2 つの時点である。 + +- 問い直しの判断(`regate1`)が、今の依頼への答えを読んだうえで先頭の動きを `False` か `None` と判定して問い直すとき。その依頼の id は `settled: true` で移す(答えは判定済みで、次の境から前の依頼の経路で読み直すと区分 `unmatched` の知らせが毎回出るため)。答えが届く前に先頭の動きで問い直すときは `settled: false` で移す +- 設計の承認の答えを当てる関数が、前の依頼の id への答えを判定したとき。当てたときも、当たらなかった答えとして知らせたときも `true` にする 自動の出し直しの行は `answer` を `revise`、`note` を空にする。項目は人の回の指摘からだけ数えるため(I2)、自動の回は指摘の本文を持たない。`restamped`(今の欄)は残し、`requests_before` の最後の印と同じ値を持つ。 @@ -66,7 +71,14 @@ | 前のループの待ち | `kind: gate1`・`loop`(行を持つループの名前)・`issue`・`pr`・`lap`・`stamp`(送った時点の設計 PR の先頭)・`question`。`lane` を持たない | `<ループ>-w<課題>-l<行の周回>`。問い直すときは末尾に `-<新しい先頭の SHA の 12 桁>` を足す | | 当たらなかった答え | `kind: unmatched`・`answered`(答えた依頼の id)・`lane`(レーンが知らせたとき)・`reason`・`question`・`options: ["確かめた"]` | `<答えた依頼の id>-unmatched` | -同じ行の古い問い(`lane` か `loop` と `pr` が同じで id が違う)は、新しい問いを受けたときに一覧から外し、新しい問いの `replaced` に古い id を入れる(I11)。この置き換えは、レーンの設計の承認の依頼にも同じく当てる。 +同じ行の古い問い(`lane` か `loop` と `pr` が同じで id が違う)は、新しい問いを受けたときに一覧から外し、新しい問いの `replaced` に古い問いの `{id, stamp}` と、古い問いの `replaced` の要素を引き継いで入れる(I11)。この置き換えは、レーンの設計の承認の依頼にも同じく当てる。`replaced` は、置き換えた id への答えの持ち主を耐久ワークフローが引く手がかりである。 + +置き換えた id への答えは、`replaced` にその id を持つ今の問いの持ち主へ流す。 + +- 持ち主がレーンなら、今の問いへの答えと同じく、止まっていればそのレーンを起こす対象へ入れる。当てるかどうかはレーンが周回の境で前の依頼の経路(前の印から今の先頭まで取り込みだけか)で判定し、当たらなければレーンが区分 `unmatched` の知らせを送る +- 持ち主が前のループの待ちなら、耐久ワークフローが `replaced` の `stamp` と今の先頭を判定の関数で比べ、取り込みのコミットだけなら `loop answer` で当てる。違えば区分 `unmatched` の問いにする + +どの問いの `replaced` にも無く、一覧にも無い id への答えだけを、耐久ワークフローが受けた時点で区分 `unmatched` の問いにする。 計画を終えたときの要約(`parallel.py wait` の終わりの出力)に、答えのまだの前のループの問いを `prior_waits` として並べる(I12)。 @@ -77,7 +89,7 @@ | F1 | 読む | 読む | 作る | — | — | — | — | | F2 | 更新 | — | — | 作る・追記 | — | — | — | | F3 | — | — | 読む | — | 読む | 作る・更新 | — | -| F4 | 作る(自動の出し直し) | — | — | 追記 | — | 読む | — | +| F4 | 作る(自動の出し直し) | — | — | 追記 | 読む | 作る・更新 | — | | F5 | — | — | 読む | — | — | 読む | — | | F7 | 更新(答え) | — | — | — | — | — | 作る(unmatched)・削除 | | F8 | 更新(`requests_before`) | — | — | — | — | — | 作る・置き換え | diff --git a/issues/issue-2510-design-decisions.md b/issues/issue-2510-design-decisions.md index 5f1dbc519..5f0895914 100644 --- a/issues/issue-2510-design-decisions.md +++ b/issues/issue-2510-design-decisions.md @@ -66,7 +66,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 #2474 の原因は、止まったループのレーンを起こすことが、オーガナイザーが `metrics.next` の 1 行を打つことだけに頼っていた点にある([「#2474 の承認が当たらなかった原因」](issue-2510-design.md#2474-の承認が当たらなかった原因))。1 スプリントのレーンには、耐久ワークフローが起こす 1 行を以後の知らせに添え続ける仕組み(`revive`・`wake`)が既にある。ループのレーンもこの対象に入れれば、新しい経路を作らずに済む。 -当てられない答え(一覧に無い id・置き換えた id・前の印から取り込みだけでない・前のループへ当てられない)は、今は耐久ワークフローの受け取りか、レーンの読み取りで黙って捨てられる。区分 `unmatched` の問いにすれば、オーガナイザーが `parallel.py wait` の `questions` で知り、答えるまで計画は終わらない。答えのイベント自体は立てたまま残すため、後から同じ id の依頼が届いても当たる。 +置き換えた id への答えは、問いの一覧の `replaced` から持ち主を引き、今の問いへの答えと同じく持ち主を起こす。問い直した後に止まったレーンへも答えが届き、前の印から取り込みだけなら当たる。当てられない答え(一覧にも `replaced` にも無い id・前の印から取り込みだけでない・前のループへ当てられない)は、今は耐久ワークフローの受け取りか、レーンの読み取りで黙って捨てられる。区分 `unmatched` の問いにすれば、オーガナイザーが `parallel.py wait` の `questions` で知り、答えるまで計画は終わらない。答えのイベント自体は立てたまま残すため、後から同じ id の依頼が届いても当たる。 耐久ワークフローが自分でレーンを起こし直す形は採らない。今の決まりは、レーンを起こす操作をオーガナイザーの 1 行に置いている(`parallel.md` の「オーガナイザーが打つのは…終わったレーンを起こす 1 行だけ」)。この課題では起こす 1 行の届け方だけを直し、担い手は変えない。 diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md index 6a760c355..3488daef0 100644 --- a/issues/issue-2510-design.md +++ b/issues/issue-2510-design.md @@ -145,7 +145,7 @@ | 表の突き合わせのコマンド(新規 `revise-note.py check`) | 設計文書の「出し直しの指摘への対応」の節を設計 PR の本文へ写し、入力の項目と突き合わせる。そろえば 0、欠ければ 10(承認ゲートで止める)、読めなければ 2 | | 開発ループの答えを当てる関数(`lib/sprint_loop.py` の `apply_answer`) | 直して出し直すの答えで、記録の行を書いた後に履歴の節へ追記する | | 周回のスプリントの組み立て(`lib/sprint_loop.py` の `cmd_sprint`・`new_sprint_argv`) | 設計する課題のうち出し直しの指摘を持つものの入力ファイルを周回のディレクトリへ書き、`supervise.py new sprint` へ `--revise-note <課題>=<パス>` で渡す | -| 人の判断待ちへ移す関数(`lib/sprint_loop_gate.py` の `move_to_waiting`) | 出し直しの指摘を持つ課題で、設計 PR の本文の表を突き合わせる。欠けがあり上限内なら自動の出し直しの行にし、オーガナイザーへ設計の承認の依頼を送らない | +| 人の判断待ちへ移す関数(`lib/sprint_loop_gate.py` の `move_to_waiting`) | 出し直しの指摘を持つ課題で、表の突き合わせのコマンドと同じ関数を呼び、設計文書の節を設計 PR の本文へ写してから突き合わせる(用語チェックの当たりで `K` を通らずに承認ゲートへ回った課題も、ここで表が本文へ届く)。欠けがあり上限内なら自動の出し直しの行にし、オーガナイザーへ設計の承認の依頼を送らない | | 判断待ちの節の文面(`lib/sprint_loop_gate.py` の `waiting_section`) | 指摘の全文を載せる代わりに、履歴の節の何回目かを指す 1 行を載せる。自動の出し直しなら届いていない項目を載せる | | 設計のプランの組み立て(`supervise_lib/sprint_waves.py` の `plan_sprint_design`・`plan_mvv_design`) | 出し直しの指摘を持つ課題で、仕様のコピーのチェックを外して要求のステップへ直行させ、要求と設計の起動指示へ指摘を入れ、表の突き合わせのステップを足し、MVV 判定へ入力を渡す | | `supervise.py new sprint` の引数(`supervise_lib/new_args.py`) | `--revise-note <課題>=<パス>`(繰り返し可)を受ける | @@ -158,13 +158,13 @@ | 要素 | 責務 | | --- | --- | | 承認の依頼の部品(新規 `lib/gate1_requests.py`) | 設計 PR の先頭の読み取り(`sprint_loop_gate.design_head` を移す)・取り込みのコミットだけかの判定・前のループの待ちの読み出しと依頼の形・依頼の id の決め方を持つ唯一の実装 | -| 問い直しの判断(`lib/sprint_loop_gate.py` の `regate1`) | 先頭が動いたら判定の関数を呼ぶ。取り込みのコミットだけなら送り直さない。送り直すときは、前の依頼の id と印を行の `requests_before` へ移す | -| 答えを読む依頼の一覧(`lib/sprint_loop_gate2.py` の `pending_requests`) | 行の `request` に加えて `requests_before` の id も読む | -| 設計の承認の答えを当てる関数(`lib/sprint_loop.py` の `apply_gate1_answers`) | 前の依頼の id への答えは、その印から今の先頭までが取り込みのコミットだけなら当てる。違えば当てずに、当たらなかった答えとしてオーガナイザーへ送る | +| 問い直しの判断(`lib/sprint_loop_gate.py` の `regate1`) | 先頭が動いたら判定の関数を呼ぶ。取り込みのコミットだけなら送り直さない。送り直すときは、前の依頼の id と印を行の `requests_before` へ移す。今の依頼への答えを読んだうえで問い直すなら、その id は判定済み(`settled: true`)で移す | +| 答えを読む依頼の一覧(`lib/sprint_loop_gate2.py` の `pending_requests`) | 行の `request` に加えて、`requests_before` のうち判定済みでない要素の id も読む | +| 設計の承認の答えを当てる関数(`lib/sprint_loop.py` の `apply_gate1_answers`) | 前の依頼の id への答えは、その印から今の先頭までが取り込みのコミットだけなら当てる。違えば当てずに、当たらなかった答えとしてオーガナイザーへ送る。どちらでも、その要素を判定済みにする | | 周回の境(`lib/sprint_loop_select.py` の `boundary_then_select`) | レーンなら、答えを当てた後・選定の前に、前のループの待ちを送り、自分の行で依頼の id を持たないもの(送れなかった依頼)を送り直す | | 選定の外す理由(`lib/sprint_loop_select.py` の `exclusion_reason`) | 前のループに答えのまだの行がある課題を、「人の判断待ち(開発ループ <名>)」で外す。棚卸しの判断待ちより先に見る | | レーンの送り手(`lib/parallel_lane.py`) | 前のループの待ちの依頼(`kind: gate1` と `loop`)と、当たらなかった答えの知らせ(`kind: unmatched`)を `inbox` へ送る関数を足す | -| 耐久ワークフロー(`lib/parallel_run.py`) | 開始時に前のループの待ちを問いの一覧へ入れる。同じ行の古い問いを置き換える。答えを受けたら、`waiting_only` で止まったループのレーンも起こす対象に入れ、前のループの問いへの答えをそのループの `loop answer` で当て、当てられない答えを区分 `unmatched` の問いにする。前のループの問いは終わりを止めない | +| 耐久ワークフロー(`lib/parallel_run.py`) | 開始時に前のループの待ちを問いの一覧へ入れる。同じ行の古い問いを置き換える。答えを受けたら、`waiting_only` で止まったループのレーンも起こす対象に入れ(置き換えた id への答えは `replaced` から持ち主を引く)、前のループの問いへの答えをそのループの `loop answer` で当て、当てられない答えを区分 `unmatched` の問いにする。前のループの問いは終わりを止めない | ### 要素どうしの呼び出し @@ -348,7 +348,7 @@ graph TD M -->|従わない| E ``` -出し直しの指摘を持たない課題では、`G` の次が今どおり仕様のコピーのチェックで、`K` は無い(I4)。`K` は用語の送り(`push-glossary`)の直後に入り、送りの元の行き先へ進む。基本形の行き先は Draft を外すステップ、MVV 判定を置く形では MVV 判定である。用語チェックの当たりが残って承認ゲートの判断(`gate`)へ回る経路は `K` を通らないが、周回の締めで同じ突き合わせを通る。 +出し直しの指摘を持たない課題では、`G` の次が今どおり仕様のコピーのチェックで、`K` は無い(I4)。`K` は用語の送り(`push-glossary`)の直後に入り、送りの元の行き先へ進む。基本形の行き先は Draft を外すステップ、MVV 判定を置く形では MVV 判定である。用語チェックの当たりが残って承認ゲートの判断(`gate`)へ回る経路は `K` を通らないが、周回の締めで同じ突き合わせ(設計文書の節を本文へ写してから突き合わせる)を通る。pr のステップが本文を作り直すため、この経路では本文に表が無いまま締めに着くが、締めの突き合わせが写すので欠けとは判定されない。 ### 周回の締めで人の判断待ちへ移すとき @@ -363,7 +363,7 @@ graph TD L -->|自動| W2[判断待ち
欠けを理由に添える] ``` -`T` は設計 PR の本文を読む。読めなければ自動の出し直しにせず、「表を読めなかった」を理由に添えて判断待ちへ移す。自動の出し直しの行は、人の答えと同じく次の周回で先頭に選ばれる(今の選定の振る舞い)。 +`T` は `revise-note.py check` と同じ関数で、設計文書の節を設計 PR の本文へ写してから、写した表を入力の項目と突き合わせる。設計 PR・設計文書・入力のどれかを読めなければ(終了コード 2 に当たる)自動の出し直しにせず、「表を読めなかった」を理由に添えて判断待ちへ移す。自動の出し直しの行は、人の答えと同じく次の周回で先頭に選ばれる(今の選定の振る舞い)。 ### オーガナイザーが答えを送ってから行へ当たるまで @@ -374,10 +374,10 @@ sequenceDiagram participant L as レーン O->>W: answer(依頼の id) W->>W: answer- を立てる - alt 一覧にある問い + alt 一覧にある問い・置き換えた id W->>W: 止まった持ち主を起こす対象へ W-->>O: 知らせに起こす 1 行 - else 一覧に無い・置き換えた id + else 一覧にも置き換えた id にも無い W-->>O: 区分 unmatched の問い end O->>L: 起こす 1 行を打つ @@ -385,7 +385,7 @@ sequenceDiagram L->>L: 先頭を比べて当てる ``` -起こす対象は、問いの持ち主のレーンのうち止まっているものである。1 スプリントのレーンに加えて、記録が `waiting_only` で答えのまだの行を持つループのレーンを含める(今の `step_waiting_lanes` と同じ判定)。起こす 1 行は、そのレーンが動き出すまで、以後の知らせ(`pause` の `wake`)に添え続ける。 +置き換えた id は、問いの一覧の `replaced` からその id を持つ今の問いを引き、その持ち主へ流す([問いの一覧に足す問い](issue-2510-design-contracts.md#問いの一覧に足す問い))。問い直した後に `waiting_only` で止まったレーンも、前の依頼への答えで起こされ、周回の境で前の依頼の経路(下の図の `P`)を通る。起こす対象は、問いの持ち主のレーンのうち止まっているものである。1 スプリントのレーンに加えて、記録が `waiting_only` で答えのまだの行を持つループのレーンを含める(今の `step_waiting_lanes` と同じ判定)。起こす 1 行は、そのレーンが動き出すまで、以後の知らせ(`pause` の `wake`)に添え続ける。 レーンが答えを読んで当てるときの判断は次のとおりである。 @@ -402,7 +402,7 @@ graph TD P -->|False か None| U[当たらなかった答え
として知らせる] ``` -先頭を読めないときは、今どおり当てずに次の境で読み直す。 +先頭を読めないときは、今どおり当てずに次の境で読み直す。`R` で問い直すときは答えた今の依頼の id を、`P` の判定を終えたときは前の依頼の id を、`requests_before` の判定済みの要素にする([データ構造](issue-2510-design-contracts.md#人の判断待ちの行に足す欄))。判定済みの id は次の境から読まないため、同じ答えで区分 `unmatched` の知らせを出し直さない。 ### 前のループの待ちを問いの一覧へ出す @@ -464,7 +464,8 @@ sequenceDiagram | 受け入れ条件 8 / I4 | 出し直しの指摘を持たない課題のプランが今と同じ(仕様のコピーのチェックがあり、起動指示に指摘の欄が無く、突き合わせのステップが無い) | 指摘の有無によらず分岐する | | 受け入れ条件 9 / I8 | 承認・やらないを当てたときの承認の記録・ラベル・判断待ちの節が今と同じ(既存のテストが通る) | 答えを当てる関数の承認とやらないの経路で履歴を書く | | 受け入れ条件 10 / I9 | 記録が `waiting_only` で止まったループのレーンが持つ問いへ答えると、そのレーンが起こす対象に入り、以後の知らせに起こす 1 行が付く。起こした後の周回の境で承認が行に当たる | 起こす対象を 1 スプリントのレーンに限る | -| 受け入れ条件 10 / I9 | 一覧に無い id・置き換えた id への答えでも `answer-` が立ち、区分 `unmatched` の問いが一覧に出る | 一覧に無い id を黙って捨てる・置き換えた id の答えを立てない | +| 受け入れ条件 10 / I9 | 一覧にも `replaced` にも無い id への答えでも `answer-` が立ち、区分 `unmatched` の問いが一覧に出る。置き換えた id への答えは、`waiting_only` で止まった持ち主のレーンを起こす対象に入れ、前の印から取り込みだけなら起こした後の境で当たる | 一覧に無い id を黙って捨てる・置き換えた id の答えを立てない・置き換えた id の答えで持ち主を起こさない | +| 受け入れ条件 10 / I10 | 今の依頼への答えを読んで問い直した行と、前の依頼への答えを判定した行で、次の境から同じ答えを読み直さず、区分 `unmatched` の知らせが 1 回だけ出る | 判定済みの `requests_before` の id を毎回読み直す | | 受け入れ条件 10 / I10 | 問い直した行で、前の依頼の id へ答えが届く。前の印から今の先頭まで取り込みのコミットだけなら当たる。そうでなければ当てずに、区分 `unmatched` の知らせが届く | 前の依頼の id を読まない・取り込みでない差分でも当てる | | 受け入れ条件 11 / I10 | 先頭が起点ブランチの取り込みのマージコミットだけで動いたとき、承認が当たり、依頼を送り直さない。設計文書か要求のコピーを変えるコミットがあるとき・取り込みでないコミットがあるとき・判定できないときは、新しい先頭で送り直し、前の id を `requests_before` に残す | 先頭が違えば常に送り直す・`--first-parent` で絞らずに起点のコミットを数える・判定できないときに当てる | | 受け入れ条件 12 / I11 | 計画を始めると、前のループの答えがまだの行が、ループの名前・課題・設計 PR つきで問いの一覧に出る。レーンの周回の境で同じ行を送っても問いは 1 つのまま。答えるとそのループの `loop answer` が打たれる。先頭が取り込みでなく動いていれば、古い問いが置き換わる | 開始時だけ・境だけで送る・id を送った時刻や先頭から作る・答えをレーンの記録へ当てる | From 31ad6761e73c27244784bfd86683c83c5c4efd29 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 08:19:42 +0000 Subject: [PATCH 042/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=E3=80=81=E4=BB=8A=E3=81=AE=E4=BE=9D=E9=A0=BC?= =?UTF-8?q?=E3=81=B8=E3=81=AE=E6=89=BF=E8=AA=8D=E3=82=92=E5=BD=93=E3=81=A6?= =?UTF-8?q?=E3=81=9A=E3=81=AB=E5=95=8F=E3=81=84=E7=9B=B4=E3=81=99=E3=81=A8?= =?UTF-8?q?=E3=81=8D=E3=82=82=E5=BD=93=E3=81=9F=E3=82=89=E3=81=AA=E3=81=8B?= =?UTF-8?q?=E3=81=A3=E3=81=9F=E7=AD=94=E3=81=88=E3=81=A8=E3=81=97=E3=81=A6?= =?UTF-8?q?=201=20=E5=9B=9E=E7=9F=A5=E3=82=89=E3=81=9B=E3=81=A6=E3=81=8B?= =?UTF-8?q?=E3=82=89=E5=88=A4=E5=AE=9A=E6=B8=88=E3=81=BF=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2510-design-contracts.md | 2 +- issues/issue-2510-design-decisions.md | 2 +- issues/issue-2510-design.md | 6 +++--- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/issues/issue-2510-design-contracts.md b/issues/issue-2510-design-contracts.md index 2205d323c..6bf9a9eed 100644 --- a/issues/issue-2510-design-contracts.md +++ b/issues/issue-2510-design-contracts.md @@ -16,7 +16,7 @@ `requests_before` の要素は消さず、判定し終えたら `settled` を `true` にする(`restamped` との対応と、置き換えた id の持ち主を引く手がかりを残すため)。`true` にするのは次の 2 つの時点である。 -- 問い直しの判断(`regate1`)が、今の依頼への答えを読んだうえで先頭の動きを `False` か `None` と判定して問い直すとき。その依頼の id は `settled: true` で移す(答えは判定済みで、次の境から前の依頼の経路で読み直すと区分 `unmatched` の知らせが毎回出るため)。答えが届く前に先頭の動きで問い直すときは `settled: false` で移す +- 問い直しの判断(`regate1`)が、今の依頼への答えを読んだうえで先頭の動きを `False` か `None` と判定して問い直すとき。その答えは行へ当たらないため、レーンが区分 `unmatched` の知らせを 1 回送ってから、その依頼の id を `settled: true` で移す(答えを捨てないため(I9)。次の境から前の依頼の経路で読み直すと同じ知らせが毎回出るため、判定済みにする)。答えが届く前に先頭の動きで問い直すときは `settled: false` で移す - 設計の承認の答えを当てる関数が、前の依頼の id への答えを判定したとき。当てたときも、当たらなかった答えとして知らせたときも `true` にする 自動の出し直しの行は `answer` を `revise`、`note` を空にする。項目は人の回の指摘からだけ数えるため(I2)、自動の回は指摘の本文を持たない。`restamped`(今の欄)は残し、`requests_before` の最後の印と同じ値を持つ。 diff --git a/issues/issue-2510-design-decisions.md b/issues/issue-2510-design-decisions.md index 5f0895914..e8e4d859a 100644 --- a/issues/issue-2510-design-decisions.md +++ b/issues/issue-2510-design-decisions.md @@ -66,7 +66,7 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 #2474 の原因は、止まったループのレーンを起こすことが、オーガナイザーが `metrics.next` の 1 行を打つことだけに頼っていた点にある([「#2474 の承認が当たらなかった原因」](issue-2510-design.md#2474-の承認が当たらなかった原因))。1 スプリントのレーンには、耐久ワークフローが起こす 1 行を以後の知らせに添え続ける仕組み(`revive`・`wake`)が既にある。ループのレーンもこの対象に入れれば、新しい経路を作らずに済む。 -置き換えた id への答えは、問いの一覧の `replaced` から持ち主を引き、今の問いへの答えと同じく持ち主を起こす。問い直した後に止まったレーンへも答えが届き、前の印から取り込みだけなら当たる。当てられない答え(一覧にも `replaced` にも無い id・前の印から取り込みだけでない・前のループへ当てられない)は、今は耐久ワークフローの受け取りか、レーンの読み取りで黙って捨てられる。区分 `unmatched` の問いにすれば、オーガナイザーが `parallel.py wait` の `questions` で知り、答えるまで計画は終わらない。答えのイベント自体は立てたまま残すため、後から同じ id の依頼が届いても当たる。 +置き換えた id への答えは、問いの一覧の `replaced` から持ち主を引き、今の問いへの答えと同じく持ち主を起こす。問い直した後に止まったレーンへも答えが届き、前の印から取り込みだけなら当たる。当てられない答え(一覧にも `replaced` にも無い id・前の印から取り込みだけでない・今の依頼への承認を受けた後に先頭が取り込み以外で動いて問い直す・前のループへ当てられない)は、今は耐久ワークフローの受け取りか、レーンの読み取りで黙って捨てられる。区分 `unmatched` の問いにすれば、オーガナイザーが `parallel.py wait` の `questions` で知り、答えるまで計画は終わらない。答えのイベント自体は立てたまま残すため、後から同じ id の依頼が届いても当たる。 耐久ワークフローが自分でレーンを起こし直す形は採らない。今の決まりは、レーンを起こす操作をオーガナイザーの 1 行に置いている(`parallel.md` の「オーガナイザーが打つのは…終わったレーンを起こす 1 行だけ」)。この課題では起こす 1 行の届け方だけを直し、担い手は変えない。 diff --git a/issues/issue-2510-design.md b/issues/issue-2510-design.md index 3488daef0..61abffa65 100644 --- a/issues/issue-2510-design.md +++ b/issues/issue-2510-design.md @@ -158,7 +158,7 @@ | 要素 | 責務 | | --- | --- | | 承認の依頼の部品(新規 `lib/gate1_requests.py`) | 設計 PR の先頭の読み取り(`sprint_loop_gate.design_head` を移す)・取り込みのコミットだけかの判定・前のループの待ちの読み出しと依頼の形・依頼の id の決め方を持つ唯一の実装 | -| 問い直しの判断(`lib/sprint_loop_gate.py` の `regate1`) | 先頭が動いたら判定の関数を呼ぶ。取り込みのコミットだけなら送り直さない。送り直すときは、前の依頼の id と印を行の `requests_before` へ移す。今の依頼への答えを読んだうえで問い直すなら、その id は判定済み(`settled: true`)で移す | +| 問い直しの判断(`lib/sprint_loop_gate.py` の `regate1`) | 先頭が動いたら判定の関数を呼ぶ。取り込みのコミットだけなら送り直さない。送り直すときは、前の依頼の id と印を行の `requests_before` へ移す。今の依頼への答えを読んだうえで問い直すなら、区分 `unmatched` の知らせを 1 回送り、その id は判定済み(`settled: true`)で移す | | 答えを読む依頼の一覧(`lib/sprint_loop_gate2.py` の `pending_requests`) | 行の `request` に加えて、`requests_before` のうち判定済みでない要素の id も読む | | 設計の承認の答えを当てる関数(`lib/sprint_loop.py` の `apply_gate1_answers`) | 前の依頼の id への答えは、その印から今の先頭までが取り込みのコミットだけなら当てる。違えば当てずに、当たらなかった答えとしてオーガナイザーへ送る。どちらでも、その要素を判定済みにする | | 周回の境(`lib/sprint_loop_select.py` の `boundary_then_select`) | レーンなら、答えを当てた後・選定の前に、前のループの待ちを送り、自分の行で依頼の id を持たないもの(送れなかった依頼)を送り直す | @@ -397,12 +397,12 @@ graph TD H -->|動かない| Y[当てる] H -->|動いた| M{取り込みだけ} M -->|True| Y - M -->|False か None| R[新しい先頭で問い直す] + M -->|False か None| R[当たらなかった答えとして知らせ
新しい先頭で問い直す] P -->|True| Y P -->|False か None| U[当たらなかった答え
として知らせる] ``` -先頭を読めないときは、今どおり当てずに次の境で読み直す。`R` で問い直すときは答えた今の依頼の id を、`P` の判定を終えたときは前の依頼の id を、`requests_before` の判定済みの要素にする([データ構造](issue-2510-design-contracts.md#人の判断待ちの行に足す欄))。判定済みの id は次の境から読まないため、同じ答えで区分 `unmatched` の知らせを出し直さない。 +先頭を読めないときは、今どおり当てずに次の境で読み直す。`R` では今の依頼への承認が当たらないため、区分 `unmatched` の知らせを 1 回送ってから問い直す。`R` で問い直すときは答えた今の依頼の id を、`P` の判定を終えたときは前の依頼の id を、`requests_before` の判定済みの要素にする([データ構造](issue-2510-design-contracts.md#人の判断待ちの行に足す欄))。判定済みの id は次の境から読まないため、同じ答えで区分 `unmatched` の知らせを出し直さない。 ### 前のループの待ちを問いの一覧へ出す From 82a39abfc2ff3595f42a586e5180223d8bbd5f94 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 08:34:38 +0000 Subject: [PATCH 043/127] =?UTF-8?q?test:=20tmux=20=E3=82=92=E4=BD=BF?= =?UTF-8?q?=E3=81=86=E7=B5=B1=E6=8B=AC=E5=BD=B9=E3=81=AE=E7=B5=90=E5=90=88?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=204=20=E4=BB=B6=E3=82=92=E5=89=8D?= =?UTF-8?q?=E6=8F=90=E3=81=AE=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=81=AE?= =?UTF-8?q?=E4=B8=80=E8=A6=A7=E3=81=A7=E7=B8=9B=E3=82=8B=EF=BC=88#2346?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根の conftest.py の照合を、項目の識別子(根からの相対パスにファイルの中の位置を :: でつないだもの)で 行う形へ変え、キーの直後が / か :: のときだけ当てる。test_relay_hub.py の 4 件をクラス TestWithTmux に まとめ、一覧に tmux を課すキーを足した。テストの側の skipif(needs_tmux)は消し、tmux の有無を一覧の 1 か所で決める。 Co-Authored-By: Claude Opus 5.5 --- conftest.py | 41 ++++-- plugins/ndf/scripts/tests/test_relay_hub.py | 151 ++++++++++---------- scripts/tests/test_root_conftest.py | 53 +++++++ 3 files changed, 157 insertions(+), 88 deletions(-) diff --git a/conftest.py b/conftest.py index b935ac705..7b3360cfe 100644 --- a/conftest.py +++ b/conftest.py @@ -55,8 +55,9 @@ # 増えた)。**生成物は収集の対象から外す。** collect_ignore_glob = ["plugins/*/dev.agy"] -# 束ごとに要る外部コマンド。**束によって前提が違う**ため、一覧は 1 か所に置きつつ -# 収集した束にだけ適用する。全体へ課すと、必要のないコマンドを求めることになる。 +# 束ごとに要る外部コマンド(前提のコマンドの一覧)。**束によって前提が違う**ため、一覧は +# 1 か所に置きつつ収集した束にだけ適用する。全体へ課すと、必要のないコマンドを求めることになる。 +# キーはリポジトリの根からの相対パスで、ファイルの中のクラスまで絞るときは `::` でつなぐ。 REQUIRED_COMMANDS: dict[str, tuple[str, ...]] = { "scripts/tests": ("bash", "jq", "git"), "plugins/ndf/skills/worktree/tests": ("bash", "jq", "git"), @@ -72,6 +73,9 @@ # スライドの生成スクリプト(pptxgenjs)を node で実行し、依存が無ければ `npm ci` で入れる(#2065) "plugins/ndf/skills/release/tests/test_slide_template.py": ("node", "npm"), "tests/test_presentation_decks.py": ("node", "npm"), + # 名前つきのラッパーを使い捨ての tmux サーバで動かす結合テスト(#2346)。同じファイルの + # ほかのテストは tmux を使わないため、クラスまで絞る。 + "plugins/ndf/scripts/tests/test_relay_hub.py::TestWithTmux": ("tmux",), } # 読み飛ばしを選ぶ指定。**既定は失敗**である。読み飛ばしたい実行環境のために残すが、 @@ -83,16 +87,27 @@ def _allow_missing() -> bool: return os.environ.get(ALLOW_MISSING_ENV, "") not in ("", "0") -def _bundle_of(path: Path) -> str | None: - """その項目が属する束を返す。一覧に無ければ `None`。""" - try: - rel = path.resolve().relative_to(ROOT).as_posix() - except ValueError: +def _item_id(item) -> str | None: + """項目の識別子(根からの相対パス、続けてファイルの中の位置を `::` でつないだもの)を返す。 + + 根の外のファイルは `None`。 + """ + path = Path(str(item.fspath)).resolve() + if not path.is_relative_to(ROOT): return None - for bundle in REQUIRED_COMMANDS: - if rel == bundle or rel.startswith(bundle + "/"): - return bundle - return None + _, sep, inner = item.nodeid.partition("::") + return path.relative_to(ROOT).as_posix() + sep + inner + + +def _bundles_of(item_id: str | None) -> list[str]: + """識別子が当たる一覧のキーを返す。 + + 当たるのは、識別子がキーと一致するか、キーの直後が `/` か `::` のときだけである + (`::TestWithTmux` は `::TestWithTmuxOld` に当たらない)。 + """ + if item_id is None: + return [] + return [k for k in REQUIRED_COMMANDS if item_id == k or item_id.startswith((k + "/", k + "::"))] def _missing(bundles: set[str]) -> dict[str, list[str]]: @@ -213,7 +228,7 @@ def _select_shard(config, items) -> None: def pytest_collection_modifyitems(config, items) -> None: _select_shard(config, items) - bundles = {b for item in items if (b := _bundle_of(Path(str(item.fspath)))) is not None} + bundles = {b for item in items for b in _bundles_of(_item_id(item))} missing = _missing(bundles) if not missing: return @@ -221,7 +236,7 @@ def pytest_collection_modifyitems(config, items) -> None: if _allow_missing(): mark = pytest.mark.skip(reason=f"外部コマンドが無い({ALLOW_MISSING_ENV} の指定により読み飛ばす)") for item in items: - if _bundle_of(Path(str(item.fspath))) in missing: + if any(b in missing for b in _bundles_of(_item_id(item))): item.add_marker(mark) return raise pytest.UsageError( diff --git a/plugins/ndf/scripts/tests/test_relay_hub.py b/plugins/ndf/scripts/tests/test_relay_hub.py index b7e45f0fe..f1c5fce99 100644 --- a/plugins/ndf/scripts/tests/test_relay_hub.py +++ b/plugins/ndf/scripts/tests/test_relay_hub.py @@ -363,8 +363,6 @@ def test_wait_notice_names_wrapper_and_attach(): # ---------------------------------------------------------------- tmux を通す振る舞い(AC2〜AC4・AC6・AC7・AC11・AC16・AC20・AC21・AC23) -needs_tmux = pytest.mark.skipif(shutil.which("tmux") is None, reason="tmux が無い") - @pytest.fixture() def hubenv(tmp_path): @@ -400,40 +398,6 @@ def wait_state(t, name, state, timeout=30): t.wait(lambda: (ls_rows(t).get(name) or {}).get("state") == state, timeout, f"{name} が {state}") -@needs_tmux -def test_spawn_ls_stop_and_hub_handover(hubenv): - hub = hubenv() - hub.wait_start(1) - p = relay_cli(hub, "spawn", "--name", "lroot", "--json", "--", "mark", "/goal 次") - assert p.returncode == 0, p.stdout + p.stderr - assert json.loads(p.stdout)["status"] == "ok" - wait_state(hub, "lroot", "running") - # 最初の入力を受けた区間 1 と、ndf-next で起動した区間 2(端末の結び付いていないラッパーの切り替え。AC3・AC8) - hub.wait(lambda: any(s["argv"][-1:] == ["/goal 次"] for s in hub.starts()), 40, "lroot の区間 2") - first = next(s for s in hub.starts() if s["argv"][-2:] == ["--", "mark /goal 次"]) - rows = ls_rows(hub) - assert rows["t1"]["hub"] is True and rows["t1"]["attached"] is None - assert rows["lroot"]["hub"] is False and rows["lroot"]["attached"] is False - assert first["relay_dir"] != hub.starts()[0]["relay_dir"] - - dup = relay_cli(hub, "spawn", "--name", "lroot", "--", "x") - assert dup.returncode == 3 and "名前が重なる" in dup.stderr - - # 統括役を kill しても名前つきは動き続け、次の claude が統括役になる(AC21) - hub.proc.kill() - hub.proc.wait(5) - hub.wait(lambda: "t1" not in ls_rows(hub), 20, "統括役のラッパーが一覧から消える") - assert ls_rows(hub)["lroot"]["state"] == "running" - nxt = hubenv() - nxt.wait(lambda: hb_pid(nxt) == nxt.proc.pid, 20, "次の claude が統括役になる") - assert "lroot" in ls_rows(nxt) - - st = relay_cli(nxt, "stop", "lroot") - assert st.returncode == 0, st.stderr - wait_state(nxt, "lroot", "stopped") - assert ls_rows(nxt)["t1"]["state"] == "running" - - def hb_pid(t): p = pathlib.Path(t.env["HOME"]) / ".local" / "state" / "ndf" / "relay" / "hub" / "hub.json" try: @@ -442,45 +406,6 @@ def hb_pid(t): return None -@needs_tmux -def test_second_claude_attaches_and_survives_terminal_close(hubenv): - hub = hubenv() - hub.wait_start(1) - second = hubenv() - hub.wait(lambda: (ls_rows(hub).get("t2") or {}).get("attached") is True, 40, "2 本目が t2 へ結び付く") - assert ls_rows(hub)["t2"]["state"] == "running" - second.close() # 端末を閉じる(AC7) - hub.wait(lambda: ls_rows(hub)["t2"]["attached"] is False, 20, "端末が外れる") - assert ls_rows(hub)["t2"]["state"] == "running" - - -@needs_tmux -def test_limit_refuses_from_terminal_and_spawn(hubenv): - hub = hubenv(env={"NDF_RELAY_MAX_NAMED": "2"}) - hub.wait_start(1) - assert relay_cli(hub, "spawn", "--name", "a", "--", "x").returncode == 0 - p = relay_cli(hub, "spawn", "--name", "b", "--json", "--", "x") - res = json.loads(p.stdout) - assert p.returncode == 3 and res["items"][0]["result"] == "limit" and "上限 2" in res["summary"] - third = hubenv(env={"NDF_RELAY_MAX_NAMED": "2"}) - assert third.finish() == 3 - assert "上限 2" in third.text - - -@needs_tmux -def test_killed_named_wrapper_shows_stopped(hubenv): - hub = hubenv() - hub.wait_start(1) - assert relay_cli(hub, "spawn", "--name", "a", "--", "x").returncode == 0 - wait_state(hub, "a", "running") - # 一覧の running は起動した直後(ロックの前)の登録簿の行でも立つ。ラッパーが pid を書くまで待つ - hub.wait(lambda: any(n == "a" and (d / "relay.pid").exists() for d, n in hb_scan(hub)), 30, "a が pid を書く") - d = next(d for d, n in hb_scan(hub) if n == "a" and (d / "relay.pid").exists()) - os.kill(int((d / "relay.pid").read_text()), 9) - wait_state(hub, "a", "stopped") - assert ls_rows(hub)["t1"]["state"] == "running" - - def hb_scan(t): root = pathlib.Path(t.env["HOME"]) / ".local" / "state" / "ndf" / "relay" return [ @@ -488,6 +413,82 @@ def hb_scan(t): ] +class TestWithTmux: + """tmux を使う 4 件。tmux の有無は根の `conftest.py` の前提のコマンドの一覧がこのクラスの単位で決める。""" + + def test_spawn_ls_stop_and_hub_handover(self, hubenv): + hub = hubenv() + hub.wait_start(1) + p = relay_cli(hub, "spawn", "--name", "lroot", "--json", "--", "mark", "/goal 次") + assert p.returncode == 0, p.stdout + p.stderr + assert json.loads(p.stdout)["status"] == "ok" + wait_state(hub, "lroot", "running") + # 最初の入力を受けた区間 1 と、ndf-next で起動した区間 2(端末の結び付いていないラッパーの切り替え。AC3・AC8) + hub.wait(lambda: any(s["argv"][-1:] == ["/goal 次"] for s in hub.starts()), 40, "lroot の区間 2") + first = next(s for s in hub.starts() if s["argv"][-2:] == ["--", "mark /goal 次"]) + rows = ls_rows(hub) + assert rows["t1"]["hub"] is True and rows["t1"]["attached"] is None + assert rows["lroot"]["hub"] is False and rows["lroot"]["attached"] is False + assert first["relay_dir"] != hub.starts()[0]["relay_dir"] + + dup = relay_cli(hub, "spawn", "--name", "lroot", "--", "x") + assert dup.returncode == 3 and "名前が重なる" in dup.stderr + + # 統括役を kill しても名前つきは動き続け、次の claude が統括役になる(AC21) + hub.proc.kill() + hub.proc.wait(5) + hub.wait(lambda: "t1" not in ls_rows(hub), 20, "統括役のラッパーが一覧から消える") + assert ls_rows(hub)["lroot"]["state"] == "running" + nxt = hubenv() + nxt.wait(lambda: hb_pid(nxt) == nxt.proc.pid, 20, "次の claude が統括役になる") + assert "lroot" in ls_rows(nxt) + + st = relay_cli(nxt, "stop", "lroot") + assert st.returncode == 0, st.stderr + wait_state(nxt, "lroot", "stopped") + assert ls_rows(nxt)["t1"]["state"] == "running" + + def test_second_claude_attaches_and_survives_terminal_close(self, hubenv): + hub = hubenv() + hub.wait_start(1) + second = hubenv() + hub.wait(lambda: (ls_rows(hub).get("t2") or {}).get("attached") is True, 40, "2 本目が t2 へ結び付く") + assert ls_rows(hub)["t2"]["state"] == "running" + second.close() # 端末を閉じる(AC7) + hub.wait(lambda: ls_rows(hub)["t2"]["attached"] is False, 20, "端末が外れる") + assert ls_rows(hub)["t2"]["state"] == "running" + + def test_limit_refuses_from_terminal_and_spawn(self, hubenv): + hub = hubenv(env={"NDF_RELAY_MAX_NAMED": "2"}) + hub.wait_start(1) + assert relay_cli(hub, "spawn", "--name", "a", "--", "x").returncode == 0 + p = relay_cli(hub, "spawn", "--name", "b", "--json", "--", "x") + res = json.loads(p.stdout) + assert p.returncode == 3 and res["items"][0]["result"] == "limit" and "上限 2" in res["summary"] + third = hubenv(env={"NDF_RELAY_MAX_NAMED": "2"}) + assert third.finish() == 3 + assert "上限 2" in third.text + + def test_killed_named_wrapper_shows_stopped(self, hubenv): + hub = hubenv() + hub.wait_start(1) + assert relay_cli(hub, "spawn", "--name", "a", "--", "x").returncode == 0 + wait_state(hub, "a", "running") + # 一覧の running は起動した直後(ロックの前)の登録簿の行でも立つ。ラッパーが pid を書くまで待つ + hub.wait(lambda: any(n == "a" and (d / "relay.pid").exists() for d, n in hb_scan(hub)), 30, "a が pid を書く") + d = next(d for d, n in hb_scan(hub) if n == "a" and (d / "relay.pid").exists()) + os.kill(int((d / "relay.pid").read_text()), 9) + wait_state(hub, "a", "stopped") + assert ls_rows(hub)["t1"]["state"] == "running" + + +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_"))] + names = [m.name for h in holders for m in getattr(h, "pytestmark", [])] + assert not {"skip", "skipif"} & set(names), names + + def test_no_tmux_runs_second_in_foreground(tmp_path): """tmux が PATH に無ければ、2 本目は 1 行出して今の前景の起動になる(AC23)。""" hub = Term(tmp_path, env={"NDF_RELAY_TMUX": "ndf-no-tmux"}) diff --git a/scripts/tests/test_root_conftest.py b/scripts/tests/test_root_conftest.py index 8fa10ed91..1d07562fd 100644 --- a/scripts/tests/test_root_conftest.py +++ b/scripts/tests/test_root_conftest.py @@ -290,3 +290,56 @@ def test_the_shards_cover_every_item_exactly_once_without_splitting_a_file() -> files = [{nodeid.split("::", 1)[0] for nodeid in ids} for ids in shards] assert not set.intersection(*files), "同じファイルが複数の shard に割れた" + + +# 前提のコマンドの一覧のキーをファイルの中のクラスまで絞る(#2346)。tmux を使う結合テストの組だけに +# tmux を課し、同じファイルのほかのテストは tmux の無い実行環境でも走ることを確かめる。 +TMUX_FILE = "plugins/ndf/scripts/tests/test_relay_hub.py" +TMUX_KEY = f"{TMUX_FILE}::TestWithTmux" +TMUX_TESTS = ( + "test_spawn_ls_stop_and_hub_handover", + "test_second_claude_attaches_and_survives_terminal_close", + "test_limit_refuses_from_terminal_and_spawn", + "test_killed_named_wrapper_shows_stopped", +) + + +def test_a_missing_tmux_fails_the_relay_hub_tests(tmp_path: Path) -> None: + """tmux が PATH に無ければ、`test_relay_hub.py` の実行が 0 以外で終わり、キーと tmux を名指しする。""" + result = _run_pytest(TMUX_FILE, path=_path_without(tmp_path, "tmux")) + out = result.stdout + result.stderr + + assert result.returncode != 0, out + assert f"{TMUX_KEY}: tmux" in out + + +def test_the_opt_in_skips_only_the_tmux_class(tmp_path: Path) -> None: + """読み飛ばしの指定があれば、飛ぶのはクラスの 4 件だけで、キーの外の項目は走る。""" + outside = f"{TMUX_FILE}::test_bad_name_is_refused[A]" + result = _run_pytest( + TMUX_KEY, + outside, + "-vv", + path=_path_without(tmp_path, "tmux"), + env_extra={"NDF_TESTS_ALLOW_MISSING_COMMANDS": "1"}, + ) + out = result.stdout + result.stderr + + assert result.returncode == 0, out + assert "1 passed, 4 skipped" in out, out + for name in TMUX_TESTS: + assert f"{TMUX_KEY}::{name} SKIPPED" in out, name + assert f"{outside} PASSED" in out + + +def test_a_key_matches_only_at_a_path_or_class_boundary() -> None: + """キーが当たるのは、一致するか直後が `/` か `::` のときだけである。""" + mod = _root_conftest_module() + + assert TMUX_KEY in mod._bundles_of(f"{TMUX_KEY}::test_x") + assert TMUX_KEY in mod._bundles_of(TMUX_KEY) + assert mod._bundles_of(f"{TMUX_FILE}::TestWithTmuxOld::test_x") == [] + assert mod._bundles_of(f"{TMUX_FILE}::test_bad_name_is_refused[A]") == [] + assert "scripts/tests" in mod._bundles_of("scripts/tests/test_x.py::test_y") + assert mod._bundles_of("scripts/testsuite/test_x.py::test_y") == [] + assert mod._bundles_of(None) == [] From 0c8e98172e24e95bd41ee901d33e566a97f1bf38 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 08:36:04 +0000 Subject: [PATCH 044/127] =?UTF-8?q?test:=20=E6=93=AC=E4=BC=BC=E7=AB=AF?= =?UTF-8?q?=E6=9C=AB=E3=81=AE=E3=83=86=E3=82=B9=E3=83=88=E3=81=AE=E5=BE=85?= =?UTF-8?q?=E3=81=A1=E3=81=8C=E5=88=87=E3=82=8C=E3=81=9F=E3=82=89=E4=BD=9C?= =?UTF-8?q?=E6=A5=AD=E3=83=87=E3=82=A3=E3=83=AC=E3=82=AF=E3=83=88=E3=83=AA?= =?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AE=E5=8C=BA=E9=96=93=E3=81=AE=E8=B5=B7?= =?UTF-8?q?=E5=8B=95=E3=81=A8=E3=83=95=E3=82=A1=E3=82=A4=E3=83=AB=E3=82=92?= =?UTF-8?q?=E5=87=BA=E3=81=99=EF=BC=88#2346=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Term.wait と Term.finish の失敗の文に、状態の置き場の作業ディレクトリ(hub/ を含む)ごとの 名前・区間の起動の引数・ディレクトリにあるファイルの名前を載せる。ファイルは決め打ちの一覧で 調べず、ある分をすべて出す。 Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/tests/test_relay.py | 50 ++++++++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/tests/test_relay.py b/plugins/ndf/scripts/tests/test_relay.py index a3cbf727a..99efda16f 100644 --- a/plugins/ndf/scripts/tests/test_relay.py +++ b/plugins/ndf/scripts/tests/test_relay.py @@ -459,7 +459,7 @@ def wait(self, pred, timeout=15, what=""): if pred(): return time.sleep(0.05) - raise AssertionError(f"待ちが切れた: {what}\n画面: {self.text[-2000:]}\n記録: {self.rows()}") + raise AssertionError(f"待ちが切れた: {what}\n画面: {self.text[-2000:]}\n記録: {self.rows()}\n{self.state_report()}") def starts(self): p = self.fake_dir / "starts.jsonl" @@ -474,6 +474,25 @@ def relay_dirs(self): # 統括役の置き場(hub/。#2346)は作業ディレクトリでない return sorted(p for p in root.iterdir() if p.is_dir() and p.name != "hub") if root.exists() else [] + def state_report(self): + """状態の置き場の作業ディレクトリ(統括役の hub/ を含む)ごとに、名前・区間の起動の引数・ファイルの名前を並べる。 + + 待ちが切れたときに、どの区間が起動し、どのシグナルファイルが置かれていたかを失敗の出力から読むため(#2346)。 + ファイルは決め打ちの一覧で調べず、ある分をすべて出す。 + """ + root = pathlib.Path(self.env["HOME"]) / ".local" / "state" / "ndf" / "relay" + dirs = sorted(p for p in root.iterdir() if p.is_dir()) if root.exists() else [] + starts = self.starts() + lines = [f"状態の置き場: {root}(作業ディレクトリ {len(dirs)} 個)"] + for d in dirs: + name = (d / "name").read_text(errors="replace").strip() if (d / "name").is_file() else "" + lines.append(f"- {d.name}(名前: {name})") + for s in starts: + if s.get("relay_dir") and pathlib.Path(s["relay_dir"]).name == d.name: + lines.append(f" 起動: {s.get('argv')}") + lines.append(f" ファイル: {sorted(f.name for f in d.iterdir() if f.is_file())}") + return "\n".join(lines) + def rows(self): out = [] for d in self.relay_dirs(): @@ -498,7 +517,7 @@ def finish(self, timeout=20): try: return self.proc.wait(timeout) except subprocess.TimeoutExpired: - raise AssertionError(f"ラッパーが終わらない\n画面: {self.text[-2000:]}\n記録: {self.rows()}") + raise AssertionError(f"ラッパーが終わらない\n画面: {self.text[-2000:]}\n記録: {self.rows()}\n{self.state_report()}") def close(self): if self.proc.poll() is None: @@ -4632,3 +4651,30 @@ def boom(text): monkeypatch.setattr(relay_mark.wait_notice, "classify_reply_wait", boom) record = relay_record.RelayRecord(str(relay.dir)) assert relay_mark.prose_wait_reason(str(relay.dir), record, {"last_assistant_message": WAIT_MSG}, False) is None + + +def test_wait_timeout_reports_each_relay_dir(tmp_path): + """待ちが切れたら、作業ディレクトリごとに区間の起動の引数とファイルの名前が失敗の文に載る(#2346)。""" + t = Term(tmp_path, cmd=[sys.executable, "-c", "import time; time.sleep(30)"]) + try: + root = pathlib.Path(t.env["HOME"]) / ".local" / "state" / "ndf" / "relay" + a, b = root / "d-a", root / "d-b" + for d, files in ((a, {"name": "lroot", "next.json": "{}"}), (b, {"stop": "", "zz-unlisted": ""})): + d.mkdir(parents=True) + for f, body in files.items(): + (d / f).write_text(body) + rows = [ + {"relay_dir": str(a), "argv": ["claude", "--", "mark /goal 次"]}, + {"relay_dir": str(b), "argv": ["claude", "--", "b の区間"]}, + ] + (t.fake_dir / "starts.jsonl").write_text("".join(json.dumps(r) + "\n" for r in rows)) + + with pytest.raises(AssertionError) as e: + t.wait(lambda: False, 0.1, "来ない") + msg = str(e.value) + finally: + t.close() + + assert "- d-a(名前: lroot)" in msg and "- d-b(名前: )" in msg + assert "['claude', '--', 'mark /goal 次']" in msg and "['claude', '--', 'b の区間']" in msg + assert "['name', 'next.json']" in msg and "['stop', 'zz-unlisted']" in msg From 4f6d342f1f3b9f443caefc80226c3239db5868ad Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 08:36:25 +0000 Subject: [PATCH 045/127] =?UTF-8?q?ci:=20=E3=83=86=E3=82=B9=E3=83=88?= =?UTF-8?q?=E3=81=AE=E3=82=B8=E3=83=A7=E3=83=96=E3=81=A7=20tmux=20?= =?UTF-8?q?=E3=82=92=E7=94=A8=E6=84=8F=E3=81=97=E3=80=81=E9=A0=85=E7=9B=AE?= =?UTF-8?q?=E3=81=94=E3=81=A8=E3=81=AE=E7=B5=90=E6=9E=9C=E3=82=92=20JUnit?= =?UTF-8?q?=20XML=20=E3=81=AE=E6=88=90=E6=9E=9C=E7=89=A9=E3=81=AB=E6=AE=8B?= =?UTF-8?q?=E3=81=99=EF=BC=88#2346=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tmux が無いときだけ apt で入れ、版をログに出す。分割ごとに pytest-junit-<分割>.xml を書き、 成否に関わらず成果物として残す。tmux を使う 4 件が走ったかを名前で確かめられる。 Co-Authored-By: Claude Opus 5.5 --- .github/workflows/pytest.yml | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index 930b397a9..22d46eb08 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -95,10 +95,24 @@ jobs: # # 前提のコマンドの確認と git の身元の設定は、根の conftest.py が行う。前提を # ワークフローの側にも置くと、増えたときに片方だけが更新される - - run: uv run --frozen --project . --all-extras pytest . -q -n auto + # 統括役の結合テスト(test_relay_hub.py::TestWithTmux)が tmux を使う。runner のイメージは tmux を + # 載せると約束していないため、無いときだけ入れる(ある間は時間を足さない。#2346)。apt の索引が古いと + # 取得で落ちるため、入れる経路では update を install の直前に打つ。版はログに残す + - name: tmux + run: | + command -v tmux || { sudo apt-get update && sudo apt-get install -y tmux; } + tmux -V + # 項目ごとの名前と結果を JUnit XML に残す(ログは -q で件数だけ。#2346) + - run: uv run --frozen --project . --all-extras pytest . -q -n auto --junitxml=pytest-junit-${{ matrix.shard }}.xml env: SHARD_TOTAL: 2 SHARD_INDEX: ${{ matrix.shard }} + - uses: actions/upload-artifact@v7 + if: always() + with: + name: pytest-junit-${{ matrix.shard }} + path: pytest-junit-${{ matrix.shard }}.xml + if-no-files-found: warn # ruleset の必須のチェックは `pytest` という名前の結果を 1 つ待つ。分けたジョブの結果を # ここで 1 つにまとめる。名前を変えると ruleset の変更が要る。 From 76e4f99b9cfc18c2163aa545f283c948b9af5ddb Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 09:12:02 +0000 Subject: [PATCH 046/127] =?UTF-8?q?docs:=20=E5=8F=97=E3=81=91=E5=85=A5?= =?UTF-8?q?=E3=82=8C=E6=9D=A1=E4=BB=B6=2032=20=E3=81=AE=E5=A4=89=E6=9B=B4?= =?UTF-8?q?=E3=81=AE=E7=AF=84=E5=9B=B2=E3=81=AB=20scripts/tests/test=5Froo?= =?UTF-8?q?t=5Fconftest.py=20=E3=82=92=E5=8A=A0=E3=81=88=E3=81=9F=E5=86=99?= =?UTF-8?q?=E3=81=97=E3=81=B8=E6=9B=B4=E6=96=B0=EF=BC=88#2346=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2346-requirements.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/issues/issue-2346-requirements.md b/issues/issue-2346-requirements.md index a7f68a11a..5c14222ac 100644 --- a/issues/issue-2346-requirements.md +++ b/issues/issue-2346-requirements.md @@ -194,7 +194,9 @@ - [ ] 29. develop 宛の Pull Request の CI のテストのジョブ(`pytest.yml` の分割の 2 本)で、tmux を使う 4 件(`test_spawn_ls_stop_and_hub_handover`・`test_second_claude_attaches_and_survives_terminal_close`・`test_limit_refuses_from_terminal_and_spawn`・`test_killed_named_wrapper_shows_stopped`)が実行されて通り、どれも skipped に数えられない。CI のログか成果物から、4 件の結果を名前で読める - [ ] 30. 開発コンテナ(tmux 3.6)で `test_spawn_ls_stop_and_hub_handover` を 10 回続けて実行し、10 回とも通る。`pytest plugins/ndf/scripts/tests -n 4` の全体の実行でも通る - [ ] 31. 結合テストが名前つきのラッパーの区間の待ちで切れて落ちるとき、失敗の出力に、そのラッパーの区間ごとの起動の引数と、作業ディレクトリのシグナルファイル(`next.json`・`stop` など)の有無が載る -- [ ] 32. `relay.py` と `relay_lib/` の振る舞いを変えない(変更は `plugins/ndf/scripts/tests/`・根の `conftest.py`・`.github/workflows/` に限る。`git diff --stat origin/develop` で確かめる) +- ~~32. `relay.py` と `relay_lib/` の振る舞いを変えない(変更は `plugins/ndf/scripts/tests/`・根の `conftest.py`・`.github/workflows/` に限る。`git diff --stat origin/develop` で確かめる)~~ → 変更の範囲に、根の `conftest.py` のテストの置き場 `scripts/tests/test_root_conftest.py` を加える(2026-11-05、承認ゲート 1 で利用者が設計を承認) +- [ ] 32. `relay.py` と `relay_lib/` の振る舞いを変えない(変更は `plugins/ndf/scripts/tests/`・根の `conftest.py`・`scripts/tests/test_root_conftest.py`・`.github/workflows/` に限る。`git diff --stat origin/develop` で確かめる) + ## 非機能の条件 From 13a62dd418e3926a4ec945cc776f99d4c1c1d084 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 09:27:57 +0000 Subject: [PATCH 047/127] =?UTF-8?q?feat:=20=E5=B7=AE=E5=88=86=E3=81=AE?= =?UTF-8?q?=E6=A3=9A=E5=8D=B8=E3=81=97=E3=81=AE=E8=A6=8B=E7=A9=8D=E3=81=AE?= =?UTF-8?q?=E7=84=A1=E3=81=84=E7=B5=8C=E8=B7=AF=E3=82=92=E8=B5=B7=E7=82=B9?= =?UTF-8?q?=E3=82=88=E3=82=8A=E5=BE=8C=E3=81=AB=E4=BD=9C=E3=82=89=E3=82=8C?= =?UTF-8?q?=E3=81=9F=E8=AA=B2=E9=A1=8C=E3=81=AB=E7=B5=9E=E3=82=8B=EF=BC=88?= =?UTF-8?q?#2315=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../backlog-refinement/scripts/upkeep.py | 6 +++-- .../tests/test_upkeep_rank_cli.py | 25 +++++++++++++++++++ 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py b/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py index 4c448ed5c..1f0838161 100644 --- a/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py +++ b/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py @@ -22,7 +22,7 @@ closed-milestone(閉じた課題のマイルストーン)/ sub-issue(閉じた親の子)/ commit-subject(..HEAD のコミットの件名が #番号で指す)/ all(--all)/ manual(--add で担当が足したもの)/ unscored(記録の見積が無いか、付けた後に要約値が変わった課題。rank を打った - リポジトリだけ。上限で切るときは数えない)。commit-subject の候補は、上限で切るときも先に残す。候補ごとに updated_at と + リポジトリだけ。`--depth diff` では のコミットの時刻(metrics.since)より後に作られた課題だけ。上限で切るときは数えない)。commit-subject の候補は、上限で切るときも先に残す。候補ごとに updated_at と 課題の要約値(題名・本文・状態・マイルストーン・ラベル)を返す。 上限(--limit → .ndf/backlog.json の candidate_limit → 既定 10)を超えた分は items に載せず、metrics.deferred に番号だけを返す(終了コード 20)。 apply: plan.json の変更を反映する。反映の直前に updated_at を照合し、変わっていれば課題の @@ -227,7 +227,9 @@ def cmd_candidates(a): _route_commits(root, a.since_ref, routes) _route_manual(a, routes, notes) sd = _state_dir(a.state_dir, repo) - for n in RC.unscored(sd, _read_state, open_issues, snapshot_digest) if has_milestones else []: + # 差分の棚卸しが見積の段階を付けるのは起点より後に作られた課題だけ(#2315 の決定 1・決定 6)。見直しは絞らない + pool = open_issues if a.depth == "review" else [i for i in open_issues if (i.get("created_at") or "") > since] + for n in RC.unscored(sd, _read_state, pool, snapshot_digest) if has_milestones else []: routes.add(n, "unscored") if a.depth == "review": _route_review(open_issues, set((D.materials(root) or {}).get("deferred_milestones") or []), routes) diff --git a/plugins/ndf/skills/backlog-refinement/tests/test_upkeep_rank_cli.py b/plugins/ndf/skills/backlog-refinement/tests/test_upkeep_rank_cli.py index 5dbacfe9b..a4256acfb 100644 --- a/plugins/ndf/skills/backlog-refinement/tests/test_upkeep_rank_cli.py +++ b/plugins/ndf/skills/backlog-refinement/tests/test_upkeep_rank_cli.py @@ -367,7 +367,24 @@ def test_waiting_decision_and_needs_detail_are_reported(env): assert rep["metrics"]["waiting_decision"] == [4] and rep["metrics"]["needs_detail"] == {"4": ["size"]} +def _created(env, before=(), after=()): + """起点(v1 のコミットの時刻)より前と後に作られた課題にする。""" + + def fn(st): + for n in before: + st["issues"][str(n)]["created_at"] = "2000-01-01T00:00:00Z" + for n in after: + st["issues"][str(n)]["created_at"] = "2999-01-01T00:00:00Z" + + env.set(fn) + + +def _unscored(out): + return {i["number"] for i in out["items"] if "unscored" in i.get("routes", [])} + + def test_candidates_pick_unscored_issues_only_after_rank(env): + _created(env, after=(1, 2, 3, 4)) _, out = env.run("candidates", "--since-ref", "v1") assert out["metrics"]["by_route"]["unscored"] == 0 snaps = {i["number"]: i["digest"] for i in env.run("candidates", "--since-ref", "v1", "--all")[1]["items"]} @@ -379,6 +396,14 @@ def test_candidates_pick_unscored_issues_only_after_rank(env): assert not (env.sd / "rank.json").exists() +def test_diff_candidates_score_only_issues_created_after_the_ref(env): + """差分の棚卸しの見積の無い経路は起点より後に作られた課題だけ。見直しは作られた時刻で絞らない(#2315 の I4・I5)。""" + env.run("rank", "--scores", env.scores([])) # 見積の記録を作る(4 件とも見積が無い) + _created(env, before=(1, 3), after=(2, 4)) + assert _unscored(env.run("candidates", "--since-ref", "v1")[1]) == {2, 4} + assert _unscored(env.run("candidates", "--since-ref", "v1", "--depth", "review")[1]) == {1, 2, 3, 4} + + def test_apply_does_not_write_the_table_when_a_planned_move_fails(env): """予定どおりに移せなかった課題があれば、順位の表を書かずに止める(表と課題の所属を食い違わせない)。""" snaps = env.snapshots() From ed662d2136b10d3bba690feb9738b4d95d70a914 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 09:29:53 +0000 Subject: [PATCH 048/127] =?UTF-8?q?feat:=20=E5=B7=AE=E5=88=86=E3=81=AE?= =?UTF-8?q?=E6=A3=9A=E5=8D=B8=E3=81=97=E3=81=AE=E8=A1=8C=E3=81=B8=E3=83=97?= =?UTF-8?q?=E3=83=A9=E3=83=B3=E3=81=AE=E9=80=B2=E8=A1=8C=E3=81=AE=E8=A8=98?= =?UTF-8?q?=E9=8C=B2=E3=81=AE=E7=A7=92=E3=83=BB=E3=83=89=E3=83=AB=E3=83=BB?= =?UTF-8?q?=E8=A8=80=E8=AA=9E=E3=83=A2=E3=83=87=E3=83=AB=E3=82=92=E5=86=99?= =?UTF-8?q?=E3=81=97=E3=80=81=E4=B8=AD=E5=A4=AE=E5=80=A4=E3=82=92=E5=9F=BA?= =?UTF-8?q?=E6=BA=96=E3=81=A8=E6=AF=94=E3=81=B9=E3=82=8B=20depth=20compare?= =?UTF-8?q?=20=E3=82=92=E8=B6=B3=E3=81=99=EF=BC=88#2315=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../backlog-refinement/scripts/upkeep.py | 2 +- .../scripts/upkeep_depth.py | 106 ++++++++++++++- .../tests/test_upkeep_depth_usage.py | 127 ++++++++++++++++++ 3 files changed, 232 insertions(+), 3 deletions(-) create mode 100644 plugins/ndf/skills/backlog-refinement/tests/test_upkeep_depth_usage.py diff --git a/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py b/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py index 1f0838161..a4a764443 100644 --- a/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py +++ b/plugins/ndf/skills/backlog-refinement/scripts/upkeep.py @@ -8,7 +8,7 @@ python3 upkeep.py report [--depth diff|review|overhaul [--run R] [--order prune,rank,...]] [--repo owner/name] [--state-dir ] [--root ] python3 upkeep.py rank [--scores <見積の入力>] [--capacity N] ...(引数と記録は upkeep_rank_cmd.py の冒頭) - python3 upkeep.py depth due|record ... / causes record ...(棚卸しの深さ。upkeep_depth.py の冒頭) + python3 upkeep.py depth due|record|compare ... / causes record ...(棚卸しの深さ。upkeep_depth.py の冒頭) python3 upkeep.py owner claim|touch|release|status ...(見直しの持ち主。upkeep_owner.py の冒頭) python3 upkeep.py research plan|run ...(ニーズとシーズの調査。upkeep_research.py の冒頭) python3 upkeep.py overhaul snapshot|count|measure ...(大整理。upkeep_overhaul.py の冒頭) diff --git a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py index 077b2cf0d..4a61819dd 100644 --- a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py +++ b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py @@ -3,18 +3,28 @@ upkeep.py depth due review|overhaul [--loop L] [--lap N] [--no-issue] [--manual] [--open N] upkeep.py depth record --depth diff|review|overhaul [--event ran|not_due|skipped] [--trigger a,b] [--run R] [--report <統一 JSON レポート>] [--loop L] [--lap N] [--sprint S] [--cost USD] [--seconds S] + [--progress <プランの進行の記録> [--step <ステップの id>]] + upkeep.py depth compare --since-ref --seconds-under S --cost-under USD [--count N] [--model M] [--depth D] upkeep.py causes record --classes <[{number, type, sub}] の JSON> 記録はメインディレクトリの `.ndf/backlog/` に置く(`depths.jsonl`・`causes.jsonl`。行を足すだけで書き換えない)。 `depth due` は起動するとき 0、起動しないとき 3(プランの実行条件の `skip_code`)を返し、起動しない判定を `not_due` の行で、起動する判定を `due` の行で残す。記録が読めないときも 3 で返し、理由(`unreadable`)を行に残す。 頻度・閾値・上限は `.ndf/backlog.json` の `depths` で上書きでき、無ければ `DEFAULTS` を使う。 +`depth record --progress` はプランの進行の記録(supervise の `progress.jsonl`)の `--step`(既定 `refine`)の最後の +`kind: step` の行から秒・ドル・言語モデルの名前(`values.models`)を写す。読めなければ空のまま行を書く。`--seconds`・ +`--cost` を渡せばその値を優先する。 +`depth compare` は `` のコミットの時刻より後の実行の行のうち、秒とドルがそろい(`no_usage`)、`--model` を渡せば +言語モデルがその 1 つだけの(`model`)行を、同じ開発ループ・周回(ループの外では同じスプリント)では最後の 1 行に +して(`duplicate`)、古い順に `--count` 件の中央値を基準と比べる。0 = 秒とドルの両方が基準を下回る / 1 = 下回らない / +3 = 件数に満たない(保留)/ 2 = 記録か ref を読めない。 """ from __future__ import annotations import copy import json +import statistics from pathlib import Path import clock @@ -22,7 +32,7 @@ import locks import project_decl import repo as repo_lib -from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, StepError, emit, git_root, result +from step_result import EXIT_PRECONDITION, EXIT_UNREADABLE, EXIT_VIOLATION, StepError, emit, git, git_root, result import upkeep_rank_cmd as RC TOOL = "backlog-refinement" @@ -282,6 +292,20 @@ def _state_values(a, root) -> dict: return out +def _step_usage(path: str | None, step: str) -> dict: + """プランの進行の記録の `step` の最後の `kind: step` の行の秒・ドル・言語モデル(#2315 の決定 2・決定 4)。読めなければ空。""" + if not path: + return {} + rows = jsonio.read_jsonl(path, missing=[], broken=[], skip_broken=True) + last = next((r for r in reversed(rows) if r.get("kind") == "step" and r.get("step") == step), None) + if last is None: + return {} + out = {k: last[k] for k in ("seconds", "cost") if isinstance(last.get(k), (int, float)) and not isinstance(last[k], bool)} + if isinstance(last.get("models"), dict): + out["models"] = sorted(last["models"]) + return out + + def cmd_record(a): root = git_root(a.root) d = backlog_dir(root) @@ -306,11 +330,77 @@ def cmd_record(a): for k in ("skipped_aspects", "order", "research"): if rep.get(k): row[k] = rep[k] - row.update(cost=a.cost, seconds=a.seconds, report=a.report) + usage = _step_usage(getattr(a, "progress", None), getattr(a, "step", None) or "refine") + if "models" in usage: + row["values"]["models"] = usage["models"] + row.update( + cost=a.cost if a.cost is not None else usage.get("cost"), + seconds=a.seconds if a.seconds is not None else usage.get("seconds"), + report=a.report, + ) append_depth_row(d / "depths.jsonl", row) emit(result(TOOL, "ok", f"深さの記録へ {a.depth} の {a.event} を足した", [row], {"path": str(d / "depths.jsonl")})) +def _is_num(v) -> bool: + return isinstance(v, (int, float)) and not isinstance(v, bool) + + +def _period_key(r: dict, k: int): + """同じ周回を 1 回と数える鍵。開発ループの外ではスプリント、どちらも無ければ行ごとに別。""" + if r.get("loop") is not None: + return ("loop", r["loop"], r.get("lap")) + if r.get("sprint") is not None: + return ("sprint", r["sprint"]) + return ("row", k) + + +def compare_rows(rows: list[dict], since, *, depth: str, model: str | None) -> tuple[list[dict], list[dict]]: + """(使える行を古い順, 外した行と理由)。秒・ドル・言語モデルで絞ってから同じ周回の重複を除く(#2315 の決定 5)。""" + picked, excluded = [], [] + for k, r in enumerate(rows): + at = clock.parse(r.get("at"), naive="utc") + if not _is_ran(r, depth) or at is None or at <= since: + continue + if not (_is_num(r.get("seconds")) and _is_num(r.get("cost"))): + excluded.append((r, "no_usage")) + elif model is not None and ((r.get("values") or {}).get("models")) != [model]: + excluded.append((r, "model")) + else: + picked.append((k, r)) + last = {_period_key(r, k): k for k, r in picked} + kept = [r for k, r in picked if last[_period_key(r, k)] == k] + excluded += [(r, "duplicate") for k, r in picked if last[_period_key(r, k)] != k] + return kept, [{**_brief(r), "result": "excluded", "reason": why} for r, why in excluded] + + +def _brief(r: dict) -> dict: + return {k: r.get(k) for k in ("at", "sprint", "loop", "lap", "seconds", "cost")} + + +def cmd_compare(a): + root = git_root(a.root) + p = git(root, "log", "-1", "--format=%cI", a.since_ref, check=False) + since = clock.parse(p.stdout.strip(), naive="utc") if p.returncode == 0 else None + if since is None: + raise StepError(f"ref を読めない: {a.since_ref}", EXIT_UNREADABLE) + kept, excluded = compare_rows(read_depth_rows(backlog_dir(root) / "depths.jsonl"), since, depth=a.depth, model=a.model) + used = kept[: a.count] + baseline = {"seconds": a.seconds_under, "cost": a.cost_under} + m = {"count": len(used), "needed": a.count, "median_seconds": None, "median_cost": None, "baseline": baseline, "delta": None} + items = [{**_brief(r), "result": "used"} for r in used] + excluded + if len(used) < a.count: + out = result(TOOL, "stopped", f"{a.depth} の行が {len(used)} / {a.count} 件で、判定を保留する", items, {**m, "judged": False}) + emit(out, EXIT_PRECONDITION) + med = {"seconds": statistics.median(r["seconds"] for r in used), "cost": statistics.median(r["cost"] for r in used)} + m.update(median_seconds=med["seconds"], median_cost=med["cost"], judged=True) + m["delta"] = {k: round(med[k] - baseline[k], 4) for k in baseline} + under = all(med[k] < baseline[k] for k in baseline) + summary = f"{a.depth} の中央値 {med['seconds']} 秒・{med['cost']} ドル(基準 {a.seconds_under} 秒・{a.cost_under} ドル)" + emit(result(TOOL, "ok" if under else "stopped", summary + ("は基準を下回る" if under else "は基準を下回らない"), items, m), + None if under else EXIT_VIOLATION) # fmt: skip + + def cmd_causes(a): root = git_root(a.root) d = backlog_dir(root) @@ -351,7 +441,19 @@ def add_depth_parsers(sub, common, state_dir) -> None: dr.add_argument("--sprint", default=None) dr.add_argument("--cost", type=float, default=None) dr.add_argument("--seconds", type=float, default=None) + dr.add_argument( + "--progress", default=None, help="プランの進行の記録(progress.jsonl)。--step の最後の行から秒・ドル・言語モデルを写す" + ) + dr.add_argument("--step", default="refine", help="--progress で読むステップの id") dr.set_defaults(func=cmd_record, state_dir_of=state_dir) + dc = dp.add_parser("compare", parents=[common]) + dc.add_argument("--since-ref", required=True, help="この ref のコミットの時刻より後の行だけを使う(配布した正式版のタグ)") + dc.add_argument("--seconds-under", type=float, required=True) + dc.add_argument("--cost-under", type=float, required=True) + dc.add_argument("--count", type=int, default=3) + dc.add_argument("--model", default=None, help="values.models がこの 1 つだけの行に限る") + dc.add_argument("--depth", choices=DEPTHS, default="diff") + dc.set_defaults(func=cmd_compare) cp = sub.add_parser("causes").add_subparsers(dest="causes_cmd", required=True) cr = cp.add_parser("record", parents=[common]) cr.add_argument("--classes", required=True) diff --git a/plugins/ndf/skills/backlog-refinement/tests/test_upkeep_depth_usage.py b/plugins/ndf/skills/backlog-refinement/tests/test_upkeep_depth_usage.py new file mode 100644 index 000000000..5d8465786 --- /dev/null +++ b/plugins/ndf/skills/backlog-refinement/tests/test_upkeep_depth_usage.py @@ -0,0 +1,127 @@ +"""差分の棚卸しの所要を深さの記録へ写すことと、比べ方のコマンド(#2315 の受け入れ条件 17b・17d、不変条件 I1・I2・I6)。""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + +import pytest + +HERE = Path(__file__).resolve().parent +SCRIPTS = HERE.parent / "scripts" +sys.path.insert(0, str(SCRIPTS.parents[2] / "scripts" / "lib")) +from step_result import validate_result # noqa: E402 + +PY = sys.executable +BEFORE, AFTER = "2000-01-01T00:00:00+00:00", "2999-01-0{}T00:00:00+00:00" + + +def _git(repo, *args): + subprocess.run(["git", "-C", str(repo), "-c", "user.email=t@t", "-c", "user.name=t", *args], check=True, capture_output=True) + + +@pytest.fixture +def root(tmp_path): + r = tmp_path / "repo" + r.mkdir() + _git(r, "init", "-q") + _git(r, "commit", "-q", "--allow-empty", "-m", "base") + _git(r, "tag", "v1") + return r + + +def _upkeep(root, *args): + p = subprocess.run([PY, str(SCRIPTS / "upkeep.py"), *args, "--root", str(root)], capture_output=True, text=True) + out = json.loads(p.stdout.strip().splitlines()[-1]) + assert validate_result(out, p.returncode) == [], (out, p.returncode, p.stderr) + return p.returncode, out + + +def _depth_rows(root): + return [json.loads(x) for x in (root / ".ndf" / "backlog" / "depths.jsonl").read_text().splitlines()] + + +def _step(step="refine", seconds=1.0, cost=1.0, models=("claude-opus-5-5",)): + return {"kind": "step", "step": step, "exit": 0, "seconds": seconds, "cost": cost, "models": {m: {"cost": cost} for m in models}} + + +# ---------------- 記録のステップ(17b) ---------------- + + +def test_record_copies_the_last_refine_row_of_the_plan_progress(root, tmp_path): + prog = tmp_path / "progress.jsonl" + rows = [_step(seconds=300.0, cost=2.0), _step("refine-record", seconds=1.0, cost=0.0), _step(seconds=150.5, cost=1.25)] + prog.write_text("\n".join(json.dumps(r) for r in rows) + "\n") + code, out = _upkeep(root, "depth", "record", "--depth", "diff", "--progress", str(prog), "--loop", "l1", "--lap", "2") + row = _depth_rows(root)[-1] + assert code == 0 and out["items"] == [row] + assert (row["seconds"], row["cost"], row["values"]["models"]) == (150.5, 1.25, ["claude-opus-5-5"]) + assert (row["loop"], row["lap"]) == ("l1", 2) + _upkeep(root, "depth", "record", "--depth", "diff", "--progress", str(prog), "--seconds", "9", "--cost", "0.5") + assert (_depth_rows(root)[-1]["seconds"], _depth_rows(root)[-1]["cost"]) == (9.0, 0.5) # 渡した値を優先する + + +@pytest.mark.parametrize("content", [None, "{壊れた\n", json.dumps(_step("retro")) + "\n"]) +def test_record_writes_an_empty_row_when_the_progress_has_no_refine_row(root, tmp_path, content): + prog = tmp_path / "progress.jsonl" + if content is not None: + prog.write_text(content) + code, _ = _upkeep(root, "depth", "record", "--depth", "diff", "--progress", str(prog)) + rows = _depth_rows(root) + assert code == 0 and len(rows) == 1 + assert rows[0]["seconds"] is None and rows[0]["cost"] is None and "models" not in rows[0]["values"] + + +# ---------------- 比べ方のコマンド(17d) ---------------- + + +def _ran(at, seconds=100.0, cost=1.0, models=("claude-opus-5-5",), **kw): + return {"at": at, "depth": "diff", "event": "ran", "seconds": seconds, "cost": cost, "values": {"models": list(models)}, **kw} + + +def _write(root, rows): + d = root / ".ndf" / "backlog" + d.mkdir(parents=True, exist_ok=True) + (d / "depths.jsonl").write_text("\n".join(json.dumps(r) for r in rows) + "\n") + + +def _compare(root, *extra): + return _upkeep(root, "depth", "compare", "--since-ref", "v1", "--seconds-under", "206", "--cost-under", "1.41", + "--model", "claude-opus-5-5", *extra) # fmt: skip + + +def test_compare_uses_the_oldest_rows_after_the_ref_once_per_lap(root): + rows = [ + _ran(BEFORE, seconds=1, cost=0.1, loop="a", lap=1), # 起点より前 + _ran(AFTER.format(1), seconds=None, cost=None, loop="a", lap=2), # 秒の空 + _ran(AFTER.format(2), models=("other",), loop="a", lap=3), # 言語モデルが違う + _ran(AFTER.format(3), seconds=300, cost=2.0, loop="a", lap=4), # 同じ周回の打ち直しで、次の行が採られる + _ran(AFTER.format(4), seconds=100, cost=1.0, loop="a", lap=4), + _ran(AFTER.format(5), seconds=200, cost=1.2, loop="b", lap=1), # 最後の行が空でも、前のそろう行を 1 回と数える + _ran(AFTER.format(6), seconds=None, cost=None, loop="b", lap=1), + _ran(AFTER.format(7), seconds=150, cost=1.3, sprint="s1"), + _ran(AFTER.format(8), seconds=1, cost=0.01, sprint="s2"), # 4 件目は使わない + {**_ran(AFTER.format(9)), "depth": "review"}, + ] + _write(root, rows) + code, out = _compare(root) + m = out["metrics"] + used = [i for i in out["items"] if i["result"] == "used"] + assert [(i["seconds"], i["cost"]) for i in used] == [(100, 1.0), (200, 1.2), (150, 1.3)] + assert (m["count"], m["needed"], m["judged"], m["median_seconds"], m["median_cost"]) == (3, 3, True, 150, 1.2) + assert m["delta"] == {"seconds": -56.0, "cost": -0.21} and code == 0 + reasons = sorted(i["reason"] for i in out["items"] if i["result"] == "excluded") + assert reasons == ["duplicate", "model", "no_usage", "no_usage"] + + +def test_compare_holds_until_enough_rows_and_needs_both_under(root): + _write(root, [_ran(AFTER.format(1), loop="a", lap=1), _ran(AFTER.format(2), loop="a", lap=2)]) + code, out = _compare(root) + assert code == 3 and out["metrics"]["count"] == 2 and out["metrics"]["judged"] is False + _write(root, [_ran(AFTER.format(k), seconds=100, cost=c, loop="a", lap=k) for k, c in ((1, 2.0), (2, 2.0), (3, 1.0))]) + code, out = _compare(root) + assert code == 1 and out["metrics"]["judged"] is True # 秒は下回るがドルは下回らない + code, _ = _upkeep(root, "depth", "compare", "--since-ref", "nope", "--seconds-under", "1", "--cost-under", "1") + assert code == 2 From ea73a7d3c904b2f3bd0bb544a323719c60b58a2e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 09:31:00 +0000 Subject: [PATCH 049/127] =?UTF-8?q?feat:=20=E3=81=BE=E3=81=A8=E3=82=81?= =?UTF-8?q?=E3=81=AE=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AE=E6=B7=B1=E3=81=95?= =?UTF-8?q?=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=AE=E3=82=B9=E3=83=86=E3=83=83?= =?UTF-8?q?=E3=83=97=E3=81=B8=E3=83=97=E3=83=A9=E3=83=B3=E3=81=AE=E9=80=B2?= =?UTF-8?q?=E8=A1=8C=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=A8=E9=96=8B=E7=99=BA?= =?UTF-8?q?=E3=83=AB=E3=83=BC=E3=83=97=E3=83=BB=E5=91=A8=E5=9B=9E=E3=82=92?= =?UTF-8?q?=E6=B8=A1=E3=81=99=EF=BC=88#2315=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop_gate.py | 1 + plugins/ndf/scripts/supervise_lib/new_args.py | 2 ++ plugins/ndf/scripts/supervise_lib/sprint_close_steps.py | 4 +++- plugins/ndf/scripts/tests/test_sprint_loop.py | 1 + plugins/ndf/scripts/tests/test_supervise_close_auto.py | 8 ++++++++ 5 files changed, 15 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 344db3a29..698041cb3 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -112,6 +112,7 @@ def build_close(env: Env, rec: dict, lap: dict, issues: list[int], tag: str = "c out = Path(lap["dir"]) / tag cmd = [sys.executable, str(SUPERVISE), "new", "close", "--pace", "auto", "--name", lap["sprint"], "--worktree", str(env.main_dir())] cmd += ["--issue", *map(str, issues), "--state", lap["state_path"], "--out", str(out), "--mode", lap.get("mode") or "standard"] + cmd += ["--loop", rec["name"], "--lap", str(lap["n"])] # 差分の棚卸しの深さの記録の行に残す(#2315 の決定 3) if carry: cmd += ["--carry", *map(str, carry)] if lap.get("milestone"): diff --git a/plugins/ndf/scripts/supervise_lib/new_args.py b/plugins/ndf/scripts/supervise_lib/new_args.py index d1dacb553..56698de9c 100644 --- a/plugins/ndf/scripts/supervise_lib/new_args.py +++ b/plugins/ndf/scripts/supervise_lib/new_args.py @@ -322,6 +322,8 @@ def positive_int(text: str) -> int: "close", ), ("--milestone", {"help": "マイルストーン(振り返りの材料)"}, "close"), + ("--loop", {"help": "まとめを組む開発ループの名前(差分の棚卸しの深さの記録の行に残す。#2315)"}, "close"), + ("--lap", {"type": int, "help": "まとめを組む開発ループの周回の番号(--loop と組)"}, "close"), ] NEW_REQUIRED = { "impl": "--issue・--tests・--title", diff --git a/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py b/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py index 47a1da11c..bfeea1642 100644 --- a/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py +++ b/plugins/ndf/scripts/supervise_lib/sprint_close_steps.py @@ -175,12 +175,14 @@ def refine_steps(a, refs: str, repo: str) -> list[dict]: """棚卸し: 差分の棚卸し → 深さの記録 → 大整理(起動するときだけ)→ 見直し(起動するときだけ)。 大整理を見直しより先に置く。両方が起動する周回では、組み直した後の課題で順位を付けるため。""" + # 秒・ドル・言語モデルは同じプランの進行の記録の refine の行から写す(#2315 の決定 2)。開発ループと周回は引数で受ける(決定 3) + scope = [x for k in ("loop", "lap") if getattr(a, k, None) is not None for x in (f"--{k}", str(getattr(a, k)))] record = { "id": "refine-record", "type": "run", "cwd": repo, "timeout": 120, - "cmd": _upkeep(repo, "depth", "record", "--depth", "diff", "--sprint", a.name), + "cmd": _upkeep(repo, "depth", "record", "--depth", "diff", "--sprint", a.name, "--progress", "{state_dir}/progress.jsonl", *scope), "next": "overhaul-due", } return [ diff --git a/plugins/ndf/scripts/tests/test_sprint_loop.py b/plugins/ndf/scripts/tests/test_sprint_loop.py index 0ff61defa..a3aa65231 100644 --- a/plugins/ndf/scripts/tests/test_sprint_loop.py +++ b/plugins/ndf/scripts/tests/test_sprint_loop.py @@ -697,6 +697,7 @@ def test_lap_copies_values_carry_reaches_the_next_close_and_list_shows_laps(env) sl_select.cmd_select(env, "l1") out = sl.cmd_sprint(env, "l1", "standard", "1.0.0-dev.2", None, []) assert "--carry 31 32" in out["metrics"]["close_command"] and "--pace auto" in out["metrics"]["close_command"] + assert "--loop l1 --lap 2" in out["metrics"]["close_command"] # 差分の棚卸しの行に開発ループと周回を残す(#2315 の 17b-2) # 周回の版は出し終えているので、まとめへ明示の版として渡さない(流す時点に次に空いている版で決める。#2164) assert "--version" not in out["metrics"]["close_command"] and "--prod" not in out["metrics"]["close_command"] assert "1-close.json" in out["metrics"]["queue"].split("--then")[-1] diff --git a/plugins/ndf/scripts/tests/test_supervise_close_auto.py b/plugins/ndf/scripts/tests/test_supervise_close_auto.py index b93f641d2..f380ee38b 100644 --- a/plugins/ndf/scripts/tests/test_supervise_close_auto.py +++ b/plugins/ndf/scripts/tests/test_supervise_close_auto.py @@ -35,6 +35,14 @@ def test_carry_reaches_the_refinement_prompt_and_record_pr_overrides(tmp_path): assert "--record-pr 77 " in steps["close"]["cmd"] +def test_refine_record_reads_the_plan_progress_and_takes_loop_and_lap(tmp_path): + """#2315 の 17b-1・17b-2: 記録のステップは同じプランの進行の記録を読み、開発ループと周回は渡したときだけ入る。""" + cmd = steps_of(load(close(tmp_path, "--pace", "auto", "--loop", "l1", "--lap", "3")["ステージ"][0]["plans"][0]))["refine-record"]["cmd"] + assert "--progress '{state_dir}/progress.jsonl'" in cmd and "--loop l1 --lap 3" in cmd + cmd = steps_of(load(close(tmp_path, "--pace", "auto")["ステージ"][0]["plans"][0]))["refine-record"]["cmd"] + assert "--progress" in cmd and "--loop" not in cmd and "--lap" not in cmd + + def test_without_pace_the_close_is_unchanged(tmp_path): manifest = close(tmp_path) assert [w["name"] for w in manifest["ステージ"]] == ["最終の検査", "開発版", "本番", "まとめ"] From 8b0333d2c5a87bc03250fff1ecf85dc42ef40b03 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 09:31:29 +0000 Subject: [PATCH 050/127] =?UTF-8?q?docs:=20=E5=B7=AE=E5=88=86=E3=81=AE?= =?UTF-8?q?=E6=A3=9A=E5=8D=B8=E3=81=97=E3=81=AE=E8=A6=8B=E7=A9=8D=E3=81=AE?= =?UTF-8?q?=E7=84=A1=E3=81=84=E7=B5=8C=E8=B7=AF=E3=81=AE=E7=B5=9E=E3=82=8A?= =?UTF-8?q?=E8=BE=BC=E3=81=BF=E3=81=A8=20depth=20compare=20=E3=82=92?= =?UTF-8?q?=E6=89=8B=E9=A0=86=E6=9B=B8=E3=81=AB=E6=9B=B8=E3=81=8F=EF=BC=88?= =?UTF-8?q?#2315=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/skills/backlog-refinement/SKILL.md | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/plugins/ndf/skills/backlog-refinement/SKILL.md b/plugins/ndf/skills/backlog-refinement/SKILL.md index 0c1f2e3d5..6b7a74912 100644 --- a/plugins/ndf/skills/backlog-refinement/SKILL.md +++ b/plugins/ndf/skills/backlog-refinement/SKILL.md @@ -31,13 +31,22 @@ allowed-tools: | 深さ | 頻度(既定) | 行うこと | 読む参照 | | --- | --- | --- | --- | -| `diff`(差分の棚卸し) | 毎スプリント | 扱う 5 つのうち 1(誤りの訂正)と、そのスプリントで起票した課題への見積の段階の付与だけ | この文書 | +| `diff`(差分の棚卸し) | 毎スプリント | 扱う 5 つのうち 1(誤りの訂正)と、そのスプリントで起票した課題(`--since-ref` のコミットの時刻より後に作られた課題)への見積の段階の付与だけ | この文書 | | `review`(見直し) | 3 回に 1 回 | 後回しを除く open の全件に 5 つすべてと、ニーズ・シーズの調査からの起票 | [references/review.md](references/review.md)・[references/research.md](references/research.md) | | `overhaul`(大整理) | 12 回に 1 回 | 未完了の全件を原因の型で分けて根本原因を探し、組み直す。後回しの再評価を含む | [references/overhaul.md](references/overhaul.md) | 起動の条件(回数と溜まり具合)・見直しの持ち主・記録の置き場は [references/depths.md](references/depths.md) にある。 まとめのプランは差分の棚卸しの後に、大整理と見直しを起動の判定つきで流す。`--loop` を使わないときは人が `--depth review` か `--depth overhaul` で呼べ、`depth due ... --manual` が起動の条件を `manual` で残す。 +まとめのプランは差分の棚卸しの行(深さの記録)へ、同じプランの進行の記録の棚卸しのステップの秒・ドル・言語モデルと、 +開発ループの名前・周回の番号を写す。差分の棚卸しの所要は、配布した版のタグより後の行の中央値を基準と比べて確かめる: + +```bash +python3 "$UPKEEP/upkeep.py" depth compare --since-ref <配布した版のタグ> --seconds-under <基準の秒> --cost-under <基準のドル> \ + [--count 3] [--model <言語モデル>] +``` + +終了コードは 0 = 秒とドルの中央値が両方とも基準を下回る / 1 = 下回らない / 3 = 行が `--count` 件に満たない(判定の保留)。 **着手の直前は判断が歪む場所である。** 実装を担う側には締め切りがあり、そこで「この課題は 成り立たない」と気づいても、止める判断は自分の作業を遅らせる。判断を下す人が、その判断で @@ -162,6 +171,8 @@ python3 "$UPKEEP/upkeep.py" candidates --since-ref <前のリリースのタグ> `diff-path` / `diff-identifier` / `no-milestone` / `closed-milestone` / `sub-issue` / `commit-subject`)。`commit-subject` の候補は上限で切るときも先に残す。`rank` を打ったことのあるリポジトリでは、記録の 見積が無い課題と、見積を付けた後に要約値が変わった課題も拾う(`unscored`。上限で切るときの経路の数には入れない)。 +差分の棚卸し(`--depth diff`)の `unscored` は、`--since-ref` のコミットの時刻(`metrics.since`)より後に作られた課題だけである。 +それより前の課題は見直しで拾う。 `rank` を打ったことが無い初回は、手順 2C の `rank` が見積の無い open の課題をすべて `excluded` で返し、その全件が採点の対象になる。担当が足す 経路の課題は `--add` で渡す(`manual`)。候補ごとに `updated_at` と課題の要約値(`digest`。題名・本文・ 状態・マイルストーン・ラベルから作る)を返し、手順 3 の照合に使う。未完了が 0 件になったマイルストーンは `kind: milestone` の項目で返る。 From 2aecc5ccab861d99b7fdfe739c57e9c952ad0daf Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 09:52:48 +0000 Subject: [PATCH 051/127] =?UTF-8?q?test:=20supervise=20=E3=81=AE=E5=A5=91?= =?UTF-8?q?=E7=B4=84=E3=81=AE=E5=9B=BA=E5=AE=9A=E3=83=87=E3=83=BC=E3=82=BF?= =?UTF-8?q?=E3=82=92=E3=80=81=E6=B7=B1=E3=81=95=E3=81=AE=E8=A8=98=E9=8C=B2?= =?UTF-8?q?=E3=81=AE=E3=82=B9=E3=83=86=E3=83=83=E3=83=97=E3=81=AE=20--prog?= =?UTF-8?q?ress=20=E3=81=A8=20new=20close=20=E3=81=AE=20--loop=E3=83=BB--l?= =?UTF-8?q?ap=20=E3=82=92=E5=90=AB=E3=82=80=E4=BB=8A=E3=81=AE=E5=87=BA?= =?UTF-8?q?=E5=8A=9B=E3=81=B8=E6=8F=83=E3=81=88=E3=82=8B=EF=BC=88#2315?= =?UTF-8?q?=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/tests/fixtures/supervise-contract/contract.json | 2 +- .../tests/fixtures/supervise-contract/plans/close/4-close.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json index 653f52dc8..0b9587bb4 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/contract.json @@ -15,7 +15,7 @@ "new fix": "usage: supervise.py new fix [-h] [--worktree WORKTREE] [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--tests PATH [PATH ...]] [--title TITLE] [--summary SUMMARY] [--branch BRANCH] [--escape-of ESCAPE_OF]", "new check": "usage: supervise.py new check [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--scope PATH [PATH ...]] [--refactor-budget-minutes MINUTES] [--pr PR] [--since-last] [--id ID] [--final] [--since-ref SINCE_REF] [--review-only] [--sprint SPRINT]", "new release": "usage: supervise.py new release [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--mode MODE] [--out OUT] [--branch BRANCH] [--version VERSION] [--prs PRS [PRS ...]] [--prs-from-queue] [--channel {dev,prod}] [--prev-tag PREV_TAG] [--repo REPO] [--mvv MVV] [--dev-state DEV_STATE] [--state STATE]", - "new close": "usage: supervise.py new close [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--pace {normal,fast,auto}] [--state STATE] [--prod PROD] [--level {patch,minor,major}] [--record-pr RECORD_PR] [--carry CARRY [CARRY ...]] [--milestone MILESTONE]", + "new close": "usage: supervise.py new close [-h] --worktree WORKTREE [--issue ISSUE [ISSUE ...]] [--base BASE] [--production-branch PRODUCTION_BRANCH] [--test-cmd TEST_CMD] [--test-kind {test,lint}] [--test-all TEST_ALL] [--mode MODE] [--out OUT] [--refactor-budget-minutes MINUTES] [--version VERSION] [--name NAME] [--pace {normal,fast,auto}] [--state STATE] [--prod PROD] [--level {patch,minor,major}] [--record-pr RECORD_PR] [--carry CARRY [CARRY ...]] [--milestone MILESTONE] [--loop LOOP] [--lap LAP]", "history import": "usage: supervise.py history import [-h] [--history HISTORY] progress [progress ...]" }, "results": { diff --git a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json index 1f1a1e4ee..0d1271368 100644 --- a/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json +++ b/plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/close/4-close.json @@ -116,7 +116,7 @@ "type": "run", "cwd": "/repo", "timeout": 120, - "cmd": "python3 /skills/backlog-refinement/scripts/upkeep.py depth record --depth diff --sprint m9 --root /repo", + "cmd": "python3 /skills/backlog-refinement/scripts/upkeep.py depth record --depth diff --sprint m9 --progress '{state_dir}/progress.jsonl' --root /repo", "next": "overhaul-due" }, { From c3786e43356fa1379b4e167eeef7ba587da02f3b Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 09:55:13 +0000 Subject: [PATCH 052/127] =?UTF-8?q?fix:=20=E6=A7=8B=E9=80=A0=E3=81=AE?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B=E3=80=81?= =?UTF-8?q?upkeep=5Fdepth=20=E3=81=AE=E6=95=B0=E3=81=AE=E5=88=A4=E5=AE=9A?= =?UTF-8?q?=E3=82=92=20ask=5Fpolicy=20=E3=81=A8=E5=90=8C=E3=81=98=E6=9C=AC?= =?UTF-8?q?=E4=BD=93=E3=81=AE=E9=96=A2=E6=95=B0=E3=81=AB=E3=81=9B=E3=81=9A?= =?UTF-8?q?=E3=80=81sprint=5Floop=5Fgate=20=E3=82=92=E4=BE=8B=E5=A4=96?= =?UTF-8?q?=E3=83=AA=E3=82=B9=E3=83=88=E3=81=AE=E8=A1=8C=E6=95=B0=E3=81=AB?= =?UTF-8?q?=E5=8F=8E=E3=82=81=E3=82=8B=EF=BC=88#2315=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- plugins/ndf/scripts/lib/sprint_loop_gate.py | 4 +--- .../ndf/skills/backlog-refinement/scripts/upkeep_depth.py | 6 +----- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 698041cb3..3541e630d 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -112,9 +112,7 @@ def build_close(env: Env, rec: dict, lap: dict, issues: list[int], tag: str = "c out = Path(lap["dir"]) / tag cmd = [sys.executable, str(SUPERVISE), "new", "close", "--pace", "auto", "--name", lap["sprint"], "--worktree", str(env.main_dir())] cmd += ["--issue", *map(str, issues), "--state", lap["state_path"], "--out", str(out), "--mode", lap.get("mode") or "standard"] - cmd += ["--loop", rec["name"], "--lap", str(lap["n"])] # 差分の棚卸しの深さの記録の行に残す(#2315 の決定 3) - if carry: - cmd += ["--carry", *map(str, carry)] + cmd += ["--loop", rec["name"], "--lap", str(lap["n"]), *(["--carry", *map(str, carry)] if carry else [])] # loop・lap は #2315 の決定 3 if lap.get("milestone"): cmd += ["--milestone", lap["milestone"]] code, so, se = env.run(cmd, cwd=str(env.main_dir())) diff --git a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py index 4a61819dd..387baea30 100644 --- a/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py +++ b/plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py @@ -342,10 +342,6 @@ def cmd_record(a): emit(result(TOOL, "ok", f"深さの記録へ {a.depth} の {a.event} を足した", [row], {"path": str(d / "depths.jsonl")})) -def _is_num(v) -> bool: - return isinstance(v, (int, float)) and not isinstance(v, bool) - - def _period_key(r: dict, k: int): """同じ周回を 1 回と数える鍵。開発ループの外ではスプリント、どちらも無ければ行ごとに別。""" if r.get("loop") is not None: @@ -362,7 +358,7 @@ def compare_rows(rows: list[dict], since, *, depth: str, model: str | None) -> t at = clock.parse(r.get("at"), naive="utc") if not _is_ran(r, depth) or at is None or at <= since: continue - if not (_is_num(r.get("seconds")) and _is_num(r.get("cost"))): + if not all(isinstance(r.get(x), (int, float)) and not isinstance(r.get(x), bool) for x in ("seconds", "cost")): excluded.append((r, "no_usage")) elif model is not None and ((r.get("values") or {}).get("models")) != [model]: excluded.append((r, "model")) From bae6cd0286cdbc5017614502f17dbb69e0b2938e Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 10:08:04 +0000 Subject: [PATCH 053/127] =?UTF-8?q?docs:=20=E5=B7=AE=E5=88=86=E3=81=AE?= =?UTF-8?q?=E6=A3=9A=E5=8D=B8=E3=81=97=E3=81=AE=E8=A6=8B=E7=A9=8D=E3=81=AE?= =?UTF-8?q?=E7=84=A1=E3=81=84=E7=B5=8C=E8=B7=AF=E3=81=AE=E7=B5=9E=E3=82=8A?= =?UTF-8?q?=E8=BE=BC=E3=81=BF=E3=83=BBdepth=20record=20=E3=81=AE=20--progr?= =?UTF-8?q?ess=E3=83=BBdepth=20compare=E3=83=BBnew=20close=20=E3=81=AE=20-?= =?UTF-8?q?-loop=20=E3=81=A8=20--lap=20=E3=82=92=E7=A2=BA=E5=AE=9A?= =?UTF-8?q?=E4=BB=95=E6=A7=98=E3=81=B8=E5=8F=8D=E6=98=A0=E3=81=99=E3=82=8B?= =?UTF-8?q?=EF=BC=88#2315=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- .../specifications/ndf-backlog-refinement-depths.md | 13 +++++++------ docs/specifications/ndf-script-shared-helpers.md | 2 +- .../specifications/ndf-workflow-development-loop.md | 5 +++-- 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/docs/specifications/ndf-backlog-refinement-depths.md b/docs/specifications/ndf-backlog-refinement-depths.md index 7d785593e..7f90a4bf4 100644 --- a/docs/specifications/ndf-backlog-refinement-depths.md +++ b/docs/specifications/ndf-backlog-refinement-depths.md @@ -125,9 +125,10 @@ | 副命令 | 引数 | 成功 | 失敗・否定 | | --- | --- | --- | --- | -| `candidates` | 既存の引数に `--depth diff\|review`(既定 `diff`) | `diff`: 経路 `diff-path`・`diff-identifier`・`sub-issue`・`commit-subject`・`manual`・`unscored`(と `--all`)。`review`: 上の経路に `no-milestone`・`closed-milestone` と、後回しを除く open の全件(`all`)を足す。上限で切らず、`updated_at` の古い順に並べる(`metrics.limit` は `null`、`metrics.depth` に深さ) | 既存 | +| `candidates` | 既存の引数に `--depth diff\|review`(既定 `diff`) | `diff`: 経路 `diff-path`・`diff-identifier`・`sub-issue`・`commit-subject`・`manual`・`unscored`(と `--all`。`unscored` は `--since-ref` のコミットの時刻(`metrics.since`)より後に作られた課題だけ)。`review`: 上の経路に `no-milestone`・`closed-milestone` と、後回しを除く open の全件(`all`)を足す。上限で切らず、`updated_at` の古い順に並べる(`metrics.limit` は `null`、`metrics.depth` に深さ) | 既存 | | `depth due review\|overhaul` | `--loop`・`--lap`・`--no-issue`・`--manual`・`--open N`(省くと GitHub から数える) | 0。`metrics.trigger` と `values`。`due` の行を足す | 3。起動しない(`not_due` の行と判定の値)。記録や設定が読めないときも 3 で、`values.reason: unreadable` の行を残す | -| `depth record` | `--depth diff\|review\|overhaul`(必須)・`--event ran\|not_due\|skipped`(既定 `ran`)・`--trigger a,b`・`--run`・`--report <統一 JSON レポート>`・`--loop`・`--lap`・`--sprint`・`--cost`・`--seconds` | 0。1 行を足す | 2 = `--trigger` の値が条件の一覧に無い | +| `depth record` | `--depth diff\|review\|overhaul`(必須)・`--event ran\|not_due\|skipped`(既定 `ran`)・`--trigger a,b`・`--run`・`--report <統一 JSON レポート>`・`--loop`・`--lap`・`--sprint`・`--cost`・`--seconds`・`--progress <プランの進行の記録>`・`--step <ステップの id>`(既定 `refine`) | 0。1 行を足す。`--progress` を渡すと、そのステップの最後の `kind: step` の行から秒・ドル・言語モデルの名前を写す(`--cost`・`--seconds` を渡せばその値を優先)。読めなければ空のまま 0 | 2 = `--trigger` の値が条件の一覧に無い | +| `depth compare` | `--since-ref `・`--seconds-under`・`--cost-under`(必須)・`--count`(既定 3)・`--model`・`--depth`(既定 `diff`) | 0。`` のコミットの時刻より後の `ran` の行のうち、秒とドルがそろい(外す理由 `no_usage`)、`--model` を渡せば `values.models` がその 1 つだけの(`model`)行を、同じ開発ループ・周回(ループの外では同じスプリント)では最後の 1 行にして(`duplicate`)、古い順に `--count` 件の秒とドルの中央値が両方とも基準を下回る。`metrics` に `count`・`needed`・`median_seconds`・`median_cost`・`baseline`・`delta`・`judged` | 1 = 中央値のどちらかが基準を下回らない / 3 = 件数に満たない(判定の保留)/ 2 = 記録か ref を読めない | | `causes record` | `--classes <[{number, type, sub}]>`(必須)・`--depth review\|overhaul`(既定 `review`) | 0。行を足す | 2 = 配列でない・型が `cause_types` に無い | | `owner claim` | `--what research\|review\|overhaul`(必須)・`--loop`・`--pid`(省くと呼び出し元)・`--keepalive <秒>` | 0(`run`・`taken_from`・`what`、見張りを起こしたら `keepalive_pid`) | 10 = 他の有効な持ち主がいる(`reason: not_owner`・`owner`)/ 3 = 錠を 5 秒で取れない | | `owner keepalive` | `--run`・`--pid`・`--seconds`(すべて必須) | 0(止まった理由 `released` / `owner_gone` / `expired`) | — | @@ -167,7 +168,7 @@ | `id` | 種類 | 中身 | 遷移 | | --- | --- | --- | --- | -| `refine-record` | run | `depth record --depth diff --sprint <名前>` | → `overhaul-due` | +| `refine-record` | run | `depth record --depth diff --sprint <名前> --progress {state_dir}/progress.jsonl`(`new close` に `--loop`・`--lap` を渡したときは、それも足す。開発ループのまとめの組み立てが渡す) | → `overhaul-due` | | `<深さ>-due` | run | `depth due <深さ>` | 3 なら `skip_to` で後へ。0 なら → `<深さ>-claim` | | `<深さ>-claim` | run | `owner claim --what <深さ> --keepalive 5400 --pid "$PPID"` | 10 なら → `<深さ>-skipped`。0 なら → `<深さ>` | | `<深さ>` | work(`full: true`、`stage` は `棚卸し`、`timeout` 5400) | `/ndf:backlog-refinement --depth <深さ>` を無人で通す。持ち主の実行は `owner status`、起動した条件は `depths.jsonl` から読む。最後に `depth record` と `owner release` を打つ | 成否によらず(`on_fail` も)→ `<深さ>-release` | @@ -199,12 +200,12 @@ | `depth` | `diff` / `review` / `overhaul` | | `event` | `ran`(行った)/ `due`(起動を判定した)/ `not_due`(起動しない)/ `skipped`(持ち主になれず飛ばした) | | `trigger` | 起動した条件の配列(`count`・`open_growth`・`carry`・`no_issue`・`manual`・`created_over_closed`・`open_rising`・`cause_share`)。`not_due` は空 | -| `values` | 判定の行は判定に使った値(`count`・`open`・`open_growth`・`carry`・`cause_share: {type, share}`、読めなければ `reason`・`detail`)。`diff` の `ran` の行は `carry`(`candidates.json` の `deferred` の件数) | +| `values` | 判定の行は判定に使った値(`count`・`open`・`open_growth`・`carry`・`cause_share: {type, share}`、読めなければ `reason`・`detail`)。`diff` の `ran` の行は `carry`(`candidates.json` の `deferred` の件数)と、プランの進行の記録から読めたときの `models`(棚卸しのステップで使った言語モデルの名前を名前の順に並べた配列) | | `loop`・`lap`・`sprint` | 空は `--loop` の外 | | `run` | 持ち主の実行。差分の棚卸しと判定の行は空 | | `open_before`・`open_after`・`closed`・`created` | `--report` の `metrics` から写す。`diff` の `ran` は `candidates.json` の `open` と `apply.json` の `closed` から写す | | `skipped_aspects`・`order`・`research` | `--report` の `metrics` にあれば写す | -| `cost`・`seconds`・`report` | 引数の値 | +| `cost`・`seconds`・`report` | 引数の値。`--cost`・`--seconds` が無く `--progress` を渡したときは、プランの進行の記録の棚卸しのステップの値(読めなければ空) | 回数は「最後のその深さの `ran` の行より後にある、`diff` の `ran` の行の数」で数える。`open_growth` は、前の見直しの `ran` の行の `open_after` と比べる。 `carry` は、最後の `diff` の `ran` の行の `values.carry` と、候補の上限 × `carry_factor` を比べる。`created_over_closed` / `open_rising` は、 @@ -343,7 +344,7 @@ classDiagram | まとめのプランで、`refine` の次が `refine-record`、大整理 → 見直しの判定は 3 で飛び、持ち主になれないと `skipped`、本体の成否によらず `release` を通る | `plugins/ndf/scripts/tests/test_supervise_pace.py`(`test_close_runs_spec_retro_and_close_once_each_in_order`)・`test_supervise_pace_verify.py` | | `vet --kind issue` が課題の本文をプロジェクト MVV と照合する | `plugins/ndf/scripts/tests/test_project_mvv.py`(`test_vet_issue_judges_an_issue_body_against_the_project_mvv`) | | `measure` が直接の依存から材料の案を問いで出す | `plugins/ndf/scripts/tests/test_project_decl.py`(`test_measure_proposes_materials_from_direct_dependencies_as_a_question`) | -| 毎スプリントの棚卸しの所要(秒・ドル)が、206〜224 秒・1.41〜1.61 ドルより短い | 手動確認(リリース後テスト)。変更の後の周回の `depths.jsonl` の `diff` の行の `cost`・`seconds` と比べる | +| 毎スプリントの棚卸しの所要(秒・ドル)が、206〜224 秒・1.41〜1.61 ドルより短い | 手動確認(リリース後テスト)。配布の後、`depth compare --since-ref <配布した版のタグ> --seconds-under 206 --cost-under 1.41 --model <言語モデル>` が 0 を返す | | 大整理の分類・柱・付け直しの一覧が #2335 と同じ形になる | 手動確認。固定の入力で大整理の手順 1 をやり直し、件数の差を報告に書く | ## 運用 diff --git a/docs/specifications/ndf-script-shared-helpers.md b/docs/specifications/ndf-script-shared-helpers.md index 48845b46d..8be3e1962 100644 --- a/docs/specifications/ndf-script-shared-helpers.md +++ b/docs/specifications/ndf-script-shared-helpers.md @@ -49,7 +49,7 @@ NDF のスクリプト(`plugins/ndf/scripts/` と、Skill の `skill-stats`・ | 再起動の行と印の行を組む(セッションの読み取り・境目の行・`cut` と再起動の付与) | `lib/context_decisions.py` の `record_row` | `context-policy.py` の `cmd_record`、同じモジュールの `record_restart`(`sprint-state.py next`) | | 正の整数(`bool` を除く)か判定する | `lib/context_policy.py` の `is_positive_int` | 同じモジュールの方針の解決、`lib/context_decisions.py` の `read_window` | | 試行方式の拒否の句で使う方針と理由の語 | `lib/context_policy.py` の既存の定数 `TRIAL`・`REASON_CAPACITY`・`REASON_COST` | `hook_lib/token_guard.py` の `_trial_cause` | -| 「その深さを実行した」行の判定と、最後に実行した行の位置 | `backlog-refinement/scripts/upkeep_depth.py` の `_is_ran`・`_last_ran` | 同じファイルの `diffs_since`・`_ran`・`_due_overhaul` | +| 「その深さを実行した」行の判定と、最後に実行した行の位置 | `backlog-refinement/scripts/upkeep_depth.py` の `_is_ran`・`_last_ran` | 同じファイルの `diffs_since`・`_ran`・`_due_overhaul`・`compare_rows` | **`clock.parse` は前後の空白と小文字の `z` も読む。** NDF の記録の時刻は `clock.now_iso` が書くため、 この 2 つを含む時刻は記録に現れず、記録を読む結果は変わらない。形の違う時刻の読み取り diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index 6e59b6fe6..e8cbdc142 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -78,7 +78,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | [`lib/sprint_loop_gate.py`](../../plugins/ndf/scripts/lib/sprint_loop_gate.py) | 承認ゲートで止まった done の扱い(`gate`。`lap --done` も同じに扱う)・続きの queue・まとめのステージ(`new close --pace auto`)の組み立て・周回の締め | | [`lib/sprint_loop_record.py`](../../plugins/ndf/scripts/lib/sprint_loop_record.py) | 記録の読み書き(排他・原子的な書き込み・終わった周回の書き換えの拒否)・消費・止まる条件の判定・再開のコマンド・引継ぎ文書・`laps.md` の描画。外の世界(gh・`upkeep.py rank`・`supervise.py`)は `Env` の関数を通して呼び、テストで差し替える | | [`lib/pace.py`](../../plugins/ndf/scripts/lib/pace.py) の `read_loop` | `loop` 節を読み、既定(`LOOP_DEFAULTS`)を埋めて形を確かめる。`read_pace` は `loop` 節を検証しない | -| [`supervise_lib/sprint.py`](../../plugins/ndf/scripts/supervise_lib/sprint.py)・[`new_args.py`](../../plugins/ndf/scripts/supervise_lib/new_args.py) | `new close --pace auto` でまとめのステージだけを組む。`--carry`・`--record-pr`、`new sprint --designed` | +| [`supervise_lib/sprint.py`](../../plugins/ndf/scripts/supervise_lib/sprint.py)・[`new_args.py`](../../plugins/ndf/scripts/supervise_lib/new_args.py) | `new close --pace auto` でまとめのステージだけを組む。`--carry`・`--record-pr`・`--loop`・`--lap`、`new sprint --designed` | | [`handoff.py`](../../plugins/ndf/scripts/handoff.py) | 名の形(`NAME_RE`)に `loop-<名>` を持つ | | `backlog-refinement` の [`upkeep_rank.py`](../../plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank.py) の `quality_slot`・[`upkeep_rank_config.py`](../../plugins/ndf/skills/backlog-refinement/scripts/upkeep_rank_config.py) の `_parse_quality` | 品質の枠を取り、切り出しの境界から枠の分を除く | | 既存のまま使うもの | `relay.py`(セッションの切り替え)・`mvv-gate.py`(判定とレッドライン)・`supervise.py new sprint --pace auto` / `queue` / `wait` / `run` | @@ -199,7 +199,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | --- | --- | | `sprint-state.py init --name <名>- --pace auto --issue ... --dev V --prod V --goal <再開のコマンド> [--mvv mvv.md --milestone M]` | `--prod` を省けば開発版の接尾辞を外した版 | | `supervise.py new sprint --pace auto --issue <選んだ課題> --design <承認済みでない課題> [--designed ] ... [<-- の後の引数>]` | `loop answer --approved` で答えた課題は設計のプランを作らず、設計の結果のステージが設計 PR の設計の結果を読む | -| `supervise.py new close --pace auto --issue <選んだ課題> --state [--carry <前の周回の refine.deferred>] [--milestone M]` | まとめのステージ(確定仕様化 → Pull Request → 振り返り → 課題を閉じる → 棚卸し。#2228)だけを組む。最終の検査・開発版・本番は置かず、`fast` 節を読まない | +| `supervise.py new close --pace auto --issue <選んだ課題> --state --loop <名> --lap [--carry <前の周回の refine.deferred>] [--milestone M]` | まとめのステージ(確定仕様化 → Pull Request → 振り返り → 課題を閉じる → 棚卸し。#2228)だけを組む。最終の検査・開発版・本番は置かず、`fast` 節を読まない | `pipeline` は、最初のステージの queue の最後に `--then <まとめのプラン>` をつなぎ、`supervise.py wait ` → `loop lap <名> --done ` を続けた 1 行である。手で行うステージか別の queue のステージが残るときは、それを終えてから @@ -211,6 +211,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | --- | --- | --- | | `--pace auto` | まとめのステージだけを組む(上の表) | 今の形(`fast` のスプリントの終わり)。出力は変わらない | | `--carry N...` | 棚卸しのステップの指示へ前の周回の持ち越しを `--add` で渡す | 指示は今のまま | +| `--loop <名>`・`--lap ` | 棚卸しの深さの記録のステップ(`depth record --depth diff`)へ開発ループの名前と周回の番号を渡す。開発ループのまとめの組み立て(`build_close`)は常に渡す(#2315) | 深さの記録の行の `loop`・`lap` は空 | | `--record-pr N` | 課題を閉じるステップ(`sprint-close.py --record-pr`)へ本番の記録の Pull Request を渡す | 経路がすべて届けない経路なら `0`、それ以外は経路にかかわらず `{queue_record}` を残し、queue がまとめを流し始める時点に GitHub の参照からリリース記録の PR を探して埋める(#2230) | ループは `--record-pr` を渡さない。承認ゲート 2 の承認の後にまとめを組み直すときも渡さない(`stop.values.pr` は検査のスプリント PR で、記録の PR ではない。#2230)。 From c17683b985f64aa0c7746d9df08386f992691d37 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 10:22:31 +0000 Subject: [PATCH 054/127] =?UTF-8?q?docs:=20=E9=96=8B=E7=99=BA=E3=83=AB?= =?UTF-8?q?=E3=83=BC=E3=83=97=E3=81=AE=E4=BB=95=E6=A7=98=E3=81=AE=E5=BC=95?= =?UTF-8?q?=E6=95=B0=E3=81=AE=E8=A1=A8=E3=81=8B=E3=82=89=E8=AA=B2=E9=A1=8C?= =?UTF-8?q?=E7=95=AA=E5=8F=B7=E3=82=92=E5=A4=96=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- docs/specifications/ndf-workflow-development-loop.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/specifications/ndf-workflow-development-loop.md b/docs/specifications/ndf-workflow-development-loop.md index e8cbdc142..41809ba10 100644 --- a/docs/specifications/ndf-workflow-development-loop.md +++ b/docs/specifications/ndf-workflow-development-loop.md @@ -211,7 +211,7 @@ MVV のレッドラインで人へ戻すという要求がそれを決める。 | --- | --- | --- | | `--pace auto` | まとめのステージだけを組む(上の表) | 今の形(`fast` のスプリントの終わり)。出力は変わらない | | `--carry N...` | 棚卸しのステップの指示へ前の周回の持ち越しを `--add` で渡す | 指示は今のまま | -| `--loop <名>`・`--lap ` | 棚卸しの深さの記録のステップ(`depth record --depth diff`)へ開発ループの名前と周回の番号を渡す。開発ループのまとめの組み立て(`build_close`)は常に渡す(#2315) | 深さの記録の行の `loop`・`lap` は空 | +| `--loop <名>`・`--lap ` | 棚卸しの深さの記録のステップ(`depth record --depth diff`)へ開発ループの名前と周回の番号を渡す。開発ループのまとめの組み立て(`build_close`)は常に渡す | 深さの記録の行の `loop`・`lap` は空 | | `--record-pr N` | 課題を閉じるステップ(`sprint-close.py --record-pr`)へ本番の記録の Pull Request を渡す | 経路がすべて届けない経路なら `0`、それ以外は経路にかかわらず `{queue_record}` を残し、queue がまとめを流し始める時点に GitHub の参照からリリース記録の PR を探して埋める(#2230) | ループは `--record-pr` を渡さない。承認ゲート 2 の承認の後にまとめを組み直すときも渡さない(`stop.values.pr` は検査のスプリント PR で、記録の PR ではない。#2230)。 From f22ed3b36f83414c7dcf11c4167fe39caeac3551 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 10:48:18 +0000 Subject: [PATCH 055/127] =?UTF-8?q?Docs:=20#2510=20=E3=81=AE=E6=B1=BA?= =?UTF-8?q?=E5=AE=9A=E3=81=AE=E8=A8=98=E9=8C=B2=E3=81=AB=E3=80=81=E8=87=AA?= =?UTF-8?q?=E5=8B=95=E3=81=A7=E5=87=BA=E3=81=97=E7=9B=B4=E3=81=99=E4=B8=8A?= =?UTF-8?q?=E9=99=90=E3=82=92=E4=BA=BA=E3=81=AE=E5=87=BA=E3=81=97=E7=9B=B4?= =?UTF-8?q?=E3=81=97=201=20=E5=9B=9E=E3=81=AB=E3=81=A4=E3=81=8D=201=20?= =?UTF-8?q?=E5=9B=9E=E3=81=A8=E3=81=99=E3=82=8B=E5=88=A9=E7=94=A8=E8=80=85?= =?UTF-8?q?=E3=81=AE=E6=B1=BA=E5=AE=9A=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01KYHP82YBP3K8vVBou7sEBN --- issues/issue-2510-design-decisions.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/issues/issue-2510-design-decisions.md b/issues/issue-2510-design-decisions.md index e8e4d859a..6ad65bf00 100644 --- a/issues/issue-2510-design-decisions.md +++ b/issues/issue-2510-design-decisions.md @@ -93,3 +93,9 @@ MVV 判定が「従う」を返すと設計 PR はマージされ、人の判断 レーンが前のループの行を自分の記録へ写す形は採らない。要求の対象範囲が「前のループの記録を計画のレーンへ移す・合わせること」を含まないとしている。 根拠: Value 2 / Value 7(MVV 版 5) + +## 決定 12: 自動で出し直す上限は、人の出し直し 1 回につき 1 回とする + +利用者の決定である(2026-11-05 の承認ゲート 1)。設計の不変条件 I7 と受け入れ条件 5 の上限(1 回)はこの決定による。 + +根拠: 利用者の決定(2026-11-05 の承認ゲート 1) From e760a8dce868c719614951399c6307d87e734326 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 11:04:27 +0000 Subject: [PATCH 056/127] =?UTF-8?q?Refactor:=20extract=5Fmethod=20?= =?UTF-8?q?=E2=80=94=20plugins/ndf/scripts/lib/sprint=5Floop=5Fgate.py#bac?= =?UTF-8?q?klog=5Fsummary?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 棚卸しの深さの行の絞り込み(lap_depths)と、ループ・周回ごとの調査の項目・費用・秒の集計 (lap_research)を backlog_summary から抽出する。sprint_loop_gate.py は行数の例外リストの 上限(658 行)にあるため、抽出した 2 関数は新しい sprint_loop_backlog.py に置く。 backlog_summary には読み込みと各周回への反映を残し、id による重複除去・該当が無い周回の 非更新・ok が偽の費用も数える規則・丸め桁は変えない。締めた後に届く費用の再精算は test_lap_records_depths_and_research_and_counts_research_in_the_budget が既に確かめている。 Item-Id: I-001 Impl-Runtime: claude Impl-Model: claude-opus-5-5 Co-Authored-By: Claude Opus 5.5 --- .../ndf/scripts/lib/sprint_loop_backlog.py | 24 +++++++++++++++++++ plugins/ndf/scripts/lib/sprint_loop_gate.py | 14 ++++------- 2 files changed, 29 insertions(+), 9 deletions(-) create mode 100644 plugins/ndf/scripts/lib/sprint_loop_backlog.py diff --git a/plugins/ndf/scripts/lib/sprint_loop_backlog.py b/plugins/ndf/scripts/lib/sprint_loop_backlog.py new file mode 100644 index 000000000..dd2b09c05 --- /dev/null +++ b/plugins/ndf/scripts/lib/sprint_loop_backlog.py @@ -0,0 +1,24 @@ +"""sprint_loop_backlog.py: 周回の記録へ写す棚卸しの深さの行と調査の費用・秒を組む(#2315)。LLM を呼ばない。 + +読み込みと周回への反映は `sprint_loop_gate.backlog_summary` が持ち、ここは読んだ行から値を組むだけである。""" + +from __future__ import annotations + + +def lap_depths(rows: list, since: str) -> list: + """棚卸しの深さの行のうち、周回の始まり以後のものを周回の記録に残す項目だけへ絞る。""" + return [{k: r.get(k) for k in ("depth", "event", "trigger", "cost", "seconds")} for r in rows if (r.get("at") or "") >= since] + + +def lap_research(calls: list, loop: str | None, n) -> dict | None: + """このループのこの周回の調査の呼び出しから、項目と費用と秒を組む。該当が無ければ None。 + + 結果を捨てた呼び出し(ok が偽)も支払った費用として数え、項目には ok の呼び出しだけを残す。""" + mine = [r for r in calls if r.get("lap") == n and (r.get("by") or {}).get("loop") == loop] + if not mine: + return None + return { + "items": [r.get("id") for r in mine if r.get("ok")], + "cost": round(sum(float(r.get("cost") or 0) for r in mine), 6), + "seconds": round(sum(float(r.get("seconds") or 0) for r in mine), 3), + } diff --git a/plugins/ndf/scripts/lib/sprint_loop_gate.py b/plugins/ndf/scripts/lib/sprint_loop_gate.py index 3541e630d..15d22be69 100644 --- a/plugins/ndf/scripts/lib/sprint_loop_gate.py +++ b/plugins/ndf/scripts/lib/sprint_loop_gate.py @@ -16,6 +16,7 @@ import project_mvv as pm import step_result from design_pr_merge import MERGED_RESULTS # 正本は design_pr_merge。sprint_loop・sprint_loop_select はここから読む +from sprint_loop_backlog import lap_depths, lap_research from sprint_loop_record import ( TOOL, @@ -607,18 +608,13 @@ def backlog_summary(env: Env, rec: dict, lap: dict) -> None: 調査の費用は呼び出しごとの記録(`research/calls.jsonl`。結果を捨てた呼び出しも含む)から集める。背景の調査は周回を 締めた後にも終わるため、このループのすべての周回を記録から数え直し、締め済みの周回へ後から終わった費用を精算する。""" d = env.main_dir() / ".ndf" / "backlog" - since = lap.get("started_at") or "" rows = jsonio.read_jsonl(d / "depths.jsonl", missing=[], skip_broken=True) - lap["depths"] = [{k: r.get(k) for k in ("depth", "event", "trigger", "cost", "seconds")} for r in rows if (r.get("at") or "") >= since] + lap["depths"] = lap_depths(rows, lap.get("started_at") or "") calls = jsonio.read_jsonl(d / "research" / "calls.jsonl", missing=[], skip_broken=True) for each in {id(x): x for x in [*rec.get("laps", []), lap]}.values(): - mine = [r for r in calls if r.get("lap") == each.get("n") and (r.get("by") or {}).get("loop") == rec.get("name")] - if mine: - each["research"] = { - "items": [r.get("id") for r in mine if r.get("ok")], - "cost": round(sum(float(r.get("cost") or 0) for r in mine), 6), - "seconds": round(sum(float(r.get("seconds") or 0) for r in mine), 3), - } + research = lap_research(calls, rec.get("name"), each.get("n")) + if research is not None: + each["research"] = research def close_lap(env: Env, rec: dict, lap: dict, outcome: str, state: dict | None = None, deferred_issues=(), done_rows=()) -> None: From 40ef3b3c6e9d3689de10e3cb8709b40ff49fd596 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 11:06:55 +0000 Subject: [PATCH 057/127] =?UTF-8?q?Docs:=20ndf-factory-shared-code-placeme?= =?UTF-8?q?nt.md=20=E3=82=92=E7=A2=BA=E5=AE=9A=E4=BB=95=E6=A7=98=E3=81=AB?= =?UTF-8?q?=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 (1M context) --- docs/specifications/README.md | 1 + .../ndf-factory-shared-code-placement.md | 192 ++++++++ issues/issue-2199-design.md | 466 ------------------ issues/issue-2199-requirements.md | 190 ------- 4 files changed, 193 insertions(+), 656 deletions(-) create mode 100644 docs/specifications/ndf-factory-shared-code-placement.md delete mode 100644 issues/issue-2199-design.md delete mode 100644 issues/issue-2199-requirements.md diff --git a/docs/specifications/README.md b/docs/specifications/README.md index d2a2f855e..53965f2a7 100644 --- a/docs/specifications/README.md +++ b/docs/specifications/README.md @@ -132,5 +132,6 @@ | [ndf-factory-sprint-views.md](ndf-factory-sprint-views.md) | Neural Development Factory: スプリントの一覧と詳細・工程の中身・バックログの読み出し(事象から導く派生・フェーズの名前の対応づけ・ランナーが送る工程の中身と伏せ字・経路の形・読み出しの口・画面と読み直し・常に成り立つ条件)。#2172 | | [ndf-script-shared-helpers.md](ndf-script-shared-helpers.md) | NDF のスクリプトの共通処理の置き場(使用量の行・時刻の読み取り・git の根・再起動の行・正の整数の判定)と、契約の抽出・パッケージ内の揃い・呼び出し元の数を理由に寄せない重複、長い関数の分け方。手順は `refactoring` の SKILL.md が正 | | [ndf-factory-agent-access.md](ndf-factory-agent-access.md) | Neural Development Factory: エージェントからの操作(委任の資格情報の発行・照合・取り消しと表・人だけが確定できる 10 の操作・要求の検査の順・MCP の受け口とツールの定義・呼び出し元の種類・追記だけの操作の記録と許可した鍵・作成の冪等の鍵の行・環境変数)。#2384 | +| [ndf-factory-shared-code-placement.md](ndf-factory-shared-code-placement.md) | Neural Development Factory: API と画面で同じ役割の処理を 1 か所に置く規則と置き場の一覧(handlers/ を handler の配線だけにする・保存の契約の port.ts・版の競合の再試行・ログインの確認と失敗の表示)。#2199 | Skill の挙動仕様はここに置かない。Skill に関する詳細は対象 Skill の `SKILL.md` を参照する。 diff --git a/docs/specifications/ndf-factory-shared-code-placement.md b/docs/specifications/ndf-factory-shared-code-placement.md new file mode 100644 index 000000000..62396a043 --- /dev/null +++ b/docs/specifications/ndf-factory-shared-code-placement.md @@ -0,0 +1,192 @@ +# Neural Development Factory: 同じ役割の処理の置き場 + +Neural Development Factory の API(`factory/api/src`)と画面(`factory/web/src`)で、同じ役割の処理を 1 か所に置く規則と、その置き場を定める。HTTP の応答・OpenAPI の契約・DynamoDB の行の形・画面の表示は、この規則で変わらない。 + +## 概要 + +- **Lambda の handler のファイル(`factory/api/src/handlers/*.ts`)は、handler の配線だけを持つ。** Hono のアプリ・環境変数からの依存の組み立てはほかのファイルに置き、`handlers/` の外から `handlers/` を読まない +- **アカウントの保存の契約は、DynamoDB の実装と別のファイルに置く。** 契約は AWS SDK を読まない +- **同じ役割の関数・定数は `factory/` の中で 1 つだけ定義する。** 下の「置き場の一覧」に載る処理を足すときは、その置き場の関数を使い、写しを作らない +- **画面のログインの確認と失敗の表示は `context.tsx` の 2 つに寄せる。** 画面ごとに token の有無を確かめない + +## 用語 + +定義は [用語集](../glossary.md) が正である(文脈 `ndf-factory`)。この文書で使う語は「同じ役割の写し」「保存の契約」である。 + +## コンテキスト + +変更は Neural Development Factory(`ndf-factory`)の中だけに属する。集約(利用者・アカウント)と持ち主は [アカウントと料金プラン](ndf-factory-accounts-and-plans.md) の「集約」のとおりで、この文書は持ち主の内部の関数の置き場だけを定める。 + +## 背景 + +`factory/` の子課題は同じ `factory/api/`・`factory/web/` に足していく。そのため、環境変数の読み取り・再試行・ログインの確認などの処理が、子課題ごとに写されて広がっていた。cross-refactoring は同じ提案を毎回出し、時間の上限で見送っていた。置き場を 1 つに決め、足す側がその関数を使う形にした。 + +## 仕様 + +### 依存の向き + +```mermaid +graph LR + IDX[index.ts] --> APP[app.ts] + IDX --> AD[auth/deps.ts] + IDX --> PD[progress/deps.ts] + RD[runs/deps.ts] --> PD + HA[handlers/api.ts] --> APP + HA --> AD + HI[handlers/ingest.ts] --> PD + HQ[handlers/issue-requests.ts] --> PD +``` + +図の横幅のため、`env.ts` を読む向きは図から省いた。`env.ts` を読むのは `auth/deps.ts`・`progress/deps.ts`・`runs/deps.ts` と、`handlers/` の `ingest.ts`・`github-oidc.ts`・`issue-requests.ts` である。 + +handler の名前と置き場(`handlers/*.ts` の `handler`)は変えない。private リポジトリのインフラがこのファイルをエントリポイントとしてバンドルするためである。 + +### 置き場の一覧 + +| 処理 | 置き場 | 使う側 | +| --- | --- | --- | +| 必須の環境変数の読み取り(`requireEnv`)。無ければ `環境変数 <名前> がありません` を投げる | `factory/api/src/env.ts` | `auth/deps.ts`・`progress/deps.ts`・`runs/deps.ts`・`handlers/` の `ingest.ts`・`github-oidc.ts`・`issue-requests.ts` | +| API の Hono のアプリ(`createApiApp`)と、ログ(`jsonLogger`・`LogEntry`・`Logger`)・画面のオリジン(`webOriginFromEnv`) | `factory/api/src/app.ts` | `handlers/api.ts`・`index.ts`・テストの補助(`testing/app.ts`) | +| 認証の依存の組み立て(`depsFromEnv`。`AuthDeps` を 1 回だけ作る) | `factory/api/src/auth/deps.ts` | `handlers/api.ts`・`index.ts` | +| 進捗の記録の保存の組み立て(`progressStoreFromEnv`) | `factory/api/src/progress/deps.ts` | `handlers/ingest.ts`・`handlers/issue-requests.ts`・`runs/deps.ts`・`index.ts` | +| 保存の契約(`AccountStore`・`AccountPatch`・`AccountUpdate`・`ConditionFailedError`) | `factory/api/src/accounts/port.ts` | `accounts/store.ts`(DynamoDB の実装)・`accounts/memory-store.ts`(メモリの実装)・`accounts/service.ts`・`accounts/seat-service.ts`・`accounts/conflict.ts` | +| 版の競合の再試行(`retryOnConflict`)と、再試行しない書き込みの失敗の読み替え(`writeOr`) | `factory/api/src/accounts/conflict.ts` | `accounts/service.ts`(チームの作成・利用者の作成・料金プランと実行の形態の変更)・`accounts/seat-service.ts`(席の追加・種類の変更・削除) | +| 履歴の行を同じトランザクションで足す書き込み(`transact`)と、アカウントの表の鍵の頭(`USER#`・`ACCOUNT#`)・更新の列の表(`PATCH_COLUMNS`) | `factory/api/src/accounts/store.ts` | 同じファイルの書き込み | +| 席の種類の検証と文言(`validateSeatType`。`席の種類は standard か premium です`) | `factory/api/src/accounts/service.ts` | `routes/accounts.ts`・`routes/teams.ts` | +| アカウントの詳細の本文(`accountDetailBody`。チームなら席を読んで足す) | `factory/api/src/routes/views.ts` | `routes/accounts.ts`(詳細の取得・実行の形態の変更)・`routes/teams.ts`(チームの作成) | +| GitHub の利用者の取得と ID の検証(`githubUser`。取れなければ `null`) | `factory/api/src/github-oidc/app.ts` の `createGithubOidcApp` の中 | `/token`・`/userinfo` | +| IdP の判定(`idpFromUsername`) | `factory/api/src/auth/verify.ts` のモジュールの中 | `verifyAuthorization` | +| ログインの確認(`RequireLogin`) | `factory/web/src/context.tsx` | `App.tsx` のログインの後の画面の枠(`AppShell` の layout route)の `element` | +| 失敗を画面の文へ変える呼び出し(`useAttempt`) | `factory/web/src/context.tsx` | 画面(`pages/*.tsx`)と `pages/parts.tsx` | +| 席の種類の選択肢(`SeatTypeOptions`) | `factory/web/src/pages/parts.tsx`(`SEAT_TYPES` の隣) | `TeamPage.tsx`・`NewTeamPage.tsx` | + +### 同じ役割として寄せない処理 + +次の処理は、名前や形が似ていても判断が違うため、上の置き場へ寄せない。 + +| 処理 | 寄せない理由 | +| --- | --- | +| 実行の表の鍵(`runs/dynamo-table.ts` の `accountPk`) | アカウントの表とは別の表の設計である | +| 実行の枠・ループ・整理役・埋め戻しの再試行(`runs/`・`organizer/` の各定数) | アカウントの版の競合とは別の集約で、読み直しと判定の中身も違う | +| スプリントとループの開始の失敗(`NewSprintPage.tsx`・`LoopsPage.tsx`) | 失敗の種類ごとに文を変える | +| スプリントの手順の詳細の読み込み(`SprintDetailPage.tsx`) | 画面を離れた後の結果を捨てる判断を含む | +| 定期の読み直し(`poll.ts` の `usePolled`)と枠の利用者の読み込み(`ui/AppShell.tsx`) | 読み直しの失敗で前の表示を残し、誤りの文を出さない | +| 画面ごとの「読み込み中」と `role="alert"` の出し分け | 画面ごとに準備の条件が違う | + +### 常に成り立つ条件 + +| # | 条件 | 破れたときの扱い | +| --- | --- | --- | +| S1 | `factory/api/src` の `handlers/` の外のコード(`*.test.ts` を除く。`testing/app.ts` は含む)が `handlers/` を import しない。`handlers/` の中のファイルどうしも互いを import しない | 置き場の一覧の場所へ移してから足す | +| S2 | 置き場の一覧の処理の定義は `factory/` の中に 1 つだけある | 写しを消し、置き場の関数を使う | +| S3 | アカウントの書き込みは `transact` を通り、`TransactItems` を業務の項目 → 履歴の行 → 作成の冪等の鍵の行の順に並べる。鍵・属性・条件式・プレースホルダ(`:plan`・`:mode` など)の名前は [アカウントと料金プラン](ndf-factory-accounts-and-plans.md) の「アカウントの表」のとおりである | 並びか名前が違えば、DynamoDB の行の形が変わる。`store.test.ts` が落とす | +| S4 | `ConditionFailedError` は `accounts/port.ts` にだけ定義し、`store.ts` は再公開しない。2 つの実装・サービス・`conflict.ts` の `instanceof` が同じクラスを見る | 別のクラスになると条件の失敗を読み替えられない | + +### 版の競合の再試行と書き込みの失敗 + +`retryOnConflict(step, onOther)` の `step` は、読み直しと判定をして `{ done: T }`(書かずに終わる)か `{ write, result }`(書いて終わる)を返す。 + +- `step` の中で投げた誤り(権限・競合・料金プラン・席が無い)は、書き込みの前の判定なので、そのまま呼ぶ側へ届く +- 読み替えるのは `write` の失敗だけである +- `onOther` は条件以外の失敗を何へ変えるかを決める。既定は `AccountUnavailableError` で、料金プランの変更は受けた誤りをそのまま返す + +```mermaid +sequenceDiagram + participant S as 呼ぶメソッド + participant R as retryOnConflict + participant St as AccountStore + loop VERSION_RETRIES 回まで + R->>S: step()(読み直しと判定) + alt done + R-->>S: done の値を返す + else write + R->>St: write() + alt 成功 + R-->>S: result を返す + else ConditionFailedError + Note over R: 次の回へ + else ほかの失敗 + R-->>S: onOther(err) を投げる + end + end + end + R-->>S: AccountUnavailableError を投げる +``` + +`writeOr(write, onCondition)` は `write` を呼ぶ。結果は次の 3 つのどれかになる。 + +- 成功なら `undefined` を返す +- 条件の失敗なら `onCondition()` の結果を返す(`onCondition` は投げてもよい) +- それ以外の失敗は `AccountUnavailableError` を投げる + +| 呼ぶメソッド | `onCondition` | +| --- | --- | +| 席の追加 | `ConflictError("seat_exists", …)` を投げる | +| チームの作成 | 冪等の鍵があれば鍵の行を読み直し、先に書いた作成の文脈を返す。無ければ `AccountUnavailableError` を投げる | +| 利用者の作成 | 利用者を読み直し、あれば返す。無ければ `AccountUnavailableError` を投げる | + +### 履歴の行と更新の引数 + +- アカウントの履歴の行は、`accounts/service.ts` の呼ぶ側が `HistoryRecord` の名前つきのオブジェクトで書く。`before` と `after` の取り違えをフィールドの名前で防ぐためである。席の履歴の行は `accounts/seat-service.ts` の `seatHistory` が組み立てる +- `updateAccount` は `AccountUpdate`(`accountId`・`expectedVersion`・`patch`・`updatedAt`・`history`)を 1 つ受ける。`version` が `expectedVersion` と等しいときだけ `patch` を当て、`version` を 1 増やす + +```mermaid +classDiagram + class AccountStore { + <> + +updateAccount(update: AccountUpdate) + +addSeat(seat, history) + } + class AccountUpdate { + +accountId + +expectedVersion + +patch: AccountPatch + +updatedAt + +history: HistoryRecord[] + } + class AccountService + class SeatService + AccountStore ..> AccountUpdate : 受け取る + AccountService --> AccountStore : 使う + SeatService --> AccountStore : 使う + AccountService --> SeatService : 席の操作を任せる + DynamoAccountStore ..|> AccountStore + MemoryAccountStore ..|> AccountStore +``` + +### 画面のログインの確認と失敗の表示 + +- `RequireLogin` は token が無ければ、枠も画面も描かずに `/login` へ移る。ログインの後の画面はすべてこの枠の下にあり、後から足す画面にも確認が掛かる。そのため画面は token の有無を確かめず、`usePolled` は token の有無を引数に取らない +- `useAttempt()` は `attempt(op, show)` を返す。`attempt` は `op` を呼び、失敗したら画面の文を `show` へ渡す(401 ならログインの画面へ移って `show(null)`)。`attempt` は投げない +- `attempt` は描くたびに同じ関数を返す(`useCallback`)。`useEffect` の依存に入れても読み込みを繰り返さない + +## 外部連携 + +インフラ(private リポジトリ)がバンドルする handler は `handlers/*.ts` の `handler` のままである。一覧は [アカウントと料金プラン](ndf-factory-accounts-and-plans.md) の「外部連携」にある。 + +## テスト観点 + +- 次の検索で、置き場の一覧の処理が 1 か所にあることを満たすこと + - `grep -rn "function requireEnv" factory/api/src` が 1 行 + - `factory/web/src/pages` で `Navigate to="/login"` と `hasToken` が 0 行 + - `SEAT_TYPES.map` が `pages/parts.tsx` の 1 行 + - 失敗を画面の文へ変える `onFailure(err)` が `context.tsx`・`poll.ts`・`ui/AppShell.tsx` と、寄せない 3 画面(`NewSprintPage.tsx`・`LoopsPage.tsx`・`SprintDetailPage.tsx`)だけにある +- S1 の 2 つの検索がどちらも 0 行を返すこと + - `grep -rn 'from "[./]*/handlers/' factory/api/src --include=*.ts | grep -v '\.test\.ts:'` + - `grep -n 'from "\./' factory/api/src/handlers/*.ts | grep -v '\.test\.ts:'` +- `npm --prefix factory run check`(vitest・`tsc --noEmit`・spectral)が 0 で終わること。応答・表示・エラーの文言は既存の vitest が縛る +- `store.test.ts` の期待値(鍵・GSI の鍵・条件式・プレースホルダ・履歴の行の位置)が S3 を満たすこと +- `service.test.ts` と `handlers/api.test.ts` で、次の 4 つを満たすこと + - 版の競合が 3 回続けば保存の失敗を返す + - 書き込みの失敗で席も履歴も増えない + - 既に席があれば 409 を返す + - 冪等の鍵の 2 回目が 1 回目のチームを返す +- `verify.test.ts`・`github-oidc/app.test.ts`・`App.test.tsx` で、認証の判定と、token が無いとき・401 のときに `/login` へ移ることが変わらないこと +- `uv run --frozen --project . --all-extras pytest scripts/tests/test_check_markdown_links.py -q` が通ること。スクリプトの読み込みは関数のスコープの fixture で、テストごとに新しいモジュールを作る + +## 関連リンク + +- [アカウントと料金プラン](ndf-factory-accounts-and-plans.md)(集約・構成要素・アカウントの表) +- [画面の部品](ndf-factory-ui-components.md) +- [サービスのディレクトリと境界](ndf-factory-service-boundary.md) +- 課題 #2199( https://github.com/devbasex/ai-plugins/issues/2199 )、実装の Pull Request #2533( https://github.com/devbasex/ai-plugins/pull/2533 ) diff --git a/issues/issue-2199-design.md b/issues/issue-2199-design.md deleted file mode 100644 index b707e342d..000000000 --- a/issues/issue-2199-design.md +++ /dev/null @@ -1,466 +0,0 @@ -# factory/: 見送ったリファクタリングの提案 20 件を片付け、同じ役割の関数を 1 か所へ寄せる(#2199) - -## 目的 - -- **今どうなっているか**: #2170 の cross-refactoring(PR #2192)は、`factory/` のリファクタリングの提案 20 件(R01〜R20)を時間(`budget`)で見送った。20 件はどの課題にも残っていない。その後に重複が広がった。2026-11-05 の `develop`(86436dace)では、`requireEnv` が 5 か所、ログインの確認が 12 画面、失敗を画面の文へ変える `onFailure(err)` が 23 行にある -- **誰に効くか**: 子課題(#2171・#2172・#2173・#2174・#2189)の実装者と、その cross-refactoring。子課題は同じ `factory/api/`・`factory/web/` に足していく。重複を写して広げ、同じ提案を毎回出し直しては時間で見送る -- **変えると何が成り立つか**: 20 件のそれぞれが「適用した」か「採らない(理由つき)」に決まり、同じ役割の関数の定義が `factory/` の中で 1 か所になる。HTTP の応答・OpenAPI の契約・DynamoDB の行の形・画面の表示は変わらない - -## 適用範囲 - -- **働く範囲**: このリポジトリの `factory/api/src`・`factory/web/src` と `scripts/tests/test_check_markdown_links.py` だけ。配布物(`plugins/`)には届かない -- **プロジェクトごとに違うもの**: 無し(`factory/` の内部の関数の置き場と引数の形だけを変える) -- **当たるモード**: `standard`。要求と受け入れ条件があり、対象には既存のテストがある。vitest は `factory/api/src`・`factory/web/src` の 37 ファイル、pytest は `scripts/tests/test_check_markdown_links.py` の 46 件である - -## あるべき姿の根拠 - -| 根拠 | 種類 | 何を示すか | -| --- | --- | --- | -| PR #2192 のリファクタリング計画のコメント「見送った提案」の 20 行(理由はすべて `budget`)と、提案の結果ファイル(`/tmp/ndf-worktrees/devbasex--ai-plugins/rf2192/work/.cross_refactoring/{claude,codex,kiro}-propose-rf2192-result.json`)の `rationale` と `plan` | 実測(記録の読み取り) | 各件の兆候と、提案した手順。この設計の判定表の出所 | -| 2026-11-05 の `develop`(86436dace)で `grep -rn "function requireEnv" factory/api/src` が 5 行(`handlers/api.ts`・`handlers/github-oidc.ts`・`handlers/ingest.ts`・`handlers/issue-requests.ts`・`runs/deps.ts`) | 実測 | PR #2192 の後に写しが 3 つ増えた(要求の前提 1) | -| 同じ日の `grep -rn 'Navigate to="/login"' factory/web/src/pages` が 12 行、`factory/web/src` の `onFailure(err)` が 13 ファイルで 23 行 | 実測 | 画面の重複が PR #2192 の後に足された画面(スプリント・ループ・委任・バックログなど)へ写っている | -| 同じ日の `factory/web/src/App.tsx` が、ログインの後の画面をすべて 1 つの枠(`AppShell` の layout route)の下に置く | 実測(コードの読み取り) | ログインの確認を 1 か所に置ける場所が既にある(R14) | -| 同じ日の `factory/api/src/github-oidc/app.ts` に `clientCredentials`・`exchangeGithubCode`・`issueIdToken` があり、POST /token の本体は 21 行 | 実測 | R07 の兆候は PR #2192 の I-001 で消えている(要求の前提 2) | -| `factory/api/src/index.ts` が `./handlers/api.ts`(`depsFromEnv`・`createApiApp`)と `./handlers/ingest.ts`(`progressStoreFromEnv`)を読み、どちらも読み込みの時点で `export const handler = ...` を組み立てる。`runs/deps.ts` も `handlers/ingest.ts` を読む | 実測(コードの読み取り) | 公開のエントリポイントが Lambda のエントリポイントを読む向き(R02) | -| 着手前の `uv run --frozen --project . --all-extras pytest scripts/tests/test_check_markdown_links.py -q` が `46 passed`(2026-11-05) | 実測 | 受け入れ条件 5 の「変更の前の件数」 | - -要求と受け入れ条件は #2199 の本文にある(コピーは [issue-2199-requirements.md](issue-2199-requirements.md))。この文書は「どう作るか」だけを扱う。 - -## ドメインモデル - -### コンテキスト - -| コンテキスト | 何の語が 1 つの意味に決まるか | -| --- | --- | -| Neural Development Factory(`ndf-factory`) | アカウント・席・履歴・実行の形態・プラン(`factory/api/src/accounts/`)、GitHub の仲立ち(`github-oidc/`)、画面(`factory/web/`) | -| NDF の cross-refactoring(`ndf-cross-refactoring`) | 見送った提案・リファクタリング計画(R01〜R20 の出所) | - -関係は**順応者(Conformist)**である。この変更は `ndf-cross-refactoring` が残した見送った提案を、その語と兆候の分類(duplication など)のまま受け取り、`ndf-factory` の構造を直す。`ndf-cross-refactoring` の側は何も変えない。 - -### 集約 - -**集約は変えない。** 持ち主と境界をそのまま保ち、持ち主の内部の関数の置き場だけを変える。 - -| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | -| --- | --- | --- | --- | --- | -| 利用者 | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | 利用者(`UserRecord`) | — | 利用者の ID(Cognito の `sub`)・IdP の種類 | -| アカウント | `AccountService`(`accounts/service.ts`)。保存は `AccountStore` の契約を通す | アカウント(`AccountRecord`) | 席(`SeatRecord`) | プラン・実行の形態・席の種類・履歴の行(`HistoryRecord`)・作成の冪等の鍵の行 | - -利用者とアカウントは別の集約である(確定仕様 `docs/specifications/ndf-factory-accounts-and-plans.md` の「集約」)。2 つを 1 つのトランザクションで書くのは初めてのログインで両方を作るときだけで、それ以外の利用者の読み書きはアカウントの操作に含めない。保存の契約(`AccountStore`)が両方の集約の保存を受け持つのは今のままで、この変更は契約の置き場(`accounts/port.ts`)だけを変える。 - -### 不変条件 - -この変更が守る条件である。I1〜I4 は既存の振る舞いをそのまま保つ条件、I5・I6 はこの変更で新しく成り立たせる構造の条件である。 - -| # | 集約 | 条件 | 破れたときの扱い | -| --- | --- | --- | --- | -| I1 | — | HTTP の応答(状態コード・本文・エラーの文言)と画面の表示が変更の前と同じ | その件を戻し、表の扱いを「採らない(適用すると振る舞いが変わる)」にする | -| I2 | アカウント | DynamoDB へ送る `TransactItems` の項目の並び(業務の項目 → 履歴 → 作成の冪等の鍵の行)・キー・属性・条件式・プレースホルダの名前(`:plan`・`:mode` など)が変更の前と同じ | その件を戻す(C3 に当たる変更を入れない) | -| I3 | アカウント | どの書き込みも履歴の行を同じトランザクションで足す(既存の I20)。条件の失敗は `ConditionFailedError`、それ以外の失敗は今と同じ型で呼ぶ側へ届く | その件を戻す | -| I4 | — | 認証(`auth/verify.ts`・`github-oidc/`)と認可(`auth/resolve.ts`)の判定が変更の前と同じ | その件を戻す(C2 に当たる変更を入れない) | -| I5 | — | 「適用した」件の同じ役割の関数・定数の定義が `factory/` の中で 1 か所にある | 残った写しを寄せるまで実装の Pull Request を出さない | -| I6 | — | `factory/api/src` の `handlers/` の外のコード(`*.test.ts` を除く。テストの補助の `testing/app.ts` は除かない)が `handlers/` を import しない。`handlers/` の中のファイルどうしも互いを import しない | import を移した先へ替えるまで出さない | - -### ドメインイベント - -要求の E1〜E6 を引き継ぐ。どれも実装の工程の中の出来事で、受け手は次の手順と実装の Pull Request の本文である。 - -| # | イベント | 発生元 | 受け手 | -| --- | --- | --- | --- | -| E1 | 20 件のそれぞれが今の `develop` に残っているかを確かめた | 実装担当(この設計の判定表を実装の開始時の `develop` で確かめ直す) | E2・E4 | -| E2 | 同じ役割の写しを PR #2192 の後のファイルから拾った | 実装担当(判定表の「写し」の列) | E4・E6 | -| E3 | 着手前のテストが通った | 実装担当 | E4(基準の件数) | -| E4 | 各件を適用したか、採らないと決めた | 実装担当 | E5・E6 | -| E5 | 全体の検査が通った | 実装担当 | E6 | -| E6 | 20 件と写しの扱いを 1 つの表として Pull Request の本文に残した | 実装担当 | 承認ゲート 2 で見る人 | - -### 用語 - -| 用語 | 意味 | 用語集への反映 | -| --- | --- | --- | -| 見送った提案 | cross-refactoring のリファクタリング計画の「見送った提案」の表の 1 行。ここでは PR #2192 の R01〜R20 | 変えない(`ndf-cross-refactoring` の既存の語のまま) | -| 同じ役割の写し | R01〜R20 の対象と同じ処理・同じ判断を、別の場所に書き写したもの。要求の S3 が対象に含める | 登録済み(`ndf-factory`) | -| 保存の契約 | `AccountStore` の interface と、その引数・失敗の型(`AccountPatch`・`AccountUpdate`・`ConditionFailedError`)。DynamoDB とメモリの 2 つの実装が満たす | 登録済み(`ndf-factory`) | - -## 機能一覧 - -利用者から見た振る舞いは変えない。この変更が作る「使える形」を並べる。 - -| # | 機能 | 誰が使うか | -| --- | --- | --- | -| F1 | 20 件の扱い(適用した・採らない)と写しの扱いを 1 つの表で読む | 承認ゲート 2 で見る人 | -| F2 | 環境変数の読み取り・API のエントリポイントの組み立て・保存の契約・再試行・ログインの確認・失敗の表示を、1 か所の定義から使う | 子課題(#2171・#2172・#2173・#2174・#2189)の実装者 | -| F3 | 後の cross-refactoring が R01〜R20 と同じ提案を出さない | cross-refactoring の提案の担当 | - -## 20 件の判定表 - -**この表が実装の Pull Request の本文の表(受け入れ条件 1)の下書きになる。** 「今の状態」は 2026-11-05 の `develop`(86436dace)で確かめた。実装の開始時に `develop` が進んでいれば、E1 として確かめ直す。 - -| # | 対象 | 今の状態 | 扱い | 直し方(1 か所にまとめる場所) | 同じ役割の写し(S3) | -| --- | --- | --- | --- | --- | --- | -| R01 | `routes/teams.ts#registerTeams` | 席の種類の検証と文言が `teams.ts` に直書き、`accounts.ts` に定数 `SEAT_TYPE_MESSAGE` | 適用する | `accounts/service.ts` の `validateSeatType(value)`(既存の `validateTeamName` の隣)。文言の定数も移す | `routes/accounts.ts` の `addSeat`・`changeSeat` | -| R02 | `index.ts#depsFromEnv` | `index.ts` が `handlers/api.ts`・`handlers/ingest.ts` を読み、`createApiApp` も `handlers/api.ts` から再 export する | 適用する | 決定 1 | `runs/deps.ts` → `handlers/ingest.ts`、`handlers/issue-requests.ts` → `handlers/ingest.ts` | -| R03 | `github-oidc/app.ts#githubJson` | `/token` と `/userinfo` が `/user` の取得と ID の検証を 2 回書く | 適用する | `createGithubOidcApp` の中の `githubUser(accessToken)`(取れなければ `null`) | 無し | -| R04 | `accounts/store.ts#DynamoAccountStore.transact` | 6 つの書き込みが履歴の行の展開を書く。`createTeam` は履歴の後に作成の冪等の鍵の行を足す | 適用する | 決定 3(R09 と同じ直し方) | 無し | -| R05 | `web/pages/TeamPage.tsx#TeamPage` | 席の種類の一覧 `SEAT_TYPES` は `parts.tsx` にあり、そこから `