create-cmp-cli 0.17.1 → 0.19.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.
Files changed (49) hide show
  1. package/README.md +3 -3
  2. package/llms.txt +1 -1
  3. package/package.json +1 -1
  4. package/packages/harness/package.json +1 -1
  5. package/packages/harness/src/approve.mjs +7 -0
  6. package/packages/harness/src/lib/approvals.mjs +44 -9
  7. package/packages/harness/src/lib/evidence-level.mjs +3 -1
  8. package/packages/harness/src/lib/feature-brief.mjs +88 -16
  9. package/packages/harness/src/lib/flight-recorder.mjs +47 -2
  10. package/packages/harness/src/lib/inputs-hash.mjs +9 -0
  11. package/packages/harness/src/lib/lane-narrator.mjs +97 -0
  12. package/packages/harness/src/lib/lane-runner.mjs +173 -0
  13. package/packages/harness/src/lib/plan.mjs +466 -0
  14. package/packages/harness/src/lib/receipt-validate.mjs +4 -1
  15. package/packages/harness/src/lib/spec-coverage.mjs +35 -2
  16. package/packages/harness/src/lib/step-cache.mjs +1 -1
  17. package/packages/harness/src/lib/step-outcomes.mjs +123 -0
  18. package/packages/harness/src/lib/steps-cmp.mjs +1275 -0
  19. package/packages/harness/src/lib/walk.mjs +262 -21
  20. package/packages/harness/src/plan.mjs +64 -0
  21. package/packages/harness/src/receipt-check.mjs +59 -1
  22. package/packages/harness/src/verify.mjs +115 -1197
  23. package/packages/harness/src/walk-status.mjs +37 -1
  24. package/packages/receipts/src/inputs-hash.mjs +9 -0
  25. package/packages/receipts/src/receipt-validate.mjs +4 -1
  26. package/src/commands/doctor.mjs +26 -0
  27. package/src/lib/project-doctor.mjs +37 -0
  28. package/template/CLAUDE.md +73 -9
  29. package/template/gitignore +10 -0
  30. package/template/qa/approve.mjs +7 -0
  31. package/template/qa/lib/approvals.mjs +44 -9
  32. package/template/qa/lib/evidence-level.mjs +3 -1
  33. package/template/qa/lib/feature-brief.mjs +88 -16
  34. package/template/qa/lib/flight-recorder.mjs +47 -2
  35. package/template/qa/lib/inputs-hash.mjs +9 -0
  36. package/template/qa/lib/lane-narrator.mjs +97 -0
  37. package/template/qa/lib/lane-runner.mjs +173 -0
  38. package/template/qa/lib/plan.mjs +466 -0
  39. package/template/qa/lib/receipt-validate.mjs +4 -1
  40. package/template/qa/lib/spec-coverage.mjs +35 -2
  41. package/template/qa/lib/step-cache.mjs +1 -1
  42. package/template/qa/lib/step-outcomes.mjs +123 -0
  43. package/template/qa/lib/steps-cmp.mjs +1275 -0
  44. package/template/qa/lib/walk.mjs +262 -21
  45. package/template/qa/plan.mjs +64 -0
  46. package/template/qa/receipt-check.mjs +59 -1
  47. package/template/qa/verify.mjs +115 -1197
  48. package/template/qa/walk-status.mjs +37 -1
  49. package/template/specs/README.md +26 -0
