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.
- package/README.md +51 -353
- package/bin/create-cmp.mjs +19 -3
- package/llms.txt +3 -3
- package/options.schema.json +4 -0
- package/package.json +2 -2
- package/packages/harness/package.json +11 -3
- package/packages/harness/src/lib/harness-lock.mjs +2 -2
- package/packages/harness/src/lib/inputs-hash.mjs +1 -1
- package/packages/harness/src/lib/receipt-validate.mjs +1 -1
- package/packages/harness/src/receipt-check.mjs +1 -1
- package/packages/harness/src/verify.mjs +15 -1
- package/packages/receipts/package.json +11 -3
- package/packages/receipts/src/index.mjs +1 -1
- package/packages/receipts/src/inputs-hash.mjs +1 -1
- package/packages/receipts/src/receipt-validate.mjs +1 -1
- package/src/commands/attach.mjs +250 -0
- package/src/commands/create.mjs +13 -2
- package/src/commands/harden.mjs +263 -0
- package/src/commands/upgrade.mjs +20 -2
- package/src/lib/adr-seed.mjs +27 -0
- package/src/lib/harness-upgrade.mjs +40 -5
- package/src/lib/hooks.mjs +140 -0
- package/src/lib/minimal.mjs +130 -0
- package/src/lib/toggle.mjs +4 -1
- package/src/lib/verify.mjs +6 -1
- package/src/scaffold.mjs +18 -1
- package/template/.github/workflows/verify.yml +15 -0
- package/template/AGENTS.md +57 -8
- package/template/CLAUDE.md +49 -0
- package/template/CONTRIBUTING.md +13 -0
- package/template/README.md +33 -0
- package/template/docs/ARCHITECTURE.md +18 -1
- package/template/docs/TESTING.md +10 -0
- package/template/manifest.json +13 -0
- package/template/qa/lib/harness-lock.mjs +2 -2
- package/template/qa/lib/inputs-hash.mjs +1 -1
- package/template/qa/lib/receipt-validate.mjs +1 -1
- package/template/qa/receipt-check.mjs +1 -1
- 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
|
+
}
|
package/src/commands/upgrade.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/lib/adr-seed.mjs
CHANGED
|
@@ -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
|
+
}
|