From b3f6beb4d1cdc23e74cb246fe44d1a2b803e03a1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:02:20 +0000 Subject: [PATCH 01/43] =?UTF-8?q?Docs:=20#2519=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=8D=E3=80=81=E7=94=A8=E8=AA=9E?= =?UTF-8?q?=E9=9B=86=E3=81=B8=203=20=E8=AA=9E=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計の前に外部の調査(標準・主要な製品・既存のライブラリとサービス)を通す手順の要求。 仕様の正は課題の本文で、issues/issue-2519-requirements.md はその写し。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 3 + docs/glossary/glossary.json | 18 +++ issues/issue-2519-requirements.md | 187 ++++++++++++++++++++++++++++++ 3 files changed, 208 insertions(+) create mode 100644 issues/issue-2519-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index cddb8f0fc..650408949 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -395,6 +395,9 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 呼び手 | — | 関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える | — | — | — | | 残す関数の一覧 | — | 関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの | — | — | — | +| 外部の調査 | — | 設計の判断の前に、業界の事実上の標準・仕様、主要な製品の解き方、使える既存のライブラリとマネージドサービスを一次情報で調べ、設計文書に残す手順(#2519)。棚卸しのシーズの調査とは別のもの | — | — | — | +| 自前で作る決定 | — | 既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録(#2519) | — | — | — | +| 確かめられなかったこと | — | 外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、その理由を並べた設計文書の欄(#2519) | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index bdd8b8390..4cd4b7865 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5456,6 +5456,24 @@ "context": "ndf-workflow", "meaning": "関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの", "pending_source": "issues/issue-2369-design.md" + }, + { + "term": "外部の調査", + "context": "ndf-workflow", + "meaning": "設計の判断の前に、業界の事実上の標準・仕様、主要な製品の解き方、使える既存のライブラリとマネージドサービスを一次情報で調べ、設計文書に残す手順(#2519)。棚卸しのシーズの調査とは別のもの", + "pending_source": "issues/issue-2519-requirements.md" + }, + { + "term": "自前で作る決定", + "context": "ndf-workflow", + "meaning": "既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録(#2519)", + "pending_source": "issues/issue-2519-requirements.md" + }, + { + "term": "確かめられなかったこと", + "context": "ndf-workflow", + "meaning": "外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、その理由を並べた設計文書の欄(#2519)", + "pending_source": "issues/issue-2519-requirements.md" } ] } diff --git a/issues/issue-2519-requirements.md b/issues/issue-2519-requirements.md new file mode 100644 index 000000000..f1ce87d77 --- /dev/null +++ b/issues/issue-2519-requirements.md @@ -0,0 +1,187 @@ +# #2519: design: 設計の前に外部の最新動向・事実上の標準・既存のライブラリを調べ、判断に反映する手順を足す + +正は課題の本文(#2519)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 何が困っているか + +`design` など設計の Skill には、設計の前に外部の最新動向・事実上の標準(デファクトスタンダード)・使える既存のライブラリやサービスを調べ、その結果を設計の判断に反映する手順が無い。設計の雛形には「あるべき姿の根拠」の表(種類に「外部の一次情報」がある)はある。しかし「何を調べるか」「いつ調べるか」「調べた結果で何を決めるか」は決まっていない。 + +そのため設計は、手元のコードと要求の文だけから組み立てられる。業界の標準の形や枯れたライブラリがあっても、自前で作る設計になったり、古い形のまま決まったりする。利用者が後から気づいて指示し、設計をやり直すことになる。 + +## 具体例: carul(volareinc/carul、2026-11) + +| 課題 | 起きたこと | 利用者が後から指示したこと | +| --- | --- | --- | +| 権限のモデル(carul #102) | 最初の設計は、所属に役職を持たせ「管理者の役職なら全店舗」という固定の規則で判定する形だった。IAM の利用者・ロール・ポリシーの形や、Cedar・Amazon Verified Permissions などの既存の選択肢を比べていなかった | 権限を全面的に設計し直す。IAM に倣い、AVP・Cedar・自前の 3 案をフラットに比べる。エージェントは人と別の主体にし、最新の権限付与(委任・短期のトークン)に準拠する | +| CRM・顧客の ID の形 | 顧客の管理と認証を、乗り換え元(JOCAR)のデータの形に寄せて設計していた | JOCAR に寄せない。データモデルは RDBMS の理想の形で設計し、取り込みで変換する。認証・CRM・商品・UX は最新の動向に合わせる | +| 自前で作る判断(carul #117) | UI の部品・データの読み込み・表・オフラインの同期・TOTP・認可を、既存のライブラリ(shadcn/ui・TanStack Query・PowerSync など)と比べないまま自前で作ると決め、確定仕様にした | 自前で作ると決めた確定仕様を、ライブラリを使う案と比べて見直す | + +carul ではこの後、プロジェクトの MVV に Value 10(使える既存のものを先に調べる)と Value 11(世界の主要な製品の良さを取り込む)を足した。2 回目の #102 の設計は、9 つの観点で 3 方式を比べ、ライブラリの候補 6 つの保守の状態と採否、競合 6 製品の権限の形を、出典つきで載せた。プロジェクトの MVV で補うと、ここまでの調査ができた。ただ、この手順は本来 `design` の側が持つべきものである。MVV を持たないプロジェクトや、MVV にこの Value が無いプロジェクトでは、同じことが起きる。 + +## 依頼(原文) + +課題の題と、上の「何が困っているか」「具体例」、下の 2 つの節は、起票者が書いた本文である(書き手: 起票者、日付: 2026-11-05)。 + +> design: 設計の前に外部の最新動向・事実上の標準・既存のライブラリを調べ、判断に反映する手順を足す + +> ## 直し方の案 +> +> `design`(と、設計の材料を作る `requirements-design`)に、設計の判断の前に通す「外部の調査」の手順を足す。 +> +> 1. **調べる対象を決める。** 触る領域ごとに、次を調べるかを決める。 +> - 業界の事実上の標準・仕様(例: 認可なら IAM・Cedar・OAuth 2.1・RFC 8693・MCP の認可の仕様。認証なら NIST SP 800-63B) +> - 主要な製品・競合の形(同じ問題を世界の主要な製品がどう解いているか) +> - 使える既存のライブラリ・マネージドサービス(自前で作る前に) +> 2. **調べた結果を表に残す。** 一次情報の出典(URL と取得日)を付ける。ライブラリは、バージョン・ライセンス・保守の状態(最後のリリース・終了のアナウンス)・要件を満たすかを並べる。 +> 3. **判断へつなぐ。** 「自前で作る」「ライブラリを使う」「標準に倣う」のどれを採ったかを決定の記録に書き、採らない案とその理由を残す。自前で作る決定には、比べた候補を必ず並べる。 +> 4. **調べられなかったことを書く。** 公開の資料が無い・取得できなかった対象は、「確かめられなかったこと」として残す(`investigation-rules` に合わせる)。 +> 5. **省ける条件を決める。** 文言の修正や既存の形の延長など、外部の判断が要らない変更は省けるようにする。省いた理由は 1 行で残す。 +> +> 検査の案: 設計文書の決定の記録に「自前で作る」とあるのに、比べた候補(ライブラリ・サービス・標準)の行が無ければ、設計のレビューか `requirements-recheck` に相当する機械の確かめで指摘する。 +> +> ## 受け入れ条件(起票時) +> +> 1. `design` の SKILL.md(または references)に、上の 1〜5 の手順と、手順を省ける条件がある +> 2. 設計の雛形に、外部の調査の節(標準・製品・ライブラリの比較と出典)と、「確かめられなかったこと」の欄がある +> 3. 「自前で作る」決定に比べた候補が無い設計文書を、設計のレビューの観点か機械の検査が指摘する +> 4. プロジェクトの MVV に調査の Value が無くても、この手順が働く + +利用者の文として確かめられたのは、マイルストーン「35 開発フローの見直しと大整理」の説明に起票者が引いた次の 1 文だけである(書き手: 起票者が利用者の指示を引用、日付: 2026-11-05)。 + +> #2519 は利用者の指示で 1 位に置いた(2026-11-05。設計で問題が出ることが多いため、できるだけ早く着手する)。 + +この要求を書く工程の起動指示(書き手: AI の起動指示、日付: 2026-11-05): 「人へ問わずに進め、決められない点は前提か未決として課題の本文へ書く」。 + +具体例の carul #102(権限のモデルを全面的に設計し直す、IAM に倣う)と carul #117(自前で作ると決めた確定仕様を見直す)は、2026-11-05 に `gh issue view <番号> -R volareinc/carul` で題と作成日(いずれも 2026-11-04)を確かめた。本文の「利用者が後から指示したこと」の列は起票者による要約で、carul の利用者の原文とは突き合わせていない。 + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 手順を「調べる対象を決める・表に残す・判断へつなぐ・確かめられなかったことを書く・省ける条件」の 5 つで組む | 本文「直し方の案」1〜5 | 受け入れ条件 1〜7 | +| S2 | `requirements-design` にも外部の調査の手順を足す | 本文「直し方の案」の冒頭の括弧書き | 含まない(前提 1) | +| S3 | 例に挙げた標準(IAM・Cedar・OAuth 2.1・RFC 8693・MCP の認可・NIST SP 800-63B)を手順に載せる | 本文「直し方の案」1 | 含まない。手順は例として示してよいが、領域ごとの標準の一覧を Skill に持たない(前提 2) | +| S4 | ライブラリは版・ライセンス・保守の状態(最後のリリース・終了のアナウンス)・要件を満たすかを並べ、出典は URL と取得日 | 本文「直し方の案」2 | 受け入れ条件 2・3 | +| S5 | 「自前で作る」決定に候補が無いものを、設計のレビューか機械の検査が指摘する | 本文「検査の案」・受け入れ条件 3 | 受け入れ条件 8。どちらで指摘するかは未決 1 | +| S6 | プロジェクトの MVV に調査の Value が無くても働く | 本文「受け入れ条件」4 | 受け入れ条件 9 | +| S7 | 調べられなかったことの書き方を `investigation-rules` に合わせる | 本文「直し方の案」4 | 受け入れ条件 5 | + +## 目的 + +- 設計が手元のコードと要求の文だけから組み立てられ、業界の標準の形・枯れたライブラリ・主要な製品の解き方を比べないまま「自前で作る」「古い形に寄せる」と決まる状態をなくす +- 設計の承認(承認ゲート 1)の時点で、承認する人が「何と比べてこの形にしたか」を出典つきで読めるようにし、利用者が後から気づいて設計をやり直す手戻りを減らす +- プロジェクトの MVV に調査の Value を足さなくても、`design` を通すだけでこの調査が行われるようにする + +## 前提 + +- 前提 1: 外部の調査は `design` の手順に置き、`requirements-design` には足さない。要求は「何を満たすか」を決め、標準・製品・ライブラリとの比較は「どう作るか」の判断の材料だからである。調べる対象は、`design` の手順 1 で決める触る領域から決める +- 前提 2: 領域ごとに調べるべき標準・製品・ライブラリの一覧を Skill に持たない。一覧は古くなり、プロジェクトごとに領域が違う(Value 6)。手順は「何の種類を調べるか」と「調べた結果の形」だけを決め、例は読み手の理解のために示すにとどめる +- 前提 3: 外部の調査は、`design` を通すすべてのモードで「通すか省くか」を決める。省ける条件に当たる変更(文言・書式の修正、既存の形の延長、外部の判断が要らない変更)は、省いた理由を 1 行残して省く。`light` / `operation` で `design` を通さない変更には掛からない +- 前提 4: 一次情報の取得は、設計を書くエージェントが手元の取得の手段(Web 検索・URL の取得・`gh` など)で行う。取得できなかったものを推定で埋めない +- 前提 5: 既存の設計文書(`issues/` の過去の設計、`docs/specifications/` の確定仕様)を遡って直さない。手順と検査は、この変更の後に書く設計文書に効く +- 前提 6: 棚卸しの見直しで行う「シーズの調査」(ライブラリの競合・周辺環境の変化を調べて起票する。#2315)とは時期と目的が別である。結果の形(出典と調べた日時を持つ)を揃えるかは設計で決める(未決 2) + +## 対象範囲 + +含む: +- `design` の手順に、外部の調査(調べる対象の決定・結果の表・決定への反映・確かめられなかったことの記録・省ける条件・調べる量の上限)を足す +- 設計文書の雛形に、外部の調査の節を足す +- 「自前で作る」と決めた決定に比べた候補が無い設計文書を指摘する仕組み(設計 PR のレビューの観点か、機械の検査) +- 用語集へ新しい語を足す + +含まない: +- `requirements-design` の手順の変更(前提 1) +- 領域ごとの標準・製品・ライブラリの一覧(前提 2) +- 既存の設計文書・確定仕様の遡った修正(前提 5)。carul #117 のような既存の決定の見直しは、各プロジェクトの課題で行う +- 棚卸しのシーズの調査(#2315)の変更 +- 外部の調査を行う新しい Skill・エージェントの追加 + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 設計で触る領域を決めた | `design` の手順 1 | 既存のとおり(受け入れ条件が無ければ要求へ戻る) | — | +| E2 | 外部の調査を通すか省くかを決めた | E1 | 省ける条件に当たるか決められないときは、通す側に倒す(受け入れ条件 6) | E1 | +| E3 | 調べる対象(標準・仕様 / 主要な製品 / 既存のライブラリとサービス)を決めた | E2 で通すと決めた | 3 種類のどれも当たらないと判断したら、種類ごとに理由を 1 行残す(受け入れ条件 1) | E2 | +| E4 | 一次情報を取得した | E3 | 取得できない・公開の資料が無い対象は、確かめられなかったことへ移す(受け入れ条件 5) | E3 | +| E5 | 調べる量の上限に当たって調査を止めた | 上限(候補の数か時間) | 残りの対象を確かめられなかったことへ移す(受け入れ条件 4) | E3 | +| E6 | 調べた結果を設計文書の外部の調査の節に残した | E4 または E5 | — | E4 | +| E7 | 決定の記録に、自前で作る・ライブラリやサービスを使う・標準に倣うのどれを採ったかと、比べた候補を書いた | E6 | 候補が無いまま自前で作ると書いた設計は、E8 で指摘される | E6 | +| E8 | 自前で作る決定に比べた候補が無い設計文書を指摘した | 設計 PR のレビューか機械の検査 | 指摘を直すまで承認ゲート 1 へ渡らない(既存の設計 PR の流れのとおり) | E7 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 外部の調査 | 設計の判断の前に、業界の事実上の標準・仕様、主要な製品の解き方、使える既存のライブラリとマネージドサービスを一次情報で調べ、設計文書に残す手順 | +| 自前で作る決定 | 既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録 | +| 確かめられなかったこと | 外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、その理由(取得の拒否・HTTP の状態・ネットワークの誤り・上限)を並べた欄 | + +## 受け入れ条件 + +- [ ] 1. `design` の手順に、外部の調査の手順がある。調べる対象の 3 種類(業界の事実上の標準・仕様 / 主要な製品の解き方 / 使える既存のライブラリとマネージドサービス)ごとに調べるかを決め、調べない種類には理由を 1 行残すと書かれている +- [ ] 2. 設計文書の雛形に外部の調査の節があり、標準・仕様、主要な製品、ライブラリとサービスの比較の表と、確かめられなかったことの欄を持つ。どの表の行も出典(URL と取得日)の列を持つ +- [ ] 3. 雛形のライブラリとサービスの表は、版・ライセンス・保守の状態(最後のリリースの日付・終了のアナウンスの有無)・要件を満たすかの列を持つ +- [ ] 4. 手順に調べる量の上限(候補の数か時間)があり、上限に当たったら調査を止め、残りの対象を確かめられなかったことへ書くと書かれている +- [ ] 5. 手順に、取得できなかった対象・公開の資料が無い対象を推定で埋めず、URL か検索の語と理由を確かめられなかったことへ書くと書かれている。書き方は `investigation-rules` の否定的な結論のエビデンスの規則と食い違わない +- [ ] 6. 手順に省ける条件があり、省いたときは理由を 1 行、設計の書き先(`deliverables.md` の「設計の書き先」)に残すと書かれている。省けるか決められないときは通す +- [ ] 7. 決定の記録の書き方(`decisions.md`)に、自前で作る・ライブラリやサービスを使う・標準に倣うのどれを採ったかと、自前で作る決定には外部の調査の節で比べた候補を並べることが書かれている +- [ ] 8. 自前で作る決定に比べた候補が無い設計文書を、設計 PR のレビューの観点か機械の検査が指摘する。指摘の例(候補が無い決定を持つ設計文書)と、指摘されない例(候補を並べた決定・外部の調査を理由つきで省いた設計文書)で確かめられる +- [ ] 9. 手順と検査は、プロジェクトの MVV が無いプロジェクト・MVV に調査の項目が無いプロジェクトでも働く。手順の文と検査の条件が MVV の項目を参照しない +- [ ] 10. 既存の設計文書(`issues/` の過去の設計)があっても、機械の検査を置いた場合にその検査が既存の文書で落ちない(前提 5) +- [ ] 11. 退行しない: `python3 scripts/check-skill-frontmatter.py`・`claude plugin validate .`・全体テストが通る。設計文書の雛形は 500 行以内に収まる + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 運用・保守性 | 外部の調査が設計の所要を際限なく延ばさない。上限(受け入れ条件 4)の値は設計で決め、実測から見直せる場所に置く | +| システム環境 | 手順は Claude Code と Codex のどちらで `design` を通しても同じに働く。特定のランタイムだけの取得の手段を前提にしない | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる(`design` の手順と設計文書の雛形に節が増える)。既存の設計文書は遡って直さず、互換を保つ | +| データ | 無し(用語集に語が 3 つ増える) | +| 既存の振る舞い | `design` を通す変更で、外部の調査を通すか省くかの判断と、その記録が増える。設計 PR のレビューの観点か機械の検査に 1 項目増える | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest . -q -n 4`。機械の検査を置くなら、受け入れ条件 8 の指摘の例・指摘されない例と、受け入れ条件 10 の既存の文書をテストで確かめる | +| 静的解析 | `python3 scripts/check-skill-frontmatter.py` / `claude plugin validate .` / `python3 plugins/ndf/scripts/glossary.py check --file <設計文書>` | +| 手動確認 | リリース後に、NDF の外のプロジェクトで `design` を 1 回通し、外部の調査の節が出典つきで埋まること・自前で作る決定に候補が並ぶこと・取得できなかった対象が確かめられなかったことへ載ることを見る | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | Skill の実体は `plugins/ndf/skills/` の 1 か所(`AGENTS.md`「NDFプラグインについて」)。配る Skill は `plugins/ndf/manifests/` が決め、この変更で Skill を増やさない | +| コーディング規約 | Skill の書き方は `plugins/ndf/skills/AUTHORING.md`、文書は `document-style`。`.md` の文言を照合するテストを書かない(`AGENTS.md`「DON'T」) | +| テスト戦略 | 機械の検査を置くなら、その振る舞い(指摘する / しない / 既存の文書で落ちない)を単体テストで縛る。手順の文は文言のテストで縛らない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 全体テスト・frontmatter のチェック・用語チェックの実行。新しい語の用語集への反映 | +| 確認してから行う | 設計 PR のレビューの観点の追加、機械の検査の追加(承認ゲート 1・2 で人が見る) | +| 行わない | 既存の設計文書と確定仕様の書き換え、`requirements-design` の手順の変更、領域ごとの標準の一覧の作成、新しい Skill の追加 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 受け入れ条件 8 の指摘を、設計 PR のレビューの観点で行うか、機械の検査(既存の検査のスクリプトへの追加か新設)で行うか、両方か。機械の検査なら、自前で作る決定を見分ける印の形 | 設計(`design`) | 承認ゲート 1 | +| 2. 外部の調査の結果の形を、棚卸しのシーズの調査(#2315)の調査の記録と揃えるか | 設計(`design`) | 承認ゲート 1 | +| 3. 調べる量の上限の値(候補の数か時間)と置き場 | 設計(`design`) | 承認ゲート 1 | + +## 根拠にした MVV の項目 + +- Mission: 開発者が価値を利用者へ届けるまでの時間と費用を減らす(設計のやり直しの手戻りを減らす) +- Value 4: 外の利用で測って決める(carul での実際の手戻りが起点) +- Value 3: やめる・縮めるを既定にする(調べる量の上限と省ける条件) +- Value 6: どのプロジェクトでも使える形を保つ(領域ごとの一覧を持たない・MVV に依らず働く) +- Value 7: 根本原因の場所で直す(プロジェクトの MVV で補うのでなく `design` に置く) From 38c785d9c68ddef39fc315bd27b3b4aa4e4073fa Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:10:34 +0000 Subject: [PATCH 02/43] =?UTF-8?q?Docs:=20#2519=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8D=E3=80=81=E7=94=A8=E8=AA=9E?= =?UTF-8?q?=E9=9B=86=E3=81=B8=202=20=E8=AA=9E=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 設計の前に外部の調査(標準・主要な製品・既存のライブラリとサービス)を通す手順を design の手順 2 に置き、 自前で作る決定に比べた候補が無い設計文書を design-survey.py の検査とレビューの観点で指摘する設計。 用語集は「作り方」「比べた候補」を足し、「自前で作る決定」「確かめられなかったこと」の意味を設計に合わせた。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 6 +- docs/glossary/glossary.json | 20 ++- issues/issue-2519-design.md | 336 ++++++++++++++++++++++++++++++++++++ 3 files changed, 356 insertions(+), 6 deletions(-) create mode 100644 issues/issue-2519-design.md diff --git a/docs/glossary.md b/docs/glossary.md index 650408949..57b177b7f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -396,8 +396,10 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 呼び手 | — | 関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える | — | — | — | | 残す関数の一覧 | — | 関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの | — | — | — | | 外部の調査 | — | 設計の判断の前に、業界の事実上の標準・仕様、主要な製品の解き方、使える既存のライブラリとマネージドサービスを一次情報で調べ、設計文書に残す手順(#2519)。棚卸しのシーズの調査とは別のもの | — | — | — | -| 自前で作る決定 | — | 既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録(#2519) | — | — | — | -| 確かめられなかったこと | — | 外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、その理由を並べた設計文書の欄(#2519) | — | — | — | +| 自前で作る決定 | — | 既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録。決定の作り方の行に「自前で作る」を含むもので見分ける(#2519) | — | — | — | +| 確かめられなかったこと | — | 外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、試した手段(URL か検索の語)と理由を並べた設計文書の欄(#2519) | — | — | — | +| 作り方 | — | 設計文書の決定の記録の中の 1 行。その決定が機能をどう得るか(自前で作る / ライブラリやサービスを使う / 標準に倣う、またはその組み合わせ)を書く(#2519) | — | — | — | +| 比べた候補 | — | 設計文書の決定の記録の中の 1 行。外部の調査の表で比べた行の番号(STD<数> / PRD<数> / LIB<数>)を並べる(#2519) | — | — | — | ## NDF の Slack 通知(`ndf-notification`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 4cd4b7865..73bb0042f 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5466,14 +5466,26 @@ { "term": "自前で作る決定", "context": "ndf-workflow", - "meaning": "既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録(#2519)", - "pending_source": "issues/issue-2519-requirements.md" + "meaning": "既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録。決定の作り方の行に「自前で作る」を含むもので見分ける(#2519)", + "pending_source": "issues/issue-2519-design.md" }, { "term": "確かめられなかったこと", "context": "ndf-workflow", - "meaning": "外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、その理由を並べた設計文書の欄(#2519)", - "pending_source": "issues/issue-2519-requirements.md" + "meaning": "外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、試した手段(URL か検索の語)と理由を並べた設計文書の欄(#2519)", + "pending_source": "issues/issue-2519-design.md" + }, + { + "term": "作り方", + "context": "ndf-workflow", + "meaning": "設計文書の決定の記録の中の 1 行。その決定が機能をどう得るか(自前で作る / ライブラリやサービスを使う / 標準に倣う、またはその組み合わせ)を書く(#2519)", + "pending_source": "issues/issue-2519-design.md" + }, + { + "term": "比べた候補", + "context": "ndf-workflow", + "meaning": "設計文書の決定の記録の中の 1 行。外部の調査の表で比べた行の番号(STD<数> / PRD<数> / LIB<数>)を並べる(#2519)", + "pending_source": "issues/issue-2519-design.md" } ] } diff --git a/issues/issue-2519-design.md b/issues/issue-2519-design.md new file mode 100644 index 000000000..4506b4ad8 --- /dev/null +++ b/issues/issue-2519-design.md @@ -0,0 +1,336 @@ +# design: 設計の判断の前に外部の標準・主要な製品・既存のライブラリを調べ、自前で作る決定に比べた候補を残す(#2519) + +## 目的 + +- **今どうなっているか**: `design` には、設計の前に業界の標準・主要な製品の解き方・使える既存のライブラリとサービスを調べる手順が無い。設計は手元のコードと要求の文だけから組み立てられ、比べないまま「自前で作る」「古い形に寄せる」と決まる(carul #102・#117 で、利用者が後から気づいて設計をやり直した) +- **誰に効くか**: `design` を通すすべてのプロジェクトの設計者と、承認ゲート 1 で設計を承認する人 +- **変えると何が成り立つか**: 設計文書に、何と比べてこの形にしたかが出典つきで残る。自前で作る決定に比べた候補が無ければ、承認ゲート 1 の前に機械の検査が止める。プロジェクトの MVV に調査の項目が無くても同じに働く + +## 適用範囲 + +- **働く範囲**: NDF を使うすべてのリポジトリ。手順は `design` の Skill、検査はスプリントの設計のプランと `design` の手順から打つスクリプトで、このリポジトリの継続的統合に依らない +- **プロジェクトごとに違うもの**: 無し(調べる対象は触る領域から設計者が決め、領域ごとの標準の一覧を持たない。上限の値は NDF の参照の 1 か所に置く) +- **当たるモード**: `design` を通すすべてのモード(`standard`・`legacy-refactor`・`documentation`、領域に当たった `light`・`operation`) + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| MADR 4.0.0 のテンプレート( https://adr.github.io/madr/ 、2026-11-05 取得) | 外部の一次情報 | 決定の記録の事実上の標準は、比べた選択肢(Considered Options)と選んだ選択肢(Decision Outcome: `Chosen option: …, because …`)を別の欄に持つ。比べた候補を決定に並べる形はこれに倣う | +| OpenSSF Scorecard の Maintained の検査( https://github.com/ossf/scorecard/blob/main/docs/checks.md 、2026-11-05 取得) | 外部の一次情報 | 保守の状態は直近 90 日の活動で見て、アーカイブしたリポジトリを最低の評価にする。ライブラリの表の「最後のリリースの日付」と「終了のアナウンス」の 2 列はこの見方に合わせる | +| carul #102・#117(課題の本文の具体例) | 実測 | 比べないまま自前で作ると決めた設計を、利用者の指示で作り直した。2 回目の #102 の設計は、ライブラリの候補 6 つと競合 6 製品を出典つきで比べた | +| マイルストーン「35 開発フローの見直しと大整理」の説明(2026-11-05) | 利用者の指示の原文 | 「設計で問題が出ることが多いため、できるだけ早く着手する」 | + +要求と受け入れ条件は #2519 の本文にある(コピーは `issues/issue-2519-requirements.md`)。この文書は「どう作るか」だけを扱う。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| NDF の工程(`ndf-workflow`) | 設計文書・決定の記録・外部の調査・自前で作る決定・作り方・比べた候補・確かめられなかったこと | + +変更は 1 つのコンテキストに収まる。棚卸しのシーズの調査は同じコンテキストの別の手順で、記録を共有しない(決定 8)。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 設計文書 | 設計を書く担い手(`design` の手順を通す worker か利用者)と、指摘を直す修正担当 | 設計文書(同じ課題の `issue-<番号>-design*.md`。`light` は受け入れ条件のファイルの設計の節) | 決定・外部の調査の節 | 作り方・比べた候補・調べた行の出典・確かめられなかったことの行・調べた量 | + +検査のスクリプトとレビュー担当は設計文書を読むだけで、書き換えない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 設計文書 | 外部の調査の節は 1 つで、「省く」の 1 行か、調べる対象の表(3 種類の行)のどちらか一方を持つ | 設計 PR のレビューで指摘する | +| I2 | 設計文書 | 調べる対象の表で「調べない」とした種類は、理由を 1 行持つ | 設計 PR のレビューで指摘する | +| I3 | 設計文書 | 調べた結果の行は、出典(URL・パス・コマンドのどれかと取得日)を持つ。出典を持てない対象は確かめられなかったことの行へ移り、推定で埋めない | 設計 PR のレビューで指摘する | +| I4 | 設計文書 | 種類ごとの候補は上限(6 件)以下。上限で止めた対象は、確かめられなかったことへ理由「上限」で載る | 設計 PR のレビューで指摘する | +| I5 | 設計文書 | 作り方に「自前で作る」を含む決定は、比べた候補を 1 つ以上持つ。ただし外部の調査を「省く」とした文書の決定は除く | 検査が終了コード 1 で指摘し、スプリントの設計のプランが承認ゲート 1 の前で止まる | +| I6 | 設計文書 | 比べた候補の番号は、同じ課題の文書にある外部の調査の表の行の番号のどれかである | I5 と同じ | +| I7 | 設計文書 | 作り方の値は「自前で作る」「ライブラリやサービスを使う」「標準に倣う」のどれか、またはその組み合わせである | I5 と同じ | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 設計で触る領域を決めた | `design` の手順 1 | `design` の手順 2(外部の調査) | +| E2 | 外部の調査を通すか省くかを決めた | `design` の手順 2 | 通すなら手順 2 の続き。省くなら設計文書の外部の調査の節(「省く」の 1 行) | +| E3 | 調べる対象を決めた | `design` の手順 2 | 一次情報の取得 | +| E4 | 一次情報を取得した | 設計を書く担い手(Web 検索・URL の取得・`gh`) | 外部の調査の節の表の行。取得できなければ確かめられなかったことの行 | +| E5 | 調べる量の上限に当たって調査を止めた | `design` の手順 2 | 確かめられなかったことの行(理由「上限」) | +| E6 | 調べた結果を外部の調査の節に残した | 設計を書く担い手 | 決定の記録(`design` の手順 4)・あるべき姿の根拠 | +| E7 | 決定の記録に作り方と比べた候補を書いた | `design` の手順 4 | 検査のスクリプトと設計 PR のレビュー | +| E8 | 自前で作る決定に比べた候補が無い設計文書を指摘した | 検査のスクリプト(`design-survey.py check`)と設計 PR のレビュー | 修正担当(スプリントの設計のプランの `fix-survey`)。直らなければプランが止まり、conductor が人へ戻す | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 外部の調査 | 設計の判断の前に、業界の事実上の標準・仕様、主要な製品の解き方、使える既存のライブラリとマネージドサービスを一次情報で調べ、設計文書に残す手順 | 追加(要求の変更で足した語。意味は変えない) | +| 自前で作る決定 | 既存のライブラリ・サービス・標準の実装を使わず、プロジェクトの中で作ると決めた決定の記録。作り方の行に「自前で作る」を含む決定 | 意味の変更(作り方の行で見分けることを足す) | +| 確かめられなかったこと | 外部の調査で、公開の資料が無い・取得できなかった・上限で止めた対象と、試した手段と理由を並べた欄 | 意味の変更(試した手段の列を足す) | +| 作り方 | 決定の記録の中の 1 行。その決定が機能をどう得るか(自前で作る / ライブラリやサービスを使う / 標準に倣う)を書く | 追加 | +| 比べた候補 | 決定の記録の中の 1 行。外部の調査の表で比べた行の番号(`STD<数>` / `PRD<数>` / `LIB<数>`)を並べる | 追加 | + +## 外部の調査 + +| 種類 | 調べるか | 理由 | +| --- | --- | --- | +| 標準・仕様 | 調べる | 決定の記録の書式と、ライブラリの保守の状態の判定に事実上の標準がある | +| 主要な製品 | 調べない | 変えるのは NDF の手順の文と文書の検査で、利用者に見える製品の機能に当たる形が無い | +| ライブラリとサービス | 調べる | 検査は Markdown の見出しと表を読む。読み方を自前で書くか既存の部品を使うかを決める | + +### 標準・仕様 + +| # | 標準・仕様 | 何を定めているか | 設計への反映 | 出典 | +| --- | --- | --- | --- | --- | +| STD1 | MADR 4.0.0 | 決定の記録に Considered Options(比べた選択肢)と Decision Outcome(`Chosen option: …, because …`)を別の欄で持つ | 作り方と比べた候補を決定の中の別の行にする(決定 4) | https://adr.github.io/madr/ (2026-11-05 取得) | +| STD2 | OpenSSF Scorecard の Maintained | 直近 90 日の活動で保守を見る。アーカイブしたリポジトリは最低の評価 | ライブラリの表に最後のリリースの日付と終了のアナウンスの列を置き、アーカイブを終了のアナウンスとして扱う | https://github.com/ossf/scorecard/blob/main/docs/checks.md (2026-11-05 取得) | + +### ライブラリとサービス + +| # | 候補 | 版 | ライセンス | 最後のリリースの日付 | 終了のアナウンス | 要件を満たすか | 出典 | +| --- | --- | --- | --- | --- | --- | --- | --- | +| LIB1 | markdown-it-py(`lib/md.py` が包む) | 4.2.0 | MIT | 2026-05-07 | 無し(アーカイブされていない。最後の push 2026-11-02) | 満たす(見出しの階層・表・コードブロックの判定を既に持つ) | https://pypi.org/pypi/markdown-it-py/json と https://api.github.com/repos/executablebooks/markdown-it-py (2026-11-05 取得)、`uv.lock` | + +### 確かめられなかったこと + +| 対象 | 試した手段 | 結果と理由 | +| --- | --- | --- | +| 決定の記録に比べた候補があるかを確かめる既存の検査の Tool | 試していない | 上限の前に止めた。比べる候補を探す検索をしていないため、既存の Tool が無いとは言えない。検査は LIB1 の上に数十行で書ける規模で、Tool を入れる費用(依存の追加・設定の書式)に見合うかは未確認 | + +調べた量: 候補 3 件・取得の試み 4 回。 + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 触る領域から、3 種類の調べる対象ごとに調べるか省くかを決め、理由を残す | 設計を書く担い手 | +| F2 | 標準・仕様、主要な製品、ライブラリとサービスを出典つきの表に残し、取得できなかった対象を確かめられなかったことへ残す | 設計を書く担い手、承認する人 | +| F3 | 調べる量の上限に当たったら調査を止める | 設計を書く担い手 | +| F4 | 決定に作り方と比べた候補を書く | 設計を書く担い手 | +| F5 | 自前で作る決定に比べた候補が無い設計文書を、機械の検査で指摘する | スプリントの設計のプラン、設計を書く担い手 | +| F6 | 印の無いまま自前で作る決定と、外部の調査の節の欠けを、設計 PR のレビューで指摘する | cross-review のレビュー担当 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| `plugins/ndf/skills/design/SKILL.md` | 手順 2「外部の調査を通す」を足し、以後の手順の番号を 1 つずつ繰り下げる(決定 1)。「すべての変更」の行に `external-survey.md` を足す。手順 5(突き合わせ)で検査のコマンドを打つ | +| `plugins/ndf/skills/design/references/external-survey.md`(新規) | 外部の調査の手順の正本。3 種類の対象の決め方・省ける条件・上限と「調べた量」の行・取得できないときの書き方(`investigation-rules` の否定的な結論の規則に合わせ、試した URL か検索の語と結果を書く)・決定へのつなぎ方・シーズの調査の記録の引き方 | +| `plugins/ndf/skills/design/references/design-template.md` | テンプレートに `## 外部の調査` の節を「機能一覧」の前に足す。「あるべき姿の根拠」の外部の一次情報は、外部の調査の行の番号を指してよいと書く。「各節が実装計画のどこへつながるか」に行を足す。「`legacy-refactor` の書き方」を 4 節にする(決定 9) | +| `plugins/ndf/skills/design/references/decisions.md` | 決定の書き方に、作り方と比べた候補の 2 行を足す(決定 4) | +| `plugins/ndf/skills/design/references/deliverables.md` | 要否表に「外部の調査」の行(全モード必須)を足す。「成果物ごとの中身」に行を足す。`legacy-refactor` の無条件の成果物の段落を 4 節に合わせる | +| `plugins/ndf/scripts/design-survey.py`(新規) | 検査のエントリポイント。I5〜I7 を読むだけで判定する(決定 6) | +| `plugins/ndf/scripts/lib/design_docs.py`(新規) | 設計文書の名前の規則と、課題の番号から HEAD の文書を読む処理。`design-match.py` から移し、2 つのエントリポイントで共有する | +| `plugins/ndf/scripts/design-match.py` | 設計文書の探し方を `lib/design_docs.py` から読む(振る舞いは変えない) | +| `plugins/ndf/scripts/supervise_lib/paths.py` | 検査のコマンドの定数(`DESIGN_SURVEY_PY`) | +| `plugins/ndf/scripts/supervise_lib/sprint_waves.py` | 設計のプランに `survey-check` / `fix-survey` / `survey-recheck` の 3 ステップを足す(決定 7) | +| `plugins/ndf/skills/cross-review/scripts/review_lib/review_focus.py` | 設計 PR の詳細レビューの観点に 1 項目足す(決定 5) | +| `plugins/ndf/skills/cross-review/docs/04-contracts.md` | 自動レビュー観点の `design` の要約に同じ項目を足す | +| `plugins/ndf/contracts/entrypoints/plugins__ndf__scripts__design-survey.py.json`(新規)と `plugins/ndf/contracts/index.md` | エントリポイントの契約(`check-ndf-contracts.py --draft` で起こし、`--render` で逆引きを作り直す) | +| `plugins/ndf/scripts/tests/test_design_survey.py`(新規) | 検査の振る舞いのテスト | +| `plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/1-design-16.json` | 設計のプランの期待値に 3 ステップを足す | +| `docs/glossary/glossary.json` / `docs/glossary.md` | 用語の表の 5 語を反映する | + +```mermaid +graph TD + subgraph 手順とテンプレート + SK[design の SKILL.md] + ES[external-survey.md] + TP[design-template.md
decisions.md] + end + subgraph 検査 + DS[design-survey.py] + DD[lib/design_docs.py] + DM[design-match.py] + end + subgraph 設計のプランとレビュー + SW[sprint_waves.py] + RF[review_focus.py] + end + SK --> ES + SK --> TP + SK -->|手順 5 で打つ| DS + SW -->|survey-check| DS + DS --> DD + DM --> DD + RF -->|観点| TP +``` + +図には、呼び出しと参照の関係を持つ要素だけを描く。`deliverables.md`・`paths.py`・`04-contracts.md`・エントリポイントの契約・テストと期待値・用語集は、図の要素の定数・説明・記録で、呼び出しの辺を持たないため描かない。 + +## 入出力の契約 + +### `design-survey.py check` + +| 項目 | 書くこと | +| --- | --- | +| 名前 | `python3 design-survey.py check (--issue N... \| --file PATH...) [--root DIR]` | +| 入力 | `--issue`: 課題の番号(1 つ以上)。HEAD の `issues/` から、名前の番号の並びにその番号を含む `issue-<番号>-design*.md` と `issue-<番号>-requirements.md` を読む。`--file`: 読む文書のパス(worktree から読む。`light` の受け入れ条件のファイルや `operation` の実行の記録を渡す)。`--root`: リポジトリの根(既定はカレントの git の根) | +| 出力 | `lib/step_result.py` の形の 1 行の JSON(`tool: "design-survey"`)。`items[]` は 1 指摘 1 件で、`name` は `<パス>:<行> 決定 `、`rule` は `no_candidates` / `unknown_candidate` / `unknown_method`、`text` は当たった行。`metrics` は読んだ文書の数・決定の数・作り方の行を持つ決定の数・自前で作る決定の数・外部の調査を省いた文書の数 | +| 失敗の形 | 0 = 指摘なし(作り方の行が 0 件の文書を含む)/ 1 = 指摘あり / 2 = 文書か git を読めない / 3 = `--issue` で文書が 1 つも見つからない | +| 互換性 | 新しいエントリポイントで、既存の呼び出し側は無い。作り方の行を持たない既存の設計文書は 0 になる(受け入れ条件 10) | + +**判定の規則**(コードブロックの中は読まない): + +| 規則 | 読む場所 | 当たる条件 | +| --- | --- | --- | +| 決定 | 見出しの文が `決定 <数字>` で始まる見出しから、同じか上の階層の次の見出しまで | — | +| 外部の調査の節 | 見出しの文が `外部の調査` の見出しから、同じか上の階層の次の見出しまで(小見出しを含む)。読んだ文書すべての節を合わせる | — | +| 省いた文書 | 外部の調査の節に `省く:` で始まる行がある | — | +| 候補の番号 | 外部の調査の節の表の 1 列目が `STD<数>` / `PRD<数>` / `LIB<数>` の行 | — | +| `unknown_method` | 決定の中の `作り方:` の行 | 値を `・` か `/` で分けた語のどれかが 3 つの語に無い | +| `no_candidates` | 作り方に `自前で作る` を含む決定 | `比べた候補:` の行に候補の番号が 1 つも無く、外部の調査を省いた文書でもない | +| `unknown_candidate` | 決定の中の `比べた候補:` の行 | 並べた番号が候補の番号のどれにも無い | + +行の頭の `- ` と、`:` の代わりの全角の `:` も受ける。MVV の宣言と本文は読まない(受け入れ条件 9)。 + +### 設計のプランのステップ + +`requirements-recheck` の次、`glossary-check` の前に置く。 + +| ステップ | 種類 | 中身 | 失敗したとき | +| --- | --- | --- | --- | +| `survey-check` | run | `design-survey.py check --issue <番号>` | `fix-survey` へ | +| `fix-survey` | work(修正) | 指摘の行に作り方・比べた候補を直すか、外部の調査の表へ候補の行を足してコミットする(push しない)。比べずに直せない指摘は直さずに「判断が要る」と報告する | — | +| `survey-recheck` | run | `survey-check` と同じコマンド | 次へ進まずプランが止まる(`on_fail` を置かない) | + +push は後の `push-glossary` が行う。 + +## 処理の流れ + +```mermaid +graph TD + A[手順 1
触る領域を決める] --> B{省ける条件に
当たるか} + B -->|当たる| C[外部の調査の節に
省く: 理由] + B -->|当たらない・
決められない| D[3 種類ごとに
調べるかを決める] + D --> E[一次情報を取得し
出典つきで表に残す] + E --> F{取得できたか
上限の内か} + F -->|できない・上限| G[確かめられなかった
ことへ書く] + F -->|できた| H[手順 3・4
設計を書き、決定に
作り方と比べた候補] + G --> H + C --> H + H --> I[手順 5
design-survey.py check] + I -->|0| J[手順 6
設計 PR] + I -->|1| H +``` + +スプリントの設計のプランでは、レビューの後にもう一度検査を通す。 + +```mermaid +graph TD + R[review
sync-review
requirements-recheck] --> S{survey-check} + S -->|0| G[glossary-check 以降] + S -->|0 以外| F[fix-survey] + F --> T{survey-recheck} + T -->|0| G + T -->|0 以外| X[プランが止まり
conductor が人へ戻す] +``` + +## 決定の記録 + +### 決定 1: 調べた結果を設計の判断より先に持つため、外部の調査を `design` の手順 2 に置き、以後の手順を繰り下げる + +外部の調査は触る領域(手順 1)から対象を決め、その結果が設計文書の中身(手順 3)と決定(手順 4)の材料になる。独立した手順にすると、省くか通すかの判断と上限を 1 か所で読める。手順の番号を参照しているのは `design` の SKILL.md の中の 4 か所だけである。`plugins/` と `docs/specifications/` を「design の手順 + 数字」の正規表現で検索すると、ほかの文書からの参照は手順 0 を指す 1 件(`docs/specifications/ndf-ubiquitous-language.md`)だけだった。 + +手順 1 の中へ足す形は採らない。手順 1 は「どの参照を読むか」を決める手順で、一次情報の取得と表の作成は性質が違う。`1a` の番号で足す形も採らない。番号の参照が SKILL.md の中に閉じているため、繰り下げの費用が小さい。 + +根拠: Value 7 / Value 8(MVV 版 5) + +### 決定 2: 調査が設計の所要を際限なく延ばさないよう、上限を種類ごとの候補 6 件と取得の試み 30 回にし、`external-survey.md` の 1 か所に置く + +候補の数は、carul #102 の 2 回目の設計が出典つきで比べ切った量(ライブラリ 6・製品 6)に合わせる。取得の試み(検索 1 回・URL の取得 1 回をそれぞれ 1 と数える)は、3 種類 × 6 件の候補にそれぞれ 1 回余りの取得を足した量で、設計のステップの時間の上限(3,600 秒)の内に収める。外部の調査の節の最後に「調べた量」の 1 行を残し、設計文書を数えれば上限が足りたかを実測できるようにする。値の見直しはこの参照の 1 か所を直す。 + +時間を上限にする形は採らない。担い手が経過の秒を測る手段がランタイムごとに違い、Claude Code と Codex で同じに働かない。プロジェクトの設定で上限を変える形も採らない。プロジェクトごとに変える理由がまだ実測で示されていない。 + +根拠: Value 3 / Value 4 / Value 6(MVV 版 5) + +### 決定 3: 通したか省いたかを 1 か所で読めるよう、外部の調査の節は通しても省いても必ず作り、省くときは `省く: <理由>` の 1 行だけを置く + +省いた理由を仕様の「対象範囲(含まない)」に書く既存の形(`deliverables.md` の「省いた理由の書き先」)は、成果物を書かない理由の置き場である。外部の調査は成果物ではなく手順で、省く判断は設計者が設計の書き先で行う(受け入れ条件 6)。節を必ず作れば、レビューと検査は節を見るだけで、通したか省いたかが分かる。要否表では全モード「必須」とし、省いたときも節が残る。 + +根拠: Value 2 / Value 8(MVV 版 5) + +### 決定 4: 自前で作る決定を機械が見分けられるよう、決定の中に `作り方:` と `比べた候補:` の 2 行を置き、候補を外部の調査の表の番号で指す + +2 行は箇条書き(`- 作り方: …` / `- 比べた候補: …`)で、決定の理由の段落の後に置く。`作り方:` の値は「自前で作る」「ライブラリやサービスを使う」「標準に倣う」の 3 語とその組み合わせ(`・` で並べる)に限る。標準を自前で実装する決定は「自前で作る・標準に倣う」と書き、検査の対象になる。比べた候補は `STD<数>` / `PRD<数>` / `LIB<数>` の番号で書き、表の行へ解決できることを検査が確かめる。番号の頭を種類ごとに分けるのは、要求の起票者の推定(`S1`…)・不変条件(`I1`…)・イベント(`E1`…)と取り違えないためである。2 つの欄を分ける形は STD1(MADR)に倣う。 + +決定の本文の語(「自前で」「独自に」など)から機械で推定する形は採らない。語の揺れで漏れと誤検知が両方出て、既存の設計文書でも当たる(受け入れ条件 10 を満たせない)。作り方の行はすべての決定に求めず、機能をどう得るかを選んだ決定にだけ書く。設計や手順の選び方の決定(この文書の決定 1 など)に書くと、意味の無い行が増える。 + +根拠: Value 5 / Value 8(MVV 版 5) + +### 決定 5: 受け入れ条件 8 の指摘は、機械の検査と設計 PR のレビューの観点の両方で行う + +機械の検査は、作り方の行を持つ決定に対して I5〜I7 を確実に見る。作り方の行を書かずに自前で作ると決めた設計と、外部の調査の節の欠け・出典の欠け(I1〜I4)は、文の意味を読まないと分からないため、設計 PR の詳細レビューの観点に 1 項目足す。機械の検査だけにすると、行を書かなければ通る。レビューだけにすると、ラウンドの上限(設計は 3)で指摘が残ったまま承認ゲート 1 へ渡りうる。 + +足す観点の中身: 外部の調査の節があるか(通すなら 3 種類ごとの採否と調べない理由、省くなら理由 1 行)。調べた行に出典と取得日があるか。取得できなかった対象を推定で埋めていないか。既存のライブラリ・サービス・標準を使わずに作ると決めた決定に、作り方の行と比べた候補があるか。 + +根拠: Value 5 / Value 1(MVV 版 5) + +### 決定 6: 検査は新しいエントリポイント `design-survey.py` にし、設計文書の探し方を `design-match.py` と `lib/design_docs.py` で共有する + +`design-match.py` は完了判定で設計と実装を突き合わせる検査で、打つ時期(実装の後)と読み手(`quality-gates`)が違う。副命令として足すと、1 つのエントリポイントが 2 つの工程の契約を持つ。一方、課題の番号から HEAD の設計文書を探す処理は同じ役割なので、`lib/design_docs.py` へ移して 2 つから読む。Markdown の読み方は LIB1 を包む `lib/md.py` を使い、自前で書かない。 + +- 作り方: ライブラリやサービスを使う +- 比べた候補: LIB1 + +`glossary.py` や `pr-body-decisions.sh` へ足す形は採らない。前者は語の検査、後者は PR の本文と見出しの突き合わせで、責務が違う。 + +根拠: Value 7 / Value 5(MVV 版 5) + +### 決定 7: 検査はスプリントの設計のプランと `design` の手順 5 から打ち、継続的統合には置かない + +設計のプランのステップと Skill の手順は、NDF を使うすべてのリポジトリで同じに通る。継続的統合のワークフローはこのリポジトリにしか効かず、ほかのリポジトリには届かない。このリポジトリの設計 PR も設計のプランを通るため、継続的統合に重ねても新しく捕まえるものが無い。プランでは、レビューの直しで決定が変わった後に確かめるため、`requirements-recheck` の後に置く。直しは 1 回だけ試み(`fix-survey`)、残れば止める。同じ指摘のラウンドを重ねない。 + +継続的統合の必須のチェックにする形は採らない。ruleset の変更が要り、ほかのリポジトリでは働かない。 + +根拠: Value 6 / Value 3(MVV 版 5) + +### 決定 8: 調査の結果の形は、棚卸しのシーズの調査と出典の項目だけ揃え、記録の置き場と形式は揃えない + +揃えるのは、出典を URL・パス・コマンドのどれかと取得日で書くことである(シーズの調査の記録は `url` / `path` / `command` と調べた日時を持つ)。外部の調査はシーズの調査の記録(`.ndf/backlog/research/`)に同じ対象があれば、その記録のパスと調べた日時を出典として引いてよい。設計文書は承認する人が読む Markdown で、シーズの調査は機械が積む JSON であり、読み手が違う。置き場と形式を揃えると、設計の工程が棚卸しの記録の形に縛られる。 + +根拠: Value 7 / Value 8(MVV 版 5) + +### 決定 9: `legacy-refactor` でも外部の調査を通すため、このモードの設計文書を 4 節(現状の構造・変更後の構造・外部の調査・決定の記録)にする + +自前の実装をライブラリへ置き換えるリファクタリングは、外部の調査が最も効く変更の 1 つである。`deliverables.md` は「`legacy-refactor` の無条件の成果物は 3 節に収まるものに限る」としているため、節を足すと要否表とテンプレートが食い違う。テンプレートと段落の両方を 4 節に直す。振る舞いを変えないリファクタリングの多くは「既存の形の延長」として省け、そのときの費用は 1 行である。 + +根拠: Value 6 / Value 7(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| I5・受け入れ条件 8(指摘する例) | 外部の調査の表を持ち、作り方が「自前で作る」で比べた候補の行が無い決定を持つ文書に、`check` が 1 と `no_candidates` を返す | 作り方の行を読まないように壊すと 0 になり落ちる | +| I5・受け入れ条件 8(指摘しない例) | 比べた候補に表の番号を並べた決定と、`省く:` の行を持つ文書の自前で作る決定に、`check` が 0 を返す | 省いた文書の判定を外すと 1 になり落ちる | +| I6 | 表に無い番号を並べた決定に `unknown_candidate` を返す。番号が `-design-decisions.md` にあり表が `-design.md` にある分けた文書でも解決する | 節を文書ごとに閉じて読むと、分けた文書で番号が解決せずに当たり、落ちる | +| I7 | 3 語に無い値(「自前でつくる」)に `unknown_method` を返す。「自前で作る・標準に倣う」は自前で作る決定として扱う | 値を分けずに完全一致で比べると、組み合わせで落ちる | +| コードブロックの扱い | コードブロックの中の `作り方: 自前で作る` には当たらない | コードブロックを読むと、テンプレートの例を含む文書で落ちる | +| 受け入れ条件 10 | このリポジトリの `issues/` にある既存の設計文書と受け入れ条件のファイルすべてに、`check --file` が 0 を返す | 作り方の行が無い文書を違反にすると落ちる | +| 受け入れ条件 9 | `.ndf/` を持たないリポジトリで `check` が同じ結果を返す | MVV の宣言を読むようにすると、宣言の無い場で 2 になり落ちる | +| 終了コード 3 | `--issue` に文書の無い番号を渡すと 3 を返す | 0 を返すと、設計のプランが文書の無いまま通り落ちる | +| `lib/design_docs.py` への移動 | 既存の `design-match.py` のテストが変えずに通る | 探し方の規則を変えると既存のテストが落ちる | +| 設計のプラン(決定 7) | 設計のプランの期待値(`1-design-16.json`)に、3 ステップが `requirements-recheck` と `glossary-check` の間にあり、`survey-recheck` が `on_fail` を持たない | 順序か `on_fail` を変えると契約のテストが落ちる | +| I1〜I4・受け入れ条件 1〜7 | 手順とテンプレートの文。文言のテストは書かない(`AGENTS.md` の DON'T)。設計 PR のレビューの観点(決定 5)と、リリース後の手動確認で見る | — | +| 受け入れ条件 11 | `python3 scripts/check-skill-frontmatter.py`・`claude plugin validate .`・全体テスト・`python3 scripts/check-ndf-contracts.py` が通り、`design-template.md` が 500 行以内 | — | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2519 | 実装する | — | `plugins/ndf/skills/design/`、`plugins/ndf/scripts/design-survey.py`、`plugins/ndf/scripts/lib/design_docs.py`、`plugins/ndf/scripts/design-match.py`、`plugins/ndf/scripts/supervise_lib/paths.py`、`plugins/ndf/scripts/supervise_lib/sprint_waves.py`、`plugins/ndf/scripts/tests/`、`plugins/ndf/skills/cross-review/scripts/review_lib/review_focus.py`、`plugins/ndf/skills/cross-review/docs/04-contracts.md`、`plugins/ndf/contracts/`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 上限の値が足りるか | 候補 6 件・取得の試み 30 回は carul の 1 例から決めた。リリース後に NDF の外のプロジェクトで `design` を通し、設計文書の「調べた量」の行を数えて見直す | +| 作り方の行の書き漏れ | 作り方の行を書かずに自前で作る決定は、検査に掛からずレビューだけが拾う。漏れの件数はリリース後の設計 PR のレビューの指摘で数える | +| 検査の既存の Tool | 外部の調査の「確かめられなかったこと」の 1 行。実装で `lib/md.py` の上に書く規模が数十行を大きく超えるなら、既存の Tool を探し直す | +| 取得の手段がランタイムで違うこと | 手順は Web 検索・URL の取得・`gh` のどれかを使うとだけ書く。Codex で `design` を通したときに取得できる範囲は未確認で、リリース後の手動確認で見る | From ec7c59116f99b294671da2be6df48a7accbbc4b0 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:13:05 +0000 Subject: [PATCH 03/43] =?UTF-8?q?Docs:=20#2519=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=82=92=202=20=E3=81=A4=E3=81=AE=E5=B0=8F=E8=A6=8B?= =?UTF-8?q?=E5=87=BA=E3=81=97=E3=81=AB=E5=88=86=E3=81=91=E3=82=8B=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 内容は足していない。平均文長 38.7→36.9 字、最長文 139→92 字。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2519-design.md | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/issues/issue-2519-design.md b/issues/issue-2519-design.md index 4506b4ad8..c50766ed7 100644 --- a/issues/issue-2519-design.md +++ b/issues/issue-2519-design.md @@ -118,6 +118,8 @@ ## 構成要素 +### 要素ごとの責務 + | 要素 | 責務 | | --- | --- | | `plugins/ndf/skills/design/SKILL.md` | 手順 2「外部の調査を通す」を足し、以後の手順の番号を 1 つずつ繰り下げる(決定 1)。「すべての変更」の行に `external-survey.md` を足す。手順 5(突き合わせ)で検査のコマンドを打つ | @@ -137,6 +139,8 @@ | `plugins/ndf/scripts/tests/fixtures/supervise-contract/plans/sprint/1-design-16.json` | 設計のプランの期待値に 3 ステップを足す | | `docs/glossary/glossary.json` / `docs/glossary.md` | 用語の表の 5 語を反映する | +### 呼び出しと参照の関係 + ```mermaid graph TD subgraph 手順とテンプレート @@ -162,7 +166,7 @@ graph TD RF -->|観点| TP ``` -図には、呼び出しと参照の関係を持つ要素だけを描く。`deliverables.md`・`paths.py`・`04-contracts.md`・エントリポイントの契約・テストと期待値・用語集は、図の要素の定数・説明・記録で、呼び出しの辺を持たないため描かない。 +図には、呼び出しと参照の関係を持つ要素だけを描く。`deliverables.md`・`paths.py`・`04-contracts.md`・エントリポイントの契約・テストと期待値・用語集は描かない。これらは図の要素の定数・説明・記録で、呼び出しの辺を持たない。 ## 入出力の契約 @@ -236,7 +240,7 @@ graph TD ### 決定 1: 調べた結果を設計の判断より先に持つため、外部の調査を `design` の手順 2 に置き、以後の手順を繰り下げる -外部の調査は触る領域(手順 1)から対象を決め、その結果が設計文書の中身(手順 3)と決定(手順 4)の材料になる。独立した手順にすると、省くか通すかの判断と上限を 1 か所で読める。手順の番号を参照しているのは `design` の SKILL.md の中の 4 か所だけである。`plugins/` と `docs/specifications/` を「design の手順 + 数字」の正規表現で検索すると、ほかの文書からの参照は手順 0 を指す 1 件(`docs/specifications/ndf-ubiquitous-language.md`)だけだった。 +外部の調査は触る領域(手順 1)から対象を決め、その結果が設計文書の中身(手順 3)と決定(手順 4)の材料になる。独立した手順にすると、省くか通すかの判断と上限を 1 か所で読める。手順の番号を参照しているのは `design` の SKILL.md の中の 4 か所だけである。`plugins/` と `docs/specifications/` を「design の手順 + 数字」の正規表現で検索した。ほかの文書からの参照は 1 件(`docs/specifications/ndf-ubiquitous-language.md`)だけで、手順 0 を指す。 手順 1 の中へ足す形は採らない。手順 1 は「どの参照を読むか」を決める手順で、一次情報の取得と表の作成は性質が違う。`1a` の番号で足す形も採らない。番号の参照が SKILL.md の中に閉じているため、繰り下げの費用が小さい。 @@ -244,7 +248,7 @@ graph TD ### 決定 2: 調査が設計の所要を際限なく延ばさないよう、上限を種類ごとの候補 6 件と取得の試み 30 回にし、`external-survey.md` の 1 か所に置く -候補の数は、carul #102 の 2 回目の設計が出典つきで比べ切った量(ライブラリ 6・製品 6)に合わせる。取得の試み(検索 1 回・URL の取得 1 回をそれぞれ 1 と数える)は、3 種類 × 6 件の候補にそれぞれ 1 回余りの取得を足した量で、設計のステップの時間の上限(3,600 秒)の内に収める。外部の調査の節の最後に「調べた量」の 1 行を残し、設計文書を数えれば上限が足りたかを実測できるようにする。値の見直しはこの参照の 1 か所を直す。 +候補の数は、carul #102 の 2 回目の設計が出典つきで比べ切った量(ライブラリ 6・製品 6)に合わせる。取得の試みは、検索 1 回・URL の取得 1 回をそれぞれ 1 と数える。試みの上限は、3 種類 × 6 件の候補にそれぞれ 1 回余りの取得を足した量で、設計のステップの時間の上限(3,600 秒)の内に収める。外部の調査の節の最後に「調べた量」の 1 行を残し、設計文書を数えれば上限が足りたかを実測できるようにする。値の見直しはこの参照の 1 か所を直す。 時間を上限にする形は採らない。担い手が経過の秒を測る手段がランタイムごとに違い、Claude Code と Codex で同じに働かない。プロジェクトの設定で上限を変える形も採らない。プロジェクトごとに変える理由がまだ実測で示されていない。 @@ -266,7 +270,7 @@ graph TD ### 決定 5: 受け入れ条件 8 の指摘は、機械の検査と設計 PR のレビューの観点の両方で行う -機械の検査は、作り方の行を持つ決定に対して I5〜I7 を確実に見る。作り方の行を書かずに自前で作ると決めた設計と、外部の調査の節の欠け・出典の欠け(I1〜I4)は、文の意味を読まないと分からないため、設計 PR の詳細レビューの観点に 1 項目足す。機械の検査だけにすると、行を書かなければ通る。レビューだけにすると、ラウンドの上限(設計は 3)で指摘が残ったまま承認ゲート 1 へ渡りうる。 +機械の検査は、作り方の行を持つ決定に対して I5〜I7 を確実に見る。作り方の行を書かずに自前で作ると決めた設計と、外部の調査の節の欠け・出典の欠け(I1〜I4)は、文の意味を読まないと分からない。そのため、設計 PR の詳細レビューの観点に 1 項目足す。機械の検査だけにすると、行を書かなければ通る。レビューだけにすると、ラウンドの上限(設計は 3)で指摘が残ったまま承認ゲート 1 へ渡りうる。 足す観点の中身: 外部の調査の節があるか(通すなら 3 種類ごとの採否と調べない理由、省くなら理由 1 行)。調べた行に出典と取得日があるか。取得できなかった対象を推定で埋めていないか。既存のライブラリ・サービス・標準を使わずに作ると決めた決定に、作り方の行と比べた候補があるか。 @@ -299,7 +303,7 @@ graph TD ### 決定 9: `legacy-refactor` でも外部の調査を通すため、このモードの設計文書を 4 節(現状の構造・変更後の構造・外部の調査・決定の記録)にする -自前の実装をライブラリへ置き換えるリファクタリングは、外部の調査が最も効く変更の 1 つである。`deliverables.md` は「`legacy-refactor` の無条件の成果物は 3 節に収まるものに限る」としているため、節を足すと要否表とテンプレートが食い違う。テンプレートと段落の両方を 4 節に直す。振る舞いを変えないリファクタリングの多くは「既存の形の延長」として省け、そのときの費用は 1 行である。 +自前の実装をライブラリへ置き換えるリファクタリングは、外部の調査が最も効く変更の 1 つである。`deliverables.md` は「`legacy-refactor` の無条件の成果物は 3 節に収まるものに限る」としている。節を足すと、要否表とテンプレートが食い違う。テンプレートと段落の両方を 4 節に直す。振る舞いを変えないリファクタリングの多くは「既存の形の延長」として省け、そのときの費用は 1 行である。 根拠: Value 6 / Value 7(MVV 版 5) From 247f933d65ad22639cb3122c5db19042f95b7bb9 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:13:07 +0000 Subject: [PATCH 04/43] =?UTF-8?q?Docs:=20#2346=20=E3=81=AE=E8=A6=81?= =?UTF-8?q?=E6=B1=82=E3=81=AB=E3=80=81tmux=20=E3=81=AE=E7=B5=90=E5=90=88?= =?UTF-8?q?=E3=83=86=E3=82=B9=E3=83=88=E3=82=92=E6=AF=8E=E5=9B=9E=E8=B5=B0?= =?UTF-8?q?=E3=82=89=E3=81=9B=E3=82=8B=E5=8F=97=E3=81=91=E5=85=A5=E3=82=8C?= =?UTF-8?q?=E6=9D=A1=E4=BB=B6=2028=E3=80=9C32=20=E3=82=92=E8=B6=B3?= =?UTF-8?q?=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 2026-11-04 に記録された結合テストの失敗は、2026-11-05 に develop の先頭とタグ ndf--v10.27.14 のどちらでも再現しない(単独 5 回・ファイル 48 件・全体 5,780 件が通過)。 tmux を使う 4 件は tmux の無い環境で黙って飛ばされるため、根の conftest.py の外部コマンドの 一覧で失敗にし、CI で走らせることを要求にした。課題の本文の写しを issues/ に置く。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2346-requirements.md | 291 ++++++++++++++++++++++++++++++ 1 file changed, 291 insertions(+) create mode 100644 issues/issue-2346-requirements.md diff --git a/issues/issue-2346-requirements.md b/issues/issue-2346-requirements.md new file mode 100644 index 000000000..a7f68a11a --- /dev/null +++ b/issues/issue-2346-requirements.md @@ -0,0 +1,291 @@ +# #2346: relay.py が同じコンテナの複数の claude セッションを統括する(2 本目以降はホストが起動し tmux で端末を結び付ける) + +正は課題の本文(#2346)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 現状(2026-11-05、origin/develop e6af6e569) + +- 統括役(2 本目以降の `claude` を tmux の window で起動し、`relay.py ls` で一覧し、名前で止める)は ndf v10.27.14 で配布した。仕様は確定仕様 https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-relay-multi-session-supervisor.md にある +- 受け入れ条件 9 の状態の語 `running` / `awaiting_approval` / `stopped` は、実装(`plugins/ndf/scripts/relay_lib/hub_client.py`)と確定仕様に入っている。`relay.py ls --json` の各行は name・state・awaiting_approval・sections・cost_usd・at・account を持つ +- 受け入れ条件 25 の欠け(環境変数 `NDF_RELAY_HUB_WAIT`・`NDF_RELAY_STOP_WAIT` と `hub/` の受け渡しのファイル、副命令の契約)は #2434(relay.py の統括役の環境変数と hub/ の受け渡しのファイルが契約の宣言に無い)が持つ +- 2026-11-04 に、受け入れ条件 3・8 を確かめる結合テスト `plugins/ndf/scripts/tests/test_relay_hub.py::test_spawn_ls_stop_and_hub_handover` が開発コンテナ(tmux 3.6)で「待ちが切れた: lroot の区間 2」で落ちたと記録された。**2026-11-05 の実測では再現しない**(下の表)。落ちた原因は未確認である +- tmux を使う結合テスト 4 件は `needs_tmux`(`pytest.mark.skipif(shutil.which("tmux") is None)`)で、tmux が無い実行環境では黙って飛ばされる。リポジトリの根の `conftest.py` には、テストの置き場所ごとに要る外部コマンドの一覧(`REQUIRED_COMMANDS`)があり、欠けると既定で失敗にする(読み飛ばすのは `NDF_TESTS_ALLOW_MISSING_COMMANDS` を付けたときだけ)。tmux はこの一覧に無い +- CI のテストのジョブ(`.github/workflows/pytest.yml` の分割の 2 本、`ubuntu-latest`)に tmux を入れる手順は無い(`grep -n tmux .github/workflows/*.yml` が 0 件)。runner に tmux が入っているか、4 件が CI で走ったかは未確認(実行 41945018391 の分割のジョブのログを `gh run view --job <番号> --log` で取ろうとしたが、件数の行を含むログが得られなかった) + +| 実行(2026-11-05) | 対象 | 結果 | +| --- | --- | --- | +| `pytest plugins/ndf/scripts/tests/test_relay_hub.py -k test_spawn_ls_stop_and_hub_handover` を 5 回続けて | develop の先頭 e6af6e569 | 5 回とも通過(1 回 3.6 秒前後) | +| 同じテストを、環境変数 `TMUX` を立てた(tmux の中から打った)状態で | 同上 | 通過 | +| `pytest plugins/ndf/scripts/tests/test_relay_hub.py -n 4` | 同上 | 48 件通過 | +| `pytest plugins/ndf/scripts/tests -n 4` | 同上 | 5,780 件通過・4 件飛ばし(8 分 39 秒) | +| 同じテストを 1 回 | タグ `ndf--v10.27.14` | 通過 | + +## 具体例 + +2026-11-01、利用者は 3 本の開発ループを並列で回すと決めた(Factory の `l20261031`・根本解決の `lroot`・開発フローの見直しの `lflow`。引継ぎ文書 `.ndf/handoff/loop-*.md`)。今のラッパー(`relay.py`)では、利用者が端末を 3 つ開き、それぞれで `claude` を打ち、文書の `ndf-next` のコマンドを手で貼る必要がある。 + +- 3 本のどれが動いていて、どれが止まり、どれが承認待ちかを一目で見る手段が無い +- `relay.py stop` は 3 本すべてを止める。1 本だけを止められない +- 端末を閉じるとそのラッパーが終わる +- conductor(m26 を回しているセッション)が「3 本のループを始める」と決めても、自分では起動できない +- 3 本が同じアカウントの 5 時間の枠を 3 倍の速さで使うが、枠を見るのは区間ごとのラッパーで、3 本をまとめて見ていない + +この課題が終わると、2 本目の `claude` を打てばその端末が tmux の window の中の新しいラッパーへ結び付き、`relay.py ls` で 3 本の状態と承認待ちが 1 つの表で見え、`relay.py stop lroot` で 1 本だけを止められる。 + +## 依頼(原文) + +> 現状はclaude コマンドを実行するとrelay.pyが立ち上がり、それを経由してclaudeが立ち上がります。これを、同じコンテナ内の1本目のclaudeはこのまま、2本目の場合、1本目のrelay.pyが2本目のclaudeを立ち上げ、screen(tmux)には2本目のclaudeに結び付ける、3本目以降も同様、とかいう形にできないでしょうか。 + +> つまり、relay.pyが複数セッションのclaudeを統括してコントロールできるようにするイメージです + +(書き手: 利用者、日付: 2026-11-01。起票者が課題の「由来」の節に原文として写したもの) + +> **決定(2026-11-01、利用者)**: 案 B を採る。tmux を親にし、セッションごとに今の `relay.py run` を 1 つの window で動かす。ホストは登録・起動・方針・一覧だけを持つ。設計の工程はこの前提で進め、案 A は比べ直さない。 + +(書き手: 起票者、日付: 2026-11-01。利用者の決定を起票者が記録した文で、利用者の原文ではない。起票者の推定 S1 として承認ゲート 1 で確かめる) + +## 起票者の推定 + +題・本文のうち、上の利用者の原文に無く起票者が推して足した条件。承認ゲート 1 で利用者が確かめるまで、利用者の決定として扱わない。 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S1 | 案 B(tmux を親にし、ラッパーごとに今の `relay.py run` を 1 つの window で動かす。統括役は登録・起動・方針・一覧だけを持つ)を採り、案 A(統括役が全 claude の擬似端末の親になる)は比べ直さない | 課題の本文「設計の候補」(起票者が利用者の決定として記録) | 前提 1 | +| S2 | conductor の応答(`ndf-spawn` のブロックなど)から名前つきのラッパーを起動できる | 課題の本文「あるべき姿」の「起動」の行 | 受け入れ条件 5 | +| S3 | 副命令 `relay.py spawn --name <名前> -- <最初の入力>` で端末の無いラッパーを起動でき、後から `relay.py attach <名前>` で結び付けられる | 同上 | 受け入れ条件 4・6 | +| S4 | 端末を閉じてもラッパーは動き続け、カットポイントの再起動(`ndf-next`)も続く | 課題の本文「あるべき姿」の「端末の結び付け」の行 | 受け入れ条件 7・8 | +| S5 | `relay.py ls` がラッパーごとの名前・状態・承認待ちの有無・区間の数・使った費用・最後に動いた時刻を出す | 課題の本文「あるべき姿」の「一覧」の行 | 受け入れ条件 9・10(費用は取れないとき空の値。前提 5) | +| S6 | `relay.py stop <名前>`・`restart <名前>`・`attach <名前>` を持ち、名前を付けない `stop` は今どおり全部を止める | 課題の本文「あるべき姿」の「個別の操作」の行 | 受け入れ条件 11〜14 | +| S7 | 同時に動かすラッパーの数の上限(既定 3)を宣言で持ち、超える起動を理由つきで断る | 課題の本文「あるべき姿」の「共有の方針」の行 | 受け入れ条件 15・16 | +| S8 | 同時に流すプランの合計の上限(例 6)を、ラッパーをまたいで持つ | 同上 | 未決 1 | +| S9 | アカウントの枠と起動の数の上限を、ラッパーをまたいで数える | 同上 | 受け入れ条件 17・18 | +| S10 | どのラッパーが承認ゲートで待っているかを一覧と通知に出す(端末の結び付いていないラッパーの承認を取りこぼさない) | 課題の本文「あるべき姿」の「承認待ちの知らせ」の行 | 受け入れ条件 10・19 | +| S11 | 1 本のラッパーが落ちてもほかと統括役は動き続け、統括役が落ちても動いているラッパーは止まらず、次の `claude` が新しい統括役になる | 課題の本文の旧受け入れ条件 8 | 受け入れ条件 20〜22 | +| S12 | tmux の無い環境では今の 1 本ずつの起動に戻り、そのことを 1 行で知らせる | 課題の本文の旧受け入れ条件 9 | 受け入れ条件 23 | +| S13 | 新しいコードは共通部品(`lib/step_result.py`・`lib/locks.py`・`lib/procs.py`・`lib/run_state.py`)を通し、#2335(異常系・ログ・回復を 1 つの実行の核へ集める大方針)の契約(#2336 入口・受け渡しのファイル・設定・環境変数の契約の宣言)の対象に入る | 課題の本文の旧受け入れ条件 10 | 受け入れ条件 24・25 | +| S14 | 起動・一覧・停止の入出力を、Factory の契約(`factory/contracts/` の実行ジョブの指示と進捗の事象)と同じ語と形で持つ。コードは参照しない | 課題の本文「Neural Development Factory との関係」 | 受け入れ条件 26・27 | +| S15 | `relay.py ls` の出力を 1 行の JSON(統一 JSON レポート)と人の読む表の 2 つにする | 課題の本文の旧受け入れ条件 3 と、2026-10-31 の利用者の方針(統一 JSON レポート) | 受け入れ条件 9 | +| S16 | 残りの作業は「統括役の結合テスト」である | 引継ぎ文書 `.ndf/handoff/notice-version-lfactory2.md` の 2026-11-05 09:58 JST の行(AI の起動指示。ループ lflow2 の周回 6 の版の予約)と、本文の「現状」の節(起票者、2026-11-04) | 受け入れ条件 28〜32 | +| S17 | tmux を使う結合テストが、tmux の無い環境で黙って飛ばされないようにする | 本文の「現状」の節の「CI は tmux を入れていないため、この結合テストは `needs_tmux` で飛ばされうる」(起票者、2026-11-04) | 受け入れ条件 28・29 | +| S18 | 2026-11-04 の失敗の原因を突き止めて直す | 本文の「現状」の節の失敗の記録(起票者、2026-11-04) | 採らない。2026-11-05 に再現しないため(前提 10)。代わりに、再び落ちたときに原因を読める出力を受け入れ条件 31 に置く | + +## 目的 + +- 同じコンテナで複数の開発ループを並列に回すとき、利用者が端末を開いてコマンドを貼る手間をなくし、どのループが動き・止まり・承認待ちかを 1 か所で見て、1 本ずつ止め・やり直し・結び付け直せる状態にする +- conductor が自分の判断で別の開発ループを起動できるようにする(人の手を止めない。Value 2) +- 受け入れ条件 3・8(端末の結び付いていない名前つきのラッパーでも `ndf-next` で次の区間が起動する)を確かめる結合テストが、手元と CI のどちらでも毎回走り、飛ばされたまま通過と数えられない状態にする(確かめていないものを済んだと記録しない。Value 1) + +## 前提 + +- 前提 1: 構成は案 B に従う。tmux がラッパーごとの端末の親になり、各 window で今の `relay.py run` を動かす。統括役は登録・起動・方針・一覧だけを持ち、各ラッパーの状態機械(区間の切り替え・`ndf-next` の拾い方・質問シグナルファイル)を変えない(S1。承認ゲート 1 で確かめる) +- 前提 2: 1 本目の `claude` は今どおり利用者の端末の前景で動き、tmux の中へ移さない。1 本目の端末を閉じれば 1 本目のラッパーは今どおり終わる。統括役の役目は、そのとき受け入れ条件 21 に従って次の `claude` へ移る +- 前提 3: 端末から打った 2 本目以降の `claude` に名前の指定が無いときは、統括役が重ならない名前を自動で付ける。名前の形は設計で決める +- 前提 4: 利用者のシェルの設定(`claude` の alias・関数)を書き換える手段は、今の `relay.py install` が置いたものの範囲に留める。新しく利用者のシェルの設定へ書き込む必要が出たら、設計で止めて人へ戻す(共通原則 C6) +- 前提 5: 区間の数・最後に動いた時刻は各ラッパーの作業ディレクトリの記録から読める。使った費用はラッパーが記録を持っていない区間があり得るため、読めないときは空の値(`null`)で出し、0 と書かない +- 前提 6: アカウントの 5 時間の枠の使用率はアカウントごとの値で、どのラッパーが読んでも同じ値になる(今の `relay_lib/switch.py` の読み方)。ラッパーをまたいだ枠の数え方は、この値を共有することで満たす +- 前提 7: 承認待ち通知は、今ある待ち通知(`lib/notify.py`・`lib/wait_notice.py`)の経路を使い、通知先を新しく足さない +- 前提 8: 対象は 1 つのコンテナ(1 つの OS の利用者)の中だけで、ほかのコンテナや機械のラッパーは統括しない +- 前提 9: 対象のランタイムは Claude Code だけである(ラッパーが Claude Code だけのため)。Codex は対象外 +- 前提 10: 2026-11-04 に記録された結合テストの失敗は、2026-11-05 に develop の先頭とタグ `ndf--v10.27.14` のどちらでも再現しない(「現状」の節の表)。原因は未確認のまま残し、この要求では原因を追わない。受け入れ条件 30 の実行で再び落ちたら、受け入れ条件 31 の出力を材料に別の課題を起こす +- 前提 11: tmux の不足を失敗にする仕組みは、根の `conftest.py` の外部コマンドの一覧(`REQUIRED_COMMANDS`)と読み飛ばしの指定(`NDF_TESTS_ALLOW_MISSING_COMMANDS`)をそのまま使い、同じ役割の判定をテストのファイルに別に作らない(Value 7) + +## 対象範囲 + +含む: +- 2 本目以降の `claude` が統括役に起動を頼み、tmux の window へ結び付く入口 +- 副命令 `relay.py spawn`・`ls`・`attach`・`restart`、名前つきの `stop` +- conductor の応答から名前つきのラッパーを起動する口 +- 同時に動かすラッパーの数の上限の宣言と判定 +- 統括役が落ちたときの引き継ぎ、tmux の無い環境での今の起動への戻り +- 一覧と通知での承認待ちの表示 +- tmux を使う結合テストを、tmux の無い環境で飛ばさず失敗にすること(読み飛ばしの指定があるときを除く) +- CI のテストのジョブで tmux を使えるようにし、4 件を走らせること +- 結合テストの待ちが切れたときに、原因を読める記録を失敗の出力へ出すこと + +含まない: +- 統括役が claude の擬似端末の親になる形(案 A) +- 複数のコンテナ・機械をまたいだ統括 +- Factory の実行ジョブの指示でラッパーを起動する入口(手元のマシンで実行ジョブを動かす仕組み #2189 が担う。ここでは語と形を揃えるだけ) +- 各ラッパーの区間の切り替え・`ndf-next` の拾い方・アカウントの切り替えの規則そのものの変更 +- tmux の導入(入っていなければ今の起動に戻るだけで、入れない) +- 同時に流すプランの合計の上限(未決 1) +- 2026-11-04 の結合テストの失敗の原因の調査(前提 10) +- 契約の宣言の欠け(受け入れ条件 25 の残り。#2434 が持つ) +- relay のラッパーと統括役のコードの変更(結合テストが通っているため。テストと CI の手順だけを変える) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 1 本目の `claude` でラッパーが起動し、統括役を兼ねた | 統括役の居ないコンテナで利用者が `claude` を打った | 統括役になれなければ、統括役の無い今の 1 本の起動として続ける(受け入れ条件 1) | — | +| E2 | 2 本目の `claude` が統括役へ起動を頼んだ | 統括役の居るコンテナで利用者が `claude` を打った | 統括役が応答しなければ、頼んだ側が統括役になる(受け入れ条件 21) | E1 | +| E3 | conductor が名前つきのラッパーの起動を頼んだ | conductor の応答の起動のブロック、または `relay.py spawn` | 名前の重複・上限超えは理由つきで断った(受け入れ条件 6・16) | E1 | +| E4 | 統括役が同時に動かす数の上限を判定した | E2・E3 | 上限を超えれば起動せず理由を返した(受け入れ条件 16) | E2 か E3 | +| E5 | 統括役が tmux の window で名前つきのラッパーを起動した | E4 を通った | tmux が無ければ今の起動に戻った(受け入れ条件 23)。window を作れなければ理由を返した | E4 | +| E6 | 端末が名前つきのラッパーの window へ結び付いた | E2 の頼み、または `relay.py attach` | 名前が無ければ理由つきで終わった(受け入れ条件 13) | E5 | +| E7 | 端末が閉じられた | 利用者が端末を閉じた | ラッパーは動き続けた(受け入れ条件 7) | E6 | +| E8 | 名前つきのラッパーがカットポイントで区間を切り替えた | `ndf-next` | 今のラッパーの規則のまま(変えない) | E5 | +| E9 | 名前つきのラッパーが承認待ちに入った | claude が承認ゲートで問いを出した | 一覧と通知に出した(受け入れ条件 10・19) | E5 | +| E10 | 利用者が 1 本を止めた・やり直した | `relay.py stop <名前>` / `restart <名前>` | 名前が無ければ理由つきで終わった | E5 | +| E11 | 名前つきのラッパーが落ちた | ラッパーのプロセスの異常終了 | 一覧で「止まった」と出し、ほかは動き続けた(受け入れ条件 20) | E5 | +| E12 | 統括役が落ちた | 1 本目のラッパーの終了・異常終了 | 動いているラッパーは止まらず、次に打った `claude` が統括役になった(受け入れ条件 21・22) | E1 | +| E13 | テストの実行が、tmux を使う結合テストを集めた | `pytest` の収集 | — | — | +| E14 | tmux が PATH に無く、テストの実行が失敗として tmux を名指しした | E13 で tmux が見つからない | 読み飛ばしの指定があれば、tmux を使う 4 件だけを飛ばした(受け入れ条件 28) | E13 | +| E15 | CI のテストのジョブが tmux を使える状態でテストを始めた | Pull Request・push での CI の起動 | tmux を用意できなければ、E14 でジョブが落ちた(受け入れ条件 29) | — | +| E16 | 結合テストの待ちが切れ、ラッパーの記録を失敗の出力に出した | 待ちの上限(区間 2 は 40 秒)に達した | — | E13 | + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 統括役 | 同じコンテナで最初に起動したラッパーが兼ねる役。ほかのラッパーの登録・起動・同時に動かす数の判定・一覧だけを持ち、各ラッパーの状態機械には触れない。課題の本文の「ホスト」に当たる(用語集の「ホスト」は cross-review の収束ループを起動する CLI を指すため使わない) | +| 名前つきのラッパー | 統括役に名前で登録され、tmux の 1 つの window で動く `relay.py run` の 1 本。カットポイントで区間(セッション)を切り替えても同じ名前で続く。課題の本文の「セッション」に当たる(用語集の「セッション」は claude の 1 回の起動を指すため使わない) | +| 端末の結び付け | 利用者の端末を、名前つきのラッパーの tmux の window へ attach すること。端末を閉じても window の中のラッパーは動き続ける | + +## 受け入れ条件 + +起動と結び付け: + +- [ ] 1. 統括役の居ないコンテナで `claude` を打つと、今と同じく端末の前景でラッパーが起動し、tmux を使わない(1 本目の振る舞いが変わらない。退行しない) +- [ ] 2. 前提: 統括役が動いている。操作: 別の端末で `claude` を打つ。結果: tmux の新しい window で名前つきのラッパーが起動し、その端末がその window へ結び付く +- [ ] 3. 2 本目の `claude` が起動した名前つきのラッパーは、端末で直接 `claude` を打ったときと同じ区間の切り替え(`ndf-next` で次の区間を起動する)を行う +- [ ] 4. `relay.py spawn --name <名前> -- <最初の入力>` を打つと、端末の結び付いていない名前つきのラッパーが起動し、最初の区間の claude が `<最初の入力>` を受け取る。終了コードは 0 +- [ ] 5. conductor の応答に起動のブロック(名前と最初の入力を持つ。形は設計で決める)を置くと、統括役がそのブロックの名前で名前つきのラッパーを起動し、置いた側の区間は止まらない +- [ ] 6. 既に動いている名前で `spawn` すると起動せず、終了コードが 0 でなく、理由(名前が重なる)を出す +- [ ] 7. 名前つきのラッパーに結び付いた端末を閉じても、そのラッパーのプロセスは動き続け、`relay.py ls` で「動いている」と出る +- [ ] 8. 端末の結び付いていない名前つきのラッパーでも、カットポイントの `ndf-next` で次の区間が起動する + +一覧と個別の操作: + +- [ ] 9. `relay.py ls --json` は 1 行の JSON(統一 JSON レポート。`lib/step_result.py` の形)を出し、`items` に名前つきのラッパーごとに名前・状態(`running` / `awaiting_approval` / `stopped` のどれか。Factory と同じ名前、2026-11-02 の承認ゲート 2 で利用者が決めた)・承認待ちの有無・区間の数・使った費用・最後に動いた時刻を持つ。`--json` の無い `relay.py ls` は同じ中身を人の読む表で出す +- [ ] 10. 名前つきのラッパーの claude が承認ゲートの問い(`AskUserQuestion`)を出している間、`relay.py ls` のその行の状態は承認待ちになり、答えた後は動いているへ戻る +- [ ] 11. 名前つきのラッパーが 3 本動いているとき `relay.py stop <名前>` を打つと、その 1 本だけが止まり、残りの 2 本は `relay.py ls` で動いているのまま +- [ ] 12. 名前を付けない `relay.py stop` は、今と同じく動いているラッパーすべてに停止シグナルファイルを置く(退行しない。前提の確認: `relay_lib/mark.py` の `cmd_stop` が `state_root()` の下の動いているラッパーすべてに `stop` を置く、2026-11-02 に読んで確かめた) +- [ ] 13. 動いていない名前で `stop` / `restart` / `attach` を打つと、何も変えずに終了コードが 0 でなく、理由(その名前は無い)を出す +- [ ] 14. `relay.py restart <名前>` は、その 1 本を止めてから同じ名前・同じ作業ディレクトリで起動し直し、ほかのラッパーに触れない。`relay.py attach <名前>` は打った端末をその window へ結び付ける + +共有の方針: + +- [ ] 15. 同時に動かす名前つきのラッパーの数の上限を宣言(置き場は設計で決める)で持ち、宣言が無いときの既定は 3 である +- [ ] 16. 上限の数だけ動いているとき、端末の `claude`・`spawn`・conductor の起動のブロックのどれから頼んでも起動せず、理由(上限の数と動いている名前)を出す +- [ ] 17. 1 日の起動回数の上限(`NDF_RELAY_MAX_STARTS`)は、今と同じくコンテナの中の全ラッパーの起動を合わせて数える(退行しない。前提の確認: `relay_lib/record.py` の `StartLimit.count_today` が `state_root()` の下の全ディレクトリの記録を数える、2026-11-02 に読んで確かめた) +- [ ] 18. `relay.py ls` の各行に、そのラッパーが今使っているアカウントの名前が出る。同じアカウントを使うラッパーが何本あるかを一覧から数えられる + +承認待ちの知らせ: + +- [ ] 19. 端末の結び付いていない名前つきのラッパーが承認待ちに入ると、今ある待ち通知が、そのラッパーの名前と `relay.py attach <名前>` の案内を付けて 1 回出る + +落ちたとき・環境の差: + +- [ ] 20. 名前つきのラッパーのプロセスを 1 本 kill すると、`relay.py ls` でその行が止まったになり、ほかのラッパーと統括役は動き続ける +- [ ] 21. 統括役のプロセスを kill しても、動いている名前つきのラッパーは止まらない。その後に打った `claude` が新しい統括役になり、`relay.py ls` に既存のラッパーが出る +- [ ] 22. 2 つの端末でほぼ同時に `claude` を打っても、統括役になるのは 1 つだけである(もう一方は名前つきのラッパーとして起動する) +- [ ] 23. tmux の実行ファイルが PATH に無い環境で 2 本目の `claude` を打つと、今の 1 本ずつの起動(端末の前景で自分のラッパーを動かす)になり、tmux が無いため統括しないことを 1 行で出す + +共通部品と契約: + +- [ ] 24. 新しい副命令の結果は `lib/step_result.py` の形で出し、ロック・子プロセス・実行の状態は `lib/locks.py`・`lib/procs.py`・`lib/run_state.py` を通す(同じ役割の関数を relay_lib に新しく作らない。Value 6) +- [ ] 25. 新しい副命令・受け渡しのファイル・設定・環境変数が #2336 の契約の宣言に載り、契約の検査(`plugins/ndf/scripts/tests/` の契約テスト)が通る +- [ ] 26. 設計文書に、起動・一覧・停止の入出力の項目と `factory/contracts/schemas/`(実行ジョブの指示・進捗の事象)の項目の対応表があり、対応する項目は同じ名前を使う +- [ ] 27. `plugins/` から `factory/` を参照しない(`python3 scripts/check-service-boundary.py` が 0 で終わる) + +結合テストを毎回走らせる(2026-11-05 に足した。起票者の推定 S16・S17): + +- [ ] 28. 前提: tmux が PATH に無い。操作: `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests/test_relay_hub.py -q` を打つ。結果: 終了コードが 0 でなく、出力が不足した外部コマンドとして `tmux` を名指しする。同じ操作に `NDF_TESTS_ALLOW_MISSING_COMMANDS=1` を付けると、tmux を使う 4 件が skipped になり、終了コード 0 で終わる(tmux を使わない 44 件を一緒に飛ばすかは未決 6) +- [ ] 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` で確かめる) + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 可用性 | 名前つきのラッパー 1 本・統括役の異常終了が、ほかのラッパーを止めない(受け入れ条件 20・21) | +| 運用・保守性 | 統括役の起動・断り・引き継ぎ、名前つきのラッパーの起動・停止は、統括役の記録に時刻・名前・理由つきで 1 行ずつ残る | +| 移行性 | 今の 1 本だけで使う利用者は、設定を変えずに今と同じ振る舞いになる(受け入れ条件 1・12)。tmux の無い環境も同じ(受け入れ条件 23) | +| セキュリティ | 統括役への頼みは同じ OS の利用者だけが送れる(受け口のファイル・ソケットの権限を所有者だけにする)。最初の入力・一覧に秘密を出さない | +| システム環境 | tmux 3.x(2026-11-02 の開発環境は 3.6)。Linux のコンテナ。Claude Code だけ。CI は GitHub Actions の `ubuntu-latest` | +| 性能・拡張性 | CI で tmux を用意する手順が足す時間は、テストのジョブ 1 本あたり 60 秒以内(今の分割の 2 本は `-n auto` で並列に回る) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わる(足すだけ)。`relay.py` に副命令 `spawn`・`ls`・`attach`・`restart` と、`stop` の名前の引数を足す。名前を付けない `stop` と今の `run` の振る舞いは変えない(互換あり) | +| データ | ラッパーの作業ディレクトリの置き場(`~/.local/state/ndf/relay/`)に、統括役の登録と名前の記録を足す。既存のシグナルファイルの形は変えない | +| 既存の振る舞い | 統括役が居るときの 2 本目以降の `claude` が、自分の端末の前景で動く代わりに tmux の window へ結び付く | +| テストの実行 | tmux の無い手元の環境で `pytest plugins/ndf/scripts/tests` を打つと、今は tmux を使う 4 件が飛ばされて通るが、失敗に変わる。続けて走らせるには `NDF_TESTS_ALLOW_MISSING_COMMANDS=1` を付ける(node・npm の不足と同じ扱い) | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| 起動 | `python3 plugins/ndf/scripts/relay.py ls --json` | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/scripts/tests -q -n 4`(擬似端末と使い捨ての tmux サーバ `tmux -L <使い捨ての名前>` で受け入れ条件 1〜23 を確かめる) | +| 静的解析・型検査 | リポジトリの CI と同じ検査(`python3 scripts/check-service-boundary.py`・契約の検査) | +| tmux の不足(受け入れ条件 28) | tmux を除いた PATH で `pytest plugins/ndf/scripts/tests/test_relay_hub.py` を打つ(pytester か `env PATH=...` の子プロセスで確かめるテスト) | +| CI(受け入れ条件 29) | 設計 PR の後の実装 PR の CI の結果で、4 件の名前と結果を読む | +| 手動確認 | 利用者が実際の端末エミュレータで 2 本目の `claude` を打ち、tmux の window の画面が崩れずに描かれ、キー入力が claude に届くことを見る(リリース後テスト) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | ラッパーのコードは `plugins/ndf/scripts/relay_lib/`、共通部品は `plugins/ndf/scripts/lib/`。`factory/` を参照しない(`AGENTS.md` の「マーケットプレイスの構造」) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。外部コマンド(tmux)の挙動は書く前に実行して確かめる(`AGENTS.md` のベストプラクティス) | +| テスト戦略 | 統括役の判定(上限・名前の重なり・引き継ぎ)は単体テスト、tmux と擬似端末を通す振る舞いは使い捨ての tmux サーバでの結合テスト。`.md` の文言を照合するテストは書かない | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存の relay のテストを通す、tmux は使い捨てのサーバ名で試し利用者の tmux サーバに触れない | +| 確認してから行う | `relay.py install` が置く `claude` の alias・関数の変更(承認ゲートで人が見る。利用者のシェルの設定へ新しく書き込むなら設計で止めて人へ戻す。C6) | +| 行わない | 各ラッパーの区間の切り替え・アカウントの切り替えの規則の変更、tmux の自動導入、`factory/` の参照 | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 1. 同時に流すプランの合計の上限(起票者の推定 S8、例 6)をこの課題に含めるか。含めないなら別の課題に起こす | 利用者(承認ゲート 1) | 設計の承認まで | +| 2. 同時に動かす数の上限の宣言の置き場(`.ndf/` の既存の設定か新しいファイルか) | 設計 | 設計の承認まで | +| 3. conductor の起動のブロックの形(`ndf-next` と同じくコードブロックの情報文字列で拾うか) | 設計 | 設計の承認まで | +| 4. 統括役が落ちた後、次の `claude` が来るまでの間に出た承認待ち通知を誰が出すか(各ラッパーが自分で出すか) | 設計 | 設計の承認まで | +| 5. CI の runner(`ubuntu-latest`)に tmux が入っているか。入っていなければ CI の手順で入れる(`apt-get install tmux` など)か。設計で runner の上で `tmux -V` を実測して決める | 設計 | 設計の承認まで | +| 6. tmux を外部コマンドの一覧へ載せる単位(`test_relay_hub.py` のファイル全体か、tmux を使う 4 件だけか)。ファイル全体にすると、tmux を使わない 44 件も tmux の無い環境で失敗になる | 設計 | 設計の承認まで | +| 7. CI のログか成果物で 4 件の結果を名前で読む形(`-rs` の要約・JUnit XML など) | 設計 | 設計の承認まで | + +## 背景 + +### 今の仕組み(2026-11-01、`plugins/ndf/scripts/relay_lib/`) + +| 観点 | 今 | +| --- | --- | +| 起動 | `claude` の alias が `relay.py run` を前景で起動し、ラッパーが claude を擬似端末の子として 1 つ持つ(`run.Relay`・`terminal.Terminal`、ptyprocess) | +| 作業ディレクトリ | ラッパーごとに `~/.local/state/ndf/relay/<時刻-pid-乱数>/`(`NDF_RELAY_DIR`)。シグナルファイル `next.json`・`stop`・`relay.lock` などを持つ | +| 共有 | `count.lock`(起動の数)とアカウント(`relay_lib/switch.py`) | +| 停止 | `relay.py stop` は動いているラッパーすべてに停止シグナルファイルを置く(`mark.cmd_stop`) | +| 端末 | ラッパーは起動した端末の前景に常駐する。端末とラッパーは 1 対 1 | + +### 設計の候補(2026-11-01 に利用者が案 B に決めた。起票者の記録) + +| 案 | 形 | 得 | 失 | +| --- | --- | --- | --- | +| A | 統括役が全 claude の擬似端末の親になり、端末の切り替えも自分で行う | プロセスが 1 つで状態を持ちやすい | 統括役が落ちると全ラッパーが落ちる。端末の切り替え(画面の再描画・入力の振り分け)を自作する。統括役の入れ替え(#1587 の `handover`)が全ラッパーに効く | +| **B(採る)** | tmux を親にし、ラッパーごとに今の `relay.py run` を 1 つの window で動かす。統括役は制御(登録・起動・方針・一覧)だけを持つ | 今のラッパーの状態機械を変えずに使える。1 本が落ちても他は動く。端末の切り替えと結び付け直しは tmux に任せる(既存ライブラリを使う方針) | tmux に依存する(無い環境では今の 1 本ずつの起動に戻る) | + +### Neural Development Factory との関係(2026-11-01 に確かめた) + +Factory(確定仕様 `docs/specifications/ndf-factory-runs-and-runner.md`)は、並行を**サービスの側**で持つ。スプリントの実行 1 本を実行ジョブ(ECS Fargate のタスク 1 つ)で動かし、conductor は端末の無い `claude -p` で起動する。同時に動かせる数は「実行の枠」(アカウントごと・リポジトリごと。プランの表で上限 3 など)が開始のトランザクションで数える。承認ゲートで止まったら状態を S3 へ保存してジョブを終え、答えで別のコンテナが続ける。人は端末でなく画面(#2172 一覧・詳細、#2173 承認と通知)で見る。 + +この課題と重なるのは **手元のマシンで Factory の実行ジョブを動かす仕組み(#2189)** である。どちらも「利用者の機械の上で、複数の claude を起動し、見て、止める常駐のプロセス」になる。 + +| | この課題(ラッパーの統括役) | #2189(手元のマシンで実行ジョブを動かす仕組み) | +| --- | --- | --- | +| 起動の出所 | 端末の `claude`・conductor の応答・`relay.py spawn` | Factory の実行ジョブの指示(サービスから受け取る) | +| claude の形 | 対話の端末(tmux の window) | 端末の無い `claude -p` | +| 見る場所 | `relay.py ls` と tmux | Factory の画面 | + +起動・一覧・停止の口を Factory の契約と同じ語と形で持てば、#2189 は統括役に「Factory から受け取った指示で起動する」入口を足すだけで作れる。`plugins/` は `factory/` を参照しないため、共有するのは契約の形だけである(受け入れ条件 26・27)。 + +関連: #2315(開発フローの見直しと大整理。同じマイルストーン 35)・#2335(根本解決)・#1587(ラッパーの入れ替え)・#1389(アカウントの切り替え) From ff13d03d624de0a27c0bbeb1b93f41bc467cc3a5 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:19:04 +0000 Subject: [PATCH 05/43] =?UTF-8?q?Docs:=20#2519=20=E3=81=AE=E6=A4=9C?= =?UTF-8?q?=E6=9F=BB=E3=81=A7=E3=80=81=E7=9C=81=E7=95=A5=E3=81=AE=E4=BE=8B?= =?UTF-8?q?=E5=A4=96=E3=81=A8=E5=80=99=E8=A3=9C=E3=81=AE=E7=95=AA=E5=8F=B7?= =?UTF-8?q?=E3=81=AE=E8=A7=A3=E6=B1=BA=E3=82=92=E8=AA=B2=E9=A1=8C=E3=81=94?= =?UTF-8?q?=E3=81=A8=E3=81=AE=E3=81=BE=E3=81=A8=E3=81=BE=E3=82=8A=E3=81=AB?= =?UTF-8?q?=E9=96=89=E3=81=98=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-2519-design.md | 21 +++++++++++---------- 1 file changed, 11 insertions(+), 10 deletions(-) diff --git a/issues/issue-2519-design.md b/issues/issue-2519-design.md index c50766ed7..417f73c73 100644 --- a/issues/issue-2519-design.md +++ b/issues/issue-2519-design.md @@ -49,8 +49,8 @@ | I2 | 設計文書 | 調べる対象の表で「調べない」とした種類は、理由を 1 行持つ | 設計 PR のレビューで指摘する | | I3 | 設計文書 | 調べた結果の行は、出典(URL・パス・コマンドのどれかと取得日)を持つ。出典を持てない対象は確かめられなかったことの行へ移り、推定で埋めない | 設計 PR のレビューで指摘する | | I4 | 設計文書 | 種類ごとの候補は上限(6 件)以下。上限で止めた対象は、確かめられなかったことへ理由「上限」で載る | 設計 PR のレビューで指摘する | -| I5 | 設計文書 | 作り方に「自前で作る」を含む決定は、比べた候補を 1 つ以上持つ。ただし外部の調査を「省く」とした文書の決定は除く | 検査が終了コード 1 で指摘し、スプリントの設計のプランが承認ゲート 1 の前で止まる | -| I6 | 設計文書 | 比べた候補の番号は、同じ課題の文書にある外部の調査の表の行の番号のどれかである | I5 と同じ | +| I5 | 設計文書 | 作り方に「自前で作る」を含む決定は、比べた候補を 1 つ以上持つ。ただし、同じ課題の文書(分けたファイルを含む)のどれかで外部の調査を「省く」としたときは、その課題の決定すべてを除く | 検査が終了コード 1 で指摘し、スプリントの設計のプランが承認ゲート 1 の前で止まる | +| I6 | 設計文書 | 比べた候補の番号は、同じ課題の文書(分けたファイルを含む)にある外部の調査の表の行の番号のどれかである。ほかの課題の文書の表では解決しない | I5 と同じ | | I7 | 設計文書 | 作り方の値は「自前で作る」「ライブラリやサービスを使う」「標準に倣う」のどれか、またはその組み合わせである | I5 と同じ | ### ドメインイベント @@ -176,7 +176,7 @@ graph TD | --- | --- | | 名前 | `python3 design-survey.py check (--issue N... \| --file PATH...) [--root DIR]` | | 入力 | `--issue`: 課題の番号(1 つ以上)。HEAD の `issues/` から、名前の番号の並びにその番号を含む `issue-<番号>-design*.md` と `issue-<番号>-requirements.md` を読む。`--file`: 読む文書のパス(worktree から読む。`light` の受け入れ条件のファイルや `operation` の実行の記録を渡す)。`--root`: リポジトリの根(既定はカレントの git の根) | -| 出力 | `lib/step_result.py` の形の 1 行の JSON(`tool: "design-survey"`)。`items[]` は 1 指摘 1 件で、`name` は `<パス>:<行> 決定 `、`rule` は `no_candidates` / `unknown_candidate` / `unknown_method`、`text` は当たった行。`metrics` は読んだ文書の数・決定の数・作り方の行を持つ決定の数・自前で作る決定の数・外部の調査を省いた文書の数 | +| 出力 | `lib/step_result.py` の形の 1 行の JSON(`tool: "design-survey"`)。`items[]` は 1 指摘 1 件で、`name` は `<パス>:<行> 決定 `、`rule` は `no_candidates` / `unknown_candidate` / `unknown_method`、`text` は当たった行。`metrics` は読んだ文書の数・決定の数・作り方の行を持つ決定の数・自前で作る決定の数・外部の調査を省いた課題の数 | | 失敗の形 | 0 = 指摘なし(作り方の行が 0 件の文書を含む)/ 1 = 指摘あり / 2 = 文書か git を読めない / 3 = `--issue` で文書が 1 つも見つからない | | 互換性 | 新しいエントリポイントで、既存の呼び出し側は無い。作り方の行を持たない既存の設計文書は 0 になる(受け入れ条件 10) | @@ -184,13 +184,14 @@ graph TD | 規則 | 読む場所 | 当たる条件 | | --- | --- | --- | +| 課題ごとのまとまり | 読んだ文書を、ファイル名の番号の並びに含む課題の番号ごとに分ける。`--issue N` では N で読んだ文書が N のまとまりになり、番号を 2 つ以上含む名前の文書はそれぞれのまとまりに入る。名前に課題の番号を持たない文書(`--file` で渡す受け入れ条件のファイルや実行の記録)は、1 ファイルで 1 つのまとまりにする。以下の規則は、まとまりごとに閉じて判定する | — | | 決定 | 見出しの文が `決定 <数字>` で始まる見出しから、同じか上の階層の次の見出しまで | — | -| 外部の調査の節 | 見出しの文が `外部の調査` の見出しから、同じか上の階層の次の見出しまで(小見出しを含む)。読んだ文書すべての節を合わせる | — | -| 省いた文書 | 外部の調査の節に `省く:` で始まる行がある | — | -| 候補の番号 | 外部の調査の節の表の 1 列目が `STD<数>` / `PRD<数>` / `LIB<数>` の行 | — | +| 外部の調査の節 | 見出しの文が `外部の調査` の見出しから、同じか上の階層の次の見出しまで(小見出しを含む)。同じまとまりの文書の節を合わせ、ほかのまとまりの節とは合わせない | — | +| 省いた課題 | まとまりの外部の調査の節のどれかに `省く:` で始まる行がある | — | +| 候補の番号 | 同じまとまりの外部の調査の節の表の 1 列目が `STD<数>` / `PRD<数>` / `LIB<数>` の行 | — | | `unknown_method` | 決定の中の `作り方:` の行 | 値を `・` か `/` で分けた語のどれかが 3 つの語に無い | -| `no_candidates` | 作り方に `自前で作る` を含む決定 | `比べた候補:` の行に候補の番号が 1 つも無く、外部の調査を省いた文書でもない | -| `unknown_candidate` | 決定の中の `比べた候補:` の行 | 並べた番号が候補の番号のどれにも無い | +| `no_candidates` | 作り方に `自前で作る` を含む決定 | `比べた候補:` の行に候補の番号が 1 つも無く、決定の属するまとまりが省いた課題でもない | +| `unknown_candidate` | 決定の中の `比べた候補:` の行 | 並べた番号が、決定の属するまとまりの候補の番号のどれにも無い | 行の頭の `- ` と、`:` の代わりの全角の `:` も受ける。MVV の宣言と本文は読まない(受け入れ条件 9)。 @@ -312,8 +313,8 @@ graph TD | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | | I5・受け入れ条件 8(指摘する例) | 外部の調査の表を持ち、作り方が「自前で作る」で比べた候補の行が無い決定を持つ文書に、`check` が 1 と `no_candidates` を返す | 作り方の行を読まないように壊すと 0 になり落ちる | -| I5・受け入れ条件 8(指摘しない例) | 比べた候補に表の番号を並べた決定と、`省く:` の行を持つ文書の自前で作る決定に、`check` が 0 を返す | 省いた文書の判定を外すと 1 になり落ちる | -| I6 | 表に無い番号を並べた決定に `unknown_candidate` を返す。番号が `-design-decisions.md` にあり表が `-design.md` にある分けた文書でも解決する | 節を文書ごとに閉じて読むと、分けた文書で番号が解決せずに当たり、落ちる | +| I5・受け入れ条件 8(指摘しない例) | 比べた候補に表の番号を並べた決定と、`省く:` の行を持つ課題の自前で作る決定に、`check` が 0 を返す。`省く:` の行が `-design.md` にあり自前で作る決定が `-design-decisions.md` にある分けた文書でも 0 を返す | 省いた課題の判定を外すか、ファイルごとに閉じて判定すると 1 になり落ちる | +| I6 | 表に無い番号を並べた決定に `unknown_candidate` を返す。番号が `-design-decisions.md` にあり表が `-design.md` にある分けた文書でも解決する。`--issue A B` で、課題 A の決定が課題 B の表にだけある番号を並べると `unknown_candidate` を返す | 節を文書ごとに閉じて読むと分けた文書で当たり、読んだ文書すべての節を合わせるとほかの課題の表で解決して当たらず、どちらも落ちる | | I7 | 3 語に無い値(「自前でつくる」)に `unknown_method` を返す。「自前で作る・標準に倣う」は自前で作る決定として扱う | 値を分けずに完全一致で比べると、組み合わせで落ちる | | コードブロックの扱い | コードブロックの中の `作り方: 自前で作る` には当たらない | コードブロックを読むと、テンプレートの例を含む文書で落ちる | | 受け入れ条件 10 | このリポジトリの `issues/` にある既存の設計文書と受け入れ条件のファイルすべてに、`check --file` が 0 を返す | 作り方の行が無い文書を違反にすると落ちる | From 987968396016a30efb78aa466e2c99a88eed1fef Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:19:18 +0000 Subject: [PATCH 06/43] =?UTF-8?q?Docs:=20#2346=20=E3=81=AE=20tmux=20?= =?UTF-8?q?=E3=82=92=E4=BD=BF=E3=81=86=E7=B5=90=E5=90=88=E3=83=86=E3=82=B9?= =?UTF-8?q?=E3=83=88=E3=82=92=E6=AF=8E=E5=9B=9E=E8=B5=B0=E3=82=89=E3=81=9B?= =?UTF-8?q?=E3=82=8B=E8=A8=AD=E8=A8=88=E3=82=92=E6=9B=B8=E3=81=8D=E3=80=81?= =?UTF-8?q?=E7=94=A8=E8=AA=9E=E9=9B=86=E3=81=AB=E3=80=8C=E5=89=8D=E6=8F=90?= =?UTF-8?q?=E3=81=AE=E3=82=B3=E3=83=9E=E3=83=B3=E3=83=89=E3=81=AE=E4=B8=80?= =?UTF-8?q?=E8=A6=A7=E3=80=8D=E3=82=92=E8=B6=B3=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 受け入れ条件 28〜32 の設計。tmux を使う 4 件をテストのクラスにまとめ、根の conftest.py の 前提のコマンドの一覧のキーをパスに :: でつないだ形まで広げて tmux を課す。テストの側の skipif は消す。 CI は tmux が無いときだけ apt で入れ、分割ごとの JUnit XML を成果物に残す。待ちが切れたときは Term の失敗の文に作業ディレクトリごとの区間の起動の引数とファイルの一覧を載せる。 CI の実行 41945018391 の分割 0 で skipped が 0 件だったため、今の runner には tmux がある。 受け入れ条件 32 の一覧に無い scripts/tests/test_root_conftest.py へテストを足すかは、 承認ゲート 1 で確かめる。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 1 + docs/glossary/glossary.json | 7 + issues/issue-2346-design.md | 266 ++++++++++++++++++++++++++++++++++++ 3 files changed, 274 insertions(+) create mode 100644 issues/issue-2346-design.md diff --git a/docs/glossary.md b/docs/glossary.md index cddb8f0fc..a045e0563 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -786,6 +786,7 @@ ai-plugins の開発で formatter と静的解析を手元と CI で走らせる | 古いバイトコード | — | Python が、書き換えたソースを、前の内容から作った .pyc で読むこと。.pyc はソースの更新時刻(秒)と大きさだけで新しさを照らすため、同じ秒に同じ大きさで書き換えると見分けられない | — | — | `docs/specifications/cross-refactoring-test-stale-bytecode.md` | | git を使うテストの土台 | — | cross-refactoring のテストで、git のリポジトリの作業ディレクトリ・pytest の起動口・状態ファイルを用意する補助(crossref_helpers.build_git_flow) | — | — | `docs/specifications/cross-refactoring-test-stale-bytecode.md` | | CLI の版の一覧 | — | スモークの像へ入れる 4 ランタイムの CLI の版数と取得先と checksum を書いたファイル(tests/runtime-smoke/cli-versions.env)。版を上げるときに書き換える唯一の場所 | — | — | `docs/specifications/runtime-smoke-cli-versions.md` | +| 前提のコマンドの一覧 | `required_commands` | 根の conftest.py が持つ、テストの置き場所(パス、またはパスに :: でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる | — | — | — | ## NDF の学習の設定(`ndf-training-optout`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index bdd8b8390..55ee098d3 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5456,6 +5456,13 @@ "context": "ndf-workflow", "meaning": "関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの", "pending_source": "issues/issue-2369-design.md" + }, + { + "term": "前提のコマンドの一覧", + "code": "required_commands", + "context": "repo-dev-checks", + "meaning": "根の conftest.py が持つ、テストの置き場所(パス、またはパスに :: でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる", + "pending_source": "issues/issue-2346-design.md" } ] } diff --git a/issues/issue-2346-design.md b/issues/issue-2346-design.md new file mode 100644 index 000000000..611fcb7ae --- /dev/null +++ b/issues/issue-2346-design.md @@ -0,0 +1,266 @@ +# relay の統括役の結合テスト: tmux を使う 4 件が手元と CI で毎回走り、落ちたときに原因を読める(#2346) + +## 目的 + +- **今どうなっているか**: 名前つきのラッパーを tmux で動かす結合テスト 4 件は、テストの側の読み飛ばし(`skipif`)で、tmux の無い実行環境では黙って飛ばされ、通過と同じ終了コード 0 になる。待ちが切れて落ちたときの出力は統括役の画面と記録だけで、名前つきのラッパーの区間ごとの起動とシグナルファイルが見えない +- **誰に効くか**: relay の統括役を直す開発者と、CI の結果で受け入れ条件 3・8 を確かめるレビュアー +- **変えると何が成り立つか**: tmux が無い実行環境では、テストの実行が tmux を名指しして失敗する。CI では 4 件が毎回走り、結果を名前で読める。待ちが切れたときは、どの区間が起動し、どのシグナルファイルが置かれていたかを失敗の出力から読める + +## 適用範囲 + +- **働く範囲**: このリポジトリのテストの実行と CI だけ。配布物(`relay.py`・`relay_lib/`)の振る舞いは変えない +- **プロジェクトごとに違うもの**: 無し(このリポジトリの開発の検査だけに効く) +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| CI の実行 41945018391(develop、2026-11-05)の分割 0 のジョブが「5375 passed」で skipped が 0 件。`test_relay_hub.py` はファイルのパスの CRC32 を 2 で割った余りが 0 で、分割 0 に入る | 実測 | CI の runner(`ubuntu-latest`)では tmux が PATH にあり、4 件は今も走っている。runner のイメージの定義(`actions/runner-images` の `toolset-2404.json` の apt の一覧)には tmux が無いため、イメージの土台に含まれているだけで、入っている保証は無い | +| 開発コンテナで `pytest plugins/ndf/scripts/tests/test_relay_hub.py --collect-only -q` を `plugins/ndf/scripts` から打つと、項目の識別子が `plugins/ndf/scripts/tests/test_relay_hub.py::test_spawn_ls_stop_and_hub_handover` と出る(48 件) | 実測 | 項目の識別子は、起点のディレクトリに関わらずリポジトリの根からの相対パスで始まる。前提のコマンドの一覧のキーを、パスの後ろへ `::` で延ばせる | +| 根の `conftest.py` の注記「読み飛ばしを既定にすると、テストの 9 割以上が消えても終了コード 0 で終わる」 | 既存の決定 | 不足を失敗にする仕組みは既にあり、tmux はその一覧に載っていないだけである | + +要求と受け入れ条件は #2346 の本文にある(コピーは [issues/issue-2346-requirements.md](issue-2346-requirements.md) )。この文書は受け入れ条件 28〜32 の「どう作るか」だけを扱う。受け入れ条件 1〜27 は ndf v10.27.14 で配布し、確定仕様 [docs/specifications/ndf-relay-multi-session-supervisor.md](../docs/specifications/ndf-relay-multi-session-supervisor.md) が持つ。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| このリポジトリの開発の検査(`repo-dev-checks`) | テストの実行に要る外部コマンド・読み飛ばしの指定・CI のテストのジョブ | + +relay のラッパー(`ndf-relay`)と統括役(`ndf-workflow`)の語は、テストが名前を引くだけで、意味を変えない。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 前提のコマンドの一覧 | 根の `conftest.py` | 一覧(`REQUIRED_COMMANDS`) | キー(テストの置き場所) | 外部コマンドの名前の組 | +| tmux を使う結合テストの組 | `plugins/ndf/scripts/tests/test_relay_hub.py` | テストのクラス `TestWithTmux` | 4 件のテスト | — | +| 擬似端末のテストの失敗の文 | `plugins/ndf/scripts/tests/test_relay.py` の `Term` | 失敗の文 | 作業ディレクトリごとの段落 | 区間の起動の引数・ファイルの名前の一覧 | +| CI のテストのジョブ | `.github/workflows/pytest.yml` | 分割のジョブ(2 本) | 手順 | JUnit XML の成果物 | + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 前提のコマンドの一覧 | tmux を使う 4 件は一覧のキーに当たる。tmux が PATH に無く読み飛ばしの指定も無ければ、収集が失敗し、出力が tmux を名指しする | 実行の全体が 0 以外で終わる(E14) | +| I2 | 前提のコマンドの一覧 | 読み飛ばしの指定があるとき、skipped になるのはキーに当たる項目だけである。`test_relay_hub.py` の残りの 44 件は走る | 指定ありでも、キーの外の項目は今どおり実行する | +| I3 | 前提のコマンドの一覧 | キーが項目に当たるのは、識別子がキーと一致するか、キーの直後が `/` か `::` のときだけである(`TestWithTmux` は `TestWithTmuxOld` に当たらない) | 境界の外の項目には前提を課さない | +| I4 | tmux を使う結合テストの組 | 4 件はテストの側に読み飛ばしの判定を持たない。tmux の有無は I1 の一覧だけが決める | — | +| I5 | 擬似端末のテストの失敗の文 | 待ちが切れた・終わらないときの失敗の文に、状態の置き場の作業ディレクトリごとに、ディレクトリの名前・ラッパーの名前・そのディレクトリで起動した区間の引数・ディレクトリにあるファイルの名前の一覧が載る | — | +| I6 | CI のテストのジョブ | テストを始める前に tmux が PATH にあり、版を 1 行出す。テストの後は、成否に関わらず分割ごとの JUnit XML を成果物に残す | tmux を用意できなければ、テストの手順で I1 により落ちる(E15) | +| I7 | (全体) | `relay.py` と `relay_lib/` に差分が無い | 実装の PR の完了判定で落とす | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E13 | テストの実行が、tmux を使う結合テストを集めた | pytest の収集 | 根の `conftest.py` の照合 | +| E14 | tmux が PATH に無く、テストの実行が失敗として tmux を名指しした | 根の `conftest.py` の照合 | テストを打った人・CI のジョブの結果 | +| E15 | CI のテストのジョブが tmux を使える状態でテストを始めた | `pytest.yml` の tmux の手順 | テストの手順 | +| E16 | 結合テストの待ちが切れ、ラッパーの記録を失敗の出力に出した | `Term` の待ち | テストの失敗の出力(手元の端末・CI のログと JUnit XML) | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 前提のコマンドの一覧 | 根の `conftest.py` が持つ、テストの置き場所(パス、またはパスに `::` でつないだファイルの中のクラス)ごとに要る外部コマンドの表。収集した項目に当たるコマンドが PATH に無ければ、読み飛ばしの指定が無い限り収集を失敗させる | 追加(`repo-dev-checks`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | tmux が無い実行環境で、tmux を使う結合テストが tmux を名指しして失敗する(指定があれば 4 件だけを飛ばす) | テストを打つ開発者・CI | +| F2 | CI のテストのジョブが tmux を用意し、4 件の結果を名前で読める成果物を残す | レビュアー・リリースの担当 | +| F3 | 擬似端末の結合テストの待ちが切れたとき、作業ディレクトリごとの区間の起動とファイルを失敗の出力へ出す | 結合テストの失敗を調べる開発者 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 根の `conftest.py` の照合(`_bundle_of` を項目で引く形へ変える) | 項目の識別子(根からの相対パス、続けてファイルの中の位置を `::` でつないだもの)を一覧のキーと照らす。境界は I3 | +| 前提のコマンドの一覧に足すキー | `plugins/ndf/scripts/tests/test_relay_hub.py::TestWithTmux` に `("tmux",)` | +| `test_relay_hub.py` の `TestWithTmux` | tmux を使う 4 件をまとめるクラス。`needs_tmux` の `skipif` を消す | +| `test_relay.py` の `Term` の失敗の文 | 状態の置き場を読み、I5 の段落を組む関数を足し、`wait` と `finish` の失敗の文へ載せる | +| `scripts/tests/test_root_conftest.py` | I1〜I3 を、PATH から tmux を外した子プロセスの pytest で確かめる | +| `.github/workflows/pytest.yml` の分割のジョブ | tmux が無ければ apt で入れ、版を出す手順。pytest に JUnit XML の出力を足し、成果物として残す手順 | + +```mermaid +graph TD + subgraph CI[CI のテストのジョブ] + Prep[tmux の用意
と版の表示] + Run[pytest の実行] + Art[JUnit XML
の成果物] + end + subgraph Root[根の conftest.py] + Table[前提のコマンド
の一覧] + Match[項目の照合] + end + subgraph Tests[plugins/ndf/scripts/tests] + Cls[TestWithTmux
の 4 件] + Term[Term の
失敗の文] + end + Prep --> Run + Run --> Match + Match --> Table + Match --> Cls + Cls --> Term + Run --> Art +``` + +図に含めない要素: `scripts/tests/test_root_conftest.py`(照合を子プロセスの pytest で確かめるテストで、実行の流れには現れない)。 + +### システムの文脈と配置 + +```mermaid +graph TD + Runner[GitHub Actions の runner
ubuntu-latest] + Apt[Ubuntu の apt] + Store[GitHub の
成果物の置き場] + Runner -->|tmux が無いときだけ| Apt + Runner -->|分割ごとに 1 つ| Store +``` + +テストは手元の開発コンテナと CI の runner の 2 か所で動く。どちらでも、統括役のテストは使い捨ての tmux サーバ(`TMUX_TMPDIR` を一時ディレクトリに向けたもの)だけを使い、利用者の tmux サーバに触れない(今のフィクスチャ `hubenv` のまま)。 + +### パッケージ・モジュール構成 + +```text +. +├── conftest.py # 照合を項目の識別子で行う・一覧にキーを足す +├── .github/workflows/pytest.yml # tmux の用意・JUnit XML の成果物 +├── scripts/tests/test_root_conftest.py # I1〜I3 のテスト +└── plugins/ndf/scripts/tests/ + ├── test_relay.py # Term の失敗の文(I5) + └── test_relay_hub.py # TestWithTmux(I4) +``` + +## 構造 + +```mermaid +classDiagram + class Term { + +env + +fake_dir + +wait(pred, timeout, what) + +finish(timeout) + +starts() + +relay_dirs() + +rows() + } + class TestWithTmux { + +test_spawn_ls_stop_and_hub_handover(hubenv) + +test_second_claude_attaches_and_survives_terminal_close(hubenv) + +test_limit_refuses_from_terminal_and_spawn(hubenv) + +test_killed_named_wrapper_shows_stopped(hubenv) + } + TestWithTmux ..> Term : hubenv が作る +``` + +`Term` に足すのは、状態の置き場から失敗の文の段落を組む処理だけである。`wait` と `finish` の引数と戻り値は変えない。`TestWithTmux` の 4 件はクラスへ移すだけで、本体を変えない。 + +## 処理の流れ + +収集の時点の照合(F1): + +```mermaid +sequenceDiagram + participant P as pytest + participant C as 根の conftest.py + participant T as 前提のコマンドの一覧 + P->>C: 収集した項目(分割の後) + C->>T: 項目の識別子に当たるキー + T-->>C: キーと外部コマンドの組 + alt どれも PATH にある + C-->>P: 何もしない + else 不足があり指定が無い + C-->>P: UsageError(キーとコマンドの名前) + else 不足があり指定がある + C-->>P: 当たる項目だけに skip の印 + end +``` + +待ちが切れたとき(F3): `Term.wait` が上限に達すると、今の画面と記録に続けて、状態の置き場(`$HOME/.local/state/ndf/relay/`)の下のディレクトリを名前の順に読み、次の段落を 1 つずつ組んで `AssertionError` に載せる。統括役の置き場(`hub/`)も同じ形で載せる。 + +| 段落に載せるもの | 出所 | +| --- | --- | +| ディレクトリの名前とラッパーの名前 | ディレクトリの名前・`name` ファイル(無ければ空) | +| そのディレクトリで起動した区間の引数 | 偽の claude が書く `starts.jsonl` のうち、`relay_dir` がそのディレクトリの行の `argv` を起動の順に | +| ディレクトリにあるファイルの名前 | ディレクトリの直下のファイルの名前を名前の順に(`next.json`・`stop`・`relay.pid` などの有無がここで読める) | + +ファイルの名前は決め打ちの一覧で調べず、ある分をすべて出す。シグナルファイルの種類が増えても、失敗の文の側を直さずに済む。 + +CI のテストのジョブ(F2): 分割の 2 本それぞれで、checkout → Python・Node・uv の用意 → tmux の用意(`command -v tmux` が見つからなければ `sudo apt-get install -y tmux`)と `tmux -V` → pytest(`--junitxml` を足す)→ 成果物の保存(`if: always()`)の順に進む。 + +## 非機能の実現方式 + +要求の非機能の条件のうち、この変更が関わるのは 2 項目である。可用性・運用・保守性・移行性・セキュリティの各項目は配布済みの統括役の振る舞いに掛かり、この変更は振る舞いを変えない(I7)。 + +| 大項目 | 実現方式 | +| --- | --- | +| システム環境 | CI は `ubuntu-latest`。tmux はイメージの土台にあるものを使い、無いときだけ apt で入れる。版は `tmux -V` でログに残し、開発コンテナ(3.6)との違いを失敗の調査で読めるようにする | +| 性能・拡張性 | tmux がある今の runner で足すのは `command -v tmux` と `tmux -V` の 2 つで、1 秒未満。JUnit XML の書き出しと成果物の保存は数秒。apt で入れる経路を通るときは `apt-get update` を含めて 60 秒以内(未確認。下の「未確認のまま残ること」) | + +## 決定の記録 + +### 決定 1: 4 件だけに tmux を課すため、テストのクラスにまとめ、一覧のキーをパスに `::` でつないだ形まで広げる + +前提のコマンドの一覧は、今はパスの前方一致だけで項目を引く。ファイル全体をキーにすると、tmux を使わない 44 件も tmux の無い実行環境で失敗し、読み飛ばしの指定では一緒に飛ばされる。4 件をクラス `TestWithTmux` にまとめ、キーを `plugins/ndf/scripts/tests/test_relay_hub.py::TestWithTmux` にすれば、一覧の 1 行で 4 件だけを指せる。キーに 4 件のテストの名前を並べる形も取れるが、テストの名前を変えたときに一覧の行が当たらなくなる。クラスなら、クラスへ足したテストにも同じ前提が掛かる。 + +4 件を別のファイルへ移す形は採らない。受け入れ条件 28 は `test_relay_hub.py` を打って tmux を名指しすることを求めており、移すとこのファイルは tmux を要らなくなる。テストに印(marker)を付けて照合で読む形も採らない。前提を書く場所が一覧と印の 2 か所になり、前提 11(同じ役割の判定を別に作らない)に合わない。 + +根拠: Value 7(MVV 版 5) + +### 決定 2: tmux の有無を 1 か所で決めるため、テストの側の `skipif`(`needs_tmux`)を消す + +一覧に載せた後も `skipif` を残すと、読み飛ばしの指定が無いときは収集の時点で先に失敗するため働かず、指定があるときは一覧と同じ結果を別の理由の文で出す。判定が 2 か所にあると、片方だけを直したときに食い違いが見えない。消して、I4 のとおり一覧だけが決める形にする。 + +根拠: Value 7(MVV 版 5) + +### 決定 3: runner のイメージが変わってもジョブが止まらないよう、tmux は無いときだけ apt で入れる + +今の runner には tmux があり(あるべき姿の根拠の 1 行目)、入れる手順が無くても 4 件は走る。ただし runner のイメージの定義に tmux は載っておらず、土台の変更で消えると、一覧に載せた後はすべての Pull Request のテストのジョブが落ちる。`command -v tmux` で確かめ、無いときだけ入れる。ある間は時間を足さない。毎回 `apt-get install` を打つ形は、ある間も `apt-get update` の時間を毎回払うため採らない。手順を足さずに一覧の失敗に任せる形は、イメージが変わった日から修正が入るまで、無関係な変更のマージが止まるため採らない。 + +根拠: Value 1 / Value 3(MVV 版 5) + +### 決定 4: 4 件の結果を名前で読むため、分割ごとの JUnit XML を成果物に残す + +今のジョブは `-q` で走り、ログには件数しか出ない。JUnit XML には項目ごとの名前と結果(通過・失敗・skipped)が残り、`gh run download` で取り出して 4 件を名前で確かめられる。ログに全項目を出す `-rA`・`-v` は、分割の 2 本で合わせて 11,000 件を超える行がログに並び、ほかの失敗を探しにくくするため採らない。4 件だけを別の手順で打ち直す形は、同じテストを 2 回走らせ、本体の実行で走ったかの証拠にならないため採らない。 + +根拠: Value 4(MVV 版 5) + +### 決定 5: どの擬似端末のテストでも原因を読めるよう、失敗の文は `Term` の共通の処理に足す + +2026-11-04 の失敗は、統括役の画面と記録だけでは、名前つきのラッパーが区間 2 を起動しようとしたかどうかが読めなかった。区間の起動の引数とファイルの一覧は、統括役のテストに限らず `Term` を使うすべての擬似端末のテストで、待ちが切れたときに役立つ。`test_relay_hub.py` の側に包む処理を作る形は、同じ役割の処理が 2 か所に分かれるため採らない。 + +根拠: Value 7(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 受け入れ条件 28・I1 | PATH から tmux を外して `test_relay_hub.py` を子プロセスの pytest で打つと、0 以外で終わり、出力にキーと `tmux` が出る | 一覧からキーを消す、または照合がパスだけを見る形に戻すと、終了コードが 0 になって落ちる | +| 受け入れ条件 28・I2 | 同じ実行に読み飛ばしの指定を付けると、終了コード 0 で、skipped がちょうど 4 件(4 件の名前) | キーをファイル全体にすると skipped が 48 件になって落ちる | +| I3 | 一覧と同じ照合に、キーの直後が `/`・`::`・それ以外の識別子を渡し、前の 2 つだけが当たる | 前方一致を文字列の前方一致だけにすると、`TestWithTmuxOld` が当たって落ちる | +| I4 | `test_relay_hub.py` の 4 件に `skipif` の印が無いことを、収集した項目の印で確かめる | `needs_tmux` を戻すと落ちる | +| 受け入れ条件 29・I6 | 実装の PR の CI で、分割 0 の成果物の JUnit XML に 4 件の名前があり、どれも failure・skipped を持たない。ジョブのログに `tmux -V` の行がある | 実装の PR の検証で確かめる(自動のテストは書かない。ワークフローの文面を照合するテストになるため) | +| 受け入れ条件 30 | 開発コンテナで `test_spawn_ls_stop_and_hub_handover` を 10 回続けて打ち、10 回とも通る。全体の実行(`-n 4`)でも通る | 実装の PR の検証で確かめ、結果を PR に残す | +| 受け入れ条件 31・I5 | 作業ディレクトリ 2 つ(`next.json` を持つものと `stop` を持つもの)と、それぞれを指す `starts.jsonl` の行を置いた状態で、`Term` の待ちを切らせると、失敗の文に両方のディレクトリの区間の引数とファイルの名前が出る | 段落を組む処理を失敗の文から外すと落ちる。ファイルの名前を決め打ちの一覧で調べる形にすると、一覧に無い名前のファイルが出ずに落ちる | +| 受け入れ条件 32・I7 | 実装の PR で `git diff --stat origin/develop -- plugins/ndf/scripts/relay.py plugins/ndf/scripts/relay_lib/` が空 | 完了判定で確かめる | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2346 | 実装する | — | `conftest.py`、`scripts/tests/test_root_conftest.py`、`plugins/ndf/scripts/tests/test_relay.py`、`plugins/ndf/scripts/tests/test_relay_hub.py`、`.github/workflows/pytest.yml` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 受け入れ条件 32 の一覧の外のファイル | 受け入れ条件 32 は変更を `plugins/ndf/scripts/tests/`・根の `conftest.py`・`.github/workflows/` に限るが、根の `conftest.py` のテストの置き場は `scripts/tests/test_root_conftest.py` で、この設計はそこにも足す。受け入れ条件 32 の目的(relay の振る舞いを変えない)は I7 で確かめる。この 1 ファイルを一覧へ加えてよいかは承認ゲート 1 で利用者が決める | +| apt で入れる経路の時間 | 今の runner には tmux があるため、apt の経路は CI で一度も通らない。60 秒以内に収まるかは未確認で、通ったときのジョブの時間で確かめる | +| 2026-11-04 の失敗の原因 | 前提 10 のとおり追わない。受け入れ条件 30 の実行で再び落ちたら、I5 の失敗の文を材料に別の課題を起こす | +| `.github/workflows/` の変更と自動の承認 | `.ndf/pace.json` の `boundary_paths` に `.github/workflows/**` があり、この変更はその対象に当たる。承認ゲートの扱いは `development-workflow` の判定に従う | From 527866399450ae3b777eb6f57b18f602115f7be1 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:22:04 +0000 Subject: [PATCH 07/43] =?UTF-8?q?Docs:=20#2346=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=97=E3=80=81=E9=95=B7=E3=81=84=E6=96=87=E3=82=92=E5=88=86?= =?UTF-8?q?=E3=81=91=E3=81=A6=20CI=20=E3=81=AE=E3=82=B8=E3=83=A7=E3=83=96?= =?UTF-8?q?=E3=81=AE=E6=89=8B=E9=A0=86=E3=82=92=E4=B8=80=E8=A6=A7=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 章の並びは設計の雛形の固定の順のまま変えず、内容は足していない。 最長文を 188 字から 136 字へ、平均文長を 41.8 字から 37.7 字へ縮めた。 行数の増加 6 行は、CI のテストのジョブの手順を番号付きの一覧へ移した分である。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2346-design.md | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/issues/issue-2346-design.md b/issues/issue-2346-design.md index 611fcb7ae..2777daa0e 100644 --- a/issues/issue-2346-design.md +++ b/issues/issue-2346-design.md @@ -2,7 +2,7 @@ ## 目的 -- **今どうなっているか**: 名前つきのラッパーを tmux で動かす結合テスト 4 件は、テストの側の読み飛ばし(`skipif`)で、tmux の無い実行環境では黙って飛ばされ、通過と同じ終了コード 0 になる。待ちが切れて落ちたときの出力は統括役の画面と記録だけで、名前つきのラッパーの区間ごとの起動とシグナルファイルが見えない +- **今どうなっているか**: 名前つきのラッパーを tmux で動かす結合テスト 4 件は、tmux の無い実行環境では、テストの側の読み飛ばし(`skipif`)で黙って飛ばされる。そのときの終了コードは、通過と同じ 0 である。待ちが切れて落ちたときの出力は統括役の画面と記録だけで、名前つきのラッパーの区間ごとの起動とシグナルファイルが見えない - **誰に効くか**: relay の統括役を直す開発者と、CI の結果で受け入れ条件 3・8 を確かめるレビュアー - **変えると何が成り立つか**: tmux が無い実行環境では、テストの実行が tmux を名指しして失敗する。CI では 4 件が毎回走り、結果を名前で読める。待ちが切れたときは、どの区間が起動し、どのシグナルファイルが置かれていたかを失敗の出力から読める @@ -18,9 +18,9 @@ | --- | --- | --- | | CI の実行 41945018391(develop、2026-11-05)の分割 0 のジョブが「5375 passed」で skipped が 0 件。`test_relay_hub.py` はファイルのパスの CRC32 を 2 で割った余りが 0 で、分割 0 に入る | 実測 | CI の runner(`ubuntu-latest`)では tmux が PATH にあり、4 件は今も走っている。runner のイメージの定義(`actions/runner-images` の `toolset-2404.json` の apt の一覧)には tmux が無いため、イメージの土台に含まれているだけで、入っている保証は無い | | 開発コンテナで `pytest plugins/ndf/scripts/tests/test_relay_hub.py --collect-only -q` を `plugins/ndf/scripts` から打つと、項目の識別子が `plugins/ndf/scripts/tests/test_relay_hub.py::test_spawn_ls_stop_and_hub_handover` と出る(48 件) | 実測 | 項目の識別子は、起点のディレクトリに関わらずリポジトリの根からの相対パスで始まる。前提のコマンドの一覧のキーを、パスの後ろへ `::` で延ばせる | -| 根の `conftest.py` の注記「読み飛ばしを既定にすると、テストの 9 割以上が消えても終了コード 0 で終わる」 | 既存の決定 | 不足を失敗にする仕組みは既にあり、tmux はその一覧に載っていないだけである | +| リポジトリの根の `conftest.py` の注記「読み飛ばしを既定にすると、テストの 9 割以上が消えても終了コード 0 で終わる」 | 既存の決定 | 不足を失敗にする仕組みは既にあり、tmux はその一覧に載っていないだけである | -要求と受け入れ条件は #2346 の本文にある(コピーは [issues/issue-2346-requirements.md](issue-2346-requirements.md) )。この文書は受け入れ条件 28〜32 の「どう作るか」だけを扱う。受け入れ条件 1〜27 は ndf v10.27.14 で配布し、確定仕様 [docs/specifications/ndf-relay-multi-session-supervisor.md](../docs/specifications/ndf-relay-multi-session-supervisor.md) が持つ。 +要求と受け入れ条件は #2346 の本文にある(コピーは [issues/issue-2346-requirements.md](issue-2346-requirements.md) )。この文書は受け入れ条件 28〜32 の「どう作るか」だけを扱う。受け入れ条件 1〜27 は ndf v10.27.14 で配布した。その内容は確定仕様 [docs/specifications/ndf-relay-multi-session-supervisor.md](../docs/specifications/ndf-relay-multi-session-supervisor.md) が持つ。 ## ドメインモデル @@ -123,7 +123,7 @@ graph TD Runner -->|分割ごとに 1 つ| Store ``` -テストは手元の開発コンテナと CI の runner の 2 か所で動く。どちらでも、統括役のテストは使い捨ての tmux サーバ(`TMUX_TMPDIR` を一時ディレクトリに向けたもの)だけを使い、利用者の tmux サーバに触れない(今のフィクスチャ `hubenv` のまま)。 +テストは手元の開発コンテナと CI の runner の 2 か所で動く。どちらでも、統括役のテストは使い捨ての tmux サーバだけを使い、利用者の tmux サーバに触れない(今のフィクスチャ `hubenv` のまま)。使い捨ての tmux サーバは、`TMUX_TMPDIR` を一時ディレクトリに向けたものである。 ### パッケージ・モジュール構成 @@ -182,7 +182,7 @@ sequenceDiagram end ``` -待ちが切れたとき(F3): `Term.wait` が上限に達すると、今の画面と記録に続けて、状態の置き場(`$HOME/.local/state/ndf/relay/`)の下のディレクトリを名前の順に読み、次の段落を 1 つずつ組んで `AssertionError` に載せる。統括役の置き場(`hub/`)も同じ形で載せる。 +待ちが切れたとき(F3): `Term.wait` が上限に達すると、今の画面と記録に続けて、次の段落を `AssertionError` に載せる。段落は、状態の置き場(`$HOME/.local/state/ndf/relay/`)の下のディレクトリを名前の順に読み、1 つずつ組む。統括役の置き場(`hub/`)も同じ形で載せる。 | 段落に載せるもの | 出所 | | --- | --- | @@ -192,7 +192,13 @@ sequenceDiagram ファイルの名前は決め打ちの一覧で調べず、ある分をすべて出す。シグナルファイルの種類が増えても、失敗の文の側を直さずに済む。 -CI のテストのジョブ(F2): 分割の 2 本それぞれで、checkout → Python・Node・uv の用意 → tmux の用意(`command -v tmux` が見つからなければ `sudo apt-get install -y tmux`)と `tmux -V` → pytest(`--junitxml` を足す)→ 成果物の保存(`if: always()`)の順に進む。 +CI のテストのジョブ(F2): 分割の 2 本それぞれで、次の順に進む。 + +1. checkout +2. Python・Node・uv の用意 +3. tmux の用意(`command -v tmux` が見つからなければ `sudo apt-get install -y tmux`)と `tmux -V` +4. pytest(`--junitxml` を足す) +5. 成果物の保存(`if: always()`) ## 非機能の実現方式 @@ -207,9 +213,9 @@ CI のテストのジョブ(F2): 分割の 2 本それぞれで、checkout ### 決定 1: 4 件だけに tmux を課すため、テストのクラスにまとめ、一覧のキーをパスに `::` でつないだ形まで広げる -前提のコマンドの一覧は、今はパスの前方一致だけで項目を引く。ファイル全体をキーにすると、tmux を使わない 44 件も tmux の無い実行環境で失敗し、読み飛ばしの指定では一緒に飛ばされる。4 件をクラス `TestWithTmux` にまとめ、キーを `plugins/ndf/scripts/tests/test_relay_hub.py::TestWithTmux` にすれば、一覧の 1 行で 4 件だけを指せる。キーに 4 件のテストの名前を並べる形も取れるが、テストの名前を変えたときに一覧の行が当たらなくなる。クラスなら、クラスへ足したテストにも同じ前提が掛かる。 +前提のコマンドの一覧は、今はパスの前方一致だけで項目を引く。ファイル全体をキーにすると、tmux を使わない 44 件も tmux の無い実行環境で失敗し、読み飛ばしの指定では一緒に飛ばされる。4 件をクラス `TestWithTmux` にまとめ、キーを `plugins/ndf/scripts/tests/test_relay_hub.py::TestWithTmux` にする。そうすれば、一覧の 1 行で 4 件だけを指せる。キーに 4 件のテストの名前を並べる形も取れるが、テストの名前を変えたときに一覧の行が当たらなくなる。クラスなら、クラスへ足したテストにも同じ前提が掛かる。 -4 件を別のファイルへ移す形は採らない。受け入れ条件 28 は `test_relay_hub.py` を打って tmux を名指しすることを求めており、移すとこのファイルは tmux を要らなくなる。テストに印(marker)を付けて照合で読む形も採らない。前提を書く場所が一覧と印の 2 か所になり、前提 11(同じ役割の判定を別に作らない)に合わない。 +4 件を別のファイルへ移す形は採らない。受け入れ条件 28 は `test_relay_hub.py` を打って tmux を名指しすることを求めており、移すとこのファイルは tmux を要らなくなる。テストに印(marker)を付けて照合で読む形も採らない。前提を書く場所が一覧と印の 2 か所になり、要求の前提 11(同じ役割の判定を別に作らない)に合わない。 根拠: Value 7(MVV 版 5) @@ -262,5 +268,5 @@ CI のテストのジョブ(F2): 分割の 2 本それぞれで、checkout | --- | --- | | 受け入れ条件 32 の一覧の外のファイル | 受け入れ条件 32 は変更を `plugins/ndf/scripts/tests/`・根の `conftest.py`・`.github/workflows/` に限るが、根の `conftest.py` のテストの置き場は `scripts/tests/test_root_conftest.py` で、この設計はそこにも足す。受け入れ条件 32 の目的(relay の振る舞いを変えない)は I7 で確かめる。この 1 ファイルを一覧へ加えてよいかは承認ゲート 1 で利用者が決める | | apt で入れる経路の時間 | 今の runner には tmux があるため、apt の経路は CI で一度も通らない。60 秒以内に収まるかは未確認で、通ったときのジョブの時間で確かめる | -| 2026-11-04 の失敗の原因 | 前提 10 のとおり追わない。受け入れ条件 30 の実行で再び落ちたら、I5 の失敗の文を材料に別の課題を起こす | +| 2026-11-04 の失敗の原因 | 要求の前提 10 のとおり追わない。受け入れ条件 30 の実行で再び落ちたら、I5 の失敗の文を材料に別の課題を起こす | | `.github/workflows/` の変更と自動の承認 | `.ndf/pace.json` の `boundary_paths` に `.github/workflows/**` があり、この変更はその対象に当たる。承認ゲートの扱いは `development-workflow` の判定に従う | From f6ff69af0822a119ceeed1ea037730ff62305b8f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:23:45 +0000 Subject: [PATCH 08/43] =?UTF-8?q?docs(design):=20=E7=95=AA=E5=8F=B7?= =?UTF-8?q?=E3=82=92=E5=85=B1=E6=9C=89=E3=81=99=E3=82=8B=E8=A8=AD=E8=A8=88?= =?UTF-8?q?=E6=96=87=E6=9B=B8=E3=82=92=201=20=E3=81=A4=E3=81=AE=E3=81=BE?= =?UTF-8?q?=E3=81=A8=E3=81=BE=E3=82=8A=E3=81=AB=E5=90=88=E3=82=8F=E3=81=9B?= =?UTF-8?q?=E3=82=8B=20(#2519)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 番号を 2 つ以上含む名前の設計文書を番号ごとのまとまりへ重ねて入れると、 片方の課題の表にだけある候補の番号が解決せず、正しい設計が承認ゲート 1 の 前で止まる。番号を共有する文書をつないで 1 つのまとまりにし、I5・I6 と テスト設計に複数番号の文書の例を足す。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2519-design.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/issues/issue-2519-design.md b/issues/issue-2519-design.md index 417f73c73..2216189c5 100644 --- a/issues/issue-2519-design.md +++ b/issues/issue-2519-design.md @@ -49,8 +49,8 @@ | I2 | 設計文書 | 調べる対象の表で「調べない」とした種類は、理由を 1 行持つ | 設計 PR のレビューで指摘する | | I3 | 設計文書 | 調べた結果の行は、出典(URL・パス・コマンドのどれかと取得日)を持つ。出典を持てない対象は確かめられなかったことの行へ移り、推定で埋めない | 設計 PR のレビューで指摘する | | I4 | 設計文書 | 種類ごとの候補は上限(6 件)以下。上限で止めた対象は、確かめられなかったことへ理由「上限」で載る | 設計 PR のレビューで指摘する | -| I5 | 設計文書 | 作り方に「自前で作る」を含む決定は、比べた候補を 1 つ以上持つ。ただし、同じ課題の文書(分けたファイルを含む)のどれかで外部の調査を「省く」としたときは、その課題の決定すべてを除く | 検査が終了コード 1 で指摘し、スプリントの設計のプランが承認ゲート 1 の前で止まる | -| I6 | 設計文書 | 比べた候補の番号は、同じ課題の文書(分けたファイルを含む)にある外部の調査の表の行の番号のどれかである。ほかの課題の文書の表では解決しない | I5 と同じ | +| I5 | 設計文書 | 作り方に「自前で作る」を含む決定は、比べた候補を 1 つ以上持つ。ただし、同じ課題の文書(分けたファイルと、`issue-7-8-design.md` のように番号を 2 つ以上含む名前で課題の番号を共有する文書を含む)のどれかで外部の調査を「省く」としたときは、それらの文書の決定すべてを除く | 検査が終了コード 1 で指摘し、スプリントの設計のプランが承認ゲート 1 の前で止まる | +| I6 | 設計文書 | 比べた候補の番号は、同じ課題の文書(I5 と同じ範囲)にある外部の調査の表の行の番号のどれかである。課題の番号を共有しない文書の表では解決しない | I5 と同じ | | I7 | 設計文書 | 作り方の値は「自前で作る」「ライブラリやサービスを使う」「標準に倣う」のどれか、またはその組み合わせである | I5 と同じ | ### ドメインイベント @@ -184,7 +184,7 @@ graph TD | 規則 | 読む場所 | 当たる条件 | | --- | --- | --- | -| 課題ごとのまとまり | 読んだ文書を、ファイル名の番号の並びに含む課題の番号ごとに分ける。`--issue N` では N で読んだ文書が N のまとまりになり、番号を 2 つ以上含む名前の文書はそれぞれのまとまりに入る。名前に課題の番号を持たない文書(`--file` で渡す受け入れ条件のファイルや実行の記録)は、1 ファイルで 1 つのまとまりにする。以下の規則は、まとまりごとに閉じて判定する | — | +| 課題ごとのまとまり | 読んだ文書を、ファイル名の番号の並びに含む課題の番号でつなぎ、番号を 1 つでも共有する文書どうしを 1 つのまとまりにする(つながりをたどって閉じる)。1 つの文書は 1 つのまとまりにだけ属する。例: `issue-7-design.md`・`issue-7-8-design.md`・`issue-8-design.md` を読むと、3 ファイルで 1 つのまとまりになる。`--issue 7` だけでは、7 を名前に含む `issue-7-design.md` と `issue-7-8-design.md` を読み、この 2 ファイルで 1 つのまとまりになる(読まない `issue-8-design.md` は入らない)。名前に課題の番号を持たない文書(`--file` で渡す受け入れ条件のファイルや実行の記録)は、1 ファイルで 1 つのまとまりにする。以下の規則は、まとまりごとに閉じて判定する | — | | 決定 | 見出しの文が `決定 <数字>` で始まる見出しから、同じか上の階層の次の見出しまで | — | | 外部の調査の節 | 見出しの文が `外部の調査` の見出しから、同じか上の階層の次の見出しまで(小見出しを含む)。同じまとまりの文書の節を合わせ、ほかのまとまりの節とは合わせない | — | | 省いた課題 | まとまりの外部の調査の節のどれかに `省く:` で始まる行がある | — | @@ -313,8 +313,8 @@ graph TD | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | | I5・受け入れ条件 8(指摘する例) | 外部の調査の表を持ち、作り方が「自前で作る」で比べた候補の行が無い決定を持つ文書に、`check` が 1 と `no_candidates` を返す | 作り方の行を読まないように壊すと 0 になり落ちる | -| I5・受け入れ条件 8(指摘しない例) | 比べた候補に表の番号を並べた決定と、`省く:` の行を持つ課題の自前で作る決定に、`check` が 0 を返す。`省く:` の行が `-design.md` にあり自前で作る決定が `-design-decisions.md` にある分けた文書でも 0 を返す | 省いた課題の判定を外すか、ファイルごとに閉じて判定すると 1 になり落ちる | -| I6 | 表に無い番号を並べた決定に `unknown_candidate` を返す。番号が `-design-decisions.md` にあり表が `-design.md` にある分けた文書でも解決する。`--issue A B` で、課題 A の決定が課題 B の表にだけある番号を並べると `unknown_candidate` を返す | 節を文書ごとに閉じて読むと分けた文書で当たり、読んだ文書すべての節を合わせるとほかの課題の表で解決して当たらず、どちらも落ちる | +| I5・受け入れ条件 8(指摘しない例) | 比べた候補に表の番号を並べた決定と、`省く:` の行を持つ課題の自前で作る決定に、`check` が 0 を返す。`省く:` の行が `-design.md` にあり自前で作る決定が `-design-decisions.md` にある分けた文書でも 0 を返す。`省く:` の行が `issue-7-design.md` にあり自前で作る決定が `issue-7-8-design.md` にあるときも、`--issue 7` と `--issue 7 8` のどちらでも 0 を返し、同じ決定の指摘を重ねない | 省いた課題の判定を外すか、ファイルごとに閉じて判定するか、番号を 2 つ含む文書を番号ごとのまとまりへ重ねて入れると 1 になり落ちる | +| I6 | 表に無い番号を並べた決定に `unknown_candidate` を返す。番号が `-design-decisions.md` にあり表が `-design.md` にある分けた文書でも解決する。`--issue A B` で、課題 A の決定が課題 B の表にだけある番号を並べると `unknown_candidate` を返す。`issue-7-8-design.md` の決定が `issue-7-design.md` の表にだけある番号を並べたとき、`--issue 7 8` で `unknown_candidate` を返さず、指摘も 1 件も出さない | 節を文書ごとに閉じて読むと分けた文書で当たり、読んだ文書すべての節を合わせるとほかの課題の表で解決して当たらず、番号を 2 つ含む文書を番号ごとのまとまりへ重ねて入れると 8 のまとまりで当たり、いずれも落ちる | | I7 | 3 語に無い値(「自前でつくる」)に `unknown_method` を返す。「自前で作る・標準に倣う」は自前で作る決定として扱う | 値を分けずに完全一致で比べると、組み合わせで落ちる | | コードブロックの扱い | コードブロックの中の `作り方: 自前で作る` には当たらない | コードブロックを読むと、テンプレートの例を含む文書で落ちる | | 受け入れ条件 10 | このリポジトリの `issues/` にある既存の設計文書と受け入れ条件のファイルすべてに、`check --file` が 0 を返す | 作り方の行が無い文書を違反にすると落ちる | From 4158b8d3096bcbc0fbd7c74f7ed7bdbccf040011 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:27:55 +0000 Subject: [PATCH 09/43] =?UTF-8?q?Docs:=20#2519=20=E3=81=AE=20--issue=20?= =?UTF-8?q?=E3=81=A7=E8=AA=AD=E3=82=80=E6=96=87=E6=9B=B8=E3=82=92=E7=95=AA?= =?UTF-8?q?=E5=8F=B7=E3=81=AE=E3=81=A4=E3=81=AA=E3=81=8C=E3=82=8A=E3=81=A7?= =?UTF-8?q?=E9=96=89=E3=81=98=E3=80=81I5=E3=83=BBI6=20=E3=81=AE=E7=AF=84?= =?UTF-8?q?=E5=9B=B2=E3=82=92=E3=81=BE=E3=81=A8=E3=81=BE=E3=82=8A=E3=81=AB?= =?UTF-8?q?=E6=8F=83=E3=81=88=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Opus 5.5 --- issues/issue-2519-design.md | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/issues/issue-2519-design.md b/issues/issue-2519-design.md index 2216189c5..2da5df262 100644 --- a/issues/issue-2519-design.md +++ b/issues/issue-2519-design.md @@ -49,8 +49,8 @@ | I2 | 設計文書 | 調べる対象の表で「調べない」とした種類は、理由を 1 行持つ | 設計 PR のレビューで指摘する | | I3 | 設計文書 | 調べた結果の行は、出典(URL・パス・コマンドのどれかと取得日)を持つ。出典を持てない対象は確かめられなかったことの行へ移り、推定で埋めない | 設計 PR のレビューで指摘する | | I4 | 設計文書 | 種類ごとの候補は上限(6 件)以下。上限で止めた対象は、確かめられなかったことへ理由「上限」で載る | 設計 PR のレビューで指摘する | -| I5 | 設計文書 | 作り方に「自前で作る」を含む決定は、比べた候補を 1 つ以上持つ。ただし、同じ課題の文書(分けたファイルと、`issue-7-8-design.md` のように番号を 2 つ以上含む名前で課題の番号を共有する文書を含む)のどれかで外部の調査を「省く」としたときは、それらの文書の決定すべてを除く | 検査が終了コード 1 で指摘し、スプリントの設計のプランが承認ゲート 1 の前で止まる | -| I6 | 設計文書 | 比べた候補の番号は、同じ課題の文書(I5 と同じ範囲)にある外部の調査の表の行の番号のどれかである。課題の番号を共有しない文書の表では解決しない | I5 と同じ | +| I5 | 設計文書 | 作り方に「自前で作る」を含む決定は、比べた候補を 1 つ以上持つ。ただし、同じまとまり(課題の番号の共有をたどって閉じた文書の集まり。分けたファイルと、`issue-7-8-design.md` のように番号を 2 つ以上含む名前の文書を介してつながる文書を含む。入出力の契約の「課題ごとのまとまり」)のどれかの文書で外部の調査を「省く」としたときは、そのまとまりの決定すべてを除く | 検査が終了コード 1 で指摘し、スプリントの設計のプランが承認ゲート 1 の前で止まる | +| I6 | 設計文書 | 比べた候補の番号は、決定の属するまとまり(I5 と同じ範囲)にある外部の調査の表の行の番号のどれかである。例: `issue-7-design.md`・`issue-7-8-design.md`・`issue-8-design.md` は 1 つのまとまりで、7 の決定は 8 の表にだけある番号でも解決する。ほかのまとまりの表では解決しない | I5 と同じ | | I7 | 設計文書 | 作り方の値は「自前で作る」「ライブラリやサービスを使う」「標準に倣う」のどれか、またはその組み合わせである | I5 と同じ | ### ドメインイベント @@ -175,7 +175,7 @@ graph TD | 項目 | 書くこと | | --- | --- | | 名前 | `python3 design-survey.py check (--issue N... \| --file PATH...) [--root DIR]` | -| 入力 | `--issue`: 課題の番号(1 つ以上)。HEAD の `issues/` から、名前の番号の並びにその番号を含む `issue-<番号>-design*.md` と `issue-<番号>-requirements.md` を読む。`--file`: 読む文書のパス(worktree から読む。`light` の受け入れ条件のファイルや `operation` の実行の記録を渡す)。`--root`: リポジトリの根(既定はカレントの git の根) | +| 入力 | `--issue`: 課題の番号(1 つ以上)。HEAD の `issues/` から、名前の番号の並びにその番号を含む `issue-<番号>-design*.md` と `issue-<番号>-requirements.md` を読む。読んだ文書の名前がほかの番号を含むときは、その番号を名前に含む文書も読み足し、新しい番号が出なくなるまで繰り返す(`--issue 8` でも `issue-8-design.md`・`issue-7-8-design.md`・`issue-7-design.md` の 3 ファイルを読む)。`--file`: 読む文書のパス(worktree から読む。`light` の受け入れ条件のファイルや `operation` の実行の記録を渡す)。`--root`: リポジトリの根(既定はカレントの git の根) | | 出力 | `lib/step_result.py` の形の 1 行の JSON(`tool: "design-survey"`)。`items[]` は 1 指摘 1 件で、`name` は `<パス>:<行> 決定 `、`rule` は `no_candidates` / `unknown_candidate` / `unknown_method`、`text` は当たった行。`metrics` は読んだ文書の数・決定の数・作り方の行を持つ決定の数・自前で作る決定の数・外部の調査を省いた課題の数 | | 失敗の形 | 0 = 指摘なし(作り方の行が 0 件の文書を含む)/ 1 = 指摘あり / 2 = 文書か git を読めない / 3 = `--issue` で文書が 1 つも見つからない | | 互換性 | 新しいエントリポイントで、既存の呼び出し側は無い。作り方の行を持たない既存の設計文書は 0 になる(受け入れ条件 10) | @@ -184,7 +184,7 @@ graph TD | 規則 | 読む場所 | 当たる条件 | | --- | --- | --- | -| 課題ごとのまとまり | 読んだ文書を、ファイル名の番号の並びに含む課題の番号でつなぎ、番号を 1 つでも共有する文書どうしを 1 つのまとまりにする(つながりをたどって閉じる)。1 つの文書は 1 つのまとまりにだけ属する。例: `issue-7-design.md`・`issue-7-8-design.md`・`issue-8-design.md` を読むと、3 ファイルで 1 つのまとまりになる。`--issue 7` だけでは、7 を名前に含む `issue-7-design.md` と `issue-7-8-design.md` を読み、この 2 ファイルで 1 つのまとまりになる(読まない `issue-8-design.md` は入らない)。名前に課題の番号を持たない文書(`--file` で渡す受け入れ条件のファイルや実行の記録)は、1 ファイルで 1 つのまとまりにする。以下の規則は、まとまりごとに閉じて判定する | — | +| 課題ごとのまとまり | 読んだ文書を、ファイル名の番号の並びに含む課題の番号でつなぎ、番号を 1 つでも共有する文書どうしを 1 つのまとまりにする(つながりをたどって閉じる)。1 つの文書は 1 つのまとまりにだけ属する。例: `issue-7-design.md`・`issue-7-8-design.md`・`issue-8-design.md` を読むと、3 ファイルで 1 つのまとまりになる。`--issue` は読む文書をつながりで閉じるため、`--issue 7`・`--issue 8`・`--issue 7 8` のどれでも、この 3 ファイルを読んで同じ 1 つのまとまりになり、判定は渡す番号で変わらない。名前に課題の番号を持たない文書(`--file` で渡す受け入れ条件のファイルや実行の記録)は、1 ファイルで 1 つのまとまりにする。以下の規則は、まとまりごとに閉じて判定する | — | | 決定 | 見出しの文が `決定 <数字>` で始まる見出しから、同じか上の階層の次の見出しまで | — | | 外部の調査の節 | 見出しの文が `外部の調査` の見出しから、同じか上の階層の次の見出しまで(小見出しを含む)。同じまとまりの文書の節を合わせ、ほかのまとまりの節とは合わせない | — | | 省いた課題 | まとまりの外部の調査の節のどれかに `省く:` で始まる行がある | — | @@ -313,8 +313,8 @@ graph TD | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | | I5・受け入れ条件 8(指摘する例) | 外部の調査の表を持ち、作り方が「自前で作る」で比べた候補の行が無い決定を持つ文書に、`check` が 1 と `no_candidates` を返す | 作り方の行を読まないように壊すと 0 になり落ちる | -| I5・受け入れ条件 8(指摘しない例) | 比べた候補に表の番号を並べた決定と、`省く:` の行を持つ課題の自前で作る決定に、`check` が 0 を返す。`省く:` の行が `-design.md` にあり自前で作る決定が `-design-decisions.md` にある分けた文書でも 0 を返す。`省く:` の行が `issue-7-design.md` にあり自前で作る決定が `issue-7-8-design.md` にあるときも、`--issue 7` と `--issue 7 8` のどちらでも 0 を返し、同じ決定の指摘を重ねない | 省いた課題の判定を外すか、ファイルごとに閉じて判定するか、番号を 2 つ含む文書を番号ごとのまとまりへ重ねて入れると 1 になり落ちる | -| I6 | 表に無い番号を並べた決定に `unknown_candidate` を返す。番号が `-design-decisions.md` にあり表が `-design.md` にある分けた文書でも解決する。`--issue A B` で、課題 A の決定が課題 B の表にだけある番号を並べると `unknown_candidate` を返す。`issue-7-8-design.md` の決定が `issue-7-design.md` の表にだけある番号を並べたとき、`--issue 7 8` で `unknown_candidate` を返さず、指摘も 1 件も出さない | 節を文書ごとに閉じて読むと分けた文書で当たり、読んだ文書すべての節を合わせるとほかの課題の表で解決して当たらず、番号を 2 つ含む文書を番号ごとのまとまりへ重ねて入れると 8 のまとまりで当たり、いずれも落ちる | +| I5・受け入れ条件 8(指摘しない例) | 比べた候補に表の番号を並べた決定と、`省く:` の行を持つ課題の自前で作る決定に、`check` が 0 を返す。`省く:` の行が `-design.md` にあり自前で作る決定が `-design-decisions.md` にある分けた文書でも 0 を返す。`省く:` の行が `issue-7-design.md` にあり自前で作る決定が `issue-7-8-design.md` にあるときも、`--issue 7`・`--issue 8`・`--issue 7 8` のどれでも 0 を返し、同じ決定の指摘を重ねない | 省いた課題の判定を外すか、ファイルごとに閉じて判定するか、番号を 2 つ含む文書を番号ごとのまとまりへ重ねて入れるか、`--issue 8` で読む文書をつながりで閉じずに `issue-7-design.md` を読まないと 1 になり落ちる | +| I6 | 表に無い番号を並べた決定に `unknown_candidate` を返す。番号が `-design-decisions.md` にあり表が `-design.md` にある分けた文書でも解決する。`--issue A B` で、課題 A の決定が課題 B の表にだけある番号を並べると `unknown_candidate` を返す。`issue-7-8-design.md` の決定が `issue-7-design.md` の表にだけある番号を並べたときと、`issue-7-design.md` の決定が `issue-8-design.md` の表にだけある番号を並べたときは、`--issue 7`・`--issue 8`・`--issue 7 8` のどれでも `unknown_candidate` を返さず、指摘も 1 件も出さない | 節を文書ごとに閉じて読むと分けた文書で当たり、読んだ文書すべての節を合わせるとほかの課題の表で解決して当たらず、番号を 2 つ含む文書を番号ごとのまとまりへ重ねて入れると 8 のまとまりで当たり、`--issue 8` で読む文書をつながりで閉じないと `issue-7-design.md` の表を読まずに当たり、いずれも落ちる | | I7 | 3 語に無い値(「自前でつくる」)に `unknown_method` を返す。「自前で作る・標準に倣う」は自前で作る決定として扱う | 値を分けずに完全一致で比べると、組み合わせで落ちる | | コードブロックの扱い | コードブロックの中の `作り方: 自前で作る` には当たらない | コードブロックを読むと、テンプレートの例を含む文書で落ちる | | 受け入れ条件 10 | このリポジトリの `issues/` にある既存の設計文書と受け入れ条件のファイルすべてに、`check --file` が 0 を返す | 作り方の行が無い文書を違反にすると落ちる | From fbe6f319cd97d437fb1273302dbe35ad109434e2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:31:15 +0000 Subject: [PATCH 10/43] =?UTF-8?q?Docs:=20#2346=20=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=81=A7=E3=80=81CI=20=E3=81=AE=20tmux=20=E3=81=AE?= =?UTF-8?q?=E7=94=A8=E6=84=8F=E3=81=AB=20apt-get=20update=20=E3=82=92?= =?UTF-8?q?=E6=98=8E=E8=A8=98=E3=81=97=E3=80=81I2=20=E3=81=AE=E3=83=86?= =?UTF-8?q?=E3=82=B9=E3=83=88=E3=81=AE=E5=AD=90=E3=83=97=E3=83=AD=E3=82=BB?= =?UTF-8?q?=E3=82=B9=E3=81=AE=E5=AF=BE=E8=B1=A1=E3=82=92=E7=B5=9E=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-2346-design.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/issues/issue-2346-design.md b/issues/issue-2346-design.md index 2777daa0e..6f097807e 100644 --- a/issues/issue-2346-design.md +++ b/issues/issue-2346-design.md @@ -196,7 +196,7 @@ CI のテストのジョブ(F2): 分割の 2 本それぞれで、次の順 1. checkout 2. Python・Node・uv の用意 -3. tmux の用意(`command -v tmux` が見つからなければ `sudo apt-get install -y tmux`)と `tmux -V` +3. tmux の用意(`command -v tmux || { sudo apt-get update && sudo apt-get install -y tmux; }`)と `tmux -V`。apt の索引が古いと `install` が取得で失敗するため、入れる経路では `update` を `install` の直前に打つ 4. pytest(`--junitxml` を足す) 5. 成果物の保存(`if: always()`) @@ -227,7 +227,7 @@ CI のテストのジョブ(F2): 分割の 2 本それぞれで、次の順 ### 決定 3: runner のイメージが変わってもジョブが止まらないよう、tmux は無いときだけ apt で入れる -今の runner には tmux があり(あるべき姿の根拠の 1 行目)、入れる手順が無くても 4 件は走る。ただし runner のイメージの定義に tmux は載っておらず、土台の変更で消えると、一覧に載せた後はすべての Pull Request のテストのジョブが落ちる。`command -v tmux` で確かめ、無いときだけ入れる。ある間は時間を足さない。毎回 `apt-get install` を打つ形は、ある間も `apt-get update` の時間を毎回払うため採らない。手順を足さずに一覧の失敗に任せる形は、イメージが変わった日から修正が入るまで、無関係な変更のマージが止まるため採らない。 +今の runner には tmux があり(あるべき姿の根拠の 1 行目)、入れる手順が無くても 4 件は走る。ただし runner のイメージの定義に tmux は載っておらず、土台の変更で消えると、一覧に載せた後はすべての Pull Request のテストのジョブが落ちる。`command -v tmux` で確かめ、無いときだけ `sudo apt-get update` と `sudo apt-get install -y tmux` を続けて打つ。ある間は時間を足さない。毎回 `apt-get install` を打つ形は、ある間も `apt-get update` の時間を毎回払うため採らない。手順を足さずに一覧の失敗に任せる形は、イメージが変わった日から修正が入るまで、無関係な変更のマージが止まるため採らない。 根拠: Value 1 / Value 3(MVV 版 5) @@ -248,7 +248,7 @@ CI のテストのジョブ(F2): 分割の 2 本それぞれで、次の順 | 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | | --- | --- | --- | | 受け入れ条件 28・I1 | PATH から tmux を外して `test_relay_hub.py` を子プロセスの pytest で打つと、0 以外で終わり、出力にキーと `tmux` が出る | 一覧からキーを消す、または照合がパスだけを見る形に戻すと、終了コードが 0 になって落ちる | -| 受け入れ条件 28・I2 | 同じ実行に読み飛ばしの指定を付けると、終了コード 0 で、skipped がちょうど 4 件(4 件の名前) | キーをファイル全体にすると skipped が 48 件になって落ちる | +| 受け入れ条件 28・I2 | 同じ PATH で、子プロセスの対象を `test_relay_hub.py::TestWithTmux` とキーの外の 1 項目 `test_relay_hub.py::test_bad_name_is_refused[A]` のノード ID に絞り、読み飛ばしの指定を付けて打つと、終了コード 0 で、skipped がちょうど 4 件(4 件の名前)・passed が 1 件(外の 1 項目)。ファイル全体を打たないのは、tmux を使わない 44 件を全体の実行のたびに子プロセスでもう一度走らせないためである | キーをファイル全体にすると、外の 1 項目も skipped になり passed が 0 件で落ちる | | I3 | 一覧と同じ照合に、キーの直後が `/`・`::`・それ以外の識別子を渡し、前の 2 つだけが当たる | 前方一致を文字列の前方一致だけにすると、`TestWithTmuxOld` が当たって落ちる | | I4 | `test_relay_hub.py` の 4 件に `skipif` の印が無いことを、収集した項目の印で確かめる | `needs_tmux` を戻すと落ちる | | 受け入れ条件 29・I6 | 実装の PR の CI で、分割 0 の成果物の JUnit XML に 4 件の名前があり、どれも failure・skipped を持たない。ジョブのログに `tmux -V` の行がある | 実装の PR の検証で確かめる(自動のテストは書かない。ワークフローの文面を照合するテストになるため) | From 51933e30138ccd9a2aec7510a94719e7179086a4 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:37:57 +0000 Subject: [PATCH 11/43] =?UTF-8?q?Docs:=20#2315=20=E3=81=AE=E6=AE=8B?= =?UTF-8?q?=E3=82=8A=EF=BC=88=E5=B7=AE=E5=88=86=E3=81=AE=E6=A3=9A=E5=8D=B8?= =?UTF-8?q?=E3=81=97=E3=81=AE=E6=89=80=E8=A6=81=EF=BC=89=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=8D?= =?UTF-8?q?=E3=80=81=E7=94=A8=E8=AA=9E=E9=9B=86=E3=81=B8=202=20=E8=AA=9E?= =?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 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 2 + docs/glossary/glossary.json | 12 +++ issues/issue-2315-requirements.md | 140 ++++++++++++++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 issues/issue-2315-requirements.md diff --git a/docs/glossary.md b/docs/glossary.md index cddb8f0fc..e0f926bf5 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -395,6 +395,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 引継ぎの区間 | — | conductor が引継ぎ文書を書き始める前に記録した「引継ぎ開始」の時点から、そのセッションの最後の行まで | — | — | `docs/specifications/ndf-context-restart-policy.md` | | 呼び手 | — | 関数かエントリポイントを呼ぶ、テストの外のファイル(Skill の本文・雛形・hook・CI・ほかのスクリプト)。テストだけが呼ぶものは呼び手 0 と数える | — | — | — | | 残す関数の一覧 | — | 関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの | — | — | — | +| プランの進行の記録 | — | supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録(`<状態のディレクトリ>/progress.jsonl`)。開発ループのまとめのプランでは `.ndf/loop/<名>/lap-<番号>/close*/` の下にある(#2315) | — | — | — | ## NDF の Slack 通知(`ndf-notification`) @@ -721,6 +722,7 @@ backlog-refinement と out-of-scope が課題を分類し、起票するとき | 棚卸しの深さ | — | 1 回の棚卸しが扱う範囲。差分の棚卸し・見直し・大整理の 3 つ。`upkeep.py` の `--depth diff\|review\|overhaul`(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 調査の項目 | — | 調査を小さく分けた単位。側面と対象の組(例: `seeds.library:pytest`)。1 項目が 1 つの調査の記録になる(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 付け直しの記録 | — | 大整理が課題の親とマイルストーンを変える前に残す、課題ごとの前の状態。`upkeep.py revert` が読んで戻す(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | +| 深さの記録 | — | 棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は秒・ドルを持つ(#2315) | — | — | — | ## NDF の worktree(`ndf-worktree`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index bdd8b8390..9694b31a5 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5456,6 +5456,18 @@ "context": "ndf-workflow", "meaning": "関数の棚卸し(uvx vulture)が呼び手 0 と見るが、vulture に見えない呼ばれ方などで残す関数を、理由の種類と理由と定義のあるファイルを 1 行ずつ添えて持つ一覧。置き場は scripts/ndf-vulture-keep.txt。構造チェックの例外リストとは別のもの", "pending_source": "issues/issue-2369-design.md" + }, + { + "term": "深さの記録", + "context": "ndf-issue-upkeep", + "meaning": "棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は秒・ドルを持つ(#2315)", + "pending_source": "issues/issue-2315-requirements.md" + }, + { + "term": "プランの進行の記録", + "context": "ndf-workflow", + "meaning": "supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録(`<状態のディレクトリ>/progress.jsonl`)。開発ループのまとめのプランでは `.ndf/loop/<名>/lap-<番号>/close*/` の下にある(#2315)", + "pending_source": "issues/issue-2315-requirements.md" } ] } diff --git a/issues/issue-2315-requirements.md b/issues/issue-2315-requirements.md new file mode 100644 index 000000000..5112a7ee6 --- /dev/null +++ b/issues/issue-2315-requirements.md @@ -0,0 +1,140 @@ +# #2315: 開発ワークフローに「見直し」と「大整理」を足す: 棚卸しを毎スプリントの差分の棚卸し・数回に 1 回の見直し・十数回に 1 回の大整理(根本原因の発見と組み直し、後回しの再評価)に分ける + +正は課題の本文(#2315)で、この文書はその写しである。`spec-copy.py write` で作り直す。手で直さない。 + +## 依頼(原文) + +> ただ、このフェーズはsprintの終わりに毎回実行、では重すぎる気もします。 + +> ただ、棚卸については、バージョンアップに伴う既存issueの追従は毎sprint、優先順位の再評価、順位付け、マイルストーン再編、枝刈りは数回に一回など、グラデーションが必要かもしれません。 + +(書き手: 利用者、日付: 2026-11-01 JST。依頼の全文と、ほかの 3 つの原文は確定仕様 https://github.com/devbasex/ai-plugins/blob/develop/docs/specifications/ndf-backlog-refinement-depths.md の元になった要求の写し(コミット 8d72be360 の `issues/issue-2315-requirements.md`)にある) + +この課題の残りは、上の「重すぎる」に対して、毎スプリントの棚卸しが実際に軽くなったかを確かめる 1 点である。棚卸しの 3 つの深さ(差分の棚卸し・見直し・大整理)は ndf v10.27.14 で配布し、受け入れ条件 17 を除く起票時の条件は、その配布と確定仕様の「テスト観点」が持つ。確かめたこと(2026-11-04、origin/develop 7476a0899): `pytest` で `test_upkeep_depth*`・`test_upkeep_owner*`・`test_upkeep_overhaul*` が 30 件合格。 + +## 起票者の推定 + +| # | 条件 | 推した出所 | この要求での扱い | +| --- | --- | --- | --- | +| S15 | 毎スプリントの棚卸しの所要が、今(206〜224 秒・1.41〜1.61 ドル)より短くなることを実測で示す | 原文の「重すぎる」。Value 4(外の利用で測って決める) | 受け入れ条件 17。2026-11-02 の承認ゲート 1 で承認済み | +| S16 | 比べる値は、変更の後の差分の棚卸し 3 回の中央値とし、今の範囲の下端(206 秒・1.41 ドル)を下回ることを「短い」とする | 1 回ごとのばらつき(変更の後の 4 回で 270〜346 秒)が範囲の幅より大きいため | 受け入れ条件 17a・前提 1 | +| S17 | 深さの記録の差分の棚卸しの行へ、棚卸しのステップの秒・ドルを機械で写す | 確定仕様の「テスト観点」が測る記録をこの行と決めているが、行の値が空のため | 受け入れ条件 17b | + +## 目的 + +- 毎スプリントの棚卸し(差分の棚卸し)の所要を、3 つの深さに分ける前より短くし、短くなったことを深さの記録で示す + +## 今の状態(実測) + +**変更の後も、差分の棚卸しは分ける前より長い。** このリポジトリのメインディレクトリのプランの進行の記録(`.ndf/loop/*/lap-*/close*/*-state/progress.jsonl`)の、`refine` のステップの行を数えた。 + +| 開発ループの周回 | 時刻(UTC) | 秒 | ドル | 終了コード | 時期 | +| --- | --- | ---: | ---: | ---: | --- | +| l20261031 周回 2 | 2026-10-31 08:24 | 224.4 | 1.6101 | 0 | 分ける前(基準) | +| l20261031 周回 3 | 2026-10-31 13:16 | 206.3 | 1.4075 | 0 | 分ける前(基準) | +| lfactory2 周回 3 | 2026-11-02 13:03 | 270.8 | 2.1563 | 0 | 変更の後 | +| lflow 周回 1 | 2026-11-04 06:22 | 316.4 | 1.7723 | 0 | 変更の後 | +| lroot2 周回 5 | 2026-11-04 06:23 | 314.4 | 1.8441 | 0 | 変更の後 | +| lfactory3 周回 1 | 2026-11-04 15:08 | 346.2 | 2.1881 | 0 | 変更の後 | + +(終了コードが 0 でない行と、同じ周回のやり直しの行は除いた。集計のコマンドは `grep '"step": "refine",' | grep '"kind": "step"'` の `seconds`・`cost`) + +**深さの記録には、比べる値が残っていない。** `.ndf/backlog/depths.jsonl` の差分の棚卸しの行は 1 件(2026-11-04 15:08 UTC、スプリント lfactory3-1)で、`cost`・`seconds`・`loop`・`lap` はどれも `null` である。まとめのプランの `refine-record` のステップのコマンドが `depth record --depth diff --sprint <名前>` だけで、`--cost`・`--seconds`・`--loop`・`--lap` を渡していない(`plugins/ndf/scripts/supervise_lib/sprint_close_steps.py` の `refine_steps`)。秒とドルは同じプランの `progress.jsonl` の `refine` の行にある(上の表の lfactory3 周回 1 の行)。 + +**差分の棚卸しが、見積の無い課題を毎回 10 件まで拾っている。** `SKILL.md` は差分の棚卸しが見積の段階を付けるのを「そのスプリントで起票した課題」だけと定めるが、候補を集める処理は深さを見ずに、記録の見積が無い open の課題すべてを `unscored` の経路で足す(`plugins/ndf/skills/backlog-refinement/scripts/upkeep.py` の 230 行目。深さで分けているのは 232 行目の `review` の経路だけ)。変更の後の報告のうち、lroot2 周回 5 と lfactory3 周回 1 は候補が上限の 10 件に達し、経路の内訳にそれぞれ「見積の無い課題 10」「見積の無い課題 8」と書いている(lflow 周回 1 と lfactory2 周回 3 の報告には内訳の数が無く、未確認)。所要が縮まらない原因の候補であり、確かめるのは設計である。 + +## 前提 + +- 前提 1: 「今より短い」は、今の範囲(206〜224 秒・1.41〜1.61 ドル)の下端を下回ることとする。1 回ごとのばらつきが大きいため、変更の後の差分の棚卸し 3 回の中央値で比べる +- 前提 2: 比べるのはこのリポジトリ(ai-plugins)のメインディレクトリの周回だけである。並行して動く別の開発ループの周回も数に入れる +- 前提 3: 差分の棚卸しを担う言語モデルは、基準の 2 回と同じ `claude-opus-5-5` とする。モデルが変わった周回は比べる 3 回に入れない +- 前提 4: 1 回の候補の上限(既定 10 件)は変えない。上限を下げて所要を縮めることは、この課題の達成として数えない + +## 対象範囲 + +含む: +- 差分の棚卸しの所要を縮めること(何を削るか・どう縮めるかは設計で決める) +- 差分の棚卸しの行に、そのステップの秒・ドルと、開発ループ・周回を残すこと +- 差分の棚卸しの見積の段階の付与を、`SKILL.md` の定めどおりそのスプリントで起票した課題に揃えること + +含まない: +- 見直しと大整理の所要と、その費用と秒の上限(#2423 が持つ) +- 1 回の候補の上限の値の変更(前提 4) +- 過去の周回の深さの記録を書き直すこと(比べる 3 回は、変更の後に新しく書かれた行から取る) + +## ドメインイベント + +| # | イベント | 引き金 | 失敗したとき | 順序の前提 | +| --- | --- | --- | --- | --- | +| E1 | 差分の棚卸しの候補を集めた | まとめのプランの `refine` のステップ | 候補が読めなければ棚卸しのステップが失敗で終わる | スプリントのマージが終わった | +| E2 | 差分の棚卸しのステップが終わり、秒・ドルがプランの進行の記録に書かれた | `refine` のステップの終了 | 失敗で終わっても秒・ドルは書かれる(今の振る舞い) | E1 | +| E3 | 差分の棚卸しの行が深さの記録に書かれた | `refine-record` のステップ | 秒・ドルが読めなければ `null` のまま書く(行は欠かさない) | E2 | +| E4 | 変更の後の 3 回の行がそろい、中央値を基準と比べた | リリース後テスト | 3 回そろわなければ判定を保留し、そろった回数を報告に書く | E3 が 3 回 | + +E3 の失敗の経路(秒・ドルが読めないときに行を欠かすか `null` で書くか)は受け入れ条件 17b-3 に移した。 + +## 用語 + +| 用語 | 意味 | +| --- | --- | +| 差分の棚卸し | 毎スプリントの棚卸し。誤りの訂正と、そのスプリントで起票した課題への見積の段階の付与だけを行う(`--depth diff`) | +| 深さの記録 | `.ndf/backlog/depths.jsonl`。深さごとの起動の判定と実行を 1 行ずつ残す | +| プランの進行の記録 | まとめのプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録(`.ndf/loop/<ループ>/lap-<番号>/close*/*-state/progress.jsonl`) | + +## 受け入れ条件 + +- [ ] 17. このリポジトリで、毎スプリントの棚卸しの所要(秒・ドル)が今(開発ループ l20261031 の周回 2・3 の `refine` のステップで 206〜224 秒・1.41〜1.61 ドル)より短くなったことを、変更の後の周回の記録で示す + - [ ] 17a. 変更を配布した後の差分の棚卸しの行 3 件(深さの記録の `depth` が `diff`・`event` が `ran`)で、`seconds` の中央値が 206 未満、`cost` の中央値が 1.41 未満である + - [ ] 17b-1. まとめのプランで差分の棚卸しを通すと、深さの記録の差分の棚卸しの行の `seconds`・`cost` が、同じプランの進行の記録の `refine` の行の `seconds`・`cost` と一致する + - [ ] 17b-2. 同じ行の `loop`・`lap` が、そのプランを起動した開発ループの名前と周回の番号になる(開発ループの外で人が起動したプランでは `null`) + - [ ] 17b-3. プランの進行の記録に `refine` の行が無いとき(ステップが打ち切られたなど)も、差分の棚卸しの行は書かれ、`seconds`・`cost` は `null` になる + - [ ] 17c. 差分の棚卸しの候補(`candidates --depth diff`)に `unscored` の経路で入る課題は、前の正式版のタグの時刻より後に作られた課題だけである。見直し(`--depth review`)の候補の集め方は変わらない + - [ ] 17d. 差分の棚卸しの行の中央値の比べ方(3 件そろったか・中央値・基準との差)を、コマンド 1 本で深さの記録から出せる。3 件に満たなければ、そろった件数を返して判定を保留する + +## 非機能の条件 + +| 大項目 | 条件 | +| --- | --- | +| 性能・拡張性 | 差分の棚卸しの所要は、変更の後 3 回の中央値で 206 秒未満・1.41 ドル未満(受け入れ条件 17a) | +| 運用・保守性 | 所要を比べる値が深さの記録に残り、プランの進行の記録を手で探さずに比べられる(受け入れ条件 17b・17d) | + +## 影響 + +| 対象 | 影響 | +| --- | --- | +| 公開インタフェース | 変わらない。`upkeep.py depth record` の引数は既にある `--cost`・`--seconds`・`--loop`・`--lap` を使う。比べ方のコマンド(17d)は足す | +| データ | 深さの記録の差分の棚卸しの行に値が入る。行の形は変わらない。既存の行は書き直さない | +| 既存の振る舞い | 差分の棚卸しの候補から、前の正式版より前に作られた見積の無い課題が外れる。それらは見直しで拾う | + +## 検証手段 + +| 項目 | 手段 | +| --- | --- | +| テスト | `uv run --frozen --project . --all-extras pytest plugins/ndf/skills/backlog-refinement/tests plugins/ndf/scripts/tests/test_supervise_pace.py -q -n 4` | +| 静的解析・型検査 | プロジェクトの CI のジョブ(`ruff`・構造の検査)をそのまま使う | +| 手動確認 | 変更を配布した後の開発ループで差分の棚卸しが 3 回通ったら、17d のコマンドで中央値を出し、基準と比べる(受け入れ条件 17a。リリース後テストで確かめる) | + +## 前提とする取り決め + +| 項目 | 参照先 / 決めたこと | +| --- | --- | +| プロジェクト構造 | `AGENTS.md`。深さの記録は `.ndf/backlog/` の手元の記録でコミットしない(確定仕様の「運用」) | +| コーディング規約 | `AGENTS.md` の「最小限のコード実装」。文言を照合するテストは書かない | +| テスト戦略 | 17b・17c・17d は単体テストで、17a はリリース後テストの実測で確かめる | + +## 境界 + +| 区分 | 内容 | +| --- | --- | +| 常に行う | 既存テストの実行、変更の後の所要の実測 | +| 確認してから行う | 差分の棚卸しの手順(`SKILL.md`)から作業を外すこと。承認ゲート 1・2 で人が見る | +| 行わない | 候補の上限を下げること、見直しと大整理の上限(#2423)、過去の記録の書き直し | + +## 未決 + +| 項目 | 誰が決めるか | 期限 | +| --- | --- | --- | +| 17c の後も中央値が基準を下回らないとき、差分の棚卸しから何を外すか(手順の中のどの作業が時間を使っているかの内訳) | 設計(内訳の実測から決め、承認ゲート 1 で人が確かめる) | 設計 PR | +| 3 回の実測が配布から何日でそろうか(並行する開発ループの数による) | リリース後テストの担当 | 配布の後 | + +関連: #2423(棚卸しの見直しのステップに費用と秒の上限が効かない) From 77839f96d997149feda578ded6324d836d3ddbe2 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:48:33 +0000 Subject: [PATCH 12/43] =?UTF-8?q?Docs:=20#2315=20=E3=81=AE=E6=AE=8B?= =?UTF-8?q?=E3=82=8A=EF=BC=88=E5=B7=AE=E5=88=86=E3=81=AE=E6=A3=9A=E5=8D=B8?= =?UTF-8?q?=E3=81=97=E3=81=AE=E6=89=80=E8=A6=81=EF=BC=89=E3=81=AE=E8=A8=AD?= =?UTF-8?q?=E8=A8=88=E3=82=92=E7=BD=AE=E3=81=8D=E3=80=81=E7=94=A8=E8=AA=9E?= =?UTF-8?q?=E9=9B=86=E3=81=AE=202=20=E8=AA=9E=E3=81=AE=E6=84=8F=E5=91=B3?= =?UTF-8?q?=E3=82=92=E6=94=B9=E3=82=81=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 差分の棚卸しで見積の段階を付ける課題を起点のタグより後に作られた課題に絞り、深さの記録の 差分の棚卸しの行へプランの進行の記録から秒・ドル・言語モデルを、開発ループから名前と周回を写し、 所要の中央値を基準と比べるコマンドを足す設計。工程ごとの所要の実測(会話の記録 4 本)を決定 1 に置く。 Co-Authored-By: Claude Opus 5.5 --- docs/glossary.md | 4 +- docs/glossary/glossary.json | 4 +- issues/issue-2315-design.md | 316 ++++++++++++++++++++++++++++++++++++ 3 files changed, 320 insertions(+), 4 deletions(-) create mode 100644 issues/issue-2315-design.md diff --git a/docs/glossary.md b/docs/glossary.md index e0f926bf5..7546a5d3f 100644 --- a/docs/glossary.md +++ b/docs/glossary.md @@ -356,7 +356,7 @@ NDF が提供する工程・承認ゲート・モード・ステップの語。d | 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 | — | 手順を持たず、書き方・記録の仕方のような規則だけを持つ Skill(document-style・logging-guidelines など)。Skill の対応表に置けるのはこれだけで、工程 Skill は置かない(#2376) | — | — | `docs/specifications/ndf-worker-stage-skills.md` | -| 差分の棚卸し | — | 毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題への見積の段階の付与だけを行う(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | +| 差分の棚卸し | — | 毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題(起点のタグの時刻より後に作られた課題)への見積の段階の付与だけを行う(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 見直し | — | 数スプリントに 1 回行う棚卸しの深さ。後回しを除く open の課題の枝刈り・順位付け・マイルストーンの再編と、ニーズ・シーズの調査からの起票を行う(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 大整理 | — | 十数スプリントに 1 回行う棚卸しの深さ。未完了の課題すべてを原因の型で分け、Issue 単位では直らない根本原因を探し、見つかれば大方針を立てて課題とマイルストーンを組み直す。後回しの再評価を含む(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 後回しの再評価 | — | 後回しのマイルストーンの課題を、前倒し・やらない・据え置きに分けること。大整理の一部(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | @@ -722,7 +722,7 @@ backlog-refinement と out-of-scope が課題を分類し、起票するとき | 棚卸しの深さ | — | 1 回の棚卸しが扱う範囲。差分の棚卸し・見直し・大整理の 3 つ。`upkeep.py` の `--depth diff\|review\|overhaul`(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 調査の項目 | — | 調査を小さく分けた単位。側面と対象の組(例: `seeds.library:pytest`)。1 項目が 1 つの調査の記録になる(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | | 付け直しの記録 | — | 大整理が課題の親とマイルストーンを変える前に残す、課題ごとの前の状態。`upkeep.py revert` が読んで戻す(#2315) | — | — | `docs/specifications/ndf-backlog-refinement-depths.md` | -| 深さの記録 | — | 棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は秒・ドルを持つ(#2315) | — | — | — | +| 深さの記録 | — | 棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は、棚卸しのステップの秒・ドル・言語モデルの名前を持つ(#2315) | — | — | — | ## NDF の worktree(`ndf-worktree`) diff --git a/docs/glossary/glossary.json b/docs/glossary/glossary.json index 9694b31a5..437861e1a 100644 --- a/docs/glossary/glossary.json +++ b/docs/glossary/glossary.json @@ -5188,7 +5188,7 @@ { "term": "差分の棚卸し", "context": "ndf-workflow", - "meaning": "毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題への見積の段階の付与だけを行う(#2315)", + "meaning": "毎スプリントのまとめで行う棚卸しの深さ。そのスプリントの変更で食い違った本文の訂正・直ったのに開いたままの課題を閉じる・閉じた親の子の見直し・そのスプリントで起票した課題(起点のタグの時刻より後に作られた課題)への見積の段階の付与だけを行う(#2315)", "source": "docs/specifications/ndf-backlog-refinement-depths.md" }, { @@ -5460,7 +5460,7 @@ { "term": "深さの記録", "context": "ndf-issue-upkeep", - "meaning": "棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は秒・ドルを持つ(#2315)", + "meaning": "棚卸しの深さごとの起動の判定と実行を 1 行ずつ残す記録。メインディレクトリの `.ndf/backlog/depths.jsonl` で、コミットしない。差分の棚卸しの行は、棚卸しのステップの秒・ドル・言語モデルの名前を持つ(#2315)", "pending_source": "issues/issue-2315-requirements.md" }, { diff --git a/issues/issue-2315-design.md b/issues/issue-2315-design.md new file mode 100644 index 000000000..3fbeb0c35 --- /dev/null +++ b/issues/issue-2315-design.md @@ -0,0 +1,316 @@ +# 差分の棚卸し: 見積の段階をそのスプリントの課題だけに付けて所要を縮め、縮んだかを深さの記録から 1 本のコマンドで確かめる(#2315) + +## 目的 + +- **今どうなっているか**: 毎スプリントの棚卸し(差分の棚卸し)は、3 つの深さに分けた後も 270〜346 秒・1.77〜2.19 ドルかかり、分ける前(206〜224 秒・1.41〜1.61 ドル)より長い。深さの記録の差分の棚卸しの行は秒・ドル・開発ループ・周回がどれも空で、比べるにはプランの進行の記録を手で探すしかない +- **誰に効くか**: 開発ループを回す利用者(周回ごとの待ち時間と費用)と、リリース後テストの担当(所要の比べ方) +- **変えると何が成り立つか**: 差分の棚卸しが見積の段階を付ける課題が、手順書の定めどおりそのスプリントで起票した課題だけになり、所要が分ける前より短くなる見込みが立つ。短くなったかを、深さの記録からコマンド 1 本で判定できる + +## 適用範囲 + +- **働く範囲**: NDF を使うすべてのリポジトリ。候補の集め方と深さの記録の書き方は配布物の `upkeep.py` が持つ。比べる値(基準の秒・ドル・件数・言語モデル)はこのリポジトリの実測の値で、コマンドの引数で受ける +- **プロジェクトごとに違うもの**: 基準の秒とドル、比べる件数、言語モデルの名前。どれも比べ方のコマンドの引数で受け、既定に埋め込まない +- **当たるモード**: `standard` + +## あるべき姿の根拠 + +| 根拠 | 種類 | 何を示すか | +| --- | --- | --- | +| 依頼の原文「このフェーズは sprint の終わりに毎回実行、では重すぎる」 | 利用者の指示の原文 | 毎スプリントの棚卸しを軽くすることが目的である | +| 差分の棚卸しの会話の記録 4 本の工程ごとの所要(決定 1 の表) | 実測 | 見積の段階を付ける工程は 21〜161 秒で、段階を付ける課題の数で伸び縮みする | +| 2026-11-05 の open の課題 246 件のうち、見積の無い経路に当たる課題 157 件(見積が無い 7 件・見積の後に要約値が変わった 150 件) | 実測 | 候補を集める処理は、深さを見ずに古い課題まで段階を付ける対象として渡している | +| 開発ループ lfactory3 周回 1 で段階を付けた 8 件は、どれも起点のタグ(ndf--v10.27.15、2026-11-03T19:04Z)より前に作られていた | 実測 | 手順書の定め(そのスプリントで起票した課題だけ)に揃えると、この周回で付ける課題は 0 件になる | + +要求と受け入れ条件は #2315 の本文にある(コピーは [issues/issue-2315-requirements.md](issue-2315-requirements.md))。この文書は「どう作るか」だけを扱う。 +3 つの深さそのものの仕様は確定仕様 [docs/specifications/ndf-backlog-refinement-depths.md](../docs/specifications/ndf-backlog-refinement-depths.md) にある。 + +## ドメインモデル + +### コンテキスト + +| コンテキスト | 何の語が 1 つの意味に決まるか | +| --- | --- | +| 課題の棚卸し(`ndf-issue-upkeep`) | 候補・見積の段階・深さの記録 | +| 開発ワークフロー(`ndf-workflow`) | 差分の棚卸し・開発ループ・周回・まとめのプラン・プランの進行の記録 | + +関係は顧客 / 供給者である。課題の棚卸しが供給者で、深さの記録の行の形と比べ方のコマンドを決める。開発ワークフローは顧客で、 +まとめのプランから深さの記録へ書くコマンドを組み、開発ループの名前と周回の番号を引数で渡す。課題の棚卸しはプランの進行の +記録の行の形(supervise の進捗ログが定める `kind: step` の行)をそのまま読む(順応者として読む部分はこの 1 つだけ)。 + +### 集約 + +| 集約 | 持ち主(書き換えてよいもの) | 根 | エンティティ | 値オブジェクト | +| --- | --- | --- | --- | --- | +| 深さの記録 | 深さの記録の書き込み(`upkeep.py depth record`・`depth due`)。行を足すだけで、書き換えない | 深さの記録のファイル | 行(1 回の判定か実行) | 所要(秒・ドル)・範囲(開発ループ・周回・スプリント)・言語モデルの名前の並び | +| 候補 | 候補の収集(`upkeep.py candidates`) | 1 回の候補の結果 | 候補の課題 | 経路の集合・要約値 | + +プランの進行の記録は開発ワークフローの集約で、持ち主は supervise のプランの実行である。この変更は読むだけで、書き換えない。 + +### 不変条件 + +| # | 集約 | 条件 | 破れたときの扱い | +| --- | --- | --- | --- | +| I1 | 深さの記録 | まとめのプランが書く差分の棚卸しの行の秒・ドルは、同じプランの進行の記録の最後の棚卸しのステップの行の秒・ドルと一致する | テストで落とす | +| I2 | 深さの記録 | プランの進行の記録が無い・読めない・棚卸しのステップの行が無いときも、差分の棚卸しの行は 1 行書かれ、秒・ドル・言語モデルは空になる | テストで落とす。行を欠かすと見直しの回数の数え方がずれる | +| I3 | 深さの記録 | 行の開発ループ・周回は、そのプランを組んだ開発ループの名前と周回の番号である。開発ループの外で組んだプランでは空である | テストで落とす | +| I4 | 候補 | 差分の棚卸しの候補に見積の無い経路で入る課題は、起点(`--since-ref`)の時刻より後に作られた課題だけである | テストで落とす | +| I5 | 候補 | 見直しの候補の集め方は変わらない(見積の無い経路は作られた時刻で絞らない) | テストで落とす | +| I6 | 深さの記録 | 比べ方のコマンドは、起点より後の差分の棚卸しの実行の行のうち、秒とドルがそろい、言語モデルが指定と一致する行を古い順に指定の件数だけ使う。件数に満たなければ判定しない | テストで落とす。終了コード 3 で保留を返す | + +### ドメインイベント + +| # | イベント | 発生元 | 受け手 | +| --- | --- | --- | --- | +| E1 | 差分の棚卸しの候補を集めた | 候補の収集 | 棚卸しの担当(区分と見積の段階を決める) | +| E2 | 差分の棚卸しのステップが終わり、秒・ドルがプランの進行の記録に書かれた | supervise のプランの実行 | 深さの記録の書き込み(次のステップ) | +| E3 | 差分の棚卸しの行が深さの記録に書かれた | 深さの記録の書き込み | 見直しと大整理の起動の判定・比べ方のコマンド | +| E4 | 変更の後の 3 回の行がそろい、中央値を基準と比べた | 比べ方のコマンド(リリース後テストの担当が打つ) | リリース後テストの判定 | + +### 用語 + +| 用語 | 意味 | 用語集への反映 | +| --- | --- | --- | +| 差分の棚卸し | 毎スプリントの棚卸しの深さ。見積の段階を付けるのは、起点のタグより後に作られた課題だけである | 意味の変更(`ndf-workflow`。「そのスプリントで起票した課題」を起点のタグの時刻で決めると明記する) | +| 深さの記録 | 深さごとの起動の判定と実行を 1 行ずつ残す記録。差分の棚卸しの行は秒・ドル・言語モデルを持つ | 意味の変更(`ndf-issue-upkeep`。言語モデルを足す) | +| プランの進行の記録 | supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録 | 変更なし(`ndf-workflow`) | + +## 機能一覧 + +| # | 機能 | 誰が使うか | +| --- | --- | --- | +| F1 | 差分の棚卸しの候補で、見積の段階を付ける対象を起点のタグより後に作られた課題に絞る | 差分の棚卸しの担当(まとめのプランの棚卸しのステップ) | +| F2 | 差分の棚卸しの行へ、そのステップの秒・ドル・言語モデルをプランの進行の記録から写す | まとめのプラン | +| F3 | 差分の棚卸しの行へ、開発ループの名前と周回の番号を残す | 開発ループ | +| F4 | 差分の棚卸しの所要の中央値を基準と比べる | リリース後テストの担当 | + +## 構成要素 + +| 要素 | 責務 | +| --- | --- | +| 候補の収集(`upkeep.py` の `cmd_candidates`) | 差分の棚卸しでは、見積の無い経路に入れる課題を作られた時刻が起点より後のものに絞る(F1) | +| 深さの記録の書き込み(`upkeep_depth.py` の `cmd_record`) | 新しい引数で受けたプランの進行の記録から、指定のステップの最後の行の秒・ドル・言語モデルを読み、行へ写す。読めなければ空で書く(F2) | +| 比べ方のコマンド(`upkeep_depth.py` の新しい `cmd_compare`) | 深さの記録から比べる行を選び、中央値・基準との差・判定を返す(F4) | +| まとめのプランの棚卸しのステップ(`sprint_close_steps.py` の `refine_steps`) | 深さの記録の書き込みのコマンドに、プランの進行の記録のパス・ステップの名前・開発ループ・周回を足す(F2・F3) | +| まとめのプランの引数(`new_args.py`) | `new close` が開発ループの名前と周回の番号を受ける(F3) | +| 開発ループのまとめの組み立て(`sprint_loop_gate.py` の `build_close`) | `new close` へ開発ループの名前と周回の番号を渡す(F3) | +| 手順書(`backlog-refinement` の `SKILL.md`)と `upkeep.py` の冒頭の説明 | 見積の無い経路の拾い方が深さで違うことと、比べ方のコマンドを書く | + +```mermaid +graph TD + subgraph 開発ワークフロー + L[開発ループの
まとめの組み立て] --> A[new close の引数] + A --> P[まとめのプランの
棚卸しのステップ] + P --> G[(プランの
プランの進行の記録)] + end + subgraph 課題の棚卸し + C[候補の収集] + W[深さの記録の書き込み] + K[比べ方のコマンド] + D[(深さの記録)] + end + P -->|棚卸しのステップ| C + P -->|記録のステップ| W + G -->|秒・ドル・言語モデル| W + W --> D + D --> K +``` + +手順書と冒頭の説明は呼び出しの関係を持たないため、図に含めない。 + +## データ構造 + +深さの記録(`.ndf/backlog/depths.jsonl`)の行の最上位の列は変えない。差分の棚卸しの実行の行で、値の入り方だけが変わる。 +過去の行は書き換えない(行を足すだけの事象の記録のまま)。 + +| 列 | 型 | 空を許すか | 意味 | +| --- | --- | --- | --- | +| `seconds` | 数 | 許す | 棚卸しのステップの秒。空は「プランの進行の記録から読めなかった」か「開発ループの外で手で打った」。`--seconds` を渡せばその値を優先する | +| `cost` | 数 | 許す | 棚卸しのステップのドル。空の意味は `seconds` と同じ | +| `values.models` | 文字列の配列 | 許す(キーを置かない) | 棚卸しのステップで使った言語モデルの名前(プランの進行の記録の `models` のキーを名前の順に並べたもの)。キーが無いのは読めなかったとき | +| `loop` | 文字列 | 許す | 開発ループの名前。空は開発ループの外で組んだプラン | +| `lap` | 整数 | 許す | 周回の番号。空の意味は `loop` と同じ | + +| 機能 | 深さの記録 | プランの進行の記録 | 候補の結果 | +| --- | --- | --- | --- | +| F1 | — | — | C | +| F2 | C | R | — | +| F3 | C | — | — | +| F4 | R | — | — | + +既存の行の移行はしない。比べ方のコマンドは秒とドルが空の行を数えないため、変更の前の行は自然に外れる。 + +## 入出力の契約 + +### `upkeep.py depth record`(引数を足す) + +| 項目 | 内容 | +| --- | --- | +| 入力(追加) | `--progress <パス>`(任意。プランの進行の記録)・`--step <ステップの id>`(任意。既定 `refine`) | +| 出力 | 変わらない(足した行を `items` に 1 つ返す) | +| 失敗の形 | プランの進行の記録が無い・読めない・行が無いときは、秒・ドル・言語モデルを空にして行を書き、終了コード 0 で返す(I2)。`items[0]` の行で空と分かる | +| 互換性 | `--progress` を渡さない既存の呼び出し(見直し・大整理の担当と、持ち主になれなかった記録)は今までどおり動く | + +### `upkeep.py depth compare`(新しい) + +```text +python3 upkeep.py depth compare --since-ref <配布した正式版のタグ> --seconds-under 206 --cost-under 1.41 + [--count 3] [--model claude-opus-5-5] [--depth diff] [--root ] +``` + +| 項目 | 内容 | +| --- | --- | +| 入力 | `--since-ref`(必須。この ref のコミットの時刻より後の行だけを使う)・`--seconds-under`・`--cost-under`(必須。基準)・`--count`(既定 3)・`--model`(任意。渡すと `values.models` がこの 1 つだけの行に限る)・`--depth`(既定 `diff`) | +| 出力 | 統一の結果の形の 1 行の JSON。`metrics` は `count`(そろった件数)・`needed`・`median_seconds`・`median_cost`・`baseline`(`{seconds, cost}`)・`delta`(`{seconds, cost}`。中央値 − 基準)・`judged`(真偽)。`items` は使った行(`at`・`sprint`・`loop`・`lap`・`seconds`・`cost`)と、外した行(`result: excluded`・`reason`: `no_usage` / `model`) | +| 失敗の形 | 0 = 秒とドルの中央値がどちらも基準を下回った / 1 = そろったが、どちらかが基準を下回らない / 3 = 件数に満たない(判定を保留。`metrics.count` にそろった件数)/ 2 = 深さの記録か ref を読めない | +| 互換性 | 新しいコマンドで、既存の呼び出し側は無い | + +### `supervise.py new close`(引数を足す) + +| 項目 | 内容 | +| --- | --- | +| 入力(追加) | `--loop <開発ループの名前>`・`--lap <周回の番号>`(どちらも任意) | +| 出力 | まとめのプランの深さの記録の書き込みのステップのコマンドに `--loop`・`--lap` が入る。渡さなければ入らない | +| 互換性 | 開発ループの外で人が組むプランは今までどおり動き、行の `loop`・`lap` は空のまま | + +### 候補の収集(振る舞いの変更) + +`candidates --depth diff` の見積の無い経路(`unscored`)は、課題の作られた時刻(`created_at`)が起点(`--since-ref` のコミットの時刻。 +結果の `metrics.since`)より後の課題だけを足す。`--depth review` は変えない。結果の形は変わらない。 + +## 処理の流れ + +```mermaid +sequenceDiagram + participant P as まとめのプラン + participant C as 候補の収集 + participant G as プランの進行の記録 + participant W as 深さの記録の書き込み + P->>C: 棚卸しのステップ(候補を集める) + C-->>P: 候補(見積の無い経路は起点より後の課題だけ) + P->>G: 棚卸しのステップの行(秒・ドル・言語モデル) + P->>W: 記録のステップ(プランの進行の記録のパス・開発ループ・周回) + W->>G: 最後の棚卸しのステップの行を読む + alt 行がある + G-->>W: 秒・ドル・言語モデル + else 無い・読めない + G-->>W: 無し(空で書く) + end + W-->>P: 差分の棚卸しの行を 1 行足した +``` + +棚卸しのステップが打ち切られても、プランは次の記録のステップへ進む(今の振る舞い)。打ち切られたステップの行もプランの進行の記録に +書かれるため、そのときの秒・ドルが写る。記録のステップから再開した(`run --from`)ときは、前の実行の最後の棚卸しの行が写る。 + +比べ方のコマンドはリリース後テストの担当が手で打つ。プランの流れには入らない。 + +## 非機能の実現方式 + +| 大項目 | 要求の条件 | 実現方式 | 確かめ方 | +| --- | --- | --- | --- | +| 性能・拡張性 | 差分の棚卸しの所要は、変更の後 3 回の中央値で 206 秒未満・1.41 ドル未満(受け入れ条件 17a) | 見積の段階を付ける課題を起点のタグより後に作られた課題に絞り、段階を付ける工程の往復を減らす(決定 1) | 配布の後、`depth compare --since-ref <配布した版のタグ> --seconds-under 206 --cost-under 1.41 --model claude-opus-5-5` が 0 を返す | +| 運用・保守性 | 所要を比べる値が深さの記録に残り、プランの進行の記録を手で探さずに比べられる(受け入れ条件 17b・17d) | 記録のステップがプランの進行の記録から秒・ドル・言語モデルを写し、比べ方のコマンドが深さの記録だけを読む(決定 2・決定 5) | 単体テスト(テスト設計の 17b・17d の行) | + +## 決定の記録 + +### 決定 1: 課題の数で伸び縮みする工程を縮めるため、差分の棚卸しで見積の段階を付ける課題を起点のタグより後に作られた課題に絞り、ほかの作業は外さない + +差分の棚卸しの会話の記録を、候補を確かめる工程・見積の段階を付ける工程・反映と報告の工程に分けて測った(段階の工程の始まりは +順位の付け方の参照か見積のコマンドを初めて打った時点、反映の始まりは反映の計画を初めて書いた時点)。 + +| 周回 | 時期 | 全体 | 候補を確かめる | 見積の段階を付ける | 反映と報告 | +| --- | --- | ---: | ---: | ---: | ---: | +| l20261031 周回 2 | 分ける前 | 223 秒 | 89 秒 | 21 秒 | 113 秒 | +| lroot2 周回 2 | 分ける前 | 209 秒 | 35 秒 | 112 秒 | 61 秒 | +| lflow 周回 1 | 変更の後 | 315 秒 | 89 秒 | 161 秒 | 65 秒 | +| lfactory3 周回 1 | 変更の後 | 345 秒 | 183 秒 | 94 秒 | 68 秒 | + +全体の約 7 割は言語モデルが応答を作る時間で、Tool の実行は 54〜103 秒である。往復の回数が所要を決める。見積の段階を付ける +工程は 21〜161 秒で、段階を付ける課題が多いほど往復が増える(順位の算出を 4〜6 回打ち直す・見積の +コマンドを打つ・見積の入力を書く)。lfactory3 周回 1 では起点のタグより前に作られた 8 件すべてに段階を付けていた。絞ると、 +この周回で付ける課題は 0 件になり、段階の工程の 94 秒の大半が要らなくなる。 + +候補を確かめる工程と反映と報告の工程は、差分の棚卸しの役目(誤りの訂正)そのものであり、外さない。lfactory3 周回 1 の +候補を確かめる工程(183 秒)は候補の中身で振れる値で、決定 1 では縮めない。3 回の中央値が基準を下回らないときは、 +リリース後テストの担当がこの表と同じ分け方で内訳を測り、外す作業の案を添えて人へ戻す。手順書から作業を外すのは +人が確かめてから行う(要求の「境界」)。 + +候補の上限を下げる案は採らない。前提 4 が達成として数えないと定めている。 + +根拠: Value 3 / Value 4(MVV 版 5) + +### 決定 2: 秒・ドルを機械で写すため、記録のステップが同じプランの進行の記録を読む + +記録のステップは棚卸しのステップの直後に同じプランで流れ、プランの状態のディレクトリ(`{state_dir}`)の置き換えを +run のステップのコマンドで使える(`supervise_lib/steps.py` の `run_cmd`)。プランの進行の記録の行の形は確定仕様が定めているため、 +深さの記録の書き込みがその行を読む。同じステップが 2 回流れた(打ち直した)ときは最後の行を採る。 + +supervise に「前のステップの秒・ドル」の置き換えを足す案は採らない。すべてのプランの実行の仕組みを変えることになり、 +使う側はこの 1 か所しかない。棚卸しの担当に自分の秒・ドルを渡させる案も採らない。担当はステップが終わるまで自分の所要を知らない。 +プランの進行の記録を読む既存の関数(所要の履歴の取り込み・見積もりの実績の取り込み)は、成功の行だけを選ぶ・実行ごとにまとめるなど +選び方が違い、共有すると片方の条件がもう片方へ漏れるため使わない。 + +根拠: Value 5 / Value 7(MVV 版 5) + +### 決定 3: 開発ループの名前と周回を残すため、開発ループが `new close` へ引数で渡す + +開発ループはまとめのプランを組むときに名前と周回の番号を持っている(`build_close` の `rec["name"]`・`lap["n"]`)。引数で渡せば、 +開発ループの外で人が組んだプランでは渡されず、行が空になる(受け入れ条件 17b-2)。 + +状態のディレクトリのパス(`.ndf/loop/<名前>/lap-<番号>/`)から読み取る案は採らない。パスの組み方は開発ループの内部の決まりで、 +変わったときに黙って空か誤った値になる。 + +根拠: Value 6(MVV 版 5) + +### 決定 4: 言語モデルが変わった回を機械で外すため、差分の棚卸しの行の `values` に言語モデルの名前を写す + +前提 3 は言語モデルが変わった周回を比べる 3 回に入れないと定める。プランの進行の記録の行は使った言語モデルごとの内訳(`models`)を持つため、 +その名前の並びを写す。置き場は判定の値を入れる `values` にし、行の最上位の列は増やさない(要求の「影響」の「行の形は変わらない」)。 + +根拠: Value 4(MVV 版 5) + +### 決定 5: 比べ方を 1 本のコマンドにするため、`depth compare` を足し、基準・件数・言語モデルは引数で受ける + +基準の秒・ドルはこのリポジトリの実測であり、ほかのリポジトリでは別の値になる。既定に埋め込むと、ほかのリポジトリで意味の無い判定が +出る。使う行は起点より後の古い順に指定の件数とする。新しい順に取ると、行が増えるたびに判定が変わり、都合の良い回を選べてしまう。 + +秒とドルの両方が基準を下回ったときだけ 0 を返す。受け入れ条件 17a が両方を求めるためである。保留は既存の「正常な否定の結果」の +終了コード 3 で返し、基準を下回らない結果(1)と分ける。 + +根拠: Value 4 / Value 6(MVV 版 5) + +### 決定 6: 起点の時刻を 1 つにするため、作られた時刻の比較は候補の収集が使う起点のコミットの時刻で行う + +候補の収集は起点(`--since-ref`。手順書では前のリリースのタグ)のコミットの時刻を既に求めて差分の経路に使っている(`metrics.since`)。 +見積の無い経路の絞り込みも同じ時刻を使い、経路ごとに起点がずれないようにする。タグを打った時刻はコミットの時刻より後で、 +差は数秒から数分である(ndf--v10.27.18 はコミット 01:12:08Z、タグ 01:12:11Z)。その間に作られた課題は差分の棚卸しで段階が付き、 +見直しで付ける課題が減る側にずれる。 + +根拠: Value 7(MVV 版 5) + +## テスト設計 + +| 受け入れ条件・不変条件 | どの振る舞いで縛るか | どう壊したら落ちるべきか | +| --- | --- | --- | +| 17b-1・I1 | 棚卸しのステップの行を 2 行(打ち直し)持つプランの進行の記録を渡して記録のステップのコマンドを打つと、行の秒・ドル・言語モデルが最後の行の値になる | 最初の行を採る・別のステップの行を採る・`--seconds` を渡したときにプランの進行の記録の値で上書きする | +| 17b-2・I3 | 開発ループの名前と周回を渡して組んだまとめのプランの記録のステップのコマンドに `--loop`・`--lap` が入り、渡さずに組んだプランには入らない。開発ループのまとめの組み立てが `new close` へ名前と周回を渡す | 引数を受けても捨てる・開発ループが渡し忘れる | +| 17b-3・I2 | プランの進行の記録が無い・壊れている・棚卸しの行が無いとき、行は 1 行書かれ、秒・ドルが空・`values.models` が無い・終了コード 0 | 読めないときに止まる(行を欠かす)・0 や空文字で埋める | +| 17c・I4 | 起点より前に作られ見積の無い課題と、起点より後に作られ見積の無い課題があるとき、差分の棚卸しの候補の見積の無い経路は後者だけを持つ | 絞り込みを外す・比較の向きを逆にする | +| 17c・I5 | 同じ入力で見直しの候補の見積の無い経路は両方を持つ | 絞り込みを深さによらず掛ける | +| 17d・I6 | 起点より前の行・秒の空の行・言語モデルの違う行を混ぜた深さの記録で、使う行が起点より後の古い順の 3 件になり、中央値と差が返る。2 件なら終了コード 3 で件数 2、基準を下回れば 0、片方でも下回らなければ 1 | 新しい順に取る・空の行を 0 として数える・言語モデルの指定を無視する・片方だけで 0 を返す | +| 17a | 手動確認(リリース後テスト)。配布の後、差分の棚卸しが 3 回通ったら非機能の表の確かめ方のコマンドを打つ | — | + +## 設計の結果 + +| 課題 | 扱い | 取り込み先 | 触るファイル | +| --- | --- | --- | --- | +| #2315 | 実装する | — | `plugins/ndf/skills/backlog-refinement/scripts/upkeep.py`、`plugins/ndf/skills/backlog-refinement/scripts/upkeep_depth.py`、`plugins/ndf/skills/backlog-refinement/SKILL.md`、`plugins/ndf/skills/backlog-refinement/tests/`、`plugins/ndf/scripts/supervise_lib/sprint_close_steps.py`、`plugins/ndf/scripts/supervise_lib/new_args.py`、`plugins/ndf/scripts/lib/sprint_loop_gate.py`、`plugins/ndf/scripts/tests/`、`docs/glossary/glossary.json`、`docs/glossary.md` | + +## 未確認のまま残ること + +| 項目 | 内容 | +| --- | --- | +| 絞った後の中央値 | 決定 1 の見込みは、段階を付ける工程が消えたときの差し引きである(lfactory3 周回 1 で約 250 秒、lflow 周回 1 で約 155 秒)。基準の 206 秒を下回るかは、配布の後の 3 回で確かめる(受け入れ条件 17a)。下回らなければ決定 1 のとおり人へ戻す | +| 3 回がそろう日数 | 並行する開発ループの数による。リリース後テストの担当が配布の後に数える | +| 配布をまたいだ周回 | 起点のタグより後に終わった周回でも、古い版のプラグインで流れた周回は比べる行に入る。比べ方のコマンドは版を見ない。リリース後テストの担当は、使った行の `at` と配布の時刻を見比べる | +| 持ち越しの減り方 | 見積の無い経路の課題が差分の棚卸しの持ち越しに入らなくなり、行の `values.carry` が減る。見直しの起動の条件の 1 つ(持ち越しが上限の 10 倍を超える)が立ちにくくなる。見直しは回数の条件でも起動するため止まらないが、起動の間隔の変化は未確認 | +| 見積の後に要約値が変わった課題 | 見積の無い経路の 157 件のうち 150 件は、見積を付けた後に本文やラベルが変わった課題である(プランの進行の記録の追記も本文を変える)。差分の棚卸しからは外れるが、見直しでは引き続き段階を付け直す対象になる。見直しの所要は #2423 が持つ | From 9b3dad4d5177d9c6fdf0e055510bddbe61329572 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:50:35 +0000 Subject: [PATCH 13/43] =?UTF-8?q?Docs:=20#2315=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=A1=8C=E3=82=92=E7=9F=AD=E3=81=8F=E3=81=97?= =?UTF-8?q?=E3=80=81=E9=95=B7=E3=81=84=E6=96=87=E3=82=92=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=80=81=E6=A7=8B=E6=88=90=E8=A6=81=E7=B4=A0=E3=81=AE=E5=9B=B3?= =?UTF-8?q?=E3=81=AE=E9=87=8D=E8=A4=87=E3=81=97=E3=81=9F=E8=AA=9E=E3=82=92?= =?UTF-8?q?=E7=9B=B4=E3=81=99=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 章の順は設計の雛形のまま保ち、内容は足していない。 平均文長 36.4 字 → 33.7 字、最長文 112 字 → 84 字、題 69 字 → 56 字。 Co-Authored-By: Claude Opus 5.5 --- issues/issue-2315-design.md | 43 +++++++++++++++++++------------------ 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/issues/issue-2315-design.md b/issues/issue-2315-design.md index 3fbeb0c35..a6355af96 100644 --- a/issues/issue-2315-design.md +++ b/issues/issue-2315-design.md @@ -1,10 +1,10 @@ -# 差分の棚卸し: 見積の段階をそのスプリントの課題だけに付けて所要を縮め、縮んだかを深さの記録から 1 本のコマンドで確かめる(#2315) +# 差分の棚卸し: 見積の段階をそのスプリントの課題だけに付け、縮んだ所要をコマンド 1 本で確かめる(#2315) ## 目的 -- **今どうなっているか**: 毎スプリントの棚卸し(差分の棚卸し)は、3 つの深さに分けた後も 270〜346 秒・1.77〜2.19 ドルかかり、分ける前(206〜224 秒・1.41〜1.61 ドル)より長い。深さの記録の差分の棚卸しの行は秒・ドル・開発ループ・周回がどれも空で、比べるにはプランの進行の記録を手で探すしかない +- **今どうなっているか**: 毎スプリントの棚卸し(差分の棚卸し)は、3 つの深さに分けた後も 270〜346 秒・1.77〜2.19 ドルかかる。分ける前(206〜224 秒・1.41〜1.61 ドル)より長い。深さの記録の差分の棚卸しの行は秒・ドル・開発ループ・周回がどれも空で、比べるにはプランの進行の記録を手で探すしかない - **誰に効くか**: 開発ループを回す利用者(周回ごとの待ち時間と費用)と、リリース後テストの担当(所要の比べ方) -- **変えると何が成り立つか**: 差分の棚卸しが見積の段階を付ける課題が、手順書の定めどおりそのスプリントで起票した課題だけになり、所要が分ける前より短くなる見込みが立つ。短くなったかを、深さの記録からコマンド 1 本で判定できる +- **変えると何が成り立つか**: 差分の棚卸しが見積の段階を付ける課題が、手順書の定めどおりそのスプリントで起票した課題だけになる。所要が分ける前より短くなる見込みが立つ。短くなったかを、深さの記録からコマンド 1 本で判定できる ## 適用範囲 @@ -21,8 +21,8 @@ | 2026-11-05 の open の課題 246 件のうち、見積の無い経路に当たる課題 157 件(見積が無い 7 件・見積の後に要約値が変わった 150 件) | 実測 | 候補を集める処理は、深さを見ずに古い課題まで段階を付ける対象として渡している | | 開発ループ lfactory3 周回 1 で段階を付けた 8 件は、どれも起点のタグ(ndf--v10.27.15、2026-11-03T19:04Z)より前に作られていた | 実測 | 手順書の定め(そのスプリントで起票した課題だけ)に揃えると、この周回で付ける課題は 0 件になる | -要求と受け入れ条件は #2315 の本文にある(コピーは [issues/issue-2315-requirements.md](issue-2315-requirements.md))。この文書は「どう作るか」だけを扱う。 -3 つの深さそのものの仕様は確定仕様 [docs/specifications/ndf-backlog-refinement-depths.md](../docs/specifications/ndf-backlog-refinement-depths.md) にある。 +要求と受け入れ条件は #2315 の本文にある(コピーは [issues/issue-2315-requirements.md](issue-2315-requirements.md) )。この文書は「どう作るか」だけを扱う。 +3 つの深さそのものの仕様は、確定仕様の [ndf-backlog-refinement-depths.md](../docs/specifications/ndf-backlog-refinement-depths.md) にある。 ## ドメインモデル @@ -34,8 +34,9 @@ | 開発ワークフロー(`ndf-workflow`) | 差分の棚卸し・開発ループ・周回・まとめのプラン・プランの進行の記録 | 関係は顧客 / 供給者である。課題の棚卸しが供給者で、深さの記録の行の形と比べ方のコマンドを決める。開発ワークフローは顧客で、 -まとめのプランから深さの記録へ書くコマンドを組み、開発ループの名前と周回の番号を引数で渡す。課題の棚卸しはプランの進行の -記録の行の形(supervise の進捗ログが定める `kind: step` の行)をそのまま読む(順応者として読む部分はこの 1 つだけ)。 +まとめのプランから深さの記録へ書くコマンドを組み、開発ループの名前と周回の番号を引数で渡す。 +課題の棚卸しは、プランの進行の記録の行の形(supervise の進捗ログが定める `kind: step` の行)をそのまま読む。 +順応者として読む部分はこの 1 つだけである。 ### 集約 @@ -100,7 +101,7 @@ graph TD subgraph 開発ワークフロー L[開発ループの
まとめの組み立て] --> A[new close の引数] A --> P[まとめのプランの
棚卸しのステップ] - P --> G[(プランの
プランの進行の記録)] + P --> G[(プランの
進行の記録)] end subgraph 課題の棚卸し C[候補の収集] @@ -174,8 +175,8 @@ python3 upkeep.py depth compare --since-ref <配布した正式版のタグ> --s ### 候補の収集(振る舞いの変更) -`candidates --depth diff` の見積の無い経路(`unscored`)は、課題の作られた時刻(`created_at`)が起点(`--since-ref` のコミットの時刻。 -結果の `metrics.since`)より後の課題だけを足す。`--depth review` は変えない。結果の形は変わらない。 +`candidates --depth diff` の見積の無い経路(`unscored`)は、課題の作られた時刻(`created_at`)が起点より後の課題だけを足す。 +起点は `--since-ref` のコミットの時刻で、結果の `metrics.since` に出る。`--depth review` は変えない。結果の形は変わらない。 ## 処理の流れ @@ -214,8 +215,8 @@ sequenceDiagram ### 決定 1: 課題の数で伸び縮みする工程を縮めるため、差分の棚卸しで見積の段階を付ける課題を起点のタグより後に作られた課題に絞り、ほかの作業は外さない -差分の棚卸しの会話の記録を、候補を確かめる工程・見積の段階を付ける工程・反映と報告の工程に分けて測った(段階の工程の始まりは -順位の付け方の参照か見積のコマンドを初めて打った時点、反映の始まりは反映の計画を初めて書いた時点)。 +差分の棚卸しの会話の記録を、候補を確かめる工程・見積の段階を付ける工程・反映と報告の工程に分けて測った。 +段階の工程の始まりは、順位の付け方の参照か見積のコマンドを初めて打った時点とした。反映の始まりは、反映の計画を初めて書いた時点とした。 | 周回 | 時期 | 全体 | 候補を確かめる | 見積の段階を付ける | 反映と報告 | | --- | --- | ---: | ---: | ---: | ---: | @@ -225,8 +226,8 @@ sequenceDiagram | lfactory3 周回 1 | 変更の後 | 345 秒 | 183 秒 | 94 秒 | 68 秒 | 全体の約 7 割は言語モデルが応答を作る時間で、Tool の実行は 54〜103 秒である。往復の回数が所要を決める。見積の段階を付ける -工程は 21〜161 秒で、段階を付ける課題が多いほど往復が増える(順位の算出を 4〜6 回打ち直す・見積の -コマンドを打つ・見積の入力を書く)。lfactory3 周回 1 では起点のタグより前に作られた 8 件すべてに段階を付けていた。絞ると、 +工程は 21〜161 秒で、段階を付ける課題が多いほど往復が増える。増える往復は、順位の算出の打ち直し(4〜6 回)・ +見積のコマンド・見積の入力の書き込みである。lfactory3 周回 1 では起点のタグより前に作られた 8 件すべてに段階を付けていた。絞ると、 この周回で付ける課題は 0 件になり、段階の工程の 94 秒の大半が要らなくなる。 候補を確かめる工程と反映と報告の工程は、差分の棚卸しの役目(誤りの訂正)そのものであり、外さない。lfactory3 周回 1 の @@ -234,20 +235,20 @@ sequenceDiagram リリース後テストの担当がこの表と同じ分け方で内訳を測り、外す作業の案を添えて人へ戻す。手順書から作業を外すのは 人が確かめてから行う(要求の「境界」)。 -候補の上限を下げる案は採らない。前提 4 が達成として数えないと定めている。 +候補の上限を下げる案は採らない。要求の前提 4 が達成として数えないと定めている。 根拠: Value 3 / Value 4(MVV 版 5) ### 決定 2: 秒・ドルを機械で写すため、記録のステップが同じプランの進行の記録を読む -記録のステップは棚卸しのステップの直後に同じプランで流れ、プランの状態のディレクトリ(`{state_dir}`)の置き換えを -run のステップのコマンドで使える(`supervise_lib/steps.py` の `run_cmd`)。プランの進行の記録の行の形は確定仕様が定めているため、 +記録のステップは、棚卸しのステップの直後に同じプランで流れる。run のステップのコマンドでは、 +プランの状態のディレクトリ(`{state_dir}`)の置き換えを使える(`supervise_lib/steps.py` の `run_cmd`)。プランの進行の記録の行の形は確定仕様が定めているため、 深さの記録の書き込みがその行を読む。同じステップが 2 回流れた(打ち直した)ときは最後の行を採る。 supervise に「前のステップの秒・ドル」の置き換えを足す案は採らない。すべてのプランの実行の仕組みを変えることになり、 使う側はこの 1 か所しかない。棚卸しの担当に自分の秒・ドルを渡させる案も採らない。担当はステップが終わるまで自分の所要を知らない。 -プランの進行の記録を読む既存の関数(所要の履歴の取り込み・見積もりの実績の取り込み)は、成功の行だけを選ぶ・実行ごとにまとめるなど -選び方が違い、共有すると片方の条件がもう片方へ漏れるため使わない。 +プランの進行の記録を読む既存の関数(所要の履歴の取り込み・見積もりの実績の取り込み)は使わない。 +成功の行だけを選ぶ・実行ごとにまとめるなど選び方が違い、共有すると片方の条件がもう片方へ漏れる。 根拠: Value 5 / Value 7(MVV 版 5) @@ -263,7 +264,7 @@ supervise に「前のステップの秒・ドル」の置き換えを足す案 ### 決定 4: 言語モデルが変わった回を機械で外すため、差分の棚卸しの行の `values` に言語モデルの名前を写す -前提 3 は言語モデルが変わった周回を比べる 3 回に入れないと定める。プランの進行の記録の行は使った言語モデルごとの内訳(`models`)を持つため、 +要求の前提 3 は言語モデルが変わった周回を比べる 3 回に入れないと定める。プランの進行の記録の行は使った言語モデルごとの内訳(`models`)を持つため、 その名前の並びを写す。置き場は判定の値を入れる `values` にし、行の最上位の列は増やさない(要求の「影響」の「行の形は変わらない」)。 根拠: Value 4(MVV 版 5) @@ -282,7 +283,7 @@ supervise に「前のステップの秒・ドル」の置き換えを足す案 候補の収集は起点(`--since-ref`。手順書では前のリリースのタグ)のコミットの時刻を既に求めて差分の経路に使っている(`metrics.since`)。 見積の無い経路の絞り込みも同じ時刻を使い、経路ごとに起点がずれないようにする。タグを打った時刻はコミットの時刻より後で、 -差は数秒から数分である(ndf--v10.27.18 はコミット 01:12:08Z、タグ 01:12:11Z)。その間に作られた課題は差分の棚卸しで段階が付き、 +差は数秒から数分である。ndf--v10.27.18 では、コミットが 01:12:08Z、タグが 01:12:11Z だった。その間に作られた課題は差分の棚卸しで段階が付き、 見直しで付ける課題が減る側にずれる。 根拠: Value 7(MVV 版 5) From 848a03396b3f34cd4de520840d87c9104410384a Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 01:56:06 +0000 Subject: [PATCH 14/43] =?UTF-8?q?docs(#2315):=20=E6=AF=94=E3=81=B9?= =?UTF-8?q?=E6=96=B9=E3=81=A7=E5=90=8C=E3=81=98=E5=91=A8=E5=9B=9E=E3=81=AE?= =?UTF-8?q?=E8=A1=8C=E3=82=92=E9=87=8D=E3=81=AD=E3=81=A6=E6=95=B0=E3=81=88?= =?UTF-8?q?=E3=81=AA=E3=81=84=E6=9D=A1=E4=BB=B6=E3=81=A8=E3=80=81=E7=94=A8?= =?UTF-8?q?=E8=AA=9E=E3=81=AE=E5=8F=8D=E6=98=A0=E3=82=92=E7=9B=B4=E3=81=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 不変条件 I6・決定 5・比べ方のコマンドの出力・テスト設計に、同じ開発ループ・周回 (開発ループの外では同じスプリント)の行は最後の 1 行だけを数える条件を足す - 用語の表で、深さの記録・プランの進行の記録の用語集への反映を新規追加にそろえる Co-Authored-By: Claude Opus 5.5 --- issues/issue-2315-design.md | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/issues/issue-2315-design.md b/issues/issue-2315-design.md index a6355af96..34a6ed820 100644 --- a/issues/issue-2315-design.md +++ b/issues/issue-2315-design.md @@ -56,7 +56,7 @@ | I3 | 深さの記録 | 行の開発ループ・周回は、そのプランを組んだ開発ループの名前と周回の番号である。開発ループの外で組んだプランでは空である | テストで落とす | | I4 | 候補 | 差分の棚卸しの候補に見積の無い経路で入る課題は、起点(`--since-ref`)の時刻より後に作られた課題だけである | テストで落とす | | I5 | 候補 | 見直しの候補の集め方は変わらない(見積の無い経路は作られた時刻で絞らない) | テストで落とす | -| I6 | 深さの記録 | 比べ方のコマンドは、起点より後の差分の棚卸しの実行の行のうち、秒とドルがそろい、言語モデルが指定と一致する行を古い順に指定の件数だけ使う。件数に満たなければ判定しない | テストで落とす。終了コード 3 で保留を返す | +| I6 | 深さの記録 | 比べ方のコマンドは、起点より後の差分の棚卸しの実行の行のうち、秒とドルがそろい、言語モデルが指定と一致する行を古い順に指定の件数だけ使う。同じ開発ループ・周回(開発ループの外では同じスプリント)の行は、最後の 1 行だけを 1 回として数える。件数に満たなければ判定しない | テストで落とす。終了コード 3 で保留を返す | ### ドメインイベント @@ -72,8 +72,8 @@ | 用語 | 意味 | 用語集への反映 | | --- | --- | --- | | 差分の棚卸し | 毎スプリントの棚卸しの深さ。見積の段階を付けるのは、起点のタグより後に作られた課題だけである | 意味の変更(`ndf-workflow`。「そのスプリントで起票した課題」を起点のタグの時刻で決めると明記する) | -| 深さの記録 | 深さごとの起動の判定と実行を 1 行ずつ残す記録。差分の棚卸しの行は秒・ドル・言語モデルを持つ | 意味の変更(`ndf-issue-upkeep`。言語モデルを足す) | -| プランの進行の記録 | supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録 | 変更なし(`ndf-workflow`) | +| 深さの記録 | 深さごとの起動の判定と実行を 1 行ずつ残す記録。差分の棚卸しの行は秒・ドル・言語モデルを持つ | 新規追加(`ndf-issue-upkeep`) | +| プランの進行の記録 | supervise のプランのステップごとの終了コード・秒・ドルを 1 行ずつ残す記録 | 新規追加(`ndf-workflow`) | ## 機能一覧 @@ -161,7 +161,7 @@ python3 upkeep.py depth compare --since-ref <配布した正式版のタグ> --s | 項目 | 内容 | | --- | --- | | 入力 | `--since-ref`(必須。この ref のコミットの時刻より後の行だけを使う)・`--seconds-under`・`--cost-under`(必須。基準)・`--count`(既定 3)・`--model`(任意。渡すと `values.models` がこの 1 つだけの行に限る)・`--depth`(既定 `diff`) | -| 出力 | 統一の結果の形の 1 行の JSON。`metrics` は `count`(そろった件数)・`needed`・`median_seconds`・`median_cost`・`baseline`(`{seconds, cost}`)・`delta`(`{seconds, cost}`。中央値 − 基準)・`judged`(真偽)。`items` は使った行(`at`・`sprint`・`loop`・`lap`・`seconds`・`cost`)と、外した行(`result: excluded`・`reason`: `no_usage` / `model`) | +| 出力 | 統一の結果の形の 1 行の JSON。`metrics` は `count`(そろった件数)・`needed`・`median_seconds`・`median_cost`・`baseline`(`{seconds, cost}`)・`delta`(`{seconds, cost}`。中央値 − 基準)・`judged`(真偽)。`items` は使った行(`at`・`sprint`・`loop`・`lap`・`seconds`・`cost`)と、外した行(`result: excluded`・`reason`: `no_usage` / `model` / `duplicate`) | | 失敗の形 | 0 = 秒とドルの中央値がどちらも基準を下回った / 1 = そろったが、どちらかが基準を下回らない / 3 = 件数に満たない(判定を保留。`metrics.count` にそろった件数)/ 2 = 深さの記録か ref を読めない | | 互換性 | 新しいコマンドで、既存の呼び出し側は無い | @@ -273,6 +273,8 @@ supervise に「前のステップの秒・ドル」の置き換えを足す案 基準の秒・ドルはこのリポジトリの実測であり、ほかのリポジトリでは別の値になる。既定に埋め込むと、ほかのリポジトリで意味の無い判定が 出る。使う行は起点より後の古い順に指定の件数とする。新しい順に取ると、行が増えるたびに判定が変わり、都合の良い回を選べてしまう。 +記録のステップから再開すると、同じ周回の棚卸しの値が新しい行として足される。同じ開発ループ・周回(開発ループの外では同じスプリント)の行は +最後の 1 行だけを数え、1 回の周回で件数がそろわないようにする。 秒とドルの両方が基準を下回ったときだけ 0 を返す。受け入れ条件 17a が両方を求めるためである。保留は既存の「正常な否定の結果」の 終了コード 3 で返し、基準を下回らない結果(1)と分ける。 @@ -297,7 +299,7 @@ supervise に「前のステップの秒・ドル」の置き換えを足す案 | 17b-3・I2 | プランの進行の記録が無い・壊れている・棚卸しの行が無いとき、行は 1 行書かれ、秒・ドルが空・`values.models` が無い・終了コード 0 | 読めないときに止まる(行を欠かす)・0 や空文字で埋める | | 17c・I4 | 起点より前に作られ見積の無い課題と、起点より後に作られ見積の無い課題があるとき、差分の棚卸しの候補の見積の無い経路は後者だけを持つ | 絞り込みを外す・比較の向きを逆にする | | 17c・I5 | 同じ入力で見直しの候補の見積の無い経路は両方を持つ | 絞り込みを深さによらず掛ける | -| 17d・I6 | 起点より前の行・秒の空の行・言語モデルの違う行を混ぜた深さの記録で、使う行が起点より後の古い順の 3 件になり、中央値と差が返る。2 件なら終了コード 3 で件数 2、基準を下回れば 0、片方でも下回らなければ 1 | 新しい順に取る・空の行を 0 として数える・言語モデルの指定を無視する・片方だけで 0 を返す | +| 17d・I6 | 起点より前の行・秒の空の行・言語モデルの違う行・同じ周回の重複の行を混ぜた深さの記録で、使う行が起点より後の古い順の 3 件(同じ周回は最後の 1 行)になり、中央値と差が返る。2 件なら終了コード 3 で件数 2、基準を下回れば 0、片方でも下回らなければ 1 | 新しい順に取る・空の行を 0 として数える・言語モデルの指定を無視する・同じ周回の行を重ねて数える・片方だけで 0 を返す | | 17a | 手動確認(リリース後テスト)。配布の後、差分の棚卸しが 3 回通ったら非機能の表の確かめ方のコマンドを打つ | — | ## 設計の結果 From df2be4bb1746ce29a4cc8a6b10526965dc2dabba Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 02:00:43 +0000 Subject: [PATCH 15/43] =?UTF-8?q?docs(#2315):=20=E5=90=8C=E3=81=98?= =?UTF-8?q?=E5=91=A8=E5=9B=9E=E3=81=AE=E9=87=8D=E8=A4=87=E3=81=AF=E7=A7=92?= =?UTF-8?q?=E3=83=BB=E3=83=89=E3=83=AB=E3=83=BB=E8=A8=80=E8=AA=9E=E3=83=A2?= =?UTF-8?q?=E3=83=87=E3=83=AB=E3=81=AE=E7=B5=9E=E3=82=8A=E8=BE=BC=E3=81=BF?= =?UTF-8?q?=E3=81=AE=E5=BE=8C=E3=81=AB=E9=99=A4=E3=81=8F=E3=81=A8=E6=98=8E?= =?UTF-8?q?=E8=A8=98=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-2315-design.md | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/issues/issue-2315-design.md b/issues/issue-2315-design.md index 34a6ed820..ca4d1a1a4 100644 --- a/issues/issue-2315-design.md +++ b/issues/issue-2315-design.md @@ -56,7 +56,7 @@ | I3 | 深さの記録 | 行の開発ループ・周回は、そのプランを組んだ開発ループの名前と周回の番号である。開発ループの外で組んだプランでは空である | テストで落とす | | I4 | 候補 | 差分の棚卸しの候補に見積の無い経路で入る課題は、起点(`--since-ref`)の時刻より後に作られた課題だけである | テストで落とす | | I5 | 候補 | 見直しの候補の集め方は変わらない(見積の無い経路は作られた時刻で絞らない) | テストで落とす | -| I6 | 深さの記録 | 比べ方のコマンドは、起点より後の差分の棚卸しの実行の行のうち、秒とドルがそろい、言語モデルが指定と一致する行を古い順に指定の件数だけ使う。同じ開発ループ・周回(開発ループの外では同じスプリント)の行は、最後の 1 行だけを 1 回として数える。件数に満たなければ判定しない | テストで落とす。終了コード 3 で保留を返す | +| I6 | 深さの記録 | 比べ方のコマンドは、起点より後の差分の棚卸しの実行の行のうち、秒とドルがそろい、言語モデルが指定と一致する行を古い順に指定の件数だけ使う。同じ開発ループ・周回(開発ループの外では同じスプリント)の行は、秒・ドル・言語モデルの絞り込みを通った行の中で最後の 1 行だけを 1 回として数える(絞り込みの後に重複を除く)。件数に満たなければ判定しない | テストで落とす。終了コード 3 で保留を返す | ### ドメインイベント @@ -161,7 +161,7 @@ python3 upkeep.py depth compare --since-ref <配布した正式版のタグ> --s | 項目 | 内容 | | --- | --- | | 入力 | `--since-ref`(必須。この ref のコミットの時刻より後の行だけを使う)・`--seconds-under`・`--cost-under`(必須。基準)・`--count`(既定 3)・`--model`(任意。渡すと `values.models` がこの 1 つだけの行に限る)・`--depth`(既定 `diff`) | -| 出力 | 統一の結果の形の 1 行の JSON。`metrics` は `count`(そろった件数)・`needed`・`median_seconds`・`median_cost`・`baseline`(`{seconds, cost}`)・`delta`(`{seconds, cost}`。中央値 − 基準)・`judged`(真偽)。`items` は使った行(`at`・`sprint`・`loop`・`lap`・`seconds`・`cost`)と、外した行(`result: excluded`・`reason`: `no_usage` / `model` / `duplicate`) | +| 出力 | 統一の結果の形の 1 行の JSON。`metrics` は `count`(そろった件数)・`needed`・`median_seconds`・`median_cost`・`baseline`(`{seconds, cost}`)・`delta`(`{seconds, cost}`。中央値 − 基準)・`judged`(真偽)。`items` は使った行(`at`・`sprint`・`loop`・`lap`・`seconds`・`cost`)と、外した行(`result: excluded`・`reason`: `no_usage` / `model` / `duplicate`。1 行に付ける理由は 1 つで、`no_usage`・`model` を `duplicate` より先に付ける) | | 失敗の形 | 0 = 秒とドルの中央値がどちらも基準を下回った / 1 = そろったが、どちらかが基準を下回らない / 3 = 件数に満たない(判定を保留。`metrics.count` にそろった件数)/ 2 = 深さの記録か ref を読めない | | 互換性 | 新しいコマンドで、既存の呼び出し側は無い | @@ -274,7 +274,8 @@ supervise に「前のステップの秒・ドル」の置き換えを足す案 基準の秒・ドルはこのリポジトリの実測であり、ほかのリポジトリでは別の値になる。既定に埋め込むと、ほかのリポジトリで意味の無い判定が 出る。使う行は起点より後の古い順に指定の件数とする。新しい順に取ると、行が増えるたびに判定が変わり、都合の良い回を選べてしまう。 記録のステップから再開すると、同じ周回の棚卸しの値が新しい行として足される。同じ開発ループ・周回(開発ループの外では同じスプリント)の行は -最後の 1 行だけを数え、1 回の周回で件数がそろわないようにする。 +最後の 1 行だけを数え、1 回の周回で件数がそろわないようにする。重複は秒・ドル・言語モデルの絞り込みの後に除く。先に除くと、 +周回の最後の行が秒の空の行(開発ループの外で手で打った行や、読めなかったときの行)のときにその周回が落ち、同じ記録で件数と終了コードが変わる。 秒とドルの両方が基準を下回ったときだけ 0 を返す。受け入れ条件 17a が両方を求めるためである。保留は既存の「正常な否定の結果」の 終了コード 3 で返し、基準を下回らない結果(1)と分ける。 @@ -299,7 +300,7 @@ supervise に「前のステップの秒・ドル」の置き換えを足す案 | 17b-3・I2 | プランの進行の記録が無い・壊れている・棚卸しの行が無いとき、行は 1 行書かれ、秒・ドルが空・`values.models` が無い・終了コード 0 | 読めないときに止まる(行を欠かす)・0 や空文字で埋める | | 17c・I4 | 起点より前に作られ見積の無い課題と、起点より後に作られ見積の無い課題があるとき、差分の棚卸しの候補の見積の無い経路は後者だけを持つ | 絞り込みを外す・比較の向きを逆にする | | 17c・I5 | 同じ入力で見直しの候補の見積の無い経路は両方を持つ | 絞り込みを深さによらず掛ける | -| 17d・I6 | 起点より前の行・秒の空の行・言語モデルの違う行・同じ周回の重複の行を混ぜた深さの記録で、使う行が起点より後の古い順の 3 件(同じ周回は最後の 1 行)になり、中央値と差が返る。2 件なら終了コード 3 で件数 2、基準を下回れば 0、片方でも下回らなければ 1 | 新しい順に取る・空の行を 0 として数える・言語モデルの指定を無視する・同じ周回の行を重ねて数える・片方だけで 0 を返す | +| 17d・I6 | 起点より前の行・秒の空の行・言語モデルの違う行・同じ周回の重複の行(最後の行が秒の空の行で、その前の行がそろう周回を含む)を混ぜた深さの記録で、使う行が起点より後の古い順の 3 件(同じ周回は絞り込みを通った最後の 1 行。最後の行が空の周回は前の行を 1 回と数える)になり、中央値と差が返る。2 件なら終了コード 3 で件数 2、基準を下回れば 0、片方でも下回らなければ 1 | 新しい順に取る・空の行を 0 として数える・言語モデルの指定を無視する・同じ周回の行を重ねて数える・絞り込みの前に重複を除いて周回を落とす・片方だけで 0 を返す | | 17a | 手動確認(リリース後テスト)。配布の後、差分の棚卸しが 3 回通ったら非機能の表の確かめ方のコマンドを打つ | — | ## 設計の結果 From efb4ca7c2f50fc5a17a9f926feccc21e35c0ecf6 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 02:06:12 +0000 Subject: [PATCH 16/43] =?UTF-8?q?Refactor:=20factory/api=20=E3=81=AE=20req?= =?UTF-8?q?uireEnv=20=E3=82=92=20env.ts=20=E3=81=AE=201=20=E3=81=A4?= =?UTF-8?q?=E3=81=AB=E3=81=97=E3=80=81handlers/=20=E3=82=92=20handler=20?= =?UTF-8?q?=E3=81=AE=E9=85=8D=E7=B7=9A=E3=81=A0=E3=81=91=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2199=20=E3=81=AE=20R20=E3=83=BBR02=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - requireEnv の 5 つの写しを src/env.ts へ寄せる - createApiApp・jsonLogger・webOriginFromEnv を app.ts、depsFromEnv を auth/deps.ts、 progressStoreFromEnv を progress/deps.ts へ移し、index.ts・runs/deps.ts・handlers/ どうしが handlers/ を読まない形にする - テストは createApiApp の import 元だけを替える(期待値は変えない) Co-Authored-By: Claude Opus 5.5 --- factory/api/src/app.ts | 82 ++++++++++++++ factory/api/src/auth/deps.ts | 28 +++++ factory/api/src/env.ts | 6 + factory/api/src/handlers/api.test.ts | 2 +- factory/api/src/handlers/api.ts | 116 +------------------- factory/api/src/handlers/github-oidc.ts | 7 +- factory/api/src/handlers/ingest.ts | 20 +--- factory/api/src/handlers/issue-requests.ts | 9 +- factory/api/src/index.ts | 6 +- factory/api/src/progress/deps.ts | 14 +++ factory/api/src/routes/factory.test.ts | 2 +- factory/api/src/routes/sprint-views.test.ts | 2 +- factory/api/src/runs/deps.ts | 9 +- factory/api/src/testing/app.ts | 2 +- 14 files changed, 147 insertions(+), 158 deletions(-) create mode 100644 factory/api/src/app.ts create mode 100644 factory/api/src/auth/deps.ts create mode 100644 factory/api/src/env.ts create mode 100644 factory/api/src/progress/deps.ts diff --git a/factory/api/src/app.ts b/factory/api/src/app.ts new file mode 100644 index 000000000..b0a1c7e48 --- /dev/null +++ b/factory/api/src/app.ts @@ -0,0 +1,82 @@ +// API の app を組む(ログ・CORS・操作の記録・認証と解決の中間処理・経路)。Lambda の handler は handlers/api.ts が配線する +import { Hono } from "hono"; +import { cors } from "hono/cors"; +import { ApiError, AccountUnavailableError } from "./errors.ts"; +import { requireCaller, type AuthDeps, type AuthEnv } from "./auth/resolve.ts"; +import { registerMe } from "./routes/me.ts"; +import { registerTeams } from "./routes/teams.ts"; +import { registerAccounts } from "./routes/accounts.ts"; +import { registerDelegations } from "./routes/delegations.ts"; +import { recordOperations, requestIdOf } from "./audit/middleware.ts"; +import { registerMcp } from "./mcp/server.ts"; +import { registerFactory, type FactoryDeps } from "./routes/factory.ts"; + +export interface LogEntry { + // 操作の記録の record_id と同じ値(同じ要求の記録とログの行をたどる) + request_id: string; + user_id: string | null; + method: string; + path: string; + status: number; +} + +export type Logger = (entry: LogEntry) => void; + +// ログは利用者の ID・経路・状態コードだけを出す。token とメールアドレスは出さない +export const jsonLogger: Logger = (entry) => console.log(JSON.stringify(entry)); + +// 画面のオリジン(FACTORY_WEB_URL のスキーム・ホスト・ポート)。無いか URL でなければ null で、どのオリジンも許さない +export function webOriginFromEnv(): string | null { + const url = process.env.FACTORY_WEB_URL; + if (!url) return null; + try { + return new URL(url).origin; + } catch { + return null; + } +} + +// API の入口。#2171・#2186・#2174 は、返った app に経路を足し、同じ認証と解決の中間処理を共有する(決定 11) +export function createApiApp( + deps: () => AuthDeps, + log: Logger = jsonLogger, + webOrigin: () => string | null = webOriginFromEnv, + factory: (() => FactoryDeps) | null = null, +): Hono { + const app = new Hono(); + app.use("*", async (c, next) => { + c.set("requestId", requestIdOf(c)); + await next(); + log({ + request_id: c.get("requestId")!, + user_id: c.get("caller")?.userId ?? null, + method: c.req.method, + path: c.req.path, + status: c.res.status, + }); + }); + // 画面と API は別のオリジン。preflight(Authorization を持たない OPTIONS)を認証より先に返す + app.use( + "*", + cors({ + origin: (origin) => (origin === webOrigin() ? origin : null), + allowHeaders: ["Authorization", "Content-Type"], + allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"], + }), + ); + // 操作の記録は認証より外に置き、401 で断った要求も記録する(I7) + app.use("*", recordOperations(() => deps().audit, (line) => console.log(JSON.stringify(line)))); + app.use("*", requireCaller(deps)); + app.onError((err, c) => { + const known = err instanceof ApiError ? err : new AccountUnavailableError(); + return c.json(known.body(), known.status); + }); + registerMe(app); + registerTeams(app); + registerAccounts(app); + registerDelegations(app); + if (factory) registerFactory(app, factory); + // MCP の受け口。依存は同じアプリへの内部の要求だけ(#2384 の I15) + registerMcp(app, (path, init, env) => app.request(path, init, env), webOrigin); + return app; +} diff --git a/factory/api/src/auth/deps.ts b/factory/api/src/auth/deps.ts new file mode 100644 index 000000000..854d4ee29 --- /dev/null +++ b/factory/api/src/auth/deps.ts @@ -0,0 +1,28 @@ +// API の認証と解決の依存を環境変数から作る +import { AccountService } from "../accounts/service.ts"; +import { DynamoAccountStore } from "../accounts/store.ts"; +import { DynamoAuditLog } from "../audit/dynamo-log.ts"; +import { StructuredLogAudit } from "../audit/structured-log.ts"; +import { DelegationService } from "../delegations/service.ts"; +import { DynamoDelegationStore } from "../delegations/store.ts"; +import { requireEnv } from "../env.ts"; +import type { AuthDeps } from "./resolve.ts"; +import { createAccessTokenVerifier } from "./verify.ts"; + +let cached: AuthDeps | null = null; + +// 環境変数から依存を作る。起動のたびに 1 回だけ作る +export function depsFromEnv(): AuthDeps { + cached ??= { + verifier: createAccessTokenVerifier({ + userPoolId: requireEnv("COGNITO_USER_POOL_ID"), + clientId: requireEnv("COGNITO_CLIENT_ID"), + }), + accounts: new AccountService({ store: new DynamoAccountStore(requireEnv("ACCOUNTS_TABLE_NAME")) }), + // 委任の資格情報の表(インフラが DELEGATIONS_TABLE_NAME で渡す)。無い間は委任の資格情報を 401 にし、委任の操作を 503 にする + delegations: process.env.DELEGATIONS_TABLE_NAME ? new DelegationService({ store: new DynamoDelegationStore(process.env.DELEGATIONS_TABLE_NAME) }) : null, + // 操作の記録の表(インフラが AUDIT_TABLE_NAME で渡す)。無い間は記録を構造化ログへ出す + audit: process.env.AUDIT_TABLE_NAME ? new DynamoAuditLog(process.env.AUDIT_TABLE_NAME) : new StructuredLogAudit(), + }; + return cached; +} diff --git a/factory/api/src/env.ts b/factory/api/src/env.ts new file mode 100644 index 000000000..ebbffeae3 --- /dev/null +++ b/factory/api/src/env.ts @@ -0,0 +1,6 @@ +// 環境変数を読む。無ければ起動の誤りとして投げる(API・受け口・GitHub の仲立ち・起票の代行・実行の制御が使う) +export function requireEnv(name: string): string { + const value = process.env[name]; + if (!value) throw new Error(`環境変数 ${name} がありません`); + return value; +} diff --git a/factory/api/src/handlers/api.test.ts b/factory/api/src/handlers/api.test.ts index 6c7bdca01..d32c0a5f7 100644 --- a/factory/api/src/handlers/api.test.ts +++ b/factory/api/src/handlers/api.test.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it } from "vitest"; import { createTestApi, firstResult, type TestApi } from "../testing/app.ts"; -import { createApiApp } from "./api.ts"; +import { createApiApp } from "../app.ts"; let api: TestApi; let keys = 0; diff --git a/factory/api/src/handlers/api.ts b/factory/api/src/handlers/api.ts index d6b306326..fa1fcf881 100644 --- a/factory/api/src/handlers/api.ts +++ b/factory/api/src/handlers/api.ts @@ -1,117 +1,7 @@ -import { Hono } from "hono"; +// API Gateway の HTTP API(payload 2.0)から呼ばれる Lambda の handler import { handle } from "hono/aws-lambda"; -import { cors } from "hono/cors"; -import { ApiError, AccountUnavailableError } from "../errors.ts"; -import { AccountService } from "../accounts/service.ts"; -import { DynamoAccountStore } from "../accounts/store.ts"; -import { createAccessTokenVerifier } from "../auth/verify.ts"; -import { requireCaller, type AuthDeps, type AuthEnv } from "../auth/resolve.ts"; -import { registerMe } from "../routes/me.ts"; -import { registerTeams } from "../routes/teams.ts"; -import { registerAccounts } from "../routes/accounts.ts"; -import { registerDelegations } from "../routes/delegations.ts"; -import { DelegationService } from "../delegations/service.ts"; -import { DynamoDelegationStore } from "../delegations/store.ts"; -import { recordOperations, requestIdOf } from "../audit/middleware.ts"; -import { DynamoAuditLog } from "../audit/dynamo-log.ts"; -import { StructuredLogAudit } from "../audit/structured-log.ts"; -import { registerMcp } from "../mcp/server.ts"; -import { registerFactory, type FactoryDeps } from "../routes/factory.ts"; +import { createApiApp, jsonLogger, webOriginFromEnv } from "../app.ts"; +import { depsFromEnv } from "../auth/deps.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; -export interface LogEntry { - // 操作の記録の record_id と同じ値(同じ要求の記録とログの行をたどる) - request_id: string; - user_id: string | null; - method: string; - path: string; - status: number; -} - -export type Logger = (entry: LogEntry) => void; - -// ログは利用者の ID・経路・状態コードだけを出す。token とメールアドレスは出さない -export const jsonLogger: Logger = (entry) => console.log(JSON.stringify(entry)); - -// 画面のオリジン(FACTORY_WEB_URL のスキーム・ホスト・ポート)。無いか URL でなければ null で、どのオリジンも許さない -export function webOriginFromEnv(): string | null { - const url = process.env.FACTORY_WEB_URL; - if (!url) return null; - try { - return new URL(url).origin; - } catch { - return null; - } -} - -// API の入口。#2171・#2186・#2174 は、返った app に経路を足し、同じ認証と解決の中間処理を共有する(決定 11) -export function createApiApp( - deps: () => AuthDeps, - log: Logger = jsonLogger, - webOrigin: () => string | null = webOriginFromEnv, - factory: (() => FactoryDeps) | null = null, -): Hono { - const app = new Hono(); - app.use("*", async (c, next) => { - c.set("requestId", requestIdOf(c)); - await next(); - log({ - request_id: c.get("requestId")!, - user_id: c.get("caller")?.userId ?? null, - method: c.req.method, - path: c.req.path, - status: c.res.status, - }); - }); - // 画面と API は別のオリジン。preflight(Authorization を持たない OPTIONS)を認証より先に返す - app.use( - "*", - cors({ - origin: (origin) => (origin === webOrigin() ? origin : null), - allowHeaders: ["Authorization", "Content-Type"], - allowMethods: ["GET", "POST", "PUT", "PATCH", "DELETE"], - }), - ); - // 操作の記録は認証より外に置き、401 で断った要求も記録する(I7) - app.use("*", recordOperations(() => deps().audit, (line) => console.log(JSON.stringify(line)))); - app.use("*", requireCaller(deps)); - app.onError((err, c) => { - const known = err instanceof ApiError ? err : new AccountUnavailableError(); - return c.json(known.body(), known.status); - }); - registerMe(app); - registerTeams(app); - registerAccounts(app); - registerDelegations(app); - if (factory) registerFactory(app, factory); - // MCP の受け口。依存は同じアプリへの内部の要求だけ(#2384 の I15) - registerMcp(app, (path, init, env) => app.request(path, init, env), webOrigin); - return app; -} - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - -let cached: AuthDeps | null = null; - -// 環境変数から依存を作る。起動のたびに 1 回だけ作る -export function depsFromEnv(): AuthDeps { - cached ??= { - verifier: createAccessTokenVerifier({ - userPoolId: requireEnv("COGNITO_USER_POOL_ID"), - clientId: requireEnv("COGNITO_CLIENT_ID"), - }), - accounts: new AccountService({ store: new DynamoAccountStore(requireEnv("ACCOUNTS_TABLE_NAME")) }), - // 委任の資格情報の表(インフラが DELEGATIONS_TABLE_NAME で渡す)。無い間は委任の資格情報を 401 にし、委任の操作を 503 にする - delegations: process.env.DELEGATIONS_TABLE_NAME ? new DelegationService({ store: new DynamoDelegationStore(process.env.DELEGATIONS_TABLE_NAME) }) : null, - // 操作の記録の表(インフラが AUDIT_TABLE_NAME で渡す)。無い間は記録を構造化ログへ出す - audit: process.env.AUDIT_TABLE_NAME ? new DynamoAuditLog(process.env.AUDIT_TABLE_NAME) : new StructuredLogAudit(), - }; - return cached; -} - -// API Gateway の HTTP API(payload 2.0)から呼ばれる Lambda の handler export const handler = handle(createApiApp(depsFromEnv, jsonLogger, webOriginFromEnv, factoryDepsFromEnv)); diff --git a/factory/api/src/handlers/github-oidc.ts b/factory/api/src/handlers/github-oidc.ts index 10ee35b99..8ef28f435 100644 --- a/factory/api/src/handlers/github-oidc.ts +++ b/factory/api/src/handlers/github-oidc.ts @@ -1,14 +1,9 @@ import { handle } from "hono/aws-lambda"; import { Hono } from "hono"; +import { requireEnv } from "../env.ts"; import { createGithubOidcApp } from "../github-oidc/app.ts"; import { loadSigningKeys, type SigningKeys } from "../github-oidc/keys.ts"; -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - // 署名の鍵は起動のたびに 1 回だけ読む let keys: Promise | null = null; let app: Hono | null = null; diff --git a/factory/api/src/handlers/ingest.ts b/factory/api/src/handlers/ingest.ts index 59e3ac681..127d96a4f 100644 --- a/factory/api/src/handlers/ingest.ts +++ b/factory/api/src/handlers/ingest.ts @@ -5,24 +5,8 @@ import { handle } from "hono/aws-lambda"; import { createIngestApp, type IngestDeps } from "../progress/app.ts"; import { runJobTokenVerifierFromEnv } from "../progress/run-job-token.ts"; import { savedAnswersFromEnv } from "../progress/saved-answers.ts"; -import { DynamoProgressEventStore, type ProgressEventStore } from "../progress/store.ts"; - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - -let store: ProgressEventStore | null = null; - -// 進捗の記録の保存。受け口と appendControlEvent が同じものを使う -export function progressStoreFromEnv(): ProgressEventStore { - store ??= new DynamoProgressEventStore( - requireEnv("PROGRESS_EVENTS_TABLE_NAME"), - requireEnv("PROGRESS_IDEMPOTENCY_TABLE_NAME"), - ); - return store; -} +import { requireEnv } from "../env.ts"; +import { progressStoreFromEnv } from "../progress/deps.ts"; let webhookSecret: Promise | null = null; diff --git a/factory/api/src/handlers/issue-requests.ts b/factory/api/src/handlers/issue-requests.ts index ac94df141..18ab1b4f5 100644 --- a/factory/api/src/handlers/issue-requests.ts +++ b/factory/api/src/handlers/issue-requests.ts @@ -4,18 +4,13 @@ import type { AttributeValue } from "@aws-sdk/client-dynamodb"; import { unmarshall } from "@aws-sdk/util-dynamodb"; import type { DynamoDBBatchResponse, DynamoDBStreamEvent } from "aws-lambda"; import type { IssueRequestedEvent } from "@ndf-factory/contracts"; +import { requireEnv } from "../env.ts"; import { DynamoIssueLedger } from "../issue-requests/ledger.ts"; import { DEFAULT_ISSUE_REQUEST_LIMIT_PER_RUN, resolveIssueRequest, type IssueRequestDeps } from "../issue-requests/resolve.ts"; import { defaultFilingStyle } from "../issue-requests/style.ts"; import { appendIssueRequestResolvedTo } from "../progress/append.ts"; import { factoryDepsFromEnv } from "../runs/deps.ts"; -import { progressStoreFromEnv } from "./ingest.ts"; - -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} +import { progressStoreFromEnv } from "../progress/deps.ts"; // 上限は ISSUE_REQUEST_LIMIT_PER_RUN(1 以上の整数)。無い・形が違えば既定の 10 export function issueRequestLimit(env: Record): number { diff --git a/factory/api/src/index.ts b/factory/api/src/index.ts index 65ec4f550..1973cfe5e 100644 --- a/factory/api/src/index.ts +++ b/factory/api/src/index.ts @@ -1,14 +1,14 @@ // 他の子課題(#2171・#2186・#2174・#2189)へ公開する入口。使うのはここから export したものだけ import type { Hono } from "hono"; import { AccountService } from "./accounts/service.ts"; -import { depsFromEnv } from "./handlers/api.ts"; +import { depsFromEnv } from "./auth/deps.ts"; import { verifyAuthorization } from "./auth/verify.ts"; import type { AuthEnv } from "./auth/resolve.ts"; import type { AccountContext, Caller, Plan } from "./accounts/model.ts"; import type { SprintRun } from "./runs/model.ts"; import type { IssueRequestResolvedEvent, JobFinishedEvent } from "@ndf-factory/contracts"; import { appendControlEventTo, appendIssueRequestResolvedTo } from "./progress/append.ts"; -import { progressStoreFromEnv } from "./handlers/ingest.ts"; +import { progressStoreFromEnv } from "./progress/deps.ts"; import { runJobTokenVerifierFromEnv, type VerifiedRunJob } from "./progress/run-job-token.ts"; import { factoryDepsFromEnv } from "./runs/deps.ts"; @@ -16,7 +16,7 @@ export { getLimits, judgeLimits, LIMITS_TABLE } from "./accounts/limits.ts"; export type { CurrentUsage, Judgment, LimitReason, Limits } from "./accounts/limits.ts"; export type { AccountContext, Caller, ExecutionMode, Plan, SeatType } from "./accounts/model.ts"; export { ApiError, AuthError, ForbiddenError, PlanError } from "./errors.ts"; -export { createApiApp } from "./handlers/api.ts"; +export { createApiApp } from "./app.ts"; export { requireCaller, resolveFromRequest } from "./auth/resolve.ts"; export type { AuthEnv }; export type ApiApp = Hono; diff --git a/factory/api/src/progress/deps.ts b/factory/api/src/progress/deps.ts new file mode 100644 index 000000000..ce4a68436 --- /dev/null +++ b/factory/api/src/progress/deps.ts @@ -0,0 +1,14 @@ +// 進捗の記録の保存を環境変数から作る +import { requireEnv } from "../env.ts"; +import { DynamoProgressEventStore, type ProgressEventStore } from "./store.ts"; + +let store: ProgressEventStore | null = null; + +// 進捗の記録の保存。受け口と appendControlEvent が同じものを使う +export function progressStoreFromEnv(): ProgressEventStore { + store ??= new DynamoProgressEventStore( + requireEnv("PROGRESS_EVENTS_TABLE_NAME"), + requireEnv("PROGRESS_IDEMPOTENCY_TABLE_NAME"), + ); + return store; +} diff --git a/factory/api/src/routes/factory.test.ts b/factory/api/src/routes/factory.test.ts index eea325c60..b9654b4f2 100644 --- a/factory/api/src/routes/factory.test.ts +++ b/factory/api/src/routes/factory.test.ts @@ -2,7 +2,7 @@ import { beforeEach, describe, expect, it } from "vitest"; import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; import { CredentialService, type SecretsPort } from "../credentials/service.ts"; -import { createApiApp, type LogEntry } from "../handlers/api.ts"; +import { createApiApp, type LogEntry } from "../app.ts"; import { RepositoryService } from "../repositories/service.ts"; import { memoryProgressReader } from "../progress/read.ts"; import { CANDIDATE_FALLBACK_NOTE, type CandidateModel } from "../runs/candidates.ts"; diff --git a/factory/api/src/routes/sprint-views.test.ts b/factory/api/src/routes/sprint-views.test.ts index c463e8cbb..bace032ea 100644 --- a/factory/api/src/routes/sprint-views.test.ts +++ b/factory/api/src/routes/sprint-views.test.ts @@ -3,7 +3,7 @@ import type { ProgressEvent } from "@ndf-factory/contracts"; import { AccountService } from "../accounts/service.ts"; import { MemoryAccountStore } from "../accounts/memory-store.ts"; import { CredentialService, type SecretsPort } from "../credentials/service.ts"; -import { createApiApp } from "../handlers/api.ts"; +import { createApiApp } from "../app.ts"; import type { GitHubPort } from "../github/port.ts"; import { appendEvent } from "../progress/append.ts"; import { memoryProgressReader } from "../progress/read.ts"; diff --git a/factory/api/src/runs/deps.ts b/factory/api/src/runs/deps.ts index 750f4118d..38c5e12a2 100644 --- a/factory/api/src/runs/deps.ts +++ b/factory/api/src/runs/deps.ts @@ -8,7 +8,8 @@ import { OrganizerControl } from "../organizer/control.ts"; import { appendControlEventTo, appendReleaseAnsweredTo } from "../progress/append.ts"; import { DynamoProgressReader } from "../progress/read.ts"; import type { RunJobSigningKey } from "../progress/run-job-token.ts"; -import { progressStoreFromEnv } from "../handlers/ingest.ts"; +import { progressStoreFromEnv } from "../progress/deps.ts"; +import { requireEnv } from "../env.ts"; import { RepositoryService } from "../repositories/service.ts"; import type { FactoryDeps } from "../routes/factory.ts"; import { bedrockCandidateModel } from "./candidates.ts"; @@ -19,12 +20,6 @@ import { AccountService } from "../accounts/service.ts"; import { DynamoAccountStore } from "../accounts/store.ts"; import { accountContextResolver, LoopControl } from "./loops.ts"; -function requireEnv(name: string): string { - const value = process.env[name]; - if (!value) throw new Error(`環境変数 ${name} がありません`); - return value; -} - const list = (name: string) => requireEnv(name).split(",").map((s) => s.trim()).filter(Boolean); // 秘密を 1 回だけ読む。読めなければ次の呼び出しで読み直す。値をログと応答に出さない diff --git a/factory/api/src/testing/app.ts b/factory/api/src/testing/app.ts index 292ba14be..aa967de2a 100644 --- a/factory/api/src/testing/app.ts +++ b/factory/api/src/testing/app.ts @@ -4,7 +4,7 @@ import { MemoryAccountStore } from "../accounts/memory-store.ts"; import { MemoryDelegationStore } from "../delegations/memory-store.ts"; import { MemoryAuditLog } from "../audit/memory-log.ts"; import { DelegationService } from "../delegations/service.ts"; -import { createApiApp, type LogEntry } from "../handlers/api.ts"; +import { createApiApp, type LogEntry } from "../app.ts"; import { createTokenKit, type TokenKit } from "./tokens.ts"; import type { FactoryDeps } from "../routes/factory.ts"; import { CredentialService, type SecretsPort } from "../credentials/service.ts"; From b6a20863b7ae4219a8a3c9cd2047221dc0ae6735 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 02:08:24 +0000 Subject: [PATCH 17/43] =?UTF-8?q?Refactor:=20=E3=82=A2=E3=82=AB=E3=82=A6?= =?UTF-8?q?=E3=83=B3=E3=83=88=E3=81=AE=E4=BF=9D=E5=AD=98=E3=81=AE=E5=A5=91?= =?UTF-8?q?=E7=B4=84=E3=82=92=20port.ts=20=E3=81=AB=E5=88=86=E3=81=91?= =?UTF-8?q?=E3=80=81=E6=9B=B8=E3=81=8D=E8=BE=BC=E3=81=BF=E3=81=AE=E9=87=8D?= =?UTF-8?q?=E8=A4=87=E3=82=92=E5=AF=84=E3=81=9B=E3=82=8B=EF=BC=88#2199=20?= =?UTF-8?q?=E3=81=AE=20R04=E3=83=BBR06=E3=83=BBR08=E3=83=BBR09=E3=83=BBR10?= =?UTF-8?q?=E3=83=BBR11=E3=83=BBR17=E3=83=BBR18=E3=83=BBR19=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AccountStore・AccountPatch・ConditionFailedError と新しい AccountUpdate を accounts/port.ts へ移す - updateAccount の 5 つの位置引数を AccountUpdate 1 つにする - DynamoAccountStore.transact が履歴を受け取り、items → 履歴 → after の順に並べる - AccountPatch の列を PATCH_COLUMNS の表で回す(プレースホルダは :plan・:mode のまま) - 鍵の頭を USER_PREFIX・ACCOUNT_PREFIX と userPk・accountPk にまとめる - AccountService の 4 つの再試行を retryOnConflict、3 つの条件の失敗の読み替えを writeOr にし、hist を消す - store.test.ts は import 元と updateAccount の呼び出しの形だけを替える(期待値は変えない) Co-Authored-By: Claude Opus 5.5 --- factory/api/src/accounts/memory-store.ts | 10 +- factory/api/src/accounts/port.ts | 50 +++++ factory/api/src/accounts/service.ts | 245 +++++++++++++---------- factory/api/src/accounts/store.test.ts | 5 +- factory/api/src/accounts/store.ts | 158 ++++++--------- 5 files changed, 252 insertions(+), 216 deletions(-) create mode 100644 factory/api/src/accounts/port.ts diff --git a/factory/api/src/accounts/memory-store.ts b/factory/api/src/accounts/memory-store.ts index b3eb4d659..a35cf3d75 100644 --- a/factory/api/src/accounts/memory-store.ts +++ b/factory/api/src/accounts/memory-store.ts @@ -1,4 +1,4 @@ -import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; +import { ConditionFailedError, type AccountStore, type AccountUpdate } from "./port.ts"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; @@ -109,13 +109,7 @@ export class MemoryAccountStore implements AccountStore { await this.write("removeSeat", () => this.seatUnchanged(expected), () => this.seats.delete(key), history); } - async updateAccount( - accountId: string, - expectedVersion: number, - patch: AccountPatch, - updatedAt: string, - history: HistoryRecord[], - ) { + async updateAccount({ accountId, expectedVersion, patch, updatedAt, history }: AccountUpdate) { await this.write( "updateAccount", () => this.accounts.get(accountId)?.version === expectedVersion, diff --git a/factory/api/src/accounts/port.ts b/factory/api/src/accounts/port.ts new file mode 100644 index 000000000..3c2c190bf --- /dev/null +++ b/factory/api/src/accounts/port.ts @@ -0,0 +1,50 @@ +// アカウントの保存の契約(AccountStore)と、その引数・失敗の型。DynamoDB とメモリの実装が満たす。AWS SDK を読まない +import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import type { IdempotencyClaim, IdempotencyRef } from "../idempotency.ts"; + +// 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える +export class ConditionFailedError extends Error { + constructor() { + super("condition failed"); + this.name = "ConditionFailedError"; + } +} + +export interface AccountPatch { + plan?: AccountRecord["plan"]; + executionMode?: AccountRecord["executionMode"]; +} + +// アカウントの更新の 1 回分。version が expectedVersion と等しいときだけ patch を当て、version を 1 増やす +export interface AccountUpdate { + accountId: string; + expectedVersion: number; + patch: AccountPatch; + updatedAt: string; + history: HistoryRecord[]; +} + +// アカウントの表への読み書き。どの書き込みも履歴の行を同じトランザクションで足す(I20) +export interface AccountStore { + getUser(userId: string): Promise; + // 利用者の行が無いときだけ、利用者・個人のアカウント・履歴を書く(I12) + createUserWithPersonalAccount(user: UserRecord, account: AccountRecord, history: HistoryRecord[]): Promise; + getAccount(accountId: string): Promise; + getSeat(accountId: string, userId: string): Promise; + listSeats(accountId: string): Promise; + // 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、 + // 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う + listMemberships(userId: string): Promise; + // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書く。期限の中の同じ鍵があれば ConditionFailedError(#2384 の I14) + createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim): Promise; + // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う + getIdempotent(ref: IdempotencyRef, now: string): Promise; + // 席が無いときだけ足す(I15) + addSeat(seat: SeatRecord, history: HistoryRecord[]): Promise; + // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ変える + updateSeat(expected: SeatRecord, seatType: SeatRecord["seatType"], history: HistoryRecord[]): Promise; + // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ外す + removeSeat(expected: SeatRecord, history: HistoryRecord[]): Promise; + // version が expectedVersion と等しいときだけ変え、version を 1 増やす + updateAccount(update: AccountUpdate): Promise; +} diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts index 85de493e0..497188c04 100644 --- a/factory/api/src/accounts/service.ts +++ b/factory/api/src/accounts/service.ts @@ -18,13 +18,12 @@ import { type Caller, type ExecutionMode, type HistoryRecord, - type HistoryType, type Plan, type SeatRecord, type SeatType, type UserRecord, } from "./model.ts"; -import { ConditionFailedError, type AccountPatch, type AccountStore } from "./store.ts"; +import { ConditionFailedError, type AccountPatch, type AccountStore } from "./port.ts"; import { claimOf, type IdempotencyRef } from "../idempotency.ts"; // 版の競合で読み直して書き直す回数の上限(docs/specifications/ndf-factory-accounts-and-plans.md の「常に成り立つ条件」) @@ -51,10 +50,6 @@ export class AccountService { return this.now().toISOString(); } - private hist(accountId: string, type: HistoryType, at: string, actor: string, before: unknown, after: unknown): HistoryRecord { - return { accountId, type, at, actor, before, after }; - } - // 新しいアカウントの初期値(I24)。形態は INITIAL_EXECUTION_MODE、版は 1 から始める private newAccount(kind: AccountKind, plan: Plan, ownerUserId: string, name: string | null, at: string): AccountRecord { return { @@ -71,12 +66,14 @@ export class AccountService { } private accountCreated(account: AccountRecord, actor: string, extra?: object): HistoryRecord { - return this.hist(account.accountId, "account.created", account.createdAt, actor, null, { - kind: account.kind, - plan: account.plan, - execution_mode: account.executionMode, - ...extra, - }); + return { + accountId: account.accountId, + type: "account.created", + at: account.createdAt, + actor, + before: null, + after: { kind: account.kind, plan: account.plan, execution_mode: account.executionMode, ...extra }, + }; } // 利用者の行を読み、無ければ利用者と個人のアカウント(Free・local)を 1 つのトランザクションで作る(I12・I24、E2) @@ -91,16 +88,15 @@ export class AccountService { personalAccountId: account.accountId, createdAt: at, }; - try { - await this.store.createUserWithPersonalAccount(user, account, [this.accountCreated(account, caller.userId)]); - return user; - } catch (err) { - if (err instanceof ConditionFailedError) { - const raced = await this.store.getUser(caller.userId); - if (raced) return raced; - } - throw new AccountUnavailableError(); - } + const raced = await writeOr( + () => this.store.createUserWithPersonalAccount(user, account, [this.accountCreated(account, caller.userId)]), + async () => { + const found = await this.store.getUser(caller.userId); + if (!found) throw new AccountUnavailableError(); + return found; + }, + ); + return raced ?? user; } // 要求の利用者と経路のアカウントの ID から、権限つきの文脈を返す(決定 4、I18) @@ -171,23 +167,25 @@ export class AccountService { if (replayed) return this.resolveAccount(caller, replayed); const account = this.newAccount("team", "team", caller.userId, name, at); const seat: SeatRecord = { accountId: account.accountId, userId: caller.userId, seatType, addedAt: at }; - try { - await this.store.createTeam( - account, - seat, - [ - this.accountCreated(account, caller.userId, { name }), - this.hist(account.accountId, "seat.added", at, caller.userId, null, { user_id: caller.userId, seat_type: seatType }), - ], - ref && claimOf(ref, account.accountId, at), - ); - } catch (err) { - // 同じ鍵の作成が同時に走り、先に書いた方が勝った - const won = ref && err instanceof ConditionFailedError ? await this.store.getIdempotent(ref, at) : null; - if (won) return this.resolveAccount(caller, won); - throw new AccountUnavailableError(); - } - return this.context(account, caller.userId, seatType); + const won = await writeOr( + () => + this.store.createTeam( + account, + seat, + [ + this.accountCreated(account, caller.userId, { name }), + { accountId: account.accountId, type: "seat.added", at, actor: caller.userId, before: null, after: { user_id: caller.userId, seat_type: seatType } }, + ], + ref && claimOf(ref, account.accountId, at), + ), + async () => { + // 同じ鍵の作成が同時に走り、先に書いた方が勝った + const id = ref ? await this.store.getIdempotent(ref, at) : null; + if (!id) throw new AccountUnavailableError(); + return this.resolveAccount(caller, id); + }, + ); + return won ?? this.context(account, caller.userId, seatType); } // 席の操作は持ち主だけ(I17)。持ち主でなければ席の有無にかかわらず先に 403 @@ -203,39 +201,35 @@ export class AccountService { } const at = this.stamp(); const seat: SeatRecord = { accountId: ctx.accountId, userId, seatType, addedAt: at }; - try { - await this.store.addSeat(seat, [ - this.hist(ctx.accountId, "seat.added", at, ctx.userId, null, { user_id: userId, seat_type: seatType }), - ]); - } catch (err) { - if (err instanceof ConditionFailedError) { + await writeOr( + () => + this.store.addSeat(seat, [ + { accountId: ctx.accountId, type: "seat.added", at, actor: ctx.userId, before: null, after: { user_id: userId, seat_type: seatType } }, + ]), + () => { throw new ConflictError("seat_exists", "その利用者は既にこのチームの席を持っています"); - } - throw new AccountUnavailableError(); - } + }, + ); return seat; } async changeSeat(ctx: AccountContext, userId: string, seatType: SeatType): Promise { this.requireTeamOwner(ctx); // 読んだ席のままのときだけ書く。競合したら読み直し、履歴の before を作り直す - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { + return retryOnConflict(async () => { const current = await this.store.getSeat(ctx.accountId, userId); if (!current) throw seatNotFound(); const at = this.stamp(); - try { - await this.store.updateSeat(current, seatType, [ - this.hist(ctx.accountId, "seat.changed", at, ctx.userId, { user_id: userId, seat_type: current.seatType }, { - user_id: userId, - seat_type: seatType, - }), - ]); - return { ...current, seatType }; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); - } - } - throw new AccountUnavailableError(); + const history: HistoryRecord = { + accountId: ctx.accountId, + type: "seat.changed", + at, + actor: ctx.userId, + before: { user_id: userId, seat_type: current.seatType }, + after: { user_id: userId, seat_type: seatType }, + }; + return { write: () => this.store.updateSeat(current, seatType, [history]), result: { ...current, seatType } }; + }); } async removeSeat(ctx: AccountContext, userId: string): Promise { @@ -245,72 +239,111 @@ export class AccountService { throw new ConflictError("owner_seat_protected", "チームの持ち主の席は外せません"); } // 読んだ席のままのときだけ外す。外して足し直した新しい席を古い要求で消さない - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { + await retryOnConflict(async () => { const current = await this.store.getSeat(ctx.accountId, userId); if (!current) throw seatNotFound(); const at = this.stamp(); - try { - await this.store.removeSeat(current, [ - this.hist(ctx.accountId, "seat.removed", at, ctx.userId, { user_id: userId, seat_type: current.seatType }, null), - ]); - return; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); - } - } - throw new AccountUnavailableError(); + const history: HistoryRecord = { + accountId: ctx.accountId, + type: "seat.removed", + at, + actor: ctx.userId, + before: { user_id: userId, seat_type: current.seatType }, + after: null, + }; + return { write: () => this.store.removeSeat(current, [history]), result: undefined }; + }); } // 実行の形態を変える。持ち主だけ(I22)。Free で managed は断る(I21) async setExecutionMode(ctx: AccountContext, mode: ExecutionMode): Promise { if (ctx.role !== "owner") throw new ForbiddenError(); - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { + return retryOnConflict(async () => { const account = await this.store.getAccount(ctx.accountId); if (!account) throw new ForbiddenError(); - if (account.executionMode === mode) return account; + if (account.executionMode === mode) return { done: account }; if (mode === "managed" && account.plan === "free") { throw new ConflictError("managed_not_allowed_on_free", "Free の料金プランでは managed(サービスの Bedrock)を選べません"); } const at = this.stamp(); - try { - await this.store.updateAccount(account.accountId, account.version, { executionMode: mode }, at, [ - this.hist(account.accountId, "execution_mode.changed", at, ctx.userId, account.executionMode, mode), - ]); - return { ...account, executionMode: mode, version: account.version + 1, updatedAt: at }; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); - } - } - throw new AccountUnavailableError(); + const history: HistoryRecord = { + accountId: account.accountId, + type: "execution_mode.changed", + at, + actor: ctx.userId, + before: account.executionMode, + after: mode, + }; + return { + write: () => + this.store.updateAccount({ accountId: account.accountId, expectedVersion: account.version, patch: { executionMode: mode }, updatedAt: at, history: [history] }), + result: { ...account, executionMode: mode, version: account.version + 1, updatedAt: at }, + }; + }); } // アカウントの料金プランを変える。#2174 が決済の後に呼ぶ内側の関数(前提 8、E6) // free へ下げるとき形態が managed なら、同じトランザクションで local へ変える(I21) async setPlan(accountId: string, plan: Plan): Promise { if (!isPlan(plan)) throw new PlanError("unknown_plan", `知らない料金プランです: ${String(plan)}`); - for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { - const account = await this.store.getAccount(accountId); - if (!account) throw new PlanError("account_not_found", "アカウントがありません"); - if (!planFitsKind(account.kind, plan)) { - throw new PlanError("plan_kind_mismatch", `${account.kind} のアカウントに ${plan} は置けません`); - } - if (account.plan === plan) return; - const at = this.stamp(); - const patch: AccountPatch = { plan }; - const history = [this.hist(accountId, "plan.changed", at, "system", account.plan, plan)]; - if (plan === "free" && account.executionMode === "managed") { - patch.executionMode = "local"; - history.push(this.hist(accountId, "execution_mode.changed", at, "system", "managed", "local")); - } - try { - await this.store.updateAccount(accountId, account.version, patch, at, history); - return; - } catch (err) { - if (!(err instanceof ConditionFailedError)) throw err; - } + // 条件以外の書き込みの失敗は包まずにそのまま返す(呼ぶ側の #2174 が受ける) + await retryOnConflict( + async () => { + const account = await this.store.getAccount(accountId); + if (!account) throw new PlanError("account_not_found", "アカウントがありません"); + if (!planFitsKind(account.kind, plan)) { + throw new PlanError("plan_kind_mismatch", `${account.kind} のアカウントに ${plan} は置けません`); + } + if (account.plan === plan) return { done: undefined }; + const at = this.stamp(); + const patch: AccountPatch = { plan }; + const history: HistoryRecord[] = [{ accountId, type: "plan.changed", at, actor: "system", before: account.plan, after: plan }]; + if (plan === "free" && account.executionMode === "managed") { + patch.executionMode = "local"; + history.push({ accountId, type: "execution_mode.changed", at, actor: "system", before: "managed", after: "local" }); + } + return { + write: () => this.store.updateAccount({ accountId, expectedVersion: account.version, patch, updatedAt: at, history }), + result: undefined, + }; + }, + (err) => err, + ); + } +} + +// 読み直しと判定の 1 回分の結果。書かずに終わる(done)か、書いて終わる(write が成功したら result) +type ConflictStep = { done: T } | { write: () => Promise; result: T }; + +// 版の競合(条件の失敗)なら step から読み直して上限の回数まで書き直す。step の中で投げた誤りはそのまま届く。 +// 条件以外の書き込みの失敗は onOther の結果を投げる(既定は AccountUnavailableError) +async function retryOnConflict( + step: () => Promise>, + onOther: (err: unknown) => unknown = () => new AccountUnavailableError(), +): Promise { + for (let attempt = 0; attempt < VERSION_RETRIES; attempt++) { + const next = await step(); + if ("done" in next) return next.done; + try { + await next.write(); + return next.result; + } catch (err) { + if (!(err instanceof ConditionFailedError)) throw onOther(err); } - throw new AccountUnavailableError(); } + throw new AccountUnavailableError(); +} + +// 再試行しない書き込み。成功なら undefined、条件の失敗なら onCondition の結果を返す(onCondition は投げてもよい)。 +// 条件以外の失敗は AccountUnavailableError +async function writeOr(write: () => Promise, onCondition: () => T | Promise): Promise { + try { + await write(); + return undefined; + } catch (err) { + if (!(err instanceof ConditionFailedError)) throw new AccountUnavailableError(); + } + return onCondition(); } function seatNotFound(): NotFoundError { diff --git a/factory/api/src/accounts/store.test.ts b/factory/api/src/accounts/store.test.ts index 9cea02796..abcf325e9 100644 --- a/factory/api/src/accounts/store.test.ts +++ b/factory/api/src/accounts/store.test.ts @@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it } from "vitest"; import { mockClient } from "aws-sdk-client-mock"; import { DynamoDBClient, TransactionCanceledException } from "@aws-sdk/client-dynamodb"; import { DynamoDBDocumentClient, GetCommand, QueryCommand, TransactWriteCommand } from "@aws-sdk/lib-dynamodb"; -import { ConditionFailedError, DynamoAccountStore } from "./store.ts"; +import { ConditionFailedError } from "./port.ts"; +import { DynamoAccountStore } from "./store.ts"; import type { AccountRecord, SeatRecord } from "./model.ts"; const ddb = mockClient(DynamoDBDocumentClient); @@ -53,7 +54,7 @@ describe("DynamoAccountStore(決定 8)", () => { it("アカウントの更新は版を条件にし、版を 1 増やす", async () => { ddb.on(TransactWriteCommand).resolves({}); - await store.updateAccount("a-1", 4, { plan: "free", executionMode: "local" }, account.updatedAt, []); + await store.updateAccount({ accountId: "a-1", expectedVersion: 4, patch: { plan: "free", executionMode: "local" }, updatedAt: account.updatedAt, history: [] }); const update = ddb.commandCalls(TransactWriteCommand)[0]!.args[0].input.TransactItems![0]!.Update!; expect(update.ConditionExpression).toBe("attribute_exists(#pk) AND #version = :expected"); expect(update.ExpressionAttributeValues).toMatchObject({ ":expected": 4, ":next": 5, ":plan": "free", ":mode": "local" }); diff --git a/factory/api/src/accounts/store.ts b/factory/api/src/accounts/store.ts index 30e626b18..85f43eb5e 100644 --- a/factory/api/src/accounts/store.ts +++ b/factory/api/src/accounts/store.ts @@ -8,55 +8,18 @@ import { type TransactWriteCommandInput, } from "@aws-sdk/lib-dynamodb"; import type { AccountRecord, HistoryRecord, SeatRecord, UserRecord } from "./model.ts"; +import { ConditionFailedError, type AccountPatch, type AccountStore, type AccountUpdate } from "./port.ts"; import { epochSeconds, idempotencyPk, liveCreatedId, type IdempotencyClaim, type IdempotencyRef } from "../idempotency.ts"; -// 条件つきの書き込みが条件で失敗した。呼ぶ側が意味(I12・I15・版の競合など)へ読み替える -export class ConditionFailedError extends Error { - constructor() { - super("condition failed"); - this.name = "ConditionFailedError"; - } -} +// 鍵の頭。表の pk と GSI gsi1 の鍵は、利用者とアカウントの ID にこの頭を付ける +const USER_PREFIX = "USER#"; +const ACCOUNT_PREFIX = "ACCOUNT#"; +const userPk = (userId: string) => `${USER_PREFIX}${userId}`; +const accountPk = (accountId: string) => `${ACCOUNT_PREFIX}${accountId}`; -export interface AccountPatch { - plan?: AccountRecord["plan"]; - executionMode?: AccountRecord["executionMode"]; -} - -// アカウントの表への読み書き。どの書き込みも履歴の行を同じトランザクションで足す(I20) -export interface AccountStore { - getUser(userId: string): Promise; - // 利用者の行が無いときだけ、利用者・個人のアカウント・履歴を書く(I12) - createUserWithPersonalAccount(user: UserRecord, account: AccountRecord, history: HistoryRecord[]): Promise; - getAccount(accountId: string): Promise; - getSeat(accountId: string, userId: string): Promise; - listSeats(accountId: string): Promise; - // 利用者が席を持つチームのアカウントの ID(GSI gsi1)。GSI は結果整合のため、 - // 返すのは候補であり、呼ぶ側は getSeat で元の席を確かめてから使う - listMemberships(userId: string): Promise; - // idempotency があれば作成の冪等の鍵の行も同じトランザクションで書く。期限の中の同じ鍵があれば ConditionFailedError(#2384 の I14) - createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim): Promise; - // 作成の冪等の鍵の行が作った識別子。期限(ttl)を過ぎた行は、残っていても無いものとして扱う - getIdempotent(ref: IdempotencyRef, now: string): Promise; - // 席が無いときだけ足す(I15) - addSeat(seat: SeatRecord, history: HistoryRecord[]): Promise; - // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ変える - updateSeat(expected: SeatRecord, seatType: SeatRecord["seatType"], history: HistoryRecord[]): Promise; - // 席が読んだときのまま(種類と足した時刻が expected と等しい)のときだけ外す - removeSeat(expected: SeatRecord, history: HistoryRecord[]): Promise; - // version が expectedVersion と等しいときだけ変え、version を 1 増やす - updateAccount( - accountId: string, - expectedVersion: number, - patch: AccountPatch, - updatedAt: string, - history: HistoryRecord[], - ): Promise; -} - -const userKey = (userId: string) => ({ pk: `USER#${userId}`, sk: "USER" }); -const accountKey = (accountId: string) => ({ pk: `ACCOUNT#${accountId}`, sk: "ACCOUNT" }); -const seatKey = (accountId: string, userId: string) => ({ pk: `ACCOUNT#${accountId}`, sk: `SEAT#${userId}` }); +const userKey = (userId: string) => ({ pk: userPk(userId), sk: "USER" }); +const accountKey = (accountId: string) => ({ pk: accountPk(accountId), sk: "ACCOUNT" }); +const seatKey = (accountId: string, userId: string) => ({ pk: accountPk(accountId), sk: `SEAT#${userId}` }); type Item = Record; @@ -88,8 +51,8 @@ function accountItem(a: AccountRecord): Item { function seatItem(s: SeatRecord): Item { return { ...seatKey(s.accountId, s.userId), - gsi1pk: `USER#${s.userId}`, - gsi1sk: `ACCOUNT#${s.accountId}`, + gsi1pk: userPk(s.userId), + gsi1sk: accountPk(s.accountId), account_id: s.accountId, user_id: s.userId, seat_type: s.seatType, @@ -99,7 +62,7 @@ function seatItem(s: SeatRecord): Item { function historyItem(h: HistoryRecord): Item { return { - pk: `ACCOUNT#${h.accountId}`, + pk: accountPk(h.accountId), sk: `HIST#${h.at}#${randomUUID()}`, account_id: h.accountId, type: h.type, @@ -148,6 +111,14 @@ const SEAT_UNCHANGED = "attribute_exists(#pk) AND #seat_type = :expected_type AN const SEAT_NAMES = { "#pk": "pk", "#seat_type": "seat_type", "#added_at": "added_at" }; const seatExpected = (s: SeatRecord) => ({ ":expected_type": s.seatType, ":expected_added_at": s.addedAt }); +// AccountPatch の項目ごとの列の名前とプレースホルダ。SET には表の並びで足す +const PATCH_COLUMNS: { field: keyof AccountPatch; column: string; placeholder: string }[] = [ + { field: "plan", column: "plan", placeholder: ":plan" }, + { field: "executionMode", column: "execution_mode", placeholder: ":mode" }, +]; + +type TransactItem = NonNullable[number]; + export class DynamoAccountStore implements AccountStore { private readonly doc: DynamoDBDocumentClient; @@ -163,13 +134,15 @@ export class DynamoAccountStore implements AccountStore { return (out.Item as Item | undefined) ?? null; } - private put(item: Item, condition?: string): NonNullable[number] { + private put(item: Item, condition?: string): TransactItem { return { Put: { TableName: this.tableName, Item: item, ConditionExpression: condition } }; } - private async transact(items: NonNullable): Promise { + // どの書き込みも履歴の行を同じトランザクションで足す(I20)。並びは items → 履歴 → after + private async transact(items: TransactItem[], history: HistoryRecord[], after: TransactItem[] = []): Promise { + const all = [...items, ...history.map((h) => this.put(historyItem(h))), ...after]; try { - await this.doc.send(new TransactWriteCommand({ TransactItems: items })); + await this.doc.send(new TransactWriteCommand({ TransactItems: all })); } catch (err) { if ( err instanceof TransactionCanceledException && @@ -187,11 +160,7 @@ export class DynamoAccountStore implements AccountStore { } async createUserWithPersonalAccount(user: UserRecord, account: AccountRecord, history: HistoryRecord[]) { - await this.transact([ - this.put(userItem(user), NOT_EXISTS), - this.put(accountItem(account), NOT_EXISTS), - ...history.map((h) => this.put(historyItem(h))), - ]); + await this.transact([this.put(userItem(user), NOT_EXISTS), this.put(accountItem(account), NOT_EXISTS)], history); } async getAccount(accountId: string) { @@ -208,7 +177,7 @@ export class DynamoAccountStore implements AccountStore { const items = await this.queryAll({ TableName: this.tableName, KeyConditionExpression: "pk = :pk AND begins_with(sk, :seat)", - ExpressionAttributeValues: { ":pk": `ACCOUNT#${accountId}`, ":seat": "SEAT#" }, + ExpressionAttributeValues: { ":pk": accountPk(accountId), ":seat": "SEAT#" }, ConsistentRead: true, }); return items.map(toSeat); @@ -219,10 +188,10 @@ export class DynamoAccountStore implements AccountStore { TableName: this.tableName, IndexName: "gsi1", KeyConditionExpression: "gsi1pk = :u", - ExpressionAttributeValues: { ":u": `USER#${userId}` }, + ExpressionAttributeValues: { ":u": userPk(userId) }, }); // 射影(KEYS_ONLY など)によらず必ず載る表の鍵 pk(ACCOUNT#)から ID を取る - return items.map((i) => (i.pk as string).slice("ACCOUNT#".length)); + return items.map((i) => (i.pk as string).slice(ACCOUNT_PREFIX.length)); } private async queryAll(input: ConstructorParameters[0]): Promise { @@ -237,11 +206,10 @@ export class DynamoAccountStore implements AccountStore { } async createTeam(account: AccountRecord, ownerSeat: SeatRecord, history: HistoryRecord[], idempotency?: IdempotencyClaim) { - await this.transact([ - this.put(accountItem(account), NOT_EXISTS), - this.put(seatItem(ownerSeat), NOT_EXISTS), - ...history.map((h) => this.put(historyItem(h))), - ...(idempotency + await this.transact( + [this.put(accountItem(account), NOT_EXISTS), this.put(seatItem(ownerSeat), NOT_EXISTS)], + history, + idempotency ? [ { Put: { @@ -253,8 +221,8 @@ export class DynamoAccountStore implements AccountStore { }, }, ] - : []), - ]); + : [], + ); } async getIdempotent(ref: IdempotencyRef, now: string) { @@ -263,7 +231,7 @@ export class DynamoAccountStore implements AccountStore { } async addSeat(seat: SeatRecord, history: HistoryRecord[]) { - await this.transact([this.put(seatItem(seat), NOT_EXISTS), ...history.map((h) => this.put(historyItem(h)))]); + await this.transact([this.put(seatItem(seat), NOT_EXISTS)], history); } async updateSeat(expected: SeatRecord, seatType: SeatRecord["seatType"], history: HistoryRecord[]) { @@ -278,8 +246,7 @@ export class DynamoAccountStore implements AccountStore { ExpressionAttributeValues: { ":t": seatType, ...seatExpected(expected) }, }, }, - ...history.map((h) => this.put(historyItem(h))), - ]); + ], history); } async removeSeat(expected: SeatRecord, history: HistoryRecord[]) { @@ -293,43 +260,34 @@ export class DynamoAccountStore implements AccountStore { ExpressionAttributeValues: seatExpected(expected), }, }, - ...history.map((h) => this.put(historyItem(h))), - ]); + ], history); } - async updateAccount( - accountId: string, - expectedVersion: number, - patch: AccountPatch, - updatedAt: string, - history: HistoryRecord[], - ) { + async updateAccount({ accountId, expectedVersion, patch, updatedAt, history }: AccountUpdate) { // 予約語と重ならないよう、列の名前は常に #名前 で書く const names: Record = { "#pk": "pk", "#version": "version", "#updated_at": "updated_at" }; const sets = ["#version = :next", "#updated_at = :at"]; const values: Record = { ":expected": expectedVersion, ":next": expectedVersion + 1, ":at": updatedAt }; - if (patch.plan !== undefined) { - names["#plan"] = "plan"; - sets.push("#plan = :plan"); - values[":plan"] = patch.plan; + for (const { field, column, placeholder } of PATCH_COLUMNS) { + if (patch[field] === undefined) continue; + names[`#${column}`] = column; + sets.push(`#${column} = ${placeholder}`); + values[placeholder] = patch[field]; } - if (patch.executionMode !== undefined) { - names["#execution_mode"] = "execution_mode"; - sets.push("#execution_mode = :mode"); - values[":mode"] = patch.executionMode; - } - await this.transact([ - { - Update: { - TableName: this.tableName, - Key: accountKey(accountId), - UpdateExpression: `SET ${sets.join(", ")}`, - ConditionExpression: "attribute_exists(#pk) AND #version = :expected", - ExpressionAttributeNames: names, - ExpressionAttributeValues: values, + await this.transact( + [ + { + Update: { + TableName: this.tableName, + Key: accountKey(accountId), + UpdateExpression: `SET ${sets.join(", ")}`, + ConditionExpression: "attribute_exists(#pk) AND #version = :expected", + ExpressionAttributeNames: names, + ExpressionAttributeValues: values, + }, }, - }, - ...history.map((h) => this.put(historyItem(h))), - ]); + ], + history, + ); } } From 068c56df2abb757d7bcf4ed245737823789c512f Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 02:09:12 +0000 Subject: [PATCH 18/43] =?UTF-8?q?Refactor:=20=E5=B8=AD=E3=81=AE=E7=A8=AE?= =?UTF-8?q?=E9=A1=9E=E3=81=AE=E6=A4=9C=E8=A8=BC=E3=83=BB=E8=A9=B3=E7=B4=B0?= =?UTF-8?q?=E3=81=AE=E6=9C=AC=E6=96=87=E3=83=BBGitHub=20=E3=81=AE=E5=88=A9?= =?UTF-8?q?=E7=94=A8=E8=80=85=E3=81=AE=E5=8F=96=E5=BE=97=E3=83=BBIdP=20?= =?UTF-8?q?=E3=81=AE=E5=88=A4=E5=AE=9A=E3=82=92=201=20=E3=81=8B=E6=89=80?= =?UTF-8?q?=E3=81=AB=E3=81=99=E3=82=8B=EF=BC=88#2199=20=E3=81=AE=20R01?= =?UTF-8?q?=E3=83=BBR13=E3=83=BBR03=E3=83=BBR12=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - validateSeatType を accounts/service.ts に置き、teams.ts と accounts.ts の 3 つの経路が使う(文言は同じ) - 「チームなら席を読んで詳細の本文を作る」を views.ts の accountDetailBody にし、3 つの経路が使う - github-oidc/app.ts の /token と /userinfo が githubUser を使う(判定の条件は同じ) - auth/verify.ts の IdP の判定を idpFromUsername に出す(判定の順序は同じ) Co-Authored-By: Claude Opus 5.5 --- factory/api/src/accounts/service.ts | 8 ++++++++ factory/api/src/auth/verify.ts | 9 +++++++-- factory/api/src/github-oidc/app.ts | 14 ++++++++++---- factory/api/src/routes/accounts.ts | 22 +++++++++------------- factory/api/src/routes/teams.ts | 13 +++++-------- factory/api/src/routes/views.ts | 7 +++++++ 6 files changed, 46 insertions(+), 27 deletions(-) diff --git a/factory/api/src/accounts/service.ts b/factory/api/src/accounts/service.ts index 497188c04..a414ce8ec 100644 --- a/factory/api/src/accounts/service.ts +++ b/factory/api/src/accounts/service.ts @@ -11,6 +11,7 @@ import { import { INITIAL_EXECUTION_MODE, isPlan, + isSeatType, planFitsKind, type AccountContext, type AccountKind, @@ -353,6 +354,13 @@ function seatNotFound(): NotFoundError { const TEAM_NAME_MAX_LENGTH = 64; const TEAM_NAME_MESSAGE = `1〜${TEAM_NAME_MAX_LENGTH} 字で入力してください`; +const SEAT_TYPE_MESSAGE = "席の種類は standard か premium です"; + +export function validateSeatType(value: unknown): SeatType { + if (!isSeatType(value)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); + return value; +} + export function validateTeamName(name: unknown): string { if (typeof name !== "string") throw new InvalidRequestError(TEAM_NAME_MESSAGE); const length = [...name].length; diff --git a/factory/api/src/auth/verify.ts b/factory/api/src/auth/verify.ts index 47ab11888..0822bcad5 100644 --- a/factory/api/src/auth/verify.ts +++ b/factory/api/src/auth/verify.ts @@ -14,6 +14,12 @@ const IDP_PREFIXES: ReadonlyArray<[string, Idp]> = [ ["GitHub_", "github"], ]; +// token の username の頭から IdP を決める。文字列でない・知らない頭なら undefined +function idpFromUsername(username: unknown): Idp | undefined { + const name = typeof username === "string" ? username : ""; + return IDP_PREFIXES.find(([prefix]) => name.startsWith(prefix))?.[1]; +} + export type AccessTokenVerifier = ReturnType; export function createAccessTokenVerifier(config: VerifierConfig) { @@ -36,8 +42,7 @@ export async function verifyAuthorization(verifier: AccessTokenVerifier, authori if (err instanceof JwtBaseError) throw new AuthError(); throw err; } - const username = typeof payload.username === "string" ? payload.username : ""; - const idp = IDP_PREFIXES.find(([prefix]) => username.startsWith(prefix))?.[1]; + const idp = idpFromUsername(payload.username); if (!idp || typeof payload.sub !== "string" || payload.sub === "") throw new AuthError(); return { userId: payload.sub, idp }; } diff --git a/factory/api/src/github-oidc/app.ts b/factory/api/src/github-oidc/app.ts index 64f5681ea..20e8f2ab9 100644 --- a/factory/api/src/github-oidc/app.ts +++ b/factory/api/src/github-oidc/app.ts @@ -120,6 +120,12 @@ export function createGithubOidcApp(config: GithubOidcConfig, deps: GithubOidcDe return (await res.json()) as T; } + // GitHub の利用者。取れない・数の ID を持たなければ null + async function githubUser(accessToken: string): Promise { + const user = await githubJson("/user", accessToken); + return user && typeof user.id === "number" ? user : null; + } + async function exchangeGithubCode( clientId: string, clientSecret: string, @@ -212,8 +218,8 @@ export function createGithubOidcApp(config: GithubOidcConfig, deps: GithubOidcDe } const token = await exchangeGithubCode(clientId, clientSecret, code.gh_code); if (!token.access_token) return oauthError(c, "invalid_grant"); - const user = await githubJson("/user", token.access_token); - if (!user || typeof user.id !== "number") return oauthError(c, "invalid_grant"); + const user = await githubUser(token.access_token); + if (!user) return oauthError(c, "invalid_grant"); // sub は GitHub の数の利用者 ID。利用者が変えられるログイン名とメールを使わない const idToken = await issueIdToken(String(user.id), clientId, code.nonce); return c.json({ @@ -227,8 +233,8 @@ export function createGithubOidcApp(config: GithubOidcConfig, deps: GithubOidcDe app.on(["GET", "POST"], "/userinfo", async (c) => { const bearer = /^Bearer (.+)$/.exec(c.req.header("Authorization") ?? ""); if (!bearer) return oauthError(c, "invalid_token", 401); - const user = await githubJson("/user", bearer[1]!); - if (!user || typeof user.id !== "number") return oauthError(c, "invalid_token", 401); + const user = await githubUser(bearer[1]!); + if (!user) return oauthError(c, "invalid_token", 401); const emails = (await githubJson("/user/emails", bearer[1]!)) ?? []; const primary = emails.find((e) => e.primary && e.verified); return c.json({ diff --git a/factory/api/src/routes/accounts.ts b/factory/api/src/routes/accounts.ts index 30b594bb5..909785128 100644 --- a/factory/api/src/routes/accounts.ts +++ b/factory/api/src/routes/accounts.ts @@ -1,11 +1,10 @@ import type { Context, Hono } from "hono"; import { ForbiddenError, InvalidRequestError } from "../errors.ts"; -import { isExecutionMode, isSeatType, type AccountContext } from "../accounts/model.ts"; +import { isExecutionMode, type AccountContext } from "../accounts/model.ts"; +import { validateSeatType } from "../accounts/service.ts"; import { resolveFromRequest, type AuthEnv } from "../auth/resolve.ts"; import { registerOperation } from "./operation.ts"; -import { accountDetail, seatView } from "./views.ts"; - -const SEAT_TYPE_MESSAGE = "席の種類は standard か premium です"; +import { accountDetailBody, seatView } from "./views.ts"; function userIdOf(raw: unknown): string { if (typeof raw !== "string" || raw === "" || raw.length > 128) throw new InvalidRequestError("利用者の ID の形ではありません"); @@ -25,8 +24,7 @@ export function registerAccounts(app: Hono): void { const accounts = c.get("auth").accounts; const account = await accounts.getAccount(ctx.accountId); if (!account) throw new ForbiddenError(); - const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null; - return accountDetail(ctx, account, seats); + return accountDetailBody(accounts, ctx, account); }, }); @@ -38,25 +36,23 @@ export function registerAccounts(app: Hono): void { } const accounts = c.get("auth").accounts; const account = await accounts.setExecutionMode(ctx, e.execution_mode); - const next = { ...ctx, executionMode: account.executionMode, plan: account.plan }; - const seats = next.kind === "team" ? await accounts.listSeats(next) : null; - return accountDetail(next, account, seats); + return accountDetailBody(accounts, { ...ctx, executionMode: account.executionMode, plan: account.plan }, account); }, }); registerOperation(app, "addSeat", { run: async (e, ctx, _s, c) => { const userId = userIdOf(e.user_id); - if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); - return seatView(await c.get("auth").accounts.addSeat(ctx!, userId, e.seat_type)); + const seatType = validateSeatType(e.seat_type); + return seatView(await c.get("auth").accounts.addSeat(ctx!, userId, seatType)); }, }); registerOperation(app, "changeSeat", { run: async (e, ctx, _s, c) => { const userId = userIdOf(e.user_id); - if (!isSeatType(e.seat_type)) throw new InvalidRequestError(SEAT_TYPE_MESSAGE); - return seatView(await c.get("auth").accounts.changeSeat(ctx!, userId, e.seat_type)); + const seatType = validateSeatType(e.seat_type); + return seatView(await c.get("auth").accounts.changeSeat(ctx!, userId, seatType)); }, }); diff --git a/factory/api/src/routes/teams.ts b/factory/api/src/routes/teams.ts index 77c0d275c..0094e2a9d 100644 --- a/factory/api/src/routes/teams.ts +++ b/factory/api/src/routes/teams.ts @@ -1,24 +1,21 @@ import type { Hono } from "hono"; -import { InvalidRequestError } from "../errors.ts"; -import { isSeatType } from "../accounts/model.ts"; -import { validateTeamName } from "../accounts/service.ts"; +import { validateSeatType, validateTeamName } from "../accounts/service.ts"; import type { AuthEnv } from "../auth/resolve.ts"; import { parseIdempotencyKey } from "../idempotency.ts"; import { registerOperation } from "./operation.ts"; -import { accountDetail } from "./views.ts"; +import { accountDetailBody } from "./views.ts"; export function registerTeams(app: Hono): void { // 同じ冪等の鍵の 2 回目は 1 回目のチームを返す(#2384 の I14) registerOperation(app, "createTeam", { run: async (e, _ctx, _s, c) => { const name = validateTeamName(e.name); - if (!isSeatType(e.seat_type)) throw new InvalidRequestError("席の種類は standard か premium です"); + const seatType = validateSeatType(e.seat_type); const key = parseIdempotencyKey(e.idempotency_key); const accounts = c.get("auth").accounts; - const ctx = await accounts.createTeam(c.get("caller"), name, e.seat_type, key); + const ctx = await accounts.createTeam(c.get("caller"), name, seatType, key); const account = await accounts.getAccount(ctx.accountId); - const seats = await accounts.listSeats(ctx); - return accountDetail(ctx, { name: account?.name ?? name }, seats); + return accountDetailBody(accounts, ctx, { name: account?.name ?? name }); }, }); } diff --git a/factory/api/src/routes/views.ts b/factory/api/src/routes/views.ts index e5c1364ab..7fada73a3 100644 --- a/factory/api/src/routes/views.ts +++ b/factory/api/src/routes/views.ts @@ -1,5 +1,6 @@ import { getLimits } from "../accounts/limits.ts"; import type { AccountContext, AccountRecord, SeatRecord, UserRecord } from "../accounts/model.ts"; +import type { AccountService } from "../accounts/service.ts"; // 応答の本文の形(factory/contracts/openapi/accounts.yaml) export function accountSummary(ctx: AccountContext, name: string | null) { @@ -30,3 +31,9 @@ export function accountDetail( seats: seats && seats.map(seatView), }; } + +// アカウントの詳細の本文。チームなら席を読んで載せ、個人なら seats を null にする +export async function accountDetailBody(accounts: AccountService, ctx: AccountContext, account: Pick) { + const seats = ctx.kind === "team" ? await accounts.listSeats(ctx) : null; + return accountDetail(ctx, account, seats); +} From ccce758bc5805c6c02267f7ab0292318d55ac154 Mon Sep 17 00:00:00 2001 From: "takemi.ohama" Date: Thu, 5 Nov 2026 02:11:23 +0000 Subject: [PATCH 19/43] =?UTF-8?q?Refactor:=20=E7=94=BB=E9=9D=A2=E3=81=AE?= =?UTF-8?q?=E3=83=AD=E3=82=B0=E3=82=A4=E3=83=B3=E3=81=AE=E7=A2=BA=E8=AA=8D?= =?UTF-8?q?=E3=83=BB=E5=A4=B1=E6=95=97=E3=81=AE=E8=A1=A8=E7=A4=BA=E3=83=BB?= =?UTF-8?q?=E5=B8=AD=E3=81=AE=E7=A8=AE=E9=A1=9E=E3=81=AE=E9=81=B8=E6=8A=9E?= =?UTF-8?q?=E8=82=A2=E3=82=92=201=20=E3=81=8B=E6=89=80=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=EF=BC=88#2199=20=E3=81=AE=20R05=E3=83=BBR14=E3=83=BBR?= =?UTF-8?q?15=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - RequireLogin(context.tsx)で App.tsx のログインの後の枠を包み、12 画面の hasToken と /login への転送を消す - usePolled の enabled の引数を消す(呼ぶ画面はすべて token の有無を渡していただけ) - 「呼んで、失敗なら画面の文を出す」を useAttempt(context.tsx)にし、判定表の対象の try/catch を置き換える - 席の種類の