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.
- package/LICENSE +21 -21
- package/README.md +152 -68
- package/dist/bin/import-sessions.js +6 -6
- package/dist/bin/install-skill.js +14 -14
- package/dist/bin/map-import.d.ts +2 -0
- package/dist/bin/map-import.js +493 -0
- package/dist/bin/setup.js +14 -14
- package/dist/bin/stats.js +20 -20
- package/dist/db/migrate.js +36 -0
- package/dist/db/schema.sql +73 -2
- package/dist/lib/consolidate.js +19 -19
- package/dist/lib/drift-detection.js +38 -2
- package/dist/lib/edge-detection.js +8 -8
- package/dist/lib/guard.js +28 -28
- package/dist/lib/map-import.d.ts +55 -0
- package/dist/lib/map-import.js +150 -0
- package/dist/lib/map-reconcile.d.ts +29 -0
- package/dist/lib/map-reconcile.js +245 -0
- package/dist/lib/map-view.d.ts +103 -0
- package/dist/lib/map-view.js +201 -0
- package/dist/lib/momentum.js +7 -7
- package/dist/lib/truth-engine.js +11 -11
- package/dist/mcp/read-smart.js +8 -8
- package/dist/mcp/server.js +45 -0
- package/dist/skill/SKILL.md +734 -734
- package/package.json +9 -5
package/dist/db/schema.sql
CHANGED
|
@@ -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', '
|
|
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 = '
|
|
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');
|
package/dist/lib/consolidate.js
CHANGED
|
@@ -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',
|
|
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 =
|
|
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;
|