create-claude-cabinet 0.44.0 → 0.46.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 (90) hide show
  1. package/README.md +9 -4
  2. package/lib/cli.js +77 -6
  3. package/lib/copy.js +56 -10
  4. package/lib/engagement-server-setup.js +34 -9
  5. package/lib/migrate-from-omega.js +13 -1
  6. package/lib/mux-setup.js +34 -9
  7. package/lib/watchtower-setup.js +210 -0
  8. package/package.json +5 -1
  9. package/templates/cabinet/_cabinet-member-template.md +8 -3
  10. package/templates/cabinet/advisories-state-schema.md +34 -7
  11. package/templates/cabinet/checklist-stats-schema.md +104 -0
  12. package/templates/cabinet/checkpoint-protocol.md +17 -5
  13. package/templates/cabinet/composition-patterns.md +4 -3
  14. package/templates/cabinet/qa-dimensions-template.yaml +7 -0
  15. package/templates/cabinet/skill-output-conventions.md +35 -1
  16. package/templates/cabinet/watchtower-contracts.md +126 -0
  17. package/templates/engagement/pib-db-patches/pib-db-lib.mjs +14 -2
  18. package/templates/hooks/action-completion-gate.sh +17 -0
  19. package/templates/hooks/watchtower-session-start.sh +80 -5
  20. package/templates/mux/__tests__/claude-carveout.fixture.sh +136 -0
  21. package/templates/mux/__tests__/claude-carveout.test.mjs +38 -0
  22. package/templates/mux/__tests__/mux-fail-loud.fixture.sh +298 -0
  23. package/templates/mux/__tests__/mux-fail-loud.test.mjs +41 -0
  24. package/templates/mux/__tests__/station-liveness.fixture.sh +234 -0
  25. package/templates/mux/__tests__/station-liveness.test.mjs +47 -0
  26. package/templates/mux/__tests__/worktree-dirty-check.fixture.sh +184 -0
  27. package/templates/mux/__tests__/worktree-dirty-check.test.mjs +35 -0
  28. package/templates/mux/bin/mux +485 -107
  29. package/templates/mux/config/worktree-cleanup.sh +55 -9
  30. package/templates/mux/config/worktree-dirty-check.sh +128 -0
  31. package/templates/mux/config/worktree-session-health.sh +62 -35
  32. package/templates/scripts/__tests__/advisor-pass.test.mjs +238 -0
  33. package/templates/scripts/__tests__/advisories.test.mjs +262 -0
  34. package/templates/scripts/__tests__/batch-disposition.test.mjs +137 -0
  35. package/templates/scripts/__tests__/feedback-outbox-flush.test.mjs +232 -0
  36. package/templates/scripts/__tests__/qa-handoff-aging.e2e.test.mjs +108 -0
  37. package/templates/scripts/__tests__/qa-handoff-gate.test.mjs +403 -0
  38. package/templates/scripts/__tests__/resolve-project.test.mjs +144 -0
  39. package/templates/scripts/__tests__/ring-state-ownership.test.mjs +333 -0
  40. package/templates/scripts/__tests__/ring2-thread-context.test.mjs +189 -0
  41. package/templates/scripts/__tests__/ring3-dedup.test.mjs +387 -0
  42. package/templates/scripts/__tests__/routine-dispatch.test.mjs +312 -0
  43. package/templates/scripts/pib-db-lib.mjs +4 -1
  44. package/templates/scripts/pib-db.mjs +4 -1
  45. package/templates/scripts/validate-memory.mjs +6 -2
  46. package/templates/scripts/watchtower-advisories.mjs +305 -0
  47. package/templates/scripts/watchtower-build-context.mjs +122 -19
  48. package/templates/scripts/watchtower-lib.mjs +441 -2
  49. package/templates/scripts/watchtower-migrate-keys.mjs +305 -0
  50. package/templates/scripts/watchtower-queue.mjs +372 -2
  51. package/templates/scripts/watchtower-ring1.mjs +138 -2
  52. package/templates/scripts/watchtower-ring2.mjs +122 -23
  53. package/templates/scripts/watchtower-ring3-close.mjs +558 -137
  54. package/templates/scripts/watchtower-routines.mjs +358 -0
  55. package/templates/scripts/watchtower-status.sh +1 -1
  56. package/templates/skills/audit/SKILL.md +30 -7
  57. package/templates/skills/audit/phases/checklist-pruning.md +108 -0
  58. package/templates/skills/briefing/SKILL.md +342 -223
  59. package/templates/skills/cabinet/SKILL.md +2 -2
  60. package/templates/skills/cabinet-anthropic-insider/SKILL.md +14 -6
  61. package/templates/skills/cabinet-historian/SKILL.md +14 -11
  62. package/templates/skills/cabinet-system-advocate/SKILL.md +22 -21
  63. package/templates/skills/cabinet-user-advocate/SKILL.md +13 -7
  64. package/templates/skills/cc-publish/SKILL.md +105 -19
  65. package/templates/skills/collab-consultant/SKILL.md +1 -1
  66. package/templates/skills/debrief/SKILL.md +160 -15
  67. package/templates/skills/debrief/phases/checklist-feedback.md +10 -3
  68. package/templates/skills/debrief/phases/qa-handoff-sweep.md +78 -0
  69. package/templates/skills/engagement-create/SKILL.md +1 -1
  70. package/templates/skills/engagement-help/SKILL.md +1 -1
  71. package/templates/skills/execute/SKILL.md +7 -1
  72. package/templates/skills/execute/phases/post-impl-checklist.md +18 -0
  73. package/templates/skills/execute-group/SKILL.md +76 -24
  74. package/templates/skills/inbox/SKILL.md +97 -13
  75. package/templates/skills/orient/SKILL.md +168 -52
  76. package/templates/skills/orient/phases/checklist-status.md +12 -0
  77. package/templates/skills/plan/SKILL.md +22 -6
  78. package/templates/skills/qa-drain/SKILL.md +119 -0
  79. package/templates/skills/qa-handoff/SKILL.md +132 -5
  80. package/templates/skills/session-handoff/SKILL.md +334 -0
  81. package/templates/skills/setup-accounts/SKILL.md +1 -1
  82. package/templates/skills/triage-audit/SKILL.md +6 -0
  83. package/templates/skills/unwrap/SKILL.md +1 -1
  84. package/templates/skills/verify/SKILL.md +2 -2
  85. package/templates/skills/watchtower/SKILL.md +64 -1
  86. package/templates/watchtower/config.json.template +3 -1
  87. package/templates/watchtower/queue/items/item.json.schema +10 -1
  88. package/templates/workflows/deliberative-audit.js +3 -0
  89. package/templates/workflows/execute-group-complete.js +93 -16
  90. package/templates/workflows/execute-group-implement.js +164 -19
