@massu/core 1.12.2 → 1.13.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.
@@ -8108,7 +8108,17 @@ var AutoLearningConfigSchema = z.object({
8108
8108
  requireIncidentReport: z.boolean().default(true),
8109
8109
  requirePreventionRule: z.boolean().default(true),
8110
8110
  requireEnforcement: z.boolean().default(true)
8111
- }).default({})
8111
+ }).default({}),
8112
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
8113
+ // custom destinations for the rule-candidate funnel. The classifier matches
8114
+ // a candidate to one of these entries when none of the framework
8115
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
8116
+ customDestinations: z.array(z.object({
8117
+ name: z.string(),
8118
+ path: z.string(),
8119
+ triggerKeywords: z.array(z.string()).default([]),
8120
+ template: z.string()
8121
+ })).default([])
8112
8122
  }).optional();
8113
8123
  var CloudConfigSchema = z.object({
8114
8124
  enabled: z.boolean().default(false),
@@ -8501,6 +8511,66 @@ function getMemoryDb() {
8501
8511
  initMemorySchema(db);
8502
8512
  return db;
8503
8513
  }
8514
+ function migrateAuditLogCheckExtension(db) {
8515
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
8516
+ if (!row) return;
8517
+ const expected = [
8518
+ "code_change",
8519
+ "rule_enforced",
8520
+ "approval",
8521
+ "review",
8522
+ "commit",
8523
+ "compaction",
8524
+ "rule_candidate_emitted",
8525
+ "rule_promoted",
8526
+ "rule_dismissed"
8527
+ ];
8528
+ const checkClauseMatch = row.sql.match(/event_type\s+TEXT\s+NOT\s+NULL\s+CHECK\s*\(\s*event_type\s+IN\s*\(([\s\S]*?)\)\s*\)/i);
8529
+ if (checkClauseMatch) {
8530
+ const values = (checkClauseMatch[1].match(/'([^']+)'/g) ?? []).map((s) => s.slice(1, -1));
8531
+ if (expected.every((v) => values.includes(v))) return;
8532
+ }
8533
+ db.pragma("foreign_keys = OFF");
8534
+ try {
8535
+ db.exec("BEGIN TRANSACTION");
8536
+ db.exec(`
8537
+ CREATE TABLE audit_log_new (
8538
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
8539
+ session_id TEXT NOT NULL,
8540
+ timestamp TEXT DEFAULT (datetime('now')),
8541
+ event_type TEXT NOT NULL CHECK(event_type IN (
8542
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
8543
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
8544
+ )),
8545
+ actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
8546
+ model_id TEXT,
8547
+ file_path TEXT,
8548
+ change_type TEXT CHECK(change_type IN ('create', 'edit', 'delete')),
8549
+ rules_in_effect TEXT,
8550
+ approval_status TEXT CHECK(approval_status IN ('auto_approved', 'human_approved', 'pending', 'denied')),
8551
+ evidence TEXT,
8552
+ metadata TEXT,
8553
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
8554
+ );
8555
+ INSERT INTO audit_log_new SELECT * FROM audit_log;
8556
+ DROP TABLE audit_log;
8557
+ ALTER TABLE audit_log_new RENAME TO audit_log;
8558
+ CREATE INDEX IF NOT EXISTS idx_al_session ON audit_log(session_id);
8559
+ CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
8560
+ CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
8561
+ CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
8562
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
8563
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
8564
+ WHERE event_type = 'rule_promoted';
8565
+ `);
8566
+ db.exec("COMMIT");
8567
+ } catch (err) {
8568
+ db.exec("ROLLBACK");
8569
+ throw err;
8570
+ } finally {
8571
+ db.pragma("foreign_keys = ON");
8572
+ }
8573
+ }
8504
8574
  function initMemorySchema(db) {
8505
8575
  db.exec(`
8506
8576
  -- Sessions table (linked to Claude Code session IDs)
@@ -8790,7 +8860,10 @@ function initMemorySchema(db) {
8790
8860
  id INTEGER PRIMARY KEY AUTOINCREMENT,
8791
8861
  session_id TEXT NOT NULL,
8792
8862
  timestamp TEXT DEFAULT (datetime('now')),
8793
- event_type TEXT NOT NULL CHECK(event_type IN ('code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction')),
8863
+ event_type TEXT NOT NULL CHECK(event_type IN (
8864
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
8865
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
8866
+ )),
8794
8867
  actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
8795
8868
  model_id TEXT,
8796
8869
  file_path TEXT,
@@ -8805,7 +8878,21 @@ function initMemorySchema(db) {
8805
8878
  CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
8806
8879
  CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
8807
8880
  CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
8881
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
8882
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
8883
+ WHERE event_type = 'rule_promoted';
8884
+ `);
8885
+ db.exec(`
8886
+ CREATE TABLE IF NOT EXISTS prompt_outcomes_signal_blacklist (
8887
+ signal TEXT PRIMARY KEY,
8888
+ dismissal_count INTEGER NOT NULL DEFAULT 0,
8889
+ first_dismissed_at TEXT DEFAULT (datetime('now')),
8890
+ last_dismissed_at TEXT DEFAULT (datetime('now'))
8891
+ );
8892
+ CREATE INDEX IF NOT EXISTS idx_psb_count
8893
+ ON prompt_outcomes_signal_blacklist(dismissal_count DESC);
8808
8894
  `);
8895
+ migrateAuditLogCheckExtension(db);
8809
8896
  db.exec(`
8810
8897
  CREATE TABLE IF NOT EXISTS validation_results (
8811
8898
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -175,7 +175,17 @@ var AutoLearningConfigSchema = z.object({
175
175
  requireIncidentReport: z.boolean().default(true),
176
176
  requirePreventionRule: z.boolean().default(true),
177
177
  requireEnforcement: z.boolean().default(true)
178
- }).default({})
178
+ }).default({}),
179
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
180
+ // custom destinations for the rule-candidate funnel. The classifier matches
181
+ // a candidate to one of these entries when none of the framework
182
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
183
+ customDestinations: z.array(z.object({
184
+ name: z.string(),
185
+ path: z.string(),
186
+ triggerKeywords: z.array(z.string()).default([]),
187
+ template: z.string()
188
+ })).default([])
179
189
  }).optional();
180
190
  var CloudConfigSchema = z.object({
181
191
  enabled: z.boolean().default(false),
@@ -562,6 +572,66 @@ function getMemoryDb() {
562
572
  initMemorySchema(db);
563
573
  return db;
564
574
  }
575
+ function migrateAuditLogCheckExtension(db) {
576
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
577
+ if (!row) return;
578
+ const expected = [
579
+ "code_change",
580
+ "rule_enforced",
581
+ "approval",
582
+ "review",
583
+ "commit",
584
+ "compaction",
585
+ "rule_candidate_emitted",
586
+ "rule_promoted",
587
+ "rule_dismissed"
588
+ ];
589
+ const checkClauseMatch = row.sql.match(/event_type\s+TEXT\s+NOT\s+NULL\s+CHECK\s*\(\s*event_type\s+IN\s*\(([\s\S]*?)\)\s*\)/i);
590
+ if (checkClauseMatch) {
591
+ const values = (checkClauseMatch[1].match(/'([^']+)'/g) ?? []).map((s) => s.slice(1, -1));
592
+ if (expected.every((v) => values.includes(v))) return;
593
+ }
594
+ db.pragma("foreign_keys = OFF");
595
+ try {
596
+ db.exec("BEGIN TRANSACTION");
597
+ db.exec(`
598
+ CREATE TABLE audit_log_new (
599
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
600
+ session_id TEXT NOT NULL,
601
+ timestamp TEXT DEFAULT (datetime('now')),
602
+ event_type TEXT NOT NULL CHECK(event_type IN (
603
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
604
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
605
+ )),
606
+ actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
607
+ model_id TEXT,
608
+ file_path TEXT,
609
+ change_type TEXT CHECK(change_type IN ('create', 'edit', 'delete')),
610
+ rules_in_effect TEXT,
611
+ approval_status TEXT CHECK(approval_status IN ('auto_approved', 'human_approved', 'pending', 'denied')),
612
+ evidence TEXT,
613
+ metadata TEXT,
614
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
615
+ );
616
+ INSERT INTO audit_log_new SELECT * FROM audit_log;
617
+ DROP TABLE audit_log;
618
+ ALTER TABLE audit_log_new RENAME TO audit_log;
619
+ CREATE INDEX IF NOT EXISTS idx_al_session ON audit_log(session_id);
620
+ CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
621
+ CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
622
+ CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
623
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
624
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
625
+ WHERE event_type = 'rule_promoted';
626
+ `);
627
+ db.exec("COMMIT");
628
+ } catch (err) {
629
+ db.exec("ROLLBACK");
630
+ throw err;
631
+ } finally {
632
+ db.pragma("foreign_keys = ON");
633
+ }
634
+ }
565
635
  function initMemorySchema(db) {
566
636
  db.exec(`
567
637
  -- Sessions table (linked to Claude Code session IDs)
@@ -851,7 +921,10 @@ function initMemorySchema(db) {
851
921
  id INTEGER PRIMARY KEY AUTOINCREMENT,
852
922
  session_id TEXT NOT NULL,
853
923
  timestamp TEXT DEFAULT (datetime('now')),
854
- event_type TEXT NOT NULL CHECK(event_type IN ('code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction')),
924
+ event_type TEXT NOT NULL CHECK(event_type IN (
925
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
926
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
927
+ )),
855
928
  actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
856
929
  model_id TEXT,
857
930
  file_path TEXT,
@@ -866,7 +939,21 @@ function initMemorySchema(db) {
866
939
  CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
867
940
  CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
868
941
  CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
942
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
943
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
944
+ WHERE event_type = 'rule_promoted';
869
945
  `);
946
+ db.exec(`
947
+ CREATE TABLE IF NOT EXISTS prompt_outcomes_signal_blacklist (
948
+ signal TEXT PRIMARY KEY,
949
+ dismissal_count INTEGER NOT NULL DEFAULT 0,
950
+ first_dismissed_at TEXT DEFAULT (datetime('now')),
951
+ last_dismissed_at TEXT DEFAULT (datetime('now'))
952
+ );
953
+ CREATE INDEX IF NOT EXISTS idx_psb_count
954
+ ON prompt_outcomes_signal_blacklist(dismissal_count DESC);
955
+ `);
956
+ migrateAuditLogCheckExtension(db);
870
957
  db.exec(`
871
958
  CREATE TABLE IF NOT EXISTS validation_results (
872
959
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1134,7 +1221,126 @@ function linkSessionToTask(db, sessionId, taskId) {
1134
1221
  }
1135
1222
 
1136
1223
  // src/hooks/user-prompt.ts
1137
- import { existsSync as existsSync3 } from "fs";
1224
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, writeFileSync, readFileSync as readFileSync2, readdirSync, openSync, fstatSync, readSync, closeSync } from "fs";
1225
+ import { join } from "path";
1226
+
1227
+ // src/rule-candidate-detector.ts
1228
+ var CORRECTION_DISMISSAL_PATTERN = /\b(nevermind|never\s+mind|forget\s+it|ignore\s+(that|it)|actually\s+you('?re|\s+were)\s+right|on\s+second\s+thought|no\s+wait|scratch\s+that|disregard|abandon\s+(that|it))\b/i;
1229
+ var RULE_CANDIDATE_THRESHOLD = 60;
1230
+ var STRONG_CORRECTION_PHRASES = [
1231
+ "that's wrong",
1232
+ "thats wrong",
1233
+ "that is wrong",
1234
+ "incorrect",
1235
+ "you broke",
1236
+ "you missed",
1237
+ "this is wrong",
1238
+ "should be",
1239
+ "not what i asked",
1240
+ "not what i wanted"
1241
+ ];
1242
+ var NEGATION_TOKENS = /\b(no|not|never|don't|dont|wrong|incorrect)\b/i;
1243
+ var INSTRUCTION_TOKENS = /\b(instead|use\s+\w+|make\s+it|should\s+be|actually|change\s+\w+)\b/i;
1244
+ var SIGNAL_BASE_WEIGHTS = {
1245
+ strong_correction_phrase: 40,
1246
+ negation_plus_instruction: 30,
1247
+ prior_edit_or_write: 25,
1248
+ bugfix_or_refactor_category: 15,
1249
+ prompt_length_gt_10: 10,
1250
+ running_correction_streak: 15,
1251
+ short_length_floor: -50,
1252
+ slash_command_excluded: -100,
1253
+ dismissal_phrase_excluded: -100
1254
+ };
1255
+ function hasNegationPlusInstructionWithinWindow(prompt, windowWords) {
1256
+ const words = prompt.split(/\s+/);
1257
+ const negIdx = [];
1258
+ const instIdx = [];
1259
+ for (let i = 0; i < words.length; i++) {
1260
+ if (NEGATION_TOKENS.test(words[i])) negIdx.push(i);
1261
+ const slice = words.slice(i, i + 3).join(" ");
1262
+ if (INSTRUCTION_TOKENS.test(slice)) instIdx.push(i);
1263
+ }
1264
+ for (const n of negIdx) {
1265
+ for (const inst of instIdx) {
1266
+ if (Math.abs(n - inst) <= windowWords) return true;
1267
+ }
1268
+ }
1269
+ return false;
1270
+ }
1271
+ function applyDismissal(base, dismissalCount) {
1272
+ if (dismissalCount >= 5) return 0;
1273
+ if (dismissalCount > 0 && base > 0) return Math.max(0, base - dismissalCount * 10);
1274
+ return base;
1275
+ }
1276
+ function scoreCorrectionPrompt(inputs) {
1277
+ const { prompt, priorAssistantTurn, priorOutcomes, category, blacklist } = inputs;
1278
+ const promptLower = prompt.toLowerCase();
1279
+ const trimmed = prompt.trim();
1280
+ const words = trimmed.split(/\s+/).filter(Boolean);
1281
+ const signals = [];
1282
+ const addSignal = (name, evidence) => {
1283
+ const baseWeight = SIGNAL_BASE_WEIGHTS[name];
1284
+ const applied = applyDismissal(baseWeight, blacklist?.get(name) ?? 0);
1285
+ signals.push({ name, baseWeight, applied, evidence });
1286
+ };
1287
+ if (/^\/\w+/.test(trimmed)) {
1288
+ addSignal("slash_command_excluded", "starts with /");
1289
+ return { score: SIGNAL_BASE_WEIGHTS.slash_command_excluded, signals, emitCandidate: false };
1290
+ }
1291
+ if (CORRECTION_DISMISSAL_PATTERN.test(prompt)) {
1292
+ addSignal("dismissal_phrase_excluded", "matched CORRECTION_DISMISSAL_PATTERN");
1293
+ return { score: SIGNAL_BASE_WEIGHTS.dismissal_phrase_excluded, signals, emitCandidate: false };
1294
+ }
1295
+ if (words.length < 4) {
1296
+ addSignal("short_length_floor", `${words.length} words < 4`);
1297
+ }
1298
+ for (const phrase of STRONG_CORRECTION_PHRASES) {
1299
+ if (promptLower.includes(phrase)) {
1300
+ addSignal("strong_correction_phrase", `matched "${phrase}"`);
1301
+ break;
1302
+ }
1303
+ }
1304
+ if (hasNegationPlusInstructionWithinWindow(prompt, 8)) {
1305
+ addSignal("negation_plus_instruction", "negation + instructional token within 8-word window");
1306
+ }
1307
+ if (priorAssistantTurn?.hadEditOrWrite) {
1308
+ addSignal("prior_edit_or_write", "prior assistant turn contained Edit/Write/Bash");
1309
+ }
1310
+ if (category === "bugfix" || category === "refactor") {
1311
+ addSignal("bugfix_or_refactor_category", `category=${category}`);
1312
+ }
1313
+ if (words.length > 10) {
1314
+ addSignal("prompt_length_gt_10", `${words.length} words > 10`);
1315
+ }
1316
+ if (priorOutcomes && priorOutcomes.lastCorrectionsNeeded >= 1) {
1317
+ addSignal("running_correction_streak", `lastCorrectionsNeeded=${priorOutcomes.lastCorrectionsNeeded}`);
1318
+ }
1319
+ const score = signals.reduce((sum, s) => sum + s.applied, 0);
1320
+ return {
1321
+ score,
1322
+ signals,
1323
+ emitCandidate: score >= RULE_CANDIDATE_THRESHOLD
1324
+ };
1325
+ }
1326
+
1327
+ // src/prompt-analyzer.ts
1328
+ import { createHash } from "crypto";
1329
+ function categorizePrompt(promptText) {
1330
+ const lower = promptText.toLowerCase();
1331
+ if (/\b(fix|bug|error|broken|issue|crash|fail)\b/.test(lower)) return "bugfix";
1332
+ if (/\b(refactor|rename|move|extract|cleanup|reorganize)\b/.test(lower)) return "refactor";
1333
+ if (/\b(what|how|why|where|when|explain|describe|tell me)\b/.test(lower)) return "question";
1334
+ if (/^\/\w+/.test(promptText.trim())) return "command";
1335
+ if (/\b(add|create|implement|build|new|feature)\b/.test(lower)) return "feature";
1336
+ return "feature";
1337
+ }
1338
+ function hashPrompt(promptText) {
1339
+ const normalized = promptText.toLowerCase().replace(/\s+/g, " ").trim();
1340
+ return createHash("sha256").update(normalized).digest("hex").slice(0, 16);
1341
+ }
1342
+
1343
+ // src/hooks/user-prompt.ts
1138
1344
  async function main() {
1139
1345
  try {
1140
1346
  const input = await readStdin();
@@ -1167,8 +1373,8 @@ async function main() {
1167
1373
  if (fileRefs.length > 0) {
1168
1374
  const knowledgeDbPath = getResolvedPaths().knowledgeDbPath;
1169
1375
  if (knowledgeDbPath && existsSync3(knowledgeDbPath)) {
1170
- const Database2 = (await import("better-sqlite3")).default;
1171
- const kdb = new Database2(knowledgeDbPath, { readonly: true });
1376
+ const BetterSqlite3Ctor = (await import("better-sqlite3")).default;
1377
+ const kdb = new BetterSqlite3Ctor(knowledgeDbPath, { readonly: true });
1172
1378
  try {
1173
1379
  const placeholders = fileRefs.map(() => "?").join(",");
1174
1380
  const matches = kdb.prepare(
@@ -1207,6 +1413,74 @@ async function main() {
1207
1413
  }
1208
1414
  } catch (_memoryNagErr) {
1209
1415
  }
1416
+ try {
1417
+ const priorTurn = detectPriorAssistantTurn(hookInput.transcript_path);
1418
+ const lastOutcome = db.prepare(
1419
+ "SELECT corrections_needed FROM prompt_outcomes WHERE session_id = ? ORDER BY id DESC LIMIT 1"
1420
+ ).get(session_id);
1421
+ const blacklist = readSignalBlacklist(db);
1422
+ const scoreResult = scoreCorrectionPrompt({
1423
+ prompt,
1424
+ priorAssistantTurn: { hadEditOrWrite: priorTurn.hadEditOrWrite },
1425
+ priorOutcomes: lastOutcome ? { lastCorrectionsNeeded: lastOutcome.corrections_needed } : void 0,
1426
+ category: categorizePrompt(prompt),
1427
+ blacklist
1428
+ });
1429
+ const candidateDir = join(hookInput.cwd, ".massu", "rule-candidates");
1430
+ if (scoreResult.emitCandidate) {
1431
+ mkdirSync2(candidateDir, { recursive: true });
1432
+ const promptHash = hashPrompt(prompt);
1433
+ const candidatePath = join(candidateDir, `${promptHash}.json`);
1434
+ if (!existsSync3(candidatePath)) {
1435
+ writeFileSync(candidatePath, JSON.stringify({
1436
+ prompt,
1437
+ prompt_hash: promptHash,
1438
+ score: scoreResult.score,
1439
+ signals: scoreResult.signals,
1440
+ prior_turn_files: priorTurn.files,
1441
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1442
+ session_id
1443
+ }, null, 2));
1444
+ }
1445
+ }
1446
+ if (existsSync3(candidateDir)) {
1447
+ const candidates = readdirSync(candidateDir).filter(
1448
+ (f) => f.endsWith(".json") && !f.startsWith(".")
1449
+ );
1450
+ const candidateCount = candidates.length;
1451
+ const surfacedPath = join(candidateDir, ".last-surfaced");
1452
+ let lastSurfaced = 0;
1453
+ if (existsSync3(surfacedPath)) {
1454
+ const raw = readFileSync2(surfacedPath, "utf-8").trim();
1455
+ const parsed = parseInt(raw, 10);
1456
+ if (!Number.isNaN(parsed)) lastSurfaced = parsed;
1457
+ }
1458
+ if (candidateCount > lastSurfaced) {
1459
+ process.stderr.write(
1460
+ `
1461
+ [RULE CANDIDATE] ${candidateCount} rule candidate(s) pending (\`/massu-rule list\`)
1462
+
1463
+ `
1464
+ );
1465
+ writeFileSync(surfacedPath, String(candidateCount));
1466
+ }
1467
+ }
1468
+ } catch (candidateErr) {
1469
+ try {
1470
+ const dir = join(hookInput.cwd, ".massu", "rule-candidates");
1471
+ if (!existsSync3(dir)) mkdirSync2(dir, { recursive: true });
1472
+ const logPath = join(dir, ".detector-failures.jsonl");
1473
+ const pre = existsSync3(logPath) ? readFileSync2(logPath, "utf-8") : "";
1474
+ const sep = pre && !pre.endsWith("\n") ? "\n" : "";
1475
+ writeFileSync(logPath, pre + sep + JSON.stringify({
1476
+ session_id,
1477
+ prompt_excerpt: prompt.slice(0, 200),
1478
+ error: candidateErr instanceof Error ? candidateErr.message : String(candidateErr),
1479
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
1480
+ }) + "\n", "utf-8");
1481
+ } catch {
1482
+ }
1483
+ }
1210
1484
  } finally {
1211
1485
  db.close();
1212
1486
  }
@@ -1214,6 +1488,73 @@ async function main() {
1214
1488
  }
1215
1489
  process.exit(0);
1216
1490
  }
1491
+ function detectPriorAssistantTurn(transcriptPath) {
1492
+ try {
1493
+ if (!transcriptPath || !existsSync3(transcriptPath)) {
1494
+ return { hadEditOrWrite: false, files: [] };
1495
+ }
1496
+ if (!transcriptPath.includes("/.claude/projects/")) {
1497
+ return { hadEditOrWrite: false, files: [] };
1498
+ }
1499
+ const fd = openSync(transcriptPath, "r");
1500
+ let buf;
1501
+ try {
1502
+ const stats = fstatSync(fd);
1503
+ const readLen = Math.min(stats.size, 200 * 1024);
1504
+ const offset = stats.size - readLen;
1505
+ buf = Buffer.alloc(readLen);
1506
+ readSync(fd, buf, 0, readLen, offset);
1507
+ } finally {
1508
+ closeSync(fd);
1509
+ }
1510
+ const lines = buf.toString("utf-8").split("\n").filter(Boolean);
1511
+ const files = [];
1512
+ let hadEditOrWrite = false;
1513
+ let foundAssistant = false;
1514
+ const WRITE_BASH = /\b(sed\s+-i|tee\s|printf\s.*?>|cat\s+<<.*?>|>>?\s*['"\w/.-]+)/;
1515
+ for (let i = lines.length - 1; i >= 0; i--) {
1516
+ let entry = null;
1517
+ try {
1518
+ entry = JSON.parse(lines[i]);
1519
+ } catch {
1520
+ continue;
1521
+ }
1522
+ if (!entry || typeof entry !== "object") continue;
1523
+ if (entry.type === "user" && foundAssistant) break;
1524
+ if (entry.type !== "assistant") continue;
1525
+ foundAssistant = true;
1526
+ const content = entry.message?.content;
1527
+ if (!Array.isArray(content)) continue;
1528
+ for (const block of content) {
1529
+ if (!block || typeof block !== "object") continue;
1530
+ const b = block;
1531
+ if (b.type !== "tool_use") continue;
1532
+ const name = String(b.name ?? "");
1533
+ if (name === "Edit" || name === "Write" || name === "NotebookEdit") {
1534
+ hadEditOrWrite = true;
1535
+ const fp = b.input?.file_path;
1536
+ if (typeof fp === "string") files.push(fp);
1537
+ } else if (name === "Bash") {
1538
+ const cmd = String(b.input?.command ?? "");
1539
+ if (WRITE_BASH.test(cmd)) hadEditOrWrite = true;
1540
+ }
1541
+ }
1542
+ }
1543
+ return { hadEditOrWrite, files: [...new Set(files)] };
1544
+ } catch (_e) {
1545
+ return { hadEditOrWrite: false, files: [] };
1546
+ }
1547
+ }
1548
+ function readSignalBlacklist(db) {
1549
+ try {
1550
+ const rows = db.prepare(
1551
+ "SELECT signal, dismissal_count FROM prompt_outcomes_signal_blacklist LIMIT 10000"
1552
+ ).all();
1553
+ return new Map(rows.map((r) => [r.signal, r.dismissal_count]));
1554
+ } catch (_e) {
1555
+ return /* @__PURE__ */ new Map();
1556
+ }
1557
+ }
1217
1558
  function extractFileReferences(prompt) {
1218
1559
  const filePattern = /(?:^|\s)((?:src|packages|lib)\/[\w./-]+\.(?:ts|tsx|js|jsx|md))/g;
1219
1560
  const matches = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.12.2",
3
+ "version": "1.13.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": "./dist/cli.js",