@@ -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: laneCostPhrase(lane) };
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,127 @@ 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
+ // EVERY recorded full run, not just the last one. A single "last run" figure
228
+ // is dominated by Gradle's cache state: a run that changed nothing reports the
229
+ // no-op cost (a 2s releaseBuild cache hit), and the operator plans a real
230
+ // change around it. Observed spread on one project: last 25s, median 140s,
231
+ // worst 558s. The distribution is the honest answer to "what will this cost";
232
+ // `durationMs` stays the last run so existing callers keep their meaning.
233
+ const full = [];
234
+ for (const line of text.split("\n")) {
235
+ if (line.trim() === "") continue;
236
+ let e;
237
+ try {
238
+ e = JSON.parse(line);
239
+ } catch {
240
+ continue;
241
+ }
242
+ if (e && e.mode !== "fast" && typeof e.durationMs === "number" && e.durationMs > 0) full.push(e);
243
+ }
244
+ if (full.length === 0) return null;
245
+ const sorted = full.map((e) => e.durationMs).sort((a, b) => a - b);
246
+ const last = full[full.length - 1];
247
+ return {
248
+ durationMs: last.durationMs,
249
+ verdict: last.verdict ?? null,
250
+ runs: sorted.length,
251
+ // Median over an even count takes the lower of the two middles — a measured
252
+ // run rather than an average of two, so every figure quoted is one that
253
+ // actually happened.
254
+ medianMs: sorted[Math.floor((sorted.length - 1) / 2)],
255
+ maxMs: sorted[sorted.length - 1],
256
+ };
257
+ }
258
+
259
+ /**
260
+ * What a full check costs, said honestly: the last run when that is all there
261
+ * is, and last + typical + worst once the journal can support a spread. Never
262
+ * an estimate — every number here is a run that happened (walk-legibility L4).
263
+ * @param {{durationMs: number, medianMs?: number, maxMs?: number, runs?: number}|null} lane
264
+ * @returns {string|null} null when nothing was ever recorded
265
+ */
266
+ export function laneCostPhrase(lane) {
267
+ if (!lane || !(lane.durationMs > 0)) return null;
268
+ const last = `${humanDuration(lane.durationMs)} last full run`;
269
+ // One or two runs is not a distribution; saying "typical" over them would be
270
+ // the same overclaim in a new costume.
271
+ if (!(lane.runs > 2) || !(lane.medianMs > 0)) return `${last} (measured)`;
272
+ const spread =
273
+ lane.maxMs > lane.medianMs ? `, typically ${humanDuration(lane.medianMs)}, worst ${humanDuration(lane.maxMs)}` : `, typically ${humanDuration(lane.medianMs)}`;
274
+ return `${last}${spread} (measured over ${lane.runs} full runs)`;
275
+ }
276
+
277
+ /** "98s" under two minutes, "~5 min" above — for humans deciding whether to wait. */
278
+ export function humanDuration(ms) {
279
+ if (!(ms > 0)) return "unknown";
280
+ return ms < 120000 ? `${Math.round(ms / 1000)}s` : `~${Math.round(ms / 60000)} min`;
281
+ }
282
+
283
+ /**
284
+ * The studio console currently registered for `root`, from the same tmp-dir
285
+ * record the console itself writes — a CROSS-PACKAGE CONTRACT with the
286
+ * inspector's preview-service.mjs (consoleRegistryPath): sha1(resolved
287
+ * root).slice(0,12), `cmp-console-<key>.json` in os.tmpdir(), fields
288
+ * {pid, port, url, buildStale}. pid-liveness only, no HTTP — this runs inside a
289
+ * statusline with a <300ms budget.
290
+ *
291
+ * `buildStale` is the console's OWN verdict on itself (studio-self-renewal R5):
292
+ * a console serving code that is no longer the code on disk. It is carried on
293
+ * the record rather than recomputed here on purpose — the harness cannot hash
294
+ * the inspector's sources (in a scaffolded app the inspector is an npm package
295
+ * elsewhere), and a per-prompt hook must not make an HTTP call. The process
296
+ * that owns the fact publishes it; every consumer stays dumb.
297
+ * @returns {{url: string, buildStale: boolean} | {stale: true} | null} null = no
298
+ * record at all (never started, or stopped cleanly — silence, not an alarm)
299
+ */
300
+ export function consoleState(root) {
301
+ try {
302
+ const key = crypto.createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 12);
303
+ const rec = JSON.parse(fs.readFileSync(path.join(os.tmpdir(), `cmp-console-${key}.json`), "utf8"));
304
+ if (!rec || typeof rec.pid !== "number") return null;
305
+ try {
306
+ process.kill(rec.pid, 0); // signal 0: existence probe, touches nothing
307
+ } catch (err) {
308
+ if (!(err && err.code === "EPERM")) return { stale: true }; // record left by a crashed console
309
+ }
310
+ return {
311
+ url: typeof rec.url === "string" ? rec.url : `http://127.0.0.1:${rec.port}/`,
312
+ buildStale: rec.buildStale === true,
313
+ };
314
+ } catch {
315
+ return null;
316
+ }
317
+ }
318
+
147
319
  /**
148
320
  * Everything the status surfaces render (D5): open walks, plus ARRIVALS (D7)
149
321
  * — governed artifacts drifted or reopened that NO open walk accounts for
@@ -167,15 +339,22 @@ export function deriveWalks(root) {
167
339
  // worked on today (seen on the showcase: bfl-catalog, alphabetical, outshouted
168
340
  // the live navigation-ia walk in the statusline).
169
341
  const journalForOrder = readJournal(root);
342
+ const byName = new Map(board.features.map((f) => [f.name, f]));
170
343
  const lastActivity = (name) => {
171
- const family = new Set([`feature-brief:${name}`, `feature-design:${name}`, `feature-spec:${name}`]);
344
+ const specNames = byName.get(name)?.specNames ?? [name];
345
+ const family = new Set([
346
+ `feature-brief:${name}`,
347
+ `feature-design:${name}`,
348
+ ...specNames.map((n) => `feature-spec:${n}`),
349
+ ]);
172
350
  for (let i = journalForOrder.length - 1; i >= 0; i--) {
173
351
  if (family.has(journalForOrder[i].artifact)) return i;
174
352
  }
175
353
  return -1;
176
354
  };
355
+ const lane = laneTiming(root);
177
356
  const walks = board.features
178
- .map((f) => walkOfFeature(root, f))
357
+ .map((f) => walkOfFeature(root, f, lane))
179
358
  .filter((w) => w.open)
180
359
  .sort((a, b) => lastActivity(b.name) - lastActivity(a.name));
181
360
 
@@ -187,7 +366,9 @@ export function deriveWalks(root) {
187
366
  if (f.phase === "accepted") continue;
188
367
  owned.add(`feature-brief:${f.name}`);
189
368
  owned.add(`feature-design:${f.name}`);
190
- owned.add(`feature-spec:${f.name}`);
369
+ // The spec side follows the brief's own pairing (L1) — a reopened spec a
370
+ // multi-spec walk owns is that walk's Contract stage, never an arrival.
371
+ for (const n of f.specNames ?? [f.name]) owned.add(`feature-spec:${n}`);
191
372
  for (const t of f.touches) owned.add(t.id);
192
373
  }
193
374
  const journal = journalForOrder;
@@ -206,7 +387,13 @@ export function deriveWalks(root) {
206
387
  reason: s.status === "reopened" ? lastReopenReason(s.id) : "changed since its signature",
207
388
  }));
208
389
 
209
- return { available: true, walks, arrivals };
390
+ // `lane` (L4) and `console` (L6) ride along so every rendering can say
391
+ // what a check costs and where the buttons are — both derived, never
392
+ // remembered, and both null-safe for projects that have neither yet.
393
+ // The live CHAIN (studio-drive-mode) rides along too: request + declared
394
+ // step plan + what is actually running. Declared state, labeled as such by
395
+ // every renderer; it gates nothing and the walk stays the truth.
396
+ return { available: true, walks, arrivals, lane, console: consoleState(root), chain: deriveChain(root) };
210
397
  }
211
398
 
212
399
  // ── Renderings — one grammar, four slots (D4) ────────────────────────────────
@@ -225,35 +412,51 @@ function loudest(walks) {
225
412
  * The always-on one-liner (statusline). "" when there is nothing to say — an
226
413
  * ungoverned project's statusline stays silent, never fabricated.
227
414
  */
