klypix-mcp 1.88.0 → 1.90.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
 
@@ -460,8 +460,8 @@ receiving model calls `brain_message_receipt` with the exact message id and per-
460
460
  token; only that token-bound action records `consumed`. Pending, offered, and acknowledged notes
461
461
  survive reconnects. Expiry or bounded-capacity eviction records a failed per-recipient receipt
462
462
  instead of silently looking delivered. The send-time audience is fixed, unresolved targeted sends
463
- fail closed, the core lane is machine-local, notes expire after 24 hours, and they are never written
464
- into the brain.
463
+ fail closed, the core lane is machine-local, a note to every session expires after 24 hours and a
464
+ directed note after 7 days, and notes are never written into the brain.
465
465
 
466
466
  Durable handoffs go in the brain itself — decisions, findings, open questions and skills captured
467
467
  as cards, each stamped with the agent that wrote it.
@@ -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 |
@@ -730,19 +750,59 @@ replaceable worker runs the brain core. A staged update is hash-verified, initia
730
750
  checked for backward-compatible tool schemas, and handed the current `brain_sync` task scope before
731
751
  the supervisor switches between requests. Added tools use the standard
732
752
  `notifications/tools/list_changed` signal. A failed or breaking candidate is rejected while the old
733
- worker keeps serving. A blocked result claim is kept in a durable per-project/session marker, so a
734
- worker replacement cannot turn a failed evidence check into a result-less completion.
753
+ worker keeps serving. A connection idle for 10 minutes releases its worker and keeps its presence;
754
+ it stays asleep until its host sends a request, and the new worker it then starts passes the same
755
+ checks. If they reject it, the connection resumes the version it last ran, or answers with a
756
+ retryable `/mcp reconnect` error rather than restarting in a loop. A blocked result claim is kept
757
+ in a durable per-project/session marker, so a worker replacement cannot turn a failed evidence
758
+ check into a result-less completion.
735
759
 
736
760
  Compatible engine updates therefore activate behind the same live connection — no reconnect, no
737
761
  host restart. Three cases still require a deliberate reconnect or manual install: the one-time
738
- legacy→supervisor migration, a supervisor-code change, and a major or tool-removing release.
739
- `brain_doctor` reports the live supervisor and the automatic-update receipt explicitly.
740
-
741
- The supervisor performs **one machine-wide npm version check per 24 hours**, however many sessions
742
- are open. It installs an exact stable same-major release in `--runtime-only` mode, preserving host
743
- settings and project files. The check is detached and fail-open, developer-owned installs are
744
- protected, concurrent sessions collapse behind one lock, and `KLYPIX_AUTO_UPDATE=0` opts out
745
- entirely.
762
+ legacy→supervisor migration, a supervisor-code change, and a major or tool-removing release. Fixes
763
+ to the supervisor itself reach a connection only after one `/mcp` reconnect or a host restart;
764
+ worker and doctor changes hot-swap. `brain_doctor` reports the live supervisors (including how many
765
+ still run older supervisor code) and the automatic-update schedule: the last result, which install
766
+ it describes, and when the next check runs. The MCP tool also returns these as structured data.
767
+
768
+ The updater checks npm **once per machine every 6 hours**, however many sessions are open. It
769
+ checks once more, never sooner than 5 minutes after the last attempt, when another install upgrades
770
+ this runtime or moves it from a developer deploy to a released install. Examples are a manual
771
+ `npx klypix-mcp install` of a newer release, or a release replacing a developer deploy. A failed
772
+ check retries after 15 minutes, then 1 hour, then 4 hours, then returns to the 6-hour cadence. Each
773
+ attempt is recorded as failed before it touches the network, so a check that dies part-way backs
774
+ off instead of retrying in a loop. Checks run from the open KLYPIX sessions: the supervisor and
775
+ worker look every 10 minutes, and a new session looks 2 seconds after it starts.
776
+
777
+ The updater installs an exact stable release of the **same major version** in `--runtime-only`
778
+ mode, preserving host settings and project files; a new major always needs a manual install. It
779
+ never downgrades. A deliberate downgrade (`npx klypix-mcp@<older> install --force`) onto a release
780
+ newer than 1.89.0 is **held**: the updater does not re-install the version it was rolled back from,
781
+ only a newer release. To take the held version back, run `npx -y klypix-mcp@latest install` (or set
782
+ `KLYPIX_AUTO_UPDATE_FORCE=1` where `KLYPIX_AUTO_UPDATE` is set, below, for one check, then remove
783
+ it). A rollback onto 1.89.0 or earlier also rolls the updater back, and those releases have no
784
+ hold: they re-install the newest same-major release within 24 hours of their last check. To stay
785
+ on such a release, set `KLYPIX_AUTO_UPDATE=0` in every place listed below for as long as you stay;
786
+ `brain_doctor` warns when a downgrade is not held. The updater never fetches anything for a
787
+ developer-owned install and never installs over it. The check is detached and fail-open, and
788
+ concurrent sessions collapse behind one lock.
789
+
790
+ `KLYPIX_AUTO_UPDATE=0` opts out. Every process reads its own environment, so set it in each host's
791
+ launch environment: the `env` of each KLYPIX MCP server entry, and the environment Claude Code runs
792
+ its hooks in. It takes effect at that host's next supervisor start or `/mcp` reconnect.
793
+
794
+ There is one second, smaller probe. In a brain project (a directory with `./brain.klypix`), the
795
+ Claude Code Stop hook refreshes a local npm-version cache, which the next SessionStart reads to say
796
+ whether an update is available. That notice also says what the updater will do with the update,
797
+ and when. The probe:
798
+
799
+ - runs at most once a day, developer-owned installs included;
800
+ - makes the same kind of anonymous request the updater makes, a GET of
801
+ `https://registry.npmjs.org/klypix-mcp/latest` that carries no user or machine identifier;
802
+ - is skipped while the updater fetched npm's latest version less than a day ago;
803
+ - is off with the same `KLYPIX_AUTO_UPDATE=0`, read from the environment Claude Code runs its hooks
804
+ in;
805
+ - never installs anything.
746
806
 
