create-cmp-cli 0.24.0 → 0.25.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 (141) hide show
  1. package/bin/create-cmp.mjs +33 -3
  2. package/package.json +10 -4
  3. package/packages/harness/package.json +18 -8
  4. package/packages/harness/src/approve.mjs +10 -11
  5. package/packages/harness/src/console/console-data.mjs +117 -0
  6. package/packages/harness/src/console/console-evidence.mjs +122 -0
  7. package/packages/harness/src/console/console-overview.mjs +642 -0
  8. package/packages/harness/src/console/console-shell.mjs +1139 -0
  9. package/packages/harness/src/console/console-tabs.mjs +2828 -0
  10. package/packages/harness/src/console/contrast.mjs +74 -0
  11. package/packages/harness/src/console/preview-service.mjs +1384 -0
  12. package/packages/harness/src/framework-check.mjs +271 -37
  13. package/packages/harness/src/lib/a11y.mjs +1 -1
  14. package/packages/harness/src/lib/affected-tests.mjs +99 -44
  15. package/packages/harness/src/lib/approvals.mjs +177 -350
  16. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  17. package/packages/harness/src/lib/comments.mjs +18 -4
  18. package/packages/harness/src/lib/determinism.mjs +275 -14
  19. package/packages/harness/src/lib/evidence-badge.mjs +34 -2
  20. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  21. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  22. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  23. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  24. package/packages/harness/src/lib/framework-check.mjs +434 -53
  25. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  26. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  27. package/packages/harness/src/lib/harness-region.mjs +106 -2
  28. package/packages/harness/src/lib/harness-source.mjs +142 -0
  29. package/packages/harness/src/lib/inputs-hash.mjs +175 -22
  30. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  31. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  32. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  33. package/packages/harness/src/lib/plan.mjs +44 -11
  34. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  35. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  36. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  37. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  38. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  39. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  40. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  41. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  42. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  43. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  44. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  45. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  46. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  47. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  48. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  49. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  50. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  51. package/packages/harness/src/lib/spec-model.mjs +262 -0
  52. package/packages/harness/src/lib/step-cache.mjs +11 -2
  53. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  54. package/packages/harness/src/lib/walk.mjs +1 -1
  55. package/packages/harness/src/preview-gallery.mjs +1 -1
  56. package/packages/harness/src/receipt-check.mjs +80 -24
  57. package/packages/harness/src/record-audit.mjs +1 -1
  58. package/packages/harness/src/scaffold-feature.mjs +10 -2
  59. package/packages/harness/src/verify.mjs +222 -142
  60. package/packages/harness/src/watch.mjs +146 -33
  61. package/packages/receipts/package.json +1 -1
  62. package/packages/receipts/src/index.mjs +2 -2
  63. package/packages/receipts/src/inputs-hash.mjs +175 -22
  64. package/packages/receipts/src/receipt-validate.mjs +84 -16
  65. package/src/commands/attach.mjs +128 -4
  66. package/src/commands/harden.mjs +7 -1
  67. package/src/commands/upgrade.mjs +7 -0
  68. package/src/lib/minimal.mjs +23 -1
  69. package/src/scaffold.mjs +8 -0
  70. package/template/.claude/settings.json +2 -2
  71. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  72. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  73. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  74. package/template/.githooks/pre-push +9 -0
  75. package/template/CLAUDE.md +55 -186
  76. package/template/docs/TESTING.md +1 -1
  77. package/template/gitignore +3 -0
  78. package/template/qa/approve.mjs +10 -11
  79. package/template/qa/comments.json +1 -1
  80. package/template/qa/evidence/schema.json +258 -34
  81. package/template/qa/framework-check.mjs +271 -37
  82. package/template/qa/harness-manifest.json +10 -0
  83. package/template/qa/lib/a11y.mjs +1 -1
  84. package/template/qa/lib/affected-tests.mjs +99 -44
  85. package/template/qa/lib/approvals.mjs +177 -350
  86. package/template/qa/lib/audit-cadence.mjs +1 -1
  87. package/template/qa/lib/comments.mjs +18 -4
  88. package/template/qa/lib/determinism.mjs +275 -14
  89. package/template/qa/lib/evidence-badge.mjs +34 -2
  90. package/template/qa/lib/evidence-ladder.mjs +202 -0
  91. package/template/qa/lib/evidence-level.mjs +144 -59
  92. package/template/qa/lib/feature-brief.mjs +53 -16
  93. package/template/qa/lib/flight-recorder.mjs +72 -8
  94. package/template/qa/lib/framework-check.mjs +434 -53
  95. package/template/qa/lib/harness-lock.mjs +87 -19
  96. package/template/qa/lib/harness-manifest.mjs +208 -0
  97. package/template/qa/lib/harness-region.mjs +106 -2
  98. package/template/qa/lib/harness-source.mjs +142 -0
  99. package/template/qa/lib/inputs-hash.mjs +175 -22
  100. package/template/qa/lib/lane-markers.mjs +78 -0
  101. package/template/qa/lib/lane-narrator.mjs +4 -2
  102. package/template/qa/lib/lane-runner.mjs +72 -6
  103. package/template/qa/lib/plan.mjs +44 -11
  104. package/template/qa/lib/plant-calibration.mjs +135 -0
  105. package/template/qa/lib/profile-loader.mjs +323 -0
  106. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  107. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  108. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  109. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  110. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  111. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  112. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  113. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  114. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  115. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  116. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  117. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  118. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
  119. package/template/qa/lib/receipt-validate.mjs +84 -16
  120. package/template/qa/lib/spec-coverage.mjs +179 -92
  121. package/template/qa/lib/spec-model.mjs +262 -0
  122. package/template/qa/lib/step-cache.mjs +11 -2
  123. package/template/qa/lib/step-outcomes.mjs +84 -151
  124. package/template/qa/lib/walk.mjs +1 -1
  125. package/template/qa/preview-gallery.mjs +1 -1
  126. package/template/qa/receipt-check.mjs +80 -24
  127. package/template/qa/record-audit.mjs +1 -1
  128. package/template/qa/scaffold-feature.mjs +10 -2
  129. package/template/qa/verified-surface.json +17 -0
  130. package/template/qa/verify.mjs +222 -142
  131. package/template/qa/watch.mjs +146 -33
  132. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  133. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  134. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  135. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  136. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  137. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  138. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  139. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  140. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  141. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -38,6 +38,17 @@
