SLOPSHOPPER

autopilot

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

newpanebandtoasttimer
★ 11v3.0.0-alpha.2MITupdated 2026-10-09cookys/autopilot
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · autopilot
│ ┃ autopilot-live ✕ › fix the failing auth test and add an audit log call │ ┃ Legend [Now] Graph Dispatch Review Decis │ ┃ no pointer · run: autopilot status runs ⏺ Read(src/auth.ts) │ ┃ --watch ⎿ Read 6 lines │ ┃ no data · no stage recorded for this session ⏺ Update(src/auth.ts) │ ┃ no data · no unit recorded ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ no pointer · run: autopilot status runs --watch │ ⓘ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Band
no pointer · run: autopilot status runs --watch │ ⓘ
Pane · autopilot-live
Legend [Now] Graph Dispatch Review Decisions Spend Hyg no pointer · run: autopilot status runs --watch no data · no stage recorded for this session no data · no unit recorded
README

<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> &nbsp;|&nbsp; <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

What Is Autopilot?

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:

  • Hand it the goal, get back a result — /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.
  • A second engine argues with your code — reviews can run on a different model family (GPT, Gemini), so more bugs get caught before your users see them instead of being rubber-stamped by the same model that wrote them.
  • Catches the "done" that isn't — a no-stub/no-TODO scan, your tests, and a real code review, run in the quality gate before you merge (and in the optional pre-push hook above).
  • Remembers, so your repo doesn't rot — captures the lessons, tracks the project, tells you what to do next, and adapts to your repo from a single markdown file in .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.

A Day With Autopilot

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.

Quick Start

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

Choose Your Path

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 withWhat you get
Claude Code userThe two-command install aboveThe 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/pluginAutopilot 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.jsonShared skills and methodology agent bodies, with an OpenCode-specific in-process plugin wrapper
Antigravity (agy) userscripts/install-antigravity.shGuarded import as a Claude Code-source plugin; no loose skills-dir scan
Contributor./scripts/dev-setup.sh --checkA read-only readiness dashboard for Claude/Codex/OpenCode/agy; mutating non-Claude setup requires --harness <name> --install

From Principle To Default

The course-sized idea is simple: teach the agent the collaboration discipline once, then stop retyping it.

PrincipleAutopilot default
Clarify the work before codingdev-flow expands goals into size, branch, plan, and gates
Ask for proof, not reassurancequality-pipeline runs tests, scans for incomplete work, and reviews the diff
Preserve context outside the modelproject-lifecycle, handoff, and finish-flow keep state readable by the next session
Don't let one brain self-approveHeterogeneous 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

What It Does

30 skills, grouped by what you're trying to do. Each one triggers from natural language — the Try saying lines are real triggers.

✍️ Build code

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?"

🧭 Make decisions

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?" · "要辯論一下"

🤖 Full autopilot

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 whereReach for it when
/l3inline, on this threadfull autonomy, but you want to watch it happen
/l4one background, worktree-isolated foremana 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 enginewhen 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.

Trust Model

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.

Capability-Adaptive Guidance

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.

🔌 Add another engine (optional)

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.

📈 Improve over time

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.

🔗 Contact a persistent peer

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"

Install

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 (TaskCreate family) 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:onboard scaffolds this pin automatically; dev-flow warns once if the tools are still missing.

Harness Support

HarnessHow to startSupported todayKnown limits
Claude Code/plugin marketplace add cookys/autopilot then /plugin install autopilot@autopilotFull plugin path: 30 skills, 3 methodology agents, 36 hooksPrimary 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 marketplaceSkills, 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
OpenCodeOpen this repo with .agents/skills/; use .opencode/opencode.json for agentsShared skills, methodology agent bodies, and an OpenCode plugin wrapperOptional TypeScript deps are only needed when editing the wrapper; hook parity is platform-specific
Antigravity (agy)./scripts/install-antigravity.shGuarded agy plugin validate / install / list flow with export-then-installRuntime 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.

Learn More

The deep material, moved out of this page so it stays an onboarding tour:

TopicDoc
All 30 skills + three modes + how they composedocs/skills.md
Superpowers coexistence — three scenarios, migrationdocs/coexistence.md
Per-project configuration — the .claude/ injection modeldocs/configuration.md
Installation & development — every platform, dev modedocs/installation.md
Architecture & design — philosophy, methodology agents, creditsdocs/architecture.md
Hooks — 25 runtime-enforcement hooks (tiers in the doc)hooks/README.md
ChangelogCHANGELOG.md

License

MIT — see LICENSE for details.

Source 5 files
mods/live/register.ts 420 lines
1// 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}
420
mods/live/advisories.ts 57 lines
1// 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}
57
mods/live/band.tsx 32 lines
1// 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}
32
mods/live/pane.tsx 99 lines
1// 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}
99
mods/live/model.ts 1432 lines
1// 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