@massu/core 1.9.3 → 1.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,145 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * `massu hook-runner <hook-name>` — dynamic hook dispatcher.
6
+ *
7
+ * Closes the P-003 install-path drift class: previously, `installHooks`
8
+ * baked an ABSOLUTE path to the installer's `dist/hooks/*.js` location
9
+ * (whatever npx happened to cache, e.g. `/opt/homebrew/lib/node_modules/...`).
10
+ * Any cache clear, global-install relocation, or npx upgrade silently 404'd
11
+ * every hook — customers thought auto-learning was working but nothing fired.
12
+ *
13
+ * The fix: settings.json now invokes `npx -y @massu/core@<pinned-version> hook-runner <name>`.
14
+ * This subcommand resolves the hook script via Node's module resolver at
15
+ * fire-time, dispatching to the same compiled hook file that ships with
16
+ * the installer. Customer never sees an absolute path.
17
+ *
18
+ * Performance: each hook fire spawns npx + node. Measured ~120-300ms cold
19
+ * (npx cache hit). Acceptable for hooks not on UI critical path; SessionStart
20
+ * and PreCompact are infrequent, PostToolUse is per-tool-call.
21
+ *
22
+ * Hook name → compiled-file mapping is exhaustive (closed enum) so we fail
23
+ * loudly on typos rather than silent-no-op. Unknown hook names print a
24
+ * diagnostic to stderr and exit 2 (distinct from hook's own non-zero exits).
25
+ */
26
+
27
+ import { existsSync } from 'fs';
28
+ import { resolve, dirname } from 'path';
29
+ import { fileURLToPath } from 'url';
30
+ import { spawn } from 'child_process';
31
+
32
+ const __filename = fileURLToPath(import.meta.url);
33
+ const __dirname = dirname(__filename);
34
+
35
+ /**
36
+ * Closed enum of recognized hook names → compiled JS filename under `dist/hooks/`.
37
+ * Keep in sync with `buildHooksConfig` in `commands/init.ts` and the source
38
+ * files in `packages/core/src/hooks/`.
39
+ */
40
+ export const HOOK_NAME_TO_FILE: Record<string, string> = {
41
+ 'session-start': 'session-start.js',
42
+ 'session-end': 'session-end.js',
43
+ 'security-gate': 'security-gate.js',
44
+ 'pre-delete-check': 'pre-delete-check.js',
45
+ 'post-tool-use': 'post-tool-use.js',
46
+ 'post-edit-context': 'post-edit-context.js',
47
+ 'quality-event': 'quality-event.js',
48
+ 'cost-tracker': 'cost-tracker.js',
49
+ 'fix-detector': 'fix-detector.js',
50
+ 'classify-failure': 'classify-failure.js',
51
+ 'incident-pipeline': 'incident-pipeline.js',
52
+ 'rule-enforcement-pipeline': 'rule-enforcement-pipeline.js',
53
+ 'auto-learning-pipeline': 'auto-learning-pipeline.js',
54
+ 'pre-compact': 'pre-compact.js',
55
+ 'user-prompt': 'user-prompt.js',
56
+ 'intent-suggester': 'intent-suggester.js',
57
+ };
58
+
59
+ /**
60
+ * Resolve the compiled hook file path for a given hook name.
61
+ *
62
+ * Search order (in order of likelihood at runtime):
63
+ * 1. `./hooks/<file>` — bundled compiled layout: dist/cli.js + dist/hooks/*.js
64
+ * (the canonical layout under npx cache + global install).
65
+ * 2. `../hooks/<file>` — TS-source dev layout: src/commands/hook-runner.ts +
66
+ * src/hooks/<file>.ts (used by direct-tsx invocation in tests).
67
+ * 3. `../../dist/hooks/<file>` — TS-source dev layout fallback after build.
68
+ *
69
+ * Hard error on miss — silently swallowing a missing hook is exactly the bug
70
+ * class P-003 closes.
71
+ */
72
+ export function resolveHookFile(hookName: string): string {
73
+ const file = HOOK_NAME_TO_FILE[hookName];
74
+ if (!file) {
75
+ throw new Error(
76
+ `Unknown hook: "${hookName}". Recognized: ${Object.keys(HOOK_NAME_TO_FILE).join(', ')}`,
77
+ );
78
+ }
79
+ const candidates = [
80
+ // Bundled compiled layout: dist/cli.js → ./hooks/<file>.js
81
+ resolve(__dirname, 'hooks', file),
82
+ // TS-source dev / sibling layout: src/commands/ → ../hooks/<file>
83
+ resolve(__dirname, '../hooks', file),
84
+ // TS-source dev fallback: src/commands/ → ../../dist/hooks/<file>
85
+ resolve(__dirname, '../../dist/hooks', file),
86
+ ];
87
+ for (const candidate of candidates) {
88
+ if (existsSync(candidate)) {
89
+ return candidate;
90
+ }
91
+ }
92
+ throw new Error(
93
+ `Hook file not found for "${hookName}". Searched: ${candidates.join(', ')}. ` +
94
+ 'This indicates a broken @massu/core install. Re-run `npx -y @massu/core init`.',
95
+ );
96
+ }
97
+
98
+ /**
99
+ * Subcommand entrypoint. Spawns `node <resolved-hook-file>` as a child
100
+ * with stdin/stdout/stderr piped through, so the hook receives the same
101
+ * JSON-on-stdin contract Claude Code expects, and stdout/stderr surface
102
+ * to Claude Code unchanged.
103
+ *
104
+ * Returns the child's exit code (or 2 on resolution error before spawn).
105
+ */
106
+ export async function runHookRunner(args: string[]): Promise<{ exitCode: number }> {
107
+ const hookName = args[0];
108
+ if (!hookName) {
109
+ process.stderr.write(
110
+ 'massu hook-runner: missing hook name.\n' +
111
+ 'Usage: massu hook-runner <hook-name>\n' +
112
+ `Recognized: ${Object.keys(HOOK_NAME_TO_FILE).join(', ')}\n`,
113
+ );
114
+ return { exitCode: 2 };
115
+ }
116
+
117
+ let hookFile: string;
118
+ try {
119
+ hookFile = resolveHookFile(hookName);
120
+ } catch (err) {
121
+ process.stderr.write(`massu hook-runner: ${err instanceof Error ? err.message : String(err)}\n`);
122
+ return { exitCode: 2 };
123
+ }
124
+
125
+ return new Promise((resolvePromise) => {
126
+ const child = spawn(process.execPath, [hookFile], {
127
+ stdio: ['inherit', 'inherit', 'inherit'],
128
+ env: process.env,
129
+ });
130
+ child.on('exit', (code, signal) => {
131
+ if (signal) {
132
+ // Mirror typical shell convention: 128 + signal number; signals are not
133
+ // easily mapped to numbers here without an explicit table, so we just
134
+ // report 128 as a sentinel "killed by signal".
135
+ resolvePromise({ exitCode: 128 });
136
+ return;
137
+ }
138
+ resolvePromise({ exitCode: code ?? 0 });
139
+ });
140
+ child.on('error', (err) => {
141
+ process.stderr.write(`massu hook-runner: failed to spawn hook "${hookName}": ${err.message}\n`);
142
+ resolvePromise({ exitCode: 2 });
143
+ });
144
+ });
145
+ }
@@ -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,
@@ -437,6 +438,22 @@ export function buildConfigFromDetection(
437
438
  }
438
439
  }
