klypix-mcp 1.63.0 → 1.65.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -416,6 +416,32 @@ rename, so a crash mid-write leaves the previous good file intact. The lock is a
416
416
  sustained contention can still lose an update. That is a deliberate trade — dropping the markers
417
417
  was judged worse — but it is a real limit, not a guarantee.
418
418
 
419
+ ### Restore points
420
+
421
+ Merging, tidying and gardening are lossless by contract. What none of them can undo is a
422
+ *deliberate-looking* deletion: you select a dozen cards, delete them, and save. That is not a bug
423
+ to prevent — a brain has to stay correctable, and an uncorrectable memory is worse than none — but
424
+ it deserves a way back, because the brain is **co-owned**: hooks, the MCP server, commit capture
425
+ and peers on other machines all write to it while nobody is watching, so you can destroy work you
426
+ never saw arrive.
427
+
428
+ So every brain write takes a restore point of the previous bytes first:
429
+
430
+ ```bash
431
+ npx klypix-mcp brain-history list # age, card count, delta against the brain now
432
+ npx klypix-mcp brain-history restore <id> # and this is itself undoable
433
+ ```
434
+
435
+ They live under `~/.claude/project-brain/history/`, never beside the brain — nothing lands in git,
436
+ in the merge driver's path, or in your diffs, and they survive deletion of the `.klypix` file
437
+ itself. Routine writes are deduped and throttled to one a minute; a write that **removes cards** is
438
+ never throttled, because that is the case they exist for. Retention is the newest 20 plus one per
439
+ day for 14 days, so a slow-burn mistake is still recoverable without unbounded growth. A snapshot
440
+ that cannot be written is logged and skipped — it never blocks your save.
441
+
442
+ Normal canvases deliberately get none of this. One human made every mark and saw every change; the
443
+ brain is the file where that is not true.
444
+
419
445
  ---
420
446
 
421
447
  ## The command line
@@ -432,6 +458,7 @@ The MCP verbs below are what agents call. These are what **you** call:
432
458
  | `npx klypix-mcp conformance` | Launch two real MCP clients against this build and verify coordination behaviour |
433
459
  | `npx klypix-mcp git-driver` | Register the lossless `.klypix` merge driver for a repo (`status` to check) |
434
460
  | `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) |
461
+ | `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 |
435
462
  | `npx klypix-mcp diff [ref]` | Card-level brain diff against a git ref, as markdown |
436
463
  | `npx klypix-mcp pr-brief [ref]` | Brain cards referencing the files changed since a ref, as markdown |
437
464
  | `npx klypix-mcp garden-code` | Print the human approval code `brain_garden` requires |
