@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.
- package/dist/cli.js +561 -283
- package/dist/hooks/auto-learning-pipeline.js +30 -8
- 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 +98 -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 +18 -2
- 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/cloud-sync.ts +18 -0
- package/src/commands/doctor.ts +9 -14
- package/src/commands/hook-runner.ts +145 -0
- package/src/commands/init.ts +239 -29
- package/src/commands/install-commands.ts +10 -0
- package/src/commands/install-hooks.ts +2 -1
- package/src/commands/template-engine.ts +41 -0
- package/src/config.ts +2 -1
- package/src/hooks/auto-learning-pipeline.ts +43 -10
- package/src/hooks/fix-detector.ts +3 -3
- package/src/hooks/post-tool-use.ts +91 -1
- package/src/lib/hook-registry.ts +43 -0
- package/src/lib/memory-path.ts +49 -0
- package/src/security/registry-pubkey.generated.ts +1 -1
- package/src/tool-db-needs.ts +8 -2
- package/src/tools.ts +23 -7
|
@@ -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
|
+
}
|
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,
|
|
@@ -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',
|
|
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
|
-
|
|
1019
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1049
|
-
{ type: 'command', command: hookCmd(
|
|
1050
|
-
{ type: 'command', command: hookCmd(
|
|
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(
|
|
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(
|
|
1060
|
-
{ type: 'command', command: hookCmd(
|
|
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(
|
|
1069
|
-
{ type: 'command', command: hookCmd(
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
1093
|
-
{ type: 'command', command: hookCmd(
|
|
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
|
-
|
|
1119
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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'),
|