439
440
 
441
+ // P-H004 (plan-stage-c-high-batch): App Router / Pages Router fallback.
442
+ // When pathsSource would default to 'src' but src/ doesn't exist, check
443
+ // recognized framework conventions before failing validation. Fixes
444
+ // `massu init` outright failure on fresh Next.js 14+ App Router repos
445
+ // (have `app/` + `package.json`, no `src/`) and Pages Router (`pages/`).
446
+ // Final fallback to '.' makes flat-layout projects work too.
447
+ if (pathsSource === 'src' && !existsSync(resolve(projectRoot, 'src'))) {
448
+ const fallbacks = ['app', 'pages', '.'];
449
+ for (const fallback of fallbacks) {
450
+ if (fallback === '.' || existsSync(resolve(projectRoot, fallback))) {
451
+ pathsSource = fallback;
452
+ break;
453
+ }
454
+ }
455
+ }
456
+
440
457
  // P1-005: emit `paths.monorepo_roots` as the distinct parent directories of
441
458
  // every workspace package when this is a monorepo. Optional + additive;
442
459
  // v1 consumers ignore it. When detection identified a monorepo type
@@ -956,6 +973,42 @@ export function copyTemplateConfig(
956
973
  // MCP Server Registration (preserved)
957
974
  // ============================================================
958
975
 
976
+ /**
977
+ * Read the installer's OWN package.json version. The installer runs from
978
+ * its compiled `dist/cli.js` (under npx cache or a global install); the
979
+ * package.json sits one directory up from that. Used to pin downstream
980
+ * MCP server invocations and hook commands so customers don't drift onto
981
+ * unpinned `@massu/core` (which would resolve to the latest dist-tag on
982
+ * every spawn and silently change behavior across versions).
983
+ *
984
+ * Hard error if the package.json can't be read or has no version field —
985
+ * an unpinned write is a structural drift bug (P-002) and silently
986
+ * falling back to an unversioned `@massu/core` is what we're closing.
987
+ */
988
+ export function getInstallerVersion(): string {
989
+ // Walk up from this module's compiled location to find package.json.
990
+ // Compiled layout: <root>/dist/cli.js → ../package.json
991
+ // TS source layout: <root>/src/commands/init.ts → ../../package.json
992
+ const candidates = [
993
+ resolve(__dirname, '../package.json'),
994
+ resolve(__dirname, '../../package.json'),
995
+ ];
996
+ for (const candidate of candidates) {
997
+ if (existsSync(candidate)) {
998
+ try {
999
+ const pkg = JSON.parse(readFileSync(candidate, 'utf-8'));
1000
+ if (typeof pkg.version === 'string' && pkg.version.length > 0 && pkg.name === '@massu/core') {
1001
+ return pkg.version;
1002
+ }
1003
+ } catch { /* try next */ }
1004
+ }
1005
+ }
1006
+ throw new Error(
1007
+ 'getInstallerVersion: could not resolve @massu/core package.json. ' +
1008
+ 'This indicates a corrupt install. Re-install via `npx -y @massu/core init`.',
1009
+ );
1010
+ }
1011
+
959
1012
  export function registerMcpServer(projectRoot: string): boolean {
960
1013
  const mcpPath = resolve(projectRoot, '.mcp.json');
961
1014
 
@@ -973,10 +1026,14 @@ export function registerMcpServer(projectRoot: string): boolean {
973
1026
  return false;
974
1027
  }
975
1028
 
1029
+ // P-002: pin the version so customers don't drift onto unpinned `@massu/core`
1030
+ // (which resolves to the latest dist-tag on every spawn). Closes the structural
1031
+ // class flagged by feedback_mcp_pin_version_in_mcp_json (precedent: 0b60916).
1032
+ const version = getInstallerVersion();
976
1033
  servers.massu = {
977
1034
  type: 'stdio',
978
1035
  command: 'npx',
979
- args: ['-y', '@massu/core'],
1036
+ args: ['-y', `@massu/core@${version}`],
980
1037
  };
981
1038
 
982
1039
  existing.mcpServers = servers;
@@ -1002,6 +1059,16 @@ interface HookGroup {
1002
1059
 
1003
1060
  type HooksConfig = Record<string, HookGroup[]>;
1004
1061
 
1062
+ /**
1063
+ * @deprecated P-003 (1.9.4+): the path returned here is unsafe to bake into
1064
+ * settings.json — under npx, `__dirname` resolves to whatever cache directory
1065
+ * npx happens to use, which is invalidated on cache clear / upgrade / move.
1066
+ *
1067
+ * Retained only for backward compatibility with `buildHooksConfig(hooksDir)`
1068
+ * callers in existing tests. New code should NEVER consume the returned path
1069
+ * verbatim in a command line; use `hook-runner` invocations instead (see
1070
+ * `buildHooksConfig` which now ignores the argument).
1071
+ */
1005
1072
  export function resolveHooksDir(): string {
1006
1073
  const cwd = process.cwd();
1007
1074
  const nodeModulesPath = resolve(cwd, 'node_modules/@massu/core/dist/hooks');
@@ -1015,16 +1082,29 @@ export function resolveHooksDir(): string {
1015
1082
  return 'node_modules/@massu/core/dist/hooks';
1016
1083
  }
1017
1084
 
1018
- function hookCmd(hooksDir: string, hookFile: string): string {
1019
- return `node ${hooksDir}/${hookFile}`;
1085
+ /**
1086
+ * Build a single hook-command line. P-003: emits `npx -y @massu/core@<version>
1087
+ * hook-runner <name>` so the hook file is resolved dynamically at fire-time
1088
+ * rather than baked as an absolute path. Pins the version (CR-49-class fix)
1089
+ * so customers don't drift onto unpinned `@massu/core` between hook fires.
1090
+ */
1091
+ function hookCmd(version: string, hookName: string): string {
1092
+ return `npx -y @massu/core@${version} hook-runner ${hookName}`;
1020
1093
  }
1021
1094
 
1022
- export function buildHooksConfig(hooksDir: string): HooksConfig {
1095
+ /**
1096
+ * Build the canonical Claude Code hooks configuration. The legacy
1097
+ * `hooksDir` parameter is now ignored; we emit `hook-runner` invocations
1098
+ * instead of `node <abs-path>` (see P-003). The parameter is retained
1099
+ * for backward-compatible call sites (existing tests pass a dir).
1100
+ */
1101
+ export function buildHooksConfig(_hooksDir?: string): HooksConfig {
1102
+ const version = getInstallerVersion();
1023
1103
  return {
1024
1104
  SessionStart: [
1025
1105
  {
1026
1106
  hooks: [
1027
- { type: 'command', command: hookCmd(hooksDir, 'session-start.js'), timeout: 10 },
1107
+ { type: 'command', command: hookCmd(version, 'session-start'), timeout: 10 },
1028
1108
  ],
1029
1109
  },
1030
1110
  ],
@@ -1032,32 +1112,32 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
1032
1112
  {
1033
1113
  matcher: 'Bash',
1034
1114
  hooks: [
1035
- { type: 'command', command: hookCmd(hooksDir, 'security-gate.js'), timeout: 5 },
1115
+ { type: 'command', command: hookCmd(version, 'security-gate'), timeout: 5 },
1036
1116
  ],
1037
1117
  },
1038
1118
  {
1039
1119
  matcher: 'Bash|Write',
1040
1120
  hooks: [
1041
- { type: 'command', command: hookCmd(hooksDir, 'pre-delete-check.js'), timeout: 5 },
1121
+ { type: 'command', command: hookCmd(version, 'pre-delete-check'), timeout: 5 },
1042
1122
  ],
1043
1123
  },
1044
1124
  ],
1045
1125
  PostToolUse: [
1046
1126
  {
1047
1127
  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 },
1128
+ { type: 'command', command: hookCmd(version, 'post-tool-use'), timeout: 10 },
1129
+ { type: 'command', command: hookCmd(version, 'quality-event'), timeout: 5 },
1130
+ { type: 'command', command: hookCmd(version, 'cost-tracker'), timeout: 5 },
1051
1131
  ],
1052
1132
  },
1053
1133
  {
1054
1134
  matcher: 'Edit|Write',
1055
1135
  hooks: [
1056
- { type: 'command', command: hookCmd(hooksDir, 'post-edit-context.js'), timeout: 5 },
1136
+ { type: 'command', command: hookCmd(version, 'post-edit-context'), timeout: 5 },
1057
1137
  // Auto-learning pipeline — classifies failures and detects fixes on
1058
1138
  // 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 },
1139
+ { type: 'command', command: hookCmd(version, 'fix-detector'), timeout: 5 },
1140
+ { type: 'command', command: hookCmd(version, 'classify-failure'), timeout: 5 },
1061
1141
  ],
1062
1142
  },
1063
1143
  {
@@ -1065,38 +1145,133 @@ export function buildHooksConfig(hooksDir: string): HooksConfig {
1065
1145
  hooks: [
1066
1146
  // Incident + rule enforcement pipelines fire on Write-only (incidents
1067
1147
  // 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 },
1148
+ { type: 'command', command: hookCmd(version, 'incident-pipeline'), timeout: 5 },
1149
+ { type: 'command', command: hookCmd(version, 'rule-enforcement-pipeline'), timeout: 5 },
1070
1150
  ],
1071
1151
  },
1072
1152
  ],
1073
1153
  Stop: [
1074
1154
  {
1075
1155
  hooks: [
1076
- { type: 'command', command: hookCmd(hooksDir, 'session-end.js'), timeout: 15 },
1156
+ { type: 'command', command: hookCmd(version, 'session-end'), timeout: 15 },
1077
1157
  // Session-end auto-learning aggregation (failure-class roll-up).
1078
- { type: 'command', command: hookCmd(hooksDir, 'auto-learning-pipeline.js'), timeout: 10 },
1158
+ { type: 'command', command: hookCmd(version, 'auto-learning-pipeline'), timeout: 10 },
1079
1159
  ],
1080
1160
  },
1081
1161
  ],
1082
1162
  PreCompact: [
1083
1163
  {
1084
1164
  hooks: [
1085
- { type: 'command', command: hookCmd(hooksDir, 'pre-compact.js'), timeout: 10 },
1165
+ { type: 'command', command: hookCmd(version, 'pre-compact'), timeout: 10 },
1086
1166
  ],
1087
1167
  },
1088
1168
  ],
1089
1169
  UserPromptSubmit: [
1090
1170
  {
1091
1171
  hooks: [
1092
- { type: 'command', command: hookCmd(hooksDir, 'user-prompt.js'), timeout: 5 },
1093
- { type: 'command', command: hookCmd(hooksDir, 'intent-suggester.js'), timeout: 5 },
1172
+ { type: 'command', command: hookCmd(version, 'user-prompt'), timeout: 5 },
1173
+ { type: 'command', command: hookCmd(version, 'intent-suggester'), timeout: 5 },
1094
1174
  ],
1095
1175
  },
1096
1176
  ],
1097
1177
  };
1098
1178
  }
1099
1179
 
1180
+ /**
1181
+ * Deep-merge two hooks configurations. P-012 (1.9.4+) — mirrors the 1.8.0
1182
+ * permissions merge pattern; closes the structural class where wholesale
1183
+ * `settings.hooks = newConfig` silently destroyed customer-defined hooks
1184
+ * on every reinstall.
1185
+ *
1186
+ * Merge semantics:
1187
+ * - Top-level keys (event names: SessionStart, PreToolUse, ...) are unioned.
1188
+ * - For each event, hook-groups are merged by `matcher` (or "" if no matcher).
1189
+ * This is the same identity key Claude Code uses for dispatch — two groups
1190
+ * with the same matcher MUST be coalesced or the dispatcher will pick one
1191
+ * and silently drop the other.
1192
+ * - Within a merged group, hook entries are deduplicated by `command` string
1193
+ * (exact match). Massu's own canonical entries are emitted first, then any
1194
+ * customer entries that don't collide. This preserves customer hooks while
1195
+ * keeping Massu's pipeline behavior deterministic.
1196
+ *
1197
+ * Massu canonical entries are identified by the `npx -y @massu/core@<version>
1198
+ * hook-runner ` prefix. ANY entry not matching that prefix is treated as
1199
+ * customer-defined and preserved verbatim across reinstalls — including
1200
+ * legacy entries from older `@massu/core` versions, which the customer can
1201
+ * clean up at their leisure.
1202
+ */
1203
+ export function mergeHooksConfig(
1204
+ existing: HooksConfig,
1205
+ additions: HooksConfig,
1206
+ ): HooksConfig {
1207
+ const eventNames = new Set<string>([
1208
+ ...Object.keys(existing ?? {}),
1209
+ ...Object.keys(additions ?? {}),
1210
+ ]);
1211
+
1212
+ const merged: HooksConfig = {};
1213
+ for (const event of eventNames) {
1214
+ const existingGroups = (existing?.[event] ?? []) as HookGroup[];
1215
+ const additionGroups = (additions?.[event] ?? []) as HookGroup[];
1216
+
1217
+ // Index by matcher key (use "" sentinel for groups with no matcher).
1218
+ const byMatcher = new Map<string, HookGroup>();
1219
+
1220
+ // Pass 1: seed with EXISTING groups (preserves customer order + structure).
1221
+ for (const group of existingGroups) {
1222
+ const key = group.matcher ?? '';
1223
+ const existingGroup = byMatcher.get(key);
1224
+ if (existingGroup) {
1225
+ // Two existing groups with same matcher — unusual but coalesce defensively.
1226
+ existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
1227
+ } else {
1228
+ byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
1229
+ }
1230
+ }
1231
+
1232
+ // Pass 2: merge ADDITIONS into the indexed groups.
1233
+ for (const group of additionGroups) {
1234
+ const key = group.matcher ?? '';
1235
+ const existingGroup = byMatcher.get(key);
1236
+ if (existingGroup) {
1237
+ existingGroup.hooks = mergeHookEntries(existingGroup.hooks, group.hooks);
1238
+ } else {
1239
+ byMatcher.set(key, { ...group, hooks: [...(group.hooks ?? [])] });
1240
+ }
1241
+ }
1242
+
1243
+ merged[event] = Array.from(byMatcher.values());
1244
+ }
1245
+ return merged;
1246
+ }
1247
+
1248
+ /**
1249
+ * Merge two arrays of hook entries, deduplicating by `command`. Customer
1250
+ * entries (any command NOT matching the Massu canonical prefix) are
1251
+ * always preserved.
1252
+ */
1253
+ function mergeHookEntries(
1254
+ existing: HookEntry[],
1255
+ additions: HookEntry[],
1256
+ ): HookEntry[] {
1257
+ const seen = new Set<string>();
1258
+ const result: HookEntry[] = [];
1259
+ // Additions go first so Massu's canonical pipeline order is deterministic.
1260
+ for (const entry of additions ?? []) {
1261
+ if (!entry || typeof entry.command !== 'string') continue;
1262
+ if (seen.has(entry.command)) continue;
1263
+ seen.add(entry.command);
1264
+ result.push(entry);
1265
+ }
1266
+ for (const entry of existing ?? []) {
1267
+ if (!entry || typeof entry.command !== 'string') continue;
1268
+ if (seen.has(entry.command)) continue;
1269
+ seen.add(entry.command);
1270
+ result.push(entry);
1271
+ }
1272
+ return result;
1273
+ }
1274
+
1100
1275
  export function installHooks(projectRoot: string): { installed: boolean; count: number } {
1101
1276
  // Read claudeDirName defensively — tests may call installHooks without
1102
1277
  // ever creating massu.config.yaml, in which case getConfig() throws (since
@@ -1115,17 +1290,23 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1115
1290
 
1116
1291
  const settings = readSettingsLocal(claudeDir);
1117
1292
 
1118
- const hooksDir = resolveHooksDir();
1119
- const hooksConfig = buildHooksConfig(hooksDir);
1293
+ // P-003: hooksDir argument is now unused (kept for legacy callers).
1294
+ // buildHooksConfig emits `npx -y @massu/core@<version> hook-runner` lines.
1295
+ const hooksConfig = buildHooksConfig(resolveHooksDir());
1296
+
1297
+ // P-012: deep-merge with existing customer hooks instead of wholesale
1298
+ // replacement. Mirrors the 1.8.0 permissions-merge pattern (CR-39 trap class).
1299
+ const existingHooks = (settings.hooks as HooksConfig | undefined) ?? {};
1300
+ const mergedHooks = mergeHooksConfig(existingHooks, hooksConfig);
1120
1301
 
1121
1302
  let hookCount = 0;
1122
- for (const groups of Object.values(hooksConfig)) {
1303
+ for (const groups of Object.values(mergedHooks)) {
1123
1304
  for (const group of groups) {
1124
1305
  hookCount += group.hooks.length;
1125
1306
  }
1126
1307
  }
1127
1308
 
1128
- settings.hooks = hooksConfig;
1309
+ settings.hooks = mergedHooks;
1129
1310
 
1130
1311
  writeSettingsLocalAtomic(claudeDir, settings);
1131
1312
 
@@ -1136,10 +1317,35 @@ export function installHooks(projectRoot: string): { installed: boolean; count:
1136
1317
  // Memory Directory Initialization (preserved)
1137
1318
  // ============================================================
1138
1319
 
1139
- export function initMemoryDir(projectRoot: string): { created: boolean; memoryMdCreated: boolean } {
1140
- const encodedRoot = '-' + projectRoot.replace(/\//g, '-');
1320
+ export function initMemoryDir(projectRoot: string): { created: boolean; memoryMdCreated: boolean; migratedFromLegacy: boolean } {
1321
+ // P-004 / CR-39: encoding MUST match the reader at `config.ts:getResolvedPaths()`.
1322
+ // The legacy writer prepended an extra `-` (producing `--Users-foo-...`) which
1323
+ // orphaned MEMORY.md from the reader's canonical single-dash path. Shared helper
1324
+ // is the SoT — never re-derive inline.
1325
+ const encodedRoot = encodeMemoryDirName(projectRoot);
1141
1326
  const memoryDir = resolve(homedir(), `.claude/projects/${encodedRoot}/memory`);
1142
1327
 
1328
+ // Legacy-double-dash migration: if the customer was previously installed by
1329
+ // a buggy version (<1.9.4) that wrote to `--<root>`, detect that orphaned
1330
+ // sibling directory and move its contents into the canonical `-<root>` form.
1331
+ // Idempotent: skips if the legacy dir doesn't exist OR if it's already migrated.
1332
+ let migratedFromLegacy = false;
1333
+ const legacyDir = resolve(homedir(), `.claude/projects/-${encodedRoot}/memory`);
1334
+ if (existsSync(legacyDir) && !existsSync(memoryDir)) {
1335
+ try {
1336
+ mkdirSync(resolve(memoryDir, '..'), { recursive: true });
1337
+ renameSync(legacyDir, memoryDir);
1338
+ // Best-effort cleanup of the now-empty parent (only if empty).
1339
+ try {
1340
+ const legacyParent = resolve(legacyDir, '..');
1341
+ if (existsSync(legacyParent) && readdirSync(legacyParent).length === 0) {
1342
+ rmSync(legacyParent, { recursive: false });
1343
+ }
1344
+ } catch { /* best effort */ }
1345
+ migratedFromLegacy = true;
1346
+ } catch { /* best effort — if migration fails, the new canonical dir is still created below */ }
1347
+ }
1348
+
1143
1349
  let created = false;
1144
1350
  if (!existsSync(memoryDir)) {
1145
1351
  mkdirSync(memoryDir, { recursive: true });
@@ -1168,7 +1374,7 @@ export function initMemoryDir(projectRoot: string): { created: boolean; memoryMd
1168
1374
  memoryMdCreated = true;
1169
1375
  }
1170
1376
 
1171
- return { created, memoryMdCreated };
1377
+ return { created, memoryMdCreated, migratedFromLegacy };
1172
1378
  }
1173
1379
 
1174
1380
  // ============================================================
@@ -1531,18 +1737,22 @@ function installSideEffects(
1531
1737
  }
1532
1738
 
1533
1739
  // Memory dir
1534
- const { created: memDirCreated, memoryMdCreated } = initMemoryDir(projectRoot);
1740
+ const { created: memDirCreated, memoryMdCreated, migratedFromLegacy } = initMemoryDir(projectRoot);
1535
1741
  if (memDirCreated) {
1536
1742
  log(' Created memory directory');
1537
1743
  }
1538
1744
  if (memoryMdCreated) {
1539
1745
  log(' Created initial MEMORY.md');
1540
1746
  }
1747
+ if (migratedFromLegacy) {
1748
+ log(' Migrated memory directory from legacy double-dash path (pre-1.9.4)');
1749
+ }
1541
1750
 
1542
1751
  // Backfill (best-effort, silent failure)
1543
1752
  (async () => {
1544
1753
  try {
1545
- const encodedRoot = projectRoot.replace(/\//g, '-');
1754
+ // Shared encode helper — must match `initMemoryDir` and `config.ts:getResolvedPaths()`.
1755
+ const encodedRoot = encodeMemoryDirName(projectRoot);
1546
1756
  const memoryDir = resolve(homedir(), '.claude', 'projects', encodedRoot, 'memory');
1547
1757
  const memFiles = existsSync(memoryDir)
1548
1758
  ? readdirSync(memoryDir).filter(f => f.endsWith('.md') && f !== 'MEMORY.md')
@@ -494,6 +494,16 @@ export function buildTemplateVars(): Record<string, unknown> {
494
494
  paths: config.paths,
495
495
  detected: config.detected ?? {},
496
496
  config,
497
+ // P-H006 (plan-stage-c-high-batch): RESERVED CLAUDE CODE PLACEHOLDER.
498
+ // Claude Code reads `{{ARGUMENTS}}` as a runtime placeholder inside
499
+ // slash-command files. The Massu template engine has no native concept
500
+ // of reserved literals; we model the placeholder as a variable whose
501
+ // value IS the literal `{{ARGUMENTS}}` string. Because the engine never
502
+ // re-renders output, this passes through verbatim. Closes the bug class
503
+ // where `/massu-article-review`, `/massu-autoresearch`, etc. silently
504
+ // failed to install because the engine threw MissingVariableError on
505
+ // their {{ARGUMENTS}} usage.
506
+ ARGUMENTS: '{{ARGUMENTS}}',
497
507
  };
498
508
  }
499
509
 
@@ -4,7 +4,8 @@
4
4
  /**
5
5
  * `massu install-hooks` — Standalone hook installation.
6
6
  *
7
- * Installs or updates all 11 Claude Code hooks in .claude/settings.local.json.
7
+ * Installs or updates the canonical Massu hook set in .claude/settings.local.json.
8
+ * Count is sourced from lib/hook-registry.ts SoT; see REGISTERED_HOOKS there.
8
9
  * Uses the same logic as `massu init` but only handles hooks.
9
10
  */
10
11
 
@@ -69,6 +69,24 @@ export function renderTemplate(template: string, vars: Record<string, unknown>):
69
69
  throw new TemplateParseError('unclosed `{{` (no matching `}}`)', tokenStart);
70
70
  }
71
71
  const inner = template.slice(i + 2, closeIdx);
72
+
73
+ // P-H007 (plan-stage-c-high-batch): JSX pass-through. Pattern docs
74
+ // contain content like `action={{ label: "X" }}` (JSX object literal)
75
+ // that LOOKS like a template token but isn't. Pre-fix the engine
76
+ // threw TemplateParseError and the WHOLE file silently failed to
77
+ // install. Now: detect ONLY clearly-JSX patterns (multi-line OR
78
+ // leading whitespace inside the braces) and emit verbatim. Everything
79
+ // else (including security probes, empty `{{}}`, malformed filters,
80
+ // invalid path characters) still goes through strict renderToken and
81
+ // throws — security tests still pass.
82
+ if (isJsxPassThrough(inner)) {
83
+ out.push('{{');
84
+ out.push(inner);
85
+ out.push('}}');
86
+ i = closeIdx + 2;
87
+ continue;
88
+ }
89
+
72
90
  const rendered = renderToken(inner, vars, tokenStart);
73
91
  out.push(rendered);
74
92
  i = closeIdx + 2;
@@ -128,6 +146,29 @@ function findTokenClose(template: string, start: number): number {
128
146
  return -1;
129
147
  }
130
148
 
149
+ /**
150
+ * P-H007: Decide whether the inner text of a `{{...}}` block is a multi-line
151
+ * JSX expression that should be emitted verbatim (NOT parsed as a Massu
152
+ * template token).
153
+ *
154
+ * Detection is intentionally MINIMAL: only multi-line content passes through.
155
+ * The original P-H007 evidence (`patterns/component-patterns.md` JSX
156
+ * `action={{ label: "X", onClick: () => ... }}` formatted across lines)
157
+ * IS multi-line; that is the bug class to close.
158
+ *
159
+ * Single-line content of EVERY shape (valid Massu vars, malformed paths,
160
+ * empty `{{}}`, security probes like `constructor.constructor("...")()`,
161
+ * default filters with escaped quotes) goes through the strict renderToken
162
+ * path so all pre-existing template-engine.test.ts behavior is preserved.
163
+ *
164
+ * Tradeoff: single-line JSX `action={{ x: 1 }}` (rare in practice) would
165
+ * still throw under this rule. The vast majority of JSX in shipped pattern
166
+ * docs is multi-line, so this is the correct conservative fix.
167
+ */
168
+ function isJsxPassThrough(inner: string): boolean {
169
+ return inner.includes('\n');
170
+ }
171
+
131
172
  /**
132
173
  * Render a single token (text BETWEEN `{{` and `}}`).
133
174
  * Format: `path.to.var` OR `path.to.var | default("fallback")`.
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'),