create-claude-cabinet 0.39.0 → 0.41.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 (76) hide show
  1. package/README.md +11 -0
  2. package/lib/cli.js +24 -3
  3. package/lib/engagement-server-setup.js +5 -1
  4. package/lib/metadata.js +9 -2
  5. package/lib/mux-setup.js +121 -1
  6. package/lib/settings-merge.js +45 -1
  7. package/package.json +4 -2
  8. package/templates/cabinet/_cabinet-member-template.md +8 -20
  9. package/templates/cabinet/watchtower-contracts.md +75 -3
  10. package/templates/engagement-server/Dockerfile +2 -0
  11. package/templates/engagement-server/server.mjs +73 -21
  12. package/templates/mux/bin/mux +221 -10
  13. package/templates/mux/config/context-help.py +6 -0
  14. package/templates/mux/config/help.txt +21 -0
  15. package/templates/mux/config/mux-server.py +5 -2
  16. package/templates/mux/config/mux.tmux.conf +27 -0
  17. package/templates/mux/config/muxlib.py +50 -1
  18. package/templates/mux/config/screenshot-to-clipboard.sh +45 -0
  19. package/templates/mux/config/unwrap-copy.py +72 -0
  20. package/templates/mux/config/worktree-cleanup.sh +86 -0
  21. package/templates/mux/config/worktree-health-popup.sh +23 -0
  22. package/templates/mux/config/worktree-session-health.sh +105 -0
  23. package/templates/rules/maintainability.md +92 -0
  24. package/templates/rules/memory-capture.md +4 -2
  25. package/templates/scripts/watchtower-build-context.mjs +118 -15
  26. package/templates/scripts/watchtower-lib.mjs +41 -1
  27. package/templates/scripts/watchtower-queue.mjs +21 -11
  28. package/templates/scripts/watchtower-ring1.mjs +248 -14
  29. package/templates/scripts/watchtower-ring2.mjs +378 -24
  30. package/templates/scripts/watchtower-ring3-close.mjs +462 -114
  31. package/templates/scripts/watchtower-status.sh +266 -0
  32. package/templates/scripts/watchtower-validate.mjs +3 -3
  33. package/templates/skills/briefing/SKILL.md +46 -7
  34. package/templates/skills/cabinet-accessibility/SKILL.md +60 -223
  35. package/templates/skills/cabinet-anthropic-insider/SKILL.md +65 -296
  36. package/templates/skills/cabinet-anti-confirmation/SKILL.md +38 -152
  37. package/templates/skills/cabinet-architecture/SKILL.md +59 -265
  38. package/templates/skills/cabinet-automation/SKILL.md +77 -398
  39. package/templates/skills/cabinet-boundary-man/SKILL.md +58 -194
  40. package/templates/skills/cabinet-cc-health/SKILL.md +64 -462
  41. package/templates/skills/cabinet-cc-health/migration-reference.md +46 -0
  42. package/templates/skills/cabinet-data-integrity/SKILL.md +53 -142
  43. package/templates/skills/cabinet-debugger/SKILL.md +67 -209
  44. package/templates/skills/cabinet-elegance/SKILL.md +34 -229
  45. package/templates/skills/cabinet-framework-quality/SKILL.md +78 -387
  46. package/templates/skills/cabinet-goal-alignment/SKILL.md +64 -218
  47. package/templates/skills/cabinet-historian/SKILL.md +76 -320
  48. package/templates/skills/cabinet-information-design/SKILL.md +89 -432
  49. package/templates/skills/cabinet-interactive-storyteller/SKILL.md +77 -307
  50. package/templates/skills/cabinet-mantine-quality/SKILL.md +42 -293
  51. package/templates/skills/cabinet-narrative-architect/SKILL.md +67 -254
  52. package/templates/skills/cabinet-organized-mind/SKILL.md +90 -340
  53. package/templates/skills/cabinet-process-therapist/SKILL.md +70 -233
  54. package/templates/skills/cabinet-qa/SKILL.md +57 -195
  55. package/templates/skills/cabinet-record-keeper/SKILL.md +59 -170
  56. package/templates/skills/cabinet-roster-check/SKILL.md +74 -300
  57. package/templates/skills/cabinet-security/SKILL.md +58 -211
  58. package/templates/skills/cabinet-small-screen/SKILL.md +38 -138
  59. package/templates/skills/cabinet-speed-freak/SKILL.md +36 -198
  60. package/templates/skills/cabinet-system-advocate/SKILL.md +62 -170
  61. package/templates/skills/cabinet-technical-debt/SKILL.md +36 -176
  62. package/templates/skills/cabinet-ui-experimentalist/SKILL.md +67 -231
  63. package/templates/skills/cabinet-usability/SKILL.md +59 -175
  64. package/templates/skills/cabinet-user-advocate/SKILL.md +69 -290
  65. package/templates/skills/cabinet-vision/SKILL.md +62 -224
  66. package/templates/skills/cabinet-workflow-cop/SKILL.md +63 -226
  67. package/templates/skills/collab-client/SKILL.md +30 -0
  68. package/templates/skills/collab-consultant/SKILL.md +146 -2
  69. package/templates/skills/decisions/SKILL.md +7 -158
  70. package/templates/skills/dx-feedback/SKILL.md +125 -0
  71. package/templates/skills/inbox/SKILL.md +194 -0
  72. package/templates/skills/investigate/SKILL.md +2 -2
  73. package/templates/skills/orient/phases/dx-captures.md +16 -17
  74. package/templates/skills/plan/phases/verify-plan.md +3 -3
  75. package/templates/skills/watchtower/SKILL.md +3 -2
  76. package/templates/watchtower/queue/items/item.json.schema +33 -6
