create-claude-cabinet 0.46.0 → 0.47.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 (139) hide show
  1. package/README.md +2 -6
  2. package/lib/cli.js +348 -91
  3. package/lib/copy.js +108 -6
  4. package/lib/db-setup.js +122 -17
  5. package/lib/metadata.js +51 -2
  6. package/lib/settings-merge.js +52 -10
  7. package/lib/watchtower-setup.js +22 -2
  8. package/package.json +1 -1
  9. package/templates/cabinet/advisories-state-schema.md +1 -1
  10. package/templates/cabinet/checklist-stats-schema.md +15 -1
  11. package/templates/cabinet/memory-lifecycle-contract.md +135 -0
  12. package/templates/cabinet/pib-db-access.md +13 -0
  13. package/templates/cabinet/watchtower-contracts.md +414 -0
  14. package/templates/cabinet/worktree-invocation-contract.md +87 -0
  15. package/templates/engagement/OVERVIEW.md +12 -0
  16. package/templates/engagement/__tests__/engagement.test.mjs +177 -2
  17. package/templates/engagement/__tests__/invoice-doc.test.mjs +195 -0
  18. package/templates/engagement/engagement-preview.mjs +100 -0
  19. package/templates/engagement/engagement-schema.md +240 -0
  20. package/templates/engagement/engagement.mjs +391 -11
  21. package/templates/engagement/pib-db-patches/pib-db-mcp-server.mjs +5 -44
  22. package/templates/engagement/pib-db-patches/pib-db.mjs +5 -2
  23. package/templates/hooks/action-completion-gate.sh +9 -3
  24. package/templates/hooks/memory-index-guard.sh +17 -11
  25. package/templates/hooks/watchtower-session-start.sh +24 -2
  26. package/templates/mcp/pib-db.json +1 -4
  27. package/templates/mux/bin/mux +8 -1
  28. package/templates/mux/config/help.txt +1 -0
  29. package/templates/rules/acknowledge-when-corrected.md +33 -0
  30. package/templates/rules/maintainability.md +11 -0
  31. package/templates/rules/memory-capture.md +26 -4
  32. package/templates/rules/plan-before-bulk-or-irreversible-actions.md +48 -0
  33. package/templates/rules/verify-before-asserting.md +79 -0
  34. package/templates/scripts/__tests__/ahead-check-origin.test.mjs +355 -0
  35. package/templates/scripts/__tests__/batch-disposition.test.mjs +98 -0
  36. package/templates/scripts/__tests__/claude-churn-authored.test.mjs +90 -0
  37. package/templates/scripts/__tests__/cross-ring-reader.test.mjs +505 -0
  38. package/templates/scripts/__tests__/dx-captures-briefing.test.mjs +169 -0
  39. package/templates/scripts/__tests__/hook-runner.test.mjs +247 -0
  40. package/templates/scripts/__tests__/narrative-corpus.test.mjs +247 -0
  41. package/templates/scripts/__tests__/phase-shim.test.mjs +112 -0
  42. package/templates/scripts/__tests__/qa-handoff-merge-state.test.mjs +162 -0
  43. package/templates/scripts/__tests__/resolve-cli.test.mjs +314 -0
  44. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +120 -0
  45. package/templates/scripts/__tests__/ring1-content-detector.test.mjs +168 -0
  46. package/templates/scripts/__tests__/ring1-flagged-actions.test.mjs +135 -0
  47. package/templates/scripts/__tests__/ring1-script-drift.test.mjs +126 -0
  48. package/templates/scripts/__tests__/ring2-queue-reader.test.mjs +80 -0
  49. package/templates/scripts/__tests__/ring2-recall-canary.test.mjs +150 -0
  50. package/templates/scripts/__tests__/ring2-roster-review.test.mjs +311 -0
  51. package/templates/scripts/__tests__/ring3-chunk-merge.test.mjs +122 -0
  52. package/templates/scripts/__tests__/ring3-close-lenses.test.mjs +359 -0
  53. package/templates/scripts/__tests__/ring3-dedup.test.mjs +92 -12
  54. package/templates/scripts/__tests__/ring3-memory-titles.test.mjs +187 -0
  55. package/templates/scripts/__tests__/ring3-novelty-rescue.test.mjs +148 -0
  56. package/templates/scripts/__tests__/ring3-recent-slice.test.mjs +101 -0
  57. package/templates/scripts/__tests__/ring4-reconcile.test.mjs +393 -0
  58. package/templates/scripts/__tests__/shared-thread-reader.test.mjs +187 -0
  59. package/templates/scripts/__tests__/suppression-ledger.test.mjs +148 -0
  60. package/templates/scripts/__tests__/verify-backfill.test.mjs +103 -0
  61. package/templates/scripts/__tests__/verify-coverage.test.mjs +80 -0
  62. package/templates/scripts/__tests__/watchtower-snapshot.test.mjs +261 -0
  63. package/templates/scripts/__tests__/watchtower-sync.test.mjs +311 -0
  64. package/templates/scripts/load-triage-history.js +5 -2
  65. package/templates/scripts/pib-db-mcp-server.mjs +5 -44
  66. package/templates/scripts/pib-db-path.mjs +61 -0
  67. package/templates/scripts/pib-db.mjs +5 -2
  68. package/templates/scripts/validate-memory.mjs +214 -16
  69. package/templates/scripts/watchtower-advisories.mjs +7 -3
  70. package/templates/scripts/watchtower-build-context.mjs +192 -8
  71. package/templates/scripts/watchtower-cross-ring-reader.mjs +700 -0
  72. package/templates/scripts/watchtower-hook-runner.mjs +422 -0
  73. package/templates/scripts/watchtower-lib.mjs +273 -4
  74. package/templates/scripts/watchtower-narrative-corpus.mjs +385 -0
  75. package/templates/scripts/watchtower-phase-shim.mjs +171 -0
  76. package/templates/scripts/watchtower-queue.mjs +341 -3
  77. package/templates/scripts/watchtower-ring1.mjs +405 -45
  78. package/templates/scripts/watchtower-ring2.mjs +710 -68
  79. package/templates/scripts/watchtower-ring3-close.mjs +988 -147
  80. package/templates/scripts/watchtower-ring4-runner.sh +85 -0
  81. package/templates/scripts/watchtower-ring4.mjs +753 -0
  82. package/templates/scripts/watchtower-routines.mjs +1 -1
  83. package/templates/scripts/watchtower-snapshot.mjs +452 -0
  84. package/templates/scripts/watchtower-sync.mjs +393 -0
  85. package/templates/skills/briefing/SKILL.md +281 -41
  86. package/templates/skills/cabinet-process-therapist/SKILL.md +28 -0
  87. package/templates/skills/cabinet-security/SKILL.md +11 -0
  88. package/templates/skills/catch-up/SKILL.md +113 -0
  89. package/templates/skills/cc-publish/SKILL.md +53 -16
  90. package/templates/skills/cc-remember/SKILL.md +45 -0
  91. package/templates/skills/close/SKILL.md +107 -0
  92. package/templates/skills/collab-client/SKILL.md +22 -5
  93. package/templates/skills/collab-consultant/SKILL.md +110 -2
  94. package/templates/skills/debrief/SKILL.md +21 -4
  95. package/templates/skills/debrief-classic/SKILL.md +696 -0
  96. package/templates/skills/debrief-classic/calibration.md +44 -0
  97. package/templates/skills/debrief-classic/phases/audit-pattern-capture.md +78 -0
  98. package/templates/skills/debrief-classic/phases/auto-maintenance.md +48 -0
  99. package/templates/skills/debrief-classic/phases/checklist-feedback.md +123 -0
  100. package/templates/skills/debrief-classic/phases/close-work.md +163 -0
  101. package/templates/skills/debrief-classic/phases/health-checks.md +54 -0
  102. package/templates/skills/debrief-classic/phases/inventory.md +40 -0
  103. package/templates/skills/debrief-classic/phases/loose-ends.md +52 -0
  104. package/templates/skills/debrief-classic/phases/methodology-capture.md +223 -0
  105. package/templates/skills/debrief-classic/phases/qa-handoff-sweep.md +78 -0
  106. package/templates/skills/debrief-classic/phases/record-lessons.md +177 -0
  107. package/templates/skills/debrief-classic/phases/report.md +59 -0
  108. package/templates/skills/debrief-classic/phases/update-state.md +48 -0
  109. package/templates/skills/debrief-classic/phases/upstream-feedback.md +185 -0
  110. package/templates/skills/debrief-classic/phases/verify-coverage.md +101 -0
  111. package/templates/skills/execute/SKILL.md +71 -6
  112. package/templates/skills/execute/phases/commit-and-deploy.md +8 -0
  113. package/templates/skills/execute-group/SKILL.md +23 -26
  114. package/templates/skills/generate-plan-groups/SKILL.md +20 -0
  115. package/templates/skills/inbox/SKILL.md +84 -28
  116. package/templates/skills/memory/SKILL.md +22 -6
  117. package/templates/skills/orient/SKILL.md +71 -5
  118. package/templates/skills/orient-classic/SKILL.md +770 -0
  119. package/templates/skills/orient-classic/phases/auto-maintenance.md +52 -0
  120. package/templates/skills/orient-classic/phases/briefing.md +53 -0
  121. package/templates/skills/orient-classic/phases/cabinet.md +46 -0
  122. package/templates/skills/orient-classic/phases/checklist-status.md +54 -0
  123. package/templates/skills/orient-classic/phases/context.md +88 -0
  124. package/templates/skills/orient-classic/phases/data-sync.md +35 -0
  125. package/templates/skills/orient-classic/phases/deferred-check.md +55 -0
  126. package/templates/skills/orient-classic/phases/dx-captures.md +53 -0
  127. package/templates/skills/orient-classic/phases/health-checks.md +50 -0
  128. package/templates/skills/orient-classic/phases/verify-backfill.md +109 -0
  129. package/templates/skills/orient-classic/phases/work-scan.md +69 -0
  130. package/templates/skills/qa-drain/SKILL.md +38 -0
  131. package/templates/skills/qa-handoff/SKILL.md +287 -46
  132. package/templates/skills/setup-accounts/SKILL.md +38 -16
  133. package/templates/skills/threads/SKILL.md +4 -0
  134. package/templates/skills/validate/phases/validators.md +41 -0
  135. package/templates/skills/watchtower/SKILL.md +160 -8
  136. package/templates/verify-runtime/CONVENTIONS.md +9 -0
  137. package/templates/verify-runtime/README.md +37 -0
  138. package/templates/watchtower/config.json.template +8 -2
  139. package/templates/workflows/execute-group-complete.js +10 -1
