create-cmp-cli 0.18.0 → 0.20.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 (42) 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 +30 -2
  5. package/packages/harness/src/lib/approvals.mjs +74 -10
  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 +71 -3
  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 +56 -1
  13. package/packages/harness/src/lib/spec-coverage.mjs +111 -3
  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 +1284 -0
  17. package/packages/harness/src/lib/walk.mjs +67 -17
  18. package/packages/harness/src/receipt-check.mjs +80 -4
  19. package/packages/harness/src/verify.mjs +119 -1197
  20. package/packages/receipts/src/index.mjs +1 -0
  21. package/packages/receipts/src/inputs-hash.mjs +71 -3
  22. package/packages/receipts/src/receipt-validate.mjs +56 -1
  23. package/template/CLAUDE.md +52 -6
  24. package/template/composeApp/src/desktopTest/kotlin/com/example/app/conformance/ArchitectureConformanceTest.kt +1 -1
  25. package/template/gitignore +3 -0
  26. package/template/qa/approve.mjs +30 -2
  27. package/template/qa/lib/approvals.mjs +74 -10
  28. package/template/qa/lib/evidence-level.mjs +3 -1
  29. package/template/qa/lib/flight-recorder.mjs +47 -2
  30. package/template/qa/lib/inputs-hash.mjs +71 -3
  31. package/template/qa/lib/lane-narrator.mjs +97 -0
  32. package/template/qa/lib/lane-runner.mjs +173 -0
  33. package/template/qa/lib/plan.mjs +286 -20
  34. package/template/qa/lib/receipt-validate.mjs +56 -1
  35. package/template/qa/lib/spec-coverage.mjs +111 -3
  36. package/template/qa/lib/step-cache.mjs +1 -1
  37. package/template/qa/lib/step-outcomes.mjs +123 -0
  38. package/template/qa/lib/steps-cmp.mjs +1284 -0
  39. package/template/qa/lib/walk.mjs +67 -17
  40. package/template/qa/receipt-check.mjs +80 -4
  41. package/template/qa/verify.mjs +119 -1197
  42. package/template/specs/README.md +26 -0
@@ -33,6 +33,13 @@ import path from "node:path";
33
33
 
34
34
  export const PLAN_REL = "qa/.plan.json";
35
35
  export const REQUEST_REL = "qa/.request.json";
36
+ // N5 (docs/features/drive-narration.md): closed chains leave a LOCAL trail —
37
+ // request, steps, wall time, receipt state at close. Gitignored and excluded
38
+ // from the hashed input surface like its siblings above, and deliberately NOT
39
+ // a committed journal: it carries raw human prompts. Lane history that
40
+ // belongs in the repo stays the flight recorder's.
41
+ export const PLAN_HISTORY_REL = "qa/.plan-history.jsonl";
42
+ const MAX_HISTORY_LINES = 50;
36
43
 
37
44
  // A marker older than this is a crashed writer, not a live run — the same
38
45
  // bound qa/watch.mjs and the preview daemon apply to the same files.
@@ -91,12 +98,17 @@ export function setPlan(root, { title, feature, steps } = {}) {
91
98
  .slice(0, MAX_STEPS)
92
99
  .map((s) => (s.length > MAX_LABEL_CHARS ? `${s.slice(0, MAX_LABEL_CHARS - 1)}…` : s));
93
100
  if (labels.length === 0) return { ok: false, reason: "a chain needs at least one step" };
101
+ // N1: the declaration's own write times ARE the timing data — createdAt for
102
+ // the whole chain, startedAt on step 1. No new claims, just timestamps the
103
+ // writes already imply; renderers derive durations from them.
104
+ const now = new Date().toISOString();
94
105
  return writeJson(path.join(root, PLAN_REL), {
95
106
  title: typeof title === "string" && title.trim() !== "" ? title.trim() : null,
96
107
  feature: typeof feature === "string" && feature.trim() !== "" ? feature.trim() : null,
97
- steps: labels.map((label, i) => ({ n: i + 1, label, done: false })),
108
+ steps: labels.map((label, i) => ({ n: i + 1, label, done: false, ...(i === 0 ? { startedAt: now } : {}) })),
98
109
  current: 1,
99
- updatedAt: new Date().toISOString(),
110
+ createdAt: now,
111
+ updatedAt: now,
100
112
  });
101
113
  }
102
114
 
