create-cmp-cli 0.14.0 → 0.15.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 (39) hide show
  1. package/README.md +51 -353
  2. package/bin/create-cmp.mjs +19 -3
  3. package/llms.txt +3 -3
  4. package/options.schema.json +4 -0
  5. package/package.json +2 -2
  6. package/packages/harness/package.json +11 -3
  7. package/packages/harness/src/lib/harness-lock.mjs +2 -2
  8. package/packages/harness/src/lib/inputs-hash.mjs +1 -1
  9. package/packages/harness/src/lib/receipt-validate.mjs +1 -1
  10. package/packages/harness/src/receipt-check.mjs +1 -1
  11. package/packages/harness/src/verify.mjs +15 -1
  12. package/packages/receipts/package.json +11 -3
  13. package/packages/receipts/src/index.mjs +1 -1
  14. package/packages/receipts/src/inputs-hash.mjs +1 -1
  15. package/packages/receipts/src/receipt-validate.mjs +1 -1
  16. package/src/commands/attach.mjs +250 -0
  17. package/src/commands/create.mjs +13 -2
  18. package/src/commands/harden.mjs +263 -0
  19. package/src/commands/upgrade.mjs +20 -2
  20. package/src/lib/adr-seed.mjs +27 -0
  21. package/src/lib/harness-upgrade.mjs +40 -5
  22. package/src/lib/hooks.mjs +140 -0
  23. package/src/lib/minimal.mjs +130 -0
  24. package/src/lib/toggle.mjs +4 -1
  25. package/src/lib/verify.mjs +6 -1
  26. package/src/scaffold.mjs +18 -1
  27. package/template/.github/workflows/verify.yml +15 -0
  28. package/template/AGENTS.md +57 -8
  29. package/template/CLAUDE.md +49 -0
  30. package/template/CONTRIBUTING.md +13 -0
  31. package/template/README.md +33 -0
  32. package/template/docs/ARCHITECTURE.md +18 -1
  33. package/template/docs/TESTING.md +10 -0
  34. package/template/manifest.json +13 -0
  35. package/template/qa/lib/harness-lock.mjs +2 -2
  36. package/template/qa/lib/inputs-hash.mjs +1 -1
  37. package/template/qa/lib/receipt-validate.mjs +1 -1
  38. package/template/qa/receipt-check.mjs +1 -1
  39. package/template/qa/verify.mjs +15 -1
