klypix-mcp 1.69.0 → 1.70.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.
@@ -19,7 +19,7 @@ const PKG_VERSION = (() => {
19
19
  }
20
20
  })();
21
21
 
22
- const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'git-hook', 'brain-history', 'brain-deleted', 'diff', 'pr-brief', 'uninstall', 'bench']);
22
+ const DIRECT = new Set(['install', 'link', 'doctor', 'runtime', 'conformance', 'garden-code', 'init', 'git-driver', 'git-hook', 'brain-history', 'brain-deleted', 'orphans', 'diff', 'pr-brief', 'uninstall', 'bench']);
23
23
 
24
24
  const USAGE = [
25
25
  `klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
@@ -38,6 +38,7 @@ const USAGE = [
38
38
  ' git-hook [install|remove|status] wire the agent-neutral commit-capture hook (any agent/branch/worktree → brain cards)',
39
39
  ' brain-history [list|restore <id>] restore points for this brain — undo an accidental delete, edit, or overwrite',
40
40
  ' brain-deleted [list|restore|purge] recycle bin for this brain — cards you deleted, kept recoverable',
41
+ ' orphans [brain] [--apply|--areas] orphan gardener backfill: report unconnected cards, link the confident subset (dry-run default)',
41
42
  ' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
42
43
  ' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
43
44
  '',
@@ -0,0 +1,168 @@
1
+ #!/usr/bin/env node
2
+ // klypix-orphans — `npx klypix-mcp orphans`. The orphan gardener's backfill pass:
3
+ // report how many live decision/milestone cards have NO connections, and link the
4
+ // CONFIDENT subset in one pass using the same selector capture uses
5
+ // (proposeOrphanAnchorLinks): at most ONE lexical-anchor edge per card — exact
6
+ // [[wikilink]], exact file-slug tag, or an unambiguous ≥0.6 title overlap —
7
+ // never a fan-out, never a guess among multiple candidates.
8
+ //
9
+ // npx klypix-mcp orphans [brain] # DRY RUN (default) — report, write nothing
10
+ // npx klypix-mcp orphans --apply # draw the confident edges (restore point first)
11
+ // npx klypix-mcp orphans --areas # ALSO include each orphan's area-container edge
12
+ // npx klypix-mcp orphans --json # machine-readable report
13
+ //
14
+ // Contract: additive + lossless — no card is archived, rewritten, or moved; every
15
+ // arrow remains individually removable in KLYPIX. `--apply` takes a FORCED restore
16
+ // point via the brain-history machinery before writing, so the whole pass is one
17
+ // `brain-history restore` away from undone. Dry-run is the default because a
18
+ // backfill touches history, not just this session's capture.
19
+ //
20
+ // `--areas` is opt-in for backfill (unlike capture, where the area edge is part
21
+ // of the no-new-orphans contract): mass-adding hundreds of containment edges to
22
+ // an old brain is a layout decision the human should make deliberately.
23
+ //
24
+ // Synchronous top-level by design: the dispatcher does `await import(this);
25
+ // process.exit(0)`, so all work must complete during module evaluation. ARGV is
26
+ // the standalone shape — the dispatcher splices its verb out first (runVerb).
27
+ import fs from 'fs';
28
+ import path from 'path';
29
+ import { parseKlypix, brainInsights, proposeOrphanAnchorLinks, addBrainConnections, atomicWrite } from '../src/klypix-format.mjs';
30
+ import { snapshotBrain } from '../src/brain-history.mjs';
31
+ import { withAdvisoryWriteLock, brainCaptureLockPath } from '../src/brain-write-lock.mjs';
32
+
33
+ const isDir = (p) => { try { return fs.statSync(p).isDirectory(); } catch { return false; } };
34
+
35
+ try {
36
+ const raw = process.argv.slice(2);
37
+ const argv = (raw[0] === 'orphans' && !isDir(path.resolve(raw[0]))) ? raw.slice(1) : raw;
38
+ const apply = argv.includes('--apply');
39
+ const withAreas = argv.includes('--areas');
40
+ const json = argv.includes('--json');
41
+ const pathArg = argv.find(a => !a.startsWith('-'));
42
+ const file = path.resolve(process.cwd(), pathArg || 'brain.klypix');
43
+ if (!fs.existsSync(file)) {
44
+ console.error(`No brain at ${file} — run from the project folder (or pass a path).`);
45
+ process.exit(1);
46
+ }
47
+
48
+ const { struct } = await parseKlypix(fs.readFileSync(file));
49
+ const before = brainInsights(struct).orphans;
50
+ const byId = new Map(struct.cards.map(c => [c.id, c]));
51
+ const flat = (id) => {
52
+ const c = byId.get(id);
53
+ if (!c) return id;
54
+ const s = c.type === 'container' ? `[area] ${c.title || ''}` : String(c.text || '').replace(/\s+/g, ' ').trim();
55
+ return s.slice(0, 70);
56
+ };
57
+
58
+ const proposals = proposeOrphanAnchorLinks(struct);
59
+ const anchored = proposals.filter(p => p.anchor);
60
+ // The confident subset: anchor edges always; area edges only with --areas.
61
+ // Reversed twins (A anchors B, B anchors A) collapse to ONE edge here so the
62
+ // dry-run list, the drawn count, and the apply receipt all agree exactly.
63
+ const seenPair = new Set();
64
+ const edges = [];
65
+ for (const p of proposals) {
66
+ if (p.anchor) {
67
+ const key = [p.cardId, p.anchor.toId].sort().join('|');
68
+ if (!seenPair.has(key)) {
69
+ seenPair.add(key);
70
+ edges.push({ fromId: p.cardId, toId: p.anchor.toId, relationship: 'relates_to', label: 'auto', why: p.anchor.why });
71
+ }
72
+ }
73
+ if (withAreas && p.areaId) edges.push({ fromId: p.cardId, toId: p.areaId, relationship: 'relates_to', label: 'in area', color: 'rgba(120,120,135,0.45)', width: 1, style: 'dashed', why: 'area container' });
74
+ }
75
+ // An anchor edge between two orphans repairs BOTH endpoints — project on the
76
+ // orphan set, not just the source card.
77
+ const orphanIdSet = new Set(before.map(o => o.id));
78
+ const repairable = new Set();
79
+ for (const e of edges) {
80
+ if (orphanIdSet.has(e.fromId)) repairable.add(e.fromId);
81
+ if (orphanIdSet.has(e.toId)) repairable.add(e.toId);
82
+ }
83
+ const projected = Math.max(0, before.length - repairable.size);
84
+
85
+ if (json && !apply) {
86
+ console.log(JSON.stringify({
87
+ brain: file, dryRun: true, orphans: before.length, confident: anchored.length,
88
+ projectedOrphans: projected,
89
+ edges: edges.map(e => ({ fromId: e.fromId, toId: e.toId, label: e.label, why: e.why })),
90
+ }, null, 2));
91
+ process.exit(0);
92
+ }
93
+
94
+ if (!apply) {
95
+ console.log(`# orphan gardener — ${path.basename(file)} (DRY RUN, nothing written)`);
96
+ console.log('');
97
+ console.log(`Orphans now: ${before.length} live decision/milestone card(s) with no connections.`);
98
+ console.log(`Confident subset: ${anchored.length} card(s) have exactly one unambiguous lexical anchor${withAreas ? `; --areas adds ${proposals.filter(p => p.areaId).length} area-container edge(s)` : ''}.`);
99
+ console.log(`Projected after --apply: ${projected} orphan(s) remain (the rest need brain_connect review or a human).`);
100
+ if (edges.length) {
101
+ console.log('');
102
+ for (const e of edges) console.log(` - ${flat(e.fromId)} → ${flat(e.toId)} (${e.why})`);
103
+ console.log('');
104
+ console.log('Re-run with --apply to draw these. Additive only: no card is archived or rewritten;');
105
+ console.log('a forced restore point is taken first (undo: npx klypix-mcp brain-history).');
106
+ } else {
107
+ console.log('');
108
+ console.log(before.length
109
+ ? 'No edge clears the confidence bar — review the rest with brain_connect (dry-run receipts).'
110
+ : 'Nothing to do — this brain has no orphaned decision/milestone cards.');
111
+ }
112
+ process.exit(0);
113
+ }
114
+
115
+ if (!edges.length) {
116
+ console.log(before.length
117
+ ? `No edge clears the confidence bar — ${before.length} orphan(s) remain for brain_connect review. Nothing written.`
118
+ : 'Nothing to do — this brain has no orphaned decision/milestone cards. Nothing written.');
119
+ process.exit(0);
120
+ }
121
+
122
+ // APPLY — one locked snapshot + read-modify-write. The restore point is taken
123
+ // INSIDE the advisory lock (forced: an additive pass never trips the shrink
124
+ // rule, and the throttle must not skip the one snapshot that makes a whole
125
+ // backfill reversible): taken outside it, a concurrent capture landing between
126
+ // snapshot and write would be silently rolled back by a later restore.
127
+ let snap = null;
128
+ const result = await withAdvisoryWriteLock(brainCaptureLockPath(file), async (locked) => {
129
+ if (!locked) return { ok: false, error: 'brain is busy (another writer holds the capture lock) — retry in a moment; nothing written' };
130
+ snap = snapshotBrain(file, { reason: 'orphan-backfill', force: true });
131
+ const { buffer, added } = await addBrainConnections(fs.readFileSync(file), edges);
132
+ if (!added) return { ok: true, added: 0, after: before.length };
133
+ await atomicWrite(file, buffer, { snapshot: false }); // the forced pre-apply snapshot above is the restore point
134
+ let after = projected;
135
+ try { after = brainInsights((await parseKlypix(buffer)).struct).orphans.length; } catch { /* projected stays the honest fallback */ }
136
+ return { ok: true, added, after };
137
+ });
138
+ if (!result.ok) { console.error(`✗ ${result.error}`); process.exit(1); }
139
+
140
+ // A drawn count below the planned count means the brain changed between the
141
+ // dry-run read and the locked write (a pair already present, an endpoint
142
+ // gone) — slicing the planned list by count would then name the WRONG edges,
143
+ // so the per-edge list is only printed when it is exact.
144
+ const exact = result.added === edges.length;
145
+ // An 'unchanged' skip means the newest restore point already holds these
146
+ // exact bytes — the pass is just as reversible as a fresh snapshot.
147
+ const covered = snap && (snap.saved || snap.skipped === 'unchanged');
148
+ if (json) {
149
+ console.log(JSON.stringify({
150
+ brain: file, dryRun: false, added: result.added,
151
+ orphansBefore: before.length, orphansAfter: result.after,
152
+ restorePoint: covered ? (snap.id || true) : null,
153
+ edges: exact ? edges.map(e => ({ fromId: e.fromId, toId: e.toId, label: e.label, why: e.why })) : null,
154
+ ...(exact ? {} : { edgesNote: `${edges.length - result.added} planned edge(s) were skipped (already present or endpoint missing) — re-run the dry run for the current pair list` }),
155
+ }, null, 2));
156
+ process.exit(0);
157
+ }
158
+ console.log(`✓ Drew ${result.added} additive connection(s). Orphan receipt: ${before.length} → ${result.after} (${Math.max(0, before.length - result.after)} repaired).`);
159
+ console.log(covered
160
+ ? 'Restore point taken first — undo the whole pass: npx klypix-mcp brain-history'
161
+ : 'Restore point: not saved (best-effort) — arrows remain individually removable in KLYPIX.');
162
+ if (exact) for (const e of edges) console.log(` - ${flat(e.fromId)} → ${flat(e.toId)} (${e.why})`);
163
+ else console.log(` (${edges.length - result.added} planned edge(s) were already present or lost an endpoint — re-run the dry run for the current pair list)`);
164
+ process.exit(0);
165
+ } catch (e) {
166
+ console.error(`✗ orphans failed (brain unchanged): ${e?.message || e}`);
167
+ process.exit(1);
168
+ }
@@ -131,6 +131,11 @@ await runVerb('brain-history', './klypix-brain-history.mjs');
131
131
  // the card's bytes to graveyard/ instead of destroying them; this lists, restores
132
132
  // and (permanently) purges them.
133
133
  await runVerb('brain-deleted', './klypix-brain-deleted.mjs');
134
+ // `npx klypix-mcp orphans` — the orphan gardener's backfill: report how many live
135
+ // cards sit outside the graph, link the CONFIDENT subset (one unambiguous lexical
136
+ // anchor each — never a fan-out). Dry-run by default; --apply takes a forced
137
+ // restore point first, so the whole pass is one brain-history restore from undone.
138
+ await runVerb('orphans', './klypix-orphans.mjs');
134
139
  await runVerb('diff', './klypix-diff.mjs');
135
140
  await runVerb('pr-brief', './klypix-pr-brief.mjs');
136
141
 
@@ -868,8 +873,12 @@ server.registerTool('brain_sync', {
868
873
  // marker before rejecting them. A strict transport schema rejected first,
869
874
  // allowing a later result-less completion to bypass that state entirely.
870
875
  results: z.unknown().optional().describe('On phase complete, 1-8 result manifests for stable claim keys. The in-handler versioned validator rejects malformed, empty, unknown-field, or incomparable evidence and retains task scope.'),
876
+ releaseIntent: z.object({
877
+ version: z.string().max(64).describe('The version this session intends to release (e.g. "1.70.0").'),
878
+ ref: z.string().max(200).describe('The git ref (branch or tag) the release will be cut from.'),
879
+ }).optional().describe('Declare EXCLUSIVE intent to prepare a release of this project. The first declarer takes a ~2h lease (refreshed by checkpoints, freed by phase "complete", by expiry, or when the holder session ends); a second declarer gets a structured hard conflict naming the holder, version, and ref. While any lease is active every peer\'s sync gains a "release in preparation" footer line.'),
871
880
  },
872
- }, async ({ project, intent, files, phase, include_context, results }, extra) => {
881
+ }, async ({ project, intent, files, phase, include_context, results, releaseIntent }, extra) => {
873
882
  const totalStartedAt = Date.now();
874
883
  const report = mcpPresence.sync({
875
884
  project,
@@ -877,6 +886,7 @@ server.registerTool('brain_sync', {
877
886
  files,
878
887
  phase,
879
888
  results,
889
+ releaseIntent,
880
890
  deliverMessages: include_context !== false,
881
891
  actionId: extra?.klypixRequestIdentity?.actionId || '',
882
892
  preflight: extra?.klypixBrainSyncPreflight,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.69.0",
3
+ "version": "1.70.0",
4
4
  "description": "Shared project brain and MCP coordination server for multi-agent coding.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",
@@ -44,7 +44,8 @@
44
44
  "klypix-append": "bin/klypix-append.mjs",
45
45
  "klypix-install": "bin/klypix-install.mjs",
46
46
  "klypix-uninstall": "bin/klypix-uninstall.mjs",
47
- "klypix-project-map": "bin/klypix-project-map.mjs"
47
+ "klypix-project-map": "bin/klypix-project-map.mjs",
48
+ "klypix-orphans": "bin/klypix-orphans.mjs"
48
49
  },
49
50
  "main": "index.mjs",
50
51
  "exports": {
@@ -83,7 +84,7 @@
83
84
  "bench": "node bin/klypix-mcp.mjs bench",
84
85
  "test:bench": "node test/bench.mjs",
85
86
  "pretest": "node test/publish-workflow.mjs",
86
- "test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/presence-liveness.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
+ "test": "node test/publish-verdict.mjs && node test/remote-client.mjs && node test/project-graph.mjs && node test/project-map-cli.mjs && node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/runtime-inspector.mjs && node test/codex-hooks.mjs && node test/request-identity.mjs && node test/session-identity-core.mjs && node test/agent-presence.mjs && node test/message-delivery-v3.mjs && node test/claude-message-delivery-v3.mjs && node test/result-reconcile.mjs && node test/evidence-publication-gate.mjs && node test/release-evidence-cli.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/brain-graveyard.mjs && node test/archived-visibility.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/install-version.mjs && node test/context-gateway.mjs && node test/repo-state.mjs && node test/released-tag-guard.mjs && node test/conformance.mjs && node test/brain-doctor.mjs && node test/version-currency.mjs && node test/ship-capture.mjs && node test/lane-message.mjs && node test/brain-quality.mjs && node test/brain-connect-orphans.mjs && node test/orphan-gardener.mjs && node test/brief-and-recall.mjs && node test/layout-cluster.mjs && node test/brain-ask.mjs && node test/retrieval-fusion.mjs && node test/field-report-2026-07-04.mjs && node test/autoprop.mjs && node test/overlay-recency-2026-07-12.mjs && node test/brain-challenge.mjs && node test/brain-lens.mjs && node test/brain-kind.mjs && node test/rule-drafts.mjs && node test/claim-engine.mjs && node test/skill-staleness.mjs && node test/canvas-view.mjs && node test/status-completeness.mjs && node test/semantic-security.mjs && node test/semantic-gate.mjs && node test/memory-runtime.mjs && node test/semantic-cache.mjs && node test/decay-status.mjs && node test/decay-hook.mjs && node test/evidence-anchors.mjs && node test/presence-visibility.mjs && node test/presence-liveness.mjs && node test/observed-scope.mjs && node test/release-lease.mjs && node test/completion-guard.mjs && node test/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.mjs && node test/capture-write-failure.mjs && node test/a2a-smoke.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs",
87
88
  "test:memory": "node test/memory-runtime.mjs",
88
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
89
90
  "runtime": "node bin/klypix-runtime.mjs"
@@ -718,6 +718,16 @@ function normalizeFiles(files) {
718
718
  return out.slice(-20);
719
719
  }
720
720
 
721
+ // Comparison fold for scope coverage checks — mirrors mcp-presence.mjs
722
+ // normalizeFileKey's slash/./-prefix/case fold (duplicated because THAT module
723
+ // imports THIS one). Coverage must match across spellings: a hook observes
724
+ // `src\App.tsx` while brain_sync declared `src/app.tsx`.
725
+ const scopeKey = (file) => String(file || '').replace(/\\/g, '/').replace(/^\.\//, '').trim().toLowerCase();
726
+
727
+ // Cap for the observed-scope lane (automatic scope adoption, 1.70.0). Kept as
728
+ // its own constant so writers and tests agree on the LRU bound.
729
+ export const OBSERVED_FILES_CAP = 20;
730
+
721
731
  export function listActiveSessions({ brainPath, home, now = Date.now() }) {
722
732
  if (!brainPath) return [];
723
733
  const lane = readLane(laneFileFor(brainPath, home));
@@ -736,6 +746,16 @@ export function upsertSession({
736
746
  intentSource = null,
737
747
  files,
738
748
  replaceFiles = false,
749
+ // Automatic scope adoption (1.70.0): paths the HOST reported this session
750
+ // editing (hook PostToolUse / codex tool events) — observations, never
751
+ // declarations. A path already covered by the row's DECLARED scope is
752
+ // ignored (the declaration is the higher-confidence claim); anything else
753
+ // joins `observedFiles` (LRU, cap OBSERVED_FILES_CAP) AND the legacy
754
+ // `files` union so pre-1.70 readers keep their overlap coverage. The
755
+ // `observedFiles` marker is what lets every 1.70+ reader state the
756
+ // observed/declared distinction instead of mistaking a live edit for a
757
+ // declared scope.
758
+ observedFiles,
739
759
  event = null,
740
760
  channel = null,
741
761
  transportStatus = null,
@@ -779,9 +799,51 @@ export function upsertSession({
779
799
  transport[String(channel)] = { status: String(status).slice(0, 40), at: now };
780
800
  channelOwners[String(channel)] = process.pid;
781
801
  }
782
- const mergedFiles = files === undefined
802
+ let mergedFiles = files === undefined
783
803
  ? normalizeFiles(previous.files)
784
804
  : normalizeFiles(replaceFiles ? files : [...(previous.files || []), ...(files || [])]);
805
+ // ── Observed-scope maintenance (automatic scope adoption, 1.70.0) ────────
806
+ // `files` arrivals are DECLARATIONS (brain_sync). A path the declaration
807
+ // now names sheds its observed marker — the claim was upgraded, never
808
+ // duplicated. A declaration NOT covering an observed path leaves the
809
+ // marker alone (replaceFiles/completion clears declared scope only:
810
+ // the 1.69.0 completion guard must never erase what a session was
811
+ // OBSERVED editing — those edits are still real in the worktree).
812
+ const prevObserved = normalizeFiles(previous.observedFiles);
813
+ const declarationKeys = files !== undefined
814
+ ? new Set(normalizeFiles(files).map(scopeKey))
815
+ : null;
816
+ let nextObserved = declarationKeys
817
+ ? prevObserved.filter((file) => !declarationKeys.has(scopeKey(file)))
818
+ : prevObserved;
819
+ if (observedFiles !== undefined) {
820
+ // Declared coverage = files minus observed markers (observed paths ride
821
+ // in `files` too, for pre-1.70 readers — they are not declarations).
822
+ const observedKeys = new Set(nextObserved.map(scopeKey));
823
+ const declaredCovered = new Set(mergedFiles.map(scopeKey).filter((key) => !observedKeys.has(key)));
824
+ for (const file of normalizeFiles(observedFiles)) {
825
+ const key = scopeKey(file);
826
+ if (declaredCovered.has(key)) continue; // declared scope covers it — nothing to adopt
827
+ nextObserved = nextObserved.filter((existing) => scopeKey(existing) !== key);
828
+ nextObserved.push(file); // LRU: a re-observation moves to the tail
829
+ if (!mergedFiles.some((existing) => scopeKey(existing) === key)) mergedFiles.push(file);
830
+ }
831
+ // Cap pressure evicts OBSERVATIONS, never declarations (review-caught,
832
+ // 1.70.0): normalizeFiles' plain slice(-20) let an observation flood push
833
+ // a session's DECLARED files out of files[] — peers silently lost
834
+ // blocking coverage on declared work, and a later re-edit of the evicted
835
+ // path re-entered it as observed, rendering an owned scope as
836
+ // `*`-unconfirmed. Declarations keep the tail (slice keeps newest); the
837
+ // observations fill whatever room remains, oldest evicted first.
838
+ if (mergedFiles.length > 20) {
839
+ const observedKeySet = new Set(nextObserved.map(scopeKey));
840
+ const declared = mergedFiles.filter((file) => !observedKeySet.has(scopeKey(file)));
841
+ const observed = mergedFiles.filter((file) => observedKeySet.has(scopeKey(file)));
842
+ mergedFiles = [...observed.slice(-Math.max(0, 20 - declared.length)), ...declared];
843
+ }
844
+ mergedFiles = normalizeFiles(mergedFiles);
845
+ }
846
+ nextObserved = nextObserved.slice(-OBSERVED_FILES_CAP);
785
847
  // Intent freshness is its OWN timestamp: lastSeen is refreshed by heartbeats
786
848
  // that never touch intent, so without intentAt a 100-minute-old intent renders
787
849
  // under "active just now" (2026-07-29 audit). Stamped only when the intent
@@ -816,6 +878,11 @@ export function upsertSession({
816
878
  ...(intentChanged ? { intentAt: now, intentSource: intentSource || 'declared' }
817
879
  : (previous.intentAt ? { intentAt: previous.intentAt, intentSource: previous.intentSource || null } : {})),
818
880
  files: mergedFiles,
881
+ // ADDITIVE observed lane: written only for rows that ever carried it, so
882
+ // pre-1.70 rows stay byte-stable through a touch that changes nothing.
883
+ ...(nextObserved.length || previous.observedFiles !== undefined
884
+ ? { observedFiles: nextObserved }
885
+ : {}),
819
886
  event: event ?? previous.event ?? null,
820
887
  ...(activityEvent
821
888
  ? { activityAt: now, activityKind: activityEvent }
@@ -903,6 +970,13 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
903
970
  const existingIdx = sessions.findIndex((session) => session.id === row.id);
904
971
  if (existingIdx >= 0 && sessions[existingIdx].via !== 'cloud') continue; // local row wins
905
972
  const previous = existingIdx >= 0 ? sessions[existingIdx] : {};
973
+ // REPLACE semantics, like `files` below: the frame is a full snapshot of
974
+ // the sender's row, so an empty/absent observed lane must CLEAR the
975
+ // mirror. The `...previous` spread otherwise pinned a completed remote
976
+ // task's stale markers to the local mirror forever (review-caught
977
+ // 1.70.0) — remote rows carry no completedAt, so no local gate could
978
+ // ever retire them.
979
+ const remoteObserved = normalizeFiles(row.observedFiles).slice(-OBSERVED_FILES_CAP);
906
980
  const next = {
907
981
  ...previous,
908
982
  id: String(row.id),
@@ -914,6 +988,10 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
914
988
  // pre-guard build can relay junk intent — never mirror it verbatim.
915
989
  intent: looksMachineTurn(row.intent) ? String(previous.intent || '') : String(row.intent || '').slice(0, 160),
916
990
  files: normalizeFiles(row.files),
991
+ // Observed/declared distinction crosses machines too (additive — an old
992
+ // sender simply never populates it; see remoteObserved above for the
993
+ // replace-not-merge rule).
994
+ ...(remoteObserved.length ? { observedFiles: remoteObserved } : {}),
917
995
  machine: String(row.machine),
918
996
  host: row.host ?? previous.host ?? null,
919
997
  via: 'cloud',
@@ -924,6 +1002,9 @@ export function upsertRemoteSessions({ brainPath, rows, machineId = MACHINE_ID,
924
1002
  startedAt: previous.startedAt || now,
925
1003
  lastSeen: now,
926
1004
  };
1005
+ // The `...previous` spread carried any stale marker into `next`; an empty
1006
+ // snapshot clears it (see the replace-not-merge rule above).
1007
+ if (!remoteObserved.length) delete next.observedFiles;
927
1008
  if (existingIdx >= 0) sessions[existingIdx] = next;
928
1009
  else sessions.push(next);
929
1010
  }
@@ -1126,6 +1207,229 @@ export function endSession({ brainPath, id, home, now = Date.now(), expectedPid
1126
1207
  }
1127
1208
  }
1128
1209
 
1210
+ // ── Release lease (1.70.0 measured wave) ────────────────────────────────────
1211
+ // One EXCLUSIVE per-project release-preparation record in the same lane file
1212
+ // every presence reader already parses. First declarer takes it; a second
1213
+ // declarer gets a hard conflict naming the holder+version+ref. The lease is
1214
+ // bounded three ways, all reusing the lane's existing liveness machinery:
1215
+ // • TTL (~2h) refreshed by the holder's checkpoints — a stalled release
1216
+ // cannot squat on the project forever;
1217
+ // • freed explicitly when the holder's task truly completes;
1218
+ // • freed implicitly when the holder stops being a live lane session
1219
+ // (TTL pruning + dead-host sweep, via pruneSessions) — a crashed holder
1220
+ // never blocks the next release engineer.
1221
+ // The stored record is additive lane state: every existing mutator spreads
1222
+ // `...data` on write, so the key survives session/message maintenance, and
1223
+ // old readers simply ignore it.
1224
+ export const RELEASE_LEASE_TTL_MS = 2 * 60 * 60 * 1000;
1225
+
1226
+ const releaseLeaseVersionKey = (value) => String(value || '').trim().replace(/^v/i, '').slice(0, 64);
1227
+ const releaseLeaseRefKey = (value) => String(value || '').trim().slice(0, 200);
1228
+
1229
+ function normalizeReleaseLease(raw) {
1230
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null;
1231
+ const holderId = recipientKey(raw.holderId);
1232
+ const version = releaseLeaseVersionKey(raw.version);
1233
+ const ref = releaseLeaseRefKey(raw.ref);
1234
+ const takenAt = Number(raw.takenAt || 0);
1235
+ const refreshedAt = Number(raw.refreshedAt || raw.takenAt || 0);
1236
+ if (!holderId || !version || !ref || !takenAt || !refreshedAt) return null;
1237
+ const ttlMs = Math.max(60_000, Number(raw.ttlMs) || RELEASE_LEASE_TTL_MS);
1238
+ return {
1239
+ holderId,
1240
+ holderClient: String(raw.holderClient || 'unknown').slice(0, 40),
1241
+ version,
1242
+ ref,
1243
+ takenAt,
1244
+ refreshedAt,
1245
+ ttlMs,
1246
+ expiresAt: refreshedAt + ttlMs,
1247
+ };
1248
+ }
1249
+
1250
+ // The holder may have rotated ids since taking the lease — match against the
1251
+ // same identity set message routing uses (id, logical id, aliases).
1252
+ const sessionOwnsLease = (session, holderId) => {
1253
+ const key = recipientKey(holderId);
1254
+ return Boolean(key && session && (recipientKey(session.id) === key
1255
+ || recipientKey(session.logicalSessionId) === key
1256
+ || normalizeAliases(session.aliases).includes(key)));
1257
+ };
1258
+
1259
+ // Verdict for a stored lease against pre-PRUNED sessions (the caller must pass
1260
+ // pruneSessions output so TTL pruning and the dead-host sweep both apply):
1261
+ // { status: 'active' | 'expired' | 'dead-holder', lease } or null (no/invalid
1262
+ // record). Only 'active' binds anyone; the other verdicts are reclaimable.
1263
+ export function releaseLeaseVerdict(rawLease, sessions, now = Date.now()) {
1264
+ const lease = normalizeReleaseLease(rawLease);
1265
+ if (!lease) return null;
1266
+ if (now >= lease.expiresAt) return { status: 'expired', lease };
1267
+ const alive = (Array.isArray(sessions) ? sessions : [])
1268
+ .some((session) => sessionOwnsLease(session, lease.holderId));
1269
+ return { status: alive ? 'active' : 'dead-holder', lease };
1270
+ }
1271
+
1272
+ // Read-only: the ACTIVE lease or null. Lock-free like every other read surface
1273
+ // (atomic lane writes guarantee a parseable snapshot).
1274
+ export function readReleaseLease({ brainPath, home, now = Date.now() } = {}) {
1275
+ if (!brainPath) return null;
1276
+ const lane = readLane(laneFileFor(brainPath, home));
1277
+ const verdict = releaseLeaseVerdict(lane.releaseLease, pruneSessions(lane.sessions, now), now);
1278
+ return verdict?.status === 'active' ? verdict.lease : null;
1279
+ }
1280
+
1281
+ // Shared lock/read/verdict/write skeleton for the three mutators. `mutate`
1282
+ // returns { write, lease, outcome }: lease === null deletes the record,
1283
+ // undefined leaves it untouched. Sessions/messages bytes are preserved as-is —
1284
+ // a lease mutation must never race presence maintenance into data loss.
1285
+ function mutateReleaseLease({ brainPath, home, now, mutate }) {
1286
+ if (!brainPath) return { ok: false, status: 'no-brain' };
1287
+ const laneFile = laneFileFor(brainPath, home);
1288
+ const lockFile = laneFile + '.lock';
1289
+ if (!acquireLock(lockFile)) return { ok: false, status: 'lane-locked' };
1290
+ try {
1291
+ const laneRead = readMutableLane(laneFile);
1292
+ if (!laneRead.ok) return { ok: false, status: laneRead.reason };
1293
+ const data = laneRead.data;
1294
+ const sessions = pruneSessions(data.sessions, now);
1295
+ const verdict = releaseLeaseVerdict(data.releaseLease, sessions, now);
1296
+ const result = mutate({ data, sessions, verdict });
1297
+ if (result.write) {
1298
+ const next = { ...data };
1299
+ if (result.lease === null) delete next.releaseLease;
1300
+ else if (result.lease !== undefined) next.releaseLease = result.lease;
1301
+ fs.mkdirSync(path.dirname(laneFile), { recursive: true });
1302
+ writeLaneFileAtomic(laneFile, JSON.stringify(next));
1303
+ }
1304
+ return result.outcome;
1305
+ } finally {
1306
+ releaseLock(lockFile);
1307
+ }
1308
+ }
1309
+
1310
+ const leaseHeldBy = (verdict, sessions, callerId) => {
1311
+ if (!verdict) return false;
1312
+ const me = sessions.find((session) => recipientKey(session.id) === callerId) || null;
1313
+ return recipientKey(verdict.lease.holderId) === callerId
1314
+ || sessionOwnsLease(me, verdict.lease.holderId);
1315
+ };
1316
+
1317
+ // First-declarer-wins exclusive take. Same holder re-declaring refreshes (and
1318
+ // may retarget version/ref); an expired or dead-holder record is reclaimed with
1319
+ // the reason surfaced; a live foreign holder is a hard conflict carrying the
1320
+ // full holder record so the caller can name it.
1321
+ export function declareReleaseLease({
1322
+ brainPath,
1323
+ sessionId,
1324
+ version,
1325
+ ref,
1326
+ client = 'unknown',
1327
+ home,
1328
+ now = Date.now(),
1329
+ ttlMs = RELEASE_LEASE_TTL_MS,
1330
+ }) {
1331
+ const holderId = recipientKey(sessionId);
1332
+ const nextVersion = releaseLeaseVersionKey(version);
1333
+ const nextRef = releaseLeaseRefKey(ref);
1334
+ if (!holderId || !nextVersion || !nextRef) return { ok: false, status: 'invalid-release-intent' };
1335
+ return mutateReleaseLease({ brainPath, home, now, mutate: ({ sessions, verdict }) => {
1336
+ const mine = leaseHeldBy(verdict, sessions, holderId);
1337
+ if (verdict?.status === 'active' && !mine) {
1338
+ return { write: false, outcome: { ok: false, status: 'conflict', holder: verdict.lease } };
1339
+ }
1340
+ const stillMine = mine && verdict?.status === 'active';
1341
+ const stored = {
1342
+ schemaVersion: 1,
1343
+ holderId,
1344
+ holderClient: String(client || 'unknown').slice(0, 40),
1345
+ version: nextVersion,
1346
+ ref: nextRef,
1347
+ takenAt: stillMine ? verdict.lease.takenAt : now,
1348
+ refreshedAt: now,
1349
+ ttlMs: Math.max(60_000, Number(ttlMs) || RELEASE_LEASE_TTL_MS),
1350
+ };
1351
+ const reclaimed = !stillMine && verdict ? verdict.status : null;
1352
+ return {
1353
+ write: true,
1354
+ lease: stored,
1355
+ outcome: {
1356
+ ok: true,
1357
+ status: stillMine ? 'refreshed' : 'taken',
1358
+ ...(reclaimed && reclaimed !== 'active' ? { reclaimed } : {}),
1359
+ lease: normalizeReleaseLease(stored),
1360
+ },
1361
+ };
1362
+ } });
1363
+ }
1364
+
1365
+ // Checkpoint refresh: only the live holder advances refreshedAt. A stale
1366
+ // record (expired / dead holder) found by ANY caller is pruned so readers stop
1367
+ // parsing it — the lane's dead-session sweep analogue for this key.
1368
+ export function refreshReleaseLease({ brainPath, sessionId, home, now = Date.now() }) {
1369
+ const callerId = recipientKey(sessionId);
1370
+ if (!callerId) return { ok: false, status: 'no-session' };
1371
+ // Lock-free fast path: this runs on EVERY non-completing sync of every
1372
+ // session, and almost always there is no lease at all. Skipping the lane
1373
+ // lock then keeps lease bookkeeping from doubling lock traffic project-wide.
1374
+ // Race-safe: a lease declared concurrently is simply refreshed on the
1375
+ // caller's NEXT sync, and a just-declared lease is by definition fresh.
1376
+ if (brainPath && readLane(laneFileFor(brainPath, home)).releaseLease === undefined) {
1377
+ return { ok: true, status: 'no-lease' };
1378
+ }
1379
+ return mutateReleaseLease({ brainPath, home, now, mutate: ({ sessions, verdict }) => {
1380
+ if (!verdict) return { write: false, outcome: { ok: true, status: 'no-lease' } };
1381
+ const mine = leaseHeldBy(verdict, sessions, callerId);
1382
+ if (verdict.status !== 'active') {
1383
+ return {
1384
+ write: true,
1385
+ lease: null,
1386
+ outcome: { ok: true, status: mine ? 'lease-lost' : 'stale-pruned', reason: verdict.status },
1387
+ };
1388
+ }
1389
+ if (!mine) return { write: false, outcome: { ok: true, status: 'not-holder' } };
1390
+ const stored = {
1391
+ schemaVersion: 1,
1392
+ holderId: verdict.lease.holderId,
1393
+ holderClient: verdict.lease.holderClient,
1394
+ version: verdict.lease.version,
1395
+ ref: verdict.lease.ref,
1396
+ takenAt: verdict.lease.takenAt,
1397
+ refreshedAt: now,
1398
+ ttlMs: verdict.lease.ttlMs,
1399
+ };
1400
+ return {
1401
+ write: true,
1402
+ lease: stored,
1403
+ outcome: { ok: true, status: 'refreshed', lease: normalizeReleaseLease(stored) },
1404
+ };
1405
+ } });
1406
+ }
1407
+
1408
+ // Explicit free — the holder's real task completion. A non-holder can never
1409
+ // free a LIVE peer's lease, but anyone may clear a stale record.
1410
+ export function freeReleaseLease({ brainPath, sessionId, home, now = Date.now() }) {
1411
+ const callerId = recipientKey(sessionId);
1412
+ if (!callerId) return { ok: false, status: 'no-session' };
1413
+ // Same lock-free fast path as refreshReleaseLease: every real completion
1414
+ // calls this, and almost none of them hold a lease. Race-safe for the same
1415
+ // reason — a record this snapshot cannot see is one this caller cannot own.
1416
+ if (brainPath && readLane(laneFileFor(brainPath, home)).releaseLease === undefined) {
1417
+ return { ok: true, status: 'no-lease' };
1418
+ }
1419
+ return mutateReleaseLease({ brainPath, home, now, mutate: ({ sessions, verdict }) => {
1420
+ if (!verdict) return { write: false, outcome: { ok: true, status: 'no-lease' } };
1421
+ const mine = leaseHeldBy(verdict, sessions, callerId);
1422
+ if (!mine && verdict.status === 'active') {
1423
+ return { write: false, outcome: { ok: true, status: 'not-holder' } };
1424
+ }
1425
+ return {
1426
+ write: true,
1427
+ lease: null,
1428
+ outcome: { ok: true, status: mine ? 'released' : 'stale-pruned', lease: verdict.lease },
1429
+ };
1430
+ } });
1431
+ }
1432
+
1129
1433
  // A long-lived MCP worker can observe SessionEnd(A) and then receive the first
1130
1434
  // request for a brand-new Codex thread B. That is rotation, not a rekey: A's
1131
1435
  // tombstone, scope, message audience/receipts, and authorship must remain bound