package/README.md CHANGED
@@ -221,6 +221,17 @@ the listed modules to what's already there, it doesn't replace your
221
221
  module set. Safe to run on a mature project without losing
222
222
  customization. You can pass multiple modules: `--modules verify,audit`.
223
223
 
224
+ ### Opt-in Modules
225
+
226
+ | Module | What it does |
227
+ |--------|-------------|
228
+ | **verify** | Cucumber + Playwright walkthrough verification harness |
229
+ | **site-audit** | 14-check deployed-site quality audit with HTML reports |
230
+ | **engagement** | Client engagement management — packets, billing, feedback loops |
231
+ | **engagement-server** | Central multi-engagement API server (Railway/Fly deploy) |
232
+ | **watchtower** | Continuous background state management replacing orient/debrief |
233
+ | **mux** | Multi-project terminal manager — desks, auto-worktrees with shared identity, trail logging, DX captures, portal color-switching, durable tmux bindings, clipboard copy with hard-wrap removal, screenshot-to-clipboard launchd watcher |
234
+
224
235
  ## CLI Options
225
236
 
226
237
  ```
package/lib/cli.js CHANGED
@@ -4,7 +4,7 @@ const fs = require('fs');
4
4
  const os = require('os');
5
5
  const crypto = require('crypto');
6
6
  const { copyTemplates } = require('./copy');
7
- const { mergeSettings, healUserSettings, mergeWatchtowerHooks } = require('./settings-merge');
7
+ const { mergeSettings, healUserSettings, mergeWatchtowerHooks, mergeMuxHooks } = require('./settings-merge');
8
8
  const { create: createMetadata, read: readMetadata } = require('./metadata');
9
9
  const { setupDb } = require('./db-setup');
10
10
  const { setupVerifyRuntime } = require('./verify-setup');
@@ -496,7 +496,7 @@ const MODULES = {
496
496
  mandatory: false,
497
497
  default: true,
498
498
  lean: false,
499
- templates: ['rules/enforcement-pipeline.md', 'memory/patterns/_pattern-template.md', 'memory/patterns/pattern-intelligence-first.md'],
499
+ templates: ['rules/enforcement-pipeline.md', 'rules/maintainability.md', 'memory/patterns/_pattern-template.md', 'memory/patterns/pattern-intelligence-first.md'],
500
500
  },
501
501
  'memory': {
502
502
  name: 'Built-In Memory (cc-remember + reader + validator)',
@@ -623,7 +623,7 @@ const MODULES = {
623
623
  },
624
624
  watchtower: {
625
625
  name: 'Watchtower (continuous background state)',
626
- description: 'Replaces orient/debrief with continuous background processing. Three rings (mechanical cron, Claude intelligence, session-aware), ambient state injection via SessionStart hook, and an asynchronous decision queue. Sessions start informed with minimal context cost.',
626
+ description: 'Replaces orient/debrief with continuous background processing. Three rings (mechanical cron, Claude intelligence, session-aware), ambient state injection via SessionStart hook, and an inbox for extracted knowledge and signals. Sessions start informed with minimal context cost.',
627
627
  mandatory: false,
628
628
  default: false,
629
629
  lean: false,
@@ -632,6 +632,7 @@ const MODULES = {
632
632
  'cabinet/watchtower-contracts.md',
633
633
  'scripts/watchtower-lib.mjs',
634
634
  'scripts/watchtower-queue.mjs',
635
+ 'skills/inbox',
635
636
  'skills/decisions',
636
637
  'hooks/watchtower-session-start.sh',
637
638
  'scripts/watchtower-build-context.mjs',
@@ -651,6 +652,7 @@ const MODULES = {
651
652
  'watchtower/watchtower-ring2-slow.timer',
652
653
  'hooks/watchtower-session-end.sh',
653
654
  'scripts/watchtower-ring3-close.mjs',
655
+ 'scripts/watchtower-status.sh',
654
656
  'skills/briefing',
655
657
  ],
656
658
  },
@@ -1285,6 +1287,11 @@ async function run() {
1285
1287
  mergeWatchtowerHooks(settingsPath);
1286
1288
  console.log(' ⚙️ Registered watchtower SessionStart/SessionEnd hooks');
1287
1289
  }
1290
+
1291
+ if (selectedModules.includes('mux')) {
1292
+ mergeMuxHooks(settingsPath);
1293
+ console.log(' ⚙️ Registered mux worktree health SessionStart hook');
1294
+ }
1288
1295
  }
1289
1296
 
1290
1297
  // --- Heal user-level ~/.claude/settings.json ---
@@ -1308,6 +1315,11 @@ async function run() {
1308
1315
  if (fs.existsSync(mcpJsonPath)) {
1309
1316
  existing = JSON.parse(fs.readFileSync(mcpJsonPath, 'utf8'));
1310
1317
  }
1318
+ // Guard shape drift: .mcpServers set on a top-level array would be
1319
+ // silently dropped on serialize.
1320
+ if (typeof existing !== 'object' || existing === null || Array.isArray(existing)) {
1321
+ existing = {};
1322
+ }
1311
1323
  if (!existing.mcpServers) existing.mcpServers = {};
1312
1324
  Object.assign(existing.mcpServers, mcpConfig.mcpServers);
1313
1325
  fs.writeFileSync(mcpJsonPath, JSON.stringify(existing, null, 2) + '\n');
@@ -1560,6 +1572,15 @@ async function run() {
1560
1572
  if (fs.existsSync(registryPath)) {
1561
1573
  registry = JSON.parse(fs.readFileSync(registryPath, 'utf8'));
1562
1574
  }
1575
+ // Normalize shape drift: a bare top-level array has been observed
1576
+ // (ad-hoc edit dropped the {"projects": ...} wrapper). Re-wrap so
1577
+ // the update below also heals the file on disk.
1578
+ if (Array.isArray(registry)) {
1579
+ registry = { projects: registry };
1580
+ }
1581
+ if (!Array.isArray(registry.projects)) {
1582
+ registry.projects = [];
1583
+ }
1563
1584
  const existingIdx = registry.projects.findIndex(p => p.path === projectDir);
1564
1585
  const entry = {
1565
1586
  path: projectDir,
@@ -57,7 +57,11 @@ function compareVersions(a, b) {
57
57
  function readGlobalManifest() {
58
58
  if (!fs.existsSync(GLOBAL_MANIFEST_PATH)) return { files: {} };
59
59
  try {
60
- return JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
60
+ const m = JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
61
+ // Guard shape drift: manifest.files is indexed unconditionally below.
62
+ if (typeof m !== 'object' || m === null || Array.isArray(m)) return { files: {} };
63
+ if (typeof m.files !== 'object' || m.files === null || Array.isArray(m.files)) m.files = {};
64
+ return m;
61
65
  } catch {
62
66
  return { files: {} };
63
67
  }
package/lib/metadata.js CHANGED
@@ -10,13 +10,20 @@ function metadataPath(projectDir) {
10
10
 
11
11
  function read(projectDir) {
12
12
  const file = metadataPath(projectDir);
13
- if (fs.existsSync(file)) return JSON.parse(fs.readFileSync(file, 'utf8'));
13
+ if (fs.existsSync(file)) return normalize(JSON.parse(fs.readFileSync(file, 'utf8')));
14
14
  // Fall back to legacy manifest from pre-v0.6.0 installs
15
15
  const legacyFile = path.join(projectDir, LEGACY_METADATA_FILE);
16
- if (fs.existsSync(legacyFile)) return JSON.parse(fs.readFileSync(legacyFile, 'utf8'));
16
+ if (fs.existsSync(legacyFile)) return normalize(JSON.parse(fs.readFileSync(legacyFile, 'utf8')));
17
17
  return null;
18
18
  }
19
19
 
20
+ // Guard shape drift: callers key into .modules/.manifest/.version — a
21
+ // non-object top level must read as "no metadata", not crash the installer.
22
+ function normalize(data) {
23
+ if (typeof data !== 'object' || data === null || Array.isArray(data)) return null;
24
+ return data;
25
+ }
26
+
20
27
  function write(projectDir, data) {
21
28
  const file = metadataPath(projectDir);
22
29
  fs.writeFileSync(file, JSON.stringify(data, null, 2) + '\n');
package/lib/mux-setup.js CHANGED
@@ -42,6 +42,12 @@ const MANAGED_FILES = [
42
42
  { src: 'config/help.txt', dest: path.join(os.homedir(), '.config', 'mux', 'help.txt') },
43
43
  { src: 'config/_mux', dest: path.join(os.homedir(), '.config', 'mux', '_mux') },
44
44
  { src: 'config/mux.bash', dest: path.join(os.homedir(), '.config', 'mux', 'mux.bash') },
45
+ { src: 'config/worktree-session-health.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-session-health.sh'), mode: 0o755 },
46
+ { src: 'config/worktree-health-popup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-health-popup.sh'), mode: 0o755 },
47
+ { src: 'config/worktree-cleanup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-cleanup.sh'), mode: 0o755 },
48
+ { src: 'config/mux.tmux.conf', dest: path.join(os.homedir(), '.config', 'mux', 'mux.tmux.conf') },
49
+ { src: 'config/unwrap-copy.py', dest: path.join(os.homedir(), '.config', 'mux', 'unwrap-copy.py'), mode: 0o755 },
50
+ { src: 'config/screenshot-to-clipboard.sh', dest: path.join(os.homedir(), '.config', 'mux', 'screenshot-to-clipboard.sh'), mode: 0o755 },
45
51
  ];
46
52
 
47
53
  const DATA_DIRS = [
@@ -50,6 +56,7 @@ const DATA_DIRS = [
50
56
  path.join(os.homedir(), '.config', 'mux', 'notes'),
51
57
  path.join(os.homedir(), '.config', 'mux', 'dx'),
52
58
  path.join(os.homedir(), '.config', 'mux', 'pending-prompts'),
59
+ path.join(os.homedir(), '.local', 'share', 'mux', 'wt-health'),
53
60
  ];
54
61
 
55
62
  function sha256(content) {
@@ -71,7 +78,11 @@ function compareVersions(a, b) {
71
78
  function readGlobalManifest() {
72
79
  if (!fs.existsSync(GLOBAL_MANIFEST_PATH)) return { files: {} };
73
80
  try {
74
- return JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
81
+ const m = JSON.parse(fs.readFileSync(GLOBAL_MANIFEST_PATH, 'utf8'));
82
+ // Guard shape drift: manifest.files is indexed unconditionally below.
83
+ if (typeof m !== 'object' || m === null || Array.isArray(m)) return { files: {} };
84
+ if (typeof m.files !== 'object' || m.files === null || Array.isArray(m.files)) m.files = {};
85
+ return m;
75
86
  } catch {
76
87
  return { files: {} };
77
88
  }
@@ -183,7 +194,116 @@ function setupMux(opts = {}) {
183
194
 
184
195
  results.push(` ${copiedCount} file${copiedCount !== 1 ? 's' : ''} installed to user paths`);
185
196
 
197
+ if (process.platform === 'darwin') {
198
+ setupDarwinIntegration({ dryRun, results });
199
+ }
200
+
186
201
  return { results, status: installedVersion ? 'upgraded' : 'installed' };
187
202
  }
188
203
 
204
+ /**
205
+ * macOS-specific wiring: tmux.conf source line, live binding reload,
206
+ * and the screenshot-to-clipboard launchd watcher. All steps are
207
+ * idempotent and individually fault-tolerant — a failure in one is
208
+ * reported but never aborts the install.
209
+ */
210
+ function setupDarwinIntegration({ dryRun, results }) {
211
+ const { execSync } = require('child_process');
212
+ const run = (cmd) => execSync(cmd, { stdio: ['ignore', 'pipe', 'pipe'] }).toString().trim();
213
+
214
+ // 1. ~/.tmux.conf sources mux.tmux.conf so mux bindings survive tmux
215
+ // server restarts (inline bind-key calls from bin/mux evaporate).
216
+ const tmuxConf = path.join(os.homedir(), '.tmux.conf');
217
+ const sourceLine = 'source-file -q ~/.config/mux/mux.tmux.conf';
218
+ try {
219
+ const existing = fs.existsSync(tmuxConf) ? fs.readFileSync(tmuxConf, 'utf8') : '';
220
+ if (!existing.includes('mux.tmux.conf')) {
221
+ if (dryRun) {
222
+ results.push(` [dry-run] append "${sourceLine}" to ~/.tmux.conf`);
223
+ } else {
224
+ const block = `\n# mux-managed bindings (CC) — keep this line; mux upgrades edit the sourced file\n${sourceLine}\n`;
225
+ fs.appendFileSync(tmuxConf, block);
226
+ results.push(' ~/.tmux.conf now sources mux.tmux.conf');
227
+ }
228
+ }
229
+ } catch (err) {
230
+ results.push(` ⚠ Could not update ~/.tmux.conf: ${err.message}`);
231
+ }
232
+
233
+ // 2. Apply bindings to a running tmux server immediately.
234
+ if (!dryRun) {
235
+ try {
236
+ run('tmux source-file ~/.config/mux/mux.tmux.conf');
237
+ results.push(' mux.tmux.conf applied to running tmux server');
238
+ } catch {
239
+ // No server running — bindings load at next server start via ~/.tmux.conf.
240
+ }
241
+ }
242
+
243
+ // 3. Screenshot-to-clipboard launchd watcher. Watches the macOS
244
+ // screenshot folder and puts each new screenshot on the clipboard
245
+ // as a file reference.
246
+ const label = 'com.mux.screenshot-to-clipboard';
247
+ const agentDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
248
+ const plistPath = path.join(agentDir, `${label}.plist`);
249
+ const legacyLabel = 'com.orenmagid.screenshot-to-clipboard';
250
+ const legacyPlist = path.join(agentDir, `${legacyLabel}.plist`);
251
+ const scriptPath = path.join(os.homedir(), '.config', 'mux', 'screenshot-to-clipboard.sh');
252
+
253
+ let shotDir = path.join(os.homedir(), 'Desktop');
254
+ try {
255
+ const loc = run('defaults read com.apple.screencapture location');
256
+ if (loc) shotDir = loc.replace(/^~/, os.homedir());
257
+ } catch {
258
+ /* default location */
259
+ }
260
+
261
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
262
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
263
+ <plist version="1.0">
264
+ <dict>
265
+ <key>Label</key>
266
+ <string>${label}</string>
267
+ <key>ProgramArguments</key>
268
+ <array>
269
+ <string>/bin/bash</string>
270
+ <string>${scriptPath}</string>
271
+ </array>
272
+ <key>WatchPaths</key>
273
+ <array>
274
+ <string>${shotDir}</string>
275
+ </array>
276
+ </dict>
277
+ </plist>
278
+ `;
279
+
280
+ if (dryRun) {
281
+ results.push(` [dry-run] install launchd watcher ${label} (watching ${shotDir})`);
282
+ return;
283
+ }
284
+
285
+ try {
286
+ const uid = run('id -u');
287
+
288
+ // Migrate: unload + remove a pre-CC hand-rolled watcher so the two
289
+ // never double-fire. The old script file is left in place.
290
+ if (fs.existsSync(legacyPlist)) {
291
+ try { run(`launchctl bootout gui/${uid}/${legacyLabel}`); } catch { /* not loaded */ }
292
+ fs.unlinkSync(legacyPlist);
293
+ results.push(` migrated legacy watcher (${legacyLabel} removed)`);
294
+ }
295
+
296
+ fs.mkdirSync(agentDir, { recursive: true });
297
+ const hadPlist = fs.existsSync(plistPath);
298
+ fs.writeFileSync(plistPath, plist);
299
+ if (hadPlist) {
300
+ try { run(`launchctl bootout gui/${uid}/${label}`); } catch { /* not loaded */ }
301
+ }
302
+ run(`launchctl bootstrap gui/${uid} ${plistPath}`);
303
+ results.push(` screenshot-to-clipboard watcher loaded (watching ${shotDir})`);
304
+ } catch (err) {
305
+ results.push(` ⚠ launchd watcher setup failed: ${err.message}`);
306
+ }
307
+ }
308
+
189
309
  module.exports = { setupMux };
@@ -107,6 +107,20 @@ const WATCHTOWER_HOOKS = {
107
107
  ],
108
108
  };
109
109
 
110
+ const MUX_HOOKS = {
111
+ SessionStart: [
112
+ {
113
+ matcher: '',
114
+ hooks: [
115
+ {
116
+ type: 'command',
117
+ command: '$HOME/.config/mux/worktree-session-health.sh',
118
+ },
119
+ ],
120
+ },
121
+ ],
122
+ };
123
+
110
124
  // Legacy hook script names that should be stripped on any merge.
111
125
  // Centralizes cleanup so a user who skips --migrate-memory but runs
112
126
  // any other CC operation still gets omega-era hooks pruned.
@@ -139,6 +153,11 @@ function mergeSettings(projectDir, { includeDb = true } = {}) {
139
153
  if (fs.existsSync(settingsPath)) {
140
154
  settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
141
155
  }
156
+ // Guard shape drift: setting .hooks on a top-level array would silently
157
+ // drop it on serialize (JSON.stringify ignores non-index array props).
158
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
159
+ settings = {};
160
+ }
142
161
 
143
162
  if (!settings.hooks) settings.hooks = {};
144
163
 
@@ -264,4 +283,29 @@ function mergeWatchtowerHooks(settingsPath) {
264
283
  fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
265
284
  }
266
285
 
267
- module.exports = { mergeSettings, healUserSettings, mergeWatchtowerHooks, DEFAULT_HOOKS, WATCHTOWER_HOOKS, LEGACY_HOOK_COMMANDS };
286
+ function mergeMuxHooks(settingsPath) {
287
+ if (!fs.existsSync(settingsPath)) return;
288
+ const settings = JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
289
+ if (!settings.hooks) settings.hooks = {};
290
+
291
+ for (const [event, newHooks] of Object.entries(MUX_HOOKS)) {
292
+ if (!settings.hooks[event]) {
293
+ settings.hooks[event] = newHooks;
294
+ } else {
295
+ for (const newHook of newHooks) {
296
+ const hookKey = h => h.command || h.prompt || '';
297
+ const existingKeys = settings.hooks[event].flatMap(h =>
298
+ h.hooks.map(hh => hookKey(hh))
299
+ );
300
+ const newKeys = newHook.hooks.map(h => hookKey(h));
301
+ if (!newKeys.every(k => existingKeys.includes(k))) {
302
+ settings.hooks[event].push(newHook);
303
+ }
304
+ }
305
+ }
306
+ }
307
+
308
+ fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
309
+ }
310
+
311
+ module.exports = { mergeSettings, healUserSettings, mergeWatchtowerHooks, mergeMuxHooks, DEFAULT_HOOKS, WATCHTOWER_HOOKS, MUX_HOOKS, LEGACY_HOOK_COMMANDS };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.39.0",
3
+ "version": "0.41.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"
@@ -8,7 +8,9 @@
8
8
  "files": [
9
9
  "bin/",
10
10
  "lib/",
11
- "templates/"
11
+ "templates/",
12
+ "!**/__pycache__",
13
+ "!**/*.pyc"
12
14
  ],
13
15
  "keywords": [
14
16
  "claude",
@@ -125,33 +125,21 @@ to anchor the boundaries.
125
125
 
126
126
  ### 7. Historically Problematic Patterns
127
127
 
128
- Two-file overlay:
129
-
130
128
  ```markdown
