create-cmp-cli 0.23.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 (145) 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 +46 -18
  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 +747 -0
  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/agent-hold.mjs +234 -0
  16. package/packages/harness/src/lib/approvals.mjs +177 -350
  17. package/packages/harness/src/lib/audit-cadence.mjs +1 -1
  18. package/packages/harness/src/lib/comments.mjs +18 -4
  19. package/packages/harness/src/lib/determinism.mjs +275 -14
  20. package/packages/harness/src/lib/evidence-badge.mjs +38 -6
  21. package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
  22. package/packages/harness/src/lib/evidence-level.mjs +144 -59
  23. package/packages/harness/src/lib/feature-brief.mjs +53 -16
  24. package/packages/harness/src/lib/flight-recorder.mjs +72 -8
  25. package/packages/harness/src/lib/framework-check.mjs +778 -0
  26. package/packages/harness/src/lib/harness-lock.mjs +87 -19
  27. package/packages/harness/src/lib/harness-manifest.mjs +208 -0
  28. package/packages/harness/src/lib/harness-region.mjs +106 -2
  29. package/packages/harness/src/lib/harness-source.mjs +142 -0
  30. package/packages/harness/src/lib/inputs-hash.mjs +180 -22
  31. package/packages/harness/src/lib/lane-markers.mjs +78 -0
  32. package/packages/harness/src/lib/lane-narrator.mjs +4 -2
  33. package/packages/harness/src/lib/lane-runner.mjs +72 -6
  34. package/packages/harness/src/lib/plan.mjs +44 -11
  35. package/packages/harness/src/lib/plant-calibration.mjs +135 -0
  36. package/packages/harness/src/lib/profile-loader.mjs +323 -0
  37. package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
  38. package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
  39. package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
  40. package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  41. package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
  42. package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
  43. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  44. package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
  45. package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
  46. package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
  47. package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
  48. package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
  49. package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  50. package/packages/harness/src/lib/receipt-validate.mjs +84 -16
  51. package/packages/harness/src/lib/spec-coverage.mjs +179 -92
  52. package/packages/harness/src/lib/spec-model.mjs +262 -0
  53. package/packages/harness/src/lib/step-cache.mjs +11 -2
  54. package/packages/harness/src/lib/step-outcomes.mjs +84 -151
  55. package/packages/harness/src/lib/walk.mjs +1 -1
  56. package/packages/harness/src/plan.mjs +30 -1
  57. package/packages/harness/src/preview-gallery.mjs +1 -1
  58. package/packages/harness/src/receipt-check.mjs +95 -26
  59. package/packages/harness/src/record-audit.mjs +1 -1
  60. package/packages/harness/src/scaffold-feature.mjs +10 -2
  61. package/packages/harness/src/verify.mjs +222 -142
  62. package/packages/harness/src/watch.mjs +146 -33
  63. package/packages/receipts/package.json +1 -1
  64. package/packages/receipts/src/index.mjs +2 -2
  65. package/packages/receipts/src/inputs-hash.mjs +180 -22
  66. package/packages/receipts/src/receipt-validate.mjs +84 -16
  67. package/src/commands/attach.mjs +128 -4
  68. package/src/commands/harden.mjs +7 -1
  69. package/src/commands/upgrade.mjs +7 -0
  70. package/src/lib/minimal.mjs +23 -1
  71. package/src/scaffold.mjs +8 -0
  72. package/template/.claude/settings.json +2 -2
  73. package/template/.claude/skills/add-feature/SKILL.md +7 -3
  74. package/template/.claude/skills/add-repository/SKILL.md +2 -2
  75. package/template/.claude/skills/add-screen/SKILL.md +8 -4
  76. package/template/.githooks/pre-push +9 -0
  77. package/template/CLAUDE.md +55 -186
  78. package/template/docs/TESTING.md +1 -1
  79. package/template/gitignore +4 -0
  80. package/template/qa/approve.mjs +46 -18
  81. package/template/qa/comments.json +1 -1
  82. package/template/qa/evidence/schema.json +258 -34
  83. package/template/qa/framework-check.mjs +747 -0
  84. package/template/qa/harness-manifest.json +10 -0
  85. package/template/qa/lib/a11y.mjs +1 -1
  86. package/template/qa/lib/affected-tests.mjs +99 -44
  87. package/template/qa/lib/agent-hold.mjs +234 -0
  88. package/template/qa/lib/approvals.mjs +177 -350
  89. package/template/qa/lib/audit-cadence.mjs +1 -1
  90. package/template/qa/lib/comments.mjs +18 -4
  91. package/template/qa/lib/determinism.mjs +275 -14
  92. package/template/qa/lib/evidence-badge.mjs +38 -6
  93. package/template/qa/lib/evidence-ladder.mjs +202 -0
  94. package/template/qa/lib/evidence-level.mjs +144 -59
  95. package/template/qa/lib/feature-brief.mjs +53 -16
  96. package/template/qa/lib/flight-recorder.mjs +72 -8
  97. package/template/qa/lib/framework-check.mjs +778 -0
  98. package/template/qa/lib/harness-lock.mjs +87 -19
  99. package/template/qa/lib/harness-manifest.mjs +208 -0
  100. package/template/qa/lib/harness-region.mjs +106 -2
  101. package/template/qa/lib/harness-source.mjs +142 -0
  102. package/template/qa/lib/inputs-hash.mjs +180 -22
  103. package/template/qa/lib/lane-markers.mjs +78 -0
  104. package/template/qa/lib/lane-narrator.mjs +4 -2
  105. package/template/qa/lib/lane-runner.mjs +72 -6
  106. package/template/qa/lib/plan.mjs +44 -11
  107. package/template/qa/lib/plant-calibration.mjs +135 -0
  108. package/template/qa/lib/profile-loader.mjs +323 -0
  109. package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
  110. package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
  111. package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
  112. package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
  113. package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
  114. package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
  115. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
  116. package/template/qa/lib/profiles/cmp/index.mjs +47 -0
  117. package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
  118. package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
  119. package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
  120. package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
  121. package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +230 -28
  122. package/template/qa/lib/receipt-validate.mjs +84 -16
  123. package/template/qa/lib/spec-coverage.mjs +179 -92
  124. package/template/qa/lib/spec-model.mjs +262 -0
  125. package/template/qa/lib/step-cache.mjs +11 -2
  126. package/template/qa/lib/step-outcomes.mjs +84 -151
  127. package/template/qa/lib/walk.mjs +1 -1
  128. package/template/qa/plan.mjs +30 -1
  129. package/template/qa/preview-gallery.mjs +1 -1
  130. package/template/qa/receipt-check.mjs +95 -26
  131. package/template/qa/record-audit.mjs +1 -1
  132. package/template/qa/scaffold-feature.mjs +10 -2
  133. package/template/qa/verified-surface.json +17 -0
  134. package/template/qa/verify.mjs +222 -142
  135. package/template/qa/watch.mjs +146 -33
  136. /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  137. /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  138. /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  139. /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  140. /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
  141. /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
  142. /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
  143. /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
  144. /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
  145. /package/template/qa/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
