@massu/core 1.9.2 → 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.
@@ -12,6 +12,13 @@ import { existsSync, readFileSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+
16
+ // src/lib/memory-path.ts
17
+ function encodeMemoryDirName(projectRoot) {
18
+ return projectRoot.replace(/\//g, "-");
19
+ }
20
+
21
+ // src/config.ts
15
22
  var DomainConfigSchema = z.object({
16
23
  name: z.string().default("Unknown"),
17
24
  routers: z.array(z.string()).default([]),
@@ -516,7 +523,7 @@ function getResolvedPaths() {
516
523
  plansDir: resolve(root, "docs/plans"),
517
524
  docsDir: resolve(root, "docs"),
518
525
  claudeDir: resolve(root, claudeDirName),
519
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
526
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
520
527
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
521
528
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
522
529
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -12,6 +12,13 @@ import { existsSync, readFileSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+
16
+ // src/lib/memory-path.ts
17
+ function encodeMemoryDirName(projectRoot) {
18
+ return projectRoot.replace(/\//g, "-");
19
+ }
20
+
21
+ // src/config.ts
15
22
  var DomainConfigSchema = z.object({
16
23
  name: z.string().default("Unknown"),
17
24
  routers: z.array(z.string()).default([]),
@@ -516,7 +523,7 @@ function getResolvedPaths() {
516
523
  plansDir: resolve(root, "docs/plans"),
517
524
  docsDir: resolve(root, "docs"),
518
525
  claudeDir: resolve(root, claudeDirName),
519
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
526
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
520
527
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
521
528
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
522
529
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -2,7 +2,7 @@
2
2
  import{createRequire as __cr}from"module";const require=__cr(import.meta.url);
3
3
 
4
4
  // src/hooks/fix-detector.ts
5
- import { execSync } from "child_process";
5
+ import { execFileSync } from "child_process";
6
6
  import { existsSync as existsSync2, appendFileSync, mkdirSync, readFileSync as readFileSync2 } from "fs";
7
7
  import { tmpdir } from "os";
8
8
  import { join } from "path";
@@ -565,9 +565,9 @@ async function main() {
565
565
  const root = getProjectRoot();
566
566
  let diff = "";
567
567
  try {
568
- diff = execSync(`git diff -- "${filePath}"`, { cwd: root, timeout: 3e3, encoding: "utf-8" });
568
+ diff = execFileSync("git", ["diff", "--", filePath], { cwd: root, timeout: 3e3, encoding: "utf-8" });
569
569
  if (!diff) {
570
- diff = execSync(`git diff HEAD -- "${filePath}"`, { cwd: root, timeout: 3e3, encoding: "utf-8" });
570
+ diff = execFileSync("git", ["diff", "HEAD", "--", filePath], { cwd: root, timeout: 3e3, encoding: "utf-8" });
571
571
  }
572
572
  } catch {
573
573
  process.exit(0);
@@ -11,6 +11,13 @@ import { existsSync, readFileSync } from "fs";
11
11
  import { homedir } from "os";
12
12
  import { parse as parseYaml } from "yaml";
13
13
  import { z } from "zod";
14
+
15
+ // src/lib/memory-path.ts
16
+ function encodeMemoryDirName(projectRoot) {
17
+ return projectRoot.replace(/\//g, "-");
18
+ }
19
+
20
+ // src/config.ts
14
21
  var DomainConfigSchema = z.object({
15
22
  name: z.string().default("Unknown"),
16
23
  routers: z.array(z.string()).default([]),
@@ -515,7 +522,7 @@ function getResolvedPaths() {
515
522
  plansDir: resolve(root, "docs/plans"),
516
523
  docsDir: resolve(root, "docs"),
517
524
  claudeDir: resolve(root, claudeDirName),
518
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
525
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
519
526
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
520
527
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
521
528
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -10,6 +10,13 @@ import { existsSync, readFileSync } from "fs";
10
10
  import { homedir } from "os";
11
11
  import { parse as parseYaml } from "yaml";
12
12
  import { z } from "zod";
13
+
14
+ // src/lib/memory-path.ts
15
+ function encodeMemoryDirName(projectRoot) {
16
+ return projectRoot.replace(/\//g, "-");
17
+ }
18
+
19
+ // src/config.ts
13
20
  var DomainConfigSchema = z.object({
14
21
  name: z.string().default("Unknown"),
15
22
  routers: z.array(z.string()).default([]),
@@ -514,7 +521,7 @@ function getResolvedPaths() {
514
521
  plansDir: resolve(root, "docs/plans"),
515
522
  docsDir: resolve(root, "docs"),
516
523
  claudeDir: resolve(root, claudeDirName),
517
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
524
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
518
525
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
519
526
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
520
527
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -12,6 +12,13 @@ import { existsSync, readFileSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+
16
+ // src/lib/memory-path.ts
17
+ function encodeMemoryDirName(projectRoot) {
18
+ return projectRoot.replace(/\//g, "-");
19
+ }
20
+
21
+ // src/config.ts
15
22
  var DomainConfigSchema = z.object({
16
23
  name: z.string().default("Unknown"),
17
24
  routers: z.array(z.string()).default([]),
@@ -516,7 +523,7 @@ function getResolvedPaths() {
516
523
  plansDir: resolve(root, "docs/plans"),
517
524
  docsDir: resolve(root, "docs"),
518
525
  claudeDir: resolve(root, claudeDirName),
519
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
526
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
520
527
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
521
528
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
522
529
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -12,6 +12,13 @@ import { existsSync, readFileSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+
16
+ // src/lib/memory-path.ts
17
+ function encodeMemoryDirName(projectRoot) {
18
+ return projectRoot.replace(/\//g, "-");
19
+ }
20
+
21
+ // src/config.ts
15
22
  var DomainConfigSchema = z.object({
16
23
  name: z.string().default("Unknown"),
17
24
  routers: z.array(z.string()).default([]),
@@ -516,7 +523,7 @@ function getResolvedPaths() {
516
523
  plansDir: resolve(root, "docs/plans"),
517
524
  docsDir: resolve(root, "docs"),
518
525
  claudeDir: resolve(root, claudeDirName),
519
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
526
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
520
527
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
521
528
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
522
529
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -11,6 +11,13 @@ import { existsSync, readFileSync } from "fs";
11
11
  import { homedir } from "os";
12
12
  import { parse as parseYaml } from "yaml";
13
13
  import { z } from "zod";
14
+
15
+ // src/lib/memory-path.ts
16
+ function encodeMemoryDirName(projectRoot) {
17
+ return projectRoot.replace(/\//g, "-");
18
+ }
19
+
20
+ // src/config.ts
14
21
  var DomainConfigSchema = z.object({
15
22
  name: z.string().default("Unknown"),
16
23
  routers: z.array(z.string()).default([]),
@@ -515,7 +522,7 @@ function getResolvedPaths() {
515
522
  plansDir: resolve(root, "docs/plans"),
516
523
  docsDir: resolve(root, "docs"),
517
524
  claudeDir: resolve(root, claudeDirName),
518
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
525
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
519
526
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
520
527
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
521
528
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -12,6 +12,13 @@ import { existsSync, readFileSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+
16
+ // src/lib/memory-path.ts
17
+ function encodeMemoryDirName(projectRoot) {
18
+ return projectRoot.replace(/\//g, "-");
19
+ }
20
+
21
+ // src/config.ts
15
22
  var DomainConfigSchema = z.object({
16
23
  name: z.string().default("Unknown"),
17
24
  routers: z.array(z.string()).default([]),
@@ -516,7 +523,7 @@ function getResolvedPaths() {
516
523
  plansDir: resolve(root, "docs/plans"),
517
524
  docsDir: resolve(root, "docs"),
518
525
  claudeDir: resolve(root, claudeDirName),
519
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
526
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
520
527
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
521
528
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
522
529
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -12,6 +12,13 @@ import { existsSync, readFileSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+
16
+ // src/lib/memory-path.ts
17
+ function encodeMemoryDirName(projectRoot) {
18
+ return projectRoot.replace(/\//g, "-");
19
+ }
20
+
21
+ // src/config.ts
15
22
  var DomainConfigSchema = z.object({
16
23
  name: z.string().default("Unknown"),
17
24
  routers: z.array(z.string()).default([]),
@@ -516,7 +523,7 @@ function getResolvedPaths() {
516
523
  plansDir: resolve(root, "docs/plans"),
517
524
  docsDir: resolve(root, "docs"),
518
525
  claudeDir: resolve(root, claudeDirName),
519
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
526
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
520
527
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
521
528
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
522
529
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -5804,6 +5804,13 @@ import { existsSync, readFileSync } from "fs";
5804
5804
  import { homedir } from "os";
5805
5805
  import { parse as parseYaml } from "yaml";
5806
5806
  import { z } from "zod";
5807
+
5808
+ // src/lib/memory-path.ts
5809
+ function encodeMemoryDirName(projectRoot) {
5810
+ return projectRoot.replace(/\//g, "-");
5811
+ }
5812
+
5813
+ // src/config.ts
5807
5814
  var DomainConfigSchema = z.object({
5808
5815
  name: z.string().default("Unknown"),
5809
5816
  routers: z.array(z.string()).default([]),
@@ -6308,7 +6315,7 @@ function getResolvedPaths() {
6308
6315
  plansDir: resolve(root, "docs/plans"),
6309
6316
  docsDir: resolve(root, "docs"),
6310
6317
  claudeDir: resolve(root, claudeDirName),
6311
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
6318
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
6312
6319
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
6313
6320
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
6314
6321
  mcpJsonPath: resolve(root, ".mcp.json"),
@@ -12,6 +12,13 @@ import { existsSync, readFileSync } from "fs";
12
12
  import { homedir } from "os";
13
13
  import { parse as parseYaml } from "yaml";
14
14
  import { z } from "zod";
15
+
16
+ // src/lib/memory-path.ts
17
+ function encodeMemoryDirName(projectRoot) {
18
+ return projectRoot.replace(/\//g, "-");
19
+ }
20
+
21
+ // src/config.ts
15
22
  var DomainConfigSchema = z.object({
16
23
  name: z.string().default("Unknown"),
17
24
  routers: z.array(z.string()).default([]),
@@ -516,7 +523,7 @@ function getResolvedPaths() {
516
523
  plansDir: resolve(root, "docs/plans"),
517
524
  docsDir: resolve(root, "docs"),
518
525
  claudeDir: resolve(root, claudeDirName),
519
- memoryDir: resolve(homedir(), claudeDirName, "projects", root.replace(/\//g, "-"), "memory"),
526
+ memoryDir: resolve(homedir(), claudeDirName, "projects", encodeMemoryDirName(root), "memory"),
520
527
  sessionStatePath: resolve(root, config.conventions?.sessionStatePath ?? `${claudeDirName}/session-state/CURRENT.md`),
521
528
  sessionArchivePath: resolve(root, config.conventions?.sessionArchivePath ?? `${claudeDirName}/session-state/archive`),
522
529
  mcpJsonPath: resolve(root, ".mcp.json"),
package/package.json CHANGED
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.9.2",
3
+ "version": "1.9.5",
4
4
  "type": "module",
5
- "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
5
+ "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 73 total), 59 workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
7
7
  "exports": {
8
8
  ".": "./src/server.ts",
@@ -14,6 +14,7 @@ import { getMemoryDb, createSession, addObservation, addSummary, addUserPrompt,
14
14
  import { parseTranscript, extractUserMessages, getLastAssistantMessage } from './transcript-parser.ts';
15
15
  import { extractObservationsFromEntries } from './observation-extractor.ts';
16
16
  import { getProjectRoot, getConfig } from './config.ts';
17
+ import { encodeMemoryDirName } from './lib/memory-path.ts';
17
18
 
18
19
  /**
19
20
  * Auto-detect the Claude Code project transcript directory.
@@ -23,8 +24,8 @@ function findTranscriptDir(): string {
23
24
  const home = process.env.HOME ?? '~';
24
25
  const projectRoot = getProjectRoot();
25
26
  const claudeDirName = getConfig().conventions?.claudeDirName ?? '.claude';
26
- // Claude Code escapes the path by replacing / with -
27
- const escapedPath = projectRoot.replace(/\//g, '-');
27
+ // Claude Code escapes the path by replacing / with -. Shared helper is SoT.
28
+ const escapedPath = encodeMemoryDirName(projectRoot);
28
29
  const candidate = resolve(home, `${claudeDirName}/projects`, escapedPath);
29
30
  if (existsSync(candidate)) return candidate;
30
31
  // Fallback: scan projects dir for directories matching the project name
package/src/cli.ts CHANGED
@@ -42,6 +42,16 @@ async function main(): Promise<void> {
42
42
  await runInstallHooks();
43
43
  break;
44
44
  }
45
+ case 'hook-runner': {
46
+ // Dynamic hook dispatcher — invoked by Claude Code's hook command lines
47
+ // (settings.json -> `npx -y @massu/core@<version> hook-runner <name>`).
48
+ // Closes P-003 by resolving the hook file at fire-time instead of baking
49
+ // an absolute npx-cache path at install-time.
50
+ const { runHookRunner } = await import('./commands/hook-runner.ts');
51
+ const result = await runHookRunner(args.slice(1));
52
+ process.exit(result.exitCode);
53
+ return;
54
+ }
45
55
  case 'install-commands': {
46
56
  const { runInstallCommands } = await import('./commands/install-commands.ts');
47
57
  await runInstallCommands();
@@ -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
+ }