SLOPSHOPPER

feature-workflow

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

newpanebandcommandtoast
★ 9v5.2.1MITupdated 2026-10-07mark22013333/crew/plugins/feature-workflow
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · feature-workflow
│ ┃ CREW ✕ › fix the failing auth test and add an audit log call │ ┃ 此 repo 沒有 CREW 任務。 │ ┃ 用 /plan-start 或 /bug-start 開始一個。 ⏺ Read(src/auth.ts) │ ┃ ⎿ Read 6 lines │ ┃ [ 重新整理 ] ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /crew-cockpit │ ⎿ feature-workflow: 已開啟 CREW Cockpit。 │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · CREW
此 repo 沒有 CREW 任務。 用 /plan-start 或 /bug-start 開始一個。 [ 重新整理 ]
README

Feature Workflow Plugin v5.2.1

跨 Host 的 Feature lifecycle:本地 .spec/ 規劃、Human approval gates、正式實作、安全/驗證/review、Human UAT 與結案同步。核心 contract 不依賴單一 Host 的 team、subagent 或 provider model 名稱。

安裝

Claude Code

claude plugin marketplace add mark22013333/crew
claude plugin install feature-workflow

Codex

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 Code

claude plugin marketplace update company-marketplace
claude plugin update feature-workflow@company-marketplace
claude plugin list

Codex

codex plugin marketplace upgrade crew
codex plugin list

更新後開新 session。若 Codex marketplace 尚未註冊,先執行 codex plugin marketplace add mark22013333/crew。


Intake refinement

/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:

  • Intake Refiner:回答「使用者到底想做什麼?」;不產 AC、不做 DB/API/架構設計。
  • Human:確認 refined intent;沉默不算核准。
  • persistence 前若疑似含 credential,先要求 Human 提供 redacted 版本;不把 secret 寫進 cache / Notion / plan.md / state。
  • Git repo 內建立 cache/state 前先確認 .spec/ 已被 .gitignore 保護。
  • feature-spec-analyst:task 建立後才把 confirmed brief 工程化成 Goal / AC / Decisions / Risks。
  • Notion「📋 需求描述」永久保存 raw original + confirmed refined brief;plan.md 只保留 refined brief。
  • Plan intake cache 是 page-aware recovery journal,保存 notion_page_id;Notion create 成功後先 journal page ID,再 init state。
  • 重跑遇到 matching pending task 會先詢問是否沿用;cache 有 page ID 時 fetch/沿用,不建立 duplicate page。
  • /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。
  • Shared intake contract 同時定義 Bug durable recovery;Bug cache 必須在 intake headings + 五個標準 Bug sections 全部 fetch 驗證完成後才可刪除,只有 headings 完整仍不夠。Feature plugin 保留同步副本是為了兩個 plugin 都可獨立安裝且 contract 不漂移。

完整 contract 見 references/intake-refinement.md。


Feature lifecycle

<!-- 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。

Approval gates

  • Requirements 未核准:不得往 DB/arch/build。
  • Architecture 未核准:不得進 build。
  • 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。


Host Capability Contract

Feature workflow 用 capability 描述角色,不要求某個 Host 一定有 team/multi-agent:

Capability用途無該 Host 能力時
project_instructions讀 AGENTS.md / CLAUDE.md無任何指令檔才阻擋
delegate_readonlyspec、探索、review主 Agent inline 唯讀執行
delegate_write已核准的正式實作主 Agent 按 allowed scope 寫入
parallel_delegate互不依賴角色加速sequential fallback
tool_probeDB/瀏覽器等外部能力no-tool fallback
ask_userrequirements / architecture / UAT decision一般對話詢問

因此平行執行是最佳化,不是 /plan-build 或 /plan-review 的 hard prerequisite。

完整 contract 見 references/host-capabilities.md。


Model Routing

Feature workflow 使用 provider-neutral profiles:

工作Profile可改產品碼
repository search、範本/交叉引用探索FAST否
一般需求分析與一般 reviewSTANDARD否
DB schema / architecture / security / performanceDEEP否
build/test/schema validationNONE否
/plan-build 正式實作DEEP(目前保守基準)是