@@ -29,92 +29,177 @@
29
29
  // the inner loop, a signal rather than evidence, so a fast receipt must
30
30
  // never be silently reused as if it were a full-lane result: pass the
31
31
  // run's mode and "fast" derives null, always.
32
+ // - A PROFILE THAT SHIPS NO PLANTS EARNS NO RUNG — the badge floor. A ladder
33
+ // names rungs; plants are what prove the steps under them still bite. A
34
+ // profile with the second and not the first is a vocabulary nobody
35
+ // calibrated, and grading it says something about a lane that nothing
36
+ // checked. See "the badge floor" below for the episode and the argument.
32
37
  // - The rung is COARSE by design. The per-step list (and the existing
33
38
  // strength string) stays the fine print alongside it — steps that may
34
39
  // SKIP for honest configuration absence (approvals unreviewed, no
35
40
  // exported schemas) are visible there; only the always-run steps gate
36
41
  // the desktop rungs, and only executed PASSes gate the device rungs.
37
42
 
38
- /** The scaffold profile's step set (verify.mjs stepsForProfile.scaffold). */
39
- const SCAFFOLD_CORE = [
40
- "specCoverage",
41
- "approvals",
42
- "componentStories",
43
- "reachability",
44
- "e2eCoverage",
45
- "archDoc",
46
- "schemaHistory",
47
- "build",
48
- "unitTests",
49
- ];
43
+ // The Compose ladder that used to live here is qa/lib/profiles/cmp/ladder.mjs
44
+ // (Stage 0 PR 3). This module derives a rung from WHATEVER ladder it is handed;
45
+ // it carries no step names of its own and defaults to none — a profile that
46
+ // declares no ladder earns no rung. The rung vocabulary above is one profile's
47
+ // example, kept because it is the clearest statement of what a rung is for.
48
+ //
49
+ // WHERE THAT LADDER COMES FROM IS NOT THIS MODULE'S QUESTION, and saying it was
50
+ // is how a defect hid: this comment used to name one of the two spellings a
51
+ // profile may use (`evidenceLadder`, on the pack), which is the one the runner
52
+ // happened to read while the Stop hook read the other. Resolving between them
53
+ // is qa/lib/evidence-ladder.mjs's single job, and every caller goes through it.
54
+ // The fields read below are the fields that resolution compares — the pairing
55
+ // is derived from this source by test/evidence-ladder.test.mjs so the two
56
+ // cannot drift.
50
57
 
