@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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,50 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [1.13.0] - 2026-05-20
8
+
9
+ **v0.2 Interactive Rule-Approval (`plan-v0.2-interactive-rule-approval`)**. Closes the silent-self-attestation bug class in Massu's auto-learning system. v0.1 (`auto-learning.md:35-46`) was a protocol obligation the model self-enforces at `/massu-loop` end, audited post-hoc by `/massu-learning-audit`. v0.2 adds real-time correction detection (UserPromptSubmit hook, score-based threshold ≥60), side-channel rule-candidate funnel (`.massu/rule-candidates/<sha>.json`), explicit slash command `/massu-rule list|show|approve|dismiss|recurrence`, atomic write through `applyRuleCandidate()` helper with `audit_log` UNIQUE-INDEX idempotency, three-class rubric (`pattern-scanner | claude-md-cr | corrections-md`) + `autoLearning.customDestinations` config extension point. Three-layer structural enforcement: protocol gate (slash command + hook) + idempotency lock (`audit_log` UNIQUE INDEX) + drift-guard vitest (`rule-promotion-effectiveness.test.ts`). New canonical rule CR-53 codifies "auto-learned rules must be effective". Ships as `@massu/core@1.13.0` — new MCP-adjacent capability + new slash command + backwards-compatible `audit_log.event_type` CHECK extension (internal-process table, zero external schema consumers — minor justified per semver §7).
10
+
11
+ ### Added
12
+
13
+ - **CR-53 / VR-INTERACTIVE-LEARNING** — every `audit_log` row with `event_type='rule_promoted'` older than 7 days MUST have `metadata.recurrence_count == 0`. Three-layer enforcement: protocol gate (`/massu-rule approve` inserts `recurrence_count: 0`) + increment hook (`post-tool-use.ts:incrementRecurrenceCountsForScannerFailures` via `lib/recurrence-incrementer.ts`) + drift-guard (`rule-promotion-effectiveness.test.ts` dual-channel: audit_log AND `.cr53-increment-failures.jsonl`). Allowlist via `MASSU_KNOWN_RULE_LIMITATIONS` env-var for documented exceptions (channel-a only; failure-log channel-b cannot be silenced).
14
+ - **`packages/core/src/rule-candidate-detector.ts`** — `scoreCorrectionPrompt()` scoring model with 9 signals + threshold ≥60. Replaces the binary `correctionPatterns` regex from `prompt-analyzer.ts:59` which mis-fired on "no problem" / "no thanks". Calibration: precision=1.000, recall=0.844 on 32 labeled positives + 122 negatives.
15
+ - **`.claude/commands/massu-rule.md`** + paired `website/content/docs/commands/massu-rule.mdx` — slash command with `list | show <id> | approve <id> | dismiss <id> | recurrence` subcommands. Show-before-approve enforced via `.shown-this-session.jsonl` (single-keystroke read-then-act; no `--force` flag — CR-46 #3).
16
+ - **`packages/core/src/rule-candidate-applier.ts`** — atomic-write applier with snapshot-set rollback. Single authoring surface for all 4 destinations (pattern-scanner, claude-md-cr, corrections-md, custom-destination). UNIQUE INDEX `idx_audit_rule_promoted` on `(event_type, json_extract(metadata, '$.prompt_hash'))` is the §5 idempotency lock — second approve of same prompt_hash is `idempotent_noop`. `claude-md-cr` destination splices the Canonical Rules table row AND appends body section (CR-46 #3 — no piecemeal escape hatch).
17
+ - **`packages/core/src/rule-classifier.ts`** — 4-rubric destination dispatch: (1) literal-grepable token → pattern-scanner, (2) process/protocol wording → claude-md-cr, (3) `custom-destination` from `autoLearning.customDestinations` config triggerKeywords, (4) corrections-md catchall.
18
+ - **`packages/core/src/template-renderer.ts`** — RCE-safe template engine for custom-destination templates. Regex-substitution only, allowlist set `{date, slug, score, signals_csv, prompt_preview, destination_name}`. `${process.exit(1)}` fails identifier-shape; `${process}` fails allowlist. 10 explicit RCE-attempt test cases.
19
+ - **`packages/core/src/rule-candidate-renderer.ts`** — six-section preview renderer for `/massu-rule show <id>` (detected correction + reacting-to + proposed rule + destination + enforcement + conflicts).
20
+ - **`packages/core/src/rule-promotion-effectiveness.ts`** + `__tests__/rule-promotion-effectiveness.test.ts` — CR-53 dual-channel evaluator consumed by both the drift-guard vitest test and `/massu-learning-audit` Section 6.
21
+ - **`packages/core/src/lib/recurrence-incrementer.ts`** — extracted from `hooks/post-tool-use.ts` (ARCH-04 fix; breaks test-infra ↔ esbuild-entry coupling). Tight 1-line FAIL scope per scanner violation (ARCH-05).
22
+ - **SQLite schema additions** in `memory-db.ts`: `audit_log` CHECK constraint extended 6 → 9 event_type values (`rule_candidate_emitted`, `rule_promoted`, `rule_dismissed`) via canonical 12-step recreate procedure. `prompt_outcomes_signal_blacklist` table for dismissal-loop signal downweighting. UNIQUE INDEX `idx_audit_rule_promoted` (partial — only on `rule_promoted` rows). Migration helper `migrateAuditLogCheckExtension()` is idempotent via CHECK-clause-shape parse with full event_type set verification (ARCH-06).
23
+ - **`AuditEntry.eventType` union** in `audit-trail.ts` extended 6 → 9 in lockstep with SQL CHECK.
24
+ - **`scripts/massu-pattern-scanner.sh` Check 29** + `pattern-scanner-check-29.test.ts` drift-guard — auto-generated scanner additions must carry `# auto-generated by /massu-rule approve <hash> (slug=<slug>)` marker followed by `# Check N:` within 4 lines.
25
+ - **`.claude/commands/massu-learning-audit.md` Section 6** — Interactive Rule-Candidate Funnel with 6 health metrics + source-tag split + VR-INTERACTIVE-LEARNING.
26
+ - **15 new vitest test files** in `packages/core/src/__tests__/`: `rule-candidate-detector` (49 tests), `rule-candidate-detector-calibration` (5), `rule-candidate-renderer` (9), `rule-candidate-applier` (10), `rule-candidate-applier-extended` (9), `rule-candidate-applier-arch-fixes` (11), `rule-classifier` (22), `template-renderer` (21), `cr53-recurrence-increment` (7), `rule-promotion-effectiveness` (10), `audit-log-event-type-migration` (11), `pattern-scanner-check-29` (3), `auto-learning-source-tag` (3), `auto-learning-mirror-drift-guard` (4). 175 new tests total.
27
+ - **`autoLearning.customDestinations`** schema in `packages/core/src/config.ts` + `massu.config.yaml` default block. Massu framework remains generalized — project-specific destinations are config-driven, NEVER framework-baked.
28
+ - **v0.1 protocol amendment** in `.claude/commands/massu-loop/references/auto-learning.md` (+ byte-equivalent mirror at `packages/core/commands/...` enforced by `auto-learning-mirror-drift-guard.test.ts`): before writing to `corrections.md`, resolve MEMORY.md path via `encodeMemoryDirName(getProjectRoot())` and check for an existing `prompt_hash:` line — idempotent across v0.1 + v0.2 paths.
29
+ - **Follow-up plan** `docs/plans/2026-05-20-loop-controller-mirror-drift-closure.md` filed to close the preexisting 78-line drift in `loop-controller.md` mirror (deliberately out of scope for v0.2 per CR-46 #4).
30
+
31
+ ### Changed
32
+
33
+ - **`packages/core/src/hooks/user-prompt.ts:115`** — wires `scoreCorrectionPrompt()` into the UserPromptSubmit hook. On `emitCandidate=true`, writes `.massu/rule-candidates/<prompt_hash>.json` (sha-keyed → idempotent on retry). Stderr nudge fires when candidate count grows since last-surfaced. Detector-swallow writes `.detector-failures.jsonl` for parallel observability symmetry with the CR-53 increment hook (ARCH-07). Transcript path bound to `/.claude/projects/` prefix (SEC-05).
34
+ - **`packages/core/src/hooks/post-tool-use.ts`** — calls `incrementRecurrenceCountsForScannerFailures` after `Bash(massu-pattern-scanner.sh)` invocations. Failure-surfaced try/catch writes to `.cr53-increment-failures.jsonl` so silent in-hook failures still surface in CI.
35
+ - **`DEFAULT_ABANDON_PATTERNS`** in `prompt-analyzer.ts:22` — `export`ed so the detector can reference it (used as a comparison constant for the dedicated `CORRECTION_DISMISSAL_PATTERN` in the detector). The detector itself uses a TIGHTER regex (excludes "instead" and "different" which are routine CORRECTION tokens).
36
+ - **`scripts/massu-pattern-scanner.sh`** — added Check 29 at the end (29 checks total, up from 28).
37
+ - **`.claude/CLAUDE.md`** — CR-53 row in Canonical Rules table + body section + VR-INTERACTIVE-LEARNING row in Verification Requirements table.
38
+ - **`packages/core/src/__tests__/codebase-introspector.test.ts`** — perf test now uses explicit 15s timeout + 2 retries to absorb parallel-suite measurement noise (pre-existing flake surfaced during this release's gate run; assertion stays strict at <2000ms).
39
+ - **`website/src/__tests__/changelog-parse.test.ts:EXPECTED_COUNT`** bumped 41 → 42.
40
+
41
+ ### Fixed
42
+
43
+ Phase 1.5 multi-perspective review surfaced 5 security + 7 architecture findings; all fixed within the same plan (CR-45 zero-tolerance).
44
+
45
+ - **SEC-01 path traversal** — `CANDIDATE_ID_PATTERN = /^[a-f0-9]{16}$/` validates candidateId at every I/O boundary in `applyRuleCandidate`, `readCandidate`, `dismissRuleCandidate`. Closes the `<id>` like `../../../tmp/foo` arbitrary-file-deletion class.
46
+ - **SEC-02 symlink bypass** — `realpathSync` containment check on `customDestination.path` rejects symlinks that point outside `projectRoot` even when the lexical path is contained.
47
+ - **SEC-03 slug shell injection** — `deriveSlug` is always called regardless of caller-supplied source; `SLUG_ALLOWED` regex re-validates the result. Caller `opts.slug` is a hint, never authority.
48
+ - **SEC-04 candidate payload tampering** — `validateCandidatePayload` runtime checks `prompt_hash` shape, `score` finite-range, `signals` array structure, required string fields. Planted/corrupted sidecars throw `CandidatePayloadValidationError` before any audit_log write.
49
+ - **ARCH-02 dismiss path** — `dismissRuleCandidate()` writes the prompt_outcomes_signal_blacklist UPSERT + audit_log row + `.dismissed.jsonl` + deletes the candidate sidecar in one SQLite transaction. Closes the "documented end-to-end but the write path was unimplemented" structural drift class.
50
+
7
51
  ## [1.12.2] - 2026-05-18
8
52
 
9
53
  **Security MEDIUM sweep (`plan-2026-05-18-security-medium-sweep`)**. Closes the 5 MEDIUM findings from the post-1.12.0 `massu-security-reviewer` audit-loop with structural drift-guards for each. M-1 IP_HASH_PEPPER consistency (silent-fallback vs throw class), M-2 webhook POST schema symmetry, M-3 Lemon Squeezy webhook idempotency parity with Stripe, M-4 v1 audit UUID echo redaction, M-5 SSO IdP host allowlist (precondition for SSO re-enable). New CR-51 + VR-PEPPER-GUARD + pattern-scanner Check 27.
@@ -43,7 +43,7 @@ After Loop Completes (Zero Gaps):
43
43
  - **Update pattern scanner**: Add new grep-able bad patterns to `scripts/pattern-scanner.sh`
44
44
  - **Codebase-wide search**: Verify no other instances of same bad pattern (CR-9)
45
45
  - **Consider new CR rule**: If a class of bug was found (not one-off), propose for CLAUDE.md
46
- - **Record user corrections**: If the user corrected any behavior during this loop, add structured entry to `memory/corrections.md` with date, wrong behavior, correction, and prevention rule
46
+ - **Record user corrections**: If the user corrected any behavior during this loop, add structured entry to `memory/corrections.md` with date, wrong behavior, correction, and prevention rule. Before writing, resolve the MEMORY.md path via `encodeMemoryDirName(getProjectRoot())` (see `packages/core/src/lib/memory-path.ts:37`) and check that file for an existing `prompt_hash:` line matching this correction; if present, the entry was captured interactively (v0.2 — plan `plan-v0.2-interactive-rule-approval`) and this step is a no-op (CR-53 effectiveness invariant — see `.claude/CLAUDE.md`).
47
47
 
48
48
  **A loop that fixes 5 bugs but records 0 learnings is 80% wasted. The fixes are temporary; the learnings are permanent.**
49
49
  **A reflection that isn't persisted to memory is a learning that will be lost. Text output is not persistence.**
package/dist/cli.js CHANGED
@@ -377,7 +377,17 @@ var init_config = __esm({
377
377
  requireIncidentReport: z.boolean().default(true),
378
378
  requirePreventionRule: z.boolean().default(true),
379
379
  requireEnforcement: z.boolean().default(true)
380
- }).default({})
380
+ }).default({}),
381
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
382
+ // custom destinations for the rule-candidate funnel. The classifier matches
383
+ // a candidate to one of these entries when none of the framework
384
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
385
+ customDestinations: z.array(z.object({
386
+ name: z.string(),
387
+ path: z.string(),
388
+ triggerKeywords: z.array(z.string()).default([]),
389
+ template: z.string()
390
+ })).default([])
381
391
  }).optional();
