klypix-mcp 1.47.0 → 1.48.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.
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin bin for `klypix-mcp diff` — the worker dispatcher splices the verb out
3
+ // of argv before importing, so this bin re-supplies it. Standalone use works
4
+ // identically: node bin/klypix-diff.mjs <args>
5
+ import { run } from './klypix-git-tools.mjs';
6
+ await run('diff', process.argv.slice(2));
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin bin for `klypix-mcp git-driver` — the worker dispatcher splices the verb out
3
+ // of argv before importing, so this bin re-supplies it. Standalone use works
4
+ // identically: node bin/klypix-git-driver.mjs <args>
5
+ import { run } from './klypix-git-tools.mjs';
6
+ await run('git-driver', process.argv.slice(2));
@@ -0,0 +1,377 @@
1
+ #!/usr/bin/env node
2
+ // klypix-git-tools — the GitHub lane: three verbs that put the brain where
3
+ // dev teams actually live (the repo and the PR page).
4
+ //
5
+ // git-driver [install|status] [repo] register the lossless .klypix merge
6
+ // driver for a repo, zero-command
7
+ // diff [ref] [--brain <path>] readable brain diff vs a git ref
8
+ // pr-brief [baseRef] [--brain <path>] brain cards touching the files
9
+ // changed since baseRef (PR comment)
10
+ //
11
+ // Design rules inherited from the engine:
12
+ // • ONE merge engine — the driver rides src/merge-brains.mjs verbatim.
13
+ // • The registered driver path is the INSTALLED runtime
14
+ // (~/.claude/project-brain) — stable across npx cache evictions; this
15
+ // module self-provisions the three engine files + their two deps there
16
+ // when missing, without running the full hook installer.
17
+ // • A truncated list must NEVER render as complete: every capped section
18
+ // emits its "…and N more" through an unguarded push.
19
+ // • Failure is a calm, specific message + non-zero exit — never a stack.
20
+
21
+ // SHAPE: this is a LIB — the worker dispatcher splices the verb out of argv
22
+ // before importing a verb bin (see runVerb), so each verb has a THIN bin
23
+ // (klypix-git-driver.mjs / klypix-diff.mjs / klypix-pr-brief.mjs) that calls
24
+ // run(<verb>, argv.slice(2)) here. Standalone `node <bin> …` works identically.
25
+
26
+ import fs from 'fs';
27
+ import os from 'os';
28
+ import path from 'path';
29
+ import { execFile } from 'child_process';
30
+ import { createRequire } from 'module';
31
+ import { fileURLToPath } from 'url';
32
+
33
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
34
+ const SRC = path.join(HERE, '..', 'src');
35
+ // Overridable for tests (temp dirs only — never point tests at the real one).
36
+ const BRAIN_DIR = process.env.KLYPIX_BRAIN_DIR || path.join(os.homedir(), '.claude', 'project-brain');
37
+
38
+ let args = [];
39
+ const flag = (name) => {
40
+ const i = args.indexOf(name);
41
+ return i >= 0 ? args[i + 1] : undefined;
42
+ };
43
+ let positional = [];
44
+
45
+ const git = (cwd, gitArgs, opts = {}) => new Promise((resolve, reject) => {
46
+ execFile('git', gitArgs, { cwd, timeout: 15000, windowsHide: true, maxBuffer: 128 * 1024 * 1024, ...opts },
47
+ (err, stdout) => err ? reject(err) : resolve(stdout));
48
+ });
49
+ const gitText = async (cwd, ...a) => String(await git(cwd, a)).trim();
50
+
51
+ async function repoToplevel(startDir) {
52
+ try { return await gitText(startDir, 'rev-parse', '--show-toplevel'); }
53
+ catch { return null; }
54
+ }
55
+
56
+ function findBrain(explicit) {
57
+ if (explicit) {
58
+ const p = path.resolve(explicit);
59
+ return fs.existsSync(p) ? p : null;
60
+ }
61
+ let dir = process.cwd();
62
+ for (let i = 0; i < 12; i++) {
63
+ const p = path.join(dir, 'brain.klypix');
64
+ if (fs.existsSync(p)) return p;
65
+ const up = path.dirname(dir);
66
+ if (up === dir) break;
67
+ dir = up;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ // Card text is stored CANVAS-WRAPPED (single \n ≈ visual line breaks), so a
73
+ // naive first-line is ~35 chars of a sentence. Join the first paragraph back
74
+ // into prose and cap it.
75
+ const firstLine = (t) => {
76
+ const para = String(t || '').split(/\n\s*\n/)[0].replace(/\s*\n\s*/g, ' ').trim();
77
+ return para.length > 110 ? `${para.slice(0, 110)}…` : para;
78
+ };
79
+
80
+ async function loadEngine() {
81
+ const format = await import(new URL('../src/klypix-format.mjs', import.meta.url).href);
82
+ const merge = await import(new URL('../src/merge-brains.mjs', import.meta.url).href);
83
+ return { format, merge };
84
+ }
85
+
86
+ // ── git-driver ──────────────────────────────────────────────────────────────
87
+
88
+ const ENGINE_FILES = ['klypix-merge-driver.mjs', 'merge-brains.mjs', 'klypix-format.mjs'];
89
+ const ENGINE_DEPS = ['jszip', 'fractional-indexing'];
90
+
91
+ // Make sure the INSTALLED runtime can actually run the driver: the three
92
+ // engine files plus their two (dependency-free) deps. This is deliberately a
93
+ // light provision — it never touches hooks or servers; the full installer
94
+ // remains `npx klypix-mcp install`.
95
+ function ensureDriverRuntime() {
96
+ const provisioned = [];
97
+ 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);
103
+ provisioned.push(f);
104
+ }
105
+ }
106
+ const requireHere = createRequire(import.meta.url);
107
+ // Modern packages fence `exports`, so `<dep>/package.json` may not resolve —
108
+ // resolve the MAIN entry instead and walk up to the package root.
109
+ const depRootOf = (dep) => {
110
+ let p = path.dirname(requireHere.resolve(dep));
111
+ for (let i = 0; i < 6; i++) {
112
+ const pkg = path.join(p, 'package.json');
113
+ try {
114
+ if (fs.existsSync(pkg) && JSON.parse(fs.readFileSync(pkg, 'utf8')).name === dep) return p;
115
+ } catch { /* keep walking */ }
116
+ const up = path.dirname(p);
117
+ if (up === p) break;
118
+ p = up;
119
+ }
120
+ throw new Error(`cannot locate package root for dependency "${dep}"`);
121
+ };
122
+ const destMods = path.join(BRAIN_DIR, 'node_modules');
123
+ // Deps are provisioned as their RECURSIVE closure (jszip alone pulls pako,
124
+ // lie, readable-stream, …) — everything resolves from the local install, so
125
+ // this stays offline and deterministic. Nested (unhoisted) deps resolve via
126
+ // a require scoped to their parent package.
127
+ const provisionDep = (dep, fromDir, seen) => {
128
+ if (seen.has(dep)) return;
129
+ seen.add(dep);
130
+ let root;
131
+ try { root = depRootOf(dep); }
132
+ catch {
133
+ const scoped = createRequire(path.join(fromDir, 'package.json'));
134
+ let p = path.dirname(scoped.resolve(dep));
135
+ while (p !== path.dirname(p) && !fs.existsSync(path.join(p, 'package.json'))) p = path.dirname(p);
136
+ root = p;
137
+ }
138
+ const destDir = path.join(destMods, dep);
139
+ if (!fs.existsSync(destDir)) {
140
+ // Exclude only node_modules NESTED INSIDE the package (the closure walk
141
+ // provisions those flat) — judged relative to the package root, because
142
+ // the source root itself lives under a node_modules path.
143
+ fs.cpSync(root, destDir, {
144
+ recursive: true,
145
+ filter: (s) => !path.relative(root, s).split(path.sep).includes('node_modules'),
146
+ });
147
+ provisioned.push(`node_modules/${dep}`);
148
+ }
149
+ let pkg = {};
150
+ try { pkg = JSON.parse(fs.readFileSync(path.join(root, 'package.json'), 'utf8')); } catch { /* leaf */ }
151
+ for (const child of Object.keys(pkg.dependencies || {})) provisionDep(child, root, seen);
152
+ };
153
+ const seen = new Set();
154
+ for (const dep of ENGINE_DEPS) provisionDep(dep, path.join(HERE, '..'), seen);
155
+ return provisioned;
156
+ }
157
+
158
+ const DRIVER_ATTR_RULE = '*.klypix merge=klypix -text';
159
+
160
+ async function gitDriver() {
161
+ const sub = positional[0] && !fs.existsSync(positional[0]) ? positional[0] : 'install';
162
+ const repoArg = positional.find(p => fs.existsSync(p)) || process.cwd();
163
+ const toplevel = await repoToplevel(repoArg);
164
+ if (!toplevel) { console.error(`Not a git repository: ${repoArg}`); process.exit(1); }
165
+
166
+ const driverPath = path.join(BRAIN_DIR, 'klypix-merge-driver.mjs');
167
+ const driverCmd = `node "${driverPath.replace(/\\/g, '/')}" %O %A %B %P`;
168
+ const gaPath = path.join(toplevel, '.gitattributes');
169
+ const gaText = fs.existsSync(gaPath) ? fs.readFileSync(gaPath, 'utf8') : '';
170
+ const gaHasRule = /merge=klypix/.test(gaText);
171
+
172
+ if (sub === 'status') {
173
+ let configured = '';
174
+ try { configured = await gitText(toplevel, 'config', '--get', 'merge.klypix.driver'); } catch { /* unset */ }
175
+ const runtimeOk = ENGINE_FILES.every(f => fs.existsSync(path.join(BRAIN_DIR, f)));
176
+ console.log(`repo: ${toplevel}`);
177
+ console.log(`driver config: ${configured || '(not registered)'}`);
178
+ console.log(`.gitattributes rule: ${gaHasRule ? 'present' : 'missing'}`);
179
+ console.log(`installed runtime: ${runtimeOk ? BRAIN_DIR : 'missing — run: npx klypix-mcp git-driver install'}`);
180
+ process.exit(configured && gaHasRule && runtimeOk ? 0 : 1);
181
+ }
182
+
183
+ const provisioned = ensureDriverRuntime();
184
+ let already = false;
185
+ try { already = (await gitText(toplevel, 'config', '--get', 'merge.klypix.driver')) === driverCmd; } catch { /* unset */ }
186
+ if (!already) {
187
+ await git(toplevel, ['config', 'merge.klypix.name', 'KLYPIX lossless brain merge (union by card id)']);
188
+ await git(toplevel, ['config', 'merge.klypix.driver', driverCmd]);
189
+ }
190
+ let gaState = 'present';
191
+ 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`;
193
+ fs.appendFileSync(gaPath, rule);
194
+ gaState = 'added';
195
+ }
196
+ console.log(`✓ ${already ? 'Already registered' : 'Registered'} the .klypix merge driver for ${toplevel}`);
197
+ console.log(` driver: ${driverPath}${provisioned.length ? ` (provisioned: ${provisioned.join(', ')})` : ''}`);
198
+ console.log(` .gitattributes rule: ${gaState}${gaState === 'added' ? ' — commit it so every teammate\'s clone routes .klypix merges here' : ''}`);
199
+ console.log(' Teammates run the same command once per machine; unregistered machines fall back to a normal conflict.');
200
+ }
201
+
202
+ // ── diff ────────────────────────────────────────────────────────────────────
203
+
204
+ function renderCardList(title, entries, cap = 20) {
205
+ if (!entries.length) return [];
206
+ const lines = [`**${title} (${entries.length})**`];
207
+ for (const e of entries.slice(0, cap)) lines.push(`- ${e}`);
208
+ // Truncation notice is NEVER subject to the cap it reports.
209
+ if (entries.length > cap) lines.push(`- …and ${entries.length - cap} more`);
210
+ lines.push('');
211
+ return lines;
212
+ }
213
+
214
+ async function brainDiff() {
215
+ const ref = positional[0] || 'HEAD';
216
+ const brain = findBrain(flag('--brain'));
217
+ if (!brain) { console.error('No brain.klypix found (searched upward from cwd; use --brain <path>).'); process.exit(1); }
218
+ const toplevel = await repoToplevel(path.dirname(brain));
219
+ if (!toplevel) { console.error(`Brain is not inside a git repository: ${brain}`); process.exit(1); }
220
+ const rel = path.relative(toplevel, brain).replace(/\\/g, '/');
221
+
222
+ const { format, merge } = await loadEngine();
223
+ const current = fs.readFileSync(brain);
224
+
225
+ let baseBuf = null;
226
+ try { baseBuf = Buffer.from(await git(toplevel, ['show', `${ref}:${rel}`], { encoding: 'buffer' })); }
227
+ catch { baseBuf = null; }
228
+
229
+ const out = [`### 🧠 Brain diff — \`${rel}\` vs \`${ref}\``, ''];
230
+ if (!baseBuf || baseBuf.length === 0) {
231
+ const { struct } = await format.parseKlypix(current);
232
+ out.push(`The brain does not exist at \`${ref}\` — everything is new here (${struct.cards.length} cards).`);
233
+ console.log(out.join('\n'));
234
+ return;
235
+ }
236
+
237
+ // SEMANTIC diff, never byte diff: .klypix re-serialization is deliberately
238
+ // non-reproducible (zip metadata, zIndex renumbering), so comparing raw item
239
+ // bytes reports the whole brain as "updated" (live-reproduced: 1308 false
240
+ // updates over 8 commits). Parse both sides and compare key-sorted JSON with
241
+ // display-derived fields stripped — the same discipline as the sync core.
242
+ const stable = (v) => JSON.stringify(v, (_k, val) =>
243
+ (val && typeof val === 'object' && !Array.isArray(val))
244
+ ? Object.fromEntries(Object.keys(val).sort().map(x => [x, val[x]]))
245
+ : val);
246
+ const cardMap = async (buf) => {
247
+ const { zip, canvas } = await format.parseKlypix(buf);
248
+ const ids = [...new Set([...(Array.isArray(canvas.order) ? canvas.order : []), ...Object.keys(canvas.positions || {})])];
249
+ const m = new Map();
250
+ for (const id of ids) {
251
+ const f = zip.file(`items/${format.shard(id)}/${id}.json`);
252
+ if (!f) continue;
253
+ try {
254
+ const item = JSON.parse(await f.async('string'));
255
+ m.set(id, {
256
+ sig: stable({ ...item, zIndex: undefined }),
257
+ title: firstLine(item.content || item.title || '') || `\`${id}\``,
258
+ });
259
+ } catch { /* unreadable item — skip rather than mis-report */ }
260
+ }
261
+ return { map: m, connections: Array.isArray(canvas.connections) ? canvas.connections.length : 0 };
262
+ };
263
+ void merge; // brainDelta stays the live-apply engine; diff is semantic by design
264
+
265
+ const [baseSide, curSide] = await Promise.all([cardMap(baseBuf), cardMap(current)]);
266
+ const added = [], updated = [], removed = [];
267
+ for (const [id, cur] of curSide.map) {
268
+ const prev = baseSide.map.get(id);
269
+ if (!prev) added.push(cur.title);
270
+ else if (prev.sig !== cur.sig) updated.push(cur.title);
271
+ }
272
+ for (const [id, prev] of baseSide.map) {
273
+ if (!curSide.map.has(id)) removed.push(prev.title);
274
+ }
275
+ const connDelta = curSide.connections - baseSide.connections;
276
+
277
+ if (!added.length && !updated.length && !removed.length && !connDelta) {
278
+ out.push('No card-level changes.');
279
+ } else {
280
+ out.push(`**${added.length} added · ${updated.length} updated · ${removed.length} removed**`, '');
281
+ out.push(...renderCardList('Added', added));
282
+ out.push(...renderCardList('Updated', updated));
283
+ out.push(...renderCardList('Removed', removed));
284
+ if (connDelta) out.push(`_${connDelta > 0 ? '+' : ''}${connDelta} connection(s)._`);
285
+ }
286
+ console.log(out.join('\n'));
287
+ }
288
+
289
+ // ── pr-brief ────────────────────────────────────────────────────────────────
290
+
291
+ // The brain's own evidence-tag convention: #file-<slug> where slug is the
292
+ // basename minus its last extension, lowercased, non-alphanumerics folded to
293
+ // hyphens. Tag matches only (precision over recall — a PR comment that spams
294
+ // unrelated cards teaches people to ignore it).
295
+ function fileSlug(p) {
296
+ const base = path.basename(p).replace(/\.[^.]+$/, '');
297
+ return base.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
298
+ }
299
+
300
+ async function prBrief() {
301
+ const baseRef = positional[0] || 'HEAD~1';
302
+ const brain = findBrain(flag('--brain'));
303
+ if (!brain) { console.error('No brain.klypix found (searched upward from cwd; use --brain <path>).'); process.exit(1); }
304
+ const toplevel = await repoToplevel(path.dirname(brain));
305
+ if (!toplevel) { console.error(`Brain is not inside a git repository: ${brain}`); process.exit(1); }
306
+
307
+ let changed = [];
308
+ try {
309
+ changed = String(await git(toplevel, ['diff', '--name-only', `${baseRef}...HEAD`]))
310
+ .split('\n').map(s => s.trim()).filter(Boolean);
311
+ } catch (e) {
312
+ console.error(`git diff against "${baseRef}" failed: ${String(e.message || e).split('\n')[0]}`);
313
+ process.exit(1);
314
+ }
315
+ if (!changed.length) { console.log('_No changed files — no brain context to attach._'); return; }
316
+
317
+ const { format } = await loadEngine();
318
+ const { struct } = await format.parseKlypix(fs.readFileSync(brain));
319
+
320
+ const perFile = new Map(); // file -> [card first lines]
321
+ let total = 0;
322
+ for (const file of changed) {
323
+ const slug = fileSlug(file);
324
+ if (!slug) continue;
325
+ const tag = `#file-${slug}`;
326
+ const hits = [];
327
+ for (const card of struct.cards) {
328
+ const text = String(card.text || card.title || '');
329
+ const idx = text.indexOf(tag);
330
+ if (idx < 0) continue;
331
+ // Tag boundary: the next char must not extend the slug (avoids
332
+ // #file-use matching #file-usechat).
333
+ const after = text[idx + tag.length];
334
+ if (after && /[a-z0-9-]/.test(after)) continue;
335
+ hits.push(firstLine(text));
336
+ if (hits.length >= 3) break; // cap per file; total notice below
337
+ }
338
+ if (hits.length) { perFile.set(file, hits); total += hits.length; }
339
+ }
340
+
341
+ if (!perFile.size) {
342
+ console.log(`_No brain cards reference the ${changed.length} changed file(s)._`);
343
+ return;
344
+ }
345
+
346
+ const out = [`### 🧠 Brain context for this PR`, '',
347
+ `Decisions and findings already recorded about the files this PR touches (${perFile.size} of ${changed.length} changed files have brain context):`, ''];
348
+ let printed = 0;
349
+ const FILE_CAP = 12;
350
+ let fileIdx = 0;
351
+ for (const [file, hits] of perFile) {
352
+ if (fileIdx >= FILE_CAP) break;
353
+ fileIdx++;
354
+ out.push(`**\`${file}\`**`);
355
+ for (const h of hits) { out.push(`- ${h}`); printed++; }
356
+ out.push('');
357
+ }
358
+ if (perFile.size > FILE_CAP) out.push(`…and ${perFile.size - FILE_CAP} more file(s) with brain context.`);
359
+ out.push(`_From \`${path.relative(toplevel, brain).replace(/\\/g, '/')}\` — the project's shared brain. ${printed} card(s) shown._`);
360
+ console.log(out.join('\n'));
361
+ }
362
+
363
+ // ── entry ───────────────────────────────────────────────────────────────────
364
+
365
+ export async function run(verb, rawArgs) {
366
+ args = Array.isArray(rawArgs) ? rawArgs : [];
367
+ positional = args.filter((a, i) => !a.startsWith('--') && args[i - 1] !== '--brain');
368
+ try {
369
+ if (verb === 'git-driver') await gitDriver();
370
+ else if (verb === 'diff') await brainDiff();
371
+ else if (verb === 'pr-brief') await prBrief();
372
+ else { console.error(`klypix-git-tools: unknown verb "${verb}"`); process.exit(2); }
373
+ } catch (e) {
374
+ console.error(`${verb} failed: ${String(e?.message || e).split('\n')[0]}`);
375
+ process.exit(1);
376
+ }
377
+ }
@@ -19,7 +19,7 @@ const PKG_VERSION = (() => {
19
19
  }
20
20
  })();
