create-cmp-cli 0.18.0 → 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 (40) hide show
  1. package/README.md +3 -3
  2. package/llms.txt +1 -1
  3. package/package.json +1 -1
  4. package/packages/harness/src/approve.mjs +7 -0
  5. package/packages/harness/src/lib/approvals.mjs +32 -6
  6. package/packages/harness/src/lib/evidence-level.mjs +3 -1
  7. package/packages/harness/src/lib/flight-recorder.mjs +47 -2
  8. package/packages/harness/src/lib/inputs-hash.mjs +1 -0
  9. package/packages/harness/src/lib/lane-narrator.mjs +97 -0
  10. package/packages/harness/src/lib/lane-runner.mjs +173 -0
  11. package/packages/harness/src/lib/plan.mjs +286 -20
  12. package/packages/harness/src/lib/receipt-validate.mjs +4 -1
  13. package/packages/harness/src/lib/spec-coverage.mjs +35 -2
  14. package/packages/harness/src/lib/step-cache.mjs +1 -1
  15. package/packages/harness/src/lib/step-outcomes.mjs +123 -0
  16. package/packages/harness/src/lib/steps-cmp.mjs +1275 -0
  17. package/packages/harness/src/lib/walk.mjs +66 -16
  18. package/packages/harness/src/receipt-check.mjs +56 -3
  19. package/packages/harness/src/verify.mjs +115 -1197
  20. package/packages/receipts/src/inputs-hash.mjs +1 -0
  21. package/packages/receipts/src/receipt-validate.mjs +4 -1
  22. package/template/CLAUDE.md +44 -6
  23. package/template/gitignore +3 -0
  24. package/template/qa/approve.mjs +7 -0
  25. package/template/qa/lib/approvals.mjs +32 -6
  26. package/template/qa/lib/evidence-level.mjs +3 -1
  27. package/template/qa/lib/flight-recorder.mjs +47 -2
  28. package/template/qa/lib/inputs-hash.mjs +1 -0
  29. package/template/qa/lib/lane-narrator.mjs +97 -0
  30. package/template/qa/lib/lane-runner.mjs +173 -0
  31. package/template/qa/lib/plan.mjs +286 -20
  32. package/template/qa/lib/receipt-validate.mjs +4 -1
  33. package/template/qa/lib/spec-coverage.mjs +35 -2
  34. package/template/qa/lib/step-cache.mjs +1 -1
  35. package/template/qa/lib/step-outcomes.mjs +123 -0
  36. package/template/qa/lib/steps-cmp.mjs +1275 -0
  37. package/template/qa/lib/walk.mjs +66 -16
  38. package/template/qa/receipt-check.mjs +56 -3
  39. package/template/qa/verify.mjs +115 -1197
  40. package/template/specs/README.md +26 -0
@@ -145,7 +145,7 @@ function walkOfFeature(root, f, lane = null) {
145
145
  // recorded history (the lane journals every run), so it says what it
146
146
  // costs — measured, never the agent's memory of it.
147
147
  if (s.key === "prove" && lane)
148
- return { ...s, state, note: `${humanDuration(lane.durationMs)} last full run` };
148
+ return { ...s, state, note: laneCostPhrase(lane) };
149
149
  return { ...s, state };
150
150
  });
151
151
 
@@ -224,20 +224,54 @@ export function laneTiming(root) {
224
224
  } catch {
225
225
  return null;
226
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;
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;
231
236
  let e;
232
237
  try {
233
238
  e = JSON.parse(line);
234
239
  } catch {
235
240
  continue;
236
241
  }
237
- if (e && e.mode !== "fast" && typeof e.durationMs === "number" && e.durationMs > 0)
238
- return { durationMs: e.durationMs, verdict: e.verdict ?? null };
242
+ if (e && e.mode !== "fast" && typeof e.durationMs === "number" && e.durationMs > 0) full.push(e);
239
243
  }
