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.
@@ -13,11 +13,14 @@
13
13
  // test is that promise kept, provenDone is all of them kept with the receipt
14
14
  // attesting this tree.
15
15
 
16
+ import crypto from "node:crypto";
16
17
  import fs from "node:fs";
18
+ import os from "node:os";
17
19
  import path from "node:path";
18
20
 
19
21
  import { getFeatureBoard, getApprovalStatuses, readJournal } from "./approvals.mjs";
20
22
  import { CLAUSE_LINE_RE } from "./spec-coverage.mjs";
23
+ import { deriveChain, renderChain } from "./plan.mjs";
21
24
 
22
25
  /** The six stages, in walk order. `label` is the only user-facing name (D2). */
23
26
  export const STAGES = [
@@ -29,6 +32,32 @@ export const STAGES = [
29
32
  { key: "signoff", label: "Sign-off" },
30
33
  ];
31
34
 
35
+ /**
36
+ * Plain-language glosses (walk-legibility L3) — what each stage IS, for a
37
+ * reader who did not build the harness. Rendered beside the stage name
38
+ * wherever a human reads; the keys and ids underneath never change.
39
+ */
40
+ export const STAGE_GLOSS = {
41
+ decide: "choosing what to build, and why",
42
+ design: "how it looks — judged on rendered screens",
43
+ contract: "agreeing what it promises",
44
+ build: "keeping the promises",
45
+ prove: "checking every promise",
46
+ signoff: "your sign-off",
47
+ };
48
+
49
+ /**
50
+ * A governed artifact id in plain words (L3): `feature-spec:meal` reads as
51
+ * "the promises for meal". Ledger ids stay ids; only the human rendering
52
+ * translates. Unknown shapes pass through untouched — never invented prose.
53
+ */
54
+ export function humanArtifact(id) {
55
+ const m = /^feature-(brief|design|spec):(.+)$/.exec(String(id));
56
+ if (!m) return String(id);
57
+ const what = m[1] === "brief" ? "the decisions for" : m[1] === "design" ? "the design of" : "the promises for";
58
+ return `${what} ${m[2]}`;
59
+ }
60
+
32
61
  /** nextStep.key -> the stage that step belongs to. `closed` maps to none. */
33
62
  const STEP_STAGE = {
34
63
  "sign-brief": "decide",
@@ -105,21 +134,50 @@ function remainingStops(stages, currentIdx) {
105
134
  * provenDone) to ONE next act — stages before it are done, after it pending.
106
135
  * Design is `skipped` when the feature honestly has no UI surface (D2).
107
136
  */
108
- function walkOfFeature(root, f) {
137
+ function walkOfFeature(root, f, lane = null) {
109
138
  const currentKey = STEP_STAGE[f.nextStep?.key] ?? null; // null => closed
110
139
  const currentIdx = currentKey ? STAGES.findIndex((s) => s.key === currentKey) : STAGES.length;
111
140
  const stages = STAGES.map((s, i) => {
112
141
  if (s.key === "design" && f.design === null)
113
142
  return { ...s, state: "skipped", note: "no UI surface" };
114
- return { ...s, state: i < currentIdx ? "done" : i === currentIdx ? "current" : "pending" };
143
+ const state = i < currentIdx ? "done" : i === currentIdx ? "current" : "pending";
144
+ // Time is part of position (L4): Prove is the one stage with its own
145
+ // recorded history (the lane journals every run), so it says what it
146
+ // costs — measured, never the agent's memory of it.
147
+ if (s.key === "prove" && lane)
148
+ return { ...s, state, note: `${humanDuration(lane.durationMs)} last full run` };
149
+ return { ...s, state };
115
150
  });
116
151
 
117
- const promises = listPromises(root, f.specRel).filter((p) => !p.withdrawn);
152
+ // The paired specs (L1): promises concatenate across every spec the brief
153
+ // names, in declaration order — same pairing the board derivation used.
154
+ const specRels = f.specRels ?? [f.specRel];
155
+ const promises = specRels.flatMap((rel) => listPromises(root, rel)).filter((p) => !p.withdrawn);
118
156
  // The promise being kept NOW: first live clause without a citing test —
119
157
  // board clause order, titles from the spec's own words.
120
158
  const citedIds = new Set(f.clauses.filter((c) => c.cited).map((c) => c.id));
121
159
  const current = promises.find((p) => !citedIds.has(p.id)) ?? null;
122
160
 
161
+ // The signature the current step is waiting for, when it IS a signature —
162
+ // so a surface with a signing control (the console) can put the button on
163
+ // the walk card itself instead of pointing at a CLI incantation (L5).
164
+ // Derived from the board's own step key, never a fourth approve path.
165
+ const signable = (() => {
166
+ switch (f.nextStep?.key) {
167
+ case "sign-brief":
168
+ case "re-approve":
169
+ return [{ verb: "approve", artifact: `feature-brief:${f.name}` }];
170
+ case "sign-design":
171
+ return [{ verb: "approve", artifact: `feature-design:${f.name}` }];
172
+ case "sign-spec":
173
+ return (f.specNames ?? [f.name]).map((n) => ({ verb: "approve", artifact: `feature-spec:${n}` }));
174
+ case "accept":
175
+ return [{ verb: "accept", artifact: f.name }];
176
+ default:
177
+ return [];
178
+ }
179
+ })();
180
+
123
181
  // Whose turn (D4): from the board's own owner, never re-derived.
124
182
  const owner = f.nextStep?.owner ?? null;
125
183
  const you =
@@ -137,13 +195,83 @@ function walkOfFeature(root, f) {
137
195
  open: f.phase !== "accepted",
138
196
  stages,
139
197
  currentStage: currentKey,
140
- promises: { total: f.total, kept: f.covered, current },
141
- you,
198
+ // `all` is the full promise list with per-promise kept state (L5) — the
199
+ // console renders the promises themselves, not only the tally.
200
+ promises: {
201
+ total: f.total,
202
+ kept: f.covered,
203
+ current,
204
+ all: promises.map((p) => ({ ...p, kept: citedIds.has(p.id) })),
205
+ },
206
+ you: { ...you, signable },
142
207
  stops: remainingStops(stages, currentIdx),
143
208
  doneReason: f.doneReason,
144
209
  };
145
210
  }
146
211
 
212
+ /**
213
+ * What a full verify-lane run costs HERE, from the lane's own journal
214
+ * (qa/flight-recorder.jsonl) — the most recent non-fast run's wall time and
215
+ * verdict. Walk-legibility L4: any surface quoting the lane quotes this,
216
+ * never a memory of it (the observed failure: a 3× overestimate, spoken
217
+ * while the human was deciding whether a run was worth it, lost the run).
218
+ * @returns {{durationMs: number, verdict: (string|null)} | null}
219
+ */
220
+ export function laneTiming(root) {
221
+ let text;
222
+ try {
223
+ text = fs.readFileSync(path.join(root, "qa/flight-recorder.jsonl"), "utf8");
224
+ } catch {
225
+ return null;
226
+ }
227
+ const lines = text.split("\n");
228
+ for (let i = lines.length - 1; i >= 0; i--) {
229
+ const line = lines[i].trim();
230
+ if (line === "") continue;
231
+ let e;
232
+ try {
233
+ e = JSON.parse(line);
234
+ } catch {
235
+ continue;
236
+ }
237
+ if (e && e.mode !== "fast" && typeof e.durationMs === "number" && e.durationMs > 0)
238
+ return { durationMs: e.durationMs, verdict: e.verdict ?? null };
239
+ }
240
+ return null;
241
+ }
242
+
243
+ /** "98s" under two minutes, "~5 min" above — for humans deciding whether to wait. */
244
+ export function humanDuration(ms) {
245
+ if (!(ms > 0)) return "unknown";
246
+ return ms < 120000 ? `${Math.round(ms / 1000)}s` : `~${Math.round(ms / 60000)} min`;
247
+ }
248
+
249
+ /**
250
+ * The studio console currently registered for `root`, from the same tmp-dir
251
+ * record the console itself writes — a CROSS-PACKAGE CONTRACT with the
252
+ * inspector's preview-service.mjs (consoleRegistryPath): sha1(resolved
253
+ * root).slice(0,12), `cmp-console-<key>.json` in os.tmpdir(), fields
254
+ * {pid, port, url}. pid-liveness only, no HTTP — this runs inside a
255
+ * statusline with a <300ms budget.
256
+ * @returns {{url: string} | {stale: true} | null} null = no record at all
257
+ * (never started, or stopped cleanly — silence, not an alarm)
258
+ */
259
+ export function consoleState(root) {
260
+ try {
261
+ const key = crypto.createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 12);
262
+ const rec = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `cmp-console-${key}.json`), "utf8"));
263
+ if (!rec || typeof rec.pid !== "number") return null;
264
+ try {
265
+ process.kill(rec.pid, 0); // signal 0: existence probe, touches nothing
266
+ } catch (err) {
267
+ if (!(err && err.code === "EPERM")) return { stale: true }; // record left by a crashed console
268
+ }
269
+ return { url: typeof rec.url === "string" ? rec.url : `http://127.0.0.1:${rec.port}/` };
270
+ } catch {
271
+ return null;
272
+ }
273
+ }
274
+
147
275
  /**
148
276
  * Everything the status surfaces render (D5): open walks, plus ARRIVALS (D7)
149
277
  * — governed artifacts drifted or reopened that NO open walk accounts for
@@ -167,15 +295,22 @@ export function deriveWalks(root) {
167
295
  // worked on today (seen on the showcase: bfl-catalog, alphabetical, outshouted
168
296
  // the live navigation-ia walk in the statusline).
169
297
  const journalForOrder = readJournal(root);
298
+ const byName = new Map(board.features.map((f) => [f.name, f]));
170
299
  const lastActivity = (name) => {
171
- const family = new Set([`feature-brief:${name}`, `feature-design:${name}`, `feature-spec:${name}`]);
300
+ const specNames = byName.get(name)?.specNames ?? [name];
301
+ const family = new Set([
302
+ `feature-brief:${name}`,
303
+ `feature-design:${name}`,
304
+ ...specNames.map((n) => `feature-spec:${n}`),
305
+ ]);
172
306
  for (let i = journalForOrder.length - 1; i >= 0; i--) {
173
307
  if (family.has(journalForOrder[i].artifact)) return i;
174
308
  }
175
309
  return -1;
176
310
  };
311
+ const lane = laneTiming(root);
177
312
  const walks = board.features
178
- .map((f) => walkOfFeature(root, f))
313
+ .map((f) => walkOfFeature(root, f, lane))
179
314
  .filter((w) => w.open)
180
315
  .sort((a, b) => lastActivity(b.name) - lastActivity(a.name));
181
316
 
@@ -187,7 +322,9 @@ export function deriveWalks(root) {
187
322
  if (f.phase === "accepted") continue;
188
323
  owned.add(`feature-brief:${f.name}`);
189
324
  owned.add(`feature-design:${f.name}`);
190
- owned.add(`feature-spec:${f.name}`);
325
+ // The spec side follows the brief's own pairing (L1) — a reopened spec a
326
+ // multi-spec walk owns is that walk's Contract stage, never an arrival.
327
+ for (const n of f.specNames ?? [f.name]) owned.add(`feature-spec:${n}`);
191
328
  for (const t of f.touches) owned.add(t.id);
192
329
  }
193
330
  const journal = journalForOrder;
@@ -206,7 +343,13 @@ export function deriveWalks(root) {
206
343
  reason: s.status === "reopened" ? lastReopenReason(s.id) : "changed since its signature",
207
344
  }));
208
345
 
209
- return { available: true, walks, arrivals };
346
+ // `lane` (L4) and `console` (L6) ride along so every rendering can say
347
+ // what a check costs and where the buttons are — both derived, never
348
+ // remembered, and both null-safe for projects that have neither yet.
349
+ // The live CHAIN (studio-drive-mode) rides along too: request + declared
350
+ // step plan + what is actually running. Declared state, labeled as such by
351
+ // every renderer; it gates nothing and the walk stays the truth.
352
+ return { available: true, walks, arrivals, lane, console: consoleState(root), chain: deriveChain(root) };
210
353
  }
211
354
 
212
355
  // ── Renderings — one grammar, four slots (D4) ────────────────────────────────
@@ -225,35 +368,51 @@ function loudest(walks) {
225
368
  * The always-on one-liner (statusline). "" when there is nothing to say — an
226
369
  * ungoverned project's statusline stays silent, never fabricated.
227
370
  */
