klypix-mcp 1.47.0 → 1.49.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/bin/klypix-diff.mjs +6 -0
- package/bin/klypix-git-driver.mjs +6 -0
- package/bin/klypix-git-tools.mjs +377 -0
- package/bin/klypix-mcp.mjs +4 -1
- package/bin/klypix-pr-brief.mjs +6 -0
- package/bin/klypix-worker.mjs +8 -0
- package/examples/github/brain-pr.yml +68 -0
- package/package.json +2 -2
- package/src/klypix-merge-driver.mjs +92 -0
- package/src/merge-brains.mjs +390 -0
|
@@ -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
|
+
}
|
package/bin/klypix-mcp.mjs
CHANGED
|
@@ -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));
|
package/bin/klypix-worker.mjs
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "1.49.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,92 @@
|
|
|
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, sameMeaning } 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
|
+
// "Untouched" by MEANING, not bytes — a side that merely re-saved the
|
|
72
|
+
// file restamps volatile fields (updatedAt), and a byte compare would
|
|
73
|
+
// read that as an edit and silently refuse to propagate a real delete.
|
|
74
|
+
const survivorJson = inA ? ai.get(id) : ti.get(id);
|
|
75
|
+
if (sameMeaning(survivorJson, baseJson)) deletedIds.push(id); // delete vs untouched → honor
|
|
76
|
+
// delete vs EDIT → no tombstone; union keeps the edited card
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const { buffer, conflicts, delta } = await mergeBrains({ base, ours: A, theirs: B, deletedIds });
|
|
81
|
+
fs.writeFileSync(aPath, buffer);
|
|
82
|
+
const bits = [];
|
|
83
|
+
if (delta.added.length) bits.push(`+${delta.added.length} card(s)`);
|
|
84
|
+
if (deletedIds.length) bits.push(`-${deletedIds.length} delete(s) honored`);
|
|
85
|
+
if (conflicts.length) bits.push(`${conflicts.length} conflict twin(s) preserved`);
|
|
86
|
+
console.error(`klypix-merge: ${realPath || 'brain'} united losslessly${bits.length ? ' — ' + bits.join(', ') : ''}`);
|
|
87
|
+
process.exit(0);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
// Any failure → normal binary conflict, same as a machine without the driver.
|
|
90
|
+
console.error(`klypix-merge: ${realPath || 'brain'} — falling back to manual conflict (${e?.message || e})`);
|
|
91
|
+
process.exit(1);
|
|
92
|
+
}
|
|
@@ -0,0 +1,390 @@
|
|
|
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
|
+
// ── Semantic item comparison (2026-08-01 field fix) ─────────────────────────
|
|
44
|
+
// A raw byte compare of item JSON was the change detector, on the assumption
|
|
45
|
+
// that "unchanged cards keep byte-identical JSON". That assumption DIED the
|
|
46
|
+
// day cards gained touch metadata: `updatedAt` is restamped whenever a card is
|
|
47
|
+
// written, so two sides holding the SAME card with the SAME text differ in
|
|
48
|
+
// bytes — and every first real sync spawned __agconf conflict twins for cards
|
|
49
|
+
// nobody edited (field-proven on the founder's pump-doctor brain: 5 twins,
|
|
50
|
+
// differing field list = ["updatedAt"] exactly).
|
|
51
|
+
//
|
|
52
|
+
// The fix is the same discipline the sync core and the brain diff already use:
|
|
53
|
+
// compare PARSED MEANING with volatile/derived fields stripped, key-sorted so
|
|
54
|
+
// two writers' key orders can't fake a difference. Byte-compare survives as the
|
|
55
|
+
// fallback for anything unparseable — a malformed item must never crash a merge.
|
|
56
|
+
//
|
|
57
|
+
// VOLATILE = written by the act of saving, not by a human/agent decision:
|
|
58
|
+
// updatedAt — touch timestamp zIndex — display order derived from zKey
|
|
59
|
+
// Everything else (content, colors, geometry, evidence, author…) stays load-
|
|
60
|
+
// bearing: a real edit to any of them is still a real conflict.
|
|
61
|
+
const VOLATILE_ITEM_FIELDS = ['updatedAt', 'zIndex'];
|
|
62
|
+
|
|
63
|
+
const sortedStable = (v) => JSON.stringify(v, (_k, val) =>
|
|
64
|
+
(val && typeof val === 'object' && !Array.isArray(val))
|
|
65
|
+
? Object.fromEntries(Object.keys(val).sort().map(k => [k, val[k]]))
|
|
66
|
+
: val);
|
|
67
|
+
|
|
68
|
+
function itemSignature(json) {
|
|
69
|
+
if (json == null) return null;
|
|
70
|
+
try {
|
|
71
|
+
const obj = JSON.parse(json);
|
|
72
|
+
for (const f of VOLATILE_ITEM_FIELDS) delete obj[f];
|
|
73
|
+
return sortedStable(obj);
|
|
74
|
+
} catch {
|
|
75
|
+
return json; // unparseable → byte identity, as before
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** True when two item JSON strings mean the same thing (volatile fields aside).
|
|
80
|
+
* EXPORTED as the single definition of "did this card actually change" — the
|
|
81
|
+
* git merge driver and the Brain Sync core both decide committed-absence
|
|
82
|
+
* tombstones with it, so all three transports agree on what an edit is. */
|
|
83
|
+
export const sameMeaning = (a, b) => {
|
|
84
|
+
if (a === b) return true; // fast path: byte-identical
|
|
85
|
+
if (a == null || b == null) return false;
|
|
86
|
+
return itemSignature(a) === itemSignature(b);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Load one .klypix buffer into a flat, comparison-friendly shape. Item JSON is
|
|
90
|
+
// kept VERBATIM (the merge must write back exactly what a side held); whether
|
|
91
|
+
// two versions actually differ is decided by sameMeaning(), never by these
|
|
92
|
+
// bytes — see its note on volatile fields.
|
|
93
|
+
async function loadSide(buf) {
|
|
94
|
+
if (!buf) return null;
|
|
95
|
+
const { zip, canvas, manifest, struct } = await parseKlypix(buf);
|
|
96
|
+
const order = Array.isArray(canvas.order) ? canvas.order : [];
|
|
97
|
+
const positions = canvas.positions || {};
|
|
98
|
+
const items = {}; // id -> raw item JSON string (verbatim bytes)
|
|
99
|
+
const idSet = new Set(order.length ? order : Object.keys(positions));
|
|
100
|
+
for (const id of idSet) {
|
|
101
|
+
const f = zip.file(`items/${shard(id)}/${id}.json`);
|
|
102
|
+
items[id] = f ? await f.async('string') : null;
|
|
103
|
+
}
|
|
104
|
+
const assets = {}; // "assets/<id>" -> nodebuffer
|
|
105
|
+
for (const p of Object.keys(zip.files)) {
|
|
106
|
+
if (p.startsWith('assets/') && !zip.files[p].dir) assets[p] = await zip.file(p).async('nodebuffer');
|
|
107
|
+
}
|
|
108
|
+
const titleById = new Map(struct.cards.map(c => [c.id, c.title || '']));
|
|
109
|
+
return {
|
|
110
|
+
order, positions, items, assets, manifest,
|
|
111
|
+
connections: Array.isArray(canvas.connections) ? canvas.connections : [],
|
|
112
|
+
lines: Array.isArray(canvas.lines) ? canvas.lines : [],
|
|
113
|
+
strokes: Array.isArray(canvas.strokes) ? canvas.strokes : [],
|
|
114
|
+
settings: canvas.settings || {},
|
|
115
|
+
nextGroupNumber: Number(canvas.nextGroupNumber) || 1, // top-level key, NOT settings
|
|
116
|
+
view: canvas.view || null,
|
|
117
|
+
titleById,
|
|
118
|
+
ids: idSet,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const samePos = (a, b) => !!a && !!b &&
|
|
123
|
+
a.x === b.x && a.y === b.y && (a.w ?? null) === (b.w ?? null) && (a.h ?? null) === (b.h ?? null);
|
|
124
|
+
const sameParent = (a, b) => (a?.parentId ?? null) === (b?.parentId ?? null);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* mergeBrains — 3-way union of two brains sharing ancestor `base`.
|
|
128
|
+
* @param {{base?:Buffer|null, ours:Buffer, theirs:Buffer, deletedIds?:string[]}} args
|
|
129
|
+
* base = on-disk struct snapshotted when the app opened (null → pure union).
|
|
130
|
+
* ours = the app's in-memory brain (what the human is saving).
|
|
131
|
+
* theirs = the current on-disk brain, re-read INSIDE the lock (has hook captures).
|
|
132
|
+
* deletedIds= ids the HUMAN explicitly deleted (tombstones). ONLY these can drop.
|
|
133
|
+
* @returns {Promise<{buffer:Buffer, delta:{added:string[],updated:string[],archived:string[],removed:string[]}, conflicts:object[], stats:object}>}
|
|
134
|
+
*/
|
|
135
|
+
export async function mergeBrains({ base = null, ours, theirs, deletedIds = [] }) {
|
|
136
|
+
if (!ours || !theirs) throw new Error('mergeBrains needs both ours and theirs buffers');
|
|
137
|
+
const B = await loadSide(base);
|
|
138
|
+
const O = await loadSide(ours);
|
|
139
|
+
const T = await loadSide(theirs);
|
|
140
|
+
const del = new Set(deletedIds);
|
|
141
|
+
|
|
142
|
+
const baseItem = (id) => (B && B.items[id]) || null;
|
|
143
|
+
const basePos = (id) => (B && B.positions[id]) || null;
|
|
144
|
+
const parentTitle = (side, pos) => {
|
|
145
|
+
const pid = pos?.parentId; if (!pid) return '';
|
|
146
|
+
return String(side.titleById.get(pid) || '');
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
const allIds = new Set([...O.ids, ...T.ids]);
|
|
150
|
+
const merged = new Map(); // id -> { json, pos }
|
|
151
|
+
const extras = []; // conflict-twin cards to append
|
|
152
|
+
const conflicts = [];
|
|
153
|
+
const delta = { added: [], updated: [], archived: [], removed: [] };
|
|
154
|
+
|
|
155
|
+
for (const id of allIds) {
|
|
156
|
+
const inO = O.items[id] != null, inT = T.items[id] != null;
|
|
157
|
+
const inB = baseItem(id) != null;
|
|
158
|
+
|
|
159
|
+
// ── Explicit human delete (tombstone) — the ONLY path that drops a card ──
|
|
160
|
+
if (del.has(id)) {
|
|
161
|
+
const theirsChanged = inT && inB && T.items[id] !== baseItem(id);
|
|
162
|
+
if (inT && theirsChanged) {
|
|
163
|
+
// delete-vs-edit: the human deleted it but a hook edited it after open →
|
|
164
|
+
// KEEP theirs (never lose the hook's new info); record the conflict.
|
|
165
|
+
conflicts.push({ id, kind: 'delete-vs-edit', kept: 'theirs' });
|
|
166
|
+
// fall through to keep from theirs below
|
|
167
|
+
} else {
|
|
168
|
+
delta.removed.push(id);
|
|
169
|
+
continue; // honored delete
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!inO && !inT) continue;
|
|
174
|
+
|
|
175
|
+
// ── Choose CONTENT ──────────────────────────────────────────────────────
|
|
176
|
+
let json, side;
|
|
177
|
+
if (inO && inT) {
|
|
178
|
+
// Change + divergence are judged by MEANING, not bytes (see sameMeaning):
|
|
179
|
+
// a restamped `updatedAt` is not an edit, and two copies of one card that
|
|
180
|
+
// differ only in volatile fields are not in conflict.
|
|
181
|
+
const oChg = !inB || !sameMeaning(O.items[id], baseItem(id));
|
|
182
|
+
const tChg = !inB || !sameMeaning(T.items[id], baseItem(id));
|
|
183
|
+
const diverged = !sameMeaning(O.items[id], T.items[id]);
|
|
184
|
+
if (inB && oChg && tChg && diverged) {
|
|
185
|
+
// GENUINE content conflict: a card that EXISTED at open, edited differently
|
|
186
|
+
// on both sides → human stays live, agent version preserved as a twin.
|
|
187
|
+
json = O.items[id]; side = 'ours';
|
|
188
|
+
const twinId = `${id}__agconf_${rand()}`;
|
|
189
|
+
extras.push({ id: twinId, json: T.items[id], srcPos: T.positions[id] || O.positions[id], of: id });
|
|
190
|
+
conflicts.push({ id, kind: 'content', keptLive: 'ours', twin: twinId });
|
|
191
|
+
} else if (tChg && !oChg) { json = T.items[id]; side = 'theirs'; delta.updated.push(id); }
|
|
192
|
+
else if (!inB && diverged) {
|
|
193
|
+
// Same NEW card (same id) present on BOTH sides but never in base — e.g. an
|
|
194
|
+
// agent card the app also holds via live-apply, re-serialized slightly
|
|
195
|
+
// differently. It's the SAME card, NOT a conflict → take the disk/agent
|
|
196
|
+
// bytes; NEVER spawn a twin for a card that was never in base (the
|
|
197
|
+
// live-apply-then-save duplication bug).
|
|
198
|
+
json = T.items[id]; side = 'theirs';
|
|
199
|
+
}
|
|
200
|
+
else { json = O.items[id]; side = 'ours'; }
|
|
201
|
+
} else if (inT) {
|
|
202
|
+
json = T.items[id]; side = 'theirs';
|
|
203
|
+
if (!inB) delta.added.push(id); // agent added since open — the anti-clobber core
|
|
204
|
+
} else {
|
|
205
|
+
json = O.items[id]; side = 'ours';
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ── Choose POSITION + parent (human spatial intent wins; hook archive
|
|
209
|
+
// applies only if the human didn't move/re-parent the card) ───────────
|
|
210
|
+
const oP = O.positions[id], tP = T.positions[id], bP = basePos(id);
|
|
211
|
+
const oMoved = oP && (!bP || !samePos(oP, bP));
|
|
212
|
+
const finalXY = oMoved ? oP : (tP || oP);
|
|
213
|
+
|
|
214
|
+
let parentId;
|
|
215
|
+
const oParentChg = oP && (!bP || !sameParent(oP, bP));
|
|
216
|
+
const tParentChg = tP && (!bP || !sameParent(tP, bP));
|
|
217
|
+
if (oParentChg) parentId = oP.parentId ?? null;
|
|
218
|
+
else if (tParentChg) parentId = tP.parentId ?? null;
|
|
219
|
+
else parentId = (finalXY?.parentId ?? oP?.parentId ?? tP?.parentId ?? null);
|
|
220
|
+
|
|
221
|
+
const pos = { ...(finalXY || oP || tP || {}), parentId };
|
|
222
|
+
// Detect a hook archive-move for the delta receipt.
|
|
223
|
+
if (side === 'theirs' && tParentChg && ARCHIVE.test(parentTitle(T, tP))) delta.archived.push(id);
|
|
224
|
+
|
|
225
|
+
merged.set(id, { json, pos });
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ── Conflict twins: place beside their source card, own valid zKey ─────────
|
|
229
|
+
for (const ex of extras) {
|
|
230
|
+
const src = ex.srcPos || {};
|
|
231
|
+
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 } });
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// ── Order + zKey heal (de-collide: duplicate zKeys silently no-op in-app) ──
|
|
235
|
+
const order = [];
|
|
236
|
+
const seen = new Set();
|
|
237
|
+
for (const id of [...T.order, ...O.order, ...extras.map(e => e.id)]) {
|
|
238
|
+
if (merged.has(id) && !seen.has(id)) { seen.add(id); order.push(id); }
|
|
239
|
+
}
|
|
240
|
+
// Any merged id not in either order[] (defensive) — append.
|
|
241
|
+
for (const id of merged.keys()) if (!seen.has(id)) { seen.add(id); order.push(id); }
|
|
242
|
+
|
|
243
|
+
const usedZ = new Set();
|
|
244
|
+
let lastZ = null;
|
|
245
|
+
for (const id of order) {
|
|
246
|
+
const rec = merged.get(id);
|
|
247
|
+
let z = rec.pos.zKey;
|
|
248
|
+
if (!z || !isValidZKey(z) || usedZ.has(z)) z = generateKeyBetween(lastZ, null);
|
|
249
|
+
usedZ.add(z); lastZ = z;
|
|
250
|
+
rec.pos = { ...rec.pos, zKey: z, zIndex: order.indexOf(id) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ── Union connections / lines / strokes by id; drop dangling connections ──
|
|
254
|
+
const byId = (arr) => { const m = new Map(); for (const x of arr) if (x && x.id) m.set(x.id, x); return m; };
|
|
255
|
+
const connMap = new Map([...byId(T.connections), ...byId(O.connections)]);
|
|
256
|
+
const liveIds = new Set(order);
|
|
257
|
+
// Collapse EXACT duplicate edges (same endpoints + relationship + label,
|
|
258
|
+
// different ids). Connection deletes have no tombstone, so an arrange/de-dup
|
|
259
|
+
// that dropped a redundant edge in-app used to see it resurrected from disk
|
|
260
|
+
// by this union — as a byte-identical twin arrow. Never meaningful to keep.
|
|
261
|
+
const seenEdge = new Set();
|
|
262
|
+
const connections = [...connMap.values()].filter(c => {
|
|
263
|
+
if (!(liveIds.has(c.fromId) && liveIds.has(c.toId))) return false;
|
|
264
|
+
const k = `${c.fromId}|${c.toId}|${c.relationship || ''}|${c.label || ''}`;
|
|
265
|
+
if (seenEdge.has(k)) return false;
|
|
266
|
+
seenEdge.add(k);
|
|
267
|
+
return true;
|
|
268
|
+
});
|
|
269
|
+
const lines = [...new Map([...byId(T.lines), ...byId(O.lines)]).values()];
|
|
270
|
+
const strokes = [...new Map([...byId(T.strokes), ...byId(O.strokes)]).values()];
|
|
271
|
+
|
|
272
|
+
// ── Union assets by path (theirs preferred, then ours, then base) ─────────
|
|
273
|
+
const assets = {};
|
|
274
|
+
for (const src of [B, O, T]) if (src) for (const [p, bytes] of Object.entries(src.assets)) assets[p] = bytes;
|
|
275
|
+
|
|
276
|
+
// ── Build merged zip ──────────────────────────────────────────────────────
|
|
277
|
+
const zip = new JSZip();
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
for (const id of order) zip.file(`items/${shard(id)}/${id}.json`, merged.get(id).json);
|
|
280
|
+
for (const [p, bytes] of Object.entries(assets)) zip.file(p, bytes);
|
|
281
|
+
|
|
282
|
+
const positions = {};
|
|
283
|
+
for (const id of order) positions[id] = merged.get(id).pos;
|
|
284
|
+
|
|
285
|
+
// Per-field manifest UNION, theirs-precedence: theirs still wins every field
|
|
286
|
+
// it carries (the original semantics — disk/hook-side stamps survive an app
|
|
287
|
+
// save), but a field only OURS has is no longer dropped. Concretely: the
|
|
288
|
+
// cloud-link stamp (manifest.cloud) added on the local side must survive a
|
|
289
|
+
// merge against an older cloud copy that predates the link.
|
|
290
|
+
const manifest = { format: 'klypix', version: 4, ...(O.manifest || {}), ...(T.manifest || {}) };
|
|
291
|
+
manifest.updatedAt = new Date(now).toISOString();
|
|
292
|
+
manifest.stats = { ...(manifest.stats || {}), itemCount: order.length, assetCount: Object.keys(assets).length };
|
|
293
|
+
zip.file('manifest.json', JSON.stringify(manifest));
|
|
294
|
+
|
|
295
|
+
const canvasJson = {
|
|
296
|
+
version: 4,
|
|
297
|
+
view: O.view || T.view || { panX: 0, panY: 0, zoom: 0.7 }, // human's viewport
|
|
298
|
+
order, connections, lines, strokes,
|
|
299
|
+
nextGroupNumber: Math.max(1, ...[O, T].map(s => Number(s.nextGroupNumber) || 1)),
|
|
300
|
+
positions,
|
|
301
|
+
settings: { ...(T.settings || {}), ...(O.settings || {}) },
|
|
302
|
+
};
|
|
303
|
+
zip.file('canvas.json', JSON.stringify(canvasJson));
|
|
304
|
+
const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' });
|
|
305
|
+
|
|
306
|
+
// ── SUPERSET VERIFICATION — prove no card was lost ─────────────────────────
|
|
307
|
+
// Every id that survived on either side (minus honored deletes) MUST be in the
|
|
308
|
+
// result; every asset path from either side MUST be present. Throw otherwise.
|
|
309
|
+
const survivors = new Set();
|
|
310
|
+
for (const id of O.ids) if (!delta.removed.includes(id)) survivors.add(id);
|
|
311
|
+
for (const id of T.ids) if (!delta.removed.includes(id)) survivors.add(id);
|
|
312
|
+
const resultIds = new Set(order);
|
|
313
|
+
const missing = [...survivors].filter(id => !resultIds.has(id));
|
|
314
|
+
if (missing.length) throw new Error(`mergeBrains INVARIANT VIOLATED — dropped ${missing.length} card(s): ${missing.slice(0, 5).join(', ')}`);
|
|
315
|
+
const wantAssets = new Set([...Object.keys(O.assets), ...Object.keys(T.assets)]);
|
|
316
|
+
const missingAssets = [...wantAssets].filter(p => !(p in assets));
|
|
317
|
+
if (missingAssets.length) throw new Error(`mergeBrains INVARIANT VIOLATED — dropped ${missingAssets.length} asset(s): ${missingAssets.slice(0, 3).join(', ')}`);
|
|
318
|
+
// Re-parse to guarantee the buffer round-trips (never ship an unreadable brain).
|
|
319
|
+
await parseKlypix(buffer);
|
|
320
|
+
|
|
321
|
+
const stats = {
|
|
322
|
+
ours: O.ids.size, theirs: T.ids.size, base: B ? B.ids.size : 0,
|
|
323
|
+
merged: order.length, conflicts: conflicts.length,
|
|
324
|
+
added: delta.added.length, updated: delta.updated.length,
|
|
325
|
+
archived: delta.archived.length, removed: delta.removed.length,
|
|
326
|
+
assets: Object.keys(assets).length,
|
|
327
|
+
};
|
|
328
|
+
return { buffer, delta, conflicts, stats };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Human deletions inferred SAFELY for the merge-on-SAVE path: ids present in the
|
|
333
|
+
* open-snapshot BASE but absent from OURS (the full current app state at save).
|
|
334
|
+
* Sound precisely because base is FROZEN at open — a card the agent added after
|
|
335
|
+
* open is never in base, so this returns ONLY cards the human actually removed,
|
|
336
|
+
* and can never mistake an un-applied agent card for a deletion. Feed the result
|
|
337
|
+
* to mergeBrains({...deletedIds}). NOTE: a card the human deletes that the AGENT
|
|
338
|
+
* added mid-session isn't in base → not returned here (it re-unions until the
|
|
339
|
+
* next reopen folds it into base); persisting that stricter case needs explicit
|
|
340
|
+
* renderer tombstones, a later increment. NEVER use this for the live watcher,
|
|
341
|
+
* where absence≠delete.
|
|
342
|
+
*/
|
|
343
|
+
export async function deletedByAbsence(baseBuf, oursBuf) {
|
|
344
|
+
if (!baseBuf || !oursBuf) return [];
|
|
345
|
+
const [b, o] = await Promise.all([parseKlypix(baseBuf), parseKlypix(oursBuf)]);
|
|
346
|
+
const oIds = new Set(o.struct.cards.map((c) => c.id));
|
|
347
|
+
return b.struct.cards.map((c) => c.id).filter((id) => !oIds.has(id));
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/**
|
|
351
|
+
* Delta for the LIVE agent→human watcher: the cards ADDED to `newBuf` since the
|
|
352
|
+
* frozen open-snapshot `baseBuf`, with each added card's raw item JSON + position
|
|
353
|
+
* so the renderer can build it with its normal v4 deserializer. Added-only by
|
|
354
|
+
* design — a new id can never clobber a human's in-progress edit, and the renderer
|
|
355
|
+
* applies it idempotently, so re-sending the full accumulated added-set every time
|
|
356
|
+
* lets a briefly-gated tab catch up without any ack/queue. (Updates/removes
|
|
357
|
+
* reconcile on the next save/reopen — safe, since the merge never loses.)
|
|
358
|
+
*/
|
|
359
|
+
export async function brainDelta(baseBuf, newBuf) {
|
|
360
|
+
const empty = { added: [], updated: [], removed: [], items: {}, positions: {}, connections: [], manifest: null };
|
|
361
|
+
if (!baseBuf || !newBuf) return empty;
|
|
362
|
+
const [b, n] = await Promise.all([parseKlypix(baseBuf), parseKlypix(newBuf)]);
|
|
363
|
+
const baseIds = new Set(b.struct.cards.map((c) => c.id));
|
|
364
|
+
const newIds = new Set(n.struct.cards.map((c) => c.id));
|
|
365
|
+
const bPos = (b.canvas && b.canvas.positions) || {};
|
|
366
|
+
const nPos = (n.canvas && n.canvas.positions) || {};
|
|
367
|
+
const posKey = (p) => (p ? JSON.stringify([p.x, p.y, p.w, p.h, p.parentId ?? null]) : ''); // ignore zKey/zIndex noise
|
|
368
|
+
const raw = async (zip, id) => { const f = zip.file(`items/${shard(id)}/${id}.json`); return f ? f.async('string') : null; };
|
|
369
|
+
|
|
370
|
+
const added = [...newIds].filter((id) => !baseIds.has(id));
|
|
371
|
+
const removed = [...baseIds].filter((id) => !newIds.has(id));
|
|
372
|
+
const updated = [];
|
|
373
|
+
for (const id of newIds) {
|
|
374
|
+
if (!baseIds.has(id)) continue;
|
|
375
|
+
const [bStr, nStr] = await Promise.all([raw(b.zip, id), raw(n.zip, id)]);
|
|
376
|
+
if (bStr !== nStr || posKey(bPos[id]) !== posKey(nPos[id])) updated.push(id);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
const items = {}, positions = {};
|
|
380
|
+
for (const id of [...added, ...updated]) {
|
|
381
|
+
const s = await raw(n.zip, id);
|
|
382
|
+
if (s) items[id] = s;
|
|
383
|
+
if (nPos[id]) positions[id] = nPos[id];
|
|
384
|
+
}
|
|
385
|
+
const baseConn = new Set((b.canvas && b.canvas.connections || []).map((c) => c.id));
|
|
386
|
+
const connections = (n.canvas && n.canvas.connections || []).filter((c) => c && c.id && !baseConn.has(c.id));
|
|
387
|
+
return { added, updated, removed, items, positions, connections, manifest: n.manifest || null };
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
export default mergeBrains;
|