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
@@ -26,8 +26,9 @@
26
26
  // COORDINATION (non-negotiable): two concurrent Gradle invocations against one
27
27
  // project corrupt each other's output (KSP cache collisions, half-written
28
28
  // classes dirs — a real 20+ bogus-failure cascade). The lane and the preview
29
- // daemon already coordinate via marker files under composeApp/build:
30
- // .cmp-lane-in-progress — stamped by verify.mjs for a run's duration
29
+ // daemon already coordinate via marker files:
30
+ // qa/.lane-in-progress — stamped by verify.mjs for a run's duration (core
31
+ // state; qa/lib/lane-markers.mjs)
31
32
  // .cmp-render-in-progress — stamped by the preview daemon while its Gradle
32
33
  // build is in flight
33
34
  // Watch mode participates as a third citizen: it never launches a run while a
@@ -51,17 +52,20 @@ import fs from "node:fs";
51
52
  import path from "node:path";
52
53
  import { fileURLToPath } from "node:url";
53
54
 
55
+ import { LANE_MARKER_REL, LANE_MARKER_STALE_MS, RENDER_MARKER_FRESH_MS, laneMarkerPath, renderMarkerPath } from "./lib/lane-markers.mjs";
56
+ import { resolveSpecModel } from "./lib/spec-model.mjs";
57
+
54
58
  const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
55
59
 
56
60
  // ── CLI ─────────────────────────────────────────────────────────────────────
57
61
 
