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
@@ -57,6 +57,7 @@ export const VERIFIED_SURFACE = [
57
57
  const EXCLUDED_PREFIXES = [
58
58
  "qa/.plan.json",
59
59
  "qa/.request.json",
60
+ "qa/.plan-history.jsonl",
60
61
  "qa/evidence",
61
62
  "qa-artifacts",
62
63
  "qa/comments.json",
@@ -134,7 +134,10 @@ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_PO
134
134
  if (!steps || steps.length === 0) {
135
135
  return { ok: false, detail: "receipt lists no verify-lane steps — nothing was executed" };
136
136
  }
137
- const executed = steps.filter((s) => s && s.verdict !== "SKIP");
137
+ // Executed = produced a verdict about the tree. SKIP did not try; ERROR
138
+ // tried and could not (a deadline, zero tests, a throw) — neither measured
139
+ // anything, so neither counts toward "this lane verified something".
140
+ const executed = steps.filter((s) => s && s.verdict !== "SKIP" && s.verdict !== "ERROR");
138
141
  if (executed.length === 0) {
139
142
  return { ok: false, detail: "every step in the receipt is a SKIP — the lane verified nothing" };
140
143
  }
@@ -38,6 +38,13 @@ it; CI still enforces it).
38
38
  New behavior begins as a spec clause in `specs/<feature>.spec.md`: Given/When/Then with a
39
39
  stable id (see [`specs/README.md`](./specs/README.md)). Propose the clause, get it confirmed,
40
40
  then implement. Durable tests cite their clause (`// SPEC: HOME-02`).
41
+
42
+ **A clause about device behavior must say so.** A citation proves a test *exists*; it cannot
43
+ prove that test could ever *observe* the promise. Add `[tier: device]` (or `[tier: e2e]`)
44
+ after the id when the claim is about OS facts a host JVM cannot see — lifecycle, alarms,
45
+ notifications, permissions, real navigation. `specCoverage` then requires a citation from
46
+ `androidInstrumentedTest` or `qa/e2e` and FAILS without one, rather than accepting a
47
+ desktop test that is structurally blind to the claim.
41
48
  [`specs/app-base.spec.md`](./specs/app-base.spec.md) states the architecture and shell
42
49
  invariants the conformance gates enforce.
43
50
 
@@ -90,8 +97,15 @@ the tree. The governed `architecture` artifact (below) hashes the document along
90
97
  if the test itself is wrong, say so in your summary and justify the change.
91
98
 
92
99
  **Platform behavior tests live in `composeApp/src/androidInstrumentedTest`** — when a
93
- feature touches alarms, notifications, lock-screen intents, or audio routing, its behavior
94
- test goes there, because no desktop tier can see those OS facts. Assertion helpers:
100
+ feature touches alarms, notifications, lock-screen intents, audio routing, **or app/process
101
+ lifecycle** (cold start vs warm resume, "once per process start", process death and
102
+ restore, `ON_STOP`/`ON_START`), its behavior test goes there, because no desktop tier can
103
+ see those OS facts. A desktop Compose test has no process lifecycle *at all*, so a claim
104
+ about one is unobservable there by construction — and `ProcessControl` below is the organ
105
+ that puts the device into the state such a claim is about. **Declare it on the clause**:
106
+ `- **MOTION-13** [tier: device] — Given a cold start, …`. The lane's `specCoverage` then
107
+ FAILS unless a test from a tier that can actually see it cites the clause, instead of
108
+ accepting a citation from a tier that cannot. Assertion helpers:
95
109
  `NotificationAsserts`, `AlarmAsserts`, `SystemState`. **Runtime state control** — put the
96
110
  device into the state your claim is about, instead of waiting for it: `TimeWarp` (clock,
97
111
  timezone), `DozeControl` (forced idle), `PermissionControl`, `ProcessControl`,
@@ -198,6 +212,20 @@ understood the change to be, which lane it takes, and why, before any tool runs.
198
212
  can overrule the lane in a word; a silent route is a routing error even when the lane was
199
213
  right.
200
214
 
215
+ **Grill before the brief** (the `grill-me` plugin skill; the rule holds without the plugin):
216
+ on the brief lane, after the triage restatement and before a word of the brief is drafted,
217
+ settle the load-bearing questions. Read what the repo already answers first — a signed brief
218
+ or spec is a CLOSED decision: cite it, never re-ask it. Then ask the frontier of unsettled
219
+ decisions as a numbered list, at most five per round, each with why it matters and a
220
+ recommended answer — and WAIT for the answers before anything else. Stop when no remaining
221
+ question would change the work; three rounds is the ceiling (more means the request needs
222
+ splitting). Answers land in the brief — settled calls become **Decisions** with their why,
223
+ the human's own calls the **Open decisions** section; the brief's signature closes them. The
224
+ direct lane is not grilled (one inline question at most, only when the restatement cannot
225
+ be made unambiguous); a bug fix or an emergency fix, never. While the grill is open, the
226
+ chain's first step reads `settle the open questions` (declare it before the first round;
227
+ re-declare when the answers reshape the steps).
228
+
201
229
  **Brief lane** — when the change carries **decisions a future contributor could plausibly