package/README.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # Claude Cabinet
2
2
 
3
3
  A cabinet of expert advisors for your Claude Code project. One command
4
- gives Claude a memory, 32 domain experts, a planning process, and the
4
+ gives Claude a memory, 34 domain experts, a planning process, and the
5
5
  habit of starting sessions informed and ending them properly.
6
6
 
7
7
  Built by a guy who'd rather talk to Claude than write code. Most of it
@@ -12,7 +12,7 @@ was built by Claude. I just complained until it (mostly) worked.
12
12
  Your project gets a cabinet — specialist advisors who each own a domain
13
13
  and weigh in when their expertise matters:
14
14
 
15
- - **Cabinet members** — 32 domain experts (security, accessibility,
15
+ - **Cabinet members** — 34 domain experts (security, accessibility,
16
16
  architecture, QA, etc.) who review your project and surface what
17
17
  you'd miss alone
18
18
  - **Briefings** — project context members read before weighing in
@@ -78,7 +78,7 @@ left off.
78
78
 
79
79
  ### The Cabinet (included in lean)
80
80
 
81
- 32 expert cabinet members who each own a domain and stay in their lane.
81
+ 34 expert cabinet members who each own a domain and stay in their lane.
82
82
  **Speed-freak** watches performance. **Boundary-man** catches edge cases.
83
83
  **Record-keeper** flags when docs drift from code. **Workflow-cop**
84
84
  evaluates whether your process actually works. Each member has a
@@ -241,8 +241,13 @@ npx create-claude-cabinet --yes # Accept all defaults
241
241
  npx create-claude-cabinet --yes --no-db # All defaults, skip database
242
242
  npx create-claude-cabinet --dry-run # Preview without writing files
243
243
  npx create-claude-cabinet --modules verify --yes # Add an opt-in module (merges, doesn't replace)
244
+ npx create-claude-cabinet --frontier-model claude-fable-5 # Designate your frontier model (watchdog)
244
245
  ```
245
246
 
247
+ ### Frontier-model watchdog
248
+
249
+ `--frontier-model <model>` records, once, which model your heavy thinking is supposed to run on. The designation is per-operator (stored in `~/.claude/cc-registry.json` under `frontierModel`, not per-project), and the installer prints the effective value on every run. From then on, `/orient` — and, on watchtower installs, the SessionStart hook — compares the session's actual model against it and leads the briefing with a loud warning on mismatch. The key can be an exact model ID (`claude-fable-5`, exact match required) or a family alias (`fable`, matches any model ID containing it). This is **visibility only**: nothing is pinned, blocked, or rerouted — it just makes "you're accidentally on the wrong model" impossible to miss. A stale key after a model-family transition nags loudly by design; update it with the same flag.
250
+
246
251
  ## What Gets Installed
247
252
 
248
253
  Everything goes into `.claude/` or `scripts/`. Nothing touches your
@@ -251,7 +256,7 @@ source code.
251
256
  ```
252
257
  .claude/
253
258
  ├── skills/ # orient, debrief, plan, execute, audit, etc.
254
- │ └── cabinet-*/ # 32 cabinet member definitions
259
+ │ └── cabinet-*/ # 34 cabinet member definitions
255
260
  ├── cabinet/ # committees, lifecycle, composition patterns
256
261
  │ # (incl. pib-db-access.md, pib-db-triggers.md)
257
262
  ├── briefing/ # project briefing templates
package/lib/cli.js CHANGED
@@ -3,7 +3,7 @@ const path = require('path');
3
3
  const fs = require('fs');
4
4
  const os = require('os');
5
5
  const crypto = require('crypto');
6
- const { copyTemplates } = require('./copy');
6
+ const { copyTemplates, recordSkip } = require('./copy');
7
7
  const { mergeSettings, healUserSettings, mergeWatchtowerHooks, mergeMuxHooks, mergeBashCompressHooks } = require('./settings-merge');
8
8
  const { create: createMetadata, read: readMetadata } = require('./metadata');
9
9
  const { setupDb } = require('./db-setup');
@@ -12,6 +12,7 @@ const { setupSiteAuditRuntime } = require('./site-audit-setup');
12
12
  const { setupEngagement } = require('./engagement-setup');
13
13
  const { setupMux } = require('./mux-setup');
14
14
  const { setupEngagementServer } = require('./engagement-server-setup');
15
+ const { refreshWatchtowerRuntime } = require('./watchtower-setup');
15
16
  const { reset } = require('./reset');
16
17
 
17
18
  const VERSION = require('../package.json').version;
@@ -460,11 +461,13 @@ const MODULES = {
460
461
  'skills/orient-quick',
461
462
  'skills/debrief',
462
463
  'skills/debrief-quick',
464
+ 'skills/session-handoff',
463
465
  // Instruction phases — always ship, overriding the default skip-phases rule in copy.js
464
466
  'skills/debrief/phases/audit-pattern-capture.md',
465
467
  'skills/debrief/phases/methodology-capture.md',
466
468
  'skills/debrief/phases/record-lessons.md',
467
469
  'skills/debrief/phases/upstream-feedback.md',
470
+ 'skills/debrief/phases/qa-handoff-sweep.md',
468
471
  'skills/menu',
469
472
  ],
470
473
  },
