功能開發工作流 — 整合 Notion 與 Claude Code,以 .spec/ 目錄做本地規劃,Agent Teams 產生程式碼與審查,瀏覽器驗收驗證,結案時批次同步 Notion。

v5.2.1跨 Host 的 Feature lifecycle:本地 .spec/ 規劃、Human approval gates、正式實作、安全/驗證/review、Human UAT 與結案同步。核心 contract 不依賴單一 Host 的 team、subagent 或 provider model 名稱。
claude plugin marketplace add mark22013333/crew
claude plugin install feature-workflow
codex plugin marketplace add mark22013333/crew
codex plugin add feature-workflow@crew
首次使用執行 /plan-setup;若 Bug workflow 也安裝,建議先跑 /bug-setup,Feature setup 可重用共用 Notion metadata。
若同時安裝 bug-workflow,可直接用統一入口:
/crew-upgrade
/crew-upgrade --check
若只安裝 Feature plugin,使用 Host-native 更新:
claude plugin marketplace update company-marketplace
claude plugin update feature-workflow@company-marketplace
claude plugin list
codex plugin marketplace upgrade crew
codex plugin list
更新後開新 session。若 Codex marketplace 尚未註冊,先執行 codex plugin marketplace add mark22013333/crew。
/plan-start 在任何 Notion / .spec / Git side effect 前,先用唯讀 feature-intake-refiner 把 raw request 整理成短標題與 task brief,再由 Human 明確確認。Bug plugin 也依同一 shared contract 使用 bug-intake-refiner;兩者都不是 Slash Skill。
<!-- crew:diagram intake-refinement-flow -->
flowchart TD
Raw["Raw user request"] --> Refiner["feature-intake-refiner<br/>delegate_readonly + STANDARD"]
Refiner --> Blocking{"Blocking ambiguity?"}
Blocking -- "yes" --> Ask["Ask Human<br/>max 3 questions"]
Ask --> Refiner
Blocking -- "no" --> Confirm{"Human confirms intent?"}
Confirm -- "modify" --> Refiner
Confirm -- "cancel" --> Stop["Stop<br/>zero side effect"]
Confirm -- "confirmed" --> Guard["Persistence security preflight<br/>redact credentials + .spec gitignore safeguard"]
Guard --> Cache[".cache/intake.md<br/>original / refined / type / notion_page_id"]
Cache --> Start["Create / reuse Notion + state + plan.md + branch"]
Start --> Spec["/plan spec"]
Spec --> Analyst["feature-spec-analyst<br/>Goal / AC / Decisions / Risks"]
責任分界與 durability:
.spec/ 已被 .gitignore 保護。feature-spec-analyst:task 建立後才把 confirmed brief 工程化成 Goal / AC / Decisions / Risks。notion_page_id;Notion create 成功後先 journal page ID,再 init state。/plan-sync 會 reconcile cache/page/state 後補建/補寫;只有 state/page/intake 全部一致才刪 cache。/plan-close 發現 cache/state page ID 衝突會 BLOCK。/plan-start、/plan-sync、/plan-close 呼叫 CREW scripts 時都先解析 CREW_PLUGIN_ROOT;不直接依賴 Claude marketplace/cache path。完整 contract 見 references/intake-refinement.md。
<!-- crew:diagram feature-lifecycle -->
flowchart LR
A["/plan-start"] --> B["spec"]
B --> C["db"]
C --> D["arch"]
D --> E["/plan-build"]
E --> F["/plan-security"]
F --> G["/plan-verify"]
G --> H["/plan-review"]
H --> I["/plan-close"]
I --> J{"Human UAT"}
J -- accepted --> K["close"]
J -- rejected --> E
Runtime steps:
start → spec → db → arch → build → security → verify → review → close
.spec/{slug}/state.json 是唯一流程狀態,唯一寫者為 scripts/crew-state.py。
verify=PASS 或 review 完成:不等於 Human UAT approved。/plan-close 是 Feature UAT + close 的合法入口;每次結案前都重新取得本輪 Human decision。/plan-close 由人類明確同意後,把未完成的 security/verify/review 標 skipped(--by human --reason 必填,且需 build 已完成、requirement/architecture 閘已通過,runtime 會擋);Human UAT 與漂移硬關卡不因此略過。.spec/ 結構v2 task 的核心檔案:
.spec/{slug}/
├── plan.md # goal / AC / decisions / risks / map / report anchors
├── state.json # machine state;唯一寫者 crew-state.py
└── deploy.sql # 有 DB migration 時才存在
files.md、log.md、handoff.md、.spec/_index.md 等 v1 artifact 不再是 v2 runtime contract。Notion intake 尚未成功持久化時可暫存 .spec/{slug}/.cache/intake.md;它 gitignored、同步成功後刪除,不是永久 runtime artifact。
Feature workflow 用 capability 描述角色,不要求某個 Host 一定有 team/multi-agent:
| Capability | 用途 | 無該 Host 能力時 |
|---|---|---|
project_instructions | 讀 AGENTS.md / CLAUDE.md | 無任何指令檔才阻擋 |
delegate_readonly | spec、探索、review | 主 Agent inline 唯讀執行 |
delegate_write | 已核准的正式實作 | 主 Agent 按 allowed scope 寫入 |
parallel_delegate | 互不依賴角色加速 | sequential fallback |
tool_probe | DB/瀏覽器等外部能力 | no-tool fallback |
ask_user | requirements / architecture / UAT decision | 一般對話詢問 |
因此平行執行是最佳化,不是 /plan-build 或 /plan-review 的 hard prerequisite。
完整 contract 見 references/host-capabilities.md。
Feature workflow 使用 provider-neutral profiles:
| 工作 | Profile | 可改產品碼 |
|---|---|---|
| repository search、範本/交叉引用探索 | FAST | 否 |
| 一般需求分析與一般 review | STANDARD | 否 |
| DB schema / architecture / security / performance | DEEP | 否 |
| build/test/schema validation | NONE | 否 |
/plan-build 正式實作 | DEEP(目前保守基準) | 是 |
Profile 由 crew-model-route.py + model-routing.json 計算,Host adapter 才決定實際 provider mapping。Workflow README 不把 provider model 名稱當成流程契約。
CREW-owned Feature config 由 shared resolver 管理,不由 README 指定 Host-specific directory。
Portable root:
CREW_CONFIG_HOME$XDG_CONFIG_HOME/crew~/.config/crew主要 logical keys:
| Key | 用途 |
|---|---|
feature/config | Notion IDs、workspace metadata、欄位對照 |
feature/project | repo-id 專案對應 |
feature/stack | 自訂 stack definition |
bug/config | setup 時可讀取共用 Notion metadata |
scripts/crew-config.py 負責 canonical path、legacy read fallback 與 representation;新寫入永遠走 portable root。Feature project / stack 的 standalone file 依 logical key 解析,不直接硬編碼某個 Host home path。
需要專案架構與規範時使用 project_instructions:
AGENTS.mdCLAUDE.md兩者並存時都可讀;若內容衝突,必須保留歧義讓 Human 決定。
| Skill | 說明 | ||
|---|---|---|---|
/plan-setup | 建立/更新 Feature portable config | ||
/plan-stack | 掃描專案並建立自訂 stack definition | ||
/plan-start <任務> | Refine raw request → Human confirm → 建立 Notion + .spec/ + Git branch | ||
/plan-explore | 想法探索、問題調查、方案比較 | ||
/plan-browse | 深讀/比較既有規劃 | ||
| `/plan [spec\ | db\ | arch]` | 三 pass 規劃與 approval loop |
/plan-build | 依核准規格執行正式實作;支援 dry-run / scoped build | ||
/plan-security | 安全審查 | ||
/plan-verify | Browser/API 驗證、evidence、Word/Excel/E2E 選項 | ||
/plan-review | 邏輯、品質、效能 review;可 quick | ||
/plan-close | Human UAT + 結案同步 | ||
/plan-sync | 中途同步 .spec/ 到 Notion | ||
/plan-deploy-confirm | 部署 SQL step 回報 | ||
/plan-status | 查看任務狀態;v1 可 migrate | ||
/plan-next | 從 state 計算下一步 | ||
/plan-drift | plan.md anchor / code drift 檢查 | ||
/plan-demo | 純本地評估模式 | ||
/crew-cockpit | 開啟 CREW Cockpit Pane(總覽/任務/驗收,唯讀;僅 Claude Code ≥ 2.1.289) |
/plan-build 執行模型/plan-build 仍採「先探索、再按角色實作」的分工,但角色是 capability contract,不是某一種 Host team API:
<!-- crew:diagram plan-build-orchestration -->
flowchart TD
Approved["Requirements + Architecture approved"] --> Explore["delegate_readonly<br/>FAST exploration"]
Explore --> Handoff["implementation handoff"]
Handoff --> Split{"Scopes independent?"}
Split -- "yes + Host supports parallel" --> Parallel["parallel_delegate"]
Split -- "no / unavailable" --> Sequential["sequential delegate_write"]
Parallel --> DB["DB"]
Parallel --> Backend["Backend"]
Parallel --> API["API"]
Parallel --> Frontend["Frontend"]
Parallel --> Test["Test"]
Sequential --> Roles["same roles<br/>in DAG order"]
DB --> Proof["NONE<br/>build / test / schema validation"]
Backend --> Proof
API --> Proof
Frontend --> Proof
Test --> Proof
Roles --> Proof
Proof --> State["state transition"]
/plan-review 執行模型Review 以邏輯、品質、效能/交易/並行等角度拆開:
<!-- crew:diagram plan-review-orchestration -->
flowchart LR
Input["changed code + approved spec"] --> Dispatch{"parallel available?"}
Dispatch -- "yes" --> Logic["Logic review<br/>STANDARD"]
Dispatch -- "yes" --> Quality["Quality review<br/>STANDARD"]
Dispatch -- "yes" --> Performance["Performance / transaction / concurrency<br/>DEEP"]
Dispatch -- "no" --> Sequential["same reviewers<br/>sequential"]
Logic --> Aggregate["aggregate findings"]
Quality --> Aggregate
Performance --> Aggregate
Sequential --> Aggregate
Aggregate --> Result{"blocking finding?"}
Result -- "yes" --> Build["back to /plan-build"]
Result -- "no" --> Close["ready for /plan-close"]
/plan-security 負責,不與一般 review 混為同一 gate。/plan-review --quick。/plan-verify/plan-verify 產出 machine/browser evidence,而不是 Human UAT 本身。
<!-- crew:diagram plan-verify-flow -->
flowchart TD
AC["plan.md AC-n"] --> Router{"Verification Router"}
Router --> Browser["browser<br/>Playwright preferred"]
Router --> API["API"]
Router --> Backend["backend test"]
Router --> DB["database"]
Router --> Manual["manual / skip"]
Browser --> Pre{"precondition gate"}
API --> Pre
Backend --> Pre
DB --> Pre
Manual --> Evidence["runtime evidence"]
Pre -- "ready" --> Evidence
Pre -- "not ready" --> Blocked["BLOCKED<br/>not product FAIL"]
Blocked --> Evidence
Evidence --> Result["state.json results.verify"]
Evidence --> IR["Verification IR"]
IR --> Draft["E2E candidate<br/>maturity=draft"]
Draft --> Promote{"source / review / stability / environment gate"}
Promote -- "ci-ready" --> CI["CI / E2E runner"]
Existing["existing maintained E2E"] --> CI
CI --> Artifact["crew-results.json"]
Artifact --> Import["/plan-verify --from-e2e"]
Import --> Result
Result --> Status{"PASS / WARN / FAIL"}
Status -- "FAIL" --> Build["/plan-build"]
Status -- "WARN" --> Recheck["/plan-verify --recheck"]
Status -- "PASS" --> Review["/plan-review"]
Review --> Close["/plan-close"]
Close --> UAT{"Human UAT"}
UAT -- "rejected" --> Build
UAT -- "approved / waived" --> Done["close"]
可依環境使用:
BLOCKED:前置條件不成立,不誤報產品 FAIL--recheck:重跑 FAIL + BLOCKED--e2e:重用既有 E2E coverage--e2e-draft:由 Verification IR 產出 draft candidate--e2e-promote:通過 source/review/stability/fingerprint/environment gate 後才可 ci-ready--from-e2e:消費 crew-results.json,不重新開瀏覽器E2E candidate 預設為
draft;只有通過 deterministic promotion gate,且 environment gate 不為 deferred,才可標示ci-ready。
外部 browser/DB 工具以 tool_probe 判斷目前 Host 是否真的可呼叫;不能用某一家 CLI listing 代替 capability probe。
Project verify memory canonical storage 是 .crew/verify-memory.md;舊 .claude/verify-memory.md 只在 canonical 不存在時相容讀取。驗證結果仍以 state.json.results.verify 為唯一 machine truth。
流程邊界:verify PASS → /plan-review → /plan-close;Human UAT 在 /plan-close 內取得,不是 /plan-verify 的副作用。
Claude Code 專屬的唯讀儀表板,隨 feature-workflow 一起安裝:
/crew-cockpit Pane:總覽儀表板、任務列表/卡片切換、驗收頁;資料全部來自 .spec/*/state.json 與驗證結果,只讀、不修改。claude --plugin-dir <路徑>;Claude Desktop 不吃 CLAUDE_CODE_PLUGIN_DIRS,需安裝後才看得到。plugins/feature-workflow/.claude-plugin/marketplace.json(4.24.3)與 category 欄位會讓 claude plugin validate --strict 失敗(main 既有,尚未處理)。Claude Code adapter 會以 crew-state.py session-brief 顯示未完成任務與 /plan-next。Hook 只讀當前 repo 的 state,不外送、不修改產品檔案,錯誤時不阻擋 session。
沒有同等 session hook 的 Host 直接使用 /plan-next 即可,不影響 workflow correctness。
v1 任務目前仍可相容完成或用 /plan-status --migrate <slug> 做機械遷移。feature-workflow@5.1.0 已達原訂 removal eligibility 的版本門檻,但本版刻意保留 v1 compatibility;真正移除 legacy-v1.md / migrate path 必須另開 breaking-change PR/release。
Removal eligibility:
feature-workflow@5.1.0+ 發布;或2026-10-26以先到者為準。門檻未達前不得提前刪 v1 compatibility surface。
/project-add 維護 shared feature/project mapping。MIT License
hooks/crew-cockpit.ts 358 lines1// CREW Cockpit:feature-workflow 在 Claude Code 上的唯讀視覺化層(Mods 模組入口)。
2//
3// Core owns truth. Cockpit owns presentation.
4// - 不寫 .spec/{slug}/state.json、不跑 crew-state.py、不送出 prompt、不攔截 tool.call(規格 §0、§3 D-2)。
5// - snapshot 只在 session.start/主 turn 結束//crew-cockpit/重新整理時重讀,存進 $.state;render 只讀 $.state(§3 D-3、§15)。
6// - 唯一的「動作」是 Fill:把固定模板 `/plan-next {slug}` 或 `/plan-close {slug}` 填進空的輸入框,絕不送出(§14)。
7// - 任何 Cockpit 錯誤都只吞掉並放行,不阻擋 Claude Code 正常工作(§16)。
8
9import { atom, read, update } from 'claude-code'
10import type { EngineInterface, Register, RenderElement } from 'claude-code'
11
12import { type LoaderPorts, loadCockpitSnapshot } from './cockpit/loader'
13import { COMMAND_NAME, MIN_CLAUDE_CODE_VERSION, PANE_ID, TEXT, type CockpitTab, type CockpitTaskLayout, compareVersions } from './cockpit/model'
14import { type PaneCallbacks, composeBand, hudModel, hudView, paneView } from './cockpit/render'
15import { type FillKind, fillTemplateFor } from './cockpit/selectors'
16
17// ---------------------------------------------------------------------------
18// $.state 參照(契約在 types/index.d.ts)。
19// Mods 規定:state 參照必須在「本檔」以字面值 plugin/key 宣告(atom 寫在本檔的 const),
20// validate 才能列出模組讀寫哪些 state;不能從別的檔案 import。寫入只在 handler/事件 hook,ui.render 只讀。
21// ---------------------------------------------------------------------------
22
23/** 最新讀取模型;尚未載入為 null。 */
24export const snapshotAtom = atom({ plugin: 'feature-workflow', key: 'snapshot' } as const, null)
25
26/** 使用者本 session 選的 task id(UI state,不是 workflow state)。 */
27export const selectedSlugAtom = atom({ plugin: 'feature-workflow', key: 'selectedSlug' } as const, null)
28
29/** pane 目前的 tab。 */
30export const tabAtom = atom({ plugin: 'feature-workflow', key: 'tab' } as const, 'overview')
31
32/** HUD 開關鏡像(§9.4;預設開啟;持久化值在 $.store)。 */
33export const hudEnabledAtom = atom({ plugin: 'feature-workflow', key: 'hudEnabled' } as const, true)
34
35/** 版本檢查結果(§19);尚未檢查為 null。 */
36export const runtimeAtom = atom({ plugin: 'feature-workflow', key: 'runtime' } as const, null)
37
38/**
39 * 把 loader 需要的唯讀 I/O 綁到 $。Mods 規定 $ 只能在本檔以 `$.noun.event(...)` 的形式出現,
40 * 不能跨 import 傳遞,validate 才能從原始碼讀出模組實際呼叫了什麼。這裡刻意沒有任何寫入能力。
41 */
42function portsOf($: EngineInterface): LoaderPorts {
43 return {
44 list: path => $.fs.list(path),
45 stat: path => $.fs.stat(path),
46 exists: path => $.fs.exists(path),
47 read: path => $.fs.read(path),
48 sessionRoot: () => $.session.root(),
49 repo: () => $.session.repo(),
50 now: () => $.clock.now(),
51 }
52}
53
54/**
55 * 重新載入 snapshot 並寫進 $.state(讀者自動重繪)。失敗不 throw。
56 * 競態:多次 refresh 可能並行(turn.complete、重新整理按鈕、/crew-cockpit),較早開始的那次可能較晚讀完。
57 * 每次先在 runtime 領一個遞增世代號,讀完時只有「仍是最新世代」的結果才寫入,舊結果直接丟棄。
58 */
59export async function refreshSnapshot($: EngineInterface): Promise<void> {
60 try {
61 const claimed = await update($, runtimeAtom, current => ({
62 // 保留 runtime 上的 UI state(isClosedExpanded),refresh 不得把展開狀態重設
63 ...current,
64 isSupported: current?.isSupported ?? false,
65 version: current?.version ?? null,
66 minimum: current?.minimum ?? MIN_CLAUDE_CODE_VERSION,
67 refreshGeneration: (current?.refreshGeneration ?? 0) + 1,
68 }))
69 const generation = claimed?.refreshGeneration ?? 0
70 const previous = await read($, snapshotAtom)
71 const userSelectedSlug = await read($, selectedSlugAtom)
72 const snapshot = await loadCockpitSnapshot(portsOf($), { previous, userSelectedSlug })
73 const latest = (await read($, runtimeAtom))?.refreshGeneration ?? 0
74 if (latest !== generation) {
75 // 已有較新的 refresh 開始(可能已寫入):這份是舊的,不得覆蓋
76 return
77 }
78 await update($, snapshotAtom, () => snapshot)
79 } catch {
80 // §16:Cockpit error → pass through
81 }
82}
83
84const checkVersion = async ($: EngineInterface): Promise<boolean> => {
85 try {
86 const { version } = await $.session.version()
87 // 區域變數不可與模組層的 isSupported() 同名:Claude Code 2.1.289 的載入檢查會判為重複宣告而拒載整個 Mod
88 const supported = compareVersions(version, MIN_CLAUDE_CODE_VERSION) >= 0
89 // 保留 refreshGeneration:重設會讓世代號倒退,與進行中的 refresh 比對失準
90 await update($, runtimeAtom, current => ({ ...current, isSupported: supported, version, minimum: MIN_CLAUDE_CODE_VERSION }))
91 return supported
92 } catch {
93 return false
94 }
95}
96
97const isSupported = async ($: EngineInterface): Promise<boolean> => (await read($, runtimeAtom))?.isSupported === true
98
99// ---------------------------------------------------------------------------
100// §9.4 HUD 開關:$.store 持久化(key 含 repo identity),$.state hudEnabled 鏡像給 render 讀。
101// ---------------------------------------------------------------------------
102
103/** $.store 的偏好 key:`{prefix}:{repo.remote ?? repoRoot ?? session root}`(每個 repo 一份)。 */
104async function prefStoreKey($: EngineInterface, prefix: 'hud' | 'layout'): Promise<string> {
105 const repo = await $.session.repo().catch(() => null)
106 if (repo?.remote) {
107 return `${prefix}:${repo.remote}`
108 }
109 const snapshot = await read($, snapshotAtom).catch(() => null)
110 if (snapshot?.repoRoot) {
111 return `${prefix}:${snapshot.repoRoot}`
112 }
113 return `${prefix}:${repo?.root ?? (await $.session.root())}`
114}
115
116/** $.store 的 HUD 開關 key:`hud:{repo.remote ?? repoRoot ?? session root}`。 */
117const hudStoreKey = ($: EngineInterface): Promise<string> => prefStoreKey($, 'hud')
118
119/** session.start:把持久化的 HUD 開關鏡像到 $.state(缺值=預設開啟)。 */
120async function loadHudPreference($: EngineInterface): Promise<void> {
121 try {
122 const stored = await $.store.get(await hudStoreKey($))
123 await update($, hudEnabledAtom, () => stored !== false)
124 } catch {
125 // §16:讀不到偏好就維持預設
126 }
127}
128
129async function setHudPreference($: EngineInterface, isEnabled: boolean): Promise<void> {
130 await update($, hudEnabledAtom, () => isEnabled)
131 try {
132 await $.store.set(await hudStoreKey($), isEnabled)
133 } catch {
134 // 持久化失敗只影響下個 session;本 session 已生效
135 }
136}
137
138// ---------------------------------------------------------------------------
139// 任務 tab 版面偏好:$.store 持久化(key `layout:{repo identity}`,比照 HUD),
140// $.state runtime.taskLayout 鏡像給 render 讀(放 runtime 而非新 key,capability baseline 不變)。
141// ---------------------------------------------------------------------------
142
143const asLayout = (value: unknown): CockpitTaskLayout => (value === 'cards' ? 'cards' : 'list')
144
145async function writeLayoutMirror($: EngineInterface, layout: CockpitTaskLayout): Promise<void> {
146 await update($, runtimeAtom, current => (current === null ? current : { ...current, taskLayout: layout }))
147}
148
149/** session.start:把持久化的版面鏡像到 runtime(缺值=列表)。 */
150async function loadLayoutPreference($: EngineInterface): Promise<void> {
151 try {
152 const stored = await $.store.get(await prefStoreKey($, 'layout'))
153 await writeLayoutMirror($, asLayout(stored))
154 } catch {
155 // §16:讀不到偏好就維持預設(列表)
156 }
157}
158
159async function setLayoutPreference($: EngineInterface, layout: CockpitTaskLayout): Promise<void> {
160 await writeLayoutMirror($, layout).catch(() => undefined)
161 try {
162 await $.store.set(await prefStoreKey($, 'layout'), layout)
163 } catch {
164 // 持久化失敗只影響下個 session;本 session 已生效
165 }
166}
167
168// ---------------------------------------------------------------------------
169// §14 Fill:只填固定模板 `/plan-next {slug}`/`/plan-close {slug}`;先讀草稿(不覆蓋)→ 開著 pane 直接 fill →
170// 被拒才關 pane 再 fill 一次 → 仍被拒就 toast。
171// 依使用者指示調整規格 §14/B1 的保守預設(原本一律先關 pane):使用者不希望按填入後 Cockpit 消失。
172// 兩種模板共用同一條流程,差別只在 fillTemplateFor 選哪個固定模板。
173// ---------------------------------------------------------------------------
174
175async function fillOnce($: EngineInterface, command: string): Promise<boolean> {
176 const filled = await $.prompt.fill({ text: command, mode: 'replace' })
177 return filled.isFilled
178}
179
180async function fillTemplate($: EngineInterface, taskId: string, kind: FillKind): Promise<void> {
181 const snapshot = await read($, snapshotAtom).catch(() => null)
182 const task = snapshot?.tasks.find(item => item.id === taskId) ?? null
183 // 內容只由固定模板+白名單 slug 組成;不用 state.next.command 或任何 render 傳來的字串
184 const command = fillTemplateFor(task, kind)
185 if (command === null) {
186 return
187 }
188 try {
189 const box = await $.prompt.read()
190 if (box.text !== '') {
191 $.ui.toast(TEXT.draftExists(command))
192 return
193 }
194 if (await fillOnce($, command)) {
195 // 成功:pane 保持開啟。Mods API 沒有把鍵盤交還輸入框的呼叫($.ui.focus 只能在本 plugin 的 site 內移動焦點),
196 // 所以 pane 仍持有鍵盤時提示使用者按 Esc 回輸入框再 Enter 送出(pane 不設 closeOnEscape,Esc 只交還鍵盤、不關 pane)。
197 const panes = await $.ui.panes().catch(() => [])
198 if (panes.some(pane => pane.id === PANE_ID && pane.isFocused)) {
199 $.ui.toast(TEXT.filledKeepPane(command))
200 }
201 return
202 }
203 // 被拒(refusal 為 dialog/no_composer/缺省=被其他 hook 擋下):退回原本的保守做法,關 pane 後再填一次
204 await $.ui.close({ id: PANE_ID }).catch(() => undefined)
205 if (!(await fillOnce($, command))) {
206 $.ui.toast(TEXT.fillRefused(command))
207 }
208 } catch {
209 $.ui.toast(TEXT.fillRefused(command))
210 }
211}
212
213/** Pane 按鈕的處理:只改 UI state(tab/selection)、重讀 snapshot、或 Fill;不寫任何 project file。 */
214function paneCallbacks($: EngineInterface): PaneCallbacks {
215 return {
216 selectTab: async (tab: CockpitTab) => {
217 await update($, tabAtom, () => tab).catch(() => undefined)
218 },
219 selectTask: async (id: string) => {
220 await update($, selectedSlugAtom, () => id).catch(() => undefined)
221 await update($, tabAtom, () => 'overview' as const).catch(() => undefined)
222 },
223 refresh: () => refreshSnapshot($),
224 fill: (id: string, kind: FillKind = 'next') => fillTemplate($, id, kind).catch(() => undefined),
225 toggleClosed: async () => {
226 // 展開狀態只存 UI state(runtime.isClosedExpanded),不寫任何 project file
227 await update($, runtimeAtom, current => (current === null ? current : { ...current, isClosedExpanded: current.isClosedExpanded !== true })).catch(
228 () => undefined,
229 )
230 },
231 setLayout: (layout: CockpitTaskLayout) => setLayoutPreference($, layout),
232 }
233}
234
235/** /crew-cockpit 的參數:空白=開 pane;`hud on|off`=HUD 開關;其他=用法。 */
236function parseArgs(args: string): 'open' | 'hud-on' | 'hud-off' | 'usage' {
237 const words = args.trim().toLowerCase().split(/\s+/).filter(word => word !== '')
238 if (words.length === 0) {
239 return 'open'
240 }
241 if (words.length === 2 && words[0] === 'hud' && (words[1] === 'on' || words[1] === 'off')) {
242 return words[1] === 'on' ? 'hud-on' : 'hud-off'
243 }
244 return 'usage'
245}
246
247export const register: Register = on => {
248 on('session.start', async ($, e, next) => {
249 try {
250 await $.command.register({
251 name: COMMAND_NAME,
252 description: '開啟 CREW Cockpit:唯讀檢視 .spec 任務、phase、核准閘與驗收狀態(hud on|off 開關 HUD)',
253 argumentHint: '[hud on|off]',
254 immediate: true,
255 })
256 if (await checkVersion($)) {
257 await refreshSnapshot($)
258 await loadHudPreference($)
259 await loadLayoutPreference($)
260 }
261 } catch {
262 // §16:不阻擋 session
263 }
264 return next(e)
265 })
266
267 on('command.run', { command: 'crew-cockpit' }, async ($, e) => {
268 try {
269 if (!(await isSupported($))) {
270 return { text: TEXT.needVersion(MIN_CLAUDE_CODE_VERSION) }
271 }
272 const action = parseArgs(e.args ?? '')
273 if (action === 'hud-on' || action === 'hud-off') {
274 await setHudPreference($, action === 'hud-on')
275 return { text: action === 'hud-on' ? TEXT.hudOn : TEXT.hudOff }
276 }
277 if (action === 'usage') {
278 return { text: TEXT.usageFull }
279 }
280 await refreshSnapshot($)
281 // §10:先問引擎 pane 是否已開著(熱重載後模組不記得,但引擎記得),已開就不重複開啟
282 const panes = await $.ui.panes().catch(() => [])
283 const existing = panes.find(pane => pane.id === PANE_ID)
284 if (existing !== undefined) {
285 if (!existing.isPlaced) {
286 $.ui.toast(TEXT.paneNotPlaced)
287 }
288 return { text: TEXT.paneOpened }
289 }
290 // 不設 holdToasts:否則 pane 變成 dialog,所有 toast 都要等它關閉(§10)
291 // 不設 closeOnEscape:Fill 後 pane 保持開啟,Esc 只把鍵盤交還輸入框、不關 pane;關閉用 pane 的關閉鈕或 ctrl+x x
292 const opened = await $.ui.open({ id: PANE_ID, title: TEXT.paneTitle, focus: true })
293 if (!opened.isPlaced) {
294 $.ui.toast(TEXT.paneNotPlaced)
295 }
296 return { text: TEXT.paneOpened }
297 } catch {
298 return { text: TEXT.paneOpened }
299 }
300 })
301
302 on('turn.complete', async ($, e, next) => {
303 // §15:只在主 turn 結束時刷新;子代理(帶 agentId)不重掃
304 if (e.agentId === undefined && (await isSupported($).catch(() => false))) {
305 await refreshSnapshot($)
306 }
307 return next(e)
308 })
309
310 on('ui.render', { component: 'Pane', requestId: 'crew-cockpit' }, async ($, e, next) => {
311 try {
312 if (!(await isSupported($))) {
313 return next(e)
314 }
315 const { Box, Text, Button } = $.ui.resolve(e)
316 const runtime = await read($, runtimeAtom)
317 const data = {
318 snapshot: await read($, snapshotAtom),
319 selectedSlug: await read($, selectedSlugAtom),
320 tab: await read($, tabAtom),
321 bodyColumns: e.props.bodyColumns,
322 isClosedExpanded: runtime?.isClosedExpanded === true,
323 taskLayout: asLayout(runtime?.taskLayout),
324 }
325 return paneView({ Box, Text, Button }, data, paneCallbacks($))
326 } catch {
327 return next(e)
328 }
329 })
330
331 // §9 AbovePrompt HUD:一律 compose next(e),不蓋掉其他 mod 的 band;survey、關閉、無 active task 時直接放行。
332 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
333 let lines: ReturnType<typeof hudModel> = null
334 try {
335 if (!e.props.hasSurvey && (await isSupported($)) && (await read($, hudEnabledAtom))) {
336 lines = hudModel({
337 snapshot: await read($, snapshotAtom),
338 selectedSlug: await read($, selectedSlugAtom),
339 bodyColumns: e.props.bodyColumns,
340 })
341 }
342 } catch {
343 lines = null
344 }
345 if (lines === null) {
346 return next(e)
347 }
348 const other: RenderElement | null | undefined = await next(e)
349 try {
350 const { Box, Text, Button } = $.ui.resolve(e)
351 const kit = { Box, Text, Button }
352 return composeBand(kit, other, hudView(kit, lines, e.props.bodyColumns))
353 } catch {
354 return other
355 }
356 })
357}
358hooks/cockpit/loader.ts 789 lines1// CREW Cockpit loader:repo-local、唯讀。
2//
3// 只透過 LoaderPorts 使用 $.session.root/repo、$.fs.list/stat/exists/read 與 $.clock.now;
4// 不寫檔、不跑 process、不呼叫 model、不連網(規格 §0、§3 D-2)。
5// 不在 TypeScript 重寫 normalize()/compute_next()(§6.3):只把 state.json 原貌讀成顯示用 view model。
6
7import type { FsEntry, FsStat, SessionRepo } from 'claude-code'
8
9import {
10 type CockpitError,
11 type CockpitField,
12 type CockpitGateView,
13 type CockpitInvalidTask,
14 type CockpitIrAcView,
15 type CockpitIrRouteCount,
16 type CockpitIrSummary,
17 type CockpitResultView,
18 type CockpitResumeHintView,
19 type CockpitSelectionReason,
20 type CockpitSnapshot,
21 type CockpitStepView,
22 type CockpitTaskView,
23 type CockpitVerifyView,
24 type CockpitWorkUnitView,
25 DONE_LIKE,
26 MAX_FILE_BYTES,
27 MAX_KNOWN_SCHEMA,
28 MODEL_VERSION,
29 TEXT,
30 asInt,
31 crewStaleRef,
32 isFillableSlug,
33 isTruthy,
34 parseIsoMs,
35 sanitizeOrNull,
36 sanitizeText,
37 staleDaysOf,
38} from './model'
39
40/**
41 * loader 需要的唯讀 I/O 埠。由 crew-cockpit.ts 以 `$.fs.*`/`$.session.*`/`$.clock.now` 綁定
42 * (Mods 規定 $ 不能跨 import 傳遞,見 claude plugin validate);測試可用記憶體實作替代。
43 * 這裡刻意沒有 write:loader 不寫任何檔案。
44 */
45export type LoaderPorts = {
46 list: (path: string) => Promise<readonly FsEntry[]>
47 stat: (path: string) => Promise<FsStat>
48 exists: (path: string) => Promise<boolean>
49 read: (path: string) => Promise<string>
50 sessionRoot: () => Promise<string>
51 repo: () => Promise<SessionRepo | null>
52 now: () => Promise<number>
53}
54
55/** loadCockpitSnapshot 的選項。 */
56export type LoadOptions = {
57 /** 上一份 snapshot;用來做 §15 增量重讀。null 表示全量載入。 */
58 previous: CockpitSnapshot | null
59 /** 使用者本 session 選的 task id(§8 優先序 1)。 */
60 userSelectedSlug: string | null
61}
62
63/** repo root 判定結果(§6.1)。 */
64export type RepoRootResult = {
65 root: string | null
66 source: CockpitSnapshot['rootSource']
67}
68
69type Json = Record<string, unknown>
70
71const isObject = (value: unknown): value is Json =>
72 typeof value === 'object' && value !== null && !Array.isArray(value)
73
74const errorText = (error: unknown): string => {
75 if (error instanceof Error) {
76 return error.message
77 }
78 return typeof error === 'string' ? error : String(error)
79}
80
81const isTooLargeError = (error: unknown): boolean => /4\s*MiB|too large|over\s+4|EFBIG/i.test(errorText(error))
82
83// ---------------------------------------------------------------------------
84// 路徑工具(不依賴 Node path)
85// ---------------------------------------------------------------------------
86
87const sepOf = (path: string): string => (path.includes('/') || !path.includes('\\') ? '/' : '\\')
88
89/** 以 path 自身的分隔符號接上子路徑。 */
90export function joinPath(base: string, ...parts: string[]): string {
91 const sep = sepOf(base)
92 const trimmed = base.length > 1 ? base.replace(/[\\/]+$/, '') : base
93 return [trimmed === sep ? '' : trimmed, ...parts].join(sep)
94}
95
96const parentOf = (path: string): string | null => {
97 const sep = sepOf(path)
98 const trimmed = path.replace(/[\\/]+$/, '')
99 const cut = trimmed.lastIndexOf(sep)
100 if (cut < 0) {
101 return null
102 }
103 if (cut === 0) {
104 return trimmed === sep ? null : sep
105 }
106 const parent = trimmed.slice(0, cut)
107 // Windows 磁碟根(C:)保留分隔符號
108 return /^[A-Za-z]:$/.test(parent) ? parent + sep : parent
109}
110
111const samePath = (a: string, b: string): boolean => a.replace(/[\\/]+$/, '') === b.replace(/[\\/]+$/, '')
112
113const isUnder = (child: string, ancestor: string): boolean => {
114 const a = ancestor.replace(/[\\/]+$/, '')
115 const c = child.replace(/[\\/]+$/, '')
116 return c === a || c.startsWith(a + sepOf(ancestor))
117}
118
119const hasSpecDir = async (io: LoaderPorts, dir: string): Promise<boolean> => {
120 try {
121 const path = joinPath(dir, '.spec')
122 // exists 不會 reject:先問,避免缺檔的 stat 進錯誤 log
123 if (!(await io.exists(path))) {
124 return false
125 }
126 const stat = await io.stat(path)
127 return stat.kind === 'dir'
128 } catch {
129 return false
130 }
131}
132
133/** 往上找工作樹根時最多走幾層(防呆,正常 repo 不會這麼深)。 */
134const MAX_ROOT_DEPTH = 64
135
136/** dir 底下是否有 `.git`(主工作樹是目錄、git worktree 是檔案);只問存在與否,不讀內容、不解析 HEAD(§8)。 */
137const hasGitMarker = async (io: LoaderPorts, dir: string): Promise<boolean> => {
138 try {
139 return await io.exists(joinPath(dir, '.git'))
140 } catch {
141 return false
142 }
143}
144
145/**
146 * 包含 start 的工作樹根:從 start(含)往上第一個含 `.git` 的目錄。
147 * 在 git worktree 裡,$.session.repo().root 是「主工作樹」,不是目前所在的 worktree,
148 * 所以只能靠 `.git` 這個標記判定邊界:worktree 根的 `.git` 是檔案,主工作樹的是目錄,兩者都算。
149 * 找不到回 null。
150 */
151const findWorkTreeRoot = async (io: LoaderPorts, start: string): Promise<string | null> => {
152 let current: string | null = start
153 for (let depth = 0; current !== null && depth < MAX_ROOT_DEPTH; depth += 1) {
154 if (await hasGitMarker(io, current)) {
155 return current
156 }
157 current = parentOf(current)
158 }
159 return null
160}
161
162/**
163 * §6.1 repo root:依序檢查,第一個含 .spec/ 目錄者勝出。
164 * 1. $.session.root()(啟動目錄/worktree 位置,不是 git 根)
165 * 2. 從 session root 逐層往上,最多到「目前所在工作樹的根」:
166 * 一般 repo 就是 git 根;在 git worktree 裡則是 worktree 根(含 `.git` 檔案的那層),
167 * 不越過它去讀到主工作樹或更上層的 .spec。找不到工作樹根時,session root 在 git 根之下才以 git 根為界。
168 * 3. $.session.repo()?.root(worktree 時是主工作樹,只能當最後 fallback)
169 * 都沒有 → null(例如多 repo workspace 根目錄;不往下掃 sub-repo)。
170 */
171export async function resolveRepoRoot(io: LoaderPorts): Promise<RepoRootResult> {
172 let sessionRoot: string | null = null
173 try {
174 sessionRoot = await io.sessionRoot()
175 } catch {
176 sessionRoot = null
177 }
178 let gitRoot: string | null = null
179 try {
180 gitRoot = (await io.repo())?.root ?? null
181 } catch {
182 gitRoot = null
183 }
184
185 if (sessionRoot !== null && sessionRoot !== '') {
186 if (await hasSpecDir(io, sessionRoot)) {
187 return { root: sessionRoot, source: 'session-root' }
188 }
189 if (gitRoot !== null && gitRoot !== '') {
190 const isInsideGitRoot = !samePath(sessionRoot, gitRoot) && isUnder(sessionRoot, gitRoot)
191 // 邊界:最近的工作樹根;在 git 根之下卻找不到標記(例如 fs 不給看)時退回 git 根
192 const found = await findWorkTreeRoot(io, sessionRoot)
193 const boundary =
194 found !== null && (!isInsideGitRoot || isUnder(found, gitRoot)) ? found : isInsideGitRoot ? gitRoot : null
195 if (boundary !== null && !samePath(boundary, sessionRoot)) {
196 let current = parentOf(sessionRoot)
197 // 往上走到(含)邊界為止
198 while (current !== null && isUnder(current, boundary)) {
199 if (await hasSpecDir(io, current)) {
200 return { root: current, source: 'ancestor' }
201 }
202 if (samePath(current, boundary)) {
203 break
204 }
205 current = parentOf(current)
206 }
207 if (samePath(boundary, gitRoot)) {
208 // git 根已經檢查過了
209 return { root: null, source: null }
210 }
211 }
212 }
213 }
214 if (gitRoot !== null && gitRoot !== '' && (sessionRoot === null || !samePath(sessionRoot, gitRoot))) {
215 if (await hasSpecDir(io, gitRoot)) {
216 return { root: gitRoot, source: 'git-root' }
217 }
218 }
219 return { root: null, source: null }
220}
221
222// ---------------------------------------------------------------------------
223// tolerant parser:state.json 原貌 → CockpitTaskView
224// ---------------------------------------------------------------------------
225
226const fieldsOf = (value: unknown, skip: readonly string[] = []): CockpitField[] => {
227 if (!isObject(value)) {
228 return []
229 }
230 return Object.entries(value)
231 .filter(([key]) => !skip.includes(key))
232 .map(([key, raw]) => ({ key: sanitizeText(key), value: sanitizeText(raw) }))
233}
234
235const stringList = (value: unknown): string[] =>
236 Array.isArray(value) ? value.map(item => sanitizeText(item)).filter(item => item !== '') : []
237
238const stepsOf = (value: unknown): CockpitStepView[] => {
239 if (!isObject(value)) {
240 return []
241 }
242 return Object.entries(value).map(([key, raw]) => {
243 const entry = isObject(raw) ? raw : {}
244 return {
245 key: sanitizeText(key),
246 status: typeof entry.status === 'string' ? sanitizeOrNull(entry.status) : null,
247 at: sanitizeOrNull(entry.at),
248 reason: sanitizeOrNull(entry.reason),
249 }
250 })
251}
252
253const gatesOf = (value: unknown): CockpitGateView[] | null => {
254 if (!isObject(value)) {
255 return null
256 }
257 return Object.entries(value).map(([key, raw]) => {
258 const entry = isObject(raw) ? raw : {}
259 return {
260 key: sanitizeText(key),
261 status: typeof entry.status === 'string' ? sanitizeOrNull(entry.status) : null,
262 at: sanitizeOrNull(entry.at),
263 by: sanitizeOrNull(entry.by),
264 reason: sanitizeOrNull(entry.reason),
265 }
266 })
267}
268
269const workUnitOf = (value: unknown): CockpitWorkUnitView | null => {
270 if (!isObject(value)) {
271 return null
272 }
273 const done = asInt(value.done)
274 const total = asInt(value.total)
275 return {
276 skill: sanitizeOrNull(value.skill),
277 done,
278 total,
279 label: sanitizeText(value.label),
280 remaining: stringList(value.remaining),
281 isInterrupted: total > 0 && done < total,
282 }
283}
284
285const resumeHintOf = (value: unknown): CockpitResumeHintView | null => {
286 if (!isObject(value)) {
287 return null
288 }
289 const branch = sanitizeOrNull(value.branch)
290 const services = stringList(value.services)
291 const readFirst = stringList(value.read_first)
292 return { branch, services, readFirst, isEmpty: branch === null && services.length === 0 && readFirst.length === 0 }
293}
294
295const resultOf = (value: unknown): CockpitResultView => {
296 if (!isObject(value)) {
297 return { status: null, entries: [], isEmpty: true }
298 }
299 return {
300 status: sanitizeOrNull(value.status),
301 entries: fieldsOf(value, ['status']),
302 isEmpty: Object.keys(value).length === 0,
303 }
304}
305
306const verifyOf = (value: unknown): CockpitVerifyView => {
307 const base = resultOf(value)
308 const hasBlockedKey = isObject(value) && Object.prototype.hasOwnProperty.call(value, 'blocked')
309 return { ...base, blocked: hasBlockedKey ? asInt((value as Json).blocked) : 0, hasBlockedKey }
310}
311
312const nextOf = (value: unknown): CockpitTaskView['recordedNext'] => {
313 if (!isObject(value)) {
314 return null
315 }
316 return { command: sanitizeOrNull(value.command), reason: sanitizeText(value.reason) }
317}
318
319const parkedOf = (value: unknown): CockpitTaskView['parked'] => {
320 if (!isTruthy(value)) {
321 return null
322 }
323 if (isObject(value)) {
324 return { at: sanitizeOrNull(value.at), reason: sanitizeOrNull(value.reason) }
325 }
326 return { at: null, reason: sanitizeOrNull(value) }
327}
328
329const schemaOf = (value: unknown): number | null =>
330 typeof value === 'number' && Number.isFinite(value) ? value : null
331
332/**
333 * state.json(已確定是 JSON 物件)→ task view。純函式,不做 I/O。
334 * @param raw state.json 的物件
335 * @param id .spec 下的目錄名原值(crew-state.py 以目錄名為 slug)
336 * @param statePath state.json 的路徑
337 * @param ir 已讀好的 IR 摘要
338 * @param nowMs 用於停滯天數
339 */
340export function toTaskView(
341 raw: Json,
342 id: string,
343 statePath: string,
344 ir: CockpitIrSummary,
345 nowMs: number,
346): CockpitTaskView {
347 const slug = sanitizeText(id)
348 const schemaVersion = schemaOf(raw.schema_version)
349 const steps = isObject(raw.steps) ? raw.steps : {}
350 const close = isObject(steps.close) ? steps.close : {}
351 const closed = typeof close.status === 'string' && DONE_LIKE.includes(close.status)
352 const parked = parkedOf(raw.parked)
353 const updatedMs = parseIsoMs(raw.updated)
354 const updatedRefMs = updatedMs ?? parseIsoMs(raw.created)
355 // 停滯天數另依 crew-state.py 的 normalize 語意取參考時間(null/缺漏=現在),不沿用排序用的 updatedRefMs
356 const stale = crewStaleRef(raw.updated, raw.created)
357 const git = fieldsOf(raw.git)
358 const name = sanitizeText(raw.name)
359 const type = typeof raw.type === 'string' && raw.type !== '' ? sanitizeText(raw.type) : 'feature'
360 const results = isObject(raw.results) ? raw.results : {}
361
362 return {
363 id,
364 slug,
365 isSlugFillable: isFillableSlug(id),
366 statePath: sanitizeText(statePath),
367 schemaVersion,
368 isSchemaNewer: schemaVersion !== null && schemaVersion > MAX_KNOWN_SCHEMA,
369 name: name === '' ? slug : name,
370 type: type === '' ? 'feature' : type,
371 phase: sanitizeOrNull(raw.phase),
372 inferred: isTruthy(raw.inferred),
373 parked,
374 closed,
375 active: !closed && parked === null,
376 staleDays: staleDaysOf(stale.refMs, nowMs),
377 staleUnknown: stale.isUnknown,
378 staleRefMs: stale.refMs,
379 updated: sanitizeOrNull(raw.updated),
380 created: sanitizeOrNull(raw.created),
381 updatedRefMs,
382 recordedNext: nextOf(raw.next),
383 resumeHint: resumeHintOf(raw.resume_hint),
384 steps: stepsOf(raw.steps),
385 gates: gatesOf(raw.gates),
386 workUnit: workUnitOf(raw.work_unit),
387 results: {
388 verify: verifyOf(results.verify),
389 review: resultOf(results.review),
390 security: resultOf(results.security),
391 },
392 git,
393 branch: isObject(raw.git) ? sanitizeOrNull(raw.git.branch) : null,
394 notion: fieldsOf(raw.notion),
395 deploy: fieldsOf(raw.deploy),
396 verificationIr: ir,
397 }
398}
399
400/**
401 * Verification IR(已確定是可解析的 JSON)→ 摘要。純函式。
402 * route 依 verification_type 的實際值動態分組(§6.4),不寫死類別。
403 */
404export function toIrSummary(raw: unknown): CockpitIrSummary {
405 if (!isObject(raw)) {
406 return { status: 'invalid', message: 'verification-ir.json 不是 JSON 物件' }
407 }
408 const acEntries: [string, unknown][] = isObject(raw.acs)
409 ? Object.entries(raw.acs)
410 : Array.isArray(raw.acs)
411 ? raw.acs.map((item, index) => [isObject(item) && typeof item.id === 'string' ? item.id : `#${index + 1}`, item])
412 : []
413 const acs: CockpitIrAcView[] = acEntries.map(([key, value]) => ({
414 id: sanitizeText(key),
415 verificationType:
416 isObject(value) && typeof value.verification_type === 'string'
417 ? sanitizeOrNull(value.verification_type)
418 : null,
419 }))
420 const counts = new Map<string | null, number>()
421 for (const ac of acs) {
422 counts.set(ac.verificationType, (counts.get(ac.verificationType) ?? 0) + 1)
423 }
424 const routes: CockpitIrRouteCount[] = [...counts.entries()]
425 .map(([verificationType, count]) => ({ verificationType, count }))
426 .sort((a, b) => {
427 if (a.verificationType === null || b.verificationType === null) {
428 return a.verificationType === null ? (b.verificationType === null ? 0 : 1) : -1
429 }
430 return b.count - a.count || (a.verificationType < b.verificationType ? -1 : a.verificationType > b.verificationType ? 1 : 0)
431 })
432 return {
433 status: 'ready',
434 schemaVersion: schemaOf(raw.schema_version),
435 acCount: acs.length,
436 routes,
437 acs,
438 preconditionCount: Array.isArray(raw.preconditions) ? raw.preconditions.length : 0,
439 safetyCount: Array.isArray(raw.safety) ? raw.safety.length : 0,
440 }
441}
442
443// ---------------------------------------------------------------------------
444// 選取(§8)與排序(§12)
445// ---------------------------------------------------------------------------
446
447const byUpdatedDesc = (a: CockpitTaskView, b: CockpitTaskView): number => {
448 const ma = a.updatedRefMs ?? Number.NEGATIVE_INFINITY
449 const mb = b.updatedRefMs ?? Number.NEGATIVE_INFINITY
450 if (ma !== mb) {
451 return mb > ma ? 1 : -1
452 }
453 return a.id < b.id ? -1 : a.id > b.id ? 1 : 0
454}
455
456const groupOf = (task: CockpitTaskView): number => (task.closed ? 2 : task.parked !== null ? 1 : 0)
457
458/** §12 排序:active → parked → closed,各組內 updated 新到舊,同時間依 id。 */
459export function sortTasks(tasks: readonly CockpitTaskView[]): CockpitTaskView[] {
460 return [...tasks].sort((a, b) => groupOf(a) - groupOf(b) || byUpdatedDesc(a, b))
461}
462
463/**
464 * §8 選取優先序:
465 * 1. 使用者本 session 已選、且 task 仍存在 → user
466 * 2. 只有一個 active → only-active
467 * 3. 多個 active → updated 最新者 → latest-active(autoSelected)
468 * 4. 沒有 active → none
469 */
470export function selectTask(
471 tasks: readonly CockpitTaskView[],
472 userSelectedSlug: string | null,
473): { slug: string | null; reason: CockpitSelectionReason } {
474 if (userSelectedSlug !== null && tasks.some(task => task.id === userSelectedSlug)) {
475 return { slug: userSelectedSlug, reason: 'user' }
476 }
477 const active = tasks.filter(task => task.active)
478 if (active.length === 1) {
479 return { slug: active[0]?.id ?? null, reason: 'only-active' }
480 }
481 if (active.length > 1) {
482 return { slug: [...active].sort(byUpdatedDesc)[0]?.id ?? null, reason: 'latest-active' }
483 }
484 return { slug: null, reason: 'none' }
485}
486
487// ---------------------------------------------------------------------------
488// I/O:讀檔與增量重讀(§6.2、§15)
489// ---------------------------------------------------------------------------
490
491type FileProbe = { kind: 'missing' } | { kind: 'other' } | { kind: 'file'; mtimeMs: number; size: number }
492
493/** 同時進行的任務 I/O 上限:每次檔案系統呼叫都是一次 worker 往返,依序 await 會讓 100 個任務的重讀線性變慢(AC-21)。 */
494const IO_CONCURRENCY = 16
495
496/**
497 * 以目錄列表(一次 list)取代逐檔 exists+stat:FsEntry 對一般檔案已帶 size 與 mtimeMs
498 * (與 stat 同一個值)。只有符號連結才補一次 stat 看它指向什麼。
499 * 判定與舊版逐檔 probe 相同:一般檔(或指向一般檔的連結)→ file;目錄等其他種類 → other;
500 * 不存在、斷掉的連結、列不出來(權限)→ missing(與 Path.is_file() 回 False 一致)。
501 */
502const probeEntry = async (
503 io: LoaderPorts,
504 dir: string,
505 entries: readonly FsEntry[] | null,
506 name: string,
507): Promise<FileProbe> => {
508 const entry = entries?.find(item => item.name === name)
509 if (entry === undefined) {
510 return { kind: 'missing' }
511 }
512 if (entry.kind === 'file') {
513 return { kind: 'file', mtimeMs: entry.mtimeMs, size: entry.size }
514 }
515 if (entry.kind === 'other' && entry.isLink) {
516 try {
517 const stat = await io.stat(joinPath(dir, name))
518 return stat.kind === 'file' ? { kind: 'file', mtimeMs: stat.mtimeMs, size: stat.size } : { kind: 'other' }
519 } catch {
520 return { kind: 'missing' }
521 }
522 }
523 return { kind: 'other' }
524}
525
526/** 列目錄;不存在或權限錯誤回 null(呼叫端視為裡面什麼都沒有)。 */
527const listOrNull = async (io: LoaderPorts, dir: string): Promise<readonly FsEntry[] | null> => {
528 try {
529 return await io.list(dir)
530 } catch {
531 return null
532 }
533}
534
535/** 子目錄(或指向目錄的連結)是否存在於列表中;連結交給 list 自己跟隨,失敗時 listOrNull 回 null。 */
536const hasDirEntry = (entries: readonly FsEntry[] | null, name: string): boolean =>
537 entries?.some(item => item.name === name && (item.kind === 'dir' || (item.kind === 'other' && item.isLink))) ?? false
538
539/** 以固定並行數跑完 items;結果依輸入順序回傳。 */
540const mapPool = async <T, R>(items: readonly T[], limit: number, worker: (item: T) => Promise<R>): Promise<R[]> => {
541 const results: R[] = new Array<R>(items.length)
542 let cursor = 0
543 const lane = async (): Promise<void> => {
544 while (cursor < items.length) {
545 const index = cursor
546 cursor += 1
547 results[index] = await worker(items[index] as T)
548 }
549 }
550 await Promise.all(Array.from({ length: Math.min(limit, items.length) }, () => lane()))
551 return results
552}
553
554type ReadJson = { ok: true; value: unknown } | { ok: false; kind: CockpitInvalidTask['kind']; message: string }
555
556const readJson = async (io: LoaderPorts, path: string, size: number): Promise<ReadJson> => {
557 if (size > MAX_FILE_BYTES) {
558 return { ok: false, kind: 'too-large', message: TEXT.fileTooLarge }
559 }
560 let text: string
561 try {
562 text = await io.read(path)
563 } catch (error) {
564 return isTooLargeError(error)
565 ? { ok: false, kind: 'too-large', message: TEXT.fileTooLarge }
566 : { ok: false, kind: 'read', message: sanitizeText(`讀取失敗:${errorText(error)}`) }
567 }
568 try {
569 return { ok: true, value: JSON.parse(text) as unknown }
570 } catch (error) {
571 return { ok: false, kind: 'parse', message: sanitizeText(`${TEXT.invalidState}:${errorText(error)}`) }
572 }
573}
574
575const loadIr = async (
576 io: LoaderPorts,
577 found: FileProbe,
578 path: string,
579 previous: CockpitSnapshot | null,
580 previousIr: CockpitIrSummary | undefined,
581 mtimes: Record<string, number>,
582 sizes: Record<string, number>,
583): Promise<{ ir: CockpitIrSummary; reread: boolean }> => {
584 if (found.kind !== 'file') {
585 return { ir: { status: 'missing' }, reread: false }
586 }
587 mtimes[path] = found.mtimeMs
588 sizes[path] = found.size
589 if (
590 previous !== null &&
591 previousIr !== undefined &&
592 previousIr.status !== 'missing' &&
593 previous.mtimes[path] === found.mtimeMs &&
594 previous.sizes[path] === found.size
595 ) {
596 return { ir: previousIr, reread: false }
597 }
598 const read = await readJson(io, path, found.size)
599 if (!read.ok) {
600 return { ir: { status: 'invalid', message: read.message }, reread: true }
601 }
602 return { ir: toIrSummary(read.value), reread: true }
603}
604
605/** 列出 .spec 第一層的「目錄」名稱(一般檔案如 _index.md 略過;指向目錄的連結算目錄)。 */
606const listSpecDirs = async (io: LoaderPorts, specDir: string): Promise<string[]> => {
607 const entries = await io.list(specDir)
608 const picked = await mapPool(entries, IO_CONCURRENCY, async (entry): Promise<string | null> => {
609 if (entry.kind === 'dir') {
610 return entry.name
611 }
612 if (entry.kind === 'other' && entry.isLink) {
613 try {
614 const stat = await io.stat(joinPath(specDir, entry.name))
615 return stat.kind === 'dir' ? entry.name : null
616 } catch {
617 // 斷掉的連結:略過(與 Path.is_dir() 一致)
618 return null
619 }
620 }
621 return null
622 })
623 return picked.filter((name): name is string => name !== null).sort()
624}
625
626/**
627 * 載入 Cockpit snapshot(§6、§7、§8、§15)。唯讀;任何單檔錯誤都不 throw。
628 * - 只掃 .spec 第一層目錄;缺 state.json 的目錄只計數。
629 * - state.json 壞掉/過大/不是物件 → invalidTasks(/plan-status 不會列出,Cockpit 刻意列出)。
630 * - 增量:state.json 與 IR 的 mtime+size 與上一份相同就沿用舊 view(停滯天數仍以 now 重算)。
631 */
632export async function loadCockpitSnapshot(io: LoaderPorts, options: LoadOptions): Promise<CockpitSnapshot> {
633 const nowMs = await io.now()
634 const errors: CockpitError[] = []
635 const { root, source } = await resolveRepoRoot(io)
636
637 const empty = (extra: Partial<CockpitSnapshot> = {}): CockpitSnapshot => ({
638 modelVersion: MODEL_VERSION,
639 repoRoot: root,
640 rootSource: source,
641 loadedAt: nowMs,
642 tasks: [],
643 invalidTasks: [],
644 untrackedDirCount: 0,
645 activeCount: 0,
646 selectedSlug: null,
647 autoSelected: false,
648 selectionReason: 'none',
649 errors,
650 mtimes: {},
651 sizes: {},
652 stats: { dirs: 0, reread: 0, reused: 0 },
653 ...extra,
654 })
655
656 if (root === null) {
657 return empty()
658 }
659
660 const previous =
661 options.previous !== null &&
662 options.previous.modelVersion === MODEL_VERSION &&
663 options.previous.repoRoot === root
664 ? options.previous
665 : null
666 const previousTasks = new Map((previous?.tasks ?? []).map(task => [task.id, task]))
667
668 const specDir = joinPath(root, '.spec')
669 let dirs: string[]
670 try {
671 dirs = await listSpecDirs(io, specDir)
672 } catch (error) {
673 errors.push({ scope: 'spec-dir', path: sanitizeText(specDir), message: sanitizeText(errorText(error)) })
674 return empty()
675 }
676
677 // 每個任務的結果先各自收好,再依 dirs 順序合併:並行完成的先後不影響 snapshot 內容與順序。
678 type TaskOutcome = {
679 task: CockpitTaskView | null
680 invalid: CockpitInvalidTask | null
681 untracked: boolean
682 reread: number
683 reused: number
684 mtimes: Record<string, number>
685 sizes: Record<string, number>
686 }
687
688 const loadOne = async (id: string): Promise<TaskOutcome> => {
689 const taskDir = joinPath(specDir, id)
690 const statePath = joinPath(taskDir, 'state.json')
691 const cacheDir = joinPath(taskDir, '.cache')
692 const irPath = joinPath(cacheDir, 'verification-ir.json')
693 const out: TaskOutcome = { task: null, invalid: null, untracked: false, reread: 0, reused: 0, mtimes: {}, sizes: {} }
694 try {
695 // 一次 list 同時取得 state.json 的 mtime/size 與 .cache 是否存在
696 const entries = await listOrNull(io, taskDir)
697 const found = await probeEntry(io, taskDir, entries, 'state.json')
698 if (found.kind !== 'file') {
699 out.untracked = true
700 return out
701 }
702 out.mtimes[statePath] = found.mtimeMs
703 out.sizes[statePath] = found.size
704 const before = previousTasks.get(id)
705 // 沒有 .cache 目錄就不必再問 IR(與舊版逐檔 probe 結果相同:missing)
706 const irFound: FileProbe = hasDirEntry(entries, '.cache')
707 ? await probeEntry(io, cacheDir, await listOrNull(io, cacheDir), 'verification-ir.json')
708 : { kind: 'missing' }
709 const { ir, reread: irReread } = await loadIr(io, irFound, irPath, previous, before?.verificationIr, out.mtimes, out.sizes)
710 if (irReread) {
711 out.reread += 1
712 }
713 const isSame =
714 before !== undefined &&
715 previous !== null &&
716 previous.mtimes[statePath] === found.mtimeMs &&
717 previous.sizes[statePath] === found.size
718 if (isSame && before !== undefined) {
719 out.reused += 1
720 out.task = { ...before, staleDays: staleDaysOf(before.staleRefMs, nowMs), verificationIr: ir }
721 return out
722 }
723 out.reread += 1
724 const read = await readJson(io, statePath, found.size)
725 if (!read.ok) {
726 out.invalid = { id, slug: sanitizeText(id), statePath: sanitizeText(statePath), kind: read.kind, message: read.message }
727 return out
728 }
729 if (!isObject(read.value)) {
730 out.invalid = { id, slug: sanitizeText(id), statePath: sanitizeText(statePath), kind: 'not-object', message: TEXT.notObject }
731 return out
732 }
733 out.task = toTaskView(read.value, id, statePath, ir, nowMs)
734 return out
735 } catch (error) {
736 // 任何未預期錯誤只影響這一筆
737 out.task = null
738 out.invalid = {
739 id,
740 slug: sanitizeText(id),
741 statePath: sanitizeText(statePath),
742 kind: 'read',
743 message: sanitizeText(errorText(error)),
744 }
745 return out
746 }
747 }
748
749 const outcomes = await mapPool(dirs, IO_CONCURRENCY, loadOne)
750
751 const mtimes: Record<string, number> = {}
752 const sizes: Record<string, number> = {}
753 const tasks: CockpitTaskView[] = []
754 const invalidTasks: CockpitInvalidTask[] = []
755 let untracked = 0
756 let reread = 0
757 let reused = 0
758 for (const outcome of outcomes) {
759 Object.assign(mtimes, outcome.mtimes)
760 Object.assign(sizes, outcome.sizes)
761 if (outcome.untracked) {
762 untracked += 1
763 }
764 reread += outcome.reread
765 reused += outcome.reused
766 if (outcome.task !== null) {
767 tasks.push(outcome.task)
768 }
769 if (outcome.invalid !== null) {
770 invalidTasks.push(outcome.invalid)
771 }
772 }
773
774 const sorted = sortTasks(tasks)
775 const selection = selectTask(sorted, options.userSelectedSlug)
776 return empty({
777 tasks: sorted,
778 invalidTasks,
779 untrackedDirCount: untracked,
780 activeCount: sorted.filter(task => task.active).length,
781 selectedSlug: selection.slug,
782 autoSelected: selection.reason === 'latest-active',
783 selectionReason: selection.reason,
784 mtimes,
785 sizes,
786 stats: { dirs: dirs.length, reread, reused },
787 })
788}
789hooks/cockpit/model.ts 381 lines1// CREW Cockpit 讀取模型:型別 re-export、常數與「純函式」工具。
2// 本檔不做任何 I/O,也不呼叫 $。型別本體定義在 types/index.d.ts($.state 契約)。
3
4export type {
5 CockpitError,
6 CockpitField,
7 CockpitGateView,
8 CockpitInvalidTask,
9 CockpitIrAcView,
10 CockpitIrRouteCount,
11 CockpitIrSummary,
12 CockpitResultView,
13 CockpitResumeHintView,
14 CockpitRuntime,
15 CockpitSelectionReason,
16 CockpitSnapshot,
17 CockpitStepView,
18 CockpitTab,
19 CockpitTaskLayout,
20 CockpitTaskView,
21 CockpitVerifyView,
22 CockpitWorkUnitView,
23} from '../../types'
24
25/** 讀取模型版本;改動 snapshot 形狀時遞增,loader 會丟棄舊版快取。 */
26export const MODEL_VERSION = 2
27
28/** Pane id(§10)。 */
29export const PANE_ID = 'crew-cockpit'
30
31/** 指令名(§10)。 */
32export const COMMAND_NAME = 'crew-cockpit'
33
34/** 最低支援的 Claude Code 版本(§19)。 */
35export const MIN_CLAUDE_CODE_VERSION = '2.1.289'
36
37/** 測試過的最高 state schema(§16)。 */
38export const MAX_KNOWN_SCHEMA = 2
39
40/** $.fs.read 單檔上限 4 MiB(§6.2)。 */
41export const MAX_FILE_BYTES = 4 * 1024 * 1024
42
43/** HUD 單一欄位上限(§18.1)。 */
44export const HUD_FIELD_MAX = 60
45
46/** Pane 欄位上限(§18.1);snapshot 內所有顯示字串都已截到這個長度。 */
47export const PANE_FIELD_MAX = 200
48
49/** Fill 代入 slug 的白名單(§14)。 */
50export const SLUG_PATTERN = /^[a-z0-9][a-z0-9._-]{0,79}$/
51
52/** 結案判定用(CS DONE_LIKE)。 */
53export const DONE_LIKE: readonly string[] = ['done', 'skipped']
54
55const DAY_MS = 24 * 60 * 60 * 1000
56
57/** UI 文字常數表(§17.5):標籤繁中,識別字保留原文。 */
58export const TEXT = {
59 paneTitle: 'CREW',
60 tabOverview: '總覽',
61 tabTasks: '任務',
62 tabVerify: '驗收',
63 progress: '進度',
64 approval: '核准閘',
65 results: '結果',
66 workUnit: '工作單元',
67 resumeHint: '接續提示',
68 recordedNext: '上次記錄的建議(快照)',
69 recordedNextShort: '上次建議',
70 refresh: '重新整理',
71 fill: (slug: string) => `填入 /plan-next ${slug}`,
72 stale: (days: number) => `停滯 ${days} 天`,
73 staleUnknown: '(時間無法解析)',
74 autoSelected: '自動選取',
75 loadedAt: (hhmm: string) => `載入於 ${hhmm}`,
76 irMissing: '尚未產生 E2E 候選',
77 irReady: '已就緒',
78 irInvalid: '無法解析',
79 irInvalidHint: 'Verification IR:檔案無法解析\n重新執行 /plan-verify 可重新產生。',
80 blockedNote: (n: number) => `BLOCKED ×${n}:驗收前置條件尚未就緒,不代表產品 FAIL`,
81 truthSeparator: 'Runtime PASS ≠ E2E ci-ready ≠ 人工 UAT',
82 /** §17.5/§9 HUD 範例:UAT gate 為 pending 時顯示「UAT 待核准」;其餘狀態值保留原文(識別字)。 */
83 uatPendingShort: 'UAT 待核准',
84 v1NoGates: '舊版格式(schema v1),無核准閘資料',
85 noTasks: '此 repo 沒有 CREW 任務。',
86 noTasksHint: '用 /plan-start 或 /bug-start 開始一個。',
87 invalidState: 'state.json 無法解析',
88 invalidStateNote: '/plan-status 不會列出此筆',
89 fileTooLarge: '檔案過大(超過 4 MiB)',
90 notObject: 'state.json 不是 JSON 物件',
91 untracked: (n: number) => `另有 ${n} 個 .spec 目錄沒有 state.json(舊格式或未初始化),未列入。`,
92 schemaNewer: (v: number) => `state schema v${v} 比 Cockpit 測試過的 v2 新,部分欄位可能未顯示`,
93 slugNotFillable: 'slug 含不支援的字元,請手動執行 /plan-next',
94 needVersion: (min: string) => `需要 Claude Code ≥ ${min}`,
95 otherActive: (n: number) => `+${n} 個進行中`,
96 paneOpened: '已開啟 CREW Cockpit。',
97 paneNotPlaced: 'Cockpit 將在畫面寬度足夠時顯示',
98 usage: '用法:/crew-cockpit',
99 // ---- 以下為 Batch 3–6 新增(只增不改)----
100 currentTask: '目前任務',
101 branchNote: '(取自 state.git,非即時 git 狀態)',
102 interrupted: (done: number, total: number) => `中斷於 ${done}/${total}`,
103 readFirst: '先讀',
104 services: '服務',
105 inferred: '⚠ phase 為推導值(inferred)',
106 inferredShort: '⚠ inferred',
107 // 對齊 crew-state.py 用語(park=「擱置任務」、list 標記「已擱置」)
108 parked: '已擱置',
109 closedTask: '已結案',
110 verifyLabel: '驗收',
111 otherBlocked: (n: number) => `另有 BLOCKED ×${n}`,
112 runtimeVerify: 'Runtime Verify',
113 verificationIr: 'Verification IR',
114 irUntyped: '(未標 verification_type)',
115 tasksTruncated: (n: number) => `只顯示前 ${n} 筆`,
116 noSelection: '目前沒有選取的任務;到「任務」分頁選一個。',
117 noVerifyResult: '尚無驗收結果',
118 hudOn: '已開啟 CREW HUD。',
119 hudOff: '已關閉 CREW HUD(/crew-cockpit hud on 可重新開啟)。',
120 usageFull: '用法:/crew-cockpit 開啟面板;/crew-cockpit hud on|off 開關 HUD',
121 draftExists: (command: string) => `輸入框有未送出的內容;指令:${command}`,
122 fillRefused: (command: string) => `無法填入輸入框,請自行輸入:${command}`,
123 filledKeepPane: (command: string) => `已填入 ${command}:按 Esc 或點一下輸入框回到輸入框,再按 Enter 送出`,
124 loadErrors: '載入問題',
125 schemaUnknown: 'schema ?',
126 schema: (v: number) => `schema v${v}`,
127 selected: '▶',
128 // ---- 以下為分組色帶樣式新增(只增不改)----
129 groupActive: (n: number) => `● 進行中 ${n}`,
130 groupParked: (n: number) => `◐ 已擱置 ${n}`,
131 groupClosed: (n: number) => `○ 已結案 ${n}`,
132 closedVerifyCount: (n: number, status: string) => `其中 ${n} 項驗收 ${status}`,
133 closedRecent: (n: number) => `最近 ${n} 筆`,
134 expandClosed: '展開全部',
135 collapseClosed: '收合',
136 invalidMark: '✕',
137 gatePending: '(待核准)',
138 // ---- 以下為儀表板總覽新增(只增不改)----
139 statusBox: '狀態',
140 staleBox: '停滯',
141 verifyBox: '驗收',
142 staleDaysBig: (days: number) => `${days} 天`,
143 lastUpdated: (time: string) => `最後更新 ${time}`,
144 phaseInProgress: (phase: string) => `${phase} 進行中`,
145 gatePendingShort: '待核准',
146 gatesNone: '—',
147 workUnitInterrupted: (done: number, total: number) => `⚠ 工作單元中斷於 ${done}/${total}`,
148 recordedNextSnapshot: '上次建議(快照)',
149 otherActiveList: (n: number) => `另有 ${n} 個進行中:`,
150 otherActiveItem: (slug: string, days: number) => `${slug}(停滯 ${days} 天)`,
151 moreItems: (n: number) => `+${n}`,
152 // ---- 以下為任務卡片版面新增(只增不改)----
153 layoutLabel: '版面:',
154 layoutList: '列表',
155 layoutCards: '卡片',
156 pendingGates: (keys: string) => `核准閘待核准:${keys}`,
157 closedSummary: (n: number) => `○ 已結案 ${n} 項`,
158 // ---- 以下為總覽結案按鈕新增(只增不改)----
159 fillClose: (slug: string) => `填入 /plan-close ${slug}`,
160} as const
161
162// ---------------------------------------------------------------------------
163// §18.1 不可信字串清理
164// ---------------------------------------------------------------------------
165
166// ANSI:CSI(ESC [ …)、OSC(ESC ] … BEL 或 ESC \)、其他 2 字元 ESC 序列,以及 8-bit CSI(\x9b …)。
167const ANSI_CSI = /\x1b\[[0-?]*[ -/]*[@-~]/g
168const ANSI_OSC = /\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)?/g
169const ANSI_OTHER = /\x1b[@-Z\\-_]/g
170const C1_CSI = /\x9b[0-?]*[ -/]*[@-~]/g
171// 剩餘的 C0(含 ESC 殘渣)、DEL、C1 控制字元,以及零寬與方向控制字元(§18.1):
172// U+200B–U+200F(零寬空白/ZWNJ/ZWJ/LRM/RLM)、U+202A–U+202E(bidi 嵌入與覆寫)、
173// U+2060–U+2069(word joiner、不可見運算子、bidi isolate)、U+FEFF(BOM/ZWNBSP)、U+061C(阿拉伯字母標記)。
174const CONTROL = /[\x00-\x1f\x7f-\x9f\u200b-\u200f\u202a-\u202e\u2060-\u2069\ufeff\u061c]/g
175const LINE_BREAKS = /[\r\n\t\v\f\u0085\u2028\u2029]+/g
176
177/**
178 * 把任意值轉成可安全顯示的一行字串(§18.1):
179 * 去 ANSI escape 與 C0/C1 控制字元、換行壓成空白、連續空白收斂、截斷並以 … 結尾。
180 * 非字串:number/boolean 轉字串;物件與陣列以 JSON 表示;null/undefined 為空字串。
181 */
182export function sanitizeText(value: unknown, max: number = PANE_FIELD_MAX): string {
183 let text: string
184 if (value === null || value === undefined) {
185 text = ''
186 } else if (typeof value === 'string') {
187 text = value
188 } else if (typeof value === 'number' || typeof value === 'boolean') {
189 text = String(value)
190 } else {
191 try {
192 text = JSON.stringify(value) ?? ''
193 } catch {
194 text = ''
195 }
196 }
197 const cleaned = text
198 .replace(ANSI_OSC, '')
199 .replace(ANSI_CSI, '')
200 .replace(C1_CSI, '')
201 .replace(ANSI_OTHER, '')
202 .replace(LINE_BREAKS, ' ')
203 .replace(CONTROL, '')
204 .replace(/ {2,}/g, ' ')
205 .trim()
206 return clip(cleaned, max)
207}
208
209/** 已清理的字串截到 max 個字元(以 code point 計),超過以 … 結尾。 */
210export function clip(text: string, max: number): string {
211 const chars = Array.from(text)
212 if (chars.length <= max) {
213 return text
214 }
215 if (max <= 1) {
216 return '…'.slice(0, Math.max(0, max))
217 }
218 return chars.slice(0, max - 1).join('') + '…'
219}
220
221/** 清理後若為空字串則回 null;原值為 null/undefined 也回 null。 */
222export function sanitizeOrNull(value: unknown, max: number = PANE_FIELD_MAX): string | null {
223 if (value === null || value === undefined) {
224 return null
225 }
226 const text = sanitizeText(value, max)
227 return text === '' ? null : text
228}
229
230/** §14 slug 白名單。 */
231export function isFillableSlug(id: string): boolean {
232 return SLUG_PATTERN.test(id)
233}
234
235// ---------------------------------------------------------------------------
236// 與 crew-state.py 對齊的判定(§8,不得自創)
237// ---------------------------------------------------------------------------
238
239/** Python truthiness(parked、inferred 用)。 */
240export function isTruthy(value: unknown): boolean {
241 if (value === null || value === undefined || value === false) {
242 return false
243 }
244 if (typeof value === 'number') {
245 return value !== 0 && !Number.isNaN(value)
246 }
247 if (typeof value === 'string') {
248 return value.length > 0
249 }
250 if (Array.isArray(value)) {
251 return value.length > 0
252 }
253 if (typeof value === 'object') {
254 return Object.keys(value as object).length > 0
255 }
256 return true
257}
258
259/**
260 * crew-state.py as_int 語意:int(value),失敗回 default。
261 * number 取整數部分(int(2.7) == 2);boolean 為 1/0;字串允許前後空白、正負號與底線分隔的十進位整數。
262 */
263export function asInt(value: unknown, fallback: number = 0): number {
264 if (typeof value === 'boolean') {
265 return value ? 1 : 0
266 }
267 if (typeof value === 'number') {
268 return Number.isFinite(value) ? Math.trunc(value) : fallback
269 }
270 if (typeof value === 'string') {
271 const trimmed = value.trim()
272 if (/^[+-]?\d+(?:_\d+)*$/.test(trimmed)) {
273 const parsed = Number.parseInt(trimmed.replace(/_/g, ''), 10)
274 return Number.isFinite(parsed) ? parsed : fallback
275 }
276 }
277 return fallback
278}
279
280const ISO_PATTERN =
281 /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2})(?::(\d{2})(?::(\d{2})(?:[.,](\d{1,9}))?)?)?)?(Z|[+-]\d{2}(?::?\d{2})?)?$/
282
283/**
284 * crew-state.py parse_iso 的近似:datetime.fromisoformat;不帶時區的值視為本機時間。
285 * 回傳 epoch ms;無法解析回 null。只接受 ISO 8601 的日期/日期時間形式,不用 Date.parse 猜格式。
286 */
287export function parseIsoMs(value: unknown): number | null {
288 if (typeof value !== 'string' || value === '') {
289 return null
290 }
291 const match = ISO_PATTERN.exec(value.trim())
292 if (!match) {
293 return null
294 }
295 const [, y, mo, d, h, mi, s, frac, zone] = match
296 const year = Number(y)
297 const month = Number(mo)
298 const day = Number(d)
299 const hour = h === undefined ? 0 : Number(h)
300 const minute = mi === undefined ? 0 : Number(mi)
301 const second = s === undefined ? 0 : Number(s)
302 const ms = frac === undefined ? 0 : Math.floor(Number(`0.${frac}`) * 1000)
303 if (month < 1 || month > 12 || day < 1 || day > 31 || hour > 23 || minute > 59 || second > 59) {
304 return null
305 }
306 if (zone === undefined) {
307 const local = new Date(year, month - 1, day, hour, minute, second, ms)
308 if (local.getFullYear() !== year || local.getMonth() !== month - 1 || local.getDate() !== day) {
309 return null
310 }
311 return local.getTime()
312 }
313 const utc = Date.UTC(year, month - 1, day, hour, minute, second, ms)
314 const check = new Date(utc)
315 if (check.getUTCFullYear() !== year || check.getUTCMonth() !== month - 1 || check.getUTCDate() !== day) {
316 return null
317 }
318 if (zone === 'Z') {
319 return utc
320 }
321 const sign = zone.startsWith('-') ? -1 : 1
322 const digits = zone.slice(1).replace(':', '')
323 const offH = Number(digits.slice(0, 2))
324 const offM = digits.length > 2 ? Number(digits.slice(2, 4)) : 0
325 if (offH > 23 || offM > 59) {
326 return null
327 }
328 return utc - sign * (offH * 60 + offM) * 60 * 1000
329}
330
331/**
332 * 停滯天數的參考時間,逐步重現 crew-state.py 的 normalize() + stale_days()(CS:299-312、1364-1369):
333 * 1. normalize 只把「null 或缺漏」的 updated/created 補成現在(空字串、壞字串原樣保留);
334 * 2. stale_days 取 parse_iso(updated) or parse_iso(created)。
335 * 因此 updated 為 null/缺漏 → 參考值是現在(0 天),不會退到 created;
336 * updated 解析失敗才看 created,created 為 null/缺漏同樣是現在(0 天);兩者都解析失敗 → 0 天且 isUnknown。
337 * refMs 為 null 代表 crew-state.py 會算出 0 天(不論是「現在」還是無法解析),增量重讀時仍是 0。
338 */
339export function crewStaleRef(updated: unknown, created: unknown): { refMs: number | null; isUnknown: boolean } {
340 const isFilledByNormalize = (value: unknown) => value === null || value === undefined
341 if (isFilledByNormalize(updated)) {
342 return { refMs: null, isUnknown: false }
343 }
344 const updatedMs = parseIsoMs(updated)
345 if (updatedMs !== null) {
346 return { refMs: updatedMs, isUnknown: false }
347 }
348 if (isFilledByNormalize(created)) {
349 return { refMs: null, isUnknown: false }
350 }
351 const createdMs = parseIsoMs(created)
352 return createdMs !== null ? { refMs: createdMs, isUnknown: false } : { refMs: null, isUnknown: true }
353}
354
355/** CS stale_days:max(0, floor((now − ref) / 1 day));ref 為 null 時 0。 */
356export function staleDaysOf(refMs: number | null, nowMs: number): number {
357 if (refMs === null) {
358 return 0
359 }
360 return Math.max(0, Math.floor((nowMs - refMs) / DAY_MS))
361}
362
363/** "2.1.291" 這類版本字串比較;無法解析的段視為 0。a<b 負、相等 0、a>b 正。 */
364export function compareVersions(a: string, b: string): number {
365 const parts = (v: string) =>
366 v
367 .split(/[^0-9]+/)
368 .filter(p => p !== '')
369 .slice(0, 3)
370 .map(p => Number(p))
371 const pa = parts(a)
372 const pb = parts(b)
373 for (let i = 0; i < 3; i += 1) {
374 const diff = (pa[i] ?? 0) - (pb[i] ?? 0)
375 if (diff !== 0) {
376 return diff
377 }
378 }
379 return 0
380}
381hooks/cockpit/render.ts 896 lines1// CREW Cockpit render:Pane(總覽/任務/驗收)與 AbovePrompt HUD 的 element tree。
2// 規則(§3 D-3):純函式,只把 snapshot 轉成 element tree;不做 I/O、不碰引擎介面、不寫 state。
3// 呼叫端(crew-cockpit.ts 的 ui.render hook)負責解析元素表、讀 state,
4// 並把「按下按鈕要做的事」以 callback 傳進來(Mods 規定引擎介面只能出現在入口檔)。
5// 所有顯示字串都取自 snapshot(loader 已依 §18.1 清理並截到 200 字);HUD 欄位再截到 60 字。
6
7import type { Elements, RenderElement } from 'claude-code'
8
9import { type CockpitInvalidTask, type CockpitSnapshot, type CockpitTab, type CockpitTaskLayout, type CockpitTaskView, TEXT } from './model'
10import {
11 TONE_COLOR,
12 type FillKind,
13 type Tone,
14 closeCommandFor,
15 fillCommandFor,
16 formatClock,
17 gateColor,
18 gateDisplay,
19 healthColor,
20 metricLayout,
21 hudText,
22 otherActiveCount,
23 phaseColor,
24 progressGlyphs,
25 progressOf,
26 resolveSelection,
27 shortTime,
28 showsStale,
29 staleColor,
30 taskGroups,
31 toneOf,
32 typeColor,
33 verifyColor,
34 verifyDisplay,
35 visibleGates,
36} from './selectors'
37
38/** render 需要的元素(terminal 與 desktop 都有的 baseline,§17.1)。 */
39export type CockpitKit = Pick<Elements['terminal'], 'Box' | 'Text' | 'Button'>
40
41/** Pane 按鈕要做的事:由入口檔綁定(只改 UI state、或填入固定模板),render 只負責接線。 */
42export type PaneCallbacks = {
43 selectTab: (tab: CockpitTab) => void | Promise<void>
44 selectTask: (id: string) => void | Promise<void>
45 refresh: () => void | Promise<void>
46 /**
47 * 以 task id 與模板種類觸發 Fill;入口檔自行以 fillTemplateFor 重算內容,不吃 render 給的字串。
48 * kind 缺省為 next(`/plan-next {slug}`);close 為 `/plan-close {slug}`。
49 */
50 fill: (id: string, kind?: FillKind) => void | Promise<void>
51 /** 任務 tab「已結案」分組展開/收合(只改 UI state)。 */
52 toggleClosed: () => void | Promise<void>
53 /** 任務 tab 版面切換(UI state+$.store 偏好;不寫任何 project file)。 */
54 setLayout: (layout: CockpitTaskLayout) => void | Promise<void>
55}
56
57export type PaneData = {
58 snapshot: CockpitSnapshot | null
59 /** state 的 selectedSlug(使用者本 session 的選擇)。 */
60 selectedSlug: string | null
61 tab: CockpitTab
62 /** e.props.bodyColumns(§17.2,不假設固定寬度)。 */
63 bodyColumns: number
64 /** 任務 tab「已結案」分組是否展開(runtime.isClosedExpanded)。 */
65 isClosedExpanded: boolean
66 /** 任務 tab 版面(runtime.taskLayout;缺值為列表)。 */
67 taskLayout: CockpitTaskLayout
68}
69
70export type HudData = {
71 snapshot: CockpitSnapshot | null
72 selectedSlug: string | null
73 /** e.props.bodyColumns;< 60 退成 1 行(§9.1)。 */
74 bodyColumns: number
75}
76
77/** Tasks tab 最多顯示幾筆(§12)。 */
78export const TASK_ROW_LIMIT = 50
79
80/** HUD 窄畫面門檻(§9.1)。 */
81export const HUD_COMPACT_COLUMNS = 60
82
83/** Pane 窄畫面門檻:低於此寬度,Tasks 列改成上下兩行。 */
84export const PANE_COMPACT_COLUMNS = 60
85
86/** resume_hint 陣列最多顯示幾項(§11)。 */
87const HINT_ITEMS = 3
88
89/** IR 的 AC 清單最多列幾筆,避免 pane 過長(§13)。 */
90const IR_AC_LIMIT = 20
91
92/** 總覽「另有 N 個進行中」最多列幾個 slug。 */
93const OTHER_ACTIVE_LIMIT = 5
94
95/** 已結案分組收合時顯示幾筆(依 loader 既有排序,最近更新在前)。 */
96export const CLOSED_PREVIEW = 5
97
98const STEP_GLYPH: Record<string, string> = {
99 done: '✓',
100 skipped: '↷',
101 in_progress: '●',
102 pending: '○',
103 failed: '✕',
104}
105
106type Child = RenderElement | string | null | undefined | false
107
108/** h 的薄包裝:濾掉 null/false 子節點(JSX 的條件渲染語意)。 */
109function el(tag: unknown, props: Record<string, unknown>, ...children: Child[]): RenderElement {
110 const kept = children.filter((child): child is RenderElement | string => child !== null && child !== undefined && child !== false)
111 return h(tag as never, props, ...kept) as RenderElement
112}
113
114/** 一行文字;tone 決定顏色(§11.3),neutral 不上色。 */
115function line(kit: CockpitKit, text: string, tone: Tone = 'neutral', extra: Record<string, unknown> = {}): RenderElement {
116 return colored(kit, text, TONE_COLOR[tone], extra)
117}
118
119/** 指定 ThemeKey 的文字;color 為 undefined 時不傳 color(不支援的 prop 不傳)。 */
120function colored(kit: CockpitKit, text: string, color: string | undefined, extra: Record<string, unknown> = {}): RenderElement {
121 return el(kit.Text, { ...(color !== undefined && { color }), ...extra }, text)
122}
123
124const dim = (kit: CockpitKit, text: string): RenderElement => el(kit.Text, { dimColor: true }, text)
125
126const heading = (kit: CockpitKit, text: string): RenderElement => el(kit.Text, { bold: true }, text)
127
128const column = (kit: CockpitKit, props: Record<string, unknown>, ...children: Child[]): RenderElement =>
129 el(kit.Box, { flexDirection: 'column', ...props }, ...children)
130
131const row = (kit: CockpitKit, ...children: Child[]): RenderElement => el(kit.Box, { flexDirection: 'row', flexWrap: 'wrap' }, ...children)
132
133const section = (kit: CockpitKit, title: string, ...children: Child[]): RenderElement =>
134 column(kit, { marginTop: 1 }, heading(kit, title), ...children)
135
136/** 列表最多 n 項,其餘以「+N」表示(§11 resume_hint)。 */
137function limited(items: readonly string[], n: number): string {
138 const shown = items.slice(0, n).join(', ')
139 return items.length > n ? `${shown} +${items.length - n}` : shown
140}
141
142const schemaText = (task: CockpitTaskView): string =>
143 task.schemaVersion === null ? TEXT.schemaUnknown : TEXT.schema(task.schemaVersion)
144
145/** 結果狀態顏色:缺值(—)inactive,其餘依 §11.3 色調。 */
146const statusColor = (status: string | null): string | undefined => (status === null ? 'inactive' : TONE_COLOR[toneOf(status)])
147
148const staleText = (task: CockpitTaskView): string => TEXT.stale(task.staleDays) + (task.staleUnknown ? TEXT.staleUnknown : '')
149
150/** 進度方塊(■ success、□ inactive)+ done/total;超過格數上限只顯示計數。 */
151function progressBar(kit: CockpitKit, task: CockpitTaskView, withCount: boolean): RenderElement[] {
152 const glyphs = progressGlyphs(task)
153 return [
154 glyphs.filled !== '' ? colored(kit, glyphs.filled, 'success') : null,
155 glyphs.empty !== '' ? colored(kit, glyphs.empty, 'inactive') : null,
156 withCount || (glyphs.filled === '' && glyphs.empty === '') ? el(kit.Text, { dimColor: true }, `${glyphs.filled !== '' || glyphs.empty !== '' ? ' ' : ''}${glyphs.count}`) : null,
157 ].filter((part): part is RenderElement => part !== null)
158}
159
160// ---------------------------------------------------------------------------
161// Pane
162// ---------------------------------------------------------------------------
163
164/** Pane 主體(§10):tab 列+目前 tab 的內容+載入時間。 */
165export function paneView(kit: CockpitKit, data: PaneData, cb: PaneCallbacks): RenderElement {
166 const { snapshot } = data
167 if (
168 snapshot === null ||
169 snapshot.repoRoot === null ||
170 (snapshot.tasks.length === 0 && snapshot.invalidTasks.length === 0 && snapshot.untrackedDirCount === 0)
171 ) {
172 // §16:載入錯誤(例如 .spec 列不出來)必須看得到,不能被「沒有任務」蓋掉
173 const errors = snapshot?.errors ?? []
174 return column(
175 kit,
176 {},
177 errors.length > 0 ? heading(kit, TEXT.loadErrors) : el(kit.Text, {}, TEXT.noTasks),
178 errors.length > 0 ? errorRows(kit, errors) : dim(kit, TEXT.noTasksHint),
179 el(kit.Box, { marginTop: 1 }, el(kit.Button, { key: 'refresh', label: TEXT.refresh, hotkey: 'r', onPress: () => cb.refresh() })),
180 )
181 }
182
183 const tabs: readonly { tab: CockpitTab; label: string; hotkey: string }[] = [
184 { tab: 'overview', label: TEXT.tabOverview, hotkey: '1' },
185 { tab: 'tasks', label: TEXT.tabTasks, hotkey: '2' },
186 { tab: 'verify', label: TEXT.tabVerify, hotkey: '3' },
187 ]
188 const tabBar = el(
189 kit.Box,
190 { flexDirection: 'row', flexWrap: 'wrap', gap: 1 },
191 ...tabs.map(item =>
192 el(kit.Button, {
193 key: `tab-${item.tab}`,
194 label: item.label,
195 hotkey: item.hotkey,
196 ...(item.tab === data.tab && { variant: 'primary' }),
197 onPress: () => cb.selectTab(item.tab),
198 }),
199 ),
200 el(kit.Button, { key: 'refresh', label: TEXT.refresh, hotkey: 'r', onPress: () => cb.refresh() }),
201 )
202
203 const selection = resolveSelection(snapshot, data.selectedSlug)
204 const body =
205 data.tab === 'tasks'
206 ? tasksView(kit, snapshot, selection.task, data, cb)
207 : data.tab === 'verify'
208 ? verifyView(kit, selection.task)
209 : overviewView(kit, snapshot, selection, data.bodyColumns, cb)
210
211 return column(
212 kit,
213 {},
214 tabBar,
215 body,
216 snapshot.errors.length > 0 && section(kit, TEXT.loadErrors, errorRows(kit, snapshot.errors)),
217 el(kit.Box, { marginTop: 1 }, dim(kit, TEXT.loadedAt(formatClock(snapshot.loadedAt)))),
218 )
219}
220
221/** 載入錯誤列(path 與 message 已由 loader 依 §18.1 清理),警示色。 */
222function errorRows(kit: CockpitKit, errors: CockpitSnapshot['errors']): RenderElement {
223 return column(kit, {}, ...errors.map(error => line(kit, `${error.path ?? error.scope} · ${error.message}`, 'warning')))
224}
225
226/** 膠囊:底色+對比文字(ThemeKey),文字本身說明內容。 */
227function capsule(kit: CockpitKit, text: string, backgroundColor: string): RenderElement {
228 return el(kit.Text, { backgroundColor, color: 'inverseText', bold: true }, ` ${text} `)
229}
230
231/** 圓角框:框色依語意(ThemeKey)。 */
232function card(kit: CockpitKit, props: Record<string, unknown>, ...children: Child[]): RenderElement {
233 return el(kit.Box, { flexDirection: 'column', borderStyle: 'round', paddingX: 1, ...props }, ...children)
234}
235
236/** 指標方塊內的大字(terminal 沒有字級,以粗體表示)。 */
237const big = (kit: CockpitKit, text: string, color: string | undefined): RenderElement => colored(kit, text, color, { bold: true })
238
239/**
240 * step 膠囊(每種狀態都有底色,文字符號本身也說明狀態,不單靠顏色):
241 * - done:success 底「✓ build」
242 * - skipped:inactive 底+inverseText、不加粗「↷ db」(與完成明顯區分)
243 * - failed:error 底「✕ verify」
244 * - 目前 phase 或 in_progress:suggestion 底、粗體「● security」
245 * - 其他(pending/未知值):subtle 深灰底+text 一般亮度、不 dim「○ verify」
246 * 底色選擇:subtle 在深色主題是深灰、淺色主題是淺灰,配 text(深色主題白字/淺色主題黑字)兩邊都可讀;
247 * inactive 配 text 在兩種主題都是中灰對亮/暗字,對比不足,故 pending 不用 inactive。
248 */
249function stepCapsule(kit: CockpitKit, task: CockpitTaskView, step: CockpitTaskView['steps'][number]): RenderElement {
250 const status = step.status ?? ''
251 if (status === 'done') {
252 return capsule(kit, `${STEP_GLYPH.done} ${step.key}`, 'success')
253 }
254 if (status === 'skipped') {
255 return el(kit.Text, { backgroundColor: 'inactive', color: 'inverseText' }, ` ${STEP_GLYPH.skipped} ${step.key} `)
256 }
257 if (status === 'failed') {
258 return capsule(kit, `${STEP_GLYPH.failed} ${step.key}`, 'error')
259 }
260 if (step.key === task.phase || status === 'in_progress') {
261 return capsule(kit, `${STEP_GLYPH.in_progress} ${step.key}`, 'suggestion')
262 }
263 return el(kit.Text, { backgroundColor: 'subtle', color: 'text' }, ` ${STEP_GLYPH[status] ?? '?'} ${step.key} `)
264}
265
266/** 指標方塊:進度。 */
267function progressMetric(kit: CockpitKit, task: CockpitTaskView): Child[] {
268 const progress = progressOf(task)
269 const state = task.closed ? TEXT.closedTask : task.parked !== null ? `${task.phase ?? '—'} · ${TEXT.parked}` : TEXT.phaseInProgress(task.phase ?? '—')
270 return [
271 big(kit, `${progress.done} / ${progress.total}`, undefined),
272 row(kit, ...progressBar(kit, task, false)),
273 colored(kit, state, phaseColor(task.phase)),
274 ]
275}
276
277/** 指標方塊:驗收(verify/review/security,BLOCKED 衍生規則沿用 verifyDisplay)。 */
278function verifyMetric(kit: CockpitKit, task: CockpitTaskView): Child[] {
279 const verify = verifyDisplay(task.results.verify)
280 return [
281 row(kit, colored(kit, `verify ${verify.label}`, verifyColor(verify)), verify.note !== null && line(kit, ` · ${verify.note}`, 'blocked')),
282 colored(kit, `review ${task.results.review.status ?? '—'}`, statusColor(task.results.review.status)),
283 colored(kit, `security ${task.results.security.status ?? '—'}`, statusColor(task.results.security.status)),
284 ]
285}
286
287/** 指標方塊:核准閘(依 type 篩選;v1 無 gates 只顯示「—」,不顯示 pending,規格 A2)。 */
288function gateMetric(kit: CockpitKit, task: CockpitTaskView): Child[] {
289 const gates = visibleGates(task)
290 if (gates === null) {
291 return [big(kit, TEXT.gatesNone, 'inactive'), dim(kit, TEXT.v1NoGates)]
292 }
293 if (gates.length === 0) {
294 return [big(kit, TEXT.gatesNone, 'inactive')]
295 }
296 return gates.map(gate => {
297 const shown = gateDisplay(gate)
298 return colored(kit, shown.text, shown.color)
299 })
300}
301
302/** 指標方塊:停滯(只對進行中);已結案/已擱置改顯示狀態文字。 */
303function staleMetric(kit: CockpitKit, task: CockpitTaskView): Child[] {
304 const updated = dim(kit, TEXT.lastUpdated(shortTime(task.updated)))
305 if (task.closed) {
306 return [colored(kit, `○ ${TEXT.closedTask}`, 'inactive', { bold: true }), updated]
307 }
308 if (task.parked !== null) {
309 return [colored(kit, `◐ ${TEXT.parked}`, 'merged', { bold: true }), updated]
310 }
311 return [
312 big(kit, TEXT.staleDaysBig(task.staleDays), staleColor(task.staleDays)),
313 task.staleUnknown && dim(kit, TEXT.staleUnknown),
314 updated,
315 ]
316}
317
318/** 方塊框色:進度依 phase、驗收依驗收結果、核准閘依最差的 gate、停滯依天數/狀態。 */
319function gateBoxColor(task: CockpitTaskView): string {
320 const gates = visibleGates(task) ?? []
321 if (gates.some(gate => gate.status === 'rejected')) {
322 return 'error'
323 }
324 if (gates.length > 0 && gates.every(gate => gate.status === 'approved')) {
325 return 'success'
326 }
327 return 'inactive'
328}
329
330/** 四個等寬指標方塊;寬度不足時折成 2×2,再窄單欄堆疊(依 bodyColumns)。 */
331function metricRows(kit: CockpitKit, task: CockpitTaskView, bodyColumns: number): RenderElement[] {
332 const layout = metricLayout(bodyColumns)
333 const verify = verifyDisplay(task.results.verify)
334 const metrics: { key: string; title: string; color: string | undefined; body: Child[] }[] = [
335 { key: 'progress', title: TEXT.progress, color: phaseColor(task.phase), body: progressMetric(kit, task) },
336 { key: 'verify', title: TEXT.verifyBox, color: verifyColor(verify) ?? 'inactive', body: verifyMetric(kit, task) },
337 { key: 'gates', title: TEXT.approval, color: gateBoxColor(task), body: gateMetric(kit, task) },
338 {
339 key: 'stale',
340 title: task.active ? TEXT.staleBox : TEXT.statusBox,
341 color: task.closed ? 'inactive' : task.parked !== null ? 'merged' : staleColor(task.staleDays),
342 body: staleMetric(kit, task),
343 },
344 ]
345 const rows: RenderElement[] = []
346 for (let start = 0; start < metrics.length; start += layout.perRow) {
347 rows.push(
348 el(
349 kit.Box,
350 { key: `metric-row-${start / layout.perRow}`, flexDirection: 'row', gap: 1, marginTop: 1 },
351 ...metrics
352 .slice(start, start + layout.perRow)
353 .map(metric =>
354 card(kit, { key: `metric-${metric.key}`, width: layout.width, ...(metric.color !== undefined && { borderColor: metric.color }) }, dim(kit, metric.title), ...metric.body),
355 ),
356 ),
357 )
358 }
359 return rows
360}
361
362/** §11 Overview(儀表板):標題卡 → 指標方塊 → 步驟流程 → 工作單元警示 → 上次建議+Fill → 其他進行中。 */
363function overviewView(
364 kit: CockpitKit,
365 snapshot: CockpitSnapshot,
366 selection: ReturnType<typeof resolveSelection>,
367 bodyColumns: number,
368 cb: PaneCallbacks,
369): RenderElement {
370 const task = selection.task
371 if (task === null) {
372 return column(kit, { marginTop: 1 }, el(kit.Text, {}, TEXT.noSelection))
373 }
374
375 // 1. 標題卡:slug+右側膠囊(type、phase、停滯);名稱;schema 與 branch(取自 state.git)
376 const typeBg = typeColor(task.type) ?? 'inactive'
377 const titleCard = card(
378 kit,
379 { key: 'title-card', marginTop: 1, borderColor: healthColor(task) },
380 el(
381 kit.Box,
382 { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 1 },
383 row(kit, el(kit.Text, { bold: true }, task.slug), selection.autoSelected && dim(kit, ` (${TEXT.autoSelected})`)),
384 el(
385 kit.Box,
386 { flexDirection: 'row', flexWrap: 'wrap', gap: 1 },
387 capsule(kit, task.type, typeBg),
388 capsule(kit, task.phase ?? '—', phaseColor(task.phase)),
389 showsStale(task) && capsule(kit, staleText(task), staleColor(task.staleDays)),
390 ),
391 ),
392 task.name !== task.slug && dim(kit, task.name),
393 task.parked !== null && colored(kit, `◐ ${TEXT.parked}${task.parked.reason !== null ? `:${task.parked.reason}` : ''}`, 'merged'),
394 task.inferred && line(kit, TEXT.inferred, 'warning'),
395 task.isSchemaNewer && task.schemaVersion !== null && line(kit, TEXT.schemaNewer(task.schemaVersion), 'warning'),
396 dim(kit, [schemaText(task), task.branch !== null ? `branch: ${task.branch} ${TEXT.branchNote}` : null].filter(part => part !== null).join(' · ')),
397 )
398
399 // 3. 步驟流程:每個實際存在的 step 一顆膠囊(不寫死步數)
400 const steps = el(kit.Box, { key: 'steps', flexDirection: 'row', flexWrap: 'wrap', gap: 1, marginTop: 1 }, ...task.steps.map(step => stepCapsule(kit, task, step)))
401
402 // 4. 工作單元警示:只在中斷(total > 0 且 done < total)時出現;完成或空的不顯示(§11、A8)
403 const unit = task.workUnit
404 const hint = task.resumeHint
405 const hintParts =
406 hint === null || hint.isEmpty
407 ? []
408 : [
409 hint.branch !== null ? `branch: ${hint.branch}` : null,
410 hint.services.length > 0 ? `${TEXT.services}: ${limited(hint.services, HINT_ITEMS)}` : null,
411 hint.readFirst.length > 0 ? `${TEXT.readFirst}: ${limited(hint.readFirst, HINT_ITEMS)}` : null,
412 ].filter((part): part is string => part !== null)
413 const unitAlert =
414 unit !== null &&
415 unit.isInterrupted &&
416 card(
417 kit,
418 { key: 'work-unit', marginTop: 1, borderColor: 'warning' },
419 line(kit, `${TEXT.workUnitInterrupted(unit.done, unit.total)}${unit.label !== '' ? ` · ${unit.label}` : ''}`, 'warning'),
420 hintParts.length > 0 && el(kit.Text, {}, `${TEXT.resumeHint} ${hintParts.join(' · ')}`),
421 )
422
423 // 5. 上次建議(快照)+Fill(只填 /plan-next {slug})+結案 Fill(只填 /plan-close {slug};非 primary,不搶下一步的主視覺)
424 const fill = fillCommandFor(task)
425 const close = closeCommandFor(task)
426 const recorded = task.recordedNext
427 const footer = column(
428 kit,
429 { marginTop: 1 },
430 recorded !== null &&
431 row(kit, dim(kit, `${TEXT.recordedNextSnapshot} `), recorded.command !== null && el(kit.Text, {}, recorded.command)),
432 recorded !== null && recorded.reason !== '' && dim(kit, recorded.reason),
433 el(
434 kit.Box,
435 { marginTop: recorded !== null ? 1 : 0, flexDirection: 'row', flexWrap: 'wrap', gap: 1 },
436 fill !== null
437 ? el(kit.Button, { key: 'fill', label: TEXT.fill(task.slug), variant: 'primary', hotkey: 'f', onPress: () => cb.fill(task.id) })
438 : line(kit, TEXT.slugNotFillable, 'warning'),
439 close !== null && el(kit.Button, { key: 'fill-close', label: TEXT.fillClose(task.slug), onPress: () => cb.fill(task.id, 'close') }),
440 ),
441 )
442
443 // 6. 其他進行中任務:一行摘要,按 slug 只切換 selected(UI state)
444 const others = snapshot.tasks.map((item, index) => ({ item, index })).filter(({ item }) => item.active && item.id !== task.id)
445 const shownOthers = others.slice(0, OTHER_ACTIVE_LIMIT)
446 const othersLine =
447 others.length > 0 &&
448 el(
449 kit.Box,
450 { key: 'others', flexDirection: 'row', flexWrap: 'wrap', gap: 1, marginTop: 1 },
451 dim(kit, TEXT.otherActiveList(others.length)),
452 ...shownOthers.map(({ item, index }) =>
453 el(kit.Button, {
454 // key 用序號,不用目錄名(目錄名是不可信的 repo 字串)
455 key: `other-${index}`,
456 label: TEXT.otherActiveItem(item.slug, item.staleDays),
457 plain: true,
458 onPress: () => cb.selectTask(item.id),
459 }),
460 ),
461 others.length > OTHER_ACTIVE_LIMIT && dim(kit, TEXT.moreItems(others.length - OTHER_ACTIVE_LIMIT)),
462 )
463
464 return column(kit, {}, titleCard, ...metricRows(kit, task, bodyColumns), steps, unitAlert, footer, othersLine)
465}
466
467/** 任務列屬於哪一段:決定欄位與樣式。 */
468type TaskGroup = 'active' | 'parked' | 'closed'
469
470/** 分組色帶:整列底色+對比文字(ThemeKey,深淺主題自動切換),文字本身就說明狀態。 */
471function groupBand(kit: CockpitKit, text: string, backgroundColor: string, ...extra: Child[]): RenderElement {
472 return el(
473 kit.Box,
474 { flexDirection: 'row', flexWrap: 'wrap', marginTop: 1, backgroundColor, gap: 1 },
475 el(kit.Text, { color: 'inverseText', bold: true }, ` ${text} `),
476 ...extra,
477 )
478}
479
480/**
481 * 一列任務。進行中:▶ slug、type、phase、進度方塊、驗收、停滯天數、時間;
482 * 已擱置:同上但不顯示停滯天數、加「已擱置」標記;已結案:整列 dim(驗收結果仍上色)、不顯示停滯天數與進度,
483 * 也不重複「已結案」字樣(所在色帶已表達)。
484 */
485function taskRow(
486 kit: CockpitKit,
487 task: CockpitTaskView,
488 index: number,
489 group: TaskGroup,
490 isSelected: boolean,
491 isCompact: boolean,
492 cb: PaneCallbacks,
493): RenderElement {
494 const isClosed = group === 'closed'
495 const verify = verifyDisplay(task.results.verify)
496 // 已結案整列降成 dim;只有驗收結果維持語意色(WARN/FAIL 不漏看)
497 const tint = (text: string, color: string | undefined): RenderElement =>
498 isClosed ? el(kit.Text, { dimColor: true }, text) : colored(kit, text, color)
499 const sep = (): RenderElement => el(kit.Text, { dimColor: true }, ' · ')
500 const button = el(kit.Button, {
501 // key 用序號,不用目錄名(目錄名是不可信的 repo 字串)
502 key: `task-${index}`,
503 label: `${isSelected ? `${TEXT.selected} ` : ''}${task.slug}`,
504 ...(isSelected && { variant: 'primary' }),
505 ...(isClosed && !isSelected && { dimColor: true }),
506 onPress: () => cb.selectTask(task.id),
507 })
508 const info = row(
509 kit,
510 isCompact ? null : el(kit.Text, {}, ' '),
511 tint(task.type, typeColor(task.type)),
512 sep(),
513 tint(task.phase ?? '—', phaseColor(task.phase)),
514 group === 'parked' && sep(),
515 group === 'parked' && colored(kit, TEXT.parked, 'merged'),
516 // 已結案:色帶已表達狀態,列內不再重複「已結案」字樣
517 !isClosed && sep(),
518 ...(isClosed ? [] : progressBar(kit, task, true)),
519 sep(),
520 el(kit.Text, { dimColor: isClosed }, `${TEXT.verifyLabel} `),
521 colored(kit, verify.label, verifyColor(verify)),
522 showsStale(task) && sep(),
523 showsStale(task) && colored(kit, staleText(task), staleColor(task.staleDays), { bold: task.staleDays >= 14 }),
524 sep(),
525 el(kit.Text, { dimColor: true }, shortTime(task.updated)),
526 )
527 const props = isSelected ? { backgroundColor: 'subtle' } : {}
528 return isCompact ? column(kit, props, button, info) : el(kit.Box, { flexDirection: 'row', flexWrap: 'wrap', ...props }, button, info)
529}
530
531/** 任務 tab 頁首:「版面:列表|卡片」,目前選中者 primary;v 切到另一個版面。 */
532function layoutBar(kit: CockpitKit, current: CockpitTaskLayout, cb: PaneCallbacks): RenderElement {
533 const items: readonly { layout: CockpitTaskLayout; label: string }[] = [
534 { layout: 'list', label: TEXT.layoutList },
535 { layout: 'cards', label: TEXT.layoutCards },
536 ]
537 return el(
538 kit.Box,
539 { key: 'layout-bar', flexDirection: 'row', flexWrap: 'wrap', gap: 1, marginTop: 1 },
540 dim(kit, TEXT.layoutLabel),
541 ...items.map(item =>
542 el(kit.Button, {
543 key: `layout-${item.layout}`,
544 label: item.label,
545 ...(item.layout === current ? { variant: 'primary' } : { hotkey: 'v' }),
546 onPress: () => cb.setLayout(item.layout),
547 }),
548 ),
549 )
550}
551
552/**
553 * §12 Tasks:頁首版面切換+列表(方向 B 分組色帶)或卡片(方向 C)。
554 * 兩種版面共用選取狀態與 e 展開狀態;壞檔列與缺 state.json 的計數維持在底部。
555 */
556function tasksView(kit: CockpitKit, snapshot: CockpitSnapshot, selected: CockpitTaskView | null, data: PaneData, cb: PaneCallbacks): RenderElement {
557 const body = data.taskLayout === 'cards' ? taskCards(kit, snapshot, selected, data, cb) : taskList(kit, snapshot, selected, data, cb)
558 return column(
559 kit,
560 {},
561 layoutBar(kit, data.taskLayout, cb),
562 ...body,
563 snapshot.tasks.length > TASK_ROW_LIMIT && dim(kit, TEXT.tasksTruncated(TASK_ROW_LIMIT)),
564 ...snapshot.invalidTasks.map(invalid => invalidRow(kit, invalid)),
565 snapshot.untrackedDirCount > 0 && el(kit.Box, { marginTop: 1 }, dim(kit, TEXT.untracked(snapshot.untrackedDirCount))),
566 )
567}
568
569type IndexedTask = { task: CockpitTaskView; index: number }
570
571/** 兩種版面共用的分段(最多 50 筆,保持 loader 排序;index 是 snapshot 序號,當按鈕 key)。 */
572function taskSections(snapshot: CockpitSnapshot) {
573 const shown: IndexedTask[] = snapshot.tasks.slice(0, TASK_ROW_LIMIT).map((task, index) => ({ task, index }))
574 const groups = taskGroups(snapshot.tasks)
575 const countBy = (status: string) => groups.closed.filter(task => task.results.verify.status === status).length
576 // 結案摘要(借 C):WARN/FAIL 不漏看,也不會誤以為要處理
577 const verifyNotes = [
578 countBy('WARN') > 0 ? TEXT.closedVerifyCount(countBy('WARN'), 'WARN') : null,
579 countBy('FAIL') > 0 ? TEXT.closedVerifyCount(countBy('FAIL'), 'FAIL') : null,
580 ].filter((note): note is string => note !== null)
581 return {
582 groups,
583 activeRows: shown.filter(({ task }) => task.active),
584 parkedRows: shown.filter(({ task }) => !task.closed && task.parked !== null),
585 closedRows: shown.filter(({ task }) => task.closed),
586 verifyNotes,
587 }
588}
589
590/** 已結案展開/收合鈕(e)。 */
591function closedToggle(kit: CockpitKit, isExpanded: boolean, cb: PaneCallbacks): RenderElement {
592 return el(kit.Button, {
593 key: 'toggle-closed',
594 label: isExpanded ? TEXT.collapseClosed : TEXT.expandClosed,
595 hotkey: 'e',
596 plain: true,
597 onPress: () => cb.toggleClosed(),
598 })
599}
600
601/** 列表版(方向 B 分組色帶):● 進行中 → ◐ 已擱置 → ○ 已結案;已結案預設只顯示最近 5 筆,按 e 展開。 */
602function taskList(kit: CockpitKit, snapshot: CockpitSnapshot, selected: CockpitTaskView | null, data: PaneData, cb: PaneCallbacks): Child[] {
603 const isCompact = data.bodyColumns < PANE_COMPACT_COLUMNS
604 const { groups, activeRows, parkedRows, closedRows, verifyNotes } = taskSections(snapshot)
605 const isSelected = (task: CockpitTaskView) => selected !== null && selected.id === task.id
606 const rowsOf = (group: TaskGroup, items: readonly IndexedTask[]) =>
607 items.map(({ task, index }) => taskRow(kit, task, index, group, isSelected(task), isCompact, cb))
608 const closedVisible = data.isClosedExpanded ? closedRows : closedRows.slice(0, CLOSED_PREVIEW)
609 const closedNotes = [...verifyNotes, closedRows.length > CLOSED_PREVIEW && !data.isClosedExpanded ? TEXT.closedRecent(CLOSED_PREVIEW) : null].filter(
610 (note): note is string => note !== null,
611 )
612
613 return [
614 groupBand(kit, TEXT.groupActive(groups.active.length), 'suggestion'),
615 ...rowsOf('active', activeRows),
616 // 已擱置為 0:只顯示色帶,不顯示空列
617 groupBand(kit, TEXT.groupParked(groups.parked.length), 'merged'),
618 ...rowsOf('parked', parkedRows),
619 groupBand(kit, [TEXT.groupClosed(groups.closed.length), ...closedNotes].join(' · '), 'inactive', closedRows.length > CLOSED_PREVIEW && closedToggle(kit, data.isClosedExpanded, cb)),
620 ...rowsOf('closed', closedVisible),
621 ]
622}
623
624/**
625 * 卡片版(方向 C):進行中與已擱置每個任務一張 round 卡(框色依健康度);
626 * 已結案不出卡,只顯示一行摘要,按 e 展開時列出與列表版相同的結案列(全部)。
627 */
628function taskCards(kit: CockpitKit, snapshot: CockpitSnapshot, selected: CockpitTaskView | null, data: PaneData, cb: PaneCallbacks): Child[] {
629 const isCompact = data.bodyColumns < PANE_COMPACT_COLUMNS
630 const { groups, activeRows, parkedRows, closedRows, verifyNotes } = taskSections(snapshot)
631 const isSelected = (task: CockpitTaskView) => selected !== null && selected.id === task.id
632 const cardsOf = (items: readonly IndexedTask[]) => items.map(({ task, index }) => taskCard(kit, task, index, isSelected(task), cb))
633
634 return [
635 groupBand(kit, TEXT.groupActive(groups.active.length), 'suggestion'),
636 ...cardsOf(activeRows),
637 groupBand(kit, TEXT.groupParked(groups.parked.length), 'merged'),
638 ...cardsOf(parkedRows),
639 closedRows.length > 0 &&
640 el(
641 kit.Box,
642 { key: 'closed-summary', flexDirection: 'row', flexWrap: 'wrap', gap: 1, marginTop: 1 },
643 colored(kit, [TEXT.closedSummary(groups.closed.length), ...verifyNotes].join(' · '), 'inactive'),
644 closedToggle(kit, data.isClosedExpanded, cb),
645 ),
646 ...(data.isClosedExpanded ? closedRows.map(({ task, index }) => taskRow(kit, task, index, 'closed', isSelected(task), isCompact, cb)) : []),
647 ]
648}
649
650/**
651 * 一張任務卡(三行):(a) ▶ slug(按下=選取)+右側 type 膠囊;(b) phase、進度方塊+計數、驗收、停滯、時間;
652 * (c) 未決核准閘摘要或「上次建議」+該卡的 Fill(只填 /plan-next {slug};slug 不合白名單不給按鈕)。
653 */
654function taskCard(kit: CockpitKit, task: CockpitTaskView, index: number, isSelected: boolean, cb: PaneCallbacks): RenderElement {
655 const verify = verifyDisplay(task.results.verify)
656 const sep = (): RenderElement => el(kit.Text, { dimColor: true }, ' · ')
657 const pending = (visibleGates(task) ?? []).filter(gate => gate.status === 'pending').map(gate => gate.key)
658 const recorded = task.recordedNext?.command ?? null
659 const fill = fillCommandFor(task)
660 const note =
661 pending.length > 0
662 ? colored(kit, TEXT.pendingGates(pending.join(', ')), 'inactive')
663 : recorded !== null
664 ? dim(kit, `${TEXT.recordedNextShort} ${recorded}`)
665 : null
666
667 return card(
668 kit,
669 { key: `card-${index}`, marginTop: 1, borderColor: healthColor(task), ...(isSelected && { backgroundColor: 'subtle' }) },
670 el(
671 kit.Box,
672 { flexDirection: 'row', flexWrap: 'wrap', justifyContent: 'space-between', gap: 1 },
673 el(kit.Button, {
674 // key 用序號,不用目錄名(目錄名是不可信的 repo 字串);與列表版同 key,選取行為一致
675 key: `task-${index}`,
676 label: `${isSelected ? `${TEXT.selected} ` : ''}${task.slug}`,
677 ...(isSelected && { variant: 'primary' }),
678 onPress: () => cb.selectTask(task.id),
679 }),
680 capsule(kit, task.type, typeColor(task.type) ?? 'inactive'),
681 ),
682 row(
683 kit,
684 colored(kit, task.phase ?? '—', phaseColor(task.phase)),
685 task.parked !== null && sep(),
686 task.parked !== null && colored(kit, TEXT.parked, 'merged'),
687 sep(),
688 ...progressBar(kit, task, true),
689 sep(),
690 el(kit.Text, {}, `${TEXT.verifyLabel} `),
691 colored(kit, verify.label, verifyColor(verify)),
692 showsStale(task) && sep(),
693 showsStale(task) && colored(kit, staleText(task), staleColor(task.staleDays), { bold: task.staleDays >= 14 }),
694 sep(),
695 el(kit.Text, { dimColor: true }, shortTime(task.updated)),
696 ),
697 (note !== null || fill !== null) &&
698 el(
699 kit.Box,
700 { flexDirection: 'row', flexWrap: 'wrap', gap: 1 },
701 note,
702 fill !== null && el(kit.Button, { key: `fill-${index}`, label: TEXT.fill(task.slug), onPress: () => cb.fill(task.id) }),
703 ),
704 )
705}
706
707function invalidRow(kit: CockpitKit, invalid: CockpitInvalidTask): RenderElement {
708 return column(
709 kit,
710 { marginTop: 1 },
711 colored(kit, `${TEXT.invalidMark} ${invalid.slug} · ${TEXT.invalidState}`, 'error', { bold: true }),
712 dim(kit, `${invalid.statePath} · ${invalid.message}(${TEXT.invalidStateNote})`),
713 )
714}
715
716/** §13 Verify:Runtime Verify(含 BLOCKED 衍生顯示)+Verification IR 摘要+語意分隔。 */
717function verifyView(kit: CockpitKit, task: CockpitTaskView | null): RenderElement {
718 if (task === null) {
719 return column(kit, { marginTop: 1 }, el(kit.Text, {}, TEXT.noSelection), el(kit.Text, { bold: true }, TEXT.truthSeparator))
720 }
721
722 const verify = verifyDisplay(task.results.verify)
723 const runtime: Child[] = task.results.verify.isEmpty
724 ? [dim(kit, TEXT.noVerifyResult)]
725 : [
726 row(kit, el(kit.Text, {}, 'status '), colored(kit, verify.label, verifyColor(verify)), verify.note !== null && line(kit, ` · ${verify.note}`, 'blocked')),
727 ...task.results.verify.entries.map(entry => el(kit.Text, {}, `${entry.key} ${entry.value}`)),
728 verify.isBlocked && line(kit, TEXT.blockedNote(verify.blocked), 'blocked'),
729 ]
730
731 const ir = task.verificationIr
732 const irRows: Child[] =
733 ir.status === 'missing'
734 ? [dim(kit, `IR ${TEXT.irMissing}`)]
735 : ir.status === 'invalid'
736 ? [...TEXT.irInvalidHint.split('\n').map(text => line(kit, text, 'warning')), dim(kit, ir.message)]
737 : [
738 line(kit, `IR ${TEXT.irReady}`, 'positive'),
739 el(kit.Text, {}, `ACs ${ir.acCount}`),
740 el(kit.Text, {}, `Routes ${ir.routes.map(route => `${route.verificationType ?? TEXT.irUntyped} ${route.count}`).join(' · ') || '—'}`),
741 el(kit.Text, {}, `Preconditions ${ir.preconditionCount}`),
742 el(kit.Text, {}, `Safety ${ir.safetyCount}`),
743 ...ir.acs.slice(0, IR_AC_LIMIT).map(ac => dim(kit, `${ac.id} ${ac.verificationType ?? TEXT.irUntyped}`)),
744 ir.acs.length > IR_AC_LIMIT && dim(kit, `+${ir.acs.length - IR_AC_LIMIT}`),
745 ]
746
747 return column(
748 kit,
749 { marginTop: 1 },
750 el(kit.Text, { bold: true }, task.slug),
751 section(kit, TEXT.runtimeVerify, ...runtime),
752 section(kit, TEXT.verificationIr, ...irRows),
753 el(kit.Box, { marginTop: 1 }, el(kit.Text, { bold: true }, TEXT.truthSeparator)),
754 )
755}
756
757// ---------------------------------------------------------------------------
758// AbovePrompt HUD(§9)
759// ---------------------------------------------------------------------------
760
761/**
762 * HUD 一行的片段:text 與樣式。color/backgroundColor 一律是 ThemeKey;
763 * tone 是 §11.3 的語意色調(color 未指定時套用)。
764 */
765export type HudSegment = { text: string; tone: Tone; color?: string; backgroundColor?: string; bold?: boolean; dim?: boolean }
766
767/**
768 * HUD 的內容(純資料):沒有 active task 或沒有選取時回 null(§9.1:不佔空間)。
769 * 寬畫面最多 2 行:CREW 色塊 → slug → type/phase(上色)→ 進度方塊 → 中斷 → 驗收 → 停滯天數(上色,只對進行中)
770 * → +N 個進行中;第二行 UAT、上次建議(dim)、載入時間、權威入口。bodyColumns < 60 退成 1 行。
771 * state.next 一律標「上次建議」,不是現況(§6.3)。不顯示 ci-ready(§9.3、AC-11)。
772 */
773export function hudModel(data: HudData): HudSegment[][] | null {
774 const { snapshot } = data
775 if (snapshot === null || snapshot.activeCount === 0) {
776 return null
777 }
778 const task = resolveSelection(snapshot, data.selectedSlug).task
779 if (task === null) {
780 return null
781 }
782 const others = otherActiveCount(snapshot, task)
783 const verify = verifyDisplay(task.results.verify)
784 const hasVerify = task.results.verify.status !== null
785 const seg = (text: string, tone: Tone = 'neutral', style: Omit<HudSegment, 'text' | 'tone'> = {}): HudSegment => ({ text, tone, ...style })
786 const sep = seg(' · ')
787 const slug = hudText(task.slug)
788 const phase = hudText(task.phase ?? '—')
789 const chip = seg(TEXT.paneTitle, 'neutral', { backgroundColor: 'suggestion', color: 'inverseText', bold: true })
790 const phaseSeg = seg(phase, 'neutral', { color: phaseColor(task.phase) })
791 const verifySeg = seg(hudText(verify.short), verify.tone, { color: verifyColor(verify) })
792
793 if (data.bodyColumns < HUD_COMPACT_COLUMNS) {
794 return [
795 [
796 chip,
797 sep,
798 seg(slug, 'neutral', { bold: true }),
799 sep,
800 phaseSeg,
801 ...(hasVerify ? [sep, verifySeg] : []),
802 ...(others > 0 ? [seg(` · +${others}`, 'neutral', { color: 'suggestion' })] : []),
803 ],
804 ]
805 }
806
807 const unit = task.workUnit
808 const glyphs = progressGlyphs(task)
809 const hasGlyphs = glyphs.filled !== '' || glyphs.empty !== ''
810 const typeTone = typeColor(task.type)
811 const first: HudSegment[] = [
812 chip,
813 sep,
814 seg(slug, 'neutral', { bold: true }),
815 sep,
816 seg(hudText(task.type), 'neutral', typeTone !== undefined ? { color: typeTone } : {}),
817 seg(' / '),
818 phaseSeg,
819 sep,
820 ...(hasGlyphs
821 ? [
822 ...(glyphs.filled !== '' ? [seg(glyphs.filled, 'neutral', { color: 'success' })] : []),
823 ...(glyphs.empty !== '' ? [seg(glyphs.empty, 'neutral', { color: 'inactive' })] : []),
824 ]
825 : [seg(glyphs.count, 'neutral', { dim: true })]),
826 ...(unit !== null && unit.isInterrupted ? [seg(` · ${TEXT.interrupted(unit.done, unit.total)}`, 'warning')] : []),
827 ...(hasVerify ? [seg(` · ${TEXT.verifyLabel} `), seg(hudText(verify.label), verify.tone, { color: verifyColor(verify) })] : []),
828 ...(verify.note !== null ? [seg(` · ${verify.note}`, 'blocked')] : []),
829 ...(showsStale(task) ? [sep, seg(TEXT.stale(task.staleDays), 'neutral', { color: staleColor(task.staleDays) })] : []),
830 ...(others > 0 ? [sep, seg(TEXT.otherActive(others), 'neutral', { color: 'suggestion' })] : []),
831 ...(task.inferred ? [seg(` · ${TEXT.inferredShort}`, 'warning')] : []),
832 ]
833
834 const uat = (visibleGates(task) ?? []).find(gate => gate.key === 'uat')
835 const recorded = task.recordedNext?.command ?? null
836 const fill = fillCommandFor(task)
837 const parts: HudSegment[] = [
838 ...(uat !== undefined
839 ? [seg(uat.status === 'pending' ? TEXT.uatPendingShort : `UAT ${hudText(uat.status ?? '—')}`, 'neutral', { color: gateColor(uat.status) ?? 'text' })]
840 : []),
841 ...(recorded !== null ? [seg(`${TEXT.recordedNextShort} ${hudText(recorded)}`, 'neutral', { dim: true })] : []),
842 seg(TEXT.loadedAt(formatClock(snapshot.loadedAt)), 'neutral', { dim: true }),
843 ...(fill !== null ? [seg(fill)] : []),
844 ]
845 const second = parts.flatMap((part, index) => (index === 0 ? [part] : [seg(' · ', 'neutral', { dim: true }), part]))
846
847 return [first, second]
848}
849
850/** HUD 分隔線的 key(測試以此辨識;線不是文字欄位,不受 §9 的 ≤60 字限制)。 */
851export const HUD_DIVIDER_KEY = 'hud-divider'
852
853/**
854 * HUD 上方的分隔線。Box 不支援單邊框,改畫一行 ─:長度取 bodyColumns(不寫死),
855 * 並以 truncate-end 保底,視窗縮小時不會換行。低調色(promptBorder+dim)與輸入框框線協調。
856 */
857function hudDivider(kit: CockpitKit, bodyColumns: number): RenderElement {
858 const width = Number.isFinite(bodyColumns) && bodyColumns > 0 ? Math.floor(bodyColumns) : 1
859 return el(
860 kit.Box,
861 { key: HUD_DIVIDER_KEY, flexDirection: 'column' },
862 colored(kit, '─'.repeat(width), 'promptBorder', { wrap: 'truncate-end', dimColor: true }),
863 )
864}
865
866/** HUD 片段轉成 element(最上方一條分隔線,其後每行一個 row Box,單行截斷)。 */
867export function hudView(kit: CockpitKit, lines: readonly HudSegment[][], bodyColumns: number): RenderElement {
868 return column(
869 kit,
870 {},
871 hudDivider(kit, bodyColumns),
872 ...lines.map(segments =>
873 el(
874 kit.Box,
875 { flexDirection: 'row' },
876 ...segments.map(segment =>
877 colored(kit, segment.text, segment.color ?? TONE_COLOR[segment.tone], {
878 wrap: 'truncate-end',
879 ...(segment.backgroundColor !== undefined && { backgroundColor: segment.backgroundColor }),
880 ...(segment.bold === true && { bold: true }),
881 ...(segment.dim === true && { dimColor: true }),
882 }),
883 ),
884 ),
885 ),
886 )
887}
888
889/**
890 * §9.2 Composition:AbovePrompt 是共享 band,保留其他 mod 經 next(e) 畫出的內容,再接上 HUD。
891 * other 為 null/undefined(沒有其他內容)時只畫 HUD。
892 */
893export function composeBand(kit: CockpitKit, other: RenderElement | null | undefined, own: RenderElement): RenderElement {
894 return other === null || other === undefined ? own : column(kit, {}, other, own)
895}
896hooks/cockpit/selectors.ts 358 lines1// CREW Cockpit selectors:只做 UI 選取與格式化的純函式(不做 I/O、不呼叫 $)。
2// render 可放心在繪製時呼叫。這些都是 presentation,不是 workflow 判定。
3
4import { selectTask } from './loader'
5import {
6 type CockpitGateView,
7 type CockpitSelectionReason,
8 type CockpitSnapshot,
9 type CockpitTaskView,
10 type CockpitVerifyView,
11 DONE_LIKE,
12 HUD_FIELD_MAX,
13 TEXT,
14 clip,
15} from './model'
16
17/** 依 snapshot 與使用者選擇($.state selectedSlug)即時算出目前選取(§8)。 */
18export function resolveSelection(
19 snapshot: CockpitSnapshot | null,
20 userSelectedSlug: string | null,
21): { task: CockpitTaskView | null; reason: CockpitSelectionReason; autoSelected: boolean } {
22 if (snapshot === null) {
23 return { task: null, reason: 'none', autoSelected: false }
24 }
25 const { slug, reason } = selectTask(snapshot.tasks, userSelectedSlug)
26 const task = slug === null ? null : (snapshot.tasks.find(item => item.id === slug) ?? null)
27 return { task, reason, autoSelected: reason === 'latest-active' }
28}
29
30/** 除了目前選取以外的 active task 數(HUD「+N 個進行中」,§9.1)。 */
31export function otherActiveCount(snapshot: CockpitSnapshot | null, selected: CockpitTaskView | null): number {
32 if (snapshot === null) {
33 return 0
34 }
35 const own = selected !== null && selected.active ? 1 : 0
36 return Math.max(0, snapshot.activeCount - own)
37}
38
39/** §11.1 進度:(done + skipped) / 實際存在的 steps 數(不寫死 9 步)。 */
40export function progressOf(task: CockpitTaskView): { done: number; total: number } {
41 const done = task.steps.filter(step => step.status !== null && DONE_LIKE.includes(step.status)).length
42 return { done, total: task.steps.length }
43}
44
45/**
46 * §11.2 依 type 決定顯示哪些 gate:bug 只顯示 uat;其他顯示全部。
47 * v1(gates === null)回 null:呼叫端改顯示「舊版格式(schema v1),無核准閘資料」,不得顯示 pending。
48 */
49export function visibleGates(task: CockpitTaskView): CockpitGateView[] | null {
50 if (task.gates === null) {
51 return null
52 }
53 return task.type === 'bug' ? task.gates.filter(gate => gate.key === 'uat') : task.gates
54}
55
56/** HUD 欄位截斷(§18.1:HUD 單一欄位 ≤ 60 字元)。輸入須是 snapshot 內已清理的字串。 */
57export function hudText(text: string): string {
58 return clip(text, HUD_FIELD_MAX)
59}
60
61/** 「載入於 HH:MM」的 HH:MM(本機時區)。 */
62export function formatClock(ms: number): string {
63 const date = new Date(ms)
64 const pad = (n: number) => String(n).padStart(2, '0')
65 return `${pad(date.getHours())}:${pad(date.getMinutes())}`
66}
67
68/**
69 * §14 Fill 的固定內容:只有 `/plan-next {slug}`,且 slug 必須通過白名單。
70 * 不得改用 state.next.command 或任何 repo 字串。不通過白名單回 null(不提供 Fill 按鈕)。
71 */
72export function fillCommandFor(task: CockpitTaskView | null): string | null {
73 if (task === null || !task.isSlugFillable) {
74 return null
75 }
76 return `/plan-next ${task.id}`
77}
78
79/** Fill 的固定模板種類:next=`/plan-next {slug}`、close=`/plan-close {slug}`。 */
80export type FillKind = 'next' | 'close'
81
82/** 核准閘「已通過」的值(對齊 crew-state.py GATE_PASSED)。 */
83const GATE_PASSED_VALUES: readonly string[] = ['approved', 'waived']
84
85/**
86 * 結案按鈕的「顯示條件」:build 為 done-like,且 requirement、architecture 兩閘已通過。
87 * 這只是顯示條件(presentation),不是 workflow 判定——真正的把關在 crew-state.py
88 * (快速結案 set --status skipped 的前置檢查、TRANSITION_GATES);這裡只讀 snapshot 已有的 steps/gates 欄位。
89 * v1(gates === null)比照 crew-state.py normalize 的遷移語意:spec/arch 為 done-like 即視為對應閘已通過。
90 */
91export function isReadyToClose(task: CockpitTaskView): boolean {
92 const stepDoneLike = (key: string): boolean => {
93 const status = task.steps.find(step => step.key === key)?.status ?? null
94 return status !== null && DONE_LIKE.includes(status)
95 }
96 if (!stepDoneLike('build')) {
97 return false
98 }
99 const gatePassed = (key: string, legacySourceStep: string): boolean => {
100 if (task.gates === null) {
101 return stepDoneLike(legacySourceStep)
102 }
103 const status = task.gates.find(gate => gate.key === key)?.status ?? null
104 return status !== null && GATE_PASSED_VALUES.includes(status)
105 }
106 return gatePassed('requirement', 'spec') && gatePassed('architecture', 'arch')
107}
108
109/**
110 * 結案 Fill 的固定內容:只有 `/plan-close {slug}`(slug 須通過白名單)。
111 * 只在任務未結案(steps.close 非 done/skipped)、非擱置,且 isReadyToClose(build 完成、兩閘通過)時提供;
112 * 不得改用 state.next.command 或任何 repo 字串。
113 * bug 任務不提供:/bug-close 不吃 slug 參數,而是以 Notion 頁面/目前分支綁定任務,
114 * 從某張任務卡按下時無法保證結的是這一筆,所以不給按鈕(使用者自行輸入 /bug-close)。
115 */
116export function closeCommandFor(task: CockpitTaskView | null): string | null {
117 if (task === null || !task.isSlugFillable || task.closed || task.parked !== null || task.type === 'bug') {
118 return null
119 }
120 if (!isReadyToClose(task)) {
121 return null
122 }
123 return `/plan-close ${task.id}`
124}
125
126/** 依模板種類取 Fill 內容(入口檔的共用 Fill 流程用)。 */
127export function fillTemplateFor(task: CockpitTaskView | null, kind: FillKind): string | null {
128 return kind === 'close' ? closeCommandFor(task) : fillCommandFor(task)
129}
130
131// ---------------------------------------------------------------------------
132// Batch 3–6 新增:顯示用的色調與驗收狀態映射(presentation,不是 workflow 判定)
133// ---------------------------------------------------------------------------
134
135/**
136 * §11.3 顏色只是 presentation。blocked 是 BLOCKED(衍生或原值)專用的色調:
137 * 刻意與 negative 分開,守住「BLOCKED 不得呈現成 FAIL」。
138 */
139export type Tone = 'positive' | 'warning' | 'negative' | 'neutral' | 'blocked'
140
141/**
142 * §11.3 一般狀態值的色調:PASS/approved/done → positive;WARN → warning;BLOCKED → blocked;
143 * FAIL/rejected/failed → negative;其他(pending/waived/MANUAL/SKIP/未知)→ neutral。
144 */
145export function toneOf(status: string | null): Tone {
146 switch (status) {
147 case 'PASS':
148 case 'approved':
149 case 'done':
150 return 'positive'
151 case 'WARN':
152 return 'warning'
153 case 'BLOCKED':
154 return 'blocked'
155 case 'FAIL':
156 case 'rejected':
157 case 'failed':
158 return 'negative'
159 default:
160 return 'neutral'
161 }
162}
163
164/** results.verify 的顯示結果。 */
165export type VerifyDisplay = {
166 /** 狀態標籤,例如 `WARN(BLOCKED ×2)`;缺值為「—」。 */
167 label: string
168 /** 窄畫面用的短標籤(WARN+blocked>0 時為 `BLOCKED ×n`)。 */
169 short: string
170 tone: Tone
171 /** 衍生 BLOCKED:status == 'WARN' && blocked > 0(§11.3)。 */
172 isBlocked: boolean
173 blocked: number
174 /** FAIL 且 blocked > 0 時的附註「另有 BLOCKED ×n」;其餘為 null。 */
175 note: string | null
176}
177
178/**
179 * §11.3 BLOCKED 衍生顯示:BLOCKED 不是 status 值,只在 WARN 且 blocked > 0 時顯示,且絕不呈現成 FAIL。
180 * WARN 且 blocked == 0 不得出現 BLOCKED;status 真的寫成 "BLOCKED" 時照原值、警示色、不改判。
181 */
182export function verifyDisplay(verify: CockpitVerifyView): VerifyDisplay {
183 const status = verify.status
184 const blocked = verify.blocked > 0 ? verify.blocked : 0
185 const plain = (label: string, tone: Tone): VerifyDisplay => ({ label, short: label, tone, isBlocked: false, blocked, note: null })
186 if (status === 'PASS') {
187 return plain('PASS', 'positive')
188 }
189 if (status === 'WARN') {
190 if (blocked > 0) {
191 return { label: `WARN(BLOCKED ×${blocked})`, short: `BLOCKED ×${blocked}`, tone: 'blocked', isBlocked: true, blocked, note: null }
192 }
193 return plain('WARN', 'warning')
194 }
195 if (status === 'FAIL') {
196 return { ...plain('FAIL', 'negative'), note: blocked > 0 ? TEXT.otherBlocked(blocked) : null }
197 }
198 if (status === 'BLOCKED') {
199 return plain('BLOCKED', 'blocked')
200 }
201 if (status === null) {
202 return plain('—', 'neutral')
203 }
204 return plain(status, 'neutral')
205}
206
207// ---------------------------------------------------------------------------
208// 分組色帶樣式(方向 B+C 的進度方塊):顏色一律是 ThemeKey,深淺主題自動切換;
209// 顏色永遠搭配文字,不單靠顏色傳達狀態。
210// ---------------------------------------------------------------------------
211
212/** 色調 → ThemeKey;neutral 不上色。 */
213export const TONE_COLOR: Record<Tone, string | undefined> = {
214 positive: 'success',
215 warning: 'warning',
216 negative: 'error',
217 neutral: undefined,
218 blocked: 'claude',
219}
220
221/** 驗收標籤顏色:沒有結果(—)用 inactive,其餘依色調。 */
222export function verifyColor(display: VerifyDisplay): string | undefined {
223 return display.label === '—' ? 'inactive' : TONE_COLOR[display.tone]
224}
225
226/** 核准閘顏色:approved success、rejected error、pending inactive,其他不上色。 */
227export function gateColor(status: string | null): string | undefined {
228 if (status === 'pending') {
229 return 'inactive'
230 }
231 return TONE_COLOR[toneOf(status)]
232}
233
234/** Phase 顏色:規劃 planMode、實作 suggestion、驗證審查 warning、結案 success,其他 text。 */
235export function phaseColor(phase: string | null): string {
236 switch (phase) {
237 case 'spec':
238 case 'db':
239 case 'arch':
240 return 'planMode'
241 case 'build':
242 case 'fix':
243 return 'suggestion'
244 case 'verify':
245 case 'review':
246 case 'uat':
247 case 'security':
248 return 'warning'
249 case 'close':
250 return 'success'
251 default:
252 return 'text'
253 }
254}
255
256/** type 顏色:bug error、feature planMode,其他不上色。 */
257export function typeColor(type: string): string | undefined {
258 return type === 'bug' ? 'error' : type === 'feature' ? 'planMode' : undefined
259}
260
261/** 停滯天數顏色:0–6 inactive、7–13 warning、≥14 error。 */
262export function staleColor(days: number): string {
263 return days >= 14 ? 'error' : days >= 7 ? 'warning' : 'inactive'
264}
265
266/** 停滯天數只對進行中的任務顯示;已結案、已擱置不顯示(避免「已結案卻停滯 N 天」的矛盾)。 */
267export function showsStale(task: CockpitTaskView): boolean {
268 return task.active
269}
270
271/** 進度方塊最多畫幾格;超過時只顯示 done/total(HUD 欄位 ≤ 60 字,§18.1)。 */
272export const PROGRESS_GLYPH_MAX = 20
273
274/** C 的進度方塊:■ 已完成(done+skipped)、□ 未完成,格數=實際存在的 steps(§11.1 A6)。 */
275export function progressGlyphs(task: CockpitTaskView): { filled: string; empty: string; count: string } {
276 const { done, total } = progressOf(task)
277 const count = `${done}/${total}`
278 if (total > PROGRESS_GLYPH_MAX) {
279 return { filled: '', empty: '', count }
280 }
281 return { filled: '■'.repeat(done), empty: '□'.repeat(Math.max(0, total - done)), count }
282}
283
284const CLOCK_PATTERN = /^\d{4}-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2}))?/
285
286/** 時間戳縮成 `MM-DD HH:mm`:直接取字串本身的欄位(不換時區);解析不了就顯示原文前 16 字。 */
287export function shortTime(value: string | null): string {
288 if (value === null || value === '') {
289 return '—'
290 }
291 const match = CLOCK_PATTERN.exec(value)
292 if (match === null) {
293 return clip(value, 16)
294 }
295 const [, month, day, hour, minute] = match
296 return hour === undefined ? `${month}-${day}` : `${month}-${day} ${hour}:${minute}`
297}
298
299/** 任務 tab 的三段分組(保持 loader 的排序)。 */
300export function taskGroups<T extends CockpitTaskView>(tasks: readonly T[]): { active: T[]; parked: T[]; closed: T[] } {
301 return {
302 active: tasks.filter(task => task.active),
303 parked: tasks.filter(task => !task.closed && task.parked !== null),
304 closed: tasks.filter(task => task.closed),
305 }
306}
307
308/**
309 * 任務健康度框色(總覽標題卡與任務卡片共用):已結案 inactive;驗收 FAIL error;BLOCKED(衍生或原值)claude(不得用 error);
310 * 已擱置 merged;進行中依停滯天數 ≥14 error、7–13 warning、否則 suggestion。
311 * 優先序:結案 → FAIL → BLOCKED → 擱置 → 停滯(驗收問題比停滯更需要先看)。
312 */
313export function healthColor(task: CockpitTaskView): string {
314 if (task.closed) {
315 return 'inactive'
316 }
317 const verify = verifyDisplay(task.results.verify)
318 if (verify.tone === 'negative') {
319 return 'error'
320 }
321 if (verify.tone === 'blocked') {
322 return 'claude'
323 }
324 if (task.parked !== null) {
325 return 'merged'
326 }
327 return task.staleDays >= 14 ? 'error' : task.staleDays >= 7 ? 'warning' : 'suggestion'
328}
329
330/** 核准閘一列的顯示:✓ approved success、● pending inactive「待核准」、✕ rejected error,其他照原值不上色。 */
331export function gateDisplay(gate: CockpitGateView): { text: string; color: string | undefined } {
332 switch (gate.status) {
333 case 'approved':
334 return { text: `✓ ${gate.key} approved`, color: 'success' }
335 case 'pending':
336 return { text: `● ${gate.key} ${TEXT.gatePendingShort}`, color: 'inactive' }
337 case 'rejected':
338 return { text: `✕ ${gate.key} rejected`, color: 'error' }
339 default:
340 return { text: `· ${gate.key} ${gate.status ?? '—'}`, color: undefined }
341 }
342}
343
344/** 總覽指標方塊的排列:依 bodyColumns 決定一列放幾個與每個的寬度(§17.2 不假設固定寬度)。 */
345export const DASH_FOUR_COLUMNS = 96
346export const DASH_TWO_COLUMNS = 44
347
348export function metricLayout(bodyColumns: number): { perRow: 1 | 2 | 4; width: number } {
349 const columns = Math.max(1, Math.floor(bodyColumns))
350 if (columns >= DASH_FOUR_COLUMNS) {
351 return { perRow: 4, width: Math.floor((columns - 3) / 4) }
352 }
353 if (columns >= DASH_TWO_COLUMNS) {
354 return { perRow: 2, width: Math.floor((columns - 1) / 2) }
355 }
356 return { perRow: 1, width: columns }
357}
358types/index.d.ts 291 lines1// CREW Cockpit 的 $.state 契約(Claude Code Mods)。
2//
3// 本檔是 Cockpit 讀取模型(snapshot)型別的唯一定義處:hooks/cockpit/model.ts
4// 只做 re-export 與執行期常數,不得在別處重複宣告這些型別。
5// 自成一檔、不 import 任何模組(Mods 契約要求)。
6//
7// 原則(規格 §31):state.json 是 truth;Cockpit 只把它讀成「顯示用」的 view model,
8// 不 normalize 回寫、不重算 next、不判定 workflow transition。
9
10/** 一個「鍵/值」顯示列;key 與 value 都已經過 §18.1 清理。 */
11export type CockpitField = {
12 key: string
13 value: string
14}
15
16/** state.steps 的一列,依檔案中實際存在的 key 與順序,不補齊、不刪減(§11.1)。 */
17export type CockpitStepView = {
18 /** step key(已清理),例如 start / spec / investigate。 */
19 key: string
20 /** 原值(已清理);缺或非字串為 null。合法值見 CS:51,但不在此改判。 */
21 status: string | null
22 at: string | null
23 reason: string | null
24}
25
26/** state.gates 的一列(只有 schema v2 才有,v1 整個 gates 為 null)。 */
27export type CockpitGateView = {
28 /** requirement / architecture / uat ……(已清理,依檔案順序)。 */
29 key: string
30 /** 原值(已清理);合法值見 CS:55,不在此改判。 */
31 status: string | null
32 at: string | null
33 by: string | null
34 reason: string | null
35}
36
37/** state.work_unit 的顯示資料。 */
38export type CockpitWorkUnitView = {
39 skill: string | null
40 /** 以 crew-state.py as_int 語意解析,失敗為 0。 */
41 done: number
42 /** 以 as_int 語意解析,失敗為 0。 */
43 total: number
44 label: string
45 remaining: string[]
46 /** total > 0 且 done < total:工作單元中斷(§11 醒目顯示)。 */
47 isInterrupted: boolean
48}
49
50/** state.resume_hint(CS:287 形狀 {branch, services, read_first})。 */
51export type CockpitResumeHintView = {
52 branch: string | null
53 services: string[]
54 readFirst: string[]
55 /** 三個欄位皆空:不顯示「接續提示」。 */
56 isEmpty: boolean
57}
58
59/** results.review / results.security 的顯示資料。 */
60export type CockpitResultView = {
61 /** results.{kind}.status 原值(已清理);缺為 null。不得改判。 */
62 status: string | null
63 /** 除 status 以外、檔案中實際存在的摘要 key(依檔案順序,已清理)。 */
64 entries: CockpitField[]
65 /** 該 results 區塊不存在、不是物件或是空物件。 */
66 isEmpty: boolean
67}
68
69/**
70 * results.verify 的顯示原料(§11.3/§13)。
71 * BLOCKED 是「衍生顯示」:status == 'WARN' && blocked > 0;這裡只提供原料,不做映射。
72 */
73export type CockpitVerifyView = CockpitResultView & {
74 /** results.verify.blocked 以 as_int 語意解析(字串 "2" → 2),失敗或缺為 0。 */
75 blocked: number
76 /** 檔案中是否真的有 blocked 這個 key。 */
77 hasBlockedKey: boolean
78}
79
80/** 每個 AC 的 route(verification_type 原值)。 */
81export type CockpitIrAcView = {
82 /** AC id(已清理),例如 AC-1。 */
83 id: string
84 /** verification_type 原值(已清理);缺或非字串為 null。 */
85 verificationType: string | null
86}
87
88/** route 動態統計的一組:依 IR 中實際出現的 verification_type 值分組(§6.4,不得寫死類別)。 */
89export type CockpitIrRouteCount = {
90 /** verification_type 原值(已清理);缺或非字串的 AC 歸在 null。 */
91 verificationType: string | null
92 count: number
93}
94
95/**
96 * .spec/{slug}/.cache/verification-ir.json 的摘要。
97 * - missing:檔案不存在(常態,不是錯誤,§6.4)
98 * - ready:可解析的 JSON 物件
99 * - invalid:讀取失敗、過大、JSON 壞掉或不是物件(§16 IR invalid)
100 */
101export type CockpitIrSummary =
102 | { status: 'missing' }
103 | {
104 status: 'invalid'
105 /** 已清理的錯誤摘要。 */
106 message: string
107 }
108 | {
109 status: 'ready'
110 /** IR 的 schema_version 原值;非數字為 null。 */
111 schemaVersion: number | null
112 acCount: number
113 /** 依出現次數由多到少、同數依值排序;null 組排最後。 */
114 routes: CockpitIrRouteCount[]
115 acs: CockpitIrAcView[]
116 preconditionCount: number
117 safetyCount: number
118 }
119
120/** 一個可解析的 task(.spec/{dir}/state.json 是 JSON 物件)。 */
121export type CockpitTaskView = {
122 /**
123 * 任務識別:.spec 下的目錄名「原值」(與 crew-state.py iter_states 一致,slug 以目錄名為準)。
124 * 只能當 key 比對與(通過白名單後)代入 /plan-next;不得直接顯示,顯示請用 slug。
125 */
126 id: string
127 /** 目錄名經 §18.1 清理後的顯示字串。 */
128 slug: string
129 /** id 符合 ^[a-z0-9][a-z0-9._-]{0,79}$;false 時不得提供 Fill(§14)。 */
130 isSlugFillable: boolean
131 /** state.json 的絕對路徑(已清理,僅供顯示)。 */
132 statePath: string
133 /** schema_version 原值;非數字為 null。v1 是常態,不是錯誤。 */
134 schemaVersion: number | null
135 /** schema_version > 2:需顯示「比 Cockpit 測試過的 v2 新」提示(§16)。 */
136 isSchemaNewer: boolean
137 /** state.name(已清理);缺時為 slug。 */
138 name: string
139 /** state.type 原值(已清理);缺或非字串為 'feature'(與 normalize 預設一致,但不推導其他欄位)。 */
140 type: string
141 /** state.phase 原值(已清理);缺為 null,不得自行推導(normalize 才會推導)。 */
142 phase: string | null
143 /** state.inferred 依 Python truthiness 判定。 */
144 inferred: boolean
145 /** parked 為 truthy 時的顯示資料(§8);否則 null。 */
146 parked: { at: string | null; reason: string | null } | null
147 /** steps.close.status ∈ {done, skipped}(§8,對齊 CS DONE_LIKE)。 */
148 closed: boolean
149 /** !closed && !parked。 */
150 active: boolean
151 /**
152 * 對齊 crew-state.py:normalize() 先把 null/缺漏的 updated、created 補成「現在」,
153 * 再 stale_days = max(0, floor((now − (parse(updated) or parse(created))) / 1 day));都解析失敗為 0(CS:299-312、1364-1369)。
154 */
155 staleDays: number
156 /** 補值後 updated 與 created 仍都無法解析(空字串或壞字串;附加資訊,staleDays 仍為 0)。 */
157 staleUnknown: boolean
158 /**
159 * 停滯天數的參考時間(epoch ms)。null 表示 crew-state.py 會算出 0 天:
160 * 參考值是被 normalize 補成的「現在」,或兩者都無法解析。增量重讀沿用舊 view 時以它重算 staleDays。
161 */
162 staleRefMs: number | null
163 /** updated 原值(已清理)。 */
164 updated: string | null
165 /** created 原值(已清理)。 */
166 created: string | null
167 /** parse(updated) ?? parse(created),epoch ms;供「最近更新」排序與選取(停滯天數改用 staleRefMs)。 */
168 updatedRefMs: number | null
169 /** state.next 快照(§6.3):只能標成「上次記錄的建議」,不得當成現況、不得當成 Fill 內容。 */
170 recordedNext: { command: string | null; reason: string } | null
171 resumeHint: CockpitResumeHintView | null
172 /** 依檔案實際存在的 steps(v2 bug 4 步、v1 bug 可能 9 步),不補齊。 */
173 steps: CockpitStepView[]
174 /** v1(或 gates 不是物件)為 null:不得顯示 pending(§6.3)。 */
175 gates: CockpitGateView[] | null
176 workUnit: CockpitWorkUnitView | null
177 results: {
178 verify: CockpitVerifyView
179 review: CockpitResultView
180 security: CockpitResultView
181 }
182 /** state.git 的鍵值(已清理;純文字,不做連結)。 */
183 git: CockpitField[]
184 /** state.git.branch(已清理);取自 state,非即時 git 狀態。 */
185 branch: string | null
186 notion: CockpitField[]
187 deploy: CockpitField[]
188 verificationIr: CockpitIrSummary
189}
190
191/** 無法解析成 task 的 state.json(Cockpit 刻意列出;/plan-status 不會列出此筆,§6.2)。 */
192export type CockpitInvalidTask = {
193 /** 目錄名原值(僅作 key)。 */
194 id: string
195 /** 目錄名清理後的顯示字串。 */
196 slug: string
197 statePath: string
198 /** parse:JSON 壞掉;not-object:不是 JSON 物件;too-large:超過 4 MiB;read:其他讀取失敗。 */
199 kind: 'parse' | 'not-object' | 'too-large' | 'read'
200 /** 已清理的錯誤摘要。 */
201 message: string
202}
203
204/** 非單一 task 的載入問題(例如 .spec 無法列出)。 */
205export type CockpitError = {
206 scope: 'spec-dir' | 'loader'
207 path: string | null
208 message: string
209}
210
211/** 自動選取的理由(§8)。 */
212export type CockpitSelectionReason = 'user' | 'only-active' | 'latest-active' | 'none'
213
214/** 存在 $.state 的讀取模型(§7)。render 只讀它,不做 I/O(§3 D-3)。 */
215export type CockpitSnapshot = {
216 /** 讀取模型版本;熱重載後版本不同時 loader 會丟棄舊快取。 */
217 modelVersion: number
218 /** 含 .spec/ 的 repo root(§6.1);沒有則 null(=無 CREW 任務)。 */
219 repoRoot: string | null
220 /** repoRoot 取自哪個候選。 */
221 rootSource: 'session-root' | 'ancestor' | 'git-root' | null
222 /** 載入完成時間(epoch ms,取自 $.clock.now)。 */
223 loadedAt: number
224 /** 可解析的 task,已排序:active → parked → closed,各組內 updated 新到舊、再依 id。 */
225 tasks: CockpitTaskView[]
226 /** 無法解析的 state.json。 */
227 invalidTasks: CockpitInvalidTask[]
228 /** .spec 下沒有 state.json 的目錄數(§6.2)。 */
229 untrackedDirCount: number
230 /** active task 數。 */
231 activeCount: number
232 /** 載入當下的選取結果(task id);render 端請用 selectors.resolveSelection 搭配 selectedSlug atom 即時計算。 */
233 selectedSlug: string | null
234 /** selectionReason === 'latest-active'(多個 active 時自動選最新,§8-3)。 */
235 autoSelected: boolean
236 selectionReason: CockpitSelectionReason
237 errors: CockpitError[]
238 /** path → mtimeMs(state.json 與 IR),供 §15 增量重讀。 */
239 mtimes: Record<string, number>
240 /** path → size,與 mtimes 一起當指紋。 */
241 sizes: Record<string, number>
242 /** 本次載入的讀檔統計(診斷/測試用)。 */
243 stats: { dirs: number; reread: number; reused: number }
244}
245
246/** Claude Code 版本檢查結果(§19)。 */
247export type CockpitRuntime = {
248 isSupported: boolean
249 version: string | null
250 minimum: string
251 /**
252 * 最近一次 refresh 領到的世代號(遞增)。存在 $.state 而非模組變數:熱重載後舊模組還在跑的 refresh
253 * 也看得到新世代,不會把較舊的結果寫回 snapshot。缺值視為 0。
254 */
255 refreshGeneration?: number
256 /**
257 * 任務 tab 的「已結案」分組是否展開(UI state,按 e 切換)。缺值視為收合(只顯示最近 5 筆)。
258 * 放在 runtime 而非新 key:不增加 Mod 的 state 讀寫清單(capability baseline 不變)。
259 */
260 isClosedExpanded?: boolean
261 /**
262 * 任務 tab 的版面(UI state,按 v 或「版面」按鈕切換)。缺值視為列表。
263 * 持久化值在 $.store(key `layout:{repo identity}`,比照 HUD 開關);這裡只是給 render 讀的鏡像。
264 * 同樣放在 runtime 而非新 key,capability baseline 不變。
265 */
266 taskLayout?: CockpitTaskLayout
267}
268
269/** 任務 tab 的版面:列表(分組色帶)或卡片(UI state)。 */
270export type CockpitTaskLayout = 'list' | 'cards'
271
272/** Cockpit pane 的 tab(UI state,§10)。 */
273export type CockpitTab = 'overview' | 'tasks' | 'verify'
274
275declare module 'claude-code' {
276 interface PluginState {
277 'feature-workflow': {
278 /** 最新讀取模型;尚未載入為 null。 */
279 snapshot: CockpitSnapshot | null
280 /** 使用者在本 session 選的 task id(UI state,不是 workflow state)。 */
281 selectedSlug: string | null
282 /** pane 目前的 tab。 */
283 tab: CockpitTab
284 /** HUD 開關鏡像(§9.4;真正保存在 $.store)。 */
285 hudEnabled: boolean
286 /** 版本檢查結果(§19)。 */
287 runtime: CockpitRuntime | null
288 }
289 }
290}
291