klypix-mcp 1.97.0 → 1.98.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
@@ -528,12 +528,22 @@ Future hosts get baseline support merely by connecting the MCP server. A deeper
528
528
 
529
529
  ## Overlap warnings
530
530
 
531
- When two sessions declare overlapping expected files, `brain_sync` surfaces it: the peer, its
532
- declared task, and the exact paths in common. A one-time alert is queued to whichever session got
533
- there first, so a late arrival is not the only one who knows.
534
-
535
- This warns. It does not prevent. Nothing blocks an edit, matching is exact-path, and both sides
536
- have to have declared their files for the overlap to be visible at all.
531
+ `brain_sync` reports three kinds of overlap with other live sessions on the same brain:
532
+
533
+ - **Same file.** Both sessions declared, or were observed editing, the same path. You see the peer,
534
+ its task and the paths in common, and a one-time alert is queued to whichever session got there
535
+ first, so a late arrival is not the only one who knows.
536
+ - **Would conflict at merge.** The two sessions are on different branches, and a test merge of the
537
+ two branch tips (`git merge-tree`, git 2.38 or newer) conflicts in named files, even though no
538
+ declared file overlaps. This sees committed work only: an uncommitted edit is invisible to git.
539
+ It also gets a one-time alert. Files the brain tooling regenerates on every branch (AGENTS.md,
540
+ rule files, `.mcp.json`, `brain.klypix`) are not counted.
541
+ - **Same folder (advisory).** Both sessions work in one folder with no file in common yet. No alert
542
+ is sent. A folder that at least three sessions, and more than half of them, touch is treated as
543
+ noise and not reported.
544
+
545
+ This warns. It does not prevent. Nothing blocks an edit. A session that never declared its files
546
+ and was never observed editing is invisible to the file and folder checks.
537
547
 
538
548
  ## Handoffs and messages
539
549
 
@@ -757,6 +767,15 @@ lost 0 of 46. If the control ever loses nothing, the run reports **inconclusive*
757
767
 
758
768
  Latest results, with hardware and date: [BENCHMARKS.md](BENCHMARKS.md).
759
769
 
770
+ **Does it change what agents do?** That needs real agents, so it lives in the repository rather than
771
+ the package: [`bench/coordination/`](bench/coordination/README.md) runs Claude Code or Codex on four
772
+ small repos where another session knows something the agent needs (a decision changed today, work
773
+ overlapping on another branch, a note from a session that has ended, a root cause another vendor's
774
+ session found). Each run is graded by a test written after the agent finishes, against no shared
775
+ channel, a human copy-paste, `AGENTS.md` kept tidy or appended, and KLYPIX over MCP only. Every number
776
+ is printed with its sample size and interval; tools it has not measured are listed as not measured.
777
+ `--dry-run` costs nothing and proves the graders.
778
+
760
779
  ## Git and concurrency
761
780
 
762
781
  One file in your repo, committed with your code — versioned, branchable, portable. So two
@@ -1105,9 +1124,10 @@ Read this section before you build on any of it.
1105
1124
  loop prevention, stable message IDs and per-recipient-machine acknowledgement primitives), but no
1106
1125
  transport: carrying frames between machines is the desktop app's job. With `klypix-mcp` alone,
1107
1126
  coordination is machine-local.
1108
- - **Overlap matching is exact-path, and both sides must declare.** A session that never declares
1109
- its expected files is invisible to overlap detection, and `src/auth/token.ts` does not match a
1110
- rename or a parent directory.
1127
+ - **File overlap is exact-path.** A session needs declared files or observed edits to be checked;
1128
+ `src/auth/token.ts` does not match a rename. Folder overlap is advisory. The merge test sees
1129
+ committed work only, needs git 2.38 or newer, and runs only for sessions on different local
1130
+ branches of the same repository.
1111
1131
  - **Overlap warnings are advisory.** Nothing is blocked. One severity string in the payload reads
1112
1132
  `blocking`; the mechanism is not.
1113
1133
  - **Codex has no automatic capture**, with or without `--codex-hooks`. The Codex hook never writes
