@magnusekdahl/parallix 1.2.0 → 1.3.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 (127) hide show
  1. package/index.js +190 -185
  2. package/lib/agents/agents.js +737 -838
  3. package/lib/agents/agents.ts +930 -0
  4. package/lib/agents/claude-telemetry.js +149 -154
  5. package/lib/agents/claude-telemetry.ts +233 -0
  6. package/lib/agents/claude.js +121 -131
  7. package/lib/agents/claude.ts +156 -0
  8. package/lib/agents/codex-telemetry.js +176 -164
  9. package/lib/agents/codex-telemetry.ts +205 -0
  10. package/lib/agents/codex.js +168 -201
  11. package/lib/agents/codex.ts +236 -0
  12. package/lib/agents/limit-hit.js +216 -226
  13. package/lib/agents/limit-hit.ts +262 -0
  14. package/lib/agents/mistral-telemetry.js +7 -10
  15. package/lib/agents/mistral-telemetry.ts +44 -0
  16. package/lib/agents/mistral.js +40 -58
  17. package/lib/agents/mistral.ts +84 -0
  18. package/lib/agents/opencode-export.js +126 -127
  19. package/lib/agents/opencode-export.ts +160 -0
  20. package/lib/agents/opencode-telemetry.js +282 -261
  21. package/lib/agents/opencode-telemetry.ts +350 -0
  22. package/lib/agents/opencode.js +263 -305
  23. package/lib/agents/opencode.ts +370 -0
  24. package/lib/agents/stage-telemetry.js +23 -24
  25. package/lib/agents/stage-telemetry.ts +47 -0
  26. package/lib/commands/active.js +502 -579
  27. package/lib/commands/active.ts +665 -0
  28. package/lib/commands/checkpoint.js +98 -74
  29. package/lib/commands/checkpoint.ts +79 -0
  30. package/lib/commands/config.js +61 -31
  31. package/lib/commands/config.ts +47 -0
  32. package/lib/commands/coverage-gate.js +271 -246
  33. package/lib/commands/coverage-gate.ts +362 -0
  34. package/lib/commands/diff.js +140 -119
  35. package/lib/commands/diff.ts +125 -0
  36. package/lib/commands/draft.js +770 -923
  37. package/lib/commands/draft.ts +1021 -0
  38. package/lib/commands/handoff.js +588 -608
  39. package/lib/commands/handoff.ts +694 -0
  40. package/lib/commands/integrate.js +1332 -1463
  41. package/lib/commands/integrate.ts +1688 -0
  42. package/lib/commands/mission-start.js +279 -237
  43. package/lib/commands/mission-start.ts +263 -0
  44. package/lib/commands/rebase.js +606 -582
  45. package/lib/commands/rebase.ts +635 -0
  46. package/lib/commands/repair-handoff.js +181 -179
  47. package/lib/commands/repair-handoff.ts +228 -0
  48. package/lib/commands/resolve-conflict.js +121 -102
  49. package/lib/commands/resolve-conflict.ts +112 -0
  50. package/lib/commands/review.js +3 -4
  51. package/lib/commands/review.ts +14 -0
  52. package/lib/commands/setup-review.js +4 -5
  53. package/lib/commands/setup-review.ts +14 -0
  54. package/lib/commands/setup.js +3 -3
  55. package/lib/commands/setup.ts +2 -0
  56. package/lib/commands/stats-backfill.js +394 -359
  57. package/lib/commands/stats-backfill.ts +418 -0
  58. package/lib/commands/stats.js +1424 -1665
  59. package/lib/commands/stats.ts +2186 -0
  60. package/lib/commands/status.js +219 -200
  61. package/lib/commands/status.ts +219 -0
  62. package/lib/commands/verify.js +3 -1
  63. package/lib/commands/verify.ts +2 -0
  64. package/lib/core/fmt.js +140 -211
  65. package/lib/core/fmt.ts +189 -0
  66. package/lib/core/git.js +121 -102
  67. package/lib/core/git.ts +146 -0
  68. package/lib/core/gitignore.js +59 -106
  69. package/lib/core/gitignore.ts +103 -0
  70. package/lib/core/mission-utils.js +833 -875
  71. package/lib/core/mission-utils.ts +998 -0
  72. package/lib/core/nels.js +112 -127
  73. package/lib/core/nels.ts +189 -0
  74. package/lib/core/persistent-data-migration.js +247 -205
  75. package/lib/core/persistent-data-migration.ts +255 -0
  76. package/lib/core/product-config.js +400 -476
  77. package/lib/core/product-config.ts +518 -0
  78. package/lib/core/runtime-matrix.js +41 -87
  79. package/lib/core/runtime-matrix.ts +84 -0
  80. package/lib/core/spawn-tee.js +152 -192
  81. package/lib/core/spawn-tee.ts +202 -0
  82. package/lib/core/state-map.js +92 -92
  83. package/lib/core/state-map.ts +115 -0
  84. package/lib/core/storage.js +101 -129
  85. package/lib/core/storage.ts +182 -0
  86. package/lib/core/subagent-limit.js +15 -16
  87. package/lib/core/subagent-limit.ts +25 -0
  88. package/lib/core/verification.js +148 -138
  89. package/lib/core/verification.ts +200 -0
  90. package/lib/index.js +142 -59
  91. package/lib/index.ts +134 -0
  92. package/lib/review/rebase.js +142 -145
  93. package/lib/review/rebase.ts +188 -0
  94. package/lib/review/review-adapter.js +99 -87
  95. package/lib/review/review-adapter.ts +198 -0
  96. package/lib/review/review-artifacts.js +486 -629
  97. package/lib/review/review-artifacts.ts +622 -0
  98. package/lib/review/review-commands.js +1220 -1373
  99. package/lib/review/review-commands.ts +1579 -0
  100. package/lib/review/review-events.js +544 -886
  101. package/lib/review/review-events.ts +954 -0
  102. package/lib/review/review-loop.js +945 -1020
  103. package/lib/review/review-loop.ts +1174 -0
  104. package/lib/review/review-polling.js +86 -115
  105. package/lib/review/review-polling.ts +194 -0
  106. package/lib/review/review-prompts.js +154 -189
  107. package/lib/review/review-prompts.ts +182 -0
  108. package/lib/review/review-state.js +246 -225
  109. package/lib/review/review-state.ts +353 -0
  110. package/lib/review/review.js +63 -84
  111. package/lib/review/review.ts +138 -0
  112. package/lib/tools/backlog.js +694 -680
  113. package/lib/tools/backlog.ts +835 -0
  114. package/lib/tools/forgejo.js +1270 -1405
  115. package/lib/tools/forgejo.ts +1793 -0
  116. package/lib/tools/gatekeeper.js +106 -96
  117. package/lib/tools/gatekeeper.ts +124 -0
  118. package/lib/tools/redgreen.js +200 -181
  119. package/lib/tools/redgreen.ts +221 -0
  120. package/lib/tools/sessions.js +58 -52
  121. package/lib/tools/sessions.ts +81 -0
  122. package/lib/tools/setup-review.js +902 -975
  123. package/lib/tools/setup-review.ts +1152 -0
  124. package/package.json +7 -5
  125. package/prompts/draft.md +1 -0
  126. package/prompts/portfolio.md +1 -1
  127. package/px.js +226 -210
