@contentrain/mcp 3.0.3 → 3.1.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.
Files changed (162) hide show
  1. package/README.md +6 -3
  2. package/dist/{annotations-C-tQVmot.mjs → annotations-CXHPnS-M.mjs} +8 -1
  3. package/dist/annotations-CXHPnS-M.mjs.map +1 -0
  4. package/dist/{apply-manager-BybvnUVC.mjs → apply-manager-DsNq7Zhm.mjs} +7 -7
  5. package/dist/{apply-manager-BybvnUVC.mjs.map → apply-manager-DsNq7Zhm.mjs.map} +1 -1
  6. package/dist/{astro-parser-Cb9uuBpW.mjs → astro-parser-dhFgc2wA.mjs} +3 -3
  7. package/dist/{astro-parser-Cb9uuBpW.mjs.map → astro-parser-dhFgc2wA.mjs.map} +1 -1
  8. package/dist/{availability-Dgcmvv99.mjs → availability-DYHNoP0_.mjs} +5 -1
  9. package/dist/availability-DYHNoP0_.mjs.map +1 -0
  10. package/dist/{branch-lifecycle-DhzHfzs3.d.mts → branch-lifecycle-DGlgjnC7.d.mts} +32 -2
  11. package/dist/branch-lifecycle-DGlgjnC7.d.mts.map +1 -0
  12. package/dist/{branch-lifecycle-B6PXZ2N7.mjs → branch-lifecycle-DUOoyNWn.mjs} +32 -4
  13. package/dist/branch-lifecycle-DUOoyNWn.mjs.map +1 -0
  14. package/dist/{compiler-B6hNQeCC.mjs → compiler-CR9F0wZZ.mjs} +2 -2
  15. package/dist/{compiler-B6hNQeCC.mjs.map → compiler-CR9F0wZZ.mjs.map} +1 -1
  16. package/dist/{compiler-sfc.cjs-8yY7b5bx.mjs → compiler-sfc.cjs-Cpexs92i.mjs} +2 -2
  17. package/dist/{compiler-sfc.cjs-8yY7b5bx.mjs.map → compiler-sfc.cjs-Cpexs92i.mjs.map} +1 -1
  18. package/dist/{config-VlRKRwAS.mjs → config-B8a4JBT1.mjs} +2 -2
  19. package/dist/{config-VlRKRwAS.mjs.map → config-B8a4JBT1.mjs.map} +1 -1
  20. package/dist/{content-manager-Dn4tqDw1.d.mts → content-manager-Bx94DXSF.d.mts} +2 -2
  21. package/dist/{content-manager-Dn4tqDw1.d.mts.map → content-manager-Bx94DXSF.d.mts.map} +1 -1
  22. package/dist/{context-C3x6I3cE.mjs → context-CxpTATh4.mjs} +7 -7
  23. package/dist/context-CxpTATh4.mjs.map +1 -0
  24. package/dist/core/apply-manager.mjs +10 -11
  25. package/dist/core/config.d.mts +1 -1
  26. package/dist/core/config.mjs +2 -2
  27. package/dist/core/content-manager.d.mts +1 -1
  28. package/dist/core/content-manager.mjs +3 -3
  29. package/dist/core/context.d.mts +1 -1
  30. package/dist/core/context.mjs +5 -6
  31. package/dist/core/contracts/index.d.mts +1 -1
  32. package/dist/core/doctor.mjs +8 -8
  33. package/dist/core/graph-builder.mjs +3 -3
  34. package/dist/core/meta-manager.mjs +2 -2
  35. package/dist/core/model-manager.d.mts +1 -1
  36. package/dist/core/model-manager.mjs +3 -3
  37. package/dist/core/ops/index.d.mts +17 -29
  38. package/dist/core/ops/index.d.mts.map +1 -1
  39. package/dist/core/ops/index.mjs +9 -6
  40. package/dist/core/overlay-reader.d.mts +1 -1
  41. package/dist/core/overlay-reader.mjs +1 -1
  42. package/dist/core/scan-config.mjs +2 -2
  43. package/dist/core/scanner.mjs +4 -4
  44. package/dist/core/validator/index.d.mts +1 -1
  45. package/dist/core/validator/index.mjs +5 -5
  46. package/dist/{detect-B0oXbQaP.mjs → detect-CI32ynpq.mjs} +2 -2
  47. package/dist/{detect-B0oXbQaP.mjs.map → detect-CI32ynpq.mjs.map} +1 -1
  48. package/dist/{doctor-DNPuGgcg.mjs → doctor-B7G0oFxO.mjs} +7 -7
  49. package/dist/{doctor-DNPuGgcg.mjs.map → doctor-B7G0oFxO.mjs.map} +1 -1
  50. package/dist/errors-BmLE-JSC.mjs +55 -0
  51. package/dist/errors-BmLE-JSC.mjs.map +1 -0
  52. package/dist/{errors-CvBS12e7.mjs → errors-DLkAWnzQ.mjs} +1 -1
  53. package/dist/{errors-CvBS12e7.mjs.map → errors-DLkAWnzQ.mjs.map} +1 -1
  54. package/dist/{fs-DLbVB-Ek.mjs → fs-DJv5G8uL.mjs} +1 -1
  55. package/dist/{fs-DLbVB-Ek.mjs.map → fs-DJv5G8uL.mjs.map} +1 -1
  56. package/dist/git/branch-lifecycle.d.mts +2 -2
  57. package/dist/git/branch-lifecycle.mjs +5 -5
  58. package/dist/git/errors.d.mts +30 -0
  59. package/dist/git/errors.d.mts.map +1 -0
  60. package/dist/git/errors.mjs +2 -0
  61. package/dist/git/identity.mjs +1 -1
  62. package/dist/git/reconcile.d.mts +49 -0
  63. package/dist/git/reconcile.d.mts.map +1 -0
  64. package/dist/git/reconcile.mjs +13 -0
  65. package/dist/git/transaction.d.mts +60 -15
  66. package/dist/git/transaction.d.mts.map +1 -1
  67. package/dist/git/transaction.mjs +10 -11
  68. package/dist/{graph-builder-BEocwW13.mjs → graph-builder-C9NS7y-a.mjs} +3 -3
  69. package/dist/{graph-builder-BEocwW13.mjs.map → graph-builder-C9NS7y-a.mjs.map} +1 -1
  70. package/dist/{id-BjhaIxTL.mjs → id-CjGMPP1k.mjs} +2 -2
  71. package/dist/{id-BjhaIxTL.mjs.map → id-CjGMPP1k.mjs.map} +1 -1
  72. package/dist/{identity-D8QoGxhT.mjs → identity-CxAB9Qb8.mjs} +1 -1
  73. package/dist/{identity-D8QoGxhT.mjs.map → identity-CxAB9Qb8.mjs.map} +1 -1
  74. package/dist/index-_HJVkDJa.d.mts +85 -0
  75. package/dist/index-_HJVkDJa.d.mts.map +1 -0
  76. package/dist/index.mjs +24 -22
  77. package/dist/index.mjs.map +1 -1
  78. package/dist/{local-Db5X9mjZ.mjs → local-bnn6yrG7.mjs} +83 -8
  79. package/dist/local-bnn6yrG7.mjs.map +1 -0
  80. package/dist/{meta-manager-B_Se183X.mjs → meta-manager-g3shoZES.mjs} +2 -2
  81. package/dist/{meta-manager-B_Se183X.mjs.map → meta-manager-g3shoZES.mjs.map} +1 -1
  82. package/dist/{model-manager-DgyKMjLN.mjs → model-manager-DqwL8ptm.mjs} +66 -5
  83. package/dist/model-manager-DqwL8ptm.mjs.map +1 -0
  84. package/dist/{node-DUGiIkv1.mjs → node-CpLm3qr6.mjs} +1 -1
  85. package/dist/{node-DUGiIkv1.mjs.map → node-CpLm3qr6.mjs.map} +1 -1
  86. package/dist/{ops-BzbThweu.mjs → ops-BfrdZ6GK.mjs} +24 -34
  87. package/dist/ops-BfrdZ6GK.mjs.map +1 -0
  88. package/dist/{overlay-reader-CkU_pGXZ.mjs → overlay-reader-Bx1YxGXq.mjs} +1 -1
  89. package/dist/{overlay-reader-CkU_pGXZ.mjs.map → overlay-reader-Bx1YxGXq.mjs.map} +1 -1
  90. package/dist/{paths-CdPPWpbG.mjs → paths-C6_C1gLp.mjs} +1 -1
  91. package/dist/{paths-CdPPWpbG.mjs.map → paths-C6_C1gLp.mjs.map} +1 -1
  92. package/dist/providers/github/index.d.mts +10 -1
  93. package/dist/providers/github/index.d.mts.map +1 -1
  94. package/dist/providers/github/index.mjs +104 -4
  95. package/dist/providers/github/index.mjs.map +1 -1
  96. package/dist/providers/gitlab/index.d.mts +8 -1
  97. package/dist/providers/gitlab/index.d.mts.map +1 -1
  98. package/dist/providers/gitlab/index.mjs +15 -2
  99. package/dist/providers/gitlab/index.mjs.map +1 -1
  100. package/dist/providers/local/index.d.mts +26 -2
  101. package/dist/providers/local/index.d.mts.map +1 -1
  102. package/dist/providers/local/index.mjs +13 -12
  103. package/dist/reconcile-D5FTVW5z.mjs +1704 -0
  104. package/dist/reconcile-D5FTVW5z.mjs.map +1 -0
  105. package/dist/reconcile-M7KBZtdn.mjs +249 -0
  106. package/dist/reconcile-M7KBZtdn.mjs.map +1 -0
  107. package/dist/{scan-config-Dx49nCqk.mjs → scan-config-C5zzygeH.mjs} +2 -2
  108. package/dist/{scan-config-Dx49nCqk.mjs.map → scan-config-C5zzygeH.mjs.map} +1 -1
  109. package/dist/{scanner-BSvXjfkp.mjs → scanner-CEiT8Tgx.mjs} +7 -7
  110. package/dist/{scanner-BSvXjfkp.mjs.map → scanner-CEiT8Tgx.mjs.map} +1 -1
  111. package/dist/server/http/index.d.mts +1 -1
  112. package/dist/server/http/index.mjs +24 -22
  113. package/dist/server/http/index.mjs.map +1 -1
  114. package/dist/{server-B7QA0Q1M.mjs → server-BtzVY7yp.mjs} +343 -227
  115. package/dist/server-BtzVY7yp.mjs.map +1 -0
  116. package/dist/{server-CLPNnhYL.d.mts → server-SDsD0XGM.d.mts} +2 -2
  117. package/dist/{server-CLPNnhYL.d.mts.map → server-SDsD0XGM.d.mts.map} +1 -1
  118. package/dist/server.d.mts +1 -1
  119. package/dist/server.mjs +24 -22
  120. package/dist/{source-map-generator-DhjiQdV3.mjs → source-map-generator-Clb2Eqse.mjs} +1 -1
  121. package/dist/{source-map-generator-DhjiQdV3.mjs.map → source-map-generator-Clb2Eqse.mjs.map} +1 -1
  122. package/dist/{svelte-parser-CwySu9yn.mjs → svelte-parser-CBN7Jxb7.mjs} +3 -3
  123. package/dist/{svelte-parser-CwySu9yn.mjs.map → svelte-parser-CBN7Jxb7.mjs.map} +1 -1
  124. package/dist/templates/index.mjs +1 -1
  125. package/dist/{templates-CAkTIRkE.mjs → templates-BZbohJc8.mjs} +1 -1
  126. package/dist/{templates-CAkTIRkE.mjs.map → templates-BZbohJc8.mjs.map} +1 -1
  127. package/dist/testing/memory-provider.d.mts +26 -1
  128. package/dist/testing/memory-provider.d.mts.map +1 -1
  129. package/dist/testing/memory-provider.mjs +62 -2
  130. package/dist/testing/memory-provider.mjs.map +1 -1
  131. package/dist/tools/annotations.d.mts.map +1 -1
  132. package/dist/tools/annotations.mjs +1 -1
  133. package/dist/tools/availability.d.mts.map +1 -1
  134. package/dist/tools/availability.mjs +1 -1
  135. package/dist/{transaction-BW9ektWu.mjs → transaction-BxDiiakv.mjs} +128 -64
  136. package/dist/transaction-BxDiiakv.mjs.map +1 -0
  137. package/dist/tsx-parser-DOQpJVgK.mjs +2 -0
  138. package/dist/{tsx-parser-BvsK6lyG.mjs → tsx-parser-go5V9zoc.mjs} +1 -1
  139. package/dist/{tsx-parser-BvsK6lyG.mjs.map → tsx-parser-go5V9zoc.mjs.map} +1 -1
  140. package/dist/util/detect.mjs +2 -2
  141. package/dist/util/fs.mjs +1 -1
  142. package/dist/util/id.mjs +1 -1
  143. package/dist/{validator-0sJ9vh1j.mjs → validator-B2GhPM3L.mjs} +5 -6
  144. package/dist/{validator-0sJ9vh1j.mjs.map → validator-B2GhPM3L.mjs.map} +1 -1
  145. package/dist/{vue-parser-auedZiJG.mjs → vue-parser-5Mp8w-mb.mjs} +3 -3
  146. package/dist/{vue-parser-auedZiJG.mjs.map → vue-parser-5Mp8w-mb.mjs.map} +1 -1
  147. package/package.json +13 -5
  148. package/dist/annotations-C-tQVmot.mjs.map +0 -1
  149. package/dist/availability-Dgcmvv99.mjs.map +0 -1
  150. package/dist/branch-lifecycle-B6PXZ2N7.mjs.map +0 -1
  151. package/dist/branch-lifecycle-DhzHfzs3.d.mts.map +0 -1
  152. package/dist/context-C3x6I3cE.mjs.map +0 -1
  153. package/dist/local-Db5X9mjZ.mjs.map +0 -1
  154. package/dist/model-manager-DgyKMjLN.mjs.map +0 -1
  155. package/dist/ops-BzbThweu.mjs.map +0 -1
  156. package/dist/paths-CmVw5Cw2.mjs +0 -65
  157. package/dist/paths-CmVw5Cw2.mjs.map +0 -1
  158. package/dist/serialization-tmgfFBOh.mjs +0 -2
  159. package/dist/server-B7QA0Q1M.mjs.map +0 -1
  160. package/dist/transaction-BW9ektWu.mjs.map +0 -1
  161. package/dist/tsx-parser-Byc7HlJ8.mjs +0 -2
  162. /package/dist/{index-BWbTJlXm.d.mts → index-ByrVsRJE.d.mts} +0 -0