Profile 由 crew-model-route.py + model-routing.json 計算,Host adapter 才決定實際 provider mapping。Workflow README 不把 provider model 名稱當成流程契約。


Portable Config

CREW-owned Feature config 由 shared resolver 管理,不由 README 指定 Host-specific directory。

Portable root:

  1. CREW_CONFIG_HOME
  2. $XDG_CONFIG_HOME/crew
  3. ~/.config/crew

主要 logical keys:

Key用途
feature/configNotion IDs、workspace metadata、欄位對照
feature/projectrepo-id 專案對應
feature/stack自訂 stack definition
bug/configsetup 時可讀取共用 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.md
  • CLAUDE.md

兩者並存時都可讀;若內容衝突,必須保留歧義讓 Human 決定。


Skill 清單

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-verifyBrowser/API 驗證、evidence、Word/Excel/E2E 選項
/plan-review邏輯、品質、效能 review;可 quick
/plan-closeHuman UAT + 結案同步
/plan-sync中途同步 .spec/ 到 Notion
/plan-deploy-confirm部署 SQL step 回報
/plan-status查看任務狀態;v1 可 migrate
/plan-next從 state 計算下一步
/plan-driftplan.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"]
  1. 唯讀探索:找相關檔案、現有範本、介面與影響範圍。
  2. 將核准 spec + 探索 handoff 拆成 DB/backend/API/frontend/test 等必要角色。
  3. Host 能平行且 scope 互斥時可 parallel delegate。
  4. Host 無平行能力時依 DAG sequential 執行。
  5. 每個可寫角色都限制 allowed paths;完成後跑 deterministic build/test。
  6. 不允許因為 Host 能力較少而跳過 approval、scope 或 verification。

/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"]
  • 各 reviewer 預設唯讀。
  • 可平行時平行,不可平行時 sequential。
  • 安全審查由 /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"]

可依環境使用:

  • Verification Router:Browser / API / backend-test / database / manual
  • Browser 驗收(Playwright preferred;chrome-devtools / local CDP fallback)
  • 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,不重新開瀏覽器
  • Excel report
  • Word report

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 的副作用。


CREW Cockpit(Claude Code Mod)

Claude Code 專屬的唯讀儀表板,隨 feature-workflow 一起安裝:

  • AbovePrompt HUD:輸入框上方顯示目前任務 slug、phase、verify 狀態、停滯天數與其他進行中任務數;沒有任務時不佔版面。
  • /crew-cockpit Pane:總覽儀表板、任務列表/卡片切換、驗收頁;資料全部來自 .spec/*/state.json 與驗證結果,只讀、不修改。
  • Fill 只填入、不送出:按鈕只會把下一步指令填進輸入框,由你自己按 Enter。
  • 最低版本 Claude Code 2.1.289;Codex 等其他 Host 的 CREW 核心流程照常運作,但沒有 Mod UI。
  • 測試開發中的 Mod:只能用 terminal 的 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 既有,尚未處理)。

SessionStart hook

Claude Code adapter 會以 crew-state.py session-brief 顯示未完成任務與 /plan-next。Hook 只讀當前 repo 的 state,不外送、不修改產品檔案,錯誤時不阻擋 session。

沒有同等 session hook 的 Host 直接使用 /plan-next 即可,不影響 workflow correctness。


v1 compatibility

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。

詳見 references/legacy-v1.md。


與 Bug Workflow 的關係

  • 共用 Notion 任務/專案 metadata。
  • /project-add 維護 shared feature/project mapping。
  • 共用 Host Capability、Model Policy、State Discipline、Portable Config Contract。
  • Bug 與 Feature lifecycle 各自 type-aware,不用 Feature phases 模擬 Bug。

設計參考

授權

MIT License

Source 6 files
hooks/crew-cockpit.ts 358 lines
1// 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}
358
hooks/cockpit/loader.ts 789 lines
1// 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}
789
hooks/cockpit/model.ts 381 lines
1// 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}
381
hooks/cockpit/render.ts 896 lines
1// 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}
896
hooks/cockpit/selectors.ts 358 lines
1// 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}
358
types/index.d.ts 291 lines
1// 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