@exla/rudder 0.0.0-stage → 3.4.1

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 (187) hide show
  1. package/LICENSE +100 -0
  2. package/README.md +733 -2
  3. package/assets/rudder.icns +0 -0
  4. package/assets/sounds/NOTICE.md +8 -0
  5. package/assets/sounds/ping.mp3 +0 -0
  6. package/dist/agent-attention.d.ts +5 -0
  7. package/dist/agent-attention.js +54 -0
  8. package/dist/agent-attention.js.map +1 -0
  9. package/dist/analytics.d.ts +36 -0
  10. package/dist/analytics.js +178 -0
  11. package/dist/analytics.js.map +1 -0
  12. package/dist/attach-session.d.ts +91 -0
  13. package/dist/attach-session.js +290 -0
  14. package/dist/attach-session.js.map +1 -0
  15. package/dist/auth.d.ts +36 -0
  16. package/dist/auth.js +397 -0
  17. package/dist/auth.js.map +1 -0
  18. package/dist/backends.d.ts +3 -0
  19. package/dist/backends.js +638 -0
  20. package/dist/backends.js.map +1 -0
  21. package/dist/board/board.css +1 -0
  22. package/dist/board/board.js +2 -0
  23. package/dist/board/daemon.d.ts +39 -0
  24. package/dist/board/daemon.js +1510 -0
  25. package/dist/board/daemon.js.map +1 -0
  26. package/dist/brain.d.ts +24 -0
  27. package/dist/brain.js +244 -0
  28. package/dist/brain.js.map +1 -0
  29. package/dist/bus.d.ts +9 -0
  30. package/dist/bus.js +23 -0
  31. package/dist/bus.js.map +1 -0
  32. package/dist/checkout-sync.d.ts +35 -0
  33. package/dist/checkout-sync.js +247 -0
  34. package/dist/checkout-sync.js.map +1 -0
  35. package/dist/cloud.d.ts +51 -0
  36. package/dist/cloud.js +4672 -0
  37. package/dist/cloud.js.map +1 -0
  38. package/dist/codex-binary.d.ts +12 -0
  39. package/dist/codex-binary.js +184 -0
  40. package/dist/codex-binary.js.map +1 -0
  41. package/dist/context-audit.d.ts +21 -0
  42. package/dist/context-audit.js +109 -0
  43. package/dist/context-audit.js.map +1 -0
  44. package/dist/daemon.d.ts +27 -0
  45. package/dist/daemon.js +203 -0
  46. package/dist/daemon.js.map +1 -0
  47. package/dist/effort.d.ts +9 -0
  48. package/dist/effort.js +72 -0
  49. package/dist/effort.js.map +1 -0
  50. package/dist/feedback.d.ts +46 -0
  51. package/dist/feedback.js +173 -0
  52. package/dist/feedback.js.map +1 -0
  53. package/dist/gc.d.ts +3 -0
  54. package/dist/gc.js +262 -0
  55. package/dist/gc.js.map +1 -0
  56. package/dist/git.d.ts +31 -0
  57. package/dist/git.js +455 -0
  58. package/dist/git.js.map +1 -0
  59. package/dist/goal.d.ts +33 -0
  60. package/dist/goal.js +95 -0
  61. package/dist/goal.js.map +1 -0
  62. package/dist/graph.d.ts +93 -0
  63. package/dist/graph.js +372 -0
  64. package/dist/graph.js.map +1 -0
  65. package/dist/handoff.d.ts +110 -0
  66. package/dist/handoff.js +565 -0
  67. package/dist/handoff.js.map +1 -0
  68. package/dist/improve/advisor.d.ts +10 -0
  69. package/dist/improve/advisor.js +115 -0
  70. package/dist/improve/advisor.js.map +1 -0
  71. package/dist/improve/collect.d.ts +49 -0
  72. package/dist/improve/collect.js +274 -0
  73. package/dist/improve/collect.js.map +1 -0
  74. package/dist/improve/gate.d.ts +17 -0
  75. package/dist/improve/gate.js +60 -0
  76. package/dist/improve/gate.js.map +1 -0
  77. package/dist/improve/index.d.ts +9 -0
  78. package/dist/improve/index.js +376 -0
  79. package/dist/improve/index.js.map +1 -0
  80. package/dist/improve/judge.d.ts +32 -0
  81. package/dist/improve/judge.js +114 -0
  82. package/dist/improve/judge.js.map +1 -0
  83. package/dist/improve/mine.d.ts +31 -0
  84. package/dist/improve/mine.js +192 -0
  85. package/dist/improve/mine.js.map +1 -0
  86. package/dist/improve/propose.d.ts +51 -0
  87. package/dist/improve/propose.js +150 -0
  88. package/dist/improve/propose.js.map +1 -0
  89. package/dist/improve/rank.d.ts +16 -0
  90. package/dist/improve/rank.js +49 -0
  91. package/dist/improve/rank.js.map +1 -0
  92. package/dist/improve/schedule.d.ts +1 -0
  93. package/dist/improve/schedule.js +95 -0
  94. package/dist/improve/schedule.js.map +1 -0
  95. package/dist/improve/ship.d.ts +25 -0
  96. package/dist/improve/ship.js +82 -0
  97. package/dist/improve/ship.js.map +1 -0
  98. package/dist/improve/state.d.ts +157 -0
  99. package/dist/improve/state.js +264 -0
  100. package/dist/improve/state.js.map +1 -0
  101. package/dist/index.d.ts +2 -0
  102. package/dist/index.js +88 -0
  103. package/dist/index.js.map +1 -0
  104. package/dist/jj-binary.d.ts +36 -0
  105. package/dist/jj-binary.js +143 -0
  106. package/dist/jj-binary.js.map +1 -0
  107. package/dist/jj.d.ts +163 -0
  108. package/dist/jj.js +926 -0
  109. package/dist/jj.js.map +1 -0
  110. package/dist/logger.d.ts +22 -0
  111. package/dist/logger.js +110 -0
  112. package/dist/logger.js.map +1 -0
  113. package/dist/main.d.ts +3 -0
  114. package/dist/main.js +1366 -0
  115. package/dist/main.js.map +1 -0
  116. package/dist/migration.d.ts +82 -0
  117. package/dist/migration.js +198 -0
  118. package/dist/migration.js.map +1 -0
  119. package/dist/models.d.ts +9 -0
  120. package/dist/models.js +623 -0
  121. package/dist/models.js.map +1 -0
  122. package/dist/move-home.d.ts +82 -0
  123. package/dist/move-home.js +472 -0
  124. package/dist/move-home.js.map +1 -0
  125. package/dist/native/darwin-arm64/rudder-native +0 -0
  126. package/dist/native/darwin-x64/rudder-native +0 -0
  127. package/dist/native/linux-x64/rudder-native +0 -0
  128. package/dist/native-agents.d.ts +8 -0
  129. package/dist/native-agents.js +178 -0
  130. package/dist/native-agents.js.map +1 -0
  131. package/dist/native-binary.d.ts +4 -0
  132. package/dist/native-binary.js +140 -0
  133. package/dist/native-binary.js.map +1 -0
  134. package/dist/onboarding.d.ts +11 -0
  135. package/dist/onboarding.js +111 -0
  136. package/dist/onboarding.js.map +1 -0
  137. package/dist/paste-bridge.d.ts +36 -0
  138. package/dist/paste-bridge.js +279 -0
  139. package/dist/paste-bridge.js.map +1 -0
  140. package/dist/plan-mode.d.ts +2 -0
  141. package/dist/plan-mode.js +22 -0
  142. package/dist/plan-mode.js.map +1 -0
  143. package/dist/planner.d.ts +27 -0
  144. package/dist/planner.js +587 -0
  145. package/dist/planner.js.map +1 -0
  146. package/dist/predict.d.ts +67 -0
  147. package/dist/predict.js +369 -0
  148. package/dist/predict.js.map +1 -0
  149. package/dist/qr.d.ts +13 -0
  150. package/dist/qr.js +377 -0
  151. package/dist/qr.js.map +1 -0
  152. package/dist/report.d.ts +74 -0
  153. package/dist/report.js +412 -0
  154. package/dist/report.js.map +1 -0
  155. package/dist/rudder-md.d.ts +33 -0
  156. package/dist/rudder-md.js +142 -0
  157. package/dist/rudder-md.js.map +1 -0
  158. package/dist/run-manager.d.ts +62 -0
  159. package/dist/run-manager.js +1400 -0
  160. package/dist/run-manager.js.map +1 -0
  161. package/dist/scheduler.d.ts +128 -0
  162. package/dist/scheduler.js +955 -0
  163. package/dist/scheduler.js.map +1 -0
  164. package/dist/state.d.ts +127 -0
  165. package/dist/state.js +704 -0
  166. package/dist/state.js.map +1 -0
  167. package/dist/surfaces.d.ts +91 -0
  168. package/dist/surfaces.js +460 -0
  169. package/dist/surfaces.js.map +1 -0
  170. package/dist/task-summary.d.ts +30 -0
  171. package/dist/task-summary.js +472 -0
  172. package/dist/task-summary.js.map +1 -0
  173. package/dist/types.d.ts +492 -0
  174. package/dist/types.js +2 -0
  175. package/dist/types.js.map +1 -0
  176. package/dist/usage.d.ts +198 -0
  177. package/dist/usage.js +830 -0
  178. package/dist/usage.js.map +1 -0
  179. package/dist/util.d.ts +73 -0
  180. package/dist/util.js +399 -0
  181. package/dist/util.js.map +1 -0
  182. package/dist/version-check.d.ts +16 -0
  183. package/dist/version-check.js +269 -0
  184. package/dist/version-check.js.map +1 -0
  185. package/package.json +68 -4
  186. package/scripts/copy-native.mjs +89 -0
  187. package/scripts/postinstall.mjs +34 -0
