get-claudia 1.55.21 → 1.56.1

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 (28) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/bin/index.js +209 -4
  3. package/bin/manifest-lib.js +245 -0
  4. package/memory-daemon/claudia_memory/daemon/health.py +1 -1
  5. package/memory-daemon/claudia_memory/daemon/scheduler.py +1 -1
  6. package/memory-daemon/claudia_memory/mcp/server.py +132 -123
  7. package/memory-daemon/claudia_memory/services/consolidate.py +1 -1
  8. package/memory-daemon/claudia_memory/services/remember.py +1 -1
  9. package/package.json +6 -2
  10. package/template-v2/.claude/hooks/hooks.json +11 -11
  11. package/template-v2/.claude/hooks/post-tool-capture.py +1 -1
  12. package/template-v2/.claude/hooks/pre-compact.py +4 -4
  13. package/template-v2/.claude/hooks/pre-compact.sh +1 -1
  14. package/template-v2/.claude/manifest.json +72 -0
  15. package/template-v2/.claude/rules/claudia-principles.md +2 -2
  16. package/template-v2/.claude/rules/memory-availability.md +3 -3
  17. package/template-v2/.claude/skills/capture-meeting/SKILL.md +6 -6
  18. package/template-v2/.claude/skills/capture-meeting/evals/basic.yaml +1 -1
  19. package/template-v2/.claude/skills/deep-context/SKILL.md +7 -7
  20. package/template-v2/.claude/skills/meditate/SKILL.md +10 -10
  21. package/template-v2/.claude/skills/meditate/evals/basic.yaml +1 -1
  22. package/template-v2/.claude/skills/meeting-prep/SKILL.md +3 -3
  23. package/template-v2/.claude/skills/memory-health/SKILL.md +1 -1
  24. package/template-v2/.claude/skills/memory-manager.md +85 -85
  25. package/template-v2/.claude/skills/morning-brief/SKILL.md +10 -10
  26. package/template-v2/.claude/skills/research/SKILL.md +2 -2
  27. package/template-v2/.claude/skills/skill-index.json +1 -1
  28. package/template-v2/CLAUDE.md +6 -6
package/CHANGELOG.md CHANGED
@@ -2,6 +2,55 @@
2
2
 
3
3
  All notable changes to Claudia will be documented in this file.
4
4
 
