create-claude-cabinet 0.47.0 → 0.49.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 (106) hide show
  1. package/README.md +1 -0
  2. package/lib/CLAUDE.md +240 -0
  3. package/lib/cli.js +202 -349
  4. package/lib/copy.js +75 -1
  5. package/lib/engagement-server-setup.js +1 -17
  6. package/lib/engagement-setup.js +1 -1
  7. package/lib/installer-gate.js +135 -0
  8. package/lib/metadata.js +20 -0
  9. package/lib/modules.js +292 -0
  10. package/lib/mux-setup.js +1 -17
  11. package/lib/project-context.js +16 -96
  12. package/lib/settings-merge.js +148 -9
  13. package/lib/site-audit-setup.js +23 -7
  14. package/lib/verify-setup.js +20 -9
  15. package/lib/watchtower-setup.js +1 -5
  16. package/package.json +1 -1
  17. package/templates/CLAUDE.md +1159 -0
  18. package/templates/briefing/_briefing-template.md +6 -11
  19. package/templates/cabinet/eval-protocol.md +27 -10
  20. package/templates/cabinet/watchtower-contracts.md +124 -0
  21. package/templates/cabinet/worktree-invocation-contract.md +59 -0
  22. package/templates/engagement/__tests__/checklist-visibility.test.mjs +76 -0
  23. package/templates/engagement/engagement-checklist.mjs +26 -2
  24. package/templates/engagement/engagement-schema.md +8 -6
  25. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +102 -18
  26. package/templates/engagement/pib-db-patches/pib-db-schema.sql +1 -1
  27. package/templates/engagement/pib-db-patches/pib-db.mjs +4 -1
  28. package/templates/engagement/sql-constants.mjs +9 -1
  29. package/templates/engagement-server/__tests__/cross-tenant-auth.test.mjs +87 -0
  30. package/templates/engagement-server/server.mjs +65 -40
  31. package/templates/hooks/action-completion-gate.sh +10 -0
  32. package/templates/hooks/action-quality-gate.sh +10 -0
  33. package/templates/hooks/skill-telemetry.sh +10 -5
  34. package/templates/hooks/skill-tool-telemetry.sh +11 -5
  35. package/templates/mcp/pib-db.json +1 -1
  36. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +4 -5
  37. package/templates/mux/__tests__/worktree-lifecycle.fixture.sh +214 -0
  38. package/templates/mux/__tests__/worktree-lifecycle.test.mjs +44 -0
  39. package/templates/mux/__tests__/worktree-status-clean.fixture.sh +245 -0
  40. package/templates/mux/__tests__/worktree-status-clean.test.mjs +89 -0
  41. package/templates/mux/bin/mux +44 -28
  42. package/templates/mux/config/worktree-cleanup.sh +57 -12
  43. package/templates/mux/config/worktree-dirty-check.sh +72 -2
  44. package/templates/mux/config/worktree-session-health.sh +401 -17
  45. package/templates/rules/enforcement-pipeline.md +0 -12
  46. package/templates/scripts/__tests__/api-usage-idle-gate.test.mjs +81 -0
  47. package/templates/scripts/__tests__/assessment-recalibration.test.mjs +387 -0
  48. package/templates/scripts/__tests__/batch-disposition.test.mjs +19 -0
  49. package/templates/scripts/__tests__/branch-diverged-reconcile.test.mjs +374 -0
  50. package/templates/scripts/__tests__/bsql-loader-verify.test.mjs +47 -0
  51. package/templates/scripts/__tests__/completion-review-reconcile.test.mjs +173 -0
  52. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +12 -4
  53. package/templates/scripts/__tests__/detector-registry.test.mjs +152 -0
  54. package/templates/scripts/__tests__/draft-surfacing.test.mjs +433 -0
  55. package/templates/scripts/__tests__/generated-state-classification.test.mjs +228 -0
  56. package/templates/scripts/__tests__/inbox-assessment.test.mjs +341 -0
  57. package/templates/scripts/__tests__/memory-reachability.test.mjs +150 -0
  58. package/templates/scripts/__tests__/mirror-parity.test.mjs +54 -0
  59. package/templates/scripts/__tests__/resolve-project-slug.test.mjs +186 -0
  60. package/templates/scripts/__tests__/ring1-pib-error-surfacing.test.mjs +69 -0
  61. package/templates/scripts/__tests__/ring1-stale-open-counts.test.mjs +143 -0
  62. package/templates/scripts/__tests__/ring3-attribution.test.mjs +267 -0
  63. package/templates/scripts/__tests__/ring3-completion-filter.test.mjs +176 -0
  64. package/templates/scripts/__tests__/ring3-coverage-debt.test.mjs +377 -0
  65. package/templates/scripts/__tests__/ring3-last-session-pointer.test.mjs +80 -0
  66. package/templates/scripts/__tests__/ring3-thread-capture.test.mjs +213 -0
  67. package/templates/scripts/__tests__/watchtower-status-ring4.test.mjs +89 -0
  68. package/templates/scripts/audit-coherence-check.mjs +127 -0
  69. package/templates/scripts/audit-synth.mjs +117 -0
  70. package/templates/scripts/load-triage-history.js +23 -2
  71. package/templates/scripts/merge-findings.js +13 -3
  72. package/templates/scripts/patterns-scope-check.mjs +122 -0
  73. package/templates/scripts/pib-db-lib.mjs +116 -18
  74. package/templates/scripts/pib-db-mcp-server.mjs +2 -1
  75. package/templates/scripts/pib-db.mjs +2 -1
  76. package/templates/scripts/review-server.mjs +7 -12
  77. package/templates/scripts/triage-server.mjs +7 -8
  78. package/templates/scripts/watchtower-cross-ring-reader.mjs +38 -7
  79. package/templates/scripts/watchtower-inbox-assessment.mjs +857 -0
  80. package/templates/scripts/watchtower-lib.mjs +550 -9
  81. package/templates/scripts/watchtower-queue.mjs +197 -2
  82. package/templates/scripts/watchtower-ring1.mjs +567 -106
  83. package/templates/scripts/watchtower-ring2.mjs +213 -15
  84. package/templates/scripts/watchtower-ring3-close.mjs +679 -90
  85. package/templates/scripts/watchtower-status.sh +17 -0
  86. package/templates/scripts/work-tracker-server.mjs +26 -22
  87. package/templates/skills/audit/SKILL.md +24 -8
  88. package/templates/skills/audit/phases/structural-checks.md +22 -0
  89. package/templates/skills/briefing/SKILL.md +8 -0
  90. package/templates/skills/cabinet-accessibility/SKILL.md +1 -1
  91. package/templates/skills/cabinet-anthropic-insider/SKILL.md +29 -8
  92. package/templates/skills/cabinet-process-therapist/SKILL.md +45 -3
  93. package/templates/skills/cc-publish/SKILL.md +41 -7
  94. package/templates/skills/debrief/phases/audit-pattern-capture.md +13 -0
  95. package/templates/skills/inbox/SKILL.md +124 -6
  96. package/templates/skills/orient/SKILL.md +4 -0
  97. package/templates/skills/pulse/SKILL.md +10 -8
  98. package/templates/skills/session-handoff/SKILL.md +6 -8
  99. package/templates/skills/spring-clean/SKILL.md +1 -1
  100. package/templates/skills/spring-clean/phases/execute-decisions.md +1 -1
  101. package/templates/skills/validate/phases/validators.md +86 -0
  102. package/templates/skills/watchtower/SKILL.md +18 -26
  103. package/templates/watchtower/config.json.template +2 -1
  104. package/templates/workflows/deliberative-audit.js +258 -87
  105. package/templates/workflows/execute-group-complete.js +16 -2
  106. package/templates/workflows/execute-group-implement.js +16 -2