@@ -112,10 +124,90 @@ export function markStep(root, n) {
112
124
  const step = Number(n);
113
125
  if (!Number.isInteger(step) || step < 1 || step > plan.steps.length + 1)
114
126
  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;
127
+ const now = new Date().toISOString();
128
+ for (const s of plan.steps) {
129
+ const willBeDone = s.n < step;
130
+ // N1: stamp doneAt the first time a step closes and startedAt the first
131
+ // time it becomes current — first-write-wins, so re-marking never
132
+ // rewrites history.
133
+ if (willBeDone && !s.done && !s.doneAt) s.doneAt = now;
134
+ s.done = willBeDone;
135
+ if (s.n === step && !s.startedAt) s.startedAt = now;
136
+ }
137
+ const closing = step > plan.steps.length && !plan.closedAt;
116
138
  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" };
139
+ if (closing) plan.closedAt = now;
140
+ plan.updatedAt = now;
141
+ if (!writeJson(path.join(root, PLAN_REL), plan).ok) return { ok: false, reason: "could not write the chain" };
142
+ // N5: the FIRST close leaves the trail entry; a re-close of an already
143
+ // closed chain never double-writes. Fail-soft — a trail that cannot be
144
+ // written must not fail the advance that was asked for.
145
+ if (closing) appendPlanHistory(root, plan);
146
+ return { ok: true, plan };
147
+ }
148
+
149
+ /** The receipt's verdict + rung right now, for the trail — fail-soft glance. */
150
+ function receiptGlance(root) {
151
+ try {
152
+ const r = JSON.parse(fs.readFileSync(path.join(root, "qa/evidence/latest.json"), "utf8"));
153
+ return { verdict: r?.verdict ?? null, rung: r?.evidenceLevel?.rung ?? null };
154
+ } catch {
155
+ return null;
156
+ }
157
+ }
158
+
159
+ function appendPlanHistory(root, plan) {
160
+ try {
161
+ const started = Date.parse(plan.createdAt ?? "");
162
+ const closed = Date.parse(plan.closedAt ?? "");
163
+ const entry = {
164
+ schema: "cmp-plan-history/1",
165
+ at: plan.closedAt ?? new Date().toISOString(),
166
+ request: readRequest(root)?.text ?? null,
167
+ title: plan.title ?? null,
168
+ feature: plan.feature ?? null,
169
+ steps: plan.steps.map((s) => s.label),
170
+ durationMs: Number.isNaN(started) || Number.isNaN(closed) ? null : Math.max(0, closed - started),
171
+ receipt: receiptGlance(root),
172
+ };
173
+ const p = path.join(root, PLAN_HISTORY_REL);
174
+ let lines = [];
175
+ try {
176
+ lines = fs.readFileSync(p, "utf8").split("\n").filter((l) => l.trim() !== "");
177
+ } catch {
178
+ /* first entry */
179
+ }
180
+ lines.push(JSON.stringify(entry));
181
+ fs.writeFileSync(p, `${lines.slice(-MAX_HISTORY_LINES).join("\n")}\n`);
182
+ return { ok: true };
183
+ } catch (err) {
184
+ return { ok: false, reason: err?.message ?? String(err) };
185
+ }
186
+ }
187
+
188
+ /**
189
+ * The last `limit` closed chains, NEWEST FIRST — Drive's "Recent requests"
190
+ * fold. Absent trail or unparsable lines read as an empty/shorter list,
191
+ * never an error.
192
+ * @returns {object[]}
193
+ */
194
+ export function readPlanHistory(root, limit = 5) {
195
+ try {
196
+ const raw = fs.readFileSync(path.join(root, PLAN_HISTORY_REL), "utf8");
197
+ const out = [];
198
+ for (const line of raw.split("\n")) {
199
+ if (!line.trim()) continue;
200
+ try {
201
+ const e = JSON.parse(line);
202
+ if (e && typeof e === "object") out.push(e);
203
+ } catch {
204
+ /* skip the line, keep the trail */
205
+ }
206
+ }
207
+ return out.slice(-Math.max(0, limit)).reverse();
208
+ } catch {
209
+ return [];
210
+ }
119
211
  }
120
212
 
121
213
  /** @returns {object|null} the declared chain, or null. */
@@ -134,33 +226,130 @@ export function clearPlan(root) {
134
226
  }
135
227
  }
136
228
 
