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
@@ -8,19 +8,27 @@
8
8
  // Answered LOCALLY, offline, on every lane run. Needs nothing
9
9
  // but the tree and this file.
10
10
  //
11
- // AUTHENTICITY "is my lane the real published @create-cmp/harness@X?"
12
- // Answered REMOTELY, on request, by comparing this file's
13
- // `sha256` against the published version's `create-cmp
14
- // upgrade --harness` does it, and so can any third party
15
- // holding a receipt.
11
+ // AUTHENTICITY "is my lane the real published prooflane-harness@X?"
12
+ // NOT ANSWERED ANYWHERE IN THIS REPO see below. The per-file
13
+ // map this lock records is what such a check would compare
14
+ // against a published artifact; nothing performs the comparison.
16
15
  //
17
- // Being honest about that split matters. Someone who edits the lane AND
18
- // rewrites this lock defeats the local check of course they do; it is a
19
- // checksum, not a signature. What it cannot survive is the remote comparison,
20
- // because the attacker cannot change what the registry published under that
21
- // version number. Local integrity catches the accident and the drift (an
22
- // agent "fixing" a lane file, a half-applied upgrade); the remote comparison
23
- // catches the lie. Neither claim is stretched to cover the other's job.
16
+ // Being honest about that split matters, and this comment was not. It used to
17
+ // say `create-cmp upgrade --harness` performed the remote comparison. It does
18
+ // not and never has: that command reads the version out of a LOCAL
19
+ // packages/harness/package.json and re-locks from local bytes
20
+ // (src/commands/upgrade.mjs:410-413). The only registry call in the tree packs
21
+ // `create-cmp-cli@<v>` as an upgrade's merge base. So the sentence promising
22
+ // that "the attacker cannot change what the registry published" described a
23
+ // defence that does not exist — in the module whose whole job is to be precise
24
+ // about which question it answers, which is the failure this harness exists to
25
+ // refuse (ADR-0008, which found it).
26
+ //
27
+ // What is true: someone who edits the lane AND rewrites this lock defeats the
28
+ // local check — of course they do; it is a checksum, not a signature. Local
29
+ // integrity catches the accident and the drift (an agent "fixing" a lane file,
30
+ // a half-applied upgrade). It catches no lie, and until a remote comparison is
31
+ // built, nothing here does.
24
32
  //
25
33
  // The lock is deliberately NOT a .mjs file, so it is not part of the region it
26
34
  // describes — a manifest inside its own manifest could never settle.
@@ -30,10 +38,16 @@
30
38
 
31
39
  import fs from "node:fs";
32
40
  import path from "node:path";
33
- import { hashHarnessRegion, compareHarnessRegion } from "./harness-region.mjs";
41
+ import { hashHarnessRegion, compareHarnessRegion, isAdopterOwned } from "./harness-region.mjs";
34
42
 
35
43
  export const LOCK_PATH = "qa/harness.lock.json";
36
- export const LOCK_SCHEMA = "cmp-harness-lock/1";
44
+ // ADR-0007 deferred this one with a condition: the lock's schema string
45
+ // "finishes that journey with the package work, not in this PR". That work has
46
+ // landed — the lock has written `name: "prooflane-harness"` since the package
47
+ // rename, which is the split ADR-0007 itself pointed at as observable in the
48
+ // tree. The condition is met, so the journey finishes here. Nothing reads the
49
+ // field; `readHarnessLock` parses the file and never inspects it.
50
+ export const LOCK_SCHEMA = "prooflane-harness-lock/1";
37
51
 
38
52
  /**
39
53
  * Read the lock, or null when it is absent or unparsable. An unreadable lock
@@ -61,7 +75,7 @@ export function readHarnessLock(root) {
61
75
  * @param {{name?: string, version: string}} harness identity to record
62
76
  * @returns {{sha256: string, fileCount: number}}
63
77
  */
64
- export function writeHarnessLock(root, { name = "@create-cmp/harness", version }) {
78
+ export function writeHarnessLock(root, { name = "prooflane-harness", version }) {
65
79
  if (typeof version !== "string" || version.length === 0) {
66
80
  throw new Error("writeHarnessLock: a harness version is required");
67
81
  }
@@ -109,6 +123,8 @@ export function checkHarnessIntegrity(root) {
109
123
  missing: [],
110
124
  extra: [],
111
125
  fileCount: region.fileCount,
126
+ engineFiles: region.engineFiles,
127
+ vacuous: region.engineFiles === 0,
112
128
  };
113
129
  }
114
130
 
@@ -123,6 +139,12 @@ export function checkHarnessIntegrity(root) {
123
139
  missing: cmp.missing,
124
140
  extra: cmp.extra,
125
141
  fileCount: region.fileCount,
142
+ // ADR-0010: how much of this region is the LANE, and whether it is a lane
143
+ // at all. `status` is deliberately untouched — a region of three
144
+ // declarations genuinely IS unmodified since it was locked; it is simply
145
+ // not a lane, and that is a different question from the one status answers.
146
+ engineFiles: region.engineFiles,
147
+ vacuous: region.engineFiles === 0,
126
148
  };
127
149
  }