@@ -0,0 +1,82 @@
1
+ #!/usr/bin/env node
2
+ // `klypix-mcp brain-deleted [list|restore <id…>|purge] [--brain <path>]`
3
+ // The recycle bin for a brain: cards a human deleted are kept recoverable
4
+ // instead of destroyed. Standalone: node bin/klypix-brain-deleted.mjs <args>
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { listGraveyard, purgeGraveyard, readGraveyardCard, restoreFromGraveyard, DEFAULT_RETENTION_DAYS } from '../src/brain-graveyard.mjs';
8
+ import { atomicWrite } from '../src/klypix-format.mjs';
9
+
10
+ const argv = process.argv.slice(2).filter((a) => a !== 'brain-deleted');
11
+ const action = ['list', 'restore', 'purge'].includes(argv[0]) ? argv.shift() : 'list';
12
+ const brainIdx = argv.indexOf('--brain');
13
+ const brainPath = path.resolve(brainIdx >= 0 && argv[brainIdx + 1] ? argv.splice(brainIdx, 2)[1] : 'brain.klypix');
14
+ const flag = (name) => { const i = argv.indexOf(name); return i >= 0 ? (argv.splice(i, 2)[1] ?? '') : null; };
15
+ const olderThan = flag('--older-than');
16
+ const all = argv.includes('--all');
17
+ const ids = argv.filter((a) => !a.startsWith('-'));
18
+
19
+ if (!fs.existsSync(brainPath)) { console.error(`No brain at ${brainPath}.`); process.exit(1); }
20
+ const buf = fs.readFileSync(brainPath);
21
+
22
+ const ago = (ts) => {
23
+ const m = Math.max(0, Math.round((Date.now() - Number(ts || 0)) / 60000));
24
+ if (!ts) return 'unknown';
25
+ if (m < 60) return `${m}m ago`;
26
+ const h = Math.round(m / 60);
27
+ return h < 48 ? `${h}h ago` : `${Math.round(h / 24)}d ago`;
28
+ };
29
+
30
+ if (action === 'list') {
31
+ const entries = await listGraveyard(buf);
32
+ if (!entries.length) {
33
+ console.log(`Nothing deleted from ${path.basename(brainPath)}.`);
34
+ console.log('Cards you delete from a brain are kept here, recoverable, instead of destroyed.');
35
+ process.exit(0);
36
+ }
37
+ console.log(`${entries.length} deleted card(s) in ${path.basename(brainPath)} — newest first\n`);
38
+ for (const e of entries) {
39
+ const full = ids.includes(e.id) ? await readGraveyardCard(buf, e.id) : null;
40
+ console.log(` ${e.id} ${ago(e.deletedAt).padEnd(9)} ${e.area ? `[${e.area}] ` : ''}${e.preview || '(no text)'}`);
41
+ if (full?.content) console.log(`\n${String(full.content).split('\n').map((l) => ` ${l}`).join('\n')}\n`);
42
+ }
43
+ console.log(`\nFull text: npx klypix-mcp brain-deleted list <id> --brain "${brainPath}"`);
44
+ console.log(`Restore: npx klypix-mcp brain-deleted restore <id>`);
45
+ console.log(`Purge: npx klypix-mcp brain-deleted purge --older-than ${DEFAULT_RETENTION_DAYS}d (or: purge <id>, purge --all)`);
46
+ process.exit(0);
47
+ }
48
+
49
+ if (action === 'restore') {
50
+ if (!ids.length) { console.error('Usage: brain-deleted restore <id…> (ids from `brain-deleted list`)'); process.exit(2); }
51
+ const res = await restoreFromGraveyard(buf, ids);
52
+ if (!res.restored.length) {
53
+ for (const s of res.skipped) console.error(` ${s.id}: ${s.reason}`);
54
+ console.error('Nothing restored.');
55
+ process.exit(1);
56
+ }
57
+ await atomicWrite(brainPath, res.buffer, { reason: 'graveyard-restore' });
58
+ for (const r of res.restored) {
59
+ console.log(`Restored ${r.id}${r.reparented ? ' (its container is gone — placed at the canvas root)' : ''}`);
60
+ }
61
+ for (const s of res.skipped) console.log(`Skipped ${s.id}: ${s.reason}`);
62
+ console.log('If the app has this brain OPEN, close and reopen the tab so it sees the restored card.');
63
+ process.exit(0);
64
+ }
65
+
66
+ // purge
67
+ if (!ids.length && !all && !olderThan) {
68
+ console.error(`Usage: brain-deleted purge --older-than ${DEFAULT_RETENTION_DAYS}d | purge <id…> | purge --all`);
69
+ console.error('Purge is permanent. A restore point is written first (npx klypix-mcp brain-history list).');
70
+ process.exit(2);
71
+ }
72
+ const days = olderThan ? Number(String(olderThan).replace(/d$/i, '')) : null;
73
+ if (olderThan && !Number.isFinite(days)) { console.error(`--older-than expects days, e.g. --older-than ${DEFAULT_RETENTION_DAYS}d`); process.exit(2); }
74
+ const res = await purgeGraveyard(buf, {
75
+ ids: ids.length ? ids : (all ? (await listGraveyard(buf)).map((e) => e.id) : null),
76
+ olderThanDays: ids.length || all ? null : days,
77
+ });
78
+ if (!res.purged.length) { console.log('Nothing matched — nothing purged.'); process.exit(0); }
79
+ await atomicWrite(brainPath, res.buffer, { reason: 'graveyard-purge' });
80
+ console.log(`Purged ${res.purged.length} deleted card(s) permanently from ${path.basename(brainPath)}.`);
81
+ console.log('Note: this removes them from the file, not from git history — a secret committed earlier is still in past commits.');
82
+ console.log(`The pre-purge state is a restore point: npx klypix-mcp brain-history list --brain "${brainPath}"`);
@@ -293,7 +293,7 @@ try {
293
293
  // canvas-view-app.html is the canvas_view MCP App UI — staged raw (an HTML
294
294
  // file must never get a JS-comment banner) beside the flat server, which
295
295
  // resolves it via its ./canvas-view-app.html candidate path.
296
- for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.mjs', 'brain-git-hook.mjs', 'git-capture-install.mjs', 'brain-history.mjs', 'klypix-format.mjs', 'klypix-core.mjs', 'brain-write-lock.mjs', 'agent-rules.mjs', 'brain-doctor.mjs', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.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']) {
296
+ for (const f of ['global-brain-hook.mjs', 'brain-semantic.mjs', 'semantic-memory.mjs', 'brain-note.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', 'agent-presence.mjs', 'mcp-presence.mjs', 'finding-routing.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']) {
297
297
  const s = path.join(SRC, f); if (exists(s)) staged.push({ dst: f, content: fs.readFileSync(s, 'utf8') });
298
298
  }
299
299
  for (const [src, dst] of [
@@ -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', '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', 'diff', 'pr-brief', 'uninstall', 'bench']);
23
23
 
24
24
  const USAGE = [
25
25
  `klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
@@ -37,6 +37,7 @@ const USAGE = [
37
37
  ' git-driver [install|status] [repo] register the lossless .klypix merge driver for a repo (zero-command teams)',
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
+ ' brain-deleted [list|restore|purge] recycle bin for this brain — cards you deleted, kept recoverable',
40
41
  ' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
41
42
  ' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
42
43
  '',
@@ -125,6 +125,10 @@ await runVerb('git-hook', './klypix-git-hook.mjs');
125
125
  // written before every brain write. The recovery path for an accidental card
126
126
  // deletion, a destructive edit, a stale overwrite, or a deleted brain file.
127
127
  await runVerb('brain-history', './klypix-brain-history.mjs');
128
+ // `npx klypix-mcp brain-deleted` — the brain's recycle bin. A human delete moves
129
+ // the card's bytes to graveyard/ instead of destroying them; this lists, restores
130
+ // and (permanently) purges them.
131
+ await runVerb('brain-deleted', './klypix-brain-deleted.mjs');
128
132
  await runVerb('diff', './klypix-diff.mjs');
129
133
  await runVerb('pr-brief', './klypix-pr-brief.mjs');
130
134
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.63.0",
3
+ "version": "1.65.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",
@@ -79,7 +79,7 @@
79
79
  "test:project-graph": "node test/project-graph.mjs",
80
80
  "bench": "node bin/klypix-mcp.mjs bench",
81
81
  "test:bench": "node test/bench.mjs",
82
- "test": "node test/publish-verdict.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/agent-presence.mjs && node test/intent-guard.mjs && node test/git-capture-install.mjs && node test/brain-history.mjs && node test/finding-routing.mjs && node test/finding-routing-hook.mjs && node test/presence-relay.mjs && node test/context-gateway.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-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/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.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",
82
+ "test": "node test/publish-verdict.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/agent-presence.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/context-gateway.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-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/merge-brains.mjs && node test/concurrent-writes.mjs && node test/lock-interop.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",
83
83
  "test:memory": "node test/memory-runtime.mjs",
84
84
  "test:memory:soak": "node --expose-gc test/memory-soak.mjs",
85
85
  "runtime": "node bin/klypix-runtime.mjs"
@@ -0,0 +1,131 @@
1
+ // brain-graveyard — the recoverable bin for cards a human deleted from a brain.
2
+ //
3
+ // WHY THIS AND NOT THE ARCHIVE CONTAINER. "Archived" in a KLYPIX brain is a
4
+ // containment fact: the card's parent is a container titled "Archive". Those
5
+ // cards are STILL in canvas.json's `order`, so they still render — this repo's
6
+ // own brain has 275 of them on the canvas right now. Routing deletes there
7
+ // would make a deleted card visibly reappear (and, because the merge's position
8
+ // comparator ignores parentId, reappear exactly where it was). It would also
9
+ // leak: `read_canvas` and `search_canvases` have no archive awareness at all,
10
+ // `brain_ask` includes archived cards by design, and the embedder embeds them.
11
+ //
12
+ // So a deleted card leaves `order` entirely and its bytes move to `graveyard/`,
13
+ // which `parseKlypix` reads into `struct.graveyard` and deliberately never
14
+ // merges into `struct.cards`. That one choice makes every leak impossible by
15
+ // construction rather than by remembering to filter in 30-odd call sites.
16
+ //
17
+ // PURGE STAYS AVAILABLE, AND HONEST. brain.klypix is git-tracked and syncs to
18
+ // collaborators, so "delete" is also the escape hatch for a pasted key or a
19
+ // personal detail. Purge removes the bytes from the working file — it cannot
20
+ // remove them from git history, and the caller is told so.
21
+ import fs from 'fs';
22
+ import JSZip from 'jszip';
23
+ import { parseKlypix, shard } from './klypix-format.mjs';
24
+
25
+ export const DEFAULT_RETENTION_DAYS = 30;
26
+
27
+ /** Deleted cards, newest first. */
28
+ export async function listGraveyard(buf) {
29
+ const { struct } = await parseKlypix(buf);
30
+ return struct.graveyard || [];
31
+ }
32
+
33
+ async function readIndex(zip) {
34
+ const f = zip.file('graveyard.json');
35
+ if (!f) return { version: 1, entries: {} };
36
+ try {
37
+ const parsed = JSON.parse(await f.async('string'));
38
+ return { version: 1, entries: (parsed && parsed.entries) || {} };
39
+ } catch { return { version: 1, entries: {} }; }
40
+ }
41
+
42
+ function writeIndex(zip, index) {
43
+ if (Object.keys(index.entries).length) zip.file('graveyard.json', JSON.stringify(index));
44
+ else zip.remove('graveyard.json');
45
+ }
46
+
47
+ /**
48
+ * Put a deleted card back into the brain. It returns to `order` (so it renders
49
+ * again) at its recorded position, and leaves the bin — a card must never be in
50
+ * both, or the next restore would duplicate it.
51
+ *
52
+ * Its former parent may itself be gone; in that case the card is restored to
53
+ * the canvas root rather than into a dangling container.
54
+ */
55
+ export async function restoreFromGraveyard(buf, ids) {
56
+ const zip = await JSZip.loadAsync(buf);
57
+ const index = await readIndex(zip);
58
+ const canvasFile = zip.file('canvas.json');
59
+ if (!canvasFile) throw new Error('Not a .klypix canvas — missing canvas.json');
60
+ const canvas = JSON.parse(await canvasFile.async('string'));
61
+ canvas.order = Array.isArray(canvas.order) ? canvas.order : [];
62
+ canvas.positions = canvas.positions || {};
63
+ const liveIds = new Set(canvas.order);
64
+
65
+ const restored = [], skipped = [];
66
+ for (const rawId of ids) {
67
+ const id = String(rawId);
68
+ const entry = index.entries[id];
69
+ const file = zip.file(`graveyard/${shard(id)}/${id}.json`);
70
+ if (!entry || !file) { skipped.push({ id, reason: 'not in the bin' }); continue; }
71
+ if (liveIds.has(id)) { skipped.push({ id, reason: 'already in the brain' }); continue; }
72
+
73
+ zip.file(`items/${shard(id)}/${id}.json`, await file.async('string'));
74
+ const pos = entry.pos || { x: 0, y: 0 };
75
+ // Re-parent only if the container still exists — a card must never point at
76
+ // a container that was itself deleted, which would make it unreachable.
77
+ const parentAlive = entry.parentId && liveIds.has(entry.parentId);
78
+ canvas.positions[id] = {
79
+ x: Number(pos.x) || 0, y: Number(pos.y) || 0,
80
+ ...(pos.w != null ? { w: pos.w } : {}), ...(pos.h != null ? { h: pos.h } : {}),
81
+ zIndex: canvas.order.length,
82
+ parentId: parentAlive ? entry.parentId : null,
83
+ };
84
+ canvas.order.push(id);
85
+ liveIds.add(id);
86
+ zip.remove(`graveyard/${shard(id)}/${id}.json`);
87
+ delete index.entries[id];
88
+ restored.push({ id, reparented: Boolean(entry.parentId) && !parentAlive });
89
+ }
90
+
91
+ zip.file('canvas.json', JSON.stringify(canvas));
92
+ writeIndex(zip, index);
93
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
94
+ await parseKlypix(buffer); // never hand back an unreadable brain
95
+ return { buffer, restored, skipped };
96
+ }
97
+
98
+ /**
99
+ * Permanently remove entries from the bin. `olderThanDays` purges by age;
100
+ * explicit `ids` purge regardless of age (the secret-was-pasted case).
101
+ */
102
+ export async function purgeGraveyard(buf, { ids = null, olderThanDays = null, now = Date.now() } = {}) {
103
+ const zip = await JSZip.loadAsync(buf);
104
+ const index = await readIndex(zip);
105
+ const cutoff = olderThanDays != null ? now - olderThanDays * 24 * 60 * 60 * 1000 : null;
106
+
107
+ const target = [];
108
+ for (const [id, meta] of Object.entries(index.entries)) {
109
+ if (ids) { if (ids.includes(id)) target.push(id); continue; }
110
+ if (cutoff != null && Number(meta?.deletedAt || 0) < cutoff) target.push(id);
111
+ }
112
+ for (const id of target) {
113
+ zip.remove(`graveyard/${shard(id)}/${id}.json`);
114
+ delete index.entries[id];
115
+ }
116
+ writeIndex(zip, index);
117
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
118
+ await parseKlypix(buffer);
119
+ return { buffer, purged: target };
120
+ }
121
+
122
+ /** Read a buried card's full text — so `list` can show more than a preview. */
123
+ export async function readGraveyardCard(buf, id) {
124
+ const zip = await JSZip.loadAsync(buf);
125
+ const f = zip.file(`graveyard/${shard(String(id))}/${String(id)}.json`);
126
+ if (!f) return null;
127
+ try { return JSON.parse(await f.async('string')); } catch { return null; }
128
+ }
129
+
130
+ /** Convenience for CLI/desktop callers that work in files rather than buffers. */
131
+ export const readBrain = (p) => fs.readFileSync(p);
@@ -264,11 +264,17 @@ export async function opSearchCanvases({ vault, query }) {
264
264
  hit(c.text) ||
265
265
  (c.tags || []).some(t => hit('#' + t)));
266
266
  if (nameMatch || matched.length) {
267
- const head = `## ${rel} "${struct.title}" · ${struct.counts.cards} cards, ${struct.counts.connections} connections${nameMatch && !matched.length ? ' (name/title match)' : ''}`;
267
+ // Matching stays recall-first (an archived card CAN be the right answer to
268
+ // "what did we try?"), but an archived hit is now labelled. Unlabelled, a
269
+ // superseded or retired decision read exactly like a current one.
270
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
271
+ const n = struct.counts;
272
+ const head = `## ${rel} — "${struct.title}" · ${n.live ?? n.cards} live cards${n.archived ? `, ${n.archived} archived` : ''}, ${n.connections} connections${nameMatch && !matched.length ? ' (name/title match)' : ''}`;
268
273
  const body = matched.slice(0, 8).map(c => {
269
274
  const pos = (c.pos && c.pos.x != null) ? ` @(${Math.round(c.pos.x)},${Math.round(c.pos.y)})` : '';
270
275
  const tags = (c.tags && c.tags.length) ? ' ' + c.tags.map(t => '#' + t).join(' ') : '';
271
- return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${tags}\n ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
276
+ const arch = isArchived(c) ? ' ⛔ archived' : '';
277
+ return `- [${c.type}] "${c.title || '(card)'}" (${c.id})${pos}${tags}${arch}\n ${String(c.text || '').replace(/\s+/g, ' ').slice(0, 240)}`;
272
278
  }).join('\n');
273
279
  hits.push(matched.length ? `${head}\n${body}` : head);
274
280
  }
@@ -296,6 +296,28 @@ export async function parseKlypix(buffer) {
296
296
  for (const it of canvas.items) items[it.id] = it;
297
297
  }
298
298
 
299
+ // ── Graveyard: cards a human deleted, kept recoverable ───────────────────
300
+ // Read from `graveyard.json`, and DELIBERATELY not merged into `items`,
301
+ // `cards`, `order` or `positions`. That single choice is the whole safety
302
+ // argument: a deleted card cannot render (the renderer only ever walks
303
+ // `order`), cannot leak through read_canvas / search_canvases (which walk
304
+ // `struct.cards`), cannot be embedded or ranked into an answer, and cannot
305
+ // skew a count — with zero edits to any of those call sites. Contrast the
306
+ // Archive container, which is containment-only: its 275 cards in this
307
+ // repo's own brain DO render, and would have reappeared in place.
308
+ let graveyard = [];
309
+ try {
310
+ const gRaw = await readText('graveyard.json');
311
+ if (gRaw) {
312
+ const parsed = JSON.parse(gRaw);
313
+ const entries = parsed && typeof parsed === 'object' ? (parsed.entries || {}) : {};
314
+ graveyard = Object.entries(entries)
315
+ .map(([id, e]) => ({ id, ...(e && typeof e === 'object' ? e : {}) }))
316
+ .filter(e => e.id)
317
+ .sort((a, b) => Number(b.deletedAt || 0) - Number(a.deletedAt || 0));
318
+ }
319
+ } catch { /* a corrupt index must never fail the whole parse */ }
320
+
299
321
  const connections = Array.isArray(canvas.connections) ? canvas.connections : [];
300
322
  const titleOf = (id) => cardTitle(items[id]) || (items[id]?.type ? `${items[id].type} ${String(id).slice(0, 8)}` : String(id).slice(0, 8));
301
323
  const assetPaths = Object.keys(zip.files).filter(p => p.startsWith('assets/') && !zip.files[p].dir);
@@ -304,7 +326,20 @@ export async function parseKlypix(buffer) {
304
326
  const struct = {
305
327
  title: manifest?.title || canvas.title || 'Untitled',
306
328
  format: isV4 ? 'klypix-v4' : `legacy-v${canvas.version ?? '?'}`,
307
- counts: { cards: cards.length, connections: connections.length, assets: assetPaths.length },
329
+ // `cards` counts EVERY item in `order` containers and archived cards
330
+ // included — and several headers print it raw, so a brain whose brief
331
+ // describes 1,574 live cards announced "1981 cards". Keeping it (readers
332
+ // depend on it) and adding the honest breakdown beside it, so a surface
333
+ // that means "how much is live here" can say so.
334
+ counts: {
335
+ cards: cards.length,
336
+ connections: connections.length,
337
+ assets: assetPaths.length,
338
+ containers: cards.filter(c => c?.type === 'container').length,
339
+ archived: cards.filter(c => c?.type !== 'container' && /^archive$/i.test(
340
+ (c?.parentId ? cardTitle(items[c.parentId]) : '') || '')).length,
341
+ get live() { return this.cards - this.containers - this.archived; },
342
+ },
308
343
  cards: cards.map(it => ({
309
344
  id: it.id, type: it.type,
310
345
  title: cardTitle(it),
@@ -341,6 +376,9 @@ export async function parseKlypix(buffer) {
341
376
  relationship: c.relationship || null, label: c.label || null,
342
377
  })),
343
378
  assets: assetPaths.map(p => path.basename(p)),
379
+ // Recoverable deletions, newest first. Never counted in counts.cards —
380
+ // a deleted card is not part of the brain, it is part of its bin.
381
+ graveyard,
344
382
  };
345
383
  return { struct, zip, assetPaths, isV4, canvas, manifest };
346
384
  }
@@ -5105,12 +5143,22 @@ export function lensToMarkdown(d, view = 'all') {
5105
5143
 
5106
5144
  /** Render a parsed struct to the markdown brief (shared by read-klypix + MCP). */
5107
5145
  export function structToMarkdown(struct, { assetsDir } = {}) {
5146
+ // Archived cards were rendered here IDENTICALLY to live ones — no marker, and
5147
+ // `area` was never printed at all — so read_canvas served superseded,
5148
+ // consolidated and deleted-then-archived decisions as current fact. This is
5149
+ // the surface an agent reads to learn a project, which makes it the worst
5150
+ // place for that. They stay in the output (this is a whole-canvas dump, and
5151
+ // history is legitimately part of it) but they are now labelled.
5152
+ const isArchived = (c) => /^archive$/i.test(c.area || '');
5108
5153
  const L = [];
5154
+ const n = struct.counts;
5109
5155
  L.push(`# ${struct.title}`);
5110
- L.push(`*${struct.format} · ${struct.counts.cards} cards · ${struct.counts.connections} connections · ${struct.counts.assets} assets*\n`);
5156
+ L.push(`*${struct.format} · ${n.live ?? n.cards} live cards${n.archived ? ` · ${n.archived} archived` : ''}${n.containers ? ` · ${n.containers} containers` : ''} · ${n.connections} connections · ${n.assets} assets*\n`);
5157
+ if (n.archived) L.push(`> ⛔ ${n.archived} card(s) below are marked archived — superseded, consolidated or retired. Read them as history, not as the current state.\n`);
5111
5158
  L.push(`## Cards`);
5112
5159
  for (const c of struct.cards) {
5113
- L.push(`### ${c.title || `(${c.type})`} \`${c.type}\``);
5160
+ const archived = isArchived(c);
5161
+ L.push(`### ${c.title || `(${c.type})`} \`${c.type}\`${archived ? ' ⛔ archived' : ''}${c.area && !archived ? ` _[${c.area}]_` : ''}`);
5114
5162
  if (c.text) L.push(c.type === 'text' ? String(c.text).trim() : `→ ${c.text}`);
5115
5163
  const meta = [];
5116
5164
  if (c.links?.length) meta.push(`links: ${c.links.map(t => `[[${t}]]`).join(', ')}`);
@@ -106,8 +106,17 @@ async function loadSide(buf) {
106
106
  if (p.startsWith('assets/') && !zip.files[p].dir) assets[p] = await zip.file(p).async('nodebuffer');
107
107
  }
108
108
  const titleById = new Map(struct.cards.map(c => [c.id, c.title || '']));
109
+ // Graveyard: deleted-but-recoverable cards. Carried verbatim so a merge never
110
+ // empties another machine's bin, and so the bytes a tombstone removes from
111
+ // `order` are preserved rather than destroyed.
112
+ const graveyard = {}; // id -> { meta, json }
113
+ for (const e of (struct.graveyard || [])) {
114
+ const f = zip.file(`graveyard/${shard(e.id)}/${e.id}.json`);
115
+ const { id, ...meta } = e;
116
+ graveyard[e.id] = { meta, json: f ? await f.async('string') : null };
117
+ }
109
118
  return {
110
- order, positions, items, assets, manifest,
119
+ order, positions, items, assets, manifest, graveyard,
111
120
  connections: Array.isArray(canvas.connections) ? canvas.connections : [],
112
121
  lines: Array.isArray(canvas.lines) ? canvas.lines : [],
113
122
  strokes: Array.isArray(canvas.strokes) ? canvas.strokes : [],
@@ -152,6 +161,39 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
152
161
  const conflicts = [];
153
162
  const delta = { added: [], updated: [], archived: [], removed: [] };
154
163
 
164
+ // ── Graveyard (2026-08-07) ───────────────────────────────────────────────
165
+ // An honored tombstone still REMOVES the card from the brain — `order`,
166
+ // `positions` and `struct.cards` are unchanged, so every read surface, the
167
+ // renderer and the no-loss invariant keep their exact current semantics. What
168
+ // changes is that the BYTES are moved to `graveyard/` instead of destroyed,
169
+ // making the delete recoverable. Deliberately NOT the Archive container:
170
+ // archived cards are only re-parented, so they still sit in `order` and still
171
+ // render — a deleted card put there would visibly reappear in place.
172
+ const graveyard = {};
173
+ for (const src of [T, O]) if (src?.graveyard) for (const [gid, g] of Object.entries(src.graveyard)) {
174
+ // Union, never prune: one machine emptying its bin must not empty another's.
175
+ if (!graveyard[gid] || Number(g.meta?.deletedAt || 0) > Number(graveyard[gid].meta?.deletedAt || 0)) graveyard[gid] = g;
176
+ }
177
+ const buryCard = (id) => {
178
+ if (graveyard[id]) return; // already buried — keep the original stamp
179
+ const json = O.items[id] ?? T.items[id] ?? null;
180
+ if (json == null) return; // nothing to preserve
181
+ const pos = O.positions[id] || T.positions[id] || null;
182
+ let preview = '';
183
+ try { preview = String(JSON.parse(json)?.content || '').replace(/\s+/g, ' ').trim().slice(0, 140); } catch { /* media card */ }
184
+ graveyard[id] = {
185
+ meta: {
186
+ deletedAt: Date.now(), // `now` below is declared later in this scope
187
+ deletedBy: 'human',
188
+ area: (O.titleById.get(pos?.parentId) || T.titleById.get(pos?.parentId) || null),
189
+ parentId: pos?.parentId ?? null,
190
+ pos: pos ? { x: pos.x, y: pos.y, w: pos.w ?? null, h: pos.h ?? null } : null,
191
+ preview,
192
+ },
193
+ json,
194
+ };
195
+ };
196
+
155
197
  for (const id of allIds) {
156
198
  const inO = O.items[id] != null, inT = T.items[id] != null;
157
199
  const inB = baseItem(id) != null;
@@ -169,10 +211,35 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
169
211
  conflicts.push({ id, kind: 'delete-vs-edit', kept: 'theirs' });
170
212
  // fall through to keep from theirs below
171
213
  } else {
214
+ buryCard(id); // keep the bytes; the card still leaves the brain
172
215
  delta.removed.push(id);
173
216
  continue; // honored delete
174
217
  }
175
218
  }
219
+ // ── The bin is a DURABLE tombstone (2026-08-07) ────────────────────────
220
+ // Before it existed, `deletedIds` was a per-call argument that was consumed
221
+ // and thrown away, so a delete could not cross machines: sync with a peer
222
+ // who still had the card and it came straight back, because "absent from
223
+ // ours" alone is deliberately never a delete. A graveyard entry is not mere
224
+ // absence — it is a recorded human deletion — so it is honored here.
225
+ //
226
+ // The delete-vs-edit rule is unchanged and still wins: if the other side
227
+ // EDITED the card after our deletion, their information is newer than our
228
+ // intent, so the card comes back live and leaves the bin. Without a base we
229
+ // cannot prove an edit, so the deletion stands (conservative: a resurrected
230
+ // card is visible and re-deletable; a lost one is not).
231
+ if (graveyard[id] && !inO) {
232
+ const theirsChangedSinceBase = inT && inB && !sameMeaning(T.items[id], baseItem(id));
233
+ if (inT && theirsChangedSinceBase) {
234
+ conflicts.push({ id, kind: 'delete-vs-edit', kept: 'theirs' });
235
+ delete graveyard[id]; // resurrected — never in the brain AND the bin
236
+ } else {
237
+ delta.removed.push(id); // the deletion propagates
238
+ continue;
239
+ }
240
+ }
241
+ // Live on our side ⇒ not deleted. Covers a restore and a re-add.
242
+ if (graveyard[id] && inO) delete graveyard[id];
176
243
 
177
244
  if (!inO && !inT) continue;
178
245
 
@@ -294,6 +361,20 @@ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }
294
361
  for (const id of order) zip.file(`items/${shard(id)}/${id}.json`, merged.get(id).json);
295
362
  for (const [p, bytes] of Object.entries(assets)) zip.file(p, bytes);
296
363
 
364
+ // Graveyard: card bytes under graveyard/, metadata in one index. Written only
365
+ // when non-empty so a brain that has never had a delete keeps a byte-identical
366
+ // shape. Nothing here is reachable from `order`, so nothing here can render,
367
+ // be searched, be embedded, or be counted.
368
+ const graveyardEntries = {};
369
+ for (const [gid, g] of Object.entries(graveyard)) {
370
+ if (g?.json == null) continue;
371
+ zip.file(`graveyard/${shard(gid)}/${gid}.json`, g.json);
372
+ graveyardEntries[gid] = g.meta || {};
373
+ }
374
+ if (Object.keys(graveyardEntries).length) {
375
+ zip.file('graveyard.json', JSON.stringify({ version: 1, entries: graveyardEntries }));
376
+ }
377
+
297
378
  const positions = {};
298
379
  for (const id of order) positions[id] = merged.get(id).pos;
299
380