@@ -0,0 +1,635 @@
1
+ import path from 'node:path';
2
+ import { detectRebaseState, git, getCurrentBranch } from '../core/git.js';
3
+ import integrate from './integrate.js';
4
+ import { findMissionDir, findMissionArea, inferSlug, resolveWorktree, conventionalWorktreePath, getPrimaryWorktree, getPrimaryBranch, resolveMissionBaseBranch, missionBranchName, missionDirForSlug } from '../core/mission-utils.js';
5
+ import { startAgent } from '../agents/agents.js';
6
+ import { createPr, readToken, resolveForgejoUser, fetchReviewBranch } from '../tools/forgejo.js';
7
+ import { resolveTaskFile, getTaskImplementer } from '../tools/backlog.js';
8
+ import { resolveReviewIdentity } from '../review/review-state.js';
9
+ import { isForgejoReviewEnabled } from '../core/product-config.js';
10
+ import * as fmt from '../core/fmt.js';
11
+ import { formatVerificationCommand } from '../core/verification.js';
12
+
13
+ /**
14
+ * Rebase a mission branch onto local master, auto-resolving mission-specific
15
+ * conflicts and launching an agent for shared-file conflicts.
16
+ *
17
+ * Usage: px rebase [<slug>] [--push]
18
+ */
19
+ /** @param {string[]} args @param {{inferSlugFn?: Function, findMissionDirFn?: Function, findMissionAreaFn?: Function, getCurrentBranchFn?: Function, resolveConflictsFn?: Function, startAgentFn?: Function, createPrFn?: Function, readTokenFn?: Function, resolveForgejoUserFn?: Function, resolveTaskFileFn?: Function, getTaskImplementerFn?: Function, detectRebaseStateFn?: Function, resolveMissionBaseBranchFn?: Function, gitFn?: Function, exitFn?: Function, isForgejoReviewEnabledFn?: Function, fetchReviewBranchFn?: Function}} opts */
20
+ async function rebase(args: string[], {
21
+ inferSlugFn = inferSlug,
22
+ findMissionDirFn = findMissionDir,
23
+ findMissionAreaFn = findMissionArea,
24
+ getCurrentBranchFn = getCurrentBranch,
25
+ resolveConflictsFn = /** @type {import('./integrate.js').IntegrateFn} */ (integrate).resolveConflictsForMission,
26
+ startAgentFn = startAgent,
27
+ createPrFn = createPr,
28
+ readTokenFn = readToken,
29
+ resolveForgejoUserFn = resolveForgejoUser,
30
+ resolveTaskFileFn = resolveTaskFile,
31
+ getTaskImplementerFn = getTaskImplementer,
32
+ detectRebaseStateFn = detectRebaseState,
33
+ resolveMissionBaseBranchFn = resolveMissionBaseBranch,
34
+ gitFn = git,
35
+ exitFn = ((code: number) => process.exit(code)) as (code: number) => void,
36
+ isForgejoReviewEnabledFn = isForgejoReviewEnabled,
37
+ fetchReviewBranchFn = fetchReviewBranch,
38
+ }: {inferSlugFn?: Function, findMissionDirFn?: Function, findMissionAreaFn?: Function, getCurrentBranchFn?: Function, resolveConflictsFn?: Function, startAgentFn?: Function, createPrFn?: Function, readTokenFn?: Function, resolveForgejoUserFn?: Function, resolveTaskFileFn?: Function, getTaskImplementerFn?: Function, detectRebaseStateFn?: Function, resolveMissionBaseBranchFn?: Function, gitFn?: Function, exitFn?: (code: number) => void, isForgejoReviewEnabledFn?: Function, fetchReviewBranchFn?: Function} = {}) {
39
+ const flags = args.filter(a => a.startsWith('--'));
40
+ const params = args.filter(a => !a.startsWith('--'));
41
+ const isPush = flags.includes('--push');
42
+ const explicitSlug = params[0];
43
+ const slug = inferSlugFn(explicitSlug);
44
+ if (!slug) {
45
+ fmt.log.fail('Usage: px rebase [<slug>] [--push]');
46
+ exitFn(1);
47
+ return;
48
+ }
49
+
50
+ const missionDir = findMissionDirFn(slug);
51
+ const area = missionDir ? findMissionAreaFn(missionDir) : 'docs';
52
+ const branch = missionBranchName(slug);
53
+
54
+ const existingRebase = detectRebaseStateFn(process.cwd());
55
+ if (existingRebase.inProgress) {
56
+ fmt.log.fail(`Rebase already in progress for ${fmt.branch(branch)}.`);
57
+ if (existingRebase.rebaseHead) {
58
+ fmt.log.info(`Current rebase head: ${existingRebase.rebaseHead}`);
59
+ }
60
+ if (existingRebase.unmergedFiles.length > 0) {
61
+ fmt.log.info('Unmerged files:');
62
+ existingRebase.unmergedFiles.forEach((file: string) => fmt.log.info(` - ${file}`));
63
+ }
64
+ fmt.log.info('Recovery commands:');
65
+ fmt.log.info(` ${fmt.command('git rebase --continue')}`);
66
+ fmt.log.info(` ${fmt.command('git rebase --abort')}`);
67
+ fmt.log.info(` ${fmt.command('git rebase --skip')}`);
68
+ exitFn(1);
69
+ return;
70
+ }
71
+
72
+ const performPush = async () => {
73
+ if (!isPush) {return;}
74
+ if (!isForgejoReviewEnabledFn(process.cwd())) {
75
+ fmt.log.info(`Skipping Forgejo push (review provider is not forgejo).`);
76
+ return;
77
+ }
78
+ fmt.log.info(`--push detected. Updating Forgejo PR for ${fmt.branch(branch)}...`);
79
+
80
+ const reviewIdentity = resolveReviewIdentity(slug, resolveWorktree(slug) || process.cwd());
81
+ let forgejoUser = reviewIdentity.forgejoUser;
82
+ if (!forgejoUser) {
83
+ const taskResolution = resolveTaskFileFn(slug, getPrimaryWorktree());
84
+ if (taskResolution.ok) {
85
+ forgejoUser = getTaskImplementerFn(taskResolution.taskFile);
86
+ }
87
+ }
88
+ forgejoUser = resolveForgejoUserFn(forgejoUser);
89
+
90
+ const token = readTokenFn(forgejoUser || 'default');
91
+ if (!token) {
92
+ fmt.log.fail(`No Forgejo token found for user "${fmt.agent(forgejoUser || 'default')}". Push failed.`);
93
+ exitFn(1);
94
+ return;
95
+ }
96
+ const result = createPrFn(branch, forgejoUser || 'default', token, { rootDir: getPrimaryWorktree(), forceWithLease: true });
97
+ if (!result.ok) {
98
+ fmt.log.fail(`Push to Forgejo failed: ${result.error}`);
99
+ exitFn(1);
100
+ return;
101
+ }
102
+ fmt.log.pass(`Branch pushed and PR updated for ${fmt.branch(branch)}.`);
103
+ };
104
+
105
+ // Verify we are on the correct branch
106
+ const currentBranch = getCurrentBranchFn();
107
+ if (currentBranch !== branch) {
108
+ fmt.log.fail(`Expected branch ${fmt.branch(branch)}, found ${fmt.branch(currentBranch)}`);
109
+ fmt.log.info(`Switch to the mission branch first: ${fmt.command(`git checkout ${branch}`)}`);
110
+ exitFn(1);
111
+ return;
112
+ }
113
+
114
+ // Honor the mission's recorded base branch (a feature-branch mission rebases
115
+ // onto its base, e.g. skunkworks — not the primary branch). Falls back to the
116
+ // primary branch for every mission without a recorded Base-Branch.
117
+ const baseBranch = resolveMissionBaseBranchFn(slug, process.cwd(), { gitFn });
118
+ fmt.log.info(`Rebasing ${fmt.branch(branch)} onto local ${fmt.branch(baseBranch)}...`);
119
+ const rebaseResult = gitFn(['-c', 'core.editor=true', '-c', 'merge.autoedit=no', 'rebase', baseBranch]);
120
+
121
+ // Rebase succeeded (status 0) or was already up to date
122
+ // Also handle "Already up to date" variants
123
+ if (rebaseResult.status === 0 || /up to date|Already up to date/i.test(rebaseResult.stdout + rebaseResult.stderr)) {
124
+ const rebaseStatus = gitFn(['rebase', '--show-current']);
125
+ // If no rebase is in progress, we're done
126
+ const rebaseInProg = rebaseStatus.stdout.trim().length > 0;
127
+ if (rebaseInProg) {
128
+ // rebase --show-current returned something but status was 0 — treat as incomplete
129
+ fmt.log.pass('Rebase round completed.');
130
+ fmt.log.warn('Rebase is still in progress (non-empty --show-current). Skipping automatic push.');
131
+ fmt.log.info(`Next: ${fmt.command(formatVerificationCommand(area, process.cwd()))}`);
132
+ fmt.log.info(`Next: ${fmt.command('git rebase --continue')}`);
133
+ exitFn(0);
134
+ return;
135
+ }
136
+
137
+ fmt.log.pass('Rebase completed cleanly.');
138
+ await performPush();
139
+ fmt.log.info(`Next: ${fmt.command(formatVerificationCommand(area, process.cwd()))}`);
140
+ fmt.log.info(`Next: ${fmt.command(`px integrate ${slug} --dry-run`)}`);
141
+ exitFn(0);
142
+ return;
143
+ }
144
+
145
+ // Rebase paused on conflicts — classify and resolve
146
+ const rebaseOutput = [rebaseResult.stdout, rebaseResult.stderr].filter(Boolean).join('\n').trim();
147
+
148
+ // Check if this is actually a conflict (contains CONFLICT lines) or another error
149
+ // Also handle localized output (e.g. Swedish "KONFLIKT")
150
+ const isConflict = /CONFLICT|KONFLIKT/i.test(rebaseOutput);
151
+ if (!isConflict) {
152
+ fmt.log.fail('Rebase failed with a non-conflict error.');
153
+ if (rebaseOutput) {
154
+ fmt.log.fail('--- Git Output ---');
155
+ fmt.log.fail(rebaseOutput);
156
+ fmt.log.fail('------------------');
157
+ }
158
+ if (rebaseResult.status === 128) {
159
+ fmt.log.fail('Hint: This might be a repository lock or an invalid upstream branch.');
160
+ } else if (rebaseOutput.toLowerCase().includes('pre-commit') || rebaseOutput.toLowerCase().includes('hook')) {
161
+ fmt.log.fail('Hint: A git hook failed. Fix the issues reported above and try again.');
162
+ }
163
+ fmt.log.fail(`Recovery: ${fmt.command('git rebase --abort')}`);
164
+ exitFn(1);
165
+ return;
166
+ }
167
+
168
+ fmt.log.warn('Rebase paused on conflicts. Classifying...');
169
+
170
+ // Resolve the worktree for conflict classification
171
+ const rootDir = getPrimaryWorktree();
172
+ const worktreePath = resolveWorktree(slug) || conventionalWorktreePath(slug, rootDir);
173
+
174
+ /** @type{{worktreePathOverride?: string}} */
175
+ const conflictOpts: {worktreePathOverride?: string} = { worktreePathOverride: worktreePath };
176
+ const conflictResult = resolveConflictsFn(slug, area, conflictOpts);
177
+
178
+ // When rebase is in progress in the worktree, dry merge may fail.
179
+ // Fall back to parsing the rebase output directly.
180
+ if (conflictResult.ok === false && conflictResult.error === 'merge-failed') {
181
+ fmt.log.info('Dry merge failed (rebase in progress). Parsing rebase output directly...');
182
+ // Prefer git status --porcelain (authoritative index entries) over localized
183
+ // rebase text, which can contain advice prefixes that parse as false paths.
184
+ const statusFiles = parseConflictFilesFromGitStatus(worktreePath, gitFn);
185
+ const conflictFiles = statusFiles.length > 0
186
+ ? statusFiles
187
+ : parseConflictFilesFromRebaseOutput(rebaseOutput);
188
+
189
+ // Classify using mission-specific patterns
190
+ const missionAbsDir = findMissionDir(slug, worktreePath);
191
+ const missionDocPrefix = missionAbsDir
192
+ ? (path.relative(worktreePath, missionAbsDir) + '/')
193
+ : path.relative(worktreePath, missionDirForSlug(worktreePath, slug)).split(path.sep).join('/') + '/';
194
+ const taskPattern = new RegExp(`backlog/(?:tasks|completed)/[^/]*${slug}`);
195
+ const missionSpecificFiles = conflictFiles.filter((f: string) =>
196
+ f.startsWith(missionDocPrefix) || taskPattern.test(f)
197
+ );
198
+ const sharedFiles = conflictFiles.filter((f: string) => !missionSpecificFiles.includes(f));
199
+
200
+ Object.assign(conflictResult, {
201
+ ok: true,
202
+ conflictFiles,
203
+ missionSpecificFiles,
204
+ sharedFiles,
205
+ });
206
+ } else if (!conflictResult.ok) {
207
+ /** @type{{error?: string}} */
208
+ const cr: {error?: string} = conflictResult;
209
+ if (cr.error === 'worktree-missing') {
210
+ fmt.log.fail(`Mission worktree not found: ${fmt.path(worktreePath)}`);
211
+ fmt.log.info(`Ensure the worktree is registered: ${fmt.command(`git worktree add ${conventionalWorktreePath(slug, getPrimaryWorktree())} ${branch}`)}`);
212
+ exitFn(1);
213
+ return;
214
+ }
215
+ fmt.log.fail('Conflict detection failed.');
216
+ fmt.log.info(`Check rebase state: ${fmt.command('git status')}`);
217
+ exitFn(1);
218
+ return;
219
+ }
220
+
221
+ // No conflicts detected by classification but rebase reported CONFLICT —
222
+ // fall through to shared-file handling with whatever git reports
223
+ const conflictFiles = conflictResult.conflictFiles.length > 0
224
+ ? conflictResult.conflictFiles
225
+ : parseConflictFilesFromRebaseOutput(rebaseOutput);
226
+
227
+ // Only treat unclassified conflict files as shared-file conflicts
228
+ if (conflictResult.sharedFiles.length === 0 && conflictFiles.length > 0) {
229
+ const classified = new Set([...conflictResult.missionSpecificFiles]);
230
+ const unclassified = conflictFiles.filter((f: string) => !classified.has(f));
231
+ if (unclassified.length > 0) {
232
+ conflictResult.sharedFiles = unclassified;
233
+ }
234
+ }
235
+
236
+ // All conflicts are mission-specific — auto-resolve with --theirs
237
+ if (conflictResult.sharedFiles.length === 0) {
238
+ fmt.log.info(`All ${conflictResult.missionSpecificFiles.length} conflict(s) are mission-specific. Auto-resolving...`);
239
+ const maxContinueAttempts = 3;
240
+ let continueAttempts = 0;
241
+ /** @param {{stdio?: string}} opts */
242
+ const continueRebase = (opts: {stdio?: string} = {}) => {
243
+ continueAttempts += 1;
244
+ return gitFn(['-c', 'core.editor=true', '-c', 'merge.autoedit=no', 'rebase', '--continue'], opts);
245
+ };
246
+ /** @param {{stdout: string, stderr: string, status: number}} result */
247
+ const reportContinueFailure = (result: {stdout: string, stderr: string, status: number}) => {
248
+ const output = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
249
+ fmt.log.fail(`git rebase --continue failed (status ${result.status}).`);
250
+ if (output) {
251
+ fmt.log.fail('--- Git Output ---');
252
+ fmt.log.fail(output);
253
+ fmt.log.fail('------------------');
254
+ }
255
+ if (output.toLowerCase().includes('pre-commit') || output.toLowerCase().includes('hook')) {
256
+ fmt.log.fail('Hint: A git hook failed. Fix the issues reported above.');
257
+ }
258
+ fmt.log.fail(`Check rebase state: ${fmt.command('git status')}`);
259
+ fmt.log.fail(`Recovery: ${fmt.command('git rebase --abort')}`);
260
+ };
261
+
262
+ /** @param {string} branchName */
263
+ const failContinueBudget = (branchName: string) => {
264
+ fmt.log.fail(`Rebase still in progress on branch ${fmt.branch(branchName || slug)} after ${continueAttempts} failed --continue attempt(s).`);
265
+ fmt.log.fail(`Check rebase state: ${fmt.command('git status')}`);
266
+ fmt.log.fail(`Next: ${fmt.command('git rebase --continue')}`);
267
+ fmt.log.fail(`If the current commit is empty: ${fmt.command('git rebase --skip')}`);
268
+ fmt.log.fail(`If recovery is needed: ${fmt.command('git rebase --abort')}`);
269
+ exitFn(1);
270
+ };
271
+
272
+ for (const file of conflictResult.missionSpecificFiles) {
273
+ fmt.log.info(`Resolving: ${fmt.path(file)}`);
274
+ const checkoutResult = gitFn(['checkout', '--theirs', file]);
275
+ if (checkoutResult.status !== 0) {
276
+ // Try add -u as fallback (file may have been added/deleted)
277
+ gitFn(['add', file]);
278
+ }
279
+ const addResult = gitFn(['add', file]);
280
+ if (addResult.status !== 0) {
281
+ fmt.log.warn(`Could not add ${fmt.path(file)} for rebase continue.`);
282
+ }
283
+ }
284
+
285
+ fmt.log.info('Continuing rebase...');
286
+ let rebaseCompleted = false;
287
+ const continueResult = continueRebase();
288
+ if (continueResult.status !== 0) {
289
+ // Might have pre-commit hook or editor — check for more conflicts
290
+ const moreOutput = [continueResult.stdout, continueResult.stderr].filter(Boolean).join('\n').trim();
291
+ if (/CONFLICT|KONFLIKT/i.test(moreOutput)) {
292
+ fmt.log.info('More conflicts found. Re-running classification...');
293
+ // Recurse once more for chained conflicts, preserving flags and options
294
+ await rebase(args, {
295
+ inferSlugFn,
296
+ findMissionDirFn,
297
+ findMissionAreaFn,
298
+ getCurrentBranchFn,
299
+ resolveConflictsFn,
300
+ startAgentFn,
301
+ createPrFn,
302
+ readTokenFn,
303
+ resolveForgejoUserFn,
304
+ resolveTaskFileFn,
305
+ getTaskImplementerFn,
306
+ gitFn,
307
+ exitFn,
308
+ isForgejoReviewEnabledFn,
309
+ });
310
+ return;
311
+ }
312
+ // Could be editor opening — check if rebase is still in progress
313
+ const statusResult = gitFn(['status', '--porcelain']);
314
+ if (statusResult.stdout.trim()) {
315
+ fmt.log.info('More changes detected. Continuing rebase...');
316
+ gitFn(['add', '-A']);
317
+ if (continueAttempts >= maxContinueAttempts) {
318
+ const rebaseCheck = gitFn(['rebase', '--show-current']);
319
+ failContinueBudget(rebaseCheck.stdout.trim());
320
+ return;
321
+ }
322
+ const cont2 = continueRebase();
323
+ if (cont2.status !== 0) {
324
+ // Verify whether a rebase is still in progress
325
+ const rebaseCheck = gitFn(['rebase', '--show-current']);
326
+ if (rebaseCheck.stdout.trim().length > 0) {
327
+ if (continueAttempts >= maxContinueAttempts) {
328
+ failContinueBudget(rebaseCheck.stdout.trim());
329
+ } else {
330
+ reportContinueFailure(cont2);
331
+ exitFn(1);
332
+ }
333
+ return;
334
+ }
335
+ reportContinueFailure(cont2);
336
+ exitFn(1);
337
+ return;
338
+ }
339
+ rebaseCompleted = true;
340
+ } else {
341
+ // No staged changes and --continue failed but no conflict — rebase may have completed
342
+ const rebaseCheck = gitFn(['rebase', '--show-current']);
343
+ if (rebaseCheck.stdout.trim().length === 0) {
344
+ rebaseCompleted = true;
345
+ } else {
346
+ // Rebase still in progress with no unresolved conflicts: likely a hook
347
+ // failure or empty pick. Retry --continue with GIT_EDITOR=true to avoid
348
+ // spawning an interactive editor.
349
+ fmt.log.info('No unresolved conflicts; rebase still in progress. Retrying --continue (hook/empty-pick)...');
350
+ if (continueAttempts >= maxContinueAttempts) {
351
+ failContinueBudget(slug);
352
+ return;
353
+ }
354
+ const retryResult = continueRebase();
355
+ if (retryResult.status !== 0) {
356
+ const retryOutput = [retryResult.stdout, retryResult.stderr].filter(Boolean).join('\n').trim();
357
+ if (/CONFLICT|KONFLIKT/i.test(retryOutput)) {
358
+ fmt.log.info('More conflicts found after retry. Re-running classification...');
359
+ await rebase(args, {
360
+ inferSlugFn,
361
+ findMissionDirFn,
362
+ findMissionAreaFn,
363
+ getCurrentBranchFn,
364
+ resolveConflictsFn,
365
+ startAgentFn,
366
+ createPrFn,
367
+ readTokenFn,
368
+ resolveForgejoUserFn,
369
+ fetchReviewBranchFn,
370
+ resolveTaskFileFn,
371
+ getTaskImplementerFn,
372
+ gitFn,
373
+ exitFn,
374
+ isForgejoReviewEnabledFn,
375
+ });
376
+ return;
377
+ }
378
+ // Another empty-pick or hook — verify again
379
+ const recheck = gitFn(['rebase', '--show-current']);
380
+ if (recheck.stdout.trim().length > 0) {
381
+ fmt.log.info('Empty pick detected; continuing to next commit...');
382
+ if (continueAttempts >= maxContinueAttempts) {
383
+ failContinueBudget(recheck.stdout.trim());
384
+ return;
385
+ }
386
+ const cont3 = continueRebase();
387
+ if (cont3.status === 0) {
388
+ rebaseCompleted = true;
389
+ } else {
390
+ const recheck2 = gitFn(['rebase', '--show-current']);
391
+ if (recheck2.stdout.trim().length === 0) {
392
+ rebaseCompleted = true;
393
+ } else {
394
+ if (continueAttempts >= maxContinueAttempts) {
395
+ failContinueBudget(recheck2.stdout.trim());
396
+ } else {
397
+ reportContinueFailure(cont3);
398
+ exitFn(1);
399
+ }
400
+ return;
401
+ }
402
+ }
403
+ } else {
404
+ rebaseCompleted = true;
405
+ }
406
+ } else {
407
+ rebaseCompleted = true;
408
+ }
409
+ }
410
+ }
411
+ } else {
412
+ rebaseCompleted = true;
413
+ }
414
+
415
+ if (rebaseCompleted) {
416
+ fmt.log.pass('Mission-specific conflicts resolved. Rebase completed.');
417
+ await performPush();
418
+ fmt.log.info(`Next: ${fmt.command(formatVerificationCommand(area, process.cwd()))}`);
419
+ fmt.log.info(`Next: ${fmt.command(`px integrate ${slug} --dry-run`)}`);
420
+ exitFn(0);
421
+ return;
422
+ }
423
+ }
424
+
425
+ // Shared-file conflicts exist — launch agent to resolve them
426
+ fmt.log.info(`${conflictResult.sharedFiles.length} shared file(s) require agent-assisted resolution:`);
427
+ conflictResult.sharedFiles.forEach((f: string) => fmt.log.info(` - ${fmt.path(f)}`));
428
+
429
+ const prompt = buildRebasePrompt({
430
+ slug,
431
+ area,
432
+ worktreePath,
433
+ missionSpecificFiles: conflictResult.missionSpecificFiles,
434
+ sharedFiles: conflictResult.sharedFiles,
435
+ gitFn: gitFn as Function,
436
+ });
437
+
438
+ fmt.log.info('Launching agent for conflict resolution...');
439
+ const { agent, result: agentResult } = await startAgentFn('conflict-resolution', {
440
+ prompt,
441
+ worktree: worktreePath,
442
+ });
443
+
444
+ if (agentResult.status !== 0) {
445
+ fmt.log.fail(`Agent (${fmt.agent(agent)}) exited with status ${agentResult.status}.`);
446
+ fmt.log.info(`You may need to abort the rebase: ${fmt.command('git rebase --abort')}`);
447
+ exitFn(agentResult.status || 1);
448
+ return;
449
+ }
450
+
451
+ // Verify rebase is actually complete before pushing
452
+ const finalRebaseCheck = gitFn(['rebase', '--show-current']);
453
+ if (finalRebaseCheck.stdout.trim().length > 0) {
454
+ fmt.log.pass(`Agent (${fmt.agent(agent)}) completed their round.`);
455
+ fmt.log.warn('Rebase is still in progress. Skipping automatic push.');
456
+ fmt.log.info('Next: Resolve remaining conflicts or continue rebase.');
457
+ exitFn(0);
458
+ return;
459
+ }
460
+
461
+ fmt.log.pass(`Agent (${fmt.agent(agent)}) completed conflict resolution.`);
462
+ await performPush();
463
+ fmt.log.info(`Next: ${fmt.command(formatVerificationCommand(area, process.cwd()))}`);
464
+ fmt.log.info(`Next: ${fmt.command(`px integrate ${slug} --dry-run`)}`);
465
+ exitFn(0);
466
+ }
467
+
468
+ /**
469
+ * Parse conflict file paths from git status --porcelain output
470
+ * when a rebase is in progress. Handles all unmerged states:
471
+ * UU (unmerged), DU/UD (modify/delete), AU/UA (add/add), AA (add/add).
472
+ */
473
+ /** @param {string} worktreePath @param {Function} gitFn */
474
+ function parseConflictFilesFromGitStatus(worktreePath: string, gitFn: Function) {
475
+ const statusResult = gitFn(['-C', worktreePath, 'status', '--porcelain']);
476
+ const files: string[] = [];
477
+ const unmergedStates = new Set(['UU', 'DU', 'UD', 'AU', 'UA', 'AA']);
478
+ for (const line of (statusResult.stdout || '').split('\n')) {
479
+ const trimmed = line.trim();
480
+ if (!trimmed) {continue;}
481
+ const match = trimmed.match(/^([A-Z]{2})\s+(.+)$/);
482
+ if (match) {
483
+ const code = match[1];
484
+ if (!unmergedStates.has(code)) {continue;}
485
+ let file = match[2].trim();
486
+ // Remove surrounding quotes if present
487
+ if ((file.startsWith('"') && file.endsWith('"')) || (file.startsWith("'") && file.endsWith("'"))) {
488
+ file = file.slice(1, -1);
489
+ }
490
+ if (file) {files.push(file);}
491
+ }
492
+ }
493
+ return [...new Set(files)];
494
+ }
495
+
496
+ /**
497
+ * Parse conflict file paths from git rebase output.
498
+ * Similar to parseConflictFilesFromMergeOutput but handles rebase-specific output.
499
+ */
500
+ /** @param {string} output */
501
+ function parseConflictFilesFromRebaseOutput(output: string) {
502
+ const seen = new Set<string>();
503
+ const files: string[] = [];
504
+ for ( const line of output.split('\n')) {
505
+ if (!/CONFLICT|KONFLIKT/i.test(line)) {continue;}
506
+ // English: "Merge conflict in <file>"
507
+ const inMatch = line.match(/Merge conflict in (.+)$/);
508
+ if (inMatch) {
509
+ let f = inMatch[1].trim();
510
+ // Strip modify/delete description (e.g. ": deleted by master, modified by HEAD")
511
+ f = f.replace(/\s*:\s*(deleted|modified|added|removed|renamed|both|ours|yours|theirs|by\s*\w+,\s*(modified|deleted)\b).*$/i, '').trim();
512
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
513
+ continue;
514
+ }
515
+ // Swedish: "Sammanslagningskonflikt i <file>"
516
+ const svMatch = line.match(/Sammanslagningskonflikt\s+i\s+(.+)$/);
517
+ if (svMatch) {
518
+ let f = svMatch[1].trim();
519
+ // Strip modify/delete description (e.g. ": deleted/raderad av master, modified/ändrad av HEAD")
520
+ f = f.replace(/\s*:\s*(deleted|modified|added|removed|renamed|both|ours|yours|theirs|raderad|ändrad|lagd till|borttagen|by|av|av\s*\w+,\s*(modified|ändrad|deleted|raderad)\b).*$/i, '').trim();
521
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
522
+ continue;
523
+ }
524
+ // Swedish modify/delete: "KONFLIKT (ändra/radera): <file> raderad i <commit> ... och ändrad i HEAD"
525
+ const svModDelMatch = line.match(/^KONFLIKT\s+\(ändra\/radera\)\s*:\s*(.+)$/i);
526
+ if (svModDelMatch) {
527
+ let f = svModDelMatch[1].trim();
528
+ // Strip trailing "Versionen HEAD av <file> lämnad i trädet." sentence (real git output)
529
+ // Must come before raderad/ändrad stripping since the trailing sentence contains "ändrad"
530
+ f = f.replace(/\s+Versionen\s+HEAD[\s\S]*$/i, '').trim();
531
+ // Strip "raderad i <commit> ... och ändrad i HEAD" (Swedish modify/delete description)
532
+ f = f.replace(/\s+raderad\s+i\s+\S+(?:\s*\([^)]*\))?\s+(?:och|and)\s+ändrad\s+i\s+\S+\.?\s*$/i, '').trim();
533
+ // Also handle "raderad i <commit> och ändrad i HEAD" without trailing period
534
+ f = f.replace(/\s+raderad\s+i\s+\S+(?:\s*\([^)]*\))?\s+och\s+ändrad\s+i\s+\S+\s*$/i, '').trim();
535
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
536
+ continue;
537
+ }
538
+
539
+ // Generic: try to extract a file path from the line using colon-delimited segments.
540
+ // Common patterns:
541
+ // "CONFLICT (content): Merge conflict in <file>" -> handled above
542
+ // "<file>: <description>" -> generic fallback
543
+ // "CONFLICT (content): <file>: <description>" -> nested colons
544
+ // "CONFLICT (modify/delete): <file>: deleted by ..., modified by ..." -> modify/delete
545
+ // "CONFLICT (modify/delete): <file>: ..." -> modify/delete header
546
+ const colonIdx = line.indexOf(':');
547
+ if (colonIdx !== -1) {
548
+ const beforeColon = line.slice(0, colonIdx).trim();
549
+ const afterColon = line.slice(colonIdx + 1).trim();
550
+ const isNonPathPrefix = /^(CONFLICT|KONFLIKT|CONFLICTS|Merge conflict|Sammanslagningskonflikt|Automatic merge|Auto-merging|resolved|merged|deleted|added|changed|modified|rejected|skipped|dropped|superseded|discarded|kept|stashed|applied|already|would|both|ours|yours|their|his|her|its|your|my|us|we|they|he|she|it|a|an|the|but|and|or|for|nor|not|so|yet)\b/i.test(beforeColon);
551
+ if (!isNonPathPrefix) {
552
+ // Skip known advice/hint labels — the rest of the line is not a path.
553
+ if (/^(tips|hint|note)\b/i.test(beforeColon)) {continue;}
554
+ const f = beforeColon;
555
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
556
+ } else if (afterColon) {
557
+ // Skip the prefix and look for a path after the first colon.
558
+ // If there's a second colon, take the segment before it as the path.
559
+ const secondColonIdx = afterColon.indexOf(':');
560
+ if (secondColonIdx !== -1) {
561
+ let f = afterColon.slice(0, secondColonIdx).trim();
562
+ // Strip modify/delete description (e.g. ": deleted by master, modified by HEAD")
563
+ f = f.replace(/\s*:\s*(deleted|modified|added|removed|renamed|both|ours|yours|theirs|raderad|ändrad|lagd till|borttagen|by|av|av\s*\w+,\s*(modified|ändrad|deleted|raderad)\b).*$/i, '');
564
+ f = f.trim();
565
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
566
+ } else {
567
+ // No second colon — take everything after the first colon as the path.
568
+ const f = afterColon;
569
+ if (f && !seen.has(f)) { seen.add(f); files.push(f); }
570
+ }
571
+ }
572
+ }
573
+ }
574
+ return files;
575
+ }
576
+
577
+ /**
578
+ * Build the prompt for the agent during shared-file conflict resolution.
579
+ */
580
+ /** @param {{slug: string, area: string, worktreePath: string, missionSpecificFiles: string[], sharedFiles: string[], gitFn?: Function}} opts */
581
+ function buildRebasePrompt({ slug, area, worktreePath, missionSpecificFiles, sharedFiles, gitFn = undefined as Function | undefined }: {slug: string, area: string, worktreePath: string, missionSpecificFiles: string[], sharedFiles: string[], gitFn?: Function}) {
582
+ const missionFileCommands = missionSpecificFiles
583
+ .map(f => ` git checkout --theirs "${f}" && git add "${f}"`)
584
+ .join('\n');
585
+ const sharedFileList = sharedFiles.map(f => ` - ${f}`).join('\n');
586
+
587
+ let primaryBranch = 'main';
588
+ try {
589
+ primaryBranch = resolveMissionBaseBranch(slug, worktreePath, { gitFn });
590
+ } catch (_) {
591
+ try {
592
+ primaryBranch = getPrimaryBranch(worktreePath, gitFn);
593
+ } catch (_e) {
594
+ primaryBranch = 'main';
595
+ }
596
+ }
597
+
598
+ return [
599
+ 'Mode: rebase conflict-resolution.',
600
+ '',
601
+ `Mission: ${slug}`,
602
+ `Mission worktree: ${worktreePath}`,
603
+ '',
604
+ `Rebase onto ${primaryBranch} paused on conflicts.`,
605
+ '',
606
+ 'Step 1 — Resolve mission-specific conflicts (--theirs):',
607
+ missionFileCommands.length > 0
608
+ ? missionFileCommands
609
+ : ' (none — all conflicts are shared files)',
610
+ '',
611
+ 'Step 2 — Resolve shared-file conflicts:',
612
+ sharedFileList,
613
+ '',
614
+ 'Step 3 — After resolving each shared file:',
615
+ ' git add <resolved-files>',
616
+ ' git rebase --continue',
617
+ '',
618
+ 'Step 4 — Repeat Steps 1-3 until rebase completes.',
619
+ '',
620
+ 'Step 5 — Verify:',
621
+ ` ${formatVerificationCommand(area, worktreePath)}`,
622
+ ` px integrate ${slug} --dry-run`,
623
+ '',
624
+ 'Rules:',
625
+ '- Take --theirs for every mission-specific file listed above.',
626
+ '- For shared files, inspect the conflict markers and resolve sensibly.',
627
+ '- If rebase pauses again, repeat the process.',
628
+ '- If any command fails, stop and report the failure.',
629
+ ].join('\n');
630
+ }
631
+
632
+ (rebase as any).buildRebasePrompt = buildRebasePrompt;
633
+ (rebase as any).parseConflictFilesFromRebaseOutput = parseConflictFilesFromRebaseOutput;
634
+ (rebase as any).parseConflictFilesFromGitStatus = parseConflictFilesFromGitStatus;
635
+ export = rebase;