klypix-mcp 1.29.0 → 1.31.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/bin/klypix-mcp.mjs +23 -2
- package/package.json +2 -2
- package/src/agent-rules.mjs +4 -0
- package/src/global-brain-hook.mjs +121 -4
- package/src/klypix-core.mjs +63 -6
- package/src/klypix-format.mjs +486 -20
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -55,6 +55,26 @@ if (process.argv[2] === 'link') { await import('./klypix-link.mjs'); process.exi
|
|
|
55
55
|
// projection in sync? One verdict, one reconcile block. Exits 1 on drift (CI gate).
|
|
56
56
|
if (process.argv[2] === 'doctor') { await import('./klypix-doctor.mjs'); process.exit(0); }
|
|
57
57
|
|
|
58
|
+
// `npx klypix-mcp garden-code` — the HUMAN half of the garden approval gate.
|
|
59
|
+
// brain_garden's apply requires an 8-char code derived from the exact dormant
|
|
60
|
+
// candidate set + day; the agent is deliberately never shown it. The human runs
|
|
61
|
+
// this after reviewing the agent's plan and pastes the code into chat — that
|
|
62
|
+
// paste IS the out-of-band approval. Optional arg: a brain path (default
|
|
63
|
+
// ./brain.klypix in the cwd).
|
|
64
|
+
if (process.argv[2] === 'garden-code') {
|
|
65
|
+
const target = path.resolve(process.cwd(), process.argv[3] || 'brain.klypix');
|
|
66
|
+
if (!fs.existsSync(target)) { console.error(`No brain at ${target} — run from the project folder (or pass a path).`); process.exit(1); }
|
|
67
|
+
const { parseKlypix, selectGardenCandidates } = await import('../src/klypix-format.mjs');
|
|
68
|
+
const { gardenApprovalCode } = await import('../src/klypix-core.mjs');
|
|
69
|
+
const { struct } = await parseKlypix(fs.readFileSync(target));
|
|
70
|
+
const areas = selectGardenCandidates(struct);
|
|
71
|
+
if (!areas.length) { console.error('Nothing to garden right now — no approval needed.'); process.exit(0); }
|
|
72
|
+
console.error(`Garden plan: ${areas.length} area(s) — ${areas.map(a => `${a.title} (${a.candidates.length})`).join(' · ')}`);
|
|
73
|
+
console.error(`If you approve what the agent showed you, paste this code into chat (valid today, for exactly this set):`);
|
|
74
|
+
console.log(gardenApprovalCode(areas));
|
|
75
|
+
process.exit(0);
|
|
76
|
+
}
|
|
77
|
+
|
|
58
78
|
// `npx klypix-mcp init` — 60-second onboarding: seed a starter project brain in
|
|
59
79
|
// the current folder so a new user's FIRST contact isn't an empty vault, then
|
|
60
80
|
// print a paste-ready MCP config. Runs before any server setup.
|
|
@@ -184,7 +204,7 @@ server.registerTool('brain_reconcile', {
|
|
|
184
204
|
inputSchema: {
|
|
185
205
|
canvas: z.string().optional().describe('Brain canvas filename/path. Defaults to the project brain ("brain").'),
|
|
186
206
|
root: z.string().optional().describe("Project root holding the migrations dir (default: the brain file's folder)."),
|
|
187
|
-
mode: z.enum(['all', 'contradictions', 'migrations', 'legacy']).optional().describe('Which pass to run (default "all"): contradictions · migrations · legacy (pre-v1.15 raw-bash ship cards to tidy).'),
|
|
207
|
+
mode: z.enum(['all', 'contradictions', 'migrations', 'legacy', 'claims']).optional().describe('Which pass to run (default "all"): contradictions · migrations · legacy (pre-v1.15 raw-bash ship cards to tidy) · claims (open "remaining:/next:" clauses a later milestone likely fulfilled — receipts + ✓ markers, never auto-archived).'),
|
|
188
208
|
},
|
|
189
209
|
}, async ({ canvas, root, mode }) => toContent(await opBrainReconcile({ vault: VAULT, canvas, root, mode })));
|
|
190
210
|
|
|
@@ -198,8 +218,9 @@ server.registerTool('brain_garden', {
|
|
|
198
218
|
title: z.string().describe('Area title EXACTLY as returned by the dry run.'),
|
|
199
219
|
synthesis: z.string().describe('3-6 sentence prose synthesis preserving every still-relevant fact/decision/number.'),
|
|
200
220
|
})).optional().describe('Required when apply:true — one entry per area you want consolidated.'),
|
|
221
|
+
approve: z.string().optional().describe('Required when apply:true — the 8-char human-approval code. You are never shown it: the human runs `npx klypix-mcp garden-code` and pastes the code into chat after reviewing your plan. Never guess or fabricate it.'),
|
|
201
222
|
},
|
|
202
|
-
}, async ({ canvas, apply, syntheses }) => toContent(await opBrainGarden({ vault: VAULT, canvas, apply, syntheses })));
|
|
223
|
+
}, async ({ canvas, apply, syntheses, approve }) => toContent(await opBrainGarden({ vault: VAULT, canvas, apply, syntheses, approve })));
|
|
203
224
|
|
|
204
225
|
server.registerTool('create_canvas', {
|
|
205
226
|
title: 'Create a KLYPIX canvas',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "klypix-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.31.0",
|
|
4
4
|
"description": "Every project gets a brain — one open .klypix file your AI agents read, write, and argue from, over MCP. Works with Claude, Cursor, Cline, any model.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "Apache-2.0",
|
|
@@ -53,7 +53,7 @@
|
|
|
53
53
|
"node": ">=18"
|
|
54
54
|
},
|
|
55
55
|
"scripts": {
|
|
56
|
-
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/canvas-view.mjs"
|
|
56
|
+
"test": "node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/canvas-view.mjs"
|
|
57
57
|
},
|
|
58
58
|
"dependencies": {
|
|
59
59
|
"@modelcontextprotocol/ext-apps": "^1.7.4",
|
package/src/agent-rules.mjs
CHANGED
|
@@ -261,6 +261,10 @@ function targets(projectDir) {
|
|
|
261
261
|
// Added 1.13.0 — close the "not generated at all" coverage gap the audit flagged.
|
|
262
262
|
{ tool: 'Gemini CLI', file: j('GEMINI.md'), kind: 'merge' },
|
|
263
263
|
{ tool: 'Aider', file: j('CONVENTIONS.md'), kind: 'dedicated', frontmatter: '' },
|
|
264
|
+
// Added 1.29.1 — Antigravity (Gemini IDE) reads .agents/AGENTS.md; the desktop
|
|
265
|
+
// app already writes it (projectBrainConnect.ts), this adds CLI audit coverage.
|
|
266
|
+
// kind:'merge' so the fenced block coexists with any existing content.
|
|
267
|
+
{ tool: 'Antigravity', file: j('.agents', 'AGENTS.md'), kind: 'merge' },
|
|
264
268
|
],
|
|
265
269
|
mcp: [
|
|
266
270
|
{ tool: 'Cursor', file: j('.cursor', 'mcp.json'), wrapKey: 'mcpServers', withType: false },
|
|
@@ -313,6 +313,10 @@ function touchSession(sid, patch = {}) {
|
|
|
313
313
|
// cards scrolled it out. Big cards are rare, so this cap effectively
|
|
314
314
|
// never evicts one — enforcing "no >1KB card full-text twice".
|
|
315
315
|
injectedBig: patch.injectedBig !== undefined ? patch.injectedBig : (prev.injectedBig ?? []),
|
|
316
|
+
// Per-session status-digest dedup hash (T8) — the fixed field
|
|
317
|
+
// list silently dropped unknown patch keys, which made the
|
|
318
|
+
// dedup dead code (review-caught).
|
|
319
|
+
statusDigestHash: patch.statusDigestHash !== undefined ? patch.statusDigestHash : (prev.statusDigestHash ?? null),
|
|
316
320
|
startedAt: prev.startedAt || now, lastSeen: now,
|
|
317
321
|
});
|
|
318
322
|
fs.mkdirSync(SESSIONS_DIR, { recursive: true });
|
|
@@ -559,6 +563,22 @@ function parseCommitLog(raw) {
|
|
|
559
563
|
function commitToCard(c) {
|
|
560
564
|
const m = CC_RE.exec(c.subject);
|
|
561
565
|
if (!m) return null; // only feat / fix / perf
|
|
566
|
+
// Closes:/Resolves:/Fulfils: trailer in the commit BODY → card.closes — the
|
|
567
|
+
// engine's close-link pass then archives the strategy/❓ card this ship
|
|
568
|
+
// fulfilled. This gives the highest-volume capture channel (and EVERY agent
|
|
569
|
+
// that commits — Cursor, Cline, a human — not just hook-wired ones) real
|
|
570
|
+
// resolution semantics. Before this, commit-derived 🏁 cards were
|
|
571
|
+
// lifecycle-INERT: shipping produced new truth without ever touching the
|
|
572
|
+
// stale "remaining:" claim it fulfilled (2026-07-23 field incident).
|
|
573
|
+
// Parsed from the RAW body (trailers are line-anchored) before flattening.
|
|
574
|
+
const tm = /^(?:closes|resolves|fulfils|fulfills)\s*:\s*(.+)$/im.exec(c.body);
|
|
575
|
+
let closes = tm ? tm[1].trim().replace(/\.$/, '') : '';
|
|
576
|
+
// URL / bare-issue-ref trailers ("Closes: https://github.com/...",
|
|
577
|
+
// "Closes: #123") tokenize to pure boilerplate (https/github/<org>/issues)
|
|
578
|
+
// that coverage-matches UNRELATED live cards mentioning the same host —
|
|
579
|
+
// false retirement, the worst case (adversarial review reproduced it).
|
|
580
|
+
// Only a prose target (a card title / claim) is a usable close-target.
|
|
581
|
+
if (/^https?:\/\//i.test(closes) || /^#?\d+$/.test(closes)) closes = '';
|
|
562
582
|
const body = c.body.replace(/\s+/g, ' ').trim();
|
|
563
583
|
if (body.length < 12) return null; // RATIONALE-bearing only — keeps it high-signal, not a dump
|
|
564
584
|
const type = m[1].toLowerCase(), scope = (m[2] || '').trim(), desc = m[3].trim();
|
|
@@ -567,6 +587,7 @@ function commitToCard(c) {
|
|
|
567
587
|
return {
|
|
568
588
|
text: `${area}: ${prefix}${desc}\n\n${body.slice(0, 400)}\n#${slugify(area)} #commit-${c.hash.slice(0, 7)}`,
|
|
569
589
|
area, borderColor: type === 'feat' ? 'rgba(59,130,246,0.8)' : 'rgba(16,185,129,0.6)', createdVia: 'commit',
|
|
590
|
+
...(closes ? { closes } : {}),
|
|
570
591
|
};
|
|
571
592
|
}
|
|
572
593
|
async function gatherCommitCards(prevCommit) {
|
|
@@ -579,7 +600,37 @@ async function gatherCommitCards(prevCommit) {
|
|
|
579
600
|
catch { return { cards: [], newLastCommit: head }; } // history rewritten → re-baseline, don't dump
|
|
580
601
|
let raw = '';
|
|
581
602
|
try { raw = git(`log ${prevCommit}..HEAD --no-merges --format=%x1e%H%x1f%s%x1f%b`); } catch { return { cards: [], newLastCommit: head }; }
|
|
582
|
-
|
|
603
|
+
const entries = parseCommitLog(raw);
|
|
604
|
+
// Revert retraction (same batch): a "feat: X" shipped-and-reverted before
|
|
605
|
+
// this hook ran must not close the open card X claimed to fulfil. Reverts
|
|
606
|
+
// themselves never become cards (CC_RE skips them), so scan the raw batch
|
|
607
|
+
// for Revert subjects and strip `closes` from any card whose subject was
|
|
608
|
+
// reverted. Cross-batch reverts (feat captured in an earlier run) need the
|
|
609
|
+
// P1 retraction machinery — consciously deferred, recorded in the plan.
|
|
610
|
+
const reverted = new Set();
|
|
611
|
+
for (const e of entries) { const rm = /^Revert\s+"(.+)"/.exec(e.subject); if (rm) reverted.add(rm[1].trim()); }
|
|
612
|
+
const cards = entries.slice(0, 15)
|
|
613
|
+
.map(e => { const card = commitToCard(e); if (card && reverted.has(e.subject)) delete card.closes; return card; })
|
|
614
|
+
.filter(Boolean);
|
|
615
|
+
// Ship-evidence guard: a Closes: trailer only CLOSES a live claim when the
|
|
616
|
+
// commit is on the repo's DEFAULT branch — a feature-branch ship that never
|
|
617
|
+
// merges must not retire an open card. Default branch is detected from
|
|
618
|
+
// origin/HEAD (so trunk/develop repos work), falling back to master|main;
|
|
619
|
+
// a strip leaves a HEALTH breadcrumb so the inert channel is diagnosable.
|
|
620
|
+
if (cards.some(c => c.closes)) {
|
|
621
|
+
let onDefault = false, branch = '';
|
|
622
|
+
try {
|
|
623
|
+
branch = git('rev-parse --abbrev-ref HEAD');
|
|
624
|
+
let def = '';
|
|
625
|
+
try { def = (git('symbolic-ref refs/remotes/origin/HEAD') || '').replace(/^refs\/remotes\/origin\//, ''); } catch { /* no origin/HEAD ref */ }
|
|
626
|
+
onDefault = def ? branch === def : /^(master|main)$/.test(branch);
|
|
627
|
+
} catch { onDefault = false; }
|
|
628
|
+
if (!onDefault) {
|
|
629
|
+
for (const c of cards) delete c.closes;
|
|
630
|
+
try { appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'commit-closes', ok: true, err: `closes stripped (branch ${branch || '?'} is not default)` }, 500); } catch { /* best-effort */ }
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
return { cards, newLastCommit: head };
|
|
583
634
|
}
|
|
584
635
|
|
|
585
636
|
// ── Live cross-session ledger (in-flight ship/version/milestone, 2026-06-28) ─────
|
|
@@ -1264,6 +1315,19 @@ async function capture(lib) {
|
|
|
1264
1315
|
process.stderr.write(`[brain] correction supersede: ${stats.corrections.map(c => `"${c.old}" (${c.overlap})`).join('; ')} — archived + arrowed; restore from Archive or ~ update if wrong\n`);
|
|
1265
1316
|
}
|
|
1266
1317
|
if (stats.added > 0 && !stats.linked) process.stderr.write(`[brain] note: ${stats.added} card(s) landed unlinked — \`brain_connect\` (or [[wikilinks]] next time) wires them into the graph\n`);
|
|
1318
|
+
// Fulfillment receipts (claim engine): a captured 🏁 that appears to cover a
|
|
1319
|
+
// live open claim raised a dashed hint — print the receipt with its
|
|
1320
|
+
// ready-to-emit ✓ marker AND the uncovered remainder (approving the covered
|
|
1321
|
+
// half must never silently retire the rest). Suggestion-only.
|
|
1322
|
+
if (Array.isArray(stats.fulfillCandidates) && stats.fulfillCandidates.length) {
|
|
1323
|
+
for (const f of stats.fulfillCandidates.slice(0, 4)) {
|
|
1324
|
+
const rest = f.uncovered && f.uncovered.length ? ` · does NOT cover: ${f.uncovered.map(u => `"${u.slice(0, 50)}"`).join(', ')}` : '';
|
|
1325
|
+
const act = f.marker
|
|
1326
|
+
? `— if truly done, emit: ${f.marker}`
|
|
1327
|
+
: '— PARTIAL/short: the card stays open; no ✓ suggested (verify by hand; dismiss via brain_connect relationship:"not_fulfilled")';
|
|
1328
|
+
process.stderr.write(`[brain] ⏳ likely fulfilled (${f.cov}): "${f.item.slice(0, 70)}"${rest} ${act}\n`);
|
|
1329
|
+
}
|
|
1330
|
+
}
|
|
1267
1331
|
appendJsonl(LEDGER, { ts: nowIso(), mode: 'capture', stats, decisions: ledger }, 1000);
|
|
1268
1332
|
appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: true, brainBytes: brainBytes(), added: stats.added, skipped: ledger.filter(d => d.action.startsWith('skipped')).length }, 500);
|
|
1269
1333
|
}
|
|
@@ -1343,7 +1407,33 @@ async function promptRetrieve(lib) {
|
|
|
1343
1407
|
const input = readHookInput();
|
|
1344
1408
|
const sid = input.session_id;
|
|
1345
1409
|
const prompt = input.prompt || input.user_prompt || input.userPrompt || '';
|
|
1346
|
-
|
|
1410
|
+
// Status-vocab quarantine (2026-07-23): "what is remaining?" must not
|
|
1411
|
+
// lexically retrieve the stale cards that SAY "remaining:" — status words
|
|
1412
|
+
// describe the question's shape, not its subject, and are anti-correlated
|
|
1413
|
+
// with truth. Content tokens only. A status-shaped prompt ALSO suppresses
|
|
1414
|
+
// the git-diff file-token fallback below: a stale "remaining:" card is
|
|
1415
|
+
// usually about the files being edited right now, so the fallback's
|
|
1416
|
+
// #file- tag hits re-serve the exact corpse the quarantine removed
|
|
1417
|
+
// (adversarial review traced the side door). The brief's computed "Area
|
|
1418
|
+
// status" section carries the current-state answer instead.
|
|
1419
|
+
let ptoks = lib.queryTokens(prompt);
|
|
1420
|
+
let statusShaped = false, statusStrong = false;
|
|
1421
|
+
if (typeof lib.splitQueryTokens === 'function') {
|
|
1422
|
+
const sp = lib.splitQueryTokens(prompt);
|
|
1423
|
+
ptoks = sp.content;
|
|
1424
|
+
statusShaped = sp.statusShaped;
|
|
1425
|
+
// strong = the prompt IS a status question (phrase shape / nothing but
|
|
1426
|
+
// status words); loose statusShaped only quarantines tokens. Only
|
|
1427
|
+
// STRONG may replace retrieval with the digest — "remove the TODO:
|
|
1428
|
+
// refactor X" is a work request (review fix). Older engine without
|
|
1429
|
+
// `strong` degrades to content-empty as the strong signal.
|
|
1430
|
+
statusStrong = sp.strong !== undefined ? sp.strong : (statusShaped && sp.content.length === 0);
|
|
1431
|
+
} else if (lib.STATUS_VOCAB instanceof Set) {
|
|
1432
|
+
const filtered = ptoks.filter(t => !lib.STATUS_VOCAB.has(t));
|
|
1433
|
+
statusShaped = filtered.length < ptoks.length;
|
|
1434
|
+
statusStrong = statusShaped && filtered.length === 0;
|
|
1435
|
+
ptoks = filtered;
|
|
1436
|
+
}
|
|
1347
1437
|
// Git diff is the retrieval FALLBACK for a prompt too terse to rank on. It is
|
|
1348
1438
|
// NOT used for the lane's files: two sessions in one repo share a working tree,
|
|
1349
1439
|
// so the diff is identical for both — the lane's files come from each session's
|
|
@@ -1351,7 +1441,7 @@ async function promptRetrieve(lib) {
|
|
|
1351
1441
|
const changedPaths = gitChangedPaths();
|
|
1352
1442
|
const branch = gitBranch();
|
|
1353
1443
|
touchSession(sid, { intent: String(prompt).replace(/\s+/g, ' ').trim().slice(0, 80), branch });
|
|
1354
|
-
const fileToks = ptoks.length < 2 ? changedPaths.flatMap(fileQueryTokens) : [];
|
|
1444
|
+
const fileToks = (ptoks.length < 2 && !statusShaped) ? changedPaths.flatMap(fileQueryTokens) : [];
|
|
1355
1445
|
const tokens = [...new Set(ptoks.concat(fileToks))];
|
|
1356
1446
|
// Other live sessions in THIS repo — surfaced even when the prompt retrieves
|
|
1357
1447
|
// nothing (a peer's presence/ship is itself the signal). Empty string when solo.
|
|
@@ -1400,6 +1490,32 @@ async function promptRetrieve(lib) {
|
|
|
1400
1490
|
} catch { semMode = 'sem-error'; }
|
|
1401
1491
|
if (semMode !== 'lexical') { try { appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'prompt', sem: semMode, hits: freshHits.length }, 500); } catch { /* */ } }
|
|
1402
1492
|
}
|
|
1493
|
+
// T8 STATUS-DIGEST INJECTION (2026-07-23): a status-shaped prompt gets the
|
|
1494
|
+
// COMPUTED current-state digest INSTEAD of card hits — so an agent that
|
|
1495
|
+
// never queried the brain still answers "what is remaining?" from state,
|
|
1496
|
+
// not from whichever stale claim happened to rank. REPLACES freshHits
|
|
1497
|
+
// (never additive) and is hash-deduped per session: turn 2+ of the same
|
|
1498
|
+
// status conversation gets a one-line pointer, not another 2KB.
|
|
1499
|
+
let statusMd = '';
|
|
1500
|
+
if (statusStrong && typeof lib.areaStatusDigest === 'function') {
|
|
1501
|
+
if (!struct) { try { struct = await cachedStruct(lib); } catch { struct = null; } }
|
|
1502
|
+
if (struct) {
|
|
1503
|
+
try {
|
|
1504
|
+
const digest = lib.areaStatusDigest(struct, { maxAreas: 12 });
|
|
1505
|
+
if (digest.length) {
|
|
1506
|
+
const h = crypto.createHash('sha1').update(digest.join('\n')).digest('hex').slice(0, 12);
|
|
1507
|
+
let lane = null; try { lane = readSessions().find(s => s.id === sid) || null; } catch { /* */ }
|
|
1508
|
+
if (lane && lane.statusDigestHash === h) {
|
|
1509
|
+
statusMd = '## 📊 Current state — unchanged since the digest shown earlier this session (`brain_ask` gives the full computed status view).';
|
|
1510
|
+
} else {
|
|
1511
|
+
statusMd = ['## 📊 Computed current state (status-shaped question detected — answer from THIS + `brain_ask`, never from memory of past sessions)', ...digest].join('\n');
|
|
1512
|
+
try { touchSession(sid, { statusDigestHash: h }); } catch { /* best-effort */ }
|
|
1513
|
+
}
|
|
1514
|
+
freshHits = [];
|
|
1515
|
+
}
|
|
1516
|
+
} catch { /* best-effort */ }
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1403
1519
|
// Other live sessions' in-flight events (struct may be null on a token-less
|
|
1404
1520
|
// prompt — then representedInBrain just keeps entries; they're rarely in-brain yet).
|
|
1405
1521
|
const inflight = inflightFooter(sid, struct);
|
|
@@ -1408,11 +1524,12 @@ async function promptRetrieve(lib) {
|
|
|
1408
1524
|
// leaves struct null; SessionStart's own draft surface + count line cover that
|
|
1409
1525
|
// case) and so a terse prompt pays zero extra parse cost.
|
|
1410
1526
|
const drafts = struct ? ruleDraftsFooter(sid, struct, { markShown: true }) : '';
|
|
1411
|
-
if (!repeats.length && !freshHits.length && !peers && !inflight && !messages && !drafts) return; // nothing → zero output, zero added context
|
|
1527
|
+
if (!repeats.length && !freshHits.length && !peers && !inflight && !messages && !drafts && !statusMd) return; // nothing → zero output, zero added context
|
|
1412
1528
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1413
1529
|
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
1414
1530
|
const head = (c, n = 120) => { const t = flat(c.text); return t.length > n ? t.slice(0, n - 1) + '…' : t; };
|
|
1415
1531
|
const lines = [];
|
|
1532
|
+
if (statusMd) lines.push(statusMd);
|
|
1416
1533
|
if (repeats.length) {
|
|
1417
1534
|
// A nudged "already shipped" card may itself have been CORRECTED since —
|
|
1418
1535
|
// nudging the agent to reuse a stale fact would be worse than no nudge.
|
package/src/klypix-core.mjs
CHANGED
|
@@ -28,7 +28,8 @@ import {
|
|
|
28
28
|
selectGardenCandidates, applyGarden, detectContradictions,
|
|
29
29
|
rankForQuestion, questionContextToMarkdown, findLegacyShipCards,
|
|
30
30
|
challengeBrain, challengeContextToMarkdown, buildRenderSpec, structToBrief,
|
|
31
|
-
brainLensData, lensToMarkdown,
|
|
31
|
+
brainLensData, lensToMarkdown, deathDateOfCard,
|
|
32
|
+
statusContextToMarkdown, findFulfillmentCandidates,
|
|
32
33
|
} from './klypix-format.mjs';
|
|
33
34
|
|
|
34
35
|
// ── Card / connection input shape (single source for every face) ─────────────
|
|
@@ -203,7 +204,10 @@ async function vectorsForBrain(pipe, brainPath, cards) {
|
|
|
203
204
|
for (const c of want) { const e = cache.cards[c.id]; if (e?.v) map.set(c.id, e.v); }
|
|
204
205
|
return map;
|
|
205
206
|
}
|
|
206
|
-
|
|
207
|
+
// Death-date reads go through the ONE shared reader in klypix-format —
|
|
208
|
+
// this file used to carry a divergent local regex (missing the bare
|
|
209
|
+
// "↩ superseded" variant AND the gardener's "⤵ consolidated" stamp), so
|
|
210
|
+
// cross-brain as_of was silently stricter than brain_ask as_of (2026-07-23).
|
|
207
211
|
|
|
208
212
|
// ── On-device cross-encoder reranker (brain_ask precision) ───────────────────
|
|
209
213
|
// Eval-proven on the frozen human-paraphrase set (2026-07-15): recall@5 15%→40%,
|
|
@@ -375,7 +379,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
|
|
|
375
379
|
const isArchived = /^archive$/i.test(c.area || '');
|
|
376
380
|
if (asOfTs != null) {
|
|
377
381
|
if ((c.createdAt || 0) > asOfTs) continue; // didn't exist yet
|
|
378
|
-
const died = isArchived ?
|
|
382
|
+
const died = isArchived ? deathDateOfCard(c) : null;
|
|
379
383
|
if (died != null && died <= asOfTs) continue; // already superseded then
|
|
380
384
|
}
|
|
381
385
|
let lex = 0;
|
|
@@ -457,7 +461,19 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
|
|
|
457
461
|
} catch { /* keep the pre-rerank order */ }
|
|
458
462
|
result.hits = result.hits.slice(0, kk);
|
|
459
463
|
}
|
|
460
|
-
|
|
464
|
+
// STATUS MODE (T7, 2026-07-23): a status-shaped question ("what is
|
|
465
|
+
// remaining?") gets the COMPUTED current-state section prepended — per-area
|
|
466
|
+
// digest, opens with overdue/corrected/likely-fulfilled flags, newest 🏁s.
|
|
467
|
+
// The lexical hits stay below as supporting context. Suppressed under as_of
|
|
468
|
+
// (time-travel answers stay deterministic ranked-card answers).
|
|
469
|
+
// STRONG shape only: a work request that merely mentions 'pending'/'TODO'
|
|
470
|
+
// must keep its ranked answer (review fix); the phrase-shaped "what is
|
|
471
|
+
// remaining?" gets the computed section.
|
|
472
|
+
let statusMd = '';
|
|
473
|
+
if (result.statusStrong && !timeTravel) {
|
|
474
|
+
try { statusMd = statusContextToMarkdown(struct); mode += ' + status-mode'; } catch { statusMd = ''; }
|
|
475
|
+
}
|
|
476
|
+
return { blocks: [text(stamp + statusMd + questionContextToMarkdown(q, result, { mode, as_of: timeTravel ? as_of : null }))] };
|
|
461
477
|
}
|
|
462
478
|
|
|
463
479
|
// ── brain_challenge — the adversarial brain ───────────────────────────────────
|
|
@@ -623,6 +639,32 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
|
|
|
623
639
|
}
|
|
624
640
|
}
|
|
625
641
|
|
|
642
|
+
// (1c) CLAIMS (T9, 2026-07-23) — the retroactive fulfillment sweep: live
|
|
643
|
+
// "remaining:/next:/pending:" prose clauses AND ❓/🎯 cards reconciled
|
|
644
|
+
// against LATER live 🏁 milestones via the ONE shared claim extractor. This
|
|
645
|
+
// is how corpses that predate the capture-time cross-check get found (the
|
|
646
|
+
// incident card sat fulfilled-but-open for a week). Receipts show the
|
|
647
|
+
// covered item AND the uncovered remainder so partial fulfillment can never
|
|
648
|
+
// be approved whole. Suggestion-only — every retirement is a human ✓.
|
|
649
|
+
if (mode === 'all' || mode === 'claims') {
|
|
650
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
651
|
+
const miles = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c) && /🏁/.test(c.text) && !/❓|🎯/.test(c.text));
|
|
652
|
+
const cands = findFulfillmentCandidates(struct, miles, { maxPerMilestone: 2 }).slice(0, 12);
|
|
653
|
+
if (cands.length) {
|
|
654
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
655
|
+
const lines = cands.map((c, i) =>
|
|
656
|
+
`${i + 1}. [${c.open.area || '?'}] (id ${c.open.id}) claim: “${flat(c.item).slice(0, 100)}” · coverage ${c.cov}\n`
|
|
657
|
+
+ ` · likely fulfilled by [${c.milestone.area || '?'}] ${flat(c.milestone.text).slice(0, 140)}\n`
|
|
658
|
+
+ (c.uncovered.length ? ` · ⚠️ PARTIAL — does NOT cover: ${c.uncovered.map(u => `“${flat(u).slice(0, 60)}”`).join(' · ')}\n` : '')
|
|
659
|
+
+ ((!c.uncovered.length && c.resolvable)
|
|
660
|
+
? ` · confirm: \`🧠 BRAIN [${c.open.area || 'Notes'}] ✓: ${flat(c.item).slice(0, 80)}\``
|
|
661
|
+
: ` · no ✓ suggested (${c.uncovered.length ? 'partial — the card stays open; a ✓ would engage the partial-resolve path only after full coverage' : 'item too short to resolve safely'}) — verify by hand`));
|
|
662
|
+
sections.push(`# ⏳ ${cands.length} open claim(s) a later milestone likely fulfilled\n_Candidates with receipts — nothing was changed, nothing auto-archives. A ✓ is only suggested for FULLY covered claims. Dismiss a wrong hint permanently: \`brain_connect\` with \`pairs:[{fromId:<open id>, toId:<milestone id>}]\` and \`relationship:"not_fulfilled"\` — it will never be re-suggested._\n\n${lines.join('\n')}`);
|
|
663
|
+
} else if (mode === 'claims') {
|
|
664
|
+
sections.push('✓ No fulfilled-claim candidates — no live open clause is covered by a later milestone.');
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
|
|
626
668
|
// (2) MIGRATIONS — the brain reconciled against committed external state.
|
|
627
669
|
// Migrations live in the CODE repo (usually beside brain.klypix), not in a
|
|
628
670
|
// separate canvas vault — so default the root to the brain file's folder.
|
|
@@ -651,7 +693,19 @@ export async function opBrainReconcile({ vault, canvas, root, mode = 'all' }) {
|
|
|
651
693
|
// for the CALLING agent to synthesize (the engine is pure — the model writes the
|
|
652
694
|
// prose); apply consolidates each area into a 🌿 card and archives the originals
|
|
653
695
|
// with audit arrows. Mirrors brain_connect's dry-run/apply discipline.
|
|
654
|
-
|
|
696
|
+
// HUMAN GATE (2026-07-23, hardened same day after adversarial review): apply
|
|
697
|
+
// used to be a bare flag the dry-run TEXT invited the agent to set — a
|
|
698
|
+
// model-proposes-model-approves loop with zero human in it. Apply now requires
|
|
699
|
+
// `approve: "<code>"` derived from the exact candidate set + day, and the code
|
|
700
|
+
// is deliberately NOT printed in the dry-run response — the HUMAN obtains it
|
|
701
|
+
// out-of-band by running `npx klypix-mcp garden-code` in the project (or via
|
|
702
|
+
// the app's Brain Health pill) and pastes it into chat after reviewing the
|
|
703
|
+
// plan. An agent that never showed the human the plan never gets the code.
|
|
704
|
+
// Stale approvals die when the selection or the day changes.
|
|
705
|
+
export const gardenApprovalCode = (areas) =>
|
|
706
|
+
sha1(areas.map(a => a.candidates.map(c => c.id).sort().join(',')).sort().join('|') + '|' + new Date().toISOString().slice(0, 10)).slice(0, 8);
|
|
707
|
+
|
|
708
|
+
export async function opBrainGarden({ vault, canvas, apply = false, syntheses, approve = '' }) {
|
|
655
709
|
const t = brainTarget(vault, canvas);
|
|
656
710
|
if (t.ambiguous) return ambiguousBrainErr(t.ambiguous);
|
|
657
711
|
if (!t.file) return err(`No brain found — looked for ./brain.klypix in the project, then ${vault}. Pass canvas: "<name>".`);
|
|
@@ -665,10 +719,13 @@ export async function opBrainGarden({ vault, canvas, apply = false, syntheses })
|
|
|
665
719
|
if (!apply) {
|
|
666
720
|
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
667
721
|
const body = areas.map(a => `## ${a.title} (${a.candidates.length} dormant cards)\n` + a.candidates.map(c => `- ${flat(c.text).slice(0, 240)}`).join('\n')).join('\n\n');
|
|
668
|
-
return { blocks: [text(stamp + `# 🌿 Gardener — ${areas.length} area(s) with DORMANT cards to consolidate\nThese are old, peripheral (≤1 link) cards only — hubs and still-referenced decisions were left untouched. For EACH area below, write ONE tight synthesis (3-6 sentences, plain prose, no headers) that preserves every still-relevant fact / decision / number and drops only repetition + play-by-play. Then call \`brain_garden\` again with \`apply:true
|
|
722
|
+
return { blocks: [text(stamp + `# 🌿 Gardener — ${areas.length} area(s) with DORMANT cards to consolidate\nThese are old, peripheral (≤1 link) cards only — hubs and still-referenced decisions were left untouched. For EACH area below, write ONE tight synthesis (3-6 sentences, plain prose, no headers) that preserves every still-relevant fact / decision / number and drops only repetition + play-by-play.\n\n⚠️ HUMAN APPROVAL REQUIRED — this pass ARCHIVES cards, and the approval code is NOT given to you. Show the human this plan (areas + card list + your syntheses) and ask them to run \`npx klypix-mcp garden-code\` in this project; they will paste an 8-character code into chat if — and only if — they approve. Then call \`brain_garden\` again with \`apply:true\`, \`syntheses: [{ "title": "<area title EXACTLY as shown>", "synthesis": "<text>" }, …]\` and \`approve: "<their code>"\`. Never guess or fabricate the code. Originals are archived with audit arrows — nothing is deleted; one undo un-gardens.\n\n${body}`)] };
|
|
669
723
|
}
|
|
670
724
|
|
|
671
725
|
if (!Array.isArray(syntheses) || !syntheses.length) return err('apply:true needs syntheses:[{title, synthesis}, …] — run the dry run first (apply omitted) to get the areas + their cards.');
|
|
726
|
+
if (String(approve || '').trim() !== gardenApprovalCode(areas)) {
|
|
727
|
+
return err('Garden apply requires HUMAN approval: show the human the dry-run plan + your syntheses, then ask them to run `npx klypix-mcp garden-code` in this project and paste the 8-character code — pass it as approve:"<code>". The code is never shown to you directly. (It changes when the candidate set or the day changes — if it expired, re-run the dry run and re-confirm.)');
|
|
728
|
+
}
|
|
672
729
|
try {
|
|
673
730
|
const { buffer, stats } = await applyGarden(fs.readFileSync(file), { syntheses });
|
|
674
731
|
const skippedNote = (stats.skipped && stats.skipped.length)
|
package/src/klypix-format.mjs
CHANGED
|
@@ -122,6 +122,10 @@ export async function parseKlypix(buffer) {
|
|
|
122
122
|
// Evidence anchors (file:line / PR#) with the git blob OID stamped at
|
|
123
123
|
// capture-time — lets the hook flag a card whose cited code drifted.
|
|
124
124
|
evidence: Array.isArray(it.evidence) && it.evidence.length ? it.evidence : null,
|
|
125
|
+
// Machine death-date (epoch ms) written by the gardener at
|
|
126
|
+
// consolidation — the prose "⤵ consolidated" stamp's reliable twin,
|
|
127
|
+
// so as_of time-travel never depends on parsing prose.
|
|
128
|
+
...(Number.isFinite(it.consolidatedAt) ? { consolidatedAt: it.consolidatedAt } : {}),
|
|
125
129
|
})),
|
|
126
130
|
connections: connections.map(c => ({
|
|
127
131
|
from: titleOf(c.fromId), to: titleOf(c.toId),
|
|
@@ -1110,6 +1114,42 @@ export async function arrangeBrain(buffer, opts = {}) {
|
|
|
1110
1114
|
return { buffer: work, stats };
|
|
1111
1115
|
}
|
|
1112
1116
|
|
|
1117
|
+
// ── Per-area status digest (2026-07-23 field incident) ───────────────────────
|
|
1118
|
+
// ONE computed current-state line per ACTIVE area: newest 🏁 headline + open
|
|
1119
|
+
// count. This is the fact whose tier-eviction let a stale "remaining:" claim
|
|
1120
|
+
// win a "what is remaining?" answer — the freshest milestone had fallen out of
|
|
1121
|
+
// the brief while the corpse stayed in the Open tier. O(areas), bounded, pure;
|
|
1122
|
+
// also the seed of the future brain_ask status mode (one digest assembler).
|
|
1123
|
+
export function areaStatusDigest(struct, { activeDays = 30, maxAreas = 20, now = Date.now() } = {}) {
|
|
1124
|
+
if (!struct || !Array.isArray(struct.cards)) return [];
|
|
1125
|
+
const cutoff = now - activeDays * 86_400_000;
|
|
1126
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1127
|
+
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
1128
|
+
const cut = (t, n) => { let s = String(t).slice(0, n); if (/[\uD800-\uDBFF]$/.test(s)) s = s.slice(0, -1); return s.trimEnd() + (String(t).length > n ? '…' : ''); };
|
|
1129
|
+
const byArea = new Map();
|
|
1130
|
+
for (const c of struct.cards) {
|
|
1131
|
+
if (c.type === 'container' || !(c.text || '').trim()) continue;
|
|
1132
|
+
const area = flat(c.area);
|
|
1133
|
+
if (!area || /^archive$/i.test(area)) continue;
|
|
1134
|
+
if (!byArea.has(area)) byArea.set(area, []);
|
|
1135
|
+
byArea.get(area).push(c);
|
|
1136
|
+
}
|
|
1137
|
+
const rows = [];
|
|
1138
|
+
for (const [area, cs] of byArea) {
|
|
1139
|
+
const newest = Math.max(...cs.map(c => c.createdAt || 0));
|
|
1140
|
+
if (newest < cutoff) continue; // dormant area — not "current state"
|
|
1141
|
+
const miles = cs.filter(c => /🏁/.test(c.text) && !/❓|🎯|🛠/.test(c.text)).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
1142
|
+
const opens = cs.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text));
|
|
1143
|
+
const m = miles[0];
|
|
1144
|
+
const mileTxt = m ? `last 🏁 ${day(m.createdAt)} “${cut(flat(m.text).replace(/^[^:\n]{1,40}:\s*/, '').replace(/^🏁\s*/, ''), 70)}”` : 'no 🏁 yet';
|
|
1145
|
+
rows.push({ newest, line: `- ${area} — ${mileTxt} · ${opens.length} open · latest ${day(newest)}` });
|
|
1146
|
+
}
|
|
1147
|
+
rows.sort((a, b) => b.newest - a.newest);
|
|
1148
|
+
const out = rows.slice(0, maxAreas).map(r => r.line);
|
|
1149
|
+
if (rows.length > maxAreas) out.push(`- …and ${rows.length - maxAreas} more active area(s) — search the brain.`);
|
|
1150
|
+
return out;
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1113
1153
|
// ── Tiered brain brief ───────────────────────────────────────────────────────
|
|
1114
1154
|
// A compact, token-bounded session brief: area map + open questions + recent
|
|
1115
1155
|
// decisions + milestones. Everything older stays in the file, reachable via the
|
|
@@ -1184,15 +1224,62 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
1184
1224
|
const odBadge = (c) => { const o = overdueById.get(c.id); return o ? ` · ⏰ OVERDUE — deadline ${o.date} passed ${o.daysOverdue}d ago; verify or close (✓)` : ''; };
|
|
1185
1225
|
push(`# ${struct.title} — brain brief`);
|
|
1186
1226
|
push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · tiered brief (focus + open + last ${recentDays}d headlines); full cards via klypix-canvas MCP search*`);
|
|
1227
|
+
// TIER CAPS (2026-07-23): Focus/Open/Skills used to render UNBOUNDED full
|
|
1228
|
+
// text — the "flat forever" budget only ever gated Recent/Connections, so
|
|
1229
|
+
// the brief was already 2× over budget at ~950 cards and heading for ~31k
|
|
1230
|
+
// tokens at 9000. Every tier now has a count cap + a budget guard + an
|
|
1231
|
+
// HONEST overflow line (never a silently-truncated "complete" list).
|
|
1187
1232
|
if (focus.length) {
|
|
1188
1233
|
push('', '## 📌 Human focus (cards the human placed here — act on these first)');
|
|
1189
|
-
|
|
1234
|
+
let shown = 0;
|
|
1235
|
+
for (const c of focus.slice(0, 20)) { push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`); shown++; }
|
|
1236
|
+
if (shown < focus.length) push(`- ⚠️ …and ${focus.length - shown} MORE focus card(s) — read them (MCP search) before acting; this list is NOT complete.`);
|
|
1237
|
+
}
|
|
1238
|
+
if (open.length) {
|
|
1239
|
+
push('', `## Open questions & goals (${open.length}${overdueById.size ? `, ${overdueById.size} ⏰ overdue` : ''})`);
|
|
1240
|
+
// Overdue first, then newest — the card that falls off the bottom must
|
|
1241
|
+
// never be a passed deadline.
|
|
1242
|
+
const sorted = open.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (b.createdAt || 0) - (a.createdAt || 0));
|
|
1243
|
+
let shown = 0;
|
|
1244
|
+
for (const c of sorted) {
|
|
1245
|
+
if (shown >= 40 || used > BUDGET_CHARS * 0.55) break;
|
|
1246
|
+
push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`);
|
|
1247
|
+
shown++;
|
|
1248
|
+
}
|
|
1249
|
+
if (shown < open.length) push(`- …and ${open.length - shown} more open item(s) — ask the brain (brain_ask) rather than assuming this list is complete.`);
|
|
1190
1250
|
}
|
|
1191
|
-
if (open.length) { push('', '## Open questions & goals'); for (const c of open) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`); }
|
|
1192
1251
|
if (skills.length) {
|
|
1193
1252
|
push('', '## 🛠️ Skills — how we do things here (reusable; applies every session)');
|
|
1194
|
-
|
|
1195
|
-
|
|
1253
|
+
let shown = 0;
|
|
1254
|
+
for (const c of skills.slice(0, maxSkills)) {
|
|
1255
|
+
if (used > BUDGET_CHARS * 0.85) break;
|
|
1256
|
+
push(`- ${fr(c)}${flat(c.text)}`);
|
|
1257
|
+
shown++;
|
|
1258
|
+
}
|
|
1259
|
+
if (shown < skills.length) push(`- …and ${skills.length - shown} more skill(s) — search the brain.`);
|
|
1260
|
+
}
|
|
1261
|
+
// ⏳ Likely fulfilled (claim engine) — dashed capture-time hints that a
|
|
1262
|
+
// shipped 🏁 covers a live open claim. Suggestion-only: the open card stays
|
|
1263
|
+
// live until a human ✓; this section exists so the hint is SEEN (the
|
|
1264
|
+
// incident: a fulfilled claim kept surfacing as still-to-do for a week).
|
|
1265
|
+
{
|
|
1266
|
+
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
1267
|
+
const likely = [];
|
|
1268
|
+
for (const cn of struct.connections || []) {
|
|
1269
|
+
if (cn.label !== 'likely closed by') continue;
|
|
1270
|
+
const o = byId.get(cn.fromId), m = byId.get(cn.toId);
|
|
1271
|
+
// BOTH endpoints must be live: a since-archived/superseded milestone
|
|
1272
|
+
// no longer vouches (mirror fulfillmentOverlaysFor — a reverted ship
|
|
1273
|
+
// must not keep whispering "likely done" in the brief).
|
|
1274
|
+
if (!o || !m || /^archive$/i.test(o.area || '') || /↩|✅|⤵/.test(o.text || '')) continue;
|
|
1275
|
+
if (/^archive$/i.test(m.area || '') || /↩|⤵/.test(m.text || '')) continue;
|
|
1276
|
+
likely.push({ o, m });
|
|
1277
|
+
}
|
|
1278
|
+
if (likely.length) {
|
|
1279
|
+
push('', '## ⏳ Likely fulfilled — a milestone appears to cover these opens (confirm with a ✓ marker, or ignore)');
|
|
1280
|
+
for (const { o, m } of likely.slice(0, 5)) push(`- ${headline(o, 90)} ← likely closed by → “${headline(m, 90)}”`);
|
|
1281
|
+
if (likely.length > 5) push(`- …and ${likely.length - 5} more — \`brain_reconcile\` mode:"claims" lists all with coverage receipts.`);
|
|
1282
|
+
}
|
|
1196
1283
|
}
|
|
1197
1284
|
// ⚠️ Conflicts — pairs flagged conflicts_with (e.g. by parallel sessions);
|
|
1198
1285
|
// surfaced HIGH so the next session reconciles them, not buries them.
|
|
@@ -1202,6 +1289,13 @@ export function structToBrief(struct, { recentDays = 14, maxRecent = 40, maxMile
|
|
|
1202
1289
|
.filter(c => !/^archive$/i.test(c.title || ''))
|
|
1203
1290
|
.map(c => `${flat(c.title)} (${texts.filter(t => t.parentId === c.id).length})`);
|
|
1204
1291
|
if (areaCounts.length) { push('', '## Areas', areaCounts.join(' · ')); }
|
|
1292
|
+
// Computed current-state row — one line per ACTIVE area (newest 🏁 + open
|
|
1293
|
+
// count). Sits ABOVE Milestones so tier pressure squeezes history, never
|
|
1294
|
+
// the "where does each area stand today" answer (the 2026-07-23 incident:
|
|
1295
|
+
// the portal-shipped 🏁 fell out of the milestone tier and a week-old
|
|
1296
|
+
// "remaining:" claim answered a status question).
|
|
1297
|
+
const digest = areaStatusDigest(struct);
|
|
1298
|
+
if (digest.length) { push('', '## Area status (computed — newest 🏁 + open count per active area)'); for (const l of digest) push(l); }
|
|
1205
1299
|
if (miles.length) {
|
|
1206
1300
|
push('', '## Milestones');
|
|
1207
1301
|
for (const c of miles.sort((a, b) => b.createdAt - a.createdAt).slice(0, maxMilestones)) push(`- ${fr(c)}${headline(c)}`);
|
|
@@ -1324,6 +1418,36 @@ const STOPWORDS = new Set(['the', 'and', 'for', 'that', 'this', 'with', 'from',
|
|
|
1324
1418
|
export function queryTokens(s) {
|
|
1325
1419
|
return [...new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{2,}/g) || [])].filter(t => !STOPWORDS.has(t));
|
|
1326
1420
|
}
|
|
1421
|
+
// ── Status-vocab quarantine (2026-07-23 field incident) ──────────────────────
|
|
1422
|
+
// Words that describe the SHAPE of a status question ("what is remaining?"),
|
|
1423
|
+
// not its subject. As content tokens they are adversarially ANTI-correlated
|
|
1424
|
+
// with truth: cards *saying* "remaining/pending" are exactly the stale claims,
|
|
1425
|
+
// while the milestones that fulfilled them ("shipped the web portal") share
|
|
1426
|
+
// zero tokens with the question — so lexical scoring structurally prefers the
|
|
1427
|
+
// corpse. Quarantined out of QUERY scoring only (card tokenSets untouched —
|
|
1428
|
+
// claim-matching thresholds were calibrated on them). Deliberately narrow:
|
|
1429
|
+
// polysemous words (open, left, next, done, state) stay content tokens and are
|
|
1430
|
+
// caught by the phrase-level shape regex instead.
|
|
1431
|
+
// 'status'/'progress' are NOT here — "sync status indicator" / "progress bar"
|
|
1432
|
+
// are real subjects (adversarial review 2026-07-23); the phrase regex below
|
|
1433
|
+
// still catches "current status"-style question shapes.
|
|
1434
|
+
export const STATUS_VOCAB = new Set(['remaining', 'remains', 'pending', 'outstanding', 'todo', 'todos', 'unfinished', 'awaits', 'awaiting']);
|
|
1435
|
+
// No bare \bto-?do\b — it matched the word "TODO" anywhere ("remove the TODO:
|
|
1436
|
+
// refactor X" is a work request, review-caught); to-do only counts inside a
|
|
1437
|
+
// question shape ("what's still to do").
|
|
1438
|
+
const STATUS_QUERY_RE = /\bwhat(?:'?s| is| are)\s+(?:still\s+)?(?:left|remaining|next|open|pending|outstanding|to\s*do|the status)\b|\bstill\s+(?:open|left|pending|remaining|to\s*do)\b|\bwhere (?:are we|do we stand)\b|\bcurrent (?:state|status)\b/i;
|
|
1439
|
+
export function splitQueryTokens(s) {
|
|
1440
|
+
const all = queryTokens(s);
|
|
1441
|
+
const content = all.filter(t => !STATUS_VOCAB.has(t));
|
|
1442
|
+
const statusShaped = content.length < all.length || STATUS_QUERY_RE.test(String(s || ''));
|
|
1443
|
+
// strong = the prompt IS a status question (phrase-shape match, or nothing
|
|
1444
|
+
// but status words). Loose statusShaped merely quarantines tokens; only
|
|
1445
|
+
// STRONG may replace retrieval with the computed digest — "remove the
|
|
1446
|
+
// TODO: refactor App.tsx" is a work request, not a status question
|
|
1447
|
+
// (review: one incidental 'pending'/'todo' token wiped targeted recall).
|
|
1448
|
+
const strong = STATUS_QUERY_RE.test(String(s || '')) || (statusShaped && content.length === 0);
|
|
1449
|
+
return { content, status: all.filter(t => STATUS_VOCAB.has(t)), statusShaped, strong };
|
|
1450
|
+
}
|
|
1327
1451
|
const wordsOf = (s) => new Set(String(s || '').toLowerCase().match(/[a-z0-9][a-z0-9_-]{1,}/g) || []);
|
|
1328
1452
|
export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2, recentDays = 30 } = {}) {
|
|
1329
1453
|
const tokens = Array.isArray(query) ? query.filter(Boolean) : queryTokens(query);
|
|
@@ -1382,10 +1506,22 @@ export function scoreCardsAgainstQuery(struct, query, { topK = 6, minScore = 2,
|
|
|
1382
1506
|
// 3. Truth-aware — every stale hit carries its live CORRECTION (the P1 machinery),
|
|
1383
1507
|
// so the agent answers from the correction, never the outdated card alone.
|
|
1384
1508
|
// Pure + node-runnable. `semantic` is Map<cardId, 0..1> or null.
|
|
1385
|
-
|
|
1509
|
+
// THE one death-date reader (2026-07-23: three divergent regex copies had
|
|
1510
|
+
// shipped — core's as_of missed "↩ superseded", and BOTH copies missed the
|
|
1511
|
+
// gardener's "⤵ consolidated" stamp, so every gardened card silently vanished
|
|
1512
|
+
// from time-travel). Accepts a struct card (machine `consolidatedAt` wins) or
|
|
1513
|
+
// a raw text string; core imports this instead of keeping a local twin.
|
|
1514
|
+
export const deathDateOfCard = (card) => {
|
|
1515
|
+
const c = card && typeof card === 'object' ? card : { text: card };
|
|
1516
|
+
if (Number.isFinite(c.consolidatedAt) && c.consolidatedAt > 0) return c.consolidatedAt;
|
|
1517
|
+
const m = /(?:↩︎ superseded|↩ superseded|✅|⤵ consolidated) (\d{4}-\d{2}-\d{2})/.exec(String(c.text || ''));
|
|
1518
|
+
return m ? Date.parse(m[1]) : null;
|
|
1519
|
+
};
|
|
1386
1520
|
export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30 } = {}) {
|
|
1387
|
-
|
|
1388
|
-
|
|
1521
|
+
// Status-shaped questions score by their CONTENT tokens only — "remaining"
|
|
1522
|
+
// must never lexically select the stale cards that say "remaining:".
|
|
1523
|
+
const { content: tokens, statusShaped, strong: statusStrong } = splitQueryTokens(question);
|
|
1524
|
+
if (!struct || !Array.isArray(struct.cards) || (!tokens.length && !semantic)) return { hits: [], total: 0, tokens, statusShaped, statusStrong };
|
|
1389
1525
|
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1390
1526
|
const asOfTs = as_of ? Date.parse(as_of) : null;
|
|
1391
1527
|
const timeTravel = asOfTs != null && Number.isFinite(asOfTs);
|
|
@@ -1402,7 +1538,7 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
|
|
|
1402
1538
|
// truth then. If it died by as_of, or carries NO dated stamp (we
|
|
1403
1539
|
// can't prove it was still live), exclude it — precision-first, so
|
|
1404
1540
|
// a "what was true then" answer never asserts a since-dead fact.
|
|
1405
|
-
const died = deathDateOfCard(c
|
|
1541
|
+
const died = deathDateOfCard(c);
|
|
1406
1542
|
if (died == null || died <= asOfTs) continue;
|
|
1407
1543
|
}
|
|
1408
1544
|
}
|
|
@@ -1439,8 +1575,12 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
|
|
|
1439
1575
|
// as they were.
|
|
1440
1576
|
let overlays = new Map();
|
|
1441
1577
|
if (!timeTravel) { try { overlays = correctionOverlaysFor(struct, top.map(h => h.card)); } catch { /* best-effort */ } }
|
|
1442
|
-
|
|
1443
|
-
|
|
1578
|
+
// Fulfillment overlays render BELOW corrections (precedence: a correction
|
|
1579
|
+
// is established truth; a fulfills-hint is a suggestion awaiting a ✓).
|
|
1580
|
+
let fulfills = new Map();
|
|
1581
|
+
if (!timeTravel) { try { fulfills = fulfillmentOverlaysFor(struct, top.map(h => h.card)); } catch { /* best-effort */ } }
|
|
1582
|
+
const hits = top.map(h => ({ ...h, correction: overlays.get(h.card.id) || null, fulfillment: fulfills.get(h.card.id) || null }));
|
|
1583
|
+
return { hits, total: scored.length, tokens, statusShaped, statusStrong };
|
|
1444
1584
|
}
|
|
1445
1585
|
|
|
1446
1586
|
// Assemble the ranked hits into a SYNTHESIS-READY markdown context: a header that
|
|
@@ -1468,6 +1608,9 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
|
|
|
1468
1608
|
let block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n${flat(c.text)}`;
|
|
1469
1609
|
if (h.correction) {
|
|
1470
1610
|
block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
|
|
1611
|
+
} else if (h.fulfillment) {
|
|
1612
|
+
// Precedence: a correction outranks a fulfills-hint (never stack both).
|
|
1613
|
+
block += `\n\n ⏳ LIKELY FULFILLED — a newer milestone appears to cover this open item: “${flat(h.fulfillment.by).slice(0, 200)}”. Verify before treating it as still-to-do; confirm with a ✓ marker if done.`;
|
|
1471
1614
|
}
|
|
1472
1615
|
if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.length - shown} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
|
|
1473
1616
|
out.push(block, '');
|
|
@@ -1477,6 +1620,60 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
|
|
|
1477
1620
|
return out.join('\n') + '\n';
|
|
1478
1621
|
}
|
|
1479
1622
|
|
|
1623
|
+
// ── Status mode (T7, 2026-07-23) — the computed answer for status questions ──
|
|
1624
|
+
// "What is remaining?" must be answered from STATE, not lexical matching:
|
|
1625
|
+
// status vocabulary is anti-correlated with truth (cards saying "remaining"
|
|
1626
|
+
// are the stale ones; the ships that falsified them share zero tokens with
|
|
1627
|
+
// the question). Deterministic O(cards) assembly, no embeddings: per-area
|
|
1628
|
+
// digest + open items (overdue first, with correction/fulfillment flags) +
|
|
1629
|
+
// likely-fulfilled hints + newest milestones. Rendered ABOVE the ranked hits.
|
|
1630
|
+
export function statusContextToMarkdown(struct, { maxOpen = 12, budgetChars = 3200 } = {}) {
|
|
1631
|
+
if (!struct || !Array.isArray(struct.cards)) return '';
|
|
1632
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
1633
|
+
const day = (ts) => ts ? new Date(ts).toISOString().slice(0, 10) : '';
|
|
1634
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
1635
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim() && !isArchived(c));
|
|
1636
|
+
const out = [];
|
|
1637
|
+
let used = 0;
|
|
1638
|
+
const push = (l) => { if (used < budgetChars) { out.push(l); used += l.length + 1; } };
|
|
1639
|
+
push('# Computed current state (status mode)');
|
|
1640
|
+
push('_A status-shaped question answers from THIS computed section first — the ranked cards below are supporting context only. A card marked ⏳ or ⚠️ must not be reported as plainly still-open._');
|
|
1641
|
+
push('');
|
|
1642
|
+
for (const l of areaStatusDigest(struct, { maxAreas: 14 })) push(l);
|
|
1643
|
+
// Resolved-but-not-archived cards (✅/↩/⤵ in text) are NOT open — every
|
|
1644
|
+
// sibling lifecycle matcher carries this guard (review parity fix).
|
|
1645
|
+
const opens = live.filter(c => /❓|🎯/.test(c.text) && !/🛠/.test(c.text) && !/↩|✅|⤵/.test(c.text));
|
|
1646
|
+
if (opens.length) {
|
|
1647
|
+
const overdueById = findOverdueOpenCards(struct).byId;
|
|
1648
|
+
const sorted = opens.slice().sort((a, b) => (overdueById.has(b.id) ? 1 : 0) - (overdueById.has(a.id) ? 1 : 0) || (b.createdAt || 0) - (a.createdAt || 0));
|
|
1649
|
+
// Slice FIRST, overlay the slice — running correction/fulfillment
|
|
1650
|
+
// overlays across ALL opens was a cards×cards-shaped pass per status
|
|
1651
|
+
// question (review scale finding).
|
|
1652
|
+
const top = sorted.slice(0, maxOpen);
|
|
1653
|
+
const fulfills = fulfillmentOverlaysFor(struct, top);
|
|
1654
|
+
let overlays = new Map();
|
|
1655
|
+
try { overlays = correctionOverlaysFor(struct, top); } catch { /* best-effort */ }
|
|
1656
|
+
push('');
|
|
1657
|
+
push(`## Open (${opens.length})`);
|
|
1658
|
+
let shown = 0;
|
|
1659
|
+
for (const c of top) {
|
|
1660
|
+
if (used > budgetChars) break;
|
|
1661
|
+
const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? ' ⏳likely-fulfilled' : ''}`;
|
|
1662
|
+
push(`- [${flat(c.area) || 'Notes'}]${flags} ${flat(c.text).slice(0, 140)}`);
|
|
1663
|
+
shown++;
|
|
1664
|
+
}
|
|
1665
|
+
if (shown < opens.length) push(`- …and ${opens.length - shown} more open item(s).`);
|
|
1666
|
+
}
|
|
1667
|
+
const miles = live.filter(c => /🏁/.test(c.text) && !/❓|🎯|🛠/.test(c.text)).sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0));
|
|
1668
|
+
if (miles.length) {
|
|
1669
|
+
push('');
|
|
1670
|
+
push('## Newest milestones');
|
|
1671
|
+
for (const m of miles.slice(0, 3)) push(`- [${flat(m.area) || '?'}] ${day(m.createdAt)} ${flat(m.text).slice(0, 130)}`);
|
|
1672
|
+
}
|
|
1673
|
+
push('');
|
|
1674
|
+
return out.join('\n') + '\n';
|
|
1675
|
+
}
|
|
1676
|
+
|
|
1480
1677
|
// ── External-state reconcile — migration omission tripwire ───────────────────
|
|
1481
1678
|
// The brain is a NARRATION-capture system: a fact exists only if someone wrote a
|
|
1482
1679
|
// 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
|
|
@@ -2381,7 +2578,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2381
2578
|
// corrections[] lists cross-area/low-bar supersedes driven by a correction
|
|
2382
2579
|
// cue, so the surface (brain_note result / capture stderr) can say WHAT was
|
|
2383
2580
|
// archived and how to undo — the confirmation channel for the widened match.
|
|
2384
|
-
const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [] };
|
|
2581
|
+
const stats = { added: 0, superseded: 0, resolved: 0, linked: 0, updated: 0, closed: 0, merged: 0, corrections: [], fulfillCandidates: [] };
|
|
2385
2582
|
|
|
2386
2583
|
// Pass 1 — resolutions + supersede marking operate on EXISTING cards.
|
|
2387
2584
|
if (resolutions.length || cards.length || updates.length) {
|
|
@@ -2460,6 +2657,12 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2460
2657
|
for (const c of liveTextCards()) {
|
|
2461
2658
|
if (r.area && (c.area || '').toLowerCase() !== r.area.toLowerCase()) continue;
|
|
2462
2659
|
if (/🛠/.test(c.text)) continue; // skills are standing reference — a ✓ must never archive one (mirror the supersede guard)
|
|
2660
|
+
// A ✓ closes opens/claims, never a pure milestone — without this
|
|
2661
|
+
// a ✓ for a fulfilled claim could near-tie the very 🏁 that
|
|
2662
|
+
// fulfilled it (item text ⊆ milestone) and archive the milestone
|
|
2663
|
+
// too (review-traced). 🏁 cards that CARRY an open clause stay
|
|
2664
|
+
// eligible — they resolve via the partial path below.
|
|
2665
|
+
if (/🏁/.test(c.text) && !extractOpenClauses(c.text).length) continue;
|
|
2463
2666
|
const s = overlapScore(rTok, tokenSet(c.text)) + (/❓|🎯/.test(c.text) ? 0.15 : 0);
|
|
2464
2667
|
if (s > 0) cands.push({ c, s });
|
|
2465
2668
|
}
|
|
@@ -2470,6 +2673,27 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2470
2673
|
: [];
|
|
2471
2674
|
if (set.length) {
|
|
2472
2675
|
for (const { c: best } of set) {
|
|
2676
|
+
// PARTIAL-RESOLVE GUARD (critical, 2026-07-23): "close ONLY
|
|
2677
|
+
// the covered part" is now mechanical, not a promise. When
|
|
2678
|
+
// the ✓ text covers a strict subset of a multi-item clause,
|
|
2679
|
+
// or the card is itself a 🏁 fact card with a clause tail,
|
|
2680
|
+
// the card is NOT archived: a dated ✔ partial note lands and
|
|
2681
|
+
// the remainder stays live. (✔ ≠ ✅ — the card remains
|
|
2682
|
+
// visible to every live-lifecycle matcher.)
|
|
2683
|
+
const items = extractOpenClauses(best.text).flatMap(cl => cl.items);
|
|
2684
|
+
const coveredItems = items.filter(it => coverageOf(it.tokens, rTok) >= 0.8);
|
|
2685
|
+
const uncoveredItems = items.filter(it => coverageOf(it.tokens, rTok) < 0.5);
|
|
2686
|
+
const partial = (coveredItems.length && uncoveredItems.length) || (/🏁/.test(best.text) && items.length > 0);
|
|
2687
|
+
if (partial) {
|
|
2688
|
+
const still = uncoveredItems.length ? ` — still open: ${uncoveredItems.map(x => x.text.slice(0, 50)).join(' + ').slice(0, 160)}` : '';
|
|
2689
|
+
await rewriteCard(best.id, j => {
|
|
2690
|
+
j.content = `${j.content}\n✔ partial ${today}: ${r.text.slice(0, 100)}${still}`;
|
|
2691
|
+
j.borderColor = 'rgba(16,185,129,0.45)';
|
|
2692
|
+
});
|
|
2693
|
+
best.text += `\n✔ partial ${today}: ${r.text}`;
|
|
2694
|
+
stats.partialResolved = (stats.partialResolved || 0) + 1;
|
|
2695
|
+
continue;
|
|
2696
|
+
}
|
|
2473
2697
|
await rewriteCard(best.id, j => {
|
|
2474
2698
|
j.content = `${j.content}\n✅ ${today}: ${r.text}`;
|
|
2475
2699
|
j.borderColor = 'rgba(16,185,129,0.35)';
|
|
@@ -2479,7 +2703,10 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2479
2703
|
stats.resolved++;
|
|
2480
2704
|
}
|
|
2481
2705
|
} else {
|
|
2482
|
-
|
|
2706
|
+
// __fromResolve: an unmatched-✓ fallback card must not seed the
|
|
2707
|
+
// fulfillment cross-check (it would cover its own source item at
|
|
2708
|
+
// cov 1.0 and self-feed a suggestion loop — review-traced).
|
|
2709
|
+
milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)', __fromResolve: true });
|
|
2483
2710
|
}
|
|
2484
2711
|
}
|
|
2485
2712
|
|
|
@@ -2497,8 +2724,30 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2497
2724
|
}
|
|
2498
2725
|
if (best && bestScore >= UPDATE_AT) {
|
|
2499
2726
|
const tag = u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : '';
|
|
2727
|
+
// NON-DESTRUCTIVE CONFIRM GUARD (2026-07-23): content replacement
|
|
2728
|
+
// is wholesale, so a terse "~ still true" against a rich card used
|
|
2729
|
+
// to truncate it to the confirmation text — the system had no
|
|
2730
|
+
// non-destructive verify verb at all. The guard is OPT-IN by
|
|
2731
|
+
// shape: only an update that READS as a confirmation ("still
|
|
2732
|
+
// true", "confirmed", "verified", "unchanged", "holds") is
|
|
2733
|
+
// appended as a re-affirmed line; anything else — including
|
|
2734
|
+
// same-vocabulary inversions, terse lowercase corrections, and
|
|
2735
|
+
// negations, none of which token-set/length heuristics can see
|
|
2736
|
+
// (adversarial review live-traced all three) — REPLACES, the
|
|
2737
|
+
// documented ~ semantics where the new text wins. Digits or
|
|
2738
|
+
// contrast/negation words disqualify ("still true but port now
|
|
2739
|
+
// 9223" is a correction). No ✅/↩/⤵ glyphs in the stamp — those
|
|
2740
|
+
// would drop the card out of liveTextCards matching forever.
|
|
2741
|
+
const bestLen = String(best.text || '').length;
|
|
2742
|
+
const CONFIRM_CUE_RE = /^\s*(?:\(|")?\s*(?:still\b|confirmed?\b|verified\b|unchanged\b|re-?affirmed?\b|holds\b|remains true\b)/i;
|
|
2743
|
+
const CONFIRM_DISQUALIFIER_RE = /\d|\b(?:not|no|never|but|instead|now|except|however|wrong|longer|actually)\b/i;
|
|
2744
|
+
const isTerseConfirm = CONFIRM_CUE_RE.test(String(u.text || ''))
|
|
2745
|
+
&& !CONFIRM_DISQUALIFIER_RE.test(String(u.text || ''))
|
|
2746
|
+
&& String(u.text || '').length < bestLen * 0.6
|
|
2747
|
+
&& !hasCorrectionCue(u.text);
|
|
2500
2748
|
await rewriteCard(best.id, j => {
|
|
2501
|
-
j.content =
|
|
2749
|
+
if (isTerseConfirm) j.content = `${j.content}\n(re-affirmed ${today}: ${u.text})`;
|
|
2750
|
+
else j.content = (u.area ? `${u.area}: ` : '') + u.text + tag;
|
|
2502
2751
|
j.createdAt = now;
|
|
2503
2752
|
j.borderColor = 'rgba(16,185,129,0.6)';
|
|
2504
2753
|
if (u.createdVia) j.createdVia = String(u.createdVia);
|
|
@@ -2506,7 +2755,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2506
2755
|
// verifiedAt), so confirming/correcting a drifted fact marks it ✅.
|
|
2507
2756
|
if (Array.isArray(u.evidence) && u.evidence.length) j.evidence = u.evidence;
|
|
2508
2757
|
});
|
|
2509
|
-
best.text = u.text;
|
|
2758
|
+
best.text = isTerseConfirm ? `${best.text}\n(re-affirmed ${today}: ${u.text})` : u.text;
|
|
2510
2759
|
stats.updated++;
|
|
2511
2760
|
} else {
|
|
2512
2761
|
cards.push({ text: (u.area ? `${u.area}: ` : '') + u.text + (u.area ? `\n#${u.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}` : ''), area: u.area, createdVia: u.createdVia, ...(Array.isArray(u.evidence) && u.evidence.length ? { evidence: u.evidence } : {}) });
|
|
@@ -2633,6 +2882,38 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2633
2882
|
}
|
|
2634
2883
|
|
|
2635
2884
|
cards.push(...milestonesFallback);
|
|
2885
|
+
|
|
2886
|
+
// FULFILLMENT CROSS-CHECK (T6, 2026-07-23) — the write-side twin of the
|
|
2887
|
+
// incident fix: a new 🏁 that carries no explicit closes: still gets
|
|
2888
|
+
// reconciled against live open claims (prose "remaining:" clauses AND
|
|
2889
|
+
// ❓/🎯 cards) via the ONE shared extractor. Output is a dashed "likely
|
|
2890
|
+
// closed by" edge + a coverage receipt (covers / does NOT cover) that
|
|
2891
|
+
// the hook prints with a ready-to-emit ✓ marker. NEVER archives —
|
|
2892
|
+
// commit-derived milestones used to be lifecycle-INERT (the supersede
|
|
2893
|
+
// pass skips 🏁 by design); now they at least raise their hand.
|
|
2894
|
+
for (const card of cards) {
|
|
2895
|
+
if (!/🏁/.test(card.text) || card.__fromResolve) continue;
|
|
2896
|
+
const closed = new Set(card.__closesIds || []);
|
|
2897
|
+
const cands = findFulfillmentCandidates(struct, [{ text: card.text, createdAt: now }], { maxPerMilestone: 2 })
|
|
2898
|
+
.filter(c => !closed.has(c.open.id));
|
|
2899
|
+
if (!cands.length) continue;
|
|
2900
|
+
card.__fulfills = cands.map(c => c.open.id);
|
|
2901
|
+
for (const c of cands) {
|
|
2902
|
+
stats.fulfillCandidates.push({
|
|
2903
|
+
open: String(c.open.text || '').replace(/\s+/g, ' ').slice(0, 90),
|
|
2904
|
+
area: c.open.area || null,
|
|
2905
|
+
item: c.item,
|
|
2906
|
+
uncovered: c.uncovered,
|
|
2907
|
+
cov: c.cov,
|
|
2908
|
+
// A ✓ is suggested ONLY when the claim is FULLY covered and
|
|
2909
|
+
// its tokens can actually clear the resolve matcher's ≥4
|
|
2910
|
+
// floor — a partial ✓ used to archive the whole card
|
|
2911
|
+
// (critical review finding), and a tiny-item ✓ resolved
|
|
2912
|
+
// nothing and spawned a junk 🏁 fallback instead.
|
|
2913
|
+
marker: (!c.uncovered.length && c.resolvable) ? `🧠 BRAIN [${c.open.area || 'Notes'}] ✓: ${c.item.slice(0, 80)}` : null,
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
}
|
|
2636
2917
|
stampBrainKind(manifest);
|
|
2637
2918
|
work = await finalizeBrainZip(zip, canvas, manifest, now);
|
|
2638
2919
|
}
|
|
@@ -2647,9 +2928,9 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2647
2928
|
const rand = () => Math.random().toString(36).slice(2, 10);
|
|
2648
2929
|
canvas.connections = Array.isArray(canvas.connections) ? canvas.connections : [];
|
|
2649
2930
|
const hasConn = (a, b) => canvas.connections.some(cn => (cn.fromId === a && cn.toId === b) || (cn.fromId === b && cn.toId === a));
|
|
2650
|
-
const addConn = (fromId, toId, label, relationship) => {
|
|
2931
|
+
const addConn = (fromId, toId, label, relationship, opts = {}) => {
|
|
2651
2932
|
if (!fromId || !toId || fromId === toId || hasConn(fromId, toId)) return;
|
|
2652
|
-
canvas.connections.push({ id: `con_${rand()}`, fromId, toId, relationship, label, arrowHead: true, width: 2, color: '#10b981', style: 'solid' });
|
|
2933
|
+
canvas.connections.push({ id: `con_${rand()}`, fromId, toId, relationship, label, arrowHead: true, width: opts.width ?? 2, color: opts.color ?? '#10b981', style: opts.style ?? 'solid' });
|
|
2653
2934
|
stats.linked++;
|
|
2654
2935
|
};
|
|
2655
2936
|
// Locate each appended card by exact text match (newest first wins).
|
|
@@ -2671,6 +2952,9 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
|
|
|
2671
2952
|
newIds.add(created.id);
|
|
2672
2953
|
if (card.__supersedes) addConn(card.__supersedes, created.id, 'superseded by', undefined);
|
|
2673
2954
|
for (const cid of (card.__closesIds || [])) addConn(cid, created.id, 'closed by', undefined);
|
|
2955
|
+
// Dashed, muted hint — a suggestion must never render pixel-identical
|
|
2956
|
+
// to a confirmed 'closed by' verdict edge on the human's canvas.
|
|
2957
|
+
for (const cid of (card.__fulfills || [])) addConn(cid, created.id, 'likely closed by', undefined, { style: 'dashed', width: 1.5, color: 'rgba(16,185,129,0.55)' });
|
|
2674
2958
|
for (const link of (created.links || [])) {
|
|
2675
2959
|
const want = String(link).trim().toLowerCase();
|
|
2676
2960
|
if (!want) continue;
|
|
@@ -2754,7 +3038,7 @@ export function selectGardenCandidates(struct, { keepNewest = GARDEN_KEEP_NEWEST
|
|
|
2754
3038
|
const title = (ctn.title || '').trim();
|
|
2755
3039
|
if (!title || GARDEN_PROTECTED.test(title)) continue;
|
|
2756
3040
|
const children = struct.cards
|
|
2757
|
-
.filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() &&
|
|
3041
|
+
.filter(c => c.type === 'text' && c.parentId === ctn.id && (c.text || '').trim() && !/⤵|↩|✅|🛠|❓|🎯/.test(c.text)) // 🛠️ skills are standing reference — never consolidate them away; ❓/🎯 are OPEN items — dormancy is not resolution, and consolidating one is silent false-retirement (2026-07-23 audit; GARDEN_PROTECTED only shields containers *titled* "open questions")
|
|
2758
3042
|
.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
|
|
2759
3043
|
const autoCutoff = now - GARDEN_AUTO_MIN_AGE_DAYS * 86_400_000;
|
|
2760
3044
|
const isAuto = (c) => (c.tags || []).some(t => String(t).toLowerCase().replace(/^#/, '') === 'auto');
|
|
@@ -2858,13 +3142,18 @@ export async function applyGarden(buffer, { syntheses = [] } = {}) {
|
|
|
2858
3142
|
const span = `${new Date(area.candidates[0].createdAt || now).toISOString().slice(0, 10)} → ${new Date(area.candidates[area.candidates.length - 1].createdAt || now).toISOString().slice(0, 10)}`;
|
|
2859
3143
|
const content = wrapText(`${area.title}: 🌿 Consolidated history (${span}, ${area.candidates.length} cards)\n${finalSynth}`);
|
|
2860
3144
|
const sid = `txt_${rand()}`;
|
|
2861
|
-
|
|
3145
|
+
// `sources` = machine lineage (which originals fed this synthesis, with
|
|
3146
|
+
// their birth dates) — as_of and future provenance passes read the
|
|
3147
|
+
// field, never the prose.
|
|
3148
|
+
zip.file(`items/${shard(sid)}/${sid}.json`, JSON.stringify({ type: 'text', locked: false, createdAt: now, createdBy: 'agent', createdVia: 'gardener', content, fontSize: 12, color: '#e8e8ed', border: true, borderColor: 'rgba(59,130,246,0.6)', heading: false, sources: area.candidates.map(c => ({ id: c.id, createdAt: c.createdAt || 0 })) }));
|
|
2862
3149
|
canvas.positions[sid] = { x: ctnPos.x + 20, y: ctnPos.y + (ctnPos.h || 0) + 10, w: 300, h: measureCardH(content), zKey: nextZKey(), zIndex: canvas.order.length, parentId: area.containerId };
|
|
2863
3150
|
canvas.order.push(sid);
|
|
2864
3151
|
newCards.push(sid);
|
|
2865
3152
|
stats.synthCards++;
|
|
2866
3153
|
for (const cand of area.candidates) {
|
|
2867
|
-
|
|
3154
|
+
// consolidatedAt = the machine death-date deathDateOfCard prefers —
|
|
3155
|
+
// as_of must never depend on the prose stamp parsing correctly.
|
|
3156
|
+
await rewriteCard(cand.id, j => { j.content = `⤵ consolidated ${today}\n${j.content}`; j.borderColor = 'rgba(120,120,135,0.5)'; j.consolidatedAt = now; });
|
|
2868
3157
|
await archiveCard(cand.id);
|
|
2869
3158
|
canvas.connections.push({ id: `con_${rand()}`, fromId: cand.id, toId: sid, relationship: 'relates_to', label: 'consolidated into', arrowHead: true, width: 1.5, color: 'rgba(120,120,135,0.7)', style: 'solid' });
|
|
2870
3159
|
stats.archived++;
|
|
@@ -2896,6 +3185,173 @@ export async function applyGarden(buffer, { syntheses = [] } = {}) {
|
|
|
2896
3185
|
return { buffer: out, stats };
|
|
2897
3186
|
}
|
|
2898
3187
|
|
|
3188
|
+
// ── Claim engine (2026-07-23, Week 2-3 of the truth-maintenance plan) ────────
|
|
3189
|
+
// THE one shared claim extractor — every fulfillment surface (capture-time
|
|
3190
|
+
// cross-check, brain_reconcile mode:'claims', findStaleOpenCards, the brief's
|
|
3191
|
+
// ⏳ section) calls THIS, never a private regex twin (the design review killed
|
|
3192
|
+
// four divergent implementations). A "claim" is a cue-anchored open clause
|
|
3193
|
+
// inside ANY live card ("remaining: X + Y", "next: Z") — the incident class:
|
|
3194
|
+
// prose claims invisible to the ❓/🎯 glyph-gated lifecycle. Multi-item clauses
|
|
3195
|
+
// split on + / · / ; so partial fulfillment stays visible ("covers X, does NOT
|
|
3196
|
+
// cover Y" — approving the covered half must not retire the uncovered half).
|
|
3197
|
+
// Cue precision (adversarial review 2026-07-23): bare 'left' is GONE (CSS
|
|
3198
|
+
// `left: 12px`, "left the meeting at 15:30" were probe-confirmed extractions),
|
|
3199
|
+
// and 'next'/'awaits' require an IMMEDIATE colon — the 40-char bridge let
|
|
3200
|
+
// `await client.messages.create({ model: … })` become a claim. Claim tokens
|
|
3201
|
+
// are stopword- and URL-free: raw tokenSet keeps any ≥4-char word, so
|
|
3202
|
+
// from/this/with/https/github cleared the 0.6 coverage bar against unrelated
|
|
3203
|
+
// milestones (four probe-confirmed FP classes, incl. the live dogfood noise).
|
|
3204
|
+
const CLAIM_CUE_RE = /\b(?:remaining|pending|still\s+to\s+do|to-?do|outstanding|open\s+items?)\b[^:\n]{0,40}:\s*([^\n]+)|\b(?:next|awaits?)\s*:\s*([^\n]+)/gi;
|
|
3205
|
+
const claimTokens = (s) => new Set([...tokenSet(String(s || '').replace(/https?:\/\/\S+/g, ' '))].filter(t => !STOPWORDS.has(t)));
|
|
3206
|
+
export function extractOpenClauses(text) {
|
|
3207
|
+
const out = [];
|
|
3208
|
+
for (const m of String(text || '').matchAll(CLAIM_CUE_RE)) {
|
|
3209
|
+
const clause = (m[1] || m[2] || '').trim();
|
|
3210
|
+
if (!clause) continue;
|
|
3211
|
+
const items = clause.split(/\s*(?:\+|·|;)\s*/)
|
|
3212
|
+
.map(t => t.trim()).filter(t => t.length >= 4)
|
|
3213
|
+
.map(t => ({ text: t, tokens: claimTokens(t) }))
|
|
3214
|
+
.filter(it => it.tokens.size >= 1);
|
|
3215
|
+
if (items.length) out.push({ clause, items });
|
|
3216
|
+
}
|
|
3217
|
+
return out;
|
|
3218
|
+
}
|
|
3219
|
+
|
|
3220
|
+
// One-sided fulfillment scan: which live open claims do these milestones appear
|
|
3221
|
+
// to fulfil? O(claims × milestones) — NEVER an all-pairs brain scan. Returns
|
|
3222
|
+
// receipt-bearing candidates ({covered item, uncovered siblings, coverage}) and
|
|
3223
|
+
// ARCHIVES NOTHING — callers draw dashed "likely closed by" edges / prompt for
|
|
3224
|
+
// a human ✓; retirement always stays a human act. Skills (🛠) and already-dead
|
|
3225
|
+
// cards are never claim sources; a milestone must post-date the claim card.
|
|
3226
|
+
export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, requireNewer = true, maxPerMilestone = 3 } = {}) {
|
|
3227
|
+
if (!struct || !Array.isArray(struct.cards) || !Array.isArray(milestones) || !milestones.length) return [];
|
|
3228
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
3229
|
+
const flat = (s) => String(s || '').replace(/\s+/g, ' ').trim();
|
|
3230
|
+
// 🏁 cards ARE claim sources for their prose clauses — the incident's own
|
|
3231
|
+
// shape was "🏁 Phase 1 VERIFIED. remaining: X + Y", and excluding 🏁 made
|
|
3232
|
+
// the incident class invisible to this very scan (review finding). Only
|
|
3233
|
+
// dead/skill cards are excluded; self-match is guarded below.
|
|
3234
|
+
const live = struct.cards.filter(c => c.type !== 'container' && (c.text || '').trim()
|
|
3235
|
+
&& !isArchived(c) && !/↩|✅|⤵|🛠/.test(c.text));
|
|
3236
|
+
// Dismissals + existing hints: a pair the human rejected (not_fulfilled
|
|
3237
|
+
// edge via brain_connect) or that already carries a hint edge is never
|
|
3238
|
+
// re-suggested — wrong hints must be dismissable, not permanent.
|
|
3239
|
+
const settled = new Set();
|
|
3240
|
+
for (const cn of struct.connections || []) {
|
|
3241
|
+
if (cn.label === 'likely closed by' || cn.relationship === 'not_fulfilled') settled.add(`${cn.fromId}|${cn.toId}`);
|
|
3242
|
+
}
|
|
3243
|
+
// Milestone token sets ONCE — this used to sit in the innermost item loop
|
|
3244
|
+
// (claims × items × milestones tokenizations; output caps bound results,
|
|
3245
|
+
// not work — the 9000-card constraint is about work).
|
|
3246
|
+
const mPre = milestones.map(m => ({ m, tok: tokenSet(m.text) }));
|
|
3247
|
+
const out = [];
|
|
3248
|
+
for (const o of live) {
|
|
3249
|
+
const clauses = extractOpenClauses(o.text);
|
|
3250
|
+
// A whole ❓/🎯 card with no prose clause IS the claim (glyph-gated
|
|
3251
|
+
// path; never for 🏁 cards — their claim is only the explicit clause).
|
|
3252
|
+
if (!clauses.length && /❓|🎯/.test(o.text) && !/🏁/.test(o.text)) {
|
|
3253
|
+
const tk = claimTokens(o.text);
|
|
3254
|
+
if (tk.size >= 4) clauses.push({ clause: null, items: [{ text: flat(o.text).slice(0, 120), tokens: tk }] });
|
|
3255
|
+
}
|
|
3256
|
+
for (const cl of clauses) {
|
|
3257
|
+
for (const it of cl.items) {
|
|
3258
|
+
if (it.tokens.size < 2) continue; // one-token items match everything
|
|
3259
|
+
for (const { m, tok: mTok } of mPre) {
|
|
3260
|
+
if (m.id && m.id === o.id) continue; // a milestone never fulfils its own clause
|
|
3261
|
+
if (m.id && settled.has(`${o.id}|${m.id}`)) continue;
|
|
3262
|
+
if (requireNewer && Number.isFinite(m.createdAt) && m.createdAt <= (o.createdAt || 0)) continue;
|
|
3263
|
+
const cov = coverageOf(it.tokens, mTok);
|
|
3264
|
+
if (cov < coverAt) continue;
|
|
3265
|
+
const uncovered = cl.items.filter(x => x !== it && coverageOf(x.tokens, mTok) < coverAt).map(x => x.text);
|
|
3266
|
+
out.push({ open: o, clause: cl.clause, item: it.text, uncovered, milestone: m, cov: Math.round(cov * 100) / 100, resolvable: it.tokens.size >= 4 });
|
|
3267
|
+
}
|
|
3268
|
+
}
|
|
3269
|
+
}
|
|
3270
|
+
}
|
|
3271
|
+
// Best candidate per (open card, milestone) pair; bounded per milestone so
|
|
3272
|
+
// one generic ship can never spray edges across the brain.
|
|
3273
|
+
const byPair = new Map();
|
|
3274
|
+
for (const c of out) {
|
|
3275
|
+
const key = `${c.open.id}|${c.milestone.id || milestones.indexOf(c.milestone)}`;
|
|
3276
|
+
if (!byPair.has(key) || byPair.get(key).cov < c.cov) byPair.set(key, c);
|
|
3277
|
+
}
|
|
3278
|
+
// GENERICITY GUARD (mirror the close-pass >4-match rule): an item whose
|
|
3279
|
+
// tokens are covered by MANY different milestones is too generic to be a
|
|
3280
|
+
// useful hint ("shipped desktop bundle" matches every release card) —
|
|
3281
|
+
// drop it entirely; a real claim matches the one or two ships that
|
|
3282
|
+
// actually fulfilled it. Then at most 2 hints per item, capped per
|
|
3283
|
+
// milestone so one generic ship can never spray edges across the brain.
|
|
3284
|
+
const byItem = new Map();
|
|
3285
|
+
for (const c of byPair.values()) {
|
|
3286
|
+
const k = `${c.open.id}|${c.item}`;
|
|
3287
|
+
if (!byItem.has(k)) byItem.set(k, []);
|
|
3288
|
+
byItem.get(k).push(c);
|
|
3289
|
+
}
|
|
3290
|
+
const perMile = new Map();
|
|
3291
|
+
const kept = [];
|
|
3292
|
+
for (const group of byItem.values()) {
|
|
3293
|
+
if (group.length > 3) continue; // too generic — no hint
|
|
3294
|
+
for (const c of group.sort((a, b) => b.cov - a.cov).slice(0, 2)) {
|
|
3295
|
+
const mk = c.milestone.id || milestones.indexOf(c.milestone);
|
|
3296
|
+
const n = perMile.get(mk) || 0;
|
|
3297
|
+
if (n >= maxPerMilestone) continue;
|
|
3298
|
+
perMile.set(mk, n + 1);
|
|
3299
|
+
kept.push(c);
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
kept.sort((a, b) => b.cov - a.cov);
|
|
3303
|
+
return kept;
|
|
3304
|
+
}
|
|
3305
|
+
|
|
3306
|
+
// Serving-time fulfillment overlay: a recalled open card that a live milestone
|
|
3307
|
+
// likely fulfilled (a dashed "likely closed by" edge exists) must never render
|
|
3308
|
+
// as plain open truth — the reader gets the hint + the confirm receipt. Renders
|
|
3309
|
+
// BELOW a correction overlay when both exist (correction wins precedence).
|
|
3310
|
+
export function fulfillmentOverlaysFor(struct, cards) {
|
|
3311
|
+
const map = new Map();
|
|
3312
|
+
if (!struct || !Array.isArray(struct.connections)) return map;
|
|
3313
|
+
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
3314
|
+
const want = new Set((cards || []).map(c => c.id));
|
|
3315
|
+
for (const cn of struct.connections) {
|
|
3316
|
+
if (cn.label !== 'likely closed by' || !want.has(cn.fromId)) continue;
|
|
3317
|
+
const m = byId.get(cn.toId);
|
|
3318
|
+
if (!m || /^archive$/i.test(m.area || '')) continue; // a since-archived milestone no longer vouches
|
|
3319
|
+
const head = String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
|
|
3320
|
+
if (!map.has(cn.fromId)) map.set(cn.fromId, { by: head, byId: m.id });
|
|
3321
|
+
}
|
|
3322
|
+
return map;
|
|
3323
|
+
}
|
|
3324
|
+
|
|
3325
|
+
// Corpse-rate (T10): the release-gated staleness metric. Fixtures are mined
|
|
3326
|
+
// from the brain's OWN supersede/close edges: for each archived card with a
|
|
3327
|
+
// live successor, build the ambiguous shared-subject question both could
|
|
3328
|
+
// answer and check who ranks higher. corpse-rate = fraction of pairs where the
|
|
3329
|
+
// corpse outranks its successor — the exact 2026-07-23 failure, measurable.
|
|
3330
|
+
export function corpseRate(struct, { k = 5, maxPairs = 40 } = {}) {
|
|
3331
|
+
if (!struct || !Array.isArray(struct.cards)) return { rate: 0, pairs: 0, served: [] };
|
|
3332
|
+
const byId = new Map(struct.cards.map(c => [c.id, c]));
|
|
3333
|
+
const isArchived = (c) => /^archive$/i.test(c.area || '');
|
|
3334
|
+
const pairs = [];
|
|
3335
|
+
for (const cn of struct.connections || []) {
|
|
3336
|
+
if (cn.label !== 'superseded by' && cn.label !== 'closed by') continue;
|
|
3337
|
+
const corpse = byId.get(cn.fromId), successor = byId.get(cn.toId);
|
|
3338
|
+
if (!corpse || !successor || !isArchived(corpse) || isArchived(successor)) continue;
|
|
3339
|
+
const succTok = tokenSet(successor.text);
|
|
3340
|
+
const shared = [...tokenSet(corpse.text)].filter(t => succTok.has(t)).slice(0, 6);
|
|
3341
|
+
if (shared.length < 3) continue;
|
|
3342
|
+
pairs.push({ corpse, successor, q: `what is the state of ${shared.join(' ')}?` });
|
|
3343
|
+
if (pairs.length >= maxPairs) break;
|
|
3344
|
+
}
|
|
3345
|
+
const served = [];
|
|
3346
|
+
for (const p of pairs) {
|
|
3347
|
+
const { hits } = rankForQuestion(struct, p.q, { k });
|
|
3348
|
+
const ci = hits.findIndex(h => h.card.id === p.corpse.id);
|
|
3349
|
+
const si = hits.findIndex(h => h.card.id === p.successor.id);
|
|
3350
|
+
if (ci !== -1 && (si === -1 || ci < si)) served.push({ q: p.q, corpse: String(p.corpse.text || '').slice(0, 80) });
|
|
3351
|
+
}
|
|
3352
|
+
return { rate: pairs.length ? Math.round((served.length / pairs.length) * 100) / 100 : 0, pairs: pairs.length, served };
|
|
3353
|
+
}
|
|
3354
|
+
|
|
2899
3355
|
// ── Stale-open reconcile ("marked open, but a milestone says it's done") ─────
|
|
2900
3356
|
// The READ-side twin of the closes: write path. An open ❓/🎯 card lingers as
|
|
2901
3357
|
// "still to do" forever unless someone emits a ✓/closes: for it — so a goal that
|
|
@@ -2918,10 +3374,20 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
|
|
|
2918
3374
|
for (const o of opens) {
|
|
2919
3375
|
const oTok = tokenSet(o.text);
|
|
2920
3376
|
if (oTok.size < 3) continue; // too vague to match safely → leave it
|
|
3377
|
+
// CLAUSE-KEYED coverage (2026-07-23): the old whole-card denominator
|
|
3378
|
+
// meant a 6-token "remaining: X" clause inside a 40-token card could
|
|
3379
|
+
// NEVER reach the bar against a terse milestone — the incident card's
|
|
3380
|
+
// exact geometry. Score each open clause item separately and take the
|
|
3381
|
+
// best of (whole card, any clause item ≥2 tokens).
|
|
3382
|
+
// ≥3 tokens per clause item: 2-token items ('docs update') hit cov 1.0
|
|
3383
|
+
// against unrelated milestones (probe-confirmed false stale flags).
|
|
3384
|
+
const clauseToks = extractOpenClauses(o.text).flatMap(cl => cl.items.map(it => it.tokens)).filter(t => t.size >= 3);
|
|
2921
3385
|
let best = null, bestCov = 0;
|
|
2922
3386
|
for (const m of miles) {
|
|
2923
3387
|
if ((m.createdAt || 0) <= (o.createdAt || 0)) continue; // only a milestone shipped AFTER the goal
|
|
2924
|
-
const
|
|
3388
|
+
const mTok = tokenSet(m.text);
|
|
3389
|
+
let cov = coverageOf(oTok, mTok); // how much of the goal the milestone covers
|
|
3390
|
+
for (const ct of clauseToks) cov = Math.max(cov, coverageOf(ct, mTok));
|
|
2925
3391
|
if (cov > bestCov) { bestCov = cov; best = m; }
|
|
2926
3392
|
}
|
|
2927
3393
|
if (best && bestCov >= coverAt) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100 });
|