klypix-mcp 1.86.1 → 1.86.2

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.
@@ -436,7 +436,9 @@ try {
436
436
  // deliberately preserving every host/project config byte.
437
437
  const brainCmd = (arg) => `node "${fwd(path.join(BRAIN_DIR, 'global-brain-hook.mjs'))}"${arg ? ' ' + arg : ''}`;
438
438
  const GROUPS = [
439
- ['SessionStart', { matcher: 'startup|resume', hooks: [{ type: 'command', command: brainCmd('') }] }],
439
+ // "clear" (1.86.2): a /clear starts a new conversation that needs the
440
+ // brief as much as a fresh one does.
441
+ ['SessionStart', { matcher: 'startup|resume|clear', hooks: [{ type: 'command', command: brainCmd('') }] }],
440
442
  ['UserPromptSubmit', { hooks: [{ type: 'command', command: brainCmd('--prompt'), timeout: 10 }] }],
441
443
  ['Stop', { hooks: [{ type: 'command', command: brainCmd('--capture') }] }],
442
444
  ['PostToolUse', { matcher: 'Bash|PowerShell|Edit|Write', hooks: [{ type: 'command', command: brainCmd('--live'), timeout: 10 }] }],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.86.1",
3
+ "version": "1.86.2",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -111,7 +111,15 @@ function inspectHooks(home) {
111
111
  const present = !!settings;
112
112
  const wired = present ? HOOK_EVENTS.filter(wiredFor) : [];
113
113
  const missing = present ? HOOK_EVENTS.filter(e => !wired.includes(e)) : HOOK_EVENTS.slice();
114
- return { settingsPresent: present, wired, missing };
114
+ // Informational (never a verdict): installs before 1.86.2 wired SessionStart
115
+ // for "startup|resume" only, so /clear starts a conversation with no brain
116
+ // brief. A runtime-only update never rewrites settings.json; a full
117
+ // `npx klypix-mcp install` does. An empty/absent matcher means every source.
118
+ const ours = (Array.isArray(settings?.hooks?.SessionStart) ? settings.hooks.SessionStart : [])
119
+ .filter(g => Array.isArray(g?.hooks) && g.hooks.some(h => typeof h?.command === 'string' && h.command.includes(HOOK_MARK)));
120
+ const coversClear = (g) => !g.matcher || String(g.matcher).split('|').map(x => x.trim()).some(x => x === 'clear' || x === '*');
121
+ const sessionStartMissesClear = ours.length > 0 && !ours.some(coversClear);
122
+ return { settingsPresent: present, wired, missing, sessionStartMissesClear };
115
123
  }
116
124
 
117
125
  // ── TOOLS (discoverable manifest) layer ──────────────────────────────────────
@@ -788,6 +796,7 @@ export function render(r, opts = {}) {
788
796
  else if (r.hooks.missing.length === 1 && r.hooks.missing[0] === 'PreToolUse') L.push(`${hmark} ${c.bold}CLAUDE${c.rst} capture path intact; ${c.yel}guard lane not wired yet${c.rst} — \`npx klypix-mcp install\` adds the PreToolUse hook (guard cards)`);
789
797
  else if (r.hooks.missing.length) L.push(`${hmark} ${c.bold}CLAUDE${c.rst} half-wired — missing: ${c.yel}${r.hooks.missing.join(', ')}${c.rst} ${c.dim}(liveness up, readiness no)${c.rst}`);
790
798
  else L.push(`${hmark} ${c.bold}CLAUDE${c.rst} existing 5-hook capture path intact: ${r.hooks.wired.join(', ')}`);
799
+ if (r.hooks.sessionStartMissesClear) L.push(` ${c.dim}note: SessionStart is not wired for /clear — a cleared conversation starts without the brain brief; "npx klypix-mcp install" adds "clear" to its matcher${c.rst}`);
791
800
  const chmark = r.layers.codexHooks === 'warning' ? warn : ok;
792
801
  const smart = r.codexSmart?.globalInstructions
793
802
  ? 'approval-free Context Gateway active (task memory + clean peers + proactive/guaranteed alerts)'
@@ -420,8 +420,51 @@ function reconcileFooter(lib, struct) {
420
420
  // project into two registry entries AND two parse-cache files.
421
421
  const normBrainPath = (p) => String(p).replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
422
422
 
423
+ // The seen set is a FIFO capped at STATE_SEEN_MAX. Every Stop re-reads the
424
+ // whole transcript, so a key is only safe to evict once no live transcript
425
+ // still carries its line: a capture moves every key it HIT to the young end
426
+ // (doCapture), and the cap leaves room for several busy sessions (review
427
+ // 2026-09-18, third round: KLYPIX held 1,658 of 2,000, and a resumed old
428
+ // session whose keys aged out re-applied its ~ lines over newer edits).
429
+ const STATE_SEEN_MAX = 5000;
423
430
  const readState = () => { try { return new Set(JSON.parse(fs.readFileSync(STATE, 'utf8')).seen || []); } catch { return new Set(); } };
424
- const writeState = (seen) => { try { fs.mkdirSync(path.dirname(STATE), { recursive: true }); fs.writeFileSync(STATE, JSON.stringify({ seen: [...seen].slice(-2000) })); } catch { /* ignore */ } };
431
+ // `legacyUntil` (1.86.2): the moment before which an OLDER hook may have keyed
432
+ // this project's markers. Older hooks keyed a marker on the text they CUT it to
433
+ // (1.86.0 at the first whitespace + closes:/ev:/verify:/q:, 1.85 the same
434
+ // without q:), and wrote no per-line key for ~ / ✓, so the upgrade path consults
435
+ // those old keys, and treats a ~ / ✓ line as already applied, ONLY for
436
+ // transcript events older than this. It used to consult them forever, and a
437
+ // 1.86.1 key is byte-identical to the old-cut key of a LATER note that starts
438
+ // with the same sentence ("X ev: README.md", then "X Q: and A: …"): the later
439
+ // note was folded into the earlier card as a "repair", dropped as "not
440
+ // re-added", or had its closes: skipped (review 2026-09-18, third round).
441
+ // no state file yet → 0: no older hook ever keyed this project;
442
+ // a state file without it → its mtime: the last write an older hook made
443
+ // (every event it keyed is older than that);
444
+ // a stamped state file → the stamp, carried by every writeState.
445
+ // An older hook that writes the file later drops the field, and the next run
446
+ // re-stamps from that newer mtime — which is right: it keyed events up to then.
447
+ let stateLegacyUntil = null;
448
+ function captureLegacyUntil() {
449
+ if (stateLegacyUntil !== null) return stateLegacyUntil;
450
+ try {
451
+ const raw = fs.readFileSync(STATE, 'utf8');
452
+ let parsed = null; try { parsed = JSON.parse(raw); } catch { /* corrupt → treat as an older writer */ }
453
+ const stamp = Number(parsed && parsed.legacyUntil);
454
+ if (parsed && Object.prototype.hasOwnProperty.call(parsed, 'legacyUntil') && Number.isFinite(stamp) && stamp >= 0) stateLegacyUntil = stamp;
455
+ else { let mtime = Date.now(); try { mtime = fs.statSync(STATE).mtimeMs; } catch { /* keep now */ } stateLegacyUntil = Math.min(mtime, Date.now()); }
456
+ } catch (error) {
457
+ stateLegacyUntil = error && error.code === 'ENOENT' ? 0 : Date.now();
458
+ }
459
+ return stateLegacyUntil;
460
+ }
461
+ const writeState = (seen) => {
462
+ try {
463
+ const legacyUntil = captureLegacyUntil(); // resolved BEFORE this write changes the mtime
464
+ fs.mkdirSync(path.dirname(STATE), { recursive: true });
465
+ fs.writeFileSync(STATE, JSON.stringify({ seen: [...seen].slice(-STATE_SEEN_MAX), legacyUntil }));
466
+ } catch { /* ignore */ }
467
+ };
425
468
  const messageHandledKey = (messageId) => `message:${String(messageId || '')}`;
426
469
  // Persist only these message keys into a fresh read. The in-memory capture set
427
470
  // may already contain brain-card markers whose brain write has not happened;
@@ -523,21 +566,86 @@ function appendJsonl(file, obj, maxLines = 0) {
523
566
  // is stolen so a crashed session can't wedge the brain forever. Best-effort: if
524
567
  // it can't get the lock within the budget it writes anyway (better than dropping
525
568
  // the markers) and flags it in the health log.
569
+ //
570
+ // Stale-lock break (review 2026-09-18, third round). The holder writes
571
+ // "<pid> <token>"; a lock is stale when that pid is provably dead (a crash no
572
+ // longer costs every waiter up to 15 s — ~2.5 s per prompt), or when its mtime
573
+ // is more than LOCK_STALE_MS away from now in EITHER direction (a crashed
574
+ // holder's lock after the clock stepped back used to stay forever). Breaking is
575
+ // serialized by a short-lived "<lock>.break" file and re-checks that the lock
576
+ // is still the SAME stale one before unlinking it: two waiters that both saw it
577
+ // stale used to unlink it in turn, and the second deleted the first one's
578
+ // fresh lock, so both held it. A release only removes a lock this process
579
+ // still owns. Lock files written by other writers (a bare pid, a JSON object,
580
+ // a test's placeholder) are read the same way; one whose pid cannot be read is
581
+ // only ever broken by age.
526
582
  const LOCK_STALE_MS = 15000;
583
+ const LOCK_BREAK_STALE_MS = 5000;
527
584
  const sleepSync = (ms) => { try { Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); } catch { /* */ } };
585
+ const heldLockTokens = new Map(); // lockPath → the content this process wrote
586
+ const lockHolderPid = (content) => {
587
+ const text = String(content || '').trim();
588
+ if (text.startsWith('{')) { try { return Number(JSON.parse(text).pid) || null; } catch { return null; } }
589
+ const m = /^(\d+)(?:\s|$)/.exec(text);
590
+ return m ? Number(m[1]) : null;
591
+ };
592
+ // `isProcessAlive` is declared with the presence helpers further down; every
593
+ // lock call site runs from main(), long after module init, so the forward
594
+ // reference is a runtime read, never a TDZ one. Keep it that way.
595
+ function lockIsStale(lockPath, now = Date.now()) {
596
+ let content, mtimeMs;
597
+ try { content = fs.readFileSync(lockPath, 'utf8'); mtimeMs = fs.statSync(lockPath).mtimeMs; } catch { return null; }
598
+ const pid = lockHolderPid(content);
599
+ const stale = (pid && pid !== process.pid && isProcessAlive(pid) === false) || Math.abs(now - mtimeMs) > LOCK_STALE_MS;
600
+ return stale ? { content, mtimeMs } : null;
601
+ }
602
+ function breakStaleLock(lockPath, seen) {
603
+ const breaker = `${lockPath}.break`;
604
+ let fd;
605
+ try { fd = fs.openSync(breaker, 'wx'); }
606
+ catch (e) {
607
+ // Another waiter is breaking it. A breaker file left by a crash inside
608
+ // this few-microsecond window is itself broken by age.
609
+ try { if (e?.code === 'EEXIST' && Math.abs(Date.now() - fs.statSync(breaker).mtimeMs) > LOCK_BREAK_STALE_MS) fs.unlinkSync(breaker); } catch { /* raced */ }
610
+ return false;
611
+ }
612
+ try {
613
+ fs.closeSync(fd);
614
+ const now = lockIsStale(lockPath);
615
+ if (!now || now.content !== seen.content || now.mtimeMs !== seen.mtimeMs) return false; // not the lock we judged
616
+ fs.unlinkSync(lockPath);
617
+ return true;
618
+ } catch { return false; }
619
+ finally { try { fs.unlinkSync(breaker); } catch { /* */ } }
620
+ }
528
621
  function acquireLock(lockPath, { tries = 60, waitMs = 60 } = {}) {
529
622
  try { fs.mkdirSync(path.dirname(lockPath), { recursive: true }); } catch { /* */ }
530
623
  for (let i = 0; i < tries; i++) {
531
- try { const fd = fs.openSync(lockPath, 'wx'); fs.writeSync(fd, String(process.pid)); fs.closeSync(fd); return true; }
624
+ try {
625
+ const token = `${process.pid} ${crypto.randomBytes(6).toString('hex')}`;
626
+ const fd = fs.openSync(lockPath, 'wx'); fs.writeSync(fd, token); fs.closeSync(fd);
627
+ heldLockTokens.set(lockPath, token);
628
+ return true;
629
+ }
532
630
  catch (e) {
533
631
  if (e && e.code !== 'EEXIST') return false; // unexpected FS error → caller writes best-effort
534
- try { if (Date.now() - fs.statSync(lockPath).mtimeMs > LOCK_STALE_MS) { fs.unlinkSync(lockPath); continue; } } catch { /* lost a race on the stale file — just retry */ }
632
+ const stale = lockIsStale(lockPath);
633
+ if (stale && breakStaleLock(lockPath, stale)) continue;
535
634
  sleepSync(waitMs);
536
635
  }
537
636
  }
538
637
  return false; // contended past ~3.6s → write best-effort (rare; captures are sub-second)
539
638
  }
540
- function releaseLock(lockPath) { try { fs.unlinkSync(lockPath); } catch { /* */ } }
639
+ function releaseLock(lockPath) {
640
+ const token = heldLockTokens.get(lockPath);
641
+ heldLockTokens.delete(lockPath);
642
+ try {
643
+ // Never remove a lock this process no longer owns (it was broken as
644
+ // stale and re-taken by another writer).
645
+ if (token !== undefined && fs.readFileSync(lockPath, 'utf8') !== token) return;
646
+ fs.unlinkSync(lockPath);
647
+ } catch { /* */ }
648
+ }
541
649
 
542
650
  // ── Live cross-session coordination (brain.sessions heartbeat) ───────────────
543
651
  // The brain is ASYNC memory — capture on Stop, recall on the next Start — so two
@@ -702,6 +810,43 @@ function queuePendingCapture(batch) {
702
810
  function clearDrainedOrphans(paths) {
703
811
  for (const f of (paths || [])) { try { fs.unlinkSync(f); } catch { /* re-drained next time; landing twice is superseded away */ } }
704
812
  }
813
+ // A queued batch the engine throws on (review 2026-09-18, third round) is
814
+ // blamed each time a capture has to land without it; after
815
+ // DRAIN_FAILURES_MAX such drains it is moved to its own
816
+ // "<queue>.poison-<id>.json" — kept, never drained again, and reported —
817
+ // instead of costing every later capture in the project a failed attempt.
818
+ // Returns the batches set aside by this call.
819
+ const DRAIN_FAILURES_MAX = 3;
820
+ function recordDrainFailure(batches, error) {
821
+ const quarantined = [];
822
+ try {
823
+ const reason = String((error && error.message) || error || 'unknown').slice(0, 200);
824
+ const bump = (b) => ({ ...b, drainFailures: (Number(b.drainFailures) || 0) + 1, lastDrainError: reason });
825
+ const setAside = (b) => {
826
+ try {
827
+ const { __orphanPath, ...clean } = b;
828
+ const file = `${PENDING_CAPTURES_FILE}.poison-${String(b.id || Date.now()).replace(/[^A-Za-z0-9_-]+/g, '-')}.json`;
829
+ fs.writeFileSync(file, JSON.stringify({ ...clean, quarantinedAt: nowIso() }));
830
+ quarantined.push({ id: b.id || null, file, cards: (b.cards || []).length, updates: (b.updates || []).length, resolutions: (b.resolutions || []).length, first: String((b.cards?.[0]?.text) || (b.updates?.[0]?.text) || (b.resolutions?.[0]?.text) || '').replace(/\s+/g, ' ').slice(0, 90) });
831
+ return true;
832
+ } catch { return false; }
833
+ };
834
+ const mainIds = new Set(batches.filter(b => b && !b.__orphanPath && b.id).map(b => b.id));
835
+ if (mainIds.size) {
836
+ updatePendingCaptures((current) => current.flatMap((b) => {
837
+ if (!b || !mainIds.has(b.id)) return [b];
838
+ const next = bump(b);
839
+ return next.drainFailures >= DRAIN_FAILURES_MAX && setAside(next) ? [] : [next];
840
+ }));
841
+ }
842
+ for (const b of batches.filter(x => x && x.__orphanPath)) {
843
+ const next = bump(b);
844
+ if (next.drainFailures >= DRAIN_FAILURES_MAX && setAside(next)) { try { fs.unlinkSync(b.__orphanPath); } catch { /* re-drained, then set aside again */ } continue; }
845
+ try { const { __orphanPath, ...clean } = next; const tmp = `${b.__orphanPath}.tmp-${process.pid}`; fs.writeFileSync(tmp, JSON.stringify(clean)); fs.renameSync(tmp, b.__orphanPath); } catch { /* the count is best-effort */ }
846
+ }
847
+ } catch { /* best-effort: an unrecorded failure only delays the set-aside */ }
848
+ return quarantined;
849
+ }
705
850
  const SESSION_FRESH_MS = 10 * 60 * 1000; // a lane unseen for 10min is treated as ended
706
851
  const MCP_SESSION_FRESH_MS = 3 * 60 * 1000; // an mcp-channel heartbeat is dead after 3min (matches agent-presence)
707
852
  // ── Dead-host sweep (BEHAVIOR PARITY with agent-presence.mjs isDeadHostRow —
@@ -2462,16 +2607,65 @@ const readSidecar = () => { try { const d = JSON.parse(fs.readFileSync(RULE_DRAF
2462
2607
  // backoff and the tmp is removed if it never lands. Returns whether the write
2463
2608
  // landed — a caller that marks something "shown" must know.
2464
2609
  const SIDECAR_RENAME_BACKOFF_MS = [20, 50, 120, 250, 500];
2610
+ // A sidecar that CANNOT be written (a read-only attribute, a deny-delete ACL, a
2611
+ // handle held without FILE_SHARE_DELETE) used to cost the whole ~940 ms backoff
2612
+ // inside the lock on every prompt with a pending receipt, for up to three days
2613
+ // (review 2026-09-18, third round: three prompts at ~1.33 s each against a
2614
+ // ~0.4 s baseline). A read-only destination now fails at once; a final
2615
+ // permission failure stamps the per-project health dir, and for
2616
+ // SIDECAR_FAIL_BACKOFF_MS every writer skips straight to "not written" (the
2617
+ // callers already treat that as "leave it pending"). Only EBUSY, or EPERM /
2618
+ // EACCES on a writable destination (a reader holding it for a moment), is
2619
+ // retried with backoff.
2620
+ const SIDECAR_FAIL_BACKOFF_MS = 10 * 60 * 1000;
2621
+ const SIDECAR_FAIL_STAMP = HEALTH.replace(/\.jsonl$/, '') + '.sidecar-unwritable';
2622
+ const stampSidecarFailure = (code) => { try { fs.mkdirSync(path.dirname(SIDECAR_FAIL_STAMP), { recursive: true }); fs.writeFileSync(SIDECAR_FAIL_STAMP, `${nowIso()} ${code || 'unwritable'} ${RULE_DRAFTS}\n`); } catch { /* best-effort */ } };
2623
+ // The FILE's own writability, deliberately: on Windows the read-only attribute
2624
+ // is exactly what makes MoveFileEx(REPLACE_EXISTING) fail, which is the field
2625
+ // case. On POSIX a rename-over only needs the DIRECTORY, so this also refuses
2626
+ // to replace a sidecar someone marked read-only there — a small, intentional
2627
+ // change of behaviour in the direction of not clobbering it, and one the stamp
2628
+ // undoes the instant the attribute is cleared.
2629
+ const sidecarWritable = () => {
2630
+ try { fs.accessSync(RULE_DRAFTS, fs.constants.W_OK); return true; }
2631
+ catch (error) { return error?.code === 'ENOENT'; } // absent → the rename creates it
2632
+ };
2633
+ // The backoff must not outlive its cause: the moment the destination is
2634
+ // writable again (the common case — someone cleared a read-only attribute) the
2635
+ // stamp is dropped and the next write goes through. It only holds the skip
2636
+ // while the destination still refuses, or while it is writable-but-locked
2637
+ // (an ACL or a handle held without FILE_SHARE_DELETE, which accessSync cannot
2638
+ // see) — then the 10 minutes are what keeps a doomed ~940 ms backoff off every
2639
+ // single prompt for three days.
2640
+ const sidecarRecentlyFailed = () => {
2641
+ let raw = '', fresh = false;
2642
+ try { fresh = Date.now() - fs.statSync(SIDECAR_FAIL_STAMP).mtimeMs < SIDECAR_FAIL_BACKOFF_MS; raw = fs.readFileSync(SIDECAR_FAIL_STAMP, 'utf8'); } catch { return false; }
2643
+ if (!fresh) return false;
2644
+ // A read-only destination is the one cause someone can clear in a second,
2645
+ // and the one accessSync can see: the moment it is gone, so is the backoff.
2646
+ if (/\bread-only\b/.test(raw) && sidecarWritable()) { try { fs.unlinkSync(SIDECAR_FAIL_STAMP); } catch { /* raced with another writer */ } return false; }
2647
+ return true;
2648
+ };
2465
2649
  const writeSidecar = (patch) => {
2466
2650
  let tmp = null;
2467
2651
  try {
2652
+ if (sidecarRecentlyFailed()) return false;
2653
+ if (!sidecarWritable()) { stampSidecarFailure('read-only'); return false; }
2468
2654
  fs.mkdirSync(path.dirname(RULE_DRAFTS), { recursive: true });
2469
2655
  tmp = `${RULE_DRAFTS}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
2470
2656
  fs.writeFileSync(tmp, JSON.stringify({ ...readSidecar(), ...patch }, null, 2));
2471
2657
  for (let attempt = 0; ; attempt++) {
2472
- try { fs.renameSync(tmp, RULE_DRAFTS); return true; }
2473
- catch (error) {
2474
- if (attempt >= SIDECAR_RENAME_BACKOFF_MS.length || !['EPERM', 'EACCES', 'EBUSY'].includes(error?.code)) throw error;
2658
+ try {
2659
+ fs.renameSync(tmp, RULE_DRAFTS);
2660
+ if (attempt > 0 || fs.existsSync(SIDECAR_FAIL_STAMP)) { try { fs.unlinkSync(SIDECAR_FAIL_STAMP); } catch { /* none */ } }
2661
+ return true;
2662
+ } catch (error) {
2663
+ const code = error?.code;
2664
+ if (attempt >= SIDECAR_RENAME_BACKOFF_MS.length || !['EPERM', 'EACCES', 'EBUSY'].includes(code)) {
2665
+ if (code === 'EPERM' || code === 'EACCES') stampSidecarFailure(code);
2666
+ throw error;
2667
+ }
2668
+ if (code !== 'EBUSY' && !sidecarWritable()) { stampSidecarFailure('read-only'); throw error; }
2475
2669
  sleepSync(SIDECAR_RENAME_BACKOFF_MS[attempt]);
2476
2670
  }
2477
2671
  }
@@ -2739,28 +2933,58 @@ async function findingDraftsFooter(sid, { markShown = true } = {}) {
2739
2933
  // stdout is model context), three at a time. Deduped by key; TTL-pruned;
2740
2934
  // never throws.
2741
2935
  //
2742
- // A receipt from a session's FINAL Stop would never be seen, so a SessionStart
2743
- // ADOPTS receipts whose author has ended: it reassigns them to itself, and its
2744
- // own first prompt prints them — nothing is printed at SessionStart, where the
2745
- // block landed past the ~2 KB preview the harness shows (review 2026-09-18).
2746
- // "Ended" is decided from the lane, never from age alone: a receipt whose
2747
- // author still has a LIVE lane row stays with that author (a new session
2748
- // used to print — and mark shown for everyone — a live session's receipts, so
2749
- // the author never saw them). The one live row that does not count is a
2750
- // predecessor in this same host process: a /clear or resume replaced that
2751
- // conversation, so its receipts move to the new session at once. With no live
2752
- // lane row, a receipt unshown for CAPTURE_RECEIPT_HANDOFF_MS is adopted.
2753
- // Adoption and every shown-mark happen under RULE_DRAFTS_LOCK in ONE
2754
- // read-modify-write, so two sessions can never both take the same receipt, and
2755
- // a receipt is printed only when its shown-mark was actually persisted (an
2756
- // unwritable sidecar printed the same receipt on every prompt).
2936
+ // A receipt from a session's FINAL Stop would never be seen, so another
2937
+ // session ADOPTS it: it reassigns the receipt to itself, and its own next
2938
+ // prompt prints it — nothing is printed at SessionStart, where the block
2939
+ // landed past the ~2 KB preview the harness shows (review 2026-09-18).
2940
+ //
2941
+ // Who may adopt (third review, 2026-09-18). Every receipt records the host
2942
+ // process (CLAUDE_PID) and the machine of the session that wrote it.
2943
+ // • The same host process — a /clear or an in-app /resume replaced that
2944
+ // conversation: the new session's next PROMPT adopts it. The prompt hook
2945
+ // always fires; SessionStart on /clear only runs where the installed
2946
+ // matcher lists "clear" (1.86.1's did not, and a runtime update never
2947
+ // rewrites settings.json), so a handoff that lived only at SessionStart
2948
+ // never ran after a real /clear, and an unrelated session printed the
2949
+ // receipt half an hour later. Capped at CAPTURE_RECEIPT_PREDECESSOR_MS, so
2950
+ // an OS-reused pid never claims an old receipt.
2951
+ // • Another host process on this machine: only once that process is
2952
+ // PROVABLY gone (isProcessAlive === false), at the next SessionStart. An
2953
+ // author that is idle but alive keeps its receipts however long it idles
2954
+ // ("no live lane row for 30 minutes" only ever meant "idle for 30
2955
+ // minutes"), and a receipt from ANOTHER machine is never adopted — its
2956
+ // TTL expires it.
2957
+ // • A receipt with no host pid (a session without CLAUDE_PID, or one 1.86.1
2958
+ // wrote) keeps the old rule: no live lane row, unshown for
2959
+ // CAPTURE_RECEIPT_HANDOFF_MS — and its heading does not claim the author
2960
+ // has ended.
2961
+ // Adoption re-stamps the receipt with the adopter's host, machine and time, so
2962
+ // it does not hop again. Adoption and every shown-mark happen under
2963
+ // RULE_DRAFTS_LOCK in ONE read-modify-write, so two sessions can never both
2964
+ // take the same receipt, and a receipt is printed only when its shown-mark was
2965
+ // actually persisted (an unwritable sidecar printed the same receipt on every
2966
+ // prompt).
2757
2967
  const CAPTURE_RECEIPT_TTL_MS = 3 * 24 * 60 * 60 * 1000;
2758
2968
  const CAPTURE_RECEIPTS_MAX = 40;
2759
2969
  const CAPTURE_RECEIPTS_SHOWN = 3;
2760
- const CAPTURE_RECEIPT_HANDOFF_MS = 30 * 60 * 1000; // no live lane row + unshown this long → a new session adopts it
2970
+ const CAPTURE_RECEIPT_HANDOFF_MS = 30 * 60 * 1000; // legacy rule: no live lane row + unshown this long
2971
+ const CAPTURE_RECEIPT_PREDECESSOR_MS = 24 * 60 * 60 * 1000; // same-host handoff window (pid reuse guard)
2761
2972
  const readCaptureReceipts = () => { const d = readSidecar(); return Array.isArray(d.captureReceipts) ? d.captureReceipts : []; };
2973
+ // The cap drops SHOWN receipts first, oldest first, and an unshown one only
2974
+ // when more than CAPTURE_RECEIPTS_MAX are unshown: trimming by position used
2975
+ // to push an idle author's unshown receipt out while shown ones sat waiting
2976
+ // for their TTL (review 2026-09-18, third round).
2977
+ function capCaptureReceipts(list) {
2978
+ if (list.length <= CAPTURE_RECEIPTS_MAX) return list;
2979
+ let excess = list.length - CAPTURE_RECEIPTS_MAX;
2980
+ const drop = new Set();
2981
+ for (const r of list) { if (excess <= 0) break; if (r.shown) { drop.add(r); excess--; } }
2982
+ for (const r of list) { if (excess <= 0) break; if (!drop.has(r)) { drop.add(r); excess--; } }
2983
+ return list.filter(r => !drop.has(r));
2984
+ }
2762
2985
  // → 'written' | 'unchanged' | false (lock timeout or a failed write).
2763
2986
  function persistCaptureReceipts(mutate) {
2987
+ if (sidecarRecentlyFailed()) return false; // known unwritable: never pay the lock + backoff again
2764
2988
  const got = acquireLock(RULE_DRAFTS_LOCK, { tries: 80, waitMs: 25 });
2765
2989
  if (!got) return false;
2766
2990
  try {
@@ -2768,11 +2992,12 @@ function persistCaptureReceipts(mutate) {
2768
2992
  const raw = readCaptureReceipts();
2769
2993
  const rawJson = JSON.stringify(raw);
2770
2994
  const live = raw.filter(r => r && r.key && r.line && (now - (r.ts || 0)) < CAPTURE_RECEIPT_TTL_MS);
2771
- const next = (mutate(live, now) || live).slice(-CAPTURE_RECEIPTS_MAX);
2995
+ const next = capCaptureReceipts(mutate(live, now) || live);
2772
2996
  if (JSON.stringify(next) === rawJson) return 'unchanged';
2773
2997
  return writeSidecar({ captureReceipts: next }) ? 'written' : false;
2774
2998
  } catch { return false; } finally { releaseLock(RULE_DRAFTS_LOCK); }
2775
2999
  }
3000
+ const receiptHost = () => ({ ...(HOST_PID ? { hostPid: HOST_PID } : {}), machine: MACHINE_ID });
2776
3001
  // Record [{ key, line }] for this session (a key already recorded is skipped).
2777
3002
  function recordCaptureReceipts(sid, items) {
2778
3003
  try {
@@ -2782,13 +3007,26 @@ function recordCaptureReceipts(sid, items) {
2782
3007
  for (const it of items) {
2783
3008
  if (!it || !it.key || !it.line || have.has(it.key)) continue;
2784
3009
  have.add(it.key);
2785
- list.push({ key: it.key, sid: String(sid), ts: now, line: String(it.line).slice(0, 700), shown: false });
3010
+ list.push({ key: it.key, sid: String(sid), ts: now, line: String(it.line).slice(0, 700), shown: false, ...receiptHost() });
2786
3011
  }
2787
3012
  return list;
2788
3013
  });
2789
3014
  } catch { return false; }
2790
3015
  }
2791
- // SessionStart: take over receipts an ended session never saw (see above).
3016
+ // An unshown receipt from ANOTHER session of this same host process: the
3017
+ // conversation this one replaced (/clear, in-app /resume).
3018
+ const isPredecessorReceipt = (r, sid, now) => Boolean(HOST_PID && r && !r.shown && r.sid !== String(sid)
3019
+ && Number(r.hostPid) === HOST_PID && r.machine === MACHINE_ID
3020
+ && now - (r.ts || 0) < CAPTURE_RECEIPT_PREDECESSOR_MS);
3021
+ function adoptReceipt(r, sid, now, why) {
3022
+ r.adoptedFrom = r.adoptedFrom || r.sid;
3023
+ r.adoptedWhy = why;
3024
+ r.sid = String(sid);
3025
+ r.ts = now;
3026
+ if (HOST_PID) r.hostPid = HOST_PID; else delete r.hostPid;
3027
+ r.machine = MACHINE_ID;
3028
+ }
3029
+ // SessionStart: take over receipts whose author is provably gone (see above).
2792
3030
  // Returns how many were adopted; prints nothing.
2793
3031
  function adoptCaptureReceipts(sid) {
2794
3032
  try {
@@ -2799,15 +3037,23 @@ function adoptCaptureReceipts(sid) {
2799
3037
  const hostOf = new Map(rows.filter(r => r && r.id).map(r => [String(r.id), r.hostPid]));
2800
3038
  const liveIds = new Set(pruneSessions(rows, now).map(r => String(r.id)));
2801
3039
  let adopted = 0;
2802
- const res = persistCaptureReceipts((list) => {
3040
+ const res = persistCaptureReceipts((list, at) => {
2803
3041
  adopted = 0;
2804
3042
  for (const r of list) {
2805
3043
  if (r.shown || r.sid === String(sid)) continue;
2806
- const predecessor = Boolean(HOST_PID && hostOf.get(String(r.sid)) === HOST_PID);
2807
- const ended = !liveIds.has(String(r.sid)) && (now - (r.ts || 0)) > CAPTURE_RECEIPT_HANDOFF_MS;
2808
- if (!predecessor && !ended) continue;
2809
- r.adoptedFrom = r.adoptedFrom || r.sid;
2810
- r.sid = String(sid);
3044
+ let why = null;
3045
+ if (isPredecessorReceipt(r, sid, at)) why = 'predecessor';
3046
+ else if (r.hostPid !== undefined && r.hostPid !== null) {
3047
+ if (r.machine === MACHINE_ID && isProcessAlive(r.hostPid) === false) why = 'ended';
3048
+ } else if (r.machine && r.machine !== MACHINE_ID) {
3049
+ why = null; // another machine's session: never ours to take
3050
+ } else if (HOST_PID && hostOf.get(String(r.sid)) === HOST_PID) {
3051
+ why = 'predecessor'; // a 1.86.1 receipt: the lane knows its host
3052
+ } else if (!liveIds.has(String(r.sid)) && (at - (r.ts || 0)) > CAPTURE_RECEIPT_HANDOFF_MS) {
3053
+ why = 'idle';
3054
+ }
3055
+ if (!why) continue;
3056
+ adoptReceipt(r, sid, at, why);
2811
3057
  adopted++;
2812
3058
  }
2813
3059
  return list;
@@ -2815,15 +3061,18 @@ function adoptCaptureReceipts(sid) {
2815
3061
  return res ? adopted : 0;
2816
3062
  } catch { return 0; }
2817
3063
  }
2818
- // The block the next prompt prints for THIS session (its own receipts and the
2819
- // ones it adopted), marking what it shows in the same locked write. Empty
2820
- // string when there is nothing — the zero-cost contract.
3064
+ // The block the next prompt prints for THIS session: its own receipts, the
3065
+ // ones it adopted, and — adopted right here, in the same locked write that
3066
+ // marks what it shows — a replaced conversation's in this host process.
3067
+ // Empty string when there is nothing — the zero-cost contract.
2821
3068
  function captureReceiptsFooter(sid) {
2822
3069
  try {
2823
3070
  if (!sid) return '';
2824
- if (!readCaptureReceipts().some(r => r && !r.shown && r.sid === String(sid))) return ''; // lock-free fast path
3071
+ const probeAt = Date.now();
3072
+ if (!readCaptureReceipts().some(r => r && !r.shown && (r.sid === String(sid) || isPredecessorReceipt(r, sid, probeAt)))) return ''; // lock-free fast path
2825
3073
  let display = [], remaining = 0;
2826
- const res = persistCaptureReceipts((list) => {
3074
+ const res = persistCaptureReceipts((list, now) => {
3075
+ for (const r of list) if (isPredecessorReceipt(r, sid, now)) adoptReceipt(r, sid, now, 'predecessor');
2827
3076
  const pending = list.filter(r => !r.shown && r.sid === String(sid));
2828
3077
  display = pending.slice(0, CAPTURE_RECEIPTS_SHOWN).map(r => ({ ...r }));
2829
3078
  remaining = pending.length - display.length;
@@ -2832,14 +3081,18 @@ function captureReceiptsFooter(sid) {
2832
3081
  return list;
2833
3082
  });
2834
3083
  if (res !== 'written' || !display.length) return '';
2835
- const adopted = display.filter(r => r.adoptedFrom).length;
3084
+ const adopted = display.filter(r => r.adoptedFrom);
3085
+ const from = [...new Set(adopted.map(r => r.adoptedWhy || 'idle'))].map(why => why === 'predecessor'
3086
+ ? 'the conversation this one replaced in this terminal (/clear or /resume)'
3087
+ : why === 'ended' ? 'a session that is no longer running'
3088
+ : 'a session with no activity for 30+ minutes (it may have ended)');
2836
3089
  const lines = ['', '---',
2837
- adopted === 0 ? '## ⚠️ Brain capture — what your last 🧠 BRAIN markers actually did'
2838
- : adopted === display.length ? '## ⚠️ Brain capture — markers from an earlier, ended session that did not do what they said'
3090
+ adopted.length === 0 ? '## ⚠️ Brain capture — what your last 🧠 BRAIN markers actually did'
3091
+ : adopted.length === display.length ? '## ⚠️ Brain capture — markers from an earlier session that did not do what they said'
2839
3092
  : '## ⚠️ Brain capture — markers that did not do what they said',
2840
- adopted === 0
3093
+ adopted.length === 0
2841
3094
  ? 'The Stop hook captured these, but not the way the marker reads. If it matters, re-emit a corrected marker; nothing else is needed.'
2842
- : 'The Stop hook captured these, but not the way the marker reads. Lines marked (earlier session) came from a session that has ended: check the card before acting, and re-emit a corrected marker only when you know what it meant — never a closes: you cannot verify.'];
3095
+ : `The Stop hook captured these, but not the way the marker reads. Lines marked (earlier session) came from ${from.join(' or ')}: check the card before acting, and re-emit a corrected marker only when you know what it meant — never a closes: you cannot verify.`];
2843
3096
  for (const r of display) lines.push(`- ${r.adoptedFrom ? '(earlier session) ' : ''}${r.line}`);
2844
3097
  if (remaining > 0) lines.push(`- …and ${remaining} more, shown on the next prompt.`);
2845
3098
  return '\n' + lines.join('\n') + '\n';
@@ -2917,6 +3170,20 @@ async function capture(lib) {
2917
3170
  const gapPaths = new Set(), gapShellCmds = [], gapErrorIds = new Set();
2918
3171
  let gapAuthored = 0, gapLatestArtifactAt = 0;
2919
3172
  const seen = readState();
3173
+ // Keys this capture found already seen: they move to the young end of the
3174
+ // FIFO so a transcript still being re-read never ages out of it — in
3175
+ // doCapture on a real capture, and here on a Stop with nothing new.
3176
+ const hitKeys = new Set();
3177
+ const refreshHitKeys = () => {
3178
+ if (!hitKeys.size) return;
3179
+ const before = [...readState()];
3180
+ const merged = new Set(before);
3181
+ for (const k of hitKeys) if (merged.delete(k)) merged.add(k); // only keys the state already holds
3182
+ const after = [...merged];
3183
+ if (after.length !== before.length || after.some((k, i) => k !== before[i])) writeState(merged);
3184
+ };
3185
+ // See captureLegacyUntil: older hooks' keys only count for older events.
3186
+ const legacyUntil = captureLegacyUntil();
2920
3187
  const cards = [];
2921
3188
  const resolutions = [];
2922
3189
  const updates = [];
@@ -3023,7 +3290,7 @@ async function capture(lib) {
3023
3290
  const sourceEvent = String(e.uuid || e.id || e.timestamp || e.ts || transcriptIndex);
3024
3291
  const messageId = sha(`msg|${sid}|${sourceEvent}|${target}|${txt}`);
3025
3292
  const handledKey = messageHandledKey(messageId);
3026
- if (seen.has(handledKey)) continue;
3293
+ if (seen.has(handledKey)) { hitKeys.add(handledKey); continue; }
3027
3294
  messages.push({
3028
3295
  id: messageId,
3029
3296
  from: sid,
@@ -3111,6 +3378,14 @@ async function capture(lib) {
3111
3378
  // repair of THAT card (engine STUB REPAIR, rewritten in place, never
3112
3379
  // a sibling); any other old key simply means "already captured".
3113
3380
  //
3381
+ // …but ONLY for a transcript event older than `legacyUntil`, the
3382
+ // last moment an older hook could have keyed it (third review: the
3383
+ // old-cut key "X" of "X Q: and A: …" is byte-for-byte this grammar's
3384
+ // key for an earlier "X ev: README.md", so a new note was folded
3385
+ // into an unrelated card, dropped, or had its closes: skipped — long
3386
+ // after any upgrade). A project that never ran an older hook has
3387
+ // legacyUntil 0 and never looks.
3388
+ //
3114
3389
  // ~ and ✓ keep their text bypass (a self-heal re-stamp of unchanged
3115
3390
  // text is a NEW marker and must apply), but ONE transcript line
3116
3391
  // applies once: Stop re-reads the whole transcript, and re-applying
@@ -3118,34 +3393,51 @@ async function capture(lib) {
3118
3393
  // another session's newer card, and landed a thin ~ on a different
3119
3394
  // card once its own was resolved (review 2026-09-18). The key is the
3120
3395
  // transcript event + the line, recorded with the rest of the seen
3121
- // set only when the batch is durably written or queued.
3396
+ // set only when the batch is durably written or queued. An older
3397
+ // hook wrote no such key, so a ~ / ✓ line from an event older than
3398
+ // `legacyUntil` counts as applied: every Stop of those hooks applied
3399
+ // it (third review: at the first 1.86.1 Stop, old lines re-applied
3400
+ // over newer cards — a milestone wholesale-replaced, two unrelated
3401
+ // cards archived by a re-read ✓).
3402
+ // An event with no readable timestamp counts as NEW: not looking at
3403
+ // an old key costs at most a stub that stays as 1.86.0 cut it,
3404
+ // while looking wrongly folds a real note into someone else's card.
3405
+ const beforeUpgrade = legacyUntil > 0 && Number.isFinite(entryAt) && entryAt < legacyUntil;
3122
3406
  const additive = type !== '✓' && type !== '~';
3123
3407
  const key = sha((type + '|' + area + '|' + (additive ? bodyWithCloses : body)).toLowerCase());
3124
- const legacyKey = additive && closes ? sha((type + '|' + area + '|' + body).toLowerCase()) : null;
3408
+ const legacyKey = beforeUpgrade && additive && closes ? sha((type + '|' + area + '|' + body).toLowerCase()) : null;
3125
3409
  let repairStub = '';
3126
3410
  if (additive) {
3127
- if (seen.has(key)) { ledger.push({ action: 'skipped-seen', area, preview }); continue; }
3411
+ if (seen.has(key)) { hitKeys.add(key); legacyClaimed.add(key); ledger.push({ action: 'skipped-seen', area, preview }); continue; }
3128
3412
  // Two DIFFERENT notes that share the words before a key had ONE
3129
3413
  // key under any cut-body rule: the first landed and the second
3130
3414
  // was skipped as "seen" — never captured. So an old key is
3131
3415
  // claimed by the FIRST marker of a capture that carries it; a
3132
3416
  // later one with the same old key is a note that never landed.
3417
+ // Every additive marker claims its OWN key too: a later marker
3418
+ // whose old cut equals it is a different note of this batch.
3133
3419
  if (legacyKey && seen.has(legacyKey) && !legacyClaimed.has(legacyKey)) {
3134
- legacyClaimed.add(legacyKey); seen.add(key);
3135
- ledger.push({ action: 'skipped-seen', area, preview }); continue;
3420
+ legacyClaimed.add(legacyKey); legacyClaimed.add(key); hitKeys.add(legacyKey); seen.add(key);
3421
+ ledger.push({ action: 'skipped-seen', area, preview, olderHook: true }); continue;
3136
3422
  }
3137
- const olderCut = publishedHookCuts(markerBody).find(cut => seen.has(sha((type + '|' + area + '|' + cut).toLowerCase())));
3138
- seen.add(key);
3423
+ const olderCut = beforeUpgrade ? publishedHookCuts(markerBody).find(cut => seen.has(sha((type + '|' + area + '|' + cut).toLowerCase()))) : undefined;
3424
+ seen.add(key); legacyClaimed.add(key);
3139
3425
  if (olderCut !== undefined) {
3140
3426
  const olderKey = sha((type + '|' + area + '|' + olderCut).toLowerCase());
3141
3427
  const landing = closes && !closesAnchored ? bodyWithCloses : body;
3142
3428
  const squash = (s) => String(s).replace(/\s+/g, '').toLowerCase();
3143
- const longer = squash(landing).length > squash(olderCut).length && squash(landing).startsWith(squash(olderCut));
3429
+ // Judged on the text BEFORE any closes: — the published hooks
3430
+ // cut there and ACTED on it, so a card they landed that way
3431
+ // was complete, and its close is final. (A "closes:txt_…"
3432
+ // with no space is prose to this grammar; "repairing" the
3433
+ // card only appended that junk to it.)
3434
+ const beforeCloses = landing.replace(/\s+closes:[\s\S]*$/i, '');
3435
+ const longer = squash(beforeCloses).length > squash(olderCut).length && squash(beforeCloses).startsWith(squash(olderCut));
3144
3436
  // Claimed per capture, as above: only the first marker
3145
3437
  // carrying an old key repairs its stub (or is skipped); a
3146
3438
  // later one with the same old key lands as the note it is.
3147
3439
  if (!legacyClaimed.has(olderKey)) {
3148
- legacyClaimed.add(olderKey);
3440
+ legacyClaimed.add(olderKey); hitKeys.add(olderKey);
3149
3441
  if (longer) repairStub = olderCut;
3150
3442
  else { ledger.push({ action: 'skipped-seen', area, preview, olderHook: true }); continue; }
3151
3443
  }
@@ -3153,7 +3445,8 @@ async function capture(lib) {
3153
3445
  } else {
3154
3446
  const sourceEvent = String(e.uuid || e.id || e.timestamp || e.ts || transcriptIndex);
3155
3447
  const appliedKey = sha(('applied|' + sourceEvent + '|' + type + '|' + area + '|' + body).toLowerCase());
3156
- if (seen.has(appliedKey)) { ledger.push({ action: 'skipped-applied', area, preview }); continue; }
3448
+ if (seen.has(appliedKey)) { hitKeys.add(appliedKey); ledger.push({ action: 'skipped-applied', area, preview }); continue; }
3449
+ if (beforeUpgrade) { ledger.push({ action: 'skipped-applied', area, preview, olderHook: true }); continue; }
3157
3450
  seen.add(appliedKey);
3158
3451
  }
3159
3452
  // A malformed suffix segment went back into the card text — say so
@@ -3254,7 +3547,7 @@ async function capture(lib) {
3254
3547
  if (!detail && p.num) { const nm = p.num.exec(cmd); if (nm) detail = '#' + nm[1]; } // pull the PR/issue number out separately
3255
3548
  const summary = `${p.kind}${detail ? ' ' + detail : ''}`;
3256
3549
  const key = sha(('ship|' + p.area + '|' + summary).toLowerCase());
3257
- if (seen.has(key)) break; // already captured this ship
3550
+ if (seen.has(key)) { hitKeys.add(key); break; } // already captured this ship
3258
3551
  seen.add(key);
3259
3552
  // #auto marks machine-harvested provenance: the repeat-detector demands an
3260
3553
  // entity-token match on these (they're dense with generic ship verbs) and
@@ -3413,6 +3706,12 @@ async function capture(lib) {
3413
3706
  // the authoritative drain happens INSIDE the brain lock (doCapture), so two
3414
3707
  // concurrent sessions can never both land the same queued batch.
3415
3708
  if (!cards.length && !resolutions.length && !updates.length && !readPendingCaptures().length) {
3709
+ // A Stop with nothing new is the COMMON case for a long session, and it
3710
+ // is exactly when the keys of a still-live transcript must not age out
3711
+ // of the capped FIFO — so the keys this run HIT move to the young end
3712
+ // here too (third review, R5). Only keys the file already holds, and
3713
+ // only when the order really changes: a no-op Stop stays a no-op.
3714
+ refreshHitKeys();
3416
3715
  // Record the commit baseline / advance even with nothing to capture, so
3417
3716
  // the next run doesn't re-scan the same commits.
3418
3717
  if (newLastCommit && newLastCommit !== prevCommit) writeLastCommit(newLastCommit);
@@ -3432,6 +3731,10 @@ async function capture(lib) {
3432
3731
  // cards into [Area] containers, and wires [[wikilink]] connections.
3433
3732
  const doCapture = async (locked) => {
3434
3733
  const merged = readState(); for (const k of seen) merged.add(k);
3734
+ // Every key this capture hit moves to the young end of the FIFO (see
3735
+ // STATE_SEEN_MAX): Set order is first insertion, and a key re-read at
3736
+ // every Stop never refreshed, so it aged out while its line was live.
3737
+ for (const k of hitKeys) { merged.delete(k); merged.add(k); }
3435
3738
  // Own-batch snapshot BEFORE the drain merges queued peers' batches in:
3436
3739
  // if the brain write fails we re-queue only OUR contribution (drained
3437
3740
  // batches stay queued — ids/paths clear only after a durable write),
@@ -3458,14 +3761,14 @@ async function capture(lib) {
3458
3761
  }
3459
3762
  // Drain the queue UNDER the lock: read, land, clear-by-id — a peer's
3460
3763
  // batch queued after this read survives, and no batch lands twice.
3461
- const pendingBatches = readPendingCaptures();
3462
- const drainedPendingIds = new Set(pendingBatches.map(b => b && b.id).filter(Boolean));
3463
- const drainedOrphanPaths = pendingBatches.map(b => b && b.__orphanPath).filter(Boolean);
3764
+ const pendingBatches = readPendingCaptures().filter(b => b && typeof b === 'object');
3765
+ const drainedPendingIds = new Set(pendingBatches.map(b => b.id).filter(Boolean));
3766
+ const drainedOrphanPaths = pendingBatches.map(b => b.__orphanPath).filter(Boolean);
3767
+ const drainedCards = [], drainedResolutions = [], drainedUpdates = [];
3464
3768
  for (const b of pendingBatches) {
3465
- if (!b || typeof b !== 'object') continue;
3466
- for (const c of (Array.isArray(b.cards) ? b.cards : [])) cards.push(c);
3467
- for (const r of (Array.isArray(b.resolutions) ? b.resolutions : [])) resolutions.push(r);
3468
- for (const u of (Array.isArray(b.updates) ? b.updates : [])) updates.push(u);
3769
+ for (const c of (Array.isArray(b.cards) ? b.cards : [])) { cards.push(c); drainedCards.push(c); }
3770
+ for (const r of (Array.isArray(b.resolutions) ? b.resolutions : [])) { resolutions.push(r); drainedResolutions.push(r); }
3771
+ for (const u of (Array.isArray(b.updates) ? b.updates : [])) { updates.push(u); drainedUpdates.push(u); }
3469
3772
  }
3470
3773
  if (!cards.length && !resolutions.length && !updates.length) return null;
3471
3774
  const brainBuf = fs.readFileSync(BRAIN);
@@ -3481,37 +3784,84 @@ async function capture(lib) {
3481
3784
  // filtering on the tag alone is a silent-loss bug, and silent loss is
3482
3785
  // the one thing the brain may never do).
3483
3786
  const isCommitCard = (c) => c && c.createdVia === 'commit' && /#commit-[0-9a-f]{7}/i.test(String(c.text || ''));
3484
- let landedCards = cards;
3485
- try {
3486
- if (cards.some(isCommitCard)) {
3487
- const { struct: cur } = await lib.parseKlypix(brainBuf);
3488
- const already = new Set();
3489
- for (const c of (cur.cards || []))
3490
- for (const m of String(c.text || '').matchAll(/#commit-([0-9a-f]{7})/gi)) already.add(m[1].toLowerCase());
3491
- landedCards = cards.filter(c => {
3787
+ let carded = null;
3788
+ const withoutCarded = async (list) => {
3789
+ try {
3790
+ if (!list.some(isCommitCard)) return list;
3791
+ if (!carded) {
3792
+ const { struct: cur } = await lib.parseKlypix(brainBuf);
3793
+ const found = new Set();
3794
+ for (const c of (cur.cards || []))
3795
+ for (const m of String(c.text || '').matchAll(/#commit-([0-9a-f]{7})/gi)) found.add(m[1].toLowerCase());
3796
+ carded = found;
3797
+ }
3798
+ return list.filter(c => {
3492
3799
  if (!isCommitCard(c)) return true;
3493
3800
  const m = /#commit-([0-9a-f]{7})/i.exec(String(c.text || ''));
3494
- return !already.has(m[1].toLowerCase());
3801
+ return !carded.has(m[1].toLowerCase());
3495
3802
  });
3803
+ } catch { return list; /* parse failed → land unfiltered; the supersede pass copes */ }
3804
+ };
3805
+ const toEngine = (c) => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.closes && typeof c.closesFallbackText === 'string' ? { closesFallbackText: c.closesFallbackText, closesAnchored: c.closesAnchored === true } : {}), ...(typeof c.repairStub === 'string' ? { repairStub: c.repairStub } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) });
3806
+ // → null when this subset has nothing left to land (every card was
3807
+ // already carded by the git hook), else the engine's result.
3808
+ const land = async (cs, rs, us) => {
3809
+ const landedCards = await withoutCarded(cs);
3810
+ if (!landedCards.length && !rs.length && !us.length) return null;
3811
+ return lib.captureIntoBrain(brainBuf, { cards: landedCards.map(toEngine), resolutions: rs, updates: us });
3812
+ };
3813
+ // A throw in the engine used to lose the WHOLE batch — and a queued
3814
+ // batch that throws was re-drained, and threw, at every later Stop of
3815
+ // every session, so capture was dead project-wide until someone deleted
3816
+ // the queue file (review 2026-09-18, third round). With drained
3817
+ // batches merged in, a failure is retried with this session's own
3818
+ // batch alone (the drained ones are blamed, and set aside once they
3819
+ // have failed DRAIN_FAILURES_MAX times), then with the drained batches
3820
+ // alone (this session's markers stay in its transcript for the next
3821
+ // Stop). Only when both halves fail does nothing land, as before.
3822
+ let res, scope = 'all', batchError = null;
3823
+ try { res = await land(cards, resolutions, updates); }
3824
+ catch (error) {
3825
+ if (!pendingBatches.length) throw error;
3826
+ batchError = error;
3827
+ try { res = await land(ownCards, ownResolutions, ownUpdates); scope = 'own'; }
3828
+ catch (ownError) {
3829
+ res = await land(drainedCards, drainedResolutions, drainedUpdates); // a throw here: both halves fail
3830
+ scope = 'drained'; batchError = ownError;
3496
3831
  }
3497
- } catch { /* parse failed → land unfiltered; the supersede pass copes */ }
3498
- if (!landedCards.length && !resolutions.length && !updates.length) {
3499
- // Everything gathered was already in the brain (the git hook carded
3500
- // it first). Advance every baseline exactly like a successful write —
3501
- // the commits ARE durable — so the same range never re-scans.
3502
- writeState(merged);
3503
- writeLastCommit(newLastCommit);
3832
+ }
3833
+ const ownLanded = scope !== 'drained', drainedLanded = scope !== 'own';
3834
+ const quarantined = scope === 'own' ? recordDrainFailure(pendingBatches, batchError) : [];
3835
+ const clearDrained = () => {
3504
3836
  if (drainedPendingIds.size) updatePendingCaptures((current) => current.filter(b => b && !drainedPendingIds.has(b.id)));
3505
3837
  clearDrainedOrphans(drainedOrphanPaths);
3838
+ };
3839
+ const advanceOwn = () => {
3840
+ writeState(merged);
3841
+ writeLastCommit(newLastCommit); // advance the commit baseline only after a durable write
3842
+ // Same discipline for the two ship channels: the queue is consumed and
3843
+ // the observation baseline advances ONLY now that the cards are durable.
3506
3844
  if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
3507
3845
  advanceShipBaseline(lib);
3846
+ };
3847
+ const reportBatch = () => {
3848
+ if (scope === 'all') return;
3849
+ const msg = String((batchError && batchError.message) || batchError).slice(0, 160);
3850
+ const what = scope === 'own'
3851
+ ? `a queued batch from an earlier capture threw (${msg}); this session's own batch landed without it${quarantined.length ? `, and ${quarantined.length} batch(es) that failed ${DRAIN_FAILURES_MAX} times were set aside as ${path.basename(PENDING_CAPTURES_FILE)}.poison-*` : ' — it stays queued'}`
3852
+ : `this session's batch threw (${msg}); the queued batches landed without it, and its markers stay in the transcript for the next Stop`;
3853
+ appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: `batch-isolated:${scope} — ${what}`.slice(0, 400) }, 500);
3854
+ process.stderr.write(`[brain] capture: ${what}\n`);
3855
+ };
3856
+ if (!res) {
3857
+ // Everything gathered was already in the brain (the git hook carded
3858
+ // it first). Advance every baseline exactly like a successful write —
3859
+ // the commits ARE durable — so the same range never re-scans.
3860
+ if (ownLanded) advanceOwn();
3861
+ if (drainedLanded) clearDrained();
3862
+ reportBatch();
3508
3863
  return null;
3509
3864
  }
3510
- const res = await lib.captureIntoBrain(brainBuf, {
3511
- cards: landedCards.map(c => ({ text: c.text, color: '#e8e8ed', borderColor: c.borderColor, area: c.area, createdVia: c.createdVia || 'claude-code', ...(c.closes ? { closes: c.closes } : {}), ...(c.closes && typeof c.closesFallbackText === 'string' ? { closesFallbackText: c.closesFallbackText, closesAnchored: c.closesAnchored === true } : {}), ...(typeof c.repairStub === 'string' ? { repairStub: c.repairStub } : {}), ...(c.evidence ? { evidence: c.evidence } : {}), ...(c.verify ? { verify: c.verify } : {}) })),
3512
- resolutions,
3513
- updates,
3514
- });
3515
3865
  // Re-pack the whole grid so a container that grew never overlaps its neighbor.
3516
3866
  let out = res.buffer; try { out = (await lib.tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
3517
3867
  try {
@@ -3523,25 +3873,22 @@ async function capture(lib) {
3523
3873
  // durably (drained batches stay queued — their ids/paths were never
3524
3874
  // cleared), advance baselines only if that queue write succeeded,
3525
3875
  // and exit 0 by contract. Field case: the 2026-08-12 EPERM landed
3526
- // on a session's FINAL Stop and its markers had nowhere to go.
3527
- const queued = (ownCards.length || ownResolutions.length || ownUpdates.length)
3876
+ // on a session's FINAL Stop and its markers had nowhere to go. An
3877
+ // own batch the engine could not apply is not queued: its markers
3878
+ // stay in the transcript.
3879
+ const queued = ownLanded && (ownCards.length || ownResolutions.length || ownUpdates.length)
3528
3880
  ? queuePendingCapture({ id: `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`, ts: nowIso(), cards: ownCards, resolutions: ownResolutions, updates: ownUpdates })
3529
- : true;
3881
+ : ownLanded;
3530
3882
  if (queued) { writeState(merged); writeLastCommit(newLastCommit); }
3531
3883
  appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: `write failed (${e?.code || String(e?.message || e).slice(0, 80)}) — own batch ${queued ? 'QUEUED durably' : 'NOT queued; markers remain re-gatherable'}; drained batches remain queued` }, 500);
3532
3884
  process.stderr.write(`[brain] capture write failed (${e?.code || 'error'}) — ${queued ? 'batch queued durably, nothing lost' : 'queueing ALSO failed; markers stay in the transcript for the next Stop'}\n`);
3533
3885
  return null;
3534
3886
  }
3535
- writeState(merged);
3536
- writeLastCommit(newLastCommit); // advance the commit baseline only after a successful write
3537
- if (drainedPendingIds.size) updatePendingCaptures((current) => current.filter(b => b && !drainedPendingIds.has(b.id)));
3538
- clearDrainedOrphans(drainedOrphanPaths);
3539
- // Same discipline for the two ship channels: the queue is consumed and
3540
- // the observation baseline advances ONLY now that the cards are durable.
3541
- if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
3542
- advanceShipBaseline(lib);
3887
+ if (ownLanded) advanceOwn();
3888
+ if (drainedLanded) clearDrained();
3889
+ reportBatch();
3543
3890
  try { await refreshAgentsBrief(lib, out); } catch { /* AGENTS.md refresh is best-effort */ }
3544
- return res.stats;
3891
+ return { ...res.stats, batchScope: scope, ...(batchError ? { batchError: String(batchError.message || batchError).slice(0, 160) } : {}), ...(quarantined.length ? { quarantined } : {}) };
3545
3892
  };
3546
3893
  // Canonical cross-process lock (heartbeat + token-checked release, shared
3547
3894
  // with the MCP engine and the desktop app). Stale bundles missing the module
@@ -3561,7 +3908,8 @@ async function capture(lib) {
3561
3908
  // without enrichment.mjs just skips, costing recall, never correctness.
3562
3909
  // An update counts (1.86.1): a ~ that rewrote or amended its card carries a
3563
3910
  // q: too, and the sidecar joins a question to whichever card holds its body.
3564
- if (enrichmentPairs.length && (stats.added > 0 || stats.updated > 0 || stats.repaired > 0)) {
3911
+ // Not when only queued batches landed (this session's own batch failed).
3912
+ if (enrichmentPairs.length && stats.batchScope !== 'drained' && (stats.added > 0 || stats.updated > 0 || stats.repaired > 0)) {
3565
3913
  try {
3566
3914
  const enrich = await import(new URL('./enrichment.mjs', import.meta.url).href);
3567
3915
  enrich.recordEnrichment(BRAIN, enrichmentPairs.map(pair => ({ body: pair.body, question: pair.question })));
@@ -3644,16 +3992,23 @@ async function capture(lib) {
3644
3992
  // replaced, and a closes: that named no live card — say so, and how to
3645
3993
  // finish the job.
3646
3994
  if (typeof lib.formatCaptureReceipts === 'function') {
3647
- for (const line of lib.formatCaptureReceipts({ closedCards: stats.closedCards, updateAmended, closesKept: stats.closesKept, stubRepairs: stats.stubRepairs }, { maxEach: 3 })) process.stderr.write(`[brain] ${line}\n`);
3648
- if (Array.isArray(stats.stubRepairMissing) && stats.stubRepairMissing.length) {
3649
- process.stderr.write(`[brain] ${stats.stubRepairMissing.length} marker(s) an older hook already landed cut short were NOT re-added: that card is no longer live (archived, superseded or edited since) — e.g. "${stats.stubRepairMissing[0].stub}"\n`);
3650
- }
3995
+ for (const line of lib.formatCaptureReceipts({ closedCards: stats.closedCards, updateAmended, closesKept: stats.closesKept, stubRepairs: stats.stubRepairs, stubRepairMissing: stats.stubRepairMissing, captureErrors: stats.captureErrors }, { maxEach: 3 })) process.stderr.write(`[brain] ${line}\n`);
3651
3996
  // …and the MODEL has to hear the ones that need a re-emit: a Stop
3652
3997
  // hook's exit-0 stderr never reaches it, so these go to a sidecar the
3653
- // next prompt prints once (captureReceiptsFooter).
3654
- const forModel = lib.formatCaptureReceipts({ updateAmended, closesKept: stats.closesKept, closeRefused: stats.closeRefused }, { maxEach: 3 });
3998
+ // next prompt prints once (captureReceiptsFooter). That includes a
3999
+ // note folded into an existing card as a stub repair, one NOT re-added
4000
+ // because an older hook captured it, and a marker the engine could
4001
+ // not apply (third review: those went to stderr only).
4002
+ const forModel = lib.formatCaptureReceipts({ updateAmended, closesKept: stats.closesKept, closeRefused: stats.closeRefused, stubRepairs: stats.stubRepairs, stubRepairMissing: stats.stubRepairMissing, captureErrors: stats.captureErrors }, { maxEach: 3 });
3655
4003
  if (forModel.length) recordCaptureReceipts(sid, forModel.map(line => ({ key: sha('capture|' + line), line })));
3656
4004
  }
4005
+ // A capture that had to land around a failing batch (see doCapture).
4006
+ if (stats.batchScope === 'drained' || (Array.isArray(stats.quarantined) && stats.quarantined.length)) {
4007
+ const lines = [];
4008
+ if (stats.batchScope === 'drained') lines.push(`⚠️ capture failed for this session's markers (${stats.batchError || 'engine error'}); nothing of this turn landed yet. They stay in the transcript and are retried at the next Stop — if this repeats, re-emit the important ones one per turn.`);
4009
+ for (const q of (stats.quarantined || []).slice(0, 3)) lines.push(`⚠️ a queued capture batch failed ${DRAIN_FAILURES_MAX} times and was set aside, not landed (${q.cards} card(s), ${q.updates} update(s), ${q.resolutions} resolve(s)${q.first ? `, e.g. "${q.first}"` : ''}): ${q.file}`);
4010
+ recordCaptureReceipts(sid, lines.map(line => ({ key: sha('capture|' + line), line })));
4011
+ }
3657
4012
  if (partialSkipped || stats.partialSkipped) {
3658
4013
  // `partialSkipped` counts MARKERS whose strongest outcome was a skip;
3659
4014
  // the engine's stat counts CARDS. They differ when one ✓ hit near-tie
@@ -3694,7 +4049,7 @@ async function capture(lib) {
3694
4049
  // whose only cards were
3695
4050
  // machine-harvested ships/commits has still recorded no reasoning, and must
3696
4051
  // stay nudgeable. brain_note (MCP) writes the same receipt for the same id.
3697
- if (gapAuthored > 0 && stats.added + (stats.resolved || 0) + (stats.updated || 0) > 0) {
4052
+ if (gapAuthored > 0 && stats.batchScope !== 'drained' && stats.added + (stats.resolved || 0) + (stats.updated || 0) > 0) {
3698
4053
  try {
3699
4054
  const gapLib = await import(new URL('./capture-gap.mjs', import.meta.url).href);
3700
4055
  gapLib.recordSessionCapture?.(String(input.session_id || ''), undefined, Date.now(), { project: CWD, head: newLastCommit || '' });
@@ -37,7 +37,7 @@ import {
37
37
  splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor, currentGuidanceFor, currentGuidancePrefix,
38
38
  isFastDecayCard, isUnresolvedOpenCard, isSkillCard, validateGuard, guardSidecarPathFor, ensureGuardSidecar, DECAY_STALE_MS, formatDecayAge,
39
39
  isPlanCard, planFulfillmentFor, PLAN_PAIR_SIM_BRAIN, isAgconfTwinId,
40
- readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts, parseVerifySuffix,
40
+ readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts, parseVerifySuffix, amendmentFirst,
41
41
  } from './klypix-format.mjs';
42
42
  import { findProjectBrain, postPresenceMessage, readReleaseLease } from './agent-presence.mjs';
43
43
  import { collectRepoState, commitsInRange, makeContainmentProbe } from './repo-state.mjs';
@@ -514,7 +514,10 @@ export async function opBrainTaskContext({
514
514
  id: hit.card.id,
515
515
  evidence: evidenceFor(hit.card),
516
516
  area: flat(hit.card.area) || 'Notes',
517
- text: clip(hit.card.text, 420),
517
+ // Newest amendment first, as in the hook's previews: a thin ~ is appended
518
+ // UNDER the claim it corrects, and 420 characters of a long card never
519
+ // reached it (review 2026-09-18, third round).
520
+ text: clip(amendmentFirst(hit.card.text), 420),
518
521
  score: Number(hit.score.toFixed(2)),
519
522
  correctedBy: correction ? clip(correction.text, 420) : null,
520
523
  ...(correction ? { correctedById: correction.id } : {}),
@@ -4250,36 +4250,92 @@ export const isThinUpdate = (words, targetWords) => words < UPDATE_MIN_WORDS &&
4250
4250
  // thin ~ naming a long path, URL or 40-char SHA was never recognised as
4251
4251
  // already said, and re-appended itself at every Stop (review 2026-09-18, the
4252
4252
  // same unbounded stacking as the 85-line ✔ partial incident). The body is now
4253
- // matched as a pattern instead: a space matches any run of whitespace, and
4254
- // between two adjacent non-space characters an optional line break — the only
4255
- // thing a mid-word wrap inserts. A match that starts or ends at a mid-word
4256
- // break is not on a word boundary (a body that is only the head or tail of a
4257
- // long token is not "said").
4258
- const sayNorm = (s) => String(s || '').toLowerCase().replace(/\s+/g, ' ').trim();
4259
- const SAY_REGEX_SPECIAL = /[.*+?^${}()|[\]\\]/g;
4253
+ // matched with the card's whitespace taken out of the way instead: a space in
4254
+ // the body matches any run of whitespace in the card, and between two adjacent
4255
+ // non-space characters the card may carry one line break — the only thing a
4256
+ // mid-word wrap inserts. A match that starts or ends at a mid-word break is not
4257
+ // on a word boundary (a body that is only the head or tail of a long token is
4258
+ // not "said").
4259
+ //
4260
+ // No regular expression (review 2026-09-18, third round): the first version
4261
+ // compiled the body into a pattern, and V8 threw "Stack overflow" from exec on
4262
+ // a ~5,000-character body — outside its try, so one long thin ~ threw
4263
+ // captureIntoBrain and, once queued, wedged capture for the whole project. It
4264
+ // also lower-cased the body before an /iu match, and "İ".toLowerCase() is two
4265
+ // code units that simple case folding never maps back, so "İstanbul" did not
4266
+ // match itself. Both sides are now lower-cased the SAME way, one code unit at a
4267
+ // time, into a whitespace-free string with a map back to the card's offsets;
4268
+ // matching is a plain indexOf, linear in the card and the body.
4260
4269
  function isMidWordBreak(raw, nl) {
4261
4270
  if (raw[nl] !== '\n') return false;
4262
4271
  const start = raw.lastIndexOf('\n', nl - 1) + 1;
4263
4272
  const line = raw.slice(start, nl);
4264
4273
  return line.length === brainCPL() && !/\s/.test(line) && /\S/.test(raw[nl + 1] || '');
4265
4274
  }
4275
+ // A catch handler that itself throws would defeat the containment it is there
4276
+ // for, so the two fields a failure is reported with are read defensively: the
4277
+ // thing that just failed may be exactly the thing whose property throws.
4278
+ const errText = (error) => { try { return String((error && error.message) || error).slice(0, 160); } catch { return 'unknown error'; } };
4279
+ function describeFailed(item, textKey) {
4280
+ let area = null, text = '';
4281
+ try { area = (item && item.area) || null; if (area !== null) area = String(area).slice(0, 60); } catch { area = null; }
4282
+ try { text = String((item && item[textKey]) || '').slice(0, 90); } catch { text = ''; }
4283
+ return { area, text };
4284
+ }
4285
+ // Whitespace-free, unit-by-unit lower-cased copy of s, with, per unit, the
4286
+ // offset in s it came from and whether whitespace preceded it.
4287
+ function squashForSays(s) {
4288
+ let out = '';
4289
+ const at = [], spaced = [];
4290
+ let sawSpace = false;
4291
+ for (let i = 0; i < s.length; i++) {
4292
+ const c = s[i];
4293
+ if (/\s/.test(c)) { sawSpace = true; continue; }
4294
+ const lc = c.toLowerCase();
4295
+ for (let k = 0; k < lc.length; k++) { out += lc[k]; at.push(i); spaced.push(sawSpace && k === 0); }
4296
+ sawSpace = false;
4297
+ }
4298
+ return { out, at, spaced };
4299
+ }
4300
+ const SAY_WORD_CHAR = /[\p{L}\p{N}]/u;
4301
+ const codePointBefore = (s, i) => { if (i <= 0) return ''; const lo = s.charCodeAt(i - 1); return (lo >= 0xdc00 && lo <= 0xdfff && i >= 2) ? s.slice(i - 2, i) : s[i - 1]; };
4302
+ const codePointAt = (s, i) => (i >= s.length ? '' : String.fromCodePoint(s.codePointAt(i)));
4266
4303
  export function cardAlreadySays(cardText, body) {
4267
- const want = sayNorm(body);
4268
- if (!want) return false;
4269
4304
  const raw = String(cardText || '');
4270
- const chars = [...want];
4271
- let src = '';
4272
- for (let i = 0; i < chars.length; i++) {
4273
- if (chars[i] === ' ') { src += '\\s+'; continue; }
4274
- src += chars[i].replace(SAY_REGEX_SPECIAL, '\\$&');
4275
- if (i + 1 < chars.length && chars[i + 1] !== ' ') src += '\\n?';
4276
- }
4277
- let re;
4278
- try { re = new RegExp(`(?<![\\p{L}\\p{N}])${src}(?![\\p{L}\\p{N}])`, 'giu'); } catch { return false; }
4279
- for (let m = re.exec(raw); m; m = re.exec(raw)) {
4280
- const end = m.index + m[0].length;
4281
- if (!(m.index > 0 && isMidWordBreak(raw, m.index - 1)) && !isMidWordBreak(raw, end)) return true;
4282
- re.lastIndex = m.index + 1;
4305
+ const want = squashForSays(String(body || '').trim());
4306
+ if (!want.out) return false;
4307
+ const hay = squashForSays(raw);
4308
+ const n = want.out.length;
4309
+ // Only offsets that could START a word-bounded match are tried. Restarting
4310
+ // indexOf at pos+1 re-scanned from every position a long repetitive card
4311
+ // matched at and then rejected on the boundary check — 2.4 s on a 60k card
4312
+ // of one repeated letter. The leading-boundary rule is the same one applied
4313
+ // below; it just runs first, so a card with one word head has one candidate.
4314
+ const starts = [];
4315
+ for (let k = 0; k < hay.out.length; k++) {
4316
+ const at = hay.at[k];
4317
+ if (k > 0 && hay.at[k - 1] === at) continue; // second unit of one expanded char
4318
+ if (at === 0 || !SAY_WORD_CHAR.test(codePointBefore(raw, at))) starts.push(k);
4319
+ }
4320
+ for (const pos of starts) {
4321
+ if (!hay.out.startsWith(want.out, pos)) continue;
4322
+ // The card's whitespace between two matched units must be what the
4323
+ // body has there: some whitespace where the body has a space, and at
4324
+ // most one line break where it has none.
4325
+ let fits = true;
4326
+ for (let k = 1; k < n && fits; k++) {
4327
+ const a = hay.at[pos + k - 1], b = hay.at[pos + k];
4328
+ if (a === b) { fits = !want.spaced[k]; continue; } // one card char lower-cased to two units
4329
+ const gap = raw.slice(a + 1, b);
4330
+ fits = want.spaced[k] ? gap.length > 0 : (gap === '' || gap === '\n');
4331
+ }
4332
+ if (!fits) continue;
4333
+ const s0 = hay.at[pos];
4334
+ const e0 = hay.at[pos + n - 1] + 1;
4335
+ const end = (e0 < raw.length && raw.charCodeAt(e0) >= 0xdc00 && raw.charCodeAt(e0) <= 0xdfff) ? e0 + 1 : e0;
4336
+ if (SAY_WORD_CHAR.test(codePointBefore(raw, s0)) || SAY_WORD_CHAR.test(codePointAt(raw, end))) continue;
4337
+ if ((s0 > 0 && isMidWordBreak(raw, s0 - 1)) || isMidWordBreak(raw, end)) continue;
4338
+ return true;
4283
4339
  }
4284
4340
  return false;
4285
4341
  }
@@ -4289,27 +4345,56 @@ export function cardAlreadySays(cardText, body) {
4289
4345
  // server port changed to 5174 now" on a 190-character card never appeared
4290
4346
  // there (review 2026-09-18). Previews render the NEWEST amendment first; the
4291
4347
  // stored card is untouched (its title still names the claim, which is what
4292
- // closes: and ✓ match against). The run is rejoined across its wrap (a
4293
- // mid-word chunk with no space) and ends at the next line that starts a block
4294
- // of its own.
4348
+ // closes: and ✓ match against).
4349
+ //
4350
+ // Third review (2026-09-18) — what a preview may NOT reorder:
4351
+ // • a leading lifecycle stamp ("↩︎ superseded …", "⤵ consolidated …") stays
4352
+ // first, and the amendment goes right after it: the repeat nudge shows
4353
+ // superseded cards, and one that opened with the amendment read as live;
4354
+ // • a newer "(re-affirmed …)", "✔ …" or "✅ …" line after the amendment
4355
+ // means the amendment is not the newest word, so the text is left as is;
4356
+ // • the run is the amendment's own lines only: it continues across a wrap
4357
+ // join (the previous line is a full mid-word chunk, or the next line's
4358
+ // first word would not have fitted on it) while its "(" is still open —
4359
+ // a human line added under a one-line amendment is not absorbed into it.
4360
+ const parenBalance = (l) => (l.match(/\(/g) || []).length - (l.match(/\)/g) || []).length;
4295
4361
  export function amendmentFirst(text) {
4296
4362
  const raw = String(text || '');
4297
4363
  if (!raw.includes('(~ amended ')) return raw;
4298
4364
  const lines = raw.split('\n');
4299
4365
  const isRunStart = (l) => /^\(~ amended \d{4}-\d{2}-\d{2}:/.test(l.trim());
4366
+ const isStamp = (l) => /^(?:↩|⤵)/u.test(l.trim());
4300
4367
  const isBlockStart = (l) => /^\s*(?:#[\p{L}\p{N}_-]+\s*)+$/u.test(l) || /^(?:✔|✅|↩|⤵|\(re-affirmed|\(~ amended)/u.test(l.trim());
4368
+ const isNewerWord = (l) => /^(?:\(re-affirmed|✔|✅)/u.test(l.trim());
4369
+ // A claim line that itself carries a ✅ / ✔ is a card that has been
4370
+ // RESOLVED: that is its newest word, so the preview is left as stored. The
4371
+ // glyph is prefixed inline rather than on a line of its own, so it cannot
4372
+ // be kept on top the way a ↩︎ / ⤵ stamp line is — hoisting past it made a
4373
+ // resolved card read live in the repeat nudge (third review, R6).
4374
+ const isResolvedHead = (l) => /^(?:✅|✔)/u.test(l.trim());
4301
4375
  let start = -1;
4302
4376
  for (let i = lines.length - 1; i >= 0; i--) if (isRunStart(lines[i])) { start = i; break; }
4303
- if (start <= 0) return raw;
4304
- let end = start;
4305
- while (end + 1 < lines.length && lines[end + 1].trim() && !isBlockStart(lines[end + 1])) end++;
4377
+ let lead = 0;
4378
+ while (lead < lines.length && isStamp(lines[lead])) lead++;
4379
+ if (start <= lead) return raw;
4306
4380
  const cpl = brainCPL();
4381
+ let end = start, depth = parenBalance(lines[start]);
4382
+ while (depth > 0 && end + 1 < lines.length) {
4383
+ const prev = lines[end], next = lines[end + 1];
4384
+ if (!next.trim() || isBlockStart(next)) break;
4385
+ const hardWrap = prev.length === cpl && !/\s/.test(prev);
4386
+ const softWrap = prev.trimEnd().length + 1 + (next.trim().split(/\s+/)[0] || '').length > cpl;
4387
+ if (!hardWrap && !softWrap) break;
4388
+ end++;
4389
+ depth += parenBalance(next);
4390
+ }
4391
+ if (lines.slice(end + 1).some(isNewerWord) || isResolvedHead(lines[lead] || '')) return raw;
4307
4392
  let run = lines[start].trim();
4308
4393
  for (let i = start + 1; i <= end; i++) {
4309
4394
  const prev = lines[i - 1];
4310
4395
  run += (prev.length === cpl && !/\s/.test(prev.trim()) ? '' : ' ') + lines[i].trim();
4311
4396
  }
4312
- return [run, ...lines.slice(0, start), ...lines.slice(end + 1)].join('\n');
4397
+ return [...lines.slice(0, lead), run, ...lines.slice(lead, start), ...lines.slice(end + 1)].join('\n');
4313
4398
  }
4314
4399
  // Cue META words describe the act of correcting, not the subject — left in, they
4315
4400
  // dilute the overlap denominator and push real correction pairs just under the
@@ -5301,7 +5386,11 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
5301
5386
  // Too thin to stand in for the card it matched (UPDATE FLOOR below) →
5302
5387
  // the card is kept whole and the text is appended to it as a dated
5303
5388
  // `(~ amended …)` line; nothing separate is ever minted for it.
5304
- for (const [uIndex, u] of updates.entries()) {
5389
+ // Each update is CONTAINED (review 2026-09-18, third round): one that
5390
+ // throws is reported in stats.captureErrors and the rest of the batch
5391
+ // still lands — a single bad marker used to throw captureIntoBrain,
5392
+ // lose every other card of its Stop and, once queued, wedge the queue.
5393
+ for (const [uIndex, u] of updates.entries()) try {
5305
5394
  const uTok = tokenSet(u.text);
5306
5395
  let best = null, bestScore = 0;
5307
5396
  for (const c of liveTextCards()) {
@@ -5425,6 +5514,8 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
5425
5514
  // glyph and no field: an unmatched disarm is inert by design.
5426
5515
  cards.push({ text: (u.area ? `${u.area}: ` : '') + (u.guard && u.guard.remove !== true && !/🛠/.test(u.text) ? '🛠️ ' : '') + 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 } : {}), ...(typeof u.verify === 'string' && u.verify.trim() ? { verify: u.verify.trim() } : {}), ...(u.guard && typeof u.guard === 'object' && u.guard.remove !== true ? { guard: u.guard } : {}) });
5427
5516
  }
5517
+ } catch (error) {
5518
+ (stats.captureErrors ||= []).push({ kind: 'update', i: uIndex, ...describeFailed(u, 'text'), error: errText(error) });
5428
5519
  }
5429
5520
  // Report only amendments that are still ON their card after the whole
5430
5521
  // batch: a thin ~ appended and then replaced by a full ~ for the same
@@ -5455,8 +5546,9 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
5455
5546
  if (area && t.toLowerCase().startsWith(`${String(area).toLowerCase()}:`)) t = t.slice(String(area).length + 1);
5456
5547
  return stripLifecycleGlyphs(t).replace(/\s+/g, '').toLowerCase();
5457
5548
  };
5458
- for (let i = cards.length - 1; i >= 0; i--) {
5459
- const card = cards[i];
5549
+ let repairing = null;
5550
+ for (let i = cards.length - 1; i >= 0; i--) try {
5551
+ const card = repairing = cards[i];
5460
5552
  if (!card || typeof card.repairStub !== 'string') continue;
5461
5553
  cards.splice(i, 1);
5462
5554
  const want = stubCore(card.repairStub, card.area);
@@ -5469,8 +5561,21 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
5469
5561
  // on the card (a ref no part of this text names) is kept.
5470
5562
  const fullText = String(card.text).replace(/\s+/g, ' ').toLowerCase();
5471
5563
  const fromThisProse = (v) => { const t = String(v || '').replace(/\s+/g, ' ').trim().toLowerCase(); return Boolean(t) && fullText.includes(t); };
5564
+ // Only the TEXT is repaired. The stub's tag lines — a user's own
5565
+ // tags, the #file-/#dir- anchors it was captured with — are what
5566
+ // the match above ignored, so they are kept and merged with the
5567
+ // marker's tag line (review 2026-09-18, third round: a repair
5568
+ // rebuilt the tag line from the current transcript scan, which
5569
+ // after an upgrade is often empty, and dropped every one).
5570
+ const isTagLine = (l) => /^\s*(?:#[\p{L}\p{N}_-]+\s*)+$/u.test(l);
5571
+ const tagsOf = (t) => String(t || '').split('\n').filter(isTagLine).flatMap(l => l.trim().split(/\s+/));
5572
+ let repaired = String(card.text);
5472
5573
  await rewriteCard(stub.id, j => {
5473
- j.content = String(card.text);
5574
+ const tags = new Map();
5575
+ for (const tag of [...tagsOf(j.content), ...tagsOf(card.text)]) if (!tags.has(tag.toLowerCase())) tags.set(tag.toLowerCase(), tag);
5576
+ const prose = String(card.text).split('\n').filter(l => !isTagLine(l)).join('\n');
5577
+ repaired = tags.size ? `${prose}\n${[...tags.values()].join(' ')}` : prose;
5578
+ j.content = repaired;
5474
5579
  if (card.borderColor) j.borderColor = card.borderColor;
5475
5580
  const byRef = new Map();
5476
5581
  for (const ev of (Array.isArray(card.evidence) ? card.evidence : [])) byRef.set(ev && ev.ref, ev);
@@ -5480,8 +5585,11 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
5480
5585
  else if (typeof j.verify === 'string' && fromThisProse(j.verify)) delete j.verify;
5481
5586
  });
5482
5587
  (stats.stubRepairs ||= []).push({ id: stub.id, area: stub.area || null, from: String(card.repairStub).slice(0, 90), to: String(card.text).replace(/\s+/g, ' ').trim().slice(0, 120) });
5483
- stub.text = String(card.text);
5588
+ stub.text = repaired;
5484
5589
  stats.repaired = (stats.repaired || 0) + 1;
5590
+ } catch (error) {
5591
+ // The stub stays as it is; the marker was captured once already.
5592
+ (stats.captureErrors ||= []).push({ kind: 'repair', ...describeFailed(repairing, 'repairStub'), error: errText(error) });
5485
5593
  }
5486
5594
 
5487
5595
  // Which live cards a close-target names — shared by the unmatched-closes
@@ -5677,8 +5785,9 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
5677
5785
  // wording + createdAt) instead of stacking a twin the close-pass would
5678
5786
  // later miss. (Supersede deliberately skips ? cards, so without this
5679
5787
  // twins could never merge at capture at all.)
5680
- for (let i = cards.length - 1; i >= 0; i--) {
5681
- const card = cards[i];
5788
+ let merging = null;
5789
+ for (let i = cards.length - 1; i >= 0; i--) try {
5790
+ const card = merging = cards[i];
5682
5791
  if (!/❓/.test(card.text) || /🏁|🛠/.test(card.text)) continue;
5683
5792
  const nTok = tokenSet(card.text);
5684
5793
  let best = null, bestScore = 0;
@@ -5713,6 +5822,10 @@ export async function captureIntoBrain(buffer, { cards = [], resolutions = [], u
5713
5822
  cards.splice(i, 1);
5714
5823
  stats.merged++;
5715
5824
  }
5825
+ } catch (error) {
5826
+ // Contained like an update: the question stays in `cards` and
5827
+ // lands as its own card.
5828
+ (stats.captureErrors ||= []).push({ kind: 'question-merge', ...describeFailed(merging, 'text'), error: errText(error) });
5716
5829
  }
5717
5830
 
5718
5831
  // SUPERSEDE — pre-mark old cards that a NEW decision replaces. The arrow
@@ -6815,7 +6928,20 @@ export function formatCaptureReceipts(stats, { maxEach = 3 } = {}) {
6815
6928
  }
6816
6929
  // A 1.86.0 stub restored to the full marker text, in place (stub repair).
6817
6930
  for (const f of (Array.isArray(s.stubRepairs) ? s.stubRepairs : []).slice(0, maxEach)) {
6818
- lines.push(`🔧 repaired a note 1.86.0 cut short: (id ${f.id}) "${f.from}" now reads "${f.to}".`);
6931
+ lines.push(`🔧 repaired a note 1.86.0 cut short: (id ${f.id}) "${f.from}" now reads "${f.to}". If that card was a different note, restore its text and re-emit this marker.`);
6932
+ }
6933
+ // …and one that was NOT re-added: the stub it would repair is not live any
6934
+ // more. Never silent — the author is the one who can tell whether the note
6935
+ // is already in the brain in another form.
6936
+ for (const f of (Array.isArray(s.stubRepairMissing) ? s.stubRepairMissing : []).slice(0, maxEach)) {
6937
+ lines.push(`↩ not re-added: a marker an older hook already captured as "${f.stub}"${f.area ? ` [${f.area}]` : ''} names no live card of that text any more (archived, superseded or edited since). If the note is not in the brain, re-emit it.`);
6938
+ }
6939
+ // A marker the engine could not apply (contained, so the rest landed).
6940
+ for (const f of (Array.isArray(s.captureErrors) ? s.captureErrors : []).slice(0, maxEach)) {
6941
+ const what = f.kind === 'update' ? '~ update' : f.kind === 'repair' ? 'stub repair' : 'open-question merge';
6942
+ const after = f.kind === 'question-merge' ? 'The question landed as its own card, and the rest of the capture landed too.'
6943
+ : 'The rest of the capture landed; re-emit this one if it matters.';
6944
+ lines.push(`⚠️ ${what} failed${f.text ? `: "${f.text}"` : ''}${f.area ? ` [${f.area}]` : ''} (${f.error}). ${after}`);
6819
6945
  }
6820
6946
  // A closes: target that names no live card closed nothing; the phrase went
6821
6947
  // back into the note's text rather than vanishing.