klypix-mcp 1.67.2 → 1.68.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -693,17 +693,22 @@ Read this section before you build on any of it.
693
693
  Every number here is measured on our own project brain. Nothing below is published, benchmarked or
694
694
  independently validated.
695
695
 
696
- - **Dogfood scale.** KLYPIX itself is built with its own brain: **1,645 cards and 1,521
696
+ - **Dogfood scale.** KLYPIX itself is built with its own brain: **2,479 cards and 2,018
697
697
  connections**, written by multiple concurrent agent sessions, receipts in the file. Current as of
698
- 2026-08-01.
698
+ 2026-08-13.
699
699
  - **Recall.** 73% of past decisions recovered with one search round, 55% brief-only, 0% cold.
700
700
  Caveat that travels with it: n=20, our own brain, self-authored questions, LLM-judged.
701
- - **Ranker.** recall@5 of the true source card went **15% 40%** across two upgrades (n=20 frozen
702
- human-paraphrase questions), measured with the optional on-device reranker enabled. The
703
- experiment that *regressed* contextual prefixes on short cards is recorded next to the wins.
701
+ - **Ranker.** With the production embedder (the eval harness was fixed 2026-08-10 it had been
702
+ measuring a vector space the product does not use): recall@5 **30%**, recall@10 35%, recall@20
703
+ 45%, MRR 0.22 of the true source card on n=20 frozen human-paraphrase questions. Lexical-only
704
+ scores 0% on the same set. The previously published "15% → 40% with the reranker" is **retired**:
705
+ re-measured validly, the reranker *reduced* recall@5 to 25% and now ships off by default. At n=20
706
+ every one of these percentages carries a ±20-point 95% confidence interval — treat them as
707
+ directional until the larger frozen set lands. The regressions are recorded next to the wins:
708
+ contextual prefixes on short cards, and the reranker itself.
704
709
  - **What we do not publish.** No download count: this package's own 24-hour auto-updater generates
705
710
  most of it, so it is not a user count. No adoption, team or customer figures. No brief-token
706
- figure — the last one was measured at ~600 cards and is stale at 1,645.
711
+ figure — the last one was measured at ~600 cards and is stale at 2,479.
707
712
  - **The eval harness is not in this repo.** It lives in the private KLYPIX desktop repository. The
708
713
  numbers above are ours to defend, not yours to reproduce from here — treat them accordingly.
709
714
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.67.2",
3
+ "version": "1.68.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -83,7 +83,7 @@
83
83
  "bench": "node bin/klypix-mcp.mjs bench",
84
84
  "test:bench": "node test/bench.mjs",
85
85
  "pretest": "node test/publish-workflow.mjs",
86
- "test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
86
+ "test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
87
  "test:memory": "node test/memory-runtime.mjs",
88
88
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
89
89
  "runtime": "node bin/klypix-runtime.mjs"
@@ -454,6 +454,7 @@ function normalizeDeliveryRecord(record) {
454
454
  ...(record?.acknowledgedActionId ? { acknowledgedActionId: String(record.acknowledgedActionId).slice(0, 160) } : {}),
455
455
  ...(record?.consumedActionId ? { consumedActionId: String(record.consumedActionId).slice(0, 160) } : {}),
456
456
  ...(record?.offerToken ? { offerToken: String(record.offerToken).slice(0, 160) } : {}),
457
+ ...(record?.consumedVia ? { consumedVia: String(record.consumedVia).slice(0, 40) } : {}),
457
458
  ...(record?.reason ? { reason: String(record.reason).slice(0, 120) } : {}),
458
459
  ...(record?.legacySeen ? { legacySeen: true } : {}),
459
460
  };
@@ -485,9 +486,16 @@ export function normalizeMessageDelivery(message, now = Date.now()) {
485
486
  next.deliveryVersion = MESSAGE_DELIVERY_VERSION;
486
487
  next.deliveries = [...byRecipient.values()];
487
488
  if (!Array.isArray(next.seen)) next.seen = [];
488
- // v2 retired on acknowledgement, which proved only model-context injection.
489
- // v3 must conservatively replay it until a token-bound consume receipt lands.
490
- if (sourceVersion < MESSAGE_DELIVERY_VERSION && next.retiredAt && !next.deadLetter) delete next.retiredAt;
489
+ // History is never rewritten (2026-08-13). The previous migration deleted
490
+ // retiredAt from v2-retired messages to "conservatively replay" them which
491
+ // resurrected up to a week of already-delivered notes, and immediately
492
+ // re-terminalized every one older than 24h as FAILED. That falsified
493
+ // delivery history in both directions on first post-upgrade touch. A v2
494
+ // retirement proved model-context injection; it stays retired, recorded as
495
+ // exactly that and no more.
496
+ if (sourceVersion < MESSAGE_DELIVERY_VERSION && next.retiredAt && !next.deadLetter && !next.retirement) {
497
+ next.retirement = { reason: 'v2-retired — model-context injection proven, explicit consumption unknown', at: Number(next.retiredAt) || now };
498
+ }
491
499
  return next;
492
500
  }
493
501
 
