@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.
- package/CHANGELOG.md +140 -0
- package/LICENSE +661 -0
- package/README.md +196 -0
- package/config/agents.json +25 -0
- package/config/agents.local.json.template +8 -0
- package/config/state-map.json +4 -0
- package/config/state-map.json.template +31 -0
- package/config/workflow.config.schema.json +98 -0
- package/data/.gitkeep +0 -0
- package/docs/adr/0031-ai-agent-instruction-boundary-and-command-floor.md +114 -0
- package/docs/adr/0032-mission-refinement-state-and-usage-budget-signals.md +135 -0
- package/docs/adr/0034-module-and-skill-invocation-model.md +202 -0
- package/docs/adr/0036-mission-sizing-and-dependency-wave-heuristics.md +79 -0
- package/docs/adr/0037-ai-workflow-coordination-architecture.md +162 -0
- package/docs/adr/0041-integration-pipeline-gates.md +165 -0
- package/docs/adr/0042-workflow-cli-color-rendering-approach.md +106 -0
- package/docs/adr/0043-git-target-resolution-strategy.md +185 -0
- package/docs/adr/0044-workflow-distribution-model.md +277 -0
- package/docs/adr/0045-parallax-branch-model.md +182 -0
- package/docs/adr/0046-npm-publish-process-and-security.md +138 -0
- package/docs/adr/index.md +20 -0
- package/docs/agents.md +212 -0
- package/docs/authority-reference.md +298 -0
- package/docs/forgejo-setup.md +31 -0
- package/docs/migration/extraction.md +61 -0
- package/docs/migration/task-classification.md +36 -0
- package/docs/operator-setup.md +76 -0
- package/docs/readme-rewrite-benchmark.md +188 -0
- package/docs/use-cases.md +105 -0
- package/examples/README.md +62 -0
- package/examples/run-enterprise-tarball-workflow-smoke.sh +257 -0
- package/examples/run-verify-env-smoke.sh +40 -0
- package/index.js +250 -0
- package/lib/README.md +13 -0
- package/lib/agents/agents.js +867 -0
- package/lib/agents/claude-telemetry.js +233 -0
- package/lib/agents/claude.js +139 -0
- package/lib/agents/codex-telemetry.js +202 -0
- package/lib/agents/codex.js +219 -0
- package/lib/agents/limit-hit.js +252 -0
- package/lib/agents/mistral-telemetry.js +44 -0
- package/lib/agents/mistral.js +68 -0
- package/lib/agents/opencode-export.js +110 -0
- package/lib/agents/opencode-telemetry.js +356 -0
- package/lib/agents/opencode.js +218 -0
- package/lib/agents/stage-telemetry.js +37 -0
- package/lib/commands/active.js +625 -0
- package/lib/commands/checkpoint.js +76 -0
- package/lib/commands/config.js +39 -0
- package/lib/commands/coverage-gate.js +358 -0
- package/lib/commands/diff.js +119 -0
- package/lib/commands/draft.js +854 -0
- package/lib/commands/handoff.js +501 -0
- package/lib/commands/integrate.js +1528 -0
- package/lib/commands/mission-start.js +246 -0
- package/lib/commands/rebase.js +597 -0
- package/lib/commands/repair-handoff.js +227 -0
- package/lib/commands/resolve-conflict.js +109 -0
- package/lib/commands/review.js +13 -0
- package/lib/commands/setup-review.js +13 -0
- package/lib/commands/setup.js +3 -0
- package/lib/commands/stats-backfill.js +395 -0
- package/lib/commands/stats.js +1601 -0
- package/lib/commands/status.js +183 -0
- package/lib/commands/verify.js +1 -0
- package/lib/core/fmt.js +202 -0
- package/lib/core/git.js +73 -0
- package/lib/core/gitignore.js +110 -0
- package/lib/core/mission-utils.js +1017 -0
- package/lib/core/persistent-data-migration.js +201 -0
- package/lib/core/product-config.js +508 -0
- package/lib/core/runtime-matrix.js +82 -0
- package/lib/core/spawn-tee.js +173 -0
- package/lib/core/state-map.js +89 -0
- package/lib/core/storage.js +165 -0
- package/lib/core/verification.js +149 -0
- package/lib/index.js +77 -0
- package/lib/review/rebase.js +163 -0
- package/lib/review/review-adapter.js +135 -0
- package/lib/review/review-artifacts.js +619 -0
- package/lib/review/review-commands.js +1375 -0
- package/lib/review/review-events.js +1007 -0
- package/lib/review/review-loop.js +1004 -0
- package/lib/review/review-polling.js +141 -0
- package/lib/review/review-prompts.js +212 -0
- package/lib/review/review-state.js +280 -0
- package/lib/review/review.js +96 -0
- package/lib/tools/backlog.js +680 -0
- package/lib/tools/forgejo.js +1585 -0
- package/lib/tools/gatekeeper.js +106 -0
- package/lib/tools/sessions.js +74 -0
- package/lib/tools/setup-review.js +1053 -0
- package/package.json +56 -0
- package/prompts/act-on-review-verbose.md +20 -0
- package/prompts/act-on-review.md +22 -0
- package/prompts/draft.md +20 -0
- package/prompts/execute.md +24 -0
- package/prompts/portfolio.md +30 -0
- package/prompts/review-verbose.md +20 -0
- package/prompts/review.md +17 -0
- package/px.js +236 -0
- package/templates/AGENTS-snippet.md +14 -0
- package/templates/AGENTS.md.template +34 -0
- package/templates/CLAUDE.md.template +27 -0
- package/templates/CODEX.md.template +38 -0
- package/templates/MISTRAL.md.template +24 -0
- package/templates/claude-commands/act-on-review.md +3 -0
- package/templates/claude-commands/area-review.md +3 -0
- package/templates/claude-commands/draft.md +6 -0
- package/templates/claude-commands/execute.md +6 -0
- package/templates/claude-commands/integrate.md +4 -0
- package/templates/claude-commands/portfolio.md +5 -0
- package/templates/claude-commands/review.md +4 -0
- package/templates/codex/config.toml +6 -0
- package/templates/mission-scaffold.md +39 -0
- package/templates/vibe/skills/act-on-review/SKILL.md +16 -0
- package/templates/vibe/skills/area-review/SKILL.md +16 -0
- package/templates/vibe/skills/draft/SKILL.md +16 -0
- package/templates/vibe/skills/execute/SKILL.md +16 -0
- package/templates/vibe/skills/integrate/SKILL.md +16 -0
- package/templates/vibe/skills/portfolio/SKILL.md +21 -0
- package/templates/vibe/skills/review/SKILL.md +16 -0
- 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;
|