38
38
  // byte-identical at scaffold time — edit the package source, then run
39
39
  // `node scripts/sync-harness.mjs`.
40
40
 
41
+ // The core's fallback grammar, for the ONE thing this file reads out of a flow:
42
+ // the citation marker. Importing it rather than re-declaring the pattern is the
43
+ // point — a second copy of "what a citation looks like" is how the selector and
44
+ // the scanner came to disagree about the same file (see `flowCitation`). The
45
+ // import performs no IO; every function below still takes data and returns data.
46
+ // The badge floor's two halves, imported for the same reason as the grammar
47
+ // above: the plant below calibrates THE grader the lane runs and THE definition
48
+ // of plant material the runner plants from, not a re-statement of either.
49
+ import { evidenceLevel } from "./evidence-level.mjs";
50
+ import { plantCalibration } from "./plant-calibration.mjs";
51
+
41
52
  /**
42
53
  * Per-direction bound. Rule 0's whole claim is about SPEED of refusal, so the
43
54
  * default is small on purpose: the smoke profile is every pure-Node gate and no
@@ -62,18 +73,133 @@ export const PLANT_KINDS = Object.freeze({
62
73
  });
63
74
 
64
75
  /**
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.
76
+ * The plants that need nothing but a lane. The step that reads the machine-owned
77
+ * region exists in every project that has a lane at all — so these two are the
78
+ * floor. If even these cannot run, the tree has no harness to check and the
79
+ * instrument must say so rather than report a vacuous PASS.
69
80
  */
70
81
  export const FLOOR_KINDS = Object.freeze([PLANT_KINDS.NARROWED_SURFACE, PLANT_KINDS.EDITED_LANE]);
71
82
 
