linksee-memory 0.8.0 → 0.11.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.
@@ -110,6 +110,42 @@ export function runMigrations(db) {
110
110
  addCol('last_confirmed_at', 'last_confirmed_at INTEGER');
111
111
  addCol('owner', 'owner TEXT');
112
112
  }
113
+ // v11 → v12: reconciler overlay columns on map_nodes (the Map shipped at v11
114
+ // without them). Only ALTER if map_nodes already exists; a v10→v12 jump creates
115
+ // it fresh (with the columns) via db.exec(sql) below.
116
+ if (currentVersion > 0 && currentVersion < 12) {
117
+ const hasMapNodes = db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='map_nodes'").get();
118
+ if (hasMapNodes) {
119
+ const have = new Set(db.prepare('PRAGMA table_info(map_nodes)').all().map((c) => c.name));
120
+ const addCol = (name, ddl) => { if (!have.has(name))
121
+ db.exec(`ALTER TABLE map_nodes ADD COLUMN ${ddl}`); };
122
+ addCol('reality', "reality TEXT NOT NULL DEFAULT '{}'");
123
+ addCol('live_verdict', 'live_verdict TEXT');
124
+ addCol('verdict_evidence', "verdict_evidence TEXT NOT NULL DEFAULT '{}'");
125
+ addCol('reconciled_at', 'reconciled_at INTEGER');
126
+ }
127
+ }
128
+ // v12 → v13: edge strength + accounted-for expiry (anti-noise / anti-graveyard).
129
+ if (currentVersion > 0 && currentVersion < 13) {
130
+ const has = (table, col) => db.prepare(`PRAGMA table_info(${table})`).all().some((c) => c.name === col);
131
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='map_edges'").get()) {
132
+ if (!has('map_edges', 'strength'))
133
+ db.exec('ALTER TABLE map_edges ADD COLUMN strength TEXT');
134
+ }
135
+ if (db.prepare("SELECT 1 FROM sqlite_master WHERE type='table' AND name='map_nodes'").get()) {
136
+ if (!has('map_nodes', 'review_by'))
137
+ db.exec('ALTER TABLE map_nodes ADD COLUMN review_by TEXT');
138
+ if (!has('map_nodes', 'revival_condition'))
139
+ db.exec('ALTER TABLE map_nodes ADD COLUMN revival_condition TEXT');
140
+ }
141
+ }
142
+ // v13 → v14: per-project uniqueness. map_nodes PK was the global `id`, and map_edges
143
+ // UNIQUE was global (from_id,to_id,type) — so two projects couldn't both have a `readme`
144
+ // node or a `readme→docs-site` edge. SQLite can't alter a PK/UNIQUE, so drop + recreate;
145
+ // safe because importMap rebuilds both from map.yaml on every run.
146
+ if (currentVersion > 0 && currentVersion < 14) {
147
+ db.exec('DROP TABLE IF EXISTS map_nodes; DROP TABLE IF EXISTS map_edges;');
148
+ }
113
149
  db.exec(sql);
