Development autopilot — 30 lifecycle skills + 3 methodology agents + 36 hooks (23 default-on, 13 opt-in) enforcing Three Red Lines discipline…

<table border="0" cellspacing="0" cellpadding="0"> <tr> <td valign="middle"><img src="docs/assets/icon.svg" alt="Autopilot" height="180"></td> <td width="24"></td> <td valign="middle"><img src="docs/assets/hero.svg" alt="Autopilot — Claude Code-first lifecycle orchestration with portable paths for Codex, OpenCode, and agy" height="180"></td> </tr> </table>
<img src="https://img.shields.io/badge/Claude_Code-plugin-5A67D8?style=flat-square&logo=anthropic&logoColor=white" alt="Claude Code Plugin"> <img src="https://img.shields.io/badge/version-3.0.0--alpha.2-E8A838?style=flat-square" alt="v3.0.0-alpha.2"> <img src="https://img.shields.io/badge/skills-30-4A90D9?style=flat-square" alt="30 Skills"> <img src="https://img.shields.io/badge/agents-3-7C9E8C?style=flat-square" alt="3 Methodology Agents"> <img src="https://img.shields.io/badge/hooks-36-6B8E6B?style=flat-square" alt="36 Hooks"> <img src="https://img.shields.io/badge/dependencies-zero-A8B5A0?style=flat-square" alt="Zero Dependencies"> <img src="https://img.shields.io/badge/license-MIT-D4A5A5?style=flat-square" alt="MIT License">
<b>English</b> | <a href="README.zh-TW.md">正體中文</a>
<b>The AI project lead for your terminal.</b><br> Claude Code is the full home base. Autopilot plans, delegates, reviews with a second engine, and remembers what it learned — with portable paths for Codex, OpenCode, and agy where their harnesses support them.
<sub>Distilled from 100+ completed AI-development projects.</sub>
# autopilot's optional pre-push hook:
❯ git push
[autopilot] completeness scan … ✗ TODO stub in auth.py:42
[autopilot] tests … ✗ 1 skipped (payment flow)
[autopilot] review … ⚠ unhandled error path
push blocked — fix it, or override with a reason
Claude Code is still the most complete host. Autopilot makes AI coding agents finish the job — the planning, checking, deciding, and remembering you'd otherwise do by hand:
/l3 /l4 /l5 /l6 and ceo-agent can take a task end-to-end (sized, planned, built, reviewed, closed) and only stop to ask at the decisions that actually matter..claude/.It ships first as a Claude Code plugin — 30 skills, 3 methodology agents, 36 hooks (23 default-on, 13 opt-in), zero dependencies — and keeps the same methodology portable where other harnesses expose compatible skill, agent, or plugin surfaces. It works fully on its own, and also plays nicely with the superpowers plugin if you have it.
This README was written by Claude and adversarially reviewed by GPT-5.5 and Gemini through Autopilot's own second-engine review flow.
New here? This page is the 5-minute tour. Everything deeper lives in Learn More.
dev-flow is the front door. It sizes the task and routes it — small things go straight through the gate, large things become a tracked project:
<img alt="A day with Autopilot: dev-flow sizes the task and routes it — small tasks go straight through the quality gate to commit; large tasks become a tracked project with a quality gate each phase, then finish-flow closes cleanly. Without Autopilot, the AI greps the codebase immediately — no plan, no phases, no quality gates." src="docs/assets/flow.svg" width="100%">
Without Autopilot, Claude starts grep-ing the codebase immediately — no plan, no phases, no quality gates. With it, the discipline is automatic.
/plugin marketplace add cookys/autopilot
/plugin install autopilot@autopilot
That's it. Now just talk to Claude — Autopilot's skills trigger on what you say:
You: "I'm starting on WebSocket compression" → sizes it, sets up a plan + branch + quality gates
You: "quick fix for the null check in auth" → fast path, still gated before commit
You: "what should I work on next?" → scans your projects and ranks them
You: "搞定這個重構,你決定" → full autonomous CEO mode
No commands to memorize — say it in your own words and the right skill steps in.
Autopilot is Claude Code-first, but not Claude Code-only. Pick the entry point that matches the harness you actually use:
| If you are... | Start with | What you get |
|---|---|---|
| Claude Code user | The two-command install above | The complete path: skills, methodology agents, hooks, /l3-/l6, and plugin-managed defaults |
| Codex user | .agents/skills/ in this repo, or the local package under platforms/codex/plugin | Autopilot skills, bundled support payload, and the one production PostCompact recovery hook; no Codex-thread-bound direct-mutation enforcement is shipped (D4=NOT_READY/NO-SHIP) |
| OpenCode user | .agents/skills/ plus .opencode/opencode.json | Shared skills and methodology agent bodies, with an OpenCode-specific in-process plugin wrapper |
Antigravity (agy) user | scripts/install-antigravity.sh | Guarded import as a Claude Code-source plugin; no loose skills-dir scan |
| Contributor | ./scripts/dev-setup.sh --check | A read-only readiness dashboard for Claude/Codex/OpenCode/agy; mutating non-Claude setup requires --harness <name> --install |
The course-sized idea is simple: teach the agent the collaboration discipline once, then stop retyping it.
| Principle | Autopilot default |
|---|---|
| Clarify the work before coding | dev-flow expands goals into size, branch, plan, and gates |
| Ask for proof, not reassurance | quality-pipeline runs tests, scans for incomplete work, and reviews the diff |
| Preserve context outside the model | project-lifecycle, handoff, and finish-flow keep state readable by the next session |
| Don't let one brain self-approve | Heterogeneous review and qc panels read artifacts, not the implementer's story |
| Delegate by risk | /l3-/l6 scale from inline autonomy to heterogeneous implementation and verification authoring |
30 skills, grouped by what you're trying to do. Each one triggers from natural language — the Try saying lines are real triggers.
dev-flow (start here — sizes & routes the task) · quality-pipeline (test → scan → review) · finish-flow (clean closing sequence, nothing skipped).
Try saying: "let's implement X" · "quick fix for Y" · "is this ready to commit?"
survey (dual-agent industry research) · think-tank (6-role debate) · brainstorm (pre-code design exploration) · think-tank-dialectic (irreversible, high-stakes calls).
Try saying: "what do others use for X?" · "should we rewrite or patch?" · "要辯論一下"
ceo-agent (you set the goal, it executes) · /l3 /l4 /l5 /l6 (terse front-doors that pre-fill the CEO startup so one line ships the goal). They escalate where the work runs:
| Runs where | Reach for it when | |
|---|---|---|
/l3 | inline, on this thread | full autonomy, but you want to watch it happen |
/l4 | one background, worktree-isolated foreman | a long run you'd rather offload — your context stays clean, the authoritative quality verdict is held at depth 0 |
/l5 | /l4, but the implementer is a different engine (agy / Gemini) | cost-arbitrage, or a decorrelated second engine doing the mechanical coding |
/l6 | /l5, plus verification authoring is delegated to a different engine | when you want implementation and verification labor offloaded, while depth 0 keeps merge authority |
Ordinary strict /l5 Engine execution is fail-closed: before workflow dispatch, the CLI must match the exact implementer/reviewer/verification-author/QC roster to Autopilot's frozen provider policy and consume fresh host-owned qualification plus live-readiness evidence. Lower-level and legacy flows remain explicitly non-strict.
/l3 fix the flaky reconnect test, you decide # inline
/l4 ship the WebSocket reconnect system # offload to a background foreman
/l5 migrate the config loader to the new schema # foreman + heterogeneous implementer
/l6 ship the parser rewrite # hetero implementer + hetero verification authoring
Try saying: "CEO mode, handle it" · "全權處理" · "/l4 ship the reconnect system"
→ Per-level behaviour, presets, override flags (--expand / -x / --solo), and full examples: docs/skills.md.
Autopilot delegates labor, not authority. Implementer self-report is never evidence; reviewers read the task, diff, logs, and artifacts directly. Deterministic gates stay authoritative, higher-risk work needs decorrelated review coverage, and a no_verdict review never clears a gate.
Autopilot remains one product for strong, weak, remote, and local models. It first admits an exact role + task scope + deployment identity, then compiles exactly one guidance payload: guided gives a bounded slice and explicit structure; autonomous removes redundant choreography for a qualified role. Neither profile changes red lines, effects, egress, assurance, or acceptance.
governance.guidance_profile is a project default (guided when omitted), and a task can override it at intake without editing the project. External benchmark data can only create provisional telemetry. Owner/reviewer qualification uses separate host-scored evals; stored JSON cannot recreate session authority. The repository's v2.33.0 cutover receipt remains hold_guided: the autonomous control source is 113 bytes smaller, but exact host-token measurements, an effectful compatibility witness, a current live owner verifier, and five complete independent dogfood receipts are not yet available. The local OpenAI-compatible adapter passed fake-contract transport tests, but no live local runtime or agentic local runner is claimed.
Claude alone is enough. But point autopilot at a second engine family and its review/implement pipeline gets stronger — a cross-family qc panel catches what one vendor and its same-family reviewer jointly miss, and you get a heterogeneous implementer for cost-arbitrage. Recommended order: a subscription you already pay for ≻ a metered API key — OAuth-login runners (codex / agy / grok / explicit-only qoderclicn) need no token at all; GLM / MiniMax go in one canonical mode-600 file (~/.autopilot/endpoints.env) and are wired declaratively in .claude/review-loop-config.md.
Try saying: "set up a GLM reviewer" · "use MiniMax as the /l5 implementer"
→ Credential placement, the subscription-≻-API-key ladder, and the copy-paste setup: docs/installation.md.
learn (capture lessons) · retro (git-history retrospective) · next (what to do next) · distill (turn your repeated workflows into personal skills) · plus debug · profiling · test-strategy · audit · doc-sync.
Try saying: "record this for next time" · "回顧這週" · "what's the highest priority?"
→ Full catalog of all 30 skills, the three cognitive modes, and how they compose: docs/skills.md.
agent-call contacts an already-running named session. Claude↔Claude uses native ListAgents / SendMessage when the target is exact; other targets go through fleet messaging v2 (the fleet CLI, or the fleet MCP tools). An offline target fails explicitly and never turns into a worker spawn.
Try saying: "ask the running codex session for its status" · "send this to the persistent reviewer"
Claude Code (primary) — the two commands above. All 30 skills are available immediately as autopilot:dev-flow, autopilot:survey, etc.
⚠️ Claude Code ≥ 2.1.233 + Claude 5-era models: the task tools (
TaskCreatefamily) are gated off by default on Opus ≥ 4.8 / Sonnet ≥ 5 / Fable ≥ 5 — which silently disables every dev-flow forcing function. Fix: keep{"env": {"CLAUDE_CODE_ENABLE_TODO_TOOLS": "1"}}in your project's.claude/settings.json(tracked, so worktrees inherit it).autopilot:onboardscaffolds this pin automatically; dev-flow warns once if the tools are still missing.
| Harness | How to start | Supported today | Known limits | |
|---|---|---|---|---|
| Claude Code | /plugin marketplace add cookys/autopilot then /plugin install autopilot@autopilot | Full plugin path: 30 skills, 3 methodology agents, 36 hooks | Primary host; Claude-specific hooks and slash behavior do not automatically transfer to other harnesses | |
| Codex | .agents/skills/, or codex plugin add autopilot@autopilot-local after adding platforms/codex as a marketplace | Skills, generated support payload, and one production PostCompact recovery hook (`manual\ | auto`) | This is a Codex-native recovery boundary, not Claude hook parity; no Claude hook bundle, apps, or MCP servers are loaded. Subagent model routing via spawn_agent needs a user opt-in — see platforms/codex/README.md |
| OpenCode | Open this repo with .agents/skills/; use .opencode/opencode.json for agents | Shared skills, methodology agent bodies, and an OpenCode plugin wrapper | Optional TypeScript deps are only needed when editing the wrapper; hook parity is platform-specific | |
Antigravity (agy) | ./scripts/install-antigravity.sh | Guarded agy plugin validate / install / list flow with export-then-install | Runtime hook firing is still unverified; install does not imply hook behavior parity |
Full per-platform instructions, Windows notes, and the contributor dev-mode workflow are in docs/installation.md. Verified capability boundaries live in references/multi-agent-portability.md.
The deep material, moved out of this page so it stays an onboarding tour:
| Topic | Doc |
|---|---|
| All 30 skills + three modes + how they compose | docs/skills.md |
| Superpowers coexistence — three scenarios, migration | docs/coexistence.md |
Per-project configuration — the .claude/ injection model | docs/configuration.md |
| Installation & development — every platform, dev mode | docs/installation.md |
| Architecture & design — philosophy, methodology agents, credits | docs/architecture.md |
| Hooks — 25 runtime-enforcement hooks (tiers in the doc) | hooks/README.md |
| Changelog | CHANGELOG.md |
MIT — see LICENSE for details.
mods/live/register.ts 420 lines1// mods/live/register.ts — the `live` mod (mods plan P1c; Claude Code only).
2//
3// Reads files the project watcher publishes and draws a band above the prompt, a pane in a wide fullscreen
4// terminal and a toast on an axis change. It is read-only: no file is written outside its own $.state value,
5// no program is started, nothing is sent to the model.
6//
7// pointer $HOME/.autopilot/live-pointer.json (the only path built from $.env.get("HOME"))
8// project session marker <autopilot_home>/session-mode/<sid>.json -> <live_base>/runs/paths/*.json longest prefix
9// roots the marker's root_run_id + its `campaign_roots` (the Mission roots of the campaigns the session launched; P1W SCOPE):
10// every root of the set is read like the marker root, one band is merged from them
11// snapshot <live_base>/runs/<project_key>[--<root>].json, else the SSD copy <autopilot_home>/review/<key>/live/runs.<scope>.json
12// context <live_base>/context/<sanitised sid>.json (this sid only)
13// job page <autopilot_home>/review/<key>/<date>/<job>/current/model.json (acceptance axis + gate rows, optional)
14// W3a <live_base>/tasks/<sid>.json, attention/<sid>.json (this sid only)
15// <live_base>/runs/<scope>.decisions.json, <scope>.foreman.json, sources/<scope>.json (scope-checked sidecars)
16// <git-common-dir>/autopilot/work-orders/<root>/*.json (campaign start = earliest bound progress receipt;
17// the common dir comes from the envelope's scope.repo_identity, a published path, never a computed live base)
18//
19// P7 <live_base>/runs/<project_key>.qc.json, .residue.json, .review.json; <live_base>/stage/<sid>.json; load-source.json; the marker's
20// §2.9 fields (size, urgent, level, stage, stage_set_at, unit, review_families); the envelope's host_today_brain_usd / brain_cap_usd;
21// the decisions sidecar's `ladder`. Each is optional: absent -> its slot is left out and its tab says "no data".
22// The band is ONE line (band.tsx, layout in model.ts layoutBand); the ⓘ opens the pane on the Legend tab.
23//
24// $.session.id() is read on EVERY tick: /clear gives the session a new id while the timer keeps running (S7).
25// Every clock comparison uses $.clock.now(), so a test with a mocked clock decides fresh / stale.
26
27import type { EngineInterface, Register } from 'claude-code'
28
29import { newTracker, parseAdvisories, takeNew } from './advisories'
30import type { AdvisoryTracker } from './advisories'
31import { Band } from './band'
32import { Pane } from './pane'
33import {
34 acceptanceToast, bandLine1, bandView, buildSections, checkEnvelope, commonDirOf, countsOf, ctxShown, ctxText, earliestReceiptMs, executionToast,
35 headerText, hhmm, projectName as projectNameOf, isKey, isObject, isPlainRoot, jobOf, longestPrefixKey, mergeDecisions, mergeEnvelopes, mergeJobModels, NO_SECTIONS, PANE_TABS, paneRows, parseJson, portOf, readAttention, readTurn, readDecisions,
36 readForeman, readLoadSource, readJobModel, readManifest, readMarker, readQc, readResidue, readReview, readStageWalk, readTasks, reviewLink, sanitizeSid, scopeKeyOf, sessionUsd,
37 spendOf, startMsOf, STATE_TEXT, POINTER_SCHEMA, NO_DETAIL, usd,
38} from './model'
39import type { Counts, DecisionsView, EnvelopeCheck, JobModel, Json, LiveSnapshot, Manifest, PaneTab, Sources } from './model'
40
41const TICK_MS = 5000
42const PANE_ID = 'autopilot-live'
43const PANE_MIN_COLUMNS = 144
44const MAX_PATH_FILES = 256
45const MAX_RECEIPT_FILES = 64
46const MAX_CAMPAIGN_ROOTS = 8 // session-mode.js CAMPAIGN_ROOTS_MAX
47
48// Module state. A hot reload drops it and session.start rebuilds it (the first tick refills the snapshot at once).
49// It is a module variable and not $.state on purpose: a $.state ref needs the plugin manifest to name a types
50// contract, and this mod is wired by the hooks.json `modules` key alone.
51let snapshot: LiveSnapshot | null = null
52let timer: { cancel: () => void } | undefined
53let viewport: { columns: number; isFullscreen?: boolean } | null = null
54let paneAttempted = false
55let paneTab: PaneTab = 'now'
56let previous: { scope: string; counts: Counts | null; acceptance: string | null } | null = null
57const advisories: AdvisoryTracker = newTracker() // P7a: advisory rows seen for the current session; advisories.count feeds a band chip
58const jobDates = new Map<string, string>()
59// earliest bound progress receipt per root: receipts only accumulate, so a found start never moves earlier
60const receiptStarts = new Map<string, number>()
61
62async function readText($: EngineInterface, path: string): Promise<string | null> {
63 try {
64 const v = await $.fs.read(path)
65 return typeof v === 'string' ? v : null
66 } catch (_e) {
67 return null
68 }
69}
70
71async function readObject($: EngineInterface, path: string): Promise<Json | null> {
72 const text = await readText($, path)
73 if (text === null) return null
74 const parsed = parseJson(text)
75 return parsed.ok && isObject(parsed.value) ? parsed.value : null
76}
77
78function plain(state: LiveSnapshot['state'], text: string, over: Partial<LiveSnapshot> = {}): LiveSnapshot {
79 return {
80 state, text, band: null, header: text, decision: null, project_key: null, root_run_id: null, link: null, rows: null, gates: null,
81 sections: NO_SECTIONS, review: null, published_at: null, session_as_of: null, host_as_of: null, detail: NO_DETAIL, ...over,
82 }
83}
84
85// The marker's campaign roots, oldest -> newest: plain path segments only (a root names files), not the marker's own root, no repeats.
86function campaignRootsOf(marker: Json, markerRoot: string | null): string[] {
87 const raw = Array.isArray(marker.campaign_roots) ? marker.campaign_roots : []
88 const out: string[] = []
89 for (const r of raw) {
90 if (typeof r === 'string' && isPlainRoot(r) && r !== markerRoot && !out.includes(r)) out.push(r)
91 }
92 return out.slice(-MAX_CAMPAIGN_ROOTS)
93}
94
95// Which project / root does this session belong to? marker first (an unexpired one with a project_key), then the
96// longest path-boundary prefix of the real cwd among the watchers' runs/paths files. Never git, never a hash.
97type Scope = { key: string; root: string | null; marker: Json | null; campaign: string[] }
98
99async function resolveScope($: EngineInterface, autopilotHome: string, liveBase: string, sid: string, nowMs: number): Promise<Scope | null> {
100 if (sid) {
101 const marker = await readObject($, autopilotHome + '/session-mode/' + sid + '.json')
102 if (marker && isKey(marker.project_key) && typeof marker.expires_at === 'string' && Date.parse(marker.expires_at) > nowMs) {
103 const root = typeof marker.root_run_id === 'string' && marker.root_run_id ? marker.root_run_id : null
104 return { key: marker.project_key, root, marker, campaign: campaignRootsOf(marker, root) }
105 }
106 }
107 const cwd = await $.session.cwd()
108 let real = cwd
109 try {
110 const st = await $.fs.stat(cwd, { resolve: true })
111 if (typeof st.realPath === 'string' && st.realPath) real = st.realPath
112 } catch (_e) { /* keep the cwd spelling */ }
113 const dir = liveBase + '/runs/paths'
114 let names: string[] = []
115 try {
116 names = (await $.fs.list(dir)).filter(entry => entry.kind === 'file' && entry.name.endsWith('.json')).map(entry => entry.name).slice(0, MAX_PATH_FILES)
117 } catch (_e) { return null }
118 const maps: Json[] = []
119 for (const name of names) {
120 const map = await readObject($, dir + '/' + name)
121 if (map) maps.push(map)
122 }
123 const key = longestPrefixKey(real, maps)
124 return key === null ? null : { key, root: null, marker: null, campaign: [] }
125}
126
127// The envelope of one scope: the tmpfs file, then the SSD copy; a fresh one beats a stale one.
128async function findEnvelope($: EngineInterface, autopilotHome: string, liveBase: string, scope: Scope, nowMs: number): Promise<EnvelopeCheck> {
129 const scopeKey = scopeKeyOf(scope.key, scope.root)
130 const want = { project_key: scope.key, root_run_id: scope.root }
131 const paths = [liveBase + '/runs/' + scopeKey + '.json', autopilotHome + '/review/' + scope.key + '/live/runs.' + scopeKey + '.json']
132 let stale: EnvelopeCheck | null = null
133 let malformed = false
134 for (const path of paths) {
135 const check = checkEnvelope(await readText($, path), want, nowMs)
136 if (check.status === 'fresh') return check
137 if (check.status === 'stale' && stale === null) stale = check
138 if (check.status === 'malformed') malformed = true
139 }
140 if (stale !== null) return stale
141 return { status: malformed ? 'malformed' : 'missing', envelope: null }
142}
143
144// date directory of a job under <autopilot_home>/review/<key>/ — the earliest one that holds the job (the
145// watcher's renderer pins the earliest too); null until the job page has been published.
146async function findJobDate($: EngineInterface, autopilotHome: string, key: string, job: string): Promise<string | null> {
147 const base = autopilotHome + '/review/' + key
148 const cacheKey = key + '/' + job
149 const cached = jobDates.get(cacheKey)
150 if (cached) return cached
151 let dates: string[] = []
152 try {
153 dates = (await $.fs.list(base)).filter(entry => entry.kind === 'dir' && /^\d{4}-\d{2}-\d{2}$/.test(entry.name)).map(entry => entry.name).sort()
154 } catch (_e) { return null }
155 for (const date of dates) {
156 try {
157 if ((await $.fs.list(base + '/' + date)).some(entry => entry.name === job)) {
158 jobDates.set(cacheKey, date)
159 return date
160 }
161 } catch (_e) { /* not a readable date dir */ }
162 }
163 return null
164}
165
166// The earliest bound progress receipt of a campaign root (W3a elapsed). The root names a directory, so only a plain
167// segment is used; an unreadable / absent directory is "no receipt" and the earliest run start takes over.
168async function receiptStart($: EngineInterface, identity: unknown, root: string): Promise<number | null> {
169 const cached = receiptStarts.get(root)
170 if (cached !== undefined) return cached
171 const common = commonDirOf(identity)
172 if (common === null || !isPlainRoot(root)) return null
173 const dir = common + '/autopilot/work-orders/' + root
174 let names: string[] = []
175 try {
176 names = (await $.fs.list(dir)).filter(entry => entry.kind === 'file' && entry.name.endsWith('.json')).map(entry => entry.name).sort().slice(0, MAX_RECEIPT_FILES)
177 } catch (_e) { return null }
178 const texts: string[] = []
179 for (const name of names) {
180 const text = await readText($, dir + '/' + name)
181 if (text !== null) texts.push(text)
182 }
183 const ms = earliestReceiptMs(texts, root)
184 if (ms !== null) receiptStarts.set(root, ms)
185 return ms
186}
187
188// The cost fuse's mode as hooks/cost-fuse.js resolves it: AUTOPILOT_COST_FUSE_MODE, else ~/.autopilot/config.json cost_fuse.mode, else warn.
189// (env first, then config: the same result as hooks/cost-fuse.js, which loads the config and then applies the env override)
190async function fuseModeOf($: EngineInterface, autopilotHome: string): Promise<string> {
191 try {
192 const env = await $.env.get('AUTOPILOT_COST_FUSE_MODE')
193 if (env === 'block' || env === 'warn' || env === 'off') return env
194 } catch (_e) { /* no env */ }
195 const cfg = await readObject($, autopilotHome + '/config.json')
196 const cf = cfg !== null && isObject(cfg.cost_fuse) ? cfg.cost_fuse : null
197 return cf !== null && (cf.mode === 'block' || cf.mode === 'warn' || cf.mode === 'off') ? cf.mode : 'warn'
198}
199
200async function buildSnapshot($: EngineInterface, nowMs: number): Promise<{ snap: LiveSnapshot; counts: Counts | null; acceptance: string | null }> {
201 const none = (snap: LiveSnapshot) => ({ snap, counts: null, acceptance: null })
202 const home = await $.env.get('HOME')
203 if (!home) return none(plain('no-pointer', STATE_TEXT.noPointer))
204 const pointerText = await readText($, home + '/.autopilot/live-pointer.json')
205 if (pointerText === null) return none(plain('no-pointer', STATE_TEXT.noPointer))
206 const parsed = parseJson(pointerText)
207 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== POINTER_SCHEMA || typeof parsed.value.live_base !== 'string' || !parsed.value.live_base) {
208 return none(plain('unreadable', STATE_TEXT.unreadable))
209 }
210 const liveBase = parsed.value.live_base.replace(/\/+$/, '')
211 const autopilotHome = typeof parsed.value.autopilot_home === 'string' && parsed.value.autopilot_home ? parsed.value.autopilot_home.replace(/\/+$/, '') : home + '/.autopilot'
212
213 const sid = await $.session.id()
214 const scope = await resolveScope($, autopilotHome, liveBase, sid, nowMs)
215 if (scope === null) return none(plain('no-project', STATE_TEXT.noProject))
216
217 // The root set: the marker root first, then the campaign roots (oldest -> newest). Each root is read like the marker root alone
218 // used to be; a root whose envelope is missing / stale / malformed is left out and never hides the others.
219 const primaryFind = { scope, found: await findEnvelope($, autopilotHome, liveBase, scope, nowMs) }
220 const finds: { scope: Scope; found: EnvelopeCheck }[] = [primaryFind]
221 for (const root of scope.campaign) {
222 const member: Scope = { key: scope.key, root, marker: null, campaign: [] }
223 finds.push({ scope: member, found: await findEnvelope($, autopilotHome, liveBase, member, nowMs) })
224 }
225 const fresh = finds.filter(f => f.found.status === 'fresh')
226 // the base envelope: the marker root's when fresh, else the first fresh campaign root's; with none fresh, the marker root's (stale /
227 // unavailable / unreadable exactly as before), else the first member that has any envelope
228 const baseFind = fresh[0] ?? (primaryFind.found.envelope !== null ? primaryFind : (finds.find(f => f.found.envelope !== null) ?? primaryFind))
229 const found = baseFind.found
230 const keyed = { project_key: scope.key, root_run_id: scope.root }
231 if (found.envelope === null) {
232 const malformed = finds.some(f => f.found.status === 'malformed')
233 return none(malformed ? plain('unreadable', STATE_TEXT.unreadable, keyed) : plain('unavailable', STATE_TEXT.unavailable(scope.key), keyed))
234 }
235 const published = typeof found.envelope.published_at === 'string' ? found.envelope.published_at : null
236
237 // pane data: the job page's model.json of each fresh root when it exists (acceptance axis, gate rows, progress, phase), a Link either way
238 const jobModelOf = async (root: string | null, env: Json): Promise<{ date: string | null; job: string; model: JobModel | null }> => {
239 const jobName = jobOf(env, root)
240 const jobDate = await findJobDate($, autopilotHome, scope.key, jobName)
241 let m: JobModel | null = null
242 if (jobDate !== null) {
243 m = readJobModel(await readText($, autopilotHome + '/review/' + scope.key + '/' + jobDate + '/' + jobName + '/current/model.json'))
244 if (m === null) jobDates.delete(scope.key + '/' + jobName)
245 }
246 return { date: jobDate, job: jobName, model: m }
247 }
248 const baseJob = await jobModelOf(baseFind.scope.root, found.envelope)
249 const primaryFresh = primaryFind.found.status === 'fresh'
250 // the marker root's own job model, kept apart: the sources-manifest fallback and the review Link belong to the marker root
251 const primaryJob = baseFind.scope === scope ? baseJob : null
252 const campaignJobs: JobModel[] = []
253 for (const f of fresh) {
254 if (f.scope === scope) continue
255 const cj = f.scope === baseFind.scope ? baseJob : await jobModelOf(f.scope.root, f.found.envelope as Json)
256 if (cj.model !== null) campaignJobs.push(cj.model)
257 }
258 const jobModel: JobModel | null = fresh.length > 0
259 ? mergeJobModels(primaryFresh && primaryJob !== null ? primaryJob.model : null, campaignJobs)
260 : baseJob.model
261 const env: Json = fresh.length > 0
262 ? mergeEnvelopes(found.envelope, fresh.filter(f => f !== baseFind).map(f => f.found.envelope as Json))
263 : found.envelope
264 const port = portOf(await readText($, liveBase + '/review/server.json'))
265 const common = {
266 ...keyed,
267 link: reviewLink(port, scope.key, baseJob.date, baseJob.job),
268 rows: paneRows(env),
269 gates: jobModel === null ? null : jobModel.gates,
270 published_at: published,
271 session_as_of: sessionUsd(env, sid).as_of,
272 host_as_of: typeof env.host_today_as_of === 'string' ? env.host_today_as_of : null,
273 }
274 if (fresh.length === 0) {
275 const at = published !== null && Number.isFinite(Date.parse(published)) ? hhmm(Date.parse(published)) : null
276 return { snap: plain('stale', STATE_TEXT.stale(scope.key, at), common), counts: null, acceptance: null }
277 }
278 // W3a sources. The per-session files are named by the sanitised sid; every sidecar is read for THIS scope only.
279 const fileSid = sanitizeSid(sid)
280 const want = { project_key: scope.key, root_run_id: scope.root }
281 const scopeKey = scopeKeyOf(scope.key, scope.root)
282 const manifestText = await readText($, liveBase + '/runs/sources/' + scopeKey + '.json')
283 const manifestParsed = manifestText === null ? null : parseJson(manifestText)
284 const manifest: Manifest | null = (manifestParsed !== null && manifestParsed.ok ? readManifest(manifestParsed.value, want) : null)
285 || (primaryJob === null || primaryJob.model === null ? null : readManifest(primaryJob.model.sources_manifest, want))
286 const tasks = readTasks(await readText($, liveBase + '/tasks/' + fileSid + '.json'), sid)
287 const attention = readAttention(await readText($, liveBase + '/attention/' + fileSid + '.json'), sid)
288 const turn = readTurn(await readText($, liveBase + '/turn/' + fileSid + '.json'), sid, nowMs, await readText($, liveBase + '/turn-effective/' + fileSid + '.json'))
289 // decisions: the proxy decisions of every fresh root of the set, summed; the foreman sidecar of the marker root (it is bound by session)
290 const decisionViews: DecisionsView[] = []
291 for (const f of fresh) {
292 const ds = f.scope.root === null ? scopeKey : scopeKeyOf(scope.key, f.scope.root)
293 const view = readDecisions(await readText($, liveBase + '/runs/' + ds + '.decisions.json'), { project_key: scope.key, root_run_id: f.scope.root })
294 if (view !== null) decisionViews.push(view)
295 }
296 const decisions = mergeDecisions(decisionViews)
297 const foreman = readForeman(await readText($, liveBase + '/runs/' + scopeKey + '.foreman.json'), want)
298 const loadSource = readLoadSource(await readText($, liveBase + '/load-source.json'))
299 const identity = isObject(env.scope) ? env.scope.repo_identity : null
300 // elapsed: the newest campaign root's bound receipt, else the marker root's
301 let receiptMs: number | null = null
302 let receiptRoot: string | null = null
303 for (const r of [...scope.campaign].reverse().concat(scope.root === null ? [] : [scope.root])) {
304 receiptMs = await receiptStart($, identity, r)
305 if (receiptMs !== null) { receiptRoot = r; break }
306 }
307 // P7: the stage-graph facts. Each is optional; an absent / foreign file reads as null and its slot / tab says "no data".
308 const marker = readMarker(scope.marker)
309 const qc = readQc(await readText($, liveBase + '/runs/' + scope.key + '.qc.json'), { project_key: scope.key, root_run_id: null })
310 const residue = readResidue(await readText($, liveBase + '/runs/' + scope.key + '.residue.json'), scope.key)
311 const stage = readStageWalk(await readText($, liveBase + '/stage/' + fileSid + '.json'), sid)
312 const review = readReview(await readText($, liveBase + '/runs/' + scope.key + '.review.json'), scope.key)
313 const src: Sources = { tasks, attention, turn, decisions, manifest, foreman, loadSource, marker, review, qc, residue, startMs: startMsOf(receiptRoot, env, tasks, scope.marker === null ? null : scope.marker.started_at, receiptMs) }
314 const ctx = ctxShown(ctxText(await readText($, liveBase + '/context/' + fileSid + '.json'), nowMs), manifest)
315 const band = bandView(env, jobModel, scope.key, nowMs, src)
316 return {
317 snap: plain('ok', bandLine1(band) + (band.reason === null ? '' : '\n' + band.reason), {
318 ...common, band, header: headerText(env, sid, ctx), decision: jobModel === null || !jobModel.needs_decision ? null : jobModel.decision,
319 sections: buildSections(src, foreman, identity, nowMs),
320 review,
321 detail: {
322 now_ms: nowMs, project: projectNameOf(identity, scope.key), marker, qc, stage, decisions, load_source: loadSource, residue,
323 spend: { session: sessionUsd(env, sid).text, host_all: usd(env.host_today_usd), brain: spendOf(env)?.brain ?? null, cap: spendOf(env)?.cap ?? null, mode: await fuseModeOf($, autopilotHome) },
324 },
325 }),
326 counts: countsOf(env),
327 acceptance: jobModel === null ? null : jobModel.acceptance,
328 }
329}
330
331// P7a advisory bridge: the rows the hooks appended to <live_base>/advisories/<sid>.jsonl, each new one a toast.
332async function pollAdvisories($: EngineInterface, nowMs: number): Promise<void> {
333 try {
334 const home = await $.env.get('HOME')
335 if (!home) return
336 const pointer = await readObject($, home + '/.autopilot/live-pointer.json')
337 if (pointer === null || pointer.schema !== POINTER_SCHEMA || typeof pointer.live_base !== 'string' || !pointer.live_base) return
338 const sid = await $.session.id()
339 if (!sid) return
340 const text = await readText($, pointer.live_base.replace(/\/+$/, '') + '/advisories/' + sanitizeSid(sid) + '.jsonl')
341 for (const row of takeNew(advisories, sid, parseAdvisories(text), nowMs)) $.ui.toast(row.text)
342 } catch (_e) {
343 // the bridge never takes the session down
344 }
345}
346
347async function refresh($: EngineInterface): Promise<void> {
348 try {
349 const nowMs = await $.clock.now()
350 await pollAdvisories($, nowMs)
351 const built = await buildSnapshot($, nowMs)
352 snapshot = built.snap
353 $.ui.invalidate('ui.render')
354 // toasts: only between two fresh snapshots of the same scope
355 if (built.snap.state === 'ok') {
356 const scope = (built.snap.project_key || '') + '/' + (built.snap.root_run_id || '')
357 if (previous !== null && previous.scope === scope) {
358 const texts: string[] = []
359 if (previous.counts !== null && built.counts !== null) {
360 const t = executionToast(previous.counts, built.counts)
361 if (t !== null) texts.push(t)
362 }
363 if (previous.acceptance !== null && built.acceptance !== null) {
364 const t = acceptanceToast(previous.acceptance, built.acceptance)
365 if (t !== null) texts.push(t)
366 }
367 for (const text of texts) $.ui.toast(text)
368 }
369 previous = { scope, counts: built.counts, acceptance: built.acceptance }
370 }
371 // pane: asked for once, and only where it would be a sidebar (fullscreen, >= 144 columns)
372 if (!paneAttempted && built.snap.project_key !== null && viewport !== null && viewport.isFullscreen === true && viewport.columns >= PANE_MIN_COLUMNS) {
373 paneAttempted = true
374 await $.ui.open({ id: PANE_ID, title: 'Autopilot' })
375 }
376 } catch (_e) {
377 // a bad tick must never take the session down; the next tick tries again
378 }
379}
380
381export const register: Register = on => {
382 on('session.start', async ($, e, next) => {
383 paneTab = 'now'
384 if (timer === undefined) timer = $.clock.every(TICK_MS, () => refresh($))
385 await refresh($)
386 return next(e)
387 })
388
389 // /clear ends the session but no session.start follows and the timer survives it (S7): only a real exit stops it.
390 on('session.end', async ($, e, next) => {
391 if (e.reason !== 'clear' && timer !== undefined) {
392 timer.cancel()
393 timer = undefined
394 }
395 return next(e)
396 })
397
398 on('ui.render', { component: 'AbovePrompt' }, ($, e, next) => {
399 viewport = e.viewport ? { columns: e.viewport.columns, isFullscreen: e.viewport.isFullscreen } : null
400 if (e.props.hasSurvey) return next(e)
401 // the ⓘ opens the panel on the Legend tab; pressed again while the panel is open it shows the next tab (the pane never gets the keyboard
402 // while the person holds the ⓘ, so this is the keyboard way to change tab). "Open" is the engine's own record, $.ui.panes() (the pane
403 // is listed until it closes, however it was opened). No hotkey: the band's autoFocus puts the keyboard path at Ctrl+x Tab, Enter.
404 const info = async () => {
405 let isOpen = false
406 try {
407 isOpen = (await $.ui.panes()).some(p => p.id === PANE_ID)
408 } catch (_e) {
409 isOpen = false
410 }
411 paneTab = isOpen ? PANE_TABS[(PANE_TABS.indexOf(paneTab) + 1) % PANE_TABS.length] : 'legend'
412 $.ui.invalidate('ui.render')
413 await $.ui.open({ id: PANE_ID, title: 'Autopilot', focus: true, closeOnEscape: true })
414 }
415 return Band($.ui.resolve(e), snapshot, e.props.bodyColumns, info)
416 })
417
418 on('ui.render', { component: 'Pane', requestId: PANE_ID }, ($, e) => Pane($.ui.resolve(e), snapshot, paneTab, (t: PaneTab) => { paneTab = t; $.ui.invalidate('ui.render') }))
419}
420mods/live/advisories.ts 57 lines1// mods/live/advisories.ts — the advisory bridge reader (stage-graph plan A1, P7a; Claude Code only).
2//
3// Four hooks (cost-tracker, version-drift-check, context-budget T1, suggest-compact) no longer inject their owner-facing
4// advice into the model's context. Each appends a row to <live_base>/advisories/<sanitised sid>.jsonl:
5// {id, kind, severity, text, at} (hooks/_shared/advisory-sink.js writes it; the text is the hook's own, unchanged)
6// This module reads that file for the CURRENT session and returns the rows not seen yet; register.ts shows each as a toast.
7// The running count (rows seen this session) is kept for a band chip (P7 renders the band). Read-only: nothing is written.
8
9export type Advisory = { id: string; kind: string; severity: 'info' | 'warn'; text: string; at: string }
10
11// Rows of one file, malformed lines and rows without an id / text skipped. Pure.
12export function parseAdvisories(text: string | null): Advisory[] {
13 if (text === null) return []
14 const out: Advisory[] = []
15 for (const line of text.split('\n')) {
16 if (!line.trim()) continue
17 let v: unknown
18 try { v = JSON.parse(line) } catch (_e) { continue }
19 if (typeof v !== 'object' || v === null) continue
20 const r = v as Record<string, unknown>
21 if (typeof r.id !== 'string' || !r.id || typeof r.text !== 'string' || !r.text) continue
22 out.push({
23 id: r.id,
24 kind: typeof r.kind === 'string' ? r.kind : 'advisory',
25 severity: r.severity === 'warn' ? 'warn' : 'info',
26 text: r.text,
27 at: typeof r.at === 'string' ? r.at : '',
28 })
29 }
30 return out
31}
32
33// A row already older than this when the mod first reads a session's file is history, not news: it is counted, not toasted.
34export const FRESH_MS = 5 * 60 * 1000
35
36export type AdvisoryTracker = { sid: string | null; seen: Set<string>; count: number }
37
38export function newTracker(): AdvisoryTracker {
39 return { sid: null, seen: new Set(), count: 0 }
40}
41
42// The rows to toast now. A new sid (or /clear) starts a fresh tracker. On the first read of a session only rows younger
43// than FRESH_MS are toasted; afterwards every unseen row is.
44export function takeNew(tracker: AdvisoryTracker, sid: string, rows: Advisory[], nowMs: number): Advisory[] {
45 const first = tracker.sid !== sid
46 if (first) { tracker.sid = sid; tracker.seen = new Set(); tracker.count = 0 }
47 const fresh: Advisory[] = []
48 for (const row of rows) {
49 if (tracker.seen.has(row.id)) continue
50 tracker.seen.add(row.id)
51 tracker.count += 1
52 const at = Date.parse(row.at)
53 if (!first || (Number.isFinite(at) && nowMs - at <= FRESH_MS)) fresh.push(row)
54 }
55 return fresh
56}
57mods/live/band.tsx 32 lines1// mods/live/band.tsx — the band above the prompt: ONE line (stage-graph P7, contract ②). Pure view over the snapshot's slots:
2// `layoutBand` (model.ts) applies the width table, this file only draws the result as a row of Texts and the ⓘ Button.
3// Colours are theme keys as text colour only: no backgroundColor, no inverse, no raw colour. No Image, no Raster.
4
5import { BAND_ICON, layoutBand, plainBandText } from './model'
6import type { LiveSnapshot } from './model'
7
8type El = (props: any) => any
9
10export function Band(el: { Box: El; Text: El; Button?: El }, snap: LiveSnapshot | null, columns: number, onInfo: () => void) {
11 const { Box, Text, Button } = el
12 const icon = Button === undefined
13 ? <Text>{BAND_ICON}</Text>
14 : <Button key="info" plain autoFocus onPress={onInfo}>{BAND_ICON}</Button>
15 if (snap === null || snap.band === null) {
16 const reason = snap === null ? 'live · waiting for the first snapshot' : snap.text
17 return (
18 <Box>
19 <Text dimColor wrap="truncate">{plainBandText(reason, columns) + ' │ '}</Text>
20 {icon}
21 </Box>
22 )
23 }
24 const line = layoutBand(snap.band.slots, columns)
25 return (
26 <Box>
27 {line.segs.map((s, i) => <Text key={'s' + i} color={s.color} bold={s.bold} wrap="truncate">{s.text}</Text>)}
28 {icon}
29 </Box>
30 )
31}
32mods/live/pane.tsx 99 lines1// mods/live/pane.tsx — the panel the ⓘ opens (stage-graph P7, contract ②): Legend · Now · Graph · Dispatch · Review · Decisions · Spend · Hygiene.
2// Pure view over the snapshot; `el` comes from $.ui.resolve(e). Every tab builds its lines in model.ts (a missing fact is an explicit
3// "no data" line there, never an empty tab); only the Dispatch rows are formatted here. Theme keys as text colour only.
4
5import { graphLines, hhmm, legendLines, nowLines, PANE_TABS, qcLines, reviewLines, decisionsTabLines, spendLines, hygieneLines, staleSuffix, TAB_LABEL } from './model'
6import type { LiveGateRow, LivePaneRow, LiveSnapshot, PaneLine, PaneTab } from './model'
7
8export type { PaneTab } from './model'
9
10type El = (props: any) => any
11
12const cell = (v: string | number | null | undefined): string => (v === null || v === undefined || v === '' ? '—' : String(v))
13
14function dispatchLine(r: LivePaneRow): string {
15 const started = r.started_at && Number.isFinite(Date.parse(r.started_at)) ? hhmm(Date.parse(r.started_at)) : null
16 return [
17 cell(r.run_id), cell(r.role), cell(r.runner) + '/' + cell(r.model), 'started ' + cell(started),
18 'elapsed ' + (r.elapsed_s === null ? '—' : r.elapsed_s + 's'), 'phase ' + cell(r.phase), 'rc ' + cell(r.rc),
19 'final ' + cell(r.final_status), 'probe ' + (r.probe_age_s === null ? '—' : r.probe_age_s + 's ago'),
20 ].join(' · ')
21}
22
23function gateLine(g: LiveGateRow): string {
24 return [cell(g.phase), 'gen ' + cell(g.generation), cell(g.verdict), cell(g.status)].join(' · ')
25}
26
27// The tab strip: Buttons when the surface has them (a click or Enter switches the tab), plain Text otherwise. It wraps on a narrow pane.
28function tabStrip(el: { Box: El; Text: El; Button?: El }, tab: PaneTab, onTab: (t: PaneTab) => void, hasReview: boolean) {
29 const { Box, Text, Button } = el
30 const label = (t: PaneTab) => TAB_LABEL[t] + (t === 'review' && hasReview ? ' •' : '')
31 return (
32 <Box flexWrap="wrap">
33 {PANE_TABS.map(t => Button === undefined
34 ? <Text key={t} bold={t === tab} dimColor={t !== tab}>{(t === tab ? '[' + label(t) + ']' : ' ' + label(t) + ' ') + ' '}</Text>
35 : <Button key={t} plain {...(t === tab ? { autoFocus: true as const } : {})} dimColor={t !== tab} bold={t === tab} onPress={() => onTab(t)}>{t === tab ? '[' + label(t) + ']' : ' ' + label(t) + ' '}</Button>)}
36 </Box>
37 )
38}
39
40export function Pane(el: { Box: El; Text: El; Link: El; Button?: El }, snap: LiveSnapshot | null, tab: PaneTab = 'now', onTab: (t: PaneTab) => void = () => {}) {
41 const { Box, Text, Link } = el
42 const line = (l: PaneLine, i: number) => l.segs !== undefined
43 ? <Box key={'l' + i} flexWrap="wrap">{l.segs.map((s, j) => <Text key={'g' + j} color={s.color} bold={s.bold}>{s.text}</Text>)}</Box>
44 : <Text key={'l' + i} bold={l.bold} dimColor={l.dim} color={l.color ?? (l.warn ? 'warning' : undefined)} wrap="truncate">{l.text}</Text>
45 if (snap === null) {
46 return (
47 <Box flexDirection="column">
48 {tabStrip(el, tab, onTab, false)}
49 <Text dimColor>waiting for the first snapshot</Text>
50 </Box>
51 )
52 }
53 const strip = tabStrip(el, tab, onTab, snap.review !== null)
54 const head = <Text bold>{snap.header}</Text>
55 if (tab === 'legend') return <Box flexDirection="column">{strip}{legendLines().map(line)}</Box>
56 if (tab === 'now') {
57 const b = snap.band
58 const d = snap.decision
59 return (
60 <Box flexDirection="column">
61 {strip}
62 {head}
63 {b === null ? null : (
64 <Box>
65 <Text bold={b.verdict !== '待命'} color={b.slots[0]?.segs[0]?.color} wrap="truncate">{b.mark + ' ' + b.verdict}</Text>
66 <Text wrap="truncate">{' ' + (snap.detail.project ?? b.project) + ' · ' + b.elapsed}</Text>
67 </Box>
68 )}
69 {nowLines(snap).map(line)}
70 {d === null || snap.sections.attention.length > 0 ? null : <Text bold color="warning">要你決定</Text>}
71 {d === null ? null : <Text>{d.question + staleSuffix(d)}</Text>}
72 {d === null ? null : d.options.map((o, i) => <Text key={'o' + i} wrap="truncate">{(i + 1) + '. ' + o.label + (o.consequence ? ' — ' + o.consequence : '')}</Text>)}
73 </Box>
74 )
75 }
76 if (tab === 'graph') return <Box flexDirection="column">{strip}{head}{graphLines(snap.detail.stage).map(line)}</Box>
77 if (tab === 'review') return <Box flexDirection="column">{strip}{head}{reviewLines(snap.review).map(line)}{qcLines(snap.detail.qc).map(line)}</Box>
78 if (tab === 'decisions') return <Box flexDirection="column">{strip}{head}{decisionsTabLines(snap).map(line)}</Box>
79 if (tab === 'spend') return <Box flexDirection="column">{strip}{head}{spendLines(snap).map(line)}</Box>
80 if (tab === 'hygiene') return <Box flexDirection="column">{strip}{head}{hygieneLines(snap.detail.load_source, snap.detail.residue).map(line)}</Box>
81 const rows = snap.rows
82 const gates = snap.gates
83 return (
84 <Box flexDirection="column">
85 {strip}
86 {head}
87 {rows === null ? <Text dimColor>no data · no runs published</Text> : <Text dimColor>dispatch · execution status, not progress (執行狀態,不是進度)</Text>}
88 {rows === null ? null : rows.length === 0 ? <Text dimColor>no runs in this scope</Text> : rows.map((r, i) => <Text key={'r' + i} wrap="truncate">{dispatchLine(r)}</Text>)}
89 {rows === null ? null : <Text dimColor>gates · review receipts of this job</Text>}
90 {rows === null ? null : gates === null ? <Text dimColor>no review page published yet · gate rows —</Text> : gates.length === 0 ? <Text dimColor>no review receipt</Text> : gates.map((g, i) => <Text key={'g' + i} wrap="truncate">{gateLine(g)}</Text>)}
91 {snap.sections.waiting.map(line)}
92 {snap.sections.tasks.map(line)}
93 {snap.sections.foreman.map(line)}
94 {snap.link === null ? null : <Link href={snap.link} label="open the review page" />}
95 {snap.published_at === null ? null : <Text dimColor>snapshot published {snap.published_at}{snap.session_as_of ? ' · session cost as of ' + snap.session_as_of : ''}{snap.host_as_of ? ' · host cost as of ' + snap.host_as_of : ''}</Text>}
96 </Box>
97 )
98}
99mods/live/model.ts 1432 lines1// mods/live/model.ts — pure helpers for the live mod (plan P1c). No `$` in here: every file read, every clock
2// read and every UI call stays in register.ts. Nothing in this file derives a number the producer already
3// published: counts, cost, context, the progress numbers and the decision are copied from the envelope / context
4// file / job model. The selections made here: the verdict word (a precedence over fields the producers already
5// published), the project short name, the earliest start and the quietest stalled run.
6// W3a: the per-session files (tasks, attention), the watcher sidecars (decisions, foreman, sources manifest) and the
7// campaign progress receipts are parsed here too; every sidecar carries its scope and a scope that is not the one
8// wanted is an absent file (same rule as checkEnvelope).
9
10
11// ---- the snapshot the tick builds and the band / pane draw (module state in register.ts) ----
12export type LivePaneRow = {
13 run_id: string
14 role: string | null
15 runner: string | null
16 model: string | null
17 started_at: string | null
18 elapsed_s: number | null
19 phase: string | null
20 rc: number | null
21 final_status: string | null
22 probe_age_s: number | null
23}
24
25export type LiveGateRow = {
26 phase: string | null
27 generation: number | null
28 verdict: string | null
29 status: string | null
30}
31
32export type LiveDecision = { question: string; options: { label: string; consequence: string }[]; stale: boolean; age_s: number | null }
33
34// one pane line: plain text plus how it is drawn
35export type PaneLine = { text: string; dim?: boolean; bold?: boolean; warn?: boolean; color?: ThemeKey; segs?: Seg[] }
36// attention = something awaits the human (drawn first); waiting = idle / source-not-wired note; the rest follow the gate rows
37export type PaneSections = { attention: PaneLine[]; waiting: PaneLine[]; tasks: PaneLine[]; decisions: PaneLine[]; foreman: PaneLine[] }
38export const NO_SECTIONS: PaneSections = { attention: [], waiting: [], tasks: [], decisions: [], foreman: [] }
39
40// The two band lines of an ok snapshot. Every ok band carries one of five words; the fifth, 待命, is the idle word.
41export type BandView = {
42 mark: string
43 verdict: string
44 head: string // "<project> · <phase> · <elapsed> · " (the progress follows it)
45 progress: string
46 progressDim: boolean // an unfrozen denominator is drawn dim
47 reason: string | null
48 project: string // P7 Now tab row: verdict + project + elapsed; progress and phase are their own lines
49 elapsed: string
50 phase: string
51 slots: Slot[] // P7: the one-line band's slots in priority order (width-independent; layoutBand applies the width table)
52}
53
54export type LiveSnapshot = {
55 // ok | stale | unavailable | unreadable | no-pointer | no-project
56 state: 'ok' | 'stale' | 'unavailable' | 'unreadable' | 'no-pointer' | 'no-project'
57 // the exact band text (an ok snapshot: line 1, then the reason on its own line)
58 text: string
59 band: BandView | null // set for state ok only
60 // pane header row: session cost / host cost / context (an ok snapshot); the state text otherwise
61 header: string
62 // what is awaited from the human, from the job model's decision object (null = nothing awaited)
63 decision: LiveDecision | null
64 project_key: string | null
65 root_run_id: string | null
66 // page the Link in the pane points at (null until an envelope was found)
67 link: string | null
68 // pane data, copied from the envelope / the review job model; null = not published
69 rows: LivePaneRow[] | null
70 gates: LiveGateRow[] | null
71 // W3a pane sections, text already built from the sources (empty = nothing to say)
72 sections: PaneSections
73 // P7d: plan-review state + latest code-review round of this project (null = none published)
74 review: ReviewView | null
75 published_at: string | null
76 session_as_of: string | null
77 host_as_of: string | null
78 // P7: the facts the panel's tabs draw (null = the fact is not published: the tab says "no data")
79 detail: PaneDetail
80}
81
82export type SpendView = { session: string; host_all: string; brain: number | null; cap: number | null; mode: string }
83export type PaneDetail = {
84 now_ms: number | null
85 project: string | null
86 marker: MarkerView | null
87 qc: QcView | null
88 stage: StageWalkView | null
89 decisions: DecisionsView | null
90 load_source: LoadSourceView | null
91 residue: ResidueView | null
92 spend: SpendView | null
93}
94export const NO_DETAIL: PaneDetail = { now_ms: null, project: null, marker: null, qc: null, stage: null, decisions: null, load_source: null, residue: null, spend: null }
95
96export const SNAPSHOT_SCHEMA = 'autopilot.runs-live/1'
97export const MODEL_SCHEMA = 'review-job-model/1'
98export const POINTER_SCHEMA = 'autopilot.live-pointer/1'
99export const DEFAULT_VALID_FOR_S = 180
100export const CONTEXT_MAX_AGE_MS = 120000
101export const DEFAULT_REVIEW_PORT = 8787
102export const RUNS_HINT = 'autopilot status runs --watch'
103
104export type Json = Record<string, unknown>
105
106export const isObject = (v: unknown): v is Json => typeof v === 'object' && v !== null && !Array.isArray(v)
107export const isKey = (v: unknown): v is string => typeof v === 'string' && /^[0-9a-f]{16}$/.test(v)
108
109// -> { ok, value }: ok false = not JSON (malformed), never throws.
110export function parseJson(text: string): { ok: true; value: unknown } | { ok: false } {
111 try {
112 return { ok: true, value: JSON.parse(text) }
113 } catch (_e) {
114 return { ok: false }
115 }
116}
117
118// Same rule as src/status/runs-watch.js safeSegment: it names the per-root envelope file and the job directory.
119export function safeSegment(value: string): string {
120 return String(value).replace(/[^A-Za-z0-9_.-]/g, '_').slice(0, 96) || '_'
121}
122
123export function scopeKeyOf(projectKey: string, root: string | null): string {
124 return root ? projectKey + '--' + safeSegment(root) : projectKey
125}
126
127export function hhmm(ms: number): string {
128 const d = new Date(ms)
129 return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0')
130}
131
132export const usd = (n: unknown): string => (typeof n === 'number' && Number.isFinite(n) ? '$' + n.toFixed(2) : '$—')
133
134// Longest path-boundary prefix of `real` among the merged runs/paths maps; -> project_key | null.
135export function longestPrefixKey(real: string, maps: Json[]): string | null {
136 let best: { len: number; key: string } | null = null
137 for (const map of maps) {
138 for (const p of Object.keys(map)) {
139 const entry = map[p]
140 if (!isObject(entry) || !isKey(entry.project_key)) continue
141 const base = p.length > 1 ? p.replace(/\/+$/, '') : p
142 const hit = real === base || real.startsWith(base === '/' ? '/' : base + '/')
143 if (hit && (best === null || base.length > best.len)) best = { len: base.length, key: entry.project_key }
144 }
145 }
146 return best === null ? null : best.key
147}
148
149export type EnvelopeCheck = { status: 'missing' | 'malformed' | 'fresh' | 'stale'; envelope: Json | null }
150
151// readEnvelope's contract (src/status/runs-watch.js): a wrong schema or a scope that is not the one wanted is a
152// MISSING file; fresh depends on published_at alone, never on when the mod read the file.
153export function checkEnvelope(text: string | null, want: { project_key: string; root_run_id: string | null }, nowMs: number): EnvelopeCheck {
154 if (text === null) return { status: 'missing', envelope: null }
155 const parsed = parseJson(text)
156 if (!parsed.ok) return { status: 'malformed', envelope: null }
157 const env = parsed.value
158 if (!isObject(env) || env.schema !== SNAPSHOT_SCHEMA || !isObject(env.scope)) return { status: 'missing', envelope: null }
159 const sameRoot = (typeof env.scope.root_run_id === 'string' ? env.scope.root_run_id : null) === want.root_run_id
160 if (env.scope.project_key !== want.project_key || !sameRoot) return { status: 'missing', envelope: null }
161 const publishedMs = typeof env.published_at === 'string' ? Date.parse(env.published_at) : NaN
162 const validFor = typeof env.valid_for_s === 'number' && Number.isFinite(env.valid_for_s) ? env.valid_for_s : DEFAULT_VALID_FOR_S
163 if (!Number.isFinite(publishedMs) || nowMs - publishedMs > validFor * 1000) return { status: 'stale', envelope: env }
164 return { status: 'fresh', envelope: env }
165}
166
167const rowsOf = (env: Json): Json[] | null => (Array.isArray(env.runs) ? env.runs.filter(isObject) : null)
168const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v)
169const startedMs = (r: Json): number => Date.parse(String(r.started_at || r.fact_at || ''))
170
171const VERDICT = {
172 decide: { mark: '▲', word: '要你決定', key: 'warning' },
173 stalled: { mark: '⏸', word: '疑似卡住', key: 'error' },
174 waiting: { mark: '✓', word: '完成待驗收', key: 'success' },
175 running: { mark: '●', word: '進行中', key: 'suggestion' },
176 idle: { mark: '◌', word: '待命', key: 'inactive' },
177} as const
178
179// "<project>": the last directory of a normal repo's git common dir. Any other shape (bare repo, no prefix, a path
180// inside .git, empty) falls back to the first 8 hex of the project key. Pure string work: never git, never a hash.
181export function projectName(identity: unknown, projectKey: string): string {
182 const fallback = projectKey.slice(0, 8)
183 const PREFIX = 'git-common-dir:'
184 if (typeof identity !== 'string' || !identity.startsWith(PREFIX)) return fallback
185 const dir = identity.slice(PREFIX.length).replace(/\/+$/, '')
186 if (!dir.endsWith('/.git')) return fallback
187 const name = dir.slice(0, -'/.git'.length).split('/').pop()
188 return name ? name : fallback
189}
190
191// "38m" / "2h14m" / "1d3h": now minus the start of this piece of work; no start -> an em dash.
192export function elapsedFrom(startMs: number | null, nowMs: number): string {
193 if (startMs === null || !Number.isFinite(startMs) || !Number.isFinite(nowMs) || nowMs < startMs) return '—'
194 const minutes = Math.floor((nowMs - startMs) / 60000)
195 if (minutes < 60) return minutes + 'm'
196 const hours = Math.floor(minutes / 60)
197 if (hours < 24) return hours + 'h' + (minutes % 60) + 'm'
198 return Math.floor(hours / 24) + 'd' + (hours % 24) + 'h'
199}
200
201// the earliest start among the scope's runs (null: none has one)
202export function earliestRunMs(env: Json): number | null {
203 let earliest = Infinity
204 for (const r of rowsOf(env) || []) {
205 const ms = Date.parse(String(r.started_at || ''))
206 if (Number.isFinite(ms) && ms < earliest) earliest = ms
207 }
208 return earliest === Infinity ? null : earliest
209}
210
211// "seconds ago" in the same shorthand as the band: 45s / 20m / 3h5m / 2d1h
212export function ageText(s: number): string {
213 if (!Number.isFinite(s) || s < 0) return '—'
214 if (s < 60) return Math.floor(s) + 's'
215 const m = Math.floor(s / 60)
216 if (m < 60) return m + 'm'
217 const h = Math.floor(m / 60)
218 if (h < 24) return h + 'h' + (m % 60) + 'm'
219 return Math.floor(h / 24) + 'd' + (h % 24) + 'h'
220}
221
222export type JobProgress = { frozen: boolean; percent: number | null; done: number | null; total: number | null }
223
224// frozen: "62.5%(5/8)" (the percent as the job model published it). Not frozen: "3 done*", drawn dim. No count: an em dash.
225export function progressView(p: JobProgress | null): { text: string; dim: boolean } {
226 if (p === null) return { text: '—', dim: false }
227 if (p.frozen && p.percent !== null && p.done !== null && p.total !== null) return { text: p.percent + '%(' + p.done + '/' + p.total + ')', dim: false }
228 if (p.done !== null) return { text: p.done + ' done*', dim: true }
229 return { text: '—', dim: false }
230}
231
232// The stalled rows are the ones the producer flagged (`stall: true`); the age is the producer's last_event_age_s.
233export function stalledReason(env: Json): string | null {
234 const stalled = (rowsOf(env) || []).filter(r => r.stall === true)
235 if (stalled.length === 0) return null
236 const ages = stalled.map(r => r.last_event_age_s).filter(finite)
237 if (ages.length === 0) return '有 ' + stalled.length + ' 個派工疑似沒有輸出'
238 return '最久的派工 ' + Math.floor(Math.max(...ages) / 60) + 'm 沒有輸出'
239}
240
241// ---- W3a: the sources the band and pane read besides the envelope and the job model ----
242export const TASKS_SCHEMA = 'autopilot.session-tasks/1'
243export const ATTENTION_SCHEMA = 'autopilot.attention/1'
244export const TURN_SCHEMA = 'autopilot.session-turn/1'
245export const TURN_EFFECTIVE_SCHEMA = 'autopilot.session-turn-effective/1'
246export const DECISIONS_SCHEMA = 'autopilot.decisions-sidecar/1'
247export const FOREMAN_SCHEMA = 'autopilot.foreman-activity/1'
248export const SOURCES_SCHEMA = 'autopilot.sources/1'
249export const LOAD_SOURCE_SCHEMA = 'autopilot.load-source/1'
250export const NOT_WIRED = '來源未接'
251export const TODO_TOOLS_HINT = '任務工具未開 · 設 CLAUDE_CODE_ENABLE_TODO_TOOLS=1 後才會記錄任務'
252
253// scripts/lib/live-state-dir.js sanitizeSessionId: every scalar outside [A-Za-z0-9_-] becomes one "_", first 64 scalars, empty -> unknown.
254export function sanitizeSid(raw: string): string {
255 if (raw.length === 0) return 'unknown'
256 const kept = Array.from(raw).slice(0, 64).map(ch => (/^[A-Za-z0-9_-]$/.test(ch) ? ch : '_')).join('')
257 return kept.length > 0 ? kept : 'unknown'
258}
259
260export type ScopeWant = { project_key: string; root_run_id: string | null }
261
262// a sidecar names the scope it was built for; any other scope is an absent file (the checkEnvelope rule)
263function scopeMatches(v: Json, want: ScopeWant): boolean {
264 const s = v.scope
265 if (!isObject(s)) return false
266 const root = typeof s.root_run_id === 'string' && s.root_run_id ? s.root_run_id : null
267 return s.project_key === want.project_key && root === want.root_run_id
268}
269
270const isCount = (v: unknown): v is number => typeof v === 'number' && Number.isInteger(v) && v >= 0
271const str = (v: unknown): string | null => (typeof v === 'string' && v ? v : null)
272
273export type TasksView = {
274 total: number; completed: number; in_progress: number
275 current: string | null
276 first_created_ms: number | null
277 rows: { subject: string; status: string }[]
278}
279
280// <live>/tasks/<sid>.json (W1d). Counts are the writer's own (deleted tasks excluded there); another session's file is absent.
281export function readTasks(text: string | null, sid: string): TasksView | null {
282 if (text === null) return null
283 const parsed = parseJson(text)
284 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== TASKS_SCHEMA) return null
285 const v = parsed.value
286 if (typeof v.session_id === 'string' && v.session_id !== sid) return null
287 const c = v.counts
288 if (!isObject(c) || !isCount(c.total) || !isCount(c.completed) || !isCount(c.in_progress)) return null
289 const first = typeof v.first_created_at === 'string' ? Date.parse(v.first_created_at) : NaN
290 const rows = (Array.isArray(v.tasks) ? v.tasks : []).filter(isObject)
291 .map(t => ({ subject: str(t.subject) || '—', status: str(t.status) || 'pending' }))
292 .filter(t => t.status !== 'deleted')
293 return {
294 total: c.total, completed: c.completed, in_progress: c.in_progress,
295 current: isObject(v.current) ? str(v.current.subject) : null,
296 first_created_ms: Number.isFinite(first) ? first : null,
297 rows,
298 }
299}
300
301export type AttentionView = { kind: 'permission' | 'question' | 'idle'; summary: string; since_ms: number | null }
302
303// <live>/attention/<sid>.json (W1e): present only while the session waits. A file of another session / schema is absent.
304export function readAttention(text: string | null, sid: string): AttentionView | null {
305 if (text === null) return null
306 const parsed = parseJson(text)
307 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== ATTENTION_SCHEMA) return null
308 const v = parsed.value
309 if (typeof v.session_id === 'string' && v.session_id !== sid) return null
310 const kind = v.kind
311 if (kind !== 'permission' && kind !== 'question' && kind !== 'idle') return null
312 const since = typeof v.since === 'string' ? Date.parse(v.since) : NaN
313 return { kind, summary: str(v.summary) || '', since_ms: Number.isFinite(since) ? since : null }
314}
315
316export type TurnView = { state: 'active' | 'ended'; since_ms: number | null }
317// the session marker's TTL (scripts/session-mode.js DEFAULT_TTL_HOURS): a turn file older than that is a crashed session's leftover
318export const TURN_TTL_MS = 24 * 3600 * 1000
319
320// <live>/turn/<sid>.json (TURN): active while a prompt is being worked, ended after Stop. Another session / schema, no `since`
321// that parses, or one older than the marker TTL is absent (stale).
322// effectiveText = <live>/turn-effective/<sid>.json (GATEFIX2, watcher-owned): an Escape interrupt fires no Stop, so the watcher reads it
323// from the transcript and publishes `ended` for ONE turn (its `turn_since`). It ends an `active` turn only while the turn file's `since`
324// is that same value, so a newer UserPromptSubmit wins at once. Anything unreadable / foreign leaves the turn as the hook wrote it.
325export function readTurn(text: string | null, sid: string, nowMs: number, effectiveText: string | null = null): TurnView | null {
326 if (text === null) return null
327 const parsed = parseJson(text)
328 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== TURN_SCHEMA) return null
329 const v = parsed.value
330 if (typeof v.session_id === 'string' && v.session_id !== sid) return null
331 if (v.state !== 'active' && v.state !== 'ended') return null
332 const since = typeof v.since === 'string' ? Date.parse(v.since) : NaN
333 if (!Number.isFinite(since) || nowMs - since > TURN_TTL_MS) return null
334 if (v.state === 'active' && effectiveText !== null) {
335 const eff = parseJson(effectiveText)
336 if (eff.ok && isObject(eff.value) && eff.value.schema === TURN_EFFECTIVE_SCHEMA && eff.value.state === 'ended'
337 && (typeof eff.value.session_id !== 'string' || eff.value.session_id === sid) && eff.value.turn_since === v.since) return { state: 'ended', since_ms: since }
338 }
339 return { state: v.state, since_ms: since }
340}
341
342export type Manifest = Record<string, { installed: boolean; enabled: boolean } | undefined>
343
344// autopilot.sources/1 (WATCH-A): sidecar `<live>/runs/sources/<scope_key>.json`, or the job model's own copy.
345export function readManifest(value: unknown, want: ScopeWant): Manifest | null {
346 if (!isObject(value) || value.schema !== SOURCES_SCHEMA || !isObject(value.sources) || !scopeMatches(value, want)) return null
347 const out: Manifest = {}
348 for (const k of Object.keys(value.sources)) {
349 const e = value.sources[k]
350 if (isObject(e) && typeof e.installed === 'boolean' && typeof e.enabled === 'boolean') out[k] = { installed: e.installed, enabled: e.enabled }
351 }
352 return out
353}
354
355// true / false = the writer is / is not installed AND enabled; null = no manifest, or it does not name this source
356export function liveSource(m: Manifest | null, name: string): boolean | null {
357 if (m === null) return null
358 const e = m[name]
359 return e === undefined ? null : e.installed && e.enabled
360}
361
362// the manifest says none of these writers is live (every one is named, and every one is off)
363export function notWired(m: Manifest | null, names: string[]): boolean {
364 return names.every(n => liveSource(m, n) === false)
365}
366
367export type DecisionRow = { round: number | null; decision: string; irreversible: boolean; writer: string | null; decision_id: string | null; source: string | null; root: string | null }
368export type LadderView = { rung: string; at: string }
369export type DecisionsView = { rows: DecisionRow[]; count: number; irreversible: number; writers: string[]; undocumented: number; identity: string | null; root: string | null; ladder: LadderView | null }
370
371// <live>/runs/<scope_key>.decisions.json (WATCH-B). The counts are the publisher's own.
372export function readDecisions(text: string | null, want: ScopeWant): DecisionsView | null {
373 if (text === null) return null
374 const parsed = parseJson(text)
375 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== DECISIONS_SCHEMA || !scopeMatches(parsed.value, want)) return null
376 const v = parsed.value
377 if (!isCount(v.count) || !isCount(v.irreversible_count) || !isCount(v.undocumented_dispatches) || !Array.isArray(v.writers_wired)) return null
378 const rows = (Array.isArray(v.rows) ? v.rows : []).filter(isObject).map(r => ({
379 round: Number.isInteger(r.round) ? (r.round as number) : null,
380 decision: str(r.decision) || '—',
381 irreversible: r.irreversible === true,
382 writer: str(r.writer), decision_id: str(r.decision_id), source: str(r.source), root: want.root_run_id,
383 }))
384 const scope = v.scope as Json
385 return {
386 rows, count: v.count, irreversible: v.irreversible_count, writers: v.writers_wired.filter((w): w is string => typeof w === 'string'),
387 undocumented: v.undocumented_dispatches, identity: str(scope.repo_identity), root: want.root_run_id,
388 ladder: readLadder(v.ladder),
389 }
390}
391
392// D2: the latest unknown-escalation rung the sidecar publishes (`{ rung: "U0".."U5", at }`); anything else is no ladder.
393export function readLadder(v: unknown): LadderView | null {
394 if (!isObject(v) || typeof v.rung !== 'string' || !/^U[0-5]$/.test(v.rung) || typeof v.at !== 'string' || !Number.isFinite(Date.parse(v.at))) return null
395 return { rung: v.rung, at: v.at }
396}
397
398// ---- P1W SCOPE: one session, several roots (the marker's job root + the campaigns it launched) ----
399// The band reads every root of the set and merges. Live counts, stall rows and proxy decisions are summed; the frozen
400// progress and the phase come from the NEWEST campaign root that has them (campaign roots are ordered oldest -> newest).
401
402// base's scope / sessions / host cost, every fresh envelope's runs concatenated and counts summed (an envelope with no
403// readable counts adds none; when no envelope has counts the base's are kept).
404export function mergeEnvelopes(base: Json, others: Json[]): Json {
405 if (others.length === 0) return base
406 const all = [base, ...others]
407 const runs: Json[] = []
408 let sum: Counts | null = null
409 for (const env of all) {
410 runs.push(...(rowsOf(env) || []))
411 const c = countsOf(env)
412 if (c !== null) sum = sum === null ? { ...c } : { confirmed_live: sum.confirmed_live + c.confirmed_live, exited: sum.exited + c.exited, unknown: sum.unknown + c.unknown }
413 }
414 const merged: Json = { ...base, runs }
415 if (sum !== null) merged.counts = { ...(isObject(base.counts) ? base.counts : {}), ...sum }
416 return merged
417}
418
419const isFrozenProgress = (p: JobProgress | null): p is JobProgress => p !== null && p.frozen && p.done !== null && p.total !== null
420
421// primary = the marker root's job model (or null), campaigns = the campaign roots' job models, oldest -> newest (absent ones left out).
422// Acceptance, gates and sources come from the primary (else the newest campaign); a decision awaited under ANY root is awaited;
423// progress = the newest campaign's frozen progress, phase = the newest campaign's phase, each falling back to the base's own.
424export function mergeJobModels(primary: JobModel | null, campaigns: JobModel[]): JobModel | null {
425 const newestFirst = [...campaigns].reverse()
426 const base: JobModel | null = primary !== null ? primary : (newestFirst[0] ?? null)
427 if (base === null) return null
428 const all = primary !== null ? [primary, ...campaigns] : campaigns
429 const asking = all.find(m => m.needs_decision)
430 const frozen = newestFirst.find(m => isFrozenProgress(m.progress))
431 const phased = newestFirst.find(m => m.phase !== null)
432 return {
433 ...base,
434 needs_decision: asking !== undefined,
435 decision: asking !== undefined ? asking.decision : base.decision,
436 conclusion: asking !== undefined ? asking.conclusion : base.conclusion,
437 progress: frozen !== undefined ? frozen.progress : base.progress,
438 phase: phased !== undefined ? phased.phase : base.phase,
439 }
440}
441
442export function mergeDecisions(views: DecisionsView[]): DecisionsView | null {
443 const first = views[0]
444 if (first === undefined) return null
445 if (views.length === 1) return first
446 const writers: string[] = []
447 for (const v of views) for (const w of v.writers) if (!writers.includes(w)) writers.push(w)
448 return {
449 rows: views.flatMap(v => v.rows),
450 count: views.reduce((n, v) => n + v.count, 0),
451 irreversible: views.reduce((n, v) => n + v.irreversible, 0),
452 writers,
453 undocumented: views.reduce((n, v) => n + v.undocumented, 0),
454 identity: views.map(v => v.identity).find(i => i !== null) || null,
455 root: first.root,
456 ladder: views.map(v => v.ladder).filter((l): l is LadderView => l !== null).sort((a, b) => Date.parse(b.at) - Date.parse(a.at))[0] ?? null,
457 }
458}
459
460export type ForemanView = {
461 binding: string
462 agents: { id: string; description: string | null; label: string | null; at_ms: number | null; age_s: number | null; stale: boolean; stamped: boolean; ended: boolean }[]
463 stage: string | null; stage_source: string | null; stage_at_ms: number | null; stage_age_s: number | null
464}
465
466// <live>/runs/<scope_key>.foreman.json (WATCH-B): liveness ages only, never a verdict. Absent file = not wired.
467export function readForeman(text: string | null, want: ScopeWant): ForemanView | null {
468 if (text === null) return null
469 const parsed = parseJson(text)
470 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== FOREMAN_SCHEMA || !scopeMatches(parsed.value, want)) return null
471 const v = parsed.value
472 const ms = (x: unknown): number | null => { const n = typeof x === 'string' ? Date.parse(x) : NaN; return Number.isFinite(n) ? n : null }
473 const agents = (Array.isArray(v.agents) ? v.agents : []).filter(isObject).map(a => ({
474 id: str(a.agent_id) || '?', description: str(a.description), label: str(a.label),
475 at_ms: ms(a.last_activity_at), age_s: finite(a.age_s) ? a.age_s : null, stale: a.stale === true,
476 stamped: a.stamped === true, ended: ms(a.ended_at) !== null,
477 }))
478 return {
479 binding: str(v.binding) || 'session', agents,
480 stage: str(v.stage), stage_source: str(v.stage_source), stage_at_ms: ms(v.stage_at), stage_age_s: finite(v.stage_age_s) ? v.stage_age_s : null,
481 }
482}
483
484export const QC_SCHEMA = 'autopilot.qc-status/1'
485export type QcView = { state: 'ok' | 'owed' | 'not_needed' | 'unknown'; range: string | null; protected_files_count: number; evidence: 'trailer' | 'artifact' | null }
486
487// <live>/runs/<project_key>.qc.json (stage-graph P7c): the pre-push qc-gate decision for HEAD vs its upstream, re-derived by the watcher
488// (scripts/qc-evidence-status.js). Absent / foreign scope / unknown state = null (nothing to say).
489export function readQc(text: string | null, want: ScopeWant): QcView | null {
490 if (text === null) return null
491 const parsed = parseJson(text)
492 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== QC_SCHEMA || !scopeMatches(parsed.value, { project_key: want.project_key, root_run_id: null })) return null
493 const v = parsed.value
494 if (v.state !== 'ok' && v.state !== 'owed' && v.state !== 'not_needed' && v.state !== 'unknown') return null
495 return {
496 state: v.state, range: str(v.range), protected_files_count: isCount(v.protected_files_count) ? v.protected_files_count : 0,
497 evidence: v.evidence === 'trailer' || v.evidence === 'artifact' ? v.evidence : null,
498 }
499}
500
501// the review-slot chip: `QC ✓` (evidence present), `QC owed` (protected diff, no evidence); nothing owed or unknown = no chip
502export function qcChip(q: QcView | null): string | null {
503 if (q === null) return null
504 return q.state === 'ok' ? 'QC ✓' : q.state === 'owed' ? 'QC owed' : null
505}
506
507// `git-common-dir:<abs path>` -> the path (only an absolute one); anything else -> null
508export function commonDirOf(identity: unknown): string | null {
509 if (typeof identity !== 'string' || !identity.startsWith('git-common-dir:')) return null
510 const dir = identity.slice('git-common-dir:'.length).replace(/\/+$/, '')
511 return dir.startsWith('/') && !dir.split('/').includes('..') ? dir : null
512}
513
514// the same root-name rule as src/status/work-order-progress.js: a root names a directory, so it must be a plain segment
515export const isPlainRoot = (root: string): boolean => /^[A-Za-z0-9._-]+$/.test(root) && root !== '.' && root !== '..'
516
517// Earliest `issued_at` of the controller progress receipts bound to `root` (W1a's binding rule: the receipt's own
518// root_run_id equals the root; a work-order file whose own top-level root_run_id is present and different is unbound).
519export function earliestReceiptMs(texts: string[], root: string): number | null {
520 let best = Infinity
521 for (const text of texts) {
522 const parsed = parseJson(text)
523 if (!parsed.ok || !isObject(parsed.value)) continue
524 const file = parsed.value
525 if (typeof file.root_run_id === 'string' && file.root_run_id !== root) continue
526 const list = isObject(file.controller) && Array.isArray(file.controller.progress_receipts) ? file.controller.progress_receipts : []
527 for (const r of list) {
528 if (!isObject(r) || r.artifact_type !== 'controller_progress_receipt' || r.root_run_id !== root) continue
529 const ms = typeof r.issued_at === 'string' ? Date.parse(r.issued_at) : NaN
530 if (Number.isFinite(ms) && ms < best) best = ms
531 }
532 }
533 return best === Infinity ? null : best
534}
535
536// Start of this piece of work, chosen by campaign-vs-session, not by "has a root" (every plain session carries a root since
537// PLAINROOT): the earliest bound progress receipt (a campaign has one; a session never does), else the tasks file's
538// first_created_at, else the marker's started_at, else the earliest run, else null (shown as an em dash).
539export function startMsOf(root: string | null, env: Json, tasks: TasksView | null, markerStartedAt: unknown, receiptMs: number | null): number | null {
540 if (root !== null && receiptMs !== null) return receiptMs
541 if (tasks !== null && tasks.first_created_ms !== null) return tasks.first_created_ms
542 const m = typeof markerStartedAt === 'string' ? Date.parse(markerStartedAt) : NaN
543 return Number.isFinite(m) ? m : earliestRunMs(env)
544}
545
546// P7b: <live>/load-source.json (machine-wide, not scope-bound): which copy of the plugin Claude Code loads. Watcher-published
547// (src/status/load-source.js); an absent / foreign-schema file reads as null (= no chip).
548export type LoadSourceView = {
549 plugin_version: string | null
550 source: string // "dev" | "cache:<semver>" | "unknown"
551 marketplace: string
552 behind_upstream: number | null
553 flags: string[]
554 stale_cache_dirs: { dir: string; in_use_pids: string[]; alive: string[] }[]
555}
556export function readLoadSource(text: string | null): LoadSourceView | null {
557 if (text === null) return null
558 const parsed = parseJson(text)
559 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== LOAD_SOURCE_SCHEMA) return null
560 const v = parsed.value
561 const strs = (x: unknown): string[] => (Array.isArray(x) ? x.filter((y): y is string => typeof y === 'string') : [])
562 return {
563 plugin_version: str(v.plugin_version), source: str(v.source) || 'unknown', marketplace: str(v.marketplace) || 'missing',
564 behind_upstream: finite(v.behind_upstream) && Number.isInteger(v.behind_upstream) && v.behind_upstream >= 0 ? v.behind_upstream : null,
565 flags: strs(v.flags),
566 stale_cache_dirs: (Array.isArray(v.stale_cache_dirs) ? v.stale_cache_dirs : []).filter(isObject).map(d => ({ dir: str(d.dir) || '?', in_use_pids: strs(d.in_use_pids), alive: strs(d.alive) })),
567 }
568}
569
570// Narrow the watcher's conservative source with the session's own claude pid when the caller has one: a pid listed in a cache
571// dir's .in_use means THIS session loaded that copy; a pid listed in none means it loaded no cache copy (dev when the dev link
572// is sound, else unknown). How the mod learns its claude pid is [unverified]; register.ts passes null today, which keeps the
573// watcher's any-alive-pid reading.
574export function loadSourceFor(v: LoadSourceView, sessionPid: number | string | null): string {
575 if (sessionPid === null) return v.source
576 const pid = String(sessionPid)
577 for (const d of v.stale_cache_dirs) {
578 if (d.in_use_pids.includes(pid)) return 'cache:' + (d.dir.split('/').filter(x => x !== '').pop() || '?')
579 }
580 if (!v.source.startsWith('cache:')) return v.source
581 return v.flags.includes('dev_link_missing_or_stale') ? 'unknown' : 'dev'
582}
583
584// The hygiene slot's load-source chip: `dev` / `dev ↓3` (behind upstream by 3), `dev ⚠` (the marketplace is not this repo's directory),
585// `cache 2.36.36 ⚠` (a stale cache copy is what loads), `src ? ⚠` (unknown). warn = the chip carries ⚠ (the renderer picks the warning colour).
586export function hygieneChip(v: LoadSourceView | null, sessionPid: number | string | null = null): { text: string; warn: boolean } | null {
587 if (v === null) return null
588 const src = loadSourceFor(v, sessionPid)
589 const marketplaceBad = v.flags.includes('marketplace_not_directory') || v.flags.includes('marketplace_not_this_repo')
590 if (src.startsWith('cache:')) return { text: 'cache ' + src.slice('cache:'.length) + ' ⚠', warn: true }
591 if (src === 'dev') {
592 const behind = v.behind_upstream !== null && v.behind_upstream > 0 ? ' ↓' + v.behind_upstream : ''
593 return { text: 'dev' + behind + (marketplaceBad ? ' ⚠' : ''), warn: marketplaceBad }
594 }
595 return { text: 'src ? ⚠', warn: true }
596}
597
598export type Sources = {
599 tasks: TasksView | null
600 attention: AttentionView | null
601 turn: TurnView | null
602 decisions: DecisionsView | null
603 manifest: Manifest | null
604 foreman: ForemanView | null
605 loadSource?: LoadSourceView | null
606 marker?: MarkerView | null
607 review?: ReviewView | null
608 qc?: QcView | null
609 residue?: ResidueView | null
610 startMs: number | null
611}
612export const NO_SOURCES: Sources = { tasks: null, attention: null, turn: null, decisions: null, manifest: null, foreman: null, startMs: null }
613
614// P1W FOREMAN: a background foreman (a native subagent) leaves no manifest, task or turn behind, so its tool-call stamp is the only
615// sign it works. THE one stall bound: the same 180 s the dispatch stall uses (scripts/dispatch-status.js DEFAULT_STALL_SECS).
616export const FOREMAN_STALL_S = 180
617export type ForemanNow = { fresh: { name: string; age_s: number } | null; stalled: { age_s: number } | null }
618// Agents of the session that wrote a stamp, not ended (SubagentStop) and not older than the marker TTL. Un-ended and quiet under the
619// bound = fresh (the freshest one is named); un-ended and quiet at/over it = stalled (the quietest one sets the age).
620export function foremanNow(f: ForemanView | null, nowMs: number): ForemanNow {
621 const out: ForemanNow = { fresh: null, stalled: null }
622 if (f === null) return out
623 for (const a of f.agents) {
624 if (!a.stamped || a.ended || a.at_ms === null) continue
625 const ageMs = nowMs - a.at_ms
626 if (ageMs > TURN_TTL_MS) continue
627 const age_s = Math.max(0, Math.floor(ageMs / 1000))
628 if (age_s < FOREMAN_STALL_S) {
629 if (out.fresh === null || age_s < out.fresh.age_s) out.fresh = { name: a.description || a.label || a.id, age_s }
630 } else if (out.stalled === null || age_s > out.stalled.age_s) out.stalled = { age_s }
631 }
632 return out
633}
634
635const waitingMinutes = (sinceMs: number | null, nowMs: number): number | null => (sinceMs === null ? null : Math.max(0, Math.floor((nowMs - sinceMs) / 60000)))
636
637// what the human is being waited on for, from the attention file (permission / question only)
638export function attentionReason(a: AttentionView, nowMs: number): string {
639 const n = waitingMinutes(a.since_ms, nowMs)
640 return (a.kind === 'permission' ? '等你批准:' : '等你回答:') + a.summary + (n === null ? '' : '(等了 ' + n + ' 分)')
641}
642
643// 「(已等 N 天)」: only the model's stale flag, only when a whole day has passed
644export function staleSuffix(d: LiveDecision | null): string {
645 if (d === null || !d.stale || d.age_s === null || d.age_s < 86400) return ''
646 return '(已等 ' + Math.floor(d.age_s / 86400) + ' 天)'
647}
648
649// 僅自動裁決, worded from the writers the sidecar says are wired; next-pick is the depth-0 writer
650export function writersLabel(writers: string[]): string | null {
651 if (writers.includes('next-pick')) return null
652 return writers.length === 0 ? '決策寫入端未接' : '僅 ' + writers.join('、') + ' 自動裁決'
653}
654
655// band line 2 tail: the proxy decisions (only when non-zero) and the dispatches nobody wrote down
656export function proxySegments(d: DecisionsView | null): string[] {
657 if (d === null) return []
658 const out: string[] = []
659 if (d.count > 0) {
660 out.push('代你決定 ' + d.count + ' 件(' + d.irreversible + ' 件不可逆)')
661 const label = writersLabel(d.writers)
662 if (label !== null) out.push(label)
663 }
664 if (d.undocumented > 0) out.push(d.undocumented + ' 件派工無決策紀錄')
665 return out
666}
667
668// Precedence: 1 needs a decision (attention permission / question, or an open decision), 2 stalled (a dispatch run, or an un-ended foreman quiet >= FOREMAN_STALL_S), 3 complete and
669// waiting acceptance (frozen progress done = total, or every session task completed; NO live run in scope and no un-ended fresh foreman), 4 running
670// (a live run, a fresh foreman, a session task in progress, or an active turn), 5 idle (the turn ended: nothing live, no task in progress).
671export function bandView(env: Json, jobModel: JobModel | null, projectKey: string, nowMs: number, src: Sources = NO_SOURCES): BandView {
672 const counts = countsOf(env)
673 const progress = jobModel === null ? null : jobModel.progress
674 const stalled = stalledReason(env)
675 const noneLive = counts !== null && counts.confirmed_live === 0
676 const undecided = jobModel === null || (jobModel.acceptance !== 'accepted' && jobModel.acceptance !== 'rejected')
677 const frozenDone = progress !== null && progress.frozen && progress.done !== null && progress.total !== null && progress.done === progress.total
678 const tasksDone = src.tasks !== null && src.tasks.total > 0 && src.tasks.completed === src.tasks.total
679 const fm = foremanNow(src.foreman, nowMs)
680 const waiting = noneLive && undecided && ((frozenDone && jobModel !== null) || tasksDone) && fm.fresh === null
681 const taskRunning = src.tasks !== null && src.tasks.in_progress > 0
682 const turnActive = src.turn !== null && src.turn.state === 'active'
683 const awaiting = src.attention !== null && src.attention.kind !== 'idle' ? src.attention : null
684 let pick: { mark: string; word: string; key: ThemeKey }
685 let reason: string | null = null
686 if (awaiting !== null || (jobModel !== null && jobModel.needs_decision)) {
687 pick = VERDICT.decide
688 if (awaiting !== null) reason = attentionReason(awaiting, nowMs)
689 else if (jobModel !== null && jobModel.decision !== null) reason = jobModel.decision.question + staleSuffix(jobModel.decision)
690 else reason = (jobModel !== null && jobModel.conclusion) || '等你決定'
691 } else if (stalled !== null) {
692 pick = VERDICT.stalled
693 reason = stalled
694 } else if (fm.stalled !== null) {
695 pick = VERDICT.stalled
696 reason = '工頭 ' + Math.floor(fm.stalled.age_s / 60) + ' 分沒有動作'
697 } else if (waiting) {
698 pick = VERDICT.waiting
699 reason = frozenDone ? '驗收結論尚未出' : '任務 ' + (src.tasks as TasksView).completed + '/' + (src.tasks as TasksView).total + ' 都完成,等你驗收'
700 } else if ((counts !== null && counts.confirmed_live > 0) || fm.fresh !== null || taskRunning || turnActive) {
701 // GATEFIX / TURN: a live run, a session task in progress, or an active turn (the session is mid-work); the live run names itself first, then the task.
702 pick = VERDICT.running
703 reason = counts !== null && counts.confirmed_live > 0
704 ? counts.confirmed_live + ' 個派工在跑'
705 : fm.fresh !== null
706 ? '工頭在跑:' + fm.fresh.name + '(' + Math.floor(fm.fresh.age_s / 60) + ' 分前有動作)'
707 : taskRunning
708 ? '任務進行中:' + (src.tasks !== null && src.tasks.current ? src.tasks.current : (src.tasks as TasksView).in_progress + ' 件')
709 : '回合進行中' + (() => { const n = waitingMinutes((src.turn as TurnView).since_ms, nowMs); return n === null ? '' : '(' + n + ' 分)' })()
710 } else {
711 pick = VERDICT.idle
712 reason = '沒有派工在跑'
713 if (src.attention !== null && src.attention.kind === 'idle') {
714 const n = waitingMinutes(src.attention.since_ms, nowMs)
715 if (n !== null) reason += ' · 停在等你指示 ' + n + ' 分'
716 }
717 }
718 const tail = proxySegments(src.decisions)
719 const line2 = [reason, ...tail].join(' · ')
720 const identity = isObject(env.scope) ? env.scope.repo_identity : null
721 // the phase is the job model's `phase.label` (campaign > receipt > marker stage `size·level ▸ stage` + `unit k/N` > task in progress > deliverable); absent / malformed: an em dash, never the process phase.
722 // No writer of a stage live per the sources manifest: 來源未接 (a phase the model really carries is shown whatever the manifest says).
723 const phase = jobModel !== null && jobModel.phase !== null ? jobModel.phase.label : notWired(src.manifest, ['stage', 'progress', 'task_status_input']) ? NOT_WIRED : '—'
724 let prog = progressView(progress)
725 if (prog.text === '—') {
726 if (src.tasks !== null && src.tasks.total > 0) prog = { text: src.tasks.completed + ' done*', dim: true } // task counts have no frozen denominator
727 else if (notWired(src.manifest, ['progress', 'tasks'])) prog = { text: NOT_WIRED, dim: false }
728 }
729 const stalledN = (rowsOf(env) || []).filter(r => r.stall === true).length + (fm.stalled !== null ? 1 : 0)
730 const slots = bandSlots({
731 verdict: pick, marker: src.marker ?? null, live: counts === null ? 0 : counts.confirmed_live, stalled: stalledN, review: src.review ?? null, qc: src.qc ?? null,
732 decisions: src.decisions, spend: spendOf(env), loadSource: src.loadSource ?? null, residue: src.residue ?? null, nowMs,
733 })
734 return {
735 mark: pick.mark,
736 verdict: pick.word,
737 head: projectName(identity, projectKey) + ' · ' + phase + ' · ' + elapsedFrom(src.startMs, nowMs) + ' · ',
738 progress: prog.text,
739 progressDim: prog.dim,
740 reason: line2,
741 project: projectName(identity, projectKey),
742 elapsed: elapsedFrom(src.startMs, nowMs),
743 phase,
744 slots,
745 }
746}
747
748export function bandLine1(v: BandView): string {
749 return v.mark + ' ' + v.verdict + ' ' + v.head + v.progress
750}
751
752export function sessionUsd(env: Json, sid: string): { text: string; as_of: string | null } {
753 const sessions = isObject(env.sessions) ? env.sessions : {}
754 const s = Object.prototype.hasOwnProperty.call(sessions, sid) ? sessions[sid] : undefined
755 if (!isObject(s) || !finite(s.session_usd)) return { text: '$—', as_of: null }
756 return { text: usd(s.session_usd), as_of: typeof s.as_of === 'string' ? s.as_of : null }
757}
758
759// context file of THIS sid only, same contract as scripts/lib/live-state-dir.js readLive.
760export function ctxText(text: string | null, nowMs: number): string {
761 if (text === null) return '—'
762 const parsed = parseJson(text)
763 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema_version !== 1) return '—'
764 const writtenMs = typeof parsed.value.written_at === 'string' ? Date.parse(parsed.value.written_at) : NaN
765 if (!Number.isFinite(writtenMs) || nowMs - writtenMs > CONTEXT_MAX_AGE_MS) return '—'
766 const cw = parsed.value.context_window
767 const pct = isObject(cw) ? cw.used_percentage : undefined
768 return finite(pct) ? Math.round(pct) + '%' : '—'
769}
770
771// no usable context value and no writer live per the manifest: 來源未接 (a value that exists is shown whatever the manifest says)
772export function ctxShown(ctx: string, manifest: Manifest | null): string {
773 return ctx === '—' && notWired(manifest, ['context']) ? NOT_WIRED : ctx
774}
775
776// pane header row: what left the band. Session cost and context are this sid's own; host cost is the day's total.
777export function headerText(env: Json, sid: string, ctx: string): string {
778 return 'session ' + sessionUsd(env, sid).text + ' · host ' + usd(env.host_today_usd) + ' · ctx ' + ctx
779}
780
781export function paneRows(env: Json): LivePaneRow[] | null {
782 const rows = rowsOf(env)
783 if (rows === null) return null
784 const str = (v: unknown) => (typeof v === 'string' && v ? v : null)
785 return rows.map(r => ({
786 run_id: String(r.run_id === undefined || r.run_id === null ? '?' : r.run_id),
787 role: str(r.role), runner: str(r.runner), model: str(r.model), started_at: str(r.started_at),
788 elapsed_s: finite(r.elapsed_s) ? r.elapsed_s : null, phase: str(r.phase),
789 rc: Number.isInteger(r.rc) ? (r.rc as number) : null, final_status: str(r.final_status),
790 probe_age_s: finite(r.probe_age_s) ? r.probe_age_s : null,
791 }))
792}
793
794export type JobModel = {
795 acceptance: string
796 gates: LiveGateRow[]
797 needs_decision: boolean
798 conclusion: string | null
799 decision: LiveDecision | null
800 progress: JobProgress | null
801 phase: { code: string | null; label: string } | null
802 sources_manifest: unknown
803}
804
805// The job page's model.json (review-job-model/1): acceptance axis, gate rows, the decision awaited, the progress
806// numbers and the phase (`{code, label, source}`; only a string `label` counts). Absent / malformed -> null. `needs_decision` is true only when the model says exactly true.
807export function readJobModel(text: string | null): JobModel | null {
808 if (text === null) return null
809 const parsed = parseJson(text)
810 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== MODEL_SCHEMA || !isObject(parsed.value.axes)) return null
811 const m = parsed.value
812 const raw = isObject(m.axes) ? m.axes.acceptance : undefined
813 const acceptance = raw === 'accepted' || raw === 'rejected' ? raw : 'unknown'
814 const gates = (Array.isArray(m.gates) ? m.gates : []).filter(isObject).map(g => ({
815 phase: typeof g.phase === 'string' ? g.phase : null,
816 generation: Number.isInteger(g.generation) ? (g.generation as number) : null,
817 verdict: typeof g.verdict === 'string' ? g.verdict : null,
818 status: typeof g.status === 'string' ? g.status : null,
819 }))
820 const d = m.decision
821 const decision: LiveDecision | null = isObject(d) && typeof d.question === 'string' && d.question
822 ? {
823 question: d.question,
824 options: (Array.isArray(d.options) ? d.options : []).filter(isObject).map(o => ({
825 label: typeof o.label === 'string' ? o.label : '',
826 consequence: typeof o.consequence === 'string' ? o.consequence : '',
827 })).filter(o => o.label !== ''),
828 stale: d.stale === true,
829 age_s: finite(d.age_s) ? d.age_s : null,
830 }
831 : null
832 const p = m.progress
833 const progress: JobProgress | null = isObject(p)
834 ? {
835 frozen: p.frozen === true,
836 percent: finite(p.percent) ? p.percent : null,
837 done: Number.isInteger(p.done) ? (p.done as number) : null,
838 total: Number.isInteger(p.total) ? (p.total as number) : null,
839 }
840 : null
841 const ph = m.phase
842 const phase = isObject(ph) && typeof ph.label === 'string' && ph.label ? { code: typeof ph.code === 'string' ? ph.code : null, label: ph.label } : null
843 return { acceptance, gates, needs_decision: m.needs_decision === true, conclusion: typeof m.conclusion === 'string' && m.conclusion ? m.conclusion : null, decision, progress, phase, sources_manifest: m.sources_manifest }
844}
845
846// Axis words follow the review page's chip mapping (plan A12).
847export const execWord = { running: 'RUNNING', exited: 'EXITED', unknown: 'UNKNOWN' } as const
848export const acceptWord = (a: string): string => (a === 'accepted' ? 'ACCEPTED' : a === 'rejected' ? 'REJECTED' : 'PENDING')
849
850export type Counts = { confirmed_live: number; exited: number; unknown: number }
851
852export function countsOf(env: Json): Counts | null {
853 const c = env.counts
854 if (!isObject(c) || !Number.isInteger(c.confirmed_live) || !Number.isInteger(c.exited) || !Number.isInteger(c.unknown)) return null
855 return { confirmed_live: c.confirmed_live as number, exited: c.exited as number, unknown: c.unknown as number }
856}
857
858export function executionToast(before: Counts, after: Counts): string | null {
859 if (before.confirmed_live === after.confirmed_live && before.exited === after.exited && before.unknown === after.unknown) return null
860 return 'execution axis: RUNNING ' + before.confirmed_live + '→' + after.confirmed_live
861 + ' · EXITED ' + before.exited + '→' + after.exited + ' · UNKNOWN ' + before.unknown + '→' + after.unknown
862}
863
864export function acceptanceToast(before: string, after: string): string | null {
865 return before === after ? null : 'acceptance axis: ' + acceptWord(before) + ' → ' + acceptWord(after)
866}
867
868// job directory for a scope: the scope's root, else the root of the most recently started row, else "unbound"
869// (the watcher's renderer names an unbound job exactly that).
870export function jobOf(env: Json, root: string | null): string {
871 if (root) return safeSegment(root)
872 let best: { ms: number; root: string } | null = null
873 for (const r of rowsOf(env) || []) {
874 if (typeof r.root_run_id !== 'string' || !r.root_run_id) continue
875 const ms = startedMs(r)
876 const at = Number.isFinite(ms) ? ms : -Infinity
877 if (best === null || at >= best.ms) best = { ms: at, root: r.root_run_id }
878 }
879 return best === null ? 'unbound' : safeSegment(best.root)
880}
881
882export function reviewLink(port: number, projectKey: string, date: string | null, job: string): string {
883 const base = 'http://localhost:' + port + '/' + projectKey + '/'
884 return date === null ? base : base + date + '/' + job + '/current/'
885}
886
887export function portOf(text: string | null): number {
888 if (text === null) return DEFAULT_REVIEW_PORT
889 const parsed = parseJson(text)
890 const p = parsed.ok && isObject(parsed.value) ? parsed.value.port : undefined
891 return Number.isInteger(p) && (p as number) >= 1 && (p as number) <= 65535 ? (p as number) : DEFAULT_REVIEW_PORT
892}
893
894export const STATE_TEXT = {
895 noPointer: 'no pointer · run: ' + RUNS_HINT,
896 noProject: 'no project · run: ' + RUNS_HINT,
897 unreadable: 'unreadable',
898 stale: (key: string, publishedAt: string | null) => 'stale' + (publishedAt ? ' · last published ' + publishedAt : '') + ' · run: ' + RUNS_HINT + ' --project ' + key,
899 unavailable: (key: string) => 'unavailable · run: ' + RUNS_HINT + ' --project ' + key,
900}
901
902// ---- W3a pane sections: text built from the sources, drawn by pane.tsx ----
903const ledgerPath = (source: string | null, common: string | null, root: string | null): string | null => {
904 if (common === null) return null
905 if (source === 'ledger_root') return root === null ? null : common + '/autopilot/work-orders/' + root + '/decision-ledger.jsonl'
906 return common + '/autopilot/ledger/decisions.jsonl'
907}
908
909// the veto verb is decision-ledger.js `veto --ledger <ledger> --id <decision_id>` (its own round report prints the same line)
910export function vetoLine(r: DecisionRow, common: string | null, root: string | null): string {
911 if (r.decision_id === null) return ' (沒有 decision_id,無法 veto)'
912 const path = ledgerPath(r.source, common, r.root !== null ? r.root : root) // a merged view carries rows of several roots: each names its own
913 if (path === null && r.source === 'ledger_root') return ' (找不到 ledger 路徑,無法組出 veto 指令)'
914 return ' veto: decision-ledger.js veto ' + (path === null ? '' : '--ledger ' + path + ' ') + '--id ' + r.decision_id
915}
916
917const MAX_DECISION_ROWS = 12
918const MAX_TASK_ROWS = 8
919
920export function buildSections(src: Sources, foreman: ForemanView | null, identity: unknown, nowMs: number): PaneSections {
921 const m = src.manifest
922 const a = src.attention
923 const attention: PaneLine[] = a !== null && a.kind !== 'idle'
924 ? [{ text: '要你決定', bold: true, warn: true }, { text: attentionReason(a, nowMs) }]
925 : []
926 const waiting: PaneLine[] = []
927 if (a !== null && a.kind === 'idle') {
928 const n = waitingMinutes(a.since_ms, nowMs)
929 waiting.push({ text: n === null ? '停在等你指示' : '停在等你指示 ' + n + ' 分', dim: true })
930 } else if (a === null && liveSource(m, 'attention') === false) {
931 waiting.push({ text: '等待狀態:' + NOT_WIRED, dim: true })
932 }
933
934 const tasks: PaneLine[] = []
935 const t = src.tasks
936 if (t !== null) {
937 if (t.total === 0) tasks.push({ text: '沒有任務', dim: true })
938 else {
939 tasks.push({ text: '任務 ' + t.completed + '/' + t.total + ' 完成 · 進行中 ' + t.in_progress, bold: true })
940 if (t.current !== null) tasks.push({ text: '目前:' + t.current })
941 for (const r of t.rows.slice(0, MAX_TASK_ROWS)) tasks.push({ text: (r.status === 'completed' ? '[x] ' : r.status === 'in_progress' ? '[~] ' : '[ ] ') + r.subject, dim: r.status === 'completed' })
942 }
943 } else if (liveSource(m, 'tasks') === true) tasks.push({ text: TODO_TOOLS_HINT, dim: true })
944 else if (liveSource(m, 'tasks') === false) tasks.push({ text: '任務:' + NOT_WIRED, dim: true })
945
946 const decisions: PaneLine[] = []
947 const d = src.decisions
948 if (d !== null) {
949 const common = commonDirOf(identity)
950 if (d.count > 0) {
951 decisions.push({ text: '代你決定 ' + d.count + ' 件(' + d.irreversible + ' 件不可逆)', bold: true })
952 const label = writersLabel(d.writers)
953 if (label !== null) decisions.push({ text: label, dim: true })
954 for (const r of d.rows.slice(-MAX_DECISION_ROWS)) {
955 decisions.push({ text: '[' + (r.decision_id === null ? '—' : r.decision_id) + '] ' + r.decision + ' · ' + (r.irreversible ? '不可逆' : '可逆') + ' · ' + (r.writer === null ? '—' : r.writer) + (r.round === null ? '' : ' · 第 ' + r.round + ' 輪') })
956 decisions.push({ text: vetoLine(r, common, d.root), dim: true })
957 }
958 if (d.rows.length > MAX_DECISION_ROWS) decisions.push({ text: '…另有 ' + (d.rows.length - MAX_DECISION_ROWS) + ' 件較早的決定', dim: true })
959 }
960 if (d.undocumented > 0) decisions.push({ text: d.undocumented + ' 件派工無決策紀錄', dim: true })
961 } else if (notWired(m, ['ledger_engine', 'ledger_depth0'])) decisions.push({ text: '代你決定:' + NOT_WIRED, dim: true })
962
963 const fm: PaneLine[] = []
964 if (foreman === null) fm.push({ text: '工頭狀態:' + NOT_WIRED, dim: true })
965 else {
966 fm.push({ text: foreman.binding === 'session' ? '工頭活動(依 session 綁定:同一 session 的多個工作會看到同一批)' : '工頭活動(綁定:' + foreman.binding + ')', bold: true })
967 const age = (at: number | null, published: number | null) => ageText(at !== null ? (nowMs - at) / 1000 : published === null ? NaN : published)
968 for (const g of foreman.agents) {
969 fm.push({ text: (g.description || g.id) + ' · ' + (g.label || '—') + ' · ' + age(g.at_ms, g.age_s) + ' 前' + (g.stale ? ' · 久未動' : ''), dim: g.stale })
970 }
971 if (foreman.stage !== null) fm.push({ text: '階段 ' + foreman.stage + ' · ' + age(foreman.stage_at_ms, foreman.stage_age_s) + ' 前' + (foreman.stage_source === null ? '' : '(' + foreman.stage_source + ')') })
972 if (foreman.agents.length === 0 && foreman.stage === null) fm.push({ text: '沒有活動紀錄', dim: true })
973 }
974 return { attention, waiting, tasks, decisions, foreman: fm }
975}
976
977// ---- stage-graph P7d: the review fact (<live>/runs/<project_key>.review.json, schema autopilot.review/1, written by the watcher) ----
978// `plan` is the plan-review state of this repo (dispatch-plan-review.js), `code` the latest hetero-review-loop round summary;
979// either may be null. The file is project-scoped, so a project_key that is not the wanted one is an absent file.
980export const REVIEW_SCHEMA = 'autopilot.review/1'
981export type ReviewSeat = { id: string; family: string | null; status: string | null; verdict?: string | null }
982export type PlanReviewView = { logical_plan_id: string | null; generation: number; max_generations: number | null; terminal: boolean; verdict: string | null; seats: ReviewSeat[]; updated_at: string | null }
983export type CodeReviewView = { phase: string | null; generation: number | null; base: string | null; head: string | null; seats: ReviewSeat[]; converged: boolean; at: string | null }
984export type ReviewView = { plan: PlanReviewView | null; code: CodeReviewView | null }
985
986function reviewSeats(v: unknown, withVerdict: boolean): ReviewSeat[] {
987 if (!Array.isArray(v)) return []
988 return v.filter(isObject).map(s => ({ id: str(s.id) || '—', family: str(s.family), status: str(s.status), ...(withVerdict ? { verdict: str(s.verdict) } : {}) }))
989}
990
991export function readReview(text: string | null, projectKey: string): ReviewView | null {
992 if (text === null) return null
993 const parsed = parseJson(text)
994 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== REVIEW_SCHEMA || parsed.value.project_key !== projectKey) return null
995 const v = parsed.value
996 const p = isObject(v.plan) ? v.plan : null
997 const c = isObject(v.code) ? v.code : null
998 const plan: PlanReviewView | null = p === null || !Number.isInteger(p.generation) ? null : {
999 logical_plan_id: str(p.logical_plan_id), generation: p.generation as number, max_generations: Number.isInteger(p.max_generations) ? (p.max_generations as number) : null,
1000 terminal: p.terminal === true, verdict: str(p.verdict), seats: reviewSeats(p.seats, false), updated_at: str(p.updated_at),
1001 }
1002 const code: CodeReviewView | null = c === null ? null : {
1003 phase: str(c.phase), generation: Number.isInteger(c.generation) ? (c.generation as number) : null, base: str(c.base), head: str(c.head),
1004 seats: reviewSeats(c.seats, true), converged: c.converged === true, at: str(c.at),
1005 }
1006 return plan === null && code === null ? null : { plan, code }
1007}
1008
1009// The band's review slot (the band layout itself is P7): `R<n> ⟲`, n = the code-review round when there is one, else the plan-review generation; null = no slot.
1010export function reviewSlot(r: ReviewView | null): string | null {
1011 if (r === null) return null
1012 const n = r.code !== null && r.code.generation !== null ? r.code.generation : r.plan !== null ? r.plan.generation : null
1013 return n === null ? null : 'R' + n + ' ⟲'
1014}
1015
1016const short = (sha: string | null): string => (sha === null ? '—' : sha.slice(0, 8))
1017
1018// The Review tab: plan review, then code review; "no review" when neither is published.
1019export function reviewLines(r: ReviewView | null): PaneLine[] {
1020 if (r === null) return [NO_DATA('no review published')]
1021 const out: PaneLine[] = []
1022 if (r.plan !== null) {
1023 const p = r.plan
1024 out.push({ text: 'plan review · ' + (p.logical_plan_id || '—') + ' · gen ' + p.generation + (p.max_generations === null ? '' : '/' + p.max_generations) + ' · ' + (p.terminal ? 'terminal' : 'active') + ' · ' + (p.verdict || '—'), bold: true })
1025 if (p.seats.length === 0) out.push({ text: ' no seat verdicts yet', dim: true })
1026 for (const s of p.seats) out.push({ text: ' ' + s.id + ' · ' + (s.family || '—') + ' · ' + (s.status || '—') })
1027 }
1028 if (r.code !== null) {
1029 const c = r.code
1030 out.push({ text: 'code review · ' + (c.phase || '—') + ' · R' + (c.generation === null ? '—' : c.generation) + ' · ' + short(c.base) + '..' + short(c.head) + ' · ' + (c.converged ? 'converged' : 'open'), bold: true })
1031 if (c.seats.length === 0) out.push({ text: ' no seats', dim: true })
1032 for (const s of c.seats) out.push({ text: ' ' + s.id + ' · ' + (s.family || '—') + ' · ' + (s.status || '—') + ' · ' + (s.verdict || '—') })
1033 }
1034 return out
1035}
1036
1037// ================= stage-graph P7: the one-line band (contract ②) and the panel's facts =================
1038// Colours are theme keys used as TEXT colour only (owner decision 5): no backgroundColor, no inverse, no raw colour.
1039export type ThemeKey = 'claude' | 'warning' | 'error' | 'success' | 'suggestion' | 'inactive' | 'subtle'
1040export type SlotId = 'verdict' | 'position' | 'unit' | 'dispatch' | 'review' | 'decisions' | 'spend' | 'hygiene'
1041// tag: which part the width table may drop or shorten (age, k/N, 族, the bar, the stage text)
1042export type SegTag = 'age' | 'kn' | 'fam' | 'bar' | 'stage' | 'live' | 'stalled'
1043export type Seg = { text: string; color?: ThemeKey; bold?: boolean; tag?: SegTag }
1044export type Slot = { id: SlotId; segs: Seg[] }
1045
1046// Terminal cell width of a string: East Asian Wide / Fullwidth = 2, combining and zero-width = 0, everything else (the band glyphs
1047// ▲⏸✓●◌▸◷▰▱⚙◆⟲ⓘ│… included) = 1.
1048export function displayWidth(text: string): number {
1049 let w = 0
1050 for (const ch of text) w += cellWidth(ch.codePointAt(0) as number)
1051 return w
1052}
1053function cellWidth(c: number): number {
1054 if (c === 0 || (c >= 0x0300 && c <= 0x036f) || (c >= 0x200b && c <= 0x200f) || (c >= 0xfe00 && c <= 0xfe0f) || (c >= 0x20d0 && c <= 0x20ff)) return 0
1055 if ((c >= 0x1100 && c <= 0x115f) || (c >= 0x2e80 && c <= 0x303e) || (c >= 0x3041 && c <= 0x33ff) || (c >= 0x3400 && c <= 0x4dbf)
1056 || (c >= 0x4e00 && c <= 0x9fff) || (c >= 0xa000 && c <= 0xa4cf) || (c >= 0xac00 && c <= 0xd7a3) || (c >= 0xf900 && c <= 0xfaff)
1057 || (c >= 0xfe30 && c <= 0xfe4f) || (c >= 0xff00 && c <= 0xff60) || (c >= 0xffe0 && c <= 0xffe6)
1058 || (c >= 0x1f300 && c <= 0x1f64f) || (c >= 0x1f900 && c <= 0x1f9ff) || (c >= 0x20000 && c <= 0x3fffd)) return 2
1059 return 1
1060}
1061
1062// the longest prefix of `text` that fits `width` cells, ending in `…` when it was cut (width < 1 -> empty)
1063export function truncateToWidth(text: string, width: number): string {
1064 if (width < 1) return ''
1065 if (displayWidth(text) <= width) return text
1066 let out = ''
1067 let w = 0
1068 for (const ch of text) {
1069 const cw = cellWidth(ch.codePointAt(0) as number)
1070 if (w + cw > width - 1) break
1071 out += ch
1072 w += cw
1073 }
1074 return out + '…'
1075}
1076
1077// ---- marker (§2.9 fields) ----
1078export type UnitView = { kind: string; index: number; total: number; label: string }
1079export type MarkerView = {
1080 size: string | null; urgent: boolean; bug: boolean; high_risk: boolean; level: string | null
1081 stage: string | null; stage_set_at_ms: number | null; unit: UnitView | null; review_families: string[]
1082}
1083const SIZES = ['XS', 'S', 'M', 'L', 'XL']
1084
1085// The session marker's stage-graph fields (scripts/session-mode.js §2.9, written by stage-advance.js). A field of the wrong type reads as absent.
1086export function readMarker(m: Json | null): MarkerView | null {
1087 if (m === null) return null
1088 const u = m.unit
1089 const pos = (x: unknown): x is number => typeof x === 'number' && Number.isInteger(x) && x >= 1
1090 const unit: UnitView | null = isObject(u) && typeof u.kind === 'string' && u.kind && pos(u.index) && pos(u.total)
1091 ? { kind: u.kind, index: u.index, total: u.total, label: typeof u.label === 'string' ? u.label : '' }
1092 : null
1093 const at = typeof m.stage_set_at === 'string' ? Date.parse(m.stage_set_at) : NaN
1094 return {
1095 size: typeof m.size === 'string' && SIZES.includes(m.size) ? m.size : null,
1096 urgent: m.urgent === true, bug: m.bug === true, high_risk: m.high_risk === true,
1097 level: typeof m.level === 'string' && m.level ? m.level : null,
1098 stage: str(m.stage), stage_set_at_ms: Number.isFinite(at) ? at : null, unit,
1099 review_families: Array.isArray(m.review_families) ? m.review_families.filter((f): f is string => typeof f === 'string' && f !== '') : [],
1100 }
1101}
1102
1103// ---- D3 residue / D4 stage walk / D1 spend ----
1104export const RESIDUE_SCHEMA = 'autopilot.residue/1'
1105export type ResidueView = { reapable: number; by_class: Record<string, number>; at: string | null }
1106export function readResidue(text: string | null, projectKey: string): ResidueView | null {
1107 if (text === null) return null
1108 const parsed = parseJson(text)
1109 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== RESIDUE_SCHEMA || parsed.value.project_key !== projectKey) return null
1110 const v = parsed.value
1111 if (!isCount(v.reapable_worktrees)) return null
1112 const by: Record<string, number> = {}
1113 if (isObject(v.by_class)) for (const k of Object.keys(v.by_class)) if (isCount(v.by_class[k])) by[k] = v.by_class[k] as number
1114 return { reapable: v.reapable_worktrees, by_class: by, at: str(v.at) }
1115}
1116
1117export const STAGE_WALK_SCHEMA = 'autopilot.stage-walk/1'
1118export type StageWalkView = {
1119 size: string | null; urgent: boolean; bug: boolean; high_risk: boolean; units: number | null
1120 nodes: string[]; walk: string[]; entry: string | null; terminal: string | null; unit_kind: string | null
1121 current: string | null; stage_set_at: string | null; at: string | null
1122}
1123export function readStageWalk(text: string | null, sid: string): StageWalkView | null {
1124 if (text === null) return null
1125 const parsed = parseJson(text)
1126 if (!parsed.ok || !isObject(parsed.value) || parsed.value.schema !== STAGE_WALK_SCHEMA) return null
1127 const v = parsed.value
1128 if (typeof v.sid === 'string' && v.sid !== sid && v.sid !== sanitizeSid(sid)) return null
1129 const names = (x: unknown): string[] => (Array.isArray(x) ? x.map(n => (typeof n === 'string' ? n : isObject(n) ? (str(n.id) ?? str(n.node_id) ?? str(n.name)) : null)).filter((n): n is string => n !== null) : [])
1130 const walk = names(v.walk)
1131 if (walk.length === 0) return null
1132 return {
1133 size: str(v.size), urgent: v.urgent === true, bug: v.bug === true, high_risk: v.high_risk === true, units: isCount(v.units) ? v.units : null,
1134 nodes: names(v.nodes), walk, entry: str(v.entry), terminal: str(v.terminal), unit_kind: str(v.unit_kind), current: str(v.current),
1135 stage_set_at: str(v.stage_set_at), at: str(v.at),
1136 }
1137}
1138
1139// D1: host-today brain-tier spend and the cost-fuse cap, as the watcher published them on the envelope (null = not published)
1140export function spendOf(env: Json): { brain: number; cap: number } | null {
1141 const brain = env.host_today_brain_usd
1142 const cap = env.brain_cap_usd
1143 return finite(brain) && finite(cap) && cap > 0 ? { brain, cap } : null
1144}
1145
1146// ---- the slots ----
1147const sp = (text: string, extra: Partial<Seg> = {}): Seg => ({ text, ...extra })
1148
1149export function slotVerdict(v: { mark: string; word: string; key: ThemeKey }): Slot {
1150 return { id: 'verdict', segs: [sp(v.mark + ' ' + v.word, { color: v.key, bold: v.key !== 'inactive' })] }
1151}
1152
1153// `<size>[!]·<level> ▸ <stage>` + ` ◷<age>`; no marker stage = no slot
1154export function slotPosition(m: MarkerView | null, nowMs: number): Slot | null {
1155 if (m === null || m.stage === null) return null
1156 const head = [m.size === null ? null : m.size + (m.urgent ? '!' : ''), m.level].filter((x): x is string => x !== null).join('·') // no size: no leading `·`
1157 const segs: Seg[] = [sp((head === '' ? '' : head + ' ') + '▸ '), sp(m.stage, { color: 'claude', tag: 'stage' })]
1158 const age = m.stage_set_at_ms === null ? '—' : elapsedFrom(m.stage_set_at_ms, nowMs)
1159 if (age !== '—') segs.push(sp(' ◷' + age, { tag: 'age' }))
1160 return { id: 'position', segs }
1161}
1162
1163// `<bar> <k>/<N>` + ` ·<n>族`; bar: k-1 done `▰` success, the current `▰` claude, the rest `▱` inactive; N > 10 scales to 10 cells
1164export function slotUnit(m: MarkerView | null): Slot | null {
1165 if (m === null || m.unit === null) return null
1166 const { index, total } = m.unit
1167 const cells = Math.min(total, 10)
1168 const k = Math.min(Math.max(index, 1), total)
1169 const cur = total > 10 ? Math.max(1, Math.min(10, Math.ceil((k * 10) / total))) : k
1170 const segs: Seg[] = []
1171 if (cur > 1) segs.push(sp('▰'.repeat(cur - 1), { color: 'success', tag: 'bar' }))
1172 segs.push(sp('▰', { color: 'claude', tag: 'bar' }))
1173 if (cells > cur) segs.push(sp('▱'.repeat(cells - cur), { color: 'inactive', tag: 'bar' }))
1174 segs.push(sp(' ' + index + '/' + total, { tag: 'kn' }))
1175 if (m.review_families.length > 0) segs.push(sp(' ·' + m.review_families.length + '族', { tag: 'fam' }))
1176 return { id: 'unit', segs }
1177}
1178
1179export function slotDispatch(live: number, stalled: number): Slot | null {
1180 if (live <= 0 && stalled <= 0) return null
1181 const segs: Seg[] = []
1182 if (live > 0) segs.push(sp('⚙' + live, { tag: 'live' }))
1183 if (stalled > 0) segs.push(sp((segs.length > 0 ? ' ' : '') + '⏸' + stalled, { color: 'error', tag: 'stalled' }))
1184 return { id: 'dispatch', segs }
1185}
1186
1187// `R<n> ⟲` + ` · QC ✓` / ` · QC owed`
1188export function slotReview(r: ReviewView | null, q: QcView | null): Slot | null {
1189 const rs = reviewSlot(r)
1190 const chip = qcChip(q)
1191 if (rs === null && chip === null) return null
1192 const segs: Seg[] = []
1193 if (rs !== null) segs.push(sp(rs))
1194 if (chip !== null) {
1195 if (rs !== null) segs.push(sp(' · '))
1196 segs.push(sp(chip, { color: chip === 'QC ✓' ? 'success' : 'warning' }))
1197 }
1198 return { id: 'review', segs }
1199}
1200