202
230
  "simplify" away** ("the day boundary is configurable, default 04:00 — not midnight") OR
203
231
  **blast radius into other governed artifacts**. After naming the lane:
@@ -330,12 +358,21 @@ steps you just printed:
330
358
  node qa/plan.mjs --set "sign the brief | draft screens | agree the promises | build | full check | your sign-off" --title "navigation redesign"
331
359
  ```
332
360
 
361
+ **The chain is an offer, not an announcement** (drive-narration N6): show the declared
362
+ steps in your first reply and invite the reshape in one breath — "say the word and I'll
363
+ reorder" — then start work immediately; the chain gates nothing, so the offer never
364
+ blocks. If the human redirects, re-declare (`--set` again) without ceremony: their
365
+ reshape IS the new chain.
366
+
333
367
  **The chain stays current** — this is part of the contract, not a nicety: advance it
334
368
  with `node qa/plan.mjs --step N` as each step lands and `--done` when the request
335
- lands. The current request itself is recorded mechanically (the per-prompt hook), the
336
- steps are yours to declare, and every surface shows the declaration's age — a stale
337
- chain reads as stale to the human watching the studio, which is worse than no chain.
338
- The chain gates nothing; the walk stays the truth for doneness.
369
+ lands (closing writes the request's line into the local trail the studio's Recent
370
+ requests fold shows). The current request itself is recorded mechanically (the
371
+ per-prompt hook), the steps are yours to declare, and every surface shows the
372
+ declaration's age — a stale chain reads as stale to the human watching the studio,
373
+ which is worse than no chain. While the full check runs, the chain's observed line
374
+ narrates the lane's own position (step, elapsed, usual cost) — quote THAT, never an
375
+ estimate. The chain gates nothing; the walk stays the truth for doneness.
339
376
 
340
377
  **The studio is a standing check:** every injected context opens with a `[studio: …]`
341
378
  line. If it says DOWN or not running, restore it before proceeding — call the
@@ -481,6 +518,7 @@ conventions) · [`CONTRIBUTING.md`](./CONTRIBUTING.md) (workflow, Conventional C
481
518
  | `./gradlew :composeApp:assembleRelease` | Android release build — R8 + `lintVital`, the variant the lane's `releaseBuild` step proves. Produces an **unsigned** APK; signing needs a keystore, which is yours to create and keep out of the repo. |
482
519
  | `./gradlew :composeApp:hotRunDesktop --auto` | Desktop dev-client with hot reload |
483
520
  | `./gradlew :composeApp:connectedDebugAndroidTest` | Instrumented behavior tests on the attached device (the lane's `androidChecks` step) |
521
+ | `node qa/verify.mjs --profile nightly` | Scheduled stage: everything `ci` proves with the determinism probe forced on. Proves the harness, never a change — its receipt (`stage: "nightly"`) is refused as done-evidence, exactly like `--fast`. Schedule it; never wait on it |
484
522
  | `node qa/verify.mjs --profile release` | Ship-time lane: everything `ci` proves plus the audit-cadence report (`auditCadence` — which androidMain subsystems changed since their last recorded `cmp-audit`; a nudge, never a gate) and the release-APK Maestro smoke (`releaseSmoke`) |
485
523
  | `node qa/verify.mjs --determinism` | Timezone determinism probe, alone: runs the JVM test tier twice under UTC-12 and UTC+14 and FAILs naming any test whose outcome differs — the dynamic net behind ARCH-13's static one. Opt-in inside a lane via `--profile ci --determinism`; never with `--fast`; writes no receipt on its own |
486
524
  | `node qa/record-audit.mjs <subsystem>` | Record that a `cmp-audit` of an androidMain subsystem happened (appends subsystem + HEAD sha + timestamp to `qa/audits.jsonl`; refuses dirty/unknown targets). `--list` shows every derived subsystem and its audit status |
@@ -41,3 +41,6 @@ xcuserdata/
41
41
  # hard-excluded from the receipt's hashed input surface (qa/lib/inputs-hash.mjs).
42
42
  qa/.request.json
43
43
  qa/.plan.json
44
+ # The closed-chain trail (drive-narration N5): local because it carries raw
45
+ # human prompts — the committed journal for lane runs stays qa/flight-recorder.jsonl.
46
+ qa/.plan-history.jsonl
@@ -201,9 +201,16 @@ if (reopenFeatureFlagIdx !== -1) {
201
201
  console.error(`error: ${result.reason}`);
202
202
  process.exit(1);
203
203
  }
204
+ const inScope = result.reopened.length + result.skipped.length + (result.stillSigned ?? []).length;
204
205
  console.log(`↺ reopened feature "${result.feature}" as one change — reason: ${reason.trim()}`);
206
+ console.log(` ${inScope} in scope · ${result.reopened.length} reopened · ${(result.stillSigned ?? []).length} still signed`);
205
207
  for (const id of result.reopened) console.log(` ↺ ${id}`);
206
208
  for (const s of result.skipped) console.log(` → skipped ${s.id} (${s.status})`);
209
+ // The declared blast radius is reported, not walked back: a signature is
210
+ // demanded again only if the change actually moves the bytes it covers.
211
+ for (const t of result.stillSigned ?? []) {
212
+ console.log(` ✓ ${t.id} still signed (${t.status}${t.hash ? ` @${t.hash}` : ""}) — re-signature demanded only if it changes; the hash enforces that`);
213
+ }
207
214
  process.exit(0);
208
215
  }
209
216
 
@@ -1066,13 +1066,33 @@ export function reopenFeature(root, name, options = {}) {
1066
1066
  // The spec side of the family follows the brief's own pairing (a multi-spec
1067
1067
  // brief reopens every spec its promises live in), defaulting to the name.
1068
1068
  const specIds = (derived?.specNames ?? [name]).map((n) => `feature-spec:${n}`);
1069
- const set = [briefId, ...specIds, `${FEATURE_DESIGN_PREFIX}${name}`, ...(derived ? derived.touches : [])];
1069
+ // WHAT A FEATURE REOPEN WALKS BACK (evidence-economics S5, aligning this
1070
+ // function with CHANGE-FLOW-DESIGN.md §"touches": "hashes enforce,
1071
+ // declaration lets the console tell as-planned from undeclared blast").
1072
+ //
1073
+ // reopened the brief, its declared spec(s), and its design when the
1074
+ // brief declares a UI surface — the documents the change
1075
+ // will AMEND. Their signatures are walked back on purpose.
1076
+ // stillSigned the declared `touches`. Before this, every one of them was
1077
+ // reopened too, and every one came back byte-identical:
1078
+ // twelve signatures for zero changes (design-system
1079
+ // d8fbdce8 → d8fbdce8). An `approved` artifact is, by
1080
+ // definition, one whose bytes still match what was signed —
1081
+ // so reopening it re-asks a question the hash has already
1082
+ // answered. Worse than wasted: it trains the signer to
1083
+ // approve without reading, the exact habit approvals exist
1084
+ // to prevent. They stay signed. If the change DOES move one,
1085
+ // its hash flips it to `changed` and demands a fresh
1086
+ // signature — the enforcement the doc always assigned to the
1087
+ // hash, not to this verb.
1088
+ const amendSet = [briefId, ...specIds, ...(derived?.screens ? [`${FEATURE_DESIGN_PREFIX}${name}`] : [])];
1089
+ const touchSet = (derived ? derived.touches : []).filter((id) => !amendSet.includes(id));
1070
1090
  const byId = new Map(getApprovalStatuses(root).map((s) => [s.id, s]));
1071
1091
  const reopened = [];
1072
1092
  const skipped = [];
1073
- for (const id of [...new Set(set)]) {
1093
+ for (const id of [...new Set(amendSet)]) {
1074
1094
  const live = byId.get(id);
1075
- if (!live) continue; // declared touch that resolves to no governed artifact — nothing to reopen
1095
+ if (!live) continue; // resolves to no governed artifact — nothing to reopen
1076
1096
  if (live.status !== "approved") {
1077
1097
  skipped.push({ id, status: live.status });
1078
1098
  continue;
@@ -1081,15 +1101,21 @@ export function reopenFeature(root, name, options = {}) {
1081
1101
  if (result.ok) reopened.push(id);
1082
1102
  else skipped.push({ id, status: `refused: ${result.reason}` });
1083
1103
  }
1104
+ const stillSigned = [];
1105
+ for (const id of [...new Set(touchSet)]) {
1106
+ const live = byId.get(id);
1107
+ if (!live) continue;
1108
+ stillSigned.push({ id, status: live.status, hash: typeof live.hash === "string" ? live.hash.slice(0, 8) : null });
1109
+ }
1084
1110
  if (reopened.length === 0) {
1085
1111
  return {
1086
1112
  ok: false,
1087
1113
  reason:
1088
- `nothing in "${name}"'s set is currently approved — there is no signature to walk back. ` +
1089
- `Set: ${[...new Set(set)].join(", ")}; states: ${skipped.map((s) => `${s.id}=${s.status}`).join(", ") || "(unresolved)"}`,
1114
+ `nothing in "${name}"'s amend set is currently approved — there is no signature to walk back. ` +
1115
+ `Set: ${[...new Set(amendSet)].join(", ")}; states: ${skipped.map((s) => `${s.id}=${s.status}`).join(", ") || "(unresolved)"}`,
1090
1116
  };