package/lib/copy.js CHANGED
@@ -50,17 +50,71 @@ function recordSkip(manifest, key, { identical = false, incomingHash = null } =
50
50
  }
51
51
  }
52
52
 
53
+ /**
54
+ * Files shipped as STARTER content but owned by the project the moment they
55
+ * land. The installer seeds them if absent, NEVER overwrites them, and NEVER
56
+ * records them in the manifest. Manifest presence means "CC-owned", which has
57
+ * two consequences a project-owned file must avoid: cc-upstream-guard blocks
58
+ * edits to it, and a `--yes` reinstall silently overwrites it. The recordSkip
59
+ * "identical → keep tracked" rule is content-based and so still recorded an
60
+ * UNEDITED seed in the manifest — which false-blocked the very first edit (a
61
+ * project-owned file is unedited exactly until someone edits it). These files
62
+ * are project-owned by DECLARATION, not by content divergence.
63
+ *
64
+ * Keyed by basename: there is one such seed today and the src→dest relPath
65
+ * mapping varies by call site, so a basename match is the robust check.
66
+ * `directives-project.yaml`'s own header says it is "NOT overwritten by the
67
+ * installer ... extend upstream members with project-specific mandates" —
68
+ * recording it as CC-owned false-blocked the exact edits it exists to receive
69
+ * (field incident 2026-06-17, act:2001bf54).
70
+ */
71
+ const PROJECT_OWNED_SEED_BASENAMES = new Set(['directives-project.yaml']);
72
+ function isProjectOwnedSeed(relPath) {
73
+ return PROJECT_OWNED_SEED_BASENAMES.has(path.basename(relPath));
74
+ }
75
+
76
+ /**
77
+ * The canonical FREEZE rule for an EXISTING file in a frozen module that
78
+ * differs from the incoming template (act:c1013967). Shared by both copy
79
+ * paths — copy.js's directory walk and cli.js's single-file branch — so the
80
+ * "third ownership state" is defined in exactly one place (the recordSkip
81
+ * precedent).
82
+ *
83
+ * Freeze is "CC-owned but temporarily pinned": the file is NOT overwritten,
84
+ * and — crucially — only a file that is ALREADY CC-owned (present in the prior
85
+ * manifest) is RETAINED in the new manifest, at its ON-DISK hash. Two reasons
86
+ * the on-disk hash is the only correct choice:
87
+ * - It keeps the file PRESENT in the manifest, so the upstream-cleanup loop
88
+ * never lists it for deletion (a frozen file dropped from allManifest would
89
+ * be deleted — the exact opposite of freeze).
90
+ * - On-disk == recorded keeps the value-comparison consumers green:
91
+ * cc-drift-check sees no drift, reset.js classifies it unmodified.
92
+ *
93
+ * A frozen-module file that differs but is NOT already CC-owned is
94
+ * project-owned (project-created or a kept/edited file deliberately omitted
95
+ * from the manifest). Freeze adds nothing for it — it's already never
96
+ * overwritten — so it must FALL THROUGH to the normal skip-and-omit path.
97
+ * RETAINING it would adopt a project-owned file into the manifest and clobber
98
+ * it on the unfreeze reinstall (violates the recordSkip invariant above).
99
+ *
100
+ * @returns {{manifestHash: string}} retain at on-disk hash (CC-owned)
101
+ * | {{omit: true}} fall through to skip-and-omit (project-owned)
102
+ */
103
+ function freezeRetain({ existing, isCcOwned }) {
104
+ return isCcOwned ? { manifestHash: hashContent(existing) } : { omit: true };
105
+ }
106
+
53
107
  /**
54
108
  * Recursively copy files from src to dest, surfacing conflicts.
55
109
  * Returns { copied: string[], skipped: string[], overwritten: string[] }
56
110
  */