@@ -0,0 +1,263 @@
1
+ // `create-cmp harden` — install the full verification harness into a minimal
2
+ // scaffold. The Act 2 → Act 3 climb (LADDER §R3), in-band, one command.
3
+ //
4
+ // This is deliberately NOT new machinery. `--minimal` subtracts the harness;
5
+ // harden re-derives the subtraction and installs it back through the SAME
6
+ // three-way walk `upgrade --harness` already trusts:
7
+ //
8
+ // base = this app's config stamped MINIMAL (what the app was given)
9
+ // new = this app's config stamped FULL (what full mode gives it)
10
+ // theirs = the app's working tree today
11
+ //
12
+ // Every base→new difference is, by construction, exactly the harness: the
13
+ // lane region (restored wholesale — decideRegionFile), the governance
14
+ // surfaces (specs/, skills, hooks — "added"), and the mode-variant documents
15
+ // (CLAUDE.md, AGENTS.md, README, CI — "applied" when untouched, three-way
16
+ // merged when the app edited them, `.cmp-new` sidecars when both moved the
17
+ // same lines). Nothing is ever clobbered, and a second run finds everything
18
+ // current — idempotent by the walk's own semantics, not by bookkeeping.
19
+ //
20
+ // One seam the walk deliberately refuses: EXCLUDED_PATTERNS keeps app state
21
+ // (qa/approvals.json, qa/evidence/, qa/golden/) out of upgrades, because
22
+ // overwriting a ledger is never an upgrade. But a MINIMAL app has no ledgers
23
+ // to protect — harden must seed them. So after the walk, anything excluded
24
+ // that exists in the full stamp and is MISSING in the app is copied in:
25
+ // seed-if-absent, never overwrite.
26
+ //
27
+ // Both stamps use the CURRENT engine's template. If the app was stamped by an
28
+ // older engine, app-shaped drift from engine evolution surfaces as merges or
29
+ // sidecars — visible, never silent — and the installed lane is the current
30
+ // one (which is what `upgrade --harness` would land anyway).
31
+
32
+ import fs from "node:fs";
33
+ import os from "node:os";
34
+ import path from "node:path";
35
+ import { spawnSync } from "node:child_process";
36
+ import { fileURLToPath } from "node:url";
37
+
38
+ import { colors, ok, warn, fail, step } from "../lib/log.mjs";
39
+ import { consent } from "../bootstrap/exec.mjs";
40
+ import { buildTokenMap } from "../lib/tokens.mjs";
41
+ import {
42
+ planHarnessUpgrade,
43
+ applyHarnessPlan,
44
+ configFromSpecRecord,
45
+ stampBaseWith,
46
+ isExcludedPath,
47
+ SIDECAR_SUFFIX,
48
+ } from "../lib/harness-upgrade.mjs";
49
+ import { listFiles } from "../lib/fsutil.mjs";
50
+ import {
51
+ writeHarnessLock,
52
+ checkHarnessIntegrity,
53
+ describeIntegrity,
54
+ } from "../../packages/harness/src/lib/harness-lock.mjs";
55
+
56
+ const REPO_ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..");
57
+
58
+ /** The harness version this engine ships (the lane's own package, not the engine's). */
59
+ function shippedHarnessVersion() {
60
+ try {
61
+ return JSON.parse(
62
+ fs.readFileSync(path.join(REPO_ROOT, "packages/harness/package.json"), "utf8")
63
+ ).version;
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function currentEngineVersion() {
70
+ try {
71
+ return JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "package.json"), "utf8")).version;
72
+ } catch {
73
+ return "unknown";
74
+ }
75
+ }
76
+
77
+ /**
78
+ * The testable core: plan (and optionally apply) the harness install.
79
+ * Throws on a hopeless setup; never calls process.exit and never prompts.
80
+ *
81
+ * @param {object} params
82
+ * @param {string} params.projectDir absolute path of the app
83
+ * @param {string} [params.templateDir] template override (tests)
84
+ * @param {boolean} [params.apply=false] write changes (false = plan only)
85
+ * @param {(msg:string)=>void} [params.log]
86
+ * @returns {Promise<{alreadyFull:boolean, plan?:object, result?:object,
87
+ * seeded?:string[], record?:object}>}
88
+ */
89
+ export async function hardenProject({ projectDir, templateDir, apply = false, log = () => {} }) {
90
+ const specPath = path.join(projectDir, "create-cmp.json");
91
+ if (!fs.existsSync(specPath)) {
92
+ throw new Error(
93
+ `no create-cmp.json under ${projectDir}.\n` +
94
+ `\`create-cmp harden\` installs the verification harness into a create-cmp-stamped ` +
95
+ `project and needs the spec-of-record the stamp wrote. Run it from the project root ` +
96
+ `or pass --target-dir. (For an app that was never stamped by create-cmp, harden ` +
97
+ `cannot help yet — that is attach mode's territory.)`
98
+ );
99
+ }
100
+ const record = JSON.parse(fs.readFileSync(specPath, "utf8"));
101
+
102
+ // Full already: record says harness and the lane's front door is present.
103
+ // (A full record with a missing lane is a broken tree harden can heal, so
104
+ // only the conjunction short-circuits.)
105
+ if (record.harness !== false && fs.existsSync(path.join(projectDir, "qa", "verify.mjs"))) {
106
+ return { alreadyFull: true, record };
107
+ }
108
+
109
+ const { scaffold } = await import("../scaffold.mjs");
110
+ const tmpRoot = fs.mkdtempSync(path.join(os.tmpdir(), "create-cmp-harden-"));
111
+ try {
112
+ const baseDir = path.join(tmpRoot, "base");
113
+ const newDir = path.join(tmpRoot, "new");
114
+ const baseConfig = { ...configFromSpecRecord(record, baseDir), harness: false };
115
+ const newConfig = { ...configFromSpecRecord(record, newDir), harness: true };
116
+
117
+ log("Stamping this app's config MINIMAL (the walk's base)…");
118
+ await scaffold(baseConfig, { verify: false, ...(templateDir ? { templateDir } : {}) });
119
+ log("Stamping this app's config FULL (the walk's target)…");
120
+ await scaffold(newConfig, { verify: false, ...(templateDir ? { templateDir } : {}) });
121
+
122
+ const plan = planHarnessUpgrade({
123
+ baseDir,
124
+ newDir,
125
+ projectDir,
126
+ stampBase: stampBaseWith(buildTokenMap(configFromSpecRecord(record, projectDir))),
127
+ });
128
+
129
+ // App-state seeds the walk excludes by design: present in the full stamp,
130
+ // absent in the app → copy. Never touches an existing file.
131
+ const seedPlan = [];
132
+ for (const abs of listFiles(newDir)) {
133
+ const rel = path.relative(newDir, abs).split(path.sep).join("/");
134
+ if (!isExcludedPath(rel)) continue;
135
+ if (rel === "create-cmp.json" || rel === "local.properties") continue; // ours below / host-specific
136
+ if (!fs.existsSync(path.join(projectDir, rel))) seedPlan.push(rel);
137
+ }
138
+
139
+ if (!apply) return { alreadyFull: false, plan, seedPlan, record };
140
+
141
+ const actionable = plan.entries.filter(
142
+ (e) => e.write !== null || e.sidecar !== null || e.remove
143
+ );
144
+ const result = applyHarnessPlan(projectDir, actionable);
145
+
146
+ const seeded = [];
147
+ for (const rel of seedPlan) {
148
+ const target = path.join(projectDir, rel);
149
+ fs.mkdirSync(path.dirname(target), { recursive: true });
150
+ fs.copyFileSync(path.join(newDir, rel), target);
151
+ seeded.push(rel);
152
+ }
153
+
154
+ const harnessVersion = shippedHarnessVersion();
155
+ if (harnessVersion) writeHarnessLock(projectDir, { version: harnessVersion });
156
+
157
+ const updated = { ...record, harness: true, engineVersion: currentEngineVersion() };
158
+ fs.writeFileSync(specPath, JSON.stringify(updated, null, 2) + "\n");
159
+
160
+ return { alreadyFull: false, plan, seedPlan, result, seeded, record: updated };
161
+ } finally {
162
+ fs.rmSync(tmpRoot, { recursive: true, force: true });
163
+ }
164
+ }
165
+
166
+ /**
167
+ * `create-cmp harden [target-dir] [--dry-run] [--yes] [--verify]`
168
+ * @param {Record<string,string|boolean>} flags
169
+ * @param {string|undefined} positional optional target dir
170
+ */
171
+ export async function runHarden(flags, positional) {
172
+ const targetDir =
173
+ (typeof flags["target-dir"] === "string" && flags["target-dir"]) || positional || ".";
174
+ const projectDir = path.resolve(targetDir);
175
+
176
+ process.stdout.write(
177
+ `\n${colors.bold("create-cmp harden")} — install the full verification harness\n` +
178
+ ` project: ${colors.cyan(projectDir)}\n\n`
179
+ );
180
+
181
+ let outcome;
182
+ try {
183
+ // Plan first (side-effect free) so the consent question shows real content.
184
+ outcome = await hardenProject({ projectDir, log: (m) => step(m) });
185
+ } catch (e) {
186
+ fail(e.message);
187
+ process.exit(1);
188
+ }
189
+
190
+ if (outcome.alreadyFull) {
191
+ ok("This app already carries the full harness — nothing to install.");
192
+ process.stdout.write(
193
+ colors.dim(
194
+ ` ${describeIntegrity(checkHarnessIntegrity(projectDir))}\n` +
195
+ ` To refresh the harness to the current engine: npx create-cmp-cli upgrade --harness\n`
196
+ )
197
+ );
198
+ process.exit(0);
199
+ }
200
+
201
+ const { plan, seedPlan } = outcome;
202
+ const counts = plan.counts;
203
+ const installing = plan.entries.filter((e) => e.write !== null || e.remove).length;
204
+ const conflicts = plan.entries.filter((e) => e.sidecar !== null).map((e) => e.relPath);
205
+ process.stdout.write(
206
+ `${colors.bold(String(installing))} file(s) to install/refresh · ` +
207
+ `${colors.bold(String(seedPlan.length))} app-state seed(s) · ` +
208
+ `${colors.dim(`already current ${counts.current + counts.unchanged}`)}\n`
209
+ );
210
+ for (const f of conflicts) {
211
+ warn(`edited since stamp — full-mode content will land beside as ${f}${SIDECAR_SUFFIX}`);
212
+ }
213
+
214
+ if (installing === 0 && seedPlan.length === 0 && conflicts.length === 0) {
215
+ ok("Nothing to do — the tree already matches full mode.");
216
+ process.exit(0);
217
+ }
218
+ if (flags["dry-run"] === true) {
219
+ for (const e of plan.entries) {
220
+ if (e.write !== null || e.remove || e.sidecar !== null) process.stdout.write(` ${e.relPath}\n`);
221
+ }
222
+ for (const rel of seedPlan) process.stdout.write(` ${rel} ${colors.dim("(seed)")}\n`);
223
+ process.stdout.write(`\n${colors.yellow("Dry run")} — nothing written.\n`);
224
+ process.exit(0);
225
+ }
226
+
227
+ const approved = await consent(
228
+ `\nInstall the harness (existing files are backed up; edited files get *${SIDECAR_SUFFIX} sidecars, never clobbered)?`,
229
+ { assumeYes: flags.yes === true }
230
+ );
231
+ if (!approved) {
232
+ process.stdout.write(`${colors.yellow("Not applied")} — re-run with --yes to skip the prompt.\n`);
233
+ process.exit(0);
234
+ }
235
+
236
+ let applied;
237
+ try {
238
+ applied = await hardenProject({ projectDir, apply: true, log: (m) => step(m) });
239
+ } catch (e) {
240
+ fail(e.message);
241
+ process.exit(1);
242
+ }
243
+
244
+ const r = applied.result;
245
+ for (const f of r.created) ok(`installed ${f}`);
246
+ for (const f of r.written) ok(`refreshed ${f}`);
247
+ for (const f of applied.seeded) ok(`seeded ${f}`);
248
+ for (const f of r.sidecars) warn(`conflict sidecar ${f} — resolve by hand, then delete it`);
249
+ ok(`create-cmp.json → harness: true · ${describeIntegrity(checkHarnessIntegrity(projectDir))}`);
250
+
251
+ process.stdout.write(
252
+ `\nProve it: ${colors.bold("node qa/verify.mjs --profile scaffold")}` +
253
+ colors.dim(" (runs now with --verify)\n")
254
+ );
255
+ if (flags.verify === true) {
256
+ const v = spawnSync("node", ["qa/verify.mjs", "--profile", "scaffold"], {
257
+ cwd: projectDir,
258
+ stdio: "inherit",
259
+ });
260
+ process.exit(v.status === 0 && r.sidecars.length === 0 ? 0 : 1);
261
+ }
262
+ process.exit(r.sidecars.length > 0 ? 1 : 0);
263
+ }
@@ -43,7 +43,8 @@ import { consent } from "../bootstrap/exec.mjs";
43
43
  import { loadRegistry, latestSet, getSet } from "../lib/registry.mjs";
