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