@systemdox/mcp-server 0.1.57 → 0.1.58

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.
@@ -352,15 +352,41 @@ function readFrontmatter(text) {
352
352
  const m = BLOCK_RE.exec(text);
353
353
  if (!m) return void 0;
354
354
  const front = { shareFalse: false };
355
- for (const line of m[1].split(/\r?\n/)) {
356
- const kv = /^([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
355
+ const lines = m[1].split(/\r?\n/);
356
+ if (lines.some((l) => /^[ \t]*\t/.test(l) && l.trim())) return front;
357
+ let topType;
358
+ let topShare;
359
+ let nestedType;
360
+ let nestedShare;
361
+ let inMetadata = false;
362
+ let nestedIndent;
363
+ for (const line of lines) {
364
+ if (!line.trim() || /^[ \t]*#/.test(line)) continue;
365
+ const indent = /^ */.exec(line)[0].length;
366
+ if (indent === 0) {
367
+ inMetadata = /^metadata[ \t]*:[ \t]*(#.*)?$/.test(line);
368
+ nestedIndent = void 0;
369
+ }
370
+ const kv = /^ *([A-Za-z_][\w-]*)[ \t]*:[ \t]*(.*?)[ \t]*$/.exec(line);
357
371
  if (!kv) continue;
358
372
  const raw = kv[2].replace(/[ \t]+#.*$/, "");
359
373
  const quoted = /^(["'])(.*)\1$/.exec(raw);
360
374
  const value = quoted ? quoted[2] : raw;
361
- if (kv[1] === "type") front.type = value.trim().toLowerCase();
362
- if (kv[1] === "share") front.shareFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
363
- }
375
+ const isFalse = quoted ? value.trim().toLowerCase() === "false" : YAML_FALSE.has(value);
376
+ if (indent === 0) {
377
+ if (kv[1] === "type") topType = value.trim().toLowerCase();
378
+ if (kv[1] === "share") topShare = isFalse;
379
+ continue;
380
+ }
381
+ if (!inMetadata) continue;
382
+ nestedIndent ??= indent;
383
+ if (indent !== nestedIndent) continue;
384
+ if (kv[1] === "type") nestedType = value.trim().toLowerCase();
385
+ if (kv[1] === "share") nestedShare = isFalse;
386
+ }
387
+ const type = topType ?? nestedType;
388
+ if (type !== void 0) front.type = type;
389
+ front.shareFalse = (topShare ?? nestedShare) === true;
364
390
  return front;
365
391
  }
366
392
  function isShareable(path, text) {
@@ -686,20 +712,52 @@ async function removeFolder(path) {
686
712
 
687
713
  // src/sync-memory.ts
688
714
  import { existsSync } from "fs";
689
- import { mkdir as mkdir2, readdir, readFile as readFile2, rm as rm2, writeFile as writeFile2 } from "fs/promises";
715
+ import { appendFile, mkdir as mkdir2, open, readdir, readFile as readFile2, rename as rename2, rm as rm2, stat, writeFile as writeFile2 } from "fs/promises";
690
716
  import { dirname as dirname2, join as join2, resolve } from "path";
717
+ var HOOK_LOG_MAX_BYTES = 1024 * 1024;
691
718
  var SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
692
719
  var SyncFailed = class extends Error {
693
720
  };
721
+ var SyncStopped = class extends Error {
722
+ constructor(reason, message) {
723
+ super(message);
724
+ this.reason = reason;
725
+ }
726
+ reason;
727
+ };
728
+ function failureReason(e) {
729
+ if (e instanceof SyncStopped) return e.reason;
730
+ const { status, code } = refusal(e);
731
+ if (status !== void 0) return code ? `SystemDox refused it, ${code}` : "SystemDox refused it";
732
+ const err = e;
733
+ const net = err?.cause?.code ?? err?.code ?? "";
734
+ if (e instanceof TypeError || /^(ECONN|ENOTFOUND|EAI_AGAIN|ETIMEDOUT|EHOSTUNREACH|ENETUNREACH|UND_ERR)/.test(net)) {
735
+ return "SystemDox could not be reached";
736
+ }
737
+ return "it could not finish";
738
+ }
739
+ async function writeWhole(path, text) {
740
+ await mkdir2(dirname2(path), { recursive: true });
741
+ const tmp = `${path}.${process.pid}.${Math.random().toString(36).slice(2)}.tmp`;
742
+ try {
743
+ await writeFile2(tmp, text, "utf8");
744
+ await rename2(tmp, path);
745
+ } catch (e) {
746
+ await rm2(tmp, { force: true });
747
+ throw e;
748
+ }
749
+ }
694
750
  function refusal(e) {
695
751
  if (!(e instanceof Error)) return {};
696
752
  const { status, code } = e;
697
753
  return { status: typeof status === "number" ? status : void 0, code: typeof code === "string" ? code : void 0 };
698
754
  }
699
755
  var toPath = (root, rel) => join2(root, ...rel.split("/"));
756
+ var LEFT_TEMP_RE = /\.\d+\.[a-z0-9]+\.tmp$/;
700
757
  async function readFolder(root) {
701
758
  const files = {};
702
759
  const names = /* @__PURE__ */ new Set();
760
+ const temps = [];
703
761
  async function walk(dir, prefix) {
704
762
  for (const e of await readdir(dir, { withFileTypes: true })) {
705
763
  if (e.name.startsWith(".")) continue;
@@ -707,12 +765,13 @@ async function readFolder(root) {
707
765
  if (e.isDirectory()) await walk(join2(dir, e.name), rel);
708
766
  else if (e.isFile()) {
709
767
  names.add(rel.toLowerCase());
768
+ if (LEFT_TEMP_RE.test(e.name)) temps.push(rel);
710
769
  if (e.name.endsWith(".md")) files[rel] = await readFile2(join2(dir, e.name), "utf8");
711
770
  }
712
771
  }
713
772
  }
714
773
  if (existsSync(root)) await walk(root, "");
715
- return { files, names };
774
+ return { files, names, temps };
716
775
  }
717
776
  async function pullTeam(client, ws, project) {
718
777
  try {
@@ -761,7 +820,11 @@ var Report = class {
761
820
  deletedHere = /* @__PURE__ */ new Set();
762
821
  deletedFromTeam = /* @__PURE__ */ new Set();
763
822
  conflicts = [];
823
+ /** The conflict copies written this run, by name. */
824
+ copies = /* @__PURE__ */ new Set();
764
825
  skipped = /* @__PURE__ */ new Map();
826
+ /** Skipped files a sync in the member's terminal can send: held for a value, or for the vault. */
827
+ waiting = /* @__PURE__ */ new Set();
765
828
  people = [];
766
829
  toAnswer = /* @__PURE__ */ new Map();
767
830
  add(plan) {
@@ -769,12 +832,32 @@ var Report = class {
769
832
  plan.pushed.forEach((p) => this.pushed.add(p));
770
833
  plan.deletedHere.forEach((p) => this.deletedHere.add(p));
771
834
  plan.deletedFromTeam.forEach((p) => this.deletedFromTeam.add(p));
772
- for (const c of plan.conflicts) this.conflicts.push(`${c.path}: the team's text; yours is beside it as ${c.copy}`);
835
+ for (const c of plan.conflicts) {
836
+ this.conflicts.push(`${c.path}: the team's text; yours is beside it as ${c.copy}`);
837
+ this.copies.add(c.copy);
838
+ }
773
839
  for (const line of plan.indexTeamLines) this.conflicts.push(`${INDEX_FILE}: your line was kept; the team's was: ${line}`);
774
840
  }
775
841
  skip(path, reason) {
776
842
  if (!this.skipped.has(path)) this.skipped.set(path, reason);
777
843
  }
844
+ /** Skipped, and waiting for the member at a terminal. */
845
+ wait(path, reason) {
846
+ this.skip(path, reason);
847
+ this.waiting.add(path);
848
+ }
849
+ summary(outcome, version, reason) {
850
+ return {
851
+ outcome,
852
+ ...reason ? { reason } : {},
853
+ version,
854
+ pulled: this.pulled.size,
855
+ pushed: this.pushed.size,
856
+ deleted: this.deletedHere.size + this.deletedFromTeam.size,
857
+ conflictCopies: [...this.copies].sort(),
858
+ waiting: [...this.waiting].sort()
859
+ };
860
+ }
778
861
  lines(summary, dryRun) {
779
862
  const out = [summary];
780
863
  const section = (title, items) => {
@@ -877,7 +960,7 @@ async function people(opts, ws, files, report, hint) {
877
960
  for (const path of g.files) {
878
961
  if (leftOut.has(path)) continue;
879
962
  current[path] = addShareFalse(current[path]);
880
- await writeFile2(toPath(opts.dir, path), current[path], "utf8");
963
+ await writeWhole(toPath(opts.dir, path), current[path]);
881
964
  leftOut.add(path);
882
965
  }
883
966
  report.people.push({ kind: g.kind, answer: "left out (share: false)", files: g.files.length });
@@ -980,7 +1063,7 @@ async function people(opts, ws, files, report, hint) {
980
1063
  const yes = /^y(es)?$/i.test((await ask(`Rewrite ${paths.length} file${paths.length === 1 ? "" : "s"} as shown? [y/N] `)).trim());
981
1064
  if (yes) {
982
1065
  for (const path of paths) {
983
- await writeFile2(toPath(opts.dir, path), rewritten[path], "utf8");
1066
+ await writeWhole(toPath(opts.dir, path), rewritten[path]);
984
1067
  current[path] = rewritten[path];
985
1068
  }
986
1069
  }
@@ -995,11 +1078,11 @@ async function people(opts, ws, files, report, hint) {
995
1078
  held.add(path);
996
1079
  const keys = pending.filter((f) => f.kind !== HOME_PATH).map((f) => groupKey(f.kind, f.normalised));
997
1080
  if (keys.some((k) => vaultDown.has(k))) {
998
- report.skip(path, "the vault did not answer (SYD-K019) for a value in it; it syncs once the value is filed");
1081
+ report.wait(path, "the vault did not answer (SYD-K019) for a value in it; it syncs once the value is filed");
999
1082
  } else if (keys.some((k) => !answers.has(k))) {
1000
- report.skip(path, `it holds a value nobody has answered for: run \`${hint}\` in a terminal`);
1083
+ report.wait(path, `it holds a value nobody has answered for: run \`${hint}\` in a terminal`);
1001
1084
  } else {
1002
- report.skip(path, `it holds a value to rewrite as its identifier or ~: run \`${hint}\` in a terminal`);
1085
+ report.wait(path, `it holds a value to rewrite as its identifier or ~: run \`${hint}\` in a terminal`);
1003
1086
  }
1004
1087
  }
1005
1088
  for (const g of groups) {
@@ -1010,12 +1093,11 @@ async function people(opts, ws, files, report, hint) {
1010
1093
  }
1011
1094
  return { files: current, held, leftOut };
1012
1095
  }
1096
+ async function removeLeftTemps(dir, temps) {
1097
+ for (const rel of temps) await rm2(toPath(dir, rel), { force: true });
1098
+ }
1013
1099
  async function applyPlan(dir, plan) {
1014
- for (const [path, text] of Object.entries(plan.writes)) {
1015
- const full = toPath(dir, path);
1016
- await mkdir2(dirname2(full), { recursive: true });
1017
- await writeFile2(full, text, "utf8");
1018
- }
1100
+ for (const [path, text] of Object.entries(plan.writes)) await writeWhole(toPath(dir, path), text);
1019
1101
  for (const path of plan.deletes) await rm2(toPath(dir, path), { force: true });
1020
1102
  }
1021
1103
  var sameManifest = (a, b) => Object.keys(a).length === Object.keys(b).length && Object.entries(a).every(([p, h]) => b[p] === h);
@@ -1060,8 +1142,19 @@ async function syncMemory(opts) {
1060
1142
  const { client, out } = opts;
1061
1143
  const now = opts.now?.() ?? /* @__PURE__ */ new Date();
1062
1144
  try {
1145
+ const checkDeadline = () => {
1146
+ if (opts.deadline !== void 0 && Date.now() > opts.deadline) {
1147
+ throw new SyncStopped("took too long", "Stopped: the sync's time ran out before anything more was written or sent.");
1148
+ }
1149
+ };
1063
1150
  const own = await client.getOwnTeamSetup();
1064
1151
  const ws = own.workspace;
1152
+ if (opts.workspace && ws !== opts.workspace) {
1153
+ throw new SyncStopped(
1154
+ "the key is for another workspace",
1155
+ `The key is for ${ws}, not ${opts.workspace}: nothing was synced. Set SYSTEMDOX_API_KEY to a key of ${opts.workspace}, or unset it.`
1156
+ );
1157
+ }
1065
1158
  const project = opts.project ?? (own.memory.length === 1 ? own.memory[0].project : void 0);
1066
1159
  if (!project) {
1067
1160
  throw new SyncFailed(
@@ -1075,6 +1168,12 @@ async function syncMemory(opts) {
1075
1168
  const date = localDate(now);
1076
1169
  const stored = (await readState(ws)).memory?.[project];
1077
1170
  let team = await pullTeam(client, ws, project);
1171
+ if (opts.unattended && stored && !team.exists) {
1172
+ throw new SyncStopped(
1173
+ "the team memory could not be read",
1174
+ `${ws}/${project} could not be read from SystemDox: nothing was changed. Run sync-memory in your terminal to see why.`
1175
+ );
1176
+ }
1078
1177
  let base = stored ? { version: stored.version, manifest: stored.manifest, exists: true } : { version: 0, manifest: {}, exists: false };
1079
1178
  const folder = await readFolder(dir);
1080
1179
  const shared = {};
@@ -1087,14 +1186,16 @@ async function syncMemory(opts) {
1087
1186
  } else shared[path] = text;
1088
1187
  }
1089
1188
  const importWrites = {};
1090
- if (opts.importDir) {
1091
- const other = await readFolder(resolve(opts.importDir));
1092
- const taken = new Set(folder.names);
1189
+ const current = { ...folder.files };
1190
+ const taken = new Set(folder.names);
1191
+ for (const importDir of opts.importDirs ?? []) {
1192
+ const other = await readFolder(resolve(importDir));
1093
1193
  for (const [path, text] of Object.entries(other.files).sort(([a], [b]) => a.localeCompare(b))) {
1094
1194
  if (!isShareable(path, text) || !isFilePath(path)) continue;
1095
- const here = folder.files[path];
1195
+ const here = current[path];
1096
1196
  if (here === void 0) {
1097
1197
  shared[path] = text;
1198
+ current[path] = text;
1098
1199
  importWrites[path] = text;
1099
1200
  taken.add(path.toLowerCase());
1100
1201
  } else if (here !== text) {
@@ -1102,16 +1203,14 @@ async function syncMemory(opts) {
1102
1203
  taken.add(copy.toLowerCase());
1103
1204
  importWrites[copy] = text;
1104
1205
  report.conflicts.push(`${path}: this folder's text; the imported one is beside it as ${copy}`);
1206
+ report.copies.add(copy);
1105
1207
  }
1106
1208
  }
1107
- folder.names = taken;
1108
- if (!opts.dryRun) {
1109
- for (const [path, text] of Object.entries(importWrites)) {
1110
- const full = toPath(dir, path);
1111
- await mkdir2(dirname2(full), { recursive: true });
1112
- await writeFile2(full, text, "utf8");
1113
- }
1114
- }
1209
+ }
1210
+ folder.names = taken;
1211
+ if (!opts.dryRun) {
1212
+ checkDeadline();
1213
+ for (const [path, text] of Object.entries(importWrites)) await writeWhole(toPath(dir, path), text);
1115
1214
  }
1116
1215
  const found = await people(run3, ws, shared, report, hint);
1117
1216
  for (const path of found.leftOut) {
@@ -1136,12 +1235,16 @@ async function syncMemory(opts) {
1136
1235
  );
1137
1236
  return 0;
1138
1237
  }
1238
+ checkDeadline();
1239
+ if (attempt === 1) await removeLeftTemps(dir, folder.temps);
1139
1240
  await applyPlan(dir, plan);
1140
1241
  let version = team.version;
1141
1242
  if (!sameManifest(plan.manifest, team.manifest)) {
1243
+ checkDeadline();
1142
1244
  await upload(client, ws, project, plan, team.manifest, report);
1143
1245
  }
1144
1246
  if (!sameManifest(plan.manifest, team.manifest)) {
1247
+ checkDeadline();
1145
1248
  try {
1146
1249
  version = (await client.pushMemoryVersion(ws, project, { baseVersion: team.version, manifest: plan.manifest })).version;
1147
1250
  } catch (e) {
@@ -1153,6 +1256,7 @@ async function syncMemory(opts) {
1153
1256
  false
1154
1257
  )
1155
1258
  );
1259
+ opts.summary?.(report.summary("moved-on", team.version, "moved on twice"));
1156
1260
  return 2;
1157
1261
  }
1158
1262
  base = team;
@@ -1177,33 +1281,45 @@ async function syncMemory(opts) {
1177
1281
  false
1178
1282
  )
1179
1283
  );
1284
+ opts.summary?.(report.summary("synced", version));
1180
1285
  return 0;
1181
1286
  }
1182
1287
  } catch (e) {
1183
1288
  out(e.message);
1289
+ opts.summary?.({
1290
+ outcome: "failed",
1291
+ reason: failureReason(e),
1292
+ version: 0,
1293
+ pulled: 0,
1294
+ pushed: 0,
1295
+ deleted: 0,
1296
+ conflictCopies: [],
1297
+ waiting: []
1298
+ });
1184
1299
  return 1;
1185
1300
  }
1186
1301
  }
1187
1302
 
1188
1303
  // src/setup-team.ts
1189
- var SETUP_USAGE = "Usage: systemdox-mcp-server setup [--project <name>] [--dir <checkout>] [--yes] [--overwrite]";
1304
+ var SETUP_USAGE = "Usage: systemdox-mcp-server setup [--project <name>] [--dir <checkout>] [--import <folder>]... [--yes] [--overwrite]";
1190
1305
  var HEADERS_HELPER = "npx -y @systemdox/mcp-server marketplace-auth";
1191
1306
  var SetupFailed = class extends Error {
1192
1307
  };
1193
1308
  function parseSetupArgs(argv) {
1194
- const args = { yes: false, overwrite: false };
1309
+ const args = { yes: false, overwrite: false, importDirs: [] };
1195
1310
  for (let i = 0; i < argv.length; i++) {
1196
1311
  const a = argv[i];
1197
1312
  if (a === "--yes") args.yes = true;
1198
1313
  else if (a === "--overwrite") args.overwrite = true;
1199
- else if (a === "--project" || a === "--dir") {
1314
+ else if (a === "--project" || a === "--dir" || a === "--import") {
1200
1315
  const value = argv[++i];
1201
1316
  if (!value || value.startsWith("--")) throw new Error(`${a} needs a value
1202
1317
  ${SETUP_USAGE}`);
1203
1318
  if (a === "--project") {
1204
1319
  if (!isWorkspace(value)) throw new Error(`${value} is not a project name (lower case letters, digits and -)`);
1205
1320
  args.project = value;
1206
- } else args.dir = value;
1321
+ } else if (a === "--dir") args.dir = value;
1322
+ else args.importDirs.push(value);
1207
1323
  } else throw new Error(`Unexpected argument ${a}
1208
1324
  ${SETUP_USAGE}`);
1209
1325
  }
@@ -1223,6 +1339,60 @@ async function claudeCodeVersion() {
1223
1339
  return void 0;
1224
1340
  }
1225
1341
  }
1342
+ async function gitTopFolder(dir) {
1343
+ try {
1344
+ const { stdout } = await run2("git", ["-C", dir, "rev-parse", "--show-toplevel"], { timeout: 1e4, windowsHide: true });
1345
+ const top = stdout.trim();
1346
+ return top ? resolve2(top) : void 0;
1347
+ } catch {
1348
+ return void 0;
1349
+ }
1350
+ }
1351
+ function claudeMemoryFolder(path) {
1352
+ return join3(homeDir(), ".claude", "projects", path.replace(/[^A-Za-z0-9]/g, "-"), "memory");
1353
+ }
1354
+ async function countMemoryFiles(folder) {
1355
+ let count = 0;
1356
+ async function walk(dir, prefix) {
1357
+ let entries2;
1358
+ try {
1359
+ entries2 = await readdir2(dir, { withFileTypes: true });
1360
+ } catch {
1361
+ return;
1362
+ }
1363
+ for (const e of entries2) {
1364
+ if (e.name.startsWith(".")) continue;
1365
+ const rel = prefix ? `${prefix}/${e.name}` : e.name;
1366
+ if (e.isDirectory()) await walk(join3(dir, e.name), rel);
1367
+ else if (e.isFile() && e.name.endsWith(".md")) {
1368
+ try {
1369
+ if (isShareable(rel, await readFile3(join3(dir, e.name), "utf8"))) count++;
1370
+ } catch {
1371
+ }
1372
+ }
1373
+ }
1374
+ }
1375
+ await walk(folder, "");
1376
+ return count;
1377
+ }
1378
+ async function memoryImports(checkout, teamFolder, named, gitTop = gitTopFolder) {
1379
+ const candidates = [resolve2(checkout)];
1380
+ const top = await gitTop(checkout);
1381
+ if (top && !candidates.includes(top)) candidates.push(top);
1382
+ const out = [];
1383
+ for (const path of candidates) {
1384
+ const folder = claudeMemoryFolder(path);
1385
+ if (resolve2(folder) === resolve2(teamFolder)) continue;
1386
+ const files = await countMemoryFiles(folder);
1387
+ if (files) out.push({ folder, files, named: false });
1388
+ }
1389
+ for (const dir of named) {
1390
+ const folder = resolve2(dir);
1391
+ if (out.some((i) => i.folder === folder)) continue;
1392
+ out.push({ folder, files: await countMemoryFiles(folder), named: true });
1393
+ }
1394
+ return out;
1395
+ }
1226
1396
  function parseVersion(text) {
1227
1397
  const m = text?.match(/(\d+)\.(\d+)\.(\d+)/);
1228
1398
  return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : void 0;
@@ -1264,9 +1434,9 @@ function skipValue(t, i) {
1264
1434
  while (i < t.length && !/[\s,}\]]/.test(t[i])) i++;
1265
1435
  return i;
1266
1436
  }
1267
- function objectMembers(t, open) {
1437
+ function objectMembers(t, open2) {
1268
1438
  const members = [];
1269
- let i = skipSpace(t, open + 1);
1439
+ let i = skipSpace(t, open2 + 1);
1270
1440
  if (t[i] === "}") return { members, close: i };
1271
1441
  for (; ; ) {
1272
1442
  i = skipSpace(t, i);
@@ -1293,13 +1463,13 @@ function setJsonMember(text, keys, value) {
1293
1463
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
1294
1464
  const render = (v, indent) => compact ? JSON.stringify(v) : JSON.stringify(v, null, unit).split("\n").join(eol + indent);
1295
1465
  const nest = (from) => keys.slice(from).reduceRight((acc, k) => ({ [k]: acc }), value);
1296
- let open = skipSpace(text, 0);
1466
+ let open2 = skipSpace(text, 0);
1297
1467
  for (let k = 0; k < keys.length; k++) {
1298
- const { members, close } = objectMembers(text, open);
1468
+ const { members, close } = objectMembers(text, open2);
1299
1469
  const found = members.filter((m) => m.key === keys[k]).at(-1);
1300
1470
  if (found) {
1301
1471
  if (k < keys.length - 1 && text[found.valueStart] === "{") {
1302
- open = found.valueStart;
1472
+ open2 = found.valueStart;
1303
1473
  continue;
1304
1474
  }
1305
1475
  return text.slice(0, found.valueStart) + render(nest(k + 1), lineIndent(text, found.keyStart)) + text.slice(found.valueEnd);
@@ -1308,16 +1478,16 @@ function setJsonMember(text, keys, value) {
1308
1478
  if (compact) {
1309
1479
  const member = `${name}:${render(nest(k + 1), "")}`;
1310
1480
  if (members.length) return text.slice(0, members.at(-1).valueEnd) + "," + member + text.slice(members.at(-1).valueEnd);
1311
- return text.slice(0, open + 1) + member + text.slice(close);
1481
+ return text.slice(0, open2 + 1) + member + text.slice(close);
1312
1482
  }
1313
1483
  if (members.length) {
1314
1484
  const last = members.at(-1);
1315
1485
  const indent2 = lineIndent(text, last.keyStart);
1316
1486
  return text.slice(0, last.valueEnd) + `,${eol}${indent2}${name}: ${render(nest(k + 1), indent2)}` + text.slice(last.valueEnd);
1317
1487
  }
1318
- const outer = lineIndent(text, open);
1488
+ const outer = lineIndent(text, open2);
1319
1489
  const indent = outer + unit;
1320
- return text.slice(0, open + 1) + `${eol}${indent}${name}: ${render(nest(k + 1), indent)}${eol}${outer}` + text.slice(close);
1490
+ return text.slice(0, open2 + 1) + `${eol}${indent}${name}: ${render(nest(k + 1), indent)}${eol}${outer}` + text.slice(close);
1321
1491
  }
1322
1492
  return text;
1323
1493
  }
@@ -1363,9 +1533,9 @@ function importBlock(workspace, eol = "\n") {
1363
1533
  function withImportBlock(text, workspace) {
1364
1534
  const eol = text.includes("\r\n") ? "\r\n" : "\n";
1365
1535
  const block = importBlock(workspace, eol);
1366
- const open = `<!-- systemdox:${workspace} -->`;
1536
+ const open2 = `<!-- systemdox:${workspace} -->`;
1367
1537
  const close = `<!-- /systemdox:${workspace} -->`;
1368
- const start = text.indexOf(open);
1538
+ const start = text.indexOf(open2);
1369
1539
  const end = start >= 0 ? text.indexOf(close, start) : -1;
1370
1540
  if (start >= 0 && end >= 0) return text.slice(0, start) + block + text.slice(end + close.length);
1371
1541
  if (!text) return block + eol;
@@ -1489,6 +1659,15 @@ async function runTeamSetup(opts) {
1489
1659
  }
1490
1660
  items.push(item(`autoMemoryDirectory in ${localSettings}`, [memoryFolder]));
1491
1661
  }
1662
+ const imports = project && memoryFolder && !state.memory?.[project] ? await memoryImports(checkout, memoryFolder, opts.importDirs ?? [], opts.gitTop) : [];
1663
+ for (const i of imports) {
1664
+ items.push(
1665
+ item(
1666
+ `${i.named ? "the folder --import names" : "the memories Claude Code kept for this checkout"}, imported into ${project} on its first sync`,
1667
+ [`${i.folder}: ${i.files} file${i.files === 1 ? "" : "s"} to share`]
1668
+ )
1669
+ );
1670
+ }
1492
1671
  const pending = items.filter((i) => !accepted.has(i.sha256));
1493
1672
  if (pending.length) {
1494
1673
  if (!canAsk) {
@@ -1558,6 +1737,7 @@ async function runTeamSetup(opts) {
1558
1737
  const code = await syncMemory({
1559
1738
  project,
1560
1739
  dryRun: false,
1740
+ importDirs: imports.map((i) => i.folder),
1561
1741
  client,
1562
1742
  out: (...lines) => lines.forEach((l) => out(l)),
1563
1743
  ask: opts.terminal ? opts.ask : void 0,
@@ -1602,7 +1782,11 @@ export {
1602
1782
  HEADERS_HELPER,
1603
1783
  SETUP_USAGE,
1604
1784
  claudeCodeVersion,
1785
+ claudeMemoryFolder,
1786
+ countMemoryFiles,
1787
+ gitTopFolder,
1605
1788
  importBlock,
1789
+ memoryImports,
1606
1790
  parseSetupArgs,
1607
1791
  runTeamSetup,
1608
1792
  runTeamSetupCommand,
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * `systemdox-mcp-server sync-memory [--project <name>] [--dir <folder>]
3
- * [--import <folder>] [--dry-run]`, and the core of the local MCP tool
4
- * `sync_memory` (ADR-217 §2.3 with Amendment 2,
5
- * specs/changes/team-setup/contract.md "sync-memory and the MCP tool
3
+ * [--import <folder>]... [--dry-run]`, the core of the local MCP tool
4
+ * `sync_memory`, and `sync-memory --hook <workspace>`, the run the team plugin
5
+ * makes at the start and the end of a session (ADR-217 §2.3 with Amendments 2
6
+ * and 3, specs/changes/team-setup/contract.md "sync-memory and the MCP tool
6
7
  * sync_memory").
7
8
  *
8
9
  * 1. Pull: the team's latest version (`getMemory`), the base (state.json; with
@@ -17,18 +18,43 @@
17
18
  * state.json takes the new base only after the push succeeds.
18
19
  *
19
20
  * Exit codes: 0 synced; 1 usage, no key, or the API refused; 2 the push was
20
- * refused twice on a stale base.
21
+ * refused twice on a stale base. `--hook` always exits 0: it syncs only the
22
+ * projects this computer synced before, asks nothing, prints one line per
23
+ * project (and a line for conflict copies and one for waiting files), appends
24
+ * the full report to the workspace's sync-memory.log, holds sync.lock, keeps
25
+ * to a 20-second budget and never holds a session up.
21
26
  */
22
27
  import { SystemDoxApiClient } from "./lib/api-client.js";
23
28
  import { type CommandIo } from "./lib/team-setup-files.js";
24
- export declare const SYNC_MEMORY_USAGE = "Usage: systemdox-mcp-server sync-memory [--project <name>] [--dir <folder>] [--import <folder>] [--dry-run]";
29
+ export declare const SYNC_MEMORY_USAGE: string;
30
+ /** The hook run's budget, lock staleness and log size (contract.md "--hook"). */
31
+ export declare const HOOK_BUDGET_MS = 20000;
32
+ export declare const HOOK_LOCK_STALE_MS = 60000;
33
+ export declare const HOOK_LOG_MAX_BYTES: number;
25
34
  export declare const SYNC_MEMORY_COMMAND = "npx -y @systemdox/mcp-server sync-memory";
26
35
  export type SyncClient = Pick<SystemDoxApiClient, "getOwnTeamSetup" | "getMemory" | "readBlobs" | "findMissingBlobs" | "uploadBlobs" | "pushMemoryVersion" | "lookupValues" | "answerValues" | "fileMemoryPeople">;
27
36
  export interface SyncMemoryArgs {
28
37
  project?: string;
29
38
  dir?: string;
30
- importDir?: string;
39
+ /** Folders to import on this sync, in order (`--import`, repeatable). */
40
+ importDirs?: string[];
31
41
  dryRun: boolean;
42
+ /** `--hook <workspace>`: the run the team plugin makes. */
43
+ hook?: string;
44
+ }
45
+ /** What a sync came to, for the hook run's one line (never a value). */
46
+ export interface SyncSummary {
47
+ outcome: "synced" | "moved-on" | "failed";
48
+ /** For `failed`: why, in a few words. */
49
+ reason?: string;
50
+ version: number;
51
+ pulled: number;
52
+ pushed: number;
53
+ deleted: number;
54
+ /** The `.local-<date>.md` copies this run wrote. */
55
+ conflictCopies: string[];
56
+ /** The files skipped this run (waiting for the member, or refused). */
57
+ waiting: string[];
32
58
  }
33
59
  export interface SyncMemoryOptions extends SyncMemoryArgs {
34
60
  client: SyncClient;
@@ -38,6 +64,14 @@ export interface SyncMemoryOptions extends SyncMemoryArgs {
38
64
  * nothing is asked and nothing is rewritten. */
39
65
  ask?: (question: string) => Promise<string>;
40
66
  now?: () => Date;
67
+ /** Told what the sync came to, once, at its end. */
68
+ summary?: (summary: SyncSummary) => void;
69
+ /** The workspace the run is for (`--hook <workspace>`): a key for another one syncs nothing. */
70
+ workspace?: string;
71
+ /** Epoch ms: past it, nothing more is written or sent (the hook's budget). */
72
+ deadline?: number;
73
+ /** A hook run: a project the team no longer serves is left as it is. */
74
+ unattended?: boolean;
41
75
  }
42
76
  export declare function parseSyncMemoryArgs(argv: string[]): SyncMemoryArgs;
43
77
  /**
@@ -45,9 +79,27 @@ export declare function parseSyncMemoryArgs(argv: string[]): SyncMemoryArgs;
45
79
  * report goes to `opts.out`.
46
80
  */
47
81
  export declare function syncMemory(opts: SyncMemoryOptions): Promise<number>;
48
- /** The command: parse, find the key, sync. */
49
- export declare function runSyncMemory(argv: string[], io: CommandIo & {
82
+ /** The lines the hook prints for one project (contract.md "--hook"): never a value. */
83
+ export declare function hookLines(ws: string, project: string, folder: string, s: SyncSummary): string[];
84
+ export interface HookIo extends CommandIo {
50
85
  client?: SyncClient;
51
- ask?: (question: string) => Promise<string>;
52
86
  now?: () => Date;
87
+ /** The budget and the lock's staleness, for tests. */
88
+ budgetMs?: number;
89
+ lockStaleMs?: number;
90
+ /** Epoch ms the budget counts from: the process start for the CLI (npx and
91
+ * loading count against Claude Code's 30 seconds too), now otherwise. */
92
+ startedAt?: number;
93
+ }
94
+ /**
95
+ * `sync-memory --hook <workspace>`: the run the team plugin makes at the start
96
+ * and the end of a session. Every project this computer has synced before, in
97
+ * its default folder; never a first sync; one line per project on stdout (plus
98
+ * a line for conflict copies and one for waiting files), the full report in
99
+ * the log; one run at a time per workspace; a budget; exit 0 whatever happens.
100
+ */
101
+ export declare function runHookSync(workspace: string, io: HookIo, env?: NodeJS.ProcessEnv): Promise<number>;
102
+ /** The command: parse, find the key, sync. */
103
+ export declare function runSyncMemory(argv: string[], io: HookIo & {
104
+ ask?: (question: string) => Promise<string>;
53
105
  }, env?: NodeJS.ProcessEnv): Promise<number>;