131
129
  ## Historically Problematic Patterns
132
130
 
133
- Two sources read both and merge at runtime:
134
-
135
- 1. **This section** (upstream, CC-owned) universal patterns that apply to
136
- any project. Grows when consuming projects promote recurring findings
137
- via field-feedback.
138
- 2. **`patterns-project.md`** in this skill's directory — project-specific
139
- patterns discovered during audits of this particular project. Project-
140
- owned, never overwritten by CC upgrades.
141
-
142
- If `patterns-project.md` exists, read it alongside this section. Both
143
- inform your analysis equally.
144
-
145
- **How patterns get here:** A consuming project's audit finds a real issue.
146
- If the same pattern recurs across projects, it gets promoted upstream via
147
- field-feedback. The CC maintainer adds it to this section. Project-specific
148
- patterns that don't generalize stay in `patterns-project.md`.
131
+ If `patterns-project.md` exists in this skill directory, read it for
132
+ project-specific patterns from prior audits and apply alongside the
133
+ universal patterns below. Absent is normalit is seeded by debrief
134
+ when recurring findings accumulate.
149
135
 
150
136
  <!-- Universal patterns below this line -->
151
137
  ```
152
138
 
153
- This section starts empty for new members. It accumulates from real
154
- findings over time — never pre-fill with hypothetical patterns.
139
+ This section starts empty for new members. Universal patterns accumulate
140
+ from real field-feedback findings over time — never pre-fill with
141
+ hypothetical patterns. Project-specific patterns live in
142
+ `patterns-project.md` (project-owned, never overwritten by CC upgrades).
155
143
 
156
144
  ## Optional Sections
157
145
 
@@ -17,12 +17,12 @@ fs.renameSync(tmp, filePath);
17
17
 
18
18
  ## No-Index Convention
19
19
 
20
- The decision queue uses directory listing, not an index file. To list
20
+ The inbox queue uses directory listing, not an index file. To list
21
21
  pending items, read `queue/items/`, open each `.json`, filter by
22
22
  status. No manifest, no index.json.
23
23
 
24
24
  Rationale: one fewer file to keep consistent. Directory listing is
25
- O(n) but n is small (decision queues rarely exceed 50 items). If
25
+ O(n) but n is small (inbox items rarely exceed 50 items). If
26
26
  listing exceeds 2 seconds or 500 items, revisit via `act:2b638b02`.
27
27
 
28
28
  ## Schema Versioning
@@ -70,7 +70,7 @@ standard files:
70
70
  | `memory-refs.md` | Ring 2 fast | Relevant memory entries |
71
71
  | `options-analysis.md` | Ring 2 fast | Pros/cons with evidence |
72
72
 
73
- `/decisions` reads these when `enrichment_status` is `"complete"`.
73
+ `/inbox` reads these when `enrichment_status` is `"complete"`.
74
74
  Missing files degrade gracefully (null in the read result).
75
75
 
76
76
  ## Deferred Schemas
@@ -85,3 +85,75 @@ their owning plan ships:
85
85
  | `hooks/<ring>-<phase>.d/` | Plan 9 | Lifecycle hooks |
86
86
  | `logs/<ring>.log` | Plan 4 | Ring 1 runner |
87
87
  | `lock/<ring>.pid` | Plan 4 | Ring 1 runner |
88
+
89
+ ## Ring 4 — Periodic Truth Reconciliation (design phase)
90
+
91
+ **Status: concept. Needs design session before implementation.**
92
+
93
+ Rings 1–3 operate on individual signals: individual files (R1),
94
+ individual patterns (R2), individual sessions (R3). Nothing catches
95
+ **cumulative drift** — the slow rot where twenty sessions each move
96
+ reality a little further from what documents claim, but no single
97
+ session is the tipping point.
98
+
99
+ Ring 4 is the answer to: "Is what we wrote still true?"
100
+
101
+ ### The problem it solves
102
+
103
+ The maginnis project's architecture briefing said "Layer 4 Not built"
104
+ and "tech stack TBD" for three weeks after the platform shipped with
105
+ a Rails 8 app, Mantine frontend, 421 RSpec specs, and staging on
106
+ Railway. No individual session caused the staleness. No individual
107
+ session's debrief would flag it. The drift was invisible until
108
+ someone tried to use the briefing for a real audit and found it
109
+ described a project that no longer existed.
110
+
111
+ This class of problem includes:
112
+ - **Briefing-to-codebase drift** — claims about architecture, tech
113
+ stack, file layout, or project state that no longer match reality
114
+ - **Memory-to-codebase drift** — memory files that reference
115
+ functions, files, or flags that have been renamed or removed
116
+ - **CLAUDE.md drift** — sections describing architecture or
117
+ conventions that have been refactored past
118
+ - **Plan-to-reality drift** — plan files describing work that's been
119
+ done but never marked complete, or describing approaches that were
120
+ abandoned
121
+
122
+ ### Cadence and weight
123
+
124
+ Less frequent than R1–R3. Daily or weekly, not per-session or
125
+ per-minute. Heavier per run — reads real code, cross-references
126
+ document claims against codebase state, may invoke Claude for
127
+ semantic comparison. Acceptable cost because it runs rarely and
128
+ catches problems that compound silently.
129
+
130
+ ### Relationship to other rings
131
+
132
+ Following the nervous system principle (not a stack): R4 consumes
133
+ signals from R1–R3 (what changed recently, what sessions touched,
134
+ what patterns emerged) to prioritize what to reconcile. R4 produces
135
+ inbox items when drift is detected. R4 does not mutate documents —
136
+ it flags, the user triages and routes.
137
+
138
+ R3 catches "this session broke the briefing." R4 catches "the
139
+ briefing has been slowly wrong for three weeks and nobody noticed."
140
+ They're complementary, not redundant.
141
+
142
+ ### Open design questions
143
+
144
+ - **Scope per run:** Reconcile everything every time, or rotate
145
+ through documents on a schedule? Full sweep is thorough but
146
+ expensive; rotation risks missing urgent drift on off-cycle docs.
147
+ - **Drift threshold:** How wrong does a document need to be before
148
+ it's worth flagging? A missing model in the architecture briefing
149
+ is clear; a slightly outdated line count is noise.
150
+ - **Trigger vs schedule:** Pure schedule (weekly), or also triggered
151
+ when R1/R2 detect significant codebase changes (new migration,
152
+ major refactor, new dependency)?
153
+ - **Cross-project scope:** Per-project reconciliation, or also
154
+ cross-project (e.g., a CC template claiming something about
155
+ consumer behavior that's no longer true)?
156
+ - **Relationship to Ring 2 slow tier:** R2 slow already does some
157
+ staleness detection (stale work, memory hygiene). Where's the line
158
+ between R2's "is this work item stale?" and R4's "is this document
159
+ still true?" Is R4 an extension of R2 slow, or genuinely distinct?
@@ -1,5 +1,7 @@
1
1
  FROM node:20-slim
2
2
 
3
+ RUN apt-get update && apt-get install -y python3 make g++ && rm -rf /var/lib/apt/lists/*
4
+
3
5
  WORKDIR /app
4
6
 
5
7
  COPY package.json package-lock.json* ./
@@ -73,7 +73,7 @@ function hashToken(raw) {
73
73
  return createHash('sha256').update(raw).digest('hex');
74
74
  }
75
75
 
76
- function resolveToken(tokenHash) {
76
+ function resolveLocalToken(tokenHash) {
77
77
  return db.prepare(`