382
392
  CloudConfigSchema = z.object({
383
393
  enabled: z.boolean().default(false),
@@ -631,6 +641,7 @@ __export(memory_db_exports, {
631
641
  incrementRetryCount: () => incrementRetryCount,
632
642
  initMemorySchema: () => initMemorySchema,
633
643
  linkSessionToTask: () => linkSessionToTask,
644
+ migrateAuditLogCheckExtension: () => migrateAuditLogCheckExtension,
634
645
  pruneOldConversationTurns: () => pruneOldConversationTurns,
635
646
  pruneOldObservations: () => pruneOldObservations,
636
647
  pruneToolCostEvents: () => pruneToolCostEvents,
@@ -668,6 +679,66 @@ function pruneToolCostEvents(db) {
668
679
  ).run(TOOL_COST_EVENTS_RETENTION_DAYS);
669
680
  return result.changes;
670
681
  }
682
+ function migrateAuditLogCheckExtension(db) {
683
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
684
+ if (!row) return;
685
+ const expected = [
686
+ "code_change",
687
+ "rule_enforced",
688
+ "approval",
689
+ "review",
690
+ "commit",
691
+ "compaction",
692
+ "rule_candidate_emitted",
693
+ "rule_promoted",
694
+ "rule_dismissed"
695
+ ];
696
+ 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);
697
+ if (checkClauseMatch) {
698
+ const values = (checkClauseMatch[1].match(/'([^']+)'/g) ?? []).map((s) => s.slice(1, -1));
699
+ if (expected.every((v3) => values.includes(v3))) return;
700
+ }
701
+ db.pragma("foreign_keys = OFF");
702
+ try {
703
+ db.exec("BEGIN TRANSACTION");
704
+ db.exec(`
705
+ CREATE TABLE audit_log_new (
706
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
707
+ session_id TEXT NOT NULL,
708
+ timestamp TEXT DEFAULT (datetime('now')),
709
+ event_type TEXT NOT NULL CHECK(event_type IN (
710
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
711
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
712
+ )),
713
+ actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
714
+ model_id TEXT,
715
+ file_path TEXT,
716
+ change_type TEXT CHECK(change_type IN ('create', 'edit', 'delete')),
717
+ rules_in_effect TEXT,
718
+ approval_status TEXT CHECK(approval_status IN ('auto_approved', 'human_approved', 'pending', 'denied')),
719
+ evidence TEXT,
720
+ metadata TEXT,
721
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
722
+ );
723
+ INSERT INTO audit_log_new SELECT * FROM audit_log;
724
+ DROP TABLE audit_log;
725
+ ALTER TABLE audit_log_new RENAME TO audit_log;
726
+ CREATE INDEX IF NOT EXISTS idx_al_session ON audit_log(session_id);
727
+ CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
728
+ CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
729
+ CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
730
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
731
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
732
+ WHERE event_type = 'rule_promoted';
733
+ `);
734
+ db.exec("COMMIT");
735
+ } catch (err) {
736
+ db.exec("ROLLBACK");
737
+ throw err;
738
+ } finally {
739
+ db.pragma("foreign_keys = ON");
740
+ }
741
+ }
671
742
  function initMemorySchema(db) {
672
743
  db.exec(`
673
744
  -- Sessions table (linked to Claude Code session IDs)
@@ -957,7 +1028,10 @@ function initMemorySchema(db) {
957
1028
  id INTEGER PRIMARY KEY AUTOINCREMENT,
958
1029
  session_id TEXT NOT NULL,
959
1030
  timestamp TEXT DEFAULT (datetime('now')),
960
- event_type TEXT NOT NULL CHECK(event_type IN ('code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction')),
1031
+ event_type TEXT NOT NULL CHECK(event_type IN (
1032
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
1033
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
1034
+ )),
961
1035
  actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
962
1036
  model_id TEXT,
963
1037
  file_path TEXT,
@@ -972,7 +1046,21 @@ function initMemorySchema(db) {
972
1046
  CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
973
1047
  CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
974
1048
  CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
1049
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
1050
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
1051
+ WHERE event_type = 'rule_promoted';
1052
+ `);
1053
+ db.exec(`
1054
+ CREATE TABLE IF NOT EXISTS prompt_outcomes_signal_blacklist (
1055
+ signal TEXT PRIMARY KEY,
1056
+ dismissal_count INTEGER NOT NULL DEFAULT 0,
1057
+ first_dismissed_at TEXT DEFAULT (datetime('now')),
1058
+ last_dismissed_at TEXT DEFAULT (datetime('now'))
1059
+ );
1060
+ CREATE INDEX IF NOT EXISTS idx_psb_count
1061
+ ON prompt_outcomes_signal_blacklist(dismissal_count DESC);
975
1062
  `);
1063
+ migrateAuditLogCheckExtension(db);
976
1064
  db.exec(`
977
1065
  CREATE TABLE IF NOT EXISTS validation_results (
978
1066
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -168,7 +168,17 @@ var AutoLearningConfigSchema = z.object({
168
168
  requireIncidentReport: z.boolean().default(true),
169
169
  requirePreventionRule: z.boolean().default(true),
170
170
  requireEnforcement: z.boolean().default(true)
171
- }).default({})
171
+ }).default({}),
172
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
173
+ // custom destinations for the rule-candidate funnel. The classifier matches
174
+ // a candidate to one of these entries when none of the framework
175
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
176
+ customDestinations: z.array(z.object({
177
+ name: z.string(),
178
+ path: z.string(),
179
+ triggerKeywords: z.array(z.string()).default([]),
180
+ template: z.string()
181
+ })).default([])
172
182
  }).optional();
173
183
  var CloudConfigSchema = z.object({
174
184
  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),
@@ -565,6 +575,66 @@ function getMemoryDb() {
565
575
  initMemorySchema(db);
566
576
  return db;
567
577
  }
578
+ function migrateAuditLogCheckExtension(db) {
579
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
580
+ if (!row) return;
581
+ const expected = [
582
+ "code_change",
583
+ "rule_enforced",
584
+ "approval",
585
+ "review",
586
+ "commit",
587
+ "compaction",
588
+ "rule_candidate_emitted",
589
+ "rule_promoted",
590
+ "rule_dismissed"
591
+ ];
592
+ 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);
593
+ if (checkClauseMatch) {
594
+ const values = (checkClauseMatch[1].match(/'([^']+)'/g) ?? []).map((s) => s.slice(1, -1));
595
+ if (expected.every((v) => values.includes(v))) return;
596
+ }
597
+ db.pragma("foreign_keys = OFF");
598
+ try {
599
+ db.exec("BEGIN TRANSACTION");
600
+ db.exec(`
601
+ CREATE TABLE audit_log_new (
602
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
603
+ session_id TEXT NOT NULL,
604
+ timestamp TEXT DEFAULT (datetime('now')),
605
+ event_type TEXT NOT NULL CHECK(event_type IN (
606
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
607
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
608
+ )),
609
+ actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
610
+ model_id TEXT,
611
+ file_path TEXT,
612
+ change_type TEXT CHECK(change_type IN ('create', 'edit', 'delete')),
613
+ rules_in_effect TEXT,
614
+ approval_status TEXT CHECK(approval_status IN ('auto_approved', 'human_approved', 'pending', 'denied')),
615
+ evidence TEXT,
616
+ metadata TEXT,
617
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
618
+ );
619
+ INSERT INTO audit_log_new SELECT * FROM audit_log;
620
+ DROP TABLE audit_log;
621
+ ALTER TABLE audit_log_new RENAME TO audit_log;
622
+ CREATE INDEX IF NOT EXISTS idx_al_session ON audit_log(session_id);
623
+ CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
624
+ CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
625
+ CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
626
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
627
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
628
+ WHERE event_type = 'rule_promoted';
629
+ `);
630
+ db.exec("COMMIT");
631
+ } catch (err) {
632
+ db.exec("ROLLBACK");
633
+ throw err;
634
+ } finally {
635
+ db.pragma("foreign_keys = ON");
636
+ }
637
+ }
568
638
  function initMemorySchema(db) {
569
639
  db.exec(`
570
640
  -- Sessions table (linked to Claude Code session IDs)
@@ -854,7 +924,10 @@ function initMemorySchema(db) {
854
924
  id INTEGER PRIMARY KEY AUTOINCREMENT,
855
925
  session_id TEXT NOT NULL,
856
926
  timestamp TEXT DEFAULT (datetime('now')),
857
- event_type TEXT NOT NULL CHECK(event_type IN ('code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction')),
927
+ event_type TEXT NOT NULL CHECK(event_type IN (
928
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
929
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
930
+ )),
858
931
  actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
859
932
  model_id TEXT,
860
933
  file_path TEXT,
@@ -869,7 +942,21 @@ function initMemorySchema(db) {
869
942
  CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
870
943
  CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
871
944
  CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
945
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
946
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
947
+ WHERE event_type = 'rule_promoted';
948
+ `);
949
+ db.exec(`
950
+ CREATE TABLE IF NOT EXISTS prompt_outcomes_signal_blacklist (
951
+ signal TEXT PRIMARY KEY,
952
+ dismissal_count INTEGER NOT NULL DEFAULT 0,
953
+ first_dismissed_at TEXT DEFAULT (datetime('now')),
954
+ last_dismissed_at TEXT DEFAULT (datetime('now'))
955
+ );
956
+ CREATE INDEX IF NOT EXISTS idx_psb_count
957
+ ON prompt_outcomes_signal_blacklist(dismissal_count DESC);
872
958
  `);
959
+ migrateAuditLogCheckExtension(db);
873
960
  db.exec(`
874
961
  CREATE TABLE IF NOT EXISTS validation_results (
875
962
  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';
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),
@@ -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),
@@ -564,6 +574,66 @@ function getMemoryDb() {
564
574
  initMemorySchema(db);
565
575
  return db;
566
576
  }
577
+ function migrateAuditLogCheckExtension(db) {
578
+ const row = db.prepare("SELECT sql FROM sqlite_master WHERE type='table' AND name='audit_log'").get();
579
+ if (!row) return;
580
+ const expected = [
581
+ "code_change",
582
+ "rule_enforced",
583
+ "approval",
584
+ "review",
585
+ "commit",
586
+ "compaction",
587
+ "rule_candidate_emitted",
588
+ "rule_promoted",
589
+ "rule_dismissed"
590
+ ];
591
+ 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);
592
+ if (checkClauseMatch) {
593
+ const values = (checkClauseMatch[1].match(/'([^']+)'/g) ?? []).map((s) => s.slice(1, -1));
594
+ if (expected.every((v) => values.includes(v))) return;
595
+ }
596
+ db.pragma("foreign_keys = OFF");
597
+ try {
598
+ db.exec("BEGIN TRANSACTION");
599
+ db.exec(`
600
+ CREATE TABLE audit_log_new (
601
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
602
+ session_id TEXT NOT NULL,
603
+ timestamp TEXT DEFAULT (datetime('now')),
604
+ event_type TEXT NOT NULL CHECK(event_type IN (
605
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
606
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
607
+ )),
608
+ actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
609
+ model_id TEXT,
610
+ file_path TEXT,
611
+ change_type TEXT CHECK(change_type IN ('create', 'edit', 'delete')),
612
+ rules_in_effect TEXT,
613
+ approval_status TEXT CHECK(approval_status IN ('auto_approved', 'human_approved', 'pending', 'denied')),
614
+ evidence TEXT,
615
+ metadata TEXT,
616
+ FOREIGN KEY (session_id) REFERENCES sessions(session_id) ON DELETE CASCADE
617
+ );
618
+ INSERT INTO audit_log_new SELECT * FROM audit_log;
619
+ DROP TABLE audit_log;
620
+ ALTER TABLE audit_log_new RENAME TO audit_log;
621
+ CREATE INDEX IF NOT EXISTS idx_al_session ON audit_log(session_id);
622
+ CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
623
+ CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
624
+ CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
625
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
626
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
627
+ WHERE event_type = 'rule_promoted';
628
+ `);
629
+ db.exec("COMMIT");
630
+ } catch (err) {
631
+ db.exec("ROLLBACK");
632
+ throw err;
633
+ } finally {
634
+ db.pragma("foreign_keys = ON");
635
+ }
636
+ }
567
637
  function initMemorySchema(db) {
568
638
  db.exec(`
569
639
  -- Sessions table (linked to Claude Code session IDs)
@@ -853,7 +923,10 @@ function initMemorySchema(db) {
853
923
  id INTEGER PRIMARY KEY AUTOINCREMENT,
854
924
  session_id TEXT NOT NULL,
855
925
  timestamp TEXT DEFAULT (datetime('now')),
856
- event_type TEXT NOT NULL CHECK(event_type IN ('code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction')),
926
+ event_type TEXT NOT NULL CHECK(event_type IN (
927
+ 'code_change', 'rule_enforced', 'approval', 'review', 'commit', 'compaction',
928
+ 'rule_candidate_emitted', 'rule_promoted', 'rule_dismissed'
929
+ )),
857
930
  actor TEXT NOT NULL DEFAULT 'ai' CHECK(actor IN ('ai', 'human', 'hook', 'agent')),
858
931
  model_id TEXT,
859
932
  file_path TEXT,
@@ -868,7 +941,21 @@ function initMemorySchema(db) {
868
941
  CREATE INDEX IF NOT EXISTS idx_al_file ON audit_log(file_path);
869
942
  CREATE INDEX IF NOT EXISTS idx_al_event ON audit_log(event_type);
870
943
  CREATE INDEX IF NOT EXISTS idx_al_timestamp ON audit_log(timestamp DESC);
944
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_audit_rule_promoted
945
+ ON audit_log (event_type, json_extract(metadata, '$.prompt_hash'))
946
+ WHERE event_type = 'rule_promoted';
947
+ `);
948
+ db.exec(`
949
+ CREATE TABLE IF NOT EXISTS prompt_outcomes_signal_blacklist (
950
+ signal TEXT PRIMARY KEY,
951
+ dismissal_count INTEGER NOT NULL DEFAULT 0,
952
+ first_dismissed_at TEXT DEFAULT (datetime('now')),
953
+ last_dismissed_at TEXT DEFAULT (datetime('now'))
954
+ );
955
+ CREATE INDEX IF NOT EXISTS idx_psb_count
956
+ ON prompt_outcomes_signal_blacklist(dismissal_count DESC);
871
957
  `);
958
+ migrateAuditLogCheckExtension(db);
872
959
  db.exec(`
873
960
  CREATE TABLE IF NOT EXISTS validation_results (
874
961
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -173,7 +173,17 @@ var AutoLearningConfigSchema = z.object({
173
173
  requireIncidentReport: z.boolean().default(true),
174
174
  requirePreventionRule: z.boolean().default(true),
175
175
  requireEnforcement: z.boolean().default(true)
176
- }).default({})
176
+ }).default({}),
177
+ // plan-v0.2-interactive-rule-approval P-D-008 / P-D-009: project-configured
178
+ // custom destinations for the rule-candidate funnel. The classifier matches
179
+ // a candidate to one of these entries when none of the framework
180
+ // destinations (pattern-scanner / claude-md-cr / corrections-md) apply.
181
+ customDestinations: z.array(z.object({
182
+ name: z.string(),
183
+ path: z.string(),
184
+ triggerKeywords: z.array(z.string()).default([]),
185
+ template: z.string()
186
+ })).default([])
177
187
  }).optional();
178
188
  var CloudConfigSchema = z.object({
179
189
  enabled: z.boolean().default(false),