83
+ /**
84
+ * How the two floor plants say which receipt row they are about.
85
+ *
86
+ * THE ROW THAT VOUCHES IS THE ROW CARRYING THE VOUCHING DATA, not the row with
87
+ * a particular name — the principle qa/lib/receipt-validate.mjs settled in
88
+ * `checkLaneVouching` one layer out, mirrored here rather than re-invented.
89
+ * These two plants used to declare `step: "harnessIntegrity"` as a literal.
90
+ * That is a name the cmp pack chose for its own step; REQUIRED_EXPORTS never
91
+ * mentions it and a profile author has no way to discover it. On a pack that
92
+ * spells its self-check `harness_integrity`, both floor plants looked for a row
93
+ * that does not exist and the instrument reported "the guard did not FAIL BY
94
+ * NAME" — about a guard that had failed, by name, on the row immediately beside
95
+ * it. An adopter's Rule 0 check therefore fails on a working lane, and the
96
+ * message sends them into the harness instead of into the spelling.
97
+ *
98
+ * `vouching` says: find the row whose `harness` object carries the integrity
99
+ * check's own findings (the field the receipt schema documents, written by
100
+ * every row that performs it). `step` stays as the FALLBACK name, for receipts
101
+ * written before rows carried one — the same two-step lookup checkLaneVouching
102
+ * uses, and the same reason.
103
+ *
104
+ * `hookPattern` is matched against the Stop hook's refusal when the plant's
105
+ * receipt is forged to PASS. It named `harnessIntegrity` too, and the hook's
106
+ * refusal quotes the failing ROW'S name — so on that same pack the hook refused
107
+ * correctly and the instrument called the correct refusal a framework defect.
108
+ * The alternatives here are the core's own two wordings for this class of
109
+ * refusal (qa/lib/receipt-validate.mjs `checkLaneVouching`: the failing-rows
110
+ * branch and the did-not-vouch branch), neither of which is any pack's to spell.
111
+ */
112
+ const VOUCHING_STEP = Object.freeze({
113
+ step: "harnessIntegrity",
114
+ vouching: true,
115
+ hookPattern: "vouch|the row is the more specific truth",
116
+ });
117
+
118
+ /**
119
+ * Which of THIS pack's steps observes the violation a plant makes.
120
+ *
121
+ * THE SECOND HALF OF THE SAME LESSON, and the reason a fix applied to instances
122
+ * comes back. `VOUCHING_STEP` above took the literal `harnessIntegrity` out of
123
+ * the two floor plants because it is a name the cmp pack chose (NORTH-STAR
124
+ * §9.1). The other five plants kept theirs — `specCoverage` on the three spec
125
+ * plants, `e2eCoverage` on the two flow ones — and those are the same string in
126
+ * the same position, doing the same damage: a name the profile protocol never
127
+ * mentions, that REQUIRED_EXPORTS does not ask for and a profile author has no
128
+ * way to discover, asserted by the core against every profile there will ever
129
+ * be.
130
+ *
131
+ * Measured rather than reasoned about, because §9.1's lesson is that a stack
132
+ * assumption naming no stack is found only by running. A `harness init`
133
+ * skeleton with its two steps renamed to snake_case and NOTHING else changed:
134
+ * the lane FAILs, `spec_coverage` names the orphaned citation exactly as it
135
+ * should, and this instrument stops at the first plant with
136
+ *
137
+ * planted "orphaned citation" and the lane said FAIL (specCoverage: no row)
138
+ * — the guard did not FAIL BY NAME
139
+ *
140
+ * That is worse than the floor-plant case rather than equal to it, because the
141
+ * spec plants run FIRST and a plant failure aborts the run: on such a tree the
142
+ * two plants that were already fixed never execute at all, and the adopter's
143
+ * Rule 0 check accuses their working lane while pointing at the harness.
144
+ *
145
+ * Three ways to fix it; only one is honest here.
146
+ *
147
+ * THE DATA SELECTOR `vouching` USES IS NOT AVAILABLE. It works because the CORE
148
+ * computes the integrity findings and the row carries them — `harness`, the one
149
+ * part of that row the receipt schema documents — so every pack that performs
150
+ * the check answers to it whether it declares anything or not. There is no
151
+ * equivalent for spec or journey coverage. What cmp's coverage row carries is
152
+ * `details` ({clauses, withdrawn, tags, files}), written by cmp's own step
153
+ * function; the generated skeleton writes a different shape ({clauses,
154
+ * citations}) from the same core scanner; and nothing documents either as a
155
+ * contract. Inventing a field now would put an undocumented requirement on
156
+ * every future pack, silently unmet by any pack that omits it — the same
157
+ * undiscoverable literal wearing different clothes.
158
+ *
159
+ * WIDENING TO "ANY ROW THAT WENT RED" IS NOT IT EITHER. The kept plant in
160
+ * test/framework-check-agnostic.test.mjs builds the receipt that refutes it: the
161
+ * integrity row FAILs quoting the planted clause id while the coverage gate sits
162
+ * PASS beside it. A lookup that accepts that reads a broken lock as a working
163
+ * spec gate, which is the mix-up this whole instrument exists to catch.
164
+ *
165
+ * SO THE PACK SAYS — through the declaration that already exists for this
166
+ * instrument. `plants` is NORTH-STAR §6's eighth declaration, "the Rule 0/1
167
+ * violations the instrument runs forever", and its endpoint in
168
+ * AGNOSTIC-HARNESS-ARCHITECTURE.md §5.1 is a `plants(tree)` that returns the
169
+ * plants themselves. Which step asserts a plant is part of that plant, so it
170
+ * belongs there and no tenth declaration is invented to hold it. The KEY is a
171
+ * plant kind — PLANT_KINDS, the core's vocabulary — and the VALUE is the pack's
172
+ * spelling, so neither side has to learn the other's words.
173
+ *
174
+ * AND THE DEFAULT IS NOT cmp's SPELLING. That is the distinction §9.1 drew for
175
+ * `compileStepName`: "the distinction is the KEY's presence, not its value — a
176
+ * pack that declares none short-circuits on NOTHING rather than inheriting
177
+ * another stack's step name." Here a pack that declares no step for a kind gets
178
+ * null, and `plantRow` then holds the LANE instead of a row nobody named: the
179
+ * assertion widens honestly, still refuses a lane that stayed green or went red
180
+ * without naming what was planted, and reports the row it actually read.
181
+ * Declaring narrows it back to exactly one row; declaring nothing never inherits
182
+ * the wrong one.
183
+ *
184
+ * @param {{observedBy?: Record<string, string>}} tree
185
+ * @param {string} kind one of PLANT_KINDS
186
+ * @returns {string|null} this pack's name for the step, or null if it named none
187
+ */
188
+ function observingStep(tree, kind) {
189
+ const declared = tree?.observedBy?.[kind];
190
+ return typeof declared === "string" && declared.length > 0 ? declared : null;
191
+ }
192
+
72
193
  /** A clause id at the head of a spec list item: `- **HOME-02** — …`. */
73
194
  const CLAUSE_RE = /^-\s+\*\*([A-Z][A-Z0-9]*-\d{2,})\*\*/m;
74
195
 
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;
196
+ /**
197
+ * The ids on a citation line, once the MARKER has already matched it:
198
+ * `# SPEC: HOME-02, HOME-03 — …`. The id grammar is the core's (it is the same
199
+ * one scanCitations uses); the marker in front of it is the profile's.
200
+ */
201
+ const CITATION_IDS_RE = /SPEC:\s*([A-Z0-9,\s-]+)/;
202
+ const CLAUSE_ID_RE = /^[A-Z][A-Z0-9]*-\d{2,}$/;
77
203
 