228
- export function renderStatusline({ available, walks, arrivals }) {
415
+ export function renderStatusline({ available, walks, arrivals, console: consoleRec }) {
229
416
  if (!available || walks.length === 0) return "";
230
417
  const w = loudest(walks);
231
418
  const extra = walks.length > 1 ? ` · +${walks.length - 1} walk${walks.length > 2 ? "s" : ""}` : "";
232
419
  const arrived = arrivals.length > 0 ? ` · ▲${arrivals.length} arrived` : "";
233
- if (w.you.turn === "you") return `■ YOUR TURN ${w.name}: ${w.you.act}${extra}${arrived}`;
420
+ // L6: the always-visible surface reports the other surface's death. A stale
421
+ // record means the console CRASHED (a clean stop removes it) — the failure
422
+ // mode was silence, and silence is the one thing this line never does.
423
+ const down = consoleRec && consoleRec.stale ? " · console down" : consoleRec && consoleRec.buildStale ? " · console stale" : "";
424
+ if (w.you.turn === "you") return `■ YOUR TURN — ${w.name}: ${w.you.act}${extra}${arrived}${down}`;
234
425
  const now =
235
426
  w.currentStage === "build" && w.promises.total > 0
236
427
  ? `keeping promise ${Math.min(w.promises.kept + 1, w.promises.total)}/${w.promises.total}`
237
428
  : stageLabel(w);
238
- return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}`;
429
+ return `${w.name} ${bar(w.stages)} ${now} · you: nothing${extra}${arrived}${down}`;
239
430
  }
240
431
 
241
- /** One walk's full card — the CLI default and the loud stop-card's body. */
242
- export function renderCard(w) {
432
+ /**
433
+ * One walk's full card — the CLI default and the loud stop-card's body.
434
+ * `ctx` carries the derivation's ride-alongs ({console, lane} from
435
+ * deriveWalks): with a live console, a human gate leads with the console
436
+ * (L5 — the product ships buttons; the CLI stays as the fallback beneath).
437
+ */
438
+ export function renderCard(w, ctx = {}) {
243
439
  const line = w.stages
244
- .map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.state === "skipped" ? ` (${s.note})` : ""}`)
440
+ .map((s) => `${s.state === "current" ? "▶" : s.state === "done" ? "●" : s.state === "skipped" ? "·" : "○"} ${s.label}${s.note ? ` (${s.note})` : ""}`)
245
441
  .join(" ");