114
150
  if (currentVersion > 0 && currentVersion < 4) {
115
151
  db.exec(`INSERT INTO memories_fts(rowid, content) SELECT id, content FROM memories;`);
@@ -335,6 +335,100 @@ CREATE TABLE IF NOT EXISTS memory_write_candidates (
335
335
  CREATE INDEX IF NOT EXISTS idx_mwc_status ON memory_write_candidates(status);
336
336
  CREATE INDEX IF NOT EXISTS idx_mwc_scope ON memory_write_candidates(scope);
337
337
 
338
+ -- ============================================================
339
+ -- v10: Re-injection log — the ACTIVE-observability stream (pre-action gate hits).
340
+ -- Separate from drift_edges (POST-action reality): the gate (guard.ts, fired by a Claude Code
341
+ -- PreToolUse hook) writes here when an accepted anchor is re-surfaced into context BEFORE an action.
342
+ -- Feeds `dream` — "re-injected N times, still contradicted (heeded=0)" is the machine evidence behind
343
+ -- #15443. trigger: boot=SessionStart · cue=prompt · gate=PreToolUse.
344
+ -- ============================================================
345
+ CREATE TABLE IF NOT EXISTS injection_log (
346
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
347
+ anchor_id INTEGER REFERENCES drift_anchors(id) ON DELETE CASCADE,
348
+ session_id TEXT,
349
+ trigger TEXT NOT NULL CHECK (trigger IN ('boot', 'cue', 'gate')),
350
+ surface TEXT NOT NULL CHECK (surface IN ('inform', 'warn', 'block', 'allow')),
351
+ tool_name TEXT,
352
+ action_snip TEXT, -- first ~120 chars of the attempted action
353
+ verdict TEXT, -- contradicts | in_scope | none
354
+ heeded INTEGER, -- NULL=unknown / 1=followed / 0=ignored (dream backfills)
355
+ occurred_at INTEGER NOT NULL DEFAULT (unixepoch())
356
+ );
357
+
358
+ CREATE INDEX IF NOT EXISTS idx_injlog_anchor ON injection_log(anchor_id, occurred_at);
359
+ CREATE INDEX IF NOT EXISTS idx_injlog_session ON injection_log(session_id, occurred_at);
360
+
361
+ -- ============================================================
362
+ -- v11: Current Truth Map — journey-spine topology (Product Drift OS spec v3).
363
+ -- map.yaml (git) is the desired-state SOURCE OF TRUTH (anchor #58); these tables
364
+ -- are the runtime index the importer reconciles INTO. A map_node is product
365
+ -- STRUCTURE (surface | implementation) — a SUPERSET of drift_anchors, which hold
366
+ -- only NORMATIVE claims. A normative node links out via anchor_id; descriptive
367
+ -- surfaces ("README exists") leave it NULL, so the violation scanner never sees
368
+ -- them. Full-rebuild import: the importer wipes a project's rows and re-inserts.
369
+ -- ============================================================
370
+ CREATE TABLE IF NOT EXISTS map_nodes (
371
+ id TEXT NOT NULL, -- stable slug from map.yaml (e.g. 'readme'); unique PER PROJECT
372
+ project TEXT NOT NULL,
373
+ layer TEXT NOT NULL, -- surface | implementation
374
+ stage TEXT, -- journey stage id (NULL for implementation layer)
375
+ statement TEXT NOT NULL,
376
+ status TEXT NOT NULL DEFAULT 'active', -- active|experiment|commitment|planned|paused|suspect|future_thesis
377
+ facets TEXT NOT NULL DEFAULT '[]', -- JSON array (the demoted old domains, as tags)
378
+ role TEXT, -- e.g. 'diffusion'
379
+ note TEXT,
380
+ due TEXT, -- ISO date (commitments)
381
+ paused_reason TEXT,
382
+ related_project TEXT,
383
+ spinout_candidate INTEGER NOT NULL DEFAULT 0,
384
+ anchor_id INTEGER REFERENCES drift_anchors(id) ON DELETE SET NULL, -- link IFF normative
385
+ review_by TEXT, -- ISO date: when an accounted-for/deferred node must be revisited
386
+ revival_condition TEXT, -- the release condition that clears a deferral (anti-graveyard)
387
+ reality TEXT NOT NULL DEFAULT '{}', -- JSON: how to verify this node from reality (kind/dir/signal)
388
+ live_verdict TEXT, -- reconciler overlay: convergence|divergence|absence|NULL(unchecked)
389
+ verdict_evidence TEXT NOT NULL DEFAULT '{}', -- JSON: file/line/term that decided the verdict
390
+ reconciled_at INTEGER, -- when the reconciler last ran for this node
391
+ extra TEXT NOT NULL DEFAULT '{}', -- JSON catch-all (forward-compat fields)
392
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch()),
393
+ PRIMARY KEY (project, id) -- a node id is unique PER PROJECT, not globally
394
+ );
395
+ CREATE INDEX IF NOT EXISTS idx_map_nodes_project ON map_nodes(project);
396
+ CREATE INDEX IF NOT EXISTS idx_map_nodes_stage ON map_nodes(stage);
397
+ CREATE INDEX IF NOT EXISTS idx_map_nodes_status ON map_nodes(status);
398
+ CREATE INDEX IF NOT EXISTS idx_map_nodes_layer ON map_nodes(layer);
399
+
400
+ -- Node↔node typed edges — the topology that makes blast-radius computable.
401
+ -- type: realizes (impl→surface) | supports | must-stay-consistent-with | reflux (expand→discover).
402
+ -- Endpoints are map_nodes slugs (validated in the lib, not FK-constrained, to keep
403
+ -- the wipe+reinsert import order trivial).
404
+ CREATE TABLE IF NOT EXISTS map_edges (
405
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
406
+ project TEXT NOT NULL,
407
+ from_id TEXT NOT NULL,
408
+ to_id TEXT NOT NULL,
409
+ type TEXT NOT NULL, -- realizes|supports|must-stay-consistent-with|should-align-with|mentions|reflux
410
+ strength TEXT, -- hard|soft|watch (controls AFFECTS noise); NULL → derived from type
411
+ note TEXT,
412
+ UNIQUE(project, from_id, to_id, type) -- an edge is unique PER PROJECT
413
+ );
414
+ CREATE INDEX IF NOT EXISTS idx_map_edges_from ON map_edges(from_id);
415
+ CREATE INDEX IF NOT EXISTS idx_map_edges_to ON map_edges(to_id);
416
+ CREATE INDEX IF NOT EXISTS idx_map_edges_type ON map_edges(type);
417
+
418
+ -- Project-level Map meta — the spine order/labels + the Job statement live in map.yaml,
419
+ -- NOT derivable from nodes alone. Persist them so any reader (dashboard) can render the
420
+ -- canonical journey order and the Job headline without parsing the YAML.
421
+ CREATE TABLE IF NOT EXISTS map_projects (
422
+ project TEXT PRIMARY KEY,
423
+ job TEXT,
424
+ audience TEXT NOT NULL DEFAULT '{}', -- JSON
425
+ product_status TEXT,
426
+ template TEXT,
427
+ stages TEXT NOT NULL DEFAULT '[]', -- JSON [{id,label}] — canonical spine order
428
+ related_projects TEXT NOT NULL DEFAULT '[]', -- JSON
429
+ updated_at INTEGER NOT NULL DEFAULT (unixepoch())
430
+ );
431
+
338
432
  -- ============================================================
339
433
  -- Meta — schema version tracking
340
434
  -- ============================================================
@@ -343,6 +437,6 @@ CREATE TABLE IF NOT EXISTS meta (
343
437
  value TEXT NOT NULL
344
438
  );
345
439
 
346
- INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '9');
440
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '14');
347
441
  INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