1091
1117
  }
1092
- return { ok: true, feature: name, reopened, skipped };
1118
+ return { ok: true, feature: name, reopened, skipped, stillSigned };
1093
1119
  }
1094
1120
 
1095
1121
  // ── The verify-lane gate ─────────────────────────────────────────────────────
@@ -84,7 +84,9 @@ const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release"
84
84
  export function evidenceLevel(stepResults, profile, { mode } = {}) { // eslint-disable-line no-unused-vars
85
85
  if (mode === "fast") return null; // the inner loop derives no rung — ever
86
86
  const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
87
- if (steps.some((s) => s.verdict === "FAIL")) return null; // a failed lane has no rung
87
+ // A failed lane has no rung and a lane with a step that could not run
88
+ // (ERROR) has none either: a rung is evidence, and "could not check" is not.
89
+ if (steps.some((s) => s.verdict === "FAIL" || s.verdict === "ERROR")) return null;
88
90
  const passed = new Set(steps.filter((s) => s.verdict === "PASS").map((s) => s.name));
89
91
 
90
92
  if (!L0_REQUIRED.every((name) => passed.has(name))) return null; // not even a stamp-time green build
@@ -93,7 +93,14 @@ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps,
93
93
  verdict,
94
94
  evidenceRung: evidenceLevel?.rung ?? null,
95
95
  durationMs,
96
- steps: stepList.map((s) => ({ name: s.name, verdict: s.verdict })),
96
+ // durationMs per step (additive, schema id unchanged old entries stay
97
+ // readable): the source for the lane's own "usually ~Ns" narration
98
+ // (drive-narration N4). Quoted from the journal, never from memory.
99
+ steps: stepList.map((s) => ({
100
+ name: s.name,
101
+ verdict: s.verdict,
102
+ ...(typeof s.durationMs === "number" && s.durationMs >= 0 ? { durationMs: s.durationMs } : {}),
103
+ })),
97
104
  // SKIP reasons verbatim — the journal's core signal (see file header).
98
105
  skips: stepList.filter((s) => s.verdict === "SKIP").map((s) => ({ step: s.name, reason: s.reason ?? "" })),
99
106
  deviceSteps: Array.isArray(onDeviceSteps) ? onDeviceSteps : [],
@@ -198,7 +205,15 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
198
205
  // JSON-array key: reasons are arbitrary text, so a delimiter-joined
199
206
  // string key would be ambiguous — and ambiguity here merges two
200
207
  // different problems into one count.
201
- const key = JSON.stringify([s.step ?? "?", s.reason ?? ""]);
208
+ //
209
+ // Grouped on the reason's FIRST LINE, which is exactly what the report
210
+ // prints. Several gates (approvals above all) end their reason with a
211
+ // variable list of artifact names, so keying on the whole string split
212
+ // ONE recurring reason into seven near-identical rows carrying the same
213
+ // visible text — a count the reader had to add up by eye. The detail is
214
+ // not lost: the verbatim reasons are still in the journal, which is the
215
+ // artifact that owes verbatim. The REPORT owes legibility.
216
+ const key = JSON.stringify([s.step ?? "?", (s.reason ?? "").split("\n")[0]]);
202
217
  skipGroups.set(key, (skipGroups.get(key) ?? 0) + 1);
203
218
  }
