@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.
@@ -181,7 +181,17 @@ var AutoLearningConfigSchema = z.object({
181
181
  requireIncidentReport: z.boolean().default(true),
182
182
  requirePreventionRule: z.boolean().default(true),
183
183
  requireEnforcement: z.boolean().default(true)
184
- }).default({})
184
+ }).default({}),
185
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
186
+ // custom destinations for the rule-candidate funnel. The classifier matches
187
+ // a candidate to one of these entries when none of the framework
188
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
189
+ customDestinations: z.array(z.object({
190
+ name: z.string(),
191
+ path: z.string(),
192
+ triggerKeywords: z.array(z.string()).default([]),
193
+ template: z.string()
194
+ })).default([])
185
195
  }).optional();
186
196
  var CloudConfigSchema = z.object({
187
197
  enabled: z.boolean().default(false),
@@ -568,6 +578,66 @@ function getMemoryDb() {
568
578
  initMemorySchema(db);
569
579
  return db;
570
580
  }
581
+ function migrateAuditLogCheckExtension(db) {
582
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
583
+ if (!row) return;
584
+ const expected = [
585
+ "code_change",
586
+ "rule_enforced",
587
+ "approval",
588
+ "review",
589
+ "commit",
590
+ "compaction",
591
+ "rule_candidate_emitted",
592
+ "rule_promoted",
593
+ "rule_dismissed"
594
+ ];
595
+ 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);
596
+ if (checkClauseMatch) {
597
+ const values = (checkClauseMatch[1].match(/'([^']+)'/g) ?? []).map((s) => s.slice(1, -1));
598
+ if (expected.every((v) => values.includes(v))) return;
599
+ }
600
+ db.pragma("foreign_keys = OFF");
601
+ try {
602
+ db.exec("BEGIN TRANSACTION");
603
+ db.exec(`
604
+ CREATE TABLE audit_log_new (
605
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
606
+ session_id TEXT NOT NULL,
607
+ timestamp TEXT DEFAULT (datetime('now')),
608
+ event_type TEXT NOT NULL CHECK(event_type IN (
609
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
610
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
611
+ )),
612
+ actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
613
+ model_id TEXT,
614
+ file_path TEXT,
615
+ change_type TEXT CHECK(change_type IN ('create', 'edit', 'delete')),
616
+ rules_in_effect TEXT,
617
+ approval_status TEXT CHECK(approval_status IN ('auto_approved', 'human_approved', 'pending', 'denied')),
618
+ evidence TEXT,
619
+ metadata TEXT,
620
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
621
+ );
622
+ INSERT INTO audit_log_new SELECT * FROM audit_log;
623
+ DROP TABLE audit_log;
624
+ ALTER TABLE audit_log_new RENAME TO audit_log;
625
+ CREATE INDEX IF NOT EXISTS idx_al_session ON audit_log(session_id);
626
+ CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
627
+ CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
628
+ CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
629
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
630
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
631
+ WHERE event_type = 'rule_promoted';
632
+ `);
633
+ db.exec("COMMIT");
634
+ } catch (err) {
635
+ db.exec("ROLLBACK");
636
+ throw err;
637
+ } finally {
638
+ db.pragma("foreign_keys = ON");
639
+ }
640
+ }
571
641
  function initMemorySchema(db) {
572
642
  db.exec(`
573
643
  -- Sessions table (linked to Claude Code session IDs)
@@ -857,7 +927,10 @@ function initMemorySchema(db) {
857
927
  id INTEGER PRIMARY KEY AUTOINCREMENT,
858
928
  session_id TEXT NOT NULL,
859
929
  timestamp TEXT DEFAULT (datetime('now')),
860
- event_type TEXT NOT NULL CHECK(event_type IN ('code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction')),
930
+ event_type TEXT NOT NULL CHECK(event_type IN (
931
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
932
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
933
+ )),
861
934
  actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
862
935
  model_id TEXT,
863
936
  file_path TEXT,
@@ -872,7 +945,21 @@ function initMemorySchema(db) {
872
945
  CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
873
946
  CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
874
947
  CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
948
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
949
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
950
+ WHERE event_type = 'rule_promoted';
951
+ `);
952
+ db.exec(`
953
+ CREATE TABLE IF NOT EXISTS prompt_outcomes_signal_blacklist (
954
+ signal TEXT PRIMARY KEY,
955
+ dismissal_count INTEGER NOT NULL DEFAULT 0,
956
+ first_dismissed_at TEXT DEFAULT (datetime('now')),
957
+ last_dismissed_at TEXT DEFAULT (datetime('now'))
958
+ );
959
+ CREATE INDEX IF NOT EXISTS idx_psb_count
960
+ ON prompt_outcomes_signal_blacklist(dismissal_count DESC);
875
961
  `);
962
+ migrateAuditLogCheckExtension(db);
876
963
  db.exec(`
877
964
  CREATE TABLE IF NOT EXISTS validation_results (
878
965
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -1878,9 +1965,67 @@ function storeSecurityScore(db, sessionId, filePath, riskScore, findings) {
1878
1965
  }
1879
1966
 
1880
1967
  // src/hooks/post-tool-use.ts
1881
- import { readFileSync as readFileSync6, existsSync as existsSync7, statSync as statSync2 } from "fs";
1968
+ import { readFileSync as readFileSync6, writeFileSync, existsSync as existsSync7, mkdirSync as mkdirSync2, statSync as statSync2 } from "fs";
1882
1969
  import { join as join2 } from "path";
1883
1970
 
1971
+ // src/lib/recurrence-incrementer.ts
1972
+ var ANSI = /\x1b\[[0-9;]*m/g;
1973
+ var FAIL_LINE = /^\s+FAIL:\s*(.*)$/;
1974
+ var FILE_TOKEN = /[\w./-]+\.(?:ts|tsx|js|jsx|sh|md|yaml|yml|json)\b/g;
1975
+ function incrementRecurrenceCountsForScannerFailures(db, sessionId, scannerStdout) {
1976
+ const stripped = scannerStdout.replace(ANSI, "");
1977
+ const lines = stripped.split("\n");
1978
+ const failedFiles = /* @__PURE__ */ new Set();
1979
+ for (const line of lines) {
1980
+ const m = line.match(FAIL_LINE);
1981
+ if (!m) continue;
1982
+ const matches = m[1].match(FILE_TOKEN);
1983
+ if (matches) {
1984
+ for (const f of matches) failedFiles.add(f);
1985
+ }
1986
+ }
1987
+ if (failedFiles.size === 0) return 0;
1988
+ const sessionEdits = db.prepare(`
1989
+ SELECT DISTINCT file_path FROM audit_log
1990
+ WHERE session_id = ?
1991
+ AND event_type = 'code_change'
1992
+ AND change_type IN ('edit', 'create')
1993
+ AND timestamp > datetime('now', '-24 hours')
1994
+ LIMIT 10000
1995
+ `).all(sessionId);
1996
+ const sessionFiles = new Set(
1997
+ sessionEdits.map((r) => r.file_path).filter((p) => Boolean(p))
1998
+ );
1999
+ if (sessionFiles.size === 0) return 0;
2000
+ const intersection = /* @__PURE__ */ new Set();
2001
+ for (const f of failedFiles) {
2002
+ if (sessionFiles.has(f)) {
2003
+ intersection.add(f);
2004
+ } else {
2005
+ for (const sf of sessionFiles) {
2006
+ if (sf.endsWith("/" + f) || f.endsWith("/" + sf)) {
2007
+ intersection.add(sf);
2008
+ break;
2009
+ }
2010
+ }
2011
+ }
2012
+ }
2013
+ if (intersection.size === 0) return 0;
2014
+ const updateStmt = db.prepare(`
2015
+ UPDATE audit_log
2016
+ SET metadata = json_set(metadata, '$.recurrence_count', COALESCE(json_extract(metadata, '$.recurrence_count'), 0) + 1)
2017
+ WHERE event_type = 'rule_promoted'
2018
+ AND file_path = ?
2019
+ AND timestamp > datetime('now', '-7 days')
2020
+ `);
2021
+ let updates = 0;
2022
+ for (const f of intersection) {
2023
+ const r = updateStmt.run(f);
2024
+ updates += r.changes;
2025
+ }
2026
+ return updates;
2027
+ }
2028
+
1884
2029
  // src/memory-file-ingest.ts
1885
2030
  import { readFileSync as readFileSync5, existsSync as existsSync6, readdirSync } from "fs";
1886
2031
  import { parse as parseYaml2 } from "yaml";
@@ -2088,6 +2233,31 @@ async function main() {
2088
2233
  }
2089
2234
  } catch (_knowledgeErr) {
2090
2235
  }
2236
+ try {
2237
+ if (tool_name === "Bash") {
2238
+ const command = tool_input.command ?? "";
2239
+ if (/massu-pattern-scanner\.sh/.test(command)) {
2240
+ const stdout = tool_response ?? "";
2241
+ incrementRecurrenceCountsForScannerFailures(db, session_id, stdout);
2242
+ }
2243
+ }
2244
+ } catch (err) {
2245
+ try {
2246
+ const projectRoot = process.cwd();
2247
+ const dir = join2(projectRoot, ".massu", "rule-candidates");
2248
+ if (!existsSync7(dir)) mkdirSync2(dir, { recursive: true });
2249
+ const logPath = join2(dir, ".cr53-increment-failures.jsonl");
2250
+ const pre = existsSync7(logPath) ? readFileSync6(logPath, "utf-8") : "";
2251
+ const sep = pre && !pre.endsWith("\n") ? "\n" : "";
2252
+ writeFileSync(logPath, pre + sep + JSON.stringify({
2253
+ session_id,
2254
+ scanner_output_excerpt: (tool_response ?? "").slice(0, 200),
2255
+ error: err instanceof Error ? err.message : String(err),
2256
+ timestamp: (/* @__PURE__ */ new Date()).toISOString()
2257
+ }) + "\n", "utf-8");
2258
+ } catch {
2259
+ }
2260
+ }
2091
2261
  } finally {
2092
2262
  db.close();
2093
2263
  }
@@ -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';
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);
869
955
  `);
956
+ migrateAuditLogCheckExtension(db);
870
957
  db.exec(`
871
958
  CREATE TABLE IF NOT EXISTS validation_results (
872
959
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -174,7 +174,17 @@ var AutoLearningConfigSchema = z.object({
174
174
  requireIncidentReport: z.boolean().default(true),
175
175
  requirePreventionRule: z.boolean().default(true),
176
176
  requireEnforcement: z.boolean().default(true)
177
- }).default({})
177
+ }).default({}),
178
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
179
+ // custom destinations for the rule-candidate funnel. The classifier matches
180
+ // a candidate to one of these entries when none of the framework
181
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
182
+ customDestinations: z.array(z.object({
183
+ name: z.string(),
184
+ path: z.string(),
185
+ triggerKeywords: z.array(z.string()).default([]),
186
+ template: z.string()
187
+ })).default([])
178
188
  }).optional();
179
189
  var CloudConfigSchema = z.object({
180
190
  enabled: z.boolean().default(false),
@@ -306,7 +306,17 @@ var AutoLearningConfigSchema = z.object({
306
306
  requireIncidentReport: z.boolean().default(true),
307
307
  requirePreventionRule: z.boolean().default(true),
308
308
  requireEnforcement: z.boolean().default(true)
309
- }).default({})
309
+ }).default({}),
310
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
311
+ // custom destinations for the rule-candidate funnel. The classifier matches
312
+ // a candidate to one of these entries when none of the framework
313
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
314
+ customDestinations: z.array(z.object({
315
+ name: z.string(),
316
+ path: z.string(),
317
+ triggerKeywords: z.array(z.string()).default([]),
318
+ template: z.string()
319
+ })).default([])
310
320
  }).optional();
311
321
  var CloudConfigSchema = z.object({
312
322
  enabled: z.boolean().default(false),
@@ -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';
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);
869
955
  `);
956
+ migrateAuditLogCheckExtension(db);
870
957
  db.exec(`
871
958
  CREATE TABLE IF NOT EXISTS validation_results (
872
959
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -166,7 +166,17 @@ var AutoLearningConfigSchema = z.object({
166
166
  requireIncidentReport: z.boolean().default(true),
167
167
  requirePreventionRule: z.boolean().default(true),
168
168
  requireEnforcement: z.boolean().default(true)
169
- }).default({})
169
+ }).default({}),
170
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
171
+ // custom destinations for the rule-candidate funnel. The classifier matches
172
+ // a candidate to one of these entries when none of the framework
173
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
174
+ customDestinations: z.array(z.object({
175
+ name: z.string(),
176
+ path: z.string(),
177
+ triggerKeywords: z.array(z.string()).default([]),
178
+ template: z.string()
179
+ })).default([])
170
180
  }).optional();
171
181
  var CloudConfigSchema = z.object({
172
182
  enabled: z.boolean().default(false),
@@ -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';
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);
869
955
  `);
956
+ migrateAuditLogCheckExtension(db);
870
957
  db.exec(`
871
958
  CREATE TABLE IF NOT EXISTS validation_results (
872
959
  id INTEGER PRIMARY KEY AUTOINCREMENT,