@massu/core 1.9.5 → 1.10.1

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 CHANGED
@@ -1772,6 +1772,13 @@ function renderTemplate(template, vars) {
1772
1772
  throw new TemplateParseError("unclosed `{{` (no matching `}}`)", tokenStart);
1773
1773
  }
1774
1774
  const inner = template.slice(i + 2, closeIdx);
1775
+ if (isJsxPassThrough(inner)) {
1776
+ out.push("{{");
1777
+ out.push(inner);
1778
+ out.push("}}");
1779
+ i = closeIdx + 2;
1780
+ continue;
1781
+ }
1775
1782
  const rendered = renderToken(inner, vars, tokenStart);
1776
1783
  out.push(rendered);
1777
1784
  i = closeIdx + 2;
@@ -1813,6 +1820,9 @@ function findTokenClose(template, start) {
1813
1820
  }
1814
1821
  return -1;
1815
1822
  }
1823
+ function isJsxPassThrough(inner) {
1824
+ return inner.includes("\n");
1825
+ }
1816
1826
  function renderToken(inner, vars, tokenStart) {
1817
1827
  const trimmed = inner.trim();
1818
1828
  if (trimmed === "") {
@@ -2462,7 +2472,17 @@ function buildTemplateVars() {
2462
2472
  framework: config.framework,
2463
2473
  paths: config.paths,
2464
2474
  detected: config.detected ?? {},
2465
- config
2475
+ config,
2476
+ // P-H006 (plan-stage-c-high-batch): RESERVED CLAUDE CODE PLACEHOLDER.
2477
+ // Claude Code reads `{{ARGUMENTS}}` as a runtime placeholder inside
2478
+ // slash-command files. The Massu template engine has no native concept
2479
+ // of reserved literals; we model the placeholder as a variable whose
2480
+ // value IS the literal `{{ARGUMENTS}}` string. Because the engine never
2481
+ // re-renders output, this passes through verbatim. Closes the bug class
2482
+ // where `/massu-article-review`, `/massu-autoresearch`, etc. silently
2483
+ // failed to install because the engine threw MissingVariableError on
2484
+ // their {{ARGUMENTS}} usage.
2485
+ ARGUMENTS: "{{ARGUMENTS}}"
2466
2486
  };
2467
2487
  }
2468
2488
  function installCommands(projectRoot, opts = {}) {
@@ -13226,6 +13246,15 @@ function buildConfigFromDetection(opts) {
13226
13246
  pathsSource = monorepoCommonRoot(detection.monorepo.packages);
13227
13247
  }
13228
13248
  }
13249
+ if (pathsSource === "src" && !existsSync11(resolve7(projectRoot, "src"))) {
13250
+ const fallbacks = ["app", "pages", "."];
13251
+ for (const fallback of fallbacks) {
13252
+ if (fallback === "." || existsSync11(resolve7(projectRoot, fallback))) {
13253
+ pathsSource = fallback;
13254
+ break;
13255
+ }
13256
+ }
13257
+ }
13229
13258
  let monorepoRoots;
13230
13259
  if (detection.monorepo.type !== "single") {
13231
13260
  if (detection.monorepo.packages.length > 0) {
@@ -14447,6 +14476,35 @@ var init_license = __esm({
14447
14476
  }
14448
14477
  });
14449
14478
 
14479
+ // src/lib/hook-registry.ts
14480
+ function getExpectedHookFiles() {
14481
+ return REGISTERED_HOOKS.map((name) => `${name}.js`);
14482
+ }
14483
+ var REGISTERED_HOOKS;
14484
+ var init_hook_registry = __esm({
14485
+ "src/lib/hook-registry.ts"() {
14486
+ "use strict";
14487
+ REGISTERED_HOOKS = [
14488
+ "auto-learning-pipeline",
14489
+ "classify-failure",
14490
+ "cost-tracker",
14491
+ "fix-detector",
14492
+ "incident-pipeline",
14493
+ "intent-suggester",
14494
+ "post-edit-context",
14495
+ "post-tool-use",
14496
+ "pre-compact",
14497
+ "pre-delete-check",
14498
+ "quality-event",
14499
+ "rule-enforcement-pipeline",
14500
+ "security-gate",
14501
+ "session-end",
14502
+ "session-start",
14503
+ "user-prompt"
14504
+ ];
14505
+ }
14506
+ });
14507
+
14450
14508
  // src/commands/doctor.ts
14451
14509
  var doctor_exports = {};
14452
14510
  __export(doctor_exports, {
@@ -14820,21 +14878,10 @@ var init_doctor = __esm({
14820
14878
  init_config();
14821
14879
  init_license();
14822
14880
  init_settings_local();
14881
+ init_hook_registry();
14823
14882
  __filename3 = fileURLToPath3(import.meta.url);
14824
14883
  __dirname3 = dirname7(__filename3);
14825
- EXPECTED_HOOKS = [
14826
- "session-start.js",
14827
- "session-end.js",
14828
- "post-tool-use.js",
14829
- "user-prompt.js",
14830
- "pre-compact.js",
14831
- "pre-delete-check.js",
14832
- "post-edit-context.js",
14833
- "security-gate.js",
14834
- "cost-tracker.js",
14835
- "quality-event.js",
14836
- "intent-suggester.js"
14837
- ];
14884
+ EXPECTED_HOOKS = getExpectedHookFiles();
14838
14885
  }
14839
14886
  });
14840
14887
 
@@ -28176,13 +28223,26 @@ function handleTrpcMap(args2, dataDb) {
28176
28223
  const total = dataDb.prepare("SELECT COUNT(*) as count FROM massu_trpc_procedures").get();
28177
28224
  const coupled = dataDb.prepare("SELECT COUNT(*) as count FROM massu_trpc_procedures WHERE has_ui_caller = 1").get();
28178
28225
  const uncoupled = total.count - coupled.count;
28179
- lines.push("## tRPC Procedure Summary");
28180
- lines.push(`- Total procedures: ${total.count}`);
28181
- lines.push(`- With UI callers: ${coupled.count}`);
28182
- lines.push(`- Without UI callers: ${uncoupled}`);
28183
- lines.push("");
28184
- lines.push('Use { router: "name" } to see details for a specific router.');
28185
- lines.push("Use { uncoupled: true } to see all procedures without UI callers.");
28226
+ if (total.count === 0) {
28227
+ lines.push("## tRPC Index Empty");
28228
+ lines.push("");
28229
+ lines.push("No tRPC procedures indexed yet. Either this repo has no tRPC");
28230
+ lines.push("routers, or the CodeGraph index has not been built. To rebuild:");
28231
+ lines.push("");
28232
+ lines.push(" npx massu sync");
28233
+ lines.push("");
28234
+ lines.push("If `massu sync` completes successfully but `trpc_map` still");
28235
+ lines.push("returns 0, the repo likely has no tRPC procedures (this is");
28236
+ lines.push("expected for non-tRPC stacks \u2014 try `schema` or `domains` tools).");
28237
+ } else {
28238
+ lines.push("## tRPC Procedure Summary");
28239
+ lines.push(`- Total procedures: ${total.count}`);
28240
+ lines.push(`- With UI callers: ${coupled.count}`);
28241
+ lines.push(`- Without UI callers: ${uncoupled}`);
28242
+ lines.push("");
28243
+ lines.push('Use { router: "name" } to see details for a specific router.');
28244
+ lines.push("Use { uncoupled: true } to see all procedures without UI callers.");
28245
+ }
28186
28246
  }
28187
28247
  return text17(lines.join("\n"));
28188
28248
  }
@@ -28525,8 +28585,14 @@ var init_tool_db_needs = __esm({
28525
28585
  coupling_check: ["codegraph", "data"],
28526
28586
  impact: ["codegraph", "data"],
28527
28587
  domains: ["codegraph", "data"],
28528
- // `trpc_map` reads only Data DB (tRPC index lives there); no CodeGraph access.
28529
- trpc_map: ["data"],
28588
+ // P-H009 (plan-stage-c-high-batch): `trpc_map` queries Data DB tables
28589
+ // populated by `ensureIndexes(d, codegraphDb)` — without CodeGraph the
28590
+ // index never builds and the flagship code-intel tool silently returns
28591
+ // "0 procedures" on fresh installs. Declaring `codegraph` here makes the
28592
+ // dispatcher open the CodeGraph DB so `buildTrpcIndex` can run; the
28593
+ // handler also surfaces an actionable hint when no procedures + no
28594
+ // codegraph (covered by `trpc-map-empty-codegraph-hint.test.ts`).
28595
+ trpc_map: ["codegraph", "data"],
28530
28596
  // `schema` reads filesystem (Prisma schema files); no DB access at all.
28531
28597
  schema: [],
28532
28598
  // === Memory tools (memory-tools.ts) ===
@@ -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 {
@@ -1455,6 +1455,32 @@ function trackModification(db, featureKey) {
1455
1455
  `).run(featureKey);
1456
1456
  }
1457
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
+ }
1458
1484
 
1459
1485
  // src/import-resolver.ts
1460
1486
  import { readFileSync as readFileSync2, existsSync as existsSync3, statSync } from "fs";
@@ -1959,6 +1985,26 @@ async function main() {
1959
1985
  }
1960
1986
  } catch (_securityErr) {
1961
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
+ }
1962
2008
  try {
1963
2009
  if (tool_name === "Edit" || tool_name === "Write") {
1964
2010
  const filePath = tool_input.file_path ?? "";
@@ -2034,6 +2080,50 @@ function updatePlanProgress(db, sessionId, progress) {
2034
2080
  addSummary(db, sessionId, { planProgress: progressMap });
2035
2081
  }
2036
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
+ }
2037
2127
  function readStdin() {
2038
2128
  return new Promise((resolve5) => {
2039
2129
  let data = "";
@@ -1418,6 +1418,7 @@ function estimateTokens(text) {
1418
1418
  // src/cloud-sync.ts
1419
1419
  var MAX_RETRIES = 3;
1420
1420
  var RETRY_DELAYS = [1e3, 2e3, 4e3];
1421
+ var DEFAULT_CLOUD_REQUEST_TIMEOUT_MS = 2e3;
1421
1422
  async function syncToCloud(db, payload) {
1422
1423
  const config = getConfig();
1423
1424
  const cloud = config.cloud;
@@ -1443,6 +1444,7 @@ async function syncToCloud(db, payload) {
1443
1444
  filteredPayload.audit = payload.audit;
1444
1445
  }
1445
1446
  let lastError = "";
1447
+ const requestTimeoutMs = cloud.requestTimeoutMs ?? DEFAULT_CLOUD_REQUEST_TIMEOUT_MS;
1446
1448
  for (let attempt = 0; attempt < MAX_RETRIES; attempt++) {
1447
1449
  try {
1448
1450
  const response = await fetch(endpoint, {
@@ -1451,7 +1453,11 @@ async function syncToCloud(db, payload) {
1451
1453
  "Content-Type": "application/json",
1452
1454
  "Authorization": `Bearer ${cloud.apiKey}`
1453
1455
  },
1454
- 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)
1455
1461
  });
1456
1462
  if (!response.ok) {
1457
1463
  lastError = `HTTP ${response.status}: ${response.statusText}`;
@@ -1476,6 +1482,9 @@ async function syncToCloud(db, payload) {
1476
1482
  };
1477
1483
  } catch (err) {
1478
1484
  lastError = err instanceof Error ? err.message : String(err);
1485
+ if (err instanceof Error && (err.name === "AbortError" || err.name === "TimeoutError")) {
1486
+ break;
1487
+ }
1479
1488
  if (attempt < MAX_RETRIES - 1) {
1480
1489
  await sleep(RETRY_DELAYS[attempt]);
1481
1490
  continue;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.9.5",
3
+ "version": "1.10.1",
4
4
  "type": "module",
5
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",
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
@@ -438,6 +438,22 @@ export function buildConfigFromDetection(
438
438
  }
439
439
  }
440
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
+
441
457
  // P1-005: emit `paths.monorepo_roots` as the distinct parent directories of
442
458
  // every workspace package when this is a monorepo. Optional + additive;
443
459
  // v1 consumers ignore it. When detection identified a monorepo type
@@ -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")`.
@@ -16,12 +16,18 @@
16
16
  // the pipeline steps.
17
17
  // ============================================================
18
18
 
19
- import { execSync } from 'child_process';
19
+ import { execFileSync } from 'child_process';
20
20
  import { existsSync, readFileSync, unlinkSync, readdirSync, statSync } from 'fs';
21
21
  import { tmpdir } from 'os';
22
22
  import { join } from 'path';
23
23
  import { getProjectRoot, getConfig } from '../config.ts';
24
24
 
25
+ // P-H002 (plan-stage-c-high-batch): bound git-diff reads so monorepos with
26
+ // 10MB+ working trees don't trigger Stop-hook timeout. Short-stat first,
27
+ // only read full diff body when estimated bytes <= cap. execFileSync argv
28
+ // form is defense-in-depth (P-001 pattern).
29
+ const MAX_FULL_DIFF_BYTES = 2 * 1024 * 1024; // 2MB; ~25k lines at 80 bytes/line
30
+
25
31
  interface HookInput {
26
32
  session_id: string;
27
33
  transcript_path: string;
@@ -67,19 +73,46 @@ async function main(): Promise<void> {
67
73
  } catch { /* ignore parse errors */ }
68
74
  }
69
75
 
70
- // Source 2: Scan uncommitted git diff for fix patterns (language-agnostic)
76
+ // Source 2: Scan uncommitted git diff for fix patterns (language-agnostic).
77
+ // Two-stage: (1) name-only to confirm any changes, (2) shortstat to estimate
78
+ // bytes, (3) full diff body ONLY if estimate <= MAX_FULL_DIFF_BYTES.
71
79
  let uncommittedFix = false;
72
80
  try {
73
- const diff = execSync('git diff --name-only', { cwd: root, timeout: 3000, encoding: 'utf-8' });
74
- if (diff.trim()) {
75
- const fullDiff = execSync('git diff', { cwd: root, timeout: 5000, encoding: 'utf-8' });
76
- const fixPatterns = (fullDiff.match(/^\+.*(try|except|catch|guard|throw|raise|assert|validate|if.*null|if.*nil|if.*None|if.*undefined)/gm) || []).length;
77
- const removedBroken = (fullDiff.match(/^-.*(bug|broken|crash|wrong|incorrect|typo|fail|error|miss|stale)/gm) || []).length;
78
- if (fixPatterns > 3 || removedBroken > 1) {
79
- uncommittedFix = true;
81
+ const nameOnly = execFileSync('git', ['diff', '--name-only'], {
82
+ cwd: root,
83
+ timeout: 3000,
84
+ encoding: 'utf-8',
85
+ maxBuffer: 1024 * 1024,
86
+ });
87
+ if (nameOnly.trim()) {
88
+ const shortstat = execFileSync('git', ['diff', '--shortstat'], {
89
+ cwd: root,
90
+ timeout: 2000,
91
+ encoding: 'utf-8',
92
+ maxBuffer: 64 * 1024,
93
+ });
94
+ const insertions = parseInt(shortstat.match(/(\d+) insertion/)?.[1] ?? '0', 10);
95
+ const deletions = parseInt(shortstat.match(/(\d+) deletion/)?.[1] ?? '0', 10);
96
+ const estimatedBytes = (insertions + deletions) * 80; // ~80 bytes/line avg
97
+ if (estimatedBytes <= MAX_FULL_DIFF_BYTES) {
98
+ const fullDiff = execFileSync('git', ['diff'], {
99
+ cwd: root,
100
+ timeout: 5000,
101
+ encoding: 'utf-8',
102
+ maxBuffer: MAX_FULL_DIFF_BYTES,
103
+ });
104
+ const fixPatterns = (fullDiff.match(/^\+.*(try|except|catch|guard|throw|raise|assert|validate|if.*null|if.*nil|if.*None|if.*undefined)/gm) || []).length;
105
+ const removedBroken = (fullDiff.match(/^-.*(bug|broken|crash|wrong|incorrect|typo|fail|error|miss|stale)/gm) || []).length;
106
+ if (fixPatterns > 3 || removedBroken > 1) {
107
+ uncommittedFix = true;
108
+ }
80
109
  }
110
+ // else: diff exceeds cap — skip pattern scan rather than risk timeout.
111
+ // The fix-detector hook fires on per-Edit/Write and already populates
112
+ // sessionFixes for the realistic case; full-diff fallback is a safety
113
+ // net that we can correctly skip for huge trees.
81
114
  }
82
- } catch { /* git not available or no changes */ }
115
+ } catch { /* git not available or no changes or buffer overflow */ }
83
116
 
84
117
  if (sessionFixes.length === 0 && !uncommittedFix) {
85
118
  // Clean up flag file
@@ -11,7 +11,7 @@
11
11
  import { getMemoryDb, addObservation, createSession, deduplicateFailedAttempt, addSummary } from '../memory-db.ts';
12
12
  import { classifyRealTimeToolCall, detectPlanProgress } from '../observation-extractor.ts';
13
13
  import { logAuditEntry } from '../audit-trail.ts';
14
- import { trackModification } from '../regression-detector.ts';
14
+ import { trackModification, recordTestResult } from '../regression-detector.ts';
15
15
  import { validateFile, storeValidationResult } from '../validation-engine.ts';
16
16
  import { scoreFileSecurity, storeSecurityScore } from '../security-scorer.ts';
17
17
  import { readFileSync, existsSync } from 'fs';
@@ -131,6 +131,42 @@ async function main(): Promise<void> {
131
131
  // Best-effort: never block post-tool-use
132
132
  }
133
133
 
134
+ // P-H029 (plan-stage-c-high-batch): wire recordTestResult() into the
135
+ // post-tool-use hook so `feature_health` dashboard reflects real test
136
+ // deltas. Pre-fix: `trackModification` fired but `recordTestResult` was
137
+ // unit-tested yet never called; dashboard showed tests_passing=0 for
138
+ // every feature.
139
+ //
140
+ // Strategy: when a Bash tool call runs a test runner AND the output
141
+ // includes parseable pass/fail counts, call recordTestResult for every
142
+ // feature with `modifications_since_test > 0`. This resets their counter
143
+ // and updates pass/fail tallies based on the run.
144
+ try {
145
+ if (tool_name === 'Bash') {
146
+ const command = (tool_input.command as string) ?? '';
147
+ if (isTestRunnerCommand(command)) {
148
+ const counts = parseTestRunOutput(tool_response ?? '');
149
+ if (counts) {
150
+ const modifiedFeatures = db
151
+ .prepare(
152
+ 'SELECT feature_key FROM feature_health WHERE modifications_since_test > 0',
153
+ )
154
+ .all() as Array<{ feature_key: string }>;
155
+ for (const row of modifiedFeatures) {
156
+ recordTestResult(db, row.feature_key, counts.passing, counts.failing);
157
+ }
158
+ // Also record a session-level aggregate so the dashboard has at
159
+ // least one row even when no features were modified.
160
+ if (modifiedFeatures.length === 0) {
161
+ recordTestResult(db, '_session_test_run', counts.passing, counts.failing);
162
+ }
163
+ }
164
+ }
165
+ }
166
+ } catch (_testResultErr) {
167
+ // Best-effort: never block post-tool-use
168
+ }
169
+
134
170
  // MEMORY.md integrity check on write
135
171
  try {
136
172
  if (tool_name === 'Edit' || tool_name === 'Write') {
@@ -217,6 +253,60 @@ function updatePlanProgress(db: import('better-sqlite3').Database, sessionId: st
217
253
  }
218
254
  }
219
255
 
256
+ /**
257
+ * P-H029: Detect test-runner commands. Conservative match — only commands that
258
+ * START with a recognized test runner so a build script invoking `npm run test`
259
+ * is included but a script that merely mentions "test" in a filename is not.
260
+ */
261
+ function isTestRunnerCommand(command: string): boolean {
262
+ const trimmed = command.trim().toLowerCase();
263
+ // Strip leading `cd <dir> && ` or `(cd <dir> && ...)` prefix so we match
264
+ // the actual test command.
265
+ const stripped = trimmed
266
+ .replace(/^cd\s+\S+\s*(&&|;)\s*/, '')
267
+ .replace(/^\(\s*cd\s+\S+\s*(&&|;)\s*/, '');
268
+ const testRunnerPrefixes = [
269
+ 'npm test', 'npm run test', 'npx vitest', 'npx jest', 'vitest', 'jest',
270
+ 'pnpm test', 'pnpm run test', 'yarn test', 'pytest', 'go test', 'cargo test',
271
+ ];
272
+ return testRunnerPrefixes.some((prefix) => stripped.startsWith(prefix));
273
+ }
274
+
275
+ /**
276
+ * P-H029: Parse test-run output for pass/fail counts. Supports vitest
277
+ * (`Tests N passed (N)`, `Tests X failed | Y passed (Z)`), jest
278
+ * (`Tests: X failed, Y passed, Z total`), and pytest (`X passed, Y failed`).
279
+ * Returns null if no parseable summary line found.
280
+ */
281
+ function parseTestRunOutput(output: string): { passing: number; failing: number } | null {
282
+ // vitest: " Tests 439 passed (439)" or " Tests 3 failed | 436 passed (439)"
283
+ const vitestSplit = output.match(/Tests?\s+(?:(\d+)\s+failed\s+\|\s+)?(\d+)\s+passed/);
284
+ if (vitestSplit) {
285
+ return {
286
+ passing: parseInt(vitestSplit[2], 10),
287
+ failing: vitestSplit[1] ? parseInt(vitestSplit[1], 10) : 0,
288
+ };
289
+ }
290
+ // jest: "Tests: 1 failed, 5 passed, 6 total"
291
+ const jest = output.match(/Tests?:\s+(?:(\d+)\s+failed,\s+)?(\d+)\s+passed/);
292
+ if (jest) {
293
+ return {
294
+ passing: parseInt(jest[2], 10),
295
+ failing: jest[1] ? parseInt(jest[1], 10) : 0,
296
+ };
297
+ }
298
+ // pytest: "5 passed, 2 failed in 1.23s" or "5 passed in 1.23s"
299
+ const pytestPassed = output.match(/(\d+)\s+passed/);
300
+ const pytestFailed = output.match(/(\d+)\s+failed/);
301
+ if (pytestPassed) {
302
+ return {
303
+ passing: parseInt(pytestPassed[1], 10),
304
+ failing: pytestFailed ? parseInt(pytestFailed[1], 10) : 0,
305
+ };
306
+ }
307
+ return null;
308
+ }
309
+
220
310
  function readStdin(): Promise<string> {
221
311
  return new Promise((resolve) => {
222
312
  let data = '';
@@ -0,0 +1,43 @@
1
+ /**
2
+ * Single source of truth for the canonical Massu hook set.
3
+ *
4
+ * P-H001 (plan-stage-c-high-batch): doctor / installer / build:hooks all
5
+ * consume from here. The drift-guard `hook-registry-parity.test.ts` asserts:
6
+ *
7
+ * 1. REGISTERED_HOOKS matches `src/hooks/*.ts` filenames (the build SoT).
8
+ * 2. REGISTERED_HOOKS matches the hook names referenced in
9
+ * `buildHooksConfig()` (the installer SoT).
10
+ * 3. REGISTERED_HOOKS matches `dist/hooks/*.js` after `npm run build:hooks`
11
+ * (the runtime SoT, when build artifacts are available).
12
+ *
13
+ * Adding a new hook requires touching THREE places: src/hooks/X.ts,
14
+ * REGISTERED_HOOKS, and buildHooksConfig() — the parity tests enforce this.
15
+ */
16
+ export const REGISTERED_HOOKS = [
17
+ 'auto-learning-pipeline',
18
+ 'classify-failure',
19
+ 'cost-tracker',
20
+ 'fix-detector',
21
+ 'incident-pipeline',
22
+ 'intent-suggester',
23
+ 'post-edit-context',
24
+ 'post-tool-use',
25
+ 'pre-compact',
26
+ 'pre-delete-check',
27
+ 'quality-event',
28
+ 'rule-enforcement-pipeline',
29
+ 'security-gate',
30
+ 'session-end',
31
+ 'session-start',
32
+ 'user-prompt',
33
+ ] as const;
34
+
35
+ export type RegisteredHook = (typeof REGISTERED_HOOKS)[number];
36
+
37
+ export function getExpectedHookFiles(): readonly string[] {
38
+ return REGISTERED_HOOKS.map((name) => `${name}.js`);
39
+ }
40
+
41
+ export function getRegisteredHookCount(): number {
42
+ return REGISTERED_HOOKS.length;
43
+ }
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-17T00:41:46.925Z.
1
+ // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-17T05:11:08.118Z.
2
2
  // Source pem: packages/core/security/registry-pubkey.pem
3
3
  // RAW-bytes sha256: 3b6226d036c472e533110d11a7d0cd2773ce1d7d4f1003517d5bd69c5418ed4c
4
4
  // DO NOT EDIT — regenerate via `node scripts/bundle-pubkey.mjs` or
@@ -70,8 +70,14 @@ export const TOOL_DB_NEEDS = {
70
70
  impact: ['codegraph', 'data'],
71
71
  domains: ['codegraph', 'data'],
72
72
 
73
- // `trpc_map` reads only Data DB (tRPC index lives there); no CodeGraph access.
74
- trpc_map: ['data'],
73
+ // P-H009 (plan-stage-c-high-batch): `trpc_map` queries Data DB tables
74
+ // populated by `ensureIndexes(d, codegraphDb)` — without CodeGraph the
75
+ // index never builds and the flagship code-intel tool silently returns
76
+ // "0 procedures" on fresh installs. Declaring `codegraph` here makes the
77
+ // dispatcher open the CodeGraph DB so `buildTrpcIndex` can run; the
78
+ // handler also surfaces an actionable hint when no procedures + no
79
+ // codegraph (covered by `trpc-map-empty-codegraph-hint.test.ts`).
80
+ trpc_map: ['codegraph', 'data'],
75
81
 
76
82
  // `schema` reads filesystem (Prisma schema files); no DB access at all.
77
83
  schema: [],
package/src/tools.ts CHANGED
@@ -858,13 +858,29 @@ function handleTrpcMap(args: Record<string, unknown>, dataDb: Database.Database)
858
858
  const coupled = dataDb.prepare('SELECT COUNT(*) as count FROM massu_trpc_procedures WHERE has_ui_caller = 1').get() as { count: number };
859
859
  const uncoupled = total.count - coupled.count;
860
860
 
861
- lines.push('## tRPC Procedure Summary');
862
- lines.push(`- Total procedures: ${total.count}`);
863
- lines.push(`- With UI callers: ${coupled.count}`);
864
- lines.push(`- Without UI callers: ${uncoupled}`);
865
- lines.push('');
866
- lines.push('Use { router: "name" } to see details for a specific router.');
867
- lines.push('Use { uncoupled: true } to see all procedures without UI callers.');
861
+ // P-H009 (plan-stage-c-high-batch): when the index is empty, return an
862
+ // actionable remedy hint instead of bare "0" — previously fresh installs
863
+ // saw "Total procedures: 0" and assumed the flagship tool was broken.
864
+ if (total.count === 0) {
865
+ lines.push('## tRPC Index Empty');
866
+ lines.push('');
867
+ lines.push('No tRPC procedures indexed yet. Either this repo has no tRPC');
868
+ lines.push('routers, or the CodeGraph index has not been built. To rebuild:');
869
+ lines.push('');
870
+ lines.push(' npx massu sync');
871
+ lines.push('');
872
+ lines.push('If `massu sync` completes successfully but `trpc_map` still');
873
+ lines.push('returns 0, the repo likely has no tRPC procedures (this is');
874
+ lines.push('expected for non-tRPC stacks — try `schema` or `domains` tools).');
875
+ } else {
876
+ lines.push('## tRPC Procedure Summary');
877
+ lines.push(`- Total procedures: ${total.count}`);
878
+ lines.push(`- With UI callers: ${coupled.count}`);
879
+ lines.push(`- Without UI callers: ${uncoupled}`);
880
+ lines.push('');
881
+ lines.push('Use { router: "name" } to see details for a specific router.');
882
+ lines.push('Use { uncoupled: true } to see all procedures without UI callers.');
883
+ }
868
884
  }
869
885
 
870
886
  return text(lines.join('\n'));