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.
- package/bin/create-cmp.mjs +33 -3
- package/package.json +10 -4
- package/packages/harness/package.json +18 -8
- package/packages/harness/src/approve.mjs +10 -11
- package/packages/harness/src/console/console-data.mjs +117 -0
- package/packages/harness/src/console/console-evidence.mjs +122 -0
- package/packages/harness/src/console/console-overview.mjs +642 -0
- package/packages/harness/src/console/console-shell.mjs +1139 -0
- package/packages/harness/src/console/console-tabs.mjs +2828 -0
- package/packages/harness/src/console/contrast.mjs +74 -0
- package/packages/harness/src/console/preview-service.mjs +1384 -0
- package/packages/harness/src/framework-check.mjs +271 -37
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/approvals.mjs +177 -350
- package/packages/harness/src/lib/audit-cadence.mjs +1 -1
- package/packages/harness/src/lib/comments.mjs +18 -4
- package/packages/harness/src/lib/determinism.mjs +275 -14
- package/packages/harness/src/lib/evidence-badge.mjs +34 -2
- package/packages/harness/src/lib/evidence-ladder.mjs +202 -0
- package/packages/harness/src/lib/evidence-level.mjs +144 -59
- package/packages/harness/src/lib/feature-brief.mjs +53 -16
- package/packages/harness/src/lib/flight-recorder.mjs +72 -8
- package/packages/harness/src/lib/framework-check.mjs +434 -53
- package/packages/harness/src/lib/harness-lock.mjs +87 -19
- package/packages/harness/src/lib/harness-manifest.mjs +208 -0
- package/packages/harness/src/lib/harness-region.mjs +106 -2
- package/packages/harness/src/lib/harness-source.mjs +142 -0
- package/packages/harness/src/lib/inputs-hash.mjs +175 -22
- package/packages/harness/src/lib/lane-markers.mjs +78 -0
- package/packages/harness/src/lib/lane-narrator.mjs +4 -2
- package/packages/harness/src/lib/lane-runner.mjs +72 -6
- package/packages/harness/src/lib/plan.mjs +44 -11
- package/packages/harness/src/lib/plant-calibration.mjs +135 -0
- package/packages/harness/src/lib/profile-loader.mjs +323 -0
- package/packages/harness/src/lib/profiles/cmp/affected.mjs +70 -0
- package/packages/harness/src/lib/profiles/cmp/android-checks.mjs +56 -0
- package/packages/harness/src/lib/profiles/cmp/artifacts.mjs +381 -0
- package/packages/harness/src/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/packages/harness/src/lib/profiles/cmp/console-copy.mjs +29 -0
- package/packages/harness/src/lib/profiles/cmp/declarations.mjs +181 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/packages/harness/src/lib/profiles/cmp/index.mjs +47 -0
- package/packages/harness/src/lib/profiles/cmp/ladder.mjs +67 -0
- package/packages/harness/src/lib/profiles/cmp/maestro.mjs +113 -0
- package/packages/harness/src/lib/profiles/cmp/plants.mjs +95 -0
- package/{template/qa/lib → packages/harness/src/lib/profiles/cmp}/reachability.mjs +1 -1
- package/packages/harness/src/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
- package/packages/harness/src/lib/receipt-validate.mjs +84 -16
- package/packages/harness/src/lib/spec-coverage.mjs +179 -92
- package/packages/harness/src/lib/spec-model.mjs +262 -0
- package/packages/harness/src/lib/step-cache.mjs +11 -2
- package/packages/harness/src/lib/step-outcomes.mjs +84 -151
- package/packages/harness/src/lib/walk.mjs +1 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +80 -24
- package/packages/harness/src/record-audit.mjs +1 -1
- package/packages/harness/src/scaffold-feature.mjs +10 -2
- package/packages/harness/src/verify.mjs +222 -142
- package/packages/harness/src/watch.mjs +146 -33
- package/packages/receipts/package.json +1 -1
- package/packages/receipts/src/index.mjs +2 -2
- package/packages/receipts/src/inputs-hash.mjs +175 -22
- package/packages/receipts/src/receipt-validate.mjs +84 -16
- package/src/commands/attach.mjs +128 -4
- package/src/commands/harden.mjs +7 -1
- package/src/commands/upgrade.mjs +7 -0
- package/src/lib/minimal.mjs +23 -1
- package/src/scaffold.mjs +8 -0
- package/template/.claude/settings.json +2 -2
- package/template/.claude/skills/add-feature/SKILL.md +7 -3
- package/template/.claude/skills/add-repository/SKILL.md +2 -2
- package/template/.claude/skills/add-screen/SKILL.md +8 -4
- package/template/.githooks/pre-push +9 -0
- package/template/CLAUDE.md +55 -186
- package/template/docs/TESTING.md +1 -1
- package/template/gitignore +3 -0
- package/template/qa/approve.mjs +10 -11
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +271 -37
- package/template/qa/harness-manifest.json +10 -0
- package/template/qa/lib/a11y.mjs +1 -1
- package/template/qa/lib/affected-tests.mjs +99 -44
- package/template/qa/lib/approvals.mjs +177 -350
- package/template/qa/lib/audit-cadence.mjs +1 -1
- package/template/qa/lib/comments.mjs +18 -4
- package/template/qa/lib/determinism.mjs +275 -14
- package/template/qa/lib/evidence-badge.mjs +34 -2
- package/template/qa/lib/evidence-ladder.mjs +202 -0
- package/template/qa/lib/evidence-level.mjs +144 -59
- package/template/qa/lib/feature-brief.mjs +53 -16
- package/template/qa/lib/flight-recorder.mjs +72 -8
- package/template/qa/lib/framework-check.mjs +434 -53
- package/template/qa/lib/harness-lock.mjs +87 -19
- package/template/qa/lib/harness-manifest.mjs +208 -0
- package/template/qa/lib/harness-region.mjs +106 -2
- package/template/qa/lib/harness-source.mjs +142 -0
- package/template/qa/lib/inputs-hash.mjs +175 -22
- package/template/qa/lib/lane-markers.mjs +78 -0
- package/template/qa/lib/lane-narrator.mjs +4 -2
- package/template/qa/lib/lane-runner.mjs +72 -6
- package/template/qa/lib/plan.mjs +44 -11
- package/template/qa/lib/plant-calibration.mjs +135 -0
- package/template/qa/lib/profile-loader.mjs +323 -0
- package/template/qa/lib/profiles/cmp/affected.mjs +70 -0
- package/template/qa/lib/profiles/cmp/android-checks.mjs +56 -0
- package/template/qa/lib/profiles/cmp/artifacts.mjs +381 -0
- package/template/qa/lib/{component-stories.mjs → profiles/cmp/component-stories.mjs} +5 -2
- package/template/qa/lib/profiles/cmp/console-copy.mjs +29 -0
- package/template/qa/lib/profiles/cmp/declarations.mjs +181 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/e2e-coverage.mjs +13 -3
- package/template/qa/lib/profiles/cmp/index.mjs +47 -0
- package/template/qa/lib/profiles/cmp/ladder.mjs +67 -0
- package/template/qa/lib/profiles/cmp/maestro.mjs +113 -0
- package/template/qa/lib/profiles/cmp/plants.mjs +95 -0
- package/{packages/harness/src/lib → template/qa/lib/profiles/cmp}/reachability.mjs +1 -1
- package/template/qa/lib/{steps-cmp.mjs → profiles/cmp/steps-cmp.mjs} +229 -27
- package/template/qa/lib/receipt-validate.mjs +84 -16
- package/template/qa/lib/spec-coverage.mjs +179 -92
- package/template/qa/lib/spec-model.mjs +262 -0
- package/template/qa/lib/step-cache.mjs +11 -2
- package/template/qa/lib/step-outcomes.mjs +84 -151
- package/template/qa/lib/walk.mjs +1 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +80 -24
- package/template/qa/record-audit.mjs +1 -1
- package/template/qa/scaffold-feature.mjs +10 -2
- package/template/qa/verified-surface.json +17 -0
- package/template/qa/verify.mjs +222 -142
- package/template/qa/watch.mjs +146 -33
- /package/packages/harness/src/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/packages/harness/src/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/packages/harness/src/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/packages/harness/src/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /package/packages/harness/src/lib/{tree.mjs → profiles/cmp/tree.mjs} +0 -0
- /package/template/qa/lib/{device-lease.mjs → profiles/cmp/device-lease.mjs} +0 -0
- /package/template/qa/lib/{device-provider.mjs → profiles/cmp/device-provider.mjs} +0 -0
- /package/template/qa/lib/{render.mjs → profiles/cmp/render.mjs} +0 -0
- /package/template/qa/lib/{token-drift.mjs → profiles/cmp/token-drift.mjs} +0 -0
- /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
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
|
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,
|
|
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) =>
|
|
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
|
|
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) =>
|
|
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 (
|
|
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 =
|
|
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 {
|
|
82
|
-
*
|
|
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 = (
|
|
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
|
-
|
|
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
|
package/template/qa/lib/plan.mjs
CHANGED
|
@@ -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
|
-
// (
|
|
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
|
-
/**
|
|
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(
|
|
239
|
-
|
|
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
|
-
|
|
266
|
-
|
|
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 (!
|
|
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
|
|
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
|
|
337
|
-
render: markerInfo(root
|
|
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.
|