@@ -527,6 +535,7 @@ function setDeliveryState(message, sessionId, state, now, reason = null, actionI
527
535
  delete record.consumedAt;
528
536
  delete record.acknowledgedActionId;
529
537
  delete record.consumedActionId;
538
+ delete record.consumedVia;
530
539
  delete record.failedAt;
531
540
  delete record.reason;
532
541
  } else if (state === 'acknowledged') {
@@ -557,14 +566,29 @@ function terminalizeMessage(message, now, reason) {
557
566
  ...(Array.isArray(next.candidateIds) ? next.candidateIds : []),
558
567
  ...next.deliveries.map((entry) => entry.recipientId),
559
568
  ].map(recipientKey).filter(Boolean));
560
- let unconsumed = known.size === 0;
569
+ // Split by how far delivery actually got (2026-08-13). 'acknowledged' means
570
+ // the note was rendered into model context on two independent actions —
571
+ // expiring after that is NOT a delivery failure, and the old blanket
572
+ // 'failed' told the sender "no target consumed it" about a note the model
573
+ // saw repeatedly. Only recipients the note never reached (pending) or
574
+ // reached exactly once without confirmation (offered) fail, each with a
575
+ // reason that says which. A message whose every recipient at least
576
+ // acknowledged retires as delivered-unconfirmed instead of dead-lettering.
577
+ let failures = 0, reachedUnconsumed = 0;
561
578
  for (const recipientId of known) {
562
- if (messageDeliveryState(next, recipientId) === 'consumed') continue;
563
- unconsumed = true;
564
- setDeliveryState(next, recipientId, 'failed', now, reason);
579
+ const state = messageDeliveryState(next, recipientId);
580
+ if (state === 'consumed') continue;
581
+ if (state === 'acknowledged') { reachedUnconsumed++; continue; }
582
+ failures++;
583
+ setDeliveryState(next, recipientId, 'failed', now,
584
+ state === 'pending' ? `${reason} (never delivered)` : `${reason} (offered once, unconfirmed)`);
585
+ }
586
+ if (failures || known.size === 0) {
587
+ next.deadLetter = { state: 'failed', reason, at: now };
588
+ } else {
589
+ next.retiredAt = now;
590
+ if (reachedUnconsumed) next.retirement = { reason: `${reason} — after acknowledgement (delivered, consumption unconfirmed)`, at: now };
565
591
  }
566
- if (unconsumed) next.deadLetter = { state: 'failed', reason, at: now };
567
- else next.retiredAt = now;
568
592
  return next;
569
593
  }
570
594
 
@@ -1537,9 +1561,13 @@ export function receiveMessages({
1537
1561
  const record = messageDeliveryRecord(message, sessionId);
1538
1562
  return !isTerminalMessage(message)
1539
1563
  && message.from !== sessionId
1540
- && (state === 'pending' || state === 'offered' || state === 'acknowledged')
1564
+ // 'acknowledged' re-renders ONLY when there is no action-identity
1565
+ // evidence of a third action (no actionId on this call, or a legacy
1566
+ // record acknowledged without one). With evidence, the lease pass
1567
+ // below retires it as consumed instead of injecting it a third time.
1568
+ && (state === 'pending' || state === 'offered'
1569
+ || (state === 'acknowledged' && (!actionId || !record?.acknowledgedActionId)))
1541
1570
  && !(state === 'offered' && actionId && record?.offeredActionId === String(actionId))
1542
- && !(state === 'acknowledged' && actionId && record?.acknowledgedActionId === String(actionId))
1543
1571
  && messageTargetsSession(message, me, sessionId, sessions);
1544
1572
  }).sort((left, right) => {
1545
1573
  // Never let an acknowledged-but-not-yet-consumed replay starve a fresh
@@ -1577,6 +1605,30 @@ export function receiveMessages({
1577
1605
  setDeliveryState(message, sessionId, 'acknowledged', now, null, actionId);
1578
1606
  }
1579
1607
  }
1608
+ // Lease auto-consume (2026-08-13). An acknowledged note was rendered into
1609
+ // model context on TWO independent actions; when a THIRD independent
1610
+ // action arrives, the model has demonstrably moved on with the note in
1611
+ // hand. Retire it as consumed (consumedVia 'auto-lease') instead of
1612
+ // replaying it every action for 24h and then dead-lettering a note the
1613
+ // model saw repeatedly as "failed" — the old design guaranteed a steady
1614
+ // background rate of false-failure receipts from every recipient that
1615
+ // cannot (hook-only lanes) or does not copy offer tokens back. The
1616
+ // explicit brain_message_receipt remains the only path that can record
1617
+ // the stronger claim (consumedVia 'receipt' — "I acted on it").
1618
+ // Requires a real, DIFFERENT actionId: with no action identity there is
1619
+ // no evidence of a third action, so behavior stays replay-until-receipt.
1620
+ if (actionId) {
1621
+ for (const message of messages) {
1622
+ if (isTerminalMessage(message) || message.from === sessionId) continue;
1623
+ const record = messageDeliveryRecord(message, sessionId);
1624
+ if (!record || record.state !== 'acknowledged') continue;
1625
+ if (!record.acknowledgedActionId || record.acknowledgedActionId === String(actionId)) continue;
1626
+ if (!messageTargetsSession(message, me, sessionId, sessions)) continue;
1627
+ setDeliveryState(message, sessionId, 'consumed', now, null, actionId);
1628
+ record.consumedVia = 'auto-lease';
1629
+ retireFullyConsumed(message, now);
1630
+ }
1631
+ }
1580
1632
  // Persist migration/expiry/dead-letter changes even when the inbox is empty.
1581
1633
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1582
1634
  writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, messages }));
