klypix-mcp 1.88.0 → 1.89.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/FORMAT.md CHANGED
@@ -17,6 +17,9 @@ your.klypix (a ZIP archive)
17
17
  ├── canvas.json spatial layout: order, positions, connections, lines, strokes, view, settings
18
18
  ├── items/
19
19
  │ └── <shard>/<id>.json one file per item (content only; geometry lives in canvas.json)
20
+ ├── graveyard.json Deleted cards index — absent until the first delete (see below)
21
+ ├── graveyard/
22
+ │ └── <shard>/<id>.json a deleted card's item JSON, or a receipt placeholder
20
23
  └── assets/ embedded binaries — any non-directory entry here is an asset
21
24
  ├── images/<shard>/<sha256>.<ext>
22
25
  ├── files/<shard>/<sha256>.bin
@@ -198,6 +201,50 @@ instead of an asset. New writers use `assetId` and leave `src` empty.
198
201
  files — cards and arrows. Embedding binaries is done by the KLYPIX app when you drop
199
202
  a file onto a canvas.
200
203
 
204
+ ## Deleted cards — `graveyard.json` + `graveyard/`
205
+
206
+ A card deleted from a brain is moved, not destroyed. Its item file goes to
207
+ `graveyard/<shard>/<id>.json` (same sharding as `items/`), and an entry for it goes
208
+ into `graveyard.json`:
209
+
210
+ ```json
211
+ { "version": 1, "entries": {
212
+ "txt_hto5hb3r_0_0": {
213
+ "rid": "r_3f1c…",
214
+ "deletedAt": 1790676275950,
215
+ "deletedBy": "user",
216
+ "deletion": { "initiator": "user", "cause": "history-restore", "source": "klypix-mcp", "confidence": "explicit" },
217
+ "area": "Work", "parentId": "ctn_…", "pos": { "x": 96, "y": 140, "w": 280, "h": 47 },
218
+ "preview": "first words of the card…"
219
+ } } }
220
+ ```
221
+
222
+ Nothing under `graveyard/` is reachable from `canvas.json`'s `order`, so a deleted card
223
+ never renders and is never searched or counted, and a card is never live and in the
224
+ bin at once. Writers add fields to an entry, never rename them; `deletedBy` is the flat
225
+ copy of `deletion.initiator` kept for older readers.
226
+
227
+ An entry is one of three kinds:
228
+
229
+ | Kind | How to recognise it | Body file |
230
+ |---|---|---|
231
+ | deleted card | no `purged` | the card's item JSON, verbatim — restorable |
232
+ | permanently deleted | `purged: true`, no `restoredAs` | the placeholder `{"type":"text","content":"","purged":true}` |
233
+ | restored | `purged: true` and `restoredAs: "<id>"` | the same placeholder; the card lives on at `restoredAs` |
234
+
235
+ The two receipts carry no content, and they are the reason **the bin must travel with
236
+ the file**. A tool that rewrites a `.klypix` has to carry `graveyard.json` and
237
+ `graveyard/` through, or merge them. Dropping them makes the next merge with an older
238
+ copy of the brain read the deletion as "never happened" and bring the card back — a
239
+ permanently deleted one included.
240
+
241
+ Two id rules follow from the same idea. A card brought back, from the bin or from a
242
+ restore point, should get a new id derived from its deletion (`<id>__r_<12 hex>`, as
243
+ this package's restores do), never the id it was deleted with: other copies still hold
244
+ that deletion and would delete it again. And a card that Arrange collapses as a
245
+ duplicate is buried like any other deletion, with `mergedInto` naming the card that
246
+ absorbed it.
247
+
201
248
  ## Connections, links, tags
202
249
 
203
250
  - **Arrows:** `canvas.json.connections` (`fromId`/`toId`, optional
package/README.md CHANGED
@@ -133,7 +133,7 @@ npx klypix-mcp install
133
133
 
134
134
  One command for supported editors detected on this machine. It finds the project root (walking up,
135
135
  so running it from `src/` is fine), gives the project a brain if it doesn't have one, wires the
136
- agent tools you actually have installed, registers the lossless `.klypix` merge driver if it's a
136
+ agent tools you actually have installed, registers the card-level `.klypix` merge driver if it's a
137
137
  git repo, and then **proves the result** before it exits:
138
138
 
139
139
  ```text
@@ -141,7 +141,7 @@ git repo, and then **proves the result** before it exits:
141
141
  brain created brain.klypix — a starter brain, ready for its first decision
142
142
  editors Claude Code · Cursor · Codex · Gemini CLI · Antigravity · VS Code
143
143
  wired 9 file(s) · 9 updated (skipped 5 for tools you don't have)
144
- git lossless .klypix merge driver registered
144
+ git .klypix merge driver registered
145
145
  verified ✓ 22 tools reachable via .mcp.json (892ms)
146
146
  ```
147
147
 
@@ -541,9 +541,18 @@ That registers a merge driver for `*.klypix` (a per-machine git config line plus
541
541
  rule you commit) and provisions the engine it needs. When two people change the brain and one
542
542
  pulls, git calls the engine instead of stopping: new cards from both sides are kept, a card only
543
543
  one side edited takes that edit, and a card edited differently on both sides keeps **both**
544
- versions — the second as a linked twin, never a silent overwrite. Before returning, the merge
545
- asserts it still contains every surviving card from both sides and refuses rather than hand back a
546
- result that lost one.
544
+ versions — the second as a linked twin, never a silent overwrite. (One exception to "takes that
545
+ edit": a card is never changed to mean exactly what its own conflict twin beside it already means
546
+ (the whole card, not only its words) — both versions stay as they are and the driver's summary line
547
+ says so. Delete the one you do not want.) Deletions travel too: a deleted
548
+ card leaves a receipt in the brain's Deleted cards, and the driver merges those three-way, so a
549
+ card deleted — or permanently deleted — on one branch stays out instead of coming back from the
550
+ other, unless the other branch edited it. That edit comes back as a new card when the deleting
551
+ branch recorded the delete (a receipt in its Deleted cards); when it did not, the edited card
552
+ simply stays. A permanently deleted card stays out even then: the driver's summary line counts the
553
+ edits it dropped, and they remain in that branch's git history. Before returning, the merge asserts
554
+ it still contains every surviving card from both sides and refuses rather than hand back a result
555
+ that lost one.
547
556
 
548
557
  The honest boundary: a machine that has not run `git-driver install` simply gets the old binary
549
558
  conflict — safe degradation, not corruption — and git keeps both parents of every merge, so even a
@@ -570,7 +579,12 @@ was judged worse — but it is a real limit, not a guarantee.
570
579
 
571
580
  ### Restore points
572
581
 
573
- Merging, tidying and gardening are lossless by contract. What none of them can undo is a
582
+ Merging, tidying and gardening are built to keep every card nobody deleted (one deliberate exception: a
583
+ card deleted permanently also leaves the other copies of the brain when they sync, an edited copy
584
+ included, and the merge reports that edit). Deleting permanently is not a guarantee that the text is
585
+ gone everywhere: a copy of the card that someone restored on another machine at about the same time,
586
+ or resized or edited after restoring it, can stay there and has to be deleted there too; and restore
587
+ points and git history keep what they already held. What none of them can undo is a
574
588
  *deliberate-looking* deletion: you select a dozen cards, delete them, and save. That is not a bug
575
589
  to prevent — a brain has to stay correctable, and an uncorrectable memory is worse than none — but
576
590
  it deserves a way back, because the brain is **co-owned**: hooks, the MCP server, commit capture
@@ -584,6 +598,12 @@ npx klypix-mcp brain-history list # age, card count, delta against the
584
598
  npx klypix-mcp brain-history restore <id> # and this is itself undoable
585
599
  ```
586
600
 
601
+ A restore is a merge into the brain as it is now, not a copy over the file, and it prints what it
602
+ changed. The point's cards come back — a card deleted since returns under a new id, so every other
603
+ copy of the brain agrees the old one was deleted — and cards written after the point move to
604
+ Deleted cards (`npx klypix-mcp brain-deleted list`), where each one can be restored. Permanently
605
+ deleted cards stay out unless you pass `--include-purged`.
606
+
587
607
  They live under `~/.claude/project-brain/history/`, never beside the brain — nothing lands in git,
588
608
  in the merge driver's path, or in your diffs, and they survive deletion of the `.klypix` file
589
609
  itself. Routine writes are deduped and throttled to one a minute; a write that **removes cards** is
@@ -608,7 +628,7 @@ The MCP verbs below are what agents call. These are what **you** call:
608
628
  | `npx klypix-mcp doctor` | One verdict: version, hosts, live sessions, tool count, drift. Exits non-zero — usable as a CI gate |
609
629
  | `npx klypix-mcp runtime` | Passive per-connection process/RAM attribution (`--json`, optional `--watch seconds`); never kills or deduplicates |
610
630
  | `npx klypix-mcp conformance` | Launch two real MCP clients against this build and verify coordination behaviour |
611
- | `npx klypix-mcp git-driver` | Register the lossless `.klypix` merge driver for a repo (`status` to check) |
631
+ | `npx klypix-mcp git-driver` | Register the card-level `.klypix` merge driver for a repo (`status` to check) |
612
632
  | `npx klypix-mcp git-hook` | Wire the agent-neutral commit-capture hook: rationale-bearing `feat`/`fix`/`perf` commits from any agent, branch, or worktree card into the brain at commit time (`install`/`remove`/`status`; sessions auto-install it where the hook slots are free) |
613
633
  | `npx klypix-mcp brain-history` | Restore points for this brain — `list` them, `restore <id>` one. Written automatically before every brain write, kept machine-local, and never throttled away for a write that removes cards |
614
634
  | `npx klypix-mcp diff [ref]` | Card-level brain diff against a git ref, as markdown |
@@ -2,10 +2,21 @@
2
2
  // `klypix-mcp brain-deleted [list|restore <id…>|purge] [--brain <path>]`
3
3
  // The recycle bin for a brain: cards a human deleted are kept recoverable
4
4
  // instead of destroyed. Standalone: node bin/klypix-brain-deleted.mjs <args>
5
+ //
6
+ // Receipts (1.89): "Delete permanently" keeps a content-free receipt in the bin
7
+ // so every other copy of the brain drops the card too (see brain-graveyard.mjs).
8
+ // Receipts are not deleted cards — `list` hides them, restore refuses them, and
9
+ // `list <id>` names what happened to one.
10
+ //
11
+ // Every write re-reads the brain INSIDE the capture lock and refuses when the
12
+ // lock is held: reading outside it and writing later would silently roll back
13
+ // a capture (or a desktop save) that landed in between.
5
14
  import fs from 'fs';
6
15
  import path from 'path';
7
16
  import { listGraveyard, purgeGraveyard, readGraveyardCard, restoreFromGraveyard, DEFAULT_RETENTION_DAYS } from '../src/brain-graveyard.mjs';
8
17
  import { atomicWrite } from '../src/klypix-format.mjs';
18
+ import { brainCaptureLockPath, withAdvisoryWriteLock } from '../src/brain-write-lock.mjs';
19
+ import { snapshotBrain } from '../src/brain-history.mjs';
9
20
 
10
21
  const argv = process.argv.slice(2).filter((a) => a !== 'brain-deleted');
11
22
  const action = ['list', 'restore', 'purge'].includes(argv[0]) ? argv.shift() : 'list';
@@ -17,7 +28,6 @@ const all = argv.includes('--all');
17
28
  const ids = argv.filter((a) => !a.startsWith('-'));
18
29
 
19
30
  if (!fs.existsSync(brainPath)) { console.error(`No brain at ${brainPath}.`); process.exit(1); }
20
- const buf = fs.readFileSync(brainPath);
21
31
 
22
32
  const ago = (ts) => {
23
33
  const m = Math.max(0, Math.round((Date.now() - Number(ts || 0)) / 60000));
@@ -27,8 +37,40 @@ const ago = (ts) => {
27
37
  return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
28
38
  };
29
39
 
40
+ // Read-modify-write under the capture lock. `mutate(buf)` returns
41
+ // { buffer?, ...report }; no buffer means nothing to write.
42
+ async function lockedWrite(reason, mutate, { forceSnapshot = false } = {}) {
43
+ return withAdvisoryWriteLock(brainCaptureLockPath(brainPath), async (locked) => {
44
+ if (!locked) return { busy: true };
45
+ const report = await mutate(fs.readFileSync(brainPath));
46
+ if (!report.buffer) return report;
47
+ if (forceSnapshot) {
48
+ // The one irreversible action gets a restore point that no throttle may
49
+ // skip, taken inside the lock so it holds exactly the bytes replaced.
50
+ try { snapshotBrain(brainPath, { reason, force: true }); } catch { /* best-effort by contract */ }
51
+ await atomicWrite(brainPath, report.buffer, { reason, snapshot: false });
52
+ } else {
53
+ await atomicWrite(brainPath, report.buffer, { reason });
54
+ }
55
+ return report;
56
+ });
57
+ }
58
+ const refuseBusy = () => {
59
+ console.error('The brain is busy (another writer holds the capture lock) — retry in a moment. Nothing written.');
60
+ process.exit(1);
61
+ };
62
+
30
63
  if (action === 'list') {
31
- const entries = await listGraveyard(buf);
64
+ const buf = fs.readFileSync(brainPath);
65
+ const everything = await listGraveyard(buf, { receipts: 'include' });
66
+ // A receipt asked for by id: say what happened to it rather than "not found".
67
+ for (const e of everything) {
68
+ if (!ids.includes(e.id) || e.kind === 'deleted') continue;
69
+ console.log(e.kind === 'restored'
70
+ ? ` ${e.id} restored as ${e.restoredAs} ${ago(e.restoredAt || e.deletedAt)} — it is live under that id`
71
+ : ` ${e.id} permanently deleted ${ago(e.purgedAt || e.deletedAt)} — its text is gone from this file (an image or file attached to it is not)`);
72
+ }
73
+ const entries = everything.filter((e) => e.kind === 'deleted');
32
74
  if (!entries.length) {
33
75
  console.log(`Nothing deleted from ${path.basename(brainPath)}.`);
34
76
  console.log('Cards you delete from a brain are kept here, recoverable, instead of destroyed.');
@@ -53,15 +95,21 @@ if (action === 'list') {
53
95
 
54
96
  if (action === 'restore') {
55
97
  if (!ids.length) { console.error('Usage: brain-deleted restore <id…> (ids from `brain-deleted list`)'); process.exit(2); }
56
- const res = await restoreFromGraveyard(buf, ids);
98
+ const res = await lockedWrite('graveyard-restore', async (buf) => {
99
+ const r = await restoreFromGraveyard(buf, ids);
100
+ return r.restored.length ? r : { restored: [], skipped: r.skipped };
101
+ });
102
+ if (res.busy) refuseBusy();
57
103
  if (!res.restored.length) {
58
104
  for (const s of res.skipped) console.error(` ${s.id}: ${s.reason}`);
59
105
  console.error('Nothing restored.');
60
106
  process.exit(1);
61
107
  }
62
- await atomicWrite(brainPath, res.buffer, { reason: 'graveyard-restore' });
63
108
  for (const r of res.restored) {
64
- console.log(`Restored ${r.id}${r.reparented ? ' (its container is gone — placed at the canvas root)' : ''}`);
109
+ const as = r.restoredAs && r.restoredAs !== r.id ? ` as ${r.restoredAs}` : '';
110
+ console.log(r.already
111
+ ? `${r.id} is already back${as}`
112
+ : `Restored ${r.id}${as}${r.reparented ? ' (its container is gone — placed at the canvas root)' : ''}`);
65
113
  }
66
114
  for (const s of res.skipped) console.log(`Skipped ${s.id}: ${s.reason}`);
67
115
  console.log('If the app has this brain OPEN, close and reopen the tab so it sees the restored card.');
@@ -76,12 +124,19 @@ if (!ids.length && !all && !olderThan) {
76
124
  }
77
125
  const days = olderThan ? Number(String(olderThan).replace(/d$/i, '')) : null;
78
126
  if (olderThan && !Number.isFinite(days)) { console.error(`--older-than expects days, e.g. --older-than ${DEFAULT_RETENTION_DAYS}d`); process.exit(2); }
79
- const res = await purgeGraveyard(buf, {
80
- ids: ids.length ? ids : (all ? (await listGraveyard(buf)).map((e) => e.id) : null),
81
- olderThanDays: ids.length || all ? null : days,
82
- });
127
+ const res = await lockedWrite('graveyard-purge', async (buf) => {
128
+ const r = await purgeGraveyard(buf, {
129
+ // --all means every deleted card; receipts hold nothing left to purge.
130
+ ids: ids.length ? ids : (all ? (await listGraveyard(buf, { receipts: 'hide' })).map((e) => e.id) : null),
131
+ olderThanDays: ids.length || all ? null : days,
132
+ });
133
+ return r.purged.length ? r : { purged: [] };
134
+ }, { forceSnapshot: true });
135
+ if (res.busy) refuseBusy();
83
136
  if (!res.purged.length) { console.log('Nothing matched — nothing purged.'); process.exit(0); }
84
- await atomicWrite(brainPath, res.buffer, { reason: 'graveyard-purge' });
85
137
  console.log(`Purged ${res.purged.length} deleted card(s) permanently from ${path.basename(brainPath)}.`);
138
+ console.log('The delete itself is kept as a receipt with no content, so copies that sync with this version or later drop the card too.');
139
+ console.log('An edit of it that a merge kept as a separate card elsewhere is not removed: purge that card as well.');
140
+ console.log('Not its attachments: an image or file a purged card held stays in the brain\'s assets, in this file and every copy.');
86
141
  console.log('Note: this removes them from the file, not from git history — a secret committed earlier is still in past commits.');
87
- console.log(`The pre-purge state is a restore point: npx klypix-mcp brain-history list --brain "${brainPath}"`);
142
+ console.log(`The pre-purge state is a restore point, and the only undo: npx klypix-mcp brain-history list --brain "${brainPath}"`);
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- // `klypix-mcp brain-history [list|restore <id>|prune] [--brain <path>]`
2
+ // `klypix-mcp brain-history [list|restore <id> [--include-purged]|prune] [--brain <path>]`
3
3
  // The human surface for brain restore points. Protection nobody can see is
4
4
  // protection nobody trusts, so `list` is the default and it says plainly what
5
- // each point would give back.
5
+ // each point would give back, and `restore` says what it changed.
6
6
  import fs from 'fs';
7
7
  import path from 'path';
8
8
  import { listBrainHistory, pruneBrainHistory, restoreBrainSnapshot, historyDirFor } from '../src/brain-history.mjs';
@@ -11,6 +11,7 @@ const argv = process.argv.slice(2).filter((a) => a !== 'brain-history');
11
11
  const action = ['list', 'restore', 'prune'].includes(argv[0]) ? argv.shift() : 'list';
12
12
  const brainIdx = argv.indexOf('--brain');
13
13
  const brainPath = path.resolve(brainIdx >= 0 && argv[brainIdx + 1] ? argv.splice(brainIdx, 2)[1] : 'brain.klypix');
14
+ const includePurged = argv.includes('--include-purged');
14
15
  const positional = argv.filter((a) => !a.startsWith('-'));
15
16
 
16
17
  const ago = (ts) => {
@@ -50,7 +51,8 @@ if (action === 'list') {
50
51
  console.log(` ${e.id} ${ago(e.ts).padEnd(9)} ${String(cards ?? '?').padStart(5)} cards ${kb(e.bytes).padStart(8)}${e.reason ? ` [${e.reason}]` : ''}${deltaText}`);
51
52
  }
52
53
  console.log(`\nRestore: npx klypix-mcp brain-history restore <id> --brain "${brainPath}"`);
53
- console.log('Restoring snapshots the current file first, so it is itself undoable.');
54
+ console.log("A restore brings that point's cards back and moves cards added since to Deleted cards.");
55
+ console.log('It snapshots the current file first, so it is itself undoable.');
54
56
  process.exit(0);
55
57
  }
56
58
 
@@ -63,13 +65,65 @@ if (action === 'prune') {
63
65
  // restore
64
66
  const id = positional[0];
65
67
  if (!id) {
66
- console.error('Usage: npx klypix-mcp brain-history restore <id> [--brain <path>]');
68
+ console.error('Usage: npx klypix-mcp brain-history restore <id> [--include-purged] [--brain <path>]');
67
69
  console.error('Run `npx klypix-mcp brain-history list` to see the ids.');
68
70
  process.exit(2);
69
71
  }
70
72
  const { parseKlypix } = await import('../src/klypix-format.mjs');
71
- const res = await restoreBrainSnapshot(brainPath, id, { parse: parseKlypix });
73
+ const res = await restoreBrainSnapshot(brainPath, id, { parse: parseKlypix, includePurged });
72
74
  if (!res.ok) { console.error(`Restore failed: ${res.error}`); process.exit(1); }
73
- console.log(`Restored ${brainPath} from ${res.restoredFrom} (${kb(res.bytes)}).`);
75
+
76
+ const SHOW = 10;
77
+ const listIds = (ids, fmt = (x) => x) => {
78
+ for (const x of ids.slice(0, SHOW)) console.log(` ${fmt(x)}`);
79
+ if (ids.length > SHOW) console.log(` …and ${ids.length - SHOW} more`);
80
+ };
81
+ if (res.mode === 'merge') {
82
+ const { reverted = [], restored = [], revived = [], buried = [], keptPurged = [] } = res;
83
+ // Ids alone mean nothing to a person: show each card's first words, from the
84
+ // live card or from its Deleted cards entry.
85
+ const text = new Map();
86
+ try {
87
+ const { struct } = await parseKlypix(fs.readFileSync(brainPath));
88
+ for (const c of struct.cards || []) text.set(c.id, String(c.text || c.title || ''));
89
+ for (const g of struct.graveyard || []) if (!text.has(g.id)) text.set(g.id, String(g.preview || ''));
90
+ } catch { /* ids only */ }
91
+ const said = (id) => {
92
+ const s = (text.get(id) || '').replace(/\s+/g, ' ').trim();
93
+ return s ? `${id} "${s.length > 60 ? `${s.slice(0, 59)}…` : s}"` : id;
94
+ };
95
+ console.log(`Restored ${brainPath} from ${res.restoredFrom} (${kb(res.bytes)}), merged into the brain as it is now:`);
96
+ if (reverted.length) {
97
+ console.log(` ${reverted.length} card(s) set back to how they were then:`);
98
+ listIds(reverted, said);
99
+ }
100
+ if (restored.length) {
101
+ console.log(` ${restored.length} card(s) that had left with no record came back:`);
102
+ listIds(restored, said);
103
+ }
104
+ if (revived.length) {
105
+ console.log(` ${revived.length} card(s) back from Deleted cards, under new ids so every copy agrees the old ones were deleted:`);
106
+ listIds(revived, (r) => (r.already ? `${said(r.as)} (already back)` : said(r.as)));
107
+ }
108
+ if (buried.length) {
109
+ console.log(` ${buried.length} card(s) added since that point moved to Deleted cards:`);
110
+ listIds(buried, said);
111
+ console.log(` Bring one back: npx klypix-mcp brain-deleted restore <id> --brain "${brainPath}"`);
112
+ }
113
+ if (keptPurged.length) {
114
+ console.log(` ${keptPurged.length} permanently deleted card(s) kept out:`);
115
+ listIds(keptPurged);
116
+ console.log(' Add --include-purged to bring them back too (under new ids).');
117
+ }
118
+ if (!reverted.length && !restored.length && !buried.length && !keptPurged.length && revived.every((r) => r.already)) {
119
+ console.log(' The brain already matched that point; nothing changed.');
120
+ }
121
+ } else if (res.mode === 'recreate') {
122
+ console.log(`The brain file was missing. Recreated ${brainPath} from ${res.restoredFrom} (${kb(res.bytes)}).`);
123
+ } else {
124
+ console.log(`Restored ${brainPath} from ${res.restoredFrom} (${kb(res.bytes)}).`);
125
+ console.warn('Warning: the merge engine installed beside this command is older, so this restore replaced the whole file.');
126
+ console.warn('Brain Sync and git will read the cards it removed as deletes. Update with: npx klypix-mcp install');
127
+ }
74
128
  if (res.safetyId) console.log(`The state you just replaced was saved as ${res.safetyId} — undo with: npx klypix-mcp brain-history restore ${res.safetyId}`);
75
129
  console.log('If the app has this brain OPEN, close and reopen the tab: its in-memory copy is now older than disk and a save would merge it back.');
@@ -2,7 +2,7 @@
2
2
  // klypix-git-tools — the GitHub lane: three verbs that put the brain where
3
3
  // dev teams actually live (the repo and the PR page).
4
4
  //
5
- // git-driver [install|status] [repo] register the lossless .klypix merge
5
+ // git-driver [install|status] [repo] register the card-level .klypix merge
6
6
  // driver for a repo, zero-command
7
7
  // diff [ref] [--brain <path>] readable brain diff vs a git ref
8
8
  // pr-brief [baseRef] [--brain <path>] brain cards touching the files
@@ -85,21 +85,51 @@ async function loadEngine() {
85
85
 
86
86
  // ── git-driver ──────────────────────────────────────────────────────────────
87
87
 
88
- const ENGINE_FILES = ['klypix-merge-driver.mjs', 'merge-brains.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs'];
88
+ // Dependencies first: klypix-format ← brain-graveyard ← merge-brains ← driver.
89
+ // Each file is swapped in atomically, so at every moment the set on disk can
90
+ // link — a new merge-brains beside an old klypix-format is the one mix that
91
+ // cannot (it imports names the old file lacks).
92
+ const ENGINE_FILES = ['klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs'];
89
93
  const ENGINE_DEPS = ['jszip', 'fractional-indexing'];
90
94
 
95
+ const readJson = (p) => { try { return JSON.parse(fs.readFileSync(p, 'utf8')); } catch { return null; } };
96
+
91
97
  // Make sure the INSTALLED runtime can actually run the driver: the four
92
98
  // engine files plus their two runtime deps. This is deliberately a
93
99
  // light provision — it never touches hooks or servers; the full installer
94
100
  // remains `npx klypix-mcp install`.
95
- function ensureDriverRuntime() {
101
+ //
102
+ // Never a downgrade. The same files arrive from the full installer, the
103
+ // desktop bundle and auto-update; an older `npx klypix-mcp git-driver install`
104
+ // (or a pinned devDependency) must not put its engine under a newer one. So
105
+ // the files are copied only when the install gate says this package may
106
+ // install (brainInstallDecision — the same rule the full installer uses), and
107
+ // then always as the whole set.
108
+ async function ensureDriverRuntime() {
96
109
  const provisioned = [];
97
110
  fs.mkdirSync(BRAIN_DIR, { recursive: true });
98
- for (const f of ENGINE_FILES) {
99
- const dest = path.join(BRAIN_DIR, f);
100
- const srcFile = path.join(SRC, f);
101
- if (!fs.existsSync(dest) || fs.readFileSync(dest, 'utf8') !== fs.readFileSync(srcFile, 'utf8')) {
102
- fs.copyFileSync(srcFile, dest);
111
+ const { brainInstallDecision } = await import(new URL('../src/install-version.mjs', import.meta.url).href);
112
+ const pkg = readJson(path.join(HERE, '..', 'package.json'));
113
+ const stamp = readJson(path.join(BRAIN_DIR, '.brain-version.json'));
114
+ const runtime = readJson(path.join(BRAIN_DIR, '.mcp-runtime.json'));
115
+ const decision = brainInstallDecision({ candidateVersion: pkg?.version, stamp, runtime });
116
+ const result = { provisioned };
117
+ if (decision.action === 'preserve') {
118
+ const owner = decision.reason === 'dev-owned' ? 'a dev deploy' : `v${decision.installedVersion}${stamp?.via ? ` (via ${stamp.via})` : ''}`;
119
+ result.kept = `kept the engine installed by ${owner}; this package is v${decision.candidateVersion}`;
120
+ const missing = ENGINE_FILES.filter((f) => !fs.existsSync(path.join(BRAIN_DIR, f)));
121
+ if (missing.length) result.missing = missing;
122
+ } else {
123
+ // Only files that differ are written, so what is on disk afterwards is
124
+ // exactly this package's set.
125
+ for (const f of ENGINE_FILES) {
126
+ const dest = path.join(BRAIN_DIR, f);
127
+ const body = fs.readFileSync(path.join(SRC, f), 'utf8');
128
+ if (fs.existsSync(dest) && fs.readFileSync(dest, 'utf8') === body) continue;
129
+ const tmp = `${dest}.klypix-new-${process.pid}`;
130
+ fs.writeFileSync(tmp, body);
131
+ try { fs.renameSync(tmp, dest); }
132
+ catch (err) { try { fs.unlinkSync(tmp); } catch { /* */ } throw err; }
103
133
  provisioned.push(f);
104
134
  }
105
135
  }
@@ -152,7 +182,7 @@ function ensureDriverRuntime() {
152
182
  };
153
183
  const seen = new Set();
154
184
  for (const dep of ENGINE_DEPS) provisionDep(dep, path.join(HERE, '..'), seen);
155
- return provisioned;
185
+ return result;
156
186
  }
157
187
 
158
188
  const DRIVER_ATTR_RULE = '*.klypix merge=klypix -text';
@@ -180,21 +210,23 @@ async function gitDriver() {
180
210
  process.exit(configured && gaHasRule && runtimeOk ? 0 : 1);
181
211
  }
182
212
 
183
- const provisioned = ensureDriverRuntime();
213
+ const { provisioned, kept, missing } = await ensureDriverRuntime();
184
214
  let already = false;
185
215
  try { already = (await gitText(toplevel, 'config', '--get', 'merge.klypix.driver')) === driverCmd; } catch { /* unset */ }
186
216
  if (!already) {
187
- await git(toplevel, ['config', 'merge.klypix.name', 'KLYPIX lossless brain merge (union by card id)']);
217
+ await git(toplevel, ['config', 'merge.klypix.name', 'KLYPIX brain merge (3-way, card by card)']);
188
218
  await git(toplevel, ['config', 'merge.klypix.driver', driverCmd]);
189
219
  }
190
220
  let gaState = 'present';
191
221
  if (!gaHasRule) {
192
- const rule = `${gaText && !gaText.endsWith('\n') ? '\n' : ''}# .klypix brains merge losslessly via the KLYPIX 3-way union driver\n# (per-machine registration: npx klypix-mcp git-driver install).\n${DRIVER_ATTR_RULE}\n`;
222
+ const rule = `${gaText && !gaText.endsWith('\n') ? '\n' : ''}# .klypix brains merge card by card via the KLYPIX 3-way driver\n# (per-machine registration: npx klypix-mcp git-driver install).\n${DRIVER_ATTR_RULE}\n`;
193
223
  fs.appendFileSync(gaPath, rule);
194
224
  gaState = 'added';
195
225
  }
196
226
  console.log(`✓ ${already ? 'Already registered' : 'Registered'} the .klypix merge driver for ${toplevel}`);
197
227
  console.log(` driver: ${driverPath}${provisioned.length ? ` (provisioned: ${provisioned.join(', ')})` : ''}`);
228
+ if (kept) console.log(` engine: ${kept}`);
229
+ if (missing?.length) console.log(` ! the installed engine is missing ${missing.join(', ')} — repair it with: npx klypix-mcp@latest install`);
198
230
  console.log(` .gitattributes rule: ${gaState}${gaState === 'added' ? ' — commit it so every teammate\'s clone routes .klypix merges here' : ''}`);
199
231
  console.log(' Teammates run the same command once per machine; unregistered machines fall back to a normal conflict.');
200
232
  }
