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
@@ -5,7 +5,7 @@
5
5
  // there is exactly one definition of the surface and the algorithm.
6
6
  //
7
7
  // SINGLE SOURCE OF TRUTH: packages/receipts/src/inputs-hash.mjs in the
8
- // create-cmp repo (the `@create-cmp/receipts` package). The copy in a generated
8
+ // create-cmp repo (the `prooflane-receipts` package). The copy in a generated
9
9
  // project's qa/lib/ is vendored byte-identical at scaffold time and pinned by
10
10
  // test/receipts-parity.test.mjs — edit the package source, then run
11
11
  // `node scripts/sync-harness.mjs`.
@@ -30,15 +30,46 @@ import path from "node:path";
30
30
  // looking the same is the worst failure this harness can have, so the surface
31
31
  // is now resolved per project (see resolveVerifiedSurface) and an empty one is
32
32
  // refused rather than hashed.
33
- export const VERIFIED_SURFACE = [
34
- "composeApp",
35
- "specs",
36
- "qa",
37
- "gradle/libs.versions.toml",
38
- "build.gradle.kts",
39
- "settings.gradle.kts",
40
- "gradle.properties",
41
- ];
33
+ /**
34
+ * The surface a project gets when it has NOT declared one.
35
+ *
36
+ * This used to be a constant: `composeApp`, `specs`, `qa`, and the Gradle files
37
+ * — one stack's directory names, in the module every adopter's receipt is
38
+ * computed by. Vendored into a repo whose code lives under `services/`, it
39
+ * matched `qa/` and `specs/` and nothing else, so the lane produced a valid,
40
+ * confident, SMALLER hash: a receipt attesting a fraction of a project while
41
+ * looking complete. The file's own header calls that the worst failure this
42
+ * harness can have, and then shipped it as the default.
43
+ *
44
+ * Derived from the tree instead, and deliberately WIDE. Over-attesting costs a
45
+ * hash that moves more often than it needs to; under-attesting silently drops
46
+ * files from what a receipt claims. Only one of those is a lie, so the error is
47
+ * taken in the safe direction. Build output and dependency directories are
48
+ * excluded because they are outputs, not the thing being attested.
49
+ *
50
+ * A project that wants a narrower or pinned surface DECLARES one — `harness
51
+ * init` writes it, and the template ships one, so this fallback is for repos
52
+ * that predate the declaration rather than the normal path.
53
+ *
54
+ * @param {string} root
55
+ * @returns {string[]}
56
+ */
57
+ export function defaultSurface(root) {
58
+ // The same construction as the walk — the universal floor plus what this
59
+ // repo's own .gitignore says — so the two agree by construction. qa-artifacts
60
+ // is the lane's own output and stays named.
61
+ const skip = new Set([...WALK_FLOOR, "qa-artifacts", ...gitignoredDirs(root), ...declaredIgnore(root)]);
62
+ let entries;
63
+ try {
64
+ entries = fs.readdirSync(root, { withFileTypes: true });
65
+ } catch {
66
+ return [];
67
+ }
68
+ return entries
69
+ .filter((e) => !e.name.startsWith(".") && !skip.has(e.name))
70
+ .map((e) => e.name)
71
+ .sort();
72
+ }
42
73
 
43
74
  // Paths EXCLUDED even though they fall under an included surface dir above.
44
75
  // qa/evidence and qa-artifacts are lane OUTPUTS — including them would make
