klypix-mcp 1.73.2 → 1.74.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.
@@ -223,12 +223,16 @@ try {
223
223
  offeredAt: now - 900,
224
224
  acknowledgedAt: now - 500,
225
225
  consumedAt: now - 250,
226
+ // The STRONG claim: this peer sent a real receipt. Without this marker the
227
+ // record is an auto-lease or unknown, and the renderer must not say
228
+ // "explicitly" — the conformance fixture has to state which it means.
229
+ consumedVia: 'receipt',
226
230
  }],
227
231
  seen: ['finding-owner'],
228
232
  }],
229
233
  sessions: lane, selfId: 'finding-sender', now,
230
234
  });
231
- checks.findingReceiptRendered = /explicitly consumed by all 1 target peer\(s\) after model-context delivery \(not human-read\)/.test(renderReceiptSummary(receipt));
235
+ checks.findingReceiptRendered = /explicitly consumed by all 1 target peer\(s\) via receipt \(not human-read\)/.test(renderReceiptSummary(receipt));
232
236
  }
233
237
 
234
238
  // ── Cross-PC presence: simulated two-machine scenario ─────────────────────
@@ -562,7 +562,7 @@ server.registerTool('project_map_scan', {
562
562
 
563
563
  server.registerTool('project_map_drift', {
564
564
  title: 'Check brain cards against the repo\'s real files (drift report)',
565
- description: 'Read-only drift check: every brain card that references THIS project\'s files is verified against the working tree. Reports cards whose referenced files are gone or moved (with rename candidates by unique basename), and headlines when the checkout itself is behind its origin default branch — in that state a "missing" file may simply not be in this checkout. Slash-joined name enumerations and other-project paths are recognized and skipped, not reported as drift. Nothing is written; fix cards with a CORRECTION marker or by editing them in KLYPIX.',
565
+ description: 'Read-only drift check: every brain card that references THIS project\'s files is verified against the working tree. Reports cards whose referenced files are gone or moved (with rename candidates by unique basename), and headlines the checkout\'s own position against its origin default branch — reporting BOTH ahead and behind, so a DIVERGED checkout is named as diverged rather than merely behind. In either state a "missing" file may simply not be in this checkout. Slash-joined name enumerations and other-project paths are recognized and skipped, not reported as drift. Nothing is written; fix cards with a CORRECTION marker or by editing them in KLYPIX.',
566
566
  annotations: { destructiveHint: false, idempotentHint: true, openWorldHint: false },
567
567
  inputSchema: {
568
568
  project: z.string().optional().describe('Absolute project root. Defaults to this MCP connection\'s configured project/vault.'),
@@ -691,7 +691,7 @@ server.registerTool('brain_note', {
691
691
 
692
692
  server.registerTool('brain_message', {
693
693
  title: 'Message the other live agent sessions on this project (one-time note, not a brain card)',
694
- description: 'Leave a DELIBERATE, targeted note for the OTHER active agent sessions working on this project right now ("merged the hook refactor — rebase before you commit", "don\'t touch canvasStore, mid-refactor"). Any MCP client can send and receive through the shared machine-local presence lane. A supported lifecycle event or KLYPIX tool result offers the note into model-visible context; a later independent supported action acknowledges that offer. Pending/offered notes replay after reconnect, while expiry or capacity loss leaves a failed per-recipient receipt instead of silently disappearing. Acknowledged means a later action followed the offer — it is NOT proof a human read it. Delivery remains OS-user-local, machine-local, bounded by a 24h TTL, and unavailable to a peer that never takes a supported action. Ephemeral and NOT persisted to the brain — for a durable decision use brain_note instead.',
694
+ description: 'Leave a DELIBERATE, targeted note for the OTHER active agent sessions working on this project right now ("merged the hook refactor — rebase before you commit", "don\'t touch canvasStore, mid-refactor"). Any MCP client can send and receive through the shared machine-local presence lane. A supported lifecycle event or KLYPIX tool result offers the note into model-visible context; a later independent supported action acknowledges that offer. Pending/offered notes replay after reconnect, while expiry or capacity loss leaves a failed per-recipient receipt instead of silently disappearing. Acknowledged means a later action followed the offer — it is NOT proof a human read it. A note then retires either by an explicit brain_message_receipt ("acted on it") or by AUTO-CONSUMPTION on a further independent action, with no receipt; your receipt line names which, and auto-consumption evidences activity, not uptake. Delivery remains OS-user-local, machine-local, bounded by a 24h TTL, and unavailable to a peer that never takes a supported action. Ephemeral and NOT persisted to the brain — for a durable decision use brain_note instead.',
695
695
  inputSchema: {
696
696
  text: z.string().describe('The note to deliver (kept to 400 chars).'),
697
697
  to: z.string().optional().describe('Target hint — a peer session id-prefix or branch name; omit or "all" for every live session.'),
@@ -790,7 +790,7 @@ server.registerTool('brain_sync', {
790
790
  releaseIntent: z.object({
791
791
  version: z.string().max(64).describe('The version this session intends to release (e.g. "1.70.0").'),
792
792
  ref: z.string().max(200).describe('The git ref (branch or tag) the release will be cut from.'),
793
- acknowledge: z.array(z.string().max(40)).max(64).optional().describe('Commit shas this release DELIBERATELY leaves behind. Only needed after a refusal: if the ref would drop finished work, the lease is refused and the response names every sha. Re-declare with those shas here to proceed — and tell the user what they are first.'),
793
+ acknowledge: z.array(z.string().max(40)).max(1024).optional().describe('Commit shas this release DELIBERATELY leaves behind. Only needed after a refusal: if the ref would drop finished work, the lease is refused and the response names every sha. Re-declare with those shas here to proceed — and tell the user what they are first.'),
794
794
  }).optional().describe('Declare EXCLUSIVE intent to prepare a release of this project. The first declarer takes a ~2h lease (refreshed by checkpoints, freed by phase "complete", by expiry, or when the holder session ends); a second declarer gets a structured hard conflict naming the holder, version, and ref. While any lease is active every peer\'s sync gains a "release in preparation" footer line. A NEW declaration is also checked against what the release would LEAVE BEHIND: if the ref is missing commits that are on trunk or on a branch a live peer session is working on, the lease is REFUSED (nothing is changed) and the response lists them — report those commits to the user, then re-declare with acknowledge:[...] naming each sha if the release should go ahead without them.'),
795
795
  },
796
796
  }, async ({ project, intent, files, phase, include_context, results, releaseIntent }, extra) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.73.2",
3
+ "version": "1.74.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/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/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.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/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.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/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-handshake.mjs && node test/completion-guard.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/one-command-setup.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/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/install-rename-backoff.mjs && node test/project-binding-rebind.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.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/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.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/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-handshake.mjs && node test/completion-guard.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/one-command-setup.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"
@@ -2471,7 +2471,7 @@ export function formatReceivedMessages(messages, now = Date.now(), decay = {}, s
2471
2471
  lines.push(`- from ${senderLabel} (${ageMin}m ago): ${neutralizeMarkers(String(message.text || '').replace(/\s+/g, ' ').trim().slice(0, 400))}`);
2472
2472
  const receipts = group.map((item) => messageDeliveryReceipt(item, sessionId)).filter(Boolean);
2473
2473
  if (receipts.length) {
2474
- lines.push(` Receipt(s): ${receipts.map((receipt) => `${receipt.messageId}:${receipt.offerToken}`).join(', ')}. After incorporating ${receipts.length === 1 ? 'it' : 'them'}, call brain_message_receipt with each exact message_id and offer_token.`);
2474
+ lines.push(` Receipt(s): ${receipts.map((receipt) => `${receipt.messageId}:${receipt.offerToken}`).join(', ')}. After incorporating ${receipts.length === 1 ? 'it' : 'them'}, call brain_message_receipt with each exact message_id and offer_token — that is the ONLY way the sender learns you acted on ${receipts.length === 1 ? 'it' : 'them'}. If you skip it, your next independent action auto-consumes ${receipts.length === 1 ? 'this note' : 'these notes'} and the sender is told only that ${receipts.length === 1 ? 'it was' : 'they were'} auto-consumed.`);
2475
2475
  }
2476
2476
  const info = messageDecayInfo({ ...message, ts: oldestTs }, now, decay);
2477
2477
  if (info) lines.push(` ${info.stampText}`);
@@ -511,6 +511,14 @@ const receiptRecordMap = (message) => {
511
511
  state: ['pending', 'offered', 'acknowledged', 'consumed', 'failed'].includes(raw?.state)
512
512
  ? raw.state : 'offered',
513
513
  reason: raw?.reason ? String(raw.reason) : null,
514
+ // HOW consumption happened, not just THAT it did. Dropping this during
515
+ // normalization is the root cause of the receipt surfaces calling every
516
+ // consumption "explicit": the distinction was recorded on disk by
517
+ // agent-presence and then discarded one layer before the renderer, so no
518
+ // renderer could tell the strong claim from the weak one. Only the two
519
+ // values the writer actually sets are honoured; anything else is unknown.
520
+ consumedVia: raw?.consumedVia === 'receipt' || raw?.consumedVia === 'auto-lease'
521
+ ? raw.consumedVia : null,
514
522
  });
515
523
  }
516
524
  // Historical `seen` was stamped before output transport/model injection. It
@@ -551,6 +559,17 @@ export function summarizeReceipts({ messages, sessions, selfId, now = Date.now()
551
559
  const failed = stateIds('failed');
552
560
  const pending = candidateIds.filter((id) => !records.has(id) || records.get(id)?.state === 'pending');
553
561
  const unresolved = [...pending, ...offered, ...acknowledged].map((id) => String(id).slice(0, 8));
562
+ // WHY consumption is split: 'consumed' is reached two very different ways.
563
+ // 'receipt' is the strong claim — the recipient named the message id and offer
564
+ // token, i.e. "I acted on it". 'auto-lease' is the weak one — a later independent
565
+ // action merely followed the offer, which is evidence of activity, not of uptake.
566
+ // An ABSENT consumedVia is UNKNOWN, never auto-lease: the field is serialized only
567
+ // when set (agent-presence.mjs:507) and deleted on state reset (:588), so records
568
+ // written by an older bundled engine carry no marker and must not be miscounted.
569
+ const via = (id) => records.get(id)?.consumedVia || null;
570
+ const consumedByReceipt = consumed.filter((id) => via(id) === 'receipt');
571
+ const consumedByLease = consumed.filter((id) => via(id) === 'auto-lease');
572
+ const consumedUnknownVia = consumed.filter((id) => via(id) === null);
554
573
  return {
555
574
  id: m.id,
556
575
  to: m.to || 'all',
@@ -560,6 +579,9 @@ export function summarizeReceipts({ messages, sessions, selfId, now = Date.now()
560
579
  text: String(m.text || '').slice(0, 120),
561
580
  acknowledged: acknowledged.length,
562
581
  consumed: consumed.length,
582
+ consumedByReceipt: consumedByReceipt.length,
583
+ consumedByLease: consumedByLease.length,
584
+ consumedUnknownVia: consumedUnknownVia.length,
563
585
  // Compatibility alias for programmatic consumers. It now means a later
564
586
  // model-context acknowledgement milestone (including subsequently
565
587
  // consumed deliveries), never `seen` and never human-read.
@@ -571,6 +593,8 @@ export function summarizeReceipts({ messages, sessions, selfId, now = Date.now()
571
593
  offeredIds: offered.map((id) => String(id).slice(0, 8)),
572
594
  acknowledgedIds: acknowledged.map((id) => String(id).slice(0, 8)),
573
595
  consumedIds: consumed.map((id) => String(id).slice(0, 8)),
596
+ consumedByReceiptIds: consumedByReceipt.map((id) => String(id).slice(0, 8)),
597
+ consumedByLeaseIds: consumedByLease.map((id) => String(id).slice(0, 8)),
574
598
  failedIds: failed.map((id) => String(id).slice(0, 8)),
575
599
  deadLetterReason: m.deadLetter?.reason ? String(m.deadLetter.reason) : null,
576
600
  };
@@ -578,6 +602,35 @@ export function summarizeReceipts({ messages, sessions, selfId, now = Date.now()
578
602
  return { sent: receipts.length, receipts };
579
603
  }
580
604
 
605
+ // ONE phrase, shared by every receipt surface. Two renderers drifting apart is how
606
+ // "explicitly consumed" survived after auto-lease made it untrue on most paths, so
607
+ // the wording lives in exactly one place and both callers below must use it.
608
+ // `receipt.consumed === receipt.candidates` is the caller's precondition.
609
+ export function fullConsumptionPhrase(receipt) {
610
+ const weak = (receipt.consumedByLease || 0) + (receipt.consumedUnknownVia || 0);
611
+ if (!weak) {
612
+ return `explicitly consumed by all ${receipt.candidates} target peer(s) via receipt (not human-read)`;
613
+ }
614
+ const parts = [];
615
+ if (receipt.consumedByReceipt) parts.push(`${receipt.consumedByReceipt} by explicit receipt`);
616
+ if (receipt.consumedByLease) parts.push(`${receipt.consumedByLease} auto-consumed after a later independent action`);
617
+ if (receipt.consumedUnknownVia) parts.push(`${receipt.consumedUnknownVia} consumed by an unrecorded route`);
618
+ return `consumed by all ${receipt.candidates} target peer(s) — ${parts.join(', ')}; auto-consumption is not proof the note was acted on, and never proof a human read it`;
619
+ }
620
+
621
+ // Shown only when some peers consumed by the weak route, so a partial line still
622
+ // distinguishes uptake from mere activity.
623
+ function partialConsumptionSplit(receipt) {
624
+ if (!receipt.consumed) return '';
625
+ const weak = (receipt.consumedByLease || 0) + (receipt.consumedUnknownVia || 0);
626
+ if (!weak) return '';
627
+ // 'auto-consumed' is itself a mechanism claim; an unknown-route record (older
628
+ // engine wrote it) must not be folded into it (2026-08-17 review catch).
629
+ return receipt.consumedUnknownVia
630
+ ? ` (${receipt.consumedByReceipt || 0} by receipt, ${weak} auto-consumed or unrecorded)`
631
+ : ` (${receipt.consumedByReceipt || 0} by receipt, ${weak} auto-consumed)`;
632
+ }
633
+
581
634
  // One honest line per receipt. `pending` is listed by id-prefix up to 4 — a name
582
635
  // you can chase beats a number you can't.
583
636
  export function renderReceipt(receipt) {
@@ -590,14 +643,14 @@ export function renderReceipt(receipt) {
590
643
  return `- ${who}, ${age}: queued with no live local target snapshot; no local delivery is claimed. “${receipt.text}”`;
591
644
  }
592
645
  if (receipt.consumed === receipt.candidates && !receipt.failed) {
593
- return `- ${who}, ${age}: explicitly consumed by all ${receipt.candidates} target peer(s) after model-context delivery (not human-read). “${receipt.text}”`;
646
+ return `- ${who}, ${age}: ${fullConsumptionPhrase(receipt)}. “${receipt.text}”`;
594
647
  }
595
648
  const pending = receipt.pendingIds.slice(0, 4).join(', ');
596
649
  const more = receipt.pendingIds.length > 4 ? ` +${receipt.pendingIds.length - 4} more` : '';
597
650
  const offered = receipt.offered ? ` · offered ${receipt.offered}, awaiting later-action ack` : '';
598
651
  const acknowledged = receipt.acknowledged ? ` · acknowledged ${receipt.acknowledged}, awaiting explicit consumption` : '';
599
652
  const failed = receipt.failed ? ` · failed ${receipt.failed}${failure ? ` (${failure})` : ''}` : '';
600
- return `- ${who}, ${age}: consumed ${receipt.consumed} of ${receipt.candidates}${offered}${acknowledged}${failed}${pending ? ` · unresolved ${pending}${more}` : ''}. “${receipt.text}”`;
653
+ return `- ${who}, ${age}: consumed ${receipt.consumed} of ${receipt.candidates}${partialConsumptionSplit(receipt)}${offered}${acknowledged}${failed}${pending ? ` · unresolved ${pending}${more}` : ''}. “${receipt.text}”`;
601
654
  }
602
655
 
603
656
  // Compact, text-free receipt for surfaces that must stay cheap (SessionStart
@@ -615,14 +668,14 @@ export function renderReceiptSummary(summary) {
615
668
  : `📬 Your last note${target} (${age}): queued with no live local target snapshot; no local delivery claimed.`;
616
669
  }
617
670
  if (receipt.consumed === receipt.candidates && !receipt.failed) {
618
- return `📬 Your last note${target} (${age}): explicitly consumed by all ${receipt.candidates} target peer(s) after model-context delivery (not human-read).`;
671
+ return `📬 Your last note${target} (${age}): ${fullConsumptionPhrase(receipt)}.`;
619
672
  }
620
673
  const pending = receipt.pendingIds.slice(0, 4).join(', ');
621
674
  const more = receipt.pendingIds.length > 4 ? ` +${receipt.pendingIds.length - 4} more` : '';
622
675
  const offered = receipt.offered ? ` · offered ${receipt.offered}, awaiting ack` : '';
623
676
  const acknowledged = receipt.acknowledged ? ` · acknowledged ${receipt.acknowledged}, awaiting explicit consumption` : '';
624
677
  const failed = receipt.failed ? ` · failed ${receipt.failed}${failure ? ` (${failure})` : ''}` : '';
625
- return `📬 Your last note${target} (${age}): consumed ${receipt.consumed} of ${receipt.candidates}${offered}${acknowledged}${failed}${pending ? ` · unresolved ${pending}${more}` : ''}.`;
678
+ return `📬 Your last note${target} (${age}): consumed ${receipt.consumed} of ${receipt.candidates}${partialConsumptionSplit(receipt)}${offered}${acknowledged}${failed}${pending ? ` · unresolved ${pending}${more}` : ''}.`;
626
679
  }
627
680
 
628
681
  export function renderReceipts(summary, { limit = 3 } = {}) {
@@ -958,7 +958,11 @@ function peerFooter(sid) {
958
958
  const legend = marked.some(f => f.endsWith('*')) ? ' (* = observed from live edits, scope not declared)' : '';
959
959
  warn = ` · ⚠️ both edited: ${marked.join(', ')} — expect a conflict, KEEP BOTH${legend}`;
960
960
  }
961
- else if (p.branch && myBranch && p.branch === myBranch) warn = ' · ⚠️ same branch — pull/rebase before you commit';
961
+ // State the fact, never the git operation. This runs in a latency-sensitive
962
+ // hook and must not spawn git, so it cannot know whether the branch is merely
963
+ // behind or has DIVERGED — and on a diverged branch "pull/rebase" is advice
964
+ // the real state contradicts. Naming the collision is what this footer is for.
965
+ else if (p.branch && myBranch && p.branch === myBranch) warn = ' · ⚠️ same branch — your commits will interleave; coordinate before you commit';
962
966
  // Unique over the full live list (not just shown peers) — the MSG router
963
967
  // resolves a prefix against every row, so uniqueness must match that set.
964
968
  lines.push(`- session ${shortestUniquePeerPrefix(list, p.id)} · ${bits.join(' · ')}${warn}`);
@@ -966,7 +970,7 @@ function peerFooter(sid) {
966
970
  // v1.32.0 law: a truncated list must never render as a complete one. This
967
971
  // overflow line is unconditional — never subject to any budget.
968
972
  if (peers.length > 4) lines.push(`- …and ${peers.length - 4} more live session(s) not shown — \`npx klypix-mcp doctor\` or brain_sync lists all.`);
969
- lines.push('Coordinate BEFORE touching shared files: reply with `🧠 MSG [<their id-prefix or branch>]: <text>` (or call `brain_message`). KLYPIX queues it for supported lifecycle/MCP actions and replays it until the receiving model explicitly records consumption with `brain_message_receipt`. Check the brain for durable decisions/ships.');
973
+ lines.push('Coordinate BEFORE touching shared files: reply with `🧠 MSG [<their id-prefix or branch>]: <text>` (or call `brain_message`). KLYPIX queues it for supported lifecycle/MCP actions and replays it until the offer is acknowledged; the receiver then either records uptake with `brain_message_receipt` or a further independent action auto-consumes it without one. Check the brain for durable decisions/ships.');
970
974
  return lines.join('\n');
971
975
  }
972
976
 
@@ -1189,7 +1189,7 @@ export async function opBrainMessage({ vault, canvas, text: msgText, to, via, fr
1189
1189
  const message = result.message;
1190
1190
  const candidates = Array.isArray(message.candidateIds) ? message.candidateIds.length : 0;
1191
1191
  return {
1192
- blocks: [text(`📨 queued in this project's coordination lane (to: ${message.to}; id: ${message.id}) — ${candidates} live target session(s) were snapshotted. Delivery is pending until a supported lifecycle/MCP action offers it into model-visible context; it replays through acknowledgement until the receiver explicitly records consumption with brain_message_receipt. This is a coordination receipt, not proof a human read it and not a brain card — use brain_note for durable project decisions.`)],
1192
+ blocks: [text(`📨 queued in this project's coordination lane (to: ${message.to}; id: ${message.id}) — ${candidates} live target session(s) were snapshotted. Delivery is pending until a supported lifecycle/MCP action offers it into model-visible context, and it replays until the offer is acknowledged. AFTER acknowledgement it retires one of two ways: the receiver calls brain_message_receipt with the exact message id and offer token ("acted on it"), or a further independent action AUTO-CONSUMES it without any receipt. Your receipt line names which. Auto-consumption is not proof the note was acted on, and no path here is proof a human read it. Not a brain card — use brain_note for durable project decisions.`)],
1193
1193
  message,
1194
1194
  };
1195
1195
  }
@@ -2002,6 +2002,33 @@ export function rankForQuestion(struct, question, { semantic = null, k = 10, as_
2002
2002
  // the embedding side, not from reweighting here. The kept behaviors are
2003
2003
  // pinned numerically in test/retrieval-fusion.mjs — a change that trips
2004
2004
  // that suite must re-run the harness before shipping.
2005
+ //
2006
+ // THE EMBEDDING SIDE WAS THEN SWEPT TOO (2026-08-17, n=113 frozen v2,
2007
+ // real 2,553-card brain, production contract via embedTexts — prefix,
2008
+ // CLS, normalize; ordering = this function's semantic formula):
2009
+ // bge-small q8 (ships) MRR .436 · @5 61 · paraphrase 46 baseline
2010
+ // bge-small fp32 MRR .456 · @5 60 · paraphrase 46 — so the
2011
+ // q8 quantization is NOT the ceiling; the 384-dim model is.
2012
+ // bge-base q8 (768d) MRR .475 · @5 61 · paraphrase 51 [39,63] vs
2013
+ // [34,58] — every needle right, none CI-clearing, at 2.4× embed
2014
+ // cost (622s vs 255s full-brain) and ~3× download. NOT flipped.
2015
+ // exact-duplicate collapse at rank time: FLAT (61→61, para 46→46),
2016
+ // confirming the MMR-deferral result above by a second route.
2017
+ // 1,500-char truncation: measurably NOT a failure mode — gold cards
2018
+ // >1,500 chars score BETTER (@5 71% vs 57%), and 0 of 34 paraphrase
2019
+ // misses attribute to it. "We truncate, therefore we miss" is
2020
+ // falsified; do not chunk on that rationale.
2021
+ // (gte-small collapsed to MRR .17 under this contract — it wants mean
2022
+ // pooling and no prefix, so that number is contract mismatch, not a
2023
+ // fair test of gte.)
2024
+ // Failure signature stands: cosine COMPRESSION — on paraphrase misses
2025
+ // the gold sits a median 0.040 cosine below the 5th hit, 21/34 at rank
2026
+ // >50, 6/34 outside the 200 pool. Same-class drop-in models do not fix
2027
+ // that on this corpus. The remaining live path is DOC-SIDE enrichment
2028
+ // (capture-time question/intent text embedded alongside the card) or a
2029
+ // genuinely larger embedder — both are product decisions with shipping
2030
+ // costs, not tuning. Runner: scratchpad ab-runner.mjs pattern — embed
2031
+ // through embedTexts with a swapped pipe, never a hand-copied contract.
2005
2032
  const semRank = new Map(scored
2006
2033
  .filter(hit => Number.isFinite(hit.sem))
2007
2034
  .sort((a, b) => b.sem - a.sem)
@@ -40,7 +40,7 @@ import {
40
40
  // canonical copy lives in the pure module because that one is import-restricted
41
41
  // (crypto only), so it can never grow a dependency this file would inherit.
42
42
  import { normalizeFileKey } from './finding-routing.mjs';
43
- import { cmpSemver3, collectRepoState, releaseAncestry, releaseAncestryWarnings, repoStateWarnings } from './repo-state.mjs';
43
+ import { cmpSemver3, collectRepoState, commitFiles, releaseAncestry, releaseAncestryWarnings, repoStateWarnings } from './repo-state.mjs';
44
44
  import { recordResultManifests } from './result-reconcile.mjs';
45
45
 
46
46
  export const MCP_HEARTBEAT_MS = 60_000;
@@ -61,7 +61,7 @@ export const KLYPIX_MCP_INSTRUCTIONS = [
61
61
  'At the start of each task, call brain_sync with the current project root, a one-sentence intent, and any expected files before editing; the explicit project root keeps separate repositories on separate brains even when an MCP host launches servers from its own install directory.',
62
62
  'A session that never calls brain_sync appears to every peer as a connection with no declared scope — sync early so concurrent sessions can coordinate with you.',
63
63
  'Use its active-task, message, and file-overlap report to coordinate concurrent work.',
64
- 'A coordination note is not consumed merely because it was offered: after a later independent KLYPIX action acknowledges the offer, call brain_message_receipt with its exact message id and offer token only when you actually incorporated it into your work.',
64
+ 'A coordination note is not consumed merely because it was offered: after a later independent KLYPIX action acknowledges the offer, call brain_message_receipt with its exact message id and offer token only when you actually incorporated it into your work. If you do not, a further independent action auto-consumes the note without a receipt — which records activity, not uptake — so the sender is told it was auto-consumed rather than acted on. Sending an explicit receipt is what makes your uptake visible to them.',
65
65
  'Call brain_sync again when your file scope materially changes, and with phase "complete" before your final response.',
66
66
  'When a task publishes a quantified or otherwise machine-checkable claim, include its validated result manifest in the completion sync; conflicting or incomparable peer results retain the task scope until reconciled.',
67
67
  'A session preparing a RELEASE of the project should declare it by adding releaseIntent {version, ref} to brain_sync: the first declarer takes an exclusive lease every peer sees, and a second declarer gets a hard conflict naming the holder.',
@@ -250,11 +250,23 @@ export function validateReleaseIntent(value) {
250
250
  if (!Array.isArray(value.acknowledge)) {
251
251
  errors.push('releaseIntent.acknowledge must be an array of commit shas the release deliberately leaves behind');
252
252
  } else {
253
- acknowledge = value.acknowledge
253
+ acknowledge = [...new Set(value.acknowledge
254
254
  .filter((s) => typeof s === 'string')
255
255
  .map((s) => s.trim().toLowerCase())
256
- .filter((s) => /^[0-9a-f]{4,40}$/.test(s))
257
- .slice(0, 64);
256
+ .filter((s) => /^[0-9a-f]{4,40}$/.test(s)))];
257
+ // NEVER silently slice (2026-08-17 review blocker, verified by execution).
258
+ // The old `.slice(0, 64)` combined with a gate that demands EVERY dropped
259
+ // sha meant any release leaving >64 commits behind — the v1.3.120 field
260
+ // case was 71 — could never be acknowledged: the caller echoed the full
261
+ // acknowledgeRequired list, the validator quietly kept 64, the gate
262
+ // demanded them all, and the refusal named no cap. An agent obeying the
263
+ // instructions verbatim looped forever. The bound now exceeds the
264
+ // ancestry scan cap (500 per source, two sources), and overflowing it is
265
+ // a LOUD error naming the number — a divergence that large should be
266
+ // resolved, not acknowledged.
267
+ if (acknowledge.length > 1024) {
268
+ errors.push(`releaseIntent.acknowledge carries ${acknowledge.length} shas — the limit is 1024. A divergence this size should be resolved (merge or rebase the ref) rather than acknowledged away.`);
269
+ }
258
270
  }
259
271
  }
260
272
  if (errors.length) return { provided: true, ok: false, errors };
@@ -273,6 +285,153 @@ export function validateReleaseIntent(value) {
273
285
  * Prefix-tolerant in both directions so a caller may echo the short shas we
274
286
  * printed or the full ones from their own `git log`.
275
287
  */
288
+ /**
289
+ * JOIN the leave-behind set to the presence table.
290
+ *
291
+ * The engine modelled "which files are two sessions editing right now" and,
292
+ * separately, "what would this release drop", and never connected them. So a
293
+ * dropped commit reached the human as an anonymous sha even when its author was
294
+ * live in the same lane, had declared the exact files, and had already told the
295
+ * human the work would be in that build.
296
+ *
297
+ * Field case, desktop v1.3.120 (2026-08-16): cdcddb1 and 086bb17 touch
298
+ * Toolbar.tsx / StrokePanel.tsx / useCanvasInteraction.ts, all DECLARED by a
299
+ * live session; b493ead touches strokeScale.ts, OBSERVED on another. Both were
300
+ * dropped, both owners were online, and the refusal named neither. A sha with a
301
+ * name attached is a different conversation from a sha without one.
302
+ *
303
+ * Declared scope and observed scope both count: observed is how the engine
304
+ * adopts scope for work a session never got round to declaring, and the whole
305
+ * point here is to catch work whose owner never said the right thing.
306
+ *
307
+ * Bounded and fail-open-to-anonymous: if the git probe cannot run we return the
308
+ * ancestry untouched. Never invent an owner — a wrong name is worse than none.
309
+ */
310
+ export function annotateAncestryOwnership(ancestry, sessions, { projectRoot, selfId, execGit } = {}) {
311
+ if (!ancestry || !Array.isArray(ancestry.sources) || !ancestry.sources.length) return ancestry;
312
+ const peers = (Array.isArray(sessions) ? sessions : []).filter((s) => s && s.id !== selfId);
313
+ if (!peers.length || !projectRoot) return ancestry;
314
+
315
+ // Probe the FULL dropped set (bounded by commitFiles' own max), not the
316
+ // 8-per-source display list. Probing only the display meant a live peer's
317
+ // dropped commit at recency rank ≥9 stayed anonymous, peerOwnedCount
318
+ // undercounted the headline, and "owned commits sort to the top" was
319
+ // unimplementable — all four pre-release reviews converged on this
320
+ // (2026-08-17). Newest-first within each source so the bound spends itself
321
+ // on the commits someone is most likely waiting for.
322
+ const shas = ancestry.sources.flatMap((s) => (
323
+ Array.isArray(s.allShas) && s.allShas.length ? s.allShas : (s.missing || []).map((c) => c.sha)
324
+ )).filter(Boolean);
325
+ if (!shas.length) return ancestry;
326
+ let touched;
327
+ try {
328
+ touched = commitFiles(projectRoot, shas, execGit ? { execGit } : {});
329
+ } catch {
330
+ return ancestry; // probe failed — anonymous, never wrong
331
+ }
332
+ if (!touched || !touched.size) return ancestry;
333
+
334
+ // Declared ∪ observed, normalized against the project root so an absolute
335
+ // path from one session matches a repo-relative one from another.
336
+ const scopeOf = (peer) => new Set([
337
+ ...(Array.isArray(peer.files) ? peer.files : []),
338
+ ...(Array.isArray(peer.observedFiles) ? peer.observedFiles : []),
339
+ ].map((f) => normalizeFileKey(f, projectRoot)).filter(Boolean));
340
+ const peerScopes = peers.map((p) => ({ peer: p, scope: scopeOf(p) })).filter((e) => e.scope.size);
341
+ if (!peerScopes.length) return ancestry;
342
+
343
+ // SHARED FILES DO NOT ESTABLISH OWNERSHIP. Some paths are touched by everyone
344
+ // — a pending release-notes file, a strings catalog, a barrel index. If a
345
+ // commit's only overlap with a session is one of those, calling that session
346
+ // its owner is noise, and this codebase already records that alarm fatigue is
347
+ // itself a release-integrity defect. Ownership therefore requires at least one
348
+ // file that is NOT in most peers' scope; commits whose overlap is entirely
349
+ // shared are still reported, just not promoted or counted as peer-owned.
350
+ // The RELEASING session's own scope counts toward "shared", though it can
351
+ // never be an owner: with a single scoped peer the old threshold of 2 was
352
+ // unreachable, so a universal file (the pending release-notes doc, a strings
353
+ // catalog) that both the peer and the releaser had declared made the peer
354
+ // "owner" of every commit touching it (2026-08-17 review catch).
355
+ const self = (Array.isArray(sessions) ? sessions : []).find((s) => s && s.id === selfId);
356
+ const selfScope = self ? scopeOf(self) : new Set();
357
+ const claimCount = new Map();
358
+ for (const { scope } of peerScopes) {
359
+ for (const key of scope) claimCount.set(key, (claimCount.get(key) || 0) + 1);
360
+ }
361
+ for (const key of selfScope) claimCount.set(key, (claimCount.get(key) || 0) + 1);
362
+ const sharedThreshold = Math.max(2, Math.ceil((peerScopes.length + (selfScope.size ? 1 : 0)) / 2));
363
+ const isShared = (key) => (claimCount.get(key) || 0) >= sharedThreshold;
364
+
365
+ const lookup = (sha) => {
366
+ const key = String(sha || '').toLowerCase();
367
+ for (const [full, entry] of touched) if (full.startsWith(key) || key.startsWith(full)) return entry;
368
+ return null;
369
+ };
370
+
371
+ const ownersFor = (sha) => {
372
+ const entry = lookup(sha);
373
+ if (!entry || !entry.files || !entry.files.length) return { owners: null, entry };
374
+ const keys = entry.files.map((f) => normalizeFileKey(f, projectRoot)).filter(Boolean);
375
+ const owners = peerScopes
376
+ .map(({ peer, scope }) => {
377
+ const overlap = keys.filter((k) => scope.has(k));
378
+ if (!overlap.length) return null;
379
+ const distinctive = overlap.filter((k) => !isShared(k));
380
+ return {
381
+ sessionId: peer.id,
382
+ prefix: String(peer.id).slice(0, 8),
383
+ branch: peer.branch || null,
384
+ intent: peer.intent ? String(peer.intent).slice(0, 120) : null,
385
+ // Lead with the files that actually identify this owner.
386
+ files: [...distinctive, ...overlap.filter((k) => isShared(k))].slice(0, 4),
387
+ sharedScopeOnly: distinctive.length === 0,
388
+ };
389
+ })
390
+ .filter(Boolean);
391
+ if (!owners.length) return { owners: null, entry };
392
+ const strong = owners.filter((o) => !o.sharedScopeOnly);
393
+ return { owners: [...strong, ...owners.filter((o) => o.sharedScopeOnly)], strong: strong.length > 0, entry };
394
+ };
395
+
396
+ let peerOwnedCount = 0;
397
+ const sources = ancestry.sources.map((source) => {
398
+ const displayed = new Map((source.missing || []).map((c) => [String(c.sha || '').toLowerCase(), c]));
399
+ const annotated = (source.missing || []).map((commit) => {
400
+ const { owners, strong } = ownersFor(commit.sha);
401
+ if (!owners) return commit;
402
+ if (strong) peerOwnedCount++;
403
+ return { ...commit, owners };
404
+ });
405
+ // PROMOTE strong-owned commits that fell outside the display into it: the
406
+ // whole point of the join is that a commit with a live owner must never be
407
+ // invisible. Their subject comes from the same bounded git probe. Bounded to
408
+ // the display cap so a pathological lane cannot flood the refusal.
409
+ const promoted = [];
410
+ const candidateShas = Array.isArray(source.allShas) && source.allShas.length
411
+ ? source.allShas : [];
412
+ for (const full of candidateShas) {
413
+ if (promoted.length >= 8) break;
414
+ const isDisplayed = [...displayed.keys()].some((short) => full.startsWith(short) || short.startsWith(full));
415
+ if (isDisplayed) continue;
416
+ const { owners, strong, entry } = ownersFor(full);
417
+ if (!owners || !strong) continue;
418
+ peerOwnedCount++;
419
+ promoted.push({ sha: full.slice(0, 9), subject: (entry && entry.subject) || '', owners, promoted: true });
420
+ }
421
+ // Peer-owned commits sort to the TOP: they are the ones with a person
422
+ // attached, and the list is truncated for display, so they must not be the
423
+ // entries that fall off the bottom.
424
+ const strongOwned = (c) => Array.isArray(c.owners) && c.owners.some((o) => !o.sharedScopeOnly);
425
+ const ordered = [
426
+ ...annotated.filter(strongOwned),
427
+ ...promoted,
428
+ ...annotated.filter((c) => !strongOwned(c)),
429
+ ];
430
+ return { ...source, missing: ordered };
431
+ });
432
+ return { ...ancestry, sources, peerOwnedCount };
433
+ }
434
+
276
435
  export function ancestryAcknowledged(ancestry, acknowledge = []) {
277
436
  if (!ancestry) return true;
278
437
  if (ancestry.isDescendant || ancestry.status === 'ok') return true;
@@ -293,7 +452,21 @@ export function ancestryAcknowledged(ancestry, acknowledge = []) {
293
452
  // to ignore the gate.
294
453
  if (ancestry.status === 'unnameable') return false;
295
454
  if (ancestry.status === 'unknown') return ancestry.reason !== 'target-unresolved';
296
- const listed = (ancestry.sources || []).flatMap((s) => s.missing || []).map((c) => String(c.sha || '').toLowerCase());
455
+ // THE COMPLETE SET, never the display list. `missing` is the 8-per-source the
456
+ // prose names; `allShas` is everything the release drops. Deriving the
457
+ // requirement from `missing` is what let desktop v1.3.120 clear a gate over 71
458
+ // dropped commits by naming 10 — and the 61 it never had to name included the
459
+ // two features live sessions had promised the founder would be in that build.
460
+ // The contract is that the lease cannot be taken without REPRODUCING the shas;
461
+ // reproducing a truncated sample is not that contract.
462
+ // `allShas` is absent on ancestry objects built by an older engine, so fall
463
+ // back to the display list rather than vacuously passing.
464
+ const required = (ancestry.sources || []).flatMap((s) => (
465
+ Array.isArray(s.allShas) && s.allShas.length
466
+ ? s.allShas
467
+ : (s.missing || []).map((c) => c.sha)
468
+ )).map((s) => String(s || '').toLowerCase()).filter(Boolean);
469
+ const listed = [...new Set(required)];
297
470
  if (!listed.length) return false;
298
471
  const given = (acknowledge || []).map((s) => String(s || '').toLowerCase()).filter(Boolean);
299
472
  return listed.every((sha) => given.some((g) => g.startsWith(sha) || sha.startsWith(g)));
@@ -2532,6 +2705,13 @@ export function createMcpPresence({
2532
2705
  .filter(Boolean)),
2533
2706
  ];
2534
2707
  gateAncestry = releaseAncestry(path.dirname(brainPath), releaseIntentChecked.ref, { peerBranches });
2708
+ // Attach the live owner of every dropped commit BEFORE the gate
2709
+ // decides, so a refusal can say whose work this is rather than
2710
+ // handing the human a list of anonymous shas.
2711
+ gateAncestry = annotateAncestryOwnership(gateAncestry, report.sessions, {
2712
+ projectRoot: path.dirname(brainPath),
2713
+ selfId: sessionId,
2714
+ });
2535
2715
  } catch {
2536
2716
  // A probe that throws is not an all-clear either.
2537
2717
  gateAncestry = { status: 'unknown', reason: 'git-unavailable', ref: releaseIntentChecked.ref, isDescendant: false, missingCount: 0, sources: [], missing: [] };
@@ -2588,7 +2768,27 @@ export function createMcpPresence({
2588
2768
  } : null;
2589
2769
  if (outcome?.status === 'ancestry-unacknowledged') {
2590
2770
  const anc = outcome.ancestry;
2591
- const shas = (anc.sources || []).flatMap((s) => s.missing || []).map((c) => c.sha);
2771
+ // The COMPLETE set the gate will demand, not the subset the prose names.
2772
+ // These two used to be the same list, which is how a release dropping 71
2773
+ // commits was acknowledged by naming 10. The prose still shows 8 per
2774
+ // source — a wall of 71 shas teaches people to skim — but the requirement
2775
+ // is now honest about its own size, and the text below says both numbers.
2776
+ const shas = [...new Set((anc.sources || []).flatMap((s) => (
2777
+ Array.isArray(s.allShas) && s.allShas.length
2778
+ ? s.allShas
2779
+ : (s.missing || []).map((c) => c.sha)
2780
+ )).map((s) => String(s || '')).filter(Boolean))];
2781
+ // PREFIX-tolerant, both directions (2026-08-17 review catch, verified by
2782
+ // execution): the prose shows abbreviated `%h` shas while allShas are
2783
+ // full 40-char, so an exact Set.has never matched and the honesty NOTE
2784
+ // claimed "only 0 spelled out above" on every refusal — the one sentence
2785
+ // added for honesty contradicted the sha list directly above it.
2786
+ const namedInProse = [...new Set((anc.sources || [])
2787
+ .flatMap((s) => s.missing || []).map((c) => String(c.sha || '').toLowerCase()).filter(Boolean))];
2788
+ const unnamed = shas.filter((s) => {
2789
+ const full = String(s).toLowerCase();
2790
+ return !namedInProse.some((p) => full.startsWith(p) || p.startsWith(full));
2791
+ }).length;
2592
2792
  releaseLease = {
2593
2793
  status: 'refused',
2594
2794
  kind: 'release-would-leave-work-behind',
@@ -2601,8 +2801,13 @@ export function createMcpPresence({
2601
2801
  sources: anc.sources,
2602
2802
  },
2603
2803
  // Exactly what the retry must echo. Named so a caller never has to
2604
- // guess the shape of the second call.
2605
- acknowledgeRequired: shas,
2804
+ // guess the shape of the second call. NEVER populated on the
2805
+ // 'unnameable' path: the gate refuses that status unconditionally, so
2806
+ // listing shas there instructs an acknowledge that can never succeed
2807
+ // (2026-08-17 review catch — the primary fix renders bare shas at the
2808
+ // source so 'unnameable' now truly means no shas at all; this guard is
2809
+ // the belt to that suspender).
2810
+ acknowledgeRequired: anc.status === 'unnameable' ? [] : shas,
2606
2811
  };
2607
2812
  releaseText = [
2608
2813
  'KLYPIX release lease REFUSED — the lease was not taken. No release state changed.',
@@ -2613,8 +2818,14 @@ export function createMcpPresence({
2613
2818
  // retry made the bypass the easiest thing on screen — an agent could
2614
2819
  // copy it and never say a word to anyone. The shas are listed above;
2615
2820
  // reproducing them is the work, and the work is the point.
2616
- shas.length
2617
- ? `To proceed anyway, re-send releaseIntent with an "acknowledge" array naming each of the ${shas.length} sha(s) listed above. Only do that after the user has been told and has decided.`
2821
+ (anc.status !== 'unnameable' && shas.length)
2822
+ ? [
2823
+ `To proceed anyway, re-send releaseIntent with an "acknowledge" array naming each of the ${shas.length} sha(s) in acknowledgeRequired.`,
2824
+ unnamed
2825
+ ? `NOTE: only ${shas.length - unnamed} of those ${shas.length} are spelled out above — ${unnamed} more are in acknowledgeRequired and are NOT shown in this text. Read them before you decide; the prose is a sample, the requirement is the whole set.`
2826
+ : '',
2827
+ 'Only do that after the user has been told and has decided.',
2828
+ ].filter(Boolean).join(' ')
2618
2829
  : 'This release cannot be acknowledged automatically — the missing work could not be listed. Resolve it with the user before continuing.',
2619
2830
  ].join('\n');
2620
2831
  } else if (outcome?.status === 'conflict') {
@@ -791,10 +791,16 @@ function checkoutStaleness(root) {
791
791
  const target = (defaultRef || '').trim() || ['origin/main', 'origin/master']
792
792
  .find(ref => runGit(root, ['rev-parse', '--verify', '--quiet', ref]) != null);
793
793
  if (!target) return null;
794
- const behindRaw = runGit(root, ['rev-list', '--count', `HEAD..${target}`]);
795
- const behind = Number((behindRaw || '').trim());
796
- if (!Number.isFinite(behind)) return null;
797
- return { branch: (head || '').trim() || null, comparedTo: target, behind };
794
+ // Two-sided, deliberately: a checkout that is behind AND ahead has DIVERGED,
795
+ // and telling its agent to "pull" is advice the measurement itself contradicts.
796
+ // Same idiom as repo-state.mjs — one call answers both questions.
797
+ const counts = runGit(root, ['rev-list', '--left-right', '--count', `HEAD...${target}`]);
798
+ const match = (counts || '').trim().match(/^(\d+)\s+(\d+)$/);
799
+ if (!match) return null;
800
+ const ahead = Number(match[1]);
801
+ const behind = Number(match[2]);
802
+ if (!Number.isFinite(ahead) || !Number.isFinite(behind)) return null;
803
+ return { branch: (head || '').trim() || null, comparedTo: target, behind, ahead, diverged: ahead > 0 && behind > 0 };
798
804
  }
799
805
 
800
806
  /**
@@ -882,8 +888,21 @@ export function brainDriftMarkdown(result) {
882
888
  }
883
889
  const lines = ['# Brain drift — cards vs the repo\'s real files'];
884
890
  const stale = result.staleCheckout;
891
+ if (stale && stale.behind === 0 && stale.ahead > 0) {
892
+ // The FALSE NEGATIVE is the expensive half (desktop 1.3.107 shipped
893
+ // off-trunk in exactly this silence): a checkout carrying unpushed commits
894
+ // deserves a line even when it is not behind — files reported PRESENT below
895
+ // may exist only here.
896
+ lines.push(`⚠️ **This checkout is ${stale.ahead} commit(s) ahead of \`${stale.comparedTo}\`** (branch \`${stale.branch || '?'}\`). Files reported present below may exist only in THIS checkout, not on the default branch.`);
897
+ }
885
898
  if (stale && stale.behind > 0) {
886
- lines.push(`⚠️ **This checkout is ${stale.behind} commit(s) behind \`${stale.comparedTo}\`** (branch \`${stale.branch || '?'}\`, as of the last fetch). Files reported missing below may simply not be in THIS checkout — pull before trusting the card verdicts.`);
899
+ // State the measurement, never the operation: on a diverged checkout "pull"
900
+ // is contradicted by the very numbers printed beside it, and this tool has
901
+ // no way to know whether the local commits are meant to be kept.
902
+ const where = `(branch \`${stale.branch || '?'}\`, as of the last fetch)`;
903
+ lines.push(stale.diverged
904
+ ? `⚠️ **This checkout has DIVERGED from \`${stale.comparedTo}\`: ${stale.ahead} commit(s) ahead, ${stale.behind} behind** ${where}. Files reported missing below may simply not be in THIS checkout, and files reported present may exist only here. The verdicts are unreliable until the two lines are reconciled — which is a decision about the ${stale.ahead} local commit(s), not a fast-forward.`
905
+ : `⚠️ **This checkout is ${stale.behind} commit(s) behind \`${stale.comparedTo}\`** ${where}. Files reported missing below may simply not be in THIS checkout; the verdicts are unreliable until this checkout matches \`${stale.comparedTo}\`.`);
887
906
  }
888
907
  const s = result.summary;
889
908
  lines.push(`Checked ${s.cards} card(s); ${s.cardsWithCheckableRefs} referenced this repo's files (${s.refsChecked} reference(s), ${s.refsOk} still valid). **${s.driftedCards} card(s) reference files that are gone or moved.**`);
@@ -177,6 +177,50 @@ const UNIT_SEP = '';
177
177
  * @returns {null|{trunk, ref, isDescendant, missingCount, missing:Array<{sha,subject}>}}
178
178
  * null when the question cannot be answered (no git, no trunk, unknown ref).
179
179
  */
180
+ /**
181
+ * Which files each commit touched — one bounded batch call.
182
+ *
183
+ * This exists to JOIN the leave-behind set to the presence table. The engine
184
+ * has always modelled "which files are two sessions editing right now" and,
185
+ * separately, "what would this release drop", and never connected them. So a
186
+ * dropped commit arrived as an anonymous sha even when the session that wrote
187
+ * it was live in the same lane, had declared the exact files, and had told the
188
+ * human its work would be in the build. Field case: desktop v1.3.120.
189
+ *
190
+ * Merge commits legitimately yield no names under `--name-only`; feature work
191
+ * is what this join is for, so that is acceptable rather than worked around.
192
+ */
193
+ export function commitFiles(projectDir, shas, { execGit = defaultExecGit, timeoutMs = 4000, max = 40 } = {}) {
194
+ const git = makeGit(execGit);
195
+ const dir = String(projectDir || '');
196
+ const list = [...new Set((shas || []).map((s) => String(s || '').trim()).filter(Boolean))].slice(0, max);
197
+ const out = new Map();
198
+ if (!dir || !list.length) return out;
199
+ // An explicit  sentinel before each sha, so blocks parse unambiguously.
200
+ // File paths may contain almost anything; a control char cannot appear in one.
201
+ const SENTINEL = '';
202
+ // `core.quotepath=false` because git otherwise C-quotes non-ASCII paths
203
+ // ("\330\247..."), which normalizeFileKey can never match against a declared
204
+ // Arabic filename — attribution went silently blind for AR users (review
205
+ // catch, verified live). The subject rides the header line behind a unit
206
+ // separator so a commit promoted into the display from beyond the prose
207
+ // list still has a human-readable name.
208
+ const raw = git(dir, ['-c', 'core.quotepath=false', 'show', '--name-only', `--format=${SENTINEL}%H${UNIT_SEP}%s`, ...list], timeoutMs);
209
+ if (raw === null) return out; // probe failed: no owners, never a wrong owner
210
+ for (const block of String(raw).split(SENTINEL)) {
211
+ const lines = block.split('\n').map((l) => l.trim()).filter(Boolean);
212
+ if (!lines.length) continue;
213
+ const sep = lines[0].indexOf(UNIT_SEP);
214
+ const sha = sep < 0 ? lines[0] : lines[0].slice(0, sep);
215
+ if (!/^[0-9a-f]{7,40}$/i.test(sha)) continue;
216
+ out.set(sha.toLowerCase(), {
217
+ files: lines.slice(1),
218
+ subject: sep < 0 ? '' : lines[0].slice(sep + 1).slice(0, 120),
219
+ });
220
+ }
221
+ return out;
222
+ }
223
+
180
224
  export function releaseAncestry(projectDir, ref, { execGit = defaultExecGit, peerBranches = [] } = {}) {
181
225
  const git = makeGit(execGit);
182
226
  const dir = String(projectDir || '');
@@ -290,16 +334,41 @@ export function releaseAncestry(projectDir, ref, { execGit = defaultExecGit, pee
290
334
  const cherry = git(dir, ['cherry', target, cand.ref], CHERRY_TIMEOUT_MS);
291
335
  let count;
292
336
  let shown;
337
+ // Every sha this source drops, not just the ones the prose will name. The
338
+ // acknowledgement handshake reads this; the prose reads `shown`.
339
+ let allMissingShas = [];
293
340
  let approximate = false;
294
341
  if (cherry !== null) {
295
- const missingShas = cherry.split('\n')
342
+ const allCherry = cherry.split('\n')
296
343
  .filter((l) => l.startsWith('+ '))
297
344
  .map((l) => l.slice(2).trim())
298
- .filter(Boolean)
299
- .slice(0, MAX_CHERRY_SCAN);
300
- count = missingShas.length;
345
+ .filter(Boolean);
346
+ // TAIL, not head. `git cherry` emits OLDEST first, so a head slice on a
347
+ // >500-commit divergence dropped the NEWEST commits from both the display
348
+ // AND the acknowledgement — the exact failure class the newest-first fix
349
+ // exists to prevent, reintroduced one level up (2026-08-17 review catch).
350
+ // The tail keeps the newest; `count` stays the TRUE total so missingCount
351
+ // never understates; `scanCapped` lets the warning text admit the cap.
352
+ const missingShas = allCherry.slice(-MAX_CHERRY_SCAN);
353
+ count = allCherry.length;
301
354
  if (count <= 0) continue; // every change is already present
302
- shown = missingShas.slice(0, MAX_MISSING_LISTED);
355
+ // NEWEST FIRST, and this is the whole point of the list. `git cherry` emits
356
+ // OLDEST first, so slicing it raw showed the eight most ANCIENT missing
357
+ // commits while hiding everything recent — and the fallback branch below,
358
+ // which uses `git log`, showed the eight NEWEST. The two paths disagreed,
359
+ // and the PRECISE one was the worse of the two.
360
+ //
361
+ // Field case 2026-08-16, desktop v1.3.120: the gate refused correctly and
362
+ // named 69 commits missing from master, but the eight it SHOWED were weeks
363
+ // old. The three the human was actually waiting for — an Arrow tool and a
364
+ // zoom-relative stroke-width pair committed 28 minutes earlier, which a peer
365
+ // session had already promised would "ride the next build" — sat at the end
366
+ // of the list and were never displayed. The human approved a narrow build on
367
+ // an accurate refusal whose visible evidence omitted the deciding facts.
368
+ // A truncated list is a RANKING problem: show what someone is most likely to
369
+ // be waiting for, which is always the most recent work.
370
+ allMissingShas = missingShas.reverse(); // newest first
371
+ shown = allMissingShas.slice(0, MAX_MISSING_LISTED);
303
372
  } else {
304
373
  // FALL BACK, never skip. rev-list is sha-based so it over-reports
305
374
  // rebases and squashes, but over-reporting is a conversation and
@@ -309,18 +378,31 @@ export function releaseAncestry(projectDir, ref, { execGit = defaultExecGit, pee
309
378
  count = Number(countRaw);
310
379
  if (!Number.isFinite(count) || count <= 0) continue;
311
380
  approximate = true;
312
- const fallbackLog = git(dir, ['log', `--max-count=${MAX_MISSING_LISTED}`, '--format=%H', `${target}..${cand.ref}`]);
313
- shown = (fallbackLog || '').split('\n').map((s) => s.trim()).filter(Boolean);
381
+ // Fetch the full bounded set, not just what will be shown: the gate needs
382
+ // every sha even on the approximate path, and `git log` is already
383
+ // newest-first so both branches now agree on ordering AND on completeness.
384
+ const fallbackLog = git(dir, ['log', `--max-count=${MAX_CHERRY_SCAN}`, '--format=%H', `${target}..${cand.ref}`]);
385
+ allMissingShas = (fallbackLog || '').split('\n').map((s) => s.trim()).filter(Boolean);
386
+ shown = allMissingShas.slice(0, MAX_MISSING_LISTED);
314
387
  }
315
388
  if (!shown.length) continue;
316
389
  // One bounded call for the subjects of just what will be shown.
317
390
  const log = git(dir, ['log', '--no-walk', `--format=%h%x1f%s`, ...shown]);
318
- const parsed = (log || '').split('\n').filter(Boolean).map((line) => {
391
+ let parsed = (log || '').split('\n').filter(Boolean).map((line) => {
319
392
  const sep = line.indexOf(UNIT_SEP);
320
393
  return sep < 0
321
394
  ? { sha: line.trim(), subject: '' }
322
395
  : { sha: line.slice(0, sep).trim(), subject: line.slice(sep + 1).slice(0, 120) };
323
396
  });
397
+ // A failed SUBJECT probe must not erase the shas (2026-08-17 review catch).
398
+ // When this log call times out while cherry succeeded, `parsed` was empty,
399
+ // `missing` was empty, and the whole ancestry degraded to 'unnameable' —
400
+ // whose refusal then listed acknowledgeRequired from allShas while the gate
401
+ // unconditionally refused 'unnameable': an instruction that can never
402
+ // succeed. Shas without subjects are still NAMEABLE; render them bare.
403
+ if (!parsed.length && shown.length) {
404
+ parsed = shown.map((sha) => ({ sha: String(sha).slice(0, 9), subject: '' }));
405
+ }
324
406
  // Trunk and a peer sitting on trunk are the DEFAULT configuration, so the
325
407
  // same commits would otherwise be counted twice and demanded twice. Each
326
408
  // sha is attributed to the first source that reports it.
@@ -331,7 +413,35 @@ export function releaseAncestry(projectDir, ref, { execGit = defaultExecGit, pee
331
413
  // source, so the list may include work that is already present under a
332
414
  // different sha. Carried on the source so the text can say so rather than
333
415
  // asserting a precision it does not have.
334
- sources.push({ ...cand, missingCount: count, missing, ...(approximate ? { approximate: true } : {}) });
416
+ // DISPLAY IS BOUNDED; THE GATE MUST NOT BE. `missing` is the 8 commits the
417
+ // prose will name. `allShas` is every sha this source drops, and it is what
418
+ // the acknowledgement handshake has to demand.
419
+ //
420
+ // Field case 2026-08-16, desktop v1.3.120: ancestryAcknowledged() derived its
421
+ // requirement from `missing` — the DISPLAY list — so a release dropping 71
422
+ // commits was cleared by naming 10. The design's whole contract is that a
423
+ // lease "cannot be taken without REPRODUCING the shas it is choosing to
424
+ // leave behind", on the reasoning that a model forced to reproduce them has
425
+ // in practice had to relay them. Deriving the requirement from a truncated
426
+ // list quietly reduced that contract to whatever happened to fit on screen.
427
+ // A truncated requirement is a truncated guarantee.
428
+ const allShas = (allMissingShas.length ? allMissingShas : parsed.map((c) => c.sha))
429
+ .map((s) => String(s || '').toLowerCase())
430
+ .filter(Boolean);
431
+ sources.push({
432
+ ...cand,
433
+ missingCount: count,
434
+ missing,
435
+ allShas,
436
+ // True when the prose cannot name everything this source drops, so the
437
+ // caller can say "8 of 71 shown" instead of implying the list is complete.
438
+ listTruncated: allShas.length > missing.length,
439
+ // True when even allShas is a cap (divergence beyond MAX_CHERRY_SCAN):
440
+ // the acknowledgement then covers the newest 500 but NOT everything, and
441
+ // the warning text must say so rather than let a capped ack read as full.
442
+ ...(count > allShas.length ? { scanCapped: true } : {}),
443
+ ...(approximate ? { approximate: true } : {}),
444
+ });
335
445
  }
336
446
 
337
447
  if (!sources.length) {
@@ -387,12 +497,31 @@ export function releaseAncestryWarnings(ancestry) {
387
497
  const lines = [
388
498
  `RELEASE WOULD LEAVE WORK BEHIND — ${missingCount} finished commit(s) already exist that this release does not include.`,
389
499
  ];
500
+ // The join to the presence table, stated first because it is the fact most
501
+ // likely to change the decision: some of this work belongs to somebody who is
502
+ // online now and may have already promised it would ship.
503
+ if (ancestry.peerOwnedCount) {
504
+ lines.push(` ${ancestry.peerOwnedCount} of them touch files a LIVE session has declared or is editing — that session may have already told the user this work would be in the build. Name them to the user, and consider asking those sessions before you acknowledge.`);
505
+ }
390
506
  for (const s of sources) {
391
507
  lines.push(s.kind === 'trunk'
392
508
  ? ` ${s.missingCount} commit(s) on ${s.ref} (the main line) are not in ${ref}:`
393
509
  : ` ${s.missingCount} commit(s) on ${s.ref} — a branch someone is working on right now — are not in ${ref}:`);
394
- for (const c of s.missing) lines.push(` · ${c.sha} ${c.subject}`);
510
+ for (const c of s.missing) {
511
+ // A sha with a live owner attached is a different conversation from an
512
+ // anonymous one: it names a person who is online RIGHT NOW and who very
513
+ // likely believes this work is in the build. Say so on the same line.
514
+ const owners = (Array.isArray(c.owners) ? c.owners : []).filter((o) => !o.sharedScopeOnly);
515
+ const who = owners.length
516
+ ? ` ← OWNED BY LIVE SESSION ${owners.map((o) => o.prefix).join(', ')} (${owners[0].files.slice(0, 2).join(', ')})`
517
+ : '';
518
+ lines.push(` · ${c.sha} ${c.subject}${who}`);
519
+ }
395
520
  if (s.missingCount > s.missing.length) lines.push(` · …and ${s.missingCount - s.missing.length} more not listed`);
521
+ if (s.scanCapped) {
522
+ lines.push(` (divergence exceeds the ${Array.isArray(s.allShas) ? s.allShas.length : 500}-commit scan cap — the acknowledgement below covers only the newest ${Array.isArray(s.allShas) ? s.allShas.length : 500};`);
523
+ lines.push(' a divergence this size should be RESOLVED, not acknowledged)');
524
+ }
396
525
  if (s.approximate) {
397
526
  lines.push(' (this list is approximate — the equivalence check could not run, so some of');
398
527
  lines.push(' these may already be in the release under a different commit id)');