137
- function markerFresh(root, name) {
229
+ /**
230
+ * A marker read WITH its content (N2, docs/features/drive-narration.md):
231
+ * every other marker consumer is mtime-only, so the content is free to carry
232
+ * the lane's own narration — verify.mjs rewrites the lane marker at each
233
+ * step start with {step, index, total, stepStartedAt, expectedStepMs,
234
+ * expectedLaneMs}. Legacy "pid iso" content (older lanes, the render marker)
235
+ * reads as a bare truthy {} — busy, no narration. Stale/absent -> false.
236
+ * @returns {object|false}
237
+ */
238
+ function markerInfo(root, name) {
239
+ const p = path.join(root, "composeApp", "build", name);
138
240
  try {
139
- const st = fs.statSync(path.join(root, "composeApp", "build", name));
140
- return Date.now() - st.mtimeMs < MARKER_FRESH_MS;
241
+ const st = fs.statSync(p);
242
+ if (Date.now() - st.mtimeMs >= MARKER_FRESH_MS) return false;
243
+ const raw = fs.readFileSync(p, "utf8").trim();
244
+ if (raw.startsWith("{")) {
245
+ try {
246
+ const parsed = JSON.parse(raw);
247
+ return parsed && typeof parsed === "object" ? parsed : {};
248
+ } catch {
249
+ return {};
250
+ }
251
+ }
252
+ return {};
141
253
  } catch {
142
254
  return false;
143
255
  }
144
256
  }
145
257
 
258
+ // The build stage's observed tier (evidence-economics S3). Between the prompt
259
+ // and the lane — most of the working time — the chain moved only if the agent
260
+ // volunteered `plan.mjs --step`, so an undeclared chain was a still photo until
261
+ // the lane landed. The lane marker already corroborates the lane mechanically;
262
+ // this corroborates the build stage the same way: writes in the working tree
263
+ // since the current request began. No agent cooperation required — which is
264
+ // the point.
265
+ const ACTIVITY_ROOTS = ["composeApp/src", "specs", "qa", "docs"];
266
+ const ACTIVITY_SKIP_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules", "evidence"]);
267
+ // Machinery, not work: the chain's own files and the lane's outputs must not
268
+ // count as "the agent wrote something", or the pulse would corroborate itself.
269
+ const ACTIVITY_SKIP_FILES = new Set([".plan.json", ".request.json", ".plan-history.jsonl", "flight-recorder.jsonl", "approvals.log.jsonl", ".DS_Store"]);
270
+ // Nothing written for this long, with no lane or render running, is a stall
271
+ // worth naming — the human is watching a strip that has stopped moving.
272
+ export const ACTIVITY_STALL_MS = 10 * 60 * 1000;
273
+
274
+ /**
275
+ * Files written under the working roots since `sinceIso` — the request stamp.
276
+ * Pure filesystem, no git: a scaffold before `git init` still answers, and a
277
+ * mtime is a fact regardless of what is staged.
278
+ * @param {string} root
279
+ * @param {string|null|undefined} sinceIso the request's `at`
280
+ * @param {{now?: number}} [opts]
281
+ * @returns {{filesChanged: number, lastWriteAgoMs: (number|null), since: string}|null}
282
+ * null when there is no request to measure from
283
+ */
284
+ export function observeActivity(root, sinceIso, { now = Date.now() } = {}) {
285
+ const since = Date.parse(sinceIso ?? "");
286
+ if (Number.isNaN(since)) return null;
287
+ let filesChanged = 0;
288
+ let newest = -Infinity;
289
+ const walk = (dir) => {
290
+ let entries;
291
+ try {
292
+ entries = fs.readdirSync(dir, { withFileTypes: true });
293
+ } catch {
294
+ return;
295
+ }
296
+ for (const e of entries) {
297
+ if (e.isDirectory()) {
298
+ if (!ACTIVITY_SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name));
299
+ continue;
300
+ }
301
+ if (!e.isFile() || ACTIVITY_SKIP_FILES.has(e.name)) continue;
302
+ let m;
303
+ try {
304
+ m = fs.statSync(path.join(dir, e.name)).mtimeMs;
305
+ } catch {
306
+ continue;
307
+ }
308
+ if (m > since) {
309
+ filesChanged += 1;
310
+ if (m > newest) newest = m;
311
+ }
312
+ }
313
+ };
314
+ for (const rel of ACTIVITY_ROOTS) walk(path.join(root, rel));
315
+ return {
316
+ filesChanged,
317
+ lastWriteAgoMs: filesChanged > 0 ? Math.max(0, now - newest) : null,
318
+ since: new Date(since).toISOString(),
319
+ };
320
+ }
321
+
146
322
  /**
147
323
  * Everything a chain-rendering surface needs, with provenance attached:
148
324
  * 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).
325
+ * (tier 3 — the markers the lane and preview daemon already stamp, the lane's
326
+ * now carrying its own step narration) + the local trail of closed chains
327
+ * (N5). `busy.lane`/`busy.render` are truthy objects while fresh — existing
328
+ * truthiness consumers keep working unchanged.
150
329
  * @returns {{request: (object|null), plan: (object|null), planAgeMs: (number|null),
151
- * busy: {lane: boolean, render: boolean}}}
330
+ * busy: {lane: (object|false), render: (object|false)}, history: object[]}}
152
331
  */