228
- export function renderStatusline({ available, walks, arrivals }) {
371
+ export function renderStatusline({ available, walks, arrivals, console: consoleRec }) {
229
372
  if (!available || walks.length === 0) return "";
230
373
  const w = loudest(walks);
231
374
  const extra = walks.length > 1 ? ` · +${walks.length - 1} walk${walks.length > 2 ? "s" : ""}` : "";
232
375
  const arrived = arrivals.length > 0 ? ` · ▲${arrivals.length} arrived` : "";
233
- if (w.you.turn === "you") return `■ YOUR TURN ${w.name}: ${w.you.act}${extra}${arrived}`;
376
+ // L6: the always-visible surface reports the other surface's death. A stale
377
+ // record means the console CRASHED (a clean stop removes it) — the failure
378
+ // mode was silence, and silence is the one thing this line never does.
379
+ const down = consoleRec && consoleRec.stale ? " · console down" : "";
380
+ if (w.you.turn === "you") return `■ YOUR TURN — ${w.name}: ${w.you.act}${extra}${arrived}${down}`;
234
381
  const now =
235
382
  w.currentStage === "build" && w.promises.total > 0
236
383
  ? `keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)}/${w.promises.total}`
237
384
  : stageLabel(w);
238
- return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}`;
385
+ return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}${down}`;
239
386
  }