@@ -395,7 +395,14 @@ try {
395
395
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
396
396
  // file must never get a JS-comment banner) beside the flat server, which
397
397
  // resolves it via its ./canvas-view-app.html candidate path.
398
- for (const f of ['global-brain-hook.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'brain-graveyard.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
398
+ // Files are renamed one at a time in this order (the hook last), so the
399
+ // shared libraries go FIRST and the merge engine in dependency order —
400
+ // klypix-format, brain-graveyard, merge-brains, the git driver. A newer
401
+ // file beside an older klypix-format can fail to link; an older one beside
402
+ // a newer klypix-format cannot. merge-brains and the driver ship here so
403
+ // brain-history's restore merge, the KLYPIX core and the git driver all
404
+ // find one engine in this directory.
405
+ for (const f of ['global-brain-hook.mjs', 'klypix-format.mjs', 'brain-graveyard.mjs', 'merge-brains.mjs', 'klypix-merge-driver.mjs', 'capture-gap.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'enrichment.mjs', 'provenance.mjs', 'brain-note.mjs', 'brain-evidence.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'editor-detect.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'repo-state.mjs', 'result-reconcile.mjs', 'finding-routing.mjs', 'presence-relay.mjs', 'mcp-supervisor.mjs', 'mcp-auto-update.mjs', 'runtime-inspector.mjs', 'project-graph.mjs', 'bench.mjs', 'codex-brain-hook.mjs', 'codex-hooks.mjs', 'canvas-view-app.html']) {
399
406
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
400
407
  }
401
408
  for (const [src, dst] of [
@@ -35,7 +35,7 @@ const USAGE = [
35
35
  ' init seed a starter ./brain.klypix + print an MCP config',
36
36
  ' garden-code [brain] print the human approval code for brain_garden',
37
37
  ' uninstall [--check|--yes|unlink] remove this install from the machine (--check inventories first; never deletes a .klypix)',
38
- ' git-driver [install|status] [repo] register the lossless .klypix merge driver for a repo (zero-command teams)',
38
+ ' git-driver [install|status] [repo] register the card-level .klypix merge driver for a repo (zero-command teams)',
39
39
  ' git-hook [install|remove|status] wire the agent-neutral commit-capture hook (any agent/branch/worktree → brain cards)',
40
40
  ' brain-history [list|restore <id>] restore points for this brain — undo an accidental delete, edit, or overwrite',
41
41
  ' brain-deleted [list|restore|purge] recycle bin for this brain — cards you deleted, kept recoverable',
@@ -116,7 +116,7 @@ await runVerb('doctor', './klypix-doctor.mjs');
116
116
  await runVerb('conformance', './klypix-conformance.mjs');
117
117
 
118
118
  // `npx klypix-mcp git-driver | diff | pr-brief` — the GitHub lane: register the
119
- // lossless .klypix merge driver for any repo, render a readable brain diff vs a
119
+ // card-level .klypix merge driver for any repo, render a readable brain diff vs a
120
120
  // git ref, and print the brain cards touching a PR's changed files. One module,
121
121
  // three verbs (it reads argv[2] itself).
122
122
  await runVerb('git-driver', './klypix-git-driver.mjs');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.88.0",
3
+ "version": "1.89.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",
@@ -84,7 +84,7 @@
84
84
  "bench": "node bin/klypix-mcp.mjs bench",
85
85
  "test:bench": "node test/bench.mjs",
86
86
  "pretest": "node test/publish-workflow.mjs",
87
- "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/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/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/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-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/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/uninstall.mjs && node test/current-guidance.mjs && node test/status-shape.mjs && node test/status-hook.mjs",
87
+ "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/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/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-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/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",
88
88
  "test:memory": "node test/memory-runtime.mjs",
89
89
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
90
90
  "runtime": "node bin/klypix-runtime.mjs",