create-claude-cabinet 0.27.0 → 0.27.2

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.
package/README.md CHANGED
@@ -186,7 +186,7 @@ absent to use the default. No config files, no YAML, no DSL.
186
186
 
187
187
  ## Adding Modules to an Existing Install
188
188
 
189
- Some modules (like `verify` and `memory`) are opt-in. To add one
189
+ Some modules (like `verify`) are opt-in. To add one
190
190
  without touching anything else in your install:
191
191
 
192
192
  ```
@@ -196,7 +196,7 @@ npx create-claude-cabinet --modules verify --yes
196
196
  The `--modules` flag **merges** with your existing install — it adds
197
197
  the listed modules to what's already there, it doesn't replace your
198
198
  module set. Safe to run on a mature project without losing
199
- customization. You can pass multiple modules: `--modules verify,memory`.
199
+ customization. You can pass multiple modules: `--modules verify,audit`.
200
200
 
201
201
  ## CLI Options
202
202
 
package/lib/cli.js CHANGED
@@ -343,6 +343,15 @@ function generateSkillIndex(projectDir) {
343
343
  return entries.length;
344
344
  }
345
345
 
346
+ // MODULES is the manifest: every template path here is copied into a
347
+ // consumer's project on install. A skill/hook/script that exists under
348
+ // templates/ but is NOT listed in any module never ships — that is the
349
+ // orphan bug that left the whole built-in-memory layer uninstalled
350
+ // before v0.27.2 (test/manifest-integrity guards against recurrence).
351
+ //
352
+ // Intentional orphans (maintainer-only, must NOT ship to consumers) are
353
+ // allowlisted in test/manifest-integrity. Currently: skills/cc-publish
354
+ // (CC-source-repo release tooling, like scripts/migrate-all-consumers.js).
346
355
  const MODULES = {
347
356
  'session-loop': {
348
357
  name: 'Session Loop (orient + debrief)',
@@ -367,7 +376,7 @@ const MODULES = {
367
376
  mandatory: false,
368
377
  default: true,
369
378
  lean: true,
370
- templates: ['hooks/git-guardrails.sh', 'hooks/cc-upstream-guard.sh', 'hooks/skill-telemetry.sh', 'hooks/skill-tool-telemetry.sh', 'hooks/work-tracker-guard.sh', 'hooks/action-quality-gate.sh', 'hooks/action-completion-gate.sh', 'hooks/domain-memories.sh', 'scripts/cc-drift-check.cjs'],
379
+ templates: ['hooks/git-guardrails.sh', 'hooks/cc-upstream-guard.sh', 'hooks/skill-telemetry.sh', 'hooks/skill-tool-telemetry.sh', 'hooks/work-tracker-guard.sh', 'hooks/action-quality-gate.sh', 'hooks/action-completion-gate.sh', 'hooks/memory-index-guard.sh', 'scripts/cc-drift-check.cjs'],
371
380
  },
372
381
  'work-tracking': {
373
382
  name: 'Work Tracking (pib-db or markdown)',
@@ -394,6 +403,27 @@ const MODULES = {
394
403
  lean: false,
395
404
  templates: ['rules/enforcement-pipeline.md', 'memory/patterns/_pattern-template.md', 'memory/patterns/pattern-intelligence-first.md'],
396
405
  },
406
+ 'memory': {
407
+ name: 'Built-In Memory (cc-remember + reader + validator)',
408
+ description: 'Curated write/validate layer over Claude Code\'s built-in file memory. /cc-remember writes indexed memories, /memory browses them, validate-memory.mjs guards MEMORY.md integrity. Replaced the retired omega engine in v0.27.',
409
+ mandatory: false,
410
+ default: true,
411
+ // lean:true is load-bearing — it keeps the skill triad, scripts, and
412
+ // rule installing together as an atomic unit. compliance is lean:false,
413
+ // so the memory-capture rule must NOT live there or lean installs get a
414
+ // partial, incoherent set.
415
+ lean: true,
416
+ templates: [
417
+ 'skills/cc-remember',
418
+ 'skills/memory',
419
+ 'rules/memory-capture.md',
420
+ 'scripts/write-memory-file.mjs',
421
+ 'scripts/validate-memory.mjs',
422
+ // project-context.cjs ships as a co-located sibling: the two .mjs
423
+ // scripts require('./project-context.cjs') and consumers have no lib/.
424
+ 'scripts/project-context.cjs',
425
+ ],
426
+ },
397
427
  'audit': {
398
428
  name: 'Audit Loop (audit + triage + cabinet)',
399
429
  description: '27 expert cabinet members review your project. Convene the full cabinet or just one committee.',
@@ -549,6 +549,111 @@ function buildEdgesJson(edges) {
549
549
  );
550
550
  }
551
551
 
552
+ // ---------------------------------------------------------------------------
553
+ // Merge mode — additive, never clobbers native memory
554
+ // ---------------------------------------------------------------------------
555
+
556
+ const OMEGA_SUBDIR = 'omega-migrated';
557
+
558
+ /**
559
+ * Build the MEMORY.md section that indexes omega topic files living under
560
+ * the omega-migrated/ subdir. Carries the preamble marker so a re-run
561
+ * detects already-migrated. Paths are prefixed with the subdir.
562
+ */
563
+ function buildMergeSection(topicFileMeta, summary) {
564
+ const lines = [
565
+ PREAMBLE_MARKER,
566
+ '',
567
+ `## Migrated from omega (${new Date().toISOString().slice(0, 10)})`,
568
+ '',
569
+ `_${summary.migrated} memories migrated from omega into \`${OMEGA_SUBDIR}/\`. ` +
570
+ `Native memory files above are unchanged. ${summary.edges} edges in ${OMEGA_SUBDIR}/edges.json._`,
571
+ '',
572
+ ];
573
+ for (const { topic, file, count } of topicFileMeta) {
574
+ const desc = describeTopic(topic);
575
+ lines.push(`- [${topic}](${OMEGA_SUBDIR}/${file}) (${count}) — ${desc}`);
576
+ }
577
+ return lines.join('\n') + '\n';
578
+ }
579
+
580
+ /**
581
+ * Additive merge: write omega topic files into an omega-migrated/ subdir
582
+ * (zero filename collision with native files), back up the existing dir
583
+ * first, and append/create a MEMORY.md section indexing them. Never
584
+ * overwrites a native file.
585
+ *
586
+ * @returns {{ backupDir, omegaDir, indexedInto }}
587
+ */
588
+ function mergeIntoExisting(outputDir, topicFiles, edgesJson, mergeSection, opts = {}) {
589
+ const omegaDir = path.join(outputDir, OMEGA_SUBDIR);
590
+ const memoryMdPath = path.join(outputDir, 'MEMORY.md');
591
+
592
+ // GUARD: never clobber whatever already occupies the subdir path. If it
593
+ // exists, it is either a native item the user named `omega-migrated/`, or
594
+ // residue from an interrupted prior merge. We cannot safely tell them
595
+ // apart, and the cost of guessing wrong is permanent loss of native data.
596
+ // Refuse and instruct — the caller surfaces this as a failure with the
597
+ // native dir fully intact (nothing has been written yet at this point).
598
+ if (fs.existsSync(omegaDir)) {
599
+ throw new Error(
600
+ `Refusing to merge: '${OMEGA_SUBDIR}/' already exists at ${omegaDir}. ` +
601
+ `If it is leftover from an interrupted migration, remove it and re-run. ` +
602
+ `If it is your own data, rename it first, then re-run.`
603
+ );
604
+ }
605
+
606
+ // 1. Back up the existing memory dir wholesale, to a guaranteed-fresh path,
607
+ // BEFORE any destructive step. Never proceed without a backup taken
608
+ // this invocation (a stale/reused backup wouldn't reflect pristine
609
+ // native state).
610
+ let backupDir = opts.backupDir;
611
+ if (!backupDir) {
612
+ const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, '-');
613
+ const base = `${outputDir}.pre-omega-merge-${stamp}`;
614
+ backupDir = base;
615
+ for (let n = 1; fs.existsSync(backupDir); n++) backupDir = `${base}-${n}`;
616
+ } else if (fs.existsSync(backupDir)) {
617
+ throw new Error(`Refusing to merge: backup path ${backupDir} already exists.`);
618
+ }
619
+ fs.cpSync(outputDir, backupDir, { recursive: true });
620
+
621
+ // 2. Write omega topic files + edges into the subdir (atomic via staging
622
+ // rename of the subdir). omegaDir is known absent from the guard above.
623
+ const stagingDir = path.join(outputDir, `.${OMEGA_SUBDIR}-staging-${process.pid}`);
624
+ fs.rmSync(stagingDir, { recursive: true, force: true });
625
+ fs.mkdirSync(stagingDir, { recursive: true });
626
+ for (const [name, content] of Object.entries(topicFiles)) {
627
+ fs.writeFileSync(path.join(stagingDir, name), content, 'utf8');
628
+ }
629
+ if (edgesJson) fs.writeFileSync(path.join(stagingDir, 'edges.json'), edgesJson, 'utf8');
630
+ fs.renameSync(stagingDir, omegaDir);
631
+
632
+ // 3. Append/create MEMORY.md with the omega section (preamble marker
633
+ // included so re-runs detect already-migrated). Idempotent: if the
634
+ // marker is somehow already present, do not append a second section.
635
+ let indexedInto;
636
+ if (fs.existsSync(memoryMdPath)) {
637
+ const existing = fs.readFileSync(memoryMdPath, 'utf8');
638
+ if (existing.includes(PREAMBLE_MARKER)) {
639
+ indexedInto = 'already-indexed';
640
+ } else {
641
+ const sep = existing.endsWith('\n') ? '\n' : '\n\n';
642
+ const merged = existing + sep + mergeSection;
643
+ const tmp = memoryMdPath + '.tmp-' + process.pid;
644
+ fs.writeFileSync(tmp, merged, 'utf8');
645
+ fs.renameSync(tmp, memoryMdPath);
646
+ indexedInto = 'appended-to-existing';
647
+ }
648
+ } else {
649
+ const header = `# Memory Index\n\n_Native memory files in this directory predate the index; Claude reads them on demand._\n\n`;
650
+ fs.writeFileSync(memoryMdPath, header + mergeSection, 'utf8');
651
+ indexedInto = 'created-new';
652
+ }
653
+
654
+ return { backupDir, omegaDir, indexedInto };
655
+ }
656
+
552
657
  // ---------------------------------------------------------------------------