240
387
 
241
- /** One walk's full card — the CLI default and the loud stop-card's body. */
242
- export function renderCard(w) {
388
+ /**
389
+ * One walk's full card — the CLI default and the loud stop-card's body.
390
+ * `ctx` carries the derivation's ride-alongs ({console, lane} from
391
+ * deriveWalks): with a live console, a human gate leads with the console
392
+ * (L5 — the product ships buttons; the CLI stays as the fallback beneath).
393
+ */
394
+ export function renderCard(w, ctx = {}) {
243
395
  const line = w.stages
244
- .map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.state === "skipped" ? ` (${s.note})` : ""}`)
396
+ .map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.note ? ` (${s.note})` : ""}`)
245
397
  .join(" ");
398
+ const gloss = STAGE_GLOSS[w.currentStage] ? ` — ${STAGE_GLOSS[w.currentStage]}` : "";
399
+ const laneNote =
400
+ w.currentStage === "prove" && ctx.lane ? ` (takes ${humanDuration(ctx.lane.durationMs)} here, measured)` : "";
246
401
  const nowLine =
247
402
  w.currentStage === "build" && w.promises.current
248
403
  ? `Now: keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)} of ${w.promises.total} — “${w.promises.current.title || w.promises.current.id}”`
249
- : `Now: ${w.you.act ?? w.doneReason}`;
404
+ : `Now: ${w.you.act ?? w.doneReason}${laneNote}`;
405
+ const consoleLine =
406
+ w.you.turn === "you" && ctx.console && ctx.console.url
407
+ ? `\n→ Easiest: the studio console at ${ctx.console.url} — the row carries the button. CLI fallback below.`
408
+ : "";
250
409
  const youLine =