57
- async function copyTemplates(src, dest, { dryRun = false, skipConflicts = false, skipPhases = false, projectRoot = null, existingManifest = {}, instructionPhases = new Set() } = {}) {
58
- const results = { copied: [], skipped: [], overwritten: [], manifest: {} };
59
- await walkAndCopy(src, dest, src, results, dryRun, skipConflicts, skipPhases, projectRoot, existingManifest, instructionPhases);
111
+ async function copyTemplates(src, dest, { dryRun = false, skipConflicts = false, skipPhases = false, projectRoot = null, existingManifest = {}, instructionPhases = new Set(), frozen = false } = {}) {
112
+ const results = { copied: [], skipped: [], overwritten: [], manifest: {}, frozen: [] };
113
+ await walkAndCopy(src, dest, src, results, dryRun, skipConflicts, skipPhases, projectRoot, existingManifest, instructionPhases, frozen);
60
114
  return results;
61
115
  }
62
116
 
63
- async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipConflicts, skipPhases, projectRoot, existingManifest, instructionPhases) {
117
+ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipConflicts, skipPhases, projectRoot, existingManifest, instructionPhases, frozen) {
64
118
  const entries = fs.readdirSync(currentSrc, { withFileTypes: true });
65
119
 
66
120
  for (const entry of entries) {
@@ -80,11 +134,32 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
80
134
  if (!dryRun && !fs.existsSync(destPath)) {
81
135
  fs.mkdirSync(destPath, { recursive: true });
82
136
  }
83
- await walkAndCopy(srcRoot, destRoot, srcPath, results, dryRun, skipConflicts, skipPhases, projectRoot, existingManifest, instructionPhases);
137
+ await walkAndCopy(srcRoot, destRoot, srcPath, results, dryRun, skipConflicts, skipPhases, projectRoot, existingManifest, instructionPhases, frozen);
84
138
  } else {
85
139
  const incoming = fs.readFileSync(srcPath, 'utf8');
86
140
  const incomingHash = hashContent(incoming);
87
141
 
142
+ // Project-owned seed (act:2001bf54): seed if absent, never overwrite,
143
+ // and ALWAYS omit from the manifest — recording it would let
144
+ // cc-upstream-guard block edits and a --yes reinstall clobber the
145
+ // consumer's content. Runs before any existsSync branch so neither the
146
+ // byte-identical path (which would re-record an unedited seed) nor the
147
+ // manifest-driven overwrite path can reach it.
148
+ if (isProjectOwnedSeed(relPath)) {
149
+ if (!fs.existsSync(destPath)) {
150
+ if (!dryRun) {
151
+ const dir = path.dirname(destPath);
152
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
153
+ fs.copyFileSync(srcPath, destPath);
154
+ }
155
+ results.copied.push(relPath);
156
+ } else {
157
+ results.skipped.push(relPath);
158
+ }
159
+ recordSkip(results.manifest, relPath); // omit — project-owned by declaration
160
+ continue;
161
+ }
162
+
88
163
  if (fs.existsSync(destPath)) {
89
164
  const existing = fs.readFileSync(destPath, 'utf8');
90
165
 
@@ -112,6 +187,24 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
112
187
  continue;
113
188
  }
114
189
 
190
+ // Frozen module (act:c1013967): the file differs and is past the
191
+ // identical + seed + customized-phase guards, so it is a CC-managed
192
+ // file that WOULD be overwritten. Hold it instead. Only a file already
193
+ // CC-owned (in the prior manifest) is retained at its on-disk hash; a
194
+ // project-owned file falls through to the normal skip-and-omit path.
195
+ if (frozen) {
196
+ const manifestKey = projectRoot ? path.relative(projectRoot, destPath) : relPath;
197
+ const decision = freezeRetain({ existing, isCcOwned: Boolean(existingManifest[manifestKey]) });
198
+ if (decision.manifestHash) {
199
+ results.skipped.push(relPath);
200
+ results.frozen.push(relPath);
201
+ results.manifest[relPath] = decision.manifestHash;
202
+ console.log(` ❄ Frozen (held): ${displayPath}`);
203
+ continue;
204
+ }
205
+ // decision.omit → project-owned; fall through.
206
+ }
207
+
115
208
  if (skipConflicts) {
116
209
  // Check if file is upstream-managed (in the old manifest).
117
210
  // If so, overwrite — it can't be customized (hook enforces this).
@@ -177,6 +270,15 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
177
270
  recordSkip(results.manifest, relPath);
178
271
  }
179
272
  } else {
273
+ // New file in a frozen module: hold it back so the on-disk module
274
+ // stays internally consistent at its pinned version. Report only — no
275
+ // manifest entry (there's no on-disk content to hash; a phantom entry
276
+ // would break cc-drift-check/cleanup). On unfreeze this branch creates it.
277
+ if (frozen) {
278
+ results.frozen.push(relPath);
279
+ console.log(` ❄ Frozen (held new): ${displayPath}`);
280
+ continue;
281
+ }
180
282
  if (!dryRun) {
181
283
  const dir = path.dirname(destPath);
182
284
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -215,4 +317,4 @@ function showDiff(existing, incoming, relPath) {
215
317
  console.log('');
216
318
  }
217
319
 
218
- module.exports = { copyTemplates, recordSkip };
320
+ module.exports = { copyTemplates, recordSkip, isProjectOwnedSeed, freezeRetain };
package/lib/db-setup.js CHANGED
@@ -1,46 +1,151 @@
1
1
  const { execSync } = require('child_process');
2
2
  const fs = require('fs');
3
3
  const path = require('path');
4
+ const metadata = require('./metadata');
5
+
6
+ // Substrings that identify a better-sqlite3 native-module ABI mismatch —
7
+ // the compiled .node binary was built under a different Node than the one
8
+ // running `pib-db.mjs init`. Node surfaces this as an ERR_DLOPEN_FAILED
9
+ // with a NODE_MODULE_VERSION line; field reports during consumer walks
10
+ // also showed the shorter "native module version mismatch" phrasing.
11
+ const ABI_MISMATCH_SIGNATURES = [
12
+ 'NODE_MODULE_VERSION',
13
+ 'different Node.js version',
14
+ 'was compiled against',
15
+ 'native module version mismatch',
16
+ 'ERR_DLOPEN_FAILED',
17
+ ];
18
+
19
+ function isAbiMismatch(text) {
20
+ const s = String(text || '');
21
+ return ABI_MISMATCH_SIGNATURES.some((sig) => s.includes(sig));
22
+ }
23
+
24
+ // Read the bundled pib-db schema version from the (already-copied)
25
+ // pib-db-lib.mjs as TEXT — never by importing it, so learning the version
26
+ // does not itself load better-sqlite3. Returns a number, or null when the
27
+ // file is missing or the constant can't be parsed.
28
+ function readBundledSchemaVersion(scriptsDir) {
29
+ try {
30
+ const src = fs.readFileSync(path.join(scriptsDir, 'pib-db-lib.mjs'), 'utf8');
31
+ const m = src.match(/SCHEMA_VERSION\s*=\s*(\d+)/);
32
+ return m ? Number(m[1]) : null;
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ // Lazy-init gate: a reinstall whose recorded schema version already matches
39
+ // the bundled one (and whose pib.db exists) needs no migration, so the init
40
+ // step can be skipped entirely — and a skipped init never loads the
41
+ // (possibly stale) native module. Conservative by construction: any
42
+ // missing/unequal input returns false, so we only skip when certain there
43
+ // is nothing to migrate.
44
+ function shouldSkipInit({ dbExists, recordedVersion, bundledVersion }) {
45
+ return Boolean(
46
+ dbExists &&
47
+ bundledVersion != null &&
48
+ recordedVersion != null &&
49
+ recordedVersion === bundledVersion
50
+ );
51
+ }
4
52
 
5
53
  /**
6
54
  * Set up the PIB database in the target project.
7
- * - Copies pib-db.mjs and schema to scripts/
55
+ * - Copies are already in place (scripts/ copied by the main install loop)
8
56
  * - Runs npm init if no package.json
9
- * - Installs better-sqlite3
10
- * - Runs pib-db init
57
+ * - Installs better-sqlite3 if absent
58
+ * - Initializes/migrates the database, UNLESS the schema is already current
59
+ * (lazy-init), and self-heals an ABI-mismatched native binary.
60
+ *
61
+ * Returns { results: string[], schemaVersion: number|null }. schemaVersion
62
+ * is the applied (or confirmed-current) schema version when init succeeded
63
+ * or was correctly skipped, and null when init could not complete (ABI
64
+ * mismatch with no working rebuild) — so the caller never records a false
65
+ * "schema current" claim in .ccrc.json.
66
+ *
67
+ * `opts.exec` and `opts.log` are injectable for testing; they default to
68
+ * execSync and console.log.
11
69
  */
12
- function setupDb(projectDir) {
70
+ function setupDb(projectDir, opts = {}) {
71
+ const exec = opts.exec || ((cmd, o) => execSync(cmd, o));
72
+ const log = opts.log || console.log;
13
73
  const scriptsDir = path.join(projectDir, 'scripts');
14
74
  const results = [];
15
75
 
16
76
  // Check if package.json exists; if not, init
17
77
  const pkgPath = path.join(projectDir, 'package.json');
18
78
  if (!fs.existsSync(pkgPath)) {
19
- console.log(' Initializing package.json...');
20
- execSync('npm init -y', { cwd: projectDir, stdio: 'pipe' });
79
+ log(' Initializing package.json...');
80
+ exec('npm init -y', { cwd: projectDir, stdio: 'pipe' });
21
81
  results.push('Created package.json');
22
82
  }
23
83
 
24
84
  // pib-db.mjs uses ESM imports — .mjs extension lets Node treat it as ESM
25
85
  // regardless of the project's package.json type field
86
+ const dbScript = path.join(scriptsDir, 'pib-db.mjs');
87
+ const dbPath = path.join(projectDir, 'pib.db');
88
+ const bundledVersion = readBundledSchemaVersion(scriptsDir);
89
+
90
+ // Lazy-init: skip when the schema is already current. This is the common
91
+ // reinstall case; skipping means a no-migration release never loads
92
+ // better-sqlite3, so a stale native binary can't wall the install with an
93
+ // ABI-mismatch error it never needed to risk.
94
+ const existingMeta = metadata.read(projectDir) || {};
95
+ if (
96
+ shouldSkipInit({
97
+ dbExists: fs.existsSync(dbPath),
98
+ recordedVersion: existingMeta.dbSchemaVersion,
99
+ bundledVersion,
100
+ })
101
+ ) {
102
+ results.push(`Database schema already current (v${bundledVersion}) — skipped init`);
103
+ return { results, schemaVersion: bundledVersion };
104
+ }
26
105
 
27
- // Install better-sqlite3 if not already installed
106
+ // Install better-sqlite3 if not already installed. A pre-existing but
107
+ // stale binary is left for the rebuild path below to heal — a plain
108
+ // reinstall wouldn't change an already-satisfied semver.
28
109
  const nodeModules = path.join(projectDir, 'node_modules', 'better-sqlite3');
29
110
  if (!fs.existsSync(nodeModules)) {
30
- console.log(' Installing better-sqlite3...');
31
- execSync('npm install better-sqlite3', { cwd: projectDir, stdio: 'pipe' });
111
+ log(' Installing better-sqlite3...');
112
+ exec('npm install better-sqlite3', { cwd: projectDir, stdio: 'pipe' });
32
113
  results.push('Installed better-sqlite3');
33
114
  }
34
115
 
35
- // Initialize the database
36
- const dbScript = path.join(scriptsDir, 'pib-db.mjs');
37
- if (fs.existsSync(dbScript)) {
38
- console.log(' Initializing database...');
39
- execSync(`node ${dbScript} init`, { cwd: projectDir, stdio: 'pipe' });
40
- results.push('Initialized pib.db');
116
+ if (!fs.existsSync(dbScript)) {
117
+ return { results, schemaVersion: null };
41
118
  }
42
119
 
43
- return results;
120
+ log(' Initializing database...');
121
+ try {
122
+ exec(`node ${dbScript} init`, { cwd: projectDir, stdio: 'pipe' });
123
+ results.push('Initialized pib.db');
124
+ return { results, schemaVersion: bundledVersion };
125
+ } catch (err) {
126
+ const detail = `${String(err.stderr || '')}\n${String(err.message || '')}`;
127
+ // Non-ABI failures keep their old handling — propagate to the caller's
128
+ // existing "Database setup failed" catch.
129
+ if (!isAbiMismatch(detail)) throw err;
130
+
131
+ // ABI mismatch: rebuild the native binary once and retry. If that
132
+ // works, the install self-heals. If it doesn't, fall back to a single
133
+ // actionable line — never a stack trace, never a silent skip.
134
+ log(' better-sqlite3 built for a different Node — rebuilding once and retrying...');
135
+ try {
136
+ exec('npm rebuild better-sqlite3', { cwd: projectDir, stdio: 'pipe' });
137
+ exec(`node ${dbScript} init`, { cwd: projectDir, stdio: 'pipe' });
138
+ results.push('Rebuilt better-sqlite3 (ABI mismatch) and initialized pib.db');
139
+ return { results, schemaVersion: bundledVersion };
140
+ } catch {
141
+ results.push(
142
+ 'DB step skipped: better-sqlite3 was built for a different Node. Run ' +
143
+ '`npm rebuild better-sqlite3` then `node scripts/pib-db.mjs init` in ' +
144
+ 'this project — your existing pib.db is untouched.'
145
+ );
146
+ return { results, schemaVersion: null };
147
+ }
148
+ }
44
149
  }
45
150
 
46
- module.exports = { setupDb };
151
+ module.exports = { setupDb, isAbiMismatch, readBundledSchemaVersion, shouldSkipInit };
package/lib/metadata.js CHANGED
@@ -44,7 +44,7 @@ function merge(projectDir, partial) {
44
44
  return data;
45
45
  }
46
46
 
47
- function create(projectDir, { modules, skipped, version, manifest = {} }) {
47
+ function create(projectDir, { modules, skipped, version, manifest = {}, dbSchemaVersion } = {}) {
48
48
  // Read existing first so unknown top-level keys (e.g.
49
49
  // `migrated_from_omega` set by --migrate-memory) survive the
50
50
  // install/upgrade rewrite. Only the install-specific fields below
@@ -60,6 +60,13 @@ function create(projectDir, { modules, skipped, version, manifest = {} }) {
60
60
  manifest,
61
61
  };
62
62
 
63
+ // Record the applied pib-db schema version when this install confirmed
64
+ // one (init ran or was skipped as already-current). When it didn't
65
+ // (--no-db, dry-run, or an unhealed ABI mismatch), `dbSchemaVersion` is
66
+ // undefined and any prior value rides through via `...existing` — we
67
+ // never claim "schema current" off the back of a skipped/failed init.
68
+ if (dbSchemaVersion != null) data.dbSchemaVersion = dbSchemaVersion;
69
+
63
70
  for (const mod of modules) {
64
71
  data.modules[mod] = true;
65
72
  }
@@ -71,4 +78,46 @@ function create(projectDir, { modules, skipped, version, manifest = {} }) {
71
78
  return data;
72
79
  }
73
80
 
74
- module.exports = { read, write, merge, create, metadataPath, METADATA_FILE };
81
+ /**
82
+ * Read the project's frozen-module list from `.ccrc.json`. Returns a
83
+ * normalized, deduped `string[]` of module keys the operator has pinned
84
+ * (the temporary, manifest-RETAINING third ownership state — act:c1013967).
85
+ *
86
+ * Defensive by construction: a non-array `frozenModules` (shape drift) reads
87
+ * as "nothing frozen" rather than throwing, and a genuinely-absent `.ccrc.json`
88
+ * returns `[]`. It deliberately does NOT swallow a JSON parse error — `read()`
89
+ * throws on a malformed `.ccrc.json`, which HALTS the reinstall. That fail-
90
+ * closed behavior is the point: when the freeze state can't be read, the
91
+ * installer must stop, never proceed as if nothing were frozen (which would
92
+ * silently rewrite the very files a freeze exists to protect).
93
+ */
94
+ function readFrozenModules(projectDir) {
95
+ const m = read(projectDir);
96
+ const raw = m && m.frozenModules;
97
+ if (!Array.isArray(raw)) return [];
98
+ return [...new Set(raw.filter((x) => typeof x === 'string'))];
99
+ }
100
+
101
+ /**
102
+ * Persist the frozen-module list, shallow-merged into `.ccrc.json` so every
103
+ * other field (manifest, modules, dbSchemaVersion, …) rides through. Input is
104
+ * normalized to a deduped string[]. Returns the written array.
105
+ */
106
+ function setFrozenModules(projectDir, mods) {
107
+ const normalized = Array.isArray(mods)
108
+ ? [...new Set(mods.filter((x) => typeof x === 'string'))]
109
+ : [];
110
+ merge(projectDir, { frozenModules: normalized });
111
+ return normalized;
112
+ }
113
+
114
+ module.exports = {
115
+ read,
116
+ write,
117
+ merge,
118
+ create,
119
+ metadataPath,
120
+ METADATA_FILE,
121
+ readFrozenModules,
122
+ setFrozenModules,
123
+ };
@@ -8,7 +8,7 @@ const DEFAULT_HOOKS = {
8
8
  hooks: [
9
9
  {
10
10
  type: 'command',
11
- command: '.claude/hooks/git-guardrails.sh',
11
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/git-guardrails.sh',
12
12
  },
13
13
  ],
14
14
  },
@@ -17,7 +17,7 @@ const DEFAULT_HOOKS = {
17
17
  hooks: [
18
18
  {
19
19
  type: 'command',
20
- command: '.claude/hooks/work-tracker-guard.sh',
20
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/work-tracker-guard.sh',
21
21
  },
22
22
  ],
23
23
  },
@@ -26,7 +26,7 @@ const DEFAULT_HOOKS = {
26
26
  hooks: [
27
27
  {
28
28
  type: 'command',
29
- command: '.claude/hooks/cc-upstream-guard.sh',
29
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/cc-upstream-guard.sh',
30
30
  },
31
31
  ],
32
32
  },
@@ -35,7 +35,7 @@ const DEFAULT_HOOKS = {
35
35
  hooks: [
36
36
  {
37
37
  type: 'command',
38
- command: '.claude/hooks/action-quality-gate.sh',
38
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/action-quality-gate.sh',
39
39
  },
40
40
  ],
41
41
  },
@@ -44,7 +44,7 @@ const DEFAULT_HOOKS = {
44
44
  hooks: [
45
45
  {
46
46
  type: 'command',
47
- command: '.claude/hooks/action-completion-gate.sh',
47
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/action-completion-gate.sh',
48
48
  },
49
49
  ],
50
50
  },
@@ -55,7 +55,7 @@ const DEFAULT_HOOKS = {
55
55
  hooks: [
56
56
  {
57
57
  type: 'command',
58
- command: '.claude/hooks/skill-telemetry.sh',
58
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/skill-telemetry.sh',
59
59
  },
60
60
  ],
61
61
  },
@@ -66,7 +66,7 @@ const DEFAULT_HOOKS = {
66
66
  hooks: [
67
67
  {
68
68
  type: 'command',
69
- command: '.claude/hooks/skill-tool-telemetry.sh',
69
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/skill-tool-telemetry.sh',
70
70
  },
71
71
  ],
72
72
  },
@@ -75,7 +75,7 @@ const DEFAULT_HOOKS = {
75
75
  hooks: [
76
76
  {
77
77
  type: 'command',
78
- command: '.claude/hooks/memory-index-guard.sh',
78
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/memory-index-guard.sh',
79
79
  },
80
80
  ],
81
81
  },
@@ -133,7 +133,7 @@ const BASH_COMPRESS_HOOKS = {
133
133
  hooks: [
134
134
  {
135
135
  type: 'command',
136
- command: '.claude/hooks/bash-output-compress.sh',
136
+ command: '$CLAUDE_PROJECT_DIR/.claude/hooks/bash-output-compress.sh',
137
137
  },
138
138
  ],
139
139
  },
@@ -156,6 +156,38 @@ const LEGACY_HOOK_COMMANDS = [
156
156
  'domain-memories.sh',
157
157
  ];
158
158
 
159
+ // CC-owned hook commands ship with a `$CLAUDE_PROJECT_DIR/` prefix so they
160
+ // resolve regardless of the hook's working directory. Older installs wrote them
161
+ // bare-relative (`.claude/hooks/X.sh`), which break ("No such file or
162
+ // directory") whenever a hook fires from a non-root cwd. bareForm() derives that
163
+ // legacy spelling so a reinstall can REPLACE it (drop old, add prefixed) rather
164
+ // than leave a broken duplicate.
165
+ function bareForm(command) {
166
+ return (command || '').replace(/^\$CLAUDE_PROJECT_DIR\//, '');
167
+ }
168
+
169
+ // Every CC-owned hook command in a hook set, in its current spelling.
170
+ function ccHookCommands(hookSet) {
171
+ return Object.values(hookSet)
172
+ .flatMap(entries => entries.flatMap(e => (e.hooks || []).map(h => h.command)));
173
+ }
174
+
175
+ // Remove every sub-hook whose command exactly matches one of `commands`, and
176
+ // prune entries left empty. Exact-match on CC-owned command strings only — a
177
+ // consumer's own hooks (different paths) are never touched.
178
+ function dropHookCommands(settings, commands) {
179
+ const drop = new Set(commands.filter(Boolean));
180
+ for (const [event, entries] of Object.entries(settings.hooks || {})) {
181
+ if (!Array.isArray(entries)) continue;
182
+ settings.hooks[event] = entries
183
+ .map(entry => (entry.hooks && Array.isArray(entry.hooks))
184
+ ? { ...entry, hooks: entry.hooks.filter(h => !drop.has(h.command)) }
185
+ : entry)
186
+ .filter(entry => !entry.hooks || !Array.isArray(entry.hooks) || entry.hooks.length > 0);
187
+ if (settings.hooks[event].length === 0) delete settings.hooks[event];
188
+ }
189
+ }
190
+
159
191
  /**
160
192
  * Merge PIB hooks into the project's .claude/settings.json.
161
193
  * Creates the file if it doesn't exist. Preserves existing hooks.
@@ -199,6 +231,13 @@ function mergeSettings(projectDir, { includeDb = true } = {}) {
199
231
  if (!settings.hooks[event]) continue;
200
232
  }
201
233
 
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));
240
+
202
241
  // Merge each hook event type from DEFAULT_HOOKS
203
242
  for (const [event, newHooks] of Object.entries(DEFAULT_HOOKS)) {
204
243
  if (!settings.hooks[event]) {
@@ -254,7 +293,7 @@ function healUserSettings() {
254
293
  const filtered = entries.filter(entry => {
255
294
  if (!entry.hooks || !Array.isArray(entry.hooks)) return true;
256
295
  const hasRelativeCcHook = entry.hooks.some(h =>
257
- /^\.claude\/hooks\//.test(h.command || '')
296
+ /^(\$CLAUDE_PROJECT_DIR\/)?\.claude\/hooks\//.test(h.command || '')
258
297
  );
259
298
  if (hasRelativeCcHook) removed++;
260
299
  return !hasRelativeCcHook;
@@ -338,6 +377,9 @@ function mergeBashCompressHooks(settingsPath) {
338
377
  const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
339
378
  if (!settings.hooks) settings.hooks = {};
340
379
 
380
+ // Replace any superseded bare-relative form with the prefixed one (see mergeSettings).
381
+ dropHookCommands(settings, ccHookCommands(BASH_COMPRESS_HOOKS).map(bareForm));
382
+
341
383
  for (const [event, newHooks] of Object.entries(BASH_COMPRESS_HOOKS)) {
342
384
  if (!settings.hooks[event]) {
343
385
  settings.hooks[event] = newHooks;
@@ -76,6 +76,7 @@ function buildManagedFiles() {
76
76
  const shellRunners = [
77
77
  'watchtower-ring1-runner.sh',
78
78
  'watchtower-ring2-runner.sh',
79
+ 'watchtower-ring4-runner.sh',
79
80
  'watchtower-status.sh',
80
81
  ];
81
82
  for (const name of shellRunners) {
@@ -168,8 +169,27 @@ function refreshWatchtowerRuntime(opts = {}) {
168
169
  const content = fs.readFileSync(file.src);
169
170
  const hash = sha256(content);
170
171
 
171
- if (manifest.files[file.dest] === hash) {
172
- continue; // file unchanged
172
+ // Content-aware skip: compare the template hash to the runtime file's
173
+ // ACTUAL on-disk bytes, NOT the cached manifest. The manifest can lie — a
174
+ // hand-edited or externally-replaced runtime file still carries the hash
175
+ // the manifest recorded at last write, so a manifest-only check would skip
176
+ // it and leave the divergence running. Reading the real file makes the
177
+ // refresh self-healing: any runtime file that differs from the template
178
+ // (or is missing) is rewritten, so a reinstall reliably reconverges the
179
+ // runtime onto the template instead of silently leaving it stale.
180
+ let runtimeHash = null;
181
+ if (fs.existsSync(file.dest)) {
182
+ try {
183
+ runtimeHash = sha256(fs.readFileSync(file.dest));
184
+ } catch {
185
+ runtimeHash = null; // unreadable runtime file → treat as divergent
186
+ }
187
+ }
188
+
189
+ if (runtimeHash === hash) {
190
+ // Already in sync on disk. Keep the manifest honest as bookkeeping.
191
+ if (!dryRun) manifest.files[file.dest] = hash;
192
+ continue;
173
193
  }
174
194
 
175
195
  if (dryRun) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.46.0",
3
+ "version": "0.47.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"
@@ -89,7 +89,7 @@ Document any new advisory's signal source here when you add it, and call out exp
89
89
  | `lsp:ruby` | `Gemfile` or `*.rb` | `/plugin install ruby-lsp@claude-plugins-official` (also needs `gem install ruby-lsp` AND `ENABLE_LSP_TOOL=1`) |
90
90
  | `mcp:railway` | `railway.toml` and no railway key in `~/.claude.json` (no-probe) | local: `railway setup agent -y` · remote: register `mcp.railway.com` (OAuth) |
91
91
  | `plugin:hookify` | `.claude/rules/enforcement-pipeline.md` exists and hookify not in `claude plugin list` (signal is **static**; probe-gated) | `/plugin install hookify` |
92
- | `briefing-file` | `.claude/briefing/_briefing.md` is **absent** (signal `missing`, **static**; no-probe) | run `/onboard` to create one |
92
+ | `briefing-file` | `.claude/cabinet/_briefing.md` is **absent** (canonical location — where cabinet members read; `.claude/briefing/` holds only installer-seeded templates) (signal `missing`, **static**; no-probe) | run `/onboard` to create one |
93
93
  | `registry-orphan` | `~/.claude/cc-registry.json` lists project path(s) that no longer exist (signal = sorted orphan-name set, so it re-arms when the registry changes; no-probe) | remove the dead entr(y/ies) from `~/.claude/cc-registry.json` |
94
94
 
95
95
  > Note: `mcp:railway` keys on `railway.toml`, which Ring 1 also marker-checks for deploy detection — these are independent reads of the same file for different purposes; do not consolidate them (the advisory adds the `~/.claude.json` registration predicate Ring 1 lacks).
@@ -19,9 +19,23 @@ state.
19
19
  | Writer | When | What |
20
20
  |--------|------|------|
21
21
  | `/execute` `post-impl-checklist` phase | every run past its no-op guard | increments `runs`; per triggered dimension increments `fires`, sets `last_fired` |
22
- | `/debrief` `checklist-feedback` phase | when a session bug WAS caught via a surfaced check | appends to that dimension's `catches` |
22
+ | Ring 3 close `checklist-catch` lens (Phase 2p) | session close, when the transcript shows a surfaced check that caught a real bug | appends to that dimension's `catches` |
23
+ | `/debrief` `checklist-feedback` phase (pre-watchtower; retiring) | when a session bug WAS caught via a surfaced check | appends to that dimension's `catches` |
23
24
  | `/audit` `checklist-pruning` phase | every pruning verdict (including "keep") | appends to `pruning_reviews` |
24
25
 
26
+ The **catch side is recorded automatically at session close** by the
27
+ Ring 3 `checklist-catch` lens (`watchtower-ring3-close.mjs`, Phase 2p)
28
+ for watchtower-installed projects — it reads the transcript, attributes
29
+ catches to a named dimension, and writes them via the protocol below.
30
+ `/debrief`'s `checklist-feedback` phase did this interactively and is
31
+ retired by the watchtower burn-in (its sharpening side — proposing new
32
+ checks for bugs that *slipped through* — needs operator approval and
33
+ cannot move to a background ring; only the catch tally moved). The two
34
+ writers are mutually exclusive in practice: a watchtower project closes
35
+ through Ring 3, a non-watchtower project through debrief, and both
36
+ follow the same atomic, fail-open write protocol — so the catches a
37
+ later audit prunes against are recorded the same way regardless.
38
+
25
39
  ## Schema (`schema_version: 1`)
26
40
 
27
41
  ```json