klypix-mcp 1.42.0 → 1.43.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.
@@ -329,6 +329,24 @@ server.registerTool('brain_sync', {
329
329
  }, async ({ project, intent, files, phase, include_context }) => {
330
330
  const totalStartedAt = Date.now();
331
331
  const report = mcpPresence.sync({ project, intent, files, phase });
332
+ // Class-C ship observation, host-neutral: brain_sync is the ONE surface every
333
+ // MCP host calls at task start, so an MCP-only project (no Claude/Codex hook)
334
+ // still notices a release nobody narrated. Queued here, drained at the next
335
+ // brain write. Zero network, ~2 cheap git/fs reads, never throws.
336
+ let shipNotice = '';
337
+ if (phase === 'start') {
338
+ try {
339
+ const dir = project ? path.resolve(project) : mcpPresence.vault;
340
+ if (typeof brainFormat.observeShipDrift === 'function' && dir) {
341
+ const { execFileSync } = await import('child_process');
342
+ shipNotice = brainFormat.observeShipDrift(dir, {
343
+ gitRun: (args) => execFileSync('git', String(args).split(/\s+/).filter(Boolean), {
344
+ cwd: dir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000,
345
+ }),
346
+ }).notice || '';
347
+ }
348
+ } catch { /* observation is best-effort — never fail a sync */ }
349
+ }
332
350
  let taskContext = null;
333
351
  if (phase !== 'complete' && include_context !== false && (intent || files?.length)) {
334
352
  taskContext = await opBrainTaskContext({
@@ -368,7 +386,7 @@ server.registerTool('brain_sync', {
368
386
  return {
369
387
  content: [{
370
388
  type: 'text',
371
- text: [report.text, contextText, timingText].filter(Boolean).join('\n\n'),
389
+ text: [report.text, shipNotice, contextText, timingText].filter(Boolean).join('\n\n'),
372
390
  }],
373
391
  structuredContent,
374
392
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.42.0",
3
+ "version": "1.43.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, Codex, Cursor, Cline, any model.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -31,6 +31,21 @@ function readInput() {
31
31
  }
32
32
  }
33
33
 
34
+ // Thin binding of the engine's host-neutral ship observer to a Codex project
35
+ // dir. Guarded on the export so an older bundled klypix-format degrades to no
36
+ // observation rather than killing the hook.
37
+ function observeShipDrift(projectDir) {
38
+ try {
39
+ if (!projectDir || typeof brainFormat.observeShipDrift !== 'function') return '';
40
+ const gitRun = (args) => execFileSync('git', String(args).split(/\s+/).filter(Boolean), {
41
+ cwd: projectDir, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000,
42
+ });
43
+ return brainFormat.observeShipDrift(projectDir, { gitRun }).notice || '';
44
+ } catch {
45
+ return '';
46
+ }
47
+ }
48
+
34
49
  function gitBranch(cwd) {
35
50
  try {
36
51
  const branch = execFileSync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], {
@@ -262,6 +277,11 @@ async function main() {
262
277
  if (event === 'SessionStart') {
263
278
  emitSystemMessage([
264
279
  formatPresenceMessage(sessions, sessionId, { includeSolo: true }),
280
+ // Class-C ship observation — the engine's, not a Claude-hook-local copy.
281
+ // Codex-driven projects had NO out-of-session ship detection at all, which
282
+ // is exactly where the incident class bites hardest (2026-07-29 review).
283
+ // The queue drains at the next brain write from any host.
284
+ observeShipDrift(projectDir),
265
285
  stampReceivedMessages(messages),
266
286
  ]);
267
287
  return;
@@ -709,7 +709,20 @@ function filesInEntry(entry) {
709
709
  // tool_use blocks, no model marker required. Only commands that SUCCEEDED (their
710
710
  // tool_result wasn't an error) count, so a failed `gh pr merge` never fabricates a
711
711
  // card; dedup rides the same persistent seen-set as markers.
712
- const SHELL_TOOLS = new Set(['Bash', 'run_shell', 'shell']);
712
+ // One membership test for "is this tool a shell?" across every host the brain
713
+ // rides in: Claude Code (Bash, PowerShell), Codex CLI (shell, local_shell),
714
+ // Gemini CLI (run_shell_command), Cursor (run_terminal_cmd), Cline/Roo
715
+ // (execute_command), VS Code Copilot (run_in_terminal), plus generic bridge
716
+ // names. Compared case-insensitively — hosts disagree on casing before they
717
+ // disagree on names. 2026-07-29 field hole: this Stop-side set lacked
718
+ // 'PowerShell' while the live detector included it, so on Windows a
719
+ // `gh release create` run through the PowerShell tool produced NO ship card —
720
+ // the v1.3.69 ship triggered no claim reconciliation at all.
721
+ const SHELL_TOOL_NAMES = new Set([
722
+ 'bash', 'powershell', 'shell', 'local_shell', 'run_shell', 'run_shell_command',
723
+ 'run_terminal_cmd', 'execute_command', 'run_in_terminal', 'run_command', 'terminal', 'exec',
724
+ ]);
725
+ const isShellTool = (name) => SHELL_TOOL_NAMES.has(String(name || '').toLowerCase());
713
726
  const SHIP_PATTERNS = [
714
727
  // PR number is read ONLY from the `pr merge <n>` argument — not "any number in the
715
728
  // command" (which grabbed a stray digit from `-R owner/repo4`, mislabeling it #4).
@@ -726,7 +739,7 @@ function scanToolBlocks(entry, shellCmds, errorIds) {
726
739
  const c = (entry?.message ?? entry)?.content;
727
740
  if (!Array.isArray(c)) return;
728
741
  for (const b of c) {
729
- if (b?.type === 'tool_use' && SHELL_TOOLS.has(b.name) && typeof b.input?.command === 'string') shellCmds.push({ id: b.id, cmd: b.input.command });
742
+ if (b?.type === 'tool_use' && isShellTool(b.name) && typeof b.input?.command === 'string') shellCmds.push({ id: b.id, cmd: b.input.command });
730
743
  else if (b?.type === 'tool_result' && b.is_error && b.tool_use_id) errorIds.add(b.tool_use_id);
731
744
  }
732
745
  }
@@ -760,6 +773,35 @@ const COMMIT_STATE = path.resolve(CWD, '.claude', 'brain-last-commit');
760
773
  const readLastCommit = () => { try { return fs.readFileSync(COMMIT_STATE, 'utf8').trim() || null; } catch { return null; } };
761
774
  const writeLastCommit = (s) => { try { fs.mkdirSync(path.dirname(COMMIT_STATE), { recursive: true }); fs.writeFileSync(COMMIT_STATE, String(s || '')); } catch { /* best-effort */ } };
762
775
  const git = (args) => execSync(`git ${args}`, { cwd: CWD, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], timeout: 4000 }).trim();
776
+
777
+ // --- Out-of-session ship observation (class-C decay leg, 2026-07-29) --------
778
+ // A ship that happens with NO hooked session watching — another host, a human
779
+ // terminal, CI — used to produce no 🏁 card and trigger no claim
780
+ // reconciliation: the brain only learned what a session narrated (the v1.3.69
781
+ // ship arrived exactly this way and its resolved ❓ stayed open for a day).
782
+ // SessionStart now OBSERVES the two deterministic, zero-network ship signals —
783
+ // the newest local git tag and package.json's version — against a per-project
784
+ // sidecar, QUEUES a pending ship record, and lets the next Stop capture drain
785
+ // it through the existing lock + persistent dedup + fulfillment cross-check.
786
+ // SessionStart itself stays a no-brain-write path (the 1.41.0 print-path
787
+ // contract); first run BASELINES silently, mirroring the commit channel.
788
+ // Ship observation lives in the ENGINE (klypix-format observeShipDrift) so every
789
+ // host shares one implementation — the incident class is worst exactly where no
790
+ // Claude hook runs. These thin wrappers bind it to this hook's CWD + git runner
791
+ // and stay version-skew-safe behind typeof guards.
792
+ function observeOutOfSessionShips(lib) {
793
+ try {
794
+ if (typeof lib?.observeShipDrift !== 'function') return '';
795
+ const { notice } = lib.observeShipDrift(CWD, { gitRun: git, now: nowIso });
796
+ return notice ? `\n${notice}\n` : '';
797
+ } catch { return ''; }
798
+ }
799
+ const advanceShipBaseline = (lib) => {
800
+ try {
801
+ if (typeof lib?.writeShipObsState !== 'function' || typeof lib?.readShipSignals !== 'function') return;
802
+ lib.writeShipObsState(CWD, lib.readShipSignals(CWD, git));
803
+ } catch { /* best-effort */ }
804
+ };
763
805
  const CC_RE = /^(feat|fix|perf)(?:\(([^)]+)\))?!?:\s*(.+)$/i;
764
806
  function parseCommitLog(raw) {
765
807
  return String(raw).split('\x1e').map(s => s.trim()).filter(Boolean).map(rec => {
@@ -958,7 +1000,7 @@ function detectLiveEvent(input) {
958
1000
  const tool = input.tool_name || '';
959
1001
  const ti = input.tool_input || {};
960
1002
  // SHIP — a release/merge/publish/tag from a shell tool that wasn't interrupted/errored.
961
- if ((tool === 'Bash' || tool === 'PowerShell' || SHELL_TOOLS.has(tool)) && typeof ti.command === 'string' && ti.command) {
1003
+ if (isShellTool(tool) && typeof ti.command === 'string' && ti.command) {
962
1004
  const tr = input.tool_response;
963
1005
  const interrupted = tr && typeof tr === 'object' && tr.interrupted === true;
964
1006
  if (!interrupted) {
@@ -1480,6 +1522,39 @@ async function capture(lib) {
1480
1522
  break; // one pattern per command
1481
1523
  }
1482
1524
  }
1525
+ // Drain out-of-session ship observations queued by SessionStart (class-C
1526
+ // decay leg) — same card shape and the same persistent dedup keys as
1527
+ // in-session ship events, so a ship both narrated AND observed lands
1528
+ // exactly once. Skipped under --dry-run (an inspection must not consume
1529
+ // the queue). Queue is cleared BEFORE processing — a throwing entry must
1530
+ // not wedge the drain forever; entries lost to a crash re-observe at the
1531
+ // next SessionStart because the sidecar only advances on write.
1532
+ // The queue is NOT deleted here — it is deleted only after the brain write
1533
+ // is durable (next to writeState/writeLastCommit), mirroring the commit
1534
+ // channel's "advance the baseline only after a successful write". Deleting
1535
+ // up front meant an EBUSY/rename failure — routine on Windows when the
1536
+ // desktop app holds brain.klypix — silently destroyed the observation with
1537
+ // no card and no retry (2026-07-29 review, CONFIRMED). Per-row JSON.parse
1538
+ // already tolerates a poison row, so nothing needs the early unlink.
1539
+ let drainedShips = false;
1540
+ if (!DRY && typeof lib.readPendingShips === 'function') {
1541
+ try {
1542
+ const rows = lib.readPendingShips(CWD);
1543
+ drainedShips = rows.length > 0;
1544
+ // A version already narrated by a session (this batch or an earlier
1545
+ // one, via the persistent seen-set) is not news — see pendingShipCards.
1546
+ const priorNarrated = ['cut release v', 'tagged v', 'tagged ', 'published to npm ']
1547
+ .flatMap(p => rows.filter(r => r.version).map(r => p + r.version))
1548
+ .filter(s => seen.has(sha(('ship|release|' + s).toLowerCase())))
1549
+ .map(s => s.replace(/^[a-z ]+/, ''));
1550
+ for (const c of lib.pendingShipCards(rows, { isSeen: (k) => seen.has(k), narrated: [...shipSummaries, ...priorNarrated], sha })) {
1551
+ if (c.key) seen.add(c.key);
1552
+ cards.push({ text: `${c.area}: 🏁 ${c.summary}\n#${slugify(c.area)} #auto`, area: c.area, borderColor: 'rgba(59,130,246,0.8)', createdVia: 'ship-observed' });
1553
+ shipSummaries.push(c.summary);
1554
+ ledger.push({ action: 'ship-observed', area: c.area, preview: c.summary.slice(0, 90) });
1555
+ }
1556
+ } catch { /* pending-ship drain is best-effort */ }
1557
+ }
1483
1558
  // Refresh this session's lane with what it ACTUALLY did — files it EDITED this
1484
1559
  // session (per-session, from the transcript; NOT the shared working-tree diff,
1485
1560
  // which is identical for two sessions in one repo) + recent ship-events, so a
@@ -1527,6 +1602,10 @@ async function capture(lib) {
1527
1602
  // Record the commit baseline / advance even with nothing to capture, so
1528
1603
  // the next run doesn't re-scan the same commits.
1529
1604
  if (newLastCommit && newLastCommit !== prevCommit) writeLastCommit(newLastCommit);
1605
+ // This session WAS watching, so whatever ship state it ends with is by
1606
+ // definition already observed — baseline it, or the next SessionStart
1607
+ // reports our own narrated release as a new signal.
1608
+ if (!DRY) advanceShipBaseline(lib);
1530
1609
  // Nothing new — but if markers were SEEN-and-skipped or example-rejected,
1531
1610
  // record that so "the brief looks stale" has a paper trail.
1532
1611
  if (ledger.length) appendJsonl(LEDGER, { ts: nowIso(), mode: 'capture', stats: { added: 0 }, decisions: ledger }, 1000);
@@ -1552,6 +1631,10 @@ async function capture(lib) {
1552
1631
  await lib.atomicWrite(BRAIN, out);
1553
1632
  writeState(merged);
1554
1633
  writeLastCommit(newLastCommit); // advance the commit baseline only after a successful write
1634
+ // Same discipline for the two ship channels: the queue is consumed and
1635
+ // the observation baseline advances ONLY now that the cards are durable.
1636
+ if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
1637
+ advanceShipBaseline(lib);
1555
1638
  try { await refreshAgentsBrief(lib, out); } catch { /* AGENTS.md refresh is best-effort */ }
1556
1639
  } finally {
1557
1640
  if (gotLock) releaseLock(LOCK);
@@ -2248,6 +2331,10 @@ async function read(lib) {
2248
2331
  return peerCount ? `\n👥 ${peerCount} other live session(s) in this project right now (snapshot at session start — the per-prompt peer footer stays current; \`npx klypix-mcp doctor\` lists all).` : '';
2249
2332
  } catch { return ''; }
2250
2333
  })();
2334
+ // Class-C decay leg: notice ships that happened while no hooked session was
2335
+ // watching (tag/version drift vs the per-project sidecar) and queue them
2336
+ // for the Stop capture. The line rides BOTH emit tiers.
2337
+ const shipObsLine = observeOutOfSessionShips(lib);
2251
2338
  const { struct } = await lib.parseKlypix(fs.readFileSync(BRAIN));
2252
2339
  const { freshness, drifted } = computeFreshness(struct);
2253
2340
  // The FULL brief: tiered brief + every self-heal/health footer. Messages are
@@ -2258,7 +2345,7 @@ async function read(lib) {
2258
2345
  + ruleDraftsFooter(input.session_id, struct, { markShown: false })
2259
2346
  + selfCheckFooter() + doctorFooter() + versionCurrencyFooter() + legendFooter() + memoryFooter();
2260
2347
  const emitFull = () => {
2261
- process.stdout.write(full + presenceLine + messageFooter(input.session_id || '', input.transcript_path, lib));
2348
+ process.stdout.write(full + presenceLine + shipObsLine + messageFooter(input.session_id || '', input.transcript_path, lib));
2262
2349
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'read', ok: true, briefBytes: Buffer.byteLength(full), cards: struct?.counts?.cards ?? null }, 500);
2263
2350
  };
2264
2351
  // --full = everything to stdout (manual runs); also the fallback when the
@@ -2299,7 +2386,7 @@ async function read(lib) {
2299
2386
  // go right after the ultra brief, at the top of the visible window, never
2300
2387
  // after a stack of footers that could push them past a preview cut.
2301
2388
  const messages = messageFooter(input.session_id || '', input.transcript_path, lib);
2302
- const out = ultra + messages + presenceLine + healLine + draftLine
2389
+ const out = ultra + messages + presenceLine + shipObsLine + healLine + draftLine
2303
2390
  + inflightFooter(input.session_id, struct)
2304
2391
  + selfCheckFooter() + doctorFooter() + versionCurrencyFooter();
2305
2392
  process.stdout.write(out);
@@ -32,6 +32,7 @@ import {
32
32
  statusContextToMarkdown, findFulfillmentCandidates,
33
33
  splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor,
34
34
  isFastDecayCard, DECAY_STALE_MS, formatDecayAge,
35
+ readPendingShips, clearPendingShips, pendingShipCards,
35
36
  } from './klypix-format.mjs';
36
37
  import { capMessages, findProjectBrain } from './agent-presence.mjs';
37
38
 
@@ -550,15 +551,15 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
550
551
  // Semantic blend (best-effort, time-bounded): embed the question + the brain's
551
552
  // cards on-device, hand rankForQuestion a Map<cardId, cosine>. A missing/warming
552
553
  // model degrades cleanly to pure lexical.
553
- let semantic = null, mode = 'lexical';
554
+ let semantic = null, mode = 'lexical', cardVecs = null;
554
555
  try {
555
556
  const pipe = await Promise.race([getEmbedder(log), new Promise(r => setTimeout(() => r(null), 20_000))]);
556
557
  if (pipe) {
557
558
  const [qv] = await embedTexts(pipe, [q]);
558
559
  const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
559
- if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); mode = 'semantic+lexical (on-device)'; }
560
+ if (qv && vecs && vecs.size) { semantic = new Map(); for (const [id, v] of vecs) semantic.set(id, dot(qv, v)); cardVecs = vecs; mode = 'semantic+lexical (on-device)'; }
560
561
  }
561
- } catch { semantic = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
562
+ } catch { semantic = null; cardVecs = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
562
563
  const kk = Math.max(1, Math.min(20, k || 10));
563
564
  const timeTravel = asOfTs != null;
564
565
  // Cross-encoder rerank (eval-proven: recall@5 15%→40% on human paraphrase
@@ -569,7 +570,11 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
569
570
  // today's ranking (same sort, longer slice). Overlays are safe: rankForQuestion
570
571
  // attaches corrections to ALL candidates before we reorder. KLYPIX_RERANK=0 kills.
571
572
  const wantRerank = !timeTravel && process.env.KLYPIX_RERANK !== '0';
572
- const result = rankForQuestion(struct, q, { semantic, k: wantRerank ? Math.max(kk, 50) : kk, as_of: timeTravel ? as_of : null });
573
+ // Card↔card cosine for the serve-time ❓↔🏁 pairing pass — the same on-device
574
+ // vectors that rank the question, reused; null degrades pairing to its
575
+ // lexical anchor/coverage fallback (works on every host, embeddings or not).
576
+ const pairSim = cardVecs ? (a, b) => { const va = cardVecs.get(a), vb = cardVecs.get(b); return va && vb ? dot(va, vb) : null; } : null;
577
+ const result = rankForQuestion(struct, q, { semantic, k: wantRerank ? Math.max(kk, 50) : kk, as_of: timeTravel ? as_of : null, pairSim });
573
578
  if (wantRerank && result.hits.length > 1) {
574
579
  try {
575
580
  const rr = await Promise.race([getReranker(log), new Promise(r => setTimeout(() => r(null), 8_000))]);
@@ -997,10 +1002,24 @@ export async function opBrainNote({ vault, canvas, text: noteText, area, marker
997
1002
  if (!noteText || !String(noteText).trim()) return err('brain_note needs a non-empty text.');
998
1003
  if (!['', '?', '!', '✓', '~', '+'].includes(marker)) return err(`Invalid marker "${marker}" — use: (none)=decision · ?=open question · !=milestone · +=skill (reusable how-to) · ✓=resolve a matching card · ~=update a matching card.`);
999
1004
  const input = noteToCaptureInput({ text: noteText, area, marker, closes: closes || '', createdVia: via || 'mcp' });
1005
+ // Deliver any queued out-of-session ship observations on THIS write. The
1006
+ // Claude Stop hook is not the only writer — an MCP-only or Codex-driven
1007
+ // project would otherwise queue observations that never drain (2026-07-29
1008
+ // review, CONFIRMED). Cards ride the same capture batch, so they also pass
1009
+ // through the fulfillment cross-check. Queue is cleared only after the write.
1010
+ const projectDir = path.dirname(file);
1011
+ let pendingShips = [];
1012
+ try { pendingShips = readPendingShips(projectDir); } catch { pendingShips = []; }
1013
+ if (pendingShips.length) {
1014
+ for (const c of pendingShipCards(pendingShips, {})) {
1015
+ input.cards.push({ text: `${c.area}: 🏁 ${c.summary}\n#${String(c.area).toLowerCase()} #auto`, area: c.area, createdVia: 'ship-observed', borderColor: 'rgba(59,130,246,0.8)' });
1016
+ }
1017
+ }
1000
1018
  try {
1001
1019
  const res = await captureIntoBrain(fs.readFileSync(file), input);
1002
1020
  let out = res.buffer; try { out = (await tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
1003
1021
  await atomicWrite(file, out);
1022
+ if (pendingShips.length) clearPendingShips(projectDir); // durable now — safe to consume
1004
1023
  const s = res.stats || {};
1005
1024
  const bits = [`${s.added || 0} added`];
1006
1025
  for (const k of ['resolved', 'updated', 'merged', 'closed', 'superseded', 'linked']) if (s[k]) bits.push(`${s[k]} ${k}`);
@@ -161,7 +161,16 @@ export async function parseKlypix(buffer) {
161
161
  // it between two cards a reconcile pass flagged as a FALSE contradiction, and
162
162
  // detectContradictions then treats the pair as settled forever (the persisted
163
163
  // dismiss path the correction-cue false-positive previously lacked).
164
- const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks', 'not_contradiction']);
164
+ // 'not_fulfilled' is its twin for the claim engine — and its ABSENCE here was a
165
+ // silent critical: every fulfillment surface has told users to dismiss a wrong
166
+ // ⏳ hint with brain_connect relationship:"not_fulfilled" since 1.31.0, but this
167
+ // allowlist coerced it to 'relates_to', so the stored edge never matched any
168
+ // settled-set check. The tool reported "✓ Drew 1 connection(s)" and the
169
+ // dismissed hint came back on the very next render, forever. Every claim-engine
170
+ // test passed because the fixtures fabricate the connection object directly and
171
+ // never round-trip it through this writer (2026-07-29 review, CONFIRMED).
172
+ export const DISMISSAL_RELS = new Set(['not_contradiction', 'not_fulfilled']);
173
+ const REL = new Set(['leads_to', 'depends_on', 'relates_to', 'conflicts_with', 'supports', 'questions', 'costs', 'blocks', ...DISMISSAL_RELS]);
165
174
 
166
175
  /**
167
176
  * Build a real .klypix v4 file (nodebuffer) from a simple spec:
@@ -1605,7 +1614,7 @@ export const deathDateOfCard = (card) => {
1605
1614
  const m = /(?:↩︎ superseded|↩ superseded|✅|⤵ consolidated) (\d{4}-\d{2}-\d{2})/.exec(String(c.text || ''));
1606
1615
  return m ? Date.parse(m[1]) : null;
1607
1616
  };
1608
- export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30 } = {}) {
1617
+ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_of = null, now = Date.now(), semFloor = 0.30, recentDays = 30, pairSim = null } = {}) {
1609
1618
  // Status-shaped questions score by their CONTENT tokens only — "remaining"
1610
1619
  // must never lexically select the stale cards that say "remaining:".
1611
1620
  const { content: tokens, statusShaped, strong: statusStrong } = splitQueryTokens(question);
@@ -1668,6 +1677,54 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
1668
1677
  let fulfills = new Map();
1669
1678
  if (!timeTravel) { try { fulfills = fulfillmentOverlaysFor(struct, top.map(h => h.card)); } catch { /* best-effort */ } }
1670
1679
  const hits = top.map(h => ({ ...h, correction: overlays.get(h.card.id) || null, fulfillment: fulfills.get(h.card.id) || null }));
1680
+ // SERVE-TIME ❓↔🏁 PAIRING (2026-07-29 incident): an answer's own hit set
1681
+ // can contain an open card AND the newer milestone that likely fulfilled
1682
+ // it, side by side, unlinked — the incident answer carried both at sims
1683
+ // 0.44/0.46 and the agent quoted the ❓ as a live blocker. Fulfillment used
1684
+ // to be a persisted-edge lookup only; one missed capture-time check
1685
+ // silenced every ⏳ surface forever. This pass inspects relationships
1686
+ // WITHIN the candidate set only (≤k², no brain scan), embedding-first
1687
+ // (pairSim = card↔card cosine from the caller's vectors) with a lexical
1688
+ // anchor/coverage fallback, respects human dismissals, and renders through
1689
+ // the ⏳ overlay as an UNCONFIRMED hint — suggestion-only, hedged wording.
1690
+ if (!timeTravel) {
1691
+ try {
1692
+ const openHits = hits.filter(h => !h.correction && !h.fulfillment && !h.archived && isUnresolvedOpenCard(h.card));
1693
+ const mileHits = hits.filter(h => isMilestoneCard(h.card) && !/^archive$/i.test(h.card.area || ''));
1694
+ if (openHits.length && mileHits.length) {
1695
+ const settled = new Set();
1696
+ for (const cn of struct.connections || []) {
1697
+ if (cn.label === 'likely closed by' || cn.relationship === 'not_fulfilled') settled.add(`${cn.fromId}|${cn.toId}`);
1698
+ }
1699
+ let df = null;
1700
+ const dfMap = () => (df ??= buildStemDf(struct));
1701
+ for (const oh of openHits) {
1702
+ let best = null, bestScore = 0;
1703
+ for (const mh of mileHits) {
1704
+ const o = oh.card, m = mh.card;
1705
+ if (m.id === o.id || (m.createdAt || 0) <= (o.createdAt || 0)) continue;
1706
+ if (settled.has(`${o.id}|${m.id}`)) continue;
1707
+ const sim = typeof pairSim === 'function' ? pairSim(o.id, m.id) : null;
1708
+ const lex = likelyFulfillsLexical(o, m, dfMap());
1709
+ const sameArea = (o.area || '') === (m.area || '');
1710
+ // Accept: strong embedding agreement backed by ANY lexical
1711
+ // signal, or lexical evidence alone at the anchor/coverage
1712
+ // bars. Pure-semantic pairs (zero shared vocabulary) stay
1713
+ // out — precision-first for a rendered hint.
1714
+ const accept = (sim != null && sim >= 0.55 && (lex.size || 0) >= SERVE_MIN_STEMS && (lex.anchors.length >= 1 || lex.cov >= 0.2))
1715
+ || serveTimeAccepts(lex, sameArea);
1716
+ if (!accept) continue;
1717
+ const score = (sim ?? 0) + lex.cov + lex.anchors.length * 0.2;
1718
+ if (score > bestScore) { bestScore = score; best = m; }
1719
+ }
1720
+ if (best) {
1721
+ const head = String(best.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
1722
+ oh.fulfillment = { by: head, byId: best.id, unconfirmed: true };
1723
+ }
1724
+ }
1725
+ }
1726
+ } catch { /* pairing is a best-effort overlay — never fail the answer */ }
1727
+ }
1671
1728
  return { hits, total: scored.length, tokens, statusShaped, statusStrong };
1672
1729
  }
1673
1730
 
@@ -1698,7 +1755,12 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
1698
1755
  block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
1699
1756
  } else if (h.fulfillment) {
1700
1757
  // Precedence: a correction outranks a fulfills-hint (never stack both).
1701
- 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.`;
1758
+ // Serve-time pairs (detected inside THIS answer's hit set, no
1759
+ // persisted edge yet) hedge harder than edge-confirmed hints — the
1760
+ // reader must verify, not inherit a machine guess as settled truth.
1761
+ block += h.fulfillment.unconfirmed
1762
+ ? `\n\n ⏳ POSSIBLY FULFILLED (serve-time hint — detected within this answer, no confirmed link): a newer milestone in this same answer may 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, or dismiss via brain_connect relationship:"not_fulfilled".`
1763
+ : `\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.`;
1702
1764
  }
1703
1765
  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; }
1704
1766
  out.push(block, '');
@@ -1746,8 +1808,35 @@ const DECAY_VERB_RE = /\b(?:uploaded|published|released|deployed|installed|shipp
1746
1808
  const DECAY_PENDING_RE = /\b(?:not\s+yet|hasn'?t(?:\s+been)?|haven'?t(?:\s+been)?|nothing(?:\s+has)?(?:\s+been)?|never)\s+(?:been\s+)?(?:uploaded|published|released|deployed|installed|shipped|triggered|tagged|submitted|gone\s+live)\b|\bno\s+(?:\w+[\s/-]+){0,2}?(?:upload|publish|deploy|release|install|build|tag|rollout)[\w/-]*(?:\s+\S+){0,3}?\s+(?:yet|so\s+far|triggered|started|fired|happened)\b|\b(?:awaiting|waiting\s+(?:for|on)|pending)\s+(?:apple|app\s?store|testflight|review|approval|upload|release|rollout)\b/i;
1747
1809
  const DECAY_NOUN_RE = /\btest\s?flight\b|\bapp\s?store\b|\bnpm\b|\bci\b|\brelease(?:s)?\b|\brollout(?:s)?\b|\bbuild\s*#?\s*\d+\b|\bv\d+(?:\.\d+)+\b|\b\d+\.\d+\.\d+\b/i;
1748
1810
  const DECAY_EVRUN_RE = /\bev:\s*[^\n]{0,60}?\b(?:run|workflow|release|build|rollout|deploy)[a-z-]*\s*[#:/]?\s*\d{3,}/i;
1749
- export function classifyDecay(text) {
1750
- const t = String(text || '');
1811
+ // Cards are STORED hard-wrapped (~37 chars/line via wrapText), so a mid-sentence
1812
+ // '\n' is layout, not punctuation. Any extractor that treats lines or [^\n] runs
1813
+ // as semantic units must rebuild sentences first, or a wrap point silently
1814
+ // gates it (2026-07-29 incident: the wrap fell between "in live" and "v1.3.68",
1815
+ // so classifyDecay saw a verb-only segment and a noun-only segment and the one
1816
+ // card that most needed a ⏱️ LAST KNOWN stamp rendered plain). Rules:
1817
+ // · pure tag lines ("#release #file-x #auto") are dropped, not joined — a
1818
+ // "#file-release-notes" tag would otherwise donate a release NOUN to the
1819
+ // sentence above it (probe-confirmed false-positive class);
1820
+ // · a single '\n' joins to a space; a blank line stays a paragraph boundary.
1821
+ export function normalizeWrappedProse(text) {
1822
+ return String(text || '')
1823
+ .split('\n')
1824
+ .filter(l => !/^\s*(?:#[\p{L}\p{N}_-]+\s*)+$/u.test(l))
1825
+ .join('\n')
1826
+ .replace(/([^\n])\n(?!\n)/g, '$1 ');
1827
+ }
1828
+ // Unwrapping is OPT-IN, and only the card store may opt in. An inter-session
1829
+ // message's newlines are AUTHORED separators: joining them merged a verb line
1830
+ // with an unrelated release-noun line and invented a ⏱️ stamp on every host
1831
+ // lane (2026-07-29 review, CONFIRMED — "shipped the auth fix to the beta
1832
+ // testers\nremaining: npm audit + release notes" is two claims, neither
1833
+ // fast-decay). Line-length geometry cannot tell the two apart either: that
1834
+ // message's authored lines are 40 and 36 chars, well inside wrap range. So the
1835
+ // caller who KNOWS its text came from wrapText declares it, and every other
1836
+ // caller — message stampers on all hosts — keeps raw '\n' segmentation.
1837
+ export function classifyDecay(text, { wrapped = false } = {}) {
1838
+ const raw = String(text || '');
1839
+ const t = wrapped ? normalizeWrappedProse(raw) : raw;
1751
1840
  if (!t.trim()) return false;
1752
1841
  if (DECAY_EVRUN_RE.test(t)) return true;
1753
1842
  // A '.' only ends a segment when followed by whitespace/end — a bare-dot
@@ -1762,7 +1851,9 @@ export function classifyDecay(text) {
1762
1851
  // A struct card decays if its TEXT classifies, or its machine evidence carries
1763
1852
  // a run/release-shaped receipt (the ev: suffix is stripped from hook-captured
1764
1853
  // prose, so the text alone can't see it).
1765
- export const isFastDecayCard = (c) => classifyDecay(c?.text)
1854
+ // Card text IS wrapped by construction (wrapText at capture), so the card path
1855
+ // unwraps explicitly rather than relying on geometry detection.
1856
+ export const isFastDecayCard = (c) => classifyDecay(c?.text, { wrapped: true })
1766
1857
  || (Array.isArray(c?.evidence) && c.evidence.some(e => DECAY_EVRUN_RE.test('ev: ' + String(e?.ref || ''))));
1767
1858
  // Compact age for stamps ("20h", "3d") — both message renderers show minutes
1768
1859
  // only, which reads as noise at 12h+ ("720m ago").
@@ -1857,6 +1948,33 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
1857
1948
  // question (review scale finding).
1858
1949
  const top = sorted.slice(0, maxOpen);
1859
1950
  const fulfills = fulfillmentOverlaysFor(struct, top);
1951
+ // Serve-time augmentation (2026-07-29): edge-lookup alone renders a
1952
+ // fulfilled ❓ plain when the one capture-time check missed the pair.
1953
+ // Re-detect lexically against the newest milestones for the opens THIS
1954
+ // render will show — bounded (top × ≤40 miles), suggestion-only, and
1955
+ // flagged '?' so an unconfirmed hint never reads as a settled one.
1956
+ try {
1957
+ const newestMiles = live.filter(isMilestoneCard)
1958
+ .sort((a, b) => (b.createdAt || 0) - (a.createdAt || 0)).slice(0, 40);
1959
+ if (newestMiles.length && top.length && top.length <= 80) {
1960
+ const settled = new Set();
1961
+ for (const cn of struct.connections || []) {
1962
+ if (cn.relationship === 'not_fulfilled') settled.add(`${cn.fromId}|${cn.toId}`);
1963
+ }
1964
+ let df = null;
1965
+ const dfMap = () => (df ??= buildStemDf(struct));
1966
+ for (const o of top) {
1967
+ if (fulfills.has(o.id)) continue;
1968
+ for (const m of newestMiles) {
1969
+ if (m.id === o.id || (m.createdAt || 0) <= (o.createdAt || 0) || settled.has(`${o.id}|${m.id}`)) continue;
1970
+ const lex = likelyFulfillsLexical(o, m, dfMap());
1971
+ if (!serveTimeAccepts(lex, (o.area || '') === (m.area || ''))) continue;
1972
+ fulfills.set(o.id, { by: String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 100), byId: m.id, unconfirmed: true });
1973
+ break;
1974
+ }
1975
+ }
1976
+ }
1977
+ } catch { /* augmentation is best-effort — the base render stands */ }
1860
1978
  let overlays = new Map();
1861
1979
  try { overlays = correctionOverlaysFor(struct, top); } catch { /* best-effort */ }
1862
1980
  pushAlways('');
@@ -1873,7 +1991,7 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
1873
1991
  let shown = 0;
1874
1992
  for (const c of top) {
1875
1993
  if (used > budgetChars) break;
1876
- const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? ' ⏳likely-fulfilled' : ''}`;
1994
+ const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? (fulfills.get(c.id)?.unconfirmed ? ' ⏳likely-fulfilled?' : ' ⏳likely-fulfilled') : ''}`;
1877
1995
  const d = decayStamp(c);
1878
1996
  if (d) pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ⏱️ LAST KNOWN (${d.age}): ${cut(flat(c.text), perCard)} — VERIFY: ${d.probe}`);
1879
1997
  else pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
@@ -1895,6 +2013,113 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
1895
2013
  return out.join('\n') + '\n';
1896
2014
  }
1897
2015
 
2016
+ // ── Out-of-session ship observation (class-C decay, host-neutral) ────────────
2017
+ // A ship that happens with NO hooked session watching — another host, a human
2018
+ // terminal, CI — produced no 🏁 card and triggered no claim reconciliation: the
2019
+ // brain only learned what a session narrated. The v1.3.69 desktop release
2020
+ // arrived exactly that way and the ❓ it resolved stayed open for a day.
2021
+ //
2022
+ // This lives in the ENGINE, not in one host's hook, because the incident class
2023
+ // is worst precisely where no Claude hook runs (Codex CLI, Cursor, a
2024
+ // MCP-only project). Every host calls observeShipDrift at task start and
2025
+ // drainPendingShips at write time; both take an explicit projectDir so nothing
2026
+ // depends on a hook's CWD. Two deterministic, zero-network signals only:
2027
+ // the newest local git tag and package.json's version.
2028
+ export const shipObsPaths = (projectDir) => ({
2029
+ state: path.join(projectDir || '.', '.claude', 'brain-ship-obs.json'),
2030
+ queue: path.join(projectDir || '.', '.claude', 'brain-pending-ships.jsonl'),
2031
+ });
2032
+ // Tag names may carry '@' (changesets / lerna: "klypix-mcp@1.43.0"); shell
2033
+ // metacharacters stay out because the tag is interpolated into a git command.
2034
+ const SAFE_TAG_RE = /^[\w.@/-]+$/;
2035
+ const cmpSemver3 = (a, b) => {
2036
+ const pa = String(a || '').split('.').map(n => parseInt(n, 10) || 0), pb = String(b || '').split('.').map(n => parseInt(n, 10) || 0);
2037
+ for (let i = 0; i < 3; i++) if ((pa[i] || 0) !== (pb[i] || 0)) return (pa[i] || 0) - (pb[i] || 0);
2038
+ return 0;
2039
+ };
2040
+ export function readShipSignals(projectDir, gitRun) {
2041
+ let tag = '';
2042
+ try { tag = String(gitRun('for-each-ref refs/tags --sort=-creatordate --count=1 --format=%(refname:short)') || '').trim(); } catch { /* no git / no tags */ }
2043
+ let version = '';
2044
+ try { version = String(JSON.parse(fs.readFileSync(path.join(projectDir, 'package.json'), 'utf8')).version || ''); } catch { /* not an npm project */ }
2045
+ return { tag, version };
2046
+ }
2047
+ export const writeShipObsState = (projectDir, sig) => {
2048
+ const { state } = shipObsPaths(projectDir);
2049
+ try { fs.mkdirSync(path.dirname(state), { recursive: true }); fs.writeFileSync(state, JSON.stringify(sig)); return true; } catch { return false; }
2050
+ };
2051
+ /**
2052
+ * Compare the live ship signals against this project's last recorded
2053
+ * observation. Returns { events, notice } — events are QUEUED for the next brain
2054
+ * write (so reconciliation runs under the capture lock) and the baseline
2055
+ * advances only after they are durably queued. First call BASELINES silently.
2056
+ * Never throws; a non-git / non-npm project simply yields nothing.
2057
+ */
2058
+ export function observeShipDrift(projectDir, { gitRun, now = () => new Date().toISOString() } = {}) {
2059
+ const empty = { events: [], notice: '' };
2060
+ if (!projectDir || typeof gitRun !== 'function') return empty;
2061
+ try {
2062
+ const { state, queue } = shipObsPaths(projectDir);
2063
+ let prev = null; try { prev = JSON.parse(fs.readFileSync(state, 'utf8')); } catch { /* first run */ }
2064
+ const sig = readShipSignals(projectDir, gitRun);
2065
+ if (!prev || typeof prev !== 'object') { writeShipObsState(projectDir, sig); return empty; }
2066
+ const events = [];
2067
+ if (sig.tag && prev.tag && sig.tag !== prev.tag && SAFE_TAG_RE.test(sig.tag)) {
2068
+ let subj = ''; try { subj = String(gitRun(`log -1 --format=%s ${sig.tag}`) || '').trim(); } catch { /* optional */ }
2069
+ events.push({ area: 'Release', key: `tagged ${sig.tag}`, tag: sig.tag, summary: `tagged ${sig.tag}${subj ? ' — ' + subj.slice(0, 90) : ''} (observed at task start — new since this project's last recorded observation)` });
2070
+ }
2071
+ // A version CHANGE, not an increase — a revert is real news, but must not
2072
+ // be called a ship. Provenance is deliberately NOT asserted: this
2073
+ // observer cannot know whether some session narrated it, and claiming
2074
+ // "outside any hooked session" was false on every routine release
2075
+ // (2026-07-29 review, CONFIRMED).
2076
+ if (sig.version && prev.version && sig.version !== prev.version) {
2077
+ const dir = cmpSemver3(sig.version, prev.version) < 0 ? 'reverted to' : '→';
2078
+ events.push({ area: 'Release', key: `version-observed ${sig.version}`, version: sig.version, summary: `version ${dir} ${sig.version} observed at task start (was ${prev.version})` });
2079
+ }
2080
+ if (!events.length) { writeShipObsState(projectDir, sig); return empty; }
2081
+ try {
2082
+ fs.mkdirSync(path.dirname(queue), { recursive: true });
2083
+ for (const e of events) fs.appendFileSync(queue, JSON.stringify({ ts: now(), ...e }) + '\n');
2084
+ } catch { return empty; } // couldn't queue → don't advance the baseline
2085
+ writeShipObsState(projectDir, sig);
2086
+ return {
2087
+ events,
2088
+ notice: `⏱️ Ship signal observed since this project's last observation: ${events.map(e => e.key).join(' · ')} — queued for capture + claim reconciliation at the next brain write. Open ❓ cards about this release may already be fulfilled; verify live before reporting them as blockers.`,
2089
+ };
2090
+ } catch { return empty; }
2091
+ }
2092
+ export function readPendingShips(projectDir) {
2093
+ try {
2094
+ const { queue } = shipObsPaths(projectDir);
2095
+ if (!fs.existsSync(queue)) return [];
2096
+ return fs.readFileSync(queue, 'utf8').split('\n').filter(Boolean)
2097
+ .map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
2098
+ } catch { return []; }
2099
+ }
2100
+ export const clearPendingShips = (projectDir) => { try { fs.unlinkSync(shipObsPaths(projectDir).queue); } catch { /* already gone */ } };
2101
+ /**
2102
+ * Turn queued observations into capture-ready ship cards. `isSeen(key)` consults
2103
+ * the caller's persistent dedup state; `narrated` are ship summaries captured in
2104
+ * THIS batch. A ship already narrated by some session is not news — an observed
2105
+ * version event keys differently from every narrated summary ("published to
2106
+ * npm", "cut release vX"), which is why the routine release flow used to produce
2107
+ * a duplicate card (2026-07-29 review, CONFIRMED).
2108
+ */
2109
+ export function pendingShipCards(rows, { isSeen = () => false, narrated = [], sha } = {}) {
2110
+ const out = [];
2111
+ for (const r of rows || []) {
2112
+ if (!r || !r.summary) continue;
2113
+ const area = r.area || 'Release';
2114
+ const key = typeof sha === 'function' ? sha(('ship|' + area + '|' + String(r.key || r.summary)).toLowerCase()) : null;
2115
+ if (key && isSeen(key)) continue;
2116
+ const ver = r.version || (r.tag ? String(r.tag).replace(/^.*?v?(\d+\.\d+\.\d+)$/, '$1') : '');
2117
+ if (ver && narrated.some(s => String(s).includes(ver))) continue;
2118
+ out.push({ key, area, summary: String(r.summary) });
2119
+ }
2120
+ return out;
2121
+ }
2122
+
1898
2123
  // ── External-state reconcile — migration omission tripwire ───────────────────
1899
2124
  // The brain is a NARRATION-capture system: a fact exists only if someone wrote a
1900
2125
  // 🧠 marker or a rationale-bearing commit body. Applying a DB migration to prod
@@ -2246,7 +2471,23 @@ export async function addBrainConnections(buffer, edges) {
2246
2471
  for (const e of edges || []) {
2247
2472
  if (!e.fromId || !e.toId || e.fromId === e.toId) continue;
2248
2473
  if (!canvas.positions[e.fromId] || !canvas.positions[e.toId]) continue; // both must exist
2249
- if (linked(e.fromId, e.toId)) continue;
2474
+ // A DISMISSAL must never be pair-deduped away by the very hint it
2475
+ // dismisses. The pair-level guard is right for topical edges (it stops
2476
+ // duplicate arrows) but it made the documented escape hatch a no-op:
2477
+ // an auto-written 'likely closed by' hint occupied the pair, so the
2478
+ // human's not_fulfilled edge was dropped ("Drew 0 connection(s)") and
2479
+ // the wrong hint became permanent (2026-07-29 review, CONFIRMED).
2480
+ // Dismissals therefore RETIRE the machine hint in place and are always
2481
+ // recorded; a second identical dismissal is still deduped.
2482
+ if (DISMISSAL_RELS.has(e.relationship)) {
2483
+ const already = canvas.connections.some(c => c.relationship === e.relationship
2484
+ && ((c.fromId === e.fromId && c.toId === e.toId) || (c.fromId === e.toId && c.toId === e.fromId)));
2485
+ if (already) continue;
2486
+ for (const c of canvas.connections) {
2487
+ const samePair = (c.fromId === e.fromId && c.toId === e.toId) || (c.fromId === e.toId && c.toId === e.fromId);
2488
+ if (samePair && c.label === 'likely closed by') { c.label = '↩ dismissed hint'; c.style = 'dashed'; c.color = 'rgba(148,163,184,0.5)'; }
2489
+ }
2490
+ } else if (linked(e.fromId, e.toId)) continue;
2250
2491
  canvas.connections.push({
2251
2492
  id: `con_${rand()}`, fromId: e.fromId, toId: e.toId,
2252
2493
  relationship: REL.has(e.relationship) ? e.relationship : 'relates_to',
@@ -3147,7 +3388,10 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
3147
3388
  const cands = findFulfillmentCandidates(struct, [{ text: card.text, createdAt: now }], { maxPerMilestone: 2 })
3148
3389
  .filter(c => !closed.has(c.open.id));
3149
3390
  if (!cands.length) continue;
3150
- card.__fulfills = cands.map(c => c.open.id);
3391
+ // Only COVERAGE-grade candidates earn a persisted edge; anchor-grade
3392
+ // ones ride the printed receipt (hedged) and are re-derived at serve
3393
+ // time. See the persist pass below for why.
3394
+ card.__fulfills = cands.filter(c => c.via !== 'anchor').map(c => c.open.id);
3151
3395
  for (const c of cands) {
3152
3396
  stats.fulfillCandidates.push({
3153
3397
  open: String(c.open.text || '').replace(/\s+/g, ' ').slice(0, 90),
@@ -3155,6 +3399,7 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
3155
3399
  item: c.item,
3156
3400
  uncovered: c.uncovered,
3157
3401
  cov: c.cov,
3402
+ ...(c.via ? { via: c.via } : {}),
3158
3403
  // A ✓ is suggested ONLY when the claim is FULLY covered and
3159
3404
  // its tokens can actually clear the resolve matcher's ≥4
3160
3405
  // floor — a partial ✓ used to archive the whole card
@@ -3227,6 +3472,49 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
3227
3472
  }
3228
3473
  } catch { /* auto-linking is opportunistic */ }
3229
3474
  }
3475
+ // PERSIST DETECTION AS EDGES (2026-07-29): staleOpenFooter printed its
3476
+ // findings as prose and wrote nothing, so a detection that fired at
3477
+ // SessionStart was invisible to brain_ask an hour later — miss the one
3478
+ // capture-time check and every ⏳ surface stayed blind forever. Every
3479
+ // capture now leaves dashed 'likely closed by' edges for the pairs the
3480
+ // detector can currently see. Suggestion-only contract unchanged (the
3481
+ // human ✓ retires cards; addConn dedups; a not_fulfilled dismissal
3482
+ // suppresses the pair inside findStaleOpenCards itself). Bounded: the
3483
+ // detector caps at 8 pairs and runs once per capture batch.
3484
+ try {
3485
+ const { gaps } = findStaleOpenCards(struct, { max: 8 });
3486
+ let hintEdges = 0;
3487
+ for (const g of gaps) {
3488
+ // ANCHOR-GRADE EVIDENCE IS NEVER PERSISTED. Its whole contract is
3489
+ // "suggestion, verify by hand" — persisting it made the next
3490
+ // render treat it as an established link, so a hint the serve
3491
+ // path hedged as "POSSIBLY FULFILLED" came back one capture
3492
+ // later at full strength and entered every settled set, i.e.
3493
+ // permanent unless dismissed (2026-07-29 review, CONFIRMED).
3494
+ // Anchor pairs stay serve-time-only, re-derived and re-hedged
3495
+ // on each render, so they cost nothing to be wrong about.
3496
+ if (g.via === 'anchor') continue;
3497
+ const before = stats.linked;
3498
+ addConn(g.open.id, g.by.id, 'likely closed by', undefined, { style: 'dashed', width: 1.5, color: 'rgba(16,185,129,0.55)' });
3499
+ if (stats.linked > before) { hintEdges++; continue; }
3500
+ // hasConn is pair-level and label-blind, and the structural
3501
+ // auto-linker runs FIRST in this same capture — so a co-tagged
3502
+ // pair already wearing an 'auto' relates_to edge could never
3503
+ // receive its hint, on this or any future capture (2026-07-29
3504
+ // review, CONFIRMED). Upgrade the weaker edge in place instead
3505
+ // of dropping the finding on the floor. Dismissals and existing
3506
+ // hints are left exactly as they are.
3507
+ for (const cn of canvas.connections) {
3508
+ const samePair = (cn.fromId === g.open.id && cn.toId === g.by.id) || (cn.fromId === g.by.id && cn.toId === g.open.id);
3509
+ if (!samePair || cn.label === 'likely closed by' || cn.label === '↩ dismissed hint' || DISMISSAL_RELS.has(cn.relationship)) continue;
3510
+ cn.fromId = g.open.id; cn.toId = g.by.id;
3511
+ cn.label = 'likely closed by'; cn.style = 'dashed'; cn.width = 1.5; cn.color = 'rgba(16,185,129,0.55)';
3512
+ hintEdges++;
3513
+ break;
3514
+ }
3515
+ }
3516
+ if (hintEdges) stats.hintEdges = hintEdges;
3517
+ } catch { /* hint persistence is opportunistic — never fail a capture */ }
3230
3518
  stampBrainKind(manifest);
3231
3519
  work = await finalizeBrainZip(zip, canvas, manifest, now);
3232
3520
  }
@@ -3451,19 +3739,167 @@ export async function applyGarden(buffer, { syntheses = [] } = {}) {
3451
3739
  // are stopword- and URL-free: raw tokenSet keeps any ≥4-char word, so
3452
3740
  // from/this/with/https/github cleared the 0.6 coverage bar against unrelated
3453
3741
  // milestones (four probe-confirmed FP classes, incl. the live dogfood noise).
3454
- 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;
3742
+ // Cue vocabulary is single-sourced: the clause matcher and the line-boundary
3743
+ // preserver below must never drift apart (a cue the matcher knows but the
3744
+ // preserver doesn't gets its line silently merged into the clause above it).
3745
+ const CLAIM_CUE_WORDS = String.raw`remaining|pending|still\s+to\s+do|to-?do|outstanding|open\s+items?`;
3746
+ const NEXT_CUE_WORDS = String.raw`next|awaits?`;
3747
+ const CLAIM_CUE_RE = new RegExp(String.raw`\b(?:${CLAIM_CUE_WORDS})\b[^:\n]{0,40}:\s*([^\n]+)|\b(?:${NEXT_CUE_WORDS})\s*:\s*([^\n]+)`, 'gi');
3748
+ const CUE_LINE_RE = new RegExp(String.raw`^\s*(?:${CLAIM_CUE_WORDS}|${NEXT_CUE_WORDS})\b[^:\n]{0,40}:`, 'i');
3455
3749
  const claimTokens = (s) => new Set([...tokenSet(String(s || '').replace(/https?:\/\/\S+/g, ' '))].filter(t => !STOPWORDS.has(t)));
3750
+ // ── Morphology + rarity (2026-07-29 incident) ────────────────────────────────
3751
+ // The incident pair shared the brain's rarest tokens (binariesgithubrelease,
3752
+ // node-llama-cpp) yet measured 0.21 flat coverage: the ❓ was a 42-token
3753
+ // forensic narrative, the 🏁 a terse fix, and tokenSet is morphology-blind
3754
+ // (excludes/excluding, packaged/packaging all count as misses). Two additions,
3755
+ // both feeding the SAME suggestion-only pipeline — the generic 0.6 bar stays:
3756
+ // · stemLight — a deliberately tiny suffix stripper (ing/ed/es/s + trailing
3757
+ // double consonant + trailing e) applied at COMPARISON time only, never to
3758
+ // stored tokens, so no other consumer's token sets change;
3759
+ // · anchor tokens — a token whose stemmed form appears in ≤ANCHOR_DF_MAX live
3760
+ // cards is near-unique in the brain; an open↔milestone pair sharing ≥2 such
3761
+ // anchors (same area; ≥3 cross-area) is hint-worthy regardless of flat
3762
+ // coverage. Rare-by-construction, so the genericity guard stays meaningful.
3763
+ // Suffix stripping is deliberately conservative and, crucially, only ever
3764
+ // consulted through matchesStem below — a bare stem comparison collides distinct
3765
+ // vocabulary (state↔stats, notes↔noting, cares↔caring all collapse), and one
3766
+ // collision is enough to be the tipping anchor on an unrelated pair
3767
+ // (2026-07-29 review, CONFIRMED: 'rotation state' vs 'cost stats').
3768
+ export const stemLight = (w) => {
3769
+ let s = String(w);
3770
+ if (s.length >= 6 && /(?:ing|ed)$/.test(s)) s = s.replace(/(?:ing|ed)$/, '');
3771
+ else if (s.length >= 5 && /[^s]s$/.test(s)) s = s.replace(/es$|s$/, '');
3772
+ if (s.length >= 5 && /(.)\1$/.test(s)) s = s.slice(0, -1);
3773
+ if (s.length >= 5 && s.endsWith('e')) s = s.slice(0, -1);
3774
+ return s;
3775
+ };
3776
+ // Two RAW tokens are the same word only if one is at most one suffix-strip away
3777
+ // from the other. excludes↔excluding passes (each strips to the same form AND
3778
+ // one strips onto the other's stem); state↔stats does not (neither raw form is
3779
+ // the other's stem). This is the guard that makes stemming safe to use as
3780
+ // evidence rather than merely as a recall boost.
3781
+ export const matchesStem = (a, b) => a === b || stemLight(a) === b || a === stemLight(b);
3782
+ // Stem→raw-tokens index: comparison keys on the stem (cheap set intersection),
3783
+ // but every accepted match is re-validated on the RAW words behind it.
3784
+ export const stemIndex = (tokens) => {
3785
+ const m = new Map();
3786
+ for (const t of tokens) { const s = stemLight(t); if (!m.has(s)) m.set(s, new Set()); m.get(s).add(t); }
3787
+ return m;
3788
+ };
3789
+ export const stemSet = (tokens) => new Set(stemIndex(tokens).keys());
3790
+ const rawMatch = (aRaws, bRaws) => { for (const x of aRaws || []) for (const y of bRaws || []) if (matchesStem(x, y)) return true; return false; };
3791
+ // Rarity is RELATIVE to corpus size: an absolute df≤4 meant that in a young
3792
+ // brain (or any area with ≤4 cards) EVERY token was "rare" — including the
3793
+ // area-name prefix every stored card carries, which handed same-area pairs a
3794
+ // free anchor (2026-07-29 review, CONFIRMED: an SSO ship "fulfilling" a
3795
+ // changelog question on [releas, enterpris]).
3796
+ const anchorDfMax = (liveCount) => Math.max(2, Math.ceil(0.02 * Math.max(0, liveCount)));
3797
+ // Document frequency of STEMMED tokens across live (non-archived, non-container)
3798
+ // cards — the rarity oracle for anchor matching. O(cards × tokens), built once
3799
+ // per detection pass and only when a pass actually needs it (lazy).
3800
+ export function buildStemDf(struct) {
3801
+ const df = new Map();
3802
+ let live = 0;
3803
+ for (const c of struct?.cards || []) {
3804
+ if (c.type === 'container' || !(c.text || '').trim() || /^archive$/i.test(c.area || '')) continue;
3805
+ live++;
3806
+ for (const t of stemSet(tokenSet(c.text))) df.set(t, (df.get(t) || 0) + 1);
3807
+ }
3808
+ df.__live = live;
3809
+ df.__max = anchorDfMax(live);
3810
+ return df;
3811
+ }
3812
+ // Tokens a pair shares BY CONSTRUCTION are never evidence: the area name (every
3813
+ // stored card is written "<Area>: …" and containers carry it too) and its #tag
3814
+ // slug. Cheap per-call set — areas are short.
3815
+ const structuralStems = (...areas) => {
3816
+ const out = new Set();
3817
+ for (const a of areas) for (const t of stemSet(tokenSet(String(a || '').replace(/[^\p{L}\p{N}]+/gu, ' ')))) out.add(t);
3818
+ return out;
3819
+ };
3820
+ export function sharedAnchors(aIdx, bIdx, df, { exclude = null } = {}) {
3821
+ const out = [];
3822
+ const max = (df && df.__max) || 2;
3823
+ const aIsMap = aIdx instanceof Map, bIsMap = bIdx instanceof Map;
3824
+ for (const t of (aIsMap ? aIdx.keys() : aIdx)) {
3825
+ if (exclude && exclude.has(t)) continue; // structural (area/tag) token — shared by construction
3826
+ if (!(bIsMap ? bIdx.has(t) : bIdx.has(t))) continue;
3827
+ if ((df?.get(t) || 0) > max) continue; // not rare enough for this corpus size
3828
+ if (aIsMap && bIsMap && !rawMatch(aIdx.get(t), bIdx.get(t))) continue; // stem collision, not the same word
3829
+ out.push(t);
3830
+ }
3831
+ return out;
3832
+ }
3833
+ // Anchors must CORROBORATE topical overlap, never substitute for it entirely:
3834
+ // a small coverage floor keeps two rare words from pairing cards that share
3835
+ // nothing else.
3836
+ const ANCHOR_COV_FLOOR = 0.15;
3837
+ // One pair-scorer for every serve-/heal-time surface: stemmed coverage of the
3838
+ // open card's claim by the milestone + shared rare anchors. Suggestion-grade
3839
+ // evidence, never a close verdict.
3840
+ export function likelyFulfillsLexical(openCard, milestone, df) {
3841
+ const oIdx = stemIndex(claimTokens(normalizeWrappedProse(openCard?.text)));
3842
+ const mIdx = stemIndex(tokenSet(milestone?.text));
3843
+ if (!oIdx.size || !mIdx.size) return { cov: 0, anchors: [], size: oIdx.size };
3844
+ const cov = coverageOf(new Set(oIdx.keys()), new Set(mIdx.keys()));
3845
+ const anchors = df ? sharedAnchors(oIdx, mIdx, df, { exclude: structuralStems(openCard?.area, milestone?.area) }) : [];
3846
+ return { cov: Math.round(cov * 100) / 100, anchors, size: oIdx.size };
3847
+ }
3848
+ const anchorsSufficient = (anchors, sameArea, cov = 1) =>
3849
+ anchors.length >= (sameArea ? 2 : 3) && cov >= ANCHOR_COV_FLOOR;
3850
+ // Serve-time acceptance for a whole-card claim, mirroring the capture paths'
3851
+ // size floors (it.tokens.size≥2 / oTok.size≥3 / tk.size≥4). Without one, a
3852
+ // 2-token open card ("Release: ❓ npm publish?") is trivially covered by any
3853
+ // milestone reusing its words, cross-area, at cov 1.0 (2026-07-29 review,
3854
+ // CONFIRMED) — and a ⏳ flag instructs the reader not to treat it as open.
3855
+ // Small claims must therefore earn a hint through rare anchors, never coverage.
3856
+ const SERVE_COV_BAR = 0.35;
3857
+ const SERVE_MIN_STEMS = 3;
3858
+ export const serveTimeAccepts = (lex, sameArea) =>
3859
+ anchorsSufficient(lex.anchors, sameArea, lex.cov)
3860
+ || ((lex.size || 0) >= SERVE_MIN_STEMS && lex.cov >= SERVE_COV_BAR);
3861
+ // Imperative-ask cue (2026-07-29): a narrative ❓ card often carries no
3862
+ // colon-anchored "remaining:" clause — its ask is an imperative sentence
3863
+ // ("Narrow the prune … and verify a packaged answer E2E"). For OPEN-shaped
3864
+ // cards only (❓/🎯 in text — a milestone's prose stays exempt), such sentences
3865
+ // become claim clauses, so the ASK is the coverage denominator instead of the
3866
+ // whole diagnosis. Curated base-form verbs, sentence-initial, ≥8 chars of
3867
+ // payload — commit-style "fix:" prefixes never reach here (no ❓/🎯).
3868
+ const IMPERATIVE_ASK_RE = /(?:^|[.!?]\s+)((?:narrow|fix|verify|ship|wire|apply|investigate|add|restore|re-?run|prove|close|migrate|land|publish|deploy|implement|rotate|upgrade|remove|rename|extend|measure|benchmark|audit|rehydrate|persist|surface)\b[^\n.!?]{8,160})/gi;
3456
3869
  export function extractOpenClauses(text) {
3870
+ // Sentence-shaped cues ([^:\n], [^\n]+, sentence-initial verbs) read the
3871
+ // UNWRAPPED prose — stored hard-wraps otherwise truncate every clause at
3872
+ // the first wrap point (~37 chars), which silently crippled clause capture
3873
+ // for all STORED cards while tests fed unwrapped text and stayed green.
3874
+ // A line that OPENS with its own cue ("remaining: A" under "pending: B")
3875
+ // is an authored list entry, not a soft wrap — promote it to a paragraph
3876
+ // boundary before joining, or the join would fold sibling claims into one.
3877
+ const t = normalizeWrappedProse(String(text || '').split('\n').map(l => CUE_LINE_RE.test(l) ? '\n' + l : l).join('\n'));
3457
3878
  const out = [];
3458
- for (const m of String(text || '').matchAll(CLAIM_CUE_RE)) {
3459
- const clause = (m[1] || m[2] || '').trim();
3879
+ for (const m of t.matchAll(CLAIM_CUE_RE)) {
3880
+ // Stop the clause at the first SENTENCE boundary, not at paragraph end.
3881
+ // Unwrapping made a stored card one long paragraph, so "remaining: X."
3882
+ // followed by "Y shipped already." absorbed the done-sentence into the
3883
+ // claim — coverage then cleared the bar against the milestone that
3884
+ // shipped Y and the footer suggested a ✓ for a card whose real
3885
+ // remaining item was untouched (2026-07-29 review, CONFIRMED).
3886
+ // Same version-literal-safe split as classifyDecay: '1.3.28' survives.
3887
+ const clause = (m[1] || m[2] || '').split(/[!?]\s|\.(?=\s|$)/)[0].trim();
3460
3888
  if (!clause) continue;
3461
3889
  const items = clause.split(/\s*(?:\+|·|;)\s*/)
3462
- .map(t => t.trim()).filter(t => t.length >= 4)
3463
- .map(t => ({ text: t, tokens: claimTokens(t) }))
3890
+ .map(x => x.trim()).filter(x => x.length >= 4)
3891
+ .map(x => ({ text: x, tokens: claimTokens(x) }))
3464
3892
  .filter(it => it.tokens.size >= 1);
3465
3893
  if (items.length) out.push({ clause, items });
3466
3894
  }
3895
+ if (/❓|🎯/.test(t)) {
3896
+ for (const m of t.matchAll(IMPERATIVE_ASK_RE)) {
3897
+ const clause = (m[1] || '').trim();
3898
+ if (!clause) continue;
3899
+ const tokens = claimTokens(clause);
3900
+ if (tokens.size >= 3) out.push({ clause, items: [{ text: clause, tokens }] });
3901
+ }
3902
+ }
3467
3903
  return out;
3468
3904
  }
3469
3905
 
@@ -3492,8 +3928,14 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
3492
3928
  }
3493
3929
  // Milestone token sets ONCE — this used to sit in the innermost item loop
3494
3930
  // (claims × items × milestones tokenizations; output caps bound results,
3495
- // not work — the 9000-card constraint is about work).
3496
- const mPre = milestones.map(m => ({ m, tok: tokenSet(m.text) }));
3931
+ // not work — the 9000-card constraint is about work). Comparison happens on
3932
+ // STEMMED sets (stemLight) so excludes/excluding, packaged/packaging count
3933
+ // as the hits they are — stored token sets stay untouched.
3934
+ const mPre = milestones.map(m => ({ m, idx: stemIndex(tokenSet(m.text)), tok: stemSet(tokenSet(m.text)) }));
3935
+ // Rarity oracle for the anchor OR-path — built lazily: most captures carry
3936
+ // no open-shaped claims at all and must not pay the corpus scan.
3937
+ let df = null;
3938
+ const dfMap = () => (df ??= buildStemDf(struct));
3497
3939
  const out = [];
3498
3940
  for (const o of live) {
3499
3941
  const clauses = extractOpenClauses(o.text);
@@ -3506,14 +3948,28 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
3506
3948
  for (const cl of clauses) {
3507
3949
  for (const it of cl.items) {
3508
3950
  if (it.tokens.size < 2) continue; // one-token items match everything
3509
- for (const { m, tok: mTok } of mPre) {
3951
+ const itIdx = stemIndex(it.tokens);
3952
+ const itStems = new Set(itIdx.keys());
3953
+ for (const { m, idx: mIdx, tok: mTok } of mPre) {
3510
3954
  if (m.id && m.id === o.id) continue; // a milestone never fulfils its own clause
3511
3955
  if (m.id && settled.has(`${o.id}|${m.id}`)) continue;
3512
3956
  if (requireNewer && Number.isFinite(m.createdAt) && m.createdAt <= (o.createdAt || 0)) continue;
3513
- const cov = coverageOf(it.tokens, mTok);
3514
- if (cov < coverAt) continue;
3515
- const uncovered = cl.items.filter(x => x !== it && coverageOf(x.tokens, mTok) < coverAt).map(x => x.text);
3516
- out.push({ open: o, clause: cl.clause, item: it.text, uncovered, milestone: m, cov: Math.round(cov * 100) / 100, resolvable: it.tokens.size >= 4 });
3957
+ const cov = coverageOf(itStems, mTok);
3958
+ // ANCHOR OR-PATH (2026-07-29 incident): a verbose diagnostic
3959
+ // ❓ vs a terse differently-worded 🏁 can NEVER reach the flat
3960
+ // bar (42-token denominator, 0.21 measured) — but the pair
3961
+ // shares near-unique tokens. ≥2 rare anchors in the same
3962
+ // area (≥3 cross-area) is hint-grade evidence on its own.
3963
+ // Suggestion-only by construction: via:'anchor' candidates
3964
+ // are never ✓-resolvable and get their own per-milestone cap.
3965
+ let viaAnchor = false;
3966
+ if (cov < coverAt) {
3967
+ const anchors = sharedAnchors(itIdx, mIdx, dfMap(), { exclude: structuralStems(o.area, m.area) });
3968
+ if (!anchorsSufficient(anchors, (o.area || '') === (m.area || ''), cov)) continue;
3969
+ viaAnchor = true;
3970
+ }
3971
+ const uncovered = cl.items.filter(x => x !== it && coverageOf(stemSet(x.tokens), mTok) < coverAt).map(x => x.text);
3972
+ out.push({ open: o, clause: cl.clause, item: it.text, uncovered, milestone: m, cov: Math.round(cov * 100) / 100, resolvable: !viaAnchor && it.tokens.size >= 4, ...(viaAnchor ? { via: 'anchor' } : {}) });
3517
3973
  }
3518
3974
  }
3519
3975
  }
@@ -3538,14 +3994,21 @@ export function findFulfillmentCandidates(struct, milestones, { coverAt = 0.6, r
3538
3994
  byItem.get(k).push(c);
3539
3995
  }
3540
3996
  const perMile = new Map();
3997
+ const perMileAnchor = new Map();
3541
3998
  const kept = [];
3542
3999
  for (const group of byItem.values()) {
3543
4000
  if (group.length > 3) continue; // too generic — no hint
3544
4001
  for (const c of group.sort((a, b) => b.cov - a.cov).slice(0, 2)) {
3545
4002
  const mk = c.milestone.id || milestones.indexOf(c.milestone);
3546
- const n = perMile.get(mk) || 0;
3547
- if (n >= maxPerMilestone) continue;
3548
- perMile.set(mk, n + 1);
4003
+ // Anchor-path candidates spend a SEPARATE (smaller) per-milestone
4004
+ // budget: the coverage-path cap must not crowd them out — the exact
4005
+ // starvation the 2026-07-29 critic probe found at low bars — and
4006
+ // they must not inflate the coverage path's spray allowance either.
4007
+ const budget = c.via === 'anchor' ? perMileAnchor : perMile;
4008
+ const cap = c.via === 'anchor' ? 2 : maxPerMilestone;
4009
+ const n = budget.get(mk) || 0;
4010
+ if (n >= cap) continue;
4011
+ budget.set(mk, n + 1);
3549
4012
  kept.push(c);
3550
4013
  }
3551
4014
  }
@@ -3562,12 +4025,24 @@ export function fulfillmentOverlaysFor(struct, cards) {
3562
4025
  if (!struct || !Array.isArray(struct.connections)) return map;
3563
4026
  const byId = new Map(struct.cards.map(c => [c.id, c]));
3564
4027
  const want = new Set((cards || []).map(c => c.id));
4028
+ // A dismissal WINS even if both edges coexist on the pair — the relabel in
4029
+ // addBrainConnections handles new dismissals, and this covers any brain
4030
+ // where the two edges got written independently (or by an older build).
4031
+ const dismissed = new Set();
4032
+ for (const cn of struct.connections) {
4033
+ if (DISMISSAL_RELS.has(cn.relationship)) { dismissed.add(`${cn.fromId}|${cn.toId}`); dismissed.add(`${cn.toId}|${cn.fromId}`); }
4034
+ }
3565
4035
  for (const cn of struct.connections) {
3566
4036
  if (cn.label !== 'likely closed by' || !want.has(cn.fromId)) continue;
4037
+ if (dismissed.has(`${cn.fromId}|${cn.toId}`)) continue;
3567
4038
  const m = byId.get(cn.toId);
3568
4039
  if (!m || /^archive$/i.test(m.area || '')) continue; // a since-archived milestone no longer vouches
3569
4040
  const head = String(m.text || '').replace(/\s+/g, ' ').trim().slice(0, 100);
3570
- if (!map.has(cn.fromId)) map.set(cn.fromId, { by: head, byId: m.id });
4041
+ // Machine-written hints (capture-time detection, no human ✓) stay
4042
+ // HEDGED after persistence. Before this, one capture silently promoted
4043
+ // a suggestion to the confirmed "⏳ LIKELY FULFILLED" tier — the diff's
4044
+ // own hedging defeated one session later (2026-07-29 review, CONFIRMED).
4045
+ if (!map.has(cn.fromId)) map.set(cn.fromId, { by: head, byId: m.id, unconfirmed: cn.hintVia !== 'human' });
3571
4046
  }
3572
4047
  return map;
3573
4048
  }
@@ -3619,9 +4094,25 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
3619
4094
  const opens = live.filter(isOpenCard);
3620
4095
  const miles = live.filter(isMilestoneCard);
3621
4096
  if (!opens.length || !miles.length) return empty;
4097
+ // Human dismissals + existing hint edges suppress a pair here exactly as in
4098
+ // findFulfillmentCandidates — a rejected hint must never resurface in the
4099
+ // self-heal footer either (parity fix, 2026-07-29).
4100
+ const settled = new Set();
4101
+ for (const cn of struct.connections || []) {
4102
+ if (cn.label === 'likely closed by' || cn.relationship === 'not_fulfilled') settled.add(`${cn.fromId}|${cn.toId}`);
4103
+ }
4104
+ // Milestone stem sets ONCE — this sat inside the opens loop (opens × miles
4105
+ // tokenizations per SessionStart; pure waste at 1,500 cards).
4106
+ const mPre = miles.map(m => ({ m, idx: stemIndex(tokenSet(m.text)), tok: stemSet(tokenSet(m.text)) }));
4107
+ let df = null;
4108
+ const dfMap = () => (df ??= buildStemDf(struct));
3622
4109
  const out = [];
3623
4110
  for (const o of opens) {
3624
- const oTok = tokenSet(o.text);
4111
+ // claimTokens (stopword-stripped, URL-free) — parity with
4112
+ // findFulfillmentCandidates. The raw tokenSet left from/this/with in the
4113
+ // denominator, which both inflated coverage and offered junk anchors.
4114
+ const oIdx = stemIndex(claimTokens(normalizeWrappedProse(o.text)));
4115
+ const oTok = new Set(oIdx.keys());
3625
4116
  if (oTok.size < 3) continue; // too vague to match safely → leave it
3626
4117
  // CLAUSE-KEYED coverage (2026-07-23): the old whole-card denominator
3627
4118
  // meant a 6-token "remaining: X" clause inside a 40-token card could
@@ -3630,16 +4121,27 @@ export function findStaleOpenCards(struct, { coverAt = 0.6, max = 5 } = {}) {
3630
4121
  // best of (whole card, any clause item ≥2 tokens).
3631
4122
  // ≥3 tokens per clause item: 2-token items ('docs update') hit cov 1.0
3632
4123
  // against unrelated milestones (probe-confirmed false stale flags).
3633
- const clauseToks = extractOpenClauses(o.text).flatMap(cl => cl.items.map(it => it.tokens)).filter(t => t.size >= 3);
3634
- let best = null, bestCov = 0;
3635
- for (const m of miles) {
4124
+ const clauseToks = extractOpenClauses(o.text).flatMap(cl => cl.items.map(it => stemSet(it.tokens))).filter(t => t.size >= 3);
4125
+ let best = null, bestCov = 0, bestVia = null;
4126
+ for (const { m, idx: mIdx, tok: mTok } of mPre) {
3636
4127
  if ((m.createdAt || 0) <= (o.createdAt || 0)) continue; // only a milestone shipped AFTER the goal
3637
- const mTok = tokenSet(m.text);
4128
+ if (m.id && settled.has(`${o.id}|${m.id}`)) continue;
3638
4129
  let cov = coverageOf(oTok, mTok); // how much of the goal the milestone covers
3639
4130
  for (const ct of clauseToks) cov = Math.max(cov, coverageOf(ct, mTok));
3640
- if (cov > bestCov) { bestCov = cov; best = m; }
4131
+ if (cov >= coverAt) {
4132
+ if (cov > bestCov || bestVia === 'anchor') { bestCov = cov; best = m; bestVia = 'coverage'; }
4133
+ continue;
4134
+ }
4135
+ // Anchor OR-path — the verbose-❓/terse-🏁 geometry (see
4136
+ // findFulfillmentCandidates). Coverage wins over anchors when both fire.
4137
+ if (bestVia !== 'coverage') {
4138
+ const anchors = sharedAnchors(oIdx, mIdx, dfMap(), { exclude: structuralStems(o.area, m.area) });
4139
+ if (anchorsSufficient(anchors, (o.area || '') === (m.area || ''), cov) && cov > bestCov) {
4140
+ bestCov = cov; best = m; bestVia = 'anchor';
4141
+ }
4142
+ }
3641
4143
  }
3642
- if (best && bestCov >= coverAt) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100 });
4144
+ if (best) out.push({ open: o, by: best, cov: Math.round(bestCov * 100) / 100, ...(bestVia === 'anchor' ? { via: 'anchor' } : {}) });
3643
4145
  }
3644
4146
  out.sort((a, b) => b.cov - a.cov);
3645
4147
  return { gaps: out.slice(0, max), total: out.length };