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
@@ -6,7 +6,7 @@
6
6
  // tarball rather than the working tree.
7
7
  //
8
8
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/receipt-validate.mjs in the
9
- // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
9
+ // create-cmp repo (the `prooflane-receipts` package). The copy in a generated
10
10
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
11
11
  // test/receipts-parity.test.mjs — edit the package source, then run
12
12
  // `node scripts/sync-harness.mjs`.
@@ -74,20 +74,31 @@ export function checkLaneVouching(receipt) {
74
74
  detail: `the receipt's verdict is PASS but ${failed.length} step(s) did not pass: ${names} — the row is the more specific truth`,
75
75
  };
76
76
  }
77
- const integrity = steps.find((s) => s && s.name === "harnessIntegrity");
77
+ // THE ROW THAT VOUCHES IS THE ROW CARRYING THE VOUCHING DATA, not the row with
78
+ // a particular name. This found a step named exactly `harnessIntegrity` — a
79
+ // name the cmp pack chose, that `REQUIRED_EXPORTS` never mentions, and that a
80
+ // profile author has no way to discover. A green lane whose self-vouching step
81
+ // was spelled `harness_integrity` minted receipts that were invalid FOREVER,
82
+ // in every reader, and the refusal accused the lane of not vouching for
83
+ // itself. The `harness` object on a step row is what the schema already
84
+ // documents as the integrity check's own findings, so it is the honest key.
85
+ // The name is kept as a fallback for receipts written before rows carried it.
86
+ const integrity = steps.find((s) => s && s.harness && typeof s.harness === "object") ?? steps.find((s) => s && s.name === "harnessIntegrity");
78
87
  if (!integrity) {
79
88
  return {
80
89
  ok: false,
81
- detail: "receipt has no harnessIntegrity row — nothing vouches that the lane's own code is the code that ran",
90
+ detail:
91
+ "no step on this receipt vouches for the lane — no row carries a `harness` object and none is named harnessIntegrity, " +
92
+ "so nothing attests that the lane's own code is the code that ran",
82
93
  };
83
94
  }