@@ -1,103 +1,23 @@
1
1
  /**
2
2
  * Shared project-identity and memory-dir resolution for CC's
3
- * memory-related tooling. Used by:
4
- * - lib/migrate-from-omega.js (Phase 1 migration)
5
- * - scripts/write-memory-file.mjs (Phase 3a /cc-remember writer)
6
- * - scripts/validate-memory.mjs (Phase 3a validator)
3
+ * memory-related tooling.
7
4
  *
8
- * Worktree-safe: resolves project identity via `git rev-parse
9
- * --git-common-dir`, so callers from a worktree write to the host
10
- * project's memory dir, not a per-worktree split.
11
- */
12
-
13
- 'use strict';
14
-
15
- const fs = require('node:fs');
16
- const path = require('node:path');
17
- const os = require('node:os');
18
- const { execSync } = require('node:child_process');
19
-
20
- /**
21
- * Resolve the absolute path of the current project's primary checkout.
22
- * Worktree-safe via git common-dir; falls back to cwd if not in a repo.
23
- */
24
- function resolveProjectAbsolutePath(cwd = process.cwd()) {
25
- try {
26
- const commonDir = execSync('git rev-parse --git-common-dir', {
27
- cwd,
28
- stdio: ['ignore', 'pipe', 'ignore'],
29
- encoding: 'utf8',
30
- }).trim();
31
- const abs = path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir);
32
- return path.dirname(abs);
33
- } catch {
34
- return path.resolve(cwd);
35
- }
36
- }
37
-
38
- /**
39
- * Project slug — the basename of the project root. Used as a
40
- * human-readable identifier and to match memory keys.
41
- */
42
- function resolveProjectSlug(cwd = process.cwd()) {
43
- return path.basename(resolveProjectAbsolutePath(cwd));
44
- }
45
-
46
- /**
47
- * Convert an absolute path to Claude Code's project-dir slug format
48
- * (dashified). `/Users/x/claude-cabinet` → `-Users-x-claude-cabinet`.
49
- */
50
- function dashifiedSlug(absolutePath) {
51
- return absolutePath.replace(/^\//, '-').replace(/\//g, '-');
52
- }
53
-
54
- /**
55
- * Read settings.json safely; returns {} on absence or parse error.
56
- */
57
- function readSettings(settingsPath) {
58
- if (!fs.existsSync(settingsPath)) return {};
59
- try {
60
- return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
61
- } catch {
62
- return {};
63
- }
64
- }
65
-
66
- /**
67
- * Resolve the memory directory for the current project.
68
- * Honors `autoMemoryDirectory` setting if present in ~/.claude/settings.json.
69
- * Otherwise uses platform default: ~/.claude/projects/<dashified>/memory/.
5
+ * SINGLE SOURCE OF TRUTH: the logic lives in
6
+ * `templates/scripts/project-context.cjs` the copy that ships to
7
+ * consumers (co-located with the memory scripts, which `require` it as a
8
+ * sibling; consumers have no `lib/`). This file is a thin re-export so
9
+ * that CC-source-repo callers — `lib/` code and the memory/orient SKILL.md
10
+ * best-effort `require('./lib/project-context')` — resolve the exact same
11
+ * implementation instead of a divergent hand-kept copy (the "one concept,
12
+ * one place" rule; the byte-identical twins had already drifted once).
70
13
  *
71
- * Rejects literal `~` in autoMemoryDirectory Claude Code does NOT
72
- * expand tilde in settings.json values, and a silent `./~/...` directory
73
- * is a footgun.
14
+ * The re-export direction is safe: lib/ can reach templates/ both in the
15
+ * source repo and in the published npm package (both are in package.json
16
+ * `files`). The manifest-integrity suite guards that the SHIPPED memory
17
+ * scripts never require this `../lib/` path and that the two modules stay
18
+ * identical.
74
19
  */
75
- function resolveMemoryDir(opts = {}) {
76
- const homeDir = opts.homeDir || os.homedir();
77
- const cwd = opts.cwd || process.cwd();
78
- const settingsPath = opts.settingsPath || path.join(homeDir, '.claude', 'settings.json');
79
-
80
- if (opts.memoryDir) return path.resolve(opts.memoryDir);
81
20
 
82
- const settings = readSettings(settingsPath);
83
- if (settings.autoMemoryDirectory) {
84
- const v = settings.autoMemoryDirectory;
85
- if (v.includes('~')) {
86
- throw new Error(
87
- `autoMemoryDirectory in ${settingsPath} contains '~' (literal). Claude Code does not expand tilde. Use an absolute path.`
88
- );
89
- }
90
- return path.resolve(v);
91
- }
92
-
93
- const projectAbs = resolveProjectAbsolutePath(cwd);
94
- return path.join(homeDir, '.claude', 'projects', dashifiedSlug(projectAbs), 'memory');
95
- }
21
+ 'use strict';
96
22
 
97
- module.exports = {
98
- resolveProjectAbsolutePath,
99
- resolveProjectSlug,
100
- resolveMemoryDir,
101
- dashifiedSlug,
102
- readSettings,
103
- };
23
+ module.exports = require('../templates/scripts/project-context.cjs');
@@ -31,7 +31,12 @@ const DEFAULT_HOOKS = {
31
31
  ],
32
32
  },