153
332
  export function deriveChain(root) {
154
333
  const plan = readPlan(root);
155
334
  const at = plan ? Date.parse(plan.updatedAt) : NaN;
335
+ const busy = {
336
+ lane: markerInfo(root, ".cmp-lane-in-progress"),
337
+ render: markerInfo(root, ".cmp-render-in-progress"),
338
+ };
339
+ const request = readRequest(root);
340
+ // S3: the build stage's observed tier — writes since the request began.
341
+ const activity = observeActivity(root, request ? request.at : null);
156
342
  return {
157
- request: readRequest(root),
343
+ request,
158
344
  plan,
159
345
  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
- },
346
+ busy,
347
+ activity,
348
+ // Pre-rendered so every surface (chat, CLI, studio) speaks the observed
349
+ // tier in identical words — the console renders this string, never its
350
+ // own paraphrase of the marker.
351
+ busyText: describeBusy(busy, Date.now(), activity),
352
+ history: readPlanHistory(root, 5),
164
353
  };
165
354
  }
166
355
 
@@ -172,29 +361,106 @@ export function formatAge(ms) {
172
361
  return `${Math.round(ms / 3600000)}h ago`;
173
362
  }
174
363
 
364
+ /** "12s" / "~3 min" — a plain duration (formatAge's sibling, no "ago"). */
365
+ export function formatDuration(ms) {
366
+ if (!(ms >= 0)) return "";
367
+ if (ms < 120000) return `${Math.max(1, Math.round(ms / 1000))}s`;
368
+ return `~${Math.round(ms / 60000)} min`;
369
+ }
370
+
371
+ /** A done step's wall time from its own N1 stamps, or null pre-N1. */
372
+ function stepDurationMs(s) {
373
+ const a = Date.parse(s.startedAt ?? "");
374
+ const b = Date.parse(s.doneAt ?? "");
375
+ return Number.isNaN(a) || Number.isNaN(b) ? null : Math.max(0, b - a);
376
+ }
377
+
378
+ /**
379
+ * The tier-3 corroboration as one phrase (N2): the lane's own narration when
380
+ * the marker carries it ("full check — unitTests (10/16) · 12s of ~3s,
381
+ * usually ~52s total"), the legacy phrase when it does not. "" when nothing
382
+ * is running. Shared by the text and HTML renderers so the observed tier
383
+ * speaks identically everywhere.
384
+ */
385
+ export function describeBusy(busy, now = Date.now(), activity = null) {
386
+ if (!busy) return describeActivity(activity);
387
+ const lane = busy.lane;
388
+ if (lane) {
389
+ if (typeof lane === "object" && typeof lane.step === "string" && lane.step !== "") {
390
+ const pos = Number.isInteger(lane.index) && Number.isInteger(lane.total) ? ` (${lane.index}/${lane.total})` : "";
391
+ const started = Date.parse(lane.stepStartedAt ?? "");
392
+ const elapsed = Number.isNaN(started) ? null : Math.max(0, now - started);
393
+ const stepExpect = typeof lane.expectedStepMs === "number" && lane.expectedStepMs > 0 ? ` of ~${formatDuration(lane.expectedStepMs)}` : "";
394
+ const laneExpect =
395
+ typeof lane.expectedLaneMs === "number" && lane.expectedLaneMs > 0 ? `, usually ${formatDuration(lane.expectedLaneMs)} total` : "";
396
+ return `full check — ${lane.step}${pos}${elapsed !== null ? ` · ${formatDuration(elapsed)}${stepExpect}` : ""}${laneExpect}`;
397
+ }
398
+ return "the full check is running NOW";
399
+ }
400
+ if (busy.render) return "a preview render is in flight";
401
+ // Nothing mechanical is running — but the working tree may still be moving.
402
+ return describeActivity(activity);
403
+ }
404
+
405
+ /**
406
+ * The build stage's phrase (S3). Files written since the request, and how
407
+ * long ago the last one landed; a stall when the tree has stopped moving.
408
+ * "" when there is no request to measure from, or nothing has happened yet.
409
+ */
410
+ export function describeActivity(activity) {
411
+ if (!activity) return "";
412
+ if (activity.filesChanged === 0) return "";
413
+ const n = activity.filesChanged;
414
+ const ago = activity.lastWriteAgoMs;
415
+ const files = `${n} file${n === 1 ? "" : "s"} written since the request`;
416
+ if (typeof ago === "number" && ago >= ACTIVITY_STALL_MS) return `${files} · stalled — nothing written for ${formatDuration(ago)}`;
417
+ return typeof ago === "number" ? `${files} · last ${formatDuration(ago)} ago` : files;
418
+ }
419
+
175
420
  /**
176
421
  * 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).
422
+ * Numbered steps: done with wall time, current ◉ with elapsed, pending
423
+ * (N1); the tier-3 corroboration is prefixed "observed:" so the machine's
424
+ * word is visibly distinct from the agent's declaration (N3). "" when
425
+ * nothing is declared AND no request is recorded (silence, never an empty
426
+ * frame).
180
427
  */
