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.
@@ -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.29.0",
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",
@@ -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
- return { cards: parseCommitLog(raw).slice(0, 15).map(commitToCard).filter(Boolean), newLastCommit: head };
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
- const ptoks = lib.queryTokens(prompt);
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.
@@ -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
- const deathDateOf = (text) => { const m = /(?:↩︎ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
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 ? deathDateOf(c.text) : null;
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
- return { blocks: [text(stamp + questionContextToMarkdown(q, result, { mode, as_of: timeTravel ? as_of : null }))] };
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
- export async function opBrainGarden({ vault, canvas, apply = false, syntheses }) {
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\` and \`syntheses: [{ "title": "<area title EXACTLY as shown>", "synthesis": "<text>" }, …]\`. Originals are archived with audit arrows — nothing is deleted; one undo un-gardens.\n\n${body}`)] };
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)
@@ -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
- for (const c of focus) push(`- ${fr(c)}${flat(c.text)}${odBadge(c)}`);
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
- for (const c of skills.slice(0, maxSkills)) push(`- ${fr(c)}${flat(c.text)}`);
1195
- if (skills.length > maxSkills) push(`- …and ${skills.length - maxSkills} more skill(s) — search the brain.`);
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
- const deathDateOfCard = (text) => { const m = /(?:↩︎ superseded|↩ superseded|✅) (\d{4}-\d{2}-\d{2})/.exec(String(text)); return m ? Date.parse(m[1]) : null; };
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
- const tokens = queryTokens(question);
1388
- if (!struct || !Array.isArray(struct.cards) || (!tokens.length && !semantic)) return { hits: [], total: 0, tokens };
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.text);
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
- const hits = top.map(h => ({ ...h, correction: overlays.get(h.card.id) || null }));
1443
- return { hits, total: scored.length, tokens };
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
- milestonesFallback.push({ text: (r.area ? `${r.area}: ` : '') + `🏁 ${r.text}`, area: r.area, borderColor: 'rgba(59,130,246,0.8)' });
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 = (u.area ? `${u.area}: ` : '') + u.text + tag;
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() && !/⤵|↩|✅|🛠/.test(c.text)) // 🛠️ skills are standing reference — never consolidate them away
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
- 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 }));
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
- await rewriteCard(cand.id, j => { j.content = `⤵ consolidated ${today}\n${j.content}`; j.borderColor = 'rgba(120,120,135,0.5)'; });
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 cov = coverageOf(oTok, tokenSet(m.text)); // how much of the goal the milestone covers
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 });