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 +29 -9
- package/bin/klypix-worker.mjs +1 -1
- package/package.json +2 -2
- package/src/global-brain-hook.mjs +22 -4
- package/src/klypix-core.mjs +16 -4
- package/src/klypix-format.mjs +69 -8
- package/src/mcp-presence.mjs +238 -22
- package/src/mcp-verify.mjs +9 -2
- package/src/repo-state.mjs +96 -1
- package/src/setup.mjs +2 -2
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
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
- **
|
|
1109
|
-
|
|
1110
|
-
|
|
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
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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:
|
|
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.
|
|
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
|
|
1500
|
-
//
|
|
1501
|
-
|
|
1502
|
-
|
|
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}`);
|
package/src/klypix-core.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
package/src/klypix-format.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
4416
|
-
|
|
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
|
-
|
|
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
|
|
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) ?
|
|
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 ?
|
|
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 = [];
|
package/src/mcp-presence.mjs
CHANGED
|
@@ -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
|
|
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
|
|
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:
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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
|
|
2692
|
-
|
|
2693
|
-
|
|
2694
|
-
|
|
2695
|
-
|
|
2696
|
-
|
|
2697
|
-
|
|
2698
|
-
|
|
2699
|
-
|
|
2700
|
-
|
|
2701
|
-
|
|
2702
|
-
|
|
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,
|
|
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,
|
package/src/mcp-verify.mjs
CHANGED
|
@@ -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 =
|
|
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),
|
package/src/repo-state.mjs
CHANGED
|
@@ -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 ||
|
|
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}`);
|