create-cmp-cli 0.17.1 → 0.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -317,21 +317,47 @@ evidence attached"). `node qa/walk-status.mjs` derives the live position; a
317
317
  UserPromptSubmit hook injects it every prompt. **Render the injected state — never
318
318
  your memory of it.**
319
319
 
320
- **At kickoff** (with the triage restatement): print the itinerary —
320
+ **At kickoff** (with the triage restatement): print the itinerary — and DECLARE it as
321
+ the live chain, so the studio's Drive strip and the statusline's readers see the same
322
+ steps you just printed:
321
323
 
322
324
  Navigation redesign — the journey (brief lane)
323
325
  Decide → Design → Contract → Build → Prove → Sign-off
324
326
  Stops for you: 3 (Decide — now · Contract · Sign-off). Build and Prove never stop for you.
325
327
  First stop is now: 2 open decisions below.
326
328
 
327
- **While working — quiet:** one line per stage transition, nothing per-file. The
328
- statusline carries the position continuously; do not repeat it in prose.
329
-
330
- **At every human gate — loud:** a full stop card, never a bare question:
329
+ ```bash
330
+ node qa/plan.mjs --set "sign the brief | draft screens | agree the promises | build | full check | your sign-off" --title "navigation redesign"
331
+ ```
331
332
 
332
- YOUR TURN<feature> · stage 3 of 6: Contract
333
+ **The chain stays current** this is part of the contract, not a nicety: advance it
334
+ with `node qa/plan.mjs --step N` as each step lands and `--done` when the request
335
+ lands. The current request itself is recorded mechanically (the per-prompt hook), the
336
+ steps are yours to declare, and every surface shows the declaration's age — a stale
337
+ chain reads as stale to the human watching the studio, which is worse than no chain.
338
+ The chain gates nothing; the walk stays the truth for doneness.
339
+
340
+ **The studio is a standing check:** every injected context opens with a `[studio: …]`
341
+ line. If it says DOWN or not running, restore it before proceeding — call the
342
+ cmp-inspector `preview { projectDir }` tool (it starts a detached resident console
343
+ that survives the session) — or, if the tools are absent, tell the human once. A
344
+ missing window is a fault to heal, never something to work silently past.
345
+
346
+ **While working — the header, then quiet:** open EVERY reply with the walk's one-line
347
+ header — the exact `[chat header]` line the per-prompt inject delivers. Paste it
348
+ verbatim, never compose it: it is the derivation's own string, so it cannot drift, and
349
+ it persists in the transcript, which the statusline beneath the input box never does.
350
+ After the header: one line per stage transition, nothing per-file. Stages carry their
351
+ plain-words gloss on first mention ("Contract — agreeing what it promises"); quote the
352
+ lane's cost only from the measured figure in the injected card, never an estimate.
353
+
354
+ **At every human gate — loud:** a full stop card, never a bare question — and the
355
+ easiest act leads:
356
+
357
+ ■ YOUR TURN — <feature> · stage 3 of 6: Contract — agreeing what it promises
333
358
  <what it is, in plain words — two lines maximum>
334
- <exactly what to do: the command, or the studio tab, or "reply approve">
359
+ Easiest: the studio console at <url from the injected card> the row carries the button.
360
+ → CLI fallback: <the command> (or "reply approve" when no console is up)
335
361
  After this: <the remaining stages, and which ones stop for the human>
336
362
 