442
+ const gloss = STAGE_GLOSS[w.currentStage] ? ` — ${STAGE_GLOSS[w.currentStage]}` : "";
443
+ const laneNote =
444
+ w.currentStage === "prove" && ctx.lane ? ` (${laneCostPhrase(ctx.lane)} here)` : "";
246
445
  const nowLine =
247
446
  w.currentStage === "build" && w.promises.current
248
447
  ? `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}`;
448
+ : `Now: ${w.you.act ?? w.doneReason}${laneNote}`;
449
+ const consoleLine =
450
+ w.you.turn === "you" && ctx.console && ctx.console.url
451
+ ? `\n→ Easiest: the studio console at ${ctx.console.url} — the row carries the button. CLI fallback below.`
452
+ : "";
250
453
  const youLine =
251
454
  w.you.turn === "you"
252
- ? `■ YOUR TURN: ${w.you.act}`
455
+ ? `■ YOUR TURN: ${w.you.act}${consoleLine}`
253
456
  : w.you.turn === "agent"
254
457
  ? `You: nothing needed${w.stops.length ? ` · next stop${w.stops.length > 1 ? "s" : ""} for you: ${w.stops.join(", ")}` : ""}`
255
458
  : "Closed.";
256
- return `${w.name} — stage: ${stageLabel(w)}\n${line}\n${nowLine}\n${youLine}`;
459
+ return `${w.name} — stage: ${stageLabel(w)}${gloss}\n${line}\n${nowLine}\n${youLine}`;
257
460
  }
