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.
- 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 +46 -18
- 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 +747 -0
- package/packages/harness/src/lib/a11y.mjs +1 -1
- package/packages/harness/src/lib/affected-tests.mjs +99 -44
- package/packages/harness/src/lib/agent-hold.mjs +234 -0
- 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 +38 -6
- 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 +778 -0
- 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 +180 -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} +230 -28
- 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/plan.mjs +30 -1
- package/packages/harness/src/preview-gallery.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +95 -26
- 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 +180 -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 +4 -0
- package/template/qa/approve.mjs +46 -18
- package/template/qa/comments.json +1 -1
- package/template/qa/evidence/schema.json +258 -34
- package/template/qa/framework-check.mjs +747 -0
- 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/agent-hold.mjs +234 -0
- 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 +38 -6
- 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 +778 -0
- 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 +180 -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} +230 -28
- 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/plan.mjs +30 -1
- package/template/qa/preview-gallery.mjs +1 -1
- package/template/qa/receipt-check.mjs +95 -26
- 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
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema": "harness-manifest/2",
|
|
3
|
+
"profile": { "id": "cmp" },
|
|
4
|
+
"receipt": "qa/evidence/latest.json",
|
|
5
|
+
"architectureDoc": "docs/ARCHITECTURE.md",
|
|
6
|
+
"specs": "specs",
|
|
7
|
+
"citationRoots": ["composeApp/src", "qa/e2e"],
|
|
8
|
+
"approvals": "qa/approvals.json",
|
|
9
|
+
"packs": ["cmp"]
|
|
10
|
+
}
|
package/template/qa/lib/a11y.mjs
CHANGED
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
// Trees produced before the role/clickable/disabled contract extension are handled
|
|
22
22
|
// gracefully: nodes without `clickable` are simply skipped, never crashed on.
|
|
23
23
|
|
|
24
|
-
import { walk } from "./tree.mjs";
|
|
24
|
+
import { walk } from "./profiles/cmp/tree.mjs";
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Audit a tree for accessibility faults.
|
|
@@ -14,11 +14,18 @@
|
|
|
14
14
|
// and the caller reports which case it was in the step's output and the
|
|
15
15
|
// receipt, so a filtered run can never be mistaken for the full suite.
|
|
16
16
|
// - The BLAST-RADIUS ESCAPE HATCH is mandatory: some paths fan out too
|
|
17
|
-
// widely to subset safely
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
17
|
+
// widely to subset safely. qa/ is the harness judging itself — that one is
|
|
18
|
+
// the core's, on every stack. WHICH OTHER paths fan out, and how a changed
|
|
19
|
+
// source maps to a test filter, are facts about one build tool and one
|
|
20
|
+
// source layout, so they come from the PROFILE (Stage 0 PR 6d;
|
|
21
|
+
// profiles/cmp/affected.mjs). Any one broad-impact change disables
|
|
22
|
+
// filtering for the run.
|
|
23
|
+
//
|
|
24
|
+
// A profile that supplies no mapping gets `mode: "all"` with that as the
|
|
25
|
+
// reason — fail open, said out loud. Vendored into a repo whose sources are
|
|
26
|
+
// not under composeApp/src, the old hardcoded rules did something worse than
|
|
27
|
+
// nothing: every path failed the layout test, so every fast run fell open to
|
|
28
|
+
// the full suite with the optimisation silently off.
|
|
22
29
|
//
|
|
23
30
|
// Pure functions over path lists — git access is injected/separate so the
|
|
24
31
|
// engine suite can test every branch with no repo state.
|
|
@@ -43,50 +50,37 @@ import path from "node:path";
|
|
|
43
50
|
// first fell open to the full suite, visible only in one parenthetical.
|
|
44
51
|
// Found by payment-blueprint's spine adoption (2026-09-03), where the same
|
|
45
52
|
// line also landed in their locked region.
|
|
46
|
-
|
|
53
|
+
// qa/.lane-in-progress is the lane's own marker (qa/lib/lane-markers.mjs) —
|
|
54
|
+
// present, untracked, for exactly the duration of the run that would read it.
|
|
55
|
+
export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts", "qa/flight-recorder.jsonl", "qa/.lane-in-progress"];
|
|
47
56
|
|
|
48
57
|
function isLaneOutput(p) {
|
|
49
58
|
return LANE_OUTPUT_PREFIXES.some((prefix) => p === prefix || p.startsWith(`${prefix}/`));
|
|
50
59
|
}
|
|
51
60
|
|
|
52
61
|
/**
|
|
53
|
-
* The
|
|
54
|
-
*
|
|
55
|
-
* `p` is broad-impact, else null. Checked in order; the first match names the
|
|
56
|
-
* reason.
|
|
62
|
+
* The core's own blast-radius rule, on every stack: a change under qa/ is the
|
|
63
|
+
* harness judging itself, so nothing may be subsetted by it.
|
|
57
64
|
* @param {string} p POSIX relpath from the project root
|
|
58
65
|
* @returns {string|null}
|
|
59
66
|
*/
|
|
60
|
-
export function
|
|
61
|
-
if (p.endsWith(".gradle.kts") || p === "gradle.properties" || p === "gradle/libs.versions.toml") {
|
|
62
|
-
return "build files rewire compilation";
|
|
63
|
-
}
|
|
64
|
-
if (/(^|\/)di\//.test(p)) return "DI rewires the object graph";
|
|
65
|
-
if (/(^|\/)theme\//.test(p)) return "theme/tokens render into every screen";
|
|
66
|
-
if (p.includes("presentation/components/")) return "shared components render into every screen";
|
|
67
|
+
export function coreBroadImpactReason(p) {
|
|
67
68
|
if (p === "qa" || p.startsWith("qa/")) return "qa/ is the harness itself";
|
|
68
|
-
if (!p.startsWith("composeApp/src/")) return "outside composeApp/src";
|
|
69
69
|
return null;
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
/**
|
|
73
73
|
* Derive the fast-mode unit-test filter from a list of changed paths.
|
|
74
74
|
*
|
|
75
|
-
* Mapping (deliberately simple and defensible): each changed `.kt` file under
|
|
76
|
-
* composeApp/src contributes its package's last segment — the parent
|
|
77
|
-
* directory name (`…/presentation/home/HomeViewModel.kt` → `home`, which the
|
|
78
|
-
* template's package-mirrors-path conformance makes a package segment) — and
|
|
79
|
-
* the union becomes Gradle `--tests "*<seg>*"` patterns matched against test
|
|
80
|
-
* class FQNs. Coarse on purpose: `*home*` runs every test whose FQN mentions
|
|
81
|
-
* the feature, which over-selects a little and under-maintains nothing.
|
|
82
|
-
*
|
|
83
75
|
* @param {string[]} changedPaths relpaths (either separator style) — tracked
|
|
84
76
|
* diffs plus untracked files, as from changedWorkingTreePaths()
|
|
77
|
+
* @param {{broadImpact: (p: string) => (string|null), patternsFor: (paths: string[]) => {patterns: string[], sourcePaths: string[]}}} [mapping]
|
|
78
|
+
* the profile's rules (profiles/<id>/affected.mjs). Absent = no subsetting.
|
|
85
79
|
* @returns {{mode: "filtered", patterns: string[], sourcePaths: string[]} |
|
|
86
80
|
* {mode: "all", reason: string, patterns: [], sourcePaths: string[]}}
|
|
87
81
|
* mode "all" ALWAYS carries the honest reason to report.
|
|
88
82
|
*/
|
|
89
|
-
export function deriveAffectedFilter(changedPaths) {
|
|
83
|
+
export function deriveAffectedFilter(changedPaths, mapping = null) {
|
|
90
84
|
const paths = [...new Set((changedPaths ?? [])
|
|
91
85
|
.filter((p) => typeof p === "string" && p.length > 0)
|
|
92
86
|
.map((p) => p.split(path.sep).join("/")))]
|
|
@@ -97,32 +91,93 @@ export function deriveAffectedFilter(changedPaths) {
|
|
|
97
91
|
return { mode: "all", reason: "no working-tree changes to scope by", patterns: [], sourcePaths: [] };
|
|
98
92
|
}
|
|
99
93
|
|
|
94
|
+
// No mapping, no subsetting — and the reason says which half is missing, so
|
|
95
|
+
// a profile author sees the optimisation is off rather than wondering why
|
|
96
|
+
// the fast lane costs what the full one does.
|
|
97
|
+
if (!mapping || typeof mapping.broadImpact !== "function" || typeof mapping.patternsFor !== "function") {
|
|
98
|
+
return { mode: "all", reason: "this profile declares no affected-test mapping — every fast run tests everything", patterns: [], sourcePaths: paths };
|
|
99
|
+
}
|
|
100
|
+
|
|
100
101
|
for (const p of paths) {
|
|
101
|
-
const broad =
|
|
102
|
+
const broad = coreBroadImpactReason(p) ?? mapping.broadImpact(p);
|
|
102
103
|
if (broad) {
|
|
103
104
|
return { mode: "all", reason: `broad-impact change — ${broad} (${p})`, patterns: [], sourcePaths: paths };
|
|
104
105
|
}
|
|
105
106
|
}
|
|
106
107
|
|
|
107
|
-
// Every remaining path is a scoped
|
|
108
|
-
//
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
const segments = new Set();
|
|
112
|
-
for (const p of ktPaths) {
|
|
113
|
-
const seg = path.posix.basename(path.posix.dirname(p));
|
|
114
|
-
if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(seg)) segments.add(seg);
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (segments.size === 0) {
|
|
108
|
+
// Every remaining path is a scoped source edit by the profile's own reckoning.
|
|
109
|
+
// A change that maps to no pattern (resources, manifests) falls open below.
|
|
110
|
+
const { patterns, sourcePaths } = mapping.patternsFor(paths);
|
|
111
|
+
if (!Array.isArray(patterns) || patterns.length === 0) {
|
|
118
112
|
return { mode: "all", reason: "changed files map to no test filter", patterns: [], sourcePaths: paths };
|
|
119
113
|
}
|
|
120
114
|
|
|
121
|
-
return {
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
115
|
+
return { mode: "filtered", patterns, sourcePaths: Array.isArray(sourcePaths) ? sourcePaths : paths };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Must the expensive tier run for this change?
|
|
120
|
+
*
|
|
121
|
+
* The most costly thing an agent does is run a device or journey tier — minutes
|
|
122
|
+
* of emulator, build and flow — to prove something about a change that tier
|
|
123
|
+
* cannot see. Running it anyway is not rigour, it is latency, and latency is
|
|
124
|
+
* how an agent ends up CLAIMING instead of deriving (G1, G2).
|
|
125
|
+
*
|
|
126
|
+
* THE DECLARATION IS OF IRRELEVANCE, NOT OF RELEVANCE, and getting that
|
|
127
|
+
* backwards turns this into the defect it prevents. The first version of this
|
|
128
|
+
* function asked "is any changed path under a root that FEEDS the tier?" and
|
|
129
|
+
* answered "not required" when none matched — an allowlist, so anything nobody
|
|
130
|
+
* thought to list was silently deferred. `cmp` declares
|
|
131
|
+
* `sourceRoots: ["composeApp/src"]`, which excludes `build.gradle.kts` and
|
|
132
|
+
* `gradle/libs.versions.toml` — both of which absolutely change what runs on a
|
|
133
|
+
* device. A dependency bump would have skipped the device tier and said so
|
|
134
|
+
* confidently (ADR-0009, "what would make this wrong").
|
|
135
|
+
*
|
|
136
|
+
* So: the tier RUNS unless every changed path is under something explicitly
|
|
137
|
+
* declared unable to affect it. A path nobody classified obliges the tier,
|
|
138
|
+
* which is the safe direction — being wrong toward running costs minutes, being
|
|
139
|
+
* wrong toward deferring costs a regression nobody saw.
|
|
140
|
+
*
|
|
141
|
+
* This does not skip anything; it answers a question and always carries the
|
|
142
|
+
* reason, so a caller can record it and a reader can disagree with it. An
|
|
143
|
+
* unrecorded skip is a lie; a recorded one is evidence.
|
|
144
|
+
*
|
|
145
|
+
* @param {string[]|null} changedPaths relpaths, or null when git could not say
|
|
146
|
+
* @param {{irrelevantRoots?: string[], tierName?: string}} [decl] paths declared
|
|
147
|
+
* unable to affect the tier: a `dir/` prefix, or a `*.ext` suffix
|
|
148
|
+
* @returns {{required: boolean, reason: string, obliging: string[]}}
|
|
149
|
+
*/
|
|
150
|
+
export function deriveTierNeed(changedPaths, { irrelevantRoots = [], tierName = "the device tier" } = {}) {
|
|
151
|
+
if (!Array.isArray(changedPaths)) {
|
|
152
|
+
return { required: true, reason: `cannot tell what changed — ${tierName} runs`, obliging: [] };
|
|
153
|
+
}
|
|
154
|
+
const paths = changedPaths
|
|
155
|
+
.filter((p) => typeof p === "string" && p.length > 0)
|
|
156
|
+
.map((p) => p.split(path.sep).join("/"))
|
|
157
|
+
.filter((p) => !isLaneOutput(p));
|
|
158
|
+
if (paths.length === 0) {
|
|
159
|
+
return { required: true, reason: `no change to reason about — ${tierName} runs`, obliging: [] };
|
|
160
|
+
}
|
|
161
|
+
// The harness judging itself is always broad impact — the same rule the
|
|
162
|
+
// fast-lane filter uses, so the two cannot disagree about the same edit.
|
|
163
|
+
const core = paths.find((p) => coreBroadImpactReason(p));
|
|
164
|
+
if (core) {
|
|
165
|
+
return { required: true, reason: `broad-impact change — ${coreBroadImpactReason(core)} (${core})`, obliging: [core] };
|
|
166
|
+
}
|
|
167
|
+
const declared = (irrelevantRoots ?? []).filter((r) => typeof r === "string" && r.length > 0);
|
|
168
|
+
if (declared.length === 0) {
|
|
169
|
+
return { required: true, reason: `nothing is declared unable to affect ${tierName} — it runs`, obliging: paths };
|
|
170
|
+
}
|
|
171
|
+
const isIrrelevant = (p) =>
|
|
172
|
+
declared.some((r) => (r.startsWith("*.") ? p.endsWith(r.slice(1)) : p === r.replace(/\/$/, "") || p.startsWith(r.endsWith("/") ? r : `${r}/`)));
|
|
173
|
+
const obliging = paths.filter((p) => !isIrrelevant(p));
|
|
174
|
+
return obliging.length > 0
|
|
175
|
+
? {
|
|
176
|
+
required: true,
|
|
177
|
+
reason: `${obliging.length} changed path(s) are not declared irrelevant to ${tierName}: ${obliging.slice(0, 3).join(", ")}${obliging.length > 3 ? ", …" : ""}`,
|
|
178
|
+
obliging,
|
|
179
|
+
}
|
|
180
|
+
: { required: false, reason: `every changed path is declared unable to affect ${tierName} (${declared.join(", ")})`, obliging: [] };
|
|
126
181
|
}
|
|
127
182
|
|
|
128
183
|
function defaultRunGit(args, root) {
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
// agent-hold.mjs — "an agent is working in this tree right now."
|
|
2
|
+
//
|
|
3
|
+
// TWO PROBLEMS, ONE MISSING FACT. Both were reported from payment-blueprint's
|
|
4
|
+
// adoption on 2026-09-04, and both are the same absence:
|
|
5
|
+
//
|
|
6
|
+
// 1. LIVENESS. There was no way to answer "is the agent working, or wedged?"
|
|
7
|
+
// without filesystem archaeology. The lead architect guessed twice with two
|
|
8
|
+
// separately broken instruments — a `find` that excluded `build/` (the only
|
|
9
|
+
// directory a proof run writes to) and a `find -newermt` that reported zero
|
|
10
|
+
// writes in 45 minutes while `ls -lT` showed one at 10 — and on the first
|
|
11
|
+
// guess killed a healthy agent mid-proof. An instrument that cannot see the
|
|
12
|
+
// thing it exists to detect is worse than no instrument: it is confidently
|
|
13
|
+
// wrong, which is the same failure class GATE-RULES Rule 1 exists for.
|
|
14
|
+
//
|
|
15
|
+
// 2. FALSE ALARMS. The Stop hook fires identically whether a receipt is stale
|
|
16
|
+
// because nobody ran the lane or because a subagent is mid-commit on a
|
|
17
|
+
// half-adopted port. It fired ~15 times in one evening while the correct
|
|
18
|
+
// action every time was to WAIT. Anthropic's tool-design guidance is
|
|
19
|
+
// explicit that an error must communicate "specific and actionable
|
|
20
|
+
// improvements"; an alarm whose advice is wrong every time it fires trains
|
|
21
|
+
// its reader to ignore it, which is strictly worse than silence.
|
|
22
|
+
//
|
|
23
|
+
// WHAT THIS IS NOT. It is not a lock — nothing waits on it, nothing is excluded
|
|
24
|
+
// by it. It is not a second journal: the flight recorder still owns lane history
|
|
25
|
+
// that belongs in the repo. It is a DECLARATION with an expiry, in the same
|
|
26
|
+
// ephemeral, gitignored, hash-excluded family as qa/.plan.json and
|
|
27
|
+
// qa/.request.json — because a fact about who is typing must never be able to
|
|
28
|
+
// invalidate a receipt.
|
|
29
|
+
//
|
|
30
|
+
// A HOLD CHANGES THE ADVICE, NEVER THE VERDICT. The Stop hook still refuses:
|
|
31
|
+
// no receipt yet means not done, and a file an agent writes about itself must
|
|
32
|
+
// never be able to end a turn — that would be turning the gate off by writing a
|
|
33
|
+
// file, which is the attack the whole harness exists to refuse. This follows the
|
|
34
|
+
// precedent already set for a lane in flight (qa/receipt-check.mjs): same
|
|
35
|
+
// refusal, different instruction. "Run the lane" is wrong advice when the tree
|
|
36
|
+
// is mid-edit and would not compile, and a gate that tells you to do the thing
|
|
37
|
+
// you are already doing trains you to stop reading it.
|
|
38
|
+
//
|
|
39
|
+
// THE ASYMMETRY THAT KEEPS IT HONEST. A hold EXPLAINS the absence of fresh
|
|
40
|
+
// evidence. It never explains CONTRADICTING evidence. A red receipt, a forged
|
|
41
|
+
// receipt, a skipped device tier: for those the hold is not the reason and
|
|
42
|
+
// saying so would mislead. Only two refusals are explicable by a hold — "no
|
|
43
|
+
// receipt yet" and "the tree moved since a PASSing receipt" — exactly the two
|
|
44
|
+
// states a working agent legitimately produces, and nothing else.
|
|
45
|
+
//
|
|
46
|
+
// SINGLE SOURCE OF TRUTH: packages/harness/src/lib/agent-hold.mjs in the
|
|
47
|
+
// create-cmp repo. The copy in a generated project's qa/lib/ is vendored
|
|
48
|
+
// byte-identical at scaffold time — edit the package source, then run
|
|
49
|
+
// `node scripts/sync-harness.mjs`.
|
|
50
|
+
|
|
51
|
+
import fs from "node:fs";
|
|
52
|
+
import path from "node:path";
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Ephemeral, gitignored, and excluded from the receipt's hashed input surface
|
|
56
|
+
* (qa/lib/inputs-hash.mjs EXCLUDED_PREFIXES) — the same family as .plan.json.
|
|
57
|
+
*/
|
|
58
|
+
export const HOLD_REL = "qa/.agent-hold.json";
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* A heartbeat older than this is a crashed writer, not a live agent. The same
|
|
62
|
+
* bound every other marker consumer in this lane applies (qa/lib/plan.mjs), for
|
|
63
|
+
* the same reason: a process that dies leaves its file behind, so freshness —
|
|
64
|
+
* never presence — is what makes a marker mean anything.
|
|
65
|
+
*/
|
|
66
|
+
export const HEARTBEAT_FRESH_MS = 5 * 60 * 1000;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A hold this old is a wedge, not work. Past the ceiling the hook resumes
|
|
70
|
+
* blocking even while heartbeats keep arriving: an agent that has held the tree
|
|
71
|
+
* for three quarters of an hour is exactly the case the human needed to see, and
|
|
72
|
+
* a heartbeat proves the process is alive, never that it is making progress.
|
|
73
|
+
*/
|
|
74
|
+
export const HOLD_CEILING_MS = 45 * 60 * 1000;
|
|
75
|
+
|
|
76
|
+
const MAX_TEXT = 200;
|
|
77
|
+
|
|
78
|
+
const clip = (s, n = MAX_TEXT) => (typeof s === "string" ? s.trim().slice(0, n) : "");
|
|
79
|
+
|
|
80
|
+
/** Fail-soft like every other status reader here: unreadable reads as absent. */
|
|
81
|
+
export function readHold(root) {
|
|
82
|
+
try {
|
|
83
|
+
const parsed = JSON.parse(fs.readFileSync(path.join(root, ...HOLD_REL.split("/")), "utf8"));
|
|
84
|
+
return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : null;
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function writeJson(root, value) {
|
|
91
|
+
try {
|
|
92
|
+
const p = path.join(root, ...HOLD_REL.split("/"));
|
|
93
|
+
fs.mkdirSync(path.dirname(p), { recursive: true });
|
|
94
|
+
fs.writeFileSync(p, `${JSON.stringify(value, null, 2)}\n`);
|
|
95
|
+
return { ok: true, hold: value };
|
|
96
|
+
} catch (err) {
|
|
97
|
+
return { ok: false, error: err.message };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Claim the tree. `holder` is a name a human will read in an alarm — an agent
|
|
103
|
+
* or session label, not a UUID: Anthropic's tool guidance is that agents (and
|
|
104
|
+
* the people reading after them) do far better with natural-language
|
|
105
|
+
* identifiers than with opaque ids, and this string's whole job is to be read
|
|
106
|
+
* at 3am by someone deciding whether to kill a process.
|
|
107
|
+
*/
|
|
108
|
+
export function claimHold(root, { holder, note = "", now = Date.now() } = {}) {
|
|
109
|
+
const name = clip(holder, 80) || "an agent";
|
|
110
|
+
const existing = readHold(root);
|
|
111
|
+
const at = existing && assessHold(existing, now).held ? existing.at : new Date(now).toISOString();
|
|
112
|
+
return writeJson(root, {
|
|
113
|
+
holder: name,
|
|
114
|
+
note: clip(note),
|
|
115
|
+
at,
|
|
116
|
+
heartbeatAt: new Date(now).toISOString(),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Still here, still working. Optionally re-states what "here" means — an agent
|
|
122
|
+
* that only says "alive" is barely better than the `find` that started this.
|
|
123
|
+
*/
|
|
124
|
+
export function beatHold(root, { note, now = Date.now() } = {}) {
|
|
125
|
+
const existing = readHold(root);
|
|
126
|
+
if (!existing) return { ok: false, error: "no hold to beat — claim one first" };
|
|
127
|
+
return writeJson(root, {
|
|
128
|
+
...existing,
|
|
129
|
+
note: note === undefined ? existing.note : clip(note),
|
|
130
|
+
heartbeatAt: new Date(now).toISOString(),
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function releaseHold(root) {
|
|
135
|
+
try {
|
|
136
|
+
fs.rmSync(path.join(root, ...HOLD_REL.split("/")), { force: true });
|
|
137
|
+
return { ok: true };
|
|
138
|
+
} catch (err) {
|
|
139
|
+
return { ok: false, error: err.message };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Is a hold in force, and what does it say?
|
|
145
|
+
*
|
|
146
|
+
* Every negative branch names WHY, because this feeds an alarm's text: "no
|
|
147
|
+
* agent holds the tree" and "an agent claimed it 50 minutes ago and is past the
|
|
148
|
+
* ceiling" call for opposite actions by the human reading them.
|
|
149
|
+
*
|
|
150
|
+
* @param {object|null} hold
|
|
151
|
+
* @param {number} now
|
|
152
|
+
* @returns {{held: boolean, reason: string, holder?: string, note?: string,
|
|
153
|
+
* heldMs?: number, sinceBeatMs?: number, expired?: boolean}}
|
|
154
|
+
*/
|
|
155
|
+
export function assessHold(hold, now = Date.now()) {
|
|
156
|
+
if (!hold || typeof hold !== "object") return { held: false, reason: "no agent holds the tree" };
|
|
157
|
+
const at = Date.parse(hold.at);
|
|
158
|
+
const beat = Date.parse(hold.heartbeatAt ?? hold.at);
|
|
159
|
+
if (Number.isNaN(at) || Number.isNaN(beat)) return { held: false, reason: "the hold file has no readable timestamp" };
|
|
160
|
+
|
|
161
|
+
const heldMs = Math.max(0, now - at);
|
|
162
|
+
const sinceBeatMs = Math.max(0, now - beat);
|
|
163
|
+
const holder = clip(hold.holder, 80) || "an agent";
|
|
164
|
+
const note = clip(hold.note);
|
|
165
|
+
const base = { holder, note, heldMs, sinceBeatMs };
|
|
166
|
+
|
|
167
|
+
if (sinceBeatMs > HEARTBEAT_FRESH_MS) {
|
|
168
|
+
return {
|
|
169
|
+
...base,
|
|
170
|
+
held: false,
|
|
171
|
+
expired: true,
|
|
172
|
+
reason: `${holder} last checked in ${formatAge(sinceBeatMs)} — that is a crashed writer, not a live agent`,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
if (heldMs > HOLD_CEILING_MS) {
|
|
176
|
+
return {
|
|
177
|
+
...base,
|
|
178
|
+
held: false,
|
|
179
|
+
expired: true,
|
|
180
|
+
reason: `${holder} has held the tree for ${formatAge(heldMs)}, past the ${formatAge(HOLD_CEILING_MS)} ceiling — a heartbeat proves the process is alive, not that it is progressing`,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
return { ...base, held: true, reason: `${holder} has held the tree for ${formatAge(heldMs)}` };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* The ONE line an alarm prints instead of demanding a lane run. It says who,
|
|
188
|
+
* since when, what they said they were doing, and what the reader should do —
|
|
189
|
+
* "specific and actionable", which the alarm it replaces was not.
|
|
190
|
+
*/
|
|
191
|
+
export function describeHold(assessment) {
|
|
192
|
+
if (!assessment?.held) return null;
|
|
193
|
+
const what = assessment.note ? ` (${assessment.note})` : "";
|
|
194
|
+
return (
|
|
195
|
+
`${assessment.holder} has held this tree for ${formatAge(assessment.heldMs)}${what} — staleness is expected while it works. ` +
|
|
196
|
+
`Wait for it rather than starting a lane on a half-edited tree; \`node qa/plan.mjs --release\` if it is gone.`
|
|
197
|
+
);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Does a hold EXPLAIN this refusal? (It never lifts it — see the header.)
|
|
202
|
+
*
|
|
203
|
+
* The whitelist is the safety property, and it is deliberately two entries
|
|
204
|
+
* long. A hold explains why fresh evidence is ABSENT — no receipt yet, or the
|
|
205
|
+
* tree has moved under one — because those are the two states a working agent
|
|
206
|
+
* legitimately produces. It never explains a receipt that says something is
|
|
207
|
+
* wrong: for a FAIL, a forgery, a skipped device tier or an unreadable surface
|
|
208
|
+
* the hold is simply not the cause, and offering it as one would send the
|
|
209
|
+
* reader to wait for an agent when the actual problem is a red test. Inverting
|
|
210
|
+
* this to a blacklist would mean every refusal added later is treated as
|
|
211
|
+
* agent-explicable by default, which is how an alarm starts lying.
|
|
212
|
+
*
|
|
213
|
+
* @param {{valid: boolean, reason?: string}} result
|
|
214
|
+
* @param {object|null} receipt
|
|
215
|
+
* @returns {boolean}
|
|
216
|
+
*/
|
|
217
|
+
export function holdExplains(result, receipt) {
|
|
218
|
+
if (!result || result.valid) return false;
|
|
219
|
+
// No receipt at all: the agent has not finished enough to run the lane.
|
|
220
|
+
if (receipt === null || receipt === undefined) return true;
|
|
221
|
+
// The tree moved under a receipt that itself PASSED — the signature of an
|
|
222
|
+
// agent mid-edit. A receipt that was not a PASS is contradicting evidence and
|
|
223
|
+
// is never excused, whatever moved since.
|
|
224
|
+
return receipt.verdict === "PASS" && /^source changed since the receipt/.test(String(result.reason ?? ""));
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/** "40s" / "12 min" / "1h 5m" — freshness a human can weigh at a glance. */
|
|
228
|
+
export function formatAge(ms) {
|
|
229
|
+
if (!(ms >= 0)) return "an unknown time";
|
|
230
|
+
if (ms < 90_000) return `${Math.round(ms / 1000)}s`;
|
|
231
|
+
if (ms < 90 * 60_000) return `${Math.round(ms / 60_000)} min`;
|
|
232
|
+
const h = Math.floor(ms / 3_600_000);
|
|
233
|
+
return `${h}h ${Math.round((ms - h * 3_600_000) / 60_000)}m`;
|
|
234
|
+
}
|