240
- return null;
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)`;
241
275
  }
242
276
 
243
277
  /** "98s" under two minutes, "~5 min" above — for humans deciding whether to wait. */
@@ -251,10 +285,17 @@ export function humanDuration(ms) {
251
285
  * record the console itself writes — a CROSS-PACKAGE CONTRACT with the
252
286
  * inspector's preview-service.mjs (consoleRegistryPath): sha1(resolved
253
287
  * 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
288
+ * {pid, port, url, buildStale}. pid-liveness only, no HTTP — this runs inside a
255
289
  * 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)
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)
258
299
  */
259
300
  export function consoleState(root) {
260
301
  try {
@@ -266,7 +307,10 @@ export function consoleState(root) {
266
307
  } catch (err) {
267
308
  if (!(err && err.code === "EPERM")) return { stale: true }; // record left by a crashed console
268
309
  }
269
- return { url: typeof rec.url === "string" ? rec.url : `http://127.0.0.1:${rec.port}/` };
310
+ return {
311
+ url: typeof rec.url === "string" ? rec.url : `http://127.0.0.1:${rec.port}/`,
312
+ buildStale: rec.buildStale === true,
313
+ };
270
314
  } catch {
271
315
  return null;
272
316
  }
@@ -376,7 +420,7 @@ export function renderStatusline({ available, walks, arrivals, console: consoleR
376
420
  // L6: the always-visible surface reports the other surface's death. A stale
377
421
  // record means the console CRASHED (a clean stop removes it) — the failure
378
422
  // mode was silence, and silence is the one thing this line never does.
379
- const down = consoleRec && consoleRec.stale ? " · console down" : "";
423
+ const down = consoleRec && consoleRec.stale ? " · console down" : consoleRec && consoleRec.buildStale ? " · console stale" : "";
380
424
  if (w.you.turn === "you") return `■ YOUR TURN — ${w.name}: ${w.you.act}${extra}${arrived}${down}`;
381
425
  const now =
382
426
  w.currentStage === "build" && w.promises.total > 0
@@ -397,7 +441,7 @@ export function renderCard(w, ctx = {}) {
397
441
  .join(" ");
398
442
  const gloss = STAGE_GLOSS[w.currentStage] ? ` — ${STAGE_GLOSS[w.currentStage]}` : "";
399
443
  const laneNote =
400
- w.currentStage === "prove" && ctx.lane ? ` (takes ${humanDuration(ctx.lane.durationMs)} here, measured)` : "";
444
+ w.currentStage === "prove" && ctx.lane ? ` (${laneCostPhrase(ctx.lane)} here)` : "";
401
445
  const nowLine =
402
446
  w.currentStage === "build" && w.promises.current
403
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}”`
@@ -426,6 +470,12 @@ export function renderCard(w, ctx = {}) {
426
470
  * standing instruction, not a discovery).
427
471
  */
428
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.]`;
429
479
  if (consoleRec && consoleRec.url) return `[studio: running at ${consoleRec.url}]`;
430
480
  if (consoleRec && consoleRec.stale)
431
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.]";
@@ -452,8 +502,8 @@ export function renderInject(data) {
452
502
  const chainText = renderChain(chain);
453
503
  parts.push(
454
504
  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.]',
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>"`.]',
457
507
  );
458
508
  if (walks.length > 0) {
459
509
  parts.push("[walk-status — derived from the ledgers; render this state, never your own memory of it]");
@@ -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
@@ -78,10 +121,20 @@ if (asHook) {
78
121
  // The walk's vocabulary (walk-legibility L3): this gate IS the Prove
79
122
  // stage refusing to close — same fact, same enforcement, words that match
80
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.";
81
136
  process.stderr.write(
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`,
137
+ `■ Prove — not done: the promises are not yet checked against this tree. ${result.reason}. ${act}\n`,
85
138
  );
86
139
  process.exit(2);
87
140
  }