21
21
 
22
- const DIRECT = new Set(['install', 'link', 'doctor', 'conformance', 'garden-code', 'init']);
22
+ const DIRECT = new Set(['install', 'link', 'doctor', 'conformance', 'garden-code', 'init', 'git-driver', 'diff', 'pr-brief']);
23
23
 
24
24
  const USAGE = [
25
25
  `klypix-mcp ${PKG_VERSION} — shared project brain + MCP coordination server.`,
@@ -31,6 +31,9 @@ const USAGE = [
31
31
  ' conformance [--json] launch two real MCP clients against this build',
32
32
  ' init seed a starter ./brain.klypix + print an MCP config',
33
33
  ' garden-code [brain] print the human approval code for brain_garden',
34
+ ' git-driver [install|status] [repo] register the lossless .klypix merge driver for a repo (zero-command teams)',
35
+ ' diff [ref] [--brain <path>] readable brain diff vs a git ref (default HEAD) — markdown to stdout',
36
+ ' pr-brief [baseRef] [--brain <path>] brain decisions touching the files changed since baseRef — PR-comment markdown',
34
37
  '',
35
38
  'With no verb (or any --flag, e.g. --vault <dir>) it runs as an MCP stdio server.',
36
39
  'There is no uninstall command — removal is manual (see README).',
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env node
2
+ // Thin bin for `klypix-mcp pr-brief` — the worker dispatcher splices the verb out
3
+ // of argv before importing, so this bin re-supplies it. Standalone use works
4
+ // identically: node bin/klypix-pr-brief.mjs <args>
5
+ import { run } from './klypix-git-tools.mjs';
6
+ await run('pr-brief', process.argv.slice(2));
@@ -102,6 +102,14 @@ await runVerb('doctor', './klypix-doctor.mjs');
102
102
  // overlap detection, proactive logging, and guaranteed next-action delivery.
103
103
  await runVerb('conformance', './klypix-conformance.mjs');
104
104
 
105
+ // `npx klypix-mcp git-driver | diff | pr-brief` — the GitHub lane: register the
106
+ // lossless .klypix merge driver for any repo, render a readable brain diff vs a
107
+ // git ref, and print the brain cards touching a PR's changed files. One module,
108
+ // three verbs (it reads argv[2] itself).
109
+ await runVerb('git-driver', './klypix-git-driver.mjs');
110
+ await runVerb('diff', './klypix-diff.mjs');
111
+ await runVerb('pr-brief', './klypix-pr-brief.mjs');
112
+
105
113
  // `npx klypix-mcp garden-code` — the HUMAN half of the garden approval gate.
106
114
  // brain_garden's apply requires an 8-char code derived from the exact dormant
107
115
  // candidate set + day; the agent is deliberately never shown it. The human runs
@@ -0,0 +1,68 @@
1
+ # Brain-aware pull requests — copy this file to .github/workflows/brain-pr.yml
2
+ # in any repo whose brain.klypix is committed.
3
+ #
4
+ # On every PR it posts (and keeps updated) ONE sticky comment with:
5
+ # 1. the brain decisions/corrections that reference the files the PR touches
6
+ # (evidence tags: #file-<name> inside cards), and
7
+ # 2. a readable card-level diff of the brain itself, when the PR changes it.
8
+ #
9
+ # Nothing here talks to any KLYPIX service — the brain is read from the
10
+ # checkout, exactly as your agents read it. Requires only the default
11
+ # GITHUB_TOKEN with pull-requests: write.
12
+
13
+ name: brain-pr
14
+ on:
15
+ pull_request:
16
+ types: [opened, synchronize, reopened]
17
+
18
+ permissions:
19
+ contents: read
20
+ pull-requests: write
21
+
22
+ jobs:
23
+ brain-context:
24
+ runs-on: ubuntu-latest
25
+ steps:
26
+ - uses: actions/checkout@v4
27
+ with:
28
+ fetch-depth: 0 # pr-brief and diff need the base ref
29
+
30
+ - uses: actions/setup-node@v4
31
+ with:
32
+ node-version: 20
33
+
34
+ - name: Build the comment
35
+ id: brain
36
+ env:
37
+ BASE: ${{ github.event.pull_request.base.sha }}
38
+ run: |
39
+ {
40
+ npx --yes klypix-mcp pr-brief "$BASE" || true
41
+ echo ""
42
+ # Only show the brain diff when the PR actually changes the brain.
43
+ if git diff --name-only "$BASE"...HEAD | grep -q '\.klypix$'; then
44
+ npx --yes klypix-mcp diff "$BASE" || true
45
+ fi
46
+ } > brain-comment.md
47
+ # Skip the comment entirely when there is nothing to say.
48
+ if ! grep -qE '🧠' brain-comment.md; then
49
+ echo "post=false" >> "$GITHUB_OUTPUT"
50
+ else
51
+ echo "post=true" >> "$GITHUB_OUTPUT"
52
+ fi
53
+
54
+ - name: Post / update the sticky comment
55
+ if: steps.brain.outputs.post == 'true'
56
+ env:
57
+ GH_TOKEN: ${{ github.token }}
58
+ PR: ${{ github.event.pull_request.number }}
59
+ run: |
60
+ MARKER="<!-- klypix-brain-pr -->"
61
+ printf '%s\n\n' "$MARKER" | cat - brain-comment.md > body.md
62
+ EXISTING=$(gh api "repos/${GITHUB_REPOSITORY}/issues/${PR}/comments" \
63
+ --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" | head -1)
64
+ if [ -n "$EXISTING" ]; then
65
+ gh api -X PATCH "repos/${GITHUB_REPOSITORY}/issues/comments/${EXISTING}" -F body=@body.md > /dev/null
66
+ else
67
+ gh pr comment "$PR" --body-file body.md > /dev/null
68
+ fi
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "klypix-mcp",
3
- "version": "1.47.0",
3
+ "version": "1.48.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",
@@ -66,7 +66,7 @@
66
66
  "node": ">=18"
67
67
  },
68
68
  "scripts": {
69
- "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.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/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/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/uninstall.mjs"
69
+ "test": "node test/mcp-auto-update.mjs && node test/mcp-supervisor.mjs && node test/codex-hooks.mjs && node test/agent-presence.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/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/decay-status.mjs && node test/decay-hook.mjs && node test/presence-visibility.mjs && node test/cli-args.mjs && node test/format-guard.mjs && node test/git-tools.mjs && node test/uninstall.mjs"
70
70
  },
71
71
  "dependencies": {
72
72
  "@modelcontextprotocol/ext-apps": "^1.7.4",
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env node
2
+ // klypix-merge-driver — git merge driver for .klypix files.
3
+ //
4
+ // Wires the existing lossless 3-way engine (./merge-brains.mjs — the same one
5
+ // the app uses for merge-on-save) into git, so two people committing to one
6
+ // brain.klypix stop hitting manual binary conflicts: git calls this on
7
+ // conflict, the union merge runs, and both sides' cards survive.
8
+ //
9
+ // git invokes it as: node scripts/klypix-merge-driver.mjs %O %A %B %P
10
+ // %O = common ancestor file %A = ours (result is written HERE)
11
+ // %B = theirs %P = real path (logging only)
12
+ // Exit 0 = merged; any failure exits 1, which leaves the normal binary
13
+ // conflict — i.e. exactly the behavior without this driver. Git's merge
14
+ // commit keeps both parents, so even a bad merge is always reconstructable.
15
+ //
16
+ // Registration is per-machine (git config is never committed):
17
+ // npx klypix-mcp git-driver install (any repo, zero setup — canonical)
18
+ // npm run setup:merge-driver (KLYPIX repo's local convenience)
19
+ // The KLYPIX desktop app also self-registers this silently when it opens a
20
+ // brain inside a git repo. .gitattributes routes *.klypix here; unregistered
21
+ // machines just get the old manual conflict. CANONICAL HOME: klypix-mcp/src —
22
+ // installs flatten it into ~/.claude/project-brain beside merge-brains.mjs.
23
+ //
24
+ // DELETE SEMANTICS (git context ≠ app context): merge-brains treats absence
25
+ // as NOT-a-delete (in the app, absence can be a deferred renderer apply) and
26
+ // drops cards only via explicit tombstones. In git, both sides are FULL
27
+ // COMMITTED snapshots, so "in ancestor, absent from a side" is a deliberate,
28
+ // committed delete. We honor it as a tombstone ONLY when the other side left
29
+ // the card untouched; if the other side EDITED it after the ancestor, no
30
+ // tombstone is passed and the union keeps the edited card (delete-vs-edit
31
+ // resolves to the edit — no-loss wins over delete).
32
+
33
+ import fs from 'node:fs';
34
+ import { mergeBrains } from './merge-brains.mjs';
35
+ import { parseKlypix, shard } from './klypix-format.mjs';
36
+
37
+ // id -> verbatim item JSON string for one side (null for an empty/absent side).
38
+ async function itemsOf(buf) {
39
+ if (!buf || buf.length === 0) return null;
40
+ const { zip, canvas } = await parseKlypix(buf);
41
+ const ids = new Set([...(Array.isArray(canvas.order) ? canvas.order : []), ...Object.keys(canvas.positions || {})]);
42
+ const m = new Map();
43
+ for (const id of ids) {
44
+ const f = zip.file(`items/${shard(id)}/${id}.json`);
45
+ m.set(id, f ? await f.async('string') : null);
46
+ }
47
+ return m;
48
+ }
49
+
50
+ const [, , oPath, aPath, bPath, realPath] = process.argv;
51
+ if (!oPath || !aPath || !bPath) {
52
+ console.error('usage: klypix-merge-driver <ancestor> <ours> <theirs> [path]');
53
+ process.exit(1);
54
+ }
55
+
56
+ try {
57
+ const O = fs.readFileSync(oPath); // may be 0 bytes (added on both sides)
58
+ const A = fs.readFileSync(aPath);
59
+ const B = fs.readFileSync(bPath);
60
+ const base = O.length ? O : null;
61
+
62
+ const [bi, ai, ti] = await Promise.all([itemsOf(base), itemsOf(A), itemsOf(B)]);
63
+
64
+ // Committed-absence tombstones (see DELETE SEMANTICS above).
65
+ const deletedIds = [];
66
+ if (bi && ai && ti) {
67
+ for (const [id, baseJson] of bi) {
68
+ const inA = ai.has(id), inB = ti.has(id);
69
+ if (inA && inB) continue; // alive on both
70
+ if (!inA && !inB) { deletedIds.push(id); continue; } // deleted on both
71
+ const survivorJson = inA ? ai.get(id) : ti.get(id);
72
+ if (survivorJson === baseJson) deletedIds.push(id); // delete vs untouched → honor
73
+ // delete vs EDIT → no tombstone; union keeps the edited card
74
+ }
75
+ }
76
+
77
+ const { buffer, conflicts, delta } = await mergeBrains({ base, ours: A, theirs: B, deletedIds });
78
+ fs.writeFileSync(aPath, buffer);
79
+ const bits = [];
80
+ if (delta.added.length) bits.push(`+${delta.added.length} card(s)`);
81
+ if (deletedIds.length) bits.push(`-${deletedIds.length} delete(s) honored`);
82
+ if (conflicts.length) bits.push(`${conflicts.length} conflict twin(s) preserved`);
83
+ console.error(`klypix-merge: ${realPath || 'brain'} united losslessly${bits.length ? ' — ' + bits.join(', ') : ''}`);
84
+ process.exit(0);
85
+ } catch (e) {
86
+ // Any failure → normal binary conflict, same as a machine without the driver.
87
+ console.error(`klypix-merge: ${realPath || 'brain'} — falling back to manual conflict (${e?.message || e})`);
88
+ process.exit(1);
89
+ }
@@ -0,0 +1,339 @@
1
+ #!/usr/bin/env node
2
+ // merge-brains — the pure, provable core of the desktop-app<->hooks brain
3
+ // concurrency fix. A 3-way UNION-by-stable-id reconcile of two .klypix brains
4
+ // that share a common ancestor, designed so that NO CARD CAN BE LOST.
5
+ //
6
+ // Why this exists: the desktop app used to SAVE the brain with a blind full-file
7
+ // overwrite, clobbering any card the Claude Code hooks captured after the app
8
+ // opened. This replaces overwrite with union: the app re-reads the disk copy
9
+ // INSIDE the capture lock and merges, so a hook capture written after open is
10
+ // always kept — even if the lock is missed (union is non-destructive).
11
+ //
12
+ // HARDENED against the adversarial design review (data-loss blockers):
13
+ // • Deletes are honored ONLY via explicit tombstones (deletedIds) — a card
14
+ // merely ABSENT from `ours` is NEVER inferred as a delete (that absence can
15
+ // be a deferred/gated renderer apply, not a deletion). This is the fix for
16
+ // the "false-delete clobber" + "delete-by-absence" blockers.
17
+ // • assets/ entries are UNIONed by path (else theirs-only images ship blank).
18
+ // • Content conflict (both edited the same card) keeps BOTH texts losslessly:
19
+ // the human's stays live on the card, the agent's is preserved as a linked
20
+ // twin card — never silently dropped.
21
+ // • zKeys are de-collided (duplicate keys silently no-op in the app reducer).
22
+ // • Post-merge SUPERSET VERIFICATION: the result is asserted to contain every
23
+ // surviving id from both sides; the function throws rather than return a
24
+ // buffer that lost a card.
25
+ //
26
+ // Pure + dependency-light: reads via the shared parseKlypix, so it stays correct
27
+ // as the format evolves. CANONICAL HOME: klypix-mcp/src (moved 2026-08-01 so the
28
+ // git merge driver is npm-distributable to ANY repo — supersedes the old
29
+ // "APP-maintained, edit in KLYPIX scripts/" note). The KLYPIX app bundles this
30
+ // file back via sync-bundled-mcp exactly like klypix-format.mjs, and its
31
+ // brainEngine/deploy paths keep loading it unchanged. Edit it HERE — the app
32
+ // copy is GENERATED. It flattens into ~/.claude/project-brain on install, where
33
+ // jszip + fractional-indexing already live.
34
+
35
+ import JSZip from 'jszip';
36
+ import { parseKlypix, shard } from './klypix-format.mjs';
37
+ import { generateKeyBetween } from 'fractional-indexing';
38
+
39
+ const isValidZKey = (k) => { try { generateKeyBetween(k, null); return true; } catch { return false; } };
40
+ const rand = () => Math.random().toString(36).slice(2, 10);
41
+ const ARCHIVE = /^archive$/i;
42
+
43
+ // Load one .klypix buffer into a flat, comparison-friendly shape. Unchanged
44
+ // cards across ancestor/descendant buffers keep byte-identical item JSON, so a
45
+ // simple string compare detects real content edits.
46
+ async function loadSide(buf) {
47
+ if (!buf) return null;
48
+ const { zip, canvas, manifest, struct } = await parseKlypix(buf);
49
+ const order = Array.isArray(canvas.order) ? canvas.order : [];
50
+ const positions = canvas.positions || {};
51
+ const items = {}; // id -> raw item JSON string (verbatim bytes)
52
+ const idSet = new Set(order.length ? order : Object.keys(positions));
53
+ for (const id of idSet) {
54
+ const f = zip.file(`items/${shard(id)}/${id}.json`);
55
+ items[id] = f ? await f.async('string') : null;
56
+ }
57
+ const assets = {}; // "assets/<id>" -> nodebuffer
58
+ for (const p of Object.keys(zip.files)) {
59
+ if (p.startsWith('assets/') && !zip.files[p].dir) assets[p] = await zip.file(p).async('nodebuffer');
60
+ }
61
+ const titleById = new Map(struct.cards.map(c => [c.id, c.title || '']));
62
+ return {
63
+ order, positions, items, assets, manifest,
64
+ connections: Array.isArray(canvas.connections) ? canvas.connections : [],
65
+ lines: Array.isArray(canvas.lines) ? canvas.lines : [],
66
+ strokes: Array.isArray(canvas.strokes) ? canvas.strokes : [],
67
+ settings: canvas.settings || {},
68
+ nextGroupNumber: Number(canvas.nextGroupNumber) || 1, // top-level key, NOT settings
69
+ view: canvas.view || null,
70
+ titleById,
71
+ ids: idSet,
72
+ };
73
+ }
74
+
75
+ const samePos = (a, b) => !!a && !!b &&
76
+ a.x === b.x && a.y === b.y && (a.w ?? null) === (b.w ?? null) && (a.h ?? null) === (b.h ?? null);
77
+ const sameParent = (a, b) => (a?.parentId ?? null) === (b?.parentId ?? null);
78
+
79
+ /**
80
+ * mergeBrains — 3-way union of two brains sharing ancestor `base`.
81
+ * @param {{base?:Buffer|null, ours:Buffer, theirs:Buffer, deletedIds?:string[]}} args
82
+ * base = on-disk struct snapshotted when the app opened (null → pure union).
83
+ * ours = the app's in-memory brain (what the human is saving).
84
+ * theirs = the current on-disk brain, re-read INSIDE the lock (has hook captures).
85
+ * deletedIds= ids the HUMAN explicitly deleted (tombstones). ONLY these can drop.
86
+ * @returns {Promise<{buffer:Buffer, delta:{added:string[],updated:string[],archived:string[],removed:string[]}, conflicts:object[], stats:object}>}
87
+ */
88
+ export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }) {
89
+ if (!ours || !theirs) throw new Error('mergeBrains needs both ours and theirs buffers');
90
+ const B = await loadSide(base);
91
+ const O = await loadSide(ours);
92
+ const T = await loadSide(theirs);
93
+ const del = new Set(deletedIds);
94
+
95
+ const baseItem = (id) => (B && B.items[id]) || null;
96
+ const basePos = (id) => (B && B.positions[id]) || null;
97
+ const parentTitle = (side, pos) => {
98
+ const pid = pos?.parentId; if (!pid) return '';
99
+ return String(side.titleById.get(pid) || '');
100
+ };
101
+
102
+ const allIds = new Set([...O.ids, ...T.ids]);
103
+ const merged = new Map(); // id -> { json, pos }
104
+ const extras = []; // conflict-twin cards to append
105
+ const conflicts = [];
106
+ const delta = { added: [], updated: [], archived: [], removed: [] };
107
+
108
+ for (const id of allIds) {
109
+ const inO = O.items[id] != null, inT = T.items[id] != null;
110
+ const inB = baseItem(id) != null;
111
+
112
+ // ── Explicit human delete (tombstone) — the ONLY path that drops a card ──
113
+ if (del.has(id)) {
114
+ const theirsChanged = inT && inB && T.items[id] !== baseItem(id);
115
+ if (inT && theirsChanged) {
116
+ // delete-vs-edit: the human deleted it but a hook edited it after open →
117
+ // KEEP theirs (never lose the hook's new info); record the conflict.
118
+ conflicts.push({ id, kind: 'delete-vs-edit', kept: 'theirs' });
119
+ // fall through to keep from theirs below
120
+ } else {
121
+ delta.removed.push(id);
122
+ continue; // honored delete
123
+ }
124
+ }
125
+
126
+ if (!inO && !inT) continue;
127
+
128
+ // ── Choose CONTENT ──────────────────────────────────────────────────────
129
+ let json, side;
130
+ if (inO && inT) {
131
+ const oChg = !inB || O.items[id] !== baseItem(id);
132
+ const tChg = !inB || T.items[id] !== baseItem(id);
133
+ if (inB && oChg && tChg && O.items[id] !== T.items[id]) {
134
+ // GENUINE content conflict: a card that EXISTED at open, edited differently
135
+ // on both sides → human stays live, agent version preserved as a twin.
136
+ json = O.items[id]; side = 'ours';
137
+ const twinId = `${id}__agconf_${rand()}`;
138
+ extras.push({ id: twinId, json: T.items[id], srcPos: T.positions[id] || O.positions[id], of: id });
139
+ conflicts.push({ id, kind: 'content', keptLive: 'ours', twin: twinId });
140
+ } else if (tChg && !oChg) { json = T.items[id]; side = 'theirs'; delta.updated.push(id); }
141
+ else if (!inB && O.items[id] !== T.items[id]) {
142
+ // Same NEW card (same id) present on BOTH sides but never in base — e.g. an
143
+ // agent card the app also holds via live-apply, re-serialized slightly
144
+ // differently. It's the SAME card, NOT a conflict → take the disk/agent
145
+ // bytes; NEVER spawn a twin for a card that was never in base (the
146
+ // live-apply-then-save duplication bug).
147
+ json = T.items[id]; side = 'theirs';
148
+ }
149
+ else { json = O.items[id]; side = 'ours'; }
150
+ } else if (inT) {
151
+ json = T.items[id]; side = 'theirs';
152
+ if (!inB) delta.added.push(id); // agent added since open — the anti-clobber core
153
+ } else {
154
+ json = O.items[id]; side = 'ours';
155
+ }
156
+
157
+ // ── Choose POSITION + parent (human spatial intent wins; hook archive
158
+ // applies only if the human didn't move/re-parent the card) ───────────
159
+ const oP = O.positions[id], tP = T.positions[id], bP = basePos(id);
160
+ const oMoved = oP && (!bP || !samePos(oP, bP));
161
+ const finalXY = oMoved ? oP : (tP || oP);
162
+
163
+ let parentId;
164
+ const oParentChg = oP && (!bP || !sameParent(oP, bP));
165
+ const tParentChg = tP && (!bP || !sameParent(tP, bP));
166
+ if (oParentChg) parentId = oP.parentId ?? null;
167
+ else if (tParentChg) parentId = tP.parentId ?? null;
168
+ else parentId = (finalXY?.parentId ?? oP?.parentId ?? tP?.parentId ?? null);
169
+
170
+ const pos = { ...(finalXY || oP || tP || {}), parentId };
171
+ // Detect a hook archive-move for the delta receipt.
172
+ if (side === 'theirs' && tParentChg && ARCHIVE.test(parentTitle(T, tP))) delta.archived.push(id);
173
+
174
+ merged.set(id, { json, pos });
175
+ }
176
+
177
+ // ── Conflict twins: place beside their source card, own valid zKey ─────────
178
+ for (const ex of extras) {
179
+ const src = ex.srcPos || {};
180
+ merged.set(ex.id, { json: ex.json, pos: { x: (src.x || 0) + 24, y: (src.y || 0) + 24, w: src.w, h: src.h, parentId: src.parentId ?? null } });
181
+ }
182
+
183
+ // ── Order + zKey heal (de-collide: duplicate zKeys silently no-op in-app) ──
184
+ const order = [];
185
+ const seen = new Set();
186
+ for (const id of [...T.order, ...O.order, ...extras.map(e => e.id)]) {
187
+ if (merged.has(id) && !seen.has(id)) { seen.add(id); order.push(id); }
188
+ }
189
+ // Any merged id not in either order[] (defensive) — append.
190
+ for (const id of merged.keys()) if (!seen.has(id)) { seen.add(id); order.push(id); }
191
+
192
+ const usedZ = new Set();
193
+ let lastZ = null;
194
+ for (const id of order) {
195
+ const rec = merged.get(id);
196
+ let z = rec.pos.zKey;
197
+ if (!z || !isValidZKey(z) || usedZ.has(z)) z = generateKeyBetween(lastZ, null);
198
+ usedZ.add(z); lastZ = z;
199
+ rec.pos = { ...rec.pos, zKey: z, zIndex: order.indexOf(id) };
200
+ }
201
+
202
+ // ── Union connections / lines / strokes by id; drop dangling connections ──
203
+ const byId = (arr) => { const m = new Map(); for (const x of arr) if (x && x.id) m.set(x.id, x); return m; };
204
+ const connMap = new Map([...byId(T.connections), ...byId(O.connections)]);
205
+ const liveIds = new Set(order);
206
+ // Collapse EXACT duplicate edges (same endpoints + relationship + label,
207
+ // different ids). Connection deletes have no tombstone, so an arrange/de-dup
208
+ // that dropped a redundant edge in-app used to see it resurrected from disk
209
+ // by this union — as a byte-identical twin arrow. Never meaningful to keep.
210
+ const seenEdge = new Set();
211
+ const connections = [...connMap.values()].filter(c => {
212
+ if (!(liveIds.has(c.fromId) && liveIds.has(c.toId))) return false;
213
+ const k = `${c.fromId}|${c.toId}|${c.relationship || ''}|${c.label || ''}`;
214
+ if (seenEdge.has(k)) return false;
215
+ seenEdge.add(k);
216
+ return true;
217
+ });
218
+ const lines = [...new Map([...byId(T.lines), ...byId(O.lines)]).values()];
219
+ const strokes = [...new Map([...byId(T.strokes), ...byId(O.strokes)]).values()];
220
+
221
+ // ── Union assets by path (theirs preferred, then ours, then base) ─────────
222
+ const assets = {};
223
+ for (const src of [B, O, T]) if (src) for (const [p, bytes] of Object.entries(src.assets)) assets[p] = bytes;
224
+
225
+ // ── Build merged zip ──────────────────────────────────────────────────────
226
+ const zip = new JSZip();
227
+ const now = Date.now();
228
+ for (const id of order) zip.file(`items/${shard(id)}/${id}.json`, merged.get(id).json);
229
+ for (const [p, bytes] of Object.entries(assets)) zip.file(p, bytes);
230
+
231
+ const positions = {};
232
+ for (const id of order) positions[id] = merged.get(id).pos;
233
+
234
+ // Per-field manifest UNION, theirs-precedence: theirs still wins every field
235
+ // it carries (the original semantics — disk/hook-side stamps survive an app
236
+ // save), but a field only OURS has is no longer dropped. Concretely: the
237
+ // cloud-link stamp (manifest.cloud) added on the local side must survive a
238
+ // merge against an older cloud copy that predates the link.
239
+ const manifest = { format: 'klypix', version: 4, ...(O.manifest || {}), ...(T.manifest || {}) };
240
+ manifest.updatedAt = new Date(now).toISOString();
241
+ manifest.stats = { ...(manifest.stats || {}), itemCount: order.length, assetCount: Object.keys(assets).length };
242
+ zip.file('manifest.json', JSON.stringify(manifest));
243
+
244
+ const canvasJson = {
245
+ version: 4,
246
+ view: O.view || T.view || { panX: 0, panY: 0, zoom: 0.7 }, // human's viewport
247
+ order, connections, lines, strokes,
248
+ nextGroupNumber: Math.max(1, ...[O, T].map(s => Number(s.nextGroupNumber) || 1)),
249
+ positions,
250
+ settings: { ...(T.settings || {}), ...(O.settings || {}) },
251
+ };
252
+ zip.file('canvas.json', JSON.stringify(canvasJson));
253
+ const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
254
+
255
+ // ── SUPERSET VERIFICATION — prove no card was lost ─────────────────────────
256
+ // Every id that survived on either side (minus honored deletes) MUST be in the
257
+ // result; every asset path from either side MUST be present. Throw otherwise.
258
+ const survivors = new Set();
259
+ for (const id of O.ids) if (!delta.removed.includes(id)) survivors.add(id);
260
+ for (const id of T.ids) if (!delta.removed.includes(id)) survivors.add(id);
261
+ const resultIds = new Set(order);
262
+ const missing = [...survivors].filter(id => !resultIds.has(id));
263
+ if (missing.length) throw new Error(`mergeBrains INVARIANT VIOLATED — dropped ${missing.length} card(s): ${missing.slice(0, 5).join(', ')}`);
264
+ const wantAssets = new Set([...Object.keys(O.assets), ...Object.keys(T.assets)]);
265
+ const missingAssets = [...wantAssets].filter(p => !(p in assets));
266
+ if (missingAssets.length) throw new Error(`mergeBrains INVARIANT VIOLATED — dropped ${missingAssets.length} asset(s): ${missingAssets.slice(0, 3).join(', ')}`);
267
+ // Re-parse to guarantee the buffer round-trips (never ship an unreadable brain).
268
+ await parseKlypix(buffer);
269
+
270
+ const stats = {
271
+ ours: O.ids.size, theirs: T.ids.size, base: B ? B.ids.size : 0,
272
+ merged: order.length, conflicts: conflicts.length,
273
+ added: delta.added.length, updated: delta.updated.length,
274
+ archived: delta.archived.length, removed: delta.removed.length,
275
+ assets: Object.keys(assets).length,
276
+ };
277
+ return { buffer, delta, conflicts, stats };
278
+ }
279
+
280
+ /**
281
+ * Human deletions inferred SAFELY for the merge-on-SAVE path: ids present in the
282
+ * open-snapshot BASE but absent from OURS (the full current app state at save).
283
+ * Sound precisely because base is FROZEN at open — a card the agent added after
284
+ * open is never in base, so this returns ONLY cards the human actually removed,
285
+ * and can never mistake an un-applied agent card for a deletion. Feed the result
286
+ * to mergeBrains({...deletedIds}). NOTE: a card the human deletes that the AGENT
287
+ * added mid-session isn't in base → not returned here (it re-unions until the
288
+ * next reopen folds it into base); persisting that stricter case needs explicit
289
+ * renderer tombstones, a later increment. NEVER use this for the live watcher,
290
+ * where absence≠delete.
291
+ */
292
+ export async function deletedByAbsence(baseBuf, oursBuf) {
293
+ if (!baseBuf || !oursBuf) return [];
294
+ const [b, o] = await Promise.all([parseKlypix(baseBuf), parseKlypix(oursBuf)]);
295
+ const oIds = new Set(o.struct.cards.map((c) => c.id));
296
+ return b.struct.cards.map((c) => c.id).filter((id) => !oIds.has(id));
297
+ }
298
+
299
+ /**
300
+ * Delta for the LIVE agent→human watcher: the cards ADDED to `newBuf` since the
301
+ * frozen open-snapshot `baseBuf`, with each added card's raw item JSON + position
302
+ * so the renderer can build it with its normal v4 deserializer. Added-only by
303
+ * design — a new id can never clobber a human's in-progress edit, and the renderer
304
+ * applies it idempotently, so re-sending the full accumulated added-set every time
305
+ * lets a briefly-gated tab catch up without any ack/queue. (Updates/removes
306
+ * reconcile on the next save/reopen — safe, since the merge never loses.)
307
+ */
308
+ export async function brainDelta(baseBuf, newBuf) {
309
+ const empty = { added: [], updated: [], removed: [], items: {}, positions: {}, connections: [], manifest: null };
310
+ if (!baseBuf || !newBuf) return empty;
311
+ const [b, n] = await Promise.all([parseKlypix(baseBuf), parseKlypix(newBuf)]);
312
+ const baseIds = new Set(b.struct.cards.map((c) => c.id));
313
+ const newIds = new Set(n.struct.cards.map((c) => c.id));
314
+ const bPos = (b.canvas && b.canvas.positions) || {};
315
+ const nPos = (n.canvas && n.canvas.positions) || {};
316
+ const posKey = (p) => (p ? JSON.stringify([p.x, p.y, p.w, p.h, p.parentId ?? null]) : ''); // ignore zKey/zIndex noise
317
+ const raw = async (zip, id) => { const f = zip.file(`items/${shard(id)}/${id}.json`); return f ? f.async('string') : null; };
318
+
319
+ const added = [...newIds].filter((id) => !baseIds.has(id));
320
+ const removed = [...baseIds].filter((id) => !newIds.has(id));
321
+ const updated = [];
322
+ for (const id of newIds) {
323
+ if (!baseIds.has(id)) continue;
324
+ const [bStr, nStr] = await Promise.all([raw(b.zip, id), raw(n.zip, id)]);
325
+ if (bStr !== nStr || posKey(bPos[id]) !== posKey(nPos[id])) updated.push(id);
326
+ }
327
+
328
+ const items = {}, positions = {};
329
+ for (const id of [...added, ...updated]) {
330
+ const s = await raw(n.zip, id);
331
+ if (s) items[id] = s;
332
+ if (nPos[id]) positions[id] = nPos[id];
333
+ }
334
+ const baseConn = new Set((b.canvas && b.canvas.connections || []).map((c) => c.id));
335
+ const connections = (n.canvas && n.canvas.connections || []).filter((c) => c && c.id && !baseConn.has(c.id));
336
+ return { added, updated, removed, items, positions, connections, manifest: n.manifest || null };
337
+ }
338
+
339
+ export default mergeBrains;