258
461
 
259
462
  /**
@@ -261,17 +464,55 @@ export function renderCard(w) {
261
464
  * standing protocol reminders, re-delivered every turn so the narration rules
262
465
  * are decay-proof — re-told, never remembered (D5/D6).
263
466
  */
264
- export function renderInject({ available, walks, arrivals }) {
467
+ /**
468
+ * The studio's status, said plainly every prompt (studio-drive-mode: the
469
+ * agent ALWAYS knows whether the human's window exists, and healing it is a
470
+ * standing instruction, not a discovery).
471
+ */
472
+ function studioLine(consoleRec) {
473
+ // Order matters: a console that is UP but drawing from old code is not
474
+ // healthy, and reporting it as "running" is the clean bill of health that
475
+ // let this go unnoticed for a whole session. It heals itself (the worker
476
+ // renews on quiescence), so say what is true and why it may be waiting.
477
+ if (consoleRec && consoleRec.url && consoleRec.buildStale)
478
+ return `[studio: running at ${consoleRec.url} but STALE — it is serving code older than the tree, and everything it shows was drawn by that older code. It renews itself once no render or lane is in flight; if it stays stale, say so rather than citing what it shows.]`;
479
+ if (consoleRec && consoleRec.url) return `[studio: running at ${consoleRec.url}]`;
480
+ if (consoleRec && consoleRec.stale)
481
+ 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.]";
482
+ 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.]";
483
+ }
484
+
485
+ export function renderInject(data) {
486
+ const { available, walks, arrivals, chain } = data;
265
487
  if (!available || (walks.length === 0 && arrivals.length === 0)) return "";
266
488
  const parts = [];
489
+ // L2 — chat is a walk surface: the reply opens with the derivation's OWN
490
+ // one-liner, pasted verbatim. Machinery-authored so it cannot drift, and
491
+ // transcript-persistent, which the statusline never is.
492
+ const header = renderStatusline(data);
493
+ if (header !== "") {
494
+ parts.push(`[chat header — open your reply with this exact line (verbatim, then a blank line):]\n${header}`);
495
+ }
496
+ // The studio's status is stated EVERY prompt — its absence was silent once
497
+ // (the walk-wiring lesson) and never gets to be silent again.
498
+ parts.push(studioLine(data.console));
499
+ // The live chain (studio-drive-mode): the current request and the declared
500
+ // step plan, with its age. Declared by the agent — which is exactly why it
501
+ // is re-shown every turn: keeping it current is part of the contract.
502
+ const chainText = renderChain(chain);
503
+ parts.push(
504
+ chainText !== ""
505
+ ? `[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. If the human redirects, re-declare (\`--set\` again) — the chain is an offer they can reshape, not an announcement. A stale chain misleads the human watching the studio.]\n${chainText}`
506
+ : '[no chain declared. At kickoff, declare the request\'s steps and show them in your first reply AS AN OFFER — the human can reorder or redirect, and you re-declare without ceremony; work starts immediately either way: `node qa/plan.mjs --set "step | step | …" --title "<the ask, restated>"`.]',
507
+ );
267
508
  if (walks.length > 0) {
268
509
  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));
510
+ for (const w of walks) parts.push(renderCard(w, data));
270
511
  }
271
512
  for (const a of arrivals)
272
513
  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
514
  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.",
515
+ "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
516
  );
276
517
  return parts.join("\n\n");
277
518
  }
@@ -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
+ }
@@ -28,6 +28,39 @@ const args = process.argv.slice(2);
28
28
  const asHook = args.includes("--hook");
29
29
  const asJson = args.includes("--json");
30
30
 
