SLOPSHOPPER

sdlc-workflow

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…

newpanebandspinnerrowsguard
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · sdlc-workflow
│ ┃ wf-live ✕ › fix the failing auth test and add an audit log call │ ┃ No live run yet. It opens when a yolo, │ ┃ campaign or brainstorm starts. ⏺ Read(src/auth.ts) │ ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · wf-live
No live run yet. It opens when a yolo, campaign or brainstorm starts.
README

SDLC Workflow

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.

Hosts

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.

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

Install

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

Your first workflow

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.

The 24 keys

KeyDoes
intakeEntry 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.
shapeProduct-owner discovery: acceptance criteria, documentation plan, augmentations.
designHuman-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).
brainstormThink 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.
sliceDecomposes the shape into shippable slices.
planPer-slice plan with a reuse scan.
implementCodes the slice. reviews runs the fix-blockers mode.
verifyTests, lints, typecheck, the user-observable AC gate, one user-gated fix loop.
reviewWorkflow review over an accumulating ledger; ad-hoc rubric or sweep <aggregate> without a slug.
handoffAggregates completed slices into a PR. Batch mode over pr#N or a branch.
shipRelease via .ai/ship-plan.md; announce, rollback.
retroPost-mortem, per slug or per branch.
probeRuntime-truth verification of built work; sweep enumerates the user surface.
simplifyThree read-only reviewers over a branch, a commit range, a plan, or a path.
autoLifecycle driver. Pauses only at a stage's own gate. Stops before handoff.
yoloAutonomous driver. Resolves each gate by written policy. Claude Code only.
campaignDrives a brainstorm's work packets in dependency waves, one PR per wave. Setup and prepare on every host; the waves Claude Code only.
taskWork whose deliverable is not a code change; observable ACs and a blast-radius gate.
statusDashboard; per-slug detail with the next command; deep drift check; advise sequencing.
recapPlain-language catch-up for a slug or a branch.
closeArchives a workflow, or closes one slice.
ship-planRelease-pipeline router: init, build, edit, audit for .ai/ship-plan.md.
docsDocumentation router: the orchestrator pipeline, or one Diátaxis primitive.
observabilityObservability 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

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.

Site map

Develop

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.

Source 32 files
hooks/mod/register.ts 1802 lines
1/**
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 lines
1/**
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}
509
hooks/mod/names.ts 36 lines
1/**
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'
36
hooks/mod/picker.ts 289 lines
1/**
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}
289
hooks/mod/styles/existing.tsx 359 lines
1/**
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}
359
hooks/mod/styles/skin.tsx 136 lines
1/**
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}
136
hooks/mod/styles/kit.tsx 154 lines
1/**
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}
154
hooks/mod/styles/tokens.ts 153 lines
1/**
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}
153
hooks/mod/live/register.ts 1225 lines
1/**
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 lines
1/**
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}`
19
hooks/mod/views.tsx 159 lines
1/* @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}
159
hooks/mod/probe.ts 302 lines
1/**
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