33
33
  {
34
- matcher: 'pib_create_action',
34
+ // pib tools arrive as `mcp__<server>__<tool>`, so the bare
35
+ // `pib_create_action` spelling never matched and this gate was silently
36
+ // dead since it shipped (audit cabinet-anthropic-insider-0001, run
37
+ // 09-20-25 — proven live: all 14 audit actions were created with no AC
38
+ // check). Match the real MCP tool name.
39
+ matcher: 'mcp__pib-db__pib_create_action',
35
40
  hooks: [
36
41
  {
37
42
  type: 'command',
@@ -40,7 +45,7 @@ const DEFAULT_HOOKS = {
40
45
  ],
41
46
  },
42
47
  {
43
- matcher: 'pib_complete_action',
48
+ matcher: 'mcp__pib-db__pib_complete_action',
44
49
  hooks: [
45
50
  {
46
51
  type: 'command',
@@ -140,6 +145,28 @@ const BASH_COMPRESS_HOOKS = {
140
145
  ],
141
146
  };
142
147
 
148
+ // Maintainer-only ("repo-local") dev hooks — registered by mergeCcDevHooks()
149
+ // ONLY when installing into the Claude Cabinet SOURCE repo (package.json
150
+ // name === 'create-claude-cabinet'), never in a consumer install. node-check
151
+ // mechanizes the CLAUDE.md "node -c after CLI edits" rule (architecture-0004).
152
+ // The hook script is a tracked maintainer tool at scripts/node-check-hook.sh —
153
+ // NOT a MODULES template, so it never ships to consumers. Registered durably
154
+ // (re-added on every dogfood reinstall) rather than hand-added to the gitignored
155
+ // settings.json, which the installer would silently regenerate away.
156
+ const CC_DEV_HOOKS = {
157
+ PostToolUse: [
158
+ {
159
+ matcher: 'Edit|Write',
160
+ hooks: [
161
+ {
162
+ type: 'command',
163
+ command: '$CLAUDE_PROJECT_DIR/scripts/node-check-hook.sh',
164
+ },
165
+ ],
166
+ },
167
+ ],
168
+ };
169
+
143
170
  // Legacy hook script names that should be stripped on any merge.
144
171
  // Centralizes cleanup so a user who skips --migrate-memory but runs
145
172
  // any other CC operation still gets omega-era hooks pruned.
@@ -231,12 +258,20 @@ function mergeSettings(projectDir, { includeDb = true } = {}) {
231
258
  if (!settings.hooks[event]) continue;
232
259
  }
233
260
 
234
- // Retire superseded bare-relative CC hooks so the $CLAUDE_PROJECT_DIR-prefixed
235
- // forms below REPLACE them rather than duplicate. A bare `.claude/hooks/X.sh`
236
- // only resolves when the hook runs from the project root; it errors ("No such
237
- // file or directory") from any other cwd. Exact-match drop on CC-owned
238
- // commands consumer-authored hooks are left intact.
239
- dropHookCommands(settings, ccHookCommands(DEFAULT_HOOKS).map(bareForm));
261
+ // Retire superseded CC hook entries so the DEFAULT_HOOKS forms below REPLACE
262
+ // them rather than duplicate covering TWO kinds of drift:
263
+ // (a) a changed command spelling (bare-relative `.claude/hooks/X.sh`
264
+ // `$CLAUDE_PROJECT_DIR/.claude/hooks/X.sh`, which resolves from any cwd);
265
+ // (b) a changed MATCHER on an unchanged command (the bare `pib_create_action`
266
+ // matcher → `mcp__pib-db__pib_create_action`). The merge loop below
267
+ // dedups by COMMAND alone, so a stale-matcher entry would otherwise
268
+ // survive untouched while the corrected entry was skipped as a dup —
269
+ // exactly how the pib gate hooks stayed dead across reinstalls.
270
+ // Dropping every CURRENT-spelling CC command (and its bare form) forces a
271
+ // clean re-add with the correct current matcher. Exact-match on CC-owned
272
+ // commands only — consumer-authored hooks are left intact.
273
+ const ccCommands = ccHookCommands(DEFAULT_HOOKS);
274
+ dropHookCommands(settings, [...ccCommands, ...ccCommands.map(bareForm)]);
240
275
 
241
276
  // Merge each hook event type from DEFAULT_HOOKS
242
277
  for (const [event, newHooks] of Object.entries(DEFAULT_HOOKS)) {
@@ -400,4 +435,108 @@ function mergeBashCompressHooks(settingsPath) {
400
435
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
401
436
  }
402
437
 
403
- module.exports = { mergeSettings, healUserSettings, mergeWatchtowerHooks, mergeMuxHooks, mergeBashCompressHooks, DEFAULT_HOOKS, WATCHTOWER_HOOKS, MUX_HOOKS, BASH_COMPRESS_HOOKS, LEGACY_HOOK_COMMANDS };
438
+ /**
439
+ * Register maintainer-only ("repo-local") dev hooks — but ONLY when installing
440
+ * into the Claude Cabinet SOURCE repo itself (target package.json
441
+ * name === 'create-claude-cabinet'). A silent no-op in every consumer project.
442
+ * Idempotent + reinstall-safe: drops the CC-owned dev commands (current + bare
443
+ * spellings) then re-adds them, so a reinstall REPLACES rather than duplicates.
444
+ * Returns true if it registered, false if it no-op'd (not the CC repo).
445
+ */
446
+ function mergeCcDevHooks(projectDir) {
447
+ let pkgName = '';
448
+ try {
449
+ pkgName =
450
+ JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8'))
451
+ .name || '';
452
+ } catch {
453
+ /* no/invalid package.json → not the CC repo → no-op */
454
+ }
455
+ if (pkgName !== 'create-claude-cabinet') return false;
456
+
457
+ const settingsDir = path.join(projectDir, '.claude');
458
+ const settingsPath = path.join(settingsDir, 'settings.json');
459
+ if (!fs.existsSync(settingsDir)) fs.mkdirSync(settingsDir, { recursive: true });
460
+
461
+ let settings = {};
462
+ if (fs.existsSync(settingsPath)) {
463
+ try {
464
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
465
+ } catch {
466
+ settings = {};
467
+ }
468
+ }
469
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
470
+ settings = {};
471
+ }
472
+ if (!settings.hooks) settings.hooks = {};
473
+
474
+ // Drop current + bare spellings so a reinstall replaces, not duplicates.
475
+ const devCommands = ccHookCommands(CC_DEV_HOOKS);
476
+ dropHookCommands(settings, [...devCommands, ...devCommands.map(bareForm)]);
477
+
478
+ for (const [event, newHooks] of Object.entries(CC_DEV_HOOKS)) {
479
+ if (!settings.hooks[event]) settings.hooks[event] = [];
480
+ for (const newHook of newHooks) settings.hooks[event].push(newHook);
481
+ }
482
+
483
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
484
+ return true;
485
+ }
486
+
487
+ /**
488
+ * Set `skillOverrides: "name-only"` for the given skills in .claude/settings.json
489
+ * (act:bee0fa16, cabinet-anthropic-insider-0002). Claude Code loads every skill's
490
+ * name+description into context so the model can auto-invoke by trigger keyword,
491
+ * under a budget of ~1% of the context window; when it overflows, the
492
+ * least-invoked skills' descriptions are dropped first — silently stripping the
493
+ * keywords that make them auto-invocable. cabinet-* skills are ~half that
494
+ * listing and are spawned by name (agentType), never auto-invoked by
495
+ * description, so "name-only" costs them nothing while freeing budget for the
496
+ * routing skills (orient/debrief/briefing/inbox/…) that DO need full keywords.
497
+ *
498
+ * Only-if-absent: an existing skillOverrides entry (a user who deliberately set
499
+ * a cabinet skill back to "on") is never clobbered. Idempotent — a reinstall
500
+ * re-applies only for skills with no entry yet (e.g. a newly-added cabinet
501
+ * member). Creates .claude/settings.json if absent. Returns the count added.
502
+ */
503
+ function mergeSkillOverrides(projectDir, skillNames = []) {
504
+ if (!Array.isArray(skillNames) || skillNames.length === 0) return 0;
505
+ const settingsDir = path.join(projectDir, '.claude');
506
+ const settingsPath = path.join(settingsDir, 'settings.json');
507
+ if (!fs.existsSync(settingsDir)) fs.mkdirSync(settingsDir, { recursive: true });
508
+
509
+ let settings = {};
510
+ if (fs.existsSync(settingsPath)) {
511
+ try {
512
+ settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
513
+ } catch {
514
+ settings = {};
515
+ }
516
+ }
517
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
518
+ settings = {};
519
+ }
520
+ if (
521
+ !settings.skillOverrides ||
522
+ typeof settings.skillOverrides !== 'object' ||
523
+ Array.isArray(settings.skillOverrides)
524
+ ) {
525
+ settings.skillOverrides = {};
526
+ }
527
+
528
+ let added = 0;
529
+ for (const name of skillNames) {
530
+ if (!name) continue;
531
+ if (!(name in settings.skillOverrides)) {
532
+ settings.skillOverrides[name] = 'name-only';
533
+ added++;
534
+ }
535
+ }
536
+ if (added > 0) {
537
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
538
+ }
539
+ return added;
540
+ }
541
+
542
+ module.exports = { mergeSettings, healUserSettings, mergeWatchtowerHooks, mergeMuxHooks, mergeBashCompressHooks, mergeCcDevHooks, mergeSkillOverrides, DEFAULT_HOOKS, WATCHTOWER_HOOKS, MUX_HOOKS, BASH_COMPRESS_HOOKS, CC_DEV_HOOKS, LEGACY_HOOK_COMMANDS };
@@ -10,12 +10,17 @@ const { execSync } = require('child_process');
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
12
  const os = require('os');
13
+ const { tarballIsStale, writeSourceHash } = require('./installer-gate');
13
14
 
14
15
  const CC_HOME = path.join(os.homedir(), '.claude-cabinet');
15
16
  const SITE_AUDIT_BASE = path.join(CC_HOME, 'site-audit');
16
17
 
17
18
  function setupSiteAuditRuntime(opts = {}) {
18
19
  const dryRun = !!opts.dryRun;
20
+ const exec = opts.exec || execSync; // injectable for tests
21
+ // The heavy extract+install step (npm install, puppeteer, chromedriver) is
22
+ // injectable so the pack/skip/re-pack gate can be exercised in tests.
23
+ const installTarball = opts.installTarball || extractAndInstall;
19
24
  const runtimeSourceDir =
20
25
  opts.runtimeSourceDir || path.resolve(__dirname, '..', 'templates', 'site-audit-runtime');
21
26
 
@@ -43,20 +48,28 @@ function setupSiteAuditRuntime(opts = {}) {
43
48
  return { installPath: installDir, version, status: 'dry-run', results };
44
49
  }
45
50
 
46
- if (fs.existsSync(tarballPath) && fs.statSync(tarballPath).size > 1024) {
47
- // Tarball exists ensure it's extracted and installed (may have been
48
- // packed but not installed on a prior run)
49
- extractAndInstall(path.join(SITE_AUDIT_BASE, version), tarballPath, results);
51
+ // Idempotency + equal-version fall-through: reuse the tarball ONLY when the
52
+ // runtime SOURCE content is unchanged (hash sidecar matches). A same-version
53
+ // reinstall carrying a template edit re-packs instead of hard-skipping (the
54
+ // tarball parallel to act:df1a1dc3; technical-debt-0003).
55
+ const staleness = tarballIsStale({ tarballPath, installDir, runtimeSourceDir });
56
+ if (!staleness.stale) {
57
+ // Tarball exists and source is unchanged — ensure it's extracted and
58
+ // installed (may have been packed but not installed on a prior run)
59
+ installTarball(path.join(SITE_AUDIT_BASE, version), tarballPath, results);
50
60
  updateCurrentBinSymlink(SITE_AUDIT_BASE, version);
51
61
  writeVersionPointer(versionPointer, version);
52
- results.push(`@claude-cabinet/site-audit@${version} already installed (${tarballPath})`);
62
+ results.push(`@claude-cabinet/site-audit@${version} already installed, source unchanged (${tarballPath})`);
53
63
  return { installPath: installDir, version, status: 'skipped', results };
54
64
  }
65
+ if (staleness.reason === 'source-changed' || staleness.reason === 'no-source-hash') {
66
+ results.push(`@claude-cabinet/site-audit@${version} runtime source changed — re-packing`);
67
+ }
55
68
 
56
69
  if (fs.existsSync(tarballPath)) fs.unlinkSync(tarballPath);
57
70
 
58
71
  fs.mkdirSync(installDir, { recursive: true });
59
- const packStdout = execSync(`npm pack --silent --pack-destination "${installDir}"`, {
72
+ const packStdout = exec(`npm pack --silent --pack-destination "${installDir}"`, {
60
73
  cwd: runtimeSourceDir,
61
74
  encoding: 'utf8',
62
75
  }).trim();
@@ -75,8 +88,11 @@ function setupSiteAuditRuntime(opts = {}) {
75
88
  }
76
89
 
77
90
  // Extract and install so the runtime is runnable (not just a tarball)
78
- extractAndInstall(path.join(SITE_AUDIT_BASE, version), tarballPath, results);
91
+ installTarball(path.join(SITE_AUDIT_BASE, version), tarballPath, results);
79
92
 
93
+ // Record the runtime-source hash so the next same-version reinstall can
94
+ // skip when unchanged and re-pack when it changed.
95
+ writeSourceHash(installDir, staleness.sourceHash);
80
96
  writeVersionPointer(versionPointer, version);
81
97
  updateCurrentBinSymlink(SITE_AUDIT_BASE, version);
82
98
  results.push(`Installed @claude-cabinet/site-audit@${version}`);
@@ -15,6 +15,7 @@ const { execSync } = require('child_process');
15
15
  const fs = require('fs');
16
16
  const path = require('path');
17
17
  const os = require('os');
18
+ const { tarballIsStale, writeSourceHash } = require('./installer-gate');
18
19
 
19
20
  const CC_HOME = path.join(os.homedir(), '.claude-cabinet');
20
21
  const VERIFY_BASE = path.join(CC_HOME, 'verify');
@@ -40,6 +41,7 @@ const VERIFY_BASE = path.join(CC_HOME, 'verify');
40
41
  */
41
42
  function setupVerifyRuntime(opts = {}) {
42
43
  const dryRun = !!opts.dryRun;
44
+ const exec = opts.exec || execSync; // injectable for tests
43
45
  const runtimeSourceDir =
44
46
  opts.runtimeSourceDir || path.resolve(__dirname, '..', 'templates', 'verify-runtime');
45
47
 
@@ -72,21 +74,27 @@ function setupVerifyRuntime(opts = {}) {
72
74
  return { installPath: installDir, version, status: 'dry-run', results };
73
75
  }
74
76
 
75
- // 3. Idempotency check. Existence alone is not enough — an earlier
76
- // stub-touch (e.g., a test fixture) can leave a 0-byte file at the
77
- // tarball path. npm install on the consumer side then fails with
78
- // ENODATA / TAR_BAD_ARCHIVE. Validate the file has non-trivial size
79
- // before treating it as installed.
80
- if (fs.existsSync(tarballPath) && fs.statSync(tarballPath).size > 1024) {
81
- results.push(`cabinet-verify@${version} already installed (${tarballPath})`);
77
+ // 3. Idempotency + equal-version fall-through. A present, non-truncated
78
+ // tarball is reused ONLY when the runtime SOURCE content is unchanged (its
79
+ // hash sidecar still matches). A same-version reinstall that carries a
80
+ // template edit re-packs instead of hard-skipping the tarball parallel
81
+ // to the per-file-manifest fix (act:df1a1dc3; technical-debt-0003). A
82
+ // truncated stub (earlier fixture touch → ENODATA/TAR_BAD_ARCHIVE on the
83
+ // consumer) also counts as stale.
84
+ const staleness = tarballIsStale({ tarballPath, installDir, runtimeSourceDir });
85
+ if (!staleness.stale) {
86
+ results.push(`cabinet-verify@${version} already installed, source unchanged (${tarballPath})`);
82
87
  // Even on skip, ensure the VERSION pointer is up-to-date so a later
83
88
  // version downgrade doesn't leave a stale pointer behind.
84
89
  writeVersionPointer(versionPointer, version);
85
90
  results.push(`Updated VERSION pointer: ${versionPointer}`);
86
91
  return { installPath: installDir, version, status: 'skipped', results };
87
92
  }
93
+ if (staleness.reason === 'source-changed' || staleness.reason === 'no-source-hash') {
94
+ results.push(`cabinet-verify@${version} runtime source changed — re-packing`);
95
+ }
88
96
 
89
- // Remove a 0-byte stub before re-packing so the move below doesn't
97
+ // Remove a stale/stub tarball before re-packing so the move below doesn't
90
98
  // hit a "file exists" surprise.
91
99
  if (fs.existsSync(tarballPath)) {
92
100
  fs.unlinkSync(tarballPath);
@@ -96,7 +104,7 @@ function setupVerifyRuntime(opts = {}) {
96
104
  fs.mkdirSync(installDir, { recursive: true });
97
105
  // Run npm pack in the runtime source dir. --pack-destination writes
98
106
  // the tarball directly to installDir without an intermediate cwd move.
99
- const packStdout = execSync(`npm pack --silent --pack-destination "${installDir}"`, {
107
+ const packStdout = exec(`npm pack --silent --pack-destination "${installDir}"`, {
100
108
  cwd: runtimeSourceDir,
101
109
  encoding: 'utf8',
102
110
  }).trim();
@@ -122,6 +130,9 @@ function setupVerifyRuntime(opts = {}) {
122
130
  );
123
131
  }
124
132
 
133
+ // Record the runtime-source hash so the next same-version reinstall can
134
+ // skip when the source is unchanged, and re-pack when it changed.
135
+ writeSourceHash(installDir, staleness.sourceHash);
125
136
  results.push(`Installed cabinet-verify@${version} to ${tarballPath}`);
126
137
 
127
138
  // 5. Write VERSION pointer
@@ -34,7 +34,7 @@
34
34
  const fs = require('fs');
35
35
  const path = require('path');
36
36
  const os = require('os');
37
- const crypto = require('crypto');
37
+ const { sha256 } = require('./installer-gate');
38
38
 
39
39
  const CC_HOME = path.join(os.homedir(), '.claude-cabinet');
40
40
  const GLOBAL_MANIFEST_PATH = path.join(CC_HOME, 'global-manifest.json');
@@ -111,10 +111,6 @@ function buildManagedFiles() {
111
111
 
112
112
  const MANAGED_FILES = buildManagedFiles();
113
113
 
114
- function sha256(content) {
115
- return crypto.createHash('sha256').update(content).digest('hex');
116
- }
117
-
118
114
  function readGlobalManifest() {
119
115
  if (!fs.existsSync(GLOBAL_MANIFEST_PATH)) return { files: {} };
120
116
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.47.0",
3
+ "version": "0.49.0",
4
4
  "description": "Claude Cabinet — opinionated process scaffolding for Claude Code projects",
5
5
  "bin": {
6
6
  "create-claude-cabinet": "bin/create-claude-cabinet.js"