One command — `/wf` (`$wf` under Codex), the single SDLC entry point for Claude Code and Codex from one plugin tree — driving a 10-stage lifecycle from intake…

A plugin that runs software work as a disciplined lifecycle. Every feature, fix, or spike moves through the same sequence of stages. Each stage writes a permanent, machine-readable artifact under .ai/workflows/<slug>/ in your repository, and the next stage reads it. A hub renders the artifacts as a local site, and hooks verify each artifact as it lands.
The full documentation is the site under docs/site/. This file is the map.
One source tree serves three hosts. The skill prose is written once and is host-neutral; five contract files under skills/wf/reference/ hold everything a host does differently.
| Host | Reads | You type |
|---|---|---|
| Claude Code | .claude-plugin/plugin.json, hooks/hooks.json | /wf … |
| Codex | .codex-plugin/plugin.json, hooks/codex.hooks.json | $wf … |
pi (pi-code extension) | the Claude Code plugin cache and hook wiring | /skill:wf … |
All three hosts share the runtime, the hub, the renderer, and the .ai/ artifacts. A workflow started under one host resumes under another. /wf yolo runs under Claude Code only. Codex selects the skills ($wf, $consult, $diataxis, $study-sources, $imagery, $uiproto) only when you name them. The per-host differences are in reference/hosts.html.
Follow start/installation.html. It covers the marketplace add for each host, the one-time Codex hook trust, and the checks that confirm the install (npm run verify:deployment).
Follow start/your-first-workflow.html. The standard lifecycle is:
/wf intake <description> → 01-intake.md
/wf shape <slug> → 02-shape.md
/wf slice <slug> → 03-slice.md
/wf plan <slug> [slice] → 04-plan-<slice>.md
/wf implement <slug> → 05-implement-<slice>.md
/wf verify <slug> → 06-verify-<slice>.md
/wf review <slug> → 07-review-<slice>.md
/wf handoff <slug> → 08-handoff.md
/wf ship <slug> → 09-ship-run-<run-id>.md
/wf retro <slug> → 10-retro.md
/wf status shows every workflow and the next command for each. /wf auto <slug> drives the lifecycle and pauses only at a stage's own gate. For small work, /wf intake fix <description> runs a compressed entry; start/everyday-fixes.html lists the lanes.
| Key | Does |
|---|---|
intake | Entry dispatcher. A description starts stage 1; a mode (fix, rca, investigate, discover, audit, hotfix, refactor, update-deps, ideate, brainstorm, adopt, amend, modernize) runs a compressed or maintenance flow. |
shape | Product-owner discovery: acceptance criteria, documentation plan, augmentations. |
design | Human-only design stage between shape and slice: the person confirms the drawings before any driver runs. Also keeps the project's design record (setup, teach, extract, direction, sync). |
brainstorm | Think an idea through with you until you say done. /wf brainstorm design <idea> concentrates on how the idea looks and behaves, with rough sketches, and carries the design thoughts to the design stage. |
slice | Decomposes the shape into shippable slices. |
plan | Per-slice plan with a reuse scan. |
implement | Codes the slice. reviews runs the fix-blockers mode. |
verify | Tests, lints, typecheck, the user-observable AC gate, one user-gated fix loop. |
review | Workflow review over an accumulating ledger; ad-hoc rubric or sweep <aggregate> without a slug. |
handoff | Aggregates completed slices into a PR. Batch mode over pr#N or a branch. |
ship | Release via .ai/ship-plan.md; announce, rollback. |
retro | Post-mortem, per slug or per branch. |
probe | Runtime-truth verification of built work; sweep enumerates the user surface. |
simplify | Three read-only reviewers over a branch, a commit range, a plan, or a path. |
auto | Lifecycle driver. Pauses only at a stage's own gate. Stops before handoff. |
yolo | Autonomous driver. Resolves each gate by written policy. Claude Code only. |
campaign | Drives a brainstorm's work packets in dependency waves, one PR per wave. Setup and prepare on every host; the waves Claude Code only. |
task | Work whose deliverable is not a code change; observable ACs and a blast-radius gate. |
status | Dashboard; per-slug detail with the next command; deep drift check; advise sequencing. |
recap | Plain-language catch-up for a slug or a branch. |
close | Archives a workflow, or closes one slice. |
ship-plan | Release-pipeline router: init, build, edit, audit for .ai/ship-plan.md. |
docs | Documentation router: the orchestrator pipeline, or one Diátaxis primitive. |
observability | Observability router: init, build, audit for .ai/observability.md. |
Arguments and artifacts for each key are in reference/commands.html. The surface is frozen by docs/internal/SURFACE-POLICY.md; npm run verify:surface enforces the pins.
Hooks verify each managed artifact on write, stage it, render it, and record the turn's exact token usage to .ai/workflows/<slug>/cost.jsonl. They never block a valid write. reference/hooks.html lists every hook, its event, and its toggle in .ai/sdlc-config.json (reference/configuration.html).
One hook is a Claude Code mod: hooks/mod/register.ts, a function-hooks module that adds the /wf picker. With CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 set, a /wf that still needs a key or a slug draws a numbered list above the prompt (a typed slug runs at once; the slice list opens after you pick a workflow); a digit picks a row, the wheel turns the page, and a filter field narrows the rows. In the terminal the last pick fills the prompt for your Enter; in the Desktop app it runs at once. The same module draws a workflow strip above the prompt (stage, slice, next step, cost), offers the next invocation as the prompt's Tab suggestion after a stage turn, toasts when a stage ends without its artifact, counts intake and shape questions in the dialog, shows a live run's heartbeat in the status line, names the stage in the spinner, and reports the hub under the logo. The strip's buttons open the dashboard pane and the live view of a yolo, campaign or brainstorm run; the mod adds no slash command of its own. Each aid has a switch in /config, and the mod switch turns off the whole module at once. Without the flag the module does not load and /wf works as before.
npm test # unit suite
npm run build # bundle hooks and scripts into dist/
npm run verify:docs # doc-site gate, including this file's line and name rules
Release steps are in docs/internal/RELEASE-DISCIPLINE.md. The change log is CHANGELOG.md.
hooks/mod/register.ts 1802 lines1/**
2 * The `/wf` picker as a Claude Code mod.
3 *
4 * The mod registers no command of its own: `/wf` is the one way in. Typing
5 * `/wf` turns the strip into the picker, and at `command.run` of a bare `/wf`
6 * or of a `/wf <key>` that still needs a slug, the mod draws a
7 * numbered list above the prompt: the keys, then the workflows under
8 * `.ai/workflows` (active first, closed marked), then the slices of the picked
9 * workflow (roster status and the furthest stage file present). A digit picks
10 * a row from the empty composer. The wheel over the band, and `0`, turn the
11 * page. Once the band holds the keyboard (a click, or ctrl+x tab) the filter
12 * field narrows the rows as the person types, Tab walks the rows and past the
13 * last row onto the next page, and Enter picks. The last pick writes the full
14 * command into the prompt box with `$.prompt.fill`; the person presses Enter
15 * to run it, or edits it first.
16 *
17 * A `/wf <key> <slug> ...` typed in full runs as before: the hook passes it
18 * on with `next(e)`.
19 *
20 * Around the picker, the session aids (WF-MOD-UX-PLAN.md): a strip under the
21 * picker with the active workflow's stage, slice, next step, and cost; the
22 * same in the pinned status line and the footer's mode labels; the next
23 * invocation as the prompt box's dim suggestion after a stage turn; a toast
24 * when a stage turn ends without its artifact; the question count in the
25 * AskUserQuestion dialog during intake and shape; the live view's heartbeat
26 * in the status line while a yolo, campaign or brainstorm runs (the
27 * `driverStatus` switch); the spinner's verb from the stage;
28 * the hub's health under the logo; and a dashboard pane, which the strip's
29 * `dashboard` button opens (the `live` button opens the live view). Each has a
30 * switch in the plugin's settings.
31 *
32 * After a stage turn lands its artifact (POST-STAGE-COMPACT-PLAN.md), the mod
33 * compacts the session between turns with instructions that keep the
34 * workflow's position and the person's decisions, then proposes the next
35 * step; every other compaction while a workflow is active gains one sentence
36 * that names the position. The switch is `stageCompact`.
37 *
38 * The module binds its host on every session, whatever the surface
39 * (MOD-DESKTOP-PLAN.md): Claude Code Desktop runs the engine through the SDK,
40 * where `session.start` reports no surface and no person at the prompt, and
41 * the parts that never draw belong there too. The Desktop app is the first
42 * surface the mod draws for, the terminal the second: each draw gates on its
43 * own `e.surface` being one of the two (`DRAW_SURFACES`); each prompt or
44 * notice call is attempted and its failure recorded. `hooks/mod/probe.ts` keeps the journal that says which
45 * parts ran, on which host, for `scripts/mod-probe.mjs` to judge.
46 *
47 * The read check (ARTIFACT-SPLIT-PLAN.md S6, `hooks/mod/readledger.ts`): a
48 * Read of a workflow artifact or a `/wf` procedure file goes into a ledger per
49 * agent, with its line ranges. When the agent writes a stage artifact a
50 * `## Requires` table names (`hooks/mod/requires.ts`), the mod compares the
51 * ledger with the table: `warn` adds the missing list to the tool result,
52 * `block` refuses the write without a `read-waiver:`. Each check is one row
53 * in the workflow's `.read-ledger.jsonl`. The switch is `readCheck`.
54 */
55import { atom, read, update } from 'claude-code'
56import type { EngineInterface, On, PluginOptions, RenderElement } from 'claude-code'
57
58import {
59 DEFAULT_SETTINGS,
60 compactEligible,
61 compactInstructionsOf,
62 compactKeepSentenceOf,
63 compactToastOf,
64 costShortOf,
65 expectedArtifactOf,
66 hubHealthOf,
67 hubNoticeTextOf,
68 isWorkflowPath,
69 nextActiveSlug,
70 ledgerTokensOf,
71 openingCandidatesOf,
72 modeLabelOf,
73 openFindingsOf,
74 reviewLedgerNameOf,
75 settingOfKey,
76 shipPlanBlockersOf,
77 settingsOf,
78 slugOfPath,
79 spinnerWordOf,
80 stageLanded,
81 statusTextOf,
82 stripTextOf,
83 wfCommandOf,
84} from './active.ts'
85import type { HubHealth, Settings, WfCommand } from './active.ts'
86
87import {
88 CLOSED_TEXT,
89 DISPATCHER_COMMANDS,
90 FILL_REFUSED_TEXT,
91 SUBMIT_TEXT,
92 NO_ROOT_TEXT,
93 NO_WORKFLOWS_TEXT,
94 PLUGIN_NAME,
95 ROTATE_KEY,
96 RUN_TEXT,
97} from './names.ts'
98import { afterFillOf, backOf, draftStepOf, fillOf, filterOptions, isSameStep, filterTextOf, keyOptions, openedTextOf, pageOf, pick, sliceOptions, slugOptions, stepFor, submitActionOf, titleOf } from './picker.ts'
99import type { Option, Step } from './picker.ts'
100import type { StripActions, StripParts } from './styles/existing.tsx'
101import { noticeStyledView, pickerRowLabel, slicesDoneOf, stageWordOf, stripStyledView, styledStripRows, workflowTone, workflowsStyledView } from './styles/existing.tsx'
102import { chipView, inked, markView } from './styles/skin.tsx'
103import { liveBandView } from './styles/kit.tsx'
104import { cardRowsOf, isDarkThemeOf, paletteOf, viewStyleOf } from './styles/tokens.ts'
105import { PUSH_TOAST_MS, registerLive } from './live/register.ts'
106import type { LiveLink } from './live/register.ts'
107import { EMPTY_DASHBOARD, EMPTY_PICKER, EMPTY_WORKFLOWS, detailsStoreKeyOf } from './state.ts'
108import type { SdlcDashboard, SdlcHub, SdlcPicker, SdlcWorkflows } from '../../types'
109import { bandCard, bandView, pageSizeOf, rowKeyOf, stack } from './views.tsx'
110import { PROBE_FILE, ProbeJournal, surfaceAfterAttach } from './probe.ts'
111import type { ProbeIdentity } from './probe.ts'
112import { findProjectRoot, joinPath, listSlices, listWorkflows } from './workflows.ts'
113import type { Reader, SliceEntry, WorkflowEntry } from './workflows.ts'
114import {
115 MAIN_AGENT,
116 READ_LEDGER_FILE,
117 agentKeyOf,
118 appendedTextOf,
119 artifactIdOf,
120 checkReads,
121 classifyPath,
122 contextTextOf,
123 isClean,
124 isExcludedWrite,
125 ledgerLineOf,
126 matchWrite,
127 promptFedOf,
128 readCheckModeOf,
129 recordRead,
130 requiredOf,
131 waiverOf,
132 readMarkOf,
133 writerStagesOf,
134 writtenTextOf,
135} from './readledger.ts'
136import type { CheckResult, Io, ReadFact, ReadLedger, RequiresEntry, WriteMatch } from './readledger.ts'
137import { REQUIRES } from './requires.ts'
138import { createUsageGuard } from './usage-guard.ts'
139
140/**
141 * The store key a test puts a fixture Requires table under; the engine loads
142 * its own copy of this module, so the store is the one place a test reaches.
143 * Nothing else writes the key, so a session reads the generated module.
144 */
145export const REQUIRES_STORE_KEY = 'readCheck:requires'
146
147/** Where the mod draws: the Desktop app first, then the terminal. VS Code and mobile raise none of its sites. */
148const DRAW_SURFACES: ReadonlySet<string> = new Set(['desktop', 'terminal'])
149
150/*
151 * The `$.state` atoms this file reads and writes (14.5). Each carries a shape tag (Z2): bump it
152 * when the code's idea of the value changes, so a reload does not read the old form.
153 */
154const pickerAtom = atom({ plugin: 'sdlc-workflow', key: 'picker' } as const, EMPTY_PICKER, { shape: 'picker-1' })
155const activeAtom = atom({ plugin: 'sdlc-workflow', key: 'active' } as const, null, { shape: 'active-1' })
156const workflowsAtom = atom({ plugin: 'sdlc-workflow', key: 'workflows' } as const, EMPTY_WORKFLOWS, { shape: 'workflows-2' })
157const dashboardAtom = atom({ plugin: 'sdlc-workflow', key: 'dashboard' } as const, EMPTY_DASHBOARD, { shape: 'dashboard-2' })
158const hubAtom = atom({ plugin: 'sdlc-workflow', key: 'hub' } as const, null, { shape: 'hub-1' })
159const usageAtom = atom({ plugin: 'sdlc-workflow', key: 'usage' } as const, null, { shape: 'usage-1' })
160const lastStageUsdAtom = atom({ plugin: 'sdlc-workflow', key: 'lastStageUsd' } as const, null, { shape: 'stage-usd-1' })
161const liveBandAtom = atom({ plugin: 'sdlc-workflow', key: 'liveBand' } as const, null, { shape: 'live-band-1' })
162
163/** One value `publish` writes to `$.state`, named so `put` can pick its literal atom. */
164type Published =
165 | { name: 'picker'; value: SdlcPicker }
166 | { name: 'active'; value: string | null }
167 | { name: 'workflows'; value: SdlcWorkflows }
168 | { name: 'dashboard'; value: SdlcDashboard }
169 | { name: 'hub'; value: SdlcHub | null }
170 | { name: 'usage'; value: string | null }
171 | { name: 'lastStageUsd'; value: number | null }
172
173/** Every `$.noun.event` the mod calls after `session.start`, bound once. */
174type Host = {
175 cwd: string
176 reader: Reader
177 fill: (text: string) => Promise<{ isFilled: boolean; refusal?: string }>
178 /** The prompt box as it stands: the draft and the cursor; empty where no box is bound. */
179 readBox: () => Promise<{ text: string; cursor: number }>
180 /** Queues `text` as the person's own prompt; it runs once the session is idle. */
181 submit: (text: string) => Promise<unknown>
182 /** Writes one `$.state` value (14.5): the write redraws exactly the drawings that read it. */
183 put: (value: Published) => Promise<void>
184 log: (text: string) => void
185 status: (text: string | undefined) => void
186 /** Moves the band's focus ring onto one of the mod's elements while the band holds the keys. */
187 focus: (requestId: string, key: string) => Promise<{ deny?: string }>
188 /** Runs `fn` once the current dispatch is over. */
189 later: (fn: () => void) => void
190 /** A file's modification time in ms, or null when it is absent. */
191 mtime: (path: string) => Promise<number | null>
192 /** A toast; a push-class one passes `timeoutMs` (F8). */
193 toast: (text: string, timeoutMs?: number) => void
194 suggest: (text: string) => Promise<unknown>
195 /** The session's cost so far in dollars, or null where the host keeps none. */
196 costUsd: () => Promise<number | null>
197 /** The context window's fill as a whole percent, or null when the engine has no figure. */
198 contextPercent: () => Promise<number | null>
199 /** Compacts the session between turns; `{ skip }` when a hook vetoed it. Rejects while a turn runs. */
200 compact: (instructions: string) => Promise<{ skip?: string | undefined }>
201 /** A GET of `url`: the body when the answer is ok, else null. */
202 fetchText: (url: string) => Promise<string | null>
203 /** The person's home directory, from USERPROFILE then HOME. */
204 home: () => Promise<string | undefined>
205 every: (ms: number, fn: () => void) => { cancel: () => void }
206 now: () => Promise<number>
207 openPane: (id: string, title: string) => Promise<unknown>
208 /** The plugin's store, kept across sessions and reloads. */
209 storeGet: (key: string) => Promise<unknown>
210 storeSet: (key: string, value: unknown) => Promise<void>
211 /** Writes the whole text of a file, making its directories; the probe journal alone uses it. */
212 writeFile: (path: string, text: string) => Promise<void>
213 /**
214 * Adds text to the end of a file, making it and its directories: a read of
215 * the old text and one whole write, trimmed past a cap. The engine has no
216 * append, so the module runs every append through one queue.
217 */
218 appendFile: (path: string, text: string) => Promise<void>
219 /** The session's id, or an empty string when the engine gives none. */
220 sessionId: () => Promise<string>
221 /** The host's own name, from `CLAUDE_CODE_ENTRYPOINT`. */
222 entrypoint: () => Promise<string | undefined>
223 /** The machine-wide sdlc state directory, `SDLC_HOME` or `<home>/.sdlc`. */
224 sdlcHome: () => Promise<string | null>
225 /** Every surface the session draws on now. */
226 surfaces: () => Promise<readonly string[]>
227}
228
229/** The turn under way: what it ran, when it started, what it wrote. */
230type Bracket = {
231 turnId: string
232 startedAt: number
233 /** `readMarkOf()` at the turn's start: a main-loop reference read after it names the stage first. */
234 readMark: number
235 command: WfCommand | null
236 costAtStart: number | null
237 /** Paths under `.ai/workflows` the turn's Write and Edit calls touched. */
238 writes: string[]
239 /** AskUserQuestion calls so far in the turn. */
240 questions: number
241 /**
242 * The background sub-agents the turn's main loop started that have not
243 * ended. While one runs, the end of the main turn is not the end of the stage.
244 */
245 background: Set<string>
246}
247
248type Model = {
249 step: Step | null
250 /** The page of the step's options on screen; reset to the first at every step. */
251 page: number
252 /** The filter field's text; the rows shown are the options it matches. */
253 filter: string
254 /** The element the band's focus ring is on, as the last `ui.focus` said. */
255 ring: string | null
256 /** The directory holding `.ai/workflows`; null before a read, or when none. */
257 root: string | null
258 isRead: boolean
259 workflows: WorkflowEntry[]
260 slices: Map<string, SliceEntry[]>
261 /** The workflow the strip shows: the last `/wf` named it, else the newest index. */
262 active: string | null
263 /** The dollars the last `/wf` turn cost, for the strip's cost row. */
264 lastStageUsd: number | null
265 hub: HubHealth | null
266 /** The pane's open state; the render hook draws only while open. */
267 isDashboardOpen: boolean
268 /** The dashboard's details state (Y5): closed workflows show only with details on. */
269 isDashboardDetailed: boolean
270 /** How many times the tree was read: each read redraws the readers of `workflows`. */
271 reads: number
272 /** Where the session draws: `terminal` under the REPL, else a surface that attached, else null. */
273 surface: string | null
274 /** Whether a person is at the prompt, as `session.start` reported it. */
275 interactive: boolean
276}
277
278const EMPTY: Model = {
279 step: null,
280 page: 0,
281 filter: '',
282 ring: null,
283 root: null,
284 isRead: false,
285 workflows: [],
286 slices: new Map(),
287 active: null,
288 lastStageUsd: null,
289 hub: null,
290 isDashboardOpen: false,
291 isDashboardDetailed: false,
292 reads: 0,
293 surface: null,
294 interactive: false,
295}
296
297const DASHBOARD_PANE = 'wf-dashboard'
298/** How long the strip trusts what it read about a workflow's run before it reads again. */
299const RUN_SEEN_MS = 10_000
300const QUESTION_FLOOR = 20
301/** The intake mode whose question batches carry no floor annotation. */
302const NO_FLOOR_INTAKE_MODE = 'brainstorm'
303const HUB_DEFAULT_PORT = 48173
304/** The write tools, as a pattern: `MultiEdit` is not a tool this build's types declare. */
305const WRITE_TOOL_PATTERN = /^(?:Write|Edit|MultiEdit|NotebookEdit)$/u
306/** The tools that dispatch a sub-agent with a prompt. */
307const AGENT_TOOL_PATTERN = /^(?:Agent|Task)$/u
308/** Past this many recorded dispatch prompts the oldest is dropped. */
309const FED_CAP = 200
310
311/** A write the read check runs on: who wrote which stage artifact, and the stage entry it matched. */
312type CheckTarget = { agent: string; slug: string; file: string; id: string; match: WriteMatch }
313/** How long a dispatcher run names the turn that follows it. */
314const LAST_RUN_WINDOW_MS = 10_000
315/** The store key of the active workflow, per repository root. */
316const activeStoreKeyOf = (root: string) => `active:${root}`
317/** Every command the module registers: one per key, plus the dashboard and the active-workflow commands. */
318/** Keys whose turn is not a stage: no "this stage" cost. */
319const READ_ONLY_KEYS: ReadonlySet<string> = new Set(['status', 'recap'])
320
321/** The drawn values a reload left in `$.state` (X1): the open pick, the dashboard, the active slug, the stage cost. */
322async function restoredOf($: EngineInterface): Promise<Partial<Model>> {
323 try {
324 const picker = await read($, pickerAtom)
325 const dashboard = await read($, dashboardAtom)
326 const active = await read($, activeAtom)
327 const lastStageUsd = await read($, lastStageUsdAtom)
328 let details = dashboard.details
329 try {
330 const stored = await $.store.get(detailsStoreKeyOf('workflows'))
331 if (typeof stored === 'boolean') details = stored
332 } catch {
333 // No store: the state's own value.
334 }
335 return { step: picker.step, page: picker.page, filter: picker.filter, ring: picker.ring, isDashboardOpen: dashboard.isOpen, isDashboardDetailed: details, active, lastStageUsd }
336 } catch {
337 return {}
338 }
339}
340
341export function register(on: On, options: PluginOptions = {}) {
342 // The one switch for the whole mod. A change to an option reloads the module, which
343 // cancels the old timers and drops its panes; off, the module hooks nothing but the
344 // start of that reload, to clear the status line the mod drew. (The engine refuses a
345 // second session.start hook without a matcher, even in a branch that does not run;
346 // `^` matches every cwd and differs from the live module's `.`.)
347 if (options?.['mod'] === false) {
348 on('session.start', { cwd: /^/u }, ($, e, next) => {
349 $.ui.status(undefined)
350 return next(e)
351 })
352 return
353 }
354 let host: Host | null = null
355 let model: Model = EMPTY
356 let settings: Settings = settingsOf(options ?? {})
357 // The usage guard (WF-CAMPAIGN-PLAN.md 17): its own file, called from this module's session hooks.
358 const usageGuard = createUsageGuard(() => settings.usageGuard)
359 /** The usage guard's windows for the status line, or null before its first reading. */
360 let usageText: string | null = null
361 let bracket: Bracket | null = null
362 /**
363 * A `/wf` turn that ended while its background sub-agents still ran. The
364 * next main turn that names no `/wf` command (the sub-agent's notification)
365 * continues it, and the stage check runs when no sub-agent of it still runs.
366 */
367 let parked: Bracket | null = null
368 let hubTimer: { cancel: () => void } | null = null
369 /** The complete `/wf` command the dispatcher last ran, for a `turn.start` whose text is the expanded skill. */
370 let lastRun: { command: WfCommand; at: number } | null = null
371 /**
372 * The command the mod last put in the prompt box or sent as a prompt. When
373 * it comes back as a run it is complete as typed: `(no slice)` and `(no
374 * slug)` must not open the step they were picked from again.
375 */
376 let issued: string | null = null
377 /**
378 * True while the picker follows the draft in the prompt box (`prompt.edit`):
379 * typing `/wf` opened it, and each edit moves it. A picker a command opened
380 * is the person's own and the box does not move it.
381 */
382 let isDraftDriven = false
383 /** True once the band asked the box for its draft this session. */
384 let boxRead = false
385 /** The prompt-box facts the journal has recorded this session: one row each. */
386 const promptNoted = new Set<string>()
387 /** When the last picker step was asked for, and how long the tree read took, for the first draw's `draw` row. */
388 let pickerTiming: { at: number; readMs: number; kind: string } | null = null
389 /** The InfoNotice instance the hub line joins: the first one drawn. */
390 let noticeRequestId: string | null = null
391 /** The work the last row press started: a press settles once it is done. */
392 let pending: Promise<void> = Promise.resolve()
393 /** The rows the band had at its last draw; the focus hook pages by the same size. */
394 let ringMaxRows = 12
395 /** The probe journal of this session, or null when the switch is off or no home was found. */
396 let journal: ProbeJournal | null = null
397 /** Every agent's Reads of workflow and procedure files this session, by agent key. */
398 let reads: ReadLedger = new Map()
399 /** The inputs a dispatch prompt fed, by the artifact id of the output it named. */
400 let fedByOutput = new Map<string, string[]>()
401 /** True once the main loop read a tracked file after its last checked stage write: a stage is under way. */
402 let mainReadSinceCheck = false
403 /** The queue every ledger append runs through, so two appends never drop a row. */
404 let appendQueue: Promise<void> = Promise.resolve()
405 /** A fixture Requires table from the store, or null for the generated module. */
406 let requiresOverride: readonly RequiresEntry[] | null = null
407 /** The view style of every part the plugin draws (V3); a `/config` change reloads the module with the new one. */
408 const viewStyle = viewStyleOf(options?.['viewStyle'])
409 /** The person's theme row (Y7), read at session start; dark when unknown. */
410 let isDarkTheme = true
411 /** The text of each `$.state` value as last written, so an unchanged value is not written again. */
412 let synced = new Map<string, string>()
413 /** The writes under way; a handler awaits them before it answers. */
414 let syncing: Promise<void> = Promise.resolve()
415 /** The live view's status-line part (K1), or null when no live run shows. */
416 let liveText: string | null = null
417 // The live views (WF-LIVE-VIEWS-PLAN.md): the same module, its own hooks (P10: one module per plugin).
418 /** The live module's side of the shared sites (K1, K3): it fills `press` and `open`, and calls `onStatus`. */
419 const live: LiveLink = {
420 press: () => undefined,
421 open: async () => undefined,
422 follow: async () => undefined,
423 kindOf: async () => null,
424 measure: () => undefined,
425 started: async () => undefined,
426 note: (ok, detail) => void journal?.write({ event: 'draw', ok, detail }),
427 onStatus: text => {
428 // The poll reports every tick: an unchanged text draws nothing.
429 if (text === liveText) return
430 liveText = text
431 if (host !== null && model.step === null) drawStatus(host)
432 },
433 }
434 registerLive(on, { options: options ?? {}, pluginName: PLUGIN_NAME, link: live })
435
436 /**
437 * Writes the drawn values to `$.state` (14.5, X1). A drawing reads them,
438 * so a write redraws exactly its readers, and a reload (a `/config` change)
439 * keeps what the person sees: `session.start` restores the module's copy.
440 */
441 function publish(engine: Host): void {
442 const values: Published[] = [
443 { name: 'picker', value: { step: model.step, page: model.page, filter: model.filter, ring: model.ring } },
444 { name: 'active', value: model.active },
445 { name: 'workflows', value: { root: model.root, isRead: model.isRead, entries: model.workflows, slices: Object.fromEntries(model.slices), reads: model.reads } },
446 { name: 'dashboard', value: { isOpen: model.isDashboardOpen, details: model.isDashboardDetailed } },
447 { name: 'hub', value: model.hub },
448 { name: 'usage', value: usageText },
449 { name: 'lastStageUsd', value: model.lastStageUsd },
450 ]
451 for (const published of values) {
452 const text = JSON.stringify(published.value)
453 if (synced.get(published.name) === text) continue
454 synced.set(published.name, text)
455 syncing = syncing.then(() => engine.put(published)).catch(error => engine.log(`sdlc-workflow state: ${messageOf(error)}`))
456 }
457 }
458
459 /** The Requires tables the check reads. */
460 function requiresNow(): readonly RequiresEntry[] {
461 return requiresOverride ?? REQUIRES
462 }
463
464 const commandNames: readonly string[] = DISPATCHER_COMMANDS
465
466 /** True where the band, the pinned line, and the panes draw: the Desktop app and the terminal. */
467 function drawsHere(): boolean {
468 return model.surface !== null && DRAW_SURFACES.has(model.surface)
469 }
470
471 /**
472 * A void `$.ui.*` call never rejects at the plugin: where a surface does not
473 * carry it the engine drops the call and reports it in its own log. So the
474 * journal records the calls that answer — `prompt.suggest` and
475 * `prompt.fill` — and the `load` and `turn` rows carry the rest.
476 */
477
478
479 /**
480 * Opens this session's probe journal, or leaves it closed when the switch is
481 * off or no home directory answers. The identity it carries names the host
482 * and the surface every row is written under.
483 */
484 async function openJournal(engine: Host, surface: string | null, interactive: boolean): Promise<ProbeJournal | null> {
485 if (!settings.probeJournal) return null
486 const home = await engine.sdlcHome()
487 if (home === null) return null
488 const identity: ProbeIdentity = {
489 session: (await engine.sessionId()).slice(0, 8),
490 host: (await engine.entrypoint()) ?? 'unknown',
491 surface: surface ?? 'none',
492 interactive,
493 }
494 return new ProbeJournal(
495 {
496 read: path => readIfPresent(engine, path),
497 write: (path, text) => engine.writeFile(path, text),
498 now: () => engine.now(),
499 },
500 joinPath(home, PROBE_FILE),
501 identity,
502 )
503 }
504
505 /** Reads the workflow list once per session; a later `/wf` re-reads it. */
506 async function readWorkflows(engine: Host, isFresh: boolean): Promise<void> {
507 if (model.isRead && !isFresh) return
508 const root = await findProjectRoot(engine.cwd, engine.reader)
509 const workflows = root === null ? [] : await listWorkflows(root, engine.reader)
510 model = { ...model, root, isRead: true, workflows, slices: new Map(), reads: model.reads + 1 }
511 if (model.active === null || !workflows.some(w => w.slug === model.active)) {
512 // The store is shared by every session of the root: a closed workflow another
513 // session last named does not open a new session while an active one waits.
514 const remembered = await rememberedSlug(engine, root)
515 const active = remembered !== null && openingCandidatesOf(workflows).some(w => w.slug === remembered) ? remembered : await newestSlug(engine, root, workflows)
516 model = { ...model, active }
517 }
518 }
519
520 /** The slug the store holds for this root: the last one a run, a write, or a rotate named. */
521 async function rememberedSlug(engine: Host, root: string | null): Promise<string | null> {
522 if (root === null) return null
523 try {
524 const value = await engine.storeGet(activeStoreKeyOf(root))
525 return typeof value === 'string' && value !== '' ? value : null
526 } catch {
527 return null
528 }
529 }
530
531 /** Keeps the active slug across a module reload (a `/config` change) and across sessions. */
532 async function remember(engine: Host): Promise<void> {
533 if (model.root === null || model.active === null) return
534 try {
535 await engine.storeSet(activeStoreKeyOf(model.root), model.active)
536 } catch (error) {
537 engine.log(messageOf(error))
538 }
539 }
540
541 /**
542 * The active workflow whose index changed last, by mtime; a closed one only
543 * when no workflow is active; the first when none can be read.
544 */
545 async function newestSlug(engine: Host, root: string | null, workflows: readonly WorkflowEntry[]): Promise<string | null> {
546 if (root === null || workflows.length === 0) return null
547 let best: { slug: string; at: number } | null = null
548 for (const workflow of openingCandidatesOf(workflows)) {
549 const at = (await engine.mtime(joinPath(root, '.ai', 'workflows', workflow.slug, '00-index.md'))) ?? 0
550 if (best === null || at > best.at) best = { slug: workflow.slug, at }
551 }
552 return best?.slug ?? null
553 }
554
555 function activeWorkflow(): WorkflowEntry | null {
556 return model.workflows.find(w => w.slug === model.active) ?? null
557 }
558
559 /** Re-reads the tree and redraws the strip, the status line, and the pane. */
560 async function refreshActive(engine: Host): Promise<void> {
561 await readWorkflows(engine, true)
562 const workflow = activeWorkflow()
563 if (workflow !== null) await readSlices(engine, workflow.slug)
564 if (model.step === null) drawStatus(engine)
565 publish(engine)
566 }
567
568 /**
569 * The pinned status line: the live view's line while a run shows (with `driverStatus` on), else the
570 * strip's short text, then the usage guard's two windows.
571 */
572 function drawStatus(engine: Host): void {
573 if (!drawsHere()) return
574 const workflow = activeWorkflow()
575 // K1: a live view's line (the heartbeat age and the 5-hour usage) takes the place of the strip's text and the usage.
576 const shownLive = settings.driverStatus ? liveText : null
577 const base = shownLive ?? (settings.strip && workflow !== null ? statusTextOf(workflow, settings.cost ? model.lastStageUsd : null, settings.hubNotice ? model.hub : null) : null)
578 const text = [base, shownLive === null ? usageText : null].filter(part => part !== null && part !== '').join(' · ')
579 engine.status(text === '' ? undefined : text)
580 publish(engine)
581 }
582
583 /**
584 * Marks a workflow active, as a `/wf` run or a write names it. The strip and
585 * the status line name the same workflow: both are drawn again. A word that
586 * names no workflow is not one: `/wf intake brainstorm …`, `/wf status
587 * advise` and `/wf handoff pr#12` keep the strip where it was.
588 */
589 function setActive(engine: Host, slug: string | null): void {
590 if (slug === null || slug === model.active) return
591 if (!model.workflows.some(workflow => workflow.slug === slug)) return
592 model = { ...model, active: slug }
593 void remember(engine)
594 if (model.step === null) drawStatus(engine)
595 publish(engine)
596 }
597
598 type StyledStrip = { parts: StripParts; detail: string | null; others: number; columns: number; hasRun: boolean }
599
600 /** Whether each workflow has a yolo, campaign or brainstorm run on disk, read again after 10 seconds. */
601 const runSeen = new Map<string, { hasRun: boolean; at: number }>()
602 async function hasRunOf(engine: Host, slug: string): Promise<boolean> {
603 const now = await engine.now()
604 const seen = runSeen.get(slug)
605 if (seen !== undefined && now - seen.at < RUN_SEEN_MS) return seen.hasRun
606 const hasRun = (await live.kindOf(slug)) !== null
607 runSeen.set(slug, { hasRun, at: now })
608 return hasRun
609 }
610
611 /** The strip's rows for the active workflow, or null when nothing is active. */
612 async function stripOf(engine: Host, columns: number): Promise<StyledStrip | null> {
613 if (!settings.strip) return null
614 const workflow = activeWorkflow()
615 if (workflow === null) return null
616 const slices = await readSlices(engine, workflow.slug)
617 let tokens: number | null = null
618 if (settings.cost && model.root !== null) {
619 const ledger = await readIfPresent(engine, joinPath(model.root, '.ai', 'workflows', workflow.slug, 'cost.jsonl'))
620 tokens = ledger === null ? null : ledgerTokensOf(ledger)
621 }
622 const detail = costShortOf(settings.cost ? model.lastStageUsd : null, tokens)
623 const others = model.workflows.length - 1
624 return { parts: { workflow, slices, text: stripTextOf(workflow, slices) }, detail, others, columns, hasRun: await hasRunOf(engine, workflow.slug) }
625 }
626
627 /** A file's text, or null when it is absent; an absent file is no error to log. */
628 async function readIfPresent(engine: Host, path: string): Promise<string | null> {
629 try {
630 if (!(await engine.reader.exists(path))) return null
631 return await engine.reader.read(path)
632 } catch {
633 return null
634 }
635 }
636
637 /** The rotate button: the next workflow in the ring (active first, then closed), or the named one. */
638 async function rotateActive(engine: Host, slug: string | null): Promise<void> {
639 const target = slug ?? nextActiveSlug(model.workflows, model.active)
640 if (target === null || !model.workflows.some(w => w.slug === target)) return
641 model = { ...model, active: target }
642 await remember(engine)
643 await readSlices(engine, target)
644 if (model.step === null) drawStatus(engine)
645 publish(engine)
646 }
647
648 async function readSlices(engine: Host, slug: string): Promise<SliceEntry[]> {
649 const known = model.slices.get(slug)
650 if (known !== undefined) return known
651 const slices = model.root === null ? [] : await listSlices(model.root, slug, engine.reader)
652 const next = new Map(model.slices)
653 next.set(slug, slices)
654 model = { ...model, slices: next }
655 return slices
656 }
657
658 /** Writes one `prompt` row per distinct fact a session; never the draft's words. */
659 function notePrompt(detail: string): void {
660 if (promptNoted.has(detail)) return
661 promptNoted.add(detail)
662 void journal?.write({ event: 'prompt', ok: true, detail })
663 }
664
665 /** Puts the next step's command into the box while the picker follows it; a refused fill changes nothing. */
666 async function fillDraft(engine: Host, text: string): Promise<void> {
667 try {
668 const filled = await engine.fill(text)
669 notePrompt(`fill ${filled.isFilled ? 'took' : `refused (${filled.refusal ?? 'unknown'})`} · ${model.surface ?? 'none'}`)
670 } catch (error) {
671 journal?.callFailed('fill', messageOf(error))
672 }
673 }
674
675 function close(engine: Host): void {
676 isDraftDriven = false
677 model = { ...model, step: null, filter: '', ring: null }
678 drawStatus(engine)
679 publish(engine)
680 }
681
682 function show(engine: Host, step: Step): void {
683 model = { ...model, step, page: 0, filter: '', ring: null }
684 publish(engine)
685 }
686
687 /** The `back` button: the step before, on its first page. */
688 function back(engine: Host): void {
689 const previous = model.step === null ? null : backOf(model.step)
690 if (previous !== null) show(engine, previous)
691 }
692
693 function turnPage(engine: Host, by: number): void {
694 model = { ...model, page: model.page + by }
695 publish(engine)
696 }
697
698 function setFilter(engine: Host, text: string): void {
699 model = { ...model, filter: text, page: 0 }
700 publish(engine)
701 }
702
703 /** The rows of the step after the filter, before paging; a bare digit in the field narrows nothing. */
704 async function rowsOf(engine: Host, step: Step): Promise<{ options: Option[]; note?: string }> {
705 const { options, note } = await optionsOf(engine, step)
706 return { options: filterOptions(options, filterTextOf(model.filter)), ...(note === undefined ? {} : { note }) }
707 }
708
709 /**
710 * Puts the command into the prompt box for the person's Enter. A surface that
711 * draws its own prompt box (the Desktop app) refuses every fill with
712 * `no_composer`; there the command is sent as the person's prompt instead.
713 */
714 async function fill(engine: Host, text: string): Promise<string> {
715 issued = commandKeyOf(text)
716 let isFilled = false
717 let refusal: string | undefined
718 try {
719 const filled = await engine.fill(`${text} `)
720 isFilled = filled.isFilled
721 refusal = filled.refusal
722 } catch (error) {
723 journal?.callFailed('fill', messageOf(error))
724 engine.log(messageOf(error))
725 }
726 const after = afterFillOf(isFilled, refusal)
727 if (after === 'filled') return `${RUN_TEXT} ${text}`
728 if (after === 'submit') {
729 // Not awaited: the prompt runs once the session is idle, and this hook holds it busy.
730 void engine.submit(text).catch(error => {
731 journal?.callFailed('submit', messageOf(error))
732 engine.log(`${FILL_REFUSED_TEXT}${text}`)
733 })
734 return `${SUBMIT_TEXT} ${text}`
735 }
736 journal?.callFailed('fill', refusal === undefined ? FILL_REFUSED_TEXT.trim() : `${FILL_REFUSED_TEXT.trim()} (${refusal})`)
737 return `${FILL_REFUSED_TEXT}${text}`
738 }
739
740 /** What a pick does: the next step, or the command into the prompt box. */
741 async function advance(engine: Host, value: string): Promise<void> {
742 const step = model.step
743 if (step === null) return
744 if (step.kind === 'key') await readWorkflows(engine, false)
745 if (step.kind === 'slug' && value !== '') await readSlices(engine, value)
746 const outcome = pick(step, value, slug => (model.slices.get(slug) ?? []).length > 0)
747 if (outcome.kind === 'step') {
748 const isDraft = isDraftDriven
749 show(engine, outcome.step)
750 if (isDraft) {
751 // The picker follows the box: the box takes the pick, and the next step opens on it.
752 isDraftDriven = true
753 const next = outcome.step
754 await fillDraft(engine, next.kind === 'slug' ? fillOf(next.key) : next.kind === 'slice' ? fillOf(next.key, next.slug) : fillOf(''))
755 }
756 return
757 }
758 const text = await fill(engine, outcome.text.trim())
759 close(engine)
760 engine.log(text)
761 }
762
763 /** The list a step draws, with the note a step without data shows instead. */
764 async function optionsOf(engine: Host, step: Step): Promise<{ options: Option[]; note?: string }> {
765 if (step.kind === 'key') return { options: keyOptions() }
766 if (step.kind === 'slug') {
767 const options = slugOptions(step, model.workflows)
768 if (model.root === null) return { options, note: NO_ROOT_TEXT }
769 if (model.workflows.length === 0) return { options, note: NO_WORKFLOWS_TEXT }
770 return { options }
771 }
772 return { options: sliceOptions(step, await readSlices(engine, step.slug)) }
773 }
774
775 on('session.measure', async ($, e, next) => {
776 await usageGuard.measure(e)
777 if (e.changed.includes('rateLimits')) live.measure(e.rateLimits)
778 return next(e)
779 })
780
781 on('session.start', async ($, e, next) => {
782 usageGuard.start({
783 cwd: e.cwd,
784 home: async () => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')),
785 sessionId: async () => {
786 try {
787 return await $.session.id()
788 } catch {
789 return ''
790 }
791 },
792 now: () => $.clock.now(),
793 usage: async () => (await $.session.usage()).rateLimits,
794 read: async path => {
795 try {
796 return (await $.fs.exists(path)) ? await $.fs.read(path) : null
797 } catch {
798 return null
799 }
800 },
801 write: (path, text) => $.fs.write(path, text),
802 list: async path => {
803 try {
804 return await $.fs.list(path)
805 } catch {
806 return []
807 }
808 },
809 exists: path => $.fs.exists(path),
810 status: text => {
811 usageText = text ?? null
812 if (host !== null && model.step === null) drawStatus(host)
813 },
814 toast: text => $.ui.toast(text),
815 submit: text => $.prompt.submit({ text }),
816 storeGet: key => $.store.get(key),
817 storeSet: (key, value) => $.store.set(key, value),
818 every: (ms, fn) => $.clock.every(ms, fn),
819 })
820 // A reload (a `/config` change, a hot reload) runs this hook again: the drawn values come back from `$.state` (X1, F2).
821 model = { ...EMPTY, surface: e.surface, interactive: e.isInteractive, ...(await restoredOf($)) }
822 synced = new Map()
823 host = null
824 journal = null
825 try {
826 isDarkTheme = isDarkThemeOf((await $.config.list()).find(row => row.key === 'theme')?.value)
827 } catch {
828 isDarkTheme = true
829 }
830 const engine: Host = {
831 cwd: e.cwd,
832 reader: {
833 list: path => $.fs.list(path),
834 read: path => $.fs.read(path),
835 exists: path => $.fs.exists(path),
836 },
837 fill: text => $.prompt.fill({ text }),
838 readBox: () => $.prompt.read(),
839 submit: text => $.prompt.submit({ text, asUser: true }),
840 put: async published => {
841 // One literal atom per value: the engine's scan reads no atom passed in a variable.
842 switch (published.name) {
843 case 'picker':
844 await update($, pickerAtom, () => published.value)
845 break
846 case 'active':
847 await update($, activeAtom, () => published.value)
848 break
849 case 'workflows':
850 await update($, workflowsAtom, () => published.value)
851 break
852 case 'dashboard':
853 await update($, dashboardAtom, () => published.value)
854 break
855 case 'hub':
856 await update($, hubAtom, () => published.value)
857 break
858 case 'usage':
859 await update($, usageAtom, () => published.value)
860 break
861 case 'lastStageUsd':
862 await update($, lastStageUsdAtom, () => published.value)
863 break
864 }
865 },
866 log: text => $.ui.log(text),
867 status: text => $.ui.status(text),
868 focus: (requestId, key) => $.ui.focus({ requestId, key }),
869 later: fn => {
870 $.clock.after(0, fn)
871 },
872 mtime: async path => {
873 try {
874 if (!(await $.fs.exists(path))) return null
875 return (await $.fs.stat(path)).mtimeMs
876 } catch {
877 return null
878 }
879 },
880 toast: (text, timeoutMs) => $.ui.toast(text, timeoutMs === undefined ? undefined : { timeoutMs }),
881 suggest: text => $.prompt.suggest({ text }),
882 costUsd: async () => {
883 try {
884 return (await $.session.usage()).cost?.usd ?? null
885 } catch {
886 return null
887 }
888 },
889 contextPercent: async () => {
890 try {
891 return (await $.session.usage()).context.percent ?? null
892 } catch {
893 return null
894 }
895 },
896 compact: async instructions => {
897 const result = await $.session.compact({ instructions })
898 return result.skip === undefined ? {} : { skip: result.skip }
899 },
900 fetchText: async url => {
901 try {
902 const answer = await $.http.fetch(url)
903 return answer.ok ? answer.text : null
904 } catch {
905 return null
906 }
907 },
908 home: async () => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')),
909 every: (ms, fn) => $.clock.every(ms, fn),
910 now: () => $.clock.now(),
911 openPane: (id, title) => $.ui.open({ id, title }),
912 storeGet: key => $.store.get(key),
913 storeSet: (key, value) => $.store.set(key, value),
914 writeFile: (path, text) => $.fs.write(path, text),
915 appendFile: async (path, text) => {
916 const prior = (await $.fs.exists(path)) ? await $.fs.read(path) : ''
917 await $.fs.write(path, appendedTextOf(prior, text))
918 },
919 sessionId: async () => {
920 try {
921 return await $.session.id()
922 } catch {
923 return ''
924 }
925 },
926 entrypoint: () => $.env.get('CLAUDE_CODE_ENTRYPOINT'),
927 sdlcHome: async () => {
928 const override = await $.env.get('SDLC_HOME')
929 if (override !== undefined && override.trim() !== '') return override.trim()
930 const home = await ((await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')))
931 return home === undefined || home === '' ? null : joinPath(home, '.sdlc')
932 },
933 surfaces: async () => {
934 try {
935 return await $.session.surfaces()
936 } catch {
937 return []
938 }
939 },
940 }
941 bracket = null
942 parked = null
943 reads = new Map()
944 fedByOutput = new Map()
945 mainReadSinceCheck = false
946 requiresOverride = null
947 try {
948 const stored = await engine.storeGet(REQUIRES_STORE_KEY)
949 if (Array.isArray(stored)) requiresOverride = stored as RequiresEntry[]
950 } catch {
951 // No store: the generated module.
952 }
953 usageText = await read($, usageAtom).catch(() => null)
954 lastRun = null
955 noticeRequestId = null
956 hubTimer?.cancel()
957 hubTimer = null
958 try {
959 journal = await openJournal(engine, e.surface, e.isInteractive)
960 host = engine
961 // The Desktop app starts the engine through the SDK: no surface at start. A Desktop
962 // client already attached says where the session draws.
963 const surfaces = await engine.surfaces()
964 if (model.surface === null) {
965 const drawn = surfaces.find(surface => DRAW_SURFACES.has(surface))
966 if (drawn !== undefined) {
967 model = { ...model, surface: drawn }
968 journal?.setSurface(drawn)
969 }
970 }
971 await refreshActive(engine)
972 if (settings.hubNotice && drawsHere()) await watchHub(engine)
973 void journal?.write({ event: 'load', ok: true, detail: `surfaces ${surfaces.join(',') || 'none'} · root ${model.root ?? 'none'} · workflows ${model.workflows.length}` })
974 } catch (error) {
975 engine.log(messageOf(error))
976 void journal?.write({ event: 'load', ok: false, detail: messageOf(error) })
977 }
978 return next(e)
979 })
980
981 on('session.end', async ($, e, next) => {
982 // A `/clear` ends the conversation and no `session.start` follows: what the
983 // agents read is out of their context, so the read check starts again, and
984 // a stage the turn bracket held is over. Nothing here waits: an end is short.
985 if (e.reason === 'clear') {
986 bracket = null
987 parked = null
988 reads = new Map()
989 fedByOutput = new Map()
990 mainReadSinceCheck = false
991 lastRun = null
992 issued = null
993 isDraftDriven = false
994 }
995 return next(e)
996 })
997
998 on('session.attach', async ($, e, next) => {
999 // A remote client (the Desktop app, a phone) joined after the session
1000 // started. It is the surface the session draws on from now on, and the
1001 // one positive signal that does not depend on `isInteractive`.
1002 const engine = host
1003 const result = await next(e)
1004 if (!engine) return result
1005 const surface = surfaceAfterAttach(model.surface, e.surface)
1006 model = { ...model, surface }
1007 journal?.setSurface(surface)
1008 void journal?.write({ event: 'attach', ok: true, detail: `${e.surface} · client ${e.clientId}` })
1009 await refreshActive(engine)
1010 if (settings.hubNotice && drawsHere()) await watchHub(engine)
1011 return result
1012 })
1013
1014 /** Reads the hub's health once, then every minute; a change of state is one toast. */
1015 async function watchHub(engine: Host): Promise<void> {
1016 if (hubTimer !== null) return
1017 const url = await hubUrl(engine)
1018 if (url === null) return
1019 // A hub that does not answer is down, not unknown: the line says so.
1020 const read = async () => hubHealthOf((await engine.fetchText(url)) ?? '')
1021 model = { ...model, hub: await read() }
1022 if (model.step === null) drawStatus(engine)
1023 publish(engine)
1024 hubTimer = engine.every(60_000, () => {
1025 void read().then(health => {
1026 const wasUp = model.hub?.ok === true
1027 const isUp = health?.ok === true
1028 model = { ...model, hub: health }
1029 if (wasUp === isUp) return
1030 engine.toast(isUp ? `sdlc hub is back (${health?.version ?? '?'})` : 'sdlc hub stopped answering')
1031 if (model.step === null) drawStatus(engine)
1032 publish(engine)
1033 })
1034 })
1035 }
1036
1037 /** The hub's health URL from `~/.sdlc/hub-config.json`, or null without a home or a config. */
1038 async function hubUrl(engine: Host): Promise<string | null> {
1039 const home = await engine.home()
1040 if (home === undefined || home === '') return null
1041 const path = joinPath(home, '.sdlc', 'hub-config.json')
1042 if (!(await engine.reader.exists(path))) return null
1043 let port = HUB_DEFAULT_PORT
1044 let hostName = '127.0.0.1'
1045 try {
1046 const config = JSON.parse(await engine.reader.read(path)) as { port?: unknown; host?: unknown }
1047 if (typeof config.port === 'number') port = config.port
1048 if (typeof config.host === 'string' && config.host !== '') hostName = config.host
1049 } catch {
1050 // A config that does not parse still names the default port.
1051 }
1052 return `http://${hostName}:${port}/__sdlc/health`
1053 }
1054
1055 on('config.set', { key: /^sdlc-workflow\./u }, async ($, e, next) => {
1056 const result = await next(e)
1057 if (e.key === `${PLUGIN_NAME}.readCheck` && result.deny === undefined) {
1058 settings = { ...settings, readCheck: readCheckModeOf(result.value) }
1059 return result
1060 }
1061 const name = settingOfKey(PLUGIN_NAME, e.key)
1062 if (name !== null && result.deny === undefined && typeof result.value === 'boolean') {
1063 settings = { ...settings, [name]: result.value }
1064 if (host) {
1065 if (name === 'hubNotice') {
1066 if (result.value) await watchHub(host)
1067 else {
1068 hubTimer?.cancel()
1069 hubTimer = null
1070 model = { ...model, hub: null }
1071 }
1072 }
1073 await refreshActive(host)
1074 }
1075 }
1076 return result
1077 })
1078
1079 /** The strip's `dashboard` button: every workflow's rows read, then the pane opened. */
1080 async function openDashboard(engine: Host): Promise<void> {
1081 if (model.step !== null) close(engine)
1082 await refreshActive(engine)
1083 for (const workflow of model.workflows) if (!workflow.terminal) await readSlices(engine, workflow.slug)
1084 model = { ...model, isDashboardOpen: true }
1085 await engine.openPane(DASHBOARD_PANE, 'sdlc workflows')
1086 }
1087
1088 on('command.run', async ($, e, next) => {
1089 const engine = host
1090 if (!engine) return next(e)
1091 const isOwn = commandNames.includes(e.command)
1092 if (!isOwn) {
1093 // Another command while the band is up closes it.
1094 if (model.step !== null) close(engine)
1095 return next(e)
1096 }
1097 const typedLine = `/wf ${e.args}`
1098 // Where a /wf command comes from (the person's Enter, the phone, a plugin): the probe that tells them apart.
1099 notePrompt(`command origin ${e.origin.kind}`)
1100 const named = wfCommandOf(typedLine)
1101 if (named?.slug) setActive(engine, named.slug)
1102 const isIssued = issued !== null && issued === commandKeyOf(typedLine)
1103 issued = null
1104 const askedAt = await engine.now()
1105 // The band draws on the Desktop app and the terminal, so where neither draws a step
1106 // that cannot be shown is not opened: the command runs as typed, or goes into the prompt box.
1107 // A command the picker itself issued is complete: it runs as typed.
1108 const step = drawsHere() && !isIssued ? stepFor(null, e.args) : null
1109 if (step === null) {
1110 // The arguments are complete: the command runs as typed. A band still
1111 // up from an earlier pick closes.
1112 if (model.step !== null) close(engine)
1113 if (named !== null) lastRun = { command: named, at: await engine.now() }
1114 return next(e)
1115 }
1116 await readWorkflows(engine, true)
1117 pickerTiming = { at: askedAt, readMs: (await engine.now()) - askedAt, kind: step.kind }
1118 show(engine, step)
1119 return { text: openedTextOf(step, model.workflows) }
1120 })
1121
1122 on('ui.render', { component: 'AbovePrompt' }, async ($, e, next) => {
1123 const below = await next(e)
1124 const engine = host
1125 const step = model.step
1126 if (!engine || (e.surface !== 'desktop' && e.surface !== 'terminal') || e.props.hasSurvey) return below
1127 // The reads subscribe the band: a write of any of them draws it again (X1).
1128 await read($, pickerAtom)
1129 await read($, workflowsAtom)
1130 await read($, activeAtom)
1131 await read($, lastStageUsdAtom)
1132 const liveBand = await read($, liveBandAtom)
1133 const palette = paletteOf(viewStyle, e.surface, isDarkTheme)
1134 const { Box, Text, Button, Input } = inked($.ui.resolve(e), palette)
1135 const strip = await stripOf(engine, e.props.bodyColumns)
1136 // The strip's buttons open the mod's own views: the live view of its workflow
1137 // (unless the live line already shows that run), the dashboard, the next workflow.
1138 const stripSlug = strip?.parts.workflow.slug ?? null
1139 const stripActions: StripActions = {
1140 others: strip?.others ?? 0,
1141 rotate: () => {
1142 pending = rotateActive(engine, null).catch(error => engine.log(messageOf(error)))
1143 },
1144 dashboard: () => {
1145 pending = openDashboard(engine).catch(error => engine.log(messageOf(error)))
1146 },
1147 live:
1148 strip === null || stripSlug === null || !strip.hasRun || liveBand?.slug === stripSlug
1149 ? null
1150 : () => {
1151 pending = live.follow(stripSlug)
1152 },
1153 }
1154 const stripTree = strip === null ? null : stripStyledView({ Box, Text, Button }, viewStyle, palette, strip.parts, strip.detail, stripActions, strip.columns)
1155 // The style's card frames the band's own parts; its border takes rows from the page.
1156 const stripHeight = (strip === null ? 0 : styledStripRows(viewStyle, strip.parts, strip.detail, strip.others, strip.columns, stripActions.live !== null)) + cardRowsOf(palette)
1157 if (step === null) {
1158 if (!boxRead) {
1159 // One look at the box per session: whether this surface lets the mod read the draft.
1160 boxRead = true
1161 void engine.readBox().then(box => notePrompt(`read · ${e.surface} · ${box.text === '' ? 'empty' : 'draft'}`), error => journal?.callFailed('read', messageOf(error)))
1162 }
1163 // K3: with no pick open, the band draws the live line (focus, actions on 1 and 2), then the strip.
1164 const openLive = () => {
1165 pending = live.open()
1166 }
1167 const liveLine = liveBand === null ? null : liveBandView({ Box, Text, Button }, liveBand, palette, openLive, key => live.press(key), viewStyle)
1168 const parts = [liveLine, stripTree].filter((part): part is RenderElement => part !== null)
1169 if (parts.length === 0) return below
1170 return stack(Box, below, bandCard(Box, palette, parts))
1171 }
1172 // The strip morphs into the picker: while a step is open, the card holds the picker alone.
1173 const pickerHeight = cardRowsOf(palette)
1174 ringMaxRows = e.props.maxRows - pickerHeight
1175 if (pickerTiming !== null) {
1176 const timing = pickerTiming
1177 pickerTiming = null
1178 const drawnMs = (await engine.now()) - timing.at
1179 void journal?.write({ event: 'draw', ok: true, detail: `picker ${timing.kind} · ${e.surface} · read ${timing.readMs} ms · drawn ${drawnMs} ms after the command` })
1180 }
1181 const { options, note } = await rowsOf(engine, step)
1182 // Every row carries a hotkey, and a digit arms only while the whole band
1183 // fits the rows the site gives it: size the page to those rows, less the
1184 // strip's.
1185 const page = pageOf(options, model.page, pageSizeOf(e.props.maxRows - pickerHeight))
1186 const pickRow = (value: string) => {
1187 pending = advance(engine, value).catch(error => engine.log(messageOf(error)))
1188 }
1189 const labelOf = (option: Option) => pickerRowLabel(viewStyle, option.label, step.kind === 'slug' ? (model.workflows.find(workflow => workflow.slug === option.value) ?? null) : null)
1190 // A workflow row: its state mark, its stage label, and its slices done (MOD-DESIGN 3.3).
1191 const rowOf = (option: Option) => {
1192 if (step.kind !== 'slug') return {}
1193 const workflow = model.workflows.find(entry => entry.slug === option.value)
1194 if (workflow === undefined) return {}
1195 const tone = workflowTone(workflow)
1196 const roster = slicesDoneOf(model.slices.get(workflow.slug) ?? [])
1197 return {
1198 mark: markView({ Text }, viewStyle, palette, tone),
1199 chip: chipView({ Text }, viewStyle, palette, tone, stageWordOf(workflow)),
1200 note: roster.total === 0 ? '' : `${roster.done} of ${roster.total} slices`,hooks/mod/active.ts 509 lines1/**
2 * The active workflow and the turn bracket: pure helpers the strip, the
3 * next-step suggestion, the stage-landed check, the post-stage compaction,
4 * the driver status, and the dashboard read. Nothing here touches the engine.
5 */
6import { entryOf } from './catalog.ts'
7import { readCheckModeOf } from './readledger.ts'
8import type { ReadCheckMode } from './readledger.ts'
9import type { SliceEntry, WorkflowEntry } from './workflows.ts'
10
11/** The settings the manifest's `userConfig` declares, defaults filled in. */
12export type Settings = {
13 strip: boolean
14 suggestNext: boolean
15 stageCheck: boolean
16 questionProgress: boolean
17 driverStatus: boolean
18 spinnerVerb: boolean
19 cost: boolean
20 hubNotice: boolean
21 stageCompact: boolean
22 probeJournal: boolean
23 /** The usage guard (WF-CAMPAIGN-PLAN.md 17). */
24 usageGuard: boolean
25 /** The read check (ARTIFACT-SPLIT-PLAN.md S6): `warn`, `block`, or `off`. */
26 readCheck: ReadCheckMode
27}
28
29/** The settings that are switches: every one but `readCheck`. */
30export type SwitchName = Exclude<keyof Settings, 'readCheck'>
31
32export const SETTING_NAMES: ReadonlyArray<SwitchName> = [
33 'strip',
34 'suggestNext',
35 'stageCheck',
36 'questionProgress',
37 'driverStatus',
38 'spinnerVerb',
39 'cost',
40 'hubNotice',
41 'stageCompact',
42 'probeJournal',
43 'usageGuard',
44]
45
46/** Every setting on, as the manifest defaults them. */
47export const DEFAULT_SETTINGS: Settings = {
48 strip: true,
49 suggestNext: true,
50 stageCheck: true,
51 questionProgress: true,
52 driverStatus: true,
53 spinnerVerb: true,
54 cost: true,
55 hubNotice: true,
56 stageCompact: true,
57 probeJournal: true,
58 usageGuard: true,
59 readCheck: 'warn',
60}
61
62/** The settings from the plugin's options: a boolean field takes its value, anything else its default. */
63export function settingsOf(options: Readonly<Record<string, unknown>>): Settings {
64 const settings: Settings = { ...DEFAULT_SETTINGS }
65 for (const name of SETTING_NAMES) {
66 const value = options[name]
67 if (typeof value === 'boolean') settings[name] = value
68 }
69 settings.readCheck = readCheckModeOf(options['readCheck'])
70 return settings
71}
72
73/** The setting a `config.set` key names (`sdlc-workflow.strip`), or null. */
74export function settingOfKey(pluginName: string, key: string): SwitchName | null {
75 const prefix = `${pluginName}.`
76 if (!key.startsWith(prefix)) return null
77 const name = key.slice(prefix.length)
78 return (SETTING_NAMES as readonly string[]).includes(name) ? (name as SwitchName) : null
79}
80
81/** The `/wf` command a turn ran, parsed from the prompt text. */
82export type WfCommand = { key: string; slug: string | null; slice: string | null }
83
84/**
85 * The `/wf <key> [slug] [slice]` a prompt starts with, or null. A
86 * `/wf-<key> ...` command and the namespaced `/sdlc-workflow:wf` form parse
87 * the same way. An unknown key is null: the dispatcher refuses it.
88 */
89export function wfCommandOf(text: string): WfCommand | null {
90 const match = /^\s*\/(?:sdlc-workflow:)?wf(?:-([a-z-]+))?(?:\s+(.*))?$/su.exec(text)
91 if (match === null) return null
92 const rest = (match[2] ?? '').trim()
93 const tokens = rest === '' ? [] : rest.split(/\s+/u)
94 let key = match[1] ?? null
95 if (key === null) {
96 key = tokens.shift() ?? null
97 }
98 if (key === null || entryOf(key) === null) return null
99 const slug = tokens[0] ?? null
100 const slice = tokens[1] ?? null
101 return { key, slug: slug === undefined ? null : slug, slice: slice === undefined ? null : slice }
102}
103
104/**
105 * The stage file a key writes for a slug and a slice, or null when the key
106 * writes none the check can name. A `*` stands for any text: ship writes one
107 * `09-ship-run-<run-id>.md` per release (the legacy `09-ship.md` is read-only),
108 * and `/wf implement <slug> reviews` writes `05-implement-<slice>.md` for the
109 * slices it fixes.
110 */
111export function expectedArtifactOf(command: WfCommand): string | null {
112 const { key, slice } = command
113 if (key === 'implement' && slice === 'reviews') return '05-implement-*.md'
114 const withSlice = (stem: string) => (slice === null || slice === 'all' ? null : `${stem}-${slice}.md`)
115 switch (key) {
116 case 'shape':
117 return '02-shape.md'
118 case 'slice':
119 return '03-slice.md'
120 case 'plan':
121 return withSlice('04-plan')
122 case 'implement':
123 return withSlice('05-implement')
124 case 'verify':
125 return withSlice('06-verify')
126 case 'handoff':
127 return '08-handoff.md'
128 case 'ship':
129 return '09-ship-run-*.md'
130 case 'retro':
131 return '10-retro.md'
132 default:
133 return null
134 }
135}
136
137/**
138 * Whether a stage turn landed its artifact: the expected file is in the turn's
139 * writes, or its modification time is at or after the turn's start. Without an
140 * expected file (`plan <slug> all`, a key with no artifact) any write under the
141 * workflow counts.
142 */
143export function stageLanded(writes: readonly string[], expected: string | null, startedAt: number, mtime: number | null): boolean {
144 if (expected === null) return writes.length > 0
145 const name = expected.toLowerCase()
146 const pattern = new RegExp(`^${name.split('*').map(part => part.replace(/[.+?^${}()|[\]\\]/gu, '\\$&')).join('.*')}$`, 'u')
147 if (writes.some(path => pattern.test(basenameOf(path)))) return true
148 return mtime !== null && mtime >= startedAt
149}
150
151/** The keys whose landed turn is a stage boundary the mod compacts at; `review` is exempt (its findings feed the fix turn). */
152export const COMPACT_KEYS: ReadonlySet<string> = new Set(['shape', 'slice', 'plan', 'implement', 'verify', 'handoff', 'ship', 'retro'])
153
154/** The end of a turn as `turn.complete` reports it, the fields the compaction decision reads. */
155export type TurnEnd = { reason: string; agentId?: string | undefined }
156
157/**
158 * Whether a landed stage turn is one the mod compacts after: the model answered
159 * (no interruption, refusal, or error), on the main loop, a `/wf <key> <slug>`
160 * of a compacting key, with a workflow still open and a next step to take.
161 */
162export function compactEligible(command: WfCommand | null, workflow: WorkflowEntry | null, end: TurnEnd): boolean {
163 if (command === null || workflow === null) return false
164 if (end.reason !== 'answer' || end.agentId !== undefined) return false
165 if (command.slug === null || !COMPACT_KEYS.has(command.key)) return false
166 if (workflow.terminal || !workflow.nextInvocation) return false
167 return true
168}
169
170/** Past this many paths the instructions name the count, not the list. */
171const COMPACT_PATH_LIMIT = 12
172
173/** The instructions the post-stage compaction hands the summarizer: what to keep, never a format. */
174export function compactInstructionsOf(workflow: WorkflowEntry, command: WfCommand, writes: readonly string[]): string {
175 const parts = [`The /wf ${command.key} stage of workflow ${workflow.slug} is complete.`]
176 const keep = [`the workflow slug ${workflow.slug}`]
177 if (workflow.selectedSlice) keep.push(`the selected slice ${workflow.selectedSlice}`)
178 keep.push(`the next invocation ${workflow.nextInvocation ?? ''}`.trimEnd())
179 parts.push(`Keep ${keep.join(', ')}.`)
180 const paths = [...new Set(writes)]
181 if (paths.length > COMPACT_PATH_LIMIT) parts.push(`Keep the paths of the ${paths.length} artifacts written this turn under .ai/workflows/${workflow.slug}/.`)
182 else if (paths.length > 0) parts.push(`Keep the paths of the artifacts written this turn: ${paths.join(', ')}.`)
183 parts.push('Keep verbatim every decision, acceptance criterion, blocker, and answer the person gave that is not yet written to an artifact.')
184 parts.push('Drop tool output, test logs, and file contents; the next stage re-reads the artifacts from disk.')
185 return parts.join(' ')
186}
187
188/** The sentence every compaction gains while a workflow is active: its position and its files. */
189export function compactKeepSentenceOf(workflow: WorkflowEntry): string {
190 const items = [`the active /wf workflow ${workflow.slug}`]
191 if (workflow.currentStage) items.push(`its stage ${workflow.currentStage}`)
192 if (workflow.selectedSlice) items.push(`its slice ${workflow.selectedSlice}`)
193 if (workflow.nextInvocation) items.push(`its next invocation ${workflow.nextInvocation}`)
194 items.push(`the paths under .ai/workflows/${workflow.slug}/`)
195 return `Keep ${items.join(', ')}.`
196}
197
198/** The toast before a post-stage compaction; the percent is left out when the usage read gave none. */
199export function compactToastOf(key: string, percent: number | null): string {
200 return percent === null ? `wf: compacting after ${key}` : `wf: compacting after ${key} (context ${percent}%)`
201}
202
203/** True when `path` lies under `<root>/.ai/workflows`, on either slash. */
204export function isWorkflowPath(root: string, path: string): boolean {
205 const base = `${normalizePath(root)}/.ai/workflows/`
206 return normalizePath(path).startsWith(base)
207}
208
209/** The workflow slug a path under `.ai/workflows` belongs to, or null. */
210export function slugOfPath(root: string, path: string): string | null {
211 const base = `${normalizePath(root)}/.ai/workflows/`
212 const normal = normalizePath(path)
213 if (!normal.startsWith(base)) return null
214 const rest = normal.slice(base.length)
215 const slug = rest.split('/')[0] ?? ''
216 return slug === '' || rest.indexOf('/') === -1 ? null : slug
217}
218
219/** The file name at the end of a path. */
220export function basenameOf(path: string): string {
221 const normal = normalizePath(path)
222 return normal.slice(normal.lastIndexOf('/') + 1)
223}
224
225export function normalizePath(path: string): string {
226 return path.replace(/\\/g, '/').replace(/\/+$/, '').toLowerCase()
227}
228
229/** The strip's first row for a workflow: slug, stage, slice with its roster count, and the next step. */
230export function stripTextOf(workflow: WorkflowEntry, slices: readonly SliceEntry[]): string {
231 const parts = [`wf ${workflow.slug}`]
232 if (workflow.terminal) {
233 parts.push(`closed (${workflow.status})`)
234 return parts.join(' · ')
235 }
236 if (workflow.status !== 'active') parts.push(workflow.status)
237 if (workflow.currentStage) parts.push(workflow.currentStage)
238 if (workflow.selectedSlice) {
239 const complete = slices.filter(slice => slice.status === 'complete' || slice.status === 'completed').length
240 const count = slices.length > 0 ? ` (${complete} of ${slices.length} complete)` : ''
241 parts.push(`slice ${workflow.selectedSlice}${count}`)
242 }
243 if (workflow.nextInvocation) parts.push(`next: ${workflow.nextInvocation}`)
244 return parts.join(' · ')
245}
246
247/**
248 * The pinned status line, the one row that stays when the band is hidden:
249 * the next invocation (the strip's identity and roster are not repeated),
250 * the last stage's dollars, and the hub in short.
251 */
252export function statusTextOf(workflow: WorkflowEntry, stageUsd: number | null = null, hub: HubHealth | null = null): string {
253 const parts: string[] = []
254 if (workflow.terminal) parts.push(`wf ${workflow.slug} · closed`)
255 else if (workflow.nextInvocation) parts.push(`next ${workflow.nextInvocation}`)
256 else parts.push([`wf ${workflow.slug}`, workflow.currentStage, workflow.selectedSlice].filter(Boolean).join(' · '))
257 if (stageUsd !== null) parts.push(`$${stageUsd.toFixed(2)} stage`)
258 if (hub !== null) parts.push(hubShortTextOf(hub))
259 return parts.join(' · ')
260}
261
262/** `hub 9.157.0` or `hub down`, for the rows that have no room for the notice. */
263export function hubShortTextOf(hub: HubHealth): string {
264 return hub.ok ? `hub ${hub.version ?? '?'}` : 'hub down'
265}
266
267/** The workflows the strip rotates through: the active ones by slug, in a ring. */
268export function nextActiveSlug(workflows: readonly WorkflowEntry[], current: string | null): string | null {
269 const ring = [...ringSlugs(workflows, false), ...ringSlugs(workflows, true)]
270 if (ring.length === 0) return null
271 const at = current === null ? -1 : ring.indexOf(current)
272 return ring[(at + 1) % ring.length] ?? null
273}
274
275function ringSlugs(workflows: readonly WorkflowEntry[], terminal: boolean): string[] {
276 return workflows.filter(w => w.terminal === terminal).map(w => w.slug).sort()
277}
278
279/**
280 * The workflows the strip may open on: the active ones, or every one when
281 * none is active. The caller picks the newest by its index's mtime.
282 */
283export function openingCandidatesOf(workflows: readonly WorkflowEntry[]): WorkflowEntry[] {
284 const active = workflows.filter(w => !w.terminal)
285 return active.length > 0 ? active : [...workflows]
286}
287
288/** Rows a text takes when wrapped into `columns` cells (one at least). */
289export function wrappedRowsOf(text: string, columns: number): number {
290 const width = Math.max(1, columns)
291 return Math.max(1, Math.ceil(text.length / width))
292}
293
294/** The footer mode label for a workflow, or null for a closed one. */
295export function modeLabelOf(workflow: WorkflowEntry): string | null {
296 if (workflow.terminal || !workflow.currentStage) return null
297 return `wf:${workflow.currentStage}`
298}
299
300/** The spinner's word while a `/wf <key>` turn runs, or null to keep the engine's. */
301export function spinnerWordOf(command: WfCommand): string | null {
302 const verbs: Record<string, string> = {
303 shape: 'Shaping',
304 slice: 'Slicing',
305 plan: 'Planning',
306 implement: 'Implementing',
307 verify: 'Verifying',
308 review: 'Reviewing',
309 handoff: 'Handing off',
310 ship: 'Shipping',
311 retro: 'Reflecting',
312 design: 'Designing',
313 probe: 'Probing',
314 simplify: 'Simplifying',
315 auto: 'Driving',
316 yolo: 'Driving',
317 campaign: 'Campaigning',
318 task: 'Working',
319 status: 'Inspecting',
320 recap: 'Recapping',
321 close: 'Closing',
322 docs: 'Documenting',
323 observability: 'Instrumenting',
324 }
325 const verb = verbs[command.key]
326 if (verb === undefined) return null
327 // Reviews mode is no slice: it fixes the review findings of the workflow's slices.
328 if (command.key === 'implement' && command.slice === 'reviews') return command.slug === null ? 'Fixing review findings' : `Fixing review findings in ${command.slug}`
329 const target = command.slice ?? command.slug
330 return target === null ? verb : `${verb} ${target}`
331}
332
333/**
334 * The tokens a `cost.jsonl` ledger sums to, main and subagent rows alike: the
335 * new input, the cache writes and the output. A cache read is the same context
336 * read again on every call, so it is not counted: with it, a long workflow
337 * reads as billions of tokens.
338 */
339export function ledgerTokensOf(text: string): number {
340 let total = 0
341 for (const line of text.split(/\r?\n/u)) {
342 if (line.trim() === '') continue
343 let row: unknown
344 try {
345 row = JSON.parse(line)
346 } catch {
347 continue
348 }
349 if (!row || typeof row !== 'object') continue
350 const record = row as { main?: unknown; subagents?: unknown }
351 total += tokensOf(record.main)
352 if (Array.isArray(record.subagents)) for (const sub of record.subagents) total += tokensOf(sub)
353 }
354 return total
355}
356
357function tokensOf(usage: unknown): number {
358 if (!usage || typeof usage !== 'object') return 0
359 const u = usage as Record<string, unknown>
360 const int = (v: unknown) => (typeof v === 'number' && Number.isFinite(v) ? Math.max(0, Math.floor(v)) : 0)
361 // A Codex row (`lib/cost-ledger.mjs`): its input holds the cached input, and its output holds the reasoning.
362 if (u['fields'] === 'codex' || 'cached_input_tokens' in u) {
363 return Math.max(0, int(u['input_tokens']) - int(u['cached_input_tokens'])) + int(u['cache_write_input_tokens']) + int(u['output_tokens'])
364 }
365 return int(u['input_tokens']) + int(u['cache_creation_input_tokens']) + int(u['output_tokens'])
366}
367
368/** `1.2B`, `1.2M`, `340k`, `900` for a token count. */
369export function tokensText(count: number): string {
370 if (count >= 1_000_000_000) return `${(count / 1_000_000_000).toFixed(1)}B`
371 if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`
372 if (count >= 1_000) return `${Math.round(count / 1_000)}k`
373 return String(count)
374}
375
376/** The strip's detail row: the last stage in dollars, the workflow in ledger tokens, and the hub. */
377export function costTextOf(stageUsd: number | null, ledgerTokens: number | null): string | null {
378 const parts: string[] = []
379 if (stageUsd !== null) parts.push(`$${stageUsd.toFixed(2)} this stage`)
380 if (ledgerTokens !== null) parts.push(`${tokensText(ledgerTokens)} tokens workflow`)
381 return parts.length === 0 ? null : parts.join(' · ')
382}
383
384/** The strip's cost, short enough to pin right: the last stage in dollars, the workflow in tokens. */
385export function costShortOf(stageUsd: number | null, ledgerTokens: number | null): string | null {
386 const parts: string[] = []
387 if (stageUsd !== null) parts.push(`$${stageUsd.toFixed(2)}`)
388 if (ledgerTokens !== null) parts.push(`${tokensText(ledgerTokens)} tok`)
389 return parts.length === 0 ? null : parts.join(' · ')
390}
391
392/** The hub health answer's fields the notice draws. */
393export type HubHealth = { version: string | null; repos: number | null; stale: number | null; ok: boolean }
394
395export function hubHealthOf(text: string): HubHealth {
396 let body: unknown
397 try {
398 body = JSON.parse(text)
399 } catch {
400 return { version: null, repos: null, stale: null, ok: false }
401 }
402 if (!body || typeof body !== 'object') return { version: null, repos: null, stale: null, ok: false }
403 const r = body as Record<string, unknown>
404 const version = typeof r['version'] === 'string' ? r['version'] : null
405 const entries = Array.isArray(r['entries']) ? (r['entries'] as Array<Record<string, unknown>>) : null
406 const repos = entries === null ? null : entries.length
407 const stale = entries === null ? null : entries.filter(entry => entry['stale'] === true).length
408 return { version, repos, stale, ok: r['ok'] === true }
409}
410
411export function hubNoticeTextOf(health: HubHealth | null): string {
412 if (health === null || !health.ok) return 'sdlc hub down'
413 const parts = [`sdlc hub ${health.version ?? '?'}`]
414 if (health.repos !== null) parts.push(`${health.repos} repos`)
415 if (health.stale !== null && health.stale > 0) parts.push(`${health.stale} renders stale`)
416 return parts.join(' · ')
417}
418
419/** The three-cell progress mark of a slice: plan, implement, verify. */
420export function sliceMarkOf(slice: SliceEntry): string {
421 const filled = slice.stage === 'verified' ? 3 : slice.stage === 'implemented' ? 2 : slice.stage === 'planned' ? 1 : 0
422 return '▰'.repeat(filled) + '▱'.repeat(3 - filled)
423}
424
425/**
426 * The items of a YAML list under a top-level key (`findings:`), each as its
427 * scalar fields; null when the text has no such list. Enough YAML for the
428 * ledgers: a list item starts with `- `, its fields are `key: value` lines
429 * indented past the dash, and nested lists are skipped.
430 */
431export function yamlListItemsOf(text: string, key: string): Array<Record<string, string>> | null {
432 const lines = text.split(/\r?\n/u)
433 const start = lines.findIndex(line => line.replace(/\s+$/u, '') === `${key}:`)
434 if (start === -1) return null
435 const items: Array<Record<string, string>> = []
436 let item: Record<string, string> | null = null
437 /** The column of the items' dashes, from the first; a deeper dash is a nested list. */
438 let itemIndent = -1
439 /** The column of the items' fields, from the first; a deeper line is nested. */
440 let fieldIndent = -1
441 for (const line of lines.slice(start + 1)) {
442 if (line.trim() === '') continue
443 if (/^\S/u.test(line)) break
444 const indent = line.length - line.trimStart().length
445 const dash = /^\s*-\s*(.*)$/u.exec(line)
446 if (dash !== null && (itemIndent === -1 || indent === itemIndent)) {
447 itemIndent = indent
448 item = {}
449 items.push(item)
450 const field = /^([\w-]+):\s*(.*)$/u.exec(dash[1] ?? '')
451 if (field !== null) {
452 fieldIndent = indent + 2
453 item[field[1] as string] = unquoted(field[2] ?? '')
454 }
455 continue
456 }
457 if (dash !== null || item === null || indent <= itemIndent) continue
458 if (fieldIndent === -1) fieldIndent = indent
459 if (indent !== fieldIndent) continue
460 const field = /^([\w-]+):\s*(.*)$/u.exec(line.trimStart())
461 if (field !== null && !((field[1] as string) in item)) item[field[1] as string] = unquoted(field[2] ?? '')
462 }
463 return items
464}
465
466function unquoted(value: string): string {
467 const trimmed = value.trim()
468 const quoted = /^"(.*)"$|^'(.*)'$/u.exec(trimmed)
469 return quoted ? (quoted[1] ?? quoted[2] ?? '').trim() : trimmed
470}
471
472/** The finding statuses a review ledger counts as open (review/_artifact.md Step 5b). */
473const OPEN_FINDING_STATUSES: ReadonlySet<string> = new Set(['open', 'deferred', 'could-not-fix'])
474
475/**
476 * Open rows of a review ledger: the `findings:` items whose status is open
477 * (absent counts as open), else, without such a list, unchecked markdown rows.
478 */
479export function openFindingsOf(text: string): number {
480 const items = yamlListItemsOf(text, 'findings')
481 if (items !== null) return items.filter(item => OPEN_FINDING_STATUSES.has((item['status'] ?? 'open').toLowerCase())).length
482 return (text.match(/^\s*[-*]\s+\[ \]\s/gmu) ?? []).length
483}
484
485/** Open BLOCKER and HIGH findings of `.ai/ship-plan-audit.md`: the rows its triage gate counts. */
486export function shipPlanBlockersOf(text: string): number {
487 const items = yamlListItemsOf(text, 'findings') ?? []
488 return items.filter(item => (item['status'] ?? 'open').toLowerCase() === 'open' && /^(?:BLOCKER|HIGH)$/iu.test(item['severity'] ?? '')).length
489}
490
491/**
492 * The review ledger a workflow's dashboard row reads, from the names in its
493 * directory: the sweep-level sibling YAML (`07-review.yaml`, else the selected
494 * slice's), else the last YAML by name; the markdown by the same rule when
495 * there is no YAML.
496 */
497export function reviewLedgerNameOf(names: readonly string[], selectedSlice: string | null): string | null {
498 const ledgers = names.filter(name => /^07-review.*\.(?:md|yaml)$/u.test(name)).sort()
499 for (const extension of ['yaml', 'md']) {
500 const own = ledgers.filter(name => name.endsWith(`.${extension}`))
501 if (own.length === 0) continue
502 const sweep =
503 own.find(name => name === `07-review.${extension}`) ??
504 (selectedSlice === null ? undefined : own.find(name => name === `07-review-${selectedSlice}.${extension}`))
505 return sweep ?? (own[own.length - 1] as string)
506 }
507 return null
508}
509hooks/mod/names.ts 36 lines1/**
2 * The names the mod is known by: its plugin name, the element keys its band
3 * draws, and the texts it prints.
4 */
5export const PLUGIN_NAME = 'sdlc-workflow'
6
7/** The filter `Input` in the band's title row; `e.element` at `ui.input`. */
8export const FILTER_KEY = 'wf-filter'
9/** The `Button` that closes the band; `e.element` at `ui.press`. */
10export const CLOSE_KEY = 'wf-close'
11/** The `Button` that returns to the step before; `e.element` at `ui.press`. */
12export const BACK_KEY = 'wf-back'
13
14/** The names the bare dispatcher command may resolve to at `command.run`. */
15export const DISPATCHER_COMMANDS = ['wf', `${PLUGIN_NAME}:wf`] as const
16
17/** One row at 80 columns: a longer hint wraps, and a wrapped band arms no digit. */
18/** The hint under a picker that follows the prompt box: the box keeps the keys. */
19export const DRAFT_HINT_TEXT = 'keep typing to narrow · click a row to put it in the box · Backspace past /wf closes'
20export const HINT_TEXT = 'digit picks · 0 or wheel pages · ctrl+x tab: type filters, digit+Enter picks, Tab moves, Esc leaves'
21export const NO_MATCH_TEXT = '(nothing matches the filter)'
22export const NOTHING_TEXT = '(nothing to pick)'
23export const MORE_KEY = 'wf-more'
24/** The strip's buttons: the next workflow, the dashboard pane, the live view of the strip's workflow. */
25export const ROTATE_KEY = 'wf-strip-rotate'
26export const DASHBOARD_KEY = 'wf-strip-dashboard'
27export const LIVE_KEY = 'wf-strip-live'
28export const OPTION_KEY_PREFIX = 'wf-opt:'
29export const NO_ROOT_TEXT = 'No .ai/workflows directory at or above the working directory; type the command in full.'
30export const NO_WORKFLOWS_TEXT = 'No workflows under .ai/workflows yet; start one with /wf intake <description>.'
31export const CLOSED_TEXT = 'Picker closed.'
32export const RUN_TEXT = 'Press Enter to run'
33export const FILL_REFUSED_TEXT = 'The prompt box is not free; type the command in full: '
34/** Where the surface draws its own prompt box (the Desktop app), the command is sent as the person's prompt. */
35export const SUBMIT_TEXT = 'Running'
36hooks/mod/picker.ts 289 lines1/**
2 * The picker's state machine, pure: which step a command opens at, the
3 * options each step offers, and what a pick does (the next step, or the
4 * command text to fill into the prompt).
5 */
6import { CATALOG, entryOf } from './catalog.ts'
7import type { ArgumentNeed } from './catalog.ts'
8import type { SliceEntry, WorkflowEntry } from './workflows.ts'
9
10export type Step =
11 | { kind: 'key' }
12 | { kind: 'slug'; key: string }
13 | { kind: 'slice'; key: string; slug: string }
14
15export type Option = { value: string; label: string }
16
17export type Outcome =
18 | { kind: 'step'; step: Step }
19 | { kind: 'fill'; text: string }
20
21/** The value of the option that ends a step without a slug or a slice. */
22export const NONE = '-'
23/** The value of the `all` option `plan` offers for its slice. */
24export const ALL = 'all'
25
26/**
27 * Which step a run of `/wf <key> <args>` opens at, or null when the typed
28 * arguments already satisfy the key and the command should run as typed.
29 *
30 * A bare `/wf` opens at the key step. A key with no argument that needs a
31 * slug opens at the slug step. A key whose arguments already hold a slug runs
32 * as typed: its slice is optional, and a person who typed or sent the slug
33 * (from the phone, where the band does not draw) is not held for one. The
34 * slice step opens only from the picker's own slug step (`pick`).
35 */
36export function stepFor(key: string | null, args: string): Step | null {
37 const tokens = args.trim() === '' ? [] : args.trim().split(/\s+/u)
38 if (key === null) {
39 if (tokens.length === 0) return { kind: 'key' }
40 const typed = tokens[0] as string
41 const entry = entryOf(typed)
42 if (entry === null) return null
43 // A typed `/wf status` is complete: a key whose slug is optional runs without one.
44 if (tokens.length === 1 && entry.need === 'slug-optional') return null
45 return stepFor(typed, tokens.slice(1).join(' '))
46 }
47 const entry = entryOf(key)
48 if (entry === null) return null
49 if (tokens.length === 0) {
50 return entry.need === 'none' ? null : { kind: 'slug', key }
51 }
52 return null
53}
54
55/** How many open workflows the opened text names before it says "and N more". */
56const NAMED_WORKFLOWS = 6
57
58/**
59 * What a command that opened the picker answers: where the list is, and the
60 * whole command to send instead, for a person whose client draws no band (the
61 * phone over Remote Control). A step that asks for a workflow names the open ones.
62 */
63export function openedTextOf(step: Step, workflows: readonly WorkflowEntry[]): string {
64 const head = 'Pick from the list above the prompt'
65 if (step.kind === 'key') return `${head}, or send /wf <key> <slug>.`
66 const key = step.kind === 'slug' ? step.key : `${step.key} ${step.slug}`
67 const form = step.kind === 'slug' ? `/wf ${key} <slug>` : `/wf ${key} <slice>`
68 if (step.kind !== 'slug') return `${head}, or send ${form}.`
69 const open = workflows.filter(workflow => !workflow.terminal).map(workflow => workflow.slug)
70 if (open.length === 0) return `${head}, or send ${form}.`
71 const named = open.slice(0, NAMED_WORKFLOWS).join(', ')
72 const more = open.length > NAMED_WORKFLOWS ? ` and ${open.length - NAMED_WORKFLOWS} more` : ''
73 return `${head}, or send ${form}. Open workflows: ${named}${more}.`
74}
75
76export function takesSlice(need: ArgumentNeed): boolean {
77 return need === 'slug-slice-optional' || need === 'slug-slice-or-all'
78}
79
80/** The heading the band draws for a step. */
81export function titleOf(step: Step): string {
82 if (step.kind === 'key') return '/wf — pick a key'
83 if (step.kind === 'slug') return `/wf ${step.key} — pick a workflow`
84 return `/wf ${step.key} ${step.slug} — pick a slice`
85}
86
87/** The options of the key step: every key with its description. */
88export function keyOptions(): Option[] {
89 return CATALOG.map(entry => ({ value: entry.key, label: `${entry.key} ${entry.description}` }))
90}
91
92/**
93 * The options of the slug step: active workflows first, then closed ones,
94 * each with its status, stage, and selected slice. An optional-slug key
95 * offers "no slug" first.
96 */
97export function slugOptions(step: Extract<Step, { kind: 'slug' }>, workflows: readonly WorkflowEntry[]): Option[] {
98 const entry = entryOf(step.key)
99 const options: Option[] = []
100 if (entry?.need === 'slug-optional') options.push({ value: NONE, label: '(no slug)' })
101 const ordered = [...workflows.filter(w => !w.terminal), ...workflows.filter(w => w.terminal)]
102 for (const workflow of ordered) options.push({ value: workflow.slug, label: workflowLabel(workflow) })
103 return options
104}
105
106export function workflowLabel(workflow: WorkflowEntry): string {
107 const parts = [workflow.terminal ? `closed (${workflow.status})` : workflow.status]
108 if (!workflow.terminal && workflow.currentStage) parts.push(`stage ${workflow.currentStage}`)
109 if (!workflow.terminal && workflow.selectedSlice) parts.push(`slice ${workflow.selectedSlice}`)
110 return `${workflow.slug} ${parts.join(' · ')}`
111}
112
113/**
114 * The options of the slice step: "no slice" first, `all` for `plan`, then
115 * every slice with its roster status and the furthest stage file present.
116 */
117export function sliceOptions(step: Extract<Step, { kind: 'slice' }>, slices: readonly SliceEntry[]): Option[] {
118 const entry = entryOf(step.key)
119 const options: Option[] = [{ value: NONE, label: '(no slice)' }]
120 if (entry?.need === 'slug-slice-or-all') options.push({ value: ALL, label: 'all every slice' })
121 for (const slice of slices) options.push({ value: slice.slug, label: sliceLabel(slice) })
122 return options
123}
124
125export function sliceLabel(slice: SliceEntry): string {
126 const parts = [slice.status]
127 if (slice.stage !== 'defined') parts.push(slice.stage)
128 if (slice.complexity) parts.push(slice.complexity)
129 return `${slice.slug} ${parts.join(' · ')}`
130}
131
132/**
133 * What a pick does. `hasSlices` says whether the picked workflow carries a
134 * roster, so a key that may take a slice skips the slice step without one.
135 */
136export function pick(step: Step, value: string, hasSlices: (slug: string) => boolean): Outcome {
137 if (step.kind === 'key') {
138 const entry = entryOf(value)
139 if (entry === null || entry.need === 'none') return { kind: 'fill', text: fillOf(value) }
140 return { kind: 'step', step: { kind: 'slug', key: value } }
141 }
142 if (step.kind === 'slug') {
143 if (value === NONE) return { kind: 'fill', text: fillOf(step.key) }
144 const entry = entryOf(step.key)
145 if (entry !== null && takesSlice(entry.need) && hasSlices(value)) {
146 return { kind: 'step', step: { kind: 'slice', key: step.key, slug: value } }
147 }
148 return { kind: 'fill', text: fillOf(step.key, value) }
149 }
150 if (value === NONE) return { kind: 'fill', text: fillOf(step.key, step.slug) }
151 return { kind: 'fill', text: fillOf(step.key, step.slug, value) }
152}
153
154/** The command text the prompt receives, with a trailing space for more arguments. */
155export function fillOf(key: string, slug?: string, slice?: string): string {
156 return `/wf ${[key, slug, slice].filter(part => part !== undefined && part !== '').join(' ')} `
157}
158
159/** One page of options: at most `size` rows, `size` never below one. */
160export type Page = { items: Option[]; page: number; pages: number }
161
162/**
163 * The options a page shows, so every row can carry a one-digit hotkey. The
164 * page index wraps, so a "more" press after the last page shows the first.
165 */
166export function pageOf(options: readonly Option[], page: number, size: number): Page {
167 const width = Math.max(1, Math.floor(size))
168 const pages = Math.max(1, Math.ceil(options.length / width))
169 const index = ((page % pages) + pages) % pages
170 return { items: options.slice(index * width, index * width + width), page: index, pages }
171}
172
173/** The most rows one page may hold: the nine digits; `0` turns the page. */
174export const MAX_PAGE_SIZE = 9
175
176/** The hotkey of the row at `index` on its page: `1`–`9`; none past nine. */
177export function hotkeyOf(index: number): string | undefined {
178 if (index < 0 || index >= MAX_PAGE_SIZE) return undefined
179 return String(index + 1)
180}
181
182/** The step a "back" press returns to: the key step from a slug step, the slug step from a slice step; none from the key step. */
183export function backOf(step: Step): Step | null {
184 if (step.kind === 'slug') return { kind: 'key' }
185 if (step.kind === 'slice') return { kind: 'slug', key: step.key }
186 return null
187}
188
189/**
190 * What a digit typed into the filter field means: `1`–`9` the row at that
191 * position on the page shown, `0` the next page. Any other text is a filter.
192 */
193export type DigitCommand = { kind: 'row'; index: number } | { kind: 'more' }
194
195export function digitCommandOf(text: string): DigitCommand | null {
196 const trimmed = text.trim()
197 if (!/^[0-9]$/u.test(trimmed)) return null
198 return trimmed === '0' ? { kind: 'more' } : { kind: 'row', index: Number(trimmed) - 1 }
199}
200
201/** The filter the rows narrow by: a bare digit is a pick, not a filter. */
202export function filterTextOf(text: string): string {
203 return digitCommandOf(text) === null ? text : ''
204}
205
206/**
207 * What Enter in the filter field does: a digit picks that row of the page
208 * shown (`0` turns the page); other text picks the first row it leaves;
209 * nothing when no row fits.
210 */
211export type SubmitAction = { kind: 'pick'; value: string } | { kind: 'more' }
212
213export function submitActionOf(text: string, page: Page, options: readonly Option[]): SubmitAction | null {
214 const digit = digitCommandOf(text)
215 if (digit !== null) {
216 if (digit.kind === 'more') return { kind: 'more' }
217 const row = page.items[digit.index]
218 return row === undefined ? null : { kind: 'pick', value: row.value }
219 }
220 const first = filterOptions(options, text)[0]
221 return first === undefined ? null : { kind: 'pick', value: first.value }
222}
223
224/** The options whose value or label holds every word of `text`, case-insensitively. */
225export function filterOptions(options: readonly Option[], text: string): Option[] {
226 const words = text.trim().toLowerCase().split(/\s+/u).filter(Boolean)
227 if (words.length === 0) return [...options]
228 return options.filter(option => {
229 const haystack = `${option.value} ${option.label}`.toLowerCase()
230 return words.every(word => haystack.includes(word))
231 })
232}
233
234/**
235 * What a command does after its fill: `filled`, the prompt box holds it for the
236 * person's Enter; `submit`, the surface draws its own prompt box (the Desktop
237 * app answers every fill `no_composer`), so the command goes in as the person's
238 * prompt; `type`, the box is busy or the cause is unknown, so the person types it.
239 */
240export function afterFillOf(isFilled: boolean, refusal: string | undefined): 'filled' | 'submit' | 'type' {
241 if (isFilled) return 'filled'
242 return refusal === 'no_composer' ? 'submit' : 'type'
243}
244
245/** `/wf`, `/sdlc-workflow:wf`, or a `/wf-<word>` shortcut at the start of a draft. */
246const DRAFT_HEAD = /^\/(?:sdlc-workflow:)?wf(?:-([a-z][a-z-]*))?(?=\s|$)/u
247
248/**
249 * The picker step a draft in the prompt box asks for, and the filter the word
250 * being typed sets; null when the draft is no `/wf` command, or is complete.
251 *
252 * `/wf` and `/wf pl` give the key step (filter `pl`); `/wf plan ` the
253 * workflow step; `/wf plan al` the workflow step (filter `al`); `/wf plan
254 * alpha ` the slice step where the key takes a slice. A `/wf-plan` shortcut
255 * reads as `/wf plan`. A word that is no key, a key that needs nothing, or a
256 * key past its last argument gives null.
257 */
258export function draftStepOf(draft: string): { step: Step; filter: string } | null {
259 const text = draft.replace(/^\s+/u, '')
260 const head = DRAFT_HEAD.exec(text)
261 if (head === null) return null
262 const rest = text.slice(head[0].length)
263 const words = rest.trim() === '' ? [] : rest.trim().split(/\s+/u)
264 const tokens = head[1] === undefined ? words : [head[1], ...words]
265 // The last word is still being typed unless the draft ends in a space.
266 const isTyping = tokens.length > 0 && !/\s$/u.test(text)
267 const done = isTyping ? tokens.slice(0, -1) : tokens
268 const filter = isTyping ? (tokens[tokens.length - 1] as string) : ''
269 if (done.length === 0) {
270 if (filter !== '' && !keyOptions().some(option => option.value.startsWith(filter))) return null
271 return { step: { kind: 'key' }, filter }
272 }
273 const key = done[0] as string
274 const entry = entryOf(key)
275 if (entry === null || entry.need === 'none') return null
276 if (done.length === 1) return { step: { kind: 'slug', key }, filter }
277 if (done.length === 2 && takesSlice(entry.need)) return { step: { kind: 'slice', key, slug: done[1] as string }, filter }
278 return null
279}
280
281/** True when two steps ask for the same pick. */
282export function isSameStep(left: Step | null, right: Step | null): boolean {
283 if (left === null || right === null) return left === right
284 if (left.kind !== right.kind) return false
285 if (left.kind === 'key') return true
286 if (left.kind === 'slug') return right.kind === 'slug' && left.key === right.key
287 return right.kind === 'slice' && left.key === right.key && left.slug === right.slug
288}
289hooks/mod/styles/existing.tsx 359 lines1/**
2 * The existing visual parts in the three styles (MOD-DESIGN, 2026-10-04):
3 * E1 the picker band's parts, E2 the strip, E3 the workflows dashboard, E4
4 * the hub notice.
5 *
6 * One layout, three skins: every part is a stack of fixed rows. A row has a
7 * left part that ends in "…" and a right part (figures, buttons) that never
8 * moves. Nothing wraps. A style changes the colours, the glyphs and the case,
9 * never a fact, a hotkey, an element key or the row order (Y3).
10 */
11import type { ElementTable, RenderElement } from 'claude-code'
12
13import type { HubHealth } from '../active.ts'
14import { DASHBOARD_KEY, LIVE_KEY, ROTATE_KEY } from '../names.ts'
15import type { SliceEntry, WorkflowEntry } from '../workflows.ts'
16import { cellsView, chipView, controlLabel, markView, say, stagePlaceOf, CELL_STAGES } from './skin.tsx'
17import type { Palette, Tone, ViewStyle } from './tokens.ts'
18
19type Ui = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button'>
20
21/** The tone of a workflow's stage: done when closed, attention when it waits for the person, run otherwise. */
22export function workflowTone(workflow: WorkflowEntry): Tone {
23 if (workflow.terminal) return 'quiet'
24 if (needsPerson(workflow)) return 'attention'
25 return 'run'
26}
27
28/** A workflow that waits for the person: its index says so. */
29export function needsPerson(workflow: WorkflowEntry): boolean {
30 return /awaiting|blocked|needs/iu.test(workflow.status)
31}
32
33/**
34 * The stage a workflow is at, for its label and its cells: the stage its next
35 * command names when that command moves it forward along the cells, else the
36 * stage its index names. A slice loop writes `plan` to the index and names
37 * `/wf implement` next: the workflow is at implement.
38 */
39export function stageAtOf(workflow: WorkflowEntry): string | null {
40 const stage = workflow.currentStage
41 if (workflow.terminal || stage === null) return stage
42 const key = /^\/wf\s+([a-z-]+)/u.exec(workflow.nextInvocation ?? '')?.[1] ?? null
43 if (key === null) return stage
44 const from = stagePlaceOf(stage, false).at
45 const to = stagePlaceOf(key, false).at
46 return from >= 0 && to > from ? key : stage
47}
48
49/** The stage label of a workflow: the stage it is at, "needs you", or "closed". */
50export function stageWordOf(workflow: WorkflowEntry): string {
51 if (workflow.terminal) return 'closed'
52 if (needsPerson(workflow)) return 'needs you'
53 return stageAtOf(workflow) ?? workflow.status
54}
55
56/** The slices done of a roster, and its size. */
57export function slicesDoneOf(slices: readonly SliceEntry[]): { done: number; total: number } {
58 return { done: slices.filter(slice => slice.status === 'complete' || slice.status === 'completed' || slice.stage === 'verified').length, total: slices.length }
59}
60
61/** The strip's slice count, named so that it does not read as the stage cells: `21/23 slices`. */
62export function sliceCountText(slices: { done: number; total: number }): string {
63 return `${slices.done}/${slices.total} slices`
64}
65
66// ---------------------------------------------------------------------------
67// E1 — the picker band's parts
68// ---------------------------------------------------------------------------
69
70/** The band's title split for the breadcrumb: the command so far, and what the step asks. */
71export function pickerCrumbsOf(title: string): { path: string[]; ask: string } {
72 const [command = title, ask = ''] = title.split(' — ')
73 return { path: command.split(/\s+/u).filter(Boolean), ask }
74}
75
76/** The breadcrumb header: `/wf › plan › pick a workflow`. */
77export function pickerTitleView(ui: Pick<Ui, 'Text'>, style: ViewStyle, palette: Palette, title: string): RenderElement[] {
78 const { Text } = ui
79 const { path, ask } = pickerCrumbsOf(title)
80 const out: RenderElement[] = []
81 path.forEach((part, index) => {
82 if (index > 0) out.push(<Text color={palette.tones.quiet} {...(palette.tones.quiet === undefined ? { dimColor: true } : {})}>›</Text>)
83 out.push(<Text bold>{say(style, part)}</Text>)
84 })
85 if (ask !== '') {
86 out.push(<Text color={palette.tones.quiet} {...(palette.tones.quiet === undefined ? { dimColor: true } : {})}>›</Text>)
87 out.push(<Text>{say(style, ask)}</Text>)
88 }
89 return out
90}
91
92/** The page count of a step that pages: `1/3`. */
93export function pickerPageText(page: number, pages: number): string | null {
94 return pages > 1 ? `${page + 1}/${pages}` : null
95}
96
97/** A row's label split into its name (the clickable part) and its note. */
98export function rowPartsOf(label: string): { name: string; note: string } {
99 const cut = label.indexOf(' ')
100 return cut === -1 ? { name: label, note: '' } : { name: label.slice(0, cut), note: label.slice(cut + 2) }
101}
102
103/**
104 * The words a workflow row's label keeps (E1 rows): the name the filter
105 * matches. The mark and the stage label draw beside it, not in it.
106 */
107export function pickerRowLabel(style: ViewStyle, label: string, _workflow: WorkflowEntry | null): string {
108 return say(style, label)
109}
110
111/** The labels of the band's own controls (E1): the same keys, in capitals for D and E. */
112export function pickerControlLabel(style: ViewStyle, label: string): string {
113 return controlLabel(style, label)
114}
115
116// ---------------------------------------------------------------------------
117// E2 — the strip
118// ---------------------------------------------------------------------------
119
120export type StripParts = {
121 workflow: WorkflowEntry
122 slices: readonly SliceEntry[]
123 /** The strip text the helpers compose (`stripTextOf`), for the status line and the tests. */
124 text: string
125}
126
127/** The width from which the strip is one row; below it the next command and the cost take a second row. */
128export const STRIP_ONE_ROW_COLUMNS = 96
129
130/**
131 * The columns the strip's first row takes with the cost on it and no next
132 * command: the padding, the words, the buttons as the terminal draws them
133 * (`[ label ]`, the label in the style's form), and a gap between parts.
134 */
135function stripHeadColumnsOf(style: ViewStyle, parts: StripParts, detail: string | null, others: number, hasLive: boolean): number {
136 const workflow = parts.workflow
137 const slices = slicesDoneOf(parts.slices)
138 const labels = [...(hasLive ? ['live'] : []), 'dashboard', ...(others === 0 ? [] : [`⇄ ${others}`])]
139 const buttons = labels.map(label => controlLabel(style, label).length + 4)
140 const words = [1, workflow.slug.length, stageWordOf(workflow).length + 2, CELL_STAGES.length, slices.total === 0 ? 0 : sliceCountText(slices).length, detail?.length ?? 0, ...buttons].filter(width => width > 0)
141 return 2 + words.reduce((sum, width) => sum + width, 0) + (words.length - 1)
142}
143
144/**
145 * Rows the strip takes at a width: one, or two when the next command moves
146 * down. With no next command, the cost stays on the first row when it fits:
147 * a second row for the cost alone is a row of nothing.
148 */
149export function styledStripRows(style: ViewStyle, parts: StripParts, detail: string | null, others: number, columns: number, hasLive = true): number {
150 const hasNext = parts.workflow.nextInvocation !== null && !parts.workflow.terminal
151 if (columns >= STRIP_ONE_ROW_COLUMNS || (!hasNext && detail === null)) return 1
152 return !hasNext && stripHeadColumnsOf(style, parts, detail, others, hasLive) <= columns ? 1 : 2
153}
154
155/**
156 * What the strip's buttons do. The strip is where the person reaches the
157 * mod's own views: no slash command opens them.
158 */
159export type StripActions = {
160 /** The other workflows the rotate button walks; 0 hides the button. */
161 others: number
162 rotate: () => void
163 /** Opens the dashboard pane. */
164 dashboard: () => void
165 /** Opens the live view of the strip's workflow, or null when it has no run (or the live line shows it). */
166 live: (() => void) | null
167}
168
169/**
170 * The strip (E2), one row: the state mark, the workflow, the stage label, the
171 * stage cells, the slices done, the next command (the part that shrinks), the
172 * cost, then the buttons: live, dashboard, rotate. Below `STRIP_ONE_ROW_COLUMNS`
173 * the next command and the cost take a second row.
174 */
175export function stripStyledView(ui: Ui, style: ViewStyle, palette: Palette, parts: StripParts, detail: string | null, actions: StripActions, columns = 120): RenderElement {
176 const { Box, Text, Button } = ui
177 const workflow = parts.workflow
178 const tone = workflowTone(workflow)
179 const place = stagePlaceOf(stageAtOf(workflow), workflow.terminal)
180 const slices = slicesDoneOf(parts.slices)
181 const next = workflow.terminal ? null : workflow.nextInvocation
182 const isOneRow = styledStripRows(style, parts, detail, actions.others, columns, actions.live !== null) === 1
183 const tail: RenderElement[] = [
184 next === null ? (
185 <Box flexGrow={1} />
186 ) : (
187 <Box flexGrow={1} flexShrink={1} flexDirection="row" gap={1}>
188 <Text dimColor>{say(style, 'next')}</Text>
189 <Text wrap="truncate-end">{next}</Text>
190 </Box>
191 ),
192 detail === null ? null : <Text dimColor>{say(style, detail)}</Text>,
193 ].filter((part): part is RenderElement => part !== null)
194 const buttons: RenderElement[] = [
195 actions.live === null ? null : <Button key={LIVE_KEY} label={controlLabel(style, 'live')} onPress={actions.live} />,
196 <Button key={DASHBOARD_KEY} label={controlLabel(style, 'dashboard')} dimColor onPress={actions.dashboard} />,
197 actions.others === 0 ? null : <Button key={ROTATE_KEY} label={controlLabel(style, `⇄ ${actions.others}`)} dimColor onPress={actions.rotate} />,
198 ].filter((button): button is RenderElement => button !== null)
199 return (
200 <Box flexDirection="column" paddingX={1}>
201 <Box flexDirection="row" gap={1}>
202 {markView(ui, style, palette, tone)}
203 <Text bold wrap="truncate-end">
204 {say(style, workflow.slug)}
205 </Text>
206 {chipView(ui, style, palette, tone, stageWordOf(workflow))}
207 <Box flexDirection="row" flexShrink={0}>
208 {cellsView(ui, style, palette, place.done, place.at, CELL_STAGES.length)}
209 </Box>
210 {slices.total === 0 ? null : <Text dimColor>{sliceCountText(slices)}</Text>}
211 {isOneRow ? tail : <Box flexGrow={1} />}
212 {buttons}
213 </Box>
214 {isOneRow ? null : (
215 <Box flexDirection="row" gap={1} paddingLeft={2}>
216 {tail}
217 </Box>
218 )}
219 </Box>
220 )
221}
222
223// ---------------------------------------------------------------------------
224// E3 — the workflows dashboard
225// ---------------------------------------------------------------------------
226
227export type WorkflowsModel = {
228 workflows: readonly WorkflowEntry[]
229 slices: ReadonlyMap<string, readonly SliceEntry[]>
230 findings: ReadonlyMap<string, number>
231 shipPlanBlockers: number | null
232 hub: HubHealth | null
233 columns: number
234 details: boolean
235}
236
237export type WorkflowsActions = {
238 status: (slug: string) => void
239 pick: (slug: string) => void
240 details: () => void
241}
242
243export const WORKFLOW_STATUS_KEY = 'wf-dash-status:'
244export const WORKFLOW_PICK_KEY = 'wf-dash-pick:'
245export const WORKFLOW_DETAILS_KEY = 'wf-dash-details'
246
247function hubLine(hub: HubHealth | null): string {
248 if (hub === null) return 'hub unknown'
249 if (!hub.ok) return `hub ${hub.version ?? '?'} down`
250 return `hub ${hub.version ?? '?'} ok`
251}
252
253/** One cell of a row: a fixed-width Box, so a proportional font keeps the columns (F7). */
254function cell(ui: Ui, width: number, children: RenderElement | RenderElement[]): RenderElement {
255 const { Box } = ui
256 return (
257 <Box width={width} flexShrink={0} flexDirection="row">
258 {children}
259 </Box>
260 )
261}
262
263/**
264 * The workflows dashboard (E3): a header row with the counts and the details
265 * button, a column header, one row per workflow with fixed columns and its
266 * buttons pinned right, and a footer.
267 */
268export function workflowsStyledView(ui: Ui, style: ViewStyle, palette: Palette, model: WorkflowsModel, actions: WorkflowsActions): RenderElement {
269 const { Box, Text, Button } = ui
270 const shown = model.details ? model.workflows : model.workflows.filter(workflow => !workflow.terminal)
271 const open = model.workflows.filter(workflow => !workflow.terminal).length
272 const closed = model.workflows.length - open
273 const hidden = model.workflows.length - shown.length
274 const widths = { mark: 2, slug: Math.min(32, Math.max(10, ...model.workflows.map(workflow => workflow.slug.length)) + 2), stage: 14, slices: 9, findings: 12 }
275 const findingsText = (workflow: WorkflowEntry) => {
276 const count = model.findings.get(workflow.slug)
277 return count === undefined ? '—' : `${count} open`
278 }
279 const footer = [model.shipPlanBlockers === null ? null : `ship-plan blockers ${model.shipPlanBlockers}`, hubLine(model.hub)].filter(Boolean).join(' · ')
280 const detailsLabel = model.details ? 'details ▾' : hidden > 0 ? `details ▸ (${hidden} closed)` : 'details ▸'
281 return (
282 <Box flexDirection="column" paddingX={1} {...(palette.ground === undefined ? {} : { backgroundColor: palette.ground })}>
283 <Box flexDirection="row" gap={1}>
284 <Text bold>{say(style, 'workflows')}</Text>
285 <Text dimColor>{say(style, `${open} open · ${closed} closed`)}</Text>
286 <Box flexGrow={1} />
287 <Button key={WORKFLOW_DETAILS_KEY} hotkey="d" label={controlLabel(style, detailsLabel)} dimColor onPress={() => actions.details()} />
288 </Box>
289 <Box flexDirection="row">
290 {cell(ui, widths.mark, <Text> </Text>)}
291 {cell(ui, widths.slug, <Text dimColor>WORKFLOW</Text>)}
292 {cell(ui, widths.stage, <Text dimColor>STAGE</Text>)}
293 {cell(ui, widths.slices, <Text dimColor>SLICES</Text>)}
294 {cell(ui, widths.findings, <Text dimColor>FINDINGS</Text>)}
295 <Text dimColor>NEXT</Text>
296 </Box>
297 {shown.length === 0 ? <Text dimColor>{say(style, '(no workflows under .ai/workflows)')}</Text> : null}
298 {shown.map(workflow => {
299 const tone = workflowTone(workflow)
300 const roster = model.slices.get(workflow.slug) ?? []
301 const done = slicesDoneOf(roster)
302 return (
303 <Box key={`wf-row:${workflow.slug}`} flexDirection="row">
304 {cell(ui, widths.mark, markView(ui, style, palette, tone))}
305 {cell(
306 ui,
307 widths.slug,
308 <Text bold={!workflow.terminal} dimColor={workflow.terminal} wrap="truncate-end">
309 {say(style, workflow.slug)}
310 </Text>,
311 )}
312 {cell(ui, widths.stage, chipView(ui, style, palette, tone, stageWordOf(workflow)))}
313 {cell(ui, widths.slices, roster.length === 0 ? <Text dimColor>—</Text> : cellsView(ui, style, palette, done.done, -1, done.total))}
314 {cell(ui, widths.findings, <Text color={model.findings.has(workflow.slug) ? palette.tones.attention : palette.tones.quiet}>{say(style, findingsText(workflow))}</Text>)}
315 <Box flexGrow={1} flexShrink={1}>
316 <Text dimColor wrap="truncate-end">
317 {workflow.terminal ? '' : (workflow.nextInvocation ?? '')}
318 </Text>
319 </Box>
320 <Button key={`${WORKFLOW_STATUS_KEY}${workflow.slug}`} label={controlLabel(style, 'status')} dimColor onPress={() => actions.status(workflow.slug)} />
321 {workflow.terminal ? null : <Button key={`${WORKFLOW_PICK_KEY}${workflow.slug}`} label={controlLabel(style, 'pick')} variant="primary" onPress={() => actions.pick(workflow.slug)} />}
322 </Box>
323 )
324 })}
325 <Text dimColor wrap="truncate-end">
326 {say(style, footer)}
327 </Text>
328 </Box>
329 )
330}
331
332// ---------------------------------------------------------------------------
333// E4 — the hub notice
334// ---------------------------------------------------------------------------
335
336/** The hub line under the logo (E4): the state mark or tag, the hub, then the rest quiet. */
337export function noticeStyledView(ui: Pick<Ui, 'Box' | 'Text'>, style: ViewStyle, palette: Palette, engineText: string, hub: HubHealth | null, hubText: string): RenderElement {
338 const { Box, Text } = ui
339 const isUp = hub !== null && hub.ok
340 const tone: Tone = isUp ? 'done' : 'stop'
341 const [head = hubText, ...rest] = hubText.split(' · ')
342 // D and E draw ink colours: the hub row carries their plate, so it reads on a dark terminal too.
343 const plate = palette.card === undefined ? {} : { backgroundColor: palette.card, paddingX: 1 }
344 return (
345 <Box flexDirection="column">
346 {engineText === '' ? null : <Text dimColor>{engineText}</Text>}
347 <Box flexDirection="row" gap={1} {...plate}>
348 {style === 'grid' ? chipView(ui, style, palette, isUp ? 'done' : 'stop', isUp ? 'operational' : 'down') : markView(ui, style, palette, tone)}
349 <Text bold={style !== 'dashboard'}>{say(style, head)}</Text>
350 {rest.length === 0 ? null : (
351 <Text dimColor wrap="truncate-end">
352 {`· ${say(style, rest.join(' · '))}`}
353 </Text>
354 )}
355 </Box>
356 </Box>
357 )
358}
359hooks/mod/styles/skin.tsx 136 lines1/**
2 * The design system every view draws with (MOD-DESIGN, 2026-10-04): one
3 * layout, three skins.
4 *
5 * - The ink layer: every word gets a colour from its own ground, so no word
6 * falls back to a surface colour that the style's ground hides.
7 * - The skin parts: the stage label, the progress cells, the state mark, and
8 * the words' case. A style changes these, never the rows, the slots, the
9 * order or the hotkeys.
10 */
11import type { ElementTable, RenderElement } from 'claude-code'
12
13import { GRID_INK, GRID_LIME, INSTRUMENT_ORANGE } from './tokens.ts'
14import type { Palette, Tone, ViewStyle } from './tokens.ts'
15
16type TextProps = Parameters<ElementTable<'terminal'>['Text']>[0]
17type TextLike = (props: TextProps) => RenderElement
18
19/**
20 * The element table with the ink layer on its Text: a word with no colour
21 * takes the palette's text colour, a dim word the palette's quiet colour.
22 * Where the palette follows the surface (style A on the terminal) the table
23 * is returned as it is.
24 */
25export function inked<T extends { Text: TextLike }>(ui: T, palette: Palette): T {
26 if (palette.text === undefined) return ui
27 const Text = ui.Text
28 const ink = palette.text
29 const quiet = palette.tones.quiet ?? ink
30 const inkedText: TextLike = props => {
31 if (props.color !== undefined) {
32 if (props.dimColor !== true) return Text(props)
33 // A coloured word stays its colour: dimming it would cost contrast.
34 const { dimColor: _dim, ...rest } = props
35 return Text(rest)
36 }
37 const { dimColor, ...rest } = props
38 return Text({ ...rest, color: dimColor === true ? quiet : ink })
39 }
40 return { ...ui, Text: inkedText }
41}
42
43/** The words of a style: capitals for D and E. */
44export function say(style: ViewStyle, text: string): string {
45 return style === 'dashboard' ? text : text.toUpperCase()
46}
47
48/** A control's label: capitals for D, capitals and an arrow for E. */
49export function controlLabel(style: ViewStyle, text: string): string {
50 if (style === 'dashboard') return text
51 return style === 'grid' ? `${text.toUpperCase()} ↗` : text.toUpperCase()
52}
53
54/** The stages the progress cells stand for, in lifecycle order. */
55export const CELL_STAGES = ['intake', 'shape', 'slice', 'plan', 'implement', 'verify', 'review', 'ship'] as const
56
57/** Where a stage sits among the cells: the cells done before it, and its own cell (-1 when none runs). */
58export function stagePlaceOf(stage: string | null, isClosed: boolean): { done: number; at: number } {
59 if (isClosed) return { done: CELL_STAGES.length, at: -1 }
60 if (stage === null) return { done: 0, at: -1 }
61 const alias: Record<string, string> = { design: 'shape', handoff: 'ship', 'ship-plan': 'ship' }
62 if (stage === 'retro') return { done: CELL_STAGES.length, at: -1 }
63 const index = CELL_STAGES.indexOf((alias[stage] ?? stage) as (typeof CELL_STAGES)[number])
64 return index === -1 ? { done: 0, at: -1 } : { done: index, at: index }
65}
66
67/** The glyphs of the progress cells: done, running, waiting. */
68function cellGlyphs(style: ViewStyle): { done: string; at: string; wait: string } {
69 if (style === 'instrument') return { done: '■', at: '▣', wait: '□' }
70 if (style === 'grid') return { done: '█', at: '▓', wait: '░' }
71 return { done: '■', at: '■', wait: '□' }
72}
73
74/** Progress as cells: the done ones, the running one lit, the waiting ones quiet. */
75export function cellsView(ui: { Text: TextLike }, style: ViewStyle, palette: Palette, done: number, at: number, total: number): RenderElement[] {
76 const { Text } = ui
77 const glyphs = cellGlyphs(style)
78 const doneCount = Math.max(0, Math.min(total, done))
79 const running = at >= 0 && at < total ? 1 : 0
80 const waiting = Math.max(0, total - doneCount - running)
81 const runColour = style === 'instrument' ? INSTRUMENT_ORANGE : palette.tones.run
82 const out: RenderElement[] = []
83 if (doneCount > 0) out.push(<Text color={style === 'dashboard' ? palette.tones.done : palette.text}>{glyphs.done.repeat(doneCount)}</Text>)
84 if (running > 0) out.push(<Text color={runColour}>{glyphs.at}</Text>)
85 if (waiting > 0) out.push(<Text color={palette.tones.quiet} {...(palette.tones.quiet === undefined ? { dimColor: true } : {})}>{glyphs.wait.repeat(waiting)}</Text>)
86 return out
87}
88
89/** A state mark: a dot in A, a square in D and E, coloured by its tone. */
90export function markView(ui: { Text: TextLike }, style: ViewStyle, palette: Palette, tone: Tone): RenderElement {
91 const { Text } = ui
92 const glyph =
93 style === 'dashboard'
94 ? tone === 'attention' || tone === 'intent'
95 ? '◆'
96 : tone === 'quiet'
97 ? '○'
98 : tone === 'stop'
99 ? '■'
100 : '●'
101 : tone === 'quiet'
102 ? '□'
103 : '■'
104 const colour = style === 'grid' && tone === 'attention' ? palette.tones.attention : palette.tones[tone]
105 return <Text color={colour}>{glyph}</Text>
106}
107
108/**
109 * A stage label: bold capitals in the tone's colour (A), the same in brackets
110 * (D), or a solid block with light words, lime with ink for "needs you" (E).
111 */
112export function chipView(ui: { Text: TextLike }, style: ViewStyle, palette: Palette, tone: Tone, text: string): RenderElement {
113 const { Text } = ui
114 const words = text.toUpperCase()
115 if (style === 'grid') {
116 // "Needs you" is a black block with lime words: lime as a fill is 1.04:1 on the grey plate, and read as plain text.
117 if (tone === 'attention') return <Text bold color={GRID_LIME} backgroundColor={GRID_INK}>{` ${words} `}</Text>
118 if (tone === 'quiet') return <Text bold color={palette.tones.quiet}>{`[${words}]`}</Text>
119 return <Text bold color="#ffffff" backgroundColor={palette.tones[tone] ?? GRID_INK}>{` ${words} `}</Text>
120 }
121 if (style === 'instrument') return <Text bold color={palette.tones[tone]}>{`[${words}]`}</Text>
122 return (
123 <Text bold color={palette.tones[tone]}>
124 {words}
125 </Text>
126 )
127}
128
129/** The props of a framed card in a style: the ground, and the frame in the line colour. */
130export function frameProps(palette: Palette): Record<string, unknown> {
131 return {
132 ...(palette.card === undefined ? {} : { backgroundColor: palette.card }),
133 ...(palette.frame === 'none' ? {} : { borderStyle: palette.frame, ...(palette.line === undefined ? {} : { borderColor: palette.line }) }),
134 }
135}
136hooks/mod/styles/kit.tsx 154 lines1/**
2 * What every style shares (WF-LIVE-VIEWS-PLAN.md 8, K4): the element table a
3 * renderer draws with, the pane's view state, the actions, the element keys,
4 * and the band's live line, which keeps one layout in every style so the
5 * hotkeys stay in the same places (Q2).
6 */
7import type { ElementTable, RenderElement } from 'claude-code'
8
9import type { SdlcLiveBand, SdlcNeed, SdlcStageMark } from '../../../types'
10import type { ViewFacts } from './facts.ts'
11import { chipView, controlLabel, markView, say } from './skin.tsx'
12import type { Palette, Tone, ViewStyle } from './tokens.ts'
13
14type Terminal = ElementTable<'terminal'>
15type Desktop = ElementTable<'desktop'>
16
17/** The elements a renderer draws with; `Svg` and `Client` only where the surface has them. */
18export type StyleUi = Pick<Terminal, 'Box' | 'Text' | 'Button' | 'Markdown'> & { Svg?: Desktop['Svg']; Client?: Terminal['Client'] }
19
20/** The renderer's table from the surface's: the optional elements only where the surface carries them. */
21export function styleUiOf(table: unknown): StyleUi {
22 const t = table as Record<string, unknown>
23 const ui = { Box: t['Box'], Text: t['Text'], Button: t['Button'], Markdown: t['Markdown'] } as StyleUi
24 if (typeof t['Svg'] === 'function') ui.Svg = t['Svg'] as Desktop['Svg']
25 if (typeof t['Client'] === 'function') ui.Client = t['Client'] as Terminal['Client']
26 return ui
27}
28
29/** What a pane draws besides the facts: the surface, the time, the details state, the tabs. */
30export type PaneView = {
31 surface: string
32 now: number
33 columns: number
34 details: boolean
35 /** Sections a need opened (V8): drawn even with details off. */
36 opened: readonly string[]
37 tabs: ReadonlyArray<{ kind: string; slug: string; isCurrent: boolean }>
38 /** False hides the style button: a locked row (V6). */
39 hasStyleButton: boolean
40 style: ViewStyle
41 palette: Palette
42}
43
44export type LiveActions = {
45 control: (key: string) => void
46 need: (id: string, action: string) => void
47 details: () => void
48 style: () => void
49 tab: (kind: string, slug: string) => void
50}
51
52/** The element keys of the live pane: the tests press them, and every style uses the same ones (T5). */
53export const LIVE_KEYS = {
54 control: (key: string) => `live:control:${key}`,
55 need: (id: string, action: string) => `live:need:${id}:${action}`,
56 details: 'live:details',
57 style: 'live:style',
58 tab: (kind: string, slug: string) => `live:tab:${kind}:${slug}`,
59 band: (key: string) => `live:band:${key}`,
60 open: 'live:band:open',
61}
62
63/** The key of the Box that carries one fact (T5): every style wraps the same facts in the same keys. */
64export const factKey = (name: string) => `fact:${name}`
65
66/** The colour of a tone in a palette. */
67export function toneColor(palette: Palette, tone: Tone): string | undefined {
68 return palette.tones[tone]
69}
70
71/** The glyph of a stage mark, per style. */
72export function markGlyph(style: ViewStyle, mark: SdlcStageMark): string {
73 if (style === 'instrument') return mark === 'done' ? '■' : mark === 'run' ? '▣' : mark === 'stop' ? '▧' : '□'
74 if (style === 'grid') return mark === 'done' ? '██' : mark === 'run' ? '▓▓' : mark === 'stop' ? '╳╳' : '░░'
75 return mark === 'done' ? '●' : mark === 'run' ? '◉' : mark === 'stop' ? '✕' : '○'
76}
77
78/** The tone of a stage mark. */
79export function markTone(mark: SdlcStageMark): Tone {
80 return mark === 'done' ? 'done' : mark === 'run' ? 'run' : mark === 'stop' ? 'stop' : 'quiet'
81}
82
83/** The facts the pane keeps in every style, whatever the details state: the keys T5 compares. */
84export function coreFactNames(facts: ViewFacts): string[] {
85 return ['focus', 'liveness', ...facts.groups.map(group => `group:${group.key}`), ...facts.needs.map(need => `need:${need.id}`), ...facts.meters.map(meter => `meter:${meter.key}`), ...facts.controls.map(control => `control:${control.key}`)]
86}
87
88/** The actions of a need as Buttons; a link action is a Markdown link, which opens a file. */
89export function needActions(ui: StyleUi, need: SdlcNeed, act: LiveActions, options: { upper: boolean; arrow: boolean; primaryFirst: boolean }): RenderElement[] {
90 const { Button, Markdown } = ui
91 return need.actions.map((action, index) => {
92 const label = `${options.upper ? action.label.toUpperCase() : action.label}${options.arrow ? ' ↗' : ''}`
93 if (action.kind === 'link' && action.path !== undefined) {
94 return <Markdown text={`[${label}](${fileUrlOf(action.path)})`} dimColor />
95 }
96 return <Button key={LIVE_KEYS.need(need.id, action.key)} label={label} {...(options.primaryFirst && index === 0 ? { variant: 'primary' as const } : { dimColor: true })} onPress={() => act.need(need.id, action.key)} />
97 })
98}
99
100/** A `file:` URL for a path, as Markdown draws a link the surface opens. */
101export function fileUrlOf(path: string): string {
102 const slashed = path.replace(/\\/gu, '/')
103 const absolute = /^[A-Za-z]:/u.test(slashed) ? `/${slashed}` : slashed
104 return `file://${absolute.split('/').map(part => encodeURIComponent(part).replace(/%3A/gu, ':')).join('/')}`
105}
106
107/** The tab row of a pane that follows several drivers (O3). */
108export function tabRow(ui: StyleUi, view: PaneView, act: LiveActions, upper: boolean): RenderElement | null {
109 if (view.tabs.length < 2) return null
110 const { Box, Button } = ui
111 return (
112 <Box flexDirection="row" gap={1}>
113 {view.tabs.map(tab => (
114 <Button key={LIVE_KEYS.tab(tab.kind, tab.slug)} label={upper ? `${tab.kind} · ${tab.slug}`.toUpperCase() : `${tab.kind} · ${tab.slug}`} {...(tab.isCurrent ? {} : { dimColor: true })} onPress={() => act.tab(tab.kind, tab.slug)} />
115 ))}
116 </Box>
117 )
118}
119
120/** The pane's own buttons: details (hotkey d) and style (hotkey s, absent on a locked row). */
121export function paneButtons(ui: StyleUi, view: PaneView, act: LiveActions, labels: { details: string; style: string }): RenderElement[] {
122 const { Button } = ui
123 const out: RenderElement[] = [<Button key={LIVE_KEYS.details} hotkey="d" label={labels.details} dimColor onPress={() => act.details()} />]
124 if (view.hasStyleButton) out.push(<Button key={LIVE_KEYS.style} hotkey="s" label={labels.style} dimColor onPress={() => act.style()} />)
125 return out
126}
127
128/**
129 * The band's live line (K3), one row: the state mark, the driver's label, the
130 * workflow, what runs (the part that shrinks), then pinned right up to two
131 * actions, with no hotkeys: a bare digit in an empty prompt box presses a band
132 * button, and these write stop requests. One layout in every style; the style gives
133 * the colours, the marks and the case.
134 */
135export function liveBandView(ui: Pick<Terminal, 'Box' | 'Text' | 'Button'>, band: SdlcLiveBand, palette: Palette, open: () => void, press: (key: string) => void, style: ViewStyle = 'dashboard'): RenderElement {
136 const { Box, Text, Button } = ui
137 const title = `${band.kind} · ${band.slug} · `
138 const focus = band.focus.startsWith(title) ? band.focus.slice(title.length) : band.focus
139 return (
140 <Box flexDirection="row" gap={1} paddingX={1}>
141 {markView(ui, style, palette, band.tone)}
142 {chipView(ui, style, palette, band.tone === 'quiet' || band.tone === 'plain' ? 'run' : band.tone, band.kind)}
143 <Text bold>{say(style, band.slug)}</Text>
144 <Box flexGrow={1} flexShrink={1}>
145 <Text wrap="truncate-end">{say(style, focus)}</Text>
146 </Box>
147 {band.actions.map((action, index) => (
148 <Button key={LIVE_KEYS.band(action.key)} label={controlLabel(style, action.label)} {...(index === 0 ? { variant: 'primary' as const } : { dimColor: true })} onPress={() => press(action.key)} />
149 ))}
150 {band.isPaneSeated ? null : <Button key={LIVE_KEYS.open} label={controlLabel(style, 'live view')} dimColor onPress={() => open()} />}
151 </Box>
152 )
153}
154hooks/mod/styles/tokens.ts 153 lines1/**
2 * The three view styles and their colours (WF-LIVE-VIEWS-PLAN.md 8, 14.3).
3 *
4 * One setting, `viewStyle`, styles every view the plugin draws: the live
5 * pane, the picker band, the strip, the hub notice and the workflows
6 * dashboard (V3). A style changes how a view looks, never what it shows (L1).
7 */
8import type { SdlcTone, SdlcViewStyle } from '../../../types'
9
10export type ViewStyle = SdlcViewStyle
11export type Tone = SdlcTone
12
13export const VIEW_STYLES: readonly ViewStyle[] = ['dashboard', 'instrument', 'grid']
14export const DEFAULT_VIEW_STYLE: ViewStyle = 'dashboard'
15
16/** The style a stored value names; a value outside the options counts as unset (V1). */
17export function viewStyleOf(value: unknown): ViewStyle {
18 return typeof value === 'string' && (VIEW_STYLES as readonly string[]).includes(value) ? (value as ViewStyle) : DEFAULT_VIEW_STYLE
19}
20
21/** The style after this one, for the pane's style button (V4). */
22export function nextViewStyle(style: ViewStyle): ViewStyle {
23 const index = VIEW_STYLES.indexOf(style)
24 return VIEW_STYLES[(index + 1) % VIEW_STYLES.length] as ViewStyle
25}
26
27/**
28 * The colours of one style. `undefined` draws the surface's own colour.
29 *
30 * Style A on the terminal uses theme keys, so it follows the person's terminal
31 * theme (Y6). Everywhere else the styles follow a light ground (the Desktop
32 * app's, or their own light plate), and every colour passes WCAG AA (4.5:1)
33 * on that ground (MOD-DESIGN, 2026-10-04).
34 */
35export type Palette = {
36 /** The pane's own ground, or undefined to leave the surface's. */
37 ground: string | undefined
38 /** The ground of the band card, or undefined to leave the surface's. */
39 card: string | undefined
40 /** The colour of every word; undefined leaves the surface's (style A on the terminal). */
41 text: string | undefined
42 line: string | undefined
43 tones: Record<Tone, string | undefined>
44 /** The frame of the band card: none, a hairline, a plate's single line, or a heavy line. */
45 frame: 'none' | 'round' | 'single' | 'bold'
46 /** True for a style drawn light on a dark ground. */
47 isDark: boolean
48}
49
50const DASHBOARD: Palette = {
51 ground: undefined,
52 card: undefined,
53 text: undefined,
54 line: 'inactive',
55 tones: { done: 'success', run: 'suggestion', attention: 'warning', stop: 'error', intent: 'permission', quiet: 'inactive', plain: undefined },
56 frame: 'none',
57 isDark: true,
58}
59
60/** Style A on the Desktop app: no painted ground, a hairline frame, the app's ink, AA accents. */
61const DASHBOARD_LIGHT: Palette = {
62 ground: undefined,
63 card: undefined,
64 text: '#1f1e1b',
65 line: '#d9d6cc',
66 tones: { done: '#15803d', run: '#1d4ed8', attention: '#b45309', stop: '#b91c1c', intent: '#6d28d9', quiet: '#5f6672', plain: '#1f1e1b' },
67 frame: 'round',
68 isDark: false,
69}
70
71/** Style D: white plates, hairline frames, black ink, one orange signal. */
72const INSTRUMENT: Palette = {
73 ground: '#ffffff',
74 card: '#ffffff',
75 text: '#111111',
76 line: '#c8c8c8',
77 tones: { done: '#0f7b55', run: '#111111', attention: '#c2410c', stop: '#c2410c', intent: '#c2410c', quiet: '#666666', plain: '#111111' },
78 frame: 'single',
79 isDark: false,
80}
81
82/** Style E: a warm grey plate in a heavy black frame, purple for what runs, lime words on a black block for what needs you. */
83const GRID: Palette = {
84 ground: '#f4f4f2',
85 card: '#f4f4f2',
86 text: '#0d0d0d',
87 line: '#0d0d0d',
88 tones: { done: '#0d0d0d', run: '#5b3fd9', attention: '#4d6300', stop: '#c4271b', intent: '#5b3fd9', quiet: '#5f5f5a', plain: '#0d0d0d' },
89 frame: 'bold',
90 isDark: false,
91}
92
93/** The lime of style E, always words on a black block (lime as a fill does not stand out from the grey plate). */
94export const GRID_LIME = '#d7ff3a'
95export const GRID_PURPLE = '#5b3fd9'
96export const GRID_INK = '#0d0d0d'
97export const INSTRUMENT_ORANGE = '#c2410c'
98
99/**
100 * The palette of a style on a surface. Style A follows the terminal theme on
101 * the terminal and the light app elsewhere; D and E draw their light plates
102 * everywhere. `isDarkTheme` is kept for callers; no palette reads it now.
103 */
104export function paletteOf(style: ViewStyle, surface: string, _isDarkTheme: boolean): Palette {
105 if (style === 'instrument') return INSTRUMENT
106 if (style === 'grid') return GRID
107 return surface === 'terminal' ? DASHBOARD : DASHBOARD_LIGHT
108}
109
110/** Every palette a surface can draw, for the contrast test. */
111export const ALL_PALETTES: ReadonlyArray<{ name: string; palette: Palette }> = [
112 { name: 'dashboard light', palette: DASHBOARD_LIGHT },
113 { name: 'instrument', palette: INSTRUMENT },
114 { name: 'grid', palette: GRID },
115]
116
117/**
118 * The text colour of a style's own words: none where the style follows the
119 * surface (style A on the terminal), else the palette's text colour.
120 */
121export function inkOf(palette: Palette): { color?: string } {
122 return palette.text === undefined ? {} : { color: palette.text }
123}
124
125/** The colour of a style's quiet words: dim on the surface's colours, else the palette's quiet tone. */
126export function quietOf(palette: Palette): { color?: string; dimColor?: boolean } {
127 return palette.text === undefined ? { dimColor: true } : { color: palette.tones.quiet }
128}
129
130/** Rows the band card's frame takes: two with a frame, none without. */
131export function cardRowsOf(palette: Palette): number {
132 return palette.frame === 'none' ? 0 : 2
133}
134
135/** WCAG relative luminance of a `#rrggbb` colour. */
136function luminanceOf(hex: string): number {
137 const channels = [1, 3, 5].map(at => parseInt(hex.slice(at, at + 2), 16) / 255)
138 const [r, g, b] = channels.map(c => (c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4)) as [number, number, number]
139 return 0.2126 * r + 0.7152 * g + 0.0722 * b
140}
141
142/** The WCAG contrast ratio of two `#rrggbb` colours. */
143export function contrastOf(left: string, right: string): number {
144 const [high, low] = [luminanceOf(left), luminanceOf(right)].sort((x, y) => y - x) as [number, number]
145 return (high + 0.05) / (low + 0.05)
146}
147
148/** True when a theme row's value names a dark theme; an unknown value counts as dark, the terminal default. */
149export function isDarkThemeOf(value: unknown): boolean {
150 if (typeof value !== 'string') return true
151 return !/light/iu.test(value)
152}
153hooks/mod/live/register.ts 1225 lines1/**
2 * The live views of `/wf yolo`, `/wf campaign` and `/wf brainstorm`
3 * (WF-LIVE-VIEWS-PLAN.md): one pane, `wf-live`, that answers at a glance
4 * whether the run is alive, where it is, what it decided, what it needs from
5 * the person, and what it costs.
6 *
7 * The engine takes one hooks module per plugin (probe P10, 2026-10-03), so
8 * this file is not a second module: `hooks/mod/register.ts` calls
9 * `registerLive` from its own `register`. Its hooks stay apart: they hook the
10 * live pane, the open rules, the poll and the actions. The shared sites (the
11 * band, the status line, the spinner, the mode label) stay with
12 * `register.ts`; this module hands them its facts through `$.state` (K1–K3).
13 *
14 * M4: every hook body catches its own errors and passes the event on, so a
15 * fault here never costs the person the picker.
16 */
17import { atom, read, update } from 'claude-code'
18import type { EngineInterface, On, PluginOptions, RenderElement } from 'claude-code'
19
20import type { SdlcLiveAction, SdlcLiveBand, SdlcLiveStatus, SdlcLiveKind, SdlcLiveModel, SdlcLiveView, SdlcNeed } from '../../../types'
21import { frontmatterField, intentRecordsOf, STAGE_FILE, stageOf } from '../../../lib/live-events.mjs'
22import type { ArtifactFact, LiveEvent } from '../../../lib/live-events.mjs'
23import { EMPTY_LIVE_VIEW, detailsStoreKeyOf } from '../state.ts'
24import { bandFocusOf, factsOf, liveStatusOf } from '../styles/facts.ts'
25import { paneRendererOf } from '../styles/index.ts'
26import { styleUiOf } from '../styles/kit.tsx'
27import { inked } from '../styles/skin.tsx'
28import type { LiveActions, PaneView } from '../styles/kit.tsx'
29import { isDarkThemeOf, nextViewStyle, paletteOf, viewStyleOf } from '../styles/tokens.ts'
30import type { ViewStyle } from '../styles/tokens.ts'
31import { findProjectRoot, joinPath, listSlices } from '../workflows.ts'
32import { buildBrainstormModel, newestChangeOf } from './model/brainstorm.ts'
33import { buildCampaignModel, gatesAfter, heavyHolderOf, lockWaitsAfter } from './model/campaign.ts'
34import type { UnitDrive } from './model/campaign.ts'
35import { absorb, freshJournalState, judge, livenessFrom, openStagesOf } from './model/journal.ts'
36import type { JournalState } from './model/journal.ts'
37import type { RecordFact } from './model/needs.ts'
38import { budgetOf, usageOf } from './model/usage.ts'
39import type { Budget, RateLimitLike } from './model/usage.ts'
40import { buildYoloModel, controlOf } from './model/yolo.ts'
41import type { RosterFact } from './model/yolo.ts'
42import { LiveReader, hashOf } from './reader.ts'
43import type { LiveIo } from './reader.ts'
44
45export const LIVE_PANE = 'wf-live'
46
47/** R1: the poll while the pane shows, while it is hidden, and while no driver is live. */
48export const POLL_SHOWN_MS = 2_000
49export const POLL_HIDDEN_MS = 15_000
50export const POLL_IDLE_MS = 60_000
51/** The protected files and the commits are read every 20 s, and only while a run is live. */
52const SLOW_FACTS_MS = 20_000
53/** A protected file over this size is not hashed. */
54const PROTECTED_MAX_BYTES = 1024 * 1024
55/** A journal or board changed this recently is a driver to follow (O1). */
56const FRESH_MS = 3 * 60_000
57/** A run that ended keeps the band's live line this long after its last line; after it, the strip's `live` button opens it. */
58export const ENDED_BAND_MS = 30 * 60_000
59/** A run silent this long is no live run, whatever its state: no live line, no status part, the slow poll. */
60export const IDLE_BAND_MS = 2 * 60 * 60_000
61
62/**
63 * Whether a run is over for the band's live line, the status line and the
64 * poll. A run silent for `IDLE_BAND_MS` is over. Past `ENDED_BAND_MS` of
65 * silence: a yolo run that ended or stopped, a campaign with no wave running
66 * and no pause, a brainstorm that is done. The strip's `live` button still
67 * opens an over run's pane.
68 */
69export function isRunOver(model: SdlcLiveModel, now: number): boolean {
70 const last = model.liveness.lastLineAt
71 if (last === null) return false
72 const silent = now - last
73 if (silent > IDLE_BAND_MS) return true
74 if (silent <= ENDED_BAND_MS) return false
75 if (model.kind === 'yolo') return model.outcome === 'ended' || model.outcome === 'stopped' || model.liveness.state === 'ended'
76 if (model.kind === 'campaign') return model.activeWave === null && model.running === 0 && model.paused === null
77 return model.mode === 'done'
78}
79/** F8: a push-class toast stays 10 s, so a person who looked away can read it. */
80export const PUSH_TOAST_MS = 10_000
81
82type Kind = SdlcLiveKind
83
84/** What one followed driver keeps between polls. The module's own: a reload rebuilds it from the files. */
85type Tracker = {
86 kind: Kind
87 slug: string
88 journal: JournalState
89 watchStart: number
90 isPrimed: boolean
91 records: Map<string, RecordFact>
92 protectedBase: Map<string, string>
93 protectedChanged: Set<string>
94 protectedWatched: number
95 commits: { count: number | null; last: string | null }
96 slowAt: number
97 roster: RosterFact[]
98 rosterAt: number
99 gates: Record<string, string[]>
100 lockWaits: string[]
101 drives: Map<string, { journal: JournalState }>
102 boardAt: number | null
103 boardBeats: number[]
104 toasted: Set<string>
105 modelText: string
106}
107
108/**
109 * The link between the two halves (K1, K3). `register.ts` owns the band and
110 * the status line: it calls `press` and `open` from the band's live line, and
111 * this module calls `onStatus` with its part of the status line. The engine
112 * keeps no value a call that takes `on` returns, so the link is passed in and
113 * this module fills its half.
114 */
115export type LiveLink = {
116 press: (key: string) => void
117 open: () => Promise<void>
118 /** The strip's `live` button: follows the run of `slug` and opens its pane. */
119 follow: (slug: string) => Promise<void>
120 /** The kind of run `slug` has on disk (a driver journal, a campaign ledger, a board), or null. */
121 kindOf: (slug: string) => Promise<Kind | null>
122 onStatus: (text: string | null) => void
123 /** Called from `register.ts`'s `session.measure` hook (U1). */
124 measure: (rateLimits: readonly unknown[]) => void
125 /** Writes one `draw` row to the probe journal; `register.ts` fills it. */
126 note: (ok: boolean, detail: string) => void
127 /**
128 * Called from `register.ts`'s `turn.start` hook with the turn's `/wf` command
129 * (the typed line, or the dispatcher's run when the text is the expanded skill):
130 * a yolo, campaign or brainstorm turn follows its run (O1).
131 */
132 started: (key: string, slug: string | null) => Promise<void>
133}
134
135export type LiveContext = {
136 options: PluginOptions
137 pluginName: string
138 link: LiveLink
139}
140
141/*
142 * The `$.state` atoms this file reads and writes (14.5). Each carries a shape tag (Z2): bump it
143 * when the code's idea of the value changes, so a reload does not read the old form.
144 */
145const liveStatusAtom = atom({ plugin: 'sdlc-workflow', key: 'liveStatus' } as const, null, { shape: 'live-status-1' })
146const liveBandAtom = atom({ plugin: 'sdlc-workflow', key: 'liveBand' } as const, null, { shape: 'live-band-1' })
147const liveViewAtom = atom({ plugin: 'sdlc-workflow', key: 'liveView' } as const, EMPTY_LIVE_VIEW, { shape: 'live-view-1' })
148const liveModelsAtom = atom({ plugin: 'sdlc-workflow', key: 'liveModels' } as const, {}, { shape: 'live-models-1' })
149
150const keyOf = (kind: string, slug: string) => `${kind}:${slug}`
151
152function messageOf(error: unknown): string {
153 return error instanceof Error ? error.message : String(error)
154}
155
156function newTracker(kind: Kind, slug: string, now: number): Tracker {
157 return {
158 kind,
159 slug,
160 journal: freshJournalState(),
161 watchStart: now,
162 isPrimed: false,
163 records: new Map(),
164 protectedBase: new Map(),
165 protectedChanged: new Set(),
166 protectedWatched: 0,
167 commits: { count: null, last: null },
168 slowAt: 0,
169 roster: [],
170 rosterAt: -1,
171 gates: {},
172 lockWaits: [],
173 drives: new Map(),
174 boardAt: null,
175 boardBeats: [],
176 toasted: new Set(),
177 modelText: '',
178 }
179}
180
181/** The kind of live view a `/wf` command starts, or null. */
182export function liveKindOf(key: string): Kind | null {
183 if (key === 'yolo' || key === 'campaign' || key === 'brainstorm') return key
184 return null
185}
186
187/** The push-class toast of a shared event, or null (commentary plan C5; K5). */
188export function toastTextOf(event: LiveEvent): string | null {
189 const where = [event['stage'], event['slice']].filter(Boolean).join(' ')
190 switch (event.event) {
191 case 'decision':
192 return `wf yolo ${event.slug}: ${where} needs you · ${(event['reasons'] as string[] | undefined)?.join(', ') ?? 'decision'}`
193 case 'stop':
194 return `wf yolo ${event.slug}: stopped at ${where} (${String(event['kind'] ?? 'stop')})`
195 case 'stale':
196 return `wf yolo ${event.slug}: no journal line for ${String(event['silentMinutes'] ?? '?')} min`
197 case 'run-end':
198 return `wf yolo ${event.slug}: the run ended${event['stoppedAt'] ? ` at ${String(event['stoppedAt'])}` : ''}`
199 default:
200 return null
201 }
202}
203
204/** The campaign journal lines that raise a toast (K5). */
205const CAMPAIGN_TOASTS: Record<string, (line: Record<string, unknown>, slug: string) => string> = {
206 'wave-ready': (line, slug) => `wf campaign ${slug}: wave ${String(line['wave'] ?? '?')} is ready to try`,
207 asked: (line, slug) => `wf campaign ${slug}: a question needs you · ${String(line['text'] ?? '')}`.trim(),
208 paused: (line, slug) => `wf campaign ${slug}: paused until ${String(line['until'] ?? '?').slice(11, 16)}`,
209 'wave-end': (line, slug) => `wf campaign ${slug}: wave ${String(line['wave'] ?? '?')} shipped`,
210 'campaign-end': (_line, slug) => `wf campaign ${slug}: the campaign ended`,
211}
212
213/*
214 * The module's own variables. `register` runs again on a reload, and `registerLive` sets them
215 * afresh; the engine lets `$` reach only functions declared at the top of the file, so the
216 * helpers below read these instead of closing over a `registerLive` scope.
217 */
218let style: ViewStyle = 'dashboard'
219let isPaneOn = true
220let detailsDefault = false
221let pluginName = 'sdlc-workflow'
222let onStatus: (text: string | null) => void = () => undefined
223let engine: LiveEngine | null = null
224let reader: LiveReader | null = null
225let root: string | null = null
226let home: string | null = null
227let budget: Budget = budgetOf(null)
228let rateLimits: RateLimitLike[] | null = null
229let isDarkTheme = true
230let isStyleLocked = false
231let timer: { cancel: () => void } | null = null
232let pollMs = 0
233let polling: Promise<void> = Promise.resolve()
234let lastScanAt = 0
235const trackers = new Map<string, Tracker>()
236/** One log line per hook fault (M4). */
237const faulted = new Set<string>()
238
239/**
240 * Every `$.noun.method` the live helpers call, bound once per hook. The engine lets `$` reach no
241 * variable and no other file, so a hook builds this from its own `$` and the helpers take it.
242 */
243export type LiveEngine = {
244 readView: () => Promise<SdlcLiveView>
245 updateView: (change: (value: SdlcLiveView | undefined) => SdlcLiveView) => Promise<SdlcLiveView>
246 readModels: () => Promise<Record<string, SdlcLiveModel>>
247 updateModels: (change: (value: Record<string, SdlcLiveModel> | undefined) => Record<string, SdlcLiveModel>) => Promise<unknown>
248 updateStatus: (change: (value: SdlcLiveStatus | null | undefined) => SdlcLiveStatus | null) => Promise<unknown>
249 updateBand: (change: (value: SdlcLiveBand | null | undefined) => SdlcLiveBand | null) => Promise<unknown>
250 exists: (path: string) => Promise<boolean>
251 stat: (path: string) => Promise<{ size: number; mtimeMs: number }>
252 read: (path: string) => Promise<string>
253 write: (path: string, text: string) => Promise<void>
254 list: (path: string) => Promise<Array<{ name: string; kind: 'file' | 'dir' | 'other'; mtimeMs?: number | undefined }>>
255 run: (argv: string[], options: { timeoutMs: number }) => Promise<{ exitCode: number | null; stdout: string }>
256 now: () => Promise<number>
257 every: (ms: number, fn: () => void) => { cancel: () => void }
258 toast: (text: string, timeoutMs: number) => void
259 open: (id: string, title: string) => Promise<unknown>
260 /** The plugin's own open panes, as the engine records them: a pane the person closed is not listed. */
261 panes: () => Promise<ReadonlyArray<{ id: string; isPlaced: boolean }>>
262 log: (text: string) => void
263 submit: (text: string) => Promise<unknown>
264 fill: (text: string) => Promise<unknown>
265 configSet: (key: string, value: string) => Promise<unknown>
266 configList: () => Promise<ReadonlyArray<{ key: string; value?: unknown; isLocked?: boolean | undefined }>>
267 storeGet: (key: string) => Promise<unknown>
268 storeSet: (key: string, value: unknown) => Promise<void>
269 usage: () => Promise<{ rateLimits?: readonly unknown[] | null | undefined }>
270 /** The person's home directory, from USERPROFILE then HOME. */
271 home: () => Promise<string | undefined>
272}
273
274function liveEngineOf($: EngineInterface): LiveEngine {
275 return {
276 readView: () => read($, liveViewAtom),
277 updateView: change => update($, liveViewAtom, change),
278 readModels: () => read($, liveModelsAtom),
279 updateModels: change => update($, liveModelsAtom, change),
280 updateStatus: change => update($, liveStatusAtom, change),
281 updateBand: change => update($, liveBandAtom, change),
282 exists: path => $.fs.exists(path),
283 stat: path => $.fs.stat(path),
284 read: path => $.fs.read(path),
285 write: (path, text) => $.fs.write(path, text),
286 list: async path => [...(await $.fs.list(path))],
287 run: (argv, options) => $.process.run(argv, options),
288 now: () => $.clock.now(),
289 every: (ms, fn) => $.clock.every(ms, fn),
290 toast: (text, timeoutMs) => $.ui.toast(text, { timeoutMs }),
291 open: (id, title) => $.ui.open({ id, title }),
292 panes: () => $.ui.panes(),
293 log: text => $.ui.log(text),
294 submit: text => $.prompt.submit({ text, asUser: true }),
295 fill: text => $.prompt.fill({ text }),
296 configSet: (key, value) => $.config.set({ key, value }),
297 configList: () => $.config.list(),
298 storeGet: key => $.store.get(key),
299 storeSet: (key, value) => $.store.set(key, value),
300 usage: () => $.session.usage(),
301 home: async () => (await $.env.get('USERPROFILE')) ?? (await $.env.get('HOME')),
302 }
303}
304
305function fault(where: string, error: unknown): void {
306 if (faulted.has(where)) return
307 faulted.add(where)
308 try {
309 engine?.log(`sdlc-workflow live view: ${where}: ${messageOf(error)}`)
310 } catch {
311 // No log: the fault stays silent.
312 }
313}
314
315function ioOf(x: LiveEngine): LiveIo {
316 return {
317 stat: async path => {
318 try {
319 if (!(await x.exists(path))) return null
320 const stat = await x.stat(path)
321 return { size: stat.size, mtimeMs: stat.mtimeMs }
322 } catch {
323 return null
324 }
325 },
326 read: async path => {
327 try {
328 return await x.read(path)
329 } catch {
330 return null
331 }
332 },
333 readFrom: async (path, offset, max) => {
334 // A child reads the bytes after the offset: `$.fs.read` has no offset and stops at 4 MiB.
335 const script =
336 'const fs=require("fs");const[p,o,m]=process.argv.slice(1);const fd=fs.openSync(p,"r");const size=fs.fstatSync(fd).size;const start=Math.min(+o,size);const n=Math.min(size-start,+m);const b=Buffer.alloc(n);fs.readSync(fd,b,0,n,start);const cut=b.lastIndexOf(10);process.stdout.write(cut<0?"":b.subarray(0,cut+1));'
337 try {
338 const result = await x.run(['node', '-e', script, path, String(offset), String(max)], { timeoutMs: 10_000 })
339 return result.exitCode === 0 ? result.stdout : null
340 } catch {
341 return null
342 }
343 },
344 list: async path => {
345 try {
346 return (await x.list(path)).map(entry => ({ name: entry.name, kind: entry.kind, mtimeMs: entry.mtimeMs ?? 0 }))
347 } catch {
348 return []
349 }
350 },
351 }
352}
353
354async function git(x: LiveEngine, args: string[]): Promise<string | null> {
355 if (root === null) return null
356 try {
357 const result = await x.run(['git', '-C', root, ...args], { timeoutMs: 5_000 })
358 return result.exitCode === 0 ? result.stdout : null
359 } catch {
360 return null
361 }
362}
363
364// -------------------------------------------------------------------------
365// The view state
366// -------------------------------------------------------------------------
367
368async function viewOf(x: LiveEngine): Promise<SdlcLiveView> {
369 try {
370 return await x.readView()
371 } catch {
372 return EMPTY_LIVE_VIEW
373 }
374}
375
376async function setView(x: LiveEngine, change: (view: SdlcLiveView) => SdlcLiveView): Promise<SdlcLiveView> {
377 return x.updateView(view => change(view ?? EMPTY_LIVE_VIEW))
378}
379
380async function detailsOf(x: LiveEngine, kind: Kind): Promise<boolean> {
381 try {
382 const stored = await x.storeGet(detailsStoreKeyOf(kind))
383 return typeof stored === 'boolean' ? stored : detailsDefault
384 } catch {
385 return detailsDefault
386 }
387}
388
389/** Follows a driver: a tab in the pane (O3), and the pane opened when the rules say so (O1, O2, O5). */
390async function follow(x: LiveEngine, kind: Kind, slug: string, isAsked: boolean): Promise<void> {
391 const now = await x.now()
392 const key = keyOf(kind, slug)
393 if (!trackers.has(key)) {
394 // A run followed again (its tab closed when another run started) reads its journal from the start.
395 if (root !== null) reader?.forget(joinPath(root, '.ai', 'workflows', slug))
396 trackers.set(key, newTracker(kind, slug, now))
397 }
398 const details = await detailsOf(x, kind)
399 const models = await x.readModels().catch(() => ({}) as Record<string, SdlcLiveModel>)
400 await setView(x, view => {
401 // O4: a new driver closes the tabs of runs that ended.
402 const kept = view.tabs.filter(tab => {
403 if (tab.kind === kind && tab.slug === slug) return true
404 const model = models[keyOf(tab.kind, tab.slug)]
405 return !(model?.kind === 'yolo' && (model.outcome === 'ended' || model.outcome === 'stopped'))
406 })
407 const tabs = kept.some(tab => tab.kind === kind && tab.slug === slug) ? kept : [...kept, { kind, slug }]
408 return { ...view, tabs, current: { kind, slug }, details: { ...view.details, [kind]: details }, isAsked: view.isAsked || isAsked }
409 })
410 const kept = await viewOf(x)
411 for (const stale of [...trackers.keys()]) if (!kept.tabs.some(tab => keyOf(tab.kind, tab.slug) === stale)) trackers.delete(stale)
412 await pollAll(x)
413 // Q3: a brainstorm opens by itself in scope mode and after done; while exploring, on request.
414 const model = (await x.readModels().catch(() => ({}) as Record<string, SdlcLiveModel>))[key]
415 const opensItself = kind !== 'brainstorm' || (model?.kind === 'brainstorm' && model.mode !== 'explore')
416 if (isAsked || opensItself) await openPane(x, kind, slug, isAsked)
417 schedule(x)
418}
419
420/**
421 * Opens the live pane. Resolves to null when a surface draws it, else to why
422 * it does not draw: the pane option is off, the engine refused it, or the pane
423 * waits (`isPlaced: false`, with the engine's reason).
424 */
425async function openPane(x: LiveEngine, kind: Kind, slug: string, isAsked: boolean): Promise<string | null> {
426 if (!isPaneOn) return 'the liveView option is off'
427 let isPlaced = true
428 let why: string | null = null
429 try {
430 const result = (await x.open(LIVE_PANE, `${kind} · ${slug}`)) as { isPlaced?: boolean; reason?: string } | null | undefined
431 isPlaced = result === undefined || result === null || result.isPlaced !== false
432 if (!isPlaced) why = result?.reason ?? 'no surface here places it'
433 } catch (error) {
434 fault('open', error)
435 isPlaced = false
436 why = messageOf(error)
437 }
438 await setView(x, view => ({ ...view, isOpen: true, isAsked: view.isAsked || isAsked }))
439 await writeBand(x, isPlaced)
440 return why
441}
442
443/** What the strip's `live` button reports when the pane does not draw: the reason, as a toast. */
444function unopenedTextOf(kind: Kind, slug: string, why: string): string {
445 return `The live view of ${kind} ${slug} follows the run, but its pane is not drawn: ${why}.`
446}
447
448/** The kind of run a workflow has on disk, or null: a campaign ledger, a brainstorm board, a driver journal. */
449async function runKindOf(slug: string): Promise<Kind | null> {
450 if (root === null || reader === null) return null
451 const base = joinPath(root, '.ai', 'workflows', slug)
452 if ((await reader.mtime(joinPath(base, 'work', 'campaign', 'ledger.json'))) != null) return 'campaign'
453 if ((await reader.mtime(joinPath(base, 'brainstorm-board.json'))) != null) return 'brainstorm'
454 if ((await reader.mtime(joinPath(base, '.driver-journal.jsonl'))) != null) return 'yolo'
455 return null
456}
457
458/**
459 * The strip's `live` button: follows the run of `slug` (a tab it already has,
460 * else the kind its files name) and opens the pane. Resolves to null when the
461 * pane draws, else to the text that says why it does not.
462 */
463async function openLive(x: LiveEngine, slug: string): Promise<string | null> {
464 const view = await viewOf(x)
465 const tab = view.tabs.find(entry => entry.slug === slug) ?? null
466 const kind = tab?.kind ?? (await runKindOf(slug))
467 if (kind === null) return `${slug} has no yolo, campaign or brainstorm run.`
468 await follow(x, kind, slug, true)
469 const why = await openPane(x, kind, slug, true)
470 return why === null ? null : unopenedTextOf(kind, slug, why)
471}
472
473// -------------------------------------------------------------------------
474// The poll (R1–R3)
475// -------------------------------------------------------------------------
476
477function schedule(x: LiveEngine): void {
478 void Promise.all([viewOf(x), x.readModels().catch(() => ({}) as Record<string, SdlcLiveModel>), x.now()]).then(([view, models, now]) => {
479 // A run is live while it is not over and, for yolo, has not ended: brainstorm and campaign carry no outcome.
480 const anyLive = [...trackers.values()].some(tracker => {
481 const model = models[keyOf(tracker.kind, tracker.slug)]
482 if (model === undefined || isRunOver(model, now)) return false
483 return !(model.kind === 'yolo' && (model.outcome === 'ended' || model.outcome === 'stopped'))
484 })
485 const want = trackers.size === 0 || !anyLive ? POLL_IDLE_MS : view.isOpen ? POLL_SHOWN_MS : POLL_HIDDEN_MS
486 if (timer !== null && want === pollMs) return
487 timer?.cancel()
488 pollMs = want
489 timer = x.every(want, () => {
490 polling = polling.then(() => pollAll(x)).catch(error => fault('poll', error))
491 })
492 })
493}
494
495async function pollAll(x: LiveEngine): Promise<void> {
496 if (reader === null || root === null) return
497 const now = await x.now()
498 if (now - lastScanAt >= POLL_IDLE_MS || trackers.size === 0) {
499 lastScanAt = now
500 await scan(x, now)
501 }
502 for (const tracker of trackers.values()) {
503 try {
504 if (tracker.kind === 'yolo') await pollYolo(x, tracker, now)
505 else if (tracker.kind === 'campaign') await pollCampaign(x, tracker, now)
506 else await pollBrainstorm(x, tracker, now)
507 } catch (error) {
508 fault(`poll ${tracker.kind}`, error)
509 }
510 }
511 await writeShared(x)
512 schedule(x)
513}
514
515/** O1, second half: a journal or a board that changed in the last minutes is a driver to follow. */
516async function scan(x: LiveEngine, now: number): Promise<void> {
517 if (reader === null || root === null) return
518 const dir = joinPath(root, '.ai', 'workflows')
519 const campaignSlugs = new Set<string>()
520 for (const tracker of trackers.values()) if (tracker.kind === 'campaign') for (const slug of tracker.drives.keys()) campaignSlugs.add(slug)
521 for (const entry of await reader.list(dir)) {
522 if (entry.kind !== 'dir') continue
523 const slug = entry.name
524 const journal = await reader.mtime(joinPath(dir, slug, '.driver-journal.jsonl'))
525 if (journal !== null && now - journal < FRESH_MS && !campaignSlugs.has(slug) && !trackers.has(keyOf('yolo', slug))) {
526 const ledger = await reader.mtime(joinPath(dir, slug, 'work', 'campaign', 'ledger.json'))
527 if (ledger === null) await follow(x, 'yolo', slug, false)
528 }
529 const campaign = await reader.mtime(joinPath(dir, slug, 'work', 'campaign', '.campaign-journal.jsonl'))
530 if (campaign !== null && now - campaign < FRESH_MS && !trackers.has(keyOf('campaign', slug))) await follow(x, 'campaign', slug, false)
531 const board = await reader.mtime(joinPath(dir, slug, 'brainstorm-board.json'))
532 if (board !== null && now - board < FRESH_MS && !trackers.has(keyOf('brainstorm', slug))) await follow(x, 'brainstorm', slug, false)
533 }
534}
535
536function toast(x: LiveEngine, tracker: Tracker, id: string, text: string | null): void {
537 if (text === null || tracker.toasted.has(id) || !tracker.isPrimed) {
538 if (text !== null) tracker.toasted.add(id)
539 return
540 }
541 tracker.toasted.add(id)
542 try {
543 x.toast(text, PUSH_TOAST_MS)
544 } catch (error) {
545 fault('toast', error)
546 }
547}
548
549/** The stage artifact a stage end wrote: `<prefix>-<slice>.md` first, then `<prefix>.md`. */
550async function artifactFor(slugDir: string, stage: string, slice: string | null): Promise<{ path: string; rel: string } | null> {
551 if (reader === null || root === null) return null
552 const prefix = STAGE_FILE[stage]
553 if (prefix === undefined) return null
554 for (const name of slice === null ? [`${prefix}.md`] : [`${prefix}-${slice}.md`, `${prefix}.md`]) {
555 const path = joinPath(slugDir, name)
556 const mtime = await reader.mtime(path)
557 if (mtime !== null) return { path, rel: path.slice(root.length + 1).replace(/\\/gu, '/') }
558 }
559 return null
560}
561
562/** Reads the artifacts the new lines' stage ends name, so the shared rules can judge them (M2). */
563async function readArtifacts(slug: string, lines: ReadonlyArray<Record<string, unknown>>, tracker: Tracker): Promise<Map<string, ArtifactFact>> {
564 const out = new Map<string, ArtifactFact>()
565 if (reader === null || root === null) return out
566 const slugDir = joinPath(root, '.ai', 'workflows', slug)
567 for (const line of lines) {
568 if (line['event'] !== 'agent-end') continue
569 const { stage, slice } = stageOf(line)
570 if (stage === null) continue
571 const found = await artifactFor(slugDir, stage, slice)
572 if (found === null) continue
573 const text = (await reader.changed(found.path)).text ?? ''
574 const yaml = (await reader.changed(found.path.replace(/\.md$/u, '.yaml'))).text ?? ''
575 const intents = intentRecordsOf(text, yaml)
576 const status = frontmatterField(text, 'status')
577 const signal = intents.length > 0 || status === 'awaiting-input' ? { reasons: [...(status === 'awaiting-input' ? ['awaiting-input'] : []), ...(intents.length > 0 ? ['intent-bearing'] : [])], intentBearing: intents.length } : null
578 const mtime = (await reader.mtime(found.path)) ?? 0
579 out.set(`${stage}:${slice ?? ''}`, { rel: found.rel, status, signal, mtime })
580 if (signal !== null) tracker.records.set(found.rel, { rel: found.rel, path: found.path, stage: stage === 'update-deps-exec' ? 'verify' : stage, slice, status, intents })
581 else tracker.records.delete(found.rel)
582 }
583 return out
584}
585
586async function pollYolo(x: LiveEngine, tracker: Tracker, now: number): Promise<void> {
587 if (reader === null || root === null) return
588 const slugDir = joinPath(root, '.ai', 'workflows', tracker.slug)
589 const { lines, reset } = await reader.tail(joinPath(slugDir, '.driver-journal.jsonl'))
590 if (reset) tracker.journal = freshJournalState()
591 let events: LiveEvent[] = []
592 if (lines.length > 0) {
593 const artifacts = await readArtifacts(tracker.slug, lines, tracker)
594 const result = absorb(tracker.slug, tracker.journal, lines, (stage, slice) => artifacts.get(`${stage}:${slice ?? ''}`) ?? null)
595 tracker.journal = result.state
596 events = result.events
597 }
598 const judged = judge(tracker.slug, tracker.journal, now, tracker.watchStart)
599 tracker.journal = judged.state
600 for (const event of [...events, ...judged.events]) toast(x, tracker, `${event.event}:${String(event['run'])}:${String(event['at'])}:${String(event['stage'] ?? '')}`, toastTextOf(event))
601
602 if (lines.length > 0 || tracker.rosterAt < 0) {
603 tracker.rosterAt = now
604 const slices = await listSlices(root, tracker.slug, { list: path => x.list(path), read: path => x.read(path), exists: path => x.exists(path) }).catch(() => [])
605 const roster: RosterFact[] = []
606 for (const slice of slices) {
607 const reviewed = (await reader.mtime(joinPath(slugDir, `07-review-${slice.slug}.md`))) !== null
608 roster.push({ slug: slice.slug, stage: slice.stage, reviewed })
609 }
610 tracker.roster = roster
611 }
612
613 const isLive = livenessFrom(tracker.journal, now).state !== 'ended'
614 if (isLive && now - tracker.slowAt >= SLOW_FACTS_MS) {
615 tracker.slowAt = now
616 await readSlowFacts(x, tracker, now)
617 }
618
619 const control = controlOf((await reader.changed(joinPath(slugDir, '.control.json'))).text)
620 const steerMtime = await reader.mtime(joinPath(slugDir, 'steer.md'))
621 const view = await viewOf(x)
622 const model = buildYoloModel({
623 slug: tracker.slug,
624 root,
625 now,
626 journal: tracker.journal,
627 roster: tracker.roster,
628 records: [...tracker.records.values()],
629 control,
630 protectedWatched: tracker.protectedWatched,
631 protectedChanged: [...tracker.protectedChanged],
632 commits: tracker.commits,
633 steerMtime,
634 usage: usageOf(rateLimits, budget),
635 dismissed: view.dismissed,
636 })
637 for (const need of model.needs) if (need.kind === 'protected') toast(x, tracker, `protected:${need.id}`, `wf yolo ${tracker.slug}: ${need.title}`)
638 tracker.isPrimed = true
639 await writeModel(x, tracker, model)
640}
641
642/** The protected files (hash, files ≤ 1 MiB) and the commits of the run (P8: through `$.process`). */
643async function readSlowFacts(x: LiveEngine, tracker: Tracker, now: number): Promise<void> {
644 if (reader === null || root === null) return
645 const config = (await reader.changed(joinPath(root, '.ai', 'sdlc-config.json'))).text
646 budget = budgetOf(config)
647 if (tracker.protectedBase.size === 0) {
648 const files = new Set(['PRODUCT.md', 'DESIGN.md'])
649 try {
650 const own = (JSON.parse(config ?? '{}') as { yolo?: { protectedFiles?: unknown } }).yolo?.protectedFiles
651 if (Array.isArray(own)) for (const file of own) if (typeof file === 'string' && file !== '') files.add(file)
652 } catch {
653 // A config that does not parse names no more files.
654 }
655 const dirty = await git(x, ['status', '--porcelain', '--untracked-files=no'])
656 for (const line of (dirty ?? '').split(/\r?\n/u)) {
657 const file = line.slice(3).trim().replace(/^"|"$/gu, '')
658 if (file !== '' && !file.startsWith('.ai/') && !file.startsWith('.scratch/')) files.add(file.includes(' -> ') ? (file.split(' -> ').pop() as string) : file)
659 }
660 for (const file of files) {
661 const path = joinPath(root, file)
662 const stat = await ioOf(x).stat(path)
663 if (stat === null || stat.size > PROTECTED_MAX_BYTES) continue
664 const text = await ioOf(x).read(path)
665 if (text !== null) tracker.protectedBase.set(file, hashOf(text))
666 }
667 tracker.protectedWatched = tracker.protectedBase.size
668 } else {
669 for (const [file, base] of tracker.protectedBase) {
670 const text = await ioOf(x).read(joinPath(root, file))
671 if (text !== null && hashOf(text) !== base) tracker.protectedChanged.add(file)
672 }
673 }
674 const since = new Date(tracker.journal.run.lastLineAt === null ? tracker.watchStart : Math.min(tracker.watchStart, firstLineOf(tracker.journal) ?? now)).toISOString()
675 const log = await git(x, ['log', `--since=${since}`, '--format=%s'])
676 if (log !== null) {
677 const subjects = log.split(/\r?\n/u).filter(line => line.trim() !== '')
678 tracker.commits = { count: subjects.length, last: subjects[0] ?? null }
679 }
680}
681
682function firstLineOf(journal: JournalState): number | null {
683 return journal.beats[0] ?? null
684}
685
686async function pollCampaign(x: LiveEngine, tracker: Tracker, now: number): Promise<void> {
687 if (reader === null || root === null) return
688 const campDir = joinPath(root, '.ai', 'workflows', tracker.slug, 'work', 'campaign')
689 const ledgerText = (await reader.changed(joinPath(campDir, 'ledger.json'))).text
690 let ledger: Record<string, unknown> | null = null
691 try {
692 ledger = ledgerText === null ? null : (JSON.parse(ledgerText) as Record<string, unknown>)
693 } catch {
694 ledger = null
695 }
696 const { lines, reset } = await reader.tail(joinPath(campDir, '.campaign-journal.jsonl'))
697 if (reset) {
698 tracker.journal = freshJournalState()
699 tracker.gates = {}
700 tracker.lockWaits = []
701 }
702 if (lines.length > 0) {
703 tracker.journal = absorb(tracker.slug, tracker.journal, lines).state
704 tracker.gates = gatesAfter(tracker.gates, lines)
705 tracker.lockWaits = lockWaitsAfter(tracker.lockWaits, lines)
706 for (const line of lines) {
707 const text = CAMPAIGN_TOASTS[String(line['event'] ?? '')]?.(line, tracker.slug) ?? null
708 toast(x, tracker, `campaign:${String(line['event'])}:${String(line['at'])}`, text)
709 }
710 }
711 // The drives: each running unit's driver journal, in the main checkout or its worktree.
712 const units = (ledger?.['units'] ?? {}) as Record<string, { slug?: unknown; state?: unknown }>
713 const drives: Record<string, UnitDrive> = {}
714 let lastLineAt = tracker.journal.run.lastLineAt
715 const beats = [...tracker.journal.beats]
716 for (const unit of Object.values(units)) {
717 const slug = typeof unit.slug === 'string' ? unit.slug : null
718 if (slug === null) continue
719 const drive = tracker.drives.get(slug) ?? { journal: freshJournalState() }
720 tracker.drives.set(slug, drive)
721 if (unit.state !== 'running') continue
722 const runId = typeof ledger?.['run-id'] === 'string' ? (ledger['run-id'] as string) : 'run'
723 const candidates = [joinPath(root, '.ai', 'workflows', slug, '.driver-journal.jsonl'), joinPath(root, '.scratch', 'campaign', runId, 'wt', slug, '.ai', 'workflows', slug, '.driver-journal.jsonl')]
724 for (const path of candidates) {
725 const tail = await reader.tail(path)
726 if (!tail.exists) continue
727 if (tail.lines.length > 0) drive.journal = absorb(slug, drive.journal, tail.lines).state
728 break
729 }
730 const open = openStagesOf(drive.journal)[0]
731 drives[slug] = { stage: open?.stage ?? drive.journal.lastStage?.stage ?? null, startedAt: open?.at ?? null, status: drive.journal.lastStage?.status ?? null }
732 if (drive.journal.run.lastLineAt !== null && (lastLineAt === null || drive.journal.run.lastLineAt > lastLineAt)) lastLineAt = drive.journal.run.lastLineAt
733 beats.push(...drive.journal.beats)
734 }
735 const liveness = livenessFrom({ ...tracker.journal, run: { ...tracker.journal.run, lastLineAt }, beats: beats.sort((a, b) => a - b).slice(-40) }, now)
736 const heavy = heavyHolderOf((await reader.changed(joinPath(root, '.scratch', 'campaign', 'heavy.lock'))).text)
737 const control = controlOf((await reader.changed(joinPath(campDir, '.control.json'))).text)
738 const view = await viewOf(x)
739 const model = buildCampaignModel({
740 slug: tracker.slug,
741 now,
742 ledger,
743 liveness,
744 gates: tracker.gates,
745 drives,
746 heavy: { holder: heavy, waiting: tracker.lockWaits.filter(slug => slug !== heavy) },
747 usage: usageOf((await guardUsage(x)) ?? rateLimits, budget),
748 control,
749 dismissed: view.dismissed,
750 })
751 for (const need of model.needs) if (need.kind === 'prepare') toast(x, tracker, `prepare:${need.id}`, `wf campaign ${tracker.slug}: ${need.title.toLowerCase()} · ${need.body}`)
752 tracker.isPrimed = true
753 await writeModel(x, tracker, model)
754}
755
756/** U2: the newest reading the usage guard wrote to `~/.claude/sdlc/usage/`, or null. */
757async function guardUsage(x: LiveEngine): Promise<RateLimitLike[] | null> {
758 if (reader === null || home === null) return null
759 const dir = joinPath(home, '.claude', 'sdlc', 'usage')
760 const files = (await reader.list(dir)).filter(entry => entry.kind === 'file' && entry.name.endsWith('.json'))
761 const newest = files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]
762 if (newest === undefined) return null
763 const text = (await reader.changed(joinPath(dir, newest.name))).text
764 try {
765 const value = JSON.parse(text ?? '') as { rateLimits?: unknown }
766 return Array.isArray(value.rateLimits) ? (value.rateLimits as RateLimitLike[]) : null
767 } catch {
768 return null
769 }
770}
771
772async function pollBrainstorm(x: LiveEngine, tracker: Tracker, now: number): Promise<void> {
773 if (reader === null || root === null) return
774 const dir = joinPath(root, '.ai', 'workflows', tracker.slug)
775 const boardPath = joinPath(dir, 'brainstorm-board.json')
776 const boardRead = await reader.changed(boardPath)
777 let board: Record<string, unknown> | null = null
778 try {
779 board = boardRead.text === null ? null : (JSON.parse(boardRead.text) as Record<string, unknown>)
780 } catch {
781 board = null
782 }
783 const boardAt = await reader.mtime(boardPath)
784 if (boardRead.changed && boardAt !== null) tracker.boardBeats = [...tracker.boardBeats, boardAt].slice(-40)
785 tracker.boardAt = boardAt
786 const count = async (sub: string, filter: (name: string) => boolean) => (await reader?.list(joinPath(dir, sub)) ?? []).filter(entry => filter(entry.name)).length
787 const sources = {
788 research: await count('research', name => /^R\d+/u.test(name)),
789 references: await count('references', name => name !== 'index.md'),
790 work: await count('work', name => name.endsWith('.md') && name !== 'index.md' && name !== 'changes.md'),
791 }
792 const index = (await reader.changed(joinPath(dir, 'work', 'index.md'))).text
793 const revision = Number(frontmatterField(index ?? '', 'work-revision'))
794 const change = newestChangeOf((await reader.changed(joinPath(dir, 'work', 'changes.md'))).text)
795 // A brainstorm is a conversation: its pulse is the board's writes. No driver runs that could die, so a
796 // long silence is the person thinking: quiet, never stale (red, "past its limit").
797 const judged = livenessFrom({ ...freshJournalState(), run: { ...freshJournalState().run, lastLineAt: boardAt, run: tracker.slug }, beats: tracker.boardBeats, lastLine: boardAt === null ? null : 'board written' }, now)
798 const liveness = judged.state === 'stale' ? { ...judged, state: 'quiet' as const } : judged
799 const view = await viewOf(x)
800 const model = buildBrainstormModel({ slug: tracker.slug, board, liveness, sources, revision: Number.isFinite(revision) && index !== null ? revision : null, change, dismissed: view.dismissed })
801 for (const need of model.needs) toast(x, tracker, `split:${need.id}`, `wf brainstorm ${tracker.slug}: ${need.title} · ${need.body}`)
802 const wasExploring = tracker.modelText === '' || /"mode":"explore"/u.test(tracker.modelText)
803 tracker.isPrimed = true
804 await writeModel(x, tracker, model)
805 // Q3: the scope walk and done open the pane by themselves.
806 if (wasExploring && model.mode !== 'explore' && !view.isOpen) await openPane(x, 'brainstorm', tracker.slug, false)
807}
808
809/** R3: the model is written only when it changed; the write redraws the pane. V8: a new need opens its section. */
810async function writeModel(x: LiveEngine, tracker: Tracker, model: SdlcLiveModel): Promise<void> {
811 const text = JSON.stringify(model)
812 if (text === tracker.modelText) return
813 tracker.modelText = text
814 const key = keyOf(tracker.kind, tracker.slug)
815 await x.updateModels(models => ({ ...(models ?? {}), [key]: model }))
816 const needs = new Map(model.needs.map(need => [need.id, need]))
817 await setView(x, view => {
818 // The ids are `<kind>:<slug>|<need id>`: a section stays open while its need is on the list.
819 const opened: Record<string, string> = {}
820 for (const [id, section] of Object.entries(view.opened)) {
821 if (!id.startsWith(`${key}|`) || needs.has(id.slice(key.length + 1))) opened[id] = section
822 }
823 for (const need of needs.values()) opened[`${key}|${need.id}`] ??= need.section
824 return { ...view, opened }
825 })
826}
827
828/** K1, K3: the status-line part and the band's live line, from the tab on screen. */
829async function writeShared(x: LiveEngine): Promise<void> {
830 const view = await viewOf(x)
831 const current = view.current
832 const models = await x.readModels().catch(() => ({}) as Record<string, SdlcLiveModel>)
833 const model = current === null ? undefined : models[keyOf(current.kind, current.slug)]
834 if (model === undefined) {
835 await x.updateStatus(() => null)
836 await x.updateBand(() => null)
837 onStatus(null)
838 return
839 }
840 const now = await x.now()
841 const facts = factsOf(model, now)
842 const isOver = facts.liveness.state === 'ended' || facts.liveness.state === 'none' || isRunOver(model, now)
843 const text = isOver ? null : liveStatusOf(facts)
844 await x.updateStatus(previous => (text === null ? null : previous?.text === text ? previous : { text }))
845 onStatus(text)
846 await writeBand(x, null)
847}
848
849/**
850 * Whether the live pane is open and placed, from the engine's record: a pane
851 * the person closed, or one a restart did not bring back, is not seated, so
852 * the live line draws its `live view` button again. Null when the engine does
853 * not answer. With the liveView option off no pane opens, and the button stays
854 * hidden.
855 */
856async function paneSeatedOf(x: LiveEngine): Promise<boolean | null> {
857 if (!isPaneOn) return true
858 try {
859 return (await x.panes()).some(pane => pane.id === LIVE_PANE && pane.isPlaced)
860 } catch {
861 return null
862 }
863}
864
865async function writeBand(x: LiveEngine, isPlaced: boolean | null): Promise<void> {
866 const view = await viewOf(x)
867 const current = view.current
868 const models = await x.readModels().catch(() => ({}) as Record<string, SdlcLiveModel>)
869 const model = current === null ? undefined : models[keyOf(current.kind, current.slug)]
870 if (model === undefined || current === null) {
871 await x.updateBand(() => null)
872 return
873 }
874 const now = await x.now()
875 const facts = factsOf(model, now)
876 // A run that is over is no live run: its line would stay above the prompt for days.
877 if (isRunOver(model, now)) {
878 await x.updateBand(() => null)
879 return
880 }
881 const isSeated = isPlaced ?? (await paneSeatedOf(x))
882 const actions: SdlcLiveAction[] = facts.controls.slice(0, 2).map((control, index) => ({ key: control.key, label: control.armed ? control.armedLabel : control.label, armed: control.armed }))
883 const tone = facts.needs.length > 0 ? (facts.needs[0] as SdlcNeed).tone : facts.liveness.tone === 'stop' ? 'stop' : facts.focus.tone
884 await x.updateBand(previous => {
885 const band: SdlcLiveBand = { kind: current.kind, slug: current.slug, focus: bandFocusOf(facts), tone, actions, isPaneSeated: isSeated ?? previous?.isPaneSeated ?? !isPaneOn }
886 return JSON.stringify(previous) === JSON.stringify(band) ? (previous as SdlcLiveBand) : band
887 })
888}
889
890// -------------------------------------------------------------------------
891// Actions (section 6)
892// -------------------------------------------------------------------------
893
894/** The control file of the tab on screen: the slug's for yolo, the campaign's for a campaign. */
895function controlPathOf(kind: Kind, slug: string): string | null {
896 if (root === null) return null
897 if (kind === 'campaign') return joinPath(root, '.ai', 'workflows', slug, 'work', 'campaign', '.control.json')
898 if (kind === 'yolo') return joinPath(root, '.ai', 'workflows', slug, '.control.json')
899 return null
900}
901
902/**
903 * A3: the whole file in one write, then a read-back. The mod is a second
904 * writer of `.control.json` beside the main session: it marks its own
905 * requests with `by: "live-view"`, and clears only those.
906 */
907async function writeControl(x: LiveEngine, path: string, value: Record<string, unknown>): Promise<boolean> {
908 const text = `${JSON.stringify(value, null, 2)}\n`
909 try {
910 await x.write(path, text)
911 const back = await x.read(path)
912 if (back === text) return true
913 } catch (error) {
914 fault('control write', error)
915 }
916 x.toast('wf live view: the stop request was not written', PUSH_TOAST_MS)
917 return false
918}
919
920async function pressControl(x: LiveEngine, key: string): Promise<void> {
921 const view = await viewOf(x)
922 const current = view.current
923 if (current === null) return
924 const models = await x.readModels()
925 const model = models[keyOf(current.kind, current.slug)]
926 if (model === undefined) return
927 const now = await x.now()
928 const requestedAt = new Date(now).toISOString()
929 const path = controlPathOf(current.kind, current.slug)
930 if (model.kind === 'yolo' && path !== null && (key === 'stop-stage' || key === 'stop-verify')) {
931 const after = key === 'stop-verify' ? 'verify' : (model.focus.stage ?? 'current')
932 // A2: a second press of an armed button removes the request, while no agent acted on it.
933 if (model.stop !== null && model.stop.after === after) {
934 if (model.stop.by === 'live-view' && model.outcome === 'running') await writeControl(x, path, { action: 'none', clearedBy: 'live-view', at: requestedAt })
935 } else {
936 await writeControl(x, path, { action: 'stop', after, by: 'live-view', requestedAt, at: requestedAt })
937 }
938 } else if (model.kind === 'campaign' && path !== null && key === 'stop-wave') {
939 if (model.stop !== null) {
940 if (model.stop.by === 'live-view') await writeControl(x, path, { action: 'none', clearedBy: 'live-view', at: requestedAt })
941 } else {
942 await writeControl(x, path, { action: 'stop', scope: 'wave', after: 'wave', by: 'live-view', requestedAt, at: requestedAt })
943 }
944 } else if (model.kind === 'campaign' && path !== null && key === 'resume') {
945 if (await writeControl(x, path, { action: 'resume', by: 'live-view', requestedAt })) await submit(x, `/wf campaign ${model.slug}`)
946 } else if (model.kind === 'campaign' && key === 'prepare-next') {
947 const need = model.needs.find(entry => entry.kind === 'prepare')
948 const prompt = need?.actions.find(action => action.key === 'prepare')?.prompt
949 if (prompt !== undefined) await submit(x, prompt)
950 }
951 const tracker = trackers.get(keyOf(current.kind, current.slug))
952 if (tracker !== undefined) tracker.modelText = ''
953 await pollAll(x)
954}
955
956async function submit(x: LiveEngine, text: string): Promise<void> {
957 try {
958 await x.submit(text)
959 } catch (error) {
960 fault('submit', error)
961 }
962}
963
964/**
965 * A need's `fill` action: the command goes into the prompt box. Where the
966 * surface draws its own box (the Desktop app answers `no_composer`), a
967 * complete command is sent as the prompt; one that waits for the person's
968 * words (it ends in a space or a colon) cannot be, and a toast names it to
969 * type. Any other refusal (a busy box) also names it in a toast.
970 */
971export async function fillOrSend(x: LiveEngine, text: string): Promise<void> {
972 let result: { isFilled?: boolean; refusal?: string } | null = null
973 try {
974 result = (await x.fill(text)) as { isFilled?: boolean; refusal?: string } | null
975 } catch (error) {
976 fault('fill', error)
977 }
978 if (result?.isFilled !== false) return
979 const command = text.trimEnd()
980 const waitsForWords = /[\s:]$/u.test(text)
981 if (result.refusal === 'no_composer' && !waitsForWords) {
982 await submit(x, command)
983 return
984 }
985 x.toast(`Type this in the prompt box${waitsForWords ? ', then your words' : ''}: ${command}`, PUSH_TOAST_MS)
986}
987
988async function needAction(x: LiveEngine, id: string, actionKey: string): Promise<void> {
989 const view = await viewOf(x)
990 const current = view.current
991 if (current === null) return
992 const model = (await x.readModels())[keyOf(current.kind, current.slug)]
993 const need = model?.needs.find(entry => entry.id === id)
994 const action = need?.actions.find(entry => entry.key === actionKey)
995 if (need === undefined || action === undefined) return
996 if (action.kind === 'prompt' && action.prompt !== undefined) await submit(x, action.prompt)
997 if (action.kind === 'fill' && action.prompt !== undefined) await fillOrSend(x, action.prompt)
998 // Keep, later and dismiss take the need off the list; so does a confirm, which the main session records.
999 if (action.kind === 'dismiss' || actionKey === 'confirm') {
1000 await setView(x, state => ({ ...state, dismissed: [...new Set([...state.dismissed, id])].slice(-200) }))
1001 const tracker = trackers.get(keyOf(current.kind, current.slug))
1002 if (tracker !== undefined) tracker.modelText = ''
1003 await pollAll(x)
1004 }
1005}
1006
1007async function toggleDetails(x: LiveEngine): Promise<void> {
1008 const view = await viewOf(x)
1009 const kind = view.current?.kind
1010 if (kind === undefined) return
1011 const next = !(view.details[kind] ?? detailsDefault)
1012 await setView(x, state => ({ ...state, details: { ...state.details, [kind]: next } }))
1013 try {
1014 await x.storeSet(detailsStoreKeyOf(kind), next)
1015 } catch (error) {
1016 fault('details store', error)
1017 }
1018}
1019
1020async function cycleStyle(x: LiveEngine): Promise<void> {
1021 if (isStyleLocked) return
1022 try {
1023 await x.configSet(`${pluginName}.viewStyle`, nextViewStyle(style))
1024 } catch (error) {
1025 fault('style', error)
1026 }
1027}
1028
1029async function selectTab(x: LiveEngine, kind: string, slug: string): Promise<void> {
1030 const live = kind === 'yolo' || kind === 'campaign' || kind === 'brainstorm' ? kind : null
1031 if (live === null) return
1032 await setView(x, view => ({ ...view, current: { kind: live, slug } }))
1033 await writeShared(x)
1034}
1035
1036function actionsOf(x: LiveEngine): LiveActions {
1037 const run = (where: string, work: () => Promise<void>) => {
1038 void work().catch(error => fault(where, error))
1039 }
1040 return {
1041 control: key => run('control', () => pressControl(x, key)),
1042 need: (id, action) => run('need', () => needAction(x, id, action)),
1043 details: () => run('details', () => toggleDetails(x)),
1044 style: () => run('style', () => cycleStyle(x)),
1045 tab: (kind, slug) => run('tab', () => selectTab(x, kind, slug)),
1046 }
1047}
1048
1049/** The live views start after the engine (`session.start` in `register.ts`): one hook per event per plugin. */
1050async function startLive(x: LiveEngine, cwd: string): Promise<void> {
1051 try {
1052 engine = x
1053 reader = new LiveReader(ioOf(x))
1054 trackers.clear()
1055 faulted.clear()
1056 timer?.cancel()
1057 timer = null
1058 pollMs = 0
1059 lastScanAt = 0
1060 root = await findProjectRoot(cwd, { list: path => x.list(path), read: path => x.read(path), exists: path => x.exists(path) })
1061 const profile = await x.home()
1062 home = profile === undefined || profile === '' ? null : profile
1063 try {
1064 const rows = await x.configList()
1065 isDarkTheme = isDarkThemeOf(rows.find(row => row.key === 'theme')?.value)
1066 isStyleLocked = rows.find(row => row.key === `${pluginName}.viewStyle`)?.isLocked === true
1067 } catch {
1068 // No config rows: the defaults stand.
1069 }
1070 try {
1071 rateLimits = ((await x.usage()).rateLimits ?? null) as RateLimitLike[] | null
1072 } catch {
1073 rateLimits = null
1074 }
1075 // V2: a reload keeps the view in `$.state`; the trackers re-read the files from the start.
1076 const view = await viewOf(x)
1077 for (const tab of view.tabs) trackers.set(keyOf(tab.kind, tab.slug), newTracker(tab.kind, tab.slug, await x.now()))
1078 for (const tracker of trackers.values()) tracker.isPrimed = false
1079 if (root !== null) await pollAll(x)
1080 schedule(x)
1081 } catch (error) {
1082 fault('session.start', error)
1083 }
1084}
1085
1086/** O1: a `/wf yolo`, `/wf campaign` or `/wf brainstorm` turn follows its run (`turn.start` in `register.ts`). */
1087async function liveTurnStarted(x: LiveEngine, key: string, slug: string | null): Promise<void> {
1088 try {
1089 const kind = liveKindOf(key)
1090 if (kind !== null && slug !== null && root !== null) await follow(x, kind, slug, false)
1091 } catch (error) {
1092 fault('turn.start', error)
1093 }
1094}
1095
1096export function registerLive(on: On, ctx: LiveContext): void {
1097 style = viewStyleOf(ctx.options['viewStyle'])
1098 isPaneOn = ctx.options['liveView'] !== false
1099 detailsDefault = ctx.options['liveViewDetails'] === true
1100 pluginName = ctx.pluginName
1101 onStatus = ctx.link.onStatus
1102 engine = null
1103
1104 // -------------------------------------------------------------------------
1105 // Hooks
1106 // -------------------------------------------------------------------------
1107
1108 // `$` never crosses an import, so this event is hooked here, with a matcher: the engine allows
1109 // one hook per event per plugin without one, and `register.ts` holds that one.
1110 on('session.start', { cwd: /./u }, async ($, e, next) => {
1111 const result = await next(e)
1112 // The live views start after the engine: they read the tree and follow a run a reload left open.
1113 await startLive(liveEngineOf($), e.cwd)
1114 return result
1115 })
1116
1117 // A `/wf yolo|campaign|brainstorm` turn: `register.ts` resolves the command (the typed line, or the
1118 // dispatcher's run when the turn text is the expanded skill) and passes it here.
1119 ctx.link.started = async (key, slug) => {
1120 const x = engine
1121 if (x !== null) await liveTurnStarted(x, key, slug)
1122 }
1123
1124 ctx.link.measure = limits => {
1125 // U1: the engine pushes a new value when a window moves; no poll for usage.
1126 rateLimits = limits as RateLimitLike[]
1127 }
1128
1129 on('ui.render', { component: 'Pane', requestId: LIVE_PANE }, async ($, e, next) => {
1130 try {
1131 const view = await read($, liveViewAtom)
1132 const models = await read($, liveModelsAtom)
1133 const current = view.current
1134 const model = current === null ? undefined : models[keyOf(current.kind, current.slug)]
1135 const table = $.ui.resolve(e)
1136 const ui = inked(styleUiOf(table), paletteOf(style, e.surface, isDarkTheme))
1137 if (model === undefined || current === null) {
1138 noteDraw(true, `live pane · ${e.surface} · no model${current === null ? '' : ` for ${current.kind} ${current.slug}`}`)
1139 const { Box, Text } = ui
1140 // D and E draw ink colours: the words carry their style's ground, so they read on a dark terminal too.
1141 const ground = paletteOf(style, e.surface, isDarkTheme).ground
1142 return Box({
1143 ...(ground === undefined ? {} : { backgroundColor: ground, paddingX: 1 }),
1144 children: [Text({ dimColor: true, children: 'No live run yet. It opens when a yolo, campaign or brainstorm starts.' })],
1145 }) as RenderElement
1146 }
1147 const now = await $.clock.now()
1148 const facts = factsOf(model, now)
1149 const key = keyOf(current.kind, current.slug)
1150 const opened = Object.entries(view.opened)
1151 .filter(([id]) => id.startsWith(`${key}|`))
1152 .map(([, section]) => section)
1153 const paneView: PaneView = {
1154 surface: e.surface,
1155 now,
1156 columns: e.props.bodyColumns,
1157 details: view.details[current.kind] ?? detailsDefault,
1158 opened,
1159 tabs: view.tabs.map(tab => ({ kind: tab.kind, slug: tab.slug, isCurrent: tab.kind === current.kind && tab.slug === current.slug })),
1160 hasStyleButton: !isStyleLocked,
1161 style,
1162 palette: paletteOf(style, e.surface, isDarkTheme),
1163 }
1164 const tree = paneRendererOf(style)(ui, facts, paneView, actionsOf(liveEngineOf($)))
1165 noteDraw(true, `live pane · ${e.surface} · ${current.kind} ${current.slug} · ${style}`)
1166 return tree
1167 } catch (error) {
1168 fault('ui.render', error)
1169 noteDraw(false, `live pane · ${e.surface} · ${messageOf(error)}`)
1170 return next(e)
1171 }
1172 })
1173
1174 on('ui.close', { id: LIVE_PANE }, async ($, e, next) => {
1175 const result = await next(e)
1176 try {
1177 const x = liveEngineOf($)
1178 await setView(x, view => ({ ...view, isOpen: false, isAsked: false }))
1179 // The live line draws its `live view` button again: the person closed the pane.
1180 await writeBand(x, false)
1181 schedule(x)
1182 } catch (error) {
1183 fault('ui.close', error)
1184 }
1185 return result
1186 })
1187
1188 /** One row per distinct outcome a session: the pane redraws every poll. */
1189 const noted = new Set<string>()
1190 const noteDraw = (ok: boolean, detail: string): void => {
1191 if (noted.has(detail)) return
1192 noted.add(detail)
1193 ctx.link.note(ok, detail)
1194 }
1195
1196 ctx.link.press = key => {
1197 const x = engine
1198 if (x === null) return
1199 void pressControl(x, key).catch(error => fault('band', error))
1200 }hooks/mod/state.ts 19 lines1/**
2 * The empty values of the mod's `$.state` (WF-LIVE-VIEWS-PLAN.md 14.5),
3 * declared in `types/index.d.ts`. The atoms themselves live in the file that
4 * reads or writes them: the engine's scan accepts only an atom written in a
5 * const of the same file. A drawing reads them with `read`, which subscribes
6 * it; a handler, an event or a timer writes them with `update`. Never written
7 * while drawing (Z1).
8 */
9import type { SdlcDashboard, SdlcLiveView, SdlcPicker, SdlcWorkflows } from '../../types'
10
11export const EMPTY_PICKER: SdlcPicker = { step: null, page: 0, filter: '', ring: null }
12export const EMPTY_WORKFLOWS: SdlcWorkflows = { root: null, isRead: false, entries: [], slices: {}, reads: 0 }
13export const EMPTY_DASHBOARD: SdlcDashboard = { isOpen: false, details: false }
14export const EMPTY_LIVE_VIEW: SdlcLiveView = { current: null, tabs: [], dismissed: [], details: {}, opened: {}, isOpen: false, isAsked: false }
15
16
17/** The store key of a view's details state (V7, Y5): `yolo`, `campaign`, `brainstorm` or `workflows`. */
18export const detailsStoreKeyOf = (kind: string) => `live-view.details.${kind}`
19hooks/mod/views.tsx 159 lines1/* @jsxRuntime classic */
2/* @jsx h */
3/* @jsxFrag Fragment */
4import type { ElementTable, RenderElement } from 'claude-code'
5
6import { BACK_KEY, CLOSE_KEY, DRAFT_HINT_TEXT, FILTER_KEY, HINT_TEXT, MORE_KEY, NOTHING_TEXT, NO_MATCH_TEXT, OPTION_KEY_PREFIX } from './names.ts'
7import { MAX_PAGE_SIZE, hotkeyOf } from './picker.ts'
8import type { Option, Page } from './picker.ts'
9import { pickerControlLabel, pickerPageText, pickerTitleView, rowPartsOf } from './styles/existing.tsx'
10import { frameProps, say } from './styles/skin.tsx'
11import { DEFAULT_VIEW_STYLE, paletteOf } from './styles/tokens.ts'
12import type { Palette, ViewStyle } from './styles/tokens.ts'
13
14/** The element constructors the band draws with; the terminal carries all four. */
15export type Ui = Pick<ElementTable<'terminal'>, 'Box' | 'Text' | 'Button' | 'Input'>
16
17export type BandModel = {
18 title: string
19 /** The rows on screen; each carries the hotkey of its position. */
20 page: Page
21 /** The filter text drawn in the field. */
22 filter: string
23 /** A line drawn dim under the list, in place of the key hint. */
24 note?: string
25 /** True when a step lies before this one, so the band draws `back`. */
26 hasBack: boolean
27 /** The view style (E1): the words' case, the marks and the control labels; keys, hotkeys and order never change (Y3). */
28 style?: ViewStyle
29 palette?: Palette
30 /** A row's name in its style; absent, the option's own name. */
31 labelOf?: (option: Option) => string
32 /** What a row draws beside its name: a state mark before it, a stage label and a note after it. */
33 rowOf?: (option: Option) => { mark?: RenderElement; chip?: RenderElement; note?: string }
34 /**
35 * True when the picker follows the prompt box: the box keeps the keys, so
36 * the band draws no field of its own (an autofocused one would take them)
37 * and shows the word being typed instead.
38 */
39 isDraft?: boolean
40}
41
42export type BandActions = {
43 pick: (value: string) => void
44 more: () => void
45 close: () => void
46 back: () => void
47 filter: (text: string) => void
48 submit: (text: string) => void
49}
50
51/** The element key of the row for one option value. */
52export function rowKeyOf(value: string): string {
53 return `${OPTION_KEY_PREFIX}${value}`
54}
55
56/**
57 * The picker's band, as a table (MOD-DESIGN 3.3):
58 *
59 * - a header: the breadcrumb of the command so far, the filter (a field, or
60 * the word being typed when the picker follows the box), then pinned right
61 * the page count, `back` past the first step, and `close`;
62 * - one row per option, in fixed columns: the state mark, the hotkey and the
63 * name (one plain `Button`, the clickable part, in a fixed-width column),
64 * the stage label, and the quiet note that ends in an ellipsis;
65 * - a footer: the hint, and `0 more` pinned right when the rows page.
66 *
67 * A row's hotkey is its digit, so a page holds nine rows at most.
68 */
69export function bandView(ui: Ui, model: BandModel, actions: BandActions): RenderElement {
70 const { Box, Text, Button, Input } = ui
71 const { items, page, pages } = model.page
72 const style = model.style ?? DEFAULT_VIEW_STYLE
73 const palette = model.palette ?? paletteOf(style, 'terminal', true)
74 const empty = model.filter.trim() === '' ? NOTHING_TEXT : NO_MATCH_TEXT
75 const label = (text: string) => pickerControlLabel(style, text)
76 const nameOf = (option: Option) => rowPartsOf(model.labelOf === undefined ? option.label : model.labelOf(option))
77 const nameWidth = Math.min(34, Math.max(8, ...items.map(option => nameOf(option).name.length + 6)))
78 const pageText = pickerPageText(page, pages)
79 return (
80 <Box flexDirection="column" paddingX={1}>
81 <Box flexDirection="row" gap={1}>
82 {pickerTitleView({ Text }, style, palette, model.title)}
83 {model.isDraft === true ? (
84 model.filter === '' ? null : <Text dimColor>{`${say(style, 'filter')}: ${model.filter}`}</Text>
85 ) : (
86 <Input
87 key={FILTER_KEY}
88 placeholder="filter"
89 value={model.filter}
90 submitLabel="pick"
91 autoFocus
92 onInput={text => actions.filter(text)}
93 onSubmit={text => actions.submit(text)}
94 />
95 )}
96 <Box flexGrow={1} />
97 {pageText === null ? null : <Text dimColor>{pageText}</Text>}
98 {model.hasBack ? <Button key={BACK_KEY} label={label('back')} dimColor onPress={() => actions.back()} /> : null}
99 <Button key={CLOSE_KEY} label={label('close')} role="dismiss" dimColor onPress={() => actions.close()} />
100 </Box>
101 {items.length === 0 ? <Text dimColor>{empty}</Text> : null}
102 {items.map((option, index) => {
103 const hotkey = hotkeyOf(index)
104 const { name, note: ownNote } = nameOf(option)
105 const extra = model.rowOf?.(option) ?? {}
106 const note = extra.note ?? ownNote
107 return (
108 <Box flexDirection="row" gap={1}>
109 {extra.mark ?? null}
110 <Box width={nameWidth} flexShrink={0}>
111 <Button key={rowKeyOf(option.value)} {...(hotkey === undefined ? {} : { hotkey })} plain label={name} onPress={() => actions.pick(option.value)} />
112 </Box>
113 {extra.chip ?? null}
114 {note === '' ? null : (
115 <Box flexGrow={1} flexShrink={1}>
116 <Text dimColor wrap="truncate-end">
117 {note}
118 </Text>
119 </Box>
120 )}
121 </Box>
122 )
123 })}
124 <Box flexDirection="row" gap={1}>
125 <Box flexGrow={1} flexShrink={1}>
126 <Text dimColor wrap="truncate-end">
127 {model.note ?? (model.isDraft === true ? DRAFT_HINT_TEXT : HINT_TEXT)}
128 </Text>
129 </Box>
130 {pages > 1 ? <Button key={MORE_KEY} hotkey="0" plain label={label('more')} onPress={() => actions.more()} /> : null}
131 </Box>
132 </Box>
133 )
134}
135
136/**
137 * The band's own parts (the live line, the picker, the strip) as one card in
138 * the style's frame: the card ground where the style paints one, and the
139 * frame in the line colour. Style A on the terminal draws the parts as they are.
140 */
141export function bandCard(Box: Ui['Box'], palette: Palette, parts: readonly RenderElement[]): RenderElement {
142 if (palette.frame === 'none') return Box({ flexDirection: 'column', children: [...parts] })
143 return Box({ flexDirection: 'column', ...frameProps(palette), children: [...parts] })
144}
145
146/** The band's tree over whatever the hooks beneath drew there. */
147export function stack(Box: Ui['Box'], below: RenderElement, band: RenderElement): RenderElement {
148 return Box({ flexDirection: 'column', children: [below, band] })
149}
150
151/**
152 * Rows one page may hold so the whole band fits `maxRows`: under the title
153 * row and above the hint row, at most nine (one digit each). A band taller
154 * than `maxRows` scrolls, and a scrolling band arms no digit.
155 */
156export function pageSizeOf(maxRows: number): number {
157 return Math.max(1, Math.min(MAX_PAGE_SIZE, Math.floor(maxRows) - 2))
158}
159hooks/mod/probe.ts 302 lines1/**
2 * The mod's probe journal: what ran, on which host and surface (MOD-DESKTOP-PLAN.md).
3 *
4 * The mod loads wherever Claude Code loads a hooks module, and only some
5 * surfaces draw. A person cannot read that from a transcript, so every
6 * session appends a few rows to `<home>/.sdlc/mod-probe.jsonl`, and
7 * `scripts/mod-probe.mjs` turns the rows into one verdict per host and
8 * surface. `verdictOf` below is that judgment; the script imports it, so the
9 * table and the tests read the same code.
10 *
11 * Nothing here throws at its caller: a journal that cannot be read or written
12 * leaves the mod as it is.
13 */
14
15/** The events a row carries, in the order a session writes them. */
16export type ProbeEvent = 'load' | 'attach' | 'commands' | 'turn' | 'compact' | 'call' | 'read' | 'draw' | 'prompt'
17
18/**
19 * The `detail` of a `read` row: `agent <id|main> <path>`. A `tool.call` hook on
20 * `Read` writes one such row (ARTIFACT-SPLIT-PLAN W0), so the journal proves
21 * that the hook fired, and whether the call carried an `agentId`. `main`
22 * stands for a call on the main loop, which carries no `agentId`.
23 */
24export function readProbeDetail(agentId: string | undefined | null, path: string): string {
25 const id = typeof agentId === 'string' && agentId.trim() !== '' ? agentId.trim() : 'main'
26 return `agent ${id} ${path}`
27}
28
29/** The agent and the path a `read` row's detail names; null when the detail has another form. */
30export function readProbeOf(detail: string): { agentId: string | null; path: string } | null {
31 const match = /^agent (\S+) (.+)$/u.exec(detail)
32 if (match === null) return null
33 const [, id = 'main', path = ''] = match
34 return { agentId: id === 'main' ? null : id, path }
35}
36
37/** One line of the journal. */
38export type ProbeRow = {
39 /** ISO-8601 UTC, to the second. */
40 at: string
41 /** The first 8 characters of the session's id, to group a session's rows. */
42 session: string
43 /** The host, from `CLAUDE_CODE_ENTRYPOINT`; `unknown` when the variable is absent. */
44 host: string
45 /** Where the session draws at the moment of the row; `none` before any surface. */
46 surface: string
47 /** Whether a person is at the prompt, as `session.start` reported it. */
48 interactive: boolean
49 event: ProbeEvent
50 /** False for a call that failed or a decision that did not run. */
51 ok: boolean
52 /** One short phrase: the counts, the key, the outcome, or the error message. */
53 detail: string
54}
55
56/** The journal's path under the sdlc home directory. */
57export const PROBE_FILE = 'mod-probe.jsonl'
58/** Rows past this count are dropped from the front at the next write. */
59export const PROBE_CAP = 400
60/** A detail longer than this is cut, so one row stays one short line. */
61const DETAIL_CAP = 400
62
63/** The row a caller hands the journal, without the fields every row shares. */
64export type ProbeFact = { event: ProbeEvent; ok: boolean; detail?: string }
65
66/** The fields every row of one session shares. */
67export type ProbeIdentity = { session: string; host: string; surface: string; interactive: boolean }
68
69/** One row from an identity, a fact, and the clock. */
70export function rowOf(identity: ProbeIdentity, fact: ProbeFact, nowMs: number): ProbeRow {
71 return {
72 at: new Date(nowMs).toISOString().replace(/\.\d{3}Z$/u, 'Z'),
73 session: identity.session,
74 host: identity.host,
75 surface: identity.surface,
76 interactive: identity.interactive,
77 event: fact.event,
78 ok: fact.ok,
79 detail: (fact.detail ?? '').slice(0, DETAIL_CAP),
80 }
81}
82
83/** Every row a journal text holds; a line that does not parse is dropped. */
84export function rowsOf(text: string): ProbeRow[] {
85 const rows: ProbeRow[] = []
86 for (const line of text.split('\n')) {
87 const trimmed = line.trim()
88 if (trimmed === '') continue
89 try {
90 const value = JSON.parse(trimmed) as Partial<ProbeRow>
91 if (typeof value.at !== 'string' || typeof value.event !== 'string') continue
92 rows.push({
93 at: value.at,
94 session: typeof value.session === 'string' ? value.session : '',
95 host: typeof value.host === 'string' ? value.host : 'unknown',
96 surface: typeof value.surface === 'string' ? value.surface : 'none',
97 interactive: value.interactive === true,
98 event: value.event as ProbeEvent,
99 ok: value.ok !== false,
100 detail: typeof value.detail === 'string' ? value.detail : '',
101 })
102 } catch {
103 // A half-written line from a killed process is not an error; drop it.
104 }
105 }
106 return rows
107}
108
109/** The journal's text from its rows, newest last, capped at `PROBE_CAP`. */
110export function textOf(rows: readonly ProbeRow[]): string {
111 const kept = rows.slice(Math.max(0, rows.length - PROBE_CAP))
112 return kept.map(row => JSON.stringify(row)).join('\n') + (kept.length > 0 ? '\n' : '')
113}
114
115/** What one host and surface did, as the table prints it. */
116export type ProbeVerdict = {
117 host: string
118 surface: string
119 /** Sessions seen for this host and surface. */
120 sessions: number
121 /** The newest row's `at`. */
122 lastAt: string
123 /** True when at least one session bound the host here. */
124 loaded: boolean
125 /** The commands registered, from the newest `commands` row; null when none was written. */
126 commands: string | null
127 /** Whether every command registered; null when no `commands` row was written. */
128 commandsOk: boolean | null
129 /** `/wf` turns seen, and the turns that took a workflow action. */
130 turns: number
131 actions: number
132 /** Compactions by outcome, for example `done 3 · refused 1`; empty when none ran. */
133 compactions: string
134 /**
135 * Proof that a `tool.call` hook on `Read` fired: the `read` rows on the main
136 * loop, and the rows inside a sub-agent (with an `agentId`).
137 */
138 reads: { main: number; agent: number }
139 /** The capability calls that failed here, newest first, at most three. */
140 failures: string[]
141 /** `ok` when the module loaded here, `dead` when it did not. */
142 status: 'ok' | 'dead'
143}
144
145/** One verdict per host and surface, newest activity first. */
146export function verdictOf(rows: readonly ProbeRow[]): ProbeVerdict[] {
147 const groups = new Map<string, ProbeRow[]>()
148 for (const row of rows) {
149 const key = `${row.host}\u0000${row.surface}`
150 const known = groups.get(key)
151 if (known === undefined) groups.set(key, [row])
152 else known.push(row)
153 }
154 const verdicts: ProbeVerdict[] = []
155 for (const [key, group] of groups) {
156 const [host = 'unknown', surface = 'none'] = key.split('\u0000')
157 const compactions = new Map<string, number>()
158 for (const row of group) {
159 if (row.event !== 'compact') continue
160 const outcome = row.detail === '' ? 'done' : row.detail
161 compactions.set(outcome, (compactions.get(outcome) ?? 0) + 1)
162 }
163 const turnRows = group.filter(row => row.event === 'turn')
164 const readRows = group.filter(row => row.event === 'read' && row.ok).map(row => readProbeOf(row.detail))
165 const commandRow = group.filter(row => row.event === 'commands').at(-1)
166 verdicts.push({
167 host,
168 surface,
169 sessions: new Set(group.map(row => row.session)).size,
170 lastAt: group.map(row => row.at).sort().at(-1) ?? '',
171 loaded: group.some(row => row.event === 'load' && row.ok),
172 commands: commandRow?.detail ?? null,
173 commandsOk: commandRow === undefined ? null : commandRow.ok,
174 turns: turnRows.length,
175 actions: turnRows.filter(row => row.ok).length,
176 compactions: [...compactions].map(([name, count]) => `${name} ${count}`).join(' · '),
177 reads: {
178 main: readRows.filter(read => read !== null && read.agentId === null).length,
179 agent: readRows.filter(read => read !== null && read.agentId !== null).length,
180 },
181 failures: group
182 .filter(row => row.event === 'call' && !row.ok)
183 .slice(-3)
184 .reverse()
185 .map(row => row.detail),
186 status: group.some(row => row.event === 'load' && row.ok) ? 'ok' : 'dead',
187 })
188 }
189 return verdicts.sort((a, b) => b.lastAt.localeCompare(a.lastAt))
190}
191
192/**
193 * What the `read` rows prove about the `tool.call` hook on `Read`, as the
194 * probe table prints it: `main 3 · agent 2` when it fired in both loops, a
195 * dash when no `read` row was written. A row with an agent id is the proof
196 * that the hook fires inside a sub-agent (a yolo stage agent).
197 */
198export function readHookCell(reads: { main: number; agent: number } | undefined): string {
199 if (reads === undefined || reads.main + reads.agent === 0) return '—'
200 return [reads.main > 0 ? `main ${reads.main}` : '', reads.agent > 0 ? `agent ${reads.agent}` : ''].filter(Boolean).join(' · ')
201}
202
203/**
204 * The surface a session draws on after a client attaches: the one it already
205 * had, else the one the client brought. A terminal session that a phone joins
206 * keeps drawing its band and its pinned line in the terminal.
207 */
208export function surfaceAfterAttach(current: string | null, attached: string): string {
209 return current ?? attached
210}
211
212/** Rows newer than `sinceMs`; every row when `sinceMs` is null. */
213export function sinceOf(rows: readonly ProbeRow[], sinceMs: number | null): ProbeRow[] {
214 if (sinceMs === null) return [...rows]
215 return rows.filter(row => {
216 const at = Date.parse(row.at)
217 return Number.isNaN(at) || at >= sinceMs
218 })
219}
220
221/** What the journal needs of the engine: a read that may fail, and a write. */
222export type ProbeIo = {
223 read: (path: string) => Promise<string | null>
224 write: (path: string, text: string) => Promise<void>
225 now: () => Promise<number>
226}
227
228/**
229 * The journal a session writes through. Writes are serialized on one promise
230 * chain, because `$.fs.write` replaces the whole file and two writes at once
231 * would lose a row.
232 */
233export class ProbeJournal {
234 #io: ProbeIo
235 #path: string
236 #identity: ProbeIdentity
237 #queue: Promise<void> = Promise.resolve()
238 /** The call kinds already recorded as failed, so one kind is one row per session. */
239 #reported = new Set<string>()
240 /** The loops (`agent <id|main>`) whose first `Read` is already recorded. */
241 #readLoops = new Set<string>()
242
243 constructor(io: ProbeIo, path: string, identity: ProbeIdentity) {
244 this.#io = io
245 this.#path = path
246 this.#identity = identity
247 }
248
249 /** Moves the session onto a surface, for the rows that follow. */
250 setSurface(surface: string): void {
251 this.#identity = { ...this.#identity, surface }
252 }
253
254 get surface(): string {
255 return this.#identity.surface
256 }
257
258 /**
259 * Appends one row. Never rejects; a failed journal is silent.
260 *
261 * The identity is taken now, not when the queued write runs, so a row keeps
262 * the surface the fact happened under even when a client attaches meanwhile.
263 */
264 write(fact: ProbeFact): Promise<void> {
265 const identity = this.#identity
266 this.#queue = this.#queue.then(async () => {
267 try {
268 const text = (await this.#io.read(this.#path)) ?? ''
269 const rows = rowsOf(text)
270 rows.push(rowOf(identity, fact, await this.#io.now()))
271 await this.#io.write(this.#path, textOf(rows))
272 } catch {
273 // A journal that cannot be written changes nothing else.
274 }
275 })
276 return this.#queue
277 }
278
279 /** Records the first failure of one call kind, and drops the rest. */
280 callFailed(kind: string, message: string): void {
281 if (this.#reported.has(kind)) return
282 this.#reported.add(kind)
283 void this.write({ event: 'call', ok: false, detail: `${kind}: ${message}` })
284 }
285
286 /**
287 * Records that the `tool.call` hook on `Read` fired, once per loop (the main
288 * loop, or one sub-agent) per session, so reads do not flood the journal.
289 */
290 readFired(agentId: string | undefined | null, path: string): void {
291 const key = readProbeDetail(agentId, '').trimEnd()
292 if (this.#readLoops.has(key)) return
293 this.#readLoops.add(key)
294 void this.write({ event: 'read', ok: true, detail: readProbeDetail(agentId, path) })
295 }
296
297 /** Waits for every queued write; the tests use it. */
298 settled(): Promise<void> {
299 return this.#queue
300 }
301}
302