create-claude-cabinet 0.49.0 → 0.51.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 (32) hide show
  1. package/lib/CLAUDE.md +30 -1
  2. package/lib/cli.js +26 -1
  3. package/lib/engagement-server-setup.js +1 -21
  4. package/lib/installer-gate.js +56 -0
  5. package/lib/modules.js +15 -0
  6. package/lib/mux-setup.js +1 -22
  7. package/lib/output-register.js +629 -0
  8. package/lib/settings-merge.js +74 -52
  9. package/lib/watchtower-setup.js +1 -21
  10. package/package.json +1 -1
  11. package/templates/CLAUDE.md +160 -26
  12. package/templates/cabinet/worktree-invocation-contract.md +16 -9
  13. package/templates/engagement/__tests__/transport-token.test.mjs +103 -0
  14. package/templates/engagement/engagement-transport.mjs +35 -1
  15. package/templates/hooks/register-reinforce.sh +125 -0
  16. package/templates/output-styles/cabinet-register.md +114 -0
  17. package/templates/scripts/__tests__/batch-disposition.test.mjs +2 -1
  18. package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +3 -0
  19. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +8 -1
  20. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +69 -20
  21. package/templates/scripts/__tests__/qa-handoff-merged-by-construction.test.mjs +310 -0
  22. package/templates/scripts/__tests__/resolve-cli.test.mjs +6 -1
  23. package/templates/scripts/__tests__/routine-dispatch.test.mjs +2 -0
  24. package/templates/scripts/__tests__/session-ack.test.mjs +61 -0
  25. package/templates/scripts/watchtower-build-context.mjs +24 -2
  26. package/templates/scripts/watchtower-queue.mjs +272 -2
  27. package/templates/skills/cc-publish/SKILL.md +20 -0
  28. package/templates/skills/collab-client/SKILL.md +13 -7
  29. package/templates/skills/collab-consultant/SKILL.md +9 -4
  30. package/templates/skills/execute/SKILL.md +60 -33
  31. package/templates/skills/qa-handoff/SKILL.md +112 -48
  32. package/templates/skills/validate/phases/validators.md +67 -0