5
+ ## 1.56.1 (2026-04-11)
6
+
7
+ ### Preserve User-Modified Skills on Upgrade
8
+
9
+ Re-running the installer in an existing project no longer silently overwrites skills, rules, or `CLAUDE.md` that the user has customized. Three-way merge via a shipped manifest detects which tracked files the user has edited and prompts before touching them.
10
+
11
+ #### Added
12
+ - **`template-v2/.claude/manifest.json`** -- SHA-256 hashes of every shipped file under `.claude/skills/`, `.claude/rules/`, and `CLAUDE.md`. Regenerated on `npm publish` via `prepublishOnly`. Users get the new manifest automatically on every upgrade so the next upgrade has a clean comparison baseline.
13
+ - **`bin/manifest-lib.js`** -- Pure-function library: `hashFile`, `generateManifest`, `detectConflicts`, `resolveBakPath`, `applyResolution`, `loadManifest`. No runtime dependencies.
14
+ - **`scripts/generate-manifest.js`** -- Standalone CLI wrapper: `npm run generate-manifest` rebuilds the shipped manifest from the current `template-v2/` tree.
15
+ - **Batch conflict prompt** -- When an upgrade would overwrite locally-modified files, the installer prints a summary and offers `[k]eep all`, `[o]verwrite all`, `[r]eview each`, or `[c]ancel`. Review-each supports `[d]iff` (uses `git diff --no-index` when available), and `[s]kip rest`.
16
+ - **Automatic `.bak` backups** -- Any file the user chooses to overwrite is first copied to `<file>.bak` (with numeric suffix on collision: `.bak.1`, `.bak.2`, ...). No existing `.bak` file is ever overwritten.
17
+ - **25 tests** -- 22 unit tests in `test/manifest.test.js` plus 3 integration tests in `test/integration.test.js`. Run with `npm test`. Uses Node's built-in `node:test`; zero new dependencies.
18
+
19
+ #### Changed
20
+ - **Upgrade copy path** in `bin/index.js` now runs conflict detection before `cpSync` and passes a filter callback that skips any file the user chose to keep. The fresh-install path is unchanged.
21
+ - **Non-TTY and `--yes` mode** defaults to keep-all for conflicts, printing what was preserved. Safe for CI.
22
+
23
+ #### Notes
24
+ - Manifest scope is deliberately narrow: `.claude/skills/**`, `.claude/rules/**`, and `CLAUDE.md`. Files under `hooks/`, `agents/`, `commands/`, `workspaces/`, and `settings.local.json` are excluded.
25
+ - Missing or corrupt user manifest falls back to direct hash comparison against the new template -- the upgrade does not crash.
26
+ - On the first upgrade after this feature ships, users will see a slightly noisier prompt because there's no prior manifest to diff against. Every subsequent upgrade is clean.
27
+
28
+ #### Rollback
29
+ Single atomic commit. `git revert <sha>` undoes everything. Pre-push `origin/main` was `2d65baa`.
30
+
31
+ ---
32
+
33
+ ## 1.56.0 (2026-04-01)
34
+
35
+ ### Claude Desktop Compatibility
36
+
37
+ MCP tool names migrated from dot notation (`memory.recall`) to underscore notation (`memory_recall`) to comply with the MCP spec's `^[a-zA-Z0-9_-]{1,64}$` requirement. Claude Code tolerated dots; Claude Desktop rejected them at registration, blocking Desktop users entirely.
38
+
39
+ #### Fixed
40
+ - **Tool names comply with MCP spec** -- All 33 memory tools renamed from `memory.xxx` to `memory_xxx` in the daemon server, scheduler, and test assertions (PR #32)
41
+ - **Instructional references updated** -- ~200 tool name references renamed across CLAUDE.md, skills (memory-manager, morning-brief, deep-context, meditate, capture-meeting, meeting-prep, research), hooks (pre-compact, hooks.json, post-tool-capture), rules (memory-availability, claudia-principles), README, bin/index.js, and internal error messages
42
+ - **template-v2/ mirrors synced** -- New installations get underscore names from day one
43
+
44
+ #### Added
45
+ - **Backward-compatible alias layer** -- The daemon registers dot-notation aliases (`memory.recall` resolves to the same handler as `memory_recall`) so users who haven't restarted their session or updated their skills keep working during the transition
46
+ - **test_tool_name_compat.py** -- 6 new tests proving aliases are registered, `list_tools()` only advertises underscore names, and `cognitive.ingest` is unaffected
47
+
48
+ #### Stats
49
+ - 762 tests pass, 0 regressions (+6 new tests)
50
+ - 30 files changed across 3 directory trees (root, claudia/, template-v2/)
51
+
52
+ ---
53
+
5
54
  ## 1.55.21 (2026-03-19)
6
55
 
7
56
  ### The Community Release
package/bin/index.js CHANGED
@@ -1,12 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync } from 'fs';
3
+ import { existsSync, mkdirSync, cpSync, readdirSync, readFileSync, writeFileSync, statSync, renameSync, unlinkSync, copyFileSync } from 'fs';
4
4
  import { join, dirname } from 'path';
5
5
  import { fileURLToPath } from 'url';
6
6
  import { spawn, execFileSync } from 'child_process';
7
7
  import { homedir } from 'os';
8
8
  import { createInterface } from 'readline';
9
9
  import { setupGoogleWorkspace, detectOldGoogleMcp, extractProjectNumber, buildApiEnableUrl, TIER_APIS } from './google-setup.js';
10
+ import {
11
+ loadManifest,
12
+ generateManifest,
13
+ detectConflicts,
14
+ resolveBakPath,
15
+ applyResolution,
16
+ } from './manifest-lib.js';
10
17
 
11
18
  const __filename = fileURLToPath(import.meta.url);
12
19
  const __dirname = dirname(__filename);
@@ -70,6 +77,181 @@ function confirm(question) {
70
77
  });
71
78
  }
72
79
 
