diff --git a/openspec/changes/add-actionability-gate/.openspec.yaml b/openspec/changes/add-actionability-gate/.openspec.yaml new file mode 100644 index 0000000..6fec2ce --- /dev/null +++ b/openspec/changes/add-actionability-gate/.openspec.yaml @@ -0,0 +1,4 @@ +schema: spec-driven +created: 2026-08-14 +created_by: che cheng +created_with: claude diff --git a/openspec/changes/add-actionability-gate/design.md b/openspec/changes/add-actionability-gate/design.md new file mode 100644 index 0000000..c742b9c --- /dev/null +++ b/openspec/changes/add-actionability-gate/design.md @@ -0,0 +1,141 @@ +## Context + +`/idd-list` 與 `/idd-all` 的 routing 需要回答一個問題:**這個 issue 現在可不可以動?** 目前四個候選訊號中只有一個有 routing consumer: + +| 訊號 | 位置 | 生命週期 | 現況 | +|---|---|---|---| +| `### Complexity` 的限定詞 | Diagnosis comment(append-only)| 凍結 | 被三個 consumer 各自窄化,互不相容 | +| `parking-lot` label | Issue labels | 可變 | 無 producer、無 consumer | +| `### Blocking` 區塊 | Issue body(`idd-update` 維護)| 可變 | 有 consumer(#84)| +| Strategy `[~] 暫緩` | Diagnosis comment | 凍結 | 有 consumer,但屬 `idd-close` 的 close-time disposition | + +2026-08-10 實測:22-issue backlog、11 個 diagnosed,routing 判對 1 個、判錯 8 個。`#131` 與 `#200` 帶有使用者親自下的 defer 裁決,routing 仍建議執行。 + +實測另一項數據推翻了「限定詞與 label 資訊重複」的假設:11 個 issue 中兩者**一致的只有 5 個**。`#37` 是 bare `Spectra` 加 `parking-lot` label(人事後 park);`#131` 與 `#200` 是有限定詞、無 label(diagnose 判 parked 但無人貼 label);`#136` 的 comment 與 body 甚至彼此分岔。兩者不是同一資訊的兩種寫法,而是**兩個會分岔的訊號**。 + +約束:`rules/append-vs-modify.md` 規定 Diagnosis comment 是 append-only 審計軌跡。既有的 `### Conflict Class` 契約(openspec/specs/parallel-orchestration/spec.md)已示範了正確形狀 —— 封閉值域、absent 或 unparseable 時保守預設、且必須 surface —— 本設計以之為對照模型。 + +## Goals / Non-Goals + +**Goals:** + +- 讓 routing 能區分「diagnosed 且現在可動」與「diagnosed 但在等 trigger」 +- 把 parked 這個**會變的狀態**從不可變的 artifact 遷到可變的 metadata +- 消除三個 consumer 各自窄化 `### Complexity` 的分岔 +- 保留 #84 既有的 Blocked 分組輸出行為,不退化 +- 建立可被未來新欄位繼承的通則:被 routing 消費的欄位必須宣告封閉值域與 unparseable 契約 + +**Non-Goals:** + +- **不 parse diagnosis 散文抽取 trigger 條件**。trigger 是關於未來世界狀態的散文命題(「等 ≥3 instances」「首次 trace-stale 實害事故」),其成立與否需要人對世界的觀察,不在 repo 內。這是認識論邊界,非本設計的遺漏。 +- **不新增 `### Park Trigger` 結構化欄位**。 +- **不讓 producer 自動貼 `parking-lot` label**(理由見決策「parked label 維持人工裁決」)。 +- **不把 Strategy `[~] 暫緩` 納入 gate**(理由見決策「gate 採三訊號」)。 +- **不做 parked issue 的回訪/staleness 機制** —— 已獨立為 #310。本變更會把 parked 藏得更乾淨、放大該問題,但兩者範圍分離。 +- **不改寫既有 Diagnosis comment 的歷史內容**(理由見決策「migration 只貼 label」)。 +- **不退役 `SDD-warranted`** —— 它是 `Spectra` 的既有 backward-compat alias,留在封閉值域內。 + +## Decisions + +### Complexity 回歸純封閉值域,parked 遷出至 label + +`### Complexity` 的合法值僅四個 tier(`Simple` / `Plan` / `Spectra` / `SDD-warranted`),各自可帶既有的 ` via <來源>` 後綴慣例。parked 狀態改由 `parking-lot` label 單獨承載。 + +理由:根因不是 parser 太窄,而是**把會變的狀態存進不可變的 artifact**。Diagnosis comment 是 append-only,parked 卻會變(trigger 成立就該 unpark)。`#136` 的 comment 與 body 分岔,正是狀態被凍住後自行漂移的自然實驗。IDD 已經知道正確做法 —— `### Blocking` 正因為會變才放在 body 由 `idd-update` 維護。 + +替代方案:(a) 讓 `when triggered` 成為合法後綴,兩訊號並存 —— 但實測已證明兩者會分岔,並存就必須定義優先序,而該優先序沒有非任意的答案;(b) 只加寬三個 parser —— 會讓三個 consumer 一致地讀到一個會分岔的訊號,一致地錯比不一致地錯更難發現。 + +### gate 採三訊號,Strategy 暫緩標記排除在外 + +actionability gate 的輸入是三個訊號:`### Complexity` 非合法值、`parking-lot` label 存在、`### Blocking` 區塊非空。放行需三者皆不成立。Strategy 的 `[~] 暫緩` **不納入**。 + +理由:`[~]` 的既有 consumer 是 `idd-close` 的 checklist gate,語意是「close 時這個 checklist item 刻意跳過」—— 那是 per-item 的 close-time disposition,不是 per-issue 的「現在可不可以動」。把它拉進 routing gate 等於用回答 A 問題的訊號去回答 B 問題,且會與 `idd-close` 的既有語意衝突。 + +### unparseable 的保守處置為 not-actionable 並強制 surface + +`### Complexity` 值不在封閉值域內時,gate 判定 not-actionable,且**必須顯示原始值**供人判讀,絕不靜默截斷。完全缺少 `### Complexity` 區段時同樣 not-actionable,理由標為 missing。 + +理由:完全對稱於 `### Conflict Class` 的 `D_diagnose_first` 契約 —— 保守預設加強制 surface。既有 `idd-all` 的 `UNKNOWN` 安全網只覆蓋「regex 完全沒 match」,結構上接不住「match 到但值非法」,本決策把兩種失敗都納入。 + +替代方案:降級為 `Plan` —— 否決,`Plan` 仍是可動 tier,仍會把 parked issue 送進 `/idd-plan`;中斷整個 `idd-list` —— 否決,對 surfacing-only 工具過重,一筆壞資料會堵死全部輸出。 + +### 解析與判定抽為共用 helper + +`### Complexity` 的解析與 actionability 判定抽成單一 shell helper,四個 skill 引用同一份實作,不各自內嵌。 + +理由:`.claude/rules/deep-integration-over-hardcode.md` 的反複製判準 —— 同構機件兩處維護等於同一個 bug 要修多次,本 issue 正是該失敗模式的實例(三處實作、三種行為)。 + +### migration 只貼 label,不改寫歷史 + +既有 9 筆帶限定詞的 Diagnosis comment **維持原狀不改寫**。migration 的動作只有兩種:對應 issue 補上 `parking-lot` label(若缺),以及 `#128` 由人重新判斷。 + +理由:改寫既有 Diagnosis comment 的 `### Complexity` 是 modify-in-place 一個 append-only 審計 artifact,違反 `rules/append-vs-modify.md`。而且**不需要改寫** —— legacy 限定詞值在新契約下落入 unparseable 路徑,判定為 not-actionable 並顯示原值,對那 8 個 parked issue 而言正是正確結果。封閉值域約束的是**新產出的** diagnosis;歷史值由 unparseable 路徑正確承接。 + +替代方案:回填改寫 —— 否決,違反 append-only 且無必要;永久放寬值域容忍 legacy —— 否決,會讓封閉值域名存實亡。 + +### parked label 維持人工裁決,producer 不自動貼 + +`idd-diagnose` **不**自動貼 `parking-lot` label。 + +理由:限定詞是 diagnose 當下的 AI 判斷,label 是人的裁決,兩者語意不同。`#37` 是 bare `Spectra` 加 label —— diagnose 當時(2026-05-19)判的是可動,label 是 2026-08-10 由人 re-park 時貼上。若讓 producer 從限定詞推導 label,就等於宣告 parked 只能在 diagnose 當下決定,這條事後裁決路徑會消失。 + +### 顯示分兩組以保留 #84 既有輸出 + +gate 產出 verdict 加 reason 清單;顯示層依 reason 分兩組 —— reason 為 blocking 者維持 #84 既有的 Blocked 分組與其 banner、footer 計數逐字不變;reason 為 parking label 或 complexity 相關者進新的 Parked 分組。 + +理由:gate 統一不等於顯示統一。把兩者合併成單一分組會構成 #84 的輸出退化,而 #84 的 anti-anxiety surface 是使用者依賴的既有行為。分組拆分讓「統一判定」與「不退化」同時成立。 + +### parking 慣例收斂為 parking-lot + +`references/ic-r011-checkpoint.md` 中 `blocker:infeasible` 與 `blocker:waiting` 的敘述改為 `parking-lot`,或明記兩者分工。 + +理由:vocabulary drift —— 文件寫的兩個 label 目前各 0 個 issue 在用,實際在用的 `parking-lot` 有 6 個。該檔同時宣稱存在一個「periodic backlog grooming」機制去 grep 那兩個 label;該機制不存在且會掃到空集合(已獨立為 #310)。本變更只收斂 label 名稱,不實作 grooming。 + +## Implementation Contract + +**Behavior** — 跑 `/idd-list` 時,被 gate 判為 not-actionable 的 issue 不再出現在 Suggested next 的可動清單,改列於 Blocked 或 Parked 分組並附判定理由;`### Complexity` 值非法時,該值原文顯示於輸出中。`/idd-all`、`/idd-implement`、`/idd-plan` 拿到非法 Complexity 值時停止 routing 並回報原值,不再落入未定義行為。 + +**Interface** — 共用 helper 提供兩個函式: + +- `idd_parse_complexity`:輸入為 Diagnosis comment 全文,stdout 為 canonical tier(四個合法值之一)。exit 0 表示合法(bare tier 或帶 ` via <來源>` 後綴);exit 3 表示區段存在但值不在封閉值域,stderr 輸出 `unparseable-complexity: <原始值>`;exit 4 表示缺少 `### Complexity` 區段,stderr 輸出 `missing-complexity`。 +- `idd_actionability_verdict`:輸入為前一函式的 exit code、是否帶 `parking-lot` label、`### Blocking` 是否非空。stdout 為 `actionable`,或 `not-actionable: [; ...]`;exit 0 為 actionable、exit 1 為 not-actionable。 + +**Reason 值域**(封閉列舉,四個):`complexity-unparseable`、`complexity-missing`、`parking-lot-label`、`blocking-nonempty`。 + +**Failure modes** — 非法 Complexity 值一律 surface,絕不靜默截斷或降級為合法 tier。helper 本身不可用(檔案缺失)時,呼叫端 fail-loud 並指出缺失路徑,不 silent degrade 回舊行為。gate 不對「trigger 條件是否已成立」做任何判斷,該問題明確在範圍外。 + +**Acceptance criteria** + +- 新增測試以既有慣例落在 `plugins/issue-driven-dev/scripts/tests/actionability-gate/test.sh`,並登錄進 `plugins/issue-driven-dev/scripts/run-all-tests.sh`。 +- fixture 為靜態對照表,記錄 issue 號、Complexity 原始值、labels、期望 verdict,覆蓋 2026-08-10 快照的 9 筆 diagnosed 路由,斷言其中只有 `#37` 為 actionable。fixture 不查詢 live GitHub。 +- 三個代表性 legacy 值(`Simple when triggered`、`Spectra when triggered (parking lot)`、`#128` 的散文值)經 `idd_parse_complexity` 皆回 exit 3 並在 stderr 顯示原值。 +- 兩個既有的合法後綴值(`Plan via Layer V`、`Spectra via hard-gate (sdd_bias)`)經 `idd_parse_complexity` 回 exit 0 且 canonical tier 分別為 `Plan` 與 `Spectra`。 +- #84 既有行為回歸測試:帶非空 `### Blocking` 的 issue 仍列於 Blocked 分組,該分組標題、全 blocked banner 文案、footer 計數與變更前逐字相同。 + +**Scope boundaries** + +- 範圍內:Complexity 值域契約、三訊號 gate、共用 helper、四個 skill 的引用改寫、`ic-r011-checkpoint.md` 的 label 名稱收斂、測試與 fixture、9 筆 issue 的 label migration。 +- 範圍外:parked 回訪/staleness 機制(#310)、`--limit` 排序缺陷(#299)、trigger 條件的機械判定、`idd-close` 對 Strategy `[~]` 的既有處理、grooming 機制的實作。 + +## Risks / Trade-offs + +- **#84 行為退化** → 顯示層分兩組而非合併,Blocked 分組的標題、banner、footer 計數列入回歸測試逐字比對。 +- **四個 skill 改寫不同步,只修一處等於沒修** → 抽共用 helper,並在測試中對四個引用點各驗一次;只修 `idd-list` 會讓 `idd-all` 的未定義行為留存。 +- **與 #299 同檔衝突** → 兩者都修改 `plugins/issue-driven-dev/skills/idd-list/SKILL.md`,需序列化或合併為同一 PR;conflict class 已判為需序列化。 +- **legacy 值走 unparseable 路徑,verdict 對但 reason 不精確** → reason 會標為 `complexity-unparseable`(資料問題)而非 parked。緩解:surface 原始值,人看到 `Simple when triggered` 即可理解實情。這是不改寫歷史所付的已知代價。 +- **本變更讓 parked 藏得更乾淨,放大無回訪機制的問題** → 已獨立為 #310 並在 Non-Goals 明記;本變更不因此擴大範圍。 + +## Migration Plan + +1. helper 與新 reference 落地,四個 skill 改為引用共用實作。 +2. 測試與 fixture 落地並登錄進 test runner。 +3. 對 8 個既有 parked issue 補齊 `parking-lot` label(`#131`、`#200` 目前有限定詞但缺 label;其餘已有)。**不改寫任何 Diagnosis comment。** +4. `#128` 交由人重新判斷 —— 其 Complexity 值為散文(tier 後接未決 UX 軸的敘述),需決定該 issue 是 parked 或可動,再決定是否貼 label。 +5. `ic-r011-checkpoint.md` 的 label 名稱收斂。 + +Rollback:本變更為 skill 文件、helper script 與 label 的變更,無資料遷移。回退方式為 revert commit 加撕除步驟 3 補上的 label;既有 Diagnosis comment 全程未被修改,無不可逆狀態。 + +## Open Questions + +- `#128` 的正確處置需人判斷,migration 步驟 4 才能完成。其值為「tier 後接未決 UX 軸」的散文,無法機械判定該 issue 是 parked 還是可動。 +- `ic-r011-checkpoint.md` 的兩個 `blocker:*` label 是「退役」還是「與 parking-lot 分工」,需在該檔改寫時定案。目前 0 使用,傾向退役,但若原設計意圖是區分 infeasible 與 waiting 兩種 parked 成因,則應保留並明記與 `parking-lot` 的關係。 diff --git a/openspec/changes/add-actionability-gate/proposal.md b/openspec/changes/add-actionability-gate/proposal.md new file mode 100644 index 0000000..25d9859 --- /dev/null +++ b/openspec/changes/add-actionability-gate/proposal.md @@ -0,0 +1,54 @@ +## Why + +> **Re-scope note(2026-08-14)**:本 change 於 #298 仍 open 時提出。走完 discuss → propose、進入 apply 時發現 **#298 已由 PR #309 / #306 修掉並 close** —— 但修正**只落在 `idd-list`**,另三個 consumer(`idd-all` / `idd-implement` / `idd-plan`)未動,且 `idd-list` 自身 Step 5 仍文載會截斷的 regex,與新增的 Step 3.7 直接矛盾。本 change 遂 re-scope 為**承接剩餘缺口**,追蹤於 **#316**;已完成的共用 helper、canonical 契約與回歸測試(全新檔、與已 merge 內容零衝突)原樣保留。Migration 相關 task 因目標 issue 全數 close 而 moot。 + + +2026-08-10 對本 repo 真實的 22-issue backlog 跑 `/idd-list`,routing 把 **8 個 parked/deferred/blocked issue 判成「Actionable now」**(11 個 diagnosed 裡只有 1 個判對)。其中 #131 與 #200 帶有使用者 2026-07-07 親自下的 defer 裁決 —— 照 routing 執行等於自動推翻已記錄的人為決策。失敗是**靜默的**:輸出的表格語法正確、格式正常、沒有任何 warning。 + +`/idd-diagnose #298` 的 root cause:**`### Complexity` 的值域從未被定義為封閉列舉,也沒有「unparseable → 保守 + surface」契約** —— 而結構相同的姊妹欄位 `### Conflict Class` 兩者都有(見 openspec/specs/parallel-orchestration/spec.md)。producer 寫出 `Simple when triggered` 並未違反任何明文規則,於是三個 consumer 各自發明了互不相容的窄化方式:`idd-list` 靜默截斷成 `Simple`;`idd-all` 與 `idd-implement` 得到非法字串,既不匹配任何 dispatch row 也不是 `UNKNOWN`,落入未定義行為(既有的 `UNKNOWN` 安全網只在 regex 完全沒 match 時觸發,結構上接不住此案例)。 + +更深一層:**把會變的狀態存進不可變的 artifact**。`### Complexity` 活在 append-only 的 Diagnosis comment 裡,但 parked 是會變的狀態(trigger 一成立就該 unpark)。#136 的 comment 寫 bare `Spectra`、body 寫 `Spectra when triggered (parking lot)`,正是狀態被凍住後自己漂移出去的自然實驗。IDD 其實已經知道正確做法 —— `### Blocking` 正因為會變才放在 body 由 `idd-update` 維護。 + +## What Changes + +- **`### Complexity` 回歸純封閉值域** —— 合法值僅 `Simple` / `Plan` / `Spectra` / `SDD-warranted`,各自可帶既有的 ` via <來源>` 後綴慣例。`when triggered` 這類限定詞不再寫進此欄位。**BREAKING**:既有 9 筆帶限定詞的 diagnosis 值在新契約下為非法,需 migration。 +- **parked 狀態改由 `parking-lot` label 單獨承載** —— 人可貼可撕,成為唯一 source of truth。實測 11 個 issue 中限定詞與 label 只有 5 個一致,證明兩者不是重複而是會分岔的兩個訊號。 +- **新增 actionability gate** —— 三訊號 OR 判定,放行需三者皆不成立:`### Complexity` 非合法值、`parking-lot` label 存在、`### Blocking` 區塊非空。 +- **default-on-unparseable = not-actionable + surface** —— 對稱於 `### Conflict Class` 的 `D_diagnose_first` 契約:不放行、必須顯示原始值、絕不靜默。 +- **三個 consumer 的 Complexity 解析統一** —— `idd-list`、`idd-all`、`idd-implement`(含 `idd-plan` 的 tier 確認)改用共用 helper,消除各自窄化。 +- **`### Blocking` 抽取重構為 gate 的 input** —— #84 既有的 Blocked 分組輸出行為不得退化。 +- **`idd-diagnose` producer 端宣告封閉值域** —— 明訂限定詞不得寫入,parked 意圖改以 label 表達。 +- **既有 9 筆 diagnosis migration** —— 8 筆機械處理(剝限定詞、必要時補 label)、#128 需人重新判斷(其值為散文,非 tier 加限定詞結構)。 +- **`references/ic-r011-checkpoint.md` 的 parking 慣例收斂** —— 該檔宣稱的 `blocker:infeasible` 與 `blocker:waiting` 目前 0 個 issue 在用,實際在用的是 `parking-lot`(6 個)。 + +## Capabilities + +### New Capabilities + +- `actionability-gate`: 定義 `### Complexity` 的封閉值域、parked 狀態的歸屬(label 而非 comment)、三訊號 actionability gate 的判定規則,以及 unparseable 時的保守處置與強制 surface。 + +### Modified Capabilities + +(none) + +為何無 modified capability:硬閘與 Layer V 這兩份既有 spec 所產出的帶後綴 verdict(形如 tier 後接 " via " 再接來源)在新封閉值域下**仍為合法值**,其 requirement 不需修改;conflict-class 規範對 Complexity 欄位的正交性敘述同樣維持成立。 + +## Impact + +- Affected specs: 新增 `actionability-gate` +- Affected code: + - New: + - `plugins/issue-driven-dev/references/actionability-gate.md` + - `plugins/issue-driven-dev/scripts/lib/actionability.sh` + - `plugins/issue-driven-dev/scripts/tests/actionability-gate/test.sh` + - `plugins/issue-driven-dev/scripts/tests/actionability-gate/fixtures/parked-routing.json` + - Modified: + - `plugins/issue-driven-dev/skills/idd-list/SKILL.md` + - `plugins/issue-driven-dev/skills/idd-all/SKILL.md` + - `plugins/issue-driven-dev/skills/idd-implement/SKILL.md` + - `plugins/issue-driven-dev/skills/idd-plan/SKILL.md` + - `plugins/issue-driven-dev/skills/idd-diagnose/SKILL.md` + - `plugins/issue-driven-dev/references/ic-r011-checkpoint.md` + - Removed: (none) +- 同檔衝突:#299(`--limit` 先於排序生效)同樣修改 `plugins/issue-driven-dev/skills/idd-list/SKILL.md`,兩者需序列化或合併處理。 +- 配套 issue:#310(parked issue 無回訪機制)—— 本變更把 parked 藏得更乾淨,會放大該問題,但不在本次範圍內。 diff --git a/openspec/changes/add-actionability-gate/specs/actionability-gate/spec.md b/openspec/changes/add-actionability-gate/specs/actionability-gate/spec.md new file mode 100644 index 0000000..d45c621 --- /dev/null +++ b/openspec/changes/add-actionability-gate/specs/actionability-gate/spec.md @@ -0,0 +1,119 @@ +## ADDED Requirements + +### Requirement: Closed value domain for the Complexity field + +The `### Complexity` field emitted by `idd-diagnose` SHALL carry exactly one of four tier values: `Simple`, `Plan`, `Spectra`, or `SDD-warranted`. A tier value SHALL also be accepted when followed by the existing ` via ` provenance suffix, in which case the canonical tier SHALL be the text preceding the first ` via ` separator. Qualifiers that express deferral state, such as `when triggered` or `(parking lot)`, SHALL NOT be written into this field; deferral state belongs to the `parking-lot` label instead. Consumers SHALL treat any other value as outside the domain. + +#### Scenario: Bare tier is accepted + +- **WHEN** a Diagnosis comment contains a `### Complexity` section whose value is `Spectra` +- **THEN** the canonical tier resolves to `Spectra` +- **AND** the value is inside the closed domain + +#### Scenario: Provenance suffix is accepted and stripped + +- **WHEN** a Diagnosis comment contains a `### Complexity` section whose value is `Plan via Layer V` +- **THEN** the canonical tier resolves to `Plan` +- **AND** the value is inside the closed domain + +#### Scenario: Deferral qualifier is outside the domain + +- **WHEN** a Diagnosis comment contains a `### Complexity` section whose value is `Simple when triggered` +- **THEN** the value is reported as outside the closed domain +- **AND** the canonical tier is not resolved to `Simple` + +### Requirement: Actionability gate evaluates three signals disjunctively + +The system SHALL determine whether a diagnosed issue is actionable by evaluating exactly three signals: whether the `### Complexity` value is outside the closed domain or absent, whether the `parking-lot` label is present on the issue, and whether the `### Blocking` section of the issue body is non-empty. The issue SHALL be reported as actionable only when none of the three signals holds. The `- [~]` disposition marker inside a Diagnosis `### Strategy` checklist SHALL NOT be an input to this gate, because that marker is a close-time per-item disposition consumed by `idd-close` rather than a per-issue actionability signal. + +#### Scenario: All three signals clear + +- **WHEN** an issue has a `### Complexity` value inside the closed domain, carries no `parking-lot` label, and has an empty `### Blocking` section +- **THEN** the gate reports the issue as actionable + +#### Scenario: Parking label alone withholds the issue + +- **WHEN** an issue has a `### Complexity` value of `Spectra`, carries the `parking-lot` label, and has an empty `### Blocking` section +- **THEN** the gate reports the issue as not actionable +- **AND** the reported reason includes `parking-lot-label` + +#### Scenario: Strategy skip marker does not withhold the issue + +- **WHEN** an issue has all three gate signals clear and its Diagnosis `### Strategy` checklist contains a `- [~]` item +- **THEN** the gate reports the issue as actionable + +### Requirement: Conservative verdict and mandatory surfacing on non-domain Complexity + +When the `### Complexity` value is outside the closed domain, the system SHALL report the issue as not actionable and SHALL surface the original unmodified value to the operator. When the `### Complexity` section is absent entirely, the system SHALL report the issue as not actionable with a distinct reason. The system SHALL NOT silently truncate a non-domain value to a tier prefix, SHALL NOT downgrade it to any tier, and SHALL NOT abort the enclosing listing operation. + +#### Scenario: Non-domain value surfaces verbatim + +- **WHEN** the gate evaluates an issue whose `### Complexity` value is `Spectra when triggered (parking lot)` +- **THEN** the issue is reported as not actionable with reason `complexity-unparseable` +- **AND** the string `Spectra when triggered (parking lot)` appears in the operator-facing output + +#### Scenario: Missing section is distinguished from non-domain value + +- **WHEN** the gate evaluates a Diagnosis comment that contains no `### Complexity` section +- **THEN** the issue is reported as not actionable with reason `complexity-missing` + +#### Scenario: One bad value does not suppress other issues + +- **WHEN** a listing contains one issue with a non-domain `### Complexity` value and other issues with valid values +- **THEN** the listing reports every issue +- **AND** the listing operation does not abort + +### Requirement: Single shared implementation of parsing and verdict + +Complexity parsing and actionability verdict logic SHALL exist as one shared implementation. Every consumer that routes on `### Complexity` — `idd-list`, `idd-all`, `idd-implement`, and `idd-plan` — SHALL invoke that shared implementation rather than embedding its own parsing. The verdict reason vocabulary SHALL be the closed set `complexity-unparseable`, `complexity-missing`, `parking-lot-label`, `blocking-nonempty`. When the shared implementation is unavailable, a consumer SHALL fail loudly and name the missing path rather than degrade to a private parsing path. + +#### Scenario: All routing consumers agree on the same input + +- **WHEN** the same Diagnosis comment containing `Simple when triggered` is evaluated by each routing consumer +- **THEN** every consumer reports the value as outside the closed domain +- **AND** no consumer resolves a canonical tier from it + +#### Scenario: Missing helper fails loudly + +- **WHEN** a consumer invokes the shared implementation and the implementation file is absent +- **THEN** the consumer reports an error naming the missing path +- **AND** the consumer does not fall back to a private parsing path + +### Requirement: Blocked-state output is preserved as a distinct display group + +The gate SHALL produce a verdict together with its reason list, and the display layer SHALL group not-actionable issues by reason. Issues whose only reason is `blocking-nonempty` SHALL continue to appear under the existing blocked-state grouping, with its group heading, its all-blocked banner text, and its footer counts unchanged from the behavior established for blocked-state awareness. Issues whose reasons include `parking-lot-label`, `complexity-unparseable`, or `complexity-missing` SHALL appear under a separate parked grouping. + +#### Scenario: Blocking-only issue keeps existing grouping + +- **WHEN** an issue is not actionable with reason `blocking-nonempty` alone +- **THEN** the issue appears under the existing blocked-state group +- **AND** the group heading, banner text, and footer counts match the pre-change behavior + +#### Scenario: Parked issue appears in the parked group + +- **WHEN** an issue is not actionable with reason `parking-lot-label` +- **THEN** the issue appears under the parked group rather than the blocked-state group + +### Requirement: Parked label is authored by a human and never derived by the producer + +`idd-diagnose` SHALL NOT apply, remove, or derive the `parking-lot` label. The label SHALL remain a human-authored decision that is settable and removable after the diagnosis was written. + +#### Scenario: Diagnosis run leaves labels untouched + +- **WHEN** `idd-diagnose` completes and emits a Diagnosis comment +- **THEN** the issue's `parking-lot` label state is unchanged by that run + +#### Scenario: Human parks an issue whose tier is inside the domain + +- **WHEN** an issue carries a `### Complexity` value of `Spectra` and a human applies the `parking-lot` label afterwards +- **THEN** the gate reports the issue as not actionable with reason `parking-lot-label` + +### Requirement: Legacy Diagnosis values are handled without rewriting history + +Existing Diagnosis comments that carry deferral qualifiers in `### Complexity` SHALL NOT be rewritten to satisfy the closed value domain. The closed value domain SHALL constrain newly emitted diagnoses, and legacy values SHALL be carried by the non-domain path, which yields a not-actionable verdict with the original value surfaced. Migration SHALL be limited to applying the `parking-lot` label where it is absent. + +#### Scenario: Legacy value yields the correct verdict without edit + +- **WHEN** the gate evaluates an issue whose Diagnosis comment still reads `Simple when triggered` and which carries no `parking-lot` label +- **THEN** the issue is reported as not actionable +- **AND** the Diagnosis comment content is unchanged diff --git a/openspec/changes/add-actionability-gate/tasks.md b/openspec/changes/add-actionability-gate/tasks.md new file mode 100644 index 0000000..aa1f5b4 --- /dev/null +++ b/openspec/changes/add-actionability-gate/tasks.md @@ -0,0 +1,33 @@ +## 1. 共用 helper(TDD) + +- [x] 1.1 建立 fixture 與失敗測試(RED)。fixture 為靜態對照表,記錄 issue 號、`### Complexity` 原始值、labels、期望 verdict 與期望 reason,覆蓋 2026-08-10 快照的 9 筆 diagnosed 路由,並額外含兩個合法後綴值(`Plan via Layer V`、`Spectra via hard-gate (sdd_bias)`)與缺少 `### Complexity` 區段的案例。fixture 不查詢 live GitHub。行為契約:測試斷言 9 筆中只有 `#37` 為 actionable。驗證:執行 plugins/issue-driven-dev/scripts/tests/actionability-gate/test.sh 全部案例失敗且訊息指出 helper 尚未存在。檔案:`plugins/issue-driven-dev/scripts/tests/actionability-gate/fixtures/parked-routing.json`、`plugins/issue-driven-dev/scripts/tests/actionability-gate/test.sh` 涵蓋需求:Closed value domain for the Complexity field、Actionability gate evaluates three signals disjunctively、Conservative verdict and mandatory surfacing on non-domain Complexity。 + +- [x] 1.2 實作共用 helper 至測試通過(GREEN),落實決策「解析與判定抽為共用 helper」、「unparseable 的保守處置為 not-actionable 並強制 surface」與「gate 採三訊號,Strategy 暫緩標記排除在外」。行為契約:`idd_parse_complexity` 對 bare tier 與帶 ` via <來源>` 後綴回 exit 0 並輸出 canonical tier;對域外值回 exit 3 並於 stderr 輸出原值;對缺區段回 exit 4。`idd_actionability_verdict` 依三訊號回 `actionable` 或 `not-actionable: `,reason 限於四個封閉值。驗證:1.1 的測試全數轉綠。檔案:`plugins/issue-driven-dev/scripts/lib/actionability.sh` 涵蓋需求:Single shared implementation of parsing and verdict。 + +- [x] 1.3 確認新測試被 test runner 納入。行為契約:全套測試執行時涵蓋 actionability-gate 套件。**runner 以 glob 自動探索 `scripts/tests/*/test.sh`,無需手動登錄** —— 本項為驗證而非編輯,`run-all-tests.sh` 不應被修改。驗證:執行 plugins/issue-driven-dev/scripts/run-all-tests.sh 的輸出包含 actionability-gate 套件且回報通過,且該檔在本 change 中維持未修改。 + +## 2. 契約文件 + +- [x] 2.1 撰寫 actionability gate 的 canonical reference,內容涵蓋封閉值域、三訊號 gate、reason 封閉值域、unparseable 的保守處置與強制 surface,並明記與 `### Conflict Class` 契約的對稱關係。行為契約:四個 consumer skill 引用此檔而非各自敘述規則。驗證:內容審查確認四項規則皆有明文,且封閉性以「僅此四值、不得類推」形式書寫。檔案:`plugins/issue-driven-dev/references/actionability-gate.md` + +## 3. Consumer 改寫 + +- [x] 3.1 消除 `idd-list` 內部矛盾:Step 5 的 Complexity 解析改為引用共用 helper,落實決策「顯示分兩組以保留 #84 既有輸出」。**背景(2026-08-14 re-scope)**:#298 的修正已由 PR #309 merge 進 Step 3.7,但同檔 Step 5 仍文載會截斷的 regex `([A-Za-z-]+)` —— 與 Step 3.7 明文禁止的截斷直接衝突。本 task **不重做 Step 3.7 的行為**,只讓 Step 5 停止規定一個矛盾的解析。行為契約:Step 5 不再出現任何私有 Complexity regex,改為引用 `references/actionability-gate.md` 與共用 helper;Step 3.7 既有的 Blocked 分組輸出不變。驗證:grep 確認該截斷 regex 在 `skills/idd-list/SKILL.md` 內**不再有 prescriptive 用法**(僅得出現在 `>` rationale blockquote 內作為反例引用 —— 把失敗史留在文件裡是刻意的,否則後人會覺得這條規則囉嗦而改回去);且 `git diff` 確認 Step 3.7 段落零刪除行。檔案:`plugins/issue-driven-dev/skills/idd-list/SKILL.md` 涵蓋需求:Blocked-state output is preserved as a distinct display group。 + +- [x] 3.2 [P] 改寫 `idd-all` 使用共用 helper,補上域外值的 dispatch 處置。行為契約:Complexity 值域外時停止 routing 並回報原值,不再落入既有 dispatch table 無匹配 row 的未定義行為;既有的缺區段 abort 行為以 `complexity-missing` reason 表達。驗證:測試以 `Simple when triggered` 為輸入,斷言回報原值且未解析出任何 canonical tier。檔案:`plugins/issue-driven-dev/skills/idd-all/SKILL.md` + +- [x] 3.3 [P] 改寫 `idd-implement` 使用共用 helper。行為契約:Complexity 值域外時停止並回報原值,不再自行以字串切割推導 tier。驗證:測試斷言該 skill 的解析路徑呼叫共用 helper 且對域外值不產生 canonical tier。檔案:`plugins/issue-driven-dev/skills/idd-implement/SKILL.md` + +- [x] 3.4 [P] 改寫 `idd-plan` 的 tier 確認步驟使用共用 helper。行為契約:確認 Complexity 為 `Plan` 的步驟改以共用 helper 的 canonical tier 判定,域外值時停止並回報原值。驗證:測試斷言帶 ` via Layer V` 後綴的值仍被認定為 `Plan`,而域外值不被認定為任何 tier。檔案:`plugins/issue-driven-dev/skills/idd-plan/SKILL.md` + +## 4. Producer 與既有文件 + +- [x] 4.1 於 `idd-diagnose` 的 verdict 寫入段宣告封閉值域,並落實決策「Complexity 回歸純封閉值域,parked 遷出至 label」與「parked label 維持人工裁決,producer 不自動貼」。行為契約:該 skill 明文規定 `### Complexity` 僅得寫四個 tier(可帶 ` via <來源>` 後綴)、限定詞改以 `parking-lot` label 表達,且該 skill 不得貼除既有 type label 以外的 `parking-lot` label。驗證:內容審查確認封閉宣告與「producer 不貼 label」兩條皆成文;並確認既有的 Layer V 與硬閘出口所產生的後綴值在新宣告下仍為合法。檔案:`plugins/issue-driven-dev/skills/idd-diagnose/SKILL.md` 涵蓋需求:Parked label is authored by a human and never derived by the producer。 + +- [x] 4.2 [P] 收斂 parking 慣例敘述,落實決策「parking 慣例收斂為 parking-lot」。行為契約:該檔不再宣稱以 `blocker:infeasible` 或 `blocker:waiting` 進行 parking 標記,改為 `parking-lot`,或明記兩者分工;同時移除對不存在的 periodic backlog grooming 機制的宣稱,改為誠實敘述並指向 #310。驗證:逐行分類檔內每個 `blocker:*` 出現處,確認**全部位於 `>` blockquote**(歷史引用/收斂理由),無任何 prescriptive 用法;且 grooming 敘述與現實一致。檔案:`plugins/issue-driven-dev/references/ic-r011-checkpoint.md` + +## 5. Migration + +- [~] 5.1 對既有 parked issue 補齊 `parking-lot` label —— **moot(2026-08-14)**:migration 目標 `#131` / `#200` / `#128` 在 2026-08-10 之後全部被 close(backlog 已清,非 park)。決策「migration 只貼 label,不改寫歷史」仍然成立且已由 helper 的 unparseable 路徑承接 —— legacy 值判為 not-actionable 並 surface 原值,無需回填。驗證(2026-08-14 實測):載入共用 helper,對**當前全部 open issue** 逐一跑 `idd_parse_complexity` —— **域外值(exit 3)數量為 0**,需補 `parking-lot` label 的 open issue 數為 0。整個 migration 類別在 live backlog 中為空集合,非僅原列三筆已 close。同時這也是 helper 對真實資料的 end-to-end 驗證。 涵蓋需求:Legacy Diagnosis values are handled without rewriting history。 + +- [~] 5.2 由人重新判斷 `#128` 的處置 —— **moot(2026-08-14)**:`#128` 已 CLOSED,處置已由 backlog 清理決定,無待判事項。原內容:行為契約:`#128` 的 `### Complexity` 值為散文(tier 後接未決 UX 軸敘述),需由人決定該 issue 為 parked 或可動,並據以決定是否貼 `parking-lot` label。驗證:`#128` 的裁決結果以 `/idd-comment --type decision` 記錄於該 issue,且 label 狀態與裁決一致。 diff --git a/plugins/issue-driven-dev/references/actionability-gate.md b/plugins/issue-driven-dev/references/actionability-gate.md new file mode 100644 index 0000000..bd9e0fd --- /dev/null +++ b/plugins/issue-driven-dev/references/actionability-gate.md @@ -0,0 +1,152 @@ +# Actionability Gate + +> The contract that answers **「這個 issue 現在可不可以動?」** — the closed value domain of the `### Complexity` Diagnosis field, where deferral state lives, and the three-signal gate that `idd-list` / `idd-all` / `idd-implement` / `idd-plan` all consume. This file is the single source of truth; the four skills cite it and MUST NOT restate the rules in their own words. +> +> **Source**: `add-actionability-gate` Spectra change (capability `actionability-gate`). Origin: issue-driven-development#298, surfaced from real dogfooding (2026-08-10 backlog routing). + +## The incident this exists to prevent + +On 2026-08-10 a real 22-issue backlog was routed by `/idd-list`. Of the 11 diagnosed issues, **8 parked/deferred/blocked ones were reported as "Actionable now"**. Two of them (#131, #200) carried a defer ruling the user had personally made on 2026-07-07 — following the routing would have re-opened work the user had decided to park. + +The failure was **silent**. The table was syntactically correct, well-formatted, and carried no warning. + +## Root cause — mutable state in an immutable artifact + +`### Complexity` lives in a Diagnosis comment, and Diagnosis comments are **append-only** (see [`rules/append-vs-modify.md`](../rules/append-vs-modify.md)). But "is this issue parked?" is **mutable** — a trigger firing should un-park it. Writing deferral qualifiers (`Simple when triggered`, `Spectra when triggered (parking lot)`) into a frozen field created a value that could never be corrected in place. + +Two consequences followed: + +1. **The field carried two kinds of information** (tier + deferral) while every consumer's parser assumed one. Three consumers each invented an incompatible narrowing, and they disagreed: + + | Consumer | private narrowing | result on `Simple when triggered` | + |---|---|---| + | `idd-list` | `([A-Za-z-]+)` | silently truncated to `Simple` → routed a parked issue to `/idd-implement` | + | `idd-all` | `(.+?)` + via-split | non-tier string; matched no dispatch row **and** was not `UNKNOWN` | + | `idd-implement` | same | same | + + `idd-all`'s `UNKNOWN → abort` safety net structurally could not catch this: it fires only when the regex fails entirely, never when it matches an out-of-domain value. + +2. **The frozen state drifted elsewhere.** #136's Diagnosis comment read bare `Spectra` while its body read `Spectra when triggered (parking lot)` — state that cannot be corrected in place migrates to wherever it can be edited. + +IDD already knew the right shape: `### Blocking` is mutable, so it lives in the issue **body** and is maintained by `idd-update`. This contract applies the same reasoning to deferral. + +## Closed value domain — `### Complexity` + +**The legal values are exactly these four. This is a CLOSED enumeration — do NOT extend it by analogy, and do NOT infer a fifth value from resemblance to an existing one:** + +1. `Simple` +2. `Plan` +3. `Spectra` +4. `SDD-warranted` — legacy alias of `Spectra`, retained for backward compatibility + +A value MAY carry the provenance suffix ` via ` (established v2.50). The canonical tier is the text preceding the **first** ` via ` separator. Both existing producers of suffixed values remain legal: `Plan via Layer V` (Layer V escalation) and `Spectra via hard-gate (sdd_bias)` (hard-gate exit). + +**Deferral qualifiers SHALL NOT be written into this field.** `when triggered`, `(parking lot)`, and any prose describing why the issue is on hold belong to the `parking-lot` label, not here. A tier field that carries deferral state is the defect this contract closes. + +### Where deferral state lives instead + +| State | Home | Mutable? | Maintained by | +|---|---|---|---| +| Complexity tier | `### Complexity` in the Diagnosis comment | no (append-only) | `idd-diagnose` | +| Deferral / parked | `parking-lot` label | **yes** | **a human** — see below | +| External blocker | `### Blocking` in the issue body | yes | `idd-update` | + +**`idd-diagnose` SHALL NOT apply, remove, or derive the `parking-lot` label.** The label is a human ruling, and it is settable *after* the diagnosis was written. Empirically the two signals disagree: of 11 diagnosed issues sampled on 2026-08-10, only 5 had the qualifier and the label in agreement. #37 was bare `Spectra` with the label applied later by a human; #131 and #200 had the qualifier with no label. They are not two spellings of one fact — they are two facts, and deriving one from the other would delete the human's ability to park an issue whose tier is perfectly clear. + +## The three-signal gate + +An issue is **actionable** only when all three signals are clear. Any one of them withholds it. + +``` + ### Complexity outside the closed domain, or absent ─┐ + parking-lot label present ─┼─→ not actionable + ### Blocking section non-empty ─┘ + + actionable ⟺ none of the three holds +``` + +### Reason vocabulary — also a CLOSED enumeration + +**Exactly four values. Do NOT add a fifth by analogy:** + +| Reason | Fires when | +|---|---| +| `complexity-unparseable` | `### Complexity` section present, value outside the closed domain | +| `complexity-missing` | no `### Complexity` section at all | +| `parking-lot-label` | the issue carries the `parking-lot` label | +| `blocking-nonempty` | the `### Blocking` section of the body is non-empty | + +### What is deliberately NOT a signal + +**The `- [~]` disposition marker inside a Diagnosis `### Strategy` checklist is NOT an input to this gate.** It has an existing consumer — `idd-close`'s checklist gate, where it means "this checklist item was deliberately skipped at close time". That is a *per-item, close-time* disposition. This gate asks a *per-issue, routing-time* question. Feeding one into the other answers a different question than the one being asked, and would collide with `idd-close`'s established semantics. + +> ⚠ Anyone editing `- [~]` handling must check `idd-close` first. Treating it as unused because routing ignores it will break the close gate. + +## Default on absent or unparseable — conservative, and always surfaced + +A consumer parsing a `### Complexity` value outside the closed domain SHALL report the issue as **not actionable** and SHALL **surface the original unmodified value** to the operator. A missing section gets the same verdict under a distinct reason. + +Three things are forbidden: + +- **SHALL NOT** silently truncate a non-domain value to its tier prefix. That truncation is the 2026-08-10 incident. +- **SHALL NOT** downgrade a non-domain value to any tier, including `Plan`. `Plan` is still actionable; downgrading routes a parked issue into `/idd-plan`. +- **SHALL NOT** abort the enclosing listing operation. One bad value must not suppress the other issues — a surfacing tool that dies on one malformed row is worse than one that flags it. + +This mirrors the `### Conflict Class` contract in [`parallel-orchestration.md`](parallel-orchestration.md), which defaults an absent or unparseable value to `D_diagnose_first` and requires the fallback be printed. The two fields are orthogonal (one classifies physical resources touched, the other routing tier) but share one discipline: **conservative default plus mandatory surfacing, never silent.** + +## Display grouping — the gate is unified, the display is not + +The gate emits a verdict together with its reason list. The display layer groups by reason: + +| Reasons | Group | +|---|---| +| `blocking-nonempty` **alone** | the existing blocked-state group (#84) — heading, all-blocked banner text, and footer counts unchanged | +| anything else, including any mix | the parked group | + +Unifying the *judgment* does not mean unifying the *presentation*. #84's blocked-state surface is user-facing behavior people rely on; merging it into one undifferentiated bucket would be a regression dressed as a simplification. + +## Consumer contract + +The four routing consumers SHALL invoke the shared implementation at `scripts/lib/actionability.sh` and MUST NOT embed a private parse: + +```bash +. "$CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh" + +tier=$(idd_parse_complexity "$diagnosis_body"); cexit=$? +verdict=$(idd_actionability_verdict \ + --complexity-exit "$cexit" \ + --parking-label "$has_parking_lot_label" \ + --blocking-section "$blocking_section_nonempty") +``` + +| Function | stdout | exit | +|---|---|---| +| `idd_parse_complexity ` | canonical tier | `0` in domain · `3` out of domain (stderr: `unparseable-complexity: `) · `4` no section (stderr: `missing-complexity`) | +| `idd_actionability_verdict …` | `actionable` / `not-actionable: [; …]` | `0` actionable · `1` not actionable · `2` bad usage | +| `idd_actionability_group ` | `blocked` / `parked` | `0` | + +**Malformed invocation fails loud (exit 2), never defaults to actionable.** An unanswered signal treated as "clear" would re-open the exact hole this contract closes. + +**When the shared implementation is missing, a consumer SHALL fail loudly and name the path** — never fall back to a private parse. A silent fallback would restore the three-way divergence this file exists to prevent. + +## Adversary discipline (audit lenses) + +Per [`.claude/rules/attribute-assessment.md`](../../../.claude/rules/attribute-assessment.md), evaluate this interface through three lenses: + +| Lens | Risk | Mitigation | +|---|---|---| +| **Scoundrel** | Write `Simple via when triggered` so the via-split yields a legal tier and the issue passes the gate | The provenance suffix only affects the *tier* channel. Deferral is asserted through the label, which the gate reads independently — a scoundrel who wants the issue withheld cannot express that through Complexity anyway, and one who wants it actionable has simply declared it actionable, which is a claim the audit trail records under their name | +| **Lazy Developer** | Skip a signal argument and let the gate assume "clear" | Every argument is required and validated; missing or non-boolean input returns exit 2 with a named cause. The cheap path is not the unsafe path | +| **Confused Developer** | Answer "does this issue block others?" when asked "is this issue blocked?" | The flag is named `--blocking-section`, pointing at the artifact section being read rather than at a relationship. The axis is **what the `### Blocking` section contains**, never who blocks whom | + +## Out of scope + +- **Evaluating whether a trigger condition has fired.** Trigger conditions are prose propositions about future world state (「等 ≥3 instances」「首次 trace-stale 實害事故」). Deciding whether one has come true requires a human observing the world; it is not derivable from the repo. This gate knows only that *someone declared the issue parked*, never whether the parking is still warranted. That is an epistemic boundary, not a missing feature. +- **Bringing parked issues back into view.** Nothing here re-surfaces an issue whose trigger has fired — tracked separately as **#310**. This contract makes parked issues *more* thoroughly hidden, which makes that gap more urgent, not less. +- **`- [~]` handling** — belongs to `idd-close`, see above. + +## See also + +- [`parallel-orchestration.md`](parallel-orchestration.md) — the `### Conflict Class` contract this one mirrors; orthogonal field, same discipline +- [`rules/append-vs-modify.md`](../rules/append-vs-modify.md) — why a Diagnosis comment cannot hold mutable state +- **Why both enumerations above are written as closed lists with explicit no-analogy clauses** rather than as summarizing criteria: a criterion plus illustrative examples is two specifications that will not be updated together, and the criterion's literal reach eventually exceeds the set of cases its author had in mind. The divergence is silent — the prose still reads fine, it just answers a boundary question nobody agreed to. Naming the members and forbidding extension-by-resemblance is what makes a boundary auditable. (This mirrors a maintainer-side writing discipline that is not part of the plugin distribution, so no link is given here.) diff --git a/plugins/issue-driven-dev/references/ic-r011-checkpoint.md b/plugins/issue-driven-dev/references/ic-r011-checkpoint.md index 293bd3c..82775e7 100644 --- a/plugins/issue-driven-dev/references/ic-r011-checkpoint.md +++ b/plugins/issue-driven-dev/references/ic-r011-checkpoint.md @@ -68,11 +68,17 @@ When the user requests to skip one or more candidates, the skill SHALL present a | Category | Meaning | Action | |----------|---------|--------| | **(a) unactionable observation** | Pure observation with no actionable form (e.g. "AI hallucinates is a statistical fact") | Real skip — NO `gh issue create`. Audit: `Skipped: (a) unactionable observation` | -| **(b) infeasible but understood** | Technically infeasible at this point but understood (e.g. "Need 100x budget to reproduce") | **Still files** as P3 with `blocker:infeasible` label. Audit: `Skipped: (b) infeasible — filed as #NNN with blocker:infeasible label` | -| **(c) blocked on external state** | Waiting on external state that will likely change (e.g. "Wait for GitHub Actions API to add X") | **Still files** as P3 with `blocker:waiting` label. Audit: `Skipped: (c) blocked-on-external — filed as #NNN with blocker:waiting label` | +| **(b) infeasible but understood** | Technically infeasible at this point but understood (e.g. "Need 100x budget to reproduce") | **Still files** as P3 with the `parking-lot` label. Audit: `Skipped: (b) infeasible — filed as #NNN with parking-lot label` | +| **(c) blocked on external state** | Waiting on external state that will likely change (e.g. "Wait for GitHub Actions API to add X") | **Still files** as P3 with the `parking-lot` label. Audit: `Skipped: (c) blocked-on-external — filed as #NNN with parking-lot label` | **Net effect**: only (a) avoids filing. (b) and (c) preserve the parking lot. +> **Label 慣例已收斂為 `parking-lot`(#298 → #316,2026-08-14)**:(b) 與 (c) 原本各自規定貼 `blocker:infeasible` / `blocker:waiting`。實測本 repo:**這兩個 label 一次都沒有被建立過、0 個 issue 在用**,而且**沒有任何 consumer 讀它們** —— `/idd-list --parked` 與 actionability gate 讀的都是 `parking-lot`。一個被文件規定、卻無人貼也無人讀的 label,只會讓「已標記」的錯覺蓋過「沒被追蹤」的事實。 +> +> 兩類合併到同一個 label,**(b) / (c) 的區分保留在 audit 字串裡**(`Skipped: (b) infeasible — …` / `Skipped: (c) blocked-on-external — …`)。理由:那個區分是**說明性**的(為什麼擱著),不是**機械判定**用的 —— gate 只問「是否 parked」,成因由人讀 audit 行。用兩個 label 承載一個布林加一段說明,是把可讀的東西塞進不可讀的通道。 +> +> 完整值域契約見 [`actionability-gate.md`](actionability-gate.md)。 + > **關於「periodic backlog grooming」(#310,2026-08-14 更正)**:本段原本寫著 grooming「可以 grep `blocker:infeasible` / `blocker:waiting` 來回訪」。實測本 repo:**這兩個 label 一次都沒有被建立過**,而且**沒有任何 periodic grooming 機制存在** —— 沒有排程、沒有 CI、沒有任何 skill 會主動回頭看 parked issue。 > > 這件事重要,因為 parked issue 的 trigger 條件全是**關於未來世界狀態的散文命題**(「等 ≥3 instances」「首次 trace-stale 實害事故」)。這類條件成立時**不會發出事件** —— 沒有 webhook、沒有訊號、沒有任何東西會通知 repo。唯一能發現「trigger 已成立」的路徑是**人主動回頭讀**。 @@ -89,9 +95,9 @@ options: - label: "(a) unactionable observation" description: "Real skip — no issue created. Pure observation with no actionable form." - label: "(b) infeasible but understood" - description: "Still files as P3 with blocker:infeasible label. Parking lot — revisit when feasibility changes." + description: "Still files as P3 with the parking-lot label. Parked — revisit when feasibility changes." - label: "(c) blocked on external state" - description: "Still files as P3 with blocker:waiting label. Parking lot — revisit when external state changes." + description: "Still files as P3 with the parking-lot label. Parked — revisit when external state changes." ``` ### 1.6 Legacy 3-option ask (close-tier only + bypass paths) @@ -179,8 +185,8 @@ For each result of the checkpoint, write ONE of these lines (literal text matter |---------|---------------------------| | Default file path — N items filed | `Filed: #NNN, #MMM, #PPP` | | Skip (a) — unactionable | `Skipped: (a) unactionable observation` | -| Skip (b) — infeasible | `Skipped: (b) infeasible — filed as #NNN with blocker:infeasible label` | -| Skip (c) — blocked-on-external | `Skipped: (c) blocked-on-external — filed as #NNN with blocker:waiting label` | +| Skip (b) — infeasible | `Skipped: (b) infeasible — filed as #NNN with parking-lot label` | +| Skip (c) — blocked-on-external | `Skipped: (c) blocked-on-external — filed as #NNN with parking-lot label` | | Empty surface | `(none surfaced)` | | Env var bypass — user chose skip in reverted ask | `Skipped (AI_LOW_BAR_ISSUE_FILING=false — reverted to 3-option ask, user chose skip)` | | Unattended mode bypass | `Skipped (unattended mode + AI_LOW_BAR_ISSUE_FILING=false → implicit (a) skip)` | @@ -195,7 +201,7 @@ When a default-file invocation surfaces 5 candidates and user skips 2 with mixed ``` Filed: #N1, #N2, #N3 Skipped: (a) unactionable observation (candidate #4: "AI race condition is statistical") -Skipped: (c) blocked-on-external — filed as #N5 with blocker:waiting label (candidate #5: "wait for new GitHub API") +Skipped: (c) blocked-on-external — filed as #N5 with parking-lot label (candidate #5: "wait for new GitHub API") ``` Each filed / skipped item gets its own audit line. Multi-line audit block is preferred over compressed single-line for readability. diff --git a/plugins/issue-driven-dev/scripts/lib/actionability.sh b/plugins/issue-driven-dev/scripts/lib/actionability.sh new file mode 100644 index 0000000..c95e163 --- /dev/null +++ b/plugins/issue-driven-dev/scripts/lib/actionability.sh @@ -0,0 +1,155 @@ +#!/usr/bin/env bash +# actionability.sh — the single shared implementation of "can this issue be +# worked on right now?" (PsychQuant/issue-driven-development#298) +# +# Source this from a skill or test runner: +# . "$(dirname "${BASH_SOURCE[0]}")/actionability.sh" +# +# WHY THIS FILE EXISTS +# Before #298 there were three private narrowings of the `### Complexity` field +# and they disagreed. idd-list truncated `Simple when triggered` to `Simple` and +# routed a parked issue to /idd-implement; idd-all and idd-implement produced a +# non-tier string that matched no dispatch row and was not `UNKNOWN` either, so +# the existing abort net could not catch it. One implementation, four consumers, +# no private parsing — that is the whole point of this file. Do not re-inline a +# regex in a SKILL.md; extend here instead. +# +# CLOSED DOMAINS (both are closed — do NOT extend by analogy) +# tier : Simple | Plan | Spectra | SDD-warranted +# optionally followed by " via " (provenance suffix, v2.50+) +# reason : complexity-unparseable | complexity-missing +# parking-lot-label | blocking-nonempty +# +# Deferral state does NOT live in the tier field. It lives in the `parking-lot` +# label, because deferral is mutable (a trigger firing should un-park an issue) +# and a Diagnosis comment is append-only. Storing mutable state in an immutable +# artifact is the root cause #298 diagnosed; keeping tier pure is the fix. + +# ── contract 1: parse the Complexity field ─────────────────────────────────── +# +# idd_parse_complexity +# stdout : canonical tier, when the value is inside the closed domain +# exit 0 : in domain +# exit 3 : section present, value outside the domain +# → stderr: "unparseable-complexity: " +# exit 4 : no `### Complexity` section at all +# → stderr: "missing-complexity" +# +# The raw value is ALWAYS surfaced on the exit-3 path. Never truncate a +# non-domain value down to its tier prefix — that silent narrowing is the +# incident this file prevents. +idd_parse_complexity() { + local body="${1-}" + local raw + + # First non-blank line under the `### Complexity` heading. Anchored at line + # start so a mention inside prose ("the ### Complexity field") cannot match. + raw=$(printf '%s\n' "$body" | awk ' + /^###[[:space:]]+Complexity[[:space:]]*$/ { grab = 1; next } + grab && /^###[[:space:]]/ { exit } + grab && NF { print; exit } + ') + + if [ -z "$raw" ]; then + printf 'missing-complexity\n' >&2 + return 4 + fi + + # Strip markdown bold/italic/code decoration around the value. Diagnoses in + # the wild wrote `**Spectra**`; the decoration is presentation, not value. + local val="$raw" + val="${val#"${val%%[![:space:]]*}"}" # ltrim + val="${val%"${val##*[![:space:]]}"}" # rtrim + val="$(printf '%s' "$val" | sed -E 's/^[*`_]+//; s/[*`_]+$//')" + val="${val#"${val%%[![:space:]]*}"}" + val="${val%"${val##*[![:space:]]}"}" + + # Provenance suffix: everything from the first " via " onward is source + # attribution, not part of the tier. `Plan via Layer V` → `Plan`. + local tier="${val%% via *}" + tier="${tier%"${tier##*[![:space:]]}"}" + + case "$tier" in + Simple|Plan|Spectra|SDD-warranted) + printf '%s\n' "$tier" + return 0 + ;; + *) + # Surface the ORIGINAL line, not the decoration-stripped form — the + # operator needs to see exactly what the artifact says. + printf 'unparseable-complexity: %s\n' "$raw" >&2 + return 3 + ;; + esac +} + +# ── contract 2: the three-signal gate ──────────────────────────────────────── +# +# idd_actionability_verdict --complexity-exit N --parking-label yes|no --blocking-section yes|no +# stdout : "actionable" +# "not-actionable: [; ...]" +# exit 0 : actionable exit 1 : not actionable exit 2 : bad usage +# +# Pass requires ALL THREE signals clear. The `- [~]` Strategy skip marker is NOT +# an input: it is a close-time per-item disposition owned by idd-close, and +# reusing it here would answer a different question than the one being asked. +idd_actionability_verdict() { + local cexit="" label="" blocking="" + + while [ $# -gt 0 ]; do + case "$1" in + --complexity-exit) cexit="${2-}"; shift 2 ;; + --parking-label) label="${2-}"; shift 2 ;; + --blocking-section) blocking="${2-}"; shift 2 ;; + *) printf 'idd_actionability_verdict: unknown argument: %s\n' "$1" >&2; return 2 ;; + esac + done + + # Fail loud on missing or malformed input. A gate that silently treats an + # unanswered signal as "clear" would re-open the exact hole #298 closed — + # this is the Lazy Developer lens: the cheap path must not be the unsafe one. + case "$cexit" in + 0|3|4) ;; + *) printf 'idd_actionability_verdict: --complexity-exit must be 0, 3 or 4 (got: %s)\n' "${cexit:-}" >&2; return 2 ;; + esac + case "$label" in + yes|no) ;; + *) printf 'idd_actionability_verdict: --parking-label must be yes or no (got: %s)\n' "${label:-}" >&2; return 2 ;; + esac + case "$blocking" in + yes|no) ;; + *) printf 'idd_actionability_verdict: --blocking-section must be yes or no (got: %s)\n' "${blocking:-}" >&2; return 2 ;; + esac + + local reasons=() + [ "$cexit" = "3" ] && reasons+=("complexity-unparseable") + [ "$cexit" = "4" ] && reasons+=("complexity-missing") + [ "$label" = "yes" ] && reasons+=("parking-lot-label") + [ "$blocking" = "yes" ] && reasons+=("blocking-nonempty") + + if [ ${#reasons[@]} -eq 0 ]; then + printf 'actionable\n' + return 0 + fi + + local joined + joined=$(printf '%s; ' "${reasons[@]}") + printf 'not-actionable: %s\n' "${joined%; }" + return 1 +} + +# ── display helper: which group does a not-actionable issue belong to? ─────── +# +# idd_actionability_group +# stdout : "blocked" | "parked" +# +# Reason `blocking-nonempty` ALONE keeps the pre-#298 blocked-state grouping +# (#84) intact — its heading, banner and footer counts are user-facing behavior +# that must not regress. Everything else lands in the parked group. +idd_actionability_group() { + local reasons="${1-}" + case "$reasons" in + "blocking-nonempty") printf 'blocked\n' ;; + *) printf 'parked\n' ;; + esac +} diff --git a/plugins/issue-driven-dev/scripts/tests/actionability-gate/fixtures/parked-routing.json b/plugins/issue-driven-dev/scripts/tests/actionability-gate/fixtures/parked-routing.json new file mode 100644 index 0000000..7f0ad66 --- /dev/null +++ b/plugins/issue-driven-dev/scripts/tests/actionability-gate/fixtures/parked-routing.json @@ -0,0 +1,229 @@ +[ + { + "number": 37, + "snapshot": true, + "note": "2026-08-10 snapshot, taken at the moment of the misroute (morning). Bare tier, no parking-lot label yet — this is the one issue the routing judged correctly that day. The label was applied later the same day when the issue was re-parked; recording that later state here would destroy the fixture's value as a regression of the actual incident.", + "complexity_raw": "Spectra", + "labels": [ + "enhancement" + ], + "expect_parse_exit": 0, + "expect_tier": "Spectra", + "expect_verdict": "actionable", + "expect_reasons": [] + }, + { + "number": 131, + "snapshot": true, + "note": "2026-08-10 snapshot. Deferral qualifier present, label absent — the exact shape that produced the silent misroute.", + "complexity_raw": "Simple when triggered", + "labels": [ + "enhancement" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable" + ] + }, + { + "number": 146, + "snapshot": true, + "note": "2026-08-10 snapshot. Qualifier and label agree.", + "complexity_raw": "Simple when triggered (parking lot)", + "labels": [ + "enhancement", + "parking-lot" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable", + "parking-lot-label" + ] + }, + { + "number": 157, + "snapshot": true, + "note": "2026-08-10 snapshot. Plan tier with deferral qualifier.", + "complexity_raw": "Plan when triggered", + "labels": [ + "enhancement", + "parking-lot" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable", + "parking-lot-label" + ] + }, + { + "number": 128, + "snapshot": true, + "note": "2026-08-10 snapshot. Free prose, not tier-plus-qualifier. Migration task 5.2 needs a human ruling on this one; the gate only has to withhold it.", + "complexity_raw": "Plan(觸發表)+ 未決 UX 軸 → **移入 discussion list**", + "labels": [ + "enhancement" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable" + ] + }, + { + "number": 200, + "snapshot": true, + "note": "2026-08-10 snapshot. Qualifier present, label absent — carries a user defer ruling dated 2026-07-07.", + "complexity_raw": "Spectra when triggered", + "labels": [ + "enhancement" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable" + ] + }, + { + "number": 143, + "snapshot": true, + "note": "2026-08-10 snapshot.", + "complexity_raw": "Spectra when triggered (parking lot)", + "labels": [ + "enhancement", + "parking-lot" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable", + "parking-lot-label" + ] + }, + { + "number": 144, + "snapshot": true, + "note": "2026-08-10 snapshot.", + "complexity_raw": "Spectra when triggered (parking lot)", + "labels": [ + "enhancement", + "parking-lot" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable", + "parking-lot-label" + ] + }, + { + "number": 145, + "snapshot": true, + "note": "2026-08-10 snapshot.", + "complexity_raw": "Spectra when triggered (parking lot)", + "labels": [ + "enhancement", + "parking-lot" + ], + "expect_parse_exit": 3, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-unparseable", + "parking-lot-label" + ] + }, + { + "number": 900, + "snapshot": false, + "note": "Synthetic. Provenance suffix from Layer V — inside the closed domain, nothing else withholds it.", + "complexity_raw": "Plan via Layer V", + "labels": [ + "enhancement" + ], + "expect_parse_exit": 0, + "expect_tier": "Plan", + "expect_verdict": "actionable", + "expect_reasons": [] + }, + { + "number": 901, + "snapshot": false, + "note": "Synthetic. Provenance suffix from the hard gate, including parentheses in the source token.", + "complexity_raw": "Spectra via hard-gate (sdd_bias)", + "labels": [ + "enhancement" + ], + "expect_parse_exit": 0, + "expect_tier": "Spectra", + "expect_verdict": "actionable", + "expect_reasons": [] + }, + { + "number": 902, + "snapshot": false, + "note": "Synthetic. No ### Complexity section at all — distinct reason from a non-domain value.", + "complexity_raw": null, + "labels": [ + "enhancement" + ], + "expect_parse_exit": 4, + "expect_tier": null, + "expect_verdict": "not-actionable", + "expect_reasons": [ + "complexity-missing" + ] + }, + { + "number": 903, + "snapshot": false, + "note": "Synthetic. Legacy alias inside the closed domain.", + "complexity_raw": "SDD-warranted", + "labels": [ + "enhancement" + ], + "expect_parse_exit": 0, + "expect_tier": "SDD-warranted", + "expect_verdict": "actionable", + "expect_reasons": [] + }, + { + "number": 904, + "snapshot": false, + "note": "Synthetic. Blocking section non-empty is the third gate signal; it must land in the blocked group, not the parked group.", + "complexity_raw": "Spectra", + "labels": [ + "enhancement" + ], + "blocking": "- 等 /spectra-discuss 對齊 acceptance metric proxy", + "expect_parse_exit": 0, + "expect_tier": "Spectra", + "expect_verdict": "not-actionable", + "expect_reasons": [ + "blocking-nonempty" + ] + }, + { + "number": 905, + "snapshot": false, + "note": "Synthetic. A Strategy checklist skip marker MUST NOT withhold the issue — that marker belongs to idd-close, not to routing.", + "complexity_raw": "Simple", + "labels": [ + "enhancement" + ], + "strategy_has_skip_marker": true, + "expect_parse_exit": 0, + "expect_tier": "Simple", + "expect_verdict": "actionable", + "expect_reasons": [] + } +] diff --git a/plugins/issue-driven-dev/scripts/tests/actionability-gate/test.sh b/plugins/issue-driven-dev/scripts/tests/actionability-gate/test.sh new file mode 100755 index 0000000..f9f0bf9 --- /dev/null +++ b/plugins/issue-driven-dev/scripts/tests/actionability-gate/test.sh @@ -0,0 +1,158 @@ +#!/usr/bin/env bash +# Test: the actionability gate — does routing know whether an issue can be worked +# on right now? (PsychQuant/issue-driven-development#298) +# +# The incident this fixture reproduces: on 2026-08-10 a real 22-issue backlog was +# routed by /idd-list. Of the 11 diagnosed issues, 8 parked/deferred/blocked ones +# were reported as "Actionable now". Two of them (#131, #200) carried a defer +# ruling the user had personally made on 2026-07-07. The failure was silent — the +# table looked syntactically correct and carried no warning. +# +# Root cause: `### Complexity` never declared a closed value domain and had no +# unparseable contract, so a producer could legally write `Simple when triggered` +# and three consumers each invented an incompatible narrowing: +# - idd-list `([A-Za-z-]+)` → silently truncated to `Simple` +# - idd-all `(.+?)` + via-split → non-tier string, matched no dispatch row +# - idd-implement same → same +# idd-all's `UNKNOWN → abort` net does NOT catch this: it only fires when the +# regex fails entirely, not when it matches an out-of-domain value. +# +# TWO CONTRACTS under test (design.md → Implementation Contract): +# idd_parse_complexity +# stdout : canonical tier — Simple | Plan | Spectra | SDD-warranted +# exit 0 : in domain (bare tier, or tier + " via ") +# exit 3 : section present, value out of domain → stderr names the raw value +# exit 4 : no `### Complexity` section at all +# idd_actionability_verdict --complexity-exit N --parking-label yes|no --blocking-section yes|no +# stdout : "actionable" | "not-actionable: [; ...]" +# exit 0 : actionable exit 1 : not actionable +# reason vocabulary is CLOSED (four values): +# complexity-unparseable | complexity-missing | parking-lot-label | blocking-nonempty +# +# The `- [~]` Strategy skip marker is deliberately NOT a gate input — it is a +# close-time per-item disposition owned by idd-close. Row 905 pins that. +# +# Usage: bash test.sh (exit 0 = pass, 1 = fail) + +set -u + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIBDIR="$(cd "$HERE/../../lib" && pwd)" +. "$LIBDIR/assert-helpers.sh" + +LIB="$LIBDIR/actionability.sh" +FIXTURE="$HERE/fixtures/parked-routing.json" + +command -v jq >/dev/null 2>&1 || { echo "jq is required for this suite" >&2; exit 1; } +[ -f "$FIXTURE" ] || { echo "fixture missing: $FIXTURE" >&2; exit 1; } + +HELPER_PRESENT=0 +if [ -f "$LIB" ]; then + # shellcheck disable=SC1090 + . "$LIB" + HELPER_PRESENT=1 +fi + +# Build a Diagnosis comment body from a fixture row's complexity value. +# `null` means the row has no `### Complexity` section at all. +synth_body() { # raw_or_NULL + if [ "$1" = "__NULL__" ]; then + printf '## Diagnosis\n\n### Type\n\nbug\n\n### Risks\n\n- none\n' + else + printf '## Diagnosis\n\n### Type\n\nbug\n\n### Complexity\n\n%s\n\n### Risks\n\n- none\n' "$1" + fi +} + +missing_helper_note="helper not implemented yet: $LIB" + +ACTIONABLE_SNAPSHOT=() + +ROWS=$(jq -c '.[]' "$FIXTURE") +while IFS= read -r row; do + num=$(jq -r '.number' <<<"$row") + snapshot=$(jq -r '.snapshot' <<<"$row") + raw=$(jq -r 'if .complexity_raw == null then "__NULL__" else .complexity_raw end' <<<"$row") + exp_exit=$(jq -r '.expect_parse_exit' <<<"$row") + exp_tier=$(jq -r 'if .expect_tier == null then "" else .expect_tier end' <<<"$row") + exp_verdict=$(jq -r '.expect_verdict' <<<"$row") + exp_reasons=$(jq -r '.expect_reasons | sort | join(";")' <<<"$row") + has_label=$(jq -r 'if (.labels | index("parking-lot")) then "yes" else "no" end' <<<"$row") + has_blocking=$(jq -r 'if (.blocking // "") == "" then "no" else "yes" end' <<<"$row") + + body=$(synth_body "$raw") + + if [ "$HELPER_PRESENT" -eq 0 ]; then + fail "#$num parse exit" "$missing_helper_note" + fail "#$num verdict" "$missing_helper_note" + fail "#$num reasons" "$missing_helper_note" + [ "$exp_verdict" = "actionable" ] && [ "$snapshot" = "true" ] && ACTIONABLE_SNAPSHOT+=("$num") + continue + fi + + # ── contract 1: parse ── + tier=$(idd_parse_complexity "$body" 2>/dev/null); act_exit=$? + assert_eq "#$num parse exit" "$exp_exit" "$act_exit" + + if [ "$exp_exit" = "0" ]; then + assert_eq "#$num canonical tier" "$exp_tier" "$tier" + else + # out-of-domain / missing MUST surface the raw value on stderr, never a tier + err=$(idd_parse_complexity "$body" 2>&1 >/dev/null) + if [ "$raw" = "__NULL__" ]; then + assert_grep "#$num stderr names missing section" "missing-complexity" "$err" + else + assert_grep "#$num stderr surfaces raw value" "$raw" "$err" + fi + fi + + # ── contract 2: verdict ── + out=$(idd_actionability_verdict \ + --complexity-exit "$act_exit" \ + --parking-label "$has_label" \ + --blocking-section "$has_blocking" 2>/dev/null) + verdict="${out%%:*}" + verdict="${verdict// /}" + assert_eq "#$num verdict" "$exp_verdict" "$verdict" + + if [ "$exp_verdict" = "not-actionable" ]; then + got_reasons=$(printf '%s' "${out#*: }" | tr ';' '\n' | sed 's/^ *//; s/ *$//' | sort | paste -sd';' -) + assert_eq "#$num reasons" "$exp_reasons" "$got_reasons" + fi + + [ "$exp_verdict" = "actionable" ] && [ "$snapshot" = "true" ] && ACTIONABLE_SNAPSHOT+=("$num") +done <<<"$ROWS" + +# ── audit discipline: the cheap path must not be the unsafe one ────────────── +# A gate that treated an unanswered signal as "clear" would re-open the hole +# #298 closed, so every malformed invocation fails loud (exit 2) instead of +# defaulting to actionable. +if [ "$HELPER_PRESENT" -eq 1 ]; then + idd_actionability_verdict --complexity-exit 0 --parking-label no 2>/dev/null + assert_exit "missing --blocking-section fails loud" "2" "$?" + + idd_actionability_verdict --complexity-exit "" --parking-label no --blocking-section no 2>/dev/null + assert_exit "empty --complexity-exit fails loud" "2" "$?" + + idd_actionability_verdict --complexity-exit 0 --parking-label maybe --blocking-section no 2>/dev/null + assert_exit "non-boolean --parking-label fails loud" "2" "$?" + + idd_actionability_verdict --complexity-exit 9 --parking-label no --blocking-section no 2>/dev/null + assert_exit "out-of-range --complexity-exit fails loud" "2" "$?" + + idd_actionability_verdict --bogus-flag yes 2>/dev/null + assert_exit "unknown flag fails loud" "2" "$?" + + # display grouping: blocking-only keeps the #84 blocked group; anything else parks + assert_eq "blocking-only → blocked group" "blocked" "$(idd_actionability_group 'blocking-nonempty')" + assert_eq "parking label → parked group" "parked" "$(idd_actionability_group 'parking-lot-label')" + assert_eq "unparseable → parked group" "parked" "$(idd_actionability_group 'complexity-unparseable')" + assert_eq "mixed reasons → parked group" "parked" "$(idd_actionability_group 'complexity-unparseable; blocking-nonempty')" +fi + +# ── acceptance criterion (design.md): among the nine 2026-08-10 snapshot rows, +# exactly #37 is actionable. This is the regression of the actual incident. ── +snap_count=$(jq '[.[] | select(.snapshot)] | length' "$FIXTURE") +assert_eq "fixture carries the 9 snapshot rows" "9" "$snap_count" +assert_eq "only #37 actionable among snapshot" "37" "$(printf '%s' "${ACTIONABLE_SNAPSHOT[*]:-}")" + +print_summary "actionability-gate" diff --git a/plugins/issue-driven-dev/skills/idd-all/SKILL.md b/plugins/issue-driven-dev/skills/idd-all/SKILL.md index f59601f..3cfe4c9 100644 --- a/plugins/issue-driven-dev/skills/idd-all/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-all/SKILL.md @@ -511,32 +511,40 @@ idd-all 必須把 `--cwd "$CWD"` 傳給 idd-diagnose,否則 sub-skill 會在 Cla Skill(skill="issue-driven-dev:idd-diagnose", args="#$N --cwd $CWD") ``` -**讀回 complexity**:idd-diagnose 結束後 fetch issue comments,grep 最新 `## Diagnosis` 區塊的 `### Complexity` 欄位: +**讀回 complexity**:idd-diagnose 結束後 fetch issue comments,取最新 `## Diagnosis` 區塊的 body,**值域判定不在此處自行寫 regex**,改呼叫 [`references/actionability-gate.md`](../../references/actionability-gate.md) 契約下的共用實作: ```bash -COMPLEXITY=$(gh issue view "$N" --json comments \ +LATEST_DIAGNOSIS=$(gh issue view "$N" --json comments \ | python3 -c " import json, sys, re d = json.load(sys.stdin) diagnosis_comments = [c for c in d['comments'] if re.search(r'(?m)^## Diagnosis', c['body'])] # v2.68.0+ #59 — line-anchored regex avoids quoted/inline false-positives (mirrors check-diagnosis-readiness.sh) -if not diagnosis_comments: - print('UNKNOWN'); exit(0) -latest = diagnosis_comments[-1]['body'] -m = re.search(r'### Complexity\n(.+?)\n', latest) -print(m.group(1).strip() if m else 'UNKNOWN') +print(diagnosis_comments[-1]['body'] if diagnosis_comments else '') ") + +# 缺 helper 一律 fail loud + 指名 path,禁止 fallback 到私有 regex(契約 §Consumer contract) +. "$CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh" || { + echo "FATAL: missing $CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh — 不得改用私有 regex" >&2 + exit 1 +} + +TIER=$(idd_parse_complexity "$LATEST_DIAGNOSIS" 2>/dev/null); CEXIT=$? +COMPLEXITY_ERR=$(idd_parse_complexity "$LATEST_DIAGNOSIS" 2>&1 >/dev/null) # cexit≠0 時的 `unparseable-complexity: ` / `missing-complexity` ``` -| Complexity 值 | 下一步 | +Dispatch 以 `(CEXIT, TIER)` 為鍵,**四列 exit 0 的 tier 是封閉值域,不得依相似性外推第五個**: + +| `CEXIT` · `TIER` | 下一步 | |--------------|--------| -| `Simple` | Phase 3a: idd-implement | -| `Plan` | **attended → Phase 3p: `/idd-plan`**(該 skill 擁有 `EnterPlanMode` 閘門,approve 後自己 chain 到 idd-implement);**unattended → Phase 3a: idd-implement**,並在 final report 標記 `[Plan tier deliberation skipped under unattended mode]` | -| `Plan via Layer V` (v2.50+) | 視同 `Plan` 處理 — verdict 是 user 在 idd-diagnose Step 3.4 選 escalate 觸發,routing 行為跟 bare `Plan` 一致 | -| `Spectra` | Phase 3b: spectra-discuss → spectra-propose → spectra-apply(unattended → 一輪收斂;attended → multi-turn 對話自然進行) | -| `SDD-warranted` (legacy alias) | 視同 `Spectra` 處理(v2.36.0+ backward compat) | -| `UNKNOWN` | **abort** — diagnose 沒判定 complexity,user 需手動釐清 | +| `0` · `Simple` | Phase 3a: idd-implement | +| `0` · `Plan` | **attended → Phase 3p: `/idd-plan`**(該 skill 擁有 `EnterPlanMode` 閘門,approve 後自己 chain 到 idd-implement);**unattended → Phase 3a: idd-implement**,並在 final report 標記 `[Plan tier deliberation skipped under unattended mode]` | +| `0` · `Plan`(原值 `Plan via Layer V`,v2.50+)| 同上 — helper 已剝除 ` via <來源>` 後綴,verdict 是 user 在 idd-diagnose Step 3.4 選 escalate 觸發,routing 行為跟 bare `Plan` 一致 | +| `0` · `Spectra` | Phase 3b: spectra-discuss → spectra-propose → spectra-apply(unattended → 一輪收斂;attended → multi-turn 對話自然進行) | +| `0` · `SDD-warranted` (legacy alias) | 視同 `Spectra` 處理(v2.36.0+ backward compat) | +| `4` — 無 `### Complexity` 區段(含完全沒有 `## Diagnosis` comment)| **abort** — diagnose 沒判定 complexity,user 需手動釐清(即舊表的 `UNKNOWN` 列,語意不變)| +| `3` — 值落在封閉值域外(如 `Simple when triggered`)| **abort** — 印出 `$COMPLEXITY_ERR` 的 `unparseable-complexity: ` **原值**,要求 user 修正 Diagnosis,或把延期狀態改掛 `parking-lot` label。**禁止**截斷成 tier 前綴、**禁止**降級成 `Plan` 或任何其他 tier | -**Parser 對 `Plan via Layer V` 的處理**(v2.50+):上面 grep 抓 `### Complexity\n(.+?)\n` 會抓到整行 `Plan via Layer V`,在 routing dispatch 時必須提取 canonical tier。實作:`canonical_tier = COMPLEXITY.split(' via ')[0].strip()`,得 `Plan`,routing 同 bare `Plan`。Backward compat:bare `Plan` / `Simple` / `Spectra` / `SDD-warranted` 都不含 ` via `,split 後仍是原值。 +> **為何不在此處寫 regex(#298 → #316)**:本段原本就地用一條 `(.+?)` 窄化抓 `### Complexity` 標題下的整行,再自行 `split(' via ')` 取 canonical tier。那條 regex 對 `Simple when triggered` 這類**帶延期修飾語**的值會**匹配成功**,回傳一個非 tier 字串——它對不上任何 dispatch 列,卻也不是 `UNKNOWN`。舊表的 `UNKNOWN → abort` 安全網結構上接不住它:`UNKNOWN` 只在 regex **完全匹配失敗**時才產生(`if m else` 分支),值域外的**成功**匹配永遠落不進那一格,routing 因此進入未定義行為。修法不是把 regex 寫得更嚴——那只會讓第四份私有窄化加入既有的三方分歧——而是讓值域判定只剩一份實作:` via <來源>` 後綴剝除、封閉值域檢查、原值 surface 全在 `scripts/lib/actionability.sh`,本 skill 只讀它的 exit code。「不得截斷、不得降級、不得靜默」的規定見 [`references/actionability-gate.md`](../../references/actionability-gate.md)。 > **Layer V under (PR, unattended) — v2.50+**: Layer V Vagueness Pre-check (idd-diagnose Step 3.4) 在 unattended 仍評分 + 寫 audit trail,但 trigger 時自動 apply `proceed anyway` 不跳 AskUserQuestion。final report 應 surface `idd-diagnose` audit trail 中含 `[Layer V: V1=N V4=M, clarify-default skipped under unattended mode, defaulting to proceed]` 的 issue,讓 user 後續可以手動重 route。 > @@ -1015,7 +1023,8 @@ fi | gh auth 沒設定 | Phase 0.3 abort,提示 gh auth login | | Issue #N 不存在 / CLOSED | Phase 0 abort | | Branch 已存在 | Phase 0 AskUserQuestion(checkout / -2 suffix / abort) | -| Diagnose 判定 UNKNOWN complexity | Phase 2 abort,提示手動跑 idd-diagnose | +| Diagnosis 缺 `### Complexity` 區段(helper cexit=4) | Phase 2 abort,提示手動跑 idd-diagnose | +| `### Complexity` 值落在封閉值域外(helper cexit=3) | Phase 2 abort,印出 `unparseable-complexity: ` 原值;不截斷、不降級成任何 tier | | spectra-discuss 沒 emit `Conclusion:` line(unattended hint 失敗)| Re-prompt 一次;再失敗 abort,branch 保留 | | spectra-propose 沒 emit `Change:` line | 同上 | | spectra-propose 遇到 unrecoverable validation error | Phase 3b abort,artifacts 保留,提示手動 `/spectra-propose` | diff --git a/plugins/issue-driven-dev/skills/idd-diagnose/SKILL.md b/plugins/issue-driven-dev/skills/idd-diagnose/SKILL.md index a0c9bc5..9979ddd 100644 --- a/plugins/issue-driven-dev/skills/idd-diagnose/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-diagnose/SKILL.md @@ -601,13 +601,30 @@ Diagnosis 完成 + Step 3.4 Vagueness Pre-check 結束後(`type=meeting` 已 ``` ### Complexity -{Simple / Plan / Spectra} +{Simple / Plan / Spectra / SDD-warranted} {對 Simple:列出哪個 Layer 1 命中、或 Layer 2/P 都沒命中的說明} {對 Plan:列出觸發的 Layer P 信號} {對 Spectra:列出 Layer 2 + Layer 3 觸發項} ``` +##### 值域是封閉的(#298 → #316) + +**`### Complexity` 的第一個非空行只得是下列四個值之一 —— 這是封閉列舉,不得依相似性類推出第五個值:** + +1. `Simple` +2. `Plan` +3. `Spectra` +4. `SDD-warranted`(`Spectra` 的既有 backward-compat alias) + +該值**得**後接 ` via <來源>` provenance 後綴(v2.50 起的既有慣例)—— `Plan via Layer V`(Layer V escalate)與 `Spectra via hard-gate (sdd_bias)`(硬閘出口)皆為合法。canonical tier 是第一個 ` via ` 之前的文字。 + +**延期修飾語 SHALL NOT 寫進這個欄位。** `when triggered`、`(parking lot)`、`(deferred pending #N)`、以及任何說明「為什麼先擱著」的散文,都屬於 `parking-lot` label,不屬於這裡。理由是生命週期不同:Diagnosis comment 是 **append-only** 審計軌跡(見 [`rules/append-vs-modify.md`](../../rules/append-vs-modify.md)),而「是否 parked」**會變**(trigger 成立就該解除)。把可變狀態凍進不可變的 artifact,正是 #298 診斷出的根因 —— 實例:`#136` 的 comment 寫 bare `Spectra`、body 卻寫 `Spectra when triggered (parking lot)`,狀態改不動就自己漂到別處去了。 + +**本 skill SHALL NOT 貼、移除或推導 `parking-lot` label。** 該 label 是**人的裁決**,且可以在 diagnosis 寫完**之後**才下。實測 2026-08-10 的 11 筆 diagnosed issue,限定詞與 label 一致的只有 5 筆:`#37` 是 bare `Spectra` 而 label 由人事後貼上;`#131` / `#200` 則有限定詞卻無 label。兩者不是同一件事的兩種寫法 —— 由 producer 從限定詞推導 label,等於取消「人可以事後 park 一個 tier 明確的 issue」這條路徑。 + +> 完整契約(含 consumer 端的保守處置與 surface 規定)見 [`references/actionability-gate.md`](../../references/actionability-gate.md)。想表達「這件事先擱著」時,寫清楚 tier、然後貼 label。 + #### 各 verdict 的 Next Step | Verdict | Next Step | Flow | diff --git a/plugins/issue-driven-dev/skills/idd-implement/SKILL.md b/plugins/issue-driven-dev/skills/idd-implement/SKILL.md index 7ad09f6..001d13b 100644 --- a/plugins/issue-driven-dev/skills/idd-implement/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-implement/SKILL.md @@ -364,25 +364,41 @@ bash "$CLAUDE_PLUGIN_ROOT/scripts/gh-egress.sh" comment $NUMBER --repo $GITHUB_R ### Step 2.5: Bootstrap TodoList(non-Spectra case) -**判斷 Complexity routing**:讀最新的 diagnosis comment 的 `### Complexity` 欄位(v2.36.0+ 三路;v2.50+ 加 Layer V variant): +**判斷 Complexity routing**:讀最新 `## Diagnosis` comment 的 `### Complexity` 欄位(v2.36.0+ 三路;v2.50+ 加 Layer V variant)。**值域判定不在此處自行寫 parser**,改呼叫 [`references/actionability-gate.md`](../../references/actionability-gate.md) 契約下的共用實作: -> **v2.50+ Parser 規則**:verdict 文字可能含 ` via X` 後綴(例如 `Plan via Layer V`),parser 必須提取 canonical tier。實作: -> -> ```python -> raw_complexity = match_group_after("### Complexity\n").strip() -> canonical_tier = raw_complexity.split(" via ")[0].strip() # "Plan via Layer V" → "Plan" -> # bare "Plan" / "Simple" / "Spectra" / "SDD-warranted" 都不含 " via ",backward compat 保留 -> ``` -> -> 對應 spec Requirement: Routing parsers SHALL recognize Plan via Layer V verdict。 +```bash +LATEST_DIAGNOSIS=$(gh issue view "$NUMBER" --repo "$GITHUB_REPO" --json comments \ + | python3 -c " +import json, sys, re +d = json.load(sys.stdin) +diagnosis_comments = [c for c in d['comments'] if re.search(r'(?m)^## Diagnosis', c['body'])] +print(diagnosis_comments[-1]['body'] if diagnosis_comments else '') +") + +# 缺 helper 一律 fail loud + 指名 path,禁止 fallback 到私有 parser(契約 §Consumer contract) +. "$CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh" || { + echo "FATAL: missing $CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh — 不得改用私有 parser" >&2 + exit 1 +} + +TIER=$(idd_parse_complexity "$LATEST_DIAGNOSIS" 2>/dev/null); CEXIT=$? +COMPLEXITY_ERR=$(idd_parse_complexity "$LATEST_DIAGNOSIS" 2>&1 >/dev/null) # cexit≠0 時的 `unparseable-complexity: ` / `missing-complexity` +``` + +` via <來源>` 後綴(例如 `Plan via Layer V`)由 helper 剝除,本 skill 拿到的 `$TIER` 已是 canonical tier — 對應 spec Requirement: Routing parsers SHALL recognize Plan via Layer V verdict。 + +Routing 以 `(CEXIT, TIER)` 為鍵,**exit 0 的四個 tier 是封閉值域,不得依相似性外推第五個**: -| Canonical tier | 行為 | +| `CEXIT` · `TIER` | 行為 | |-----------|------| -| `Simple` | ✅ 本 step 啟動 TaskList 追蹤每個 checklist item | -| `Plan` (含 `Plan via Layer V`) | ✅ 同 Simple — TaskList 啟動。**注意**:使用者通常透過 `/idd-plan #NNN` 呼叫進來,approval gate 已在 idd-plan 處理完,本 skill 直接走 TDD loop。若使用者直接呼叫 `/idd-implement` 而 Complexity=Plan,**先提示**「Complexity 判定為 Plan,建議改走 `/idd-plan #NNN` 進入 approval gate;繼續直接 implement 等於跳過 Plan tier 的 deliberation 價值」並用 AskUserQuestion 確認 continue/abort。`Plan via Layer V` 同樣行為(routing 一致),只是 verdict 標記提示這是 Layer V 觸發 | -| `Spectra` | ⏭ 跳過本 step(由 `spectra-apply` 管 `openspec/changes//tasks.md`)| -| `SDD-warranted` (legacy alias) | ⏭ 跳過本 step — 視同 `Spectra` 處理(v2.36.0+ backward compat)| -| _(missing / unclear)_ | ✅ 預設當 Simple,啟動 TaskList(保守作法)| +| `0` · `Simple` | ✅ 本 step 啟動 TaskList 追蹤每個 checklist item | +| `0` · `Plan`(原值可能是 `Plan via Layer V`)| ✅ 同 Simple — TaskList 啟動。**注意**:使用者通常透過 `/idd-plan #NNN` 呼叫進來,approval gate 已在 idd-plan 處理完,本 skill 直接走 TDD loop。若使用者直接呼叫 `/idd-implement` 而 Complexity=Plan,**先提示**「Complexity 判定為 Plan,建議改走 `/idd-plan #NNN` 進入 approval gate;繼續直接 implement 等於跳過 Plan tier 的 deliberation 價值」並用 AskUserQuestion 確認 continue/abort。`Plan via Layer V` 同樣行為(routing 一致),只是 verdict 標記提示這是 Layer V 觸發 | +| `0` · `Spectra` | ⏭ 跳過本 step(由 `spectra-apply` 管 `openspec/changes//tasks.md`)| +| `0` · `SDD-warranted` (legacy alias) | ⏭ 跳過本 step — 視同 `Spectra` 處理(v2.36.0+ backward compat)| +| `3` — 值落在封閉值域外(如 `Simple when triggered`)| 🛑 **停止實作** — 印出 `$COMPLEXITY_ERR` 的 `unparseable-complexity: ` **原值**,要求 user 修正 Diagnosis,或把延期狀態改掛 `parking-lot` label。**禁止**截斷成 tier 前綴、**禁止**降級成 `Simple` / `Plan` 或任何其他 tier、**禁止**沿用舊的「不確定就當 Simple」預設 | +| `4` — 無 `### Complexity` 區段(含完全沒有 `## Diagnosis` comment)| 🛑 **停止實作** — 印出 `missing-complexity`,提示先跑 `/idd-diagnose #$NUMBER` 判定 complexity。同樣不得代 user 挑一個 tier | + +> **為何不在此處自己解析(#298 → #316)**:本段原本規定就地取 `### Complexity` 標題底下那一行的原文,再以 ` via ` 分隔符切出前半當 canonical tier。那個做法對 `Simple when triggered` 這類**帶延期修飾語**的值會**成功產出**一個非 tier 字串——它對不上任何 routing 列,卻也不是「解析失敗」,只能落進舊表最後一列的 catch-all `_(missing / unclear)_ → 預設當 Simple`,把一個被人裁決延期的 issue 直接送進 TDD loop。「不確定就當 Simple」在 tier 已被寫壞時不是保守,是**代 user 挑了一個 tier**——契約明文禁止截斷、禁止降級、禁止靜默,所以該列已改為上表的 `3` / `4` 兩個停止列。修法不是把 parser 寫得更嚴——那只會讓第四份私有窄化加入既有的三方分歧——而是讓值域判定只剩一份實作:後綴剝除、封閉值域檢查、原值 surface 全在 `scripts/lib/actionability.sh`,本 skill 只讀它的 exit code。完整規定見 [`references/actionability-gate.md`](../../references/actionability-gate.md)。 **Simple / Plan case 執行**: diff --git a/plugins/issue-driven-dev/skills/idd-list/SKILL.md b/plugins/issue-driven-dev/skills/idd-list/SKILL.md index 5fd7e20..bfe805b 100644 --- a/plugins/issue-driven-dev/skills/idd-list/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-list/SKILL.md @@ -501,7 +501,21 @@ Suggested next: | `Spectra` (含 alias `SDD-warranted`) | `/spectra-discuss` (default) 或 `/spectra-propose` (opt-out) | | 推不出 | `/idd-implement #N` (保守 default) | -**Complexity 解析**:對 phase=`diagnosed` 的 issue,掃最新 `## Diagnosis` comment 的 `### Complexity` 行(regex `### Complexity\n([A-Za-z-]+)`),取第一個 token。`SDD-warranted` 視同 `Spectra`。Verdict 後綴(如 `Plan via Layer V`,v2.50.0+)用 `split(' via ')[0]` 取 canonical tier。 +**Complexity 解析**:**不要在此處自行寫 regex。** 依 [`references/actionability-gate.md`](../../references/actionability-gate.md) 的封閉值域契約,呼叫共用實作: + +```bash +. "$CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh" || { + echo "FATAL: missing $CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh — 不得改用私有 regex" >&2 + exit 1 +} +tier=$(idd_parse_complexity "$latest_diagnosis_body"); cexit=$? +``` + +**helper 缺失必須 fail loud**(契約要求):silent fallback 回私有解析,正是本次要消滅的東西 —— 一個「找不到就自己想辦法」的 consumer 會把三方分歧原封不動地帶回來。 + +`cexit=0` → `$tier` 是 canonical tier(`SDD-warranted` 視同 `Spectra`;` via <來源>` 後綴已剝除),依上表 routing。**`cexit=3`(值在封閉值域外)或 `cexit=4`(缺區段)→ 不給任何 lifecycle 命令**,改依 Step 3.7 歸入 Parked 組並 surface 原值。 + +> **為何不在這裡寫 regex(#298 → #316)**:本行原本規定 `### Complexity\n([A-Za-z-]+)`「取第一個 token」—— 那個 regex 在第一個空白處停止,`Simple when triggered` 被截成 `Simple`,正是 Step 3.7 明文禁止的截斷。同一份 SKILL.md 裡一段禁止截斷、另一段規定截斷,實作者照哪段做行為就不同。解析規則現在只有一份,住在共用 helper 裡。 ## 鐵律 diff --git a/plugins/issue-driven-dev/skills/idd-plan/SKILL.md b/plugins/issue-driven-dev/skills/idd-plan/SKILL.md index 711d3eb..7b0538b 100644 --- a/plugins/issue-driven-dev/skills/idd-plan/SKILL.md +++ b/plugins/issue-driven-dev/skills/idd-plan/SKILL.md @@ -39,7 +39,7 @@ description: | ``` TaskCreate(name="resolve_pr_path", description="Phase 0.5: --pr/--no-pr flag → fork detection → pr_policy config → ask. 若 PR path: 建 feature branch") -TaskCreate(name="read_issue_and_diagnosis", description="gh issue view + 確認最新 diagnosis comment 的 Strategy + Complexity == Plan/Simple") +TaskCreate(name="read_issue_and_diagnosis", description="gh issue view + 讀最新 diagnosis comment 的 Strategy;Complexity 值域判定一律走 scripts/lib/actionability.sh 的 idd_parse_complexity,不自行比對字串") TaskCreate(name="draft_implementation_plan", description="依 Strategy 起草 Implementation Plan(5 段:files + reasoning + tests + risks + sequence)並 comment 到 issue") TaskCreate(name="tangential_sweep", description="Step 2.5: review session log from Step 1 to here, identify mid-plan tangential discoveries (sister bugs / unrelated quality issues / user-mentioned sub-concerns); AskUserQuestion to file as follow-up issues; append filed list to plan body before ExitPlanMode (per IC_R011 #524)") TaskCreate(name="enter_plan_mode_for_approval", description="Step 4: EnterPlanMode → 呈現 full Implementation Plan → ExitPlanMode 等 user approve / revise / abort") @@ -56,14 +56,40 @@ TaskCreate(name="auto_update_body", description="Step 7: idd-update phase → pl gh issue view $NUMBER --repo $GITHUB_REPO --json title,body,labels,comments ``` -確認最新的 `## Diagnosis` comment 存在且 `### Complexity` 是 `Plan`(或 `Simple`,user 主動 deliberate)。 +**Complexity 值域判定不在此處自行比對字串**,改呼叫 [`references/actionability-gate.md`](../../references/actionability-gate.md) 契約下的共用實作: -| Complexity | 行為 | +```bash +LATEST_DIAGNOSIS=$(gh issue view "$NUMBER" --repo "$GITHUB_REPO" --json comments \ + | python3 -c " +import json, sys, re +d = json.load(sys.stdin) +diagnosis_comments = [c for c in d['comments'] if re.search(r'(?m)^## Diagnosis', c['body'])] # line-anchored,避免引述/inline 誤判 +print(diagnosis_comments[-1]['body'] if diagnosis_comments else '') +") + +# 缺 helper 一律 fail loud + 指名 path,禁止 fallback 到私有檢查(契約 §Consumer contract) +. "$CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh" || { + echo "FATAL: missing $CLAUDE_PLUGIN_ROOT/scripts/lib/actionability.sh — 不得改用私有 Complexity 檢查" >&2 + exit 1 +} + +TIER=$(idd_parse_complexity "$LATEST_DIAGNOSIS" 2>/dev/null); CEXIT=$? +COMPLEXITY_ERR=$(idd_parse_complexity "$LATEST_DIAGNOSIS" 2>&1 >/dev/null) # cexit≠0 時的 `unparseable-complexity: ` / `missing-complexity` +``` + +以 `(CEXIT, TIER)` 為鍵決定行為。**exit 0 的四個 tier 是封閉值域,不得依相似性外推第五個**: + +| `CEXIT` · `TIER` | 行為 | |-----------|------| -| `Plan` | ✅ 預期 — 繼續 | -| `Simple` | ⚠️ 詢問 user:「Complexity 判定為 Simple,確定要走 Plan tier 多一道 approval gate 嗎?」 | -| `Spectra` (含 alias `SDD-warranted`) | ⛔ 提示「Spectra 應走 `/spectra-discuss`,Plan tier 不會產出 spec/proposal/tasks artifacts」,AskUserQuestion abort 或 continue(continue 等於 user 自願降級到 Plan tier) | -| _(missing)_ | ⛔ 提示「找不到 diagnosis,先跑 `/idd-diagnose #NNN`」並 abort | +| `0` · `Plan` | ✅ 預期 — 繼續 Step 2 | +| `0` · `Plan`(原值 `Plan via Layer V`)| 同上 — helper 已剝除 ` via <來源>` 後綴,canonical tier 即 `Plan`,行為與 bare `Plan` 完全一致 | +| `0` · `Simple` | ⚠️ 詢問 user:「Complexity 判定為 Simple,確定要走 Plan tier 多一道 approval gate 嗎?」(行為不變 — user 主動要 deliberate 是允許的)| +| `0` · `Spectra` | ⛔ 提示「Spectra 應走 `/spectra-discuss`,Plan tier 不會產出 spec/proposal/tasks artifacts」,AskUserQuestion abort 或 continue(continue 等於 user 自願降級到 Plan tier)— 行為不變 | +| `0` · `SDD-warranted`(legacy alias)| 視同 `Spectra` 處理 — 行為不變 | +| `3` — 值落在封閉值域外(如 `Plan when triggered`)| ⛔ **abort** — 印出 `$COMPLEXITY_ERR` 的 `unparseable-complexity: ` **原值**,要求 user 修正 Diagnosis,或把延期狀態改掛 `parking-lot` label。**禁止**截斷成 tier 前綴、**禁止**降級成 `Plan` 或任何其他 tier、**禁止**當成 `Simple` 問過 user 就繼續 | +| `4` — 無 `### Complexity` 區段(含完全沒有 `## Diagnosis` comment)| ⛔ **abort** — 提示「找不到 diagnosis / Complexity 判定,先跑 `/idd-diagnose #NNN`」(即舊表的 _(missing)_ 列,語意不變)| + +> **為何不在此處自行認定 tier(#298 → #316)**:本 step 原本用一句散文自行認定「`### Complexity` 是 `Plan`(或 `Simple`)」,值域外的值沒有任何定義行為。像 `Plan when triggered` 這種**帶延期修飾語**的歷史寫法,字面以 `Plan` 開頭、讀起來像 Plan tier,很容易被直接放行進 approval gate —— 但它真正的意思是「這件事被人為延期了」。`### Complexity` 的值域是**封閉的四個 tier**(可帶 ` via <來源>` 後綴),延期狀態屬於 `parking-lot` label,不屬於這個欄位。修法不是把散文判準寫得更嚴 —— 那只會讓第 N 份私有窄化加入既有的多方分歧 —— 而是讓值域判定只剩一份實作:後綴剝除、封閉值域檢查、原值 surface 全在 `scripts/lib/actionability.sh`,本 skill 只讀它的 exit code。「不得截斷、不得降級、不得靜默」的規定見 [`references/actionability-gate.md`](../../references/actionability-gate.md)。 ### Step 2: Draft Implementation Plan