251
410
  w.you.turn === "you"
252
- ? `■ YOUR TURN: ${w.you.act}`
411
+ ? `■ YOUR TURN: ${w.you.act}${consoleLine}`
253
412
  : w.you.turn === "agent"
254
413
  ? `You: nothing needed${w.stops.length ? ` · next stop${w.stops.length > 1 ? "s" : ""} for you: ${w.stops.join(", ")}` : ""}`
255
414
  : "Closed.";
256
- return `${w.name} — stage: ${stageLabel(w)}\n${line}\n${nowLine}\n${youLine}`;
415
+ return `${w.name} — stage: ${stageLabel(w)}${gloss}\n${line}\n${nowLine}\n${youLine}`;
257
416
  }
258
417
 
259
418
  /**
@@ -261,17 +420,49 @@ export function renderCard(w) {
261
420
  * standing protocol reminders, re-delivered every turn so the narration rules
262
421
  * are decay-proof — re-told, never remembered (D5/D6).
263
422
  */
264
- export function renderInject({ available, walks, arrivals }) {
423
+ /**
424
+ * The studio's status, said plainly every prompt (studio-drive-mode: the
425
+ * agent ALWAYS knows whether the human's window exists, and healing it is a
426
+ * standing instruction, not a discovery).
427
+ */
428
+ function studioLine(consoleRec) {
429
+ if (consoleRec && consoleRec.url) return `[studio: running at ${consoleRec.url}]`;
430
+ if (consoleRec && consoleRec.stale)
431
+ return "[studio: DOWN — it crashed (stale registry record). Restore it now: call the cmp-inspector `preview { projectDir }` tool (it starts a detached resident console), or tell the human it is down. Do not proceed silently.]";
432
+ return "[studio: not running. If the cmp-inspector tools are available, start it now with `preview { projectDir }` — the human's window should exist whenever work is happening. If they are absent, say so once.]";
433
+ }
434
+
435
+ export function renderInject(data) {
436
+ const { available, walks, arrivals, chain } = data;
265
437
  if (!available || (walks.length === 0 && arrivals.length === 0)) return "";
266
438
  const parts = [];
439
+ // L2 — chat is a walk surface: the reply opens with the derivation's OWN
440
+ // one-liner, pasted verbatim. Machinery-authored so it cannot drift, and
441
+ // transcript-persistent, which the statusline never is.
442
+ const header = renderStatusline(data);
443
+ if (header !== "") {
444
+ parts.push(`[chat header — open your reply with this exact line (verbatim, then a blank line):]\n${header}`);
445
+ }
446
+ // The studio's status is stated EVERY prompt — its absence was silent once
447
+ // (the walk-wiring lesson) and never gets to be silent again.
448
+ parts.push(studioLine(data.console));
449
+ // The live chain (studio-drive-mode): the current request and the declared
450
+ // step plan, with its age. Declared by the agent — which is exactly why it
451
+ // is re-shown every turn: keeping it current is part of the contract.
452
+ const chainText = renderChain(chain);
453
+ parts.push(
454
+ chainText !== ""
455
+ ? `[the chain — the current request's steps. Keep it CURRENT: advance with \`node qa/plan.mjs --step N\` as steps land, \`--done\` when the request lands. A stale chain misleads the human watching the studio.]\n${chainText}`
456
+ : '[no chain declared. At kickoff, declare the request\'s steps so the human can watch position live: `node qa/plan.mjs --set "step | step | …" --title "<the ask, restated>"` — mirror the itinerary you print in chat.]',
457
+ );
267
458
  if (walks.length > 0) {
268
459
  parts.push("[walk-status — derived from the ledgers; render this state, never your own memory of it]");
269
- for (const w of walks) parts.push(renderCard(w));
460
+ for (const w of walks) parts.push(renderCard(w, data));
270
461
  }
271
462
  for (const a of arrivals)
272
463
  parts.push(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}. Offer: handle now, or after the current walk lands (recommended: after).`);
273
464
  parts.push(
274
- "Protocol: speak stages as Decide·Design·Contract·Build·Prove·Sign-off and clauses as promises. Quiet while working (one line per stage transition). At any human gate, render the full stop card (stage, what it is in plain words, exactly what to do, what comes after). Never open a second walk silently.",
465
+ "Protocol: open every reply with the chat header line above, verbatim. Speak stages as Decide·Design·Contract·Build·Prove·Sign-off — with their plain-words gloss on first mention — and clauses as promises. Declare the chain at kickoff and advance it as you go; if the studio line above says DOWN or not running, restore it (preview tool) or surface it before proceeding. Quiet between headers (one line per stage transition). At any human gate, render the full stop card: stage, what it is in plain words, then the easiest act first (the studio console when it is up; the CLI as fallback), then what comes after. Quote the lane's cost only from the measured figure in the card — never estimate it. Never open a second walk silently.",
275
466
  );
276
467
  return parts.join("\n\n");
277
468
  }
@@ -0,0 +1,64 @@
1
+ #!/usr/bin/env node
2
+ // plan.mjs — the live chain's CLI (docs/features/studio-drive-mode.md).
3
+ // The agent declares the current request's step chain at kickoff and advances
4
+ // it as work lands; the statusline stays the walk's, but the studio's Drive
5
+ // strip and the per-prompt inject both render this chain with its age.
6
+ //
7
+ // node qa/plan.mjs # show the chain
8
+ // node qa/plan.mjs --set "a | b | c" [--title "…"] [--feature <name>]
9
+ // node qa/plan.mjs --step 3 # steps 1..2 done, 3 current
10
+ // node qa/plan.mjs --done # close the chain
11
+ // node qa/plan.mjs --clear # a landed request leaves no stale windshield
12
+ //
13
+ // Unlike walk-status (a fail-open status surface), this CLI is a WRITER the
14
+ // agent invokes deliberately: bad input gets a refusal and exit 1, because a
15
+ // silently-dropped declaration would leave the surfaces lying about position.
16
+
17
+ import path from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+
20
+ import { deriveChain, markStep, readPlan, renderChain, setPlan, clearPlan } from "./lib/plan.mjs";
21
+
22
+ const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
23
+ const args = process.argv.slice(2);
24
+
25
+ const valueOf = (flag) => {
26
+ const i = args.indexOf(flag);
27
+ return i !== -1 && i + 1 < args.length ? args[i + 1] : null;
28
+ };
29
+
30
+ function out(result) {
31
+ if (!result.ok) {
32
+ process.stderr.write(`plan: ${result.reason}\n`);
33
+ process.exit(1);
34
+ }
35
+ const rendered = renderChain(deriveChain(ROOT));
36
+ process.stdout.write(`${rendered === "" ? "No chain declared." : rendered}\n`);
37
+ process.exit(0);
38
+ }
39
+
40
+ if (args.includes("--set")) {
41
+ const spec = valueOf("--set");
42
+ if (spec === null) {
43
+ process.stderr.write('plan: --set needs a value: --set "step | step | …"\n');
44
+ process.exit(1);
45
+ }
46
+ out(
47
+ setPlan(ROOT, {
48
+ title: valueOf("--title") ?? undefined,
49
+ feature: valueOf("--feature") ?? undefined,
50
+ steps: spec.split("|"),
51
+ }),
52
+ );
53
+ } else if (args.includes("--step")) {
54
+ out(markStep(ROOT, valueOf("--step")));
55
+ } else if (args.includes("--done")) {
56
+ const plan = readPlan(ROOT);
57
+ out(plan ? markStep(ROOT, plan.steps.length + 1) : { ok: false, reason: "no declared chain to close" });
58
+ } else if (args.includes("--clear")) {
59
+ out(clearPlan(ROOT));
60
+ } else {
61
+ const rendered = renderChain(deriveChain(ROOT));
62
+ process.stdout.write(`${rendered === "" ? "No chain declared. Declare one: node qa/plan.mjs --set \"step | step | …\"" : rendered}\n`);
63
+ process.exit(0);
64
+ }
@@ -75,8 +75,13 @@ if (asHook) {
75
75
  process.exit(0);
76
76
  }
77
77
  if (!result.valid) {
78
+ // The walk's vocabulary (walk-legibility L3): this gate IS the Prove
79
+ // stage refusing to close — same fact, same enforcement, words that match
80
+ // every other surface. The precise reason stays verbatim beneath.
78
81
  process.stderr.write(
79
- `Not done: ${result.reason}. Run \`node qa/verify.mjs\` and commit the receipt, or see README §Verification enforcement to bypass.\n`,
82
+ `■ Prove not done: the promises are not yet checked against this tree. ` +
83
+ `${result.reason}. Run \`node qa/verify.mjs\` (it checks every promise and writes the receipt), ` +
84
+ `commit the receipt, or see README §Verification enforcement to bypass.\n`,
80
85
  );
81
86
  process.exit(2);
82
87
  }
