@imenam/mcp-documentor 1.0.71 → 1.0.74

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.
Files changed (72) hide show
  1. package/dist/src/core/conflict-inspector.d.ts +63 -0
  2. package/dist/src/core/conflict-inspector.js +157 -0
  3. package/dist/src/core/git-sync.d.ts +113 -8
  4. package/dist/src/core/git-sync.js +296 -45
  5. package/dist/src/core/merge-drivers/merge-config.d.ts +1 -0
  6. package/dist/src/core/merge-drivers/merge-config.js +68 -0
  7. package/dist/src/core/merge-drivers/merge-doc.d.ts +1 -0
  8. package/dist/src/core/merge-drivers/merge-doc.js +105 -0
  9. package/dist/src/core/merge-drivers/text-merge.d.ts +15 -0
  10. package/dist/src/core/merge-drivers/text-merge.js +53 -0
  11. package/dist/src/core/merge-drivers/three-way.d.ts +34 -0
  12. package/dist/src/core/merge-drivers/three-way.js +86 -0
  13. package/dist/src/gui-worker.js +21 -0
  14. package/dist/src/index.js +146 -12
  15. package/dist/src/tools/conflicts.d.ts +12 -0
  16. package/dist/src/tools/conflicts.js +190 -0
  17. package/dist/src/tools/sync.js +55 -11
  18. package/dist/src/version.d.ts +1 -1
  19. package/dist/src/version.js +1 -1
  20. package/gui/dist/assets/{arc-Carsq0k3.js → arc-BNuZ3dSL.js} +1 -1
  21. package/gui/dist/assets/{architectureDiagram-3BPJPVTR-caqcwdTw.js → architectureDiagram-3BPJPVTR-D8g3tPQx.js} +1 -1
  22. package/gui/dist/assets/{blockDiagram-GPEHLZMM-0wUO8k2I.js → blockDiagram-GPEHLZMM-limhup8_.js} +1 -1
  23. package/gui/dist/assets/{c4Diagram-AAUBKEIU-DS6q7fMy.js → c4Diagram-AAUBKEIU-Ct39JMIy.js} +1 -1
  24. package/gui/dist/assets/channel-uoEIgtym.js +1 -0
  25. package/gui/dist/assets/{chunk-2J33WTMH-CrJc-G6D.js → chunk-2J33WTMH-BkLJTezj.js} +1 -1
  26. package/gui/dist/assets/{chunk-4BX2VUAB-C5vtPFbB.js → chunk-4BX2VUAB-CR5NbhDR.js} +1 -1
  27. package/gui/dist/assets/{chunk-55IACEB6-BchyQUFM.js → chunk-55IACEB6-CtQYMnJS.js} +1 -1
  28. package/gui/dist/assets/{chunk-727SXJPM-BCIaWtKq.js → chunk-727SXJPM-B2q7tUn8.js} +1 -1
  29. package/gui/dist/assets/{chunk-AQP2D5EJ-BEQJDJt5.js → chunk-AQP2D5EJ-Bbcnrfrp.js} +1 -1
  30. package/gui/dist/assets/{chunk-FMBD7UC4-IfQ3QtrC.js → chunk-FMBD7UC4-D8gd4zRe.js} +1 -1
  31. package/gui/dist/assets/{chunk-ND2GUHAM-4Du33w7t.js → chunk-ND2GUHAM-DiZKzZtG.js} +1 -1
  32. package/gui/dist/assets/{chunk-QZHKN3VN-HR0CP6fa.js → chunk-QZHKN3VN-CrKx-e2_.js} +1 -1
  33. package/gui/dist/assets/classDiagram-4FO5ZUOK-DmhYb54l.js +1 -0
  34. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-DmhYb54l.js +1 -0
  35. package/gui/dist/assets/{cose-bilkent-S5V4N54A-C9Re3ENx.js → cose-bilkent-S5V4N54A-BDUCVkTl.js} +1 -1
  36. package/gui/dist/assets/{dagre-BM42HDAG-DZmcvMMp.js → dagre-BM42HDAG-DkoYBHXK.js} +1 -1
  37. package/gui/dist/assets/{diagram-2AECGRRQ-EyJrAGht.js → diagram-2AECGRRQ-BT1YJHon.js} +1 -1
  38. package/gui/dist/assets/{diagram-5GNKFQAL-CHd3T9_b.js → diagram-5GNKFQAL-C00r6F35.js} +1 -1
  39. package/gui/dist/assets/{diagram-KO2AKTUF-CIyFrZSR.js → diagram-KO2AKTUF-B4wcN5a_.js} +1 -1
  40. package/gui/dist/assets/{diagram-LMA3HP47-BlkJALyz.js → diagram-LMA3HP47-B9FqCfel.js} +1 -1
  41. package/gui/dist/assets/{diagram-OG6HWLK6-P8LA7IZE.js → diagram-OG6HWLK6-COPCDzdG.js} +1 -1
  42. package/gui/dist/assets/{erDiagram-TEJ5UH35-BPywgWN1.js → erDiagram-TEJ5UH35-BGQGT2wN.js} +1 -1
  43. package/gui/dist/assets/{flowDiagram-I6XJVG4X-ByL0kGiV.js → flowDiagram-I6XJVG4X-A7jKWUpi.js} +1 -1
  44. package/gui/dist/assets/{ganttDiagram-6RSMTGT7-_pnaYCU4.js → ganttDiagram-6RSMTGT7-NhFkpY-x.js} +1 -1
  45. package/gui/dist/assets/{gitGraphDiagram-PVQCEYII-DN67CDGs.js → gitGraphDiagram-PVQCEYII-z0AqyYG4.js} +1 -1
  46. package/gui/dist/assets/{index-Bjc5MOe1.js → index-Dub5OLKk.js} +98 -98
  47. package/gui/dist/assets/index-QkCDSQ2A.css +1 -0
  48. package/gui/dist/assets/{infoDiagram-5YYISTIA-Bm5LEpzH.js → infoDiagram-5YYISTIA-D69UR9u9.js} +1 -1
  49. package/gui/dist/assets/{ishikawaDiagram-YF4QCWOH-DUYSULkT.js → ishikawaDiagram-YF4QCWOH-DSTqIoro.js} +1 -1
  50. package/gui/dist/assets/{journeyDiagram-JHISSGLW-BvJ1_f0L.js → journeyDiagram-JHISSGLW-CekXdcoE.js} +1 -1
  51. package/gui/dist/assets/{kanban-definition-UN3LZRKU-DDYKg6RY.js → kanban-definition-UN3LZRKU-CseGvFK9.js} +1 -1
  52. package/gui/dist/assets/{linear-HlpG9u6P.js → linear-BtVl_9_C.js} +1 -1
  53. package/gui/dist/assets/{mindmap-definition-RKZ34NQL-CPa_wo_s.js → mindmap-definition-RKZ34NQL-XLjQIU37.js} +1 -1
  54. package/gui/dist/assets/{pieDiagram-4H26LBE5-BWo0c4DP.js → pieDiagram-4H26LBE5-BcvUzWoW.js} +1 -1
  55. package/gui/dist/assets/{quadrantDiagram-W4KKPZXB-CLahU-6B.js → quadrantDiagram-W4KKPZXB-BzoYEmNW.js} +1 -1
  56. package/gui/dist/assets/{requirementDiagram-4Y6WPE33-BoYbgzQ7.js → requirementDiagram-4Y6WPE33-icKC1_Nd.js} +1 -1
  57. package/gui/dist/assets/{sankeyDiagram-5OEKKPKP-CGm79aMo.js → sankeyDiagram-5OEKKPKP-mi3i-2dZ.js} +1 -1
  58. package/gui/dist/assets/{sequenceDiagram-3UESZ5HK-BkknTD52.js → sequenceDiagram-3UESZ5HK-XrcDkRlM.js} +1 -1
  59. package/gui/dist/assets/{stateDiagram-AJRCARHV-DSBurgwC.js → stateDiagram-AJRCARHV-UXaqrCNT.js} +1 -1
  60. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-Bhdu4lv4.js +1 -0
  61. package/gui/dist/assets/{timeline-definition-PNZ67QCA-CBHyrGuz.js → timeline-definition-PNZ67QCA-yd6607p6.js} +1 -1
  62. package/gui/dist/assets/{vennDiagram-CIIHVFJN-Dn9fr_bR.js → vennDiagram-CIIHVFJN-BsRqUcIp.js} +1 -1
  63. package/gui/dist/assets/{wardley-L42UT6IY-nMuySeAb.js → wardley-L42UT6IY-BmdA7yk0.js} +1 -1
  64. package/gui/dist/assets/{wardleyDiagram-YWT4CUSO-Byn2STLI.js → wardleyDiagram-YWT4CUSO-CRdmckD8.js} +1 -1
  65. package/gui/dist/assets/{xychartDiagram-2RQKCTM6-Bi-Y9wp5.js → xychartDiagram-2RQKCTM6-DWpNIQY-.js} +1 -1
  66. package/gui/dist/index.html +2 -2
  67. package/package.json +65 -65
  68. package/gui/dist/assets/channel-D51HexOc.js +0 -1
  69. package/gui/dist/assets/classDiagram-4FO5ZUOK-CU0dQ3zy.js +0 -1
  70. package/gui/dist/assets/classDiagram-v2-Q7XG4LA2-CU0dQ3zy.js +0 -1
  71. package/gui/dist/assets/index-hjkEDRmc.css +0 -1
  72. package/gui/dist/assets/stateDiagram-v2-BHNVJYJU-DfKn22XF.js +0 -1
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shared plumbing for the git merge drivers: reading driver arguments and
3
+ * delegating to git's own textual 3-way merge.
4
+ */
5
+ import { spawnSync } from "child_process";
6
+ import fs from "fs";
7
+ import os from "os";
8
+ import path from "path";
9
+ export const CLEAN = 0;
10
+ export const CONFLICT = 1;
11
+ /** A driver argument may point at a missing file (add/add merges have no ancestor). */
12
+ export function read(file) {
13
+ if (!file || !fs.existsSync(file))
14
+ return "";
15
+ return fs.readFileSync(file, "utf-8");
16
+ }
17
+ /** Runs git's own textual merge over three strings. Returns null if git itself failed. */
18
+ export function mergeText(base, ours, theirs) {
19
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "mcp-documentor-merge-"));
20
+ try {
21
+ const files = {
22
+ base: path.join(dir, "base"),
23
+ ours: path.join(dir, "ours"),
24
+ theirs: path.join(dir, "theirs"),
25
+ };
26
+ fs.writeFileSync(files.base, base, "utf-8");
27
+ fs.writeFileSync(files.ours, ours, "utf-8");
28
+ fs.writeFileSync(files.theirs, theirs, "utf-8");
29
+ const res = spawnSync("git",
30
+ // --diff3 keeps the common ancestor in the markers, so conflict inspection
31
+ // can show what each side changed rather than just what they disagree on.
32
+ ["merge-file", "--diff3", "--stdout", "-L", "ours", "-L", "base", "-L", "theirs", files.ours, files.base, files.theirs], { encoding: "utf-8", maxBuffer: 64 * 1024 * 1024 });
33
+ // git merge-file returns the conflict count, or a negative value (255) on error.
34
+ if (res.status === null || res.status < 0 || res.status === 255)
35
+ return null;
36
+ return { text: res.stdout, conflict: res.status > 0 };
37
+ }
38
+ finally {
39
+ fs.rmSync(dir, { recursive: true, force: true });
40
+ }
41
+ }
42
+ /**
43
+ * Last resort when structured merging is impossible: merge the raw files
44
+ * textually, exactly as git would have without this driver. Never leaves %A
45
+ * untouched — git requires the result to be written there either way.
46
+ */
47
+ export function fallback(basePath, oursPath, theirsPath) {
48
+ const merged = mergeText(read(basePath), read(oursPath), read(theirsPath));
49
+ if (!merged)
50
+ return CONFLICT;
51
+ fs.writeFileSync(oursPath, merged.text, "utf-8");
52
+ return merged.conflict ? CONFLICT : CLEAN;
53
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Generic 3-way merge primitives shared by the git merge drivers.
3
+ *
4
+ * All helpers follow git's contract: `base` is the common ancestor, `ours` is
5
+ * the side being merged into, `theirs` is the incoming side. A value that only
6
+ * one side changed is taken from that side; a value both sides changed to the
7
+ * same thing is not a conflict.
8
+ */
9
+ export type Merged<T> = {
10
+ conflict: false;
11
+ value: T;
12
+ } | {
13
+ conflict: true;
14
+ ours: T;
15
+ theirs: T;
16
+ };
17
+ /** Classic 3-way merge of a single value. Conflicts only when both sides diverged differently. */
18
+ export declare function mergeScalar<T>(base: T | undefined, ours: T | undefined, theirs: T | undefined): Merged<T | undefined>;
19
+ /**
20
+ * Set merge that honours deletions: an entry disappears only if a side that had
21
+ * it in `base` removed it. Plain union would resurrect deleted entries forever.
22
+ * Order is stable — base order first, then additions from ours, then theirs.
23
+ */
24
+ export declare function mergeSet(base?: string[], ours?: string[], theirs?: string[]): string[];
25
+ /**
26
+ * Set merge over objects identified by `keyOf`. Entries present on both sides
27
+ * with diverging content are resolved by `pick` rather than reported as a
28
+ * conflict — callers supply a domain rule (e.g. keep the freshest snapshot).
29
+ */
30
+ export declare function mergeKeyedList<T>(base: T[] | undefined, ours: T[] | undefined, theirs: T[] | undefined, keyOf: (item: T) => string, pick: (ours: T, theirs: T) => T): T[];
31
+ /** Later of two date-ish strings. Falls back to lexicographic order for unparseable values. */
32
+ export declare function laterDate(a?: string, b?: string): string | undefined;
33
+ /** Earlier of two date-ish strings. Falls back to lexicographic order. */
34
+ export declare function earlierDate(a?: string, b?: string): string | undefined;
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Generic 3-way merge primitives shared by the git merge drivers.
3
+ *
4
+ * All helpers follow git's contract: `base` is the common ancestor, `ours` is
5
+ * the side being merged into, `theirs` is the incoming side. A value that only
6
+ * one side changed is taken from that side; a value both sides changed to the
7
+ * same thing is not a conflict.
8
+ */
9
+ function eq(a, b) {
10
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
11
+ }
12
+ /** Classic 3-way merge of a single value. Conflicts only when both sides diverged differently. */
13
+ export function mergeScalar(base, ours, theirs) {
14
+ if (eq(ours, theirs))
15
+ return { conflict: false, value: ours };
16
+ if (eq(ours, base))
17
+ return { conflict: false, value: theirs };
18
+ if (eq(theirs, base))
19
+ return { conflict: false, value: ours };
20
+ return { conflict: true, ours, theirs };
21
+ }
22
+ /**
23
+ * Set merge that honours deletions: an entry disappears only if a side that had
24
+ * it in `base` removed it. Plain union would resurrect deleted entries forever.
25
+ * Order is stable — base order first, then additions from ours, then theirs.
26
+ */
27
+ export function mergeSet(base = [], ours = [], theirs = []) {
28
+ const baseSet = new Set(base);
29
+ const ourSet = new Set(ours);
30
+ const theirSet = new Set(theirs);
31
+ const result = [];
32
+ for (const item of base) {
33
+ const removed = !ourSet.has(item) || !theirSet.has(item);
34
+ if (!removed)
35
+ result.push(item);
36
+ }
37
+ for (const item of [...ours, ...theirs]) {
38
+ if (!baseSet.has(item) && !result.includes(item))
39
+ result.push(item);
40
+ }
41
+ return result;
42
+ }
43
+ /**
44
+ * Set merge over objects identified by `keyOf`. Entries present on both sides
45
+ * with diverging content are resolved by `pick` rather than reported as a
46
+ * conflict — callers supply a domain rule (e.g. keep the freshest snapshot).
47
+ */
48
+ export function mergeKeyedList(base = [], ours = [], theirs = [], keyOf, pick) {
49
+ const index = (list) => new Map(list.map((item) => [keyOf(item), item]));
50
+ const baseMap = index(base);
51
+ const ourMap = index(ours);
52
+ const theirMap = index(theirs);
53
+ const keys = mergeSet([...baseMap.keys()], [...ourMap.keys()], [...theirMap.keys()]);
54
+ const result = [];
55
+ for (const key of keys) {
56
+ const o = ourMap.get(key);
57
+ const t = theirMap.get(key);
58
+ if (o && t)
59
+ result.push(eq(o, t) ? o : pick(o, t));
60
+ else if (o)
61
+ result.push(o);
62
+ else if (t)
63
+ result.push(t);
64
+ }
65
+ return result;
66
+ }
67
+ /** Later of two date-ish strings. Falls back to lexicographic order for unparseable values. */
68
+ export function laterDate(a, b) {
69
+ if (!a)
70
+ return b;
71
+ if (!b)
72
+ return a;
73
+ const da = Date.parse(a);
74
+ const db = Date.parse(b);
75
+ if (Number.isNaN(da) || Number.isNaN(db))
76
+ return a > b ? a : b;
77
+ return da >= db ? a : b;
78
+ }
79
+ /** Earlier of two date-ish strings. Falls back to lexicographic order. */
80
+ export function earlierDate(a, b) {
81
+ if (!a)
82
+ return b;
83
+ if (!b)
84
+ return a;
85
+ return laterDate(a, b) === a ? b : a;
86
+ }
@@ -192,6 +192,7 @@ const READ_ONLY_WRITE_ROUTES = [
192
192
  { method: "PUT", prefix: "/api/doc/" },
193
193
  { method: "POST", prefix: "/api/images" },
194
194
  { method: "POST", prefix: "/api/configurations/apply" },
195
+ { method: "POST", prefix: "/api/sync" },
195
196
  ];
196
197
  app.use("*", async (c, next) => {
197
198
  if (isReadOnly()) {
@@ -314,6 +315,26 @@ app.get("/api/config", async (c) => {
314
315
  return c.json({ error: result.error }, 500);
315
316
  return c.json(result.data);
316
317
  });
318
+ app.get("/api/sync/status", async (c) => {
319
+ const result = await ipcRequest("SYNC_STATUS", {}, 20000);
320
+ if (result.error)
321
+ return c.json({ error: result.error }, 500);
322
+ return c.json(result.data);
323
+ });
324
+ app.post("/api/sync", async (c) => {
325
+ let message;
326
+ try {
327
+ message = (await c.req.json())?.message;
328
+ }
329
+ catch {
330
+ // Body is optional — the commit message falls back to an auto-generated one.
331
+ }
332
+ // Network round-trips (fetch + push) plus a possible embedding reindex.
333
+ const result = await ipcRequest("SYNC_DOCS", { message }, 180000);
334
+ if (result.error)
335
+ return c.json({ error: result.error }, 500);
336
+ return c.json(result.data);
337
+ });
317
338
  app.get("/api/projects", async (c) => {
318
339
  const result = await ipcRequest("LIST_PROJECTS");
319
340
  if (result.error)
package/dist/src/index.js CHANGED
@@ -43,6 +43,7 @@ import { uploadImage } from "./tools/upload-image.js";
43
43
  import { getImage } from "./tools/get-image.js";
44
44
  import { SyncEngine } from "./core/git-sync.js";
45
45
  import { syncStatus, pullDocs, pushDocs, syncDocs, resolveSyncConflict } from "./tools/sync.js";
46
+ import { listConflicts, getConflictHunks, resolveConflictHunks } from "./tools/conflicts.js";
46
47
  import { isReadOnly, WRITE_TOOLS } from "../shared/read-only.js";
47
48
  const __filename = fileURLToPath(import.meta.url);
48
49
  const __dirname = path.dirname(__filename);
@@ -167,6 +168,12 @@ function handleIPCMessage(msg) {
167
168
  else if (msg.type === "GET_FILE_SECTION") {
168
169
  handleGetFileSection(msg);
169
170
  }
171
+ else if (msg.type === "SYNC_STATUS") {
172
+ handleSyncStatusIPC(msg);
173
+ }
174
+ else if (msg.type === "SYNC_DOCS") {
175
+ handleSyncDocsIPC(msg);
176
+ }
170
177
  else {
171
178
  logger.debug(`Unhandled message from GUI: ${msg.type}`);
172
179
  }
@@ -197,7 +204,14 @@ async function handleGetInitialState(msg) {
197
204
  broadcastToGUI({
198
205
  type: "INITIAL_STATE",
199
206
  correlationId: msg.correlationId,
200
- data: { config, docs, projects: config.projects, bundles, readOnly: isReadOnly() },
207
+ data: {
208
+ config,
209
+ docs,
210
+ projects: config.projects,
211
+ bundles,
212
+ readOnly: isReadOnly(),
213
+ syncEnabled: syncEngine.isConfigured(),
214
+ },
201
215
  timestamp: new Date().toISOString(),
202
216
  });
203
217
  }
@@ -210,7 +224,7 @@ async function handleGetConfig(msg) {
210
224
  broadcastToGUI({
211
225
  type: "CONFIG_RESPONSE",
212
226
  correlationId: msg.correlationId,
213
- data: { ...config, version: getConfig().version, readOnly: isReadOnly() },
227
+ data: { ...config, version: getConfig().version, readOnly: isReadOnly(), syncEnabled: syncEngine.isConfigured() },
214
228
  timestamp: new Date().toISOString(),
215
229
  });
216
230
  }
@@ -636,6 +650,81 @@ async function handleGitDiff(msg) {
636
650
  });
637
651
  }
638
652
  }
653
+ /**
654
+ * Sync state for the GUI button. `configured` is false when the environment has
655
+ * no MCP_DOCUMENTOR_SYNC_REMOTE — the GUI then hides the button entirely.
656
+ */
657
+ async function handleSyncStatusIPC(msg) {
658
+ try {
659
+ if (!syncEngine.isConfigured()) {
660
+ broadcastToGUI({
661
+ type: "SYNC_STATUS_RESPONSE",
662
+ correlationId: msg.correlationId,
663
+ data: { configured: false },
664
+ timestamp: new Date().toISOString(),
665
+ });
666
+ return;
667
+ }
668
+ const s = await syncEngine.status();
669
+ broadcastToGUI({
670
+ type: "SYNC_STATUS_RESPONSE",
671
+ correlationId: msg.correlationId,
672
+ data: { configured: true, ...s },
673
+ timestamp: new Date().toISOString(),
674
+ });
675
+ }
676
+ catch (err) {
677
+ broadcastToGUI({
678
+ type: "SYNC_STATUS_RESPONSE",
679
+ correlationId: msg.correlationId,
680
+ error: err.message,
681
+ timestamp: new Date().toISOString(),
682
+ });
683
+ }
684
+ }
685
+ async function handleSyncDocsIPC(msg) {
686
+ if (isReadOnly()) {
687
+ broadcastToGUI({
688
+ type: "SYNC_DOCS_RESPONSE",
689
+ correlationId: msg.correlationId,
690
+ error: "READ_ONLY mode: sync is disabled",
691
+ timestamp: new Date().toISOString(),
692
+ });
693
+ return;
694
+ }
695
+ try {
696
+ // Reuse the tool so the GUI and the agent get identical pull/push semantics.
697
+ const result = await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, {
698
+ message: msg.data?.message,
699
+ });
700
+ const text = result.content.map((c) => c.text).join("\n");
701
+ if (result.isError) {
702
+ broadcastToGUI({
703
+ type: "SYNC_DOCS_RESPONSE",
704
+ correlationId: msg.correlationId,
705
+ error: text,
706
+ timestamp: new Date().toISOString(),
707
+ });
708
+ return;
709
+ }
710
+ broadcastToGUI({
711
+ type: "SYNC_DOCS_RESPONSE",
712
+ correlationId: msg.correlationId,
713
+ data: { message: text },
714
+ timestamp: new Date().toISOString(),
715
+ });
716
+ // A pull can add/remove docs — push a fresh tree to every connected GUI.
717
+ await handleGetInitialState({});
718
+ }
719
+ catch (err) {
720
+ broadcastToGUI({
721
+ type: "SYNC_DOCS_RESPONSE",
722
+ correlationId: msg.correlationId,
723
+ error: err.message,
724
+ timestamp: new Date().toISOString(),
725
+ });
726
+ }
727
+ }
639
728
  async function handleUpdateConfig(msg) {
640
729
  if (isReadOnly()) {
641
730
  broadcastToGUI({
@@ -1201,17 +1290,17 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1201
1290
  },
1202
1291
  {
1203
1292
  name: "sync_status",
1204
- description: "Show the synchronization status: current branch, how many commits the local repo is ahead/behind the remote, conflicted files, and uncommitted changes. The sync target (repository, branch, token) is fixed by the server administrator via environment variables and cannot be changed from here.",
1293
+ description: "Show the synchronization status: current branch, whether a merge or rebase is in progress, how many commits the local repo is ahead/behind the remote, conflicted files, and uncommitted changes. The sync target (repository, branch, token) is fixed by the server administrator via environment variables and cannot be changed from here.",
1205
1294
  inputSchema: { type: "object", properties: {} },
1206
1295
  },
1207
1296
  {
1208
1297
  name: "pull_docs",
1209
- description: "Pull documentation updates from the GitHub remote. Defensive: only fast-forwards a clean working tree, never auto-merges a divergent history (local data is never overwritten). Reindexes embeddings for new documents after a successful update.",
1298
+ description: "Pull documentation updates from the GitHub remote. Defensive: fast-forwards when purely behind, and rebases local commits on top of the remote when the history diverged. Never touches an uncommitted local change. A conflict stops the rebase with both versions preserved - resolve it with resolve_sync_conflict. Reindexes embeddings for new documents after a successful update.",
1210
1299
  inputSchema: { type: "object", properties: {} },
1211
1300
  },
1212
1301
  {
1213
1302
  name: "push_docs",
1214
- description: "Commit local documentation changes (markdown + assets + project list) and push them to the GitHub remote.",
1303
+ description: "Commit local documentation changes (markdown + assets + project list) and push them to the GitHub remote. Refuses to run while a conflict is unresolved, so conflict markers are never committed as documentation content.",
1215
1304
  inputSchema: {
1216
1305
  type: "object",
1217
1306
  properties: {
@@ -1221,7 +1310,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1221
1310
  },
1222
1311
  {
1223
1312
  name: "sync_docs",
1224
- description: "Full synchronization: pull (defensive) then push. If the pull reveals a divergence or conflict, the push is skipped so it can be resolved first.",
1313
+ description: "Full synchronization: pull (fast-forward or rebase) then push. If the pull reveals a divergence or conflict, the push is skipped so it can be resolved first with resolve_sync_conflict.",
1225
1314
  inputSchema: {
1226
1315
  type: "object",
1227
1316
  properties: {
@@ -1231,14 +1320,56 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1231
1320
  },
1232
1321
  {
1233
1322
  name: "resolve_sync_conflict",
1234
- description: "Bulk-resolve sync merge conflicts by keeping one side for the conflicted files, then stage them. For nuanced resolution, edit the conflicted markdown files directly instead. After resolving, run push_docs.",
1323
+ description: "Bulk-resolve sync conflicts by keeping one side for the conflicted files, stage them, and carry the merge or rebase to completion when nothing is left conflicted. Sides are absolute ('local' vs 'remote'), not git's ours/theirs, which swap meaning between a merge and a rebase. For nuanced resolution, edit the conflicted markdown files directly instead. After resolving, run push_docs.",
1235
1324
  inputSchema: {
1236
1325
  type: "object",
1237
1326
  properties: {
1238
- strategy: { type: "string", enum: ["mine", "theirs"], description: "'mine' keeps the local version, 'theirs' keeps the incoming remote version" },
1327
+ side: { type: "string", enum: ["local", "remote"], description: "'local' keeps this machine's version, 'remote' keeps the incoming version from the other machine" },
1239
1328
  paths: { type: "array", items: { type: "string" }, description: "Specific conflicted file paths to resolve (defaults to all conflicted files)" },
1240
1329
  },
1241
- required: ["strategy"],
1330
+ required: ["side"],
1331
+ },
1332
+ },
1333
+ {
1334
+ name: "list_conflicts",
1335
+ description: "List every file left conflicted by a sync, without opening any of them: which project and document each one is, whether it is a content / delete-modify / add-add conflict, how many hunks it has and in which markdown sections, and who last changed it on each side. Sides are absolute: 'local' is this machine, 'remote' is the other machine. Start here when a pull reports a conflict.",
1336
+ inputSchema: { type: "object", properties: {} },
1337
+ },
1338
+ {
1339
+ name: "get_conflict_hunks",
1340
+ description: "Return only the passages two machines disagree on in one conflicted document, as numbered hunks: the section each falls in, the common ancestor, and both sides. Output is capped per side so a large document never has to be read in full. Use this instead of get_doc when resolving a sync conflict.",
1341
+ inputSchema: {
1342
+ type: "object",
1343
+ properties: {
1344
+ path: { type: "string", description: "Conflicted file path as reported by list_conflicts (e.g. 'projects/alpha/api.md')" },
1345
+ hunks: { type: "array", items: { type: "number" }, description: "Specific hunk numbers to return (defaults to all)" },
1346
+ max_chars_per_side: { type: "number", description: "Character budget per side, per hunk (default 1200). Anything beyond it is withheld and counted." },
1347
+ },
1348
+ required: ["path"],
1349
+ },
1350
+ },
1351
+ {
1352
+ name: "resolve_conflict_hunks",
1353
+ description: "Resolve a conflicted document hunk by hunk. Each decision keeps the local side, the remote side, both (local first), neither, or your own replacement text. The file is only staged once every hunk has been decided, so conflict markers can never be committed as documentation content. When the file is the last one conflicted, the merge or rebase is carried to completion.",
1354
+ inputSchema: {
1355
+ type: "object",
1356
+ properties: {
1357
+ path: { type: "string", description: "Conflicted file path as reported by list_conflicts" },
1358
+ decisions: {
1359
+ type: "array",
1360
+ description: "One entry per hunk you are deciding. Undecided hunks keep their markers.",
1361
+ items: {
1362
+ type: "object",
1363
+ properties: {
1364
+ hunk: { type: "number", description: "Hunk number from get_conflict_hunks" },
1365
+ take: { type: "string", enum: ["local", "remote", "both", "none", "custom"], description: "'local' this machine, 'remote' the other machine, 'both' concatenates local then remote, 'none' drops the passage, 'custom' uses the provided content. 'both' is refused on frontmatter hunks, where it would duplicate a YAML key." },
1366
+ content: { type: "string", description: "Replacement text, required when take is 'custom'" },
1367
+ },
1368
+ required: ["hunk", "take"],
1369
+ },
1370
+ },
1371
+ },
1372
+ required: ["path", "decisions"],
1242
1373
  },
1243
1374
  },
1244
1375
  {
@@ -1342,6 +1473,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1342
1473
  return await syncDocs(syncEngine, storageEngine, embeddingEngine, embeddingStore, args);
1343
1474
  case "resolve_sync_conflict":
1344
1475
  return await resolveSyncConflict(syncEngine, args);
1476
+ case "list_conflicts":
1477
+ return await listConflicts(syncEngine);
1478
+ case "get_conflict_hunks":
1479
+ return await getConflictHunks(syncEngine, args);
1480
+ case "resolve_conflict_hunks":
1481
+ return await resolveConflictHunks(syncEngine, args);
1345
1482
  case "reconnect_gui":
1346
1483
  return await reconnectGui(args);
1347
1484
  default:
@@ -1368,9 +1505,6 @@ async function syncPullOnStartup() {
1368
1505
  await reindexEmbeddings(storageEngine, embeddingEngine, embeddingStore, {});
1369
1506
  }
1370
1507
  }
1371
- else if (res.status === "diverged") {
1372
- logger.warn(`Startup sync: local history diverged (ahead ${res.ahead}, behind ${res.behind}). Started on local data — run sync_status / sync_docs to reconcile.`);
1373
- }
1374
1508
  else if (res.status === "conflict") {
1375
1509
  logger.warn(`Startup sync: merge conflict in ${res.conflicted.length} file(s). Started on local data — resolve with resolve_sync_conflict.`);
1376
1510
  }
@@ -0,0 +1,12 @@
1
+ import type { SyncEngine } from "../core/git-sync.js";
2
+ type ToolResult = {
3
+ content: {
4
+ type: "text";
5
+ text: string;
6
+ }[];
7
+ isError?: boolean;
8
+ };
9
+ export declare function listConflicts(syncEngine: SyncEngine): Promise<ToolResult>;
10
+ export declare function getConflictHunks(syncEngine: SyncEngine, args: unknown): Promise<ToolResult>;
11
+ export declare function resolveConflictHunks(syncEngine: SyncEngine, args: unknown): Promise<ToolResult>;
12
+ export {};
@@ -0,0 +1,190 @@
1
+ import matter from "gray-matter";
2
+ import { z } from "zod";
3
+ import { parseConflicts, applyResolutions, preview, hasConflictMarkers } from "../core/conflict-inspector.js";
4
+ const ok = (text) => ({ content: [{ type: "text", text }] });
5
+ const err = (text) => ({ content: [{ type: "text", text }], isError: true });
6
+ const NOT_CONFIGURED = "Sync is not configured. Set MCP_DOCUMENTOR_SYNC_REMOTE (and MCP_DOCUMENTOR_SYNC_TOKEN) in the server environment.";
7
+ /** Documents live at projects/<project>/<id>.md — recover both for reporting. */
8
+ function describeTarget(filePath) {
9
+ const match = filePath.match(/^projects\/([^/]+)\/(.+)\.md$/);
10
+ return match ? `project "${match[1]}", doc "${match[2]}"` : filePath;
11
+ }
12
+ function describeSide(label, side) {
13
+ if (!side)
14
+ return ` ${label}: (no commit recorded)`;
15
+ return ` ${label}: ${side.author} · ${side.date} · "${side.subject}"`;
16
+ }
17
+ export async function listConflicts(syncEngine) {
18
+ try {
19
+ if (!syncEngine.isConfigured())
20
+ return err(NOT_CONFIGURED);
21
+ const { operation, files } = await syncEngine.listConflicts();
22
+ if (files.length === 0)
23
+ return ok("No conflicts. Nothing to resolve.");
24
+ const lines = [
25
+ `${files.length} conflicted file(s) during a ${operation}.`,
26
+ `"local" is this machine's version, "remote" is the other machine's.`,
27
+ "",
28
+ ];
29
+ for (const file of files) {
30
+ lines.push(`${file.path} (${describeTarget(file.path)})`);
31
+ if (file.kind === "delete-modify") {
32
+ lines.push(` Kind: delete/modify — the ${file.deletedOn} side deleted this document while the other edited it.`);
33
+ }
34
+ else if (file.kind === "add-add") {
35
+ lines.push(` Kind: add/add — both sides created this document independently; there is no common ancestor.`);
36
+ }
37
+ else {
38
+ // Counting hunks needs the working file, which only exists for content
39
+ // conflicts (a deleted side leaves nothing on disk).
40
+ try {
41
+ const { hunks } = parseConflicts(syncEngine.readWorkingFile(file.path), operation);
42
+ const sections = [...new Set(hunks.map((h) => h.section))];
43
+ lines.push(` Kind: content — ${hunks.length} conflicting hunk(s) in: ${sections.join(", ")}`);
44
+ }
45
+ catch {
46
+ lines.push(` Kind: content`);
47
+ }
48
+ }
49
+ lines.push(describeSide("local ", file.local), describeSide("remote", file.remote), "");
50
+ }
51
+ lines.push(`Next: get_conflict_hunks to see just the disagreeing passages, resolve_conflict_hunks to decide hunk by hunk,`, `or resolve_sync_conflict to keep one whole side for every file at once.`);
52
+ return ok(lines.join("\n"));
53
+ }
54
+ catch (e) {
55
+ return err(`Error listing conflicts: ${e instanceof Error ? e.message : String(e)}`);
56
+ }
57
+ }
58
+ const GetHunksArgs = z.object({
59
+ path: z.string(),
60
+ hunks: z.array(z.number().int().positive()).optional(),
61
+ max_chars_per_side: z.number().int().positive().max(20000).default(1200),
62
+ });
63
+ export async function getConflictHunks(syncEngine, args) {
64
+ try {
65
+ if (!syncEngine.isConfigured())
66
+ return err(NOT_CONFIGURED);
67
+ const v = GetHunksArgs.parse(args);
68
+ const operation = await syncEngine.operationInProgress();
69
+ const { hunks } = parseConflicts(syncEngine.readWorkingFile(v.path), operation);
70
+ if (hunks.length === 0)
71
+ return ok(`No conflict markers in "${v.path}".`);
72
+ const wanted = v.hunks?.length ? hunks.filter((h) => v.hunks.includes(h.index)) : hunks;
73
+ if (wanted.length === 0)
74
+ return ok(`None of the requested hunks exist in "${v.path}" (it has ${hunks.length}).`);
75
+ const lines = [`${v.path} — ${wanted.length} of ${hunks.length} hunk(s), during a ${operation}.`, ""];
76
+ for (const hunk of wanted) {
77
+ const scope = hunk.inFrontmatter ? " [frontmatter — \"both\" is not available, it would duplicate a key]" : "";
78
+ lines.push(`Hunk ${hunk.index} — section "${hunk.section}" (line ${hunk.line})${scope}`);
79
+ if (hunk.base)
80
+ lines.push(` [base — what both sides started from]`, preview(hunk.base, v.max_chars_per_side));
81
+ lines.push(` [local — this machine]`, preview(hunk.local, v.max_chars_per_side));
82
+ lines.push(` [remote — the other machine]`, preview(hunk.remote, v.max_chars_per_side));
83
+ lines.push("");
84
+ }
85
+ lines.push(`Resolve with resolve_conflict_hunks: take "local", "remote", "both", "none" (drop it), or "custom" with your own text.`);
86
+ return ok(lines.join("\n"));
87
+ }
88
+ catch (e) {
89
+ return err(`Error reading conflict hunks: ${e instanceof Error ? e.message : String(e)}`);
90
+ }
91
+ }
92
+ const ResolveHunksArgs = z.object({
93
+ path: z.string(),
94
+ decisions: z
95
+ .array(z.object({
96
+ hunk: z.number().int().positive(),
97
+ take: z.enum(["local", "remote", "both", "none", "custom"]),
98
+ content: z.string().optional(),
99
+ }))
100
+ .min(1),
101
+ });
102
+ export async function resolveConflictHunks(syncEngine, args) {
103
+ try {
104
+ if (!syncEngine.isConfigured())
105
+ return err(NOT_CONFIGURED);
106
+ const v = ResolveHunksArgs.parse(args);
107
+ const missingContent = v.decisions.filter((d) => d.take === "custom" && d.content === undefined);
108
+ if (missingContent.length > 0) {
109
+ return err(`Hunk(s) ${missingContent.map((d) => d.hunk).join(", ")} use take "custom" but no content was provided.`);
110
+ }
111
+ const operation = await syncEngine.operationInProgress();
112
+ const decisions = v.decisions.map((d) => d.take === "custom" ? { index: d.hunk, take: "custom", content: d.content } : { index: d.hunk, take: d.take });
113
+ const original = syncEngine.readWorkingFile(v.path);
114
+ if (!hasConflictMarkers(original)) {
115
+ return err(`"${v.path}" has no conflict markers — there is nothing to resolve hunk by hunk.`);
116
+ }
117
+ // Concatenating both sides of a frontmatter hunk repeats the key it holds,
118
+ // which makes the YAML unparseable for every reader of the document.
119
+ const { hunks } = parseConflicts(original, operation);
120
+ const badBoth = v.decisions.filter((d) => d.take === "both" && hunks.find((h) => h.index === d.hunk)?.inFrontmatter);
121
+ if (badBoth.length > 0) {
122
+ return err(`Hunk(s) ${badBoth.map((d) => d.hunk).join(", ")} concern frontmatter, where "both" would duplicate a key and break the YAML. ` +
123
+ `Use "local", "remote", or "custom" with the single value you want.`);
124
+ }
125
+ // A hoisted frontmatter block lives in the body but describes YAML keys.
126
+ // Applying its chosen side literally would paste "title: X" into the prose
127
+ // and leave the real frontmatter untouched, so those hunks are dropped from
128
+ // the body and their values folded back into the frontmatter instead.
129
+ const frontmatterUpdates = {};
130
+ const effective = decisions.map((decision) => {
131
+ const hunk = hunks.find((h) => h.index === decision.index);
132
+ if (!hunk?.hoisted)
133
+ return decision;
134
+ const chosen = decision.take === "local" ? hunk.local
135
+ : decision.take === "remote" ? hunk.remote
136
+ : decision.take === "custom" ? decision.content.split(/\r?\n/)
137
+ : [];
138
+ if (chosen.length > 0) {
139
+ try {
140
+ const asYaml = ["---", ...chosen, "---", ""].join("\n");
141
+ Object.assign(frontmatterUpdates, matter(asYaml).data);
142
+ }
143
+ catch {
144
+ // Not parseable as YAML - leave the frontmatter alone rather than
145
+ // corrupting it; the caller still sees the block disappear.
146
+ }
147
+ }
148
+ return { index: decision.index, take: "none" };
149
+ });
150
+ const result = applyResolutions(original, operation, effective);
151
+ if (Object.keys(frontmatterUpdates).length > 0) {
152
+ try {
153
+ const parsed = matter(result.text);
154
+ result.text = matter.stringify(parsed.content, { ...parsed.data, ...frontmatterUpdates });
155
+ }
156
+ catch {
157
+ // Frontmatter still holds markers of its own; it will be rewritten
158
+ // once those hunks are resolved too.
159
+ }
160
+ }
161
+ // The invariant is checked against the resulting text rather than the hunk
162
+ // bookkeeping: staging a file that still holds markers tells git the
163
+ // conflict is settled and lets them be committed as documentation content.
164
+ const settled = !hasConflictMarkers(result.text);
165
+ await syncEngine.writeResolvedFile(v.path, result.text, settled);
166
+ const lines = [`Resolved ${result.applied.length} hunk(s) in "${v.path}".`];
167
+ if (result.unknown.length > 0) {
168
+ lines.push(`Ignored — no such hunk: ${result.unknown.join(", ")}.`);
169
+ }
170
+ if (!settled) {
171
+ lines.push(`${result.remaining} hunk(s) still conflicted; the file is left unstaged until every hunk is decided.`, `Run get_conflict_hunks again — the remaining hunks have been renumbered.`);
172
+ return ok(lines.join("\n"));
173
+ }
174
+ lines.push(`File fully resolved and staged.`);
175
+ const finish = await syncEngine.continueOperation();
176
+ if (finish.remaining.length > 0) {
177
+ lines.push(``, `Still conflicted elsewhere (${finish.remaining.length}):`, ` - ${finish.remaining.join("\n - ")}`);
178
+ }
179
+ else if (finish.done && finish.operation !== "none") {
180
+ lines.push(``, `The ${finish.operation} is complete. Run push_docs to publish.`);
181
+ }
182
+ else if (finish.error) {
183
+ lines.push(``, `The ${finish.operation} could not be completed: ${finish.error}`);
184
+ }
185
+ return ok(lines.join("\n"));
186
+ }
187
+ catch (e) {
188
+ return err(`Error resolving hunks: ${e instanceof Error ? e.message : String(e)}`);
189
+ }
190
+ }