553
658
  // Main orchestrator
554
659
  // ---------------------------------------------------------------------------
@@ -564,19 +669,17 @@ async function migrateFromOmega(opts = {}) {
564
669
  const currentProject = opts.currentProject || resolveCurrentProject(cwd, homeDir);
565
670
  const outputDir = resolveOutputDir({ ...opts, cwd, homeDir });
566
671
 
672
+ // Determine write mode. Foreign content (native memory present, no
673
+ // migration preamble) triggers MERGE — additive, never clobbers — so
674
+ // omega memories land alongside native memory instead of being skipped.
675
+ let mode = 'fresh';
567
676
  if (!opts.force) {
568
677
  const existing = checkExistingMigration(outputDir);
569
678
  if (existing.state === 'migrated') {
570
679
  return { migrated: 0, reason: 'already-migrated', outputDir };
571
680
  }
572
681
  if (existing.state === 'foreign-content' || existing.state === 'partial-or-foreign') {
573
- return {
574
- migrated: 0,
575
- reason: existing.state,
576
- outputDir,
577
- details: existing.files || ['MEMORY.md without migration preamble'],
578
- hint: 'Inspect outputDir. If safe to overwrite, re-run with { force: true }.',
579
- };
682
+ mode = 'merge';
580
683
  }
581
684
  }
582
685
 
@@ -587,6 +690,10 @@ async function migrateFromOmega(opts = {}) {
587
690
  const { memories, edges } = readVault(vaultDir);
588
691
 
589
692
  if (memories.length === 0) {
693
+ // Nothing to migrate. In merge mode, leave native memory untouched.
694
+ if (mode === 'merge') {
695
+ return { migrated: 0, reason: 'empty-db', outputDir, mode: 'merge', noop: true };
696
+ }
590
697
  const minimalIndex = `${PREAMBLE_MARKER}\n# Memory Index\n\n_Source: migrated from omega on ${new Date()
591
698
  .toISOString()
592
699
  .slice(0, 10)}. No prior memories migrated — omega database was empty._\n`;
@@ -636,11 +743,7 @@ async function migrateFromOmega(opts = {}) {
636
743
  }
637
744
 
638
745
  const memoryMd = buildMemoryMd(topicFileMeta, summary);
639
- filesToWrite['MEMORY.md'] = memoryMd;
640
-
641
- if (edges.length > 0) {
642
- filesToWrite['edges.json'] = buildEdgesJson(edges);
643
- }
746
+ const edgesJson = edges.length > 0 ? buildEdgesJson(edges) : null;
644
747
 
645
748
  if (opts.dryRun) {
646
749
  return {
@@ -648,13 +751,39 @@ async function migrateFromOmega(opts = {}) {
648
751
  edges: edges.length,
649
752
  outputDir,
650
753
  dryRun: true,
754
+ mode,
651
755
  topicFiles: topicFileMeta,
652
- memoryMdPreview: memoryMd,
756
+ memoryMdPreview: mode === 'merge' ? buildMergeSection(topicFileMeta, summary) : memoryMd,
653
757
  omegaBin,
654
758
  currentProject,
655
759
  };
656
760
  }
657
761
 
762
+ if (mode === 'merge') {
763
+ const mergeSection = buildMergeSection(topicFileMeta, summary);
764
+ const { backupDir, omegaDir, indexedInto } = mergeIntoExisting(
765
+ outputDir,
766
+ filesToWrite,
767
+ edgesJson,
768
+ mergeSection,
769
+ opts
770
+ );
771
+ return {
772
+ migrated: memories.length,
773
+ edges: edges.length,
774
+ outputDir,
775
+ mode: 'merge',
776
+ backupDir,
777
+ omegaDir,
778
+ indexedInto,
779
+ topicFiles: topicFileMeta,
780
+ currentProject,
781
+ };
782
+ }
783
+
784
+ // Fresh write (empty dir, or force clobber).
785
+ filesToWrite['MEMORY.md'] = memoryMd;
786
+ if (edgesJson) filesToWrite['edges.json'] = edgesJson;
658
787
  const stagingDir = path.join(path.dirname(outputDir), STAGING_PREFIX + process.pid);
659
788
  writeStaging(stagingDir, filesToWrite);
660
789
  commitStaging(stagingDir, outputDir, { force: opts.force });
@@ -663,6 +792,7 @@ async function migrateFromOmega(opts = {}) {
663
792
  migrated: memories.length,
664
793
  edges: edges.length,
665
794
  outputDir,
795
+ mode: 'fresh',
666
796
  topicFiles: topicFileMeta,
667
797
  currentProject,
668
798
  };
@@ -221,15 +221,20 @@ async function stepMigrateMemories(ctx) {
221
221
  if (result.reason === 'already-migrated') {
222
222
  return { action: `memories already migrated; reusing existing output at ${result.outputDir}` };
223
223
  }
224
- if (result.reason === 'foreign-content' || result.reason === 'partial-or-foreign') {
225
- return {
226
- action: `migrate-from-omega refused: ${result.reason} at ${result.outputDir}. ` +
227
- `Hint: ${result.hint || 'inspect manually'}. Continuing — omega cleanup steps will still run.`,
228
- };
229
- }
230
224
  if (result.reason === 'empty-db') {
225
+ if (result.mode === 'merge') {
226
+ return { action: `omega DB was empty; native memory at ${result.outputDir} left untouched` };
227
+ }
231
228
  return { action: `omega DB was empty; minimal MEMORY.md written at ${result.outputDir}` };
232
229
  }
230
+ if (result.mode === 'merge') {
231
+ return {
232
+ action:
233
+ `merged ${result.migrated} omega memories (${result.edges || 0} edges) into existing native ` +
234
+ `memory at ${result.outputDir} (omega content under omega-migrated/; backup at ${result.backupDir}). ` +
235
+ `Native memories preserved.`,
236
+ };
237
+ }
233
238
  return {
234
239
  action: `migrated ${result.migrated} memories (${result.edges || 0} edges) → ${result.outputDir}`,
235
240
  };
@@ -70,6 +70,15 @@ const DEFAULT_HOOKS = {
70
70
  },
71
71
  ],
72
72
  },
73
+ {
74
+ matcher: 'Edit|Write',
75
+ hooks: [
76
+ {
77
+ type: 'command',
78
+ command: '.claude/hooks/memory-index-guard.sh',
79
+ },
80
+ ],
81
+ },
73
82
  ],
74
83
  };
75
84
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-claude-cabinet",
3
- "version": "0.27.0",
3
+ "version": "0.27.2",
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"
@@ -24,7 +24,8 @@
24
24
  "node": ">=18"
25
25
  },