package/dist/jj.js ADDED
@@ -0,0 +1,926 @@
1
+ import fs from "node:fs";
2
+ import fsp from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { commandExists, MissingToolError, pathExistsSync, runCommand, runCommandSync, shortHash, } from "./util.js";
5
+ import { ensureProjectRuntimeIgnored, saveRunRecord, workspacePath } from "./state.js";
6
+ import { DECISIONS_HEADER } from "./surfaces.js";
7
+ import { withIntegrationLock } from "./rudder-md.js";
8
+ import { managedJjInstallError } from "./jj-binary.js";
9
+ // Minimum jj version we are confident in. Older releases may lack flags we use
10
+ // (e.g. `jj workspace add -r`); we warn rather than throw so an older jj still
11
+ // works through the documented fallbacks.
12
+ const MIN_JJ = "0.21.0";
13
+ /**
14
+ * Hard requirement: jj must be installed. Throws MissingToolError otherwise.
15
+ */
16
+ export function ensureJj() {
17
+ if (!commandExists("jj")) {
18
+ const reason = managedJjInstallError();
19
+ throw new MissingToolError("jj", reason ? `jj is not installed, and Rudder could not set it up (${reason}). ${TOOL_JJ_FALLBACK}` : undefined);
20
+ }
21
+ }
22
+ const TOOL_JJ_FALLBACK = "Install it yourself with `brew install jj` or `cargo install jj-cli` (https://jj-vcs.github.io), then run rudder again.";
23
+ /**
24
+ * Parse `jj --version`. Returns the version string (e.g. "0.40.0") or "".
25
+ * Warns (does not throw) when below MIN_JJ.
26
+ */
27
+ export async function jjVersion() {
28
+ const result = await runCommand("jj", ["--version"], { allowFailure: true });
29
+ const match = result.stdout.match(/(\d+)\.(\d+)\.(\d+)/);
30
+ const version = match ? match[0] : "";
31
+ if (version && compareVersions(version, MIN_JJ) < 0) {
32
+ console.warn(`[rudder] jj ${version} is older than the recommended ${MIN_JJ}; some workspace flags may fall back.`);
33
+ }
34
+ return version;
35
+ }
36
+ /**
37
+ * Idempotently colocate a jj repo with the git repo at repoRoot. If already a
38
+ * jj repo, no-op. Otherwise runs `jj git init --colocate`.
39
+ */
40
+ export async function ensureColocated(repoRoot) {
41
+ // Every jj command snapshots the working copy by default, including `jj
42
+ // root`. Install the exclusions before even probing repository state.
43
+ await ensureProjectRuntimeIgnored(repoRoot);
44
+ ensureJj();
45
+ if (!isJjRepo(repoRoot)) {
46
+ await runCommand("jj", ["git", "init", "--colocate"], { cwd: repoRoot });
47
+ }
48
+ await untrackLegacyRudderState(repoRoot);
49
+ }
50
+ /** Remove runtime files snapshotted by older Rudder versions while preserving
51
+ * them on disk. Ignore rules alone do not untrack a path already present in @. */
52
+ async function untrackLegacyRudderState(repoRoot) {
53
+ const tracked = await runCommand("jj", ["file", "list", "--ignore-working-copy", "-r", "@", ".rudder"], { cwd: repoRoot, allowFailure: true });
54
+ if (tracked.code !== 0 || !tracked.stdout.trim())
55
+ return;
56
+ await runCommand("jj", ["file", "untrack", ".rudder"], { cwd: repoRoot });
57
+ }
58
+ export function isJjRepo(cwd) {
59
+ if (!commandExists("jj")) {
60
+ return false;
61
+ }
62
+ const result = runCommandSync("jj", ["root"], { cwd, allowFailure: true });
63
+ if (result.code === 0 && result.stdout.trim()) {
64
+ return samePath(result.stdout.trim(), cwd);
65
+ }
66
+ return hasLocalJjMarker(cwd);
67
+ }
68
+ export function findJjRoot(cwd) {
69
+ if (commandExists("jj")) {
70
+ const result = runCommandSync("jj", ["root"], { cwd, allowFailure: true });
71
+ if (result.code === 0 && result.stdout.trim()) {
72
+ return result.stdout.trim();
73
+ }
74
+ }
75
+ const marker = findJjMarker(cwd);
76
+ return marker ? path.dirname(marker) : path.resolve(cwd);
77
+ }
78
+ /**
79
+ * jj refuses to operate on a STALE workspace — one whose working-copy commit was
80
+ * moved by a concurrent operation in another workspace (every command in any
81
+ * workspace advances the shared op log). The command exits non-zero with a
82
+ * "working copy is stale" message and a hint to run `jj workspace update-stale`.
83
+ * In a multi-worker run this happens routinely, so any jj read against a worker
84
+ * workspace must be able to recover instead of falsely reporting "no changes"
85
+ * (which marks a good run failed) or failing a merge.
86
+ */
87
+ function isStaleWorkingCopy(text) {
88
+ return /working copy is stale|stale working copy/i.test(text);
89
+ }
90
+ /**
91
+ * Re-point a stale workspace at its recorded working-copy commit. jj snapshots
92
+ * the on-disk working copy as part of this, so a worker's changes are preserved.
93
+ * Returns true when the workspace is no longer stale afterwards.
94
+ */
95
+ async function recoverStaleWorkspace(workspacePath) {
96
+ const result = await runCommand("jj", ["workspace", "update-stale"], {
97
+ cwd: workspacePath,
98
+ allowFailure: true,
99
+ });
100
+ return result.code === 0;
101
+ }
102
+ /**
103
+ * Run a read-only jj command in a workspace, transparently recovering once from a
104
+ * stale working copy. This keeps the verifier's change-detection and the merge's
105
+ * change-id reads honest while workers run concurrently in sibling workspaces.
106
+ */
107
+ async function jjReadInWorkspace(workspacePath, args) {
108
+ let result = await runCommand("jj", args, { cwd: workspacePath, allowFailure: true });
109
+ if (result.code !== 0 && isStaleWorkingCopy(result.stderr)) {
110
+ await recoverStaleWorkspace(workspacePath);
111
+ result = await runCommand("jj", args, { cwd: workspacePath, allowFailure: true });
112
+ }
113
+ return result;
114
+ }
115
+ /**
116
+ * The same recovery for a jj command that WRITES.
117
+ *
118
+ * Reads had this and writes did not, which left the main checkout able to wedge
119
+ * every launch in a repo: `jj workspace add` snapshots the workspace it is run
120
+ * FROM, so once a sibling worker's operation left the main checkout stale, every
121
+ * new agent — a typed task, a plan node, a `/resume`, review-all — died on
122
+ * "The working copy is stale" until the user ran `update-stale` by hand. The
123
+ * recovery is exactly what jj's own hint says to do, and it snapshots the
124
+ * working copy rather than discarding it, so nothing on disk is lost.
125
+ */
126
+ export async function jjWriteWithStaleRecovery(cwd, args) {
127
+ let result = await runCommand("jj", args, { cwd, allowFailure: true });
128
+ // jj reports staleness on stderr, but check both: the message has moved
129
+ // between jj releases and a missed match reintroduces the wedge.
130
+ if (result.code !== 0 && isStaleWorkingCopy(`${result.stderr}\n${result.stdout}`)) {
131
+ if (await recoverStaleWorkspace(cwd)) {
132
+ result = await runCommand("jj", args, { cwd, allowFailure: true });
133
+ }
134
+ }
135
+ return result;
136
+ }
137
+ /**
138
+ * The change id of `@` (the working-copy commit) in the given workspace.
139
+ */
140
+ export async function currentJjChangeId(workspacePath) {
141
+ const result = await jjReadInWorkspace(workspacePath, ["log", "--no-graph", "-r", "@", "-T", "change_id"]);
142
+ return result.code === 0 ? result.stdout.trim().split(/\s+/)[0] ?? "" : "";
143
+ }
144
+ /** The immutable commit id of a workspace's current revision. Prefer this when
145
+ * feeding a finished workspace into a merge: concurrent jj snapshots can make
146
+ * its stable change id divergent, while the workspace's @ commit remains an
147
+ * unambiguous revision. */
148
+ export async function currentJjCommitId(workspacePath) {
149
+ const result = await jjReadInWorkspace(workspacePath, ["log", "--no-graph", "-r", "@", "-T", "commit_id"]);
150
+ return result.code === 0 ? result.stdout.trim().split(/\s+/)[0] ?? "" : "";
151
+ }
152
+ export async function jjStatus(workspacePath) {
153
+ const result = await jjReadInWorkspace(workspacePath, ["status"]);
154
+ return result.code === 0 ? parseJjStatus(result.stdout) : [];
155
+ }
156
+ export function parseJjStatus(stdout) {
157
+ const lines = [];
158
+ let inConflictSection = false;
159
+ for (const raw of stdout.split(/\r?\n/)) {
160
+ const line = raw.trim();
161
+ if (!line) {
162
+ continue;
163
+ }
164
+ if (/^working copy changes:?$/i.test(line)) {
165
+ inConflictSection = false;
166
+ continue;
167
+ }
168
+ if (/^(conflicts|unresolved conflicts|there are unresolved conflicts)(:|$)/i.test(line)) {
169
+ inConflictSection = true;
170
+ continue;
171
+ }
172
+ if (isJjStatusMetadata(line)) {
173
+ continue;
174
+ }
175
+ const statusPath = statusPathFromSummaryLine(line);
176
+ if (statusPath) {
177
+ if (!isRudderMetadataPath(statusPath)) {
178
+ lines.push(line);
179
+ }
180
+ continue;
181
+ }
182
+ if (inConflictSection) {
183
+ const conflictPath = line.replace(/^[-*]\s+/, "").replace(/^conflict in\s+/i, "").trim();
184
+ if (conflictPath && !isRudderMetadataPath(conflictPath)) {
185
+ lines.push(`C ${conflictPath}`);
186
+ }
187
+ }
188
+ }
189
+ return lines;
190
+ }
191
+ export async function jjDiff(workspacePath) {
192
+ const status = await jjStatus(workspacePath);
193
+ // A sibling workspace can make this one stale mid-verification; an unrecovered
194
+ // read returns an empty diff and the verifier flags a healthy run.
195
+ const stat = await jjReadInWorkspace(workspacePath, ["diff", "--stat"]);
196
+ const patch = await jjReadInWorkspace(workspacePath, ["diff", "--git"]);
197
+ return [
198
+ status.length ? `status:\n${status.join("\n")}` : "",
199
+ stat.stdout.trim(),
200
+ patch.stdout.trim(),
201
+ ]
202
+ .filter(Boolean)
203
+ .join("\n\n");
204
+ }
205
+ /**
206
+ * Create a new empty change parented on the given changes. Reads the new change
207
+ * id back by querying a unique marker embedded in the description, never by
208
+ * scraping stdout. Returns the new change id.
209
+ */
210
+ export async function createEmptyChange(params) {
211
+ ensureJj();
212
+ const marker = `rudder-node:${shortHash(`${params.description}:${Date.now()}:${Math.random()}`)}`;
213
+ const description = `${marker} ${params.description}`.trim();
214
+ const args = ["new", ...params.parents.filter(Boolean), "-m", description, "--no-edit"];
215
+ // Same stale exposure as `workspace add`: the scheduler creates a node's base
216
+ // change in the main checkout moments before its workspace, so one stale main
217
+ // checkout would fail the plan a step earlier instead.
218
+ const created = await jjWriteWithStaleRecovery(params.repoRoot, args);
219
+ if (created.code !== 0) {
220
+ throw new Error(created.stderr.trim() || created.stdout.trim() || "jj new failed.");
221
+ }
222
+ // jj normalizes descriptions to end with a trailing newline, and
223
+ // `description(exact:...)` matches the full stored description, so the revset
224
+ // literal must include the trailing "\n".
225
+ const exact = JSON.stringify(`${description}\n`);
226
+ const result = await runCommand("jj", ["log", "--no-graph", "-r", `description(exact:${exact})`, "-T", "change_id"], { cwd: params.repoRoot, allowFailure: true });
227
+ let changeId = result.stdout.trim().split(/\s+/)[0] ?? "";
228
+ if (!changeId) {
229
+ // Fallback: match on the unique marker as a substring.
230
+ const bySubstring = await runCommand("jj", ["log", "--no-graph", "-r", `description(substring:${JSON.stringify(marker)})`, "-T", "change_id"], { cwd: params.repoRoot, allowFailure: true });
231
+ changeId = bySubstring.stdout.trim().split(/\s+/)[0] ?? "";
232
+ }
233
+ if (!changeId) {
234
+ throw new Error(`Could not read back the change id for the new empty change (${marker}).`);
235
+ }
236
+ return changeId;
237
+ }
238
+ export async function describeChange(repoRoot, changeId, message) {
239
+ await runCommand("jj", ["describe", changeId, "-m", message], { cwd: repoRoot });
240
+ }
241
+ /**
242
+ * Create a jj workspace for a node/run. Workspace name matches the original
243
+ * rudder regex `rudder-<id.slice(0,14)>-<shortHash(id).slice(0,6)>`. If
244
+ * `atChangeId` is given, parent the working-copy commit on it; falls back to
245
+ * `jj workspace add` + `jj edit <atChangeId>` if `-r` is unsupported.
246
+ */
247
+ export async function createNodeWorkspace(params) {
248
+ ensureJj();
249
+ // jj snapshots the main checkout while adding a workspace. Runtime state
250
+ // must already be excluded at that exact point.
251
+ await ensureProjectRuntimeIgnored(params.repoRoot);
252
+ const id = params.nodeId ?? params.runId;
253
+ if (!id) {
254
+ throw new Error("createNodeWorkspace requires nodeId or runId.");
255
+ }
256
+ const workspaceName = workspaceNameFor(id);
257
+ const targetPath = workspacePath(params.repoRoot, params.runId ?? id, params.task);
258
+ await fsp.mkdir(path.dirname(targetPath), { recursive: true });
259
+ const baseArgs = ["workspace", "add", targetPath, "--name", workspaceName];
260
+ let added = await jjWriteWithStaleRecovery(params.repoRoot, params.atChangeId ? [...baseArgs, "-r", params.atChangeId] : baseArgs);
261
+ if (added.code !== 0 && params.atChangeId) {
262
+ // Older jj without `workspace add -r`: add, then edit onto the change.
263
+ added = await jjWriteWithStaleRecovery(params.repoRoot, baseArgs);
264
+ if (added.code === 0) {
265
+ await runCommand("jj", ["edit", params.atChangeId], { cwd: targetPath, allowFailure: true });
266
+ }
267
+ }
268
+ if (added.code !== 0) {
269
+ throw new Error(added.stderr.trim() || added.stdout.trim() || `jj ${baseArgs.join(" ")} failed.`);
270
+ }
271
+ // Describe the workspace's OWN working-copy change (@) so `jj log` shows what
272
+ // this agent is doing instead of "(no description set)". Rudder agents read
273
+ // each other's jj descriptions to coordinate; RUDDER.md stays the aggregate
274
+ // human snapshot. Best-effort — a missing description must never fail a launch.
275
+ await runCommand("jj", ["describe", "-m", workspaceDescription(params, "running")], {
276
+ cwd: targetPath,
277
+ allowFailure: true,
278
+ });
279
+ return { workspaceName, path: targetPath };
280
+ }
281
+ /** jj working-copy description for a Rudder agent's workspace. */
282
+ function workspaceDescription(params, status) {
283
+ const label = params.nodeId ? `rudder[${params.nodeId}]` : "rudder";
284
+ const task = (params.task ?? "").trim().replace(/\s+/g, " ");
285
+ const summary = task.length > 72 ? `${task.slice(0, 71)}…` : task;
286
+ return summary ? `${label}: ${summary} [${status}]` : `${label} [${status}]`;
287
+ }
288
+ /**
289
+ * Phase-1 entry point used by run-manager. Delegates to createNodeWorkspace.
290
+ */
291
+ export async function createRunJjWorkspace(params) {
292
+ return await createNodeWorkspace({
293
+ repoRoot: params.repoRoot,
294
+ runId: params.runId,
295
+ task: params.task,
296
+ });
297
+ }
298
+ export function workspaceNameFor(id) {
299
+ return `rudder-${id.slice(0, 14)}-${shortHash(id).slice(0, 6)}`;
300
+ }
301
+ /**
302
+ * Merge a run's jj change into the current (main) workspace `@`. Captures the
303
+ * operation id before the merge and records it on merge.operationId. On
304
+ * conflict sets merge.status="conflict" + conflictedFiles + mergeChangeId.
305
+ */
306
+ export async function mergeJjRunIntoCurrentWorkspace(run, allowDirty = false) {
307
+ // Integration is one-at-a-time ACROSS PROCESSES: a `rudder merge` CLI call
308
+ // and a daemon tick are separate processes sharing the same @, and a second
309
+ // merge starting mid-flight would stack a merge change on a moving target.
310
+ return withIntegrationLock(run.repoRoot, () => mergeJjRunIntoCurrentWorkspaceLocked(run, allowDirty));
311
+ }
312
+ async function mergeJjRunIntoCurrentWorkspaceLocked(run, allowDirty) {
313
+ if (!run.workspace.workspaceName) {
314
+ throw new Error("Run has no jj workspace name to merge.");
315
+ }
316
+ ensureJjRepo(run.repoRoot);
317
+ ensureJjRepo(run.workspace.path);
318
+ // A resolver edits the already-created merge change in place. Once its
319
+ // conflicts are gone, finalize that transaction instead of creating a second
320
+ // merge change on top of it.
321
+ if (run.merge?.status === "conflict" && run.merge.mergeChangeId) {
322
+ const remaining = await jjConflictedFiles(run.repoRoot);
323
+ if (remaining.length > 0) {
324
+ run.merge = { ...run.merge, status: "conflict", conflictedFiles: remaining };
325
+ await saveRunRecord(run);
326
+ return run;
327
+ }
328
+ return await finalizeJjIntegration(run, run.merge.mergeChangeId);
329
+ }
330
+ // Rudder writes its own files (.rudder/ is already filtered by jjStatus; RUDDER.md
331
+ // and DECISIONS.md are the live coordination surfaces) into the main workspace
332
+ // continuously, so they must not count as "dirty" and block a merge.
333
+ if (!allowDirty) {
334
+ const dirty = (await jjStatus(run.repoRoot)).filter((line) => !isMergeIgnorablePath(line));
335
+ if (dirty.length > 0) {
336
+ throw new Error("Target workspace is dirty. Squash/abandon changes or pass --allow-dirty.");
337
+ }
338
+ }
339
+ // --allow-dirty (which the TUI always passes) skips the dirty gate above, but a
340
+ // merge parented on a still-conflicted @ (e.g. the user declined the resolver
341
+ // after a previous conflicted merge) nests conflicts, so this guard is
342
+ // unconditional.
343
+ // PRE-FLIGHT. A conflicted integration workspace refuses EVERY later merge, so
344
+ // one unresolved DECISIONS.md used to strand an entire plan: finished nodes sat
345
+ // in review and everything hard-depending on them stayed unlaunchable. Settle
346
+ // what needs no judgment first; only a real product conflict should ever stop
347
+ // integration.
348
+ const preexistingConflicts = await autoResolveMechanicalConflicts(run.repoRoot);
349
+ if (preexistingConflicts.length > 0) {
350
+ const error = `integration workspace already has unresolved conflicts (${preexistingConflicts.join(", ")}); resolve them before merging another run`;
351
+ await markMergeFailed(run, error);
352
+ throw new Error(error);
353
+ }
354
+ const opIdBefore = await currentOpId(run.repoRoot);
355
+ run.merge = {
356
+ status: "not-started",
357
+ attemptedAt: new Date().toISOString(),
358
+ targetBranch: (await currentJjChangeId(run.repoRoot)) || "@",
359
+ operationId: opIdBefore || undefined,
360
+ };
361
+ await saveRunRecord(run);
362
+ const sourceChangeId = await currentJjChangeId(run.workspace.path);
363
+ if (!sourceChangeId) {
364
+ await markMergeFailed(run, "Could not determine jj change id for run workspace.");
365
+ throw new Error("Could not determine jj change id for run workspace.");
366
+ }
367
+ const message = `rudder: ${run.task.slice(0, 72)}`;
368
+ const merge = await mergeNode({
369
+ repoRoot: run.repoRoot,
370
+ nodeChangeId: sourceChangeId,
371
+ intoChangeId: "@",
372
+ message,
373
+ });
374
+ if (!merge.mergeChangeId) {
375
+ const error = "jj merge failed.";
376
+ await markMergeFailed(run, error);
377
+ throw new Error(error);
378
+ }
379
+ if (merge.conflictedFiles.length === 0) {
380
+ // KEEP the pre-merge operationId captured above: it is the undo waypoint.
381
+ // Overwriting it with the POST-merge op id (as this used to) made
382
+ // `rudder undo <op>` restore to the state after the merge — a perfect
383
+ // no-op that reported success while changing nothing.
384
+ return await finalizeJjIntegration(run, merge.mergeChangeId);
385
+ }
386
+ run.status = "merge-conflict";
387
+ run.hadMergeConflict = true;
388
+ run.merge = {
389
+ ...run.merge,
390
+ status: "conflict",
391
+ conflictedFiles: merge.conflictedFiles,
392
+ mergeChangeId: merge.mergeChangeId,
393
+ // Pre-merge op stays the undo waypoint (see the clean-merge path above).
394
+ error: "jj merge created conflicts.",
395
+ };
396
+ await saveRunRecord(run);
397
+ return run;
398
+ }
399
+ async function finalizeJjIntegration(run, mergeChangeId) {
400
+ const targetBookmark = await integrationBookmark(run.repoRoot, run.targetBranch);
401
+ await createBookmark(run.repoRoot, targetBookmark, mergeChangeId);
402
+ await exportToGit(run.repoRoot);
403
+ const localCommit = await gitRevision(run.repoRoot, targetBookmark);
404
+ const pushed = localCommit
405
+ ? await remoteContainsCommit(run.repoRoot, targetBookmark, localCommit)
406
+ : false;
407
+ run.status = "merged";
408
+ run.merge = {
409
+ ...run.merge,
410
+ status: "merged",
411
+ targetBranch: targetBookmark,
412
+ mergeChangeId,
413
+ localCommit: localCommit || undefined,
414
+ pushed,
415
+ pushedAt: pushed ? new Date().toISOString() : undefined,
416
+ conflictedFiles: undefined,
417
+ conflictKind: undefined,
418
+ error: undefined,
419
+ };
420
+ await saveRunRecord(run);
421
+ return run;
422
+ }
423
+ export async function closestLocalBookmark(repoRoot) {
424
+ const result = await runCommand("jj", ["log", "--no-graph", "-r", "heads(::@ & bookmarks())", "-T", "bookmarks ++ \"\\n\""], { cwd: repoRoot, allowFailure: true });
425
+ if (result.code !== 0)
426
+ return "";
427
+ const names = result.stdout
428
+ .split(/\s+/)
429
+ .map((name) => name.trim().replace(/\*$/, ""))
430
+ .filter((name) => name && !name.includes("@"));
431
+ return names.find((name) => name === "main")
432
+ ?? names.find((name) => name === "master")
433
+ ?? names[0]
434
+ ?? "";
435
+ }
436
+ async function integrationBookmark(repoRoot, recordedTarget) {
437
+ const candidate = recordedTarget.trim();
438
+ if (candidate && candidate !== "HEAD" && !/^[a-z]{12,}$/i.test(candidate) && !/^[0-9a-f]{12,}$/i.test(candidate)) {
439
+ return candidate;
440
+ }
441
+ return (await closestLocalBookmark(repoRoot)) || "main";
442
+ }
443
+ async function gitRevision(repoRoot, ref) {
444
+ const result = await runCommand("git", ["rev-parse", ref], { cwd: repoRoot, allowFailure: true });
445
+ return result.code === 0 ? result.stdout.trim() : "";
446
+ }
447
+ async function remoteContainsCommit(repoRoot, bookmark, commit) {
448
+ const remote = `origin/${bookmark}`;
449
+ const exists = await runCommand("git", ["rev-parse", "--verify", remote], {
450
+ cwd: repoRoot,
451
+ allowFailure: true,
452
+ });
453
+ if (exists.code !== 0)
454
+ return false;
455
+ const contains = await runCommand("git", ["merge-base", "--is-ancestor", commit, remote], {
456
+ cwd: repoRoot,
457
+ allowFailure: true,
458
+ });
459
+ return contains.code === 0;
460
+ }
461
+ /**
462
+ * Merge two changes into a new change. Captures the op id (after the merge),
463
+ * returns the new merge change id and any conflicted files. jj merges never
464
+ * fail on conflict; the conflict is recorded in the merge change.
465
+ */
466
+ export async function mergeNode(params) {
467
+ ensureJjRepo(params.repoRoot);
468
+ const merge = await runCommand("jj", ["new", params.intoChangeId, params.nodeChangeId, "-m", params.message], { cwd: params.repoRoot, allowFailure: true });
469
+ if (merge.code !== 0) {
470
+ throw new Error(merge.stderr.trim() || merge.stdout.trim() || "jj merge failed.");
471
+ }
472
+ const mergeChangeId = await currentJjChangeId(params.repoRoot);
473
+ const opId = await currentOpId(params.repoRoot);
474
+ const conflictedFiles = await jjConflictedFiles(params.repoRoot);
475
+ return { mergeChangeId, opId, conflictedFiles };
476
+ }
477
+ export async function jjConflictedFiles(workspacePath) {
478
+ // Stale-recovering read: a stale failure here would falsely report "no
479
+ // conflicts" to the merge/sync paths.
480
+ const result = await jjReadInWorkspace(workspacePath, ["resolve", "--list"]);
481
+ return result.code === 0 ? parseJjConflictedFiles(result.stdout) : [];
482
+ }
483
+ /**
484
+ * Alias matching the plan's API name.
485
+ */
486
+ export async function listConflicts(workspacePath) {
487
+ return await jjConflictedFiles(workspacePath);
488
+ }
489
+ export function parseJjConflictedFiles(stdout) {
490
+ return stdout
491
+ .split(/\r?\n/)
492
+ .map((line) => line.trim())
493
+ .filter(Boolean)
494
+ .map((line) => line.replace(/^[-*]\s+/, "").replace(/^conflict in\s+/i, "").trim())
495
+ // `jj resolve --list` prints "path/to/file 2-sided conflict" (and
496
+ // "... including 1 deletion"). Everything downstream treats these as PATHS:
497
+ // they are joined into merge errors, stored on run.merge.conflictedFiles, and
498
+ // fed to the mechanical resolver, which keys on the file NAME. Leaving the
499
+ // descriptor attached makes every one of those miss. Split on the run of
500
+ // whitespace jj uses as the separator so paths containing single spaces
501
+ // survive intact.
502
+ .map((line) => line.replace(/\s{2,}\d+-sided conflict.*$/, "").trim())
503
+ .filter((line) => line && !isRudderMetadataPath(line));
504
+ }
505
+ /**
506
+ * The id of the latest operation in the op log. Recovers once from a stale
507
+ * workspace; if the id still cannot be read, warns and returns "" instead of
508
+ * throwing — merges must proceed, they just lose their undo waypoint.
509
+ */
510
+ export async function currentOpId(repoRoot) {
511
+ const result = await jjReadInWorkspace(repoRoot, ["op", "log", "--no-graph", "-n", "1", "-T", "id.short()"]);
512
+ const opId = result.code === 0 ? result.stdout.trim().split(/\s+/)[0] ?? "" : "";
513
+ if (!opId) {
514
+ console.warn("rudder: could not capture jj op id; this merge will not be undoable via rudder undo");
515
+ }
516
+ return opId;
517
+ }
518
+ /**
519
+ * Restore the repo to an earlier operation. One call rewinds all workspaces and
520
+ * refs atomically (op restore is global).
521
+ */
522
+ export async function undoToOp(repoRoot, opId) {
523
+ await runCommand("jj", ["op", "restore", opId], { cwd: repoRoot });
524
+ }
525
+ export async function undoLast(repoRoot) {
526
+ await runCommand("jj", ["undo"], { cwd: repoRoot });
527
+ }
528
+ /**
529
+ * Export jj changes to the colocated git repo. Call after each merge, before
530
+ * opening a diff review, and before a PR.
531
+ */
532
+ export async function exportToGit(repoRoot) {
533
+ await runCommand("jj", ["git", "export"], { cwd: repoRoot });
534
+ }
535
+ export async function createBookmark(repoRoot, name, atChangeId) {
536
+ const create = await runCommand("jj", ["bookmark", "create", name, "-r", atChangeId], {
537
+ cwd: repoRoot,
538
+ allowFailure: true,
539
+ });
540
+ if (create.code !== 0) {
541
+ // Bookmark may already exist; move it to the target change.
542
+ await runCommand("jj", ["bookmark", "set", name, "-r", atChangeId, "--allow-backwards"], {
543
+ cwd: repoRoot,
544
+ });
545
+ }
546
+ }
547
+ export async function pushBookmark(repoRoot, name) {
548
+ const push = await runCommand("jj", ["git", "push", "--bookmark", name, "--allow-new"], {
549
+ cwd: repoRoot,
550
+ allowFailure: true,
551
+ });
552
+ if (push.code !== 0) {
553
+ // jj 0.40 drops `--allow-new`; a plain `--bookmark` push handles new bookmarks.
554
+ await runCommand("jj", ["git", "push", "--bookmark", name], { cwd: repoRoot });
555
+ }
556
+ }
557
+ /**
558
+ * Forget a jj workspace and remove its directory.
559
+ */
560
+ export async function forgetWorkspace(params) {
561
+ if (commandExists("jj")) {
562
+ await runCommand("jj", ["workspace", "forget", params.workspaceName], {
563
+ cwd: params.repoRoot,
564
+ allowFailure: true,
565
+ }).catch(() => undefined);
566
+ }
567
+ await fsp.rm(params.workspacePath, { recursive: true, force: true });
568
+ }
569
+ export async function removeRunWorkspace(run) {
570
+ if (!run.workspace.enabled) {
571
+ return;
572
+ }
573
+ if (!run.workspace.workspaceName) {
574
+ throw new Error("Run has no jj workspace name to remove.");
575
+ }
576
+ await forgetWorkspace({
577
+ repoRoot: run.repoRoot,
578
+ workspaceName: run.workspace.workspaceName,
579
+ workspacePath: run.workspace.path,
580
+ });
581
+ }
582
+ /**
583
+ * Rebase a run's node change onto the trunk. jj records conflicts in place and
584
+ * never blocks, so there is no rebase-in-progress dance.
585
+ */
586
+ export async function syncRunWorkspace(run, baseBranch) {
587
+ if (!run.workspace.workspaceName) {
588
+ throw new Error("Run has no jj workspace to sync.");
589
+ }
590
+ ensureJjRepo(run.repoRoot);
591
+ const nodeChange = run.workspace.jjChangeId || (await currentJjChangeId(run.workspace.path));
592
+ if (!nodeChange) {
593
+ run.sync = {
594
+ ...run.sync,
595
+ status: "failed",
596
+ attemptedAt: new Date().toISOString(),
597
+ baseBranch,
598
+ error: "Could not determine jj change id for run workspace.",
599
+ };
600
+ await saveRunRecord(run);
601
+ return run;
602
+ }
603
+ const trunk = baseBranch && baseBranch !== "HEAD" ? baseBranch : run.targetBranch || "@";
604
+ run.sync = {
605
+ status: "not-started",
606
+ attemptedAt: new Date().toISOString(),
607
+ baseBranch: trunk,
608
+ };
609
+ await saveRunRecord(run);
610
+ const rebase = await runCommand("jj", ["rebase", "-s", nodeChange, "-d", trunk], {
611
+ cwd: run.repoRoot,
612
+ allowFailure: true,
613
+ });
614
+ if (rebase.code !== 0) {
615
+ // jj 0.40 replaced `-d`/`--destination` with `-o`/`--onto`.
616
+ const onto = await runCommand("jj", ["rebase", "-s", nodeChange, "--onto", trunk], {
617
+ cwd: run.repoRoot,
618
+ allowFailure: true,
619
+ });
620
+ if (onto.code !== 0) {
621
+ run.sync = {
622
+ ...run.sync,
623
+ status: "failed",
624
+ error: onto.stderr.trim() || onto.stdout.trim() || `Rebase onto ${trunk} failed.`,
625
+ };
626
+ await saveRunRecord(run);
627
+ return run;
628
+ }
629
+ }
630
+ const conflicted = await jjConflictedFiles(run.workspace.path);
631
+ if (conflicted.length) {
632
+ run.hadMergeConflict = true;
633
+ }
634
+ run.sync = {
635
+ ...run.sync,
636
+ status: conflicted.length ? "conflict" : "synced",
637
+ conflictedFiles: conflicted,
638
+ };
639
+ await saveRunRecord(run);
640
+ return run;
641
+ }
642
+ function ensureJjRepo(cwd) {
643
+ if (!commandExists("jj")) {
644
+ throw new MissingToolError("jj");
645
+ }
646
+ const result = runCommandSync("jj", ["root"], { cwd, allowFailure: true });
647
+ if (result.code !== 0 || !result.stdout.trim()) {
648
+ throw new Error(`Expected ${cwd} to be inside a jj repository.`);
649
+ }
650
+ }
651
+ function hasLocalJjMarker(cwd) {
652
+ const marker = path.join(path.resolve(cwd), ".jj");
653
+ try {
654
+ return fs.statSync(marker).isDirectory();
655
+ }
656
+ catch {
657
+ return false;
658
+ }
659
+ }
660
+ function findJjMarker(cwd) {
661
+ let current = path.resolve(cwd);
662
+ while (true) {
663
+ const marker = path.join(current, ".jj");
664
+ if (pathExistsSync(marker)) {
665
+ try {
666
+ if (fs.statSync(marker).isDirectory()) {
667
+ return marker;
668
+ }
669
+ }
670
+ catch {
671
+ // Keep walking if the marker cannot be inspected.
672
+ }
673
+ }
674
+ const parent = path.dirname(current);
675
+ if (parent === current) {
676
+ return null;
677
+ }
678
+ current = parent;
679
+ }
680
+ }
681
+ function samePath(left, right) {
682
+ const normalize = (value) => {
683
+ const resolved = path.resolve(value);
684
+ try {
685
+ return fs.realpathSync(resolved);
686
+ }
687
+ catch {
688
+ return resolved;
689
+ }
690
+ };
691
+ return normalize(left) === normalize(right);
692
+ }
693
+ function isJjStatusMetadata(line) {
694
+ return (/^the working copy has no changes\.?$/i.test(line) ||
695
+ /^the working copy is clean\.?$/i.test(line) ||
696
+ /^no changes\.?$/i.test(line) ||
697
+ /^working copy\s*:/i.test(line) ||
698
+ /^parent commit\s*:/i.test(line) ||
699
+ /^current operation\s*:/i.test(line) ||
700
+ /^no conflicts found\.?$/i.test(line));
701
+ }
702
+ function statusPathFromSummaryLine(line) {
703
+ const match = line.match(/^[A-Z?]{1,2}\s+(.+)$/);
704
+ return match?.[1]?.trim() || null;
705
+ }
706
+ function isRudderMetadataPath(filePath) {
707
+ const normalized = filePath.replace(/\\/g, "/").replace(/^["']|["']$/g, "");
708
+ return normalized === ".rudder" || normalized.startsWith(".rudder/");
709
+ }
710
+ /// A jj-status line that names a Rudder-managed file (a status path or a `C path`
711
+ /// conflict line), which should never count toward the target-dirty merge gate.
712
+ function isMergeIgnorablePath(line) {
713
+ const path = line.replace(/^[ACMDR?]\s+/, "").replace(/^C\s+/, "").trim();
714
+ return (isRudderMetadataPath(path) ||
715
+ path === "RUDDER.md" ||
716
+ path === "DECISIONS.md" ||
717
+ path.endsWith("/RUDDER.md") ||
718
+ path.endsWith("/DECISIONS.md"));
719
+ }
720
+ async function markMergeFailed(run, error) {
721
+ run.merge = {
722
+ ...run.merge,
723
+ status: "failed",
724
+ error,
725
+ };
726
+ await saveRunRecord(run);
727
+ }
728
+ function compareVersions(a, b) {
729
+ const pa = a.split(".").map((n) => Number.parseInt(n, 10) || 0);
730
+ const pb = b.split(".").map((n) => Number.parseInt(n, 10) || 0);
731
+ for (let i = 0; i < Math.max(pa.length, pb.length); i += 1) {
732
+ const diff = (pa[i] ?? 0) - (pb[i] ?? 0);
733
+ if (diff !== 0) {
734
+ return diff < 0 ? -1 : 1;
735
+ }
736
+ }
737
+ return 0;
738
+ }
739
+ // ---------------------------------------------------------------------------
740
+ // Mechanical conflict resolution.
741
+ //
742
+ // A conflict in one of Rudder's own coordination files is never a real product
743
+ // disagreement, and neither is a lockfile: they are the dominant cause of the
744
+ // N-sided pileups that used to wedge integration. The native side pre-flights
745
+ // the same classes before every interactive merge; this is the same rule for
746
+ // the scheduler/daemon path, which had none and so could still freeze a whole
747
+ // plan behind one DECISIONS.md conflict.
748
+ // ---------------------------------------------------------------------------
749
+ const CONFLICT_MARKER = /^(<{7}|={7}|>{7}|%{7}|\+{7})/;
750
+ export function containsConflictMarkers(content) {
751
+ return content.split(/\r?\n/).some((line) => CONFLICT_MARKER.test(line));
752
+ }
753
+ /** The parent revisions of `@`; a merge has two or more. */
754
+ export async function jjParentRevs(repoRoot) {
755
+ const result = await jjReadInWorkspace(repoRoot, [
756
+ "log",
757
+ "--no-graph",
758
+ "--no-pager",
759
+ "-r",
760
+ "@-",
761
+ "-T",
762
+ 'change_id.short() ++ "\\n"',
763
+ ]);
764
+ if (result.code !== 0) {
765
+ return [];
766
+ }
767
+ return result.stdout
768
+ .split(/\r?\n/)
769
+ .map((line) => line.trim())
770
+ .filter(Boolean);
771
+ }
772
+ async function jjFileShow(repoRoot, rev, filePath) {
773
+ const result = await runCommand("jj", ["file", "show", "-r", rev, filePath], {
774
+ cwd: repoRoot,
775
+ allowFailure: true,
776
+ });
777
+ return result.code === 0 ? result.stdout : "";
778
+ }
779
+ /** Union of `- ` bullet entries across every side, order-preserving and deduped. */
780
+ export function mergeDecisionsSides(sides) {
781
+ const seen = new Set();
782
+ const entries = [];
783
+ for (const side of sides) {
784
+ for (const entry of side.split(/\n(?=- )/)) {
785
+ const normalized = entry.trim();
786
+ if (!normalized.startsWith("- ") || containsConflictMarkers(normalized)) {
787
+ continue;
788
+ }
789
+ if (!seen.has(normalized)) {
790
+ seen.add(normalized);
791
+ entries.push(normalized);
792
+ }
793
+ }
794
+ }
795
+ if (!entries.length) {
796
+ return longestCleanSide(sides);
797
+ }
798
+ return `${DECISIONS_HEADER}\n\n${entries.join("\n\n")}\n`;
799
+ }
800
+ function longestCleanSide(sides) {
801
+ return sides
802
+ .filter((side) => !containsConflictMarkers(side))
803
+ .sort((a, b) => b.length - a.length)[0];
804
+ }
805
+ /** Deep-union of dependency/script maps; scalar ties keep the earlier side. */
806
+ export function mergePackageJsonSides(sides) {
807
+ const parsed = [];
808
+ for (const side of sides) {
809
+ if (!side.trim()) {
810
+ continue;
811
+ }
812
+ try {
813
+ const value = JSON.parse(side);
814
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
815
+ return undefined;
816
+ }
817
+ parsed.push(value);
818
+ }
819
+ catch {
820
+ // A side that is not valid JSON means this is not a mechanical conflict.
821
+ return undefined;
822
+ }
823
+ }
824
+ if (!parsed.length) {
825
+ return undefined;
826
+ }
827
+ const merged = parsed.reduce((acc, value) => deepMergePreferringFirst(acc, value));
828
+ return `${JSON.stringify(merged, null, 2)}\n`;
829
+ }
830
+ function deepMergePreferringFirst(left, right) {
831
+ const out = { ...left };
832
+ for (const [key, value] of Object.entries(right)) {
833
+ const existing = out[key];
834
+ if (existing === undefined) {
835
+ out[key] = value;
836
+ }
837
+ else if (isPlainObject(existing) && isPlainObject(value)) {
838
+ out[key] = deepMergePreferringFirst(existing, value);
839
+ }
840
+ // Scalar/array collision: keep the earlier side, matching the native rule.
841
+ }
842
+ return out;
843
+ }
844
+ function isPlainObject(value) {
845
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
846
+ }
847
+ /** Union of non-empty lines, order-preserving. */
848
+ function unionLines(sides) {
849
+ const seen = new Set();
850
+ const lines = [];
851
+ for (const side of sides) {
852
+ for (const line of side.split(/\r?\n/)) {
853
+ if (containsConflictMarkers(line)) {
854
+ continue;
855
+ }
856
+ const key = line.trim();
857
+ if (key && seen.has(key)) {
858
+ continue;
859
+ }
860
+ if (key) {
861
+ seen.add(key);
862
+ }
863
+ lines.push(line);
864
+ }
865
+ }
866
+ return `${lines.join("\n").replace(/\n+$/, "")}\n`;
867
+ }
868
+ /**
869
+ * The mechanical resolution for one conflicted path, or undefined to leave it to
870
+ * the LLM resolver. Keyed on file NAME so it works at any depth.
871
+ */
872
+ export function mechanicalMergeFor(fileName, sides) {
873
+ switch (fileName) {
874
+ case "DECISIONS.md":
875
+ return mergeDecisionsSides(sides);
876
+ case "RUDDER.md":
877
+ case "RUDDER_SHARED.md":
878
+ // Regenerated by the orchestrator right after the merge.
879
+ return longestCleanSide(sides);
880
+ case ".gitignore":
881
+ return unionLines(sides);
882
+ case "package.json":
883
+ return mergePackageJsonSides(sides);
884
+ case "package-lock.json":
885
+ case "pnpm-lock.yaml":
886
+ case "yarn.lock":
887
+ // Regenerable: take the most complete side, install rebuilds it.
888
+ return longestCleanSide(sides);
889
+ default:
890
+ return undefined;
891
+ }
892
+ }
893
+ /**
894
+ * Resolve every conflict Rudder can settle without judgment, and return what is
895
+ * left. Never writes content that still carries conflict markers.
896
+ */
897
+ export async function autoResolveMechanicalConflicts(repoRoot) {
898
+ const conflicted = await jjConflictedFiles(repoRoot);
899
+ if (!conflicted.length) {
900
+ return [];
901
+ }
902
+ const parents = await jjParentRevs(repoRoot);
903
+ if (parents.length < 2) {
904
+ return conflicted;
905
+ }
906
+ let resolvedAny = false;
907
+ for (const filePath of conflicted) {
908
+ const fileName = path.basename(filePath);
909
+ const sides = await Promise.all(parents.map((rev) => jjFileShow(repoRoot, rev, filePath)));
910
+ const merged = mechanicalMergeFor(fileName, sides);
911
+ // Never snapshot a "resolution" that still carries markers: it would commit
912
+ // literal <<<<<<< lines into history for every agent to read.
913
+ if (merged === undefined || containsConflictMarkers(merged)) {
914
+ continue;
915
+ }
916
+ await fsp.writeFile(path.join(repoRoot, filePath), merged, "utf8").then(() => {
917
+ resolvedAny = true;
918
+ }, () => undefined);
919
+ }
920
+ if (!resolvedAny) {
921
+ return conflicted;
922
+ }
923
+ // jj is the source of truth for what is still conflicted after the writes.
924
+ return await jjConflictedFiles(repoRoot);
925
+ }
926
+ //# sourceMappingURL=jj.js.map