181
428
  export function renderChain(chain) {
182
429
  if (!chain || (!chain.plan && !chain.request)) return "";
430
+ const now = Date.now();
183
431
  const lines = [];
184
432
  const title = chain.plan?.title ?? chain.request?.text ?? null;
185
433
  if (title) lines.push(`Request: ${title}`);
186
434
  if (chain.plan) {
187
435
  const p = chain.plan;
188
436
  const seq = p.steps
189
- .map((s) => `${s.done ? "✓" : s.n === p.current ? "◉" : "○"} ${s.n}. ${s.label}`)
437
+ .map((s) => {
438
+ if (s.done) {
439
+ const d = stepDurationMs(s);
440
+ return `✓ ${s.n}. ${s.label}${d !== null ? ` (${formatDuration(d)})` : ""}`;
441
+ }
442
+ if (s.n === p.current) {
443
+ const a = Date.parse(s.startedAt ?? "");
444
+ return `◉ ${s.n}. ${s.label}${Number.isNaN(a) ? "" : ` · ${formatDuration(Math.max(0, now - a))} in`}`;
445
+ }
446
+ return `○ ${s.n}. ${s.label}`;
447
+ })
190
448
  .join(" → ");
191
449
  lines.push(seq);
192
450
  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" : "";
451
+ const busyText = typeof chain.busyText === "string" ? chain.busyText : describeBusy(chain.busy, now, chain.activity ?? null);
452
+ const busy = busyText !== "" ? ` · observed: ${busyText}` : "";
194
453
  const age = chain.planAgeMs !== null ? ` · declared by the agent, updated ${formatAge(chain.planAgeMs)}` : "";
195
454
  lines.push(cur ? `now: step ${cur.n} of ${p.steps.length} — ${cur.label}${busy}${age}` : `chain complete${busy}${age}`);
196
455
  } else {
197
456
  lines.push("(no declared chain for this request yet — node qa/plan.mjs --set \"step | step | …\")");
457
+ // S3: an undeclared chain is no longer a still photo. The observed tier —
458
+ // a running lane, a render, or writes since the request — is printed even
459
+ // when the agent declared nothing, because it is the machine's word and
460
+ // needs no declaration to exist. This is the case that used to show nothing
461
+ // for forty minutes.
462
+ const observed = typeof chain.busyText === "string" ? chain.busyText : describeBusy(chain.busy, Date.now(), chain.activity ?? null);
463
+ if (observed !== "") lines.push(`observed: ${observed}`);
198
464
  }
199
465
  return lines.join("\n");
200
466
  }
@@ -45,6 +45,51 @@ export function readReceipt(root, relPath = RECEIPT_REL_PATH) {
45
45
  * FAIL verdict), so callers don't pay for a hash they don't need.
46
46
  * @returns {{valid: boolean, reason: string, profile: (string|undefined), recomputed?: {hash: string, fileCount: number}}}
47
47
  */