204
219
  }
@@ -258,6 +273,36 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
258
273
  };
259
274
  }
260
275
 
276
+ /**
277
+ * Steps that SKIPped in THIS run and have skipped in EVERY recorded full run —
278
+ * a tier that has never executed on this machine.
279
+ *
280
+ * A single SKIP is a fact; skipping every recorded run is a different fact,
281
+ * and only the journal can tell them apart. maestro was never installed on one
282
+ * machine, so e2eSmoke skipped on all 37 recorded runs while the lane said
283
+ * PASS each time — the end-to-end flow had never run once, and nothing said so.
284
+ *
285
+ * Needs a journal long enough to mean something: below `floor` recorded runs
286
+ * carrying the step, "every time" is a coincidence, not a pattern.
287
+ *
288
+ * @param {Array<{name: string, verdict: string, reason?: string}>} steps this run's results
289
+ * @param {object[]} entries parsed journal entries (any mode; fast runs are ignored)
290
+ * @param {{floor?: number}} [opts]
291
+ * @returns {Array<{name: string, runs: number, reason: string}>}
292
+ */
293
+ export function neverRunTiers(steps, entries, { floor = 3 } = {}) {
294
+ const full = (Array.isArray(entries) ? entries : []).filter((e) => e && e.mode !== "fast" && Array.isArray(e.steps));
295
+ const out = [];
296
+ for (const st of (Array.isArray(steps) ? steps : []).filter((x) => x && x.verdict === "SKIP")) {
297
+ const seen = full.filter((e) => e.steps.some((s) => s && s.name === st.name));
298
+ // "Ran" means produced a verdict about the tree: PASS or FAIL. An ERROR
299
+ // tried and could not; it is not evidence that the tier works here.
300
+ const ran = seen.filter((e) => e.steps.some((s) => s && s.name === st.name && (s.verdict === "PASS" || s.verdict === "FAIL")));
301
+ if (seen.length >= floor && ran.length === 0) out.push({ name: st.name, runs: seen.length, reason: st.reason ?? "" });
302
+ }
303
+ return out;
304
+ }
305
+
261
306
  /**
262
307
  * Render the summary as the plain-text report a human reads in ten seconds.
263
308
  * Every line is a recorded fact; the honesty notes (short journal, single
@@ -57,6 +57,7 @@ export const VERIFIED_SURFACE = [
57
57
  const EXCLUDED_PREFIXES = [
58
58
  "qa/.plan.json",
59
59
  "qa/.request.json",
60
+ "qa/.plan-history.jsonl",
60
61
  "qa/evidence",
61
62
  "qa-artifacts",
62
63
  "qa/comments.json",
@@ -0,0 +1,97 @@
1
+ #!/usr/bin/env node
2
+ // lane-narrator.mjs — the lane's pulse while a step is running.
3
+ //
4
+ // node qa/lib/lane-narrator.mjs <projectRoot>
5
+ //
6
+ // WHY THIS IS A SEPARATE PROCESS, and not a setInterval in verify.mjs. The lane's
7
+ // steps are SYNCHRONOUS: each one blocks on execSync/spawnSync while Gradle works.
8
+ // A timer inside that process cannot fire — the event loop is not running — so the
9
+ // lane could only ever print when a step FINISHED. Observed live: fourteen minutes
10
+ // without a single byte while the release build ran, with no way to tell a grinding
11
+ // step from a wedged one except checking the Gradle daemon's CPU by hand. A step
12
+ // that can take minutes must emit a heartbeat, or the operator's only signal is
13
+ // silence, and silence is exactly what a crash looks like.
14
+ //
15
+ // The marker verify.mjs already rewrites at each step start (.cmp-lane-in-progress,
16
+ // JSON: step, index, total, stepStartedAt, expectedStepMs, expectedLaneMs) carries
17
+ // everything a pulse needs, so this narrator INVENTS NOTHING — it reads what the
18
+ // lane declared about itself and says it out loud on a timer the lane cannot run.
19
+ //
20
+ // It writes to STDERR and never to stdout: --json consumers parse stdout, and a
21
+ // narrator that corrupted machine output would be worse than the silence it fixes.
22
+ // It is spawned only for human runs, and killed with the step loop.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ const ROOT = process.argv[2];
28
+ const MARKER = path.join(ROOT ?? ".", "composeApp", "build", ".cmp-lane-in-progress");
29
+
30
+ // A step under this is not a wait — saying anything about it is noise.
31
+ const FIRST_AFTER_MS = 20_000;
32
+ const EVERY_MS = 30_000;
33
+ const POLL_MS = 1_000;
34
+
35
+ /** "42s" / "4m12s" — short enough to sit inside one line without wrapping. */
36
+ export function shortDuration(ms) {
37
+ if (!(ms > 0)) return "0s";
38
+ const s = Math.round(ms / 1000);
39
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s`;
40
+ }
41
+
42
+ /**
43
+ * The line for one poll, or null when there is nothing worth saying yet.
44
+ * Pure, so the cadence and the wording are testable without a clock or a lane.
45
+ *
46
+ * @param {object|null} marker parsed marker content (null when absent/legacy)
47
+ * @param {number} elapsedMs how long the CURRENT step has been running
48
+ * @param {number|null} lastSaidAtMs elapsed value at the previous line, or null
49
+ * @returns {string|null}
50
+ */
51
+ export function pulseLine(marker, elapsedMs, lastSaidAtMs) {
52
+ if (!marker || typeof marker.step !== "string") return null; // legacy marker: nothing to narrate
53
+ if (elapsedMs < FIRST_AFTER_MS) return null;
54
+ if (lastSaidAtMs !== null && elapsedMs - lastSaidAtMs < EVERY_MS) return null;
55
+ const where = marker.index && marker.total ? ` (${marker.index}/${marker.total})` : "";
56
+ // The expectation is quoted from the flight journal's last full run — measured,
57
+ // never estimated (walk-legibility L4). Absent until one such run exists.
58
+ const usually = marker.expectedStepMs > 0 ? `, usually ~${shortDuration(marker.expectedStepMs)}` : "";
59
+ // Past its usual time is the fact an operator actually wants: it is the
60
+ // difference between "grinding" and "possibly wedged", and it is derived, not
61
+ // guessed — so it is stated plainly rather than dressed up as a warning.
62
+ const over = marker.expectedStepMs > 0 && elapsedMs > marker.expectedStepMs * 1.5 ? " — longer than usual" : "";
63
+ return `⋯ ${marker.step}${where} — ${shortDuration(elapsedMs)} elapsed${usually}${over}`;
64
+ }
65
+
66
+ function readMarker() {
67
+ try {
68
+ return JSON.parse(fs.readFileSync(MARKER, "utf8"));
69
+ } catch {
70
+ return null;
71
+ }
72
+ }
73
+
74
+ if (ROOT) {
75
+ let currentStep = null;
76
+ let lastSaidAtMs = null;
77
+ const timer = setInterval(() => {
78
+ const m = readMarker();
79
+ if (!m) return; // between steps, or the lane has finished and cleared it
80
+ if (m.step !== currentStep) {
81
+ currentStep = m.step;
82
+ lastSaidAtMs = null; // each step narrates on its own clock
83
+ }
84
+ const started = Date.parse(m.stepStartedAt ?? "");
85
+ if (Number.isNaN(started)) return;
86
+ const elapsed = Date.now() - started;
87
+ const line = pulseLine(m, elapsed, lastSaidAtMs);
88
+ if (line) {
89
+ lastSaidAtMs = elapsed;
90
+ process.stderr.write(`${line}\n`);
91
+ }
92
+ }, POLL_MS);
93
+ timer.unref?.();
94
+ for (const sig of ["SIGINT", "SIGTERM", "SIGHUP"]) process.on(sig, () => process.exit(0));
95
+ // Hold the process open against the unref'd timer: the parent kills us.
96
+ setInterval(() => {}, 1 << 30);
97
+ }
@@ -0,0 +1,173 @@
1
+ // lane-runner.mjs — the lane's step loop, as a function (evidence-economics S8a).
2
+ //
3
+ // The SPINE, separated from the STEPS. Everything a lane does around its steps —
4
+ // stamp the in-flight marker with the step's own narration, set the step's
5
+ // deadline from its measured history, keep a pulse alive while a synchronous
6
+ // step blocks, turn a throw or a timeout into one ERROR row and keep going,
7
+ // print the mark, derive the verdict — is the same for a Compose app and for
8
+ // a Kotlin backend. Only the steps differ. payment-blueprint re-implemented
9
+ // all of this by hand (2,769 lines) because it lived inside verify.mjs next
10
+ // to Gradle calls it could not use; this file is what it should have been
11
+ // able to import.
12
+ //
13
+ // PURE OF PROJECT KNOWLEDGE. No ROOT, no Gradle, no composeApp path: every
14
+ // project-specific fact arrives through `ctx`. The runner never reads argv.
15
+
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { spawn } from "node:child_process";
19
+
20
+ import { stepDeadlineMs, stepErrorResult } from "./step-outcomes.mjs";
21
+
22
+ /**
23
+ * Per-step expected durations from the journal's LAST FULL run — the source
24
+ * of the marker's "usually ~Ns" and of each step's deadline. Measured, never
25
+ * estimated; empty until one new-format full run exists.
26
+ * @param {object[]} entries parsed flight-journal entries, oldest first
27
+ * @returns {{byName: Map<string, number>, laneMs: (number|null)}}
28
+ */
29
+ export function expectedDurations(entries) {
30
+ const list = Array.isArray(entries) ? entries : [];
31
+ for (let i = list.length - 1; i >= 0; i--) {
32
+ const e = list[i];
33
+ if (!e || e.mode === "fast" || !Array.isArray(e.steps)) continue;
34
+ const byName = new Map();
35
+ for (const s of e.steps) {
36
+ if (s && typeof s.name === "string" && typeof s.durationMs === "number" && s.durationMs > 0) byName.set(s.name, s.durationMs);
37
+ }
38
+ return { byName, laneMs: typeof e.durationMs === "number" && e.durationMs > 0 ? e.durationMs : null };
39
+ }
40
+ return { byName: new Map(), laneMs: null };
41
+ }
42
+
43
+ /**
44
+ * "stepUnitTests" → "unitTests", "stepSpecCoverageMemo" → "specCoverage" — the
45
+ * name the result will carry. An anonymous step narrates as null rather than
46
+ * guessing. Exported so a step pack can name its steps the same way.
47
+ * @param {Function} fn
48
+ * @returns {string|null}
49
+ */
50
+ export function stepDisplayName(fn) {
51
+ const raw = typeof fn?.name === "string" ? fn.name.replace(/^step/, "").replace(/Memo$/, "") : "";
52
+ return raw === "" ? null : raw.charAt(0).toLowerCase() + raw.slice(1);
53
+ }
54
+
55
+ /** The lane's verdict over its rows: FAIL on any FAIL or ERROR, else PASS. CACHED counts as PASS (it IS a prior PASS). */
56
+ export function laneVerdict(steps) {
57
+ return steps.some((s) => s && (s.verdict === "FAIL" || s.verdict === "ERROR")) ? "FAIL" : "PASS";
58
+ }
59
+
60
+ /** The mark a row wears on the console: ✓ PASS · ⚡ CACHED · → SKIP · ⊘ ERROR (could not run) · ✗ FAIL. */
61
+ export function verdictMark(verdict) {
62
+ return verdict === "PASS" ? "✓" : verdict === "CACHED" ? "⚡" : verdict === "SKIP" ? "→" : verdict === "ERROR" ? "⊘" : "✗";
63
+ }
64
+
65
+ /**
66
+ * Run the steps, in order, under the lane's own discipline.
67
+ *
68
+ * @param {object} ctx
69
+ * @param {Function[]} ctx.steps the step functions, each returning a result row
70
+ * @param {string} ctx.markerPath the in-flight marker (.cmp-lane-in-progress) — stamped
71
+ * before every step with {pid, at, step, index, total, stepStartedAt, expectedStepMs,
72
+ * expectedLaneMs}, removed when the loop ends however it ends
73
+ * @param {{byName: Map<string, number>, laneMs: (number|null)}} [ctx.expected] from expectedDurations
74
+ * @param {(ms: number) => void} [ctx.setDeadline] receives each step's deadline before it
75
+ * runs — the project's subprocess helper reads it (verify.mjs's sh())
76
+ * @param {(line: string) => void|null} [ctx.print] one line per finished step; null = silent
77
+ * (--json). Also gates the narrator: no print, no pulse.
78
+ * @param {{entry: string, root: string}|null} [ctx.narrator] the pulse process to spawn
79
+ * beside the loop (lane-narrator.mjs) — a separate process because the steps are
80
+ * synchronous and no timer in this process can fire while one runs
81
+ * @param {(result: object) => boolean} [ctx.stopAfter] short-circuit predicate; default:
82
+ * stop after a FAILed "build" — nothing downstream is meaningful
83
+ * @param {() => void} [ctx.onFinally] runs in the finally (the project releases its device lease here)
84
+ * @param {number} [ctx.startedAt] the lane's start, for the marker's `at`
85
+ * @returns {{steps: object[], verdict: "PASS"|"FAIL", durationMs: number}}
86
+ */
87
+ export function runLane(ctx) {
88
+ const {
89
+ steps: stepFns,
90
+ markerPath,
91
+ expected = { byName: new Map(), laneMs: null },
92
+ setDeadline = () => {},
93
+ print = null,
94
+ narrator = null,
95
+ stopAfter = (r) => r.name === "build" && r.verdict === "FAIL",
96
+ onFinally = () => {},
97
+ startedAt = Date.now(),
98
+ } = ctx;
99
+
100
+ const stamp = (stepFn, index, total) => {
101
+ try {
102
+ const name = stepFn ? stepDisplayName(stepFn) : null;
103
+ const narration = {
104
+ pid: process.pid,
105
+ at: new Date(startedAt).toISOString(),
106
+ step: name,
107
+ index,
108
+ total,
109
+ stepStartedAt: new Date().toISOString(),
110
+ expectedStepMs: name !== null ? (expected.byName.get(name) ?? null) : null,
111
+ expectedLaneMs: expected.laneMs,
112
+ };
113
+ fs.writeFileSync(markerPath, `${JSON.stringify(narration)}\n`);
114
+ } catch {
115
+ /* the narration must never break the lane it narrates */
116
+ }
117
+ };
118
+
119
+ fs.mkdirSync(path.dirname(markerPath), { recursive: true });
120
+ stamp(null, 0, stepFns.length);
121
+
122
+ let pulse = null;
123
+ if (print && narrator) {
124
+ try {
125
+ pulse = spawn(process.execPath, [narrator.entry, narrator.root], { stdio: ["ignore", "ignore", "inherit"] });
126
+ pulse.on("error", () => {});
127
+ } catch {
128
+ /* a missing pulse is a quieter lane, never a failed one */
129
+ }
130
+ }
131
+
132
+ const results = [];
133
+ try {
134
+ for (const [i, step] of stepFns.entries()) {
135
+ stamp(step, i + 1, stepFns.length);
136
+ const name = stepDisplayName(step) ?? `step${i + 1}`;
137
+ // S4: every step under a deadline from its own history (×3, floor 5 min,
138
+ // ceiling 30). A deadline or a throw is ONE ERROR row — the lane keeps
139
+ // going, because the other verdicts are still worth having.
140
+ setDeadline(stepDeadlineMs(expected.byName.get(name)));
141
+ const stepStarted = Date.now();
142
+ let result;
143
+ try {
144
+ result = step();
145
+ } catch (err) {
146
+ result = stepErrorResult(name, err, Date.now() - stepStarted);
147
+ }
148
+ results.push(result);
149
+ if (print) {
150
+ print(
151
+ `${verdictMark(result.verdict)} ${result.name}: ${result.verdict}${result.note ? ` (${result.note})` : ""}${result.reason ? ` — ${String(result.reason).split("\n")[0]}` : ""}`,
152
+ );
153
+ }
154
+ if (stopAfter(result)) break;
155
+ }
156
+ } finally {
157
+ if (pulse) {
158
+ try {
159
+ pulse.kill();
160
+ } catch {
161
+ /* the narrator holds nothing; a failed kill must not fail the lane */
162
+ }
163
+ }
164
+ fs.rmSync(markerPath, { force: true });
165
+ try {
166
+ onFinally();
167
+ } catch {
168
+ /* a finalizer that throws must not hide the rows already earned */
169
+ }
170
+ }
171
+
172
+ return { steps: results, verdict: laneVerdict(results), durationMs: Date.now() - startedAt };
173
+ }