51
- /** Steps every PASS must carry to claim even L0 they run in every profile and never SKIP. */
52
- const L0_REQUIRED = ["build", "unitTests"];
58
+ // ── THE BADGE FLOOR, AND WHY THE GRADER IS WHERE IT LIVES ───────────────────
59
+ //
60
+ // Until 2026-09-08 this module derived a rung from the ladder alone. Two
61
+ // scratch adopters built by scripts/stage2-gate.mjs, differing in EXACTLY one
62
+ // export — one shipping `plants`, one shipping none — ran the real lane and
63
+ // both earned `L1 · every promise bound`. The twin that could calibrate nothing
64
+ // was graded identically to the twin that could, against a guarantee that was
65
+ // already binding (NORTH-STAR.md §8.9, §6.7, and §3's third *never*).
66
+ //
67
+ // The floor could have been enforced one layer out, in the lane runner, leaving
68
+ // this function a pure ladder→rung derivation. It is here instead, because a
69
+ // grader that can be called without the floor is a grader that WILL be: the
70
+ // ladder's own two-spellings defect (§9.2) happened precisely because two
71
+ // readers reached the same question by different routes and only one of them
72
+ // was right. There is one grader, it takes the profile's plant declaration, and
73
+ // a caller that does not pass one gets NO RUNG rather than an ungrounded one —
74
+ // forgetting fails CLOSED, which is the only direction a floor may fail in.
75
+ //
76
+ // WHAT COUNTS AS PLANTS is deliberately not decided here. It is the
77
+ // instrument's judgement, and asking qa/lib/plant-calibration.mjs is how the
78
+ // grader and the instrument are kept from holding two definitions of the same
79
+ // word — the `flowCitation`/`scanCitations` failure, which cost a gate nobody
80
+ // was calibrating. That file also carries the argument for WHICH reading of
81
+ // "calibrated" is implemented (structurally usable, not merely declared, and
82
+ // not the observed reading, which no lane run can derive).
83
+ import { plantCalibration } from "./plant-calibration.mjs";
53
84
 
54
85
  /**
55
- * The steps that distinguish full desktop evidence (L1) from the scaffold
56
- * checks. None of these can SKIP — they PASS or FAIL — so "PASSed" is exactly
57
- * "ran green".
86
+ * Derive the receipt's evidence rung AND the sentence explaining an absent one.
87
+ *
88
+ * TWO RETURNS, ONE DECISION. `evidenceLevel` below is this function's `.level`
89
+ * and nothing else — there is no second copy of the derivation — because the
90
+ * rung and the reason it is missing must never be able to disagree. The reason
91
+ * exists because "no rung, no explanation" is itself a defect this project has
92
+ * already paid for: a foreign author who declared their ladder in the seeded
93
+ * spelling got a green lane, no rung and not one word about why, and it took a
94
+ * gate written in another ecosystem to find it (NORTH-STAR.md §9.2).
95
+ *
96
+ * The `why` is NOT written to the receipt. A receipt records what was earned;
97
+ * this is a sentence for a human reading a lane, and putting it on the receipt
98
+ * would change the receipt's schema, which is a decision for an ADR rather than
99
+ * for this fix (fit-test question 5).
100
+ *
101
+ * @param {Array<{name: string, verdict: string}>} stepResults the lane's steps
102
+ * @param {string} [profile] the profile that was REQUESTED (scaffold | local |
103
+ * ci | …) — recorded context only, never part of the derivation, and
104
+ * deliberately never quoted in `why`: it names the RUN, not the pack, and a
105
+ * message that confused the two would send a reader to the wrong file
106
+ * @param {{mode?: string, ladder?: object|null, plants?: object|null}} [opts]
107
+ * @returns {{level: {rung: string, name: string, satisfiedBy: string[]}|null, why: string|null}}
108
+ * `why` is null exactly when `level` is not.
58
109
  */