44
44
  import { planUpgrade, BACKUP_SUFFIX } from "../lib/upgrade.mjs";
45
45
  import { writeHarnessLock, checkHarnessIntegrity, describeIntegrity } from "../../packages/harness/src/lib/harness-lock.mjs";
46
- import { LOCAL_PATCH_PATH } from "../lib/harness-upgrade.mjs";
46
+ import { LOCAL_PATCH_PATH, stampBaseWith } from "../lib/harness-upgrade.mjs";
47
+ import { buildTokenMap } from "../lib/tokens.mjs";
47
48
  import {
48
49
  planHarnessUpgrade,
49
50
  applyHarnessPlan,
@@ -341,11 +342,28 @@ async function harnessPlanAndApply({ flags, record, projectDir, targetDir, tmpRo
341
342
  templateDir: baseTemplateDir,
342
343
  verify: false,
343
344
  });
344
- const plan = planHarnessUpgrade({ baseDir, newDir, projectDir });
345
+ // Engines before 0.14.0 ran lane code through the token stamper, so an app
346
+ // stamped by one carries `Fuelled` where the base template says
347
+ // `__APP_NAME__`. Without this the migration would report the engine's own
348
+ // substitution as if the app had forked the lane.
349
+ const plan = planHarnessUpgrade({
350
+ baseDir,
351
+ newDir,
352
+ projectDir,
353
+ stampBase: stampBaseWith(buildTokenMap(configFromSpecRecord(record, projectDir))),
354
+ });
345
355
 
346
356
  const anythingToDo = printHarnessReport(plan);
347
357
  if (!anythingToDo) {
348
358
  ok("Engine-owned files are fully up to date — nothing to apply.");
359
+ // Still advance the record. An app that is ALREADY at the new engine is as
360
+ // upgraded as it can be, and leaving engineVersion stale here would keep
361
+ // the very defect this write-back exists to fix: the next run would fetch
362
+ // an obsolete merge base and re-litigate changes that already landed. Not
363
+ // on a dry run — that promises to write nothing.
364
+ if (flags["dry-run"] !== true && writeBackEngineVersion(projectDir, currentVersion)) {
365
+ ok(`create-cmp.json engineVersion → ${colors.bold(currentVersion)}`);
366
+ }
349
367
  return 0;
350
368
  }
351
369
 
@@ -57,6 +57,33 @@ function renderAdr(number, title, body, dateIso) {
57
57
  // true, room true, firebase.auth "both") — matching every default seeds
58
58
  // nothing beyond the shipped four; only a genuine choice gets a record.
59
59
  const DECISION_RULES = [
60
+ {
61
+ id: "mode",
62
+ applies: (config) => config.harness === false,
63
+ title: () => "Minimal scaffold — verification harness deferred",
64
+ render: () => ({
65
+ context:
66
+ "create-cmp stamps the full verification harness by default: the verify lane " +
67
+ "(`qa/verify.mjs`) with evidence receipts, behavior specs (`specs/`), approval " +
68
+ "gates, feature generators, and a Stop hook making the definition of done " +
69
+ "machine-checked. This app was scaffolded `--minimal` — a deliberate choice to " +
70
+ "start with the smallest thing that builds green, deferring the harness rather " +
71
+ "than rejecting it.",
72
+ decision:
73
+ "We will start without the verification harness. The app keeps its full " +
74
+ "architecture, unit/conformance/golden tests (`./gradlew :composeApp:desktopTest`), " +
75
+ "headless previews, the live inspector, and advisory session hooks; it carries no " +
76
+ "verify lane, receipts, specs, approvals, generators, or enforcement hooks.",
77
+ consequences:
78
+ "- The definition of done is honor-system: green `desktopTest` plus review, with " +
79
+ "no receipt attesting what actually ran.\n" +
80
+ "- No spec-first behavior flow and no generators — new features are written by " +
81
+ "hand against `docs/ARCHITECTURE.md`.\n" +
82
+ "- Reversing this is one idempotent command, not a re-scope: " +
83
+ "`npx create-cmp-cli harden` installs the full harness and this ADR is " +
84
+ "superseded by that act.",
85
+ }),
86
+ },
60
87
  {
61
88
  id: "persistence",
62
89
  applies: (config) => config.room === false,
@@ -40,7 +40,7 @@ import path from "node:path";
40
40
  import { spawnSync } from "node:child_process";
41
41
 
42
42
  import { listFiles } from "./fsutil.mjs";
43
- import { isBinaryPath } from "./tokens.mjs";
43
+ import { isBinaryPath, replaceTokens } from "./tokens.mjs";
44
44
  import { BACKUP_SUFFIX } from "./upgrade.mjs";
45
45
  import { isHarnessFile } from "../../packages/harness/src/lib/harness-region.mjs";
46
46
 
@@ -178,6 +178,23 @@ export function mergeThreeWay(theirs, base, next) {
178
178
  * null when the path is in neither base nor new (app-authored — invisible)
179
179
  */
180
180
 
181
+
182
+ /**
183
+ * A `stampBase` function for an app's recorded config — reproduces what a
184
+ * pre-0.14.0 engine would have written into that app's lane, so a file the
185
+ * stamper touched is not mistaken for a local edit. Binary-safe: content that
186
+ * does not round-trip through UTF-8 is returned unchanged.
187
+ * @param {Array<[string,string]>} tokenMap from buildTokenMap(config)
188
+ * @returns {(base: Buffer) => Buffer}
189
+ */
190
+ export function stampBaseWith(tokenMap) {
191
+ return (base) => {
192
+ const text = base.toString("utf8");
193
+ if (!Buffer.from(text, "utf8").equals(base)) return base;
194
+ return Buffer.from(replaceTokens(text, tokenMap), "utf8");
195
+ };
196
+ }
197
+
181
198
  /** Where a preserved local patch to machine-owned lane code is written. */
182
199
  export const LOCAL_PATCH_PATH = "qa/harness-local.patch";
183
200
 
@@ -253,7 +270,7 @@ export function diffPatch(base, theirs, relPath) {
253
270
  * @param {Function} [params.merge] injectable three-way merge (classifier only)
254
271
  * @returns {{bucket:string, write:Buffer|null, sidecar:null, remove:boolean, patch?:string}|null}
255
272
  */
256
- export function decideRegionFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
273
+ export function decideRegionFile({ relPath, base, next, theirs, merge = mergeThreeWay, stampBase = null }) {
257
274
  const eq = (a, b) => a !== null && b !== null && a.equals(b);
258
275
  const replace = (bucket, extra = {}) => ({
259
276
  bucket,
@@ -274,6 +291,19 @@ export function decideRegionFile({ relPath, base, next, theirs, merge = mergeThr
274
291
  if (base === null) return replace("region-patched", { patch: "" }); // no base to diff against
275
292
  if (eq(theirs, base)) return replace("region-clean");
276
293
 
294
+ // The app's copy may be what its OWN engine produced rather than what the
295
+ // base tree holds, because engines before 0.14.0 ran lane code through the
296
+ // token stamper. Such an app carries `Fuelled` where the base template says
297
+ // `__APP_NAME__` — a difference the engine created, not the app. Asking
298
+ // "does this match the base as THIS app would have had it stamped?" is the
299
+ // accurate form of "did the app never touch it", and it needs no version
300
+ // comparison: for a 0.14.0+ base there is nothing left to stamp, so the
301
+ // check simply never fires.
302
+ if (stampBase !== null) {
303
+ const asStamped = stampBase(base);
304
+ if (eq(theirs, asStamped)) return replace("region-clean");
305
+ }
306
+
277
307
  // The app edited lane code. Is that edit already carried by the new engine?
278
308
  // A clean three-way merge landing exactly on `next` means the local change
279
309
  // contributed nothing beyond it — the hand-mirror case.
@@ -283,10 +313,10 @@ export function decideRegionFile({ relPath, base, next, theirs, merge = mergeThr
283
313
  return replace("region-patched", { patch: diffPatch(base, theirs, relPath) });
284
314
  }
285
315
 
286
- export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay }) {
316
+ export function decideFile({ relPath, base, next, theirs, merge = mergeThreeWay, stampBase = null }) {
287
317
  // Machine-owned lane files answer a different question — see decideRegionFile.
288
318
  if (isHarnessFile(relPath) && !(base === null && next === null)) {
289
- return decideRegionFile({ relPath, base, next, theirs, merge });
319
+ return decideRegionFile({ relPath, base, next, theirs, merge, stampBase });
290
320
  }
291
321
  const none = (bucket) => ({ bucket, write: null, sidecar: null, remove: false });
292
322
  const write = (bucket, content) => ({ bucket, write: content, sidecar: null, remove: false });
@@ -355,7 +385,7 @@ function toRel(root, abs) {
355
385
  * sidecar?:Buffer|null, remove?:boolean}>,
356
386
  * counts: Record<string, number>}}
357
387
  */
358
- export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeThreeWay }) {
388
+ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeThreeWay, stampBase = null }) {
359
389
  const rels = new Set();
360
390
  for (const f of listFiles(baseDir)) rels.add(toRel(baseDir, f));
361
391
  for (const f of listFiles(newDir)) rels.add(toRel(newDir, f));
@@ -390,6 +420,7 @@ export function planHarnessUpgrade({ baseDir, newDir, projectDir, merge = mergeT
390
420
  next: readIfPresent(newDir, relPath),
391
421
  theirs: readIfPresent(projectDir, relPath),
392
422
  merge,
423
+ stampBase,
393
424
  });
394
425
  if (decision === null) continue;
395
426
  counts[decision.bucket] = (counts[decision.bucket] ?? 0) + 1;
@@ -506,6 +537,10 @@ export function configFromSpecRecord(record, targetDir) {
506
537
  iosBundleId: record.bundleId,
507
538
  region: record.region ?? "us-central1",
508
539
  themePrefix: record.themePrefix,
540
+ // Deliberately the OPPOSITE default from the feature toggles below: every
541
+ // app stamped before the mode split carries the full harness, so an
542
+ // absent `harness` key means full, not absent.
543
+ harness: record.harness ?? true,
509
544
  platforms: record.platforms ?? { android: true, ios: true },
510
545
  firebase: record.firebase ?? { enabled: false },
511
546
  room: record.room ?? false,
@@ -0,0 +1,140 @@
1
+ // hooks.mjs — the advisory/enforcement split for a stamped project's hook set.
2
+ //
3
+ // The template's .claude/settings.json is not one thing. It carries three
4
+ // kinds of hook, and telling them apart IS the product's Act 2 / Act 3 line:
5
+ //
6
+ // ENFORCEMENT — the Stop hook (qa/receipt-check.mjs --hook). It can refuse
7
+ // to let a session claim "done" without a fresh PASS receipt. It is the
8
+ // harness's teeth, it presupposes the lane, and it ships only in full
9
+ // mode. Classified BY EVENT: Stop/SubagentStop are where Claude Code can
10
+ // block, so any hook registered there is enforcement by construction.
11
+ //
12
+ // LANE ADVISORY — wall-time nudges whose command text names `qa/` (the
13
+ // verify-fast reminder, the device-lease reminder). They constrain
14
+ // nothing, but they presuppose the lane: in a scaffold without qa/ they
15
+ // would advertise commands the agent cannot run. A discovery surface that
16
+ // lies is worse than one that is absent, so these ship only where the
17
+ // lane does. Classified by reference: naming the lane is depending on it.
18
+ //
19
+ // PORTABLE ADVISORY — everything else that informs (the screenshots-lose-
20
+ // structure nudge). True in every mode; ships in every mode. An advisory
21
+ // hook always resolves to permissionDecision "allow" — if a future
22
+ // PreToolUse hook wanted to DENY, that is an enforcement decision to make
23
+ // deliberately here, not a string to pattern-match.
24
+ //
25
+ // SessionStart is deliberately exempt from the lane-reference rule, and the
26
+ // distinction is not a special case but the actual difference between the two
27
+ // kinds of hook. A PreToolUse nudge is a fixed instruction that fires at a
28
+ // wall: its command IS the advice, so a command naming qa/ can only be kept
29
+ // or dropped. SessionStart's command is a `printf` of narration that the
30
+ // stamper AUTHORS PER MODE — it is the one hook whose content is a variable,
31
+ // so it is never dropped for describing the full mode's lane; it is rewritten
32
+ // to describe the mode actually being stamped. Dropping it instead (the first
33
+ // cut of this module did) left a minimal scaffold with no opening context at
34
+ // all, which is the silence this whole discovery layer exists to prevent.
35
+ //
36
+ // Every function is pure: settings in, new settings out, input never mutated.
37
+
38
+ export const ENFORCEMENT_EVENTS = new Set(["Stop", "SubagentStop"]);
39
+
40
+ /** Events whose hooks can constrain the agent (vs inform it). */
41
+ export function isEnforcementEvent(event) {
42
+ return ENFORCEMENT_EVENTS.has(event);
43
+ }
44
+
45
+ /** Does this hook's command presuppose the verify lane (`qa/`)? */
46
+ export function referencesLane(hook) {
47
+ return String(hook?.command ?? "").includes("qa/");
48
+ }
49
+
50
+ /**
51
+ * Events whose hook command is narration the stamper rewrites per mode,
52
+ * rather than a fixed instruction that fires at a wall. These are never
53
+ * dropped for naming the lane — they are re-authored. See the header.
54
+ */
55
+ const REWRITTEN_EVENTS = new Set(["SessionStart"]);
56
+
57
+ /**
58
+ * Classify one hook: "enforcement" | "lane-advisory" | "advisory".
59
+ * @param {string} event the settings.hooks key the hook is registered under
60
+ * @param {object} hook one entry of a group's `hooks` array
61
+ */
62
+ export function classifyHook(event, hook) {
63
+ if (isEnforcementEvent(event)) return "enforcement";
64
+ if (REWRITTEN_EVENTS.has(event)) return "advisory";
65
+ if (referencesLane(hook)) return "lane-advisory";
66
+ return "advisory";
67
+ }
68
+
69
+ /**
70
+ * Remove hooks matching `drop(event, hook)`; drop groups and events left
71
+ * empty, so the result is an honest hook set rather than a skeleton of empty
72
+ * arrays. Tolerates malformed shapes by passing them through untouched.
73
+ */
74
+ function filterHooks(settings, drop) {
75
+ const out = structuredClone(settings);
76
+ if (!out || typeof out.hooks !== "object" || out.hooks === null) return out;
77
+ for (const [event, groups] of Object.entries(out.hooks)) {
78
+ if (!Array.isArray(groups)) continue;
79
+ for (const group of groups) {
80
+ if (!Array.isArray(group?.hooks)) continue;
81
+ group.hooks = group.hooks.filter((h) => !drop(event, h));
82
+ }
83
+ out.hooks[event] = groups.filter((g) => !Array.isArray(g?.hooks) || g.hooks.length > 0);
84
+ if (out.hooks[event].length === 0) delete out.hooks[event];
85
+ }
86
+ return out;
87
+ }
88
+
89
+ /**
90
+ * The hook set with enforcement removed — advisory hooks (both kinds) pass
91
+ * through byte-identical. Idempotent; input never mutated.
92
+ * @param {object} settings parsed .claude/settings.json content
93
+ */
94
+ export function stripEnforcementHooks(settings) {
95
+ return filterHooks(settings, (event) => isEnforcementEvent(event));
96
+ }
97
+
98
+ /**
99
+ * Build a SessionStart hook command that prints `context` as
100
+ * additionalContext, in the exact shape the template's own hook uses
101
+ * (`printf '%s'` around a single-quoted JSON payload). The payload is
102
+ * single-quoted for the shell, so the copy must carry no apostrophe — that is
103
+ * a constraint on the author of the copy, enforced here rather than escaped
104
+ * around, so the stamped command stays trivially auditable.
105
+ * @param {string} context
106
+ */
107
+ export function sessionStartCommand(context) {
108
+ if (context.includes("'")) {
109
+ throw new Error(
110
+ "SessionStart context must not contain an apostrophe (the command is single-quoted for the shell) — reword the copy"
111
+ );
112
+ }
113
+ const payload = JSON.stringify({
114
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: context },
115
+ });
116
+ return `printf '%s' '${payload}'`;
117
+ }
118
+
119
+ /**
120
+ * The minimal-mode hook set, DERIVED from the full one rather than kept as a
121
+ * second file to hold in sync (light is a filter, not a fork). Three edits:
122
+ *
123
+ * (a) enforcement goes — the Stop hook is Act 3;
124
+ * (b) lane-advisory goes — a nudge naming qa/ presupposes the lane;
125
+ * (c) SessionStart says what is true HERE — `sessionContext` describes what
126
+ * this scaffold carries and the one command that adds the rest.
127
+ *
128
+ * @param {object} settings parsed .claude/settings.json content
129
+ * @param {object} opts
130
+ * @param {string} opts.sessionContext additionalContext for the SessionStart hook
131
+ */
132
+ export function minimalHookSettings(settings, { sessionContext }) {
133
+ const out = filterHooks(settings, (event, hook) => classifyHook(event, hook) !== "advisory");
134
+ if (!out || typeof out.hooks !== "object" || out.hooks === null) return out;
135
+ for (const group of out.hooks.SessionStart ?? []) {
136
+ if (!Array.isArray(group?.hooks)) continue;
137
+ for (const hook of group.hooks) hook.command = sessionStartCommand(sessionContext);
138
+ }
139
+ return out;
140
+ }