747
807
  When the optional semantic runtime is already enabled, an update also schedules one detached,
748
808
  single-writer cache migration across registered brains. That removes the multi-minute first-query
@@ -754,9 +814,13 @@ keep lazy first-use indexing instead.
754
814
 
755
815
  - **Apache-2.0, source public** at [github.com/dahshanlabs/klypix-mcp](https://github.com/dahshanlabs/klypix-mcp).
756
816
  - **The brain engine makes no network calls and sends no telemetry.** All engine intelligence is
757
- deterministic and local; the only LLM anywhere is *your* agent. The one exception in this package
758
- is the supervisor's once-per-24h npm version check described above — turn it off with
759
- `KLYPIX_AUTO_UPDATE=0`.
817
+ deterministic and local; the only LLM anywhere is *your* agent. The exceptions in this package
818
+ are the two update probes described above. One is the updater's npm version check, every 6
819
+ hours, plus one re-check after another install upgrades the runtime, and retries after a failed
820
+ check (15 minutes, 1 hour, 4 hours); when it finds a newer same-major release, it also runs that
821
+ release's npm install. The other is the Claude Code Stop hook's version probe, at most once a day
822
+ in brain projects. Both are the same kind of anonymous GET of the package's `latest` version, and
823
+ `KLYPIX_AUTO_UPDATE=0` turns both off.
760
824
  - **The optional semantic model runs on device.** Enabling it (or upgrading its model) can fetch
761
825
  model weights from Hugging Face; retrieval inference and brain data stay local.
762
826
  - **Coordination state is local files.** The brain is a file in your repo; the presence lane is a
@@ -849,8 +913,8 @@ independently validated.
849
913
  fewer than four content words: junk injection 93% → 35%, mean cards injected on junk 3.6 → 1.5,
850
914
  at a one-question cost on the 35 real prompts (inside noise). The prompts stay private; the
851
915
  sweep tables are in the source next to the bars they chose.
852
- - **What we do not publish.** No download count: this package's own 24-hour auto-updater generates
853
- most of it, so it is not a user count. No adoption, team or customer figures. No brief-token
916
+ - **What we do not publish.** No download count: this package's own auto-updater generates most
917
+ of it, so it is not a user count. No adoption, team or customer figures. No brief-token
854
918
  figure — the last one was measured at ~600 cards and is stale at 2,479.
855
919
  - **The eval harness is not in this repo.** It lives in the private KLYPIX desktop repository. The
856
920
  numbers above are ours to defend, not yours to reproduce from here — treat them accordingly.
@@ -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,8 +2,8 @@
2
2
  // klypix-doctor — `npx klypix-mcp doctor`. The brain's self-check: is THIS machine's
3
3
  // brain current, are the Claude capture and Codex presence adapters wired, what verbs
4
4
  // does it expose, which lifecycle sessions are live, and is the harness projection in
5
- // sync? ONE fact, ONE reconcile block. Read-only (never writes). Exits 0 = ALIGNED,
6
- // 1 = DRIFTED — so it
5
+ // sync? ONE fact, ONE reconcile block. Read-only (never writes). Exits 0 = ALIGNED
6
+ // or PARTIAL (readiness warnings, no drift), 1 = DRIFTED — so it
7
7
  // doubles as a pre-commit / CI readiness gate.
8
8
  //
9
9
  // npx klypix-mcp doctor # this project + this machine's brain
@@ -63,9 +63,14 @@ try {
63
63
  }
64
64
 
65
65
  const drifted = report.verdict === 'DRIFTED' || extraDrift > 0;
66
+ // PARTIAL used to end with "✓ aligned." under a head that said PARTIAL
67
+ // (2026-10-03) — the closing line is what people read. Exit code unchanged.
68
+ const warnings = Array.isArray(report.readinessWarnings) ? report.readinessWarnings.length : 0;
66
69
  console.log(drifted ? (color ? '\x1b[33m' : '') + `\n✗ drift found — see reconcile above.` + (color ? '\x1b[0m' : '')
67
70
  : report.verdict === 'NOT-INSTALLED' ? '\n• brain not installed on this machine.'
68
- : (color ? '\x1b[32m' : '') + '\n✓ aligned.' + (color ? '\x1b[0m' : ''));
71
+ : report.verdict === 'PARTIAL'
72
+ ? (color ? '\x1b[33m' : '') + `\n• partial — ${warnings} readiness warning${warnings === 1 ? '' : 's'} above (no drift)` + (color ? '\x1b[0m' : '')
73
+ : (color ? '\x1b[32m' : '') + '\n✓ aligned.' + (color ? '\x1b[0m' : ''));
69
74
  process.exit(drifted ? 1 : 0);
70
75
  } catch (e) {
71
76
  console.error(`✗ doctor failed: ${e?.message || e}`);
@@ -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 [