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