linksee-memory 0.10.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.
@@ -358,6 +358,77 @@ CREATE TABLE IF NOT EXISTS injection_log (
358
358
  CREATE INDEX IF NOT EXISTS idx_injlog_anchor ON injection_log(anchor_id, occurred_at);
359
359
  CREATE INDEX IF NOT EXISTS idx_injlog_session ON injection_log(session_id, occurred_at);
360
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
+
361
432
  -- ============================================================
362
433
  -- Meta — schema version tracking
363
434
  -- ============================================================
@@ -366,6 +437,6 @@ CREATE TABLE IF NOT EXISTS meta (
366
437
  value TEXT NOT NULL
367
438
  );
368
439
 
369
- INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '10');
440
+ INSERT OR IGNORE INTO meta (key, value) VALUES ('schema_version', '14');
370
441
  INSERT OR IGNORE INTO meta (key, value) VALUES ('created_at', CAST(unixepoch() AS TEXT));
371
- UPDATE meta SET value = '10' WHERE key = 'schema_version' AND value IN ('1', '2', '3', '4', '5', '6', '7', '8', '9');
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');
@@ -25,17 +25,17 @@ export function consolidate(db, opts = {}) {
25
25
  // table is actually populated by the MCP server).
26
26
  const layerPlaceholders = CLUSTER_LAYERS.map(() => '?').join(',');
27
27
  const rows = db
28
- .prepare(`
29
- SELECT m.id, m.entity_id, m.layer, m.content, m.importance,
30
- m.last_accessed_at, m.access_count, m.created_at, m.protected,
31
- m.altitude,
32
- e.name as entity_name
33
- FROM memories m
34
- JOIN entities e ON e.id = m.entity_id
35
- WHERE m.protected = 0
36
- AND m.layer IN (${layerPlaceholders})
37
- AND m.created_at <= ?
38
- ORDER BY m.entity_id, m.layer, m.created_at
28
+ .prepare(`
29
+ SELECT m.id, m.entity_id, m.layer, m.content, m.importance,
30
+ m.last_accessed_at, m.access_count, m.created_at, m.protected,
31
+ m.altitude,
32
+ e.name as entity_name
33
+ FROM memories m
34
+ JOIN entities e ON e.id = m.entity_id
35
+ WHERE m.protected = 0
36
+ AND m.layer IN (${layerPlaceholders})
37
+ AND m.created_at <= ?
38
+ ORDER BY m.entity_id, m.layer, m.created_at
39
39
  `)
40
40
  .all(...CLUSTER_LAYERS, ageCutoff);
41
41
  // Filter to cold-heat memories
@@ -68,10 +68,10 @@ export function consolidate(db, opts = {}) {
68
68
  memoryEdgesCreated: 0,
69
69
  decisionsSuperseded: 0,
70
70
  };
71
- const insertLearning = db.prepare(`INSERT INTO memories (entity_id, layer, content, importance, protected, source)
71
+ const insertLearning = db.prepare(`INSERT INTO memories (entity_id, layer, content, importance, protected, source)
72
72
  VALUES (?, 'learning', ?, ?, 1, ?)`);
73
73
  const deleteMemory = db.prepare('DELETE FROM memories WHERE id = ?');
74
- const insertAudit = db.prepare(`INSERT INTO consolidations (learning_id, replaced_ids, replaced_count, entity_id, original_layer)
74
+ const insertAudit = db.prepare(`INSERT INTO consolidations (learning_id, replaced_ids, replaced_count, entity_id, original_layer)
75
75
  VALUES (?, ?, ?, ?, ?)`);
76
76
  const insertEvent = db.prepare('INSERT INTO events (entity_id, kind, payload) VALUES (?, ?, ?)');
77
77
  const tx = db.transaction(() => {
@@ -137,12 +137,12 @@ export function consolidate(db, opts = {}) {
137
137
  // Uses json_set() to update the state field in content JSON atomically.
138
138
  const STALLED_THRESHOLD_DAYS = 30;
139
139
  const stalledCutoff = now - STALLED_THRESHOLD_DAYS * 86400;
140
- const stalledResult = db.prepare(`
141
- UPDATE memories SET content = json_set(content, '$.state', 'stalled')
142
- WHERE json_valid(content)
143
- AND json_extract(content, '$.state') = 'in_progress'
144
- AND last_accessed_at < ?
145
- AND protected = 0
140
+ const stalledResult = db.prepare(`
141
+ UPDATE memories SET content = json_set(content, '$.state', 'stalled')
142
+ WHERE json_valid(content)
143
+ AND json_extract(content, '$.state') = 'in_progress'
144
+ AND last_accessed_at < ?
145
+ AND protected = 0
146
146
  `).run(stalledCutoff);
147
147
  result.stalledTransitions = stalledResult.changes;
148
148
  // Memory→memory edge detection: link superseding/contradicting decisions so the
@@ -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
@@ -71,11 +71,11 @@ export function detectMemoryEdges(db, opts = {}) {
71
71
  const res = {
72
72
  decisionsScanned: 0, edgesCreated: 0, supersedes: 0, contradicts: 0, extends: 0, supersededMarked: 0, samples: [],
73
73
  };
74
- const rows = db.prepare(`
75
- SELECT id, entity_id, content, created_at
76
- FROM memories
77
- WHERE mem_type = 'decision' AND json_valid(content)
78
- ORDER BY entity_id ASC, created_at ASC, id ASC
74
+ const rows = db.prepare(`
75
+ SELECT id, entity_id, content, created_at
76
+ FROM memories
77
+ WHERE mem_type = 'decision' AND json_valid(content)
78
+ ORDER BY entity_id ASC, created_at ASC, id ASC
79
79
  `).all();
80
80
  res.decisionsScanned = rows.length;
81
81
  if (rows.length < 2)
@@ -91,9 +91,9 @@ export function detectMemoryEdges(db, opts = {}) {
91
91
  // Prepare write statements only when actually writing — keeps dryRun safe on a
92
92
  // readonly connection (preview / verification path).
93
93
  const insEdge = opts.dryRun ? null : db.prepare(`INSERT OR IGNORE INTO memory_edges (from_memory_id, to_memory_id, relation) VALUES (?, ?, ?)`);
94
- const markSuperseded = opts.dryRun ? null : db.prepare(`
95
- UPDATE memories SET content = json_set(content, '$.state', 'superseded')
96
- WHERE id = ? AND json_valid(content) AND json_extract(content, '$.state') <> 'superseded'
94
+ const markSuperseded = opts.dryRun ? null : db.prepare(`
95
+ UPDATE memories SET content = json_set(content, '$.state', 'superseded')
96
+ WHERE id = ? AND json_valid(content) AND json_extract(content, '$.state') <> 'superseded'
97
97
  `);
98
98
  const apply = () => {
99
99
  for (const decisions of byEntity.values()) {
package/dist/lib/guard.js CHANGED
@@ -15,8 +15,8 @@ import { normPath, compileGlob, parseArray, matchViolation, SCRAPE_ANCHOR } from
15
15
  // "accepted = the only thing the gate compares against": declared (status active), still live
16
16
  // (lifecycle active|experiment — NOT at_risk/superseded/deprecated), and not explicitly card-disabled.
17
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')
18
+ const ACCEPTED_SQL = `status = 'active'
19
+ AND lifecycle IN ('active', 'experiment')
20
20
  AND COALESCE(json_extract(card_policy, '$.enabled'), 1) != 0`;
21
21
  const nowSec = () => Math.floor(Date.now() / 1000);
22
22
  function jsonGet(json, key, def) {
@@ -38,7 +38,7 @@ function bestEffort(fn) {
38
38
  }
39
39
  export function acceptedAnchors(db) {
40
40
  return db
41
- .prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
41
+ .prepare(`SELECT id, kind, statement, rationale, affects, detect_terms, violation_signal, card_policy
42
42
  FROM drift_anchors WHERE ${ACCEPTED_SQL}`)
43
43
  .all();
44
44
  }
@@ -123,7 +123,7 @@ function withinCooldown(db, anchorId, sessionId) {
123
123
  return !!row;
124
124
  }
125
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)
126
+ const ins = db.prepare(`INSERT INTO injection_log (anchor_id, session_id, trigger, surface, tool_name, action_snip, verdict)
127
127
  VALUES (?, ?, 'gate', ?, ?, ?, ?)`);
128
128
  const snip = (act.lines[0] ?? '').slice(0, 120);
129
129
  const tx = db.transaction(() => {
@@ -176,15 +176,15 @@ export function buildBootDigest(db, opts = {}) {
176
176
  const maxAnchors = opts.maxAnchors ?? 8;
177
177
  const maxForks = opts.maxForks ?? 5;
178
178
  const anchors = db
179
- .prepare(`SELECT id, statement, rationale FROM drift_anchors
180
- WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
179
+ .prepare(`SELECT id, statement, rationale FROM drift_anchors
180
+ WHERE ${ACCEPTED_SQL} AND kind IN ('prohibition', 'decision', 'constraint')
181
181
  ORDER BY confidence DESC, updated_at DESC LIMIT ?`)
182
182
  .all(maxAnchors);
183
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'
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
188
  ORDER BY c.created_at DESC LIMIT ?`)
189
189
  .all(maxForks);
190
190
  // Distillation pressure — the routine's structural trigger. The drain must not depend on
@@ -192,10 +192,10 @@ export function buildBootDigest(db, opts = {}) {
192
192
  // EVERY session boot says so. Inflow (new sessions) vs drain (8/dream) stays visible.
193
193
  let distill = 0;
194
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'
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
199
  OR json_extract(content, '$.why') = 'User-stated warning/prohibition — auto-extracted by caveat pattern match')`).get().n;
200
200
  }
201
201
  catch {
@@ -228,11 +228,11 @@ export function buildBootDigest(db, opts = {}) {
228
228
  export function backfillHeeded(db) {
229
229
  bestEffort(() => {
230
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'
231
+ db.prepare(`UPDATE injection_log SET heeded = 0
232
+ WHERE heeded IS NULL AND surface IN ('warn', 'inform') AND verdict = 'contradicts'
233
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')
234
+ db.prepare(`UPDATE injection_log SET heeded = 1
235
+ WHERE heeded IS NULL AND surface IN ('warn', 'inform')
236
236
  AND anchor_id NOT IN (${live})`).run();
237
237
  db.prepare(`UPDATE injection_log SET heeded = 1 WHERE heeded IS NULL AND surface = 'block'`).run();
238
238
  });
@@ -244,16 +244,16 @@ export function getReinjectionFriction(db, opts = {}) {
244
244
  const minC = opts.minContradicts ?? 3;
245
245
  backfillHeeded(db);
246
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
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
257
  HAVING gate_contradicts >= ?`)
258
258
  .all(minC);
259
259
  const realityStmt = db.prepare(`SELECT COUNT(*) AS n FROM drift_edges WHERE anchor_id = ? AND verdict = 'contradicts' AND status = 'open'`);
@@ -0,0 +1,55 @@
1
+ import type Database from 'better-sqlite3';
2
+ export declare const EDGE_TYPES: Set<string>;
3
+ export declare const EDGE_STRENGTHS: Set<string>;
4
+ export declare const NODE_LAYERS: Set<string>;
5
+ export declare const NODE_STATUSES: Set<string>;
6
+ export interface MapNode {
7
+ id: string;
8
+ layer: string;
9
+ stage?: string;
10
+ statement: string;
11
+ status?: string;
12
+ facets?: string[];
13
+ role?: string;
14
+ note?: string;
15
+ due?: string;
16
+ paused_reason?: string;
17
+ related_project?: string;
18
+ spinout_candidate?: boolean;
19
+ anchor?: number;
20
+ review_by?: string;
21
+ revival_condition?: string;
22
+ reality?: Record<string, unknown>;
23
+ [k: string]: unknown;
24
+ }
25
+ export interface MapEdge {
26
+ from: string;
27
+ to: string;
28
+ type: string;
29
+ strength?: string;
30
+ note?: string;
31
+ }
32
+ export interface ParsedMap {
33
+ project: string;
34
+ template?: string;
35
+ product_status?: string;
36
+ job?: string;
37
+ audience?: Record<string, unknown>;
38
+ related_projects?: unknown[];
39
+ stages: Array<{
40
+ id: string;
41
+ label?: string;
42
+ }>;
43
+ nodes: MapNode[];
44
+ edges: MapEdge[];
45
+ }
46
+ export declare function parseMapFile(path: string): ParsedMap;
47
+ export interface ImportResult {
48
+ project: string;
49
+ nodes: number;
50
+ edges: number;
51
+ linked_anchors: number;
52
+ warnings: string[];
53
+ }
54
+ export declare function importMap(db: Database.Database, map: ParsedMap): ImportResult;
55
+ export declare function importMapFile(db: Database.Database, path: string): ImportResult;
@@ -0,0 +1,150 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse as parseYaml } from 'yaml';
3
+ export const EDGE_TYPES = new Set(['realizes', 'supports', 'must-stay-consistent-with', 'should-align-with', 'mentions', 'reflux']);
4
+ export const EDGE_STRENGTHS = new Set(['hard', 'soft', 'watch']);
5
+ export const NODE_LAYERS = new Set(['surface', 'implementation']);
6
+ // Recognized statuses — anything else imports but is flagged as a warning (so a
7
+ // typo can't silently become a phantom lifecycle the dashboard renders blank).
8
+ export const NODE_STATUSES = new Set([
9
+ 'active', 'experiment', 'commitment', 'planned', 'paused', 'suspect', 'future_thesis',
10
+ ]);
11
+ const KNOWN_NODE_COLS = new Set([
12
+ 'id', 'layer', 'stage', 'statement', 'status', 'facets', 'role', 'note',
13
+ 'due', 'paused_reason', 'related_project', 'spinout_candidate', 'anchor', 'reality',
14
+ 'review_by', 'revival_condition',
15
+ ]);
16
+ export function parseMapFile(path) {
17
+ const raw = parseYaml(readFileSync(path, 'utf8'));
18
+ if (!raw || typeof raw !== 'object')
19
+ throw new Error(`map.yaml at ${path} parsed to nothing`);
20
+ if (!raw.project)
21
+ throw new Error('map.yaml missing required top-level `project`');
22
+ if (!Array.isArray(raw.stages) || raw.stages.length === 0)
23
+ throw new Error('map.yaml needs a non-empty `stages` spine');
24
+ if (!Array.isArray(raw.nodes))
25
+ throw new Error('map.yaml `nodes` must be a list');
26
+ return {
27
+ project: String(raw.project),
28
+ template: raw.template ? String(raw.template) : undefined,
29
+ product_status: raw.product_status ? String(raw.product_status) : undefined,
30
+ job: raw.job ? String(raw.job) : undefined,
31
+ audience: raw.audience ?? undefined,
32
+ related_projects: Array.isArray(raw.related_projects) ? raw.related_projects : undefined,
33
+ stages: raw.stages.map((s) => ({ id: String(s.id), label: s.label ? String(s.label) : undefined })),
34
+ nodes: raw.nodes ?? [],
35
+ edges: Array.isArray(raw.edges) ? raw.edges : [],
36
+ };
37
+ }
38
+ // Full-rebuild import inside one transaction. Validates topology and collects
39
+ // warnings (dangling edge endpoints, unknown stage/status) WITHOUT aborting — a
40
+ // map mid-edit should still load so the dashboard can show what IS there.
41
+ export function importMap(db, map) {
42
+ const warnings = [];
43
+ const stageIds = new Set(map.stages.map((s) => s.id));
44
+ const nodeIds = new Set(map.nodes.map((n) => n.id));
45
+ // validate nodes
46
+ for (const n of map.nodes) {
47
+ if (!n.id) {
48
+ warnings.push('node with no id skipped');
49
+ continue;
50
+ }
51
+ if (!NODE_LAYERS.has(n.layer))
52
+ warnings.push(`node ${n.id}: unknown layer "${n.layer}"`);
53
+ if (n.layer === 'surface' && n.stage && !stageIds.has(n.stage))
54
+ warnings.push(`node ${n.id}: stage "${n.stage}" not in spine`);
55
+ if (n.layer === 'surface' && !n.stage)
56
+ warnings.push(`node ${n.id}: surface node has no stage`);
57
+ if (n.status && !NODE_STATUSES.has(n.status))
58
+ warnings.push(`node ${n.id}: unrecognized status "${n.status}"`);
59
+ }
60
+ // validate edges
61
+ for (const e of map.edges) {
62
+ if (!EDGE_TYPES.has(e.type))
63
+ warnings.push(`edge ${e.from}→${e.to}: unknown type "${e.type}"`);
64
+ if (e.strength && !EDGE_STRENGTHS.has(e.strength))
65
+ warnings.push(`edge ${e.from}→${e.to}: unknown strength "${e.strength}"`);
66
+ if (!nodeIds.has(e.from))
67
+ warnings.push(`edge endpoint "${e.from}" is not a node (dangling)`);
68
+ if (!nodeIds.has(e.to))
69
+ warnings.push(`edge endpoint "${e.to}" is not a node (dangling)`);
70
+ }
71
+ const anchorExists = db.prepare('SELECT 1 FROM drift_anchors WHERE id = ?');
72
+ let linked = 0;
73
+ const tx = db.transaction(() => {
74
+ db.prepare(`
75
+ INSERT INTO map_projects (project, job, audience, product_status, template, stages, related_projects, updated_at)
76
+ VALUES (@project, @job, @audience, @product_status, @template, @stages, @related_projects, unixepoch())
77
+ ON CONFLICT(project) DO UPDATE SET
78
+ job=excluded.job, audience=excluded.audience, product_status=excluded.product_status,
79
+ template=excluded.template, stages=excluded.stages, related_projects=excluded.related_projects,
80
+ updated_at=unixepoch()
81
+ `).run({
82
+ project: map.project,
83
+ job: map.job ?? null,
84
+ audience: JSON.stringify(map.audience ?? {}),
85
+ product_status: map.product_status ?? null,
86
+ template: map.template ?? null,
87
+ stages: JSON.stringify(map.stages),
88
+ related_projects: JSON.stringify(map.related_projects ?? []),
89
+ });
90
+ db.prepare('DELETE FROM map_edges WHERE project = ?').run(map.project);
91
+ db.prepare('DELETE FROM map_nodes WHERE project = ?').run(map.project);
92
+ const insNode = db.prepare(`
93
+ INSERT INTO map_nodes
94
+ (id, project, layer, stage, statement, status, facets, role, note, due,
95
+ paused_reason, related_project, spinout_candidate, anchor_id, review_by, revival_condition, reality, extra, updated_at)
96
+ VALUES
97
+ (@id, @project, @layer, @stage, @statement, @status, @facets, @role, @note, @due,
98
+ @paused_reason, @related_project, @spinout_candidate, @anchor_id, @review_by, @revival_condition, @reality, @extra, unixepoch())
99
+ `);
100
+ for (const n of map.nodes) {
101
+ if (!n.id)
102
+ continue;
103
+ let anchorId = null;
104
+ if (n.anchor != null) {
105
+ if (anchorExists.get(n.anchor)) {
106
+ anchorId = Number(n.anchor);
107
+ linked++;
108
+ }
109
+ else
110
+ warnings.push(`node ${n.id}: anchor #${n.anchor} not found — link skipped`);
111
+ }
112
+ const extra = {};
113
+ for (const k of Object.keys(n))
114
+ if (!KNOWN_NODE_COLS.has(k))
115
+ extra[k] = n[k];
116
+ insNode.run({
117
+ id: n.id,
118
+ project: map.project,
119
+ layer: n.layer ?? 'surface',
120
+ stage: n.stage ?? null,
121
+ statement: n.statement ?? '',
122
+ status: n.status ?? 'active',
123
+ facets: JSON.stringify(n.facets ?? []),
124
+ role: n.role ?? null,
125
+ note: n.note ?? null,
126
+ due: n.due ?? null,
127
+ paused_reason: n.paused_reason ?? null,
128
+ related_project: n.related_project ?? null,
129
+ spinout_candidate: n.spinout_candidate ? 1 : 0,
130
+ anchor_id: anchorId,
131
+ review_by: n.review_by ?? null,
132
+ revival_condition: n.revival_condition ?? null,
133
+ reality: JSON.stringify(n.reality ?? {}),
134
+ extra: JSON.stringify(extra),
135
+ });
136
+ }
137
+ const insEdge = db.prepare(`
138
+ INSERT OR IGNORE INTO map_edges (project, from_id, to_id, type, strength, note)
139
+ VALUES (?, ?, ?, ?, ?, ?)
140
+ `);
141
+ for (const e of map.edges)
142
+ insEdge.run(map.project, e.from, e.to, e.type, e.strength ?? null, e.note ?? null);
143
+ });
144
+ tx();
145
+ return { project: map.project, nodes: map.nodes.filter((n) => n.id).length, edges: map.edges.length, linked_anchors: linked, warnings };
146
+ }
147
+ export function importMapFile(db, path) {
148
+ return importMap(db, parseMapFile(path));
149
+ }
150
+ //# sourceMappingURL=map-import.js.map
@@ -0,0 +1,29 @@
1
+ import type Database from 'better-sqlite3';
2
+ export type Verdict = 'convergence' | 'divergence' | 'absence';
3
+ export interface CheckResult {
4
+ claim: string;
5
+ ok: boolean;
6
+ file: string | null;
7
+ line: number | null;
8
+ detail: string;
9
+ }
10
+ export interface NodeVerdict {
11
+ id: string;
12
+ status: string;
13
+ verdict: Verdict | null;
14
+ reason: string;
15
+ why: string | null;
16
+ fix: string[];
17
+ checks: CheckResult[];
18
+ evidence: Record<string, unknown>;
19
+ flipped: boolean;
20
+ }
21
+ export interface ReconcileResult {
22
+ project: string;
23
+ checked: number;
24
+ external: number;
25
+ verdicts: NodeVerdict[];
26
+ refuted: NodeVerdict[];
27
+ confirmed: NodeVerdict[];
28
+ }
29
+ export declare function reconcile(db: Database.Database, project: string, repoRoot: string): ReconcileResult;