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
@@ -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
+ }
@@ -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 (build files rewire compilation, DI rewires
18
- // object graphs, theme/tokens and shared components render into every
19
- // screen, qa/ is the harness judging itself, and anything outside
20
- // composeApp/src is by definition not a scoped source edit). Any one such
21
- // change disables filtering for the run.
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
- export const LANE_OUTPUT_PREFIXES = ["qa/evidence", "qa-artifacts", "qa/flight-recorder.jsonl"];
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 mandatory blast-radius escape hatch: paths whose change fans out too
54
- * widely to subset the suite safely. Returns the human-readable category when
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 broadImpactReason(p) {
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 = broadImpactReason(p);
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 file under composeApp/src. Only .kt
108
- // files map to test patterns; a change that maps to nothing (resources,
109
- // manifests) falls open to the full suite below.
110
- const ktPaths = paths.filter((p) => p.endsWith(".kt"));
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
- mode: "filtered",
123
- patterns: [...segments].sort().map((s) => `*${s}*`),
124
- sourcePaths: ktPaths,
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
+ }