48
+ /**
49
+ * Does this receipt's own row-level evidence support its PASS?
50
+ *
51
+ * The receipt is necessarily excluded from the inputs hash it carries — a file
52
+ * cannot hash itself — so steps[] is the only thing between this gate and a text
53
+ * editor, and the top-level verdict is the most editable field on it.
54
+ *
55
+ * Two failures this catches, both observed downstream (payment-blueprint F2/F3):
56
+ * a receipt whose verdict was hand-edited from FAIL to PASS while its rows still
57
+ * said otherwise, and a lane made green by DELETING harness.lock.json, which
58
+ * downgraded harnessIntegrity from FAIL to SKIP and took the lane's verdict with
59
+ * it — a lane vouching for a tree with nothing vouching for the lane.
60
+ *
61
+ * @param {{verdict?: string, steps?: Array<{name?: string, verdict?: string}>}} receipt
62
+ * @returns {{ok: boolean, detail: string}}
63
+ */
64
+ export function checkLaneVouching(receipt) {
65
+ const steps = Array.isArray(receipt?.steps) ? receipt.steps : null;
66
+ if (!steps || steps.length === 0) {
67
+ return { ok: false, detail: "receipt lists no verify-lane steps — a PASS over nothing attests nothing" };
68
+ }
69
+ const failed = steps.filter((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR"));
70
+ if (failed.length > 0) {
71
+ const names = failed.map((s) => `${s.name ?? "?"} (${s.verdict})`).join(", ");
72
+ return {
73
+ ok: false,
74
+ detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
75
+ };
76
+ }
77
+ const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
78
+ if (!integrity) {
79
+ return {
80
+ ok: false,
81
+ detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
82
+ };
83
+ }
84
+ if (integrity.verdict !== "PASS") {
85
+ return {
86
+ ok: false,
87
+ detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
88
+ };
89
+ }
90
+ return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
91
+ }
92
+
48
93
  export function evaluateReceipt(receipt, recompute) {
49
94
  const profile = receipt.profile;
50
95
 
@@ -83,6 +128,13 @@ export function evaluateReceipt(receipt, recompute) {
83
128
  };
84
129
  }
85
130
 
131
+ // Did the lane vouch for ITSELF? See checkLaneVouching — the top-level verdict
132
+ // is the most editable field on a file the hash cannot cover.
133
+ const vouching = checkLaneVouching(receipt);
134
+ if (!vouching.ok) {
135
+ return { valid: false, reason: `${vouching.detail} (attesting profile: ${profile ?? "unknown"})`, profile, recomputed };
136
+ }
137
+
86
138
  return { valid: true, reason: `receipt is valid — PASS, attesting profile: ${profile ?? "unknown"}`, profile, recomputed };
87
139
  }
88
140
 
@@ -134,7 +186,10 @@ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_PO
134
186
  if (!steps || steps.length === 0) {
135
187
  return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
136
188
  }
137
- const executed = steps.filter((s) => s && s.verdict !== "SKIP");
189
+ // Executed = produced a verdict about the tree. SKIP did not try; ERROR
190
+ // tried and could not (a deadline, zero tests, a throw) — neither measured
191
+ // anything, so neither counts toward "this lane verified something".
192
+ const executed = steps.filter((s) => s && s.verdict !== "SKIP" && s.verdict !== "ERROR");
138
193
  if (executed.length === 0) {
139
194
  return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
140
195
  }
@@ -14,8 +14,94 @@ import path from "node:path";
14
14
 
15
15
  /** `- **HOME-01** — …` (live) or `- ~~**HOME-01**~~ — …` (withdrawn). */
16
16
  export const CLAUSE_LINE_RE = /^-\s+(~~)?\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/;
17
+ // An OPTIONAL tier requirement on the clause line itself:
18
+ //
19
+ // - **MOTION-13** [tier: device] — Given a cold start, When … Then …
20
+ //
21
+ // The clause declares what it takes to OBSERVE it, which is a property of the
22
+ // promise, not of whatever test happened to cite it. Note this attaches to the
23
+ // clause line, not to `[enforced: …]` — that tags docs/ARCHITECTURE.md prose and
24
+ // is a different grammar entirely.
25
+ const CLAUSE_TIER_RE = /\[tier:\s*(device|e2e)\]/i;
26
+ /** Which citing tiers satisfy a declared requirement. */
27
+ export const TIERS_SATISFYING = Object.freeze({
28
+ device: ["androidInstrumentedTest", "e2e"],
29
+ e2e: ["e2e"],
30
+ });
17
31
 
18
32
  const TAG_LINE_RE = /^(?:\/\/|#)\s*SPEC:/;
33
+
34
+ // A citation is a claim that a TEST covers a clause, so it has to sit on one.
35
+ // Counting the tag wherever it appears makes a red specCoverage curable with a
36
+ // comment and zero assertions — the one escape this gate exists to close. It is
37
+ // not hypothetical: payment-blueprint hit a citation that had drifted onto a
38
+ // class declaration, where it counted for the whole file while testing nothing.
39
+ //
40
+ // So a tag counts only when a test declaration follows it within
41
+ // BINDING_WINDOW non-blank lines. The window is small enough that the tag must
42
+ // be attached to the test, and loose enough for the @DisplayName / annotation
43
+ // stack that idiomatically sits between them.
44
+ export const BINDING_WINDOW = 5;
45
+
46
+ // Kotlin @Test, a backticked test function, and the node:test / Maestro-adjacent
47
+ // `test(` / `it(` call forms. Deliberately syntactic: a citation's binding must
48
+ // be readable without compiling anything.
49
+ const TEST_DECL_RE = /@Test\b|\bfun\s+`[^`]+`\s*\(|\b(?:test|it)\s*\(/;
50
+
51
+ // A tag whose first meaningful line declares a TYPE is documenting that type,
52
+ // not claiming a test — and it must be refused structurally rather than by
53
+ // distance, because a short class body puts a real @Test inside the window and
54
+ // would otherwise launder the citation. This is exactly payment-blueprint's
55
+ // drift: `// SPEC: PP-07` sat on `class PaymentWorkerTest`, three properties
56
+ // above a genuine @Test, and vouched for the whole file.
57
+ const TYPE_DECL_RE = /^(?:@\w+\s+)*(?:public\s+|internal\s+|private\s+|abstract\s+|open\s+|sealed\s+|data\s+|enum\s+)*(?:class|object|interface)\b/;
58
+
59
+ // A YAML flow's own shape counts as its test: a Maestro file IS the test, so a
60
+ // tag in one binds to the flow rather than to a declaration inside it.
61
+ const FLOW_EXTS = [".yaml", ".yml"];
62
+
63
+ /**
64
+ * Does a test declaration follow `index` within BINDING_WINDOW non-blank lines,
65
+ * skipping block-comment bodies (a tag inside one is documentation, not a claim)?
66
+ * @param {string[]} lines
67
+ * @param {number} index line the tag sits on
68
+ * @returns {boolean}
69
+ */
70
+ export function citationIsBound(lines, index) {
71
+ let seen = 0;
72
+ let inBlockComment = false;
73
+ for (let i = index + 1; i < lines.length && seen < BINDING_WINDOW; i += 1) {
74
+ const line = lines[i].trim();
75
+ if (line === "") continue;
76
+ if (inBlockComment) {
77
+ if (line.includes("*/")) inBlockComment = false;
78
+ continue;
79
+ }
80
+ if (line.startsWith("/*")) {
81
+ if (!line.includes("*/")) inBlockComment = true;
82
+ continue;
83
+ }
84
+ if (line.startsWith("//") || line.startsWith("*")) continue;
85
+ seen += 1;
86
+ // The FIRST meaningful line decides whether this tag is on a test at all.
87
+ if (seen === 1 && TYPE_DECL_RE.test(line)) return false;
88
+ if (TEST_DECL_RE.test(line)) return true;
89
+ }
90
+ return false;
91
+ }
92
+
93
+ /** Is this tag inside a block comment that began earlier in the file? */
94
+ function insideBlockComment(lines, index) {
95
+ let open = false;
96
+ for (let i = 0; i < index; i += 1) {
97
+ const line = lines[i];
98
+ for (let c = 0; c < line.length - 1; c += 1) {
99
+ if (!open && line[c] === "/" && line[c + 1] === "*") open = true;
100
+ else if (open && line[c] === "*" && line[c + 1] === "/") open = false;
101
+ }
102
+ }
103
+ return open;
104
+ }
19
105
  const TAG_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
20
106
  const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
21
107
 
@@ -45,7 +131,12 @@ export function scanSpecClauses(root) {
45
131
  for (const line of fs.readFileSync(abs, "utf8").split("\n")) {
46
132
  const m = line.match(CLAUSE_LINE_RE);
47
133
  if (!m) continue;
48
- clauses.set(m[2], { file: path.relative(root, abs), withdrawn: Boolean(m[1]) });
134
+ const tierMatch = line.match(CLAUSE_TIER_RE);
135
+ clauses.set(m[2], {
136
+ file: path.relative(root, abs),
137
+ withdrawn: Boolean(m[1]),
138
+ requiredTier: tierMatch ? tierMatch[1].toLowerCase() : null,
139
+ });
49
140
  }
50
141
  }
51
142
  return clauses;
@@ -86,11 +177,14 @@ export function scanCitations(root) {
86
177
  const tier = tierForFile(rel);
87
178
  fs.readFileSync(f, "utf8")
88
179
  .split("\n")
89
- .forEach((line, i) => {
180
+ .forEach((line, i, lines) => {
90
181
  const trimmed = line.trim();
91
182
  if (!TAG_LINE_RE.test(trimmed)) return;
92
183
  const m = trimmed.match(TAG_IDS_RE);
93
184
  if (!m) return;
185
+ // A flow file IS its test; anything else must have a test under the tag.
186
+ const isFlow = FLOW_EXTS.some((ext) => rel.endsWith(ext));
187
+ if (!isFlow && (insideBlockComment(lines, i) || !citationIsBound(lines, i))) return;
94
188
  const ids = m[1]
95
189
  .split(/[,\s]+/)
96
190
  .map((s) => s.trim())
@@ -108,6 +202,9 @@ export function scanCitations(root) {
108
202
  * (commonTest/desktopTest) — behavior claims no device-tier evidence backs.
109
203
  * `summaryLine` is the one line the lane's specCoverage step (and any other
110
204
  * consumer) can print verbatim; null when nothing is desktop-only.
205
+ * `unmetTier` is the PRESCRIPTIVE half — clauses that declared `[tier: …]` and
206
+ * have no citation from a tier that could observe them. specCoverage FAILS on it:
207
+ * "instrument before you police" was the right first move, and this is the second.
111
208
  * @param {Map<string, {file: string, withdrawn: boolean}>} clauses from scanSpecClauses
112
209
  * @param {Array<{id: string, tier: string}>} tags from scanCitations
113
210
  * @returns {{tiersByClause: Record<string, string[]>, desktopOnly: string[], summaryLine: string|null}}
@@ -117,6 +214,17 @@ export function clauseTierCoverage(clauses, tags) {
117
214
  for (const t of tags) {
118
215
  (tiersByClause[t.id] ??= []).includes(t.tier) || tiersByClause[t.id].push(t.tier);
119
216
  }
217
+ // The gate input. A clause that DECLARED the tier it needs and has no citation
218
+ // from that tier is not covered — it is cited by tests structurally incapable
219
+ // of observing it, which is the exact hole `desktopOnly` below could only ever
220
+ // describe. MOTION-13 promised an animation "plays once per process start" and
221
+ // was cited by a desktop Compose test, a tier with no process lifecycle at all:
222
+ // the citation existed, the gate went green, and nothing ever observed the
223
+ // promise. Declared requirements are checked; undeclared clauses are unchanged.
224
+ const unmetTier = [...clauses.entries()]
225
+ .filter(([, c]) => !c.withdrawn && c.requiredTier)
226
+ .map(([id, c]) => ({ id, requiredTier: c.requiredTier, tiers: tiersByClause[id] ?? [], file: c.file }))
227
+ .filter((u) => !(TIERS_SATISFYING[u.requiredTier] ?? []).some((t) => u.tiers.includes(t)));
120
228
  const desktopOnly = [...clauses.entries()]
121
229
  .filter(([, c]) => !c.withdrawn)
122
230
  .map(([id]) => id)
@@ -127,5 +235,5 @@ export function clauseTierCoverage(clauses, tags) {
127
235
  const summaryLine = desktopOnly.length
128
236
  ? `${desktopOnly.length} clause${desktopOnly.length === 1 ? "" : "s"} cited only from desktop-tier tests (${desktopOnly.join(", ")})`
129
237
  : null;
130
- return { tiersByClause, desktopOnly, summaryLine };
238
+ return { tiersByClause, desktopOnly, unmetTier, summaryLine };
131
239
  }
@@ -138,7 +138,7 @@ export function loadStepCache(root) {
138
138
  export function lookupCachedPass(root, stepName, inputsHash) {
139
139
  const entry = loadStepCache(root).steps[stepName];
140
140
  if (!entry || typeof entry !== "object") return null;
141
- if (entry.verdict !== "PASS") return null; // FAIL/SKIP are never reused
141
+ if (entry.verdict !== "PASS") return null; // FAIL/SKIP/ERROR are never reused
142
142
  if (typeof inputsHash !== "string" || entry.inputsHash !== inputsHash) return null;
143
143
  if (typeof entry.at !== "string") return null;
144
144
  return entry;