59
- const L1_REQUIRED = ["releaseBuild", "conformance", "goldenTrees", "a11y"];
60
-
61
- /** On-device EXECUTION steps the only steps that can earn L2. */
62
- const DEVICE_EXECUTION = ["e2eSmoke", "tokenDrift", "androidChecks"];
110
+ export function gradeEvidence(stepResults, profile, { mode, ladder, plants } = {}) { // eslint-disable-line no-unused-vars
111
+ const none = (why) => ({ level: null, why });
112
+ if (mode === "fast") return none("no evidence rung: this run was `--fast`, the inner loop a signal, never evidence");
63
113
 
64
- /** The one step that can lift L2 to L3. */
65
- const RELEASE_EXECUTION = "releaseSmoke";
66
-
67
- const RUNG_NAMES = { L0: "scaffold", L1: "desktop", L2: "device", L3: "release" };
114
+ // THE BADGE FLOOR IS ASKED FIRST, before the ladder, because it decides
115
+ // whether this profile may be graded AT ALL: a ladder over uncalibrated
116
+ // plants is a vocabulary for a claim nobody checked, so the ladder question
117
+ // is moot until this one is answered. Both absences are reported together
118
+ // when both apply — an author who fixes one and comes back for the other has
119
+ // been charged two round trips by a message that knew both answers.
120
+ const calibrated = plantCalibration(plants);
121
+ if (!calibrated.ok) {
122
+ const alsoNoLadder = !ladder || typeof ladder !== "object";
123
+ return none(
124
+ // The §6.7 citation is already inside `calibrated.reason`; repeating it
125
+ // here made the most-read line in the lane cite the same section twice.
126
+ `no evidence rung: ${calibrated.reason}. A profile ships plants its Rule 0 instrument can run — ` +
127
+ "`node qa/framework-check.mjs` — or it ships without a badge (§8.9)" +
128
+ (alsoNoLadder ? ", and this profile declares no `ladder` either, which alone earns no rung" : ""),
129
+ );
130
+ }
68
131
 
69
- /**
70
- * The Compose Multiplatform pack's ladder the step names above, as one
71
- * object a pack hands to the spine. THE LADDER IS THE PACK'S, NOT THE SPINE'S
72
- * (2026-09-03): vendored into a Kotlin backend, these names graded its
73
- * strongest run detekt, Konsist, gitleaksas L0 "scaffold" and
74
- * made L1 unreachable by construction. A fixed-amount understatement is not
75
- * conservative, it is wrong, and receipts are where labels get quoted. So a
76
- * pack declares its ladder (`evidenceLadder` on createXSteps' return); a pack
77
- * that declares none earns no rung at all, which is the honest grade for a
78
- * ladder nobody has calibrated. Every field is a list of step names except
79
- * `release`, one name. `names` maps rung → label.
80
- */
81
- export const CMP_LADDER = Object.freeze({
82
- scaffoldCore: Object.freeze(SCAFFOLD_CORE),
83
- l0Required: Object.freeze(L0_REQUIRED),
84
- l1Required: Object.freeze(L1_REQUIRED),
85
- deviceExecution: Object.freeze(DEVICE_EXECUTION),
86
- release: RELEASE_EXECUTION,
87
- names: Object.freeze(RUNG_NAMES),
88
- });
132
+ // No ladder → no rung. There is no default: a rung is a claim in a
133
+ // profile's own vocabulary, and the spine has none to lend. `null` and
134
+ // `undefined` mean the same thing here the pack declared nothing.
135
+ if (!ladder || typeof ladder !== "object") {
136
+ return none("no evidence rung: this profile declares no `ladder`, so there are no rungs to earn which is the honest grade, not a failure");
137
+ }
138
+ const level = rungFor(stepResults, ladder);
139
+ if (level) return { level, why: null };
140
+ const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
141
+ const red = steps.filter((s) => s.verdict === "FAIL" || s.verdict === "ERROR");
142
+ if (red.length) {
143
+ return none(
144
+ `no evidence rung: ${red.map((s) => `${s.name} ${s.verdict}`).join(", ")} — a lane that failed, or that could not check, has no rung`,
145
+ );
146
+ }
147
+ const floor = (ladder.l0Required ?? []).filter((name) => !steps.some((s) => s.name === name && s.verdict === "PASS"));
148
+ return none(
149
+ `no evidence rung: this ladder's floor rung needs ${(ladder.l0Required ?? []).join(", ") || "(nothing)"} to PASS and ` +
150
+ `${floor.join(", ")} did not — a SKIP never earns a rung`,
151
+ );
152
+ }
89
153
 
90
154
  /**
91
155
  * Derive the receipt's evidence rung from the lane's step results.
92
156
  *
157
+ * THE ONE GRADER. Everything that shows a rung derives it here or reads one a
158
+ * run of this function wrote onto a receipt; nothing recomputes it its own way.
159
+ *
93
160
  * @param {Array<{name: string, verdict: string}>} stepResults the lane's steps
94
161
  * as recorded on the receipt (verdict PASS | FAIL | SKIP per step)
95
162
  * @param {string} [profile] the profile that was REQUESTED — recorded context
96
163
  * only, never part of the derivation (see honesty rules above)
97
- * @param {{mode?: string}} [opts] the run's mode ("full" | "fast"). "fast"
98
- * derives null unconditionally the inner loop earns no rung (see honesty
99
- * rules above). Absent/other values mean full.
164
+ * @param {{mode?: string, ladder?: object|null, plants?: object|null}} [opts]
165
+ * the run's mode ("full" | "fast"), the PROFILE's resolved ladder
166
+ * (qa/lib/evidence-ladder.mjs) and the PROFILE's `plants` declaration.
167
+ * "fast" derives null unconditionally — the inner loop earns no rung.
168
+ * Absent/other values for mode mean full; an absent `plants` means no rung,
169
+ * because the badge floor fails closed (see above).
100
170
  * @returns {{rung: "L0"|"L1"|"L2"|"L3", name: string, satisfiedBy: string[]}|null}
101
- * null when any step FAILed (a failed lane has no rung), when the run was
102
- * fast-mode (the inner loop is never evidence), or when even the L0 floor
103
- * was not earned. `satisfiedBy` lists the PASSed steps the rung counts as
104
- * its evidence, in lane order.
171
+ * null when the profile ships no usable plants (the badge floor), when it
172
+ * declares no ladder, when any step FAILed (a failed lane has no rung), when
173
+ * the run was fast-mode (the inner loop is never evidence), or when even the
174
+ * L0 floor was not earned. `satisfiedBy` lists the PASSed steps the rung
175
+ * counts as its evidence, in lane order. A caller that wants the SENTENCE for
176
+ * an absent rung calls `gradeEvidence` and reads `.why` — same decision, one
177
+ * implementation.
178
+ */
179
+ export function evidenceLevel(stepResults, profile, opts = {}) {
180
+ return gradeEvidence(stepResults, profile, opts).level;
181
+ }
182
+
183
+ /**
184
+ * The ladder half of the derivation, once the badge floor has been cleared:
185
+ * which rung THESE steps earn under THIS ladder, and nothing about whether the
186
+ * profile was entitled to be graded. Private on purpose — `gradeEvidence` is
187
+ * the only caller, so the floor cannot be walked around from inside this file
188
+ * any more than it can from outside it.
189
+ *
190
+ * @param {Array<{name: string, verdict: string}>} stepResults
191
+ * @param {object} ladder
192
+ * @returns {{rung: string, name: string, satisfiedBy: string[]}|null}
105
193
  */