@@ -491,7 +494,7 @@ const MODULES = {
491
494
  mandatory: false,
492
495
  default: true,
493
496
  lean: true,
494
- templates: ['skills/plan', 'skills/execute', 'skills/execute/phases/post-impl-checklist.md', 'skills/debrief/phases/checklist-feedback.md', 'skills/checklist-discover', 'skills/generate-plan-groups', 'skills/execute-group', 'workflows/execute-group-implement.js', 'workflows/execute-group-complete.js', 'skills/investigate', 'cabinet/checkpoint-protocol.md', 'cabinet/elicitation-methods.md', 'cabinet/qa-dimensions-template.yaml', 'scripts/qa-dimensions-validator.cjs', 'skills/orient/phases/checklist-status.md'],
497
+ templates: ['skills/plan', 'skills/execute', 'skills/execute/phases/post-impl-checklist.md', 'skills/debrief/phases/checklist-feedback.md', 'skills/checklist-discover', 'skills/generate-plan-groups', 'skills/execute-group', 'workflows/execute-group-implement.js', 'workflows/execute-group-complete.js', 'skills/investigate', 'cabinet/checkpoint-protocol.md', 'cabinet/elicitation-methods.md', 'cabinet/qa-dimensions-template.yaml', 'cabinet/checklist-stats-schema.md', 'scripts/qa-dimensions-validator.cjs', 'skills/orient/phases/checklist-status.md', 'skills/audit/phases/checklist-pruning.md'],
495
498
  },