@@ -19,8 +19,41 @@ import { fileURLToPath } from "node:url";
19
19
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
20
20
  const args = process.argv.slice(2);
21
21
 
22
+ /**
23
+ * The hook's stdin, parsed — UserPromptSubmit delivers {prompt, ...} as JSON.
24
+ * Bounded read, fail-soft: no stdin / non-JSON / no prompt -> null. Only the
25
+ * --inject path consumes this (the statusline gets no stdin and must not wait
26
+ * on one).
27
+ */
28
+ async function readHookStdin() {
29
+ if (process.stdin.isTTY) return null;
30
+ try {
31
+ let raw = "";
32
+ for await (const chunk of process.stdin) {
33
+ raw += chunk;
34
+ if (raw.length > 1_000_000) break; // a prompt is never this — stop reading, keep what we have
35
+ }
36
+ const parsed = JSON.parse(raw);
37
+ return parsed && typeof parsed === "object" ? parsed : null;
38
+ } catch {
39
+ return null;
40
+ }
41
+ }
42
+
22
43
  try {
23
44
  const { deriveWalks, renderStatusline, renderCard, renderInject } = await import("./lib/walk.mjs");
45
+
46
+ // Tier 1 of the chain (studio-drive-mode): record the human's own prompt
47
+ // BEFORE deriving, so this very inject already reflects the new request.
48
+ // Machinery-owned — the words are the hook's, never the agent's.
49
+ if (args.includes("--inject")) {
50
+ const hook = await readHookStdin();
51
+ if (hook && typeof hook.prompt === "string") {
52
+ const { recordRequest } = await import("./lib/plan.mjs");
53
+ recordRequest(ROOT, hook.prompt);
54
+ }
55
+ }
56
+
24
57
  const data = deriveWalks(ROOT);
25
58
 
26
59
  if (args.includes("--json")) {
@@ -43,7 +76,10 @@ try {
43
76
  } else if (data.walks.length === 0 && data.arrivals.length === 0) {
44
77
  process.stdout.write("No open walks. Every accepted feature's brief is its doc-of-record.\n");
45
78
  } else {
46
- for (const w of data.walks) process.stdout.write(`${renderCard(w)}\n\n`);
79
+ const { renderChain } = await import("./lib/plan.mjs");
80
+ const chainText = renderChain(data.chain);
81
+ if (chainText !== "") process.stdout.write(`${chainText}\n\n`);
82
+ for (const w of data.walks) process.stdout.write(`${renderCard(w, data)}\n\n`);
47
83
  for (const a of data.arrivals)
48
84
  process.stdout.write(`▲ ARRIVED, UNPLANNED — ${a.label} (${a.status}): ${a.reason ?? "no recorded reason"}\n`);
49
85
  }