348
- UPDATE meta SET value = '9' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8');
442
+ UPDATE meta SET value = '14' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9', '10', '11', '12', '13');
@@ -285,12 +285,47 @@ export function detectDrift(db, opts = {}) {
285
285
  // session_file_edits (so we know where the affects files actually live on disk); code
286
286
  // extensions only; most-recent first; capped per anchor. Purely additive — detectDrift is
287
287
  // untouched. Comment lines are skipped so a doc-mention of a forbidden term isn't a hit.
288
+ // Prose (.md/.html) DESCRIBES rules; it is not where a code/operational constraint is *violated*.
289
+ // Scanning it produced false positives (a SKILL.md line literally saying "NOT raw chat"). Code only.
288
290
  const CODE_EXT = new Set([
289
291
  '.ts', '.tsx', '.js', '.jsx', '.mjs', '.cjs', '.json', '.sql', '.py', '.go', '.rs',
290
- '.java', '.rb', '.php', '.sh', '.yml', '.yaml', '.toml', '.env', '.md', '.html',
292
+ '.java', '.rb', '.php', '.sh', '.yml', '.yaml', '.toml', '.env',
291
293
  ]);
292
294
  const MAX_FILES_PER_ANCHOR = 400;
293
295
  const COMMENT_PREFIXES = ['//', '*', '/*', '#', '<!--', '--'];
296
+ // ── precision guards ─────────────────────────────────────────────────────────
297
+ // A raw substring of a forbidden term is too weak a notion of "violation". On the real corpus
298
+ // the bare-substring match produced ~90% false positives in three classes; reject each deductively:
299
+ // 1. sub-token — "forget" inside "forgetting" / "decideForgetting" → word-boundary match
300
+ // 2. negated — the line FORBIDS the term ("...NOT raw chat", "…禁止") → negation window before
301
+ // 3. citation — a scrape-prohibition's host appears only as a stored value → require a real net call
302
+ // ('cosmetic-info.jp' as a provenance label is not a fetch of it)
303
+ const NEGATION_NEAR = /\b(?:not|never|no\s+longer|don'?t|do\s+not|avoid|without)\b|禁止|しない|させない|不可|避け|してはいけない|ではなく/i;
304
+ const SCRAPE_ANCHOR = /crawl|scrap|robots|クロール|スクレイピング|スクレイプ|自動収集|自動巡回/i;
305
+ const NET_CALL = /\b(?:fetch|axios|requests?|urllib|httpx|curl|wget|got|puppeteer|playwright|cheerio|beautifulsoup|selenium|crawl|scrape|scraping)\b|クロール|スクレイピング/i;
306
+ // Locate a signal: ASCII signals must hit on a word boundary; CJK (no boundaries) stays substring.
307
+ function signalIndex(lowerLine, signal) {
308
+ if (/^[\x20-\x7e]+$/.test(signal)) {
309
+ const esc = signal.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
310
+ const m = new RegExp(`(?<![a-z0-9_])${esc}(?![a-z0-9_])`).exec(lowerLine);
311
+ return m ? m.index : -1;
312
+ }
313
+ return lowerLine.indexOf(signal);
314
+ }
315
+ // Deductive violation match with the three precision guards applied (returns the hit term or null).
316
+ function matchViolation(rawLine, lowerLine, signals, isScrapeAnchor) {
317
+ for (const s of signals) {
318
+ const idx = signalIndex(lowerLine, s);
319
+ if (idx < 0)
320
+ continue; // 1. word-boundary
321
+ if (NEGATION_NEAR.test(rawLine.slice(Math.max(0, idx - 24), idx)))
322
+ continue; // 2. negated context
323
+ if (isScrapeAnchor && !NET_CALL.test(rawLine))
324
+ continue; // 3. citation, no net call
325
+ return s;
326
+ }
327
+ return null;
328
+ }
294
329
  export function detectFileViolations(db, opts = {}) {
295
330
  const emitThreshold = opts.emitThreshold ?? EMIT_THRESHOLD_DEFAULT;
296
331
  const res = {
@@ -333,6 +368,7 @@ export function detectFileViolations(db, opts = {}) {
333
368
  if (signals.length === 0)
334
369
  continue; // no signal → can't deduce a contradiction
335
370
  const terms = parseArray(a.detect_terms);
371
+ const isScrapeAnchor = SCRAPE_ANCHOR.test(a.statement);
336
372
  const aW = tierWeight(a.tier);
337
373
  const confidence = Math.min(aW, REALITY_TIER_WEIGHT) * SCOPE_WEIGHT_GLOB;
338
374
  if (confidence < emitThreshold)
@@ -367,7 +403,7 @@ export function detectFileViolations(db, opts = {}) {
367
403
  if (COMMENT_PREFIXES.some((p) => trimmed.startsWith(p)))
368
404
  continue; // skip comments
369
405
  const ll = trimmed.toLowerCase();
370
- const hit = signals.find((s) => ll.includes(s));
406
+ const hit = matchViolation(trimmed, ll, signals, isScrapeAnchor);
371
407
  if (hit) {
372
408
  found = { line_no: i + 1, line_text: trimmed.slice(0, 280), hit };
373
409
  break; // one edge per (anchor, file): first real hit
@@ -0,0 +1,81 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type GateMode = 'off' | 'soft' | 'hard';
3
+ export type GateLevel = 'allow' | 'inform' | 'warn' | 'block';
4
+ export interface AcceptedAnchor {
5
+ id: number;
6
+ kind: string;
7
+ statement: string;
8
+ rationale: string | null;
9
+ affects: string;
10
+ detect_terms: string;
11
+ violation_signal: string;
12
+ card_policy: string;
13
+ }
14
+ export interface GateMatch {
15
+ anchor_id: number;
16
+ statement: string;
17
+ rationale: string | null;
18
+ verdict: 'contradicts' | 'in_scope';
19
+ why: string;
20
+ gate_mode: GateMode;
21
+ }
22
+ export interface GateResult {
23
+ gate: GateLevel;
24
+ matched: GateMatch[];
25
+ reinject: string;
26
+ }
27
+ export interface ActionInput {
28
+ tool?: string;
29
+ file_path?: string;
30
+ files?: string[];
31
+ command?: string;
32
+ content?: string;
33
+ diff?: string;
34
+ action?: string;
35
+ }
36
+ interface ActionCtx {
37
+ tool: string;
38
+ files: string[];
39
+ lines: string[];
40
+ haystack: string;
41
+ }
42
+ export declare function acceptedAnchors(db: Database.Database): AcceptedAnchor[];
43
+ export declare function matchAction(db: Database.Database, act: ActionCtx): GateMatch[];
44
+ export declare function gateAction(db: Database.Database, input: ActionInput, opts?: {
45
+ sessionId?: string;
46
+ }): GateResult;
47
+ export declare function formatReinject(matches: GateMatch[], gate: GateLevel): string;
48
+ export declare function buildBootDigest(db: Database.Database, opts?: {
49
+ maxAnchors?: number;
50
+ maxForks?: number;
51
+ }): {
52
+ text: string;
53
+ anchors: number;
54
+ forks: number;
55
+ distill: number;
56
+ };
57
+ export interface FrictionItem {
58
+ anchor_id: number;
59
+ statement: string;
60
+ gate_mode: GateMode;
61
+ lifecycle: string;
62
+ gate_contradicts: number;
63
+ gate_blocks: number;
64
+ ignored: number;
65
+ reality_contradicts: number;
66
+ last_at: string | null;
67
+ signal: 'violated_for_real' | 'gate_holding';
68
+ suggested_action: 'escalate_to_hard' | 'review_or_supersede' | 'none';
69
+ recommendation: string;
70
+ }
71
+ export declare function backfillHeeded(db: Database.Database): void;
72
+ export declare function getReinjectionFriction(db: Database.Database, opts?: {
73
+ minContradicts?: number;
74
+ }): FrictionItem[];
75
+ export declare function setGateMode(db: Database.Database, anchorId: number, mode: GateMode): {
76
+ ok: boolean;
77
+ anchor_id: number;
78
+ gate_mode: GateMode;
79
+ card_policy: Record<string, unknown>;
80
+ };
81
+ export {};
@@ -0,0 +1,321 @@
1
+ // Re-injection guard (the "6th pillar" — active observability). The post-hoc detector
2
+ // (drift-detection.ts) answers "did reality drift from a declared anchor?" AFTER the fact, into
3
+ // drift_edges. This module answers "is the action ABOUT to be taken in scope of / contradicting an
4
+ // accepted anchor?" BEFORE the fact, and re-surfaces that anchor into the agent's context. Pre vs
5
+ // post: the gate writes ONLY injection_log, never drift_edges — the two streams stay separate and
6
+ // rejoin in `dream`.
7
+ //
8
+ // Enforcement lives OUTSIDE the agent's volition (a Claude Code PreToolUse hook calls this via the
9
+ // guard-hook bin), because the load-bearing pain — "Claude read the rule, understood it, still used
10
+ // cp" (anthropics/claude-code#15443) — is precisely the case where the agent will NOT self-check.
11
+ //
12
+ // FAIL-OPEN by construction: every DB op is best-effort; only an explicit 'hard' contradiction yields
13
+ // a block. Lexical only (no embeddings): reuses lexical-match.ts (the same logic as the detector).
14
+ import { normPath, compileGlob, parseArray, matchViolation, SCRAPE_ANCHOR } from './lexical-match.js';
15
+ // "accepted = the only thing the gate compares against": declared (status active), still live
16
+ // (lifecycle active|experiment — NOT at_risk/superseded/deprecated), and not explicitly card-disabled.
17
+ // at_risk(stale) anchors deliberately DON'T gate — we don't enforce a rule we're no longer sure of.
18
+ const ACCEPTED_SQL = `status = 'active'
19
+ AND lifecycle IN ('active', 'experiment')
20
+ AND COALESCE(json_extract(card_policy, '$.enabled'), 1) != 0`;
21
+ const nowSec = () => Math.floor(Date.now() / 1000);
22
+ function jsonGet(json, key, def) {
23
+ try {
24
+ const o = JSON.parse(json || '{}');
25
+ return o && o[key] !== undefined && o[key] !== null ? o[key] : def;
26
+ }
27
+ catch {
28
+ return def;
29
+ }
30
+ }
31
+ function bestEffort(fn) {
32
+ try {
33
+ fn();
34
+ }
35
+ catch {
36
+ /* logging/telemetry must NEVER block the gate */
37
+ }
38
+ }
39
+ export function acceptedAnchors(db) {
40
+ return db
41
+ .prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
42
+ FROM drift_anchors WHERE ${ACCEPTED_SQL}`)
43
+ .all();
44
+ }
45
+ function buildActionCtx(input) {
46
+ const files = [];
47
+ if (input.file_path)
48
+ files.push(input.file_path);
49
+ if (Array.isArray(input.files))
50
+ files.push(...input.files);
51
+ const rawParts = [input.command, input.diff, input.content, input.action].filter((x) => typeof x === 'string' && x.length > 0);
52
+ const lines = [];
53
+ for (const part of rawParts) {
54
+ for (const ln of part.split(/\r?\n/)) {
55
+ const t = ln.trim();
56
+ if (t)
57
+ lines.push(t);
58
+ }
59
+ }
60
+ const haystack = [...files.map(normPath), ...rawParts].join('\n').toLowerCase();
61
+ return { tool: input.tool ?? 'unknown', files, lines, haystack };
62
+ }
63
+ export function matchAction(db, act) {
64
+ const out = [];
65
+ for (const a of acceptedAnchors(db)) {
66
+ const gate_mode = jsonGet(a.card_policy, 'gate_mode', 'soft');
67
+ if (gate_mode === 'off')
68
+ continue;
69
+ const globs = parseArray(a.affects).map(compileGlob);
70
+ const terms = parseArray(a.detect_terms)
71
+ .map((t) => t.trim().toLowerCase())
72
+ .filter(Boolean);
73
+ const signals = parseArray(a.violation_signal)
74
+ .map((s) => s.trim().toLowerCase())
75
+ .filter(Boolean);
76
+ const hasScope = globs.length > 0;
77
+ const pathHit = hasScope && act.files.some((f) => {
78
+ const p = normPath(f);
79
+ return globs.some((g) => g(p));
80
+ });
81
+ const termHit = terms.length > 0 && terms.some((t) => act.haystack.includes(t));
82
+ const isScrape = SCRAPE_ANCHOR.test(a.statement);
83
+ let sigHit = null;
84
+ if (signals.length > 0) {
85
+ for (const line of act.lines) {
86
+ const hit = matchViolation(line, line.toLowerCase(), signals, isScrape);
87
+ if (hit) {
88
+ sigHit = hit;
89
+ break;
90
+ }
91
+ }
92
+ }
93
+ // Scope (mirrors the detector): a path-scoped anchor requires the action to touch an in-scope
94
+ // file; a global anchor (no affects) fires on topical-term OR forbidden-signal relevance.
95
+ const inScope = hasScope ? pathHit : termHit || sigHit != null;
96
+ if (!inScope)
97
+ continue;
98
+ out.push({
99
+ anchor_id: a.id,
100
+ statement: a.statement,
101
+ rationale: a.rationale,
102
+ verdict: sigHit ? 'contradicts' : 'in_scope',
103
+ why: sigHit
104
+ ? `your action contains \`${sigHit}\``
105
+ : pathHit
106
+ ? `touches a file under this decision's scope`
107
+ : `matches this decision's topic`,
108
+ gate_mode,
109
+ });
110
+ }
111
+ // contradicts first (the headline), then in_scope.
112
+ return out.sort((x, y) => (y.verdict === 'contradicts' ? 1 : 0) - (x.verdict === 'contradicts' ? 1 : 0));
113
+ }
114
+ function withinCooldown(db, anchorId, sessionId) {
115
+ if (!sessionId)
116
+ return false;
117
+ const minutes = jsonGet(db.prepare(`SELECT card_policy FROM drift_anchors WHERE id = ?`).get(anchorId)
118
+ ?.card_policy, 'reinject_cooldown_min', 30);
119
+ const cutoff = nowSec() - minutes * 60;
120
+ const row = db
121
+ .prepare(`SELECT 1 FROM injection_log WHERE anchor_id = ? AND session_id = ? AND occurred_at >= ? LIMIT 1`)
122
+ .get(anchorId, sessionId, cutoff);
123
+ return !!row;
124
+ }
125
+ function logInjection(db, matches, act, surface, sessionId) {
126
+ const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
127
+ VALUES (?, ?, 'gate', ?, ?, ?, ?)`);
128
+ const snip = (act.lines[0] ?? '').slice(0, 120);
129
+ const tx = db.transaction(() => {
130
+ for (const m of matches)
131
+ ins.run(m.anchor_id, sessionId ?? null, surface, act.tool, snip, m.verdict);
132
+ });
133
+ tx();
134
+ }
135
+ export function gateAction(db, input, opts = {}) {
136
+ const act = buildActionCtx(input);
137
+ let matches = matchAction(db, act);
138
+ if (matches.length === 0)
139
+ return { gate: 'allow', matched: [], reinject: '' };
140
+ const contradictions = matches.filter((m) => m.verdict === 'contradicts');
141
+ let gate = contradictions.length > 0 ? 'warn' : 'inform';
142
+ if (contradictions.some((m) => m.gate_mode === 'hard'))
143
+ gate = 'block';
144
+ // Cooldown applies ONLY to pure-informational re-injection (no contradiction). A real contradiction
145
+ // is surfaced every single time — an ignored rule must not be silenced by a timer.
146
+ if (gate === 'inform') {
147
+ matches = matches.filter((m) => !withinCooldown(db, m.anchor_id, opts.sessionId));
148
+ if (matches.length === 0)
149
+ return { gate: 'allow', matched: [], reinject: '' };
150
+ }
151
+ const shown = matches.slice(0, 4);
152
+ const reinject = formatReinject(shown, gate);
153
+ bestEffort(() => logInjection(db, shown, act, gate, opts.sessionId));
154
+ return { gate, matched: shown, reinject };
155
+ }
156
+ export function formatReinject(matches, gate) {
157
+ const head = gate === 'block'
158
+ ? '⛔ Blocked — this action breaks a decision you locked earlier.'
159
+ : gate === 'warn'
160
+ ? '⚠ Heads up — this action contradicts a decision you locked earlier.'
161
+ : 'ℹ Reminder — you have an active decision covering what you are about to touch.';
162
+ const body = matches.map((m) => {
163
+ const rationale = m.rationale ? ` — ${m.rationale}` : '';
164
+ const tail = m.verdict === 'contradicts' ? `${m.why} → contradicts it.` : `${m.why}.`;
165
+ return `• [#${m.anchor_id}] "${m.statement}"${rationale}\n ↳ ${tail}`;
166
+ });
167
+ const firstContra = matches.find((m) => m.verdict === 'contradicts');
168
+ const foot = firstContra
169
+ ? `\nIf you are intentionally changing this decision, supersede it on the record:\n resolve_drift(anchor_id: ${firstContra.anchor_id}, action: 'supersede', superseded_by: <new anchor>).`
170
+ : '';
171
+ return [head, ...body, foot].filter(Boolean).join('\n');
172
+ }
173
+ // SessionStart boot digest — re-load the accepted anchors + open forks into a fresh session, killing
174
+ // cross-session amnesia ("groundhog day"). Kept small (top-N) and budget-bounded by caller.
175
+ export function buildBootDigest(db, opts = {}) {
176
+ const maxAnchors = opts.maxAnchors ?? 8;
177
+ const maxForks = opts.maxForks ?? 5;
178
+ const anchors = db
179
+ .prepare(`SELECT id, statement, rationale FROM drift_anchors
180
+ WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
181
+ ORDER BY confidence DESC, updated_at DESC LIMIT ?`)
182
+ .all(maxAnchors);
183
+ const forks = db
184
+ .prepare(`SELECT c.id, c.rationale, a.statement
185
+ FROM memory_write_candidates c
186
+ LEFT JOIN drift_anchors a ON a.id = c.target_node_id
187
+ WHERE c.scope = 'orphaned_proposal' AND c.status = 'pending_review'
188
+ ORDER BY c.created_at DESC LIMIT ?`)
189
+ .all(maxForks);
190
+ // Distillation pressure — the routine's structural trigger. The drain must not depend on
191
+ // the agent remembering to dream (#15443 lesson): while raw auto-captured memories exist,
192
+ // EVERY session boot says so. Inflow (new sessions) vs drain (8/dream) stays visible.
193
+ let distill = 0;
194
+ try {
195
+ distill = db.prepare(`SELECT COUNT(*) AS n FROM memories
196
+ WHERE layer IN ('learning', 'caveat') AND json_valid(content)
197
+ AND (json_extract(content, '$.needs_distill') = 1
198
+ OR json_extract(content, '$.why') = 'Decision detected by pattern match — may need agent enrichment'
199
+ OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
200
+ }
201
+ catch {
202
+ /* digest must never fail on the nudge */
203
+ }
204
+ if (anchors.length === 0 && forks.length === 0 && distill === 0)
205
+ return { text: '', anchors: 0, forks: 0, distill: 0 };
206
+ const parts = [];
207
+ if (anchors.length > 0) {
208
+ parts.push('📌 Linksee — decisions you locked (still in force):');
209
+ for (const a of anchors)
210
+ parts.push(`• [#${a.id}] "${a.statement}"${a.rationale ? ` — ${a.rationale}` : ''}`);
211
+ }
212
+ if (forks.length > 0) {
213
+ parts.push('', '🔀 Open forks still awaiting your call:');
214
+ for (const f of forks)
215
+ parts.push(`• ${f.statement ?? f.rationale}`);
216
+ }
217
+ if (distill > 0) {
218
+ parts.push('', `🧪 ${distill} auto-captured memories are still raw utterances — call dream() and rewrite the distill_queue via remember(memory_id, content) with "distilled": true.`);
219
+ }
220
+ if (anchors.length > 0)
221
+ parts.push('', 'Honor these unless you explicitly supersede them (resolve_drift action=supersede).');
222
+ return { text: parts.join('\n'), anchors: anchors.length, forks: forks.length, distill };
223
+ }
224
+ // Coarse, best-effort heeded backfill (idempotent — only touches heeded IS NULL). heeded=0 when a soft
225
+ // re-injection failed to prevent the violation (the anchor still has an OPEN reality contradiction);
226
+ // heeded=1 for blocks (the tool was denied) and for soft injections whose anchor's reality is clean.
227
+ // Intentionally NOT timestamp-correlated — it powers the "ignored" tally only, never a gating decision.
228
+ export function backfillHeeded(db) {
229
+ bestEffort(() => {
230
+ const live = `SELECT anchor_id FROM drift_edges WHERE verdict = 'contradicts' AND status = 'open'`;
231
+ db.prepare(`UPDATE injection_log SET heeded = 0
232
+ WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
233
+ AND anchor_id IN (${live})`).run();
234
+ db.prepare(`UPDATE injection_log SET heeded = 1
235
+ WHERE heeded IS NULL AND surface IN ('warn', 'inform')
236
+ AND anchor_id NOT IN (${live})`).run();
237
+ db.prepare(`UPDATE injection_log SET heeded = 1 WHERE heeded IS NULL AND surface = 'block'`).run();
238
+ });
239
+ }
240
+ // Surface anchors the gate keeps re-injecting. PRIMARY signal = two directly-queryable counts (no fragile
241
+ // inference): gate_contradicts (pre-action) × reality_contradicts (post-action). Both > 0 ⇒ re-injection
242
+ // isn't holding ⇒ escalate (soft→hard) or review/supersede (if reality has outrun the rule).
243
+ export function getReinjectionFriction(db, opts = {}) {
244
+ const minC = opts.minContradicts ?? 3;
245
+ backfillHeeded(db);
246
+ const rows = db
247
+ .prepare(`SELECT i.anchor_id,
248
+ SUM(CASE WHEN i.verdict = 'contradicts' THEN 1 ELSE 0 END) AS gate_contradicts,
249
+ SUM(CASE WHEN i.surface = 'block' THEN 1 ELSE 0 END) AS gate_blocks,
250
+ SUM(CASE WHEN i.heeded = 0 THEN 1 ELSE 0 END) AS ignored,
251
+ datetime(MAX(i.occurred_at), 'unixepoch') AS last_at,
252
+ a.statement, a.lifecycle, a.card_policy
253
+ FROM injection_log i
254
+ JOIN drift_anchors a ON a.id = i.anchor_id
255
+ WHERE a.status = 'active'
256
+ GROUP BY i.anchor_id
257
+ HAVING gate_contradicts >= ?`)
258
+ .all(minC);
259
+ const realityStmt = db.prepare(`SELECT COUNT(*) AS n FROM drift_edges WHERE anchor_id = ? AND verdict = 'contradicts' AND status = 'open'`);
260
+ const out = [];
261
+ for (const r of rows) {
262
+ const gate_mode = jsonGet(r.card_policy, 'gate_mode', 'soft');
263
+ const reality_contradicts = realityStmt.get(r.anchor_id).n;
264
+ let signal;
265
+ let suggested_action;
266
+ let recommendation;
267
+ if (reality_contradicts > 0) {
268
+ signal = 'violated_for_real';
269
+ if (gate_mode !== 'hard') {
270
+ suggested_action = 'escalate_to_hard';
271
+ recommendation = `Re-surfaced ${r.gate_contradicts}× yet ${reality_contradicts} contradiction(s) are still live in the code — soft warnings aren't holding. Escalate to gate_mode:'hard', or supersede the anchor if reality has outrun the rule.`;
272
+ }
273
+ else {
274
+ suggested_action = 'review_or_supersede';
275
+ recommendation = `Hard-gated yet ${reality_contradicts} contradiction(s) persist (pre-existing or overridden) — the rule is fighting reality. Review whether it is still correct; supersede if not.`;
276
+ }
277
+ }
278
+ else {
279
+ signal = 'gate_holding';
280
+ const heavy = gate_mode !== 'hard' && r.gate_contradicts >= minC * 2;
281
+ suggested_action = heavy ? 'escalate_to_hard' : 'none';
282
+ recommendation = `The gate caught ${r.gate_contradicts} attempt(s) and reality stayed clean — working as intended.${heavy ? " It keeps firing — consider gate_mode:'hard' to stop the attempts at the source." : ''}`;
283
+ }
284
+ out.push({
285
+ anchor_id: r.anchor_id,
286
+ statement: r.statement,
287
+ gate_mode,
288
+ lifecycle: r.lifecycle,
289
+ gate_contradicts: r.gate_contradicts,
290
+ gate_blocks: r.gate_blocks,
291
+ ignored: r.ignored,
292
+ reality_contradicts,
293
+ last_at: r.last_at,
294
+ signal,
295
+ suggested_action,
296
+ recommendation,
297
+ });
298
+ }
299
+ return out.sort((x, y) => y.reality_contradicts - x.reality_contradicts || y.gate_contradicts - x.gate_contradicts);
300
+ }
301
+ // One-call enforcement change — applies the friction "escalate_to_hard" recommendation. Merges gate_mode
302
+ // into the anchor's card_policy (preserving other keys). Exposed via resolve_drift(action:'harden'|'soften'),
303
+ // NOT a new tool — honoring anchor #1 ("public tools = 3, don't add a 4th"; the surface already drifted to 10).
304
+ export function setGateMode(db, anchorId, mode) {
305
+ const row = db.prepare(`SELECT card_policy FROM drift_anchors WHERE id = ?`).get(anchorId);
306
+ if (!row)
307
+ throw new Error(`anchor #${anchorId} not found`);
308
+ let policy = {};
309
+ try {
310
+ policy = JSON.parse(row.card_policy || '{}') || {};
311
+ }
312
+ catch {
313
+ policy = {};
314
+ }
315
+ policy.gate_mode = mode;
316
+ if (policy.enabled === undefined)
317
+ policy.enabled = true;
318
+ db.prepare(`UPDATE drift_anchors SET card_policy = ?, updated_at = unixepoch() WHERE id = ?`).run(JSON.stringify(policy), anchorId);
319
+ return { ok: true, anchor_id: anchorId, gate_mode: mode, card_policy: policy };
320
+ }
321
+ //# sourceMappingURL=guard.js.map
@@ -0,0 +1,6 @@
1
+ export declare function parseArray(s: string | null | undefined): string[];
2
+ export declare function normPath(p: string): string;
3
+ export declare function compileGlob(glob: string): (path: string) => boolean;
4
+ export declare const SCRAPE_ANCHOR: RegExp;
5
+ export declare function signalIndex(lowerLine: string, signal: string): number;
6
+ export declare function matchViolation(rawLine: string, lowerLine: string, signals: string[], isScrapeAnchor: boolean): string | null;