58
62
  export const USAGE = `node qa/watch.mjs [--once] [--json] [--help]
59
63
 
60
- Resident watch mode — the inner verification loop. Watches composeApp/src,
61
- specs/, and qa/ and runs \`node qa/verify.mjs --fast\` on every save (debounced;
64
+ Resident watch mode — the inner verification loop. Watches the project's
65
+ source roots, specs/, and qa/ and runs \`node qa/verify.mjs --fast\` on every save (debounced;
62
66
  a save storm triggers ONE run, changes during a run coalesce into one
63
67
  follow-up). It defers while a verify lane or a preview-daemon render holds the
64
- project (the .cmp-*-in-progress markers under composeApp/build), so two Gradle
68
+ project (qa/.lane-in-progress and the eyes' own render marker), so two build
65
69
  invocations never collide.
66
70
 
67
71
  THIS IS NOT A GATE. It runs the fast tier only: every receipt records
@@ -93,23 +97,106 @@ export function parseWatchArgs(rawArgs) {
93
97
  }
94
98
 
95
99
  // ── The watch set ───────────────────────────────────────────────────────────
96
- // composeApp/src (the app), specs/ (the contract), qa/ (the harness itself —
97
- // a golden-tree or e2e-flow edit should re-verify too). NOT watched: build
98
- // output anywhere, qa/evidence/ (verify.mjs writes latest.json there on every
99
- // run watching your own output is an infinite loop), and dotfiles (VCS
100
- // internals, editor droppings, and the .cmp-*-in-progress markers themselves).
100
+ // The profile's source roots (the app), its specs directory (the contract),
101
+ // and qa/ (the harness itself — a golden-tree or flow edit should re-verify
102
+ // too). NOT watched: the build output directory the PROFILE declares, at any
103
+ // depth (see shouldIgnorePath); qa/evidence/ (verify.mjs writes latest.json
104
+ // there on every run watching your own output is an infinite loop); and
105
+ // dotfiles (VCS internals, editor droppings, and the in-progress markers
106
+ // themselves).
107
+
108
+ /**
109
+ * The trees this loop watches: the profile's own source roots and specs
110
+ * directory, plus qa/ (the harness judging itself — always core).
111
+ *
112
+ * Stage 0 PR 6b: this was `["composeApp/src", "specs", "qa"]`, so the inner
113
+ * loop in a repo whose code lives anywhere else watched two directories out of
114
+ * three and never fired on a source edit — a watcher that looks idle and is.
115
+ * A project with no usable manifest gets the core roots alone and the startup
116
+ * banner says so, rather than guessing a source layout.
117
+ * @param {string} root
118
+ * @returns {{roots: string[], degraded: string|null}}
119
+ */
120
+ export function watchRoots(root) {
121
+ const model = resolveSpecModel(root);
122
+ if (!model.ok) {
123
+ return { roots: ["specs", "qa"], degraded: model.reason };
124
+ }
125
+ return { roots: [...new Set([...model.model.sourceRoots, model.model.specsDir, "qa"])], degraded: null };
126
+ }
127
+
128
+ // ── Build output: the profile's word, not one stack's ───────────────────────
129
+ // The ignore set's whole job is to keep the loop from watching its own output.
130
+ // It used to find that output by the single literal directory name `build`,
131
+ // which is Gradle's — Rust writes `target/`, .NET `bin/` and `obj/`, Python
132
+ // `__pycache__`, Xcode `DerivedData`. Both halves were wrong, and both
133
+ // silently, with a watcher that looked like it was working:
134
+ //
135
+ // on those stacks the lane's own writes woke the watcher, which ran the lane
136
+ // again — a feedback loop, in the one process whose entire purpose is to fire
137
+ // on a human's edit
138
+ // and on EVERY stack a real source directory named `build/` (a build script
139
+ // package, a `build/` module, Python's `app/build/`) was never watched at
140
+ // all — the "watcher that looks idle and is" that Stage 0 PR 6b already
141
+ // fixed once for source ROOTS and left in place here
142
+ //
143
+ // The profile declares `layout.buildDir` (qa/lib/spec-model.mjs), so the name
144
+ // is available and was simply not asked for.
145
+ //
146
+ // THE DECLARED DIRECTORY'S NAME MATCHES AT ANY DEPTH, not just the one declared
147
+ // path. A profile declares its app module's output, but a multi-module tree has
148
+ // one such directory PER MODULE (Gradle writes one into every module; the
149
+ // harness's own qa/ subtrees get one too — see test/watch-loop.test.mjs's
150
+ // `qa/somewhere/build/out.json`). Ignoring only the declared path would re-open
151
+ // the feedback loop one directory over. Only that name matches, so a stack that
152
+ // calls its output `target` starts watching `build/` as the ordinary source it
153
+ // is there.
154
+ //
155
+ // That rule is deliberately BROADER than the declaration, and the price is
156
+ // named here rather than discovered: a profile declaring a nested `buildDir`
157
+ // gets its LEAF name ignored everywhere (`build/outputs` ignores every
158
+ // `outputs/` in the tree, not just that one). The startup banner prints the
159
+ // glob it settled on for exactly this reason — an ignore set a reader can see
160
+ // is one they can correct in `layout.buildDir`.
101
161
 
102
- export const WATCH_ROOTS = ["composeApp/src", "specs", "qa"];
162
+ /**
163
+ * The historical name, kept ONLY for a project whose profile could not be
164
+ * resolved or which declares no `layout.buildDir`. It is a FALLBACK and not a
165
+ * default in the approving sense — the startup banner names it, exactly as
166
+ * spec-model.mjs's `DEFAULT_GRAMMAR.isDefault` makes the borrowed citation
167
+ * grammar say so out loud. The two failures are not symmetric: watching your
168
+ * own output loops forever, while missing a source directory named `build/`
169
+ * costs one manual run. When we know nothing, we take the cheaper mistake.
170
+ */
171
+ export const FALLBACK_BUILD_OUTPUT_NAME = "build";
172
+
173
+ /**
174
+ * The directory NAMES this project's build output wears, from the profile's
175
+ * declaration. `composeApp/build` → ["build"]; `target` → ["target"].
176
+ * @param {{buildDir?: string|null}|null} [model] a SpecModel, or null when none resolved
177
+ * @returns {string[]}
178
+ */
179
+ export function buildOutputNames(model) {
180
+ const declared = typeof model?.buildDir === "string" ? model.buildDir : "";
181
+ const name = declared.replace(/\\/g, "/").split("/").filter(Boolean).pop();
182
+ return name ? [name] : [FALLBACK_BUILD_OUTPUT_NAME];
183
+ }
103
184
 
104
- /** Ignore predicate over a ROOT-relative path (forward slashes or backslashes). */
105
- export function shouldIgnorePath(rel) {
185
+ /**
186
+ * Ignore predicate over a ROOT-relative path (forward slashes or backslashes).
187
+ * @param {string} rel
188
+ * @param {{buildDir?: string|null}|null} [model] the project's SpecModel — supplies the
189
+ * build output name. Omitted/null falls back to FALLBACK_BUILD_OUTPUT_NAME.
190
+ */
191
+ export function shouldIgnorePath(rel, model = null) {
106
192
  const norm = String(rel).replace(/\\/g, "/");
107
193
  if (!norm) return true;
108
194
  const parts = norm.split("/");
109
- // Any dotted segment: .git, .gradle, .DS_Store, .cmp-lane-in-progress, …
195
+ // Any dotted segment: .git, .gradle, .DS_Store, .lane-in-progress, …
110
196
  if (parts.some((s) => s.startsWith("."))) return true;
111
- // Any build dir at any depth (composeApp/build, qa/**/build, ).
112
- if (parts.includes("build")) return true;
197
+ // This profile's build output, at any depth (see the note above).
198
+ const buildNames = buildOutputNames(model);
199
+ if (parts.some((s) => buildNames.includes(s))) return true;
113
200
  // The lane's own output — the one path that would make watch feed itself.
114
201
  if (norm === "qa/evidence" || norm.startsWith("qa/evidence/")) return true;
115
202
  return false;
@@ -133,10 +220,11 @@ export const POLL_MS = 2000; // marker-wait poll AND the no-recursive-watch fall
133
220
  // around its Gradle builds. Freshness is mtime-bounded so a crashed stamper
134
221
  // never wedges us.
135
222
 
136
- export const LANE_MARKER_REL = ["composeApp", "build", ".cmp-lane-in-progress"];
137
- export const RENDER_MARKER_REL = ["composeApp", "build", ".cmp-render-in-progress"];
138
- export const LANE_MARKER_STALE_MS = 30 * 60 * 1000; // preview-service.mjs's bound for this marker
139
- export const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // verify.mjs's bound for this marker
223
+ // Paths and bounds are qa/lib/lane-markers.mjs's — the lane marker is core state
224
+ // under qa/, the render marker is the profile's (under its buildDir, or none).
225
+ // Re-exported because markerDecision's bounds are part of this module's
226
+ // contract (qa/watch.mjs --json consumers and the engine suite read them).
227
+ export { LANE_MARKER_STALE_MS, RENDER_MARKER_FRESH_MS };
140
228
 
141
229
  /**
142
230
  * The launch decision, pure: given the two markers' mtimes (null = absent) and
@@ -145,10 +233,10 @@ export const RENDER_MARKER_FRESH_MS = 5 * 60 * 1000; // verify.mjs's bound for t
145
233
  */
146
234
  export function markerDecision({ laneMtimeMs = null, renderMtimeMs = null, nowMs = Date.now() } = {}) {
147
235
  if (laneMtimeMs != null && nowMs - laneMtimeMs < LANE_MARKER_STALE_MS) {
148
- return { launch: false, reason: "a verify lane is in progress (.cmp-lane-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
236
+ return { launch: false, reason: `a verify lane is in progress (${LANE_MARKER_REL} is fresh) — deferring; changes coalesce into one run when it finishes` };
149
237
  }
150
238
  if (renderMtimeMs != null && nowMs - renderMtimeMs < RENDER_MARKER_FRESH_MS) {
151
- return { launch: false, reason: "the preview daemon has a Gradle build in flight (.cmp-render-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
239
+ return { launch: false, reason: "the preview daemon has a build in flight (.cmp-render-in-progress is fresh) — deferring; changes coalesce into one run when it finishes" };
152
240
  }
153
241
  return { launch: true };
154
242
  }
@@ -304,8 +392,8 @@ function markerMtime(absPath) {
304
392
 
305
393
  function launchDecisionNow() {
306
394
  return markerDecision({
307
- laneMtimeMs: markerMtime(path.join(ROOT, ...LANE_MARKER_REL)),
308
- renderMtimeMs: markerMtime(path.join(ROOT, ...RENDER_MARKER_REL)),
395
+ laneMtimeMs: markerMtime(laneMarkerPath(ROOT)),
396
+ renderMtimeMs: markerMtime(renderMarkerPath(ROOT)),
309
397
  });
310
398
  }
311
399
 
@@ -383,7 +471,7 @@ function main() {
383
471
  currentChild = null;
384
472
  // A signal-killed child never ran verify's `finally` — clean the lane
385
473
  // marker it stamped so nothing defers on a ghost for 30 minutes.
386
- if (signal) clearMarkerIfOwnedBy(path.join(ROOT, ...LANE_MARKER_REL), child.pid);
474
+ if (signal) clearMarkerIfOwnedBy(laneMarkerPath(ROOT), child.pid);
387
475
  const durationMs = Date.now() - started;
388
476
  const receipt = parseReceipt(stdout);
389
477
  const rawTail = `${stdout}\n${stderr}`.split("\n").filter(Boolean);
@@ -453,7 +541,18 @@ function main() {
453
541
 
454
542
  const watchers = [];
455
543
  const pollTimers = [];
456
- const watchedRoots = WATCH_ROOTS.filter((rel) => fs.existsSync(path.join(ROOT, rel)));
544
+ const { roots: declaredRoots, degraded: rootsDegraded } = watchRoots(ROOT);
545
+ const watchedRoots = declaredRoots.filter((rel) => fs.existsSync(path.join(ROOT, rel)));
546
+ // The ignore set is this profile's too: WHICH directory holds build output is
547
+ // a stack fact (`layout.buildDir`), and a watcher that guesses it either
548
+ // feeds itself or skips real source. resolveSpecModel shares the module cache
549
+ // with watchRoots's own call, so this is a map lookup, not a second load.
550
+ const specModel = (() => {
551
+ const r = resolveSpecModel(ROOT);
552
+ return r.ok ? r.model : null;
553
+ })();
554
+ const ignoredBuildNames = buildOutputNames(specModel);
555
+ const ignorePath = (rel) => shouldIgnorePath(rel, specModel);
457
556
 
458
557
  // Poll fallback for platforms without recursive fs.watch: a full mtime scan
459
558
  // per tick, diffed against the previous one so changed paths still get
@@ -470,7 +569,7 @@ function main() {
470
569
  }
471
570
  for (const e of entries) {
472
571
  const rel = `${dirRel}/${e.name}`;
473
- if (shouldIgnorePath(rel)) continue;
572
+ if (ignorePath(rel)) continue;
474
573
  const abs = path.join(dirAbs, e.name);
475
574
  if (e.isDirectory()) walk(abs, rel);
476
575
  else {
@@ -511,7 +610,7 @@ function main() {
511
610
  try {
512
611
  const w = fs.watch(path.join(ROOT, rootRel), { recursive: true }, (_event, filename) => {
513
612
  const rel = filename ? `${rootRel}/${String(filename).replace(/\\/g, "/")}` : rootRel;
514
- if (shouldIgnorePath(rel)) return;
613
+ if (ignorePath(rel)) return;
515
614
  loop.change(rel);
516
615
  });
517
616
  w.on("error", () => {
@@ -531,9 +630,23 @@ function main() {
531
630
  }
532
631
 
533
632
  // Startup: what is watched, what is respected, what this is NOT.
633
+ // The ignore globs are PRINTED, never assumed: they name the directory this
634
+ // profile declared, so a reader can see at a glance that the loop is ignoring
635
+ // this stack's output and not some other stack's word for it.
636
+ const ignoreGlobs = [...ignoredBuildNames.map((n) => `**/${n}/**`), "qa/evidence/**", "dotfiles"];
534
637
  say("qa/watch.mjs — resident inner loop: runs `node qa/verify.mjs --fast` on save");
535
- say(`watching: ${watchedRoots.join(", ")} (ignoring **/build/**, qa/evidence/**, dotfiles)`);
536
- say("coordination: defers while composeApp/build/.cmp-lane-in-progress or .cmp-render-in-progress is fresh never two Gradle invocations against this project");
638
+ say(`watching: ${watchedRoots.join(", ")} (ignoring ${ignoreGlobs.join(", ")})`);
639
+ // Never watch a guessed layout silently: if the manifest could not be read,
640
+ // the source roots are unknown and this loop is watching less than it looks.
641
+ if (rootsDegraded) say(`NOTE: watching the core roots only — ${rootsDegraded}`);
642
+ // And never ignore a guessed layout silently either. A profile that declares
643
+ // no layout.buildDir leaves this loop on FALLBACK_BUILD_OUTPUT_NAME — one
644
+ // stack's convention, applied to a stack that never claimed it — so it says
645
+ // so, the way a borrowed citation grammar does (spec-model.mjs).
646
+ else if (specModel && typeof specModel.buildDir !== "string") {
647
+ say(`NOTE: this profile declares no layout.buildDir — ignoring **/${FALLBACK_BUILD_OUTPUT_NAME}/** as a fallback, which may be this project's real source`);
648
+ }
649
+ say(`coordination: defers while ${LANE_MARKER_REL} or the eyes' render marker is fresh — never two builds against this project`);
537
650
  say(`debounce: ${DEBOUNCE_MS}ms — a save storm triggers one run; changes during a run coalesce into one follow-up`);
538
651
  say(FOOTER);
539
652
  say("waiting for changes… (Ctrl-C to stop · --once for a single pass · --json for line-per-run output)");
@@ -541,9 +654,9 @@ function main() {
541
654
  event: "start",
542
655
  pid: process.pid,
543
656
  watching: watchedRoots,
544
- ignoring: ["**/build/**", "qa/evidence/**", "dotfiles"],
657
+ ignoring: ignoreGlobs,
545
658
  debounceMs: DEBOUNCE_MS,
546
- coordinates: [LANE_MARKER_REL.join("/"), RENDER_MARKER_REL.join("/")],
659
+ coordinates: [LANE_MARKER_REL, renderMarkerPath(ROOT) ? path.relative(ROOT, renderMarkerPath(ROOT)).split(path.sep).join("/") : null].filter(Boolean),
547
660
  runs: "node qa/verify.mjs --fast",
548
661
  note: FOOTER,
549
662
  });
@@ -598,7 +711,7 @@ function installSignalHandlers(stopWork, getChild, emit, say) {
598
711
  signalTree("SIGKILL");
599
712
  await Promise.race([exited, sleep(1000)]);
600
713
  }
601
- clearMarkerIfOwnedBy(path.join(ROOT, ...LANE_MARKER_REL), child.pid);
714
+ clearMarkerIfOwnedBy(laneMarkerPath(ROOT), child.pid);
602
715
  }
603
716
  say(`watch mode stopped (${sig}) — no receipt was made valid by watching; the done-gate is still one full \`node qa/verify.mjs\` run`);
604
717
  emit({ event: "shutdown", reason: sig });