84
95
  if (integrity.verdict !== "PASS") {
85
96
  return {
86
97
  ok: false,
87
- detail: `harnessIntegrity is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
98
+ detail: `${integrity.name ?? "the integrity step"} is ${integrity.verdict}, not PASS — the lane did not vouch for itself, so its PASS over the tree cannot be trusted`,
88
99
  };
89
100
  }
90
- return { ok: true, detail: "lane vouched for itself (harnessIntegrity PASS, no failing rows)" };
101
+ return { ok: true, detail: `lane vouched for itself (${integrity.name ?? "integrity step"} PASS, no failing rows)` };
91
102
  }
92
103
 
93
104
  export function evaluateReceipt(receipt, recompute) {
@@ -101,6 +112,33 @@ export function evaluateReceipt(receipt, recompute) {
101
112
  };
102
113
  }
103
114
 
115
+ // WHICH PACK GRADED THIS — ADR-0011. §8.9's comparability rule rests entirely
116
+ // on this field ("a `cmp` L2 and any other pack's L2 are different claims"),
117
+ // and until now no predicate read it: remove `pack` and a receipt kept its
118
+ // rung while losing the only thing that says what the rung is a rung OF.
119
+ //
120
+ // REFUSED, not flagged, and the cost is known. A receipt written before the
121
+ // field existed (2026-09-04) is refused too, because nothing in a receipt can
122
+ // tell "never had one" from "had one, and it was removed" — and of those two
123
+ // errors, accepting tampering is the one a predicate exists to prevent. The
124
+ // remedy is the one the binding check above already offers for the same class
125
+ // of staleness, and costs the same: re-run the lane.
126
+ //
127
+ // This is deliberately NOT the ADR-0007 case. There a label moved and no
128
+ // assertion changed, so invalidating old receipts would have been pure loss.
129
+ // Here the receipt is genuinely missing the field that makes its rung mean
130
+ // something — it is not being punished for a name, it is being asked for a
131
+ // claim it never made.
132
+ if (!receipt.pack || typeof receipt.pack.id !== "string" || receipt.pack.id.length === 0) {
133
+ return {
134
+ valid: false,
135
+ reason:
136
+ `receipt names no step pack — re-run the lane (attesting profile: ${profile ?? "unknown"}). ` +
137
+ `A rung is comparable only within its pack, so a receipt that does not name one cannot be compared to any other`,
138
+ profile,
139
+ };
140
+ }
141
+
104
142
  if (receipt.verdict === "FAIL") {
105
143
  return {
106
144
  valid: false,
@@ -146,12 +184,33 @@ export const DEFAULT_POLICY = {
146
184
  /** A receipt older than this no longer counts as fresh (hosted check only). */
147
185
  maxAgeMs: 30 * 24 * 60 * 60 * 1000, // 30 days
148
186
  /**
149
- * Executed (non-SKIP) gates must report at least this much total wall time.
150
- * A "PASS" receipt whose executed gates sum to less cannot attest a real
151
- * lane run — the tell for replayed/cached or hand-written verdicts
152
- * (evidence must attest execution, not results).
187
+ * An absolute wall-time floor for executed (non-SKIP) gates. `null` OFF by
188
+ * default, and that is a decision rather than an omission.
189
+ *
190
+ * This was 5000, with the reasoning that a PASS receipt summing to less
191
+ * cannot attest a real lane run: the tell for a replayed/cached green or a
192
+ * hand-written verdict. That reasoning holds for a Gradle lane and is FALSE
193
+ * for a Go service, a Rust crate, a Python package or a TypeScript library,
194
+ * whose lanes honestly finish in hundreds of milliseconds. Those adopters
195
+ * were told their evidence was fabricated — the one accusation this product
196
+ * cannot afford to make wrongly.
197
+ *
198
+ * The number was not the defect. ONE receipt carries nothing that could
199
+ * justify any number: no start time, no top-level duration, no baseline —
200
+ * `generatedAt` is a timestamp, not an interval — so nothing on it can be
201
+ * cross-checked against anything else on it. A floor is therefore a fact
202
+ * about the STACK, and this module does not know the stack. It is the
203
+ * notary's to set, from data the notary has and the receipt does not: a
204
+ * lane that has taken thirty seconds every day for a month and today claims
205
+ * forty-two milliseconds is a real finding, and it is a finding about a
206
+ * HISTORY, not about a receipt.
207
+ *
208
+ * What is lost, said plainly: a hand-written receipt claiming small
209
+ * durations is no longer refused here. It was never much of a defence — a
210
+ * forger types a larger number — and every stack-independent check that
211
+ * does catch fabrication is untouched below.
153
212
  */
154
- minExecutedMs: 5000,
213
+ minExecutedMs: null,
155
214
  };
156
215
 
157
216
  /**
@@ -176,9 +235,15 @@ export function checkFreshness(receipt, { now = Date.now(), maxAgeMs = DEFAULT_P
176
235
  }
177
236
 
178
237
  /**
179
- * Execution plausibility: do the executed (non-SKIP) gates report durations a
180
- * real lane run could produce? Catches replayed/cached greens and hand-edited
181
- * receipts whose numbers were never lived.
238
+ * Execution plausibility: did this lane execute anything, and are its numbers
239
+ * real numbers?
240
+ *
241
+ * Three refusals, all stack-independent and all about the SHAPE of the
242
+ * evidence rather than its size: a receipt with no steps, a receipt whose every
243
+ * step is a SKIP or an ERROR (neither measured anything), and a step whose
244
+ * duration is not a finite non-negative number. An absolute wall-time floor is
245
+ * a fourth check and is OFF unless a caller sets `minExecutedMs` — see
246
+ * DEFAULT_POLICY for why a default one is a claim about the stack.
182
247
  * @returns {{ok: boolean, detail: string, executedMs?: number, executedSteps?: number}}
183
248
  */
184
249
  export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_POLICY.minExecutedMs } = {}) {
@@ -200,10 +265,13 @@ export function checkExecutionPlausibility(receipt, { minExecutedMs = DEFAULT_PO
200
265
  }
201
266
  total += step.durationMs;
202
267
  }
203
- if (total < minExecutedMs) {
268
+ // Applied only when a caller supplies one. The message attributes the floor
269
+ // to whoever set it and states the measurement, rather than asserting that a
270
+ // fast receipt cannot be real — which this module has no way to know.
271
+ if (typeof minExecutedMs === "number" && minExecutedMs > 0 && total < minExecutedMs) {
204
272
  return {
205
273
  ok: false,
206
- detail: `implausibly fast — executed gates report ${total}ms total, below the ${minExecutedMs}ms floor; a receipt this fast cannot attest a real lane run (evidence must attest execution)`,
274
+ detail: `executed gates report ${total}ms total, below this validator's configured ${minExecutedMs}ms floor for a fast stack that may be honest, so treat it as a finding to explain rather than proof of fabrication`,
207
275
  executedMs: total,
208
276
  executedSteps: executed.length,
209
277
  };
@@ -247,7 +315,7 @@ export function validateReceiptForTree({ root, now = Date.now(), policy = {} } =
247
315
  if (receipt === null) {
248
316
  return {
249
317
  status: "missing",
250
- reason: `no receipt at ${RECEIPT_REL_PATH} — this repo does not carry the create-cmp evidence harness (that is not a failure)`,
318
+ reason: `no receipt at ${RECEIPT_REL_PATH} — this repo does not carry the prooflane evidence harness (that is not a failure)`,
251
319
  checks: [{ id: "receipt-present", ok: false, detail: `no parsable receipt at ${RECEIPT_REL_PATH}` }],
252
320
  skips: [],
253
321
  };
@@ -19,6 +19,12 @@ import { colors, ok, warn, fail } from "../lib/log.mjs";
19
19
  import { consent } from "../bootstrap/exec.mjs";
20
20
  import { sessionStartCommand } from "../lib/hooks.mjs";
21
21
  import { SIDECAR_SUFFIX } from "../lib/harness-upgrade.mjs";
22
+ import {
23
+ MANIFEST_REL_PATH,
24
+ PROFILE_ID_RE,
25
+ manifestFor,
26
+ manifestProblems,
27
+ } from "../../packages/harness/src/lib/harness-manifest.mjs";
22
28
 
23
29
  const COMPOSE_SIGNALS = [
24
30
  "org.jetbrains.compose",
@@ -155,7 +161,7 @@ export function classifyTarget(projectDir) {
155
161
  * @param {boolean} [params.apply=false]
156
162
  * @returns {{units: Array<{relPath:string, action:string}>, notWired: string[]}}
157
163
  */
158
- export function attachProject({ projectDir, apply = false }) {
164
+ export function attachProject({ projectDir, apply = false, manifest = null }) {
159
165
  const target = classifyTarget(projectDir);
160
166
  if (!target.ok) throw new Error(target.reason);
161
167
 
@@ -163,6 +169,17 @@ export function attachProject({ projectDir, apply = false }) {
163
169
  { relPath: "AGENTS.md", content: attachAgentsMd() },
164
170
  { relPath: ".claude/settings.json", content: attachSettings() },
165
171
  ];
172
+ // The harness manifest — WHICH STACK PROFILE this repo uses, and where its
173
+ // specs, sources and receipt live. The lane refuses to run without one and
174
+ // there is no default (decision 3, 2026-09-04), so for a repo create-cmp
175
+ // never stamped this is the interview's output, landed as one more unit.
176
+ // Only ever offered when the caller resolved one: a manifest already on disk
177
+ // is the repo's own declaration and is never touched here.
178
+ if (manifest) {
179
+ const problems = manifestProblems(manifest);
180
+ if (problems.length) throw new Error(`refusing to write an invalid manifest: ${problems.join("; ")}`);
181
+ files.push({ relPath: MANIFEST_REL_PATH, content: `${JSON.stringify(manifest, null, 2)}\n` });
182
+ }
166
183
  const units = [];
167
184
  for (const { relPath, content } of files) {
168
185
  const abs = path.join(projectDir, relPath);
@@ -191,11 +208,109 @@ export function attachProject({ projectDir, apply = false }) {
191
208
  notWired: [
192
209
  "headless screen previews (PreviewRegistry + renderScreens) — staged M0b, needs a per-repo Gradle wiring design",
193
210
  "live on-device inspector — ships with scaffolded apps; not injectable into a foreign build yet",
194
- "verify lane / evidence receipts / enforcement — the lane addresses the stamped layout by name",
211
+ "verify lane / evidence receipts / enforcement — the manifest above tells the lane which profile and where things live; vendoring the lane itself into a foreign repo is the next M0b step",
195
212
  ],
196
213
  };
197
214
  }
198
215
 
216
+ /**
217
+ * A manifest from flags alone — the non-interactive path (`--yes`). Pure:
218
+ * returns null when no `--profile` was given, otherwise the manifest with its
219
+ * problems (empty when valid) so the caller decides how to refuse.
220
+ * @param {object} flags
221
+ * @returns {{manifest: object, problems: string[]} | null}
222
+ */
223
+ export function manifestFromFlags(flags) {
224
+ if (typeof flags.profile !== "string") return null;
225
+ const layout = {};
226
+ if (typeof flags.specs === "string") layout.specs = flags.specs;
227
+ if (typeof flags.receipt === "string") layout.receipt = flags.receipt;
228
+ if (typeof flags["citation-roots"] === "string") {
229
+ const roots = flags["citation-roots"]
230
+ .split(",")
231
+ .map((s) => s.trim())
232
+ .filter(Boolean);
233
+ // An empty value is "no override", never an empty list — the lane would
234
+ // refuse `citationRoots: []` and the user would have to decode why.
235
+ if (roots.length) layout.citationRoots = roots;
236
+ }
237
+ const manifest = manifestFor(flags.profile, layout);
238
+ return { manifest, problems: manifestProblems(manifest) };
239
+ }
240
+
241
+ /**
242
+ * The manifest interview — the questions a foreign repo has to answer before
243
+ * the lane can know what a step or a test root is here. Four questions, each
244
+ * with a default a Compose/KMP repo would accept unchanged; a backend types
245
+ * its own. Validated to the same contract the lane applies before it is
246
+ * written, so the interview can never produce a manifest the lane refuses.
247
+ */
248
+ async function interviewManifest() {
249
+ let prompts;
250
+ try {
251
+ prompts = (await import("prompts")).default;
252
+ } catch {
253
+ process.stderr.write(
254
+ "The manifest interview needs the 'prompts' package. Re-run with --yes --profile <id> [--specs <dir>] [--citation-roots a,b] [--receipt <path>] for non-interactive use.\n"
255
+ );
256
+ process.exit(1);
257
+ }
258
+ const onCancel = () => {
259
+ process.stdout.write("Cancelled — no manifest written.\n");
260
+ process.exit(1);
261
+ };
262
+ const a = await prompts(
263
+ [
264
+ {
265
+ type: "text",
266
+ name: "profile",
267
+ message: "Stack profile id (a directory under qa/lib/profiles/)",
268
+ initial: "cmp",
269
+ validate: (v) => (PROFILE_ID_RE.test(String(v).trim()) ? true : `must match ${PROFILE_ID_RE}`),
270
+ },
271
+ { type: "text", name: "specs", message: "Where do specs live?", initial: "specs" },
272
+ { type: "text", name: "citationRoots", message: "Source roots the lane scans for SPEC: citations (comma-separated)", initial: "composeApp/src, qa/e2e" },
273
+ { type: "text", name: "receipt", message: "Where should the receipt be written?", initial: "qa/evidence/latest.json" },
274
+ ],
275
+ { onCancel }
276
+ );
277
+ return manifestFor(String(a.profile).trim(), {
278
+ specs: String(a.specs).trim(),
279
+ citationRoots: String(a.citationRoots)
280
+ .split(",")
281
+ .map((s) => s.trim())
282
+ .filter(Boolean),
283
+ receipt: String(a.receipt).trim(),
284
+ });
285
+ }
286
+
287
+ /**
288
+ * Resolve the manifest `attach` should land, if any: none when one is already
289
+ * on disk (never overwritten here — valid or not, it is the repo's own
290
+ * declaration; a malformed one is the lane's refusal to explain); from flags
291
+ * when `--profile` is given; from the interview otherwise; and a refusal when
292
+ * `--yes` forbids asking and no flags answer.
293
+ */
294
+ async function manifestForAttach(projectDir, flags) {
295
+ if (fs.existsSync(path.join(projectDir, ...MANIFEST_REL_PATH.split("/")))) return null;
296
+ const fromFlags = manifestFromFlags(flags);
297
+ if (fromFlags) {
298
+ if (fromFlags.problems.length) {
299
+ fail(`the manifest from your flags is invalid: ${fromFlags.problems.join("; ")}`);
300
+ process.exit(1);
301
+ }
302
+ return fromFlags.manifest;
303
+ }
304
+ if (flags.yes === true) {
305
+ fail(
306
+ `${MANIFEST_REL_PATH} is missing and --yes forbids asking. Pass --profile <id> ` +
307
+ `[--specs <dir>] [--citation-roots a,b] [--receipt <path>], or run without --yes to be asked.`
308
+ );
309
+ process.exit(1);
310
+ }
311
+ return interviewManifest();
312
+ }
313
+
199
314
  /**
200
315
  * `create-cmp attach [dir] [--dry-run] [--yes]`
201
316
  */
@@ -209,9 +324,18 @@ export async function runAttach(flags, positional) {
209
324
  ` project: ${colors.cyan(projectDir)}\n\n`
210
325
  );
211
326
 
327
+ // Classify BEFORE asking anything: a stamped app or a non-Gradle tree is
328
+ // refused with its reason, not interviewed and then refused.
329
+ const target = classifyTarget(projectDir);
330
+ if (!target.ok) {
331
+ fail(target.reason);
332
+ process.exit(1);
333
+ }
334
+ const manifest = await manifestForAttach(projectDir, flags);
335
+
212
336
  let plan;
213
337
  try {
214
- plan = attachProject({ projectDir, apply: false });
338
+ plan = attachProject({ projectDir, apply: false, manifest });
215
339
  } catch (e) {
216
340
  fail(e.message);
217
341
  process.exit(1);
@@ -241,7 +365,7 @@ export async function runAttach(flags, positional) {
241
365
  process.exit(0);
242
366
  }
243
367
 
244
- const applied = attachProject({ projectDir, apply: true });
368
+ const applied = attachProject({ projectDir, apply: true, manifest });
245
369
  for (const u of applied.units) {
246
370
  if (u.action === "written") ok(`wrote ${u.relPath}`);
247
371
  if (u.action === "sidecar") warn(`wrote ${u.relPath}${SIDECAR_SUFFIX} (yours untouched)`);
@@ -52,6 +52,7 @@ import {
52
52
  checkHarnessIntegrity,
53
53
  describeIntegrity,
54
54
  } from "../../packages/harness/src/lib/harness-lock.mjs";
55
+ import { writeHarnessSource, HARNESS_PKG_NAME } from "../../packages/harness/src/lib/harness-source.mjs";
55
56
 
56
57
  const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
57
58
 
@@ -152,7 +153,12 @@ export async function hardenProject({ projectDir, templateDir, apply = false, lo
152
153
  }
153
154
 
154
155
  const harnessVersion = shippedHarnessVersion();
155
- if (harnessVersion) writeHarnessLock(projectDir, { version: harnessVersion });
156
+ if (harnessVersion) {
157
+ // Same ordering as the stamp: the record is inside the region the lock
158
+ // hashes, so it exists first (ADR-0008).
159
+ writeHarnessSource(projectDir, { name: HARNESS_PKG_NAME, version: harnessVersion, source: "local" });
160
+ writeHarnessLock(projectDir, { version: harnessVersion });
161
+ }
156
162
 
157
163
  const updated = { ...record, harness: true, engineVersion: currentEngineVersion() };
158
164
  fs.writeFileSync(specPath, JSON.stringify(updated, null, 2) + "\n");
@@ -43,6 +43,7 @@ import { consent } from "../bootstrap/exec.mjs";
43
43
  import { loadRegistry, latestSet, getSet } from "../lib/registry.mjs";
44
44
  import { planUpgrade, BACKUP_SUFFIX, sidecarDroppedLines, staleBackupPaths } from "../lib/upgrade.mjs";
45
45
  import { writeHarnessLock, checkHarnessIntegrity, describeIntegrity } from "../../packages/harness/src/lib/harness-lock.mjs";
46
+ import { writeHarnessSource, HARNESS_PKG_NAME } from "../../packages/harness/src/lib/harness-source.mjs";
46
47
  import { LOCAL_PATCH_PATH, stampBaseWith } from "../lib/harness-upgrade.mjs";
47
48
  import { buildTokenMap } from "../lib/tokens.mjs";
48
49
  import {
@@ -409,6 +410,12 @@ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRo
409
410
  // version numbers buy. So the lock is rewritten on its own schedule.
410
411
  const harnessVersion = shippedHarnessVersion();
411
412
  if (harnessVersion) {
413
+ // The provenance record first: it is inside the region the lock hashes, and
414
+ // rewriting it is what makes a version-only upgrade visible in the TREE
415
+ // rather than only in a number (ADR-0008 / Stage 1 criterion D). Without
416
+ // this a stamped app upgraded here moved its lock's `version` and not one
417
+ // digest — the shape the criterion forbids.
418
+ writeHarnessSource(projectDir, { name: HARNESS_PKG_NAME, version: harnessVersion, source: "local" });
412
419
  writeHarnessLock(projectDir, { version: harnessVersion });
413
420
  ok(`lane locked at ${colors.bold(harnessVersion)} — ${describeIntegrity(checkHarnessIntegrity(projectDir))}`);
414
421
  }
@@ -25,6 +25,7 @@
25
25
  // additive, idempotent, never clobbering. See src/commands/harden.mjs.
26
26
 
27
27
  import fs from "node:fs";
28
+ import { HARNESS_DECLARATIONS, HARNESS_GENERATED } from "../../packages/harness/src/lib/harness-region.mjs";
28
29
  import path from "node:path";
29
30
 
30
31
  import { listHarnessFiles } from "../../packages/harness/src/lib/harness-region.mjs";
@@ -59,12 +60,33 @@ const IMPORT_SPECIFIER_RE = /(?:from\s+|new URL\(\s*)["'](\.\.?\/[^"']+\.mjs)["'
59
60
 
60
61
  /**
61
62
  * The machine-owned files a minimal scaffold keeps: the entry points plus
62
- * their transitive ./-relative imports, resolved against the tree as stamped.
63
+ * their transitive ./-relative imports, resolved against the tree as stamped
64
+ * PLUS the region members that are not modules and therefore cannot be reached
65
+ * by any import closure.
66
+ *
67
+ * That last clause is not a special case, it is the shape of the region: it
68
+ * holds code AND the files the lane reads about itself (the two declarations)
69
+ * and the record of which harness it carries (`qa/harness-source.json`,
70
+ * ADR-0008). A closure walk can only ever see the code half, so deriving the
71
+ * keep-set from imports alone quietly proposes deleting the other half — and
72
+ * the file it would delete is the one that says what this lane IS.
73
+ *
63
74
  * @param {string} projectDir
64
75
  * @returns {Set<string>} project-relative posix paths
65
76
  */
66
77
  export function laneKeepSet(projectDir) {
67
78
  const keep = new Set();
79
+ // The declarations are gated on presence; the generated record is NOT, and
80
+ // the asymmetry is the point. `qa/harness-source.json` is written at STAMP
81
+ // time, so it is absent from the template this set is usually derived against
82
+ // and present in every stamped tree — gating it on presence would produce a
83
+ // keep-set that is right about the template and wrong about the app, which is
84
+ // the only tree the answer is ever used on. A name kept but absent costs
85
+ // nothing: the set decides what NOT to delete.
86
+ for (const rel of HARNESS_DECLARATIONS) {
87
+ if (fs.existsSync(path.join(projectDir, ...rel.split("/")))) keep.add(rel);
88
+ }
89
+ for (const rel of HARNESS_GENERATED) keep.add(rel);
68
90
  const queue = MINIMAL_LANE_ENTRY_POINTS.filter((rel) =>
69
91
  fs.existsSync(path.join(projectDir, rel))
70
92
  );
package/src/scaffold.mjs CHANGED
@@ -22,6 +22,7 @@ import { validatePackageName } from "./lib/package-name.mjs";
22
22
  import { buildTokenMap, replaceTokens, replacePathTokens, isBinaryPath, slugifyAppName } from "./lib/tokens.mjs";
23
23
  import { isHarnessFile, listHarnessFiles } from "../packages/harness/src/lib/harness-region.mjs";
24
24
  import { writeHarnessLock } from "../packages/harness/src/lib/harness-lock.mjs";
25
+ import { writeHarnessSource, HARNESS_PKG_NAME } from "../packages/harness/src/lib/harness-source.mjs";
25
26
  import { renamePackageDirs } from "./lib/rename.mjs";
26
27
  import {
27
28
  stripFeatureBlocks,
@@ -305,6 +306,13 @@ function writeLaneLock(projectDir) {
305
306
  // it would attest nothing while creating the very qa/ directory the strip
306
307
  // just removed.
307
308
  if (listHarnessFiles(projectDir).length === 0) return;
309
+ // PROVENANCE BEFORE THE LOCK (ADR-0008). The record is inside the region, so
310
+ // it must exist before the region is hashed. A stamped app's lane bytes were
311
+ // COPIED FROM A PATH ON THIS MACHINE — the template inside whichever
312
+ // create-cmp is installed here — which is `local` exactly as the enum defines
313
+ // it, and is true however create-cmp itself arrived. Calling it `registry`
314
+ // because the scaffolder came from npx would name the wrong artifact.
315
+ writeHarnessSource(projectDir, { name: HARNESS_PKG_NAME, version, source: "local" });
308
316
  writeHarnessLock(projectDir, { version });
309
317
  }
310
318
 
@@ -6,7 +6,7 @@
6
6
  "hooks": [
7
7
  {
8
8
  "type": "command",
9
- "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This app is governed by its delivery contract (CLAUDE.md): behavior starts in specs/, done is `node qa/verify.mjs` with a committed receipt, approvals gate signed artifacts. The cmp-inspector MCP tools (preview loop, live tier) are the expected eyes \u2014 if they are absent from this session, that is a fault to diagnose (plugin disabled, session predates plugin enablement, or stale plugin copy; see cmp-doctor), not a cue to fall back to screenshots or blind adb.\"}}'"
9
+ "command": "printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"SessionStart\",\"additionalContext\":\"This app is governed by its delivery contract (CLAUDE.md): behavior starts in specs/, done is `node qa/verify.mjs` with a receipt that attests this tree (commit it with the change), approvals gate signed artifacts. The cmp-inspector MCP tools (preview loop, live tier) are the expected eyes \u2014 if they are absent from this session, that is a fault to diagnose (plugin disabled, session predates plugin enablement, or stale plugin copy; see cmp-doctor), not a cue to fall back to screenshots or blind adb.\"}}'"
10
10
  }
11
11
  ]
12
12
  }
@@ -21,7 +21,7 @@
21
21
  },
22
22
  {
23
23
  "type": "command",
24
- "command": "grep -qE 'connected[A-Za-z]*AndroidTest|maestro test|adb (-s [^ ]+ )?(install|uninstall)' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: device evidence is lane-owned and batched. node qa/verify.mjs sequences the device steps once, last, under a machine-global per-serial lease (qa/lib/device-lease.mjs) \u2014 the one device is scarce, slow, and fragile, so device proof is a checkpoint, never an inner loop. Driving it by hand mid-task risks colliding with a running lane (wedged adbd, device offline, false reds, crossed app state). Ad-hoc debugging stays allowed; batch the evidence into the lane.\"}}' || true"
24
+ "command": "grep -qE 'connected[A-Za-z]*AndroidTest|maestro test|adb (-s [^ ]+ )?(install|uninstall)|emulator (-avd|@)|:composeApp:installDebug' && printf '%s' '{\"hookSpecificOutput\":{\"hookEventName\":\"PreToolUse\",\"permissionDecision\":\"allow\",\"permissionDecisionReason\":\"Reminder: device evidence is lane-owned and batched. node qa/verify.mjs sequences the device steps once, last, under a machine-global per-serial lease (qa/lib/profiles/cmp/device-lease.mjs) \u2014 the one device is scarce, slow, and fragile, so device proof is a checkpoint, never an inner loop. Driving it by hand mid-task risks colliding with a running lane (wedged adbd, device offline, false reds, crossed app state). Ad-hoc debugging stays allowed; batch the evidence into the lane.\"}}' || true"
25
25
  },
26
26
  {
27
27
  "type": "command",
@@ -15,7 +15,7 @@ description: >-
15
15
 
16
16
  > Spec-first, deterministic-stamp, gate-proven. The script (`qa/scaffold-feature.mjs`) does the
17
17
  > mechanical work — copy the exemplar file set, whole-word identifier rename, anchor
18
- > injection into the three shared files. You (the AI) only refine spec wording and adapt the
18
+ > injection into the three shared files (four when the inspector shipped — the preview registry too). You (the AI) only refine spec wording and adapt the
19
19
  > feature to its real shape. You are not done until `node qa/verify.mjs` PASSes and the receipt
20
20
  > is committed — see this project's `CLAUDE.md`.
21
21
 
@@ -126,7 +126,7 @@ Then adapt the generated code to match:
126
126
  registry vocabulary** (`ScreenColumn`/`AppHeader`/`ContentStateContainer`/`ListItemCard`,
127
127
  `presentation/components/*.kt`) — adapt the content shape inside `ContentStateContainer`'s
128
128
  trailing slot, don't hand-roll a new header/loading state/list row on top of it. If the
129
- feature's data genuinely needs a component the nine don't cover, propose the addition to the
129
+ feature's data genuinely needs a component the registry doesn't cover, propose the addition to the
130
130
  human explicitly (a new file is a registry change — it invalidates the `components` approval).
131
131
  - Update the copied tests (`<Feature>ViewModelTest.kt`, `<Feature>ScreenTest.kt`) to match
132
132
  whatever you changed. The gate (step 6) will tell you exactly what you missed — a compile
@@ -152,12 +152,16 @@ copy-paste artifact. Commit it alongside the feature.
152
152
 
153
153
  ### 6. Gate
154
154
 
155
+ **Before the lane — the device journey.** The stamper wrote `qa/e2e/<feature>.yaml` as a placeholder and
156
+ said so; the lane's `e2eCoverage` gate FAILs until that flow is a real journey citing a `<FEATURE>-NN`
157
+ clause it proves. That red is the gate working, not a bug — make the flow real first.
158
+
155
159
  ```
156
160
  node qa/verify.mjs
157
161
  ```
158
162
 
159
163
  This must PASS. It proves: the spec's seven clauses are all bound to a citing test
160
- (`specCoverage`), the build compiles, unit tests pass (ViewModel + UseCase + Repository +
164
+ (`specCoverage`), the device journey cites a clause it proves (`e2eCoverage`), the build compiles, unit tests pass (ViewModel + UseCase + Repository +
161
165
  fakes), architecture conformance holds (`presentation` doesn't import `data`, the new
162
166
  `*Screen.kt` is automation-reachable — a literal `testTag` or `screenTag =` wiring into a
163
167
  registry component — the new `*ViewModel.kt` has a matching test, and it references no
@@ -3,7 +3,7 @@ name: add-repository
3
3
  description: >-
4
4
  Add a data/domain slice ONLY — model, repository interface + impl, use case, and a
5
5
  hand-written fake — to this Compose Multiplatform app, cloned deterministically from the
6
- `home` exemplar's data layer. No screen, no ViewModel, no nav route, no spec clauses. Use this
6
+ project's configured exemplar feature's data layer (`qa/approvals.json`'s `exemplarFeature` — `home` by default). No screen, no ViewModel, no nav route, no spec clauses. Use this
7
7
  when the user wants to "add a repository", "add a data source", "model a new entity", "new
8
8
  entity with a repository", or names a domain noun they want backed by data before any UI exists
9
9
  (e.g. "add a Tag repository", "I need a data source for Bookmarks"). Works with NO create-cmp
@@ -14,7 +14,7 @@ description: >-
14
14
  # add-repository — stamp a data/domain slice only
15
15
 
16
16
  > Deterministic-stamp, gate-proven. The script (`qa/scaffold-feature.mjs --preset repository`)
17
- > does the mechanical work — copy the `home` exemplar's data/domain files, whole-word identifier
17
+ > does the mechanical work — copy the exemplar's data/domain files, whole-word identifier
18
18
  > rename, DI injection for the repository + use case only. You (the AI) only customize the
19
19
  > entity's fields and its sample data. You are not done until `node qa/verify.mjs` PASSes and the
20
20
  > receipt is committed — see this project's `CLAUDE.md`.
@@ -3,7 +3,7 @@ name: add-screen
3
3
  description: >-
4
4
  Add a presentation-only slice — Screen + ViewModel + tests + golden tree + spec + nav route —
5
5
  for an EXISTING entity/repository in this Compose Multiplatform app, cloned deterministically
6
- from the `home` exemplar's presentation layer. Requires the entity's data layer (model,
6
+ from the project's configured exemplar feature's presentation layer (`qa/approvals.json`'s `exemplarFeature` — `home` by default). Requires the entity's data layer (model,
7
7
  repository, use case, fake) to already exist. Use this when the user wants to "add a screen",
8
8
  "add a screen for an existing entity", "add a screen backed by a repository I already have",
9
9
  "put a UI on this data", or names an existing domain noun they want a screen for (e.g. "add a
@@ -86,7 +86,7 @@ failure.
86
86
 
87
87
  ### 4. Refine the spec, then the behavior
88
88
 
89
- Rewrite the clause prose in `specs/<feature>.spec.md` for the entity's real behavior — the six
89
+ Rewrite the clause prose in `specs/<feature>.spec.md` for the entity's real behavior — the seven
90
90
  clause **ids stay fixed** (specCoverage binds tests to ids, not prose). Propose the rewritten
91
91
  clauses to the human; get them confirmed before moving on.
92
92
 
@@ -114,12 +114,16 @@ it alongside the feature.
114
114
 
115
115
  ### 6. Gate
116
116
 
117
+ **Before the lane — the device journey.** The stamper wrote `qa/e2e/<feature>.yaml` as a placeholder and
118
+ said so; the lane's `e2eCoverage` gate FAILs until that flow is a real journey citing a `<FEATURE>-NN`
119
+ clause it proves. That red is the gate working, not a bug — make the flow real first.
120
+
117
121
  ```
118
122
  node qa/verify.mjs
119
123
  ```
120
124
 
121
- This must PASS. It proves: the spec's six clauses are all bound to a citing test
122
- (specCoverage — `<FEATURE>-01..07` newly bound), the build compiles, unit tests pass, architecture
125
+ This must PASS. It proves: the spec's seven clauses are all bound to a citing test
126
+ (specCoverage — `<FEATURE>-01..07` newly bound), the device journey cites a clause it proves (e2eCoverage), the build compiles, unit tests pass, architecture
123
127
  conformance holds (the screen is automation-reachable via a literal `testTag` or `screenTag =`
124
128
  wiring into a registry component, and references no `CircularProgressIndicator`/
125
129
  `LinearProgressIndicator` directly), the golden tree matches, and accessibility holds. **Not done
@@ -13,6 +13,15 @@ if [ ! -f qa/receipt-check.mjs ]; then
13
13
  exit 0 # not a create-cmp project layout — nothing to gate
14
14
  fi
15
15
 
16
+ # First the half nothing else checks: the receipt must be IN the commit being
17
+ # pushed, not merely on disk. receipt-check reads the working tree by hash and
18
+ # has no opinion about git; this line is what makes "committed receipt" true.
19
+ if ! git ls-files --error-unmatch qa/evidence/latest.json >/dev/null 2>&1 || ! git diff --quiet HEAD -- qa/evidence/latest.json; then
20
+ echo ""
21
+ echo "✗ pre-push blocked — qa/evidence/latest.json is not committed (or differs from HEAD)."
22
+ echo " Commit the receipt with your change, then push again."
23
+ exit 1
24
+ fi
16
25
  if node qa/receipt-check.mjs; then
17
26
  exit 0
18
27
  fi