@@ -0,0 +1,1704 @@
1
+ import { t as buildContextChange } from "./context-CxpTATh4.mjs";
2
+ import { D as contentDirPath, O as contentFilePath, k as documentFilePath } from "./model-manager-DqwL8ptm.mjs";
3
+ import { t as OverlayReader } from "./overlay-reader-Bx1YxGXq.mjs";
4
+ import { t as validateProject } from "./validator-B2GhPM3L.mjs";
5
+ import { dirname, join } from "node:path";
6
+ import { MODEL_FIELD_ORDER, canonicalStringify, conflictId, parseMarkdownFrontmatter, serializeMarkdownFrontmatter, sortKeys, stableHash } from "@contentrain/types";
7
+ import { mkdir, rm, writeFile } from "node:fs/promises";
8
+ //#region src/core/ops/apply-to-worktree.ts
9
+ /**
10
+ * Phase 2 shim — applies a `FileChange[]` to a local worktree directory.
11
+ *
12
+ * Phase 3 replaces this with `LocalProvider.applyPlan` (same logic, owned by
13
+ * the provider) while a `GitHubProvider.applyPlan` produces the equivalent
14
+ * commit through the Git Data API. Until then, tool handlers call this shim
15
+ * inside the existing `createTransaction` flow so Phase 2 gets plan/apply
16
+ * semantics without rewriting the transaction machinery.
17
+ *
18
+ * - `content: string` → `writeFile` (creates parent dirs).
19
+ * - `content: null` → `rm`; missing files are tolerated.
20
+ * - Changes are applied in their given order; callers should sort for
21
+ * determinism.
22
+ */
23
+ async function applyChangesToWorktree(worktree, changes) {
24
+ for (const change of changes) {
25
+ const absolute = join(worktree, change.path);
26
+ if (change.content === null) {
27
+ await rm(absolute, { force: true }).catch(() => {});
28
+ continue;
29
+ }
30
+ await mkdir(dirname(absolute), { recursive: true });
31
+ await writeFile(absolute, change.content, "utf-8");
32
+ }
33
+ }
34
+ //#endregion
35
+ //#region src/core/ops/reconcile/read.ts
36
+ /**
37
+ * Per-side promise-memo over a `RepoReader`: every `(path)` read and listing
38
+ * happens at most once for the whole plan, and concurrent duplicate requests
39
+ * coalesce onto the same promise instead of racing. On a GitHub-backed
40
+ * reader this is the difference between one API call per file and one per
41
+ * phase that touches it.
42
+ *
43
+ * The `RepoReader` contract is preserved (`readFile` throws on a missing
44
+ * file) so a `CachedReader` can be handed to `OverlayReader`,
45
+ * `validateProject` and `buildContextChange` unchanged; the planner's own
46
+ * code uses the null-tolerant extras.
47
+ */
48
+ var CachedReader = class {
49
+ files = /* @__PURE__ */ new Map();
50
+ dirs = /* @__PURE__ */ new Map();
51
+ exists = /* @__PURE__ */ new Map();
52
+ constructor(inner) {
53
+ this.inner = inner;
54
+ }
55
+ /** Contract-shaped read: throws when missing. */
56
+ async readFile(path, _ref) {
57
+ const content = await this.readOrNull(path);
58
+ if (content === null) throw new Error(`ENOENT: no such file, open '${path}'`);
59
+ return content;
60
+ }
61
+ /** Planner-shaped read: `null` when missing, cached either way. */
62
+ readOrNull(path) {
63
+ let cached = this.files.get(path);
64
+ if (!cached) {
65
+ cached = this.inner.readFile(path).then((content) => content, () => null);
66
+ this.files.set(path, cached);
67
+ }
68
+ return cached;
69
+ }
70
+ listDirectory(path, _ref) {
71
+ let cached = this.dirs.get(path);
72
+ if (!cached) {
73
+ cached = this.inner.listDirectory(path).then((entries) => entries, () => []);
74
+ this.dirs.set(path, cached);
75
+ }
76
+ return cached;
77
+ }
78
+ fileExists(path, _ref) {
79
+ let cached = this.exists.get(path);
80
+ if (!cached) {
81
+ cached = this.inner.fileExists(path).then((v) => v, () => false);
82
+ this.exists.set(path, cached);
83
+ }
84
+ return cached;
85
+ }
86
+ };
87
+ /** Parse a JSON file, `null` when missing or unparseable. */
88
+ async function readJsonOrNull(reader, path) {
89
+ const raw = await reader.readOrNull(path);
90
+ if (raw === null) return null;
91
+ try {
92
+ return JSON.parse(raw);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+ //#endregion
98
+ //#region src/core/ops/reconcile/resolutions.ts
99
+ /**
100
+ * The decisions carried into a second reconcile round, indexed by conflict
101
+ * id. Merge functions call `consume` at the exact point they would create a
102
+ * `ConflictItem`; a hit turns the conflict into a mechanical outcome, a
103
+ * miss reports it. After planning, `unconsumed()` names the stale
104
+ * resolutions — values changed between rounds, so the decision no longer
105
+ * applies and the conflict reappears with a fresh id.
106
+ */
107
+ var ResolutionIndex = class {
108
+ byId = /* @__PURE__ */ new Map();
109
+ consumed = /* @__PURE__ */ new Set();
110
+ constructor(resolutions = []) {
111
+ for (const resolution of resolutions) this.byId.set(resolution.id, resolution);
112
+ }
113
+ consume(candidate) {
114
+ const id = conflictId(candidate);
115
+ const resolution = this.byId.get(id);
116
+ if (!resolution) return null;
117
+ this.consumed.add(id);
118
+ return resolution;
119
+ }
120
+ unconsumed() {
121
+ return [...this.byId.values()].filter((r) => !this.consumed.has(r.id));
122
+ }
123
+ };
124
+ /**
125
+ * Apply a consumed resolution to the candidate's values. `choose` picks a
126
+ * side (absence on that side means deletion → `undefined`); `value`
127
+ * supplies a replacement outright.
128
+ */
129
+ function resolvedValue(resolution, candidate) {
130
+ if ("choose" in resolution) return resolution.choose === "ours" ? candidate.ours : candidate.theirs;
131
+ return resolution.value;
132
+ }
133
+ //#endregion
134
+ //#region src/core/ops/reconcile/types.ts
135
+ const EMPTY_STATS = {
136
+ takenOurs: 0,
137
+ takenTheirs: 0,
138
+ fieldMerged: 0
139
+ };
140
+ function addStats(a, b) {
141
+ return {
142
+ takenOurs: a.takenOurs + b.takenOurs,
143
+ takenTheirs: a.takenTheirs + b.takenTheirs,
144
+ fieldMerged: a.fieldMerged + b.fieldMerged
145
+ };
146
+ }
147
+ //#endregion
148
+ //#region src/core/ops/reconcile/three-way.ts
149
+ /**
150
+ * Canonical equality: both values normalized through `sortKeys` (which drops
151
+ * `null`/`undefined` values and sorts keys recursively — the same normal
152
+ * form `canonicalStringify` writes to disk), then compared as JSON. So
153
+ * `{ a: null }` equals `{}`, key order never matters, and absent equals
154
+ * null — exactly the distinctions the storage layer cannot represent.
155
+ */
156
+ function eqCanonical(a, b) {
157
+ const na = sortKeys(a);
158
+ const nb = sortKeys(b);
159
+ if (na === void 0 || nb === void 0) return na === nb;
160
+ return JSON.stringify(na) === JSON.stringify(nb);
161
+ }
162
+ /**
163
+ * The three-way rule every policy row reduces to: a side that did not
164
+ * change keeps no vote; identical changes converge; a lone change wins;
165
+ * two different changes are a conflict — with delete-vs-edit split out
166
+ * because it needs a different question ("keep or drop?" instead of
167
+ * "which value?").
168
+ */
169
+ function mergeLeaf3(base, ours, theirs) {
170
+ const oursSame = eqCanonical(ours, base);
171
+ const theirsSame = eqCanonical(theirs, base);
172
+ if (oursSame && theirsSame) return {
173
+ ok: true,
174
+ value: base,
175
+ from: "base"
176
+ };
177
+ if (eqCanonical(ours, theirs)) return {
178
+ ok: true,
179
+ value: ours,
180
+ from: "both"
181
+ };
182
+ if (oursSame) return {
183
+ ok: true,
184
+ value: theirs,
185
+ from: "theirs"
186
+ };
187
+ if (theirsSame) return {
188
+ ok: true,
189
+ value: ours,
190
+ from: "ours"
191
+ };
192
+ const baseAbsent = sortKeys(base) === void 0;
193
+ const oursAbsent = sortKeys(ours) === void 0;
194
+ const theirsAbsent = sortKeys(theirs) === void 0;
195
+ if (!baseAbsent && oursAbsent !== theirsAbsent) return {
196
+ ok: false,
197
+ reason: "delete_edit",
198
+ deletedBy: oursAbsent ? "ours" : "theirs"
199
+ };
200
+ return {
201
+ ok: false,
202
+ reason: "both_changed"
203
+ };
204
+ }
205
+ /**
206
+ * Three-way merge of a keyed record (entries by ID, dictionary by key,
207
+ * terms by slug, schema by property name): iterate the sorted union of
208
+ * keys, delegate each item to `mergeItem`, assemble the output map.
209
+ */
210
+ function mergeKeyedRecord3(three, mergeItem) {
211
+ const keys = [...new Set([
212
+ ...Object.keys(three.base ?? {}),
213
+ ...Object.keys(three.ours ?? {}),
214
+ ...Object.keys(three.theirs ?? {})
215
+ ])].toSorted();
216
+ const merged = {};
217
+ const conflicts = [];
218
+ let stats = EMPTY_STATS;
219
+ for (const key of keys) {
220
+ const outcome = mergeItem(key, {
221
+ base: three.base?.[key],
222
+ ours: three.ours?.[key],
223
+ theirs: three.theirs?.[key]
224
+ });
225
+ if (outcome.value !== void 0) merged[key] = outcome.value;
226
+ conflicts.push(...outcome.conflicts);
227
+ stats = addStats(stats, outcome.stats);
228
+ }
229
+ return {
230
+ merged,
231
+ conflicts,
232
+ stats
233
+ };
234
+ }
235
+ /** Attach the value-derived id — the only way a ConflictItem is ever built. */
236
+ function makeConflict(input) {
237
+ return {
238
+ id: conflictId(input),
239
+ ...input
240
+ };
241
+ }
242
+ /** Stats bucket for a clean leaf outcome; `base`/`both` count nowhere. */
243
+ function statsFor(from) {
244
+ if (from === "ours") return {
245
+ takenOurs: 1,
246
+ takenTheirs: 0,
247
+ fieldMerged: 0
248
+ };
249
+ if (from === "theirs") return {
250
+ takenOurs: 0,
251
+ takenTheirs: 1,
252
+ fieldMerged: 0
253
+ };
254
+ return EMPTY_STATS;
255
+ }
256
+ //#endregion
257
+ //#region src/core/ops/reconcile/merge-content.ts
258
+ /**
259
+ * Three-way merge of one entry's fields. Called when the entry as a whole
260
+ * changed on BOTH sides: different fields both win (the union), the same
261
+ * field with two values is a conflict, and a field deleted on one side but
262
+ * edited on the other asks a different question and gets its own code.
263
+ * An unresolved conflict keeps ours' value in the output — the plan is not
264
+ * applied while conflicts remain, so nothing is ever silently overwritten.
265
+ */
266
+ function mergeEntryFields(three, ctx, entryKey) {
267
+ const fields = [...new Set([
268
+ ...Object.keys(three.base ?? {}),
269
+ ...Object.keys(three.ours ?? {}),
270
+ ...Object.keys(three.theirs ?? {})
271
+ ])].toSorted();
272
+ const merged = {};
273
+ const conflicts = [];
274
+ for (const field of fields) {
275
+ const candidate = {
276
+ path: ctx.outPath,
277
+ key: entryKey,
278
+ field,
279
+ locale: ctx.locale,
280
+ base: three.base?.[field],
281
+ ours: three.ours?.[field],
282
+ theirs: three.theirs?.[field]
283
+ };
284
+ const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
285
+ if (leaf.ok) {
286
+ if (leaf.value !== void 0) merged[field] = leaf.value;
287
+ continue;
288
+ }
289
+ const resolution = ctx.resolutions.consume(candidate);
290
+ if (resolution) {
291
+ const value = resolvedValue(resolution, candidate);
292
+ if (value !== void 0) merged[field] = value;
293
+ continue;
294
+ }
295
+ if (candidate.ours !== void 0) merged[field] = candidate.ours;
296
+ const label = entryKey ? `"${entryKey}" field "${field}"` : `field "${field}"`;
297
+ conflicts.push(makeConflict({
298
+ ...candidate,
299
+ kind: ctx.kind,
300
+ model: ctx.model,
301
+ code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "field_value_conflict",
302
+ message: leaf.reason === "delete_edit" ? `${label} was deleted on one side and edited on the other in ${ctx.outPath}.` : `${label} was changed to different values on both sides in ${ctx.outPath}.`
303
+ }));
304
+ }
305
+ return {
306
+ merged,
307
+ conflicts
308
+ };
309
+ }
310
+ /**
311
+ * Collection file: entry-level three-way over the object-map, field-level
312
+ * union when both sides touched the same entry.
313
+ */
314
+ function mergeCollectionFile(three, ctx) {
315
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
316
+ if (fileLeaf.ok) return {
317
+ merged: fileLeaf.value,
318
+ conflicts: [],
319
+ stats: statsFor(fileLeaf.from)
320
+ };
321
+ return mergeKeyedRecord3(three, (entryId, entry) => {
322
+ const leaf = mergeLeaf3(entry.base, entry.ours, entry.theirs);
323
+ if (leaf.ok) return {
324
+ value: leaf.value,
325
+ conflicts: [],
326
+ stats: statsFor(leaf.from)
327
+ };
328
+ if (leaf.reason === "delete_edit") {
329
+ const candidate = {
330
+ path: ctx.outPath,
331
+ key: entryId,
332
+ locale: ctx.locale,
333
+ base: entry.base,
334
+ ours: entry.ours,
335
+ theirs: entry.theirs
336
+ };
337
+ const resolution = ctx.resolutions.consume(candidate);
338
+ if (resolution) return {
339
+ value: resolvedValue(resolution, candidate),
340
+ conflicts: [],
341
+ stats: EMPTY_STATS
342
+ };
343
+ return {
344
+ value: entry.ours,
345
+ conflicts: [makeConflict({
346
+ ...candidate,
347
+ kind: ctx.kind,
348
+ model: ctx.model,
349
+ code: "delete_edit_conflict",
350
+ message: `Entry "${entryId}" was deleted on one side and edited on the other in ${ctx.outPath}.`
351
+ })],
352
+ stats: EMPTY_STATS
353
+ };
354
+ }
355
+ const fieldMerge = mergeEntryFields(entry, ctx, entryId);
356
+ return {
357
+ value: fieldMerge.merged,
358
+ conflicts: fieldMerge.conflicts,
359
+ stats: fieldMerge.conflicts.length === 0 ? {
360
+ takenOurs: 0,
361
+ takenTheirs: 0,
362
+ fieldMerged: 1
363
+ } : EMPTY_STATS
364
+ };
365
+ });
366
+ }
367
+ /** Singleton file: one record — straight field-level three-way. */
368
+ function mergeSingletonFile(three, ctx) {
369
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
370
+ if (fileLeaf.ok) return {
371
+ merged: fileLeaf.value,
372
+ conflicts: [],
373
+ stats: statsFor(fileLeaf.from)
374
+ };
375
+ if (fileLeaf.reason === "delete_edit") {
376
+ const candidate = {
377
+ path: ctx.outPath,
378
+ locale: ctx.locale,
379
+ base: three.base,
380
+ ours: three.ours,
381
+ theirs: three.theirs
382
+ };
383
+ const resolution = ctx.resolutions.consume(candidate);
384
+ if (resolution) return {
385
+ merged: resolvedValue(resolution, candidate),
386
+ conflicts: [],
387
+ stats: EMPTY_STATS
388
+ };
389
+ return {
390
+ merged: three.ours,
391
+ conflicts: [makeConflict({
392
+ ...candidate,
393
+ kind: ctx.kind,
394
+ model: ctx.model,
395
+ code: "delete_edit_conflict",
396
+ message: `${ctx.outPath} was deleted on one side and edited on the other.`
397
+ })],
398
+ stats: EMPTY_STATS
399
+ };
400
+ }
401
+ const fieldMerge = mergeEntryFields(three, ctx);
402
+ return {
403
+ merged: fieldMerge.merged,
404
+ conflicts: fieldMerge.conflicts,
405
+ stats: fieldMerge.conflicts.length === 0 ? {
406
+ takenOurs: 0,
407
+ takenTheirs: 0,
408
+ fieldMerged: 1
409
+ } : EMPTY_STATS
410
+ };
411
+ }
412
+ /**
413
+ * Dictionary file: keys are the identities, values are string leaves. The
414
+ * three-way generalization of the inline upsert semantics: with an author
415
+ * present (a save), the incoming value wins and is reported; with no
416
+ * author (reconcile), two changed values are a question, not a pick.
417
+ */
418
+ function mergeDictionaryFile(three, ctx) {
419
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
420
+ if (fileLeaf.ok) return {
421
+ merged: fileLeaf.value,
422
+ conflicts: [],
423
+ stats: statsFor(fileLeaf.from)
424
+ };
425
+ return mergeKeyedRecord3(three, (key, item) => {
426
+ const leaf = mergeLeaf3(item.base, item.ours, item.theirs);
427
+ if (leaf.ok) return {
428
+ value: leaf.value,
429
+ conflicts: [],
430
+ stats: statsFor(leaf.from)
431
+ };
432
+ const candidate = {
433
+ path: ctx.outPath,
434
+ key,
435
+ locale: ctx.locale,
436
+ base: item.base,
437
+ ours: item.ours,
438
+ theirs: item.theirs
439
+ };
440
+ const resolution = ctx.resolutions.consume(candidate);
441
+ if (resolution) return {
442
+ value: resolvedValue(resolution, candidate),
443
+ conflicts: [],
444
+ stats: EMPTY_STATS
445
+ };
446
+ return {
447
+ value: item.ours,
448
+ conflicts: [makeConflict({
449
+ ...candidate,
450
+ kind: "dictionary",
451
+ model: ctx.model,
452
+ code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "dictionary_value_conflict",
453
+ message: leaf.reason === "delete_edit" ? `Dictionary key "${key}" was deleted on one side and edited on the other in ${ctx.outPath}.` : `Dictionary key "${key}" has two different values in ${ctx.outPath}.`
454
+ })],
455
+ stats: EMPTY_STATS
456
+ };
457
+ });
458
+ }
459
+ /**
460
+ * `normalize-sources.json`: keyed tooling metadata — merged with the
461
+ * dictionary rule, values compared canonically whatever their shape.
462
+ */
463
+ function mergeKeyedJson(three, ctx) {
464
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
465
+ if (fileLeaf.ok) return {
466
+ merged: fileLeaf.value,
467
+ conflicts: [],
468
+ stats: statsFor(fileLeaf.from)
469
+ };
470
+ const result = mergeKeyedRecord3(three, (key, item) => {
471
+ const leaf = mergeLeaf3(item.base, item.ours, item.theirs);
472
+ if (leaf.ok) return {
473
+ value: leaf.value,
474
+ conflicts: [],
475
+ stats: statsFor(leaf.from)
476
+ };
477
+ const candidate = {
478
+ path: ctx.outPath,
479
+ key,
480
+ base: item.base,
481
+ ours: item.ours,
482
+ theirs: item.theirs
483
+ };
484
+ const resolution = ctx.resolutions.consume(candidate);
485
+ if (resolution) return {
486
+ value: resolvedValue(resolution, candidate),
487
+ conflicts: [],
488
+ stats: EMPTY_STATS
489
+ };
490
+ return {
491
+ value: item.ours,
492
+ conflicts: [makeConflict({
493
+ ...candidate,
494
+ kind: "file",
495
+ code: "file_conflict",
496
+ message: `Key "${key}" of ${ctx.outPath} was changed differently on both sides.`
497
+ })],
498
+ stats: EMPTY_STATS
499
+ };
500
+ });
501
+ return {
502
+ merged: result.merged,
503
+ conflicts: result.conflicts,
504
+ stats: result.stats
505
+ };
506
+ }
507
+ //#endregion
508
+ //#region src/core/ops/reconcile/merge-document.ts
509
+ /**
510
+ * Document (.md) three-way merge: frontmatter key-by-key, body as one leaf.
511
+ *
512
+ * The body is deliberately never text-merged. Interleaving two prose edits
513
+ * line-by-line produces syntactically plausible, editorially wrong hybrids —
514
+ * choosing between two versions of prose is a content decision, and MCP
515
+ * does not make those.
516
+ *
517
+ * The frontmatter parser round-trips only scalars and scalar arrays
518
+ * (nested maps serialize but do not parse back). A side whose frontmatter
519
+ * does not survive parse→serialize is not safe to rewrite, so the merge
520
+ * falls back to whole-file resolution for that document instead of
521
+ * silently destroying nested data.
522
+ */
523
+ function mergeDocumentFile(three, ctx) {
524
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
525
+ if (fileLeaf.ok) return {
526
+ merged: fileLeaf.value,
527
+ conflicts: [],
528
+ stats: statsFor(fileLeaf.from),
529
+ advisories: []
530
+ };
531
+ if (fileLeaf.reason === "delete_edit") {
532
+ const candidate = {
533
+ path: ctx.outPath,
534
+ key: ctx.slug,
535
+ locale: ctx.locale,
536
+ base: three.base,
537
+ ours: three.ours,
538
+ theirs: three.theirs
539
+ };
540
+ const resolution = ctx.resolutions.consume(candidate);
541
+ if (resolution) return {
542
+ merged: resolvedValue(resolution, candidate),
543
+ conflicts: [],
544
+ stats: EMPTY_STATS,
545
+ advisories: []
546
+ };
547
+ return {
548
+ merged: three.ours,
549
+ conflicts: [makeConflict({
550
+ ...candidate,
551
+ kind: "document",
552
+ model: ctx.model,
553
+ code: "delete_edit_conflict",
554
+ message: `Document "${ctx.slug}" was deleted on one side and edited on the other (${ctx.outPath}).`
555
+ })],
556
+ stats: EMPTY_STATS,
557
+ advisories: []
558
+ };
559
+ }
560
+ const parsed = {
561
+ base: three.base === void 0 ? void 0 : parseMarkdownFrontmatter(three.base),
562
+ ours: three.ours === void 0 ? void 0 : parseMarkdownFrontmatter(three.ours),
563
+ theirs: three.theirs === void 0 ? void 0 : parseMarkdownFrontmatter(three.theirs)
564
+ };
565
+ if (["ours", "theirs"].some((side) => {
566
+ const doc = parsed[side];
567
+ if (!doc) return false;
568
+ const reserialized = parseMarkdownFrontmatter(serializeMarkdownFrontmatter(doc.frontmatter, doc.body));
569
+ return !eqCanonical(reserialized.frontmatter, doc.frontmatter) || reserialized.body !== doc.body;
570
+ })) {
571
+ const candidate = {
572
+ path: ctx.outPath,
573
+ key: ctx.slug,
574
+ locale: ctx.locale,
575
+ base: three.base,
576
+ ours: three.ours,
577
+ theirs: three.theirs
578
+ };
579
+ const resolution = ctx.resolutions.consume(candidate);
580
+ if (resolution) return {
581
+ merged: resolvedValue(resolution, candidate),
582
+ conflicts: [],
583
+ stats: EMPTY_STATS,
584
+ advisories: []
585
+ };
586
+ return {
587
+ merged: three.ours,
588
+ conflicts: [makeConflict({
589
+ ...candidate,
590
+ kind: "document",
591
+ model: ctx.model,
592
+ code: "file_conflict",
593
+ message: `Document "${ctx.slug}" (${ctx.outPath}) changed on both sides and carries frontmatter the structured merge cannot round-trip — choose a side.`
594
+ })],
595
+ stats: EMPTY_STATS,
596
+ advisories: [`Document "${ctx.slug}": fell back to whole-file resolution (nested frontmatter does not round-trip).`]
597
+ };
598
+ }
599
+ const conflicts = [];
600
+ const fmKeys = [...new Set([
601
+ ...Object.keys(parsed.base?.frontmatter ?? {}),
602
+ ...Object.keys(parsed.ours?.frontmatter ?? {}),
603
+ ...Object.keys(parsed.theirs?.frontmatter ?? {})
604
+ ])].toSorted();
605
+ const mergedFm = {};
606
+ for (const key of fmKeys) {
607
+ const candidate = {
608
+ path: ctx.outPath,
609
+ key: ctx.slug,
610
+ field: key,
611
+ locale: ctx.locale,
612
+ base: parsed.base?.frontmatter[key],
613
+ ours: parsed.ours?.frontmatter[key],
614
+ theirs: parsed.theirs?.frontmatter[key]
615
+ };
616
+ const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
617
+ if (leaf.ok) {
618
+ if (leaf.value !== void 0) mergedFm[key] = leaf.value;
619
+ continue;
620
+ }
621
+ const resolution = ctx.resolutions.consume(candidate);
622
+ if (resolution) {
623
+ const value = resolvedValue(resolution, candidate);
624
+ if (value !== void 0) mergedFm[key] = value;
625
+ continue;
626
+ }
627
+ if (candidate.ours !== void 0) mergedFm[key] = candidate.ours;
628
+ conflicts.push(makeConflict({
629
+ ...candidate,
630
+ kind: "document",
631
+ model: ctx.model,
632
+ code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "frontmatter_value_conflict",
633
+ message: `Frontmatter key "${key}" of document "${ctx.slug}" differs on both sides (${ctx.outPath}).`
634
+ }));
635
+ }
636
+ let mergedBody;
637
+ const bodyCandidate = {
638
+ path: ctx.outPath,
639
+ key: ctx.slug,
640
+ field: "body",
641
+ locale: ctx.locale,
642
+ base: parsed.base?.body,
643
+ ours: parsed.ours?.body,
644
+ theirs: parsed.theirs?.body
645
+ };
646
+ const bodyLeaf = mergeLeaf3(bodyCandidate.base, bodyCandidate.ours, bodyCandidate.theirs);
647
+ if (bodyLeaf.ok) mergedBody = bodyLeaf.value ?? "";
648
+ else {
649
+ const resolution = ctx.resolutions.consume(bodyCandidate);
650
+ if (resolution) mergedBody = resolvedValue(resolution, bodyCandidate) ?? "";
651
+ else {
652
+ mergedBody = bodyCandidate.ours ?? "";
653
+ conflicts.push(makeConflict({
654
+ ...bodyCandidate,
655
+ kind: "document",
656
+ model: ctx.model,
657
+ code: "document_body_conflict",
658
+ message: `Body of document "${ctx.slug}" was edited on both sides (${ctx.outPath}) — bodies are never text-merged.`
659
+ }));
660
+ }
661
+ }
662
+ return {
663
+ merged: serializeMarkdownFrontmatter(mergedFm, mergedBody),
664
+ conflicts,
665
+ stats: conflicts.length === 0 ? {
666
+ takenOurs: 0,
667
+ takenTheirs: 0,
668
+ fieldMerged: 1
669
+ } : EMPTY_STATS,
670
+ advisories: []
671
+ };
672
+ }
673
+ //#endregion
674
+ //#region src/core/ops/reconcile/merge-meta.ts
675
+ /**
676
+ * Three-way merge of one entry's meta. Special fields:
677
+ *
678
+ * - `updated_at`: the later timestamp wins (ISO strings compare lexically);
679
+ * a present value beats an absent one.
680
+ * - `updated_by`: follows the side that supplied the winning `updated_at`;
681
+ * with no timestamps to arbitrate, ours is kept and an advisory says so.
682
+ * - `status`: a publish decision belongs to whoever made it — one side's
683
+ * change wins mechanically, but two different decisions are a question
684
+ * for a human, never a pick.
685
+ *
686
+ * Entry-level delete-vs-edit resolves mechanically to the edited side with
687
+ * an advisory: meta is bookkeeping that trails content, and the CONTENT
688
+ * delete-vs-edit conflict (reported separately) is the real question. The
689
+ * orphan-meta validator catches any residue once that resolves.
690
+ */
691
+ function mergeEntryMeta3(three, ctx, entryKey) {
692
+ const leaf = mergeLeaf3(three.base, three.ours, three.theirs);
693
+ if (leaf.ok) return {
694
+ merged: leaf.value,
695
+ conflicts: [],
696
+ advisories: []
697
+ };
698
+ if (leaf.reason === "delete_edit") return {
699
+ merged: leaf.deletedBy === "ours" ? three.theirs : three.ours,
700
+ conflicts: [],
701
+ advisories: [`Kept the edited side of ${entryKey ? `meta of "${entryKey}"` : `meta ${ctx.outPath}`} (deleted on the other) — bookkeeping follows the content decision.`]
702
+ };
703
+ const conflicts = [];
704
+ const advisories = [];
705
+ const merged = {};
706
+ const oursAt = typeof three.ours?.["updated_at"] === "string" ? three.ours["updated_at"] : void 0;
707
+ const theirsAt = typeof three.theirs?.["updated_at"] === "string" ? three.theirs["updated_at"] : void 0;
708
+ let byWinner = null;
709
+ if (oursAt !== void 0 || theirsAt !== void 0) {
710
+ if (oursAt === void 0) byWinner = "theirs";
711
+ else if (theirsAt === void 0) byWinner = "ours";
712
+ else byWinner = theirsAt > oursAt ? "theirs" : "ours";
713
+ const winnerAt = byWinner === "theirs" ? theirsAt : oursAt;
714
+ if (winnerAt !== void 0) merged["updated_at"] = winnerAt;
715
+ }
716
+ const fields = [...new Set([
717
+ ...Object.keys(three.base ?? {}),
718
+ ...Object.keys(three.ours ?? {}),
719
+ ...Object.keys(three.theirs ?? {})
720
+ ])].toSorted().filter((f) => f !== "updated_at");
721
+ for (const field of fields) {
722
+ const candidate = {
723
+ path: ctx.outPath,
724
+ key: entryKey,
725
+ field,
726
+ locale: ctx.locale,
727
+ base: three.base?.[field],
728
+ ours: three.ours?.[field],
729
+ theirs: three.theirs?.[field]
730
+ };
731
+ if (field === "updated_by" && byWinner !== null) {
732
+ const value = byWinner === "theirs" ? candidate.theirs ?? candidate.ours : candidate.ours ?? candidate.theirs;
733
+ if (value !== void 0) merged[field] = value;
734
+ continue;
735
+ }
736
+ const fieldLeaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
737
+ if (fieldLeaf.ok) {
738
+ if (fieldLeaf.value !== void 0) merged[field] = fieldLeaf.value;
739
+ continue;
740
+ }
741
+ if (field === "updated_by") {
742
+ if (candidate.ours !== void 0) merged[field] = candidate.ours;
743
+ advisories.push(`${entryKey ? `"${entryKey}": ` : ""}updated_by differed with no updated_at to arbitrate — kept ours.`);
744
+ continue;
745
+ }
746
+ const resolution = ctx.resolutions.consume(candidate);
747
+ if (resolution) {
748
+ const value = resolvedValue(resolution, candidate);
749
+ if (value !== void 0) merged[field] = value;
750
+ continue;
751
+ }
752
+ if (candidate.ours !== void 0) merged[field] = candidate.ours;
753
+ conflicts.push(makeConflict({
754
+ ...candidate,
755
+ kind: "meta",
756
+ model: ctx.model,
757
+ code: field === "status" ? "meta_status_conflict" : "field_value_conflict",
758
+ message: field === "status" ? `Publish status of ${entryKey ? `entry "${entryKey}"` : ctx.outPath} was changed differently on both sides.` : `Meta field "${field}" of ${entryKey ? `entry "${entryKey}"` : ctx.outPath} differs on both sides.`
759
+ }));
760
+ }
761
+ return {
762
+ merged,
763
+ conflicts,
764
+ advisories
765
+ };
766
+ }
767
+ /**
768
+ * A meta file: keyed by entry ID for collections, a single record
769
+ * otherwise. Dispatch comes from the winning model definition — shapes are
770
+ * never sniffed, because a dictionary meta record is indistinguishable
771
+ * from a small collection map.
772
+ */
773
+ function mergeMetaFile(three, ctx, perEntry) {
774
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
775
+ if (fileLeaf.ok) return {
776
+ merged: fileLeaf.value,
777
+ conflicts: [],
778
+ stats: statsFor(fileLeaf.from),
779
+ advisories: []
780
+ };
781
+ if (!perEntry) {
782
+ const single = mergeEntryMeta3(three, ctx);
783
+ return {
784
+ merged: single.merged,
785
+ conflicts: single.conflicts,
786
+ stats: EMPTY_STATS,
787
+ advisories: single.advisories
788
+ };
789
+ }
790
+ const advisories = [];
791
+ const result = mergeKeyedRecord3(three, (entryId, entry) => {
792
+ const entryLeaf = mergeLeaf3(entry.base, entry.ours, entry.theirs);
793
+ if (entryLeaf.ok) return {
794
+ value: entryLeaf.value,
795
+ conflicts: [],
796
+ stats: statsFor(entryLeaf.from)
797
+ };
798
+ const entryMerge = mergeEntryMeta3(entry, ctx, entryId);
799
+ advisories.push(...entryMerge.advisories);
800
+ return {
801
+ value: entryMerge.merged,
802
+ conflicts: entryMerge.conflicts,
803
+ stats: EMPTY_STATS
804
+ };
805
+ });
806
+ return {
807
+ merged: result.merged,
808
+ conflicts: result.conflicts,
809
+ stats: result.stats,
810
+ advisories
811
+ };
812
+ }
813
+ //#endregion
814
+ //#region src/core/ops/reconcile/merge-model.ts
815
+ /**
816
+ * Schema keys that decide where a model's files live and how they are
817
+ * keyed. When one of THESE is itself in conflict, the content of the model
818
+ * cannot be located or merged safely — the orchestrator blocks the model's
819
+ * content phase and surfaces only the model-level conflict.
820
+ */
821
+ const STRUCTURAL_MODEL_KEYS = [
822
+ "kind",
823
+ "i18n",
824
+ "locale_strategy",
825
+ "content_path"
826
+ ];
827
+ /**
828
+ * Model definition three-way: top-level keys are leaves, except `fields`,
829
+ * which recurses one level so two sides adding different fields both win.
830
+ * A key changed differently on both sides is a conflict with
831
+ * `suggested: 'theirs'` — the policy table's "the schema belongs to the
832
+ * developer" carried as a suggestion, never auto-applied.
833
+ */
834
+ function mergeModelFile(three, ctx) {
835
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
836
+ if (fileLeaf.ok) return {
837
+ merged: fileLeaf.value,
838
+ conflicts: [],
839
+ stats: statsFor(fileLeaf.from),
840
+ structurallyBlocked: false
841
+ };
842
+ if (fileLeaf.reason === "delete_edit") return {
843
+ merged: three.ours,
844
+ conflicts: [],
845
+ stats: EMPTY_STATS,
846
+ deleteEdit: { deletedBy: fileLeaf.deletedBy },
847
+ structurallyBlocked: false
848
+ };
849
+ const conflicts = [];
850
+ const merged = {};
851
+ const keys = [...new Set([
852
+ ...Object.keys(three.base ?? {}),
853
+ ...Object.keys(three.ours ?? {}),
854
+ ...Object.keys(three.theirs ?? {})
855
+ ])].toSorted();
856
+ for (const key of keys) {
857
+ if (key === "fields") continue;
858
+ const candidate = {
859
+ path: ctx.outPath,
860
+ key,
861
+ base: three.base?.[key],
862
+ ours: three.ours?.[key],
863
+ theirs: three.theirs?.[key]
864
+ };
865
+ const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
866
+ if (leaf.ok) {
867
+ if (leaf.value !== void 0) merged[key] = leaf.value;
868
+ continue;
869
+ }
870
+ const resolution = ctx.resolutions.consume(candidate);
871
+ if (resolution) {
872
+ const value = resolvedValue(resolution, candidate);
873
+ if (value !== void 0) merged[key] = value;
874
+ continue;
875
+ }
876
+ if (candidate.ours !== void 0) merged[key] = candidate.ours;
877
+ conflicts.push(makeConflict({
878
+ ...candidate,
879
+ kind: "model",
880
+ model: ctx.model,
881
+ code: "model_key_conflict",
882
+ message: `Model "${ctx.model}": schema key "${key}" was changed differently on both sides.`,
883
+ suggested: "theirs"
884
+ }));
885
+ }
886
+ const fieldNames = [...new Set([
887
+ ...Object.keys(three.base?.["fields"] ?? {}),
888
+ ...Object.keys(three.ours?.["fields"] ?? {}),
889
+ ...Object.keys(three.theirs?.["fields"] ?? {})
890
+ ])].toSorted();
891
+ if (fieldNames.length > 0) {
892
+ const mergedFields = {};
893
+ for (const name of fieldNames) {
894
+ const candidate = {
895
+ path: ctx.outPath,
896
+ key: `fields.${name}`,
897
+ base: (three.base?.["fields"])?.[name],
898
+ ours: (three.ours?.["fields"])?.[name],
899
+ theirs: (three.theirs?.["fields"])?.[name]
900
+ };
901
+ const leaf = mergeLeaf3(candidate.base, candidate.ours, candidate.theirs);
902
+ if (leaf.ok) {
903
+ if (leaf.value !== void 0) mergedFields[name] = leaf.value;
904
+ continue;
905
+ }
906
+ const resolution = ctx.resolutions.consume(candidate);
907
+ if (resolution) {
908
+ const value = resolvedValue(resolution, candidate);
909
+ if (value !== void 0) mergedFields[name] = value;
910
+ continue;
911
+ }
912
+ if (candidate.ours !== void 0) mergedFields[name] = candidate.ours;
913
+ conflicts.push(makeConflict({
914
+ ...candidate,
915
+ kind: "model",
916
+ model: ctx.model,
917
+ code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "model_key_conflict",
918
+ message: `Model "${ctx.model}": field "${name}" was changed differently on both sides.`,
919
+ suggested: "theirs"
920
+ }));
921
+ }
922
+ if (Object.keys(mergedFields).length > 0) merged["fields"] = mergedFields;
923
+ }
924
+ const structurallyBlocked = conflicts.some((c) => STRUCTURAL_MODEL_KEYS.includes(c.key ?? ""));
925
+ return {
926
+ merged,
927
+ conflicts,
928
+ stats: conflicts.length === 0 ? {
929
+ takenOurs: 0,
930
+ takenTheirs: 0,
931
+ fieldMerged: 1
932
+ } : EMPTY_STATS,
933
+ structurallyBlocked
934
+ };
935
+ }
936
+ //#endregion
937
+ //#region src/core/ops/reconcile/merge-vocabulary.ts
938
+ /**
939
+ * Vocabulary three-way at TERM + LOCALE granularity — finer than the brief
940
+ * asked for, and the reason the collabers scenario resolves with zero
941
+ * human input: one side adding a term's Turkish while the other fixes its
942
+ * English are different leaves, both win. Only the same term in the same
943
+ * locale with two different translations is a question.
944
+ *
945
+ * Matches the inline save semantics (`planVocabularySave` merges
946
+ * per-locale) — one policy family, two doors.
947
+ */
948
+ function mergeVocabulary(three, ctx) {
949
+ const fileLeaf = mergeLeaf3(three.base, three.ours, three.theirs);
950
+ if (fileLeaf.ok) return {
951
+ merged: fileLeaf.value,
952
+ conflicts: [],
953
+ stats: statsFor(fileLeaf.from)
954
+ };
955
+ const version = Math.max(three.base?.version ?? 1, three.ours?.version ?? 1, three.theirs?.version ?? 1);
956
+ const termResult = mergeKeyedRecord3({
957
+ base: three.base?.terms,
958
+ ours: three.ours?.terms,
959
+ theirs: three.theirs?.terms
960
+ }, (term, item) => {
961
+ const termLeaf = mergeLeaf3(item.base, item.ours, item.theirs);
962
+ if (termLeaf.ok) return {
963
+ value: termLeaf.value,
964
+ conflicts: [],
965
+ stats: statsFor(termLeaf.from)
966
+ };
967
+ if (termLeaf.reason === "delete_edit") {
968
+ const candidate = {
969
+ path: ctx.outPath,
970
+ key: term,
971
+ base: item.base,
972
+ ours: item.ours,
973
+ theirs: item.theirs
974
+ };
975
+ const resolution = ctx.resolutions.consume(candidate);
976
+ if (resolution) return {
977
+ value: resolvedValue(resolution, candidate),
978
+ conflicts: [],
979
+ stats: EMPTY_STATS
980
+ };
981
+ return {
982
+ value: item.ours,
983
+ conflicts: [makeConflict({
984
+ ...candidate,
985
+ kind: "vocabulary",
986
+ code: "delete_edit_conflict",
987
+ message: `Vocabulary term "${term}" was deleted on one side and edited on the other.`
988
+ })],
989
+ stats: EMPTY_STATS
990
+ };
991
+ }
992
+ const inner = mergeKeyedRecord3(item, (locale, translation) => {
993
+ const leaf = mergeLeaf3(translation.base, translation.ours, translation.theirs);
994
+ if (leaf.ok) return {
995
+ value: leaf.value,
996
+ conflicts: [],
997
+ stats: statsFor(leaf.from)
998
+ };
999
+ const candidate = {
1000
+ path: ctx.outPath,
1001
+ key: term,
1002
+ locale,
1003
+ base: translation.base,
1004
+ ours: translation.ours,
1005
+ theirs: translation.theirs
1006
+ };
1007
+ const resolution = ctx.resolutions.consume(candidate);
1008
+ if (resolution) return {
1009
+ value: resolvedValue(resolution, candidate),
1010
+ conflicts: [],
1011
+ stats: EMPTY_STATS
1012
+ };
1013
+ return {
1014
+ value: translation.ours,
1015
+ conflicts: [makeConflict({
1016
+ ...candidate,
1017
+ kind: "vocabulary",
1018
+ code: leaf.reason === "delete_edit" ? "delete_edit_conflict" : "vocabulary_value_conflict",
1019
+ message: `Term "${term}" has two different "${locale}" translations.`
1020
+ })],
1021
+ stats: EMPTY_STATS
1022
+ };
1023
+ });
1024
+ return {
1025
+ value: Object.keys(inner.merged).length > 0 ? inner.merged : void 0,
1026
+ conflicts: inner.conflicts,
1027
+ stats: inner.conflicts.length === 0 ? addStats(inner.stats, {
1028
+ takenOurs: 0,
1029
+ takenTheirs: 0,
1030
+ fieldMerged: 1
1031
+ }) : inner.stats
1032
+ };
1033
+ });
1034
+ return {
1035
+ merged: {
1036
+ version,
1037
+ terms: termResult.merged
1038
+ },
1039
+ conflicts: termResult.conflicts,
1040
+ stats: termResult.stats
1041
+ };
1042
+ }
1043
+ //#endregion
1044
+ //#region src/core/ops/reconcile/walk.ts
1045
+ const SIDES = [
1046
+ "base",
1047
+ "ours",
1048
+ "theirs"
1049
+ ];
1050
+ /** Union of model ids across the three trees, sorted. */
1051
+ async function listModelIds(readers) {
1052
+ const listings = await Promise.all(SIDES.map((side) => readers[side].listDirectory(".contentrain/models")));
1053
+ const ids = /* @__PURE__ */ new Set();
1054
+ for (const listing of listings) for (const entry of listing) if (entry.endsWith(".json")) ids.add(entry.slice(0, -5));
1055
+ return [...ids].toSorted();
1056
+ }
1057
+ /**
1058
+ * Enumerate a model's content files on one side, using THAT side's model
1059
+ * definition — `content_path` and `locale_strategy` may differ between
1060
+ * sides, so each side's files are found where that side actually keeps
1061
+ * them. Filenames are mapped back to (locale, slug) by inverting the
1062
+ * `paths.ts` layout for the declared strategy; nothing is guessed from
1063
+ * config.
1064
+ */
1065
+ async function enumerateSideUnits(reader, def, defaultLocale) {
1066
+ const dir = contentDirPath(def);
1067
+ const strategy = def.locale_strategy ?? "file";
1068
+ const units = [];
1069
+ if (def.kind === "document") {
1070
+ if (!def.i18n || strategy === "none") {
1071
+ const entries = await reader.listDirectory(dir);
1072
+ for (const entry of entries) if (entry.endsWith(".md")) units.push({
1073
+ locale: defaultLocale,
1074
+ slug: entry.slice(0, -3),
1075
+ path: `${dir}/${entry}`
1076
+ });
1077
+ return units;
1078
+ }
1079
+ if (strategy === "suffix") {
1080
+ const entries = await reader.listDirectory(dir);
1081
+ for (const entry of entries) {
1082
+ const match = /^(.+)\.([a-z]{2}(?:-[A-Za-z]{2})?)\.md$/.exec(entry);
1083
+ if (match) units.push({
1084
+ slug: match[1],
1085
+ locale: match[2],
1086
+ path: `${dir}/${entry}`
1087
+ });
1088
+ }
1089
+ return units;
1090
+ }
1091
+ if (strategy === "directory") {
1092
+ const locales = await reader.listDirectory(dir);
1093
+ const listings = await Promise.all(locales.map(async (locale) => ({
1094
+ locale,
1095
+ entries: locale.includes(".") ? [] : await reader.listDirectory(`${dir}/${locale}`)
1096
+ })));
1097
+ for (const { locale, entries } of listings) for (const entry of entries) if (entry.endsWith(".md")) units.push({
1098
+ locale,
1099
+ slug: entry.slice(0, -3),
1100
+ path: `${dir}/${locale}/${entry}`
1101
+ });
1102
+ return units;
1103
+ }
1104
+ const slugs = await reader.listDirectory(dir);
1105
+ const listings = await Promise.all(slugs.map(async (slug) => ({
1106
+ slug,
1107
+ entries: slug.includes(".") ? [] : await reader.listDirectory(`${dir}/${slug}`)
1108
+ })));
1109
+ for (const { slug, entries } of listings) for (const entry of entries) if (entry.endsWith(".md")) units.push({
1110
+ slug,
1111
+ locale: entry.slice(0, -3),
1112
+ path: `${dir}/${slug}/${entry}`
1113
+ });
1114
+ return units;
1115
+ }
1116
+ if (!def.i18n) {
1117
+ if (await reader.fileExists(`${dir}/data.json`)) units.push({
1118
+ locale: defaultLocale,
1119
+ path: `${dir}/data.json`
1120
+ });
1121
+ return units;
1122
+ }
1123
+ if (strategy === "none") {
1124
+ if (await reader.fileExists(`${dir}/${def.id}.json`)) units.push({
1125
+ locale: defaultLocale,
1126
+ path: `${dir}/${def.id}.json`
1127
+ });
1128
+ return units;
1129
+ }
1130
+ if (strategy === "suffix") {
1131
+ const entries = await reader.listDirectory(dir);
1132
+ const prefix = `${def.id}.`;
1133
+ for (const entry of entries) if (entry.startsWith(prefix) && entry.endsWith(".json")) {
1134
+ const locale = entry.slice(prefix.length, -5);
1135
+ if (locale && !locale.includes(".")) units.push({
1136
+ locale,
1137
+ path: `${dir}/${entry}`
1138
+ });
1139
+ }
1140
+ return units;
1141
+ }
1142
+ if (strategy === "directory") {
1143
+ const locales = await reader.listDirectory(dir);
1144
+ const checks = await Promise.all(locales.map(async (locale) => ({
1145
+ locale,
1146
+ exists: locale.includes(".") ? false : await reader.fileExists(`${dir}/${locale}/${def.id}.json`)
1147
+ })));
1148
+ for (const { locale, exists } of checks) if (exists) units.push({
1149
+ locale,
1150
+ path: `${dir}/${locale}/${def.id}.json`
1151
+ });
1152
+ return units;
1153
+ }
1154
+ const entries = await reader.listDirectory(dir);
1155
+ for (const entry of entries) if (entry.endsWith(".json") && entry !== "data.json") units.push({
1156
+ locale: entry.slice(0, -5),
1157
+ path: `${dir}/${entry}`
1158
+ });
1159
+ return units;
1160
+ }
1161
+ /** Resolve the output path of a logical unit under the WINNING definition. */
1162
+ function unitOutPath(winning, defaultLocale, locale, slug) {
1163
+ if (winning.kind === "document") return documentFilePath(winning, locale ?? defaultLocale, slug ?? "");
1164
+ return contentFilePath(winning, locale ?? defaultLocale);
1165
+ }
1166
+ /**
1167
+ * Enumerate a model's meta files on one side. The meta layout is fixed
1168
+ * (`.contentrain/meta/{id}/[{slug}/]{locale}.json`) regardless of
1169
+ * `content_path` or locale strategy, so this stays definition-light and
1170
+ * works even while the definitions disagree.
1171
+ */
1172
+ async function enumerateMetaUnits(reader, modelId) {
1173
+ const dir = `.contentrain/meta/${modelId}`;
1174
+ const entries = await reader.listDirectory(dir);
1175
+ const units = [];
1176
+ const slugDirs = [];
1177
+ for (const entry of entries) if (entry.endsWith(".json")) units.push({
1178
+ locale: entry.slice(0, -5),
1179
+ path: `${dir}/${entry}`
1180
+ });
1181
+ else if (!entry.includes(".")) slugDirs.push(entry);
1182
+ const nested = await Promise.all(slugDirs.map(async (slug) => ({
1183
+ slug,
1184
+ entries: await reader.listDirectory(`${dir}/${slug}`)
1185
+ })));
1186
+ for (const { slug, entries: files } of nested) for (const file of files) if (file.endsWith(".json")) units.push({
1187
+ locale: file.slice(0, -5),
1188
+ slug,
1189
+ path: `${dir}/${slug}/${file}`
1190
+ });
1191
+ return units;
1192
+ }
1193
+ const SCAN_MAX_DEPTH = 6;
1194
+ const SCAN_SKIP = new Set([".contentrain/client", ".contentrain/context.json"]);
1195
+ /**
1196
+ * Recursively list candidate text files (`.json` / `.md`) under `root` on
1197
+ * one side. The generated client and context.json are never candidates;
1198
+ * depth is bounded as a runaway guard. Anything that is not a candidate
1199
+ * extension is treated as a possible directory — listing a file yields an
1200
+ * empty array on every reader, so the probe is safe.
1201
+ */
1202
+ async function listTextFiles(reader, root, depth = 0) {
1203
+ if (depth > SCAN_MAX_DEPTH) return [];
1204
+ const entries = await reader.listDirectory(root);
1205
+ return (await Promise.all(entries.map(async (entry) => {
1206
+ const path = root === "" ? entry : `${root}/${entry}`;
1207
+ if (SCAN_SKIP.has(path)) return [];
1208
+ if (entry.endsWith(".json") || entry.endsWith(".md")) return [path];
1209
+ return listTextFiles(reader, path, depth + 1);
1210
+ }))).flat();
1211
+ }
1212
+ //#endregion
1213
+ //#region src/core/ops/reconcile/plan.ts
1214
+ const CONTEXT_PATH = ".contentrain/context.json";
1215
+ const VOCABULARY_PATH = ".contentrain/vocabulary.json";
1216
+ const SOURCES_PATH = ".contentrain/normalize-sources.json";
1217
+ const MAX_VALIDATION_ADVISORIES = 15;
1218
+ /**
1219
+ * Content-aware three-way merge of `.contentrain/**` (plus `content_path`
1220
+ * roots) between a merge-base, the content branch (ours) and the base
1221
+ * branch (theirs). Pure planning: reads through the three bound readers,
1222
+ * writes nothing, and returns `FileChange[]` to apply ON TOP OF OURS,
1223
+ * `ConflictItem[]` for whatever the policy table cannot decide, and a
1224
+ * regenerated context.json whenever the plan is not a no-op.
1225
+ *
1226
+ * Determinism: the same three trees with the same resolutions produce the
1227
+ * same changes and the same conflict ids — context.json's embedded
1228
+ * timestamp is the sole documented exception.
1229
+ */
1230
+ async function planReconcile(input) {
1231
+ const readers = {
1232
+ base: new CachedReader(input.base),
1233
+ ours: new CachedReader(input.ours),
1234
+ theirs: new CachedReader(input.theirs)
1235
+ };
1236
+ const resolutions = new ResolutionIndex(input.resolutions ?? []);
1237
+ const conflicts = [];
1238
+ const advisories = [];
1239
+ const outputs = [];
1240
+ const claimed = /* @__PURE__ */ new Set();
1241
+ let stats = EMPTY_STATS;
1242
+ const defaultLocale = (await readJsonOrNull(readers.ours, ".contentrain/config.json"))?.locales?.default ?? "en";
1243
+ const modelIds = await listModelIds(readers);
1244
+ const modelStates = (await Promise.all(modelIds.map(async (id) => {
1245
+ const path = `.contentrain/models/${id}.json`;
1246
+ const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], path)));
1247
+ return {
1248
+ id,
1249
+ path,
1250
+ three: {
1251
+ base: base ?? void 0,
1252
+ ours: ours ?? void 0,
1253
+ theirs: theirs ?? void 0
1254
+ }
1255
+ };
1256
+ }))).map((m) => {
1257
+ claimed.add(m.path);
1258
+ const result = mergeModelFile(m.three, {
1259
+ outPath: m.path,
1260
+ kind: "model",
1261
+ model: m.id,
1262
+ resolutions
1263
+ });
1264
+ return {
1265
+ id: m.id,
1266
+ path: m.path,
1267
+ three: m.three,
1268
+ result
1269
+ };
1270
+ });
1271
+ await Promise.all(modelStates.map(async (state) => {
1272
+ const { id, path, three, result } = state;
1273
+ const oursAbsent = three.ours === void 0;
1274
+ const theirsAbsent = three.theirs === void 0;
1275
+ const oneSideDeleted = three.base !== void 0 && oursAbsent !== theirsAbsent;
1276
+ if (result.deleteEdit || result.merged === void 0 && oneSideDeleted) {
1277
+ const deletedBy = result.deleteEdit?.deletedBy ?? (oursAbsent ? "ours" : "theirs");
1278
+ const survivor = deletedBy === "ours" ? "theirs" : "ours";
1279
+ await collapseModelDeletion(state.id, path, three, deletedBy, survivor, !!result.deleteEdit);
1280
+ return;
1281
+ }
1282
+ conflicts.push(...result.conflicts);
1283
+ stats = addStats(stats, result.stats);
1284
+ if (result.merged === void 0) {
1285
+ await claimModelFiles(id, three);
1286
+ return;
1287
+ }
1288
+ if (result.structurallyBlocked) {
1289
+ advisories.push(`Model "${id}": structural schema keys are in conflict — its content was left untouched until they are resolved.`);
1290
+ await claimModelFiles(id, three);
1291
+ return;
1292
+ }
1293
+ const winning = result.merged;
1294
+ if (typeof winning.kind !== "string" || typeof winning.id !== "string") {
1295
+ advisories.push(`Model "${id}": merged definition is structurally incomplete — content left untouched.`);
1296
+ await claimModelFiles(id, three);
1297
+ return;
1298
+ }
1299
+ outputs.push({
1300
+ path,
1301
+ format: "model",
1302
+ value: result.merged
1303
+ });
1304
+ const sideDefs = {};
1305
+ for (const side of SIDES) {
1306
+ const def = three[side];
1307
+ if (def && typeof def.kind === "string") sideDefs[side] = def;
1308
+ }
1309
+ const perSide = await Promise.all(SIDES.map(async (side) => {
1310
+ const def = sideDefs[side];
1311
+ return {
1312
+ side,
1313
+ units: def ? await enumerateSideUnits(readers[side], def, defaultLocale) : []
1314
+ };
1315
+ }));
1316
+ const unitMap = /* @__PURE__ */ new Map();
1317
+ for (const { side, units } of perSide) for (const unit of units) {
1318
+ claimed.add(unit.path);
1319
+ const key = unitKey(unit);
1320
+ const entry = unitMap.get(key) ?? {
1321
+ locale: unit.locale,
1322
+ slug: unit.slug,
1323
+ paths: {}
1324
+ };
1325
+ entry.paths[side] = unit.path;
1326
+ unitMap.set(key, entry);
1327
+ }
1328
+ await Promise.all([...unitMap.values()].map(async (unit) => {
1329
+ const [baseRaw, oursRaw, theirsRaw] = await Promise.all(SIDES.map((side) => unit.paths[side] ? readers[side].readOrNull(unit.paths[side]) : Promise.resolve(null)));
1330
+ const outPath = unitOutPath(winning, defaultLocale, unit.locale, unit.slug);
1331
+ claimed.add(outPath);
1332
+ const ctx = {
1333
+ outPath,
1334
+ kind: winning.kind,
1335
+ model: id,
1336
+ locale: unit.locale,
1337
+ resolutions
1338
+ };
1339
+ if (winning.kind === "document") {
1340
+ const merge = mergeDocumentFile({
1341
+ base: baseRaw ?? void 0,
1342
+ ours: oursRaw ?? void 0,
1343
+ theirs: theirsRaw ?? void 0
1344
+ }, {
1345
+ ...ctx,
1346
+ slug: unit.slug ?? ""
1347
+ });
1348
+ conflicts.push(...merge.conflicts);
1349
+ advisories.push(...merge.advisories);
1350
+ stats = addStats(stats, merge.stats);
1351
+ outputs.push({
1352
+ path: outPath,
1353
+ format: "md",
1354
+ value: merge.merged ?? null
1355
+ });
1356
+ } else {
1357
+ const parsed = {
1358
+ base: parseJson(baseRaw ?? null),
1359
+ ours: parseJson(oursRaw ?? null),
1360
+ theirs: parseJson(theirsRaw ?? null)
1361
+ };
1362
+ const merge = winning.kind === "dictionary" ? mergeDictionaryFile(parsed, ctx) : winning.kind === "singleton" ? mergeSingletonFile(parsed, ctx) : mergeCollectionFile(parsed, ctx);
1363
+ conflicts.push(...merge.conflicts);
1364
+ stats = addStats(stats, merge.stats);
1365
+ outputs.push({
1366
+ path: outPath,
1367
+ format: "json",
1368
+ value: merge.merged ?? null
1369
+ });
1370
+ }
1371
+ if (unit.paths.ours && unit.paths.ours !== outPath) outputs.push({
1372
+ path: unit.paths.ours,
1373
+ format: "raw",
1374
+ value: null
1375
+ });
1376
+ }));
1377
+ const metaPerSide = await Promise.all(SIDES.map((side) => enumerateMetaUnits(readers[side], id)));
1378
+ const metaMap = /* @__PURE__ */ new Map();
1379
+ for (const units of metaPerSide) for (const unit of units) {
1380
+ claimed.add(unit.path);
1381
+ metaMap.set(unit.path, unit);
1382
+ }
1383
+ await Promise.all([...metaMap.values()].map(async (unit) => {
1384
+ const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], unit.path)));
1385
+ const merge = mergeMetaFile({
1386
+ base: base ?? void 0,
1387
+ ours: ours ?? void 0,
1388
+ theirs: theirs ?? void 0
1389
+ }, {
1390
+ outPath: unit.path,
1391
+ kind: "meta",
1392
+ model: id,
1393
+ locale: unit.locale,
1394
+ resolutions
1395
+ }, winning.kind === "collection");
1396
+ conflicts.push(...merge.conflicts);
1397
+ advisories.push(...merge.advisories);
1398
+ stats = addStats(stats, merge.stats);
1399
+ outputs.push({
1400
+ path: unit.path,
1401
+ format: "json",
1402
+ value: merge.merged ?? null
1403
+ });
1404
+ }));
1405
+ }));
1406
+ claimed.add(VOCABULARY_PATH);
1407
+ claimed.add(SOURCES_PATH);
1408
+ {
1409
+ const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], VOCABULARY_PATH)));
1410
+ if (base !== null || ours !== null || theirs !== null) {
1411
+ const merge = mergeVocabulary({
1412
+ base: base ?? void 0,
1413
+ ours: ours ?? void 0,
1414
+ theirs: theirs ?? void 0
1415
+ }, {
1416
+ outPath: VOCABULARY_PATH,
1417
+ kind: "vocabulary",
1418
+ resolutions
1419
+ });
1420
+ conflicts.push(...merge.conflicts);
1421
+ stats = addStats(stats, merge.stats);
1422
+ outputs.push({
1423
+ path: VOCABULARY_PATH,
1424
+ format: "json",
1425
+ value: merge.merged ?? null
1426
+ });
1427
+ }
1428
+ }
1429
+ {
1430
+ const [base, ours, theirs] = await Promise.all(SIDES.map((side) => readJsonOrNull(readers[side], SOURCES_PATH)));
1431
+ if (base !== null || ours !== null || theirs !== null) {
1432
+ const merge = mergeKeyedJson({
1433
+ base: base ?? void 0,
1434
+ ours: ours ?? void 0,
1435
+ theirs: theirs ?? void 0
1436
+ }, {
1437
+ outPath: SOURCES_PATH,
1438
+ kind: "file",
1439
+ resolutions
1440
+ });
1441
+ conflicts.push(...merge.conflicts);
1442
+ stats = addStats(stats, merge.stats);
1443
+ outputs.push({
1444
+ path: SOURCES_PATH,
1445
+ format: "json",
1446
+ value: merge.merged ?? null
1447
+ });
1448
+ }
1449
+ }
1450
+ const roots = new Set([".contentrain"]);
1451
+ for (const state of modelStates) for (const side of SIDES) {
1452
+ const contentPath = state.three[side]?.content_path;
1453
+ if (typeof contentPath === "string" && contentPath.length > 0) roots.add(contentPath);
1454
+ }
1455
+ const scanned = await Promise.all([...roots].map(async (root) => Promise.all(SIDES.map((side) => listTextFiles(readers[side], root)))));
1456
+ const candidates = /* @__PURE__ */ new Set();
1457
+ for (const perRoot of scanned) for (const files of perRoot) for (const file of files) if (!claimed.has(file)) candidates.add(file);
1458
+ await Promise.all([...candidates].toSorted().map(async (path) => {
1459
+ const [baseRaw, oursRaw, theirsRaw] = await Promise.all(SIDES.map((side) => readers[side].readOrNull(path)));
1460
+ const leaf = mergeLeaf3(baseRaw ?? void 0, oursRaw ?? void 0, theirsRaw ?? void 0);
1461
+ if (leaf.ok) {
1462
+ if (leaf.from === "theirs") outputs.push({
1463
+ path,
1464
+ format: "raw",
1465
+ value: leaf.value ?? null
1466
+ });
1467
+ return;
1468
+ }
1469
+ const digestCandidate = {
1470
+ path,
1471
+ base: baseRaw == null ? void 0 : stableHash(baseRaw),
1472
+ ours: oursRaw == null ? void 0 : stableHash(oursRaw),
1473
+ theirs: theirsRaw == null ? void 0 : stableHash(theirsRaw)
1474
+ };
1475
+ const resolution = resolutions.consume(digestCandidate);
1476
+ if (resolution && "choose" in resolution) {
1477
+ const chosen = resolution.choose === "ours" ? oursRaw : theirsRaw;
1478
+ outputs.push({
1479
+ path,
1480
+ format: "raw",
1481
+ value: chosen
1482
+ });
1483
+ return;
1484
+ }
1485
+ if (resolution) advisories.push(`Resolution for "${path}" supplied a value — file-level conflicts accept only { choose } and the resolution was ignored.`);
1486
+ conflicts.push({
1487
+ id: conflictId(digestCandidate),
1488
+ path,
1489
+ kind: "file",
1490
+ code: "file_conflict",
1491
+ message: `${path} was changed on both sides and is not content this planner understands — choose a side.`
1492
+ });
1493
+ }));
1494
+ const changes = [];
1495
+ await Promise.all(outputs.map(async (out) => {
1496
+ const oursRaw = await readers.ours.readOrNull(out.path);
1497
+ if (out.value === null) {
1498
+ if (oursRaw !== null) changes.push({
1499
+ path: out.path,
1500
+ content: null
1501
+ });
1502
+ return;
1503
+ }
1504
+ if (out.format === "md" || out.format === "raw") {
1505
+ const next = out.value;
1506
+ if (oursRaw === null) {
1507
+ changes.push({
1508
+ path: out.path,
1509
+ content: next
1510
+ });
1511
+ return;
1512
+ }
1513
+ if (out.format === "raw") {
1514
+ if (oursRaw !== next) changes.push({
1515
+ path: out.path,
1516
+ content: next
1517
+ });
1518
+ return;
1519
+ }
1520
+ const a = parseMarkdownFrontmatter(oursRaw);
1521
+ const b = parseMarkdownFrontmatter(next);
1522
+ if (!eqCanonical(a.frontmatter, b.frontmatter) || a.body !== b.body) changes.push({
1523
+ path: out.path,
1524
+ content: next
1525
+ });
1526
+ return;
1527
+ }
1528
+ const oursParsed = parseJson(oursRaw);
1529
+ if (eqCanonical(out.value, oursParsed)) return;
1530
+ const fieldOrder = out.format === "model" ? MODEL_FIELD_ORDER : void 0;
1531
+ changes.push({
1532
+ path: out.path,
1533
+ content: canonicalStringify(out.value, fieldOrder)
1534
+ });
1535
+ }));
1536
+ const filesMerged = changes.length;
1537
+ const regenerated = [];
1538
+ if (changes.length > 0) try {
1539
+ const contextChange = await buildContextChange(new OverlayReader(readers.ours, changes), {
1540
+ tool: "contentrain_reconcile",
1541
+ model: "*"
1542
+ }, input.source);
1543
+ changes.push(contextChange);
1544
+ regenerated.push(CONTEXT_PATH);
1545
+ } catch {
1546
+ advisories.push("context.json could not be regenerated — run contentrain_validate after applying.");
1547
+ }
1548
+ if (changes.length > 0) try {
1549
+ const issues = (await validateProject(new OverlayReader(readers.ours, changes), {})).issues.filter((i) => i.severity !== "notice");
1550
+ const shown = issues.slice(0, MAX_VALIDATION_ADVISORIES);
1551
+ for (const issue of shown) advisories.push(`validation (${issue.severity}): ${issue.model ?? ""}${issue.field ? `.${issue.field}` : ""} — ${issue.message}`);
1552
+ if (issues.length > shown.length) advisories.push(`validation: ${issues.length - shown.length} more issue(s) — run contentrain_validate for the full report.`);
1553
+ } catch {
1554
+ advisories.push("Post-merge validation could not run — validate after applying.");
1555
+ }
1556
+ for (const stale of resolutions.unconsumed()) advisories.push(`Resolution ${stale.id} no longer matches any conflict — values changed since the dry-run; the conflict is re-reported with a new id.`);
1557
+ if (changes.length === 0 && conflicts.length === 0) advisories.push("Trees are already reconciled — nothing to merge.");
1558
+ changes.sort((a, b) => a.path.localeCompare(b.path, "en"));
1559
+ conflicts.sort((a, b) => a.path.localeCompare(b.path, "en") || (a.key ?? "").localeCompare(b.key ?? "", "en") || (a.field ?? "").localeCompare(b.field ?? "", "en") || (a.locale ?? "").localeCompare(b.locale ?? "", "en"));
1560
+ return {
1561
+ changes,
1562
+ conflicts,
1563
+ advisories,
1564
+ result: {
1565
+ files_merged: filesMerged,
1566
+ entries_taken_ours: stats.takenOurs,
1567
+ entries_taken_theirs: stats.takenTheirs,
1568
+ entries_field_merged: stats.fieldMerged,
1569
+ regenerated
1570
+ }
1571
+ };
1572
+ /** Claim every side's files of a model so the scan never double-reports. */
1573
+ async function claimModelFiles(id, three) {
1574
+ await Promise.all(SIDES.map(async (side) => {
1575
+ const def = three[side];
1576
+ if (def && typeof def.kind === "string") {
1577
+ const units = await enumerateSideUnits(readers[side], def, defaultLocale);
1578
+ for (const unit of units) claimed.add(unit.path);
1579
+ }
1580
+ const meta = await enumerateMetaUnits(readers[side], id);
1581
+ for (const unit of meta) claimed.add(unit.path);
1582
+ }));
1583
+ }
1584
+ /**
1585
+ * One side deleted the model. If the surviving side left everything
1586
+ * untouched, the deletion cascades mechanically over content and meta.
1587
+ * The moment the survivor edited anything — the definition or one entry —
1588
+ * the whole model collapses into a SINGLE delete-vs-edit question
1589
+ * instead of one conflict per file.
1590
+ */
1591
+ async function collapseModelDeletion(id, modelPath, three, deletedBy, survivor, modelFileEdited) {
1592
+ await claimModelFiles(id, three);
1593
+ const survivorDef = three[survivor];
1594
+ const survivorFiles = await modelFileDigestList(survivor, id, survivorDef);
1595
+ const baseFiles = await modelFileDigestList("base", id, three.base);
1596
+ const contentEdited = JSON.stringify(survivorFiles) !== JSON.stringify(baseFiles);
1597
+ if (!modelFileEdited && !contentEdited) {
1598
+ await deleteModelEverywhere(id, three);
1599
+ return;
1600
+ }
1601
+ const digestFor = async (side) => {
1602
+ const def = three[side];
1603
+ if (!def && side !== "base") return void 0;
1604
+ const files = await modelFileDigestList(side, id, def);
1605
+ return {
1606
+ model: three[side] ? stableHash(JSON.stringify(three[side])) : null,
1607
+ files: stableHash(JSON.stringify(files))
1608
+ };
1609
+ };
1610
+ const [baseDigest, oursDigest, theirsDigest] = await Promise.all([
1611
+ digestFor("base"),
1612
+ digestFor("ours"),
1613
+ digestFor("theirs")
1614
+ ]);
1615
+ const candidate = {
1616
+ path: modelPath,
1617
+ base: baseDigest,
1618
+ ours: oursDigest,
1619
+ theirs: theirsDigest
1620
+ };
1621
+ const resolution = resolutions.consume(candidate);
1622
+ if (resolution && "choose" in resolution) {
1623
+ if (resolution.choose === deletedBy) await deleteModelEverywhere(id, three);
1624
+ else await keepModelWholesale(id, modelPath, three, survivor);
1625
+ return;
1626
+ }
1627
+ if (resolution) advisories.push(`Resolution for model "${id}" supplied a value — model delete-vs-edit accepts only { choose } and the resolution was ignored.`);
1628
+ conflicts.push(makeConflict({
1629
+ ...candidate,
1630
+ kind: "model",
1631
+ model: id,
1632
+ code: "delete_edit_conflict",
1633
+ message: `Model "${id}" was deleted on one side (${deletedBy}) while the other side edited its ${modelFileEdited ? "definition" : "content"} — keep the model or drop it.`
1634
+ }));
1635
+ }
1636
+ /** Digest list of one side's model-owned files (content + meta), sorted. */
1637
+ async function modelFileDigestList(side, id, def) {
1638
+ const units = def && typeof def.kind === "string" ? await enumerateSideUnits(readers[side], def, defaultLocale) : [];
1639
+ const meta = await enumerateMetaUnits(readers[side], id);
1640
+ const paths = [...units.map((u) => u.path), ...meta.map((u) => u.path)].toSorted();
1641
+ const raws = await Promise.all(paths.map((path) => readers[side].readOrNull(path)));
1642
+ return paths.map((path, i) => [path, stableHash(raws[i] ?? "")]);
1643
+ }
1644
+ /** Emit deletions for every model-owned file ours still carries. */
1645
+ async function deleteModelEverywhere(id, three) {
1646
+ outputs.push({
1647
+ path: `.contentrain/models/${id}.json`,
1648
+ format: "raw",
1649
+ value: null
1650
+ });
1651
+ const oursDef = three.ours;
1652
+ const units = oursDef && typeof oursDef.kind === "string" ? await enumerateSideUnits(readers.ours, oursDef, defaultLocale) : [];
1653
+ const meta = await enumerateMetaUnits(readers.ours, id);
1654
+ for (const unit of [...units.map((u) => u.path), ...meta.map((u) => u.path)]) outputs.push({
1655
+ path: unit,
1656
+ format: "raw",
1657
+ value: null
1658
+ });
1659
+ }
1660
+ /** Take the surviving side wholesale: definition, content, meta. */
1661
+ async function keepModelWholesale(id, modelPath, three, survivor) {
1662
+ outputs.push({
1663
+ path: modelPath,
1664
+ format: "model",
1665
+ value: three[survivor]
1666
+ });
1667
+ const survivorDef = three[survivor];
1668
+ const units = survivorDef && typeof survivorDef.kind === "string" ? await enumerateSideUnits(readers[survivor], survivorDef, defaultLocale) : [];
1669
+ const meta = await enumerateMetaUnits(readers[survivor], id);
1670
+ const survivorPaths = [...units.map((u) => u.path), ...meta.map((u) => u.path)];
1671
+ const raws = await Promise.all(survivorPaths.map((path) => readers[survivor].readOrNull(path)));
1672
+ survivorPaths.forEach((path, i) => {
1673
+ outputs.push({
1674
+ path,
1675
+ format: "raw",
1676
+ value: raws[i]
1677
+ });
1678
+ });
1679
+ const oursDef = three.ours;
1680
+ const oursUnits = oursDef && typeof oursDef.kind === "string" ? await enumerateSideUnits(readers.ours, oursDef, defaultLocale) : [];
1681
+ const oursMeta = await enumerateMetaUnits(readers.ours, id);
1682
+ const survivorSet = new Set(survivorPaths);
1683
+ for (const path of [...oursUnits.map((u) => u.path), ...oursMeta.map((u) => u.path)]) if (!survivorSet.has(path)) outputs.push({
1684
+ path,
1685
+ format: "raw",
1686
+ value: null
1687
+ });
1688
+ }
1689
+ }
1690
+ function unitKey(u) {
1691
+ return `${u.locale ?? ""}::${u.slug ?? ""}`;
1692
+ }
1693
+ function parseJson(raw) {
1694
+ if (raw === null) return void 0;
1695
+ try {
1696
+ return JSON.parse(raw);
1697
+ } catch {
1698
+ return;
1699
+ }
1700
+ }
1701
+ //#endregion
1702
+ export { applyChangesToWorktree as n, planReconcile as t };
1703
+
1704
+ //# sourceMappingURL=reconcile-D5FTVW5z.mjs.map