26
26
  "scripts": {
27
- "test": "node --test test/**/*.test.js"
27
+ "test": "node --test test/**/*.test.js",
28
+ "prepublishOnly": "node -e \"if (!process.env.CC_ALLOW_PUBLISH) { console.error('\\n\\u2717 Use /cc-publish (which runs the mandatory consumer-walk), not raw npm publish.\\n Raw npm publish on v0.27.1 left every consumer stale on v0.26.\\n To publish manually anyway, set CC_ALLOW_PUBLISH=1.\\n'); process.exit(1); }\""
28
29
  },
29
30
  "dependencies": {
30
31
  "better-sqlite3": "^12.8.0",
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Shared project-identity and memory-dir resolution for CC's
3
+ * memory-related tooling. Used by:
4
+ * - lib/migrate-from-omega.js (Phase 1 migration)
5
+ * - scripts/write-memory-file.mjs (Phase 3a /cc-remember writer)
6
+ * - scripts/validate-memory.mjs (Phase 3a validator)
7
+ *
8
+ * Worktree-safe: resolves project identity via `git rev-parse
9
+ * --git-common-dir`, so callers from a worktree write to the host
10
+ * project's memory dir, not a per-worktree split.
11
+ */
12
+
13
+ 'use strict';
14
+
15
+ const fs = require('node:fs');
16
+ const path = require('node:path');
17
+ const os = require('node:os');
18
+ const { execSync } = require('node:child_process');
19
+
20
+ /**
21
+ * Resolve the absolute path of the current project's primary checkout.
22
+ * Worktree-safe via git common-dir; falls back to cwd if not in a repo.
23
+ */
24
+ function resolveProjectAbsolutePath(cwd = process.cwd()) {
25
+ try {
26
+ const commonDir = execSync('git rev-parse --git-common-dir', {
27
+ cwd,
28
+ stdio: ['ignore', 'pipe', 'ignore'],
29
+ encoding: 'utf8',
30
+ }).trim();
31
+ const abs = path.isAbsolute(commonDir) ? commonDir : path.resolve(cwd, commonDir);
32
+ return path.dirname(abs);
33
+ } catch {
34
+ return path.resolve(cwd);
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Project slug — the basename of the project root. Used as a
40
+ * human-readable identifier and to match memory keys.
41
+ */
42
+ function resolveProjectSlug(cwd = process.cwd()) {
43
+ return path.basename(resolveProjectAbsolutePath(cwd));
44
+ }
45
+
46
+ /**
47
+ * Convert an absolute path to Claude Code's project-dir slug format
48
+ * (dashified). `/Users/x/claude-cabinet` → `-Users-x-claude-cabinet`.
49
+ */
50
+ function dashifiedSlug(absolutePath) {
51
+ return absolutePath.replace(/^\//, '-').replace(/\//g, '-');
52
+ }
53
+
54
+ /**
55
+ * Read settings.json safely; returns {} on absence or parse error.
56
+ */
57
+ function readSettings(settingsPath) {
58
+ if (!fs.existsSync(settingsPath)) return {};
59
+ try {
60
+ return JSON.parse(fs.readFileSync(settingsPath, 'utf8'));
61
+ } catch {
62
+ return {};
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Resolve the memory directory for the current project.
68
+ * Honors `autoMemoryDirectory` setting if present in ~/.claude/settings.json.
69
+ * Otherwise uses platform default: ~/.claude/projects/<dashified>/memory/.
70
+ *
71
+ * Rejects literal `~` in autoMemoryDirectory — Claude Code does NOT
72
+ * expand tilde in settings.json values, and a silent `./~/...` directory
73
+ * is a footgun.
74
+ */
75
+ function resolveMemoryDir(opts = {}) {
76
+ const homeDir = opts.homeDir || os.homedir();
77
+ const cwd = opts.cwd || process.cwd();
78
+ const settingsPath = opts.settingsPath || path.join(homeDir, '.claude', 'settings.json');
79
+
80
+ if (opts.memoryDir) return path.resolve(opts.memoryDir);
81
+
82
+ const settings = readSettings(settingsPath);
83
+ if (settings.autoMemoryDirectory) {
84
+ const v = settings.autoMemoryDirectory;
85
+ if (v.includes('~')) {
86
+ throw new Error(
87
+ `autoMemoryDirectory in ${settingsPath} contains '~' (literal). Claude Code does not expand tilde. Use an absolute path.`
88
+ );
89
+ }
90
+ return path.resolve(v);
91
+ }
92
+
93
+ const projectAbs = resolveProjectAbsolutePath(cwd);
94
+ return path.join(homeDir, '.claude', 'projects', dashifiedSlug(projectAbs), 'memory');
95
+ }
96
+
97
+ module.exports = {
98
+ resolveProjectAbsolutePath,
99
+ resolveProjectSlug,
100
+ resolveMemoryDir,
101
+ dashifiedSlug,
102
+ readSettings,
103
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * Validate the structural integrity of a Claude Code memory directory.
3
+ *
4
+ * Checks:
5
+ * 1. MEMORY.md exists and is within Claude Code's session-start
6
+ * load budget: ≤200 lines AND ≤25KB.
7
+ * 2. Every .md file in the memory dir (except MEMORY.md, edges.json)
8
+ * is referenced by MEMORY.md (bidirectional: no orphans).
9
+ * 3. Every file referenced in MEMORY.md exists on disk.
10
+ * 4. Topic-style files (migrated from omega) do not exceed 50KB.
11
+ * Per-file curated style: no size cap.
12
+ *
13
+ * Wired into /validate via templates/skills/validate/phases/validators.md.
14
+ * Also runs PostToolUse on memory-dir writes via
15
+ * templates/.claude/hooks/memory-index-guard.sh.
16
+ *
17
+ * Exit codes:
18
+ * 0 — pass
19
+ * 1 — one or more violations
20
+ * 2 — bad usage / inaccessible memory dir
21
+ *
22
+ * @module scripts/validate-memory
23
+ */
24
+
25
+ import fs from 'node:fs';
26
+ import path from 'node:path';
27
+ import { createRequire } from 'node:module';
28
+
29
+ const require = createRequire(import.meta.url);
30
+ const { resolveMemoryDir } = require('./project-context.cjs');
31
+
32
+ const MEMORY_INDEX_FILE = 'MEMORY.md';
33
+ const MEMORY_INDEX_LINE_CAP = 200;
34
+ const MEMORY_INDEX_BYTE_CAP = 25_000;
35
+ const TOPIC_FILE_SIZE_CAP = 50_000;
36
+ const NON_MEMORY_FILES = new Set(['MEMORY.md', 'edges.json', '.DS_Store']);
37
+
38
+ // Files that match this pattern are "topic-style" (multi-memory files
39
+ // produced by Phase 1 migration). The 50KB cap applies to them.
40
+ // Per-file curated style files (one memory each) are not size-capped.
41
+ const TOPIC_FILE_NAMES = new Set([
42
+ 'decisions.md',
43
+ 'lessons.md',
44
+ 'preferences.md',
45
+ 'constraints.md',
46
+ 'session-summaries.md',
47
+ 'subagent-residue.md',
48
+ 'unscoped.md',
49
+ ]);
50
+ const TOPIC_FILE_PATTERNS = [
51
+ /^(decisions|lessons|preferences|constraints|session-summaries|subagent-residue|unscoped)(-recent|-archive)?(-\d+)?\.md$/,
52
+ /^cross-[a-z0-9_-]+(-recent|-archive)?(-\d+)?\.md$/,
53
+ ];
54
+
55
+ function isTopicStyleFile(name) {
56
+ if (TOPIC_FILE_NAMES.has(name)) return true;
57
+ return TOPIC_FILE_PATTERNS.some((re) => re.test(name));
58
+ }
59
+
60
+ /**
61
+ * Extract all `.md` filenames referenced from MEMORY.md.
62
+ * Handles both index formats:
63
+ * - Topic-files: `- **decisions.md** (56 entries) — desc`
64
+ * - Curated: `- [Title](file.md) — desc`
65
+ */
66
+ function parseMemoryIndex(memoryMdText) {
67
+ const refs = new Set();
68
+ // Topic-files format: bolded filename
69
+ for (const m of memoryMdText.matchAll(/\*\*([a-z0-9_.-]+\.md)\*\*/gi)) {
70
+ refs.add(m[1]);
71
+ }
72
+ // Curated format: markdown link
73
+ for (const m of memoryMdText.matchAll(/\]\(([a-z0-9_.-]+\.md)\)/gi)) {
74
+ refs.add(m[1]);
75
+ }
76
+ return refs;
77
+ }
78
+
79
+ /**
80
+ * Validate one memory directory.
81
+ *
82
+ * @returns {{ violations: string[], warnings: string[], stats: object }}
83
+ */
84
+ export function validateMemoryDir(opts = {}) {
85
+ const memoryDir = opts.memoryDir
86
+ ? path.resolve(opts.memoryDir)
87
+ : resolveMemoryDir({ homeDir: opts.homeDir, cwd: opts.cwd, settingsPath: opts.settingsPath });
88
+
89
+ const violations = [];
90
+ const warnings = [];
91
+ const stats = { memoryDir };
92
+
93
+ if (!fs.existsSync(memoryDir)) {
94
+ return {
95
+ violations: [`memory directory does not exist: ${memoryDir}`],
96
+ warnings,
97
+ stats,
98
+ };
99
+ }
100
+
101
+ const indexPath = path.join(memoryDir, MEMORY_INDEX_FILE);
102
+ if (!fs.existsSync(indexPath)) {
103
+ return {
104
+ violations: [`${MEMORY_INDEX_FILE} missing under ${memoryDir}`],
105
+ warnings,
106
+ stats,
107
+ };
108
+ }
109
+
110
+ const indexText = fs.readFileSync(indexPath, 'utf8');
111
+ const indexBytes = Buffer.byteLength(indexText, 'utf8');
112
+ const indexLines = indexText.split('\n').length;
113
+ stats.indexLines = indexLines;
114
+ stats.indexBytes = indexBytes;
115
+
116
+ if (indexLines > MEMORY_INDEX_LINE_CAP) {
117
+ violations.push(
118
+ `MEMORY.md exceeds line cap: ${indexLines} lines (max ${MEMORY_INDEX_LINE_CAP}). Reduce index verbosity or move details into topic files.`
119
+ );
120
+ }
121
+ if (indexBytes > MEMORY_INDEX_BYTE_CAP) {
122
+ violations.push(
123
+ `MEMORY.md exceeds byte cap: ${indexBytes} bytes (max ${MEMORY_INDEX_BYTE_CAP}). Reduce index verbosity.`
124
+ );
125
+ }
126
+
127
+ const referenced = parseMemoryIndex(indexText);
128
+ stats.indexedFileCount = referenced.size;
129
+
130
+ const entries = fs.readdirSync(memoryDir).filter((f) => !f.startsWith('.'));
131
+ const onDiskMd = entries.filter((f) => f.endsWith('.md') && !NON_MEMORY_FILES.has(f));
132
+ stats.onDiskMemoryFileCount = onDiskMd.length;
133
+
134
+ // Orphans on disk (file exists, not indexed).
135
+ for (const f of onDiskMd) {
136
+ if (!referenced.has(f)) {
137
+ violations.push(
138
+ `orphan memory file: ${f} exists in ${memoryDir} but is not referenced by MEMORY.md. ` +
139
+ `Either reference it (manually or via /cc-remember next time) or delete it.`
140
+ );
141
+ }
142
+ }
143
+
144
+ // Broken references (indexed but missing on disk).
145
+ for (const ref of referenced) {
146
+ if (ref === MEMORY_INDEX_FILE) continue;
147
+ if (!entries.includes(ref)) {
148
+ violations.push(
149
+ `broken reference in MEMORY.md: ${ref} is indexed but does not exist on disk.`
150
+ );
151
+ }
152
+ }
153
+
154
+ // Size cap on topic-style files.
155
+ for (const f of onDiskMd) {
156
+ if (!isTopicStyleFile(f)) continue;
157
+ const bytes = fs.statSync(path.join(memoryDir, f)).size;
158
+ if (bytes > TOPIC_FILE_SIZE_CAP) {
159
+ violations.push(
160
+ `topic file too large: ${f} is ${bytes} bytes (cap ${TOPIC_FILE_SIZE_CAP}). Re-shard via migration tool or split manually.`
161
+ );
162
+ }
163
+ }
164
+
165
+ return { violations, warnings, stats };
166
+ }
167
+
168
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
169
+ if (isMain) {
170
+ const args = process.argv.slice(2);
171
+ let memoryDir = null;
172
+ let quiet = false;
173
+ for (let i = 0; i < args.length; i++) {
174
+ if (args[i] === '--memory-dir' || args[i] === '--dir') memoryDir = args[++i];
175
+ else if (args[i] === '--quiet') quiet = true;
176
+ else if (args[i] === '--help' || args[i] === '-h') {
177
+ console.log('usage: validate-memory.mjs [--memory-dir <path>] [--quiet]');
178
+ console.log(' Validates a Claude Code memory directory. Defaults to the');
179
+ console.log(' current project\'s memory dir. Exits 0 on pass, 1 on violations.');
180
+ process.exit(0);
181
+ }
182
+ }
183
+ try {
184
+ const { violations, warnings, stats } = validateMemoryDir({ memoryDir });
185
+ if (!quiet) {
186
+ console.log(`memory-dir: ${stats.memoryDir}`);
187
+ if (stats.indexLines !== undefined) {
188
+ console.log(`MEMORY.md: ${stats.indexLines} lines / ${stats.indexBytes} bytes`);
189
+ }
190
+ if (stats.onDiskMemoryFileCount !== undefined) {
191
+ console.log(`on disk: ${stats.onDiskMemoryFileCount} files`);
192
+ console.log(`indexed: ${stats.indexedFileCount} references`);
193
+ }
194
+ }
195
+ for (const w of warnings) console.warn(`WARN: ${w}`);
196
+ for (const v of violations) console.error(`FAIL: ${v}`);
197
+ if (violations.length > 0) {
198
+ console.error(`\nvalidate-memory: ${violations.length} violation(s)`);
199
+ process.exit(1);
200
+ }
201
+ if (!quiet) console.log('validate-memory: PASS');
202
+ process.exit(0);
203
+ } catch (e) {
204
+ console.error('validate-memory failed:', e.message);
205
+ process.exit(2);
206
+ }
207
+ }
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Write a single memory file under the project's memory dir and
3
+ * update MEMORY.md's index to reference it.
4
+ *
5
+ * This is the per-file curated-style writer: each memory is its own
6
+ * .md file with a descriptive slug, matching Claude Code's native
7
+ * auto-memory convention. Used by /cc-remember and (Phase 3b) by
8
+ * debrief's record-lessons phase.
9
+ *
10
+ * Does NOT depend on omega — works against the file-based memory
11
+ * layout regardless of whether omega is installed.
12
+ *
13
+ * @module scripts/write-memory-file
14
+ */
15
+
16
+ import fs from 'node:fs';
17
+ import path from 'node:path';
18
+ import { createRequire } from 'node:module';
19
+
20
+ const require = createRequire(import.meta.url);
21
+ const { resolveMemoryDir } = require('./project-context.cjs');
22
+
23
+ const MEMORY_INDEX_FILE = 'MEMORY.md';
24
+ const CURATED_SECTION_HEADER = '## Curated entries (hand-authored)';
25
+ const CURATED_SECTION_BODY =
26
+ '_Hand-curated memory files. Each is one memory, written by Claude or you. Loaded on demand when Claude references them below._\n';
27
+
28
+ const SLUG_RE = /^[a-z0-9][a-z0-9_-]{0,79}$/;
29
+
30
+ /**
31
+ * Normalize an input string to a valid slug.
32
+ * Lowercase, alphanumeric + underscore + hyphen, 1-80 chars,
33
+ * starts with alphanumeric. Strips leading/trailing punctuation.
34
+ */
35
+ export function normalizeSlug(input) {
36
+ if (!input || typeof input !== 'string') {
37
+ throw new Error('slug: input must be a non-empty string');
38
+ }
39
+ const normalized = input
40
+ .toLowerCase()
41
+ .trim()
42
+ .replace(/[^a-z0-9_-]+/g, '_')
43
+ .replace(/^[_-]+|[_-]+$/g, '')
44
+ .slice(0, 80);
45
+ if (!normalized || !SLUG_RE.test(normalized)) {
46
+ throw new Error(`slug "${input}" could not be normalized to a valid filename`);
47
+ }
48
+ return normalized;
49
+ }
50
+
51
+ /**
52
+ * Write a memory file. If a file with the same slug exists, append
53
+ * a date suffix until unique (slug.md → slug_2026-05-27.md →
54
+ * slug_2026-05-27-2.md).
55
+ *
56
+ * @param {object} opts
57
+ * @param {string} opts.slug — descriptive identifier for the memory
58
+ * @param {string} opts.content — markdown body
59
+ * @param {string} [opts.title] — optional `# Title` heading; defaults
60
+ * to a title-cased version of the slug
61
+ * @param {string} [opts.description] — one-line description for the
62
+ * MEMORY.md index entry (recommended; defaults to first line of content)
63
+ * @param {string} [opts.memoryDir] — override the resolved memory dir
64
+ * @param {string} [opts.homeDir] — override $HOME (for tests)
65
+ * @param {string} [opts.cwd] — override cwd (for tests)
66
+ * @param {string} [opts.settingsPath] — override settings.json path
67
+ * @param {Date|string} [opts.date] — override "today" for testing
68
+ * @returns {{filePath: string, slug: string, indexed: boolean, bytesWritten: number}}
69
+ */
70
+ export function writeMemoryFile(opts = {}) {
71
+ if (!opts.content || typeof opts.content !== 'string') {
72
+ throw new Error('writeMemoryFile: content is required and must be a string');
73
+ }
74
+ if (!opts.slug) {
75
+ throw new Error('writeMemoryFile: slug is required (provide via opts.slug or via /cc-remember --slug)');
76
+ }
77
+
78
+ const slug = normalizeSlug(opts.slug);
79
+ const memoryDir = opts.memoryDir
80
+ ? path.resolve(opts.memoryDir)
81
+ : resolveMemoryDir({ homeDir: opts.homeDir, cwd: opts.cwd, settingsPath: opts.settingsPath });
82
+
83
+ fs.mkdirSync(memoryDir, { recursive: true });
84
+
85
+ const date = opts.date ? new Date(opts.date) : new Date();
86
+ const dateStr = date.toISOString().slice(0, 10);
87
+
88
+ // Resolve filename, avoiding collision via date + counter suffix.
89
+ let fileName = `${slug}.md`;
90
+ let counter = 1;
91
+ while (fs.existsSync(path.join(memoryDir, fileName))) {
92
+ counter++;
93
+ fileName = counter === 2
94
+ ? `${slug}_${dateStr}.md`
95
+ : `${slug}_${dateStr}-${counter - 1}.md`;
96
+ }
97
+
98
+ const finalSlug = fileName.replace(/\.md$/, '');
99
+ const filePath = path.join(memoryDir, fileName);
100
+
101
+ const title = opts.title || finalSlug.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
102
+ const body = [`# ${title}`, '', `_Captured: ${dateStr}_`, '', opts.content.trim(), ''].join('\n');
103
+
104
+ // Atomic write via temp + rename.
105
+ const tmpPath = filePath + '.tmp-' + process.pid;
106
+ fs.writeFileSync(tmpPath, body, 'utf8');
107
+ fs.renameSync(tmpPath, filePath);
108
+
109
+ const description = opts.description || extractFirstLine(opts.content) || title;
110
+ const indexed = updateMemoryIndex({ memoryDir, fileName, title, description });
111
+
112
+ return {
113
+ filePath,
114
+ slug: finalSlug,
115
+ indexed,
116
+ bytesWritten: Buffer.byteLength(body, 'utf8'),
117
+ };
118
+ }
119
+
120
+ function extractFirstLine(text) {
121
+ for (const raw of text.split('\n')) {
122
+ const line = raw.trim().replace(/^[-*#>\s]+/, '');
123
+ if (line) return line.slice(0, 100);
124
+ }
125
+ return null;
126
+ }
127
+
128
+ /**
129
+ * Add an entry for `fileName` to MEMORY.md's curated-entries section.
130
+ * Creates the section if absent. If the file is already indexed
131
+ * anywhere (Topic files OR Curated entries section), no-op.
132
+ *
133
+ * Returns true if MEMORY.md was modified, false if no change needed.
134
+ */
135
+ function updateMemoryIndex({ memoryDir, fileName, title, description }) {
136
+ const indexPath = path.join(memoryDir, MEMORY_INDEX_FILE);
137
+ let body = fs.existsSync(indexPath) ? fs.readFileSync(indexPath, 'utf8') : '# Memory Index\n\n';
138
+
139
+ // Idempotency: don't re-index a file already referenced anywhere.
140
+ if (body.includes(`(${fileName})`) || body.includes(`**${fileName}**`)) {
141
+ return false;
142
+ }
143
+
144
+ const entry = `- [${title}](${fileName}) — ${description}`;
145
+
146
+ if (body.includes(CURATED_SECTION_HEADER)) {
147
+ body = body.replace(CURATED_SECTION_HEADER, `${CURATED_SECTION_HEADER}\n${entry}`);
148
+ } else {
149
+ const trailing = body.endsWith('\n') ? '' : '\n';
150
+ body = `${body}${trailing}\n${CURATED_SECTION_HEADER}\n\n${CURATED_SECTION_BODY}\n${entry}\n`;
151
+ }
152
+
153
+ const tmp = indexPath + '.tmp-' + process.pid;
154
+ fs.writeFileSync(tmp, body, 'utf8');
155
+ fs.renameSync(tmp, indexPath);
156
+ return true;
157
+ }
158
+
159
+ // CLI mode: `node scripts/write-memory-file.mjs --slug foo "content..."`
160
+ const isMain = import.meta.url === `file://${process.argv[1]}`;
161
+ if (isMain) {
162
+ const args = process.argv.slice(2);
163
+ let slug = null;
164
+ let title = null;
165
+ let description = null;
166
+ const positional = [];
167
+ for (let i = 0; i < args.length; i++) {
168
+ if (args[i] === '--slug') slug = args[++i];
169
+ else if (args[i] === '--title') title = args[++i];
170
+ else if (args[i] === '--description') description = args[++i];
171
+ else positional.push(args[i]);
172
+ }
173
+ const content = positional.join(' ');
174
+ if (!slug || !content) {
175
+ console.error('usage: write-memory-file.mjs --slug <slug> [--title <title>] [--description <desc>] <content>');
176
+ process.exit(2);
177
+ }
178
+ try {
179
+ const result = writeMemoryFile({ slug, content, title, description });
180
+ console.log(JSON.stringify(result, null, 2));
181
+ } catch (e) {
182
+ console.error('write-memory-file failed:', e.message);
183
+ process.exit(1);
184
+ }
185
+ }
@@ -63,7 +63,10 @@ With user confirmation:
63
63
  1. Update `version` in `package.json`
64
64
  2. Commit: `Bump to <version>`
65
65
  3. Tag: `git tag v<version>`
66
- 4. `npm publish`
66
+ 4. `CC_ALLOW_PUBLISH=1 npm publish` — the `prepublishOnly` guard blocks
67
+ a bare `npm publish` (raw publish on v0.27.1 left every consumer
68
+ stale on v0.26); the env var is the deliberate escape, and reaching
69
+ this step via /cc-publish guarantees Step 6's consumer walk follows.
67
70
  5. `git push && git push --tags`
68
71
 
69
72
  ### 5. Post-Publish
@@ -17,6 +17,10 @@ Return true if ANY of:
17
17
  4. `~/.claude/settings.json.mcpServers` or
18
18
  `~/.claude.json.mcpServers` has any key matching
19
19
  `/^omega(-memory)?$/i` OR any command containing `omega-venv`
20
+ 5. Any inert omega file artifact remains in the project (a migration
21
+ that tore down the venv/hooks/MCP can still leave these on disk):
22
+ `.claude/hooks/omega-memory-guard.sh`, `.claude/hooks/domain-memories.sh`,
23
+ `scripts/cabinet-memory-adapter.py`, `scripts/migrate-memory-to-omega.py`
20
24
 
21
25
  Full detection check:
22
26
 
@@ -38,10 +42,35 @@ for p in ['$HOME/.claude/settings.json', '$HOME/.claude.json']:
38
42
  pass
39
43
  raise SystemExit(1)
40
44
  " 2>/dev/null && HAS_OMEGA=1
45
+ for f in .claude/hooks/omega-memory-guard.sh .claude/hooks/domain-memories.sh \
46
+ scripts/cabinet-memory-adapter.py scripts/migrate-memory-to-omega.py; do
47
+ test -f "$f" && HAS_OMEGA=1
48
+ done
41
49
  ```
42
50
 
43
51
  If `HAS_OMEGA=1`, proceed. If 0, skip this phase silently.
44
52
 
53
+ ## Inert-artifact sweep (always runs when this phase proceeds)
54
+
55
+ A consumer whose omega was already migrated (`migrated_from_omega.state
56
+ === 'complete'`, venv/hooks/MCP gone) can still carry inert omega files
57
+ that the teardown didn't remove. Remove them by exact name. This is
58
+ idempotent — `rm -f` no-ops when the file is absent, so it's safe to
59
+ run on every cc-upgrade:
60
+
61
+ ```bash
62
+ rm -f .claude/hooks/omega-memory-guard.sh \
63
+ .claude/hooks/domain-memories.sh \
64
+ scripts/cabinet-memory-adapter.py \
65
+ scripts/migrate-memory-to-omega.py
66
+ ```
67
+
68
+ Report which files were actually removed (test before/after, or capture
69
+ `rm -v` output). Only remove these exact paths — do not glob or remove
70
+ anything else. If omega is still ACTIVE (conditions 1–4 above), run the
71
+ full migration flow below FIRST; the sweep cleans up what teardown
72
+ leaves behind, it does not replace migration.
73
+
45
74
  ## User-friendly prompt
46
75
 
47
76
  Default to **dry-run** — a non-Oren user just upgrading CC shouldn't