@massu/core 1.9.5 → 1.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli.js +89 -23
- package/dist/hooks/auto-learning-pipeline.js +30 -8
- package/dist/hooks/post-tool-use.js +90 -0
- package/dist/hooks/session-end.js +10 -1
- package/package.json +1 -1
- package/src/cloud-sync.ts +18 -0
- package/src/commands/doctor.ts +9 -14
- package/src/commands/init.ts +16 -0
- package/src/commands/install-commands.ts +10 -0
- package/src/commands/install-hooks.ts +2 -1
- package/src/commands/template-engine.ts +41 -0
- package/src/hooks/auto-learning-pipeline.ts +43 -10
- package/src/hooks/post-tool-use.ts +91 -1
- package/src/lib/hook-registry.ts +43 -0
- package/src/security/registry-pubkey.generated.ts +1 -1
- package/src/tool-db-needs.ts +8 -2
- package/src/tools.ts +23 -7
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
|
-
|
|
28180
|
-
|
|
28181
|
-
|
|
28182
|
-
|
|
28183
|
-
|
|
28184
|
-
|
|
28185
|
-
|
|
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`
|
|
28529
|
-
|
|
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 {
|
|
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
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
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.
|
|
3
|
+
"version": "1.10.0",
|
|
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;
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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
|
+
* 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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
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 {
|
|
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
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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-
|
|
1
|
+
// AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-17T04:53:58.787Z.
|
|
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
|
package/src/tool-db-needs.ts
CHANGED
|
@@ -70,8 +70,14 @@ export const TOOL_DB_NEEDS = {
|
|
|
70
70
|
impact: ['codegraph', 'data'],
|
|
71
71
|
domains: ['codegraph', 'data'],
|
|
72
72
|
|
|
73
|
-
// `trpc_map`
|
|
74
|
-
|
|
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
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
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'));
|