78
78
  SELECT u.id AS user_id, u.engagement_id, u.role, u.name AS user_name,
79
79
  e.auth_mode, e.auth_config
@@ -84,31 +84,83 @@ function resolveToken(tokenHash) {
84
84
  `).get(tokenHash);
85
85
  }
86
86
 
87
+ function getExternalEngagements() {
88
+ return db.prepare(`
89
+ SELECT id, auth_config FROM engagements
90
+ WHERE auth_mode = 'external' AND auth_config IS NOT NULL
91
+ `).all();
92
+ }
93
+
94
+ function resolveExternalUser(engagementId, email, roleMapping) {
95
+ const user = db.prepare(`
96
+ SELECT id AS user_id, engagement_id, role, name AS user_name
97
+ FROM users
98
+ WHERE engagement_id = ? AND email = ?
99
+ `).get(engagementId, email);
100
+ if (user) return user;
101
+
102
+ return null;
103
+ }
104
+
87
105
  async function authenticateRequest(req) {
88
106
  const authHeader = req.headers['authorization'];
89
107
  if (!authHeader || !authHeader.startsWith('Bearer ')) return null;
90
108
 
91
109
  const raw = authHeader.slice(7);
92
- const tokenData = resolveToken(hashToken(raw));
93
- if (!tokenData) return null;
94
110
 
95
- if (tokenData.auth_mode === 'external' && tokenData.auth_config) {
111
+ // Path 1: local token lookup (works for all auth modes)
112
+ const localData = resolveLocalToken(hashToken(raw));
113
+ if (localData && localData.auth_mode === 'local') return localData;
114
+
115
+ // Path 2: external auth — forward token to client app's validate_url
116
+ const externals = getExternalEngagements();
117
+ for (const eng of externals) {
96
118
  try {
97
- const config = JSON.parse(tokenData.auth_config);
98
- if (config.validate_url) {
99
- const resp = await fetch(config.validate_url, {
100
- headers: { 'Authorization': `Bearer ${raw}` },
101
- signal: AbortSignal.timeout(5000),
102
- });
103
- if (!resp.ok) return null;
104
- }
119
+ const config = JSON.parse(eng.auth_config);
120
+ if (!config.validate_url) continue;
121
+
122
+ const resp = await fetch(config.validate_url, {
123
+ headers: { 'Authorization': `Bearer ${raw}` },
124
+ signal: AbortSignal.timeout(5000),
125
+ });
126
+ if (!resp.ok) continue;
127
+
128
+ const identity = await resp.json();
129
+ const email = identity.email;
130
+ if (!email) continue;
131
+
132
+ // Map platform role to engagement role via role_mapping
133
+ const roleMapping = config.role_mapping || {};
134
+ const mappedRole = roleMapping[identity.role] || identity.role;
135
+
136
+ // Find matching local user by email for message attribution
137
+ const user = resolveExternalUser(eng.id, email, roleMapping);
138
+ if (user) return { ...user, auth_mode: 'external' };
139
+
140
+ // User authenticated with platform but no local user record —
141
+ // auto-create so messages are properly attributed
142
+ const userId = `usr_${randomBytes(6).toString('hex')}`;
143
+ const engRole = ['consultant', 'client'].includes(mappedRole) ? mappedRole : 'client';
144
+ db.prepare(`INSERT INTO users (id, engagement_id, name, email, role) VALUES (?, ?, ?, ?, ?)`)
145
+ .run(userId, eng.id, email.split('@')[0], email, engRole);
146
+
147
+ return {
148
+ user_id: userId,
149
+ engagement_id: eng.id,
150
+ role: engRole,
151
+ user_name: email.split('@')[0],
152
+ auth_mode: 'external',
153
+ };
105
154
  } catch (err) {
106
- console.error(`External auth failed: ${err.message}`);
107
- return { error: 'auth_backend_unavailable' };
155
+ console.error(`External auth failed for engagement ${eng.id}: ${err.message}`);
108
156
  }
109
157
  }
110
158
 
111
- return tokenData;
159
+ // Path 3: local token in an external-auth engagement (consultant-side
160
+ // tokens may still be locally managed)
161
+ if (localData) return localData;
162
+
163
+ return null;
112
164
  }
113
165
 
114
166
  // ---------------------------------------------------------------------------
@@ -278,18 +330,18 @@ const server = createServer(async (req, res) => {
278
330
  const path = url.pathname;
279
331
  const method = req.method;
280
332
 
333
+ // Health check — no auth, before HTTPS enforcement (Railway internal probes lack x-forwarded-proto)
334
+ if (path === '/health' && method === 'GET') {
335
+ logRequest(req, 200, null);
336
+ return json(res, 200, { ok: true, version: SCHEMA_VERSION });
337
+ }
338
+
281
339
  // HTTPS enforcement (Railway terminates TLS)
282
340
  if (process.env.RAILWAY_ENVIRONMENT && req.headers['x-forwarded-proto'] !== 'https') {
283
341
  logRequest(req, 421, null);
284
342
  return json(res, 421, { error: 'https_required' });
285
343
  }
286
344
 
287
- // Health check — no auth
288
- if (path === '/health' && method === 'GET') {
289
- logRequest(req, 200, null);
290
- return json(res, 200, { ok: true, version: SCHEMA_VERSION });
291
- }
292
-
293
345
  // All other routes require auth
294
346
  const authHeader = req.headers['authorization'];
295
347
  if (!authHeader || !authHeader.startsWith('Bearer ')) {