78
204
  /**
79
205
  * The first clause id in a spec, or null. Used to pick something real to plant
@@ -90,13 +216,43 @@ export function firstClauseId(text) {
90
216
 
91
217
  /**
92
218
  * The clause a flow cites, or null.
219
+ *
220
+ * THE MARKER IS THE PROFILE'S. This matched `^#\s*SPEC:` and nothing else,
221
+ * which is true of Maestro YAML and false of a stack whose journeys are .ts,
222
+ * .kt, .rb or anything C-family. The cost was not a refusal: a project whose
223
+ * journeys cite with `//` was told `no "# SPEC:" citation in N flow file(s)`,
224
+ * both e2eCoverage plants stood down, and the check printed PASS with the
225
+ * reason folded into an ⓘ line — a gate reported as calibrated that had never
226
+ * been read. And the disagreement was internal: the core's own fallback marker
227
+ * (spec-model.mjs `DEFAULT_GRAMMAR.citationMarker`) has always accepted `//`,
228
+ * so qa/lib/spec-coverage.mjs `scanCitations` was counting the very citation
229
+ * this function could not see, in the same file, in the same tree.
230
+ *
231
+ * Same shape as scanCitations: test the profile's marker against the trimmed
232
+ * line, then read the ids after it. A profile that declares no `grammar` gets
233
+ * the same fallback the scan uses, so the two answers cannot drift apart again.
234
+ *
93
235
  * @param {string} text
236
+ * @param {{citationMarker?: RegExp}} [grammar] the SpecModel's grammar
94
237
  * @returns {string|null}
95
238
  */