@@ -0,0 +1,629 @@
1
+ /**
2
+ * output-register.js — install the Cabinet Register output style (user-level)
3
+ * and report where the register stands (act:9a0af01a).
4
+ *
5
+ * WHY AN OUTPUT STYLE. Plain-English, concept-first communication is already
6
+ * written down in three places that agree with each other (~/.claude/CLAUDE.md
7
+ * "Communication Style", .claude/rules/plain-english-decisions.md,
8
+ * skill-output-conventions.md §9). What none of them has is RE-ASSERTION: each
9
+ * is stated once, then competes with everything else in a growing context. An
10
+ * output style lands the contract in the SYSTEM PROMPT — re-sent in full on
11
+ * every request, never decays, unaffected by compaction. That is the strongest
12
+ * placement the platform offers. It is still prompt context, not a hook: this
13
+ * is not a move up the compliance stack and does not claim to be.
14
+ *
15
+ * THE FOOTGUN THIS FILE EXISTS TO GUARD (§4 of the plan).
16
+ * `keep-coding-instructions` was added in Claude Code v2.0.37. Below that
17
+ * version the field is SILENTLY IGNORED, its `false` default applies, and
18
+ * Claude Code's own software-engineering instructions are STRIPPED from the
19
+ * system prompt with no error. Verified in the shipped 2.1.209 binary:
20
+ *
21
+ * p = TEt(o["keep-coding-instructions"]) // kebab, raw read
22
+ * function TEt(e){ if(e===!0||e==="true")return!0;
23
+ * if(e===!1||e==="false")return!1; return } // absent → undefined
24
+ * c===null || c.keepCodingInstructions===!0 ? Qh_() : null // strict === true
25
+ *
26
+ * A test that the field is PRESENT IN THE FILE passes on every version ever
27
+ * shipped and detects this never — it guards the author forgetting the field,
28
+ * not the platform refusing to honor it. Only a `claude --version` check can.
29
+ * Hence versionGate(), and hence validateStyleSource() runs against the
30
+ * INSTALLED copy in ~/.claude/output-styles/ — the file Claude Code actually
31
+ * reads — not just the template.
32
+ *
33
+ * OWNERSHIP: seed-if-absent, never overwrite, validate loudly.
34
+ * A prose register is the most edit-inviting artifact CC ships — the operator
35
+ * WILL hand-tune it. The two sibling engines are opposites and neither is right
36
+ * here: mux-setup compares a cached manifest hash (a hand edit is never
37
+ * re-examined, so a footgun persists forever), watchtower-setup compares the
38
+ * dest's on-disk bytes and reconverges (hand edits reverted every reinstall).
39
+ * We take the third path CC already built — isProjectOwnedSeed()'s semantics
40
+ * (lib/copy.js, act:2001bf54): seed if absent, NEVER overwrite, omit from the
41
+ * project manifest — then close the gap that opens by validating the installed
42
+ * copy on every install and FAILING LOUD rather than silently rewriting it.
43
+ */
44
+
45
+ 'use strict';
46
+
47
+ const fs = require('fs');
48
+ const os = require('os');
49
+ const path = require('path');
50
+ const { execFileSync } = require('child_process');
51
+ const { sha256, compareVersions, readGlobalManifest, writeGlobalManifest, globalManifestPath } = require('./installer-gate');
52
+
53
+ const TEMPLATE_DIR = path.resolve(__dirname, '..', 'templates', 'output-styles');
54
+
55
+ /**
56
+ * The style's identity. Claude Code keys a custom style by its FRONTMATTER
57
+ * `name`, verbatim — the filename is only a fallback when `name` is absent.
58
+ * Verified in 2.1.209:
59
+ * c = basename(n).replace(/\.md$/,""),
60
+ * u = (o.name != null ? String(o.name) : void 0) || c
61
+ * ...then folded into a map keyed by `.name` and selected by exact lookup
62
+ * (`e[settings.outputStyle] ?? null`). So THIS STRING is what must land in the
63
+ * `outputStyle` settings key, and it must stay in step with the frontmatter of
64
+ * templates/output-styles/cabinet-register.md (asserted by the test suite).
65
+ */
66
+ const REGISTER_STYLE_NAME = 'Cabinet Register';
67
+
68
+ /**
69
+ * The floor below which `keep-coding-instructions` does not exist. Changelog:
70
+ * v1.0.81 released output styles · v2.0.30 DEPRECATED them · v2.0.32
71
+ * un-deprecated on community feedback · v2.0.37 ADDED the field · v2.1.94 added
72
+ * it for plugin styles. That leaves a ~250-version window where output styles
73
+ * work and the field is silently ignored — the window this gate closes.
74
+ */
75
+ const MIN_CLAUDE_VERSION = '2.0.37';
76
+
77
+ /**
78
+ * Anthropic's built-in style roster, so we never ship a name that collides
79
+ * (a collision would make which-style-wins depend on load order).
80
+ * SNAPSHOT, verified against 2.1.209's registry:
81
+ * Lle = { default: null, Proactive: {...}, Explanatory: {...}, Learning: {...} }
82
+ * This roster is Anthropic's and it GROWS — Proactive post-dates the release
83
+ * that introduced output styles, and the published schema is already stale
84
+ * against the binary. A future built-in named "Cabinet Register" is
85
+ * vanishingly unlikely, but if this list goes stale the failure is a silent
86
+ * shadow, so re-verify it against the binary rather than trusting the docs.
87
+ */
88
+ const BUILT_IN_STYLE_NAMES = ['default', 'Proactive', 'Explanatory', 'Learning'];
89
+
90
+ /** Where Claude Code reads user-level custom styles from. */
91
+ function userStyleDir(home = os.homedir()) {
92
+ return path.join(home, '.claude', 'output-styles');
93
+ }
94
+
95
+ /**
96
+ * The shipped style set, discovered by GLOB — never hand-enumerated.
97
+ * The manifest-integrity orphan test only scans templates/skills/, so a style
98
+ * added under templates/output-styles/ but missing from a hardcoded list would
99
+ * ship to nobody with every test green. Globbing makes the engine's managed set
100
+ * equal to what is on disk by construction.
101
+ */
102
+ function styleTemplates(templateDir = TEMPLATE_DIR) {
103
+ if (!fs.existsSync(templateDir)) return [];
104
+ return fs
105
+ .readdirSync(templateDir)
106
+ .filter((f) => f.endsWith('.md'))
107
+ .sort()
108
+ .map((f) => ({ file: f, src: path.join(templateDir, f) }));
109
+ }
110
+
111
+ /**
112
+ * Parse the leading `---` frontmatter block. Deliberately minimal — the style
113
+ * frontmatter is flat `key: value` scalars and CC ships no YAML dependency
114
+ * (the two-dependency footprint is intentional). Returns
115
+ * `{ ok, frontmatter, error }`; a file with no frontmatter block is `ok:false`.
116
+ */
117
+ function parseFrontmatter(text) {
118
+ const src = String(text || '');
119
+ if (!/^---\r?\n/.test(src)) return { ok: false, frontmatter: {}, error: 'no frontmatter block' };
120
+ const end = src.indexOf('\n---', 3);
121
+ if (end === -1) return { ok: false, frontmatter: {}, error: 'unterminated frontmatter block' };
122
+ const body = src.slice(4, end);
123
+ const frontmatter = {};
124
+ for (const rawLine of body.split('\n')) {
125
+ const line = rawLine.trim();
126
+ if (!line || line.startsWith('#')) continue;
127
+ const idx = line.indexOf(':');
128
+ if (idx === -1) continue;
129
+ const key = line.slice(0, idx).trim();
130
+ let value = line.slice(idx + 1).trim();
131
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
132
+ value = value.slice(1, -1);
133
+ }
134
+ frontmatter[key] = value;
135
+ }
136
+ return { ok: true, frontmatter, error: null };
137
+ }
138
+
139
+ /**
140
+ * Claude Code's own coercion for `keep-coding-instructions`, mirrored exactly
141
+ * (function TEt in 2.1.209): true/"true" → true, false/"false" → false,
142
+ * ANYTHING ELSE → undefined, and the gate then requires a strict `=== true`.
143
+ * So `keep-coding-instructions: yes` — valid YAML truth, wrong here — reads as
144
+ * undefined and strips the coding instructions. We reject anything that does
145
+ * not coerce to exactly true.
146
+ */
147
+ function coerceKeepCoding(value) {
148
+ if (value === true || value === 'true') return true;
149
+ if (value === false || value === 'false') return false;
150
+ return undefined;
151
+ }
152
+
153
+ /**
154
+ * Validate one style file's SOURCE TEXT against everything that can silently
155
+ * break it. Used for BOTH the template and the installed copy — the installed
156
+ * copy is the one Claude Code reads, and validating only the template would be
157
+ * checking the one file that cannot cause the failure.
158
+ *
159
+ * @returns {{ ok: boolean, name: string|null, problems: string[] }}
160
+ */
161
+ function validateStyleSource(text, { label = 'style' } = {}) {
162
+ const problems = [];
163
+ const { ok, frontmatter, error } = parseFrontmatter(text);
164
+ if (!ok) {
165
+ return { ok: false, name: null, problems: [`${label}: ${error}`] };
166
+ }
167
+ const name = typeof frontmatter.name === 'string' && frontmatter.name.trim() ? frontmatter.name.trim() : null;
168
+ if (!name) problems.push(`${label}: frontmatter is missing a \`name\` (the key Claude Code selects the style by)`);
169
+ if (!frontmatter.description || !String(frontmatter.description).trim()) {
170
+ problems.push(`${label}: frontmatter is missing a \`description\` (shown in the /config picker)`);
171
+ }
172
+ if (coerceKeepCoding(frontmatter['keep-coding-instructions']) !== true) {
173
+ problems.push(
174
+ `${label}: \`keep-coding-instructions: true\` is missing or not literally true — ` +
175
+ `without it Claude Code STRIPS its software-engineering instructions from the system prompt`
176
+ );
177
+ }
178
+ if (name && BUILT_IN_STYLE_NAMES.includes(name)) {
179
+ problems.push(`${label}: name "${name}" collides with a built-in output style (${BUILT_IN_STYLE_NAMES.join(', ')})`);
180
+ }
181
+ return { ok: problems.length === 0, name, problems };
182
+ }
183
+
184
+ // ── The version gate ────────────────────────────────────────────────────────
185
+
186
+ /**
187
+ * Read the installed Claude Code version. `exec` is injectable so tests can
188
+ * drive every arm without a real binary.
189
+ * @returns {{ status: 'ok'|'absent'|'unparseable', version: string|null, raw: string|null }}
190
+ */
191
+ function detectClaudeVersion({ exec } = {}) {
192
+ const run =
193
+ exec ||
194
+ (() =>
195
+ execFileSync('claude', ['--version'], {
196
+ encoding: 'utf8',
197
+ timeout: 5000,
198
+ stdio: ['ignore', 'pipe', 'ignore'],
199
+ }));
200
+ let raw;
201
+ try {
202
+ raw = run();
203
+ } catch {
204
+ // No binary on PATH, non-zero exit, timeout — absence is NOT an old
205
+ // version, so this fails OPEN (see versionGate).
206
+ return { status: 'absent', version: null, raw: null };
207
+ }
208
+ const m = /(\d+\.\d+\.\d+)/.exec(String(raw || ''));
209
+ if (!m) return { status: 'unparseable', version: null, raw: String(raw || '').trim() };
210
+ return { status: 'ok', version: m[1], raw: String(raw).trim() };
211
+ }
212
+
213
+ /**
214
+ * Decide whether it is safe to install the style on this Claude Code.
215
+ *
216
+ * Below MIN_CLAUDE_VERSION we REFUSE: installing here would work — the style
217
+ * loads fine — and silently strip CC's software-engineering instructions. The
218
+ * failure is invisible from inside the file, which is why refusing is the only
219
+ * honest option.
220
+ *
221
+ * An ABSENT or UNPARSEABLE version fails OPEN: absence is not evidence of an
222
+ * old version, and refusing to install because we couldn't find a binary would
223
+ * break every CI/container install for a hazard we have no reason to believe
224
+ * exists there.
225
+ *
226
+ * @returns {{ allowed: boolean, reason: string, version: string|null, status: string }}
227
+ */
228
+ function versionGate(detected) {
229
+ const { status, version } = detected;
230
+ if (status === 'absent') {
231
+ return {
232
+ allowed: true,
233
+ status,
234
+ version: null,
235
+ reason: `could not run \`claude --version\` — installing anyway (absence is not an old version). ` +
236
+ `If this Claude Code is older than v${MIN_CLAUDE_VERSION}, \`keep-coding-instructions\` is ignored there.`,
237
+ };
238
+ }
239
+ if (status === 'unparseable') {
240
+ return {
241
+ allowed: true,
242
+ status,
243
+ version: null,
244
+ reason: `could not parse a version out of \`claude --version\` — installing anyway (fail-open).`,
245
+ };
246
+ }
247
+ if (compareVersions(version, MIN_CLAUDE_VERSION) < 0) {
248
+ return {
249
+ allowed: false,
250
+ status,
251
+ version,
252
+ reason:
253
+ `Claude Code v${version} is older than v${MIN_CLAUDE_VERSION}, which is when ` +
254
+ `\`keep-coding-instructions\` was added. On this version the field is silently ignored, its ` +
255
+ `\`false\` default applies, and Claude Code's software-engineering instructions are STRIPPED ` +
256
+ `from the system prompt with no error. Upgrade Claude Code, then re-run the installer.`,
257
+ };
258
+ }
259
+ return { allowed: true, status, version, reason: `Claude Code v${version} honors \`keep-coding-instructions\`.` };
260
+ }
261
+
262
+ // ── The enumerator ──────────────────────────────────────────────────────────
263
+
264
+ /**
265
+ * The settings layers we can actually READ, low → high precedence. Verified in
266
+ * 2.1.209, whose own comment on the precedence array reads "Ordered low-to-high
267
+ * priority — later entries override earlier ones":
268
+ * ["userSettings","projectSettings","localSettings","flagSettings","policySettings"]
269
+ * The last two are deliberately absent here — see resolveRegisterLevel.
270
+ */
271
+ const SETTINGS_LAYERS = [
272
+ { layer: 'userSettings', label: 'User settings', rel: null },
273
+ { layer: 'projectSettings', label: 'Project settings', rel: path.join('.claude', 'settings.json') },
274
+ { layer: 'localSettings', label: 'Local settings', rel: path.join('.claude', 'settings.local.json') },
275
+ ];
276
+
277
+ function layerPaths({ projectDir = process.cwd(), home = os.homedir() } = {}) {
278
+ return SETTINGS_LAYERS.map((l) => ({
279
+ ...l,
280
+ path: l.rel === null ? path.join(home, '.claude', 'settings.json') : path.join(projectDir, l.rel),
281
+ }));
282
+ }
283
+
284
+ /**
285
+ * Read one settings layer's `outputStyle`, distinguishing every way it can be
286
+ * non-informative. "Unparseable" must NEVER read as "absent" — that would turn
287
+ * a broken settings file into a confident "the register is off".
288
+ *
289
+ * state ∈ 'absent' — no such file
290
+ * | 'unparseable' — file exists, JSON.parse threw
291
+ * | 'not-an-object' — parsed to [] / null / a scalar
292
+ * | 'no-key' — parsed fine, no outputStyle key
293
+ * | 'null' — outputStyle: null
294
+ * | 'empty' — outputStyle: ""
295
+ * | 'non-string' — outputStyle: 7 / {} / []
296
+ * | 'set' — outputStyle names a style
297
+ */
298
+ function readLayerOutputStyle(filePath) {
299
+ if (!fs.existsSync(filePath)) return { state: 'absent', value: null };
300
+ let parsed;
301
+ try {
302
+ parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
303
+ } catch {
304
+ return { state: 'unparseable', value: null };
305
+ }
306
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
307
+ return { state: 'not-an-object', value: null };
308
+ }
309
+ if (!Object.prototype.hasOwnProperty.call(parsed, 'outputStyle')) return { state: 'no-key', value: null };
310
+ const v = parsed.outputStyle;
311
+ if (v === null) return { state: 'null', value: null };
312
+ if (typeof v !== 'string') return { state: 'non-string', value: v };
313
+ if (v.trim() === '') return { state: 'empty', value: v };
314
+ return { state: 'set', value: v };
315
+ }
316
+
317
+ /**
318
+ * Resolve what the NEXT session's output style will be, as best as a file
319
+ * reader can know it.
320
+ *
321
+ * THIS ENUMERATES; IT DOES NOT ADJUDICATE. Three layers are invisible to it:
322
+ * a plugin's `force-for-plugin` (which overrides `outputStyle` entirely), CLI
323
+ * `--settings`, and enterprise managed policy — all of which outrank everything
324
+ * below. A tool rendering a verdict it cannot back is exactly the fact-shaped
325
+ * guess verify-before-asserting.md exists to prevent, shipped as a feature. So
326
+ * the contract is honest about its own blind spots and points at `/status` and
327
+ * `/context` for ground truth.
328
+ *
329
+ * THREE-VALUED, deliberately: a foreign style reports its literal name with
330
+ * `ours:false`, which is NOT the same as `off`. Collapsing "Explanatory" into
331
+ * "off" is what would let a never-clobber guard clobber it.
332
+ *
333
+ * @returns {{ level: string, ours: boolean, source: string|null, uncertain: boolean, sources: object[] }}
334
+ * level: the style name, or 'off' (no layer names one), or 'unknown' (a layer
335
+ * we could not read sits ABOVE whatever we could).
336
+ */
337
+ function resolveRegisterLevel({ projectDir = process.cwd(), home = os.homedir() } = {}) {
338
+ const sources = [];
339
+ let winner = null;
340
+ let unreadableAboveWinner = false;
341
+
342
+ for (const l of layerPaths({ projectDir, home })) {
343
+ const read = readLayerOutputStyle(l.path);
344
+ sources.push({ layer: l.layer, label: l.label, path: l.path, state: read.state, value: read.value });
345
+ if (read.state === 'set') {
346
+ winner = { layer: l.layer, value: read.value };
347
+ unreadableAboveWinner = false; // this layer outranks anything unreadable below it
348
+ } else if (read.state === 'unparseable' || read.state === 'not-an-object' || read.state === 'non-string') {
349
+ unreadableAboveWinner = true;
350
+ }
351
+ }
352
+
353
+ if (unreadableAboveWinner) {
354
+ return { level: 'unknown', ours: false, source: null, uncertain: true, sources };
355
+ }
356
+ if (!winner) {
357
+ return { level: 'off', ours: false, source: null, uncertain: false, sources };
358
+ }
359
+ return {
360
+ level: winner.value,
361
+ ours: winner.value === REGISTER_STYLE_NAME,
362
+ source: winner.layer,
363
+ uncertain: false,
364
+ sources,
365
+ };
366
+ }
367
+
368
+ const STATE_PROSE = {
369
+ absent: 'no such file',
370
+ unparseable: 'FILE EXISTS BUT IS NOT VALID JSON — cannot tell what it says',
371
+ 'not-an-object': 'FILE EXISTS BUT IS NOT A JSON OBJECT — cannot tell what it says',
372
+ 'no-key': 'no outputStyle key',
373
+ null: 'outputStyle: null (present, names nothing)',
374
+ empty: 'outputStyle: "" (present, names nothing)',
375
+ 'non-string': 'outputStyle is not a string — Claude Code will not resolve it',
376
+ };
377
+
378
+ /**
379
+ * Render `--register`: every settings file that mentions outputStyle, what each
380
+ * says, the layers we could NOT consult, and where the real answer lives.
381
+ */
382
+ function renderRegisterReport({ projectDir = process.cwd(), home = os.homedir() } = {}) {
383
+ const lines = [];
384
+ const res = resolveRegisterLevel({ projectDir, home });
385
+
386
+ if (res.level === 'off') {
387
+ lines.push(' Register: OFF — no settings file names an output style.');
388
+ } else if (res.level === 'unknown') {
389
+ lines.push(' Register: UNKNOWN — a settings file could not be read (see below).');
390
+ } else if (res.ours) {
391
+ lines.push(` Register: ON — "${res.level}" (set in ${res.source}).`);
392
+ } else {
393
+ lines.push(` Register: a DIFFERENT style is active — "${res.level}" (set in ${res.source}). Not ours; left alone.`);
394
+ }
395
+
396
+ lines.push('');
397
+ lines.push(' What each settings file says (low → high precedence):');
398
+ for (const s of res.sources) {
399
+ const said = s.state === 'set' ? `outputStyle: "${s.value}"` : STATE_PROSE[s.state] || s.state;
400
+ lines.push(` ${s.label.padEnd(16)} ${said}`);
401
+ lines.push(` ${''.padEnd(16)} ${s.path}`);
402
+ }
403
+
404
+ const styleDir = userStyleDir(home);
405
+ const installed = fs.existsSync(styleDir) ? fs.readdirSync(styleDir).filter((f) => f.endsWith('.md')) : [];
406
+ lines.push('');
407
+ lines.push(` Installed styles in ${styleDir}: ${installed.length ? installed.join(', ') : '(none)'}`);
408
+
409
+ lines.push('');
410
+ lines.push(' This is an ENUMERATOR, not a verdict. It cannot see:');
411
+ lines.push(' - a plugin\'s force-for-plugin style, which overrides outputStyle entirely');
412
+ lines.push(' - command-line --settings, which outranks every file above');
413
+ lines.push(' - enterprise managed policy, which outranks everything');
414
+ lines.push(' For ground truth: `/status` shows the active settings sources, and');
415
+ lines.push(' `/context` shows the system prompt — the only place that proves the style landed.');
416
+ lines.push('');
417
+ lines.push(' Note: this reports what the NEXT session will start with. Output style is');
418
+ lines.push(' fixed at session start (for prompt caching); changing it mid-session does');
419
+ lines.push(' nothing until /clear or a new session.');
420
+
421
+ return lines;
422
+ }
423
+
424
+ // ── The installer ───────────────────────────────────────────────────────────
425
+
426
+ /**
427
+ * Seed `outputStyle` into the PROJECT's .claude/settings.json.
428
+ *
429
+ * Project-level, not user-level, on purpose: this module is selected per
430
+ * project, so turning the register on portfolio-wide as a side effect of a
431
+ * single project's install would be a surprise. A project install changes the
432
+ * project. (The operator's $HOME CC install is the global layer — installing
433
+ * there sets it everywhere, which is exactly what that layer is for.)
434
+ *
435
+ * Seed-if-absent, never clobber: an incumbent style — ours or foreign, at ANY
436
+ * layer — is reported and left alone.
437
+ *
438
+ * Atomic + read-back: re-read immediately before writing and verify after, so a
439
+ * concurrent writer (healUserSettings and mergeSettings are both non-atomic
440
+ * read-modify-writes elsewhere in this install) can't leave us announcing a
441
+ * write that didn't land.
442
+ */
443
+ function seedOutputStyleSetting({ projectDir, dryRun = false, home = os.homedir(), results }) {
444
+ const res = resolveRegisterLevel({ projectDir, home });
445
+
446
+ if (res.level === 'unknown') {
447
+ results.push(` ⚠ Not setting outputStyle: a settings file is unreadable, so the incumbent is unknown.`);
448
+ results.push(` Run \`npx create-claude-cabinet --register\` to see which file.`);
449
+ return { status: 'skipped-uncertain' };
450
+ }
451
+ if (res.ours) {
452
+ results.push(` ✓ Register already active — "${REGISTER_STYLE_NAME}" (set in ${res.source}).`);
453
+ return { status: 'already-set' };
454
+ }
455
+ if (res.level !== 'off') {
456
+ results.push(` ⚠ Leaving the existing output style alone: "${res.level}" is set in ${res.source}.`);
457
+ results.push(` Only one style can be active. To switch, set outputStyle to "${REGISTER_STYLE_NAME}" there, or use /config.`);
458
+ return { status: 'incumbent' };
459
+ }
460
+
461
+ const settingsPath = path.join(projectDir, '.claude', 'settings.json');
462
+ if (dryRun) {
463
+ results.push(` [dry-run] would set outputStyle: "${REGISTER_STYLE_NAME}" in ${settingsPath}`);
464
+ return { status: 'would-set' };
465
+ }
466
+
467
+ // Read immediately before writing — not from the resolve above — so a
468
+ // concurrent writer's changes are preserved rather than reverted.
469
+ let settings = {};
470
+ if (fs.existsSync(settingsPath)) {
471
+ try {
472
+ const parsed = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
473
+ if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) settings = parsed;
474
+ } catch {
475
+ results.push(` ⚠ ${settingsPath} is not valid JSON — not touching it. Set outputStyle by hand or via /config.`);
476
+ return { status: 'skipped-unparseable' };
477
+ }
478
+ }
479
+ settings.outputStyle = REGISTER_STYLE_NAME;
480
+ fs.mkdirSync(path.dirname(settingsPath), { recursive: true });
481
+ const tmp = settingsPath + '.tmp';
482
+ fs.writeFileSync(tmp, JSON.stringify(settings, null, 2) + '\n');
483
+ fs.renameSync(tmp, settingsPath);
484
+
485
+ // Verify the write, don't announce it from the call's success.
486
+ const after = readLayerOutputStyle(settingsPath);
487
+ if (after.state !== 'set' || after.value !== REGISTER_STYLE_NAME) {
488
+ results.push(` ⚠ Wrote outputStyle to ${settingsPath} but reading it back did not confirm it (${after.state}).`);
489
+ return { status: 'write-unconfirmed' };
490
+ }
491
+ results.push(` ✓ Set outputStyle: "${REGISTER_STYLE_NAME}" in ${path.relative(projectDir, settingsPath)} (takes effect next session).`);
492
+ return { status: 'set' };
493
+ }
494
+
495
+ /**
496
+ * postInstall handler for the `output-register` module.
497
+ *
498
+ * @param {object} opts
499
+ * @param {boolean} [opts.dryRun]
500
+ * @param {string} [opts.projectDir]
501
+ * @param {string} [opts.home] — injectable for tests
502
+ * @param {Function} [opts.exec] — injectable `claude --version` runner
503
+ * @returns {{ results: string[], status: 'refused'|'installed'|'unchanged'|'failed' }}
504
+ */
505
+ function setupOutputRegister(opts = {}) {
506
+ const dryRun = !!opts.dryRun;
507
+ const projectDir = opts.projectDir || process.cwd();
508
+ const home = opts.home || os.homedir();
509
+ const results = opts.results || [];
510
+
511
+ results.push('Installing the Cabinet Register output style');
512
+
513
+ // 1. The version gate — the only guard that can catch the real hazard.
514
+ const detected = detectClaudeVersion({ exec: opts.exec });
515
+ const gate = versionGate(detected);
516
+ if (!gate.allowed) {
517
+ results.push(` ✗ REFUSED: ${gate.reason}`);
518
+ return { results, status: 'refused' };
519
+ }
520
+ results.push(` ${gate.status === 'ok' ? '✓' : '⚠'} ${gate.reason}`);
521
+
522
+ const templates = styleTemplates();
523
+ if (templates.length === 0) {
524
+ results.push(' ⚠ No styles found under templates/output-styles/ — nothing to install.');
525
+ return { results, status: 'failed' };
526
+ }
527
+
528
+ const styleDir = userStyleDir(home);
529
+ // Resolve the manifest against THIS run's home, not os.homedir() — `home` is
530
+ // injectable, and a bare readGlobalManifest() would escape the sandbox.
531
+ const manifestPath = globalManifestPath(home);
532
+ const manifest = readGlobalManifest(manifestPath);
533
+ let seeded = 0;
534
+ let failures = 0;
535
+
536
+ for (const t of templates) {
537
+ const content = fs.readFileSync(t.src, 'utf8');
538
+
539
+ // 2. Validate the TEMPLATE (author error — catch it before it ships).
540
+ const srcCheck = validateStyleSource(content, { label: `template ${t.file}` });
541
+ if (!srcCheck.ok) {
542
+ for (const p of srcCheck.problems) results.push(` ✗ ${p}`);
543
+ failures++;
544
+ continue;
545
+ }
546
+
547
+ const dest = path.join(styleDir, t.file);
548
+
549
+ if (fs.existsSync(dest)) {
550
+ // 3. NEVER overwrite — but validate the INSTALLED copy, the one Claude
551
+ // Code actually reads. A copy missing the flag fails LOUD rather than
552
+ // being silently rewritten (which would eat the operator's tuning).
553
+ let installedText = '';
554
+ try {
555
+ installedText = fs.readFileSync(dest, 'utf8');
556
+ } catch (err) {
557
+ results.push(` ✗ Installed style ${dest} is unreadable: ${err.message}`);
558
+ failures++;
559
+ continue;
560
+ }
561
+ const check = validateStyleSource(installedText, { label: `installed ${t.file}` });
562
+ if (!check.ok) {
563
+ for (const p of check.problems) results.push(` ✗ ${p}`);
564
+ results.push(` Not overwriting your copy — fix ${dest} by hand, or delete it and re-run to reseed.`);
565
+ failures++;
566
+ continue;
567
+ }
568
+ const edited = sha256(installedText) !== sha256(content);
569
+ results.push(` ✓ ${t.file} already installed${edited ? ' (your edits preserved — never overwritten)' : ''}`);
570
+ continue;
571
+ }
572
+
573
+ // 4. Seed.
574
+ if (dryRun) {
575
+ results.push(` [dry-run] ${t.file} → ${dest}`);
576
+ seeded++;
577
+ continue;
578
+ }
579
+ fs.mkdirSync(styleDir, { recursive: true });
580
+ fs.writeFileSync(dest, content);
581
+
582
+ // Verify the seed by re-reading the file Claude Code will read.
583
+ const after = validateStyleSource(fs.readFileSync(dest, 'utf8'), { label: `installed ${t.file}` });
584
+ if (!after.ok) {
585
+ for (const p of after.problems) results.push(` ✗ ${p}`);
586
+ failures++;
587
+ continue;
588
+ }
589
+ // Global-manifest bookkeeping only: the style is project-owned the moment
590
+ // it lands, so this records WHAT WE SEEDED (so --reset can find it), never
591
+ // a claim we may overwrite it.
592
+ manifest.files[dest] = sha256(content);
593
+ results.push(` ✓ Seeded ${t.file} → ${dest}`);
594
+ seeded++;
595
+ }
596
+
597
+ if (!dryRun && seeded > 0) {
598
+ manifest.installedAt = new Date().toISOString();
599
+ writeGlobalManifest(manifest, manifestPath);
600
+ }
601
+
602
+ if (failures > 0) return { results, status: 'failed' };
603
+
604
+ // 5. Turn it on — offering, never clobbering.
605
+ seedOutputStyleSetting({ projectDir, dryRun, home, results });
606
+
607
+ results.push(' Check where the register stands any time: npx create-claude-cabinet --register');
608
+ return { results, status: seeded > 0 ? 'installed' : 'unchanged' };
609
+ }
610
+
611
+ module.exports = {
612
+ setupOutputRegister,
613
+ resolveRegisterLevel,
614
+ renderRegisterReport,
615
+ detectClaudeVersion,
616
+ versionGate,
617
+ validateStyleSource,
618
+ parseFrontmatter,
619
+ coerceKeepCoding,
620
+ styleTemplates,
621
+ userStyleDir,
622
+ seedOutputStyleSetting,
623
+ readLayerOutputStyle,
624
+ REGISTER_STYLE_NAME,
625
+ MIN_CLAUDE_VERSION,
626
+ BUILT_IN_STYLE_NAMES,
627
+ SETTINGS_LAYERS,
628
+ TEMPLATE_DIR,
629
+ };