create-cmp-cli 0.23.0 → 0.24.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.
@@ -0,0 +1,234 @@
1
+ // agent-hold.mjs — "an agent is working in this tree right now."
2
+ //
3
+ // TWO PROBLEMS, ONE MISSING FACT. Both were reported from payment-blueprint's
4
+ // adoption on 2026-09-04, and both are the same absence:
5
+ //
6
+ // 1. LIVENESS. There was no way to answer "is the agent working, or wedged?"
7
+ // without filesystem archaeology. The lead architect guessed twice with two
8
+ // separately broken instruments — a `find` that excluded `build/` (the only
9
+ // directory a proof run writes to) and a `find -newermt` that reported zero
10
+ // writes in 45 minutes while `ls -lT` showed one at 10 — and on the first
11
+ // guess killed a healthy agent mid-proof. An instrument that cannot see the
12
+ // thing it exists to detect is worse than no instrument: it is confidently
13
+ // wrong, which is the same failure class GATE-RULES Rule 1 exists for.
14
+ //
15
+ // 2. FALSE ALARMS. The Stop hook fires identically whether a receipt is stale
16
+ // because nobody ran the lane or because a subagent is mid-commit on a
17
+ // half-adopted port. It fired ~15 times in one evening while the correct
18
+ // action every time was to WAIT. Anthropic's tool-design guidance is
19
+ // explicit that an error must communicate "specific and actionable
20
+ // improvements"; an alarm whose advice is wrong every time it fires trains
21
+ // its reader to ignore it, which is strictly worse than silence.
22
+ //
23
+ // WHAT THIS IS NOT. It is not a lock — nothing waits on it, nothing is excluded
24
+ // by it. It is not a second journal: the flight recorder still owns lane history
25
+ // that belongs in the repo. It is a DECLARATION with an expiry, in the same
26
+ // ephemeral, gitignored, hash-excluded family as qa/.plan.json and
27
+ // qa/.request.json — because a fact about who is typing must never be able to
28
+ // invalidate a receipt.
29
+ //
30
+ // A HOLD CHANGES THE ADVICE, NEVER THE VERDICT. The Stop hook still refuses:
31
+ // no receipt yet means not done, and a file an agent writes about itself must
32
+ // never be able to end a turn — that would be turning the gate off by writing a
33
+ // file, which is the attack the whole harness exists to refuse. This follows the
34
+ // precedent already set for a lane in flight (qa/receipt-check.mjs): same
35
+ // refusal, different instruction. "Run the lane" is wrong advice when the tree
36
+ // is mid-edit and would not compile, and a gate that tells you to do the thing
37
+ // you are already doing trains you to stop reading it.
38
+ //
39
+ // THE ASYMMETRY THAT KEEPS IT HONEST. A hold EXPLAINS the absence of fresh
40
+ // evidence. It never explains CONTRADICTING evidence. A red receipt, a forged
41
+ // receipt, a skipped device tier: for those the hold is not the reason and
42
+ // saying so would mislead. Only two refusals are explicable by a hold — "no
43
+ // receipt yet" and "the tree moved since a PASSing receipt" — exactly the two
44
+ // states a working agent legitimately produces, and nothing else.
45
+ //
46
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/agent-hold.mjs in the
47
+ // create-cmp repo. The copy in a generated project's qa/lib/ is vendored
48
+ // byte-identical at scaffold time — edit the package source, then run
49
+ // `node scripts/sync-harness.mjs`.
50
+
51
+ import fs from "node:fs";
52
+ import path from "node:path";
53
+
54
+ /**
55
+ * Ephemeral, gitignored, and excluded from the receipt's hashed input surface
56
+ * (qa/lib/inputs-hash.mjs EXCLUDED_PREFIXES) — the same family as .plan.json.
57
+ */
58
+ export const HOLD_REL = "qa/.agent-hold.json";
59
+
60
+ /**
61
+ * A heartbeat older than this is a crashed writer, not a live agent. The same
62
+ * bound every other marker consumer in this lane applies (qa/lib/plan.mjs), for
63
+ * the same reason: a process that dies leaves its file behind, so freshness —
64
+ * never presence — is what makes a marker mean anything.
65
+ */
66
+ export const HEARTBEAT_FRESH_MS = 5 * 60 * 1000;
67
+
68
+ /**
69
+ * A hold this old is a wedge, not work. Past the ceiling the hook resumes
70
+ * blocking even while heartbeats keep arriving: an agent that has held the tree
71
+ * for three quarters of an hour is exactly the case the human needed to see, and
72
+ * a heartbeat proves the process is alive, never that it is making progress.
73
+ */
74
+ export const HOLD_CEILING_MS = 45 * 60 * 1000;
75
+
76
+ const MAX_TEXT = 200;
77
+
78
+ const clip = (s, n = MAX_TEXT) => (typeof s === "string" ? s.trim().slice(0, n) : "");
79
+
80
+ /** Fail-soft like every other status reader here: unreadable reads as absent. */
81
+ export function readHold(root) {
82
+ try {
83
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, ...HOLD_REL.split("/")), "utf8"));
84
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
85
+ } catch {
86
+ return null;
87
+ }
88
+ }
89
+
90
+ function writeJson(root, value) {
91
+ try {
92
+ const p = path.join(root, ...HOLD_REL.split("/"));
93
+ fs.mkdirSync(path.dirname(p), { recursive: true });
94
+ fs.writeFileSync(p, `${JSON.stringify(value, null, 2)}\n`);
95
+ return { ok: true, hold: value };
96
+ } catch (err) {
97
+ return { ok: false, error: err.message };
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Claim the tree. `holder` is a name a human will read in an alarm — an agent
103
+ * or session label, not a UUID: Anthropic's tool guidance is that agents (and
104
+ * the people reading after them) do far better with natural-language
105
+ * identifiers than with opaque ids, and this string's whole job is to be read
106
+ * at 3am by someone deciding whether to kill a process.
107
+ */
108
+ export function claimHold(root, { holder, note = "", now = Date.now() } = {}) {
109
+ const name = clip(holder, 80) || "an agent";
110
+ const existing = readHold(root);
111
+ const at = existing && assessHold(existing, now).held ? existing.at : new Date(now).toISOString();
112
+ return writeJson(root, {
113
+ holder: name,
114
+ note: clip(note),
115
+ at,
116
+ heartbeatAt: new Date(now).toISOString(),
117
+ });
118
+ }
119
+
120
+ /**
121
+ * Still here, still working. Optionally re-states what "here" means — an agent
122
+ * that only says "alive" is barely better than the `find` that started this.
123
+ */
124
+ export function beatHold(root, { note, now = Date.now() } = {}) {
125
+ const existing = readHold(root);
126
+ if (!existing) return { ok: false, error: "no hold to beat — claim one first" };
127
+ return writeJson(root, {
128
+ ...existing,
129
+ note: note === undefined ? existing.note : clip(note),
130
+ heartbeatAt: new Date(now).toISOString(),
131
+ });
132
+ }
133
+
134
+ export function releaseHold(root) {
135
+ try {
136
+ fs.rmSync(path.join(root, ...HOLD_REL.split("/")), { force: true });
137
+ return { ok: true };
138
+ } catch (err) {
139
+ return { ok: false, error: err.message };
140
+ }
141
+ }
142
+
143
+ /**
144
+ * Is a hold in force, and what does it say?
145
+ *
146
+ * Every negative branch names WHY, because this feeds an alarm's text: "no
147
+ * agent holds the tree" and "an agent claimed it 50 minutes ago and is past the
148
+ * ceiling" call for opposite actions by the human reading them.
149
+ *
150
+ * @param {object|null} hold
151
+ * @param {number} now
152
+ * @returns {{held: boolean, reason: string, holder?: string, note?: string,
153
+ * heldMs?: number, sinceBeatMs?: number, expired?: boolean}}
154
+ */
155
+ export function assessHold(hold, now = Date.now()) {
156
+ if (!hold || typeof hold !== "object") return { held: false, reason: "no agent holds the tree" };
157
+ const at = Date.parse(hold.at);
158
+ const beat = Date.parse(hold.heartbeatAt ?? hold.at);
159
+ if (Number.isNaN(at) || Number.isNaN(beat)) return { held: false, reason: "the hold file has no readable timestamp" };
160
+
161
+ const heldMs = Math.max(0, now - at);
162
+ const sinceBeatMs = Math.max(0, now - beat);
163
+ const holder = clip(hold.holder, 80) || "an agent";
164
+ const note = clip(hold.note);
165
+ const base = { holder, note, heldMs, sinceBeatMs };
166
+
167
+ if (sinceBeatMs > HEARTBEAT_FRESH_MS) {
168
+ return {
169
+ ...base,
170
+ held: false,
171
+ expired: true,
172
+ reason: `${holder} last checked in ${formatAge(sinceBeatMs)} — that is a crashed writer, not a live agent`,
173
+ };
174
+ }
175
+ if (heldMs > HOLD_CEILING_MS) {
176
+ return {
177
+ ...base,
178
+ held: false,
179
+ expired: true,
180
+ reason: `${holder} has held the tree for ${formatAge(heldMs)}, past the ${formatAge(HOLD_CEILING_MS)} ceiling — a heartbeat proves the process is alive, not that it is progressing`,
181
+ };
182
+ }
183
+ return { ...base, held: true, reason: `${holder} has held the tree for ${formatAge(heldMs)}` };
184
+ }
185
+
186
+ /**
187
+ * The ONE line an alarm prints instead of demanding a lane run. It says who,
188
+ * since when, what they said they were doing, and what the reader should do —
189
+ * "specific and actionable", which the alarm it replaces was not.
190
+ */
191
+ export function describeHold(assessment) {
192
+ if (!assessment?.held) return null;
193
+ const what = assessment.note ? ` (${assessment.note})` : "";
194
+ return (
195
+ `${assessment.holder} has held this tree for ${formatAge(assessment.heldMs)}${what} — staleness is expected while it works. ` +
196
+ `Wait for it rather than starting a lane on a half-edited tree; \`node qa/plan.mjs --release\` if it is gone.`
197
+ );
198
+ }
199
+
200
+ /**
201
+ * Does a hold EXPLAIN this refusal? (It never lifts it — see the header.)
202
+ *
203
+ * The whitelist is the safety property, and it is deliberately two entries
204
+ * long. A hold explains why fresh evidence is ABSENT — no receipt yet, or the
205
+ * tree has moved under one — because those are the two states a working agent
206
+ * legitimately produces. It never explains a receipt that says something is
207
+ * wrong: for a FAIL, a forgery, a skipped device tier or an unreadable surface
208
+ * the hold is simply not the cause, and offering it as one would send the
209
+ * reader to wait for an agent when the actual problem is a red test. Inverting
210
+ * this to a blacklist would mean every refusal added later is treated as
211
+ * agent-explicable by default, which is how an alarm starts lying.
212
+ *
213
+ * @param {{valid: boolean, reason?: string}} result
214
+ * @param {object|null} receipt
215
+ * @returns {boolean}
216
+ */
217
+ export function holdExplains(result, receipt) {
218
+ if (!result || result.valid) return false;
219
+ // No receipt at all: the agent has not finished enough to run the lane.
220
+ if (receipt === null || receipt === undefined) return true;
221
+ // The tree moved under a receipt that itself PASSED — the signature of an
222
+ // agent mid-edit. A receipt that was not a PASS is contradicting evidence and
223
+ // is never excused, whatever moved since.
224
+ return receipt.verdict === "PASS" && /^source changed since the receipt/.test(String(result.reason ?? ""));
225
+ }
226
+
227
+ /** "40s" / "12 min" / "1h 5m" — freshness a human can weigh at a glance. */
228
+ export function formatAge(ms) {
229
+ if (!(ms >= 0)) return "an unknown time";
230
+ if (ms < 90_000) return `${Math.round(ms / 1000)}s`;
231
+ if (ms < 90 * 60_000) return `${Math.round(ms / 60_000)} min`;
232
+ const h = Math.floor(ms / 3_600_000);
233
+ return `${h}h ${Math.round((ms - h * 3_600_000) / 60_000)}m`;
234
+ }
@@ -149,10 +149,10 @@ export function updateReadmeBadge(root) {
149
149
  // The same rule for the two other receipts qa/receipt-check.mjs refuses as
150
150
  // done-evidence: smoke (Rule 0 — proves the framework, never the change) and
151
151
  // nightly (proves the harness and the tree's invariants). Both derive no
152
- // rung, and a smoke run — scripts/framework-check.mjs runs one on every
153
- // scaffold — was rewriting a true L1 badge to "rung unrecorded". Found on
154
- // 2026-09-03 by deriving the affected filter on a fresh app: README.md was
155
- // the dirty file. Receipts predating `stage` are read by profile.
152
+ // rung, and a smoke run — qa/framework-check.mjs runs several, and restores
153
+ // this file afterwards — was rewriting a true L1 badge to "rung unrecorded".
154
+ // Found on 2026-09-03 by deriving the affected filter on a fresh app:
155
+ // README.md was the dirty file. Receipts predating `stage` are read by profile.
156
156
  const stage = receipt && (typeof receipt.stage === "string" ? receipt.stage : receipt.profile);
157
157
  if (stage === "smoke" || stage === "nightly") {
158
158
  return { changed: false, reason: `${stage} run — refused as done-evidence, so the badge is left as it stands` };
@@ -0,0 +1,397 @@
1
+ // framework-check.mjs (lib) — Rule 0's instrument, aimed at an app's OWN tree.
2
+ //
3
+ // GATE-RULES Rule 0 says: before any real work is pointed at the harness, prove
4
+ // the FRAMEWORK returns — a deterministic PASS and a deterministic FAIL, fast,
5
+ // through the real lane machinery, with a bound short enough that a hang is
6
+ // obvious rather than patient.
7
+ //
8
+ // create-cmp's own `scripts/framework-check.mjs` proves that for the ENGINE: it
9
+ // stamps a scratch app and reads the lane it just shipped. That script has never
10
+ // existed inside a generated project, and it never can — it needs `bin/create-
11
+ // cmp.mjs` and a tree to stamp. Meanwhile the lane that DOES ship names it four
12
+ // times (verify.mjs, steps-cmp.mjs, evidence-badge.mjs, USAGE.md), pointing every
13
+ // adopter at a path they do not have.
14
+ //
15
+ // That dangling reference has a measured cost. payment-blueprint read those
16
+ // comments, could not find the file, and hand-built its own copy with nine
17
+ // plants; then, months later, briefed a whole wave to prove new gates by hand —
18
+ // plant, `./gradlew`, confirm red, revert, build again, 30–60 s per cycle — and
19
+ // burned ~38 minutes reproducing exactly what the missing instrument does in
20
+ // seconds. Their own diagnosis was "I failed to check what already existed". The
21
+ // truer reading is narrower and is ours: the harness advertised a tool it never
22
+ // handed over, so there was nothing in their tree to find.
23
+ //
24
+ // This module is the half of that instrument an app can run against itself. The
25
+ // plants are DERIVED from the tree rather than hardcoded, because an adopted
26
+ // project is not a stamped Compose app: it may have no `specs/`, no `qa/e2e/`,
27
+ // no Kotlin test source at all. A plant whose target is absent is reported as
28
+ // unavailable WITH ITS REASON and does not silently vanish — a framework check
29
+ // that skips everything and prints PASS is the failure this exists to refuse.
30
+ //
31
+ // Pure by construction: every function here takes data and returns data. The IO
32
+ // — reading the tree, writing the plant, running the lane, reverting in a
33
+ // `finally` — lives in the runner (qa/framework-check.mjs), so the decisions
34
+ // this file makes are unit-testable without a scaffold.
35
+ //
36
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/framework-check.mjs in the
37
+ // create-cmp repo. The copy in a generated project's qa/lib/ is vendored
38
+ // byte-identical at scaffold time — edit the package source, then run
39
+ // `node scripts/sync-harness.mjs`.
40
+
41
+ /**
42
+ * Per-direction bound. Rule 0's whole claim is about SPEED of refusal, so the
43
+ * default is small on purpose: the smoke profile is every pure-Node gate and no
44
+ * Gradle, which returns in around a second on a real tree. A direction that
45
+ * does not return inside the bound is killed and reported as a hang — the bound
46
+ * IS the assertion, never a courtesy timeout waited out.
47
+ */
48
+ export const DEFAULT_BOUND_MS = 10_000;
49
+
50
+ /**
51
+ * Every plant this instrument knows how to make. The kinds are named so tests
52
+ * (and a report) can talk about them without matching prose.
53
+ */
54
+ export const PLANT_KINDS = Object.freeze({
55
+ ORPHANED_CITATION: "orphaned-citation",
56
+ UNBOUND_CITATION: "unbound-citation",
57
+ TIER_UNMET: "tier-unmet",
58
+ FEATURE_WITHOUT_FLOW: "feature-without-flow",
59
+ NESTED_FLOW: "flow-the-lane-never-runs",
60
+ NARROWED_SURFACE: "narrowed-surface",
61
+ EDITED_LANE: "edited-lane",
62
+ });
63
+
64
+ /**
65
+ * The plants that need nothing but a lane. `harnessIntegrity` reads the machine-
66
+ * owned region, which exists in every project that has a lane at all — so these
67
+ * two are the floor. If even these cannot run, the tree has no harness to check
68
+ * and the instrument must say so rather than report a vacuous PASS.
69
+ */
70
+ export const FLOOR_KINDS = Object.freeze([PLANT_KINDS.NARROWED_SURFACE, PLANT_KINDS.EDITED_LANE]);
71
+
72
+ /** A clause id at the head of a spec list item: `- **HOME-02** — …`. */
73
+ const CLAUSE_RE = /^-\s+\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/m;
74
+
75
+ /** `# SPEC: HOME-02` in a flow file — the citation an e2e journey carries. */
76
+ const FLOW_CITATION_RE = /^#\s*SPEC:\s*([A-Z][A-Z0-9]*-\d{2,})/m;
77
+
78
+ /**
79
+ * The first clause id in a spec, or null. Used to pick something real to plant
80
+ * against: a clause that already exists and is already cited is the only kind
81
+ * whose removal proves a gate READS, rather than proving a gate rejects garbage.
82
+ * @param {string} text
83
+ * @returns {string|null}
84
+ */
85
+ export function firstClauseId(text) {
86
+ if (typeof text !== "string") return null;
87
+ const m = text.match(CLAUSE_RE);
88
+ return m ? m[1] : null;
89
+ }
90
+
91
+ /**
92
+ * The clause a flow cites, or null.
93
+ * @param {string} text
94
+ * @returns {string|null}
95
+ */
96
+ export function flowCitation(text) {
97
+ if (typeof text !== "string") return null;
98
+ const m = text.match(FLOW_CITATION_RE);
99
+ return m ? m[1] : null;
100
+ }
101
+
102
+ /**
103
+ * The clause-id PREFIX ("HOME" from "HOME-02"), used to mint planted ids that
104
+ * cannot collide with a real clause: a spec's own family with a number far above
105
+ * anything hand-authored.
106
+ * @param {string} clause
107
+ * @returns {string}
108
+ */
109
+ export function clauseFamily(clause) {
110
+ const m = String(clause ?? "").match(/^([A-Z][A-Z0-9]*)-/);
111
+ return m ? m[1] : "SPEC";
112
+ }
113
+
114
+ /**
115
+ * Decide which plants this tree can support, and say WHY each unavailable one
116
+ * is unavailable.
117
+ *
118
+ * @param {{specs?: Array<{rel: string, text: string}>,
119
+ * flows?: Array<{rel: string, text: string}>,
120
+ * harnessLib?: string[],
121
+ * testDir?: string|null}} tree
122
+ * @returns {{plants: Array<{kind: string, label: string, step: string,
123
+ * names: string[], target: object}>,
124
+ * unavailable: Array<{kind: string, reason: string}>}}
125
+ */
126
+ export function selectPlants(tree) {
127
+ const specs = Array.isArray(tree?.specs) ? tree.specs : [];
128
+ const flows = Array.isArray(tree?.flows) ? tree.flows : [];
129
+ const harnessLib = Array.isArray(tree?.harnessLib) ? tree.harnessLib : [];
130
+ const testDir = tree?.testDir ?? null;
131
+
132
+ const plants = [];
133
+ const unavailable = [];
134
+ const skip = (kind, reason) => unavailable.push({ kind, reason });
135
+
136
+ // ── Spec-derived plants ──────────────────────────────────────────────────
137
+ const spec = specs.find((s) => firstClauseId(s?.text));
138
+ const clause = spec ? firstClauseId(spec.text) : null;
139
+
140
+ if (!spec) {
141
+ const why = specs.length
142
+ ? `no clause of the form "- **ID-NN**" in ${specs.length} spec file(s)`
143
+ : "no spec files — nothing declares behavior to plant against";
144
+ for (const kind of [PLANT_KINDS.ORPHANED_CITATION, PLANT_KINDS.UNBOUND_CITATION, PLANT_KINDS.TIER_UNMET]) skip(kind, why);
145
+ } else {
146
+ // Renaming a live clause orphans every citation of it: specCoverage must
147
+ // name the id it can no longer find.
148
+ plants.push({
149
+ kind: PLANT_KINDS.ORPHANED_CITATION,
150
+ label: "orphaned citation",
151
+ step: "specCoverage",
152
+ names: [clause],
153
+ target: { spec: spec.rel, clause },
154
+ });
155
+
156
+ const family = clauseFamily(clause);
157
+ // A tag on a CLASS with no test inside the binding window: the clause
158
+ // exists, the tag exists, and nothing runs. The two remaining spec plants
159
+ // need somewhere to put that Kotlin, so they hang on a test source dir.
160
+ if (!testDir) {
161
+ const why = "no Kotlin test source directory — a planted citation has nowhere to live";
162
+ skip(PLANT_KINDS.UNBOUND_CITATION, why);
163
+ skip(PLANT_KINDS.TIER_UNMET, why);
164
+ } else {
165
+ plants.push({
166
+ kind: PLANT_KINDS.UNBOUND_CITATION,
167
+ label: "unbound citation",
168
+ step: "specCoverage",
169
+ names: [`${family}-99`],
170
+ target: { spec: spec.rel, clause: `${family}-99`, testDir },
171
+ });
172
+ plants.push({
173
+ kind: PLANT_KINDS.TIER_UNMET,
174
+ label: "tier unmet",
175
+ step: "specCoverage",
176
+ names: [`${family}-98`],
177
+ target: { spec: spec.rel, clause: `${family}-98`, testDir },
178
+ });
179
+ }
180
+ }
181
+
182
+ // ── Flow-derived plants ──────────────────────────────────────────────────
183
+ // Both strip EVERY citation from every flow, not just one line. e2eCoverage
184
+ // asks whether a screen feature has any device journey at all, so removing a
185
+ // single `# SPEC:` from a flow that carries several leaves the feature
186
+ // covered and the gate — correctly — green. A plant that does not actually
187
+ // produce the violation is worse than no plant: it reads as a calibrated
188
+ // gate while proving nothing.
189
+ const citingFlows = flows.filter((f) => flowCitation(f?.text));
190
+ if (!citingFlows.length) {
191
+ const why = flows.length
192
+ ? `no "# SPEC:" citation in ${flows.length} flow file(s) — e2eCoverage has nothing to lose`
193
+ : "no qa/e2e flows — this project declares no device journeys";
194
+ skip(PLANT_KINDS.FEATURE_WITHOUT_FLOW, why);
195
+ skip(PLANT_KINDS.NESTED_FLOW, why);
196
+ } else {
197
+ const rels = citingFlows.map((f) => f.rel);
198
+ // A real feature with a screen and a spec and no device journey at all.
199
+ plants.push({
200
+ kind: PLANT_KINDS.FEATURE_WITHOUT_FLOW,
201
+ label: "feature without a flow",
202
+ step: "e2eCoverage",
203
+ names: [],
204
+ // FAIL BY NAME, without knowing this project's feature names: the gate
205
+ // must name the feature it caught, in the [brackets] its reason uses.
206
+ reasonPattern: String.raw`\[[^\]\s]+\]`,
207
+ target: { flows: rels },
208
+ });
209
+ // The citations move into a subdirectory Maestro's directory run never
210
+ // executes. The tags exist, the YAML is real, and nothing runs it — which
211
+ // must read exactly like having no journey.
212
+ plants.push({
213
+ kind: PLANT_KINDS.NESTED_FLOW,
214
+ label: "flow the lane never runs",
215
+ step: "e2eCoverage",
216
+ names: [],
217
+ reasonPattern: String.raw`\[[^\]\s]+\]`,
218
+ target: { flows: rels, nestInto: "qa/e2e/wip" },
219
+ });
220
+ }
221
+
222
+ // ── Region plants — the floor ────────────────────────────────────────────
223
+ // A narrowed declaration un-attests a whole layer while every checker stays
224
+ // intact (payment-blueprint's planted proof); an edited lane cannot vouch for
225
+ // its own verdict. Both are read by harnessIntegrity, which needs only a lane.
226
+ plants.push({
227
+ kind: PLANT_KINDS.NARROWED_SURFACE,
228
+ label: "narrowed surface declaration",
229
+ step: "harnessIntegrity",
230
+ names: ["unrecorded"],
231
+ hookPattern: "harnessIntegrity|vouch",
232
+ target: { declaration: "qa/verified-surface.json" },
233
+ });
234
+
235
+ const spine = harnessLib.find((rel) => rel.endsWith("/spec-coverage.mjs")) ?? harnessLib[0] ?? null;
236
+ if (!spine) {
237
+ skip(PLANT_KINDS.EDITED_LANE, "no machine-owned lane files found under qa/lib — there is no region to edit");
238
+ } else {
239
+ plants.push({
240
+ kind: PLANT_KINDS.EDITED_LANE,
241
+ label: "edited lane cannot vouch",
242
+ step: "harnessIntegrity",
243
+ names: ["modified"],
244
+ hookPattern: "harnessIntegrity|vouch",
245
+ target: { file: spine },
246
+ });
247
+ }
248
+
249
+ return { plants, unavailable };
250
+ }
251
+
252
+ /**
253
+ * Is this set of plants enough to make a claim at all?
254
+ *
255
+ * The refusal is the point. An instrument that finds nothing to plant and
256
+ * prints PASS has proven that it ran, not that the framework returns — the
257
+ * "green with gaps" the harness exists to refuse, applied to itself.
258
+ *
259
+ * @param {Array<{kind: string}>} plants
260
+ * @returns {{ok: true}|{ok: false, reason: string}}
261
+ */
262
+ export function assessCoverage(plants) {
263
+ const kinds = new Set((plants ?? []).map((p) => p?.kind));
264
+ const missingFloor = FLOOR_KINDS.filter((k) => !kinds.has(k));
265
+ if (missingFloor.length === FLOOR_KINDS.length) {
266
+ return {
267
+ ok: false,
268
+ reason:
269
+ "no plant could be made at all — this tree has no machine-owned lane to check. " +
270
+ "Run this from a project root whose qa/lib/ carries the vendored harness.",
271
+ };
272
+ }
273
+ if (missingFloor.length) {
274
+ return { ok: false, reason: `the region plants are the floor and ${missingFloor.join(", ")} could not be made` };
275
+ }
276
+ return { ok: true };
277
+ }
278
+
279
+ /**
280
+ * Judge one planted run. Every branch here is a distinct framework defect and
281
+ * says which one it is: a hang, a lane that produced no receipt, a guard that
282
+ * did not fail, or a guard that failed WITHOUT NAMING what it caught.
283
+ *
284
+ * "FAIL BY NAME" is not decoration. A gate that fails with a generic message
285
+ * costs the reader the diagnosis every time it fires, and — worse — cannot be
286
+ * told apart from a gate failing for an unrelated reason, which is how a
287
+ * calibration passes on a gate that was never actually read.
288
+ *
289
+ * @param {{hung?: boolean, ms?: number, exit?: number|null,
290
+ * receipt?: {verdict?: string, steps?: Array<object>}|null,
291
+ * stderr?: string}} run
292
+ * @param {{label: string, step: string, names?: string[]}} plant
293
+ * @param {number} boundMs
294
+ * @returns {{ok: true}|{ok: false, reason: string}}
295
+ */
296
+ export function assessPlantRun(run, plant, boundMs) {
297
+ const label = plant?.label ?? "(unnamed plant)";
298
+ if (run?.hung) {
299
+ return { ok: false, reason: `"${label}" did not return inside ${boundMs}ms — the framework HANGS on a failing input` };
300
+ }
301
+ const receipt = run?.receipt;
302
+ if (!receipt) {
303
+ const tail = String(run?.stderr ?? "").slice(-600);
304
+ return { ok: false, reason: `"${label}" returned no receipt (exit ${run?.exit ?? "?"})${tail ? `:\n${tail}` : ""}` };
305
+ }
306
+ const row = (receipt.steps ?? []).find((s) => s?.name === plant.step);
307
+ if (receipt.verdict !== "FAIL" || !row || row.verdict !== "FAIL") {
308
+ return {
309
+ ok: false,
310
+ reason: `planted "${label}" and the lane said ${receipt.verdict} (${plant.step}: ${row ? row.verdict : "no row"}) — the guard did not FAIL BY NAME`,
311
+ };
312
+ }
313
+ const reason = String(row.reason ?? "");
314
+ for (const name of plant.names ?? []) {
315
+ if (!reason.includes(name)) {
316
+ return { ok: false, reason: `${plant.step} FAILed on "${label}" but did not NAME ${name}:\n${reason}` };
317
+ }
318
+ }
319
+ // Some gates name something the selector cannot know in advance — a feature
320
+ // this project happens to have. The pattern is how those still assert FAIL BY
321
+ // NAME instead of settling for "it went red".
322
+ if (plant.reasonPattern && !new RegExp(plant.reasonPattern).test(reason)) {
323
+ return { ok: false, reason: `${plant.step} FAILed on "${label}" but named nothing matching /${plant.reasonPattern}/:\n${reason}` };
324
+ }
325
+ return { ok: true };
326
+ }
327
+
328
+ /**
329
+ * Judge the baseline (and the post-revert re-run): a tree that cannot go green
330
+ * on its own has nothing to plant against, and every FAIL below would be
331
+ * unattributable.
332
+ *
333
+ * @param {{hung?: boolean, receipt?: object|null, exit?: number|null, stderr?: string}} run
334
+ * @param {string} phase "baseline" or "revert"
335
+ * @param {number} boundMs
336
+ * @returns {{ok: true}|{ok: false, reason: string}}
337
+ */
338
+ export function assessGreenRun(run, phase, boundMs) {
339
+ if (run?.hung) {
340
+ return { ok: false, reason: `the ${phase} run did not return inside ${boundMs}ms — the framework HANGS on a passing input` };
341
+ }
342
+ if (!run?.receipt) {
343
+ const tail = String(run?.stderr ?? "").slice(-600);
344
+ return { ok: false, reason: `the ${phase} run returned no receipt (exit ${run?.exit ?? "?"})${tail ? `:\n${tail}` : ""}` };
345
+ }
346
+ if (run.receipt.verdict !== "PASS") {
347
+ const bad = (run.receipt.steps ?? [])
348
+ .filter((s) => s?.verdict === "FAIL" || s?.verdict === "ERROR")
349
+ .map((s) => `${s.name}: ${String(s.reason ?? "").split("\n")[0]}`)
350
+ .join("; ");
351
+ const suffix =
352
+ phase === "revert"
353
+ ? " — the plants were not the only cause, or a revert did not restore the tree"
354
+ : " — fix the tree before calibrating anything against it";
355
+ return { ok: false, reason: `the ${phase} run is ${run.receipt.verdict} (${bad || "no failing row named"})${suffix}` };
356
+ }
357
+ return { ok: true };
358
+ }
359
+
360
+ /**
361
+ * Rule 1's stated bound, made checkable.
362
+ *
363
+ * Rule 1 says a calibration is "four steps, seconds each". That sentence has
364
+ * always been prose, and prose does not refuse: payment-blueprint calibrated
365
+ * through a 30–60 s composite Gradle build, which violated the rule on its own
366
+ * terms from the first cycle, and nothing noticed for three occurrences. The
367
+ * cost is not the single cycle — it is per-instance cost times every instance,
368
+ * which is how 38 minutes disappears into something that looks like rigour.
369
+ *
370
+ * A calibration cycle slower than this is not wrong, but it is a finding: it
371
+ * means the plant is being run through the wrong instrument, and the report
372
+ * must say so while the choice is still cheap to change.
373
+ */
374
+ export const CALIBRATION_BUDGET_MS = 5_000;
375
+
376
+ /**
377
+ * @param {Array<{label: string, ms: number}>} cycles
378
+ * @param {number} [budgetMs]
379
+ * @returns {{withinBudget: boolean, slowest: {label: string, ms: number}|null,
380
+ * totalMs: number, note: string|null}}
381
+ */
382
+ export function assessCalibrationCost(cycles, budgetMs = CALIBRATION_BUDGET_MS) {
383
+ const rows = Array.isArray(cycles) ? cycles.filter((c) => Number.isFinite(c?.ms)) : [];
384
+ const totalMs = rows.reduce((n, c) => n + c.ms, 0);
385
+ if (!rows.length) return { withinBudget: true, slowest: null, totalMs: 0, note: null };
386
+ const slowest = rows.reduce((a, b) => (b.ms > a.ms ? b : a));
387
+ if (slowest.ms <= budgetMs) return { withinBudget: true, slowest, totalMs, note: null };
388
+ return {
389
+ withinBudget: false,
390
+ slowest,
391
+ totalMs,
392
+ note:
393
+ `slowest calibration cycle "${slowest.label}" took ${slowest.ms}ms against a ${budgetMs}ms budget. ` +
394
+ `GATE-RULES Rule 1 calls a calibration "four steps, seconds each" — a cycle past that is being run ` +
395
+ `through the wrong instrument, and the cost is paid on every plant forever.`,
396
+ };
397
+ }
@@ -69,6 +69,11 @@ const EXCLUDED_PREFIXES = [
69
69
  "qa/.plan.json",
70
70
  "qa/.request.json",
71
71
  "qa/.plan-history.jsonl",
72
+ // Who is typing right now (qa/lib/agent-hold.mjs). A liveness declaration
73
+ // that could invalidate a receipt would mean an agent saying "I am here"
74
+ // un-proves the tree — the exact inversion this family of exclusions exists
75
+ // to prevent.
76
+ "qa/.agent-hold.json",
72
77
  "qa/evidence",
73
78
  "qa-artifacts",
74
79
  "qa/comments.json",
@@ -1244,7 +1244,7 @@ const stepsForProfile = {
1244
1244
  // end-to-end lane — every pure-Node step through the REAL runner, marker,
1245
1245
  // receipt and journal, and NO Gradle, no device, no network. Its job is to
1246
1246
  // prove the framework RETURNS, fast, in both directions, before any real
1247
- // work is pointed at it. scripts/framework-check.mjs drives it: PASS on a
1247
+ // work is pointed at it. qa/framework-check.mjs drives it: PASS on a
1248
1248
  // fresh scaffold, then FAIL BY NAME on one planted spec edit, each bounded
1249
1249
  // in seconds. Its receipt is refused as done-evidence (qa/receipt-check.mjs)
1250
1250
  // exactly like --fast: it proves the instrument, never the change.