96
- export function flowCitation(text) {
239
+ export function flowCitation(text, grammar) {
97
240
  if (typeof text !== "string") return null;
98
- const m = text.match(FLOW_CITATION_RE);
99
- return m ? m[1] : null;
241
+ // The marker is the profile's; with none declared nothing can be a citation — and the lane refuses that profile before this runs.
242
+ const MARKER = grammar?.citationMarker;
243
+ if (!(MARKER instanceof RegExp)) return null;
244
+ for (const line of text.split("\n")) {
245
+ const trimmed = line.trim();
246
+ if (!MARKER.test(trimmed)) continue;
247
+ const m = trimmed.match(CITATION_IDS_RE);
248
+ if (!m) continue;
249
+ const id = m[1]
250
+ .split(/[,\s]+/)
251
+ .map((s) => s.trim())
252
+ .find((s) => CLAUSE_ID_RE.test(s));
253
+ if (id) return id;
254
+ }
255
+ return null;
100
256
  }
101
257
 
102
258
  /**
@@ -115,11 +271,26 @@ export function clauseFamily(clause) {
115
271
  * Decide which plants this tree can support, and say WHY each unavailable one
116
272
  * is unavailable.
117
273
  *
274
+ * `grammar` is the SpecModel's (qa/lib/spec-model.mjs): the caller passes the
275
+ * profile's, and a caller that passes none gets the same fallback the coverage
276
+ * scan uses. It decides one thing here — what a citation in a journey looks
277
+ * like — and getting it from the profile is what stops this instrument from
278
+ * disagreeing with the gate it is calibrating (see `flowCitation`).
279
+ *
280
+ * `observedBy` is the profile's `plants.observedBy` — plant kind → the name
281
+ * THIS pack gives the step that catches that kind of violation. A kind it does
282
+ * not name gets a null `step`, which is an assertion over the lane rather than
283
+ * over one row, never cmp's spelling by default (see `observingStep`).
284
+ *
118
285
  * @param {{specs?: Array<{rel: string, text: string}>,
119
286
  * flows?: Array<{rel: string, text: string}>,
120
287
  * harnessLib?: string[],
121
- * testDir?: string|null}} tree
122
- * @returns {{plants: Array<{kind: string, label: string, step: string,
288
+ * testDir?: string|null,
289
+ * plantsDeclared?: boolean,
290
+ * unmeetableTier?: string,
291
+ * observedBy?: Record<string, string>,
292
+ * grammar?: {citationMarker?: RegExp}}} tree
293
+ * @returns {{plants: Array<{kind: string, label: string, step: string|null,
123
294
  * names: string[], target: object}>,
124
295
  * unavailable: Array<{kind: string, reason: string}>}}
125
296
  */
@@ -128,6 +299,14 @@ export function selectPlants(tree) {
128
299
  const flows = Array.isArray(tree?.flows) ? tree.flows : [];
129
300
  const harnessLib = Array.isArray(tree?.harnessLib) ? tree.harnessLib : [];
130
301
  const testDir = tree?.testDir ?? null;
302
+ // Where this stack keeps the flow-shaped citation files the lane executes
303
+ // (the profile's `layout.flows.dir`). The nested-flow plant must land INSIDE
304
+ // it — a subdirectory the runner walks past — so the directory cannot be a
305
+ // constant here. Derived from the flows themselves when the caller does not
306
+ // say, so a caller with flows always gets the plant.
307
+ const flowsDir = typeof tree?.flowsDir === "string" && tree.flowsDir
308
+ ? tree.flowsDir
309
+ : (flows.find((f) => typeof f?.rel === "string" && f.rel.includes("/"))?.rel.replace(/\/[^/]*$/, "") ?? null);
131
310
 
132
311
  const plants = [];
133
312
  const unavailable = [];
@@ -143,12 +322,13 @@ export function selectPlants(tree) {
143
322
  : "no spec files — nothing declares behavior to plant against";
144
323
  for (const kind of [PLANT_KINDS.ORPHANED_CITATION, PLANT_KINDS.UNBOUND_CITATION, PLANT_KINDS.TIER_UNMET]) skip(kind, why);
145
324
  } else {
146
- // Renaming a live clause orphans every citation of it: specCoverage must
147
- // name the id it can no longer find.
325
+ // Renaming a live clause orphans every citation of it: whichever step reads
326
+ // clause↔test citations must name the id it can no longer find. WHICH step
327
+ // that is is the pack's to spell and never this file's — see observingStep.
148
328
  plants.push({
149
329
  kind: PLANT_KINDS.ORPHANED_CITATION,
150
330
  label: "orphaned citation",
151
- step: "specCoverage",
331
+ step: observingStep(tree, PLANT_KINDS.ORPHANED_CITATION),
152
332
  names: [clause],
153
333
  target: { spec: spec.rel, clause },
154
334
  });
@@ -158,39 +338,71 @@ export function selectPlants(tree) {
158
338
  // exists, the tag exists, and nothing runs. The two remaining spec plants
159
339
  // need somewhere to put that Kotlin, so they hang on a test source dir.
160
340
  if (!testDir) {
161
- const why = "no Kotlin test source directory a planted citation has nowhere to live";
341
+ // TWO different causes reached this branch and the message named only
342
+ // one of them, wrongly: a profile that declares no `plants` never even
343
+ // looks for a test directory (framework-check.mjs passes testDir: null),
344
+ // so an adopter with two perfectly good test directories was told they
345
+ // had none. Worse, the plant this silently skips is `tier-unmet` — the
346
+ // one that calibrates the gate the whole instrument exists to prove —
347
+ // and the run still printed PASS. Saying which cause it is turns an
348
+ // invisible gap into a one-line fix. (And the core names no language.)
349
+ const why = tree?.plantsDeclared === false
350
+ ? "this profile declares no plants — add a `plants` export { testFileBasename, unboundCitationSource, tierUnmetCitationSource, unmeetableTier }"
351
+ : "no test source directory found — a planted citation has nowhere to live";
162
352
  skip(PLANT_KINDS.UNBOUND_CITATION, why);
163
353
  skip(PLANT_KINDS.TIER_UNMET, why);
164
354
  } else {
165
355
  plants.push({
166
356
  kind: PLANT_KINDS.UNBOUND_CITATION,
167
357
  label: "unbound citation",
168
- step: "specCoverage",
358
+ step: observingStep(tree, PLANT_KINDS.UNBOUND_CITATION),
169
359
  names: [`${family}-99`],
170
360
  target: { spec: spec.rel, clause: `${family}-99`, testDir },
171
361
  });
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
- });
362
+ // The tier this plant declares must be the PROFILE'S. The core used to
363
+ // fall back to `?? "e2e"` at the write site — a CMP tier name, asserted
364
+ // by the spine into any stack's spec file. A profile that ships plants
365
+ // but names no unmeetable tier would then plant a clause tagged with a
366
+ // tier it does not define, and the resulting FAIL would be right for
367
+ // entirely the wrong reason. There is no default; there is a skip.
368
+ if (!tree?.unmeetableTier) {
369
+ skip(
370
+ PLANT_KINDS.TIER_UNMET,
371
+ "this profile's plants declare no `unmeetableTier` — name a tier in `tiers.satisfying` that a host-tier test cannot satisfy",
372
+ );
373
+ } else {
374
+ plants.push({
375
+ kind: PLANT_KINDS.TIER_UNMET,
376
+ label: "tier unmet",
377
+ step: observingStep(tree, PLANT_KINDS.TIER_UNMET),
378
+ names: [`${family}-98`],
379
+ target: { spec: spec.rel, clause: `${family}-98`, testDir, unmeetableTier: tree.unmeetableTier },
380
+ });
381
+ }
179
382
  }
180
383
  }
181
384
 
182
385
  // ── Flow-derived plants ──────────────────────────────────────────────────
183
386
  // Both strip EVERY citation from every flow, not just one line. e2eCoverage
184
387
  // 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
388
+ // single citation from a flow that carries several leaves the feature
186
389
  // covered and the gate — correctly — green. A plant that does not actually
187
390
  // produce the violation is worse than no plant: it reads as a calibrated
188
391
  // gate while proving nothing.
189
- const citingFlows = flows.filter((f) => flowCitation(f?.text));
392
+ //
393
+ // WHICH flows cite is decided by the profile's citation marker, never by `#`:
394
+ // see `flowCitation`. The skip below prints the marker it used, because "no
395
+ // citation in 3 flow files" over three flows that all carry one is a sentence
396
+ // that sends the reader to the flows instead of to the grammar.
397
+ const grammar = tree?.grammar ?? null;
398
+ const marker = grammar?.citationMarker instanceof RegExp ? grammar.citationMarker : null;
399
+ const citingFlows = marker ? flows.filter((f) => flowCitation(f?.text, grammar)) : [];
190
400
  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";
401
+ const why = !marker
402
+ ? "the profile declares no grammar.citationMarker the plant selector cannot read a citation (the lane refuses this profile for the same reason)"
403
+ : flows.length
404
+ ? `no citation matching /${marker.source}/ in ${flows.length} flow file(s) — the journey-coverage gate has nothing to lose`
405
+ : `no flows${flowsDir ? ` under ${flowsDir}` : ""} — this project declares no journeys`;
194
406
  skip(PLANT_KINDS.FEATURE_WITHOUT_FLOW, why);
195
407
  skip(PLANT_KINDS.NESTED_FLOW, why);
196
408
  } else {
@@ -199,36 +411,44 @@ export function selectPlants(tree) {
199
411
  plants.push({
200
412
  kind: PLANT_KINDS.FEATURE_WITHOUT_FLOW,
201
413
  label: "feature without a flow",
202
- step: "e2eCoverage",
414
+ step: observingStep(tree, PLANT_KINDS.FEATURE_WITHOUT_FLOW),
203
415
  names: [],
204
416
  // FAIL BY NAME, without knowing this project's feature names: the gate
205
417
  // must name the feature it caught, in the [brackets] its reason uses.
206
418
  reasonPattern: String.raw`\[[^\]\s]+\]`,
207
419
  target: { flows: rels },
208
420
  });
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.
421
+ // The citations move into a subdirectory of the flows directory that the
422
+ // lane's own directory run does not descend into. The tags exist, the flow
423
+ // is real, and nothing executes it — which must read exactly like having no
424
+ // journey at all.
212
425
  plants.push({
213
426
  kind: PLANT_KINDS.NESTED_FLOW,
214
427
  label: "flow the lane never runs",
215
- step: "e2eCoverage",
428
+ step: observingStep(tree, PLANT_KINDS.NESTED_FLOW),
216
429
  names: [],
217
430
  reasonPattern: String.raw`\[[^\]\s]+\]`,
218
- target: { flows: rels, nestInto: "qa/e2e/wip" },
431
+ target: { flows: rels, nestInto: `${flowsDir}/wip` },
219
432
  });
220
433
  }
221
434
 
222
435
  // ── Region plants — the floor ────────────────────────────────────────────
223
436
  // A narrowed declaration un-attests a whole layer while every checker stays
224
437
  // 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.
438
+ // its own verdict. Both are read by the step that vouches for the lane, which
439
+ // needs only a lane — see `vouching` below for how that row is found.
226
440
  plants.push({
227
441
  kind: PLANT_KINDS.NARROWED_SURFACE,
228
442
  label: "narrowed surface declaration",
229
- step: "harnessIntegrity",
230
- names: ["unrecorded"],
231
- hookPattern: "harnessIntegrity|vouch",
443
+ ...VOUCHING_STEP,
444
+ // Assert the FILE, not an internal state word. "unrecorded" only holds when
445
+ // the declaration is absent from the lock — true for a freshly stamped app,
446
+ // false for a repo whose lock was taken after `harness init` wrote the
447
+ // surface, where the identical edit reads as "modified". Both are the same
448
+ // correct refusal; pinning one of them made the instrument fail on a lane
449
+ // that was working. What the plant actually cares about is that the refusal
450
+ // NAMES what it refused over, which is now true in either state.
451
+ names: ["qa/verified-surface.json"],
232
452
  target: { declaration: "qa/verified-surface.json" },
233
453
  });
234
454
 
@@ -239,9 +459,11 @@ export function selectPlants(tree) {
239
459
  plants.push({
240
460
  kind: PLANT_KINDS.EDITED_LANE,
241
461
  label: "edited lane cannot vouch",
242
- step: "harnessIntegrity",
462
+ ...VOUCHING_STEP,
463
+ // "modified" is the integrity check's own status word (qa/lib/harness-
464
+ // region.mjs), not a step name and not a stack's — every pack's row
465
+ // reports it, because the core computes it.
243
466
  names: ["modified"],
244
- hookPattern: "harnessIntegrity|vouch",
245
467
  target: { file: spine },
246
468
  });
247
469
  }
@@ -276,6 +498,148 @@ export function assessCoverage(plants) {
276
498
  return { ok: true };
277
499
  }
278
500
 
501
+ /**
502
+ * THE BADGE FLOOR'S KEPT PLANT — "a profile with no calibrated plants earns no
503
+ * rung" (NORTH-STAR.md §8.9), watched failing rather than assumed to work.
504
+ *
505
+ * WHY IT IS HERE AND NOT ONLY IN THE HARNESS'S OWN SUITE. GATE-RULES Rule 1 is
506
+ * explicit about where a plant goes — "Add the plant to `qa/framework-check.mjs`
507
+ * and run that" — and the reason is this gate's own subject: the floor exists to
508
+ * protect a claim made on EVERY adopter's receipt, so the plant that proves it
509
+ * still bites has to run in every adopter's tree, forever, not once in ours.
510
+ * A gate proved only in the repository that wrote it is a gate proved on the one
511
+ * profile it was written against, which is how every wrong verdict in
512
+ * NORTH-STAR.md §9.1 survived a green suite.
513
+ *
514
+ * THE VIOLATION PLANTED is the twin `scripts/stage2-gate.mjs` built: this
515
+ * profile's own ladder and a lane whose rows all PASS, graded with the `plants`
516
+ * declaration REMOVED. Before 2026-09-08 that earned `L1` — measured on two
517
+ * scratch adopters differing in exactly one export. It must now earn nothing,
518
+ * and the same rows WITH the declaration must still earn their rung, or the
519
+ * plant would go green on a grader that had simply stopped grading anyone.
520
+ * Those two halves are the same pair criteria F and G of the stage-2 gate make,
521
+ * and they are made here in microseconds instead of two adopted trees.
522
+ *
523
+ * NO TREE IS TOUCHED AND NO LANE IS RUN. The rows are synthesised from the
524
+ * ladder's own step names, so this asserts over the GRADER — which is the only
525
+ * thing the floor lives in. That is also its limit, and it is stated in the
526
+ * runner's own output rather than hidden here: it proves the rung a lane WOULD
527
+ * be given, not that a lane was run.
528
+ *
529
+ * @param {{ladder: object|null|undefined, plants: object|null|undefined}} profile
530
+ * the profile's resolved ladder (qa/lib/evidence-ladder.mjs) and its `plants`
531
+ * declaration — both exactly as the lane runner reads them
532
+ * @returns {{ok: true, rung: string}
533
+ * |{ok: false, reason: string}
534
+ * |{available: false, reason: string}} `available: false` is neither a pass
535
+ * nor a failure: it is a plant this tree cannot make, reported with its cause
536
+ * the same way every unavailable plant is.
537
+ */
538
+ export function assessBadgeFloor({ ladder, plants } = {}) {
539
+ const calibrated = plantCalibration(plants);
540
+ if (!calibrated.ok) {
541
+ return { available: false, reason: `${calibrated.reason} — there is no declaration to strip, and no rung to lose` };
542
+ }
543
+ if (!ladder || typeof ladder !== "object") {
544
+ return {
545
+ available: false,
546
+ reason:
547
+ "this profile declares no `ladder` a reader can see without starting a lane, so no rung exists either way " +
548
+ "(a ladder declared only on the object `steps(ctx)` returns is invisible here — qa/lib/evidence-ladder.mjs)",
549
+ };
550
+ }
551
+ const names = [...new Set([...(ladder.l0Required ?? []), ...(ladder.l1Required ?? [])])];
552
+ const rows = names.map((name) => ({ name, verdict: "PASS" }));
553
+ const earned = evidenceLevel(rows, null, { mode: "full", ladder, plants });
554
+ if (!earned) {
555
+ return {
556
+ available: false,
557
+ reason:
558
+ `this profile's ladder grants no rung even with ${names.length ? names.join(", ") : "every declared step"} PASSing, ` +
559
+ "so there is no rung for the floor to withhold",
560
+ };
561
+ }
562
+ // THE PLANT: the same rows, the same ladder, and no plant material.
563
+ const stripped = evidenceLevel(rows, null, { mode: "full", ladder, plants: undefined });
564
+ if (stripped) {
565
+ return {
566
+ ok: false,
567
+ reason:
568
+ `PLANTED the profile's own ladder with its \`plants\` declaration removed and the lane still earned ` +
569
+ `${stripped.rung} · ${stripped.name} — the badge floor is not being applied. NORTH-STAR.md §8.9: "a profile ` +
570
+ `with no calibrated plants earns no rung"; §6.7: a profile ships plants this instrument can run, or it ships ` +
571
+ `without a badge. The grader is qa/lib/evidence-level.mjs and the floor it asks is qa/lib/plant-calibration.mjs`,
572
+ };
573
+ }
574
+ return { ok: true, rung: earned.rung };
575
+ }
576
+
577
+ /**
578
+ * What "FAIL BY NAME" means for one plant, in ONE place.
579
+ *
580
+ * Two callers need this answer — `plantRow`, to pick the row a plant with no
581
+ * declared step is about, and `assessPlantRun`, to say which part of the
582
+ * naming is missing — and a second copy of "what counts as naming" is exactly
583
+ * how `flowCitation` and `scanCitations` came to disagree about the same file
584
+ * in the same tree. So there is one, and the caller that needs a sentence gets
585
+ * the offending part back rather than re-deriving it.
586
+ *
587
+ * @param {{reason?: string}} row
588
+ * @param {{names?: string[], reasonPattern?: string}} plant
589
+ * @returns {{kind: "name"|"pattern", want: string, reason: string}|null} null when the row names everything asked of it
590
+ */
591
+ function missingName(row, plant) {
592
+ const reason = String(row?.reason ?? "");
593
+ for (const name of plant?.names ?? []) {
594
+ if (!reason.includes(name)) return { kind: "name", want: name, reason };
595
+ }
596
+ if (plant?.reasonPattern && !new RegExp(plant.reasonPattern).test(reason)) {
597
+ return { kind: "pattern", want: plant.reasonPattern, reason };
598
+ }
599
+ return null;
600
+ }
601
+
602
+ /**
603
+ * The receipt row a plant's assertion is about — found three ways, in the order
604
+ * of how much each one can be trusted about a pack nobody here has met.
605
+ *
606
+ * BY ITS DATA, for the two floor plants. Their assertion is about the step that
607
+ * VOUCHES FOR THE LANE, whatever the pack calls it, and the core computes the
608
+ * findings that row carries — so `harness` identifies it on every pack, with
609
+ * the name only as the pre-`harness` fallback. See VOUCHING_STEP.
610
+ *
611
+ * BY THE NAME THE PACK GAVE IT, when the pack declared one for this plant kind
612
+ * (`plants.observedBy`). This is the sharp case and the one to prefer: a
613
+ * different row failing instead is the mix-up the instrument exists to catch,
614
+ * and only a named row can catch it.
615
+ *
616
+ * BY THE LANE, when the pack declared nothing. There is no honest third source
617
+ * for another pack's step name (see `observingStep`), so the assertion becomes
618
+ * "some row went red naming what was planted" — the row carrying the plant's own
619
+ * fingerprint. Only FAIL rows can satisfy it: an ERROR is a step that fell over,
620
+ * not a gate that read something and refused, and treating the two alike is how
621
+ * a crash comes to read as a calibrated gate. When no FAIL row names the plant,
622
+ * a red row is returned anyway rather than null, because `assessPlantRun` then
623
+ * prints that row's reason — "this is what your lane actually said" is a far
624
+ * better sentence to hand an adopter than "no row" — and the FAIL rows are
625
+ * offered ahead of the ERROR ones, or a step that blew up alongside a gate that
626
+ * fired correctly would be reported as the finding. This branch is weaker than
627
+ * a declared name and is meant to be: it is what a pack gets for not saying,
628
+ * and it still bites.
629
+ *
630
+ * @param {Array<object>} steps
631
+ * @param {{step?: string|null, vouching?: boolean, names?: string[], reasonPattern?: string}} plant
632
+ * @returns {object|null}
633
+ */
634
+ export function plantRow(steps, plant) {
635
+ const rows = Array.isArray(steps) ? steps : [];
636
+ const byName = plant?.step ? (rows.find((s) => s && s.name === plant.step) ?? null) : null;
637
+ if (plant?.vouching) return rows.find((s) => s && s.harness && typeof s.harness === "object") ?? byName;
638
+ if (plant?.step) return byName;
639
+ const failed = rows.filter((s) => s && s.verdict === "FAIL");
640
+ return failed.find((s) => !missingName(s, plant)) ?? failed[0] ?? rows.find((s) => s && s.verdict === "ERROR") ?? null;
641
+ }
642
+
279
643
  /**
280
644
  * Judge one planted run. Every branch here is a distinct framework defect and
281
645
  * says which one it is: a hang, a lane that produced no receipt, a guard that
@@ -286,10 +650,15 @@ export function assessCoverage(plants) {
286
650
  * told apart from a gate failing for an unrelated reason, which is how a
287
651
  * calibration passes on a gate that was never actually read.
288
652
  *
653
+ * Every message below names the row it ACTUALLY READ, not the row the plant
654
+ * asked for: on a pack whose steps are spelled differently those are two
655
+ * different strings, and reporting the request instead of the finding is how a
656
+ * lookup miss reads as a gate defect.
657
+ *
289
658
  * @param {{hung?: boolean, ms?: number, exit?: number|null,
290
659
  * receipt?: {verdict?: string, steps?: Array<object>}|null,
291
660
  * stderr?: string}} run
292
- * @param {{label: string, step: string, names?: string[]}} plant
661
+ * @param {{label: string, kind?: string, step?: string|null, names?: string[], vouching?: boolean}} plant
293
662
  * @param {number} boundMs
294
663
  * @returns {{ok: true}|{ok: false, reason: string}}
295
664
  */
@@ -303,24 +672,36 @@ export function assessPlantRun(run, plant, boundMs) {
303
672
  const tail = String(run?.stderr ?? "").slice(-600);
304
673
  return { ok: false, reason: `"${label}" returned no receipt (exit ${run?.exit ?? "?"})${tail ? `:\n${tail}` : ""}` };
305
674
  }
306
- const row = (receipt.steps ?? []).find((s) => s?.name === plant.step);
675
+ const row = plantRow(receipt.steps, plant);
676
+ const rowName = row?.name ?? plant.step ?? "the lane";
307
677
  if (receipt.verdict !== "FAIL" || !row || row.verdict !== "FAIL") {
678
+ // A missing row says what was looked for. "harnessIntegrity: no row" over a
679
+ // receipt whose vouching row is called something else is a true sentence
680
+ // that points at the wrong thing. The third branch is the pack that named
681
+ // no step for this kind: "no row" would be the same wrong sentence again,
682
+ // so it says what the lookup actually was AND names the declaration that
683
+ // would make it sharp — an adopter cannot fix a requirement nobody states.
684
+ const found = row
685
+ ? `${rowName}: ${row.verdict}`
686
+ : plant.vouching
687
+ ? `no row carries a \`harness\` object and none is named ${plant.step}`
688
+ : plant.step
689
+ ? `${plant.step}: no row`
690
+ : `no row on this receipt went red at all, and this profile's \`plants.observedBy\` names no step for ${plant.kind ?? "this plant"}`;
308
691
  return {
309
692
  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`,
693
+ reason: `planted "${label}" and the lane said ${receipt.verdict} (${found}) — the guard did not FAIL BY NAME`,
311
694
  };
312
695
  }
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
696
  // 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}` };
697
+ // this project happens to have. `reasonPattern` is how those still assert
698
+ // FAIL BY NAME instead of settling for "it went red"; `missingName` holds
699
+ // both halves so the row lookup above cannot drift from the judgement here.
700
+ const missing = missingName(row, plant);
701
+ if (missing) {
702
+ return missing.kind === "name"
703
+ ? { ok: false, reason: `${rowName} FAILed on "${label}" but did not NAME ${missing.want}:\n${missing.reason}` }
704
+ : { ok: false, reason: `${rowName} FAILed on "${label}" but named nothing matching /${missing.want}/:\n${missing.reason}` };
324
705
  }
325
706
  return { ok: true };
326
707
  }