80
+ // Single-keystroke prompt. Returns the lowercased first character of the
81
+ // user's answer, or `defaultKey` when non-TTY / --yes.
82
+ function promptKey(question, validKeys, defaultKey) {
83
+ if (!isTTY || process.argv.includes('--yes') || process.argv.includes('-y')) {
84
+ return Promise.resolve(defaultKey);
85
+ }
86
+ return new Promise((resolve) => {
87
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
88
+ rl.question(question, (answer) => {
89
+ rl.close();
90
+ const c = (answer || '').trim().toLowerCase().charAt(0);
91
+ if (validKeys.includes(c)) resolve(c);
92
+ else resolve(defaultKey);
93
+ });
94
+ });
95
+ }
96
+
97
+ // Detect + resolve conflicts between shipped framework files and the user's
98
+ // locally modified versions. Returns the set of relative paths the caller
99
+ // must skip during cpSync. Saves .bak siblings for any file the user chose
100
+ // to overwrite. May exit(0) if the user cancels.
101
+ async function handleSkillConflicts(targetPath, templatePath) {
102
+ const userManifestPath = join(targetPath, '.claude', 'manifest.json');
103
+ const newManifestPath = join(templatePath, '.claude', 'manifest.json');
104
+
105
+ const oldManifest = loadManifest(userManifestPath);
106
+ let newManifest = loadManifest(newManifestPath);
107
+
108
+ // If the shipped manifest is missing (older package or dev build), fall
109
+ // back to generating one on the fly from the template tree. This keeps
110
+ // the feature working even when scripts/generate-manifest.js wasn't run.
111
+ if (!newManifest) {
112
+ try {
113
+ newManifest = generateManifest(templatePath, { version: getVersion() });
114
+ } catch {
115
+ return new Set(); // can't detect conflicts; preserve old behavior
116
+ }
117
+ }
118
+
119
+ const result = detectConflicts({
120
+ userDir: targetPath,
121
+ templateDir: templatePath,
122
+ oldManifest,
123
+ newManifest,
124
+ });
125
+
126
+ if (result.conflicts.length === 0) {
127
+ return new Set(); // nothing to prompt about
128
+ }
129
+
130
+ // Non-TTY or --yes → default to keeping user versions (safe in CI).
131
+ const isNonInteractive = !isTTY || process.argv.includes('--yes') || process.argv.includes('-y');
132
+
133
+ console.log('');
134
+ console.log(` ${colors.yellow}⚠${colors.reset} ${result.conflicts.length} file(s) have local modifications that would be overwritten:`);
135
+ console.log('');
136
+ for (const f of result.conflicts) {
137
+ console.log(` ${colors.dim}${f}${colors.reset}`);
138
+ }
139
+ console.log('');
140
+
141
+ if (isNonInteractive) {
142
+ console.log(` ${colors.cyan}i${colors.reset} Non-interactive mode — keeping your versions. Updates for these files skipped.`);
143
+ console.log('');
144
+ return new Set(result.conflicts);
145
+ }
146
+
147
+ console.log(' How do you want to handle these?');
148
+ console.log(` ${colors.bold}[k]${colors.reset} Keep all my versions (skip updates for these files)`);
149
+ console.log(` ${colors.bold}[o]${colors.reset} Overwrite all ${colors.dim}(saves your versions as .bak)${colors.reset}`);
150
+ console.log(` ${colors.bold}[r]${colors.reset} Review each one`);
151
+ console.log(` ${colors.bold}[c]${colors.reset} Cancel upgrade`);
152
+ console.log('');
153
+
154
+ const topChoice = await promptKey(' Choice: ', ['k', 'o', 'r', 'c'], 'k');
155
+
156
+ let resolution;
157
+ if (topChoice === 'k') {
158
+ resolution = applyResolution(result.conflicts, { choice: 'keep-all' });
159
+ } else if (topChoice === 'o') {
160
+ resolution = applyResolution(result.conflicts, { choice: 'overwrite-all' });
161
+ } else if (topChoice === 'c') {
162
+ resolution = applyResolution(result.conflicts, { choice: 'cancel' });
163
+ } else {
164
+ // review each
165
+ const perFile = {};
166
+ let skipRest = false;
167
+ for (const f of result.conflicts) {
168
+ if (skipRest) {
169
+ perFile[f] = 'keep';
170
+ continue;
171
+ }
172
+ console.log('');
173
+ console.log(` ${colors.cyan}•${colors.reset} ${f}`);
174
+ const k = await promptKey(
175
+ ` ${colors.bold}[k]${colors.reset}eep / ${colors.bold}[o]${colors.reset}verwrite / ${colors.bold}[d]${colors.reset}iff / ${colors.bold}[s]${colors.reset}kip rest: `,
176
+ ['k', 'o', 'd', 's'],
177
+ 'k',
178
+ );
179
+ if (k === 'd') {
180
+ showDiff(join(targetPath, f), join(templatePath, f));
181
+ // Re-prompt after showing the diff
182
+ const k2 = await promptKey(
183
+ ` ${colors.bold}[k]${colors.reset}eep / ${colors.bold}[o]${colors.reset}verwrite: `,
184
+ ['k', 'o'],
185
+ 'k',
186
+ );
187
+ perFile[f] = k2 === 'o' ? 'overwrite' : 'keep';
188
+ } else if (k === 's') {
189
+ perFile[f] = 'keep';
190
+ skipRest = true;
191
+ } else {
192
+ perFile[f] = k === 'o' ? 'overwrite' : 'keep';
193
+ }
194
+ }
195
+ resolution = applyResolution(result.conflicts, { choice: 'per-file', perFile });
196
+ }
197
+
198
+ if (resolution.cancelled) {
199
+ console.log('');
200
+ console.log(` ${colors.dim}Upgrade cancelled. No files changed.${colors.reset}`);
201
+ process.exit(0);
202
+ }
203
+
204
+ // Back up user versions for any file they chose to overwrite
205
+ for (const relPath of resolution.overwrite) {
206
+ const userAbs = join(targetPath, relPath);
207
+ if (existsSync(userAbs)) {
208
+ try {
209
+ const bakAbs = resolveBakPath(userAbs);
210
+ copyFileSync(userAbs, bakAbs);
211
+ console.log(` ${colors.cyan}↺${colors.reset} Backed up ${colors.dim}${relPath}${colors.reset} → ${colors.dim}${bakAbs.replace(targetPath + '/', '')}${colors.reset}`);
212
+ } catch (err) {
213
+ console.log(` ${colors.red}!${colors.reset} Failed to back up ${relPath}: ${err.message}`);
214
+ }
215
+ }
216
+ }
217
+
218
+ if (resolution.skip.length > 0) {
219
+ console.log('');
220
+ console.log(` ${colors.green}✓${colors.reset} Kept your versions of ${resolution.skip.length} file(s).`);
221
+ }
222
+
223
+ return new Set(resolution.skip);
224
+ }
225
+
226
+ // Best-effort diff display. Uses `git diff --no-index` if git is on PATH;
227
+ // otherwise prints a plain head-of-each-file comparison.
228
+ function showDiff(userAbs, templateAbs) {
229
+ try {
230
+ const out = execFileSync('git', ['diff', '--no-index', '--no-color', userAbs, templateAbs], {
231
+ stdio: ['ignore', 'pipe', 'pipe'],
232
+ encoding: 'utf8',
233
+ });
234
+ console.log(out);
235
+ } catch (err) {
236
+ // git diff --no-index returns exit 1 when files differ — that's normal
237
+ if (err.stdout) {
238
+ console.log(err.stdout);
239
+ return;
240
+ }
241
+ // No git available — fall back to naive display
242
+ try {
243
+ const userLines = readFileSync(userAbs, 'utf8').split('\n').slice(0, 40);
244
+ const tmplLines = readFileSync(templateAbs, 'utf8').split('\n').slice(0, 40);
245
+ console.log(` ${colors.dim}--- your version (first 40 lines) ---${colors.reset}`);
246
+ userLines.forEach((l) => console.log(` ${l}`));
247
+ console.log(` ${colors.dim}--- shipped version (first 40 lines) ---${colors.reset}`);
248
+ tmplLines.forEach((l) => console.log(` ${l}`));
249
+ } catch {
250
+ console.log(` ${colors.dim}(diff unavailable)${colors.reset}`);
251
+ }
252
+ }
253
+ }
254
+
73
255
  // Compact portrait-only banner