128
150
 
@@ -133,6 +155,19 @@ export function checkHarnessIntegrity(root) {
133
155
  * @returns {string}
134
156
  */
135
157
  export function describeIntegrity(r) {
158
+ // ADR-0010, AND THIS IS THE REACHABLE HALF OF IT. `status` is honest — a
159
+ // region of declarations really is unmodified since it was locked — so a
160
+ // caller rendering that status alone says "N files verified" over a lane that
161
+ // holds no lane. Every caller that checks a tree it does not LIVE in reaches
162
+ // this: `create-cmp upgrade --harness` and `harden` print it over an
163
+ // arbitrary project directory, `prooflane upgrade` over the adopter's root,
164
+ // and a hosted checker over a repo it fetched. The shipped lane cannot (it
165
+ // derives its root from its own location, so its own module is always in the
166
+ // region) — which is exactly why the fix belongs in the shared voice rather
167
+ // than in one step.
168
+ if (r.vacuous) {
169
+ return `${r.name ?? "harness"} ${r.version ?? "?"} — ${r.fileCount} file(s) locked and NONE of them engine code: this is not a lane, and nothing here can vouch for one`;
170
+ }
136
171
  if (r.status === "intact") {
137
172
  // The region digest rides beside the version: two lanes can carry the same
138
173
  // package version with different content (create-cmp-showcase, 2026-09-03 —
@@ -144,9 +179,42 @@ export function describeIntegrity(r) {
144
179
  if (r.status === "unlocked") {
145
180
  return `no ${LOCK_PATH} — this app's lane version is unrecorded`;
146
181
  }
182
+ // NAME THE FILES. This used to report counts only — "1 unrecorded" — while
183
+ // holding the paths in `r.extra` and never showing them. The first adopter to
184
+ // hit it was following our own README, whose step 1 writes a file that is in
185
+ // HARNESS_DECLARATIONS: a correct refusal they could not act on, because the
186
+ // one fact that makes it actionable was in the object and not in the sentence.
187
+ // Evidence-or-silence: a gate that refuses names what it refused over.
147
188
  const parts = [];
148
- if (r.modified.length) parts.push(`${r.modified.length} modified`);
149
- if (r.missing.length) parts.push(`${r.missing.length} missing`);
150
- if (r.extra.length) parts.push(`${r.extra.length} unrecorded`);
151
- return `${r.name ?? "harness"} ${r.version ?? "?"} ${parts.join(", ")}`;
189
+ const show = (list, label) => {
190
+ if (!list.length) return;
191
+ const head = list.slice(0, 3).join(", ");
192
+ parts.push(`${list.length} ${label}: ${head}${list.length > 3 ? `, +${list.length - 3} more` : ""}`);
193
+ };
194
+ show(r.modified, "modified");
195
+ show(r.missing, "missing");
196
+ show(r.extra, "unrecorded");
197
+ // WHICH command helps depends on WHOSE files differ, and the two answers are
198
+ // opposites. An adopter editing their own profile or declaration is doing the
199
+ // one thing the harness tells them to do (the profile header says "This file
200
+ // is YOURS"), and the cure is to re-take the lock. A machine-owned file
201
+ // differing is a fork, and re-taking the lock over THAT would make every
202
+ // later receipt vouch for code the harness has never seen. Naming one command
203
+ // for both is how the first adopter to edit their profile got a lane that
204
+ // could not be un-failed: `harness init` refused ("already exists") and
205
+ // `upgrade --harness` refused (no create-cmp.json).
206
+ //
207
+ // CONDITIONAL on purpose. `isAdopterOwned` is a name rule and cannot know
208
+ // whether a given profile is one the ENGINE vendors (`qa/lib/profiles/cmp/`
209
+ // in every stamped Compose app) — teaching this module a profile id is the
210
+ // coupling Stage 0 removed. So this offers the command rather than asserting
211
+ // the ownership; `create-cmp harness relock` is where the decision is made,
212
+ // and it refuses a shipped profile by name.
213
+ const differing = [...r.modified, ...r.missing, ...r.extra];
214
+ const fix = differing.every(isAdopterOwned)
215
+ ? " — if these are yours (your profile, your declarations), re-lock with `create-cmp harness relock`"
216
+ : r.extra.length && !r.modified.length && !r.missing.length
217
+ ? " — re-lock with `create-cmp upgrade --harness`, or remove the file if it should not be there"
218
+ : "";
219
+ return `${r.name ?? "harness"} ${r.version ?? "?"} — ${parts.join("; ")}${fix}`;
152
220
  }
@@ -0,0 +1,208 @@
1
+ // harness-manifest.mjs — WHICH STACK PROFILE this project uses, and where it
2
+ // keeps the things the lane reads. The lane's own reader of
3
+ // qa/harness-manifest.json.
4
+ //
5
+ // WHY THE LANE READS IT NOW. The manifest existed before this file — written
6
+ // for the console, read by seven console modules and zero lane modules — so
7
+ // the lane kept hardcoding one project's layout (composeApp/src, qa/e2e, the
8
+ // Compose step pack imported by name) while the console beside it had learned
9
+ // to ask. This closes that: the lane resolves the manifest first, loads the
10
+ // profile it names (qa/lib/profile-loader.mjs), and only then knows what a
11
+ // "step" or a "test root" is for this project.
12
+ //
13
+ // THERE IS NO DEFAULT PROFILE (decision 3, 2026-09-04). The Compose profile is
14
+ // a profile like any other; a privileged default would be the coupling this
15
+ // removes wearing a different name. So an ABSENT manifest is a refusal that
16
+ // names the command which writes one:
17
+ //
18
+ // a stamped app (create-cmp.json present) → create-cmp upgrade --harness
19
+ // derives it from what it knows
20
+ // a foreign repo, any stack → create-cmp harness init
21
+ // writes the manifest, a working
22
+ // profile, the surface and the
23
+ // lock, then runs Rule 0
24
+ //
25
+ // A PRESENT-BUT-MALFORMED manifest is refused too, naming every problem at
26
+ // once — never silently defaulted, for the reason the console's reader gives:
27
+ // a lane that falls back to the wrong layout reports an honest-looking absence
28
+ // of files that exist ten characters away.
29
+ //
30
+ // The shape is FLAT: the layout fields sit beside `schema` and `profile`, not
31
+ // under a nested `layout` key. The console's reader validates field-by-field
32
+ // and refuses unknown keys, and a stamped app must satisfy both readers with
33
+ // one file. (Stage 0.5 unifies the two readers; the flat shape is what both
34
+ // accept today.)
35
+ //
36
+ // SINGLE SOURCE OF TRUTH: packages/harness/src/lib/harness-manifest.mjs in the
37
+ // create-cmp repo. The copy in a generated project's qa/lib/ is vendored
38
+ // byte-identical at scaffold time — edit the package source, then run
39
+ // `node scripts/sync-harness.mjs`.
40
+
41
+ import fs from "node:fs";
42
+ import path from "node:path";
43
+
44
+ /** Where a project declares its profile and layout. Required for the lane to run. */
45
+ export const MANIFEST_REL_PATH = "qa/harness-manifest.json";
46
+
47
+ /** The manifest schema this lane writes and understands. */
48
+ export const MANIFEST_SCHEMA = "harness-manifest/2";
49
+
50
+ /** Layout fields that are single project-relative paths. */
51
+ export const LAYOUT_PATH_FIELDS = ["receipt", "architectureDoc", "specs", "approvals"];
52
+ /** Layout fields that are non-empty lists. `packs` is informational (console). */
53
+ export const LAYOUT_LIST_FIELDS = ["citationRoots", "packs"];
54
+ const META_FIELDS = ["schema", "profile"];
55
+ const KNOWN_FIELDS = new Set([...META_FIELDS, ...LAYOUT_PATH_FIELDS, ...LAYOUT_LIST_FIELDS]);
56
+
57
+ /** A profile id is one path segment, lowercase, dash-separated — it becomes a directory name. */
58
+ export const PROFILE_ID_RE = /^[a-z][a-z0-9-]*$/;
59
+
60
+ function pathProblem(field, value) {
61
+ if (typeof value !== "string" || value.trim() === "") return `${field} must be a non-empty string`;
62
+ if (value.startsWith("/") || /^[A-Za-z]:[\\/]/.test(value)) return `${field} must be relative to the project root (got "${value}")`;
63
+ if (value.includes("\\")) return `${field} must use "/" separators (got "${value}")`;
64
+ if (value.split("/").some((seg) => seg === "..")) return `${field} may not escape the project root (got "${value}")`;
65
+ return null;
66
+ }
67
+
68
+ /**
69
+ * Every contract violation in a parsed manifest, or [] when it is valid. Never
70
+ * throws — a caller prints all defects at once rather than the first.
71
+ * @param {unknown} parsed
72
+ * @returns {string[]}
73
+ */
74
+ export function manifestProblems(parsed) {
75
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return ["the manifest must be a JSON object"];
76
+ const problems = [];
77
+ for (const key of Object.keys(parsed)) {
78
+ if (!KNOWN_FIELDS.has(key)) problems.push(`unknown field "${key}" (known: ${[...KNOWN_FIELDS].join(", ")})`);
79
+ }
80
+ if ("schema" in parsed && (typeof parsed.schema !== "string" || !parsed.schema.startsWith("harness-manifest/"))) {
81
+ problems.push(`schema must be a string of the form "harness-manifest/<n>" (got ${JSON.stringify(parsed.schema)})`);
82
+ }
83
+ // The profile is the one REQUIRED field: without it the lane cannot know
84
+ // what a step is for this project, and guessing is the bug this file closes.
85
+ if (!("profile" in parsed)) {
86
+ problems.push(`profile is required — add "profile": { "id": "<profile>" } naming the stack profile under qa/lib/profiles/`);
87
+ } else {
88
+ const p = parsed.profile;
89
+ if (!p || typeof p !== "object" || Array.isArray(p)) problems.push("profile must be an object { id, version? }");
90
+ else {
91
+ if (typeof p.id !== "string" || !PROFILE_ID_RE.test(p.id)) {
92
+ problems.push(`profile.id must match ${PROFILE_ID_RE} — it names a directory under qa/lib/profiles/ (got ${JSON.stringify(p.id)})`);
93
+ }
94
+ if ("version" in p && typeof p.version !== "string") problems.push("profile.version must be a string when present");
95
+ for (const key of Object.keys(p)) {
96
+ if (key !== "id" && key !== "version") problems.push(`profile has an unknown field "${key}" (known: id, version)`);
97
+ }
98
+ }
99
+ }
100
+ for (const field of LAYOUT_PATH_FIELDS) {
101
+ if (!(field in parsed)) continue;
102
+ const p = pathProblem(field, parsed[field]);
103
+ if (p) problems.push(p);
104
+ }
105
+ for (const field of LAYOUT_LIST_FIELDS) {
106
+ if (!(field in parsed)) continue;
107
+ const list = parsed[field];
108
+ if (!Array.isArray(list) || list.length === 0) {
109
+ problems.push(`${field} must be a non-empty array of strings`);
110
+ continue;
111
+ }
112
+ list.forEach((entry, i) => {
113
+ const p =
114
+ field === "citationRoots"
115
+ ? pathProblem(`${field}[${i}]`, entry)
116
+ : typeof entry === "string" && entry.trim()
117
+ ? null
118
+ : `${field}[${i}] must be a non-empty string`;
119
+ if (p) problems.push(p);
120
+ });
121
+ }
122
+ return problems;
123
+ }
124
+
125
+ /**
126
+ * The refusal for a MISSING manifest — which names the command that writes
127
+ * one, because the two kinds of project get there differently.
128
+ * @param {string} root
129
+ * @returns {string}
130
+ */
131
+ export function absentManifestReason(root) {
132
+ const stamped = fs.existsSync(path.join(root, "create-cmp.json"));
133
+ const how = stamped
134
+ ? "This is a create-cmp app: run `create-cmp upgrade --harness` and it will write the manifest from what it already knows."
135
+ : "Run `create-cmp harness init` — it writes the manifest, a working profile for this project, the verified surface and the lock, then proves the lane returns.";
136
+ return (
137
+ `${MANIFEST_REL_PATH} is missing — the lane cannot run without knowing which stack profile this project uses, ` +
138
+ `and there is no default. ${how}`
139
+ );
140
+ }
141
+
142
+ /**
143
+ * Resolve the project's manifest.
144
+ *
145
+ * @param {string} root project root
146
+ * @returns {{ok: true, manifest: object, relPath: string}
147
+ * | {ok: false, absent: boolean, reason: string, relPath: string}}
148
+ * absent true when no file exists (the reason names the command that writes one)
149
+ * false when a file exists and is unusable (the reason names every problem)
150
+ */
151
+ export function resolveHarnessManifest(root) {
152
+ const file = path.join(root, ...MANIFEST_REL_PATH.split("/"));
153
+ if (!fs.existsSync(file)) {
154
+ return { ok: false, absent: true, relPath: MANIFEST_REL_PATH, reason: absentManifestReason(root) };
155
+ }
156
+ let parsed;
157
+ try {
158
+ parsed = JSON.parse(fs.readFileSync(file, "utf8"));
159
+ } catch (err) {
160
+ return {
161
+ ok: false,
162
+ absent: false,
163
+ relPath: MANIFEST_REL_PATH,
164
+ reason: `${MANIFEST_REL_PATH} is not valid JSON (${err && err.message ? err.message : String(err)}) — fix it; no layout is assumed while a manifest is present`,
165
+ };
166
+ }
167
+ const problems = manifestProblems(parsed);
168
+ if (problems.length) {
169
+ return {
170
+ ok: false,
171
+ absent: false,
172
+ relPath: MANIFEST_REL_PATH,
173
+ reason: `${MANIFEST_REL_PATH} is malformed: ${problems.join("; ")} — fix it; no layout is assumed while a manifest is present`,
174
+ };
175
+ }
176
+ return { ok: true, manifest: parsed, relPath: MANIFEST_REL_PATH };
177
+ }
178
+
179
+ /**
180
+ * A manifest for a profile, with optional layout overrides — what `attach`
181
+ * writes after its interview and what a stamper ships.
182
+ * @param {string} profileId
183
+ * @param {object} [layout] layout fields to carry (validated on write)
184
+ * @returns {object}
185
+ */
186
+ export function manifestFor(profileId, layout = {}) {
187
+ return { schema: MANIFEST_SCHEMA, profile: { id: profileId }, ...layout };
188
+ }
189
+
190
+ /**
191
+ * Write a manifest — after validating it. A writer that could put an invalid
192
+ * manifest on disk would hand the next lane run a refusal it created itself.
193
+ * @param {string} root
194
+ * @param {object} manifest
195
+ * @returns {{ok: true, relPath: string} | {ok: false, reason: string}}
196
+ */
197
+ export function writeHarnessManifest(root, manifest) {
198
+ const problems = manifestProblems(manifest);
199
+ if (problems.length) return { ok: false, reason: `refusing to write an invalid manifest: ${problems.join("; ")}` };
200
+ try {
201
+ const file = path.join(root, ...MANIFEST_REL_PATH.split("/"));
202
+ fs.mkdirSync(path.dirname(file), { recursive: true });
203
+ fs.writeFileSync(file, `${JSON.stringify(manifest, null, 2)}\n`);
204
+ return { ok: true, relPath: MANIFEST_REL_PATH };
205
+ } catch (err) {
206
+ return { ok: false, reason: err && err.message ? err.message : String(err) };
207
+ }
208
+ }
@@ -64,6 +64,18 @@ export const HARNESS_DIRS = ["qa", "qa/lib"];
64
64
  */
65
65
  export const HARNESS_TEST_DIR = "qa/test";
66
66
 
67
+ /**
68
+ * STACK PROFILES — qa/lib/profiles/<id>/**. Every `.mjs` under it, recursively,
69
+ * is machine-owned: a profile is the set of gates, tiers and steps the lane
70
+ * runs for this project, loaded by id from qa/harness-manifest.json
71
+ * (qa/lib/profile-loader.mjs). An edited profile that stayed outside the lock
72
+ * could redefine what "done" means and still have the lane vouch for the
73
+ * result — GATE-RULES Rule 2, the layer you changed cannot certify itself.
74
+ * Named and recursive on purpose, like HARNESS_TEST_DIR: a nested directory
75
+ * enters the region only by someone widening the rule here, never by shape.
76
+ */
77
+ export const HARNESS_PROFILES_DIR = "qa/lib/profiles";
78
+
67
79
  /**
68
80
  * DECLARATIONS the lane READS to decide what it attests — locked for the same
69
81
  * reason verify.mjs is. payment-blueprint's planted proof (2026-09-03): remove
@@ -77,6 +89,88 @@ export const HARNESS_TEST_DIR = "qa/test";
77
89
  */
78
90
  export const HARNESS_DECLARATIONS = ["qa/verified-surface.json", "qa/harness-manifest.json"];
79
91
 
92
+ /**
93
+ * MACHINE-OWNED files that are not .mjs and not declarations. Today: the
94
+ * provenance record (lib/harness-source.mjs) — which artifact was vendored and
95
+ * where it came from, written by init and rewritten by upgrade.
96
+ *
97
+ * It is in the region for one reason: inside it, the record is inside
98
+ * `lock.files` and inside `inputs.hash`, so a hand-edited origin FAILs
99
+ * harnessIntegrity by name. Provenance is offered as no kind of check
100
+ * (ADR-0008), and that is precisely why it must not also be silently editable —
101
+ * a field nothing verifies and anyone can rewrite is worse than an absent one.
102
+ *
103
+ * It is deliberately NOT in HARNESS_DECLARATIONS, though it sits beside them:
104
+ * that list is what `harness relock` may re-baseline, and an adopter re-locking
105
+ * their own edited provenance is the one outcome this must never allow.
106
+ *
107
+ * Absent is fine everywhere — `listHarnessFiles` lists what exists, so a lane
108
+ * installed before this file existed has an unchanged region and an unchanged
109
+ * lock.
110
+ */
111
+ export const HARNESS_GENERATED = ["qa/harness-source.json"];
112
+
113
+ /**
114
+ * Of the locked region, WHICH files does the adopter author?
115
+ *
116
+ * The region is one lock but two kinds of file, and conflating them is what
117
+ * bricked the first foreign adopter (2026-09-05). `harness init` generates
118
+ * qa/lib/profiles/<id>/index.mjs with a header that says "This file is YOURS",
119
+ * and the README tells adopters to correct qa/harness-manifest.json and
120
+ * qa/verified-surface.json — then the first such edit FAILs harnessIntegrity
121
+ * with no command that could re-take the lock. Their profile is inside the
122
+ * lock for a good reason (an edited gate pack must not certify itself
123
+ * silently), so the answer is not to unlock it; it is to name the subset a
124
+ * re-lock may cover:
125
+ *
126
+ * qa/lib/profiles/<id>/** the profile they wrote — <id> REQUIRED, so a
127
+ * loose qa/lib/profiles/*.mjs stays machine-owned
128
+ * HARNESS_DECLARATIONS the two files the lane READS from them
129
+ *
130
+ * Everything else — every .mjs directly under qa/ and qa/lib/, and qa/test/**
131
+ * — is engine code. An edit there is a fork, and `create-cmp harness relock`
132
+ * refuses it rather than re-baselining a lane the harness has never seen.
133
+ *
134
+ * This rule is deliberately NAME-based and stack-free: it says nothing about
135
+ * which profile ids the engine itself vendors (`create-cmp harness relock`
136
+ * layers that on, from the harness package it ships with — the core does not
137
+ * learn a profile id). So a vendored lane can use this to point a failing
138
+ * adopter at the right command; the command is where the gate actually is.
139
+ *
140
+ * @param {string} relPath project-relative path, "/"-separated
141
+ * @returns {boolean}
142
+ */
143
+ export function isAdopterOwned(relPath) {
144
+ if (!isHarnessFile(relPath)) return false;
145
+ if (HARNESS_DECLARATIONS.includes(relPath)) return true;
146
+ const prefix = `${HARNESS_PROFILES_DIR}/`;
147
+ return relPath.startsWith(prefix) && relPath.slice(prefix.length).includes("/");
148
+ }
149
+
150
+ /**
151
+ * Is this region member ENGINE CODE — the lane itself, as opposed to what the
152
+ * lane reads about itself or the profile the adopter wrote?
153
+ *
154
+ * The three exclusions are the region's non-engine halves: the declarations
155
+ * (`qa/verified-surface.json`, `qa/harness-manifest.json`), the generated
156
+ * provenance record, and `qa/lib/profiles/<id>/**` — an adopter's own pack is
157
+ * theirs, and a region holding a profile and no spine is still a lane with no
158
+ * engine in it.
159
+ *
160
+ * ADR-0010: a region with zero of these is VACUOUS. It reads `intact` because
161
+ * it genuinely is unmodified since it was locked — it is simply not a lane.
162
+ *
163
+ * @param {string} relPath project-relative path, "/"-separated
164
+ * @returns {boolean}
165
+ */
166
+ export function isEngineModule(relPath) {
167
+ if (!isHarnessFile(relPath)) return false;
168
+ if (HARNESS_DECLARATIONS.includes(relPath)) return false;
169
+ if (HARNESS_GENERATED.includes(relPath)) return false;
170
+ if (relPath.startsWith(`${HARNESS_PROFILES_DIR}/`)) return false;
171
+ return relPath.endsWith(".mjs");
172
+ }
173
+
80
174
  /**
81
175
  * Is this project-relative path part of the machine-owned harness region?
82
176
  * @param {string} relPath project-relative path, "/"-separated
@@ -85,8 +179,10 @@ export const HARNESS_DECLARATIONS = ["qa/verified-surface.json", "qa/harness-man
85
179
  export function isHarnessFile(relPath) {
86
180
  if (typeof relPath !== "string") return false;
87
181
  if (HARNESS_DECLARATIONS.includes(relPath)) return true;
182
+ if (HARNESS_GENERATED.includes(relPath)) return true;
88
183
  if (!relPath.endsWith(".mjs")) return false;
89
184
  if (relPath.startsWith(`${HARNESS_TEST_DIR}/`)) return true;
185
+ if (relPath.startsWith(`${HARNESS_PROFILES_DIR}/`)) return true;
90
186
  const dir = relPath.includes("/") ? relPath.slice(0, relPath.lastIndexOf("/")) : "";
91
187
  return HARNESS_DIRS.includes(dir);
92
188
  }
@@ -127,10 +223,11 @@ export function listHarnessFiles(root) {
127
223
  }
128
224
  }
129
225
  walkMjs(path.join(root, HARNESS_TEST_DIR), HARNESS_TEST_DIR, found);
226
+ walkMjs(path.join(root, HARNESS_PROFILES_DIR), HARNESS_PROFILES_DIR, found);
130
227
  // A declaration directly under qa/ is already seen by the scan above (it is
131
228
  // a harness file by name); the explicit loop covers one that lives deeper.
132
229
  // Deduplicated so no path is hashed twice.
133
- for (const rel of HARNESS_DECLARATIONS) {
230
+ for (const rel of [...HARNESS_DECLARATIONS, ...HARNESS_GENERATED]) {
134
231
  if (fs.existsSync(path.join(root, ...rel.split("/")))) found.push(rel);
135
232
  }
136
233
  return [...new Set(found)].sort();
@@ -161,7 +258,14 @@ export function hashHarnessRegion(root) {
161
258
  files[rel] = h;
162
259
  digest.update(rel, "utf8").update("\0").update(h, "utf8").update("\n");
163
260
  }
164
- return { sha256: digest.digest("hex"), fileCount: rels.length, files };
261
+ return {
262
+ sha256: digest.digest("hex"),
263
+ fileCount: rels.length,
264
+ // ADR-0010: how many of those files are the LANE. `fileCount` counts the
265
+ // region; this counts the engine in it, and zero is the vacuous self-vouch.
266
+ engineFiles: rels.filter(isEngineModule).length,
267
+ files,
268
+ };
165
269
  }
166
270
 
167
271
  /**
@@ -0,0 +1,142 @@
1
+ // WHERE THIS LANE'S BYTES CAME FROM — ADR-0008's provenance, as a file.
2
+ //
3
+ // THE DECISION THIS IMPLEMENTS. ADR-0008 (accepted 2026-09-08) settled that the
4
+ // harness is ALWAYS vendored: pinning is how the bytes arrive, never how they
5
+ // are trusted, so there is one kind of receipt. What resolution adds is
6
+ // PROVENANCE — `source ∈ {local, registry, git}` — "recorded because the
7
+ // resolver knows it, and never counted as a check by anything."
8
+ //
9
+ // WHAT IT BUYS, precisely, and it is one thing. A checker with a network needs
10
+ // to know WHICH artifact to fetch before comparing it to `lock.files`, and
11
+ // whether fetching one is meaningful at all. `name` + `version` + `source` are
12
+ // those fetch coordinates. Without them a reader cannot tell a receipt naming a
13
+ // version that was never published from one naming a version they can pull —
14
+ // and the published core HAS sat versions behind this tree, so both exist.
15
+ //
16
+ // IT IS NOT A CHECK, AND MUST NEVER READ AS ONE. It is exactly as forgeable as
17
+ // the `version` beside it. Nothing gates on it, no verdict consults it, and a
18
+ // notary that compares the lock against a registry reports that as ITS finding,
19
+ // dated and named — never as a property of the receipt (ADR-0008; NORTH-STAR
20
+ // §8.2's "notarisation is not examination").
21
+ //
22
+ // WHY IT IS A FILE IN THE LOCKED REGION rather than a field in the lock. Two
23
+ // reasons, and the second is the load-bearing one:
24
+ //
25
+ // 1. The lock cannot hash itself, so a `source` written there sits outside
26
+ // every digest — the one place an editor's change leaves no trace.
27
+ // 2. Inside the region it is inside `lock.files` AND inside `inputs.hash`,
28
+ // so editing it FAILs harnessIntegrity by name. Provenance is offered as
29
+ // no kind of check; that is no reason to make it silently editable.
30
+ //
31
+ // It is machine-owned, NOT adopter-owned: `harness relock` re-baselines the
32
+ // files an adopter authors, and provenance is not one of them. An adopter who
33
+ // hand-edits this file gets a refusal naming it, which is the correct outcome —
34
+ // the alternative is a lane that will re-lock a forged origin without comment.
35
+ //
36
+ // ABSENT MEANS UNRECORDED, never `local`. Every receipt minted before this
37
+ // existed has no provenance, and inventing one for them would be the precise
38
+ // falsehood the field was added to prevent.
39
+ import fs from "node:fs";
40
+ import path from "node:path";
41
+
42
+ /**
43
+ * The package whose bytes a lane is made of. Named here because four writers
44
+ * record it — `prooflane init`, `prooflane upgrade`, the stamp and
45
+ * `upgrade --harness` — and a provenance record naming the wrong package is
46
+ * exactly the falsehood this file exists to prevent.
47
+ */
48
+ export const HARNESS_PKG_NAME = "prooflane-harness";
49
+
50
+ /** Project-relative path of the provenance record. */
51
+ export const SOURCE_PATH = "qa/harness-source.json";
52
+
53
+ export const SOURCE_SCHEMA = "prooflane-harness-source/1";
54
+
55
+ /**
56
+ * How the bytes arrived. The enum is ADR-0008's and gains no fourth value here:
57
+ * an unknown origin is an ABSENT record, not a new word.
58
+ *
59
+ * registry a package manager placed them (resolved from node_modules)
60
+ * local copied from a path on this machine — a checkout, a workspace
61
+ * git cloned from a repository
62
+ */
63
+ export const SOURCE_KINDS = Object.freeze(["local", "registry", "git"]);
64
+
65
+ /**
66
+ * Read the record, or null when there is none or it is unusable.
67
+ *
68
+ * Unparseable reads as ABSENT rather than throwing: a malformed provenance file
69
+ * must not stop a lane from running, because provenance is not a check. The
70
+ * integrity gate is what notices the file changed; this reader's job is only to
71
+ * answer "what does it say", and "nothing usable" is a valid answer.
72
+ *
73
+ * @param {string} root project root
74
+ * @returns {{name: string, version: string, source: string|null}|null}
75
+ */
76
+ export function readHarnessSource(root) {
77
+ let parsed;
78
+ try {
79
+ parsed = JSON.parse(fs.readFileSync(path.join(root, ...SOURCE_PATH.split("/")), "utf8"));
80
+ } catch {
81
+ return null;
82
+ }
83
+ if (!parsed || typeof parsed !== "object") return null;
84
+ const name = typeof parsed.name === "string" && parsed.name ? parsed.name : null;
85
+ const version = typeof parsed.version === "string" && parsed.version ? parsed.version : null;
86
+ if (!name || !version) return null;
87
+ const source = SOURCE_KINDS.includes(parsed.source) ? parsed.source : null;
88
+ return { name, version, source };
89
+ }
90
+
91
+ /**
92
+ * Write the record. Deterministic by construction — no timestamp, no host, no
93
+ * user. A field that changes on every run would make the region's digest move
94
+ * for no reason and turn `harnessIntegrity` into noise; and the question this
95
+ * answers ("which artifact, from where") has no time in it.
96
+ *
97
+ * @param {string} root project root
98
+ * @param {{name: string, version: string, source?: string|null}} rec
99
+ * @returns {{name: string, version: string, source: string|null}}
100
+ */
101
+ export function writeHarnessSource(root, { name, version, source = null }) {
102
+ if (typeof name !== "string" || !name) throw new Error("writeHarnessSource: a package name is required");
103
+ if (typeof version !== "string" || !version) throw new Error("writeHarnessSource: a version is required");
104
+ const kind = SOURCE_KINDS.includes(source) ? source : null;
105
+ const body = { schema: SOURCE_SCHEMA, name, version, source: kind };
106
+ const abs = path.join(root, ...SOURCE_PATH.split("/"));
107
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
108
+ fs.writeFileSync(abs, `${JSON.stringify(body, null, 2)}\n`);
109
+ return { name, version, source: kind };
110
+ }
111
+
112
+ /**
113
+ * What a package manager's own record says about where a dependency came from,
114
+ * for the one project that has the answer: the adopter's lockfile.
115
+ *
116
+ * DERIVED, NEVER ASSUMED. The tempting shortcut is "it is in node_modules, so
117
+ * it came from the registry" — and that is false for the common local-tarball
118
+ * install, which is exactly how this repo's own Stage 1 gate installs the
119
+ * harness. A wrong provenance is worse than none: it tells a checker to fetch
120
+ * an artifact that was never published and to conclude something from failing.
121
+ * So the answer comes from `resolved` in the lockfile, and anything it does not
122
+ * cover reads as null — unrecorded.
123
+ *
124
+ * @param {string} root project root
125
+ * @param {string} pkgName
126
+ * @returns {string|null} one of SOURCE_KINDS, or null when unknowable
127
+ */
128
+ export function resolvedSourceKind(root, pkgName) {
129
+ let lock;
130
+ try {
131
+ lock = JSON.parse(fs.readFileSync(path.join(root, "package-lock.json"), "utf8"));
132
+ } catch {
133
+ return null;
134
+ }
135
+ const entry = lock?.packages?.[`node_modules/${pkgName}`] ?? lock?.dependencies?.[pkgName];
136
+ const resolved = typeof entry?.resolved === "string" ? entry.resolved : null;
137
+ if (!resolved) return null;
138
+ if (/^https?:\/\//.test(resolved)) return "registry";
139
+ if (/^file:/.test(resolved)) return "local";
140
+ if (/^git(\+|:)/.test(resolved)) return "git";
141
+ return null;
142
+ }