337
363
  **Arrivals:** work that belongs to no open walk (undeclared drift, a harness
@@ -34,3 +34,10 @@ xcuserdata/
34
34
  # ignored by git. Delete them once you have reviewed the upgrade's diff.
35
35
  *.bak-upgrade
36
36
  *.cmp-new
37
+
38
+ # The live chain's ephemeral state (studio-drive-mode): the current request
39
+ # (rewritten by the UserPromptSubmit hook on every prompt) and the agent's
40
+ # declared step plan. Per-session windshield, not project history — and also
41
+ # hard-excluded from the receipt's hashed input surface (qa/lib/inputs-hash.mjs).
42
+ qa/.request.json
43
+ qa/.plan.json
@@ -1063,7 +1063,10 @@ export function reopenFeature(root, name, options = {}) {
1063
1063
  return { ok: false, reason: `unknown feature "${name}" — known briefs: ${briefs.join(", ") || "(none)"}` };
1064
1064
  }
1065
1065
  const derived = deriveAllFeatures(root).find((d) => d.name === name);
1066
- const set = [briefId, `feature-spec:${name}`, `${FEATURE_DESIGN_PREFIX}${name}`, ...(derived ? derived.touches : [])];
1066
+ // The spec side of the family follows the brief's own pairing (a multi-spec
1067
+ // brief reopens every spec its promises live in), defaulting to the name.
1068
+ const specIds = (derived?.specNames ?? [name]).map((n) => `feature-spec:${n}`);
1069
+ const set = [briefId, ...specIds, `${FEATURE_DESIGN_PREFIX}${name}`, ...(derived ? derived.touches : [])];
1067
1070
  const byId = new Map(getApprovalStatuses(root).map((s) => [s.id, s]));
1068
1071
  const reopened = [];
1069
1072
  const skipped = [];
@@ -1274,7 +1277,12 @@ export function getFeatureBoard(root) {
1274
1277
  // feature-spec:* that is still signed must be reopened and amended, and the
1275
1278
  // step says so by name — that is what the human's signature set in motion.
1276
1279
  const deriveNextStep = (d, phase) => {
1277
- const specArtifact = byId.get(`feature-spec:${d.name}`);
1280
+ // The brief's PAIRED specs (feature-brief.mjs pairedSpecNames — the one
1281
+ // pairing function): a multi-spec brief waits on ALL of them being
1282
+ // signed, and its contract step names each one still waiting.
1283
+ const specArtifacts = (d.specNames ?? [d.name])
1284
+ .map((n) => byId.get(`feature-spec:${n}`))
1285
+ .filter(Boolean);
1278
1286
  const designArtifact = byId.get(`${FEATURE_DESIGN_PREFIX}${d.name}`) ?? null;
1279
1287
  const declaredSpecAmendments = d.touches
1280
1288
  .filter((id) => id.startsWith("feature-spec:") && byId.get(id)?.status === "approved")
@@ -1343,8 +1351,9 @@ export function getFeatureBoard(root) {
1343
1351
  // phase === "approved": building — which part of the loop is open?
1344
1352
  if (!d.specExists || d.total === 0)
1345
1353
  return { key: "contract", owner: "agent drafts → human signs", label: `contract: write the clauses in ${d.specRel}${amendNote}` };
1346
- if (specArtifact && specArtifact.status !== "approved")
1347
- return { key: "sign-spec", owner: "human", label: `sign the contract (feature-spec:${d.name})${amendNote}` };
1354
+ const unsignedSpecs = specArtifacts.filter((a) => a.status !== "approved");
1355
+ if (unsignedSpecs.length > 0)
1356
+ return { key: "sign-spec", owner: "human", label: `sign the contract (${unsignedSpecs.map((a) => a.id).join(", ")})${amendNote}` };
1348
1357
  if (d.covered < d.total)
1349
1358
  return { key: "build", owner: "agent", label: `build & cite: ${d.total - d.covered} clause(s) have no citing test yet` };
1350
1359
  return { key: "prove", owner: "agent", label: "prove: run node qa/verify.mjs so the receipt attests this tree" };
@@ -7,8 +7,9 @@
7
7
  // docs/features/ is a governed `feature-brief:<name>` artifact, hashed and
8
8
  // signed like anything else, approved BEFORE the feature is built. (Harness
9
9
  // design standards stay in docs/proposals/ — different directory, different
10
- // meaning.) `<name>` matches the feature's spec: docs/features/meal.md pairs
11
- // with specs/meal.spec.md.
10
+ // meaning.) `<name>` pairs with the feature's spec by default
11
+ // specs/<name>.spec.md, overridable by the brief itself when its promises
12
+ // genuinely live in several spec files (see pairedSpecNames).
12
13
  //
13
14
  // The brief carries at most ONE machine-read block, and it declares — it never
14
15
  // gates:
@@ -163,30 +164,90 @@ export function briefSections(markdown) {
163
164
 
164
165
  /**
165
166
  * A brief's declarations: blast radius (`touches`), UI surface (`screens`),
166
- * and the reachability exemption (`unrouted` — FI-7's escape hatch: a screen
167
- * intentionally not wired into the navigation graph yet). A missing block, or
168
- * one without a field, declares nothing legal and common. A block that IS
169
- * present but malformed is surfaced as `error`: a doc that tried to declare
170
- * and failed should say so, not read as "declares nothing".
167
+ * the reachability exemption (`unrouted` — FI-7's escape hatch: a screen
168
+ * intentionally not wired into the navigation graph yet), and the paired
169
+ * spec files (`specs`walk-legibility L1: spec NAMES, no path/extension;
170
+ * `"specs": ["catalog", "entry-editing"]`). A missing block, or one without a
171
+ * field, declares nothing legal and common. A block that IS present but
172
+ * malformed is surfaced as `error`: a doc that tried to declare and failed
173
+ * should say so, not read as "declares nothing".
171
174
  * @param {string} markdown
172
- * @returns {{touches: string[], screens: boolean, unrouted: boolean, error: (string|null)}}
175
+ * @returns {{touches: string[], screens: boolean, unrouted: boolean, specs: string[], error: (string|null)}}
173
176
  */
174
177
  export function parseFeatureBlock(markdown) {
175
178
  const m = typeof markdown === "string" ? markdown.match(FEATURE_FENCE_RE) : null;
176
- if (!m) return { touches: [], screens: false, unrouted: false, error: null };
179
+ if (!m) return { touches: [], screens: false, unrouted: false, specs: [], error: null };
177
180
  let parsed;
178
181
  try {
179
182
  parsed = JSON.parse(m[1]);
180
183
  } catch (err) {
181
- return { touches: [], screens: false, unrouted: false, error: `cmp:feature block is not valid JSON — ${err.message}` };
184
+ return { touches: [], screens: false, unrouted: false, specs: [], error: `cmp:feature block is not valid JSON — ${err.message}` };
182
185
  }
183
186
  if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
184
- return { touches: [], screens: false, unrouted: false, error: "cmp:feature must be a JSON object" };
187
+ return { touches: [], screens: false, unrouted: false, specs: [], error: "cmp:feature must be a JSON object" };
185
188
  }
186
189
  const touches = Array.isArray(parsed.touches)
187
190
  ? parsed.touches.filter((t) => typeof t === "string" && t.trim() !== "")
188
191
  : [];
189
- return { touches, screens: parsed.screens === true, unrouted: parsed.unrouted === true, error: null };
192
+ // `specs` entries are normalized to bare names ("specs/catalog.spec.md" and
193
+ // "catalog" both mean specs/catalog.spec.md) — declaring in either form is
194
+ // fine; storing one form keeps every consumer's arithmetic identical.
195
+ const specs = Array.isArray(parsed.specs)
196
+ ? [
197
+ ...new Set(
198
+ parsed.specs
199
+ .filter((s) => typeof s === "string" && s.trim() !== "")
200
+ .map((s) => s.trim().replace(/^specs\//, "").replace(/\.spec\.md$/, "")),
201
+ ),
202
+ ]
203
+ : [];
204
+ return { touches, screens: parsed.screens === true, unrouted: parsed.unrouted === true, specs, error: null };
205
+ }
206
+
207
+ /**
208
+ * The spec files a brief's promises live in — THE pairing function
209
+ * (walk-legibility L1). One definition, consumed by the board derivation, the
210
+ * walk, and (through them) the console, so no surface can pair differently.
211
+ * Precedence:
212
+ * 1. the cmp:feature block's `"specs": [...]` — the explicit declaration
213
+ * 2. the brief's `**Spec:**` paragraph — every `specs/<name>.spec.md`
214
+ * reference in it (the form briefs already carry for human readers)
215
+ * 3. the filename default: `specs/<name>.spec.md`
216
+ * Before this existed, a brief whose blast radius genuinely spans two specs
217
+ * (catalog-and-editing, showcase 2026-08-26) derived as "still awaiting a
218
+ * contract" forever — a standing false instruction on the primary surface
219
+ * that invites an agent to write a second definition of signed behavior.
220
+ * @param {string} markdown the brief's full text
221
+ * @param {string} name the brief's name (docs/features/<name>.md)
222
+ * @param {{specs?: string[]}} [block] a parseFeatureBlock result, if the
223
+ * caller already has one (avoids re-parsing; same answer either way)
224
+ * @returns {string[]} spec names, e.g. ["catalog", "entry-editing"]
225
+ */
226
+ export function pairedSpecNames(markdown, name, block) {
227
+ const declared = (block ?? parseFeatureBlock(markdown)).specs ?? [];
228
+ if (declared.length > 0) return declared;
229
+ const fromHeader = specHeaderNames(markdown);
230
+ if (fromHeader.length > 0) return fromHeader;
231
+ return [name];
232
+ }
233
+
234
+ /**
235
+ * Every `specs/<name>.spec.md` referenced in the brief's `**Spec:**`
236
+ * paragraph — the line starting `**Spec:**` through the next blank line, so
237
+ * later prose that merely MENTIONS a spec path never redirects the pairing.
238
+ */
239
+ function specHeaderNames(markdown) {
240
+ if (typeof markdown !== "string") return [];
241
+ const lines = markdown.split("\n");
242
+ const start = lines.findIndex((l) => /^\*\*Spec:?\*\*/.test(l.trim()));
243
+ if (start === -1) return [];
244
+ const para = [];
245
+ for (let i = start; i < lines.length && lines[i].trim() !== ""; i++) para.push(lines[i]);
246
+ const out = [];
247
+ for (const m of para.join("\n").matchAll(/specs\/([A-Za-z0-9_-]+)\.spec\.md/g)) {
248
+ if (!out.includes(m[1])) out.push(m[1]);
249
+ }
250
+ return out;
190
251
  }
191
252
 
192
253
  /**
@@ -262,12 +323,21 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
262
323
  } catch {
263
324
  readable = false;
264
325
  }
265
- const block = readable ? parseFeatureBlock(markdown) : { touches: [], screens: false, error: `${brief.rel} could not be read` };
326
+ const block = readable
327
+ ? parseFeatureBlock(markdown)
328
+ : { touches: [], screens: false, specs: [], error: `${brief.rel} could not be read` };
266
329
 
267
- const specRel = `specs/${brief.name}.spec.md`;
268
- const specExists = fs.existsSync(path.join(root, specRel));
330
+ // The paired specs (walk-legibility L1): usually one, by filename; a brief
331
+ // may name several. Clauses concatenate in declaration order — "done" means
332
+ // every live clause across ALL of them is cited.
333
+ const specNames = pairedSpecNames(markdown, brief.name, block);
334
+ const specRels = specNames.map((n) => `specs/${n}.spec.md`);
335
+ const specExists = specRels.every((rel) => fs.existsSync(path.join(root, rel)));
336
+ const specRel = specRels.join(" + ");
269
337
  const citedIds = new Set((pre.citations ?? scanCitations(root)).map((t) => t.id));
270
- const clauses = clausesOfSpec(root, specRel).map((c) => ({ ...c, cited: citedIds.has(c.id) }));
338
+ const clauses = specRels
339
+ .flatMap((rel) => clausesOfSpec(root, rel))
340
+ .map((c) => ({ ...c, cited: citedIds.has(c.id) }));
271
341
  const live = clauses.filter((c) => !c.withdrawn);
272
342
  const covered = live.filter((c) => c.cited).length;
273
343
 
@@ -286,6 +356,8 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
286
356
  // rung's only mechanical signal (see countEdgeCases).
287
357
  edgeCases: readable ? countEdgeCases(markdown) : 0,
288
358
  specRel,
359
+ specNames,
360
+ specRels,
289
361
  specExists,
290
362
  clauses,
291
363
  covered,
@@ -48,7 +48,15 @@ export const VERIFIED_SURFACE = [
48
48
  // bookkeeping about a commit that already happened, so appending a record
49
49
  // must never invalidate a receipt for a tree whose code did not change
50
50
  // (approvals.log.jsonl's principle, applied to audits).
51
+ // qa/.request.json and qa/.plan.json are the live chain's EPHEMERAL state
52
+ // (studio-drive-mode): the request file is rewritten on EVERY user prompt by
53
+ // the UserPromptSubmit hook, so hashing either would invalidate the receipt
54
+ // the moment the human speaks. They are also gitignored on fresh scaffolds,
55
+ // but the exclusion here is the load-bearing one — upgraded apps keep their
56
+ // own .gitignore, which never learns new entries.
51
57
  const EXCLUDED_PREFIXES = [
58
+ "qa/.plan.json",
59
+ "qa/.request.json",
52
60
  "qa/evidence",
53
61
  "qa-artifacts",
54
62
  "qa/comments.json",
@@ -0,0 +1,200 @@
1
+ // plan.mjs — the live chain: what the CURRENT REQUEST is, which step the
2
+ // agent is on, and what comes next. docs/features/studio-drive-mode.md is the
3
+ // brief of record; this is D8's itinerary (walk-status.md) promoted from
4
+ // kickoff prose to a tracked object.
5
+ //
6
+ // PROVENANCE TIERS, each rendered as what it is — this is the one surface in
7
+ // the harness that is not purely derived, and the design is honest about it:
8
+ //
9
+ // 1. The REQUEST is machinery-owned: the UserPromptSubmit hook records the
10
+ // human's own prompt verbatim (walk-status --inject reads it from the
11
+ // hook's stdin). No agent claim involved.
12
+ // 2. The STEPS are agent-declared: written once at kickoff (`node
13
+ // qa/plan.mjs --set`), advanced as work lands (`--step N`). Every
14
+ // rendering carries the declaration's age — a stale plan reads as
15
+ // stale, never as true.
16
+ // 3. The CORROBORATION is derived and overrides: the lane/render markers
17
+ // (composeApp/build/.cmp-lane-in-progress / .cmp-render-in-progress,
18
+ // mtime-bounded like every other consumer) say what is ACTUALLY running
19
+ // right now, regardless of what was declared.
20
+ //
21
+ // THE PLAN GATES NOTHING. The walk (walk.mjs — a pure projection) stays the
22
+ // load-bearing truth for doneness; the chain is a windshield, not an
23
+ // instrument. Both live in EPHEMERAL dot-files that are excluded from the
24
+ // receipt's hashed input surface (qa/lib/inputs-hash.mjs EXCLUDED_PREFIXES —
25
+ // a request recorded on every prompt must never invalidate a receipt) and
26
+ // gitignored on fresh scaffolds.
27
+ //
28
+ // FAIL-SOFT EVERYWHERE: readers return null, writers return {ok:false} — a
29
+ // status surface never breaks the work it reports on.
30
+
31
+ import fs from "node:fs";
32
+ import path from "node:path";
33
+
34
+ export const PLAN_REL = "qa/.plan.json";
35
+ export const REQUEST_REL = "qa/.request.json";
36
+
37
+ // A marker older than this is a crashed writer, not a live run — the same
38
+ // bound qa/watch.mjs and the preview daemon apply to the same files.
39
+ const MARKER_FRESH_MS = 5 * 60 * 1000;
40
+ const MAX_REQUEST_CHARS = 500;
41
+ const MAX_STEPS = 20;
42
+ const MAX_LABEL_CHARS = 120;
43
+
44
+ function readJson(p) {
45
+ try {
46
+ return JSON.parse(fs.readFileSync(p, "utf8"));
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ function writeJson(p, value) {
53
+ try {
54
+ fs.writeFileSync(p, `${JSON.stringify(value, null, 2)}\n`);
55
+ return { ok: true };
56
+ } catch (err) {
57
+ return { ok: false, reason: err?.message ?? String(err) };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Record the human's latest prompt — tier 1, machinery-owned. Called by the
63
+ * UserPromptSubmit hook path with the hook's own `prompt` field; never by
64
+ * the agent with words of its own choosing.
65
+ */
66
+ export function recordRequest(root, text) {
67
+ const t = typeof text === "string" ? text.trim() : "";
68
+ if (t === "") return { ok: false, reason: "empty prompt — nothing to record" };
69
+ return writeJson(path.join(root, REQUEST_REL), {
70
+ text: t.length > MAX_REQUEST_CHARS ? `${t.slice(0, MAX_REQUEST_CHARS - 1)}…` : t,
71
+ at: new Date().toISOString(),
72
+ });
73
+ }
74
+
75
+ /** @returns {{text: string, at: string}|null} */
76
+ export function readRequest(root) {
77
+ const r = readJson(path.join(root, REQUEST_REL));
78
+ return r && typeof r.text === "string" ? r : null;
79
+ }
80
+
81
+ /**
82
+ * Declare the chain — tier 2, agent-declared, said so on every rendering.
83
+ * `title` is the agent's triage restatement of the ask (the contract already
84
+ * mandates one); `steps` are plain labels in order. Declaring replaces any
85
+ * previous chain: one request, one chain.
86
+ */
87
+ export function setPlan(root, { title, feature, steps } = {}) {
88
+ const labels = (Array.isArray(steps) ? steps : [])
89
+ .map((s) => String(s ?? "").trim())
90
+ .filter((s) => s !== "")
91
+ .slice(0, MAX_STEPS)
92
+ .map((s) => (s.length > MAX_LABEL_CHARS ? `${s.slice(0, MAX_LABEL_CHARS - 1)}…` : s));
93
+ if (labels.length === 0) return { ok: false, reason: "a chain needs at least one step" };
94
+ return writeJson(path.join(root, PLAN_REL), {
95
+ title: typeof title === "string" && title.trim() !== "" ? title.trim() : null,
96
+ feature: typeof feature === "string" && feature.trim() !== "" ? feature.trim() : null,
97
+ steps: labels.map((label, i) => ({ n: i + 1, label, done: false })),
98
+ current: 1,
99
+ updatedAt: new Date().toISOString(),
100
+ });
101
+ }
102
+
103
+ /**
104
+ * Advance to step `n`: everything before it is done, `n` is current. `--done`
105
+ * (n past the end) closes the chain. Refuses without a declared chain —
106
+ * advancing nothing would fabricate a plan that was never stated.
107
+ */
108
+ export function markStep(root, n) {
109
+ const plan = readJson(path.join(root, PLAN_REL));
110
+ if (!plan || !Array.isArray(plan.steps) || plan.steps.length === 0)
111
+ return { ok: false, reason: "no declared chain — declare one first: node qa/plan.mjs --set \"step | step | …\"" };
112
+ const step = Number(n);
113
+ if (!Number.isInteger(step) || step < 1 || step > plan.steps.length + 1)
114
+ return { ok: false, reason: `step must be 1..${plan.steps.length + 1} (=${plan.steps.length + 1} closes the chain), got ${n}` };
115
+ for (const s of plan.steps) s.done = s.n < step;
116
+ plan.current = step > plan.steps.length ? null : step;
117
+ plan.updatedAt = new Date().toISOString();
118
+ return writeJson(path.join(root, PLAN_REL), plan).ok ? { ok: true, plan } : { ok: false, reason: "could not write the chain" };
119
+ }
120
+
121
+ /** @returns {object|null} the declared chain, or null. */
122
+ export function readPlan(root) {
123
+ const p = readJson(path.join(root, PLAN_REL));
124
+ return p && Array.isArray(p.steps) ? p : null;
125
+ }
126
+
127
+ /** Clear the chain (a landed request leaves no stale windshield behind). */
128
+ export function clearPlan(root) {
129
+ try {
130
+ fs.rmSync(path.join(root, PLAN_REL), { force: true });
131
+ return { ok: true };
132
+ } catch (err) {
133
+ return { ok: false, reason: err?.message ?? String(err) };
134
+ }
135
+ }
136
+
137
+ function markerFresh(root, name) {
138
+ try {
139
+ const st = fs.statSync(path.join(root, "composeApp", "build", name));
140
+ return Date.now() - st.mtimeMs < MARKER_FRESH_MS;
141
+ } catch {
142
+ return false;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Everything a chain-rendering surface needs, with provenance attached:
148
+ * request (tier 1) + plan with its age (tier 2) + what is ACTUALLY running
149
+ * (tier 3 — the markers the lane and preview daemon already stamp).
150
+ * @returns {{request: (object|null), plan: (object|null), planAgeMs: (number|null),
151
+ * busy: {lane: boolean, render: boolean}}}
152
+ */
153
+ export function deriveChain(root) {
154
+ const plan = readPlan(root);
155
+ const at = plan ? Date.parse(plan.updatedAt) : NaN;
156
+ return {
157
+ request: readRequest(root),
158
+ plan,
159
+ planAgeMs: Number.isNaN(at) ? null : Math.max(0, Date.now() - at),
160
+ busy: {
161
+ lane: markerFresh(root, ".cmp-lane-in-progress"),
162
+ render: markerFresh(root, ".cmp-render-in-progress"),
163
+ },
164
+ };
165
+ }
166
+
167
+ /** "40s ago" / "12 min ago" — freshness a human can weigh at a glance. */
168
+ export function formatAge(ms) {
169
+ if (!(ms >= 0)) return "age unknown";
170
+ if (ms < 90000) return `${Math.round(ms / 1000)}s ago`;
171
+ if (ms < 90 * 60000) return `${Math.round(ms / 60000)} min ago`;
172
+ return `${Math.round(ms / 3600000)}h ago`;
173
+ }
174
+
175
+ /**
176
+ * The chain as one text block — the CLI's and the inject's rendering.
177
+ * Numbered steps: done ✓, current ◉ (with tier-3 corroboration when a lane
178
+ * or render is genuinely in flight), pending ○. "" when nothing is declared
179
+ * AND no request is recorded (silence, never an empty frame).
180
+ */
181
+ export function renderChain(chain) {
182
+ if (!chain || (!chain.plan && !chain.request)) return "";
183
+ const lines = [];
184
+ const title = chain.plan?.title ?? chain.request?.text ?? null;
185
+ if (title) lines.push(`Request: ${title}`);
186
+ if (chain.plan) {
187
+ const p = chain.plan;
188
+ const seq = p.steps
189
+ .map((s) => `${s.done ? "✓" : s.n === p.current ? "◉" : "○"} ${s.n}. ${s.label}`)
190
+ .join(" → ");
191
+ lines.push(seq);
192
+ const cur = p.steps.find((s) => s.n === p.current) ?? null;
193
+ const busy = chain.busy?.lane ? " · the full check is running NOW" : chain.busy?.render ? " · a preview render is in flight" : "";
194
+ const age = chain.planAgeMs !== null ? ` · declared by the agent, updated ${formatAge(chain.planAgeMs)}` : "";
195
+ lines.push(cur ? `now: step ${cur.n} of ${p.steps.length} — ${cur.label}${busy}${age}` : `chain complete${busy}${age}`);
196
+ } else {
197
+ lines.push("(no declared chain for this request yet — node qa/plan.mjs --set \"step | step | …\")");
198
+ }
199
+ return lines.join("\n");
200
+ }