@@ -1684,6 +1736,15 @@ export function consumeMessageReceipt({
1684
1736
  return receiptResult(false, false, 'rejected', 'offer-token-mismatch', wantedMessageId, recipientId);
1685
1737
  }
1686
1738
  if (state === 'consumed') {
1739
+ // An auto-leased consumption (the third-action lease in receiveMessages)
1740
+ // proves the model moved on with the note in context; an explicit
1741
+ // receipt is the STRONGER claim — "I acted on it". Record the upgrade.
1742
+ if (record.consumedVia !== 'receipt') {
1743
+ record.consumedVia = 'receipt';
1744
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1745
+ writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, messages }));
1746
+ return receiptResult(true, true, 'consumed', null, wantedMessageId, recipientId);
1747
+ }
1687
1748
  return receiptResult(true, false, 'consumed', null, wantedMessageId, recipientId);
1688
1749
  }
1689
1750
  if (state === 'offered') {
@@ -1698,6 +1759,8 @@ export function consumeMessageReceipt({
1698
1759
  return receiptResult(false, false, 'rejected', 'delivery-not-acknowledged', wantedMessageId, recipientId);
1699
1760
  }
1700
1761
  setDeliveryState(message, recipientId, 'consumed', now, null, actionId);
1762
+ const consumedRecord = messageDeliveryRecord(message, recipientId);
1763
+ if (consumedRecord) consumedRecord.consumedVia = 'receipt';
1701
1764
  retireFullyConsumed(message, now);
1702
1765
  fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1703
1766
  writeLaneFileAtomic(laneFile, JSON.stringify({ ...data, sessions, messages }));
@@ -400,20 +400,80 @@ function sweepStaleTmp(dir) {
400
400
  // replace only what it drained, so a batch a peer queued meanwhile survives.
401
401
  const PENDING_CAPTURES_FILE = path.join(os.homedir(), '.claude', 'project-brain', 'pending', `${sha(normBrainPath(laneCanon(BRAIN)))}.captures.json`);
402
402
  const PENDING_CAPTURES_LOCK = PENDING_CAPTURES_FILE + '.lock';
403
+ // A non-ENOENT read failure must ABORT any read-modify-write instead of
404
+ // masquerading as an empty queue — an RMW over a transient EPERM/corrupt read
405
+ // would rewrite the file as "just my change" and destroy every queued batch
406
+ // (2026-08-14 adversarial review). ENOENT alone means genuinely empty.
407
+ function readMainPendingChecked() {
408
+ try { const d = JSON.parse(fs.readFileSync(PENDING_CAPTURES_FILE, 'utf8')); return { ok: true, batches: Array.isArray(d) ? d : [] }; }
409
+ catch (e) { return e?.code === 'ENOENT' ? { ok: true, batches: [] } : { ok: false, batches: [] }; }
410
+ }
411
+ function readMainPendingFile() { return readMainPendingChecked().batches; }
412
+ // Orphan sidecars: the lossless fallback when the pending LOCK itself is
413
+ // contended (two sessions refuse in the same instant — the trigger, a held
414
+ // brain lock, is CORRELATED across sessions, so "rare" was wrong). Each orphan
415
+ // is one batch in its own uniquely-named file, written tmp→rename so a drain's
416
+ // readdir never sees a torn one. Drained orphans are deleted by PATH only
417
+ // after the brain write is durable — same discipline as clear-by-id.
418
+ function listOrphanCaptureFiles() {
419
+ try {
420
+ const dir = path.dirname(PENDING_CAPTURES_FILE);
421
+ const prefix = path.basename(PENDING_CAPTURES_FILE) + '.orphan-';
422
+ return fs.readdirSync(dir).filter((n) => n.startsWith(prefix) && !/\.tmp-[a-z0-9-]+$/.test(n)).map((n) => path.join(dir, n));
423
+ } catch { return []; }
424
+ }
403
425
  function readPendingCaptures() {
404
- try { const d = JSON.parse(fs.readFileSync(PENDING_CAPTURES_FILE, 'utf8')); return Array.isArray(d) ? d : []; } catch { return []; }
426
+ const batches = readMainPendingFile();
427
+ for (const f of listOrphanCaptureFiles()) {
428
+ try { const b = JSON.parse(fs.readFileSync(f, 'utf8')); if (b && typeof b === 'object') batches.push({ ...b, __orphanPath: f }); }
429
+ catch { /* unreadable orphan stays in place; the next drain retries it */ }
430
+ }
431
+ return batches;
405
432
  }
406
433
  function updatePendingCaptures(mutate) {
407
434
  const got = acquireLock(PENDING_CAPTURES_LOCK, { tries: 20, waitMs: 25 });
408
435
  try {
409
- const next = mutate(readPendingCaptures());
436
+ const read = readMainPendingChecked();
437
+ if (!read.ok) return; // unreadable ≠ empty: abort the RMW, preserve the bytes; the next drain retries
438
+ const next = mutate(read.batches);
410
439
  fs.mkdirSync(path.dirname(PENDING_CAPTURES_FILE), { recursive: true });
411
- const tmp = PENDING_CAPTURES_FILE + '.tmp';
440
+ const tmp = `${PENDING_CAPTURES_FILE}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
412
441
  fs.writeFileSync(tmp, JSON.stringify(next));
413
442
  fs.renameSync(tmp, PENDING_CAPTURES_FILE);
414
- } catch { /* never break the session; the transcript markers remain the fallback */ }
443
+ } catch { /* clears are retried by the next drain; adds go through queuePendingCapture */ }
415
444
  finally { if (got) releaseLock(PENDING_CAPTURES_LOCK); }
416
445
  }
446
+ // Lossless add. RMW under the pending lock is first choice; on lock contention
447
+ // or a failed write it falls back to an append-only orphan file. Returns false
448
+ // only when BOTH paths failed — the caller must then leave every baseline
449
+ // unadvanced so the markers are re-gathered next Stop instead of lost.
450
+ function queuePendingCapture(batch) {
451
+ const got = acquireLock(PENDING_CAPTURES_LOCK, { tries: 20, waitMs: 25 });
452
+ if (got) {
453
+ try {
454
+ const read = readMainPendingChecked();
455
+ if (!read.ok) throw new Error('pending queue unreadable — RMW would destroy it; using the orphan path');
456
+ const next = [...read.batches, batch];
457
+ fs.mkdirSync(path.dirname(PENDING_CAPTURES_FILE), { recursive: true });
458
+ const tmp = `${PENDING_CAPTURES_FILE}.tmp-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
459
+ fs.writeFileSync(tmp, JSON.stringify(next));
460
+ fs.renameSync(tmp, PENDING_CAPTURES_FILE);
461
+ return true;
462
+ } catch { /* fall through to the orphan path */ }
463
+ finally { releaseLock(PENDING_CAPTURES_LOCK); }
464
+ }
465
+ try {
466
+ fs.mkdirSync(path.dirname(PENDING_CAPTURES_FILE), { recursive: true });
467
+ const orphan = `${PENDING_CAPTURES_FILE}.orphan-${process.pid}-${Math.random().toString(36).slice(2, 8)}`;
468
+ const tmp = `${orphan}.tmp-${process.pid}`;
469
+ fs.writeFileSync(tmp, JSON.stringify(batch));
470
+ fs.renameSync(tmp, orphan);
471
+ return true;
472
+ } catch { return false; }
473
+ }
474
+ function clearDrainedOrphans(paths) {
475
+ for (const f of (paths || [])) { try { fs.unlinkSync(f); } catch { /* re-drained next time; landing twice is superseded away */ } }
476
+ }
417
477
  const SESSION_FRESH_MS = 10 * 60 * 1000; // a lane unseen for 10min is treated as ended
418
478
  const MCP_SESSION_FRESH_MS = 3 * 60 * 1000; // an mcp-channel heartbeat is dead after 3min (matches agent-presence)
419
479
  // The host CLI's pid (Claude Code exports CLAUDE_PID to every child, including
@@ -806,6 +866,7 @@ function normalizeMsg(m, now = Date.now()) {
806
866
  ...(raw.acknowledgedActionId ? { acknowledgedActionId: String(raw.acknowledgedActionId).slice(0, 160) } : {}),
807
867
  ...(raw.consumedActionId ? { consumedActionId: String(raw.consumedActionId).slice(0, 160) } : {}),
808
868
  ...(raw.offerToken ? { offerToken: String(raw.offerToken).slice(0, 160) } : {}),
869
+ ...(raw.consumedVia ? { consumedVia: String(raw.consumedVia).slice(0, 40) } : {}),
809
870
  ...(raw.reason ? { reason: String(raw.reason).slice(0, 120) } : {}),
810
871
  ...(raw.legacySeen ? { legacySeen: true } : {}),
811
872
  });
@@ -821,10 +882,15 @@ function normalizeMsg(m, now = Date.now()) {
821
882
  next.deliveryVersion = MSG_DELIVERY_VERSION;
822
883
  next.deliveries = [...records.values()];
823
884
  if (!Array.isArray(next.seen)) next.seen = [];
824
- // v2 retired on acknowledgement, which proved only model-context injection.
825
- // Conservatively reopen those records. A native v3 retired receipt already
826
- // represents full consumption and must survive normalization unchanged.
827
- if (sourceVersion < MSG_DELIVERY_VERSION && next.retiredAt && !next.deadLetter) delete next.retiredAt;
885
+ // History is never rewritten (2026-08-13, PARITY with agent-presence.mjs
886
+ // normalizeMessageDelivery). The old "conservatively reopen" migration
887
+ // deleted retiredAt from v2-retired messages, resurrecting a week of
888
+ // delivered notes and re-terminalizing the >24h ones as FAILED. A v2
889
+ // retirement proved model-context injection; it stays retired, recorded
890
+ // as exactly that and no more.
891
+ if (sourceVersion < MSG_DELIVERY_VERSION && next.retiredAt && !next.deadLetter && !next.retirement) {
892
+ next.retirement = { reason: 'v2-retired — model-context injection proven, explicit consumption unknown', at: Number(next.retiredAt) || now };
893
+ }
828
894
  return next;
829
895
  }
830
896
  function msgDeliveryState(m, sid) {
@@ -852,6 +918,7 @@ function setMsgDelivery(m, sid, state, now, reason = null, actionId = '') {
852
918
  if (actionId) r.offeredActionId = String(actionId).slice(0, 160);
853
919
  delete r.acknowledgedAt; delete r.consumedAt;
854
920
  delete r.acknowledgedActionId; delete r.consumedActionId;
921
+ delete r.consumedVia;
855
922
  delete r.failedAt; delete r.reason;
856
923
  } else if (state === 'acknowledged') {
857
924
  if (!r.offerToken) r.offerToken = makeMsgOfferToken();
@@ -874,13 +941,25 @@ function setMsgDelivery(m, sid, state, now, reason = null, actionId = '') {
874
941
  function terminalizeMsg(m, now, reason) {
875
942
  const next = normalizeMsg(m, now);
876
943
  const known = new Set([...(Array.isArray(next.candidateIds) ? next.candidateIds : []), ...next.deliveries.map(r => r.recipientId)].map(msgRecipientKey).filter(Boolean));
877
- let failed = known.size === 0;
944
+ // Honest terminal split (2026-08-13, PARITY with agent-presence.mjs
945
+ // terminalizeMessage): 'acknowledged' reached model context on two
946
+ // independent actions — expiry after that is not a delivery failure.
947
+ // Only never-delivered (pending) and offered-once-unconfirmed records
948
+ // fail; an all-acknowledged message retires as delivered-unconfirmed.
949
+ let failures = 0, reachedUnconsumed = 0;
878
950
  for (const id of known) {
879
- if (msgDeliveryState(next, id) === 'consumed') continue;
880
- failed = true; setMsgDelivery(next, id, 'failed', now, reason);
951
+ const state = msgDeliveryState(next, id);
952
+ if (state === 'consumed') continue;
953
+ if (state === 'acknowledged') { reachedUnconsumed++; continue; }
954
+ failures++;
955
+ setMsgDelivery(next, id, 'failed', now, state === 'pending' ? `${reason} (never delivered)` : `${reason} (offered once, unconfirmed)`);
956
+ }
957
+ if (failures || known.size === 0) {
958
+ next.deadLetter = { state: 'failed', reason, at: now };
959
+ } else {
960
+ next.retiredAt = now;
961
+ if (reachedUnconsumed) next.retirement = { reason: `${reason} — after acknowledgement (delivered, consumption unconfirmed)`, at: now };
881
962
  }
882
- if (failed) next.deadLetter = { state: 'failed', reason, at: now };
883
- else next.retiredAt = now;
884
963
  return next;
885
964
  }
886
965
  const msgTerminal = (m) => Boolean(m?.deadLetter || m?.retiredAt);
@@ -1273,10 +1352,14 @@ function messageFooter(sid, tp, lib, actionId = messageActionId(readHookInput(),
1273
1352
  const due = messages.filter(m => {
1274
1353
  const state = msgDeliveryState(m, sid);
1275
1354
  const record = msgDeliveryRecord(m, sid);
1355
+ // 'acknowledged' re-renders ONLY without action-identity evidence
1356
+ // of a third action; with evidence the lease pass below retires it
1357
+ // as consumed instead of injecting it a third time (PARITY with
1358
+ // agent-presence.mjs receiveMessages).
1276
1359
  return m && !msgTerminal(m) && m.from !== sid
1277
- && ['pending', 'offered', 'acknowledged'].includes(state)
1360
+ && (['pending', 'offered'].includes(state)
1361
+ || (state === 'acknowledged' && (!actionId || !record?.acknowledgedActionId)))
1278
1362
  && !(state === 'offered' && actionId && record?.offeredActionId === String(actionId))
1279
- && !(state === 'acknowledged' && actionId && record?.acknowledgedActionId === String(actionId))
1280
1363
  && msgTargetsMe(m, me, sid, sessions);
1281
1364
  }).sort((left, right) => {
1282
1365
  // Fresh pending work wins the six-message context budget, followed
@@ -1320,13 +1403,29 @@ function messageFooter(sid, tp, lib, actionId = messageActionId(readHookInput(),
1320
1403
  const prior = msgDeliveryState(m, sid);
1321
1404
  if (prior === 'pending') setMsgDelivery(m, sid, 'offered', now, null, actionId);
1322
1405
  else if (prior === 'offered' || prior === 'acknowledged') {
1323
- // A later independent action acknowledges the offer. Further
1324
- // actions keep replaying it and refresh only the action dedupe;
1325
- // acknowledgement never retires a note in v3.
1406
+ // A later independent action acknowledges the offer.
1326
1407
  setMsgDelivery(m, sid, 'acknowledged', now, null, actionId);
1327
1408
  }
1328
1409
  retireFullyConsumedMsg(m, now);
1329
1410
  }
1411
+ // Lease auto-consume (2026-08-13, PARITY with agent-presence.mjs
1412
+ // receiveMessages): a note acknowledged on an EARLIER action is retired
1413
+ // as consumed (consumedVia 'auto-lease') when this later independent
1414
+ // action arrives, instead of replaying for 24h and dead-lettering as
1415
+ // "failed" a note the model saw repeatedly. The explicit
1416
+ // brain_message_receipt still records the stronger 'receipt' claim.
1417
+ if (actionId) {
1418
+ for (const m of messages) {
1419
+ if (msgTerminal(m) || m.from === sid) continue;
1420
+ const record = msgDeliveryRecord(m, sid);
1421
+ if (!record || record.state !== 'acknowledged') continue;
1422
+ if (!record.acknowledgedActionId || record.acknowledgedActionId === String(actionId)) continue;
1423
+ if (!msgTargetsMe(m, me, sid, sessions)) continue;
1424
+ setMsgDelivery(m, sid, 'consumed', now, null, actionId);
1425
+ record.consumedVia = 'auto-lease';
1426
+ retireFullyConsumedMsg(m, now);
1427
+ }
1428
+ }
1330
1429
  writeLaneAtomic(JSON.stringify({ ...data, sessions, messages }));
1331
1430
  } catch (error) {
1332
1431
  const reason = String(error?.code || error?.message || 'lane-write-failed').slice(0, 100);
@@ -2531,28 +2630,35 @@ async function capture(lib) {
2531
2630
  // cards into [Area] containers, and wires [[wikilink]] connections.
2532
2631
  const doCapture = async (locked) => {
2533
2632
  const merged = readState(); for (const k of seen) merged.add(k);
2633
+ // Own-batch snapshot BEFORE the drain merges queued peers' batches in:
2634
+ // if the brain write fails we re-queue only OUR contribution (drained
2635
+ // batches stay queued — ids/paths clear only after a durable write),
2636
+ // so nothing lands twice and nothing is lost.
2637
+ const ownCards = cards.slice(), ownResolutions = resolutions.slice(), ownUpdates = updates.slice();
2534
2638
  if (!locked) {
2535
- // REFUSED: never write from a stale base. The batch is queued durably
2536
- // (same home as the dedup state) and drained by the next capture in
2537
- // this project — deferred-but-safe beats immediate-but-clobbering.
2538
- if (cards.length || resolutions.length || updates.length) {
2539
- updatePendingCaptures((current) => [
2540
- ...current,
2541
- { id: `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`, ts: nowIso(), cards, resolutions, updates },
2542
- ]);
2639
+ // REFUSED: never write from a stale base. The batch is queued
2640
+ // (lock-held RMW, falling back to an orphan sidecar) and drained by
2641
+ // the next capture — deferred-but-safe beats immediate-but-clobbering.
2642
+ // Baselines advance ONLY if the queue write really succeeded;
2643
+ // otherwise everything stays re-gatherable from the transcript.
2644
+ const queued = (cards.length || resolutions.length || updates.length)
2645
+ ? queuePendingCapture({ id: `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`, ts: nowIso(), cards, resolutions, updates })
2646
+ : true;
2647
+ if (queued) {
2648
+ writeState(merged);
2649
+ writeLastCommit(newLastCommit); // safe: the batch itself is durably queued (verified above, not assumed)
2650
+ if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
2651
+ advanceShipBaseline(lib);
2543
2652
  }
2544
- writeState(merged);
2545
- writeLastCommit(newLastCommit); // safe: the batch itself is durably queued
2546
- if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
2547
- advanceShipBaseline(lib);
2548
- appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: 'lock-timeout — batch QUEUED for the next capture; brain untouched' }, 500);
2549
- process.stderr.write('[brain] capture deferred: the brain lock is held (desktop save or peer capture) — batch queued durably, nothing lost\n');
2653
+ appendJsonl(HEALTH, { ts: nowIso(), project: path.basename(CWD), mode: 'capture', ok: false, err: queued ? 'lock-timeout — batch QUEUED for the next capture; brain untouched' : 'lock-timeout AND queue write failed — nothing advanced; markers remain re-gatherable' }, 500);
2654
+ process.stderr.write(queued ? '[brain] capture deferred: the brain lock is held (desktop save or peer capture) — batch queued durably, nothing lost\n' : '[brain] capture deferred AND queueing failed — markers stay in the transcript for the next Stop\n');
2550
2655
  return null;
2551
2656
  }
2552
2657
  // Drain the queue UNDER the lock: read, land, clear-by-id — a peer's
2553
2658
  // batch queued after this read survives, and no batch lands twice.
2554
2659
  const pendingBatches = readPendingCaptures();
2555
2660
  const drainedPendingIds = new Set(pendingBatches.map(b => b && b.id).filter(Boolean));
2661
+ const drainedOrphanPaths = pendingBatches.map(b => b && b.__orphanPath).filter(Boolean);
2556
2662
  for (const b of pendingBatches) {
2557
2663
  if (!b || typeof b !== 'object') continue;
2558
2664
  for (const c of (Array.isArray(b.cards) ? b.cards : [])) cards.push(c);
@@ -2594,6 +2700,7 @@ async function capture(lib) {
2594
2700
  writeState(merged);
2595
2701
  writeLastCommit(newLastCommit);
2596
2702
  if (drainedPendingIds.size) updatePendingCaptures((current) => current.filter(b => b && !drainedPendingIds.has(b.id)));
2703
+ clearDrainedOrphans(drainedOrphanPaths);
2597
2704
  if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
2598
2705
  advanceShipBaseline(lib);
2599
2706
  return null;
@@ -2605,10 +2712,28 @@ async function capture(lib) {
2605
2712
  });
2606
2713
  // Re-pack the whole grid so a container that grew never overlaps its neighbor.
2607
2714
  let out = res.buffer; try { out = (await lib.tidyBrain(res.buffer)).buffer; } catch { /* keep append result if tidy fails */ }
2608
- await lib.atomicWrite(BRAIN, out);
2715
+ try {
2716
+ await lib.atomicWrite(BRAIN, out);
2717
+ } catch (e) {
2718
+ // The write failed even after atomicWrite's bounded EPERM backoff
2719
+ // (something held brain.klypix past ~2.7s — a desktop save, AV, an
2720
+ // indexer). Same discipline as a lock refusal: queue OUR OWN batch
2721
+ // durably (drained batches stay queued — their ids/paths were never
2722
+ // cleared), advance baselines only if that queue write succeeded,
2723
+ // and exit 0 by contract. Field case: the 2026-08-12 EPERM landed
2724
+ // on a session's FINAL Stop and its markers had nowhere to go.
2725
+ const queued = (ownCards.length || ownResolutions.length || ownUpdates.length)
2726
+ ? queuePendingCapture({ id: `${Date.now()}-${process.pid}-${Math.random().toString(36).slice(2)}`, ts: nowIso(), cards: ownCards, resolutions: ownResolutions, updates: ownUpdates })
2727
+ : true;
2728
+ if (queued) { writeState(merged); writeLastCommit(newLastCommit); }
2729
+ 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);
2730
+ 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`);
2731
+ return null;
2732
+ }
2609
2733
  writeState(merged);
2610
2734
  writeLastCommit(newLastCommit); // advance the commit baseline only after a successful write
2611
2735
  if (drainedPendingIds.size) updatePendingCaptures((current) => current.filter(b => b && !drainedPendingIds.has(b.id)));
2736
+ clearDrainedOrphans(drainedOrphanPaths);
2612
2737
  // Same discipline for the two ship channels: the queue is consumed and
2613
2738
  // the observation baseline advances ONLY now that the cards are durable.
2614
2739
  if (drainedShips && typeof lib.clearPendingShips === 'function') lib.clearPendingShips(CWD);
@@ -171,12 +171,15 @@ const sha1 = (s) => crypto.createHash('sha1').update(s).digest('hex');
171
171
  // "↩ superseded" variant AND the gardener's "⤵ consolidated" stamp), so
172
172
  // cross-brain as_of was silently stricter than brain_ask as_of (2026-07-23).
173
173
 
174
- // ── On-device cross-encoder reranker (brain_ask precision) ───────────────────
175
- // Eval-proven on the frozen human-paraphrase set (2026-07-15): recall@5 15%→40%,
176
- // MRR 0.087→0.28, top-1 0%→20%. Scores (question, cardText) PAIRS jointly (full
177
- // token interaction, unlike the bi-encoder cosine) and reorders a wide candidate
178
- // net. The bounded runtime keeps the same q8 model and scoring while owning
179
- // tensor disposal and serialization. Disable outright with KLYPIX_RERANK=0.
174
+ // ── On-device cross-encoder reranker (brain_ask, opt-in experiment) ──────────
175
+ // RETIRED as a default after the 2026-08-10 harness fix. The "recall@5 15%→40%"
176
+ // that once justified it here was measured in a vector space the product does
177
+ // not use (mean-pooled, unprefixed queries — the broken pre-fix harness).
178
+ // Re-measured with the PRODUCTION embedder on the frozen human-paraphrase set:
179
+ // the single BGE pass scores recall@5 30% / MRR 0.22; adding the reranker DROPS
180
+ // that to 25% / 0.167 and costs ~3.5s/query. It still scores (question, card)
181
+ // pairs jointly with full token interaction; keep it reachable for experiments
182
+ // via KLYPIX_RERANK=1 (opt-IN — off by default; see the gate below).
180
183
 
181
184
  // ── small block helpers ──────────────────────────────────────────────────────
182
185
  const text = (t) => ({ kind: 'text', text: t });
@@ -523,11 +526,23 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
523
526
  const vecs = await vectorsForBrain(pipe, t.file, struct.cards);
524
527
  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)'; }
525
528
  }
526
- } catch { semantic = null; cardVecs = null; mode = 'lexical (semantic warming — retry for semantic ranking)'; }
529
+ } catch { semantic = null; cardVecs = null; }
530
+ // Parity with search_all_brains (8445e9c): never say "warming — retry" to a
531
+ // host with no runtime installed — for them that advice is wrong forever and
532
+ // their results are the measured recall@5 = 0% lexical path. The full notice
533
+ // is a SEPARATE advisory line: gluing three sentences and a filesystem path
534
+ // into the "N matched, X ranking" header garbled every lexical-only answer
535
+ // (2026-08-14 review), and later "+ rerank"/"+ status-mode" suffixes would
536
+ // have landed mid-sentence.
537
+ let fallbackNotice = null;
538
+ if (!semantic) {
539
+ fallbackNotice = semanticFallbackNotice(false);
540
+ if (fallbackNotice) mode = fallbackNotice.startsWith('LEXICAL ONLY') ? 'lexical-only (no semantic runtime)' : 'lexical (semantic warming)';
541
+ }
527
542
  const kk = Math.max(1, Math.min(20, k || 10));
528
543
  const timeTravel = asOfTs != null;
529
544
  // Cross-encoder rerank is OPT-IN. On the frozen human/paraphrase set it made
530
- // BGE top-5 recall worse (35%→25%) and added ~3.5s/query. Keep the reversible
545
+ // BGE top-5 recall worse (30%→25%) and added ~3.5s/query. Keep the reversible
531
546
  // escape hatch for experiments, but the best measured experience is the single
532
547
  // BGE pass. Suppressed under as_of either way.
533
548
  const wantRerank = !timeTravel && process.env.KLYPIX_RERANK === '1';
@@ -558,7 +573,8 @@ export async function opBrainAsk({ vault, canvas, question, as_of, k = 10, log =
558
573
  if (result.statusStrong && !timeTravel) {
559
574
  try { statusMd = statusContextToMarkdown(struct); mode += ' + status-mode'; } catch { statusMd = ''; }
560
575
  }
561
- return { blocks: [text(stamp + statusMd + questionContextToMarkdown(q, result, { mode, as_of: timeTravel ? as_of : null }))] };
576
+ const noticeMd = fallbackNotice ? `> ${fallbackNotice}\n\n` : '';
577
+ return { blocks: [text(stamp + noticeMd + statusMd + questionContextToMarkdown(q, result, { mode, as_of: timeTravel ? as_of : null }))] };
562
578
  }
563
579
 
564
580
  // ── brain_challenge — the adversarial brain ───────────────────────────────────
@@ -218,9 +218,25 @@ export async function atomicWrite(filePath, buf, opts = {}) {
218
218
  }
219
219
  const tmp = filePath + '.tmp-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6);
220
220
  fs.writeFileSync(tmp, buf);
221
- try { fs.renameSync(tmp, filePath); } // Node uses MoveFileEx(REPLACE_EXISTING) on Windows → overwrites atomically
222
- catch (e) { try { fs.rmSync(tmp); } catch { /* */ } throw e; }
221
+ // Windows rename-over-open-destination throws EPERM/EBUSY/EACCES while
222
+ // another process (a desktop save/read, AV, an indexer) briefly holds the
223
+ // target — three real capture failures in the field (.hook-health.jsonl,
224
+ // 2026-08-11/12). A bounded backoff (~2.7s total) outlasts a transient
225
+ // hold; a persistent holder still throws so callers can queue the batch —
226
+ // delayed is acceptable, lost is not.
227
+ for (let attempt = 0; ; attempt++) {
228
+ try { fs.renameSync(tmp, filePath); break; } // Node uses MoveFileEx(REPLACE_EXISTING) on Windows → overwrites atomically
229
+ catch (e) {
230
+ if (attempt >= RENAME_BACKOFF_MS.length || !RENAME_RETRYABLE_CODES.has(e?.code)) {
231
+ try { fs.rmSync(tmp); } catch { /* */ }
232
+ throw e;
233
+ }
234
+ await new Promise((resolve) => setTimeout(resolve, RENAME_BACKOFF_MS[attempt]));
235
+ }
236
+ }
223
237
  }
238
+ const RENAME_RETRYABLE_CODES = new Set(['EPERM', 'EBUSY', 'EACCES']);
239
+ const RENAME_BACKOFF_MS = [40, 120, 300, 700, 1500];
224
240
 
225
241
  // ── verify: suffix (decay-aware status, 2026-07-28 post-mortem) ──────────────
226
242
  // A card may carry the EXACT live-probe command for its fast-decay claim as a
@@ -1589,6 +1589,11 @@ export function createMcpPresence({
1589
1589
  hostIdentityPrepared: details.hostIdentityPrepared === true,
1590
1590
  clientInfoPrepared: details.clientInfoPrepared || null,
1591
1591
  branchPrepared: details.branchPrepared,
1592
+ // Forward the action identity (2026-08-14 review): without it, every
1593
+ // server instance's FIRST sync advanced delivery receipts with
1594
+ // actionId '' — records got no offered/acknowledgedActionId, breaking
1595
+ // same-action dedupe and the lease's third-action evidence.
1596
+ actionId: details.actionId || '',
1592
1597
  });
1593
1598
  // Consume the write verdict (1.52.0 plumbed it; nothing read it): a
1594
1599
  // contended lane skips the write, and ~3 skipped heartbeats in a row used
@@ -2157,9 +2162,9 @@ export function createMcpPresence({
2157
2162
  delivery: {
2158
2163
  proactive: 'mcp-logging-best-effort-preview',
2159
2164
  modelContext: shouldDeliverMessages ? 'supported-klypix-action' : 'deferred',
2160
- stateMachine: 'pending -> offered -> acknowledged -> consumed | failed',
2161
- acknowledgement: 'a later independent supported action followed an offer; explicit token-bound brain_message_receipt records actual model consumption',
2162
- retention: 'machine-local; 24h TTL and bounded lane capacity, with explicit failed receipts on expiry/overflow',
2165
+ stateMachine: 'pending -> offered -> acknowledged -> consumed (auto-lease on the next independent action, or explicit receipt) | failed',
2166
+ acknowledgement: 'a later independent supported action followed an offer; a further independent action auto-consumes the lease, and explicit token-bound brain_message_receipt records the stronger acted-on-it claim',
2167
+ retention: 'machine-local; 24h TTL and bounded lane capacity; expiry fails only never-delivered or offered-once records — an acknowledged note retires as delivered-unconfirmed, never as failed',
2163
2168
  writeOk: shouldDeliverMessages ? report.deliveryWriteOk : null,
2164
2169
  ...(report.deliveryWriteSkippedReason ? { writeFailure: report.deliveryWriteSkippedReason } : {}),
2165
2170
  },