106
- export function evidenceLevel(stepResults, profile, { mode, ladder } = {}) { // eslint-disable-line no-unused-vars
107
- if (mode === "fast") return null; // the inner loop derives no rung — ever
108
- // `ladder` absent → the Compose ladder (every caller before packs declared
109
- // one). `ladder: null` → the pack declares none: no rung, by decision.
110
- if (ladder === null) return null;
111
- const L = ladder ?? CMP_LADDER;
194
+ function rungFor(stepResults, ladder) {
195
+ const L = ladder;
112
196
  const SCAFFOLD_CORE = L.scaffoldCore ?? [];
113
197
  const L0_REQUIRED = L.l0Required ?? [];
114
198
  const L1_REQUIRED = L.l1Required ?? [];
115
199
  const DEVICE_EXECUTION = L.deviceExecution ?? [];
116
200
  const RELEASE_EXECUTION = L.release ?? null;
117
- const RUNG_NAMES = L.names ?? CMP_LADDER.names;
201
+ // A ladder without labels still grades — the rung id is its own label.
202
+ const RUNG_NAMES = L.names ?? { L0: "L0", L1: "L1", L2: "L2", L3: "L3" };
118
203
  const steps = Array.isArray(stepResults) ? stepResults.filter((s) => s && typeof s.name === "string") : [];
119
204
  // A failed lane has no rung — and a lane with a step that could not run
120
205
  // (ERROR) has none either: a rung is evidence, and "could not check" is not.
@@ -50,6 +50,7 @@ import path from "node:path";
50
50
 
51
51
  import { computeInputsHash } from "./inputs-hash.mjs";
52
52
  import { CLAUSE_LINE_RE, scanCitations } from "./spec-coverage.mjs";
53
+ import { requireSpecModel } from "./spec-model.mjs";
53
54
 
54
55
  export const FEATURES_DIR_REL = "docs/features";
55
56
 
@@ -197,7 +198,7 @@ export function parseFeatureBlock(markdown) {
197
198
  ...new Set(
198
199
  parsed.specs
199
200
  .filter((s) => typeof s === "string" && s.trim() !== "")
200
- .map((s) => s.trim().replace(/^specs\//, "").replace(/\.spec\.md$/, "")),
201
+ .map((s) => s.trim().replace(/^[A-Za-z0-9_./-]*?specs\//, "").replace(/\.spec\.md$/, "")),
201
202
  ),
202
203
  ]
203
204
  : [];
@@ -223,10 +224,10 @@ export function parseFeatureBlock(markdown) {
223
224
  * caller already has one (avoids re-parsing; same answer either way)
224
225
  * @returns {string[]} spec names, e.g. ["catalog", "entry-editing"]
225
226
  */
226
- export function pairedSpecNames(markdown, name, block) {
227
+ export function pairedSpecNames(markdown, name, block, specsDir = "specs") {
227
228
  const declared = (block ?? parseFeatureBlock(markdown)).specs ?? [];
228
229
  if (declared.length > 0) return declared;
229
- const fromHeader = specHeaderNames(markdown);
230
+ const fromHeader = specHeaderNames(markdown, specsDir);
230
231
  if (fromHeader.length > 0) return fromHeader;
231
232
  return [name];
232
233
  }
@@ -236,7 +237,7 @@ export function pairedSpecNames(markdown, name, block) {
236
237
  * paragraph — the line starting `**Spec:**` through the next blank line, so
237
238
  * later prose that merely MENTIONS a spec path never redirects the pairing.
238
239
  */
239
- function specHeaderNames(markdown) {
240
+ function specHeaderNames(markdown, specsDir = "specs") {
240
241
  if (typeof markdown !== "string") return [];
241
242
  const lines = markdown.split("\n");
242
243
  const start = lines.findIndex((l) => /^\*\*Spec:?\*\*/.test(l.trim()));
@@ -244,7 +245,7 @@ function specHeaderNames(markdown) {
244
245
  const para = [];
245
246
  for (let i = start; i < lines.length && lines[i].trim() !== ""; i++) para.push(lines[i]);
246
247
  const out = [];
247
- for (const m of para.join("\n").matchAll(/specs\/([A-Za-z0-9_-]+)\.spec\.md/g)) {
248
+ for (const m of para.join("\n").matchAll(new RegExp(`${specsDir}/([A-Za-z0-9_-]+)\\.spec\\.md`, "g"))) {
248
249
  if (!out.includes(m[1])) out.push(m[1]);
249
250
  }
250
251
  return out;
@@ -315,6 +316,25 @@ export function receiptAttestation(root) {
315
316
  * shared scans (callers resolving many features pass these once)
316
317
  * @returns {object}
317
318
  */
319
+ /**
320
+ * Where this stack's journey for `name` is written — the profile's flow
321
+ * directory when it declares one, else the tier's own name.
322
+ *
323
+ * Stage 0 PR 6c: the doneReason used to say "write the journey in
324
+ * qa/e2e/<name>.yaml" on every stack, which is a Maestro flow path. The
325
+ * sentence a human is told to act on must name a file their project could
326
+ * actually have.
327
+ * @param {import("./spec-model.mjs").SpecModel} model
328
+ * @param {string} name
329
+ * @returns {string}
330
+ */
331
+ function journeyWhere(model, name) {
332
+ if (model.flows && model.flows.dir && model.flows.exts.length) {
333
+ return `write it in ${model.flows.dir}/${name}${model.flows.exts[0]} and cite the clause it proves`;
334
+ }
335
+ return `add a ${model.tiers.journey} test that cites one of its clauses`;
336
+ }
337
+
318
338
  export function deriveFeatureStatus(root, brief, pre = {}) {
319
339
  let markdown = "";
320
340
  let readable = true;
@@ -330,24 +350,40 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
330
350
  // The paired specs (walk-legibility L1): usually one, by filename; a brief
331
351
  // may name several. Clauses concatenate in declaration order — "done" means
332
352
  // every live clause across ALL of them is cited.
333
- const specNames = pairedSpecNames(markdown, brief.name, block);
334
- const specRels = specNames.map((n) => `specs/${n}.spec.md`);
353
+ // The MODEL first, because the spec directory is the profile's and this
354
+ // module used to hardcode `specs/`. A project declaring `"specs": "docs/specs"`
355
+ // — a legal, validated manifest field the lane's own scanner honours — had
356
+ // `deriveFeatureStatus` looking somewhere else entirely: specExists false,
357
+ // total 0, provenDone false forever, and the Features view telling a human to
358
+ // start writing a spec that already existed. The header of spec-coverage.mjs
359
+ // says these two readers exist so the Features view and the lane can never
360
+ // disagree about the same clause. They disagreed.
361
+ const model = pre.model ?? requireSpecModel(root);
362
+ const specNames = pairedSpecNames(markdown, brief.name, block, model.specsDir);
363
+ const specRels = specNames.map((n) => `${model.specsDir}/${n}.spec.md`);
335
364
  const specExists = specRels.every((rel) => fs.existsSync(path.join(root, rel)));
336
365
  const specRel = specRels.join(" + ");
337
- const citations = pre.citations ?? scanCitations(root);
366
+ const citations = pre.citations ?? scanCitations(root, model);
338
367
  const citedIds = new Set(citations.map((t) => t.id));
339
- // Which clauses a DEVICE journey proves: citations from qa/e2e flows (tier
340
- // "e2e" spec-coverage's tierForFile). A UI feature (screens: true) is not
341
- // done until at least one of its live clauses is cited from a flow: JVM
342
- // tests prove logic and structure, the flow proves the journey on a device.
343
- const e2eCitedIds = new Set(citations.filter((t) => t.tier === "e2e").map((t) => t.id));
368
+ // Which clauses a JOURNEY proves: citations from the profile's journey tier
369
+ // (mobile: flows under qa/e2e). A UI feature (screens: true) is not done
370
+ // until at least one of its live clauses is cited from that tier: JVM tests
371
+ // prove logic and structure, the journey proves it on the target.
372
+ const journeyTier = model.tiers.journey;
373
+ const e2eCitedIds = new Set(citations.filter((t) => journeyTier && t.tier === journeyTier).map((t) => t.id));
344
374
  const clauses = specRels
345
375
  .flatMap((rel) => clausesOfSpec(root, rel))
346
376
  .map((c) => ({ ...c, cited: citedIds.has(c.id), e2eCited: e2eCitedIds.has(c.id) }));
347
377
  const live = clauses.filter((c) => !c.withdrawn);
348
378
  const covered = live.filter((c) => c.cited).length;
349
379
  const e2eCovered = live.filter((c) => c.e2eCited).length;
350
- const needsJourney = block.screens === true && block.unrouted !== true;
380
+ // A journey is only required when the profile HAS a journey tier.
381
+ // `tiers.journey` is documented as nullable ("or null when this stack has no
382
+ // journey tier") and the citation filter above already yields nothing for
383
+ // null — but this condition never asked, so on a backend, a CLI or a library
384
+ // every feature with a surface was permanently un-done and the remedy printed
385
+ // was literally "add a null test that cites one of its clauses".
386
+ const needsJourney = block.screens === true && block.unrouted !== true && Boolean(model.tiers.journey);
351
387
 
352
388
  const receipt = pre.receipt ?? receiptAttestation(root);
353
389
  const provenDone =
@@ -384,7 +420,7 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
384
420
  : live.length === 0
385
421
  ? `${specRel} has no live clauses — nothing is promised yet`
386
422
  : needsJourney && covered === live.length && e2eCovered === 0
387
- ? `${covered}/${live.length} clauses cited, but none from a qa/e2e flow — a UI feature is proven on a device: write the journey in qa/e2e/${brief.name}.yaml and cite the clause it proves`
423
+ ? `${covered}/${live.length} clauses cited, but none from the ${journeyTier} tier — a feature with a surface is proven where it runs, not only on the host: ${journeyWhere(model, brief.name)}`
388
424
  : covered < live.length
389
425
  ? `${covered}/${live.length} clauses cited — ${live.length - covered} promise(s) have no citing test`
390
426
  : !receipt.present
@@ -404,6 +440,7 @@ export function deriveFeatureStatus(root, brief, pre = {}) {
404
440
  export function deriveAllFeatures(root) {
405
441
  const briefs = listFeatureBriefs(root);
406
442
  if (briefs.length === 0) return [];
407
- const pre = { citations: scanCitations(root), receipt: receiptAttestation(root) };
443
+ const model = requireSpecModel(root);
444
+ const pre = { model, citations: scanCitations(root, model), receipt: receiptAttestation(root) };
408
445
  return briefs.map((b) => deriveFeatureStatus(root, b, pre));
409
446
  }
@@ -52,13 +52,44 @@ import fs from "node:fs";
52
52
  import path from "node:path";
53
53
 
54
54
  export const FLIGHT_JOURNAL_REL_PATH = "qa/flight-recorder.jsonl";
55
- export const FLIGHT_SCHEMA = "cmp-flight/1";
55
+ // ADR-0007's principle, applied to the journal. The receipt's rename left this
56
+ // behind, and the reason that ADR gave for deferring LOCK_SCHEMA — "it finishes
57
+ // that journey with the package work" — does NOT carry over: a journal is
58
+ // APPEND-ONLY and is never rewritten, so deferring it is deciding never. It is
59
+ // committed in every stamped app, so a Go project's repo carried `cmp` on every
60
+ // line of it. Nothing reads this field (readFlightJournal accepts any object),
61
+ // so old entries and new ones coexist in one file and both stay readable.
62
+ export const FLIGHT_SCHEMA = "prooflane-flight/1";
56
63
 
57
64
  // Below this many entries the report carries an explicit shortness note —
58
65
  // two entries are two facts, not a trend, and the report must say so rather
59
66
  // than let a reader infer a pattern from a journal that cannot support one.
60
67
  const SHORT_JOURNAL_FLOOR = 5;
61
68
 
69
+ /**
70
+ * The highest rung recorded, said the only way it can honestly be said: with
71
+ * the pack that graded it.
72
+ *
73
+ * One pack — "L2 (pack cmp)". Several — each named, because §8.9 makes "the
74
+ * highest of an L2 and an L1 from different packs" a question with no answer.
75
+ * A rung whose entry predates pack recording is `unattributed`, never quietly
76
+ * credited to whichever pack is present.
77
+ *
78
+ * @param {{highestRung: string|null, highestRungByPack?: Record<string,string>}} device
79
+ * @returns {string} "" when nothing was recorded
80
+ */
81
+ export function describeHighestRung(device) {
82
+ const byPack = device?.highestRungByPack ?? {};
83
+ const entries = Object.entries(byPack);
84
+ if (entries.length === 0) return "";
85
+ if (entries.length === 1) {
86
+ const [pack, rung] = entries[0];
87
+ return ` (highest evidence rung recorded: ${rung}, pack ${pack})`;
88
+ }
89
+ const parts = entries.sort(([a], [b]) => a.localeCompare(b)).map(([pack, rung]) => `${rung} (${pack})`);
90
+ return ` (highest evidence rung per pack — not comparable across them: ${parts.join(", ")})`;
91
+ }
92
+
62
93
  /**
63
94
  * Shape one lane run into a journal entry. Pure — verify.mjs passes what it
64
95
  * already computed for the receipt, so the journal can never disagree with
@@ -71,6 +102,11 @@ const SHORT_JOURNAL_FLOOR = 5;
71
102
  * @param {{rung: string}|null} run.evidenceLevel the derived rung (or null —
72
103
  * fast runs and FAILed lanes carry none, and the journal records that
73
104
  * honestly rather than borrowing a rung from elsewhere)
105
+ * @param {{id: string}|null} run.pack WHICH PACK graded that rung. A rung is
106
+ * comparable only within its pack (NORTH-STAR §8.9): a `cmp` L2 and a
107
+ * backend pack's L2 are different claims. An entry that stored a rung with
108
+ * no pack could not be compared to anything without inventing the missing
109
+ * half, and `highestRung` below did exactly that across every entry.
74
110
  * @param {Array<{name: string, verdict: string, reason?: string}>} run.steps
75
111
  * the lane's step results, verbatim
76
112
  * @param {string|null} run.sha parent HEAD at run time (null before git init)
@@ -82,7 +118,7 @@ const SHORT_JOURNAL_FLOOR = 5;
82
118
  * (self-heals, fallbacks) — each a short verbatim description
83
119
  * @returns {object} one journal entry (JSON-serializable)
84
120
  */
85
- export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps, sha, durationMs, onDeviceSteps, degraded }) {
121
+ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, pack, steps, sha, durationMs, onDeviceSteps, degraded }) {
86
122
  const stepList = Array.isArray(steps) ? steps.filter((s) => s && typeof s.name === "string") : [];
87
123
  return {
88
124
  schema: FLIGHT_SCHEMA,
@@ -92,6 +128,10 @@ export function buildFlightEntry({ profile, mode, verdict, evidenceLevel, steps,
92
128
  mode,
93
129
  verdict,
94
130
  evidenceRung: evidenceLevel?.rung ?? null,
131
+ // The grader, beside the grade. Null on an entry that earned no rung, and
132
+ // on one written before packs were recorded — absent is honest, invented is
133
+ // not, and the summary below refuses to compare what it cannot attribute.
134
+ pack: typeof pack?.id === "string" ? pack.id : null,
95
135
  durationMs,
96
136
  // durationMs per step (additive, schema id unchanged — old entries stay
97
137
  // readable): the source for the lane's own "usually ~Ns" narration
@@ -231,11 +271,31 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
231
271
 
232
272
  const fullRuns = runs.filter((e) => e.mode === "full");
233
273
  const deviceReached = runs.filter((e) => Array.isArray(e.deviceSteps) && e.deviceSteps.length > 0);
274
+ // THE HIGHEST RUNG, PER PACK — never across them.
275
+ //
276
+ // This used to take the maximum over every entry in the journal, which is a
277
+ // cross-pack comparison in a single line: if the journal held a `cmp` L2 and
278
+ // a backend pack's L1, it reported "L2" as though one number described both.
279
+ // §8.9 forbids exactly that, and the fix is not a smarter sort — it is
280
+ // refusing to put two incomparable claims in one ordering.
281
+ //
282
+ // An entry with a rung and no pack (written before packs were recorded)
283
+ // cannot be attributed, so it is counted under `null` and reported as
284
+ // unattributed rather than folded into whichever pack happens to be present.
234
285
  const rungOrder = { L0: 0, L1: 1, L2: 2, L3: 3 };
235
- const highestRung = runs
236
- .map((e) => e.evidenceRung)
237
- .filter((r) => typeof r === "string" && r in rungOrder)
238
- .sort((a, b) => rungOrder[b] - rungOrder[a])[0] ?? null;
286
+ const highestRungByPack = new Map();
287
+ for (const e of runs) {
288
+ const r = e.evidenceRung;
289
+ if (typeof r !== "string" || !(r in rungOrder)) continue;
290
+ const id = typeof e.pack === "string" ? e.pack : null;
291
+ const seen = highestRungByPack.get(id);
292
+ if (!seen || rungOrder[r] > rungOrder[seen]) highestRungByPack.set(id, r);
293
+ }
294
+ // The single-value form survives ONLY where the journal holds one pack, which
295
+ // is every project that has not changed profiles. With more than one it is
296
+ // null, and the per-pack map is the answer — a reader asking for one number
297
+ // over two packs is asking a question §8.9 says has no answer.
298
+ const highestRung = highestRungByPack.size === 1 ? [...highestRungByPack.values()][0] : null;
239
299
 
240
300
  // Longest stretch with no full lane — only computable BETWEEN two recorded
241
301
  // full runs. One full run is a date, not a stretch; the report says so
@@ -269,7 +329,7 @@ export function summarizeFlightJournal(entries, { now = new Date() } = {}) {
269
329
  lastAgoMs: lastFullAgoMs,
270
330
  longestGap: longestFullGap,
271
331
  },
272
- device: { reachedRuns: deviceReached.length, highestRung },
332
+ device: { reachedRuns: deviceReached.length, highestRung, highestRungByPack: Object.fromEntries([...highestRungByPack].map(([k, v]) => [k ?? "unattributed", v])) },
273
333
  };
274
334
  }
275
335
 
@@ -338,7 +398,11 @@ export function renderFlightReport(summary, { malformed = 0 } = {}) {
338
398
 
339
399
  if (summary.device.reachedRuns > 0) {
340
400
  lines.push(
341
- `device tier: reached in ${summary.device.reachedRuns} of ${summary.total} run(s)${summary.device.highestRung ? ` (highest evidence rung recorded: ${summary.device.highestRung})` : ""}`,
401
+ // A rung is shown WITH its pack, always an unqualified "L2" is a claim
402
+ // about a grader nobody named (§8.9, §6.5). With more than one pack in
403
+ // the journal there is no single highest, and each is printed on its own
404
+ // rather than one being chosen to stand for both.
405
+ `device tier: reached in ${summary.device.reachedRuns} of ${summary.total} run(s)${describeHighestRung(summary.device)}`,
342
406
  );
343
407
  } else {
344
408
  lines.push("device tier: never reached in any recorded run (no device-tier step ever PASSed)");