@@ -74,6 +105,10 @@ const EXCLUDED_PREFIXES = [
74
105
  // un-proves the tree — the exact inversion this family of exclusions exists
75
106
  // to prevent.
76
107
  "qa/.agent-hold.json",
108
+ // The lane's own in-flight marker (qa/lib/lane-markers.mjs): stamped for a
109
+ // run's duration, rewritten at every step. Hashing it would make the lane
110
+ // invalidate its own receipt mid-run.
111
+ "qa/.lane-in-progress",
77
112
  "qa/evidence",
78
113
  "qa-artifacts",
79
114
  "qa/comments.json",
@@ -139,7 +174,111 @@ function tryGitLsFiles(root) {
139
174
  // "INVALID — source changed" the moment the user runs `git init`, even though
140
175
  // no source changed. Pre-git and post-git hashes must agree for identical
141
176
  // source; that is the invariant the regression test pins.
142
- const WALK_EXCLUDED_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules"]);
177
+ // A FLOOR, not the rule. This used to BE the rule, and it was one ecosystem's:
178
+ // it knew build/.gradle/.kotlin/.idea/node_modules and nothing of __pycache__,
179
+ // venv, target (Rust, Maven), bin and obj (.NET), vendor (Go, PHP), Pods or
180
+ // DerivedData. So a Python tree hashed one set of bytes before `git init` and a
181
+ // different set after, and the stamp-time PASS receipt read "source changed
182
+ // since the receipt" the instant a user ran `git init` — with no source change.
183
+ // That is the exact invariant the constant was written to hold, broken for
184
+ // every ecosystem but the first.
185
+ // THE FLOOR IS UNIVERSAL; EVERYTHING ELSE IS DECLARED. `.git` is git's, and
186
+ // `node_modules` is this lane's own runtime. Until 2026-09-08 this set also
187
+ // carried `build`, `.gradle`, `.kotlin`, `.idea` — one ecosystem's build output,
188
+ // applied to every tree. PATTERN: the repo's own ignore file is the truth
189
+ // (ripgrep, watchman and git itself all read .gitignore rather than a table);
190
+ // beneath it, the profile declares `layout.ignore` and `layout.buildDir`,
191
+ // written into qa/verified-surface.json as `ignore` so THIS package — the
192
+ // notary's, which must know no profile — reads a project fact, not a stack.
193
+ // WHY IT WORKS: the people who know the stack maintain the list, in the file
194
+ // they already maintain. HOW IT FAILS: a tree with no git, no .gitignore and no
195
+ // declaration hashes its build output — a hash that moves too often, which is
196
+ // the safe direction. WHAT WE DO: that case is the walk, and the walk prints
197
+ // its rules. Q5 (NORTH-STAR §10): git-mode hashing is untouched, and a cmp
198
+ // tree's .gitignore already lists these directories, so no receipt moves.
199
+ const WALK_FLOOR = new Set([".git", "node_modules"]);
200
+ const WALK_EXCLUDED_DIRS = WALK_FLOOR;
201
+
202
+ /**
203
+ * Directories the project DECLARED unhashable — qa/verified-surface.json's
204
+ * optional `ignore` list, written by `harness init` from `layout.ignore` and
205
+ * `layout.buildDir`, so the notary reads a project fact rather than a profile.
206
+ * @param {string} root
207
+ * @returns {Set<string>}
208
+ */
209
+ export function declaredIgnore(root) {
210
+ try {
211
+ const parsed = JSON.parse(fs.readFileSync(path.join(root, SURFACE_CONFIG_REL), "utf8"));
212
+ const list = Array.isArray(parsed?.ignore) ? parsed.ignore : [];
213
+ return new Set(list.filter((x) => typeof x === "string" && x.trim() && !x.includes("..")).map((x) => x.replace(/^\.\//, "").replace(/\/$/, "")));
214
+ } catch {
215
+ return new Set();
216
+ }
217
+ }
218
+
219
+ /**
220
+ * The directories THIS repo ignores, read from its own `.gitignore`.
221
+ *
222
+ * Walk mode exists only before `git init`, and its whole job is to agree with
223
+ * what `git ls-files --exclude-standard` will say afterwards. Maintaining a
224
+ * hand-written list of every ecosystem's build directory is a losing game and
225
+ * was already lost; reading the file git reads makes the two modes agree BY
226
+ * CONSTRUCTION rather than by vigilance.
227
+ *
228
+ * Deliberately a small subset of gitignore syntax — bare directory names, with
229
+ * or without a leading or trailing slash. Globs, negations and nested paths are
230
+ * left to git, which is running in every case that matters. Missing one costs a
231
+ * hash that moves too often; that is the safe direction, and the same one
232
+ * `defaultSurface` errs in.
233
+ *
234
+ * @param {string} root
235
+ * @returns {Set<string>}
236
+ */
237
+ export function gitignoredDirs(root) {
238
+ let text;
239
+ try {
240
+ text = fs.readFileSync(path.join(root, ".gitignore"), "utf8");
241
+ } catch {
242
+ return new Set();
243
+ }
244
+ const out = new Set();
245
+ for (const raw of text.split("\n")) {
246
+ const line = raw.trim();
247
+ if (!line || line.startsWith("#") || line.startsWith("!")) continue;
248
+ if (line.includes("*") || line.includes("?") || line.includes("[")) continue;
249
+ const name = line.replace(/^\//, "").replace(/\/$/, "");
250
+ if (name && !name.includes("/")) out.add(name);
251
+ }
252
+ return out;
253
+ }
254
+ /**
255
+ * Files that are INPUTS TO THE RESOLUTION rather than members of the surface.
256
+ *
257
+ * `.gitignore` decides what is attested: `git ls-files --exclude-standard`
258
+ * honours it, and `gitignoredDirs` above makes the walk fallback honour it too.
259
+ * A file that decides the attested set and is not itself attested is a hole —
260
+ * edit one line and a directory silently leaves every future receipt, with
261
+ * nothing in the chain saying the coverage moved. That is the narrowing failure
262
+ * this module's header calls the worst one it can have, arriving through the
263
+ * side door.
264
+ *
265
+ * Attested STRUCTURALLY, outside `surfaceEntries`, because a declaration that
266
+ * could omit it would reopen exactly the hole: `defaultSurface` skips dotfiles,
267
+ * the shipped template surface lists none, and no adopter writing
268
+ * `qa/verified-surface.json` by hand would think to add it. It is not the
269
+ * adopter's to declare — it is the resolver's own input.
270
+ *
271
+ * Not locked: adopters edit `.gitignore` legitimately and often. Attestation is
272
+ * the right instrument — a change makes the receipt say "source changed", which
273
+ * is true, and re-running mints a valid one.
274
+ */
275
+ const RESOLUTION_INPUTS = Object.freeze([".gitignore"]);
276
+
277
+ /** @param {string} relPath @returns {boolean} */
278
+ function isResolutionInput(relPath) {
279
+ return RESOLUTION_INPUTS.includes(relPath);
280
+ }
281
+
143
282
  // File-level mirror of the same principle (OS/editor junk the .gitignore covers).
144
283
  const WALK_EXCLUDED_FILES = new Set([".DS_Store"]);
145
284
  const WALK_EXCLUDED_SUFFIXES = [".iml", ".log"];
@@ -150,14 +289,16 @@ function walkIncludesFile(name) {
150
289
  }
151
290
 
152
291
  // Dependency-free recursive walk, used when git is unavailable (non-git scaffold).
153
- function walkAllFiles(dir) {
292
+ // `ignored` is the floor plus whatever this repo's own .gitignore names, so the
293
+ // pre-git hash agrees with the post-git one for any ecosystem, not just the first.
294
+ function walkAllFiles(dir, ignored = WALK_EXCLUDED_DIRS) {
154
295
  const out = [];
155
296
  if (!fs.existsSync(dir)) return out;
156
297
  for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
157
298
  const p = path.join(dir, entry.name);
158
299
  if (entry.isDirectory()) {
159
- if (WALK_EXCLUDED_DIRS.has(entry.name)) continue; // non-source scratch — see note above
160
- out.push(...walkAllFiles(p));
300
+ if (ignored.has(entry.name)) continue; // non-source scratch — see note above
301
+ out.push(...walkAllFiles(p, ignored));
161
302
  } else if (entry.isFile() && walkIncludesFile(entry.name)) out.push(p);
162
303
  }
163
304
  return out;
@@ -191,7 +332,7 @@ export function resolveVerifiedSurface(root) {
191
332
  try {
192
333
  raw = fs.readFileSync(p, "utf8");
193
334
  } catch {
194
- return VERIFIED_SURFACE; // no declaration — the CMP default, unchanged
335
+ return defaultSurface(root); // no declaration — the tree's own top level
195
336
  }
196
337
  let parsed;
197
338
  try {
@@ -208,32 +349,41 @@ export function resolveVerifiedSurface(root) {
208
349
 
209
350
  // Resolve the verified surface to a flat, sorted list of paths (relative to
210
351
  // root, POSIX-style `/` separators) that currently exist on disk.
211
- function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
352
+ function resolveSurfaceFiles(root, surfaceEntries) {
212
353
  const gitFiles = tryGitLsFiles(root);
213
354
 
214
355
  if (gitFiles) {
215
356
  return gitFiles
216
357
  .map((p) => p.split(path.sep).join("/"))
217
- .filter((relPath) => VERIFIED_SURFACE.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
358
+ .filter((relPath) => isResolutionInput(relPath) || surfaceEntries.some((surface) => relPath === surface || relPath.startsWith(`${surface}/`)))
218
359
  .filter((relPath) => !isExcluded(relPath))
219
360
  .filter((relPath) => fs.existsSync(path.join(root, relPath)) && fs.statSync(path.join(root, relPath)).isFile());
220
361
  }
221
362
 
222
363
  // Fallback: no git available — walk the surface directories directly so a
223
- // non-git scaffold still produces a stable hash.
364
+ // non-git scaffold still produces a stable hash. The ignore set is the floor
365
+ // PLUS this repo's own .gitignore, which is what `git ls-files
366
+ // --exclude-standard` will honour the moment the tree becomes a repo. Reading
367
+ // the same file is what makes the two modes agree by construction.
368
+ const ignored = new Set([...WALK_FLOOR, ...gitignoredDirs(root), ...declaredIgnore(root)]);
224
369
  const collected = [];
225
- for (const surface of VERIFIED_SURFACE) {
370
+ for (const surface of surfaceEntries) {
226
371
  const abs = path.join(root, surface);
227
372
  if (!fs.existsSync(abs)) continue;
228
373
  const stat = fs.statSync(abs);
229
374
  if (stat.isFile()) {
230
375
  collected.push(surface);
231
376
  } else if (stat.isDirectory()) {
232
- for (const file of walkAllFiles(abs)) {
377
+ for (const file of walkAllFiles(abs, ignored)) {
233
378
  collected.push(path.relative(root, file).split(path.sep).join("/"));
234
379
  }
235
380
  }
236
381
  }
382
+ // The resolver's own inputs, attested whatever the surface declares.
383
+ for (const rel of RESOLUTION_INPUTS) {
384
+ const abs = path.join(root, rel);
385
+ if (!collected.includes(rel) && fs.existsSync(abs) && fs.statSync(abs).isFile()) collected.push(rel);
386
+ }
237
387
  return collected.filter((relPath) => !isExcluded(relPath));
238
388
  }
239
389
 
@@ -246,7 +396,9 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
246
396
  * it belongs in qa/verified-surface.json. Sorted; [] when git is unavailable
247
397
  * (the walk fallback has no notion of "what git sees") or everything is
248
398
  * covered. Lane outputs (EXCLUDED_PREFIXES) are not "undeclared" — they are
249
- * excluded by decision.
399
+ * excluded by decision, and RESOLUTION_INPUTS are not undeclared either: they
400
+ * are attested structurally, so naming them here would report a file as
401
+ * unattested that the very same hash attests.
250
402
  * @param {string} root
251
403
  * @param {string[]} [surface] defaults to resolveVerifiedSurface(root)
252
404
  * @returns {string[]}
@@ -254,7 +406,8 @@ function resolveSurfaceFiles(root, VERIFIED_SURFACE) {
254
406
  export function undeclaredTopLevel(root, surface = resolveVerifiedSurface(root)) {
255
407
  const gitFiles = tryGitLsFiles(root);
256
408
  if (!gitFiles) return [];
257
- const covered = (relPath) => surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath);
409
+ const covered = (relPath) =>
410
+ surface.some((entry) => relPath === entry || relPath.startsWith(`${entry}/`)) || isExcluded(relPath) || isResolutionInput(relPath);
258
411
  const out = new Set();
259
412
  for (const raw of gitFiles) {
260
413
  const relPath = raw.split(path.sep).join("/");
@@ -0,0 +1,78 @@
1
+ // lane-markers.mjs — where the lane says "I am running", and where the eyes say
2
+ // "I am building". One place, so no reader ever looks in the wrong directory.
3
+ //
4
+ // THE LANE MARKER IS CORE STATE (Stage 0 PR 6a). It used to live at
5
+ // composeApp/build/.cmp-lane-in-progress — inside a Compose app's Gradle
6
+ // build directory — and every reader (the Stop hook, the narrator, the
7
+ // watcher, the chain view, the console) carried that path as a constant. A
8
+ // backend has no composeApp/build; a lane there stamped a marker nobody read.
9
+ // The marker is the lane's, not the stack's, so it lives beside the lane's
10
+ // other transient state (qa/.agent-hold.json, qa/.plan.json): gitignored,
11
+ // excluded from the inputs hash, never a lane OUTPUT for the fast filter,
12
+ // outside the lock region (the region is .mjs files and the two declarations).
13
+ //
14
+ // THE RENDER MARKER IS THE PROVIDER'S. The preview daemon (mobile's eyes)
15
+ // stamps <buildDir>/.cmp-render-in-progress around its Gradle builds so the
16
+ // lane can defer and self-heal around it. Which directory that is comes from
17
+ // the profile's `layout.buildDir`; a profile with none has no render marker,
18
+ // and every reader takes `null` as "nothing to wait for".
19
+ //
20
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/lane-markers.mjs in the
21
+ // create-cmp repo. Vendored byte-identical into qa/lib/ — edit the package
22
+ // source, then run `node scripts/sync-harness.mjs`.
23
+
24
+ import fs from "node:fs";
25
+ import path from "node:path";
26
+
27
+ import { resolveHarnessManifest } from "./harness-manifest.mjs";
28
+ import { loadProfileSync } from "./profile-loader.mjs";
29
+
30
+ /** The lane's in-flight marker, project-relative. Stamped by qa/verify.mjs for a run's duration. */
31
+ export const LANE_MARKER_REL = "qa/.lane-in-progress";
32
+ /** The eyes' in-flight marker's file name, under the profile's `layout.buildDir`. */
33
+ export const RENDER_MARKER_NAME = ".cmp-render-in-progress";
34
+
35
+ /** Past this, a lane marker is a crashed lane's leftover, not a lane. */
36
+ export const LANE_MARKER_STALE_MS = 30 * 60 * 1000;
37
+ /** Past this, a render marker is a crashed daemon's leftover, not a build. */
38
+ export const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000;
39
+
40
+ /**
41
+ * @param {string} root project root
42
+ * @returns {string} absolute path of the lane marker
43
+ */
44
+ export function laneMarkerPath(root) {
45
+ return path.join(root, ...LANE_MARKER_REL.split("/"));
46
+ }
47
+
48
+ /**
49
+ * The render marker's absolute path for this project, or null when the
50
+ * profile declares no build directory (no eyes, nothing to wait for). Resolved
51
+ * through the manifest and the profile, synchronously; an unusable manifest or
52
+ * profile also yields null — a coordination courtesy must never refuse a lane.
53
+ * @param {string} root
54
+ * @returns {string|null}
55
+ */
56
+ export function renderMarkerPath(root) {
57
+ const manifest = resolveHarnessManifest(root);
58
+ if (!manifest.ok) return null;
59
+ const loaded = loadProfileSync(root, manifest.manifest.profile);
60
+ if (!loaded.ok) return null;
61
+ const buildDir = loaded.profile.layout && typeof loaded.profile.layout.buildDir === "string" ? loaded.profile.layout.buildDir : null;
62
+ if (!buildDir) return null;
63
+ return path.join(root, ...buildDir.split("/"), RENDER_MARKER_NAME);
64
+ }
65
+
66
+ /**
67
+ * A marker's mtime in ms, or null when absent/unreadable.
68
+ * @param {string|null} markerPath
69
+ * @returns {number|null}
70
+ */
71
+ export function markerMtimeMs(markerPath) {
72
+ if (!markerPath) return null;
73
+ try {
74
+ return fs.statSync(markerPath).mtimeMs;
75
+ } catch {
76
+ return null;
77
+ }
78
+ }
@@ -12,7 +12,7 @@
12
12
  // that can take minutes must emit a heartbeat, or the operator's only signal is
13
13
  // silence, and silence is exactly what a crash looks like.
14
14
  //
15
- // The marker verify.mjs already rewrites at each step start (.cmp-lane-in-progress,
15
+ // The marker verify.mjs already rewrites at each step start (qa/.lane-in-progress,
16
16
  // JSON: step, index, total, stepStartedAt, expectedStepMs, expectedLaneMs) carries
17
17
  // everything a pulse needs, so this narrator INVENTS NOTHING — it reads what the
18
18
  // lane declared about itself and says it out loud on a timer the lane cannot run.
@@ -24,8 +24,10 @@
24
24
  import fs from "node:fs";
25
25
  import path from "node:path";
26
26
 
27
+ import { laneMarkerPath } from "./lane-markers.mjs";
28
+
27
29
  const ROOT = process.argv[2];
28
- const MARKER = path.join(ROOT ?? ".", "composeApp", "build", ".cmp-lane-in-progress");
30
+ const MARKER = laneMarkerPath(ROOT ?? ".");
29
31
 
30
32
  // A step under this is not a wait — saying anything about it is noise.
31
33
  const FIRST_AFTER_MS = 20_000;
@@ -17,7 +17,7 @@ import fs from "node:fs";
17
17
  import path from "node:path";
18
18
  import { spawn } from "node:child_process";
19
19
 
20
- import { stepDeadlineMs, stepErrorResult } from "./step-outcomes.mjs";
20
+ import { stepDeadlineMs, stepErrorResult, resolveStepDeadlines } from "./step-outcomes.mjs";
21
21
 
22
22
  /**
23
23
  * Per-step expected durations from the journal's LAST FULL run — the source
@@ -62,6 +62,53 @@ export function verdictMark(verdict) {
62
62
  return verdict === "PASS" ? "✓" : verdict === "CACHED" ? "⚡" : verdict === "SKIP" ? "→" : verdict === "ERROR" ? "⊘" : "✗";
63
63
  }
64
64
 
65
+ // ── The compile short-circuit ───────────────────────────────────────────────
66
+ // Once the thing that turns source into a runnable artifact has FAILED, every
67
+ // verdict behind it is meaningless: the tests are testing the last successful
68
+ // artifact or nothing at all, and the lane would spend minutes producing rows
69
+ // nobody can read. Stopping there is right.
70
+ //
71
+ // WHICH STEP THAT IS, IS THE PACK'S TO SAY. This predicate was written
72
+ // `r.name === "build"`, which is Gradle's word twice over — the directory and
73
+ // the task. Keyed to the literal it was wrong in both directions on any other
74
+ // stack, and wrong SILENTLY, with a plausible-looking lane to show for it:
75
+ //
76
+ // a pack that compiles in `py_build` never short-circuited, so its whole slow
77
+ // tier ran against a tree that does not compile
78
+ // a pack that compiles elsewhere but has SOME step named `build` — a
79
+ // packaging step, a container image, a docs build; the word is not reserved —
80
+ // had its lane truncated there, dropping real verdicts on the floor
81
+ //
82
+ // So the name arrives on `ctx.compileStepName`, declared by the step pack
83
+ // (test/fixtures/profiles/py-alien/index.mjs declares "py_build";
84
+ // profiles/cmp/steps-cmp.mjs declares "build"). Three cases, and the
85
+ // distinction is the KEY's presence, not its value:
86
+ //
87
+ // key absent a caller written before the declaration existed. It keeps
88
+ // the historical behaviour rather than silently losing its
89
+ // short-circuit — a compatibility fallback, and the only
90
+ // place this file still spells one stack's word.
91
+ // key present, the pack has no compile phase (a lint-only lane, an
92
+ // no name interpreted stack). Short-circuit on NOTHING: every step
93
+ // runs and every verdict is taken. Never inherit someone
94
+ // else's step name — that is how a lane loses rows.
95
+ // key present, stop after that step FAILs, and only that step.
96
+ // a name
97
+ //
98
+ // FAIL and not ERROR, deliberately and unchanged: ERROR means "I could not
99
+ // check this", which is not the same claim as "this does not compile", and
100
+ // widening it here would change the shipped cmp lane's behaviour.
101
+ export const LEGACY_COMPILE_STEP_NAME = "build";
102
+
103
+ function compileShortCircuit(ctx) {
104
+ if (!Object.hasOwn(ctx, "compileStepName")) {
105
+ return (r) => r.name === LEGACY_COMPILE_STEP_NAME && r.verdict === "FAIL";
106
+ }
107
+ const declared = ctx.compileStepName;
108
+ if (typeof declared !== "string" || declared === "") return () => false;
109
+ return (r) => r.name === declared && r.verdict === "FAIL";
110
+ }
111
+
65
112
  /**
66
113
  * Run the steps, in order, under the lane's own discipline.
67
114
  *
@@ -78,8 +125,12 @@ export function verdictMark(verdict) {
78
125
  * @param {{entry: string, root: string}|null} [ctx.narrator] the pulse process to spawn
79
126
  * beside the loop (lane-narrator.mjs) — a separate process because the steps are
80
127
  * synchronous and no timer in this process can fire while one runs
81
- * @param {(result: object) => boolean} [ctx.stopAfter] short-circuit predicate; default:
82
- * stop after a FAILed "build" nothing downstream is meaningful
128
+ * @param {string|null} [ctx.compileStepName] the PACK's name for the step whose failure
129
+ * makes every later verdict meaningless (see compileShortCircuit above). Omit the key
130
+ * entirely and the historical `"build"` short-circuit is kept; pass null/undefined and
131
+ * the lane short-circuits on nothing
132
+ * @param {(result: object) => boolean} [ctx.stopAfter] short-circuit predicate — the
133
+ * caller's own word, outranking ctx.compileStepName; default: compileShortCircuit(ctx)
83
134
  * @param {() => void} [ctx.onFinally] runs in the finally (the project releases its device lease here)
84
135
  * @param {number} [ctx.startedAt] the lane's start, for the marker's `at`
85
136
  * @returns {{steps: object[], verdict: "PASS"|"FAIL", durationMs: number}}
@@ -90,13 +141,23 @@ export function runLane(ctx) {
90
141
  markerPath,
91
142
  expected = { byName: new Map(), laneMs: null },
92
143
  setDeadline = () => {},
144
+ // HOW LONG a step may take before it is wedged is the pack's to say, the
145
+ // same way `compileStepName` and `timeoutHint` are. Absent, the spine's
146
+ // fallback applies AND the ERROR row says so, so an adopter whose cold
147
+ // toolchain was cut short learns the knob exists.
148
+ stepDeadlines = undefined,
93
149
  print = null,
94
150
  narrator = null,
95
- stopAfter = (r) => r.name === "build" && r.verdict === "FAIL",
151
+ stopAfter = compileShortCircuit(ctx),
96
152
  onFinally = () => {},
97
153
  startedAt = Date.now(),
98
154
  } = ctx;
99
155
 
156
+ // Resolved once per lane, not once per step: the bounds cannot change mid-run
157
+ // and `isDefault` must be the same answer in the deadline and in the ERROR row
158
+ // that explains it.
159
+ const DEADLINES = resolveStepDeadlines(stepDeadlines);
160
+
100
161
  const stamp = (stepFn, index, total) => {
101
162
  try {
102
163
  const name = stepFn ? stepDisplayName(stepFn) : null;
@@ -137,13 +198,18 @@ export function runLane(ctx) {
137
198
  // S4: every step under a deadline from its own history (×3, floor 5 min,
138
199
  // ceiling 30). A deadline or a throw is ONE ERROR row — the lane keeps
139
200
  // going, because the other verdicts are still worth having.
140
- setDeadline(stepDeadlineMs(expected.byName.get(name)));
201
+ setDeadline(stepDeadlineMs(expected.byName.get(name), DEADLINES));
141
202
  const stepStarted = Date.now();
142
203
  let result;
143
204
  try {
144
205
  result = step();
145
206
  } catch (err) {
146
- result = stepErrorResult(name, err, Date.now() - stepStarted);
207
+ // `step.timeoutHint` is the pack's where-to-look sentence, marked on the
208
+ // step function like `step.layer`. The spine carries it; it never writes one.
209
+ result = stepErrorResult(name, err, Date.now() - stepStarted, {
210
+ hint: typeof step.timeoutHint === "string" ? step.timeoutHint : undefined,
211
+ deadlineWasDefault: DEADLINES.isDefault,
212
+ });
147
213
  }
148
214
  // Layer tag: a pack may mark a step function with the layer of the
149
215
  // stack it proves (`fn.layer = "backend"`). The runner stamps it onto
@@ -14,7 +14,7 @@
14
14
  // rendering carries the declaration's age — a stale plan reads as
15
15
  // stale, never as true.
16
16
  // 3. The CORROBORATION is derived and overrides: the lane/render markers
17
- // (composeApp/build/.cmp-lane-in-progress / .cmp-render-in-progress,
17
+ // (qa/.lane-in-progress / the profile's <buildDir>/.cmp-render-in-progress,
18
18
  // mtime-bounded like every other consumer) say what is ACTUALLY running
19
19
  // right now, regardless of what was declared.
20
20
  //
@@ -29,8 +29,12 @@
29
29
  // status surface never breaks the work it reports on.
30
30
 
31
31
  import fs from "node:fs";
32
+ import { gitignoredDirs, declaredIgnore } from "./inputs-hash.mjs";
32
33
  import path from "node:path";
33
34
 
35
+ import { laneMarkerPath, renderMarkerPath } from "./lane-markers.mjs";
36
+ import { resolveSpecModel } from "./spec-model.mjs";
37
+
34
38
  export const PLAN_REL = "qa/.plan.json";
35
39
  export const REQUEST_REL = "qa/.request.json";
36
40
  // N5 (docs/features/drive-narration.md): closed chains leave a LOCAL trail —
@@ -146,11 +150,20 @@ export function markStep(root, n) {
146
150
  return { ok: true, plan };
147
151
  }
148
152
 
149
- /** The receipt's verdict + rung right now, for the trail — fail-soft glance. */
153
+ /**
154
+ * The receipt's verdict + rung right now, for the trail — fail-soft glance.
155
+ *
156
+ * The pack rides along with the rung and is not optional decoration. This
157
+ * record is WRITTEN to the plan history and outlives the run that made it, so a
158
+ * bare rung here is worse than a bare rung printed to a terminal: nothing later
159
+ * can attribute it, and §8.9's rule that one pack's L2 and another's are
160
+ * different claims becomes unenforceable for every reader of the trail. `null`
161
+ * when the receipt names no pack, which is itself the honest answer.
162
+ */
150
163
  function receiptGlance(root) {
151
164
  try {
152
165
  const r = JSON.parse(fs.readFileSync(path.join(root, "qa/evidence/latest.json"), "utf8"));
153
- return { verdict: r?.verdict ?? null, rung: r?.evidenceLevel?.rung ?? null };
166
+ return { verdict: r?.verdict ?? null, rung: r?.evidenceLevel?.rung ?? null, pack: r?.pack?.id ?? null };
154
167
  } catch {
155
168
  return null;
156
169
  }
@@ -235,8 +248,8 @@ export function clearPlan(root) {
235
248
  * reads as a bare truthy {} — busy, no narration. Stale/absent -> false.
236
249
  * @returns {object|false}
237
250
  */
238
- function markerInfo(root, name) {
239
- const p = path.join(root, "composeApp", "build", name);
251
+ function markerInfo(p) {
252
+ if (!p) return false;
240
253
  try {
241
254
  const st = fs.statSync(p);
242
255
  if (Date.now() - st.mtimeMs >= MARKER_FRESH_MS) return false;
@@ -262,8 +275,27 @@ function markerInfo(root, name) {
262
275
  // this corroborates the build stage the same way: writes in the working tree
263
276
  // since the current request began. No agent cooperation required — which is
264
277
  // the point.
265
- const ACTIVITY_ROOTS = ["composeApp/src", "specs", "qa", "docs"];
266
- const ACTIVITY_SKIP_DIRS = new Set(["build", ".gradle", ".kotlin", ".git", ".idea", "node_modules", "evidence"]);
278
+ /**
279
+ * The trees whose writes count as observed agent activity: the profile's own
280
+ * source roots and specs directory, plus qa/ and docs/ (the harness and the
281
+ * governed prose — always core). Stage 0 PR 6b: this was a hardcoded
282
+ * ["composeApp/src", …], so on any other stack the build stage's observed tier
283
+ * saw nothing and the chain view reported an idle agent that was working.
284
+ * A project with no usable manifest gets the core roots alone — never a guess.
285
+ * @param {string} root
286
+ * @returns {string[]}
287
+ */
288
+ function activityRoots(root) {
289
+ const model = resolveSpecModel(root);
290
+ if (!model.ok) return ["qa", "docs"];
291
+ return [...new Set([...model.model.sourceRoots, model.model.specsDir, "qa", "docs"])];
292
+ }
293
+ // Machinery and the repo's own ignored directories — never one stack's build
294
+ // dirs by name (the 2026-09-08 language audit found .gradle and .kotlin here).
295
+ const ACTIVITY_SKIP_FLOOR = new Set([".git", "node_modules", "evidence"]);
296
+ function activitySkipDirs(root) {
297
+ return new Set([...ACTIVITY_SKIP_FLOOR, ...gitignoredDirs(root), ...declaredIgnore(root)]);
298
+ }
267
299
  // Machinery, not work: the chain's own files and the lane's outputs must not
268
300
  // count as "the agent wrote something", or the pulse would corroborate itself.
269
301
  const ACTIVITY_SKIP_FILES = new Set([".plan.json", ".request.json", ".plan-history.jsonl", "flight-recorder.jsonl", "approvals.log.jsonl", ".DS_Store"]);
@@ -282,6 +314,7 @@ export const ACTIVITY_STALL_MS = 10 * 60 * 1000;
282
314
  * null when there is no request to measure from
283
315
  */
284
316
  export function observeActivity(root, sinceIso, { now = Date.now() } = {}) {
317
+ const skipDirs = activitySkipDirs(root);
285
318
  const since = Date.parse(sinceIso ?? "");
286
319
  if (Number.isNaN(since)) return null;
287
320
  let filesChanged = 0;
@@ -295,7 +328,7 @@ export function observeActivity(root, sinceIso, { now = Date.now() } = {}) {
295
328
  }
296
329
  for (const e of entries) {
297
330
  if (e.isDirectory()) {
298
- if (!ACTIVITY_SKIP_DIRS.has(e.name)) walk(path.join(dir, e.name));
331
+ if (!skipDirs.has(e.name)) walk(path.join(dir, e.name));
299
332
  continue;
300
333
  }
301
334
  if (!e.isFile() || ACTIVITY_SKIP_FILES.has(e.name)) continue;
@@ -311,7 +344,7 @@ export function observeActivity(root, sinceIso, { now = Date.now() } = {}) {
311
344
  }
312
345
  }
313
346
  };
314
- for (const rel of ACTIVITY_ROOTS) walk(path.join(root, rel));
347
+ for (const rel of activityRoots(root)) walk(path.join(root, rel));
315
348
  return {
316
349
  filesChanged,
317
350
  lastWriteAgoMs: filesChanged > 0 ? Math.max(0, now - newest) : null,
@@ -333,8 +366,8 @@ export function deriveChain(root) {
333
366
  const plan = readPlan(root);
334
367
  const at = plan ? Date.parse(plan.updatedAt) : NaN;
335
368
  const busy = {
336
- lane: markerInfo(root, ".cmp-lane-in-progress"),
337
- render: markerInfo(root, ".cmp-render-in-progress"),
369
+ lane: markerInfo(laneMarkerPath(root)),
370
+ render: markerInfo(renderMarkerPath(root)),
338
371
  };
339
372
  const request = readRequest(root);
340
373
  // S3: the build stage's observed tier — writes since the request began.