@massu/core 1.9.3 → 1.9.5

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.
@@ -35,6 +35,7 @@ import { backfillMemoryFiles } from '../memory-file-ingest.ts';
35
35
  import { getConfig, resetConfig } from '../config.ts';
36
36
  import { installAll } from './install-commands.ts';
37
37
  import { readSettingsLocal, writeSettingsLocalAtomic } from '../lib/settings-local.ts';
38
+ import { encodeMemoryDirName } from '../lib/memory-path.ts';
38
39
  import {
39
40
  runDetection,
40
41
  type DetectionResult,
@@ -956,6 +957,42 @@ export function copyTemplateConfig(
956
957
  // MCP Server Registration (preserved)
957
958
  // ============================================================
958
959
 
960
+ /**
961
+ * Read the installer's OWN package.json version. The installer runs from
962
+ * its compiled `dist/cli.js` (under npx cache or a global install); the
963
+ * package.json sits one directory up from that. Used to pin downstream
964
+ * MCP server invocations and hook commands so customers don't drift onto
965
+ * unpinned `@massu/core` (which would resolve to the latest dist-tag on
966
+ * every spawn and silently change behavior across versions).
967
+ *
968
+ * Hard error if the package.json can't be read or has no version field —
969
+ * an unpinned write is a structural drift bug (P-002) and silently
970
+ * falling back to an unversioned `@massu/core` is what we're closing.
971
+ */
972
+ export function getInstallerVersion(): string {
973
+ // Walk up from this module's compiled location to find package.json.
974
+ // Compiled layout: <root>/dist/cli.js → ../package.json
975
+ // TS source layout: <root>/src/commands/init.ts → ../../package.json
976
+ const candidates = [
977
+ resolve(__dirname, '../package.json'),
978
+ resolve(__dirname, '../../package.json'),
979
+ ];
980
+ for (const candidate of candidates) {
981
+ if (existsSync(candidate)) {
982
+ try {
983
+ const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
984
+ if (typeof pkg.version === 'string' && pkg.version.length > 0 && pkg.name === '@massu/core') {
985
+ return pkg.version;
986
+ }
987
+ } catch { /* try next */ }
988
+ }
989
+ }
990
+ throw new Error(
991
+ 'getInstallerVersion: could not resolve @massu/core package.json. ' +
992
+ 'This indicates a corrupt install. Re-install via `npx -y @massu/core init`.',
993
+ );
994
+ }
995
+
959
996
  export function registerMcpServer(projectRoot: string): boolean {
960
997
  const mcpPath = resolve(projectRoot, '.mcp.json');
961
998
 
@@ -973,10 +1010,14 @@ export function registerMcpServer(projectRoot: string): boolean {
973
1010
  return false;
974
1011
  }
975
1012
 
1013
+ // P-002: pin the version so customers don't drift onto unpinned `@massu/core`
1014
+ // (which resolves to the latest dist-tag on every spawn). Closes the structural
1015
+ // class flagged by feedback_mcp_pin_version_in_mcp_json (precedent: 0b60916).
1016
+ const version = getInstallerVersion();
976
1017
  servers.massu = {
977
1018
  type: 'stdio',
978
1019
  command: 'npx',
979
- args: ['-y', '@massu/core'],
1020
+ args: ['-y', `@massu/core@${version}`],
980
1021
  };
981
1022
 
982
1023
  existing.mcpServers = servers;
@@ -1002,6 +1043,16 @@ interface HookGroup {
1002
1043
 
1003
1044
  type HooksConfig = Record<string, HookGroup[]>;
1004
1045
 
1046
+ /**
1047
+ * @deprecated P-003 (1.9.4+): the path returned here is unsafe to bake into
1048
+ * settings.json — under npx, `__dirname` resolves to whatever cache directory
1049
+ * npx happens to use, which is invalidated on cache clear / upgrade / move.
1050
+ *
1051
+ * Retained only for backward compatibility with `buildHooksConfig(hooksDir)`
1052
+ * callers in existing tests. New code should NEVER consume the returned path
1053
+ * verbatim in a command line; use `hook-runner` invocations instead (see
1054
+ * `buildHooksConfig` which now ignores the argument).
1055
+ */
1005
1056
  export function resolveHooksDir(): string {
1006
1057
  const cwd = process.cwd();
1007
1058
  const nodeModulesPath = resolve(cwd, 'node_modules/@massu/core/dist/hooks');
@@ -1015,16 +1066,29 @@ export function resolveHooksDir(): string {
1015
1066
  return 'node_modules/@massu/core/dist/hooks';
1016
1067
  }
1017
1068
 
1018
- function hookCmd(hooksDir: string, hookFile: string): string {
1019
- return `node ${hooksDir}/${hookFile}`;
1069
+ /**
1070
+ * Build a single hook-command line. P-003: emits `npx -y @massu/core@<version>
1071
+ * hook-runner <name>` so the hook file is resolved dynamically at fire-time
1072
+ * rather than baked as an absolute path. Pins the version (CR-49-class fix)
1073
+ * so customers don't drift onto unpinned `@massu/core` between hook fires.
1074
+ */
1075
+ function hookCmd(version: string, hookName: string): string {
1076
+ return `npx -y @massu/core@${version} hook-runner ${hookName}`;
1020
1077
  }
1021
1078
 
1022
- export function buildHooksConfig(hooksDir: string): HooksConfig {
1079
+ /**
1080
+ * Build the canonical Claude Code hooks configuration. The legacy
1081
+ * `hooksDir` parameter is now ignored; we emit `hook-runner` invocations
1082
+ * instead of `node <abs-path>` (see P-003). The parameter is retained
1083
+ * for backward-compatible call sites (existing tests pass a dir).
1084
+ */
1085
+ export function buildHooksConfig(_hooksDir?: string): HooksConfig {
1086
+ const version = getInstallerVersion();
1023
1087
  return {
1024
1088
  SessionStart: [
1025
1089
  {
1026
1090
  hooks: [
1027
- { type: 'command', command: hookCmd(hooksDir, 'session-start.js'), timeout: 10 },
1091
+ { type: 'command', command: hookCmd(version, 'session-start'), timeout: 10 },
1028
1092
  ],
1029
1093
  },
1030
1094
  ],
@@ -1032,32 +1096,32 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
1032
1096
  {
1033
1097
  matcher: 'Bash',
1034
1098
  hooks: [
1035
- { type: 'command', command: hookCmd(hooksDir, 'security-gate.js'), timeout: 5 },
1099
+ { type: 'command', command: hookCmd(version, 'security-gate'), timeout: 5 },
1036
1100
  ],
1037
1101
  },
1038
1102
  {
1039
1103
  matcher: 'Bash|Write',
1040
1104
  hooks: [
1041
- { type: 'command', command: hookCmd(hooksDir, 'pre-delete-check.js'), timeout: 5 },
1105
+ { type: 'command', command: hookCmd(version, 'pre-delete-check'), timeout: 5 },
1042
1106
  ],
1043
1107
  },
1044
1108
  ],
1045
1109
  PostToolUse: [
1046
1110
  {
1047
1111
  hooks: [
1048
- { type: 'command', command: hookCmd(hooksDir, 'post-tool-use.js'), timeout: 10 },
1049
- { type: 'command', command: hookCmd(hooksDir, 'quality-event.js'), timeout: 5 },
1050
- { type: 'command', command: hookCmd(hooksDir, 'cost-tracker.js'), timeout: 5 },
1112
+ { type: 'command', command: hookCmd(version, 'post-tool-use'), timeout: 10 },
1113
+ { type: 'command', command: hookCmd(version, 'quality-event'), timeout: 5 },
1114
+ { type: 'command', command: hookCmd(version, 'cost-tracker'), timeout: 5 },
1051
1115
  ],
1052
1116
  },
1053
1117
  {
1054
1118
  matcher: 'Edit|Write',
1055
1119
  hooks: [
1056
- { type: 'command', command: hookCmd(hooksDir, 'post-edit-context.js'), timeout: 5 },
1120
+ { type: 'command', command: hookCmd(version, 'post-edit-context'), timeout: 5 },
1057
1121
  // Auto-learning pipeline — classifies failures and detects fixes on
1058
1122
  // file changes. See Phase 5-6 of the autodetect plan.
1059
- { type: 'command', command: hookCmd(hooksDir, 'fix-detector.js'), timeout: 5 },
1060
- { type: 'command', command: hookCmd(hooksDir, 'classify-failure.js'), timeout: 5 },
1123
+ { type: 'command', command: hookCmd(version, 'fix-detector'), timeout: 5 },
1124
+ { type: 'command', command: hookCmd(version, 'classify-failure'), timeout: 5 },
1061
1125
  ],
1062
1126
  },
1063
1127
  {
@@ -1065,38 +1129,133 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
1065
1129
  hooks: [
1066
1130
  // Incident + rule enforcement pipelines fire on Write-only (incidents
1067
1131
  // are authored as .md files; rules are enforced after new-file drops).
1068
- { type: 'command', command: hookCmd(hooksDir, 'incident-pipeline.js'), timeout: 5 },
1069
- { type: 'command', command: hookCmd(hooksDir, 'rule-enforcement-pipeline.js'), timeout: 5 },
1132
+ { type: 'command', command: hookCmd(version, 'incident-pipeline'), timeout: 5 },
1133
+ { type: 'command', command: hookCmd(version, 'rule-enforcement-pipeline'), timeout: 5 },
1070
1134
  ],
1071
1135
  },
1072
1136
  ],
1073
1137
  Stop: [
1074
1138
  {
1075
1139
  hooks: [
1076
- { type: 'command', command: hookCmd(hooksDir, 'session-end.js'), timeout: 15 },
1140
+ { type: 'command', command: hookCmd(version, 'session-end'), timeout: 15 },
1077
1141
  // Session-end auto-learning aggregation (failure-class roll-up).
1078
- { type: 'command', command: hookCmd(hooksDir, 'auto-learning-pipeline.js'), timeout: 10 },
1142
+ { type: 'command', command: hookCmd(version, 'auto-learning-pipeline'), timeout: 10 },
1079
1143
  ],
1080
1144
  },
1081
1145
  ],
1082
1146
  PreCompact: [
1083
1147
  {
1084
1148
  hooks: [
1085
- { type: 'command', command: hookCmd(hooksDir, 'pre-compact.js'), timeout: 10 },
1149
+ { type: 'command', command: hookCmd(version, 'pre-compact'), timeout: 10 },
1086
1150
  ],
1087
1151
  },
1088
1152
  ],
1089
1153
  UserPromptSubmit: [
1090
1154
  {
1091
1155
  hooks: [
1092
- { type: 'command', command: hookCmd(hooksDir, 'user-prompt.js'), timeout: 5 },
1093
- { type: 'command', command: hookCmd(hooksDir, 'intent-suggester.js'), timeout: 5 },
1156
+ { type: 'command', command: hookCmd(version, 'user-prompt'), timeout: 5 },
1157
+ { type: 'command', command: hookCmd(version, 'intent-suggester'), timeout: 5 },
1094
1158
  ],
1095
1159
  },
1096
1160
  ],
1097
1161
  };
1098
1162
  }
1099
1163
 
1164
+ /**
1165
+ * Deep-merge two hooks configurations. P-012 (1.9.4+) — mirrors the 1.8.0
1166
+ * permissions merge pattern; closes the structural class where wholesale
1167
+ * `settings.hooks = newConfig` silently destroyed customer-defined hooks
1168
+ * on every reinstall.
1169
+ *
1170
+ * Merge semantics:
1171
+ * - Top-level keys (event names: SessionStart, PreToolUse, ...) are unioned.
1172
+ * - For each event, hook-groups are merged by `matcher` (or "" if no matcher).
1173
+ * This is the same identity key Claude Code uses for dispatch — two groups
1174
+ * with the same matcher MUST be coalesced or the dispatcher will pick one
1175
+ * and silently drop the other.
1176
+ * - Within a merged group, hook entries are deduplicated by `command` string
1177
+ * (exact match). Massu's own canonical entries are emitted first, then any
1178
+ * customer entries that don't collide. This preserves customer hooks while
1179
+ * keeping Massu's pipeline behavior deterministic.
1180
+ *
1181
+ * Massu canonical entries are identified by the `npx -y @massu/core@<version>
1182
+ * hook-runner ` prefix. ANY entry not matching that prefix is treated as
1183
+ * customer-defined and preserved verbatim across reinstalls — including
1184
+ * legacy entries from older `@massu/core` versions, which the customer can
1185
+ * clean up at their leisure.
1186
+ */
1187
+ export function mergeHooksConfig(
1188
+ existing: HooksConfig,
1189
+ additions: HooksConfig,
1190
+ ): HooksConfig {
1191
+ const eventNames = new Set<string>([
1192
+ ...Object.keys(existing ?? {}),
1193
+ ...Object.keys(additions ?? {}),
1194
+ ]);
1195
+
1196
+ const merged: HooksConfig = {};
1197
+ for (const event of eventNames) {
1198
+ const existingGroups = (existing?.[event] ?? []) as HookGroup[];
1199
+ const additionGroups = (additions?.[event] ?? []) as HookGroup[];
1200
+
1201
+ // Index by matcher key (use "" sentinel for groups with no matcher).
1202
+ const byMatcher = new Map<string, HookGroup>();
1203
+
1204
+ // Pass 1: seed with EXISTING groups (preserves customer order + structure).
1205
+ for (const group of existingGroups) {
1206
+ const key = group.matcher ?? '';
1207
+ const existingGroup = byMatcher.get(key);
1208
+ if (existingGroup) {
1209
+ // Two existing groups with same matcher — unusual but coalesce defensively.
1210
+ existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
1211
+ } else {
1212
+ byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
1213
+ }
1214
+ }
1215
+
1216
+ // Pass 2: merge ADDITIONS into the indexed groups.
1217
+ for (const group of additionGroups) {
1218
+ const key = group.matcher ?? '';
1219
+ const existingGroup = byMatcher.get(key);
1220
+ if (existingGroup) {
1221
+ existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
1222
+ } else {
1223
+ byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
1224
+ }
1225
+ }
1226
+
1227
+ merged[event] = Array.from(byMatcher.values());
1228
+ }
1229
+ return merged;
1230
+ }
1231
+
1232
+ /**
1233
+ * Merge two arrays of hook entries, deduplicating by `command`. Customer
1234
+ * entries (any command NOT matching the Massu canonical prefix) are
1235
+ * always preserved.
1236
+ */
1237
+ function mergeHookEntries(
1238
+ existing: HookEntry[],
1239
+ additions: HookEntry[],
1240
+ ): HookEntry[] {
1241
+ const seen = new Set<string>();
1242
+ const result: HookEntry[] = [];
1243
+ // Additions go first so Massu's canonical pipeline order is deterministic.
1244
+ for (const entry of additions ?? []) {
1245
+ if (!entry || typeof entry.command !== 'string') continue;
1246
+ if (seen.has(entry.command)) continue;
1247
+ seen.add(entry.command);
1248
+ result.push(entry);
1249
+ }
1250
+ for (const entry of existing ?? []) {
1251
+ if (!entry || typeof entry.command !== 'string') continue;
1252
+ if (seen.has(entry.command)) continue;
1253
+ seen.add(entry.command);
1254
+ result.push(entry);
1255
+ }
1256
+ return result;
1257
+ }
1258
+
1100
1259
  export function installHooks(projectRoot: string): { installed: boolean; count: number } {
1101
1260
  // Read claudeDirName defensively — tests may call installHooks without
1102
1261
  // ever creating massu.config.yaml, in which case getConfig() throws (since
@@ -1115,17 +1274,23 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1115
1274
 
1116
1275
  const settings = readSettingsLocal(claudeDir);
1117
1276
 
1118
- const hooksDir = resolveHooksDir();
1119
- const hooksConfig = buildHooksConfig(hooksDir);
1277
+ // P-003: hooksDir argument is now unused (kept for legacy callers).
1278
+ // buildHooksConfig emits `npx -y @massu/core@<version> hook-runner` lines.
1279
+ const hooksConfig = buildHooksConfig(resolveHooksDir());
1280
+
1281
+ // P-012: deep-merge with existing customer hooks instead of wholesale
1282
+ // replacement. Mirrors the 1.8.0 permissions-merge pattern (CR-39 trap class).
1283
+ const existingHooks = (settings.hooks as HooksConfig | undefined) ?? {};
1284
+ const mergedHooks = mergeHooksConfig(existingHooks, hooksConfig);
1120
1285
 
1121
1286
  let hookCount = 0;
1122
- for (const groups of Object.values(hooksConfig)) {
1287
+ for (const groups of Object.values(mergedHooks)) {
1123
1288
  for (const group of groups) {
1124
1289
  hookCount += group.hooks.length;
1125
1290
  }
1126
1291
  }
1127
1292
 
1128
- settings.hooks = hooksConfig;
1293
+ settings.hooks = mergedHooks;
1129
1294
 
1130
1295
  writeSettingsLocalAtomic(claudeDir, settings);
1131
1296
 
@@ -1136,10 +1301,35 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1136
1301
  // Memory Directory Initialization (preserved)
1137
1302
  // ============================================================
1138
1303
 
1139
- export function initMemoryDir(projectRoot: string): { created: boolean; memoryMdCreated: boolean } {
1140
- const encodedRoot = '-' + projectRoot.replace(/\//g, '-');
1304
+ export function initMemoryDir(projectRoot: string): { created: boolean; memoryMdCreated: boolean; migratedFromLegacy: boolean } {
1305
+ // P-004 / CR-39: encoding MUST match the reader at `config.ts:getResolvedPaths()`.
1306
+ // The legacy writer prepended an extra `-` (producing `--Users-foo-...`) which
1307
+ // orphaned MEMORY.md from the reader's canonical single-dash path. Shared helper
1308
+ // is the SoT — never re-derive inline.
1309
+ const encodedRoot = encodeMemoryDirName(projectRoot);
1141
1310
  const memoryDir = resolve(homedir(), `.claude/projects/${encodedRoot}/memory`);
1142
1311
 
1312
+ // Legacy-double-dash migration: if the customer was previously installed by
1313
+ // a buggy version (<1.9.4) that wrote to `--<root>`, detect that orphaned
1314
+ // sibling directory and move its contents into the canonical `-<root>` form.
1315
+ // Idempotent: skips if the legacy dir doesn't exist OR if it's already migrated.
1316
+ let migratedFromLegacy = false;
1317
+ const legacyDir = resolve(homedir(), `.claude/projects/-${encodedRoot}/memory`);
1318
+ if (existsSync(legacyDir) && !existsSync(memoryDir)) {
1319
+ try {
1320
+ mkdirSync(resolve(memoryDir, '..'), { recursive: true });
1321
+ renameSync(legacyDir, memoryDir);
1322
+ // Best-effort cleanup of the now-empty parent (only if empty).
1323
+ try {
1324
+ const legacyParent = resolve(legacyDir, '..');
1325
+ if (existsSync(legacyParent) && readdirSync(legacyParent).length === 0) {
1326
+ rmSync(legacyParent, { recursive: false });
1327
+ }
1328
+ } catch { /* best effort */ }
1329
+ migratedFromLegacy = true;
1330
+ } catch { /* best effort — if migration fails, the new canonical dir is still created below */ }
1331
+ }
1332
+
1143
1333
  let created = false;
1144
1334
  if (!existsSync(memoryDir)) {
1145
1335
  mkdirSync(memoryDir, { recursive: true });
@@ -1168,7 +1358,7 @@ export function initMemoryDir(projectRoot: string): { created: boolean; memoryMd
1168
1358
  memoryMdCreated = true;
1169
1359
  }
1170
1360
 
1171
- return { created, memoryMdCreated };
1361
+ return { created, memoryMdCreated, migratedFromLegacy };
1172
1362
  }
1173
1363
 
1174
1364
  // ============================================================
@@ -1531,18 +1721,22 @@ function installSideEffects(
1531
1721
  }
1532
1722
 
1533
1723
  // Memory dir
1534
- const { created: memDirCreated, memoryMdCreated } = initMemoryDir(projectRoot);
1724
+ const { created: memDirCreated, memoryMdCreated, migratedFromLegacy } = initMemoryDir(projectRoot);
1535
1725
  if (memDirCreated) {
1536
1726
  log(' Created memory directory');
1537
1727
  }
1538
1728
  if (memoryMdCreated) {
1539
1729
  log(' Created initial MEMORY.md');
1540
1730
  }
1731
+ if (migratedFromLegacy) {
1732
+ log(' Migrated memory directory from legacy double-dash path (pre-1.9.4)');
1733
+ }
1541
1734
 
1542
1735
  // Backfill (best-effort, silent failure)
1543
1736
  (async () => {
1544
1737
  try {
1545
- const encodedRoot = projectRoot.replace(/\//g, '-');
1738
+ // Shared encode helper — must match `initMemoryDir` and `config.ts:getResolvedPaths()`.
1739
+ const encodedRoot = encodeMemoryDirName(projectRoot);
1546
1740
  const memoryDir = resolve(homedir(), '.claude', 'projects', encodedRoot, 'memory');
1547
1741
  const memFiles = existsSync(memoryDir)
1548
1742
  ? readdirSync(memoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
package/src/config.ts CHANGED
@@ -15,6 +15,7 @@ import { existsSync, readFileSync } from 'fs';
15
15
  import { homedir } from 'os';
16
16
  import { parse as parseYaml } from 'yaml';
17
17
  import { z } from 'zod';
18
+ import { encodeMemoryDirName } from './lib/memory-path.ts';
18
19
 
19
20
  // ============================================================
20
21
  // Massu Configuration — Zod Schemas & Types
@@ -747,7 +748,7 @@ export function getResolvedPaths() {
747
748
  plansDir: resolve(root, 'docs/plans'),
748
749
  docsDir: resolve(root, 'docs'),
749
750
  claudeDir: resolve(root, claudeDirName),
750
- memoryDir: resolve(homedir(), claudeDirName, 'projects', root.replace(/\//g, '-'), 'memory'),
751
+ memoryDir: resolve(homedir(), claudeDirName, 'projects', encodeMemoryDirName(root), 'memory'),
751
752
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
752
753
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
753
754
  mcpJsonPath: resolve(root, '.mcp.json'),
@@ -14,7 +14,7 @@
14
14
  // Must complete in <1000ms.
15
15
  // ============================================================
16
16
 
17
- import { execSync } from 'child_process';
17
+ import { execFileSync } from 'child_process';
18
18
  import { existsSync, appendFileSync, mkdirSync, readFileSync } from 'fs';
19
19
  import { tmpdir } from 'os';
20
20
  import { join } from 'path';
@@ -119,9 +119,9 @@ async function main(): Promise<void> {
119
119
  const root = getProjectRoot();
120
120
  let diff = '';
121
121
  try {
122
- diff = execSync(`git diff -- "${filePath}"`, { cwd: root, timeout: 3000, encoding: 'utf-8' });
122
+ diff = execFileSync('git', ['diff', '--', filePath], { cwd: root, timeout: 3000, encoding: 'utf-8' });
123
123
  if (!diff) {
124
- diff = execSync(`git diff HEAD -- "${filePath}"`, { cwd: root, timeout: 3000, encoding: 'utf-8' });
124
+ diff = execFileSync('git', ['diff', 'HEAD', '--', filePath], { cwd: root, timeout: 3000, encoding: 'utf-8' });
125
125
  }
126
126
  } catch {
127
127
  process.exit(0);
@@ -0,0 +1,49 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Memory directory name encoding — canonical helpers.
6
+ *
7
+ * Single source of truth for translating a project's absolute filesystem
8
+ * root into the directory name used under `~/.claude/projects/<encoded-root>/memory/`.
9
+ *
10
+ * Closes the P-004 install-path drift class: the writer in `commands/init.ts`
11
+ * historically prepended an EXTRA leading `-` while the reader in
12
+ * `config.ts:getResolvedPaths()` and the backfill code path used the
13
+ * canonical single-dash form. Result: 100% of `massu init` runs orphaned
14
+ * `MEMORY.md` in a directory the reader could never find.
15
+ *
16
+ * Both encoding and decoding live here so the round-trip property is testable.
17
+ *
18
+ * Encoding rule:
19
+ * Replace every `/` in the absolute project root with `-`.
20
+ * An absolute path always starts with `/`, so the result always starts with
21
+ * `-` exactly once. NEVER prepend an additional `-`.
22
+ *
23
+ * Decoding rule:
24
+ * Replace every `-` in the directory name with `/`. This is the canonical
25
+ * inverse used by Claude Code's session-state plumbing. Note: project roots
26
+ * that contain literal `-` characters cannot be unambiguously round-tripped
27
+ * through this encoding — the same trade-off Claude Code's own resolver makes.
28
+ */
29
+
30
+ /**
31
+ * Encode an absolute project root into the directory name used under
32
+ * `~/.claude/projects/<dir>/memory/`.
33
+ *
34
+ * @param projectRoot Absolute filesystem path (must start with `/`).
35
+ * @returns Canonical encoded directory name (always begins with `-`).
36
+ */
37
+ export function encodeMemoryDirName(projectRoot: string): string {
38
+ return projectRoot.replace(/\//g, '-');
39
+ }
40
+
41
+ /**
42
+ * Decode a memory-directory name back to its slash-separated form.
43
+ *
44
+ * @param dirname The directory name as it appears under `~/.claude/projects/`.
45
+ * @returns A slash-separated path (begins with `/`).
46
+ */
47
+ export function decodeMemoryDirName(dirname: string): string {
48
+ return dirname.replace(/-/g, '/');
49
+ }
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-16T02:54:04.271Z.
1
+ // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-17T00:41:46.925Z.
2
2
  // Source pem: packages/core/security/registry-pubkey.pem
3
3
  // RAW-bytes sha256: 3b6226d036c472e533110d11a7d0cd2773ce1d7d4f1003517d5bd69c5418ed4c
4
4
  // DO NOT EDIT — regenerate via `node scripts/bundle-pubkey.mjs` or