@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.
- package/dist/cli.js +472 -260
- package/dist/hooks/classify-failure.js +8 -1
- package/dist/hooks/cost-tracker.js +8 -1
- package/dist/hooks/fix-detector.js +3 -3
- package/dist/hooks/incident-pipeline.js +8 -1
- package/dist/hooks/post-edit-context.js +8 -1
- package/dist/hooks/post-tool-use.js +8 -1
- package/dist/hooks/pre-compact.js +8 -1
- package/dist/hooks/pre-delete-check.js +8 -1
- package/dist/hooks/quality-event.js +8 -1
- package/dist/hooks/session-end.js +8 -1
- package/dist/hooks/session-start.js +8 -1
- package/dist/hooks/user-prompt.js +8 -1
- package/package.json +2 -2
- package/src/backfill-sessions.ts +3 -2
- package/src/cli.ts +10 -0
- package/src/commands/hook-runner.ts +145 -0
- package/src/commands/init.ts +223 -29
- package/src/config.ts +2 -1
- package/src/hooks/fix-detector.ts +3 -3
- package/src/lib/memory-path.ts +49 -0
- package/src/security/registry-pubkey.generated.ts +1 -1
package/src/commands/init.ts
CHANGED
|
@@ -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',
|
|
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
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1049
|
-
{ type: 'command', command: hookCmd(
|
|
1050
|
-
{ type: 'command', command: hookCmd(
|
|
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(
|
|
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(
|
|
1060
|
-
{ type: 'command', command: hookCmd(
|
|
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(
|
|
1069
|
-
{ type: 'command', command: hookCmd(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1093
|
-
{ type: 'command', command: hookCmd(
|
|
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
|
-
|
|
1119
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 =
|
|
122
|
+
diff = execFileSync('git', ['diff', '--', filePath], { cwd: root, timeout: 3000, encoding: 'utf-8' });
|
|
123
123
|
if (!diff) {
|
|
124
|
-
diff =
|
|
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-
|
|
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
|