496
499
  'compliance': {
497
500
  name: 'Compliance Stack (rules + enforcement)',
@@ -630,6 +633,9 @@ const MODULES = {
630
633
  'cabinet/watchtower-contracts.md',
631
634
  'scripts/watchtower-lib.mjs',
632
635
  'scripts/watchtower-queue.mjs',
636
+ 'scripts/watchtower-routines.mjs',
637
+ 'scripts/watchtower-advisories.mjs',
638
+ 'cabinet/advisories-state-schema.md',
633
639
  'skills/inbox',
634
640
  'hooks/watchtower-session-start.sh',
635
641
  'scripts/watchtower-build-context.mjs',
@@ -653,6 +659,7 @@ const MODULES = {
653
659
  'skills/briefing',
654
660
  'skills/threads',
655
661
  'skills/qa-handoff',
662
+ 'skills/qa-drain',
656
663
  ],
657
664
  },
658
665
  mux: {
@@ -750,6 +757,14 @@ function parseArgs(argv) {
750
757
  else if (arg === '--modules' && i + 1 < args.length) {
751
758
  flags.modules = args[++i].split(',').map(s => s.trim()).filter(Boolean);
752
759
  }
760
+ else if (arg === '--frontier-model' && i + 1 < args.length) {
761
+ // Empty/whitespace values are treated as absent: '' is a substring of
762
+ // every model ID, which would match everything and leave the watchdog
763
+ // permanently silent while appearing configured.
764
+ const value = args[++i].trim();
765
+ if (value) flags.frontierModel = value;
766
+ else flags.frontierModelEmpty = true;
767
+ }
753
768
  else if (!arg.startsWith('-')) flags.targetDir = arg;
754
769
  }
755
770
 
@@ -775,6 +790,11 @@ function printHelp() {
775
790
  disables omega hooks/MCP. Idempotent — safe to re-run.
776
791
  Pair with --dry-run to preview.
777
792
  --unmigrate-memory Roll back --migrate-memory using its backup dir.
793
+ --frontier-model <model> Designate your frontier model (user-level, stored
794
+ in ~/.claude/cc-registry.json). Visibility only: /orient and
795
+ the watchtower SessionStart hook warn loudly when a session
796
+ runs a different model. Does NOT pin or route anything.
797
+ Accepts an exact ID (claude-fable-5) or a family alias (fable).
778
798
  --help, -h Show this help
779
799
 
780
800
  Examples:
@@ -1214,7 +1234,7 @@ async function run() {
1214
1234
  const existingContent = fs.readFileSync(destPath, 'utf8');
1215
1235
  if (existingContent === incoming) {
1216
1236
  totalSkipped++;
1217
- allManifest[mPath] = incomingHash;
1237
+ recordSkip(allManifest, mPath, { identical: true, incomingHash });
1218
1238
  continue;
1219
1239
  }
1220
1240
 
@@ -1230,7 +1250,9 @@ async function run() {
1230
1250
  if (isPhaseFile && !isInstructionPhase && existingContent.trim() !== '' && existingContent.trim() !== incoming.trim()) {
1231
1251
  console.log(` Preserved customized phase: ${tmpl}`);
1232
1252
  totalSkipped++;
1233
- allManifest[mPath] = hashContent(existingContent);
1253
+ // Customized phase = project-owned content → omit from manifest
1254
+ // (recordSkip in copy.js — omission means "not ours").
1255
+ recordSkip(allManifest, mPath);
1234
1256
  continue;
1235
1257
  }
1236
1258
 
@@ -1240,10 +1262,19 @@ async function run() {
1240
1262
  if (existingManifest[mPath]) {
1241
1263
  if (!flags.dryRun) fs.copyFileSync(srcPath, destPath);
1242
1264
  totalOverwritten++;
1265
+ // Log single-file overwrites too — the directory path (copy.js)
1266
+ // already does. Without this, scripts/ updates are invisible in
1267
+ // install output, masking whether a changed script propagated.
1268
+ console.log(` Updated: ${path.relative(projectDir, destPath)}`);
1269
+ allManifest[mPath] = incomingHash;
1243
1270
  } else {
1244
1271
  totalSkipped++;
1272
+ // Project-created file → omit from manifest entirely. Ownership
1273
+ // classification is manifest-PRESENCE-based, so recording ANY
1274
+ // hash here would mark the file upstream-owned and the NEXT
1275
+ // install would silently overwrite it (act:bf21c95b).
1276
+ recordSkip(allManifest, mPath);
1245
1277
  }
1246
- allManifest[mPath] = incomingHash;
1247
1278
  } else {
1248
1279
  const response = await prompts({
1249
1280
  type: 'select',
@@ -1258,10 +1289,13 @@ async function run() {
1258
1289
  if (response.action === 'overwrite') {
1259
1290
  if (!flags.dryRun) fs.copyFileSync(srcPath, destPath);
1260
1291
  totalOverwritten++;
1292
+ allManifest[mPath] = incomingHash;
1261
1293
  } else {
1262
1294
  totalSkipped++;
1295
+ // Keep: the user claimed this file → project-owned → omit from
1296
+ // the manifest so it is never mistaken for upstream content.
1297
+ recordSkip(allManifest, mPath);
1263
1298
  }
1264
- allManifest[mPath] = incomingHash;
1265
1299
  }
1266
1300
  } else {
1267
1301
  if (!flags.dryRun) fs.copyFileSync(srcPath, destPath);
@@ -1382,6 +1416,27 @@ async function run() {
1382
1416
  }
1383
1417
  }
1384
1418
 
1419
+ // --- Refresh the GLOBAL watchtower runtime (content-aware) ---
1420
+ // The watchtower module copies its files into the PROJECT, but the global
1421
+ // runtime at ~/.claude-cabinet/watchtower/ is set up only by the one-time
1422
+ // `/watchtower install` SKILL.md step. Without this, a reinstall left the
1423
+ // global runtime scripts/docs/hooks stale and never delivered newly shipped
1424
+ // ones. refreshWatchtowerRuntime is REFRESH-ONLY: it no-ops (status
1425
+ // 'absent', zero writes) when no runtime exists yet, so it's safe to call
1426
+ // unconditionally whenever the module is selected.
1427
+ if (selectedModules.includes('watchtower')) {
1428
+ try {
1429
+ const result = refreshWatchtowerRuntime({ dryRun: !!flags.dryRun });
1430
+ if (result.status !== 'absent') {
1431
+ console.log('');
1432
+ for (const r of result.results || []) console.log(` 📋 ${r}`);
1433
+ }
1434
+ } catch (err) {
1435
+ console.log(` ⚠ watchtower runtime refresh failed: ${err.message}`);
1436
+ console.log(' Re-run the installer to retry.');
1437
+ }
1438
+ }
1439
+
1385
1440
  // --- Manifest key migration (act:d1f16bee) ---
1386
1441
  // When CC renames directories (e.g., perspectives/ → cabinet-*/), old manifest
1387
1442
  // keys no longer match new template paths. Migrate keys BEFORE cleanup so the
@@ -1610,6 +1665,18 @@ async function run() {
1610
1665
  // Register with folder name. /onboard fills in name and description later.
1611
1666
  registry.projects.push(entry);
1612
1667
  }
1668
+ // --- Frontier-model designation (visibility watchdog) ---
1669
+ // User-level, per-operator key. Read-preserve-rewrite: only the
1670
+ // frontierModel key is touched; every other key rides through.
1671
+ if (flags.frontierModelEmpty) {
1672
+ console.log(' ⚠ Ignoring empty --frontier-model value (an empty key would match every model and silence the watchdog)');
1673
+ }
1674
+ if (flags.frontierModel) {
1675
+ registry.frontierModel = flags.frontierModel;
1676
+ } else if (typeof registry.frontierModel === 'string' && !registry.frontierModel.trim()) {
1677
+ // Heal a hand-edited empty key — treat as absent (see parseArgs note).
1678
+ delete registry.frontierModel;
1679
+ }
1613
1680
  fs.writeFileSync(registryPath, JSON.stringify(registry, null, 2) + '\n');
1614
1681
  const otherCount = registry.projects.filter(p => p.path !== projectDir).length;
1615
1682
  if (otherCount > 0) {
@@ -1617,6 +1684,10 @@ async function run() {
1617
1684
  } else {
1618
1685
  console.log(' 📋 Registered in project registry');
1619
1686
  }
1687
+ // Self-announcing: print the effective designation on every run.
1688
+ if (registry.frontierModel) {
1689
+ console.log(` 🛰 Frontier model: ${registry.frontierModel} (visibility watchdog — /orient + SessionStart warn on mismatch; nothing is pinned)`);
1690
+ }
1620
1691
  } catch (err) {
1621
1692
  // Non-fatal — registry is nice-to-have
1622
1693
  }
package/lib/copy.js CHANGED
@@ -7,6 +7,49 @@ function hashContent(content) {
7
7
  return crypto.createHash('sha256').update(content).digest('hex').slice(0, 16);
8
8
  }
9
9
 
10
+ /**
11
+ * Record the manifest consequence of SKIPPING a file at install time
12
+ * (act:bf21c95b). The single shared rule for ALL skip sites in BOTH
13
+ * install code paths (lib/copy.js and the single-file branches in
14
+ * lib/cli.js):
15
+ *
16
+ * - A skipped file whose on-disk content differs from the incoming
17
+ * template is NOT upstream content — it is project-owned
18
+ * (project-created, user-kept, or a customized phase). It must be
19
+ * OMITTED from the manifest entirely. An absent entry means "not
20
+ * ours". Recording any hash for it poisons the manifest: ownership
21
+ * classification is manifest-PRESENCE-based, so the next install
22
+ * would classify the file upstream-owned and silently overwrite it
23
+ * (and cc-upstream-guard / cc-drift-check would false-positive on it).
24
+ * - The one exception: a skipped file byte-identical to the incoming
25
+ * template is indistinguishable from upstream content and stays
26
+ * tracked under the template hash.
27
+ *
28
+ * Omission only — never a marker value or an alternate manifest value
29
+ * shape (lesson_shared_json_shape_drift). All manifest consumers
30
+ * (ownership classification, cleanup loop, key migration, cc-drift-check,
31
+ * cc-upstream-guard, lib/reset.js) treat an absent key as "not ours" and
32
+ * leave the file alone.
33
+ *
34
+ * Known limitation (documented, not solved here): manifests already
35
+ * poisoned by past installs cannot retroactively distinguish a recorded
36
+ * project-created file from genuine upstream content. This helper only
37
+ * prevents NEW poisoning.
38
+ *
39
+ * @param {object} manifest manifest object being built for this install
40
+ * @param {string} key manifest key for the skipped file
41
+ * @param {object} [opts]
42
+ * @param {boolean} [opts.identical] on-disk content === incoming template
43
+ * @param {string} [opts.incomingHash] hash of the incoming template content
44
+ */
45
+ function recordSkip(manifest, key, { identical = false, incomingHash = null } = {}) {
46
+ if (identical && incomingHash) {
47
+ manifest[key] = incomingHash;
48
+ } else {
49
+ delete manifest[key];
50
+ }
51
+ }
52
+
10
53
  /**
11
54
  * Recursively copy files from src to dest, surfacing conflicts.
12
55
  * Returns { copied: string[], skipped: string[], overwritten: string[] }
@@ -55,7 +98,8 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
55
98
  const trimmedExisting = existing.trim();
56
99
  if (trimmedExisting !== '' && trimmedExisting !== incoming.trim()) {
57
100
  results.skipped.push(relPath);
58
- results.manifest[relPath] = hashContent(existing);
101
+ // Customized phase = project-owned content → omit from manifest.
102
+ recordSkip(results.manifest, relPath);
59
103
  console.log(` Preserved customized phase: ${displayPath}`);
60
104
  continue;
61
105
  }
@@ -64,7 +108,7 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
64
108
 
65
109
  if (existing === incoming) {
66
110
  results.skipped.push(relPath);
67
- results.manifest[relPath] = incomingHash;
111
+ recordSkip(results.manifest, relPath, { identical: true, incomingHash });
68
112
  continue;
69
113
  }
70
114
 
@@ -82,9 +126,8 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
82
126
  console.log(` Updated: ${displayPath}`);
83
127
  } else {
84
128
  results.skipped.push(relPath);
85
- // Record the hash of what's actually on disk, not the template —
86
- // otherwise the manifest lies about file content after a skip.
87
- results.manifest[relPath] = hashContent(existing);
129
+ // Project-created file omit from manifest ("not ours").
130
+ recordSkip(results.manifest, relPath);
88
131
  }
89
132
  continue;
90
133
  }
@@ -101,9 +144,9 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
101
144
  });
102
145
 
103
146
  if (!response.action) {
104
- // User cancelled
147
+ // User cancelled → file kept as-is → project-owned → omit.
105
148
  results.skipped.push(relPath);
106
- results.manifest[relPath] = incomingHash;
149
+ recordSkip(results.manifest, relPath);
107
150
  continue;
108
151
  }
109
152
 
@@ -118,17 +161,20 @@ async function walkAndCopy(srcRoot, destRoot, currentSrc, results, dryRun, skipC
118
161
  if (followUp.overwrite && !dryRun) {
119
162
  fs.copyFileSync(srcPath, destPath);
120
163
  results.overwritten.push(relPath);
164
+ results.manifest[relPath] = incomingHash;
121
165
  } else {
166
+ // Diff shown, user kept their file → project-owned → omit.
122
167
  results.skipped.push(relPath);
168
+ recordSkip(results.manifest, relPath);
123
169
  }
124
- results.manifest[relPath] = incomingHash;
125
170
  } else if (response.action === 'overwrite') {
126
171
  if (!dryRun) fs.copyFileSync(srcPath, destPath);
127
172
  results.overwritten.push(relPath);
128
173
  results.manifest[relPath] = incomingHash;
129
174
  } else {
175
+ // 'Keep existing' → project-owned → omit from manifest.
130
176
  results.skipped.push(relPath);
131
- results.manifest[relPath] = incomingHash;
177
+ recordSkip(results.manifest, relPath);
132
178
  }
133
179
  } else {
134
180
  if (!dryRun) {
@@ -169,4 +215,4 @@ function showDiff(existing, incoming, relPath) {
169
215
  console.log('');
170
216
  }
171
217
 
172
- module.exports = { copyTemplates };
218
+ module.exports = { copyTemplates, recordSkip };
@@ -9,7 +9,10 @@
9
9
  *
10
10
  * Version semantics:
11
11
  * - First install: copy all managed files, create data dir, write .cc-version
12
- * - Same version: skip with log
12
+ * - Same version: fall through to the SHA256 manifest hash-compare and
13
+ * copy only files whose hashes differ (dogfood-from-source changes
14
+ * templates without a version bump — equal-version must still
15
+ * propagate; the manifest check makes this cheap and idempotent)
13
16
  * - Newer CC version: upgrade managed files, preserve data dir
14
17
  * - Older CC version than installed: skip (don't downgrade)
15
18
  */
@@ -23,6 +26,7 @@ const CC_HOME = path.join(os.homedir(), '.claude-cabinet');
23
26
  const GLOBAL_MANIFEST_PATH = path.join(CC_HOME, 'global-manifest.json');
24
27
  const INSTALL_DIR = path.join(CC_HOME, 'engagement-server');
25
28
  const VERSION_FILE = path.join(INSTALL_DIR, '.cc-version');
29
+ const TEMPLATE_DIR = path.resolve(__dirname, '..', 'templates', 'engagement-server');
26
30
 
27
31
  const MANAGED_FILES = [
28
32
  'server.mjs',
@@ -99,7 +103,7 @@ function setupEngagementServer(opts = {}) {
99
103
  const results = [];
100
104
 
101
105
  const ccVersion = require('../package.json').version;
102
- const templateDir = path.resolve(__dirname, '..', 'templates', 'engagement-server');
106
+ const templateDir = TEMPLATE_DIR;
103
107
 
104
108
  if (!fs.existsSync(templateDir)) {
105
109
  throw new Error(`engagement-server-setup: ${templateDir} not found.`);
@@ -109,15 +113,19 @@ function setupEngagementServer(opts = {}) {
109
113
 
110
114
  if (installedVersion) {
111
115
  const cmp = compareVersions(ccVersion, installedVersion);
112
- if (cmp === 0) {
113
- results.push(`engagement-server ${installedVersion} already installed — skipping`);
114
- return { results, status: 'skipped' };
115
- }
116
116
  if (cmp < 0) {
117
117
  results.push(`engagement-server ${installedVersion} is newer than CC ${ccVersion} — skipping (won't downgrade)`);
118
118
  return { results, status: 'skipped' };
119
119
  }
120
- results.push(`Upgrading engagement-server from ${installedVersion} to ${ccVersion}`);
120
+ // Equal version (cmp === 0): do NOT early-return. Fall through to the
121
+ // SHA256 manifest hash-compare below — it copies only changed files,
122
+ // so a dogfood-from-source template edit propagates even without a
123
+ // package.json version bump. The hash-compare makes this idempotent.
124
+ if (cmp === 0) {
125
+ results.push(`engagement-server ${installedVersion} already installed — checking for changed files`);
126
+ } else {
127
+ results.push(`Upgrading engagement-server from ${installedVersion} to ${ccVersion}`);
128
+ }
121
129
  } else {
122
130
  results.push(`Installing engagement-server ${ccVersion}`);
123
131
  }
@@ -187,7 +195,24 @@ function setupEngagementServer(opts = {}) {
187
195
  results.push(` Re-deploy: cd ${INSTALL_DIR} && railway up --detach`);
188
196
  }
189
197
 
190
- return { results, status: installedVersion ? 'upgraded' : 'installed' };
198
+ let status;
199
+ if (!installedVersion) {
200
+ status = 'installed';
201
+ } else if (copiedCount > 0) {
202
+ status = 'upgraded';
203
+ } else {
204
+ // Equal-or-newer version, manifest already current — nothing to do.
205
+ status = 'unchanged';
206
+ }
207
+ return { results, status };
191
208
  }
192
209
 
193
- module.exports = { setupEngagementServer };
210
+ // MANAGED_FILES, INSTALL_DIR, and TEMPLATE_DIR are exported for the
211
+ // version-gate test so it can seed a faithful, complete global manifest
212
+ // (single source of truth — the test never re-derives the file list).
213
+ module.exports = {
214
+ setupEngagementServer,
215
+ MANAGED_FILES,
216
+ INSTALL_DIR,
217
+ TEMPLATE_DIR,
218
+ };
@@ -82,6 +82,13 @@ function stripUserPrefix(raw, homeDir) {
82
82
  const segs = tail.split('/');
83
83
  return segs.slice(1).join('/');
84
84
  }
85
+ // Omega project keys are historical strings recorded on whatever machine
86
+ // wrote them — a DB carried from a Mac to a Linux box (or exercised on a
87
+ // Linux CI runner) still holds /Users/<name>/... paths that the current
88
+ // machine's home root won't match. Recognize the conventional home roots
89
+ // structurally so cross-machine migrations classify instead of crashing.
90
+ const foreignHome = raw.match(/^\/(?:Users|home)\/[^/]+\/(.+)$/);
91
+ if (foreignHome) return foreignHome[1];
85
92
  return raw;
86
93
  }
87
94
 
@@ -100,7 +107,12 @@ function canonicalizeProjectKey(raw, ctx = {}) {
100
107
  const slugPath = stripUserPrefix(work, ctx.homeDir);
101
108
  const topSlug = slugPath.split('/')[0];
102
109
 
103
- return { canonical: topSlug || null, kind: 'project' };
110
+ // kind 'project' with a null canonical is an incoherent contract — the
111
+ // bucketing consumer would key a cross-project map on null and crash on
112
+ // slug derivation. An unclassifiable key is unscoped, not a null project.
113
+ if (!topSlug) return { canonical: null, kind: 'unscoped' };
114
+
115
+ return { canonical: topSlug, kind: 'project' };
104
116
  }
105
117
 
106
118
  function resolveCurrentProject(cwd, homeDir) {
package/lib/mux-setup.js CHANGED
@@ -8,7 +8,10 @@
8
8
  *
9
9
  * Version semantics:
10
10
  * - First install: copy all managed files, write .cc-version
11
- * - Same version: skip with log
11
+ * - Same version: fall through to the SHA256 manifest hash-compare and
12
+ * copy only files whose hashes differ (dogfood-from-source changes
13
+ * templates without a version bump — equal-version must still
14
+ * propagate; the manifest check makes this cheap and idempotent)
12
15
  * - Newer CC version: upgrade managed files, preserve data dirs
13
16
  * - Older CC version than installed: skip (don't downgrade)
14
17
  */
@@ -21,6 +24,7 @@ const crypto = require('crypto');
21
24
  const CC_HOME = path.join(os.homedir(), '.claude-cabinet');
22
25
  const GLOBAL_MANIFEST_PATH = path.join(CC_HOME, 'global-manifest.json');
23
26
  const MUX_VERSION_FILE = path.join(os.homedir(), '.config', 'mux', '.cc-version');
27
+ const TEMPLATE_DIR = path.resolve(__dirname, '..', 'templates', 'mux');
24
28
 
25
29
  const MANAGED_FILES = [
26
30
  { src: 'bin/mux', dest: path.join(os.homedir(), '.local', 'bin', 'mux'), mode: 0o755 },
@@ -45,6 +49,7 @@ const MANAGED_FILES = [
45
49
  { src: 'config/worktree-session-health.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-session-health.sh'), mode: 0o755 },
46
50
  { src: 'config/worktree-health-popup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-health-popup.sh'), mode: 0o755 },
47
51
  { src: 'config/worktree-cleanup.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-cleanup.sh'), mode: 0o755 },
52
+ { src: 'config/worktree-dirty-check.sh', dest: path.join(os.homedir(), '.config', 'mux', 'worktree-dirty-check.sh'), mode: 0o755 },
48
53
  { src: 'config/mux.tmux.conf', dest: path.join(os.homedir(), '.config', 'mux', 'mux.tmux.conf') },
49
54
  { src: 'config/unwrap-copy.py', dest: path.join(os.homedir(), '.config', 'mux', 'unwrap-copy.py'), mode: 0o755 },
50
55
  { src: 'config/screenshot-to-clipboard.sh', dest: path.join(os.homedir(), '.config', 'mux', 'screenshot-to-clipboard.sh'), mode: 0o755 },
@@ -121,7 +126,7 @@ function setupMux(opts = {}) {
121
126
  const results = [];
122
127
 
123
128
  const ccVersion = require('../package.json').version;
124
- const templateDir = path.resolve(__dirname, '..', 'templates', 'mux');
129
+ const templateDir = TEMPLATE_DIR;
125
130
 
126
131
  if (!fs.existsSync(templateDir)) {
127
132
  throw new Error(`mux-setup: ${templateDir} not found.`);
@@ -131,15 +136,19 @@ function setupMux(opts = {}) {
131
136
 
132
137
  if (installedVersion) {
133
138
  const cmp = compareVersions(ccVersion, installedVersion);
134
- if (cmp === 0) {
135
- results.push(`mux ${installedVersion} already installed — skipping`);
136
- return { results, status: 'skipped' };
137
- }
138
139
  if (cmp < 0) {
139
140
  results.push(`mux ${installedVersion} is newer than CC ${ccVersion} — skipping (won't downgrade)`);
140
141
  return { results, status: 'skipped' };
141
142
  }
142
- results.push(`Upgrading mux from ${installedVersion} to ${ccVersion}`);
143
+ // Equal version (cmp === 0): do NOT early-return. Fall through to the
144
+ // SHA256 manifest hash-compare below — it copies only changed files,
145
+ // so a dogfood-from-source template edit propagates even without a
146
+ // package.json version bump. The hash-compare makes this idempotent.
147
+ if (cmp === 0) {
148
+ results.push(`mux ${installedVersion} already installed — checking for changed files`);
149
+ } else {
150
+ results.push(`Upgrading mux from ${installedVersion} to ${ccVersion}`);
151
+ }
143
152
  } else {
144
153
  results.push(`Installing mux ${ccVersion}`);
145
154
  }
@@ -199,7 +208,16 @@ function setupMux(opts = {}) {
199
208
  setupDarwinIntegration({ dryRun, results });
200
209
  }
201
210
 
202
- return { results, status: installedVersion ? 'upgraded' : 'installed' };
211
+ let status;
212
+ if (!installedVersion) {
213
+ status = 'installed';
214
+ } else if (copiedCount > 0) {
215
+ status = 'upgraded';
216
+ } else {
217
+ // Equal-or-newer version, manifest already current — nothing to do.
218
+ status = 'unchanged';
219
+ }
220
+ return { results, status };
203
221
  }
204
222
 
205
223
  /**
@@ -307,4 +325,11 @@ function setupDarwinIntegration({ dryRun, results }) {
307
325
  }
308
326
  }
309
327
 
310
- module.exports = { setupMux };
328
+ // MANAGED_FILES and TEMPLATE_DIR are exported for the version-gate test so it
329
+ // can seed a faithful, complete global manifest (single source of truth — the
330
+ // test never re-derives the file list).
331
+ module.exports = {
332
+ setupMux,
333
+ MANAGED_FILES,
334
+ TEMPLATE_DIR,
335
+ };