31
+ // The lane's own in-flight marker (verify.mjs stamps it, rewriting it at each
32
+ // step start with the step name and index). Mirrors qa/watch.mjs's bound.
33
+ const LANE_MARKER_REL = ["composeApp", "build", ".cmp-lane-in-progress"];
34
+ const LANE_MARKER_STALE_MS = 30 * 60 * 1000;
35
+
36
+ /**
37
+ * The full check running RIGHT NOW, or null. The gate must still refuse — a
38
+ * lane in flight has not produced a receipt yet — but it must not tell the
39
+ * agent to start one. Repeating "run the lane" at a session whose lane is
40
+ * already ten minutes into its release build is an instruction to do the wrong
41
+ * thing, and it fired ~8 times in one observed session.
42
+ * @returns {{step: string|null, index: number|null, total: number|null}|null}
43
+ */
44
+ function laneInFlight() {
45
+ try {
46
+ const p = path.join(ROOT, ...LANE_MARKER_REL);
47
+ const st = fs.statSync(p);
48
+ if (Date.now() - st.mtimeMs >= LANE_MARKER_STALE_MS) return null;
49
+ // Content is a bonus, never a requirement: legacy markers hold "pid iso".
50
+ try {
51
+ const n = JSON.parse(fs.readFileSync(p, "utf8"));
52
+ if (n && typeof n === "object") {
53
+ return { step: n.step ?? null, index: n.index ?? null, total: n.total ?? null };
54
+ }
55
+ } catch {
56
+ /* legacy marker — its EXISTENCE is the fact that matters */
57
+ }
58
+ return { step: null, index: null, total: null };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
31
64
  function readStdinJson() {
32
65
  try {
33
66
  const raw = fs.readFileSync(0, "utf8");
@@ -56,6 +89,16 @@ function evaluate() {
56
89
  profile: receipt.profile,
57
90
  };
58
91
  }
92
+ // A nightly receipt proves the HARNESS and the tree's invariants under a
93
+ // forced double-run — never a change. Refused as done-evidence for the same
94
+ // reason --fast is: the receipt's own stage says what it is allowed to mean.
95
+ if (receipt.stage === "nightly" || receipt.profile === "nightly") {
96
+ return {
97
+ valid: false,
98
+ reason: "the last verify run was the nightly stage (it proves the harness, not this change); run the change-stage lane (`node qa/verify.mjs`) before finishing",
99
+ profile: receipt.profile,
100
+ };
101
+ }
59
102
  const result = evaluateReceipt(receipt, () => computeInputsHash(ROOT));
60
103
  // Surface the receipt's evidence rung (the ladder — qa/lib/evidence-level.mjs)
61
104
  // alongside the verdict: the rung is the receipt's own derived field, read
@@ -75,8 +118,23 @@ if (asHook) {
75
118
  process.exit(0);
76
119
  }
77
120
  if (!result.valid) {
121
+ // The walk's vocabulary (walk-legibility L3): this gate IS the Prove
122
+ // stage refusing to close — same fact, same enforcement, words that match
123
+ // every other surface. The precise reason stays verbatim beneath.
124
+ //
125
+ // The REFUSAL never changes with a lane in flight — no receipt yet means
126
+ // not done, and that is the whole point of the gate. What changes is the
127
+ // INSTRUCTION: "run the lane" is wrong advice when one is already running,
128
+ // and a gate that tells you to do the thing you are doing trains you to
129
+ // stop reading it.
130
+ const flight = laneInFlight();
131
+ const act = flight
132
+ ? `A full check is ALREADY RUNNING${flight.step ? ` (${flight.step}${flight.index && flight.total ? `, step ${flight.index} of ${flight.total}` : ""})` : ""} — ` +
133
+ `wait for it to finish and commit its receipt. Do NOT start a second one; two lanes fight over the same build directory.`
134
+ : "Run `node qa/verify.mjs` (it checks every promise and writes the receipt), " +
135
+ "commit the receipt, or see README §Verification enforcement to bypass.";
78
136
  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`,
137
+ `■ Prove not done: the promises are not yet checked against this tree. ${result.reason}. ${act}\n`,
80
138
  );
81
139
  process.exit(2);
82
140
  }