@@ -1066,7 +1066,7 @@ server.registerTool('brain_reopen', {
1066
1066
 
1067
1067
  server.registerTool('brain_sync', {
1068
1068
  title: 'KLYPIX Context Gateway — synchronize task, peers, conflicts, and relevant memory',
1069
- description: 'APPROVAL-FREE task gateway over the authorized MCP connection. Call FIRST with a concise intent and expected files, again when scope changes, and with phase:"complete" before the final response. One bounded response returns compact task-relevant brain context, active TASK peers (idle connections hidden), one-time messages, structured exact-file conflicts, and late-arrival overlap alerts. A completion that supplies machine-checkable result manifests is fail-closed: invalid, conflicting, or incomparable evidence returns needs-reconciliation and retains task scope. Works on any MCP host — it needs only the authorized MCP connection, so native lifecycle hooks are optional. LIMITS: conflict matching is EXACT-PATH and both sessions must have declared their files, coordination/result reconciliation is machine-local and OS-user-local (a teammate on another machine is invisible), and file overlap remains ADVISORY.',
1069
+ description: 'APPROVAL-FREE task gateway over the authorized MCP connection. Call FIRST with a concise intent and expected files, again when scope changes, and with phase:"complete" before the final response. One bounded response returns compact task-relevant brain context, active TASK peers (idle connections hidden), one-time messages, structured exact-file conflicts, and late-arrival overlap alerts. A completion that supplies machine-checkable result manifests is fail-closed: invalid, conflicting, or incomparable evidence returns needs-reconciliation and retains task scope. `results` is ONLY for such manifests, never a summary of the work: with nothing machine-checkable to claim, complete without it. Works on any MCP host — it needs only the authorized MCP connection, so native lifecycle hooks are optional. LIMITS: blocking overlap is EXACT-PATH over the declared files of each session plus the edits its host observed; sessions on different branches are also test-merged with git merge-tree (committed work only); a same-folder overlap is an advisory notice; coordination/result reconciliation is machine-local and OS-user-local (a teammate on another machine is invisible), and file overlap remains ADVISORY.',
1070
1070
  annotations: {
1071
1071
  destructiveHint: false,
1072
1072
  idempotentHint: true,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.97.0",
3
+ "version": "1.98.0",
4
4
  "mcpName": "io.github.dahshanlabs/klypix-mcp",
5
5
  "description": "Active state management for multi-agent coding: a shared, versioned project brain over MCP.",
6
6
  "type": "module",
@@ -86,7 +86,7 @@
86
86
  "bench": "node bin/klypix-mcp.mjs bench",
87
87
  "test:bench": "node test/bench.mjs",
88
88
  "pretest": "node test/publish-workflow.mjs",
89
- "test": "node test/publish-verdict.mjs && node test/npx-owned-names.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/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.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/capture-gap.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/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.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/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.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/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.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-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.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/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
89
+ "test": "node test/publish-verdict.mjs && node test/npx-owned-names.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/session-mailbox.mjs && node test/session-reopen.mjs && node test/lane-write-retry.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/capture-gap.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/guard-cards.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/brain-ask-words.mjs && node test/retrieval-fusion.mjs && node test/eval-retrieval.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/partial-notes.mjs && node test/lifecycle-prefix.mjs && node test/close-link-safety.mjs && node test/resolve-ledger.mjs && node test/plan-fulfillment.mjs && node test/arrange-receipts.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/semantic-preparation.mjs && node test/semantic-hash-parity.mjs && node test/enrichment.mjs && node test/provenance.mjs && node test/confirm-trail.mjs && node test/hook-fallback.mjs && node test/eval-hook-lane.mjs && node test/hook-unified-lane.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/marker-suffix-grammar.mjs && node test/evidence-anchors.mjs && node test/brain-evidence.mjs && node test/presence-visibility.mjs && node test/undeclared-active.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/overlap-tiers.mjs && node test/coordination-bench.mjs && node test/release-lease.mjs && node test/release-reconcile.mjs && node test/release-ancestry.mjs && node test/release-claim-join.mjs && node test/release-claims.mjs && node test/release-handshake.mjs && node test/completion-guard.mjs && node test/results-not-manifests.mjs && node test/merge-brains.mjs && node test/merge-readings.mjs && node test/revival-map.mjs && node test/merge-scale.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/canvas-groups.mjs && node test/git-tools.mjs && node test/link-compat.mjs && node test/uninstall.mjs && node test/current-guidance.mjs && node test/current-first.mjs && node test/status-shape.mjs && node test/status-hook.mjs && node test/read-canvas-saved-readings.mjs && node test/document-readings.mjs && node test/open-lease.mjs && node test/agent-card-style.mjs && node test/tool-schema-freeze.mjs && node test/app-lease-vectors.mjs && node test/app-tools-stdio.mjs && node test/card-contents-files.mjs && node test/codex-app-table.mjs && node test/plugin-mode.mjs && node test/app-bridge-vectors.mjs && node test/app-bridge-client.mjs && node test/app-tools-static.mjs && node test/app-tools-stdio-live.mjs",
90
90
  "test:memory": "node test/memory-runtime.mjs",
91
91
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
92
92
  "runtime": "node bin/klypix-runtime.mjs",
@@ -1462,6 +1462,22 @@ function peerFooter(sid) {
1462
1462
  const lines = ['', `## ⚠️ Other live session(s) in this project — ${peers.length} total, as of ${asOf}${hiddenIdle ? ` (+${hiddenIdle} inactive not shown)` : ''} — coordinate (the brain is shared, NOT live-merged)`];
1463
1463
  const releaseLine = releaseLeaseFooterLine(list, now);
1464
1464
  if (releaseLine) lines.push(releaseLine);
1465
+ // Same branch, said ONCE (1.98.0). Two sessions can only share a branch
1466
+ // name by sharing a checkout (git refuses one branch in two worktrees), so
1467
+ // the risk is concrete: a broad `git add` sweeps the other session's edits
1468
+ // into your commit. Per-peer, this line repeated on every row of a busy
1469
+ // lane and stopped being read.
1470
+ const sameBranchCount = myBranch ? peers.filter(p => p.branch === myBranch).length : 0;
1471
+ if (sameBranchCount) lines.push(`- ⚠️ ${sameBranchCount} of these session(s) share your branch \`${myBranch}\` (one checkout): stage only your own files — never \`git add -u\` / \`-A\` — and coordinate before you commit.`);
1472
+ // Same folder, no shared file yet (twin of mcp-presence findNearbyWork, no
1473
+ // git): with 3+ scoped sessions, a folder most of them touch is noise.
1474
+ const dirOf = (k) => { const at = k.lastIndexOf('/'); return at > 0 ? k.slice(0, at) : ''; };
1475
+ const dirsOfScope = (s) => new Set(scopeOf(s).map(fileKey).map(dirOf).filter(Boolean));
1476
+ const myDirs = dirsOfScope(me);
1477
+ const scopedRows = [me, ...peers].filter(s => s && scopeOf(s).length);
1478
+ const dirCount = new Map();
1479
+ for (const s of scopedRows) for (const d of dirsOfScope(s)) dirCount.set(d, (dirCount.get(d) || 0) + 1);
1480
+ const dirIsNoise = (d) => { const n = dirCount.get(d) || 0; return n >= 3 && n > scopedRows.length / 2; };
1465
1481
  for (const p of peers.slice(0, 4)) {
1466
1482
  const bits = [];
1467
1483
  if (p.branch) bits.push(`branch \`${p.branch}\``);
@@ -1496,10 +1512,12 @@ function peerFooter(sid) {
1496
1512
  warn = ` · ⚠️ both edited: ${marked.join(', ')} — expect a conflict, KEEP BOTH${legend}`;
1497
1513
  }
1498
1514
  // State the fact, never the git operation. This runs in a latency-sensitive
1499
- // hook and must not spawn git, so it cannot know whether the branch is merely
1500
- // behind or has DIVERGED — and on a diverged branch "pull/rebase" is advice
1501
- // the real state contradicts. Naming the collision is what this footer is for.
1502
- else if (p.branch && myBranch && p.branch === myBranch) warn = ' · ⚠️ same branch — your commits will interleave; coordinate before you commit';
1515
+ // hook and must not spawn git. The same-branch fact is stated once above
1516
+ // the list; per peer, only a shared folder is added (advisory).
1517
+ else {
1518
+ const near = [...dirsOfScope(p)].filter(d => myDirs.has(d) && !dirIsNoise(d)).sort().slice(0, 2);
1519
+ if (near.length) warn = ` · 📁 same folder: ${near.map(d => `${d}/`).join(', ')} — no shared file yet`;
1520
+ }
1503
1521
  // Unique over the full live list (not just shown peers) — the MSG router
1504
1522
  // resolves a prefix against every row, so uniqueness must match that set.
1505
1523
  lines.push(`- session ${shortestUniquePeerPrefix(list, p.id)} · ${bits.join(' · ')}${warn}`);
@@ -34,7 +34,7 @@ import {
34
34
  challengeBrain, challengeContextToMarkdown, buildRenderSpec, structToBrief,
35
35
  brainLensData, lensToMarkdown, deathDateOfCard,
36
36
  statusContextToMarkdown, findFulfillmentCandidates, findStaleOpenCards, releaseFulfilledOpens, openStatusSummary, perAreaTableToMarkdown,
37
- splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor, currentGuidanceFor, currentGuidancePrefix,
37
+ splitQueryTokens, scoreCardsAgainstQuery, correctionOverlaysFor, supersededByNote, currentGuidanceFor, currentGuidancePrefix,
38
38
  isFastDecayCard, isUnresolvedOpenCard, isSkillCard, validateGuard, guardSidecarPathFor, ensureGuardSidecar, DECAY_STALE_MS, formatDecayAge,
39
39
  isPlanCard, planFulfillmentFor, PLAN_PAIR_SIM_BRAIN, isAgconfTwinId,
40
40
  readPendingShips, clearPendingShips, pendingShipCards, formatCaptureReceipts, parseVerifySuffix, amendmentFirst,
@@ -435,6 +435,9 @@ export async function opSearchCanvases({ vault, query }) {
435
435
  const isArchived = (c) => /^archive$/i.test(c.area || '');
436
436
  const n = struct.counts;
437
437
  const head = `## ${rel} — "${struct.title}" · ${n.live ?? n.cards} live cards${n.archived ? `, ${n.archived} archived` : ''}, ${n.connections} connections${saved ? ` · saved ${saved}` : ''}${nameMatch && !matched.length ? ' (name/title match)' : ''}`;
438
+ // A stale hit names its replacement (1.98.0) — never a bare "archived".
439
+ let successors = new Map();
440
+ try { successors = correctionOverlaysFor(struct, matched.slice(0, 8)); } catch { successors = new Map(); }
438
441
  const body = matched.slice(0, 8).map(c => {
439
442
  const pos = (c.pos && c.pos.x != null) ? ` @(${Math.round(c.pos.x)},${Math.round(c.pos.y)})` : '';
440
443
  const allTags = [...new Set([...(c.tags || []), ...itemTags(c)])];
@@ -449,7 +452,10 @@ export async function opSearchCanvases({ vault, query }) {
449
452
  const fromReading = reading && !hit(c.title) && !hit(c.text) && hit(reading.text)
450
453
  ? `\n in KLYPIX's saved reading: ${escapeInstructionLines(reading.text.replace(/\s+/g, ' ').slice(0, 240))}`
451
454
  : '';
452
- return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${date ? ` · ${date}` : ''}${tags}${arch}\n ${escapeInstructionLines(ownText)}${fromReading}`;
455
+ const replaced = successors.has(c.id)
456
+ ? `\n ↪ ${supersededByNote(successors.get(c.id))} — read that card for the current state`
457
+ : '';
458
+ return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${date ? ` · ${date}` : ''}${tags}${arch}\n ${escapeInstructionLines(ownText)}${fromReading}${replaced}`;
453
459
  }).join('\n');
454
460
  hits.push(matched.length ? `${head}\n${body}` : head);
455
461
  }
@@ -504,6 +510,12 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
504
510
  try { ({ struct } = await parseKlypix(fs.readFileSync(b.path))); } catch { continue; }
505
511
  let vecs = null;
506
512
  if (qv) { try { const r = await questionVectors(pipe, b.path, struct.cards); vecs = r.usable ? r.vecs : null; } catch { /* lexical for this brain */ } }
513
+ // Replacement per stale card (1.98.0), only when this brain HAS lifecycle
514
+ // edges; skipped under as_of (a correction is a present-day fact).
515
+ let successors = new Map();
516
+ if (asOfTs == null && (struct.connections || []).some(cn => cn.label === 'superseded by' || cn.label === 'closed by')) {
517
+ try { successors = correctionOverlaysFor(struct, struct.cards); } catch { successors = new Map(); }
518
+ }
507
519
  for (const c of struct.cards) {
508
520
  if (c.type === 'container') continue;
509
521
  const t = String(c.text || '').toLowerCase();
@@ -529,7 +541,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
529
541
  if (isArchived) score -= 1;
530
542
  if (isCur) score += sem != null ? 1.5 : 1; // current-project locality prior
531
543
  }
532
- scored.push({ score, sem, cur: isCur, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c });
544
+ scored.push({ score, sem, cur: isCur, project: b.project || path.basename(path.dirname(b.path)), area: c.area, c, successor: successors.get(c.id) || null });
533
545
  }
534
546
  }
535
547
  if (!scored.length) return { blocks: [text(`No matches for "${query}" across ${brains.length} registered brain(s).`)] };
@@ -537,7 +549,7 @@ export async function opSearchAllBrains({ vault, query, as_of, log = () => {} })
537
549
  const top = scored.slice(0, 20);
538
550
  const lines = top.map(h => {
539
551
  const when = h.c.createdAt ? new Date(h.c.createdAt).toISOString().slice(0, 10) : '';
540
- return `- ${h.cur ? '★ ' : ''}[${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
552
+ return `- ${h.cur ? '★ ' : ''}[${h.project}${h.area ? ' › ' + h.area : ''}] ${when} ${String(h.c.text || '').replace(/\s+/g, ' ').slice(0, 240)}${h.successor ? `\n ↪ ${supersededByNote(h.successor)}` : ''}`;
541
553
  });
542
554
  // Never say "warming — retry" to someone who simply has not installed the
543
555
  // runtime: for them that advice is wrong forever, and their results are the
@@ -4405,17 +4405,63 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
4405
4405
  }
4406
4406
  const out = [];
4407
4407
  out.push(`# Answer “${flat(question)}” from these ${hits.length} brain card(s)${as_of ? ` (as of ${as_of})` : ''} — ${total} matched, ${mode} ranking`);
4408
- out.push('_Synthesize a DIRECT answer from the cards below, then cite the ones you used by [Area]+date. Where a card is marked ⚠️ CORRECTED, answer from the correction, NOT the stale card. Include superseded/archived cards only to show how a decision CHANGED. If the cards don\'t actually answer the question, say so — don\'t pad._');
4408
+ out.push('_Synthesize a DIRECT answer from the cards below, then cite the ones you used by [Area]+date. Where a card is marked ⚠️ CORRECTED, the CURRENT truth is printed FIRST — answer from it; the ↩︎ line under it is superseded history, never the answer. Include superseded/archived cards only to show how a decision CHANGED. If the cards don\'t actually answer the question, say so — don\'t pad._');
4409
4409
  out.push('');
4410
4410
  let used = out.join('\n').length;
4411
4411
  let shown = 0;
4412
+ // CURRENT-FIRST corrections (1.98.0). Agents followed a withdrawn rule in
4413
+ // 77% of tries unless its replacement was made prominent (arXiv 2608.25553),
4414
+ // and a stale card can outrank its own replacement (corpseRate). So a
4415
+ // corrected hit prints the current truth BEFORE the stale words, and when
4416
+ // the replacement ranked too, the pair renders ONCE, at the better rank —
4417
+ // never a stale block above a separate current block.
4418
+ const hitById = new Map(hits.map(h => [h.card.id, h]));
4419
+ const replaces = new Map(); // current id → the stale hit it supersedes
4412
4420
  for (const h of hits) {
4413
- const c = h.card;
4421
+ const succ = h.correction?.by?.id;
4422
+ if (succ && hitById.has(succ) && !replaces.has(succ)) replaces.set(succ, h);
4423
+ }
4424
+ const rendered = new Set();
4425
+ const relOf = (h) => (h && h.sem != null ? ` · sim ${h.sem.toFixed(2)}` : '');
4426
+ for (const h of hits) {
4427
+ if (rendered.has(h.card.id)) continue;
4428
+ let c = h.card;
4429
+ let block;
4430
+ // Pair: either this is the current card of a stale hit, or a stale hit
4431
+ // whose current card is also in this answer — render the pair here.
4432
+ const staleHit = replaces.get(c.id) || (h.correction && hitById.has(h.correction.by.id) ? h : null);
4433
+ if (staleHit) {
4434
+ const currentHit = hitById.get(staleHit.correction.by.id);
4435
+ const cur = currentHit.card;
4436
+ const old = staleHit.card;
4437
+ rendered.add(cur.id); rendered.add(old.id);
4438
+ c = cur;
4439
+ block = `## [${flat(cur.area) || 'Notes'}] ${day(cur.createdAt)} · ✅ CURRENT (supersedes an older card)${relOf(currentHit)}\n`
4440
+ + `⚠️ CORRECTED — the current truth is:\n${flat(cur.text)}\n\n`
4441
+ + ` ↩︎ superseded [${flat(old.area) || 'Notes'}] ${day(old.createdAt)} (do NOT act on it — history only): ${flat(old.text).slice(0, 240)}`;
4442
+ if (typeof evidenceForCard === 'function') {
4443
+ const evidence = evidenceForCard(cur);
4444
+ if (evidence) block += `\n\n${evidence}`;
4445
+ }
4446
+ if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.filter(x => !rendered.has(x.card.id)).length + 2} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
4447
+ out.push(block, '');
4448
+ used += block.length + 2;
4449
+ shown++;
4450
+ continue;
4451
+ }
4452
+ rendered.add(c.id);
4414
4453
  const status = h.archived ? ' · ⛔ archived/superseded' : '';
4415
- const rel = h.sem != null ? ` · sim ${h.sem.toFixed(2)}` : '';
4416
- let block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n${flat(c.text)}`;
4454
+ const rel = relOf(h);
4455
+ if (h.correction) {
4456
+ // The replacement is NOT in this answer: still print it first.
4457
+ block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n`
4458
+ + `⚠️ CORRECTED — this card is STALE; the current truth is:\n${flat(h.correction.by.text).slice(0, 600)}\n\n`
4459
+ + ` ↩︎ stale text (do NOT act on it — history only): ${flat(c.text).slice(0, 240)}`;
4460
+ } else {
4461
+ block = `## [${flat(c.area) || 'Notes'}] ${day(c.createdAt)}${status}${rel}\n${flat(c.text)}`;
4462
+ }
4417
4463
  if (h.correction) {
4418
- block += `\n\n ⚠️ CORRECTED — this card is STALE; the current truth is:\n ${flat(h.correction.by.text).slice(0, 600)}`;
4464
+ // (rendered above — current truth first)
4419
4465
  } else if (h.fulfillment && (h.fulfillment.kind === 'plan' || isPlanCard(c))) {
4420
4466
  // A PLAN/PROPOSAL card a newer 🏁 appears to have shipped (2026-08-23
4421
4467
  // incident: "it's only a proposal" answered about a live feature).
@@ -4446,7 +4492,7 @@ export function questionContextToMarkdown(question, result, { mode = 'lexical',
4446
4492
  if (correctionEvidence) block += `\n\nCorrection ${correctionEvidence}`;
4447
4493
  }
4448
4494
  }
4449
- if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.length - shown} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
4495
+ if (used + block.length + 2 > budgetChars && shown > 0) { out.push(`\n_…and ${hits.filter(x => !rendered.has(x.card.id)).length + 1} more matched card(s) omitted for length — narrow the question or use search for the rest._`); break; }
4450
4496
  out.push(block, '');
4451
4497
  used += block.length + 2;
4452
4498
  shown++;
@@ -4665,7 +4711,7 @@ export function statusContextToMarkdown(struct, { maxOpen = Infinity, budgetChar
4665
4711
  let shown = 0;
4666
4712
  for (const c of top) {
4667
4713
  if (used > budgetChars) break;
4668
- const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ' ⚠️CORRECTED' : ''}${fulfills.has(c.id) ? (fulfills.get(c.id)?.unconfirmed ? ' ⏳likely-fulfilled?' : ' ⏳likely-fulfilled') : ''}`;
4714
+ const flags = `${overdueById.has(c.id) ? ' ⏰OVERDUE' : ''}${overlays.has(c.id) ? ` ⚠️CORRECTED → current: “${cut(flat(overlays.get(c.id).by.text), 80)}”` : ''}${fulfills.has(c.id) ? (fulfills.get(c.id)?.unconfirmed ? ' ⏳likely-fulfilled?' : ' ⏳likely-fulfilled') : ''}`;
4669
4715
  const d = decayStamp(c);
4670
4716
  if (d) pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ⏱️ LAST KNOWN (${d.age}): ${cut(flat(c.text), perCard)} — VERIFY: ${d.probe}`);
4671
4717
  else pushAlways(`- [${flat(c.area) || 'Notes'}]${flags} ${cut(flat(c.text), perCard)}`);
@@ -5810,6 +5856,16 @@ const reAdoptionReceipt = (prior, today) => {
5810
5856
  return `↪ re-adopts ${today}: ${headline || 'an earlier superseded decision'}`;
5811
5857
  };
5812
5858
 
5859
+ // One wording for "this card was replaced, read that one" on every surface that
5860
+ // lists cards (status, read_canvas, search_canvases, search_all_brains), so a
5861
+ // stale card is never shown without naming its replacement (1.98.0).
5862
+ export function supersededByNote(overlay, max = 100) {
5863
+ if (!overlay || !overlay.by) return '';
5864
+ const head = String(overlay.by.text || '').replace(/\s+/g, ' ').trim();
5865
+ const cutHead = head.length > max ? `${head.slice(0, max - 1)}…` : head;
5866
+ return `superseded by “${cutHead}” (${overlay.by.id})`;
5867
+ }
5868
+
5813
5869
  // Recall-side truth guard: label a card stale ONLY when the graph contains an
5814
5870
  // explicit identity-bearing lifecycle edge ("superseded by" / "closed by").
5815
5871
  // Lexical correction matching remains useful at capture/reconcile time, where
@@ -9675,8 +9731,13 @@ export function structToMarkdown(struct, { assetsDir, parsed = null, lockedBoxes
9675
9731
  if (strokes || lines) L.push(`*Ink: ${strokes} freehand stroke${strokes === 1 ? '' : 's'} · ${lines} drawn line${lines === 1 ? '' : 's'}/shape${lines === 1 ? '' : 's'}${frozenInk ? ` · ${frozenInk} frozen` : ''} (not readable as text)*\n`);
9676
9732
  }
9677
9733
  L.push(`## Cards`);
9734
+ // The replacement rides in the stale card's own heading (1.98.0) — before,
9735
+ // the edge appeared only in the separate connection list at the end.
9736
+ let successors = new Map();
9737
+ try { successors = correctionOverlaysFor(struct, struct.cards.filter(c => c.type !== 'container')); } catch { successors = new Map(); }
9678
9738
  for (const c of struct.cards) {
9679
9739
  const archived = isArchived(c);
9740
+ const replacedNote = successors.has(c.id) ? ` — ${supersededByNote(successors.get(c.id), 90)}` : '';
9680
9741
  const it = items[c.id] || {};
9681
9742
  // Human-set state rides in the heading, where a reader cannot miss it.
9682
9743
  const state = [
@@ -9685,7 +9746,7 @@ export function structToMarkdown(struct, { assetsDir, parsed = null, lockedBoxes
9685
9746
  c.type === 'container' && c.containerKind === 'block' ? 'a frameless block' : null,
9686
9747
  c.type === 'container' && c.collapsed ? 'collapsed by the person (its cards are still listed)' : null,
9687
9748
  ].filter(Boolean);
9688
- L.push(`### ${c.title || `(${c.type})`} \`${c.type}\` · id ${c.id}${archived ? ' ⛔ archived' : ''}${c.area && !archived ? ` _[${c.area}]_` : ''}${state.length ? ` · ${state.join(' · ')}` : ''}`);
9749
+ L.push(`### ${c.title || `(${c.type})`} \`${c.type}\` · id ${c.id}${archived ? ` ⛔ archived${replacedNote}` : (replacedNote ? ` ⚠️${replacedNote}` : '')}${c.area && !archived ? ` _[${c.area}]_` : ''}${state.length ? ` · ${state.join(' · ')}` : ''}`);
9689
9750
  if (c.text) L.push(escapeInstructionLines(c.type === 'text' ? String(c.text).trim() : `→ ${c.text}`));
9690
9751
  if (rich) {
9691
9752
  const extra = [];
@@ -46,7 +46,7 @@ import {
46
46
  // canonical copy lives in the pure module because that one is import-restricted
47
47
  // (crypto only), so it can never grow a dependency this file would inherit.
48
48
  import { normalizeFileKey } from './finding-routing.mjs';
49
- import { cmpSemver3, collectRepoState, commitFiles, deleteCommittedClaim, readCommittedClaims, releaseAncestry, releaseAncestryWarnings, repoStateWarnings, settleClaimsAgainstRef, writeCommittedClaim } from './repo-state.mjs';
49
+ import { cmpSemver3, collectRepoState, commitFiles, mergeTreeConflicts, deleteCommittedClaim, readCommittedClaims, releaseAncestry, releaseAncestryWarnings, repoStateWarnings, settleClaimsAgainstRef, writeCommittedClaim } from './repo-state.mjs';
50
50
  import { recordResultManifests } from './result-reconcile.mjs';
51
51
 
52
52
  export const MCP_HEARTBEAT_MS = 60_000;
@@ -82,6 +82,19 @@ export const KLYPIX_MCP_INSTRUCTIONS = [
82
82
 
83
83
  const compact = (value) => String(value || '').replace(/\s+/g, ' ').trim();
84
84
 
85
+ // A `results` value is EVIDENCE-SHAPED when it is an array (the manifest list,
86
+ // even an empty or malformed one) or an object carrying any field a result
87
+ // manifest has. Only evidence-shaped submissions enter the fail-closed result
88
+ // contract. Prose or a summary object claims nothing machine-checkable: it is
89
+ // refused, but it must not lock the task — an AI tool that mistook `results`
90
+ // for "describe what you did" (Codex did, 2026-10-11) finishes with a plain
91
+ // retry instead of staying active to every peer.
92
+ const MANIFEST_FIELDS = ['schemaVersion', 'claimKey', 'metrics', 'provenance', 'evaluation', 'inputFingerprint', 'scopeFingerprint'];
93
+ export function isEvidenceShapedResults(value) {
94
+ if (Array.isArray(value)) return true;
95
+ return !!value && typeof value === 'object' && MANIFEST_FIELDS.some((key) => Object.prototype.hasOwnProperty.call(value, key));
96
+ }
97
+
85
98
  const EXACT_FILE_MAX_CHARS = 512;
86
99
  const EXACT_FILE_MAX_COUNT = 20;
87
100
  const GLOB_FILE_CHARS = new Set(['*', '?', '[', ']', '{', '}']);
@@ -798,6 +811,151 @@ const isTaskSession = (session) =>
798
811
  // rest of the host process's life.
799
812
  || liveObservedFiles(session).length > 0);
800
813
 
814
+ // ── Overlap tiers beyond the exact file (1.98.0) ────────────────────────────
815
+ // Exact-file overlap (findPresenceConflicts) stays the blocking signal and its
816
+ // shape is unchanged. Two further tiers, each with its own honesty limit:
817
+ //
818
+ // same-directory (advisory) — both sessions work in one folder with no file
819
+ // in common yet. Most real collisions start as "I'm in the folder you're
820
+ // in". Noise control borrowed from release ownership: with three or more
821
+ // scoped sessions, a folder that more than half of them touch (docs/,
822
+ // src/) says nothing about any one pair and is dropped. Never alerts.
823
+ // merge-conflict (blocking) — the two sessions' BRANCHES, test-merged with
824
+ // git merge-tree, conflict in named files although no declared file
825
+ // overlaps. Committed work only; skipped silently whenever git cannot say.
826
+
827
+ const dirOfKey = (key) => {
828
+ const at = String(key || '').lastIndexOf('/');
829
+ return at > 0 ? key.slice(0, at) : ''; // '' = repo root: never a "folder" signal
830
+ };
831
+
832
+ const scopeDisplayByKey = (session, root) => {
833
+ const out = new Map();
834
+ for (const file of [
835
+ ...(Array.isArray(session?.files) ? session.files : []),
836
+ ...liveObservedFiles(session),
837
+ ]) {
838
+ const key = normalizeFileKey(file, root);
839
+ if (key && !out.has(key)) out.set(key, String(file || '').replace(/\\/g, '/'));
840
+ }
841
+ return out;
842
+ };
843
+
844
+ export function findNearbyWork(sessions, selfId, { projectRoot, excludePeerIds = new Set(), maxDirs = 3, maxFiles = 4 } = {}) {
845
+ const active = Array.isArray(sessions) ? sessions : [];
846
+ const me = active.find((session) => session.id === selfId);
847
+ if (!me) return [];
848
+ const root = projectRoot || me.cwd || null;
849
+ const mine = scopeDisplayByKey(me, root);
850
+ if (!mine.size) return [];
851
+ const scoped = active.filter((s) => s && (s.id === selfId || (!isSuspectedTwin(s, me) && isTaskSession(s))));
852
+ const dirsOf = (byKey) => {
853
+ const map = new Map(); // dir → [display files]
854
+ for (const [key, display] of byKey) {
855
+ const dir = dirOfKey(key);
856
+ if (!dir) continue;
857
+ if (!map.has(dir)) map.set(dir, []);
858
+ map.get(dir).push(display);
859
+ }
860
+ return map;
861
+ };
862
+ const myDirs = dirsOf(mine);
863
+ if (!myDirs.size) return [];
864
+ // Shared-folder noise: count sessions touching each folder.
865
+ const dirSessions = new Map();
866
+ const scopes = new Map();
867
+ for (const s of scoped) {
868
+ const byKey = s.id === selfId ? mine : scopeDisplayByKey(s, root);
869
+ scopes.set(s.id, byKey);
870
+ for (const dir of dirsOf(byKey).keys()) dirSessions.set(dir, (dirSessions.get(dir) || 0) + 1);
871
+ }
872
+ const scopedCount = [...scopes.values()].filter((m) => m.size).length;
873
+ // Noise needs a CROWD: at least three sessions in the folder AND more than
874
+ // half of all scoped sessions. Two sessions sharing a folder is exactly the
875
+ // pair signal this tier exists for, however small the lane.
876
+ const isNoise = (dir) => {
877
+ const n = dirSessions.get(dir) || 0;
878
+ return n >= 3 && n > scopedCount / 2;
879
+ };
880
+ const out = [];
881
+ for (const peer of scoped) {
882
+ if (peer.id === selfId || excludePeerIds.has(peer.id)) continue;
883
+ const theirsByKey = scopes.get(peer.id);
884
+ if (!theirsByKey || !theirsByKey.size) continue;
885
+ if ([...theirsByKey.keys()].some((key) => mine.has(key))) continue; // exact overlap is the stronger report
886
+ const theirDirs = dirsOf(theirsByKey);
887
+ const shared = [...theirDirs.keys()].filter((dir) => myDirs.has(dir) && !isNoise(dir)).sort();
888
+ if (!shared.length) continue;
889
+ const dirs = shared.slice(0, maxDirs);
890
+ out.push({
891
+ id: peer.id,
892
+ client: peer.client,
893
+ surface: peer.surface || null,
894
+ branch: peer.branch || null,
895
+ intent: peer.intent || '',
896
+ dirs,
897
+ mine: dirs.flatMap((d) => myDirs.get(d)).slice(0, maxFiles),
898
+ theirs: dirs.flatMap((d) => theirDirs.get(d)).slice(0, maxFiles),
899
+ kind: 'same-directory',
900
+ severity: 'advisory',
901
+ });
902
+ }
903
+ return out;
904
+ }
905
+
906
+ export function findMergeConflicts(sessions, selfId, {
907
+ repoDir,
908
+ maxPeers = 6,
909
+ budgetMs = 2000,
910
+ clock = () => Date.now(),
911
+ mergeTree = mergeTreeConflicts,
912
+ } = {}) {
913
+ const active = Array.isArray(sessions) ? sessions : [];
914
+ const me = active.find((session) => session.id === selfId);
915
+ if (!me || !repoDir || !compact(me.branch)) return [];
916
+ const candidates = active
917
+ .filter((peer) => peer && peer.id !== selfId
918
+ && !isSuspectedTwin(peer, me)
919
+ && isTaskSession(peer)
920
+ && compact(peer.branch) && peer.branch !== me.branch
921
+ // A teammate's PC: its branch refs are not in this repo (the lookup
922
+ // would simply fail), so do not spend the budget on it.
923
+ && !(peer.machine && me.machine && peer.machine !== me.machine))
924
+ .sort((a, b) => Number(b.lastSeen || 0) - Number(a.lastSeen || 0))
925
+ .slice(0, maxPeers);
926
+ const started = clock();
927
+ const byBranch = new Map();
928
+ const out = [];
929
+ for (const peer of candidates) {
930
+ if (clock() - started > budgetMs) break;
931
+ let result = byBranch.get(peer.branch);
932
+ if (result === undefined) {
933
+ try { result = mergeTree(repoDir, me.branch, peer.branch); } catch { result = null; }
934
+ byBranch.set(peer.branch, result);
935
+ }
936
+ if (!result || result.clean || !Array.isArray(result.files) || !result.files.length) continue;
937
+ out.push({
938
+ id: peer.id,
939
+ client: peer.client,
940
+ surface: peer.surface || null,
941
+ branch: peer.branch || null,
942
+ intent: peer.intent || '',
943
+ files: result.files.slice(0, 12),
944
+ kind: 'merge-conflict',
945
+ severity: 'blocking',
946
+ sameWorktree: false,
947
+ peerObservedFiles: [],
948
+ selfObservedFiles: [],
949
+ refs: {
950
+ mine: `${me.branch}@${String(result.shaA || '').slice(0, 7)}`,
951
+ theirs: `${peer.branch}@${String(result.shaB || '').slice(0, 7)}`,
952
+ },
953
+ committedOnly: true,
954
+ });
955
+ }
956
+ return out;
957
+ }
958
+
801
959
  const publicSession = (session, now) => {
802
960
  const transport = session?.transport && typeof session.transport === 'object'
803
961
  ? Object.fromEntries(Object.entries(session.transport).slice(0, 4).map(([channel, value]) => [
@@ -2411,6 +2569,7 @@ export function createMcpPresence({
2411
2569
  }
2412
2570
  if (nextPhase === 'checkpoint') checkpointSinceTaskStart = true;
2413
2571
  const hasDefinedResults = results !== undefined;
2572
+ const resultsAreEvidenceShaped = hasDefinedResults && isEvidenceShapedResults(results);
2414
2573
  let resultReconciliation = null;
2415
2574
  let completionBlocked = false;
2416
2575
  let resultSubmissionRejected = false;
@@ -2437,7 +2596,31 @@ export function createMcpPresence({
2437
2596
  }
2438
2597
  }
2439
2598
 
2440
- if (hasDefinedResults) {
2599
+ if (hasDefinedResults && !resultsAreEvidenceShaped) {
2600
+ // Refused for THIS call (scope retained), with no durable evidence
2601
+ // obligation: nothing machine-checkable was claimed, so a retry without
2602
+ // `results` is the ordinary completion it always was. A prior
2603
+ // evidence-shaped claim still holds — the next result-less complete reads
2604
+ // it in the branch below and stays blocked.
2605
+ resultSubmissionRejected = true;
2606
+ resultReconciliation = {
2607
+ ok: false,
2608
+ status: 'needs-reconciliation',
2609
+ ledgerWriteOk: null,
2610
+ claims: [],
2611
+ conflicts: [{
2612
+ kind: 'results-not-manifests',
2613
+ severity: 'blocking',
2614
+ reason: '`results` takes only result manifests (machine-checkable claims, a list of 1-8), never a summary of the work. Nothing was recorded and this task owes no evidence: to finish, call brain_sync with phase "complete" again WITHOUT results.',
2615
+ }],
2616
+ };
2617
+ if (completing) {
2618
+ completionBlocked = true;
2619
+ // A tool that writes up its outcome is ending the task, not a turn: the
2620
+ // plain retry must not then be deferred as a premature turn-end.
2621
+ checkpointSinceTaskStart = true;
2622
+ }
2623
+ } else if (hasDefinedResults) {
2441
2624
  resultClaimsPending = true;
2442
2625
  const marked = markResultClaimPendingFn({
2443
2626
  brainPath: resultBrainPath,
@@ -2660,16 +2843,28 @@ export function createMcpPresence({
2660
2843
  }
2661
2844
 
2662
2845
  const stamp = now();
2663
- const conflicts = findPresenceConflicts(report.sessions, sessionId);
2846
+ const exactConflicts = findPresenceConflicts(report.sessions, sessionId);
2847
+ // 1.98.0 tiers (see findNearbyWork / findMergeConflicts). Both are skipped
2848
+ // on completion — a finished task has nothing left to collide with.
2849
+ const mergeConflicts = completing ? [] : findMergeConflicts(report.sessions, sessionId, { repoDir: path.dirname(brainPath) });
2850
+ const nearby = completing ? [] : findNearbyWork(report.sessions, sessionId, {
2851
+ excludePeerIds: new Set([...exactConflicts, ...mergeConflicts].map((c) => c.id)),
2852
+ });
2853
+ // `conflicts` keeps its contract (blocking overlaps, exact-file shape) and
2854
+ // gains the merge-conflict kind additively.
2855
+ const conflicts = [...exactConflicts, ...mergeConflicts];
2664
2856
  const snapshot = buildPresenceSnapshot(report.sessions, sessionId, { now: stamp });
2665
2857
  const alertsQueued = [];
2858
+ const selfLabel = shortestUniqueSessionPrefix(report.sessions, sessionId, 12) || String(sessionId).slice(0, 12);
2859
+ const planLabel = compact(intent) ? ` plans "${compact(intent).slice(0, 110)}"` : ' synchronized a task';
2666
2860
  if (!completing) {
2667
2861
  for (const peer of conflicts) {
2668
2862
  // Explicit arrow: normalizeFileKey now takes an optional `root` second
2669
2863
  // argument, so a bare `.map(normalizeFileKey)` would hand it the array
2670
2864
  // INDEX as the project root.
2671
2865
  const filesKey = peer.files.map((file) => normalizeFileKey(file)).sort().join('|');
2672
- const key = `${peer.id}|${filesKey}`;
2866
+ const isMerge = peer.kind === 'merge-conflict';
2867
+ const key = `${isMerge ? 'merge|' : ''}${peer.id}|${filesKey}`;
2673
2868
  if (notifiedConflicts.has(key)) continue;
2674
2869
  notifiedConflicts.add(key);
2675
2870
  const annotated = annotateConflictFiles(peer);
@@ -2677,29 +2872,47 @@ export function createMcpPresence({
2677
2872
  brainPath,
2678
2873
  from: sessionId,
2679
2874
  to: peer.id,
2680
- text: `Automatic KLYPIX overlap alert: ${shortestUniqueSessionPrefix(report.sessions, sessionId, 12) || String(sessionId).slice(0, 12)}`
2681
- + `${compact(intent) ? ` plans "${compact(intent).slice(0, 110)}"` : ' synchronized a task'}`
2682
- + ` and reports the same file(s): ${annotated.files.join(', ')}${annotated.legend}. Coordinate before editing.`,
2683
- dedupeKey: `overlap|${sessionId}|${peer.id}|${taskStartedAt}|${filesKey}`,
2875
+ text: isMerge
2876
+ ? `Automatic KLYPIX merge-conflict alert: ${selfLabel}${planLabel}. A test merge of its branch ${peer.refs?.mine || ''} with yours ${peer.refs?.theirs || ''} conflicts in: ${peer.files.join(', ')} (committed work only; no declared file overlaps). Coordinate before either of you merges.`
2877
+ : `Automatic KLYPIX overlap alert: ${selfLabel}${planLabel}`
2878
+ + ` and reports the same file(s): ${annotated.files.join(', ')}${annotated.legend}. Coordinate before editing.`,
2879
+ dedupeKey: `${isMerge ? 'merge-overlap' : 'overlap'}|${sessionId}|${peer.id}|${taskStartedAt}|${filesKey}`,
2684
2880
  home,
2685
2881
  now: stamp,
2686
2882
  });
2687
- if (queued.posted) alertsQueued.push({ peerId: peer.id, files: peer.files });
2883
+ if (queued.posted) alertsQueued.push({ peerId: peer.id, files: peer.files, ...(isMerge ? { kind: 'merge-conflict' } : {}) });
2688
2884
  }
2689
2885
  }
2690
2886
 
2691
- const conflictText = conflicts.length
2692
- ? [
2693
- 'KLYPIX exact file-overlap warning:',
2694
- ...conflicts.map((peer) => {
2695
- const annotated = annotateConflictFiles(peer);
2696
- return `- ${shortestUniqueSessionPrefix(report.sessions, peer.id, 12) || String(peer.id).slice(0, 12)}${peer.intent ? ` "${String(peer.intent).slice(0, 80)}"` : ''}: ${annotated.files.join(', ')}${annotated.legend}`;
2697
- }),
2698
- alertsQueued.length
2699
- ? 'The earlier peer was automatically queued an exact-once overlap alert; coordinate ownership before editing.'
2700
- : 'Coordinate ownership before editing the overlapping files.',
2701
- ].join('\n')
2702
- : 'No exact file overlap is currently reported by another synchronized task.';
2887
+ const peerLabel = (peer) => `${shortestUniqueSessionPrefix(report.sessions, peer.id, 12) || String(peer.id).slice(0, 12)}${peer.intent ? ` "${String(peer.intent).slice(0, 80)}"` : ''}`;
2888
+ const exactAlerted = alertsQueued.some((a) => a.kind !== 'merge-conflict');
2889
+ const conflictText = [
2890
+ exactConflicts.length
2891
+ ? [
2892
+ 'KLYPIX exact file-overlap warning:',
2893
+ ...exactConflicts.map((peer) => {
2894
+ const annotated = annotateConflictFiles(peer);
2895
+ return `- ${peerLabel(peer)}: ${annotated.files.join(', ')}${annotated.legend}`;
2896
+ }),
2897
+ exactAlerted
2898
+ ? 'The earlier peer was automatically queued an exact-once overlap alert; coordinate ownership before editing.'
2899
+ : 'Coordinate ownership before editing the overlapping files.',
2900
+ ].join('\n')
2901
+ : 'No exact file overlap is currently reported by another synchronized task.',
2902
+ mergeConflicts.length
2903
+ ? [
2904
+ 'KLYPIX merge-conflict warning (git merge-tree, committed work only — your branches would conflict even though no declared file overlaps):',
2905
+ ...mergeConflicts.map((peer) => `- ${peerLabel(peer)} on ${peer.refs.theirs} vs your ${peer.refs.mine}: ${peer.files.join(', ')}`),
2906
+ 'Agree who merges first, or rebase onto the other branch before you finish.',
2907
+ ].join('\n')
2908
+ : '',
2909
+ nearby.length
2910
+ ? [
2911
+ 'KLYPIX nearby-work notice (advisory — same folder, no shared file yet):',
2912
+ ...nearby.map((peer) => `- ${peerLabel(peer)}: ${peer.dirs.map((d) => `${d}/`).join(', ')} — theirs: ${peer.theirs.join(', ')}; yours: ${peer.mine.join(', ')}`),
2913
+ ].join('\n')
2914
+ : '',
2915
+ ].filter(Boolean).join('\n\n');
2703
2916
  // Mechanical repo-state (2026-08-14 bundle-currency incident): branch, tag
2704
2917
  // and version truth plus bundled-mirror drift, collected IN THE SERVER so
2705
2918
  // every MCP client — Claude, Codex, Cursor, Cline, Desktop — gets equal
@@ -3352,6 +3565,8 @@ export function createMcpPresence({
3352
3565
  },
3353
3566
  peers: snapshot.peers,
3354
3567
  conflicts,
3568
+ // Additive (1.98.0): same-folder work with no shared file (advisory).
3569
+ ...(nearby.length ? { nearby } : {}),
3355
3570
  messages: report.messages.map((message) => {
3356
3571
  // Raw structured path gets the same decay verdict as the rendered
3357
3572
  // text: a consumer reading structured.messages directly must see the
@@ -3433,7 +3648,7 @@ export function createMcpPresence({
3433
3648
  ? `KLYPIX message delivery was deferred safely: ${report.deliveryWriteSkippedReason || 'the receipt write did not land'}. No queued note was reported as delivered; retry on the next KLYPIX action.`
3434
3649
  : '';
3435
3650
  const deferralText = completionDeferred
3436
- ? 'KLYPIX completion deferred: this complete carried no results and arrived moments after task start with no intervening checkpoint, so it reads as turn-end, not task-end. The declared intent/files were RETAINED for peers. When the task is truly finished, complete again after a checkpoint (or include a result manifest) to clear the scope.'
3651
+ ? 'KLYPIX completion deferred: this complete carried no results and arrived moments after task start with no intervening checkpoint, so it reads as turn-end, not task-end. The declared intent/files were RETAINED for peers. When the task is truly finished, call brain_sync with phase "checkpoint", then "complete" again, to clear the scope. (Do not add `results` for this: it takes only machine-checkable result manifests, never a summary.)'
3437
3652
  : '';
3438
3653
  const text = [
3439
3654
  `KLYPIX Context Gateway: session ${sessionId} · phase ${nextPhase} · coordination ${durationMs}ms.`,
@@ -3459,6 +3674,7 @@ export function createMcpPresence({
3459
3674
  ...report,
3460
3675
  snapshot,
3461
3676
  conflicts,
3677
+ nearby,
3462
3678
  resultConflicts,
3463
3679
  alertsQueued,
3464
3680
  structured,
@@ -72,6 +72,13 @@ export function readLaunchSpec(file) {
72
72
  };
73
73
  }
74
74
 
75
+ // How long setup waits for the first handshake. On a busy machine a first run
76
+ // can spend most of it starting Node and loading the server (the release
77
+ // suite's live handshake took 29-92 s under load on 2026-10-11, about 7 s
78
+ // idle), and a false "did not start a server" sends a person hunting for a
79
+ // fault that is not there. Setup runs once, so waiting longer costs little.
80
+ export const VERIFY_TIMEOUT_MS = 60_000;
81
+
75
82
  /**
76
83
  * Launch the server exactly as the editor would and count the tools it offers.
77
84
  *
@@ -82,7 +89,7 @@ export function readLaunchSpec(file) {
82
89
  * @param {{ file: string, projectDir: string, timeoutMs?: number, env?: object }} opts
83
90
  * @returns {Promise<{ ok: boolean, toolCount?: number, ms?: number, why?: string, hasBrainSync?: boolean }>}
84
91
  */
85
- export async function verifyMcpConfig({ file, projectDir, timeoutMs = 25_000, env = process.env }) {
92
+ export async function verifyMcpConfig({ file, projectDir, timeoutMs = VERIFY_TIMEOUT_MS, env = process.env }) {
86
93
  const spec = readLaunchSpec(file);
87
94
  if (!spec.ok) return { ok: false, why: spec.why };
88
95
 
@@ -108,7 +115,7 @@ export async function verifyMcpConfig({ file, projectDir, timeoutMs = 25_000, en
108
115
  client.connect(transport).then(() => 'ok'),
109
116
  new Promise((r) => setTimeout(() => r('timeout'), timeoutMs)),
110
117
  ]);
111
- if (connected === 'timeout') return { ok: false, why: `no response within ${Math.round(timeoutMs / 1000)}s` };
118
+ if (connected === 'timeout') return { ok: false, why: `no response within ${Math.round(timeoutMs / 1000)}s (a busy machine or a first download can be this slow; \`npx klypix-mcp doctor\` checks again)` };
112
119
 
113
120
  const listed = await Promise.race([
114
121
  client.listTools().then((t) => t),
@@ -29,7 +29,7 @@
29
29
  import crypto from 'crypto';
30
30
  import fs from 'fs';
31
31
  import path from 'path';
32
- import { execFileSync } from 'child_process';
32
+ import { execFileSync, spawnSync } from 'child_process';
33
33
 
34
34
  // Same shape as the presence-side gitBranch / resolveAuthor probes: short
35
35
  // timeout, stderr discarded, failure is data (null), never an exception.
@@ -65,6 +65,101 @@ const makeGit = (execGit) => (cwd, args, timeoutMs) => {
65
65
  }
66
66
  };
67
67
 
68
+ // ── Test-merge two sessions' branches (git merge-tree) ───────────────────────
69
+ // Declared-file overlap misses the conflicts that matter most at merge time:
70
+ // on 715 pairs of concurrently open agent PRs, 79 of 167 textual conflicts
71
+ // (47%) happened although the two PRs' own file lists did not overlap — each
72
+ // side was compared against a different base (arXiv 2610.04779). A real
73
+ // three-way test merge of the two branch tips catches them, and costs a median
74
+ // 0.02 s (git >= 2.38, `merge-tree --write-tree`, no working tree touched).
75
+ //
76
+ // Contract: committed work only (an uncommitted edit is invisible to git), never
77
+ // throws, null whenever the answer is unknown — git too old (exit 129), unrelated
78
+ // histories (128), a branch that does not exist in THIS repo (a teammate's PC),
79
+ // a timeout, or a ref name that is not safe to hand to git.
80
+
81
+ // Files the brain tooling regenerates on every branch, so they differ between
82
+ // almost any two branches and a "conflict" in them is projection noise, not
83
+ // work at risk. brain.klypix has its own merge driver. The rest mirror
84
+ // uninstall.mjs PROJECT_TARGETS (test/overlap-tiers.mjs fails if they drift).
85
+ export const MERGE_NOISE_PATHS = Object.freeze([
86
+ 'brain.klypix', 'brain.any',
87
+ 'agents.md', '.agents/agents.md', 'gemini.md', 'conventions.md',
88
+ '.cursor/rules/klypix-brain.mdc', '.windsurf/rules/klypix-brain.md', '.clinerules/klypix-brain.md',
89
+ '.github/copilot-instructions.md', '.codex/config.toml',
90
+ '.mcp.json', '.cursor/mcp.json', '.cline/mcp.json', '.gemini/settings.json', '.vscode/mcp.json',
91
+ ]);
92
+ const MERGE_NOISE = new Set(MERGE_NOISE_PATHS);
93
+ export const isMergeNoise = (file) => {
94
+ const key = String(file || '').replace(/\\/g, '/').replace(/^\.\//, '').trim().toLowerCase();
95
+ if (!key) return true;
96
+ const base = key.slice(key.lastIndexOf('/') + 1);
97
+ return MERGE_NOISE.has(key) || base === 'brain.klypix' || base === 'brain.any';
98
+ };
99
+
100
+ // A branch name we will put on a git command line: no leading dash (option
101
+ // injection), no "..", no reflog/revision syntax, no whitespace.
102
+ const SAFE_BRANCH = /^(?!-)(?!.*\.\.)(?!.*@\{)[A-Za-z0-9._/+-]{1,200}$/;
103
+ export const isSafeBranchName = (name) => typeof name === 'string' && SAFE_BRANCH.test(name) && !name.endsWith('/') && !name.endsWith('.lock');
104
+
105
+ function defaultRunMergeTree(args, cwd, timeoutMs) {
106
+ const r = spawnSync('git', args, {
107
+ cwd,
108
+ encoding: 'utf8',
109
+ stdio: ['ignore', 'pipe', 'ignore'],
110
+ timeout: Number(timeoutMs) > 0 ? Number(timeoutMs) : GIT_TIMEOUT_MS,
111
+ windowsHide: true,
112
+ });
113
+ return { status: r.error ? null : r.status, stdout: typeof r.stdout === 'string' ? r.stdout : '' };
114
+ }
115
+
116
+ const mergeTreeCache = new Map(); // `${dir}|${shaA}|${shaB}` → result (tips are immutable)
117
+ const MERGE_TREE_CACHE_MAX = 200;
118
+
119
+ /**
120
+ * Test-merge the tips of two local branches without touching any working tree.
121
+ * Returns { clean, files, shaA, shaB, noiseOnly } or null when unknown.
122
+ * `files` are the conflicted paths minus MERGE_NOISE_PATHS.
123
+ */
124
+ export function mergeTreeConflicts(dir, branchA, branchB, {
125
+ execGit = defaultExecGit,
126
+ runMergeTree = defaultRunMergeTree,
127
+ timeoutMs = 1500,
128
+ } = {}) {
129
+ if (!dir || !isSafeBranchName(branchA) || !isSafeBranchName(branchB) || branchA === branchB) return null;
130
+ const git = makeGit(execGit);
131
+ const shaA = git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branchA}^{commit}`]);
132
+ const shaB = git(dir, ['rev-parse', '--verify', '--quiet', `refs/heads/${branchB}^{commit}`]);
133
+ if (!shaA || !shaB || !/^[0-9a-f]{7,64}$/i.test(shaA) || !/^[0-9a-f]{7,64}$/i.test(shaB)) return null;
134
+ if (shaA === shaB) return { clean: true, files: [], shaA, shaB, noiseOnly: false };
135
+ const key = `${dir}|${shaA}|${shaB}`;
136
+ if (mergeTreeCache.has(key)) return mergeTreeCache.get(key);
137
+ let res = null;
138
+ try { res = runMergeTree(['merge-tree', '--write-tree', '--name-only', '--no-messages', shaA, shaB], dir, timeoutMs); } catch { res = null; }
139
+ let out = null;
140
+ if (res && res.status === 0) out = { clean: true, files: [], shaA, shaB, noiseOnly: false };
141
+ else if (res && res.status === 1) {
142
+ // Line 1 is the merged tree's OID; the conflicted file names follow, one
143
+ // per line, until a blank line (informational messages are suppressed).
144
+ const lines = String(res.stdout || '').split(/\r?\n/);
145
+ const all = [];
146
+ for (let i = 1; i < lines.length; i++) {
147
+ const line = lines[i].trim();
148
+ if (!line) break;
149
+ all.push(line);
150
+ }
151
+ const files = [...new Set(all)].filter((f) => !isMergeNoise(f));
152
+ out = { clean: files.length === 0, files, shaA, shaB, noiseOnly: all.length > 0 && files.length === 0 };
153
+ }
154
+ if (out) {
155
+ if (mergeTreeCache.size >= MERGE_TREE_CACHE_MAX) mergeTreeCache.delete(mergeTreeCache.keys().next().value);
156
+ mergeTreeCache.set(key, out);
157
+ }
158
+ return out;
159
+ }
160
+
161
+ export function __resetMergeTreeCacheForTests() { mergeTreeCache.clear(); }
162
+
68
163
  function readPackage(dir) {
69
164
  try {
70
165
  const pkg = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
package/src/setup.mjs CHANGED
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'url';
19
19
  import { execFile } from 'child_process';
20
20
  import { detectEditors } from './editor-detect.mjs';
21
21
  import { linkProject } from './agent-rules.mjs';
22
- import { verifyMcpConfig, readLaunchSpec } from './mcp-verify.mjs';
22
+ import { verifyMcpConfig, readLaunchSpec, VERIFY_TIMEOUT_MS } from './mcp-verify.mjs';
23
23
 
24
24
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
25
25
  const CLI = path.join(__dirname, '..', 'bin', 'klypix-mcp.mjs');
@@ -228,7 +228,7 @@ export async function runSetup(opts = {}) {
228
228
  const res = await verifyMcpConfig({
229
229
  file: path.join(root, target.file),
230
230
  projectDir: root,
231
- timeoutMs: opts.timeoutMs || 25_000,
231
+ timeoutMs: opts.timeoutMs || VERIFY_TIMEOUT_MS,
232
232
  });
233
233
  results.push({ tool: target.tool, file: target.file, ...res });
234
234
  if (!res.ok) report.warnings.push(`${target.file} did not start a server: ${res.why}`);