@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.
@@ -2,7 +2,7 @@
2
2
  import{createRequire as __cr}from"module";const require=__cr(import.meta.url);
3
3
 
4
4
  // src/hooks/auto-learning-pipeline.ts
5
- import { execSync } from "child_process";
5
+ import { execFileSync } from "child_process";
6
6
  import { existsSync as existsSync2, readFileSync as readFileSync2, unlinkSync, readdirSync, statSync } from "fs";
7
7
  import { tmpdir } from "os";
8
8
  import { join } from "path";
@@ -490,6 +490,7 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
490
490
  }
491
491
 
492
492
  // src/hooks/auto-learning-pipeline.ts
493
+ var MAX_FULL_DIFF_BYTES = 2 * 1024 * 1024;
493
494
  function getSessionFlagPath(sessionId) {
494
495
  return join(tmpdir(), "massu-auto-learning", `fixes-${sessionId.slice(0, 12)}.jsonl`);
495
496
  }
@@ -516,13 +517,34 @@ async function main() {
516
517
  }
517
518
  let uncommittedFix = false;
518
519
  try {
519
- const diff = execSync("git diff --name-only", { cwd: root, timeout: 3e3, encoding: "utf-8" });
520
- if (diff.trim()) {
521
- const fullDiff = execSync("git diff", { cwd: root, timeout: 5e3, encoding: "utf-8" });
522
- const fixPatterns = (fullDiff.match(/^\+.*(try|except|catch|guard|throw|raise|assert|validate|if.*null|if.*nil|if.*None|if.*undefined)/gm) || []).length;
523
- const removedBroken = (fullDiff.match(/^-.*(bug|broken|crash|wrong|incorrect|typo|fail|error|miss|stale)/gm) || []).length;
524
- if (fixPatterns > 3 || removedBroken > 1) {
525
- uncommittedFix = true;
520
+ const nameOnly = execFileSync("git", ["diff", "--name-only"], {
521
+ cwd: root,
522
+ timeout: 3e3,
523
+ encoding: "utf-8",
524
+ maxBuffer: 1024 * 1024
525
+ });
526
+ if (nameOnly.trim()) {
527
+ const shortstat = execFileSync("git", ["diff", "--shortstat"], {
528
+ cwd: root,
529
+ timeout: 2e3,
530
+ encoding: "utf-8",
531
+ maxBuffer: 64 * 1024
532
+ });
533
+ const insertions = parseInt(shortstat.match(/(\d+) insertion/)?.[1] ?? "0", 10);
534
+ const deletions = parseInt(shortstat.match(/(\d+) deletion/)?.[1] ?? "0", 10);
535
+ const estimatedBytes = (insertions + deletions) * 80;
536
+ if (estimatedBytes <= MAX_FULL_DIFF_BYTES) {
537
+ const fullDiff = execFileSync("git", ["diff"], {
538
+ cwd: root,
539
+ timeout: 5e3,
540
+ encoding: "utf-8",
541
+ maxBuffer: MAX_FULL_DIFF_BYTES
542
+ });
543
+ const fixPatterns = (fullDiff.match(/^\+.*(try|except|catch|guard|throw|raise|assert|validate|if.*null|if.*nil|if.*None|if.*undefined)/gm) || []).length;
544
+ const removedBroken = (fullDiff.match(/^-.*(bug|broken|crash|wrong|incorrect|typo|fail|error|miss|stale)/gm) || []).length;
545
+ if (fixPatterns > 3 || removedBroken > 1) {
546
+ uncommittedFix = true;
547
+ }
526
548
  }
527
549
  }
528
550
  } catch {
@@ -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"),
@@ -1448,6 +1455,32 @@ function trackModification(db, featureKey) {
1448
1455
  `).run(featureKey);
1449
1456
  }
1450
1457
  }
1458
+ function recordTestResult(db, featureKey, passing, failing) {
1459
+ const existing = db.prepare(
1460
+ "SELECT * FROM feature_health WHERE feature_key = ?"
1461
+ ).get(featureKey);
1462
+ const healthScore = calculateHealthScore(passing, failing, 0, (/* @__PURE__ */ new Date()).toISOString(), existing?.last_modified);
1463
+ db.prepare(`
1464
+ INSERT INTO feature_health
1465
+ (feature_key, last_tested, test_coverage_pct, health_score, tests_passing, tests_failing, modifications_since_test)
1466
+ VALUES (?, datetime('now'), ?, ?, ?, ?, 0)
1467
+ ON CONFLICT(feature_key) DO UPDATE SET
1468
+ last_tested = datetime('now'),
1469
+ health_score = ?,
1470
+ tests_passing = ?,
1471
+ tests_failing = ?,
1472
+ modifications_since_test = 0
1473
+ `).run(
1474
+ featureKey,
1475
+ passing > 0 ? passing / (passing + failing) * 100 : 0,
1476
+ healthScore,
1477
+ passing,
1478
+ failing,
1479
+ healthScore,
1480
+ passing,
1481
+ failing
1482
+ );
1483
+ }
1451
1484
 
1452
1485
  // src/import-resolver.ts
1453
1486
  import { readFileSync as readFileSync2, existsSync as existsSync3, statSync } from "fs";
@@ -1952,6 +1985,26 @@ async function main() {
1952
1985
  }
1953
1986
  } catch (_securityErr) {
1954
1987
  }
1988
+ try {
1989
+ if (tool_name === "Bash") {
1990
+ const command = tool_input.command ?? "";
1991
+ if (isTestRunnerCommand(command)) {
1992
+ const counts = parseTestRunOutput(tool_response ?? "");
1993
+ if (counts) {
1994
+ const modifiedFeatures = db.prepare(
1995
+ "SELECT feature_key FROM feature_health WHERE modifications_since_test > 0"
1996
+ ).all();
1997
+ for (const row of modifiedFeatures) {
1998
+ recordTestResult(db, row.feature_key, counts.passing, counts.failing);
1999
+ }
2000
+ if (modifiedFeatures.length === 0) {
2001
+ recordTestResult(db, "_session_test_run", counts.passing, counts.failing);
2002
+ }
2003
+ }
2004
+ }
2005
+ }
2006
+ } catch (_testResultErr) {
2007
+ }
1955
2008
  try {
1956
2009
  if (tool_name === "Edit" || tool_name === "Write") {
1957
2010
  const filePath = tool_input.file_path ?? "";
@@ -2027,6 +2080,50 @@ function updatePlanProgress(db, sessionId, progress) {
2027
2080
  addSummary(db, sessionId, { planProgress: progressMap });
2028
2081
  }
2029
2082
  }
2083
+ function isTestRunnerCommand(command) {
2084
+ const trimmed = command.trim().toLowerCase();
2085
+ const stripped = trimmed.replace(/^cd\s+\S+\s*(&&|;)\s*/, "").replace(/^\(\s*cd\s+\S+\s*(&&|;)\s*/, "");
2086
+ const testRunnerPrefixes = [
2087
+ "npm test",
2088
+ "npm run test",
2089
+ "npx vitest",
2090
+ "npx jest",
2091
+ "vitest",
2092
+ "jest",
2093
+ "pnpm test",
2094
+ "pnpm run test",
2095
+ "yarn test",
2096
+ "pytest",
2097
+ "go test",
2098
+ "cargo test"
2099
+ ];
2100
+ return testRunnerPrefixes.some((prefix) => stripped.startsWith(prefix));
2101
+ }
2102
+ function parseTestRunOutput(output) {
2103
+ const vitestSplit = output.match(/Tests?\s+(?:(\d+)\s+failed\s+\|\s+)?(\d+)\s+passed/);
2104
+ if (vitestSplit) {
2105
+ return {
2106
+ passing: parseInt(vitestSplit[2], 10),
2107
+ failing: vitestSplit[1] ? parseInt(vitestSplit[1], 10) : 0
2108
+ };
2109
+ }
2110
+ const jest = output.match(/Tests?:\s+(?:(\d+)\s+failed,\s+)?(\d+)\s+passed/);
2111
+ if (jest) {
2112
+ return {
2113
+ passing: parseInt(jest[2], 10),
2114
+ failing: jest[1] ? parseInt(jest[1], 10) : 0
2115
+ };
2116
+ }
2117
+ const pytestPassed = output.match(/(\d+)\s+passed/);
2118
+ const pytestFailed = output.match(/(\d+)\s+failed/);
2119
+ if (pytestPassed) {
2120
+ return {
2121
+ passing: parseInt(pytestPassed[1], 10),
2122
+ failing: pytestFailed ? parseInt(pytestFailed[1], 10) : 0
2123
+ };
2124
+ }
2125
+ return null;
2126
+ }
2030
2127
  function readStdin() {
2031
2128
  return new Promise((resolve5) => {
2032
2129
  let data = "";
@@ -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"),
@@ -1411,6 +1418,7 @@ function estimateTokens(text) {
1411
1418
  // src/cloud-sync.ts
1412
1419
  var MAX_RETRIES = 3;
1413
1420
  var RETRY_DELAYS = [1e3, 2e3, 4e3];
1421
+ var DEFAULT_CLOUD_REQUEST_TIMEOUT_MS = 2e3;
1414
1422
  async function syncToCloud(db, payload) {
1415
1423
  const config = getConfig();
1416
1424
  const cloud = config.cloud;
@@ -1436,6 +1444,7 @@ async function syncToCloud(db, payload) {
1436
1444
  filteredPayload.audit = payload.audit;
1437
1445
  }
1438
1446
  let lastError = "";
1447
+ const requestTimeoutMs = cloud.requestTimeoutMs ?? DEFAULT_CLOUD_REQUEST_TIMEOUT_MS;
1439
1448
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
1440
1449
  try {
1441
1450
  const response = await fetch(endpoint, {
@@ -1444,7 +1453,11 @@ async function syncToCloud(db, payload) {
1444
1453
  "Content-Type": "application/json",
1445
1454
  "Authorization": `Bearer ${cloud.apiKey}`
1446
1455
  },
1447
- body: JSON.stringify(filteredPayload)
1456
+ body: JSON.stringify(filteredPayload),
1457
+ // P-H003: bounded request — AbortSignal.timeout fires AbortError when
1458
+ // the request stalls (DNS failure, TCP unreachable, slow server). Cleans
1459
+ // up before hook timeout kills the whole process.
1460
+ signal: AbortSignal.timeout(requestTimeoutMs)
1448
1461
  });
1449
1462
  if (!response.ok) {
1450
1463
  lastError = `HTTP ${response.status}: ${response.statusText}`;
@@ -1469,6 +1482,9 @@ async function syncToCloud(db, payload) {
1469
1482
  };
1470
1483
  } catch (err) {
1471
1484
  lastError = err instanceof Error ? err.message : String(err);
1485
+ if (err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) {
1486
+ break;
1487
+ }
1472
1488
  if (attempt < MAX_RETRIES - 1) {
1473
1489
  await sleep(RETRY_DELAYS[attempt]);
1474
1490
  continue;
@@ -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.3",
3
+ "version": "1.10.0",
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();
package/src/cloud-sync.ts CHANGED
@@ -60,6 +60,12 @@ export interface SyncResult {
60
60
  const MAX_RETRIES = 3;
61
61
  const RETRY_DELAYS = [1000, 2000, 4000]; // exponential backoff
62
62
 
63
+ // P-H003 (plan-stage-c-high-batch): bound each HTTP request so offline
64
+ // customers don't burn the entire Stop-hook 15s budget on a single
65
+ // unreachable endpoint. Default 2_000ms is well under hook timeout while
66
+ // still tolerating typical latency. Override via config.cloud.requestTimeoutMs.
67
+ const DEFAULT_CLOUD_REQUEST_TIMEOUT_MS = 2_000;
68
+
63
69
  /**
64
70
  * Sync data to the cloud endpoint.
65
71
  * Respects config flags for selective sync.
@@ -103,6 +109,8 @@ export async function syncToCloud(
103
109
 
104
110
  // Attempt sync with retry
105
111
  let lastError = '';
112
+ const requestTimeoutMs = (cloud as { requestTimeoutMs?: number }).requestTimeoutMs
113
+ ?? DEFAULT_CLOUD_REQUEST_TIMEOUT_MS;
106
114
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
107
115
  try {
108
116
  const response = await fetch(endpoint, {
@@ -112,6 +120,10 @@ export async function syncToCloud(
112
120
  'Authorization': `Bearer ${cloud.apiKey}`,
113
121
  },
114
122
  body: JSON.stringify(filteredPayload),
123
+ // P-H003: bounded request — AbortSignal.timeout fires AbortError when
124
+ // the request stalls (DNS failure, TCP unreachable, slow server). Cleans
125
+ // up before hook timeout kills the whole process.
126
+ signal: AbortSignal.timeout(requestTimeoutMs),
115
127
  });
116
128
 
117
129
  if (!response.ok) {
@@ -140,6 +152,12 @@ export async function syncToCloud(
140
152
  };
141
153
  } catch (err) {
142
154
  lastError = err instanceof Error ? err.message : String(err);
155
+ // P-H003: AbortError from AbortSignal.timeout means the request stalled
156
+ // (customer offline / DNS failure / unreachable). Don't burn the remaining
157
+ // hook budget retrying; queue for later and bail.
158
+ if (err instanceof Error && (err.name === 'AbortError' || err.name === 'TimeoutError')) {
159
+ break;
160
+ }
143
161
  if (attempt < MAX_RETRIES - 1) {
144
162
  await sleep(RETRY_DELAYS[attempt]);
145
163
  continue;
@@ -8,7 +8,7 @@
8
8
  * 1. massu.config.yaml exists and parses correctly
9
9
  * 2. .mcp.json has massu entry
10
10
  * 3. .claude/settings.local.json has hooks config
11
- * 4. All 11 compiled hook files exist
11
+ * 4. All compiled hook files exist (count sourced from lib/hook-registry.ts SoT)
12
12
  * 5. Knowledge DB exists (.massu/memory.db)
13
13
  * 6. Memory directory exists (~/.claude/projects/.../memory/)
14
14
  * 7. Shell hooks wired in settings.local.json
@@ -24,6 +24,7 @@ import { parse as parseYaml } from 'yaml';
24
24
  import { getConfig, getResolvedPaths } from '../config.ts';
25
25
  import { getCurrentTier, getLicenseInfo, daysUntilExpiry } from '../license.ts';
26
26
  import { readSettingsAtPath } from '../lib/settings-local.ts';
27
+ import { getExpectedHookFiles } from '../lib/hook-registry.ts';
27
28
 
28
29
  const __filename = fileURLToPath(import.meta.url);
29
30
  const __dirname = dirname(__filename);
@@ -42,19 +43,13 @@ interface CheckResult {
42
43
  // Hook Files
43
44
  // ============================================================
44
45
 
45
- const EXPECTED_HOOKS = [
46
- 'session-start.js',
47
- 'session-end.js',
48
- 'post-tool-use.js',
49
- 'user-prompt.js',
50
- 'pre-compact.js',
51
- 'pre-delete-check.js',
52
- 'post-edit-context.js',
53
- 'security-gate.js',
54
- 'cost-tracker.js',
55
- 'quality-event.js',
56
- 'intent-suggester.js',
57
- ];
46
+ // P-H001 (plan-stage-c-high-batch): drift-fix. EXPECTED_HOOKS now consumes
47
+ // the lib/hook-registry.ts SoT; the registry-parity drift-guard test asserts
48
+ // this list matches `src/hooks/*.ts` AND `buildHooksConfig()`. Previously
49
+ // a hand-maintained list of 11 entries silently drifted from the 16 hooks
50
+ // actually registered by installHooks(), letting hook files go missing
51
+ // without doctor noticing.
52
+ const EXPECTED_HOOKS: readonly string[] = getExpectedHookFiles();
58
53
 
59
54
  // ============================================================
60
55
  // Individual Checks