74
256
  function getBanner(version) {
75
257
  if (!isTTY) {
@@ -646,6 +828,26 @@ async function main() {
646
828
  // Upgrade: copy framework files, preserve user data
647
829
  const frameworkPaths = ['.claude', 'CLAUDE.md', '.mcp.json.example', 'LICENSE', 'NOTICE', 'workspaces'];
648
830
 
831
+ // Detect user-modified shipped files and let the user decide what to
832
+ // do before we touch anything. Returns a Set of POSIX-relative paths
833
+ // to exclude from the copy; may exit(0) if the user cancels.
834
+ let skipPaths;
835
+ try {
836
+ skipPaths = await handleSkillConflicts(targetPath, templatePath);
837
+ } catch (err) {
838
+ // Conflict detection must never break the upgrade. Fall back to the
839
+ // original copy-over-top behavior with a warning.
840
+ console.log(` ${colors.yellow}!${colors.reset} Conflict detection failed (${err.message}); falling back to overwrite.`);
841
+ skipPaths = new Set();
842
+ }
843
+
844
+ // Build an absolute-path skip set for the cpSync filter callback.
845
+ const skipAbs = new Set();
846
+ for (const rel of skipPaths) {
847
+ skipAbs.add(join(targetPath, rel));
848
+ }
849
+ const copyFilter = (_src, dest) => !skipAbs.has(dest);
850
+
649
851
  try {
650
852
  for (const item of frameworkPaths) {
651
853
  const src = join(templatePath, item);
@@ -654,9 +856,12 @@ async function main() {
654
856
 
655
857
  const srcStat = statSync(src);
656
858
  if (srcStat.isDirectory()) {
657
- cpSync(src, dest, { recursive: true, force: true });
859
+ cpSync(src, dest, { recursive: true, force: true, filter: copyFilter });
658
860
  } else {
659
- cpSync(src, dest, { force: true });
861
+ // For top-level files (CLAUDE.md, LICENSE, etc.), check skip manually
862
+ if (!skipAbs.has(dest)) {
863
+ cpSync(src, dest, { force: true });
864
+ }
660
865
  }
661
866
  }
662
867
 
@@ -1932,7 +2137,7 @@ ${contextual.map(s => `- **/${s.name}** - ${s.description}`).join('\n')}
1932
2137
  ${explicit.map(s => `- **/${s.name}** - ${s.description}`).join('\n')}
1933
2138
 
1934
2139
  ## Memory System
1935
- Memory operations use MCP tools from the claudia-memory daemon (memory.recall, memory.remember, memory.about, etc.).
2140
+ Memory operations use MCP tools from the claudia-memory daemon (memory_recall, memory_remember, memory_about, etc.).
1936
2141
  The daemon provides ~33 tools for semantic search, pattern detection, and relationship tracking.
1937
2142
  See the memory-manager skill for the full tool reference.`;
1938
2143
  } catch {
@@ -0,0 +1,245 @@
1
+ // Manifest library: pure functions powering user-skill preservation on upgrade.
2
+ //
3
+ // Shipped with every Claudia release. bin/index.js imports from here for the
4
+ // upgrade flow; the unit tests in test/manifest.test.js cover every exported
5
+ // function.
6
+ //
7
+ // See the execution brief in the commit that introduced this file for the
8
+ // full design (three-way merge via shipped manifest + batch prompt UX).
9
+
10
+ import { createHash } from 'node:crypto';
11
+ import {
12
+ readFileSync,
13
+ readdirSync,
14
+ statSync,
15
+ existsSync,
16
+ } from 'node:fs';
17
+ import { join, relative, sep, posix } from 'node:path';
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Scope rules — what the manifest tracks.
21
+ // Only files matching TRACK_PREFIXES (or the exact file CLAUDE.md) are
22
+ // included. Anything matching EXCLUDE_PATTERNS is filtered out.
23
+ // ---------------------------------------------------------------------------
24
+
25
+ const TRACK_PREFIXES = [
26
+ '.claude/skills/',
27
+ '.claude/rules/',
28
+ ];
29
+
30
+ const TRACKED_ROOT_FILES = new Set([
31
+ 'CLAUDE.md',
32
+ ]);
33
+
34
+ const EXCLUDE_SEGMENTS = [
35
+ '.claude/hooks/',
36
+ '.claude/agents/',
37
+ '.claude/commands/',
38
+ 'workspaces/',
39
+ 'node_modules/',
40
+ ];
41
+
42
+ const EXCLUDE_BASENAMES = new Set([
43
+ 'settings.local.json',
44
+ 'manifest.json',
45
+ '.DS_Store',
46
+ ]);
47
+
48
+ function isTrackedPath(relPosixPath) {
49
+ if (EXCLUDE_BASENAMES.has(relPosixPath.split('/').pop())) return false;
50
+ for (const seg of EXCLUDE_SEGMENTS) {
51
+ if (relPosixPath.startsWith(seg)) return false;
52
+ }
53
+ if (TRACKED_ROOT_FILES.has(relPosixPath)) return true;
54
+ for (const prefix of TRACK_PREFIXES) {
55
+ if (relPosixPath.startsWith(prefix)) return true;
56
+ }
57
+ return false;
58
+ }
59
+
60
+ // ---------------------------------------------------------------------------
61
+ // hashFile — SHA-256 hex over raw bytes.
62
+ // ---------------------------------------------------------------------------
63
+
64
+ export function hashFile(absPath) {
65
+ const bytes = readFileSync(absPath);
66
+ return createHash('sha256').update(bytes).digest('hex');
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // walkFiles — recursive directory walk yielding posix-style relative paths.
71
+ // ---------------------------------------------------------------------------
72
+
73
+ function walkFiles(rootDir) {
74
+ const out = [];
75
+ function recurse(dir) {
76
+ let entries;
77
+ try {
78
+ entries = readdirSync(dir, { withFileTypes: true });
79
+ } catch {
80
+ return;
81
+ }
82
+ for (const entry of entries) {
83
+ const full = join(dir, entry.name);
84
+ if (entry.isSymbolicLink()) continue;
85
+ if (entry.isDirectory()) {
86
+ recurse(full);
87
+ } else if (entry.isFile()) {
88
+ out.push(full);
89
+ }
90
+ }
91
+ }
92
+ recurse(rootDir);
93
+ return out;
94
+ }
95
+
96
+ function toPosixRel(rootDir, absPath) {
97
+ return relative(rootDir, absPath).split(sep).join(posix.sep);
98
+ }
99
+
100
+ // ---------------------------------------------------------------------------
101
+ // generateManifest — hashes every tracked file under rootDir.
102
+ // ---------------------------------------------------------------------------
103
+
104
+ export function generateManifest(rootDir, { version = 'unknown' } = {}) {
105
+ const files = {};
106
+ for (const abs of walkFiles(rootDir)) {
107
+ const relPosix = toPosixRel(rootDir, abs);
108
+ if (!isTrackedPath(relPosix)) continue;
109
+ files[relPosix] = hashFile(abs);
110
+ }
111
+ return {
112
+ version,
113
+ generated: new Date().toISOString(),
114
+ algorithm: 'sha256',
115
+ files,
116
+ };
117
+ }
118
+
119
+ // ---------------------------------------------------------------------------
120
+ // detectConflicts — three-way merge between old shipped, new shipped, and
121
+ // the user's current files.
122
+ // ---------------------------------------------------------------------------
123
+
124
+ function safeManifestFiles(manifest) {
125
+ if (!manifest || typeof manifest !== 'object') return null;
126
+ if (!manifest.files || typeof manifest.files !== 'object' || Array.isArray(manifest.files)) {
127
+ return null;
128
+ }
129
+ return manifest.files;
130
+ }
131
+
132
+ export function detectConflicts({ userDir, templateDir, oldManifest, newManifest }) {
133
+ const conflicts = [];
134
+ const userModifiedOnly = [];
135
+ const templateChangedOnly = [];
136
+ const unchanged = [];
137
+
138
+ const newFiles = safeManifestFiles(newManifest) || {};
139
+ const oldFiles = safeManifestFiles(oldManifest); // may be null
140
+
141
+ for (const relPath of Object.keys(newFiles)) {
142
+ const newHash = newFiles[relPath];
143
+ const userAbs = join(userDir, ...relPath.split('/'));
144
+
145
+ // File doesn't exist in user dir → not a conflict, the normal copy will create it
146
+ if (!existsSync(userAbs)) continue;
147
+
148
+ let userHash;
149
+ try {
150
+ userHash = hashFile(userAbs);
151
+ } catch {
152
+ // Unreadable file → skip, let the normal copy handle it
153
+ continue;
154
+ }
155
+
156
+ if (oldFiles && typeof oldFiles[relPath] === 'string') {
157
+ const oldHash = oldFiles[relPath];
158
+ const userDiffers = userHash !== oldHash;
159
+ const templateDiffers = newHash !== oldHash;
160
+
161
+ if (!userDiffers && !templateDiffers) {
162
+ unchanged.push(relPath);
163
+ } else if (!userDiffers && templateDiffers) {
164
+ templateChangedOnly.push(relPath);
165
+ } else if (userDiffers && !templateDiffers) {
166
+ userModifiedOnly.push(relPath);
167
+ } else {
168
+ // both changed
169
+ if (userHash === newHash) {
170
+ // User happened to edit to the new value — no conflict
171
+ unchanged.push(relPath);
172
+ } else {
173
+ conflicts.push(relPath);
174
+ }
175
+ }
176
+ } else {
177
+ // No prior manifest entry — fall back to direct compare against new template
178
+ if (userHash === newHash) {
179
+ unchanged.push(relPath);
180
+ } else {
181
+ conflicts.push(relPath);
182
+ }
183
+ }
184
+ }
185
+
186
+ return { conflicts, userModifiedOnly, templateChangedOnly, unchanged };
187
+ }
188
+
189
+ // ---------------------------------------------------------------------------
190
+ // resolveBakPath — returns a .bak path that doesn't collide.
191
+ // ---------------------------------------------------------------------------
192
+
193
+ export function resolveBakPath(absPath) {
194
+ const base = absPath + '.bak';
195
+ if (!existsSync(base)) return base;
196
+ let n = 1;
197
+ while (existsSync(base + '.' + n)) n++;
198
+ return base + '.' + n;
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // applyResolution — turns a user choice into explicit skip/overwrite lists.
203
+ // Pure: no filesystem side effects. Wraps the decision for unit testing.
204
+ // ---------------------------------------------------------------------------
205
+
206
+ export function applyResolution(conflicts, { choice, perFile } = {}) {
207
+ if (choice === 'cancel') {
208
+ return { skip: [], overwrite: [], cancelled: true };
209
+ }
210
+ if (choice === 'keep-all') {
211
+ return { skip: [...conflicts], overwrite: [], cancelled: false };
212
+ }
213
+ if (choice === 'overwrite-all') {
214
+ return { skip: [], overwrite: [...conflicts], cancelled: false };
215
+ }
216
+ if (choice === 'per-file') {
217
+ const skip = [];
218
+ const overwrite = [];
219
+ for (const f of conflicts) {
220
+ const answer = (perFile && perFile[f]) || 'keep';
221
+ if (answer === 'overwrite') overwrite.push(f);
222
+ else skip.push(f);
223
+ }
224
+ return { skip, overwrite, cancelled: false };
225
+ }
226
+ // Unknown choice → fail safe: keep user versions
227
+ return { skip: [...conflicts], overwrite: [], cancelled: false };
228
+ }
229
+
230
+ // ---------------------------------------------------------------------------
231
+ // loadManifest — safe JSON load that returns null on any failure.
232
+ // Used by bin/index.js to read the user's existing manifest.
233
+ // ---------------------------------------------------------------------------
234
+
235
+ export function loadManifest(absPath) {
236
+ try {
237
+ if (!existsSync(absPath)) return null;
238
+ const raw = readFileSync(absPath, 'utf8');
239
+ const parsed = JSON.parse(raw);
240
+ if (!safeManifestFiles(parsed)) return null;
241
+ return parsed;
242
+ } catch {
243
+ return null;
244
+ }
245
+ }
@@ -290,7 +290,7 @@ class HealthCheckHandler(BaseHTTPRequestHandler):
290
290
  self.send_error(500, str(e))
291
291
 
292
292
  def _send_briefing_response(self):
293
- """Send compact session briefing (same data as memory.briefing MCP tool).
293
+ """Send compact session briefing (same data as memory_briefing MCP tool).
294
294
 
295
295
  Lets session hooks call briefing data over HTTP before MCP tools register,
296
296
  providing a pre-MCP layer in the fallback chain.
@@ -31,7 +31,7 @@ logger = logging.getLogger(__name__)
31
31
  RELEVANT_TOOL_PREFIXES = {
32
32
  "gmail", "google_workspace", "slack", "telegram",
33
33
  "SLACK_", "GMAIL_", "NOTION_", "CALENDAR_",
34
- "memory.file", "memory.remember",
34
+ "memory_file", "memory_remember",
35
35
  }
36
36
 
37
37
  COMMITMENT_RE = re.compile(